Auto-detect already worked — the backend returns
detectedLanguage:{language,confidence} and the panel put it in the
status line. But that only appeared after a translation had already
run, in small text, away from the control that raised the question. You
could not tell what "Auto-detect" had decided before committing to it.
The first row of the source select now says "Auto-detect · German", and
it says so while you are still typing. Detection runs on its own via
LibreTranslate's /detect, debounced 700 ms and gated at 12 characters,
because a detector given two words is guessing and firing per keystroke
would pound a public mirror for nothing. It chains the same mirror
fallback as translation, so a dead primary does not make detection look
broken while translating still works.
Confidence below 60 renders as "German?" rather than silently asserting
a coin-flip. The Google backend has no detect-only route, so there
doDetect returns null instead of burning a request, and the label is
filled from the translation response — which every backend returns
anyway, so a skipped or failed detect is never worse than before.
Detection retires when a source is named explicitly, comes back on
returning to Auto-detect, and is wiped by clear. A right-click
selection schedules one too, since that text arrives with no keystroke.
Verified against the real mirror (de/fr/ja at 100/100/90%) and in the
harness: short text fires nothing, long text fires once, four rapid
edits debounce to one call, and every transition above lands.
Also adds the xray removal script used to take the old engine off all
three exits now that they run sing-box.
169 lines
6.9 KiB
JavaScript
169 lines
6.9 KiB
JavaScript
// Translate — right-click a selection, get a translation in the sidebar.
|
|
//
|
|
// 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("<!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,
|
|
confidence: typeof j.detectedLanguage?.confidence === "number" ? j.detectedLanguage.confidence : null,
|
|
via: `libretranslate (${new URL(urlBase).host})`,
|
|
};
|
|
}
|
|
|
|
// Standalone detection, so the panel can name the language before anyone
|
|
// presses Translate. LibreTranslate exposes /detect; the Google endpoint has
|
|
// no detect-only route, so there the language comes back with the
|
|
// translation instead and this returns null rather than burning a request.
|
|
async function detectLibre(text, urlBase, apiKey) {
|
|
const url = urlBase.replace(/\/+$/, "") + "/detect";
|
|
const body = { q: text };
|
|
if (apiKey) body.api_key = apiKey;
|
|
const j = await fetchJson(url, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const best = Array.isArray(j) ? j[0] : null;
|
|
if (!best || !best.language) return null;
|
|
return {
|
|
language: String(best.language),
|
|
confidence: typeof best.confidence === "number" ? best.confidence : 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, confidence: null, via: "google" };
|
|
}
|
|
|
|
// Mirror-chaining detection, same fall-through as doTranslate: the user's
|
|
// chosen mirror first, then the known-good list, so a dead mirror does not
|
|
// make detection look broken when translation still works.
|
|
async function doDetect(payload) {
|
|
const p = payload || {};
|
|
const text = String(p.text || "").trim();
|
|
if (!text) return null;
|
|
if (p.backend === "google") return null; // no detect-only route; comes with the translation
|
|
const primary = String(p.ltUrl || LT_FALLBACKS[0]).replace(/\/+$/, "");
|
|
const tried = new Set();
|
|
for (const url of [primary, ...LT_FALLBACKS.filter((u) => u !== primary)]) {
|
|
if (tried.has(url)) continue;
|
|
tried.add(url);
|
|
try { return await detectLibre(text, url, p.ltKey || ""); } catch {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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) {
|
|
// No `icon` here on purpose — registerSidebarPanel defaults to
|
|
// manifest.icon, and passing one would shadow the data-URI mark.
|
|
api.registerSidebarPanel({
|
|
id: "main",
|
|
title: "Translate",
|
|
page: "panel.html",
|
|
});
|
|
|
|
api.onMessage("context-menu", async (payload) => {
|
|
const text = String(payload && payload.selectionText || "").trim();
|
|
if (!text) { api.log("context-menu fired with no selection"); return; }
|
|
// Cap what we stash to keep storage tiny; the address bar and menu already
|
|
// truncate visually, but the raw selection can be huge.
|
|
const clip = text.length > 12_000 ? text.slice(0, 12_000) : text;
|
|
api.storage.set("__pending", { text: clip, host: payload.host || "", at: Date.now() });
|
|
api.revealSidebar("main");
|
|
api.log(`context-menu → translate ${clip.length} chars from ${payload.host || "?"}`);
|
|
return { ok: true };
|
|
});
|
|
|
|
api.onMessage("translate", async (payload) => doTranslate(payload));
|
|
api.onMessage("detect", async (payload) => doDetect(payload));
|
|
|
|
api.log("registered translate panel + context-menu item");
|
|
},
|
|
};
|