Sincronizando hreflang em 13 idiomas com traduções ainda pela metade (site estático + git + Python)
Sou o Ayumu Kitabayashi, desenvolvedor do minulo, um app para iPhone. O site do app é estático: HTML escrito à mão, sem framework e sem etapa de build, tudo num repositório git. Ele está em 13 idiomas. O japonês fica na raiz e os outros 12 (inglês, coreano, alemão, francês, espanhol, português do Brasil, italiano, chinês tradicional, indonésio, tailandês, vietnamita e russo) ficam cada um na sua pasta.
Neste post eu conto um problema que apareceu quando passei a traduzir vários lotes de páginas ao mesmo tempo, e como resolvi com dois ajustes pequenos em volta de um script Python, sem mudar nada na estrutura do site.
O cenário que dava errado
Imagine o seguinte: as páginas em italiano e indonésio estão prontas para commit. Na mesma pasta, ainda sem git add, estão as páginas em vietnamita que eu ainda estou traduzindo.
Meu script de sincronização olha o disco para decidir quais idiomas existem para cada página. Ele encontra os arquivos em vietnamita, conclui que estão prontos e coloca links para eles em todas as versões: no bloco de hreflang, no rodapé e no sitemap. Eu faço commit das páginas em italiano e, no site publicado, os links para o vietnamita dão 404 até o outro lote entrar.
Isso não era hipótese. Olha os commits recentes (as mensagens estão em japonês, mas os números já dizem tudo):
5421e94 aeo: 後半の問い10本を独・仏・西・葡へ(40ページ)
731339c aeo: 後半の問いを繁・タイへ(17ページ)
8b2bd96 aeo: 後半の問い9本を越・露へ(18ページ)
5a935ec aeo: 後半の問い8本を伊・尼へ(16ページ)
São 40 páginas em alemão, francês, espanhol e português, 17 em chinês tradicional e tailandês, 18 em vietnamita e russo, 16 em italiano e indonésio. Vários desses lotes estavam abertos ao mesmo tempo no mesmo working tree.
Como o sync funciona
Cada artigo existe uma vez por idioma, sempre com o mesmo nome de arquivo (o slug). Toda cópia precisa de três coisas em sincronia:
- as tags
<link rel="alternate" hreflang="...">no head, uma por idioma existente, mais ox-default; - a linha de links de idioma no rodapé, com o idioma atual em negrito;
- uma entrada no
sitemap.xmlpara cada URL, todas com o mesmo conjunto dexhtml:link.
A lista de idiomas fica em tools/sync_langs.py. Cada tupla é (código hreflang, pasta, rótulo do rodapé). O japonês tem pasta vazia porque fica na raiz:
L=[('ja','', '日本語'),('en','en','English'),('ko','ko','한국어'),
('de','de','Deutsch'),('fr','fr','Français'),('es','es','Español'),
('pt-BR','pt','Português'),('it','it','Italiano'), ...]
O ponto central está aqui. Para cada slug, o script decide quais idiomas existem verificando se o arquivo está no disco. O x-default aponta para o inglês quando ele existe:
have=[(h,d,n) for h,d,n in L if os.path.exists(path(d,s))]
xdef = url('en',s) if any(d=='en' for _,d,_ in have) else url(have[0][1],s)
Depois ele reescreve todas as cópias daquele slug: apaga as tags alternate antigas, insere um bloco novo logo depois do canonical, reconstrói o <div class="langs"> do rodapé e, no sitemap, remove todos os <url> do slug e adiciona de novo no final com o lastmod do dia.
t=re.sub(r'<link rel="alternate" hreflang="[^"]+" href="[^"]+">\n?','',t)
t=re.sub(r'(<link rel="canonical"[^>]*>\n)',lambda m:m.group(1)+block+'\n',t,count=1)
Ou seja, rodar o sync para uma página nova mexe também nas versões dessa página em outros idiomas. É simples e funciona bem, desde que o disco mostre só o que deve ir para o ar. Com tradução pela metade na pasta, essa premissa cai.
(Existe um caso especial: uma página em japonês tem nome de arquivo diferente das outras, e um dicionário JA faz o mapeamento. Isso não muda o resto.)
Ajuste 1: esconder do sync o que ainda não está pronto
Em vez de colocar uma flag nova no sync_langs.py, escrevi um wrapper, o integ_tr.py. Ele recebe a lista de arquivos novos prontos, a lista de arquivos editados e a mensagem de commit.
Primeiro ele pergunta ao git quais arquivos não estão rastreados. Tudo que está fora do git e não está na lista de prontos vira lista de exclusão:
untracked=subprocess.run(['git','ls-files','--others','--exclude-standard'],
capture_output=True,text=True).stdout.split()
open(J+'/th_excl.txt','w').write('\n'.join(u for u in untracked if u not in new))
Em seguida ele lê o código do sync_langs.py, troca duas strings e salva uma cópia numa pasta temporária. Uma troca fixa o ROOT no repositório e carrega o conjunto EX. A outra muda a checagem de existência:
.replace("os.path.exists(path(d,s))",
"(os.path.exists(path(d,s)) and path(d,s) not in EX)")
Pronto. Nessa execução, um arquivo em andamento é tratado exatamente como um arquivo que não existe. O script original continua simples e sem alteração, e a cópia modificada só vive durante essa rodada. O wrapper roda essa cópia uma vez para cada slug.
Depois do sync, git diff --name-only mostra quais arquivos rastreados foram reescritos. Os que têm o mesmo nome de um dos slugs entram no commit, porque são as versões antigas que ganharam linhas novas de hreflang. O wrapper também acrescenta cada URL nova no llms.txt e no llms-full.txt, na mesma linha que já tem a URL em inglês daquele slug.
Ajuste 2: não deixar commitar link para arquivo fora do git
A lista de exclusão resolve o problema que eu conheço. Para pegar os que eu não conheço, tem uma checagem antes de qualquer git add. Todo HTML que vai entrar no commit passa por aqui:
tracked=set(subprocess.run(['git','ls-files'],capture_output=True,
text=True).stdout.split())|set(new)
...
for h in re.findall(r'href="([^"#?:]+\.html)',x):
q=os.path.normpath(os.path.join(os.path.dirname(p),h))
if q not in tracked: print('bad link',p,h); bad+=1
A regra é rígida de propósito: um link relativo para .html só vale se o destino já estiver no git ou na lista de arquivos novos deste commit. Existir no disco não basta. A regex ignora qualquer coisa com dois-pontos (URLs absolutas), âncoras e query strings.
No mesmo loop, cada bloco JSON-LD passa por json.loads. Se a tradução de um FAQ deixou uma aspa solta dentro de uma string, o script para ali.
Por último, o sitemap não pode ter <loc> repetido:
locs=re.findall(r'<loc>([^<]*)</loc>',open('sitemap.xml').read())
if len(locs)!=len(set(locs)): print('DUP'); bad+=1
if bad: sys.exit('abort')
Se qualquer coisa falhar, o script sai antes do git add. Nada vai para o stage e nada vai para o push.
Por que checar duplicata no sitemap
Essa checagem nasceu de um bug real. O commit d754107 corrigiu um caso em que o sync não conseguia apagar linhas do sitemap escritas num formato antigo. Essas linhas sobreviviam à remoção e as entradas novas iam parar do lado delas. A correção limpou 23 entradas duplicadas. Agora, se isso acontecer de novo, o script falha na hora em vez de deixar passar.
Só entra no commit o que foi checado
Com tudo certo, o wrapper faz git add de uma lista explícita: arquivos novos, editados, os que o sync reescreveu, mais sitemap.xml, llms.txt e llms-full.txt. Nunca git add -A.
subprocess.run(['git','add']+files+['sitemap.xml','llms.txt','llms-full.txt'],check=True)
subprocess.run(['git','commit','-qm',msg],check=True)
subprocess.run(['git','push','-q','origin','main'],check=True)
Os arquivos dos outros lotes continuam fora do git e, na próxima execução, caem de novo na lista de exclusão até ficarem prontos.
Resultado
Hoje o sitemap tem 1.237 URLs. Os lotes daqueles commits entraram um depois do outro, com outras traduções ainda paradas, sem rastreamento, no mesmo working tree.
O que eu tiro disso: se um script de sincronização lê o estado do disco, o disco precisa mostrar só o que você quer publicar. Esconder os arquivos inacabados durante a execução e validar os links contra o git, e não contra o sistema de arquivos, resolveu o problema sem colocar um sistema de build num site que não precisa de um.



