// ==UserScript== // @name AI HTML Translator Safari // @namespace https://github.com/syf2211/ai-html-translator-safari // @version 0.1.0 // @description One-click AI translation for Safari through the Userscripts app, without an Apple Developer account. // @author syf2211 // @homepageURL https://github.com/syf2211/ai-html-translator-safari // @downloadURL https://raw.githubusercontent.com/syf2211/ai-html-translator-safari/main/userscript/ai-html-translator.user.js // @updateURL https://raw.githubusercontent.com/syf2211/ai-html-translator-safari/main/userscript/ai-html-translator.user.js // @match https://*/* // @match http://*/* // @grant GM.xmlHttpRequest // @grant GM.setValue // @grant GM.getValue // @connect api.deepseek.com // @connect * // @run-at document-idle // @noframes // ==/UserScript== (() => { "use strict"; if (window.__aiHtmlTranslatorUserscriptLoaded) { window.__aiHtmlTranslatorUserscriptEnsureWidget?.(); return; } window.__aiHtmlTranslatorUserscriptLoaded = true; const DEFAULT_SETTINGS = { apiKey: "", baseUrl: "https://api.deepseek.com", model: "deepseek-v4-flash", targetLanguage: "简体中文", temperature: 0.1, batchChars: 1200, batchItems: 12, includeAttributes: true, skipCodeBlocks: false, useJsonResponseFormat: true, disableDeepSeekThinking: true }; const STATE = { originals: [], translated: false, running: false, retryQueue: [], hostEl: null, shadow: null, statusEl: null, progressEl: null, translateEl: null, retryEl: null, restoreEl: null, configEl: null, minimized: false }; const EXCLUDED_TAGS = new Set([ "SCRIPT", "STYLE", "NOSCRIPT", "SVG", "CANVAS", "IFRAME", "OBJECT", "EMBED", "TEXTAREA", "SELECT", "OPTION" ]); const CODE_TAGS = new Set(["CODE", "PRE", "KBD", "SAMP"]); const ATTRIBUTE_NAMES = ["title", "alt", "aria-label", "placeholder"]; window.__aiHtmlTranslatorUserscriptEnsureWidget = () => { ensureWidget(); return { ok: true }; }; ensureWidget(); async function translatePage(settingsOverride = null) { if (STATE.running) { return { message: "running" }; } STATE.running = true; clearRetryQueue(); setBusy(true); setStatus("读取页面文本..."); try { const settings = normalizeSettings(settingsOverride || (await getSettings())); if (!settings.apiKey) { showConfig(true); throw new Error("请先保存 API Key。"); } const entries = collectEntries(settings); if (!entries.length) { setStatus("没有找到需要翻译的文本。"); return { message: "empty" }; } rememberOriginals(entries); const batches = makeBatches(entries, settings); let completed = 0; let translatedCount = 0; let failedBatchCount = 0; for (let index = 0; index < batches.length; index += 1) { const batch = batches[index]; setStatus(`翻译中 ${completed + 1}/${batches.length}...`); try { const translated = await translateBatchWithRetry(batch, settings); translatedCount += applyTranslations(entries, translated); } catch { failedBatchCount += 1; queueRetryTask({ label: `第 ${index + 1}/${batches.length} 批`, run: async () => { const translated = await translateBatchWithRetry(batch, settings); const applied = applyTranslations(entries, translated); STATE.translated = STATE.translated || applied > 0; return applied; } }); } completed += 1; setProgress(Math.round((completed / batches.length) * 100)); } const missingCount = Math.max(0, entries.length - translatedCount); STATE.translated = translatedCount > 0; if (failedBatchCount) { setStatus(`已翻译 ${translatedCount} 段。失败批次 ${failedBatchCount},可点“重试失败”。`, true); } else if (missingCount) { setStatus(`已翻译 ${translatedCount} 段,${missingCount} 段未返回。`, true); } else { setStatus(`已翻译 ${translatedCount} 段文本。`); } return { message: "done", count: translatedCount, failedBatches: failedBatchCount }; } catch (error) { setStatus(error.message, true); return { message: "error", error: error.message }; } finally { STATE.running = false; setBusy(false); } } function collectEntries(settings) { const entries = []; const unique = new Map(); const root = document.body || document.documentElement; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (!parent || shouldSkipElement(parent, settings) || !isVisible(parent)) { return NodeFilter.FILTER_REJECT; } const text = normalizeText(node.textContent); if (!shouldTranslateText(text)) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }); let node; while ((node = walker.nextNode())) { addTarget(unique, entries, { type: "text", target: node, original: node.textContent, text: normalizeText(node.textContent) }); } if (settings.includeAttributes) { collectAttributes(unique, entries, settings); } return entries.map((entry, index) => ({ ...entry, id: String(index + 1) })); } function collectAttributes(unique, entries, settings) { const selector = ATTRIBUTE_NAMES.map((name) => `[${cssEscape(name)}]`).join(","); const elements = Array.from(document.querySelectorAll(selector)); for (const element of elements) { if (shouldSkipElement(element, settings) || !isVisible(element)) { continue; } for (const attr of ATTRIBUTE_NAMES) { if (!element.hasAttribute(attr)) continue; const value = element.getAttribute(attr); const text = normalizeText(value); if (!shouldTranslateText(text)) continue; addTarget(unique, entries, { type: "attribute", target: element, attribute: attr, original: value, text }); } } const buttonInputs = Array.from( document.querySelectorAll('input[type="button"],input[type="submit"],input[type="reset"],button[value]') ); for (const element of buttonInputs) { if (shouldSkipElement(element, settings) || !isVisible(element)) { continue; } const value = element.getAttribute("value"); const text = normalizeText(value); if (!shouldTranslateText(text)) continue; addTarget(unique, entries, { type: "attribute", target: element, attribute: "value", original: value, text }); } } function addTarget(unique, entries, target) { const key = target.text; let entry = unique.get(key); if (!entry) { entry = { id: "", text: target.text, targets: [] }; unique.set(key, entry); entries.push(entry); } entry.targets.push(target); } function makeBatches(entries, settings) { const maxChars = Number(settings.batchChars) || DEFAULT_SETTINGS.batchChars; const maxItems = Number(settings.batchItems) || DEFAULT_SETTINGS.batchItems; const batches = []; let batch = []; let chars = 0; for (const entry of entries) { const entryChars = entry.text.length; if (batch.length && (chars + entryChars > maxChars || batch.length >= maxItems)) { batches.push(batch); batch = []; chars = 0; } batch.push({ id: entry.id, text: entry.text }); chars += entryChars; } if (batch.length) { batches.push(batch); } return batches; } async function translateBatchWithRetry(batch, settings) { const translated = await translateBatch(batch, settings); const seen = new Set(translated.map((item) => String(item.id))); const missing = batch.filter((item) => !seen.has(String(item.id))); if (!missing.length || batch.length <= 1) { return translated; } setStatus(`补翻 ${missing.length} 段...`); const retrySettings = { ...settings, batchChars: Math.max(500, Math.floor((Number(settings.batchChars) || DEFAULT_SETTINGS.batchChars) / 2)), batchItems: Math.max(1, Math.floor((Number(settings.batchItems) || DEFAULT_SETTINGS.batchItems) / 2)) }; const retryBatches = makeBatches(missing, retrySettings); for (const retryBatch of retryBatches) { const retried = await translateBatch(retryBatch, retrySettings); for (const item of retried) { const id = String(item.id); if (!seen.has(id)) { seen.add(id); translated.push(item); } } } return translated; } async function translateBatch(items, overrides = {}) { const settings = normalizeSettings({ ...(await getSettings()), ...overrides }); if (!settings.apiKey) { throw new Error("请先保存 API Key。"); } if (!settings.model) { throw new Error("请先填写模型名称。"); } if (!Array.isArray(items) || !items.length) { return []; } const payloadItems = items.map((item) => ({ id: String(item.id), text: String(item.text || "") })); const body = { model: settings.model, temperature: settings.temperature, messages: [ { role: "system", content: [ "You are a precise webpage translation engine.", "Translate only the provided user-visible text into the target language.", "Preserve meaning, numbers, URLs, inline variables, formulas, units, and product names when appropriate.", "Keep table labels and short UI text concise.", "Return strict JSON only: {\"items\":[{\"id\":\"same id\",\"text\":\"translated text\"}]}", "Return exactly one item for each input item, in any order, with unchanged ids." ].join(" ") }, { role: "user", content: JSON.stringify({ target_language: settings.targetLanguage, items: payloadItems }) } ] }; if (settings.useJsonResponseFormat) { body.response_format = { type: "json_object" }; } if (settings.disableDeepSeekThinking && settings.baseUrl.includes("api.deepseek.com")) { body.thinking = { type: "disabled" }; } const data = await requestJson({ url: `${settings.baseUrl}/chat/completions`, method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${settings.apiKey}` }, body }); const content = readAssistantContent(data); const parsed = parseJsonObject(content); if (!Array.isArray(parsed.items)) { throw new Error("模型返回格式不正确:没有 items 数组。"); } const byId = new Map(); for (const item of parsed.items) { if (item && item.id !== undefined && item.text !== undefined) { byId.set(String(item.id), String(item.text)); } } return payloadItems .filter((item) => byId.has(item.id)) .map((item) => ({ id: item.id, text: byId.get(item.id) })); } function requestJson({ url, method, headers, body }) { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ url, method, headers, data: JSON.stringify(body), timeout: 120000, onload(response) { const raw = response.responseText || ""; let data; try { data = raw ? JSON.parse(raw) : {}; } catch { data = { raw }; } if (response.status < 200 || response.status >= 300) { const message = data?.error?.message || data?.message || raw.slice(0, 500) || `HTTP ${response.status}`; reject(new Error(`API 请求失败:${message}`)); return; } resolve(data); }, onerror() { reject(new Error("API 请求失败:网络错误。")); }, ontimeout() { reject(new Error("API 请求超时,请调小批次后重试。")); } }); }); } function readAssistantContent(data) { const content = data?.choices?.[0]?.message?.content; if (typeof content === "string") { return content; } if (Array.isArray(content)) { return content .map((part) => { if (typeof part === "string") return part; if (part?.text) return part.text; if (part?.content) return part.content; return ""; }) .join(""); } throw new Error("API 返回里没有可读取的 message.content。"); } function parseJsonObject(text) { const cleaned = String(text || "") .replace(/^```(?:json)?/i, "") .replace(/```$/i, "") .trim(); try { return JSON.parse(cleaned); } catch { const start = cleaned.indexOf("{"); const end = cleaned.lastIndexOf("}"); if (start >= 0 && end > start) { return JSON.parse(cleaned.slice(start, end + 1)); } throw new Error("模型没有返回可解析的 JSON。"); } } function applyTranslations(entries, translatedItems) { const byId = new Map(translatedItems.map((item) => [String(item.id), String(item.text || "")])); let applied = 0; for (const entry of entries) { const translated = byId.get(entry.id); if (!translated) continue; applied += 1; for (const target of entry.targets) { const value = preserveBoundaryWhitespace(target.original, translated); if (target.type === "text") { target.target.textContent = value; } else { target.target.setAttribute(target.attribute, value); if (target.attribute === "value") { target.target.value = value; } } } } return applied; } function rememberOriginals(entries) { if (STATE.translated || STATE.originals.length) { return; } const originals = []; for (const entry of entries) { for (const target of entry.targets) { originals.push({ type: target.type, target: target.target, attribute: target.attribute, original: target.original }); } } STATE.originals = originals; } function restorePage() { clearRetryQueue(); for (const item of STATE.originals) { if (!item.target) continue; if (item.type === "text") { item.target.textContent = item.original; } else { item.target.setAttribute(item.attribute, item.original); if (item.attribute === "value") { item.target.value = item.original; } } } STATE.originals = []; STATE.translated = false; setProgress(0); setStatus("已恢复原文。"); } function queueRetryTask(task) { STATE.retryQueue.push(task); updateRetryButton(); } function clearRetryQueue() { STATE.retryQueue = []; updateRetryButton(); } async function retryFailedBatches() { if (STATE.running || !STATE.retryQueue.length) { return; } STATE.running = true; setBusy(true); const pending = [...STATE.retryQueue]; STATE.retryQueue = []; updateRetryButton(); let successCount = 0; const failedAgain = []; try { for (let index = 0; index < pending.length; index += 1) { const task = pending[index]; setStatus(`重试失败批次 ${index + 1}/${pending.length}...`); try { await task.run(); successCount += 1; } catch { failedAgain.push(task); } } } finally { STATE.running = false; setBusy(false); } STATE.retryQueue = failedAgain; updateRetryButton(); if (failedAgain.length) { setStatus(`重试完成:成功 ${successCount} 批,仍失败 ${failedAgain.length} 批。`, true); } else { setStatus(`重试完成:成功 ${successCount} 批。`); } } async function testApi() { const settings = normalizeSettings(await getSettings()); if (!settings.apiKey) { throw new Error("请先保存 API Key。"); } const translated = await translateBatch([{ id: "test", text: "This page contains a table and several images." }], settings); return translated[0]?.text || ""; } async function getSettings() { const stored = await GM.getValue("settings", {}); return normalizeSettings({ ...DEFAULT_SETTINGS, ...(stored || {}) }); } async function saveSettings(settings) { const normalized = normalizeSettings(settings); await GM.setValue("settings", normalized); return normalized; } function normalizeSettings(settings) { const baseUrl = String(settings?.baseUrl || DEFAULT_SETTINGS.baseUrl).trim().replace(/\/+$/, ""); return { ...DEFAULT_SETTINGS, ...settings, apiKey: String(settings?.apiKey || DEFAULT_SETTINGS.apiKey).trim(), baseUrl, model: String(settings?.model || DEFAULT_SETTINGS.model).trim(), targetLanguage: String(settings?.targetLanguage || DEFAULT_SETTINGS.targetLanguage).trim(), temperature: clampNumber(settings?.temperature, 0, 2, DEFAULT_SETTINGS.temperature), batchChars: Math.round(clampNumber(settings?.batchChars, 500, 2500, DEFAULT_SETTINGS.batchChars)), batchItems: Math.round(clampNumber(settings?.batchItems, 1, 25, DEFAULT_SETTINGS.batchItems)), includeAttributes: Boolean(settings?.includeAttributes ?? DEFAULT_SETTINGS.includeAttributes), skipCodeBlocks: Boolean(settings?.skipCodeBlocks), useJsonResponseFormat: Boolean(settings?.useJsonResponseFormat ?? DEFAULT_SETTINGS.useJsonResponseFormat), disableDeepSeekThinking: Boolean(settings?.disableDeepSeekThinking ?? DEFAULT_SETTINGS.disableDeepSeekThinking) }; } function shouldSkipElement(element, settings) { for (let current = element; current && current !== document.documentElement; current = current.parentElement) { if (EXCLUDED_TAGS.has(current.tagName)) { return true; } if (settings.skipCodeBlocks && CODE_TAGS.has(current.tagName)) { return true; } if (current.isContentEditable) { return true; } if (current.closest?.("[data-ai-translator-userscript-widget]")) { return true; } } return false; } function isVisible(element) { const style = window.getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) { return false; } if (element.getClientRects().length > 0) { return true; } return Boolean(element.offsetWidth || element.offsetHeight); } function normalizeText(value) { return String(value || "").replace(/\s+/g, " ").trim(); } function shouldTranslateText(text) { if (!text || text.length < 2) { return false; } if (!/\p{L}/u.test(text)) { return false; } if (/^[\d\s.,:;!?()[\]{}+\-*/%=<>|\\_$#@~^&]+$/.test(text)) { return false; } return true; } function preserveBoundaryWhitespace(original, translated) { const source = String(original || ""); const leading = source.match(/^\s*/)?.[0] || ""; const trailing = source.match(/\s*$/)?.[0] || ""; return `${leading}${String(translated || "").trim()}${trailing}`; } function cssEscape(value) { if (window.CSS?.escape) { return window.CSS.escape(value); } return String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&"); } function clampNumber(value, min, max, fallback) { const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; return Math.min(max, Math.max(min, parsed)); } function ensureWidget() { if (STATE.hostEl?.isConnected) { return; } document.querySelectorAll("[data-ai-translator-userscript-widget]").forEach((host) => host.remove()); const host = document.createElement("div"); host.setAttribute("data-ai-translator-userscript-widget", "true"); host.style.position = "fixed"; host.style.right = "18px"; host.style.bottom = "18px"; host.style.zIndex = "2147483647"; document.documentElement.appendChild(host); const shadow = host.attachShadow({ mode: "open" }); shadow.innerHTML = getWidgetMarkup(); STATE.hostEl = host; STATE.shadow = shadow; STATE.statusEl = shadow.querySelector(".status"); STATE.progressEl = shadow.querySelector(".progress-bar"); STATE.translateEl = shadow.querySelector(".translate"); STATE.retryEl = shadow.querySelector(".retry"); STATE.restoreEl = shadow.querySelector(".restore"); STATE.configEl = shadow.querySelector(".config-panel"); STATE.translateEl.addEventListener("click", () => translatePage().catch((error) => setStatus(error.message, true))); STATE.retryEl.addEventListener("click", () => retryFailedBatches().catch((error) => setStatus(error.message, true))); STATE.restoreEl.addEventListener("click", restorePage); shadow.querySelector(".settings").addEventListener("click", () => showConfig()); shadow.querySelector(".collapse").addEventListener("click", () => toggleMinimized(true)); shadow.querySelector(".mini").addEventListener("click", () => toggleMinimized(false)); shadow.querySelector(".close-config").addEventListener("click", () => showConfig(false)); shadow.querySelector(".settings-form").addEventListener("submit", async (event) => { event.preventDefault(); await saveSettings(readFormValues()); setStatus("配置已保存。"); showConfig(false); }); shadow.querySelector(".test-api").addEventListener("click", async () => { try { await saveSettings(readFormValues()); setStatus("正在测试 API..."); const text = await testApi(); setStatus(`API 正常:${text}`); } catch (error) { setStatus(error.message, true); } }); makeWidgetDraggable(host, shadow.querySelector(".drag")); loadSettingsIntoForm().catch(() => {}); updateRetryButton(); } function getWidgetMarkup() { return ` AI ⋮⋮ AI HTML Translator ⚙ × AI 翻译 原文 重试失败 首次使用请点 ⚙ 保存服务端 URL、API Key 和模型。 API Key Base URL Model 目标语言 每批字符 每批段数 翻译 alt、按钮和提示文本 跳过 code/pre 启用 JSON 模式 DeepSeek 关闭 thinking 保存配置 测试 API 关闭 `; } async function loadSettingsIntoForm() { const settings = await getSettings(); const shadow = STATE.shadow; shadow.querySelector(".api-key").value = settings.apiKey; shadow.querySelector(".base-url").value = settings.baseUrl; shadow.querySelector(".model").value = settings.model; shadow.querySelector(".target-language").value = settings.targetLanguage; shadow.querySelector(".batch-chars").value = settings.batchChars; shadow.querySelector(".batch-items").value = settings.batchItems; shadow.querySelector(".include-attributes").checked = settings.includeAttributes; shadow.querySelector(".skip-code-blocks").checked = settings.skipCodeBlocks; shadow.querySelector(".use-json-response-format").checked = settings.useJsonResponseFormat; shadow.querySelector(".disable-deepseek-thinking").checked = settings.disableDeepSeekThinking; } function readFormValues() { const shadow = STATE.shadow; return { apiKey: shadow.querySelector(".api-key").value.trim(), baseUrl: shadow.querySelector(".base-url").value.trim().replace(/\/+$/, "") || DEFAULT_SETTINGS.baseUrl, model: shadow.querySelector(".model").value.trim() || DEFAULT_SETTINGS.model, targetLanguage: shadow.querySelector(".target-language").value.trim() || DEFAULT_SETTINGS.targetLanguage, batchChars: shadow.querySelector(".batch-chars").value, batchItems: shadow.querySelector(".batch-items").value, includeAttributes: shadow.querySelector(".include-attributes").checked, skipCodeBlocks: shadow.querySelector(".skip-code-blocks").checked, useJsonResponseFormat: shadow.querySelector(".use-json-response-format").checked, disableDeepSeekThinking: shadow.querySelector(".disable-deepseek-thinking").checked }; } function showConfig(force = null) { if (!STATE.configEl) return; const open = force === null ? !STATE.configEl.classList.contains("open") : Boolean(force); STATE.configEl.classList.toggle("open", open); if (open) { loadSettingsIntoForm().catch(() => {}); } } function toggleMinimized(minimized) { STATE.minimized = minimized; STATE.shadow?.host?.classList.toggle("minimized", minimized); } function makeWidgetDraggable(host, handle) { let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; let dragging = false; handle.addEventListener("pointerdown", (event) => { dragging = true; const rect = host.getBoundingClientRect(); startX = event.clientX; startY = event.clientY; startLeft = rect.left; startTop = rect.top; host.style.left = `${rect.left}px`; host.style.top = `${rect.top}px`; host.style.right = "auto"; host.style.bottom = "auto"; handle.setPointerCapture?.(event.pointerId); event.preventDefault(); }); handle.addEventListener("pointermove", (event) => { if (!dragging) return; const nextLeft = clampNumber(startLeft + event.clientX - startX, 0, window.innerWidth - 48, startLeft); const nextTop = clampNumber(startTop + event.clientY - startY, 0, window.innerHeight - 48, startTop); host.style.left = `${nextLeft}px`; host.style.top = `${nextTop}px`; }); handle.addEventListener("pointerup", (event) => { dragging = false; handle.releasePointerCapture?.(event.pointerId); }); } function setBusy(isBusy) { for (const button of [STATE.translateEl, STATE.restoreEl, STATE.retryEl]) { if (button) { button.disabled = Boolean(isBusy); } } } function setStatus(message, isError = false) { ensureWidget(); if (!STATE.statusEl) return; STATE.statusEl.textContent = message || ""; STATE.statusEl.classList.toggle("error", Boolean(isError)); } function setProgress(percent) { if (!STATE.progressEl) return; const value = Math.max(0, Math.min(100, Number(percent) || 0)); STATE.progressEl.style.width = `${value}%`; } function updateRetryButton() { if (!STATE.retryEl) return; const count = STATE.retryQueue.length; STATE.retryEl.hidden = count === 0; STATE.retryEl.textContent = count ? `重试失败(${count})` : "重试失败"; } })();