博客园博主全站文章一键导出工具(Scrapy版,含反爬适配与JSON/CSV输出)
本文还有配套的精品资源,点击获取
简介:专为博客园平台设计的批量文章采集工具,用Python Scrapy框架开发,能自动抓取指定博主所有公开文章正文、标题、发布时间、URL等字段。项目结构规范,内置请求延迟控制、随机User-Agent切换、并发数调节、去重逻辑(基于URL和内容指纹)、中间件支持,适配博客园常见反爬机制。数据清洗和字段定义清晰,支持通过pipelines.py自定义处理流程,原始结果默认导出为结构化JSON或CSV文件,无需数据库依赖,开箱即用。附带完整日志记录(x1.log记录运行状态,url.log追踪已抓链接),.idea配置文件已预置,PyCharm等IDE可直接导入调试。requirements.txt明确列出依赖,scrapy.cfg和settings.py提供标准化配置入口,方便二次开发与参数微调。
1. 项目概述:为什么需要一个“博客园博主全站导出工具”
我做技术博主快八年了,从最早在CSDN写Java入门,到后来转战博客园发Spring Boot源码分析、K8s排障实录,再到最近两年专注写AI工程化落地笔记——手头积攒了200多篇公开文章。去年想把所有内容迁移到自建静态站,第一反应是:手动复制粘贴?不行,光标题+摘要就得花三天;用浏览器插件批量保存?试过三个,要么漏掉分页内容,要么抓不到代码块高亮后的HTML结构,更别说发布时间、阅读量这些元数据了。直到我真正动手写了一个Scrapy爬虫,才意识到:博客园不是不能爬,而是它的反爬逻辑藏得深、节奏感强、且对“人味”要求极高。
这个工具不是为黑产或数据倒卖设计的,它解决的是真实技术人的刚需:知识资产自主权。你辛辛苦苦写的每一篇《MySQL索引失效的7种典型场景》,每一行带注释的Prometheus告警规则YAML,都该由你自己掌控存档方式、格式和生命周期。它不碰登录态、不刷评论、不模拟点赞,只做一件事——像一个守规矩的读者,按博客园公开页面的自然访问节奏,把博主已发布的全部文章,原样、结构化、可验证地搬进你的本地硬盘。
核心关键词“博客园爬虫”“Scrapy采集”“博主文章导出”,其实对应着三层现实约束:第一层是平台特性——博客园用ASP.NET MVC架构,分页靠?page=2参数,正文内容包裹在<div class="post">里,但关键字段(如发布时间)藏在<span id="post-date">这种非标准位置,且首页列表页和文章详情页结构完全不同;第二层是框架选型——Scrapy不是最轻量的方案,但它天然支持异步调度、中间件链、去重指纹、管道化清洗,比requests+BeautifulSoup手撸一套状态管理要稳得多;第三层是交付形态——导出JSON/CSV不是为了炫技,而是让数据能直接喂给Obsidian做双链笔记、导入Notion做知识图谱、甚至扔进LangChain做RAG微调。我见过太多人写完爬虫只导出一个乱码HTML文件,最后还得人工开浏览器一个个点开复制,本末倒置。
所以这个项目从第一天起就定下铁律:不求快,但求稳;不求全,但求准;不求炫,但求可追溯。x1.log里每一条INFO日志都带时间戳和请求URL,url.log记录每个成功抓取的链接及HTTP状态码,连中间件里User-Agent切换的随机种子都固定下来——这不是过度设计,而是当你某天发现第137篇文章的发布时间错位了两小时,你能立刻回溯到那一行yield scrapy.Request(url, callback=self.parse_post, meta={'retry_times': 0}),而不是对着空荡荡的CSV文件干瞪眼。
2. 整体架构与设计思路拆解
2.1 为什么选Scrapy而非Requests+BeautifulSoup?
很多人看到“爬博客园”第一反应是requests.get()+bs4.find(),简单粗暴。我试过——用纯requests写了个初版,跑前50篇文章没问题,到第83篇时突然卡住,日志显示ConnectionResetError,重试三次全失败。查了半天才发现博客园对短时间高频请求会返回HTTP 403并静默关闭连接,而requests本身没有内置的请求队列、失败重试、并发控制机制。你得自己写线程池、自己管cookie、自己实现URL去重、自己处理重定向循环……等把这些补全,代码量早超过Scrapy默认生成的项目骨架了。
Scrapy的核心优势在于它把爬虫开发中那些“重复造轮子”的脏活全包圆了:
- 调度器(Scheduler):自动维护待抓取URL队列,支持优先级队列(比如首页列表页优先级设为100,详情页设为1),避免因某个详情页响应慢导致整个队列阻塞;
- 下载器中间件(Downloader Middleware):这是对抗博客园反爬的主战场。我们能在请求发出前动态注入User-Agent、添加Referer头、设置随机延迟;在响应返回后检查是否被跳转到登录页(
response.url.startswith('https://account.cnblogs.com/'))、是否返回验证码图片(response.headers.get('Content-Type') == 'image/jpeg'),触发自定义重试逻辑; - Spider中间件(Spider Middleware):在解析前拦截响应,比如检测到页面包含“您访问过于频繁”的提示文字,就主动抛出
IgnoreRequest异常,让调度器跳过这条URL; - Item Pipeline:数据清洗和存储完全解耦。
pipelines.py里可以写if item['content'] and len(item['content']) < 200: raise DropItem("Content too short"),把明显抓取失败的空内容过滤掉,不影响其他数据流。
更重要的是,Scrapy的CrawlSpider类天生适配博客园这种“列表页→详情页”的两级结构。我们只需定义rules = (Rule(LinkExtractor(allow=r'/p/\d+\.html$'), callback='parse_post', follow=False),),框架自动帮你提取所有匹配的详情页链接并发起请求,不用手写正则解析<a href="/p/123456.html">这种容易出错的逻辑。
当然,Scrapy也有代价:学习曲线陡峭、调试不如requests直观、小项目显得笨重。但当你面对200+篇文章、每篇含3~5个代码块、平均长度2000字、且博客园随时可能调整DOM结构时,Scrapy提供的稳定性、可维护性和可扩展性,远超初期多花的那两天学习成本。
2.2 反爬适配策略:不是硬刚,而是“装得像个人”
博客园的反爬不是靠复杂加密或JS渲染,而是典型的“行为识别”:它观察你是不是一个真实的、有耐心的、偶尔会翻页的读者。我们的策略就是模仿这种行为模式,而不是试图破解什么算法。
首先明确几个事实:
- 博客园首页列表页(https://xxx.cnblogs.com/)最多显示10篇文章,翻页用?page=2;
- 每篇文章详情页URL固定为https://xxx.cnblogs.com/p/123456.html;
- 列表页里的发布时间是相对时间(如“2小时前”),详情页里才是绝对时间(2024-03-15 14:22),必须抓详情页才能拿到准确时间戳;
- 博客园对同一IP的请求频率敏感,实测连续请求间隔低于1.5秒大概率触发403。
因此,我们在settings.py里做了四层柔性防护:
- 请求延迟(DOWNLOAD_DELAY):设为2.5秒,不是整数而是带小数,模拟人类操作的不精确性。Scrapy会在此基础上再加一个±0.5秒的随机抖动(
RANDOMIZE_DOWNLOAD_DELAY=True),实际间隔在2.0~3.0秒之间浮动; - 并发控制(CONCURRENT_REQUESTS):设为1,彻底杜绝并发请求。虽然慢,但确保每个请求都是独立、可追踪的。你想提速?可以开到3,但必须同步把
DOWNLOAD_DELAY提到4秒以上,否则风险陡增; - User-Agent轮换:
middlewares.py里预置了12个主流浏览器UA字符串(Chrome最新版、Firefox、Safari、Edge),每次请求随机选取一个,并通过scrapy.downloadermiddlewares.useragent.UserAgentMiddleware注入。关键是——我们禁用了Scrapy自带的UA中间件,自己写了RandomUserAgentMiddleware,确保每次请求都走一遍随机逻辑; - Referer伪造:列表页请求的Referer设为
https://www.cnblogs.com/,详情页请求的Referer设为对应列表页URL(如https://xxx.cnblogs.com/?page=3)。这模拟了用户从列表页点击进入详情页的真实路径,博客园服务器日志里能看到连贯的Referer链。
提示:不要迷信“高质量代理IP”。我试过用付费代理池,结果发现博客园对某些代理IP段有黑名单,反而比家用宽带IP更容易被封。老老实实用自己家的网络,配合合理延迟,成功率反而更高。
2.3 数据模型与输出设计:字段定义即业务逻辑
items.py里的CnblogsArticleItem定义,表面看只是几个字段声明,实则承载着整个采集流程的业务规则:
class CnblogsArticleItem(scrapy.Item): title = scrapy.Field() # 文章标题,必须非空 url = scrapy.Field() # 原始URL,用于去重和溯源 publish_time = scrapy.Field() # ISO8601格式时间字符串,如"2024-03-15T14:22:33" content_html = scrapy.Field() # 完整HTML内容,含代码块、图片标签 content_text = scrapy.Field() # 纯文本内容,用于后续NLP分析 read_count = scrapy.Field() # 阅读数,整数类型 comment_count = scrapy.Field() # 评论数,整数类型 tags = scrapy.Field() # 标签列表,如["Python", "Scrapy"]这里的关键设计点在于content_html和content_text的分离。博客园的文章HTML结构复杂:代码块用<pre><code class="language-python">包裹,图片用<img src="https://images.cnblogs.com/...">,还有各种自定义CSS类。如果只存HTML,后续做全文搜索或向量化会遇到编码、标签嵌套等问题;如果只存纯文本,又丢失了代码块的语法高亮信息和图片的语义。所以我们选择双存:content_html保留原始结构供归档,content_text则用lxml.html.fromstring()解析后调用.text_content()方法提取,再用正则清理多余空白和换行。
另一个细节是publish_time的标准化。列表页给的是“2小时前”,详情页给的是“2024-03-15 14:22”,但我们最终存的是ISO8601格式的2024-03-15T14:22:33。这一步在pipelines.py的process_item方法里完成:先尝试用dateutil.parser.parse()解析详情页时间,如果失败(比如页面结构突变),则fallback到用当前时间减去列表页的相对时间(需调用arrow.get().shift(hours=-2)这类库)。这种容错设计,保证了即使博客园某天改版,95%的数据仍能正确入库。
3. 核心模块详解与实操要点
3.1 Spider主逻辑:两级解析的精准控制
spiders/cnblogs_spider.py是整个项目的神经中枢,它严格遵循“先抓列表页,再抓详情页”的两级解析范式。我们不追求一次性把所有URL都扒出来再并发抓取,而是让Scrapy的调度器自然流转,确保每个环节都可控。
列表页解析(parse_list方法):
def parse_list(self, response): # 提取当前页所有文章链接 post_links = response.css('div.post-item > div.post-item-title > a::attr(href)').getall() for link in post_links: if link and link.startswith('/p/'): yield scrapy.Request( url=urljoin(response.url, link), callback=self.parse_post, priority=100, # 列表页优先级最高 meta={'list_url': response.url} # 记录来源,用于Referer ) # 提取下一页链接(博客园分页按钮是<a href="?page=2">下一页</a>) next_page = response.css('div.pager > a:contains("下一页")::attr(href)').get() if next_page: yield scrapy.Request( url=urljoin(response.url, next_page), callback=self.parse_list, priority=10 # 下一页优先级略低,避免阻塞详情页 )这里有两个易错点必须强调:
-urljoin(response.url, link):博客园列表页URL可能是https://xxx.cnblogs.com/或https://xxx.cnblogs.com/?page=2,而link是相对路径/p/123456.html,直接拼接会出错。必须用urllib.parse.urljoin做标准化;
-priority参数:Scrapy默认按FIFO顺序处理请求,但我们需要确保列表页的解析永远优先于详情页。设priority=100让列表页请求排在队首,避免因某个详情页响应慢导致整个翻页停滞。
详情页解析(parse_post方法):
def parse_post(self, response): # 检查是否被重定向到登录页(反爬触发) if response.url.startswith('https://account.cnblogs.com/'): self.logger.warning(f"Redirected to login page: {response.url}") raise IgnoreRequest("Redirected to login") # 提取标题(博客园标题在<h1 class="post-title">里) title = response.css('h1.post-title::text').get(default='').strip() if not title: self.logger.warning(f"Empty title at {response.url}") raise DropItem("Empty title") # 提取发布时间(详情页内#post-date元素) publish_time_raw = response.css('span#post-date::text').get(default='') publish_time = self._parse_publish_time(publish_time_raw) # 提取正文HTML(博客园正文在<div id="cnblogs_post_body">里) content_html = response.css('div#cnblogs_post_body').get(default='') # 提取纯文本(去除HTML标签,保留换行) content_text = '' if content_html: try: doc = lxml.html.fromstring(content_html) content_text = doc.text_content().replace('\xa0', ' ').strip() except Exception as e: self.logger.error(f"Failed to parse content text: {e}") # 提取阅读数和评论数(博客园用<span class="post-view-count">和<span class="post-comment-count">) read_count = self._extract_number(response.css('span.post-view-count::text').get(default='')) comment_count = self._extract_number(response.css('span.post-comment-count::text').get(default='')) # 提取标签(博客园标签在<div class="post-tags">里,每个标签是<a>) tags = response.css('div.post-tags a::text').getall() item = CnblogsArticleItem( title=title, url=response.url, publish_time=publish_time, content_html=content_html, content_text=content_text, read_count=read_count, comment_count=comment_count, tags=tags ) yield item最关键的实操心得是:永远用::text提取纯文本,用.get()而非.getall()获取单值字段。博客园的DOM里经常有多个同名元素(比如多个<span class="post-view-count">),如果你用getall(),得到的是列表,后续存CSV时会报类型错误。而get()只取第一个,符合业务预期。
3.2 中间件实战:让请求“看起来像人”
middlewares.py是我们对抗博客园反爬的前线指挥部。它不像pipelines.py那样处理数据,而是全程监控请求与响应的“生命体征”。
随机User-Agent中间件(RandomUserAgentMiddleware):
class RandomUserAgentMiddleware: def __init__(self): self.user_agents = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.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.3 Safari/605.1.15', # ... 共12个UA ] def process_request(self, request, spider): ua = random.choice(self.user_agents) request.headers['User-Agent'] = ua # 同时设置Accept-Language,模拟真实浏览器 request.headers['Accept-Language'] = 'zh-CN,zh;q=0.9,en;q=0.8'这里有个隐藏技巧:UA字符串里不要包含“Scrapy”字样。我最初用Scrapy默认UA,结果发现博客园对Scrapy/2.11.2 (+https://scrapy.org)这种标识有特殊拦截,换成纯浏览器UA后成功率从68%飙升到99.2%。
Referer中间件(RefererMiddleware):
class RefererMiddleware: def process_request(self, request, spider): # 如果请求来自列表页,则设置Referer为列表页URL if 'list_url' in request.meta: request.headers['Referer'] = request.meta['list_url'] # 如果是首次请求(抓取首页),Referer设为博客园主站 elif request.url == spider.start_urls[0]: request.headers['Referer'] = 'https://www.cnblogs.com/'Referer不是可有可无的装饰,它是博客园判断“你是不是从正常入口进来”的关键依据。没有Referer的请求,哪怕UA再真实,也容易被标记为异常。
响应检查中间件(ResponseCheckMiddleware):
class ResponseCheckMiddleware: def process_response(self, request, response, spider): # 检查HTTP状态码 if response.status == 403: spider.logger.warning(f"403 Forbidden for {request.url}") # 主动重试,但降低优先级,避免雪崩 request.priority -= 50 return request # 检查是否被重定向到登录页 if response.url.startswith('https://account.cnblogs.com/'): spider.logger.warning(f"Redirected to login: {request.url}") raise IgnoreRequest("Login redirect") # 检查页面是否包含反爬提示文字 body_text = response.text[:5000] # 只检查前5000字符,提升性能 if '您访问过于频繁' in body_text or '请稍后再试' in body_text: spider.logger.warning(f"Anti-spider warning detected: {request.url}") # 触发重试,但增加延迟 request.meta['download_delay'] = 10.0 return request return response这个中间件的价值在于:它把“反爬响应”的识别和应对逻辑从业务代码里剥离出来,统一处理。你不需要在每个parse_*方法里写if '您访问过于频繁' in response.text:,降低了出错概率。
3.3 数据清洗与去重:确保每条数据都干净可信
dupe.py和pipelines.py共同构成了数据质量的最后防线。
URL去重(dupe.py):
class CnblogsDupeFilter: def __init__(self): self.urls_seen = set() def request_seen(self, request): # 只对详情页URL去重,列表页URL允许重复(翻页需要) if '/p/' in request.url: if request.url in self.urls_seen: return True self.urls_seen.add(request.url) return False return False注意:我们没用Scrapy内置的RFPDupeFilter,而是手写了一个轻量级去重器。因为内置过滤器基于请求指纹(包含所有参数和headers),会导致https://xxx.cnblogs.com/p/123456.html和https://xxx.cnblogs.com/p/123456.html?utm_source=share被视为不同URL。而博客园的分享链接带UTM参数,实际内容完全一样,必须去重。
内容指纹去重(pipelines.py):
import hashlib class ContentDeduplicationPipeline: def __init__(self): self.content_fingerprints = set() def process_item(self, item, spider): # 对content_html做MD5哈希,作为内容指纹 if item.get('content_html'): html_bytes = item['content_html'].encode('utf-8') fingerprint = hashlib.md5(html_bytes).hexdigest() if fingerprint in self.content_fingerprints: spider.logger.info(f"Dropped duplicate content: {item['url']}") raise DropItem(f"Duplicate content: {fingerprint[:8]}...") self.content_fingerprints.add(fingerprint) return item这是防止“同一篇文章发布在多个URL”的终极手段。博客园允许博主设置文章别名(alias),比如/p/123456.html和/p/my-awesome-post.html指向同一篇内容。仅靠URL去重会漏掉,必须用内容哈希兜底。
数据清洗管道(pipelines.py):
class DataCleaningPipeline: def process_item(self, item, spider): # 清洗标题:去除前后空格、替换不可见字符 if item.get('title'): item['title'] = re.sub(r'[\u200b-\u200f\u202a-\u202f]', '', item['title']).strip() # 清洗发布时间:确保是ISO8601格式 if item.get('publish_time'): try: dt = dateutil.parser.parse(item['publish_time']) item['publish_time'] = dt.isoformat() except: item['publish_time'] = datetime.now(timezone.utc).isoformat() # 清洗阅读数:转为整数,异常值设为0 if item.get('read_count') is not None: try: item['read_count'] = int(item['read_count']) except (ValueError, TypeError): item['read_count'] = 0 return item清洗不是锦上添花,而是数据可用的前提。我曾遇到过博客园某次改版,把阅读数字段从<span>1234</span>改成<span>1,234</span>,导致int("1,234")直接报错中断整个爬取流程。现在有了这层清洗,错误被静默处理,流程继续。
4. 实操过程与完整运行指南
4.1 环境准备与依赖安装
这不是一个“下载即用”的exe程序,而是一个需要你亲手配置的Python项目。好处是——你完全掌控每一个字节,坏处是——你得亲手搞定环境。别怕,步骤很清晰:
第一步:确认Python版本
必须使用Python 3.8或更高版本。博客园的HTTPS证书需要较新的SSL协议支持,Python 3.7以下版本可能握手失败。检查命令:
python --version # 输出应为 Python 3.8.x 或更高第二步:创建虚拟环境(强烈推荐)
避免污染全局Python环境,也方便日后迁移:
# 在项目根目录执行 python -m venv venv # Windows激活 venv\Scripts\activate.bat # macOS/Linux激活 source venv/bin/activate第三步:安装依赖
项目根目录下的requirements.txt已经锁定了所有依赖版本,这是稳定性的基石:
pip install -r requirements.txtrequirements.txt内容精简但关键:
Scrapy==2.11.2 lxml==4.9.3 pytz==2023.3 dateutil==2.8.2 arrow==1.2.3特别说明lxml:它是解析HTML的底层引擎,比bs4快5倍以上,且对博客园那种不规范HTML(如未闭合标签)容忍度更高。安装时如果报错,Windows用户请去lxml官网下载对应Python版本的.whl文件手动安装;macOS用户用brew install libxml2 libxslt预装系统库再pip install lxml。
第四步:配置目标博主
打开spiders/cnblogs_spider.py,找到start_urls变量:
class CnblogsSpider(scrapy.Spider): name = 'cnblogs' allowed_domains = ['xxx.cnblogs.com'] # 替换为你的博主域名 start_urls = ['https://xxx.cnblogs.com/'] # 同样替换把xxx.cnblogs.com替换成你要采集的博主域名,比如zhangsan.cnblogs.com。注意:域名必须准确,少一个字母或多一个斜杠都会导致allowed_domains校验失败,所有请求被丢弃。
4.2 运行爬虫与参数调优
一切就绪后,启动爬虫只需一条命令:
scrapy crawl cnblogs -o output.json -s LOG_FILE=x1.log这条命令的含义:
-scrapy crawl cnblogs:运行名为cnblogs的Spider;
--o output.json:将结果导出为JSON文件(也可用-o output.csv);
--s LOG_FILE=x1.log:指定日志输出到x1.log文件,而不是控制台。
关键参数调优指南:
-控制抓取深度:博客园默认只显示10页(100篇文章),如果你想抓更多,修改settings.py里的DEPTH_LIMIT = 20(最大20页);
-调整请求延迟:如果发现x1.log里频繁出现403,把DOWNLOAD_DELAY从2.5提高到3.5;
-启用调试日志:临时加参数-s LOG_LEVEL=DEBUG,可以看到每个请求的详细Headers和Cookies,排查问题神器;
-限制抓取数量(测试用):加参数-s CLOSESPIDER_ITEMCOUNT=10,只抓10条就自动停止,适合首次运行验证逻辑。
运行时日志解读(x1.log):
打开x1.log,你会看到类似这样的记录:
2024-03-15 14:22:33 [scrapy.core.engine] INFO: Spider opened 2024-03-15 14:22:33 [scrapy.downloadermiddlewares.retry] DEBUG: Retrying <GET https://zhangsan.cnblogs.com/> (failed 1 times): 503 Service Unavailable 2024-03-15 14:22:36 [scrapy.core.scraper] DEBUG: Scraped from <200 https://zhangsan.cnblogs.com/> {'title': '深入理解Scrapy中间件机制', 'url': 'https://zhangsan.cnblogs.com/p/123456.html', ...} 2024-03-15 14:22:38 [scrapy.core.engine] INFO: Closing spider (finished)重点关注DEBUG级别的Scraped from日志,它告诉你哪条数据成功入库;WARNING级别的日志则提示潜在问题(如空标题、重定向)。
4.3 输出文件解析与后续处理
爬虫结束后,你会得到两个文件:output.json(或output.csv)和url.log。
output.json结构示例:
[ { "title": "深入理解Scrapy中间件机制", "url": "https://zhangsan.cnblogs.com/p/123456.html", "publish_time": "2024-03-15T14:22:33", "content_html": "<div id=\"cnblogs_post_body\"><h2>一、下载器中间件</h2><p>...</p></div>", "content_text": "一、下载器中间件\n...\n", "read_count": 1234, "comment_count": 45, "tags": ["Scrapy", "Python"] } ]url.log内容示例:
2024-03-15 14:22:33 SUCCESS https://zhangsan.cnblogs.com/ 2024-03-15 14:22:36 SUCCESS https://zhangsan.cnblogs.com/p/123456.html 2024-03-15 14:22:38 WARNING https://zhangsan.cnblogs.com/p/789012.html Empty titleurl.log是你的审计线索。如果发现某篇文章没出现在JSON里,去url.log里搜它的URL,看是SUCCESS还是WARNING,就能快速定位是数据问题还是逻辑问题。
后续处理建议:
-导入Obsidian:用Obsidian的Dataview插件,新建一个articles.md文件,写查询TABLE title, publish_time FROM "output.json",所有文章自动变成可排序表格;
-生成静态网站:用jinja2模板渲染output.json,生成index.html和每篇文章的/p/123456.html,部署到GitHub Pages;
-训练RAG模型:把content_text字段提取出来,用langchain.text_splitter.RecursiveCharacterTextSplitter切分成chunk,喂给ChromaDB向量库。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
爬虫启动后立即退出,x1.log里只有Spider opened | start_urls域名与allowed_domains不匹配 | 检查spiders/cnblogs_spider.py中两处域名是否完全一致(包括https://和结尾斜杠) | 确保allowed_domains = ['zhangsan.cnblogs.com'],start_urls = ['https://zhangsan.cnblogs.com/'] |
x1.log里大量403 Forbidden,且url.log全是WARNING | 请求频率过高或UA被识别 | 查看x1.log里User-Agent字段是否为空;检查settings.py中DOWNLOAD_DELAY是否小于2.0 | 将DOWNLOAD_DELAY设为3.0,CONCURRENT_REQUESTS设为1,重启爬虫 |
JSON文件里文章标题全是None,url.log显示SUCCESS | 博客园DOM结构变更,CSS选择器失效 | 手动打开一个详情页,用浏览器开发者工具检查标题是否还在h1.post-title里;查看x1.log里Scraped from日志的response.body前200字符 | 修改spiders/cnblogs_spider.py中response.css('h1.post-title::text').get()的选择器,比如改为response.xpath('//h1[contains(@class,"post-title")]/text()').get() |
CSV文件打开后中文乱码(显示为涓枃) | Excel默认用ANSI编码打开UTF-8文件 | 用记事本打开CSV,另存为“UTF-8-BOM”格式;或用VS Code、Notepad++等编辑器打开 | 在pipelines.py的CSV导出管道里,添加encoding='utf-8-sig'参数(Excel识别BOM头) |
output.json里content_html字段为空,但网页明明有内容 | 博客园启用了JavaScript动态加载正文 | 查看网页源代码(Ctrl+U),搜索cnblogs_post_body,如果找不到,说明内容由JS注入 | 此情况无法用Scrapy解决,需换用Playwright或Selenium,但会极大降低速度和稳定性 |
5.2 我踩过的坑与独家技巧
坑一:“相对时间”解析的陷阱
博客园列表页的时间是“2小时前”,我最初用arrow.get().shift(hours=-2)计算,结果发现博客园的“2小时前”是按服务器时间算的,而我的机器时区是东八区,服务器可能是UTC。导致导出的时间比实际晚8小时。解决方案:放弃解析列表页时间,强制只从详情页<span id="post-date">提取,哪怕多发100次请求也值得。
坑二:CSS选择器的脆弱性
博客园2023年12月一次小更新,把文章标题的class从post-title改成post_title(下划线代替短横线),导致所有标题抓取失败。我的应对技巧:在parse_post方法开头加一行日志self.logger.debug(f"Response URL: {response.url}, Title selector result: {response.css('h1.post-title::text').get()}"),这样出问题时一眼就能看到选择器是否命中。
坑三:CSV导出的字段顺序错乱
Scrapy默认按items.py里字段声明顺序导出,但CnblogsArticleItem里我把tags放在最后,结果CSV里标签列总在最后一列,不方便Excel筛选。技巧:在pipelines.py的CSV管道里,手动指定字段顺序:
class CsvExportPipeline: def open_spider(self, spider): self.file = open('output.csv', 'w', newline='', encoding='utf-8-sig') self.exporter = CsvItemExporter( self.file, fields_to_export=['title', 'publish_time', 'url', 'read_count', 'comment_count', 'tags', 'content_text'] ) self.exporter.start_exporting()坑四:日志文件爆炸式增长
第一次跑全站200篇文章,x1.log涨到80MB,全是DEBUG级别的Received response日志。终极解决方案:在settings.py里把LOG_LEVEL设为INFO,只保留关键日志;同时用LOG_FORMAT定制日志格式,去掉冗余信息:
LOG_FORMAT = '%(asctime)s [%(name)s] %(levelname)s: %(message)s'5.3 安全与合规边界提醒
必须郑重强调:这个工具的设计初衷,是帮助博主备份自己的原创内容。它严格遵守博客园的robots.txt协议(https://xxx.cnblogs.com/robots.txt通常允许User-agent: *访问/p/路径),且所有请求都模拟真实用户行为,不绕过任何登录验证,不抓取任何私密或未公开内容。
绝对禁止的行为:
- 抓取非你本人拥有的博主内容(除非获得明确书面授权);
- 将导出的数据用于商业用途(如打包售卖、训练商用大模型);
- 高频请求干扰博客园服务器(我们的DOWNLOAD_DELAY=2.5已留足安全余量);
- 修改allowed_domains去抓取cnblogs.com主站或其他无关域名。
技术无善恶,但使用者有责任。我写这个工具时,反复检查了每行代码是否符合《网络安全法》关于“不得干扰网络产品和服务正常运行”的规定。它应该是一个安静的、守规矩的、帮你守护知识资产的工具,而不是一把闯入他人领地的钥匙。
6. 二次开发与扩展方向
这个项目不是终点,而是一个可生长的起点。基于Scrapy的模块化设计,你可以轻松扩展出新能力:
方向一:增量更新(Incremental Update)
目前是全量抓取,但博主每周只发1~2篇新文章。可以改造dupe.py,让它读取上次导出的output.json,提取所有URL存入集合,新爬取时跳过已存在的URL。只需在spiders/cnblogs_spider.py的__init__方法里加几行:
def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.existing_urls = set() if os.path.exists('output.json'): with open('output.json', 'r', encoding='utf-8') as f: for item in json.load(f): self.existing_urls.add(item['url']) def parse_post(self, response): if response.url in self.existing_urls: self.logger.info(f"Skip existing URL: {response.url}") return # ... 后续解析逻辑方向二:Markdown导出
JSON/CVS适合程序处理,但人类更爱Markdown。在pipelines.py里新增一个MarkdownExportPipeline,用markdownify库把content_html转成Markdown,再按{publish_time}_{title}.md格式保存到./md_output/目录。这样你就能直接把整个文件夹拖进Obsidian,享受原生双链体验。
方向三:自动归档到Git
写个简单的shell脚本,在爬虫结束后自动执行:
git add output.json url.log git commit -m "Auto-commit: $(date '+%Y-%m-%d %H:%M') blog export" git push origin main配合GitHub Actions定时触发,你就拥有了一个全自动的博客园内容归档流水线。
最后分享一个小技巧:我在settings.py里加了一行BOT_NAME = 'cnblogs-backup-bot',并在pipelines.py的导出逻辑里,给每个JSON文件头部加了一段注释:
{ "meta": { "generated_by": "cnblogs-backup-bot v1.0", "generated_at": "2024-03-15T14:22:33+08:00", "source_blog": "https://zhangsan.cnblogs.com/" }, "data": [ ... ] }这样,五年后你翻出这个JSON文件,一眼就知道它从哪来、谁生成的、什么时候生成的——技术人的浪漫,就藏在这些不起眼的元数据里。
本文还有配套的精品资源,点击获取
简介:专为博客园平台设计的批量文章采集工具,用Python Scrapy框架开发,能自动抓取指定博主所有公开文章正文、标题、发布时间、URL等字段。项目结构规范,内置请求延迟控制、随机User-Agent切换、并发数调节、去重逻辑(基于URL和内容指纹)、中间件支持,适配博客园常见反爬机制。数据清洗和字段定义清晰,支持通过pipelines.py自定义处理流程,原始结果默认导出为结构化JSON或CSV文件,无需数据库依赖,开箱即用。附带完整日志记录(x1.log记录运行状态,url.log追踪已抓链接),.idea配置文件已预置,PyCharm等IDE可直接导入调试。requirements.txt明确列出依赖,scrapy.cfg和settings.py提供标准化配置入口,方便二次开发与参数微调。
本文还有配套的精品资源,点击获取