Python异步爬虫实战:动态渲染页面表情包批量下载

发布时间:2026/9/9 6:17:38
Python异步爬虫实战:动态渲染页面表情包批量下载 做Python网络爬虫这么久我一直觉得“表情包批量获取”是一个被低估的练手项目。它表面上只是把一堆图片下载到本地实际却能把网络爬虫里最磨人的几个环节全串起来——动态渲染页面怎么拿到真实地址、异步并发怎么控制节奏、下载失败怎么自动重试、几千张图片怎么不重不漏地保存好。写这篇文章的起因是前两周有人问我很多表情包站点是JS动态加载的直接用requests打开只能看到一堆空壳要怎么抓我当时就手写了一个基于异步技术加动态渲染的脚本从拿到页面到把全套表情包下到硬盘整个过程比想象中顺畅。今天就把这个项目的设计思路、核心代码和踩坑记录整理出来适合有一定Python基础、想系统练一练异步爬虫和动态渲染的朋友参考。1. 项目整体设计与思路拆解1.1 这个项目到底在解决什么问题先说你遇到的典型场景。你打开一个表情包聚合站页面滚动几次图片不断加载看起来很方便。可真要批量保存的时候麻烦就来了手动右键另存为一张张点几十张还能忍几百上千张直接让人崩溃。更麻烦的是这类站点基本都做了懒加载图片不是一个静态HTML里全写死的而是页面滚动到某个位置才触发请求把真实图片地址动态塞进DOM。你用最基础的 requests BeautifulSoup 去抓拿到的是没有表情包的“空壳页面”根本提取不到完整图片链接。所以这个项目要解决的核心问题有三个第一让Python脚本像浏览器一样执行JavaScript拿到动态渲染之后的完整页面数据第二把图片下载过程改成异步并发充分利用网络等待时间而不是一张张排队下载第三做好去重、断点续传和失败重试保证几千张图片下载下来不重、不漏、不损坏。另外还有一层不太显眼但很重要的需求防封控。大批量快速请求不管是页面接口还是图片CDN都有可能触发限流。异步技术不光是提速更要配合信号量、延时、重试策略让整个抓取过程既快又稳。1.2 为什么选异步技术加动态渲染这条技术路线如果你只是想抓一个纯静态页面图片链接全在HTML源代码里那确实没必要上动态渲染。但实际表情包站点几乎都逃不开两种情况一种是图片列表由接口异步返回需要分析XHR请求再去拼参数另一种是接口加密严重直接抓接口很费劲不如让浏览器自己跑完JS再读取渲染后的DOM。第二种情况下轻量级的requests方案基本要废弃所以这里选择了Playwright做动态渲染。异步技术则是因为下载场景天然适合协程。图片下载是典型的I/O密集型操作网络等待时间占大头CPU基本闲着。传统多线程能解决问题但线程切换有开销线程数量一多内存和上下文切换的压力就上来了。多进程更重适合计算密集型任务用来下载图片有点杀鸡用牛刀。Python协程则可以在一个线程内用事件循环管理成千上万个连接遇到网络I/O就挂起数据到了再继续执行。配合Semaphore信号量控制并发数量既快又可控。你也可以把异步理解为“同时向服务器发起20个下载请求但并不是开20个线程而是让一个线程在这20个任务之间快速切换”。每个任务的网络等待时间被其他任务利用起来整体吞吐量就有了质的提升。1.3 技术栈选型与各组件职责这个项目我用到的核心组件如下Playwright负责动态渲染。它的职责是打开浏览器、访问页面、等待JavaScript执行完、提取最终渲染后的图片节点。httpx负责图片下载。它同时支持同步和异步API异步下载时用AsyncClient能复用连接池性能表现比每次请求都新建连接好很多。BeautifulSoup4 lxml负责解析HTML。虽然Playwright本身可以用选择器提取元素但配合BeautifulSoup做数据清洗和整理会更灵活。hashlib生成文件名的哈希值天然去重。asyncioPython标准库里的异步框架用来编排所有下载任务。这里有个选型心得曾有人问我为什么不用aiohttp。aiohttp也很成熟但httpx的API风格更接近requests上手成本低而且原生支持HTTP/2和流式下载在下载大图时能把数据一块块写入文件不会一次性把所有二进制数据都塞进内存。对表情包这种动辄几MB的图片流式下载能明显降低内存占用。2. 核心细节解析与实操要点2.1 动态页面与静态页面的核心区别一开始接触爬虫的人经常混淆“页面源代码”和“浏览器里看到的页面”。静态页面的内容直接写在HTML文件里你用requests拿到的响应和浏览器看到的差不多。动态页面的HTML只是一个空壳真正的数据是页面加载后JavaScript发请求、改DOM得到的。判断一个页面是不是动态渲染有个很直接的方法在浏览器里右键“查看网页源代码”如果能看到表情包图片地址基本就是静态的如果源代码里只有一堆script标签和空容器说明数据靠JS动态填充。更常见的还有懒加载图片真正地址放在>import asyncio import hashlib from pathlib import Path from urllib.parse import urlparse import httpx SAVE_DIR Path(expressions) SAVE_DIR.mkdir(exist_okTrue) SEM asyncio.Semaphore(12) def safe_filename(url: str, index: int) - str: ext Path(urlparse(url).path).suffix.lower() if ext not in {.jpg, .jpeg, .png, .gif, .webp}: ext .jpg name_hash hashlib.md5(url.encode(utf-8)).hexdigest()[:16] return f{index:04d}_{name_hash}{ext} async def download_one(client: httpx.AsyncClient, url: str, index: int): filename safe_filename(url, index) filepath SAVE_DIR / filename if filepath.exists() and filepath.stat().st_size 0: print(fskip exist: {filename}) return False async with SEM: for attempt in range(3): try: async with client.stream(GET, url) as resp: if resp.status_code ! 200: raise RuntimeError(fHTTP {resp.status_code}) temp_path SAVE_DIR / f{filename}.part with open(temp_path, wb) as f: async for chunk in resp.aiter_bytes(): f.write(chunk) temp_path.rename(filepath) return True except Exception as exc: print(f[attempt {attempt 1}] failed: {url}, reason: {exc}) if attempt 2: await asyncio.sleep(2 ** attempt) return False2.3 请求头、超时与重试机制为什么不能省很多初学者下载图片时不带请求头结果拿回来的是一张错误提示图或者403页面。原因是不少图床做了防盗链会检查Referer是不是来自它自己的页面。所以下载表情包时请求头至少要带上User-Agent和Referer伪装成一个真实浏览器。超时也必须设置。如果不设超时某个连接卡死了整个协程会一直挂在那里事件循环里其他任务也会受影响。我一般用httpx.Timeout(30.0)连接超时和读取超时都控制在合理范围。配合重试机制单张图片最多尝试3次每次失败后等待时间按指数退避第一次等1秒第二次等2秒。这样既能扛住偶发网络波动又不会对服务器造成太大压力。重试时要注意一个细节下载到一半失败时残留的.part临时文件要保留还是删除我的习惯是不删除因为下一次重试会用同一个filename.part覆盖写入。但如果失败次数用尽了脚本结束之后最好清理掉所有.part文件免得下次运行时误判。2.4 文件命名、去重与目录管理表情包文件名五花八门有些是乱码有些带中文有些甚至没有扩展名。直接用原始文件名保存很容易遇到文件系统不允许的字符或者重名覆盖。所以这个项目里我用“序号 URL哈希 扩展名”作为文件名。序号方便排序哈希保证同一个URL不会重复下载扩展名从URL路径里解析。如果URL里没有合理扩展名就默认存成jpg。去重方面我做了两层判断。第一层在提取URL阶段用集合去重同一个URL只保留一次。第二层在下载阶段检查目标文件是否已存在且大小不为0如果存在就跳过。这样脚本中断了、图片下载失败了重新跑一遍也不需要把已完成的图片再下一遍。目录管理也值得注意。如果一次抓取几百页所有图片堆在一个文件夹里后续根本没法管理。我建议按照“站点名/分类/日期”这种层级建目录或者在文件名前加上页码。比如每抓取一页先创建page_01这样的子目录再把本页的图片放进去。虽然多了一点代码量但后期整理起来会轻松很多。3. 实操过程与核心环节实现3.1 环境准备与依赖安装这是个手把手可以复现的项目环境不用太复杂。首先确保电脑里已经装了Python 3.9以上版本然后用虚拟环境隔离依赖。如果你用的是VSCode建议先在VSCode里配置好Python解释器再打开终端执行下面的命令。Windows环境注意PowerShell和CMD的激活命令略有不同。python -m venv .venv # Windows .venv\Scripts\activate # Linux / macOS source .venv/bin/activate pip install playwright httpx beautifulsoup4 lxml playwright install chromiumplaywright install chromium这步一定要执行它会下载浏览器内核。下载时间取决于网络情况耐心等一会儿就好。装完之后可以跑一个极简测试打开百度首页打印标题如果能看到标题说明Playwright环境没问题。import asyncio from playwright.async_api import async_playwright async def test_playwright(): async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) page await browser.new_page() await page.goto(https://example.com) print(await page.title()) await browser.close() asyncio.run(test_playwright())3.2 用Playwright获取动态渲染后的页面数据下面这段代码是抓取流程的第一阶段遍历多个页面等待图片节点出现然后把图片地址收集到一个列表里。这里的example.com只是示意实际使用时替换成你自己的目标站点。async def fetch_image_urls(start_page: int, end_page: int) - list[str]: image_urls [] seen set() async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) page await browser.new_page() for page_no in range(start_page, end_page 1): target_url fhttps://example.com/expressions?page{page_no} print(fcrawling page {page_no}) await page.goto(target_url, wait_untildomcontentloaded) await page.wait_for_selector(img.expression-image, timeout15000) items await page.query_selector_all(img.expression-image) for item in items: src await item.get_attribute(data-src) or await item.get_attribute(src) if src and src.startswith(http) and src not in seen: seen.add(src) image_urls.append(src) print(fpage {page_no}: collected {len(items)} images, total {len(image_urls)}) await browser.close() return image_urls这里的wait_for_selector是重点。它告诉Playwright轮询页面直到能找到img.expression-image这个选择器对应的元素。选择器可以从浏览器的开发者工具里复制也可以根据页面结构自己写。如果15秒内还没出现会抛超时异常。超时后别急着加等待时间先看看是不是选择器写错了。有些图片是背景图元素不是img而是div地址写在style属性里。这时候选择器要改成div.expression-item再用正则从style里把url(...)提取出来。处理方式略有不同但整体思路一致先确认DOM结构再写提取逻辑。3.3 解析表情包链接与信息清洗我自己习惯把页面交给BeautifulSoup再做一轮清洗因为有时候Playwright拿到的属性值里带有缩进、换行、引号等杂质。比如>from bs4 import BeautifulSoup def clean_urls(raw_urls: list[str]) - list[str]: cleaned [] for url in raw_urls: url url.strip() if url.startswith(//): url https: url if url.startswith(http://) or url.startswith(https://): cleaned.append(url) return list(dict.fromkeys(cleaned))这里也适合做内容筛选。如果你只想要gif动图就用suffix in (.gif, .webp)过滤一遍如果只想要某一类表情包可以在URL路径里匹配关键词。这一步早点做能减少无效下载节省带宽和时间。3.4 异步批量下载图片的全过程拿到URL列表后异步下载的主流程特别简单创建AsyncClient把每个URL包装成协程再用asyncio.gather一起执行。async def main(): urls await fetch_image_urls(1, 10) urls clean_urls(urls) print(ftotal urls to download: {len(urls)}) headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/, } async with httpx.AsyncClient( headersheaders, timeouthttpx.Timeout(30.0), follow_redirectsTrue, ) as client: tasks [ download_one(client, url, index) for index, url in enumerate(urls) ] results await asyncio.gather(*tasks, return_exceptionsTrue) downloaded sum(1 for r in results if r is True) print(fdownload finished: {downloaded} new files) if __name__ __main__: asyncio.run(main())几点说明。follow_redirectsTrue必须开很多图床会把图片地址重定向到CDN不开的话拿不到最终内容。gather的return_exceptionsTrue也很关键它保证单个任务异常不会影响其他任务否则一个烂链接就能让整个下载任务崩掉。下载逻辑里已经把异常在协程内部消化了返回True或False所以外层其实不会拿到太多异常。关于事件循环的关闭asyncio.run(main())会自动创建和关闭事件循环不需要手动调用loop.close()。如果自己用asyncio.new_event_loop()一定要在程序退出前关闭否则容易看到Event loop is closed的警告。3.5 断点续传与多轮脚本运行的实现这个项目的核心要求是“大规模”几百上千张图片很难保证一次跑完。断点续传是我认为这个项目里最有价值的细节比单纯的下载速度更能体现工程的完整性。实现方案其实很简单下载前检查目标文件是否已存在并且大小大于0下载中先写入.part临时文件全部写完后重命名为正式文件名。这样即使中途断电、断网、脚本被手动终止已下载完成的文件不会被破坏没有完成的只是留下一个.part文件重新运行脚本时会从零开始覆盖写入。另一个实用做法是把失败的URL写到一个文本文件里比如failed.txt。下载结束后读一遍这个文件把失败项再跑一轮。对于动辄几千张图片的任务这个机制能救命。我第一次跑这个脚本时没有加失败记录结果最后发现40多张图片静默失败了排查半天才定位到是Referer不对只能重新跑一遍浪费时间。4. 常见问题与排查技巧实录4.1 出现SSL证书校验错误怎么办httpx对SSL证书校验比较严格有些图床的证书链不完整会抛出certificate verify failed。这时候很多人的第一反应是设置verifyFalse确实能临时解决问题但我不建议在正式脚本里这么做因为它会让你所有请求都失去证书校验存在中间人攻击风险。更稳妥的办法是先用浏览器打开目标图片地址看看是不是真的证书有问题。有些时候是系统根证书过期升级Python或者更新系统证书就能解决。如果只是个别图床的问题可以单独给这个域名配置一个SSLContext而不是全局关闭校验。4.2 图片下载到一半就结束文件损坏出现过好几次这样的情况HTTP状态码是200文件也下载了但打开图片提示文件损坏。原因通常是重定向或压缩问题。CDN返回的内容不一定是图片本身可能是错误页、压缩包或者一个302跳转。所以下载时一定要检查响应的Content-Type应该是image/jpeg、image/png这类类型如果返回text/html说明拿到的根本不是图片。另一个原因是磁盘空间不足。当并发数量调得很大、图片又比较大时临时文件会占用大量磁盘空间。我的建议是下载完成后先检查文件大小如果小于几KB多半就是错误响应直接删掉并记录到失败列表。if filepath.stat().st_size 1024: filepath.unlink() print(funlink too small file: {filename})4.3 动态内容一直加载不出来如果wait_for_selector超时先不要盲目把超时时间从15秒改成60秒那样只会让脚本卡得更久。建议进入调试模式把headlessFalse打开让浏览器窗口显示出来亲自观察页面加载过程。你可能会看到两种情况页面里有个验证码弹窗需要人工处理或者图片懒加载机制是“滚动到可视区域才触发”从来没触发过滚动事件。对于懒加载页面可以在Playwright里执行滚动操作模拟用户往下翻页。简单的做法是用page.mouse.wheel(0, 3000)滚几次或者执行JS把所有图片的src从>async def download_one(client, url, index): async with SEM: await asyncio.sleep(random.uniform(0.1, 0.6)) # 原有下载逻辑随机延时的作用是让请求在时间轴上分布得更自然。另外如果被封的对象是整个IP断网重拨或者等待一段时间恢复是常见办法。这个项目本身以学习和练手为主不建议也不讨论任何绕过反爬的手段合规和克制才是长期能稳定运行的前提。4.5 常见问题速查表症状可能原因解决办法页面拿不到图片元素选择器写错或懒加载未触发调试模式查看DOM模拟滚动下载返回403缺少Referer或User-Agent带上完整浏览器请求头图片文件无法打开响应不是图片内容检查Content-Type设置重试连接卡住不结束超时未设置设置httpx.Timeout并重试磁盘被占满并发过高导致临时文件积压降低并发清理.part重复下载大量图片没有做去重URL集合去重文件hash命名5. 避坑经验与扩展方向5.1 我在实操中踩过的三个比较典型的坑第一个坑是复用连接。最初我每下载一张图片就新建一个httpx.Client下载到200多张时明显感觉速度下降CPU占用也不正常。后来改成复用AsyncClient整个脚本只用一次连接池速度立刻提上来了。连接池能复用TCP连接省掉了反复握手的时间对大规模下载提升非常明显。第二个坑是URL里的问号参数导致扩展名识别失败。有些图片地址类似https://cdn.example.com/photo?id12345我用urlparse(url).path取不到.jpg后缀默认存成了.jpg但实际内容是PNG或者WebP结果图片能打开只是扩展名不匹配。后来用系统库或者依赖响应头的Content-Type来推断扩展名顺便把format转成统一格式问题就解决了。第三个坑是页面编码问题。BeautifulSoup在解析网页时如果页面声明的是UTF-8但实际内容是GBK中文标题会乱码。虽然表情包URL大多是英文数字但万一以后扩展成抓取标题、分类信息编码问题就绕不过去。稳妥的做法是在page.goto()之后用page.content()获取整个渲染后的HTML再交给BeautifulSoup让BeautifulSoup按照页面meta标签自动识别编码。5.2 这个项目后续还能怎么扩展表情包批量获取只是异步爬虫的一个切入场景把这个项目吃透之后稍加改动就能迁移到很多其他场景。比如把输入从固定URL改成关键词搜索就能做成“关键词表情包下载器”把解析规则抽象成配置文件就能适配不同站点。下载下来的图片还能继续做数据清洗、分类打标甚至拿去训练一个表情包分类模型整个链路就更有意思了。从工程化角度可以把URL列表存进SQLite下载状态用数据库字段标记进度显示可以用tqdm库跑起来更直观多分类站点可以考虑用 playwright 的browser.new_context()开多个隔离上下文互不干扰。异步架构里还能加入任务队列比如用asyncio.Queue缓存待下载URL生产者不断往队列里放消费者按优先级下载。另外一个扩展点是保存策略。现在图片直接落本地磁盘图片量大了以后可以改造成先下载到临时目录校验完毕后移动到正式目录定期做增量备份。对于动辄几个GB的表情包库这个习惯能避免很多重复劳动。最后分享一个小技巧写这种批量下载脚本时我在每个环节都会打印进度日志并且把日志同步写入一个download.log文件。这样脚本跑到一半出了异常不用一直盯着终端回头翻日志就能定位是哪一张图片、哪个请求失败。异步程序的错误不像同步代码那么直观日志越详细排查越快。我个人的体会是爬虫项目的核心从来不是“能不能爬到”而是“能不能稳定、高效、可维护地爬到”。异步技术负责高效动态渲染负责解决稳定性里的页面加载问题而真正让脚本长时间跑不翻车的是那些看起来不起眼的信号量、重试、临时文件处理和日志。希望这份记录能帮你少走点弯路也欢迎你在这个思路上改造出更适合自己使用习惯的版本。