爬虫这个活儿,写第一行代码的时候最上瘾,跑通第一个项目的时候最上头。我带的几个新人,第一个练手项目基本都是抓图片:目标明确、反馈直接、成就感来得快,图片存进文件夹那一刻,你能立刻看到自己写的循环到底干了什么事。这篇就来拆一个典型的入门项目——用 Python 抓取某写真图片站上的图片。下文我把它称作“喵站”,这只是一个方便叙述的代号,没有任何指向性,实际练手时请换成你自己搭的本地模拟站,或者明确允许抓取的公开站点,这一点后面还会反复强调。
这个项目适合谁?如果你已经会写for循环、知道什么是列表和字典、能看懂报错信息,那就可以直接上手。它不要求你懂异步、不要求你懂分布式,甚至不需要框架,requests加BeautifulSoup这两个库就能从头跑到尾。但它也不像表面上那么简单:分页怎么推、懒加载的图怎么找、防盗链怎么绕过去、文件下下来为什么是 0 字节、脚本跑两百张之后为什么开始返回 403——这些问题你迟早会撞上,而它们才是这个项目真正值钱的部分。我把整个流程从设计思路到代码落地再到踩坑排查完整走一遍,你照着抄能跑,跑完能改,改完能扩展。
1. 项目整体设计与思路拆解
1.1 为什么拿图片站当第一个爬虫练手项目
选练手项目,我一般看三个指标:结构够不够典型、反馈够不够直观、失败成本够不够低。图片站在这三点上几乎是满分。它的页面结构基本是万年不变的“列表页 → 详情页 → 资源文件”三层,你把这三层跑顺了,换成新闻站、商品站、论坛帖,套路完全通用,只是选择器换一换。这种结构上的可迁移性,比抓某个特定站点本身重要得多。
反馈直观这点更关键。文字类爬虫你得盯着终端看输出,数据对不对还得自己核对;图片爬虫不一样,脚本跑完打开文件夹,缩略图一排排躺在那里,缺了哪张、重复了哪张、有没有下成 0 字节的坏文件,肉眼一眼就看出来了。对新手来说,这种即时反馈能大幅压缩“我到底哪里写错了”的排查时间,学习效率比看十篇教程高。
失败成本低则是安全层面的考虑。图片是静态资源,你多抓一张少抓一张,对目标站点的影响很小;就算你把选择器写错了,最多是抓回来一堆占位图,删掉重来就行。相比之下,抓表单、抓接口、抓需要登录才能看的数据,一旦逻辑写错,可能产生大量无效请求甚至误操作,试错代价完全不是一个量级。所以我的建议一直是:爬虫入门,从静态图片开始,把基本功打扎实了再碰复杂场景。
1.2 技术栈选型:为什么先别急着上框架
新手最容易犯的错,是打开教程看到别人用 Scrapy,自己也跟着上。Scrapy 是好东西,管道、中间件、调度器一应俱全,但它是为“规模”设计的,你现在只抓几百张图,用 Scrapy 就像开着货车去楼下买菜——能到,但你会花大量时间在理解框架的运行机制上,而不是理解爬虫本身。
我的选型是requests + BeautifulSoup + 标准库。理由很实在:requests负责“把页面拿回来”,BeautifulSoup负责“从页面里挑出我要的东西”,剩下的下载、存盘、限速、重试,全用 Python 标准库的os、time、logging、re就够。整条链路你都能看懂每一行在干什么,没有任何“框架帮我做了但我不清楚”的黑箱。等你能徒手写出一个稳定跑一晚上的脚本,再去看 Scrapy 的文档,你会发现它的每一个设计你都能对上号——因为你已经亲手实现过简化版了。
解析器我选lxml而不是内置的html.parser。两者语法一样,lxml快好几倍,容错也更好,遇到不规范的 HTML 标签闭合问题时不容易直接崩。代价是需要额外装一下,一条pip install lxml的事。至于requests的版本,别太老就行,新版本在连接池和超时处理上更靠谱。
1.3 动手之前先划边界:哪些能爬、哪些不该碰
这一段我放在最前面讲,因为它比任何代码都重要。技术上“能不能抓”和“该不该抓”是两个完全不同的问题,很多新手把这两件事混为一谈,结果脚本写得很溜,行为却站不住脚。
我的做法是三步走。第一步,看目标站点的robots.txt,浏览器里直接访问域名/robots.txt就能看到,里面会写明哪些路径允许抓取、哪些不允许。这不是法律条文,但它是站点方明确表达的意愿,遵守它是基本的行业礼貌。第二步,看站点的服务条款里有没有关于自动化访问的说明,有明确禁止的就别碰。第三步,控制自己的请求节奏——这一条我后面会专门展开讲,简单说就是别把人家的服务器当自己的硬盘用。
还有一个容易被忽视的点:图片是有版权的。抓下来自己看、做技术练习,和二次分发、商用、二次上传,性质完全不同。我自己的习惯是,练手项目只抓公开可见的、正常展示的图片,抓完就存在本地当练习素材,不上传、不传播、不打包分享。如果是拿别人的站点练手,抓完最好把本地文件清掉,只保留代码。这个习惯听起来有点“较真”,但它是让你能长期安心做技术的前提。
提示:如果你只是想练技术,最稳妥的方案是自己搭一个本地静态站。用 Python 的
http.server起一个目录,塞几十张自己拍的或者无版权图库里的图片进去,页面结构照着常见图片站的样子写。这样做的好处是,你可以随便折腾、随便调频率、随便改选择器,完全不涉及任何他人的资源和权益。
2. 目标站点的结构拆解与页面分析
2.1 三层结构:列表页、详情页、图片资源
绝大多数图片站的组织方式都是三层。第一层是列表页,一页展示十几个到几十个图集的封面,每个封面链到一个详情页,底部有分页器。第二层是详情页,进来之后是这个图集的所有图片,通常是缩略图网格或者纵向长列表,图片多的还会再分页。第三层是图片资源本身,也就是那个.jpg或.webp文件,它可能和详情页同域,也可能放在一个独立的图片域名下。
理解这三层,你就知道脚本的主循环长什么样了:遍历列表页 → 拿到详情页链接集合 → 逐个进详情页 → 拿到图片地址集合 → 逐个下载。这是一个典型的两层嵌套循环,外层是图集,内层是图片。我在纸上画这个结构的时候,会同时标出每一层的“入口 URL”和“分页参数”,因为这两样东西决定了脚本能不能自动往后翻。
有个细节值得注意:有些站点的列表页和详情页是同一个模板,只是参数不同;有些站点的“详情页”其实是列表页的一个弹层,URL 根本没变。遇到后者,你就不能靠“进详情页”这个动作拿数据,得改成从列表页的 DOM 里直接抠图片地址。这个判断必须在写代码之前做完,否则你写完一整套两层循环,跑起来发现详情页永远是同一张图,白忙一场。
2.2 用开发者工具锁定关键节点
打开目标页面,按 F12 或者右键“检查”,这是整个项目里最需要耐心的一步。我的习惯是先看元素面板,把鼠标在 HTML 结构上移动,页面上对应的区块会高亮,靠这个动作快速定位到“图集封面”这个容器。找到容器之后,往里看它的 class 名,通常是有语义的,比如album-list、gallery-item这种。
找到容器还不够,你要找的是那个<a>标签的href,以及<img>标签的src。这里有个坑要提前说:很多站点的<img>标签上,src里放的是一张极小的占位图(base64 编码的灰色方块,或者一个loading.gif),真正的图片地址在>pip install requests beautifulsoup4 lxml
装完验证一下,进 Python 交互环境敲import requests, bs4, lxml,没报错就成。这里提醒一句,如果你电脑上同时装了多个 Python 版本,注意pip和python是不是指向同一个环境,我见过太多“明明装了却 import 不到”的问题,全是环境错位导致的。
3.2 会话、请求头与超时设置
用requests.Session()而不是每次都requests.get(),原因有两个:一是复用 TCP 连接,抓几十张图的时候能明显感觉到速度差异;二是Session会自动保持 Cookie,某些站点第一次访问会下发一个会话标识,后续请求带着它才认,用 Session 就省了这个手动处理。
请求头是重头戏。至少要带User-Agent,这是最基本的身份声明;带Accept-Language能让返回的页面语言更符合预期;Referer在下载图片时经常是必须的——很多站点的图片服务器会校验来源页,没有 Referer 直接返回 403。Referer的值填图片所在的那个详情页地址,不是站点首页,这个细节很多人搞错。
超时一定要设,而且要设成元组(连接超时, 读取超时)。我一般用(5, 15),意思是 5 秒内连不上就放弃,连上了但 15 秒没读完数据也放弃。不设超时的后果是,遇到一个卡住的连接,整个脚本就挂在那里不动,你盯着屏幕看不出任何异常,还以为程序在正常工作。
import requests def build_session(): s = requests.Session() s.headers.update({ "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.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", }) adapter = requests.adapters.HTTPAdapter(pool_connections=10, pool_maxsize=10) s.mount("http://", adapter) s.mount("https://", adapter) return s连接池这两个参数也顺便设一下。默认的连接池只有 10 个连接,如果你后面想加速改成多线程,不调这个参数会频繁触发“连接池已满”的警告,虽然不影响结果,但日志里一堆警告看着烦。
3.3 列表页解析:把详情页链接捞出来
这一步的核心是选择器。假设列表页的每个图集封面是这样的结构:<div class="album-list">里面一堆<a class="album-cover" href="/album/12345/">。那选择器就写div.album-list a.album-cover,取href属性。
拿到href之后必须做一件容易被忽略的事:用urljoin拼成绝对地址。页面里写的往往是/album/12345/这种相对路径,你直接拿去请求会失败。urljoin(当前页URL, href)会自动处理各种情况,包括../这种上级目录、//cdn.xxx.com/这种协议相对地址,比手写字符串拼接靠谱得多。
from urllib.parse import urljoin from bs4 import BeautifulSoup def parse_list(session, list_url): resp = fetch(session, list_url) if resp is None: return [] resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "lxml") links, seen = [], set() for a in soup.select("div.album-list a.album-cover"): href = (a.get("href") or "").strip() if not href or href.startswith(("#", "javascript:")): continue full = urljoin(list_url, href) if full not in seen: seen.add(full) links.append(full) return linksresp.encoding = resp.apparent_encoding这行别省。requests默认按响应头的Content-Type猜编码,猜错的概率不低,尤其是一些老站点,声明是ISO-8859-1实际是UTF-8,结果就是你解析出来的中文全是乱码,选择器当然匹配不到。apparent_encoding是从内容本身探测编码,准确率高得多,代价是多花几十毫秒。这个取舍我觉得很值。
3.4 详情页解析:拿到图片的真实地址
详情页的解析逻辑和列表页类似,但多了一层懒加载的判断。我的写法是按优先级依次尝试三个属性,取到第一个有效的就用:
ATTR_CANDIDATES = ("data-original", "data-src", "data-lazy-src", "src") def parse_detail(session, detail_url): resp = fetch(session, detail_url, referer=detail_url) if resp is None: return [] resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "lxml") urls = [] for img in soup.select("div.gallery-item img, div.gallery img"): src = "" for attr in ATTR_CANDIDATES: val = (img.get(attr) or "").strip() if val and not val.startswith("data:"): src = val break if not src: continue urls.append(urljoin(detail_url, src)) return urls这里有两个细节。一是startswith("data:")的过滤,懒加载占位图基本都是 base64 内联的,特征是data:image/gif;base64,...,不排除的话你会把一堆几十字节的灰方块当成图片下下来。二是选择器我写了两个并用逗号隔开,这是 CSS 选择器的“或”语法,意思是两种容器结构都匹配。实际项目里容器 class 改名是常事,多写几个备用选择器,能让脚本在站点小改版后依然活着。
3.5 下载落盘:流式写入与文件名治理
下载环节有两个必须做的动作:流式写入和文件名清洗。流式写入是指用stream=True配合iter_content,别用resp.content一次性读到内存。一张图几百 KB 无所谓,但如果你后面扩展到视频或者大图,一次性读内存很容易把内存吃满。iter_content(chunk_size=8192)每次读 8KB 写进文件,内存占用是恒定的小数值。
文件名清洗是另一个坑。图片文件名可能包含?、:、*这些在 Windows 下非法的字符,也可能带一串?w=800&h=1200的查询参数。我的处理是先urlparse取 path 部分丢掉查询串,再用正则把非法字符替换成下划线,最后限制长度——Windows 的完整路径有 260 字符限制,图集名加上图片名很容易超,超了会报“文件名过长”的错误,而且这个错误在 Linux 上根本复现不出来,等部署到别的机器上才炸。
import os, re from urllib.parse import urlparse INVALID_CHARS = re.compile(r'[\\/:*?"<>|\r\n\t]') def safe_name(name, limit=80): name = INVALID_CHARS.sub("_", name).strip(" .") return name[:limit] or "unnamed" def download(session, img_url, save_dir, referer=None): path_part = urlparse(img_url).path filename = safe_name(os.path.basename(path_part)) if not filename or "." not in filename: filename = safe_name(str(abs(hash(img_url)))) + ".jpg" path = os.path.join(save_dir, filename) if os.path.exists(path) and os.path.getsize(path) > 0: return "skip" resp = fetch(session, img_url, referer=referer) if resp is None: return "fail" tmp = path + ".part" with open(tmp, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) if os.path.getsize(tmp) == 0: os.remove(tmp) return "empty" os.replace(tmp, path) return "ok"先写.part临时文件、校验非空之后再os.replace重命名,这个模式是我从实际踩坑里总结出来的。直接写目标文件的话,一旦中途断网或者程序崩溃,你会得到一个半截的损坏文件,而os.path.exists判断又会认为它已经下过了,下次直接跳过,这个坏文件就永远留在那里了。用临时文件,中途失败最多留下一个.part,清理一下就行,目标文件永远要么不存在、要么是完整的。
3.6 限速、重试与日志:让脚本能跑一整晚
限速这件事我需要说得重一点。很多新手写出来的脚本是这样的:一个for循环,几百个请求瞬间发出去。在自己电脑上跑没感觉,但如果目标是个小站点,这基本等同于一次轻量压测。轻则你的 IP 被临时限制,重则影响别人正常访问,这就不合适了。我的默认参数是每次请求之间sleep(1.2 + random.random() * 0.8),也就是 1.2 到 2.0 秒之间的随机间隔。为什么要随机?固定间隔的请求序列特征太明显,从流量上看就像机器在打节拍;随机化之后更接近正常用户的浏览节奏。
重试的策略是“只对可恢复的错误重试”。连接超时、读取超时、502、503 这些值得重试,通常是网络抖动或服务端短时过载;403、404 重试没意义,403 多半是请求头不对或者被限了,404 就是页面没了,重试一百次也还是 404。重试间隔用指数退避,第一次等 2 秒,第二次等 4 秒,第三次 8 秒,给服务端喘息时间。
import time, random, logging def polite_sleep(base=1.2, jitter=0.8): time.sleep(base + random.random() * jitter) def fetch(session, url, referer=None, retries=3, timeout=(5, 15)): for attempt in range(1, retries + 1): try: headers = {"Referer": referer} if referer else {} resp = session.get(url, headers=headers, timeout=timeout, stream=False) if resp.status_code == 200: return resp if resp.status_code in (403, 404, 410): logging.warning("状态码 %s,放弃:%s", resp.status_code, url) return None logging.warning("状态码 %s,第 %d 次重试:%s", resp.status_code, attempt, url) except requests.RequestException as exc: logging.warning("请求异常(第 %d 次)%s:%s", attempt, url, exc) time.sleep(2 ** attempt) return None日志用标准库的logging,别用print。原因很实际:print打到终端,脚本挂后台跑的时候你什么都看不到;logging可以同时输出到终端和文件,跑完之后翻run.log就知道哪张图失败了、失败原因是什么。格式我一般用时间 - 级别 - 消息,方便按时间排序定位。
4. 全套代码串起来:一个可直接复现的完整脚本
4.1 完整可跑的主流程
前面拆开讲的各个函数,串起来就是完整脚本。核心是两层循环加上去重集合,另外我把“已抓过的详情页”持久化到文件里,这样中断之后重跑不会从头再来。
import os, logging BASE = "https://www.example.com" # 换成你自己的模拟站地址 LIST_TPL = BASE + "/album/page/{page}/" # 分页模板 SAVE_ROOT = "downloads" SEEN_FILE = "seen.txt" logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", handlers=[logging.FileHandler("run.log", encoding="utf-8"), logging.StreamHandler()], ) def load_seen(): if not os.path.exists(SEEN_FILE): return set() with open(SEEN_FILE, "r", encoding="utf-8") as f: return {line.strip() for line in f if line.strip()} def mark_seen(url): with open(SEEN_FILE, "a", encoding="utf-8") as f: f.write(url + "\n") def run(max_pages=20, empty_tolerance=2): session = build_session() seen = load_seen() empty_streak = 0 for page in range(1, max_pages + 1): list_url = LIST_TPL.format(page=page) logging.info("开始解析列表页:%s", list_url) album_links = parse_list(session, list_url) polite_sleep() fresh = [u for u in album_links if u not in seen] if not fresh: empty_streak += 1 logging.info("第 %d 页无新内容,连续空页 %d", page, empty_streak) if empty_streak >= empty_tolerance: logging.info("连续空页达到阈值,结束抓取") break continue empty_streak = 0 for album_url in fresh: album_name = safe_name(album_url.rstrip("/").split("/")[-1]) save_dir = os.path.join(SAVE_ROOT, album_name) os.makedirs(save_dir, exist_ok=True) img_urls = parse_detail(session, album_url) polite_sleep() logging.info("图集 %s 共解析到 %d 张图片", album_name, len(img_urls)) stat = {"ok": 0, "skip": 0, "fail": 0, "empty": 0} for i, img_url in enumerate(img_urls, 1): result = download(session, img_url, save_dir, referer=album_url) stat[result] = stat.get(result, 0) + 1 if result in ("fail", "empty"): logging.warning("第 %d 张异常(%s):%s", i, result, img_url) polite_sleep(0.8, 0.6) mark_seen(album_url) logging.info("图集 %s 完成:成功 %d / 跳过 %d / 失败 %d / 空文件 %d", album_name, stat["ok"], stat["skip"], stat["fail"], stat["empty"]) if __name__ == "__main__": os.makedirs(SAVE_ROOT, exist_ok=True) run()4.2 跑起来之后怎么观察、怎么调
第一次跑,别急着放开页数。我的习惯是先把max_pages设成 1,就抓一页,看看输出。终端上应该能看到“开始解析列表页”“图集 xxx 共解析到 N 张图片”“图集 xxx 完成”这几行日志。然后去downloads/目录,随便点开几个子文件夹,看图片是不是真的图片——右键属性看大小,几百 KB 到几 MB 是正常的,如果是几 KB,八成是下到了占位图或者错误页。
如果解析到的图片数是 0,先别改代码,去run.log里看parse_detail那一步有没有报错。如果没报错但就是 0,那就是选择器没匹配上。这时候最快的排查方式是把resp.text存到本地文件,用编辑器打开搜一下你预期的 class 名,看看实际结构和你以为的差在哪。这个“把 HTML 落盘再肉眼比对”的动作,比在代码里加十个print都管用。
调通了单页,再把max_pages放到 3 页、5 页,观察每页耗时和成功率。如果发现从第 2 页开始大面积 403,那大概率是触发限流了,把polite_sleep的参数往上调,比如改成(2.5, 1.5),也就是 2.5 到 4 秒一次。别心疼时间,一个跑一小时但成功率 95% 的脚本,比一个跑十分钟但成功率 60%、还得人工补漏的脚本划算得多。
4.3 增量爬取与断点续传的两层保障
seen.txt是第一层保障,它记录的是“这个图集我处理过了”,粒度是图集级。有了它,脚本中断之后重跑,会从还没处理过的图集开始,已经完成的不会重复抓。这个文件是纯文本,一行一个 URL,你可以随时手动编辑——比如某个图集抓了一半失败了,把那一行删掉,重跑时就会重新处理。
第二层保障是download里的文件存在性检查,粒度是图片级。就算某个图集处理到一半断了,seen.txt里没有它,重跑时会重新进这个详情页,但已经下好的图片会被os.path.exists判断跳过,只补下缺的那几张。这两层加起来,整个脚本的任何时刻中断,重跑的成本都很低。
有个细节值得说:判断“已下载”时我用了os.path.getsize(path) > 0而不是单纯的存在性判断。原因前面提过,如果历史遗留了 0 字节的坏文件,只看存在性会永远跳过它。加上大小判断,坏文件会被重新下载覆盖。这种“防御性判断”写起来多几行,但能省掉很多莫名其妙的“为什么这张图打不开”的排查时间。
5. 常见问题与排查技巧实录
5.1 状态码速查表
状态码是排查的第一手信息,我把实际遇到过的情况整理成表,遇到问题先对着查:
| 状态码 | 典型原因 | 排查方向 | 处理方式 |
|---|---|---|---|
| 200 但内容为空壳 | 页面靠 JS 渲染数据 | 查看源码里能否搜到图片名 | 找接口或换目标 |
| 200 但图片是占位图 | 懒加载,真地址在> RELATED READING 延伸阅读更多一线实战笔记与深度复盘,助您持续精进 |