first commit

This commit is contained in:
2026-10-11 12:56:38 +08:00
commit 7d21f317a4
41 changed files with 10758 additions and 0 deletions
+381
View File
@@ -0,0 +1,381 @@
// ==UserScript==
// @name 4KHD Downloader 助手
// @namespace http://127.0.0.1:37778
// @version 1.0.0
// @description ① 画廊页:把当前画廊推送到本地 4KHD Downloader ② 列表页:勾选后批量推送
// @author -
// @match *://migmg.ssuu.uk/*
// @match *://www.4khd.com/*
// @match *://4khd.com/*
// @match *://m.4khd.com/*
// @grant GM_xmlhttpRequest
// @connect 127.0.0.1
// @connect localhost
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
'use strict';
const PORT = 37778;
const TOKEN = 'hd4k-local-4b91e3d7';
const API = `http://127.0.0.1:${PORT}`;
// ============================================================
// 公共:通讯
// ============================================================
function ping(cb) {
GM_xmlhttpRequest({
method: 'GET', url: `${API}/ping`, timeout: 1500,
onload: (r) => cb(r.status === 200),
onerror: () => cb(false),
ontimeout: () => cb(false),
});
}
function post(path, data, cb) {
GM_xmlhttpRequest({
method: 'POST',
url: `${API}${path}`,
headers: { 'Content-Type': 'application/json', 'X-HD4K-Token': TOKEN },
data: JSON.stringify(data),
timeout: 8000,
onload: (r) => {
let j = {};
try { j = JSON.parse(r.responseText); } catch (_) {}
cb(r.status === 200, j);
},
// 连不上 = 工具没开
onerror: () => cb(false, { error: 'offline' }),
ontimeout: () => cb(false, { error: 'timeout' }),
});
}
function addStyle(css) {
const s = document.createElement('style');
s.textContent = css;
document.head.appendChild(s);
}
const CSS = `
#hd4k-btn {
display:inline-flex; align-items:center; gap:6px;
padding:5px 13px; border-radius:20px; cursor:pointer;
font:13px/1.4 -apple-system,"PingFang SC",sans-serif;
color:#fff; background:#4a9eff; border:1px solid #3b8ae6;
transition:background .12s,opacity .12s;
}
#hd4k-btn:hover{background:#62acff}
#hd4k-btn:active{transform:translateY(1px)}
#hd4k-btn[disabled]{opacity:.45;cursor:default}
#hd4k-btn.is-off{background:#6b7280;border-color:#6b7280}
#hd4k-btn.is-done{background:#3fb950;border-color:#3fb950}
#hd4k-status{font-size:12px;color:#8b93a3}
#hd4k-status.err{color:#f85149}
#hd4k-status.ok{color:#3fb950}
/* 列表页勾选框 */
.hd4k-cb {
display:inline-block; vertical-align:middle; margin-right:6px;
width:15px; height:15px; cursor:pointer; accent-color:#4a9eff;
position:relative; top:-1px;
}
.hd4k-cb-row.off a{ opacity:.5 }
/* 悬浮批量工具条 */
#hd4k-bar {
position:fixed; right:18px; bottom:18px; z-index:2147483000;
display:flex; align-items:center; gap:9px;
padding:9px 13px; border-radius:11px;
background:rgba(28,31,38,.96); border:1px solid #2e333d;
box-shadow:0 10px 34px rgba(0,0,0,.5);
font:12px/1.4 -apple-system,"PingFang SC",sans-serif; color:#e6e8ec;
backdrop-filter:blur(8px); max-width:calc(100vw - 36px); flex-wrap:wrap;
}
#hd4k-bar .n{ font-variant-numeric:tabular-nums; color:#8b93a3 }
#hd4k-bar .n b{ color:#4a9eff; font-size:14px }
#hd4k-bar button {
background:#23272f; color:#e6e8ec; border:1px solid #2e333d;
border-radius:7px; padding:5px 11px; font-size:12px; cursor:pointer;
}
#hd4k-bar button:hover:not([disabled]){ background:#2c313a; border-color:#3d4450 }
#hd4k-bar button[disabled]{ opacity:.4; cursor:default }
#hd4k-bar button.primary{ background:#4a9eff; border-color:#4a9eff; color:#04121f; font-weight:600 }
#hd4k-bar .dot{
width:8px; height:8px; border-radius:50%;
background:#6b7280; flex:none;
}
#hd4k-bar .dot.on{ background:#3fb950; box-shadow:0 0 7px #3fb950 }
#hd4k-bar .msg{ font-size:12px; color:#8b93a3 }
#hd4k-bar .msg.ok{ color:#3fb950 }
#hd4k-bar .msg.err{ color:#f85149 }
`;
// ============================================================
// 公共:URL 解析
// ============================================================
// 画廊链接形态:/content/{分类数字}/{名字}.html
// 分页是路径后缀(…/x.html/2),归一时必须剥掉。
const RE_GALLERY = /^\/content\/(\d+)\/([^/]+\.html)$/;
function isGalleryPath(pathname) {
return RE_GALLERY.test(pathname);
}
/**
* 把页面上的链接规范化成「当前站点」的绝对 URL。
*
* ⚠️ 这一步不能省:实测 migmg.ssuu.uk 首页里 90 个 /content/ 链接
* **全部**指向 www.4khd.com,而那个域名已经 404。
* 原样把 href 推给后端,后端就会去请求一个死域名。
*/
function canonicalize(href) {
try {
const u = new URL(href, location.origin);
if (!RE_GALLERY.test(u.pathname)) return null;
return `${location.origin}${u.pathname}`;
} catch (_) {
return null;
}
}
/** 取当前页面画廊标题(优先 h3 —— 那是页面可见标题且没有站点后缀) */
function readPageTitle() {
const h3 = document.querySelector('h3.wp-block-post-title');
if (h3) {
const t = h3.textContent.replace(/\s+/g, ' ').trim();
if (t) return t.slice(0, 300);
}
const og = document.querySelector('meta[property="og:title"]');
if (og && og.content) {
return og.content.replace(/\s+/g, ' ').trim().slice(0, 300);
}
return document.title.replace(/\s*-\s*4KHD\s*$/, '').trim().slice(0, 300);
}
// ============================================================
// 模式 A:画廊详情页 —— 顶部加发送按钮
// ============================================================
function runGalleryPage() {
if (!isGalleryPath(location.pathname)) return false;
const galleryUrl = `${location.origin}${location.pathname}`;
const title = readPageTitle();
addStyle(CSS);
// 挂在正文标题附近;找不到就退到页首
const anchor =
document.querySelector('h3.wp-block-post-title') ||
document.querySelector('h1') ||
document.body.firstElementChild;
if (!anchor) return false;
const box = document.createElement('div');
box.style.cssText = 'display:flex;align-items:center;gap:10px;margin:14px 0;flex-wrap:wrap';
const btn = document.createElement('button');
btn.id = 'hd4k-btn';
btn.type = 'button';
btn.innerHTML = '<span>📤</span><span>发送画廊地址</span>';
const status = document.createElement('span');
status.id = 'hd4k-status';
box.append(btn, status);
anchor.insertAdjacentElement('afterend', box);
let online = false;
const setStatus = (t, k) => { status.textContent = t; status.className = k || ''; };
const markOffline = () => {
btn.classList.add('is-off');
btn.title = '4KHD Downloader 没在运行,请先打开';
};
const markDone = () => {
btn.classList.add('is-done');
btn.innerHTML = '<span>✓</span><span>已发送</span>';
};
btn.addEventListener('click', () => {
if (!online) {
markOffline();
setStatus('4KHD Downloader 没在运行,请先打开它', 'err');
return;
}
btn.disabled = true;
setStatus('发送中…', '');
post('/add', { url: galleryUrl, title }, (ok, j) => {
btn.disabled = false;
if (!ok) {
online = false;
markOffline();
btn.innerHTML = '<span>📤</span><span>发送画廊地址</span>';
setStatus(
j.error === 'timeout'
? '✗ 连接超时,请先打开工具'
: '✗ 连不上,请先打开 4KHD Downloader',
'err'
);
return;
}
markDone();
setStatus(j.existed ? '已在列表中(跳过重复)' : '✓ 已推送,等待下载', 'ok');
});
});
ping((ok) => {
online = ok;
if (ok) setStatus('4KHD Downloader 运行中', 'ok');
else { markOffline(); setStatus('4KHD Downloader 未运行 —— 点击会提示', ''); }
});
return true;
}
// ============================================================
// 模式 B:列表页 —— 勾选批量推送
// ============================================================
function runListPage() {
const links = [...document.querySelectorAll('a[href*="/content/"]')];
if (!links.length) return false;
const seen = new Set();
const items = [];
for (const a of links) {
const href = a.getAttribute('href') || '';
const url = canonicalize(href);
if (!url) continue;
// 同一路径在页面上常出现多次(缩略图 + 标题两处)
if (seen.has(url)) continue;
seen.add(url);
// 标题优先取 h3(列表页卡片里的标题),退回链接文本
let title = '';
const t = a.querySelector('h3');
if (t) title = t.textContent;
else if (a.querySelector('img') && a.parentElement?.querySelector('h3')) {
title = a.parentElement.querySelector('h3').textContent;
} else {
title = a.textContent;
}
title = (title || '').replace(/\s+/g, ' ').trim().slice(0, 300);
items.push({ url, title, anchor: a, cb: null });
}
if (!items.length) return false;
addStyle(CSS);
// 每行前面插复选框
for (const it of items) {
const a = it.anchor;
if (!a || !a.parentNode) continue;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'hd4k-cb';
cb.title = `勾选 ${it.url}`;
a.parentNode.insertBefore(cb, a);
cb.addEventListener('change', refresh);
it.cb = cb;
}
// ---- 悬浮工具条 ----
const bar = document.createElement('div');
bar.id = 'hd4k-bar';
bar.innerHTML = `
<span class="dot" id="hd4k-dot"></span>
<span class="n">已选 <b id="hd4k-n">0</b> / ${items.length}</span>
<button id="hd4k-all">全选</button>
<button id="hd4k-inv">反选</button>
<button id="hd4k-none">清空</button>
<button id="hd4k-send" class="primary" disabled>推送到工具</button>
<span class="msg" id="hd4k-msg"></span>
`;
document.body.appendChild(bar);
const $ = (id) => bar.querySelector('#' + id);
const dot = $('hd4k-dot');
const numEl = $('hd4k-n');
const sendBtn = $('hd4k-send');
const msg = $('hd4k-msg');
let online = false;
const boxes = items.map((i) => i.cb).filter(Boolean);
function selected() {
return boxes.filter((c) => c.checked).length;
}
function refresh() {
const n = selected();
numEl.textContent = n;
sendBtn.disabled = n === 0 || !online;
sendBtn.textContent = online ? '推送到工具' : '工具未运行';
sendBtn.classList.toggle('primary', online);
}
function say(text, kind) {
msg.textContent = text;
msg.className = 'msg' + (kind ? ' ' + kind : '');
}
$('hd4k-all').onclick = () => { boxes.forEach((c) => (c.checked = true)); refresh(); };
$('hd4k-inv').onclick = () => { boxes.forEach((c) => (c.checked = !c.checked)); refresh(); };
$('hd4k-none').onclick = () => { boxes.forEach((c) => (c.checked = false)); refresh(); };
sendBtn.onclick = () => {
const picked = items.filter((i) => i.cb && i.cb.checked);
if (!picked.length) return;
sendBtn.disabled = true;
say(`推送 ${picked.length} 个…`, '');
post('/add-batch', {
items: picked.map((i) => ({ url: i.url, title: i.title })),
}, (ok, j) => {
sendBtn.disabled = false;
if (!ok) {
online = false;
dot.classList.remove('on');
say(
j.error === 'timeout'
? '✗ 连接超时,请先打开工具'
: '✗ 连不上,请先打开 4KHD Downloader',
'err'
);
refresh();
return;
}
const bits = [];
if (j.added) bits.push(`新增 ${j.added}`);
if (j.existed) bits.push(`已存在 ${j.existed}`);
if (j.invalid) bits.push(`无效 ${j.invalid}`);
say(`✓ ${bits.join(' · ') || '无变化'}`, 'ok');
picked.forEach((i) => { if (i.cb) i.cb.checked = false; });
refresh();
});
};
ping((ok) => {
online = ok;
dot.classList.toggle('on', ok);
say(ok ? '工具运行中' : '4KHD Downloader 未运行', ok ? 'ok' : 'err');
refresh();
});
refresh();
return true;
}
// ============================================================
// 入口
// ============================================================
if (runGalleryPage()) return;
runListPage();
})();