Como funciona o crawler de imóveis

Mapa técnico do fluxo atual do Imóvel Central: agendamento, scrapers, autenticação, ingestão no Laravel, upsert no banco, atualização e desativação de anúncios obsoletos.

Resumo executivo

5
imobiliárias registradas
01:00–06:00
janela diária em America/Sao_Paulo
7 dias
padrão de obsolescência
60 s
timeout de cada chamada Python
3600 s
timeout da execução síncrona
Git: após git fetch origin main, os dois repositórios estão alinhados com origin/main (0 à frente/atrás). Os não rastreados existentes imovel-central-docker/.ai/ e crawler-imoveis/.envrc foram preservados.
Duração: não existe duração fixa por imobiliária. O código define horários, timeouts e withoutOverlapping(180); a duração depende de páginas, volume, Selenium, site externo e rede. Os 180 minutos são a janela de bloqueio contra sobreposição, não uma duração garantida.

Fluxo completo atual

Laravel Schedulerschedule:work crawler:runcompany + mode Runner Python :8080POST /run, async padrão Scraper da imobiliáriaSelenium ou requests POST /api/v1/crawler/propertiesBearer token • 1 imóvel Middleware de tokenBearer ou X-Crawler-Token Validação + upsertcompany, endereço, enums Banco propertiesativo + last_synced_at Update noturnore-sincroniza registrados Prune staleactive=false após N dias Frontend / buscasativos por padrão O fluxo de lote legado entra por /sync/properties e usa filas de 200.

O scheduler Laravel dispara o comando; por padrão chama o runner HTTP do container crawler. O runner inicia o CLI em uma thread quando assíncrono e devolve 202 Accepted. Cada scraper coleta e envia cada imóvel individualmente; o backend grava properties, imagens e endereço.

Quando roda

Imobiliária / chaveDiscoverUpdate
Carol Gianazi — carol-gianazi01:0003:30
Fadel — fadel01:3004:00
Lúzia Imóveis — luzia02:0004:30
Village Imobiliária — village02:3005:00
Silvana Lopes — silvana-lopes03:0005:30
crawler:prune-stale06:00

Fonte: routes/console.php:L25-L65 · config/crawler.php:L15-L43

O que cada scraper faz

ChaveFonteDiscoverUpdateDetalhes
carol-gianazicarolgianazi.com.brSelenium, paginação startURLs ativos do backendEspera de 1–3 s.
fadelimobiliariafadel.com.brSelenium, páginas numeradasURLs ativos do backendEspera de ~2 s.
luzialuziaimoveis.com.brSelenium, páginas numeradasURLs ativos do backendEspera de 1–3 s.
villagevillageimobiliaria.imb.brAPI /api/listings?pagina=NVarre API até IDs registradosTimeout 20 s/página; filtra imob_property.
silvana-lopessilvanalopesimobiliaria.com.brPOST para API externaCompara IDs registradosTimeout externo 60 s; paginação própria.

discover coleta o catálogo e chama sync_property() por imóvel. update consulta GET /api/v1/crawler/properties paginado (até 100 por página), filtrando ativos e imobiliária, e só então re-coleta os registrados.

registry · BaseScraper · cliente/paginação

Chaves, autenticação e payload

Segredo

CRAWLER_API_TOKEN é igual no backend e Python. Envio: Authorization: Bearer <token>; também aceita X-Crawler-Token.

Endereços

Docker: http://backend:9000/api/v1/crawler/properties; runner: http://crawler:8080; standalone: http://localhost:9000/api/v1/crawler/properties.

Modos

discover/full varrem o site; update reprocessa cadastrados.

Formato enviado por imóvel

{
  "external_id": "ID da imobiliária", "external_url": "https://...",
  "real_estate_company_name": "Nome", "description": "...", "price": 0,
  "property_type": "apartment", "business_type": "sell",
  "latitude": -23.0, "longitude": -46.0,
  "count_suites": 1, "count_rooms": 2, "count_bedrooms": 2,
  "count_bathrooms": 2, "count_kitchens": 1, "count_parking_spaces": 1,
  "images": ["https://..."],
  "address": {"state":"SP","city":"...","neighborhood":"...","street":"...",
              "number":10,"address":"...","complement":"..."}
}

A validação exige ID, URL, imobiliária, tipo de propriedade, tipo de negócio e estado/cidade/bairro. Preço, coordenadas, contagens, imagens e detalhes de endereço podem ser nulos; imagens precisam ser URLs.

validação · serialização · token

O que acontece com cada imóvel

  1. Resolve estado por nome/sigla; cidade por nome ou correspondência textual próxima; bairro com a mesma lógica.
  2. Resolve a imobiliária por mapas de nomes/chaves, por exemplo carol-gianazi → Gianazi, luzia → Lúzia Imóveis, village → Village.
  3. Identifica por real_estate_company_id + external_id via updateOrCreate; novo recebe ID, existente é atualizado.
  4. Monta slug com tipo, bairro, cidade, imobiliária e ID externo; garante unicidade com -1, -2 etc.
  5. Cada sync força active=true e last_synced_at=now(); preço, descrição, tipo, negócio, coordenadas e contagens vêm do payload.
  6. Imagens externas entram com firstOrCreate; endereço é atualizado na relação.
  7. Às 06:00, externo ativo com last_synced_at nulo ou anterior ao limite vira active=false; não é deletado.
Consequência: desaparecimento no site externo não apaga imediatamente o imóvel; após o prune ele deixa de aparecer nas consultas padrão de ativos, mantendo histórico.

upsert · prune · comando

Como a execução é disparada

ConfiguraçãoPadrãoEfeito
CRAWLER_RUNNER_DRIVERhttpLaravel faz POST no runner Python.
CRAWLER_RUNNER_URLhttp://crawler:8080Container/porta do runner.
CRAWLER_RUNNER_TIMEOUT3600Timeout da chamada síncrona; agenda normal é async.
--syncdesligadoSem flag, Laravel recebe aceite e logs ficam no crawler; com flag, aguarda.
POST /runasync=trueResponde 202 e roda CLI em thread; false espera subprocesso e retorna stdout/stderr.

O container crawler usa restart: unless-stopped, monta backend/crawler:/app e Chromium em /usr/bin/chromium. O scheduler usa php artisan schedule:work. Em produção, limites declarados: crawler 1 CPU/1536 MB; scheduler 0,20 CPU/256 MB.

comando Laravel · runner · Docker

Double check: Meilisearch e indexação

Resultado local verificado: o Laravel em execução está com scout_driver=meilisearch, scout_queue=false, queue_default=redis e host http://meilisearch:7700. O endpoint de saúde respondeu disponível, o índice properties existe e contém 8.966 documentos, exatamente a mesma quantidade de registros em properties no MySQL.
ParteComo estáImpacto
ModeloProperty usa Laravel\Scout\Searchable e indexa id, price, endereço textual e description.O texto da busca pode vir do Meilisearch.
ConsultaProperty::search($term)->keys() produz IDs; depois o MySQL aplica active=true e filtros de negócio, tipo, preço, quartos etc.Filtros continuam no banco; a ordenação final também é do MySQL.
Índice atualproperties, primary key id, 8.966 documentos, sem atributos filterable/sortable configurados.Está adequado ao padrão atual de buscar IDs e filtrar no MySQL, mas não para filtros nativos no Meili.
Ambiente localSCOUT_QUEUE=false.A sincronização acontece na própria operação de criação/alteração do imóvel.
Produçãodocker-compose.prod.yml força SCOUT_QUEUE=true e usa fallback SCOUT_DRIVER=collection se a variável não vier do ambiente.Sem SCOUT_DRIVER=meilisearch efetivo, produção não usa Meili; com fila, o Horizon precisa estar saudável.

Pontos de atenção encontrados

  1. Endereço/imagens não reindexam o imóvel sozinhos: no crawler, Property::updateOrCreate() acontece antes de PropertyAddress::updateOrCreate() e das imagens. Como PropertyAddress e PropertyImage não usam Searchable, uma mudança apenas no endereço pode não atualizar o documento do Meili.
  2. Prune em massa não dispara evento do modelo: ->update(['active' => false]) é update de query. O campo active nem está no documento indexado, então a busca continua correta porque o MySQL filtra ativos, mas ficam documentos inativos no índice.
  3. Configuração do índice é genérica: searchableAttributes=['*'], sem filterableAttributes e sortableAttributes. Isso é coerente com a implementação atual, mas impede aproveitar filtros/sort diretamente no Meili.
  4. Dados atuais não estão recentes no ambiente local: o índice e o banco têm atualização máxima em 2026-07-09. Isso prova consistência de contagem, não atualização contínua; os containers crawler e scheduler não estavam em execução neste compose local.
Conclusão: a indexação está operacional e consistente no snapshot local, mas eu não classificaria o desenho como totalmente robusto para produção sem garantir SCOUT_DRIVER=meilisearch e reindexar o imóvel depois de atualizar endereço/imagens (idealmente em uma transação/serviço único ou por observers/jobs específicos).

Property/Searchable · config Scout · config produção · ordem do upsert do crawler

Standalone e fluxo legado

crawler-imoveis/ contém notebooks Jupyter, modelos e cliente de API. O código integrado em imovel-central-docker/backend/crawler/ é a versão operacional do Docker; o standalone é execução manual/experimental.

Atenção: existem migrations antigas para scraped_properties, mas o serviço atual grava em properties. As tabelas scraped_* não são o destino do fluxo atual mostrado.

README · compose · job de lote

Operação e diagnóstico

# listar chaves
docker compose exec backend php artisan crawler:run --list

# descoberta manual
php artisan crawler:run --company=village --mode=discover

# atualizar cadastrados
php artisan crawler:run --company=fadel --mode=update

# aguardar término
php artisan crawler:run --company=luzia --mode=discover --sync

# simular prune
php artisan crawler:prune-stale --dry-run

# logs
docker compose logs -f crawler scheduler

Token vazio → 503; token incorreto → 401. Imobiliária/endereço não resolvido → falha de validação. Item sem ID/URL → skipped.

Fontes consultadas

Orquestração

routes/console.php · RunCrawlerCommand.php · config/crawler.php · runner_server.py

Scrapers

cli.py · api_client.py · scrapers/ · property.py

API e persistência

api.php · token · validação · sync service · prune

Standalone / lote

README · api_client.py · storage.py · ProcessCrawlerDataJob.php · ProcessCrawlerChunkJob.php