From 2b8e3a7eb67f9551ecbaba33150c788cf784323c Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 21 Sep 2026 03:34:14 +0200 Subject: [PATCH] =?UTF-8?q?translate=200.1.1=20=E2=86=92=200.1.2:=20fetch?= =?UTF-8?q?=20from=20the=20addon's=20Node=20side,=20not=20the=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel was doing the HTTP call itself, which meant any mirror sitting behind a Cloudflare-style anti-bot check returned "…" for a POST from Origin: file:// and JSON.parse choked on it. Moving the fetch into index.js gets rid of the whole class of browser-context interceptors (CORS preflights, captive portals, anti-bot pages) and lets the addon look at the response body before trying to parse it — an HTML body is now reported cleanly as "server returned an HTML page instead of JSON". While there, chain a small mirror list — translate.disroot.org, translate.plausibility.cloud, lingva.ml — so a single mirror being down does not take the feature with it. A user whose saved URL points at a mirror that stopped resolving (translate.argosopentech.com is the notable case) now transparently gets a translation from the next mirror in line instead of a stack trace. Verified end-to-end from Node against both a working URL and a dead one; the dead one falls through to disroot as expected. --- bundled-addons/translate/addon.json | 2 +- bundled-addons/translate/index.js | 104 ++++++++++++++++++++++++++-- bundled-addons/translate/panel.html | 57 ++++----------- 3 files changed, 113 insertions(+), 50 deletions(-) diff --git a/bundled-addons/translate/addon.json b/bundled-addons/translate/addon.json index eb4396a..c5bad71 100644 --- a/bundled-addons/translate/addon.json +++ b/bundled-addons/translate/addon.json @@ -1,7 +1,7 @@ { "id": "translate", "name": "Translate", - "version": "0.1.1", + "version": "0.1.2", "description": "Right-click a selection to translate it. Sidebar panel with LibreTranslate or Google as the backend.", "author": "Silent Mode", "icon": "🌐", diff --git a/bundled-addons/translate/index.js b/bundled-addons/translate/index.js index f6cf9b8..0826785 100644 --- a/bundled-addons/translate/index.js +++ b/bundled-addons/translate/index.js @@ -1,10 +1,102 @@ // Translate — right-click a selection, get a translation in the sidebar. // -// One sidebar panel; the panel HTML does the actual translation (LibreTranslate -// or Google unofficial free endpoint, user's pick). When the user picks -// "Translate selection" from a page's right-click menu we stash the selection -// under storage.__pending and reveal our sidebar; the panel reads __pending on -// load / on visibility change and translates immediately. +// One sidebar panel; the actual HTTP call runs here on the Node side, not in +// the panel's browser context, so a captive portal, anti-bot page or CORS +// preflight cannot substitute HTML for the JSON the panel expects. The panel +// invokes "translate" with { text, source, target, backend, ltUrl, ltKey } +// and this handler picks the backend, tries the requested URL, then falls +// through a small list of known mirrors if that URL returns HTML or a network +// error (a single mirror going down shouldn't take the whole feature with it). + +// Ordered list of LibreTranslate mirrors used as automatic fallbacks when the +// user's chosen URL fails. Kept short on purpose — three tries is plenty to +// route around one mirror being down, and we don't want to spam a chain of +// public instances for one click. +const LT_FALLBACKS = [ + "https://translate.disroot.org", + "https://translate.plausibility.cloud", + "https://lingva.ml", +]; + +function looksLikeHtml(s) { + const head = String(s || "").trimStart().slice(0, 32).toLowerCase(); + return head.startsWith(" (Array.isArray(c) ? String(c[0] || "") : "")).join(""); + const detected = typeof j?.[2] === "string" ? j[2] : null; + return { text: translated, detected, via: "google" }; +} + +async function doTranslate(payload) { + const p = payload || {}; + const text = String(p.text || "").trim(); + if (!text) throw new Error("no text"); + const source = String(p.source || "auto"); + const target = String(p.target || "en"); + if (source !== "auto" && source === target) { + return { text, detected: null, via: "identity" }; + } + if (p.backend === "google") { + return translateGoogle({ text, source, target }); + } + const primary = String(p.ltUrl || LT_FALLBACKS[0]).replace(/\/+$/, ""); + const tried = new Set(); + const order = [primary, ...LT_FALLBACKS.filter((u) => u !== primary)]; + const errs = []; + for (const url of order) { + if (tried.has(url)) continue; + tried.add(url); + try { + return await translateLibre({ text, source, target }, url, p.ltKey || ""); + } catch (e) { + errs.push(`${new URL(url).host}: ${e?.message || e}`); + } + } + throw new Error(`all mirrors failed — ${errs.join(" | ")}`); +} module.exports = { activate(api) { @@ -27,6 +119,8 @@ module.exports = { return { ok: true }; }); + api.onMessage("translate", async (payload) => doTranslate(payload)); + api.log("registered translate panel + context-menu item"); }, }; diff --git a/bundled-addons/translate/panel.html b/bundled-addons/translate/panel.html index 0f9b1bc..3a001aa 100644 --- a/bundled-addons/translate/panel.html +++ b/bundled-addons/translate/panel.html @@ -221,47 +221,12 @@ status.className = "m" + (cls ? " " + cls : ""); } - // --- backends ---------------------------------------------------------- - async function translateLibre(text, source, target) { - const url = settingsState.ltUrl.replace(/\/+$/, "") + "/translate"; - const body = { q: text, source: source === "auto" ? "auto" : source, target, format: "text" }; - if (settingsState.ltKey) body.api_key = settingsState.ltKey; - const r = await fetch(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - if (!r.ok) { - let msg = "HTTP " + r.status; - try { const j = await r.json(); if (j?.error) msg = j.error; } catch {} - throw new Error(msg); - } - const j = await r.json(); - return { - text: String(j.translatedText || j.translated_text || ""), - detected: j.detectedLanguage?.language || null, - }; - } - async function translateGoogle(text, source, target) { - // Unofficial free endpoint used by every browser translation extension. - // Google can break this at any time; it's an opt-in fallback. - const params = new URLSearchParams({ - client: "gtx", - sl: source === "auto" ? "auto" : source, - tl: target, - dt: "t", - q: text, - }); - const r = await fetch(`https://translate.googleapis.com/translate_a/single?${params}`); - if (!r.ok) throw new Error("HTTP " + r.status); - const j = await r.json(); - // Response shape: [ [ [translated, original, null, null, ...], ... ], null, detectedLang ] - const chunks = Array.isArray(j?.[0]) ? j[0] : []; - const translated = chunks.map((c) => (Array.isArray(c) ? String(c[0] || "") : "")).join(""); - const detected = typeof j?.[2] === "string" ? j[2] : null; - return { text: translated, detected }; - } - + // The panel used to fetch the translation server directly, but a public + // LibreTranslate mirror sitting behind a Cloudflare-style anti-bot page + // returns HTML for a POST from `Origin: file://`, and JSON.parse chokes on + // "