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:
parent
9fbba50999
commit
2b8e3a7eb6
3 changed files with 113 additions and 50 deletions
|
|
@ -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": "🌐",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 = "";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue