- JavaScript 53.5%
- HTML 42.1%
- Python 4.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The Chrome Web Store requires a privacy policy for extensions that send page content and hold authentication tokens. The new page states what is stored locally, what is sent to Cloudflare, and that nothing is collected by the developer. The landing page links to it from the footer and now points setup at the options page, where the credentials moved. Also ignore the .wrangler directory left by Pages deploys. |
||
| .serena | ||
| _locales | ||
| icons | ||
| site | ||
| tools | ||
| .gitignore | ||
| background.js | ||
| CLAUDE.md | ||
| clipboard.js | ||
| i18n.js | ||
| manifest.json | ||
| options.html | ||
| options.js | ||
| popup.html | ||
| popup.js | ||
| README.md | ||
| test-clipboard.mjs | ||
| test.mjs | ||
| translate.js | ||
S15 — String Translator
Extensão do Chrome que traduz o texto selecionado usando um modelo de linguagem da Cloudflare Workers AI e coloca o resultado na área de transferência.
Selecione um texto → Ctrl+Shift+Y (ou botão direito → Traduzir com S15) → notificação de conclusão → Ctrl+V onde quiser.
A extensão nunca altera a página: o resultado vai sempre para a área de transferência.
Credenciais da Cloudflare
A API precisa de duas coisas, preenchidas na tela de opções (botão Editar… no popup, ou botão direito no ícone → Opções; enquanto faltar alguma, o popup avisa no topo):
- Account ID — em https://dash.cloudflare.com, entre em Workers & Pages; o Account ID aparece na barra lateral direita (e também na URL do painel).
- API Token — em https://dash.cloudflare.com/profile/api-tokens → Create Token
→ use o template Workers AI. Um token com
Account · Workers AI · Readbasta para executar o modelo.
Ambos ficam em chrome.storage.local, ou seja, só neste navegador — não são
sincronizados com a conta Google.
Para conferir as credenciais fora do Chrome:
curl -s https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai/run/@cf/google/gemma-4-26b-a4b-it \
-H "Authorization: Bearer $TOKEN" \
-d '{"messages":[{"role":"system","content":"Translate into Brazilian Portuguese. Output only the translation."},{"role":"user","content":"hello world"}],"max_tokens":4096}'
Esperado: {"result":{"response":"olá mundo",...},"success":true,...}
Trocar o @cf/... da URL é a forma mais rápida de comparar modelos antes de escolher
um no popup.
Instalação
python3 tools/make_icons.py(os PNGs já vêm gerados; só é preciso se você mudar o ícone)- Abra
chrome://extensions, ative o Modo do desenvolvedor - Carregar sem compactação → selecione esta pasta
- Abra as opções da extensão (botão direito no ícone → Opções) e preencha Account ID e API Token
Modelo
A tradução é feita por um modelo de linguagem de propósito geral com um prompt de
tradução, e não por um modelo dedicado de tradução automática (o m2m100-1.2b, usado
na primeira versão, entregava qualidade ruim em pt-BR). Escolha no popup:
| Modelo | ID | Entrada / Saída por 1M tokens | Nota |
|---|---|---|---|
| Gemma 4 26B (padrão) | @cf/google/gemma-4-26b-a4b-it |
$0,10 / $0,30 | linhagem do Gemini, 256k de contexto |
| GLM 4.7 Flash | @cf/zai-org/glm-4.7-flash |
$0,06 / $0,40 | 100+ idiomas, entrada mais barata |
| Llama 3.3 70B | @cf/meta/llama-3.3-70b-instruct-fp8-fast |
$0,29 / $2,25 | o maior da lista |
| Mistral Small 3.1 24B | @cf/mistralai/mistral-small-3.1-24b-instruct |
$0,35 / $0,56 | idiomas europeus |
Esta tabela é a comparação entre eles: no popup, o rótulo de cada modelo é só nome
próprio (Gemma 4 26B — Google), justamente para não precisar de tradução.
O padrão é o Gemma 4 porque é da linhagem do Gemini (a Cloudflare o descreve como "built from Gemini 3 research"), custa pouco e tem 256k de contexto. Troque no popup e compare com os seus próprios textos — qualidade de tradução varia por domínio e nenhum benchmark substitui um teste no material real. A Workers AI dá 10.000 neurons por dia sem cobrança, o que cobre uso pessoal com folga.
Para acrescentar outro modelo do catálogo, adicione uma entrada em MODELS no
translate.js. O ID precisa incluir o fornecedor (@cf/google/…), não
só o nome do modelo como aparece no índice da documentação.
Prompt
buildBody() monta a requisição no formato de chat. O texto selecionado vai isolado na
mensagem user, e a mensagem system instrui explicitamente a nunca seguir instruções
contidas nele — texto copiado de uma página qualquer pode conter algo que pareça uma
ordem para o modelo.
Ao contrário do m2m100, que exigia códigos ISO de duas letras, o prompt usa o nome do
idioma por extenso (Brazilian Portuguese), que é o campo name de cada entrada em
LANGS. Modelos de linguagem acertam mais assim do que com códigos. O botão ⇄ inverte
origem e destino.
Idioma da interface
Por padrão a extensão segue o idioma do navegador; sem tradução para ele, cai no inglês. Para forçar outro idioma, use o seletor no topo da tela de opções (botão Editar… no popup, ou botão direito no ícone → Opções).
A interface existe em todos os idiomas para os quais a extensão traduz: inglês, português (Brasil e Portugal), espanhol, francês, alemão, italiano, holandês, russo, polonês, turco, árabe, hebraico, híndi, japonês, coreano, chinês simplificado, sueco, ucraniano e indonésio. Em árabe e hebraico a tela inverte para RTL sozinha.
Duas diferenças entre essa lista e a LANGS, impostas pelo catálogo de locales do Chrome:
es-ES é es, e en cobre en-US e en-GB (que não diferem em nada aqui).
As traduções ficam em _locales/<dir>/messages.json, no formato padrão de extensão do
Chrome — inclusive a descrição da extensão e a do atalho, que o manifest referencia como
__MSG_extDescription__ e __MSG_cmdDescription__. Repare no sublinhado do diretório:
pt_BR, não pt-BR.
Acrescentar um idioma são dois passos: criar _locales/<dir>/messages.json com as mesmas
chaves de en e incluir o código em LOCALES, no i18n.js. node test.mjs
falha se faltar chave, e uma chave sem tradução cai no inglês em vez de sumir da tela.
Os nomes de idioma nas listas não estão nesses arquivos: vêm do Intl.DisplayNames,
que já traduz "American English" para "Inglês (Estados Unidos)" sozinho.
A extensão lê o messages.json com fetch, e não com chrome.i18n.getMessage(): a API
nativa resolve pelo idioma do Chrome e não aceita troca em tempo de execução, o que
deixaria o seletor acima sem efeito. Por isso os placeholders são {n}, {reason}
(substituídos por translator()), e não o $nome$ do getMessage().
O relato de falha da cópia (focused=, secure=) e os logs do service worker ficam fora
disso de propósito: são diagnóstico, saem concatenados com mensagens cruas do navegador.
Glossário
Termos que precisam sair sempre traduzidos da mesma forma ficam numa lista própria, na tela de opções da extensão (botão Editar… no popup, ou botão direito no ícone → Opções). Uma linha por termo:
issue = chamado
deploy = publicação
# linhas começando com # são comentários
release notes = notas da versão
O = divide na primeira ocorrência, então a tradução pode conter =. Linhas sem =,
sem origem ou sem tradução são ignoradas — entrariam vazias no prompt. A lista é salva
sozinha meio segundo depois da última tecla; não há botão salvar.
A tela mora nas opções, e não no popup, porque a lista pode ter centenas de linhas — no popup só fica a contagem (Seguindo N termos do glossário), que é a confirmação de que a tradução está usando o padrão.
Em cada tradução, apenas os termos que aparecem no texto selecionado entram na mensagem
system; um glossário longo inteiro em toda requisição custaria tokens e afogaria as
instruções de tradução. A comparação é por substring, sem exigir limite de palavra: com
limite, o glossário ficaria mudo em japonês e chinês, onde não há espaços entre palavras.
O preço é um eventual termo a mais no prompt — inofensivo perto de um termo a menos.
Atalho
O padrão é Ctrl+Shift+Y, alterável em chrome://extensions/shortcuts. A tela de
opções mostra o atalho atual (lido de chrome.commands.getAll(), nunca hardcoded) e
tem um botão que abre essa página do Chrome: a extensão não tem como capturar ou
gravar teclas por conta própria, só apontar para onde isso se faz.
Não é Ctrl+\ porque o Chrome só aceita letras, números e um conjunto fechado de
teclas nomeadas (Comma, Period, Space, Home, End…) em atalhos de extensão; barra
invertida não está nessa lista. A alternativa seria injetar um listener de teclado
em todas as páginas, o que exigiria a permissão <all_urls>.
Testes
node test.mjs && node test-clipboard.mjs # cada um imprime "ok"
test-clipboard.mjs roda a cópia (clipboard.js) contra um DOM falso de
~25 linhas — sem jsdom, só os métodos que a função toca. Existe porque essa cópia já
falhou em uso real sem dizer o motivo; o teste trava o relato de erro.
Cobre o formato da requisição, o parse da resposta e a limpeza da saída. Casos que o
teste trava de propósito: resposta de erro da API (colocaria undefined no clipboard),
resposta no formato antigo translated_text (denunciaria um ID de modelo errado), e
aspas internas sendo confundidas com aspas envolvendo o texto todo.
Diagnóstico
O ícone da extensão mostra um selo com o estado da última tradução, e o texto completo
aparece ao passar o mouse: os segundos decorridos em cinza (12s, em andamento), ✓
verde (copiada), ! vermelho (falhou). Ele existe porque a notificação pode ser
suprimida pelo sistema — modo "não perturbe", notificações do Chrome desativadas — e
nesse caso a tradução parecia simplesmente não acontecer. O selo não depende do sistema
operacional.
O contador é o que separa "demorando" de "travou": com prazo de 45s por tentativa e até três tentativas, uma tradução legítima pode passar de dois minutos. Se o número continua subindo, ainda está esperando a Cloudflare; se ele congela, o service worker foi encerrado no meio da chamada.
O popup mostra, embaixo, o estado da última chamada — ✓ Traduzido em 8s · há 2 minutos,
a mensagem de erro completa, ou "A última tradução foi interrompida — acione de
novo". Essa última linha é o caso do selo congelado: o Chrome desligou o service worker
antes de a resposta chegar, e nenhum aviso ia chegar nunca. É a única situação em que
acionar de novo é a resposta certa; nas outras a extensão já fez o que dava. Enquanto uma
tradução corre, a linha conta os segundos junto com o selo.
Enquanto o selo está contando, um novo acionamento é ignorado em vez de disparar uma segunda requisição — só cabe um resultado na área de transferência, e duas traduções em paralelo terminariam em ordem indefinida. O acionamento descartado fica registrado no log do service worker.
Os logs do service worker não aparecem no console da página. Abra
chrome://extensions → S15 → link "service worker". Cada tradução registra ali o
modelo, o status HTTP e o corpo completo da resposta ([S15] …).
Se a notificação disser "Resposta inesperada da Cloudflare", ela vem acompanhada de um
recorte do JSON recebido — a resposta chegou, mas em um formato que
parseTranslation() não reconhece. Para ver o formato inteiro, chame o modelo direto:
curl -s https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai/run/@cf/google/gemma-4-26b-a4b-it \
-H "Authorization: Bearer $TOKEN" \
-d '{"messages":[{"role":"user","content":"translate to portuguese: hello"}],"max_tokens":256}' | jq
Erros transitórios da Workers AI
Capacity temporarily exceeded e Internal server error são sobrecarga momentânea do
lado da Cloudflare, não problema de configuração: a mesma requisição costuma passar
segundos depois. A extensão repete sozinha até duas vezes (esperas de 1,5s e 4s) antes de
desistir, e o título do ícone mostra tentando de novo (2ª)… enquanto isso. Se a mensagem de erro
terminar em (3 tentativas), as três falharam — aí é esperar um pouco ou trocar de
modelo no popup, porque a capacidade é por modelo.
Quem decide o que repetir é isTransient() em translate.js: HTTP 429 e
5xx, mais as mensagens de capacidade. Token inválido e ID de modelo errado ficam fora de
propósito — repetir esses só faz esperar mais pelo mesmo erro.
Causas comuns, na ordem: ID de modelo incorreto ou indisponível na conta (errors com
código 7000/5007), token sem a permissão Workers AI, ou um campo de saída diferente
dos reconhecidos. Achou um campo novo? Acrescente na lista de candidatos dentro de
parseTranslation(), em translate.js.
O Gemma 4 devolve result.choices[0].message.content ("object": "chat.completion"),
não result.response — parseTranslation() aceita os dois porque os modelos do
catálogo não são consistentes entre si.
Se a falha for "Não foi possível copiar", a mensagem traz o motivo e o estado da página
(focused=, secure=). secure=false significa página http:, onde a Clipboard API
não existe e a cópia depende do document.execCommand, que é obsoleto.
Limitações
- Não funciona em
chrome://, na Chrome Web Store, no visualizador de PDF nem em páginas de outras extensões: o Chrome bloqueia injeção de script nesses lugares, para qualquer extensão. - Seleção dentro de um iframe de outra origem não é lida pelo atalho:
activeTabnão concede acesso a esses frames. Use o menu de contexto nesse caso — ele recebe o texto direto do Chrome, sem depender de injeção. - A resposta é limitada a
max_tokens: 4096; uma seleção muito longa sai truncada. O default da Cloudflare seria 256, o que cortaria até parágrafos curtos. - Sendo um modelo de linguagem e não um tradutor dedicado, a saída pode eventualmente
vir com um comentário do modelo apesar do prompt.
cleanOutput()remove blocos<think>e aspas envolventes, mas não é um filtro completo. - Em páginas
http:a Clipboard API não está disponível (contexto não seguro) e a cópia cai paradocument.execCommand('copy'). - A extensão não escreve na página. Houve uma tentativa de substituir a seleção no lugar
quando ela estava num campo de texto (v1.2.0–1.2.1), removida na v1.3.0 por não
funcionar de forma confiável:
document.execCommand('insertText')chegava a devolvertruesem alterar o campo, e nemsetRangeTextcomo reserva resolveu.
Permissões e por quê
| Permissão | Motivo |
|---|---|
activeTab + scripting |
ler a seleção e escrever na área de transferência, só na aba onde você acionou a extensão |
contextMenus |
item "Traduzir com S15" no botão direito |
notifications |
avisar quando a tradução termina ou falha |
storage |
guardar credenciais, idiomas, o modelo escolhido, o glossário e o idioma da interface |
clipboardWrite |
copiar sem exigir ativação transitória — o gesto do usuário já expirou quando a tradução volta da API |
https://api.cloudflare.com/* |
chamar a API de tradução |
Não há content_scripts nem <all_urls>: o acesso à página é concedido pelo próprio
gesto (atalho ou menu de contexto) e expira quando você sai dela.