hackertube-zh 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ body { background: var(--bg2); }
2
+ main { max-width: 720px; margin: 0 auto; padding: 32px 16px 48px; }
3
+ header { display: flex; gap: 14px; align-items: center; margin-bottom: 20px; }
4
+ header h1 { font-size: 20px; }
5
+ header .hint { margin: 2px 0 0; }
6
+ .card { background: var(--bg); border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; }
7
+ .card h2 { font-size: 15px; color: var(--text); font-weight: 600; margin-bottom: 14px; }
8
+ .field { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
9
+ .field > span:first-child { width: 88px; flex: none; color: var(--muted); }
10
+ .field .seg { flex: 1; max-width: 360px; }
11
+ .field input[type=range] { flex: 1; max-width: 300px; accent-color: var(--accent); }
12
+ .field output { width: 44px; }
13
+ .field.check { align-items: flex-start; }
14
+ .field.check span { width: auto; color: var(--text); }
15
+ .field.check .hint.block { display: block; margin-top: 4px; color: var(--muted); }
16
+ .engines { display: grid; gap: 8px; margin-bottom: 14px; }
17
+ .engine { display: grid; grid-template-columns: 20px 1fr; column-gap: 8px; padding: 10px 12px;
18
+ border: 1px solid var(--line); border-radius: 8px; cursor: pointer; }
19
+ .engine:has(input:checked) { border-color: var(--accent); }
20
+ .engine input { grid-row: span 2; margin-top: 3px; accent-color: var(--accent); }
21
+ .engine span { color: var(--muted); font-size: 13px; }
22
+ .cfg { display: none; padding: 12px; background: var(--bg2); border-radius: 8px; margin-bottom: 14px; }
23
+ .cfg.on { display: block; }
24
+ .cfg label { display: block; margin-bottom: 10px; color: var(--muted); font-size: 13px; }
25
+ .cfg label input, .cfg label select { margin-top: 4px; }
26
+ .two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
27
+ .actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
28
+ #msg, #cacheMsg { font-size: 13px; }
29
+ code { background: var(--bg2); padding: 1px 4px; border-radius: 4px; font-size: 12px; }
30
+ @media (max-width: 560px) { .two { grid-template-columns: 1fr; } .field:not(.check) { flex-wrap: wrap; } }
@@ -0,0 +1,105 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>HackerTube 中文助手 · 设置</title>
6
+ <link rel="stylesheet" href="../ui/common.css">
7
+ <link rel="stylesheet" href="options.css">
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <header>
12
+ <img src="../icons/icon128.png" alt="" width="40" height="40">
13
+ <div>
14
+ <h1>HackerTube 中文助手</h1>
15
+ <p class="hint">为 hackertube.org 提供双语界面、标题/简介/评论翻译和实时中文字幕。</p>
16
+ </div>
17
+ </header>
18
+
19
+ <section class="card">
20
+ <h2>显示</h2>
21
+ <div class="field">
22
+ <span>网页翻译</span>
23
+ <div class="seg" data-key="pageMode">
24
+ <button value="bilingual">双语对照</button><button value="zh">仅中文</button><button value="off">关闭</button>
25
+ </div>
26
+ </div>
27
+ <div class="field">
28
+ <span>视频字幕</span>
29
+ <div class="seg" data-key="subMode">
30
+ <button value="bilingual">中英双语</button><button value="zh">仅中文</button><button value="off">关闭</button>
31
+ </div>
32
+ </div>
33
+ <label class="field"><span>字幕大小</span>
34
+ <input type="range" id="subScale" min="0.7" max="1.8" step="0.1"><output id="subScaleOut"></output></label>
35
+ <label class="field"><span>字幕离底部</span>
36
+ <input type="range" id="subBottom" min="2" max="30" step="1"><output id="subBottomOut"></output></label>
37
+ <label class="field check"><input type="checkbox" id="searchTranslate">
38
+ <span>搜索框输入中文时,先翻译成英文再搜索(HackerTube 的搜索只理解英文)</span></label>
39
+ </section>
40
+
41
+ <section class="card">
42
+ <h2>播放</h2>
43
+ <label class="field check"><input type="checkbox" id="embedLogin">
44
+ <span>用我的 YouTube 登录状态播放
45
+ <span class="hint block">HackerTube 用 youtube-nocookie.com 嵌入视频,这个域名读不到你的 YouTube 登录,网络环境被 YouTube 怀疑时(常见于代理 / VPN 节点)就会一直提示 “Sign in to confirm you're not a bot”。
46
+ 打开后,HackerTube 里的视频改由 youtube.com 播放,能用上你已登录的账号,中文字幕照常显示。
47
+ 代价:观看会计入你的 YouTube 历史记录。需要 Chrome 允许第三方 Cookie(无痕窗口默认不允许)。开启后刷新视频页生效。</span></span></label>
48
+ </section>
49
+
50
+ <section class="card">
51
+ <h2>翻译引擎</h2>
52
+ <div class="engines">
53
+ <label class="engine"><input type="radio" name="engine" value="google">
54
+ <b>Google 翻译</b><span>免费、无需配置、速度快。需要能访问 translate.googleapis.com。</span></label>
55
+ <label class="engine"><input type="radio" name="engine" value="claude">
56
+ <b>Claude</b><span>理解上下文,字幕和评论翻得更自然。按 Anthropic API 用量计费。</span></label>
57
+ <label class="engine"><input type="radio" name="engine" value="openai">
58
+ <b>OpenAI 兼容接口</b><span>OpenAI、DeepSeek、通义千问、Moonshot、本地 Ollama 等任何 /chat/completions 接口。</span></label>
59
+ </div>
60
+
61
+ <div class="cfg" data-for="claude">
62
+ <label>Claude API Key<input type="password" id="claudeKey" placeholder="sk-ant-…" autocomplete="off"></label>
63
+ <div class="two">
64
+ <label>模型<input type="text" id="claudeModel" list="claudeModels"></label>
65
+ <label>思考力度(effort)
66
+ <select id="claudeEffort">
67
+ <option value="low">low(推荐,翻译够用且最快)</option>
68
+ <option value="medium">medium</option>
69
+ <option value="high">high</option>
70
+ <option value="">模型默认</option>
71
+ </select></label>
72
+ </div>
73
+ <datalist id="claudeModels">
74
+ <option value="claude-opus-5-5"><option value="claude-sonnet-5-5"><option value="claude-haiku-5-5">
75
+ </datalist>
76
+ <p class="hint">Key 只保存在本机(chrome.storage.local),不会同步,也只发送给 api.anthropic.com。</p>
77
+ </div>
78
+
79
+ <div class="cfg" data-for="openai">
80
+ <label>接口地址(Base URL)<input type="url" id="openaiBaseUrl" placeholder="https://api.openai.com/v1"></label>
81
+ <div class="two">
82
+ <label>模型<input type="text" id="openaiModel" placeholder="例如 deepseek-chat"></label>
83
+ <label>API Key<input type="password" id="openaiKey" placeholder="本地 Ollama 可留空" autocomplete="off"></label>
84
+ </div>
85
+ <p class="hint">示例:DeepSeek <code>https://api.deepseek.com/v1</code> + <code>deepseek-chat</code>;
86
+ Ollama <code>http://localhost:11434/v1</code> + 本地模型名。保存时浏览器会请求访问该地址的权限。</p>
87
+ </div>
88
+
89
+ <div class="actions">
90
+ <button class="primary" id="save">保存</button>
91
+ <button class="plain" id="test">测试当前引擎</button>
92
+ <span id="msg"></span>
93
+ </div>
94
+ </section>
95
+
96
+ <section class="card">
97
+ <h2>缓存</h2>
98
+ <p>已缓存 <b id="cacheCount">…</b> 条译文。看过的视频和页面再次打开时直接使用缓存,不再请求翻译。</p>
99
+ <div class="actions"><button class="plain" id="clearCache">清空缓存</button><span id="cacheMsg"></span></div>
100
+ </section>
101
+ </main>
102
+ <script src="../lib/runtime.js"></script>
103
+ <script src="options.js"></script>
104
+ </body>
105
+ </html>
@@ -0,0 +1,99 @@
1
+ const R = globalThis.HTZ.runtime;
2
+ const $ = (s) => document.querySelector(s);
3
+ const send = (msg) => new Promise((resolve) => chrome.runtime.sendMessage(msg, resolve));
4
+
5
+ function setMsg(el, text, cls = "") { el.textContent = text; el.className = cls; }
6
+
7
+ function paintSegs(s) {
8
+ for (const seg of document.querySelectorAll(".seg")) {
9
+ for (const b of seg.querySelectorAll("button")) b.classList.toggle("on", b.value === s[seg.dataset.key]);
10
+ }
11
+ }
12
+
13
+ function paint(s, secrets) {
14
+ paintSegs(s);
15
+ $("#subScale").value = s.subScale;
16
+ $("#subScaleOut").textContent = `${Math.round(s.subScale * 100)}%`;
17
+ $("#subBottom").value = s.subBottom;
18
+ $("#subBottomOut").textContent = `${s.subBottom}%`;
19
+ $("#searchTranslate").checked = s.searchTranslate;
20
+ $("#embedLogin").checked = s.embedLogin;
21
+ const radio = document.querySelector(`input[name=engine][value="${s.engine}"]`);
22
+ if (radio) radio.checked = true;
23
+ showCfg(s.engine);
24
+ $("#claudeModel").value = s.claudeModel;
25
+ $("#claudeEffort").value = s.claudeEffort;
26
+ $("#openaiBaseUrl").value = s.openaiBaseUrl;
27
+ $("#openaiModel").value = s.openaiModel;
28
+ if (secrets) {
29
+ $("#claudeKey").value = secrets.claudeKey || "";
30
+ $("#openaiKey").value = secrets.openaiKey || "";
31
+ }
32
+ }
33
+
34
+ const showCfg = (engine) => document.querySelectorAll(".cfg").forEach((c) => c.classList.toggle("on", c.dataset.for === engine));
35
+ const selectedEngine = () => document.querySelector("input[name=engine]:checked")?.value || "google";
36
+
37
+ async function refreshCache() {
38
+ const r = await send({ type: "cacheStats" });
39
+ $("#cacheCount").textContent = r && r.ok ? r.entries.toLocaleString("zh-CN") : "?";
40
+ }
41
+
42
+ // Display settings apply immediately; engine settings wait for 保存 because they need keys and permissions.
43
+ for (const seg of document.querySelectorAll(".seg")) {
44
+ seg.addEventListener("click", async (e) => {
45
+ const b = e.target.closest("button");
46
+ if (b) paintSegs(await R.saveSettings({ [seg.dataset.key]: b.value }));
47
+ });
48
+ }
49
+ for (const [id, fmt] of [["subScale", (v) => `${Math.round(v * 100)}%`], ["subBottom", (v) => `${v}%`]]) {
50
+ $(`#${id}`).addEventListener("input", (e) => { $(`#${id}Out`).textContent = fmt(+e.target.value); });
51
+ $(`#${id}`).addEventListener("change", (e) => R.saveSettings({ [id]: +e.target.value }));
52
+ }
53
+ $("#searchTranslate").addEventListener("change", (e) => R.saveSettings({ searchTranslate: e.target.checked }));
54
+ $("#embedLogin").addEventListener("change", (e) => R.saveSettings({ embedLogin: e.target.checked }));
55
+ document.querySelectorAll("input[name=engine]").forEach((r) => r.addEventListener("change", () => showCfg(selectedEngine())));
56
+
57
+ async function save() {
58
+ const msg = $("#msg");
59
+ const engine = selectedEngine();
60
+ const patch = {
61
+ engine,
62
+ claudeModel: $("#claudeModel").value.trim() || R.DEFAULTS.claudeModel,
63
+ claudeEffort: $("#claudeEffort").value,
64
+ openaiBaseUrl: $("#openaiBaseUrl").value.trim().replace(/\/+$/, ""),
65
+ openaiModel: $("#openaiModel").value.trim(),
66
+ };
67
+ if (engine === "claude" && !$("#claudeKey").value.trim()) return setMsg(msg, "请填写 Claude API Key", "err"), false;
68
+ if (engine === "openai") {
69
+ let url;
70
+ try { url = new URL(patch.openaiBaseUrl); } catch { return setMsg(msg, "接口地址不是有效的网址", "err"), false; }
71
+ if (!patch.openaiModel) return setMsg(msg, "请填写模型名", "err"), false;
72
+ // the service worker can only reach hosts the user has granted; ask for exactly this one
73
+ const origin = `${url.protocol}//${url.hostname}/*`;
74
+ const granted = await chrome.permissions.request({ origins: [origin] }).catch(() => false);
75
+ if (!granted) return setMsg(msg, `需要授权访问 ${url.hostname} 才能使用该接口`, "err"), false;
76
+ }
77
+ await R.saveSecrets({ claudeKey: $("#claudeKey").value.trim(), openaiKey: $("#openaiKey").value.trim() });
78
+ await R.saveSettings(patch);
79
+ setMsg(msg, "已保存", "ok");
80
+ return true;
81
+ }
82
+
83
+ $("#save").addEventListener("click", save);
84
+ $("#test").addEventListener("click", async () => {
85
+ const msg = $("#msg");
86
+ if (!(await save())) return;
87
+ setMsg(msg, "测试中…");
88
+ const r = await send({ type: "testEngine" });
89
+ if (r && r.ok) setMsg(msg, `${r.engine} 可用(${r.ms} ms):${r.translations.join(" / ")}`, "ok");
90
+ else setMsg(msg, `失败:${r ? r.error : "无响应"}`, "err");
91
+ });
92
+ $("#clearCache").addEventListener("click", async () => {
93
+ const r = await send({ type: "clearCache" });
94
+ setMsg($("#cacheMsg"), r && r.ok ? `已清除 ${r.removed} 条` : "清除失败", r && r.ok ? "ok" : "err");
95
+ refreshCache();
96
+ });
97
+
98
+ Promise.all([R.loadSettings(), R.loadSecrets()]).then(([s, secrets]) => paint(s, secrets));
99
+ refreshCache();
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "hackertube-zh",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "HackerTube 中文助手 Chrome 扩展:双语界面、标题评论翻译、实时中文字幕。npx hackertube-zh 一行安装。",
5
+ "keywords": ["hackertube", "chrome-extension", "translation", "subtitles", "chinese"],
6
+ "bin": {
7
+ "hackertube-zh": "bin/install.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "manifest.json",
12
+ "background.js",
13
+ "lib",
14
+ "content",
15
+ "popup",
16
+ "options",
17
+ "ui",
18
+ "icons"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "publishConfig": {
24
+ "registry": "https://registry.npmjs.org/"
25
+ },
26
+ "scripts": {
27
+ "test": "node --test \"tests/*.test.js\"",
28
+ "check": "node scripts/check.js",
29
+ "icons": "python3 scripts/make_icons.py",
30
+ "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
+ }
32
+ }
@@ -0,0 +1,9 @@
1
+ body { width: 320px; padding: 14px 16px 12px; }
2
+ header { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
3
+ section { margin-bottom: 14px; }
4
+ .row { display: flex; align-items: center; gap: 8px; margin-top: 10px; color: var(--muted); font-size: 13px; }
5
+ .row input { flex: 1; accent-color: var(--accent); }
6
+ .row output { width: 34px; text-align: right; color: var(--text); }
7
+ .toggle { display: flex; align-items: center; gap: 8px; margin-top: 12px; cursor: pointer; }
8
+ .toggle input { accent-color: var(--accent); margin: 0; }
9
+ footer { display: flex; justify-content: space-between; border-top: 1px solid var(--line); padding-top: 10px; font-size: 13px; }
@@ -0,0 +1,52 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>HackerTube 中文助手</title>
6
+ <link rel="stylesheet" href="../ui/common.css">
7
+ <link rel="stylesheet" href="popup.css">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <img src="../icons/icon48.png" alt="" width="24" height="24">
12
+ <h1>HackerTube 中文助手</h1>
13
+ </header>
14
+
15
+ <section>
16
+ <h2>网页翻译</h2>
17
+ <div class="seg" data-key="pageMode">
18
+ <button value="bilingual">双语对照</button><button value="zh">仅中文</button><button value="off">关闭</button>
19
+ </div>
20
+ </section>
21
+
22
+ <section>
23
+ <h2>视频字幕</h2>
24
+ <div class="seg" data-key="subMode">
25
+ <button value="bilingual">中英双语</button><button value="zh">仅中文</button><button value="off">关闭</button>
26
+ </div>
27
+ <label class="row">字幕大小
28
+ <input type="range" id="subScale" min="0.7" max="1.8" step="0.1">
29
+ <output id="subScaleOut"></output>
30
+ </label>
31
+ <label class="toggle"><input type="checkbox" id="embedLogin"><span>用我的 YouTube 登录状态播放</span></label>
32
+ <p class="hint">播放器提示 “Sign in to confirm you're not a bot” 时打开。观看会计入你的 YouTube 历史记录,开启后刷新视频页生效。</p>
33
+ </section>
34
+
35
+ <section>
36
+ <h2>翻译引擎</h2>
37
+ <select id="engine">
38
+ <option value="google">Google 翻译(免费,无需配置)</option>
39
+ <option value="claude">Claude(需 API Key)</option>
40
+ <option value="openai">OpenAI 兼容接口(DeepSeek / 通义 / Ollama…)</option>
41
+ </select>
42
+ <p class="hint" id="engineHint"></p>
43
+ </section>
44
+
45
+ <footer>
46
+ <a href="#" id="openOptions">更多设置</a>
47
+ <a href="https://hackertube.org" target="_blank" rel="noopener">打开 HackerTube</a>
48
+ </footer>
49
+ <script src="../lib/runtime.js"></script>
50
+ <script src="popup.js"></script>
51
+ </body>
52
+ </html>
package/popup/popup.js ADDED
@@ -0,0 +1,43 @@
1
+ const R = globalThis.HTZ.runtime;
2
+ const $ = (s) => document.querySelector(s);
3
+
4
+ async function engineHint(s) {
5
+ const secrets = await R.loadSecrets();
6
+ const el = $("#engineHint");
7
+ el.className = "hint";
8
+ if (s.engine === "google") el.textContent = "免费可用。需要能访问 translate.googleapis.com 的网络。";
9
+ else if (s.engine === "claude") {
10
+ el.textContent = secrets.claudeKey ? `模型:${s.claudeModel}` : "还没有填写 Claude API Key,请到“更多设置”中填写。";
11
+ if (!secrets.claudeKey) el.classList.add("err");
12
+ } else {
13
+ const ready = s.openaiBaseUrl && s.openaiModel;
14
+ el.textContent = ready ? `${s.openaiModel} @ ${new URL(s.openaiBaseUrl).host}` : "还没有配置接口地址和模型,请到“更多设置”中填写。";
15
+ if (!ready) el.classList.add("err");
16
+ }
17
+ }
18
+
19
+ function paint(s) {
20
+ for (const seg of document.querySelectorAll(".seg")) {
21
+ for (const b of seg.querySelectorAll("button")) b.classList.toggle("on", b.value === s[seg.dataset.key]);
22
+ }
23
+ $("#subScale").value = s.subScale;
24
+ $("#subScaleOut").textContent = `${Math.round(s.subScale * 100)}%`;
25
+ $("#engine").value = s.engine;
26
+ $("#embedLogin").checked = s.embedLogin;
27
+ engineHint(s);
28
+ }
29
+
30
+ R.loadSettings().then(paint);
31
+
32
+ for (const seg of document.querySelectorAll(".seg")) {
33
+ seg.addEventListener("click", async (e) => {
34
+ const b = e.target.closest("button");
35
+ if (b) paint(await R.saveSettings({ [seg.dataset.key]: b.value }));
36
+ });
37
+ }
38
+ // label follows the drag; the setting is written once on release (storage.sync limits writes per minute)
39
+ $("#subScale").addEventListener("input", (e) => { $("#subScaleOut").textContent = `${Math.round(e.target.value * 100)}%`; });
40
+ $("#subScale").addEventListener("change", async (e) => paint(await R.saveSettings({ subScale: +e.target.value })));
41
+ $("#engine").addEventListener("change", async (e) => paint(await R.saveSettings({ engine: e.target.value })));
42
+ $("#embedLogin").addEventListener("change", async (e) => paint(await R.saveSettings({ embedLogin: e.target.checked })));
43
+ $("#openOptions").addEventListener("click", (e) => { e.preventDefault(); chrome.runtime.openOptionsPage(); });
package/ui/common.css ADDED
@@ -0,0 +1,41 @@
1
+ :root {
2
+ --bg: #ffffff; --bg2: #f2f2f2; --text: #0f0f0f; --muted: #606060; --line: #e5e5e5;
3
+ --accent: #ff6600; --on: #0f0f0f; --on-text: #ffffff; --err: #c62828; --ok: #2e7d32;
4
+ color-scheme: light dark;
5
+ }
6
+ @media (prefers-color-scheme: dark) {
7
+ :root {
8
+ --bg: #181818; --bg2: #272727; --text: #f1f1f1; --muted: #aaaaaa; --line: #333333;
9
+ --on: #f1f1f1; --on-text: #0f0f0f; --err: #ef5350; --ok: #66bb6a;
10
+ }
11
+ }
12
+ * { box-sizing: border-box; }
13
+ body {
14
+ margin: 0; background: var(--bg); color: var(--text);
15
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
16
+ }
17
+ h1 { font-size: 16px; margin: 0; font-weight: 600; }
18
+ h2 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 500; }
19
+ a { color: var(--accent); text-decoration: none; }
20
+ a:hover { text-decoration: underline; }
21
+
22
+ .seg { display: flex; background: var(--bg2); border-radius: 8px; padding: 3px; gap: 3px; }
23
+ .seg button {
24
+ flex: 1; border: 0; background: transparent; color: var(--text); padding: 6px 4px; border-radius: 6px;
25
+ font: inherit; cursor: pointer;
26
+ }
27
+ .seg button:hover { background: rgba(127,127,127,.15); }
28
+ .seg button.on { background: var(--on); color: var(--on-text); }
29
+
30
+ select, input[type=text], input[type=password], input[type=url] {
31
+ width: 100%; padding: 7px 9px; border: 1px solid var(--line); border-radius: 6px;
32
+ background: var(--bg); color: var(--text); font: inherit;
33
+ }
34
+ button.primary, button.plain {
35
+ border: 1px solid var(--line); border-radius: 6px; padding: 6px 14px; font: inherit; cursor: pointer;
36
+ background: var(--bg2); color: var(--text);
37
+ }
38
+ button.primary { background: var(--on); color: var(--on-text); border-color: var(--on); }
39
+ .hint { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
40
+ .err { color: var(--err); }
41
+ .ok { color: var(--ok); }