
简介Fantia-Downloader-tampermonkey是一款面向Fantia平台用户的内容批量下载工具主要解决在Fantia图片展示框中逐张保存图片过于繁琐、效率不高的问题。脚本通过Tampermonkey扩展在浏览器中运行加载后会在Fantia的每张图片上增加下载按钮点击即可自动抓取页面中的图片资源并打包成ZIP文件保存到本地。压缩包约3KB体积非常小共包含2个文件其中JS脚本文件为下载器本体MD文档为使用与安装说明整体结构简洁、部署方便。开发者明确建议在Firefox中使用此脚本以规避Chrome在跨域加载图片时可能遇到的CORS问题这一事项提前说明可帮助使用者避免不必要的排错弯路。目前已有10586人学习/下载适合需要批量整理Fantia图片素材、追求更高下载效率的创作者或浏览者包内包含可直接部署的脚本与清晰的说明文件用户只需具备Tampermonkey的基础使用能力按说明导入并刷新页面即可获得一键打包下载能力。1. 先说清楚 Fantia-Downloader-tampermonkey 在解决什么问题在 Fantia 上订阅了画师之后每次更新十几张原图加一段视频靠右键“图片另存为”一张张保存坚持不了几次就会放弃。Fantia-Downloader-tampermonkey 就是挂载在 Tampermonkey篡改猴里的用户脚本它直接把帖子里的图片、视频识别出来在页面角落放一个“下载本页资源”的按钮点击后按队列下载到本地。它解决的痛点是手动保存太慢、懒加载图片抓不全、文件名没有语义、视频容易被页面逻辑打断。适合收藏原图素材、做本地备份归档或者想学油猴脚本怎么处理动态页面的开发者也值得拆开看一遍。有一点必须先说清楚这类脚本只能下载你已经登录、已经解锁的资源它不能绕过付费墙。2. 把脚本跑起来Tampermonkey 安装、match 匹配和第一次下载2.1 浏览器接管先装 Tampermonkey再挂用户脚本Tampermonkey 不是脚本本身而是脚本的运行容器。浏览器上装好 Tampermonkey 扩展后点浏览器工具栏里的扩展图标选择“添加新脚本”把下载到的 Fantia-Downloader 用户脚本粘贴进去保存。用户脚本的头部有一组元数据注释负责告诉 Tampermonkey这个脚本叫什么、在哪些网址上运行、允许调用哪些 API。一个典型的头部长这样// UserScript // name Fantia Downloader // namespace fantia-dl // version 2025.01 // description Fantia 帖子页批量抓取原图与视频并触发下载 // match https://fantia.jp/posts/* // match https://fantia.jp/fanclubs/* // grant GM_download // run-at document-idle // /UserScriptmatch决定了脚本在哪些页面生效posts/*是帖子详情页fanclubs/*是创作者主页。grant GM_download用来申请油猴的跨域下载能力后面保存视频时会用到。run-at document-idle表示等页面基本渲染完再执行避免脚本插入按钮时 DOM 都还没生成。参数说明如果你的 Fantia 页面实际跑在www.fantia.jp下match https://fantia.jp/*不会匹配www前缀需要改成match https://*.fantia.jp/*这类前缀问题在下面的避坑章还会细讲。2.2 match 匹配规则为什么装了脚本页面却没有任何反应新手最容易在这里翻车脚本保存了Fantia 页面也正常打开但页面上没有任何下载按钮。打开浏览器控制台如果看到 Tampermonkey 报错或根本没执行第一个怀疑对象就是match没匹配上。用下面这段代码验证脚本有没有在当前页面注入按钮function injectButton() { if (document.querySelector(#fantia-dl-trigger)) return; const btn document.createElement(button); btn.id fantia-dl-trigger; btn.textContent 下载本页资源; btn.style.position fixed; btn.style.top 96px; btn.style.right 24px; btn.style.zIndex 99999; btn.addEventListener(click, startDownload); document.body.appendChild(btn); } new MutationObserver(injectButton).observe(document.body, { childList: true, subtree: true });injectButton每次都先检查按钮是否已存在防止重复注入。这里用MutationObserver监听body的变化因为 Fantia 是动态页面切换帖子或点击“加载更多”后旧按钮可能被框架重绘掉监听器能在 DOM 变化时把按钮重新补回来。按钮的zIndex设成99999是为了不让页面自身的浮动元素盖住它。2.3 跑通第一次下载注入下载按钮和执行保存按钮不能只是显示真正干活的是startDownload。常见实现是先收集资源再逐条触发浏览器下载。最简单的保存方式是fetch拿回二进制数据转成Blob后用临时a标签触发下载。下面这段是核心流程async function startDownload() { const resources collectResources(); // 收集本页资源返回 [{url, filename}] if (!resources.length) { alert(没有发现可下载的图片或视频); return; } const failed []; for (const item of resources) { try { await downloadResource(item.url, item.filename); } catch (err) { failed.push(item.url); } } if (failed.length) { console.warn(下载失败列表可用于重试, failed); } } async function downloadResource(url, filename) { const resp await fetch(url, { credentials: include }); if (!resp.ok) throw new Error(HTTP ${resp.status}); const blob await resp.blob(); const objectUrl URL.createObjectURL(blob); const a document.createElement(a); a.href objectUrl; a.download filename; a.click(); URL.revokeObjectURL(objectUrl); }逻辑说明fetch的credentials: include把当前登录凭证带进请求这样拿到的才是你有权限看的内容URL.createObjectURL生成一个临时内存地址给a标签用download属性写入目标文件名。参数说明filename必须是纯文件名不要带/或\下载完成立即revokeObjectURL否则标签页长时间跑批量下载内存会一路涨上去。这段代码能跑通第一次下载但只适合十几张图片的小帖子。图片数量过百时内存占用会非常明显所以完整的 Fantia-Downloader-tampermonkey 会把第一版拆成“遍历收集”“队列调度”“逐个保存”三段来写下面两章就是把这三段拆开讲。3. 脚本内部在抓什么从页面 DOM 到本地文件的四步转换3.1 图片在页面里的几种存在形态选择器怎么写写 Fantia 下载脚本最核心的是“资源发现”。你不能只写一个document.querySelectorAll(img)就收工因为同一张图在页面里可能以四种形态出现正文插图、封面缩略图、懒加载占位图、附件下载链接。后两种没有完整的img[src]或者只有缩略图地址。一个更稳的选择器集合是这样的const IMG_SELECTORS [ .post__content img, .post__cover img, img[data-src], img[data-original], img[srcset], a[href$.jpg], a[href$.jpeg], a[href$.png], a[href$.gif] ]; function collectImages(root) { const results []; for (const selector of IMG_SELECTORS) { root.querySelectorAll(selector).forEach((el) { let url el.src || el.href || el.getAttribute(data-src) || el.getAttribute(data-original); if (url !results.some((r) r.url url)) { results.push({ url, el }); } }); } return results; }参数说明collectImages里的el.src || el.href顺序很重要img标签取srca标签取hrefgetAttribute(data-src)是给懒加载图片预留的el.src可能已经被占位图覆盖。去重用some逐条比对图片多时性能一般但 Fantia 单页通常几十张够了。真正要留意的是这个函数必须等正文渲染完再调用否则抓到的是骨架页面。3.2 懒加载、分页和瀑布流为什么脚本只抓到前几张Fantia 的帖子分两种形态短帖子直接渲染全文长帖子或创作花絮会分页或滚到页面底部才加载更多。直接collectImages()只会拿到首屏因为懒加载图片的真实地址还在>function expandLazyImages(root) { root.querySelectorAll(img[data-src], img[data-original]).forEach((img) { const realSrc img.getAttribute(data-src) || img.getAttribute(data-original); if (realSrc img.src ! realSrc) { img.src realSrc; } }); } function loadAllPages() { return new Promise((resolve) { let retry 0; const timer setInterval(() { const loadMore [...document.querySelectorAll(button, a)].find((el) /加载更多|Load more|もっと見る/i.test(el.textContent) ); if (loadMore retry 10) { loadMore.click(); retry 1; } else { clearInterval(timer); resolve(); } }, 1500); }); }逻辑说明expandLazyImages把懒加载占位图替换成真实地址loadAllPages每隔 1.5 秒找一次“加载更多”按钮并点击最多点 10 次点不到就结束。这个轮询比一次性click()靠谱因为 Fantia 的加载更多是异步的点击后新内容需要时间插入 DOM直接连续点击会跳过中间批次。参数说明retry 10是给超长帖子留的余量1500ms是经验值网络慢或图片大时建议放到 2000ms。3.3 原图与缩略图的区别srcset 和路径后缀的玄学抓到的图片地址不一定都是原图。Fantia 的 CDN 会按尺寸输出缩略图常见路径里有_thumb、_s或尺寸后缀比如illust_1234_600x800.jpg。脚本需要把缩略图地址“还原”成原图地址。下面这段替换逻辑就是干这个的function toOriginalUrl(url) { if (!url) return url; return url .replace(/\/thumbs?\//i, /original/) .replace(/_\d{1,4}x\d{1,4}(\.\w)$/i, $1) .replace(/\/tmb\//i, /); }参数说明两个替换规则只对特定 CDN 前缀生效——_600x800.jpg会变成.jpg/thumbs/会换成/original/。这里的“玄学”在于不同创作者上传的图片走的是同一套 CDN但有些历史图片路径不带这些标记有些图片本身就是原尺寸强行替换会把_original路径替换错。实操里我会先拿替换后的 URL 发一个HEAD请求看返回的Content-Length是否大于当前图片字节数大于才用替换结果。3.4 视频和音频视频标签、分段流和可选下载图片能直接下载视频要复杂一些。Fantia 原生视频通常用video标签嵌入里层还有source。收集标签的地址比图片简单但要过滤掉.m3u8那是分段流浏览器不能直接保存成单个文件。代码里加一个过滤条件function collectVideos(root) { const urls new Set(); root.querySelectorAll(video).forEach((video) { if (video.src) urls.add(video.src); video.querySelectorAll(source).forEach((s) { if (s.src) urls.add(s.src); }); }); return [...urls].filter( (url) /\.(mp4|mov|webm|m4v)(\?.*)?$/i.test(url) !/\.m3u8(\?.*)?$/i.test(url) ); }参数说明Set用来去掉重复源因为video和source可能指向同一个文件过滤条件保留了mp4/mov/webm/m4v排除m3u8。遇到 m3u8 时脚本的常见做法是把视频地址写进下载清单提示用户用外部工具做流合并而不是在油猴脚本里硬解。下面是收集完图片和视频后把两类资源合并成统一队列function collectResources() { const root document; expandLazyImages(root); const postId location.pathname.split(/).pop(); const images collectImages(root) .map((item, index) ({ url: toOriginalUrl(item.url), filename: ${postId}_${String(index 1).padStart(3, 0)}.jpg })); const videos collectVideos(root).map((url, index) ({ url, filename: ${postId}_v${String(index 1).padStart(3, 0)}.mp4 })); return [...images, ...videos]; }这里的filename忽略了原文件的扩展名统一用.jpg如果你在原地址里能拿到.png或.webp最好根据地址后缀动态决定否则toOriginalUrl替换出来的地址是 PNG保存成.jpg也能播放但文件管理时会有隐患。第 4 章会专门讲命名规则怎么定更稳。4. 下载参数怎么调并发、命名、过滤与权限边界4.1 并发数、延迟与重试三个参数决定下载成功率把资源收集好后剩下的就是队列下载。浏览器对同一域名有并发连接数限制脚本一次性发起 20 个fetch并不是 20 个同时跑而是有十几个排队等连接等太久会出现超时或 429。所以要给 Fantia-Downloader 加一个可控的队列调度器。最简实现是一个带并发上界的任务队列class DownloadQueue { constructor(limit, delayBetween) { this.limit limit; this.delayBetween delayBetween; this.running 0; this.tasks []; } add(fn) { return new Promise((resolve) { this.tasks.push(() fn().then(resolve).catch(resolve)); this.pump(); }); } pump() { if (this.running this.limit) return; const task this.tasks.shift(); if (!task) return; this.running 1; setTimeout(() { task().finally(() { this.running - 1; this.pump(); }); }, this.delayBetween); } }这里每个任务开始前都等了delayBetween毫秒相当于人为限速。task().then(resolve).catch(resolve)的写法是保证某个任务失败不会让整个 Promise 链断掉失败记录留给外层处理。参数说明图片场景concurrency 2、delayBetween 1200是底线视频比较大时建议降到concurrency 1否则几百 MB 的视频同时下载浏览器标签页内存会冲得很高。4.2 命名规则{postId}_{index}为什么比标题命名稳下载文件的命名直接决定素材库能不能搜索。用帖子标题做文件名有一个问题标题里有日文汉字、emoji、斜杠时不同操作系统会给出完全不一致的处理结果有的把斜杠替换成下划线有的直接报错。更稳的方案是只用数字帖子 ID 加序号function buildFilename(postId, index, ext) { const safeIndex String(index).padStart(3, 0); return ${postId}_${safeIndex}.${ext}; }参数说明padStart(3, 0)让1变成001文件管理器按字符串排序时才不会出现10排在2前面的问题ext要从最终下载地址里提取不能写死。帖子 ID 从location.pathname.split(/).pop()取天然唯一。标题信息不是不要而是建议丢进 JSON 元数据而不是文件名这也是第 6 章的做法。4.3 过滤条件图片、视频、付费内容的开关不是每次都要把图片视频全下下来。有些用户只想要原图有些用户只想要视频脚本界面上一般放两个开关。代码里的过滤逻辑长这样const FILTER { images: true, videos: false, originalSizeOnly: true }; function filterResources(list) { return list.filter((item) { if (/\.(mp4|webm|mov|m4v)(\?|$)/i.test(item.url)) return FILTER.videos; if (/\.(jpe?g|png|gif|webp)(\?|$)/i.test(item.url)) return FILTER.images; return true; }); }逻辑说明先按扩展名判断资源类型再查对应开关两个开关都关时返回空列表startDownload会提示“没有可下载的资源”。这个过滤条件和第 3 章的视频过滤是两回事这里是用户意愿那里是技术可行性。4.4 权限边界脚本能做什么不能做什么这个位置需要把丑话说在前面。Fantia-Downloader 这类脚本的权限边界有三个第一它只能下载你已经登录、已经订阅、已经解锁的帖子未购买的付费区块在页面上根本不会渲染脚本没有地方能拿到 URL第二它原则上只发GET请求不做注册、不写评论、不调用任何登录接口第三油猴的GM_download只在你点击脚本按钮后由用户主动触发不允许脚本在后台静默批量下载。提示下载自己订阅的素材做备份是合理需求但不代表可以二次传播。脚本本身不加密不加水印拿到文件后怎么用是另一条线要守住的事。权限判断在代码里可以做得更明确比如发现帖子状态是privacy时直接终止下载if (document.querySelector(.post-privacy) !confirm(该帖子为隐私内容确认你有访问权限吗)) { return; }这里confirm弹窗给用户一个确认机会避免脚本误下到没有权限的页面占了本地空间也避免用户在公共场合演示时误操作。5. 常见问题与排查按钮没出现、下载中断和“假原图”5.1 按钮没出现匹配失效或注入被中断现象脚本显示已启用Fantia 页面正常打开但页面右上角没有下载按钮控制台也没有脚本报错。原因最常见的是match前缀写错。Fantia 登录后会跳转到fantia.jp还是www.fantia.jp取决于账号设置和地区如果实际打开的是www.fantia.jp而你写的是match https://fantia.jp/posts/*脚本不会执行。另有一个隐蔽场景Tampermonkey 的“仅允许用户覆盖”或浏览器里安装了多个脚本管理器注入被管理器拦截。解决在油猴面板点“编辑”检查match改成match https://*.fantia.jp/posts/*一劳永逸也可以在脚本开头加一行console.log(fantia-dl loaded)刷新页面看有没有输出没有输出就说明脚本压根没注入。5.2 下载到一半停住并发撞上浏览器连接数上限现象前五张图片顺利下载第六张开始任务一直处于 pending界面卡住不动但标签页没有崩溃。原因浏览器对同一域名的 HTTP 并发连接数做了限制HTTP/1.1 下通常是 6 个左右脚本并发开太高时后面的fetch虽然已经在队列里但迟迟拿不到连接看起来就是挂起。另一方面Fantia 的 CDN 对高频请求会有限流短时间连续拿几十张图会触发 HTTP 429fetch不会自动重试。解决把concurrency降到 1 或 2delayBetween提高到 2000对 429 响应做一次重试重试间隔至少 3 秒。不要在一个任务失败后立刻重新加入队尾那样会让同一批文件反复试错把请求量放大好几倍。const resp await fetch(url, { credentials: include }); if (resp.status 429) { await new Promise((r) setTimeout(r, 3000)); // 重新入队由外部队列逻辑决定是否继续 throw new Error(rate-limited); }参数说明3000ms是给限流的退避时间如果你用的是第 4 章的DownloadQueue失败的任务会直接结束不会自动重试这套设计是对的——手动再点一次下载总比脚本在后台无限重试安全。5.3 下载完的文件打不开后缀对但内容不是图片现象文件名是123456_001.jpg本地也能看到文件但双击打开报格式错误用编辑器看文件头不是FF D8。原因脚本只看了地址后缀没看响应内容。当请求带credentials: include但登录失效时服务端返回 HTML 登录页被脚本当成图片保存下来另一种情况是toOriginalUrl替换路径失败拿回来的实际是 404 页面。解决在downloadResource里拿到响应后不要直接转 Blob 保存先检查Content-Type和文件头async function verifyImageBlob(resp, expectedType) { const blob await resp.blob(); const bytes new Uint8Array(await blob.slice(0, 2).arrayBuffer()); const isJpg bytes[0] 0xff bytes[1] 0xd8; const isPng bytes[0] 0x89 bytes[1] 0x50; if ((expectedType jpg !isJpg) || (expectedType png !isPng)) { throw new Error(resource is not an image: resp.url); } return blob; }参数说明blob.slice(0, 2)只读前两个字节避免把整个大图读进内存做校验expectedType由外部传入和filename里的扩展名一致。这个检查每张图都做一次图片多时略费时间但能避免成批保存垃圾文件值得保留。5.4 视频全部失败跨域、m3u8 和防盗链现象图片全部下载成功只有视频任务一个没跑通控制台报Failed to fetch或CORS policy。原因Fantia 的视频文件通常存在独立的 CDN 域名上页面里浏览没问题但脚本用fetch跨域拿资源时如果 CDN 响应头里没有Access-Control-Allow-Origin浏览器会直接拦截响应fetch拿不到数据。另有一部分视频以.m3u8分段流形式提供不可能通过单次fetch保存。解决脚本对这类资源改用油猴的GM_download它的实现不受页面 CORS 约束可以直接把远程 URL 交给浏览器下载器function downloadViaGM(url, filename) { return new Promise((resolve) { GM_download({ url, name: filename, saveAs: false, onload: () resolve({ status: ok }), onerror: (e) resolve({ status: error, error: e }) }); }); }参数说明saveAs: false让浏览器直接存进下载目录不弹“另存为”窗口批量下视频时不用人工点确认onerror回调要把错误信息记下来视频下载失败的原因通常是 CDN 做了 Referer 校验GM_download 默认带的是页面 Referer如果还失败就需要在油猴设置里给该域名开放跨域权限这属于浏览器级配置脚本里管不到。5.5 重复下载与乱码命名缺少任务清单现象同一个帖子跑第二次又把所有文件下载了一遍第二次运行后文件管理器里出现一堆123456_001_1.jpg。原因脚本没有记录哪些文件已经下载过。浏览器遇到重名文件会在名字后追加序号看起来是脚本 bug本质是缺少下载历史记录。另外文件名里有中文标题时跨平台会变成乱码本质是命名规则没避开非 ASCII 字符。解决给脚本加一个基于 localStorage 的下载记录表下载前先核对const HISTORY_KEY fantia_dl_history; const store JSON.parse(localStorage.getItem(HISTORY_KEY) || {}); function markDownloaded(postId, filename) { store[postId] store[postId] || []; if (!store[postId].includes(filename)) store[postId].push(filename); localStorage.setItem(HISTORY_KEY, JSON.stringify(store)); } function alreadyDownloaded(postId, filename) { return store[postId] store[postId].includes(filename); }markDownloaded在每次成功下载后调用alreadyDownloaded在收集资源时过滤一次。这个表跟着浏览器走同一台电脑、同一个油猴配置下不会重复下载换电脑后会重新下载这不算缺陷。乱码命名问题按 4.2 的buildFilename用数字命名后不会再有旧文件用批量改名工具处理一次即可。6. 进阶用法把下载结果整理成可检索的素材清单6.1 输出 JSON 清单把下载动作交给外部归档工具脚本收集资源时已经把文件名和 URL 都算好了顺手写一份 JSON 记录到 localStorage 或直接复制出来之后的归档会轻松很多。这里做的不是“多一步操作”而是把下载动作和归档动作解耦脚本只负责发现资源和触发下载整理交给本地工具。元数据清单的结构可以定成这样{ postId: 123456, title: 夏コミ新刊サンプル, publishedAt: 2025-01-20T12:00:0009:00, files: [ { name: 123456_001.jpg, url: https://cdn.fantia.jp/uploads/... }, { name: 123456_v001.mp4, url: https://cdn.fantia.jp/uploads/... } ] }这份清单可以在startDownload结束后调用一个buildManifest函数生成然后把 JSON 字符串拼到页面上让用户手动复制。它最大的价值是图片文件本身没有上下文信息但 JSON 里有帖子标题、发布时间和原始 URL。我一般会把 JSON 存成manifest_123456.json和下载的图片放在同一个文件夹里之后写本地索引工具时直接读它不用再去网页上人工对齐。6.2 验证结果而不是相信日志下载完成后脚本日志里显示成功不一定代表文件都可用。我自己会加一个粗暴的核对步骤把已下载文件名和collectResources得出的清单逐条比对看少没少。最简单的办法是把清单输出复制到本地用文件管理器按名字排序再对照清单数一遍。有一点是反复踩坑后总结出来的头一次跑新脚本、新帖子类型时不要直接全量下载先手动下载三张一张缩略图、一张原图、一段视频确认这三类都能正常保存再放队列跑。否则你会发现跑了二十分钟结果因为命名规则错误图片后缀全是.jpg实际的 PNG 图打不开。我自己第一次用这类下载器时把并发调到 10、延迟设 0结果下载到一半标签页直接崩了本地只留下几个损坏的空文件。后来所有脚本配置都改成保守值下载前打印资源数量和总大小跑完核对一遍 JSON 清单这个习惯再没让我翻车。希望帮到你。本文还有配套的精品资源点击获取