Python爬虫实战:从零搭建开源插画资源聚合库
从“收藏夹吃灰”到“本地聚合库”这个爬虫项目值得做我猜很多设计师、自媒体运营还有刚入门的Python同学收藏夹里都躺着十几个免费可商用的插画网站。真到用的时候不是这个站打不开就是那个站检索逻辑反人类更别说有些开源图库的资源质量参差不齐一页一页翻下来纯属浪费时间。所以我自己动手写了一个Python爬虫项目把分散在各个开源插画站的好东西按标签、作者、色系统一抓进本地数据库再用一个简单的聚合检索入口管理起来。今天就把这套从零到一搭建私有“开源插画资源聚合库”的完整思路、核心代码和踩着过的坑全部捋一遍。不管你是刚学爬虫的初学者还是想优化素材管理流程的设计师这篇都值得花十分钟看完。1. 项目定位与技术选型1.1 聚合库解决的核心痛点先把这个项目的起点说清楚。市面上的插画资源站模式大概分三类第一种是类似国际知名图库那种需要订阅会员的资源全但费用高第二种是各个独立设计师个人站风格鲜明但体量小、检索难第三种就是真正开源协议友好的聚合型社区比如一些收录CC0协议的插画平台。前两种在授权合规性和管理成本上都有问题我们普通人最现实的需求其实是第三种——把合法可商用、来源分散、风格各异的开源协议插画聚合到一个地方用统一规则去重、打标签、归类告别一个站点一个站点翻的原始状态。这个项目里我定的目标非常明确第一是覆盖面广前期至少接入六个以上不同风格的开源插画站点第二是数据干净标题、作者、标签、来源URL、图片字节流都能精确定位第三是检索效率高不做复杂前端界面先做到命令行和本地HTML预览两种查询方式就够用。1.2 技术栈选择的理由从技术选型上看Python之所以在这个场景里是最顺手的选择是因为它生态里每个环节都有高度成熟的库可以直接用不用自己造轮子。请求库requests加retry适配器简单直接比那些乱七八糟的异步框架容易调试多了解析库parsel基于lxml语法和Scrapy的选择器一致以后真把代码迁移到框架里不用改逻辑存储层SQLAlchemy ORM配SQLite起步数据量上去以后平滑换MySQL或者PostgreSQL不锁死下载器用requests配合ThreadPoolExecutor做多线程下载图片体积都不大用不上重型队列去重逻辑文件内容MD5加URL双重去重前者防重复下载后者防重复入库这套组合的好处是每一层都可以单独替换而且特别容易调试。比如解析出错时直接把响应内容dump成HTML文件打开看很快能定位问题不用在框架里绕圈子。2. 数据库模型设计与解析策略2.1 数据库模型怎么设计才能不返工数据库表设计是整个聚合库的地基。很多初学者做爬虫项目压根不建表爬到什么就存成JSON这在一两个站点时还能忍站点一多你就会发现数据对不上这个站有作者字段那个站作者在简介里还有个站压根没有标签。所以我强烈建议第一步就把表结构设计扎实。这个项目里我设计了images和tags两张核心表外加一张多对多的关联表。images表包含资源原始标题、作者、来源站点标识、原始页面URL、图片直链、本地存储路径、色彩主色调、文件大小、授权类型、抓取时间这些字段。tags表单独存放标签名image_tags关联表用来做多对多映射。为什么一定要拆这三张表而不是把标签塞在一个字段里因为后续做标签筛选的时候数据库索引的效率差距是数量级的差别。你用逗号拼接的标签字段做模糊查询索引直接失效数据量过万就是灾难。ORM建表模型先放出来from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, ForeignKey, Table from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, sessionmaker from datetime import datetime Base declarative_base() image_tags Table( image_tags, Base.metadata, Column(image_id, Integer, ForeignKey(images.id), primary_keyTrue), Column(tag_id, Integer, ForeignKey(tags.id), primary_keyTrue), ) class Image(Base): __tablename__ images id Column(Integer, primary_keyTrue, autoincrementTrue) title Column(String(255), nullableFalse) author Column(String(120), default未知) source_site Column(String(80), nullableFalse) source_url Column(Text, nullableFalse, uniqueTrue) image_url Column(Text, nullableFalse) local_path Column(String(255)) dominant_color Column(String(16)) file_size Column(Integer) license_type Column(String(50)) created_at Column(DateTime, defaultdatetime.now) tags relationship(Tag, secondaryimage_tags, backrefimages) class Tag(Base): __tablename__ tags id Column(Integer, primary_keyTrue, autoincrementTrue) name Column(String(50), nullableFalse, uniqueTrue)这里有个细节很多人会忽略source_url一定要加unique约束。同一个插画可能被多个页面收录不加唯一约束你跑第二次采集脚本时数据库里会多出一堆重复记录靠程序去重总不如约束设在数据库层来得靠谱。2.2 页面解析的三种常见结构开源插画站的页面结构看起来五花八门但抽象出来就三套玩法。第一套是服务端渲染的老式页面插画列表在HTML里直接给你整整齐齐排好这种情况直接用XPath提取就行是最简单的。第二套是JSON接口注入型页面加载后通过script标签里的初始化数据渲染数据通常在window.__INITIAL_STATE__这种全局变量里。这种情况千万不用正则硬抠把script内容提取出来交给json模块解析速度更快也不容易出错。第三套是懒加载型列表页只给前几张图往下滑触发Ajax请求补数据。这种情况需要抓包看接口地址和参数规律模拟请求拿到JSON。我接入的几个站点里大概一半是第二套和第三套的混合形态。拿到页面之后正常套路是先解析出每张插画的标题、作者、标签和详情页链接然后进入详情页再提取原图地址。有些站的列表页直接给出原图地址这种情况最省事一步到位。为了兼容各种情况我在解析函数里做了一个统一出口def parse_list_page(html, source_site): selector parsel.Selector(html) items [] if source_site site_a: cards selector.xpath(//div[contains(class,work-list)]//figure) for card in cards: items.append({ title: card.xpath(.//figcaption/text()).get(), author: card.xpath(.//span[contains(class,author)]/text()).get(), detail_url: card.xpath(.//a[contains(class,cover)]/href).get(), tags: card.xpath(.//a[contains(rel,tag)]/text()).getall(), }) elif source_site site_b: json_data json.loads(selector.xpath(//script[contains(text(),window.__DATA__)]/text()).re_first(rwindow\.__DATA__\s*\s*(.*?);)) for item in json_data.get(items, []): items.append({ title: item[title], author: item[user][name], detail_url: item[link], tags: [t[name] for t in item.get(tags, [])], }) return items看到这段代码你应该能感觉到不同站点只需要写好自己的提取分支其余逻辑完全可以复用这就是数据模型统一设计的好处。3. 采集、下载与入库的完整实现3.1 主流程和请求调度怎么写才优雅整个采集流程我用一个主控函数串起来核心步骤是遍历站点配置列表、请求列表页解析出详情页URL、请求详情页解析原图信息、构造Image对象写入数据库、触发下载任务。请求这块的关键不是能不能请求到而是怎么请求得稳。我的实践是三件套必带随机的User-Agent、超时时间、失败重试。随机UA直接用fake-useragent库生成每次请求自动切换别信网上那些手动维护UA列表的做法新库出来你还要手动更新不现实。超时和重试比较推荐用requests的HTTPAdapter来配置比在每个get里写try-except省事太多import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session requests.Session() retry_strategy Retry( total3, status_forcelist[500, 502, 503, 504], allowed_methods[GET], backoff_factor0.5, ) adapter HTTPAdapter(max_retriesretry_strategy) session.mount(http://, adapter) session.mount(https://, adapter) session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 })backoff_factor这个参数很有讲究它让每次重试之间的等待时间是2的指数倍数递增0.5意味着第一次等0.5秒第二次等1秒第三次等2秒。这个策略对临时性网络波动特别有效也不至于因为频繁重试把对方服务器打崩。请求频率控制上我建议以站点为单位做单独限速不要全局一把梭。有些站比较宽松单次请求间隔0.5秒没问题有些站你一秒两次它就403。稳妥的写法是在站点配置里加一个request_interval字段每次请求前sleep对应时长。3.2 图片下载与文件去重图片下载看着简单其实有大学问。最基础的坑就是直接把response.content写到文件结果一张大图多个线程同时写磁盘IO飙升。实际操作中我做了三个优化点。先通过响应头里Content-Length判断文件大小设置合理上限有些开源站会挂着几十MB的超大图这种直接跳过没必要因为个别巨无霸拖慢整体进度。下载时用流式处理而不是一次性读到内存设置streamTrue后按块写盘对大文件友好还能实时看到下载进度def download_image(session, url, save_path): with session.get(url, streamTrue, timeout20) as resp: if resp.status_code ! 200: return False total_size int(resp.headers.get(Content-Length, 0)) if total_size 20 * 1024 * 1024: return False with open(save_path, wb) as f: for chunk in resp.iter_content(chunk_size8192): f.write(chunk) return True文件去重我采用URL加内容MD5双重验证。URL去重能在数据库层面挡住大部分重复请求MD5是为了防止那种不同URL指向同一张图的情况这在开源插图站里很常见——同一个画师在不同页面引用同一张图。文件下载完成后计算MD5跟数据库里已有的记录比对有重复就直接删除刚下载的文件不浪费磁盘空间。3.3 断点续传与失败重试设计聚合库项目跑的时间长了网络抖动和反爬策略调整是必然的事。我一开始没做断点续传结果跑到一半崩了只能从头再来浪费时间不说对目标站点的负担也不小。后来我在主控逻辑里加了一个标志字段下载成功的记录标记为done未完成的继续抓取时先查一下只处理失败和未下载的条目。整个状态机的设计简单来说就是一次采集任务看成一个队列队列里的每个任务对象包含采集状态和重试次数。遇到网络错误重试三次三次都失败就把状态标记为failed把URL和失败原因记录下来。下次运行采集时可以从数据库中捞出所有failed记录重新入队。多线程下载这块用concurrent.futures控制线程数经验值是单站点用4到8个线程站点多了反而容易触发反爬。线程数不是越大越好我踩过的坑就是贪多设了16个线程结果跑了两分钟就被目标站点限制了。后来老老实实退回4线程一路稳如老狗。4. 聚合检索与多源数据归一化4.1 多源数据的清洗与归一化处理上面提到的还只是单个站点的采集聚合库真正复杂的工作都在数据合并阶段。各个站点给的数据字段五花八门比如作者字段有的是昵称有的是真名有的是ID加昵称混合体。标签字段就更乱了同一个语义的标签这个站叫“植物”那个站叫“绿植”还有一个站叫“botanical”。这种问题不能靠爬虫阶段解决必须在入库后跑一个归一化清洗脚本。我的做法是维护一个同义词映射表把方向差不多的标签映射到统一标准名。比如“植物”、“绿植”、“botanical”统一映射成“植物”。清洗脚本每周跑一次跑完以后检索出来的结果就干净多了。色彩信息这块我用了Pillow库打开图片取主色调。具体做法是把图片缩放到比较小的尺寸比如50乘50像素然后统计出现频率最高的颜色值转成十六进制字符串存储。这样后续就能支持按色系筛选插画比如想要偏蓝绿色调的封面图直接查dominant_color字段就能搜出来。4.2 本地检索管理的两种交互方式聚合库的查询入口我做了一个极简的命令行版和一个稍微好看点的本地Web版。命令行版就是接收关键词参数去数据库里查标题、作者、标签字段把结果按来源站点和缩略图路径打印出来。选图以后自动打开本地文件管理器定位算是满足日常需求。本地Web版用Flask配一个单页展示最近入库的资源支持关键词筛选和标签筛选点击缩略图看大图再点一下复制图片文件路径。这个版本主要给公司团队内部用不用教他们命令行操作打开浏览器就能上手。如果你也打算做Web版我建议不要一上来就上前后端分离框架一个Flask加Jinja2模板足够了。等数据量真的大到需要用户系统、收藏夹、标签管理这些复杂功能时再重构也不迟。5. 常见问题与排查经验实录5.1 请求频率一高就被限制怎么排查这是爬虫项目遇到最多的异常状态之一。特征是程序跑一段时间后目标站点开始返回418或者403页面提示需要验证身份。排查思路分三步走先看错误返回的页面内容确认是验证码拦截还是IP风控再检查自己的请求头是不是少了关键字段比如Referer和Origin最后确认一下是不是自己请求频率太快单纯被限速了。我的实际经验是被限频的概率远远大于被验证码盯着打的概率。很多开发者低估了目标站点运维的监控能力用了并发和分布式就肆无忌惮这是最要命的做法。合规的做法是严格遵守每站点的robots协议和公开API限制请求频率控制在人类操作水平比如每次请求间隔一秒以上绝对不碰验证码破解这类灰色操作。5.2 页面结构调整了怎么快速修复爬虫依赖页面结构站点一改版脚本就瘫这事无从避免。能做的就是尽量减少改版对解析逻辑的影响。我的做法是把每个站点的解析配置尽量参数化比如选择器规则单独放在一个配置文件里不写死在代码里。这样改版后只需要更新配置不用动主逻辑。页面改版常见的信号是字段提取为空这时候不要直接去看代码先把当前页面保存成HTML用浏览器开发者工具手动对比新旧选择器差异定位到新的位置后再修改配置。5.3 数据库并发写入的矛盾怎么处理多线程下载的时候每个线程完成一个任务都要写入数据库。如果每个线程都独立创建sessionSQLite对这种并发写操作支持很差容易报database is locked。我的处理方式是全局共用一个sessionmaker实例但是每个线程都要单独调用sessionmaker创建自己的session互不干扰。写入操作尽量用批量方式攒够一批统一commit减少锁冲突。对于数据量级比较大的场景还是建议把存储层从SQLite换成MySQL这个项目一早就通过SQLAlchemy抽象了存储层所以切换成本非常低这也是当初选ORM框架的一个隐性好处。5.4 常见异常速查表异常问题可能原因推荐解法请求返回403User-Agent缺失或老旧使用fake-useragent随机UA列表页解析为空页面结构改版保存HTML排查新选择器数据库锁死SQLite多线程并发写批量提交或换MySQL图片下载中断反爬限制或网络波动设置重试策略和断点续传标签全是乱码编码识别错误检查响应头charset和页面meta声明重复数据过多缺唯一约束数据库加unique索引6. 合规边界与个人体会聊到最后想认真说说开源和合规这件事。这个项目的核心是“开源插画资源”所以接入的每个站点的授权协议我都在代码注释和数据库的license_type字段里做了记录。CC0、CC-BY、MIT协议这些含义不同使用约束也不一样聚合的时候做好标注是对创作者的基本尊重。从技术上讲爬虫本质是一个数据采集工具工具本身没有原罪关键看用在什么场景、守不守规则。我自己的原则很简单只爬取公开站点且遵守robots协议不通过绕过技术手段获取非公开数据不采集个人隐私信息采集的数据仅用于个人学习和管理不批量下载后用于商业传播。有些人总觉得“能爬下来就算本事”但实际上很多爬虫相关的案例都是因为突破了这些边界才惹上的麻烦。我自己写这个项目时最大的体会是聚合库的长期价值不在爬虫本身而在数据治理。把一千张零散的图片下载下来只是第一步真正费工夫的是让这一千张图片变得有结构、可检索、可管理。这需要的耐心和工程能力远远超过写几十行爬虫脚本本身。现在这个聚合库已经跑了两三个月接入站点从最初的六个拓展到十几个累计入库两千多张高质量开源插画。平时做个PPT封面、配个公众号头图、给客户提案做参考直接在本地检索几下就搞定了效率飙升。后续我还打算加一键导出SVG源文件和批量裁剪尺寸的功能让这个库在输出端也更顺手。如果你也想搭一套自己的素材管理工具就从这个小项目开始动手吧。哪怕第一天只是写个requests请求到一张图也算正式把轮子造起来了。踩坑的过程收获最大。