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