Hoje eu rodei dois comandos:
launchctl disable gui/501/com.noticias.scraping
launchctl bootout gui/501/com.noticias.scrapingE 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 descartada | Por quê |
|---|---|
| OpenAI / Anthropic API para resumo e tradução | Custo 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 paga | Idem |
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:#fffTrê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 noteO 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:#fffO 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 progressoTrê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-logsalé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:
sourceType | Como a API resolve |
|---|---|
manual | Artigos fixados à mão, ignorando pins expirados |
category / tag / author | Artigos publicados do agrupamento, com limitCount |
recent | publishedAt DESC |
popular | viewCount DESC |
trending | Popular nos últimos 30 dias, com fallback para popular |
static | Sem 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:#fffEsse 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:#fffCada 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:#fffTrê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:#fffNote 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 permittedO 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:
- 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.
- 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.
- 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ão | Veredito | Por quê |
|---|---|---|
| LLM local (Ollama) em vez de API paga | ✅ Acertei | Custo marginal zero, 509 artigos processados, sem fatura |
| Pipeline como máquina de estados persistida | ✅ Acertei | Sobreviveu a reinício, sono do Mac e falha de modelo sem perder trabalho |
Parar em ready e exigir clique humano | ✅ Acertei | Nunca publiquei tradução constrangedora, porque nada publicava sozinho |
| Apps consumindo RSS estático | ✅ Acertei | Custo zero e os apps seguem vivos com a coleta pausada |
| Deduplicação via constraint + HTTP 409 | ✅ Acertei | Discovery ficou idempotente sem estado de "até onde li" |
| Worker falando HTTP em vez de tocar o banco | ✅ Acertei | Regra de negócio num lugar só, apesar de mais lento |
| Server-Driven UI completo | ⚠️ Caro demais | Contrato para N clientes, um cliente construído |
| Projeto em volume externo | ❌ Errei | Quebrou a automação de forma silenciosa por meses |
| Agendar no meu Mac em vez de CI | ⚠️ Inevitável | A API só existe na LAN; sem alternativa real, mas frágil |
build.sh apagar output/ antes de validar | ❌ Errei | Remendado 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.publishO 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_stopEle 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

