hackertube-zh 0.1.0 → 0.1.2
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/LICENSE +21 -0
- package/README.md +29 -4
- package/background.js +12 -29
- package/content/player-main.js +18 -3
- package/content/player.css +12 -2
- package/content/player.js +14 -8
- package/content/site.js +8 -0
- package/icons/icon-small.svg +35 -0
- package/icons/icon.svg +38 -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/cache.js +98 -0
- package/lib/engines.js +16 -7
- package/lib/runtime.js +1 -0
- package/manifest.json +2 -1
- package/options/options.html +6 -0
- package/package.json +12 -2
- package/popup/popup.css +2 -0
- package/popup/popup.html +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 crisxuan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.png">
|
|
4
|
+
<img src="docs/logo-light.png" width="470" alt="HackerTube 中文插件">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://www.npmjs.com/package/hackertube-zh"><img src="https://img.shields.io/npm/v/hackertube-zh?color=ff6600" alt="npm"></a>
|
|
10
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License"></a>
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center"><code>npx hackertube-zh</code></p>
|
|
2
14
|
|
|
3
15
|
给 [HackerTube](https://hackertube.org)(Karpathy 做的“HN 推荐过的 YouTube 视频”站)用的 Chrome 扩展,把整个观看体验实时变成中文:
|
|
4
16
|
|
|
@@ -8,7 +20,8 @@
|
|
|
8
20
|
- **中文搜索**:在搜索框输入中文,会先翻译成英文再交给 HackerTube 的语义搜索(它只理解英文)。
|
|
9
21
|
- **用 YouTube 登录状态播放**(可选):解决嵌入播放器一直提示 “Sign in to confirm you're not a bot” 的问题,见下方“常见问题”。
|
|
10
22
|
- **三种翻译引擎**:Google 翻译(免费,默认)、Claude、任意 OpenAI 兼容接口(DeepSeek、通义千问、Ollama 等)。LLM 出错时自动临时回退到 Google。
|
|
11
|
-
-
|
|
23
|
+
- 所有译文缓存在本地,同一个视频、同一页再次打开不再请求翻译。缓存有上限,很久没用到的译文会自动清掉。
|
|
24
|
+
- 翻译请求失败(断网、被限流)时会自动重试,网络恢复后页面和字幕会自己补上中文,不用刷新。
|
|
12
25
|
|
|
13
26
|
## 安装
|
|
14
27
|
|
|
@@ -109,7 +122,7 @@ hackertube.org 页面 YouTube 嵌入播放器(iframe,
|
|
|
109
122
|
```bash
|
|
110
123
|
npm test # 单元测试:字幕解析与分句、引擎请求/响应、界面词典
|
|
111
124
|
npm run check # 静态检查:manifest 引用的文件都存在、所有 JS 语法正确
|
|
112
|
-
npm run icons #
|
|
125
|
+
npm run icons # 从 icons/*.svg 渲染插件图标 PNG 和 README 横版 logo(用本机 Chrome 的无头模式)
|
|
113
126
|
npm run zip # 打包到 dist/hackertube-zh.zip(上架 Chrome 应用商店用)
|
|
114
127
|
```
|
|
115
128
|
|
|
@@ -119,7 +132,7 @@ npm run zip # 打包到 dist/hackertube-zh.zip(上架 Chrome 应用商
|
|
|
119
132
|
npm publish # 之后任何人都能 npx hackertube-zh
|
|
120
133
|
```
|
|
121
134
|
|
|
122
|
-
不想发布到 npm 的话,推到 GitHub 后也可以用 `npx github
|
|
135
|
+
不想发布到 npm 的话,推到 GitHub 后也可以用 `npx github:crisxuan/hackertube-zh`。发布前在本地试:`npm pack` 后运行 `npx --package=./hackertube-zh-<版本号>.tgz hackertube-zh --dir /tmp/htz-test --no-open`。
|
|
123
136
|
|
|
124
137
|
不安装扩展也能调试:`node dev/server.js 8787 <放测试视频的目录>` 启动开发服务器(HackerTube 的 CSP 禁止注入脚本,所以这里用本地镜像):
|
|
125
138
|
|
|
@@ -132,6 +145,7 @@ manifest.json 扩展清单(MV3)
|
|
|
132
145
|
background.js Service Worker:翻译调度、并发限制、缓存、引擎回退
|
|
133
146
|
lib/ 公共模块(浏览器和 node 测试共用)
|
|
134
147
|
engines.js Google / Claude / OpenAI 兼容引擎
|
|
148
|
+
cache.js 译文缓存(内存 + storage.local,分两代淘汰旧条目)
|
|
135
149
|
embedlogin.js “用 YouTube 登录状态播放”的重定向规则
|
|
136
150
|
subtitles.js json3 / srv3 / WebVTT 解析与分句
|
|
137
151
|
uidict.js HackerTube 界面词典
|
|
@@ -142,4 +156,15 @@ popup/ options/ ui/ 弹窗、选项页、公共样式
|
|
|
142
156
|
bin/install.js npx 安装器
|
|
143
157
|
dev/ 本地调试服务器和模拟页面
|
|
144
158
|
tests/ node:test 单元测试
|
|
159
|
+
scripts/ 静态检查、图标渲染
|
|
160
|
+
icons/ 插件图标:icon.svg(48/128px)、icon-small.svg(16/32px 简化版)及渲染出的 PNG
|
|
161
|
+
docs/ README 横版 logo(浅色 / 深色两版,由 npm run icons 生成)
|
|
145
162
|
```
|
|
163
|
+
|
|
164
|
+
## 参与贡献
|
|
165
|
+
|
|
166
|
+
欢迎提 Issue 和 Pull Request。提交前请跑一下 `npm test` 和 `npm run check`;改动行为时请补上对应的测试。HackerTube 或 YouTube 改版导致失效时,欢迎附上出问题的页面地址。
|
|
167
|
+
|
|
168
|
+
## 许可证
|
|
169
|
+
|
|
170
|
+
[MIT](LICENSE)。本项目与 HackerTube、Hacker News、YouTube 均无关联。
|
package/background.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Service worker: the only place that talks to translation engines. Content scripts send batches of text;
|
|
2
|
-
// results are cached (
|
|
2
|
+
// results are cached (lib/cache.js) per engine, so revisiting a page or video is free and instant.
|
|
3
3
|
import "./lib/textutil.js";
|
|
4
4
|
import "./lib/engines.js";
|
|
5
5
|
import "./lib/runtime.js";
|
|
6
6
|
import "./lib/embedlogin.js";
|
|
7
|
+
import "./lib/cache.js";
|
|
7
8
|
|
|
8
|
-
const { text: T, engines: E, runtime: R, embedlogin: EL } = globalThis.HTZ;
|
|
9
|
+
const { text: T, engines: E, runtime: R, embedlogin: EL, cache: C } = globalThis.HTZ;
|
|
9
10
|
|
|
10
11
|
// keep the embed redirect rule in step with the setting: on every service worker start, and on every change
|
|
11
12
|
const syncEmbedLogin = (s) => EL.sync(!!s.embedLogin).catch((e) => console.warn("[HackerTube 中文] embed rule:", e));
|
|
@@ -15,7 +16,8 @@ R.onSettings(syncEmbedLogin);
|
|
|
15
16
|
const ENGINE_NAMES = { google: "Google 翻译", claude: "Claude", openai: "OpenAI 兼容接口" };
|
|
16
17
|
const LIMITS = {
|
|
17
18
|
google: { maxItems: 50, maxChars: 4500, concurrency: 4 },
|
|
18
|
-
|
|
19
|
+
// small batches: a model has to finish the whole reply inside the engines' 25 s limit (see lib/engines.js)
|
|
20
|
+
llm: { maxItems: 12, maxChars: 1500, concurrency: 3 },
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
// ---------- small helpers ----------
|
|
@@ -33,13 +35,7 @@ function limiter(n) {
|
|
|
33
35
|
const limits = { google: limiter(LIMITS.google.concurrency), llm: limiter(LIMITS.llm.concurrency) };
|
|
34
36
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
35
37
|
|
|
36
|
-
const
|
|
37
|
-
const mem = new Map();
|
|
38
|
-
function memSet(k, v) {
|
|
39
|
-
mem.delete(k);
|
|
40
|
-
mem.set(k, v);
|
|
41
|
-
if (mem.size > MEM_MAX) mem.delete(mem.keys().next().value);
|
|
42
|
-
}
|
|
38
|
+
const cache = C.create(chrome.storage.local);
|
|
43
39
|
|
|
44
40
|
// The cache key includes everything that changes the output: engine, model, target language and, for LLMs,
|
|
45
41
|
// the kind of text (subtitle prompts differ from page prompts).
|
|
@@ -96,16 +92,8 @@ async function translate({ texts, kind = "page", to = "zh-CN", context }) {
|
|
|
96
92
|
if (texts.some((t) => typeof t !== "string")) throw new Error("texts must be strings");
|
|
97
93
|
const [settings, secrets] = await Promise.all([R.loadSettings(), R.loadSecrets()]);
|
|
98
94
|
const eid = engineId(settings, kind, to);
|
|
99
|
-
const
|
|
100
|
-
const out =
|
|
101
|
-
|
|
102
|
-
const missingKeys = keys.filter((k, i) => out[i] == null);
|
|
103
|
-
if (missingKeys.length) {
|
|
104
|
-
const stored = await chrome.storage.local.get(missingKeys);
|
|
105
|
-
keys.forEach((k, i) => {
|
|
106
|
-
if (out[i] == null && stored[k] != null) { out[i] = stored[k]; memSet(k, stored[k]); }
|
|
107
|
-
});
|
|
108
|
-
}
|
|
95
|
+
const ids = texts.map((t) => T.hash(eid + "\u0001" + t));
|
|
96
|
+
const out = await cache.get(ids);
|
|
109
97
|
|
|
110
98
|
const todo = [];
|
|
111
99
|
out.forEach((v, i) => { if (v == null) todo.push(i); });
|
|
@@ -119,18 +107,13 @@ async function translate({ texts, kind = "page", to = "zh-CN", context }) {
|
|
|
119
107
|
const toStore = {};
|
|
120
108
|
for (const i of todo) {
|
|
121
109
|
out[i] = byText.get(texts[i]);
|
|
122
|
-
if (res.cache)
|
|
110
|
+
if (res.cache) toStore[ids[i]] = out[i];
|
|
123
111
|
}
|
|
124
|
-
|
|
112
|
+
cache.put(toStore);
|
|
125
113
|
}
|
|
126
114
|
return { translations: out, warning };
|
|
127
115
|
}
|
|
128
116
|
|
|
129
|
-
async function cacheKeys() {
|
|
130
|
-
const all = await chrome.storage.local.get(null);
|
|
131
|
-
return Object.keys(all).filter((k) => k.startsWith("c:"));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
117
|
async function testEngine() {
|
|
135
118
|
const [settings, secrets] = await Promise.all([R.loadSettings(), R.loadSecrets()]);
|
|
136
119
|
const sample = ["Overwatch Gameplay Architecture and Netcode", "The quick brown fox jumps over the lazy dog."];
|
|
@@ -150,8 +133,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
150
133
|
switch (msg && msg.type) {
|
|
151
134
|
case "translate": return reply(translate(msg));
|
|
152
135
|
case "testEngine": return reply(testEngine());
|
|
153
|
-
case "cacheStats": return reply(
|
|
154
|
-
case "clearCache": return reply(
|
|
136
|
+
case "cacheStats": return reply(cache.count().then((entries) => ({ entries })));
|
|
137
|
+
case "clearCache": return reply(cache.clear().then((removed) => ({ removed })));
|
|
155
138
|
default: return false;
|
|
156
139
|
}
|
|
157
140
|
});
|
package/content/player-main.js
CHANGED
|
@@ -80,11 +80,14 @@
|
|
|
80
80
|
try { list = p.getOption("captions", "tracklist", { includeAsr: true }) || []; } catch {}
|
|
81
81
|
if (!list.length) await sleep(300);
|
|
82
82
|
}
|
|
83
|
+
// subtitles were switched off while we waited: leave the player alone, and try again if they come back on
|
|
84
|
+
if (!wanted) { triedFor = null; return; }
|
|
83
85
|
const tracks = responseTracks(p);
|
|
84
86
|
if (!list.length && !tracks.length) return post("nocaptions", { vid });
|
|
85
|
-
if (list.length) { try { p.setOption("captions", "track", choose(list)); } catch {} }
|
|
87
|
+
if (list.length) { try { p.setOption("captions", "track", choose(list)); turnedOnFor = vid; } catch {} }
|
|
86
88
|
await sleep(3500);
|
|
87
89
|
if (gotFor === vid) return;
|
|
90
|
+
if (!wanted) { triedFor = null; return; }
|
|
88
91
|
// second route: the track's own URL (works for videos whose captions aren't token-gated)
|
|
89
92
|
const t = tracks.length && choose(tracks);
|
|
90
93
|
if (t && t.baseUrl) {
|
|
@@ -98,7 +101,16 @@
|
|
|
98
101
|
if (gotFor !== vid) post("captionsfailed", { vid });
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
|
|
104
|
+
// Our subtitles going off also turns off the YouTube captions we switched on to get the caption file, or they
|
|
105
|
+
// would show in their place. Captions the viewer turned on themselves are theirs and stay.
|
|
106
|
+
function restoreCaptions() {
|
|
107
|
+
const p = player();
|
|
108
|
+
if (!p || turnedOnFor === null || turnedOnFor !== lastVid) return;
|
|
109
|
+
turnedOnFor = null;
|
|
110
|
+
try { p.setOption("captions", "track", {}); } catch {}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let wanted = false, triedFor = null, turnedOnFor = null;
|
|
102
114
|
setInterval(() => {
|
|
103
115
|
const p = player();
|
|
104
116
|
if (!p || !p.getVideoData) return;
|
|
@@ -115,7 +127,10 @@
|
|
|
115
127
|
|
|
116
128
|
window.addEventListener("message", (e) => {
|
|
117
129
|
if (e.source !== window || !e.data || e.data.source !== "htz-iso") return;
|
|
118
|
-
if (e.data.type
|
|
130
|
+
if (e.data.type !== "config") return;
|
|
131
|
+
const was = wanted;
|
|
132
|
+
wanted = !!e.data.enabled;
|
|
133
|
+
if (was && !wanted) restoreCaptions();
|
|
119
134
|
});
|
|
120
135
|
post("hello");
|
|
121
136
|
})();
|
package/content/player.css
CHANGED
|
@@ -11,10 +11,20 @@
|
|
|
11
11
|
/* lift above the control bar while it is visible */
|
|
12
12
|
#movie_player:not(.ytp-autohide) .htz-sub { bottom: calc(var(--htz-bottom, 8) * 1% + 52px); }
|
|
13
13
|
|
|
14
|
+
/* Default: outlined text with no box, so the picture isn't covered. The layered shadows make a soft dark outline
|
|
15
|
+
that keeps white text readable on bright frames and slides. */
|
|
14
16
|
.htz-line {
|
|
15
|
-
display: inline-block; max-width: 100%; padding: .
|
|
16
|
-
background: rgba(8, 8, 8, .72); color: #fff; line-height: 1.38; white-space: pre-wrap;
|
|
17
|
+
display: inline-block; max-width: 100%; padding: 0 .2em; color: #fff; line-height: 1.38; white-space: pre-wrap;
|
|
17
18
|
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", Roboto, Arial, sans-serif;
|
|
19
|
+
text-shadow:
|
|
20
|
+
0 0 1px rgba(0, 0, 0, .95), 0 0 2px rgba(0, 0, 0, .9),
|
|
21
|
+
1px 1px 1px rgba(0, 0, 0, .85), -1px -1px 1px rgba(0, 0, 0, .85),
|
|
22
|
+
1px -1px 1px rgba(0, 0, 0, .85), -1px 1px 1px rgba(0, 0, 0, .85),
|
|
23
|
+
0 2px 6px rgba(0, 0, 0, .55);
|
|
24
|
+
}
|
|
25
|
+
/* Optional: a compact translucent box, for videos with lots of white slides */
|
|
26
|
+
.htz-style-box .htz-line {
|
|
27
|
+
padding: .06em .4em; border-radius: 6px; background: rgba(0, 0, 0, .55); text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
|
|
18
28
|
}
|
|
19
29
|
.htz-line:empty { display: none; }
|
|
20
30
|
.htz-zh { font-size: max(15px, calc(var(--htz-h, 360px) * .05 * var(--htz-scale, 1))); font-weight: 500; }
|
package/content/player.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
let settings = { ...R.DEFAULTS };
|
|
18
18
|
let vid = null, title = "";
|
|
19
|
-
let sentences = [], zh = [], st = []; // st: 0 = not sent, 1 = in flight, 2 = done
|
|
19
|
+
let sentences = [], zh = [], st = []; // st: 0 = not sent, 1 = in flight, 2 = done
|
|
20
20
|
let gen = 0, inflight = 0, failures = 0, doneCount = 0;
|
|
21
|
+
let retryAt = 0; // after a failed request nothing is sent before this time
|
|
21
22
|
|
|
22
23
|
const post = (type, data = {}) => window.postMessage({ source: "htz-iso", type, ...data }, "*");
|
|
23
24
|
const sendConfig = () => post("config", { enabled: settings.subMode !== "off" });
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
if (!p) return;
|
|
53
54
|
p.style.setProperty("--htz-scale", String(settings.subScale || 1));
|
|
54
55
|
p.style.setProperty("--htz-bottom", String(settings.subBottom ?? 8));
|
|
56
|
+
p.classList.toggle("htz-style-box", settings.subStyle === "box");
|
|
55
57
|
// our lines include the English, so YouTube's own captions would only duplicate them
|
|
56
58
|
p.classList.toggle("htz-hide-native", settings.subMode !== "off" && sentences.length > 0);
|
|
57
59
|
}
|
|
@@ -101,7 +103,7 @@
|
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
function pump() {
|
|
104
|
-
if (settings.subMode === "off" || !sentences.length) return;
|
|
106
|
+
if (settings.subMode === "off" || !sentences.length || Date.now() < retryAt) return;
|
|
105
107
|
const v = video();
|
|
106
108
|
const now = v ? v.currentTime * 1000 : 0;
|
|
107
109
|
while (inflight < MAX_INFLIGHT) {
|
|
@@ -127,10 +129,12 @@
|
|
|
127
129
|
.catch((err) => {
|
|
128
130
|
if (myGen !== gen) return;
|
|
129
131
|
failures++;
|
|
130
|
-
|
|
131
|
-
idx.forEach((k) => { st[k] = giveUp ? 3 : 0; });
|
|
132
|
+
idx.forEach((k) => { st[k] = 0; });
|
|
132
133
|
say(`字幕翻译失败:${err.message}`, { error: true, ms: 6000 });
|
|
133
|
-
|
|
134
|
+
// never give up for good, the network may come back mid-video: wait longer each time, up to 30 s
|
|
135
|
+
const wait = Math.min(2500 * failures, 30000);
|
|
136
|
+
retryAt = Date.now() + wait;
|
|
137
|
+
setTimeout(pump, wait + 50);
|
|
134
138
|
})
|
|
135
139
|
.finally(() => {
|
|
136
140
|
if (myGen !== gen) return;
|
|
@@ -138,7 +142,7 @@
|
|
|
138
142
|
shown = "";
|
|
139
143
|
render();
|
|
140
144
|
progress();
|
|
141
|
-
|
|
145
|
+
pump();
|
|
142
146
|
});
|
|
143
147
|
}
|
|
144
148
|
progress();
|
|
@@ -148,7 +152,7 @@
|
|
|
148
152
|
gen++;
|
|
149
153
|
vid = newVid;
|
|
150
154
|
sentences = []; zh = []; st = [];
|
|
151
|
-
inflight = 0; failures = 0; doneCount = 0; shown = "";
|
|
155
|
+
inflight = 0; failures = 0; doneCount = 0; shown = ""; retryAt = 0;
|
|
152
156
|
if (overlay) { zhLine.textContent = ""; enLine.textContent = ""; overlay.classList.remove("on"); }
|
|
153
157
|
applyStyle();
|
|
154
158
|
}
|
|
@@ -187,7 +191,8 @@
|
|
|
187
191
|
// media events don't bubble, but a capturing listener on the document still sees them
|
|
188
192
|
let lastPump = 0;
|
|
189
193
|
document.addEventListener("play", kick, true);
|
|
190
|
-
|
|
194
|
+
// a seek or a changed setting is the viewer asking again: retry now instead of waiting out the pause
|
|
195
|
+
document.addEventListener("seeked", () => { shown = ""; retryAt = 0; render(); pump(); }, true);
|
|
191
196
|
document.addEventListener("pause", render, true);
|
|
192
197
|
document.addEventListener("timeupdate", () => {
|
|
193
198
|
render();
|
|
@@ -209,6 +214,7 @@
|
|
|
209
214
|
st = new Array(keep.length).fill(0);
|
|
210
215
|
}
|
|
211
216
|
shown = "";
|
|
217
|
+
retryAt = 0;
|
|
212
218
|
render();
|
|
213
219
|
pump();
|
|
214
220
|
});
|
package/content/site.js
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
const memo = new Map(); // source text -> translation, for this page's lifetime
|
|
41
41
|
const waiting = new Map(); // source text -> elements waiting for it
|
|
42
42
|
const inflight = new Set(); // source texts currently being translated
|
|
43
|
+
let failures = 0; // requests failed in a row: sets the pause before the next retry
|
|
43
44
|
const replaced = new Map(); // Text node -> original text, so "off" can restore the page exactly
|
|
44
45
|
let handled = new WeakSet();
|
|
45
46
|
|
|
@@ -158,11 +159,18 @@
|
|
|
158
159
|
try {
|
|
159
160
|
const res = await R.translate(texts, { kind: "page" });
|
|
160
161
|
if (mode !== myMode) return;
|
|
162
|
+
failures = 0;
|
|
161
163
|
texts.forEach((t, i) => memo.set(t, res.translations[i]));
|
|
162
164
|
for (const t of texts) for (const el of waiting.get(t) || []) if (el.isConnected) render(el);
|
|
163
165
|
if (res.warning) status(res.warning, { error: true, ms: 6000 });
|
|
164
166
|
} catch (e) {
|
|
165
167
|
status(`翻译失败:${e.message}`, { error: true, ms: 6000 });
|
|
168
|
+
// hand the elements back to the viewport observer after a pause (longer each time, up to 30 s): whatever
|
|
169
|
+
// is still near the screen then is asked for again, so the page recovers once the network or engine does
|
|
170
|
+
const els = texts.flatMap((t) => waiting.get(t) || []);
|
|
171
|
+
setTimeout(() => {
|
|
172
|
+
if (mode === myMode) for (const el of els) if (el.isConnected) io.observe(el);
|
|
173
|
+
}, Math.min(3000 * ++failures, 30000));
|
|
166
174
|
} finally {
|
|
167
175
|
texts.forEach((t) => { inflight.delete(t); waiting.delete(t); });
|
|
168
176
|
if (!document.querySelector("#htz-status.err.on")) showProgress();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
|
2
|
+
<!-- 16 / 32 px toolbar art: the same play button and puzzle piece as icon.svg, filling more of the square, with a
|
|
3
|
+
wider gap. The piece gets a second knob on the right: with only the top knob it reads as a padlock when tiny. -->
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="play" x1="0" y1="0" x2="1" y2="1">
|
|
6
|
+
<stop offset="0" stop-color="#FF6A33"/>
|
|
7
|
+
<stop offset="1" stop-color="#FC4C1B"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
<linearGradient id="piece" x1="0" y1="0" x2="0" y2="1">
|
|
10
|
+
<stop offset="0" stop-color="#FF961F"/>
|
|
11
|
+
<stop offset="1" stop-color="#FF7E14"/>
|
|
12
|
+
</linearGradient>
|
|
13
|
+
<mask id="gap">
|
|
14
|
+
<rect width="128" height="128" fill="#fff"/>
|
|
15
|
+
<g fill="#000" stroke="#000" stroke-width="14" stroke-linejoin="round">
|
|
16
|
+
<rect x="66" y="74" width="46" height="46" rx="7"/>
|
|
17
|
+
<circle cx="89" cy="63" r="11"/>
|
|
18
|
+
<circle cx="116" cy="97" r="9.5"/>
|
|
19
|
+
</g>
|
|
20
|
+
</mask>
|
|
21
|
+
</defs>
|
|
22
|
+
|
|
23
|
+
<g mask="url(#gap)">
|
|
24
|
+
<rect x="2" y="10" width="116" height="94" rx="28" fill="url(#play)"/>
|
|
25
|
+
<path d="M34 36 L34 74 L64 55 Z" fill="#fff" stroke="#fff" stroke-width="11" stroke-linejoin="round"/>
|
|
26
|
+
</g>
|
|
27
|
+
|
|
28
|
+
<g fill="url(#piece)">
|
|
29
|
+
<rect x="66" y="74" width="46" height="46" rx="7"/>
|
|
30
|
+
<circle cx="89" cy="63" r="11"/>
|
|
31
|
+
<rect x="83" y="63" width="12" height="12"/>
|
|
32
|
+
<circle cx="116" cy="97" r="9.5"/>
|
|
33
|
+
<rect x="110" y="91" width="7" height="12"/>
|
|
34
|
+
</g>
|
|
35
|
+
</svg>
|
package/icons/icon.svg
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
|
2
|
+
<!-- HackerTube 中文插件: a video play button with a browser-extension puzzle piece, kept apart by a transparent
|
|
3
|
+
gap. Used for the 48 and 128 px icons and the README wordmark; icon-small.svg is the 16 / 32 px art. -->
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="play" x1="0" y1="0" x2="1" y2="1">
|
|
6
|
+
<stop offset="0" stop-color="#FF6A33"/>
|
|
7
|
+
<stop offset="1" stop-color="#FC4C1B"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
<linearGradient id="piece" x1="0" y1="0" x2="0" y2="1">
|
|
10
|
+
<stop offset="0" stop-color="#FF961F"/>
|
|
11
|
+
<stop offset="1" stop-color="#FF7E14"/>
|
|
12
|
+
</linearGradient>
|
|
13
|
+
<!-- the puzzle piece's footprint, grown by the gap, is cut out of the play button -->
|
|
14
|
+
<mask id="gap">
|
|
15
|
+
<rect width="128" height="128" fill="#fff"/>
|
|
16
|
+
<g fill="#000" stroke="#000" stroke-width="9" stroke-linejoin="round">
|
|
17
|
+
<rect x="75.5" y="71" width="44.5" height="40" rx="5"/>
|
|
18
|
+
<circle cx="99" cy="63.5" r="9.6"/>
|
|
19
|
+
</g>
|
|
20
|
+
</mask>
|
|
21
|
+
<!-- the socket on the piece's left side -->
|
|
22
|
+
<mask id="socket">
|
|
23
|
+
<rect width="128" height="128" fill="#fff"/>
|
|
24
|
+
<circle cx="76.5" cy="91" r="6.4" fill="#000"/>
|
|
25
|
+
</mask>
|
|
26
|
+
</defs>
|
|
27
|
+
|
|
28
|
+
<g mask="url(#gap)">
|
|
29
|
+
<rect x="8" y="17" width="106" height="83" rx="26" fill="url(#play)"/>
|
|
30
|
+
<path d="M49.5 43 L49.5 72 L76.5 57.5 Z" fill="#fff" stroke="#fff" stroke-width="9" stroke-linejoin="round"/>
|
|
31
|
+
</g>
|
|
32
|
+
|
|
33
|
+
<g fill="url(#piece)" mask="url(#socket)">
|
|
34
|
+
<rect x="75.5" y="71" width="44.5" height="40" rx="5"/>
|
|
35
|
+
<circle cx="99" cy="63.5" r="9.6"/>
|
|
36
|
+
<rect x="94" y="64" width="10" height="9"/>
|
|
37
|
+
</g>
|
|
38
|
+
</svg>
|
package/icons/icon128.png
CHANGED
|
Binary file
|
package/icons/icon16.png
CHANGED
|
Binary file
|
package/icons/icon32.png
CHANGED
|
Binary file
|
package/icons/icon48.png
CHANGED
|
Binary file
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Translation cache: a small in-memory copy in front of chrome.storage.local.
|
|
2
|
+
// Storage is capped by generations. New entries go to the current generation; when it has taken genMax entries a
|
|
3
|
+
// new one starts and everything older than the previous generation is deleted. An entry found in the previous
|
|
4
|
+
// generation is copied to the current one, so translations that are still in use survive the rotation.
|
|
5
|
+
(function (root, factory) {
|
|
6
|
+
const api = factory();
|
|
7
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
8
|
+
else (root.HTZ = root.HTZ || {}).cache = api;
|
|
9
|
+
})(globalThis, function () {
|
|
10
|
+
const META = "cacheMeta";
|
|
11
|
+
const ENTRY = /^c\d*:/;
|
|
12
|
+
// entries saved before generations existed are keyed "c:<id>": they are read as generation 0
|
|
13
|
+
const prefix = (gen) => (gen > 0 ? `c${gen}:` : "c:");
|
|
14
|
+
// an entry copied forward exists in two generations but is one translation
|
|
15
|
+
const distinct = (keys) => new Set(keys.map((k) => k.slice(k.indexOf(":") + 1))).size;
|
|
16
|
+
|
|
17
|
+
function create(storage, { genMax = 20000, memMax = 8000 } = {}) {
|
|
18
|
+
const mem = new Map();
|
|
19
|
+
let meta; // { gen, count }: the current generation and how many entries were written to it
|
|
20
|
+
|
|
21
|
+
function remember(id, value) {
|
|
22
|
+
mem.delete(id);
|
|
23
|
+
mem.set(id, value);
|
|
24
|
+
if (mem.size > memMax) mem.delete(mem.keys().next().value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let loading;
|
|
28
|
+
const load = () => loading
|
|
29
|
+
|| (loading = storage.get(META).then((got) => { meta = got[META] || { gen: 1, count: 0 }; }));
|
|
30
|
+
|
|
31
|
+
async function entryKeys() {
|
|
32
|
+
// getKeys (Chrome 130+) lists the keys without reading every cached translation into memory
|
|
33
|
+
const keys = storage.getKeys ? await storage.getKeys() : Object.keys(await storage.get(null));
|
|
34
|
+
return keys.filter((k) => ENTRY.test(k));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function rotate() {
|
|
38
|
+
const gen = meta.gen + 1;
|
|
39
|
+
meta = { gen, count: 0 };
|
|
40
|
+
await storage.set({ [META]: meta });
|
|
41
|
+
const keep = [prefix(gen), prefix(gen - 1)];
|
|
42
|
+
const stale = (await entryKeys()).filter((k) => !keep.some((p) => k.startsWith(p)));
|
|
43
|
+
if (stale.length) await storage.remove(stale);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// entries: { id: translation }
|
|
47
|
+
async function put(entries) {
|
|
48
|
+
const ids = Object.keys(entries);
|
|
49
|
+
if (!ids.length) return;
|
|
50
|
+
await load();
|
|
51
|
+
const items = {};
|
|
52
|
+
for (const id of ids) { remember(id, entries[id]); items[prefix(meta.gen) + id] = entries[id]; }
|
|
53
|
+
meta = { gen: meta.gen, count: meta.count + ids.length };
|
|
54
|
+
items[META] = meta;
|
|
55
|
+
await storage.set(items);
|
|
56
|
+
if (meta.count >= genMax) await rotate();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// the cached translation for each id, in order; undefined where there is none
|
|
60
|
+
async function get(ids) {
|
|
61
|
+
const out = ids.map((id) => mem.get(id));
|
|
62
|
+
const missing = ids.filter((id, i) => out[i] === undefined);
|
|
63
|
+
if (!missing.length) return out;
|
|
64
|
+
await load();
|
|
65
|
+
const cur = prefix(meta.gen), old = prefix(meta.gen - 1);
|
|
66
|
+
const stored = await storage.get(missing.flatMap((id) => [cur + id, old + id]));
|
|
67
|
+
const carry = {};
|
|
68
|
+
ids.forEach((id, i) => {
|
|
69
|
+
if (out[i] !== undefined) return;
|
|
70
|
+
if (stored[cur + id] != null) out[i] = stored[cur + id];
|
|
71
|
+
else if (stored[old + id] != null) out[i] = carry[id] = stored[old + id];
|
|
72
|
+
else return;
|
|
73
|
+
remember(id, out[i]);
|
|
74
|
+
});
|
|
75
|
+
await put(carry);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function count() {
|
|
80
|
+
return distinct(await entryKeys());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// removes every cached translation and nothing else; resolves to how many there were
|
|
84
|
+
async function clear() {
|
|
85
|
+
await load();
|
|
86
|
+
mem.clear();
|
|
87
|
+
const keys = await entryKeys();
|
|
88
|
+
if (keys.length) await storage.remove(keys);
|
|
89
|
+
meta = { gen: meta.gen, count: 0 };
|
|
90
|
+
await storage.set({ [META]: meta });
|
|
91
|
+
return distinct(keys);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { get, put, count, clear };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { create };
|
|
98
|
+
});
|
package/lib/engines.js
CHANGED
|
@@ -125,13 +125,25 @@
|
|
|
125
125
|
return checkCount(extractJson(text).translations, n, "Claude");
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// Chrome stops an extension's service worker when a fetch() has had no response for 30 s, and the request is
|
|
129
|
+
// then lost without any error. Model calls give up just before that, so the caller can fall back and say why.
|
|
130
|
+
const LLM_TIMEOUT_MS = 25000;
|
|
131
|
+
async function postJson(fetchImpl, req) {
|
|
132
|
+
try {
|
|
133
|
+
return await fetchImpl(req.url, {
|
|
134
|
+
method: "POST", headers: req.headers, body: JSON.stringify(req.body), signal: AbortSignal.timeout(LLM_TIMEOUT_MS),
|
|
135
|
+
});
|
|
136
|
+
} catch (e) {
|
|
137
|
+
if (e.name === "TimeoutError") throw fail(`no reply within ${LLM_TIMEOUT_MS / 1000} s`);
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
128
142
|
async function claude(texts, opts) {
|
|
129
143
|
if (!opts.apiKey) throw fail("Claude API key is not set");
|
|
130
144
|
const fetchImpl = opts.fetchImpl || fetch;
|
|
131
145
|
const req = buildClaudeRequest(texts, opts);
|
|
132
|
-
const r = await fetchImpl
|
|
133
|
-
method: "POST", headers: req.headers, body: JSON.stringify(req.body), signal: AbortSignal.timeout(120000),
|
|
134
|
-
});
|
|
146
|
+
const r = await postJson(fetchImpl, req);
|
|
135
147
|
const json = await r.json().catch(() => ({}));
|
|
136
148
|
if (!r.ok) throw fail(`Claude HTTP ${r.status}: ${json.error?.message || ""}`.trim(), r.status);
|
|
137
149
|
return parseClaude(json, texts.length);
|
|
@@ -163,10 +175,7 @@
|
|
|
163
175
|
if (!opts.baseUrl || !opts.model) throw fail("OpenAI-compatible base URL or model is not set");
|
|
164
176
|
const fetchImpl = opts.fetchImpl || fetch;
|
|
165
177
|
const send = async (jsonMode) => {
|
|
166
|
-
const
|
|
167
|
-
const r = await fetchImpl(req.url, {
|
|
168
|
-
method: "POST", headers: req.headers, body: JSON.stringify(req.body), signal: AbortSignal.timeout(120000),
|
|
169
|
-
});
|
|
178
|
+
const r = await postJson(fetchImpl, buildOpenAIRequest(texts, opts, jsonMode));
|
|
170
179
|
return { r, json: await r.json().catch(() => ({})) };
|
|
171
180
|
};
|
|
172
181
|
let { r, json } = await send(true);
|
package/lib/runtime.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
embedLogin: false, // play HackerTube's embeds via youtube.com/embed so the user's YouTube login applies
|
|
13
13
|
subScale: 1, // subtitle size multiplier
|
|
14
14
|
subBottom: 8, // subtitle distance from the bottom of the video, in %
|
|
15
|
+
subStyle: "outline", // outline (text with a dark outline, no box) | box (translucent box)
|
|
15
16
|
claudeModel: "claude-opus-5-5",
|
|
16
17
|
claudeEffort: "low", // translation is a simple task: low effort keeps latency and thinking spend down
|
|
17
18
|
openaiBaseUrl: "https://api.openai.com/v1",
|
package/manifest.json
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "HackerTube 中文助手",
|
|
4
4
|
"short_name": "HackerTube 中文",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.2",
|
|
6
|
+
"homepage_url": "https://github.com/crisxuan/hackertube-zh",
|
|
6
7
|
"description": "为 HackerTube 提供实时中文:双语界面,视频标题、简介和 HN 评论翻译,以及嵌入视频的实时中英双语字幕。",
|
|
7
8
|
"minimum_chrome_version": "116",
|
|
8
9
|
"icons": {
|
package/options/options.html
CHANGED
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
</div>
|
|
33
33
|
<label class="field"><span>字幕大小</span>
|
|
34
34
|
<input type="range" id="subScale" min="0.7" max="1.8" step="0.1"><output id="subScaleOut"></output></label>
|
|
35
|
+
<div class="field">
|
|
36
|
+
<span>字幕样式</span>
|
|
37
|
+
<div class="seg" data-key="subStyle">
|
|
38
|
+
<button value="outline">描边(不挡画面)</button><button value="box">半透明底框(白底幻灯片更清楚)</button>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
35
41
|
<label class="field"><span>字幕离底部</span>
|
|
36
42
|
<input type="range" id="subBottom" min="2" max="30" step="1"><output id="subBottomOut"></output></label>
|
|
37
43
|
<label class="field check"><input type="checkbox" id="searchTranslate">
|
package/package.json
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hackertube-zh",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "HackerTube 中文助手 Chrome 扩展:双语界面、标题评论翻译、实时中文字幕。npx hackertube-zh 一行安装。",
|
|
5
5
|
"keywords": ["hackertube", "chrome-extension", "translation", "subtitles", "chinese"],
|
|
6
|
+
"author": "crisxuan",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/crisxuan/hackertube-zh#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/crisxuan/hackertube-zh.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/crisxuan/hackertube-zh/issues"
|
|
15
|
+
},
|
|
6
16
|
"bin": {
|
|
7
17
|
"hackertube-zh": "bin/install.js"
|
|
8
18
|
},
|
|
@@ -26,7 +36,7 @@
|
|
|
26
36
|
"scripts": {
|
|
27
37
|
"test": "node --test \"tests/*.test.js\"",
|
|
28
38
|
"check": "node scripts/check.js",
|
|
29
|
-
"icons": "
|
|
39
|
+
"icons": "node scripts/render-icons.js",
|
|
30
40
|
"zip": "node scripts/check.js && rm -f dist/hackertube-zh.zip && mkdir -p dist && zip -r dist/hackertube-zh.zip manifest.json background.js lib content popup options ui icons"
|
|
31
41
|
}
|
|
32
42
|
}
|
package/popup/popup.css
CHANGED
|
@@ -4,6 +4,8 @@ section { margin-bottom: 14px; }
|
|
|
4
4
|
.row { display: flex; align-items: center; gap: 8px; margin-top: 10px; color: var(--muted); font-size: 13px; }
|
|
5
5
|
.row input { flex: 1; accent-color: var(--accent); }
|
|
6
6
|
.row output { width: 34px; text-align: right; color: var(--text); }
|
|
7
|
+
.row .seg { flex: 1; font-size: 13px; }
|
|
8
|
+
.row .seg button { padding: 4px; }
|
|
7
9
|
.toggle { display: flex; align-items: center; gap: 8px; margin-top: 12px; cursor: pointer; }
|
|
8
10
|
.toggle input { accent-color: var(--accent); margin: 0; }
|
|
9
11
|
footer { display: flex; justify-content: space-between; border-top: 1px solid var(--line); padding-top: 10px; font-size: 13px; }
|
package/popup/popup.html
CHANGED
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
<input type="range" id="subScale" min="0.7" max="1.8" step="0.1">
|
|
29
29
|
<output id="subScaleOut"></output>
|
|
30
30
|
</label>
|
|
31
|
+
<div class="row">字幕样式
|
|
32
|
+
<div class="seg" data-key="subStyle"><button value="outline">描边</button><button value="box">半透明底框</button></div>
|
|
33
|
+
</div>
|
|
31
34
|
<label class="toggle"><input type="checkbox" id="embedLogin"><span>用我的 YouTube 登录状态播放</span></label>
|
|
32
35
|
<p class="hint">播放器提示 “Sign in to confirm you're not a bot” 时打开。观看会计入你的 YouTube 历史记录,开启后刷新视频页生效。</p>
|
|
33
36
|
</section>
|