☰
Scrapy爬取博客园文章:从项目搭建到增量更新的工程化实践
2026/10/13 15:23:32 网站建设 项目流程

简介:这是一份基于Scrapy框架编写的博客园文章爬虫示例项目,适合正在学习Python爬虫或希望系统掌握Scrapy工程结构的开发者。项目以“myspider”为实例,演示了如何抓取指定博主全部文章,覆盖Scrapy项目搭建、Spider编写、XPath/CSS选择器提取数据、Item定义、Pipeline存储及中间件配置等关键环节,能帮助读者快速理解Scrapy框架的目录划分与工作流。

压缩包体积仅15KB,共包含23个文件,其中8个.py源码文件构成核心逻辑(settings.py、pipelines.py、middlewares.py、items.py等),另有8个.pyc编译文件、3个XML工程配置、2个运行日志、1个.cfg配置文件以及1个IDE工程信息文件,结构相对完整,便于对照实际项目逐一学习。目前已有585人学习下载。

借助该项目,可以直观看到Scrapy项目的标准布局,并学会如何将爬虫任务拆解为配置、爬取、处理和存储四个环节;同时项目中的日志与中间件配置也展示了处理反爬和调试请求的常见思路,适合作为第一个完整Scrapy练手项目的参考模板。

1. 用 Scrapy 爬取博客园文章:一份可以直接落地的工程化爬虫方案

提到爬虫,很多人第一反应是 requests 加 BeautifulSoup 写个脚本,跑通一次就完事了。但当你面对的是博客园这类每天更新几百篇、且页面结构存在多种列表变体的技术社区时,临时脚本往往撑不过一星期——要么请求被限流,要么字段解析漏数据,要么增量更新无从下手。Scrapy 的价值恰恰在这里:它把调度器、下载器、解析器、管道串成一条完整流水线,让爬取从「跑一次」变成「可持续跑的工程」。这份资源以博客园文章为目标,给出了从项目骨架到增量更新的完整实现,适合想系统掌握 Scrapy 或需要长期稳定抓取技术社区数据的开发者直接套用。只要跟着把环境配好,改几个配置项,就能跑出干净的 JSON 或写进数据库。

2. 搭建工程:Scrapy 项目结构与第一个能跑通的 Spider

2.1 先想清楚要抓什么:字段边界比代码更早定

博客园的文章列表页和详情页分开抓,这是整套爬虫的核心思路。列表页拿到的只是标题、链接、摘要、发布时间,而正文内容、阅读数、评论数在详情页里。我一般会先把字段边界定清楚,避免后面反复改 Item。

# items.py import scrapy class CnblogsArticleItem(scrapy.Item): article_id = scrapy.Field() # 文章ID,用于去重 title = scrapy.Field() # 标题 url = scrapy.Field() # 详情页链接 summary = scrapy.Field() # 摘要 author = scrapy.Field() # 作者 publish_time = scrapy.Field() # 发布时间 content = scrapy.Field() # 正文HTML read_count = scrapy.Field() # 阅读数 comment_count = scrapy.Field() # 评论数

这段定义里最关键的是article_id。博客园的文章 URL 形如https://www.cnblogs.com/用户名/p/一串数字.html,那串数字就是全网唯一的文章 ID。用它在管道里去重,比用 URL 去重更保险,因为同一篇文章可能被多个列表页重复收录。

2.2 创建项目与编写第一个 Spider

环境准备按常规做法来,Python 3.8 以上,直接pip install scrapy。创建项目用scrapy startproject,它会自动生成settings.py、middlewares.py、pipelines.py等骨架文件。

scrapy startproject cnblogs_crawler cd cnblogs_crawler scrapy genspider cnblogs_spider news.cnblogs.com

genspider生成的 Spider 只是空壳,需要自己重写start_requests和parse。博客园的首页和分页 URL 规律明显,第 n 页就是https://news.cnblogs.com/n/n这样的形式,我直接构造前 20 页的请求。

# spiders/cnblogs_spider.py import scrapy from cnblogs_crawler.items import CnblogsArticleItem class CnblogsSpider(scrapy.Spider): name = "cnblogs" allowed_domains = ["cnblogs.com"] start_urls = ["https://news.cnblogs.com/"] def start_requests(self): # 抓取前20页列表,n=1是首页,n=2就是第2页 for page in range(1, 21): url = f"https://news.cnblogs.com/n/{page}" if page > 1 else "https://news.cnblogs.com/" yield scrapy.Request(url=url, callback=self.parse_list, dont_filter=False) def parse_list(self, response): # 列表页里每条新闻的标题链接在 h2 下的 a 标签 article_nodes = response.css("div.news_block h2 a") for node in article_nodes: title = node.css("::text").get().strip() url = node.css("::attr(href)").get() # 提取文章ID:URL最后一段去掉.html article_id = url.split("/")[-1].replace(".html", "") yield scrapy.Request( url=url, callback=self.parse_detail, meta={"title": title, "article_id": article_id} ) def parse_detail(self, response): item = CnblogsArticleItem() item["article_id"] = response.meta["article_id"] item["title"] = response.meta["title"] item["url"] = response.url # 作者信息一般在 #blogTitle 或页面顶部的用户名区域 item["author"] = response.css("a.blog_user_name::text, #author_profile a::text").get() # 正文内容取整个 div 的 HTML item["content"] = response.css("div#cnblogs_post_body").get() summary_node = response.css("div.news_block h2 + div.entry_summary") item["summary"] = summary_node.css("::text").get() # 阅读数和评论数在详情页有专门的结构 item["read_count"] = response.css("span.read_count::text").re_first(r"\d+") item["comment_count"] = response.css("span.comment_count::text").re_first(r"\d+") yield item

逻辑说明:列表页解析完成后直接构造详情页请求,并把已经拿到的标题和 ID 通过meta传给详情页回调,避免在详情页二次解析提取。dont_filter=False表示对重复 URL 启用过滤,这是 Scrapy 默认行为,可以避免同一个详情页被多个列表页重复发起请求。

参数说明:re_first(r"\d+")是把「阅读(12)」这种文本里的数字抠出来,返回字符串,方便后续管道转 int。response.css("div.news_block h2 a")是 CSS 选择器,对应博客园列表页的 HTML 结构,其他社区需要自行调整。

2.3 settings.py 配置:并发、延迟与日志

跑通第一版之后必须调 settings,否则很容易在爬到几百条时被服务器断开连接。下面是这份资源里我常用的基础配置:

# settings.py BOT_NAME = "cnblogs_crawler" ROBOTSTXT_OBEY = False DOWNLOAD_DELAY = 1.5 # 请求间隔1.5秒,不要太激进 CONCURRENT_REQUESTS = 8 # 并发请求数 CONCURRENT_REQUESTS_PER_DOMAIN = 8 COOKIES_ENABLED = False # 列表页和详情页都不需要登录,关掉减少请求头体积 DEFAULT_REQUEST_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", } ITEM_PIPELINES = { "cnblogs_crawler.pipelines.JsonPipeline": 300, "cnblogs_crawler.pipelines.DuplicatesPipeline": 200, } FEED_EXPORT_ENCODING = "utf-8" LOG_LEVEL = "INFO"

这里有个常见误区:新手喜欢把CONCURRENT_REQUESTS调到 32 甚至 64,结果就是 IP 被临时封禁。博客园这类社区站点对高频请求并不友好,DOWNLOAD_DELAY = 1.5加CONCURRENT_REQUESTS = 8的组合,大约每秒 5 个请求左右,实测基本稳定。

管道顺序需要注意:DuplicatesPipeline先跑(数值小的先执行),JsonPipeline后跑,这样重复文章不会被二次写盘。

3. 数据管道:把 HTML 变成干净的结构化数据

3.1 去重管道:用 article_id 做唯一键

Scrapy 自带的去重是基于请求 URL 的,这只能防止同一个详情页被重复下载,但拦不住「同一篇文章出现在两个不同列表页」这种场景。所以管道里的去重逻辑必须基于article_id。

# pipelines.py import json class DuplicatesPipeline: def __init__(self): self.seen_ids = set() # 从已导出的 JSON 里加载历史ID,实现重启不去重 try: with open("articles.json", "r", encoding="utf-8") as f: for line in f: record = json.loads(line) self.seen_ids.add(record["article_id"]) except FileNotFoundError: pass def process_item(self, item, spider): if item["article_id"] in self.seen_ids: raise DropItem(f"Duplicate article: {item['article_id']}") self.seen_ids.add(item["article_id"]) return item

这个实现比单纯if not in set多了一层:启动时读历史 JSON 文件,把已抓过的 ID 全部加载进内存。这样爬虫中断后重启,不会把之前已经抓过的文章再抓一遍。注意的是DropItem必须从scrapy.exceptions导入。

3.2 JSON 导出与 MySQL 写入双轨并行

开发调试阶段直接用 JSON Lines 格式最方便,一行一条记录,不用处理复杂的嵌套结构。但生产环境一般要落库,所以这份资源里准备了两种管道。

# pipelines.py class JsonPipeline: def open_spider(self, spider): self.file = open("articles.json", "a", encoding="utf-8") def close_spider(self, spider): self.file.close() def process_item(self, item, spider): line = json.dumps(dict(item), ensure_ascii=False) + "\n" self.file.write(line) return item

ensure_ascii=False必须写,否则中文会变成\uXXXX转义序列,后期排查数据时非常痛苦。JSON 文件用追加模式打开,注意在爬虫启动前手动清空一次,或者保留历史文件配合去重管道,这两种方式任选其一。

如果需要写 MySQL,管道逻辑类似,但要注意字段类型匹配。publish_time建议直接存字符串,MySQL 里用VARCHAR(50),不要用DATETIME强转,因为博客园的发布时间格式可能混有「昨天」「3小时前」这类相对时间,强转会直接报错。

# pipelines.py (MySQL版本) import pymysql class MysqlPipeline: def open_spider(self, spider): self.conn = pymysql.connect( host="127.0.0.1", user="root", password="yourpassword", database="blog_crawler", charset="utf8mb4", ) self.cursor = self.conn.cursor() def close_spider(self, spider): self.conn.commit() self.cursor.close() self.conn.close() def process_item(self, item, spider): sql = """ INSERT INTO articles (article_id, title, url, summary, author, publish_time, content, read_count, comment_count) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE read_count=VALUES(read_count) """ self.cursor.execute(sql, ( item["article_id"], item["title"], item["url"], item["summary"], item["author"], item["publish_time"], item["content"], item["read_count"], item["comment_count"] )) self.conn.commit() return item

ON DUPLICATE KEY UPDATE是关键——当文章 ID 已存在时,不插入新行,只更新阅读数和评论数。这天然解决了增量爬取时的数据刷新问题。

3.3 字段清洗:空值处理与类型转换

跑过实际数据就会知道,详情页里某些字段可能缺失。比如有的文章没有摘要,有的评论数显示为「评论」两个字而不是数字。这些脏数据必须在管道里提前处理,否则写库时会报错或污染数据。

def clean_item(item): # 摘要为空则用正文前80个字代替 if not item.get("summary"): content_text = item.get("content") or "" text = content_text.replace("<[^>]+>", "", regex=True) item["summary"] = text[:80] + "..." # 阅读数和评论数转int,解析失败给默认值 for field in ("read_count", "comment_count"): try: item[field] = int(item.get(field) or 0) except (ValueError, TypeError): item[field] = 0 return item

clean_item函数在管道入口处调用,两个管道都先过一遍清洗再处理。需要注意的是正文 HTML 里的<[^>]+>这类正则替换,在 Pandas 里要加regex=True参数,在纯 Python 里用re.sub更直接,这里只是示意思路,实际实现时用re.sub(r"<[^>]+>", "", content)即可。

4. 反爬应对与避坑指南:常见问题排查实录

4.1 User-Agent 轮换与请求头伪装

博客园的反爬不像电商那么严格,但连续快速请求后会出现两种情况:Response 状态码仍是 200,但页面内容变成了验证码提示页;或者直接返回 403。第一种更隐蔽,因为 Scrapy 不会报错,解析结果全是None。

# middlewares.py import random class RandomUserAgentMiddleware: def __init__(self): self.user_agents = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", ] def process_request(self, request, spider): request.headers["User-Agent"] = random.choice(self.user_agents) return None

在settings.py里启用这个中间件,并把DOWNLOADER_MIDDLEWARES配置加上。注意启用中间件后,原来DEFAULT_REQUEST_HEADERS里的 UA 会被覆盖,这是正常的,中间件的优先级更高。

DOWNLOADER_MIDDLEWARES = { "cnblogs_crawler.middlewares.RandomUserAgentMiddleware": 543, }

数字 543 是 Scrapy 默认中间件顺序的参考值,取这个值可以保证在默认 UA 中间件之后执行,覆盖掉默认设置。

4.2 登录态与 Cookie 处理:什么时候需要带 Cookie

博客园绝大多数文章无需登录就能访问全文,但少数「园子」内文章或设置了访问权限的内容,不登录会返回部分内容或跳转登录页。遇到这种情况,常规做法是手动从浏览器复制 Cookie,在start_requests里带上。

def start_requests(self): cookies = { "Cnblogs_UserGuid": "xxxxx", "UserInfo": "xxxxx", ".AspNetCore.Identity.Application": "xxxxx", } for page in range(1, 21): url = f"https://news.cnblogs.com/n/{page}" if page > 1 else "https://news.cnblogs.com/" yield scrapy.Request( url=url, callback=self.parse_list, cookies=cookies, dont_filter=False )

这里有个坑:Cookie 里的.AspNetCore.Identity.Application值非常长,包含大量 URL 编码字符,直接粘贴到代码里容易因为引号嵌套而出错。建议放到settings.py的变量里,或者用环境变量传入,不要硬编码在 Spider 文件里。

4.3 避坑记录:四条高频翻车点

现象一:爬了几百条后,所有请求变成 403,但浏览器访问正常。 原因:同一个 IP 每秒请求数过高,触发了站点限流。查看 Scrapy 日志会看到403 Forbidden连续出现。 解决:把DOWNLOAD_DELAY从 1.5 提高到 3,CONCURRENT_REQUESTS降到 4。慢不可怕,被封才可怕。实测 2 秒间隔是最稳的组合。

现象二:正文抓回来是None,但列表页的标题和链接都正常。 原因:详情页的正文选择器写错了。博客园详情页正文在div#cnblogs_post_body,而列表页摘要可能在div.entry_summary,两个选择器不能混用。 解决:在浏览器开发者工具里重新确认详情页正文的 CSS 路径。有时候你看到的是div.postBody,不同模板不一样,需要以实际页面为准。

现象三:publish_time字段有时是「昨天」,有时是「2024-05-12 08:30」。 原因:博客园部分页面用相对时间显示,直接抓取拿到的是格式化后的文本,不是标准时间。 解决:管道里加一层时间解析,识别「今天」「昨天」「X分钟前」这类模式,手动转成 ISO 格式。认不出来的就保留原字符串,不要乐观假设。

现象四:爬虫跑到一半意外中断,重启后从头开始爬。 原因:默认情况下 Scrapy 不持久化调度队列,中断后pending请求全部丢失。 解决:启用JOBDIR持久化,或者基于article_id去重管道配合历史 JSON 恢复。两种方式可以同时用,前者省流量,后者保证数据不重复。

scrapy crawl cnblogs -s JOBDIR=crawls/cnblogs

恢复时执行同一条命令,Scrapy 会自动加载上次未完成的请求队列。需要注意的是,JOBDIR目录不能删,删了等于从零开始。

5. 增量爬取与自动化更新:让爬虫按时上班

增量爬取的思路其实不复杂:列表页永远只取最新几页,详情页通过article_id去重,已存在的文章只更新阅读数和评论数。这套逻辑在前面的管道里已经实现了,最后需要解决的是「定时触发」问题。

最轻量的方案是 Crontab 加 Shell 脚本。写一个run_crawler.sh,内容就两行:

#!/bin/bash cd /path/to/cnblogs_crawler /usr/bin/python3 -m scrapy crawl cnblogs

Crontab 加一条规则,每天早上八点和晚上八点各跑一次:

0 8 * * * /bin/bash /path/to/cnblogs_crawler/run_crawler.sh >> /var/log/cnblogs_crawler.log 2>&1 0 20 * * * /bin/bash /path/to/cnblogs_crawler/run_crawler.sh >> /var/log/cnblogs_crawler.log 2>&1

注意 Crontab 的环境变量和手动执行不一样,Python 路径最好写成绝对路径,否则可能找不到scrapy命令。

比 Crontab 更可控的方案是 Scrapyd 加定时调度。Scrapyd 是 Scrapy 官方推荐的远程部署工具,把项目打包成 egg 上传到服务端,通过 HTTP API 控制爬虫启动和停止。写完爬虫后执行:

scrapyd-deploy

然后在另一台机器上用requests调 API 控制启动:

import requests def schedule_crawl(project, spider): resp = requests.post( "http://127.0.0.1:6800/schedule.json", data={"project": project, "spider": spider} ) return resp.json()["jobid"] job_id = schedule_crawl("cnblogs_crawler", "cnblogs") print(job_id)

Scrapyd 的好处是爬虫在独立进程运行,本地终端关掉不影响执行,日志可以远程拉取。配合requests写个简单的定时循环,就能精确控制抓取频率。

日志分析是增量爬虫的日常功课。每次跑完看一眼这几项:抓取条数、失败条数、重复条数、耗时。用scrapy crawl cnblogs -s LOG_LEVEL=INFO输出的日志里,最后几行会汇总item_scraped_count和finish_reason。finish_reason为finished表示正常跑完,为shutdown说明中断了。

如果爬虫每天跑两次,数据量会持续增长,JSON 文件也会变大。建议每两周归档一次,把旧数据压缩存到archive/目录,避免去重管道加载历史 ID 时内存占用过高。归档操作放进 Crontab,每天凌晨执行一次,简单可靠。

从跑通第一个列表页到现在,这套方案我已经在一个模拟数据采集任务上连续运行了两个月。最大的教训就是:不要迷信选择器一次写对,页面改版是常态,每次运行前花两分钟抽查几条数据,比任何架构优化都管用。从那以后,我每次调度完都会强制走一遍单条数据人工核对流程,确认正文、时间、阅读数都合理再让任务自动化跑起来。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询