Python爬虫进阶:图片社区站点两级流水线采集实战与避坑指南
发布时间:2026/10/31 12:44:47 锦皓数字建站

爬虫练手项目做到第三个很多人会卡在一个尴尬的位置静态页面已经能扒下来了requests加BeautifulSoup的组合用得挺顺可一旦目标站点的数据结构稍微复杂一点比如列表页和详情页分离、图片资源分散在多个属性里、分页逻辑藏在脚本中就容易抓瞎。我当初拿美空网这类图片社区站点练手就是冲着这个进阶坎去的——它不像纯文本站点那样一个find_all就完事而是逼着你去处理多层级页面跳转、字段清洗和资源定位。这篇就把我当时那套爬取思路完整拆一遍从目标分析到代码落地再到实际跑起来遇到的那些坑全部摊开讲。适合已经写过一两个静态爬虫、想往结构化数据采集方向走一步的朋友也适合拿它当模板去套其他图片类站点的采集需求。1. 为什么拿图片社区站点当第三个练手目标1.1 静态文本站和图片社区站的结构差异前两个练手项目大概率是新闻列表、天气数据这类结构规整的站点一个页面里标题、正文、时间挨得整整齐齐select一条 CSS 路径就能全捞出来。图片社区站完全是另一套逻辑列表页只给你缩略图和跳转链接真正的图片地址、作者信息、标签体系全在详情页里而且详情页的图片往往不是单个img src而是散落在>import requests session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://example.com/list/, Accept-Language: zh-CN,zh;q0.9, })这里有个细节值得说Referer别写死成详情页自己的地址要写列表页。有些站点的防盗链逻辑是校验来源页来源对不上直接返回 403。我踩过一次详情页单独请求全 403加上列表页 Referer 立刻正常排查了半小时才发现是这个原因。3.2 解析层列表抽取与详情抽取分离解析层我拆成两个函数一个负责列表页一个负责详情页互不干扰。列表函数只干一件事返回这一页所有详情页的 URL 列表。详情函数接收一个 URL返回一个字段字典。这样拆的好处是单测方便——列表函数可以拿固定 HTML 测详情函数可以拿单个页面测出问题能快速定位是哪一层。from bs4 import BeautifulSoup def parse_list(html): soup BeautifulSoup(html, html.parser) urls [] for a in soup.select(.list-item a.detail-link): href a.get(href, ).strip() if href: urls.append(href) return urls def parse_detail(html, page_url): soup BeautifulSoup(html, html.parser) data {} data[title] (soup.select_one(h1.title).get_text(stripTrue) if soup.select_one(h1.title) else ) imgs [] for img in soup.select(.gallery img): src img.get(data-original) or img.get(src) or if src: imgs.append(src) data[images] imgs data[source] page_url return data注意parse_detail里每个字段都做了空值兜底select_one返回None时不会直接.get_text()崩掉。这是容错的第一道防线后面还会在调用层再加一道 try/except。3.3 调度层循环、限速与异常隔离调度层是把上面两层串起来的主循环。核心原则是单条失败不影响整批。具体做法是每个详情页的抓取包在 try/except 里异常时记录日志并跳过继续下一条。同时加一个time.sleep做限速间隔我一般设 1 到 2 秒别贪快请求太密轻则被限流重则封 IP得不偿失。import time def crawl(start_url, max_pages5): results [] current start_url for _ in range(max_pages): resp session.get(current, timeout10) resp.encoding resp.apparent_encoding detail_urls parse_list(resp.text) for url in detail_urls: try: d session.get(url, timeout10) d.encoding d.apparent_encoding results.append(parse_detail(d.text, url)) except Exception as e: print(fskip {url}: {e}) time.sleep(1.5) current find_next_page(resp.text, current) if not current: break return resultsfind_next_page就是从当前页 HTML 里找下一页链接找不到就返回None让循环自然结束。这个设计让分页逻辑和抓取逻辑解耦站点改版时只改这一个函数。4. 跑起来之后才暴露的几个真问题4.1 编码乱码apparent_encoding 不是万能药第一次跑完标题里全是问号和乱码。原因是requests默认按响应头里的 charset 解码但不少站点响应头写的编码和实际内容编码对不上。我一开始用resp.encoding resp.apparent_encoding让库自己猜大部分页面能救回来但个别页面还是乱。后来发现是页面里meta charset声明的编码和响应头冲突apparent_encoding基于内容统计猜的遇到中英混排多的页面会猜错。更稳的做法是优先读页面里的meta charset读不到再用apparent_encoding兜底。或者干脆用resp.content拿到原始字节交给BeautifulSoup时显式指定编码让解析器自己处理。我现在的习惯是BeautifulSoup(resp.content, html.parser, from_encodingutf-8)配合resp.apparent_encoding做二次校正乱码率基本降到零。4.2 图片地址是相对路径直接下载全 404详情页里抽出来的图片地址有一半是/upload/2023/xxx.jpg这种相对路径直接拿去下载全部 404。原因是相对路径要拼上站点域名才完整。处理办法是用urllib.parse.urljoin(page_url, src)做拼接它会自动处理../、//这类情况比手动字符串拼接靠谱得多。from urllib.parse import urljoin full_src urljoin(page_url, src)这个坑很典型——解析出来的字段看着没问题一到下载环节全崩。所以我在解析层就统一做 URL 归一化存进结果里的全是绝对地址下游下载逻辑不用再操心拼接。4.3 重复抓取去重键选错导致效率腰斩前面提过详情页链接带追踪参数的问题我实际跑的时候没在意结果同一批图集被抓了两三遍。后来在调度层加了个seen集合用归一化后的 URL去掉 query 里的追踪参数做键重复的直接跳过。加完之后总请求数降了将近三成效果立竿见影。去重键的选择有讲究用完整 URL 太严同一资源不同参数算两条用纯数字 ID 太松不同资源可能撞 ID。我的经验是取域名路径去掉 query或者取路径里的核心 ID 段具体看站点结构。这个判断得在摸结构阶段就定下来别等跑完才发现重复。4.4 请求被限流从报错码反推限速策略跑到第三页左右开始出现 429 和 403。429 是明确的限流信号403 多半是触发了防盗链或频率风控。我的应对是三步先把sleep从 1 秒提到 2 秒观察是否缓解如果还不行就在请求头里补全Accept、Connection等字段让请求更像真实浏览器最后实在不行就换请求节奏比如每抓 10 条停 5 秒模拟人的浏览停顿。这里有个反直觉的经验限速不是越慢越好。太慢会导致单次任务耗时过长中途网络波动或会话过期的概率上升。我一般把单条间隔控制在 1.5 到 2.5 秒之间配合每批之间的长停顿整体成功率最高。纯靠拉长间隔硬扛反而容易在长任务里出岔子。5. 数据落地与后续可扩展的方向5.1 结构化存储先落 JSON 再入库抓下来的结果我建议先存 JSON 文件别急着往数据库塞。JSON 的好处是结构直观、方便人工抽查、出问题能直接看原始数据。等字段稳定了、清洗规则定下来了再写个脚本批量导入数据库。我当时的流程是每抓完一页就追加写一次 JSON这样即使中途崩了前面抓的也不丢。import json with open(result.json, a, encodingutf-8) as f: for item in page_results: f.write(json.dumps(item, ensure_asciiFalse) \n)用追加模式按行写JSONL 格式比一次性json.dump整个列表更抗中断。读的时候逐行json.loads就行处理大文件也不吃内存。5.2 图片下载的并发与断点续传图片下载是 IO 密集型任务串行下载几百张图能等到天荒地老。我的做法是用concurrent.futures.ThreadPoolExecutor开 4 到 8 个线程并发下别开太多太多容易触发对方风控。断点续传靠判断本地文件是否已存在且大小大于零存在就跳过这样任务中断后重跑不会重复下载。import os from concurrent.futures import ThreadPoolExecutor def download(url, save_dir): name url.split(/)[-1] path os.path.join(save_dir, name) if os.path.exists(path) and os.path.getsize(path) 0: return r session.get(url, timeout15) with open(path, wb) as f: f.write(r.content) with ThreadPoolExecutor(max_workers6) as pool: pool.map(lambda u: download(u, images), all_image_urls)文件名直接用 URL 末段有风险——不同目录下可能有同名文件会互相覆盖。稳妥点用 URL 的哈希值做文件名或者保留路径层级。我吃过一次亏几百张图下完发现只剩几十张全是同名覆盖惹的祸。5.3 从单站采集到通用模板的抽象思路这套骨架跑通之后我把它抽象成了一个通用模板请求层、列表解析、详情解析、调度、存储五个模块每个模块留出配置项选择器、字段映射、分页规则。换一个图片类站点只改配置不改代码就能跑。这个抽象过程本身就是很好的训练——你会被迫思考哪些是站点特有的、哪些是通用的边界划清楚了代码复用率自然上去。不过要提醒一句抽象别过度。我见过有人为了通用把配置写得比代码还复杂结果维护成本反而更高。我的原则是三个以上站点有共性才抽象两个站点的差异直接写两份配置更省事。这个度得自己把握多写几个项目就有感觉了。最后分享一个我踩过的小坑抓图片社区站时别在高峰期跑大批量任务对方服务器压力大时响应慢、错误率高你的重试逻辑会被频繁触发反而拖垮整体效率。挑个对方负载低的时段跑同样的限速策略成功率能高出一截。这个经验没什么技术含量但确实省了我不少重跑的时间。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。