hackertube-zh 0.0.0-stage → 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,3 +1,170 @@
1
- # Temporary Holding Version
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>
2
7
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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>
14
+
15
+ 给 [HackerTube](https://hackertube.org)(Karpathy 做的“HN 推荐过的 YouTube 视频”站)用的 Chrome 扩展,把整个观看体验实时变成中文:
16
+
17
+ - **界面汉化**:菜单、筛选、按钮、观看次数、日期、HN 推荐信息等,本地词典即时替换,不走网络。
18
+ - **内容翻译**:视频标题、简介、“HN 为什么喜欢它”、HN 评论、分类名、播放列表介绍。滚动到哪翻到哪,译文以**双语对照**或**仅中文**方式渲染在原文位置,样式与原站一致。
19
+ - **实时中文字幕**:在 HackerTube 的嵌入播放器里显示中英双语(或仅中文)字幕,全屏也能看到,提前翻译当前播放位置往后 5 分钟。
20
+ - **中文搜索**:在搜索框输入中文,会先翻译成英文再交给 HackerTube 的语义搜索(它只理解英文)。
21
+ - **用 YouTube 登录状态播放**(可选):解决嵌入播放器一直提示 “Sign in to confirm you're not a bot” 的问题,见下方“常见问题”。
22
+ - **三种翻译引擎**:Google 翻译(免费,默认)、Claude、任意 OpenAI 兼容接口(DeepSeek、通义千问、Ollama 等)。LLM 出错时自动临时回退到 Google。
23
+ - 所有译文缓存在本地,同一个视频、同一页再次打开不再请求翻译。缓存有上限,很久没用到的译文会自动清掉。
24
+ - 翻译请求失败(断网、被限流)时会自动重试,网络恢复后页面和字幕会自己补上中文,不用刷新。
25
+
26
+ ## 安装
27
+
28
+ 需要 Node.js 18+ 和 Chrome 116+(Edge 等 Chromium 浏览器同样可用)。
29
+
30
+ ```bash
31
+ npx hackertube-zh
32
+ ```
33
+
34
+ 它会把扩展放到 `~/hackertube-zh`,把 `chrome://extensions` 复制到剪贴板,并在 Finder 里选中这个文件夹。然后在 Chrome 里:
35
+
36
+ 1. 地址栏粘贴 `chrome://extensions` 回车;
37
+ 2. 打开右上角「开发者模式」;
38
+ 3. 点「加载已解压的扩展程序」选择 `~/hackertube-zh`(或者直接把 Finder 里的文件夹拖到页面上)。
39
+
40
+ 打开 <https://hackertube.org>,页面就是双语的了。点工具栏上橙色的「中」图标可以切换模式和引擎。
41
+
42
+ **更新**:再运行一次 `npx hackertube-zh`,然后在扩展卡片上点刷新按钮 ↻。可选参数:`--dir <路径>` 换安装位置,`--no-open` 只复制文件,`--help` 查看帮助。
43
+
44
+ 为什么最后三步不能也自动化:Chrome 正式版会忽略命令行传入的 `chrome://` 地址,也不再支持用命令行参数加载扩展,加载未上架的扩展必须由用户自己打开开发者模式。要做到真正的一键安装,需要上架 Chrome 应用商店。
45
+
46
+ **从源码安装**:在 `chrome://extensions` 打开开发者模式,点「加载已解压的扩展程序」选择本目录。改代码后在扩展卡片上点刷新按钮,再刷新 HackerTube 页面。
47
+
48
+ ## 使用
49
+
50
+ | 位置 | 作用 |
51
+ | --- | --- |
52
+ | 工具栏弹窗 | 网页翻译:双语对照 / 仅中文 / 关闭;视频字幕:中英双语 / 仅中文 / 关闭;字幕大小;用 YouTube 登录状态播放;翻译引擎 |
53
+ | 更多设置(选项页) | 引擎的 Key / 模型 / 接口地址,测试引擎,字幕离底部的距离,中文搜索开关,清空缓存 |
54
+ | 视频页 | 点播放后,播放器左上角会提示“中文字幕 · 已获取 N 句 / 翻译中 / 已就绪” |
55
+
56
+ ### 翻译引擎
57
+
58
+ - **Google 翻译**:免费、零配置,需要网络能访问 `translate.googleapis.com`。个别标题 Google 会原样返回(它当成专有名词),这时不显示译文。
59
+ - **Claude**:在选项页填 API Key。默认模型 `claude-opus-5-5`,思考力度默认 `low`(翻译不需要深度推理,响应更快、思考开销更小);想更省钱可以把模型换成 `claude-haiku-5-5`。字幕请求会带上视频标题和前几句作为上下文,自动生成字幕里没有标点的碎句也能翻得通顺。Key 只存在本机 `chrome.storage.local`,不会同步。
60
+ - **OpenAI 兼容接口**:填 Base URL 和模型名,例如 DeepSeek `https://api.deepseek.com/v1` + `deepseek-chat`,本地 Ollama `http://localhost:11434/v1` + 模型名。保存时浏览器会请求访问该地址的权限。
61
+
62
+ LLM 引擎按用量计费。字幕只翻译播放位置往后 5 分钟的内容,所以费用跟实际看了多少成正比,不会一打开视频就把整部翻完。
63
+
64
+ ## 常见问题
65
+
66
+ **播放器提示 “Sign in to confirm you're not a bot”,在 YouTube 登录了还是看不了?**
67
+
68
+ 两个原因叠在一起:
69
+
70
+ 1. YouTube 怀疑你的网络环境(最常见的是代理 / VPN 节点,很多人共用同一个出口 IP),要求登录才能播放。
71
+ 2. HackerTube 用 `youtube-nocookie.com`(隐私增强模式)嵌入视频,这个域名故意不使用 youtube.com 的登录 Cookie,所以你在 youtube.com 登录了,嵌入播放器里仍然是未登录状态。
72
+
73
+ 解决办法(任选其一):
74
+
75
+ - 在弹窗或选项页打开 **“用我的 YouTube 登录状态播放”**,然后刷新视频页。扩展会把 HackerTube 的嵌入改由 `www.youtube.com/embed` 播放(HackerTube 的安全策略本来就允许这个域名),能用上你已登录的账号,中文字幕照常显示。代价是观看会计入你的 YouTube 历史记录;还需要 Chrome 允许第三方 Cookie(设置 → 隐私和安全 → 第三方 Cookie,无痕窗口默认屏蔽)。
76
+ - 换一个不那么拥挤的代理节点。
77
+ - 点视频下方的「YouTube」按钮到 youtube.com 观看(中文字幕在那里不生效)。
78
+
79
+ 这个开关只影响 HackerTube 页面里加载的播放器(用 declarativeNetRequest 规则,限定由 hackertube.org 发起的 iframe 请求),不影响其他网站。
80
+
81
+ ## 工作原理
82
+
83
+ ```
84
+ hackertube.org 页面 YouTube 嵌入播放器(iframe, youtube-nocookie.com)
85
+ ┌──────────────────────────────┐ ┌─────────────────────────────────────────────┐
86
+ │ content/site.js │ │ content/player-main.js(页面主世界) │
87
+ │ MutationObserver 监听 SPA 渲染 │ │ 播放后通过播放器 API 打开英文字幕, │
88
+ │ ├ 界面字符串 → lib/uidict.js │ │ 拦截播放器自己下载的 /api/timedtext 字幕文件 │
89
+ │ ├ 内容元素进入视口 → 批量翻译 │ │ │ postMessage │
90
+ │ └ 中文搜索词 → 英文再搜索 │ │ content/player.js(扩展隔离世界) │
91
+ └──────────────┬───────────────┘ │ lib/subtitles.js 把字幕重组为句子 │
92
+ │ │ 按播放进度分批翻译,叠加层渲染双语字幕 │
93
+ │ chrome.runtime └──────────────────────┬──────────────────────┘
94
+ ▼ │
95
+ background.js(Service Worker)◄───────────────────────────┘
96
+ 缓存(内存 + storage.local)→ lib/engines.js:Google / Claude / OpenAI 兼容
97
+ ```
98
+
99
+ 几个关键设计:
100
+
101
+ - **为什么要拦截字幕而不是自己下载**:YouTube 的字幕接口现在要求播放器生成的 proof-of-origin 令牌,直接请求通常拿到空文件。所以扩展在嵌入页的主世界里调用播放器的 `loadModule('captions')` / `setOption('captions','track',…)` 打开英文字幕(优先人工字幕,其次自动生成),再复制播放器自己下载的那份。拿不到时会提示“点一下 CC 按钮重试”。
102
+ - **字幕重组**:自动生成字幕是逐词、无标点的;人工字幕常把一句话拆在两行。`lib/subtitles.js` 按标点、停顿和长度把它们重组成适合翻译和阅读的句子,并按字数比例分配时间。
103
+ - **只在 HackerTube 里生效**:嵌入播放器的脚本会检查 iframe 的上级页面是不是 hackertube.org,其他网站里的 YouTube 嵌入完全不受影响。
104
+ - **可完全还原**:网页翻译设为“关闭”时,所有替换的文字恢复原样,插入的译文全部移除。
105
+
106
+ ## 隐私
107
+
108
+ - 被发送去翻译的只有:页面上的标题、简介、评论、分类名,字幕文本,以及你输入的中文搜索词。发送对象是你选的那个引擎。
109
+ - 不收集、不上传任何浏览记录;HackerTube 自己存在 localStorage 里的“赞过 / 历史 / 稍后观看”不会被读取。
110
+ - API Key 只存在本机。
111
+
112
+ ## 已知限制
113
+
114
+ - 视频本身没有任何字幕(人工和自动生成都没有)时,无法生成中文字幕。
115
+ - 只处理 HackerTube 页面内的嵌入播放器;点“YouTube”按钮跳到 youtube.com 观看时不生效。
116
+ - YouTube 播放器接口不是公开 API,YouTube 改版后字幕获取可能需要调整(`content/player-main.js`)。
117
+
118
+ ## 开发
119
+
120
+ 项目没有构建步骤,也没有任何依赖。
121
+
122
+ ```bash
123
+ npm test # 单元测试:字幕解析与分句、引擎请求/响应、界面词典
124
+ npm run check # 静态检查:manifest 引用的文件都存在、所有 JS 语法正确
125
+ npm run icons # 从 icons/*.svg 渲染插件图标 PNG 和 README 横版 logo(用本机 Chrome 的无头模式)
126
+ npm run zip # 打包到 dist/hackertube-zh.zip(上架 Chrome 应用商店用)
127
+ ```
128
+
129
+ 发布 npx 安装包(`bin/install.js` 是安装器,`package.json` 的 `files` 决定包里有什么,`npm run check` 会核对它覆盖了扩展用到的每个文件、版本号与 manifest 一致):
130
+
131
+ ```bash
132
+ npm publish # 之后任何人都能 npx hackertube-zh
133
+ ```
134
+
135
+ 不想发布到 npm 的话,推到 GitHub 后也可以用 `npx github:crisxuan/hackertube-zh`。发布前在本地试:`npm pack` 后运行 `npx --package=./hackertube-zh-<版本号>.tgz hackertube-zh --dir /tmp/htz-test --no-open`。
136
+
137
+ 不安装扩展也能调试:`node dev/server.js 8787 <放测试视频的目录>` 启动开发服务器(HackerTube 的 CSP 禁止注入脚本,所以这里用本地镜像):
138
+
139
+ - `http://localhost:8787/`:真实的 HackerTube 页面(`/api` 代理到 hackertube.org),注入了扩展的页面脚本和后台逻辑,`chrome.*` 由 `dev/chrome-shim.js` 模拟。控制台里可以用 `__htzDev.setSettings({ pageMode: 'zh' })` 切换设置。
140
+ - `http://localhost:8787/player`:模拟嵌入播放器,按钮发送自动生成 / 人工字幕,检查字幕叠加层、同步和样式。测试视频可以用 `ffmpeg -f lavfi -i testsrc=size=640x360:rate=24 -t 60 -pix_fmt yuv420p test.mp4` 生成。
141
+ - `http://localhost:8787/preview/popup`、`/preview/options`:弹窗和选项页。
142
+
143
+ ```
144
+ manifest.json 扩展清单(MV3)
145
+ background.js Service Worker:翻译调度、并发限制、缓存、引擎回退
146
+ lib/ 公共模块(浏览器和 node 测试共用)
147
+ engines.js Google / Claude / OpenAI 兼容引擎
148
+ cache.js 译文缓存(内存 + storage.local,分两代淘汰旧条目)
149
+ embedlogin.js “用 YouTube 登录状态播放”的重定向规则
150
+ subtitles.js json3 / srv3 / WebVTT 解析与分句
151
+ uidict.js HackerTube 界面词典
152
+ textutil.js 哈希、分批等小工具
153
+ runtime.js 设置读写、消息通信
154
+ content/ 内容脚本:site.*(HackerTube 页面)、player*(嵌入播放器)
155
+ popup/ options/ ui/ 弹窗、选项页、公共样式
156
+ bin/install.js npx 安装器
157
+ dev/ 本地调试服务器和模拟页面
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 生成)
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 ADDED
@@ -0,0 +1,140 @@
1
+ // Service worker: the only place that talks to translation engines. Content scripts send batches of text;
2
+ // results are cached (lib/cache.js) per engine, so revisiting a page or video is free and instant.
3
+ import "./lib/textutil.js";
4
+ import "./lib/engines.js";
5
+ import "./lib/runtime.js";
6
+ import "./lib/embedlogin.js";
7
+ import "./lib/cache.js";
8
+
9
+ const { text: T, engines: E, runtime: R, embedlogin: EL, cache: C } = globalThis.HTZ;
10
+
11
+ // keep the embed redirect rule in step with the setting: on every service worker start, and on every change
12
+ const syncEmbedLogin = (s) => EL.sync(!!s.embedLogin).catch((e) => console.warn("[HackerTube 中文] embed rule:", e));
13
+ R.loadSettings().then(syncEmbedLogin);
14
+ R.onSettings(syncEmbedLogin);
15
+
16
+ const ENGINE_NAMES = { google: "Google 翻译", claude: "Claude", openai: "OpenAI 兼容接口" };
17
+ const LIMITS = {
18
+ google: { maxItems: 50, maxChars: 4500, concurrency: 4 },
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 },
21
+ };
22
+
23
+ // ---------- small helpers ----------
24
+ function limiter(n) {
25
+ let active = 0;
26
+ const queue = [];
27
+ const next = () => {
28
+ if (active >= n || !queue.length) return;
29
+ active++;
30
+ const { fn, resolve, reject } = queue.shift();
31
+ fn().then(resolve, reject).finally(() => { active--; next(); });
32
+ };
33
+ return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); });
34
+ }
35
+ const limits = { google: limiter(LIMITS.google.concurrency), llm: limiter(LIMITS.llm.concurrency) };
36
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
37
+
38
+ const cache = C.create(chrome.storage.local);
39
+
40
+ // The cache key includes everything that changes the output: engine, model, target language and, for LLMs,
41
+ // the kind of text (subtitle prompts differ from page prompts).
42
+ function engineId(s, kind, to) {
43
+ if (s.engine === "claude") return `claude|${s.claudeModel}|${kind}|${to}`;
44
+ if (s.engine === "openai") return `openai|${s.openaiBaseUrl}|${s.openaiModel}|${kind}|${to}`;
45
+ return `google|${to}`;
46
+ }
47
+
48
+ // ---------- engines ----------
49
+ async function googleBatch(texts, to) {
50
+ for (let attempt = 0; ; attempt++) {
51
+ try {
52
+ return await limits.google(() => E.google(texts, { to }));
53
+ } catch (e) {
54
+ // the free endpoint rate-limits bursts: back off once, then give up
55
+ if (attempt === 0 && (e.status === 429 || e.status >= 500 || e.name === "TimeoutError")) { await sleep(1500); continue; }
56
+ throw e;
57
+ }
58
+ }
59
+ }
60
+
61
+ async function runChunks(texts, opts, fn) {
62
+ const chunks = T.chunk(texts, opts);
63
+ const parts = await Promise.all(chunks.map((idx) => fn(idx.map((i) => texts[i]))));
64
+ const out = new Array(texts.length);
65
+ chunks.forEach((idx, c) => idx.forEach((i, j) => { out[i] = parts[c][j]; }));
66
+ return out;
67
+ }
68
+
69
+ async function runEngine(texts, { settings, secrets, kind, to, context }) {
70
+ if (settings.engine === "claude" || settings.engine === "openai") {
71
+ const call = settings.engine === "claude"
72
+ ? (batch) => limits.llm(() => E.claude(batch, {
73
+ to, kind, context, model: settings.claudeModel, effort: settings.claudeEffort, apiKey: secrets.claudeKey,
74
+ }))
75
+ : (batch) => limits.llm(() => E.openai(batch, {
76
+ to, kind, context, model: settings.openaiModel, baseUrl: settings.openaiBaseUrl, apiKey: secrets.openaiKey,
77
+ }));
78
+ try {
79
+ return { translations: await runChunks(texts, LIMITS.llm, call), cache: true };
80
+ } catch (e) {
81
+ // keep the page and subtitles working: fall back to Google for this request and say why
82
+ console.warn("[HackerTube 中文] LLM engine failed, falling back to Google:", e);
83
+ const translations = await runChunks(texts, LIMITS.google, (b) => googleBatch(b, to));
84
+ return { translations, cache: false, warning: `${ENGINE_NAMES[settings.engine]} 出错(${e.message}),已临时改用 Google 翻译` };
85
+ }
86
+ }
87
+ return { translations: await runChunks(texts, LIMITS.google, (b) => googleBatch(b, to)), cache: true };
88
+ }
89
+
90
+ async function translate({ texts, kind = "page", to = "zh-CN", context }) {
91
+ if (!Array.isArray(texts) || !texts.length) return { translations: [] };
92
+ if (texts.some((t) => typeof t !== "string")) throw new Error("texts must be strings");
93
+ const [settings, secrets] = await Promise.all([R.loadSettings(), R.loadSecrets()]);
94
+ const eid = engineId(settings, kind, to);
95
+ const ids = texts.map((t) => T.hash(eid + "\u0001" + t));
96
+ const out = await cache.get(ids);
97
+
98
+ const todo = [];
99
+ out.forEach((v, i) => { if (v == null) todo.push(i); });
100
+ let warning;
101
+ if (todo.length) {
102
+ // dedupe within the request: the same title often appears twice on one page
103
+ const uniq = [...new Set(todo.map((i) => texts[i]))];
104
+ const res = await runEngine(uniq, { settings, secrets, kind, to, context });
105
+ warning = res.warning;
106
+ const byText = new Map(uniq.map((t, j) => [t, res.translations[j]]));
107
+ const toStore = {};
108
+ for (const i of todo) {
109
+ out[i] = byText.get(texts[i]);
110
+ if (res.cache) toStore[ids[i]] = out[i];
111
+ }
112
+ cache.put(toStore);
113
+ }
114
+ return { translations: out, warning };
115
+ }
116
+
117
+ async function testEngine() {
118
+ const [settings, secrets] = await Promise.all([R.loadSettings(), R.loadSecrets()]);
119
+ const sample = ["Overwatch Gameplay Architecture and Netcode", "The quick brown fox jumps over the lazy dog."];
120
+ const started = Date.now();
121
+ const res = await runEngine(sample, { settings, secrets, kind: "page", to: "zh-CN" });
122
+ if (res.warning) throw new Error(res.warning);
123
+ return { engine: ENGINE_NAMES[settings.engine], ms: Date.now() - started, sample, translations: res.translations };
124
+ }
125
+
126
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
127
+ const reply = (p) => {
128
+ p.then((data) => sendResponse({ ok: true, ...data }), (e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));
129
+ return true; // async response
130
+ };
131
+ // translation requests come only from our own content scripts and pages
132
+ if (sender.id !== chrome.runtime.id) return false;
133
+ switch (msg && msg.type) {
134
+ case "translate": return reply(translate(msg));
135
+ case "testEngine": return reply(testEngine());
136
+ case "cacheStats": return reply(cache.count().then((entries) => ({ entries })));
137
+ case "clearCache": return reply(cache.clear().then((removed) => ({ removed })));
138
+ default: return false;
139
+ }
140
+ });
package/bin/install.js ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // npx hackertube-zh — put the extension in a fixed folder Chrome can load it from, then walk the user through the
3
+ // last two clicks. Chrome doesn't let scripts finish the job: release builds ignore chrome:// URLs passed on the
4
+ // command line, and loading an unpacked extension needs Developer mode, which only the user can switch on.
5
+ // Running it again updates the files in place; Chrome then needs one click on the extension's reload button.
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+ const { spawnSync } = require("child_process");
10
+
11
+ const PKG_ROOT = path.join(__dirname, "..");
12
+ // everything the browser loads; scripts/check.js verifies this covers every file the manifest references
13
+ const EXTENSION_FILES = ["manifest.json", "background.js", "lib", "content", "popup", "options", "ui", "icons"];
14
+ const EXTENSIONS_URL = "chrome://extensions";
15
+ const DEFAULT_DIR = path.join(os.homedir(), "hackertube-zh"); // visible, so the folder picker finds it easily
16
+
17
+ const readManifest = (dir) => {
18
+ try { return JSON.parse(fs.readFileSync(path.join(dir, "manifest.json"), "utf8")); } catch { return null; }
19
+ };
20
+
21
+ // Copy the extension into `dir`. Refuses to touch a non-empty folder that isn't a previous install.
22
+ function install(dir, src = PKG_ROOT) {
23
+ const target = path.resolve(dir);
24
+ if (target === path.resolve(src)) throw new Error("目标文件夹就是扩展源码目录本身,请换一个 --dir");
25
+ const ours = readManifest(src);
26
+ const existing = fs.existsSync(target) ? fs.readdirSync(target) : [];
27
+ const previous = existing.length ? readManifest(target) : null;
28
+ if (existing.length && (!previous || previous.name !== ours.name)) {
29
+ throw new Error(`${target} 已存在且不是本扩展的安装目录,为避免覆盖你的文件,已停止。可以用 --dir 指定别的位置。`);
30
+ }
31
+ fs.mkdirSync(target, { recursive: true });
32
+ for (const entry of EXTENSION_FILES) {
33
+ // replace whole entries so files removed in a new version don't linger
34
+ fs.rmSync(path.join(target, entry), { recursive: true, force: true });
35
+ fs.cpSync(path.join(src, entry), path.join(target, entry), { recursive: true });
36
+ }
37
+ return { dir: target, version: ours.version, previousVersion: previous && previous.version };
38
+ }
39
+
40
+ // The desktop conveniences for each OS: put chrome://extensions on the clipboard, show the folder in the file
41
+ // manager (so it can be dragged onto the extensions page), and bring Chrome to the front.
42
+ function plan(platform, dir) {
43
+ if (platform === "darwin") {
44
+ return [
45
+ { what: "clipboard", cmd: "pbcopy", args: [], input: EXTENSIONS_URL },
46
+ { what: "reveal", cmd: "open", args: ["-R", dir] },
47
+ { what: "browser", cmd: "open", args: ["-a", "Google Chrome"] },
48
+ ];
49
+ }
50
+ if (platform === "win32") {
51
+ return [
52
+ { what: "clipboard", cmd: "clip", args: [], input: EXTENSIONS_URL },
53
+ { what: "reveal", cmd: "explorer", args: [`/select,${dir}`] },
54
+ { what: "browser", cmd: "cmd", args: ["/c", "start", "", "chrome"] },
55
+ ];
56
+ }
57
+ return [
58
+ { what: "clipboard", cmd: "wl-copy", args: [], input: EXTENSIONS_URL },
59
+ { what: "clipboard", cmd: "xclip", args: ["-selection", "clipboard"], input: EXTENSIONS_URL },
60
+ { what: "reveal", cmd: "xdg-open", args: [path.dirname(dir)] },
61
+ ];
62
+ }
63
+
64
+ // Best effort: a missing tool just means that convenience is skipped. Returns which conveniences worked.
65
+ function runPlan(steps) {
66
+ const done = new Set();
67
+ for (const s of steps) {
68
+ if (done.has(s.what)) continue;
69
+ const r = spawnSync(s.cmd, s.args, { input: s.input, stdio: [s.input == null ? "ignore" : "pipe", "ignore", "ignore"], timeout: 5000 });
70
+ // explorer.exe exits with 1 even when it worked
71
+ if (!r.error && (r.status === 0 || (s.cmd === "explorer" && r.status === 1))) done.add(s.what);
72
+ }
73
+ return done;
74
+ }
75
+
76
+ function usage() {
77
+ return `用法:npx hackertube-zh [选项]
78
+
79
+ 把「HackerTube 中文助手」Chrome 扩展放到固定文件夹,并引导你在 Chrome 里加载它。
80
+ 再次运行即更新。
81
+
82
+ 选项:
83
+ --dir <路径> 安装到指定文件夹(默认 ${DEFAULT_DIR})
84
+ --no-open 只复制文件:不写剪贴板、不打开文件夹和 Chrome
85
+ -h, --help 显示帮助`;
86
+ }
87
+
88
+ function parseArgs(argv) {
89
+ const opts = { dir: DEFAULT_DIR, open: true, help: false };
90
+ for (let i = 0; i < argv.length; i++) {
91
+ const a = argv[i];
92
+ if (a === "-h" || a === "--help") opts.help = true;
93
+ else if (a === "--no-open") opts.open = false;
94
+ else if (a === "--dir") {
95
+ if (!argv[i + 1]) throw new Error("--dir 需要一个路径");
96
+ opts.dir = argv[++i].replace(/^~(?=$|[\\/])/, os.homedir());
97
+ } else throw new Error(`不认识的参数:${a}\n\n${usage()}`);
98
+ }
99
+ return opts;
100
+ }
101
+
102
+ function main(argv = process.argv.slice(2), platform = process.platform) {
103
+ const opts = parseArgs(argv);
104
+ if (opts.help) return console.log(usage());
105
+
106
+ const res = install(opts.dir);
107
+ const done = opts.open ? runPlan(plan(platform, res.dir)) : new Set();
108
+ const tty = process.stdout.isTTY;
109
+ const b = (s) => (tty ? `\x1b[1m${s}\x1b[0m` : s);
110
+ const g = (s) => (tty ? `\x1b[32m${s}\x1b[0m` : s);
111
+ const urlHint = done.has("clipboard") ? `(已复制,粘贴即可)` : "";
112
+ const picker = platform === "darwin" ? "(文件选择框里按 ⌘⇧G 可以直接输入路径)" : "";
113
+ const lines = [];
114
+
115
+ if (res.previousVersion) {
116
+ const head = res.previousVersion === res.version
117
+ ? `✔ HackerTube 中文助手已是最新版本(${res.version}),文件已重新写入:`
118
+ : `✔ HackerTube 中文助手已更新:${res.previousVersion} → ${res.version}`;
119
+ lines.push(g(head), ` ${res.dir}`, "",
120
+ "让 Chrome 用上新版本:",
121
+ ` 1. 在 Chrome 地址栏打开 ${b(EXTENSIONS_URL)}${urlHint}`,
122
+ ` 2. 在「HackerTube 中文助手」卡片上点刷新按钮 ↻,再刷新 HackerTube 页面`);
123
+ } else {
124
+ lines.push(g(`✔ HackerTube 中文助手 ${res.version} 已放到:`), ` ${b(res.dir)}`, "",
125
+ "在 Chrome 里还差三步(只需做一次):",
126
+ ` 1. 在 Chrome 地址栏打开 ${b(EXTENSIONS_URL)}${urlHint}`,
127
+ ` 2. 打开页面右上角的「开发者模式」`,
128
+ ` 3. 点「加载已解压的扩展程序」,选择上面这个文件夹${picker}`,
129
+ done.has("reveal") ? ` 也可以直接把刚弹出的 ${path.basename(res.dir)} 文件夹拖到扩展页面上` : "",
130
+ "",
131
+ "之后打开 https://hackertube.org 就是中文了。以后更新:再运行一次 npx hackertube-zh。",
132
+ "提示:如果之前从别的文件夹加载过这个扩展(比如源码目录),请先在扩展页移除旧的,否则会重复翻译。");
133
+ }
134
+ console.log(lines.filter((l, i) => l !== "" || lines[i - 1] !== "").join("\n"));
135
+ }
136
+
137
+ if (require.main === module) {
138
+ try { main(); } catch (e) { console.error(`✖ ${e.message}`); process.exit(1); }
139
+ }
140
+
141
+ module.exports = { EXTENSION_FILES, DEFAULT_DIR, install, plan, parseArgs, main };
@@ -0,0 +1,136 @@
1
+ // Runs in the page's own JavaScript world inside the YouTube embed that HackerTube shows (youtube-nocookie.com).
2
+ // YouTube's caption requests are signed by the player (a proof-of-origin token), so instead of fetching captions
3
+ // ourselves we turn captions on through the player's API and copy the caption file the player downloads.
4
+ // Everything found is handed to the extension's isolated script (player.js) with window.postMessage.
5
+ (() => {
6
+ if (window.__htzMain) return;
7
+ window.__htzMain = true;
8
+
9
+ // only inside HackerTube's embeds; YouTube embeds on other sites are left completely alone
10
+ const anc = location.ancestorOrigins;
11
+ const origins = anc ? Array.from({ length: anc.length }, (_, i) => anc[i]) : [document.referrer];
12
+ const fromHackerTube = origins.some((o) => { try { return /(^|\.)hackertube\.org$/.test(new URL(o).hostname); } catch { return false; } });
13
+ if (!fromHackerTube) return;
14
+
15
+ const post = (type, data = {}) => window.postMessage({ source: "htz-main", type, ...data }, "*");
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+ const isTimedText = (u) => /\/api\/timedtext\b/.test(u);
18
+
19
+ let lastVid = null, lastTitle = "", gotFor = null;
20
+
21
+ function onTimedText(url, body) {
22
+ if (!body || body.length < 20) return;
23
+ let u;
24
+ try { u = new URL(url, location.href); } catch { return; }
25
+ // a tlang= request is YouTube's own machine translation of a track: we translate the source track ourselves
26
+ if (u.searchParams.get("tlang")) return;
27
+ const vid = u.searchParams.get("v") || lastVid;
28
+ gotFor = vid;
29
+ post("captions", { vid, title: lastTitle, lang: u.searchParams.get("lang") || "", kind: u.searchParams.get("kind") || "", body });
30
+ }
31
+
32
+ // ---------- copy caption downloads (fetch and XHR) ----------
33
+ const origFetch = window.fetch;
34
+ window.fetch = function (input) {
35
+ const url = typeof input === "string" ? input : input instanceof Request ? input.url : String(input);
36
+ const p = origFetch.apply(this, arguments);
37
+ if (isTimedText(url)) p.then((r) => r.clone().text()).then((body) => onTimedText(url, body)).catch(() => {});
38
+ return p;
39
+ };
40
+ const xhrOpen = XMLHttpRequest.prototype.open, xhrSend = XMLHttpRequest.prototype.send;
41
+ XMLHttpRequest.prototype.open = function (method, url) {
42
+ this.__htzUrl = String(url);
43
+ return xhrOpen.apply(this, arguments);
44
+ };
45
+ XMLHttpRequest.prototype.send = function () {
46
+ if (this.__htzUrl && isTimedText(this.__htzUrl)) {
47
+ this.addEventListener("load", () => {
48
+ try {
49
+ const rt = this.responseType;
50
+ const body = rt === "" || rt === "text" ? this.responseText
51
+ : rt === "json" ? JSON.stringify(this.response)
52
+ : rt === "arraybuffer" ? new TextDecoder().decode(this.response) : "";
53
+ onTimedText(this.__htzUrl, body);
54
+ } catch {}
55
+ });
56
+ }
57
+ return xhrSend.apply(this, arguments);
58
+ };
59
+
60
+ // ---------- turn captions on once the video plays ----------
61
+ const player = () => document.getElementById("movie_player");
62
+ const isEnglish = (t) => /^en\b/i.test(t.languageCode || "");
63
+ const isAsr = (t) => t.kind === "asr" || /^a\./.test(t.vss_id || t.vssId || "");
64
+ const choose = (list) => list.find((t) => isEnglish(t) && !isAsr(t)) || list.find(isEnglish)
65
+ || list.find((t) => !isAsr(t)) || list[0];
66
+
67
+ function responseTracks(p) {
68
+ try {
69
+ const r = p.getPlayerResponse && p.getPlayerResponse();
70
+ return (r && r.captions && r.captions.playerCaptionsTracklistRenderer
71
+ && r.captions.playerCaptionsTracklistRenderer.captionTracks) || [];
72
+ } catch { return []; }
73
+ }
74
+
75
+ async function enableCaptions(p, vid) {
76
+ if (gotFor === vid) return; // the player already loaded captions (user had CC on)
77
+ try { p.loadModule("captions"); } catch {}
78
+ let list = [];
79
+ for (let i = 0; i < 12 && !list.length; i++) {
80
+ try { list = p.getOption("captions", "tracklist", { includeAsr: true }) || []; } catch {}
81
+ if (!list.length) await sleep(300);
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; }
85
+ const tracks = responseTracks(p);
86
+ if (!list.length && !tracks.length) return post("nocaptions", { vid });
87
+ if (list.length) { try { p.setOption("captions", "track", choose(list)); turnedOnFor = vid; } catch {} }
88
+ await sleep(3500);
89
+ if (gotFor === vid) return;
90
+ if (!wanted) { triedFor = null; return; }
91
+ // second route: the track's own URL (works for videos whose captions aren't token-gated)
92
+ const t = tracks.length && choose(tracks);
93
+ if (t && t.baseUrl) {
94
+ try {
95
+ const url = t.baseUrl + (t.baseUrl.includes("fmt=") ? "" : "&fmt=json3");
96
+ const body = await (await origFetch(url, { credentials: "include" })).text();
97
+ onTimedText(url, body);
98
+ } catch {}
99
+ }
100
+ await sleep(3000);
101
+ if (gotFor !== vid) post("captionsfailed", { vid });
102
+ }
103
+
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;
114
+ setInterval(() => {
115
+ const p = player();
116
+ if (!p || !p.getVideoData) return;
117
+ let vd = {};
118
+ try { vd = p.getVideoData() || {}; } catch {}
119
+ const vid = vd.video_id;
120
+ if (!vid) return;
121
+ if (vid !== lastVid) { lastVid = vid; lastTitle = vd.title || ""; post("video", { vid, title: lastTitle }); }
122
+ else if (!lastTitle && vd.title) { lastTitle = vd.title; post("video", { vid, title: lastTitle }); }
123
+ let state = -1;
124
+ try { state = p.getPlayerState(); } catch {}
125
+ if (wanted && triedFor !== vid && state === 1) { triedFor = vid; enableCaptions(p, vid); }
126
+ }, 700);
127
+
128
+ window.addEventListener("message", (e) => {
129
+ if (e.source !== window || !e.data || e.data.source !== "htz-iso") return;
130
+ if (e.data.type !== "config") return;
131
+ const was = wanted;
132
+ wanted = !!e.data.enabled;
133
+ if (was && !wanted) restoreCaptions();
134
+ });
135
+ post("hello");
136
+ })();
@@ -0,0 +1,42 @@
1
+ /* Subtitles drawn inside YouTube's player element, so they follow it into fullscreen. Sizes scale with the
2
+ player's height (--htz-h, set by a ResizeObserver) like YouTube's own captions do. */
3
+ .htz-sub {
4
+ position: absolute; left: 50%; transform: translateX(-50%);
5
+ bottom: calc(var(--htz-bottom, 8) * 1%);
6
+ width: 92%; z-index: 38; pointer-events: none;
7
+ display: none; flex-direction: column; align-items: center; gap: .15em;
8
+ text-align: center; transition: bottom .2s ease;
9
+ }
10
+ .htz-sub.on { display: flex; }
11
+ /* lift above the control bar while it is visible */
12
+ #movie_player:not(.ytp-autohide) .htz-sub { bottom: calc(var(--htz-bottom, 8) * 1% + 52px); }
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. */
16
+ .htz-line {
17
+ display: inline-block; max-width: 100%; padding: 0 .2em; color: #fff; line-height: 1.38; white-space: pre-wrap;
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);
28
+ }
29
+ .htz-line:empty { display: none; }
30
+ .htz-zh { font-size: max(15px, calc(var(--htz-h, 360px) * .05 * var(--htz-scale, 1))); font-weight: 500; }
31
+ .htz-en { font-size: max(12px, calc(var(--htz-h, 360px) * .036 * var(--htz-scale, 1))); color: #e8e8e8; }
32
+
33
+ .htz-hide-native .ytp-caption-window-container { display: none !important; }
34
+
35
+ .htz-badge {
36
+ position: absolute; top: 12px; left: 12px; z-index: 61; pointer-events: none;
37
+ padding: 4px 10px; border-radius: 999px; background: rgba(8, 8, 8, .75); color: #fff;
38
+ font: 500 13px/1.4 "PingFang SC", "Microsoft YaHei", Roboto, Arial, sans-serif;
39
+ opacity: 0; transition: opacity .25s;
40
+ }
41
+ .htz-badge.on { opacity: 1; }
42
+ .htz-badge.err { background: rgba(198, 40, 40, .9); }