Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
324e269
ci: add lint and test workflow
LacerdaTraderCode Oct 9, 2026
b28e86e
build: add ruff configuration
LacerdaTraderCode Oct 9, 2026
76f924c
test: add pytest configuration
LacerdaTraderCode Oct 9, 2026
f96d4f3
build: add development requirements
LacerdaTraderCode Oct 9, 2026
99601f2
refactor: translate rate limiter and use monotonic clock
LacerdaTraderCode Oct 9, 2026
646569c
refactor: translate exporters and replace prints with logging
LacerdaTraderCode Oct 9, 2026
91983f8
refactor: translate BeautifulSoup scraper and extract quote parsing
LacerdaTraderCode Oct 9, 2026
68a8fa6
refactor: translate Selenium scraper and allow driver injection
LacerdaTraderCode Oct 9, 2026
dcba57b
refactor: translate Playwright scraper and ensure browser cleanup
LacerdaTraderCode Oct 10, 2026
aa3b80c
refactor: translate BeautifulSoup example and run as module
LacerdaTraderCode Oct 10, 2026
3255b6d
refactor: translate Selenium example and run as module
LacerdaTraderCode Oct 10, 2026
e87133c
refactor: translate Playwright example and run as module
LacerdaTraderCode Oct 10, 2026
9b52f6f
test: add tests package
LacerdaTraderCode Oct 10, 2026
286045a
test: cover rate limiter and user agent rotation
LacerdaTraderCode Oct 10, 2026
8a5e712
test: cover data exporters
LacerdaTraderCode Oct 10, 2026
750c4ae
test: cover BeautifulSoup scraper
LacerdaTraderCode Oct 10, 2026
8e3514f
test: cover Selenium scraper with a mocked driver
LacerdaTraderCode Oct 10, 2026
23bf026
style: keep test within line length
LacerdaTraderCode Oct 10, 2026
cfe9b8f
test: cover Playwright scraper with a mocked browser
LacerdaTraderCode Oct 10, 2026
af81ce1
docs: add CI badge
LacerdaTraderCode Oct 10, 2026
f00824d
docs: update structure and run examples as modules
LacerdaTraderCode Oct 10, 2026
49d241c
docs: run BeautifulSoup example as module
LacerdaTraderCode Oct 10, 2026
504b23c
docs: run Selenium example as module
LacerdaTraderCode Oct 10, 2026
810cb52
docs: run Playwright example as module
LacerdaTraderCode Oct 10, 2026
98ad46c
docs: add development section
LacerdaTraderCode Oct 10, 2026
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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: pip install -r requirements-dev.txt
- name: Lint
run: ruff check . && ruff format --check .
- name: Run tests
run: pytest -v
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

**A complete web scraping kit with BeautifulSoup, Selenium, and Playwright**

[![CI](https://github.com/LacerdaTraderCode/web-scraper-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/LacerdaTraderCode/web-scraper-toolkit/actions/workflows/ci.yml)
[![Python](https://img.shields.io/badge/Python-3.11%2B-blue?logo=python&logoColor=white)](https://www.python.org/)
[![Selenium](https://img.shields.io/badge/Selenium-43B02A?logo=selenium&logoColor=white)](https://www.selenium.dev/)
[![Playwright](https://img.shields.io/badge/Playwright-2EAD33?logo=playwright&logoColor=white)](https://playwright.dev/)
Expand Down Expand Up @@ -57,7 +58,10 @@ web-scraper-toolkit/
│ ├── quotes_bs4.py # Scraping quotes.toscrape.com
│ ├── books_selenium.py # Scraping books.toscrape.com
│ └── news_playwright.py # News scraping (async)
├── tests/
├── .github/workflows/ci.yml
├── requirements.txt
├── requirements-dev.txt
└── README.md
```

Expand Down Expand Up @@ -85,19 +89,19 @@ playwright install chromium

### BeautifulSoup — static HTML
```bash
python examples/quotes_bs4.py
python -m examples.quotes_bs4
```
Extracts quotes from `quotes.toscrape.com`, saves to CSV and Parquet.

### Selenium — JavaScript-heavy sites
```bash
python examples/books_selenium.py
python -m examples.books_selenium
```
Navigates the `books.toscrape.com` catalog simulating a real user.

### Playwright — asynchronous scraping
```bash
python examples/news_playwright.py
python -m examples.news_playwright
```
Collects headlines asynchronously (faster than Selenium).

Expand All @@ -113,6 +117,18 @@ Collects headlines asynchronously (faster than Selenium).

---

## 🧪 Development

```bash
pip install -r requirements-dev.txt
ruff check . && ruff format --check .
pytest -v
```

Browser drivers and HTTP sessions are mocked, so the test suite needs no browser or network access. The same checks run on every push and pull request via GitHub Actions.

---

## ⚖️ Ethical Use

This toolkit is for educational purposes. When scraping:
Expand Down
13 changes: 2 additions & 11 deletions examples/books_selenium.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
"""
Exemplo: scraping de livros usando Selenium.
Executar: python examples/books_selenium.py
"""
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))
import logging

from scrapers.selenium_scraper import SeleniumScraper
from utils.exporters import export_all


def main():
print("🚀 Iniciando scraping com Selenium...\n")
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

scraper = SeleniumScraper(headless=True)
try:
Expand All @@ -21,8 +14,6 @@ def main():
finally:
scraper.close()

print("\n✅ Concluído!")


if __name__ == "__main__":
main()
13 changes: 2 additions & 11 deletions examples/news_playwright.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
"""
Exemplo: scraping assíncrono com Playwright.
Executar: python examples/news_playwright.py
"""
import asyncio
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))
import logging

from scrapers.playwright_scraper import PlaywrightScraper
from utils.exporters import export_all


async def main():
print("🚀 Iniciando scraping assíncrono com Playwright...\n")
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

scraper = PlaywrightScraper()
quotes = await scraper.scrape_quotes_async(max_pages=5)
export_all(quotes, "output/quotes_playwright")

print("\n✅ Concluído!")


if __name__ == "__main__":
asyncio.run(main())
14 changes: 2 additions & 12 deletions examples/quotes_bs4.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
"""
Exemplo: scraping de quotes usando BeautifulSoup.
Executar: python examples/quotes_bs4.py
"""
import sys
from pathlib import Path

# Adiciona raiz do projeto ao path
sys.path.insert(0, str(Path(__file__).parent.parent))
import logging

from scrapers.bs4_scraper import BeautifulSoupScraper
from utils.exporters import export_all


def main():
print("🚀 Iniciando scraping com BeautifulSoup...\n")
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

scraper = BeautifulSoupScraper(min_interval=1.0)
try:
Expand All @@ -22,8 +14,6 @@ def main():
finally:
scraper.close()

print("\n✅ Concluído!")


if __name__ == "__main__":
main()
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[tool.ruff]
line-length = 100
exclude = [".venv"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "RUF"]
4 changes: 4 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pytest]
python_files = test_*.py
testpaths = tests
asyncio_mode = auto
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-r requirements.txt
pytest==9.1.1
pytest-asyncio==1.4.0
ruff==0.16.9
52 changes: 22 additions & 30 deletions scrapers/bs4_scraper.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
"""
Scraper usando BeautifulSoup + Requests para HTML estático.
"""
import logging

import requests
from bs4 import BeautifulSoup
from tenacity import retry, stop_after_attempt, wait_exponential
from typing import List, Dict

from utils.rate_limiter import RateLimiter, get_random_user_agent

logger = logging.getLogger(__name__)

class BeautifulSoupScraper:
"""Scraper simples e rápido para páginas estáticas."""

class BeautifulSoupScraper:
def __init__(self, min_interval: float = 1.0):
self.rate_limiter = RateLimiter(min_interval)
self.session = requests.Session()

@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
reraise=True,
)
def fetch(self, url: str) -> BeautifulSoup:
"""Busca uma URL e retorna objeto BeautifulSoup."""
self.rate_limiter.wait()

headers = {"User-Agent": get_random_user_agent()}
Expand All @@ -30,41 +28,35 @@ def fetch(self, url: str) -> BeautifulSoup:

return BeautifulSoup(response.text, "lxml")

def scrape_quotes(self, base_url: str = "https://quotes.toscrape.com") -> List[Dict]:
"""
Exemplo: extrai quotes do site quotes.toscrape.com (site oficial pra aprender).
"""
@staticmethod
def _parse_quote(div) -> dict:
tags = [tag.get_text(strip=True) for tag in div.find_all("a", class_="tag")]
return {
"text": div.find("span", class_="text").get_text(strip=True),
"author": div.find("small", class_="author").get_text(strip=True),
"tags": ", ".join(tags),
}

def scrape_quotes(self, base_url: str = "https://quotes.toscrape.com") -> list[dict]:
quotes = []
page = 1

while True:
url = f"{base_url}/page/{page}/"
print(f"📄 Scraping página {page}...")

soup = self.fetch(url)
logger.info("Scraping page %d", page)
soup = self.fetch(f"{base_url}/page/{page}/")
quote_divs = soup.find_all("div", class_="quote")

if not quote_divs:
break

for div in quote_divs:
quotes.append({
"text": div.find("span", class_="text").get_text(strip=True),
"author": div.find("small", class_="author").get_text(strip=True),
"tags": ", ".join(
tag.get_text(strip=True)
for tag in div.find_all("a", class_="tag")
),
})

# Verifica se tem próxima página
next_btn = soup.find("li", class_="next")
if not next_btn:
quotes.extend(self._parse_quote(div) for div in quote_divs)

if not soup.find("li", class_="next"):
break
page += 1

print(f"✅ Total coletado: {len(quotes)} quotes")
logger.info("Collected %d quotes", len(quotes))
return quotes

def close(self):
def close(self) -> None:
self.session.close()
87 changes: 38 additions & 49 deletions scrapers/playwright_scraper.py
Original file line number Diff line number Diff line change
@@ -1,65 +1,54 @@
"""
Scraper assíncrono usando Playwright - moderno e rápido.
"""
import asyncio
from typing import List, Dict
from playwright.async_api import async_playwright
import logging

from playwright.async_api import Error, async_playwright

class PlaywrightScraper:
"""Scraper moderno, assíncrono e multi-browser."""
logger = logging.getLogger(__name__)

EXTRACT_QUOTES_SCRIPT = """
() => {
const items = document.querySelectorAll('.quote');
return Array.from(items).map(q => ({
text: q.querySelector('.text')?.innerText || '',
author: q.querySelector('.author')?.innerText || '',
tags: Array.from(q.querySelectorAll('.tag')).map(t => t.innerText).join(', ')
}));
}
"""


class PlaywrightScraper:
async def scrape_quotes_async(
self, base_url: str = "https://quotes.toscrape.com", max_pages: int = 5
) -> List[Dict]:
"""
Scraping assíncrono - várias páginas em paralelo.
Muito mais rápido que Selenium.
"""
) -> list[dict]:
quotes = []

async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()

# Criar tasks em paralelo
tasks = []
for page_num in range(1, max_pages + 1):
tasks.append(
self._scrape_page(context, f"{base_url}/page/{page_num}/")
)

results = await asyncio.gather(*tasks, return_exceptions=True)

for result in results:
if isinstance(result, list):
quotes.extend(result)

await browser.close()

print(f"✅ Total coletado: {len(quotes)} quotes (async)")
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(headless=True)
try:
context = await browser.new_context()
tasks = [
self._scrape_page(context, f"{base_url}/page/{page_number}/")
for page_number in range(1, max_pages + 1)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
finally:
await browser.close()

for result in results:
if isinstance(result, list):
quotes.extend(result)

logger.info("Collected %d quotes (async)", len(quotes))
return quotes

async def _scrape_page(self, context, url: str) -> List[Dict]:
"""Extrai quotes de uma única página."""
async def _scrape_page(self, context, url: str) -> list[dict]:
page = await context.new_page()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=15000)

quotes = await page.evaluate("""
() => {
const items = document.querySelectorAll('.quote');
return Array.from(items).map(q => ({
text: q.querySelector('.text')?.innerText || '',
author: q.querySelector('.author')?.innerText || '',
tags: Array.from(q.querySelectorAll('.tag'))
.map(t => t.innerText).join(', ')
}));
}
""")
return quotes
except Exception as e:
print(f"⚠️ Erro em {url}: {e}")
return await page.evaluate(EXTRACT_QUOTES_SCRIPT)
except Error as exc:
logger.warning("Failed to scrape %s: %s", url, exc)
return []
finally:
await page.close()
Loading
Loading