Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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..."
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -255,4 +263,4 @@ all:

# Parar todos os serviços
down:
docker compose down
docker compose down
1 change: 1 addition & 0 deletions create_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions plays/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,4 +29,5 @@
MaisGoias,
AliadosBrasilPlay,
JornalDaParaibaPlay,
CidadeVerdePlay,
]
189 changes: 189 additions & 0 deletions plays/cidadeVerde.py
Original file line number Diff line number Diff line change
@@ -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,
)
2 changes: 2 additions & 0 deletions spiders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,4 +31,5 @@
MaisGoiasSpider,
AliadosBrasilSpider,
JornalDaParaibaSpider,
CidadeVerdeSpider,
]
91 changes: 91 additions & 0 deletions spiders/cidadeVerde.py
Original file line number Diff line number Diff line change
@@ -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"
)