1. 本节目标
我们的主要目标是利用代理爬取微信公众号的文章,提取正文、发表日期、公众号等内容,爬取来源是搜狗微信,其链接为 http://weixin.sogou.com/,然后把爬取结果保存到 MySQL 数据库。
2. 准备工作
首先需要准备并正常运行前文中所介绍的代理池。这里需要用的 Python 库有 aiohttp、requests、redis-py、pyquery、Flask、PyMySQL,如这些库没有安装可以参考第 1 章的安装说明。
3. 爬取分析
搜狗对微信公众平台的公众号和文章做了整合。我们可以通过上面的链接搜索到相关的公众号和文章,例如搜索 NBA,可以搜索到最新的文章,如图 9-21 所示。图 9-21 搜索结果 点击搜索后,搜索结果的 URL 中其实有很多无关 GET 请求参数,将无关的参数去掉,只保留 type 和 query 参数,例如 http://weixin.sogou.com/weixin?type=2&query=NBA,搜索关键词为 NBA,类型为 2,2 代表搜索微信文章。 下拉网页,点击下一页即可翻页,如图 9-22 所示。图 9-22 翻页列表 注意,如果没有输入账号登录,那只能看到 10 页的内容,登录之后可以看到 100 页内容,如图 9-23 和图 9-24 所示。图 9-23 不登录的结果图 9-24 登录后的结果 如果需要爬取更多内容,就需要登录并使用 Cookies 来爬取。 搜狗微信站点的反爬虫能力很强,如连续刷新,站点就会弹出类似如图 9-25 所示的验证。图 9-25 验证码页面 网络请求出现了 302 跳转,返回状态码为 302,跳转的链接开头为 http://weixin.sogou.com/antispider/,这很明显就是一个反爬虫的验证页面。所以我们得出结论,如果服务器返回状态码为 302 而非 200,则 IP 访问次数太高,IP 被封禁,此请求就是失败了。 如果遇到这种情况,我们可以选择识别验证码并解封,也可以使用代理直接切换 IP。在这里我们采用第二种方法,使用代理直接跳过这个验证。代理使用上一节所讲的代理池,还需要更改检测的 URL 为搜狗微信的站点。 对于这种反爬能力很强的网站来说,如果我们遇到此种返回状态就需要重试。所以我们采用另一种爬取方式,借助数据库构造一个爬取队列,待爬取的请求都放到队列里,如果请求失败了重新放回队列,就会被重新调度爬取。 在这里我们可以采用 Redis 的队列数据结构,新的请求就加入队列,或者有需要重试的请求也放回队列。调度的时候如果队列不为空,那就把一个个请求取出来执行,得到响应后再进行解析,提取出我们想要的结果。 这次我们采用 MySQL 存储,借助 PyMySQL 库,将爬取结果构造为一个字典,实现动态存储。 综上所述,我们本节实现的功能有如下几点。
- 修改代理池检测链接为搜狗微信站点
- 构造 Redis 爬取队列,用队列实现请求的存取
- 实现异常处理,失败的请求重新加入队列
- 实现翻页和提取文章列表并把对应请求加入队列
- 实现微信文章的信息的提取
- 将提取到的信息保存到 MySQL
好,那么接下来我们就用代码来实现一下。
4. 构造 Request
既然我们要用队列来存储请求,那么肯定要实现一个请求 Request 的数据结构,这个请求需要包含一些必要信息,如请求链接、请求头、请求方式、超时时间。另外对于某个请求,我们需要实现对应的方法来处理它的响应,所以需要再加一个 Callback 回调函数。每次翻页请求需要代理来实现,所以还需要一个参数 NeedProxy。如果一个请求失败次数太多,那就不再重新请求了,所以还需要加失败次数的记录。 这些字段都需要作为 Request 的一部分,组成一个完整的 Request 对象放入队列去调度,这样从队列获取出来的时候直接执行这个 Request 对象就好了。 我们可以采用继承 reqeusts 库中的 Request 对象的方式来实现这个数据结构。requests 库中已经有了 Request 对象,它将请求 Request 作为一个整体对象去执行,得到响应后再返回。其实 requests 库的 get ()、post () 等方法都是通过执行 Request 对象实现的。 我们首先看看 Request 对象的源码:
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
|
class Request(RequestHooksMixin): def __init__(self, method=None, url=None, headers=None, files=None, data=None, params=None, auth=None, cookies=None, hooks=None, json=None):
# Default empty dicts for dict params. data = [] if data is None else data files = [] if files is None else files headers =
|
这是 requests 库中 Request 对象的构造方法。这个 Request 已经包含了请求方式、请求链接、请求头这几个属性,但是相比我们需要的还差了几个。我们需要实现一个特定的数据结构,在原先基础上加入上文所提到的额外几个属性。这里我们需要继承 Request 对象重新实现一个请求,将它定义为 WeixinRequest,实现如下:
1 2 3 4 5 6 7 8 9 10
|
TIMEOUT = 10 from requests import Request
class WeixinRequest(Request): def __init__(self, url, callback, method=‘GET‘, headers=None, need_proxy=False, fail_time=0, timeout=TIMEOUT): Request.__init__(self, method, url, headers) self.callback = callback self.need_proxy = need_proxy self.fail_time = fail_time self.timeout = timeout
|
在这里我们实现了 WeixinRequest 数据结构。init() 方法先调用了 Request 的 init() 方法,然后加入额外的几个参数,定义为 callback、need_proxy、fail_time、timeout,分别代表回调函数、是否需要代理爬取、失败次数、超时时间。 我们就可以将 WeixinRequest 作为一个整体来执行,一个个 WeixinRequest 对象都是独立的,每个请求都有自己的属性。例如,我们可以调用它的 callback,就可以知道这个请求的响应应该用什么方法来处理,调用 fail_time 就可以知道这个请求失败了多少次,判断失败次数是不是到了阈值,该不该丢弃这个请求。这里我们采用了面向对象的一些思想。
5. 实现请求队列
接下来我们就需要构造请求队列,实现请求的存取。存取无非就是两个操作,一个是放,一个是取,所以这里利用 Redis 的 rpush () 和 lpop () 方法即可。 另外还需要注意,存取不能直接存 Request 对象,Redis 里面存的是字符串。所以在存 Request 对象之前我们先把它序列化,取出来的时候再将其反序列化,这个过程可以利用 pickle 模块实现。
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
|
from pickle import dumps, loads from request import WeixinRequest
class RedisQueue(): def __init__(self): """初始化 Redis""" self.db = StrictRedis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD)
def add(self, request): """ 向队列添加序列化后的 Request :param request: 请求对象 :param fail_time: 失败次数 :return: 添加结果 """ if isinstance(request, WeixinRequest): return self.db.rpush(REDIS_KEY, dumps(request)) return False
def pop(self): """ 取出下一个 Request 并反序列化 :return: Request or None """ if self.db.llen(REDIS_KEY): return loads(self.db.lpop(REDIS_KEY)) else: return False
def empty(self): return self.db.llen(REDIS_KEY) == 0
|
这里实现了一个 RedisQueue,它的 init() 构造方法里面初始化了一个 StrictRedis 对象。随后实现了 add () 方法,首先判断 Request 的类型,如果是 WeixinRequest,那么就把程序就会用 pickle 的 dumps () 方法序列化,然后再调用 rpush () 方法加入队列。pop () 方法则相反,调用 lpop () 方法将请求从队列取出,然后再用 pickle 的 loads () 方法将其转为 WeixinRequest 对象。另外,empty () 方法返回队列是否为空,只需要判断队列长度是否为 0 即可。 在调度的时候,我们只需要新建一个 RedisQueue 对象,然后调用 add () 方法,传入 WeixinRequest 对象,即可将 WeixinRequest 加入队列,调用 pop () 方法,即可取出下一个 WeixinRequest 对象,非常简单易用。
6. 修改代理池
接下来我们要生成请求并开始爬取。在此之前还需要做一件事,那就是先找一些可用代理。 之前代理池检测的 URL 并不是搜狗微信站点,所以我们需要将代理池检测的 URL 修改成搜狗微信站点,以便于把被搜狗微信站点封禁的代理剔除掉,留下可用代理。 现在将代理池的设置文件中的 TEST_URL 修改一下,如 http://weixin.sogou.com/weixin?type=2& query=nba,被本站点封的代理就会减分,正常请求的代理就会赋值为 100,最后留下的就是可用代理。 修改之后将获取模块、检测模块、接口模块的开关都设置为 True,让代理池运行一会,如图 9-26 所示。图 9-26 代理池运行结果 这样,数据库中留下的 100 分的代理就是针对搜狗微信的可用代理了,如图 9-27 所示。图 9-27 可用代理列表 同时访问代理接口,接口设置为 5555,访问 http://127.0.0.1:5555/random,即可获取到随机可用代理,如图 9-28 所示。图 9-28 代理接口 再定义一个函数来获取随机代理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
PROXY_POOL_URL = ‘http://127.0.0.1:5555/random‘
def get_proxy(self): """ 从代理池获取代理 :return: """ try: response = requests.get(PROXY_POOL_URL) if response.status_code == 200: print(‘Get Proxy‘, response.text) return response.text return None except requests.ConnectionError: return None
|
7. 第一个请求
一切准备工作都做好,下面我们就可以构造第一个请求放到队列里以供调度了。定义一个 Spider 类,实现 start () 方法的代码如下:
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
|
from requests import Session from db import RedisQueue from request import WeixinRequest from urllib.parse import urlencode
class Spider(): base_url = ‘http://weixin.sogou.com/weixin‘ keyword = ‘NBA‘ headers = { ‘Accept‘: ‘text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8‘, ‘Accept-Encoding‘: ‘gzip, deflate‘, ‘Accept-Language‘: ‘zh-CN,zh;q=0.8,en;q=0.6,ja;q=0.4,zh-TW;q=0.2,mt;q=0.2‘, ‘Cache-Control‘: ‘max-age=0‘, ‘Connection‘: ‘keep-alive‘, ‘Cookie‘: ‘IPLOC=CN1100; SUID=6FEDCF3C541C940A000000005968CF55; SUV=1500041046435211; ABTEST=0|1500041048|v1; SNUID=CEA85AE02A2F7E6EAFF9C1FE2ABEBE6F; weixinIndexVisited=1; JSESSIONID=aaar_m7LEIW-jg_gikPZv; ld=Wkllllllll2BzGMVlllllVOo8cUlllll5G@HbZllll9lllllRklll5@@@@@@@@@@‘, ‘Host‘: ‘weixin.sogou.com‘, ‘Upgrade-Insecure-Requests‘: ‘1‘, ‘User-Agent‘: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36‘ } session = Session() queue = RedisQueue()
def start(self): """初始化工作"""
|
这里定义了 Spider 类,设置了很多全局变量,比如 keyword 设置为 NBA,headers 就是请求头。在浏览器里登录账号,然后在开发者工具里将请求头复制出来,记得带上 Cookie 字段,这样才能爬取 100 页的内容。然后初始化了 Session 和 RedisQueue 对象,它们分别用来执行请求和存储请求。 首先,start () 方法全局更新了 headers,使得所有请求都能应用 Cookies。然后构造了一个起始 URL:http://weixin.sogou.com/weixin?type=2&query=NBA,随后用改 URL 构造了一个 WeixinRequest 对象。回调函数是 Spider 类的 parse_index () 方法,也就是当这个请求成功之后就用 parse_index () 来处理和解析。need_proxy 参数设置为 True,代表执行这个请求需要用到代理。随后我们调用了 RedisQueue 的 add () 方法,将这个请求加入队列,等待调度。
8. 调度请求
加入第一个请求之后,调度开始了。我们首先从队列中取出这个请求,将它的结果解析出来,生成新的请求加入队列,然后拿出新的请求,将结果解析,再生成新的请求加入队列,这样循环往复执行,直到队列中没有请求,则代表爬取结束。我们用代码实现如下:
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
|
VALID_STATUSES = [200]
def schedule(self): """ 调度请求 :return: """ while not self.queue.empty(): weixin_request = self.queue.pop() callback = weixin_request.callback print(‘Schedule‘, weixin_request.url) response = self.request(weixin_request) if response and response.status_code in VALID_STATUSES: results = list(callback(response)) if results: for result in results: print(‘New Result‘, result) if isinstance(result, WeixinRequest): self.queue.add(result) if isinstance(result, dict): self.mysql.insert(‘articles‘, result) else: self.error(weixin_request) else: self.error(weixin_request)
|
在这里实现了一个 schedule () 方法,其内部是一个循环,循环的判断是队列不为空。 当队列不为空时,调用 pop () 方法取出下一个请求,调用 request () 方法执行这个请求,request () 方法的实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
from requests import ReadTimeout, ConnectionError
def request(self, weixin_request): """ 执行请求 :param weixin_request: 请求 :return: 响应 """ try: if weixin_request.need_proxy: proxy = get_proxy() if proxy: proxies = { ‘http‘: ‘http://‘ + proxy, ‘https‘: ‘https://‘ + proxy } return self.session.send(weixin_request.prepare(), timeout=weixin_request.timeout, allow_redirects=False, proxies=proxies) return self.session.send(weixin_request.prepare(), timeout=weixin_request.timeout, allow_redirects=False) except (ConnectionError, ReadTimeout) as e: print(e.args) return False
|
这里首先判断这个请求是否需要代理,如果需要代理,则调用 get_proxy () 方法获取代理,然后调用 Session 的 send () 方法执行这个请求。这里的请求调用了 prepare () 方法转化为 Prepared Request,具体的用法可以参考 http://docs.python-requests.org/en/master/user/advanced/#prepared-requests,同时设置 allow_redirects 为 False,timeout 是该请求的超时时间,最后响应返回。 执行 request () 方法之后会得到两种结果:一种是 False,即请求失败,连接错误;另一种是 Response 对象,还需要判断状态码,如果状态码合法,那么就进行解析,否则重新将请求加回队列。 如果状态码合法,解析的时候就会调用 WeixinRequest 的回调函数进行解析。比如这里的回调函数是 parse_index (),其实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
from pyquery import PyQuery as pq
def parse_index(self, response): """ 解析索引页 :param response: 响应 :return: 新的响应 """ doc = pq(response.text) items = doc(‘.news-box .news-list li .txt-box h3 a‘).items() for item in items: url = item.attr(‘href‘) weixin_request = WeixinRequest(url=url, callback=self.parse_detail) yield weixin_request next = doc(‘#sogou_next‘).attr(‘href‘) if next: url = self.base_url + str(next) weixin_request = WeixinRequest(url=url, callback=self.parse_index, need_proxy=True) yield weixin_request
|
此方法做了两件事:一件事就是获取本页的所有微信文章链接,另一件事就是获取下一页的链接,再构造成 WeixinRequest 之后 yield 返回。 然后,schedule () 方法将返回的结果进行遍历,利用 isinstance () 方法判断返回结果,如果返回结果是 WeixinRequest,就将其重新加入队列。 至此,第一次循环结束。 这时 while 循环会继续执行。队列已经包含第一页内容的文章详情页请求和下一页的请求,所以第二次循环得到的下一个请求就是文章详情页的请求,程序重新调用 request () 方法获取其响应,然后调用其对应的回调函数解析。这时详情页请求的回调方法就不同了,这次是 parse_detail () 方法,此方法实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
|
def parse_detail(self, response): """ 解析详情页 :param response: 响应 :return: 微信公众号文章 """ doc = pq(response.text) data = {‘title‘: doc(‘.rich_media_title‘).text(), ‘content‘: doc(‘.rich_media_content‘).text(), ‘date‘: doc(‘#post-date‘).text(), ‘nickname‘: doc(‘#js_profile_qrcode> div > strong‘).text(), ‘wechat‘: doc(‘#js_profile_qrcode> div > p:nth-child(3) > span‘).text()} yield data
|
这个方法解析了微信文章详情页的内容,提取出它的标题、正文文本、发布日期、发布人昵称、微信公众号名称,将这些信息组合成一个字典返回。 结果返回之后还需要判断类型,如是字典类型,程序就调用 mysql 对象的 insert () 方法将数据存入数据库。 这样,第二次循环执行完毕。 第三次循环、第四次循环,循环往复,每个请求都有各自的回调函数,索引页解析完毕之后会继续生成后续请求,详情页解析完毕之后会返回结果以便存储,直到爬取完毕。 现在,整个调度就完成了。 我们完善一下整个 Spider 代码,实现如下:
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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150
|
from requests import Session from config import * from db import RedisQueue from mysql import MySQL from request import WeixinRequest from urllib.parse import urlencode import requests from pyquery import PyQuery as pq from requests import ReadTimeout, ConnectionError
class Spider(): base_url = ‘http://weixin.sogou.com/weixin‘ keyword = ‘NBA‘ headers = { ‘Accept‘: ‘text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8‘, ‘Accept-Encoding‘: ‘gzip, deflate‘, ‘Accept-Language‘: ‘zh-CN,zh;q=0.8,en;q=0.6,ja;q=0.4,zh-TW;q=0.2,mt;q=0.2‘, ‘Cache-Control‘: ‘max-age=0‘, ‘Connection‘: ‘keep-alive‘, ‘Cookie‘: ‘IPLOC=CN1100; SUID=6FEDCF3C541C940A000000005968CF55; SUV=1500041046435211; ABTEST=0|1500041048|v1; SNUID=CEA85AE02A2F7E6EAFF9C1FE2ABEBE6F; weixinIndexVisited=1; JSESSIONID=aaar_m7LEIW-jg_gikPZv; ld=Wkllllllll2BzGMVlllllVOo8cUlllll5G@HbZllll9lllllRklll5@@@@@@@@@@‘, ‘Host‘: ‘weixin.sogou.com‘, ‘Upgrade-Insecure-Requests‘: ‘1‘, ‘User-Agent‘: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36‘ } session = Session() queue = RedisQueue() mysql = MySQL()
def get_proxy(self): """ 从代理池获取代理 :return: """ try: response = requests.get(PROXY_POOL_URL) if response.status_code == 200: print(‘Get Proxy‘, response.text) return response.text return None except requests.ConnectionError: return None
def start(self): """初始化工作""" |