Translate
Translate selected text to another language
Code Snippet
1const targetLang = await input.text(
2 "Translate to which language?",
3);
4
5if (!targetLang || targetLang.length == 0) return;
6
7const BATCH_SIZE = 20;
8const POLL_INTERVAL = 1500;
9const DEFAULT_RETRY_AFTER_MS = 60000;
10
11let rateLimited = false;
12let rateLimitPopupShown = false;
13let isProcessing = false;
14
15function sleep(ms) {
16 return new Promise(resolve => setTimeout(resolve, ms));
17}
18
19function chunk(arr, size) {
20 const result = [];
21 for (let i = 0; i < arr.length; i += size) {
22 result.push(arr.slice(i, i + size));
23 }
24 return result;
25}
26
27function isRateLimitError(err) {
28 const message = String(err?.message || err || "").toLowerCase();
29 return (
30 message.includes("429") ||
31 message.includes("rate limit") ||
32 message.includes("too many requests")
33 );
34}
35
36function getRetryAfterMs(err) {
37 const message = String(err?.message || err || "");
38 const headerMatch = message.match(/retry-after[:=]\s*(\d+)/i);
39 if (headerMatch) return Number(headerMatch[1]) * 1000;
40 const bodyMatch = message.match(/retry[_-]?after["']?\s*[:=]\s*(\d+)/i);
41 if (bodyMatch) return Number(bodyMatch[1]) * 1000;
42 return null;
43}
44
45async function enterRateLimitMode(waitMs) {
46 if (rateLimited) return;
47 rateLimited = true;
48 spinner.hide();
49 const seconds = Math.ceil(waitMs / 1000);
50 if (!rateLimitPopupShown) {
51 rateLimitPopupShown = true;
52 popup.error(`Model rate limit reached. Paused for ${seconds} seconds.`);
53 }
54 console.warn(`Rate limited. Waiting ${seconds}s`);
55 await sleep(waitMs);
56 rateLimited = false;
57 rateLimitPopupShown = false;
58 popup.success("Translation resumed.");
59}
60
61/**
62 * ==========================================================
63 * DETECT CAPTCHA / CLOUDFLARE PAGES
64 * ==========================================================
65 */
66const pageTitle = (await page.getTitle() || "").toLowerCase();
67const pageURL = (await page.getURL() || "").toLowerCase();
68
69const BLOCKED_KEYWORDS = [
70 "captcha", "cloudflare", "verification", "security check", "are you human",
71];
72
73if (BLOCKED_KEYWORDS.some(kw => pageTitle.includes(kw) || pageURL.includes(kw))) {
74 popup.error("Translation disabled on verification pages.");
75 return;
76}
77
78/**
79 * ==========================================================
80 * CODE HEURISTICS
81 * Filter nodes that are unambiguously code before sending to
82 * the AI — reduces token usage and avoids mistranslations.
83 * ==========================================================
84 */
85function looksLikeCode(s) {
86 if (/^https?:\/\/\S+$/.test(s)) return true; // URL
87 if (/^([a-z0-9-]+\.)+[a-z]{2,}(\/\S*)?$/i.test(s)) return true; // bare domain
88 if (/^\.{0,2}[/\\][^\s]{3,}$/.test(s)) return true; // file path
89 if (/^[$#]\s+\S/.test(s)) return true; // shell prompt
90 if (/^#!\//.test(s)) return true; // shebang
91 if (/^#[0-9a-fA-F]{3,8}$/.test(s)) return true; // hex colour
92 if (/^v?\d+\.\d+(\.\d+)?(-[\w.]+)?$/.test(s)) return true; // semver
93 if (/^import\s+[{*]/.test(s)) return true; // import stmt
94 if (/^(require|from)\s*['"`(]/.test(s)) return true; // require/from
95 return false;
96}
97
98function shouldSkipText(text) {
99 if (!text) return true;
100 const t = text.trim();
101 if (t.length < 2) return true;
102 if (looksLikeCode(t)) return true;
103 return false;
104}
105
106/**
107 * ==========================================================
108 * TRANSLATE BATCH
109 * ==========================================================
110 */
111async function translateBatch(batch) {
112 const payload = batch
113 .map(item => `NODE_ID:${item.id}\nTEXT:\n${item.text}\nEND_NODE`)
114 .join("\n\n");
115
116 try {
117 const response = await ai.chat([
118 {
119 role: "system",
120 content: `You are a professional translator. Translate all text into ${targetLang}.
121
122═══════════════════════════════════════
123WHAT YOU MUST NEVER TRANSLATE
124═══════════════════════════════════════
125
126Leave the following COMPLETELY UNCHANGED — return them word-for-word:
127
128• URLs and domain names e.g. https://example.com github.com
129• File paths e.g. /usr/bin/node ./src/index.js
130• Shell / terminal commands e.g. npm install react git commit -m "fix"
131• Package names e.g. react lodash numpy @types/node
132• Import / require statements e.g. import React from 'react'
133• Function / class / variable names e.g. handleClick UserService MAX_RETRIES
134• Code keywords e.g. const async null undefined
135• Version strings e.g. v1.2.3 2.0.0-beta
136• Hex colours e.g. #ff0000 #1a2b3c
137• Operators / symbols e.g. => && !==
138• Any text that is clearly source code
139
140If a node is entirely untranslatable (pure code, symbol, punctuation), set TYPE:skip and return the text unchanged.
141
142═══════════════════════════════════════
143RETURN FORMAT — no deviations
144═══════════════════════════════════════
145
146NODE_ID:<id>
147TYPE:<prose|skip>
148TEXT:
149<translated text, or original if skip>
150END_NODE
151
152Rules:
1531. Output EVERY input node — never omit one.
1542. Never merge or split nodes.
1553. Never modify NODE_ID values.
1564. No markdown, no commentary, nothing outside the blocks.
1575. Preserve leading/trailing whitespace and punctuation exactly.`,
158 },
159 {
160 role: "user",
161 content: payload,
162 },
163 ]);
164
165 const regex = /NODE_ID:(.*?)\n(?:TYPE:(.*?)\n)?TEXT:\n([\s\S]*?)\nEND_NODE/g;
166 const updates = [];
167 let match;
168 while ((match = regex.exec(response)) !== null) {
169 const id = match[1].trim();
170 const type = (match[2] || "prose").trim().toLowerCase();
171 const text = match[3].trim();
172 updates.push({ id, text, skip: type === "skip" });
173 }
174
175 return updates;
176 } catch (err) {
177 if (isRateLimitError(err)) {
178 const waitMs = getRetryAfterMs(err) ?? DEFAULT_RETRY_AFTER_MS;
179 await enterRateLimitMode(waitMs);
180 return [];
181 }
182 throw err;
183 }
184}
185
186/**
187 * ==========================================================
188 * PROCESS CURRENT VIEWPORT
189 * ==========================================================
190 */
191async function processViewport() {
192 if (isProcessing) return;
193 if (rateLimited) return;
194
195 isProcessing = true;
196
197 try {
198 const allNodes = await dom.getVisibleTextNodes();
199 if (!allNodes || !allNodes.length) return;
200
201 // Filter out code-like text nodes before sending to AI.
202 // dom.getVisibleTextNodes() already skips <code>/<pre>/etc,
203 // but looksLikeCode catches inline code-like values the DOM
204 // structure alone can't detect (semver, hex, shell prompts…).
205 const nodes = allNodes.filter(node => !shouldSkipText(node.text));
206
207 if (!nodes.length) return;
208
209 const batches = chunk(nodes, BATCH_SIZE);
210 spinner.show(`Translating ${nodes.length} text blocks...`);
211
212 for (let i = 0; i < batches.length; i++) {
213 if (rateLimited) break;
214 spinner.update(`Translating batch ${i + 1}/${batches.length}`);
215 try {
216 const updates = await translateBatch(batches[i]);
217 if (updates && updates.length) {
218 // dom.patchTextNodes expects { id, text } pairs.
219 // Filter out TYPE:skip nodes — their text is unchanged
220 // so there is nothing to write back.
221 const patches = updates
222 .filter(u => !u.skip)
223 .map(u => ({ id: u.id, text: u.text }));
224
225 if (patches.length) {
226 await dom.patchTextNodes(patches);
227 }
228 }
229 } catch (err) {
230 console.error(err);
231 }
232 }
233
234 spinner.hide();
235 } finally {
236 isProcessing = false;
237 }
238}
239
240/**
241 * ==========================================================
242 * INITIAL PASS
243 * ==========================================================
244 */
245await processViewport();
246
247popup.success(`Lazy translation enabled (${targetLang})`);
248
249/**
250 * ==========================================================
251 * SCROLL-BASED LAZY TRANSLATION
252 * ==========================================================
253 */
254setInterval(async () => {
255 try {
256 await processViewport();
257 } catch (err) {
258 console.error(err);
259 }
260}, POLL_INTERVAL);Submitted on 6/27/2026