hackertube-zh 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -2
- package/background.js +157 -0
- package/bin/install.js +141 -0
- package/content/player-main.js +121 -0
- package/content/player.css +32 -0
- package/content/player.js +215 -0
- package/content/site.css +31 -0
- package/content/site.js +283 -0
- package/icons/icon128.png +0 -0
- package/icons/icon16.png +0 -0
- package/icons/icon32.png +0 -0
- package/icons/icon48.png +0 -0
- package/lib/embedlogin.js +32 -0
- package/lib/engines.js +184 -0
- package/lib/runtime.js +64 -0
- package/lib/subtitles.js +210 -0
- package/lib/textutil.js +40 -0
- package/lib/uidict.js +105 -0
- package/manifest.json +59 -0
- package/options/options.css +30 -0
- package/options/options.html +105 -0
- package/options/options.js +99 -0
- package/package.json +30 -4
- package/popup/popup.css +9 -0
- package/popup/popup.html +52 -0
- package/popup/popup.js +43 -0
- package/ui/common.css +41 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Isolated content script inside HackerTube's YouTube embed: receives the caption file from player-main.js,
|
|
2
|
+
// regroups it into sentences, translates them a few minutes ahead of playback and draws the Chinese subtitles
|
|
3
|
+
// over the video (inside the player element, so they also show in fullscreen).
|
|
4
|
+
(() => {
|
|
5
|
+
const H = globalThis.HTZ;
|
|
6
|
+
const S = H.subtitles, R = H.runtime;
|
|
7
|
+
|
|
8
|
+
const anc = location.ancestorOrigins;
|
|
9
|
+
const origins = anc ? Array.from({ length: anc.length }, (_, i) => anc[i]) : [document.referrer];
|
|
10
|
+
const fromHackerTube = origins.some((o) => { try { return /(^|\.)hackertube\.org$/.test(new URL(o).hostname); } catch { return false; } });
|
|
11
|
+
// HTZ_ALLOW_ANY_HOST is only ever set by the local test harness (dev/), which loads this file as a page script
|
|
12
|
+
if (!fromHackerTube && !globalThis.HTZ_ALLOW_ANY_HOST) return;
|
|
13
|
+
|
|
14
|
+
const LOOKAHEAD_MS = 5 * 60 * 1000; // translate up to five minutes ahead: cost follows what is actually watched
|
|
15
|
+
const MAX_INFLIGHT = 2;
|
|
16
|
+
|
|
17
|
+
let settings = { ...R.DEFAULTS };
|
|
18
|
+
let vid = null, title = "";
|
|
19
|
+
let sentences = [], zh = [], st = []; // st: 0 = not sent, 1 = in flight, 2 = done, 3 = gave up
|
|
20
|
+
let gen = 0, inflight = 0, failures = 0, doneCount = 0;
|
|
21
|
+
|
|
22
|
+
const post = (type, data = {}) => window.postMessage({ source: "htz-iso", type, ...data }, "*");
|
|
23
|
+
const sendConfig = () => post("config", { enabled: settings.subMode !== "off" });
|
|
24
|
+
const player = () => document.getElementById("movie_player");
|
|
25
|
+
const video = () => document.querySelector("#movie_player video") || document.querySelector("video");
|
|
26
|
+
|
|
27
|
+
// ---------- overlay ----------
|
|
28
|
+
let overlay, zhLine, enLine, badge, badgeTimer, shown = "";
|
|
29
|
+
const ro = new ResizeObserver((entries) => {
|
|
30
|
+
for (const e of entries) e.target.style.setProperty("--htz-h", `${Math.round(e.contentRect.height)}px`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function ensureOverlay() {
|
|
34
|
+
const p = player();
|
|
35
|
+
if (!p) return false;
|
|
36
|
+
if (overlay && overlay.isConnected && overlay.parentElement === p) return true;
|
|
37
|
+
overlay = document.createElement("div");
|
|
38
|
+
overlay.className = "htz-sub";
|
|
39
|
+
overlay.setAttribute("aria-hidden", "true");
|
|
40
|
+
zhLine = document.createElement("div"); zhLine.className = "htz-line htz-zh"; zhLine.lang = "zh-CN";
|
|
41
|
+
enLine = document.createElement("div"); enLine.className = "htz-line htz-en";
|
|
42
|
+
overlay.append(zhLine, enLine);
|
|
43
|
+
badge = document.createElement("div"); badge.className = "htz-badge";
|
|
44
|
+
p.append(overlay, badge);
|
|
45
|
+
ro.observe(p);
|
|
46
|
+
applyStyle();
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function applyStyle() {
|
|
51
|
+
const p = player();
|
|
52
|
+
if (!p) return;
|
|
53
|
+
p.style.setProperty("--htz-scale", String(settings.subScale || 1));
|
|
54
|
+
p.style.setProperty("--htz-bottom", String(settings.subBottom ?? 8));
|
|
55
|
+
// our lines include the English, so YouTube's own captions would only duplicate them
|
|
56
|
+
p.classList.toggle("htz-hide-native", settings.subMode !== "off" && sentences.length > 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function say(msg, { ms = 4000, error = false } = {}) {
|
|
60
|
+
if (!ensureOverlay()) return;
|
|
61
|
+
clearTimeout(badgeTimer);
|
|
62
|
+
badge.textContent = msg;
|
|
63
|
+
badge.classList.toggle("err", error);
|
|
64
|
+
badge.classList.add("on");
|
|
65
|
+
if (ms) badgeTimer = setTimeout(() => badge.classList.remove("on"), ms);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function render() {
|
|
69
|
+
if (!overlay || !overlay.isConnected) { if (!sentences.length || !ensureOverlay()) return; }
|
|
70
|
+
const v = video();
|
|
71
|
+
const i = v && sentences.length && settings.subMode !== "off" ? S.findActive(sentences, v.currentTime * 1000) : -1;
|
|
72
|
+
let z = "", e = "";
|
|
73
|
+
if (i >= 0) {
|
|
74
|
+
const en = sentences[i].text, tr = zh[i];
|
|
75
|
+
if (settings.subMode === "zh") z = tr || en;
|
|
76
|
+
else { z = tr || ""; e = en; }
|
|
77
|
+
}
|
|
78
|
+
const key = `${z}\u0001${e}`;
|
|
79
|
+
if (key === shown) return;
|
|
80
|
+
shown = key;
|
|
81
|
+
zhLine.textContent = z;
|
|
82
|
+
enLine.textContent = e;
|
|
83
|
+
overlay.classList.toggle("on", !!(z || e));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let raf = 0;
|
|
87
|
+
function loop() {
|
|
88
|
+
raf = 0;
|
|
89
|
+
render();
|
|
90
|
+
const v = video();
|
|
91
|
+
if (v && !v.paused) raf = requestAnimationFrame(loop);
|
|
92
|
+
}
|
|
93
|
+
const kick = () => { if (!raf) raf = requestAnimationFrame(loop); };
|
|
94
|
+
|
|
95
|
+
// ---------- translation scheduling ----------
|
|
96
|
+
function progress() {
|
|
97
|
+
if (!sentences.length) return;
|
|
98
|
+
const total = sentences.length;
|
|
99
|
+
if (doneCount < total && inflight) say(`中文字幕 · 翻译中 ${doneCount}/${total}`, { ms: 0 });
|
|
100
|
+
else if (badge && badge.textContent.startsWith("中文字幕 · 翻译中")) say(`中文字幕 · 已就绪 ${doneCount}/${total}`, { ms: 1500 });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pump() {
|
|
104
|
+
if (settings.subMode === "off" || !sentences.length) return;
|
|
105
|
+
const v = video();
|
|
106
|
+
const now = v ? v.currentTime * 1000 : 0;
|
|
107
|
+
while (inflight < MAX_INFLIGHT) {
|
|
108
|
+
let i = S.firstFrom(sentences, now - 2000);
|
|
109
|
+
while (i < sentences.length && st[i] !== 0) i++;
|
|
110
|
+
if (i >= sentences.length || sentences[i].start > now + LOOKAHEAD_MS) break;
|
|
111
|
+
// first request is small so the first subtitle appears within a second or two
|
|
112
|
+
const size = doneCount === 0 && inflight === 0 ? 6 : 25;
|
|
113
|
+
const idx = [];
|
|
114
|
+
for (let j = i; j < sentences.length && idx.length < size && st[j] === 0; j++) idx.push(j);
|
|
115
|
+
idx.forEach((k) => { st[k] = 1; });
|
|
116
|
+
inflight++;
|
|
117
|
+
const myGen = gen;
|
|
118
|
+
const previous = sentences.slice(Math.max(0, i - 3), i).map((s) => s.text);
|
|
119
|
+
R.translate(idx.map((k) => sentences[k].text), { kind: "subtitle", context: { title, previous } })
|
|
120
|
+
.then((res) => {
|
|
121
|
+
if (myGen !== gen) return;
|
|
122
|
+
failures = 0;
|
|
123
|
+
idx.forEach((k, n) => { zh[k] = res.translations[n]; st[k] = 2; });
|
|
124
|
+
doneCount += idx.length;
|
|
125
|
+
if (res.warning) say(res.warning, { error: true, ms: 6000 });
|
|
126
|
+
})
|
|
127
|
+
.catch((err) => {
|
|
128
|
+
if (myGen !== gen) return;
|
|
129
|
+
failures++;
|
|
130
|
+
const giveUp = failures >= 3;
|
|
131
|
+
idx.forEach((k) => { st[k] = giveUp ? 3 : 0; });
|
|
132
|
+
say(`字幕翻译失败:${err.message}`, { error: true, ms: 6000 });
|
|
133
|
+
if (!giveUp) setTimeout(pump, 2500 * failures);
|
|
134
|
+
})
|
|
135
|
+
.finally(() => {
|
|
136
|
+
if (myGen !== gen) return;
|
|
137
|
+
inflight--;
|
|
138
|
+
shown = "";
|
|
139
|
+
render();
|
|
140
|
+
progress();
|
|
141
|
+
if (failures < 3) pump();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
progress();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function reset(newVid) {
|
|
148
|
+
gen++;
|
|
149
|
+
vid = newVid;
|
|
150
|
+
sentences = []; zh = []; st = [];
|
|
151
|
+
inflight = 0; failures = 0; doneCount = 0; shown = "";
|
|
152
|
+
if (overlay) { zhLine.textContent = ""; enLine.textContent = ""; overlay.classList.remove("on"); }
|
|
153
|
+
applyStyle();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function onCaptions(msg) {
|
|
157
|
+
if (msg.vid && msg.vid !== vid) reset(msg.vid);
|
|
158
|
+
if (msg.title) title = msg.title;
|
|
159
|
+
const list = S.buildSentences(S.parseTimedText(msg.body));
|
|
160
|
+
if (!list.length) return;
|
|
161
|
+
// a second copy of the same track (YouTube re-requests on seek or quality change) changes nothing
|
|
162
|
+
if (sentences.length === list.length && sentences.every((s, i) => s.text === list[i].text)) return;
|
|
163
|
+
reset(vid);
|
|
164
|
+
sentences = list;
|
|
165
|
+
zh = new Array(list.length);
|
|
166
|
+
st = new Array(list.length).fill(0);
|
|
167
|
+
ensureOverlay();
|
|
168
|
+
applyStyle();
|
|
169
|
+
if (settings.subMode !== "off") say(`中文字幕 · 已获取 ${list.length} 句${msg.kind === "asr" ? "(自动生成字幕)" : ""}`, { ms: 0 });
|
|
170
|
+
pump();
|
|
171
|
+
kick();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------- wiring ----------
|
|
175
|
+
window.addEventListener("message", (e) => {
|
|
176
|
+
if (e.source !== window || !e.data || e.data.source !== "htz-main") return;
|
|
177
|
+
const m = e.data;
|
|
178
|
+
if (m.type === "hello") sendConfig();
|
|
179
|
+
else if (m.type === "video") { if (m.vid !== vid) reset(m.vid); title = m.title || title; }
|
|
180
|
+
else if (m.type === "captions" && typeof m.body === "string") onCaptions(m);
|
|
181
|
+
else if (m.type === "nocaptions" && m.vid === vid && settings.subMode !== "off") say("这个视频没有字幕,无法生成中文字幕", { ms: 6000 });
|
|
182
|
+
else if (m.type === "captionsfailed" && m.vid === vid && !sentences.length && settings.subMode !== "off") {
|
|
183
|
+
say("没拿到字幕:点一下播放器右下角的 CC 按钮即可重试", { ms: 8000 });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// media events don't bubble, but a capturing listener on the document still sees them
|
|
188
|
+
let lastPump = 0;
|
|
189
|
+
document.addEventListener("play", kick, true);
|
|
190
|
+
document.addEventListener("seeked", () => { shown = ""; render(); pump(); }, true);
|
|
191
|
+
document.addEventListener("pause", render, true);
|
|
192
|
+
document.addEventListener("timeupdate", () => {
|
|
193
|
+
render();
|
|
194
|
+
if (Date.now() - lastPump > 3000) { lastPump = Date.now(); pump(); }
|
|
195
|
+
}, true);
|
|
196
|
+
|
|
197
|
+
R.loadSettings().then((s) => { settings = s; sendConfig(); applyStyle(); });
|
|
198
|
+
R.onSettings((s) => {
|
|
199
|
+
const prev = settings;
|
|
200
|
+
settings = s;
|
|
201
|
+
sendConfig();
|
|
202
|
+
applyStyle();
|
|
203
|
+
const engineChanged = ["engine", "claudeModel", "openaiBaseUrl", "openaiModel"].some((k) => s[k] !== prev[k]);
|
|
204
|
+
if (engineChanged && sentences.length) {
|
|
205
|
+
const keep = sentences;
|
|
206
|
+
reset(vid);
|
|
207
|
+
sentences = keep;
|
|
208
|
+
zh = new Array(keep.length);
|
|
209
|
+
st = new Array(keep.length).fill(0);
|
|
210
|
+
}
|
|
211
|
+
shown = "";
|
|
212
|
+
render();
|
|
213
|
+
pump();
|
|
214
|
+
});
|
|
215
|
+
})();
|
package/content/site.css
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* Bilingual: the original and its translation sit together as one pair, then the site's usual spacing. */
|
|
2
|
+
html[data-htz-mode="bilingual"] .htz-src { margin-bottom: 2px !important; }
|
|
3
|
+
html[data-htz-mode="bilingual"] .htz-tr { margin-top: 0 !important; }
|
|
4
|
+
html[data-htz-mode="bilingual"] .htz-tr.vt,
|
|
5
|
+
html[data-htz-mode="bilingual"] h1.htz-tr { opacity: .92; }
|
|
6
|
+
|
|
7
|
+
/* Chinese only: hide the original, the translated copy already carries the original's classes */
|
|
8
|
+
html[data-htz-mode="zh"] .htz-src { display: none !important; }
|
|
9
|
+
|
|
10
|
+
.htz-pair { min-width: 0; }
|
|
11
|
+
.htz-inline { margin-left: .4em; opacity: .75; }
|
|
12
|
+
|
|
13
|
+
#htz-status {
|
|
14
|
+
position: fixed; right: 16px; bottom: 16px; z-index: 2147483000;
|
|
15
|
+
max-width: min(420px, calc(100vw - 32px)); padding: 6px 12px; border-radius: 999px;
|
|
16
|
+
background: var(--chip-on, #0f0f0f); color: var(--chip-on-text, #fff);
|
|
17
|
+
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
18
|
+
box-shadow: 0 2px 10px rgba(0,0,0,.2); pointer-events: none;
|
|
19
|
+
opacity: 0; transform: translateY(6px); transition: opacity .2s, transform .2s;
|
|
20
|
+
}
|
|
21
|
+
#htz-status.on { opacity: .92; transform: none; }
|
|
22
|
+
#htz-status.err { background: #c62828; color: #fff; border-radius: 12px; }
|
|
23
|
+
|
|
24
|
+
#htz-toast {
|
|
25
|
+
position: fixed; left: 50%; bottom: 24px; z-index: 2147483000; transform: translate(-50%, 8px);
|
|
26
|
+
max-width: calc(100vw - 32px); padding: 10px 16px; border-radius: 8px;
|
|
27
|
+
background: var(--chip-on, #0f0f0f); color: var(--chip-on-text, #fff);
|
|
28
|
+
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
29
|
+
opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s;
|
|
30
|
+
}
|
|
31
|
+
#htz-toast.on { opacity: 1; transform: translate(-50%, 0); }
|
package/content/site.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// HackerTube pages: localize the interface instantly, translate titles / summaries / comments as they scroll
|
|
2
|
+
// into view, and render the Chinese right under the original (or instead of it). HackerTube is a single-page
|
|
3
|
+
// app that re-renders with innerHTML, so everything is driven by a MutationObserver.
|
|
4
|
+
(() => {
|
|
5
|
+
const H = globalThis.HTZ;
|
|
6
|
+
const T = H.text, UI = H.uidict, R = H.runtime;
|
|
7
|
+
|
|
8
|
+
// Content written per video goes to the translation engine. "block": the translation is a copy of the element
|
|
9
|
+
// (same tag and classes, so it looks native) inserted right after it. "inline": a short label inside a link
|
|
10
|
+
// or chip, translated in place. "dict": try the local UI dictionary first (page headings like "Settings").
|
|
11
|
+
const RULES = [
|
|
12
|
+
{ sel: ".vt", kind: "block" },
|
|
13
|
+
{ sel: ".watch h1", kind: "block" },
|
|
14
|
+
{ sel: ".snip", kind: "block" },
|
|
15
|
+
{ sel: ".desc > p", kind: "block" },
|
|
16
|
+
{ sel: ".cmt .body > p", kind: "block" },
|
|
17
|
+
{ sel: ".plnote", kind: "block" },
|
|
18
|
+
{ sel: ".plinfo h2", kind: "block" },
|
|
19
|
+
{ sel: ".plinfo .intro", kind: "block" },
|
|
20
|
+
{ sel: ".plcard p.sub", kind: "block" },
|
|
21
|
+
{ sel: ".pagehead h2", kind: "block", dict: true },
|
|
22
|
+
{ sel: ".setting b", kind: "block" },
|
|
23
|
+
{ sel: ".setting p", kind: "block" },
|
|
24
|
+
{ sel: ".about .lede", kind: "block" },
|
|
25
|
+
{ sel: ".about > h2", kind: "block" },
|
|
26
|
+
{ sel: ".about > p", kind: "block" },
|
|
27
|
+
{ sel: ".about li", kind: "block" },
|
|
28
|
+
{ sel: ".areaname", kind: "inline" },
|
|
29
|
+
{ sel: ".cats a", kind: "inline" },
|
|
30
|
+
{ sel: ".catwrap .chip", kind: "inline" },
|
|
31
|
+
{ sel: ".tag.cat", kind: "inline" },
|
|
32
|
+
];
|
|
33
|
+
const CONTENT_SEL = RULES.map((r) => r.sel).join(",");
|
|
34
|
+
const OURS = ".htz-tr, .htz-inline, .htz-pair, #htz-status, #htz-toast";
|
|
35
|
+
// never touched: our own nodes, code, the copyable agent prompt, hashtags, people's and channels' names
|
|
36
|
+
const SKIP_SEL = `${OURS}, script, style, pre, code, textarea, #agent-prompt, .tag:not(.cat), .chan, .who b, .cmt .meta b, .count, .dur`;
|
|
37
|
+
|
|
38
|
+
let settings = { ...R.DEFAULTS };
|
|
39
|
+
let mode = "off";
|
|
40
|
+
const memo = new Map(); // source text -> translation, for this page's lifetime
|
|
41
|
+
const waiting = new Map(); // source text -> elements waiting for it
|
|
42
|
+
const inflight = new Set(); // source texts currently being translated
|
|
43
|
+
const replaced = new Map(); // Text node -> original text, so "off" can restore the page exactly
|
|
44
|
+
let handled = new WeakSet();
|
|
45
|
+
|
|
46
|
+
// ---------- status pill + toast ----------
|
|
47
|
+
const status = (() => {
|
|
48
|
+
let el, timer;
|
|
49
|
+
return (msg, { error = false, ms = 0 } = {}) => {
|
|
50
|
+
if (!el) { el = document.createElement("div"); el.id = "htz-status"; document.body.append(el); }
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
el.textContent = msg || "";
|
|
53
|
+
el.classList.toggle("on", !!msg);
|
|
54
|
+
el.classList.toggle("err", error);
|
|
55
|
+
if (msg && ms) timer = setTimeout(() => el.classList.remove("on"), ms);
|
|
56
|
+
};
|
|
57
|
+
})();
|
|
58
|
+
function toast(msg) {
|
|
59
|
+
let el = document.getElementById("htz-toast");
|
|
60
|
+
if (!el) { el = document.createElement("div"); el.id = "htz-toast"; document.body.append(el); }
|
|
61
|
+
el.textContent = msg;
|
|
62
|
+
el.classList.add("on");
|
|
63
|
+
clearTimeout(toast.t);
|
|
64
|
+
toast.t = setTimeout(() => el.classList.remove("on"), 3000);
|
|
65
|
+
}
|
|
66
|
+
function showProgress() {
|
|
67
|
+
if (inflight.size) status(`翻译中 · ${inflight.size} 条`);
|
|
68
|
+
else status("");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------- text replacement that can be undone ----------
|
|
72
|
+
function replaceText(node, zh) {
|
|
73
|
+
if (!replaced.has(node)) replaced.set(node, node.data);
|
|
74
|
+
const orig = replaced.get(node);
|
|
75
|
+
const lead = orig.match(/^\s*/)[0], trail = orig.match(/\s*$/)[0];
|
|
76
|
+
node.data = lead + zh + trail;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isChannelLine(el) {
|
|
80
|
+
// cards: <h3 class="vt">title</h3><div class="sub">channel</div><div class="sub">views • date</div>
|
|
81
|
+
if (!el.classList.contains("sub") || !el.closest(".vcard, .next")) return false;
|
|
82
|
+
const prev = el.previousElementSibling;
|
|
83
|
+
return !!prev && prev.classList.contains("vt");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Interface strings: every text node outside content elements that the dictionary knows.
|
|
87
|
+
function localizeUi(root) {
|
|
88
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
89
|
+
acceptNode(n) {
|
|
90
|
+
const p = n.parentElement;
|
|
91
|
+
if (!p || !n.data.trim()) return NodeFilter.FILTER_REJECT;
|
|
92
|
+
if (p.closest(SKIP_SEL)) return NodeFilter.FILTER_REJECT;
|
|
93
|
+
// the "▶ this video" / "link" pills inside comments are interface, though they sit in content
|
|
94
|
+
if (p.matches(".mk")) return NodeFilter.FILTER_ACCEPT;
|
|
95
|
+
if (p.closest(CONTENT_SEL) || isChannelLine(p)) return NodeFilter.FILTER_REJECT;
|
|
96
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const nodes = [];
|
|
100
|
+
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
101
|
+
for (const n of nodes) {
|
|
102
|
+
if (replaced.has(n)) continue;
|
|
103
|
+
const zh = UI.localize(n.data);
|
|
104
|
+
if (zh) replaceText(n, zh);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------- content translation ----------
|
|
109
|
+
const ruleFor = (el) => RULES.find((r) => el.matches(r.sel));
|
|
110
|
+
const firstText = (el) => [...el.childNodes].find((n) => n.nodeType === 3 && n.data.trim());
|
|
111
|
+
|
|
112
|
+
function register(el) {
|
|
113
|
+
if (handled.has(el)) return;
|
|
114
|
+
handled.add(el);
|
|
115
|
+
const rule = ruleFor(el);
|
|
116
|
+
if (!rule) return;
|
|
117
|
+
const node = rule.kind === "inline" ? firstText(el) : null;
|
|
118
|
+
const text = T.normalize(rule.kind === "inline" ? node && node.data : el.textContent);
|
|
119
|
+
if (!text) return;
|
|
120
|
+
if (rule.dict) {
|
|
121
|
+
const zh = UI.localize(text);
|
|
122
|
+
if (zh) { const n = firstText(el); if (n) replaceText(n, zh); return; }
|
|
123
|
+
}
|
|
124
|
+
if (!T.needsTranslation(text) || T.hasCJK(text)) return;
|
|
125
|
+
el.__htz = { rule, node, text };
|
|
126
|
+
if (memo.has(text)) render(el);
|
|
127
|
+
else io.observe(el);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Only what is on (or near) the screen gets translated: the home feed scrolls forever.
|
|
131
|
+
const io = new IntersectionObserver((entries) => {
|
|
132
|
+
for (const e of entries) {
|
|
133
|
+
if (!e.isIntersecting) continue;
|
|
134
|
+
io.unobserve(e.target);
|
|
135
|
+
enqueue(e.target);
|
|
136
|
+
}
|
|
137
|
+
}, { rootMargin: "800px 0px" });
|
|
138
|
+
|
|
139
|
+
function enqueue(el) {
|
|
140
|
+
const { text } = el.__htz;
|
|
141
|
+
if (memo.has(text)) return render(el);
|
|
142
|
+
if (!waiting.has(text)) waiting.set(text, []);
|
|
143
|
+
waiting.get(text).push(el);
|
|
144
|
+
clearTimeout(enqueue.t);
|
|
145
|
+
enqueue.t = setTimeout(flush, 60);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function flush() {
|
|
149
|
+
const texts = [...waiting.keys()].filter((t) => !inflight.has(t));
|
|
150
|
+
if (!texts.length) return;
|
|
151
|
+
for (const idx of T.chunk(texts, { maxItems: 40, maxChars: 5000 })) send(idx.map((i) => texts[i]));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function send(texts) {
|
|
155
|
+
texts.forEach((t) => inflight.add(t));
|
|
156
|
+
showProgress();
|
|
157
|
+
const myMode = mode;
|
|
158
|
+
try {
|
|
159
|
+
const res = await R.translate(texts, { kind: "page" });
|
|
160
|
+
if (mode !== myMode) return;
|
|
161
|
+
texts.forEach((t, i) => memo.set(t, res.translations[i]));
|
|
162
|
+
for (const t of texts) for (const el of waiting.get(t) || []) if (el.isConnected) render(el);
|
|
163
|
+
if (res.warning) status(res.warning, { error: true, ms: 6000 });
|
|
164
|
+
} catch (e) {
|
|
165
|
+
status(`翻译失败:${e.message}`, { error: true, ms: 6000 });
|
|
166
|
+
} finally {
|
|
167
|
+
texts.forEach((t) => { inflight.delete(t); waiting.delete(t); });
|
|
168
|
+
if (!document.querySelector("#htz-status.err.on")) showProgress();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function render(el) {
|
|
173
|
+
if (mode === "off" || !el.__htz) return;
|
|
174
|
+
const { rule, node, text } = el.__htz;
|
|
175
|
+
const zh = memo.get(text);
|
|
176
|
+
if (!zh || T.normalize(zh) === text) return;
|
|
177
|
+
if (rule.kind === "inline") {
|
|
178
|
+
if (!node || !node.isConnected) return;
|
|
179
|
+
if (mode === "zh") return replaceText(node, zh);
|
|
180
|
+
if (node.parentElement && node.parentElement.classList.contains("htz-pair")) return;
|
|
181
|
+
// these labels sit in flex rows (name ... count): wrapping original + translation in one span keeps them
|
|
182
|
+
// a single flex item that flows as text, instead of two squeezed columns
|
|
183
|
+
const pair = document.createElement("span");
|
|
184
|
+
pair.className = "htz-pair";
|
|
185
|
+
const s = document.createElement("span");
|
|
186
|
+
s.className = "htz-inline";
|
|
187
|
+
s.lang = "zh-CN";
|
|
188
|
+
s.textContent = zh;
|
|
189
|
+
node.before(pair);
|
|
190
|
+
pair.append(node, s);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (el.nextElementSibling && el.nextElementSibling.classList.contains("htz-tr")) return;
|
|
194
|
+
// a copy with the same tag and classes picks up the site's own styling (title size, muted colour, clamp)
|
|
195
|
+
const tr = document.createElement(el.tagName === "LI" ? "div" : el.tagName);
|
|
196
|
+
tr.className = `${el.className} htz-tr`.trim();
|
|
197
|
+
tr.lang = "zh-CN";
|
|
198
|
+
tr.textContent = zh;
|
|
199
|
+
el.classList.add("htz-src");
|
|
200
|
+
el.after(tr);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- scanning ----------
|
|
204
|
+
function process(root) {
|
|
205
|
+
if (mode === "off" || !(root instanceof Element) || root.closest(OURS)) return;
|
|
206
|
+
const host = root.parentElement && root.parentElement.closest(CONTENT_SEL);
|
|
207
|
+
if (host) return register(host);
|
|
208
|
+
if (root.matches(CONTENT_SEL)) register(root);
|
|
209
|
+
root.querySelectorAll(CONTENT_SEL).forEach(register);
|
|
210
|
+
localizeUi(root);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const mo = new MutationObserver((muts) => {
|
|
214
|
+
if (mode === "off") return;
|
|
215
|
+
for (const m of muts) {
|
|
216
|
+
for (const n of m.addedNodes) {
|
|
217
|
+
if (n.nodeType === 1) process(n);
|
|
218
|
+
else if (n.nodeType === 3 && n.parentElement) process(n.parentElement);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (replaced.size > 3000) for (const n of replaced.keys()) if (!n.isConnected) replaced.delete(n);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function revertAll() {
|
|
225
|
+
for (const [n, orig] of replaced) if (n.isConnected) n.data = orig;
|
|
226
|
+
replaced.clear();
|
|
227
|
+
document.querySelectorAll(".htz-pair").forEach((p) => p.replaceWith(...[...p.childNodes].filter((n) => n.nodeType === 3)));
|
|
228
|
+
document.querySelectorAll(".htz-tr, .htz-inline").forEach((n) => n.remove());
|
|
229
|
+
document.querySelectorAll(".htz-src").forEach((n) => n.classList.remove("htz-src"));
|
|
230
|
+
io.disconnect();
|
|
231
|
+
waiting.clear();
|
|
232
|
+
handled = new WeakSet();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function applyMode(next) {
|
|
236
|
+
revertAll();
|
|
237
|
+
mode = next;
|
|
238
|
+
document.documentElement.dataset.htzMode = mode;
|
|
239
|
+
const q = document.getElementById("q");
|
|
240
|
+
if (q) q.placeholder = mode === "off" ? "Search" : settings.searchTranslate ? "搜索(可直接输入中文)" : "搜索";
|
|
241
|
+
if (mode !== "off") process(document.body);
|
|
242
|
+
else status("");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------- Chinese search: translate the query to English, then let the site search as usual ----------
|
|
246
|
+
// The site's semantic search embeds English text; a Chinese query finds little. We intercept the submit in the
|
|
247
|
+
// capture phase, swap the input's value for the English, and resubmit so the site's own handler runs.
|
|
248
|
+
let bypass = false;
|
|
249
|
+
document.addEventListener("submit", async (e) => {
|
|
250
|
+
const form = e.target;
|
|
251
|
+
if (bypass || mode === "off" || !settings.searchTranslate || !(form instanceof HTMLFormElement) || form.id !== "search") return;
|
|
252
|
+
const q = form.querySelector("#q");
|
|
253
|
+
const query = q && q.value.trim();
|
|
254
|
+
if (!query || !T.hasCJK(query)) return;
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
e.stopImmediatePropagation();
|
|
257
|
+
status("正在把搜索词翻译成英文…");
|
|
258
|
+
try {
|
|
259
|
+
const res = await R.translate([query], { kind: "query", to: "en" });
|
|
260
|
+
const en = T.normalize(res.translations[0]);
|
|
261
|
+
if (en) { q.value = en; toast(`已将「${query}」译为 “${en}” 进行搜索`); }
|
|
262
|
+
status("");
|
|
263
|
+
} catch (err) {
|
|
264
|
+
status(`搜索词翻译失败,按原文搜索:${err.message}`, { error: true, ms: 5000 });
|
|
265
|
+
}
|
|
266
|
+
bypass = true;
|
|
267
|
+
try { form.requestSubmit(); } finally { bypass = false; }
|
|
268
|
+
}, true);
|
|
269
|
+
|
|
270
|
+
// ---------- start ----------
|
|
271
|
+
R.loadSettings().then((s) => {
|
|
272
|
+
settings = s;
|
|
273
|
+
applyMode(s.pageMode);
|
|
274
|
+
mo.observe(document.body, { childList: true, subtree: true });
|
|
275
|
+
});
|
|
276
|
+
R.onSettings((s) => {
|
|
277
|
+
const prev = settings;
|
|
278
|
+
settings = s;
|
|
279
|
+
const engineChanged = ["engine", "claudeModel", "openaiBaseUrl", "openaiModel"].some((k) => s[k] !== prev[k]);
|
|
280
|
+
if (engineChanged) memo.clear();
|
|
281
|
+
if (engineChanged || s.pageMode !== prev.pageMode || s.searchTranslate !== prev.searchTranslate) applyMode(s.pageMode);
|
|
282
|
+
});
|
|
283
|
+
})();
|
|
Binary file
|
package/icons/icon16.png
ADDED
|
Binary file
|
package/icons/icon32.png
ADDED
|
Binary file
|
package/icons/icon48.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// "Play with my YouTube login": HackerTube embeds videos from youtube-nocookie.com, which never sees the user's
|
|
2
|
+
// youtube.com login cookies, so a network YouTube distrusts gets "Sign in to confirm you're not a bot" even after
|
|
3
|
+
// signing in. This declarativeNetRequest rule sends HackerTube's embed frames to www.youtube.com/embed instead
|
|
4
|
+
// (HackerTube's CSP allows that origin), where the login applies. The rest of the URL, including ?autoplay=1, is kept.
|
|
5
|
+
(function (root, factory) {
|
|
6
|
+
const api = factory();
|
|
7
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
8
|
+
else (root.HTZ = root.HTZ || {}).embedlogin = api;
|
|
9
|
+
})(globalThis, function () {
|
|
10
|
+
const RULE_ID = 1;
|
|
11
|
+
|
|
12
|
+
function rule() {
|
|
13
|
+
return {
|
|
14
|
+
id: RULE_ID,
|
|
15
|
+
priority: 1,
|
|
16
|
+
action: { type: "redirect", redirect: { regexSubstitution: "https://www.youtube.com/embed/\\1" } },
|
|
17
|
+
condition: {
|
|
18
|
+
regexFilter: "^https://www\\.youtube-nocookie\\.com/embed/(.*)$",
|
|
19
|
+
resourceTypes: ["sub_frame"],
|
|
20
|
+
// only frames that HackerTube itself loads (also matches www.hackertube.org)
|
|
21
|
+
initiatorDomains: ["hackertube.org"],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Add or remove the rule to match the setting. Dynamic rules persist across browser restarts and updates.
|
|
27
|
+
async function sync(enabled, dnr = chrome.declarativeNetRequest) {
|
|
28
|
+
await dnr.updateDynamicRules({ removeRuleIds: [RULE_ID], addRules: enabled ? [rule()] : [] });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { RULE_ID, rule, sync };
|
|
32
|
+
});
|