Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9693436bb | ||
|
|
6b18d8f934 | ||
|
|
65c325de9a | ||
|
|
8da5aaf259 | ||
|
|
00e8fdd3e6 | ||
|
|
b2eea5d0d7 | ||
|
|
9a8e24f590 | ||
|
|
32c6d45cb0 | ||
|
|
74ce6f2f1f | ||
|
|
573865cf10 | ||
|
|
56d4733e2a | ||
|
|
a8965a01e3 | ||
|
|
beef51ef38 | ||
|
|
b5b3ee8709 | ||
|
|
4f1e01dde0 | ||
|
|
d42ff51de5 | ||
|
|
c39861b7b7 | ||
|
|
d39b9fd73e | ||
|
|
ecab4ab634 | ||
|
|
5e67e15842 | ||
|
|
2af1a8b72c | ||
|
|
2510ed0ebb | ||
|
|
6827985289 | ||
|
|
a095a2c01c | ||
|
|
2033ff6777 | ||
|
|
0c22288833 | ||
|
|
0576150067 | ||
|
|
9cdcf616f7 | ||
|
|
2de10364f3 |
2
.env
2
.env
@@ -2,7 +2,7 @@ GENERATE_SOURCEMAP=false
|
||||
|
||||
REACT_APP_NAME=KISS Translator
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
>
|
||||
> - 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.)
|
||||
|
||||
- [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.
|
||||
|
||||
### 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
|
||||
|
||||
This is a side project with no strict timeline. Community contributions are welcome. The following are preliminary feature directions:
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
> 注:基于以下原因,建议优先使用浏览器扩展
|
||||
>
|
||||
> - 浏览器扩展的功能更完整(字幕翻译、本地语言识别、右键菜单等)
|
||||
> - 浏览器扩展的功能更完整(本地语言识别、右键菜单等)
|
||||
> - 油猴脚本会遇到更多使用上的问题(跨域问题、脚本冲突等)
|
||||
|
||||
- [x] 浏览器扩展
|
||||
@@ -143,6 +143,12 @@
|
||||
|
||||
油猴脚本需要增加域名白名单,否则不能发出请求。
|
||||
|
||||
### 如何设置自定义接口的hook函数
|
||||
|
||||
自定义接口功能非常强大、灵活,理论可以接入任何翻译接口。
|
||||
|
||||
示例参考: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
|
||||
|
||||
## 未来规划
|
||||
|
||||
本项目为业余开发,无严格时间表,欢迎社区共建。以下为初步设想的功能方向:
|
||||
|
||||
@@ -106,6 +106,7 @@ const userscriptWebpack = (config, env) => {
|
||||
// @connect openai.azure.com
|
||||
// @connect workers.dev
|
||||
// @connect github.io
|
||||
// @connect github.com
|
||||
// @connect githubusercontent.com
|
||||
// @connect kiss-translator.rayjar.com
|
||||
// @connect ghproxy.com
|
||||
@@ -130,7 +131,6 @@ const userscriptWebpack = (config, env) => {
|
||||
config.entry = {
|
||||
main: paths.appIndexJs,
|
||||
options: paths.appSrc + "/options.js",
|
||||
injector: paths.appSrc + "/injector.js",
|
||||
"kiss-translator.user": paths.appSrc + "/userscript.js",
|
||||
};
|
||||
|
||||
|
||||
@@ -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 量化模型
|
||||
|
||||
206
custom-api_v2.md
Normal file
206
custom-api_v2.md
Normal 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 || ""]] };
|
||||
};
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kiss-translator",
|
||||
"description": "A minimalist bilingual translation Extension & Greasemonkey Script",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"default_locale": "en",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"homepage_url": "https://github.com/fishjar/kiss-translator",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"default_locale": "en",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"homepage_url": "https://github.com/fishjar/kiss-translator",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"default_locale": "en",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"homepage_url": "https://github.com/fishjar/kiss-translator",
|
||||
|
||||
@@ -106,7 +106,7 @@ export const apiMicrosoftLangdetect = async (text) => {
|
||||
|
||||
const key = `${URL_CACHE_DELANG}_${OPT_TRANS_MICROSOFT}`;
|
||||
const queue = getBatchQueue(key, handleMicrosoftLangdetect, {
|
||||
batchInterval: 500,
|
||||
batchInterval: 200,
|
||||
batchSize: 20,
|
||||
batchLength: 100000,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
INPUT_PLACE_KEY,
|
||||
INPUT_PLACE_MODEL,
|
||||
DEFAULT_USER_AGENT,
|
||||
defaultSystemPrompt,
|
||||
defaultSubtitlePrompt,
|
||||
} from "../config";
|
||||
import { msAuth } from "../libs/auth";
|
||||
import { genDeeplFree } from "./deepl";
|
||||
@@ -98,8 +100,9 @@ const parseAIRes = (raw) => {
|
||||
|
||||
try {
|
||||
const jsonString = extractJson(raw);
|
||||
const data = JSON.parse(jsonString);
|
||||
if (!jsonString) return [];
|
||||
|
||||
const data = JSON.parse(jsonString);
|
||||
if (Array.isArray(data.translations)) {
|
||||
// todo: 考虑序号id可能会打乱
|
||||
return data.translations.map((item) => [
|
||||
@@ -677,13 +680,16 @@ export const genTransReq = async ({ reqHook, ...args }) => {
|
||||
if (reqHook?.trim() && !events) {
|
||||
try {
|
||||
interpreter.run(`exports.reqHook = ${reqHook}`);
|
||||
const hookResult = await interpreter.exports.reqHook(args, {
|
||||
url,
|
||||
body,
|
||||
headers,
|
||||
userMsg,
|
||||
method,
|
||||
});
|
||||
const hookResult = await interpreter.exports.reqHook(
|
||||
{ ...args, defaultSystemPrompt, defaultSubtitlePrompt },
|
||||
{
|
||||
url,
|
||||
body,
|
||||
headers,
|
||||
userMsg,
|
||||
method,
|
||||
}
|
||||
);
|
||||
if (hookResult && hookResult.url) {
|
||||
return genInit(hookResult);
|
||||
}
|
||||
@@ -731,6 +737,8 @@ export const parseTransRes = async (
|
||||
fromLang,
|
||||
toLang,
|
||||
langMap,
|
||||
extractJson,
|
||||
parseAIRes,
|
||||
});
|
||||
if (hookResult && Array.isArray(hookResult.translations)) {
|
||||
if (history && userMsg && hookResult.modelMsg) {
|
||||
@@ -925,7 +933,7 @@ export const handleTranslate = async (
|
||||
userMsg,
|
||||
...apiSetting,
|
||||
});
|
||||
if (!Array.isArray(result)) {
|
||||
if (!result?.length) {
|
||||
throw new Error("tranlate got an unexpected result");
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
CMD_OPEN_TRANBOX,
|
||||
CLIENT_THUNDERBIRD,
|
||||
MSG_SET_LOGLEVEL,
|
||||
MSG_CLEAR_CACHES,
|
||||
} from "./config";
|
||||
import { getSettingWithDefault, tryInitDefaultData } from "./libs/storage";
|
||||
import { trySyncSettingAndRules } from "./libs/sync";
|
||||
@@ -275,6 +276,7 @@ const messageHandlers = {
|
||||
[MSG_BUILTINAI_DETECT]: (args) => chromeDetect(args),
|
||||
[MSG_BUILTINAI_TRANSLATE]: (args) => chromeTranslate(args),
|
||||
[MSG_SET_LOGLEVEL]: (args) => logger.setLevel(args),
|
||||
[MSG_CLEAR_CACHES]: () => tryClearCaches(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ import { trySyncAllSubRules } from "./libs/subRules";
|
||||
import { isInBlacklist } from "./libs/blacklist";
|
||||
import { runSubtitle } from "./subtitle/subtitle";
|
||||
import { logger } from "./libs/log";
|
||||
import { injectInlineJs } from "./libs/injector";
|
||||
|
||||
/**
|
||||
* 油猴脚本设置页面
|
||||
@@ -35,9 +36,10 @@ function runSettingPage() {
|
||||
const ping = genEventName();
|
||||
window.addEventListener(ping, handlePing);
|
||||
// window.eval(`(${injectScript})("${ping}")`); // eslint-disable-line
|
||||
const script = document.createElement("script");
|
||||
script.textContent = `(${injectScript})("${ping}")`;
|
||||
document.head.append(script);
|
||||
injectInlineJs(
|
||||
`(${injectScript})("${ping}")`,
|
||||
"kiss-translator-options-injector"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +129,7 @@ function showErr(message) {
|
||||
});
|
||||
|
||||
const closeButton = document.createElement("span");
|
||||
closeButton.innerHTML = "×";
|
||||
closeButton.textContent = "×";
|
||||
|
||||
Object.assign(closeButton.style, {
|
||||
position: "absolute",
|
||||
@@ -216,7 +218,7 @@ export async function run(isUserscript = false) {
|
||||
}
|
||||
|
||||
// 字幕翻译
|
||||
runSubtitle({ href, setting, rule });
|
||||
runSubtitle({ href, setting, rule, isUserscript });
|
||||
|
||||
// 监听消息
|
||||
// !isUserscript && runtimeListener(translator);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const DEFAULT_HTTP_TIMEOUT = 10000; // 调用超时时间
|
||||
export const DEFAULT_FETCH_LIMIT = 10; // 默认最大任务数量
|
||||
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_LENGTH = 10000; // 每次发送最大文字数量
|
||||
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);
|
||||
});
|
||||
|
||||
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:
|
||||
{"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]').
|
||||
// `;
|
||||
|
||||
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:**
|
||||
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 } = {}) => {
|
||||
console.log("request hook args:", args);
|
||||
console.log("request hook args:", { args, url, body, headers, userMsg, method });
|
||||
// return { url, body, headers, userMsg, method };
|
||||
}`;
|
||||
};`;
|
||||
|
||||
const defaultResponseHook = `async ({ res, ...args }) => {
|
||||
console.log("reaponse hook args:", res, args);
|
||||
console.log("reaponse hook args:", { res, args });
|
||||
// const translations = [["你好", "zh"]];
|
||||
// const modelMsg = "";
|
||||
// return { translations, modelMsg };
|
||||
}`;
|
||||
};`;
|
||||
|
||||
// 翻译接口默认参数
|
||||
const defaultApi = {
|
||||
@@ -448,7 +448,7 @@ const defaultApi = {
|
||||
useBatchFetch: false, // 是否启用聚合发送请求
|
||||
useContext: false, // 是否启用智能上下文
|
||||
contextSize: DEFAULT_CONTEXT_SIZE, // 智能上下文保留会话数
|
||||
temperature: 0,
|
||||
temperature: 0.0,
|
||||
maxTokens: 20480,
|
||||
think: false,
|
||||
thinkIgnore: "qwen3,deepseek-r1",
|
||||
|
||||
@@ -137,46 +137,42 @@ ${customApiLangs}
|
||||
`;
|
||||
|
||||
const requestHookHelperZH = `1、第一个参数包含如下字段:'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ...
|
||||
2、返回值必须是包含以下字段的对象: 'url', 'body', 'headers', 'userMsg', 'method'
|
||||
2、返回值必须是包含以下字段的对象: 'url', 'body', 'headers', 'method'
|
||||
3、如返回空值,则hook函数不会产生任何效果。
|
||||
|
||||
// 示例
|
||||
async (args, { url, body, headers, userMsg, method } = {}) => {
|
||||
console.log("request hook args:", args);
|
||||
return { url, body, headers, userMsg, method };
|
||||
}`;
|
||||
|
||||
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.
|
||||
|
||||
// Example
|
||||
async (args, { url, body, headers, userMsg, method } = {}) => {
|
||||
console.log("request hook args:", args);
|
||||
return { url, body, headers, userMsg, method };
|
||||
}`;
|
||||
|
||||
const responsetHookHelperZH = `1、第一个参数包含如下字段:'res', ...
|
||||
2、返回值必须是包含以下字段的对象: 'translations', 'modelMsg'
|
||||
2、返回值必须是包含以下字段的对象: 'translations'
|
||||
('translations' 应为一个二维数组:[[译文, 源语言]])
|
||||
3、如返回空值,则hook函数不会产生任何效果。
|
||||
|
||||
// 示例
|
||||
async ({ res, ...args }) => {
|
||||
console.log("reaponse hook args:", res, args);
|
||||
const translations = [["你好", "zh"]];
|
||||
const modelMsg = "";
|
||||
return { translations, modelMsg };
|
||||
}`;
|
||||
|
||||
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]]).
|
||||
3. If a null value is returned, the hook function will have no effect.
|
||||
|
||||
// Example
|
||||
async ({ res, ...args }) => {
|
||||
console.log("reaponse hook args:", res, args);
|
||||
const translations = [["你好", "zh"]];
|
||||
const modelMsg = "";
|
||||
return { translations, modelMsg };
|
||||
@@ -718,6 +714,11 @@ export const I18N = {
|
||||
en: `Selector Style`,
|
||||
zh_TW: `選擇器節點樣式`,
|
||||
},
|
||||
terms_style: {
|
||||
zh: `专业术语样式`,
|
||||
en: `Terms Style`,
|
||||
zh_TW: `專業術語樣式`,
|
||||
},
|
||||
selector_style_helper: {
|
||||
zh: `开启翻译时注入。`,
|
||||
en: `It is injected when translation is turned on.`,
|
||||
@@ -1609,8 +1610,8 @@ export const I18N = {
|
||||
zh_TW: `AI处理切割长度(200-20000)`,
|
||||
},
|
||||
subtitle_helper_1: {
|
||||
zh: `1、目前仅支持Youtube桌面网站,且仅支持浏览器扩展。`,
|
||||
en: `1. Currently only supports Youtube desktop website and browser extension.`,
|
||||
zh: `1、目前仅支持Youtube桌面网站。`,
|
||||
en: `1. Currently only supports Youtube desktop website.`,
|
||||
zh_TW: `1.目前僅支援Youtube桌面網站,且僅支援瀏覽器擴充功能。`,
|
||||
},
|
||||
subtitle_helper_2: {
|
||||
@@ -1663,6 +1664,11 @@ export const I18N = {
|
||||
en: `Log Level`,
|
||||
zh_TW: `日誌等級`,
|
||||
},
|
||||
goto_custom_api_example: {
|
||||
zh: `点击查看【自定义接口示例】`,
|
||||
en: `Click to view [Custom Interface Example]`,
|
||||
zh_TW: `點選查看【自訂介面範例】`,
|
||||
},
|
||||
};
|
||||
|
||||
export const i18n = (lang) => (key) => I18N[key]?.[lang] || "";
|
||||
|
||||
@@ -3,7 +3,7 @@ export const CMD_TOGGLE_STYLE = "toggleStyle";
|
||||
export const CMD_OPEN_OPTIONS = "openOptions";
|
||||
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_PUT_HTTPCACHE = "put_httpcache";
|
||||
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_TRANSLATE = "builtinai_translte";
|
||||
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_GLOBAL_VAR_FETCH = "KISS_GLOBAL_VAR_FETCH";
|
||||
|
||||
@@ -78,8 +78,7 @@ background: linear-gradient(
|
||||
|
||||
export const DEFAULT_SELECTOR =
|
||||
"h1, h2, h3, h4, h5, h6, li, p, dd, blockquote, figcaption, label, legend";
|
||||
export const DEFAULT_IGNORE_SELECTOR =
|
||||
"aside, button, footer, form, pre, mark, nav";
|
||||
export const DEFAULT_IGNORE_SELECTOR = "button, footer, pre, mark, nav";
|
||||
export const DEFAULT_KEEP_SELECTOR = `a:has(code)`;
|
||||
export const DEFAULT_RULE = {
|
||||
pattern: "", // 匹配网址
|
||||
@@ -94,6 +93,7 @@ export const DEFAULT_RULE = {
|
||||
transOpen: GLOBAL_KEY, // 开启翻译
|
||||
bgColor: "", // 译文颜色
|
||||
textDiyStyle: "", // 自定义译文样式
|
||||
termsStyle: "", // 专业术语样式
|
||||
selectStyle: "", // 选择器节点样式
|
||||
parentStyle: "", // 选择器父节点样式
|
||||
grandStyle: "", // 选择器父节点样式
|
||||
@@ -132,6 +132,7 @@ export const GLOBLA_RULE = {
|
||||
transOpen: "false", // 开启翻译
|
||||
bgColor: "", // 译文颜色
|
||||
textDiyStyle: DEFAULT_DIY_STYLE, // 自定义译文样式
|
||||
termsStyle: "font-weight: bold;", // 专业术语样式
|
||||
selectStyle: DEFAULT_SELECT_STYLE, // 选择器节点样式
|
||||
parentStyle: DEFAULT_SELECT_STYLE, // 选择器父节点样式
|
||||
grandStyle: DEFAULT_SELECT_STYLE, // 选择器祖节点样式
|
||||
@@ -192,6 +193,11 @@ const RULES_MAP = {
|
||||
rootsSelector: `ytd-page-manager`,
|
||||
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)
|
||||
|
||||
@@ -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 = {
|
||||
useMouseHover: true, // 是否启用鼠标悬停翻译
|
||||
useMouseHover: false, // 是否启用鼠标悬停翻译
|
||||
mouseHoverKey: DEFAULT_MOUSEHOVER_KEY, // 鼠标悬停翻译组合键
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTING = {
|
||||
darkMode: false, // 深色模式
|
||||
darkMode: "auto", // 深色模式
|
||||
uiLang: "en", // 界面语言
|
||||
// fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至rule,作废)
|
||||
// fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至rule,作废)
|
||||
|
||||
@@ -12,7 +12,12 @@ export function useDarkMode() {
|
||||
} = useSetting();
|
||||
|
||||
const toggleDarkMode = useCallback(() => {
|
||||
updateSetting({ darkMode: !darkMode });
|
||||
const nextMode = {
|
||||
light: "dark",
|
||||
dark: "auto",
|
||||
auto: "light",
|
||||
};
|
||||
updateSetting({ darkMode: nextMode[darkMode] || "light" });
|
||||
}, [darkMode, updateSetting]);
|
||||
|
||||
return { darkMode, toggleDarkMode };
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { STOKEY_WORDS, KV_WORDS_KEY } from "../config";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useStorage } from "./Storage";
|
||||
import { debounceSyncMeta } from "../libs/storage";
|
||||
|
||||
const DEFAULT_FAVWORDS = {};
|
||||
|
||||
export function useFavWords() {
|
||||
const { data: favWords, save } = useStorage(
|
||||
const { data: favWords, save: saveWords } = useStorage(
|
||||
STOKEY_WORDS,
|
||||
DEFAULT_FAVWORDS,
|
||||
KV_WORDS_KEY
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
(objOrFn) => {
|
||||
saveWords(objOrFn);
|
||||
debounceSyncMeta(KV_WORDS_KEY);
|
||||
},
|
||||
[saveWords]
|
||||
);
|
||||
|
||||
const toggleFav = useCallback(
|
||||
(word) => {
|
||||
save((prev) => {
|
||||
|
||||
@@ -2,18 +2,27 @@ import { STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY } from "../config";
|
||||
import { useStorage } from "./Storage";
|
||||
import { checkRules } from "../libs/rules";
|
||||
import { useCallback } from "react";
|
||||
import { debounceSyncMeta } from "../libs/storage";
|
||||
|
||||
/**
|
||||
* 规则 hook
|
||||
* @returns
|
||||
*/
|
||||
export function useRules() {
|
||||
const { data: list = [], save } = useStorage(
|
||||
const { data: list = [], save: saveRules } = useStorage(
|
||||
STOKEY_RULES,
|
||||
DEFAULT_RULES,
|
||||
KV_RULES_KEY
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
(objOrFn) => {
|
||||
saveRules(objOrFn);
|
||||
debounceSyncMeta(KV_RULES_KEY);
|
||||
},
|
||||
[saveRules]
|
||||
);
|
||||
|
||||
const add = useCallback(
|
||||
(rule) => {
|
||||
save((prev) => {
|
||||
@@ -48,11 +57,7 @@ export function useRules() {
|
||||
const put = useCallback(
|
||||
(pattern, obj) => {
|
||||
save((prev) => {
|
||||
if (
|
||||
prev.some(
|
||||
(item) => item.pattern === obj.pattern && item.pattern !== pattern
|
||||
)
|
||||
) {
|
||||
if (pattern !== obj.pattern) {
|
||||
return prev;
|
||||
}
|
||||
return prev.map((item) =>
|
||||
@@ -71,15 +76,26 @@ export function useRules() {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
// 不进行深度合并
|
||||
// [...prev, ...adds].forEach((item) => {
|
||||
// const k = item.pattern;
|
||||
// map.set(k, { ...(map.get(k) || {}), ...item });
|
||||
// });
|
||||
prev.forEach((item) => map.set(item.pattern, item));
|
||||
adds.forEach((item) => map.set(item.pattern, item));
|
||||
return [...map.values()];
|
||||
// const map = new Map();
|
||||
// // 不进行深度合并
|
||||
// // [...prev, ...adds].forEach((item) => {
|
||||
// // const k = item.pattern;
|
||||
// // map.set(k, { ...(map.get(k) || {}), ...item });
|
||||
// // });
|
||||
// prev.forEach((item) => map.set(item.pattern, item));
|
||||
// adds.forEach((item) => map.set(item.pattern, item));
|
||||
// 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]
|
||||
|
||||
@@ -17,6 +17,7 @@ import { debounceSyncMeta } from "../libs/storage";
|
||||
import Loading from "./Loading";
|
||||
import { logger } from "../libs/log";
|
||||
import { sendBgMsg } from "../libs/msg";
|
||||
import { isExt } from "../libs/client";
|
||||
|
||||
const SettingContext = createContext({
|
||||
setting: DEFAULT_SETTING,
|
||||
@@ -32,11 +33,22 @@ export function SettingProvider({ children }) {
|
||||
reload,
|
||||
} = 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(() => {
|
||||
(async () => {
|
||||
try {
|
||||
logger.setLevel(setting?.logLevel);
|
||||
await sendBgMsg(MSG_SET_LOGLEVEL, setting?.logLevel);
|
||||
if (isExt) {
|
||||
await sendBgMsg(MSG_SET_LOGLEVEL, setting?.logLevel);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch log level, using default.", error);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import { CssBaseline, GlobalStyles } from "@mui/material";
|
||||
import { useDarkMode } from "./ColorMode";
|
||||
@@ -11,6 +11,21 @@ import { THEME_DARK, THEME_LIGHT } from "../config";
|
||||
*/
|
||||
export default function Theme({ children, options, styles }) {
|
||||
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(() => {
|
||||
let htmlFontSize = 16;
|
||||
try {
|
||||
@@ -23,16 +38,19 @@ export default function Theme({ children, options, styles }) {
|
||||
//
|
||||
}
|
||||
|
||||
const isDarkMode =
|
||||
darkMode === "dark" || (darkMode === "auto" && systemMode === THEME_DARK);
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode: darkMode ? THEME_DARK : THEME_LIGHT,
|
||||
mode: isDarkMode ? THEME_DARK : THEME_LIGHT,
|
||||
},
|
||||
typography: {
|
||||
htmlFontSize,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}, [darkMode, options]);
|
||||
}, [darkMode, options, systemMode]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useState, useEffect } from "react";
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,7 +29,9 @@ function ValidationInput({ value, onChange, name, min, max, ...props }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedValue = limitNumber(numValue, min, max);
|
||||
const validatedValue = isFloat
|
||||
? limitFloat(numValue, min, max)
|
||||
: limitNumber(numValue, min, max);
|
||||
|
||||
if (validatedValue !== numValue) {
|
||||
setLocalValue(validatedValue);
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
import { MSG_XHR_DATA_YOUTUBE } from "./config";
|
||||
import { XMLHttpRequestInjector } from "./subtitle/XMLHttpRequestInjector";
|
||||
|
||||
(function () {
|
||||
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);
|
||||
};
|
||||
})();
|
||||
XMLHttpRequestInjector();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
CACHE_NAME,
|
||||
DEFAULT_CACHE_TIMEOUT,
|
||||
MSG_CLEAR_CACHES,
|
||||
MSG_GET_HTTPCACHE,
|
||||
MSG_PUT_HTTPCACHE,
|
||||
} from "../config";
|
||||
@@ -15,7 +16,11 @@ import { blobToBase64 } from "./utils";
|
||||
*/
|
||||
export const tryClearCaches = async () => {
|
||||
try {
|
||||
caches.delete(CACHE_NAME);
|
||||
if (isExt && !isBg) {
|
||||
await sendBgMsg(MSG_CLEAR_CACHES);
|
||||
} else {
|
||||
await caches.delete(CACHE_NAME);
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("clean caches", err);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
// Function to inject inline JavaScript code
|
||||
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);
|
||||
};
|
||||
import { trustedTypesHelper } from "./trustedTypes";
|
||||
|
||||
// Function to inject external JavaScript file
|
||||
export const injectExternalJs = (src, id = "kiss-translator-injector") => {
|
||||
// Function to inject inline JavaScript code
|
||||
export const injectInlineJs = (code, id = "kiss-translator-inline-js") => {
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// const el = document.createElement("script");
|
||||
// el.setAttribute("data-source", "kiss-inject injectExternalJs");
|
||||
// el.setAttribute("type", "text/javascript");
|
||||
// el.setAttribute("src", src);
|
||||
// el.setAttribute("id", id);
|
||||
// document.body?.appendChild(el);
|
||||
const script = document.createElement("script");
|
||||
script.id = id;
|
||||
script.src = src;
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
const el = document.createElement("script");
|
||||
el.type = "text/javascript";
|
||||
el.id = id;
|
||||
el.textContent = trustedTypesHelper.createScript(code);
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
};
|
||||
|
||||
// Function to inject external JavaScript file
|
||||
export const injectExternalJs = (src, id = "kiss-translator-external-js") => {
|
||||
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
|
||||
|
||||
@@ -52,6 +52,7 @@ export const matchRule = async (href, { injectRules, subrulesList }) => {
|
||||
"ignoreSelector",
|
||||
"terms",
|
||||
"aiTerms",
|
||||
"termsStyle",
|
||||
"selectStyle",
|
||||
"parentStyle",
|
||||
"grandStyle",
|
||||
@@ -136,6 +137,7 @@ export const checkRules = (rules) => {
|
||||
ignoreSelector,
|
||||
terms,
|
||||
aiTerms,
|
||||
termsStyle,
|
||||
selectStyle,
|
||||
parentStyle,
|
||||
grandStyle,
|
||||
@@ -170,6 +172,7 @@ export const checkRules = (rules) => {
|
||||
ignoreSelector: type(ignoreSelector) === "string" ? ignoreSelector : "",
|
||||
terms: type(terms) === "string" ? terms : "",
|
||||
aiTerms: type(aiTerms) === "string" ? aiTerms : "",
|
||||
termsStyle: type(termsStyle) === "string" ? termsStyle : "",
|
||||
selectStyle: type(selectStyle) === "string" ? selectStyle : "",
|
||||
parentStyle: type(parentStyle) === "string" ? parentStyle : "",
|
||||
grandStyle: type(grandStyle) === "string" ? grandStyle : "",
|
||||
|
||||
@@ -34,12 +34,18 @@ export const shortcutListener = (
|
||||
pressedKeys.delete(e.code);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
pressedKeys.clear();
|
||||
};
|
||||
|
||||
target.addEventListener("keydown", handleKeyDown);
|
||||
target.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
return () => {
|
||||
target.removeEventListener("keydown", handleKeyDown);
|
||||
target.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
pressedKeys.clear();
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ import { browser } from "./browser";
|
||||
import { isIframe, sendIframeMsg } from "./iframe";
|
||||
import { TransboxManager } from "./tranbox";
|
||||
import { InputTranslator } from "./inputTranslate";
|
||||
import { trustedTypesHelper } from "./trustedTypes";
|
||||
|
||||
/**
|
||||
* @class Translator
|
||||
@@ -958,6 +959,7 @@ export class Translator {
|
||||
transStartHook,
|
||||
transEndHook,
|
||||
transOnly,
|
||||
termsStyle,
|
||||
selectStyle,
|
||||
parentStyle,
|
||||
grandStyle,
|
||||
@@ -987,8 +989,10 @@ export class Translator {
|
||||
}
|
||||
|
||||
try {
|
||||
const [processedString, placeholderMap] =
|
||||
this.#serializeForTranslation(nodes);
|
||||
const [processedString, placeholderMap] = this.#serializeForTranslation(
|
||||
nodes,
|
||||
termsStyle
|
||||
);
|
||||
// console.log("processedString", processedString);
|
||||
if (this.#isInvalidText(processedString)) return;
|
||||
|
||||
@@ -1021,10 +1025,19 @@ export class Translator {
|
||||
return;
|
||||
}
|
||||
|
||||
inner.innerHTML = this.#restoreFromTranslation(
|
||||
const htmlString = this.#restoreFromTranslation(
|
||||
translatedText,
|
||||
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, {
|
||||
nodes,
|
||||
isHide: hideOrigin,
|
||||
@@ -1068,7 +1081,7 @@ export class Translator {
|
||||
}
|
||||
|
||||
// 处理节点转为翻译字符串
|
||||
#serializeForTranslation(nodes) {
|
||||
#serializeForTranslation(nodes, termsStyle) {
|
||||
let replaceCounter = 0; // {{n}}
|
||||
let wrapCounter = 0; // <tagn>
|
||||
const placeholderMap = new Map();
|
||||
@@ -1108,7 +1121,7 @@ export class Translator {
|
||||
const termValue = this.#termValues[matchedIndex];
|
||||
|
||||
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);
|
||||
injectCss && sendBgMsg(MSG_INJECT_CSS, injectCss);
|
||||
} else {
|
||||
injectJs && injectInlineJs(injectJs);
|
||||
injectJs &&
|
||||
injectInlineJs(injectJs, "kiss-translator-userinit-injector");
|
||||
injectCss && injectInternalCss(injectCss);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
33
src/libs/trustedTypes.js
Normal file
33
src/libs/trustedTypes.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
@@ -15,7 +15,7 @@ export const limitNumber = (num, min = 0, max = 100) => {
|
||||
return number;
|
||||
};
|
||||
|
||||
export const limitFloat = (num, min = 0, max = 100) => {
|
||||
export const limitFloat = (num, min = 0.0, max = 100.0) => {
|
||||
const number = parseFloat(num);
|
||||
if (Number.isNaN(number) || number < min) {
|
||||
return min;
|
||||
|
||||
@@ -258,7 +258,7 @@ export class BilingualSubtitleManager {
|
||||
p1.textContent = truncateWords(subtitle.text);
|
||||
|
||||
const p2 = document.createElement("p");
|
||||
p2.style.cssText = this.#setting.originStyle;
|
||||
p2.style.cssText = this.#setting.translationStyle;
|
||||
p2.textContent = truncateWords(subtitle.translation) || "...";
|
||||
|
||||
if (this.#setting.isBilingual) {
|
||||
|
||||
19
src/subtitle/XMLHttpRequestInjector.js
Normal file
19
src/subtitle/XMLHttpRequestInjector.js
Normal 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);
|
||||
};
|
||||
};
|
||||
@@ -38,7 +38,6 @@ class YouTubeCaptionProvider {
|
||||
|
||||
initialize() {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
if (event.data?.type === MSG_XHR_DATA_YOUTUBE) {
|
||||
const { url, response } = event.data;
|
||||
if (url && response) {
|
||||
@@ -66,23 +65,50 @@ class YouTubeCaptionProvider {
|
||||
});
|
||||
}
|
||||
|
||||
get #videoEl() {
|
||||
return document.querySelector(VIDEO_SELECT);
|
||||
}
|
||||
|
||||
#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) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "childList") {
|
||||
const videoEl = this.#videoEl;
|
||||
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);
|
||||
// todo: 顺带把广告快速跳过
|
||||
if (videoEl) {
|
||||
videoEl.playbackRate = 16;
|
||||
videoEl.currentTime = videoEl.duration;
|
||||
}
|
||||
if (this.#managerInstance) {
|
||||
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) => {
|
||||
if (node.nodeType === 1 && node.matches(adSlector)) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
if (node.matches(adLayoutSelector)) {
|
||||
logger.debug("Youtube Provider: Ad ends!");
|
||||
if (videoEl) {
|
||||
videoEl.playbackRate = 1;
|
||||
}
|
||||
if (this.#managerInstance) {
|
||||
this.#managerInstance.setIsAdPlaying(false);
|
||||
}
|
||||
@@ -468,7 +494,7 @@ class YouTubeCaptionProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoEl = document.querySelector(VIDEO_SELECT);
|
||||
const videoEl = this.#videoEl;
|
||||
if (!videoEl) {
|
||||
logger.warn("Youtube Provider: No video element found");
|
||||
return;
|
||||
@@ -879,7 +905,7 @@ class YouTubeCaptionProvider {
|
||||
textAlign: "center",
|
||||
});
|
||||
|
||||
const videoEl = document.querySelector(VIDEO_SELECT);
|
||||
const videoEl = this.#videoEl;
|
||||
const videoContainer = videoEl?.parentElement?.parentElement;
|
||||
if (videoContainer) {
|
||||
videoContainer.appendChild(notificationEl);
|
||||
|
||||
@@ -5,12 +5,14 @@ import { DEFAULT_API_SETTING } from "../config/api.js";
|
||||
import { DEFAULT_SUBTITLE_SETTING } from "../config/setting.js";
|
||||
import { injectExternalJs } from "../libs/injector.js";
|
||||
import { logger } from "../libs/log.js";
|
||||
import { XMLHttpRequestInjector } from "./XMLHttpRequestInjector.js";
|
||||
import { injectInlineJs } from "../libs/injector.js";
|
||||
|
||||
const providers = [
|
||||
{ pattern: "https://www.youtube.com", start: YouTubeInitializer },
|
||||
];
|
||||
|
||||
export function runSubtitle({ href, setting }) {
|
||||
export function runSubtitle({ href, setting, isUserscript }) {
|
||||
try {
|
||||
const subtitleSetting = setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING;
|
||||
if (!subtitleSetting.enabled) {
|
||||
@@ -19,9 +21,13 @@ export function runSubtitle({ href, setting }) {
|
||||
|
||||
const provider = providers.find((item) => isMatch(href, item.pattern));
|
||||
if (provider) {
|
||||
const id = "kiss-translator-injector";
|
||||
const src = browser.runtime.getURL("injector.js");
|
||||
injectExternalJs(src, id);
|
||||
const id = "kiss-translator-xmlHttp-injector";
|
||||
if (isUserscript) {
|
||||
injectInlineJs(`(${XMLHttpRequestInjector})()`, id);
|
||||
} else {
|
||||
const src = browser.runtime.getURL("injector.js");
|
||||
injectExternalJs(src, id);
|
||||
}
|
||||
|
||||
const apiSetting =
|
||||
setting.transApis.find(
|
||||
|
||||
@@ -17,6 +17,7 @@ import Alert from "@mui/material/Alert";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import Link from "@mui/material/Link";
|
||||
import { useAlert } from "../../hooks/Alert";
|
||||
import { useApiList, useApiItem } from "../../hooks/Api";
|
||||
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>
|
||||
<Grid container spacing={2} columns={12}>
|
||||
@@ -299,8 +300,9 @@ function ApiFields({ apiSlug, isUserApi, deleteApi }) {
|
||||
name="temperature"
|
||||
value={temperature}
|
||||
onChange={handleChange}
|
||||
min={0}
|
||||
max={2}
|
||||
min={0.0}
|
||||
max={2.0}
|
||||
isFloat={true}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}>
|
||||
@@ -805,6 +807,12 @@ export default function Apis() {
|
||||
{i18n("about_api_2")}
|
||||
<br />
|
||||
{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>
|
||||
|
||||
<Box>
|
||||
|
||||
@@ -2,12 +2,19 @@ import IconButton from "@mui/material/IconButton";
|
||||
import { useDarkMode } from "../../hooks/ColorMode";
|
||||
import LightModeIcon from "@mui/icons-material/LightMode";
|
||||
import DarkModeIcon from "@mui/icons-material/DarkMode";
|
||||
import BrightnessAutoIcon from "@mui/icons-material/BrightnessAuto";
|
||||
|
||||
export default function DarkModeButton() {
|
||||
const { darkMode, toggleDarkMode } = useDarkMode();
|
||||
return (
|
||||
<IconButton onClick={toggleDarkMode} color="inherit">
|
||||
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
|
||||
<IconButton sx={{ ml: 1 }} onClick={toggleDarkMode} color="inherit">
|
||||
{darkMode === "dark" ? (
|
||||
<DarkModeIcon />
|
||||
) : darkMode === "light" ? (
|
||||
<LightModeIcon />
|
||||
) : (
|
||||
<BrightnessAutoIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
|
||||
ignoreSelector = "",
|
||||
terms = "",
|
||||
aiTerms = "",
|
||||
termsStyle = "",
|
||||
selectStyle = "",
|
||||
parentStyle = "",
|
||||
grandStyle = "",
|
||||
@@ -547,10 +548,19 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
|
||||
maxRows={10}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("terms_style")}
|
||||
name="termsStyle"
|
||||
value={termsStyle}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
maxRows={10}
|
||||
multiline
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("selector_style")}
|
||||
helperText={i18n("selector_style_helper")}
|
||||
name="selectStyle"
|
||||
value={selectStyle}
|
||||
disabled={disabled}
|
||||
@@ -561,7 +571,6 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("selector_parent_style")}
|
||||
helperText={i18n("selector_style_helper")}
|
||||
name="parentStyle"
|
||||
value={parentStyle}
|
||||
disabled={disabled}
|
||||
@@ -572,7 +581,6 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("selector_grand_style")}
|
||||
helperText={i18n("selector_style_helper")}
|
||||
name="grandStyle"
|
||||
value={grandStyle}
|
||||
disabled={disabled}
|
||||
@@ -867,9 +875,9 @@ function UserRules({ subRules, rules }) {
|
||||
|
||||
<UploadButton text={i18n("import")} handleImport={handleImport} />
|
||||
<DownloadButton
|
||||
handleData={() => JSON.stringify([...rules.list].reverse(), null, 2)}
|
||||
handleData={() => JSON.stringify([...rules.list], null, 2)}
|
||||
text={i18n("export")}
|
||||
fileName={`kiss-rules_${Date.now()}.json`}
|
||||
fileName={`kiss-rules_v2_${Date.now()}.json`}
|
||||
/>
|
||||
<DownloadButton
|
||||
handleData={async () => JSON.stringify(await getRulesOld(), null, 2)}
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function Settings() {
|
||||
<DownloadButton
|
||||
handleData={() => JSON.stringify(setting, null, 2)}
|
||||
text={i18n("export")}
|
||||
fileName={`kiss-setting_${Date.now()}.json`}
|
||||
fileName={`kiss-setting_v2_${Date.now()}.json`}
|
||||
/>
|
||||
<DownloadButton
|
||||
handleData={async () =>
|
||||
|
||||
@@ -471,11 +471,9 @@ export default function Popup({ setShowPopup, translator }) {
|
||||
<Button variant="text" onClick={handleSaveRule}>
|
||||
{i18n("save_rule")}
|
||||
</Button>
|
||||
{!isExt && (
|
||||
<Button variant="text" onClick={handleClearCache}>
|
||||
{i18n("clear_cache")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="text" onClick={handleClearCache}>
|
||||
{i18n("clear_cache")}
|
||||
</Button>
|
||||
<Button variant="text" onClick={handleOpenSetting}>
|
||||
{i18n("setting")}
|
||||
</Button>
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function TranCont({
|
||||
<Box>
|
||||
<TextField
|
||||
size="small"
|
||||
label={`${i18n("translated_text")} - ${apiSetting.apiSlug}`}
|
||||
label={`${i18n("translated_text")} - ${apiSetting.apiName}`}
|
||||
// disabled
|
||||
fullWidth
|
||||
multiline
|
||||
|
||||
Reference in New Issue
Block a user