Skip to content

Instantly share code, notes, and snippets.

@alekrutkowski
Last active November 7, 2025 10:50
Show Gist options
  • Save alekrutkowski/6d617a0341c73c045f4c9b0211eb1514 to your computer and use it in GitHub Desktop.
Save alekrutkowski/6d617a0341c73c045f4c9b0211eb1514 to your computer and use it in GitHub Desktop.
Chrome/Edge addon/extension to translate the current web page to a pre-set language when the addon's icon is clicked

How to install

Page Translator v2 (text-node safe)

Click the extension button to translate text inside paragraphs, headings (h1–h6), and list items into the language defined in MY_LANG while preserving inline markup and links.

Made with ChatGPT 5 (after a few iterations).

Motivation

It can be useful in cases when Google Translate full web page translation fails with the message "Can't translate this page" e.g. when translating https://cepr.org/voxeu/columns/chinas-belt-and-road-initiative-and-shifting-landscape-trade-and-investmenthttps://cepr-org.translate.goog/voxeu/columns/chinas-belt-and-road-initiative-and-shifting-landscape-trade-and-investment?_x_tr_sl=en&_x_tr_tl=pl

Install

  1. Download the ZIP and extract it.
  2. Open chrome://extensions in Chrome.
  3. Enable Developer mode.
  4. Click Load unpacked and choose the extracted folder.

Notes

  • This uses an unofficial Google Translate endpoint. Availability can change.
  • If you want additional containers (e.g., blockquotes), you can add their tag names to ALLOWED_CONTAINERS in content.js.
  • For very large pages there may be many requests; batching can be added if needed.
// background.js v2
// Injects the content script on button click and performs translation requests
const MY_LANG = 'pl'; // adjust as needed
chrome.action.onClicked.addListener(async (tab) => {
try {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content.js']
});
chrome.tabs.sendMessage(tab.id, { type: 'START_TRANSLATION' });
} catch (e) {
console.error('Injection failed:', e);
}
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'TRANSLATE_ONE') {
(async () => {
try {
const translated = await translateText(message.text);
sendResponse({ ok: true, translated });
} catch (err) {
sendResponse({ ok: false, error: err && err.message ? err.message : String(err) });
}
})();
return true; // Keep channel open
}
});
async function translateText(text) {
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${MY_LANG}&dt=t`;
const body = new URLSearchParams();
body.append('q', text);
const resp = await fetch(url, {
method: 'POST',
body
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
// data[0] is an array of [ translatedChunk, originalChunk, ... ]
if (Array.isArray(data) && Array.isArray(data[0])) {
return data[0].map(seg => seg[0]).join('');
}
return text;
}
// content.js v2
// Translates only text nodes inside P/H1-H6/LI elements, preserving inline markup and links.
// It does NOT touch href attributes; it only replaces the text node values.
(function () {
if (window.__polish_translator_installed_v2) return;
window.__polish_translator_installed_v2 = true;
const ALLOWED_CONTAINERS = new Set([
'P','H1','H2','H3','H4','H5','H6','LI'
]);
const DISALLOWED_ANCESTORS = new Set([
'SCRIPT','STYLE','NOSCRIPT','CODE','PRE','TEXTAREA','INPUT','SELECT','BUTTON'
]);
function hasDisallowedAncestor(node) {
let n = node.parentNode;
while (n && n.nodeType === Node.ELEMENT_NODE) {
const tag = n.tagName;
if (DISALLOWED_ANCESTORS.has(tag)) return true;
n = n.parentNode;
}
return false;
}
function isInsideAllowedContainer(node) {
let n = node.parentNode;
while (n && n.nodeType === Node.ELEMENT_NODE) {
if (ALLOWED_CONTAINERS.has(n.tagName)) return true;
n = n.parentNode;
}
return false;
}
function* collectTextNodes(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
if (hasDisallowedAncestor(node)) return NodeFilter.FILTER_REJECT;
if (!isInsideAllowedContainer(node)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
let current;
while (current = walker.nextNode()) {
yield current;
}
}
async function translateText(text) {
try {
const res = await chrome.runtime.sendMessage({ type: 'TRANSLATE_ONE', text });
if (res && res.ok) return res.translated;
} catch (e) {
console.warn('Translation failed:', e);
}
return text;
}
async function translateNodes(nodes) {
for (const node of nodes) {
const original = node.nodeValue.trim();
if (!original) continue;
const translated = await translateText(original);
// Replace only the portion we trimmed if there was surrounding whitespace
const leadingWS = node.nodeValue.match(/^\s*/)[0];
const trailingWS = node.nodeValue.match(/\s*$/)[0];
node.nodeValue = leadingWS + translated + trailingWS;
}
}
async function translatePage() {
const nodes = Array.from(collectTextNodes(document.body));
await translateNodes(nodes);
}
chrome.runtime.onMessage.addListener((msg) => {
if (msg && msg.type === 'START_TRANSLATION') {
translatePage();
}
});
})();
{
"manifest_version": 3,
"name": "Page Translator (Text Nodes)",
"version": "2.0.0",
"description": "Translate visible paragraph, heading, and list text into a pre-set language using Google Translate, preserving links, when you click the toolbar button.",
"permissions": [
"scripting",
"activeTab"
],
"host_permissions": [
"https://translate.googleapis.com/*"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "Translate text nodes to a pre-set language"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment