简介:一份围绕 Python 网页抓取、页面解析与结果文档化整理的学习材料,面向已具备基础语法、希望掌握爬虫采集与数据保存流程的初学者和开发者。内容以 requests 发起请求、BeautifulSoup 或 lxml 解析页面为线索,结合 MongoDB 中文档、集合、数据库等核心概念,讲解如何抽取公示类网页信息、按字段组织数据并写入集合,同时给出连接数据库、建库建集合、插入文档与简单查询的代码示例,便于理解非关系型存储在 Web 数据管理中的实际用法。资源包共 1 个 docx 文件,约 608KB,以图文和代码片段组织,适合作为从页面解析到持久化保存的参考笔记;其中涉及文档键值结构、集合划分与索引等要点,可帮助读者梳理爬虫采集、内容整理与结果保存的完整思路。已有 11258 人学习,适合需要示例代码、排错参考和项目结构借鉴的读者按需查阅。
1. 需求先立住:从“抓完就完”到“入库出文档”的闭环
做 Python 爬虫的人大多经历过这个阶段:脚本跑通,数据在终端里刷了一屏,然后就没有了。这次的需求很具体——把某政务公示站点上几百条公告抓下来,先落进 MongoDB 变成可检索的文档库,再按批次导出成 Word 文档交出去存档。抓取只是第一环,真正决定这套东西能不能交付的,是数据落在哪里、字段怎么定、导出的 Word 能不能直接送去打印。
适合看这套方案的人有三类:课程设计要交一个“爬虫 + 数据库 + 文档导出”完整链路的学生;毕业设计需要一个能演示、能答辩的数据采集子系统的人;以及手上有零星网页数据、想把它整理成正式文档的从业者。下面按需求、选型、模型、抓取、落库、导出、验收的顺序,把这套流程讲解清楚。
2. 架构与数据模型:三级存储结构 + 八个字段的记录设计
方案整体分三段:抓取层负责把列表页翻完、把详情页字段抠出来;存储层负责把一条公告变成一个 BSON 文档落进 MongoDB;导出层负责按条件把集合里的文档渲染成 Word。三层之间只靠一个约定通信——一条公告就是一个字典,键名固定,谁也别改。这个约定立住了,后面哪一层出问题都好定位。
2.1 为什么网页正文适合放文档型数据库
公示类页面的字段结构其实很不稳定。有的公告带附件,有的不带;有的附件是一个 PDF,有的是三张图片;正文短的只有两行,长的能到七八千字。用关系型数据库存,得开一张主表放标题日期,再开一张附件子表存链接,导出的时候还要 join 回来。字段一改就得改表结构,改动一次要重新迁移数据。
MongoDB 的存储单位是文档,一条公告就是一个文档,附件是一个数组字段,有就三个元素,没有就空数组,不需要任何表结构变更。这正好对上项目里“字段不固定、数据量中等、查询以单条和按日期范围为主”的场景。反过来说,如果这个项目要做多表关联的统计报表、要求跨文档强事务,那还是老老实实上关系型,文档型数据库在这类需求上并不占便宜。选型没有绝对好坏,只有跟需求对不对得上。
集合的划分也有讲究。原文把所有公示塞进一个gov_publicity集合,这个粒度对单一站点是够的。如果后面要抓多个栏目,建议按栏目拆集合,比如“通知公告”“政策解读”“结果公示”各一个,管理上更清晰,查询效率也更好。MongoDB 允许你随便往一个集合里塞结构完全不同的文档,但“能塞”不等于“该塞”,该分的还得分开。
2.2 字段清单与命名约定
字段设计是整个方案里最值得反复推敲的部分。综合抓取和导出两头的需求,一条公示记录通常定成这八个字段:
| 字段名 | 类型 | 是否必有 | 说明 |
|---|---|---|---|
| url | string | 是 | 详情页绝对地址,建唯一索引,幂等的依据 |
| title | string | 是 | 公告标题,去掉首尾空白 |
| source | string | 否 | 发布来源,抓不到存空串 |
| publish_date | string | 否 | 统一格式化成 YYYY-MM-DD 字符串 |
| content | string | 是 | 正文,段落之间用 \n 拼接 |
| attachments | array | 否 | 附件绝对链接数组,无附件存 [] |
| fetched_at | datetime | 是 | 入库时间,用 datetime.now() 写入 |
| content_hash | string | 否 | 正文的 md5 前 16 位,用来判断页面有没有改过 |
这里有一个容易被忽略的取舍:要不要用中文键名。{"标题": ..., "公示内容": ...}在 pymongo 里完全合法,看起来还更直观。但导出 Word 的时候你要维护一张“字段名→中文小标题”的映射表,抓取代码里写中文键、导出代码里写英文变量,两边对不上就是一堆 KeyError。我一般统一用英文字段名,中文只出现在展示层,抓取、存储、导出三段的键名完全一致,改一处不用改三处。
content_hash这个字段看着多余,实际很有用。站点经常对旧公告做小修订,如果只按 url 做幂等,重跑的时候旧文档会被覆盖但没人知道内容变了。有了哈希,导出前跑一次比对,把变化过的挑出来单独提醒,比事后被人问“这版跟上一版怎么不一样”要从容得多。
2.3 环境准备与依赖锁定
环境这块没什么玄学,照做就行。建虚拟环境、装四个包、把版本冻住:
python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install requests lxml pymongo python-docx pip freeze > requirements.txt # 锁版本,换机器重装时不会因为小版本差异翻车四个包各自管一摊:requests发 HTTP 请求拿 HTML,lxml负责把 HTML 解析成树并支持 XPath,pymongo是 MongoDB 的官方驱动,python-docx负责生成 .docx。这里没有用 BeautifulSoup,原因不是它不好,而是这个站点要抠的字段都在固定的 class 上,XPath 一行能定位到底,省掉一层 find 链式调用。如果你的目标页面结构比较松、class 名不规整,用 BeautifulSoup 配合 CSS 选择器反而顺手,两个库不冲突。
数据库端本地起一个实例就够,默认端口 27017。启动后建议装一个可视化的客户端,随便哪款都行,主要图一个直观——跑完抓取脚本,打开客户端看一眼集合里的文档数和字段是不是齐的,比在终端print强太多。数据库命名webpages,集合命名gov_publicity,这两个名字一旦定下来,后面所有代码和导出脚本都引用同一组常量,别一边叫webpages一边叫web_page。
3. 抓取层实现:翻页发现、字段抽取与四进程并发
抓取层的任务链条很短:先拿到所有详情页的链接,再逐个进详情页抠字段,最后交给存储层。短归短,坑几乎全在这一层。下面按“列表页 → 详情页 → 并发调度”的顺序拆。
3.1 列表页翻页与链接补全
列表页的翻页规律通常是index.html、index_1.html、index_2.html这样递增。先把全部页码生成出来,再逐个请求:
import requests from lxml import etree from urllib.parse import urljoin HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" } BASE = "http://www.example-notice.com/gggs" # 列表页根地址,换成目标站点 DETAIL_ROOT = "http://www.example-notice.com/" # 详情页链接补全的基准 def get_links(url, retry=3): """请求一个列表页,返回该页所有详情页的绝对链接""" for i in range(retry): try: resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding tree = etree.HTML(resp.text) # 列表项固定在 class 为 list-group 的 ul 下 hrefs = tree.xpath('//ul[@class="list-group"]/li/a/@href') # href 形如 "./201911/t20191114_489608.html",用 urljoin 补成绝对地址 return [urljoin(DETAIL_ROOT, h) for h in hrefs] except Exception: if i == retry - 1: return [] time.sleep(1 + i) return []这段代码有两处刻意为之。一是urljoin而不是字符串替换。原始写法里用re.sub("^.", BASE, href)拼链接,正则里的.匹配的是任意字符,只要 href 不是以点开头,第一个字符就会被吃掉,链接立刻 404。这种错误还特别隐蔽,因为大部分链接恰好是./开头,跑起来看着正常,偶尔几条失败就以为是网络抖动。二是加了重试和超时。页面偶尔返回 502 或者连接卡住,没有 timeout 的requests.get会一直挂着,进程池里四个 worker 一起挂住,整个脚本就卡死在那儿。
翻页这一层还有个小细节:首页是index.html,从第 1 页开始才是index_1.html。生成 URL 列表的时候首页单独放,循环从 1 开始,别写成从 0 开始,否则会多请求一个index_0.html,返回 404 空页面,白白浪费一次请求。
3.2 详情页字段抽取与安全取值
详情页是重头戏。标题、来源、日期、正文、附件五样东西,分布在不同的 DOM 位置上,取法各不相同:
import re, hashlib, time from datetime import datetime def pick(tree, xpath, default=""): """安全取值:XPath 命中就返回第一个结果,没命中返回默认值""" res = tree.xpath(xpath) return res[0].strip() if res else default def parse_detail(url): resp = requests.get(url, headers=HEADERS, timeout=15) resp.encoding = resp.apparent_encoding tree = etree.HTML(resp.text) title = pick(tree, '//div[@class="xxym"]/h4/text()') # 来源和日期挤在同一个 h5 里,整段拿下来再用正则拆 h5 = " ".join(tree.xpath('//div[@class="xxym"]/h5//text()')) m_src = re.search(r"来源[::]\s*(\S+)", h5) m_date = re.search(r"(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})", h5) source = m_src.group(1) if m_src else "" pub_date = f"{m_date.group(1)}-{int(m_date.group(2)):02d}-{int(m_date.group(3)):02d}" if m_date else "" # 正文可能嵌在 p 里的 span 中,用 // 而不是 / 才能抓到嵌套文本 paras = tree.xpath('//div[@class="TRS_Editor"]//p//text()') content = "\n".join(t.strip() for t in paras if t.strip()) # 附件链接是相对路径,逐个补全 atts = [urljoin(DETAIL_ROOT, a) for a in tree.xpath('//i[@class="fa fa-caret-right"]/a/@href')] return { "url": url, "title": title, "source": source, "publish_date": pub_date, "content": content, "attachments": atts, "fetched_at": datetime.now(), "content_hash": hashlib.md5(content.encode("utf-8")).hexdigest()[:16], }pick这个函数是整个解析层的保险。原始写法里大量xpath(...)[0],只要某条公告缺了日期或没有附件,立刻抛 IndexError,而且是在子进程里抛,父进程只能看到一句“error”,连是哪条 url 挂的都不知道,成了典型的黑匣子。统一走安全取值 + 默认值,单条数据缺失不会中断整批任务,缺哪几项最后统计一下就知道页面结构是不是变了。
正文取//p//text()而不是/p/text(),是因为很多正文段落里嵌了<span>做加粗或者标记,/p/text()只能拿到 p 的直接子文本节点,遇到嵌套就返回空字符串,结果就是正文一大片空白,标题日期都正常,只有内容丢了,排查起来非常费劲。日期那段也不用固定的制表符切分,直接上\d{4}开头的正则,页面加个空格少个空格照样能匹配上。
3.3 进程池并发与请求节流
几百条详情页串行请求,一条一秒,跑完十几分钟。用进程池把并发提上来:
from multiprocessing import Pool def fetch_one(link): """子进程 worker:抓一条、存一条、失败只影响这一条""" try: doc = parse_detail(link) col.update_one({"url": doc["url"]}, {"$set": doc}, upsert=True) time.sleep(0.1) # 主动降速,别把对方站点打疼 return True except Exception as e: print(f"fail: {link} -> {e}") return False if __name__ == "__main__": urls = [f"{BASE}/index.html"] + [f"{BASE}/index_{i}.html" for i in range(1, 34)] links = [] for u in urls: links.extend(get_links(u)) print(f"共发现 {len(links)} 条详情页") with Pool(processes=4) as pool: result = pool.map(fetch_one, links, chunksize=5) print(f"成功 {sum(result)} / {len(result)}")几个参数值得说清楚。processes=4是保守值,本机抓取开 4 到 8 个进程足够,再往上提升有限,反而更容易触发对方站点的频率限制。chunksize=5让每个 worker 一次领五条任务,减少进程间调度开销,链接成千上万时效果明显。with Pool(...)的写法保证任务跑完自动回收,不会留一堆僵尸进程,尤其是中途 Ctrl+C 中断的时候。
并发带来的副作用要说在前面:time.sleep(0.1)不是可选项。四个进程同时发请求,对单个站点来说已经是四倍压力,如果不降速,轻则被限流返回空页面,重则封 IP,前面抓的全白干。抓取是个需要讲规矩的活,控制并发、加延时、只在合理时间内跑,是最基本的职业习惯。
4. 落库与导出:幂等写入 + python-docx 排版的两段式流水线
前半段解决“数据从哪来”,后半段解决“数据怎么变成能交付的东西”。这一章的两个点——写入幂等和文档排版——直接决定这份方案能不能反复跑、导出的文件能不能直接给人。
4.1 幂等写入:把 insert_one 换成 update_one
原始代码用的insert_one,跑第一次没问题,跑第二次就重复了。几百条公告变成六百条,导出 Word 的时候同一篇内容出现两遍,人工去重能耗掉一下午。改成基于 url 的“有则更新、无则插入”:
from pymongo import MongoClient, ASCENDING, UpdateOne from pymongo.errors import BulkWriteError client = MongoClient("mongodb://localhost:27017/", maxPoolSize=50, connect=False) col = client["webpages"]["gov_publicity"] # 唯一索引是整个幂等机制的地基,没有它 update 也可能插重复 col.create_index([("url", ASCENDING)], unique=True) col.create_index([("publish_date", ASCENDING)]) # 导出按日期排序会用到 def save_batch(docs): """批量写入:一次网络往返处理 N 条,比逐条 update_one 快一个量级""" ops = [UpdateOne({"url": d["url"]}, {"$set": d}, upsert=True) for d in docs] try: res = col.bulk_write(ops, ordered=False) # ordered=False 允许部分失败继续 return res.upserted_count + res.modified_count except BulkWriteError as e: print(f"部分写入失败:{e.details.get('writeErrors')}") return 0upsert=True的意思是“按条件查,查到了就更新,查不到就插入”,天然幂等。唯一索引是保险,万一并发写入时两条同样的 url 同时进来,数据库会直接拒掉其中一条,而不是产生两份。ordered=False让批量操作跳过失败项继续执行,不会因为一条脏数据把整批回滚。
还有一个常见的坑:connect=False这个参数。pymongo 默认在创建 MongoClient 时就连上数据库,如果客户端是在父进程创建的、再被 fork 到子进程里用,每个子进程会继承同一个 socket 句柄,多进程并发操作同一个连接会出各种诡异错误——有时候报连接重置,有时候干脆静默丢数据。加connect=False让连接延迟到第一次实际操作时才建立,每个子进程各自连各自的,问题就没了。
4.2 从集合到 Word:中文字体与排版规则
导出的核心是python-docx。它生成的是标准 .docx,Word、WPS 都能打开,也可以直接转 PDF。写法不复杂,但中文字体这一块如果不处理,导出来的文档全是等线,标题正文一个样,看着就很不正式:
from docx import Document from docx.shared import Pt, Cm from docx.oxml.ns import qn def set_cn(run, font="宋体", size=10.5, bold=False): """python-docx 设置中文字体必须同时设 ascii 和 eastAsia,只设 name 没用""" run.font.name = font run.font.size = Pt(size) run.bold = bold run._element.rPr.rFonts.set(qn("w:eastAsia"), font) def export_doc(docs, out_path, title="公示汇编"): doc = Document() sec = doc.sections[0] sec.left_margin = sec.right_margin = Cm(2.54) # 标准公文页边距 normal = doc.styles["Normal"] normal.font.name = "宋体" normal.font.size = Pt(10.5) normal._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体") doc.add_heading(title, level=0) for i, item in enumerate(docs, 1): h = doc.add_heading(level=2) # 二级标题样式 set_cn(h.add_run(f"{i}. {item.get('title') or '无标题'}"), "黑体", 14, True) meta = f"来源:{item.get('source') or '未标注'} 发布日期:{item.get('publish_date') or '未标注'}" set_cn(doc.add_paragraph().add_run(meta), "楷体", 9) for para in (item.get("content") or "").split("\n"): if para.strip(): p = doc.add_paragraph() p.paragraph_format.first_line_indent = Cm(0.74) # 首行缩进两字符 set_cn(p.add_run(para.strip())) for a in item.get("attachments") or []: p = doc.add_paragraph(style="List Bullet") set_cn(p.add_run(a), "宋体", 9) doc.save(out_path)set_cn里那行qn("w:eastAsia")是关键。python-docx 的font.name只设置西文字体,中文字符走的是另一套属性,两者都设了 Word 里显示的才是宋体。只设一个的话,英文数字是宋体、中文变成默认字体,一页文档里两种字形混着,第一次遇到会以为是编码问题,查半天才发现是字体属性。这个坑几乎每个用 python-docx 做中文文档的人都踩过。
排版上还有两个细节:正文段落首行缩进 0.74 厘米(对应五号字的两字符),标题用add_heading而不是手动设字号,这样 Word 的导航窗格里能直接看到目录树,几百页的文件翻起来方便很多。附件用 List Bullet 样式列成项目符号,比我手打“-”要规整。
4.3 批量导出的目录与命名规范
几百条公告塞进一个 Word 会变成上千页,打开都卡。按批次拆开,每份 50 条,用日期范围做文件名:
import os, re def safe_name(name, limit=50): """清掉文件名非法字符,并截断,防止超出系统路径长度限制""" name = re.sub(r'[\\/:*?"<>|\r\n\t]+', "_", name or "untitled").strip("_ ") return name[:limit] or "untitled" def batch_export(col, out_dir, size=50): os.makedirs(out_dir, exist_ok=True) docs, idx = [], 1 # 按发布日期倒序,最新的排最前 for d in col.find({}, {"_id": 0}).sort("publish_date", -1): docs.append(d) if len(docs) >= size: save_and_clear(docs, out_dir, idx); docs = []; idx += 1 if docs: save_and_clear(docs, out_dir, idx) def save_and_clear(docs, out_dir, idx): dates = sorted(d["publish_date"] for d in docs if d.get("publish_date")) span = f"{dates[0]}_{dates[-1]}" if dates else "无日期" path = os.path.join(out_dir, f"公示汇编_{idx:02d}_{span}.docx") export_doc(docs, path)文件名清理这步别省。公告标题里带书名号、斜杠、冒号是常态,直接拿来当文件名,Linux 下会创建出一堆子目录,Windows 下直接报错。再加上标题可能上百字,超过路径长度限制又是一轮报错。统一safe_name处理,长度截到 50,问题一次性解决。
导出目录建议按“年月/批次”两级组织,比如export/2024-11/公示汇编_01_2024-11-01_2024-11-15.docx。找文件的时候按时间翻,比在一堆杂乱文件名里翻要快得多。
5. 避坑与排查:五类最容易翻车的现场和对应解法
这一章是从实际运行日志里总结出来的。每一条都按“现象 → 原因 → 解决”写,遇到了直接对号入座。
5.1 抓取侧的三类翻车
现象:正文抓下来全是乱码,中文变成一串问号或者方块。原因:requests在没有显式指定编码时,会优先按响应头里的 charset 解码,很多站点的响应头写的是text/html不带 charset,requests 就退回到 ISO-8859-1,中文自然全乱。这类站点实际用的是 GB2312 或 GBK。 解决:请求后立刻resp.encoding = resp.apparent_encoding,让 chardet 去猜。但apparent_encoding会把整个响应体扫一遍,页面大的时候明显变慢,更快的做法是只喂前 2048 字节给检测器,或者干脆看页面<meta charset>里写的是什么,直接resp.encoding = "gb2312"写死。另外注意,etree.HTML(resp.text)用的是已经解码过的字符串,如果编码猜错了,lxml 也救不回来;把resp.content直接传给etree.HTML,lxml 会自己读 meta 标签判断编码,反而更稳。
现象:跑着跑着大批量失败,日志里全是 IndexError。原因:xpath(...)[0]在列表页改版、某条公告缺字段、或者页面返回了错误页的时候,返回空列表,取下标直接炸。而且异常发生在子进程里,父进程拿不到堆栈,只知道失败了。 解决:所有取值统一走pick()安全函数,给默认值;子进程的 worker 用 try/except 包住并打印具体 url 和异常信息;跑完之后统计各字段的空值率,如果“来源”字段空值率突然从 2% 涨到 60%,说明页面结构变了,该改 XPath 了。这套东西就是给爬虫装的仪表盘,没有它,改版了你还以为抓得好好的。
现象:大部分链接正常,零星几条拼出来的地址是错的。原因:用正则re.sub("^.", BASE, href)补全相对路径,正则里的.匹配任意字符,只要 href 不是以点开头,第一个字符就被替换掉了。 解决:把字符串拼接换成urllib.parse.urljoin(DETAIL_ROOT, href)。urljoin 是按 RFC 规范实现的,能正确处理./、../、/开头和已经是绝对地址的四种情况,比手写正则靠谱一个量级。同理,附件链接、翻页链接全部走 urljoin。
5.2 存储与导出侧的两类翻车
现象:并发抓取时抛出连接异常,或者数据莫名其妙少了几条。原因:MongoClient 在父进程创建,被 fork 到子进程后共用同一个连接池。pymongo 的连接对象内部有锁和 socket,多进程同时操作会互相踩,表现出的错误五花八门——有时是AutoReconnect,有时是写入成功但查不到。 解决:创建客户端时加connect=False,让连接延迟到真正用的时候才建立,这样每个子进程都会自己建一条新连接。如果代码结构允许,更彻底的做法是在每个 worker 函数的开头各自实例化一个客户端,用完就丢,虽然多几次握手但最稳。
现象:导出的 Word 里中文全是默认字体,标题和正文分不出来。原因:python-docx 的run.font.name只影响西文字符,中文字符走w:eastAsia这个 XML 属性,不设它,Word 就用主题字体渲染中文。 解决:封装一个set_cn(),同时设置font.name和rFonts的 eastAsia 属性;正文样式styles["Normal"]也要同样处理一遍,否则没加 run 的空段落还是默认字体。改完拿一份文件双击打开,翻到中间几页看字形一致不一致,别只看第一页。
现象:同一个脚本跑两次,Word 里同一篇公告出现了两遍。原因:写入用的是insert_one,没有幂等保护。 解决:给 url 建唯一索引,写入改成update_one(..., upsert=True)或bulk_write+UpdateOne(..., upsert=True)。索引这一步必须做,只改写入方式不建索引,在并发场景下仍然可能插进重复文档。
6. 验收与答辩:七个检查点和一套讲述顺序
方案跑通和方案能交付是两码事。交付前我一般会照着下面这张表过一遍,每项都必须有明确的通过标准,不能靠“我看着还行”:
| 检查项 | 通过标准 | 怎么验 |
|---|---|---|
| 完整性 | 抓取条数与列表页总条数误差小于 1% | 人工数一遍前 5 页的总数,跟数据库 count 对比 |
| 字段完整率 | 标题、正文、url 缺失率为 0,其余字段缺失率低于 5% | 用聚合查询按字段统计空值数量 |
| 幂等性 | 连续跑两次,集合总数不变 | 记录第一次 count,重跑后再 count 一次比对 |
| 正文质量 | 随机抽 10 条,正文无乱码、无 HTML 残留、段落分隔正确 | 打开文档逐条看,重点看长正文和带附件的 |
| 附件可用 | 随机抽 5 个附件链接,浏览器能打开 | 直接点,别只看字符串非空 |
| Word 排版 | 打开后标题层级正常、中文字体统一、页码连续 | 在导航窗格里看目录树,翻到中间页看字体 |
| 可复现 | 换一台机器,按 requirements.txt 装完能直接跑 | 真的换一台机器试,别凭感觉 |
这七项里最容易被跳过的是“幂等性”和“可复现”。前一项决定了这套脚本能不能当日常工具用,后一项决定了交出去的东西别人能不能接手。其余几项属于质量底线,抽查十来个样本就能发现问题,成本不高。
答辩或者汇报的时候,讲述顺序比讲了多少更重要。我的习惯是倒着讲:先放一份导出的 Word,翻到中间一页,让人看到一条公告的标题、来源、日期、正文、附件整整齐齐排在那里;再回过头讲这条数据是怎么从网页变成文档的——列表页发现链接、详情页 XPath 抽取、字段清洗、upsert入库、按批次渲染。听众先看到结果,后面的技术细节才有落点,否则一上来就讲进程池和 XPath,讲了十分钟对方还不知道这东西长什么样。
被问到技术选型时,别背概念,讲取舍。为什么用 MongoDB 不用 MySQL,一句话说清:字段不稳定、附件是变长数组、不需要跨表事务。为什么用 lxml 不用 BeautifulSoup,一句话:目标页面 class 固定,XPath 一行定位到底,省掉链式调用。为什么开四个进程不开二十个,一句话:对方站点扛不住,被限流了整个任务全废。这种答法比背文档有说服力得多。
还有一个常被忽略的点:主动交代方案的边界。比如“这套流程只覆盖列表页能翻到的部分,如果站点改成无限滚动加载,得换抓包方案”;“正文里的表格结构会被拍平成纯文本,需要保表格的话要单独处理table节点”。提前把这些说清楚,比被人当场问住要体面。答辩不是证明自己无所不能,是证明自己知道边界在哪。
从那以后我每次做这类“抓取 + 存储 + 导出”的项目,都会在写第一行业务代码之前先把字段表和验收清单列出来,跑完之后照着清单逐条打勾,缺一项就不算完。这一步看着慢,实际省下的是后面反复返工的时间。希望这套流程能帮到你,拿去改改字段名和 XPath,就是你自己的方案。
本文还有配套的精品资源,点击获取