// ==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 = `
解析中…
选集(点击可下集)
仅下载站点已开放的集数;锁定集需官方 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();
}
})();