From 6d64eff90898b5e461b445cf8176bd55e8440b0e Mon Sep 17 00:00:00 2001 From: DanielRogs Date: Wed, 19 Nov 2025 00:52:11 -0300 Subject: [PATCH] feat: cria spider e scraper para o portal Imirante --- Makefile | 8 ++ README.md | 46 ++++++++-- plays/__init__.py | 2 + plays/imirante.py | 204 ++++++++++++++++++++++++++++++++++++++++++++ spiders/__init__.py | 2 + spiders/imirante.py | 149 ++++++++++++++++++++++++++++++++ 6 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 plays/imirante.py create mode 100644 spiders/imirante.py diff --git a/Makefile b/Makefile index a7f7ce2..f899524 100644 --- a/Makefile +++ b/Makefile @@ -91,6 +91,9 @@ scrape_folha: scrape_jornaldaparaiba: docker compose exec scraper python scrape_no_openai.py --platform jornaldaparaiba.com.br +scrape_imirante: + docker compose exec scraper python scrape_no_openai.py --platform imirante.com + # Crawler para todos os portais ou específicos crawl: @@ -124,6 +127,9 @@ crawl_folha: crawl_jornaldaparaiba: docker compose run scraper python crawl.py jornaldaparaiba +crawl_imirante: + docker compose run scraper python crawl.py imirantespider + # Workflow completo de coleta de URLs crawl_all_working: @echo "Executando crawl de todos os portais funcionais..." @@ -136,6 +142,7 @@ crawl_all_working: @make crawl_ig @make crawl_folha @make crawl_jornaldaparaiba + @make crawl_imirante @echo "Crawl de todos os portais concluído!" # Workflow completo de scraping @@ -150,6 +157,7 @@ scrape_all_working: @make scrape_uol @make scrape_folha @make scrape_jornaldaparaiba + @make scrape_imirante @echo "Scraping de todos os portais concluído!" # Pipeline completo: crawl + scrape diff --git a/README.md b/README.md index 1c50248..aca93d7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Check-up + ![cover](https://raw.githubusercontent.com/aosfatos/check-up/refs/heads/develop/cover.png) O Check-up é um projeto de **extração de conteúdo de notícias** de portais brasileiros para análise de desinformação. Este repositório contém ferramentas para coletar e extrair o texto completo de notícias de diversos portais de notícia do Brasil. @@ -15,6 +16,7 @@ Este projeto foi adaptado a partir de uma ferramenta original do [**Aos Fatos**] ### 🏗️ Arquitetura A ferramenta possui dois módulos principais: + - **Crawler (Spiders)**: Coleta URLs de notícias das páginas iniciais dos portais - **Extrator (Plays)**: Acessa cada notícia e extrai o conteúdo completo @@ -25,16 +27,20 @@ O código pode ser facilmente adaptado para novos portais de notícia, seguindo ## 🚀 Como Rodar o Projeto ### 📋 Pré-requisitos + - Docker e Docker Compose instalados - Make (disponível por padrão no macOS e Linux) ### 🔧 Configuração Inicial **1. Setup completo do ambiente:** + ```bash make setup ``` + Este comando faz toda a configuração inicial: + - Cria o arquivo `.env` a partir do `env.example` - Inicia os serviços Docker (PostgreSQL, MinIO, scraper) - Aguarda o banco de dados ficar pronto @@ -48,14 +54,16 @@ O processo de extração acontece em duas etapas principais: **🕷️ Etapa 1: Crawling (Coleta de URLs de Notícias)** Colete URLs de notícias de todos os portais funcionais: + ```bash make crawl_all_working ``` Ou execute individualmente: + ```bash make crawl_metropoles # Portal Metrópoles -make crawl_veja # Portal Veja +make crawl_veja # Portal Veja make crawl_r7 # Portal R7 make crawl_uol # Portal UOL make crawl_maisgoias # Portal MaisGoiás @@ -67,11 +75,13 @@ make crawl_folha # Portal Folha **📰 Etapa 2: Scraping (Extração de Conteúdo das Notícias)** Execute a extração de conteúdo de todos os portais: + ```bash make scrape_all_working ``` Ou execute por portal específico: + ```bash make scrape_metropoles # Extração do Metrópoles make scrape_maisgoias # Extração do MaisGoiás @@ -86,6 +96,7 @@ make scrape_folha # Extração da Folha ### 📋 Conteúdo Extraído Para cada notícia, o sistema extrai: + - **Título**: Título principal da notícia - **Descrição**: Subtítulo ou resumo da notícia - **Corpo**: Texto completo da matéria @@ -94,11 +105,13 @@ Para cada notícia, o sistema extrai: ### ⚡ Workflows Automatizados **Pipeline completo (crawl + scraping):** + ```bash make pipeline_complete ``` **Coleta otimizada:** + ```bash make collect_working ``` @@ -106,21 +119,25 @@ make collect_working ### 🛠️ Comandos Úteis **Acessar o container:** + ```bash make bash ``` **Ver logs dos serviços:** + ```bash docker compose logs -f ``` **Parar serviços:** + ```bash make stop ``` **Ver todos os comandos disponíveis:** + ```bash make help ``` @@ -128,13 +145,14 @@ make help ### � Onde os Dados São Salvos - **URLs coletadas**: Salvos no banco PostgreSQL (tabela `URLQueue`) -- **Notícias extraídas**: Salvos no banco PostgreSQL (tabela `Entry`) com título, descrição, corpo e tags +- **Notícias extraídas**: Salvos no banco PostgreSQL (tabela `Entry`) com título, descrição, corpo e tags - **Arquivos temporários**: Salvos no MinIO (acessível em `http://localhost:9001`) - **Logs**: Disponíveis via `docker compose logs` ### � Configurações Importantes **Variáveis de ambiente (arquivo `.env`):** + - Credenciais dos portais (se necessário) - Configurações do banco PostgreSQL - Configurações do MinIO para armazenamento @@ -147,16 +165,19 @@ make help Este projeto coleta dados dos seguintes portais brasileiros: ### ✅ **Portais Funcionais** (Totalmente operacionais) + - **[Metrópoles](https://www.metropoles.com)** - `make crawl_metropoles` / `make scrape_metropoles` -- **[IG](https://www.ig.com.br)** - `make crawl_ig` / `make scrape_ig` +- **[IG](https://www.ig.com.br)** - `make crawl_ig` / `make scrape_ig` - **[MaisGoiás](https://www.maisgoias.com.br)** - `make crawl_maisgoias` / `make scrape_maisgoias` - **[AliadosBrasil](https://www.aliadosbrasiloficial.com.br)** - `make crawl_aliadosBrasil` / `make scrape_aliadosbrasil` - **[Veja](https://veja.abril.com.br)** - `make crawl_veja` / `make scrape_veja` - **[R7](https://www.r7.com)** - `make crawl_r7` / `make scrape_r7` - **[UOL](https://www.uol.com.br)** - `make crawl_uol` / `make scrape_uol` - **[Folha](https://www.folha.uol.com.br)** - `make crawl_folha` / `make scrape_folha` +- **[Imirante](https://imirante.com)** - `make crawl_imirante` / `make scrape_imirante` ### 🔧 **Em Desenvolvimento** + - **[Estadão](https://www.estadao.com.br)** - Necessita ajustes nos seletores CSS - **[Globo](https://oglobo.globo.com/)** - Requer configurações específicas de autenticação - **[RBS](https://www.clicrbs.com.br)** - Spider em desenvolvimento @@ -188,6 +209,7 @@ Quer contribuir para o projeto? Consulte nosso **[Guia de Contribuição](./.git ### Problemas Comuns **1. Erro "Playwright browsers not installed"** + ```bash # Se o IG spider falhar, reconstrua a imagem Docker: docker compose down @@ -196,6 +218,7 @@ make start ``` **2. Container não conecta ao banco** + ```bash # Aguarde o banco ficar pronto: make wait-for-db @@ -204,6 +227,7 @@ make stop && make start ``` **3. MinIO não acessível** + ```bash # Verifique se o MinIO está rodando: docker compose ps @@ -211,6 +235,7 @@ docker compose ps ``` **4. Containers órfãos** + ```bash # Limpe containers órfãos: docker compose down --remove-orphans @@ -220,6 +245,7 @@ make prune ### 💡 Exemplos Práticos **Coleta rápida de um portal específico:** + ```bash # Setup inicial (só uma vez) make setup @@ -230,21 +256,24 @@ make scrape_metropoles ``` **Pipeline completo para produção:** + ```bash # Coleta de todos os portais funcionais make pipeline_complete ``` **Monitoramento em tempo real:** + ```bash # Terminal 1 - Execute a coleta make crawl_all_working -# Terminal 2 - Monitore os logs +# Terminal 2 - Monitore os logs docker compose logs -f scraper ``` **Acesso aos dados coletados:** + ```bash # Via container make bash @@ -259,6 +288,7 @@ open http://localhost:9001 ``` ### 2- Executar as migrações do banco de dados + Para executar as migrações do banco de dados, utilize: `make migrate_db` @@ -266,6 +296,7 @@ Para executar as migrações do banco de dados, utilize: Este comando irá aplicar todas as migrações pendentes no banco de dados. ### 3- Coletar URL de notícias + O primeiro passo é coletar URLs de notícias nas páginas iniciais dos portais. Cada portal possui um "spider" implementado com a biblioteca Scrapy, localizado no diretório `spiders/`. Exemplo de script para a [Folha]("https://www.folha.uol.com.br"): `spiders/folha.py`. @@ -285,6 +316,7 @@ Para executar a extração de todas as notícias: ### 5- Adicionar um Novo Portal Para adicionar um novo portal, consulte o **[TUTORIAL_SPIDERS_PLAYS.md](./TUTORIAL_SPIDERS_PLAYS.md)** que contém instruções detalhadas sobre: + - Como criar spiders para coleta de URLs - Como criar plays para extração de conteúdo - Exemplos práticos e boas práticas @@ -295,15 +327,15 @@ Para adicionar um novo portal, consulte o **[TUTORIAL_SPIDERS_PLAYS.md](./TUTORI Os dados extraídos são armazenados no banco PostgreSQL e arquivos temporários no MinIO. Para configurações específicas de armazenamento, consulte o arquivo `.env`. ## Importante -Os scripts dependem da estrutura HTML dos portais e podem precisar de ajustes após atualizações nos sites. +Os scripts dependem da estrutura HTML dos portais e podem precisar de ajustes após atualizações nos sites. ## Executando um spider específico Para executar apenas um spider específico, você pode passar o nome do spider como argumento: ``` - docker compose run scraper python crawl.py metropolesspider + docker compose run scraper python crawl.py metropolesspider ``` Para verificar o banco de dados, você pode executar o seguinte comando: @@ -335,4 +367,4 @@ make clean # Ver todos os comandos disponíveis make help -``` \ No newline at end of file +``` diff --git a/plays/__init__.py b/plays/__init__.py index 8189816..af1f1b0 100644 --- a/plays/__init__.py +++ b/plays/__init__.py @@ -2,6 +2,7 @@ from plays.folha import FolhaPlay from plays.globo import GloboPlay from plays.ig import IGPlay +from plays.imirante import ImirantePlay from plays.metropoles import MetropolesPlay from plays.r7 import R7Play from plays.rbs import ClicRBSPlay @@ -19,6 +20,7 @@ FolhaPlay, GloboPlay, IGPlay, + ImirantePlay, MetropolesPlay, R7Play, TerraPlay, diff --git a/plays/imirante.py b/plays/imirante.py new file mode 100644 index 0000000..dcdfb3d --- /dev/null +++ b/plays/imirante.py @@ -0,0 +1,204 @@ +import time +from playwright.sync_api import sync_playwright +from plays.base import BasePlay +from plays.items import EntryItem +from plog import logger + +class ImirantePlay(BasePlay): + name = "imirante" + + @classmethod + def match(cls, url): + return "imirante.com" in url + + def pre_run(self): + pass + + def run(self) -> EntryItem: + with sync_playwright() as p: + browser = self.launch_browser(p, viewport={"width": 1920, "height": 1080}) + page = browser.new_page() + logger.info(f"[{self.name}] Opening URL '{self.url}'...") + page.goto(self.url, timeout=180_000) + page.wait_for_selector("h1", timeout=30000) + title = self._extract_title(page) + description = self._extract_description(page) + body = self._extract_body(page) + tags = self._extract_tags(page) + + logger.info(f"[{self.name}] Extração concluída:") + logger.info(f" - Título: {'✓' if title else '✗'}") + logger.info(f" - Descrição: {'✓' if description else '✗'}") + logger.info(f" - Corpo: {'✓' if body else '✗'} ({len(body) if body else 0} chars)") + logger.info(f" - Tags: {'✓' if tags else '✗'} ({len(tags) if tags else 0} tags)") + + return EntryItem( + title=title, + url=self.url, + description=description, + body=body, + tags=tags, + ) + + def _extract_title(self, page): + title_selectors = [ + "h1", + ".article-title h1", + ".news-title h1", + ".post-title h1", + "header h1" + ] + + for selector in title_selectors: + try: + element = page.locator(selector) + if element.count() > 0: + title = element.first.inner_text().strip() + if title: + logger.info(f"[{self.name}] Título extraído com seletor: {selector}") + return title + except Exception as e: + logger.warning(f"[{self.name}] Erro ao extrair título com {selector}: {str(e)}") + continue + + try: + title = page.title().strip() + if " - Imirante" in title: + title = title.replace(" - Imirante", "").strip() + if title: + logger.info(f"[{self.name}] Título extraído do ") + return title + except Exception as e: + logger.warning(f"[{self.name}] Erro ao extrair título do <title>: {str(e)}") + + logger.error(f"[{self.name}] Falha ao extrair título") + return "" + + def _extract_description(self, page): + description_selectors = [ + "h2", + ".article-subtitle", + ".news-subtitle", + ".post-subtitle", + ".lead", + ".summary", + ".excerpt", + "header h2", + "meta[name='description']" + ] + + for selector in description_selectors: + try: + if selector == "meta[name='description']": + element = page.locator(selector) + if element.count() > 0: + description = element.get_attribute("content") + if description and description.strip() and len(description.strip()) > 10: + logger.info(f"[{self.name}] Descrição extraída de meta description") + return description.strip() + else: + element = page.locator(selector) + if element.count() > 0: + description = element.first.inner_text().strip() + if description and len(description) > 10: + logger.info(f"[{self.name}] Descrição extraída com seletor: {selector}") + return description + except Exception as e: + logger.warning(f"[{self.name}] Erro ao extrair descrição com {selector}: {str(e)}") + continue + + logger.info(f"[{self.name}] Nenhuma descrição encontrada") + return "" + + def _extract_body(self, page): + body_selectors = [ + "article", + ".article-content", + ".article-body", + ".post-content", + ".news-content", + ".content", + ".text", + ".story-body", + "main" + ] + + for selector in body_selectors: + try: + element = page.locator(selector) + if element.count() > 0: + page.evaluate(""" + (selector) => { + const container = document.querySelector(selector); + if (container) { + // Remover elementos indesejados + const unwanted = container.querySelectorAll( + 'script, style, .ad, .advertisement, .ads, ' + + '.social-share, .share, .comments, .related, ' + + '.newsletter, .subscription, .promo, .banner' + ); + unwanted.forEach(el => el.remove()); + } + } + """, selector) + + body = element.first.inner_text().strip() + if body and len(body) > 100: + logger.info(f"[{self.name}] Corpo extraído com seletor: {selector} ({len(body)} chars)") + return body + except Exception as e: + logger.warning(f"[{self.name}] Erro ao extrair corpo com {selector}: {str(e)}") + continue + + logger.error(f"[{self.name}] Falha ao extrair corpo da notícia") + return "" + + def _extract_tags(self, page): + tag_selectors = [ + ".tags a", + ".categories a", + ".post-tags a", + ".article-tags a", + ".keywords a", + "[rel='tag']", + ".tag-list a", + ".breadcrumb a" + ] + + tags = [] + + for selector in tag_selectors: + try: + elements = page.locator(selector) + if elements.count() > 0: + for i in range(min(elements.count(), 10)): + try: + tag_text = elements.nth(i).inner_text().strip() + if tag_text and tag_text not in tags: + generic_tags = {"home", "início", "principal", "notícias", "imirante"} + if tag_text.lower() not in generic_tags and len(tag_text) > 2: + tags.append(tag_text) + except Exception: + continue + + if tags: + logger.info(f"[{self.name}] Tags extraídas com seletor {selector}: {tags}") + break + except Exception as e: + logger.warning(f"[{self.name}] Erro ao extrair tags com {selector}: {str(e)}") + continue + + if not tags: + try: + from urllib.parse import urlparse + parsed_url = urlparse(self.url) + path_parts = [part for part in parsed_url.path.split('/') if part] + if len(path_parts) >= 2: + category = path_parts[1] + if category and category not in ["noticias"]: + tags.append(category.replace("-", " ").title()) + logger.info(f"[{self.name}] Tag extraída da URL: {tags}") + except Exception as e: + logger.warning(f"[{self.name}] Erro ao extrair tag da URL: {str(e)}") + + return tags diff --git a/spiders/__init__.py b/spiders/__init__.py index d2bb555..b4ce9bb 100644 --- a/spiders/__init__.py +++ b/spiders/__init__.py @@ -3,6 +3,7 @@ from spiders.folha import FolhaSpider from spiders.globo import GloboSpider from spiders.ig import IGSpider +from spiders.imirante import ImiranteSpider from spiders.metropoles import MetropolesSpider from spiders.r7 import R7Spider from spiders.rbs import RBSSpider @@ -20,6 +21,7 @@ FolhaSpider, GloboSpider, IGSpider, + ImiranteSpider, MetropolesSpider, R7Spider, RBSSpider, diff --git a/spiders/imirante.py b/spiders/imirante.py new file mode 100644 index 0000000..b7ca1ac --- /dev/null +++ b/spiders/imirante.py @@ -0,0 +1,149 @@ +import scrapy +from urllib.parse import urlparse +from spiders.base import BaseSpider +from spiders.items import URLItem + +class ImiranteSpider(BaseSpider): + name = "imirantespider" + start_urls = ["https://imirante.com/"] + allowed_domains = ["imirante.com"] + + custom_settings = { + **BaseSpider.custom_settings, + "COOKIES_ENABLED": True, + "DOWNLOAD_DELAY": 2, + "COMPRESSION_ENABLED": False, + "HTTPCOMPRESSION_ENABLED": False, + "DEFAULT_REQUEST_HEADERS": { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7", + "Accept-Encoding": "identity", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + } + } + + def allow_url(self, url: str) -> bool: + if not url.startswith("https://imirante.com"): + return False + + p = urlparse(url) + path = p.path.rstrip('/') + + blacklisted_sections = { + "/videos", "/podcasts", "/programas", "/mirantefm", "/mirantenews", + "/entretenimento", "/culinaria", "/esporte", "/colunas", "/maranhao", + "/namira", "/agenda", "/sobre", "/contato", "/anuncie", + "/servicos", "/editais", "/25anos", "/politica-de-cookies", + "/politica-de-privacidade" + } + + if any(path.startswith(section) for section in blacklisted_sections): + return False + + if "/noticias/" in path: + segments = [seg for seg in path.split('/') if seg] + if len(segments) >= 6: + try: + year = int(segments[2]) + month = int(segments[3]) + day = int(segments[4]) + if 2020 <= year <= 2030 and 1 <= month <= 12 and 1 <= day <= 31: + slug = segments[5] + if len(slug) > 5 and slug.count('-') >= 1: + return True + except (ValueError, IndexError): + pass + + news_indicators = [ + "ipolitica", "brasil", "maranhao", "sao-luis", "economia", + "politica", "/2025/", "/2024/" + ] + + if any(indicator in url.lower() for indicator in news_indicators): + segments = [seg for seg in path.split('/') if seg] + if len(segments) >= 2: + last_segment = segments[-1] + if len(last_segment) > 10 and last_segment.count('-') >= 2: + return True + + return False + + def start_requests(self): + for url in self.start_urls: + yield scrapy.Request( + url, + callback=self.parse, + dont_filter=True, + meta={"dont_redirect": True, "handle_httpstatus_list": [403]}, + ) + + def parse(self, response): + if not response.text.strip().startswith('<'): + return + + selectors = [ + "a[href*='/noticias/']", + "a[href*='/esporte/']", + "a[href*='ipolitica']", + "a[href*='/2025/']", + "a[href*='/2024/']", + ] + + seen_urls = set() + valid_count = 0 + + for selector in selectors: + links = response.css(selector) + + for link in links: + url = link.attrib.get("href") + if not url: + continue + + full_url = response.urljoin(url) + full_url = full_url.split('#', 1)[0].split('?', 1)[0] + + if full_url in seen_urls: + continue + seen_urls.add(full_url) + + if self._is_potential_news_url(full_url) and self.allow_url(full_url): + yield URLItem(url=full_url) + valid_count += 1 + + if valid_count >= 50: + break + + if valid_count > 0: + break + + def _is_potential_news_url(self, url: str) -> bool: + if not url.startswith("https://imirante.com"): + return False + + exclude_patterns = [ + "/videos", "/programas", "/mirantefm", "/mirantenews", + "/culinaria", "/esporte", "/entretenimento", "/colunas", + "/politica-de-privacidade", "/contato", "/sobre", "/anuncie", + "/servicos", "/editais", "/25anos", "javascript:", "mailto:", + "#", "facebook.com", "instagram.com", "twitter.com", "whatsapp.com" + ] + + for pattern in exclude_patterns: + if pattern in url.lower(): + return False + + include_patterns = [ + "/noticias/", "/2025/", "/2024/", "ipolitica", "brasil", + "maranhao", "sao-luis", "economia", "politica" + ] + + for pattern in include_patterns: + if pattern in url.lower(): + return True + + return False