PixivDesk:从零构建一个可断点续传的 Pixiv 桌面客户端

一个非官方 Pixiv 桌面客户端的完整复盘:Electron 权限边界、登录会话、API 适配、断点续传、下载历史与使用方法。

PixivDesk 深度解析 · 完整版

一个非官方客户端,也是一次关于 Electron 权限边界、下载状态机与本地数据设计的实践。

PixivDesk 最初只是为了解决一个很小的麻烦:在浏览器里看完一组多页作品后,要一页一页保存,还要记住哪些图已经下过。后来需求慢慢长成了“批量收藏、按画师归档、断点续传、下载历史、代理、R-18 筛选”,一个普通的下载脚本就不够用了。

这个项目最终选择用 Electron 重写:不是为了做一个“更像原生”的壳,而是因为下载器需要同时具备三样东西——Chromium 的页面能力、Node.js 的文件系统能力,以及一个能持久保存 Cookie 的会话。把这三样东西放进同一个应用,才可能把“浏览 → 选择 → 入队 → 续传 → 记账”连成一条不断裂的链路。

这篇文章会沿着这条链路,讲清楚整体架构、关键实现和使用方法。它既是一篇项目说明,也是一次复盘:哪些边界必须提前划清,哪些状态必须持久化,哪些“看起来能用”的实现其实会在断网或重启时写坏文件。

PixivDesk:从零构建一个可断点续传的 Pixiv 桌面客户端
PixivDesk · 一个可断点续传的 Pixiv 桌面客户端

提示:本项目与 pixiv 官方无关。所有作品版权归原作者所有。请遵守 pixiv 服务条款,不要高频请求或滥用下载功能。文中出现的接口、路径与默认值均以当前版本为准,Pixiv 页面结构变化后可能需要相应调整。

PixivDesk 是一个非官方的 Pixiv 桌面客户端。它把「浏览作品」「批量收藏」「后台下载」「下载归档」放进同一个窗口,并且把登录凭据、下载记录和队列都留在本机。项目使用 Electron 45 与原生 JavaScript 编写,没有前端框架,也没有前端构建工具(webpack / Vite 之类)。所有作品版权归原作者所有,请遵守 Pixiv 服务条款,合理设置并发数。

先把功能拆开看:一个“能看”的客户端和一个“能归档”的客户端,差别不在界面,而在状态怎么保存、失败怎么恢复,以及哪些能力根本不应该交给页面。

Table of Contents

为什么还要做一个桌面客户端

浏览器里看 Pixiv 并不难,难的是“看完之后”的动作:多选收藏、按画师归档、批量下载多页作品、断点续传、失败重试、记录哪些文件已经下载过。浏览器的下载管理器通常不提供作品级的分组、去重和恢复能力;而网页本身又受同源策略限制,无法随意读取 i.pximg.net 的图片并写入本地目录。

Electron 正好补上这一段:它既有 Chromium 的页面渲染能力,又有 Node.js 的文件系统与网络能力,还能直接管理持久化会话、代理和窗口。PixivDesk 因此选择了一个很“窄”的定位:

  • 本地优先:没有自建服务器,不托管图片,不上传 Cookie。
  • 浏览与归档一体:看到作品即可勾选,加入后台队列,完成后自动记账。
  • 可恢复:队列、断点、下载记录都持久化;关掉应用再打开还能继续。
  • 不绕过权限:R-18 仍然依赖账号设置;遇到人机验证时只做有限兜底,不尝试破解。

它不是一个“爬虫农场”,也不是一个绕过 Pixiv 限制的工具。它更像一个带归档能力的浏览器:把重复的点击和整理工作收进队列,把“我下过没有”这件事交给本地历史。

功能全景

PixivDesk 功能地图
图 1 · 功能地图:浏览、整理、下载与本地数据

从模块上可以分成四组:

分组能做什么关键设计
发现与搜索个性化推荐、关注动态、小说、榜单、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。

一次典型使用流程

  1. 在「设置 → 网络代理」里填写 http://127.0.0.1:7890 或 socks5://127.0.0.1:7891,保存并测试连接。
  2. 点击右上角「登录」,在内置窗口完成 Pixiv 登录;如果窗口无法使用,可以导入 PHPSESSID。
  3. 在「发现」页切换个性化推荐、关注动态、小说、榜单或 R-18,或者在顶栏搜索关键词、标签、作品 ID、画师 ID、小说 ID,甚至直接粘贴 Pixiv 链接。
  4. 在卡片左上角勾选作品;底部出现批量操作栏。可以「全选」或「选择未下载」。
  5. 点击「批量下载」,任务进入后台队列;界面不会跳走,可以继续浏览或添加下一批。
  6. 在「下载」页查看进度、暂停 / 继续、重试失败任务;在「历史」页确认哪些文件已经落盘。
  7. 需要排错时,打开「设置 → 运行日志」,日志文件在 %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 白名单。

三层分工:渲染、桥梁、主进程

PixivDesk 运行架构
图 2 · 主进程、preload、渲染进程与 Electron 能力层
层运行位置主要职责不该做的事
渲染进程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 分成两类:

  1. 请求 / 响应:页面主动要数据或执行动作,例如 bootstrap、pixiv:search、downloads:enqueue、history:export-json。统一用 ipcMain.handle + ipcRenderer.invoke。
  2. 单向事件:主进程把下载进度、队列汇总、登录状态、历史变化推给所有窗口。统一用 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(),因为设置更新的频率低,而且需要在返回前确保已经落盘。

PixivDesk 本地数据布局
图 3 · 应用数据目录与下载目录的职责划分

这种“应用状态”和“作品文件”分开的布局,也解释了为什么换下载目录不会丢历史:历史记录在 %APPDATA%\PixivDesk\history.json,图片在用户选择的目录;历史里保存的是绝对路径,所以移动文件后会被标记为“文件缺失”,但记录本身还在。

架构讲到这里,下一章进入真正的业务核心:登录、API 适配与下载状态机。

这一章拆四个核心模块:登录、API 适配、下载引擎、历史判定。它们之间的依赖是单向的:下载器依赖 API 与历史,API 依赖登录会话,历史只依赖文件系统。理解这条依赖链,就能理解为什么某些错误会出现在“下载失败”,而不是“登录失败”。

登录:复用 Electron 会话,而不是自己维护 Cookie

Pixiv 的登录态本质上是一枚 PHPSESSID Cookie。很多桌面客户端会选择“让用户在浏览器登录,再复制 Cookie”,PixivDesk 也支持这条路,但更推荐内置登录窗口:它复用了 Electron 的持久化会话,登录完成后 Cookie 直接留在 persist:pixivdesk 分区里,不需要经过剪贴板。

PixivDesk 登录与会话流程
图 4 · 两条登录入口、会话验证与 CSRF 令牌

内置登录的流程并不复杂:

  1. 用 BrowserWindow 打开 accounts.pixiv.net/login,session 指向持久化分区。
  2. 每 1.8 秒调用一次 verify(),同时监听页面导航事件。
  3. GET /ajax/user/extra 返回 200 + error:false 时,视为登录成功。
  4. 成功后同步 /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=hidehide 只允许看自己的
珍藏册/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

PixivDesk 下载流水线
图 5 · 下载流水线:从批量选择到可恢复落盘

下载器是项目中状态最多、边界最多的模块。它的设计目标不是“最快”,而是“可控、可恢复、可解释”。

任务模型

一件多页作品会被拆成多条任务,任务键是 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 防抖;暂停、入队完成、任务完成等关键节点会立即保存。退出时:

  1. 设置 shuttingDown = true;
  2. abort 所有活跃任务;
  3. 最多等 3 秒让写入停止;
  4. 把 downloading 状态改回 queued;
  5. 保存队列并刷新历史。

即使进程被强制结束,.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:

  1. 在浏览器中登录 pixiv;
  2. 打开开发者工具 → Application / Storage → Cookies;
  3. 找到 PHPSESSID,复制 PHPSESSID=...(或整段 Cookie);
  4. 在「设置 → 账号 → 导入 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 本,避免一次请求过多。

多选与批量操作

  1. 鼠标移到作品卡片左上角,点击圆形勾选框;
  2. 底部出现操作栏,显示已选数量;
  3. 可以「全选当前列表」或「选择未下载」;
  4. 点击「批量收藏」或「批量下载」。

几个细节:

  • 全选只选当前可见作品。被 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。

如果历史显示“文件缺失”,有两种处理方式:

  1. 重新下载该作品;
  2. 把文件放回原来的绝对路径,或在下载队列里重新入队,让“收养”逻辑补登记。

设置项

设置默认值说明
下载目录%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 需要同时满足三个条件:

  1. 已登录 Pixiv;
  2. 在 Pixiv 账号设置中允许显示 R-18 作品;
  3. 客户端开启「显示 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。

最稳妥的迁移方式是:

  1. 在「历史」页导出 JSON;
  2. 复制下载目录;
  3. 在新机器上安装 PixivDesk,设置相同的下载目录;
  4. 导入 JSON;
  5. 重新登录。

卸载

  • 安装版通过 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 里的关键构建配置:

配置值作用
appIdcom.pixivdesk.appWindows 应用标识
asartrue把源码打进 asar 包
electronDistnode_modules/electron/dist复用本地 Electron
compressionnormal打包速度与体积的折中
win.targetnsis + portable,x64安装版与便携版
nsis.oneClickfalse允许选择安装目录
nsis.perMachinefalse默认当前用户安装
portable.requestExecutionLeveluser便携版不要求管理员

当前没有配置代码签名。SmartScreen 的“未知发布者”提示来自缺少可信证书,不是应用本身的问题。如果要公开分发,建议:

  1. 购买代码签名证书;
  2. 在 electron-builder 的 win 配置里加入 certificateFile / certificatePassword(或用环境变量);
  3. 对安装版和便携版都签名;
  4. 发布 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 暴露给页面。

新增一个设置项

设置项涉及四个位置:

  1. store.js 的 DEFAULT_SETTINGS 与 cleanSettings();
  2. src/renderer/index.html 的设置表单;
  3. src/renderer/js/views/settings.js 的 syncForm() / readForm();
  4. 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 作为键,不依赖文件名;所以修改命名不会破坏“已下载”判断,但会让旧的本地文件无法被“收养”逻辑识别,除非它们仍然位于新规则计算出的路径上。

新增一个视图

  1. 在 src/renderer/index.html 里加一个 [data-view-panel] 容器;
  2. 在 src/renderer/js/views/ 下新建模块,导出 init() / render();
  3. 在 app.js 的 switchView() 里加入口;
  4. 在左侧导航加 .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:

  1. 更新 package.json 的 version,并在 README / 更新日志里写清楚变更;
  2. 运行 npm run check 与 npm run dist;
  3. 在干净的 Windows 环境测试安装版与便携版;
  4. 如需公开分发,配置代码签名,并发布 SHA-256;
  5. 准备截图、封面图、功能清单和隐私说明;
  6. 保留 LICENSE(MIT)与第三方依赖许可;
  7. 在文章和仓库里注明“非官方、版权归原作者、遵守 Pixiv 条款”;
  8. 如果要发布到 WordPress,使用 readme/wordpress/ 下的 HTML 与图片,按 publish-guide.md 操作。

结语

PixivDesk 的实现并不复杂,它的价值在于把几个容易出错的边界处理清楚:

  • 权限边界:渲染层不碰 Node,主进程不信任输入;
  • 网络边界:API、图片、登录窗口共用一套会话与代理;
  • 状态边界:队列、历史、设置各自持久化,键设计让“已下载”可解释;
  • 恢复边界:.part + Range + 原子 rename,让中断不等于失败。

如果你要基于它继续做,建议先守住这四条边界,再加功能。功能可以慢慢长,边界一旦破了,后面每个新特性都会变成例外。

PixivDesk 是非官方项目,与 pixiv 官方无关。所有作品版权归原作者所有,请遵守 pixiv 服务条款。

主题测试文章,只做测试使用。发布者:zy,转转请注明出处:https://zynetc.top/?p=900001

赞 (0)
zy的头像zy
从 B 站链接到任意播放器:B2VRC 的完整实现与使用指南
上一篇 6天前
R730xd 服务器管理器:用 Redfish + IPMI 打造一台戴尔服务器的桌面控制台
下一篇 6天前