From c000794a860e1ad6a1656eb26cab122a7a5844c6 Mon Sep 17 00:00:00 2001 From: Alana Gabriele Amorim Silva Date: Wed, 19 Nov 2025 13:29:37 -0300 Subject: [PATCH 1/2] feat: spider cidade verde --- plays/__init__.py | 2 + plays/cidadeVerde.py | 189 +++++++++++++++++++++++++++++++++++++++++ spiders/__init__.py | 2 + spiders/cidadeVerde.py | 91 ++++++++++++++++++++ 4 files changed, 284 insertions(+) create mode 100644 plays/cidadeVerde.py create mode 100644 spiders/cidadeVerde.py diff --git a/plays/__init__.py b/plays/__init__.py index 8189816..30f913a 100644 --- a/plays/__init__.py +++ b/plays/__init__.py @@ -12,6 +12,7 @@ from plays.maisGoias import MaisGoias from plays.aliadosBrasil import AliadosBrasilPlay from plays.jornaldaparaiba import JornalDaParaibaPlay +from plays.cidadeVerde import CidadeVerdePlay __all__ = [ ClicRBSPlay, @@ -28,4 +29,5 @@ MaisGoias, AliadosBrasilPlay, JornalDaParaibaPlay, + CidadeVerdePlay, ] diff --git a/plays/cidadeVerde.py b/plays/cidadeVerde.py new file mode 100644 index 0000000..d0757ff --- /dev/null +++ b/plays/cidadeVerde.py @@ -0,0 +1,189 @@ +from playwright.sync_api import sync_playwright + +from plays.base import BasePlay +from plays.items import EntryItem +from plog import logger + + +class CidadeVerdePlay(BasePlay): + name = "cidadeverde" + + @classmethod + def match(cls, url: str) -> bool: + return "cidadeverde.com" in url + + def pre_run(self): + # Não há necessidade de login ou preparação especial conhecida + pass + + def run(self) -> EntryItem: + with sync_playwright() as p: + browser = self.launch_browser(p, viewport={"width": 1600, "height": 1000}) + page = browser.new_page() + + # Bloqueia recursos pesados e domínios de anúncios para acelerar + blocked_domains = ( + "doubleclick.net", + "googlesyndication.com", + "google-analytics.com", + "analytics.google.com", + "clarity.ms", + "tailtarget.com", + "flashtalking.com", + "googletagmanager.com", + ) + + def block_ads(route, request): + try: + if request.resource_type in {"image", "media", "font", "stylesheet"}: + return route.abort() + if any(d in request.url for d in blocked_domains): + return route.abort() + except Exception: + pass + return route.continue_() + + page.route("**/*", block_ads) + page.set_default_navigation_timeout(60_000) + page.set_default_timeout(20_000) + + logger.info(f"[{self.name}] Opening URL '{self.url}'...") + page.goto(self.url, wait_until="domcontentloaded", timeout=60_000) + + # Espera pelo título principal se possível + try: + page.wait_for_selector("h1", timeout=15_000) + except Exception: + pass + + # Título + title = "" + # Seletores genéricos e alguns comuns em sites de notícias + for selector in [ + ".post-title", + "article h1", + "h1", + + ]: + try: + if selector.startswith("meta["): + el = page.locator(selector) + if el.count() > 0: + content = el.first.get_attribute("content") or "" + content = content.strip() + if content: + title = content + logger.info(f"[{self.name}] Title from '{selector}'") + break + continue + + el = page.locator(selector) + if el.count() > 0: + title = el.first.inner_text().strip() + if title: + logger.info(f"[{self.name}] Title from '{selector}'") + break + except Exception: + continue + + if not title: + try: + title = page.title().strip() + except Exception: + title = "" + + # Descrição + description = "" + for selector in [ + ".post-subtitle" + "article h2", + "header h2", + ]: + try: + if selector.startswith("meta["): + el = page.locator(selector) + if el.count() > 0: + content = el.first.get_attribute("content") or "" + content = content.strip() + if content: + description = content + logger.info(f"[{self.name}] Description from '{selector}'") + break + continue + + el = page.locator(selector) + if el.count() > 0: + description = el.first.inner_text().strip() + if description: + logger.info(f"[{self.name}] Description from '{selector}'") + break + except Exception: + continue + + # Corpo da notícia + body = "" + for selector in [ + ".post-body", + "article .post-body", + "article .post-body p", + "article p", + ]: + try: + els = page.locator(selector) + count = els.count() + if count == 0: + continue + + if count > 1: + parts = [] + for i in range(count): + t = els.nth(i).inner_text().strip() + # Ignora parágrafos que começam com "Foto: " + if t and not t.startswith("Foto: "): + parts.append(t) + body = "\n\n".join(parts).strip() + else: + first_text = els.first.inner_text().strip() + if first_text.startswith("Foto: "): + first_text = "" + body = first_text + + if body and len(body) > 100: + logger.info( + f"[{self.name}] Body from '{selector}' (len={len(body)})" + ) + break + except Exception: + continue + + # Tags + tags = [] + for selector in [ + ".tags a", + ".tags li a", + ".tags li", + ]: + try: + els = page.locator(selector) + n = els.count() + if n == 0: + continue + for i in range(n): + t = els.nth(i).inner_text().strip() + if t and t not in tags: + tags.append(t) + if tags: + logger.info( + f"[{self.name}] {len(tags)} tags from '{selector}'" + ) + break + except Exception: + continue + + return EntryItem( + title=title, + url=self.url, + description=description, + body=body, + tags=tags, + ) diff --git a/spiders/__init__.py b/spiders/__init__.py index d2bb555..4088bc4 100644 --- a/spiders/__init__.py +++ b/spiders/__init__.py @@ -13,6 +13,7 @@ from spiders.maisGoias import MaisGoiasSpider from spiders.aliadosBrasil import AliadosBrasilSpider from spiders.jornaldaparaiba import JornalDaParaibaSpider +from spiders.cidadeVerde import CidadeVerdeSpider __all__ = [ BaseSpider, @@ -30,4 +31,5 @@ MaisGoiasSpider, AliadosBrasilSpider, JornalDaParaibaSpider, + CidadeVerdeSpider, ] diff --git a/spiders/cidadeVerde.py b/spiders/cidadeVerde.py new file mode 100644 index 0000000..7c8c02e --- /dev/null +++ b/spiders/cidadeVerde.py @@ -0,0 +1,91 @@ +import re + +import scrapy + +from spiders.base import BaseSpider +from spiders.items import URLItem + + +class CidadeVerdeSpider(BaseSpider): + name = "cidadeverdespider" + start_urls = ["https://cidadeverde.com/"] + allowed_domains = ["cidadeverde.com", "www.cidadeverde.com"] + + # Seções claramente não relacionadas a notícias ou que tendem a ser utilitários + BLACKLISTED_SECTIONS = { + "cvplay" + } + + def allow_url(self, url: str) -> bool: + """ + Filtra URLs de matérias do Cidade Verde. + A estratégia é bem genérica para funcionar sem depender de layout específico. + """ + if not url: + return False + + # Remove fragmentos e query string + url = url.split("#", 1)[0].split("?", 1)[0] + + # Garante que é do domínio alvo + if not re.match(r"https?://(www\.)?cidadeverde\.com/", url): + return False + + # Extrai path + path = re.sub(r"https?://[^/]+", "", url) + segments = [s for s in path.strip("/").split("/") if s] + + # Muito curto normalmente significa home ou seção agregadora + if len(segments) < 2: + return False + + # Normaliza para verificar seções indesejadas + blacklist_prefixes = {p.lower() for p in self.BLACKLISTED_SECTIONS} + for seg in segments: + seg_low = seg.lower() + if any(seg_low.startswith(pref) for pref in blacklist_prefixes): + return False + + slug = segments[-1] + + # Exige slug minimamente descritivo: + # - termina com .html OU + # - possui hífens suficientes para parecer título de matéria + if slug.endswith(".html"): + base_slug = slug[:-5] + else: + base_slug = slug + + return base_slug.count("-") >= 2 and len(base_slug) > 20 + + def parse(self, response): + """ + Coleta URLs de notícias da página inicial (sem crawling recursivo). + """ + seen = set() + + # Seletores genéricos; se o site tiver CSS específico, podemos refinar depois. + selectors = [ + "a[href*='cidadeverde.com']", + "a[href^='/']", + ] + + for sel in selectors: + for entry in response.css(sel): + href = entry.attrib.get("href") + if not href: + continue + + url = response.urljoin(href).split("?", 1)[0].split("#", 1)[0] + + if url in seen: + continue + + if self.allow_url(url): + seen.add(url) + yield URLItem(url=url) + + self.logger.info( + f"Collected {len(seen)} unique news URLs from Cidade Verde" + ) + From 0ff1bf4c6774a6980ccff17d5803fc3acdb46e51 Mon Sep 17 00:00:00 2001 From: Alana Gabriele Amorim Silva Date: Wed, 19 Nov 2025 13:29:59 -0300 Subject: [PATCH 2/2] feat: add Cidade Verde in makefile --- Makefile | 10 +++++++++- create_db.py | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a7f7ce2..0da6885 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_cidadeverde: + docker compose exec scraper python scrape_no_openai.py --platform cidadeverde.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_cidadeverde: + docker compose run scraper python crawl.py cidadeverdespider + # 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_cidadeverde @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_cidadeverde @echo "Scraping de todos os portais concluído!" # Pipeline completo: crawl + scrape @@ -255,4 +263,4 @@ all: # Parar todos os serviços down: - docker compose down \ No newline at end of file + docker compose down diff --git a/create_db.py b/create_db.py index 7ce5701..b57987d 100644 --- a/create_db.py +++ b/create_db.py @@ -43,6 +43,7 @@ ("Mais Goiás", "https://www.maisgoias.com.br/", "maisgoias"), ("Aliados Brasil", "https://www.aliadosbrasiloficial.com.br/", "aliadosbrasil"), ("Jornal da Paraíba", "https://jornaldaparaiba.com.br/", "jornaldaparaiba"), + ("Cidade Verde", "https://cidadeverde.com/", "cidadeverde"), ] portals_to_add = [] for portal in portals: