☰
Python爬虫实战:博客园文章采集与Markdown导出全流程
2026/10/13 18:46:18 网站建设 项目流程

做采集这条路,博客园(Cnblogs)是我经常拿来练手和做数据沉淀的平台。文章标题、发布日期、标签、正文HTML,再加上一份直接把正文转好的Markdown预览文档,这套需求看起来不复杂,真正跑顺却有不少细节要处理。这篇就完整拆解一个Python爬虫实战项目:从列表页抓取文章链接,到详情页提取字段,再到正文清洗和Markdown导出,全流程给出手写代码和你直接能用的思路。

1. 项目整体设计与核心思路

1.1 核心需求拆解

这个项目的目标很明确:采集博客园文章的基础元信息和正文内容,再生成一份便于阅读和分发的Markdown文档。需求拆开看是四个部分。

第一,列表页发现。博客园的首页或者聚合列表页会持续更新文章,我们需要从列表页里拿到每篇文章的入口链接,这是整个流程的起点。列表页没有这个信息,后面所有事都无从谈起。

第二,详情页字段提取。每一篇文章详情页里有标题、发布日期、标签、作者信息、正文HTML。其中正文是体积最大、结构最复杂的一块,需要单独做清洗和转换。

第三,正文内容清洗。博客园的文章正文里经常混着脚本、样式、无关链接、平台推荐位。直接抓回来的HTML不能直接用,要先把噪声去掉,只保留真正的正文内容。

第四,Markdown格式预览。把清洗后的HTML转成Markdown,既能保存成文档归档,也能方便后续接入知识库、全文检索或者二次排版发布。标题、标签、日期这些元信息也一起放进Markdown头部,形成完整预览文件。

我一直觉得爬虫项目的价值不在于“爬到内容”,而在于“爬到的内容能用”。所以这套流程把采集、清洗、转化、落地串成一条线,产出是结构化的数据和可读性强的文档,不是一堆堆没法处理的HTML。

1.2 技术选型解析

技术栈选的是Python的requests加BeautifulSoup,这是轻量级爬虫的标准组合。相比之下,Scrapy功能更强,但针对这种单站点、小规模的采集需求,Scrapy的分布式调度、中间件体系属于杀鸡用牛刀。requests直连、BeautifulSoup解析,代码短、调试直观,遇到问题很容易定位。

表格对比一下几种常见方案的取舍:

技术方案优点缺点适用场景
requests + BeautifulSoup轻量、灵活、易调试需要手动处理翻页、重试中小规模、单站点采集
Scrapy并发、去重、扩展完善学习成本高,项目偏重大规模、多站点、长期运行
Selenium / Playwright能处理动态渲染页面速度慢、资源占用高必须执行JS才能拿到内容

博客园的文章页面是服务端渲染出来的HTML,源代码里就有完整内容,不需要浏览器执行JavaScript,所以完全没必要上Selenium。动手前先确认目标站的渲染方式,能省掉大批不必要的复杂度。

Markdown转换我选用html2text库,它的表格、代码块处理比较成熟。当然文章里也会给一个不依赖第三方库的简化转换方案,适合只需要标题、段落、列表、代码块的轻量场景。

1.3 整体流程串联

整个采集流程可以概括成五步:列表页解析、详情页请求、字段提取、内容清洗、数据导出。代码上我用一个简易的Pipeline去串,每个环节拆成独立函数,方便单独调试和复用。

这个设计的好处是,无论目标换成其他技术博客,还是需要增加字段,都只要替换对应模块即可。比如之后想抓其他平台的文章,只需要改列表页解析和详情字段提取的部分,存储和Markdown生成逻辑完全不用动。

2. 环境准备与基础爬虫搭建

2.1 依赖库安装

新建一个干净的项目目录,准备Python 3.9以上版本。需要安装的库不多,四个就够用:

pip install requests beautifulsoup4 lxml pip install html2text

lxml是BeautifulSoup的解析器,比Python自带的html.parser更快,容错也更好。html2text负责把HTML转成Markdown。

如果还想做数据落库,可以把标准库的sqlite3直接用起来,不需要额外安装。

2.2 构造请求头和会话

爬虫起步最容易被忽略的是请求头。很多请求失败不是IP被封,而是请求头暴露了“不是真人访问”特征。我习惯把常用请求头提取成全局配置,尤其是User-Agent和Referer。

import requests BASE_URL = "https://www.cnblogs.com" HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": BASE_URL + "/", } session = requests.Session() session.headers.update(HEADERS)

这里用Session而不是每次直接requests.get,因为Session会自动保存Cookie,后续请求会带上更自然的请求状态。虽然博客园普通页面的Cookie要求不高,但提前把Session用起来,能减少很多潜在问题。

2.3 列表页文章链接发现

博客园列表页的核心工作是从一堆重复的HTML结构里找到文章入口。打开页面,按F12检查元素,能看到每篇文章大体都包在一个大的容器里,标题链接、摘要、作者信息都在这层容器内。

不同列表页的class命名不完全一样,但抓取逻辑是相通的:定位重复容器、提取标题链接、再拼接完整URL。下面这段代码演示了通用思路。

from bs4 import BeautifulSoup def fetch_html(url): resp = session.get(url, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text def parse_list_page(html): soup = BeautifulSoup(html, "lxml") articles = [] for item in soup.select("div.post_item"): title_tag = item.select_one("a.titlelnk") if not title_tag: continue title = title_tag.get_text(strip=True) href = title_tag.get("href") if href.startswith("/"): href = BASE_URL + href articles.append({"title": title, "link": href}) return articles

为什么一定要先用开发者工具确认结构而不是直接照抄网络上的选择器?因为网页改版是常态,类名随时会变。我实测过,同一个选择器在不同时间段会因为平台新增推荐位导致匹配不到,所以先验证、再写代码,是最稳的路子。这也是整个项目里最容易踩的第一个坑。

2.4 翻页与下钻设计

列表页的翻页规则每个站点差异很大,博客园新的聚合页面和旧版分页URL格式也不同。稳妥的做法是动态去找“下一页”链接,而不是自己硬拼分页参数。

def find_next_page_url(soup): next_tag = soup.select_one("a:has(> span)") # 示意写法,需结合真实页面调整 if next_tag and "下一页" in next_tag.get_text(): href = next_tag.get("href") return BASE_URL + href if href.startswith("/") else href return None

这个函数给你的不是最终答案,而是思路。写代码前先看目标页面里的下一页链接长什么样,把规则改成匹配实际结构即可。

拿到列表页的文章链接之后,每篇文章都会发起一次详情页请求。这一步需要注意的是频率控制,我会在文章列表循环里加一个随机休眠,别一口气把几十个请求全打出去。

3. 详情页字段提取与正文清洗

3.1 标题、日期与标签的提取

进入详情页后,先提取标题、发布日期、标签这些基础字段。博客园的文章标题通常在标题链接里,日期和标签分布在页面顶部的信息区域和正文下方的标签区域。

下面的解析函数示意了核心字段的提取思路。真实现场上还要根据抓回来的HTML做微调。

import re from datetime import datetime def parse_detail_page(html): soup = BeautifulSoup(html, "lxml") # 标题 title_tag = soup.select_one("#cb_post_title_url") or soup.select_one(".postTitle") title = title_tag.get_text(strip=True) if title_tag else "" # 发布日期:从常见的“posted @ 2025-01-05 10:30”文本里提取 post_desc = soup.select_one(".postDesc") pub_date = "" if post_desc: text = post_desc.get_text() match = re.search(r"(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2})", text) if match: pub_date = match.group(1) # 标签:通常在 EntryTags 容器里的一组 <a> 链接 tags = [] tags_container = soup.select_one("#EntryTags") or soup.select_one(".postDesc") if tags_container: for a in tags_container.select("a"): tag = a.get_text(strip=True) if tag and tag not in tags: tags.append(tag) return {"title": title, "pub_date": pub_date, "tags": tags}

分享一个调试技巧:爬虫开发阶段不要直接跑全量循环。先抽样抓一个详情页保存成本地HTML文件,然后用BeautifulSoup在这个文件上反复试选择器,直到所有字段都能取到,再放进循环里跑全量。这种“先单页后批量”的习惯能帮你省下大量时间,也能避免反复请求目标网站。

3.2 HTML正文的精细化清洗

正文提取是整个项目最需要认真对待的环节。博客园的正文主体区域一般有明确的容器,比如文章正文的div,但容器里会混入代码高亮脚本、隐藏广告、分享按钮、推荐位等噪声。

直接抓完整HTML再转Markdown,会得到一堆“垃圾结构”。我的清洗策略分成几步。

第一步,用选择器锁定正文容器。

body_div = soup.select_one("#cnblogs_post_body")

第二步,移除不需要的标签。脚本和样式直接整段删除,图片要小心处理,只保留真正的文章配图,去掉统计像素和广告图标。

def clean_content_body(body_div): if not body_div: return "" for tag in body_div.find_all(["script", "style", "iframe", "ins"]): tag.decompose() for tag in body_div.find_all("a", href=True): # 去掉常见平台跳转链接,比如博客园自己的站内推荐等 if "cnblogs.com" in tag["href"] and tag.find("img"): tag.unwrap() for img in body_div.find_all("img"): src = img.get("src") or img.get("data-original") if src and src.startswith("//"): img["src"] = "https:" + src if src and not src.startswith("http"): img["src"] = BASE_URL + src return body_div

这里有个容易被忽视的细节:图片路径常常是相对路径或者协议相对路径。如果不把src补全成完整URL,导出的Markdown文档在自己的电脑上打开时会全部裂图。我实际处理时就撞过这个情况,原来只存了/upload/xxx.png,换到本地环境完全看不到。所以图片地址必须统一转为绝对地址。

第三步,处理相对链接和锚点链接,让清洗后的HTML能够脱离原站正常打开。

注意:清洗正文时别把pre和code里的内容误删。代码块是技术文章最值钱的部分,转换时一定要保留原样,包括缩进和特殊字符。

3.3 Markdown转换与预览生成

清洗完HTML后,可以直接交给html2text转换。这个库能把标题、段落、列表、表格、代码块都转成对应的Markdown语法。

import html2text def html_to_markdown(clean_html): converter = html2text.HTML2Text() converter.ignore_images = False converter.body_width = 0 converter.protect_links = True converter.unicode_snob = True converter.ignore_emphasis = False return converter.handle(clean_html)

body_width = 0很重要,意思是不要自动换行。默认值会把长段落折行成很多短行,Markdown渲染出来虽然一样,但阅读这份源码的时候会特别难受,所以我一般直接关掉。

如果你不想引入html2text这个库,也可以自己写一个简化转换器,只处理标题、段落、无序列表、有序列表和代码块。这样代码更轻,而且对输出格式的控制力更强。

def simple_html_to_markdown(soup): lines = [] for element in soup.find_all(["h2", "h3", "h4", "p", "li", "pre", "blockquote"]): name = element.name text = element.get_text().strip() if not text: continue if name == "h2": lines.append(f"## {text}") elif name == "h3": lines.append(f"### {text}") elif name == "li": lines.append(f"- {text}") elif name == "pre": lines.append("\n```\n" + text + "\n```\n") else: lines.append(text) return "\n\n".join(lines)

这个简化版最大的坑是嵌套结构不好处理,比如列表里有段落、段落里有代码块,转换完层级会丢。如果只是给自己阅读、不追求完全还原格式,它是完全够用的。正式项目我更推荐html2text,省心而且边界情况处理得好。

Markdown文档的“预览”效果,本质上就是让读者能在GitHub、语雀、本地编辑器或者博客后台直接看到渲染后的排版。所以生成文档时,我会顺手把元信息也写进文档头部。

4. 数据落地与文件导出

4.1 结构化存储方案

每次采集到的文章数据,建议同时落到两种存储里:JSON用于程序读取和二次处理,SQLite用于去重和增量管理。

JSON的好处是直观。每条记录包含标题、链接、日期、标签、Markdown内容,一眼看过去结构清楚。

import json data = { "url": article_url, "title": title, "pub_date": pub_date, "tags": tags, "markdown": markdown_content, } with open("articles.json", "a", encoding="utf-8") as f: f.write(json.dumps(data, ensure_ascii=False) + "\n")

注意这里用了JSONL格式,每行一条记录,而不是把整个列表一次性塞进JSON数组。好处是采集过程中断时,已经写入的行不会受影响,下次继续追加就行,不用重新解析整个文件。

SQLite的建表语句可以直接参考下面的代码。

import sqlite3 conn = sqlite3.connect("cnblogs.db") conn.execute(""" CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE, title TEXT, pub_date TEXT, tags TEXT, markdown TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) """)

这里给url字段加了UNIQUE约束,它是天然的去重键。同一篇文章地址不会重复入库,省掉了大量判断代码。

4.2 一键导出Markdown文档

面向人工阅读的场景,我倾向于每篇文章单独导出一个.md文件,文件名包含日期和标题关键词,既方便排序,也能一眼看出文章时间。

from pathlib import Path import re output_dir = Path("./markdown_preview") output_dir.mkdir(exist_ok=True) def sanitize_filename(name): name = re.sub(r'[\\/:*?"<>|]', "_", name) return name.strip()[:50] def export_markdown(article): filename = f"{article['pub_date'][:10]}_{sanitize_filename(article['title'])}.md" filepath = output_dir / filename header = ( f"# {article['title']}\n\n" f"- 发布日期:{article['pub_date']}\n" f"- 标签:{', '.join(article['tags'])}\n" f"- 原文链接:{article['url']}\n\n" "---\n\n" ) filepath.write_text(header + article["markdown"], encoding="utf-8") return filepath

文件名里的特殊字符必须做清洗。Windows和Linux对非法字符的处理不一样,冒号、斜杠、问号这些在文件名里很容易报错。我见过有人因为忘了这一步,在Mac上跑得好好的脚本,换到Windows服务器上立刻崩溃。所以文件名清洗不是可选项,是必须项。

文件导出后,整个项目就是“采集JSON存档 + Markdown可读文档”双份产出,数据既可以被程序消费,也能被人类阅读。这也是“附Markdown文档格式预览”在项目里真正的价值体现。

5. 常见问题与排查技巧实录

5.1 请求不稳定与超时

爬取过程最典型的故障就是请求超时或者返回异常状态码。原因往往是目标网站对高频访问的限流,也可能是本机网络波动。

我处理这个问题的标准做法是重试加退避。

import time def safe_get(url, retries=3, timeout=10): for attempt in range(retries): try: resp = session.get(url, timeout=timeout) if resp.status_code == 200: return resp.text elif resp.status_code in (403, 429): wait = 2 ** (attempt + 1) print(f"[提示] 请求被限流,{wait} 秒后重试") time.sleep(wait) except requests.RequestException as e: print(f"[异常] 第 {attempt + 1} 次请求失败: {e}") time.sleep(2) return None

这里有几个点要展开说。第一次返回403或者429,不要立刻重试,先停几秒。指数退避的好处是给服务端留出恢复时间。连续超过三次就不要硬顶了,说明可能被更严格的策略盯上。

重要:爬虫应该是克制的。采集频率不要高于正常人浏览速度,更不要在短时间把整个站点文章全部抓完。我个人的实践准则是每次请求间隔两到五秒,整批数据分时段跑。

5.2 解析结果为空或乱码

解析结果为空,十有八九是选择器没有匹配到节点。这时不要急着改代码,先用返回的HTML片段做一次“本地探测”。

把页面HTML保存到本地文件,再用选择器逐个测试:

with open("debug.html", "w", encoding="utf-8") as f: f.write(html) # 在本地调试 soup = BeautifulSoup(open("debug.html", encoding="utf-8"), "lxml") print(soup.select_one("#cnblogs_post_body"))

如果本地能匹配到但运行时不匹配,再检查是不是被反爬逻辑返回了一个不同的页面。我也碰到过运行时抓到的是浏览器验证页,而不是目标文章,所以代码里最好加一个标志性关键词检测,比如页面上是否存在“正文”容器的特征,没有就跳过去。

乱码问题主要来自编码识别错误。requests会猜页面编码,但有时候猜不准。用apparent_encoding能改善,但更保险的方式是从页面源码的charset声明里手动读取。我在之前fetch_html里已经用了apparent_encoding,如果还遇到个别页面乱码,可以直接针对那一页单独指定编码。

5.3 正文提取不全或包含噪声

正文提取不全多数是页面结构变化。比如原来的正文都放在#cnblogs_post_body里,后来平台改版,正文前面多了一层包裹容器,原有选择器就只能取到部分内容。

这个时候先去浏览器里看真实结构,确认新的正文容器是什么,然后更新选择器。流程很简单,重点是要建立“页面变了我能知道”的机制。我在爬虫里加了一个简单的校验逻辑:如果提取出来的正文字数少于某个阈值,就触发告警,把该页HTML存档,便于后续排查。

这个机制非常实用。某些文章短是正常的,但如果连续十几篇都是短正文,必然是选择器出了问题。靠人眼检查几千篇文章不现实,给程序加一条“异常检测”规则,就能第一时间发现问题。

5.4 常见问题速查表

现象可能原因解决办法
请求返回403缺少请求头或触发限流完善User-Agent和Referer,降低频率
页面能打开但解析为空选择器和当前页面结构不匹配本地化保存页面,重新分析HTML结构
中文乱码requests编码识别错误手动指定charset或使用apparent_encoding
正文图片不显示图片是相对路径转换时将src补为绝对URL
文件名保存报错标题包含非法字符导出时用正则清洗文件名
连续请求后超时被限流或网络抖动重试+指数退避,加随机睡眠
Markdown代码块错乱原HTML中转义字符导致检查pre/code内容,保留原始文本
重复入库没做去重SQLite对url字段加UNIQUE约束

这张表我每次跑类似项目都会参考。大部分爬虫问题不是技术难度高,而是细节没有兜底。

6. 实操心得与后续扩展

6.1 代码结构怎么组织更省心

整个过程跑通后,建议把代码拆成四个模块,而不是全部写在一个脚本里。

采集器负责请求和基础解析,清洗器负责正文加工,转换器负责Markdown生成,存储层负责JSON、SQLite和文件导出。拆完之后每一块都能单独调试,尤其清洗和转换逻辑在页面上出现变动时,可以直接单独测试而不用从头跑一遍采集流程。

我当时在这个项目里还加了一个简单的调度入口,支持按页码范围抓取或者只抓当天最新文章。这样既不会一下把数据量撑得太大,也方便定时任务每天运行一次做增量更新。

6.2 频率控制与采集边界

对于博客园这类公开技术博客社区,爬取公开内容进行个人学习和归档是很多开发者都会做的事,但边界一定要守住。不要绕过登录限制去抓需要权限才能看到的内容,不要用高并发冲击目标服务器,也不要把他人的文章批量采集后直接用于商业发布。

我的习惯是采集频率保持“真人浏览”水平,单次请求之间至少间隔两秒,单批任务控制在几百条以内。这样对方服务器没有压力,自己也更少碰到封禁问题。

6.3 后续扩展方向

这套项目做完以后,可以做几个方向的扩展。

把采集结果接入全文搜索,比如使用轻量级检索引擎,让Markdown正文快速检索。或者把数据接入个人知识库,配合时间、标签信息形成内容沉淀。

Markdown预览功能还可以进一步增强,比如生成索引页,把所有文章的标题、日期、标签放在一个总览表格里,点击链接跳转到对应的Markdown文档。这个功能实现起来不复杂,只需要在导出时额外生成一个README.md,遍历所有文件写入链接列表即可。

代码层面,如果你想长期跑,可以引入定时任务,每天增量抓取当天更新的文章。我这里贴一个增量过滤的简单思路:

def is_article_exists(url): cursor = conn.execute("SELECT 1 FROM articles WHERE url = ?", (url,)) return cursor.fetchone() is not None

插入前先查一下URL是否已存在,存在就跳过,不存在才继续解析和入库。这个操作加上之后,整个爬虫就从一个“一次性抓取工具”变成了“可持续更新的采集服务”。

6.4 个人实操的一点体会

整个项目做下来,我最想说的一点是:爬虫的难点从来不是写代码,而是理解目标网站的结构和变化节奏。写代码只占整个项目的一小部分时间,更多时间花在“查看网页源代码、调整选择器、清洗异常内容、验证导出效果”这些反复迭代上。

还有一点是数据质量。同样的正文,有的人转出来的Markdown很干净,有的人转出来全是广告链接和样式残留,差别全在清洗这一步。不要怕麻烦,清洗逻辑越细致,产出越有价值。现在我把这套流程沉淀成了一个自己的采集模板,之后抓任何类似站点,只需要替换列表页和详情页的字段解析,其余部分都能原样复用。这种一次投入、长期受益的感觉,是这类项目最值得花时间的回报。

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

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

立即咨询