用Python爬虫给自己攒一个“开源插画资源”聚合库看到这个标题可能不少人有同感做设计、写文档、搭个人博客的时候最烦的就是找配图。网上的插画资源站倒是不少但今天这个站点收藏一下、明天那个平台存个书签真到用的时候还是抓瞎。文件散落在各个下载文件夹风格不统一版权信息也理不清。与其到处求资源包不如自己动手写一套Python爬虫把分散在各处的开源插画资源抓下来按标签、风格、作者统一存进一个私有聚合库再用一个简单的可视化页面去检索和管理。这个项目听起来好像有点大其实拆开来看就三条线爬虫采集、数据存储、聚合展示。适合有一定Python基础、想练手爬虫和SQLAlchemy数据操作的读者也适合想解决“资源管理混乱”这个实际问题的非专业开发者。这篇文章就把我自己的实操过程完整讲一遍从选型、建表、写爬虫到做可视化页面包括踩过的坑和最后沉淀下来的经验全部摊开来说。别担心不会一上来就上分布式爬虫那一套重型方案就用最稳的Requests加SQLAlchemy把核心链路跑通。1. 项目整体设计与思路拆解1.1 为什么要做私有聚合库而不是直接下资源包市面上的插画资源站很多但“聚合”这个需求一直没人能完美解决。有些站点提供打包下载但打包内容太杂风格不统一有些站点质量高但只提供在线预览没有批量导出接口还有些平台虽然开放了API但需要申请密钥、限制调用次数对个人用户并不友好。自己做聚合库的核心优势在于你可以完全按自己的使用习惯来组织资源。举个例子我个人比较常用“扁平风格插画”和“3D人物素材”但很多资源站是按插画师来分区的并不会按照“适用场景”给每张图打上详细标签。等资源一多找起来就很痛苦。自己的聚合库就灵活得多我可以自定义标签体系比如“商务”“教育”“科技”“节日”“人物”“场景”等等存数据的时候顺手就把标签写进去查询的时候一个条件就过滤出来了。另外一个关键点是版权管理。开源插画资源不等于可以随意商用每个站点都有自己的授权协议比如有些要求署名有些只允许非商业用途。下载资源时把这些协议字段一并抓下来存进数据库里后面做项目的时候打开详情页就能看到授权信息既是对原作者的尊重也避免了自己踩坑。所以这个项目的定位很明确不是做一个全自动的爬虫工程而是做一个以数据管理为核心的私有资源工具。爬虫是手段数据存储和检索才是核心价值。1.2 技术选型背后的取舍技术方案上我做了几轮对比这里直接说结论。爬虫框架Requests BeautifulSoup而不是 Scrapy。主要原因是目标站点数量不多且每个站点的页面结构差异较大Scrapy的Item Pipeline和Selector在这种场景下反而会增加配置成本。Requests加BeautifulSoup可以一把梭逻辑都在一个脚本里完成调试起来也方便适合个人项目。后面如果站点数量多了再往Scrapy迁移也不难核心的解析逻辑是可以直接复用的。数据存储SQLite SQLAlchemy而不是直接拼SQL。这类聚合库的数据量通常不会很大几万张图片的元数据已经完全够用SQLite单文件就能搞定不需要单独装MySQL。但直接用Python自带的sqlite3写SQL脚本的话建表、插入、查询的代码会有点散而且字段一多很容易出错。SQLAlchemy的ORM层可以把表结构和Python对象对应起来代码读起来清晰后面要迁移到PostgreSQL或者MySQL也只需要改一行连接串。聚合展示Flask 简单的HTML页面。为什么不用Django因为这个项目没有用户系统、没有后台管理只需要一个列表页加一个详情页再加一个搜索框Flask已经绰绰有余。顺便说一句如果你后面想把聚合库做成局域网可访问的小工具Flask也是出了名的轻量部署起来很省事。部署方式本机运行按需手动触发爬虫。我一开始想过用定时任务每天自动爬取更新但后来发现很多资源站内容更新频率并不高反而是每天跑一次全量抓取会频繁触发防盗链机制导致IP被临时限制。后来改成每周手动跑一次更新脚本必要的时候单独针对某一个站点做部分更新稳定很多。2. 核心细节解析与实操要点2.1 目标站点分析与爬取策略动手写代码之前先花点时间整理目标站点。我不建议一上来就想爬几十个站务实的做法是先拿两个站跑通全流程再逐步扩展。我的起步清单是站点A提供免费可商用的矢量插画页面结构清晰每个插画详情页有标题、作者、标签、授权说明和下载链接。站点B以3D人物素材为主支持在线预览有分类目录和标签页缩略图和原图是两套URL规则。做站点分析的时候重点关注三块一是robots协议。这不是教条是底线。我们聚合的是开源资源更要遵守对方的抓取规则。用requests去访问一下目标站点的robots.txt确认哪些路径允许爬取把允许的路径加进白名单。二是页面URL规律。以站点A为例列表页的URL是/list/{category}/{page}详情页的URL是/detail/{id}。搞清楚URL结构之后爬虫逻辑就很简单——先遍历列表页拿到详情页ID列再逐个访问详情页抓字段。三是翻页和分页机制。有些站点是传统的?page1、?page2参数有些是下拉加载触发的接口请求这个要具体分析。我一般先用Chrome的开发者工具看一下Network面板确定是静态HTML还是动态渲染。能用静态HTML解析的绝不上无头浏览器性能和稳定性都不是一个量级的。这里给一份我当时整理的字段规划表字段名说明示例id主键自增1source_site来源站点site_asource_url详情页URLhttps://...title插画标题商务会议插画author插画作者xxxtags标签组合商务, 会议, 扁平license_type授权类型免费可商用thumbnail_url缩略图地址https://...download_url原图下载地址https://...created_at抓取时间2024-01-012.2 编写爬虫时的几个实践细则字段规划好之后爬虫代码本身并不复杂但有一些细节直接影响成败。请求头设置。不要用默认的Python-requests UA大部分站点都会拦。我用的是一个常用浏览器的User-Agent同时加了Accept-Language头。更关键的是Referer——有些资源站的原图地址做了防盗链直接下载会返回403但加上了详情页的Referer就能正常访问。这个经验在站点B上帮了大忙缩略图和原图用的是同一个CDN域名但原图的请求必须带Referer否则拿不到数据。请求频率控制。很多人写爬虫喜欢加time.sleep(1)就完事其实不够我建议是每个请求之间做随机延时比如1到3秒之间随机再加上目标站点数量本身不多跑一轮下来也不会太慢还能有效降低被限制的概率。页面解析用稳定的容器特征。不要用动态class名因为很多前端框架会在刷新时改动class名比如sc-xxxxx这种。尽可能用固定的HTML结构特征比如article标签、meta属性或者固定的id。我做站点A解析的时候就是先看HTML源码发现每个插画详情页都有一个meta[nameauthor]标签直接用BeautifulSoup抓这个字段比在DOM里层层找class稳定太多了。2.3 数据清洗与去重逻辑爬虫抓回来的数据不能直接入库原文里会有不少脏数据比如标签里混入HTML实体、标题首尾有空白字符、下载链接是相对路径需要拼接域名。这些清洗工作放在入库前的Pipeline阶段统一处理。去重是数据层的关键。我用的是source_site source_url做联合唯一约束这样同一个页面即使被爬两次第二次插入时也会被数据库挡住。但SQLAlchemy的ORM不会自动处理唯一约束冲突直接commit会抛出IntegrityError。我的处理方式是插入前先去查一次数据库如果存在就跳过不存在再insert。数据量小这种查询策略完全够用不用上复杂的upsert逻辑。下载文件的时候注意区分图片格式。有些下载链接没有扩展名或者扩展名与实际文件内容不一致。我的做法是按Content-Type来推断格式必要时用Python的imghdr模块新版本可以改用pillow读取文件头比信任URL后缀可靠得多。3. 数据存储层与聚合管理界面实现3.1 用SQLAlchemy定义模型与初始化数据库这一节直接上代码都是我在项目中实际用过的。先安装依赖pip install requests beautifulsoup4 sqlalchemy flask定义数据库模型这里以插图资源表为例from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text from sqlalchemy.orm import declarative_base, sessionmaker from datetime import datetime Base declarative_base() class Illustration(Base): __tablename__ illustrations id Column(Integer, primary_keyTrue, autoincrementTrue) source_site Column(String(50), nullableFalse) source_url Column(String(500), nullableFalse, uniqueTrue) title Column(String(200), nullableFalse) author Column(String(100), default) tags Column(String(500), default) license_type Column(String(100), default) thumbnail_url Column(String(500), default) download_url Column(String(500), default) created_at Column(DateTime, defaultdatetime.now) __table_args__ ( UniqueConstraint(source_site, source_url, nameuq_source_site_url), )这里有两个细节值得说。第一source_url我同时加了uniqueTrue和联合唯一约束其实联合唯一约束已经覆盖了单个字段唯一的情况但保留uniqueTrue可以加快按来源URL查询的速度。第二为什么用String存tags而不是单独建一张标签表就个人聚合库的量级来说用逗号分隔的标签字段配合SQLite的LIKE查询已经够用不需要引入多对多表关系简化实现优先。再写一个初始化数据库的通用函数def init_db(db_pathillustration_library.db): engine create_engine(fsqlite:///{db_path}, echoFalse) Base.metadata.create_all(engine) return sessionmaker(bindengine)()SQLite的连接串注意要写相对或者绝对路径路径对应的目录一定要存在否则SQLAlchemy会静默创建数据库文件但不会创建目录目录不存在会直接报错。这个问题我遇到过两次后来统一封装了一个初始化函数先检查目录再创建引擎。3.2 聚合管理界面Flask快速搭一个检索页数据入库之后下一步就是怎么把这些资源用起来。如果只是用命令行去查询始终不够直观而且给非技术背景的人分享资源库时对方也不可能去敲SQL。所以我用Flask搭了一个极简的Web界面功能就三个列表浏览、按标签搜索、查看资源详情。路由设计如下from flask import Flask, request, render_template from sqlalchemy import or_ app Flask(__name__) session init_db() app.route(/) def index(): keyword request.args.get(q, ) page int(request.args.get(page, 1)) per_page 24 query session.query(Illustration) if keyword: like_pattern f%{keyword}% query query.filter(or_( Illustration.title.like(like_pattern), Illustration.tags.like(like_pattern), Illustration.author.like(like_pattern) )) total query.count() items query.order_by(Illustration.created_at.desc()) \ .offset((page - 1) * per_page) \ .limit(per_page) \ .all() return render_template(index.html, itemsitems, totaltotal, pagepage, keywordkeyword) app.route(/detail/int:item_id) def detail(item_id): item session.get(Illustration, item_id) return render_template(detail.html, itemitem)前端模板我就不完整贴了核心是用一个循环把缩略图展示成网格点击缩略图进入详情页详情页展示大图、作者、标签、授权信息和原始链接。这里有一个特别重要的体验优化缩略图务必加载本地化副本不要直接使用远端的缩略图URL。为什么因为很多资源站的图床做了防盗链放在自己的页面里会403图片裂掉。而且爬虫抓取时的URL如果有一天失效了整个历史数据就全瞎了。我的做法是在下载原图的同时顺手把缩略图也抓一份存到本地的thumbnails目录数据库里存的是本地相对路径。这样聚合库的Web页面完全不依赖外网也能正常展示断网了都能看图。3.3 下载文件与数据更新的协调策略初始版本是爬虫脚本抓完元数据后再批量下载图片。后来发现这样有两个问题一是如果批量下载过程中某个图片下载失败整个流程都得中断二是下载图片很耗时但元数据已经抓完了其实可以先入库图片下载放到后台慢慢做。所以我调整了策略设计了一个简单的两阶段流程第一阶段只抓元数据信息快速入库。第二阶段从数据库里筛选出那些download_url非空但本地文件不存在的记录逐条下载图片。这个策略在数据量上来之后优势很明显元数据抓取很快图片下载可以随时中断、随时继续没有入库阻塞。因为判断一张图片是否已经下载就是看对应记录里有没有local_path字段本地文件存在就跳过。def download_image(item, save_dir): if item.local_path and os.path.exists(item.local_path): return True # 实际下载代码记得带上Referer resp requests.get(item.download_url, headers{ User-Agent: UA, Referer: item.source_url, }, timeout20) if resp.status_code 200: ext infer_extension(resp.headers.get(Content-Type, )) filename f{item.id}_{slugify(item.title)}{ext} filepath os.path.join(save_dir, filename) with open(filepath, wb) as f: f.write(resp.content) item.local_path filepath session.commit() return True return False4. 常见问题与排查技巧实录4.1 页面结构变了导致解析失败这是爬虫项目里最经常遇到的问题。上个月还能正常解析的页面这个月就抓不到字段了。可能原因包括前端框架改版、class名更新、DOM结构调整、加了登录墙等等。我的排查思路是先手动用requests拉一下详情页的HTML把内容存到本地文件再用BeautifulSoup逐步检查目标字段是否还存在。如果是字段位置变了调整一下选择器就行如果是整个站点加了登录墙或验证码那就要评估一下继续爬的成本和合规风险了该放弃就放弃。另外有个小技巧把每次抓到的原始HTML保存一份到独立的logs目录按日期命名这样出问题时能对比之前的页面结构排查速度快很多。4.2 图片下载出现403前面提过这个问题的核心是Referer。在资源站里缩略图URL和原图URL往往在同一个域名下但原图下载接口会校验Referer是否来自详情页。解决办法是下载时带上Referer为详情页URL的请求头。还有另一种403情况是下载请求频率过高触发了CDN防护此时适当增加请求间隔或者切换到代理IP池。个人项目不建议一上来就上代理池大概率是请求频率的问题而不是IP被封。多等几秒通常就恢复了。4.3 SQLAlchemy批量插入重复数据报错这个问题很典型。如果你用ORM直接add一个重复记录然后commit会抛出IntegrityError。有些人会说直接捕获异常忽略就好但这样做的问题是当前事务已经变脏了后续的commit也会一直失败。正确的做法是在插入前先查询确认或者使用SQLAlchemy的session.execute配合insert().on_conflict_do_nothing()。在SQLite里最简单可靠的就是先查再插exists session.query(Illustration.id).filter_by( source_sitesite, source_urlurl ).first() if not exists: session.add(record)虽然多了一次查询但对个人项目来说性能完全不是问题胜在稳定。4.4 抓取任务中断后如何断点续爬爬虫跑一半网络断了或者程序被手动停止再启动时从头开始会很浪费时间。我在列表页遍历和详情页抓取两个环节都加了断点记录。实现方式很轻盈用一个JSON文件记录每个站点当前抓到的最大页码每个详情页抓取成功后记录当前URL下次启动时读取记录从断点继续。不需要引入任何任务队列中间件一个文件搞定。4.5 常见问题速查表问题可能原因解决方案列表页能访问详情页403缺失Referer或Cookie请求头补Referer和浏览器UA图片能预览下载403防盗链校验下载请求带上详情页URL作为Referer中文字段乱码页面编码未正确解析用resp.apparent_encoding覆盖默认编码重复入库缺少去重约束建表加联合唯一约束插入前先查询页面字段抓不到动态渲染检查是否为Ajax接口改用接口请求local_path存在但文件无效下载被中断或写入了空文件判断文件大小小于阈值重新下载最后再分享一点我的实际操作体会做完这套聚合库我最深的感受是技术层面没有难题真正有价值的是数据治理的思路。爬虫代码写起来也就几百行但怎么组织字段、怎么清洗数据、怎么设计标签体系、怎么做增量更新这些才决定这个工具到底好不好用。有几个小的设计细节用起来真的香一是库表里一定要留source_url字段这样后续发现某张图有版权争议时可以快速回溯到原始页面核对授权信息二是标签里除了风格、题材之外我还会额外追加一个“适用场景”前缀比如“封面-商务”“插图-教育”这样在Web界面里按场景筛选时特别顺手三是每隔一段时间我会手动抽查一批license_type确认授权协议没有变化毕竟授权条款是可能调整的。如果你打算照着这个思路做自己的聚合库我的建议是先选一个站点跑通全流程再慢慢加新站点。一上来就想做全站聚合大概率会被各种反爬策略和页面结构差异劝退。等你能熟练处理两三个站的解析和去重逻辑再往里面加新资源站就只是个累加的活完全不用改数据库结构。这套项目后续可以扩展的方向还有不少比如给Web界面加上图片颜色筛选、按作者聚类浏览、导出成语雀/Notion格式的资源清单、对接Tagger工具做本地文件管理等等。你完全可以根据自己的使用习惯去延伸。说到底自己的工具自己用得顺手就是最好的产出。
