// ==UserScript== // @name 红果短剧可播集下载 // @namespace https://bbs.tampermonkey.net.cn/ // @version 0.1.0 // @description 在播放页提取可播 MP4,按「剧名_第N集」下载;批量仅处理站点已开放集数,不绕过锁定 // @author local // @match *://hongguoduanju.com/* // @match *://*.hongguoduanju.com/* // @run-at document-idle // @grant GM_download // @grant GM_xmlhttpRequest // @grant GM_notification // @connect hongguoduanju.com // @connect *.hongguoduanju.com // @connect *.qznovelvod.com // @connect qznovelvod.com // @connect * // ==/UserScript== (function () { "use strict"; const VERSION = "0.1.0"; const IS_TOP = window === window.top; if (!IS_TOP) return; const state = { seriesId: "", seriesName: "", vidList: [], accessibleCnt: 0, currentVid: "", currentEp: 0, currentUrl: "", busy: false, queue: [], logs: [], }; function now() { return Date.now(); } function log(msg, level) { const line = "[" + new Date().toLocaleTimeString() + "] " + msg; state.logs.unshift({ line, level: level || "info" }); if (state.logs.length > 80) state.logs.length = 80; const box = document.getElementById("hg-dl-log"); if (box) { box.innerHTML = state.logs .map((x) => { const c = x.level === "ok" ? "#3dd68c" : x.level === "warn" ? "#f5a524" : x.level === "err" ? "#ff6369" : "#c8c8c8"; return '
' + escapeHtml(x.line) + "
"; }) .join(""); } try { console.log("[hg-dl " + VERSION + "]", msg); } catch (_) {} } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function sanitizeName(name) { return String(name || "未命名") .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") .replace(/\s+/g, " ") .trim() .slice(0, 80); } function padEp(n) { return String(n).padStart(2, "0"); } function buildFilename(seriesName, ep) { return sanitizeName(seriesName) + "_第" + padEp(ep) + "集.mp4"; } function extractBalancedObject(text, fromIdx) { const start = text.indexOf("{", fromIdx); if (start < 0) return null; let depth = 0; for (let i = start; i < text.length; i++) { const ch = text[i]; if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) return text.slice(start, i + 1); } } return null; } function parseRouterData(html) { const marker = "_ROUTER_DATA"; const idx = html.indexOf(marker); if (idx < 0) return null; const raw = extractBalancedObject(html, idx); if (!raw) return null; try { return Function("return (" + raw + ")")(); } catch (e) { return null; } } function pickPlayerPage(router) { if (!router || !router.loaderData) return null; const ld = router.loaderData; const prefer = [ "player_(series_id)/(vid)/page", "player_(series_id)/page", "player_(series_id)\\/(vid)/page", "player_(series_id)\\/page", ]; for (let i = 0; i < prefer.length; i++) { if (ld[prefer[i]] && ld[prefer[i]].seriesDetail) return ld[prefer[i]]; } const keys = Object.keys(ld); for (let i = 0; i < keys.length; i++) { const v = ld[keys[i]]; if (v && v.seriesDetail && (v.video_player_info || v.vid)) return v; } return null; } function applyPageInfo(page) { if (!page || !page.seriesDetail) return false; const sd = page.seriesDetail; state.seriesId = String(sd.series_id || page.series_id || ""); state.seriesName = sd.series_name || state.seriesName || ""; state.vidList = Array.isArray(sd.vid_list) ? sd.vid_list.map(String) : []; state.accessibleCnt = Number(sd.accessible_episode_cnt) || 0; state.currentVid = String(page.vid || ""); const epIdx = state.vidList.indexOf(state.currentVid); state.currentEp = epIdx >= 0 ? epIdx + 1 : 0; const info = page.video_player_info || {}; state.currentUrl = info.main_url || ""; return true; } function readFromDomVideo() { const videos = Array.from(document.querySelectorAll("video")); for (let i = 0; i < videos.length; i++) { const v = videos[i]; const src = v.currentSrc || v.src || ""; if (/qznovelvod|\.mp4|video\/tos/i.test(src)) return src; } return ""; } function refreshFromDocument() { const router = parseRouterData(document.documentElement.innerHTML); const page = pickPlayerPage(router); const ok = applyPageInfo(page); if (!state.currentUrl) { const domUrl = readFromDomVideo(); if (domUrl) state.currentUrl = domUrl; } // URL 回落解析 if (!state.seriesId) { const m = location.pathname.match(/\/player\/(\d+)(?:\/(\d+))?/); if (m) { state.seriesId = m[1]; if (m[2]) state.currentVid = m[2]; } } renderMeta(); renderEpisodeList(); return ok; } function episodePageUrl(seriesId, vid, epIndex0) { if (epIndex0 === 0) return location.origin + "/player/" + seriesId; return location.origin + "/player/" + seriesId + "/" + vid; } function gmGet(url) { return new Promise((resolve, reject) => { try { GM_xmlhttpRequest({ method: "GET", url, timeout: 45000, anonymous: false, headers: { Accept: "text/html,application/xhtml+xml", "Cache-Control": "no-cache", }, onload(res) { resolve(res); }, onerror(err) { reject(err || new Error("network error")); }, ontimeout() { reject(new Error("timeout")); }, }); } catch (e) { reject(e); } }); } async function fetchEpisodePlayable(ep) { const vid = state.vidList[ep - 1]; if (!vid) throw new Error("无 vid"); if (state.accessibleCnt > 0 && ep > state.accessibleCnt) { throw new Error("第" + ep + "集未开放(站点锁定,需 App/权益)"); } // 当前页且已有地址 if (String(vid) === String(state.currentVid) && state.currentUrl) { return { ep, vid, url: state.currentUrl, name: state.seriesName }; } const pageUrl = episodePageUrl(state.seriesId, vid, ep - 1); const res = await gmGet(pageUrl); if (res.status === 404) throw new Error("第" + ep + "集 404(未开放)"); if (res.status < 200 || res.status >= 300) throw new Error("HTTP " + res.status); const router = parseRouterData(res.responseText || ""); const page = pickPlayerPage(router); if (!page) throw new Error("第" + ep + "集无播放数据"); const url = page.video_player_info && page.video_player_info.main_url; if (!url) throw new Error("第" + ep + "集无 main_url"); const name = (page.seriesDetail && page.seriesDetail.series_name) || state.seriesName; return { ep, vid, url, name }; } function gmDownloadFile(url, filename) { return new Promise((resolve, reject) => { try { const ret = GM_download({ url, name: filename, saveAs: false, timeout: 120000, onload() { resolve(true); }, onerror(err) { reject(err || new Error("GM_download failed")); }, ontimeout() { reject(new Error("download timeout")); }, }); // 部分实现同步返回 if (ret === false) reject(new Error("GM_download 被拒绝")); } catch (e) { reject(e); } }); } async function downloadOne(ep) { const info = await fetchEpisodePlayable(ep); const filename = buildFilename(info.name || state.seriesName, info.ep); log("开始下载 " + filename, "info"); await gmDownloadFile(info.url, filename); log("已触发下载 " + filename, "ok"); try { GM_notification({ title: "红果短剧下载", text: filename, timeout: 2500, }); } catch (_) {} return info; } async function downloadCurrent() { if (state.busy) return; state.busy = true; setBusyUi(true); try { refreshFromDocument(); if (!state.currentEp && state.currentUrl) state.currentEp = 1; if (!state.currentEp) throw new Error("未识别当前集数,请在播放页使用"); await downloadOne(state.currentEp); } catch (e) { log("本集失败: " + ((e && e.message) || e), "err"); } finally { state.busy = false; setBusyUi(false); } } async function downloadBatchAccessible() { if (state.busy) return; refreshFromDocument(); if (!state.seriesId || !state.vidList.length) { log("请先打开某部剧的播放页", "warn"); return; } const max = Math.min( state.accessibleCnt > 0 ? state.accessibleCnt : state.vidList.length, state.vidList.length ); if (max <= 0) { log("没有可下载的开放集数", "warn"); return; } if ( !confirm( "将依次下载本剧已开放的前 " + max + " 集(共 " + state.vidList.length + " 集,锁定集不会尝试绕过)。\n文件名:剧名_第NN集.mp4\n继续?" ) ) { return; } state.busy = true; setBusyUi(true); let ok = 0; let fail = 0; try { for (let ep = 1; ep <= max; ep++) { try { await downloadOne(ep); ok++; // 轻微间隔,降低风控 await sleep(800); } catch (e) { fail++; log("第" + ep + "集跳过: " + ((e && e.message) || e), "warn"); } } log("批量结束:成功 " + ok + " / 失败 " + fail, ok ? "ok" : "warn"); } finally { state.busy = false; setBusyUi(false); } } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function setBusyUi(busy) { ["hg-dl-cur", "hg-dl-batch", "hg-dl-refresh"].forEach((id) => { const el = document.getElementById(id); if (el) el.disabled = !!busy; }); } function renderMeta() { const meta = document.getElementById("hg-dl-meta"); if (!meta) return; meta.innerHTML = "
剧名 " + escapeHtml(state.seriesName || "-") + "
" + "
系列ID " + escapeHtml(state.seriesId || "-") + "
" + "
当前 第" + (state.currentEp || "?") + "集 / 共" + (state.vidList.length || "?") + "集
" + "
开放 前 " + (state.accessibleCnt || 0) + " 集可下(其余站点锁定)
" + "
本集地址 " + (state.currentUrl ? "已捕获" : "未捕获") + "
"; } function renderEpisodeList() { const box = document.getElementById("hg-dl-eps"); if (!box) return; if (!state.vidList.length) { box.innerHTML = '
未解析到选集,请打开 /player/… 播放页
'; return; } const html = state.vidList .map((vid, i) => { const ep = i + 1; const locked = state.accessibleCnt > 0 && ep > state.accessibleCnt; const current = String(vid) === String(state.currentVid); const label = locked ? "锁定" : "可下"; const color = locked ? "#888" : current ? "#ff8f1f" : "#3dd68c"; return ( '" ); }) .join(""); box.innerHTML = html; box.querySelectorAll("button.hg-ep").forEach((btn) => { btn.addEventListener("click", async () => { if (state.busy) return; const ep = Number(btn.getAttribute("data-ep")); const locked = btn.getAttribute("data-locked") === "1"; if (locked) { log("第" + ep + "集被站点锁定,脚本不会绕过", "warn"); return; } state.busy = true; setBusyUi(true); try { await downloadOne(ep); } catch (e) { log("第" + ep + "集失败: " + ((e && e.message) || e), "err"); } finally { state.busy = false; setBusyUi(false); } }); }); } function ensurePanel() { if (document.getElementById("hg-dl-panel")) return; const css = document.createElement("style"); css.textContent = ` #hg-dl-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:340px;max-height:78vh; background:#16181d;color:#eee;border:1px solid #333;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.45); font:13px/1.45 system-ui,-apple-system,Segoe UI,sans-serif;display:flex;flex-direction:column;overflow:hidden} #hg-dl-panel header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:#1f232b;cursor:move;user-select:none} #hg-dl-panel header b{font-size:13px} #hg-dl-panel .body{padding:10px 12px;overflow:auto} #hg-dl-panel .btns{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0} #hg-dl-panel button{appearance:none;border:0;border-radius:8px;padding:7px 10px;background:#ff8f1f;color:#111;font-weight:600;cursor:pointer} #hg-dl-panel button.secondary{background:#2a2f3a;color:#ddd} #hg-dl-panel button:disabled{opacity:.5;cursor:not-allowed} #hg-dl-meta{opacity:.95;font-size:12px;display:grid;gap:4px} #hg-dl-eps{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;margin-top:8px;max-height:180px;overflow:auto} #hg-dl-eps .hg-ep{padding:6px 0;background:transparent;font-weight:600;display:flex;flex-direction:column;align-items:center;gap:2px} #hg-dl-eps .hg-ep small{font-size:10px;font-weight:500;opacity:.85} #hg-dl-log{margin-top:8px;max-height:140px;overflow:auto;background:#0f1115;border-radius:8px;padding:8px;font-size:11px} #hg-dl-panel .hint{margin-top:8px;color:#9aa;font-size:11px} `; document.documentElement.appendChild(css); const panel = document.createElement("div"); panel.id = "hg-dl-panel"; panel.innerHTML = `
红果短剧下载 v${VERSION} 可拖动
解析中…
选集(点击可下集)
仅下载站点已开放的集数;锁定集需官方 App/权益,脚本不会破解。文件名:剧名_第NN集.mp4
`; document.documentElement.appendChild(panel); document.getElementById("hg-dl-cur").addEventListener("click", downloadCurrent); document.getElementById("hg-dl-batch").addEventListener("click", downloadBatchAccessible); document.getElementById("hg-dl-refresh").addEventListener("click", () => { refreshFromDocument(); log("已刷新页面信息", "info"); }); // drag const header = panel.querySelector("header"); let dragging = false; let ox = 0; let oy = 0; header.addEventListener("mousedown", (e) => { dragging = true; const rect = panel.getBoundingClientRect(); ox = e.clientX - rect.left; oy = e.clientY - rect.top; panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.left = rect.left + "px"; panel.style.top = rect.top + "px"; e.preventDefault(); }); window.addEventListener("mousemove", (e) => { if (!dragging) return; panel.style.left = Math.max(0, e.clientX - ox) + "px"; panel.style.top = Math.max(0, e.clientY - oy) + "px"; }); window.addEventListener("mouseup", () => { dragging = false; }); } function hookSpa() { const wrap = (fn) => function () { const ret = fn.apply(this, arguments); setTimeout(refreshFromDocument, 600); return ret; }; try { history.pushState = wrap(history.pushState); history.replaceState = wrap(history.replaceState); } catch (_) {} window.addEventListener("popstate", () => setTimeout(refreshFromDocument, 600)); } function boot() { if (!/hongguoduanju\.com/i.test(location.host)) return; ensurePanel(); hookSpa(); refreshFromDocument(); // 播放器晚加载,再扫几次 let n = 0; const timer = setInterval(() => { refreshFromDocument(); n++; if (n >= 8 || state.currentUrl) clearInterval(timer); }, 1200); log("脚本已加载。请打开剧集播放页使用。", "ok"); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } })();