Hoje eu rodei dois comandos:

launchctl disable gui/501/com.noticias.scraping
launchctl bootout  gui/501/com.noticias.scraping

E o Notícias Mobile parou de se alimentar sozinho. Vale dizer logo de saída: o site continua no ar e os dois apps também. O que dorme é a coleta, não o produto. Pausar um sistema me pareceu um bom momento para escrever o que ele era, enquanto o conhecimento ainda está fresco.

O projeto era um portal de notícias sobre desenvolvimento mobile que se abastecia sozinho: descobria matérias em 28 fontes, buscava o conteúdo, resumia e traduzia com um LLM rodando local no meu Mac Mini, e publicava num site estático gerado com WingedSwift que alimentava também um app iOS e um Android. No momento em que pausei, eram 509 artigos publicados, 15 categorias, 8 jobs de sincronização e 1.061 arquivos estáticos em produção.

Este post é o mapa desse sistema. Não é um tutorial, é o tipo de documento que eu queria ter encontrado quando comecei: a arquitetura real, com as decisões que envelheceram bem, as que envelheceram mal, e uma que nunca funcionou e eu só descobri hoje, na hora de desligar.

1. A restrição que definiu tudo: custo marginal zero

Todo projeto pessoal tem uma restrição inegociável que define a arquitetura inteira. Aqui era simples: não quero pagar mensalidade por um projeto que talvez eu pause. (Spoiler: pausei.)

Essa restrição matou várias opções óbvias antes da primeira linha de código:

Opção descartadaPor quê
OpenAI / Anthropic API para resumo e traduçãoCusto por token cresce com o volume, e volume era o ponto
Backend gerenciado (Vercel Functions, Lambda quente)Cobrança por invocação num sistema que roda o dia inteiro
Banco gerenciado (RDS, Supabase pago)Mensalidade fixa
CDN de imagens pagaIdem

O que sobrou foi uma arquitetura híbrida caseira: computação pesada em hardware que eu já tenho e que já está ligado, distribuição em serviço estático que é gratuito na minha escala. O LLM não é uma API, é um qwen2.5:14b ocupando cerca de 9,7 GB de RAM no Mac Mini M4 Pro. O banco não é gerenciado, é um PostgreSQL numa Raspberry Pi 3 Model B Rev 1.2 na rede local. A API e o backoffice rodam no maxiserver, hoje um Dell Inspiron N4050 com 4 GB de RAM. O site não tem servidor: é HTML pré-gerado com WingedSwift e publicado no AWS Amplify.

Guarde essa restrição, porque ela explica quase toda decisão estranha que vem a seguir.

2. Visão macro: seis repositórios, três fronteiras

O sistema é um monorepo-de-fato (um workspace com seis projetos independentes) organizado em três fronteiras bem separadas: coleta, núcleo e distribuição.

flowchart LR subgraph COLETA["🔎 Coleta · Python + LLM local"] SRC["28 fontes<br/>RSS · HTML · YouTube"] W["noticias-mobile-scraping<br/>worker Python 3.12"] OL["Ollama<br/>qwen2.5:14b"] SRC --> W W <--> OL end subgraph NUCLEO["⚙️ Núcleo · Swift + PostgreSQL"] API["noticias-mobile-api<br/>Vapor 4 · Swift 6.3"] DB[("PostgreSQL<br/>noticias_mobile")] BO["noticias-mobile-backoffice<br/>Next.js 16"] API <--> DB BO --> API end subgraph DIST["📤 Distribuição"] SITE["noticias-mobile-site<br/>gerador WingedSwift"] HTML["output/<br/>1.061 arquivos estáticos"] AMP["AWS Amplify"] IOS["App iOS 26+<br/>MVVM-C"] AND["App Android<br/>Compose"] SITE --> HTML --> AMP AMP -. "feeds RSS" .-> IOS AMP -. "feeds RSS" .-> AND end W -->|"HTTP: itens, logs, heartbeat"| API API -->|"SDUI: GET /screens/home-1"| SITE style COLETA fill:#1e3a5f,stroke:#4a90d9,color:#fff style NUCLEO fill:#3d2c4f,stroke:#9b6bc4,color:#fff style DIST fill:#1f4030,stroke:#4caf7d,color:#fff
flowchart LR
    subgraph COLETA["🔎 Coleta · Python + LLM local"]
        SRC["28 fontes<br/>RSS · HTML · YouTube"]
        W["noticias-mobile-scraping<br/>worker Python 3.12"]
        OL["Ollama<br/>qwen2.5:14b"]
        SRC --> W
        W <--> OL
    end

    subgraph NUCLEO["⚙️ Núcleo · Swift + PostgreSQL"]
        API["noticias-mobile-api<br/>Vapor 4 · Swift 6.3"]
        DB[("PostgreSQL<br/>noticias_mobile")]
        BO["noticias-mobile-backoffice<br/>Next.js 16"]
        API <--> DB
        BO --> API
    end

    subgraph DIST["📤 Distribuição"]
        SITE["noticias-mobile-site<br/>gerador WingedSwift"]
        HTML["output/<br/>1.061 arquivos estáticos"]
        AMP["AWS Amplify"]
        IOS["App iOS 26+<br/>MVVM-C"]
        AND["App Android<br/>Compose"]
        SITE --> HTML --> AMP
        AMP -. "feeds RSS" .-> IOS
        AMP -. "feeds RSS" .-> AND
    end

    W -->|"HTTP: itens, logs, heartbeat"| API
    API -->|"SDUI: GET /screens/home-1"| SITE

    style COLETA fill:#1e3a5f,stroke:#4a90d9,color:#fff
    style NUCLEO fill:#3d2c4f,stroke:#9b6bc4,color:#fff
    style DIST fill:#1f4030,stroke:#4caf7d,color:#fff

Três detalhes desse diagrama merecem atenção, porque são contraintuitivos:

O worker não toca o banco. Ele fala HTTP com a API, como qualquer outro cliente. Poderia abrir uma conexão PostgreSQL direta e seria mais rápido, mas aí eu teria regra de negócio (deduplicação, validação, transição de estado) em dois lugares, em duas linguagens. A API é a única dona do schema.

Os apps não falam com a API. Eles consomem os feeds RSS do site estático. Isso é deliberado e eu volto nisso na seção 6, porque foi a decisão de melhor custo-benefício do projeto inteiro.

O site é um cliente da API, não um irmão dela. O gerador em WingedSwift faz GET /api/v1/screens/home-1 e recebe a mesma árvore de widgets que um app nativo receberia. A diferença é só o que ele faz com ela: em vez de renderizar UIView, escreve HTML.

3. O coração: um pipeline como máquina de estados

A parte mais interessante do sistema é o pipeline de ingestão. Ele não é um script que roda do início ao fim, é uma máquina de estados persistida no banco, onde cada item avança um passo por vez e salva progresso entre passos.

Essa escolha tem um motivo prático: tradução de matéria longa com LLM local demora. Se o processo morrer no meio (Mac dormiu, worker reiniciou, modelo travou), eu não quero recomeçar do zero nem perder o trabalho já feito.

stateDiagram-v2 [*] --> discovered: discovery RSS / listing / YouTube discovered --> scraped: fetch Firecrawl ou httpx scraped --> summarized: LLM resumo pt-BR summarized --> translated: LLM titulo e corpo pt-BR translated --> ready: validacao editorial ready --> published: decisao humana no backoffice published --> [*] discovered --> failed: erro scraped --> failed: erro summarized --> failed: erro translated --> failed: erro failed --> discovered: reprocessar fromStage ready --> summarized: reprocessar traducao note right of ready O worker PARA aqui. Nada vai ao ar sozinho. end note
stateDiagram-v2
    [*] --> discovered: discovery RSS / listing / YouTube
    discovered --> scraped: fetch Firecrawl ou httpx
    scraped --> summarized: LLM resumo pt-BR
    summarized --> translated: LLM titulo e corpo pt-BR
    translated --> ready: validacao editorial
    ready --> published: decisao humana no backoffice
    published --> [*]

    discovered --> failed: erro
    scraped --> failed: erro
    summarized --> failed: erro
    translated --> failed: erro

    failed --> discovered: reprocessar fromStage
    ready --> summarized: reprocessar traducao

    note right of ready
        O worker PARA aqui.
        Nada vai ao ar sozinho.
    end note

O worker para em `ready`, e isso é a decisão mais importante do projeto

Um pipeline de scraping com LLM que publica sozinho é uma máquina de produzir constrangimento em escala. Tradução automática erra nome próprio, LLM inventa contexto, fonte publica retratação depois.

Então o pipeline automatiza tudo o que é trabalho e para no que é julgamento. Ele descobre, busca, resume, traduz, normaliza HTML, extrai imagem de capa e deixa o item pronto. A transição de ready para published só acontece com um clique meu no backoffice.

Isso transformou a tarefa "manter um portal" em "revisar uma fila". São coisas de ordem de grandeza diferente em esforço.

Idempotência via HTTP 409

Um detalhe pequeno que resolveu uma classe inteira de bug: URLs são normalizadas (sem fragmento, sem UTM) antes do POST /scrape-items, e a API responde 409 Conflict para URL que já existe.

O worker trata 409 como "já conheço, segue o baile", e não como erro. O resultado é que o discovery pode rodar de hora em hora sobre o mesmo feed sem nenhum controle de "até onde eu já li". A deduplicação é uma constraint de banco, não lógica de aplicação. Constraint de banco não tem race condition; lógica de aplicação tem.

Fetch com degradação em camadas

flowchart TD A["URL do candidato"] --> B{"FIRECRAWL_API_KEY<br/>configurada?"} B -->|sim| C["Firecrawl<br/>HTML + markdown limpos"] B -->|não| D["httpx + readability-lxml"] C -->|falhou| D D --> E["sanitize_editorial_html<br/>remove p vazios, absolutiza src"] E --> F{"Extrair imagem de capa"} F --> G["og:image"] G -->|ausente| H["twitter:image"] H -->|ausente| I["imagem do RSS"] I -->|ausente| J["1ª img útil do corpo"] style C fill:#1e3a5f,stroke:#4a90d9,color:#fff style D fill:#4a3520,stroke:#d4913a,color:#fff
flowchart TD
    A["URL do candidato"] --> B{"FIRECRAWL_API_KEY<br/>configurada?"}
    B -->|sim| C["Firecrawl<br/>HTML + markdown limpos"]
    B -->|não| D["httpx + readability-lxml"]
    C -->|falhou| D
    D --> E["sanitize_editorial_html<br/>remove p vazios, absolutiza src"]
    E --> F{"Extrair imagem de capa"}
    F --> G["og:image"]
    G -->|ausente| H["twitter:image"]
    H -->|ausente| I["imagem do RSS"]
    I -->|ausente| J["1ª img útil do corpo"]

    style C fill:#1e3a5f,stroke:#4a90d9,color:#fff
    style D fill:#4a3520,stroke:#d4913a,color:#fff

O padrão aqui é o mesmo em todo o sistema: o caminho pago é opcional e o caminho grátis é o fallback. Sem chave do Firecrawl, o worker usa httpx + readability-lxml e a qualidade cai um pouco. Ele nunca para por falta de serviço externo.

4. Control plane: pausar sem matar o processo

O worker roda sob launchd com KeepAlive=true. Isso significa que matar o processo não o desliga, porque o macOS o ressuscita em 10 segundos. Então eu precisava de um jeito de controlar o worker sem mexer no processo.

A solução foi um control plane simples via API: a cada ciclo, o worker lê um desired_state e obedece.

sequenceDiagram participant BO as Backoffice participant API as API Vapor participant W as Worker Python participant OL as Ollama Note over W: loop a cada 60s W->>API: heartbeat + GET desired_state API-->>W: "running" W->>API: GET /scrape-sync-jobs?enabled=true API-->>W: jobs vencidos W->>W: discovery → fetch W->>OL: resumir + traduzir OL-->>W: JSON pt-BR W->>API: PATCH item → ready W->>API: POST /scrape-run-logs Note over BO: preciso da GPU<br/>para gerar imagem BO->>API: desired_state = "paused" W->>API: heartbeat + GET desired_state API-->>W: "paused" Note over W: para entre itens,<br/>sem perder progresso
sequenceDiagram
    participant BO as Backoffice
    participant API as API Vapor
    participant W as Worker Python
    participant OL as Ollama

    Note over W: loop a cada 60s
    W->>API: heartbeat + GET desired_state
    API-->>W: "running"

    W->>API: GET /scrape-sync-jobs?enabled=true
    API-->>W: jobs vencidos
    W->>W: discovery → fetch
    W->>OL: resumir + traduzir
    OL-->>W: JSON pt-BR
    W->>API: PATCH item → ready
    W->>API: POST /scrape-run-logs

    Note over BO: preciso da GPU<br/>para gerar imagem
    BO->>API: desired_state = "paused"
    W->>API: heartbeat + GET desired_state
    API-->>W: "paused"
    Note over W: para entre itens,<br/>sem perder progresso

Três coisas que esse desenho me deu de graça:

  • Pausar entre itens, não no meio de um. O gate.refresh_stage_flags() é consultado antes de cada

estágio, então pausar nunca corrompe um item pela metade.

  • Logs centralizados. O worker manda erro operacional para POST /scrape-run-logs além do stdout.

Debugar um daemon lendo ~/Library/Logs é ruim; ler no backoffice é bem mais confortável.

  • Controle de GPU. Isso resolveu um problema físico real: com 24 GB de RAM, eu não posso ter o

qwen2.5:14b (cerca de 9,7 GB) e um modelo de imagem (cerca de 12 GB) carregados ao mesmo tempo. Pausar o scraping pelo painel libera a memória. A restrição de hardware virou um botão na interface.

5. Server-Driven UI: o contrato que eu construí para três clientes e usei em um

O portal nasceu de um template HTML com 21 páginas e 256 slots de widget. Em vez de tratar isso como "o layout do site", transformei o mapa de slots em contrato de API.

Cada widget tem widgetId (ex.: home-1.hero), widgetType (ex.: hero-mosaic) e um sourceType que diz como o conteúdo é resolvido no servidor:

sourceTypeComo a API resolve
manualArtigos fixados à mão, ignorando pins expirados
category / tag / authorArtigos publicados do agrupamento, com limitCount
recentpublishedAt DESC
popularviewCount DESC
trendingPopular nos últimos 30 dias, com fallback para popular
staticSem artigos; o cliente usa config ou o site config
flowchart TD DB[("widget_slots<br/>widget_bindings")] --> API["GET /api/v1/screens/home-1"] API --> J["Árvore de widgets<br/>regions.main[] · regions.sidebar[]<br/>items: ArticleCard[]"] J --> S["Gerador Swift<br/>WingedSwift"] J --> N["Cliente nativo<br/>hipotético"] S --> H["HTML estático"] N --> V["Views nativas"] subgraph REAL["O que eu<br/>de fato construí"] H end subgraph NUNCA["O que o contrato permitia<br/>mas eu não usei"] V end style REAL fill:#1f4030,stroke:#4caf7d,color:#fff style NUNCA fill:#4a2020,stroke:#c45b5b,color:#fff
flowchart TD
    DB[("widget_slots<br/>widget_bindings")] --> API["GET /api/v1/screens/home-1"]
    API --> J["Árvore de widgets<br/>regions.main[] · regions.sidebar[]<br/>items: ArticleCard[]"]

    J --> S["Gerador Swift<br/>WingedSwift"]
    J --> N["Cliente nativo<br/>hipotético"]

    S --> H["HTML estático"]
    N --> V["Views nativas"]

    subgraph REAL["O que eu<br/>de fato construí"]
        H
    end
    subgraph NUNCA["O que o contrato permitia<br/>mas eu não usei"]
        V
    end

    style REAL fill:#1f4030,stroke:#4caf7d,color:#fff
    style NUNCA fill:#4a2020,stroke:#c45b5b,color:#fff

Esse diagrama tem uma caixa vermelha de propósito, e ela é a lição mais honesta do post.

O SDUI funcionou tecnicamente e foi caro em relação ao que entregou. Eu construí um contrato capaz de dirigir qualquer cliente e no fim usei um renderizador só: o gerador de HTML em WingedSwift. Os apps nativos foram por outro caminho (feeds RSS), porque era mais barato e resolvia o problema.

Não é que SDUI seja errado. É que SDUI paga por si mesmo quando você tem múltiplos clientes que precisam mudar de layout sem passar por review de loja. Eu tinha um cliente, e ele era um build estático que eu podia regerar quando quisesse. Paguei o preço da flexibilidade sem consumir o benefício. Se eu recomeçasse hoje, faria endpoints REST comuns e só introduziria SDUI quando o segundo cliente aparecesse com necessidade real de layout dinâmico.

6. A decisão de melhor custo-benefício: apps consumindo RSS

Os apps iOS e Android são modulares e sérios: padrão MVVM-C, módulos SPM/Gradle espelhados (SharedKit, DomainKit, DataKit, NavigationKit, DSKit), Compose de um lado, SwiftUI do outro.

E eles não têm backend.

flowchart LR AMP["AWS Amplify<br/>site estático"] F1["feed.xml"] F2["feed-ios.xml"] F3["feed-android.xml"] F4["feed-{categoria}.xml<br/>15 categorias"] AMP --> F1 & F2 & F3 & F4 F1 & F2 & F3 & F4 --> IOS["iOS · DataKit<br/>RSS + SwiftData"] F1 & F2 & F3 & F4 --> AND["Android · datakit<br/>RSS + Room"] IOS --> C1["SwiftData<br/>cache offline"] AND --> C2["Room<br/>cache offline"] FB["Firebase<br/>FCM · Analytics · Crashlytics"] FB -. "push e telemetria" .-> IOS FB -. "push e telemetria" .-> AND style AMP fill:#1f4030,stroke:#4caf7d,color:#fff style FB fill:#4a3520,stroke:#d4913a,color:#fff
flowchart LR
    AMP["AWS Amplify<br/>site estático"]
    F1["feed.xml"]
    F2["feed-ios.xml"]
    F3["feed-android.xml"]
    F4["feed-{categoria}.xml<br/>15 categorias"]

    AMP --> F1 & F2 & F3 & F4

    F1 & F2 & F3 & F4 --> IOS["iOS · DataKit<br/>RSS + SwiftData"]
    F1 & F2 & F3 & F4 --> AND["Android · datakit<br/>RSS + Room"]

    IOS --> C1["SwiftData<br/>cache offline"]
    AND --> C2["Room<br/>cache offline"]

    FB["Firebase<br/>FCM · Analytics · Crashlytics"]
    FB -. "push e telemetria" .-> IOS
    FB -. "push e telemetria" .-> AND

    style AMP fill:#1f4030,stroke:#4caf7d,color:#fff
    style FB fill:#4a3520,stroke:#d4913a,color:#fff

Cada categoria gera seu próprio feed (feed-ios.xml, feed-arquitetura.xml, feed-seguranca.xml…). O app baixa o feed da categoria que o usuário abriu e persiste local.

O que isso comprou:

  • Custo de infra dos apps: zero. Nenhuma requisição a servidor meu. O Amplify serve XML estático.
  • Disponibilidade dos apps desacoplada do meu servidor. E isso não é teórico: E isso não é teórico: **agora que pausei a

coleta, o site e os dois apps continuam funcionando normalmente.coleta, o site e os dois apps continuam funcionando normalmente.** O conteúdo publicado segue no ar, servido por arquivo estático. O sistema tem uma degradação elegante que eu não projetei conscientemente, ela caiu no meu colo por causa do formato.

  • Nenhuma autenticação para construir. Conteúdo público lido por formato público.

O que isso custou é menos do que parece, porque conteúdo e engajamento viajam por canais separados. O conteúdo vem do RSS estático; push, telemetria e crash reporting vêm do Firebase, que também é gratuito na minha escala. Os apps têm notificação de nova matéria (FCM), preferência por stack de interesse, Analytics e Crashlytics, e nada disso passa por servidor meu.

A conta que sobra é a de sempre com RSS: a granularidade é do feed, não do usuário. Eu sei que a categoria iOS publicou, não que você leu. Para um portal sem login, isso nunca me atrapalhou.

RSS é uma tecnologia de 1999 e resolveu, em 2026, um problema de distribuição melhor do que a API que eu tinha construído para o mesmo fim. Achei que valia registrar isso.

A lição generalizável não é "use RSS". É separar o caminho do conteúdo do caminho do engajamento. Quando eu tratava os dois como um só problema, a resposta era "preciso de um backend disponível 24/7". Quando separei, cada metade encontrou uma solução gratuita e independente, e foi essa separação que permitiu pausar o backend hoje sem derrubar os apps.

7. Topologia física: a arquitetura que a rede impõe

Diagramas de arquitetura costumam mentir por omissão: mostram serviços flutuando no vácuo. A topologia física deste projeto é parte da arquitetura, porque ela restringe o que é possível.

flowchart TB subgraph MAC["🖥️ Mac Mini M4 Pro · 24 GB"] W["Worker Python<br/>launchd KeepAlive"] OL["Ollama :11434<br/>qwen2.5:14b"] PV["Preview HTTP :8787"] GEN["Gerador WingedSwift<br/>swift build"] W <--> OL W --- PV end subgraph MAXI["🗄️ maxiserver<br/>Dell Inspiron N4050 · 4 GB"] API["API Vapor :5151<br/>Docker · 192.168.1.205"] BO["Backoffice :5252<br/>Docker"] end subgraph PI["🍓 Raspberry Pi 3<br/>Model B Rev 1.2"] PG[("PostgreSQL<br/>noticias_mobile<br/>192.168.1.140")] end subgraph CLOUD["☁️ AWS"] AMP["Amplify<br/>noticiasmobile.com.br"] end W -->|"LAN"| API BO --> API API --> PG GEN -->|"GET /screens"| API GEN -->|"git push → deploy"| AMP style MAC fill:#1e3a5f,stroke:#4a90d9,color:#fff style MAXI fill:#3d2c4f,stroke:#9b6bc4,color:#fff style PI fill:#4a3520,stroke:#d4913a,color:#fff style CLOUD fill:#1f4030,stroke:#4caf7d,color:#fff
flowchart TB
    subgraph MAC["🖥️ Mac Mini M4 Pro · 24 GB"]
        W["Worker Python<br/>launchd KeepAlive"]
        OL["Ollama :11434<br/>qwen2.5:14b"]
        PV["Preview HTTP :8787"]
        GEN["Gerador WingedSwift<br/>swift build"]
        W <--> OL
        W --- PV
    end

    subgraph MAXI["🗄️ maxiserver<br/>Dell Inspiron N4050 · 4 GB"]
        API["API Vapor :5151<br/>Docker · 192.168.1.205"]
        BO["Backoffice :5252<br/>Docker"]
    end

    subgraph PI["🍓 Raspberry Pi 3<br/>Model B Rev 1.2"]
        PG[("PostgreSQL<br/>noticias_mobile<br/>192.168.1.140")]
    end

    subgraph CLOUD["☁️ AWS"]
        AMP["Amplify<br/>noticiasmobile.com.br"]
    end

    W -->|"LAN"| API
    BO --> API
    API --> PG
    GEN -->|"GET /screens"| API
    GEN -->|"git push → deploy"| AMP

    style MAC fill:#1e3a5f,stroke:#4a90d9,color:#fff
    style MAXI fill:#3d2c4f,stroke:#9b6bc4,color:#fff
    style PI fill:#4a3520,stroke:#d4913a,color:#fff
    style CLOUD fill:#1f4030,stroke:#4caf7d,color:#fff

Três máquinas na minha casa e um serviço na nuvem. Essa distribuição não é arbitrária:

  • O worker mora no Mac porque o Ollama mora no Mac. LLM local em Apple Silicon é rápido de verdade; a mesma inferência em CPU de servidor doméstico seria inviável. O worker foi para onde a GPU estava.
  • A API mora no maxiserver porque precisa estar sempre no ar. Hoje esse servidor é um Dell Inspiron N4050 com 4 GB de RAM. Meu Mac de trabalho dorme, atualiza, reinicia. O Inspiron não.
  • O Postgres mora numa Raspberry Pi 3 Model B Rev 1.2 porque é o dado, a única coisa aqui que eu não consigo reconstruir. Ele tem o backup mais chato e mais importante. Uma placa de 2016, na rede local, segura o estado de 509 artigos e de toda a máquina de ingestão.

E a consequência dessa topologia: o build do site só roda na minha rede. Ele depende de 192.168.1.205:5151, que não existe fora de casa. Nenhum runner de CI na nuvem alcança essa API. Foi isso que me obrigou a agendar a publicação com launchd no meu próprio Mac, em vez de usar GitHub Actions, e foi essa decisão que produziu o bug da seção 9.

8. O detalhe que quase me custou o histórico: `<lastBuildDate>`

Um problema pequeno e delicioso, do tipo que só aparece em produção.

Todo build regenera os feeds RSS, e todo feed RSS carrega um <lastBuildDate> com a hora da geração. Consequência: todo build produz um diff, mesmo quando nenhuma notícia nova entrou. Rodando diariamente, isso geraria um commit por dia com "conteúdo" que é só um carimbo de hora, e um histórico do Git onde é impossível achar quando um artigo de verdade entrou.

A solução foi ensinar o publish.sh a comparar ignorando o carimbo:

neutralize_build_date() {
  LC_ALL=C sed -E 's#<(lastBuildDate|dateCreated)>[^<]*</(lastBuildDate|dateCreated)>#<\1/>#g'
}

Se a única diferença entre o build novo e o publicado for a data, o script Se a única diferença entre o build novo e o publicado for a data, o script **reverte o output/** e não cria commit. O --force existe para publicar assim mesmo.

O LC_ALL=C naquela linha não é enfeite: sem ele, o sed do macOS aborta com "illegal byte sequence" ao encontrar UTF-8 malformado vindo de alguma fonte estrangeira. Levei um tempo para achar.

O publish.sh acabou virando o componente mais defensivo do sistema, porque ele checa tudo antes de gastar um build de quatro minutos:

flowchart TD A["./publish.sh"] --> B{"API respondendo<br/>/health?"} B -->|não| X1["❌ aborta"] B -->|sim| C{"SSH do GitHub<br/>autentica?"} C -->|não| X2["❌ aborta"] C -->|sim| D{"repo atrás de<br/>origin/main?"} D -->|sim| X3["❌ aborta: puxe antes"] D -->|não| E["./build.sh"] E -->|falhou| R["♻️ restaura output/<br/>publicado"] E -->|ok| F{"diff só no<br/>lastBuildDate?"} F -->|sim| G["reverte · sem commit"] F -->|não| H["commit só de output/<br/>+ push → Amplify"] style X1 fill:#4a2020,stroke:#c45b5b,color:#fff style X2 fill:#4a2020,stroke:#c45b5b,color:#fff style X3 fill:#4a2020,stroke:#c45b5b,color:#fff style H fill:#1f4030,stroke:#4caf7d,color:#fff
flowchart TD
    A["./publish.sh"] --> B{"API respondendo<br/>/health?"}
    B -->|não| X1["❌ aborta"]
    B -->|sim| C{"SSH do GitHub<br/>autentica?"}
    C -->|não| X2["❌ aborta"]
    C -->|sim| D{"repo atrás de<br/>origin/main?"}
    D -->|sim| X3["❌ aborta: puxe antes"]
    D -->|não| E["./build.sh"]
    E -->|falhou| R["♻️ restaura output/<br/>publicado"]
    E -->|ok| F{"diff só no<br/>lastBuildDate?"}
    F -->|sim| G["reverte · sem commit"]
    F -->|não| H["commit só de output/<br/>+ push → Amplify"]

    style X1 fill:#4a2020,stroke:#c45b5b,color:#fff
    style X2 fill:#4a2020,stroke:#c45b5b,color:#fff
    style X3 fill:#4a2020,stroke:#c45b5b,color:#fff
    style H fill:#1f4030,stroke:#4caf7d,color:#fff

Note o ♻️ restaura output/. O build.sh apaga o output/ antes de validar se a API está no ar. Se ele falhasse no meio, eu ficava sem site publicado. Em vez de reordenar o build, o publish.sh guarda uma cópia e restaura em caso de falha. É um remendo, e está documentado como tal.

9. O agente que nunca funcionou (e eu só descobri ao desligar)

Aqui está a parte que me deixou desconfortável e que talvez seja a mais útil do post.

Eu tinha dois agentes launchd: o worker de scraping e um publicador diário às 08:00. Ao pausar, fui conferir os logs. O do worker estava saudável, com jobs rodando, next_run_at calculado, tudo certo. O do publicador tinha 60 linhas. As 60 eram a mesma coisa:

job-working-directory: error retrieving current directory: getcwd:
cannot access parent directories: Operation not permitted
/bin/bash: .../noticias-mobile-site/publish.sh: Operation not permitted

O agente de publicação nunca executou com sucesso. Nenhuma vez.

A causa: o projeto mora num volume externo (/Volumes/MLI-MK45/...), e processos lançados pelo launchd não herdam a permissão de Full Disk Access que o Terminal tem. Rodando ./publish.sh na mão, funciona perfeitamente. Rodando pelo agendador, o macOS bloqueia o acesso ao volume antes do script começar.

E por que eu não percebi durante meses? Porque eu publicava na mão de qualquer jeito. O histórico do Git mostra: noticias 18.08, noticias 20.08, noticias 24.08, todos commits manuais. O site estava sempre atualizado. A automação estava morta e o resultado que ela deveria produzir aparecia mesmo assim, produzido por mim. O sintoma que deveria denunciar a falha estava sendo mascarado pelo trabalho manual que a automação deveria substituir.

O publish.sh até mandava notificação do macOS em caso de falha, mas a falha acontecia antes do script rodar, então não havia nada para notificar. O monitoramento estava dentro daquilo que ele deveria monitorar.

Três lições, e a terceira é a que vou levar para o trabalho:

  1. Automação em volume externo no macOS exige Full Disk Access explícito para o binário que o

launchd invoca, e falha silenciosamente sem isso.

  1. Alarme que mora dentro do processo vigiado não é alarme. Ele precisa ser externo: um

heartbeat que grita quando para de chegar. O worker de scraping tinha isso e por isso eu sabia que ele estava vivo. O publicador não tinha, e por isso eu não sabia que estava morto.

  1. Fazer na mão o que a automação deveria fazer esconde a automação quebrada. Se eu tivesse me

forçado a não publicar manualmente, teria descoberto no primeiro dia.

10. Placar honesto das decisões

Um ano depois, o que envelheceu bem e o que não:

DecisãoVereditoPor quê
LLM local (Ollama) em vez de API paga✅ AcerteiCusto marginal zero, 509 artigos processados, sem fatura
Pipeline como máquina de estados persistida✅ AcerteiSobreviveu a reinício, sono do Mac e falha de modelo sem perder trabalho
Parar em ready e exigir clique humano✅ AcerteiNunca publiquei tradução constrangedora, porque nada publicava sozinho
Apps consumindo RSS estático✅ AcerteiCusto zero e os apps seguem vivos com a coleta pausada
Deduplicação via constraint + HTTP 409✅ AcerteiDiscovery ficou idempotente sem estado de "até onde li"
Worker falando HTTP em vez de tocar o banco✅ AcerteiRegra de negócio num lugar só, apesar de mais lento
Server-Driven UI completo⚠️ Caro demaisContrato para N clientes, um cliente construído
Projeto em volume externo❌ ErreiQuebrou a automação de forma silenciosa por meses
Agendar no meu Mac em vez de CI⚠️ InevitávelA API só existe na LAN; sem alternativa real, mas frágil
build.sh apagar output/ antes de validar❌ ErreiRemendado no publish.sh em vez de corrigido na origem

O padrão que emerge: acertei nas decisões de arquitetura e errei nas de operação. O desenho do sistema aguentou bem. O que quebrou foi tudo aquilo que estava em volta: permissão de sistema operacional, onde o código mora, quem observa o observador.

Isso bate com a minha experiência em contexto enterprise, aliás. Raramente é o diagrama que falha.

11. Como se pausa um sistema desses

Para o registro, e para o meu eu futuro que vai querer religar:

# Reversível: desabilita e descarrega, mas mantém os plists
launchctl disable gui/$(id -u)/com.noticias.scraping
launchctl bootout  gui/$(id -u)/com.noticias.scraping

launchctl disable gui/$(id -u)/br.com.noticiasmobile.publish
launchctl bootout  gui/$(id -u)/br.com.noticiasmobile.publish

O disable persiste entre reboots (é o que impede o launchd de recarregar o plist no próximo login); o bootout manda SIGTERM e descarrega agora. O worker trata SIGTERM, então o log fecha limpo:

2026-08-25T00:10:34 [info] shutdown_signal  signum=15
2026-08-25T00:10:34 [info] worker_stop

Ele parou entre itens, não no meio de um. A máquina de estados garante que a fila está num ponto consistente: cada item está em algum estado nomeado, e religar é só voltar a consumir de onde parou.

Para religar, é o inverso (launchctl enable e bootstrap).

O que continua no ar, sem nenhum custo: o site, os feeds e os dois apps, iOS e Android, funcionando normalmente com todo o acervo publicado. O que dorme: a coleta, a tradução e o painel.

12. O que esse experimento me deixou

Uma coisa que quero deixar clara, porque é fácil ler "desliguei" e entender "acabou": o Notícias Mobile sempre foi um experimento. Não nasceu como produto para escalar, nasceu como uma pergunta técnica com um portal de verdade em volta dela.

E como todo experimento honesto, ele respondeu mais do que eu perguntei. A pergunta era sobre arquitetura e custo. O que eu ganhei junto foi um ano de observação de perto sobre como conteúdo técnico é consumido nesse nicho: quais assuntos as pessoas realmente abrem, com que frequência elas voltam, qual formato faz alguém ler até o fim, o que gera notificação aberta e o que gera notificação ignorada, e a distância entre "publiquei muito" e "publiquei o que importava". Isso não estava no plano, veio de graça por ter mantido a coisa rodando com números reais na frente.

Essas leituras me deram ideias novas, e elas não voltam para o Notícias Mobile. Elas vão para um projeto meu que já está no ar e que deve receber a maior parte da minha atenção nos próximos meses. É por isso que a coleta está pausada e não porque o portal deu errado: a atenção é o recurso mais escasso que eu tenho, e ela mudou de lugar.

Sem spoilers por enquanto. Quando estiver pronto para mostrar, mostro aqui.

Fechamento

Construí este sistema para responder a uma pergunta que me interessava: até onde dá para automatizar um portal de conteúdo sem pagar mensalidade a ninguém? A resposta acabou sendo "quase tudo, menos o julgamento editorial", e "menos o julgamento editorial" não é uma limitação técnica que vai cair com o próximo modelo. É onde eu prefiro que a fronteira fique.

A coleta fica pausada, o site e os apps continuam no ar, e eu fico com duas coisas: o aprendizado sobre consumo de conteúdo que já está virando decisão em outro projeto, e um achado técnico que me pegou de surpresa. O componente que falhou não foi o LLM local, nem a máquina de estados, nem o contrato de widgets. Foi um chmod metafórico numa pasta de volume externo, silencioso por meses, mascarado por mim mesmo fazendo na mão o trabalho que eu tinha automatizado.

Se tem uma coisa que este projeto me ensinou e que eu levo para squads enterprise, é essa: a pergunta não é "isso funciona?", é "como eu vou saber quando parar de funcionar?". Eu tinha resposta para a primeira em todos os componentes. Só tinha resposta para a segunda em um deles.

Referências

  • WingedSwift: DSL Swift para HTML, usada para gerar o site estático do portal (e deste site também)
  • Vapor 4: framework Swift do lado servidor
  • Ollama: runtime de LLM local
  • uv: gerenciador de projeto Python usado no worker
  • Notícias Mobile: o portal, ainda no ar