// ==UserScript== // @name 片源网一键115离线 // @namespace https://github.com/Jssmme/Greasy-Fork // @version 1.4.0 // @description 在资源列表页注入「115离线」按钮:点击后台抓取磁力、复制 BTIH 哈希,提交 115 离线,并在离线资源目录内自动创建以哈希命名的文件夹 // @author JSSM // @license MIT // @match https://wangyougu.net/m_*.html // @match https://www.wangyougu.net/m_*.html // @match https://pianyuan.org/m_*.html // @match https://pianyuan.org/m_*.html // @icon https://wangyougu.net/favicon.ico // @connect 115.com // @connect webapi.115.com // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_notification // @grant GM_openInTab // @run-at document-end // @noframes // ==/UserScript== (function () { 'use strict'; if (window.__WYG115_LOADED__) return; window.__WYG115_LOADED__ = true; /* ================================================================== * * 0. 常量 / 配置 * ================================================================== */ const API = { ADD_URL: 'https://115.com/web/lixian/?ct=lixian&ac=add_task_url', ADD_BT: 'https://115.com/web/lixian/?ct=lixian&ac=add_task_bt', TASK_LIST: 'https://115.com/web/lixian/?ct=lixian&ac=task_lists', FILES: 'https://webapi.115.com/files', FILES_SEARCH: 'https://webapi.115.com/files/search', FILES_ADD: 'https://webapi.115.com/files/add', }; const DEFAULT = { dir: '云下载/临时', pick: 'first', autoTorrent: true, hashInside: true, // 离线后在资源目录内新建 <哈希> 文件夹 copyHash: true, // 提交前把 BTIH 哈希复制到剪贴板 }; // 提交后每隔这么久看一次基础目录,最多看几次;只认这么久之内出现的文件夹 const HASH_FOLDER_DELAY = 3000; const HASH_FOLDER_TRIES = 3; const RECENT_MS = 12000; const S = { get: (k, d = DEFAULT[k]) => { try { const v = GM_getValue('wyg115_' + k); return v === undefined || v === null ? d : v; } catch { return d; } }, set: (k, v) => { try { GM_setValue('wyg115_' + k, v); } catch { /* noop */ } }, }; const HAS_GM = typeof GM_xmlhttpRequest === 'function'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** 调试日志:设置面板里开,默认关 */ const log = (...a) => { try { if (GM_getValue('wyg115_debug', false) === true) console.log('[wyg115]', ...a); } catch { /* noop */ } }; /* ================================================================== * * 1. 网络层 * ================================================================== */ function gm(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ timeout: 30000, ...opts, onload: (r) => resolve(r), onerror: () => reject(new Error('网络错误(115 不可达或被拦截)')), ontimeout: () => reject(new Error('请求超时')), onabort: () => reject(new Error('已取消')), }); }); } function asJson(r) { const body = r.response; if (body && typeof body === 'object') return body; const s = String(body == null ? r.responseText : body).trim(); if (s.startsWith('{') || s.startsWith('[')) { try { return JSON.parse(s); } catch { /* fallthrough */ } } return s; } async function apiGet(url, params = {}) { const u = new URL(url); Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, v); }); const r = await gm({ method: 'GET', url: u.toString(), responseType: 'json', headers: { Accept: 'application/json, text/plain, */*' }, }); if (r.status >= 400) throw new Error(`HTTP ${r.status}`); return asJson(r); } async function apiPostForm(url, data) { const fd = new FormData(); Object.entries(data).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') fd.append(k, v); }); const r = await gm({ method: 'POST', url, data: fd, responseType: 'json', headers: { Accept: 'application/json, text/plain, */*' }, }); if (r.status >= 400) throw new Error(`HTTP ${r.status}`); return asJson(r); } /* ================================================================== * * 2. 115 目录操作 * ================================================================== */ const cidCache = new Map(); const normSeg = (s) => String(s || '').trim().replace(/[\\/:*?"<>|]/g, ''); /** 逐级查找/创建目录链,返回末级 cid,失败 null */ async function ensurePath(routes) { let cid = '0'; for (const route of routes) { const got = await ensureFolderIn(cid, route); if (!got) return null; cid = got; } return cid; } /** 在 pid 下确保有名为 name 的文件夹,返回其 cid */ async function ensureFolderIn(pid, name) { const parent = pid || '0'; const key = `${parent}/${name}`; const memo = cidCache.get(key); if (memo) return memo; try { const j = await apiGet(API.FILES_SEARCH, { search_value: name, fc: 1, cid: parent, limit: 100 }); const list = j && Array.isArray(j.data) ? j.data : []; const hit = list.find((x) => x && String(x.n).trim() === name && x.cid); if (hit) { cidCache.set(key, String(hit.cid)); return String(hit.cid); } } catch (e) { log('search fail', name, e && e.message); } try { const r = await apiPostForm(API.FILES_ADD, { cname: name, pid: parent }); const cid = r && (r.cid || (r.data && r.data.cid)); if (cid) { cidCache.set(key, String(cid)); return String(cid); } log('files/add 返回异常', name, r); } catch (e) { log('add fail', name, e && e.message); } return null; } /** 基础目录 cid(/云下载/临时) */ async function resolveBaseCid() { const base = String(S.get('dir') || '').replace(/^\/+|\/+$/g, '').trim(); const routes = base.split('/').map(normSeg).filter(Boolean); const key = 'cidp_' + routes.join('/'); const memo = cidCache.get(key) || S.get(key, ''); if (memo) return memo; if (!routes.length) return '0'; const got = await ensurePath(routes); if (!got) return '0'; cidCache.set(key, got); S.set(key, got); return got; } /* ================================================================== * * 3. 115 离线任务 * ================================================================== */ const ERRCODE_TEXT = { 911: '触发了 115 安全验证:请在 115 页面手动完成一次验证后重试', 10008: '115 里已有该离线任务(重复提交)', 10004: '离线链接无效', 10003: '离线下载不可用(可能缺少会员权限)', }; function explain(res) { if (!res || typeof res !== 'object') return '115 返回了非预期内容(大概率未登录 115,或接口被拦截)'; if (res.state === true || res.state === 'true') return null; const code = Number(res.errcode); if (ERRCODE_TEXT[code]) return ERRCODE_TEXT[code]; if (res.error_msg) return `${res.error_msg}${code ? ` (errcode ${code})` : ''}`; if (code) return `提交失败 (errcode ${code})`; return '提交失败(未知原因)'; } async function addTaskUrl(magnetUrl, cid) { const res = await apiPostForm(API.ADD_URL, { url: magnetUrl, wp_path_id: cid || '0' }); return { ok: !!(res && (res.state === true || res.state === 'true')), raw: res, msg: explain(res), infoHash: res && (res.info_hash || (res.data && res.data.info_hash)) || '', }; } async function addTaskBt(torrentUrl, cid, thisHash) { const bin = await gm({ method: 'GET', url: torrentUrl, responseType: 'arraybuffer', timeout: 60000 }); if (bin.status >= 400) throw new Error(`种子下载失败 HTTP ${bin.status}`); const blob = new Blob([bin.response], { type: 'application/x-bittorrent' }); const name = decodeURIComponent((torrentUrl.split('/').pop() || 'a.torrent').split('?')[0]) || 'a.torrent'; const fd = new FormData(); fd.append('file', blob, /\.torrent$/i.test(name) ? name : name + '.torrent'); fd.append('wp_path_id', cid || '0'); const r = await gm({ method: 'POST', url: API.ADD_BT, data: fd, responseType: 'json', headers: { Accept: 'application/json, text/plain, */*' }, }); const res = asJson(r); return { ok: !!(res && (res.state === true || res.state === 'true')), raw: res, msg: explain(res), infoHash: res && (res.info_hash || (res.data && res.data.info_hash)) || thisHash || '', }; } /** * 取 115 对象的时间戳(秒或字符串两种形态都兼容) */ function folderTime(x) { const raw = [x.te, x.tp, x.tu, x.t]; for (const v of raw) { if (v === undefined || v === null || v === '') continue; if (typeof v === 'number') return v > 1e12 ? v : v * 1000; const s = String(v).trim(); if (/^\d+$/.test(s)) { const n = Number(s); return n > 1e12 ? n : n * 1000; } const t = Date.parse(s.replace(/-/g, '/')); if (!Number.isNaN(t)) return t; } return 0; } /** * 在基础目录里找「最近刚出现」的子文件夹 * 请求时让 115 按时间倒序,再在本地排序兜底;只认窗口内的,否则视为没有 */ async function findRecentFolder(baseCid) { const parent = String(baseCid || '0'); let data = []; try { const j = await apiGet(API.FILES, { cid: parent, fc: 1, limit: 200, offset: 0, o: 'te', asc: 0 }); data = j && Array.isArray(j.data) ? j.data : []; } catch (e) { log('findRecentFolder', e && e.message); return null; } const now = Date.now(); const newest = data .filter((x) => x && x.cid && String(x.cid) !== parent) .map((x) => ({ cid: String(x.cid), name: String(x.n || ''), time: folderTime(x) })) .filter((x) => x.time > 0) .sort((a, b) => b.time - a.time)[0]; if (!newest) return null; // age 为负说明 115 服务器时间超前,给 60 秒容差 const age = now - newest.time; return age <= RECENT_MS && age >= -60000 ? newest : null; } /* ================================================================== * * 4. 哈希文件夹(定位资源根目录后在它里面新建) * ================================================================== */ /** * 轮询基础目录,找 RECENT_MS 内刚出现的资源根目录,在它里面建哈希文件夹。 * 找不到就不建(交由用户在 115 里手动处理),不再往基础目录乱塞。 */ async function placeHashFolder({ baseCid, hash }) { for (let i = 0; i < HASH_FOLDER_TRIES; i++) { await sleep(HASH_FOLDER_DELAY); const recent = await findRecentFolder(baseCid); if (!recent) continue; const got = await ensureFolderIn(recent.cid, hash); if (got) return { ok: true, pid: recent.cid, name: recent.name }; return { ok: false, reason: 'mkdir-failed' }; } return { ok: false, reason: 'no-recent-folder' }; } /** 串行执行,避免批量时同时打一堆请求 */ let hashChain = Promise.resolve(); function queueHashJob(job, notify) { hashChain = hashChain.then(async () => { const r = await placeHashFolder(job); notify && notify(r.ok, r); }).catch((e) => log('hashChain', e && e.message)); return hashChain; } /* ================================================================== * * 5. 磁力 / 哈希 / 剪贴板 * ================================================================== */ const MAGNET_RE = /magnet:\?xt=urn:btih:[0-9a-zA-Z]{32,40}[^"'\s<>\\]*/gi; const BTIH_RE = /urn:btih:([0-9a-zA-Z]{32,40})/i; const SIZE_MULT = { TB: 1024 ** 4, GB: 1024 ** 3, MB: 1024 ** 2, KB: 1024 }; const unescapeHtml = (s) => String(s).replace(/&/gi, '&').replace(/&/g, '&'); function btihOf(magnet) { const m = String(magnet || '').match(BTIH_RE); return m ? m[1] : ''; } function sizeFromText(txt) { const m = String(txt || '').match(/(\d+(?:\.\d+)?)\s*(TB|GB|MB|KB)/i); return m ? Number.parseFloat(m[1]) * SIZE_MULT[m[2].toUpperCase()] : 0; } function extractMagnets(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const out = []; const seen = new Set(); const push = (raw, ctx) => { if (!raw) return; const m = String(raw).match(/magnet:\?xt=urn:btih:[0-9a-zA-Z]{32,40}[^"'\s<>\\]*/i); if (!m) return; const url = unescapeHtml(m[0]); if (seen.has(url)) return; seen.add(url); let size = 0; let name = ''; if (ctx) { const scope = ctx.closest('tr, li, .item, .magnet, p, div') || ctx; const txt = (scope.textContent || '').replace(/\s+/g, ' ').trim(); size = sizeFromText(txt); name = txt.slice(0, 140); } out.push({ url, size, name }); }; doc.querySelectorAll('a[href^="magnet:"]').forEach((a) => push(a.getAttribute('href'), a)); ['data-clipboard-text', 'data-magnet', 'data-url', 'data-href', 'data-link', 'value', 'onclick'].forEach((attr) => { doc.querySelectorAll(`[${attr}]`).forEach((el) => { const v = el.getAttribute(attr); if (v && /magnet:/i.test(v)) push(v, el); }); }); const text = unescapeHtml(html); let r; while ((r = MAGNET_RE.exec(text))) { const url = r[0].replace(/[&,;]+$/, ''); if (seen.has(url)) continue; seen.add(url); out.push({ url, size: 0, name: '' }); } MAGNET_RE.lastIndex = 0; const torrents = [...doc.querySelectorAll('a[href]')] .map((a) => a.getAttribute('href')) .filter((h) => h && /\.torrent(\?|$)/i.test(h)) .map((h) => new URL(h, location.origin).href); return { magnets: out, torrents: [...new Set(torrents)] }; } const detailCache = new Map(); async function loadDetail(url) { if (detailCache.has(url)) return detailCache.get(url); const p = (async () => { const resp = await fetch(url, { credentials: 'include', headers: { Accept: 'text/html,*/*' } }); if (!resp.ok) throw new Error(`详情页 HTTP ${resp.status}`); const html = await resp.text(); if (html.length < 3000 && /请登录后访问|请先登录|User\/login/i.test(html)) { throw new Error('网游谷登录态已失效,请重新登录'); } return extractMagnets(html); })(); detailCache.set(url, p); p.catch(() => detailCache.delete(url)); return p; } async function copyText(text) { if (!text) return false; try { await navigator.clipboard.writeText(text); return true; } catch { /* 回退 */ } try { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;'; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, ta.value.length); const ok = document.execCommand('copy'); ta.remove(); return ok; } catch { return false; } } /* ================================================================== * * 6. UI * ================================================================== */ const CLS = 'wyg115-b'; const CSS = ` .${CLS}{display:inline-flex;align-items:center;justify-content:center;gap:3px;cursor:pointer; border:0;border-radius:3px;background:#2563eb;color:#fff;font:12px/18px -apple-system,"Microsoft YaHei",sans-serif; padding:2px 8px;white-space:nowrap;vertical-align:middle;transition:background .15s,opacity .15s;user-select:none} .${CLS}:hover{background:#1d4ed8} .${CLS}[data-st="load"]{background:#6b7280;cursor:progress} .${CLS}[data-st="ok"]{background:#15803d} .${CLS}[data-st="err"]{background:#b91c1c} .${CLS}[disabled]{opacity:.7;cursor:progress} .wyg115-all{display:inline-block;margin-left:4px;padding:0 5px;border-radius:3px;background:#e0e7ff; color:#1d4ed8!important;font-size:11px;font-weight:400;cursor:pointer;line-height:15px;vertical-align:middle} .wyg115-all:hover{background:#c7d2fe} #wyg115-toast-box{position:fixed;right:16px;bottom:56px;z-index:999999;display:flex;flex-direction:column;gap:8px; align-items:flex-end;pointer-events:none;font:13px/1.5 -apple-system,"Microsoft YaHei",sans-serif} .wyg115-toast{max-width:400px;background:rgba(17,24,39,.94);color:#fff;padding:8px 14px;border-radius:6px; box-shadow:0 4px 16px rgba(0,0,0,.28);opacity:0;transform:translateY(8px);transition:.2s;word-break:break-all} .wyg115-toast.on{opacity:1;transform:none} .wyg115-toast.ok{background:rgba(21,128,61,.96)} .wyg115-toast.err{background:rgba(185,28,28,.96)} #wyg115-login-tip{position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:999998; display:flex;align-items:center;gap:10px;background:rgba(185,28,28,.96);color:#fff;padding:8px 14px; border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,.28);pointer-events:auto; font:13px/1.5 -apple-system,"Microsoft YaHei",sans-serif} #wyg115-login-tip a{color:#fff;text-decoration:underline;font-weight:600} #wyg115-login-tip b{cursor:pointer;font-weight:400;opacity:.75;padding:0 3px;font-size:15px;line-height:1} #wyg115-login-tip b:hover{opacity:1} #wyg115-gear{position:fixed;right:16px;bottom:16px;z-index:999999;width:32px;height:32px;border-radius:50%; border:0;background:rgba(37,99,235,.9);color:#fff;cursor:pointer;font-size:16px;line-height:32px;text-align:center; box-shadow:0 2px 10px rgba(0,0,0,.25);padding:0} #wyg115-gear:hover{background:#1d4ed8} #wyg115-panel{position:fixed;right:16px;bottom:58px;z-index:999999;width:268px;background:#fff;color:#111; border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.24);padding:14px; font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;display:none} #wyg115-panel h4{margin:0 0 10px;font-size:13px;font-weight:600} #wyg115-panel label{display:block;margin:8px 0 3px;color:#4b5563;font-size:12px} #wyg115-panel input,#wyg115-panel select{width:100%;box-sizing:border-box;padding:5px 7px;border:1px solid #d1d5db; border-radius:4px;font-size:12px;outline:none} #wyg115-panel .chk{display:flex;align-items:center;gap:6px;margin:8px 0 0;color:#374151;font-size:12px;cursor:pointer} #wyg115-panel .chk input{width:auto;margin:0} #wyg115-panel .row{display:flex;gap:8px;margin-top:12px} #wyg115-panel button{flex:1;padding:6px 0;border:0;border-radius:4px;cursor:pointer;font-size:12px} #wyg115-panel .ok{background:#2563eb;color:#fff} #wyg115-panel .cancel{background:#e5e7eb;color:#374151} #wyg115-panel .tip{margin-top:10px;color:#6b7280;font-size:11px;line-height:1.55} `; function injectStyle() { const el = document.createElement('style'); el.textContent = CSS; (document.head || document.documentElement).appendChild(el); } let toastBox = null; function toast(msg, type = '') { if (!toastBox || !document.body.contains(toastBox)) { toastBox = document.createElement('div'); toastBox.id = 'wyg115-toast-box'; document.body.appendChild(toastBox); } const t = document.createElement('div'); t.className = 'wyg115-toast' + (type ? ' ' + type : ''); t.textContent = msg; toastBox.appendChild(t); requestAnimationFrame(() => t.classList.add('on')); setTimeout(() => { t.classList.remove('on'); setTimeout(() => t.remove(), 260); }, type === 'err' ? 5600 : 3000); } function setState(btn, st, text) { btn.dataset.st = st; if (text) btn.textContent = text; if (st === 'load') btn.setAttribute('disabled', ''); else btn.removeAttribute('disabled'); } /* ---------------- 115 登录态检测 ---------------- */ /** 探测 115 登录态:能拿到 files 接口的正常响应就算已登录 */ async function check115Login() { if (!HAS_GM) return true; // 降级模式下不打扰 try { const r = await gm({ method: 'GET', url: 'https://webapi.115.com/files?cid=0&limit=1&offset=0&show_dir=1', responseType: 'json', timeout: 12000, headers: { Accept: 'application/json, text/plain, */*' }, }); const j = asJson(r); if (!j || typeof j !== 'object') return false; // 多半是登录页 HTML if (j.state === true || j.state === 'true') return true; if (Array.isArray(j.data)) return true; if (j.count !== undefined) return true; return false; } catch (e) { log('check115Login', e && e.message); return false; } } function showLoginTip() { if (document.getElementById('wyg115-login-tip')) return; const bar = document.createElement('div'); bar.id = 'wyg115-login-tip'; const txt = document.createElement('span'); txt.textContent = '未检测到 115 登录态,脚本无法提交离线'; const link = document.createElement('a'); link.href = 'https://115.com'; link.target = '_blank'; link.rel = 'noopener'; link.textContent = '去登录'; const hint = document.createElement('span'); hint.style.opacity = '.85'; hint.textContent = '(登录后刷新本页)'; const close = document.createElement('b'); close.textContent = '×'; close.title = '关闭提示'; close.addEventListener('click', () => bar.remove()); bar.append(txt, link, hint, close); document.body.appendChild(bar); } /* ================================================================== * * 7. 业务 * ================================================================== */ function askMagnet(magnets) { return new Promise((resolve) => { const mask = document.createElement('div'); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000000;display:flex;align-items:center;justify-content:center;'; const box = document.createElement('div'); box.style.cssText = 'background:#fff;border-radius:8px;max-width:560px;width:90%;max-height:70vh;overflow:auto;padding:16px;font:13px/1.5 -apple-system,"Microsoft YaHei",sans-serif;'; const fmt = (b) => (!b ? '未知大小' : b >= 1024 ** 3 ? (b / 1024 ** 3).toFixed(2) + ' GB' : (b / 1024 ** 2).toFixed(0) + ' MB'); box.innerHTML = `