image

Bootcamps ilimitados y a más de 750 cursos para siempre

70
%OFF

AK

Ayumu Kitabayashi25/10/2026 04:46
Compartir

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 o x-default;
    • a linha de links de idioma no rodapé, com o idioma atual em negrito;
    • uma entrada no sitemap.xml para cada URL, todas com o mesmo conjunto de xhtml: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.

    https://minulo.app/pt/?ref=dio-pt-integ-1025j

    Compartir
    Recomendado para ti
    Neo4j - Certification
    DXC - IA do Zero ao Primeiro Agente
    Reclame AQUI - Dados e IA na Prática
    Comentarios (0)