引言

数据采集工作中,难免会遇到增量采集。而在增量采集中,如何去重是一个大问题,因为实际的需要采集的数据也许并不多,但往往要在判断是否已经采集过这件事上花点时间。比如对于资讯采集,如果发布网站每天只更新几条或者根本就不更新,那么如何让采集程序每次只采集这更新的几条(或不采集)是一件很简单的事,数据库就是一种实现方式。不过当面临大量的目标网站时,每次采集前也许就需要先对数据库进行大量的查询操作,这是一件费时的事情,难免降低采集程序的性能,使得每次采集耗时变大。本文从资讯采集角度出发,以新浪新闻排行(地址:http://news.sina.com.cn/hotnews/)资讯采集为例,针对增量采集提出了去重方案。

数据库去重

考虑到数据库查询亦需耗费时间,因此去重字段可单独存放到一张表上,或使用 redis 等查询耗时较少的数据库。一般来说,可以将文章详情页源地址作为去重字段。考虑到某些连接字符过长,可以使用 md5 将其转化为统一长度的字符。而后在每次采集时,先抓取列表页,解析其中的文章信息,将链接 md5 化,然后再将得到的结果到数据库中查询,如果存在则略过,否则深入采集。以 redis 为例,实现代码如下:

import hashlib

import redis
import requests
import parsel


class NewsSpider(object):

    start_url = 'http://news.sina.com.cn/hotnews/'

    def __init__(self):
        self.db = RedisClient('news')

    def start(self):
        r = requests.get(self.start_url)
        for url in self.parse_article(r):

            fingerprint = get_url_fingerprint(url)

            if self.db.is_existed(fingerprint):
                continue

            try:
                self.parse_detail(requests.get(url))
            except Exception as e:
                print(e)
            else:
                # 如果解析正常则将地址放入数据库
                self.db.add(fingerprint)

    def parse_article(self, response):
        """解析文章列表页并返回文章地址"""
        selector = parsel.Selector(text=response.text)
        # 获取所有文章地址
        return selector.css('.ConsTi a::attr(href)').getall()

    def parse_detail(self, response):
        """详情页解析逻辑省略"""
        pass


class RedisClient(object):
    """Redis 客户端"""
    def __init__(self, key):
        self._db = redis.Redis(
            host='localhost',
            port=6379,
            decode_responses=True
        )
        self.key = key

    def is_existed(self, value):
        """检测 value 是否已经存在
        如果存在则返回 True 否则 False
        """
        return self._db.sismember(self.key, value)

    def add(self, value):
        """存入数据库"""
        return self._db.sadd(self.key, value)


def get_url_fingerprint(url):
    """对 url 进行 md5 加密并返回加密后的字符串"""
    md5 = hashlib.md5()
    md5.update(url.encode('utf-8'))
    return md5.hexdigest()

注意以上代码中,对请求的 md5 并没有考虑对 POST 请求的 md5 加密,如有需要可自行实现。

根据 HTTP 缓存机制去重

设若我们有大量的列表页中的文章需要采集,而距离上次采集时,有的更新了几条新闻,有的则没有更新。那么如何判断数据库去重虽然能解决具体文章的去重,但对于文章索引页仍需要每次请求,因为 HEAD 请求所花费的时间要比 GET/POST 请求要少,所以可用 HEAD 请求获取索引页的相关信息,从而判断索引页是否有更新。通过对目标地址请求可以查看具体信息:

>>> r = requests.head('http://news.sina.com.cn/hotnews/')
>>> for k, v in r.headers.items():
...     print(k, v)
...
Server: nginx
Date: Fri, 31 Jan 2020 09:33:22 GMT
Content-Type: text/html
Content-Length: 37360
Connection: keep-alive
Vary: Accept-Encoding
ETag: "5e33f39e-28e01"V=CCD0B746
X-Powered-By: shci_v1.03
Expires: Fri, 31 Jan 2020 09:34:16 GMT
Cache-Control: max-age=60
Content-Encoding: gzip
Age: 6
Via: http/1.1 ctc.guangzhou.union.182 (ApacheTrafficServer/6.2.1 [cSsNfU]), http/1.1 ctc.chongqing.union.138 (ApacheTrafficServer/6.2.1 [cHs f ])
X-Via-Edge: 158046320209969a5527d9b2299db18a555d7
X-Cache: HIT.138
X-Via-CDN: f=edge,s=ctc.chongqing.union.144.nb.sinaedge.com,c=125.82.165.105;f=Edge,s=ctc.chongqing.union.138,c=219.153.34.144

在此,基于 HTTP 缓存机制,我们有以下几种方式来判断页面是否有更新:

1. Content-Length

注意以上信息中的 Content-Length 字段,它指明了索引页字符的长度。由于一般有更新的页面字符长度也会有所不同,因此可以使用它来判定索引页是否有更新。

2. ETag

Etag 响应头字段表示资源的版本,在发送请求时带上 If-None-Match 头字段,来询问服务器该版本是否仍然可用。如果服务器发现该版本仍然是最新的,就可以返回 304 状态码指示 UA 继续使用缓存,那么就不用再采集具体的页面了。本例中亦可使用:

# ETag 完整字段为  "5e33f39e-28e01"V=CCD0B746
# 实际需要的则是 5e33f39e-28e01
>>> r = requests.get('http://news.sina.com.cn/hotnews/', headers={'If-None-Match': '5e33f39e-28e01'})
>>> r.status_code
304
>>> r.text
''

3. Last-Modified

本例并没有 Last-Modified 字段,不过考虑到其他网站可能会有该字段,因此亦可考虑作为去重方式之一。该字段与 Etag 类似,Last-Modified HTTP 响应头也用来标识资源的有效性。不同的是使用修改时间而不是实体标签。对应的请求头字段为 If-Modified-Since

实现思路

以上三种都可以将上次请求得到的信息存入到数据库中,再次请求时则取出相应信息并发送相应请求,如果与本地一致(或响应码为 304)则判断为未更新,不然则继续请求。鉴于有的网站并没有实现 HTTP 缓存机制,有的则只实现某一种。因此可以考虑在采集程序中将以上三种机制全部实现,从而保证最大化的减少无效请求。具体实现思路为:

请求目标网站

    - 以 Content-Length 判断 -> HEAD 请求
        与本地存储长度对比
            一致    -> 忽略
            不一致  -> 发送 GET/POST 请求
                       同时更新本地数据

    - 以 ETag/Last-Modified 判断 -> GET/POST 请求
      并在请求头中带上相应信息
        判断响应码
            304 -> 忽略
            200 -> 解析并更新本地数据

    - 无相应字段 -> GET/POST 请求

根据更新频率分配优先权

根据 HTTP 缓存机制并不能完美的适配所有网站,因此,可以记录各个目标网站的更新频率,并将更新较为频繁的网站作为优先采集对象。

参考

  1. 使用 HTTP 缓存:Etag, Last-Modified 与 Cache-Control: https://harttle.land/2017/04/04/using-http-cache.html
09-21 11:14