translate 0.1.1 → 0.1.2: fetch from the addon's Node side, not the panel

The panel was doing the HTTP call itself, which meant any mirror sitting
behind a Cloudflare-style anti-bot check returned "<!doctype html>…" 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.
This commit is contained in:
Local Dev 2026-09-21 03:34:14 +02:00
parent 9fbba50999
commit 2b8e3a7eb6
3 changed files with 113 additions and 50 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "translate", "id": "translate",
"name": "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.", "description": "Right-click a selection to translate it. Sidebar panel with LibreTranslate or Google as the backend.",
"author": "Silent Mode", "author": "Silent Mode",
"icon": "🌐", "icon": "🌐",

View file

@ -1,10 +1,102 @@
// Translate — right-click a selection, get a translation in the sidebar. // Translate — right-click a selection, get a translation in the sidebar.
// //
// One sidebar panel; the panel HTML does the actual translation (LibreTranslate // One sidebar panel; the actual HTTP call runs here on the Node side, not in
// or Google unofficial free endpoint, user's pick). When the user picks // the panel's browser context, so a captive portal, anti-bot page or CORS
// "Translate selection" from a page's right-click menu we stash the selection // preflight cannot substitute HTML for the JSON the panel expects. The panel
// under storage.__pending and reveal our sidebar; the panel reads __pending on // invokes "translate" with { text, source, target, backend, ltUrl, ltKey }
// load / on visibility change and translates immediately. // 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("<!doctype") || head.startsWith("<html") || head.startsWith("<?xml");
}
async function fetchJson(url, init) {
const r = await fetch(url, { ...init, redirect: "follow" });
const text = await r.text();
if (looksLikeHtml(text)) {
throw new Error(`server returned an HTML page instead of JSON (probably a captive-portal or anti-bot interstitial in front of ${new URL(url).host})`);
}
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try { const j = JSON.parse(text); if (j?.error) msg = j.error; } catch {}
throw new Error(msg);
}
try { return JSON.parse(text); }
catch { throw new Error(`server returned invalid JSON (${text.slice(0, 80)}…)`); }
}
async function translateLibre({ text, source, target }, urlBase, apiKey) {
const url = urlBase.replace(/\/+$/, "") + "/translate";
const body = { q: text, source: source === "auto" ? "auto" : source, target, format: "text" };
if (apiKey) body.api_key = apiKey;
const j = await fetchJson(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return {
text: String(j.translatedText || j.translated_text || ""),
detected: j.detectedLanguage?.language || null,
via: `libretranslate (${new URL(urlBase).host})`,
};
}
async function translateGoogle({ text, source, target }) {
// Unofficial free endpoint used by browser translation extensions. Runs from
// Node so no browser anti-bot page can slot itself in front of the response.
const params = new URLSearchParams({
client: "gtx",
sl: source === "auto" ? "auto" : source,
tl: target, dt: "t", q: text,
});
const url = `https://translate.googleapis.com/translate_a/single?${params}`;
const j = await fetchJson(url, {});
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, 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 = { module.exports = {
activate(api) { activate(api) {
@ -27,6 +119,8 @@ module.exports = {
return { ok: true }; return { ok: true };
}); });
api.onMessage("translate", async (payload) => doTranslate(payload));
api.log("registered translate panel + context-menu item"); api.log("registered translate panel + context-menu item");
}, },
}; };

View file

@ -221,47 +221,12 @@
status.className = "m" + (cls ? " " + cls : ""); status.className = "m" + (cls ? " " + cls : "");
} }
// --- backends ---------------------------------------------------------- // The panel used to fetch the translation server directly, but a public
async function translateLibre(text, source, target) { // LibreTranslate mirror sitting behind a Cloudflare-style anti-bot page
const url = settingsState.ltUrl.replace(/\/+$/, "") + "/translate"; // returns HTML for a POST from `Origin: file://`, and JSON.parse chokes on
const body = { q: text, source: source === "auto" ? "auto" : source, target, format: "text" }; // "<!doctype …". Doing the fetch server-side (via the add-on's index.js)
if (settingsState.ltKey) body.api_key = settingsState.ltKey; // sidesteps that whole class of problem and lets index.js fall through a
const r = await fetch(url, { // small mirror list if the user's URL is down.
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 };
}
async function doTranslate() { async function doTranslate() {
const text = inp.value.trim(); const text = inp.value.trim();
if (!text) { setStatus("nothing to translate"); return; } if (!text) { setStatus("nothing to translate"); return; }
@ -277,10 +242,14 @@
setStatus("translating…"); setStatus("translating…");
out.classList.remove("empty"); out.classList.remove("empty");
try { try {
const fn = settingsState.backend === "google" ? translateGoogle : translateLibre; const res = await window.silentmode.invoke("translate", {
const res = await fn(text, source, target); text, source, target,
backend: settingsState.backend,
ltUrl: settingsState.ltUrl,
ltKey: settingsState.ltKey,
});
out.textContent = res.text || "(empty response)"; out.textContent = res.text || "(empty response)";
const via = settingsState.backend === "google" ? "google" : "libretranslate"; const via = res.via || settingsState.backend;
setStatus(res.detected ? `${via} · detected ${res.detected}` : via, "ok"); setStatus(res.detected ? `${via} · detected ${res.detected}` : via, "ok");
} catch (e) { } catch (e) {
out.textContent = ""; out.textContent = "";