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 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
- # HackerTube 中文助手
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 # 重新生成图标(Python 标准库)
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:<你的用户名>/<仓库名>`。发布前在本地试:`npm pack` 后运行 `npx --package=./hackertube-zh-0.1.0.tgz hackertube-zh --dir /tmp/htz-test --no-open`。
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 (memory + chrome.storage.local) per engine, so revisiting a page or video is free and instant.
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
- llm: { maxItems: 40, maxChars: 6000, concurrency: 3 },
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 MEM_MAX = 8000;
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 keys = texts.map((t) => "c:" + T.hash(eid + "\u0001" + t));
100
- const out = keys.map((k) => mem.get(k));
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) { memSet(keys[i], out[i]); toStore[keys[i]] = out[i]; }
110
+ if (res.cache) toStore[ids[i]] = out[i];
123
111
  }
124
- if (Object.keys(toStore).length) chrome.storage.local.set(toStore);
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(cacheKeys().then((k) => ({ entries: k.length })));
154
- case "clearCache": return reply(cacheKeys().then(async (k) => { mem.clear(); await chrome.storage.local.remove(k); return { removed: k.length }; }));
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
  });
@@ -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
- let wanted = false, triedFor = null;
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 === "config") wanted = !!e.data.enabled;
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
  })();
@@ -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: .08em .45em; border-radius: 4px;
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, 3 = gave up
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
- const giveUp = failures >= 3;
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
- if (!giveUp) setTimeout(pump, 2500 * failures);
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
- if (failures < 3) pump();
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
- document.addEventListener("seeked", () => { shown = ""; render(); pump(); }, true);
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(req.url, {
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 req = buildOpenAIRequest(texts, opts, jsonMode);
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.0",
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": {
@@ -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.0",
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": "python3 scripts/make_icons.py",
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>