资讯详情

Python爬虫实战:博客园文章采集与Markdown导出全流程

📅 2026/10/13 18:46:37 | 华诺云谱 👁 阅读
Python爬虫实战:博客园文章采集与Markdown导出全流程
做采集这条路博客园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 html2textlxml是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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.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, timeout10) 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(stripTrue) 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(stripTrue) 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(stripTrue) 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, hrefTrue): # 去掉常见平台跳转链接比如博客园自己的站内推荐等 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, encodingutf-8) as f: f.write(json.dumps(data, ensure_asciiFalse) \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_okTrue) 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], encodingutf-8) return filepath文件名里的特殊字符必须做清洗。Windows和Linux对非法字符的处理不一样冒号、斜杠、问号这些在文件名里很容易报错。我见过有人因为忘了这一步在Mac上跑得好好的脚本换到Windows服务器上立刻崩溃。所以文件名清洗不是可选项是必须项。文件导出后整个项目就是“采集JSON存档 Markdown可读文档”双份产出数据既可以被程序消费也能被人类阅读。这也是“附Markdown文档格式预览”在项目里真正的价值体现。5. 常见问题与排查技巧实录5.1 请求不稳定与超时爬取过程最典型的故障就是请求超时或者返回异常状态码。原因往往是目标网站对高频访问的限流也可能是本机网络波动。我处理这个问题的标准做法是重试加退避。import time def safe_get(url, retries3, timeout10): for attempt in range(retries): try: resp session.get(url, timeouttimeout) 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, encodingutf-8) as f: f.write(html) # 在本地调试 soup BeautifulSoup(open(debug.html, encodingutf-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很干净有的人转出来全是广告链接和样式残留差别全在清洗这一步。不要怕麻烦清洗逻辑越细致产出越有价值。现在我把这套流程沉淀成了一个自己的采集模板之后抓任何类似站点只需要替换列表页和详情页的字段解析其余部分都能原样复用。这种一次投入、长期受益的感觉是这类项目最值得花时间的回报。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑