最近在做一个新闻数据分析的项目,需要大量获取ChinaDaily的新闻关键词。本以为用Requests加BeautifulSoup写个脚本就能搞定,结果一上手就发现,各种反爬机制让人头疼,动态加载、请求限制、页面结构变动……踩了不少坑。经过一番折腾,终于搭建了一套还算稳定高效的爬虫系统,日均能稳定采集10万条关键词。今天就把这套架构的设计思路、核心实现以及那些“血泪”避坑经验整理出来,希望能帮到有类似需求的同学。

图片

1. 背景与痛点:为什么新闻网站这么难爬?

刚开始的时候,我用最简单的Requests去抓取,很快就遇到了问题。首先是请求被直接拒绝,返回403状态码。这通常是网站检测到了非常规的User-Agent。接着,即使换了User-Agent,连续请求几次后,IP就被暂时封禁了,要么返回429(请求过多),要么直接连接超时。

更麻烦的是页面内容。ChinaDaily的部分列表页和详情页,关键信息(比如新闻发布时间、正文)是通过JavaScript动态渲染的。用Requests拿到的是初始HTML,这些动态内容都是空的,BeautifulSoup再厉害也解析不出东西来。

总结下来,主要痛点有三个:

  • 反爬机制严密:包括User-Agent校验、IP频率限制、请求头校验等。
  • 页面结构复杂:静态与动态内容混合,定位目标数据的XPath或CSS选择器需要足够健壮。
  • 数据质量要求高:需要从混杂的HTML中精准提取出“关键词”这类特定信息,并清洗干净。

2. 技术选型:Scrapy为何胜出?

面对这些挑战,我评估了几个主流方案:

  • Requests + BeautifulSoup/ lxml:优点是灵活、简单、学习成本低,适合小规模、一次性任务。但缺点也很明显:需要自己管理请求队列、去重、异常重试、并发控制,代码会变得臃肿,维护成本高,在应对复杂反爬和规模化采集时力不从心。

  • Playwright / Selenium:这类浏览器自动化工具的优点是能完美解决JavaScript渲染问题,所见即所得。但致命缺点是效率极低,每个页面都要启动或复用浏览器实例,资源消耗巨大,速度慢,不适合大规模数据采集。它更适合作为辅助工具,针对少量必须用JS渲染的页面。

  • Scrapy:这是一个为爬虫而生的异步框架。它提供了完整的爬虫生命周期管理(请求调度、下载、解析、存储)。其中间件机制可以优雅地插入代理IP、动态User-Agent等逻辑。内置的去重、并发、延迟控制功能开箱即用。虽然处理纯JS渲染页面需要结合scrapy-splashPlaywright,但其高效率和可扩展性使其成为生产级爬虫项目的首选。

综合考虑效率、可维护性和项目规模(日均10万条),我选择了Scrapy作为核心框架,并用scrapy-playwright来处理少数动态页面作为补充。

3. 核心实现:构建健壮的爬虫系统

整个爬虫项目分为几个核心模块,下面我挑重点讲。

3.1 使用中间件对抗反爬

这是稳定爬取的基础。我主要编写了两个下载器中间件。

动态User-Agent中间件:每次请求前,从一个预定义的、丰富的User-Agent列表中随机选取一个设置到请求头中。

# middlewares.py
import random

class RandomUserAgentMiddleware:
    """随机User-Agent中间件"""
    def __init__(self, user_agent_list):
        self.user_agents = user_agent_list

    @classmethod
    def from_crawler(cls, crawler):
        # 从settings.py中加载USER_AGENT_LIST配置
        return cls(crawler.settings.getlist('USER_AGENT_LIST'))

    def process_request(self, request, spider):
        if self.user_agents:
            request.headers['User-Agent'] = random.choice(self.user_agents)
        # 建议也设置一些常见浏览器头部,提升真实性
        request.headers.setdefault('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8')
        request.headers.setdefault('Accept-Language', 'zh-CN,zh;q=0.9,en;q=0.8')

代理IP池中间件:从付费或自建的代理IP服务中获取IP,并设置到请求的meta中。这里需要加入IP失效检测和更换逻辑。

# middlewares.py
class ProxyMiddleware:
    """代理IP中间件"""
    def process_request(self, request, spider):
        # 假设从某个接口或队列获取一个可用代理
        proxy = get_proxy_from_pool()
        if proxy:
            request.meta['proxy'] = f"http://{proxy}"
            # 可以为请求添加一个错误回调,当代理失败时标记该代理不可用
            request.meta['dont_retry'] = False # 允许重试

settings.py中启用并配置它们:

# settings.py
DOWNLOADER_MIDDLEWARES = {
    'your_project.middlewares.RandomUserAgentMiddleware': 543,
    'your_project.middlewares.ProxyMiddleware': 544,
    'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None, # 禁用默认的
}

# 准备一堆User-Agent
USER_AGENT_LIST = [
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ...',
    # ... 添加更多
]
3.2 健壮的页面解析逻辑

ChinaDaily的页面结构并非一成不变,不同频道、不同时期的页面模板可能有细微差别。为了确保解析的健壮性,我采用了以下策略:

  1. 多选择器备用:对同一个目标元素(如新闻标题),同时编写XPath和CSS选择器。在解析函数中,尝试用主选择器,如果失败则尝试备用选择器。
  2. 数据清洗管道:解析出的原始文本通常包含多余的空格、换行符、不可见字符。我写了一个专用的清洗函数。
  3. 关键词提取:这里不仅仅是提取页面已有的关键词标签(<meta keywords>),更主要的是从标题和正文中,通过TF-IDF或TextRank等算法(可以集成jieba.analyse)提取出核心词汇作为补充关键词。
# spiders/chinadaily_keyword.py
import scrapy
from bs4 import BeautifulSoup
import jieba.analyse
from w3lib.html import remove_tags

class ChinadailyKeywordSpider(scrapy.Spider):
    name = 'chinadaily_keyword'
    start_urls = ['http://www.chinadaily.com.cn/']

    def parse(self, response):
        # 1. 解析列表页,获取文章链接
        # 使用XPath,并做好异常处理
        article_links = response.xpath('//h4/a/@href').getall() or response.css('h4 a::attr(href)').getall()
        for link in article_links:
            yield response.follow(link, callback=self.parse_article)

    def parse_article(self, response):
        item = {}
        # 2. 解析详情页,尝试多种选择器获取标题
        title = (response.xpath('//h1[@class="title"]/text()').get() or
                 response.css('h1.headline::text').get() or
                 response.xpath('//title/text()').get())
        item['title'] = self.clean_text(title) if title else None

        # 3. 获取正文 - 通常在一个特定的div里
        content_elem = response.xpath('//div[@id="Content"]') or response.css('div#Content')
        if content_elem:
            # 移除所有HTML标签,得到纯文本
            content_text = remove_tags(content_elem.get())
            item['content'] = self.clean_text(content_text)
        else:
            item['content'] = None

        # 4. 提取关键词
        item['keywords'] = self.extract_keywords(item.get('title'), item.get('content'))
        # 5. 其他元数据:发布时间、来源等
        # ...

        yield item

    def clean_text(self, text):
        """清洗文本"""
        if not text:
            return ''
        # 去除多余空白字符
        import re
        text = re.sub(r'\s+', ' ', text).strip()
        return text

    def extract_keywords(self, title, content, topK=5):
        """从标题和正文中提取关键词"""
        if not content:
            return []
        # 将标题和正文合并作为分析文本
        text = (title or '') + ' ' + content
        # 使用jieba的TF-IDF算法提取关键词
        # 可以加载自定义词典以提升领域词识别
        keywords = jieba.analyse.extract_tags(text, topK=topK, withWeight=False)
        return keywords
3.3 高效去重:BloomFilter的应用

Scrapy有基于内存的默认去重(RFPDupeFilter),但对于海量URL(比如爬取几个月甚至几年的新闻),内存消耗会很大。我引入了布隆过滤器(BloomFilter)

布隆过滤器的特点是空间效率极高,但有一定误判率(可能将未收录的URL判为已收录,但绝不会将已收录的判为未收录)。对于爬虫来说,这可以接受,因为宁可少爬一个页面,也不能重复爬取。

我使用了pybloom-live库来实现,并将其集成到自定义的调度器(Scheduler)中。

# 使用示例,实际需集成到调度器逻辑中
from pybloom_live import BloomFilter

# 假设我们预计有100万个URL,可接受的误判率为0.001
bloom = BloomFilter(capacity=1000000, error_rate=0.001)

def is_url_seen(url):
    """检查URL是否已存在"""
    if url in bloom:
        return True
    else:
        bloom.add(url)
        return False

在Scrapy中,你可以通过继承BaseDupeFilter并重写request_seen方法,将默认的set存储替换为BloomFilter。

4. 生产环境考量:稳定与监控

爬虫在开发环境跑通只是第一步,要长时间稳定运行,还需要更多配置。

  • 控制爬取频率:Scrapy的AutoThrottle扩展非常有用。它能根据服务器的响应延迟自动调整请求间隔,既礼貌又能最大化利用带宽。在settings.py中启用即可。

    AUTOTHROTTLE_ENABLED = True
    AUTOTHROTTLE_START_DELAY = 5.0 # 初始延迟
    AUTOTHROTTLE_MAX_DELAY = 60.0 # 最高延迟
    AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0 # 目标并发数,调低更友好
    
  • 监控健康状态:使用PrometheusGrafana来监控爬虫。可以通过scrapy-prometheus-exporter这样的中间件,将爬虫的统计信息(如请求数、成功/失败数、Item抓取数)暴露给Prometheus,从而实时查看爬虫状态、发现异常。

图片

5. 避坑指南:那些我踩过的“坑”

  1. JavaScript渲染页面

    • 方案一(推荐):分析网络请求。打开浏览器开发者工具,切换到Network(网络)选项卡,刷新页面,查看XHR或Fetch请求,往往能直接找到包含数据的JSON接口。直接爬这个接口,效率最高。
    • 方案二:如果必须渲染页面,使用scrapy-playwright。在Scrapy中集成Playwright,只对特定的、需要JS的URL使用浏览器渲染,其他请求仍用默认下载器。这样可以兼顾效率和功能。
  2. 应对验证码

    • 降低触发概率:合理使用AutoThrottle,模拟人类浏览间隔,避免高频请求。
    • 设置熔断机制:在中间件中监控响应状态码和内容。如果连续多次收到验证码页面(可通过检测特定关键词如“captcha”判断),则暂停爬虫一段时间,或者切换代理IP池。
    • 人工介入或第三方打码:对于必须解决的验证码,可以配置报警,通知人工处理,或者接入付费的打码平台API。

6. 延伸思考:走向分布式与持续化

当单机爬虫的性能或可靠性遇到瓶颈时,可以考虑以下方向:

  • 部署到Scrapyd集群:Scrapyd是运行Scrapy爬虫的服务。你可以将爬虫部署到多台服务器上的Scrapyd中,形成一个分布式集群。然后使用scrapy-redis来管理共享的请求队列和去重集合,实现真正的分布式爬取,大幅提升采集能力和可靠性。

  • 增量爬取策略:对于新闻网站,我们往往只需要最新的新闻。增量爬取的核心是识别“新内容”。

    • 基于发布时间:在解析时记录文章的发布时间。下次爬取时,只抓取发布时间晚于上次最后记录时间的文章。这需要网站列表页有清晰的发布时间信息。
    • 基于URL模式:如果URL中包含日期(如/2023-10-27/content_xxx.html),可以根据URL的日期部分进行过滤。
    • 结合数据库:将已爬取文章的ID或唯一标识(如URL的MD5)存入数据库。每次爬取前先查询,只调度新的URL。

写在最后

搭建一个生产级的新闻爬虫系统,远不止写一个解析函数那么简单。它涉及到反爬对抗、资源调度、异常处理、数据清洗和系统监控等多个方面。这次ChinaDaily关键词爬取的项目,让我对Scrapy框架的深度和灵活性有了新的认识。特别是中间件和扩展机制,让很多复杂功能都能以模块化的方式实现。

目前这套系统已经稳定运行了几周,数据质量也符合预期。下一步的计划就是将其部署到Scrapyd集群上,并完善更精细的增量爬取逻辑,让整个数据采集流程更加自动化和智能化。如果你也在做类似的项目,希望这篇笔记能给你带来一些启发。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐