# Extrator de Notícias do Google News — Guia Completo de Funcionamento > Documento auto-contido. Pode ser copiado e colado em outra sessão sem memória: > ele contém toda a informação necessária para entender, reproduzir ou portar > o extrator de manchetes do Google News deste repositório (`GoogleNewsETL`). --- ## 1. Visão geral (arquitetura) O extrator segue **Clean Architecture** em camadas: ``` ┌──────────────────────────────────────────────────────────────┐ │ application/ (casos de uso + DTOs) │ │ extract_news_use_case.py → orquestra o fluxo │ │ dtos/extract_news_dto.py → contratos de entrada/saída │ ├──────────────────────────────────────────────────────────────┤ │ domain/ (entidades, value objects, portas, serviços) │ │ entities/news_article.py → entidade NewsArticle │ │ entities/search_query.py → value object SearchQuery │ │ ports/news_extractor_port.py → interface NewsExtractorPort│ │ ports/url_resolver_port.py → interface UrlResolverPort │ │ services/rate_limiter_service.py → throttling │ ├──────────────────────────────────────────────────────────────┤ │ infrastructure/ (adaptadores concretos) │ │ adapters/google_news_extractor_adapter.py → ★ o extrator │ │ adapters/playwright_url_resolver_adapter.py → resolve URLs│ └──────────────────────────────────────────────────────────────┘ ``` **Princípio chave:** o caso de uso depende da *porta* (`NewsExtractorPort`), nunca do adaptador concreto. O `GoogleNewsExtractorAdapter` implementa essa porta. Isso permite trocar o mecanismo de raspagem (RSS, Playwright, etc.) sem tocar no domínio. **Fluxo resumido:** ``` InputDTO ──► ExtractNewsUseCase.execute() ──► SearchQuery (validação) │ ▼ NewsExtractorPort.extract(query) │ ▼ GoogleNewsExtractorAdapter.extract() ├─ _fetch_rss() → parseia feed RSS └─ url_resolver.resolve_batch() → URLs finais │ ▼ list[NewsArticle] │ ▼ ExtractNewsOutputDTO (JSON) ``` --- ## 2. Entrada ### 2.1 DTO de entrada (`googlenews_etl/application/dtos/extract_news_dto.py`) ```python class ExtractNewsInputDTO(BaseModel): """DTO de Entrada do Caso de Uso de Extração.""" keyword: str = Field(..., description="Palavra ou expressão de busca") language: str = Field(..., description="Código do idioma (ex: 'es', 'pt', 'en')") max_pages: int = Field( default=3, ge=1, le=10, description="Quantidade de páginas para extrair (1 a 10)" ) ``` | Campo | Tipo | Obrigatório | Descrição | |------------|------|-------------|--------------------------------------------| | `keyword` | str | sim | Palavra/expressão de busca | | `language` | str | sim | Código do idioma (ex: `pt`, `en`, `es`) | | `max_pages`| int | não (def=3) | Páginas a extrair, entre **1 e 10** (10 itens/página) | ### 2.2 Value Object de validação (`googlenews_etl/domain/entities/search_query.py`) O use case **nunca usa o DTO cru**: converte-o em `SearchQuery`, que valida as regras de domínio no `__post_init__`: ```python @dataclass(frozen=True) class SearchQuery: """Value Object representando os parâmetros validados de consulta.""" keyword: str language: str max_pages: int = 3 def __post_init__(self) -> None: if not self.keyword or not self.keyword.strip(): raise InvalidSearchQueryError("A palavra-chave não pode ser vazia.") if not self.language or len(self.language.strip()) < 2: raise InvalidSearchQueryError( "O idioma deve conter pelo menos 2 caracteres (ex: 'es', 'pt', 'en')." ) if self.max_pages < 1 or self.max_pages > 10: raise InvalidSearchQueryError("O número máximo de páginas deve estar entre 1 e 10.") @property def clean_keyword(self) -> str: return self.keyword.strip() @property def clean_language(self) -> str: return self.language.strip().lower() ``` - Campos congelados (`frozen=True`) → imutáveis. - `clean_keyword` / `clean_language` são os valores normalizados usados na busca. - Falha de validação lança `InvalidSearchQueryError` (exceção de domínio). --- ## 3. Processamento — passo a passo ### 3.1 O caso de uso (`googlenews_etl/application/use_cases/extract_news_use_case.py`) É o orquestrador completo. Ele faz 3 coisas: ```python class ExtractNewsUseCase: def __init__(self, extractor: NewsExtractorPort | None = None) -> None: if extractor is None: from googlenews_etl.infrastructure.adapters.google_news_extractor_adapter import ( GoogleNewsExtractorAdapter, ) self.extractor = GoogleNewsExtractorAdapter() else: self.extractor = extractor def execute(self, input_dto: ExtractNewsInputDTO) -> ExtractNewsOutputDTO: # 1. Validação de Domínio (Value Object) search_query = SearchQuery( keyword=input_dto.keyword, language=input_dto.language, max_pages=input_dto.max_pages, ) # 2. Execução da Extração via Porta (Desacoplada) articles = self.extractor.extract(search_query) # 3. Mapeamento de Entidades de Domínio -> Output DTO article_dtos = [ NewsArticleDTO( titulo=art.title, subtitulo=art.subtitle, quando_publicado=art.published_at, url=art.url, pagina=art.page, ) for art in articles ] return ExtractNewsOutputDTO( query=search_query.clean_keyword, language=search_query.clean_language, total_paginas=search_query.max_pages, total_itens=len(article_dtos), scraped_at=datetime.now(UTC).isoformat(), items=article_dtos, ) ``` Notas importantes: - **Injeção de dependência com fallback:** se não receber um extrator, o use case instancia `GoogleNewsExtractorAdapter()` por padrão (import lazy dentro do `__init__`). - A extração passa pela **porta** `NewsExtractorPort.extract(query)` — desacoplamento da infraestrutura. - A saída é montada com `scraped_at` = timestamp UTC ISO do momento da raspagem. ### 3.2 A porta (`googlenews_etl/domain/ports/news_extractor_port.py`) ```python class NewsExtractorPort(ABC): """ Porta (Interface) para o serviço de extração de notícias do Google News. Permite desacoplar totalmente o mecanismo de raspagem (HTTP, Playwright, RSS, RabbitMQ, etc.) do domínio. """ @abstractmethod def extract(self, query: SearchQuery) -> list[NewsArticle]: """Extrai as notícias correspondentes aos critérios de busca.""" pass ``` ### 3.3 O adaptador concreto — o coração do extrator (`googlenews_etl/infrastructure/adapters/google_news_extractor_adapter.py`) #### 3.3.1 Inicialização: sessão HTTP com impersonação de browser ```python class GoogleNewsExtractorAdapter(NewsExtractorPort): DEFAULT_HEADERS = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", } def __init__( self, rate_limiter: RateLimiterService | None = None, url_resolver: UrlResolverPort | None = None, impersonate: str = "chrome120", resolve_final_urls: bool = True, ) -> None: self.impersonate = impersonate self.rate_limiter = rate_limiter or RateLimiterService( min_delay_seconds=0.5, max_delay_seconds=1.0 ) self.url_resolver = url_resolver or PlaywrightUrlResolverAdapter() self.resolve_final_urls = resolve_final_urls self.session = requests.Session(impersonate=self.impersonate) self.session.headers.update(self.DEFAULT_HEADERS) ``` - Usa `curl_cffi` com `impersonate="chrome120"` — imita o TLS fingerprint do Chrome para evitar bloqueios. - `RateLimiterService` com delay aleatório 0.5–1.0s entre requisições (configurável). - `resolve_final_urls=True` por padrão → resolve as URLs intermediárias do Google para as URLs reais dos veículos. #### 3.3.2 Mapeamento idioma → parâmetros `hl`/`gl` (`_get_hl_gl`) ```python def _get_hl_gl(self, lang_raw: str) -> tuple[str, str]: """Mapeia dinamicamente código de idioma e país para os parâmetros hl e gl do Google News.""" lang_clean = lang_raw.lower().replace("-", "_") locale_map = { "pt": ("pt-BR", "BR"), "pt_br": ("pt-BR", "BR"), "es": ("es-419", "AR"), "es_mx": ("es-419", "MX"), "es_es": ("es", "ES"), "en": ("en-US", "US"), "en_gb": ("en-GB", "GB"), "en_uk": ("en-GB", "GB"), "en_us": ("en-US", "US"), "de": ("de", "DE"), "de_de": ("de", "DE"), "it": ("it", "IT"), "it_it": ("it", "IT"), "fr": ("fr", "FR"), } if lang_clean in locale_map: return locale_map[lang_clean] parts = lang_clean.split("_") if len(parts) == 2: return (f"{parts[0]}-{parts[1].upper()}", parts[1].upper()) return (lang_clean, lang_clean.upper()) ``` - `hl` = idioma da interface, `gl` = país da região (ex: `pt` → `pt-BR`/`BR`). - Fallback genérico: `xx_yy` → `xx-YY`/`YY`; senão `xx`/`XX`. #### 3.3.3 ★ Extração do RSS — o ponto fundamental (`_fetch_rss`) **Este é o trecho que extrai de fato as manchetes com título, URL e data de publicação:** ```python def _fetch_rss(self, query: SearchQuery) -> list[NewsArticle]: encoded_query = urllib.parse.quote_plus(query.clean_keyword) hl, gl = self._get_hl_gl(query.clean_language) rss_url = f"https://news.google.com/rss/search?q={encoded_query}&hl={hl}&gl={gl}&ceid={gl}:{hl}" response = self.session.get(rss_url) response.raise_for_status() soup = BeautifulSoup(response.content, "xml") rss_items = soup.find_all("item") articles: list[NewsArticle] = [] max_allowed = query.max_pages * 10 for idx, item in enumerate(rss_items[:max_allowed]): page_number = (idx // 10) + 1 title = item.find("title").text if item.find("title") else "" link = item.find("link").text if item.find("link") else "" pub_date = item.find("pubDate").text if item.find("pubDate") else "" desc_raw = item.find("description").text if item.find("description") else "" desc_soup = BeautifulSoup(desc_raw, "html.parser") snippet = desc_soup.get_text(separator=" ", strip=True) if desc_raw else None if title and link: articles.append( NewsArticle( title=title, subtitle=snippet if snippet != title else None, published_at=pub_date, url=link, page=page_number, ) ) return articles ``` **Pontos fundamentais deste trecho:** | # | Mecanismo | Detalhe | |---|-----------|---------| | 1 | **URL do RSS** | `https://news.google.com/rss/search?q={keyword}&hl={hl}&gl={gl}&ceid={gl}:{hl}` — RSS oficial de busca do Google News | | 2 | **Parsing XML** | `BeautifulSoup(response.content, "xml")` + `find_all("item")` (formato RSS padrão) | | 3 | **Limite** | `max_pages * 10` itens — cada "página" do Google News = 10 itens | | 4 | **Nº da página** | `page_number = (idx // 10) + 1` — agrupa os itens em páginas de 10 | | 5 | **Campos extraídos por item** | `title`, `link`, `pubDate`, `description` (XML do RSS) | | 6 | **Limpeza do resumo** | `description` contém HTML — `BeautifulSoup(desc_raw, "html.parser")` + `get_text(separator=" ", strip=True)` remove tags | | 7 | **Filtro** | item só entra se tiver `title` **e** `link` | | 8 | **Dedupe de subtítulo** | `subtitle = snippet if snippet != title else None` — se o resumo for igual ao título, fica `None` | #### 3.3.4 Orquestração com resolução de URLs (`extract`) ```python def extract(self, query: SearchQuery) -> list[NewsArticle]: # Busca direta das manchetes pelo RSS oficial do Google News all_articles = self._fetch_rss(query) # Resolução paralela das URLs finais dos veículos se ativado if self.resolve_final_urls and all_articles: raw_urls = [a.url for a in all_articles] resolved_urls = self.url_resolver.resolve_batch(raw_urls) resolved_articles: list[NewsArticle] = [] for idx, article in enumerate(all_articles): new_url = resolved_urls[idx] if idx < len(resolved_urls) else article.url resolved_articles.append(dataclasses.replace(article, url=new_url)) all_articles = resolved_articles return all_articles ``` - As URLs do RSS do Google News são intermediárias (`news.google.com/rss/articles/CBMi...`). - `resolve_batch` resolve **em paralelo** (Playwright headless) para as URLs diretas dos veículos. - `dataclasses.replace(article, url=new_url)` preserva os demais campos. ### 3.4 Resolução de URLs (`googlenews_etl/infrastructure/adapters/playwright_url_resolver_adapter.py`) ```python class PlaywrightUrlResolverAdapter(UrlResolverPort): def __init__(self, timeout_ms: int = 6000, max_concurrent: int = 5) -> None: self.timeout_ms = timeout_ms self.max_concurrent = max_concurrent async def _resolve_single_async(self, context, semaphore, url: str) -> str: if not url or "news.google.com/rss/articles/" not in url: return url async with semaphore: page = await context.new_page() target_url = url def handle_request(req): nonlocal target_url u = req.url if not any( x in u for x in [ "google.", "gstatic.", "googleapis.", "googletagmanager.", "w3.org", "schema.org", ] ): if not target_url or target_url == url: if u.startswith("http"): target_url = u page.on("request", handle_request) try: await page.goto(url, wait_until="commit", timeout=self.timeout_ms) for _ in range(12): await asyncio.sleep(0.25) if "google.com" not in page.url: target_url = page.url break if target_url and target_url != url: break except Exception: pass finally: await page.close() return target_url or url ``` - Abre cada URL em uma página headless e **captura o primeiro request não-Google** (o redirecionamento para o veículo). - `Semaphore(max_concurrent=5)` limita concorrência; timeout de 6s por URL. - Intercepta requisições (`page.on("request")`) filtrando domínios de Google/telemetria. ### 3.5 Rate limiter (`googlenews_etl/domain/services/rate_limiter_service.py`) ```python class RateLimiterService: def __init__(self, min_delay_seconds: float = 1.0, max_delay_seconds: float = 2.5) -> None: self.min_delay = min_delay_seconds self.max_delay = max_delay_seconds def wait(self) -> float: """Aplica uma pausa aleatória dentro dos limites configurados e retorna o tempo aguardado.""" delay = random.uniform(self.min_delay, self.max_delay) time.sleep(delay) return delay ``` - Delay **aleatório** entre requisições (evita padrão detectável/anti-bot). --- ## 4. Entidade de domínio da notícia (`googlenews_etl/domain/entities/news_article.py`) ```python @dataclass(frozen=True) class NewsArticle: """Entidade do Domínio representando uma notícia extraída do Google News.""" title: str url: str page: int subtitle: str | None = None published_at: str | None = None def __post_init__(self) -> None: if not self.title or not self.title.strip(): raise ValueError("O título da notícia não pode ser vazio.") if not self.url or not self.url.strip(): raise ValueError("A URL da notícia não pode ser vazia.") ``` | Campo | Tipo | Origem no RSS | |----------------|------------|----------------------------------| | `title` | str | `