381 lines
13 KiB
JavaScript
381 lines
13 KiB
JavaScript
// ==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();
|
|
})(); |