Python:爬虫技术

概述

爬虫,应该称为网络爬虫,也叫网页蜘蛛、网络机器人、网络蚂蚁等。

搜索引擎,就是网络爬虫的应用者。

为什么到了今天,反而这个词汇被频繁的提起呢?有搜索引擎不就够了吗?

实际上,大数据时代的到了,所有的企业都希望通过海量数据发现其中的价值。

所以,需要爬取对特定网站、特定类别的数据,而搜索引擎不能提供这样的功能,因此,需要自己开发爬虫来解决。

1991年,CERN诞生第一个WWW网站。慢慢地更多的网站涌现出来来。

网站多了,不方便找。最早,人工收集各种网站分门别类组织成网页。但是不能知道这么多网站的网页中是否有关心的数据。由此,出现了爬虫程序。

爬虫是一个程序,使用HTTP协议。网站只需要提供一个网页,往往是首页,从首页开始获取有用的数据,通过首页中的链接继续探索其他页面,将这些页面内容传输给爬虫程序。

有了爬虫,就出现了雅虎这样的搜索引擎站点。后来,拉里佩奇、谢盖而布林,凭借搜索引擎算法于1998年创建了谷歌公司。

保存?

  • Hadoop 大数据 HDFS, Mapreduce, HBASE

内容分词,一篇文章所有可能关键字->这篇文章的映射 存入倒排索引库

  • solr, ElasticSearch

pagerank 算法,排名问题

爬虫分类

1.通用爬虫

常见就是搜索引擎,无差别的搜集数据、存储、提取关键字、构建索引库,给用户提供搜索接口。

爬取一般流程

  1. 初始化一批URL,将这些URL放到带爬队列
  2. 从队列取出这些URL,通过DNS解析IP,对IP对应的站点下载HTML页面,保存到本地服务器中,爬取完的URL放到已爬取队列。
  3. 分析这些网页内容,找出网页里面的其他关心的URL链接,继续执行第2步,直到爬取条件结束。

搜索引擎如何获取一个网站的URL

  1. 新网站主动提交给搜索引擎
  2. 通过其他网站页面中设置的外链接
  3. 搜索引擎和DNS服务商合作,获取最新收录的网站

2. 聚焦爬虫

有针对性的编写特定领域数据的爬取程序,针对某些类别数据采集的爬虫,是面向主题的爬虫

Robots协议

指定一个robots.txt文件,告诉爬虫引擎什么可以爬取

  • / 表示网站根目录,表示网站所有目录。
  • Allow 允许爬取的目录
  • Disallow 禁止爬取的目录
  • 可以使用通配符

robots是一个君子协定,"爬亦有道" 这个协议为了让搜索引擎更有效率搜索自己内容,提供了Sitemap这样的文件。Sitemap往往是一个XML文件,提供了网站想让大家爬取的内容的更新信息。
这个文件禁止爬取的往往又是可能我们感兴趣的内容,反而泄露了这些地址。

示例:淘宝的robotshttp://www.taobao.com/robots.txt

User-agent:  Baiduspider
Allow:  /article
Allow:  /oshtml
Allow:  /ershou
Allow: /$
Disallow:  /product/
Disallow:  /

User-Agent:  Googlebot
Allow:  /article
Allow:  /oshtml
Allow:  /product
Allow:  /spu
Allow:  /dianpu
Allow:  /oversea
Allow:  /list
Allow:  /ershou
Allow: /$
Disallow:  /

User-agent:  Bingbot
Allow:  /article
Allow:  /oshtml
Allow:  /product
Allow:  /spu
Allow:  /dianpu
Allow:  /oversea
Allow:  /list
Allow:  /ershou
Allow: /$
Disallow:  /

User-Agent:  360Spider
Allow:  /article
Allow:  /oshtml
Allow:  /ershou
Disallow:  /

User-Agent:  Yisouspider
Allow:  /article
Allow:  /oshtml
Allow:  /ershou
Disallow:  /

User-Agent:  Sogouspider
Allow:  /article
Allow:  /oshtml
Allow:  /product
Allow:  /ershou
Disallow:  /

User-Agent:  Yahoo!  Slurp
Allow:  /product
Allow:  /spu
Allow:  /dianpu
Allow:  /oversea
Allow:  /list
Allow:  /ershou
Allow: /$
Disallow:  /

User-Agent:  *
Disallow:  /

示例马蜂窝tobotshttp://www.mafengwo.cn/robots.txt

User-agent: *
Disallow: /
Disallow: /poi/detail.php

Sitemap: http://www.mafengwo.cn/sitemapIndex.xml

法律知识

中国爬虫违法违规案例汇总: https://github.com/HiddenStrawberry/Crawler_Illegal_Cases_In_China

单位犯罪和个人犯罪的关系

首先了解一下单位犯罪。除了自然人犯罪,还有单位犯罪,是指公司、企业、事业单位、机关、团体为单位谋取不利益,经单位决策机构或者负责人决定实施的,法律规定应当负刑事责任的危害社会的行行为。

我国刑法对单位犯罪原则上采取双罚制度,即单位犯罪的为,对单位判处罚金,并对其直接负责的主管人员和其他直接责任人员判处刑罚。相关司法解释规定,在审理单位故意犯罪案件时,对其直接负责的主管人员和其他直接责任人员,可不区分主犯、从犯,按照其在单位犯罪中所起的作用判处刑罚。

因此,公司犯罪有可能会牵连员工,尤其是案件中对非法获取数据有直接责任的爬虫工程师。这也是为什么当事人在公公司人小言微但还是被批捕的原因。

其次,是否可以"不知者不为罪"来辩解?刑法原则之一是法无明文规定不为罪,并没有"不知者不为罪"。主观上的恶意是衡量犯罪的要素之一,结合客观上的行为来推理主观恶意。破解别人的服务器,获取别人不公开的信息,不能说没有恶意,不能以不懂法来糖塞。

重点: 什么样的爬虫是违法?

如果爬虫程序采集到公民的姓名、身份证件号码、通信通讯取关系方式、住址、账号密码、财产状况、行踪轨迹等个人信息,并将之用于非法途径的,则肯定构成非法获取公民个人信息的违法行为。

除此之外,根据相关规定,对于违反国家有关规定,向他人出售或者提供公民个人信息,情节严重的,窃取或者以其他方法非法获取公民个人信息的,均可构成成"侵犯公民个人信息罪",处三年以下有期徒刑或者拘役,并处或者单处罚金;情节特别严重的,处三年以上七年以下有期徒刑,并处罚金。

重点关注:下列情况下,爬虫有可能违法,严重的甚甚至构成犯罪。

  • 爬虫程序规避网站经营者设置的反爬虫措施或者破解服务器防抓取措施,非法获取相关信息,情节严重的,有可能能构成"非法获取计算机信息系统数据罪"。
  • 爬虫程序干扰被访问的网站或系统正常运营,后果严重的,触犯刑法,构成"破坏计算机信息系统罪"
  • 爬虫采集的信息属于公民个人信息的,有可能构成非法获取公民个人信息的违法行为,情节严重的,有可能构成"侵犯公民个人信息罪"。

HTTP请求和响应处理

其实爬取网页就是通过HTTP协议访问网页,不过通过浏览器反问往往是人的行为,把这种行为变成使用程序来访问。

urllib包

urllib是标准库,它一个工具包模块,包含下面模块来处理url:

  • urllib.request 用于打开和读写url
  • urllib.error 包含了由urllib.request引起的异常
  • urllib.parse 用于解析url
  • urllib.robotparser 分析robots.txt文件

Python2中提供了urllib和urllib2。urllib提供较为底层的接口,urllib2对urllib进行了进一步封装。

Python3中将urllib合并到了urllib2中,并更名为标准库urllib包。

urllib.request模块

模块定义了在基本和摘要式身份验证、重定向、cookies等应用中打开Url(主要是HTTP)的函数和类。

urlopen方法

urlopen(url,data=None)

  • url是链接地址字符串,或请求类的实例
  • data提交的数据,如果data为Non发起的 GET 请求,否则发起 POST 请求。见 urllib.request.Request#get_method
  • 返回http.client.HTTPResponse类的相遇对象,这是一个类文件对象。
from urllib.request import urlopen
from http.client import HTTPResponse # 很简陋

# request 发起请求都和它有关
# urlopen(url, data=None) # data不为None就是POST请求

# 打开一个url返回一个相应对象,类文件对象
# 下面链接访问后会有跳转
response:HTTPResponse = urlopen('https://www.bing.com') # request -> response GET

with response:
    print(type(response), response) #http.client.HTTPResponse类文件对象
    print(response.status, response.reason) # 状态 200 OK
    print(response.headers)  # response.headers 和 response.info()是一个东西
    print(response.geturl())  # 返回真正的URL 301 302 重定向后的url https://cn.bing.com/
    print(response.read()) # bytes
    # bytes -> str?

print(response.isclosed())
print(response.closed)

上例,通过urllib.request.urlopen方法,发起一个HTTP的GET请求,WEB服务器返回了网页内容。

响应的数据被封装到类文件对象中,可以通过read方法、readline方法、readlines方法获取数据,status和reason属性表示返回的状态码,info方法返回头信息,等等。

注意url的变化,说明重定向过。

User-Agent问题

上例代码非常精简,即可以获得网站的响应数据。但目前urlopen方法通过url字符串和data发起HTTP的请求。如果想修改HTTP头,例如useragent,就的借助其他方式。

原码中构造的useragen如下:

# urllib.request.OpenerDirector
class OpenerDirector:
    def __init__(self):
        client_version = "Python-urllib/%s" % __version__
        self.addheaders = [('User-agent', client_version)]

当前显示为Python-urlib/3.13

有些网站是反爬虫的,所以要把爬虫伪装成浏览器。顺便打开一个浏览器,复制李立群的UA值,用来伪装。

https://httpbin.org/#/HTTP_Methods/get_get

from urllib.request import urlopen

from http.client import HTTPResponse # 很简陋

# request 发起请求都和它有关
# urlopen(url, data=None) # data不为None就是POST请求

# 判断爬虫最简单的方式,通用user-agent解读、通过行为判断(正常人不可能点那么快)
# 1. 伪装
ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'


# site = 'https://www.bing.com'
site = 'https://httpbin.org/get'
response:HTTPResponse = urlopen(site) # request -> response GET

# 每一个request请求时都会带上
with response:
    print(type(response), response) #http.client.HTTPResponse类文件对象
    print(response.status, response.reason) # 状态 200 OK
    print(response.headers)  # response.headers 和 response.info()是一个东西
    print(response.geturl())  # 返回真正的URL 301 302 重定向后的url
    print(response.read()) # bytes
    # bytes -> str?
    # 请求头 "User-Agent": "Python-urllib/3.13"

print(response.isclosed())
print(response.closed)
Request类

Request(url,data=None,headers={})

  • 初始化方法,构造一个请求对象。
  • 可添加一个header的字典。
  • data参数决定是GET还是POST请求,参看Request.get_method()方法
  • obj.add_header(key,val) 为header增加一个键值对。
from urllib.request import urlopen, Request
from http.client import HTTPResponse # 很简陋
import random


# request 发起请求都和它有关
# urlopen(url, data=None) # data不为None就是POST请求

site = 'https://www.bing.com'
site = 'https://httpbin.org/get'

# 判断爬虫最简单的方式,通用user-agent解读、通过行为判断(正常人不可能点那么快)
# 1. 伪装
ua_list = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", # chrome
    "Mozilla/5.0 (Windows; U; Windows NT 6.1; zh-CN) AppleWebKit/537.36 (KHTML, like Gecko) Version/5.0.1 Safari/537.36", # safafi
    "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:50.0) Gecko/20100101 Firefox/50.0", # Firefox
    "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)" # IE
]

ua = random.choice(ua_list)

# 构造request对象
request = Request(site)
request.add_header('User-Agent', ua) # 很重要,通过它可以对移动端做出更友好的体验 Native App, Web App http请求

response:HTTPResponse = urlopen(request, timeout=20) # request对象或者url都可以

# 每一个request请求时都会带上
with response:
    print(type(response), response) #http.client.HTTPResponse类文件对象
    print(response.status, response.reason) # 状态 200 OK
    print(response.headers)  # response.headers 和 response.info()是一个东西
    print(response.geturl())  # 返回真正的URL 301 302 重定向后的url
    print(response.read()) # bytes
    # bytes -> str?
    # 请求头 "User-Agent": "Python-urllib/3.13"


print(response.isclosed())
print(response.closed)

urllib.parse模块

该模块可以完成对url的编解码

  1. parse.urlencode({key:value}) #对查询字符串进行编码
from urllib import parse

u = parse.urlencode({
    "url":"http://www.xx.com/python",
    "p_url":"http://www.xx.com/python?id=1&name=张三"
})
print(u)

# 运行结果
url=http%3A%2F%2Fwww.xx.com%2Fpython&p_url=http%3A%2F%2Fwww.xx.com%2Fpython%3Fid%3D1%26name%3D%E5%BC%A0%E4%B8%89

从运行结果来看冒号、斜杠、&、等号、问号等符号全部被编码了,%之后实际上是单字节十六进制表示的值。

一般来说url中的地址部分,一般不需要使用中文路径,但是参数部分,不管GET还是POST方法,提交的数据中,可能有斜杆、等号、问号等符号,这样这些字符表示数据,不表示元字符。如果直接发给服务器端,就会导致接收方无法判断谁是元字符,谁是数据了。为了安全,一般会将数据部分的字符做url编码,这样就不会有歧义了。

后来可以传送中文,同样会做编码,一般先按照字符集的encoding要求转换成字节序列,每一个字节对应的十六进制字符串前加上百分号即可。

# 页面使用utf-8编码
# https://www.baidu.com/s?wd=中
# 上面的url编码后,如下
# https://www.baidu.com/s?wd=%E4%B8%AD

from urllib import parse

u = parse.urlencode({"wd":"中"}) #编码
url= "https://www.baidu.com/s?{}".format(u)
print(url)

print("中".encode("utf-8")) # b'xe4\xb8\xad'
print(parse.unquote(u)) #解码
print(parse.unquote(url))

提交方法method

常用的HTTP交互数据的方法是GET、POST

  1. GET方法,数据是通过URL传递的,也就是说数据是在HTTP报文的header部分。
  2. POST方法,数据是放在HTTP报文的body部分提交的。
  3. 数据是键值对形式,多个参数质检使用&符号链接。例如a=1&b=abc

GET方法

链接 必应 搜索引擎官网,获取一个搜索的URL http://cn.bing.com/search?q=jasper

需求

  • 请写程序完成对关键字bing搜索,将返回的结果保存到一个网页文件。
from urllib.request import Request, urlopen
from urllib import parse

baseurl = 'https://cn.bing.com/search'
params = parse.urlencode({
    'q':'jasper'
})
url = "{}?{}".format(baseurl, params)
print(url)

ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'

request = Request(url, headers={'user-agent':ua})
with urlopen(request) as response:
    # print(response.read())
    with open('d:/tmp/a.html', 'wb') as f:
        f.write(response.read())

POST方法

测试网站

from urllib.request import Request, urlopen
from urllib import parse
import simplejson

url = 'https://httpbin.org/post'
params = parse.urlencode({
    'q':'jasper'
})

ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'

request = Request(url, headers={
    'X-REQUEST':'CICI',
    'user-agent':ua
})

with urlopen(request, params.encode('utf-8')) as response:
    print(simplejson.loads(response.read()))

执行结果

{
    'args': {

    },
    'data': '',
    'files': {

    },
    'form': {
        'q': 'jasper'
    },
    'headers': {
        'Accept-Encoding': 'identity',
        'Content-Length': '8',
        'Content-Type': 'application/x-www-form-urlencoded',
        'Host': 'httpbin.org',
        'User-Agent': 'Mozilla/5.0(WindowsNT10.0;Win64;x64)AppleWebKit/537.36(KHTML,
        likeGecko)Chrome/138.0.0.0Safari/537.36',
        'X-Amzn-Trace-Id': 'Root=1-68932b41-6306d8875659ae8129ecb0ad',
        'X-Request': 'CICI'
    },
    'json': None,
    'origin': '101.71.195.151',
    'url': 'https: //httpbin.org/post'
}

处理JSON数据

XMLHttpRequest(简称xhr),是浏览器提供的JS对象,通过它可以请求到服务器上的数据资源。

访问:https://movie.douban.com/ 查看“豆瓣电影”,中的热门电影, 通过分析,我们知道这部分内容,是通过AJAX从后台拿到的JSON数据并插入到网页。

服务器返回json数据如下(略,轮播组件,共50条数据)

from urllib.request import Request, urlopen
from urllib import parse
import json

url = 'https://httpbin.org/post'
url = 'https://movie.douban.com/j/search_subjects'
params = parse.urlencode({
    "tag":"热门",
    "type": "movie",
    "page_limit": 5,
    "page_start": 0 # 偏移

})

url = "{}?{}".format(url, params)

ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'

# POST
request = Request(url, headers={
    'user-agent':ua
})
with urlopen(request, params.encode('utf-8')) as response:  # urlopen(url, data) data不为None就是POST请求
    print(response.status, response.reason, '++++')
    j = json.loads(response.read())
    print(len(j['subjects']))
print('-'*30)

HTTPS证书忽略

HTTPS使用SSL安全套层协议,在传输层对网络数据进行加密。HTTPS使用的时候需要证书,而证书需要CA认证。

CA(Certificate Authority)是数字证书认证中心的简称,是指发放、管理、废除数字证书的机构。

CA是受信任的第三方,有CA签发的证书具有可信任。如果用户由于信任了CA签发的证书导致的损失,可以追究CA的法律责任。

CA是层级结构,下级CA信任上级CA,且有上级CA颁发给下级CA证书并认证。

一些网站,例如淘宝,使用HTTPS加密数据更加安全。

以前旧版本12306网站需要下载证书

from urllib.request import Request,urlopen

# request = Request("http://www.12306.cn/mormhweb/") #可以访问
# request = Request("https://www.baidu.com/") #可以访问

request = Request("https://www.12306.cn/mormhweb/") #旧版本报SSL认证异常
request.add_header(
    "User-agent",
    "Mozilla/5.0 (Windows; U; Windows NT 6.1; zh-CN) AppleWebKit/537.36 (KHTML, like Gecko) Version/5.0.1 Safari/537.36"
)

# ssl.CertificateError: hostname 'www.12306.cn' doesn't match either of ......
with urlopen(request) as res:
    print(res._method)
    print(res.read())

通过HTTPS访问12306的时候,失败的原因在于12306的证书未通过CA认证,1它是自己生成的证书,不可信。而其它网站访问,如https://www.baidu.com/%E5%B9%B6%E6%B2%A1%E6%9C%89%E6%8F%90%E7%A4%BA%E7%9A%84%E5%8E%9F%E5%9B%A0%EF%BC%8C%E5%AE%83%E7%9A%84%E8%AF%81%E4%B9%A6%E7%9A%84%E5%8F%91%E8%A1%8C%E8%80%85%E5%8F%97%E4%BF%A1%E4%BB%BB%EF%BC%8C%E4%B8%94%E6%97%A9%E5%B0%B1%E5%AD%98%E5%82%A8%E5%9C%A8%E5%BD%93%E5%89%8D%E7%B3%BB%E7%BB%9F%E4%B8%AD

现在12306证书是可信机构颁发的了。

遇到这种问题,解决思路:忽略证书不安全信息

from urllib.request import Request,urlopen
import ssl #导入ssl模块


# request = Request("http://www.12306.cn/mormhweb/") #可以访问
# request = Request("https://www.baidu.com/") #可以访问

request = Request("https://www.12306.cn/mormhweb/") #旧版本报SSL认证异常
request.add_header(
    "User-agent",
    "Mozilla/5.0 (Windows; U; Windows NT 6.1; zh-CN) AppleWebKit/537.36 (KHTML, like Gecko) Version/5.0.1 Safari/537.36"
)

# 忽略不信任的证书
context = ssl._create_unverified_context()
res = urlopen(request,context=context)

# ssl.CertificateError: hostname 'www.12306.cn' doesn't match either of ......
with res:
    print(res._method)
    print(res.geturl())
    print(res.read().decode())

urllib3库

https://urllib3.readthedocs.io/en/latest/

标准库urlib缺少了一些关键的功能,非标准库的第三方库urllib3提供了,比如说连接池管理。

安装

pip install urllib3

测试一下网站访问,https://movie.douban.com

import urllib3
from urllib3 import PoolManager, HTTPResponse

# pool = PoolManager() # http 对象
url = 'https://movie.douban.com/'
ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'

with PoolManager() as http:
    res:HTTPResponse = http.request('GET',url, headers={
        'user-agent':ua
    })
    # print(type(res), res)
    print(res.status, res.reason)
    print(res.headers)
    with open('d:/tmp/a.html', 'wb') as f:
        f.write(res.data)

requests库**

requests使用了urllib3,但是API更加友好,推荐使用。

很多人做爬虫时基本都用requests,前面讲的都是基本原理,底层实现。

安装

pip install requests

测试一下网站访问,https://movie.douban.com

import requests

ua = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36"
url = "https://movie.douban.com/"

response = requests.request("GET",url,headers={"User-Agent":ua})

with response:
    print(type(response))
    print(response.url)
    print(response.status_code)
    print(response.request.headers) #请求头
    print(response.headers) #响应头
    response.encoding = "utf-8"
    print(response.text[:200]) #HTML的内容
    with open('d:/movie.html',"w",encoding='utf-8') as f:
        f.write(response.text)

requests默认使用Session对象,是为了多次和服务器端交互中保留会话的信息,例如:cookie。

#直接使用Session
import requests

# pool = PoolManager() # http 对象
base_url = 'https://movie.douban.com/'
ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'

# 在同一个会话里发起2次请求
with requests.Session() as session:
    for url in [base_url, base_url]:
        response = session.get(base_url, headers={'user-agent':ua})
        # response = requests.get(base_url, headers={'user-agent': ua}) #观察两种方式区别
        # print(type(response), response)
        # print(response.__enter__())

        with response:
            print(response.status_code, response.reason)
            print(response.encoding)
            print(response.cookies)
            print('=' * 30)
            print(*response.request.headers.items(), sep='\n')
            print('=' * 30)
            # print(*response.headers.items(), sep='\n')

            # print(response.content) # 内容 html文本 bytes
            # print(response.text)  # 内容 html文本, content + encoding

使用session访问,第二次带上了cookie

HTML解析-Xpath

HTML的内容返回给浏览器,浏览器就会解析它,并对它渲染。

HTML超文本表示语言,设计的初衷就是为了超越普通文本,让文本表现力更强。

XML扩展标记语言,不是为了替代HTML,而是觉得HTML的设计中包含了过多的格式,承担了一部分数据之外的任务,所以才设计了XML只用来描述数据。

HTML和XML都有结构,使用标记形成树型的嵌套结构。DOM(Document Object Model)来解析这种嵌套树型结构,浏览器往往都提供了对DOM操作的API,可以用面向对象的方式来操作DOM。

XPath***

https://www.w3school.com.cn/xpath/index.asp 中文教程

XPath是一门在XML文档中查找信息的语言。XPath可用来在XML文档中对元素和属性进行遍历。

工具

测试工具:XMLQuire win7+需要.net框架4.0-4.5。

  • 解压后,进入xmlquire\XMLQuireWin8\Application Files\XMLQuire_1_1_7_804目录下,打开XMLQuire.exe

测试XML、XPath

测试文档 books.xml

<?xml version="1.0" encoding="utf-8"?>
<bookstore>
  <book id="bk101">
    <author>Gambardella, Matthew</author>
    <title>XML Developer's Guide</title>
    <genre>Computer</genre>
    <price>44.95</price>
    <publish_date>2000-10-01</publish_date>
    <description>An in-depth look at creating applications with XML.</description>
  </book>
  <book id="bk102" class="bookinfo even">
    <author>Ralls, Kim</author>
    <title>Midnight Rain</title>
    <genre>Fantasy</genre>
    <price>5.95</price>
    <publish_date>2000-12-16</publish_date>
    <description>A former architect battles corporate zombies, an evil sorceress, and her own childhood to become queen of the world.</description>
  </book>
  <book id="bk103">
    <author>Corets, Eva</author>
    <title>Maeve Ascendant</title>
    <genre>Fantasy</genre>
    <price>5.95</price>
    <publish_date>2000-11-17</publish_date>
    <description>After the collapse of a nanotechnology society in England, the young survivors lay the foundation for a new society.</description>
  </book>
  <book id="bk104">
    <author>Corets, Eva</author>
    <title>Oberon's Legacy</title>
    <genre>Fantasy</genre>
    <price>5.95</price>
    <publish_date>2001-03-10</publish_date>
    <description>In post-apocalypse England, the mysterious agent known only as Oberon helps to create a new life for the inhabitants of London. Sequel to Maeve Ascendant.</description>
  </book>
</bookstore>

节点

在XPath中,有七种类型的节点:元素、属性、文本、命名空间、处理指令、注释以及文档(根)节点。

举例 说明
<bookstore> 元素节点
<author>Corets,Eva</author> 元素节点
id="bk104" 属性节点,id是元素节点book的属性
44.95 文本节点

节点之间的嵌套形成 父子(parent,children)关系

具有统一个父结点的不同节点是 兄弟(sibling)关系

节点选择

操作符或表达式 含义
// 从当前节点开始的任意层找
. 当前节点
.. 当前结点的父节点
@ 选择属性
节点名 选取所有这个节点名的节点
* 匹配任意元素节点
@* 匹配任意属性节点
node() 匹配任意类型的节点
text() 匹配text类型节点

谓语(Predicates)

  1. 谓语用来查找某个特定的节点或者包含某个指定的值的节点。
  2. 谓语被嵌在方括号中
  3. 谓语就是查询的条件。
  4. 即在路径选择时,在中括号内指定查询条件。

XPath轴(Axes)

轴的意思是相对于当前结点的节点集

轴名称 结果
ancestor 选取当前结点的所有先辈(父、祖父等)
ancestor-or-self 选取当前节点的所有先辈(父、祖父等)以及当前节点本身
attribute 选取当前节点的所有属性。@id等价于attribute::id
child 选取当前节点的所有子元素,title等价于child:title
descendant 选取当前节点的所有后代元素(子、孙等)
descendant-or-self 选取当前节点的所有后代运算(子、孙等)以及当前节点本身
following 选取文档中当前节点的结束标签之后的所有结点
namespace 选取当前节点的所有命名空间节点
parent 选取当前节点的父节点
preceding 直到所有这个节点的父辈节点,顺序选择这个父辈节点前的所有同级节点
preceding-sibling 选取当前节点之前的所有同级节点
self 选取当前节点。等驾驭self::node()

步Step

步的语法 轴名称:节点测试[谓语]

例子 结果
child::book 选取所有属于当前节点的只元素的book节点
attribute::lang 选取当前节点的lang属性
child::* 选取当前节点的所有只元素
attribute::* 选取当前节点的所有属性
child::text() 选取当前节点的所有文本子节点
child::node() 选取当前节点的所有子节点
descendant::book 选取当前节点的所有book后代
ancestor:book 选择当前节点的所有book先辈
ancestor-or-self::book 选取当前节点的所有book先辈以及当前节点(如果此节点是book节点)
child::*/child::price 选取当前节点的所有price孙节点

XPATH示例

  1. 以斜杠开始的称为绝对路径,表示从根开始。
  2. 不以斜杠开始的称为相对路径,一般都是依照当前节点来计算。当前节点在上下文环境中,当前节点很可能已经补是根节点了。
  3. 一般为了方便,往往xml如果层次很深,都会使用=//=来查找节点。
路径表达式 含义
title 选取当前节点下所有title子节点
/book 从根节点找子节点是book的,找不到
book/title 当前节点下所有子节点book下的title节点
//title 从根节点向下找任意层中title的结点
book//title 当前节点下所有book子节点下任意层次的title节点
//@id 任意层次下含有id的 属性 ,取回的是属性
//book[@id] 任意层次下含有id属性的book节点
//*[@id] 任意层下含有id属性的节点
//book[@id="bk102"] 任意层次下book节点,且含有id属性为bk102的节点。
/bookstore/book[1] 根节点bookstore下第一个book节点, 从1开始
/bookstore/book[1]/@id 根节点bookstore下的第一个book节点的id属性
/bookstore/book[last()-1] 根节点bookstore下*倒数*第二个book节点,函数last()返回最后一个元素索引
/bookstore/* 匹配根节点bookstore的所有子节点,不递归
//* 匹配所有子孙节点
//*[@*] 匹配所有有属性的节点
//book[@*] 匹配所有有属性的book节点
//@* 匹配所有属性
//book/title | //price 匹配任意层下的book下节点是title节点,或者任意层下的price
//book[position()=2] 匹配book节点,取第二个
//book[position()<last()-1] 匹配book节点,取位置小于倒数第二个
//book[price>40] 匹配book节点,取节点值大于40的book节点
//book[2]/node() 匹配位置为2的book节点下的所有类型的节点
//book[1]/text() 匹配第一个book节点下的所有文本子节点
//book[1]//text() 匹配第一个book节点下的所有文本节点
//*[local-name()="book"] 匹配所有节点且不带限定名的节点名称为book的所有节点。local-name函数取不带限定名的名称。
路径表达式 含义
//book/child::node() 所有有class属性的节点
//book/child::node()[local-name()='price' and text() < 10] 所有book节点的子节点中名字叫做pricer 的
  且其内容小于10的节点
  等价于//book/price[text(),10]
   
  //book[price<6]/price
  //book/price[text()<6]
  //book/child::node()[local-name()="price" and text()<6]
  这三种等价
/book//*[self::title or self::price] 所有book节点下子孙节点,且这些节点是title或者price
  等价于 //book//title | //book/price
  也等价于=//book//*[local-name()="title" or local-name()="price"]
//*[@class] 所有有class属性的节点
//*[@class="bookinfo even"] 所有属性为“bookinfo even”的节点
//*[contains(@class,'even') 获取所有属性class中包含even字符串的节点
//*[contains(local-name(),'book') 标签名包含book的节点

轴少用。

函数总结

函数 含义
local-name() 获取不带限定名的名称。相当于指定*标签元素*
text() 获取标签之间的文本内容
node() 所有节点。
contains(@class,str) 包含
starts-with(local-name(),"book") 以book开头
last() 最后一个元素索引
position() 元素索引

lxml

lxml是Python下功能丰富的XML、HTML解析库,性能非常好,是对libxml2和libxslt的封装。

注意,不同平台不一样,参看https://lxml.de/installation.html

lxml安装

pip install lxml

https://lxml.de/tutorial.html

from lxml import etree

# 使用etree构建HTML
root = etree.Element("html")
print(type(root))
print(root.tag)

body = etree.Element("body")
root.append(body)
print(etree.tostring(root))

#增加子节点
sub = etree.SubElement(body,"child1")
print(type(sub))
sub = etree.SubElement(body,"child2").append(etree.Element("child21"))
html = etree.tostring(root,pretty_print=True).decode()
print(html)
print("- "*30)

r = etree.HTML(html) #返回根节点
print(r.tag)
print(r.xpath("//*[contains(local-name(),'child')]"))
  1. etree还提供了2个有用的函数
  2. etree.HTML(text)解析HTML文档,返回根节点
  3. anode.xpath('xpath路径')对节点使用xpath语法

范例

from lxml import etree

# root = etree.Element('html')
# body = etree.Element('body')
# root.append(body)
# print(root)
# print(body)


# body.append(etree.Element('div'))
# p = etree.SubElement(body, 'p')  # body里加个p标签

# print(etree.tostring(root, pretty_print=True))
# # b'<html>\n  <body>\n    <div/>\n    <p/>\n  </body>\n</html>\n'

root = etree.HTML(b'<html>\n  <body>\n    <div/>\n    <p>test string<p/>\n  </body>\n</html>\n')
print(etree.tostring(root))

print(root.xpath('//p/text()'))

# 返回值
# b'<html>\n  <body>\n    <div/>\n    <p>test string</p><p/>\n  </body>\n</html>'
# ['test string']

练习:爬取“口碑榜”

从豆瓣电影中获取”本周口碑榜”

  • Chrome浏览器F12,选择 Elements (元素) 面板,按Ctrl+F,按xpath语法搜索
  • 火狐浏览F12,按xpath语法搜索 //div[@class="billboard-bd"]//td/a/text()
<div class="billboard-bd">
            <table>
                    <tbody><tr>
                        <td class="order">1</td>
                        <td class="title"><a onclick="moreurl(this, {from:'mv_rk'})" href="https://movie.douban.com/subject/36809864/">南京照相馆</a></td>
                    </tr>
                    <tr>
                        <td class="order">2</td>
                        <td class="title"><a onclick="moreurl(this, {from:'mv_rk'})" href="https://movie.douban.com/subject/36448279/">罗小黑战记2</a></td>
                    </tr>
from lxml import etree
import requests

url = 'https://movie.douban.com/'
ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'

response = requests.get(url, headers={'user-agent': ua})

with response:
    print(response.status_code)
    if 200 <= response.status_code < 300:
        print(response.text[:300]) # html内容
        root = etree.HTML(response.content) # 分析html,返回DOM根节点
        print(root.xpath('//div[@class="billboard-bd"]//td/a/text()'))

执行结果

200
<!DOCTYPE html>
<html lang="zh-CN" class="ua-windows ua-webkit">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <meta name="renderer" content="webkit">
    <meta name="referrer" content="always">
    <meta name="google-site-verification" content="ok0wCgT20tBBgo9_z
['南京照相馆', '罗小黑战记2', '戏台', '关于约会的一切', '冥婚红包', '长安的荔枝', '花漾少女杀人事件', '没事,没事,没事!', '麦克白', '邓南遮占领阜姆自由邦']

MongoDB在python中的使用

它是由C++编写的分布式文档数据库,内部使用类似于Json的bson格式。

安装

官方安装文档:https://www.mongodb.com/zh-cn/docs/manual/installation/

# https://www.mongodb.com/zh-cn/docs/manual/tutorial/install-mongodb-on-windows-zip/
# Server
D:.
│  LICENSE-Community.txt
│  MPL-2
│  README
│  THIRD-PARTY-NOTICES
│
└─bin
        Install-Compass.ps1
        mongod.exe #Server
        mongod.pdb
        mongos.exe #Router
        mongos.pdb
        vc_redist.x64.exe
# client
D:.
│  .sbom.json
│  LICENSE-crypt-library
│  LICENSE-mongosh
│  mongosh.1.gz
│  README
│  THIRD_PARTY_NOTICES
│
└─bin
        mongosh.exe
        mongosh_crypt_v1.dll
组件 文件名
Server mongod.exe
Router mongos.exe
Client mongosh 旧版为mongo.exe
MonitoringTools mongostat.exe,mongotop.exe
importExportTools mongodump.exe,mongorestore.exe,mongoexport.exe,mongoimport.exe
MiscellaneousTools bsondump.exe,mongofiles.exe,mongooplog.exe,mongoperf.exe

运行

./bin/mongod  #没有指定数据目录,报错。
# ./bin/mongod  --dbpath="d:/tmp/mongo/db"

选项说明

--bind_ip ip    #逗号分隔ip地址。默认localhost
--bing_ip_all   #绑定所有本地ip地址
--port port     #端口,默认27017
--dbpath path   #数据路径,缺省为=\data\db\=。windows下缺省就是当前盘符的根目录
--logpath path  #指定日志文件,替代stdout,说明默认是控制台打印日志
-f file         #指定配置文件,yaml格式

注册wiendows服务
--install                 #注册windwos服务
--serviceName name        #服务名称
--serviceDisplayName name #服务显示名

linux安装mongodb

#debian
wget https://repo.mongodb.org/apt/debian/dists/bookworm/mongodb-org/8.0/main/binary-amd64/mongodb-org-server_8.0.13_amd64.deb

└─$ dpkg -c ./mongodb-org-server_8.0.13_amd64.deb  #查看包文件
-rw-r--r-- root/root       578 2013-12-19 13:41 ./etc/mongod.conf # 配置文件
-rw-r--r-- root/root       837 2013-12-19 13:41 ./lib/systemd/system/mongod.service # uint启动文件
-rwxr-xr-x root/root 219688608 2013-12-19 13:41 ./usr/bin/mongod

vim /etc/mongod.conf
net:
  port: 27017
  bindIp: 0.0.0.0

# 启动服务
systemctl start mongod.service 

配置文件

mongodb配置使用YAML格式

  • 嵌套使用缩进晚餐,不支持Tab等制表符,支持空格
  • 冒号后要有空格

Yaml参考https://www.w3cschool.cn/iqmrhf/dotvpozt.html

配置: https://www.mongodb.com/zh-cn/docs/manual/reference/configuration-options/ 在mongodb安装目录新建配置文件mongodb.yml。内容如下:

mongod.yml

systemLog:
  destination: file
  path: "d:/tmp/mongo/mongod.log"
  logAppend: true
storage:
  dbPath: "d:/tmp/mongo/db"
net:
  bindIp: 127.0.0.1
  port: 27017

选项

  1. systemLog
    • destination,缺省是输出日志到std,file表示输出到文件
    • path,日志路径
    • logAppend,true表示在已存在的日志文件追加。默认false,每次启动服务,重新创建新的日志。
  2. storage
  3. dbPath,必须指定mongodb的数据目录,目录必须存在。
  4. net
    1. bindlp,缺省绑定到127.0.0.1
    2. port,端口,缺省为27017,客户端连接用

windows下注册为服务的命令如下,使用了配置文件:

mongod.exe -f "D:/Application/mogodb/package/mongodb4.0/mongod.yml" --serviceName mongod --serviceDisplayName mongo --install

注意,注册服务需要管理员权限。

去掉配置文件中的配置日志信息部分。这样日志将会显示在控制台

storage:
  dbPath: "d:/tmp/mongo/db"
net:
  bindIp: 127.0.0.1
  port: 27017

启动服务

./bin/mongod -f mongod.yml

客户端

客户端连接

客户端连接文档:

./bin/mongosh "mongodb://localhost:27017"
#旧版本 bin/mongo.exe

#操作
help 打开帮助
show dbs    查看当前库
use blog    有就切换过去,没有就创建后切换过去。刚创建的并不在数据库列表中,需要写入数据后才能看到
db          查看当前数据库
db.users.insert({user:"tom",age:20}) db指代当前数据库;users集合名

范例

test> help
  Shell Help:

    log                                        'log.info(<msg>)': Write a custom info/warn/error/fatal/debug message to the log file
                                               'log.getPath()': Gets a path to the current log file

    use                                        Set current database
    show                                       'show databases'/'show dbs': Print a list of all available databases
                                               'show collections'/'show tables': Print a list of all collections for current database
                                               'show profile': Prints system.profile information
                                               'show users': Print a list of all users for current database
                                               'show roles': Print a list of all roles for current database
                                               'show log <name>': Display log for current connection, if name is not set uses 'global'
                                               'show logs': Print all logger names.
    exit                                       Quit the MongoDB shell with exit/exit()/.exit
    quit                                       Quit the MongoDB shell with quit/quit()
    Mongo                                      Create a new connection and return the Mongo object. Usage: new Mongo(URI, options [optional])
    connect                                    Create a new connection and return the Database object. Usage: connect(URI, username [optional], password [optional])
    it                                         result of the last line evaluated; use to further iterate
    version                                    Shell version
    load                                       Loads and runs a JavaScript file into the current shell environment
    enableTelemetry                            Enables collection of anonymous usage data to improve the mongosh CLI
    disableTelemetry                           Disables collection of anonymous usage data to improve the mongosh CLI
    passwordPrompt                             Prompts the user for a password
    sleep                                      Sleep for the specified number of milliseconds
    print                                      Prints the contents of an object to the output
    printjson                                  Alias for print()
    convertShardKeyToHashed                    Returns the hashed value for the input using the same hashing function as a hashed index.
    cls                                        Clears the screen like console.clear()
    isInteractive                              Returns whether the shell will enter or has entered interactive mode

  For more information on usage: https://mongodb.com/docs/manual/reference/method
test> show dbs
admin    40.00 KiB
config  108.00 KiB
local    40.00 KiB
test> db                                                                                                                                                                
test                                                                                                                                                                    
test> use aaa      # 没有写数据的话,退出后不会创建库。                                                                                                                                                     
switched to db aaa                                                                                                                                                      
aaa>

aaa> use users                                                                                                                                                          
switched to db users                                                                                                                                                    
users> db.users.insert({'name':'tom', 'age':20})  #插入数据                                                                                             
DeprecationWarning: Collection.insert() is deprecated. Use insertOne, insertMany, or bulkWrite.                                                                         
{                                                                                                                                                                       
  acknowledged: true,                                                                                                                                                   
  insertedIds: { '0': ObjectId('6895bc37ad8daf59bceec4a9') }                                                                                                            
}                                                                                                                                                                       
users> db.users.find()                                                                                                                                                  
[ { _id: ObjectId('6895bc37ad8daf59bceec4a9'), name: 'tom', age: 20 } ]                                                                                                 
users> 

Pycharm插件 在settings/plugins中输入mongo,安装Mongo Plugin,完成后重启Pycharm。

菜单项view/Tool windows/Mongo Explorer

Python链接

Mongodb官方推荐使用pymongo。参考https://www.mongodb.com/zh-cn/docs/languages/python/pymongo-driver/current/

#pymongo-4.14.0
pip install pymongo

mongodb的链接字符串 mongodb://gdy:[email protected]:27017/test

from pymongo import MongoClient
from pymongo.synchronous.database import Database

url = 'mongodb://127.0.0.1:27017'
client = MongoClient(url)
print(client)

db:Database = client['users']
# db = client.users # 与上面等价,因为MongoClient有__getitem__ __getattr__ 方法
print(type(db), db)

table = db['users']
print(type(table), table)

result = table.find() # select * from users.users
print(type(result), *result)
client.close()

# with client:

执行结果

MongoClient(host=['127.0.0.1:27017'], document_class=dict, tz_aware=False, connect=True)
<class 'pymongo.synchronous.database.Database'> Database(MongoClient(host=['127.0.0.1:27017'], document_class=dict, tz_aware=False, connect=True), 'users')
<class 'pymongo.synchronous.collection.Collection'> Collection(Database(MongoClient(host=['127.0.0.1:27017'], document_class=dict, tz_aware=False, connect=True), 'users'), 'users')
<class 'pymongo.synchronous.cursor.Cursor'> {'_id': ObjectId('6895bc37ad8daf59bceec4a9'), 'name': 'tom', 'age': 20}

即能用属性访问,又能像key一样访问,一定用到了魔术方法的 __getattr__、__getitem__

基本概念

MongoDB中可以创建使用多个库,但有一些数据库名是保留的,可以直接访问这些特殊作用的数据库。

  1. admin: 从权限的角度来看,这是"root"数据库。要是将一个用户添加到这个数据库,这个用户自动继承所有数据库的权限。一些特定的服务器端命令也只能从这个数据库运行,比如列出所有的数据库或者关闭服务器。
  2. local: 这个数据永远不会被赋值,可以用来存储限于本地单台服务器的任意集合
  3. config: 当Mongo用于分片设置时,config数据库在内部使用,用于保存分片的相关信息。
RDBMS MongoDB
Database Database
Table Collection
Row Document
Column Field
Join Embedded Document嵌入文档或Reference引用
Primary Key 主键(MongoDB提供了Key为_id)

插入数据

每条数据插入后都有一个唯一key,属性 _id 唯一标识一个文档。没有没有显示指明该属性,会自动生成一个Objectld类型的 _id 属性。

pymongo.collection.Collection类

  1. db.collection.insert_one(dict)->InsertOneResult #单行插入
    • dict是一个字典
    • InsertOneResult:f返回结果ObjectId对象,即=_id=的值
  2. db.collection.insert_many([dict,…])->[InsertOneResult,…] #多行插入
    • 第一个参数是个列表,列表中记录需要插入的文档类型即dict
    • 返回结果,被插入的对象所获得的id
from pymongo import MongoClient
from pymongo.synchronous.database import Database

url = 'mongodb://127.0.0.1:27017'
client = MongoClient(url)
db:Database = client['users']
table = db['users']

# C
# 单条插入
r = table.insert_one({'id':'1','name':'ben', 'age':20}) # _id
print(r.inserted_id) #68961acd2a6de1bd5c0fd597

# 批量插入
r = table.insert_many([
    {'id':257, 'name':'tom', 'age':32},
    {'id':258, 'name':'jerry', 'age':48}
])
print(r.inserted_ids)
#[ObjectId('68961ace2a6de1bd5c0fd598'), ObjectId('68961ace2a6de1bd5c0fd599')]
r = table.insert_one({'id':'3','name':'sam', 'age':16}) # _id
print(r.inserted_id) # 68961ace2a6de1bd5c0fd59a

result  = table.find() # select * from users.users
print(type(result), *result)
client.close()

每条数据插入后都有一个唯一key,属性 _id 唯一标识一个文档。没有显式指明该属性,会自动生成一个ObjectId类型的 _id 属性。

ObjectID由12个字节组成

  • 4个字节时间戳
  • 3个字节机器识别码
  • 2个字节进程id
  • 3个字节随机数
import datetime

id = '68961ace2a6de1bd5c0fd59a'
x = id[:8] # 时间戳提取
print(x)
print(0x68961ace)
print(int('0x68961ace', 16))
print(datetime.datetime.fromtimestamp(0x68961ace))
print('=' * 30)
y = int.from_bytes(bytes.fromhex(x), 'big') #大端模式
print(y)
print(datetime.datetime.fromtimestamp(y))

# 也可以使用函数获取
import bson #json
z = bson.ObjectId(id)
print(z)
print(z.generation_time)

文档

每一条记录对应一个文档,其格式使用BSON。BSON即Binary Json。

BSON二进制格式如下:

img_20250820_152345.png

查看"\db\collection-4-*.wt"对应的二进制格式

00 00 05 82 d7 35 00 00 00 07 5f 69 64 00 5d 45

35 表示文档二精制数据长度
07 表示MongoDB中特殊数据类型ObjectID
5f 69 64 ,ascii码_id

57 be 12 f2 bf 14 48 c7 43 de 10 69 64 00 01 01

5d 45 57 be 12 f2 bf 14 48 c7 43 de,ObjectId(‘5d4557be12f2bf1448c743de')_id的值
10 69 64,10表示类型int-32, 69 64是ascii码id
0101小端模式,十进制257

mongodb数据类型参考 https://docs.mongodb.com/v3.6/reference/bson-types/

00 00 02 6e 61 6d 65 00 04 00 00 00 74 6f 6d 00

  • 02 表示数据类型UTF-8 String
  • 6e 61 6d 65 表示字符串name
  • 04, 表示字符串长度
  • 74 6f 6d 00,表示字符串tom和结束符

10 61 67 65 00 20 00

  • 10,表示数据类型int-32
  • 61 67 65,表示字符串age
  • 20,表示十进制32

文档

  1. 文档中,使用键值对
  2. 文档中的键/值对时*有序*的
  3. 键是字符串
    • 区分大小写 ,使用UTF-8字符
    • 键不能含有\0(空字符)。这个字符用来表示键字符串的结尾
    • .$ 有特别的意义,只有在特定环境下才能使用
    • 以下划线 _ 开头的键是保留的,例如: _id
  4. 值可以是:
    1. 字符串,32位或64位整数、双精度、时间戳(毫秒)、布尔型、null
    2. 字节数组、BSON数组、BSON对象

查询数据

pymongo.collection.Collection类

db.collection.find_one(dict)->result 查询单条数据
db.collection.find(dict)->resutl 查询多条数据
  • dict,使用查询操作符组成的字典
  • result,返回的结果集

单条查询

find_on第一参数是filter, 相当于SQL的where子句。

from pymongo import MongoClient
from pymongo.collection import Collection
from bson.objectid import ObjectId

client = MongoClient("mongodb://127.0.0.1:27017")
db = client["blog"] #指定数据库
users:Collection = db.users #集合

# 查询
result = users.find_one({"name":"tom"})
print(type(result),result)

# 使用key查询
result2 = users.find_one({"_id":ObjectId("5d539433c1b3dcb23654814d")})
print(type(result2),result2)

#查不到,返回None
result3 = users.find_one({"name":"tommy"})
print(type(result3),result3)1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.

多条查询

from pymongo import MongoClient
from pymongo.collection import Collection

client = MongoClient("mongodb://127.0.0.1:27017")
db = client["blog"] #指定数据库
users:Collection = db.users #集合

# 多条查询
results = users.find({"name":"tom"})
print(type(results))
print(results)

print("- "*30)
for x in results:
    print(type(x),x)

查询操作符

https://www.mongodb.com/zh-cn/docs/manual/reference/operator/query/

比较符号 含义 示例
$lt 小于 {"age":{"$lt":20}}
$gt 大于 {"age":{"$gt":20}}
$lte 小于等于 {"age":{"$lte":20}}
$gte 大于等于 {"age":{"$gte":20}}
$ne 不等于 {"age":{"$ne":20}}
$eq 等于,可以不用增符号 {"age":{"$eq":20}}
$in 在范围内 {"age":{"$in":[20,23]}}
$nin 不在范围内 {"age":{"$nin":[20,30]]}}
逻辑符号 含义 示例
$and {"$and":[{"name":"tom"},{"age":{"$gt":20}}]}
$or {'$or':[ {'age':{'$lt':20}}, {'age':{'$gt':40}}]}
$not {'name':{'$not':{'$eq':'tom'}}}
元素 含义 示例
$exists 文档中是否有这个字段 {'Name':{'$exists':True}}
$type 字段是否是指定的类型 {'age':{'$type':16}}

常用类型

  • 字符串类型编码为2,别名为string
  • 整型编码为16,别名为int
  • 长整型编码为18,别名为long

https://docs.mongodb.com/manual/reference/operator/query/type/#op._S_type

操作符 含义 示例
$regex 文档中字段内容匹配 {"name":{"$regex":"^t"}}
$mod 取模 {"age":{"$mod":[10,2]}}模10余2

范例

from pymongo import MongoClient
from pymongo.synchronous.database import Database

url = 'mongodb://127.0.0.1:27017'
client = MongoClient(url)
db:Database = client['users']
table = db['users'] # db.users

# 操作符
# result = table.find({'age':{"$gt":20}}) # select * from users.users where name='tom' and age > 20
# result = table.find({'age':{'$nin':[20, 16]}})

# result = table.find({'$and':[
#     {'name':'tom'}, {'age':{'$gt':20}}
# ]})
# result = table.find({'name':'tom', 'age':{'$gt':20}}) #与上面等价

# result = table.find({'$or':[
#     {'age':{'$lt':20}}, {'age':{'$gt':40}}
# ]})
# result = table.find({'name':{'$not':{'$eq':'tom'}}})

# result = table.find({'id':{'$type':16}})
# result = table.find({'Name':{'$exists':True}})

# result = table.find({'name':{'$regex':'^t'}})
result = table.find({'age':{'$mod':[10, 2]}})
print(*result, sep='\n')

client.close()

投影

from pymongo import MongoClient
from pymongo.synchronous.database import Database

url = 'mongodb://127.0.0.1:27017'
client = MongoClient(url)
db:Database = client['users']
table = db['users'] # db.users

# 投影
# result = table.find({'age':{'$gt':20}}, ('name',))
# result = table.find({'age':{'$gt':20}}, ['name', 'id']) #允许
result = table.find({'age':{'$gt':20}}, {'name':0, 'age':False}) #排除。 0为排除,非0为允许。但不能混写。
for x in result:
    print(type(x), x)

client.close()

可以使用列表、元组、字典描述字段,或排除投影的字段。

统计

  1. db.collection.find(条件字典).count() 统计次数(已过时)
  2. db.collection.count_documents(条件字典)
from pymongo import MongoClient
from pymongo.collection import Collection

client = MongoClient("mongodb://127.0.0.1:27017")
db = client["blog"] #指定数据库
users:Collection = db.users #集合

# 统计
# 被弃用
# count = users.find({"age":{"$gt":10}}).count()

#求出age>10的数量
count = users.count_documents({"age":{"$gt":10}})
print(count) #返回结果41.2.3.4.5.6.7.8.9.10.11.12.13.14.
result = table.find({'age':{'$gt':20}}).distinct('name') #去 重

聚合

https://www.mongodb.com/zh-cn/docs/manual/aggregation/

from pymongo import MongoClient
from pymongo.synchronous.database import Database

url = 'mongodb://127.0.0.1:27017'
client = MongoClient(url)
db:Database = client['users']
table = db['users'] # db.users

# 聚合
result = table.aggregate([
    {'$match':{'age':{'$gte':20}}}, #过滤
    { '$count':'age'}
])
print(*result)
result = table.count_documents({'age':{'$gte':20}})
print(result)
client.close()
from pymongo import MongoClient
from pymongo.synchronous.database import Database

url = 'mongodb://127.0.0.1:27017'
client = MongoClient(url)
db:Database = client['users']
table = db['users'] # db.users

# 聚合
result = table.aggregate([
    {'$match':{'age':{'$gte':5}}},
    {'$group':{'_id':'$name', 'res':{'$sum':'$age'}}} # 注意前面加$。 按name分组,res为age合
])
print(*result)

client.close()

排序

  1. db.collection.find().sort(字段名,排序规则)
  2. db.collection.find().sort(list)
from pymongo import MongoClient, ASCENDING, DESCENDING
from pymongo.synchronous.database import Database

url = 'mongodb://127.0.0.1:27017'
client = MongoClient(url)
db:Database = client['users']
table = db['users'] # db.users

# 排序
result = table.find().sort('age', DESCENDING)
print(*list(result), sep='\n')
print('=' * 30)

result2 = table.find().sort([
    ('name', DESCENDING), # name降序
    ('age', ASCENDING) # age升序
])
print(*result2, sep='\n')

client.close()

分页

  1. db.collection.find().skip(n).limit(m) #分页,查询结果中跳过前n个,最多显示m个
  2. skip(n):从查询结果中跳过前面指定n个
  3. limit(m):从查询结果中最多只显示m个
  4. skip跳过几个,limit限制结果个数
from pymongo import MongoClient
from pymongo.collection import Collection

client = MongoClient("mongodb://127.0.0.1:27017")
db = client["blog"] #指定数据库
users:Collection = db.users #集合

# 分页
results = users.find()
print(*list(results),sep="\n")
print("- "*30)

results1 = users.find().skip(2) #跳过前2个
print(*list(results1),sep="\n")
print("- "*30)

results2 = users.find().skip(1).limit(2) #跳过前1个最多显示2个
print(*list(results2),sep="\n")
print("- "*30)

更新

更新操作符 含义 示例
$inc 对给定字段数字值增减 {"$inc":{"age":-5}}对age的值-5
$set 设置字段值,如果字段不存在则创建 {"$set":{"gender":"M"}}
$unset 移除字段 {"$unset":{"Name":""}}
  • db.collection.updateOne(查询条件dict,更新dict)方法只更新查询结果集中的第一个
  • db.collection.update_many(查询条件dict,更新dict)方法多行更新
  • db.collection.replace_one(查询条件,新文档dict) 更新一个文档,会将匹配到的第一个结果中的文档替换为新文档。
  • 替换文档,更新除 _id 外的所有字段

update_one更新第一个示例

from pymongo import MongoClient
from pymongo.synchronous.database import Database

url = 'mongodb://127.0.0.1:27017'
client = MongoClient(url)
db:Database = client['users']
table = db['users'] # db.users

# U
print(*table.find(), sep='\n')
print('-' * 30)

# 更新,将name为tom的结果中第一行文档中age减5
r = table.update_one({'name':'tom'}, {'$inc':{'age': -5}})
print(type(r), r.upserted_id, r.modified_count, r.matched_count)

print(*table.find(), sep='\n')

client.close()

update_many更新多行

from pymongo import MongoClient
from pymongo.collection import Collection

client = MongoClient("mongodb://127.0.0.1:27017")
db = client["blog"] #指定数据库
users:Collection = db.users #集合

print(*list(users.find()),sep="\n")
print("- "*30)

# 更新,将name为tom的字段都添加一个gender等于m的属性
result = users.update_many({"name":"tom"},{"$set":{"gender":"M"}})
print(type(result),result)
print(result.matched_count,result.modified_count)

print("- "*30)
print(*list(users.find()),sep="\n")
print("- "*30)

# 更新,将有name为tom的文档中包含Name属性的删除
result = users.update_many({"name":"tom"},{"$unset":{"Name":""}})
print(type(result),result)
print(result.matched_count,result.modified_count)

print("- "*30)
print(*list(users.find()),sep="\n")

replace_one替换一个文档

  • 更新除 _id 字段外的所有字段
from pymongo import MongoClient
from pymongo.collection import Collection

client = MongoClient("mongodb://127.0.0.1:27017")
db = client["blog"] #指定数据库
users:Collection = db.users #集合

print(*list(users.find()),sep="\n")
print("- "*30)

# 替换文档
result = users.replace_one({"name":"tom"},{"id":200,"name":"sam"}) #完全替换
print(type(result),result)

print("- "*30)
print(*list(users.find()),sep="\n")

全文索引

https://www.mongodb.com/zh-cn/docs/manual/text-search/

https://www.mongodb.com/zh-cn/docs/manual/core/indexes/create-index/

from pymongo import MongoClient, ASCENDING, DESCENDING
from pymongo.synchronous.database import Database

url = 'mongodb://127.0.0.1:27017'
client = MongoClient(url)
db:Database = client['users']
table = db['users'] # db.users

# 创建全文索引
r = table.create_index([('name', 'text'), ('age', DESCENDING)])
print(type(r), r)
print(*table.find(), sep='\n')

client.close()
from pymongo import MongoClient, ASCENDING, DESCENDING
from pymongo.synchronous.database import Database

url = 'mongodb://127.0.0.1:27017'
client = MongoClient(url)
db:Database = client['users']
table = db['users'] # db.users

# # 创建全文索引
# r = table.create_index([('name', 'text'), ('age', DESCENDING)])
# print(type(r), r)

# 全文搜索
r = table.find({'$text':{'$search': 'tom china'}}) # 搜索包含 tom或china的文档
print(*r, sep='\n')

client.close()

删除

  • db.collection.remove(条件dict) #删除,已过时的方法
  • db.collection.delete_one(条件dict) #将满足条件的结果删除第一行
  • db.collection.delete_many(条件dict)#将满足条件的结果全部删除
  • db.collection.delete_many({})删除所有文档,慎用
from pymongo import MongoClient
from pymongo.collection import Collection
from pymongo.results import DeleteResult

client = MongoClient("mongodb://127.0.0.1:27017")
db = client["blog"] #指定数据库
users:Collection = db.users #集合

print(*list(users.find()),sep="\n")
print("- "*30)

# 删除age为20的文档,只删除一条
result:DeleteResult = users.delete_one({"age":20})
print(type(result),result.deleted_count)

print("- "*30)
print(*list(users.find()),sep="\n")

# 删除所有存在age字段的文档
result2:DeleteResult = users.delete_many({"age":{"$exists":True}})
print(type(result2),result2.deleted_count)

print("- "*30)
print(*list(users.find()),sep="\n")

HTML解析-BeautifulSoup4

BeautifulSoup4

BeautifulSoup可以从HTML、XML中提取数据,目前BS4在持续开发。

https://www.crummy.com/software/BeautifulSoup/

官方中文文档https://www.crummy.com/software/BeautifulSoup/bs4/doc.zh/

安装

pip install bs4
#pip install beautifulsoup4

pip install lxml

初始化

BeautifulSoup(markup=““,features=None)

  • markup,被解析对象,可以是文件对象或者html字符串
  • feature指定解析器
  • return:返回一个文档对象
from bs4 import BeautifulSoup

#文件对象
soup = BeautifulSoup(open("test.html"))
# 标记字符串
soup = BeautifulSoup("<html>data</html>")

可以不指定解析器,就依赖系统已经安装的解析器库了。

解析器 使用方法 优势 劣势
Python标准库 BeautifulSoup(markup,"html.parser") Python的内置标准库执行速度适中文档容错能力强 Python 2.7.3、3.2.2前 的版本中文档容错能力差
lxml HTML 解析器 BeautifulSoup(markup,"lxml") 速度快文档容错能力强 需要安装C语言库
lxml XML 解析器 BeautifulSoup(markup,["lxml","xml"])BeautifulSoup(markup,"xml") 速度快唯一支持XML的解析器 需要安装C语言库
html5lib BeautifulSoup(markup,"html5lib") 最好的容错性以浏览器的方式解析文档生成HTML5格式的文档 速度慢不依赖外部扩展
  • BeautifulSoup(markup,"html.parser")使用Python标准库,容错差且性能一般。
  • BeautifulSoup(markup,"lxml")容错能力强,速度快。需要安装系统C库。
  • 推荐使用lxml作为解析器,效率高。
  • 需要手动指定解析器,以保证代码在所有运行环境中解析器一致。
  • 使用下面内容构建test.html使用bs4解析它
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>首页</title>
</head>
<body>
<h1>TEST</h1>
<div id="main">
    <h3 class="title highlight"><a href="http://www.python.org">python</a>高级班</h3>
    <div class="content">
        <p id="first">字典</p>
        <p id="second">列表</p>
        <input type="hidden" name="_csrf" value="absdoia23lkso234r23oslfn">
        <!-- comment -->
        <img id="bg1" src="http://www.cici.com/">
        <img id="bg2" src="http://httpbin.org/">
    </div>
</div>
<p>bottom</p>
</body>
from bs4 import BeautifulSoup

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')
    print(doc.prettify())

将html对象转化为DOM树,剩下是如何定位数据了。

四种对象

BeautifulSoup将HTML文档解析成复杂的树型结构,每个节点都是Python的对象,可分为4种:

  • BeautifulSoup、Tag、NavigableString、Comment
BeautifulSoup对象

代表整个文档。

Tag对象

对应着HTML中的标签。有2个常用的属性:

  1. name:Tag对象的名称,就是标签名称
  2. attrs:标签的属性字典
    • 多值属性,对于class属性可能是下面的形式, <h3 class="title highlight">python高级班</h3> 这个属性就是多值({"class":["title","highlight"]})
    • 属性可以被修改、删除
  3. BeautifulSoup.prettify() ​#带格式输出解析的文档对象(即有缩进的输出),注意:直接输出BeautifulSoup会直接输出解析的文档对象,没有格式。
  4. BeautifulSoup.div ​#输出匹配到的第一个div对象中的内容,返回对象是bs4.element.Tag类型
  5. BeautifulSoup.h3.get("class") ​#获取文档中第一个标签为h3对象中class属性值
from bs4 import BeautifulSoup

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    print(soup.builder)
    print(0,soup) #输出整个解析的文档对象(不带格式)
    print(1,soup.prettify()) #按照格式输出文档内容
    print("- "*30)
    div = soup.div
    print(2, div, type(div)) #类型bs4.element.Tag,Tag对象
    print(3, div.name, div.attrs)
    # 属性获取
    print(div.attrs['id'], div.attrs.get('id'), div.get('id'), div['id']) # 4种一样
    #print(3, div['class']) #KeyError, div没有class属性
    print(3, div.get('class')) #没有返回None

    h3 = soup.h3
    print(4, h3.get('class'), h3["class"], h3.attrs['class'], h3.attrs.get('class')) #多值属性 ['title', 'highlight']

    print(5,soup.img.get("src")) #获取img中src属性值
    soup.img["src"] = "http://www.ciciupdate.com" #修改值
    print(5,soup.img["src"])
    print(6,soup.a) #找不到返回None

    del soup.h3["class"] #删除属性
    print(4,soup.h3.get("class"))

注意:我们一般不使用声明这种方式来操作HTML,此代码时为了熟悉对象类型

范例

from bs4 import BeautifulSoup
from bs4.element import Tag

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')
    # print(doc.prettify()) #按照格式输出文档内容
    # print(type(doc)) # soup 对象
    # Tag object <=> soup object
    x:Tag = doc.p # soup.find('p') __getattr__ 找p标签
    print(type(x), x, x.name)  # bs4.element.Tag <p id="first">字典</p>
    # y = doc.div.div
    # print(y)

    print(x.attrs, type(x.attrs))
    print(x.attrs.get('id'), x.attrs['id'])
    print(x.get('id'), x['id']) # Tag __getitem__
    print('-' * 30)
    y = doc.h3
    print(type(y.attrs), y.attrs) # class 多值字典
    print(y.attrs.get('class'))
    print(y.get('class'), y['class'])
    print('=' * 30)
    img = doc.div.img
    print(img.get('src'))
    img['src'] = 'https://baidu.com' # Tag __setitem__
    print(img.get('src'))

    del img['src']
    print(img.get('src', 'nothing'))
NavigableString

如果只想输出标记的文本,而不关心标记的话,就要使用NavigableString.

print(soup.div.p.string) #第一个div下第一个p的字符串
print(soup.p.string) #同上

范例

from bs4 import BeautifulSoup
from bs4.element import Tag

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')
    print(doc.p.string)
    print(doc.div.string) # 仅有一个文本类型节点
    print(doc.h3.a.string, type(doc.h3.a.string)) # python <class 'bs4.element.NavigableString'>
注释对象

这就是HTML中的注释,它被BeautifulSoup解析后对应Comment对象。

遍历文档树

在文档树中找到关心的内容才是日常的工资,也就是说如何遍历树中的节点。使用上面的test.html来测试

使用Tag
  • soup.div可以找到从根节点开始查找第一个div节点,返回一个Tag对象
  • soup.div.p说明从根节点开始找到第一个div后返回一个Tag对象,这个Tag对象下继续找第一个p,找到返回Tag对象
  • soup.p返回了文字“字典”,而不是文字“bottom”说明遍历时 深度优先 ,返回也是Tag对象
遍历直接子节点
  • Tag.contents #将对象的所有类型直接子节点以列表方式输出
  • Tag.children #返回子节点的迭代器
    • Tag.children #等价于Tag.contents
遍历所有子孙节点
  • Tag.descendants ​#返回节点的所有类型子孙节点,可以看出迭代次序是深度优先
from bs4 import BeautifulSoup
from bs4.element import Tag

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    print(soup.p.string)
    print(soup.div.contents) #直接子标签列表
    print("- "*30)

    for i in soup.div.children: #直接子标签可迭代对象
        print(i.name)
    print("- "*30)
    print(list(map(
        lambda x:x.name if x.name else x,
        soup.div.descendants #所有子孙
    )))
遍历字符串

在前面的例子中,soup.div.string返回None,是因为string要求soup.div只能有一个NavigableString类型子节点,也就是这样 <div>only string</div>

如果div有很多子孙节点,如何提取字符串?

from bs4 import BeautifulSoup
from bs4.element import Tag

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    print(soup.div.string) #返回None,因为多余1个子节点
    print("".join(soup.div.strings) #返回迭代器,带多余的空白字符
    print("".join(soup.div.stripped_strings)) #返回迭代器,去除多余空白字符
遍历祖先节点
  • BeautifulSoup.parent ​#获取根节点的父结点,必定返回None,根节点没有父结点
  • Tag.parent #获取第一个Tag的父结点
  • Tag.parent.parent.get("id") #获取第一个tag的父结点的父结点的id属性
  • Tag.parents #获取Tag节点的所有父结点,由近及远
from bs4 import BeautifulSoup
from bs4.element import Tag

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    print(type(soup))
    print(soup.parent) #None 根节点没有父节点
    print(soup.div.parent.name) #body ,第一个div的父节点
    print(soup.p.parent.parent.get("id")) #取id属性, main
    print("- "*30)
    print(list(map(lambda x:x.name,soup.p.parents))) #父迭代器,由近及远
遍历兄弟节点
  • Tag.next_sibling ​#第一个Tag元素的下一个(下面)兄弟节点,注意:可能是一个文本节点
  • Tag.previous_sibling ​#第一个Tag元素之前的兄弟节点(上面),注意:可能是一个文本节点
  • Tag.next_siblings #获取Tag元素的下面的所有兄弟节点
print('{} [{}]'.format(1, soup.p.next_sibling)) #第一个p元素的下一个兄弟节点,注意可能是一个文本节点
print('{} [{}]'.format(2, soup.previous_sibling))
print(list(soup.p.next_siblings)) #所有同级兄弟,包括文本。previous_siblings
遍历其他元素*
  • Tag.next_element 是下一个可被解析的对象(字符串或tag),和下一个兄弟节点next_sibling不一样
  • Tag.next_elements #返回所有下一个可被解析的对象,是个可迭代对象。
from bs4 import BeautifulSoup

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    print(type(soup),type(soup.p))
    print(soup.p.next_element) #返回"字典"2个字
    print(soup.p.next_element.next_element.encode())
    print(soup.p.next_element.next_element.next_element)
    print(list(soup.p.next_elements))

    print("- "*30)
    #对比差异
    print(list(soup.p.next_elements))
    print(list(soup.p.next_siblings))

范例

from bs4 import BeautifulSoup
from bs4.element import Tag

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')
    print(doc.div.contents)
    print('-' * 30)
    print(*doc.div.children, sep='||')
    print(doc.parent)
    print(doc.div.parent.name)
    print(doc.div.parent.attrs)
    print(*map(lambda x:x.name, doc.div.p.parents)) #  只有Tag类型
    print('+' * 30)
    print(*doc.div.descendants, sep='|||') # 所有层次的子孙,也包括文本
    print('============')
    # 下一个元素
    print(doc.div.p.next_sibling) # 同辈的标签对象,包括文本
    print(doc.div.p.next_sibling.next_sibling)
    print(doc.div.p.next_sibling.next_sibling.next_sibling)
    print(doc.div.p.next_sibling.next_sibling.next_sibling.next_sibling)
    print(*doc.div.p.next_siblings, sep="***")
    print('~' * 30)
    print(doc.div.p.next_element) # 字典
    print(doc.div.p.next_element.next_element) # \n
    print(doc.div.p.next_element.next_element.next_element) # p
    print(doc.div.p.next_element.next_element.next_element.next_element) # 列表
    print('=' * 30)
    print(*doc.div.p.next_elements, sep='&&&')

搜索文档树

find系有很多分发,请执行查询帮助

find_all(name=None,attrs={},recursive=True,string=None,limit=None,**kwargs) #立即返回一个列表
name参数

官方称为 fiter过滤器 ,这个参数可以是一下

1 字符串:一个标签名称的字符串,会按照这个字符串全长匹配标签名

print(soup.find_all('p'))#返回文档中所有p标签

2 正则表达式对象:按照”正则表达式对象”的模式匹配标签名

import re
print(soup.find_all(re.compile("^h\d"))) #标签名以h开头后接数字

3 列表:或关系查找列表中的每个字符串

print(soup.find_all(["p","h1","h3"])) #或关系,找出列表所有的标签
print(soup.find_all(re.compile(r"^p|h|\d$"))) #使用正则表达式完成

4 True或None,则find_all返回全部非字符串节点、非注释节点,就是Tag标签类型

from bs4 import BeautifulSoup

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    print(list(map(lambda x: x.name, soup.find_all(True))))
    print(list(map(lambda x: x.name, soup.find_all(None))))
    print(list(map(lambda x: x.name, soup.find_all())))

源码中确实上面三种情况都返回的Tag类型

5 函数

  • 如果使用以上过滤器还不能提取想要的节点,可以使用函数,此函数仅只能*接收一个参数*。
  • 如果这个函数返回True,表示当前节点配置;返回False则是不匹配。
  • 示例:找出所有class属性且有多个值的节点(测试html中符合这个条件只有h3标签)
from bs4 import BeautifulSoup
from bs4.element import Tag

def many_classes(tag:Tag):
    # print(type(tag))
    # print(type(tag.attrs))
    return len(tag.attrs.get("class",[])) > 1

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    print(soup.find_all(many_classes))

范例: 找到出所有class属性且有多个值的节点

from bs4 import BeautifulSoup
from bs4.element import Tag
import re

# 请提取属性值是2个的标签对象
# def test(tag:Tag):
#     # print('+++++++++++++')
#     if isinstance(tag, Tag):
#         # print(type(tag), tag.name, tag.attrs.get('class'))
#         attr = tag.get('class')
#         if isinstance(attr, list):
#             if len(attr) >1:
#                 print(tag.name)
#                 return True
#     return False

# def test(tag:Tag):
#     return True if isinstance(tag, Tag) and isinstance(tag.get('class'), list) and len(tag.get('class')) >1 else False
# def test(tag:Tag):
#     return isinstance(tag, Tag) and isinstance(tag.get('class'), list) and len(tag.get('class')) >1
def many_values(tag:Tag):
    return isinstance(tag, Tag) and len(tag.get('class', [])) >1


with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')
    # BeautifulSoup Tag拥有 find find_all方法
    # 方法1
    # x = doc.find_all('div') # 找div标签
    # print(type(x), x)
    # print('-' * 30)
    # for i in x:
    #     print(type(i), i)
    #     print('<>'*30)

    # print(doc.find_all('p'))

    # 方法2
    print(doc.find_all(re.compile(r'h\d|p'))) # 包含有h数字的标签或者包含p的标签

    # 方法3
    print(doc.find_all(['p', 'a'])) # p 标签或a标签
    print(doc.find_all(re.compile(r'^(a|p)$'))) # p 标签或a标签
    print(doc.find_all(['p', 'a', re.compile(r'h\d')]))  # p 标签或a标签或者包含有h数字的标签

    # 方法4
    print(*map(lambda x:x.name, doc.find_all(None))) # find_all => Tag类型
    # 方法5
    print(doc.find_all(many_values))
keyword传参
  1. 使用关键字传参,如果参数名不是find系函数已定义的位置参数名,参数会被kwargs收集并被 当做标签的属性 来搜索。
  2. 属性的传参可以是字符串、正则表达式对象、True、列表。
from bs4 import BeautifulSoup
import re

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    print(soup.find_all(id="first")) #id为first的所有结点列表
    print(1,"- "*30)
    print(soup.find_all(id=re.compile("\w+"))) #相当于找有di的所有节点
    print(2,"- " * 30)
    print(soup.find_all(id=True)) #所有有id的节点

    print(3,"- " * 30)
    print(list(map(lambda x:x["id"],soup.find_all(id=True))))
    print(4,"- " * 30)
    print(soup.find_all(id=["first",re.compile(r"^sec")])) #指定id的名称列表
    print(5,"- " * 30)
    print(soup.find_all(id=True,src=True)) #相当于条件and,既有id又有src属性的节点列表

范例

import re

from bs4 import BeautifulSoup

def test(t):
    print('++++')
    if t:
        print(type(t), t)
        return True
    return False

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')

    # print(doc.find_all(id='main')) # **kwargs
    print(doc.find_all(id='first'))
    print('-' * 30)
    print(doc.find_all(src=True, id=True)) # and
    print(doc.find_all(id=['second', re.compile(r'\d$')])) # or关系
    print('-'*30)
    print(*doc.find_all(id=test), sep='||||||||') # 返回符合条件的Tag对象
css的class的特殊处理
  1. class是Python关键字,所以使用 class_ 。class是多值属性,可以匹配其中任意一个,也可以完全匹配。
print(soup.find_all(class_="content"))
print(soup.find_all(class_="title")) #可以使用任意一个css类
print(soup.find_all(class_="highlight")) #可以使用任意一个css类
print(soup.find_all(class_="highlight title")) #顺序错了,找不到
print(soup.find_all(class_="title highlight")) #顺序一致,找到。就是字符串完全匹配

范例

from bs4 import BeautifulSoup
import re

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')

    print(doc.find_all(class_=True))
    print(doc.find_all(class_='highlight')) # 全字符全长匹配。 匹配
    print(doc.find_all(class_='highlight title')) # 字符不匹配返回空
    print(doc.find_all(class_='title highlight')) # 匹配
    print('-' * 30)
    print(doc.find_all(class_=re.compile(r'e$')))
attrs参数
  • attrs接收一个字典,字典的key为属性名,value可以是字符串、正则表达式对象、True、列表。可以多个属性
print(soup.find_all(attrs={"class":"title"}))
print(soup.find_all(attrs={"class":"highlight"}))
print(soup.find_all(attrs={"class":"title highlight"}))
print(soup.find_all(attrs={"id":True}))
print(soup.find_all(attrs={"id":re.compile(r"\d$")}))
print(list(map(lambda x:x.name,soup.find_all(attrs={"id":True,"src":True})))) #并且and关系

范例

from bs4 import BeautifulSoup
import re

def test(t):
    print('++++')
    if t:
        print(type(t), t)
        return True
    return False

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')

    print(doc.find_all(
        'p', # p标签
        attrs={'id':[re.compile(r'sec'), 'first']} # id包含sec或者等于first
    )) # 满足p标签并且属性匹配
string参数
  • 可以通过string参数搜索文档中的字符串内容,接受字符串、正则表达式对象、True、列表。旧版本为text参数
from bs4 import BeautifulSoup
import re

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    print(list(map(lambda x:(type(x),x),soup.find_all(string=re.compile("\w+"))))) #返回文本类节点
    print("- "*30)
    print(list(map(lambda x:(type(x),x),soup.find_all(string=re.compile("[a-z]+")))))
    print("- "*30)
    print(soup.find_all(re.compile(r"^(h|p)"), string=re.compile("[a-z]+"))) #相当于过滤Tag对象,并看它的string是否符合text参数要求,返回Tag对象

范例:

from bs4 import BeautifulSoup
import re

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')

    print(doc.find_all(string=True)) # 独立使用string参数,返回元素是字符串
    print(list(map(lambda x:x.strip(), filter(lambda x:len(x.strip()) != 0, doc.find_all(string=True)))))
    print(doc.find_all(string=re.compile(r'[a-zA-Z]+')))
    print('=' * 30)
    # string不独立使用,返回就是Tag类型对象
    print(doc.find_all(href=True, string=re.compile(r'[a-zA-Z]+')))

#执行结果
# ['html', '\n', '\n', '\n', '首页', '\n', '\n', '\n', 'TEST', '\n', '\n', 'python', '高级班', '\n', '\n', '字典', '\n', '列表', '\n', '\n', ' comment ', '\n', '\n', '\n', '\n', '\n', 'bottom', '\n']
# ['html', '首页', 'TEST', 'python', '高级班', '字典', '列表', 'comment', 'bottom']
# ['html', 'TEST', 'python', ' comment ', 'bottom']
# [<a href="http://www.python.org">python</a>]
limit参数

显示返回结果的数量

print(soup.find_all(id=True,limit=3)) #返回列表中有3个结果
recursive参数
  • 默认是递归搜索所有子孙节点,如果不需要请设置为False
简化写法

find_all()是非常常用的方法,可以简化省略掉

from bs4 import BeautifulSoup
import re

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    print(soup("img")) #所有img标签对象的列表,等价于soup.find_all("img")
    print(soup.img) #深度优先第一个img, 调用的soup.find('img)

    print(soup.h3)
    print(soup.h3.find_all(string=True)) #返回文本
    print(soup.h3(string=True)) #返回文本,和上面等价
    print(soup("p", string=True)) #返回a标签对象

    print(soup.find_all("img",attrs={"id":"bg1"}))
    print(soup("img",attrs={"id":"bg1"})) #find_all的省略
    print(soup("img",attrs={"id":re.compile("1")}))

范例

from bs4 import BeautifulSoup
import re

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')

    print(doc.div.div) # 本质使用 __getattr__, 内部调用的find
    print(doc.find('div').find('div'))
    print('=' * 30)
    print(doc.find_all('p')) # 简写doc('p')
    print(doc('p')) # 使用__call__,内部调用find_all
find方法
find(name,attrs,recursive,string,**kwargs)
  • 参数几乎和find_all一样。
  • 找到了,find_all返回一个列表,而find返回一个单值,元素对象。
  • 找不到,find_all返回一个空列表,而find返回一个None。
print(soup.find("img",attrs={"id":"bg1"}).attrs.get("src","cici"))
print(soup.find("img",attrs={"id":"bg1"}).get("src")) #简化了attrs
print(soup.find("img",attrs={"id":"bg1"})["src"])

CSS选择器

  • 和JQuery一样,可以使用CSS选择器来 查找节点
  • 使用soup.select()方法,select方法支持大部分CSS选择器,返回列表。
  • CSS中,标签名直接使用,类名前加 . 点号,id名前加 # 井号。
  • BeautifulSoup.select("css选择器")
from bs4 import BeautifulSoup

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    #元素选择器
    print(1,soup.select("p")) #所有的p标签

    #类选择器
    print(2,soup.select(".title"))

    #使用了伪类
    #直接子标签是p的同类型的所有p标签中的第二个
    #(同类型)同标签名p的第2个,nth-of-type,且要求是数字
    #最新版本实现更多伪类支持
    #参考soupsieve.css_parser.CSSParser.parse_pseudo_class
    print(3,soup.select("div.content >p:nth-of-type(2)"))

    # id选择器
    print(4,soup.select("p#second"))
    print(5,soup.select("#bg1"))

    #后代选择器
    print(6,soup.select("div p")) # div下逐层找p
    print(7,soup.select("div div p")) #div下逐层找div下逐层找p

    #子选择器,直接后代
    print(8,soup.select("div > p")) #div下直接子标签的p,有2个

    #相邻兄弟选择器
    print(9, soup.select("div p:nth-of-type(1) + [src]")) #返回[]
    print(9, soup.select("div p:nth-of-type(1) + p"))  # 返回p标签
    print(9, soup.select("div > p:nth-of-type(2) + input"))  # 返回input Tag
    print(9, soup.select("div > p:nth-of-type(2) + [type]"))  # 同上

    #普通兄弟选择器
    print(10, soup.select("div p:nth-of-type(1) ~ [src]")) #返回2个img

    #属性选择器
    print(11,soup.select("[src]")) #有属性src
    print(12,soup.select("[src='/']")) #属性src等于/
    print(13,soup.select("[src='http://www.cici.com/']")) #完全匹配
    print(14,soup.select("[src^='http://www']")) #以http://www开头
    print(15,soup.select("[src$='com/']")) #以com/结尾
    print(16,soup.select("img[src*='cici']")) #包含cici
    print(17,soup.select("img[src*='.com']")) #包含.com
    print(18,soup.select("[class='title highlight']")) #完全匹配calss等于'title highlight'
    print(19,soup.select("[class~=title]")) #多值属性中有一个title

范例

from bs4 import BeautifulSoup
import re

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')
    # html解析3种方法 lxml xpath; bs4 find*; bs4 select css selector
    # 1 标签选择器
    print(doc.select('p')) # 相当于写xpath
    # 2 id
    print(doc.select('p#first'))
    # 3 group
    print(doc.select('p#first,p#second'))
    # 4 class
    print(doc.select('.title'))
    # 5 后代 descendants
    print(doc.select('div h3'))
    print(doc.select('div > h3'))
    # 6 伪类 pseudo-class
    print(doc.select('div.content > p:nth-of-type(2)'))
    # 7 sibling
    # + 直接兄弟
    print(doc.select('div.content > p:nth-of-type(2) + input'))
    # ~ 后续兄弟
    print(doc.select('div.content > p:nth-of-type(2) ~ input'))
    print(doc.select('div.content > p:nth-of-type(2) ~ img'))

    # 8 属性 attribute
    print(doc.select('[src],a[href]')) # 匹配src属性或者a标签中具有href属性的
    print(doc.select('[id=bg2]'))
    print(doc.select('[class="title highlight"]'))
    print(doc.select('[class~="title"]')) # 匹配多值属性中的一个
    print(doc.select('[class*="title"]')) # 包含title
    print(doc.select('[class^="title"]')) # 前面有title
    print(doc.select('[class$="highlight"]')) # 后面有highlight

获取文本内容

  1. 搜索节点的目的往往是为了提取该节点的文本内容,一般不需要HTML标记,只需要文字
from bs4 import BeautifulSoup

with open("test.html",encoding="utf-8") as f:
    soup = BeautifulSoup(f,"lxml")
    # 元素选择器
    ele = soup.select("div") #所有的div标签
    print(type(ele))
    print(ele[0].string) #内容仅仅只能是文本类型,否则返回None
    print(list(ele[0].strings)) #迭代保留空白字符
    print(list(ele[0].stripped_strings)) #迭代不保留空白字符

    print("- "*30)
    print(ele[0])
    print("- " * 30)

    print(list(ele[0].text))#本质上就是get_text(),保留空白字符的strings
    print(list(ele[0].get_text())) #迭代并join,保留空白字符,strip默认为False
    print(list(ele[0].get_text(strip=True))) #迭代并join,不保留空白字符

范例

from bs4 import BeautifulSoup
import re

with open('test.html', encoding='utf-8') as f:
    doc = BeautifulSoup(f, 'lxml')

    print(doc.find('div').string)
    print(*doc.find('div').strings)
    print(doc.find('div').text)
    print(*doc.find('div').stripped_strings, sep=',')
    print(doc.find('div').get_text(separator=',' , strip=True))
  • bs4.element.Tag#string源码
class Tag(PageElement):
@property
    def string(self):
        if len(self.contents) != 1:
            return None
        child = self.contents[0]
        if isinstance(child, NavigableString):
            return child
        return child.string

    @string.setter
    def string(self, string):
        self.clear()
        self.append(string.__class__(string))

    def _all_strings(self, strip=False, types=(NavigableString, CData)):
        for descendant in self.descendants:
            if (
                (types is None and not isinstance(descendant, NavigableString))
                or
                (types is not None and type(descendant) not in types)):
                continue
            if strip:
                descendant = descendant.strip()
                if len(descendant) == 0:
                    continue
            yield descendant

    strings = property(_all_strings)

    @property
    def stripped_strings(self):
        for string in self._all_strings(True):
            yield string

    def get_text(self, separator="", strip=False,
                 types=(NavigableString, CData)):
        return separator.join([s for s in self._all_strings(
                    strip, types=types)])
    getText = get_text
    text = property(get_text)

Json解析

拿到一个Json字符串,如果想提取其中的部分内容,就需要遍历了。在遍历过程中进行判断。

还有一种方式,类似于XPath,叫做jsonPath。

安装

pip install jsonpath

参考:

XPath JsonPath 说明
/ $ 根元素
. @ 当前节点
/ .=或者[]= 获取子节点
.. 不支持 父节点
// .. 任意层次
* * 通配符,匹配任意节点
@ 不支持 json中没有属性
[] [] 下标操作
= = [,] XPath是或操作,JSONPath allows alternate names or array indices as a set.
不支持 [start:stop:step] 切片
[] ?() 过滤操作
不支持 () 表达式计算
() 不支持 分组
{
    "subjects":[
        {
            "rate":"8.8",
            "cover_x":1500,
            "title":"寄生虫",
            "url":"https://movie.douban.com/subject/27010768/",
            "playable":false,
            "cover":"https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2561439800.jpg",
            "id":"27010768",
            "cover_y":2138,
            "is_new":false
        },
        {
            "rate":"7.7",
            "cover_x":1500,
            "title":"恶人传",
            "url":"https://movie.douban.com/subject/30211551/",
            "playable":false,
            "cover":"https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2555084871.jpg",
            "id":"30211551",
            "cover_y":2145,
            "is_new":false
        },
        {
            "rate":"6.6",
            "cover_x":1500,
            "title":"异地母子情",
            "url":"https://movie.douban.com/subject/26261189/",
            "playable":false,
            "cover":"https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2562107493.jpg",
            "id":"26261189",
            "cover_y":2222,
            "is_new":true
        },
        {
            "rate":"6.7",
            "cover_x":2025,
            "title":"我的生命之光",
            "url":"https://movie.douban.com/subject/26962841/",
            "playable":false,
            "cover":"https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2563625370.jpg",
            "id":"26962841",
            "cover_y":3000,
            "is_new":true
        },
        {
            "rate":"7.3",
            "cover_x":2025,
            "title":"皮肤",
            "url":"https://movie.douban.com/subject/27041467/",
            "playable":false,
            "cover":"https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2559479239.jpg",
            "id":"27041467",
            "cover_y":3000,
            "is_new":true
        },
        {
            "rate":"8.9",
            "cover_x":2000,
            "title":"绿皮书",
            "url":"https://movie.douban.com/subject/27060077/",
            "playable":true,
            "cover":"https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2549177902.jpg",
            "id":"27060077",
            "cover_y":3167,
            "is_new":false
        },
        {
            "rate":"8.0",
            "cover_x":3600,
            "title":"疾速备战",
            "url":"https://movie.douban.com/subject/26909790/",
            "playable":false,
            "cover":"https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2551393832.jpg",
            "id":"26909790",
            "cover_y":5550,
            "is_new":false
        },
        {
            "rate":"7.9",
            "cover_x":1786,
            "title":"流浪地球",
            "url":"https://movie.douban.com/subject/26266893/",
            "playable":true,
            "cover":"https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2545472803.jpg",
            "id":"26266893",
            "cover_y":2500,
            "is_new":false
        },
        {
            "rate":"8.2",
            "cover_x":684,
            "title":"沦落人",
            "url":"https://movie.douban.com/subject/30140231/",
            "playable":false,
            "cover":"https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2555952192.jpg",
            "id":"30140231",
            "cover_y":960,
            "is_new":false
        },
        {
            "rate":"6.4",
            "cover_x":960,
            "title":"疯狂的外星人",
            "url":"https://movie.douban.com/subject/25986662/",
            "playable":true,
            "cover":"https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2541901817.jpg",
            "id":"25986662",
            "cover_y":1359,
            "is_new":false
        }
    ]
}
from jsonpath import jsonpath
import requests
import json

ua = "Mozilla/5.0 (Windows; U; Windows NT 6.1; zh-CN) AppleWebKit/537.36 (KHTML, like Gecko) Version/5.0.1 Safari/537.36"
url = "https://movie.douban.com/j/search_subjects?type=movie&tag=%E7%83%AD%E9%97%A8&page_limit=10&page_start=0"

with requests.get(url,headers={"User-agent":ua}) as response:
    if response.status_code==200:
        text = response.text
        print(text[:100])
        js = json.loads(text)
        print(str(js)[:100]) #json转换为Python数据结构

        #知道所有电影的名称
        rs1 = jsonpath(js,"$..title") #从根目录开始,任意层次的title属性
        print(rs1)

        #找到所有subjects
        rs2 = jsonpath(js,"$..subjects")
        print(len(rs2),str(rs2[0])[:100]) #由于太长,取前100个字符

        print("- " * 30)
        # 找到所有得分高于8分的电影名称
        # 根下任意层的subjects的子节点rate大于字符串8
        rs3 = jsonpath(js,'$..subjects[?(@.rate > "8")]') #?()是过滤器
        print(rs3)

        print("- "*30)
        #根下任意层的subjects的子节点rate大于字符串8的节点的子节点title
        rs4 = jsonpath(js,'$..subjects[?(@.rate > "8")].title')
        print(rs4)
        print("- " * 30)

        #切片
        rs5 = jsonpath(js,"$..subjects[?(@.rate > '6')].title")
        print(rs5[:2])

范例

from jsonpath import jsonpath
import json
import requests

# url = 'https://movie.douban.com/j/search_subjects?type=movie&tag=%E7%83%AD%E9%97%A8&page_limit=10&page_start=0'
#
# headers = {
#     'User-agent': "Mozilla/5.0 (Windows; U; Windows NT 6.1; zh-CN) AppleWebKit/537.36 (KHTML, like Gecko) Version/5.0.1 Safari/537.36"
# }
#
# with requests.get(url, headers=headers) as response:
#     if response.status_code == 200:
#         # text = response.text
#         # print(type(text), text)
#         with open('a.json', 'wb') as f:
#             f.write(response.content)

with open('a.json', encoding='utf8') as f:
    j = json.loads(f.read())
    x = jsonpath(j, '$..title') # 内部异常不抛出,错了就False
    print(x)

    # 拿到所有title和url
    print(jsonpath(j, '$..title,url')) # xpath //tite|//url

    # 获取8分以上
    # 找到所有得分高于8分的电影名称
    # 根下任意层的subjects的子节点rate大于字符串8
    print(jsonpath(j, '$..subjects[?(@.rate > "8")]')) # 8分以上信息
    print(jsonpath(j, '$..subjects[?(@.rate > "8")].title,url'))  # 8分以上title,url
    print(jsonpath(j, '$..subjects[?(@.rate > "8")].title,url')[:2]) # 8分以上title和url,切片

RabbitMQ

RabbitMQ是由LShift提供的一个Advanced Message Queuing Protocol (AMQP)的开源实现,由以高性能、健壮以及可伸缩性出名的Erlang写成,因此也是继承了这些些优点。

很成熟,久经考验,应用广泛。

文档详细,客户端丰富,几乎常用语言都有RabbitMQ的开发库

部署

安装

https://www.rabbitmq.com/docs/install-rpm

  • 选择RPM包下载,选择对应平台,本次安装在Rocky,其他平台类似。

由于使用了erlang语言开发,所以需要erlang的包。erlang和RabbitMQ的兼容性,参考https://www.rabbitmq.com/docs/which-erlang#compatibility-matrix

  • 下载 rabbitmq-server-3.7.16-1.el7.noarch.rpm、erlang-21.3.8.6-1.el7.x86_64.rpm。socat在CentOS中源中有。
yum -y install erlang-21.3.8.6-1.el7.x86_64.rpm
rabbitmq-server-3.7.16-1.el7.noarch.rpm
  • 查看安装的文件
[root@xdd ~]# rpm -ql rabbitmq-server 
/etc/logrotate.d/rabbitmq-server
/etc/profile.d/rabbitmqctl-autocomplete.sh
/etc/rabbitmq
/usr/lib/ocf/resource.d/rabbitmq/rabbitmq-server
/usr/lib/ocf/resource.d/rabbitmq/rabbitmq-server-ha
/usr/lib/rabbitmq/autocomplete/bash_autocomplete.sh
/usr/lib/rabbitmq/autocomplete/zsh_autocomplete.sh
/usr/lib/rabbitmq/bin/cuttlefish
/usr/lib/rabbitmq/bin/rabbitmq-defaults

配置

环境配置
  • 使用系统环境变量,如果没有使用rabbitmq-env.conf中定义环境变量,否则使用缺省值
RABBITMQ_NODE_IP_ADDRESS the empty string, meaning that it should bind to all network interfaces.  
RABBITMQ_NODE_PORT 5672  
RABBITMQ_DIST_PORT RABBITMQ_NODE_PORT + 20000  #内部节点和客户端工具通信用  
RABBITMQ_CONFIG_FILE 配置文件路径默认为/etc/rabbitmq/rabbitmq  

环境变量文件,可以不配置

工作特性配置文件
  • rabbitmq.config配置文件
  • 3.7支持新旧两种配置文件格式
    1. erlang配置文件格式,为了兼容继续采用
    2. sysctl格式,如果不需要兼容,RabbitMQ鼓励使用。 (这个文件也可以不配置)

插件管理

列出所有可用插件

rabbitmq-plugins list
  • 启动WEB管理插件,会依赖启用其他几个插件。
rabbitmq-plugins enable rabbitmq_management

启动服务

systemctl start rabbitmq-server
[root@xdd ~]# ss -tanl | grep 5672
LISTEN     0      128          *:25672                    *:*
LISTEN     0      128          *:15672                    *:*
LISTEN     0      128         :::5672                    :::*
[root@xdd ~]#
容器方式启动服务
# 拉取docker镜像
docker pull rabbitmq:management
mkdir -p docker/rabbitmq

docker run -d --name=rabbitmq \
  -v ./docker/rabbitmq:/var/lib/rabbitmq \
  -p 15672:15672 -p 5672:5672 \
  -e RABBITMQ_DEFAULT_USER=admin \
  -e RABBITMQ_DEFAULT_PASS=admin \
  rabbitmq:management

# http://<IP>:15672 用户名和密码: admin

用户管理

  • 开始登陆WEB界面, http://<ip>:15672
  • 使用guest/guest只能本地登陆,远程登录会报错

rabbitmqctl命令

└─$ docker exec -i rabbitmq  rabbitmqctl          
rabbitmqctl [--node <node>] [--timeout <timeout>] [--longnames] [--quiet] <command> [<command options>]
Users:
   add_user                                      添加用户
   authenticate_user                             Attempts to authenticate a user. Exits with a non-zero code if authentication fails.
   change_password                               修改用户名,密码
   clear_password                                Clears (resets) password and disables password login for a user
   clear_user_limits                             Clears user connection/channel limits
   delete_user                                   删除用户
   list_user_limits                              Displays configured user limits
   list_users                                    列出用户
   set_user_limits                               Sets user limits
   set_user_tags                                 设置用户tag

docker exec -i rabbitmq  rabbitmqctl  help add_user
  • 添加用户: rabbitmqctl add_user username password
  • 删除用户: rabbitmqctl delete_user username
  • 更改密码: rabbitmqctl change_password username newpassword
  • 设置权限Tags,其实就是分配组: rabbitmqctl set_user_tags username tag

用户tag

  • management 用户可以访问管理插件
  • policymaker 用户可以访问管理插件并管理他们有权访问的虚拟主机的策略和参数。
  • monitoring 用户可以访问管理插件并查看所有连接和通道以及节点相关信息。
  • administrator 用户可以执行监控可以执行的所有操作,管理用户、虚拟主机和权限,关闭其他用户的连接,以及管理所有虚拟主机的策略和参数。

设置jasper用户为管理员tag后登陆

rabbitmqctl add_user jasper 123456  #添加jasper用户
rabbitmqctl list_users #查看所有用户
rabbitmqctl set_user_tags jasper administrator #设置admin用户为管理员用户
  • tag的意义如下:
    1. administrator可以管理用户、权限、虚拟主机。
  • 基本信息(web管理端口15672,协议端口5672)
  • 虚拟主机
    1. 缺省虚拟主机,默认只能是guest用户在本机链接,下图新建的用户gdy默认无法访问任何虚拟主机

虚拟主机

/ 为确实虚拟主机

缺省虚拟主机,默认只是guest用户在本机连接。新建用户默认无法访问任何虚拟主机。

这里我们使用jasper用户在Admin页面中创建一个虚拟主机 test。

Pika库

Pika是纯Python实现的支持AMQP协议的库

pip install pika

RabbitMQ工作原理及应用

工作模式

参考官网 https://www.rabbitmq.com/tutorials

  1. 简单列表模式
  2. 工作队列模式
  3. 发布文章模式
  4. 路由模式
  5. 话题模式
  6. RPC模式

名词解释

名词 说明
Server 服务器。
  接受客户端连接,实现消息队列及路由功能的进程(服务),也称为消息代理
  注意:客户端可用生产者,也可以是消费者,它们都需要连接到Server
Connection 网络物理连接
Channel 一个连接允许多个客户端连接
Exchange 交换器。接收生产者发来的消息,决定如何 路由 给服务器中的队列。
  常用的类型有:direct(point-to-point) 路由模式; topic(publish-subscribe) ; fanout(multicast)广播模式
Message 消息
Message Queue 消息队列,数据的存储载体
Bind 绑定。
  建立消息队列和交换器之间的关系,也就是说交换器拿到数据,把什么样的数据送给哪个队列
Virtual Host 虚拟主机。
  一批交换器、消息队列和相关对象的集合。为了多用户互不干扰,使用虚拟主机分组交换机,消息队列
Topic 主题、话题
Broker 可等价为Server

1.队列 *

参照官方例子,写一个小程序

# send.py
import pika
from pika.adapters.blocking_connection import BlockingChannel

#构建用户名密码对象
credential = pika.PlainCredentials("jasper","123456")
# 配置链接参数
params = pika.ConnectionParameters(
    "192.168.61.108",#ip地址
    5672,  #端口
    "test",#虚拟机
    credential #用户名密码
)

# # 第二种建立连接方式
# params = pika.URLParameters("amqp://jasper:[email protected]:5672/test")

# 建立连接
connection = pika.BlockingConnection(params)

with connection:
    # 建立通道
    channel:BlockingChannel = connection.channel()

    #创建一个队列,queue命名为hello,如果queue不存在,消息将被dropped
    channel.queue_declare(queue="hello")

    channel.basic_publish(
        exchange="",#使用缺省exchange
        routing_key="hello", #routing_key必须指定,这里要求和目标queue一致
        body="Hello world" #消息
    )
    print("消息发送成功Sent Message OK")

测试通过。去服务管理界面查看Exchanges和Queues。

注意:出现如下运行结果

pika.exceptions.ProbableAuthenticationError: (403, 'ACCESS_REFUSED - Login was refused using authentication mechanism PLAIN. For details see the broker logfile.')
  • 访问被拒绝,还是权限问题,原因还是guest用户只能访问localhost上的缺省虚拟主机

解决办法

  1. 缺省虚拟主机,默认只能在本机访问,不要修改为远程访问,是安全的考虑。
  2. 因此,在Admin中Virtual hosts中,新建一个虚拟主机test。
  3. 注意:新建的test虚拟主机的Users是谁,本次是gdy用户

在ConnectionParameters中没有用户名、密码填写的参数,它使用参数credentials传入,这个需要构建一个pika.credentials.Credentials对象。

URLParameters,也可以使用URL创建参数

# amqp://username:password@host:port/<virtual_host>[?query-string] 
parameters = pika.URLParameters('amqp://guest:guest@rabbit-server1:5672/%2F') 
# %2F指代/,就是缺省虚拟主机
  1. queue_declare声明一个queue,有必要的话,创建它。
  2. basic_publish exchange为空就使用缺省exchange,如果找不到指定的exchange,抛异常

使用缺省exchange,就必须指定routing_key,使用它找到queue

修改上面生产者代码,让生产者连续发送send Message。在web端查看Queues中Ready的变化

# send.py
import pika
from pika.adapters.blocking_connection import BlockingChannel
import time

# 第二种建立连接方式
params = pika.URLParameters("amqp://jasper:[email protected]:5672/test")
# 建立连接
connection = pika.BlockingConnection(params)

with connection:
    # 建立通道
    channel:BlockingChannel = connection.channel()

    #创建一个队列,queue命名为hello,如果queue不存在,消息将被dropped
    channel.queue_declare(queue="hello")

    for i in range(40):

        channel.basic_publish(
            exchange="",#使用缺省exchange
            routing_key="hello", #routing_key必须指定,这里要求和目标queue一致
            body="data{:02}".format(i) #消息
        )
        time.sleep(0.5)
    print("消息发送成功Sent Message OK")

范例

import pika
import time
#from pika.credentials import PlainCredentials
# sender
# param = pika.ConnectionParameters(
#     '192.168.226.130', '5672',
#     'test', #虚拟机
#     PlainCredentials('jasper', '123456')
# )
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

# 建立连接
connection = pika.BlockingConnection(param)
channel = connection.channel() # 建立通道

channel.queue_declare(queue='hello')
# 生产端声明。如果不存在,立即创建该q。创建的q不会因为连接中断而删除

for i in range(40):
    channel.basic_publish(exchange='', # 不指定,默认缺省 test/default
                          routing_key='hello', # q的名字
                          body='data-{:2}'.format(i))
    time.sleep(1)
print(" [x] Sent 'Hello World!'")

connection.close()

构建receive.py消费者代码

单个消费消息

  • BlockingChannel.basic_get("queue名称",是否阻塞)->(method,props,body)
    • body为返回的消息
# receie.py
import pika
from pika.adapters.blocking_connection import BlockingChannel

# 建立连接
params = pika.URLParameters("amqp://jasper:[email protected]:5672/test")
connection = pika.BlockingConnection(params)

with connection:
    channel:BlockingChannel = connection.channel()
    msg = channel.basic_get("hello",True) #从名称为hello的queue队列中获取消息,获取不到阻塞
    method,props,body = msg #拿不到的消息tuple为(None,None,None)
    if body:
        print("获取到了一个消息Get A message = {}".format(body))
    else:
        print("没有获取到消息empty")

获取到消息后msg的结构如下:

(<Basic.GetOk(['delivery_tag=1', 'exchange=', 'message_count=38', 'redelivered=False', 'routing_key=hello'])>, <BasicProperties>, b'data01')  
返回元组:(方法method,属性properties,消息body)
无数据返回:(None,None,None)

批量消费消息recieve.py

# receie.py 消费代码
import pika
from pika.adapters.blocking_connection import BlockingChannel

# 建立连接
params = pika.URLParameters("amqp://jasper:[email protected]:5672/test")
connection = pika.BlockingConnection(params)

def callback(channel,method,properties,body):
    print("Get a message = {}".format(body))

with connection:
    channel:BlockingChannel = connection.channel()
    channel.basic_consume(
        "hello",#队列名
        callback,#消费回调函数
        True,#不回应
    )
    print("等待消息,退出按CTRL+C;Waiting for messages. To exit press CTRL+C")
    channel.start_consuming()
import pika
# receive
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

connection = pika.BlockingConnection(param)
channel = connection.channel()

#channel.queue_declare(queue='hello') # q生产端创建的,我们直接拿就行了
def callback(ch, method, properties, body):
    print(f" [x] Received {body}")

channel.basic_consume(queue='hello',
                      auto_ack=True,
                      on_message_callback=callback)

channel.start_consuming()  # 阻塞

2.工作队列 **

两个种方式:

  • 继续使用队列模式的生产者和消费者代码,启动2个消费者
  • 修改消费者代码,增加basic_consume方法
import pika
# receive 消费者代码
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')
connection = pika.BlockingConnection(param)

def callback(ch, method, properties, body):
    print(f" [x] Received {body}")
def callback1(ch, method, properties, body):
    print(f" [y] Received {body}")

with connection:
    channel = connection.channel()
    channel.queue_declare(queue='hello')  # 生产端和消费端都要看看q是否存在,不存在就创建。
    # 消费者,每一个消费者使用一个basic_consume
    channel.basic_consume(queue='hello',
                          auto_ack=True,
                          on_message_callback=callback)
    channel.basic_consume(queue='hello',
                          auto_ack=True,
                          on_message_callback=callback1)
    print('Waiting for message. To exit press CTRL+C')
    channel.start_consuming()  # 阻塞

观察结果,可以看到,2个消费者是交替拿到不同的消息。

  • 这种工作模式是一种竞争工作方式,对某一个消息来说,只能有有一个消费者拿走它。
  • 从结果知道,使用的是轮询方式拿走数据的。

如果启动2个消费者解释器进程,实际上就有了4个消费者,还是是采用轮询来获取消息。

注意:虽然上面的图中没有画出exchange,用到缺省exchange。

应答

消息队列一般需要缓冲成千上万条消息,队列中消息只有一份,只能给一个消费者处理。消费者读取一个消息后,需要给RabbitMQServer一个确认(acknowledgement),然后RabbitMQ才会删除它。

默认basic_consume中auto_ack为False,也就是需要手动确认收到到了,不会自动回应。

持久化

交换机、队列都不会持久化,如需持久化需要未交换机、队列设置durable为True。

消息持久化,需要队列首先持久化,然后生产者发布消息时增加持久化属性。

#生产端
# 消息持久化 1. 队列必须持久化 2.改变消息的属性值
for i in range(50):
    channel.basic_publish(exchange='', # 不指定,默认缺省 test/default
                          routing_key='hello', # q的名字
                          properties=pika.BasicProperties(delivery_mode=2),
                          # 持久化数据,队列必须持久化
                          body='data-{:2}'.format(i))
#消费端
# durable=True
# channel.exchange_declare(durable=True)
channel.queue_declare(queue='hello') #durable=True)

特殊注意,持久化不能保证百分百消息不丢失,如果数据在缓存中,还未真正写入磁盘,数据还是有部分丢失风险。

公平分发

上面的轮询方式,不管消费者是否空闲还是繁忙,只是看似公平的分发,但其实Server没有关注消费者未确认消息数。

使用 basic_qos(prefetch_count=1) 来解决,该方法告诉RabbitMQ不不要一直给消费者发多条消息,如果消费者为确认上一条消息,就不要给它发了,发给别的不忙的消消费者

范例

生产者

import pika
import time
#from pika.credentials import PlainCredentials
# sender
# param = pika.ConnectionParameters(
#     '192.168.226.130', '5672',
#     'test', #虚拟机
#     PlainCredentials('jasper', '123456')
# )
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

# 建立连接
connection = pika.BlockingConnection(param)
channel = connection.channel() # 建立通道

channel.queue_declare(queue='hello')
# 生产端声明。如果不存在,立即创建该q。创建的q不会因为连接中断而删除

# 消息持久化 1. 队列必须持久化 2.改变消息的属性值
for i in range(50):
    channel.basic_publish(exchange='', # 不指定,默认缺省 test/default
                          routing_key='hello', # q的名字
                          properties=pika.BasicProperties(delivery_mode=2),
                          # 持久化数据,队列必须持久化
                          body='data-{:2}'.format(i))
    time.sleep(1)
print(" [x] Sent 'Hello World!'")

connection.close()

消费者1

import pika
import time
# receive
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

connection = pika.BlockingConnection(param)
channel = connection.channel()

# durable=True
# channel.exchange_declare(durable=True)
channel.queue_declare(queue='hello') #durable=True)

def callback(ch, method, properties, body):
    ctag = method.consumer_tag
    dtag = method.delivery_tag
    time.sleep(5) # 故意放慢
    print(f"{ctag} {dtag} ** {body}")
    channel.basic_ack(dtag) # 手动应答

# 返回值为消费者号
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue='hello',
                      auto_ack=False,
                      on_message_callback=callback)

channel.start_consuming()  # 阻塞

消费者2

import pika
import time
# receive
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

connection = pika.BlockingConnection(param)
channel = connection.channel()

# durable=True
# channel.exchange_declare(durable=True)
channel.queue_declare(queue='hello') #durable=True)

def callback(ch, method, properties, body):
    ctag = method.consumer_tag
    dtag = method.delivery_tag
    print(f"{ctag} {dtag} ** {body}")
    channel.basic_ack(dtag) # 手动应答

# 返回值为消费者号
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue='hello',
                      auto_ack=False,
                      on_message_callback=callback)

channel.start_consuming()  # 阻塞

3.发布、订阅模式(Publish/Subscribe)

Publish/Subscribe发布订阅,想象一下订阅者(消费者)订阅这个报纸(消息),都应该拿到一份同样内容的报纸。

订阅者和消费者之间还有一个exchange,可以想象成邮局,消费者去邮局订阅报纸,报社发报纸到邮局,邮局决定如何投递到消费者手中。

上例子中工作队列模式的使用,相当于,每个人只能拿到不同的报纸。所以不适合发布订阅模式。

img_20250826_165145.png

当模式的exchange的type是fanout,就是一对多,即广播模式。

注意,同一个queue的消息只能被消费一次,所以,这里使用了多个queue,相当于为了保证不同的消费者拿到同样的数据,每一个消费者都应该有自己的queue。

# 生成一个交换机
channel.exchange_declare(
    exchange="logs", #新交换机
    exchange_type="fanout" #广播
)

生产者使用 广播模式 。在test虚拟主机中构建了一个logs交换机。这个交换机是不持久的,服务重启就消失了。一般也不需要持久,如果需要持久,请设置参数durable=True。

  1. 至于queue,可以由生产者创建,也可以由消费者创建。
  2. 本次采用使用消费者端创建,生产者把数据都发往交换机logs,采用了fanout,然后将数据通过交换机发往已经绑定到此交换机的所有queue。

绑定Bingding,建立exchange和queue之间的联系

# 消费者端
result =channel.queue_declare(queue="") #生成一个随机名称的queue
result = channel.queue_declare(queue="",exclusive=True) #生成一个随机名称的queue,并在断开链接时删除queue
# exclusive意思是只允许当前connection访问,当前连接断开时删除queue

# 生成queue
q1:Method = channel.queue_declare(queue="",exclusive=True)
q2:Method = channel.queue_declare(queue="",exclusive=True)
q1name = q1.method.queue #可以通过result.method.queue 查看随机名称
q2name = q2.method.queue
print(q1name,q2name)

#绑定
channel.queue_bind(exchange="logs",queue=q1name)
channel.queue_bind(exchange="logs",queue=q2name)

生成者代码

  1. 注意观察 交换机和队列
# send.py 生产者代码
import pika
import time

# 建立连接
params = pika.URLParameters("amqp://jasper:[email protected]:5672/test")
connection = pika.BlockingConnection(params)
channel = connection.channel()

#指定交换机和模式
exchange_name = 'logs'
channel.exchange_declare(
    exchange=exchange_name, #新交换机
    exchange_type="fanout" #扇出,广播
)

with connection:
    for i in range(40):
        channel.basic_publish(
            exchange=exchange_name, #使用指定的exhcange
            routing_key="", #广播模式,不指定routing_key
            body = "data-{:02}".format(i) #消息
        )
        time.sleep(0.01)
    print("Send OK")

特别注意:如果先开启生产者,由于没有队列queue,请观察数据. 数据消失。应该提前启动消费者。

消费者代码

  1. 构建queue并绑定到test虚拟机的logs交换机上
# receie.py 消费者代码

import time
import pika

exchange_name = 'logs'
connection = pika.BlockingConnection(pika.URLParameters("amqp://jasper:[email protected]:5672/test"))
channel = connection.channel()
# 指定交换机
channel.exchange_declare(
    exchange=exchange_name, #新交换机
    exchange_type="fanout" #扇出,广播
)

# 生成队列,名称随机,exclusive=True断开删除队列
q1 = channel.queue_declare(queue="",exclusive=True)
q2 = channel.queue_declare(queue="",exclusive=True)
name1 = q1.method.queue #查看队列名
name2 = q2.method.queue

# 绑定到交换机
channel.queue_bind(exchange=exchange_name, queue=name1)
channel.queue_bind(exchange=exchange_name, queue=name2)

def callback(channel,method,properties,body):
    print("{}\n{}".format(channel,method))
    print("Get a message = {}".format(body))

with connection:
    # 消费者,每一个消费者使用一个basic_consume
    channel.basic_consume(queue=name1,
                    auto_ack=True,
                    on_message_callback=callback)
    channel.basic_consume(queue=name2,
                    auto_ack=True,
                    on_message_callback=callback)

    print("Waiting for messages. To exit press CTRL+C")
    channel.start_consuming()

先启动消费者receie.py可以看到已经创建了exchange

如果exchange是fanout,也就是广播了,routing_key就不用关心了。

q1 = channel.queue_declare(queue="",exclusive=True)
q2 = channel.queue_declare(queue="",exclusive=True)
  • 尝试先启动生产者,再启动消费者试试看。
  • 会导致部分数据丢失。因为:exchange收了数据,没有queue接受,所以,exchange丢弃了这些数据。

范例:

  • 生产者
import pika
import time
#from pika.credentials import PlainCredentials
# sender
# param = pika.ConnectionParameters(
#     '192.168.226.130', '5672',
#     'test', #虚拟机
#     PlainCredentials('jasper', '123456')
# )
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

# 建立连接
connection = pika.BlockingConnection(param)
channel = connection.channel() # 建立通道

ex_name = 'logs'
ex_type = 'fanout'
channel.exchange_declare(exchange=ex_name,
                         exchange_type=ex_type)

# channel.queue_declare(queue='hello')
# 生产端声明。如果不存在,立即创建该q。创建的q不会因为连接中断而删除

# 消息持久化 1. 队列必须持久化 2.改变消息的属性值
for i in range(50):
    channel.basic_publish(exchange=ex_name, # 不指定,默认缺省 test/default
                          routing_key='', # q的名字
                          # properties=pika.BasicProperties(delivery_mode=2),
                          # 持久化数据,队列必须持久化
                          body='data-{:2}'.format(i)
    time.sleep(0.5)
print(" [x] Sent 'Hello World!'")

connection.close()
  • 消费者
import pika
import time
# receive
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

connection = pika.BlockingConnection(param)
channel = connection.channel()

ex_name = 'logs'
ex_type = 'fanout'
# 生成交换机
channel.exchange_declare(exchange=ex_name,
                         exchange_type=ex_type)
result = channel.queue_declare(queue='', exclusive=True)
q_name1 = result.method.queue
channel.queue_bind(exchange=ex_name, queue=q_name1)
result2 = channel.queue_declare(queue='', exclusive=True)
q_name2 = result2.method.queue
channel.queue_bind(exchange=ex_name, queue=q_name2)

def callback(ch, method, properties, body):
    ctag = method.consumer_tag
    dtag = method.delivery_tag
    # time.sleep(5)
    print(f"{ctag} {dtag} ** {body}")
    channel.basic_ack(dtag) # 手动应答

# 返回值为消费者号
# channel.basic_qos(prefetch_count=1)
c1 = channel.basic_consume(queue=q_name1,
                      auto_ack=False,
                      on_message_callback=callback)
c2 = channel.basic_consume(queue=q_name2,
                      auto_ack=False,
                      on_message_callback=callback)
channel.start_consuming()  # 阻塞

4.路由模式Routing *

img_20250826_182122.png

路由其实就是生产者的数据经过exhange的时候,通过匹配规则,决定数据的去向。

  • 生产者代码,交换机类型为direct,指定路由的key
import pika
import time
import random
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

# 建立连接
connection = pika.BlockingConnection(param)
channel = connection.channel() # 建立通道

# 交换机
ex_name = 'colors'
ex_type = 'direct'
colors = ('orange', 'black', 'green')
channel.exchange_declare(exchange=ex_name,
                         exchange_type=ex_type) # 路由

# channel.queue_declare(queue='hello')
# 生产端声明。如果不存在,立即创建该q。创建的q不会因为连接中断而删除

# 消息持久化 1. 队列必须持久化 2.改变消息的属性值
for i in range(50):
    rk = random.choice(colors)
    channel.basic_publish(exchange=ex_name, # 不指定,默认缺省 test/default
                          routing_key=rk, # q的名字
                          # properties=pika.BasicProperties(delivery_mode=2),
                          # 持久化数据,队列必须持久化
                          body='{}-data-{:2}'.format(rk, i))
    time.sleep(0.5)
print(" [x] Sent 'Hello World!'")

connection.close()
  • 消费者代码
import pika
import time
# receive
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

connection = pika.BlockingConnection(param)
channel = connection.channel()

ex_name = 'colors'
ex_type = 'direct'
colors = ('orange', 'black', 'green')
# 生成交换机
channel.exchange_declare(exchange=ex_name,
                         exchange_type=ex_type)

# 生成队列,名称随机,exclusive=True断开删除该队列
result = channel.queue_declare(queue='', exclusive=True)
q_name1 = result.method.queue
# 绑定到交换机,而且一定要绑定routing_key
channel.queue_bind(exchange=ex_name, queue=q_name1, routing_key=colors[0])
# channel.queue_bind(q_name1, ex_name, colors[0])

result2 = channel.queue_declare(queue='', exclusive=True)
q_name2 = result2.method.queue
channel.queue_bind(q_name2, ex_name, colors[1])
channel.queue_bind(q_name2, ex_name, colors[2])

def callback(ch, method, properties, body):
    ctag = method.consumer_tag
    dtag = method.delivery_tag
    # time.sleep(5)
    print(f"{ctag} {dtag} ** {body}")
    # channel.basic_ack(dtag) # 手动应答

# 返回值为消费者号
# channel.basic_qos(prefetch_count=1)
c1 = channel.basic_consume(queue=q_name1,
                      auto_ack=True, # 自动应答
                      on_message_callback=callback)
c2 = channel.basic_consume(queue=q_name2,
                      auto_ack=True,
                      on_message_callback=callback)
channel.start_consuming()  # 阻塞
connection.close()

注意:如果routing_key设置一样,绑定的时候指定routing_key='black'。和fanout就类似了,都是1对多,但是不同

  1. 因为fanout时,exchange不做数据过滤,1个消息,所有绑定的queue都会拿到一个副部。
  2. direct时候,要按照routing_key分配数据,上图的black有2个queue设置了,就会把1个消息分发给这2个queue。

5.Topic话题

Topic就是更加高级的路由,支持模式匹配而已。

Topic的routing_key必须使用=.=点号分割的单词组成。最多255个字节。

支持使用通配符:

  1. *表示严格的一个单词
  2. #表示0个或多个单词

如果queue绑定的routing_key只是一个#,这个queue其实可以接收所有的消息。

如果没有使用任何通配符,效果类似于direct,因为只能和字符串匹配了。

生产者代码

import pika
import time
import random
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

# 建立连接
connection = pika.BlockingConnection(param)
channel = connection.channel() # 建立通道

ex_name = 'products'
ex_type = 'topic'
products = ('pc', 'phone', 'tv')
colors = ('orange', 'black', 'red')
channel.exchange_declare(exchange=ex_name,
                         exchange_type=ex_type)

# channel.queue_declare(queue='hello')
# 生产端声明。如果不存在,立即创建该q。创建的q不会因为连接中断而删除

# 消息持久化 1. 队列必须持久化 2.改变消息的属性值
for i in range(50):
    rk = "{}.{}".format(random.choice(products), random.choice(colors))
    msg = '{}-data-{:2}'.format(rk, i)
    channel.basic_publish(exchange=ex_name, # 不指定,默认缺省 test/default
                          routing_key=rk, # q的名字
                          # properties=pika.BasicProperties(delivery_mode=2),
                          # 持久化数据,队列必须持久化
                          body=msg)
    print(msg)
    time.sleep(0.5)
print(" [x] Sent 'Hello World!'")

connection.close()
  • 消费者代码
import pika
import time
# receive
param = pika.URLParameters('amqp://jasper:[email protected]:5672/test')

connection = pika.BlockingConnection(param)
channel = connection.channel()

ex_name = 'products'
ex_type = 'topic'
products = ('pc', 'phone', 'tv')
colors = ('orange', 'black', 'red')
channel.exchange_declare(exchange=ex_name,
                         exchange_type=ex_type)

result = channel.queue_declare(queue='', exclusive=True)
q_name1 = result.method.queue
channel.queue_bind(q_name1, ex_name, 'phone.*')

result2 = channel.queue_declare(queue='', exclusive=True)
q_name2 = result2.method.queue
channel.queue_bind(q_name2, ex_name, '*.red')

def callback(ch, method, properties, body):
    ctag = method.consumer_tag
    dtag = method.delivery_tag
    # time.sleep(5)
    print(f"{ctag} {dtag} ** {body}")
    # channel.basic_ack(dtag) # 手动应答

# 返回值为消费者号
# channel.basic_qos(prefetch_count=1)
c1 = channel.basic_consume(queue=q_name1,
                      auto_ack=True, # 自动应答
                      on_message_callback=callback)
c2 = channel.basic_consume(queue=q_name2,
                      auto_ack=True,
                      on_message_callback=callback)
channel.start_consuming()  # 阻塞
connection.close()

观察消费者拿到的数据,注意观察phone.red的数据出现次数。

由此,可以知道 交换机在路由消息的时候,只要和queue的routing_key匹配,就把消息发给该queue。

RPC远程过程调用

  • RabbitMQ的RPC的应用场景较少,因为有更好的RPC通信框架。

消息队列的作用

  1. 系统间解耦
  2. 解决生产者、消费者速度匹配

由于稍微上规模的项目都会分层、分模块开发,模块间或系统间尽量不要直接耦合,需要开放公共接口提供给别的模块或系统调用,而调用可能触发并发问题,为了缓冲和解耦,往往采用中间件技术。

RabbitMQ只是消息中间件中的一种应用程序,也是较常用的中间件服务。

模拟登陆oschina(新浪)

一般登录后,用户就可以一段时间内可以使用该用户身份操作,不需要频繁登录了。这背后往往使用了Cookie技术。

登录后,用户获得一个cookie值,这个值在浏览器当前会话中保存,只要不过期甚至可以保存很久。

用户每次向服务器提交请求时,将这些Cookie提交到服务器,服务器经过分析Cookie中的信息,以确认用户身份,确认是信任的用户身份,就可以继续使用网站功能。

Cookie网景公司发明。cookie一般是一个键值对name=value,但还可以包括expire过期时间、path路径、domain域、secure安全、httponly等信息。

清空oschina.net的所有cookies,重新登录,勾选”记住密码“

登陆前需将所有cookies清除

  • 对比登录前后的cookie值,发现登录后有oscid
  • 那就把这个HTTP 请求头放在代码中

注意:每次登录后要重新生成下面的headers

使用Postman将请求头改为KV对形式

使用postman的python代码生成器生成代码

  • 浏览器登录oschina.net网站
  • F12,复制原始的请求信息到postman
    • 打开postman新建请求,点击headers下面的Bulk Edit
    • 粘贴请求header信息
    • 点击code,以python requests代码展示
  • 复制生成的python,再进行修改
import requests

url = "https://my.oschina.net/u/9494857/admin/profile"

headers = {
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
  'Accept-Encoding': 'gzip, deflate, br, zstd',
  'Accept-Language': 'zh-CN,zh;q=0.9',
  'Connection': 'keep-alive',
  'Cookie': "aliyungf_tc=AQAAAMgC7FB9xAEADtR2e0KAzgb3hr0a; _user_behavior_=768762a3-dbce-4152-a024-264820556c9c; OSCHINA_SESSION=4DFB86BECA93B1D28BC8FEF2E1478E97; _reg_key_=EwiEfyB66X3jlOb4pNzk; oscid=ZV2oveUqo28xv80qumQtfRqukWzpKq2brNqjn0Y0a5kFTeUQUUbcPj2dwLIiVt%2FuobUFKx4%2FabVv%2BZ5n%2BrJhvE8p%2BKdiM%2FUIONcDpf9cQ%2FCwMTYxj0IZhKrEKkqVYfw%2BdNYj1bbHQEhDiqhDeFBZbsf7ouMp1Msoa4cH6mU1ZtM%3D",
  'DNT': '1',
  'Host': 'my.oschina.net',
  'Referer': 'https://www.oschina.net/',
  'Sec-Fetch-Dest': 'document',
  'Sec-Fetch-Mode': 'navigate',
  'Sec-Fetch-Site': 'same-site',
  'Sec-Fetch-User': '?1',
  'Upgrade-Insecure-Requests': '1',
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
  'sec-ch-ua': '"Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"',
  'sec-ch-ua-mobile': '?0',
  'sec-ch-ua-platform': '"Windows"'
}
session = requests.Session()

with session:
    response = session.get(url, headers=headers)
    with response:
        print(response.url)
        print(url)
        if url == response.url:
            print('已登录')
            with open('d:/tmp/profile.html', 'wb') as f:
                f.write(response.content)
        else:
            print('未登录')

# logout 浏览器用户登录再执行脚本返回一样的结果。说明,因为用户太多了,如果保存,和sessionid无异。 response set-cookie oscid清空
# 我们一定要在oscid中增加一个时间戳,到时候这个值一定要过期。证书签名,防止修改。JWT 防篡改。

由于站点升级到了HTTP2,可能部分页面请求头有了变化。

也可以通过页面内容判断是否登录,这就需要解析页面了。

已登录访问页面,右上角会有用户信息,如果未登录,就会出现登录、注册。这就是判断依据。

新浪微博等都一样,只要允许记住用户登录,就可以通过上述方法登录后爬取内容

并发爬取博客园

博客园的新闻分页地址 https://news.cnblogs.com/n/page/10/ ,多线程成批爬取新闻的 标题和链接.

https://news.cnblogs.com/n/page/2/ ,这个url中变化的是最后的数字一直在变,它是页码

基础

基本代码

xpath解析html

import requests
from lxml import etree

BASE_URL = 'https://news.cnblogs.com'
NEWS_PAGE = '/n/page/'
headers = {
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
}
url = "{}{}{}/".format(BASE_URL, NEWS_PAGE, 1)
print(url)

response = requests.get(url, headers=headers)

with response:
    print(response.status_code)
    if response.status_code == 200:
        xpath = '//h2[@class="news_entry"]/a/text()'
        doc = etree.HTML(response.text)
        e = doc.xpath(xpath)
        print(len(e), e)

beatifulsoup解析html

import requests
from lxml import etree
from bs4 import BeautifulSoup

BASE_URL = 'https://news.cnblogs.com'
NEWS_PAGE = '/n/page/'
headers = {
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
}
url = "{}{}{}/".format(BASE_URL, NEWS_PAGE, 1)
print(url)

response = requests.get(url, headers=headers)

with response:
    print(response.status_code)
    if response.status_code == 200:
        ## xpath方法
        # xpath = '//h2[@class="news_entry"]/a/text()'
        # doc = etree.HTML(response.text)
        # e = doc.xpath(xpath)
        # print(len(e), e)

        doc = BeautifulSoup(response.content, 'lxml')
        # print(doc)
        ## beatifulsoup方法
        ## 方法1 css选择器
        # selector = 'h2.news_entry > a'
        # titles = doc.select(selector)
        # for title in titles:
        #     print(title.get('href'), title.text)

        ## 方法2 find_all
        h2s = doc.find_all('h2', class_='news_entry')
        for h in h2s:
            a = h.a # h.find('a')
            print(a.get('href'), a.text)

函数封装

  1. 增加爬取页链接生成函数
  2. 增加爬取分析函数
import requests
from lxml import etree
from bs4 import BeautifulSoup

BASE_URL = 'https://news.cnblogs.com'
NEWS_PAGE = '/n/page/'
headers = {
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
}

urls = [] # 待爬取url,先进先出

# 创建博客园的新闻urls,每页30条新闻
def start_urls(start=1, stop=2, step=1):
    for i in range(start, stop, step):
        url = "{}{}{}/".format(BASE_URL, NEWS_PAGE, i)
        urls.append(url)
    print('链接创建完成')

def crawler():
    for url in urls:
        response = requests.get(url, headers=headers)
        with response:
            print(response.status_code)
            if response.status_code == 200:
                # xpath = '//h2[@class="news_entry"]/a/text()'
                # selector = 'h2.news_entry > a'
                # find_all
                doc = BeautifulSoup(response.content, 'lxml')
                h2s = doc.find_all('h2', class_='news_entry')
                for h in h2s:
                    a = h.a # h.find('a')
                    print(a.get('href'), a.text)

start_urls(1, 2)
print(urls)
crawler()

异步

上面函数可以直接使用多线程,可以分别执行。但是如果改成多线程,有什么问题吗?

使用多线程最大的问题在于这里使用的列表,一个线程正在追加URL,一个线程需要拿走一个URL。应该使用Queue来完成,线程安全。 1、 引入线程池

2、使用线程安全的先进先出Queue

import requests
from bs4 import BeautifulSoup
from bs4.element import Tag
from queue import Queue
from concurrent.futures import ThreadPoolExecutor


BASE_URL = 'http://news.cnblogs.com'
NEWS_PAGE = '/n/page/'
# https://news.cnblogs.com/n/page/2/ 列表页
# https://news.cnblogs.com/n/628919/ 详情页

headers = {
    'User-agent': "Mozilla/5.0 (Windows; U; Windows NT 6.1; zh-CN) AppleWebKit/537.36 (KHTML,like Gecko)"
                  " Version / 5.0.1Safari / 537.36"
}

# 异步,队列,以后换成第三方队列
urls = Queue()


# 创建博客园的新闻urls,每页30条新闻
def starts_url(start, stop, step=1):
    for i in range(start, stop + 1, step):
        url = "{}{}{}/".format(BASE_URL, NEWS_PAGE, i)
        print(url)
        urls.put(url)  # 加入队列
    print('任务链接创建完毕')


# 爬取页面
def crawler():
    url = urls.get()  # 阻塞,拿一条
    with requests.get(url, headers=headers) as response:
        html = response.text

        # 解析
        soup = BeautifulSoup(html, 'lxml')
        # h2.news_entry > a
        # //h2[@new_entry=""]/a
        titles = soup.select('h2.news_entry > a')
        for title in titles:
            print(title.get('href'), title.text)


# starts_url(1, 1)
# crawler()

# 线程池
executor = ThreadPoolExecutor(10)

executor.submit(starts_url, 1, 1)
for i in range(5):
    executor.submit(crawler)

3、解析函数

4、修改循环条件为Event

解析内容是一个比较耗时的过程,不适合放在crawler中同步处理。同样使用队列解耦

img_20250829_173122.png

现在线程都是拿一条数据,执行完就结束了。修改为可以不停的从队列中取数据

import requests
from bs4 import BeautifulSoup
from bs4.element import Tag
from queue import Queue
from concurrent.futures import ThreadPoolExecutor
from threading import Event


BASE_URL = 'http://news.cnblogs.com'
NEWS_PAGE = '/n/page/'
# https://news.cnblogs.com/n/page/2/ 列表页
# https://news.cnblogs.com/n/628919/ 详情页

headers = {
    'User-agent': "Mozilla/5.0 (Windows; U; Windows NT 6.1; zh-CN) AppleWebKit/537.36 (KHTML,like Gecko)"
                  " Version / 5.0.1Safari / 537.36"
}

# 异步,队列,以后换成第三方队列
urls = Queue()# 待爬取队列
htmls = Queue() # 待分析队列
outputs = Queue() # 待输出队列



# 创建博客园的新闻urls,每页30条新闻
def starts_urls(start, stop, step=1):
    for i in range(start, stop + 1, step):
        url = "{}{}{}/".format(BASE_URL, NEWS_PAGE, i)
        print(url)
        urls.put(url)  # 加入队列
    print('任务链接创建完毕')


# 爬取页面
def crawler(e:Event):
    while not e.is_set():
        url = urls.get()  # 阻塞,拿一条
        with requests.get(url, headers=headers) as response:
            html = response.text
            htmls.put(html)

# 解析页面
def parse(e:Event):
    # 解析
    while not e.is_set():
        html = htmls.get()

        soup = BeautifulSoup(html, 'lxml')
        # xpath = '//h2[@class="news_entry"]/a/text()'
        # selector = 'h2.news_entry > a'
        titles = soup.find_all('h2', class_='news_entry')
        for title in titles:
            a = title.a
            if a:
                href = BASE_URL + a.get('href')
                txt = a.txt
                val = href, txt
                outputs.put(val)
                print(val)

# start_urls(1, 1)
# crawler()
# parse()

event = Event()

# 线程池
executor = ThreadPoolExecutor(10)

executor.submit(starts_url, 1, 1)
for i in range(5):
    executor.submit(crawler, event)
for i in range(5):
    executor.submit(parse, event)

html分析函数parse,分析完成后,需要将结果持久化。不要在parse中直接持久化,放入队列中,统一持久化

5、增加持久化函数

import requests
from lxml import etree
from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor
from queue import Queue
from threading import Event
import json

BASE_URL = 'https://news.cnblogs.com'
NEWS_PAGE = '/n/page/'
headers = {
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
}

urls = Queue() # # 待爬取url队列 # 未来准备用 MQ中间件 替换
htmls = Queue()
outputs = Queue()

# 创建博客园的新闻urls,每页30条新闻
def start_urls(start=1, stop=1, step=1):
    for i in range(start, stop+1, step):
        url = "{}{}{}/".format(BASE_URL, NEWS_PAGE, i)
        urls.put(url)

    print('链接创建完成')

def crawler(e:Event):
    while not e.is_set():
        url = urls.get() # blocked
        response = requests.get(url, headers=headers)
        with response:
            if response.status_code == 200:
                html = response.text
                htmls.put(html)

def parse(e:Event):
    while not e.is_set():
        html = htmls.get() # 阻塞

        # xpath = '//h2[@class="news_entry"]/a/text()'
        # selector = 'h2.news_entry > a'
        # find_all
        doc = BeautifulSoup(html, 'lxml')
        h2s = doc.find_all('h2', class_='news_entry')
        for i, h in enumerate(h2s, 1):
            a = h.a # h.find('a')
            print(i, a.get('href'), a.text) # => 数据可以存储到mongodb中
            val = {
                'href': BASE_URL + a.get('href'),
                'title': a.text
            }
            # Queue中python自己序列化 pickle,但是第三方未必如此
            outputs.put(val)


def persist(e:Event, path:str):
    with open(path, 'a+', encoding='utf-8') as f:
        while not e.is_set():
            val = outputs.get() # dict
            f.write(json.dumps(val))
            f.flush()

# start_urls(1, 1)
# crawler()
# parse()

event = Event()
# 线程池
executor = ThreadPoolExecutor(10)

executor.submit(start_urls,1, 1)
executor.submit(persist, event, 'd:/tmp/titles.txt')
for i in range(5):
    executor.submit(crawler, event)
for i in range(4):
    executor.submit(parse, event)

这样一个实用的并行的爬虫就基本完成了,一般提取新的URL源源不断地注入到待爬取队列,就可以实现不间断的爬取了

可以很方便的扩展成多进程等版本

进阶(消息队列)

将队列换成第三方服务,本次采用较为常用RabbitMQ

import pika
import time

params = pika.URLParameters(
    'amqp://jasper:[email protected]:5672/test'
)

# direct路由,交换机 queue 由消费者随机生成名称绑定rk,生产者exchange 生成时写rk
connection = pika.BlockingConnection(params)
channel = connection.channel()
ex_name = 'news'
ex_type = 'direct'
rks = ('urls', 'htmls', 'outputs')

#生成交换机
channel.exchange_declare(
    exchange=ex_name,
    exchange_type=ex_type)
rk = rks[0]

for i in range(50):
    msg = '{}-data-{:2}'.format(rk, i)
    channel.basic_publish(exchange=ex_name, # 不指定,默认缺省 test/default
                          routing_key=rk, # q的名字
                          body=msg)
    print(msg)
    time.sleep(0.5)

connection.close()
import pika
import time

params = pika.URLParameters(
    'amqp://jasper:[email protected]:5672/test'
)

# direct路由,交换机 queue 由消费者随机生成名称绑定rk,生产者exchange 生成时写rk
connection = pika.BlockingConnection(params)
channel = connection.channel()
ex_name = 'news'
ex_type = 'direct'
rks = ('urls', 'htmls', 'outputs')

#生成交换机
channel.exchange_declare(
    exchange=ex_name,
    exchange_type=ex_type)
rk = rks[0]

#生成队列,并绑定
result = channel.queue_declare(queue='', exclusive=True)
q_name = result.method.queue
# 绑定到交换机,而且一定要绑定routing_key
channel.queue_bind(q_name, ex_name, rk)

def callback(ch, method, properties, body):
    print(f"{method.consumer_tag} -- {body}")

#消费队列
# method, properties, body = channel.basic_get(q_name,True)
channel.basic_consume(
    q_name, on_message_callback=callback, auto_ack=True
)

print(" [*] Waiting for messages. To exit press CTRL+C")
channel.start_consuming()

选型

1、队列工作模式选择

以爬虫程序的htmls队列为例,这个队列有多个生产者(爬取函数)写入,有多个消费者(解析函数)读取。每一个消息只能被消费一次。所以,采用RabbitMQ的 工作队列模式

RabbitMQ生产者、消费者两端都可以创建交换机、队列。

2、队列中如何如何分发

工作队列模式,说到底就是路由模式。RabbitMQ的队列和工作队列模式,交换机都工作在direct,其实都是路由模式,只不过使用了缺省交换机

我们自己使用,可以单独创建交换机,不使用缺省交换机

3、队列是否断开删除

如果丢几条新闻没有关系,exclusive=True。否则,就要选择队列exclusive=False,甚至是durable=True。

生产者消息者代码

生产者代码

#生产者代码
import pika
import time

params = pika.URLParameters(
    'amqp://jasper:[email protected]:5672/test'
)

# direct路由,交换机 queue 由消费者随机生成名称绑定rk,生产者exchange 生成时写rk
connection = pika.BlockingConnection(params)
channel = connection.channel()
ex_name = 'news'
ex_type = 'direct'
rks = ('urls', 'htmls', 'outputs')

#生成交换机
channel.exchange_declare(
    exchange=ex_name,
    exchange_type=ex_type)
q_name = rks[0]

#生成队列,并绑定
result = channel.queue_declare(q_name, exclusive=False)
channel.queue_bind(q_name, ex_name)

for i in range(50):
    msg = '{}-data-{:2}'.format(q_name, i)
    channel.basic_publish(exchange=ex_name, # 不指定,默认缺省 test/default
                          routing_key=q_name, # q的名字
                          body=msg)
    print(msg)
    time.sleep(0.5)



connection.close()

消费者代码

#消费者代码
import pika
import time

params = pika.URLParameters(
    'amqp://jasper:[email protected]:5672/test'
)

# direct路由,交换机 queue 由消费者随机生成名称绑定rk,生产者exchange 生成时写rk
connection = pika.BlockingConnection(params)
channel = connection.channel()
ex_name = 'news'
ex_type = 'direct'
rks = ('urls', 'htmls', 'outputs')

#生成交换机
channel.exchange_declare(
    exchange=ex_name,
    exchange_type=ex_type)
q_name = rks[0]

#生成队列,并绑定
result = channel.queue_declare(q_name, exclusive=False)
channel.queue_bind(q_name, ex_name)

def callback(ch, method, properties, body):
    print(f"{method.consumer_tag} -- {body}")

#消费队列
# method, properties, body = channel.basic_get(q_name,True)
channel.basic_consume(
    q_name, on_message_callback=callback, auto_ack=True
)

print(" [*] Waiting for messages. To exit press CTRL+C")
channel.start_consuming()

可以看到,前面一些代码一样,做一下类的封装。

重构消息队列类

#创建包文件
./mq/__init__.py
#生产者代码
import pika
import time

class Base:
    def __init__(self, host, vh, exchange, queue, username, password, port=5672, **kwargs):
        params = pika.URLParameters(
            'amqp://{}:{}@{}:{}/{}'.format(
                username,password,host,port,vh
            )
        )

        # direct路由,交换机 queue 由消费者随机生成名称绑定rk,生产者exchange 生成时写rk
        self.connection = pika.BlockingConnection(params)
        self.channel = self.connection.channel()
        self.exchange = exchange
        self.queue = queue
        # ex_name = 'news'
        ex_type = 'direct'
        rks = ('urls', 'htmls', 'outputs')

        #生成交换机
        self.channel.exchange_declare(
            exchange=exchange,
            exchange_type=ex_type)
        # q_name = rks[0]

        #生成队列,并绑定
        result = self.channel.queue_declare(queue, exclusive=False)
        self.channel.queue_bind(queue, exchange)

    def __enter__(self):
        return self
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.channel.close()
        self.connection.close()

class Producer(Base):
    def produce(self, message:str):
        self.channel.basic_publish(
            exchange=self.exchange,  # 不指定,默认缺省 test/default
            routing_key=self.queue,  # q的名字
            body=message)

import time
class Consummer(Base):
    def consume(self):
        method, properties, body = self.channel.basic_get(self.queue,True) # 非阻塞
        if body:
            return body
        else:
            time.sleep(1)
            # print('~~~~')

if __name__ == '__main__':
    rks = ('urls', 'htmls', 'outputs')

    #host, vh, exchange, queue, username, password, port=5672,
    with Producer('192.168.226.130', 'test', 'news', 'urls', 'jasper', 123456) as p:
        for i in range(5):
            msg = '{}-data-{:2}'.format(p.queue, i)
            p.produce(msg)

    # request --> response
    with Consummer('192.168.226.130', 'test', 'news', 'urls', 'jasper', 123456) as c:
        with Producer('192.168.226.130', 'test', 'news', 'htmls', 'jasper', 123456) as p:
            for i in range(6):
                url = c.consume()
                if url:
                    # 爬取
                    html = 'aa'
                    p.produce(html)
                print(url, '+++')

    # parse
    with Consummer('192.168.226.130', 'test', 'news', 'htmls', 'jasper', 123456) as c:
        with Producer('192.168.226.130', 'test', 'news', 'outputs', 'jasper', 123456) as p:
            for i in range(6):
                html = c.consume()
                if html:
                    # 解析
                    value = {}
                    p.produce(value)

    with Consummer('192.168.226.130', 'test', 'news', 'outputs', 'jasper', 123456) as c:
        for i in range(6):
            value = c.consume()
            if value:
                # 入库
                pass

重构爬虫代码

import requests
from bs4 import BeautifulSoup
from bs4.element import Tag
from queue import Queue
from concurrent.futures import ThreadPoolExecutor
from threading import Event
import simplejson
from messagequeue import Producer, Consumer


BASE_URL = 'http://news.cnblogs.com'
NEWS_PAGE = '/n/page/'
# https://news.cnblogs.com/n/page/2/ 列表页
# https://news.cnblogs.com/n/628919/ 详情页

headers = {
    'User-agent': "Mozilla/5.0 (Windows; U; Windows NT 6.1; zh-CN) AppleWebKit/537.36 (KHTML,like Gecko)"
                  " Version / 5.0.1Safari / 537.36"
}

# 异步,队列,以后换成第三方队列
# urls = Queue()# 待爬取队列
# htmls = Queue() # 待分析队列
# outputs = Queue() # 待输出队列

# 创建博客园的新闻urls,每页30条新闻
def starts_url(start, stop, step=1):
    p = Producer('192.168.1.5', 5672, 'lqx', 'lqx', 'test', 'news', 'urls')
    for i in range(start, stop + 1, step):
        url = "{}{}{}/".format(BASE_URL, NEWS_PAGE, i)
        print(url)
        # urls.put(url)  # 加入队列
        p.produce(url)
    print('任务链接创建完毕')


# 爬取页面
def crawler(e:Event):
    p = Producer('192.168.1.5', 5672, 'lqx', 'lqx', 'test', 'news', 'urls')
    c = Consumer('192.168.1.5', 5672, 'lqx', 'lqx', 'test', 'news', 'urls')
    while not e.wait(1):
        # url = urls.get()  # 阻塞,拿一条
        url = c.consume()
        if url:
            with requests.get(url, headers=headers) as response:
                if response.status_code == 200:
                    html = response.text
                    #htmls.put(html)
                    p.produce(html)


# 解析页面
def parse(e:Event):
    # 解析
    p = Producer('192.168.1.5', 5672, 'lqx', 'lqx', 'test', 'news', 'urls')
    c = Consumer('192.168.1.5', 5672, 'lqx', 'lqx', 'test', 'news', 'urls')
    while not e.wait(1):
        # html = htmls.get()
        html = c.consume()

        if html:
            soup = BeautifulSoup(html, 'lxml')
            # h2.news_entry > a
            # //h2[@new_entry=""]/a
            titles = soup.select('h2.news_entry > a')
            for title in titles:
                val = simplejson.dumps({
                    'title': title.text,
                    'url': BASE_URL + title.get('href','')
                })
                # outputs.put(val)
                p.produce(val)
                # print(val)

# 持久化
def persist(path, e: Event):
    # 以后持久化到数据库当中去
    c = Consumer('192.168.1.5', 5672, 'lqx', 'lqx', 'test', 'news', 'urls')
    with open(path, 'a+', encoding='utf-8') as f:
        while not e.wait(1):
            # val = outputs.get()
            data = c.consume()
            if data:
                val = simplejson.loads(data)
                f.write("{}\x01{}\n".format(val['url'], val['title']))
                f.flush()

event = Event()

# 线程池
executor = ThreadPoolExecutor(10)

executor.submit(starts_url, 1, 2)
executor.submit(persist, 'd:/news.txt', event)

for i in range(5):
    executor.submit(crawler, event)
for i in range(4):
    executor.submit(parse, event)

爬取、解析、存储、url生成都可以完全独立,分别部署

持久化

import requests
from lxml import etree
from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor
from queue import Queue
from threading import Event
import json
from mq import Producer, Consummer
import pymongo

BASE_URL = 'https://news.cnblogs.com'
NEWS_PAGE = '/n/page/'
headers = {
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
}

# urls = Queue() # # 待爬取url队列 # 未来准备用 MQ中间件 替换
# htmls = Queue()
# outputs = Queue()

# 创建博客园的新闻urls,每页30条新闻
def start_urls(start=1, stop=1, step=1):
    p = Producer('192.168.226.130', 'test', 'news', 'urls', 'jasper', 123456)
    for i in range(start, stop+1, step):
        url = "{}{}{}/".format(BASE_URL, NEWS_PAGE, i)
        # urls.put(url)
        p.produce(url)

    print('链接创建完成')

# 爬取页面
def crawler(e:Event):
    c = Consummer('192.168.226.130', 'test', 'news', 'urls', 'jasper', 123456)
    p = Producer('192.168.226.130', 'test', 'news', 'htmls', 'jasper', 123456)
    while not e.wait(1):
        # url = urls.get() # blocked
        url = c.consume()
        if url:
            response = requests.get(url, headers=headers)
            with response:
                if response.status_code == 200:
                    html = response.text
                    # htmls.put(html)
                    p.produce(html)

# 解析页面
def parse(e:Event):
    c = Consummer('192.168.226.130', 'test', 'news', 'htmls', 'jasper', 123456)
    p = Producer('192.168.226.130', 'test', 'news', 'outputs', 'jasper', 123456)
    while not e.is_set():
        # html = htmls.get() # 阻塞
        html = c.consume()
        if html:
            # xpath = '//h2[@class="news_entry"]/a/text()'
            # selector = 'h2.news_entry > a'
            # find_all
            doc = BeautifulSoup(html, 'lxml')
            h2s = doc.find_all('h2', class_='news_entry')
            for i, h in enumerate(h2s, 1):
                a = h.a # h.find('a')
                print(i, a.get('href'), a.text) # => 数据可以存储到mongodb中
                val = {
                    'id': i,
                    'href': BASE_URL + a.get('href'),
                    'title': a.text
                }
                # Queue中python自己序列化 pickle,但是第三方未必如此
                # outputs.put(val)
                p.produce(json.dumps(val))

# 持久化
def persist(e:Event, path:str):
    c = Consummer('192.168.226.130', 'test', 'news', 'outputs', 'jasper', 123456)
    client = pymongo.MongoClient(
        'mongodb://192.168.226.130:27017'
    )
    db = client.news  # 
    href = db.href  # 表,集合,n条 文档
    while not e.wait(1):
        value = c.consume()
        if value:
            try:
                href.insert_one(json.loads(value))
            except Exception as ex:
                print(ex)
                print(value)

    # with open(path, 'ab') as f:
    #     while not e.is_set():
    #         # val = outputs.get() # dict
    #         value = c.consume()
    #         if value:
    #             # print(value)
    #             f.write(value + b',\n')
    #             f.flush()

event = Event()
# 线程池
executor = ThreadPoolExecutor(10)

executor.submit(start_urls,1, 2)
executor.submit(persist, event, 'd:/tmp/titles.txt')
for i in range(5):
    executor.submit(crawler, event)
for i in range(4):
    executor.submit(parse, event)

Selenium和PhantomJS

动态网页处理

很多网站都采用A JAX技术、SPA技术,部分内容都是异步动态加载的。可以提高用户体验,减少不必要的流量,方便CDN加速等。

但是,对于爬虫程序爬取到的HTML页面相当于页面模板了,动态内容不在其中

解决办法之一,如果能构造一个包含JS引擎的浏览器,让它加载网页并和网站交互,我们编程从这个浏览器获取内容包括动态内容。这个浏览器不需要和用户交互的界面,只要能支持HTTP、HTTPS协议和服务器端交互,能解析HTML、CSS、JS就行。

PhantomJS

它是一个headless无头浏览器,支持Javascript。可以运行在Windows、Linux、Mac OS等。

所谓无头浏览器,就是包含Js引擎、浏览器排版引擎等核心组件,但是没有和用户交互的界面的浏览器。

测试编写test.js,运行命令 phantomjs/bin/phantomjs.exe test.js

"use strict";
console.log('hello world');
phantom.exit();

Selenium

它是一个WEB自动化测试工具。它可以直接运行在浏览器中,支持主流的浏览器,包括PhantomJS(无头浏览器)

安装

pip install selenium

官网:https://www.selenium.dev/

selenium基本语法学习

元素定位

Selenium元素定位是指通过特定的方法在网页中准确定位到需要操作的元素,例如按钮、文本框、下拉菜单等。以下是一些常用的Selenium元素定位相关的语法:

Selenium3.x版本前

在Selenium3.x版本及之前,语法如下:
(1)通过ID定位元素
element = driver.find_element_by_id("element_id")
(2)通过名称定位元素
element = driver.find_element_by_name("element_name")
(3)通过类名定位元素
element = driver.find_element_by_class_name("class_name")
(4)通过标签名定位元素
element = driver.find_element_by_tag_name("tag_name")
(5)通过链接文本定位元素(<a> 标签)
element = driver.find_element_by_link_text("link_text")
(6)通过部分链接文本定位元素(<a> 标签)
element = driver.find_element_by_partial_link_text("partial_link_text")
(7)通过XPath定位元素
element = driver.find_element_by_xpath("xpath_expression")
(8)通过CSS选择器定位元素
element = driver.find_element_by_css_selector("css_selector")
上面都是获取单个元素,要获取多个元素,将其中的element修改为elements即可。

Selenium4.x版本后

元素的定位不再是上面这种一个类型一个方法的模式,而是变为两个方法find_element和find_elements:
find_element方法返回一个元素
ind_elements方法返回一个列表

通过ID还是NAME等获取方式,变为一个By对象的属性,作为参数入参到find_element和find_elements方法中
    ID = "id"
    XPATH = "xpath"
    LINK_TEXT = "link text"
    PARTIAL_LINK_TEXT = "partial link text"
    NAME = "name"
    TAG_NAME = "tag name"
    CLASS_NAME = "class name"
    CSS_SELECTOR = "css selector"
# 根据xpath选择元素(万金油)
driver.find_element(By.XPATH, '//*[@id="kw"]') 
# 根据css选择器选择元素
driver.find_element(By.CSS_SELECTOR, '#kw') 
# 根据name属性值选择元素
driver.find_element(By.NAME, 'wd') 
# 根据类名选择元素
driver.find_element(By.CLASS_NAME, 's_ipt') 
# 根据链接文本选择元素
driver.find_element(By.LINK_TEXT, 'hao123') 
# 根据包含文本选择
driver.find_element(By.PARTIAL_LINK_TEXT, 'hao') 
# 根据标签名选择
# 目标元素在当前html中是唯一标签或众多标签第一个时候使用
driver.find_element(By.TAG_NAME, 'title') 
# 根据id选择
driver.find_element(By.ID, 'su') 
元素访问
#获取元素文本
element_text = element.text
#获取元素属性
attribute_value = element.get_attribute("attribute_name")
#执行点击操作
element.click()
#输入文本到输入框
element.send_keys("text")
#提交表单
element.submit()
#切换到iframe
driver.switch_to.frame(element)

交互操作

单击元素

  • 使用click()方法实现单击操作:
element = driver.find_element(By.XPATH, 'xpath_expression')
element.click()

输入内容

  • 使用send_keys()方法实现单击操作:
element = driver.find_element(By.XPATH, 'xpath_expression')
element.send_keys("要输入的字符串")

前进和后退操作

  • 使用driver.back()方法实现后退操作,driver.forward()方法实现前进操作:
driver = webdriver.Chrome()
# 做几项网络操作,最好是可以前往新页面的操作
driver.back()
driver.forward()

JS模拟鼠标滚动

  • 使用execute_script()方法实现鼠标滚动:
driver.execute_script("window.scrollTo(0,document.body.scrollHeight)")
#JS代码,通过修改scrollHeight的值可以调整滚动的位置,如将scrollHeight修改为0则表示滚动至页面顶部。

获取网页代码

  • 使用 page_source 属性获取当前网页的完整代码:
page_source = driver.page_source
print(page_source)
  • 获取特定元素的代码片段:
element = driver.find_element(By.XPATH, 'xpath_expression')
element_html = element.get_attribute("outerHTML")
print(element_html)
#返回指定元素的HTML代码片段,包括元素本身及其所有子元素

获取当前网页的 URL

  • 使用current_url属性获取当前网页的 URL:
url = driver.current_url
print(url)

右键单击元素

  • 使用context_click()方法实现右键单击操作:
element = driver.find_element(By.XPATH, 'xpath_expression')
ActionChains(driver).context_click(element).perform()

悬停在元素上

  • 使用move_to_element()方法实现悬停操作:
element = driver.find_element(By.XPATH, 'xpath_expression')
ActionChains(driver).move_to_element(element).perform()

元素拖放

  • 使用drag_and_drop()方法实现元素拖放操作:
source_element = driver.find_element(By.XPATH, 'xpath_expression')
target_element = driver.find_element(By.XPATH, 'xpath_expression')
ActionChains(driver).drag_and_drop(source_element, target_element).perform()

等待操作完成

  • 使用显式等待(Explicit Wait)机制等待特定条件达成:
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

element = WebDriverWait(driver, 10).until(
    EC.presence_of_element_located((By.XPATH, 'xpath_expression'))
)
代码说明:
WebDriverWait类用于设置最长等待时间,并提供了多种条件来等待特定条件达成。
EC.presence_of_element_located()是一个预定义的条件,用于等待元素出现在页面中。
  • 使用隐式等待(Implicit Wait)机制等待特定时间段:
driver.implicitly_wait(10)

异步加载页面的处理

  • 使用page_load_timeout属性设置页面加载超时时间:
driver.set_page_load_timeout(10)
如果页面在指定时间内未完全加载完成,将抛出TimeoutException异常。
  • 使用execute_script()方法判断页面是否加载完成:
is_page_loaded = driver.execute_script("return document.readyState") == "complete"

关闭网页

# 使用close()方法关闭当前窗口:
driver.close()
# 使用quit()方法退出整个浏览器会话:
driver.quit()

开发实战

  • 不同浏览器都会提供操作的接口,Selenium就是使用这些接口来操作浏览器
  • Selenium最核心的对象就是webdriver,通过它就可以操作浏览器、截图、HTTP访问、解析HTML等

注意:从Selenium 4开始,PhantomJS被废弃了,建议使用基于Chrome的Headless模式代替。

处理异步请求

Chrome Headless是一个无界面的浏览器环境,它是Google Chrome浏览器在59版本之后新增的一种运行模式。与传统的浏览器不同,Chrome Headless可以在后台执行网页操作,而无需显示可见的用户界面。

它更轻量级,节省了系统资源,并且执行速度更快。其次,它稳定性高,不受弹窗、广告或其他干扰因素的影响。

bing/baidu的查询结果是通过异步请求返回结果,所以,直接访问页面不能直接获取到搜索结果

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
from urllib import parse

import datetime
import random
import time

# 创建ChromeOptions对象,配置Chrome Headless选项
options = webdriver.ChromeOptions()
options.add_argument('--headless') # 设置Chrome为Headless模式
# options.add_argument("--disable-gpu")  # 禁用GPU加速

driver = webdriver.Chrome(options=options)
# 设置窗口大小
driver.set_window_size(1280, 1924)

# 保存图片
def savepic():
    base_path = 'd:/tmp/'
    filename = '{}{:%Y%m%d%H%M%S}{:03}.png'.format(
        base_path,datetime.datetime.now(),random.randint(1, 100)
    )
    driver.save_screenshot(filename)

# 打开网页GET方法,模拟浏览器地址栏输入网址
url = 'https://www.baidu.com/s?' + parse.urlencode({
    'wd': 'python'
})
driver.get(url)
print(url) # https://www.baidu.com/s?wd=python

time.sleep(1)
savepic()

MAXRETRIES = 6 # 最大尝试次数
for i in range(MAXRETRIES):
    time.sleep(4)
    try:
        ele = driver.find_element(By.ID,  'content_left') # 如果查询结果来了,就会有这个id的标签
        if not ele.is_displayed(): # 等待数据显示出来
            print('display none')
            continue
        print('displayed')
        savepic()
        break
    except Exception as e:
        print(e)

driver.quit()

PhantomJS

# 获取bing查询数据
from selenium import webdriver # 核心对象
import datetime
import random
import time
from urllib import parse

# 指定PhantomJS的执行文件路径
driver = webdriver.PhantomJS('d:/python/phantomjs/bin/phantomjs.exe')
# 设置窗口大小
driver.set_window_size(1280, 1024)

# 打开网页GET方法,模拟浏览器地址栏输入网址
url = 'http://www.bing.com/search?' + parse.urlencode({
    'q': 'python'
})
driver.get(url)

# 保存图片
def savepic():
    base_dir = 'd:/'
    filename = '{}{:%Y%m%d%H%M%S}{:03}.png'.format(
        base_dir,
        datetime.datetime.now(),
        random.randint(1, 100)
    )

    driver.save_screenshot(filename)

#time.sleep(10) # 等等截图就能看到内容
savepic()

MAXRETRIES = 5 # 最大重试次数
for i in range(MAXRETRIES):  # 循环测试
    time.sleep(1)
    try:
        ele = driver.find_element_by_id('b_results')  # 如果查询结果来了,就会有这个id的标签
        if not ele.is_displayed():  # 等待数据显示出来
            print('diplay none')
            continue
        print('ok')
        savepic()
        break
    except Exception as e:
        print(e)

driver.quit()

可能结果未必能看到,说明数据回来了,而且组织好了,但是没有显示出来

可以增加判断元素是否显示的代码,直到等待的数据呈现在页面上

下拉框处理

了解即可。

Selenium专门提供了Select类来处理网页中的下拉框

不过下拉框用的页面越来越少了,本次使用 https://www.oschina.net/search?scope=project&q=python

# oschina软件搜索
<select name='tag1' onchange="submit();">
    <option value='0'>所有分类</option>
    <option value='309' >Web应用开发</option> 
    <option value='331' >手机/移动开发</option> 
    <option value='364' >iOS代码库</option> 
    <option value='12' >程序开发</option> 
    <option value='11' >开发工具</option> 
    <option value='273' >jQuery 插件</option> 
    <option value='256' >建站系统</option> 
    <option value='5' >企业应用</option> 
    <option value='10' >服务器软件</option> 
    <option value='6' >数据库相关</option> 
    <option value='8' >应用工具</option> 
    <option value='18' >插件和扩展</option> 
    <option value='7' >游戏/娱乐</option>
    <option value='14' >管理和监控</option> 
    <option value='9' >其他开源</option>
</select>

这个下拉框影响下一个下拉框“所有子类”。下面就模拟来操作下拉框,需要使用 selenium.webdriver.support.select.Select

from selenium import webdriver # 核心对象
import datetime
import random
from selenium.webdriver.support.ui import Select

# 指定PhantomJS的执行文件路径
driver = webdriver.PhantomJS('d:/python/phantomjs/bin/phantomjs.exe')
# 设置窗口大小
driver.set_window_size(1280, 1024)

# 保存图片
def savepic():
    base_dir = 'd:/'
    filename = '{}{:%Y%m%d%H%M%S}{:03}.png'.format(
        base_dir,
        datetime.datetime.now(),
        random.randint(1, 100)
    )

    driver.save_screenshot(filename)

# 打开网页GET方法,模拟浏览器地址栏输入网址
url = 'https://www.oschina.net/search?q=python&scope=project'
driver.get(url)

ele = driver.find_element_by_name('tag1') # 获取元素
print(ele.tag_name) # 标签名
print(driver.current_url) # 当前url
savepic()

s = Select(ele)
#s.select_by_index(1)
s.select_by_value('309')
print(driver.current_url) # 新页面
savepic()

driver.quit()

由于该网页改版,舍弃了select,无法测试。

模拟键盘操作(模拟登录)

webdriver提供了一些列find方法,用户获取一个网页中的元素。元素对象可以使用send_keys模拟键盘输入

oschina的登录页,登录成功后会跳转到首页,首页右上角会显示会员信息,如果未登录,无此信息

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.chrome.options import Options
from urllib import parse

import datetime
import random
import time

# 创建ChromeOptions对象,配置Chrome Headless选项
options = webdriver.ChromeOptions()
options.add_argument('--headless') # 设置Chrome为Headless模式
# options.add_argument("--disable-gpu")  # 禁用GPU加速

driver = webdriver.Chrome(options=options)
# 设置窗口大小
driver.set_window_size(1920, 1080)

# 保存图片
def savepic():
    base_path = 'd:/tmp/'
    filename = '{}{:%Y%m%d%H%M%S}{:03}.png'.format(
        base_path,datetime.datetime.now(),random.randint(1, 100)
    )
    driver.save_screenshot(filename)

url = 'https://www.oschina.net/'
driver.get(url)
savepic()

# 点击登录页
login_button = driver.find_element(By.XPATH, '//div[@class="user-bar"]/ul/a')
print(login_button.get_attribute('href')) # 获取属性值
login_button.click() # 点击元素
print(driver.current_url) # 当前url
savepic()

# 登录页,切换标签页
tab = driver.find_element(By.XPATH, '//div[@data-osc-tab="password"]')
tab.click()
savepic()

# 用户名和密码
username = driver.find_element(By.XPATH, '//input[@name="username"]')
password = driver.find_element(By.XPATH, '//input[@name="password"]')
username.send_keys('1xxxx') # 输入用户名
password.send_keys('1xxxx')
savepic()

password.send_keys(Keys.ENTER) # 模拟键盘敲击
# 或者合并 password.send_keys('1xxxx', Keys.ENTER)
time.sleep(5) # 等待2秒
print(driver.current_url)
savepic()

# 模拟登录后获得cookies
cookies = driver.get_cookies()
print(type(cookies), cookies)
for c in cookies:
    print(type(c), c)
print('=' * 30)

# 模拟请求
import requests
from requests.cookies import RequestsCookieJar

# cookies = [
#     {'domain': '.oschina.net', 'httpOnly': False, 'name': 'Hm_lpvt_a411c4d1664dd70048ee98afe7b28f0b', 'path': '/',
#      'sameSite': 'Lax', 'secure': False, 'value': '1757401509'},
# {'domain': '.oschina.net', 'expiry': 1788937508, 'httpOnly': True, 'name': 'oscid', 'path': '/', 'sameSite': 'Lax',
#  'secure': False,
#  'value': 'H1%2FKYVEN1WFHSFPDrSA9WFqLGKWIss6348jpkpX6ZA9DObORyc2LnhWySW0ENLj4GqXLr%2FTP6VOvr9IfJ5TwnVBG3CBGsEj5KmkZ5du2cqfS2%2FTtQ4Nrpbczj4EkvwhjZw9Cxw8hQktrhwfqZTVm0w%3D%3D'},
# ]

headers = {
    'referer':'https://www.oschina.net/',
    'User-agent':"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
}

jar = RequestsCookieJar()
for c in cookies:
    jar.set(c.get('name'), c.get('value'))

url = 'https://www.oschina.net/'

# 不带cookie
with requests.get(url, headers=headers) as response:
    with open('d:/tmp/nocookies.html', 'wb') as f:
        f.write(response.content)
# 带cookie
with requests.get(url, headers=headers, cookies=jar) as response:
    with open('d:/tmp/cookies.html', 'wb') as f:
        f.write(response.content)

driver.quit()

PhantomJS

# 模拟开源中国登陆
from selenium import webdriver # 核心对象
import datetime
import random
import time
from selenium.webdriver.common.keys import Keys

# 指定PhantomJS的执行文件路径
driver = webdriver.PhantomJS('d:/python/phantomjs/bin/phantomjs.exe')
# 设置窗口大小
driver.set_window_size(1280, 1024)

# 保存图片
def savepic():
    base_dir = 'd:/'
    filename = '{}{:%Y%m%d%H%M%S}{:03}.png'.format(
        base_dir,
        datetime.datetime.now(),
        random.randint(1, 100)
    )

    driver.save_screenshot(filename)

# 打开网页GET方法,模拟浏览器地址栏输入网址
url = 'https://www.oschina.net/home/login'
driver.get(url)
print(driver.current_url) # 当前url
savepic()

# 模拟输入用户名密码
username = driver.find_element_by_id('userMail') # 获取元素
username.send_keys('[email protected]')
password = driver.find_element_by_id('userPassword') # 获取元素
password.send_keys('cici.com18')
savepic()

password.send_keys(Keys.ENTER)
print('-' * 30)

for i in range(5):
    time.sleep(1)
    try:
        # xpath定位数据
        ele = driver.find_element_by_xpath('//div[@title="cici_wayne"]')
        print(ele.tag_name, '!~~~~~~')
        print(ele.get_attribute('data-user-id'))
        savepic()
        break
    except Exception as e:
        print(e)

# 模拟登录后获得cookies
cookies = driver.get_cookies()
print(cookies)

for c in cookies:
    print(type(c))
    print(c)
print('-' * 30)

import requests
from requests.cookies import RequestsCookieJar

jar = RequestsCookieJar()
for c in cookies:
    jar.set(c.get('name'), c.get('value'))

print(jar)

headers = {'User-agent': "Mozilla/5.0 (Windows; U; "
"Windows NT 6.1; zh-CN) AppleWebKit/537.36(KHTML, like Gecko) Version / 5.0.1Safari / 537.36"}

print('========== 不带cookie ==========')
response = requests.get(url, headers=headers)
with response:
    print(10, response.url)  # 这就是登录页
    with open('d:/nocookie.html', 'w', encoding='utf-8') as f:
        f.write(response.text)

print('========== 带cookie ==========')
response = requests.get(url, headers=headers, cookies=jar)
with response:
    print(11, response.url)
    with open('d:/withcookie.html', 'w', encoding='utf-8') as f:
        f.write(response.text)

driver.quit()

页面等待

越来越多的页面使用Ajax这样的异步加载技术,这就会导致代码中要访问的页面元素,还没有被加载就被访问了,抛出异常。

方法1 线程休眠

使用time.sleep(n)来等待数据加载

配合循环一直等到数据被加载完成,可以解决很多页面动态加载或加载慢的问题。当然可以设置一个最大重试次数,以免一直循环下去。参看本文“处理异步请求”

方法2 Selenium等待

Selenium的等待分为:显示等待和隐式等待

  • 隐式等待,等待特定的时间
  • 显式等待,指定一个条件,一直等到这个条件成立后继续执行,也可以设置超时时间,超时会抛异常

参考:

  • 显示等待
    from selenium import webdriver
    from selenium.webdriver.common.by import By
    from selenium.webdriver.common.keys import Keys
    from selenium.webdriver.support.wait import WebDriverWait
    from selenium.webdriver.support import expected_conditions as EC
    from urllib import parse
    
    import datetime
    import random
    import time
    
    # 创建ChromeOptions对象,配置Chrome Headless选项
    options = webdriver.ChromeOptions()
    options.add_argument('--headless') # 设置Chrome为Headless模式
    # options.add_argument("--disable-gpu")  # 禁用GPU加速
    
    driver = webdriver.Chrome(options=options)
    # 设置窗口大小
    driver.set_window_size(1920, 1080)
    
    # 保存图片
    def savepic():
        base_path = 'd:/tmp/'
        filename = '{}{:%Y%m%d%H%M%S}{:03}.png'.format(
            base_path,datetime.datetime.now(),random.randint(1, 100)
        )
        driver.save_screenshot(filename)
    
    url = 'https://www.baidu.com/s?' + parse.urlencode({
        'wd': 'python'
    })
    driver.get(url)
    print(url) # https://www.baidu.com/s?wd=python
    savepic()
    
    try:
        print('+' * 30)
        WebDriverWait(driver, timeout=5).until(
            lambda d: d.find_element(By.ID, 'content_left') # 这个元素他在,但还没来得及显示
        )
        savepic()
        print('+'*30)
    except Exception as e:
        print(e)
    finally:
        driver.quit()
    

    上面代码还没有显示出来内容。所以可以在until中加入条件

    Expected Conditions: https://www.selenium.dev/documentation/webdriver/support_features/expected_conditions/

    expected_conditionsn内置条件 说明
    title_is 判断当前页面的title是否精确等于预期
    title_contains 判断当前页面的title是否包含预期字符串
    presence_of_element_located 判断某个元素是否被加到了dom树里,并不代表该元素一定可见
    visibility_of_element_located 判断某个元素是否可见。可见代表元素非隐藏,并且元素的宽和高都不等于0
    visibility_of 跟上面的方法做一样的事情,只是上面的方法要传入locator,这个方法直接传定位到的element就好了
    presence_of_all_elements_located 判断是否至少有1个元素存在于dom树中。举个例子,如果页面上有n个元素的class都是’column-md-3’,那么只要有1个元素存在,这个方法就返回True
    text_to_be_present_in_element 判断某个元素中的text是否包含了预期的字符串
    text_to_be_present_in_element_value 判断某个元素中的value属性是否包含了预期的字符串
    frame_to_be_available_and_switch_to_it 判断该frame是否可以switch进去,如果可以的话,返回True并且switch进去,否则返回False
    invisibility_of_element_located 判断某个元素中是否不存在于dom树或不可见
    element_to_be_clickable 判断某个元素中是否可见并且是enable的,这样的话才叫clickable
    staleness_of 等某个元素从dom树中移除,注意,这个方法也是返回True或False
    element_to_be_selected 判断某个元素是否被选中了,一般用在下拉列表
    element_selection_state_to_be 判断某个元素的选中状态是否符合预期
    element_located_selection_state_to_be 跟上面的方法作用一样,只是上面的方法传入定位到的element,而这个方法传入locator
    alert_is_present 判断页面上是否存在alert
    from selenium import webdriver
    from selenium.webdriver.common.by import By
    from selenium.webdriver.common.keys import Keys
    from selenium.webdriver.support.wait import WebDriverWait
    from selenium.webdriver.support import expected_conditions as EC
    from urllib import parse
    
    import datetime
    import random
    import time
    
    # 创建ChromeOptions对象,配置Chrome Headless选项
    options = webdriver.ChromeOptions()
    options.add_argument('--headless') # 设置Chrome为Headless模式
    # options.add_argument("--disable-gpu")  # 禁用GPU加速
    
    driver = webdriver.Chrome(options=options)
    # 设置窗口大小
    driver.set_window_size(1920, 1080)
    
    # 保存图片
    def savepic():
        base_path = 'd:/tmp/'
        filename = '{}{:%Y%m%d%H%M%S}-{:03}.png'.format(
            base_path,datetime.datetime.now(),random.randint(1, 100)
        )
        driver.save_screenshot(filename)
    
    url = 'https://www.baidu.com/s?' + parse.urlencode({
        'wd': 'python'
    })
    driver.get(url)
    print(url) # https://www.baidu.com/s?wd=python
    savepic()
    
    try:
        print('+' * 30)
        WebDriverWait(driver, timeout=5).until(
            # lambda d: d.find_element(By.ID, 'content_left') # 这个元素他在,但还没来得及显示
            # EC.presence_of_element_located((By.ID, 'content_left')) # 合上面效果一样
    
            # lambda d: d.find_element(By.ID, 'content_left').is_displayed()
            EC.visibility_of_element_located((By.ID, 'content_left'))
        )
        savepic()
        print('+'*30)
    except Exception as e:
        print(e)
    finally:
        driver.quit()
    

    PhantomJS

    # 定位搜索框,搜索电影
    from selenium import webdriver # 核心对象
    import datetime
    import random
    
    from selenium.webdriver.common.by import By
    # 键盘操作
    from selenium.webdriver.common.keys import Keys
    # WebDriverWait 负责循环等待
    from selenium.webdriver.support.wait import WebDriverWait
    # expected_conditions条件,负责条件触发
    from selenium.webdriver.support import expected_conditions as EC
    
    
    # 指定PhantomJS的执行文件路径
    driver = webdriver.PhantomJS('d:/python/phantomjs/bin/phantomjs.exe')
    # 设置窗口大小
    driver.set_window_size(1280, 1024)
    
    # 保存图片
    def savepic():
        base_dir = 'd:/'
        filename = '{}{:%Y%m%d%H%M%S}{:03}.png'.format(
            base_dir,
            datetime.datetime.now(),
            random.randint(1, 100)
        )
    
        driver.save_screenshot(filename)
    
    # 打开网页GET方法,模拟浏览器地址栏输入网址
    from selenium.webdriver.common.by import By
    from selenium.webdriver.support.ui import WebDriverWait # available since 2.4.0
    from selenium.webdriver.support import expected_conditions as EC # available since 2.26.0
    
    url = 'http://cn.bing.com/search?q=douban+TRON'
    driver.get(url)
    print(url)
    print(driver.current_url) # 当前url
    
    try:
        ele = WebDriverWait(driver, 20).until(
    
            EC.visibility_of_element_located(
                (By.ID, "b_results")
            )
        )
    
        savepic()
        print('-' * 30)
    except Exception as e:
        print(e)
    finally:
        driver.quit()
    

    默认的查看频率是0.5秒每次,当元素存在则立即返回这个元素。

    上面代码发现看到的还是一片空白,所以要使用另外一个条件visibility_of_element_located

    EC.visibility_of_element_located((By.ID, 'content_left')) # 元组
    
  • 隐式等待

    如果出现No Such Element Exception,则智能的等待指定的时长。缺省值是0

    from selenium import webdriver
    from selenium.webdriver.common.by import By
    from selenium.webdriver.common.keys import Keys
    from selenium.webdriver.support.wait import WebDriverWait
    from selenium.webdriver.support import expected_conditions as EC
    from urllib import parse
    
    import datetime
    import random
    import time
    
    # 创建ChromeOptions对象,配置Chrome Headless选项
    options = webdriver.ChromeOptions()
    options.add_argument('--headless') # 设置Chrome为Headless模式
    # options.add_argument("--disable-gpu")  # 禁用GPU加速
    
    driver = webdriver.Chrome(options=options)
    # 设置窗口大小
    driver.set_window_size(1920, 1080)
    driver.implicitly_wait(10) # 统一设定隐式等待秒数,如果find_element找不到,会等待10秒
    
    # 保存图片
    def savepic():
        base_path = 'd:/tmp/'
        filename = '{}{:%Y%m%d%H%M%S}-{:03}.png'.format(
            base_path,datetime.datetime.now(),random.randint(1, 100)
        )
        driver.save_screenshot(filename)
    
    url = 'https://www.baidu.com/s?' + parse.urlencode({
        'wd': 'python'
    })
    driver.get(url)
    print(url) # https://www.baidu.com/s?wd=python
    savepic()
    
    try:
        print('+' * 30)
        # WebDriverWait(driver, timeout=5).until(
        #     # lambda d: d.find_element(By.ID, 'content_left') # 这个元素他在,但还没来得及显示
        #     # EC.presence_of_element_located((By.ID, 'content_left')) # 合上面效果一样
        #
        #     # lambda d: d.find_element(By.ID, 'content_left').is_displayed()
        #     EC.visibility_of_element_located((By.ID, 'content_left'))
        # )
        e = driver.find_element(By.ID, 'content_left')
        print(e, e.is_displayed())
        savepic()
        print('+'*30)
    except Exception as e:
        print(e)
    finally:
        driver.quit()
    

    PhantomJS

    # 定位搜索框,搜索电影
    from selenium import webdriver # 核心对象
    import datetime
    import random
    
    from selenium.webdriver.common.by import By
    
    # 指定PhantomJS的执行文件路径
    driver = webdriver.PhantomJS('d:/python/phantomjs/bin/phantomjs.exe')
    # 设置窗口大小
    driver.set_window_size(1280, 1024)
    driver.implicitly_wait(10) # 统一设定隐式等待秒数
    
    # 保存图片
    def savepic():
        base_dir = 'd:/'
        filename = '{}{:%Y%m%d%H%M%S}{:03}.png'.format(
            base_dir,
            datetime.datetime.now(),
            random.randint(1, 100)
        )
    
        driver.save_screenshot(filename)
    
    # 打开网页GET方法,模拟浏览器地址栏输入网址
    
    url = 'http://cn.bing.com/search?q=douban+TRON'
    driver.get(url)
    print(url)
    print(driver.current_url) # 当前url
    
    try:
        # ele = WebDriverWait(driver, 20).until(
        #
        #     EC.visibility_of_element_located(
        #         (By.ID, "b_results")
        #     )
        # )
        driver.find_element_by_id('b_results')
        # driver.find_element_by_id('abcdefgh')
        savepic()
        print('-' * 30)
    except Exception as e:
        print(e)
    finally:
        driver.quit()
    
    driver.implicitly_wait(10) # 统一设定隐式等待秒数,如果find_element找不到,会等待10秒
    

总结

Selenium的WebDriver是其核心,从Selenium2开始就是最重要的编程核心对象,在Selenium3中更是如此。

和浏览器交互全靠它,它可以:

  • 打开URL,可以跟踪跳转,可以返回当前页面的实际URL
  • 获取页面的title
  • 处理cookie
  • 控制浏览器的操作,例如前进、后退、刷新、关闭,最大化等
  • 执行JS脚本
  • 在DOM中搜索页面元素Web Element,指定的或一批,find系方法
  • 操作网页元素
    • 模拟下拉框操作Select(element)
    • 在元素上模拟鼠标操作click()
    • 在元素上模拟键盘输入send_keys()
    • 获取元素文字 text
    • 获取元素的属性 get_attribute()

Selenium通过WebDriver来驱动浏览器工作,而浏览器是一个个独立的浏览器进程。

Scrapy 框架

Scrapy框架

Scrapy是用Python实现的一个为了爬取网站数据,提取结构性数据而编写的应用框架。 可以应用在包括数据挖掘、信息处理或存储历史数据等一系列的程序中。

Scrapy使用Twisted基于事件的高效异步网络框架来处理网络通信,可以加快下载速度,不用自己去实现异步框架,并且包含了各种中间件接口,可以灵活的完成各种需求。

数据流(Data flow)

  1. 引擎打开一个网站(open a domain),找到处理该网站的Spider并向该spider请求第一个(批)要爬取的URL(s)
  2. 引擎从Spider中获取到第一个要爬取的URL并加入到调度器(Scheduler)作为请求以备调度
  3. 引擎向调度器请求下一个要爬取的URL
  4. 调度器返回下一个要爬取的URL给引擎,引擎将URL通过下载中间件并转发给下载器(Downloader)
  5. 一旦页面下载完毕,下载器生成一个该页面的Response,并将其通过下载中间件发送给引擎
  6. 引擎从下载器中接收到Response,然后通过Spider中间件发送给Spider处理
  7. Spider处理Response并返回提取到的Item及(跟进的)新的Request给引擎
  8. 引擎将Spider返回的Item交给Item Pipeline,将Spider返回的Request交给调度器
  9. (从第3步)重复执行,直到调度器中没有待处理的request,引擎关闭

注意:只有当调度器中没有任何request了,整个程序才会停止执行。如果有下载失败的URL,会重新下载

组件

Scrapy Engine

  • 引擎,负责控制数据流在系统中所有组件中流动,并在相应动作发生时触发事件。 此组件相当于爬虫的“大脑”,是整个爬虫的调度中心。

调度器(Scheduler)

  • 调度器接收从引擎发送过来的request,并将他们入队,以便之后引擎请求他们时提供给引擎。
  • 初始的爬取URL和后续在页面中获取的待爬取的URL将放入调度器中,等待爬取。同时调度器会 自动去除重复的URL (如果特定的URL不需要去重也可以通过设置实现,如post请求的URL)

下载器(Downloader)

  • 下载器负责获取页面数据并提供给引擎,而后提供给spider。

Spiders爬虫

  • Spider是编写的类,作用如下:
  • Scrapy用户编写用于分析response并提取item(即获取到的item)
  • 额外跟进的URL,将额外跟进的URL提交给引擎,加入到Scheduler调度器中。将每个spider负责处理一个特定(或一些)网站。

Item Pipeline

  • Item Pipeline负责处理被spider提取出来的item。典型的处理有清理、 验证及持久化(例如存取到数据库中)。
  • 当页面被爬虫解析所需的数据存入Item后,将被发送到项目管道(Pipeline),并经过设置好次序的pipeline程序处理这些数据,最后将存入本地文件或存入数据库
  • 类似管道 $ ls | grep test 或者类似于Django 模板中的过滤器。

以下是item pipeline的一些典型应用:

  • 清理HTML数据
  • 验证爬取的数据(检查item包含某些字段)
  • 查重(或丢弃)
  • 将爬取结果保存到数据库中

下载器中间件(Downloader middlewares)

简单讲就是自定义扩展下载功能的组件

  • 下载器中间件,是在引擎和下载器之间的特定钩子(specific hook),处理它们之间的请求request和响应response。 它提供了一个简便的机制,通过插入自定义代码来扩展Scrapy功能。
  • 通过设置下载器中间件可以实现爬虫自动更换user-agent、IP等功能。

Spider中间件(Spider middlewares)

Spider中间件,是在引擎和Spider之间的特定钩子(specific hook),处理spider的输入(response)和输出(items或requests)。 也提供了同样的简便机制,通过插入自定义代码来扩展Scrapy功能。

安装scrapy

#安装scrapy框架
pip install scrapy

windows下出现如下问题

copying src\twisted\words\xish\xpathparser.g -> build\lib.win-amd64-3.5\twisted\words\xish
 running build_ext
 building 'twisted.test.raiser' extension
 error: Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++ Build 
Tools": http://landinghub.visualstudio.com/visual-cpp-build-tools

解决方案是,下载编译好的twisted,https://www.lfd.uci.edu/~gohlke/pythonlibs/#twisted
python3.5 下载 Twisted-18.4.0-cp35-cp35m-win_amd64.whl
python3.6 下载 Twisted-18.4.0-cp36-cp36m-win_amd64.whl

安装twisted
$ pip install Twisted-18.4.0-cp35-cp35m-win_amd64.whl
之后在安装scrapy就没有什么问题了

安装好,使用scrapy命令看看

(.venv1) PS D:\project\pyproj\pycharm> scrapy
Scrapy 2.13.3 - no active project

Usage:
  scrapy <command> [options] [args]

Available commands:
  bench         Run quick benchmark test
  fetch         Fetch a URL using the Scrapy downloader
  genspider     Generate new spider using pre-defined templates
  runspider     Run a self-contained spider (without creating a project)
  settings      Get settings values
  shell         Interactive scraping console
  startproject  Create new project
  version       Print Scrapy version
  view          Open URL in browser, as seen by Scrapy

  [ more ]      More commands available when run from project directory

Use "scrapy <command> -h" to see more info about a command

#startproject 创建完项目,命令选项
(.venv1) PS D:\project\pyproj\pycharm> scrapy      
Scrapy 2.13.3 - active project: first

Usage:
  scrapy <command> [options] [args]

Available commands:
  bench         Run quick benchmark test
  check         Check spider contracts
  crawl         Run a spider
  edit          Edit spider
  fetch         Fetch a URL using the Scrapy downloader
  genspider     Generate new spider using pre-defined templates
  list          List available spiders
  parse         Parse URL (using its spider) and print the results
  runspider     Run a self-contained spider (without creating a project)
  settings      Get settings values
  shell         Interactive scraping console
  startproject  Create new project
  version       Print Scrapy version
  view          Open URL in browser, as seen by Scrapy

Use "scrapy <command> -h" to see more info about a command

Scrapy从2.x开始不支持python2,从2.4.x开始要求python3.6+

Scrapy开发

项目编写流程

  • 创建项目
    • 使用 scrapy startproject proname 创建一个scrapy项目
    • scrapy startproject <project_name> [project_dir]
  • 编写item
    • 在items.py中编写Item类,明确从response中提取的item
  • 编写爬虫
    • 编写spiders/proname_spider.py,即爬取网站的spider并提取出item
  • 编写item pipeline
    • item的处理,可以存储

创建项目

豆瓣书评爬取

标签为“编程”,第一页、第二页链接

随便找一个目录来创建项目,执行下面命令

scrapy startproject first .

会产生如下目录和文件

./
 ├─ scrapy.cfg
 └─ first
    ├─ items.py
     ├─ middlewares.py
     ├─ pipelines.py
     ├─ settings.py
     ├─ __init__.py
     └─ spiders
         └─ __init__.py


./ 项目目录
- scrapy.cfg   #必须有的重要的项目的配置文件
- first        #项目目录
- __init__.py  #必须有,包文件
- items.py     #定义Item类,从scrapy.Item继承,里面定义scrapy.Field类实例
- pipelines.py #重要的是process_item()方法,处理item
- settings.py:
  - BOT_NAME                #爬虫名
  - ROBOTSTXT_OBEY = True   #是否遵从robots协议
  - USER_AGENT = ''         #指定爬取时使用
  - CONCURRENT_REQEUST = 16 #默认16个并行
  - DOWNLOAD_DELAY = 3      #下载延时,一般要设置,不宜过快发起连续请求
  - COOKIES_ENABLED = False #缺省是启用,一般需要登录时才需要开启cookie
  - SPIDER_MIDDLEWARES      #爬虫中间件
  - DOWNLOADER_MIDDLEWARES  #下载中间件
    - 'first.middlewares.FirstDownloaderMiddleware': 543
    - 543 #越小优先级越高
  - ITEM_PIPELINES          #管道配置
    - 'firstscrapy.pipelines.FirstscrapyPipeline': 300
    - item #交给哪一个管道处理,300 越小优先级越高
- spiders目录
  - __init__.py             #必须有,可以在这里写爬虫类,也可以写爬虫子模块
# settings.py参考
BOT_NAME = 'first'
SPIDER_MODULES = ['first.spiders']
NEWSPIDER_MODULE = 'first.spiders'

USER_AGENT = "Mozilla/5.0 (Windows NT 6.1)AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.75 Safari/537.36"
ROBOTSTXT_OBEY = False

DOWNLOAD_DELAY = 3

# Disable cookies (enabled by default)
COOKIES_ENABLED = True

注意一定要更改User-Agent,否则访问 https://book.douban.com/ 会返回403

编写Item

在items.py中编写

import scrapy

class BookItem(scrapy.Item):
    title = scrapy.Field() # 书名
    rate = scrapy.Field() # 评分

编写爬虫

为爬取豆瓣书评编写爬虫类,在spiders目录下

编写的爬虫类需要继承自scrapy.Spider,在这个类中定义爬虫名、爬取范围、其实地址等

在scrapy.Spider中parse方法未实现,所以子类应该实现parse方法。该方法传入response对象

# scrapy源码中
#scrapy.spiders.Spider.parse
class Spider():
    if TYPE_CHECKING:
        parse: CallbackT
    else:

        def parse(self, response: Response, **kwargs: Any) -> Any:  # 解析返回的内容
            raise NotImplementedError(
                f"{self.__class__.__name__}.parse callback is not defined"
            )

爬取读书频道,tag为“编程”的书名和评分

使用模板创建spider

scrapy genspider -t basic book douban.com

#./first/spiders/book.py

./first/spiders/book.py

import scrapy

class BookSpider(scrapy.Spider): # BookSpider 
    name = 'doubanbook' # 爬虫名,可修改,重要 
    allowed_domains = ['douban.com'] # 爬虫爬取范围 
    url = 'https://book.douban.com/tag/%E7%BC%96%E7%A8%8B?start=0&type=T' 
    start_urls = [url] # 起始URL 

    # 下载器获取了WEB Server的response就行了,parse就是解析响应的内容 
    def parse(self, response): 
        print(type(response), '~~~~~~~~~') #scrapy.http.response.html.HtmlResponse 
        print(response) 
        print('-' * 30)

使用crawl爬取子命令

scrapy list
scrapy crawl -h
scrapy crawl [options] <spider>


#指定爬虫名称开始爬取
scrapy crawl doubanbook

#可以不打印日志
scrapy crawl doubanbook --nolog

response是服务器端HTTP响应,它是scrapy.http.response.html.HtmlResponse类。由此,修改代码如下

import scrapy
from scrapy.http.response.html import HtmlResponse

class BookSpider(scrapy.Spider): # BookSpider
    name = 'doubanbook' # 爬虫名 
    allowed_domains = ['douban.com'] # 爬虫爬取范围 
    url = 'https://book.douban.com/tag/%E7%BC%96%E7%A8%8B?start=0&type=T' 
    start_urls = [url] # 起始URL 

    # 下载器获取了WEB Server的response就行了,parse就是解析响应的内容 
    def parse(self, response:HtmlResponse): 
        print(type(response)) #scrapy.http.response.html.HtmlResponse 
        print('-'*30) 
        print(type(response.text), type(response.body))
        print('-'*30)
        print(response.encoding)
        with open('o:/testbook.html', 'w', encoding='utf-8') as f:
            try: 
                f.write(response.text) 
                f.flush() 
            except Exception as e: 
                print(e)
import scrapy
from scrapy.http import HtmlResponse

class BookSpider(scrapy.Spider):
    name = "doubanbook"
    allowed_domains = ["douban.com"]
    url = "https://book.douban.com/tag/%E7%BC%96%E7%A8%8B?start=0&type=T"
    start_urls = [url]

    def parse(self, response:HtmlResponse): # parse函数,解析html
        print(response.status)
        with open('d:/tmp/book.html', 'wb') as f:
            f.write(response.body)
        # titles = response.xpath('//li[@class="subject-item"]//h2/a/text()').extract() # SelectorList -> list
        # for title in titles:
        #     print(type(title), title.strip())
解析HTML

爬虫获得的内容response对象,可以使用解析库来解析

scrapy包装了lxml,父类TextResponse类也提供了xpath方法和css方法,可以混合使用这两套接口解析HTML。

选择器参考: https://docs.scrapy.org/en/latest/topics/selectors.html#using-selectors

xpath返回scrapy.selector.unified.SelectorList对象,里面装着scrapy.selector.unified.Selector对象。

t.py

import scrapy
from scrapy.http.response.html import HtmlResponse

response = HtmlResponse('file:///O:/testbook.html', encoding='utf-8') # 构造对象

with open('o:/testbook.html', encoding='utf8') as f: 
    response._set_body(f.read()) # 填充数据 
    #print(response.text)

    # 获取所有标题及评分
    # xpath解析 
    subjects = response.xpath('//li[@class="subject-item"]') 
    for subject in subjects: 
        title = subject.xpath('.//h2/a/text()').extract() # list 
        print(title[0].strip())

        rate = subject.xpath('.//span[@class="rating_nums"]/text()').extract()
        print(rate[0].strip()) 

    print('-'*30) 
    # css解析 
    subjects = response.css('li.subject-item') 
    for subject in subjects: 
        title = subject.css('h2 a::text').extract() 
        print(title[0].strip()) 

        rate = subject.css('span.rating_nums::text').extract() 
        print(rate[0].strip()) 
    print('-'*30)

    # xpath和css混合使用、正则表达式匹配 
    subjects = response.css('li.subject-item') 
    for subject in subjects:
    # 提取链接
        href =subject.xpath('.//h2').css('a::attr(href)').extract()
        print(href[0])

        # 使用正则表达式
        id = subject.xpath('.//h2/a/@href').re(r'\d*99\d*')
        if id: 
            print(id[0]) 

        # 要求显示9分以上数据 
        rate = subject.xpath('.//span[@class="rating_nums"]/text()').re(r'^9.*') 
        # rate = subject.css('span.rating_nums::text').re(r'^9\..*') 
        if rate: 
            print(rate)

上例练习t.py

from scrapy.http import HtmlResponse
from scrapy.selector.unified import SelectorList, Selector

response = HtmlResponse('', encoding='utf-8')
print(response)

with open('d:/tmp/book.html', 'rb') as f:
    response._set_body(f.read())
    # print(response.text)

    # xpath解析
    subjects:SelectorList = response.xpath('//li[@class="subject-item"]')
    for s in subjects:
        # print(type(s), s)
        s:Selector = s
        titlelist = s.xpath('.//h2/a/text()') # xpath css => SelectorList -> List
        # print(titlelist)
        # print(titlelist.extract())
        # print(titlelist.getall())
        # print(titlelist.extract_first())
        # print(titlelist.get().strip())
        title = titlelist.get('').strip()
        if not title: continue
        rate = s.xpath('.//span[@class="rating_nums"]/text()').get('0').strip()
        print(title, rate)
    print('-'*30)

    # css解析
    subjects = response.css('li.subject-item')
    for s in subjects:
        title = s.css('h2 a::text').get('').strip()
        rate = s.css('span.rating_nums::text').get('0').strip()
        print(title, rate)
    print('-' * 30)

    # 混合提取
    subjects = response.css('li.subject-item')
    for s in subjects:
        title = s.css('h2').xpath('./a/text()').get('').strip()
        rate = s.xpath('.//span[@class="rating_nums"]/text()').get('0').strip()

    # 提取一定分数的title rate怎么做?
    subjects = response.css('li.subject-item')
    for s in subjects:
        rate = s.xpath('.//span[@class="rating_nums"]/text()').re('^9.*') # re -> list
        if rate:
            title = s.css('h2').xpath('./a/text()').get('').strip()
            print(title, rate)

建议scrapy中使用xpath解析。

item封装数据
# spiders/books.py
class BookSpider(scrapy.Spider):
    name = "doubanbook"
    allowed_domains = ["douban.com"]
    url = "https://book.douban.com/tag/%E7%BC%96%E7%A8%8B?start=0&type=T"
    start_urls = [url]

    def parse(self, response:HtmlResponse): # parse函数,解析html
        items = []
        subjects = response.xpath('//li[@class="subject-item"]')
        for s in subjects:
            title = s.xpath('.//h2/a/text()').get('').strip()
            if not title: continue
            rate = s.xpath('.//span[@class="rating_nums"]/text()').get('0').strip()
            print(title, rate)
            item = BookItem()
            item['title'] = title
            item['rate'] = rate
            items.append(item)

        return items # parse方法必须返回可迭代对象
        # print(response.status)
        # with open('d:/tmp/book.html', 'wb') as f:
        #     f.write(response.body)
        # titles = response.xpath('//li[@class="subject-item"]//h2/a/text()').extract() # SelectorList -> list
        # for title in titles:
        #     print(type(title), title.strip())

使用命令保存return的数据

scrapy crawl -h
# --output=FILE, -o FILE dump scraped items into FILE (use - for stdout)
# 文件扩展名支持'json', 'jsonlines', 'jl', 'csv', 'xml', 'marshal', 'pickle'

scrapy crawl doubanbook -o dbbooks.json

pipeline处理

将bookspider.py中BookSpider改成生成器,只需要把 return items 改造成 yield item ,即由产生一个列表变成yield一个个item

脚手架帮我们创建了一个pipelines.py文件和一个类

开启pipeline

settings.py

# Configure item pipelines
# See https://doc.scrapy.org/en/latest/topics/item-pipeline.html
ITEM_PIPELINES = {
    'first.pipelines.FirstPipeline': 300,
}

整数300表示优先级,越小越高。取值范围为0-1000

常用方法
名称 参数  
process_item(self, item, spider) item爬取的一个个数据 必须
  spider表示item的爬取者  
  每一个item处理都调用  
  返回一个Item对象,或抛出DropItem异常  
  被丢弃的Item对象将不会被之后的pipeline组件处理  
open_spider(self, spider) spider表示被开启的spider 调用一次 可选
close_spider(self, spider) spider表示被关闭的spider 调用一次 可选
__init__(self) spider实例创建时调用一次 可选

常用方法

#.\first\pipelines.py
# See: https://docs.scrapy.org/en/latest/topics/item-pipeline.html
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem


class FirstPipeline:
    def __init__(self): # 全局设置
        print('~~~init~~~')

    def open_spider(self, spider): # 当某spider开启时调用
        print('-' * 30)
        print(type(spider),spider.name, spider)
    def close_spider(self, spider): # 当某spider关闭时调用
        print('*' * 30)
        print(type(spider),spider.name, spider)

    def process_item(self, item, spider): # 每一个item都调用一次这个方法
        print(type(spider), spider.name, '++++++++++++++++++')
        print(item,'~~~~~~~~~~~~~~')
        return item # 给下一级 item:Item dict None
        # raise DropItem # 扔掉

测试一下

(.venv1) PS D:\project\pyproj\pycharm> scrapy crawl  doubanbook

需求

通过pipeline将爬取的数据存入json文件中

#D:\project\pyproj\pycharm\first\spiders\book.py
import scrapy
from scrapy.http import HtmlResponse
from ..items import BookItem

class BookSpider(scrapy.Spider):
    name = "doubanbook" # 爬虫名称
    allowed_domains = ["douban.com"] # 爬虫范围
    url = "https://book.douban.com/tag/%E7%BC%96%E7%A8%8B?start=0&type=T"
    start_urls = [url] # 起始URL

    # spider上自定义配置信息
    custom_settings = {
        'filename':'d:/tmp/b1.json'
    }

    def parse(self, response:HtmlResponse): # parse函数,解析html
        # items = []
        subjects = response.xpath('//li[@class="subject-item"]')
        for s in subjects:
            title = s.xpath('.//h2/a/text()').get('').strip()
            if not title: continue
            rate = s.xpath('.//span[@class="rating_nums"]/text()').get('0').strip()
            item = BookItem()
            item['title'] = title
            item['rate'] = rate
            # items.append(item)
            yield item
        # return items # parse方法必须返回可迭代对象

        # print(response.status)
        # with open('d:/tmp/book.html', 'wb') as f:
        #     f.write(response.body)
        # titles = response.xpath('//li[@class="subject-item"]//h2/a/text()').extract() # SelectorList -> list
        # for title in titles:
        #     print(type(title), title.strip())

#D:\project\pyproj\pycharm\first\pipelines.py
# Define your item pipelines here
#
# Don't forget to add your pipeline to the ITEM_PIPELINES setting
# See: https://docs.scrapy.org/en/latest/topics/item-pipeline.html
import json

# useful for handling different item types with a single interface
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem


class FirstPipeline:
    def __init__(self): # 全局设置
        print('~~~init~~~')

    def open_spider(self, spider): # 当某spider开启时调用
        print('-' * 30)
        print(type(spider),spider.name, spider)
        # self.file = open('d:/tmp/book.json', 'w', encoding='utf-8')
        print(spider.settings.get('filename', 'aaaa'))
        self.file = open(spider.settings['filename'], 'w', encoding='utf-8')
        self.file.write('[\n')

    def close_spider(self, spider): # 当某spider关闭时调用
        print('*' * 30)
        print(type(spider),spider.name, spider)
        self.file.write(']')
        self.file.close()

    def process_item(self, item, spider): # 每一个item都调用一次这个方法
        print(type(spider), spider.name, '++++++++++++++++++')
        print(item,'~~~~~~~~~~~~~~')
        self.file.write(json.dumps(dict(item)) + ',\n')
        return item # 给下一级 item:Item dict None
        # raise DropItem # 扔掉
#D:\project\pyproj\pycharm\first\spiders\book.py
import json

# useful for handling different item types with a single interface
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem


class FirstPipeline:
    def __init__(self, path): # 全局设置
        print('~~~init~~~')
        print(path, '&&&&&&&&&&&')
        self.path = path

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            path = crawler.settings.get('FILENAME') # 方法3,
        )

    def open_spider(self, spider): # 当某spider开启时调用
        print('-' * 30)
        print(type(spider),spider.name, spider)
        # self.file = open('d:/tmp/book.json', 'w', encoding='utf-8')

        # print(spider.settings.get('filename', 'aaaa')) # 方法1 自定义spider配置{'filename':'d:/tmp/b1.json'}
        # print(spider.settings.get('FILENAME', 'cccc')) # 方法2 全局settings.py配置 FILENAME = ':/tmp/b2.json'
        # self.file = open(spider.settings['filename'], 'w', encoding='utf-8')
        self.file = open(self.path, 'w', encoding='utf-8')
        self.file.write('[\n')

    def close_spider(self, spider): # 当某spider关闭时调用
        print('*' * 30)
        print(type(spider),spider.name, spider)
        self.file.write(']')
        self.file.close()

    def process_item(self, item, spider): # 每一个item都调用一次这个方法
        print(type(spider), spider.name, '++++++++++++++++++')
        print(item,'~~~~~~~~~~~~~~')
        self.file.write(json.dumps(dict(item)) + ',\n')
        return item # 给下一级 item:Item dict None
        # raise DropItem # 扔掉

url提取

如果要爬取下一页内容,可以自己分析每一页的页码变化,也可以通过提取分页栏的链接

测试提取路径,t.py

from scrapy.http import HtmlResponse
from scrapy.selector.unified import SelectorList, Selector

response = HtmlResponse('https://book.douban.com', encoding='utf-8')
print(response)

with open('d:/tmp/book.html', 'rb') as f:
    response._set_body(f.read())
    h = response.xpath('//span[@class="next"]//a/@href')
    print(h)
    print(h.re(r'.*start=[24]0[^\d]+'))
    # 添加路径
    print(response.urljoin(h.re(r'.*start=[24]0[^\d]+')[0]))
# spider/books.py
import scrapy
from scrapy.http.response.html import HtmlResponse
from ..items import BookItem

class BookSpider(scrapy.Spider): # BookSpider 
    name = 'doubanbook' # 爬虫名
    allowed_domains = ['douban.com'] # 爬虫爬取范围
    url = 'https://book.douban.com/tag/%E7%BC%96%E7%A8%8B?start=0&type=T' 
    start_urls = [url] # 起始URL

    # spider上自定义配置信息 
    custom_settings = {  
        'filename' : 'o:/books.json'
     }

    # 下载器获取了WEB Server的response就行了,parse就是解析响应的内容    
    def parse(self, response:HtmlResponse): 
        #items = []  
        # xpath解析    
        # 获取下一页,只是测试,所以使用re来控制页码 
        print('-' * 30)
        urls = response.xpath('//div[@class="paginator"]/span[@class="next"]/a/@href').re(
                            r'.*start=[24]\d[^\d].*') 
        print(urls)
        print('-' * 30)
        yield from (scrapy.Request(response.urljoin(url)) for url in urls)   
        print('++++++++++++++++++++++++')

        subjects = response.xpath('//li[@class="subject-item"]') 
        for subject in subjects:
        # 解决图书副标题拼接 
            title = "".join(map(lambda x:x.strip(), subject.xpath('.//h2/a//text()').extract())) 
            rate = subject.xpath('.//span[@class="rating_nums"]/text()').extract_first() 
            #print(rate) # 有的没有评分,要注意可能返回None

            item = BookItem()
            item['title'] = title
            item['rate'] = rate 
            #items.append(item) 
            yield item

        #return items

同上练习

# spider/books.py
import scrapy
from scrapy.http import HtmlResponse
from ..items import BookItem

class BookSpider(scrapy.Spider):
    name = "doubanbook" # 爬虫名称
    allowed_domains = ["douban.com"] # 爬虫范围
    url = "https://book.douban.com/tag/%E7%BC%96%E7%A8%8B?start=0&type=T"
    start_urls = [url] # 起始URL

    # spider上自定义配置信息
    custom_settings = {
        'filename':'d:/tmp/b1.json'
    }

    def parse(self, response:HtmlResponse): # parse函数,解析html
        # 提取后续爬取的链接
        urls = response.xpath('//div[@class="paginator"]//a/@href').re(r'.*start=[24]0[^\d]+')
        for url in urls:
            print('*+'*30)
            print(response.urljoin(url))
            yield scrapy.http.Request(response.urljoin(url)) # => 如果是请求就进scheduler
        # request\None\item
        subjects = response.xpath('//li[@class="subject-item"]')
        for s in subjects:
            title = s.xpath('.//h2/a/text()').get('').strip()
            if not title: continue
            rate = s.xpath('.//span[@class="rating_nums"]/text()').get('0').strip()
            item = BookItem()
            item['title'] = title
            item['rate'] = rate
            # items.append(item)
            yield item # => pipeline 如果是item就进pipeline
        # return items # parse方法必须返回可迭代对象

        # print(response.status)
        # with open('d:/tmp/book.html', 'wb') as f:
        #     f.write(response.body)
        # titles = response.xpath('//li[@class="subject-item"]//h2/a/text()').extract() # SelectorList -> list
        # for title in titles:
        #     print(type(title), title.strip())

爬取豆瓣读书项目

需求

爬取豆瓣读书,并提取后续链接加入待爬取队列。

项目开发

创建项目

# 创建项目
(.venv1) PS D:\project\pyproj\pycharm> scrapy startproject books .

配置 settings.py

D:\project\pyproj\pycharm\books\settings.py

BOT_NAME = "books"

SPIDER_MODULES = ["books.spiders"]
NEWSPIDER_MODULE = "books.spiders"

ADDONS = {}

USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36"

ROBOTSTXT_OBEY = False
CONCURRENT_REQUESTS = 4
DOWNLOAD_DELAY = 1 # 1秒爬一次
COOKIES_ENABLED = False   # 每一次全新的请求;

编写 item

import scrapy

class BooksItem(scrapy.Item):
    title = scrapy.Field()  # 书名
    rate = scrapy.Field()  # 分数

    def __repr__(self):
        return "<BooksItem {}> ~~".format(dict(self))

创建爬虫

scrapy genspider [-t template] <name> <domain>

模板:-t 模板,这个选项可以使用一个模板来创建爬虫类,常用模板有 basic、crawl。

#使用模板创建spider,这里不用基础模板,而使用crawl模板
(.venv1) PS D:\project\pyproj\pycharm> scrapy genspider -t crawl doubanbook douban.com
Created spider 'doubanbook' using template 'crawl' in module:
  books.spiders.doubanbook

./
 ├─ scrapy.cfg
 └─ books
    ├─ items.py
     ├─ middlewares.py
     ├─ pipelines.py
     ├─ settings.py
     ├─ __init__.py
     └─ spiders
         ├─ __init__.py
         └─ doubanbook.py
(.venv1) PS D:\project\pyproj\pycharm> scrapy list 
doubanbook

使用模板创建爬虫 ,得到如下代码:

D:\project\pyproj\pycharm\books\spiders\doubanbook.py

import scrapy
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule


class DoubanbookSpider(CrawlSpider):
    name = "doubanbook"
    allowed_domains = ["douban.com"]
    start_urls = ["https://douban.com"]

    rules = (Rule(LinkExtractor(allow=r"Items/"), callback="parse_item", follow=True),)

    def parse_item(self, response):
        item = {}
        #item["domain_id"] = response.xpath('//input[@id="sid"]/@value').get()
        #item["name"] = response.xpath('//div[@id="name"]').get()
        #item["description"] = response.xpath('//div[@id="description"]').get()
        return item

scrapy.spiders.crawl.CrawlSpider 是 scrapy.spiders.Spider 的子类,增强了功能。在其中可以使用 LinkExtractor、Rule。

规则 Rule 定义:

  • rules 元组里面定义多条规则 Rule,用规则来方便地跟进链接。
  • LinkExtractor 从 response 中提取链接。
    • allow 需要一个对象或可迭代对象,其中配置正则表达式,表示匹配什么链接,即只关心 <a> 标签。
  • callback 定义匹配链接后执行的回调,特别注意 不要使用 parse 这个名称 。返回一个包含 Item 或 Request 对象的列表。
    • 参考 scrapy.spiders.crawl.CrawlSpider#_parse_response
  • follow 是否跟进链接。

由此得到一个本例程的规则,如下:

books\spiders\doubanbook.py

import scrapy
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from scrapy.http import HtmlResponse
from ..items import BooksItem

class DoubanbookSpider(CrawlSpider):
    name = "dbbook"
    allowed_domains = ["douban.com"]
    start_urls = ["https://book.douban.com/tag/%E7%BC%96%E7%A8%8B?start=0&type=T"]

    rules = (Rule(LinkExtractor(allow=r"start=[24]0&"), callback="parse_item", follow=False),)
    # 所有的起始页只到里面提取
    # Link <a href匹配,匹配到了某个href的链接,才算你感兴趣的链接,加入到待爬取队列(去重)

    def parse_item(self, response:HtmlResponse):
        for subject in response.xpath('//li[@class="subject-item"]'):
            item = BooksItem()
            title = subject.xpath('.//h2/a//text()').extract()
            # ['\n\n    编程不难\n\n\n    \n      ', ' : 全彩图解 + 微课 + Python 编程 ', '\n\n  '] 9.5 +++
            item['title'] = ''.join(map(lambda x:x.strip(), title)) # 合并副标题
            rate = subject.xpath('.//span[@class="rating_nums"]/text()').get('0') # 没有评分默认给0
            item['rate'] = rate
            print(title, rate, '+++')
            yield item
  • 爬虫会首先爬取start_urls,按照规则Rule,会分析并抽取页面面内匹配的链接,并发起对这些链接的请求,任一请求响应后,执行回调函数parse_item
  • 回调中response就是提取到的链接的页面请求返回的HTML,直接对这这个HTML使用xpath或css分析即可
  • follow决定着是否在回调函数parse_item中对response内容中的链接进行抽取,如果跟进则抽取加入到待爬取队列中
爬取
scrapy crawl dbbook

将follow改为True试一试。改成True,上面的代码只能爬取一页。

MongoDB pipeline

在settings.py中开启pipeline

ITEM_PIPELINES = {
   "books.pipelines.BooksPipeline": 300,
}

在settings.py末尾添加mongo配置

# MongoDB
MONGO_URI = "mongodb://192.168.226.130:27017"
MONGO_DATABASE = "dbbooks"
MONGO_COLLECTION = "rates"

将数据存入MongoDB中

books\pipelines.py

from itemadapter import ItemAdapter
import pymongo

class BooksPipeline:
    def __init__(self, mongo_uri, mongo_db, mongo_collection):
        self.mongo_uri = mongo_uri
        self.mongo_db = mongo_db
        self.mongo_coll = mongo_collection

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            mongo_uri=crawler.settings.get("MONGO_URI"),
            mongo_db=crawler.settings.get("MONGO_DATABASE"),
            mongo_collection=crawler.settings.get("MONGO_COLLECTION")
        )

    def open_spider(self, spider):
        self.client = pymongo.MongoClient(self.mongo_uri)
        self.db = self.client[self.mongo_db]
        self.coll = self.db[self.mongo_coll]

    def close_spider(self, spider):
        self.client.close()

    def process_item(self, item, spider):
        self.coll.insert_one(ItemAdapter(item).asdict())
        return item

修改Spider中rules中的allow的正则表达式为

#books\spiders\doubanbook.py
rules = (Rule(LinkExtractor(allow=r"start=\d+"), callback="parse_item", follow=True),)

则可以返回一千条数据,豆瓣做了分页的限制 https://book.douban.com/tag/%E7%BC%96%E7%A8%8B?start=1000&type=T。但是这样大规模集中爬取,会很快被网站禁止,所以需要 反爬

from pymongo import MongoClient

MONGO_URI = "mongodb://192.168.226.130:27017"
MONGO_DATABASE = "dbbooks"
MONGO_COLLECTION = "rates"

client = MongoClient(MONGO_URI)
db = client[MONGO_DATABASE] #指定数据库
coll = db[MONGO_COLLECTION] #集合

# 多条查询
print(coll.find({}).count())  # 1000

参考:https://docs.scrapy.org/en/latest/topics/item-pipeline.html#write-items-to-mongodb

代理

在爬取过程中,豆瓣使用了反爬策略,可能会出现以下现象:

# 302到安全页面
Status Code: 302 Moved Temporarily
Location: https://sec.douban.com/b?r=https://book.douban.comxxx

这相当于封了 IP,所以,可以在爬取时使用代理来解决。

思路:在发起 HTTP 请求之前,会经过下载中间件,自定义一个下载中间件,在其中临时获取一个代理地址,然后再发起 HTTP 请求。

访问 https://myip.ipip.net/ 测试自己的出公网口IP

IP代理商

1、下载中间件

仿照 middlewares.py 中的下载中间件BooksDownloaderMiddleware,编写 process_request阶段,返回 None。

from scrapy.http.request import Request
import random

class ProxyDownloaderMiddleware:
    # 增加代理IP池,可以从网络搜索,可以从配置文件中读取
    proxy_ip = '192.168.0.163'  # 代理ip
    proxy_port = '58591'  # 代理端口号
    proxies = [
        f'http://{proxy_ip}:{proxy_port}',
    ]

    def process_request(self, request: Request, spider):
        request.meta['proxy'] = random.choice(self.proxies)  # 增加proxy
        print(request.url, request.meta['proxy'])
        print('-' * 30)

2、配置

在 settings.py 中配置:

# 3秒
DOWNLOAD_DELAY = 3

DOWNLOADER_MIDDLEWARES = {
    'spiderman.middlewares.ProxyDownloaderMiddleware': 125,
}

增加一个测试用的 spider 类,如果代理设置成功,该爬虫返回的内容就会是当前代理的信息:

#books\spiders\iptest.py
import scrapy

class IPTestSpider(scrapy.Spider):
    name = 'test'
    allowed_domains = ['ipip.net']
    start_urls = ['http://myip.ipip.net/']

    def parse(self, response, **kwargs):
        text = response.text
        print(1, '-->', text)
        return text

执行

scrapy crawl test

查看打印的内容,是不是代理的地址信息。如果代理测试成功,就可以继续下面的操作。

在 settings.py 中开启 pipeline:

ITEM_PIPELINES = {
   "books.pipelines.BooksPipeline": 300,
}

将数据存入 json 文件:

import json

class BooksPipeline:
    def process_item(self, item, spider):
        # item 获取的item;spider 获取该item的spider
        self.jsonfile.write(json.dumps(dict(item)) + ',\n')
        return item  # 向后处理

    def open_spider(self, spider):
        filename = 'e:/books.json'

        self.jsonfile = open(filename, 'w')
        self.jsonfile.write('[\n')

    def close_spider(self, spider):
        if self.jsonfile:
            self.jsonfile.write(']')
            self.jsonfile.close()

redis-py

安装库

pip install redis

普通连接

r = redis.Redis(host='10.0.0.5', port=6379, decode_responses=True)
  • decode_responses 表示响应的结果是解码后的

指定密码

r = redis.Redis(host='10.0.0.5', port=6379, password='111111')

指定库

r = redis.Redis(host='10.0.0.5', port=6379, password='111111', db=5)

使用TLS连接Redis

import redis

r = redis.Redis(
    host="10.0.0.5", port=6379,
    username="default", # use your Redis user. More info https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/
    password="secret", # use your Redis password
    ssl=True,
    ssl_certfile="./redis_user.crt",
    ssl_keyfile="./redis_user_private.key",
    ssl_ca_certs="./redis_ca.pem",
)

示例

import redis

db = redis.Redis('192.168.226.130', password='123456')
print(db)
print(db.keys('*')) # 所有当前库的中keys

# 字符串 set get [key:str]
db.set('t1', 0b01100010) # 98 int
print(db.get('t1')) # b'98'

db.set(0b11, 0x63)
print(db.get(0b11)) # 可以,数值会转成字符串
print(db.get(3))    # 可以,数值会转成字符串
print(db.get('3'))  # 可以,数值会转成字符串

通过地址池连接

import redis

# pool=redis.ConnectionPool(host='10.0.0.5', db=2, password='111111')
# pool = redis.ConnectionPool.from_url("redis://:[email protected]/4")
pool = redis.ConnectionPool.from_url("redis://:[email protected]/4?decode_responses=True&socket_timeout=10&encoding=utf-8")
r = redis.Redis(connection_pool=pool)
#或者
r = redis.Redis.from_url('redis://:[email protected]:6379/8')
import redis

r = redis.Redis.from_url('redis://:[email protected]:6379/8')
pool = r.connection_pool
print(r.keys('*')) # 每一个命令用完之后数据就又回到了_available_connections可用连接中
print(r.connection_pool)
print(pool._in_use_connections)
print(pool._available_connections[0])
print(pool._available_connections[0]._sock)

print('-' * 30)
print(r.keys('*')) # 每一个命令用完之后数据就又回到了_available_connections可用连接中
print(r.connection_pool)
print(pool._in_use_connections)
print(pool._available_connections[0])
print(pool._available_connections[0]._sock)

pool.disconnect()

r.keys()方法每一次执行,代码中都会去获取一个连接池中的连接,命令执行后立即归还连接。

Redis数据模型

redis支持数据模型非常丰富

参考资料:https://redis.io/docs/latest/develop/data-types/

键Key

  • Redis key需要一个二进制值,可以用任何二进制序列作为key值,可以是简单字符串,也可以是个JPEG文件的二精制序列。空字符串也有效率key值
  • key取值原则
    1. 键值不需要太长,消耗内存,而且查找这类键值的计算成本较高
    2. 键值不宜过短,可读性较差
    3. 习惯上key采用=user:123:password= 形式,表示用户id为123的用户的密码

字符串

  • 字符串是一种基本简单的Redis值类型。说是字符串,其实可以是任意可以序列化的数据。
  • 一个字符串类型的值最多能存储*512M字节*的内容。

查看帮助

  • 使用redis-cli可以进入redis命令行界面
> Help 查看帮助
> Help <tab> 使用tab建切换帮助
> Help set 查看set命令帮助
127.0.0.1:6379> help
redis-cli 8.2.0
To get help about Redis commands type:
      "help @<group>" to get a list of commands in <group>
      "help <command>" for help on <command>
      "help <tab>" to get a list of possible help topics
      "quit" to exit

To set redis-cli preferences:
      ":set hints" enable online hints
      ":set nohints" disable online hints

# string类型命令
127.0.0.1:6379> help @string
# list列表相关的命令
127.0.0.1:6379> help @list

字符串设置

语法:=SET key value [Ex seconds][PX milliseconds] [NX|XX]= 设置字符串值(设置单个键值)

  1. EX 设置过期时间,秒,等同于 SETEX key seconds value
  2. PX 设置过期时间,毫秒,等同于 PSETEX key millieconds value
  3. NX 键不存在,才能设置,等同于 SETNX key value
  4. XX 键存在时,才能设置

设置多个键值 MSET key value [key value ...]

  • 设置多个键值字符串,key存在则覆盖,key不存在则增加。原子操作

MSETNX key value [key value ...] 可以不存在则设置,key存在则失败。nx指代不存在。也是原子操作命令。

127.0.0.1:6379> set s1 abc
OK
127.0.0.1:6379> get s1
"abc"
127.0.0.1:6379> mset s2 a s3 b s4 c
OK
127.0.0.1:6379> keys *
1) "xdd"
2) "3"
3) "s1"
4) "s2"
5) "s3"
6) "s4"
127.0.0.1:6379> msetnx s1 s
(integer) 0
127.0.0.1:6379> get s1
"abc"
过期操作和生存时间

Redis中可以给每个Key设置一个生存时间(秒或毫秒),当达到这个时长后,这些键值将会被自动删除

设置多少秒或者毫秒后过期

  1. EXPIRE key seconds 设置key多少秒后过期
  2. PEXPIRE key milliseconds 设置key多少毫秒后过期

设置在指定Unix时间戳过期

  • EXPIREAT key timestamp 设置到指定时间戳后过期
  • PEXPIREAT key milliseconds-timestamp

持久key,即取消过期

  • PERSIST key 持久key,即取消过期

适用场景

  • 多少秒过期,例如一个缓存数据失效
  • PEXPIREAT key milliseconds-timestamp 比如现在开始缓存数据,0点失效

Time to Live,key的剩余生存时间

  • TTL key 查看key的剩余生存时间,秒级别
  • PTTL key 查看key的剩余生存时间,毫秒级别
  • 命令返回结果:
    • key存在但没有设置TTL,返回-1
    • key存在,但还在生存期内,返回剩余的秒或者毫秒
    • key曾经存在,但已经消亡,返回-2(2.8版本之前返回-1)

示例一:

设置键值s5:abc 20 秒后过期
# set s5 abc ex 20
查看s5剩余过期时间
# ttl s5
127.0.0.1:6379[1]> set s5 abc ex 20
OK
127.0.0.1:6379[1]> ttl s5
(integer) 17
127.0.0.1:6379[1]> ttl s5
(integer) 15
127.0.0.1:6379[1]> ttl s5
(integer) 14
127.0.0.1:6379[1]> ttl s5
(integer) 12
127.0.0.1:6379[1]> ttl s5
(integer) 11
127.0.0.1:6379[1]> ttl s5
(integer) -2
127.0.0.1:6379[1]>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.
命令 意义
ttl s6 查看key为s6的键过期时间,秒级别
pttl s6 查看key为s6的键过期时间,毫秒级
setnx s6 6 设置key为s6的值为6
expire s6 60 设置key为s6的键60秒后过期
persist s6 取消key为s6的过期时间,即永不过期
EXPIREAT cache 1355292000 设置key为cache在1355292000(秒)时间戳后过期
PEXPIREAT cache 1555555555005 设置key为cache在1555555555005(毫秒)时间戳后过期
key操作

语法:=keys pattern= 查询key

  • pattern可以取如下值:
    1. * 任意长度字符
    2. ? 任意一个字符
    3. [] 字符集合,表示一个字符
命令 意义
keys * 查看当前库中所有key键
keys s? 查看当前库中以s开头,只有两个字符的key
keys s[13] 查看当前库中以s开头,只有两个字符,且第二个字符时1或者3的key
keys s[1-3] 查看当前库中以s开头,只有两个字符,且第二字符在[1,3]之间的key
keys s* 查看当前库中以s开头的字符
keys ?? 查看当前库中是两个字符组成的key

其他相关命令

命令 意义
TYPE key key类型
EXISTS key key是否存在
RENAME key newkey 将key的建值重命名为newkey
RENAMENX key newkey 将key的键值重命令为newkey
DEL key [key ...] 将key键值对删除
字符串获取
命令 意义
GET key 获取值
MGET key [key ...] 获取多个给定的键的值
GETSET key value 返回旧值并设置新值,如果键不存在,就创建并赋值
STRLEN key 获取key的value字符串长度
127.0.0.1:6379> keys *
1) "xdd"
2) "3"
3) "s2"
4) "s3"
5) "s4"
127.0.0.1:6379> get s4
"c"
127.0.0.1:6379> mget s2 s3 s1
1) "a"
2) "b"
3) (nil)
127.0.0.1:6379> strlen s2
(integer) 1
127.0.0.1:6379> getset s5 100
(nil)
127.0.0.1:6379> get s5
"100"
127.0.0.1:6379> keys *
1) "xdd"
2) "3"
3) "s2"
4) "s3"
5) "s4"
6) "s5"
127.0.0.1:6379>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.
字符串操作

追加字符串

  1. APPEND key value 追加字符串。如果键存在就追加;如果不存在就等同于= SET key value=

获取子字符串

  1. GETRANGE key start end 索引值从0开始,支持负索引,-1表示最后一个字符。范围是[start,end],start必须在end的左边,否则返回空串

覆盖字符串

  1. SETRANGE key offset value 从指定索引处开始覆盖字符串,返回覆盖后字符串长度。key不存在会创建新的。

简单示例

127.0.0.1:6379> select 1
OK
127.0.0.1:6379[1]> keys *
(empty list or set)
127.0.0.1:6379[1]> append s2 abc
(integer) 3
127.0.0.1:6379[1]> get s2
"abc"
127.0.0.1:6379[1]> append s2 efg
(integer) 6
127.0.0.1:6379[1]> get s2
"abcefg"
127.0.0.1:6379[1]> getrange s2 1 3
"bce"
127.0.0.1:6379[1]> getrange s2 0 -1
"abcefg"
127.0.0.1:6379[1]> setrange s2 3 12
(integer) 6
127.0.0.1:6379[1]> get s2
"abc12g"
127.0.0.1:6379[1]> setrange s2 -1 123456
(error) ERR offset is out of range
127.0.0.1:6379[1]> get s2
"abc12g"
127.0.0.1:6379[1]> setrange s2 3 123456789
(integer) 12
127.0.0.1:6379[1]> get s2
"abc123456789"
127.0.0.1:6379[1]> setrange s7 3 abc
(integer) 6
127.0.0.1:6379[1]> get s7
"\x00\x00\x00abc"
自增、自减
  • INCR key 将key键对应的值增加1.必须是integer类型
  • DECR key 将key键对应的值减少1,必须是integer类型
  • INCRby key decrement 将key键对应的值增加decrement。
    1. decrement是数字,可以为正负
    2. key对应的value必须是integer类型
  • DECRBY key decrement 将key键对应的值减少加decrement。
    • decrement是数字,可以为正负
    • key对应的value必须是integer类型
  • 简单示例
127.0.0.1:6379[1]> flushdb
OK
127.0.0.1:6379[1]> keys *
(empty list or set)
127.0.0.1:6379[1]> set s1 ab1
OK
127.0.0.1:6379[1]> set s2 4
OK
127.0.0.1:6379[1]> incr s1
(error) ERR value is not an integer or out of range
127.0.0.1:6379[1]> incr s2
(integer) 5
127.0.0.1:6379[1]> incr s3
(integer) 1
127.0.0.1:6379[1]> incr s2
(integer) 6
127.0.0.1:6379[1]> get s2
"6"
127.0.0.1:6379[1]> keys *
1) "s3"
2) "s2"
3) "s1"
127.0.0.1:6379[1]> get s3
"1"
127.0.0.1:6379[1]> incrby s2 -10
(integer) -4
127.0.0.1:6379[1]> incrby s2 8
(integer) 4
127.0.0.1:6379[1]> decrby s2 -10
(integer) 14
127.0.0.1:6379[1]> get s2
"14"
127.0.0.1:6379[1]>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.

库操作

redis-cli --help #查看帮助

redis-cli -n 2 # 登录到第2号库

  • 登录后命令上中切换库
    1. SELECT n 选择第n号库
    2. FLUSHDB 清除 当前库 数据
    3. FLUSHALL 清除 所有库 中的数据

位图bitmap

位图不是真正的数类型,它是定义在字符串类型上,只不过把字符串按位操作

一个字符串类型的值最多能存储512M字节的内容,可以表示 \(2^{32}\) 位

  1. 位上限:
    • \(512 = 2^9\)
    • \(1M = 1024 \times 1024 = 2^{10+10}\)
    • \(1Byte = 8bit = 2^{3bit}\)
    • \(2^{9+10+10+3} = 2^{32}b = 4294967296b\), 接近43忆个位
  2. SETBIT key offset value 设置某一位上的值
    1. offset 偏移量,从0开始
    2. value不写,默认是0,只能是0或1
  3. GETBIT key offset 获取某一位上的值
  4. BITPOS key bit [start][end] 返回指定值bit[0或者1]在指定区间上第一次出现的位置
  5. BITCOUNT key [start] [end] 统计指针位区间上值为1的个数,从左向右从0开始,从右向左从-1开始,注意:官方start,end指的是字节的索引。
    • BITCOUNT testkey 0 0 表示从索引为0个字节到索引为0个字节,就是第一个字节的统计
    • BITCOUNT testkey 0 -1 等同于=BITCOUNT testkey=
    • 最常用的是 BITCOUNT testkey

示例:

0 1 2 3 4 5 6 7 索引位
  1 1         1
----------------- 16进制
6 1  # 0x61=a
127.0.0.1:6379> setbit s1 7 1
(integer) 0
127.0.0.1:6379> setbit s1 1 1
(integer) 0
127.0.0.1:6379> setbit s1 2 1
(integer) 0
127.0.0.1:6379> get s1
"a"

# 字符串7 的16进制为37. 即 0011 0111
#0 1 2 3 4 5 6 7  #索引位
#0 0 1 1 0 1 1 1  #二进制
127.0.0.1:6379> set s2 7  # 转换为字符串7,对应16进制为0x37
OK
127.0.0.1:6379> getbit s2 0 #
(integer) 0
127.0.0.1:6379> getbit s2 1
(integer) 0
127.0.0.1:6379> getbit s2 2
(integer) 1
127.0.0.1:6379> getbit s2 3
(integer) 1
127.0.0.1:6379> getbit s2 4
(integer) 0
127.0.0.1:6379> getbit s2 5
(integer) 1
127.0.0.1:6379> BITCOUNT s2 #5 统计1的个数
(integer) 5
127.0.0.1:6379> bitpos s2 1 # 1第一次出现的索引
(integer) 2

127.0.0.1:6379> set str1 abc
OK
127.0.0.1:6379> setbit str1 6 1
(integer) 0
127.0.0.1:6379> setbit str1 7 0
(integer) 1
127.0.0.1:6379> get str1
"bbc"

127.0.0.1:6379[1]> SETBIT ss 2 1
(integer) 0
127.0.0.1:6379[1]> SETBIT ss 18 1
(integer) 0
127.0.0.1:6379[1]> get ss
" \x00 "
127.0.0.1:6379[1]> BITPOS ss 1
(integer) 2
127.0.0.1:6379[1]> BITPOS ss 1 1
(integer) 18
127.0.0.1:6379[1]> BITPOS ss 1 2
(integer) 18
127.0.0.1:6379[1]> BITPOS ss 1 3
(integer) -1
127.0.0.1:6379[1]> STRLEN ss
(integer) 3
位操作
  • 对于一个或多个保存二进制位的字符串key进行位元操作,并将结果保存到destkey上operation可以是AND、OR、NOT、XOR这四种操作中的任意一种
  • BITOP AND destkey key [key ...] 对一个或多个key求 位与 ,并将结果保存到destkey
  • BITOP OR destkey key [key ...] 对一个或多个key求 位或 ,并将结果保存到destkey
  • BITOP XOR destkey key [key ...] 对一个或多个key求 位异或, 并将结果保存到destkey
  • BITOP NOT destkey key 对给定key求 逻辑非 ,并将结果保存到destkey
  • 除了NOT操作之外,其他操作都可以接受一个或多个key作为输入,当BITOP处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作0.空的key也被看作是包含0的字符串序列

示例:a位或b

127.0.0.1:6379> set s1 a
OK
127.0.0.1:6379> set s2 b
OK
127.0.0.1:6379> BITOP OR ss s1 s2
(integer) 1
127.0.0.1:6379> get ss
"c"

a a
b 0x0  #按索引对齐
------- # 按位或  相当于相加
c a
127.0.0.1:6379> set s5 aa
OK
127.0.0.1:6379> set s6 b
OK
127.0.0.1:6379> bitop or s7 s5 s6
(integer) 2
127.0.0.1:6379> get s7
"ca"


127.0.0.1:6379> set s3 中
OK
127.0.0.1:6379> get s3
"\xe4\xb8\xad"  # utf-8
127.0.0.1:6379> BITCOUNT s3
(integer) 13

位图练习:

  1. 指定时间段内(月、年)网站用户上线次数统计(活跃用户)
  2. 按天统计网站活跃用户

参考 1、指定时间段内(月、年)网站用户上线次数统计(活跃用户)

  • 为每一个用户做上线记录,某天登陆就标记一次。
  • 用户ID为key,天作为offset,上线置为1,ID为500的用户,今年的第一天上线,第30天上线:
setbit u:500 1 1 #使用位图标记,上线一次在对应天数上标记一次
setbit u:500 30 1
bitcount u:500 #统计一共上线次数
import redis

# 链接redis数据库
db = redis.Redis("192.168.61.109",6379,db=2)
print(db.keys("*")) #查看所有keys

# user1
db.setbit("u:1",1,1) #第一天登录,标记一次
db.setbit("u:1",30,1) #第30天登陆,标记一次
print("id为500的用户登录次数:",db.bitcount("u:500")) #统计

# user2
db.setbit("u:2",110,1)
db.setbit("u:2",300,1)

# user101,模拟登录,每3天登陆一次
for i in range(3,365,3):
    db.setbit("u:101",i,1)

# user102,模拟登录,每2天登陆一次
for i in range(3,365,2):
    db.setbit("u:102",i,1)

userlist = db.keys("u*") #查询所有用户登录信息
print(userlist)

active = [] #统计活跃用户
inactive = [] #统计非活跃用户

for u in userlist:
    logincount = db.bitcount(u)
    if logincount >100:
        active.append(u)
    else:
        inactive.append(u)

print("活跃用户为:{}".format(active))
print("非活跃用户为:{}".format(inactive))

2、按天统计网站活跃用户

  • 这是日活、周活、月活等统计。天作为key,用户ID为offset,上线设置为1
#一段时间内活跃用户数:
setbit 20160602 15 1
setbit 20160601 123 1
setbit 20160606 123 1

#求6月1日到6月10日的活跃用户
BITOP OR 20160601-10 20160601 20160602 20160603 20160610
bitcount 20160601-10 #结果为2
127.0.0.1:6379> setbit 20160602 15 1
(integer) 0
127.0.0.1:6379> setbit 20160601 123 1
(integer) 0
127.0.0.1:6379> setbit 20160606 123 1
(integer) 0
127.0.0.1:6379> BITOP OR 20160601-10 20160601 20160602 20160603 20160610 
(integer) 16
127.0.0.1:6379> bitcount 20160601-10
(integer) 2

List列表

  • 其列表是基于双向链表实现,列表头尾增删快,中间增删慢
  • 元素是字符串类型
  • 元素可以重复出现
  • 索引支持正索引和负索引,从左至右从0开始,从右至左从-1开始

命令说明

字母 说明
B Block阻塞
L Left左起,或指列表
R Right右起
X exist存在

查看长度

  • LLEN key 返回列表元素个数

添加元素

  • LPUSH key value [value ...] 从左边向队列中压入元素
  • LPUSHX key value 从左边向队列加入元素,要求key必须存在(即列表已经存在)
  • RPUSH key value [value ...] 从右边向队列中压如数据
  • RPUSHX key value 要求key存在(即列表已经存在),从右边向队列中加入元素
  • LINSERT key BEFORE|AFTER pivot value 在列表中某个存在的值(pivot)前后后插入元素一次,key或pivot不存在,不进行任何操作
127.0.0.1:6379> rpush lst 1 2 3 4 5
(integer) 5
127.0.0.1:6379> lrange lst 0 -1
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
127.0.0.1:6379> LINSERT lst after 2 python
(integer) 6
127.0.0.1:6379> lrange lst 0 -1
1) "1"
2) "2"
3) "python"
4) "3"
5) "4"
6) "5"
127.0.0.1:6379> LINSERT lst before 2 ruby
(integer) 7
127.0.0.1:6379> lrange lst 0 -1
1) "1"
2) "ruby"
3) "2"
4) "python"
5) "3"
6) "4"
7) "5"

弹出元素

  • LPOP key 从左边弹出列表中一个元素
  • RPOP key 从右边弹出列表中一个元素
  • RPOPLPUSH source destination 从源列表中右边pop一个元素,从左边加入到目标列表
    • source 源列表,需要从右边弹出一个元素的列表
    • destination 目标列表,需要从左边加入元素的列表
127.0.0.1:6379> lpush s1 1 2 3 4 5 6
(integer) 6
127.0.0.1:6379> lpop s1
"6"
127.0.0.1:6379> lpush s2 11 12 13
(integer) 3
127.0.0.1:6379> RPOPLPUSH s1 s2
"1"
127.0.0.1:6379> RPOPLPUSH s1 s2
"2"
127.0.0.1:6379> rpop s2
"11"
127.0.0.1:6379> lpop s2
"2"
127.0.0.1:6379> lpop s2
"1"

元素访问与修改

  • LRANGE key start stop 返回列表中指定访问的元素,例如 LRANGE user 0 -1
  • LINDEX key index 返回列表中指定索引位置的元素
  • LSET key index value 设置列表中指定索引位置的元素值,index不能超界

移除元素

  • LREM key count value 从左边删除列表中与value相等的元素删除count个
    • count>0 从左至右搜索,移除与value相等的元素,数量至多为count次
    • count<0 从右至左搜索,移除与value相等的元素,数量至多为-count次
    • count = 0 移除列表中所有value值
  • LTRIM key start stop 去除指定 范围外 的元素
    • 保留范围区间[start,stop]其余全部移除
RPUSH listkey c abc c ab 123 ab bj ab redis list
LTRIM listkey 0 -1 #什么都没有去除
LTRIM listkey 1 -1 #去掉左边头
LTRIM listkey 1 10000
  • LINSERT key BEFORE|AFTER pivot value在列表中某个存在的值(pivot)前或后插入元素一次,key或pivot不存在,不进行任何操作
RPUSH Ist 1 23428
LINSERT Ist AFTER 2 Python # 在lst中2后面插入Python
LINSERT Ist BEFORE 2 Ruby

阻塞

  • 如果弹出的列表不存在或者为空,就会*阻塞*
  • 超时时间设置为0,就是永久阻塞,直到有数据可以弹出
  • 如果多个客户端阻塞就在同一个列表上,使用First In First Service原则,先到先服务
  • BLPOP key [key ...] timeout 列表右边阻塞弹出一个元素。
    • key列表键名
    • timeout是超时秒数,为0表示永久阻塞
    • 返回弹出列表名和弹出的值,如果有多个列表,会优先从第一个列表中弹出。
  • BRPOP key [key ...] timeout 列表右边阻塞弹出一个元素
  • BRPOPLPUSH source destination timeout 从一个列表尾部阻塞弹出元素压入到另一个列表的头部

应用场景

# 阻塞式消息队列
BLPOP MyQueue 0 #阻塞获取
RPUSH MyQueue hello #向消息队列添加值

应用:微博某贴最后评论的50条

LPUSH u1234:forumid:comments "这是第1条评论"
LPUSH u1234:forumid:comments "这是第2条评论"
LPUSH u1234:forumid:comments "这是第3条评论"
# 使用LTRIM原因是,获取后可以清楚多余存放在redis中的评论

hash散列

值是由field和value组成的map键值对

field和value都是字符串类型

设置key

  • HSET key field value 设置单个字段。field不存在就创建,存在覆盖value
  • HSETNX key field value 设置单个字段,要求field不存在。如果key不存在,相当于field也不存在
  • HMSET key field value [field value ...] 设置多个字段

长度和判断

  • HLEN key 返回字段个数
  • HSTRLEN key field
  • HEXISTS key field 判断字段是否存在。key或者field不存在,返回0

获取值

  • HGET key field 返回字段值
  • HMGET key field [field ...] 返回多个字段值
  • HGETALL key 返回所有的键值对
  • HKEYS key 返回所有字段名
  • HVALS key 返回所有值

计算

  • HINCRBY key field increment 在字段对应的值上进行整数的增量计算
  • HINCRBYFLOAT key field increment 在字段对应的值上进行浮点数的增量计算

删除

  • HDEL key field [field ...] 删除指定的字段

简单示例

127.0.0.1:6379> HINCRBY number x -50
(integer) -50
127.0.0.1:6379> HGET number x
"-50"
127.0.0.1:6379> HINCRBYFLOAT number x 3.14
"-46.86"
127.0.0.1:6379> HGET number x
"-46.86"
127.0.0.1:6379> hdel number x
(integer)1

Hash用途

  • 节约内存空间。每创建一个键,它就会为这个键存储一些附加的管理信息(比如这个键的类型,这个键最后一次被访问的时间等等)
  • 所以数据库里面的键越多,redis数据库服务器在储存附加管理信息方面耗费的内存就越多,花在管理数据库键上的CPU时间也会越多

应用场景

  • 用户纬度统计
    • 统计数包括:关注数、粉丝数、喜欢商品数、发帖数
    • 用户为key,不同维度为Field,value为统计数
    • 比如关注了5个人
HSET user:100000 follow 5
HINCRBY user:100000 follow 1
  • 商品维度统计
    • 统计值包括喜欢数,评论数,购买数,浏览数等
HSET item:58000 fav 500
HINCRBY item:58000 fav 1
  • 缓存用户信息
    • 登录后,反复需要读取用户的常用信息,最好的方式就是缓存起来
set user:001 "bob,18,20010101"
mset user:001:name "bob" user:001:age 18 user:001:birthday "20010101" #不可取,重复值多
hmset user:001 name "bob" age 18 birthday "20010101"

Set集合

集合的元素是无序的、去重的、元素是字符串类型。

#查看set类型相关的命令
127.0.0.1:6379> help @set

添加和删除

  • SADD key member [member ...] 增加一个或多个元素,元素已经存在将忽略
  • SREM key member [member ...] 移除一个或多个元素,元素不存在自动忽略
  • SMOVE source destination member 把元素从源集合移动到目标集合

查询和长度

  • SCARD key 返回集合中元素的个数。不需要遍历。
  • SMEMBERS key 返回集合中的所有元素。注意,如果集合中元素过多,应当避免使用该方法
  • SISMEMBER key number 元素是否在集合中
127.0.0.1:6379> SADD ss 1 2 1 2 3 5 6 8 5 6
(integer) 6
127.0.0.1:6379> SCARD ss
(integer) 6
127.0.0.1:6379> SMEMBERS ss
1) "1"
2) "2"
3) "3"
4) "5"
5) "6"
6) "8"
127.0.0.1:6379> SREM ss 1 2 5
(integer) 3
127.0.0.1:6379> SMEMBERS ss
1) "3"
2) "6"
3) "8"
  • 注意:元素相同的两个集合未必有相同的顺序,去重且有序可使用有序集合

随机获取移除

  • SRANDMEMBER key [count] 随机返回结合中个鸡丁个数的元素
    1. 如果count为正数,且小于集合基数,那么命令返回一个包含count个元素的数组,数组中的元素各不相同。如果count大于等于集合基数,那么返回整个集合
    2. 如果count为负数,那么命令返回一个数组,数组中的元素可能会重复出现多次,而数组长度为count的绝对值
    3. 如果count为0 返回空
    4. 如果count不指定,随机返回一个元素
  • SPOP key 从集合中随机移除一个元素并返回该元素

集合运算

  • 差集
    • SDIFF key [key ...] 从第一个key的集合中去除其他集合和自己的交集部分
    • SDIFFSTORE destination key [key ...] 将差集结果存储在目标key中
127.0.0.1:6379> sadd number1 123 456 789
(integer) 3
127.0.0.1:6379> sadd number2 123 456 999
(integer) 3
127.0.0.1:6379> sdiff number1 number2
1) "789"
  • 交集
    • SINTER key [key ...] 取所有集合交集部分
    • SINTERSTORE destination key [key ...] 将交集结果存储在目标key中
127.0.0.1:6379> sadd number1 123 456 789
(integer) 3
127.0.0.1:6379> sadd number2 123 456 999
(integer) 3
127.0.0.1:6379> sinter number1 number2
1) "123"
2) "456"
  • 并集
    • SUNION key [key ...] 取所有集合交集部分
    • SINTERSTORE destination key [key ...] 将交集结果存储在目标key中
127.0.0.1:6379> sadd number1 123 456 789
(integer) 3
127.0.0.1:6379> sadd number2 123 456 999
(integer) 3
127.0.0.1:6379> SUNION number1 number2
1) "123"
2) "456"
3) "789"
4) "999"

应用场景示例:微博的共同关注

  1. 需求:当用户访问另一个用户的时候,会显示出两个用户共同关注哪些相同的用户
  2. 设计:将每个用户关注的用户放在集合中,求交集即可
peter = {"john","jack","may"}
ben = {"john","jack","tom"}
那么peter和ben的共同关注为:
SINTER peter ben #结果为:{“john","jack"}

SortedSet有序集合

类似Set集合,有序的集合。每一个元素都关联着一个浮点数分值(Score),并按照分值从小到大的顺序排列集合中的元素。分值可以相同

127.0.0.1:6379> help zadd

  ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]
  summary: Adds one or more members to a sorted set, or updates their scores. Creates the key if it doesn't exist.
  since: 1.2.0
  group: sorted-set

# 查看sorted-set相关命令
127.0.0.1:6379> help @sorted-set

添加

  • ZADD key score member [score member ...] 增加一个或多个元素。如果元素已经存在,则使用新的score

简单查询

  • ZCARD key 返回集合的元素个数
  • ZCOUNT key min max 返回指定score范围元素的个数
  • ZSCORE key member 显示分值

修改

  • ZINCRBY key increment member 增加或减少分值。increment为负数就是减少

高级查询

  • ZRANGE key start stop [WITHSCORES] 返回指定索引区间元素(从大到小)
    • WITHSCORES 选项,带上时返回序列中会携带分值
    • 有序集合里面,如果score相同,则按照字典序lexicographical order排列
    • 默认按照score从大到小,如果需要score从小到大排列,使用ZREVRANGE
  • ZREVRANGE key start stop [WITHSCORES] 返回指定索引区间元素(从小到大)
    • WITHSCORES 选项,带上时返回序列中会携带分值
    • 如果score相同,则按照字典序lexicographical order的*逆序*排列
    • 默认按照score从大到小,如果需要score从小到大排列,使用ZRANGE
  • ZRANK key number 返回元素的排名(索引)
  • ZREVRANK key number 返回元素的逆序排名(索引)
  • ZRANGEBYSCORE key min max [WITHSCORES][LIMIT offset count] 返回指定分数区间的元素
    • 返回score默认属于[min,max]之间,元素按照score升序排列,score相同字典序
    • LIMIT中offset代表跳过多少个元素,count是返回几个。类似于Mysql
    • 使用小括号,修改区间为开区间,例如 (5 10
    • -inf和+inf表示负无穷和正无穷
  • ZREVRANGEBYSCORE key max min [WITHSCORES][LIMIT offset count] 降序返回指定分数区间的元素
    • 返回score默认属于[min,max]之间,元素按照score降序排列,score相同字典降序
#添加过个值到employees中
127.0.0.1:6379> ZADD employees 3500 jack 4000 peter 4000 john 4500 tom 2500 david
(integer) 0
#显示employees集合所有key
127.0.0.1:6379> zrange employees 0 -1
1) "david"
2) "jack"
3) "john"
4) "peter"
5) "tom"
#统计employees有序集合中分值在[3000,4000]的集合个数
127.0.0.1:6379> ZCOUNT employees 3000 4000
(integer) 3
#添加值david 其分值为3.2
127.0.0.1:6379> zadd employees 3.2 david
(integer) 0
#查看david的分值
127.0.0.1:6379> ZSCORE employees david
"3.2000000000000002"
#将jack的分值增加1.5
127.0.0.1:6379> ZINCRBY employees 1.5 jack
"3501.5"
#将tom的分值减少500
127.0.0.1:6379> zincrby employees -500 tom
"4000"
#带分值显示employees有序集合
127.0.0.1:6379> zrange employees  0 -1 WITHSCORES
1) "david"
2) "3.2000000000000002"
3) "jack"
4) "3501.5"
5) "john"
6) "4000"
7) "peter"
8) "4000"
9) "tom"
10) "4000"
#查询peter的排名
127.0.0.1:6379> zrank employees peter
(integer) 3

#逆序后的索引0到-1,即返回所有
127.0.0.1:6379> zrevrange employees 0 -1 WITHSCORES
1) "tom"
2) "4000"
3) "peter"
4) "4000"
5) "john"
6) "4000"
7) "jack"
8) "3501.5"
9) "david"
10) "3.2000000000000002"
#查询peter的逆序排名
127.0.0.1:6379> zrevrank employees peter
(integer) 1
127.0.0.1:6379>

#高级查询示例
#查询分值在[3500,4000]范围内的键,升序排列显示
127.0.0.1:6379> zrangebyscore employees 3500 4000
1) "jack"
2) "john"
3) "peter"
4) "tom"

127.0.0.1:6379> zrangebyscore employees (4000 5000 WITHSCORES
(empty list or set)
#小括号表示闭区间变为开区间。查询分值在(2000,5000)范围内的键和值,升序排列显示
127.0.0.1:6379> zrangebyscore employees (2000 5000 WITHSCORES
1) "jack"
2) "3501.5"
3) "john"
4) "4000"
5) "peter"
6) "4000"
7) "tom"
8) "4000"
#查询分值在(2000,5000)范围内的键和值,升序排列显示,跳过第一个,最多显示2个
127.0.0.1:6379> zrangebyscore employees (2000 5000 WITHSCORES LIMIT 1 2
1) "john"
2) "4000"
3) "peter"
4) "4000"

#查询分值在[正无穷大,负无穷大]的键,降序显示
127.0.0.1:6379> zrevrangebyscore employees +inf -inf
1) "tom"
2) "peter"
3) "john"
4) "jack"
5) "david"

删除

  • ZREM key member [member ...] 移除一个或多个元素。元素不存在,自动忽略
  • ZREMRANGEBYRANK key start stop 移除指定排名范围的元素
  • ZREMRANGEBYSCORE key min max 移除指定分值范围的元素
127.0.0.1:6379> zadd employees 1000 tom 2000 john 2000 jahh 3000 peter 4000 david 5000 xdd
(integer) 6
127.0.0.1:6379> ZRANGEBYSCORE employees -inf +inf WITHSCORES
1) "tom"
2) "1000"
3) "jahh"
4) "2000"
5) "john"
6) "2000"
7) "peter"
8) "3000"
9) "david"
10) "4000"
11) "xdd"
12) "5000"
#删除employees中分索引在[0,1]范围内的值
127.0.0.1:6379> ZREMRANGEBYRANK employees 0 1
(integer) 2
127.0.0.1:6379> ZRANGEBYSCORE employees -inf +inf WITHSCORES
1) "john"
2) "2000"
3) "peter"
4) "3000"
5) "david"
6) "4000"
7) "xdd"
8) "5000"
#删除employees集合中分值在[4000,5000]范围内的值
127.0.0.1:6379> ZREMRANGEBYSCORE employees 4000 5000
(integer) 2
127.0.0.1:6379> ZRANGEBYSCORE employees -inf +inf WITHSCORES
1) "john"
2) "2000"
3) "peter"
4) "3000"

集合运算

  • 并集
  • ZUNIONSTORE destination numkeys key [key ...] [WEIGHTS weight] [AGGREGATE SUM|MIN|MAX] 并集运算
    • numkeys指定key的数量,必须
    • WEIGHTS选项,与前面设定的key对应,对应key中每一个score都要乘以这个权重
    • AGGREGATE选项,指定并集结果的聚合方式
      • SUM: 将所有集合中某一个元素的score值之和作为结果集中该成员的score值,默认行为
      • MIN: 将所有集合中某一个元素的score值中最小值作为结果集中该成员的score值
      • MAX: 将所有集合中某一个元素的score值中最大值作为结果集中该成员的score值
127.0.0.1:6379> zadd s1 70 tom 80 peter 60 john
(integer) 3
127.0.0.1:6379> zadd s2 90 peter 60 ben
(integer) 2
#求s1 s2的交集,s1的权重是1,s2的权重是1,默认合并后的分支使用sum求和方式
127.0.0.1:6379> ZUNIONSTORE scores-all 2 s1 s2
(integer) 4
127.0.0.1:6379> ZRANGEBYSCORE scores-all -inf +inf WITHSCORES
1) "ben"
2) "60"
3) "john"
4) "60"
5) "tom"
6) "70"
7) "peter"
8) "170"

#求s1 s2的交集,s1的权重是1,s2的权重是1,合并后的分支使用sum求和方式
127.0.0.1:6379> ZUNIONSTORE scores-all1 2 s1 s2 aggregate sum
(integer) 4
127.0.0.1:6379> ZRANGEBYSCORE scores-all1 -inf +inf WITHSCORES
1) "ben"
2) "60"
3) "john"
4) "60"
5) "tom"
6) "70"
7) "peter"
8) "170"

#求s1 s2的交集,s1的权重是1,s2的权重是0.5,合并后的分支使用sum求和方式
127.0.0.1:6379> ZUNIONSTORE scores-all2 2 s1 s2 weights 1 0.5 aggregate sum
(integer) 4
127.0.0.1:6379> ZRANGEBYSCORE scores-all2 -inf +inf WITHSCORES
1) "ben"
2) "30"
3) "john"
4) "60"
5) "tom"
6) "70"
7) "peter"
8) "125"
  • 交集
  • ZINTERSTORE destination numkeys key [key ...] [WEIGHTS weight] [AGGREGATE SUM|MIN|MAX]
    • numkeys指定key的数量,必须
    • WEIGHTS选项,与前面设定的key对应,对应key中每一个score都要乘以这个权重
    • AGGREGATE选项,指定交集结果的聚合方式
      • SUM:将所有集合中某一个元素的score值之和作为结果集中该成员的score值
      • MIN:将所有结合中某一个元素的score值中最小值作为结果集中该成员的score值
      • MAX:将所有集合中某一个元素的score值中最大值作为结果集中该成员的score值

应用练习:音乐排行榜的实现

  • 每首歌的歌名作为元素(先补补考虑重复)
  • 每首歌的播放次数作为分值
  • ZREVRANGE来获取播放次数最多的歌曲(就是最多播放榜了,云音乐热歌榜,没有竞价,没有权重)

python中redis库是3.x版本代码如下:

# redis版本3.x版本
import redis

r = redis.Redis(host="192.168.61.109",port=6379,db=3)
#清空数据库
r.flushdb()
r.zadd("mboard",{"yellow":1,"rolling inthe deep":1,"happy":1,"just the way you are":1})
r.zadd("mboard",{"eye of the tiger":1,"billie jean":1,"say you say me":1,"payphone":1})
r.zadd("mboard",{"my heart will go on":1,"when you believe":1,"hero":1})

# 修改yellow的分值为50
r.zincrby("mboard",50,"yellow")
r.zincrby("mboard",60,"rolling in the deep")
r.zincrby("mboard",68.8,"my heart will go on")
r.zincrby("mboard",70,"when you believe")

# 所有元素
allmusic = r.zrange("mboard",0,-1,withscores=True)
print(type(allmusic))

for m in allmusic:
    print(m)

print(" -"*30)
# 排行榜
musicboard = r.zrevrange("mboard",0,9,withscores=True)
print("欧美热曲榜")

for i,m in enumerate(musicboard):
    print(i,*m)

python中如果redis版本是2.x代码如下:

# redis库 2.x版本
import redis

r = redis.Redis(host='192.168.142.135', port=6379, db=3)

r.zadd('mboard','yellow',1,'rolling in the deep',1,'happy',1,'just the way you are',1)
r.zadd('mboard','eye of the tiger',1,'billie jean',1,'say you say me',1,'payphone',1)
r.zadd('mboard','my heart will go on',1,'when you believe',1,'hero',1)

r.zincrby('mboard','yellow',50)
r.zincrby('mboard','rolling in the deep',60)
r.zincrby('mboard','my heart will go on',68.8)
r.zincrby('mboard','when you believe',70)
# 所有元素
allmusic = r.zrange('mboard', 0, -1, withscores=True)
print(type(allmusic))
for m in allmusic:
    print(m)

print('-'*30)

# 排行榜
musicboard = r.zrevrange('mboard', 0, 9, True)
print('欧美热曲榜')
for i, m in enumerate(musicboard):
    print(i, *m)

应用场景

新浪微博翻页

  • 新闻网站、播客、论坛、搜索引擎,页面列表条目多,都需要分页
  • blog这个key使用时间戳作为score
ZADD blog 1407000000 '今天天气不错'=
ZADD blog 1450000000 '今天我们学习Redis' 
ZADD blog 1560000000 '几个Redis使用示例' 
ZREVRANGE blog 10 20
显示所有播客中最后的评论的条目

京东图书畅销榜

  • 统计单日榜,计算出周榜单、月榜单、年榜单
#每天统计一次榜单
ZADD bk:it:01 1000 'java' 1500 'Redis' 2000 'haoop' 100 'scala' 80 'python'
ZADD bk:it:02 1020 'java' 1500 'Redis' 2100 'haoop' 120 'python' 110 'scala'
ZADD bk:it:03 1620 'java' 1510 'Redis' 3000 'haoop' 150 'storm' 120 'python'


#求销售前10名
- 由于上面的日榜单是累计值,所以不能直接使用并集,要指定聚合运算为MAX
127.0.0.1:6379> ZUNIONSTORE bk:it:01-03 3 bk:it:01 bk:it:02 bk:it:03 AGGREGATE MAX
(integer) 6
127.0.0.1:6379> ZREVRANGE bk:it:01-03 0 9 WITHSCORES
 1) "haoop"
 2) "3000"
 3) "java"
 4) "1620"
 5) "Redis"
 6) "1510"
 7) "storm"
 8) "150"
 9) "python"
10) "120"
11) "scala"
12) "110"
#注意:如果参与并集元素的元素太多,会消耗大量内存和计算时间,可能会导致Redis服务阻塞,如果非要计算,选在空闲时间或备用服务器上计算。

另一种统计, 非累加值,每天卖了多少本书,不是累计卖了多少本书。
ZADD bk:it:01 50 'java' 20 'Redis' 40 'haoop'
ZADD bk:it:02 70 'java' 30 'Redis' 20 'haoop'
ZADD bk:it:03 20 'java' 30 'Redis' 5 'haoop'
ZUNIONSTORE bk:it:01-03 3 bk:it:01 bk:it:02 bk:it:03 AGGREGATE sum

操作

键操作

方法 作用 参数说明 示例 示例说明 示例结果
exists(name) 判断一个键是否存在 name:键名 redis.exists('name') 是否存在name这个键 1
delete(name) 删除一个键 name:键名 redis.delete('name') 删除name这个键 1
type(name) 判断键类型 name:键名 redis.type('name') 判断name这个键类型 b'string'
keys(pattern) 获取所有符合规则的键 pattern:匹配规则 redis.keys('n*') 获取所有以n开头的键 [b'name']
randomkey() 获取随机的一个键   redis.randomkey() 获取随机的一个键 b'name'
rename(src, dst) 重命名键 src:原键名; redis.rename('name', 'nickname') 将name重命名为nickname True
    dst:新键名      
dbsize() 获取当前数据库中键的数目   redis.dbsize() 获取当前数据库中键的数目 100
expire(name, time) 设定键的过期时间,单位为秒 name:键名; redis.expire('name', 2) 将name键的过期时间设置为2秒 True
    time:秒数      
ttl(name) 获取键的过期时间,单位为秒,-1表示永久不过期 name:键名 redis.ttl('name') 获取name这个键的过期时间 -1
move(name, db) 将键移动到其他数据库 name:键名; redis.move('name', 2) 将name移动到2号数据库 True
    db:数据库代号      
flushdb() 删除当前选择数据库中的所有键   redis.flushdb() 删除当前选择数据库中的所有键 True
flushall() 删除所有数据库中的所有键   redis.flushall() 删除所有数据库中的所有键 True

值操作:字符串操作

Redis支持最基本的键值对形式存储

方法 作用 参数说明 示例 示例说明 示例结果
set(name, value) 给数据库中键为name的string赋予值value name: 键名; value: 值 redis.set('name', 'Bob') 给name这个键的value赋值为Bob True
get(name) 返回数据库中键为name的string的value name:键名 redis.get('name') 返回name这个键的value b'Bob'
getset(name, value) 给数据库中键为name的string赋予值value并返回上次的value name:键名; value:新值 redis.getset('name', 'Mike') 赋值name为Mike并得到上次的value b'Bob'
mget(keys, *args) 返回多个键对应的value keys:键的列表 redis.mget(['name', 'nickname']) 返回name和nickname的value [b'Mike', b'Miker']
setnx(name, value) 如果不存在这个键值对,则更新value,否则不变 name:键名 redis.setnx('newname', 'James') 如果newname这个键不存在,则设置值为James 第一次运行结果是True
          第二次运行结果是False
setex(name, time, value) 设置可以对应的值为string类型的value,并指定此键值对应的有效期 name: 键名;time: 有效期;value:值 redis.setex('name', 1, 'James') 将name这个键的值设为James,有效期为1秒 True
setrange(name, offset, value) 设置指定键的value值的子字符串 name:键名; redis.set('name', 'Hello') 设置name为Hello字符串,并在index为6的位置补World 11 修改后的字符串长度
    offset:偏移量; value:值 redis.setrange('name', 6, 'World')    
mset(mapping) 批量赋值 mapping:字典 redis.mset({'name1': 'Durant', 'name2': 'James'}) 将name1设为Durant,name2设为James True
msetnx(mapping) 键均不存在时才批量赋值 mapping:字典 redis.msetnx({'name3': 'Smith', 'name4': 'Curry'}) 在name3和name4均不存在的情况下才设置二者值 True
incr(name, amount=1) 键为name的value增值操作,默认为1 name:键名; redis.incr('age', 1) age对应的值增1,若不存在,则会创建并设置为1 1 即修改后的值
  键不存在则被创建并设为amount amount:增长的值      
decr(name, amount=1) 键为name的value减值操作,默认为1 name:键名; amount:减少的值 redis.decr('age', 1) age对应的值减1,若不存在,则会创建并设置为-1 -1
  键不存在则被创建并将value设置为-amount       即修改后的值
append(key, value) 键为name的string的值附加value key:键名 redis.append('nickname', 'OK') 向键为nickname的值后追加OK 13 即修改后的字符串长度
substr(name, start, end-1)= 返回键为name的string的子串 name:键名; start:起始索引; redis.substr('name', 1, 4) 返回键为name的值的字符串,截取索引为1~4的字符 b'ello'
    end:终止索引,默认为-1,表示截取到末尾      
getrange(key, start, end) 获取键的value值从start到end的子字符串 key:键名; start:起始索引; end:终止索引 redis.getrange('name', 1, 4) 返回键为name的值的字符串,截取索引为1~4的字符 b'ello'

值操作:列表操作

Redis还提供了列表存储,列表内的元素可以重复,而且可以从两端存储

方法 作用 参数说明 示例 示例说明 示例结果
rpush(name, *values) 在键为name的列表末尾添加值为value的元素,可以传多个 name:键名;values:值 redis.rpush('list', 1, 2, 3) 向键为list的列表尾添加1、2、3 3列表大小
lpush(name, *values) 在键为name的列表头添加值为value的元素,可以传多个 name:键名;values:值 redis.lpush('list', 0) 向键为list的列表头部添加0 4列表大小
llen(name) 返回键为name的列表的长度 name:键名 redis.llen('list') 返回键为list的列表的长度 4
lrange(name, start, end) 返回键为name的列表中start至end之间的元素 name:键名;start:起始索引;end:终止索引 redis.lrange('list', 1, 3) 返回起始索引为1终止索引为3的索引范围对应的列表 [b'3', b'2', b'1']
ltrim(name, start, end) 截取键为name的列表,保留索引为start到end的内容 name:键名;start:起始索引;end:终止索引 ltrim('list', 1, 3) 保留键为list的索引为1到3的元素 True
lindex(name, index) 返回键为name的列表中index位置的元素 name:键名; index:索引 redis.lindex('list', 1) 返回键为list的列表索引为1的元素 b'2'
lset(name, index, value) 给键为name的列表中index位置的元素赋值,越界则报错 name:键名; index:索引位置;value:值 redis.lset('list', 1, 5) 将键为list的列表中索引为1的位置赋值为5 True
lrem(name, count, value) 删除count个键的列表中值为value的元素 name:键名;count:删除个数;value:值 redis.lrem('list', 2, 3) 将键为list的列表删除两个3 1即删除的个数
lpop(name) 返回并删除键为name的列表中的首元素 name:键名 redis.lpop('list') 返回并删除名为list的列表中的第一个元素 b'5'
rpop(name) 返回并删除键为name的列表中的尾元素 name:键名 redis.rpop('list') 返回并删除名为list的列表中的最后一个元素 b'2'
blpop(keys, timeout=0) 返回并删除名称在keys中的list中的首个元素,如果列表为空,则会一直阻塞等待 keys:键列表; redis.blpop('list') 返回并删除键为list的列表中的第一个元素 [b'5']
    timeout: 超时等待时间,0为一直等待      
brpop(keys, timeout=0) 返回并删除键为name的列表中的尾元素,如果list为空,则会一直阻塞等待 keys:键列表; redis.brpop('list') 返回并删除名为list的列表中的最后一个元素 [b'2']
    timeout:超时等待时间,0为一直等待      
rpoplpush(src, dst) 返回并删除名称为src的列表的尾元素,并将该元素添加到名称为dst的列表头部 src:源列表的键; redis.rpoplpush('list', 'list2') 将键为list的列表尾元素删除并将其添加到键为list2的列表头部,然后返回 b'2'
    dst:目标列表的key      

值操作:集合操作

Redis还提供了集合存储,集合中的元素都是不重复的

方法 作用 参数说明 示例 示例说明 示例结果
sadd(name, *values) 向键为name的集合中添加元素 name:键名;values:值,可为多个 redis.sadd('tags', 'Book', 'Tea', 'Coffee') 向键为tags的集合中添加Book、Tea和Coffee这3个内容 3即插入的数据个数
srem(name, *values) 从键为name的集合中删除元素 name:键名; values:值,可为多个 redis.srem('tags', 'Book') 从键为tags的集合中删除Book 1即删除的数据个数
spop(name) 随机返回并删除键为name的集合中的一个元素 name:键名 redis.spop('tags') 从键为tags的集合中随机删除并返回该元素 b'Tea'
smove(src, dst, value) 从src对应的集合中移除元素并将其添加到dst对应的集合中 src:源集合; redis.smove('tags', 'tags2', 'Coffee') 从键为tags的集合中删除元素Coffee并将其添加到键为tags2的集合 True
    dst:目标集合;value:元素值      
scard(name) 返回键为name的集合的元素个数 name:键名 redis.scard('tags') 获取键为tags的集合中的元素个数 3
sismember(name, value) 测试member是否是键为name的集合的元素 name:键值 redis.sismember('tags', 'Book') 判断Book是否是键为tags的集合元素 True
sinter(keys, *args) 返回所有给定键的集合的交集 keys:键列表 redis.sinter(['tags', 'tags2']) 返回键为tags的集合和键为tags2的集合的交集 {b'Coffee'}
sinterstore(dest, keys, *args) 求交集并将交集保存到dest的集合 dest:结果集合;keys:键列表 redis.sinterstore('inttag', ['tags', 'tags2']) 求键为tags的集合和键为tags2的集合的交集并将其保存为inttag 1
sunion(keys, *args) 返回所有给定键的集合的并集 keys:键列表 redis.sunion(['tags', 'tags2']) 返回键为tags的集合和键为tags2的集合的并集 {b'Coffee', b'Book', b'Pen'}
sunionstore(dest, keys, *args) 求并集并将并集保存到dest的集合 dest:结果集合; redis.sunionstore('inttag', ['tags', 'tags2']) 求键为tags的集合和键为tags2的集合的并集并将其保存为inttag 3
    keys:键列表      
sdiff(keys, *args) 返回所有给定键的集合的差集 keys:键列表 redis.sdiff(['tags', 'tags2']) 返回键为tags的集合和键为tags2的集合的差集 {b'Book', b'Pen'}
sdiffstore(dest, keys, *args) 求差集并将差集保存到dest集合 dest:结果集合;keys:键列表 redis.sdiffstore('inttag', ['tags', 'tags2']) 求键为tags的集合和键为tags2的集合的差集并将其保存为inttag` 3
smembers(name) 返回键为name的集合的所有元素 name:键名 redis.smembers('tags') 返回键为tags的集合的所有元素 {b'Pen', b'Book', b'Coffee'}
srandmember(name) 随机返回键为name的集合中的一个元素,但不删除元素 name:键值 redis.srandmember('tags') 随机返回键为tags的集合中的一个元素  

值操作:有序集合操作

有序集合比集合多了一个分数字段,利用它可以对集合中的数据进行排序

方法 作用 参数说明 示例 示例说明 示例结果
zadd(name, *args, **kwargs) 向键为name的zset中添加元素member,score用于排序。 如果该元素存在,则更新其顺序 name: 键名;args:可变参数 redis.zadd('grade', 100, 'Bob', 98, 'Mike') 向键为grade的zset中添加Bob(其score为100),并添加Mike(其score为98) 2即添加的元素个数
zrem(name, *values) 删除键为name的zset中的元素 name:键名; values:元素 redis.zrem('grade', 'Mike') 从键为grade的zset中删除Mike 1即删除的元素个数
zincrby(name, value, amount=1) 如果在键为name的zset中已经存在元素value,则将该元素的score增加amount;否则向该集合中添加该元素,其score的值为amount name:key名; redis.zincrby('grade', 'Bob', -2) 键为grade的zset中Bob的score减2 98.0
    value:元素;     即修改后的值
    amount:增长的score值      
zrank(name, value) 返回键为name的zset中元素的排名,按score从小到大排序,即名次 name:键名; value:元素值 redis.zrank('grade', 'Amy') 得到键为grade的zset中Amy的排名 1
zrevrank(name, value) 返回键为name的zset中元素的倒数排名(按score从大到小排序),即名次 name:键名;value:元素值 redis.zrevrank('grade', 'Amy') 得到键为grade的zset中Amy的倒数排名 2
           
zrevrange(name, start, end, withscores=False) 返回键为name的zset(按score从大到小排序)中index从start到end的所有元素 name:键值; redis.zrevrange('grade', 0, 3) 返回键为grade的zset中前四名元素 [b'Bob', b'Mike', b'Amy', b'James']
    start:开始索引;      
    end:结束索引;      
    withscores:是否带score      
zrangebyscore(name, min, max, start=None, num=None, withscores=False) 返回键为name的zset中score在给定区间的元素 name:键名; redis.zrangebyscore('grade', 80, 95) 返回键为grade的zset中score在80和95之间的元素 [b'Bob', b'Mike', b'Amy', b'James']
    min:最低score;      
    max:最高score;      
    start:起始索引;      
    num:个数      
    withscores:是否带score      
zcount(name, min, max) 返回键为name的zset中score在给定区间的数量 name:键名; redis.zcount('grade', 80, 95) 返回键为grade的zset中score在80到95的元素个数 2
    min:最低score;      
    max:最高score      
zcard(name) 返回键为name的zset的元素个数 name:键名 redis.zcard('grade') 获取键为grade的zset中元素的个数 3
zremrangebyrank(name, min, max) 删除键为name的zset中排名在给定区间的元素 name:键名; redis.zremrangebyrank('grade', 0, 0) 删除键为grade的zset中排名第一的元素 1
    min:最低位次;     即删除的元素个数
    max:最高位次      
zremrangebyscore(name, min, max) 删除键为name的zset中score在给定区间的元素 name:键名; redis.zremrangebyscore('grade', 80, 90) 删除score在80到90之间的元素 1
    min:最低score;     即删除的元素个数
    max:最高score      

值操作:散列操作

Redis还提供了散列表的数据结构,我们可以用 name 指定一个散列表的名称,表内存储了各个键值对

方法 作用 参数说明 示例 示例说明 示例结果
hset(name, key, value) 向键为name的散列表中添加映射 name:键名; hset('price', 'cake', 5) 向键为price的散列表中添加映射关系,cake的值为5 1
    key:映射键名;     即添加的映射个数
    value:映射键值      
hsetnx(name, key, value) 如果映射键名不存在,则向键为name的散列表中添加映射 name:键名 hsetnx('price', 'book', 6) 向键为price的散列表中添加映射关系,book的值为6 1
    key:映射键名     即添加的映射个数
    value:映射键值      
hget(name, key) 返回键为name的散列表中key对应的值 name:键名; redis.hget('price', 'cake') 获取键为price的散列表中键名为cake的值 5
    key:映射键名      
hmget(name, keys, *args) 返回键为name的散列表中各个键对应的值 name:键名; redis.hmget('price', ['apple', 'orange']) 获取键为price的散列表中apple和orange的值 [b'3', b'7']
    keys:映射键名列表      
hmset(name, mapping) 向键为name的散列表中批量添加映射 name:键名; redis.hmset('price', {'banana': 2, 'pear': 6}) 向键为price的散列表中批量添加映射 True
    mapping:映射字典      
hincrby(name, key, amount=1) 将键为name的散列表中映射的值增加amount name:键名; redis.hincrby('price', 'apple', 3) key为price的散列表中apple的值增加3 6
    key:映射键名;     修改后的值
    amount:增长量      
hexists(name, key) 键为name的散列表中是否存在键名为键的映射 name:键名; redis.hexists('price', 'banana') 键为price的散列表中banana的值是否存在 True
    key:映射键名      
hdel(name, *keys) 在键为name的散列表中,删除键名为键的映射 name:键名; redis.hdel('price', 'banana') 从键为price的散列表中删除键名为banana的映射 True
    keys:映射键名      
hlen(name) 从键为name的散列表中获取映射个数 name: 键名 redis.hlen('price') 从键为price的散列表中获取映射个数 6
hkeys(name) 从键为name的散列表中获取所有映射键名 name:键名 redis.hkeys('price') 从键为price的散列表中获取所有映射键名 [b'cake', b'book', b'banana', b'pear']
hvals(name) 从键为name的散列表中获取所有映射键值 name:键名 redis.hvals('price') 从键为price的散列表中获取所有映射键值 [b'5', b'6', b'2', b'6']
hgetall(name) 从键为name的散列表中获取所有映射键值对 name:键名 redis.hgetall('price') 从键为price的散列表中获取所有映射键值对 {b'cake': b'5', b'book': b'6', b'orange': b'7', b'pear': b'6'}

订阅/发布

127.0.0.1:6379> SUBSCRIBE ch1 ch2 #订阅者事先订阅指定的频道,之后发布的消息才能收到。阻塞监听
1) "subscribe"
2) "ch1"
3) (integer) 1
1) "subscribe"
2) "ch2"
3) (integer) 2

# 发布者发布消息
127.0.0.1:6379> PUBLISH ch1 test1
(integer) 1 # 订阅者个数
127.0.0.1:6379> PUBLISH ch1 test2
(integer) 1
127.0.0.1:6379> PUBLISH ch2 t1
(integer) 1

# 订阅者显示
127.0.0.1:6379> SUBSCRIBE ch1 ch2
1) "subscribe"
2) "ch1"
3) (integer) 1
1) "subscribe"
2) "ch2"
3) (integer) 2
1) "message"
2) "ch1"
3) "test1"
1) "message"
2) "ch1"
3) "test2"
1) "message"
2) "ch2"
3) "t1"

订阅

import time
import redis

with redis.Redis.from_url("redis://:[email protected]/4") as c:
    # 消费者
    # 需要先创建订阅对象
    p1 = c.pubsub()
    p1.subscribe("c1")
    # # 阻塞的方式接收消息
    for i in p1.listen():
        print(i,type(i))

    # 非阻塞的方式接收消息
    while True:
        msg = p1.get_message(ignore_subscribe_messages=True)
        if msg:
            print(msg)
        time.sleep(0.5)

发布

import redis

with redis.Redis.from_url("redis://:[email protected]/4") as c:
    print(type(c), c)
    # 生产者
    p = c.publish("c1", 11111)
    print("订阅的个数:", p)

pipeline

import redis

r = redis.Redis.from_url('redis://:[email protected]:6379/8')
pool = r.connection_pool

# pipeline, 一系列动作
pl = r.pipeline(True) # 事务,要么全部成功执行,要么失败
print(type(pl), pl, id(pl))
x = pl.mset({'s1':1, 's2':2, 's3':3})
print(type(pl), pl, id(pl)) # 返回的是pipeline自己。redis.client.Pipeline
print(type(x), x, id(x))
pl.msetnx({'s4':4, 's3':3})
results = pl.mget(('s1', 's2', 's3', 's4')).execute()
print(type(results), results)

# 链式写法
results = pl.mset(
        {'s1':1, 's2':2, 's3':3}
    ).msetnx(
        {'s4':4, 's3':3}
    ).mget(('s1', 's2', 's3', 's4')).execute()
print(type(results), results) # 结果是一个列表,有每一个命令执行的结果
#<class 'list'> [True, False, [b'1', b'2', b'3', None]]

r.close()

特别注意,如果我们需要事务处理的时候,可以考虑pipeline中开启,保证一系列操作完整操作。

哨兵模式编程

https://github.com/RedisLabs/redis-py#sentinel-support

通过哨兵获取主节点和从节点

  • 返回的元组方式
from redis.sentinel import Sentinel

sentinels = Sentinel([("10.0.0.4", 26379)])

# mymaster2是集群的标识符
print(sentinels.discover_master("mymaster2"))
print(sentinels.discover_slaves("mymaster2"))

"""
('10.0.0.5', 6379)
[('10.0.0.4', 6379)]
"""

如果 抛异常,无法访问Sentinel ,请修改Sentinel的配置,增加 protected-mode no 。保护模式如果开启,只接受回环地址连接,拒绝外部链接,而且正常应该配置多个哨兵,避免一个哨兵出现独裁情况,如果配置多个哨兵,开启保护,也会拒绝其它sentinel的连接,从而导致哨兵配置无法生效。

  • 返回Redis类的方式
from redis.sentinel import Sentinel

sentinels = Sentinel([("10.0.0.4", 26379)])

m_rd = sentinels.master_for("mymaster2")
s_rd = sentinels.slave_for("mymaster2")
print(m_rd, type(m_rd), s_rd, type(s_rd))

"""
Redis<SentinelConnectionPool<service=mymaster2(master)> <class 'redis.client.Redis'>
Redis<SentinelConnectionPool<service=mymaster2(slave)> <class 'redis.client.Redis'>
"""

返回的Redis类可以直接用来连接redis

需要加入库号和密码

from redis.sentinel import Sentinel

sentinels = Sentinel([("10.0.0.4", 26379)])

# 通过源码可以看出,可以通过塞入库号和密码的方式来访问库
m_rd = sentinels.master_for("mymaster2", db=1, password="111111")
s_rd = sentinels.slave_for("mymaster2", db=1, password="111111")
print(m_rd, type(m_rd), s_rd, type(s_rd))
# 通过返回的类直接操作数据库
print(m_rd.keys())
print(s_rd.keys())
print(m_rd.get("a"))
print(s_rd.get("a"))

"""
Redis<SentinelConnectionPool<service=mymaster2(master)> <class 'redis.client.Redis'> Redis<SentinelConnectionPool<service=mymaster2(slave)> <class 'redis.client.Redis'>
[b's2', b'a', b's1', b's3', b'c', b'3', b'd']
[b'd', b'c', b's3', b'a', b's2', b'3', b's1']
b'2'
b'2'
"""

Scrapy-redis组件

Scrapy-redis组件

这是一个能给Scrapy框架引入分布式的组件。把架构中的Scheduler替换成redis.

分布式由Redis提供,可以在不同节点上运行爬虫,共用同一个Redis实例。

在Redis中存储待爬取的URLs、Items。

github: https://github.com/rmax/scrapy-redis

特点:

  • 分布式爬取/抓取
    • 您可以启动多个共享单个 redis 队列的爬虫实例。最适合广泛的多域名爬取。
  • 分布式后处理
    • 抓取的项目将被推送到 redis 队列中,这意味着您可以启动共享项目队列的任意数量的后处理进程。
  • Scrapy 即插即用组件
    • 调度程序 + 重复过滤器、项目管道、基础蜘蛛。
  • 在此分叉版本中:添加了json对 Redis 数据的支持

安装

pip install scrapy-redis

配置

# Enables scheduling storing requests queue in redis. 
SCHEDULER = "scrapy_redis.scheduler.Scheduler"

# Ensure all spiders share same duplicates filter through redis. 
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"

# Store scraped item in redis for post-processing.
ITEM_PIPELINES = { 'scrapy_redis.pipelines.RedisPipeline': 300}

# The item pipeline serializes and stores the items in this redis key.
# 这个key很重要
#REDIS_ITEMS_KEY = '%(spider)s:items'

# Specify the host and port to use when connecting to Redis (optional). 
#REDIS_HOST = 'localhost'
#REDIS_PORT = 6379

# Specify the full Redis URL for connecting (optional).
# If set, this takes precedence over the REDIS_HOST and REDIS_PORT settings.
#REDIS_URL = 'redis://user:pass@hostname:9001'

# Default start urls key for RedisSpider and RedisCrawlSpider.
#REDIS_START_URLS_KEY = '%(name)s:start_urls'

在以下方面做了增强

Scheduler + Duplication Filter, Item Pipeline, Base Spiders

  • Duplication Filter
    • scrapy使用set来去重,去重的指纹有method+url+body+header去重比例小。参考,scrapy.utils.request.request_fingerprint
    • scrapy-redis使用同样的scrapy指纹算法,使用redis的set类型去重。生成指纹的hash存入Redis set中比对
    • 使用Redis服务,实现 增量式爬虫
  • Scheduler
    • 本质上将原来的普通队列,变成了redis以提供多爬虫多进程共享,并行能力增强。
  • Duplication Filter
    • scrapy使用set来去重,scrapy-redis使用redis的set类型去重 Item Pipeline
  • Item Pipelline
    • 在Item Pipeline增加一个处理,即将数据items存入redis的items queue中 Base Spiders
  • Base Spiders
    • 提供了使用了RedisMixin的RedisSpider和RedisCrawlSpider,从Redis中读取Url。

Redis是服务,爬虫就是它的客户端,客户端就可以扩展出并行的很多爬虫一起爬取。

redis安装

这里不再赘述。

豆瓣影评分析项目

抓取内容分析

抓取最新top 1电影,分析其影评

提取影评的xpath = //div[@class="comment-item"]//span[@class="short"]

创建Scrapy项目

scrapy startproject review .

配置

D:\project\pyproj\pycharm\books\settings.py

BOT_NAME = "review"

SPIDER_MODULES = ["review.spiders"]
NEWSPIDER_MODULE = "review.spiders"

ADDONS = {}

USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36"

ROBOTSTXT_OBEY = False
CONCURRENT_REQUESTS = 4
DOWNLOAD_DELAY = 1 # 1秒爬一次
COOKIES_ENABLED = False   # 每一次全新的请求;

# Disable Telnet Console (enabled by default)
#TELNETCONSOLE_ENABLED = False


# Enable or disable spider middlewares
# See https://docs.scrapy.org/en/latest/topics/spider-middleware.html
#SPIDER_MIDDLEWARES = {
#    'dbreview.middlewares.DbreviewSpiderMiddleware': 543,
#}

# Enable or disable downloader middlewares
# See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html
#DOWNLOADER_MIDDLEWARES = {
#    'dbreview.middlewares.DbreviewDownloaderMiddleware': 543,
#}

# Enable or disable extensions
# See https://docs.scrapy.org/en/latest/topics/extensions.html
#EXTENSIONS = {
#    'scrapy.extensions.telnet.TelnetConsole': None,
#}

# Configure item pipelines
# See https://docs.scrapy.org/en/latest/topics/item-pipeline.html
# ITEM_PIPELINES = {
# #    'dbreview.pipelines.DbreviewPipeline': 299,
# 'scrapy_redis.pipelines.RedisPipeline': 300
# }



# scrapy-redis
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
REDIS_URL = 'redis://:[email protected]:6379'
# Default start urls key for RedisSpider and RedisCrawlSpider.
#REDIS_START_URLS_KEY = '%(name)s:start_urls'
# 修改默认的ITEM_PIPELINES
ITEM_PIPELINES = {
    'scrapy_redis.pipelines.RedisPipeline': 300
}

构建Item

D:\project\pyproj\pycharm\review\items.py

import scrapy


class ReviewItem(scrapy.Item):
    comment = scrapy.Field()

    # def __repr__(self):
    #     return "{}".format(dict(self))

构建爬虫

#创建爬虫
scrapy genspider -t crawl dbreview douban.com

(.venv1) PS D:\project\pyproj\pycharm> scrapy list   
dbreview

写完先测试,然后将类型改为RedisCrawlSpider

review\spiders\dbreview.py

import scrapy
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from scrapy_redis.spiders import RedisCrawlSpider
from ..items import ReviewItem

class DbreviewSpider(RedisCrawlSpider): # -t crawl RedisCrawlSpider; -t basic RedisSpider
    name = "dbreview" # REDIS_START_URLS_KEY : default: "<spider.name>:start_urls"
    # LPUSH dbreview:start_urls https://movie.douban.com/subject/27605659/comments?status=P
    allowed_domains = ["douban.com"]

    # start_urls = ["https://movie.douban.com/subject/27605659/comments?status=P"] # 起始页 要换掉,通过redis存入
    """Spider that reads urls from redis queue (myspider:start_urls)"""
    # redis_key = 'dbreview:start_urls' # 缺省值是 REDIS_START_URLS_KEY 配置

    rules = (Rule(LinkExtractor(allow=r"start=20"), callback="parse_item", follow=False),)
    # rules = (Rule(LinkExtractor(allow=r"start=\d+"), callback="parse_item", follow=True),)

    def parse_item(self, response):
        comments = response.xpath('//span[@class="short"]//text()').extract() # list
        for c in comments:
            # print(type(c), c, '++++++++++++')
            item = ReviewItem()
            item['comment'] = c
            yield  item
爬取
scrapy crawl dbreview
手动添加开始url

会发现程序会卡住,这是因为在等待起始URL,手动添加开始url

127.0.0.1:6379> LPUSH dbreview:start_urls https://movie.douban.com/subject/27605659/comments?status=P
开启pipeline

settings.py

# 修改默认的ITEM_PIPELINES
ITEM_PIPELINES = {
    'scrapy_redis.pipelines.RedisPipeline': 300
}
代理(可选)

解决反爬问题

思路:在发起 HTTP 请求之前,会经过下载中间件,自定义一个下载中间件,在其中临时获取一个代理地址,然后再发起 HTTP 请求。

访问 https://myip.ipip.net/ 测试自己的出公网口IP

IP代理商

1、下载中间件

仿照 middlewares.py 中的下载中间件编写 process_request阶段,返回 None。

review\middlewares.py

from scrapy.http.request import Request
import random

class ProxyDownloaderMiddleware:
    # 增加代理IP池,可以从网络搜索,可以从配置文件中读取
    # 可以写一个方法,从IP的json中提取,可以存redis, 加过期
    proxy_ip = '192.168.0.163'  # 代理ip
    proxy_port = '58591'  # 代理端口号
    proxies = [
        f'http://{proxy_ip}:{proxy_port}',
    ]

    def process_request(self, request: Request, spider):
        request.meta['proxy'] = random.choice(self.proxies)  # 增加proxy

2、配置

在 settings.py 中配置:

# 3秒
DOWNLOAD_DELAY = 3

DOWNLOADER_MIDDLEWARES = {
    'spiderman.middlewares.ProxyDownloaderMiddleware': 125,
}

增加一个测试用的 spider 类,如果代理设置成功,该爬虫返回的内容就会是当前代理的信息:

#review\spiders\iptest.py
import scrapy

class IPTestSpider(scrapy.Spider):
    name = 'test'
    allowed_domains = ['ipip.net']
    start_urls = ['http://myip.ipip.net/']

    def parse(self, response, **kwargs):
        text = response.text
        print(1, '-->', text)
        return None

执行

scrapy crawl test

查看打印的内容,是不是代理的地址信息。如果代理测试成功,就可以继续下面的操作。

分析

使用爬虫,爬取所有数据,然后使用redis中的数据开始分析

import redis
import simplejson

client = redis.from_url('redis://:[email protected]:6379/0')
comments = client.lrange('dbreview:items', 0, -1)

for c in comments:
    x = simplejson.loads(c).get('comment')
    if x:
        print(x)

client.close()
#--------------------------------------------------------
迪士尼撸出来的玩意,糟蹋了一个好题材,一帮中国演员说着不那么标准的英语在那里拿腔拿调的念台词真是尴尬至极
-----------------------
(70/100)工整流畅合格的商业片。迪士尼公主电影里品质上乘的存在。刘亦菲的表演是没有问题的,动作戏精彩,画面质感一流,片尾出字幕时的title design超好看。中国公主花木兰没有让人失望。
-----------------------
太出戏了,河北土楼?Niubi
-----------------------
跟玩似的,弱智一群人,成就一个人。
真实与逻辑并不需要。
-----------------------
先说结论:壳是中国壳,魂还是老外那个劲,造型是日本艺伎,内核是老美超级英雄主义。
总之是外国对中国片面化的认识。
。。。。。。
看完挺不适的,电影还是明显的迪士尼风格。能理解迪士尼想凸现花木兰的英勇女性角色,但是如果这种精神是以贬低中国和女性为衬托,那么我表示拒绝!整体挺局限的,从巩俐女巫角色的莫名其妙到刚训练不久的几人小队拯救国家和皇帝,再到整体营造的女性以三从四德和嫁人为荣耀的奇怪思想。多种文化杂糅,多重风格杂糅。影片显然不了解中国文化和花木兰精神。大概从头至尾最能提现花木兰精神的就是剑上那几个字了吧。
-----------------------
jieba分词-结巴分词

官网 https://github.com/fxsjy/jieba

安装

pip install jieba

测试代码

import jieba

seg_list = jieba.cut("我来到北京清华大学", cut_all=True)
print("Full Mode: " + "/ ".join(seg_list))  # 全模式

seg_list = jieba.cut("我来到北京清华大学", cut_all=False)
print("Default Mode: " + "/ ".join(seg_list))  # 精确模式

seg_list = jieba.lcut("他来到了网易杭研大厦")  # 默认是精确模式
print(seg_list)

seg_list = jieba.lcut_for_search("小明硕士毕业于中国科学院计算所,后在日本京都大学深造")  # 搜索引擎模式
print(seg_list)
# ------------------------------------------------
Full Mode: 我/ 来到/ 北京/ 清华/ 清华大学/ 华大/ 大学
Default Mode: 我/ 来到/ 北京/ 清华大学
['他', '来到', '了', '网易', '杭研', '大厦']
['小明', '硕士', '毕业', '于', '中国', '科学', '学院', '科学院', '中国科学院', '计算', '计算所', ',', '后', '在', '日本', '京都', '大学', '日本京都大学', '深造']
stopword 停用词

数据清洗:把脏数据洗掉。检测出并去除掉数据中无效或无关的数据。例如,空值、非法值的检测,重复数据检测等。

对于一条条影评来说,我们分析的数据中包含了很多无效的数据,比如标点符号、英文的冠词、中文"的"等等,需要把它们清除掉。

使用停用词来去除这些无效的数据。

范例

创建文件 chineseStopWords.txt,添加不用的词

,
。
!
那么

测试文件

import redis
import simplejson
import jieba

client = redis.from_url('redis://:[email protected]:6379/0')
comments = client.lrange('dbreview:items', 0, -1)

stopwords = set() # 停用词
with open('./chineseStopWords.txt', encoding='utf-8') as f:
    for line in f:
        # print(line.encode())
        stopwords.add(line.rstrip('\n'))
    stopwords.add(' ')
    stopwords.add('\n')

words = {}

for c in comments:
    review = simplejson.loads(c).get('comment')
    for word in jieba.cut(review): # 分词
        # 统计
        if word not in stopwords:
            words[word] = words.get(word, 0) + 1

print(sorted(words.items(), key=lambda x:x[1], reverse=True)) # 按单词倒排

client.close()

执行结果

[('的', 48), ('是', 12), ('题材', 11), ('了', 9), ('?' xxxx
worlcloud词云

wordcloud词云: https://github.com/amueller/word_cloud

安装

#依赖numpy、matplotlib
pip install wordcloud

#matplotlib:python中绘制二维图的模块。
#pip install matplotlib

常用方法

方法 说明
fit_words(frequencies) Create a word_cloud from words and frequencies.
generate(text) Generate wordcloud from text.
generate_from_frequencies(frequencies[, …]) Create a word_cloud from words and frequencies.
generate_from_text(text) Generate wordcloud from text.
process_text(text) Splits a long text into words, eliminates the stopwords.
recolor([random_state, color_func, colormap]) Recolor existing layout.
to_array() Convert to numpy array.
to_file(filename) Export to image file.
to_svg([embed_font, optimize_embedded_font, …]) Export to SVG.

WordCloud参数

  • font_path:指明要用的字体的路径
  • width:默认值400。设定词云画布的宽度
  • height:默认值200。画布高度
  • mask:默认无。用来设定词云的形状
  • min_font_size:默认值4,整数类型。设定最小的词的尺寸/大小
  • max_font_size:默认无,整数型。设定最大词的大小
  • max_words:默认值200。设定词云最多显示的词的个数
  • background_color:默认值为黑色。设定词云画布底色
  • Scale:默认值1。值越大,图像密度越大越清晰

范例

import redis
import simplejson
import jieba

client = redis.from_url('redis://:[email protected]:6379/0')
comments = client.lrange('dbreview:items', 0, -1)

stopwords = set() # 停用词
with open('./chineseStopWords.txt', encoding='utf-8') as f:
    for line in f:
        # print(line.encode())
        stopwords.add(line.rstrip('\n'))
    stopwords.add(' ')
    stopwords.add('\n')

words = {}

for c in comments:
    review = simplejson.loads(c).get('comment')
    for word in jieba.cut(review): # 分词
        # 统计
        if word not in stopwords:
            words[word] = words.get(word, 0) + 1

print(sorted(words.items(), key=lambda x:x[1], reverse=True)) # 按单词倒排

from wordcloud import WordCloud
import matplotlib.pyplot as plt
# 如果弹不出图片,指定Qt5Agg解决
# import matplotlib
# matplotlib.use('Qt5Agg') # pip install pyqt5

wdc = WordCloud(font_path='simhei.ttf', scale=15, max_font_size=40)

plt.figure(2)
wdc.fit_words(words) # 使用单词创建词云
plt.imshow(wdc)      # 将一个图显示在二维坐标轴上
plt.axis('off')      # 不打印坐标第 
plt.show()           # 展示

client.close()

词频

import redis
import simplejson
import jieba

client = redis.from_url('redis://:[email protected]:6379/0')
comments = client.lrange('dbreview:items', 0, -1)

stopwords = set() # 停用词
with open('./chineseStopWords.txt', encoding='utf-8') as f:
    for line in f:
        # print(line.encode())
        stopwords.add(line.rstrip('\n'))
    stopwords.add(' ')
    stopwords.add('\n')

words = {}
total = 0
for c in comments:
    review = simplejson.loads(c).get('comment')
    for word in jieba.cut(review): # 分词
        # 统计
        if word not in stopwords:
            words[word] = words.get(word, 0) + 1
            total += 1

print(len(words))
print(sorted(words.items(), key=lambda x:x[1], reverse=True)) # 按单词倒排

# 总数
print(total)

# 词频
frenq = {k:v/total for k,v in words.items()}
print(sorted(frenq.items(), key=lambda x:x[1], reverse=True))

from wordcloud import WordCloud
import matplotlib.pyplot as plt
# 如果弹不出图片,指定Qt5Agg解决
# import matplotlib
# matplotlib.use('Qt5Agg') # pip install pyqt5

wdc = WordCloud(font_path='simhei.ttf', scale=15, max_font_size=40)

plt.figure(2)
wdc.fit_words(frenq) # 使用单词和词频创建词云
plt.imshow(wdc)      # 将一个图显示在二维坐标轴上
plt.axis('off')      # 不打印坐标第
plt.show()           # 展示

client.close()
emacs

Emacs

org-mode

Orgmode

Donations

打赏

Copyright

© 2025 Jasper Hsu

Creative Commons

Creative Commons

Attribute

Attribute

Noncommercial

Noncommercial

Share Alike

Share Alike