Compare commits

..

29 Commits

Author SHA1 Message Date
Gabe
b9693436bb Update version number: 2.0.2 2025-10-17 10:31:04 +08:00
Gabe
6b18d8f934 fix: rules 2025-10-17 10:20:42 +08:00
Gabe
65c325de9a fix: custom api example 2025-10-17 10:14:28 +08:00
Gabe
8da5aaf259 fix: add custom api example link 2025-10-17 10:01:02 +08:00
Gabe
00e8fdd3e6 fix: update custom api doc 2025-10-17 09:44:53 +08:00
Gabe
b2eea5d0d7 fix: custom api doc 2025-10-17 01:47:36 +08:00
Gabe
9a8e24f590 fix: sync rules, words 2025-10-17 01:19:24 +08:00
Gabe
32c6d45cb0 feat: add custom api examples 2025-10-16 23:51:49 +08:00
Gabe
74ce6f2f1f fix: youtube live caht rule 2025-10-16 21:29:58 +08:00
Gabe
573865cf10 fix: merge rules 2025-10-16 20:58:27 +08:00
Gabe
56d4733e2a feat: terms style 2025-10-16 20:16:03 +08:00
Gabe
a8965a01e3 fix: change some default setting 2025-10-16 19:35:28 +08:00
Gabe
beef51ef38 fix: default ignore selector 2025-10-16 01:25:52 +08:00
Gabe
b5b3ee8709 update version number: 2.0.1 2025-10-16 00:55:16 +08:00
Gabe
4f1e01dde0 feat: youtube ad skip 2025-10-15 23:58:57 +08:00
Gabe
d42ff51de5 feat: youtube ad skip 2025-10-15 23:44:45 +08:00
Gabe
c39861b7b7 fix: readme 2025-10-15 22:10:17 +08:00
Gabe
d39b9fd73e fix: api name in tranbox(Closes #322) 2025-10-15 22:04:03 +08:00
Gabe
ecab4ab634 feat: support subtitle translate for userscript 2025-10-15 21:41:09 +08:00
Gabe
5e67e15842 feat: clear caches in popup 2025-10-15 14:40:58 +08:00
Gabe
2af1a8b72c fix: remove retranslate subtitle code 2025-10-15 14:08:56 +08:00
Gabe
2510ed0ebb fix: shortcut(alt+tab) bug 2025-10-15 13:55:23 +08:00
XYenon
6827985289 feat: auto dark mode (#321) 2025-10-15 12:43:24 +08:00
Gabe
a095a2c01c fix: temperature limit float 2025-10-15 00:59:32 +08:00
Gabe
2033ff6777 fix: subtitle: retry translation failed 2025-10-14 23:45:30 +08:00
Gabe
0c22288833 Merge remote-tracking branch 'origin/dev' into dev 2025-10-14 23:37:30 +08:00
zxhzxhz
0576150067 Update BilingualSubtitleManager.js (#320)
fix translation style
2025-10-14 23:36:57 +08:00
Gabe
9cdcf616f7 fix: change innerHTML to trustedHTML 2025-10-14 23:36:28 +08:00
Gabe
2de10364f3 fix: try fix subtitle in userscript 2025-10-14 22:41:18 +08:00
43 changed files with 578 additions and 147 deletions

2
.env
View File

@@ -2,7 +2,7 @@ GENERATE_SOURCEMAP=false
REACT_APP_NAME=KISS Translator REACT_APP_NAME=KISS Translator
REACT_APP_NAME_CN=简约翻译 REACT_APP_NAME_CN=简约翻译
REACT_APP_VERSION=2.0.0 REACT_APP_VERSION=2.0.2
REACT_APP_HOMEPAGE=https://github.com/fishjar/kiss-translator REACT_APP_HOMEPAGE=https://github.com/fishjar/kiss-translator

View File

@@ -96,7 +96,7 @@ A simple, open source [bilingual translation extension & Greasemonkey script](ht
> Note: For the following reasons, it is recommended to use browser extensions first > Note: For the following reasons, it is recommended to use browser extensions first
> >
> - Browser extensions have more complete functions (subtitle translation, local language recognition, context menu, etc.) > - Browser extensions have more complete functions (local language recognition, context menu, etc.)
> - Grease Monkey script will encounter more usage problems (cross domain issues, script conflicts, etc.) > - Grease Monkey script will encounter more usage problems (cross domain issues, script conflicts, etc.)
- [x] Browser extension - [x] Browser extension
@@ -147,6 +147,12 @@ If encountering a 403 error, refer to: https://github.com/fishjar/kiss-translato
Tampermonkey scripts require adding domains to the whitelist; otherwise, requests cannot be sent. Tampermonkey scripts require adding domains to the whitelist; otherwise, requests cannot be sent.
### How to set up a hook function for a custom API
Custom APIs are very powerful and flexible, and can theoretically connect to any translation API.
Example reference: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
## Future Plans ## Future Plans
This is a side project with no strict timeline. Community contributions are welcome. The following are preliminary feature directions: This is a side project with no strict timeline. Community contributions are welcome. The following are preliminary feature directions:

View File

@@ -92,7 +92,7 @@
> 注:基于以下原因,建议优先使用浏览器扩展 > 注:基于以下原因,建议优先使用浏览器扩展
> >
> - 浏览器扩展的功能更完整(字幕翻译、本地语言识别、右键菜单等) > - 浏览器扩展的功能更完整(本地语言识别、右键菜单等)
> - 油猴脚本会遇到更多使用上的问题(跨域问题、脚本冲突等) > - 油猴脚本会遇到更多使用上的问题(跨域问题、脚本冲突等)
- [x] 浏览器扩展 - [x] 浏览器扩展
@@ -143,6 +143,12 @@
油猴脚本需要增加域名白名单,否则不能发出请求。 油猴脚本需要增加域名白名单,否则不能发出请求。
### 如何设置自定义接口的hook函数
自定义接口功能非常强大、灵活,理论可以接入任何翻译接口。
示例参考: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
## 未来规划 ## 未来规划
本项目为业余开发,无严格时间表,欢迎社区共建。以下为初步设想的功能方向: 本项目为业余开发,无严格时间表,欢迎社区共建。以下为初步设想的功能方向:

View File

@@ -106,6 +106,7 @@ const userscriptWebpack = (config, env) => {
// @connect openai.azure.com // @connect openai.azure.com
// @connect workers.dev // @connect workers.dev
// @connect github.io // @connect github.io
// @connect github.com
// @connect githubusercontent.com // @connect githubusercontent.com
// @connect kiss-translator.rayjar.com // @connect kiss-translator.rayjar.com
// @connect ghproxy.com // @connect ghproxy.com
@@ -130,7 +131,6 @@ const userscriptWebpack = (config, env) => {
config.entry = { config.entry = {
main: paths.appIndexJs, main: paths.appIndexJs,
options: paths.appSrc + "/options.js", options: paths.appSrc + "/options.js",
injector: paths.appSrc + "/injector.js",
"kiss-translator.user": paths.appSrc + "/userscript.js", "kiss-translator.user": paths.appSrc + "/userscript.js",
}; };

View File

@@ -1,5 +1,7 @@
# 自定义接口示例(本文档已过期,新版不再适用) # 自定义接口示例(本文档已过期,新版不再适用)
V2版的示例请查看这里[custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
以下示例为网友提供,仅供学习参考。 以下示例为网友提供,仅供学习参考。
## 本地运行 Seed-X-PPO-7B 量化模型 ## 本地运行 Seed-X-PPO-7B 量化模型

206
custom-api_v2.md Normal file
View File

@@ -0,0 +1,206 @@
# 自定义接口示例
## 谷歌翻译接口
> 此接口不支持聚合
URL
```
https://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN
```
Request Hook
```js
async (args) => {
const url = args.url.replace("{{text}}", args.texts[0]);
const method = "GET";
return { url, method };
};
```
Response Hook
```js
async ({ res }) => {
return { translations: [[res?.sentences?.[0]?.trans || "", res?.src]] };
};
```
## Ollama
> 此示例为支持聚合的模型类(要支持上下文,需进一步改动)
* 注意 ollama 启动参数需要添加环境变量 `OLLAMA_ORIGINS=*`
* 检查环境变量生效命令:`systemctl show ollama | grep OLLAMA_ORIGINS`
URL
```
http://localhost:11434/v1/chat/completions
```
Request Hook
```js
async (args) => {
const url = args.url;
const method = "POST";
const headers = { "Content-type": "application/json" };
const body = {
model: "gemma3",
messages: [
{
role: "system",
content:
'Act as a translation API. Output a single raw JSON object only. No extra text or fences.\n\nInput:\n{"targetLanguage":"<lang>","title":"<context>","description":"<context>","segments":[{"id":1,"text":"..."}],"glossary":{"sourceTerm":"targetTerm"},"tone":"<formal|casual>"}\n\nOutput:\n{"translations":[{"id":1,"text":"...","sourceLanguage":"<detected>"}]}\n\nRules:\n1. Use title/description for context only; do not output them.\n2. Keep id, order, and count of segments.\n3. Preserve whitespace, HTML entities, and all HTML-like tags (e.g., <i1>, <a1>). Translate inner text only.\n4. Highest priority: Follow \'glossary\'. Use value for translation; if value is "", keep the key.\n5. Do not translate: content in <code>, <pre>, text enclosed in backticks, or placeholders like {1}, {{1}}, [1], [[1]].\n6. Apply the specified tone to the translation.\n7. Detect sourceLanguage for each segment.\n8. Return empty or unchanged inputs as is.\n\nExample:\nInput: {"targetLanguage":"zh-CN","segments":[{"id":1,"text":"A <b>React</b> component."}],"glossary":{"component":"组件","React":""}}\nOutput: {"translations":[{"id":1,"text":"一个<b>React</b>组件","sourceLanguage":"en"}]}\n\nFail-safe: On any error, return {"translations":[]}.',
},
{
role: "user",
content: JSON.stringify({
targetLanguage: args.to,
segments: args.texts.map((text, id) => ({ id, text })),
glossary: {},
}),
},
],
temperature: 0,
max_tokens: 20480,
think: false,
stream: false,
};
return { url, body, headers, method };
};
```
v2.0.2 Request Hook 可以简化为:
```js
async (args) => {
const url = args.url;
const method = "POST";
const headers = { "Content-type": "application/json" };
const body = {
model: "gemma3", // v2.0.2 版后此处可填 args.model
messages: [
{
role: "system",
content: args.defaultSystemPrompt, // 或者 args.systemPrompt
},
{
role: "user",
content: JSON.stringify({
targetLanguage: args.to,
segments: args.texts.map((text, id) => ({ id, text })),
glossary: {},
}),
},
],
temperature: 0,
max_tokens: 20480,
think: false,
stream: false,
};
return { url, body, headers, method };
};
```
Response Hook
```js
async ({ res }) => {
const extractJson = (raw) => {
const jsonRegex = /({.*}|\[.*\])/s;
const match = raw.match(jsonRegex);
return match ? match[0] : null;
};
const parseAIRes = (raw) => {
if (!raw) return [];
try {
const jsonString = extractJson(raw);
if (!jsonString) return [];
const data = JSON.parse(jsonString);
if (Array.isArray(data.translations)) {
return data.translations.map((item) => [
item?.text ?? "",
item?.sourceLanguage ?? "",
]);
}
} catch (err) {
console.log("parseAIRes", err);
}
return [];
};
const translations = parseAIRes(res?.choices?.[0]?.message?.content);
return { translations };
};
```
v2.0.2 版后内置`parseAIRes`函数Response Hook 可以简化为:
```js
async ({ res, parseAIRes, }) => {
const translations = parseAIRes(res?.choices?.[0]?.message?.content);
return { translations };
};
```
## 硅基流动
> 此示例为不支持聚合模型类,支持聚合的模型类参考上面 Ollama 的写法
URL
```
https://api.siliconflow.cn/v1/chat/completions
```
Request Hook
```js
async (args) => {
const url = args.url;
const method = "POST";
const headers = {
"Content-type": "application/json",
Authorization: `Bearer ${args.key}`,
};
const body = {
model: "tencent/Hunyuan-MT-7B", // v2.0.2 版后此处可填 args.model
messages: [
{
role: "system",
content:
"You are a professional, authentic machine translation engine.",
},
{
role: "user",
content: `Translate the following source text from to ${args.to}. Output translation directly without any additional text.\n\nSource Text: ${args.texts[0]}\n\nTranslated Text:`,
},
],
temperature: 0,
max_tokens: 20480,
};
return { url, body, headers, method };
};
```
Response Hook
```js
async ({ res }) => {
return { translations: [[res?.choices?.[0]?.message?.content || ""]] };
};
```

View File

@@ -1,7 +1,7 @@
{ {
"name": "kiss-translator", "name": "kiss-translator",
"description": "A minimalist bilingual translation Extension & Greasemonkey Script", "description": "A minimalist bilingual translation Extension & Greasemonkey Script",
"version": "2.0.0", "version": "2.0.2",
"author": "Gabe<yugang2002@gmail.com>", "author": "Gabe<yugang2002@gmail.com>",
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@@ -2,7 +2,7 @@
"manifest_version": 2, "manifest_version": 2,
"name": "__MSG_app_name__", "name": "__MSG_app_name__",
"description": "__MSG_app_description__", "description": "__MSG_app_description__",
"version": "2.0.0", "version": "2.0.2",
"default_locale": "en", "default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>", "author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator", "homepage_url": "https://github.com/fishjar/kiss-translator",

View File

@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_app_name__", "name": "__MSG_app_name__",
"description": "__MSG_app_description__", "description": "__MSG_app_description__",
"version": "2.0.0", "version": "2.0.2",
"default_locale": "en", "default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>", "author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator", "homepage_url": "https://github.com/fishjar/kiss-translator",

View File

@@ -2,7 +2,7 @@
"manifest_version": 2, "manifest_version": 2,
"name": "__MSG_app_name__", "name": "__MSG_app_name__",
"description": "__MSG_app_description__", "description": "__MSG_app_description__",
"version": "2.0.0", "version": "2.0.2",
"default_locale": "en", "default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>", "author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator", "homepage_url": "https://github.com/fishjar/kiss-translator",

View File

@@ -106,7 +106,7 @@ export const apiMicrosoftLangdetect = async (text) => {
const key = `${URL_CACHE_DELANG}_${OPT_TRANS_MICROSOFT}`; const key = `${URL_CACHE_DELANG}_${OPT_TRANS_MICROSOFT}`;
const queue = getBatchQueue(key, handleMicrosoftLangdetect, { const queue = getBatchQueue(key, handleMicrosoftLangdetect, {
batchInterval: 500, batchInterval: 200,
batchSize: 20, batchSize: 20,
batchLength: 100000, batchLength: 100000,
}); });

View File

@@ -26,6 +26,8 @@ import {
INPUT_PLACE_KEY, INPUT_PLACE_KEY,
INPUT_PLACE_MODEL, INPUT_PLACE_MODEL,
DEFAULT_USER_AGENT, DEFAULT_USER_AGENT,
defaultSystemPrompt,
defaultSubtitlePrompt,
} from "../config"; } from "../config";
import { msAuth } from "../libs/auth"; import { msAuth } from "../libs/auth";
import { genDeeplFree } from "./deepl"; import { genDeeplFree } from "./deepl";
@@ -98,8 +100,9 @@ const parseAIRes = (raw) => {
try { try {
const jsonString = extractJson(raw); const jsonString = extractJson(raw);
const data = JSON.parse(jsonString); if (!jsonString) return [];
const data = JSON.parse(jsonString);
if (Array.isArray(data.translations)) { if (Array.isArray(data.translations)) {
// todo: 考虑序号id可能会打乱 // todo: 考虑序号id可能会打乱
return data.translations.map((item) => [ return data.translations.map((item) => [
@@ -677,13 +680,16 @@ export const genTransReq = async ({ reqHook, ...args }) => {
if (reqHook?.trim() && !events) { if (reqHook?.trim() && !events) {
try { try {
interpreter.run(`exports.reqHook = ${reqHook}`); interpreter.run(`exports.reqHook = ${reqHook}`);
const hookResult = await interpreter.exports.reqHook(args, { const hookResult = await interpreter.exports.reqHook(
url, { ...args, defaultSystemPrompt, defaultSubtitlePrompt },
body, {
headers, url,
userMsg, body,
method, headers,
}); userMsg,
method,
}
);
if (hookResult && hookResult.url) { if (hookResult && hookResult.url) {
return genInit(hookResult); return genInit(hookResult);
} }
@@ -731,6 +737,8 @@ export const parseTransRes = async (
fromLang, fromLang,
toLang, toLang,
langMap, langMap,
extractJson,
parseAIRes,
}); });
if (hookResult && Array.isArray(hookResult.translations)) { if (hookResult && Array.isArray(hookResult.translations)) {
if (history && userMsg && hookResult.modelMsg) { if (history && userMsg && hookResult.modelMsg) {
@@ -925,7 +933,7 @@ export const handleTranslate = async (
userMsg, userMsg,
...apiSetting, ...apiSetting,
}); });
if (!Array.isArray(result)) { if (!result?.length) {
throw new Error("tranlate got an unexpected result"); throw new Error("tranlate got an unexpected result");
} }

View File

@@ -23,6 +23,7 @@ import {
CMD_OPEN_TRANBOX, CMD_OPEN_TRANBOX,
CLIENT_THUNDERBIRD, CLIENT_THUNDERBIRD,
MSG_SET_LOGLEVEL, MSG_SET_LOGLEVEL,
MSG_CLEAR_CACHES,
} from "./config"; } from "./config";
import { getSettingWithDefault, tryInitDefaultData } from "./libs/storage"; import { getSettingWithDefault, tryInitDefaultData } from "./libs/storage";
import { trySyncSettingAndRules } from "./libs/sync"; import { trySyncSettingAndRules } from "./libs/sync";
@@ -275,6 +276,7 @@ const messageHandlers = {
[MSG_BUILTINAI_DETECT]: (args) => chromeDetect(args), [MSG_BUILTINAI_DETECT]: (args) => chromeDetect(args),
[MSG_BUILTINAI_TRANSLATE]: (args) => chromeTranslate(args), [MSG_BUILTINAI_TRANSLATE]: (args) => chromeTranslate(args),
[MSG_SET_LOGLEVEL]: (args) => logger.setLevel(args), [MSG_SET_LOGLEVEL]: (args) => logger.setLevel(args),
[MSG_CLEAR_CACHES]: () => tryClearCaches(),
}; };
/** /**

View File

@@ -20,6 +20,7 @@ import { trySyncAllSubRules } from "./libs/subRules";
import { isInBlacklist } from "./libs/blacklist"; import { isInBlacklist } from "./libs/blacklist";
import { runSubtitle } from "./subtitle/subtitle"; import { runSubtitle } from "./subtitle/subtitle";
import { logger } from "./libs/log"; import { logger } from "./libs/log";
import { injectInlineJs } from "./libs/injector";
/** /**
* 油猴脚本设置页面 * 油猴脚本设置页面
@@ -35,9 +36,10 @@ function runSettingPage() {
const ping = genEventName(); const ping = genEventName();
window.addEventListener(ping, handlePing); window.addEventListener(ping, handlePing);
// window.eval(`(${injectScript})("${ping}")`); // eslint-disable-line // window.eval(`(${injectScript})("${ping}")`); // eslint-disable-line
const script = document.createElement("script"); injectInlineJs(
script.textContent = `(${injectScript})("${ping}")`; `(${injectScript})("${ping}")`,
document.head.append(script); "kiss-translator-options-injector"
);
} }
} }
@@ -127,7 +129,7 @@ function showErr(message) {
}); });
const closeButton = document.createElement("span"); const closeButton = document.createElement("span");
closeButton.innerHTML = "&times;"; closeButton.textContent = "×";
Object.assign(closeButton.style, { Object.assign(closeButton.style, {
position: "absolute", position: "absolute",
@@ -216,7 +218,7 @@ export async function run(isUserscript = false) {
} }
// 字幕翻译 // 字幕翻译
runSubtitle({ href, setting, rule }); runSubtitle({ href, setting, rule, isUserscript });
// 监听消息 // 监听消息
// !isUserscript && runtimeListener(translator); // !isUserscript && runtimeListener(translator);

View File

@@ -1,7 +1,7 @@
export const DEFAULT_HTTP_TIMEOUT = 10000; // 调用超时时间 export const DEFAULT_HTTP_TIMEOUT = 10000; // 调用超时时间
export const DEFAULT_FETCH_LIMIT = 10; // 默认最大任务数量 export const DEFAULT_FETCH_LIMIT = 10; // 默认最大任务数量
export const DEFAULT_FETCH_INTERVAL = 100; // 默认任务间隔时间 export const DEFAULT_FETCH_INTERVAL = 100; // 默认任务间隔时间
export const DEFAULT_BATCH_INTERVAL = 1000; // 批处理请求间隔时间 export const DEFAULT_BATCH_INTERVAL = 400; // 批处理请求间隔时间
export const DEFAULT_BATCH_SIZE = 10; // 每次最多发送段落数量 export const DEFAULT_BATCH_SIZE = 10; // 每次最多发送段落数量
export const DEFAULT_BATCH_LENGTH = 10000; // 每次发送最大文字数量 export const DEFAULT_BATCH_LENGTH = 10000; // 每次发送最大文字数量
export const DEFAULT_CONTEXT_SIZE = 3; // 上下文会话数量 export const DEFAULT_CONTEXT_SIZE = 3; // 上下文会话数量
@@ -340,7 +340,7 @@ Object.entries(OPT_LANGS_TO_SPEC).forEach(([t, m]) => {
OPT_LANGS_TO_CODE[t] = specToCode(m); OPT_LANGS_TO_CODE[t] = specToCode(m);
}); });
const defaultSystemPrompt = `Act as a translation API. Output a single raw JSON object only. No extra text or fences. export const defaultSystemPrompt = `Act as a translation API. Output a single raw JSON object only. No extra text or fences.
Input: Input:
{"targetLanguage":"<lang>","title":"<context>","description":"<context>","segments":[{"id":1,"text":"..."}],"glossary":{"sourceTerm":"targetTerm"},"tone":"<formal|casual>"} {"targetLanguage":"<lang>","title":"<context>","description":"<context>","segments":[{"id":1,"text":"..."}],"glossary":{"sourceTerm":"targetTerm"},"tone":"<formal|casual>"}
@@ -381,7 +381,7 @@ Fail-safe: On any error, return {"translations":[]}.`;
// 4. **Special Cases**: '[Music]' (and similar cues) are standalone entries. Translate appropriately (e.g., '[音乐]', '[Musique]'). // 4. **Special Cases**: '[Music]' (and similar cues) are standalone entries. Translate appropriately (e.g., '[音乐]', '[Musique]').
// `; // `;
const defaultSubtitlePrompt = `You are an expert AI for subtitle generation. Convert a JSON array of word-level timestamps into a bilingual VTT file. export const defaultSubtitlePrompt = `You are an expert AI for subtitle generation. Convert a JSON array of word-level timestamps into a bilingual VTT file.
**Workflow:** **Workflow:**
1. Merge \`text\` fields into complete sentences; ignore empty text. 1. Merge \`text\` fields into complete sentences; ignore empty text.
@@ -409,16 +409,16 @@ Good morning.
\`\`\``; \`\`\``;
const defaultRequestHook = `async (args, { url, body, headers, userMsg, method } = {}) => { const defaultRequestHook = `async (args, { url, body, headers, userMsg, method } = {}) => {
console.log("request hook args:", args); console.log("request hook args:", { args, url, body, headers, userMsg, method });
// return { url, body, headers, userMsg, method }; // return { url, body, headers, userMsg, method };
}`; };`;
const defaultResponseHook = `async ({ res, ...args }) => { const defaultResponseHook = `async ({ res, ...args }) => {
console.log("reaponse hook args:", res, args); console.log("reaponse hook args:", { res, args });
// const translations = [["你好", "zh"]]; // const translations = [["你好", "zh"]];
// const modelMsg = ""; // const modelMsg = "";
// return { translations, modelMsg }; // return { translations, modelMsg };
}`; };`;
// 翻译接口默认参数 // 翻译接口默认参数
const defaultApi = { const defaultApi = {
@@ -448,7 +448,7 @@ const defaultApi = {
useBatchFetch: false, // 是否启用聚合发送请求 useBatchFetch: false, // 是否启用聚合发送请求
useContext: false, // 是否启用智能上下文 useContext: false, // 是否启用智能上下文
contextSize: DEFAULT_CONTEXT_SIZE, // 智能上下文保留会话数 contextSize: DEFAULT_CONTEXT_SIZE, // 智能上下文保留会话数
temperature: 0, temperature: 0.0,
maxTokens: 20480, maxTokens: 20480,
think: false, think: false,
thinkIgnore: "qwen3,deepseek-r1", thinkIgnore: "qwen3,deepseek-r1",

View File

@@ -137,46 +137,42 @@ ${customApiLangs}
`; `;
const requestHookHelperZH = `1、第一个参数包含如下字段'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ... const requestHookHelperZH = `1、第一个参数包含如下字段'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ...
2、返回值必须是包含以下字段的对象 'url', 'body', 'headers', 'userMsg', 'method' 2、返回值必须是包含以下字段的对象 'url', 'body', 'headers', 'method'
3、如返回空值则hook函数不会产生任何效果。 3、如返回空值则hook函数不会产生任何效果。
// 示例 // 示例
async (args, { url, body, headers, userMsg, method } = {}) => { async (args, { url, body, headers, userMsg, method } = {}) => {
console.log("request hook args:", args);
return { url, body, headers, userMsg, method }; return { url, body, headers, userMsg, method };
}`; }`;
const requestHookHelperEN = `1. The first parameter contains the following fields: 'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ... const requestHookHelperEN = `1. The first parameter contains the following fields: 'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ...
2. The return value must be an object containing the following fields: 'url', 'body', 'headers', 'userMsg', 'method' 2. The return value must be an object containing the following fields: 'url', 'body', 'headers', 'method'
3. If a null value is returned, the hook function will have no effect. 3. If a null value is returned, the hook function will have no effect.
// Example // Example
async (args, { url, body, headers, userMsg, method } = {}) => { async (args, { url, body, headers, userMsg, method } = {}) => {
console.log("request hook args:", args);
return { url, body, headers, userMsg, method }; return { url, body, headers, userMsg, method };
}`; }`;
const responsetHookHelperZH = `1、第一个参数包含如下字段'res', ... const responsetHookHelperZH = `1、第一个参数包含如下字段'res', ...
2、返回值必须是包含以下字段的对象 'translations', 'modelMsg' 2、返回值必须是包含以下字段的对象 'translations'
'translations' 应为一个二维数组:[[译文, 源语言]] 'translations' 应为一个二维数组:[[译文, 源语言]]
3、如返回空值则hook函数不会产生任何效果。 3、如返回空值则hook函数不会产生任何效果。
// 示例 // 示例
async ({ res, ...args }) => { async ({ res, ...args }) => {
console.log("reaponse hook args:", res, args);
const translations = [["你好", "zh"]]; const translations = [["你好", "zh"]];
const modelMsg = ""; const modelMsg = "";
return { translations, modelMsg }; return { translations, modelMsg };
}`; }`;
const responsetHookHelperEN = `1. The first parameter contains the following fields: 'res', ... const responsetHookHelperEN = `1. The first parameter contains the following fields: 'res', ...
2. The return value must be an object containing the following fields: 'translations', 'modelMsg' 2. The return value must be an object containing the following fields: 'translations'
('translations' should be a two-dimensional array: [[translation, source language]]). ('translations' should be a two-dimensional array: [[translation, source language]]).
3. If a null value is returned, the hook function will have no effect. 3. If a null value is returned, the hook function will have no effect.
// Example // Example
async ({ res, ...args }) => { async ({ res, ...args }) => {
console.log("reaponse hook args:", res, args);
const translations = [["你好", "zh"]]; const translations = [["你好", "zh"]];
const modelMsg = ""; const modelMsg = "";
return { translations, modelMsg }; return { translations, modelMsg };
@@ -718,6 +714,11 @@ export const I18N = {
en: `Selector Style`, en: `Selector Style`,
zh_TW: `選擇器節點樣式`, zh_TW: `選擇器節點樣式`,
}, },
terms_style: {
zh: `专业术语样式`,
en: `Terms Style`,
zh_TW: `專業術語樣式`,
},
selector_style_helper: { selector_style_helper: {
zh: `开启翻译时注入。`, zh: `开启翻译时注入。`,
en: `It is injected when translation is turned on.`, en: `It is injected when translation is turned on.`,
@@ -1609,8 +1610,8 @@ export const I18N = {
zh_TW: `AI处理切割长度(200-20000)`, zh_TW: `AI处理切割长度(200-20000)`,
}, },
subtitle_helper_1: { subtitle_helper_1: {
zh: `1、目前仅支持Youtube桌面网站,且仅支持浏览器扩展`, zh: `1、目前仅支持Youtube桌面网站。`,
en: `1. Currently only supports Youtube desktop website and browser extension.`, en: `1. Currently only supports Youtube desktop website.`,
zh_TW: `1.目前僅支援Youtube桌面網站且僅支援瀏覽器擴充功能。`, zh_TW: `1.目前僅支援Youtube桌面網站且僅支援瀏覽器擴充功能。`,
}, },
subtitle_helper_2: { subtitle_helper_2: {
@@ -1663,6 +1664,11 @@ export const I18N = {
en: `Log Level`, en: `Log Level`,
zh_TW: `日誌等級`, zh_TW: `日誌等級`,
}, },
goto_custom_api_example: {
zh: `点击查看【自定义接口示例】`,
en: `Click to view [Custom Interface Example]`,
zh_TW: `點選查看【自訂介面範例】`,
},
}; };
export const i18n = (lang) => (key) => I18N[key]?.[lang] || ""; export const i18n = (lang) => (key) => I18N[key]?.[lang] || "";

View File

@@ -3,7 +3,7 @@ export const CMD_TOGGLE_STYLE = "toggleStyle";
export const CMD_OPEN_OPTIONS = "openOptions"; export const CMD_OPEN_OPTIONS = "openOptions";
export const CMD_OPEN_TRANBOX = "openTranbox"; export const CMD_OPEN_TRANBOX = "openTranbox";
export const MSG_FETCH = "fetch"; export const MSG_FETCH = "kiss_fetch";
export const MSG_GET_HTTPCACHE = "get_httpcache"; export const MSG_GET_HTTPCACHE = "get_httpcache";
export const MSG_PUT_HTTPCACHE = "put_httpcache"; export const MSG_PUT_HTTPCACHE = "put_httpcache";
export const MSG_OPEN_OPTIONS = "open_options"; export const MSG_OPEN_OPTIONS = "open_options";
@@ -25,6 +25,7 @@ export const MSG_UPDATE_CSP = "update_csp";
export const MSG_BUILTINAI_DETECT = "builtinai_detect"; export const MSG_BUILTINAI_DETECT = "builtinai_detect";
export const MSG_BUILTINAI_TRANSLATE = "builtinai_translte"; export const MSG_BUILTINAI_TRANSLATE = "builtinai_translte";
export const MSG_SET_LOGLEVEL = "set_loglevel"; export const MSG_SET_LOGLEVEL = "set_loglevel";
export const MSG_CLEAR_CACHES = "clear_caches";
export const MSG_XHR_DATA_YOUTUBE = "KISS_XHR_DATA_YOUTUBE"; export const MSG_XHR_DATA_YOUTUBE = "KISS_XHR_DATA_YOUTUBE";
// export const MSG_GLOBAL_VAR_FETCH = "KISS_GLOBAL_VAR_FETCH"; // export const MSG_GLOBAL_VAR_FETCH = "KISS_GLOBAL_VAR_FETCH";

View File

@@ -78,8 +78,7 @@ background: linear-gradient(
export const DEFAULT_SELECTOR = export const DEFAULT_SELECTOR =
"h1, h2, h3, h4, h5, h6, li, p, dd, blockquote, figcaption, label, legend"; "h1, h2, h3, h4, h5, h6, li, p, dd, blockquote, figcaption, label, legend";
export const DEFAULT_IGNORE_SELECTOR = export const DEFAULT_IGNORE_SELECTOR = "button, footer, pre, mark, nav";
"aside, button, footer, form, pre, mark, nav";
export const DEFAULT_KEEP_SELECTOR = `a:has(code)`; export const DEFAULT_KEEP_SELECTOR = `a:has(code)`;
export const DEFAULT_RULE = { export const DEFAULT_RULE = {
pattern: "", // 匹配网址 pattern: "", // 匹配网址
@@ -94,6 +93,7 @@ export const DEFAULT_RULE = {
transOpen: GLOBAL_KEY, // 开启翻译 transOpen: GLOBAL_KEY, // 开启翻译
bgColor: "", // 译文颜色 bgColor: "", // 译文颜色
textDiyStyle: "", // 自定义译文样式 textDiyStyle: "", // 自定义译文样式
termsStyle: "", // 专业术语样式
selectStyle: "", // 选择器节点样式 selectStyle: "", // 选择器节点样式
parentStyle: "", // 选择器父节点样式 parentStyle: "", // 选择器父节点样式
grandStyle: "", // 选择器父节点样式 grandStyle: "", // 选择器父节点样式
@@ -132,6 +132,7 @@ export const GLOBLA_RULE = {
transOpen: "false", // 开启翻译 transOpen: "false", // 开启翻译
bgColor: "", // 译文颜色 bgColor: "", // 译文颜色
textDiyStyle: DEFAULT_DIY_STYLE, // 自定义译文样式 textDiyStyle: DEFAULT_DIY_STYLE, // 自定义译文样式
termsStyle: "font-weight: bold;", // 专业术语样式
selectStyle: DEFAULT_SELECT_STYLE, // 选择器节点样式 selectStyle: DEFAULT_SELECT_STYLE, // 选择器节点样式
parentStyle: DEFAULT_SELECT_STYLE, // 选择器父节点样式 parentStyle: DEFAULT_SELECT_STYLE, // 选择器父节点样式
grandStyle: DEFAULT_SELECT_STYLE, // 选择器祖节点样式 grandStyle: DEFAULT_SELECT_STYLE, // 选择器祖节点样式
@@ -192,6 +193,11 @@ const RULES_MAP = {
rootsSelector: `ytd-page-manager`, rootsSelector: `ytd-page-manager`,
ignoreSelector: `aside, button, footer, form, header, pre, mark, nav, #player, #container, .caption-window, .ytp-settings-menu`, ignoreSelector: `aside, button, footer, form, header, pre, mark, nav, #player, #container, .caption-window, .ytp-settings-menu`,
}, },
"www.youtube.com/live_chat": {
rootsSelector: `div#items`,
selector: `span.yt-live-chat-text-message-renderer`,
autoScan: `false`,
},
}; };
export const BUILTIN_RULES = Object.entries(RULES_MAP) export const BUILTIN_RULES = Object.entries(RULES_MAP)

View File

@@ -134,14 +134,14 @@ export const DEFAULT_SUBRULES_LIST = [
}, },
]; ];
export const DEFAULT_MOUSEHOVER_KEY = ["KeyQ"]; export const DEFAULT_MOUSEHOVER_KEY = ["ControlLeft"];
export const DEFAULT_MOUSE_HOVER_SETTING = { export const DEFAULT_MOUSE_HOVER_SETTING = {
useMouseHover: true, // 是否启用鼠标悬停翻译 useMouseHover: false, // 是否启用鼠标悬停翻译
mouseHoverKey: DEFAULT_MOUSEHOVER_KEY, // 鼠标悬停翻译组合键 mouseHoverKey: DEFAULT_MOUSEHOVER_KEY, // 鼠标悬停翻译组合键
}; };
export const DEFAULT_SETTING = { export const DEFAULT_SETTING = {
darkMode: false, // 深色模式 darkMode: "auto", // 深色模式
uiLang: "en", // 界面语言 uiLang: "en", // 界面语言
// fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至rule作废) // fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至rule作废)
// fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至rule作废) // fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至rule作废)

View File

@@ -12,7 +12,12 @@ export function useDarkMode() {
} = useSetting(); } = useSetting();
const toggleDarkMode = useCallback(() => { const toggleDarkMode = useCallback(() => {
updateSetting({ darkMode: !darkMode }); const nextMode = {
light: "dark",
dark: "auto",
auto: "light",
};
updateSetting({ darkMode: nextMode[darkMode] || "light" });
}, [darkMode, updateSetting]); }, [darkMode, updateSetting]);
return { darkMode, toggleDarkMode }; return { darkMode, toggleDarkMode };

View File

@@ -1,16 +1,25 @@
import { STOKEY_WORDS, KV_WORDS_KEY } from "../config"; import { STOKEY_WORDS, KV_WORDS_KEY } from "../config";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useStorage } from "./Storage"; import { useStorage } from "./Storage";
import { debounceSyncMeta } from "../libs/storage";
const DEFAULT_FAVWORDS = {}; const DEFAULT_FAVWORDS = {};
export function useFavWords() { export function useFavWords() {
const { data: favWords, save } = useStorage( const { data: favWords, save: saveWords } = useStorage(
STOKEY_WORDS, STOKEY_WORDS,
DEFAULT_FAVWORDS, DEFAULT_FAVWORDS,
KV_WORDS_KEY KV_WORDS_KEY
); );
const save = useCallback(
(objOrFn) => {
saveWords(objOrFn);
debounceSyncMeta(KV_WORDS_KEY);
},
[saveWords]
);
const toggleFav = useCallback( const toggleFav = useCallback(
(word) => { (word) => {
save((prev) => { save((prev) => {

View File

@@ -2,18 +2,27 @@ import { STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY } from "../config";
import { useStorage } from "./Storage"; import { useStorage } from "./Storage";
import { checkRules } from "../libs/rules"; import { checkRules } from "../libs/rules";
import { useCallback } from "react"; import { useCallback } from "react";
import { debounceSyncMeta } from "../libs/storage";
/** /**
* 规则 hook * 规则 hook
* @returns * @returns
*/ */
export function useRules() { export function useRules() {
const { data: list = [], save } = useStorage( const { data: list = [], save: saveRules } = useStorage(
STOKEY_RULES, STOKEY_RULES,
DEFAULT_RULES, DEFAULT_RULES,
KV_RULES_KEY KV_RULES_KEY
); );
const save = useCallback(
(objOrFn) => {
saveRules(objOrFn);
debounceSyncMeta(KV_RULES_KEY);
},
[saveRules]
);
const add = useCallback( const add = useCallback(
(rule) => { (rule) => {
save((prev) => { save((prev) => {
@@ -48,11 +57,7 @@ export function useRules() {
const put = useCallback( const put = useCallback(
(pattern, obj) => { (pattern, obj) => {
save((prev) => { save((prev) => {
if ( if (pattern !== obj.pattern) {
prev.some(
(item) => item.pattern === obj.pattern && item.pattern !== pattern
)
) {
return prev; return prev;
} }
return prev.map((item) => return prev.map((item) =>
@@ -71,15 +76,26 @@ export function useRules() {
return prev; return prev;
} }
const map = new Map(); // const map = new Map();
// 不进行深度合并 // // 不进行深度合并
// [...prev, ...adds].forEach((item) => { // // [...prev, ...adds].forEach((item) => {
// const k = item.pattern; // // const k = item.pattern;
// map.set(k, { ...(map.get(k) || {}), ...item }); // // map.set(k, { ...(map.get(k) || {}), ...item });
// }); // // });
prev.forEach((item) => map.set(item.pattern, item)); // prev.forEach((item) => map.set(item.pattern, item));
adds.forEach((item) => map.set(item.pattern, item)); // adds.forEach((item) => map.set(item.pattern, item));
return [...map.values()]; // return [...map.values()];
const addsMap = new Map(adds.map((item) => [item.pattern, item]));
const prevPatterns = new Set(prev.map((item) => item.pattern));
const updatedPrev = prev.map(
(prevItem) => addsMap.get(prevItem.pattern) || prevItem
);
const newItems = adds.filter(
(addItem) => !prevPatterns.has(addItem.pattern)
);
return [...newItems, ...updatedPrev];
}); });
}, },
[save] [save]

View File

@@ -17,6 +17,7 @@ import { debounceSyncMeta } from "../libs/storage";
import Loading from "./Loading"; import Loading from "./Loading";
import { logger } from "../libs/log"; import { logger } from "../libs/log";
import { sendBgMsg } from "../libs/msg"; import { sendBgMsg } from "../libs/msg";
import { isExt } from "../libs/client";
const SettingContext = createContext({ const SettingContext = createContext({
setting: DEFAULT_SETTING, setting: DEFAULT_SETTING,
@@ -32,11 +33,22 @@ export function SettingProvider({ children }) {
reload, reload,
} = useStorage(STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY); } = useStorage(STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY);
useEffect(() => {
if (typeof setting?.darkMode === "boolean") {
update((currentSetting) => ({
...currentSetting,
darkMode: currentSetting.darkMode ? "dark" : "light",
}));
}
}, [setting?.darkMode, update]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
logger.setLevel(setting?.logLevel); logger.setLevel(setting?.logLevel);
await sendBgMsg(MSG_SET_LOGLEVEL, setting?.logLevel); if (isExt) {
await sendBgMsg(MSG_SET_LOGLEVEL, setting?.logLevel);
}
} catch (error) { } catch (error) {
logger.error("Failed to fetch log level, using default.", error); logger.error("Failed to fetch log level, using default.", error);
} }

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { ThemeProvider, createTheme } from "@mui/material/styles"; import { ThemeProvider, createTheme } from "@mui/material/styles";
import { CssBaseline, GlobalStyles } from "@mui/material"; import { CssBaseline, GlobalStyles } from "@mui/material";
import { useDarkMode } from "./ColorMode"; import { useDarkMode } from "./ColorMode";
@@ -11,6 +11,21 @@ import { THEME_DARK, THEME_LIGHT } from "../config";
*/ */
export default function Theme({ children, options, styles }) { export default function Theme({ children, options, styles }) {
const { darkMode } = useDarkMode(); const { darkMode } = useDarkMode();
const [systemMode, setSystemMode] = useState(THEME_LIGHT);
useEffect(() => {
if (typeof window.matchMedia !== "function") {
return;
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
setSystemMode(mediaQuery.matches ? THEME_DARK : THEME_LIGHT);
};
handleChange(); // Set initial value
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
const theme = useMemo(() => { const theme = useMemo(() => {
let htmlFontSize = 16; let htmlFontSize = 16;
try { try {
@@ -23,16 +38,19 @@ export default function Theme({ children, options, styles }) {
// //
} }
const isDarkMode =
darkMode === "dark" || (darkMode === "auto" && systemMode === THEME_DARK);
return createTheme({ return createTheme({
palette: { palette: {
mode: darkMode ? THEME_DARK : THEME_LIGHT, mode: isDarkMode ? THEME_DARK : THEME_LIGHT,
}, },
typography: { typography: {
htmlFontSize, htmlFontSize,
}, },
...options, ...options,
}); });
}, [darkMode, options]); }, [darkMode, options, systemMode]);
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>

View File

@@ -1,8 +1,16 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { limitNumber } from "../libs/utils"; import { limitNumber, limitFloat } from "../libs/utils";
function ValidationInput({ value, onChange, name, min, max, ...props }) { function ValidationInput({
value,
onChange,
name,
min,
max,
isFloat = false,
...props
}) {
const [localValue, setLocalValue] = useState(value); const [localValue, setLocalValue] = useState(value);
useEffect(() => { useEffect(() => {
@@ -21,7 +29,9 @@ function ValidationInput({ value, onChange, name, min, max, ...props }) {
return; return;
} }
const validatedValue = limitNumber(numValue, min, max); const validatedValue = isFloat
? limitFloat(numValue, min, max)
: limitNumber(numValue, min, max);
if (validatedValue !== numValue) { if (validatedValue !== numValue) {
setLocalValue(validatedValue); setLocalValue(validatedValue);

View File

@@ -1,21 +1,3 @@
import { MSG_XHR_DATA_YOUTUBE } from "./config"; import { XMLHttpRequestInjector } from "./subtitle/XMLHttpRequestInjector";
(function () { XMLHttpRequestInjector();
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (...args) {
const url = args[1];
if (typeof url === "string" && url.includes("timedtext")) {
this.addEventListener("load", function () {
window.postMessage(
{
type: MSG_XHR_DATA_YOUTUBE,
url: this.responseURL,
response: this.responseText,
},
window.location.origin
);
});
}
return originalOpen.apply(this, args);
};
})();

View File

@@ -1,6 +1,7 @@
import { import {
CACHE_NAME, CACHE_NAME,
DEFAULT_CACHE_TIMEOUT, DEFAULT_CACHE_TIMEOUT,
MSG_CLEAR_CACHES,
MSG_GET_HTTPCACHE, MSG_GET_HTTPCACHE,
MSG_PUT_HTTPCACHE, MSG_PUT_HTTPCACHE,
} from "../config"; } from "../config";
@@ -15,7 +16,11 @@ import { blobToBase64 } from "./utils";
*/ */
export const tryClearCaches = async () => { export const tryClearCaches = async () => {
try { try {
caches.delete(CACHE_NAME); if (isExt && !isBg) {
await sendBgMsg(MSG_CLEAR_CACHES);
} else {
await caches.delete(CACHE_NAME);
}
} catch (err) { } catch (err) {
kissLog("clean caches", err); kissLog("clean caches", err);
} }

View File

@@ -1,28 +1,29 @@
// Function to inject inline JavaScript code import { trustedTypesHelper } from "./trustedTypes";
export const injectInlineJs = (code) => {
const el = document.createElement("script");
el.setAttribute("data-source", "kiss-inject injectInlineJs");
el.setAttribute("type", "text/javascript");
el.textContent = code;
document.body?.appendChild(el);
};
// Function to inject external JavaScript file // Function to inject inline JavaScript code
export const injectExternalJs = (src, id = "kiss-translator-injector") => { export const injectInlineJs = (code, id = "kiss-translator-inline-js") => {
if (document.getElementById(id)) { if (document.getElementById(id)) {
return; return;
} }
// const el = document.createElement("script"); const el = document.createElement("script");
// el.setAttribute("data-source", "kiss-inject injectExternalJs"); el.type = "text/javascript";
// el.setAttribute("type", "text/javascript"); el.id = id;
// el.setAttribute("src", src); el.textContent = trustedTypesHelper.createScript(code);
// el.setAttribute("id", id); (document.head || document.documentElement).appendChild(el);
// document.body?.appendChild(el); };
const script = document.createElement("script");
script.id = id; // Function to inject external JavaScript file
script.src = src; export const injectExternalJs = (src, id = "kiss-translator-external-js") => {
(document.head || document.documentElement).appendChild(script); if (document.getElementById(id)) {
return;
}
const el = document.createElement("script");
el.type = "text/javascript";
el.id = id;
el.src = trustedTypesHelper.createScriptURL(src);
(document.head || document.documentElement).appendChild(el);
}; };
// Function to inject internal CSS code // Function to inject internal CSS code

View File

@@ -52,6 +52,7 @@ export const matchRule = async (href, { injectRules, subrulesList }) => {
"ignoreSelector", "ignoreSelector",
"terms", "terms",
"aiTerms", "aiTerms",
"termsStyle",
"selectStyle", "selectStyle",
"parentStyle", "parentStyle",
"grandStyle", "grandStyle",
@@ -136,6 +137,7 @@ export const checkRules = (rules) => {
ignoreSelector, ignoreSelector,
terms, terms,
aiTerms, aiTerms,
termsStyle,
selectStyle, selectStyle,
parentStyle, parentStyle,
grandStyle, grandStyle,
@@ -170,6 +172,7 @@ export const checkRules = (rules) => {
ignoreSelector: type(ignoreSelector) === "string" ? ignoreSelector : "", ignoreSelector: type(ignoreSelector) === "string" ? ignoreSelector : "",
terms: type(terms) === "string" ? terms : "", terms: type(terms) === "string" ? terms : "",
aiTerms: type(aiTerms) === "string" ? aiTerms : "", aiTerms: type(aiTerms) === "string" ? aiTerms : "",
termsStyle: type(termsStyle) === "string" ? termsStyle : "",
selectStyle: type(selectStyle) === "string" ? selectStyle : "", selectStyle: type(selectStyle) === "string" ? selectStyle : "",
parentStyle: type(parentStyle) === "string" ? parentStyle : "", parentStyle: type(parentStyle) === "string" ? parentStyle : "",
grandStyle: type(grandStyle) === "string" ? grandStyle : "", grandStyle: type(grandStyle) === "string" ? grandStyle : "",

View File

@@ -34,12 +34,18 @@ export const shortcutListener = (
pressedKeys.delete(e.code); pressedKeys.delete(e.code);
}; };
const handleBlur = () => {
pressedKeys.clear();
};
target.addEventListener("keydown", handleKeyDown); target.addEventListener("keydown", handleKeyDown);
target.addEventListener("keyup", handleKeyUp); target.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => { return () => {
target.removeEventListener("keydown", handleKeyDown); target.removeEventListener("keydown", handleKeyDown);
target.removeEventListener("keyup", handleKeyUp); target.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
pressedKeys.clear(); pressedKeys.clear();
}; };
}; };

View File

@@ -37,6 +37,7 @@ import { browser } from "./browser";
import { isIframe, sendIframeMsg } from "./iframe"; import { isIframe, sendIframeMsg } from "./iframe";
import { TransboxManager } from "./tranbox"; import { TransboxManager } from "./tranbox";
import { InputTranslator } from "./inputTranslate"; import { InputTranslator } from "./inputTranslate";
import { trustedTypesHelper } from "./trustedTypes";
/** /**
* @class Translator * @class Translator
@@ -958,6 +959,7 @@ export class Translator {
transStartHook, transStartHook,
transEndHook, transEndHook,
transOnly, transOnly,
termsStyle,
selectStyle, selectStyle,
parentStyle, parentStyle,
grandStyle, grandStyle,
@@ -987,8 +989,10 @@ export class Translator {
} }
try { try {
const [processedString, placeholderMap] = const [processedString, placeholderMap] = this.#serializeForTranslation(
this.#serializeForTranslation(nodes); nodes,
termsStyle
);
// console.log("processedString", processedString); // console.log("processedString", processedString);
if (this.#isInvalidText(processedString)) return; if (this.#isInvalidText(processedString)) return;
@@ -1021,10 +1025,19 @@ export class Translator {
return; return;
} }
inner.innerHTML = this.#restoreFromTranslation( const htmlString = this.#restoreFromTranslation(
translatedText, translatedText,
placeholderMap placeholderMap
); );
const trustedHTML = trustedTypesHelper.createHTML(htmlString);
// const parser = new DOMParser();
// const doc = parser.parseFromString(trustedHTML, "text/html");
// const innerElement = doc.body.firstChild;
// inner.replaceChildren(innerElement);
inner.innerHTML = trustedHTML;
this.#translationNodes.set(wrapper, { this.#translationNodes.set(wrapper, {
nodes, nodes,
isHide: hideOrigin, isHide: hideOrigin,
@@ -1068,7 +1081,7 @@ export class Translator {
} }
// 处理节点转为翻译字符串 // 处理节点转为翻译字符串
#serializeForTranslation(nodes) { #serializeForTranslation(nodes, termsStyle) {
let replaceCounter = 0; // {{n}} let replaceCounter = 0; // {{n}}
let wrapCounter = 0; // <tagn> let wrapCounter = 0; // <tagn>
const placeholderMap = new Map(); const placeholderMap = new Map();
@@ -1108,7 +1121,7 @@ export class Translator {
const termValue = this.#termValues[matchedIndex]; const termValue = this.#termValues[matchedIndex];
return pushReplace( return pushReplace(
`<i class="${Translator.KISS_CLASS.term}">${termValue || fullMatch}</i>` `<i class="${Translator.KISS_CLASS.term}" style="${termsStyle}">${termValue || fullMatch}</i>`
); );
}); });
} }
@@ -1382,7 +1395,8 @@ export class Translator {
injectJs && sendBgMsg(MSG_INJECT_JS, injectJs); injectJs && sendBgMsg(MSG_INJECT_JS, injectJs);
injectCss && sendBgMsg(MSG_INJECT_CSS, injectCss); injectCss && sendBgMsg(MSG_INJECT_CSS, injectCss);
} else { } else {
injectJs && injectInlineJs(injectJs); injectJs &&
injectInlineJs(injectJs, "kiss-translator-userinit-injector");
injectCss && injectInternalCss(injectCss); injectCss && injectInternalCss(injectCss);
} }
} catch (err) { } catch (err) {

33
src/libs/trustedTypes.js Normal file
View File

@@ -0,0 +1,33 @@
export const trustedTypesHelper = (() => {
const POLICY_NAME = "kiss-translator-policy";
let policy = null;
if (globalThis.trustedTypes && globalThis.trustedTypes.createPolicy) {
try {
policy = globalThis.trustedTypes.createPolicy(POLICY_NAME, {
createHTML: (string) => string,
createScript: (string) => string,
createScriptURL: (string) => string,
});
} catch (err) {
if (err.message.includes("already exists")) {
policy = globalThis.trustedTypes.policies.get(POLICY_NAME);
} else {
console.error("cont create Trusted Types", err);
}
}
}
return {
createHTML: (htmlString) => {
return policy ? policy.createHTML(htmlString) : htmlString;
},
createScript: (scriptString) => {
return policy ? policy.createScript(scriptString) : scriptString;
},
createScriptURL: (urlString) => {
return policy ? policy.createScriptURL(urlString) : urlString;
},
isEnabled: () => policy !== null,
};
})();

View File

@@ -15,7 +15,7 @@ export const limitNumber = (num, min = 0, max = 100) => {
return number; return number;
}; };
export const limitFloat = (num, min = 0, max = 100) => { export const limitFloat = (num, min = 0.0, max = 100.0) => {
const number = parseFloat(num); const number = parseFloat(num);
if (Number.isNaN(number) || number < min) { if (Number.isNaN(number) || number < min) {
return min; return min;

View File

@@ -258,7 +258,7 @@ export class BilingualSubtitleManager {
p1.textContent = truncateWords(subtitle.text); p1.textContent = truncateWords(subtitle.text);
const p2 = document.createElement("p"); const p2 = document.createElement("p");
p2.style.cssText = this.#setting.originStyle; p2.style.cssText = this.#setting.translationStyle;
p2.textContent = truncateWords(subtitle.translation) || "..."; p2.textContent = truncateWords(subtitle.translation) || "...";
if (this.#setting.isBilingual) { if (this.#setting.isBilingual) {

View File

@@ -0,0 +1,19 @@
export const XMLHttpRequestInjector = () => {
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (...args) {
const url = args[1];
if (typeof url === "string" && url.includes("timedtext")) {
this.addEventListener("load", function () {
window.postMessage(
{
type: "KISS_XHR_DATA_YOUTUBE",
url: this.responseURL,
response: this.responseText,
},
window.location.origin
);
});
}
return originalOpen.apply(this, args);
};
};

View File

@@ -38,7 +38,6 @@ class YouTubeCaptionProvider {
initialize() { initialize() {
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type === MSG_XHR_DATA_YOUTUBE) { if (event.data?.type === MSG_XHR_DATA_YOUTUBE) {
const { url, response } = event.data; const { url, response } = event.data;
if (url && response) { if (url && response) {
@@ -66,23 +65,50 @@ class YouTubeCaptionProvider {
}); });
} }
get #videoEl() {
return document.querySelector(VIDEO_SELECT);
}
#moAds(adContainer) { #moAds(adContainer) {
const adSlector = ".ytp-ad-player-overlay-layout"; const adLayoutSelector = ".ytp-ad-player-overlay-layout";
const skipBtnSelector =
".ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern";
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) { for (const mutation of mutations) {
if (mutation.type === "childList") { if (mutation.type === "childList") {
const videoEl = this.#videoEl;
mutation.addedNodes.forEach((node) => { mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.matches(adSlector)) { if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches(adLayoutSelector)) {
logger.debug("Youtube Provider: AD start playing!", node); logger.debug("Youtube Provider: AD start playing!", node);
// todo: 顺带把广告快速跳过 // todo: 顺带把广告快速跳过
if (videoEl) {
videoEl.playbackRate = 16;
videoEl.currentTime = videoEl.duration;
}
if (this.#managerInstance) { if (this.#managerInstance) {
this.#managerInstance.setIsAdPlaying(true); this.#managerInstance.setIsAdPlaying(true);
} }
} else if (node.matches(skipBtnSelector)) {
logger.debug("Youtube Provider: AD skip button!", node);
node.click();
}
const skipBtn = node?.querySelector(skipBtnSelector);
if (skipBtn) {
logger.debug("Youtube Provider: AD skip button!!", skipBtn);
skipBtn.click();
} }
}); });
mutation.removedNodes.forEach((node) => { mutation.removedNodes.forEach((node) => {
if (node.nodeType === 1 && node.matches(adSlector)) { if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches(adLayoutSelector)) {
logger.debug("Youtube Provider: Ad ends!"); logger.debug("Youtube Provider: Ad ends!");
if (videoEl) {
videoEl.playbackRate = 1;
}
if (this.#managerInstance) { if (this.#managerInstance) {
this.#managerInstance.setIsAdPlaying(false); this.#managerInstance.setIsAdPlaying(false);
} }
@@ -468,7 +494,7 @@ class YouTubeCaptionProvider {
return; return;
} }
const videoEl = document.querySelector(VIDEO_SELECT); const videoEl = this.#videoEl;
if (!videoEl) { if (!videoEl) {
logger.warn("Youtube Provider: No video element found"); logger.warn("Youtube Provider: No video element found");
return; return;
@@ -879,7 +905,7 @@ class YouTubeCaptionProvider {
textAlign: "center", textAlign: "center",
}); });
const videoEl = document.querySelector(VIDEO_SELECT); const videoEl = this.#videoEl;
const videoContainer = videoEl?.parentElement?.parentElement; const videoContainer = videoEl?.parentElement?.parentElement;
if (videoContainer) { if (videoContainer) {
videoContainer.appendChild(notificationEl); videoContainer.appendChild(notificationEl);

View File

@@ -5,12 +5,14 @@ import { DEFAULT_API_SETTING } from "../config/api.js";
import { DEFAULT_SUBTITLE_SETTING } from "../config/setting.js"; import { DEFAULT_SUBTITLE_SETTING } from "../config/setting.js";
import { injectExternalJs } from "../libs/injector.js"; import { injectExternalJs } from "../libs/injector.js";
import { logger } from "../libs/log.js"; import { logger } from "../libs/log.js";
import { XMLHttpRequestInjector } from "./XMLHttpRequestInjector.js";
import { injectInlineJs } from "../libs/injector.js";
const providers = [ const providers = [
{ pattern: "https://www.youtube.com", start: YouTubeInitializer }, { pattern: "https://www.youtube.com", start: YouTubeInitializer },
]; ];
export function runSubtitle({ href, setting }) { export function runSubtitle({ href, setting, isUserscript }) {
try { try {
const subtitleSetting = setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING; const subtitleSetting = setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING;
if (!subtitleSetting.enabled) { if (!subtitleSetting.enabled) {
@@ -19,9 +21,13 @@ export function runSubtitle({ href, setting }) {
const provider = providers.find((item) => isMatch(href, item.pattern)); const provider = providers.find((item) => isMatch(href, item.pattern));
if (provider) { if (provider) {
const id = "kiss-translator-injector"; const id = "kiss-translator-xmlHttp-injector";
const src = browser.runtime.getURL("injector.js"); if (isUserscript) {
injectExternalJs(src, id); injectInlineJs(`(${XMLHttpRequestInjector})()`, id);
} else {
const src = browser.runtime.getURL("injector.js");
injectExternalJs(src, id);
}
const apiSetting = const apiSetting =
setting.transApis.find( setting.transApis.find(

View File

@@ -17,6 +17,7 @@ import Alert from "@mui/material/Alert";
import Menu from "@mui/material/Menu"; import Menu from "@mui/material/Menu";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import Link from "@mui/material/Link";
import { useAlert } from "../../hooks/Alert"; import { useAlert } from "../../hooks/Alert";
import { useApiList, useApiItem } from "../../hooks/Api"; import { useApiList, useApiItem } from "../../hooks/Api";
import { useConfirm } from "../../hooks/Confirm"; import { useConfirm } from "../../hooks/Confirm";
@@ -263,7 +264,7 @@ function ApiFields({ apiSlug, isUserApi, deleteApi }) {
/> />
)} )}
{API_SPE_TYPES.ai.has(apiType) && ( {(API_SPE_TYPES.ai.has(apiType) || apiType === OPT_TRANS_CUSTOMIZE) && (
<> <>
<Box> <Box>
<Grid container spacing={2} columns={12}> <Grid container spacing={2} columns={12}>
@@ -299,8 +300,9 @@ function ApiFields({ apiSlug, isUserApi, deleteApi }) {
name="temperature" name="temperature"
value={temperature} value={temperature}
onChange={handleChange} onChange={handleChange}
min={0} min={0.0}
max={2} max={2.0}
isFloat={true}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={12} md={6} lg={3}> <Grid item xs={12} sm={12} md={6} lg={3}>
@@ -805,6 +807,12 @@ export default function Apis() {
{i18n("about_api_2")} {i18n("about_api_2")}
<br /> <br />
{i18n("about_api_3")} {i18n("about_api_3")}
<Link
href="https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md"
target="_blank"
>
{i18n("goto_custom_api_example")}
</Link>
</Alert> </Alert>
<Box> <Box>

View File

@@ -2,12 +2,19 @@ import IconButton from "@mui/material/IconButton";
import { useDarkMode } from "../../hooks/ColorMode"; import { useDarkMode } from "../../hooks/ColorMode";
import LightModeIcon from "@mui/icons-material/LightMode"; import LightModeIcon from "@mui/icons-material/LightMode";
import DarkModeIcon from "@mui/icons-material/DarkMode"; import DarkModeIcon from "@mui/icons-material/DarkMode";
import BrightnessAutoIcon from "@mui/icons-material/BrightnessAuto";
export default function DarkModeButton() { export default function DarkModeButton() {
const { darkMode, toggleDarkMode } = useDarkMode(); const { darkMode, toggleDarkMode } = useDarkMode();
return ( return (
<IconButton onClick={toggleDarkMode} color="inherit"> <IconButton sx={{ ml: 1 }} onClick={toggleDarkMode} color="inherit">
{darkMode ? <LightModeIcon /> : <DarkModeIcon />} {darkMode === "dark" ? (
<DarkModeIcon />
) : darkMode === "light" ? (
<LightModeIcon />
) : (
<BrightnessAutoIcon />
)}
</IconButton> </IconButton>
); );
} }

View File

@@ -97,6 +97,7 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
ignoreSelector = "", ignoreSelector = "",
terms = "", terms = "",
aiTerms = "", aiTerms = "",
termsStyle = "",
selectStyle = "", selectStyle = "",
parentStyle = "", parentStyle = "",
grandStyle = "", grandStyle = "",
@@ -547,10 +548,19 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
maxRows={10} maxRows={10}
/> />
<TextField
size="small"
label={i18n("terms_style")}
name="termsStyle"
value={termsStyle}
disabled={disabled}
onChange={handleChange}
maxRows={10}
multiline
/>
<TextField <TextField
size="small" size="small"
label={i18n("selector_style")} label={i18n("selector_style")}
helperText={i18n("selector_style_helper")}
name="selectStyle" name="selectStyle"
value={selectStyle} value={selectStyle}
disabled={disabled} disabled={disabled}
@@ -561,7 +571,6 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
<TextField <TextField
size="small" size="small"
label={i18n("selector_parent_style")} label={i18n("selector_parent_style")}
helperText={i18n("selector_style_helper")}
name="parentStyle" name="parentStyle"
value={parentStyle} value={parentStyle}
disabled={disabled} disabled={disabled}
@@ -572,7 +581,6 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
<TextField <TextField
size="small" size="small"
label={i18n("selector_grand_style")} label={i18n("selector_grand_style")}
helperText={i18n("selector_style_helper")}
name="grandStyle" name="grandStyle"
value={grandStyle} value={grandStyle}
disabled={disabled} disabled={disabled}
@@ -867,9 +875,9 @@ function UserRules({ subRules, rules }) {
<UploadButton text={i18n("import")} handleImport={handleImport} /> <UploadButton text={i18n("import")} handleImport={handleImport} />
<DownloadButton <DownloadButton
handleData={() => JSON.stringify([...rules.list].reverse(), null, 2)} handleData={() => JSON.stringify([...rules.list], null, 2)}
text={i18n("export")} text={i18n("export")}
fileName={`kiss-rules_${Date.now()}.json`} fileName={`kiss-rules_v2_${Date.now()}.json`}
/> />
<DownloadButton <DownloadButton
handleData={async () => JSON.stringify(await getRulesOld(), null, 2)} handleData={async () => JSON.stringify(await getRulesOld(), null, 2)}

View File

@@ -124,7 +124,7 @@ export default function Settings() {
<DownloadButton <DownloadButton
handleData={() => JSON.stringify(setting, null, 2)} handleData={() => JSON.stringify(setting, null, 2)}
text={i18n("export")} text={i18n("export")}
fileName={`kiss-setting_${Date.now()}.json`} fileName={`kiss-setting_v2_${Date.now()}.json`}
/> />
<DownloadButton <DownloadButton
handleData={async () => handleData={async () =>

View File

@@ -471,11 +471,9 @@ export default function Popup({ setShowPopup, translator }) {
<Button variant="text" onClick={handleSaveRule}> <Button variant="text" onClick={handleSaveRule}>
{i18n("save_rule")} {i18n("save_rule")}
</Button> </Button>
{!isExt && ( <Button variant="text" onClick={handleClearCache}>
<Button variant="text" onClick={handleClearCache}> {i18n("clear_cache")}
{i18n("clear_cache")} </Button>
</Button>
)}
<Button variant="text" onClick={handleOpenSetting}> <Button variant="text" onClick={handleOpenSetting}>
{i18n("setting")} {i18n("setting")}
</Button> </Button>

View File

@@ -72,7 +72,7 @@ export default function TranCont({
<Box> <Box>
<TextField <TextField
size="small" size="small"
label={`${i18n("translated_text")} - ${apiSetting.apiSlug}`} label={`${i18n("translated_text")} - ${apiSetting.apiName}`}
// disabled // disabled
fullWidth fullWidth
multiline multiline