ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python多模态爬虫设计实战:从文本到PDF的采集链路

Python多模态爬虫设计实战:从文本到PDF的采集链路 先说一个真实场景。去年我在维护一套行业资讯自动归档系统每天需要把一批目标站点的内容抓下来当时觉得只要会 Python 爬虫就够了requests 拿 HTML正则清洗正文存进数据库就算完成。真正跑了两周才发现只做文本保存会把网页里最有价值的图片图表、PDF 附件全部丢掉。后来我把采集链路改成了 Python 多模态爬虫文本、图片、PDF 三类资源各走一个解析通道再统一汇入同一个数据模型整套系统才真正可用。这次改造给我最大的感受是多模态爬虫并不是把“下载图片”和“下载PDF”两个功能硬塞进旧项目而是一开始就要想清楚内容边界、处理流程和数据落盘方式。这套东西听起来唬人拆开其实就是“分类型解析 统一汇合”八个字。下面我把完整的设计思路、关键代码和踩坑记录写出来给正在做行业情报监控、本地知识库建设、文档归档这类项目的朋友做个参考。1. 先给采集需求做减法这一步决定多模态爬虫好不好用1.1 把网页内容拆成三类实体动手写代码前我建议你先回答一个问题你真正需要保存的网页内容有哪些不是“所有图片都要”也不是“所有链接都要”。以我做的资讯归档为例最终我只关心三类实体内容类型典型来源采集重点后续加工价值文本类文章正文、标题、作者、发布时间、正文中的关键结论完整结构化的纯文本和元数据检索、摘要、语义分析图片类正文配图、数据图表、产品截图原图 URL、清洗后的本地文件、尺寸与格式图表对比、PPT素材、人工复核附件类白皮书、产品手册、参考资料 PDFPDF 文件本身以及其中的文本、表格、内嵌图片深层次内容提取与归档这个分类标准的核心是“主体内容”和“页面装饰”的区分。页头背景图、icon 图标、广告 banner、分享按钮的小图标一概不抓能体现文章信息量的正文插图、数据图表必须抓挂在文章里的 PDF 附件也必须抓。不要指望一套代码自动识别所有情况建议在解析阶段先用规则粗略分类再用人工定期抽检比一开始就追求“全智能”务实得多。分类清楚之后你再去做代码结构就很容易知道每个模块的边界在哪里。1.2 “智能解析”不是堆 AI而是分路由决策很多朋友看到“智能解析”四个字就以为要上深度学习模型其实是把概念想大了。在我这个项目里“智能解析”做的事情更朴素也更重要——根据不同内容的特性把它们路由到正确的处理策略图片进来先判断是“正文配图”还是“页面装饰”再判断尺寸是否足够清晰下载后还要通过内容哈希判断是否和已有图片重复。PDF 进来先判断它是文本型 PDF 还是扫描型 PDF文本型走文本抽取和表格识别扫描型走切页转图片和 OCR。解析出的正文也要清洗掉导航、版权声明、相关文章推荐等“页壳信息”只保留文章本身的文字流。打个不恰当的比方这不是一个只会把所有东西装进同一个袋子的搬运工而是一个在快递分拣中心工作过几年的老手——看到包裹上的标签就能判断该走哪条传送带。这套规则写清楚以后每个新增网站的适配成本会大幅度降低。1.3 统一数据结构来承接三类结果多模态爬虫最容易犯的错误是“各做各的”图片存图片目录PDF 存 PDF 目录正文塞数据库最后相互之间对不上号。所以我一开始就用两个数据类把所有产物串起来。from dataclasses import dataclass, field from typing import Optional dataclass class MediaAsset: asset_type: str # image 或 pdf source_url: str # 原始下载地址 local_path: str # 本地相对路径 content_hash: str # 文件内容哈希sha1 mime_type: str size_bytes: int 0 width: Optional[int] None # 图片专用 height: Optional[int] None # 图片专用 alt_text: str # 图片的 alt 属性能留就留 referer: str # 下载时带的来源页方便追溯 dataclass class ArticleRecord: url: str title: str publish_time: str content_text: str # 清洗后的正文文本 raw_html_path: str # 原始 HTML 存档便于回查 images: list[MediaAsset] field(default_factorylist) attachments: list[MediaAsset] field(default_factorylist) extra_meta: dict field(default_factorydict)所有解析结果最终都汇聚成 ArticleRecord图片和附件挂在对应文章下面。数据库里存 URL、本地路径、哈希、状态文件系统里存真正的二进制内容。这样后面做检索、对比、删除都有据可查不会出现“文件在但不知道属于谁”的尴尬情况。2. 先解决正文与入口从 HTML 中拿到标题、正文和资源 URL2.1 正文提取工具怎么选多模态爬虫最底层的一步还是要把网页正文抓出来。很多旧项目用 requests BeautifulSoup 正则硬写遇到一个网站就要调一次维护成本很高。我更推荐先用现成的正文提取库跑一遍只有它不满足需求时再手写规则。我实际用下来排序是这样的首选 Trafilatura对新闻、博客、报告类页面效果很好能自动过滤页头页脚、相关推荐、版权声明返回正文时还附带标题、作者、日期、图片链接等元数据。它默认拿到的内容质量很干净能省大量清洗功夫。readability-lxml 也可以轻量但提取结果相对粗糙需要自己继续清洗。手写 BeautifulSoup 规则放在最后兜底只针对前两者解析质量不行的站点。import trafilatura downloaded trafilatura.fetch_url(article_url) extracted trafilatura.extract(downloaded, output_formatjson, with_metadataTrue) if extracted: data json.loads(extracted) title data.get(title) or text data.get(text) or # 图片列表和 PDF 附件还是需要自己在 HTML 里找注意 Trafilatura 的fetch_url会替你发请求但它对自定义请求头和会话控制的能力很弱所以我在正式工程里通常只用它解析“已经被 requests 拿到的 HTML 字符串”trafilatura.extract(html_content, input_formathtml, ...)。2.2 资源地址探测关注>from urllib.parse import urljoin def resolve_img_url(img_tag, base_url): for attr in (data-original, data-lazy-src, data-src, data-url, src): val img_tag.get(attr) if val and not val.startswith(data:): return urljoin(base_url, val.strip()) return None第二个是srcset响应式图片。有些页面在做高清适配真实图片可能出现在srcset里的最大分辨率项中。如果你的目标是保存原图可以解析srcset并按宽度值取最大的一项如果只是普通配图取src就够了。第三个是相对路径。无论是img src/uploads/xxx.jpg还是a href../files/report.pdf都必须用urljoin拼接成绝对 URL否则下载器根本不知道要去哪取文件。PDF 链接的识别相对简单但注意 URL 后缀可能有大写、可能有 query 参数例如download?idxxxtypepdf。我的策略是两层判断先看去掉?后的路径是否以.pdf结尾再看a标签的download属性或链接周围的说明文字是否包含“PDF”“白皮书”“下载”等关键词。前者命中概率高后者用来兜底。2.3 动态加载内容别急着上无头浏览器项目里经常遇到的另一个问题是页面正文能拿到但文章里的图片列表或 PDF 下载接口是异步加载出来的直接解析 HTML 找不到目标。很多朋友第一反应是引入 Selenium 或 Playwright打开完整浏览器再等页面渲染这种做法能用但速度慢、资源占用高、稳定性也差。我建议的顺序是先在浏览器开发者工具的 Network 面板里看请求页面的图片列表往往是通过一个 JSON API 返回的PDF 下载按钮背后可能是一个单独的下载接口。找到这些接口后用 requests 直接请求它们拿数据远比无头浏览器可靠。实际案例 某行业站的文章图片不是直接写在 HTML 里而是页面加载时请求 /api/article?idxxxfieldsimages 返回一个 JSON 数组。爬虫只需要模拟这个接口请求就能拿到所有高清原图 URL。只有当内容依赖 JavaScript 计算后才能渲染且找不到现成数据接口时我才会考虑 Playwright。用 Playwright 时也要注意设置合理的等待条件不要盲目time.sleep(5)推荐等待某个内容节点出现再继续解析。3. 图片通道从 URL 到本地文件的完整工程链路3.1 抓图前先做质量初筛不是所有图都值得下载如果把页面里所有img都丢给下载器你的带宽和磁盘都会很难受。我在图片下载器前面加了一层“质量门禁”满足条件的才进入下载队列URL 里的路径出现logo、icon、banner、sprite、emoji、avatar、placeholder等关键词的优先排除。图片还没有加载前如果能从 HTML 属性中拿到宽高width、height或style宽或高小于 300px 的直接排除因为正文插图通常不会这么小。如果图片是 base64 data URI说明它通常是小图标或动态生成的透明像素直接丢弃。万一确实有关键内容也应该由文本字段保存而不是当作独立图片文件。这个阈值不是绝对的建议做成可配置参数。比如做电商商品归档时商品主图通常是 800x800 或更高阈值可以提高到 500px但做技术博客归档时很多截图宽度只有 600px 左右阈值设太低会把有用的图丢掉。3.2 下载过程中必须做的三类校验图片下载看着简单实际坑不少。我最开始只写了一个requests.get(url)然后resp.content写文件结果一天下来存储里多了几百个“.jpg 的 HTML 登录页”。原因很简单部分网站的图片地址需要带Referer不带 Referer 时它返回一个 200 的 HTML 页面浏览器里能看到提示而我这边把 HTML 当图片存了。所以下载函数至少要加三道校验import hashlib from pathlib import Path from urllib.parse import urlparse def download_image(url, save_dir, referer, session, timeout20): headers { User-Agent: Mozilla/5.0 ..., Referer: referer, Accept: image/avif,image/webp,image/apng,image/*,*/*;q0.8, } resp session.get(url, headersheaders, timeouttimeout, streamTrue) # 第一道状态码必须是 200且是图片类型 if resp.status_code ! 200: return None content_type resp.headers.get(Content-Type, ) if not content_type.startswith(image/): resp.close() return None # 第二道限制大小避免下载超大文件 content_length int(resp.headers.get(Content-Length) or 0) if content_length 10 * 1024 * 1024: resp.close() return None # 第三道写入临时文件后用 Pillow 打开验证 data resp.content resp.close() suffix _guess_ext_by_mime(content_type, url) sha1 hashlib.sha1(data).hexdigest() tmp_path Path(save_dir) / f{sha1}.part tmp_path.write_bytes(data) from PIL import Image try: with Image.open(tmp_path) as im: im.verify() except Exception: tmp_path.unlink(missing_okTrue) return None final_path Path(save_dir) / f{sha1}{suffix} tmp_path.rename(final_path) return final_pathPillow 的verify()方法不会把图片完全解码只会校验文件头和数据完整性速度很快不会拖垮下载速度。如果文件头不对即使 Content-Type 写了 image/png也会在这个环节被拦截。3.3 内容哈希命名与跨站去重图片落盘时的命名我强烈建议不要用原始文件名。原因很常见不同站点可能有大量1.jpg、photo.jpg直接存会互相覆盖另外同一张图在不同文章、不同站点反复出现你希望它只存一份。用内容哈希做文件名最省心例如sha1前两位作为子目录避免单目录文件过多assets/images/3a/3a7c...b9f2.jpg assets/images/3a/3a7c...b9f2_1.jpg # 冲突时再叠加序号但如果只做 MD5/SHA1 级去重同一张图被站点改尺寸后缩略图、详情页大图就会存两份。对于这类需求我引入感知哈希做“相似图”判断。最常用的dhash算法会把图片缩小成 9x8 灰度图比较相邻像素亮度差生成 64 位二进制指纹。import imagehash from PIL import Image hash_a imagehash.dhash(Image.open(thumb.jpg)) hash_b imagehash.dhash(Image.open(full.jpg)) distance hash_a - hash_b if distance 5: print(两张图视觉上基本一致可以认为是同一张)在实际项目里同一张原图和它的缩略图dhash距离通常在 0 到 8 之间阈值我一般设 5 到 8。不过要提醒一点感知哈希只适合“整体缩图、轻微裁剪”的场景如果配图上叠加了完全不同的文字信息距离可能超过阈值那就只能当作两张独立图片处理。把图片 URL、内容哈希、本地路径、所属文章 URL 写进索引表后批量重命名、跨站去重、定期清理都变得很轻松。网上有人做“批量照片图片信息修改文件名工具”其实原理一样就是先用哈希识别内容再根据规则生成新文件名避免人工肉眼辨认。4. PDF 智能解析下载只是第一步路由到正确抽取策略才是重点4.1 PDF 附件的下载与命名PDF 下载前很多朋友会忽略一个问题URL 里的文件名和服务器返回的“真实文件名”可能完全不同。CDN 下载地址往往是一串无意义的 ID真实文件名在响应头Content-Disposition里。所以我保存 PDF 时会先看这个响应头取到其中的filename字段失败才用 URL 里的路径名。同时必须做文件名清洗因为文件名可能包含../、/、?等字符直接拼路径会造成目录穿越或写入异常。写一个简单的安全函数import re def safe_filename(name: str) - str: name re.sub(r[\\/:*?|\r\n], _, name) name name.strip( .) return name or unnamed.pdf保存方式也建议“先下载到.part临时文件完整后再os.replace到最终路径”。这能避免下载中断时留下半个文件并且在断点续爬时看到.part就知道它不该被当作有效附件。4.2 判断文档类型文本 PDF、扫描 PDF 和有图片层的复杂 PDFPDF 和 HTML 最大的不同在于PDF 的“视觉内容”和“文本内容”不总是一一对应的。有的 PDF
返回列表