简介这是一份基于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 项目结构与第一个能跑通的 Spider2.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.comgenspider生成的 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页列表n1是首页n2就是第2页 for page in range(1, 21): url fhttps://news.cnblogs.com/n/{page} if page 1 else https://news.cnblogs.com/ yield scrapy.Request(urlurl, callbackself.parse_list, dont_filterFalse) 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() # 提取文章IDURL最后一段去掉.html article_id url.split(/)[-1].replace(.html, ) yield scrapy.Request( urlurl, callbackself.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_filterFalse表示对重复 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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.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, encodingutf-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(fDuplicate 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, encodingutf-8) def close_spider(self, spider): self.file.close() def process_item(self, item, spider): line json.dumps(dict(item), ensure_asciiFalse) \n self.file.write(line) return itemensure_asciiFalse必须写否则中文会变成\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( host127.0.0.1, userroot, passwordyourpassword, databaseblog_crawler, charsetutf8mb4, ) 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_countVALUES(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 itemON 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([^], , regexTrue) 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 itemclean_item函数在管道入口处调用两个管道都先过一遍清洗再处理。需要注意的是正文 HTML 里的[^]这类正则替换在 Pandas 里要加regexTrue参数在纯 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 fhttps://news.cnblogs.com/n/{page} if page 1 else https://news.cnblogs.com/ yield scrapy.Request( urlurl, callbackself.parse_list, cookiescookies, dont_filterFalse )这里有个坑Cookie 里的.AspNetCore.Identity.Application值非常长包含大量 URL 编码字符直接粘贴到代码里容易因为引号嵌套而出错。建议放到settings.py的变量里或者用环境变量传入不要硬编码在 Spider 文件里。4.3 避坑记录四条高频翻车点现象一爬了几百条后所有请求变成 403但浏览器访问正常。 原因同一个 IP 每秒请求数过高触发了站点限流。查看 Scrapy 日志会看到403 Forbidden连续出现。 解决把DOWNLOAD_DELAY从 1.5 提高到 3CONCURRENT_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 JOBDIRcrawls/cnblogs恢复时执行同一条命令Scrapy 会自动加载上次未完成的请求队列。需要注意的是JOBDIR目录不能删删了等于从零开始。5. 增量爬取与自动化更新让爬虫按时上班增量爬取的思路其实不复杂列表页永远只取最新几页详情页通过article_id去重已存在的文章只更新阅读数和评论数。这套逻辑在前面的管道里已经实现了最后需要解决的是「定时触发」问题。最轻量的方案是 Crontab 加 Shell 脚本。写一个run_crawler.sh内容就两行#!/bin/bash cd /path/to/cnblogs_crawler /usr/bin/python3 -m scrapy crawl cnblogsCrontab 加一条规则每天早上八点和晚上八点各跑一次0 8 * * * /bin/bash /path/to/cnblogs_crawler/run_crawler.sh /var/log/cnblogs_crawler.log 21 0 20 * * * /bin/bash /path/to/cnblogs_crawler/run_crawler.sh /var/log/cnblogs_crawler.log 21注意 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_LEVELINFO输出的日志里最后几行会汇总item_scraped_count和finish_reason。finish_reason为finished表示正常跑完为shutdown说明中断了。如果爬虫每天跑两次数据量会持续增长JSON 文件也会变大。建议每两周归档一次把旧数据压缩存到archive/目录避免去重管道加载历史 ID 时内存占用过高。归档操作放进 Crontab每天凌晨执行一次简单可靠。从跑通第一个列表页到现在这套方案我已经在一个模拟数据采集任务上连续运行了两个月。最大的教训就是不要迷信选择器一次写对页面改版是常态每次运行前花两分钟抽查几条数据比任何架构优化都管用。从那以后我每次调度完都会强制走一遍单条数据人工核对流程确认正文、时间、阅读数都合理再让任务自动化跑起来。希望帮到你。本文还有配套的精品资源点击获取