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 +21 -0
- package/README.md +169 -2
- package/background.js +140 -0
- package/bin/install.js +141 -0
- package/content/player-main.js +136 -0
- package/content/player.css +42 -0
- package/content/player.js +221 -0
- package/content/site.css +31 -0
- package/content/site.js +291 -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/embedlogin.js +32 -0
- package/lib/engines.js +193 -0
- package/lib/runtime.js +65 -0
- package/lib/subtitles.js +210 -0
- package/lib/textutil.js +40 -0
- package/lib/uidict.js +105 -0
- package/manifest.json +60 -0
- package/options/options.css +30 -0
- package/options/options.html +111 -0
- package/options/options.js +99 -0
- package/package.json +40 -4
- package/popup/popup.css +11 -0
- package/popup/popup.html +55 -0
- package/popup/popup.js +43 -0
- package/ui/common.css +41 -0
package/lib/uidict.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// HackerTube's own interface strings (menus, filters, buttons, counts, dates), translated locally and instantly.
|
|
2
|
+
// Content written per video (titles, summaries, comments) goes through a translation engine instead.
|
|
3
|
+
(function (root, factory) {
|
|
4
|
+
const api = factory();
|
|
5
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
6
|
+
else (root.HTZ = root.HTZ || {}).uidict = api;
|
|
7
|
+
})(globalThis, function () {
|
|
8
|
+
const EXACT = {
|
|
9
|
+
// sidebar
|
|
10
|
+
Home: "首页", Longs: "长视频", Liked: "赞过的视频", History: "历史记录", "Watch later": "稍后观看",
|
|
11
|
+
Categories: "分类", Playlists: "播放列表", Settings: "设置",
|
|
12
|
+
// filters
|
|
13
|
+
Filters: "筛选", "Upload date": "上传日期", "Past year": "过去一年", Type: "类型", Duration: "时长",
|
|
14
|
+
Features: "特色", "Sort by": "排序方式", Talk: "演讲", Lecture: "讲座", Explainer: "讲解", Tutorial: "教程",
|
|
15
|
+
Documentary: "纪录片", Interview: "访谈", Demo: "演示", Commentary: "评论", Comedy: "喜剧", Music: "音乐",
|
|
16
|
+
"Under 4 minutes": "4 分钟以内", "4–20 minutes": "4–20 分钟", "20–60 minutes": "20–60 分钟", "Over 1 hour": "1 小时以上",
|
|
17
|
+
"Hidden gems": "冷门佳作", "Evergreen only": "仅常青内容", Relevance: "相关性", "Recently recommended": "最近推荐",
|
|
18
|
+
"Oldest first": "最早优先", "View count": "观看次数", "Like ratio": "点赞率", "Most recommended": "推荐最多",
|
|
19
|
+
"Best first": "最佳优先", "Play all on YouTube": "在 YouTube 上全部播放", "All videos": "全部视频",
|
|
20
|
+
// pages and buttons
|
|
21
|
+
"Liked videos": "赞过的视频", "Browse all videos": "浏览全部视频", "Clear all watch history": "清除全部观看记录",
|
|
22
|
+
"Every video over an hour. The opposite of Shorts.": "所有超过一小时的视频。Shorts 的反面。",
|
|
23
|
+
"Start here": "从这里开始", "Deep dives": "深入专题", "Broad, essential lists for your first visit.": "首次来访的入门必看清单。",
|
|
24
|
+
"One obsession each, for when you want to go down a rabbit hole.": "每个专题一个主题,适合想深挖的时候。",
|
|
25
|
+
"Coming soon.": "即将推出。", Like: "顶", Saved: "已保存", YouTube: "YouTube", "View on HN": "在 HN 查看",
|
|
26
|
+
"Strongly recommends": "强烈推荐", Recommends: "推荐", Cites: "引用", Joke: "玩笑", Critical: "批评",
|
|
27
|
+
"▶ this video": "▶ 本视频", link: "链接", Search: "搜索",
|
|
28
|
+
"Loading…": "加载中…", "Searching…": "搜索中…", "Not found.": "未找到。", "Nothing here yet.": "这里还没有内容。",
|
|
29
|
+
"No videos match these filters.": "没有符合这些筛选条件的视频。",
|
|
30
|
+
"Lots of searches right now. Give it a minute and try again.": "当前搜索的人很多,请稍等一分钟再试。",
|
|
31
|
+
"Videos you open will show up here.": "你打开过的视频会显示在这里。",
|
|
32
|
+
"Videos you like will show up here, and shape your Home feed.": "你赞过的视频会显示在这里,并影响首页推荐。",
|
|
33
|
+
"Save videos to watch later with the clock button on a thumbnail or on a video's page.": "用缩略图或视频页上的时钟按钮,把视频存到稍后观看。",
|
|
34
|
+
// toasts and the shortcuts dialog
|
|
35
|
+
"Prompt copied": "提示词已复制", "Added to Liked": "已加入赞过的视频", "Removed from Liked": "已从赞过的视频中移除",
|
|
36
|
+
"Saved to Watch later": "已存到稍后观看", "Removed from Watch later": "已从稍后观看中移除",
|
|
37
|
+
"Keyboard shortcuts": "键盘快捷键", "Next / previous video": "下一个 / 上一个视频", "Open the selected video": "打开选中的视频",
|
|
38
|
+
"Like the selected or current video": "顶选中或当前的视频", "Watch later for the selected or current video": "将选中或当前视频存到稍后观看",
|
|
39
|
+
"Play (on a video page)": "播放(视频页)", "Show these shortcuts": "显示快捷键", "Close / leave the search box": "关闭 / 离开搜索框",
|
|
40
|
+
// about page stats
|
|
41
|
+
videos: "个视频", "HN comments read": "条 HN 评论已读", "years of HN": "年的 HN",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Words that appear as metadata tokens (content type, shelf life), shown lowercase on the video page.
|
|
45
|
+
const TOKENS = {
|
|
46
|
+
talk: "演讲", lecture: "讲座", explainer: "讲解", tutorial: "教程", documentary: "纪录片", interview: "访谈",
|
|
47
|
+
demo: "演示", commentary: "评论", comedy: "喜剧", music: "音乐", other: "其他",
|
|
48
|
+
evergreen: "常青", dated: "有时效", timely: "时效性", news: "新闻",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function views(num, unit) {
|
|
52
|
+
const n = parseFloat(num) * ({ K: 1e3, M: 1e6, B: 1e9 }[unit] || 1);
|
|
53
|
+
if (n >= 1e8) return `${+(n / 1e8).toFixed(1)}亿次观看`;
|
|
54
|
+
if (n >= 1e4) return `${+(n / 1e4).toFixed(1)}万次观看`;
|
|
55
|
+
return `${Math.round(n)}次观看`;
|
|
56
|
+
}
|
|
57
|
+
const AGO = { year: "年", month: "个月", week: "周", day: "天" };
|
|
58
|
+
const hrmin = (s) => s.replace(/(\d+) hr/, "$1 小时").replace(/(\d+) min/, "$1 分钟");
|
|
59
|
+
|
|
60
|
+
// One metadata piece such as "196K views", "7 years ago", "lecture".
|
|
61
|
+
function piece(p) {
|
|
62
|
+
let m;
|
|
63
|
+
if ((m = /^([\d.]+)([KMB]?) views?$/.exec(p))) return views(m[1], m[2]);
|
|
64
|
+
if ((m = /^(\d+) (year|month|week|day)s? ago$/.exec(p))) return `${m[1]} ${AGO[m[2]]}前`;
|
|
65
|
+
if (p === "today") return "今天";
|
|
66
|
+
if (TOKENS[p]) return TOKENS[p];
|
|
67
|
+
if ((m = /^(\d+) videos?$/.exec(p))) return `${m[1]} 个视频`;
|
|
68
|
+
if (/^(\d+ hr)?\s*(\d+ min)?$/.test(p) && /\d/.test(p)) return hrmin(p);
|
|
69
|
+
if (p === "Curated by Claude") return "由 Claude 精选";
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const PATTERNS = [
|
|
74
|
+
// "3 HN recommenders · recommended in 4 months, 2015–2023"
|
|
75
|
+
[/^(\d+) HN recommenders? · recommended in (\d+) months?, (.+)$/, (m) => `${m[1]} 位 HN 用户推荐 · 在 ${m[2]} 个不同月份被推荐,${m[3]}`],
|
|
76
|
+
[/^Cited (\d+)× on HN, (.+)$/, (m) => `在 HN 被引用 ${m[1]} 次,${m[2]}`],
|
|
77
|
+
[/^(\d+) comments? from Hacker News$/, (m) => `来自 Hacker News 的 ${m[1]} 条评论`],
|
|
78
|
+
[/^(\d{4}) · in “(.*)”$/, (m) => `${m[1]} · 发表于「${m[2]}」`],
|
|
79
|
+
[/^Topic match · (.+)$/, (m) => `主题匹配 · ${m[1]}`],
|
|
80
|
+
[/^topics in (\d+) areas$/, (m) => `个主题,分属 ${m[1]} 个领域`],
|
|
81
|
+
[/^No videos match “(.*)” with these filters\.$/, (m) => `没有符合筛选条件的“${m[1]}”相关视频。`],
|
|
82
|
+
[/^No strong matches for “(.*)”\. Here are the closest videos\.$/, (m) => `没有与“${m[1]}”高度匹配的视频,以下是最接近的结果。`],
|
|
83
|
+
[/^Something went wrong \((.*)\)\.$/, (m) => `出错了(${m[1]})。`],
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// Returns the Chinese for a whole UI string, or null when the string isn't one we know.
|
|
87
|
+
function localize(text) {
|
|
88
|
+
const t = text.trim();
|
|
89
|
+
if (!t) return null;
|
|
90
|
+
if (EXACT[t]) return EXACT[t];
|
|
91
|
+
for (const [re, fn] of PATTERNS) { const m = re.exec(t); if (m) return fn(m); }
|
|
92
|
+
// metadata lines joined by " • " or " · ": translate only if every piece is known
|
|
93
|
+
const parts = t.split(/\s+([•·])\s+/);
|
|
94
|
+
const out = [];
|
|
95
|
+
for (let i = 0; i < parts.length; i++) {
|
|
96
|
+
if (i % 2) { out.push(` ${parts[i]} `); continue; }
|
|
97
|
+
const z = piece(parts[i]);
|
|
98
|
+
if (z == null) return null;
|
|
99
|
+
out.push(z);
|
|
100
|
+
}
|
|
101
|
+
return out.join("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { localize, EXACT };
|
|
105
|
+
});
|
package/manifest.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "HackerTube 中文助手",
|
|
4
|
+
"short_name": "HackerTube 中文",
|
|
5
|
+
"version": "0.1.2",
|
|
6
|
+
"homepage_url": "https://github.com/crisxuan/hackertube-zh",
|
|
7
|
+
"description": "为 HackerTube 提供实时中文:双语界面,视频标题、简介和 HN 评论翻译,以及嵌入视频的实时中英双语字幕。",
|
|
8
|
+
"minimum_chrome_version": "116",
|
|
9
|
+
"icons": {
|
|
10
|
+
"16": "icons/icon16.png",
|
|
11
|
+
"32": "icons/icon32.png",
|
|
12
|
+
"48": "icons/icon48.png",
|
|
13
|
+
"128": "icons/icon128.png"
|
|
14
|
+
},
|
|
15
|
+
"permissions": ["storage", "unlimitedStorage", "declarativeNetRequestWithHostAccess"],
|
|
16
|
+
"host_permissions": [
|
|
17
|
+
"https://hackertube.org/*",
|
|
18
|
+
"https://www.hackertube.org/*",
|
|
19
|
+
"https://www.youtube-nocookie.com/*",
|
|
20
|
+
"https://translate.googleapis.com/*",
|
|
21
|
+
"https://api.anthropic.com/*",
|
|
22
|
+
"https://api.openai.com/*"
|
|
23
|
+
],
|
|
24
|
+
"optional_host_permissions": ["https://*/*", "http://*/*"],
|
|
25
|
+
"background": {
|
|
26
|
+
"service_worker": "background.js",
|
|
27
|
+
"type": "module"
|
|
28
|
+
},
|
|
29
|
+
"content_scripts": [
|
|
30
|
+
{
|
|
31
|
+
"matches": ["https://hackertube.org/*", "https://www.hackertube.org/*"],
|
|
32
|
+
"js": ["lib/textutil.js", "lib/uidict.js", "lib/runtime.js", "content/site.js"],
|
|
33
|
+
"css": ["content/site.css"],
|
|
34
|
+
"run_at": "document_idle"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"matches": ["https://www.youtube-nocookie.com/embed/*", "https://www.youtube.com/embed/*"],
|
|
38
|
+
"js": ["content/player-main.js"],
|
|
39
|
+
"world": "MAIN",
|
|
40
|
+
"all_frames": true,
|
|
41
|
+
"run_at": "document_start"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"matches": ["https://www.youtube-nocookie.com/embed/*", "https://www.youtube.com/embed/*"],
|
|
45
|
+
"js": ["lib/subtitles.js", "lib/runtime.js", "content/player.js"],
|
|
46
|
+
"css": ["content/player.css"],
|
|
47
|
+
"all_frames": true,
|
|
48
|
+
"run_at": "document_start"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"action": {
|
|
52
|
+
"default_title": "HackerTube 中文助手",
|
|
53
|
+
"default_popup": "popup/popup.html",
|
|
54
|
+
"default_icon": {
|
|
55
|
+
"16": "icons/icon16.png",
|
|
56
|
+
"32": "icons/icon32.png"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"options_page": "options/options.html"
|
|
60
|
+
}
|
|
@@ -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,111 @@
|
|
|
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
|
+
<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>
|
|
41
|
+
<label class="field"><span>字幕离底部</span>
|
|
42
|
+
<input type="range" id="subBottom" min="2" max="30" step="1"><output id="subBottomOut"></output></label>
|
|
43
|
+
<label class="field check"><input type="checkbox" id="searchTranslate">
|
|
44
|
+
<span>搜索框输入中文时,先翻译成英文再搜索(HackerTube 的搜索只理解英文)</span></label>
|
|
45
|
+
</section>
|
|
46
|
+
|
|
47
|
+
<section class="card">
|
|
48
|
+
<h2>播放</h2>
|
|
49
|
+
<label class="field check"><input type="checkbox" id="embedLogin">
|
|
50
|
+
<span>用我的 YouTube 登录状态播放
|
|
51
|
+
<span class="hint block">HackerTube 用 youtube-nocookie.com 嵌入视频,这个域名读不到你的 YouTube 登录,网络环境被 YouTube 怀疑时(常见于代理 / VPN 节点)就会一直提示 “Sign in to confirm you're not a bot”。
|
|
52
|
+
打开后,HackerTube 里的视频改由 youtube.com 播放,能用上你已登录的账号,中文字幕照常显示。
|
|
53
|
+
代价:观看会计入你的 YouTube 历史记录。需要 Chrome 允许第三方 Cookie(无痕窗口默认不允许)。开启后刷新视频页生效。</span></span></label>
|
|
54
|
+
</section>
|
|
55
|
+
|
|
56
|
+
<section class="card">
|
|
57
|
+
<h2>翻译引擎</h2>
|
|
58
|
+
<div class="engines">
|
|
59
|
+
<label class="engine"><input type="radio" name="engine" value="google">
|
|
60
|
+
<b>Google 翻译</b><span>免费、无需配置、速度快。需要能访问 translate.googleapis.com。</span></label>
|
|
61
|
+
<label class="engine"><input type="radio" name="engine" value="claude">
|
|
62
|
+
<b>Claude</b><span>理解上下文,字幕和评论翻得更自然。按 Anthropic API 用量计费。</span></label>
|
|
63
|
+
<label class="engine"><input type="radio" name="engine" value="openai">
|
|
64
|
+
<b>OpenAI 兼容接口</b><span>OpenAI、DeepSeek、通义千问、Moonshot、本地 Ollama 等任何 /chat/completions 接口。</span></label>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="cfg" data-for="claude">
|
|
68
|
+
<label>Claude API Key<input type="password" id="claudeKey" placeholder="sk-ant-…" autocomplete="off"></label>
|
|
69
|
+
<div class="two">
|
|
70
|
+
<label>模型<input type="text" id="claudeModel" list="claudeModels"></label>
|
|
71
|
+
<label>思考力度(effort)
|
|
72
|
+
<select id="claudeEffort">
|
|
73
|
+
<option value="low">low(推荐,翻译够用且最快)</option>
|
|
74
|
+
<option value="medium">medium</option>
|
|
75
|
+
<option value="high">high</option>
|
|
76
|
+
<option value="">模型默认</option>
|
|
77
|
+
</select></label>
|
|
78
|
+
</div>
|
|
79
|
+
<datalist id="claudeModels">
|
|
80
|
+
<option value="claude-opus-5-5"><option value="claude-sonnet-5-5"><option value="claude-haiku-5-5">
|
|
81
|
+
</datalist>
|
|
82
|
+
<p class="hint">Key 只保存在本机(chrome.storage.local),不会同步,也只发送给 api.anthropic.com。</p>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="cfg" data-for="openai">
|
|
86
|
+
<label>接口地址(Base URL)<input type="url" id="openaiBaseUrl" placeholder="https://api.openai.com/v1"></label>
|
|
87
|
+
<div class="two">
|
|
88
|
+
<label>模型<input type="text" id="openaiModel" placeholder="例如 deepseek-chat"></label>
|
|
89
|
+
<label>API Key<input type="password" id="openaiKey" placeholder="本地 Ollama 可留空" autocomplete="off"></label>
|
|
90
|
+
</div>
|
|
91
|
+
<p class="hint">示例:DeepSeek <code>https://api.deepseek.com/v1</code> + <code>deepseek-chat</code>;
|
|
92
|
+
Ollama <code>http://localhost:11434/v1</code> + 本地模型名。保存时浏览器会请求访问该地址的权限。</p>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="actions">
|
|
96
|
+
<button class="primary" id="save">保存</button>
|
|
97
|
+
<button class="plain" id="test">测试当前引擎</button>
|
|
98
|
+
<span id="msg"></span>
|
|
99
|
+
</div>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<section class="card">
|
|
103
|
+
<h2>缓存</h2>
|
|
104
|
+
<p>已缓存 <b id="cacheCount">…</b> 条译文。看过的视频和页面再次打开时直接使用缓存,不再请求翻译。</p>
|
|
105
|
+
<div class="actions"><button class="plain" id="clearCache">清空缓存</button><span id="cacheMsg"></span></div>
|
|
106
|
+
</section>
|
|
107
|
+
</main>
|
|
108
|
+
<script src="../lib/runtime.js"></script>
|
|
109
|
+
<script src="options.js"></script>
|
|
110
|
+
</body>
|
|
111
|
+
</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,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hackertube-zh",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "HackerTube 中文助手 Chrome 扩展:双语界面、标题评论翻译、实时中文字幕。npx hackertube-zh 一行安装。",
|
|
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
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"hackertube-zh": "bin/install.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"manifest.json",
|
|
22
|
+
"background.js",
|
|
23
|
+
"lib",
|
|
24
|
+
"content",
|
|
25
|
+
"popup",
|
|
26
|
+
"options",
|
|
27
|
+
"ui",
|
|
28
|
+
"icons"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"registry": "https://registry.npmjs.org/"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test \"tests/*.test.js\"",
|
|
38
|
+
"check": "node scripts/check.js",
|
|
39
|
+
"icons": "node scripts/render-icons.js",
|
|
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"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/popup/popup.css
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
.row .seg { flex: 1; font-size: 13px; }
|
|
8
|
+
.row .seg button { padding: 4px; }
|
|
9
|
+
.toggle { display: flex; align-items: center; gap: 8px; margin-top: 12px; cursor: pointer; }
|
|
10
|
+
.toggle input { accent-color: var(--accent); margin: 0; }
|
|
11
|
+
footer { display: flex; justify-content: space-between; border-top: 1px solid var(--line); padding-top: 10px; font-size: 13px; }
|
package/popup/popup.html
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
<div class="row">字幕样式
|
|
32
|
+
<div class="seg" data-key="subStyle"><button value="outline">描边</button><button value="box">半透明底框</button></div>
|
|
33
|
+
</div>
|
|
34
|
+
<label class="toggle"><input type="checkbox" id="embedLogin"><span>用我的 YouTube 登录状态播放</span></label>
|
|
35
|
+
<p class="hint">播放器提示 “Sign in to confirm you're not a bot” 时打开。观看会计入你的 YouTube 历史记录,开启后刷新视频页生效。</p>
|
|
36
|
+
</section>
|
|
37
|
+
|
|
38
|
+
<section>
|
|
39
|
+
<h2>翻译引擎</h2>
|
|
40
|
+
<select id="engine">
|
|
41
|
+
<option value="google">Google 翻译(免费,无需配置)</option>
|
|
42
|
+
<option value="claude">Claude(需 API Key)</option>
|
|
43
|
+
<option value="openai">OpenAI 兼容接口(DeepSeek / 通义 / Ollama…)</option>
|
|
44
|
+
</select>
|
|
45
|
+
<p class="hint" id="engineHint"></p>
|
|
46
|
+
</section>
|
|
47
|
+
|
|
48
|
+
<footer>
|
|
49
|
+
<a href="#" id="openOptions">更多设置</a>
|
|
50
|
+
<a href="https://hackertube.org" target="_blank" rel="noopener">打开 HackerTube</a>
|
|
51
|
+
</footer>
|
|
52
|
+
<script src="../lib/runtime.js"></script>
|
|
53
|
+
<script src="popup.js"></script>
|
|
54
|
+
</body>
|
|
55
|
+
</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); }
|