// ==UserScript== // @name PikPak Aria2 Helper // @name:en PikPak Aria2 Helper // @name:ja PikPak Aria2 ヘルパー // @name:zh-CN PikPak Aria2 助手 // @name:zh-TW PikPak Aria2 助手 // @name:ko PikPak Aria2 도우미 // @name:ru PikPak Aria2 Помощник // @name:es PikPak Aria2 Ayudante // @name:pt-BR PikPak Aria2 Auxiliar // @name:fr PikPak Aria2 Assistant // @name:de PikPak Aria2 Helfer // @namespace https://github.com/CheerChen // @version 0.1.0 // @description Push PikPak files and folders to Aria2 for downloading. // @description:en Push PikPak files and folders to Aria2 for downloading. // @description:ja PikPakのファイルとフォルダをAria2にプッシュしてダウンロードします。 // @description:zh-CN 将 PikPak 文件和文件夹推送到 Aria2 进行下载。 // @description:zh-TW 將 PikPak 檔案和資料夾推送到 Aria2 進行下載。 // @description:ko PikPak 파일과 폴더를 Aria2로 푸시하여 다운로드합니다. // @description:ru Отправка файлов и папок PikPak в Aria2 для скачивания. // @description:es Enviar archivos y carpetas de PikPak a Aria2 para descargar. // @description:pt-BR Enviar arquivos e pastas do PikPak para o Aria2 para download. // @description:fr Envoyer les fichiers et dossiers PikPak vers Aria2 pour le téléchargement. // @description:de PikPak-Dateien und -Ordner zum Herunterladen an Aria2 senden. // @author cheerchen37 // @match *://*mypikpak.com/* // @match *://*mypikpak.net/* // @match *://*pikpak.me/* // @require https://unpkg.com/preact@10/dist/preact.umd.js // @require https://unpkg.com/preact@10/hooks/dist/hooks.umd.js // @require https://unpkg.com/htm@3/dist/htm.umd.js // @grant GM_xmlhttpRequest // @connect * // @icon https://www.google.com/s2/favicons?domain=mypikpak.com // @license MIT // @homepage https://github.com/CheerChen/userscripts // @supportURL https://github.com/CheerChen/userscripts/issues // @downloadURL https://update.greasyfork.icu/scripts/558824/PikPak%20Aria2%20Helper.user.js // @updateURL https://update.greasyfork.icu/scripts/558824/PikPak%20Aria2%20Helper.meta.js // ==/UserScript== (function () { 'use strict'; const { h, render } = preact; const { useState, useEffect } = preactHooks; const html = htm.bind(h); // ─── i18n ─── const i18n = { zh: { aria2Download: 'Aria2 下载', pushToAria2: '推送到 Aria2', configAria2: '配置 Aria2', selectAll: '全选', name: '名称', size: '大小', createdTime: '创建时间', modifiedTime: '修改时间', asc: '升序', desc: '降序', selectFiles: '请先选择要推送的文件', configFirst: '请先配置 Aria2', pushing: '推送中...', pushBtn: n => `推送到 Aria2 (${n})`, progress: (c, t, s, f) => `推送进度: ${c}/${t} (成功: ${s}, 失败: ${f})`, pushDone: (s, f) => f === 0 ? `推送完成!成功 ${s} 个文件` : `推送完成:成功 ${s},失败 ${f}`, scanning: name => `正在扫描文件夹: ${name}`, preparing: t => `准备推送 ${t} 个文件`, connected: 'Aria2 连接正常', disconnected: 'Aria2 连接失败', testing: '正在测试连接...', unknown: '连接状态未知', testBtn: '测试连接', testingBtn: '测试中...', rpcUrl: 'RPC 地址', rpcUrlHint: 'Aria2 RPC 服务地址,通常是 http://127.0.0.1:6800/jsonrpc', rpcToken: 'RPC 密钥', rpcTokenHint: '如果 Aria2 设置了 rpc-secret,请在此填写', rpcTokenPlaceholder: '没有请留空', downloadPath: '下载路径', downloadPathHint: '文件保存路径,例如 /downloads/ 或 D:\\Downloads\\', customParams: '其他参数', customParamsHint: '额外参数,以分号分隔,如 user-agent=Mozilla;split=10', save: '保存', cancel: '取消', }, en: { aria2Download: 'Aria2 Download', pushToAria2: 'Push to Aria2', configAria2: 'Configure Aria2', selectAll: 'Select All', name: 'Name', size: 'Size', createdTime: 'Created', modifiedTime: 'Modified', asc: 'Asc', desc: 'Desc', selectFiles: 'Please select files first', configFirst: 'Please configure Aria2 first', pushing: 'Pushing...', pushBtn: n => `Push to Aria2 (${n})`, progress: (c, t, s, f) => `Progress: ${c}/${t} (Success: ${s}, Failed: ${f})`, pushDone: (s, f) => f === 0 ? `Done! ${s} file(s) pushed` : `Done: ${s} success, ${f} failed`, scanning: name => `Scanning folder: ${name}`, preparing: t => `Preparing ${t} file(s)`, connected: 'Aria2 connected', disconnected: 'Aria2 connection failed', testing: 'Testing connection...', unknown: 'Connection unknown', testBtn: 'Test', testingBtn: 'Testing...', rpcUrl: 'RPC URL', rpcUrlHint: 'Aria2 RPC address, usually http://127.0.0.1:6800/jsonrpc', rpcToken: 'RPC Token', rpcTokenHint: 'Fill in if Aria2 has rpc-secret configured', rpcTokenPlaceholder: 'Leave empty if none', downloadPath: 'Download Path', downloadPathHint: 'e.g. /downloads/ or D:\\Downloads\\', customParams: 'Extra Params', customParamsHint: 'Semicolon-separated, e.g. user-agent=Mozilla;split=10', save: 'Save', cancel: 'Cancel', }, }; const lang = (navigator.language || '').startsWith('zh') ? 'zh' : 'en'; const t = key => i18n[lang][key]; // ─── PikPak API ─── function getHeader() { let token = '', captcha = ''; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key) continue; if (key.startsWith('credentials')) { const d = JSON.parse(localStorage.getItem(key)); token = d.token_type + ' ' + d.access_token; } if (key.startsWith('captcha')) { const d = JSON.parse(localStorage.getItem(key)); captcha = d.captcha_token; } } let deviceId = localStorage.getItem('deviceid') || ''; if (deviceId.includes('.')) deviceId = deviceId.split('.')[1]?.substring(0, 32) || deviceId; return { Authorization: token, 'x-device-id': deviceId, 'x-captcha-token': captcha }; } function getList(parentId) { return fetch(`https://api-drive.mypikpak.com/drive/v1/files?thumbnail_size=SIZE_MEDIUM&limit=500&parent_id=${parentId}&with_audit=true&filters=${encodeURIComponent('{"phase":{"eq":"PHASE_TYPE_COMPLETE"},"trashed":{"eq":false}}')}`, { headers: { 'Content-Type': 'application/json', ...getHeader() }, }).then(r => r.json()); } function getDownloadUrl(fileId) { return fetch(`https://api-drive.mypikpak.com/drive/v1/files/${fileId}?`, { headers: { 'Content-Type': 'application/json', ...getHeader() }, }).then(r => r.json()); } // ─── Aria2 RPC ─── function rpcCall(rpcUrl, data) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: rpcUrl, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(data), onload: res => { try { resolve(JSON.parse(res.responseText)); } catch { reject(new Error('Invalid response')); } }, onerror: e => reject(new Error(e.statusText || 'Network error')), }); }); } // ─── Config ─── const CONFIG_KEY = 'pikpak-aria2-helper-config'; const defaultConfig = { rpcUrl: 'http://127.0.0.1:6800/jsonrpc', rpcToken: '', downloadPath: '', customParams: '', sortBy: 'name', sortDir: 'asc' }; const getConfig = () => { try { return { ...defaultConfig, ...JSON.parse(localStorage.getItem(CONFIG_KEY)) }; } catch { return { ...defaultConfig }; } }; const saveConfig = c => localStorage.setItem(CONFIG_KEY, JSON.stringify(c)); // ─── Helpers ─── const delay = ms => new Promise(r => setTimeout(r, ms)); const colors = { secondary: '#606266', success: '#67c23a', danger: '#f56c6c', blue: '#409eff' }; const formatBytes = b => { if (!b || b <= 0) return ''; const k = 1024, s = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(b) / Math.log(k)); return (b / Math.pow(k, i)).toFixed(1) + ' ' + s[i]; }; const sortFiles = (list, by, dir) => [...list].sort((a, b) => { const af = a.kind === 'drive#folder', bf = b.kind === 'drive#folder'; if (af !== bf) return af ? -1 : 1; let av = a[by], bv = b[by]; if (by === 'size') { av = parseInt(av || '0'); bv = parseInt(bv || '0'); } else if (by.includes('time')) { av = new Date(av).getTime(); bv = new Date(bv).getTime(); } else { av = (av || '').toLowerCase(); bv = (bv || '').toLowerCase(); } const c = av > bv ? 1 : av < bv ? -1 : 0; return dir === 'asc' ? c : -c; }); function testAria2(rpcUrl, rpcToken) { const payload = { jsonrpc: '2.0', method: 'aria2.getVersion', id: 1, params: rpcToken ? [`token:${rpcToken}`] : [] }; return rpcCall(rpcUrl, payload).then(r => !!(r && r.result)); } // ─── Components ─── function Toast({ message, type }) { if (!message) return null; const bg = { success: 'rgba(103,194,58,.9)', error: 'rgba(245,108,108,.9)', warning: 'rgba(230,162,60,.9)', info: 'rgba(64,158,255,.9)' }; const icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' }; return html`
${icons[type] || icons.info} ${message}
`; } function ConnectionStatus({ status, onTest, testing }) { const cfg = { connected: { color: colors.success, text: t('connected') }, disconnected: { color: colors.danger, text: t('disconnected') }, testing: { color: '#e6a23c', text: t('testing') }, unknown: { color: '#909399', text: t('unknown') } }; const s = cfg[status] || cfg.unknown; return html`
${s.text}
`; } function FileItem({ file, selected, onSelect, status, sortBy }) { const icons = { success: '✅', error: '❌', downloading: '⏳' }; const info = () => { if (sortBy === 'size') return file.size && parseInt(file.size) > 0 ? formatBytes(parseInt(file.size)) : ''; if (sortBy === 'created_time' || sortBy === 'modified_time') return file[sortBy] ? new Date(file[sortBy]).toLocaleString() : ''; return file.size && parseInt(file.size) > 0 ? formatBytes(parseInt(file.size)) : ''; }; return html`
onSelect(file.id, e.target.checked)} style="margin-right:12px" /> ${file.kind === 'drive#folder' ? '📁' : '📄'}
${file.name}
${info()} ${status && html`${icons[status] || ''}`}
`; } function ConfigPanel({ config, onSave, onClose }) { const [local, setLocal] = useState(config); const [connStatus, setConnStatus] = useState('unknown'); const [testing, setTesting] = useState(false); const doTest = async () => { if (!local.rpcUrl) return; setTesting(true); setConnStatus('testing'); try { setConnStatus(await testAria2(local.rpcUrl, local.rpcToken) ? 'connected' : 'disconnected'); } catch { setConnStatus('disconnected'); } finally { setTesting(false); } }; useEffect(() => { if (local.rpcUrl) doTest(); }, []); const handleSave = () => { const c = { ...local }; if (c.downloadPath && !/[/\\]$/.test(c.downloadPath)) c.downloadPath += '/'; saveConfig(c); onSave(c); onClose(); }; const field = (key, label, hint, placeholder) => html`
setLocal({ ...local, [key]: e.target.value })} style="width:100%;padding:8px 12px;border:1px solid #dcdfe6;border-radius:4px;font-size:14px;box-sizing:border-box" />
${hint}
`; return html`

${t('configAria2')}

<${ConnectionStatus} status=${connStatus} onTest=${doTest} testing=${testing} />
${field('rpcUrl', t('rpcUrl'), t('rpcUrlHint'), 'http://127.0.0.1:6800/jsonrpc')} ${field('rpcToken', t('rpcToken'), t('rpcTokenHint'), t('rpcTokenPlaceholder'))} ${field('downloadPath', t('downloadPath'), t('downloadPathHint'), '/downloads/')} ${field('customParams', t('customParams'), t('customParamsHint'), 'user-agent=xxx;split=10')}
`; } function Aria2Modal({ onClose }) { const [files, setFiles] = useState([]); const [selected, setSelected] = useState(new Set()); const [statuses, setStatuses] = useState({}); const [pushing, setPushing] = useState(false); const [showConfig, setShowConfig] = useState(false); const [config, setConfigState] = useState(getConfig()); const [toast, setToast] = useState(null); const [connStatus, setConnStatus] = useState('unknown'); const [testing, setTesting] = useState(false); const [progress, setProgress] = useState({ cur: 0, total: 0, success: 0, failed: 0 }); const [sortBy, setSortBy_] = useState(config.sortBy || 'name'); const [sortDir, setSortDir_] = useState(config.sortDir || 'asc'); const setSortBy = v => { setSortBy_(v); const c = { ...config, sortBy: v }; saveConfig(c); setConfigState(c); }; const setSortDir = v => { setSortDir_(v); const c = { ...config, sortDir: v }; saveConfig(c); setConfigState(c); }; const showToastMsg = (message, type = 'info') => { setToast({ message, type }); setTimeout(() => setToast(null), 3000); }; const doTest = async () => { if (!config.rpcUrl) return; setTesting(true); setConnStatus('testing'); try { setConnStatus(await testAria2(config.rpcUrl, config.rpcToken) ? 'connected' : 'disconnected'); } catch { setConnStatus('disconnected'); } finally { setTesting(false); } }; useEffect(() => { let pid = location.pathname.split('/').pop(); if (pid === 'all') pid = ''; getList(pid).then(r => r.files && setFiles(sortFiles(r.files, sortBy, sortDir))).catch(console.error); setTimeout(doTest, 500); }, []); useEffect(() => { setFiles(f => sortFiles(f, sortBy, sortDir)); }, [sortBy, sortDir]); const toggleSelect = (id, on) => setSelected(s => { const n = new Set(s); on ? n.add(id) : n.delete(id); return n; }); const selectAll = on => setSelected(on ? new Set(files.map(f => f.id)) : new Set()); const getAllFiles = async () => { const allFiles = [], folders = []; for (const id of selected) { const f = files.find(x => x.id === id); if (!f) continue; f.kind === 'drive#folder' ? folders.push({ id: f.id, name: f.name, path: f.name }) : allFiles.push({ ...f, path: '' }); } while (folders.length > 0) { const folder = folders.shift(); showToastMsg(t('scanning')(folder.name), 'info'); try { const res = await getList(folder.id); if (res.files) for (const f of res.files) { f.kind === 'drive#folder' ? folders.push({ id: f.id, name: f.name, path: `${folder.path}/${f.name}` }) : allFiles.push({ ...f, path: folder.path }); } } catch (e) { console.error('Folder scan failed:', folder.name, e); } } return allFiles; }; const pushToAria = async () => { if (selected.size === 0) return showToastMsg(t('selectFiles'), 'warning'); if (!config.rpcUrl) { showToastMsg(t('configFirst'), 'error'); setShowConfig(true); return; } setPushing(true); const filesToPush = await getAllFiles(); let success = 0, failed = 0; setProgress({ cur: 0, total: filesToPush.length, success: 0, failed: 0 }); showToastMsg(t('preparing')(filesToPush.length), 'info'); for (let i = 0; i < filesToPush.length; i++) { const file = filesToPush[i]; try { const dl = await getDownloadUrl(file.id); if (dl.error_description) throw new Error(dl.error_description); const params = [[ dl.web_content_link ], { out: dl.name }]; if (config.downloadPath) params[1].dir = config.downloadPath + (file.path || ''); if (config.customParams) config.customParams.split(';').forEach(p => { const [k, v] = p.split('='); if (k && v) params[1][k] = v; }); if (config.rpcToken) params.unshift(`token:${config.rpcToken}`); const res = await rpcCall(config.rpcUrl, { id: Date.now(), jsonrpc: '2.0', method: 'aria2.addUri', params }); if (res.result) { success++; setStatuses(p => ({ ...p, [file.id]: 'success' })); } else throw new Error(res.error?.message || 'Unknown error'); } catch { failed++; setStatuses(p => ({ ...p, [file.id]: 'error' })); } setProgress({ cur: i + 1, total: filesToPush.length, success, failed }); if (i < filesToPush.length - 1) await delay(100); } showToastMsg(t('pushDone')(success, failed), failed === 0 ? 'success' : success === 0 ? 'error' : 'warning'); setPushing(false); }; if (showConfig) return html`<${ConfigPanel} config=${config} onSave=${c => setConfigState(c)} onClose=${() => setShowConfig(false)} />`; return html`
e.target === e.currentTarget && onClose()}> <${Toast} ...${{ ...toast }} />
e.stopPropagation()}>

${t('pushToAria2')}

<${ConnectionStatus} status=${connStatus} onTest=${doTest} testing=${testing} />
${files.map(f => html`<${FileItem} key=${f.id} file=${f} selected=${selected.has(f.id)} onSelect=${toggleSelect} status=${statuses[f.id]} sortBy=${sortBy} />`)}
${pushing && html`
${t('progress')(progress.cur, progress.total, progress.success, progress.failed)}
`}
`; } // ─── Init ─── function initApp() { if (location.pathname === '/') return; const ops = document.querySelector('.file-operations'); if (!ops) return setTimeout(initApp, 1000); if (ops.querySelector('.aria2-helper-button')) return; const li = document.createElement('li'); li.className = 'icon-with-label aria2-helper-button'; li.innerHTML = ` ${t('aria2Download')} `; li.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); if (document.getElementById('pikpak-aria2-helper-modal')) return; const container = document.createElement('div'); container.id = 'pikpak-aria2-helper-modal'; document.body.appendChild(container); render(html`<${Aria2Modal} onClose=${() => { render(null, container); container.remove(); }} />`, container); }); const divider = ops.querySelector('.divider-in-operations'); divider ? ops.insertBefore(li, divider) : ops.appendChild(li); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initApp); else setTimeout(initApp, 1000); })();