Skip to content

Commit 3d34d93

Browse files
committed
docs: document scraper architecture and add chat timing logs
1 parent 49ee134 commit 3d34d93

2 files changed

Lines changed: 369 additions & 1 deletion

File tree

CONTRIBUTING.md

Lines changed: 356 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Obrigado por querer contribuir! O Ifinho é o assistente virtual do IFRS Campus
1515
- [Fluxo de contribuição](#fluxo-de-contribuição)
1616
- [Padrão de commits](#padrão-de-commits)
1717
- [Estrutura do projeto](#estrutura-do-projeto)
18+
- [Criando um novo scraper](#criando-um-novo-scraper)
1819

1920
---
2021

@@ -219,9 +220,11 @@ Use o padrão [Conventional Commits](https://www.conventionalcommits.org/pt-br/v
219220
ifinho/
220221
├── apps/
221222
│ ├── web/ # Frontend (React + React Router + TailwindCSS + shadcn/ui)
222-
│ └── server/ # Backend (Express + Ollama)
223+
│ ├── server/ # Backend (Express + Ollama)
224+
│ └── worker/ # Worker de scraping (pg-boss + pipeline)
223225
├── packages/
224226
│ ├── db/ # Schema e queries do banco (Drizzle + PostgreSQL)
227+
│ ├── scraper/ # Engine de scraping (plugins + pipeline)
225228
│ ├── env/ # Validação de variáveis de ambiente (Zod)
226229
│ ├── http/ # Cliente HTTP compartilhado
227230
│ └── config/ # Configurações compartilhadas (TypeScript, Biome)
@@ -231,6 +234,358 @@ ifinho/
231234

232235
---
233236

237+
## Criando um novo scraper
238+
239+
Uma das formas mais valiosas de contribuir é adicionar scrapers para novas fontes de conteúdo do IFRS Canoas (editais, calendários, PDFs, etc).
240+
241+
### Como funciona a arquitetura
242+
243+
```
244+
scrape_configs (banco)
245+
246+
247+
Scheduler (worker) — verifica configs vencidas a cada 15 min
248+
249+
250+
ScraperRunner
251+
252+
├── Plugin (seu scraper) → AsyncGenerator<ScrapeResult>
253+
254+
└── Pipeline (fixo, igual para todos)
255+
├── SanitizeStep — normaliza o texto
256+
├── HashCheckStep — ignora conteúdo que não mudou
257+
├── PersistStep — salva no banco (sources + documents)
258+
└── EmbedStep — gera embedding vetorial para busca semântica
259+
```
260+
261+
O plugin é responsável apenas por **buscar e parsear** o conteúdo. O pipeline cuida do resto automaticamente.
262+
263+
---
264+
265+
### Passo 1 — Entender a anatomia de um plugin
266+
267+
Um plugin vive em `packages/scraper/src/plugins/<nome>/` e tem sempre a mesma estrutura:
268+
269+
```
270+
plugins/
271+
└── news/ ← nome do plugin (mesmo valor que Scraper.id)
272+
├── index.ts ← classe principal — orquestra o scraping
273+
└── pages/
274+
├── list.ts ← Page Object da página de listagem
275+
└── detail.ts ← Page Object da página de detalhe
276+
```
277+
278+
#### `index.ts` — a classe principal
279+
280+
É a única parte obrigatória. Implementa a interface `Scraper`:
281+
282+
```typescript
283+
export interface Scraper {
284+
readonly id: string; // ex: "news", "edital"
285+
run(request: ScrapeRequest): AsyncGenerator<ScrapeResult>;
286+
}
287+
```
288+
289+
O método `run` é um **async generator**: ele não retorna uma lista, mas vai `yield`ando cada item conforme os processa. Isso permite que o pipeline comece a persistir resultados antes do scraping terminar.
290+
291+
```typescript
292+
// Fluxo típico do index.ts
293+
async *run(request: ScrapeRequest): AsyncGenerator<ScrapeResult> {
294+
// 1. Busca a página de listagem
295+
const html = await this.fetcher.get(request.startUrl);
296+
297+
// 2. Usa o Page Object da lista para extrair links e navegar páginas
298+
const listPage = new NewsListPage(cheerio.load(html));
299+
const items = listPage.extractItems();
300+
301+
// 3. Para cada item, busca e extrai o conteúdo da página de detalhe
302+
for (const item of items) {
303+
const detailHtml = await this.fetcher.get(item.url);
304+
const detailPage = new NewsDetailPage(cheerio.load(detailHtml));
305+
306+
// 4. Yield do resultado — o pipeline recebe e processa imediatamente
307+
yield {
308+
url: item.url,
309+
title: detailPage.extractTitle(),
310+
rawText: detailPage.extractContent(),
311+
contentHash: crypto.createHash("md5").update(rawText).digest("hex"),
312+
category: "noticia",
313+
sourceType: "webpage",
314+
};
315+
}
316+
}
317+
```
318+
319+
**O `Fetcher`** é injetado via construtor e deve ser sempre usado no lugar de `fetch`/`axios` diretamente. Ele aplica rate limiting automático (delay configurável entre requisições) e define o User-Agent do bot:
320+
321+
```typescript
322+
constructor(private fetcher: Fetcher) {}
323+
324+
// ✅ correto
325+
const html = await this.fetcher.get(url);
326+
327+
// ❌ nunca faça isso dentro de um plugin
328+
const html = await fetch(url).then(r => r.text());
329+
```
330+
331+
---
332+
333+
#### `pages/list.ts` — Page Object da listagem
334+
335+
Responsável por parsear a página que lista os itens (ex: página de notícias com cards). Não faz requisições — recebe o `CheerioAPI` já carregado do `index.ts`.
336+
337+
Deve expor dois métodos:
338+
- `extractItems()` → retorna os links e metadados básicos dos cards
339+
- `nextPageUrl()` → retorna a URL da próxima página, ou `null` se não houver
340+
341+
```typescript
342+
export class NewsListPage {
343+
constructor(private $: CheerioAPI) {}
344+
345+
extractItems(): NewsListItem[] {
346+
const items: NewsListItem[] = [];
347+
348+
this.$("article.noticia").each((_, el) => {
349+
const url = this.$(el).find("a.noticia__link").attr("href") ?? "";
350+
if (!url) return;
351+
352+
items.push({
353+
url,
354+
title: this.$(el).find("h2.noticia__titulo").text().trim(),
355+
publishedAt: parsePtDate(this.$(el).find("span.noticia__data").text()),
356+
});
357+
});
358+
359+
return items;
360+
}
361+
362+
nextPageUrl(): string | null {
363+
return this.$("a.next.page-link").attr("href") ?? null;
364+
}
365+
}
366+
```
367+
368+
> Os **seletores CSS** (`article.noticia`, `a.noticia__link`, etc.) são específicos de cada site. Inspecione o HTML da página alvo com o DevTools do navegador para descobrir os corretos.
369+
370+
---
371+
372+
#### `pages/detail.ts` — Page Object do detalhe
373+
374+
Responsável por parsear a página individual de cada item. Também recebe o `CheerioAPI` pronto.
375+
376+
Deve expor os métodos necessários para extrair os dados do `ScrapeResult`:
377+
378+
```typescript
379+
export class NewsDetailPage {
380+
constructor(private $: CheerioAPI) {}
381+
382+
extractTitle(): string {
383+
return this.$("h2.post__title").first().text().trim();
384+
}
385+
386+
extractDate(): Date | undefined {
387+
const content = this.$('meta[property="article:published_time"]').attr("content");
388+
return content ? new Date(content) : undefined;
389+
}
390+
391+
extractContent(): string {
392+
const $content = this.$("div.post__content").first().clone();
393+
// Remove elementos que poluem o texto (scripts, iframes, widgets)
394+
$content.find("script, iframe, style, .ultimos-posts, figcaption").remove();
395+
return $content.text().replace(/\s+/g, " ").trim();
396+
}
397+
}
398+
```
399+
400+
> Sempre use `.clone()` antes de remover elementos para não mutar o DOM original. O `replace(/\s+/g, " ").trim()` é importante para normalizar espaços e quebras de linha do HTML.
401+
402+
---
403+
404+
#### Quando usar pages/ e quando não usar
405+
406+
As classes `pages/` são uma convenção para manter o código organizado, não uma obrigação técnica.
407+
408+
| Situação | Recomendação |
409+
|----------|-------------|
410+
| Site com listagem + página de detalhe | Use `list.ts` + `detail.ts` |
411+
| Página única com todo o conteúdo | Pode parsear direto no `index.ts` |
412+
| Muitas variações de página | Crie arquivos separados por tipo |
413+
| Plugin muito simples (1 página, 1 item) | Tudo no `index.ts` é ok |
414+
415+
---
416+
417+
#### O `ScrapeResult` — o que cada campo significa
418+
419+
```typescript
420+
export interface ScrapeResult {
421+
url: string;
422+
// URL canônica e permanente do conteúdo. Usada como chave de deduplicação
423+
// no banco — dois itens com a mesma URL são tratados como o mesmo documento.
424+
425+
title: string;
426+
// Título legível do documento. Aparece nas respostas do chat como referência.
427+
428+
rawText: string;
429+
// Texto puro extraído, sem nenhuma tag HTML.
430+
// É esse texto que será dividido em chunks e indexado vetorialmente.
431+
// Quanto mais limpo e relevante, melhor a qualidade das respostas do Ifinho.
432+
433+
contentHash: string;
434+
// MD5 do rawText. O HashCheckStep compara com o hash salvo anteriormente —
435+
// se for igual, o item é descartado e não reprocessado.
436+
// Sempre gere assim: crypto.createHash("md5").update(rawText).digest("hex")
437+
438+
category: SourceCategory;
439+
// Classificação do conteúdo. Valores disponíveis definidos no enum do banco.
440+
// Ex: "noticia", "edital", "documento"
441+
442+
sourceType: SourceType;
443+
// Tipo da fonte. Ex: "webpage" para páginas HTML, "pdf" para arquivos PDF.
444+
445+
publishedAt?: Date;
446+
// Data de publicação, quando disponível. Opcional.
447+
448+
metadata?: Record<string, unknown>;
449+
// Dados extras que não cabem nos campos acima. Opcional.
450+
}
451+
```
452+
453+
---
454+
455+
### Passo 2 — Criar o plugin
456+
457+
Com a estrutura clara, crie os arquivos:
458+
459+
```
460+
packages/scraper/src/plugins/edital/
461+
├── index.ts
462+
└── pages/
463+
├── list.ts
464+
└── detail.ts
465+
```
466+
467+
Exemplo de `index.ts` completo:
468+
469+
```typescript
470+
import crypto from "node:crypto";
471+
import * as cheerio from "cheerio";
472+
import type { Fetcher } from "../../core/fetcher.js";
473+
import type { ScrapeRequest, ScrapeResult, Scraper } from "../../core/types.js";
474+
import { EditalListPage } from "./pages/list.js";
475+
import { EditalDetailPage } from "./pages/detail.js";
476+
477+
export class EditalScraper implements Scraper {
478+
readonly id = "edital";
479+
480+
constructor(private fetcher: Fetcher) {}
481+
482+
async *run(request: ScrapeRequest): AsyncGenerator<ScrapeResult> {
483+
const { startUrl, options } = request;
484+
const maxPages = options.maxPages ?? 3;
485+
486+
let listUrl: string | null = startUrl;
487+
let page = 1;
488+
489+
while (listUrl !== null && page <= maxPages) {
490+
const html = await this.fetcher.get(listUrl);
491+
const $ = cheerio.load(html);
492+
const listPage = new EditalListPage($);
493+
494+
for (const item of listPage.extractItems()) {
495+
try {
496+
const detailHtml = await this.fetcher.get(item.url);
497+
const detailPage = new EditalDetailPage(cheerio.load(detailHtml));
498+
499+
const rawText = detailPage.extractContent();
500+
if (!rawText) continue;
501+
502+
yield {
503+
url: item.url,
504+
title: detailPage.extractTitle() || item.title,
505+
rawText,
506+
contentHash: crypto.createHash("md5").update(rawText).digest("hex"),
507+
category: "edital",
508+
sourceType: "webpage",
509+
};
510+
} catch (err) {
511+
console.error(`[EditalScraper] Failed to scrape ${item.url}:`, err);
512+
}
513+
}
514+
515+
listUrl = listPage.nextPageUrl();
516+
page++;
517+
}
518+
}
519+
}
520+
```
521+
522+
> Use `this.fetcher.get(url)` em vez de `fetch` diretamente — o `Fetcher` aplica delay entre requisições para não sobrecarregar o servidor.
523+
524+
---
525+
526+
### Passo 3 — Registrar o plugin no worker
527+
528+
Abra `apps/worker/src/index.ts` e adicione o novo plugin ao `Map`:
529+
530+
```typescript
531+
// antes
532+
const plugins = new Map([
533+
["news", new NewsScraper(new Fetcher({ delayMs: 1500 }))],
534+
]);
535+
536+
// depois
537+
const plugins = new Map([
538+
["news", new NewsScraper(new Fetcher({ delayMs: 1500 }))],
539+
["edital", new EditalScraper(new Fetcher({ delayMs: 1500 }))],
540+
]);
541+
```
542+
543+
---
544+
545+
### Passo 4 — Criar o `scrape_config` no banco
546+
547+
Com o worker rodando, insira uma configuração para o novo scraper:
548+
549+
```sql
550+
INSERT INTO scrape_configs (id, name, plugin_id, category, base_url, options, priority, check_interval_minutes, enabled)
551+
VALUES (
552+
gen_random_uuid(),
553+
'IFRS Canoas — Editais', -- nome legível
554+
'edital', -- deve bater com Scraper.id
555+
'edital', -- categoria (enum: noticia, edital, documento, ...)
556+
'https://ifrs.edu.br/canoas/editais/',
557+
'{"maxPages": 3}', -- opções passadas para ScrapeRequest.options
558+
5, -- prioridade (1 = mais alta)
559+
1440, -- intervalo em minutos (1440 = 24h)
560+
true
561+
);
562+
```
563+
564+
O worker vai detectar a nova config no próximo ciclo (até 15 min) e iniciar o scraping automaticamente.
565+
566+
---
567+
568+
### Passo 5 — Exportar o plugin do pacote
569+
570+
Abra `packages/scraper/src/index.ts` e adicione a exportação:
571+
572+
```typescript
573+
export { EditalScraper } from "./plugins/edital/index.js";
574+
```
575+
576+
---
577+
578+
### Dicas de implementação
579+
580+
- **Use `cheerio`** para parsear HTML — já é dependência do pacote
581+
- **Inspecione o HTML** da página alvo com DevTools antes de escrever os seletores
582+
- **Separe list page e detail page** em classes distintas quando a fonte tiver paginação (veja `plugins/news/pages/` como referência)
583+
- **Retorne `null` / pule** itens sem conteúdo relevante — o pipeline ignora automaticamente
584+
- **O `contentHash`** deve ser MD5 do `rawText` — o `HashCheckStep` usa isso para evitar reprocessar conteúdo que não mudou
585+
- **Não se preocupe** com persistência, embeddings ou deduplicação — o pipeline cuida de tudo
586+
587+
---
588+
234589
## Dúvidas?
235590

236591
Abra uma issue com a label `question`.

0 commit comments

Comments
 (0)