// ==UserScript== // @name PikPak 分享下载助手 // @namespace pikpak-share-helper // @version 0.6.0 // @description 下载小视频~ // @author qiscard // @match https://mypikpak.com/s/* // @grant GM_xmlhttpRequest // @grant GM_download // @grant GM_setValue // @grant GM_getValue // @grant GM_setClipboard // @grant GM_notification // @grant GM_addStyle // @connect api-drive.mypikpak.com // @connect user.mypikpak.com // @connect *.mypikpak.com // @connect localhost // @connect 127.0.0.1 // @connect * // @noframes // ==/UserScript== (function () { 'use strict'; // ---------------------------------------------------------------- 常量 const API = 'https://api-drive.mypikpak.com'; const NON_MEDIA_MAX_BYTES = 100 * 1024 * 1024; const STANDARD_DAILY_LIMIT_BYTES = 20 * 1024 * 1024 * 1024; const CDN_REFRESH_MARGIN_MS = 5 * 60 * 1000; const MEDIA_EXTS = new Set([ '3gp', 'asf', 'avi', 'av1', 'flac', 'flv', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'm2ts', 'm4a', 'm4v', 'mkv', 'mov', 'mp3', 'mp4', 'mpeg', 'mpg', 'ogg', 'opus', 'png', 'rm', 'rmvb', 'ts', 'webm', 'webp', 'wmv', 'wav', ]); const ARIA2_FIELDS = [ 'status', 'completedLength', 'totalLength', 'downloadSpeed', 'connections', 'errorCode', 'errorMessage', 'numPieces', 'pieceLength', 'files', ]; const BROWSER_DOWNLOAD_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'; // 多套客户端凭证(clientId + clientVersion + packageName + 对应 salt 组)。 // PikPak 更新会使某一套 captcha_sign 算法失效;这里按顺序探测,哪套通就锁定哪套, // 单点失效仍有备份。移植自 crl/huangguo 的多 profile 故障转移。 // 顺序:新版 android → web → 旧版 android(本项目已验证)。 const PROFILES = [ { name: 'android-1.53.2', clientId: 'YNxT9w7GMdWvEOKa', clientVersion: '1.53.2', packageName: 'com.pikcloud.pikpak', salts: [ 'SOP04dGzk0TNO7t7t9ekDbAmx+eq0OI1ovEx', 'nVBjhYiND4hZ2NCGyV5beamIr7k6ifAsAbl', 'Ddjpt5B/Cit6EDq2a6cXgxY9lkEIOw4yC1GDF28KrA', 'VVCogcmSNIVvgV6U+AochorydiSymi68YVNGiz', 'u5ujk5sM62gpJOsB/1Gu/zsfgfZO', 'dXYIiBOAHZgzSruaQ2Nhrqc2im', 'z5jUTBSIpBN9g4qSJGlidNAutX6', 'KJE2oveZ34du/g1tiimm', ], }, { name: 'web-2.0.0', clientId: 'YUMx5nI8ZU8Ap8pm', clientVersion: '2.0.0', packageName: 'mypikpak.com', salts: [ 'C9qPpZLN8ucRTaTiUMWYS9cQvWOE', '+r6CQVxjzJV6LCV', 'F', 'pFJRC', '9WXYIDGrwTCz2OiVlgZa90qpECPD6olt', '/750aCr4lm/Sly/c', 'RB+DT/gZCrbV', '', 'CyLsf7hdkIRxRm215hl', '7xHvLi2tOYP0Y92b', 'ZGTXXxu8E/MIWaEDB+Sm/', '1UI3', 'E7fP5Pfijd+7K+t6Tg/NhuLq0eEUVChpJSkrKxpO', 'ihtqpG6FMt65+Xk+tWUH2', 'NhXXU9rg4XXdzo7u5o', ], }, { name: 'android-1.47.1', clientId: 'YNxT9w7GMdWvEOKa', clientVersion: '1.47.1', packageName: 'com.pikcloud.pikpak', salts: [ 'Gez0T9ijiI9WCeTsKSg3SMlx', 'zQdbalsolyb1R/', 'ftOjr52zt51JD68C3s', 'yeOBMH0JkbQdEFNNwQ0RI9T3wU/v', 'BRJrQZiTQ65WtMvwO', 'je8fqxKPdQVJiy1DM6Bc9Nb1', 'niV', '9hFCW2R1', 'sHKHpe2i96', 'p7c5E6AcXQ/IJUuAEC9W6', '', 'aRv9hjc9P+Pbn+u3krN6', 'BzStcgE8qVdqjEH16l4', 'SqgeZvL5j9zoHP95xWHt', 'zVof5yaJkPe3VFpadPof', ], }, ]; // 当前锁定的 profile(establishProfile 探测后设定) let ACTIVE = PROFILES[0]; // ---------------------------------------------------------------- MD5(自包含) function md5(str) { function rl(n, c) { return (n << c) | (n >>> (32 - c)); } function cmn(q, a, b, x, s, t) { return rl(((q + a + x + t) | 0), s) + b | 0; } function ff(a, b, c, d, x, s, t) { return cmn((b & c) | (~b & d), a, b, x, s, t); } function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & ~d), a, b, x, s, t); } function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t); } function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | ~d), a, b, x, s, t); } function binl2hex(binarray) { const hex = '0123456789abcdef'; let s = ''; for (let i = 0; i < binarray.length * 4; i++) { s += hex.charAt((binarray[i >> 2] >> ((i % 4) * 8 + 4)) & 15) + hex.charAt((binarray[i >> 2] >> ((i % 4) * 8)) & 15); } return s; } const s2a = unescape(encodeURIComponent(str)); const nblk = ((s2a.length + 8) >> 6) + 1; const blks = new Array(nblk * 16).fill(0); for (let i = 0; i < s2a.length; i++) blks[i >> 2] |= s2a.charCodeAt(i) << ((i % 4) * 8); blks[s2a.length >> 2] |= 0x80 << ((s2a.length % 4) * 8); blks[nblk * 16 - 2] = s2a.length * 8; let a = 1732584193, b = -271733879, c = -1732584194, d = 271733878; for (let i = 0; i < blks.length; i += 16) { const [oa, ob, oc, od] = [a, b, c, d]; a = ff(a, b, c, d, blks[i + 0], 7, -680876936); d = ff(d, a, b, c, blks[i + 1], 12, -389564586); c = ff(c, d, a, b, blks[i + 2], 17, 606105819); b = ff(b, c, d, a, blks[i + 3], 22, -1044525330); a = ff(a, b, c, d, blks[i + 4], 7, -176418897); d = ff(d, a, b, c, blks[i + 5], 12, 1200080426); c = ff(c, d, a, b, blks[i + 6], 17, -1473231341); b = ff(b, c, d, a, blks[i + 7], 22, -45705983); a = ff(a, b, c, d, blks[i + 8], 7, 1770035416); d = ff(d, a, b, c, blks[i + 9], 12, -1958414417); c = ff(c, d, a, b, blks[i + 10], 17, -42063); b = ff(b, c, d, a, blks[i + 11], 22, -1990404162); a = ff(a, b, c, d, blks[i + 12], 7, 1804603682); d = ff(d, a, b, c, blks[i + 13], 12, -40341101); c = ff(c, d, a, b, blks[i + 14], 17, -1502002290);b = ff(b, c, d, a, blks[i + 15], 22, 1236535329); a = gg(a, b, c, d, blks[i + 1], 5, -165796510); d = gg(d, a, b, c, blks[i + 6], 9, -1069501632); c = gg(c, d, a, b, blks[i + 11], 14, 643717713); b = gg(b, c, d, a, blks[i + 0], 20, -373897302); a = gg(a, b, c, d, blks[i + 5], 5, -701558691); d = gg(d, a, b, c, blks[i + 10], 9, 38016083); c = gg(c, d, a, b, blks[i + 15], 14, -660478335); b = gg(b, c, d, a, blks[i + 4], 20, -405537848); a = gg(a, b, c, d, blks[i + 9], 5, 568446438); d = gg(d, a, b, c, blks[i + 14], 9, -1019803690); c = gg(c, d, a, b, blks[i + 3], 14, -187363961); b = gg(b, c, d, a, blks[i + 8], 20, 1163531501); a = gg(a, b, c, d, blks[i + 13], 5, -1444681467); d = gg(d, a, b, c, blks[i + 2], 9, -51403784); c = gg(c, d, a, b, blks[i + 7], 14, 1735328473); b = gg(b, c, d, a, blks[i + 12], 20, -1926607734); a = hh(a, b, c, d, blks[i + 5], 4, -378558); d = hh(d, a, b, c, blks[i + 8], 11, -2022574463); c = hh(c, d, a, b, blks[i + 11], 16, 1839030562); b = hh(b, c, d, a, blks[i + 14], 23, -35309556); a = hh(a, b, c, d, blks[i + 1], 4, -1530992060); d = hh(d, a, b, c, blks[i + 4], 11, 1272893353); c = hh(c, d, a, b, blks[i + 7], 16, -155497632); b = hh(b, c, d, a, blks[i + 10], 23, -1094730640); a = hh(a, b, c, d, blks[i + 13], 4, 681279174); d = hh(d, a, b, c, blks[i + 0], 11, -358537222); c = hh(c, d, a, b, blks[i + 3], 16, -722521979); b = hh(b, c, d, a, blks[i + 6], 23, 76029189); a = hh(a, b, c, d, blks[i + 9], 4, -640364487); d = hh(d, a, b, c, blks[i + 12], 11, -421815835); c = hh(c, d, a, b, blks[i + 15], 16, 530742520); b = hh(b, c, d, a, blks[i + 2], 23, -995338651); a = ii(a, b, c, d, blks[i + 0], 6, -198630844); d = ii(d, a, b, c, blks[i + 7], 10, 1126891415); c = ii(c, d, a, b, blks[i + 14], 15, -1416354905);b = ii(b, c, d, a, blks[i + 5], 21, -57434055); a = ii(a, b, c, d, blks[i + 12], 6, 1700485571); d = ii(d, a, b, c, blks[i + 3], 10, -1894986606); c = ii(c, d, a, b, blks[i + 10], 15, -1051523); b = ii(b, c, d, a, blks[i + 1], 21, -2054922799); a = ii(a, b, c, d, blks[i + 8], 6, 1873313359); d = ii(d, a, b, c, blks[i + 15], 10, -30611744); c = ii(c, d, a, b, blks[i + 6], 15, -1560198380); b = ii(b, c, d, a, blks[i + 13], 21, 1309151649); a = ii(a, b, c, d, blks[i + 4], 6, -145523070); d = ii(d, a, b, c, blks[i + 11], 10, -1120210379); c = ii(c, d, a, b, blks[i + 2], 15, 718787259); b = ii(b, c, d, a, blks[i + 9], 21, -343485551); a = (a + oa) | 0; b = (b + ob) | 0; c = (c + oc) | 0; d = (d + od) | 0; } return binl2hex([a, b, c, d]); } // ---------------------------------------------------------------- 工具 const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function gmXhr(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ timeout: 30000, ...opts, onload: resolve, onerror: (detail) => { const error = new Error('网络错误(可能被拦截): ' + opts.url); error.details = detail; reject(error); }, ontimeout: () => reject(new Error('请求超时: ' + opts.url)), }); }); } function humanSize(n) { const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return n.toFixed(n >= 100 || i === 0 ? 0 : 1) + ' ' + u[i]; } const sanitize = (s) => (s || 'untitled').replace(/[\\/:*?"<>|]/g, '_').replace(/[.\s]+$/, '').slice(0, 180); // ---------------------------------------------------------------- 页面请求捕获:跟踪用户当前浏览的目录 // 分享 URL 的 "AAAA..." 段是加密引用无法解析;页面进入子目录时会调 // share/detail?parent_id=<真实id>,从页面自己的请求里捕获当前目录。 // 注意:脚本运行在隔离上下文,必须用 unsafeWindow 修补页面真实窗口; // 另用 PerformanceObserver 兜底(资源时间线按 frame 共享,不受隔离影响)。 const pageWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; const nav = { shareId: '', parentId: '', pagePct: '' }; function recordPageReq(u) { try { const url = new URL(u, location.href); if (url.hostname !== 'api-drive.mypikpak.com') return; const sid = url.searchParams.get('share_id'); if (!sid) return; if (sid !== nav.shareId) { nav.shareId = sid; nav.parentId = ''; } const pid = url.searchParams.get('parent_id'); const pct = url.searchParams.get('pass_code_token'); if (pid) nav.parentId = pid; if (pct) nav.pagePct = pct; } catch { /* ignore */ } } function hookPageRequests() { // 1) 修补页面真实窗口的 fetch / XHR try { if (!pageWin.__pph_hooked) { pageWin.__pph_hooked = true; const of = pageWin.fetch; if (of) { pageWin.fetch = function (...a) { try { recordPageReq(typeof a[0] === 'string' ? a[0] : (a[0] && a[0].url) || ''); } catch { /* ignore */ } return of.apply(this, a); }; } const oo = pageWin.XMLHttpRequest.prototype.open; pageWin.XMLHttpRequest.prototype.open = function (m, u) { try { recordPageReq(String(u)); } catch { /* ignore */ } return oo.apply(this, arguments); }; } } catch { /* ignore */ } // 2) PerformanceObserver(buffered 会回放页面已发出的请求) try { pageWin.performance.setResourceTimingBufferSize(4096); new PerformanceObserver((list) => { for (const e of list.getEntries()) recordPageReq(e.name); }).observe({ type: 'resource', buffered: true }); } catch { /* ignore */ } } // 兜底:直接扫资源时间线(不依赖任何钩子是否生效) function detectParentFromPerf(shareId) { try { const entries = pageWin.performance.getEntriesByType('resource'); for (let i = entries.length - 1; i >= 0; i--) { const name = entries[i].name || ''; if (name.includes('/drive/v1/share/detail')) { recordPageReq(name); } } } catch { /* ignore */ } return nav.shareId === shareId ? nav.parentId : ''; } // ---------------------------------------------------------------- PikPak API 客户端 const deviceId = (() => { let id = GM_getValue('device_id', ''); if (!id) { id = crypto.randomUUID().replace(/-/g, ''); GM_setValue('device_id', id); } return id; })(); const captchaCache = new Map(); // "auth|profile|action" -> {token, expireAt} // ---------------------------------------------------------------- 登录态(自动读取) // PikPak 网页端把登录凭证存在 credentials_ 中。只读取 token 元数据, // 不把 token 写入日志或 GM 存储;网页刷新 token 后下一次 API 调用会自动跟随。 function parseExpiry(value) { if (typeof value === 'number' && Number.isFinite(value)) { return value < 1e12 ? value * 1000 : value; } if (typeof value === 'string') { const trimmed = value.trim(); if (/^\d+(\.\d+)?$/.test(trimmed)) { const n = Number(trimmed); return n < 1e12 ? n * 1000 : n; } const parsed = Date.parse(trimmed); return Number.isFinite(parsed) ? parsed : 0; } return 0; } function readAuth() { try { const keys = Object.keys(pageWin.localStorage) .filter((key) => /^credentials_/.test(key)) .sort((a, b) => { const preferred = 'credentials_' + ACTIVE.clientId; return a === preferred ? -1 : b === preferred ? 1 : 0; }); for (const key of keys) { try { const raw = pageWin.localStorage.getItem(key); const credentials = JSON.parse(raw || '{}'); const token = credentials && credentials.access_token; if (typeof token !== 'string' || !token.trim()) continue; const expiresAt = parseExpiry(credentials.expires_at); if (expiresAt && expiresAt <= Date.now()) continue; return { token, clientId: key.replace(/^credentials_/, ''), userId: typeof credentials.sub === 'string' ? credentials.sub : '', expiresAt, }; } catch { // 一个损坏的 credentials 项不应阻塞后续凭证项。 } } } catch { // localStorage 不可读时静默降级为匿名。 } return null; } let AUTH = null; let AUTH_REJECTED = ''; function authFingerprint(auth) { return auth ? `${auth.clientId}|${auth.userId}|${auth.token.slice(0, 12)}` : 'anonymous'; } function refreshAuth() { const candidate = readAuth(); const candidateFingerprint = authFingerprint(candidate); const next = candidateFingerprint !== AUTH_REJECTED ? candidate : null; if (authFingerprint(next) !== authFingerprint(AUTH)) captchaCache.clear(); AUTH = next; return AUTH; } function captchaSign(profile, ts) { let sign = profile.clientId + profile.clientVersion + profile.packageName + deviceId + ts; for (const salt of profile.salts) sign = md5(sign + salt); return '1.' + sign; } async function captchaToken(action, force) { refreshAuth(); const key = `${authFingerprint(AUTH)}|${ACTIVE.name}|${action}`; if (!force) { const hit = captchaCache.get(key); if (hit && hit.expireAt > Date.now()) return hit.token; } const ts = String(Date.now()); const body = { client_id: ACTIVE.clientId, action, device_id: deviceId, meta: { captcha_sign: captchaSign(ACTIVE, ts), client_version: ACTIVE.clientVersion, package_name: ACTIVE.packageName, user_id: (AUTH && AUTH.userId) || '', timestamp: ts, }, }; const headers = { 'Content-Type': 'application/json; charset=utf-8', 'X-Device-Id': deviceId, 'X-Client-Id': ACTIVE.clientId }; if (AUTH) headers.Authorization = 'Bearer ' + AUTH.token; const resp = await gmXhr({ method: 'POST', url: 'https://user.mypikpak.com/v1/shield/captcha/init', headers, data: JSON.stringify(body), }); let j; try { j = JSON.parse(resp.responseText); } catch { throw new Error('captcha/init 响应解析失败'); } if (resp.status !== 200 || !j.captcha_token) { const desc = j.error_description || j.error || resp.responseText.slice(0, 120); const err = new Error('captcha/init 失败(' + resp.status + '): ' + desc); err.error = j.error; err.errorCode = Number.isFinite(Number(j.error_code)) ? Number(j.error_code) : undefined; err.statusCode = resp.status; throw err; } captchaCache.set(key, { token: j.captcha_token, expireAt: Date.now() + 280e3 }); return j.captcha_token; } async function apiGet(path, params, action) { const qs = new URLSearchParams(params).toString(); const url = API + path + '?' + qs; let lastError; for (let attempt = 0; attempt < 3; attempt++) { refreshAuth(); const token = await captchaToken(action, attempt > 0); const headers = { 'X-Device-Id': deviceId, 'X-Client-Id': ACTIVE.clientId, 'X-Captcha-Token': token }; if (AUTH) headers.Authorization = 'Bearer ' + AUTH.token; let resp; try { resp = await gmXhr({ method: 'GET', url, headers }); } catch (error) { lastError = error; if (attempt < 2) { await sleep(300 * (attempt + 1)); continue; } throw error; } let j; try { j = JSON.parse(resp.responseText); } catch { throw new Error(path + ' 响应解析失败'); } const code = Number.isFinite(Number(j.error_code)) ? Number(j.error_code) : 0; const hasError = Boolean(j.error) || (j.error_code !== undefined && code !== 0); if (resp.status >= 200 && resp.status < 300 && !hasError) return j; const authFailure = resp.status === 401 || resp.status === 403 || code === 16 || /unauthenticated|invalid auth|token expired/i.test(`${j.error || ''} ${j.error_description || ''}`); const retryable = code === 9 || authFailure || resp.status === 429 || (resp.status >= 500 && resp.status <= 599); const desc = j.error_description || j.error || j.details?.[0]?.detail || '未知错误'; const error = new Error(path + ' ' + resp.status + ': ' + desc); error.error = j.error; error.errorCode = code || undefined; error.statusCode = resp.status; lastError = error; if (!retryable || attempt >= 2) throw error; if (authFailure) { const previous = authFingerprint(AUTH); refreshAuth(); if (authFingerprint(AUTH) === previous) { AUTH_REJECTED = previous; AUTH = null; } } captchaCache.clear(); await sleep(300 * (attempt + 1)); } throw lastError || new Error(path + ' 请求失败'); } const shareInfo = (shareId, passCode) => { const p = { share_id: shareId, thumbnail_size: 'SIZE_LARGE', limit: '100', order: '3' }; if (passCode) p.pass_code = passCode; return apiGet('/drive/v1/share', p, 'GET:/drive/v1/share'); }; // 探测哪套 profile 能通:依次用每套凭证请求 share,成功即锁定并持久化。 // 上次成功的 profile 会排到最前,避免每次都从头试。 async function establishProfile(shareId, passCode) { refreshAuth(); // 已登录:token 的 aud 就是某套 clientId,直接用匹配的 profile(无需轮询) if (AUTH) { const matched = PROFILES.find((p) => p.clientId === AUTH.clientId); ACTIVE = matched || PROFILES.find((p) => p.name === 'web-2.0.0') || PROFILES[0]; try { const info = await shareInfo(shareId, passCode); log('已登录(' + (AUTH.userId || '用户') + '),凭证 ' + ACTIVE.name + ';非媒体文件可直链下载'); return info; } catch (error) { if (AUTH || ![401, 403].includes(error.statusCode) && error.errorCode !== 16) throw error; log('登录态已失效,媒体文件改用匿名模式;非媒体文件请重新登录后再试'); } } const saved = GM_getValue('active_profile', ''); const order = [...PROFILES].sort((a, b) => (a.name === saved ? -1 : b.name === saved ? 1 : 0)); let lastErr = new Error('无可用凭证'); for (const profile of order) { ACTIVE = profile; try { const info = await shareInfo(shareId, passCode); // 能拿到响应即说明 captcha_sign 被接受(含需要提取码的状态) if (saved !== profile.name) { GM_setValue('active_profile', profile.name); } if (profile !== PROFILES[0] || saved !== profile.name) log('已锁定凭证: ' + profile.name); return info; } catch (e) { lastErr = e; // 凭证/签名类错误才继续换下一套;其它错误(如网络)直接抛出 if (e.errorCode === 9 || /captcha|sign|shield|invalid/i.test(e.message)) { log('凭证 ' + profile.name + ' 不通,换下一套…'); continue; } throw e; } } ACTIVE = PROFILES[0]; throw lastErr; } async function listFolder(shareId, pctToken, parentId) { const params = { share_id: shareId, pass_code_token: pctToken, limit: '100', thumbnail_size: 'SIZE_LARGE', order: '6', folders_first: 'true', }; if (parentId) params.parent_id = parentId; const files = []; let pageToken = ''; for (;;) { if (pageToken) params.page_token = pageToken; const j = await apiGet('/drive/v1/share/detail', params, 'GET:/drive/v1/share/detail'); files.push(...(j.files || [])); pageToken = j.next_page_token || ''; if (!pageToken) return files; } } function fileExtension(file) { const raw = file?.file_extension || file?.name || ''; const name = String(raw).toLowerCase(); const dot = name.lastIndexOf('.'); return dot >= 0 ? name.slice(dot + 1) : ''; } function isMediaFile(file) { const mime = String(file?.mime_type || '').toLowerCase(); return /^(video|audio|image)\//.test(mime) || MEDIA_EXTS.has(fileExtension(file)); } function nonMediaLimitError(file) { const size = Number(file?.size || 0); if (!isMediaFile(file) && size >= NON_MEDIA_MAX_BYTES) { const error = new Error(`非媒体文件仅支持小于 100 MB(当前 ${humanSize(size)})`); error.code = 'NON_MEDIA_LIMIT'; return error; } return null; } function extractLink(value) { if (typeof value === 'string') return value; if (value && typeof value.url === 'string') return value.url; return ''; } function urlExpireAt(url) { try { const value = Number(new URL(url).searchParams.get('expire') || 0); return value > 0 ? value * 1000 : 0; } catch { return 0; } } // 取文件直链。媒体默认原画;非媒体优先登录态下的 web_content_link。 async function resolveUrl(shareId, pctToken, fileId, fileMeta, preferTranscoding) { const limitError = nonMediaLimitError(fileMeta); if (limitError) throw limitError; refreshAuth(); if (!isMediaFile(fileMeta) && !AUTH) { const error = new Error('非媒体文件需要先登录 PikPak;匿名模式只能下载媒体文件'); error.code = 'AUTH_REQUIRED_NON_MEDIA'; throw error; } const params = { share_id: shareId, pass_code_token: pctToken, file_id: fileId }; const first = await apiGet('/drive/v1/share/file_info', params, 'GET:/drive/v1/share/file_info'); const collect = (j) => { const info = j.file_info || {}; const medias = (info.medias || []).filter((m) => extractLink(m?.link)); const candidates = []; const seen = new Set(); const push = (url, source) => { if (!url || seen.has(url)) return; seen.add(url); candidates.push({ url, source, expireAt: urlExpireAt(url) }); }; const origin = (m) => m.is_origin === true || m.category === 'original' || /(?:^|[?&])category=original(?:&|$)/i.test(extractLink(m?.link)); const transcoded = (m) => !origin(m) && (m.category === 'transcoded' || /(?:^|[?&])(?:category=transcoded|t=1)(?:&|$)/i.test(extractLink(m?.link))); const nonMedia = !isMediaFile(fileMeta) && !isMediaFile(info); if (nonMedia) { push(extractLink(info.web_content_link), 'web_content_link'); for (const [mime, value] of Object.entries(info.links || {})) push(extractLink(value), mime === 'application/octet-stream' ? 'octet-stream' : 'link:' + mime); } if (preferTranscoding) for (const media of medias) if (transcoded(media)) push(extractLink(media.link), 'transcoded'); for (const media of medias) if (origin(media)) push(extractLink(media.link), 'original'); if (!nonMedia) { push(extractLink(info.web_content_link), 'web_content_link'); for (const [mime, value] of Object.entries(info.links || {})) push(extractLink(value), mime === 'application/octet-stream' ? 'octet-stream' : 'link:' + mime); } for (const media of medias) push(extractLink(media.link), transcoded(media) ? 'transcoded' : 'media'); return candidates; }; let candidates = collect(first); if (!candidates.length) { const fetched = await apiGet('/drive/v1/share/file_info', { ...params, usage: 'FETCH' }, 'GET:/drive/v1/share/file_info'); candidates = collect(fetched); } const selected = candidates.find((candidate) => !candidate.expireAt || candidate.expireAt > Date.now() + CDN_REFRESH_MARGIN_MS); if (!selected) { const error = new Error('下载直链为空或已过期;请重新获取直链'); error.code = 'NO_VALID_URL'; throw error; } return selected; } // ---------------------------------------------------------------- 扫描器 async function scanTree(shareId, pctToken, rootId, onProgress, state) { const queue = [{ id: rootId, path: '' }]; const results = []; let dirsDone = 0; while (queue.length) { if (state.aborted) break; const { id, path } = queue.shift(); let items; try { items = await listFolder(shareId, pctToken, id); } catch (e) { if (e.error === 'file_not_found') continue; throw e; } for (const f of items) { const p = path + '/' + sanitize(f.name); if (f.kind === 'drive#folder') queue.push({ id: f.id, path: p }); else results.push({ path: p, file: f }); } dirsDone++; onProgress && onProgress(dirsDone, queue.length, results.length); await sleep(300); // 节流,避免触发风控 } return results; } // 懒取直链:失败可重试;签名 URL 接近过期时自动刷新。 async function ensureUrl(entry, force = false) { if (!entry || !entry.file) return false; const hardLimit = nonMediaLimitError(entry.file); if (hardLimit) { entry.error = hardLimit.message; entry.errorCode = hardLimit.code; return false; } const validCached = entry.url && (!entry.urlExpireAt || entry.urlExpireAt > Date.now() + CDN_REFRESH_MARGIN_MS); if (!force && validCached) return true; if (!force && entry.errorCode === 'NON_MEDIA_LIMIT') return false; entry.url = null; entry.urlSource = ''; entry.urlExpireAt = 0; entry.error = null; entry.errorCode = ''; refreshAuth(); try { const resolved = await resolveUrl(state.shareId, state.pctToken, entry.file.id, entry.file, cfg.preferTranscoding); entry.url = resolved.url; entry.urlSource = resolved.source; entry.urlExpireAt = resolved.expireAt; entry.error = null; entry.errorCode = ''; return true; } catch (e) { entry.error = e.message || String(e); entry.errorCode = e.code || e.errorCode || ''; return false; } finally { await sleep(250); // file_info 节流 } } // ---------------------------------------------------------------- aria2 RPC const aria2Tasks = new Map(); let aria2MonitorRunning = false; async function aria2Call(rpcUrl, secret, method, params) { const requestId = 'pph-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7); const body = { jsonrpc: '2.0', id: requestId, method, params: secret ? ['token:' + secret, ...params] : params }; let resp; try { resp = await gmXhr({ method: 'POST', url: rpcUrl, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(body), }); } catch (e) { throw new Error('无法连接 aria2 RPC(' + rpcUrl + '):' + (e.message || '连接失败')); } if (resp.status < 200 || resp.status >= 300) throw new Error('aria2 RPC HTTP ' + resp.status + ':请检查 RPC 地址、代理和跨域设置'); let j; try { j = JSON.parse(resp.responseText); } catch { throw new Error('aria2 响应异常(HTTP ' + resp.status + ')'); } if (j.error) { const m = j.error.message || ''; if (/Unauthorized/i.test(m)) throw new Error('aria2 认证失败:RPC 密钥不匹配(检查 --rpc-secret 与脚本设置)'); throw new Error('aria2 错误 ' + (j.error.code ?? '') + ':' + (m || '未知错误')); } if (!Object.prototype.hasOwnProperty.call(j, 'result')) throw new Error('aria2 响应缺少 result'); return j.result; } function aria2Options(entry, snapshot) { const slash = entry.path.lastIndexOf('/'); const relativeDir = slash > 0 ? entry.path.slice(0, slash) : ''; const dir = snapshot.savePath.replace(/[\\/]+$/, '') + relativeDir; const out = entry.path.slice(slash + 1); const options = { dir, out, split: '8', 'max-connection-per-server': '8', 'min-split-size': '4M', continue: 'true', 'max-tries': '5', 'retry-wait': '5', timeout: '30', 'connect-timeout': '30', 'file-allocation': 'none', 'auto-file-renaming': 'false', header: [ 'Referer: https://mypikpak.com/', 'Accept: */*', 'User-Agent: ' + BROWSER_DOWNLOAD_UA, ], }; if (snapshot.proxy) options['all-proxy'] = snapshot.proxy; return { options, out }; } function ariaStatusText(status) { return ({ active: '下载中', waiting: '排队中', paused: '已暂停', complete: '✓ 完成', error: '✗ 失败', removed: '已移除' })[status] || status || '未知'; } function ensureAriaTaskRow(entry, gid) { if (!aria2TasksEl) return null; const row = document.createElement('div'); row.className = 'task'; const title = document.createElement('span'); title.className = 'nm'; title.textContent = entry.path; const status = document.createElement('span'); status.className = 'st'; status.textContent = '已入队 · GID ' + gid; row.append(title, document.createTextNode(' '), status); aria2TasksEl.prepend(row); while (aria2TasksEl.children.length > 12) aria2TasksEl.lastChild.remove(); return { row, status }; } function updateAriaTask(task, info) { if (!task || !task.status) return; const status = String(info.status || 'unknown'); const done = Number(info.completedLength || 0); const total = Number(info.totalLength || task.expectedSize || 0); const speed = Number(info.downloadSpeed || 0); const connections = Number(info.connections || 0); if (status === 'complete') { task.status.textContent = `✓ 完成 · ${humanSize(total || done)}`; } else if (status === 'error') { task.status.textContent = `✗ ${info.errorMessage || ('错误码 ' + (info.errorCode || 'unknown'))}`; } else { const progress = total > 0 ? ` · ${((done / total) * 100).toFixed(1)}%` : ''; task.status.textContent = `${ariaStatusText(status)} · ${humanSize(done)} / ${humanSize(total)}${progress} · ${humanSize(speed)}/s · ${connections} 连接`; } } function startAria2Monitor() { if (aria2MonitorRunning) return; aria2MonitorRunning = true; const tick = async () => { const tasks = [...aria2Tasks.values()].filter((task) => !task.terminal); if (!tasks.length) { aria2MonitorRunning = false; return; } await Promise.all(tasks.map(async (task) => { try { const info = await aria2Call(task.rpcUrl, task.secret, 'aria2.tellStatus', [task.gid, ARIA2_FIELDS]); updateAriaTask(task, info); if (['complete', 'error', 'removed'].includes(String(info.status))) { task.terminal = true; setTimeout(() => aria2Tasks.delete(task.gid), 30_000); } } catch (error) { task.status.textContent = '监控失败:' + (error.message || error); } })); if (aria2Tasks.size) setTimeout(tick, 1000); else aria2MonitorRunning = false; }; void tick(); } // ---------------------------------------------------------------- 配置 const cfg = { get rpcUrl() { return GM_getValue('rpcUrl', 'http://127.0.0.1:6800/jsonrpc'); }, set rpcUrl(v) { GM_setValue('rpcUrl', v); }, get secret() { return GM_getValue('secret', ''); }, set secret(v) { GM_setValue('secret', v); }, get proxy() { return GM_getValue('aria2Proxy', ''); }, set proxy(v) { GM_setValue('aria2Proxy', v); }, get savePath() { return GM_getValue('savePath', 'D:/PikPak'); }, set savePath(v) { GM_setValue('savePath', v); }, get preferTranscoding() { return GM_getValue('preferTranscoding', false); }, set preferTranscoding(v) { GM_setValue('preferTranscoding', v); }, }; // ---------------------------------------------------------------- UI GM_addStyle(` #pph-btn{position:fixed;right:16px;bottom:16px;z-index:2147483647;background:#3b82f6;color:#fff;border:none; border-radius:8px;padding:10px 16px;font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.35)} #pph-btn:hover{background:#2563eb} #pph-panel{position:fixed;right:16px;bottom:60px;z-index:2147483647;width:460px;max-height:84vh;overflow:auto; background:#0f172a;color:#e2e8f0;border-radius:12px;font-size:13px; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; box-shadow:0 8px 32px rgba(0,0,0,.5);display:none} /* 头部 */ #pph-head{display:flex;align-items:center;gap:8px;padding:12px 14px;background:#1e293b; border-radius:12px 12px 0 0;position:sticky;top:0;z-index:2} #pph-head .ttl{font-size:14px;font-weight:600;color:#93c5fd;flex:1} #pph-head .ver{font-size:11px;font-weight:400;color:#64748b} #pph-head .x{cursor:pointer;color:#94a3b8;font-size:18px;line-height:1;padding:0 4px} #pph-head .x:hover{color:#f87171} /* 登录状态条 */ #pph-auth{display:flex;align-items:center;gap:8px;margin:10px 14px 0;padding:8px 10px; border-radius:8px;background:#1e293b;font-size:12px} #pph-auth .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} #pph-auth .dot.on{background:#22c55e;box-shadow:0 0 6px #22c55e} #pph-auth .dot.off{background:#f59e0b} #pph-auth .txt{flex:1;color:#cbd5e1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #pph-auth button{padding:3px 8px;font-size:11px} #pph-notice{margin:10px 14px 0;padding:10px 12px;border-radius:8px;background:#172554; border:1px solid #1d4ed8;color:#dbeafe;font-size:11px;line-height:1.55} #pph-notice ul{margin:5px 0 0;padding-left:18px} #pph-notice li{margin:2px 0} #pph-notice .warn{color:#fde68a} #pph-notice .ok{color:#bbf7d0} /* 分区 */ .pph-sec{margin:10px 14px 0;padding:10px;background:#1e293b;border-radius:8px} .pph-sec .hd{font-size:11px;color:#64748b;margin-bottom:6px;letter-spacing:.5px} #pph-panel button{background:#3b82f6;color:#fff;border:none;border-radius:6px;padding:6px 11px; margin:2px 4px 2px 0;cursor:pointer;font-size:12px} #pph-panel button:hover{filter:brightness(1.1)} #pph-panel button.sec{background:#475569} #pph-panel button:disabled{opacity:.5;cursor:not-allowed} #pph-panel input[type=text],#pph-panel input:not([type]){background:#0f172a;color:#e2e8f0;border:1px solid #334155; border-radius:5px;padding:5px 7px;font-size:12px} #pph-field{display:flex;align-items:center;gap:8px;margin:5px 0} #pph-field label{width:72px;flex-shrink:0;color:#94a3b8;font-size:12px} #pph-field input{flex:1;min-width:0} #pph-stat{margin:8px 14px 0;color:#93c5fd;font-size:12px;min-height:16px} #pph-list{margin:8px 14px 0;max-height:220px;overflow:auto;background:#1e293b;border-radius:8px} #pph-list .row{display:flex;gap:8px;padding:5px 10px;border-bottom:1px solid #0f172a;align-items:center} #pph-list .row:last-child{border-bottom:none} #pph-list .row:hover{background:#334155} #pph-list .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #pph-list .sz{color:#94a3b8;flex-shrink:0;font-size:11px} #pph-list a{color:#93c5fd;text-decoration:none;flex-shrink:0;cursor:pointer;padding:0 2px} #pph-list a:hover{color:#bfdbfe;text-decoration:underline} #pph-dltasks,#pph-aria2tasks{margin:8px 14px 0} #pph-dltasks .task,#pph-aria2tasks .task{display:flex;gap:8px;align-items:center;margin:4px 0;padding:6px 8px;background:#1e293b;border-radius:6px;font-size:12px;color:#cbd5e1} #pph-dltasks .nm,#pph-aria2tasks .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #pph-dltasks .st,#pph-aria2tasks .st{color:#93c5fd;flex-shrink:0} #pph-log{white-space:pre-wrap;word-break:break-all;background:#020617;border-radius:8px;color:#94a3b8; padding:8px 10px;max-height:130px;overflow:auto;margin:8px 14px 0;line-height:1.5;font-family:monospace;font-size:11px} #pph-foot{color:#475569;font-size:11px;padding:10px 14px 14px;line-height:1.5} .pph-red{background:#dc2626 !important} .pph-chk{display:flex;align-items:center;gap:6px;color:#94a3b8;font-size:12px;margin:5px 0} .pph-chk input{width:14px;height:14px} `); let panel, logEl, listEl, statEl, abortBtn, dlTasksEl, aria2TasksEl, authEl, noticeEl; const state = { shareId: '', pctToken: null, rootId: '', title: '', entries: [], aborted: false, busy: false, }; function log(msg) { const t = new Date().toLocaleTimeString('zh-CN', { hour12: false }); logEl.textContent += `[${t}] ${msg}\n`; logEl.scrollTop = logEl.scrollHeight; } function setBusy(b) { state.busy = b; panel.querySelectorAll('button:not(#pph-abort), input').forEach((el) => (el.disabled = b)); abortBtn.disabled = !b; } function currentShareId() { const m = location.pathname.match(/\/s\/([^/]+)/); return m ? m[1] : ''; } function extAllowed(name, filter) { if (!filter.trim()) return true; const ext = (name.split('.').pop() || '').toLowerCase(); return filter.split(',').map((s) => s.trim().toLowerCase().replace(/^\./, '')).includes(ext); } function filteredEntries() { return state.entries.filter((e) => !e.file || extAllowed(e.path, panel.querySelector('#pph-ext').value)); } function renderList(entries) { listEl.innerHTML = ''; const shown = entries.slice(0, 300); for (const e of shown) { const row = document.createElement('div'); row.className = 'row'; const isFolder = !e.file; const nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = (isFolder ? '📁 ' : '') + (e.error ? '✗ ' + e.path : e.path); if (e.error) nm.style.color = '#f87171'; nm.title = e.path; const sz = document.createElement('span'); sz.className = 'sz'; if (isFolder) { sz.textContent = ''; } else { const size = Number(e.file.size || 0); const restricted = nonMediaLimitError(e.file); sz.textContent = humanSize(size) + (restricted ? ' · 非媒体>100MB' : (!isMediaFile(e.file) ? ' · 需登录' : '')); if (restricted) sz.style.color = '#fbbf24'; } row.append(nm, sz); if (!isFolder) { const dl = document.createElement('a'); dl.textContent = '下载'; dl.onclick = () => downloadSingle(e); const cp = document.createElement('a'); cp.textContent = '复制'; cp.onclick = async () => { cp.textContent = '…'; const ok = await ensureUrl(e); cp.textContent = '复制'; renderList(filteredEntries()); if (!ok || !e.url) return log('取链失败: ' + e.path.split('/').pop() + ' — ' + (e.error || '无可用直链')); GM_setClipboard(e.url); log('已复制直链: ' + e.path.split('/').pop()); }; row.append(dl, cp); } listEl.append(row); } if (entries.length > 300) { const more = document.createElement('div'); more.className = 'row'; more.textContent = `…共 ${entries.length} 项,仅显示前 300`; listEl.append(more); } } // 单文件下载:browser 模式在当前 ScriptCat 实现中直接交给 chrome.downloads, // 避免 native 路径先把大文件读成 Blob。失败时重新取一次签名直链再重试。 function downloadSingle(e) { if (state.busy || e.downloading) return; const relPath = e.path.replace(/^\//, ''); const name = relPath.split('/').pop(); const run = async () => { e.downloading = true; const box = document.createElement('div'); box.className = 'task'; const label = document.createElement('span'); label.className = 'nm'; label.textContent = relPath; const st = document.createElement('span'); st.className = 'st'; st.textContent = '准备中…'; box.append(label, document.createTextNode(' '), st); dlTasksEl.prepend(box); if (dlTasksEl.children.length > 12) dlTasksEl.lastChild.remove(); const fallback = (reason) => { st.textContent = '已交给浏览器(请查看下载栏)'; log('直接浏览器下载: ' + name + (reason ? '(' + reason + ')' : '')); const a = document.createElement('a'); a.href = e.url; a.download = name; a.target = '_blank'; a.rel = 'noopener'; document.body.append(a); a.click(); setTimeout(() => a.remove(), 1000); }; const attempt = (retry) => new Promise((resolve) => { let settled = false; const finish = (ok, message) => { if (settled) return; settled = true; resolve({ ok, message }); }; try { GM_download({ url: e.url, name: relPath, downloadMode: 'browser', saveAs: false, conflictAction: 'uniquify', onprogress: (d) => { const total = Number(d.total || 0); const done = Number(d.done || 0); if (total > 0) st.textContent = `${humanSize(done)} / ${humanSize(total)} (${((done / total) * 100).toFixed(1)}%)`; else if (done > 0) st.textContent = humanSize(done); }, onload: () => finish(true, '✓ 完成'), ontimeout: () => finish(false, '下载超时'), onerror: (err) => { const code = (err && err.error) || 'unknown'; finish(false, code + (err && err.details ? ': ' + err.details : '')); }, }); } catch (error) { finish(false, String(error && error.message || error)); } }); try { if (!(await ensureUrl(e))) { st.textContent = '✗ ' + e.error; log('取链失败: ' + name + ' — ' + e.error); return; } let result = await attempt(false); if (!result.ok) { log('下载失败,重新获取直链重试: ' + name + ' — ' + result.message); if (await ensureUrl(e, true)) result = await attempt(true); } if (result.ok) { st.textContent = result.message; log('下载已交给浏览器: ' + name); } else { st.textContent = '✗ ' + result.message; log('下载失败: ' + name + ' — ' + result.message); fallback(result.message); } } catch (error) { st.textContent = '✗ 异常'; log('单文件下载异常: ' + name + ' — ' + (error.message || error)); } finally { e.downloading = false; } }; void run().catch((error) => log('下载任务异常: ' + (error.message || error))); } function saveTextFile(text, filename) { const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.append(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 3000); } // 为一组 entry 补齐直链(带进度与中止) async function fillUrls(entries) { let done = 0; for (const e of entries) { if (state.aborted) break; await ensureUrl(e); done++; statEl.textContent = `取直链: ${done}/${entries.length}`; if (done % 20 === 0) renderList(filteredEntries()); } renderList(filteredEntries()); return entries.filter((e) => e.url).length; } async function ensureShare() { const sid = currentShareId(); if (!sid) throw new Error('当前页面不是分享链接'); if (state.shareId === sid && state.pctToken) return; state.shareId = sid; if (nav.shareId !== sid) { nav.shareId = sid; nav.parentId = ''; } // 优先复用页面自己的 pass_code_token(与页面会话一致,含用户已输入的提取码), // 否则自己匿名获取 if (nav.pagePct) { state.pctToken = nav.pagePct; if (!state.rootId) { try { // establishProfile 会锁定可用凭证,我们后续的 file_info 等请求都要用 const info = await establishProfile(sid); state.rootId = (info.files?.[0] || {}).id || ''; state.title = info.title || document.title.split(' Shared by ')[0].trim(); } catch { /* rootId 留空, resolveScanRoot 会再取 */ } } log(`分享: ${state.title || sid}(复用页面会话,凭证 ${ACTIVE.name})`); return; } let info = await establishProfile(sid); if (info.share_status !== 'OK') { const code = prompt('该分享需要提取码:'); if (!code) throw new Error('未输入提取码'); info = await shareInfo(sid, code); } if (info.share_status !== 'OK') throw new Error('分享不可用: ' + (info.share_status || '未知')); state.title = info.title || ''; state.pctToken = info.pass_code_token; state.rootId = (info.files?.[0] || {}).id || ''; log(`分享: ${state.title}(${AUTH ? '已登录' : '匿名模式'},凭证 ${ACTIVE.name})`); } // 确定扫描起点:页面正在浏览的目录 > 分享根目录 async function resolveScanRoot() { let pid = ''; if (nav.shareId === state.shareId && nav.parentId) { pid = nav.parentId; } else { pid = detectParentFromPerf(state.shareId); } if (pid) { // 校验该目录仍属于当前分享且可列出;页面 token 不被接受时换匿名 token 重试 try { await listFolder(state.shareId, state.pctToken, pid); return { id: pid, from: '当前浏览目录' }; } catch { try { const info = await shareInfo(state.shareId); state.rootId = (info.files?.[0] || {}).id || ''; if (info.pass_code_token) state.pctToken = info.pass_code_token; await listFolder(state.shareId, state.pctToken, pid); return { id: pid, from: '当前浏览目录' }; } catch { /* fallthrough 到根目录 */ } } } if (!state.rootId) { const info = await shareInfo(state.shareId); state.rootId = (info.files?.[0] || {}).id || ''; if (info.pass_code_token && !state.pctToken) state.pctToken = info.pass_code_token; } return { id: state.rootId, from: '分享根目录(未捕获到你所在的子目录)' }; } async function doScan(recursive) { try { await ensureShare(); } catch (e) { return log('初始化失败: ' + e.message); } state.aborted = false; setBusy(true); try { const root = await resolveScanRoot(); if (recursive) { log(`递归扫描(起点: ${root.from})…`); state.entries = await scanTree(state.shareId, state.pctToken, root.id, (dirs, pending, files) => { statEl.textContent = `已扫 ${dirs} 个目录(待处理 ${pending}),文件 ${files} 个`; }, state); } else { const items = await listFolder(state.shareId, state.pctToken, root.id); state.entries = items.map((f) => f.kind === 'drive#folder' ? { path: '/' + sanitize(f.name), file: null } : { path: '/' + sanitize(f.name), file: f }); const nFolder = state.entries.filter((e) => !e.file).length; const nFile = state.entries.length - nFolder; log(`当前目录(${root.from}): ${nFile} 个文件, ${nFolder} 个文件夹`); if (nFile === 0 && nFolder > 0) { log('提示: 当前目录只有子文件夹。进入子目录后再扫,或使用"扫描全部(递归)"。'); } } const total = state.entries.reduce((s, e) => s + Number(e.file?.size || 0), 0); statEl.textContent = `扫描完成: ${state.entries.filter((e) => e.file).length} 个文件 / ${humanSize(total)}(直链在推送或点击时按需获取)`; renderList(filteredEntries()); } catch (e) { log('扫描失败: ' + e.message); } finally { setBusy(false); } } async function doBatchAria2() { if (!state.entries.length) return log('请先扫描'); setBusy(true); state.aborted = false; const snapshot = { rpcUrl: cfg.rpcUrl, secret: cfg.secret, savePath: cfg.savePath, proxy: cfg.proxy.trim(), }; try { const target = filteredEntries().filter((e) => e.file); if (!target.length) return log('没有可下载的文件'); log(`开始处理 ${target.length} 个文件(取直链 + 推送 aria2)…`); let queued = 0, fail = 0, done = 0, skipped = 0; for (const entry of target) { if (state.aborted) { skipped += target.length - done; break; } const out = entry.path.slice(entry.path.lastIndexOf('/') + 1); const gotUrl = await ensureUrl(entry); done++; if (!gotUrl || !entry.url) { fail++; log('取链失败: ' + out + ' — ' + (entry.error || '无可用直链')); } else { try { const built = aria2Options(entry, snapshot); const gid = await aria2Call(snapshot.rpcUrl, snapshot.secret, 'aria2.addUri', [[entry.url], built.options]); if (typeof gid !== 'string' || !gid) throw new Error('aria2 未返回任务 GID'); const ui = ensureAriaTaskRow(entry, gid); aria2Tasks.set(gid, { gid, rpcUrl: snapshot.rpcUrl, secret: snapshot.secret, expectedSize: Number(entry.file.size || 0), status: ui ? ui.status : { textContent: '' }, terminal: false, }); queued++; startAria2Monitor(); } catch (error) { fail++; log('aria2 入队失败: ' + out + ' — ' + (error.message || error)); } await sleep(120); } statEl.textContent = `处理进度: ${done}/${target.length}(已入队 ${queued} / 失败 ${fail}${skipped ? ` / 跳过 ${skipped}` : ''})`; } const suffix = state.aborted ? '(已中止)' : ''; log(`处理完成:已入队 ${queued} / 失败 ${fail} / 跳过 ${skipped}${suffix}。实际速度请看 aria2 任务区。`); if (queued) GM_notification({ text: `已入队 ${queued} 个 aria2 任务(不是已完成)`, title: 'PikPak 助手' }); } catch (error) { log('批量下载失败: ' + (error.message || error)); } finally { setBusy(false); } } async function doTestAria2() { log('测试 aria2 连接 ' + cfg.rpcUrl + ' …'); try { const v = await aria2Call(cfg.rpcUrl, cfg.secret, 'aria2.getVersion', []); log('✓ aria2 连接成功, 版本 ' + v.version + '(配置正确,可直接推送)'); } catch (e) { log('✗ ' + e.message); } } async function doExportAria2() { if (!state.entries.length) return log('请先扫描'); setBusy(true); state.aborted = false; try { const target = filteredEntries().filter((e) => e.file); const n = await fillUrls(target); if (state.aborted && !n) return; const lines = []; const snapshot = { savePath: cfg.savePath, proxy: cfg.proxy.trim() }; for (const e of target) { if (!e.url) continue; const built = aria2Options(e, snapshot); lines.push(e.url); lines.push(' dir=' + built.options.dir); lines.push(' out=' + built.options.out); lines.push(' split=' + built.options.split); lines.push(' max-connection-per-server=' + built.options['max-connection-per-server']); lines.push(' min-split-size=' + built.options['min-split-size']); lines.push(' continue=' + built.options.continue); lines.push(' max-tries=' + built.options['max-tries']); lines.push(' retry-wait=' + built.options['retry-wait']); if (snapshot.proxy) lines.push(' all-proxy=' + snapshot.proxy); for (const header of built.options.header) lines.push(' header=' + header); } saveTextFile(lines.join('\n') + '\n', `pikpak_${sanitize(state.title) || state.shareId}.aria2.txt`); log(`已导出 aria2 列表(${lines.filter((l) => l && !l.startsWith(' ')).length} 条 URL),用法: aria2c -i 文件 --continue=true`); } finally { setBusy(false); } } async function doExportUrls() { if (!state.entries.length) return log('请先扫描'); setBusy(true); state.aborted = false; try { const target = filteredEntries().filter((e) => e.file); const n = await fillUrls(target); if (state.aborted && !n) return; saveTextFile(target.filter((e) => e.url).map((e) => e.url).join('\n') + '\n', `pikpak_${state.shareId}.urls.txt`); log(`已导出 ${n} 条直链`); } finally { setBusy(false); } } function buildPanel() { const btn = document.createElement('button'); btn.id = 'pph-btn'; btn.textContent = 'PikPak 下载助手'; document.documentElement.append(btn); panel = document.createElement('div'); panel.id = 'pph-panel'; panel.innerHTML = `
PikPak 分享下载助手 v0.6.0 ×
检测登录中…
下载规则提示
扫描
下载 / 导出
媒体文件不设脚本大小拦截;非媒体文件需登录且仅接受小于 100 MB。单文件浏览器下载使用直接下载路径,批量/大文件推荐 aria2。直链约 38h 过期 · aria2 任务区会显示实际速度与错误。
`; document.documentElement.append(panel); btn.onclick = () => { const show = panel.style.display !== 'block'; panel.style.display = show ? 'block' : 'none'; if (show) renderAuth(); }; panel.querySelector('#pph-close').onclick = () => (panel.style.display = 'none'); logEl = panel.querySelector('#pph-log'); listEl = panel.querySelector('#pph-list'); statEl = panel.querySelector('#pph-stat'); abortBtn = panel.querySelector('#pph-abort'); dlTasksEl = panel.querySelector('#pph-dltasks'); aria2TasksEl = panel.querySelector('#pph-aria2tasks'); noticeEl = panel.querySelector('#pph-notice'); authEl = { dot: panel.querySelector('#pph-dot'), txt: panel.querySelector('#pph-authtxt') }; panel.querySelector('#pph-detect').onclick = () => { AUTH_REJECTED = ''; refreshAuth(); renderAuth(); log('已重新检测登录状态'); }; panel.querySelector('#pph-scan').onclick = () => doScan(false); panel.querySelector('#pph-scanall').onclick = () => doScan(true); panel.querySelector('#pph-dl').onclick = doBatchAria2; panel.querySelector('#pph-test').onclick = doTestAria2; panel.querySelector('#pph-exp2').onclick = doExportAria2; panel.querySelector('#pph-txt').onclick = doExportUrls; abortBtn.onclick = () => { state.aborted = true; log('已请求中止…'); }; panel.querySelector('#pph-rpc').value = cfg.rpcUrl; panel.querySelector('#pph-rpc').onchange = (e) => (cfg.rpcUrl = e.target.value.trim()); panel.querySelector('#pph-sec-in').value = cfg.secret; panel.querySelector('#pph-sec-in').onchange = (e) => (cfg.secret = e.target.value.trim()); panel.querySelector('#pph-proxy').value = cfg.proxy; panel.querySelector('#pph-proxy').onchange = (e) => (cfg.proxy = e.target.value.trim()); panel.querySelector('#pph-path').value = cfg.savePath; panel.querySelector('#pph-path').onchange = (e) => (cfg.savePath = e.target.value.trim()); const trans = panel.querySelector('#pph-trans'); trans.checked = cfg.preferTranscoding; trans.onchange = (e) => { cfg.preferTranscoding = e.target.checked; // 切换后清掉已缓存的直链,下次取链按新偏好重取 for (const en of state.entries) { if (en.file) { en.url = null; en.error = null; } } log('优先转码: ' + (e.target.checked ? '开(取转码流)' : '关(取原画)') + ',已清空直链缓存'); }; renderAuth(); } // 刷新登录状态条:绿点=已登录(非媒体文件可下),黄点=匿名(仅媒体) function renderAuth() { if (!authEl) return; const a = refreshAuth(); if (a) { const mins = a.expiresAt ? Math.round((a.expiresAt - Date.now()) / 60000) : 0; const exp = mins > 0 ? `,剩约 ${mins} 分钟` : (a.expiresAt ? ',可能已过期' : ''); authEl.dot.className = 'dot on'; authEl.txt.textContent = `已登录(${a.userId || '当前会话'})· 压缩包等可下${exp}`; } else { authEl.dot.className = 'dot off'; authEl.txt.textContent = '匿名模式 · 仅视频/音频/图片;压缩包请先在本页登录 PikPak'; } } hookPageRequests(); buildPanel(); })();