PixivDesk 深度解析 · 完整版
一个非官方客户端,也是一次关于 Electron 权限边界、下载状态机与本地数据设计的实践。
PixivDesk 最初只是为了解决一个很小的麻烦:在浏览器里看完一组多页作品后,要一页一页保存,还要记住哪些图已经下过。后来需求慢慢长成了“批量收藏、按画师归档、断点续传、下载历史、代理、R-18 筛选”,一个普通的下载脚本就不够用了。
这个项目最终选择用 Electron 重写:不是为了做一个“更像原生”的壳,而是因为下载器需要同时具备三样东西——Chromium 的页面能力、Node.js 的文件系统能力,以及一个能持久保存 Cookie 的会话。把这三样东西放进同一个应用,才可能把“浏览 → 选择 → 入队 → 续传 → 记账”连成一条不断裂的链路。
这篇文章会沿着这条链路,讲清楚整体架构、关键实现和使用方法。它既是一篇项目说明,也是一次复盘:哪些边界必须提前划清,哪些状态必须持久化,哪些“看起来能用”的实现其实会在断网或重启时写坏文件。

提示:本项目与 pixiv 官方无关。所有作品版权归原作者所有。请遵守 pixiv 服务条款,不要高频请求或滥用下载功能。文中出现的接口、路径与默认值均以当前版本为准,Pixiv 页面结构变化后可能需要相应调整。
PixivDesk 是一个非官方的 Pixiv 桌面客户端。它把「浏览作品」「批量收藏」「后台下载」「下载归档」放进同一个窗口,并且把登录凭据、下载记录和队列都留在本机。项目使用 Electron 45 与原生 JavaScript 编写,没有前端框架,也没有前端构建工具(webpack / Vite 之类)。所有作品版权归原作者所有,请遵守 Pixiv 服务条款,合理设置并发数。
先把功能拆开看:一个“能看”的客户端和一个“能归档”的客户端,差别不在界面,而在状态怎么保存、失败怎么恢复,以及哪些能力根本不应该交给页面。
为什么还要做一个桌面客户端
浏览器里看 Pixiv 并不难,难的是“看完之后”的动作:多选收藏、按画师归档、批量下载多页作品、断点续传、失败重试、记录哪些文件已经下载过。浏览器的下载管理器通常不提供作品级的分组、去重和恢复能力;而网页本身又受同源策略限制,无法随意读取 i.pximg.net 的图片并写入本地目录。
Electron 正好补上这一段:它既有 Chromium 的页面渲染能力,又有 Node.js 的文件系统与网络能力,还能直接管理持久化会话、代理和窗口。PixivDesk 因此选择了一个很“窄”的定位:
- 本地优先:没有自建服务器,不托管图片,不上传 Cookie。
- 浏览与归档一体:看到作品即可勾选,加入后台队列,完成后自动记账。
- 可恢复:队列、断点、下载记录都持久化;关掉应用再打开还能继续。
- 不绕过权限:R-18 仍然依赖账号设置;遇到人机验证时只做有限兜底,不尝试破解。
它不是一个“爬虫农场”,也不是一个绕过 Pixiv 限制的工具。它更像一个带归档能力的浏览器:把重复的点击和整理工作收进队列,把“我下过没有”这件事交给本地历史。
功能全景

从模块上可以分成四组:
| 分组 | 能做什么 | 关键设计 |
|---|---|---|
| 发现与搜索 | 个性化推荐、关注动态、小说、榜单、R-18;关键词 / 标签 / ID / 链接跳转 | 所有入口最终都归一成同一种 work 模型 |
| 画师与收藏 | 画师全部作品、公开 / 私密收藏夹、他人公开收藏、珍藏册 | 分页与“加载全部”共用同一套 API 适配 |
| 批量操作 | 多选、全选、选择未下载、批量收藏、批量下载 | 选择集跨页面共享,下载只入队不阻塞 |
| 下载与归档 | 持久化队列、并发控制、断点续传、失败重试、下载历史、导入导出 | 每页一条任务,.part + HTTP Range |
几个容易被忽略、但实际很影响体验的细节:
- 多页作品按页拆分任务。一件作品的第 0 页失败,不会拖累第 1 页;队列里可以单独重试、单独删除。
- “选择未下载”不是只看队列。它会同时检查下载历史和本地文件是否存在,所以换过下载目录、手动删过图片也能识别。
- 批量下载不弹进度窗。它只把任务加入队列,真正的传输在后台进行;上一批还在解析图片地址时,可以继续添加下一批。
- 小说有自己的阅读器。正文分页、
ruby注音、标签与作者跳转都在客户端内完成,不依赖外部浏览器。
技术选型:为什么是 Electron + 原生 JavaScript
| 选择 | 原因 | 代价 |
|---|---|---|
| Electron 45 | 需要持久化会话、代理、窗口、文件系统与 Chromium 渲染 | 安装包较大,内存占用高于原生应用 |
| 原生 ES Modules | 页面逻辑足够直接,省掉构建链和框架运行时 | 需要自己管理状态与视图更新 |
session.fetch | 自动携带 Cookie,能统一 UA / Referer / 代理 | 请求必须经过主进程,IPC 设计要克制 |
| 自定义协议 | 把本地页面和远程图片都纳入可控的白名单 | 需要自己做 MIME、缓存与路径校验 |
| JSON 文件持久化 | 数据量可控,易于备份、导入和排错 | 需要原子写入与防抖,避免半截文件 |
| electron-builder | 一次产出 NSIS 安装版与单文件便携版 | 未签名时 SmartScreen 会提示未知发布者 |
没有使用 React / Vue,并不是为了“炫技”。这个项目的界面以列表、网格和弹窗为主,状态集中在少数几个对象里;用原生模块配合事件总线,反而更容易看清数据从哪里来、到哪里去。代价是视图更新要手写,所以代码里大量使用事件委托和局部重渲染,而不是全量 diff。
一次典型使用流程
- 在「设置 → 网络代理」里填写
http://127.0.0.1:7890或socks5://127.0.0.1:7891,保存并测试连接。 - 点击右上角「登录」,在内置窗口完成 Pixiv 登录;如果窗口无法使用,可以导入
PHPSESSID。 - 在「发现」页切换个性化推荐、关注动态、小说、榜单或 R-18,或者在顶栏搜索关键词、标签、作品 ID、画师 ID、小说 ID,甚至直接粘贴 Pixiv 链接。
- 在卡片左上角勾选作品;底部出现批量操作栏。可以「全选」或「选择未下载」。
- 点击「批量下载」,任务进入后台队列;界面不会跳走,可以继续浏览或添加下一批。
- 在「下载」页查看进度、暂停 / 继续、重试失败任务;在「历史」页确认哪些文件已经落盘。
- 需要排错时,打开「设置 → 运行日志」,日志文件在
%APPDATA%\PixivDesk\logs\app.log。
这条链路对应的数据流是:渲染层收集作品 → IPC 入队 → 主进程解析图片地址 → 下载队列调度 → .part 写入 → 历史记账 → 事件回推界面。后面的章节会逐段拆开。
项目结构
main.js Electron 主进程入口、窗口、协议、生命周期
preload.js contextBridge 安全 API
src/main/
auth.js 登录窗口、Cookie 会话、CSRF、收藏与关注
pixiv-api.js Pixiv AJAX / 推荐 / 榜单 / 搜索 / 画师 / 珍藏册 / 小说
downloader.js 下载队列、并发、断点续传、失败任务、持久化
history.js 下载记录、导入导出、备份
logger.js 日志写入、轮转与读取
store.js 设置与 JSON 持久化
ipc.js IPC 处理与事件广播
util.js 工具函数
src/renderer/
index.html 界面结构
styles.css 浅色主题样式
js/ 状态、视图、卡片、批量操作、设置
search-suggest.js 标签自动联想与中日英翻译
views/ 发现、画师、收藏、珍藏册、下载、历史、设置、阅读器
scripts/start.js 清除 ELECTRON_RUN_AS_NODE 的开发启动器
接下来先看整体架构:三个执行层如何分工,以及为什么“图片必须经过 pixivimg://”。
PixivDesk 的运行结构可以概括成一句话:渲染层只负责“看见什么、选中什么”,主进程负责“请求什么、写到哪里”,preload 是两者之间唯一的窄门。
这样做不是为了套用 Electron 的最佳实践,而是因为下载器需要的权限太“重”了:读写任意下载目录、发起带 Cookie 的请求、设置系统代理、打开文件夹。把这些能力直接暴露给页面,任何一个 XSS 或远程内容都可能变成任意文件写入。PixivDesk 的选择是:页面永远拿不到 ipcRenderer、fs 或 session,只能调用一个被冻结的 window.pixivDesk 白名单。
三层分工:渲染、桥梁、主进程

| 层 | 运行位置 | 主要职责 | 不该做的事 |
|---|---|---|---|
| 渲染进程 | Chromium | 视图、状态、选择集、事件绑定、弹窗与提示 | 不直接发 Pixiv 请求,不读写文件 |
| preload | 渲染进程启动前 | 用 contextBridge 暴露白名单 API,转发事件订阅 | 不暴露 ipcRenderer 原始对象,不拼接动态通道 |
| 主进程 | Node.js | 登录、API、下载、历史、设置、日志、代理、窗口 | 不渲染业务界面,不信任渲染层传入的路径 |
| Electron / OS | 浏览器与系统 | 持久化会话、自定义协议、文件系统、网络栈 | —— |
preload.js 里的 API 是显式列出的,而不是用 ipcRenderer.invoke(channel, ...) 动态透传:
function subscribe(channel, callback) {
if (typeof callback !== 'function') return () => {};
const listener = (_event, payload) => callback(payload);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
}
const api = {
bootstrap: () => ipcRenderer.invoke('app:bootstrap'),
login: () => ipcRenderer.invoke('auth:login'),
enqueueDownloads: (works) => ipcRenderer.invoke('downloads:enqueue', works),
getQueue: () => ipcRenderer.invoke('downloads:get'),
inspectWorks: (works) => ipcRenderer.invoke('library:inspect', works),
onTasks: (callback) => subscribe('downloads:tasks', callback),
onQueueState: (callback) => subscribe('downloads:state', callback),
// ...
};
contextBridge.exposeInMainWorld('pixivDesk', Object.freeze(api));
这带来两个直接好处。第一,渲染层能调用的能力是可枚举的,安全审计只需要看这一份对象。第二,事件通道也是固定的:下载进度、队列状态、登录变化、历史变化各有自己的频道,不会出现“任意 channel 都能订阅”的漏洞。
启动链路
main.js 的启动顺序是刻意安排的:先确定用户数据目录,再创建持久化会话,然后初始化设置、登录、历史、API 和下载器,最后才注册协议、IPC 和窗口。原因是下载器在 init() 时就要读取队列并可能立即开始续传,它依赖 settings、history 和 auth 已经就绪。
app.setName('PixivDesk');
app.setPath('userData', path.join(app.getPath('appData'), 'PixivDesk'));
app.whenReady().then(async () => {
await initializeServices(); // settings → auth → history → api → downloader
configureProtocols(); // pixivdesk:// / pixivimg://
registerIpc({ /* ... */ }); // 请求与事件
mainWindow = createMainWindow();
});
启动时还会做几件容易被忽略的事:
- 单实例锁。第二次启动不会开出第二个下载器,而是把已有窗口恢复并聚焦。
- 下载目录兜底。如果设置里的目录不可写,会依次回退到“图片/PixivDesk”和用户数据目录下的
downloads。 - 代理预校验。启动时如果代理地址非法,会禁用代理而不是让整个应用起不来。
- 退出前保存。
before-quit会阻止默认退出,先让下载器 abort 正在传输的任务、等待写入停止并保存队列,再刷新历史,最后才真正退出。
自定义协议:把本地页面和图片都纳入白名单
PixivDesk 没有用 file:// 加载界面,也没有让 <img> 直接指向 i.pximg.net。它注册了两个自定义协议:
| 协议 | 用途 | 关键限制 |
|---|---|---|
pixivdesk://bundle/... | 加载 src/renderer 下的 HTML / JS / CSS | 路径必须落在渲染目录内,防止目录穿越 |
pixivimg://image/?url=... | 代理 Pixiv 图片,补上 Referer | 只允许 HTTPS 与 pximg.net / embed.pixiv.net |
本地页面协议的核心是路径校验:
const filePath = path.resolve(
RENDERER_ROOT,
`.${pathname.replace(/\\/g, '/')}`,
);
if (
filePath !== RENDERER_ROOT &&
!filePath.startsWith(`${RENDERER_ROOT}${path.sep}`)
) {
return new Response('Forbidden', { status: 403 });
}
图片协议解决的是一个很实际的问题:i.pximg.net 会检查 Referer,直接用 <img src="https://i.pximg.net/..."> 很可能拿到 403;而把图片地址交给渲染层再发请求,又会让 Cookie 和代理配置散落到页面里。主进程代理的方案是:渲染层只写 pixivimg://image/?url=...,主进程校验域名后用 auth.fetchRaw() 发请求,补上 Referer: https://www.pixiv.net/,再把响应流原样返回。
const host = imageUrl.hostname.toLowerCase();
if (
imageUrl.protocol !== 'https:' ||
!(
host === 'pximg.net' ||
host.endsWith('.pximg.net') ||
host === 'embed.pixiv.net'
)
) {
return new Response('Image host is not allowed', { status: 403 });
}
这样一来,图片请求和 API 请求走同一条网络出口:同一个代理、同一套 UA、同一份 Cookie。切换代理时不需要分别处理页面和下载器。
安全边界
Electron 应用最常见的风险是“渲染层被当成 Node 用”。PixivDesk 在几个层面上把这条路径堵住:
| 风险点 | 处理方式 |
|---|---|
| 渲染层获得 Node 能力 | nodeIntegration: false、contextIsolation: true、sandbox: true |
| 页面被导航到外部站点 | will-navigate 只允许 pixivdesk://bundle/,其他 http(s) 交给系统浏览器 |
| 新窗口打开远程页面 | setWindowOpenHandler 一律 deny,http(s) 用 shell.openExternal |
<webview> 绕过隔离 | will-attach-webview 直接阻止 |
| 远程脚本注入 | CSP:script-src 'self'、connect-src 'self'、object-src 'none' |
| 伪造 IPC 来源 | 每个 handler 先执行 assertTrusted(event) |
| 本地文件路径被恶意构造 | isSafeLocalPath() 拒绝 UNC / 设备路径,只接受绝对盘符路径 |
| 图片协议被当成任意代理 | 仅 HTTPS,且域名必须在 pximg.net 白名单内 |
| 导入历史触发网络路径 | 导入时把非法 filePath 归一为空字符串 |
IPC 来源校验的实现很短,但它是所有 handler 的第一道门:
function assertTrusted(event) {
const url = event.senderFrame?.url || '';
if (!url.startsWith('pixivdesk://bundle/')) {
throw new Error('不受信任的请求来源');
}
}
需要说明的是,这些措施的目标是“把攻击面收窄”,而不是宣称绝对安全。项目仍然依赖 Electron 与 Chromium 的安全更新;如果你要把它分发到不受控的环境,应该保持依赖更新,并考虑代码签名。
IPC 设计:请求用 invoke,状态用事件
PixivDesk 的 IPC 分成两类:
- 请求 / 响应:页面主动要数据或执行动作,例如
bootstrap、pixiv:search、downloads:enqueue、history:export-json。统一用ipcMain.handle+ipcRenderer.invoke。 - 单向事件:主进程把下载进度、队列汇总、登录状态、历史变化推给所有窗口。统一用
webContents.send,preload 里再包一层subscribe。
下载器的事件不是“每写一个 chunk 就发一次”。那样在高速下载时会把 IPC 打满。它做了两级合并:
- 任务级事件
downloads:tasks每 180ms 合并一次; - 队列汇总
downloads:state每 300ms 合并一次; - 队列文件每 500ms 防抖保存,退出时强制落盘。
这类节流不会影响进度条的“顺滑感”,因为界面本来就不需要每个字节都刷新。它换来的是更低的 CPU 占用和更稳定的渲染。
渲染层:一个状态对象 + 一个事件总线
渲染层没有引入框架,而是用一个中心 state 和一个 EventTarget 总线组织代码:
const eventTarget = new EventTarget();
export const bus = {
emit(name, detail) {
eventTarget.dispatchEvent(new CustomEvent(name, { detail }));
},
on(name, callback) {
const listener = (event) => callback(event.detail);
eventTarget.addEventListener(name, listener);
return () => eventTarget.removeEventListener(name, listener);
},
};
export const state = {
settings: { /* ... */ },
auth: { /* ... */ },
browse: { items: [], page: 1, hasMore: true, /* ... */ },
user: { items: [], /* ... */ },
bookmarks: { items: [], /* ... */ },
collections: { works: [], /* ... */ },
queue: { tasks: [], summary: { /* ... */ } },
selection: new Map(),
downloadStatus: new Map(),
};
几个关键约定:
- 选择集是全局的。
state.selection是一个Map<id, work>,所以在“发现”页勾选的作品,切到“收藏夹”后仍然保留;底部操作栏读取的是同一个集合。 - 视图只渲染自己关心的列表。
browse、user、bookmarks、collections各自持有items,但共用grid.js的卡片渲染和事件委托。 - 事件委托优先。网格容器只绑定一次
click,通过data-work-id、data-select-work、data-open-user判断目标,避免每次重渲染都重新绑定几百个监听器。 - 错误统一转成可读文本。
friendlyError()会剥掉 Electron 的Error invoking remote method ...前缀,避免把内部通道名直接展示给用户。
数据持久化:原子写入与防抖
所有 JSON 状态都通过 store.js 里的 JsonFile 或原子写入函数落盘。核心不是“写文件”,而是“不要在崩溃时留下半个 JSON”:
async flush() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const snapshot = JSON.stringify(this.data);
this.writeChain = this.writeChain
.catch(() => {}) // 上一次失败不能污染后续写入
.then(async () => {
await fs.promises.mkdir(path.dirname(this.file), { recursive: true });
const temp = `${this.file}.${process.pid}.${Date.now()}.tmp`;
await fs.promises.writeFile(temp, snapshot, 'utf8');
await fs.promises.rename(temp, this.file);
});
return this.writeChain;
}
先写同目录临时文件,再 rename 覆盖目标文件。rename 在同一文件系统内是原子的,所以读取方要么看到旧文件,要么看到新文件,不会看到写了一半的内容。设置文件则使用同步版本 writeJsonAtomicSync(),因为设置更新的频率低,而且需要在返回前确保已经落盘。

这种“应用状态”和“作品文件”分开的布局,也解释了为什么换下载目录不会丢历史:历史记录在 %APPDATA%\PixivDesk\history.json,图片在用户选择的目录;历史里保存的是绝对路径,所以移动文件后会被标记为“文件缺失”,但记录本身还在。
架构讲到这里,下一章进入真正的业务核心:登录、API 适配与下载状态机。
这一章拆四个核心模块:登录、API 适配、下载引擎、历史判定。它们之间的依赖是单向的:下载器依赖 API 与历史,API 依赖登录会话,历史只依赖文件系统。理解这条依赖链,就能理解为什么某些错误会出现在“下载失败”,而不是“登录失败”。
登录:复用 Electron 会话,而不是自己维护 Cookie
Pixiv 的登录态本质上是一枚 PHPSESSID Cookie。很多桌面客户端会选择“让用户在浏览器登录,再复制 Cookie”,PixivDesk 也支持这条路,但更推荐内置登录窗口:它复用了 Electron 的持久化会话,登录完成后 Cookie 直接留在 persist:pixivdesk 分区里,不需要经过剪贴板。

内置登录的流程并不复杂:
- 用
BrowserWindow打开accounts.pixiv.net/login,session指向持久化分区。 - 每 1.8 秒调用一次
verify(),同时监听页面导航事件。 GET /ajax/user/extra返回200 + error:false时,视为登录成功。- 成功后同步
/ajax/user/self的账号信息,并尽量从登录页上下文提取 CSRF 令牌;后续写操作再按需刷新。
验证逻辑要区分三种状态:已登录、未登录、网络不可达。这个区分决定了界面应该提示“请重新登录”还是“请检查代理”:
async verify(timeout = 20_000) {
const response = await this.fetchRaw(
'https://www.pixiv.net/ajax/user/extra',
{ headers: { Accept: 'application/json, text/plain, */*' }, timeout },
);
const data = await response.json().catch(() => null);
if (response.ok && data && data.error === false) {
this.verified = true;
this.hasCookies = true;
this.offline = false;
this.lastError = '';
await this.resolveAccountFromSelf().catch(() => {});
this.emitStatus();
return true;
}
if (response.ok || [400, 401, 403].includes(response.status)) {
this.verified = false;
this.hasCookies = await this.detectSessionCookies();
this.offline = false;
this.lastError = data?.message || '登录状态已失效';
this.emitStatus();
return false;
}
throw new Error(`Pixiv 会话检查失败(HTTP ${response.status})`);
}
当 fetch 本身抛错(超时、DNS、代理拒绝)时,外层会设置 offline = true,界面显示“网络不可用 · 请检查代理”。这比简单地说“登录失效”更接近真实原因。
CSRF 令牌:收藏和关注的另一半凭据
Pixiv 的写操作不只需要 Cookie,还需要 x-csrf-token。令牌藏在首页的 <meta id="meta-global-data"> 里,也可能出现在 window.pixiv.context.token 中。PixivDesk 的做法是:
- 优先从
meta-global-data解析; - 解析失败时用正则从 HTML 里找
token; - 登录窗口还开着时,直接执行页面脚本读取
window.pixiv.context; - 令牌缓存 10 分钟,写操作返回 403 或提示 token 失效时强制刷新一次。
const pages = [
'https://www.pixiv.net/',
'https://www.pixiv.net/bookmark_add.php?type=illust&illust_id=1',
'https://www.pixiv.net/en/',
];
for (const url of pages) {
const response = await this.fetchRaw(url, { /* ... */ });
const html = await response.text();
token = findToken(html, parseMetaGlobalData(html));
if (token) break;
}
把令牌和会话分开处理,是因为它们的生命周期不同:Cookie 可能长期有效,令牌却会随页面状态变化。缓存时间设成 10 分钟,是在“少发一次首页请求”和“令牌失效后能自动恢复”之间取的折中。
代理:一次设置,整条链路生效
代理不是只作用于 API。PixivDesk 在 PixivAuth.applyProxy() 里调用 session.setProxy(),而 API、图片、登录窗口都复用同一个 session。切换代理后会调用 session.closeAllConnections(),避免旧连接继续走旧出口。
支持 http://、https://、socks4://、socks5://,也支持 proxyBypass。如果启动时发现代理地址非法,会禁用代理并记录错误,而不是让应用直接崩溃。
Pixiv API 适配层:把多种响应形状归一成一种作品模型
Pixiv 的接口并不统一:榜单返回 illust_id,搜索返回 id,关注动态把缩略图按索引存在 thumbnails.illust 里,珍藏册又要求解析 Next.js 的 __NEXT_DATA__。如果让每个视图自己处理这些差异,代码会迅速失控。
PixivDesk 用 src/main/pixiv-api.js 做了一层适配:所有入口最终都返回 { items, page, hasMore, total, source },每个 item 都是同一种 work 结构。
| 场景 | 接口 | 备注 |
|---|---|---|
| 个性化推荐 | /ajax/discovery/artworks?mode=&limit=60&offset= | 登录后按账号推荐;未登录回退榜单 |
| 关注动态 | /ajax/follow_latest/illust?mode=all&p= | 缩略图按索引返回,需要按 ID 建映射 |
| 排行榜 | /ranking.php?format=json&mode=&content=illust&p= | 日 / 周 / 月 / 新人 / 原创 / 男性向 / 女性向 |
| 搜索 | /ajax/search/artworks/{word}?word=&order=&mode=&s_mode=&p= | s_tag_full 为标签精确匹配 |
| 标签联想 | /ajax/search/artworks/{word} + /ajax/search/tags/{tag}?lang=zh | 合并相关标签与中日英翻译 |
| 作品详情 | /ajax/illust/{id} | 单页作品可能带 meta_single_page |
| 作品图片 | /ajax/illust/{id}/pages | 返回每页的 original / regular 地址 |
| 相关作品 | /ajax/illust/{id}/recommend/init?limit= | 不足时用标签搜索和 discovery 兜底 |
| 画师作品 | /ajax/user/{id}/profile/all + /profile/illusts?ids[]= | 先拿全部 ID,再按页取详情 |
| 收藏夹 | /ajax/user/{id}/illusts/bookmarks?rest=show / ?rest=hide | hide 只允许看自己的 |
| 珍藏册 | /ajax/user/{id}/profile/collections?ids[]= / /collections/{id} | 详情页是 HTML,解析 __NEXT_DATA__ |
| 小说 | /ajax/top/novel?mode=all / /ajax/novel/{id} | 正文、字数、R-18 / AI 标记 |
所有请求都经过同一个 getJson()。它只做三件事:拼出完整 URL、对 429 / 5xx 做一次短重试、把网络错误转成可读提示:
async getJson(pathname, options = {}) {
const url = pathname.startsWith('http')
? pathname
: `https://www.pixiv.net${pathname}`;
let lastError;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
return await this.auth.apiJson(url, options);
} catch (error) {
lastError = error;
const status = error.status || 0;
if (status === 429 || status >= 500) {
await sleep(900 * (attempt + 1));
continue;
}
if (!status) throw errorFromNetwork(error);
throw error;
}
}
throw errorFromNetwork(lastError);
}
统一的作品模型
normalizeWork() 负责把不同接口的字段映射成同一份对象,并做默认值、空值清理和 AI 标记:
return {
id,
title: normalizeWhitespace(title) || `作品 ${id}`,
userId,
userName: normalizeWhitespace(userName) || '未知画师',
userImage,
thumbnail,
pageCount: Math.max(1, pageCount),
xRestrict,
aiType,
ai: detectAI(aiType, tags, title),
tags,
illustType,
bookmarkData: source.bookmarkData || null,
isMasked,
createDate,
width,
height,
description: stripHtml(source.description || source.illustComment || ''),
bookmarkCount,
likeCount,
viewCount,
original: asString(source.urls?.original || source.meta_single_page?.original_image_url),
regular: asString(source.urls?.regular || source.urls?.small || source.url),
};
这一步看似只是“改字段名”,实际上解决了三个问题:
- 视图不需要知道数据来自哪个接口。搜索结果和榜单结果可以放进同一个网格。
- 缺失字段有稳定默认值。画师名缺失时显示“未知画师”,页码至少为 1,避免后续出现
NaN。 - AI 标记只算一次。优先使用
aiType === 2,否则用标签和标题的正则匹配;搜索结果还会通过/touch/ajax/illust/details/many批量补充详情。
AI 识别为什么要“补充详情”
搜索列表返回的 aiType 有时是 0,但并不代表作品不是 AI 生成。PixivDesk 会把缺失 aiType 的作品按 50 个一组调用 touch/ajax/illust/details/many,合并标签、页数、R-18 和 AI 类型。接口不可用时,退化为标签正则:
const AI_TAG_PATTERN =
/(^|\s)(ai|ai生成|aiイラスト|ai-art|aiart|ai绘画|ai作品|生成ai|stable[\s-]?diffusion|midjourney|novelai|nijijourney|dall[\s-]?e|sdxl)(\s|$)/i;
function detectAI(aiType, tags, title = '') {
if (Number(aiType) === 2) return true;
const combined = [...(tags || []), title].join(' ');
return AI_TAG_PATTERN.test(combined);
}
这不是完美的分类器,但它足够稳定:有官方标记时用官方标记,没有时用保守的标签规则,且不会因为一次接口失败而让整个列表不可用。
标签联想:把“翻译”也当成排序信号
顶栏输入文字时,PixivDesk 会同时请求相关标签与标签翻译,然后把日文、中文、英文一起展示。候选的排序分数由三部分组成:
score =
completeness * 20 + // 有几种语言翻译
(exact ? 12 : 0) + // 与输入完全匹配
positionBonus; // 相关标签越靠前越高
最终取前 8 个,缓存 60 秒;单个标签的翻译缓存 30 分钟。这样用户输入“猫”时,既能找到日文标签“猫”,也能看到英文 cat,而不会只得到一串无法辨认的假名。
下载引擎:每页一条任务,断点落在 .part

下载器是项目中状态最多、边界最多的模块。它的设计目标不是“最快”,而是“可控、可恢复、可解释”。
任务模型
一件多页作品会被拆成多条任务,任务键是 illustId_p页码:
function taskId(illustId, page) {
return `${illustId}_p${page}`;
}
文件路径按画师归档,文件名包含作品 ID、页码和标题:
function targetPath(settings, work, pageIndex, extension) {
const folder = sanitizeSegment(
`${work.userName || '未知画师'}_${work.userId || '0'}`,
'未知画师',
);
const title = sanitizeSegment(work.title || '', '')
.replace(/\s+/g, ' ')
.slice(0, 52);
const fileName = `${work.id}_p${String(pageIndex).padStart(2, '0')}${
title ? `_${title}` : ''
}.${extension}`;
return path.join(settings.downloadDir, folder, fileName);
}
sanitizeSegment() 会处理 Windows 非法字符、结尾的点和空格、CON / NUL 等保留名,并限制长度。这一步不是“洁癖”,而是避免某些标题直接导致写入失败。
入队:先判断“要不要下”,再解析地址
enqueue() 不会立刻开始传输。它先做去重、ID 校验和历史判断,再按 3 路并发解析每件作品的图片地址。解析请求之间有 180ms 的节流,避免一次性向 Pixiv 打出几十个请求。
跳过逻辑分三层:
// 1. 整件作品已经下载过(历史 + 本地文件)
if (
settings.skipDownloaded &&
this.history.isWorkDownloaded(work, { treatMissingAsDownloaded })
) {
local.skipped += Math.max(1, pageCount);
return local;
}
// 2. 队列里已经存在同一页的 queued / downloading / completed 任务
const existing = this.getTask(taskId(illustId, pageIndex));
if (existing && ['queued', 'downloading', 'completed'].includes(existing.status)) {
local.skipped += 1;
continue;
}
// 3. 本地已有文件,但没有历史记录:补登记后跳过
if (
settings.skipDownloaded &&
(historySaysDownloaded ||
(fs.existsSync(filePath) &&
(!historyEntry || this.history.fileExists(historyEntry))))
) {
if (!historyEntry) {
const stat = await fs.promises.stat(filePath);
this.history.add({
key: id,
illustId,
page: pageIndex,
title: asString(work.title),
fileName: path.basename(filePath),
filePath,
bytes: stat.size,
downloadedAt: new Date().toISOString(),
adopted: true,
});
}
local.skipped += 1;
continue;
}
第三层叫“收养”(adopt):用户在文件管理器里手动放进去的图片,只要路径和命名符合规则,就会被补登记到历史里,下次不会重复下载。这比单纯检查“文件存在”更可靠,因为历史记录还保存了作品 ID、页码、大小和下载时间。
调度:并发是上限,不是目标
pump() 只做一件事:在并发上限内,把 queued 任务交给 startTask()。
pump() {
if (this.paused || this.shuttingDown) return;
const concurrency = Math.max(1, this.settingsGetter().concurrency || 3);
let activeCount = [...this.active.keys()].length;
for (const task of this.tasks) {
if (activeCount >= concurrency) break;
if (task.status !== 'queued' || this.active.has(task.key)) continue;
this.startTask(task);
activeCount += 1;
}
}
默认并发是 3,设置范围是 1–6。项目在 README 里建议 2–4,是因为 Pixiv 的图片服务器会对高频请求限流;并发越高,越容易触发 429,反而拖慢整体完成时间。
每个任务还有一个 AbortController。暂停、退出应用时,控制器会 abort 当前请求,任务回到 queued,.part 文件保留。
断点续传:206 追加、200 重下、416 清理
downloadOnce() 是断点续传的核心。它先读取 .part 的已有大小,再决定是否带 Range 头:
const partPath = `${task.filePath}.part`;
let start = 0;
try {
start = (await fs.promises.stat(partPath)).size;
} catch {
start = 0;
}
const headers = {
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
Referer: PIXIV_REFERER,
};
if (start > 0) headers.Range = `bytes=${start}-`;
const response = await this.auth.fetchRaw(task.sourceUrl, {
headers,
signal,
timeout: IMAGE_TIMEOUT,
});
接下来要处理三种响应:
- 206 Partial Content:服务器接受了 Range。检查
Content-Range的起点是否等于已有字节数;一致才用追加模式打开.part,不一致就删掉临时文件重下。 - 200 OK:服务器忽略了 Range。此时不能追加,否则会把整个文件拼在旧数据后面;必须清空
.part,从头写入。 - 416 Range Not Satisfiable:说明
.part的大小已经超出服务器认为的文件范围,通常是旧断点失效。删除.part,抛出一个可重试错误,下一轮从头开始。
const append = response.status === 206 && start > 0;
if (append) {
const contentRange = response.headers.get('content-range') || '';
const match = contentRange.match(/bytes\s+(\d+)-(\d+)\/(\d+|\*)/i);
if (match && Number(match[1]) !== start) {
await fs.promises.rm(partPath, { force: true });
throw new Error('断点位置不一致,正在重新下载');
}
task.bytesTotal = match && match[3] !== '*'
? Number(match[3])
: start + Number(response.headers.get('content-length') || 0);
} else {
start = 0;
task.bytesReceived = 0;
task.bytesTotal = Number(response.headers.get('content-length') || 0);
}
写入完成后还要做一次字节数校验:如果 bytesTotal > 0 且实际接收小于它,说明连接提前结束,抛出错误让重试机制接管。只有完整接收后,才会把 .part rename 成正式文件:
await fs.promises.rename(partPath, task.filePath);
rename 保证正式文件不会以“写了一半”的状态出现;在 Windows 上,如果目标文件已存在,代码会先 rm 再 rename。
重试:只对“可能恢复”的错误重试
for (let attempt = 0; attempt < 4; attempt += 1) {
task.attempt = attempt + 1;
try {
await this.downloadOnce(task, signal);
return;
} catch (error) {
const status = error.status || 0;
const retryable =
!status || status === 429 || status === 408 || status >= 500;
if (!retryable || attempt === 3) break;
const backoff = status === 429 ? 5000 : 1000 * 2 ** attempt;
await sleep(backoff, signal);
}
}
- 网络错误(没有 HTTP 状态)、408、429、5xx 会重试;
- 404、403 等不可恢复错误直接标记
failed,避免无意义等待; - 429 固定等 5 秒,其他错误按 1s / 2s / 4s 指数退避;
- 每页最多 4 次尝试,仍失败则保留错误信息,用户可以手动重试或删除。
持久化与退出
队列保存在 download-queue.json,但不会每写一个 chunk 就落盘。persist() 默认走 500ms 防抖;暂停、入队完成、任务完成等关键节点会立即保存。退出时:
- 设置
shuttingDown = true; - abort 所有活跃任务;
- 最多等 3 秒让写入停止;
- 把
downloading状态改回queued; - 保存队列并刷新历史。
即使进程被强制结束,.part 文件还在,下次启动 init() 会把 downloading 改回 queued,并从已有字节继续。
下载历史:illustId_pN 是唯一的账本键
历史记录不是“文件列表”,而是“作品页 → 文件”的映射。键是 illustId_pN,值是文件路径、大小、标题、画师、URL 和下载时间。
static key(illustId, page) {
return `${illustId}_p${page}`;
}
判断一件作品是否“已下载”时,不能只看历史里有没有记录,还要看文件是否还在:
workStatus(work, { treatMissingAsDownloaded = false } = {}) {
const illustId = asString(work.id);
const pageCount = parseInteger(work.pageCount, 0);
let downloadedPages = 0;
let missingFiles = 0;
for (let page = 0; page < pageCount; page += 1) {
const entry = this.get(HistoryStore.key(illustId, page));
if (!entry) continue;
const exists = this.fileExists(entry);
if (exists || treatMissingAsDownloaded) downloadedPages += 1;
if (!exists) missingFiles += 1;
}
let status = 'new';
if (downloadedPages === pageCount) status = 'downloaded';
else if (downloadedPages > 0) status = 'partial';
return { id: illustId, status, pageCount, downloadedPages, missingFiles };
}
三个状态对应三种界面行为:
new:没有下载过,正常入队;partial:下过一部分,默认会跳过已完成的页,只补缺失的页;downloaded:所有页都有记录且文件存在,直接跳过。
treatMissingAsDownloaded 来自设置项「本地文件删除后仍视为已下载」。它默认开启,意思是:只要历史里有记录,即使图片被手动删除,再次下载同一作品时仍然跳过。关闭后,缺失文件会被视为未下载,重新补下。这个开关解决的是两种相反的需求:有人把历史当“永久账本”,有人希望“文件没了就重下”。
历史还支持 JSON / CSV 导出、导入合并和备份到 下载目录\.pixivdesk-backups。导入时会对 filePath 做安全校验,拒绝 UNC 和设备路径;CSV 使用 UTF-8 BOM 与 CRLF,方便 Excel 直接打开。
批量收藏与关注:把写操作也纳入队列
批量收藏不是简单地循环 fetch。它需要登录校验、CSRF 令牌、公开 / 私开展开选择、限速和进度回推。
async addBookmark(illustId, restrict = 0) {
let token = await this.getToken();
const response = await this.fetchRaw(
'https://www.pixiv.net/ajax/illusts/bookmarks/add',
{
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Origin: 'https://www.pixiv.net',
'x-csrf-token': token,
},
body: JSON.stringify({
comment: '',
illust_id: Number(illustId),
restrict: restrict ? 1 : 0,
tags: [],
}),
},
);
// 403 / token 失效时刷新令牌并重试一次
}
主进程的批量循环每收藏一件作品,就广播一次 bookmarks:progress,并在两次请求之间等待 800ms:
for (let index = 0; index < items.length; index += 1) {
await auth.addBookmark(items[index].id, restrict);
broadcast('bookmarks:progress', {
done: index + 1,
total: items.length,
ok,
failed: failed.length,
current: items[index].title,
});
if (index < items.length - 1) await sleep(800);
}
关注作者走的是另一条接口:POST /bookmark_add.php,参数里 type=user、mode=add|delete。如果接口失败(例如 Pixiv 要求人机验证),会尝试用一个隐藏窗口打开画师主页,执行页面脚本点击“关注 / 取消关注”按钮。这是有限兜底,不保证成功;失败时界面会提示用户去浏览器手动操作。
界面层:链接识别与“选择未下载”
顶栏搜索支持“自动识别”。用户不需要先选类型,直接粘贴链接即可:
function parsePixivLink(value) {
const text = String(value || '').trim();
if (!/pixiv\.net/i.test(text) && !text.startsWith('/')) return null;
const parsed = new URL(
text.startsWith('/') ? `https://www.pixiv.net${text}` : text,
);
if (!/(^|\.)pixiv\.net$/i.test(parsed.hostname)) return null;
const path = decodeURIComponent(parsed.pathname);
let match = path.match(/\/artworks\/(\d+)/);
if (match) return { type: 'illust', id: match[1] };
match = path.match(/\/collections\/(\d+)/);
if (match) return { type: 'collection', id: match[1] };
match = path.match(/\/users\/(\d+)\/bookmarks(?:\/artworks)?/);
if (match) return { type: 'bookmarks', id: match[1] };
match = path.match(/\/users\/(\d+)\/collections/);
if (match) return { type: 'user-collections', id: match[1] };
match = path.match(/\/users\/(\d+)/);
if (match) return { type: 'artist', id: match[1] };
match = path.match(/\/novel\/(\d+)/);
if (match) return { type: 'novel', id: match[1] };
match = path.match(/\/tags\/([^/]+)/);
if (match) return { type: 'keyword', keyword: decodeURIComponent(match[1]) };
return null;
}
识别顺序很重要:/users/{id}/bookmarks 必须排在 /users/{id} 前面,否则会被当成画师主页;“选择未下载”则把判断交给主进程,而不是在前端猜测文件是否存在:
export async function selectUndownloaded() {
const works = currentVisibleWorks().filter((work) => work.kind !== 'novel');
const statuses = await window.pixivDesk.inspectWorks(
works.map((work) => ({ id: work.id, pageCount: work.pageCount })),
);
setDownloadStatuses(statuses);
const statusMap = new Map(
statuses.map((item) => [String(item.id), item.status]),
);
for (const work of works) {
if (statusMap.get(String(work.id)) === 'downloaded') continue;
selectWork(work);
}
}
inspectWorks 在主进程里调用 history.workStatus(),所以它看到的是历史记录与真实文件系统的组合,而不是渲染层缓存的“已下载”徽章。这也是为什么“删除本地文件后重新选择未下载”能正常工作。
错误处理与日志
主进程的错误分两类:可以直接给用户看的(登录失效、网络超时、文件不存在)和需要写日志的(接口结构变化、写入失败、未知异常)。logger.js 负责后者:
- 日志文件:
%APPDATA%\PixivDesk\logs\app.log; - 单文件超过 5 MB 时轮转为
app.log.1; - 写入失败不会影响应用运行;
- 渲染层的
window.onerror和unhandledrejection也会通过 IPC 写进同一份日志。
渲染层则用 friendlyError() 清理 Electron 的错误前缀:
export function friendlyError(error) {
let message = String(error?.message || error || '操作失败');
return message
.replace(/^Error invoking remote method '[^']+':\s*Error:\s*/i, '')
.replace(/^Error invoking remote method '[^']+':\s*/i, '')
.replace(/^Error:\s*/i, '')
.trim();
}
性能取舍
| 位置 | 策略 | 目的 |
|---|---|---|
| 图片地址解析 | 3 路并发 + 180ms 请求间隔 | 避免入队时打爆 Pixiv |
| 下载 | 并发 1–6,默认 3 | 在速度与限流之间取平衡 |
| 任务事件 | 180ms 合并 | 降低 IPC 与重渲染频率 |
| 队列汇总 | 300ms 合并 | 进度条仍然顺滑 |
| 队列落盘 | 500ms 防抖 | 减少磁盘写入 |
| 历史落盘 | 150ms 防抖 | 批量完成时合并写入 |
| 已完成任务 | 最多保留 400 条 | 防止队列文件无限增长 |
| 下载页渲染 | 最多 150 组 | 避免超大 DOM |
| 图片缓存 | Cache-Control: public, max-age=86400 | 减少重复请求 |
这些数字都不是“绝对最优”,而是项目在当前网络环境下的经验值。真正重要的是它们被集中定义、可以被解释;如果你要把它改成更适合自己网络的配置,应该从这些常量入手,而不是在每个视图里散落魔法数字。
核心逻辑讲完,下一章换到使用者视角:怎么安装、登录、搜索、批量下载,以及遇到问题时先看哪里。
这一章从使用者视角出发:怎么启动、怎么登录、怎么搜索、怎么批量下载,以及遇到问题时应该先检查什么。命令和路径都以 Windows 为准。
环境要求
- Windows 10 / 11(项目在 Windows 上开发和验证)
- 免安装版不需要 Node.js;从源码运行需要 Node.js 22.12 或更高版本
- 能访问
www.pixiv.net与i.pximg.net,或者配置可用的代理 - 建议至少保留 2 GB 磁盘空间用于图片与缓存
三种启动方式
方式一:免安装版(推荐给普通用户)
仓库里已经包含构建好的 Windows x64 版本:
release\PixivDesk-win32-x64\PixivDesk.exe
如果双击没有反应,或者终端环境里设置过 ELECTRON_RUN_AS_NODE,请使用根目录的启动脚本:
启动 PixivDesk.cmd
这个脚本会在启动前清除 ELECTRON_RUN_AS_NODE,避免 Electron 被当成 Node 运行。某些 IDE、终端或自动化环境会继承这个变量,这是最常见的“双击没反应”原因之一。
方式二:安装版 / 便携版
如果你自己打包,dist 目录会生成:
dist/PixivDesk-1.0.0-Setup.exe # 安装版,可选目录并创建快捷方式
dist/PixivDesk-1.0.0-Portable.exe # 单文件便携版,解压即用
当前构建没有配置代码签名证书,Windows SmartScreen 可能提示“未知发布者”。选择“更多信息 → 仍要运行”即可。如果要公开分发给他人,建议使用可信证书签名。
方式三:从源码运行
npm install
npm start
开发模式(自动打开 DevTools):
npm run dev
只做语法检查:
npm run check
首次配置
1. 配置代理(如果需要)
如果直接连接 Pixiv 超时,先进入「设置 → 网络代理」:
- 勾选「启用代理」;
- 填写代理地址,例如
http://127.0.0.1:7890或socks5://127.0.0.1:7891; - 如有需要,填写绕过规则(默认
<local>); - 点击「保存并测试连接」。
测试会请求 https://www.pixiv.net/ajax/user/extra。返回 200、400 或 401 都说明网络可达;超时或连接拒绝则说明代理不可用。代理会同时作用于 API、图片和登录窗口,不需要分别设置。
2. 登录 Pixiv
点击右上角「登录」,或进入「设置 → 账号 → 登录 Pixiv」。内置窗口会打开 Pixiv 登录页,完成登录后窗口会自动关闭。
如果内置窗口无法使用,可以导入 Cookie:
- 在浏览器中登录 pixiv;
- 打开开发者工具 → Application / Storage → Cookies;
- 找到
PHPSESSID,复制PHPSESSID=...(或整段 Cookie); - 在「设置 → 账号 → 导入 Cookie」中粘贴并确认。
导入的 Cookie 会写入 .pixiv.net 域,有效期 365 天。退出登录时,应用会清除 Cookie、缓存和 account.json,但不会删除下载记录、队列或本地图片。
3. 确认登录状态
登录成功后,侧边栏会显示账号头像与昵称,设置页会显示「已连接到 Pixiv」。如果显示「网络不可用 · 请检查代理」,说明请求没有到达 Pixiv,而不是账号失效。
界面地图
左侧导航有七个入口:
| 入口 | 用途 |
|---|---|
| 发现 | 个性化推荐、关注动态、小说、榜单、R-18 |
| 画师 | 输入画师 ID 或主页链接,浏览全部投稿 |
| 收藏夹 | 自己的公开 / 私密收藏,或他人的公开收藏 |
| 珍藏册 | 画师公开珍藏册列表与详情 |
| 下载 | 队列、进度、暂停 / 继续、失败重试 |
| 历史 | 下载记录、文件缺失检查、导入导出 |
| 设置 | 账号、下载、筛选、代理、日志、关于 |
顶栏还有全局搜索、R-18 / AI 筛选开关、账号入口。Ctrl + K 可以快速聚焦搜索框。
发现页
发现页有五个板块:
- 个性化推荐:登录后根据账号的收藏与浏览习惯生成,可连续加载更多;未登录时回退到热门榜单。
- 关注动态:显示关注画师的最新作品,需要登录。
- 小说:Pixiv 小说推荐与排行,卡片显示字数与 R-18 / AI 标记。
- 榜单:日榜、周榜、月榜、新人榜、原创榜、男性向、女性向。
- R-18:成人向推荐,需要登录,并且在账号设置中允许显示 R-18。
R-18 与 AI 开关会直接作用于发现页、画师页、收藏夹和珍藏册。筛选只影响“显示”,不会改变已经入队的下载任务。
搜索与链接跳转
顶栏搜索默认是「自动识别」,也可以在下拉框里指定关键词、标签、插画、画师或小说。输入文字时会出现标签联想,同时显示日文、中文、英文翻译;用 ↑ ↓ 选择,Enter 搜索,点击候选会按该标签检索。
支持直接粘贴的链接:
| 链接形式 | 行为 |
|---|---|
https://www.pixiv.net/artworks/123456 | 打开作品详情 |
https://www.pixiv.net/users/123456 | 打开画师主页 |
https://www.pixiv.net/users/123456/bookmarks/artworks | 打开该用户公开收藏 |
https://www.pixiv.net/users/123456/collections | 打开该用户珍藏册列表 |
https://www.pixiv.net/collections/789 | 打开珍藏册详情 |
https://www.pixiv.net/novel/show.php?id=789 | 打开小说阅读器 |
https://www.pixiv.net/novel/789 | 同上 |
https://www.pixiv.net/tags/猫 | 按标签检索 |
...?word=关键词 或 ?q=关键词 | 按关键词检索 |
如果只输入纯数字,客户端会依次尝试“插画 → 小说 → 画师”,所以输入作品 ID 和画师 ID 都能直接跳转。作品详情页里的标签可以点击,按该标签精确检索;作者名和「作者主页」按钮可以打开画师页。
画师主页
输入画师数字 ID 或主页链接即可打开。画师页支持:
- 分页浏览全部投稿;
- 「加载全部作品」一次性拉取该画师的完整作品列表;
- 关注 / 取消关注;
- 跳转到该画师的公开收藏;
- 跳转到该画师的珍藏册。
作品卡片和详情页上的画师名都可以点击,回到画师主页。
收藏夹
「收藏夹」页可以切换:
- 我的公开收藏;
- 我的私密收藏(仅自己可见);
- 他人的公开收藏(输入画师 ID 或主页链接)。
支持「加载更多」和「加载全部」。收藏数量很大时会分批加载,完成后会提示。收藏夹里的作品同样支持多选、批量收藏和批量下载。
珍藏册
「珍藏册」页以卡片形式展示每个珍藏册的封面、标题、创建者、标签和公开 / 私密状态。可以查看自己的全部珍藏册,也可以输入画师 ID 或主页链接查看该画师的公开珍藏册。
点击卡片进入详情,展示作品网格、标签、收藏数、浏览数与发布时间。珍藏册里的插画和小说会分别解析;小说最多加载前 30 本,避免一次请求过多。
多选与批量操作
- 鼠标移到作品卡片左上角,点击圆形勾选框;
- 底部出现操作栏,显示已选数量;
- 可以「全选当前列表」或「选择未下载」;
- 点击「批量收藏」或「批量下载」。
几个细节:
- 全选只选当前可见作品。被 R-18 / AI 筛选隐藏的卡片不会被选中。
- 选择未下载会检查历史 + 本地文件。已下载的作品跳过,部分下载的作品仍会被选中,以便补齐缺失页。
- 批量收藏会先选可见性。公开或非公开;默认值来自设置里的「默认非公开收藏」。
- 批量下载只入队,不弹进度窗。界面会提示“已加入下载队列”,随后可以继续浏览。
- 可以连续添加批次。上一批还在解析图片地址时,依然可以勾选新作品并再次下载。
下载队列
「下载」页按作品分组,展开后可以看到每一页的状态、大小、速度和错误信息。
| 操作 | 说明 |
|---|---|
| 暂停全部 / 继续全部 | abort 当前请求;任务回到等待状态,.part 保留 |
| 重试失败 | 把所有 failed 任务重新设为 queued |
| 删除单个失败任务 | 移除任务并删除对应的 .part |
| 清除全部失败 | 批量删除失败任务与临时文件 |
| 清空已完成 | 只从队列移除,不删除文件和下载记录 |
| 打开下载目录 | 在资源管理器中打开当前下载目录 |
| 点击缩略图 / 标题 / 详情 | 重新打开作品详情页 |
文件按画师分文件夹:
下载目录\画师名_画师ID\作品ID_p页码_标题.扩展名
例如:
D:\Pictures\PixivDesk\某某画师_123456\987654_p00_夏日海边.jpg
页码从 p00 开始补零,标题会截断到 52 个字符并清理 Windows 非法字符。多页作品的每一页都是独立任务,可以单独失败、单独重试。
下载历史
「历史」页记录每一个成功下载的文件,包括作品 ID、页码、标题、画师、路径、大小和时间。
- 支持按标题、画师、作品 ID、文件名搜索;
- 文件被移动或删除后,会标记「文件缺失」;
- 点击任意记录或右侧「详情」,回到对应作品详情页;
- 支持 JSON / CSV 导出、JSON 导入合并;
- 支持一键备份到
下载目录\.pixivdesk-backups。
如果历史显示“文件缺失”,有两种处理方式:
- 重新下载该作品;
- 把文件放回原来的绝对路径,或在下载队列里重新入队,让“收养”逻辑补登记。
设置项
| 设置 | 默认值 | 说明 |
|---|---|---|
| 下载目录 | %USERPROFILE%\Pictures\PixivDesk | 可更改;需要可写 |
| 同时下载数 | 3 | 范围 1–6;建议 2–4 |
| 跳过已下载文件 | 开启 | 结合历史与本地文件判断 |
| 本地文件删除后仍视为已下载 | 开启 | 关闭后,缺失文件会重新下载 |
| 启动时自动继续任务 | 开启 | 关闭后队列以暂停状态启动 |
| 默认非公开收藏 | 关闭 | 批量收藏弹窗的默认选项 |
| 显示 R-18 | 关闭 | 需要登录且账号允许 |
| 显示 AI 作品 | 开启 | 过滤 aiType 与常见 AI 标签 |
| 启用代理 | 关闭 | HTTP / HTTPS / SOCKS4 / SOCKS5 |
| 代理地址 | http://127.0.0.1:7890 | 主机和端口必填 |
| 绕过规则 | <local> | 传给 Electron 的 proxyBypassRules |
设置会写入 settings.json,修改后立即生效。切换代理会重新验证登录状态,并清空 API 缓存。
R-18 与 AI 筛选
R-18 需要同时满足三个条件:
- 已登录 Pixiv;
- 在 Pixiv 账号设置中允许显示 R-18 作品;
- 客户端开启「显示 R-18」。
未登录时,所有请求都会使用 mode=safe;登录后如果开启 R-18,才会使用 mode=all。客户端不会绕过账号的 R-18 设置。
AI 过滤优先使用 Pixiv 返回的 aiType,接口缺失时退化为标签与标题匹配。搜索结果会通过补充详情接口 enrich;接口不可用时只做标签识别。这个开关记忆在设置里,重启后仍然生效。
日志与排错
日志文件:
%APPDATA%\PixivDesk\logs\app.log
单文件超过 5 MB 会轮转为 app.log.1。设置页可以查看最近日志、打开日志文件或日志目录。下载、登录、接口和界面错误都会记录。
常见问题:
| 现象 | 先检查什么 | 处理方式 |
|---|---|---|
| 启动没反应 | ELECTRON_RUN_AS_NODE | 使用根目录 启动 PixivDesk.cmd |
| 搜索 / 登录超时 | 代理是否可用 | 设置里保存并测试连接;确认能访问 www.pixiv.net |
| 登录窗口空白 | 代理 / 网络 | 换代理节点,或改用 Cookie 导入 |
| 图片显示不出来 | 图片域名 / 代理 | 确认能访问 i.pximg.net;应用会补 Referer |
| R-18 列表为空 | 账号设置 / 登录 / 开关 | 三项都确认;未登录时强制安全模式 |
| 关注失败 | 人机验证 | 在浏览器中手动关注后刷新;客户端只做有限兜底 |
| 下载大量 429 | 并发过高 | 把同时下载数降到 2–3,稍后重试 |
| 历史显示文件缺失 | 文件被移动或删除 | 放回原路径,或重新下载 |
| 下载目录不可写 | 权限 / 磁盘 | 换目录,或在设置里重新选择 |
| SmartScreen 提示未知发布者 | 未签名 | 选择“仍要运行”;公开分发建议签名 |
如果问题持续,打开日志文件,搜索 [ERROR] 附近的上下文。日志里会记录请求 URL、HTTP 状态、任务键和错误堆栈,通常足以定位是网络、登录还是文件路径问题。
备份与迁移
需要备份的内容分两部分:
| 内容 | 路径 | 说明 |
|---|---|---|
| 设置、账号展示信息、历史、队列 | %APPDATA%\PixivDesk\ | 关闭应用后复制 |
| Cookie 会话 | %APPDATA%\PixivDesk\Partitions\ | 复制后可能仍要求重新登录 |
| 图片文件 | 你设置的下载目录 | 体积最大,按需复制 |
历史记录里保存的是绝对路径。如果把图片移动到新盘符,历史会显示“文件缺失”;此时可以在新位置重新下载,或导入一份更新过路径的历史 JSON。
最稳妥的迁移方式是:
- 在「历史」页导出 JSON;
- 复制下载目录;
- 在新机器上安装 PixivDesk,设置相同的下载目录;
- 导入 JSON;
- 重新登录。
卸载
- 安装版通过 Windows“应用和功能”卸载;
- 便携版直接删除程序目录;
- 应用数据目录不会自动删除,需要手动删除
%APPDATA%\PixivDesk; - 下载目录里的图片不会被卸载程序删除。
合规提醒
- 本项目是非官方客户端,与 pixiv 官方无关;
- 所有作品版权归原作者所有;
- 请遵守 pixiv 服务条款,不要高频请求、批量抓取或公开分发受版权保护的内容;
- 建议并发数保持在 2–4,避免给 Pixiv 造成压力或被临时限流;
- 不要用本项目绕过账号权限、R-18 设置或人机验证。
到这里,使用层面已经闭环。最后一章回到开发者视角:怎么构建、怎么扩展,以及这个项目还有哪些已知限制。
前面几章讲的是“它怎么工作”,这一章讲“怎么把它跑起来、改下去、发出去”。如果你只是想使用,可以跳过;如果你想基于它做二次开发,或者把它打包给朋友,下面的命令和边界会更有用。
从源码运行
npm install
npm start
npm start 实际执行的是 node scripts/start.js,它做了两件普通 electron . 不会做的事:
- 清除从父进程继承的
ELECTRON_RUN_AS_NODE,避免 Electron 被当成 Node 运行; - 以项目根目录为
cwd启动 Electron,保证main.js、preload.js和src/renderer的相对路径稳定。
开发模式:
npm run dev
它会在窗口打开后自动打开 DevTools。也可以在主窗口按 F12 切换 DevTools。
语法检查:
npm run check
这个脚本只对主进程与 preload 文件做 node --check,不会检查渲染层的 ES Modules,也没有单元测试。它适合在提交前快速发现语法错误,但不能替代手动回归。渲染层的错误会通过 window.onerror / unhandledrejection 写进日志,调试时优先看 %APPDATA%\PixivDesk\logs\app.log。
打包
npm run dist
dist 目录会生成两个 Windows x64 产物:
dist/PixivDesk-1.0.0-Setup.exe # NSIS 安装版
dist/PixivDesk-1.0.0-Portable.exe # 单文件便携版
package.json 里的关键构建配置:
| 配置 | 值 | 作用 |
|---|---|---|
appId | com.pixivdesk.app | Windows 应用标识 |
asar | true | 把源码打进 asar 包 |
electronDist | node_modules/electron/dist | 复用本地 Electron |
compression | normal | 打包速度与体积的折中 |
win.target | nsis + portable,x64 | 安装版与便携版 |
nsis.oneClick | false | 允许选择安装目录 |
nsis.perMachine | false | 默认当前用户安装 |
portable.requestExecutionLevel | user | 便携版不要求管理员 |
当前没有配置代码签名。SmartScreen 的“未知发布者”提示来自缺少可信证书,不是应用本身的问题。如果要公开分发,建议:
- 购买代码签名证书;
- 在
electron-builder的win配置里加入certificateFile/certificatePassword(或用环境变量); - 对安装版和便携版都签名;
- 发布 SHA-256 校验值。
目录结构详解
main.js 主进程入口:窗口、协议、服务初始化、生命周期
preload.js contextBridge API 白名单
src/main/
auth.js 登录窗口、Cookie、CSRF、收藏 / 关注
pixiv-api.js Pixiv 接口适配与作品模型归一
downloader.js 下载队列、并发、断点续传、持久化
history.js 下载记录、文件状态、导入导出
logger.js 日志写入、轮转、读取
store.js 设置与 JsonFile 原子写入
ipc.js IPC handler 与事件广播
util.js 字符串、路径、CSV、限流等工具
src/renderer/
index.html 页面结构、SVG 图标 sprite
styles.css 浅色主题与组件样式
js/
app.js 启动、导航、全局事件
state.js 中心状态与事件总线
actions.js 登录、批量收藏、批量下载、选择未下载
grid.js 作品卡片、网格渲染、选择状态
format.js 格式化、HTML 转义、图片代理 URL
ui.js 弹窗、Toast、进度、空状态
search-suggest.js 标签联想与中日英翻译
views/ 发现、画师、收藏、珍藏册、下载、历史、设置、查看器、小说
scripts/start.js 可靠启动 Electron 的开发启动器
一个容易混淆的点:src/main/ 下的文件都运行在主进程,src/renderer/js/ 下的文件都运行在渲染进程。它们之间没有直接 import,只能通过 preload.js 暴露的 window.pixivDesk 通信。
扩展指南
新增一个 Pixiv 接口
假设要加一个“画师新作提醒”接口。按四层顺序改:
1)在 pixiv-api.js 里加方法
async getArtistUpdates({ userId, page = 1 } = {}) {
const id = asString(userId).trim();
if (!/^\d+$/.test(id)) throw new Error('画师 ID 无效');
const data = await this.getJson(
`/ajax/user/${id}/profile/illusts?page=${page}`,
{
headers: {
Referer: `https://www.pixiv.net/users/${id}`,
Accept: 'application/json, text/plain, */*',
},
},
);
return {
items: (data?.body?.works?.data || [])
.map((item) => normalizeWork(item))
.filter(Boolean),
page,
hasMore: Boolean(data?.body?.works?.data?.length),
};
}
2)在 ipc.js 里注册 handler
ipcMain.handle('pixiv:artist-updates', async (event, options) => {
assertTrusted(event);
return pixivApi.getArtistUpdates(options || {});
});
3)在 preload.js 里暴露方法
getArtistUpdates: (options) =>
ipcRenderer.invoke('pixiv:artist-updates', options),
4)在渲染层调用
const result = await window.pixivDesk.getArtistUpdates({
userId: '123456',
page: 1,
});
不要跳过第 3 步直接 require('electron')。nodeIntegration 是关闭的,渲染层没有 require;即使有,也不应该把 ipcRenderer 暴露给页面。
新增一个设置项
设置项涉及四个位置:
store.js的DEFAULT_SETTINGS与cleanSettings();src/renderer/index.html的设置表单;src/renderer/js/views/settings.js的syncForm()/readForm();src/renderer/js/state.js的默认settings对象(用于启动时占位)。
如果这个设置需要影响主进程行为,在 ipc.js 的 settings:update 里读取 next 即可;如果需要在渲染层立即生效,监听 bus.on('settings-saved', ...)。
修改文件命名规则
统一改 downloader.js 里的 targetPath():
function targetPath(settings, work, pageIndex, extension) {
const folder = sanitizeSegment(`${work.userName}_${work.userId}`);
const title = sanitizeSegment(work.title || '').slice(0, 52);
const fileName = `${work.id}_p${String(pageIndex).padStart(2, '0')}_${title}.${extension}`;
return path.join(settings.downloadDir, folder, fileName);
}
注意:历史记录用 illustId_pN 作为键,不依赖文件名;所以修改命名不会破坏“已下载”判断,但会让旧的本地文件无法被“收养”逻辑识别,除非它们仍然位于新规则计算出的路径上。
新增一个视图
- 在
src/renderer/index.html里加一个[data-view-panel]容器; - 在
src/renderer/js/views/下新建模块,导出init()/render(); - 在
app.js的switchView()里加入口; - 在左侧导航加
.nav-item[data-view]。
视图模块不要直接操作主进程对象,只调用 window.pixivDesk;共享状态通过 state.js 和 bus。
调试与回归清单
没有自动化测试的情况下,建议每次改动后按下面这条链路手动回归:
- ☐ 冷启动,窗口能显示,设置能加载;
- ☐ 代理测试通过,登录窗口能打开;
- ☐ 搜索关键词、标签、作品 ID、画师 ID、小说 ID;
- ☐ 粘贴作品 / 画师 / 收藏夹 / 珍藏册 / 小说链接;
- ☐ 打开多页作品,勾选并批量下载;
- ☐ 下载中暂停,再继续,确认从
.part续传; - ☐ 下载中强制结束进程,重启后任务恢复;
- ☐ 下载完成后历史出现记录,文件存在;
- ☐ 手动删除文件,历史标记缺失;
- ☐ 「选择未下载」能跳过已下载、选中缺失;
- ☐ 批量收藏公开 / 非公开,进度与失败提示正常;
- ☐ 导出 JSON / CSV,再导入合并;
- ☐ 切换代理后登录状态与图片加载正常;
- ☐ R-18 / AI 开关作用于发现页、画师页、收藏夹、珍藏册;
- ☐ 退出应用时队列保存,下次启动不重复下载。
如果改动涉及下载器,额外测试 429 / 断网 / 服务器忽略 Range 三种情况。后两者可以通过代理工具模拟,或者临时修改 downloadOnce() 的响应判断来构造。
已知限制
- Pixiv 接口可能变化。登录检测、CSRF 令牌、收藏接口、搜索结果结构都依赖 Pixiv 的页面与 AJAX 结构;一旦变化,需要同步更新适配层。
- 网络不可达时无法工作。搜索、登录验证、图片加载都会失败;请先通过设置中的代理测试确认连通性。
- 关注作者依赖 Pixiv 接口。如果 Pixiv 要求人机验证,应用会尝试通过隐藏页面完成,仍失败时需要在浏览器中手动关注。
- 不绕过账号权限或 R-18 设置。客户端只会在“已登录 + 账号允许 + 开关开启”时请求成人内容。
- 限流是真实存在的。并发过高会触发 429;建议 2–4,并避免短时间反复重试。
- Windows 优先。项目在 Windows 10/11 上开发和验证;Electron 本身跨平台,但路径、图标、安装包和部分交互是按 Windows 设计的。
- 未签名。公开分发时 SmartScreen 会提示未知发布者。
- Cookie 存在 Electron 分区。它不会上传到第三方,但也没有额外接入系统钥匙串;能访问本机用户目录的人可以读取会话。
- 历史使用绝对路径。移动图片后会被标记为缺失;这是“可检测”的代价,而不是静默失效。
- 队列只保留最近 400 条已完成任务。更早的完成记录仍可在下载历史里查到,但不会出现在队列页。
- AI 识别是启发式的。官方
aiType优先,缺失时用标签正则;不保证 100% 准确。 - 没有自动更新。新版本需要手动替换程序;数据目录会保留。
发布清单
如果你要把这个项目发布到 GitHub、个人站点或 WordPress:
- 更新
package.json的version,并在 README / 更新日志里写清楚变更; - 运行
npm run check与npm run dist; - 在干净的 Windows 环境测试安装版与便携版;
- 如需公开分发,配置代码签名,并发布 SHA-256;
- 准备截图、封面图、功能清单和隐私说明;
- 保留 LICENSE(MIT)与第三方依赖许可;
- 在文章和仓库里注明“非官方、版权归原作者、遵守 Pixiv 条款”;
- 如果要发布到 WordPress,使用
readme/wordpress/下的 HTML 与图片,按publish-guide.md操作。
结语
PixivDesk 的实现并不复杂,它的价值在于把几个容易出错的边界处理清楚:
- 权限边界:渲染层不碰 Node,主进程不信任输入;
- 网络边界:API、图片、登录窗口共用一套会话与代理;
- 状态边界:队列、历史、设置各自持久化,键设计让“已下载”可解释;
- 恢复边界:
.part+Range+ 原子rename,让中断不等于失败。
如果你要基于它继续做,建议先守住这四条边界,再加功能。功能可以慢慢长,边界一旦破了,后面每个新特性都会变成例外。
PixivDesk 是非官方项目,与 pixiv 官方无关。所有作品版权归原作者所有,请遵守 pixiv 服务条款。
主题测试文章,只做测试使用。发布者:zy,转转请注明出处:https://zynetc.top/?p=900001