polite-scraper 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polite_scraper/__init__.py +3 -0
- polite_scraper/__main__.py +4 -0
- polite_scraper/cli.py +186 -0
- polite_scraper/config.py +40 -0
- polite_scraper/crawler.py +59 -0
- polite_scraper/extractor.py +92 -0
- polite_scraper/fetcher.py +94 -0
- polite_scraper/generic.py +153 -0
- polite_scraper/main.py +224 -0
- polite_scraper/models.py +60 -0
- polite_scraper/reporter.py +71 -0
- polite_scraper/ui.py +137 -0
- polite_scraper/utils.py +75 -0
- polite_scraper/validator.py +47 -0
- polite_scraper-1.0.0.dist-info/METADATA +283 -0
- polite_scraper-1.0.0.dist-info/RECORD +20 -0
- polite_scraper-1.0.0.dist-info/WHEEL +5 -0
- polite_scraper-1.0.0.dist-info/entry_points.txt +2 -0
- polite_scraper-1.0.0.dist-info/licenses/LICENSE +21 -0
- polite_scraper-1.0.0.dist-info/top_level.txt +1 -0
polite_scraper/cli.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Command-line interface for The Polite Scraper."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from polite_scraper.config import Config
|
|
11
|
+
|
|
12
|
+
USAGE_EXAMPLES = """\
|
|
13
|
+
[bold]Examples:[/]
|
|
14
|
+
python -m polite_scraper https://books.toscrape.com
|
|
15
|
+
python -m polite_scraper https://en.wikipedia.org --mode generic --pages 10
|
|
16
|
+
python -m polite_scraper https://books.toscrape.com --mode book --pages 5 --force
|
|
17
|
+
python -m polite_scraper https://example.com -v # verbose logs
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Parser(argparse.ArgumentParser):
|
|
22
|
+
def error(self, message: str): # pragma: no cover - argparse hook
|
|
23
|
+
from polite_scraper import ui
|
|
24
|
+
|
|
25
|
+
ui.error(f"{message}\n")
|
|
26
|
+
ui.console.print(USAGE_EXAMPLES)
|
|
27
|
+
raise SystemExit(2)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_parser() -> Parser:
|
|
31
|
+
parser = Parser(
|
|
32
|
+
prog="polite-scraper",
|
|
33
|
+
description="A polite, rate-limited web scraper with caching and validation.",
|
|
34
|
+
epilog=USAGE_EXAMPLES,
|
|
35
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"url",
|
|
39
|
+
nargs="?",
|
|
40
|
+
default=None,
|
|
41
|
+
help="Website to scrape (e.g. https://books.toscrape.com)",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--mode",
|
|
45
|
+
choices=["auto", "book", "generic"],
|
|
46
|
+
default="auto",
|
|
47
|
+
help=(
|
|
48
|
+
"Scraper mode: 'book' uses the catalogue/product-page extractor, "
|
|
49
|
+
"'generic' extracts page metadata from any site, "
|
|
50
|
+
"'auto' detects book catalogues and falls back to generic (default: auto)"
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--pages",
|
|
55
|
+
type=int,
|
|
56
|
+
default=3,
|
|
57
|
+
help="Maximum number of pages to crawl (default: 3)",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--max-links",
|
|
61
|
+
type=int,
|
|
62
|
+
default=8,
|
|
63
|
+
help="Generic mode only: max new same-domain links followed per page (default: 8)",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--delay",
|
|
67
|
+
type=float,
|
|
68
|
+
default=None,
|
|
69
|
+
help="Minimum delay in seconds between network requests (default: 0.5)",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--timeout",
|
|
73
|
+
type=int,
|
|
74
|
+
default=None,
|
|
75
|
+
help="Network request timeout in seconds (default: 10)",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--force",
|
|
79
|
+
action="store_true",
|
|
80
|
+
help="Ignore the local HTML cache and re-fetch everything",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"-v",
|
|
84
|
+
"--verbose",
|
|
85
|
+
action="store_true",
|
|
86
|
+
help="Show detailed per-request logs and raw record checkpoints",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--name",
|
|
90
|
+
default=None,
|
|
91
|
+
help="Custom name for this run's output folder (default: derived from the website name)",
|
|
92
|
+
)
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_config(args: argparse.Namespace) -> Config:
|
|
97
|
+
"""Map parsed CLI arguments onto a Config instance."""
|
|
98
|
+
config = Config()
|
|
99
|
+
|
|
100
|
+
if args.url:
|
|
101
|
+
url = args.url.strip()
|
|
102
|
+
if not url.startswith(("http://", "https://")):
|
|
103
|
+
url = f"https://{url}"
|
|
104
|
+
config.BASE_URL = url if url.endswith("/") else f"{url}/"
|
|
105
|
+
|
|
106
|
+
if args.delay is not None:
|
|
107
|
+
config.MIN_DELAY_SECONDS = args.delay
|
|
108
|
+
if args.timeout is not None:
|
|
109
|
+
config.TIMEOUT = args.timeout
|
|
110
|
+
config.MAX_CATALOGUE_PAGES = max(1, args.pages)
|
|
111
|
+
config.FORCE_REFRESH = args.force
|
|
112
|
+
config.MAX_LINKS_PER_PAGE = max(1, args.max_links)
|
|
113
|
+
|
|
114
|
+
# Environment variables still work, but CLI flags win.
|
|
115
|
+
env_ua = os.getenv("USER_AGENT")
|
|
116
|
+
if env_ua:
|
|
117
|
+
config.USER_AGENT = env_ua
|
|
118
|
+
|
|
119
|
+
return config
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def derive_run_name(base_url: str) -> str:
|
|
123
|
+
"""Derive a filesystem-safe run folder name from the website, e.g.
|
|
124
|
+
'https://www.books.toscrape.com/' -> 'books-toscrape'."""
|
|
125
|
+
host = urlparse(base_url).netloc.lower()
|
|
126
|
+
host = re.sub(r"^www\.", "", host)
|
|
127
|
+
name = re.sub(r"[^a-z0-9-]+", "-", host).strip("-")
|
|
128
|
+
return name or "run"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_output_dir(config: Config, explicit_name: str | None) -> Path:
|
|
132
|
+
"""
|
|
133
|
+
Resolve the per-run output folder under config.OUTPUT_ROOT.
|
|
134
|
+
|
|
135
|
+
The default name comes from the website (e.g. 'books-toscrape'). If the
|
|
136
|
+
folder already exists, the user is asked to pick a new name or accept an
|
|
137
|
+
auto-suffix ('books-toscrape-1', 'books-toscrape-2', ...). In
|
|
138
|
+
non-interactive sessions the auto-suffix is applied silently, so runs
|
|
139
|
+
never overwrite a previous project's output.
|
|
140
|
+
"""
|
|
141
|
+
base_name = (explicit_name or derive_run_name(config.BASE_URL)).strip()
|
|
142
|
+
base_name = re.sub(r"[^a-zA-Z0-9_-]+", "-", base_name).strip("-") or "run"
|
|
143
|
+
|
|
144
|
+
candidate = config.OUTPUT_ROOT / base_name
|
|
145
|
+
if not candidate.exists():
|
|
146
|
+
return candidate
|
|
147
|
+
|
|
148
|
+
# Collision: find the next free suffix
|
|
149
|
+
suffix = 1
|
|
150
|
+
while (config.OUTPUT_ROOT / f"{base_name}-{suffix}").exists():
|
|
151
|
+
suffix += 1
|
|
152
|
+
auto_name = f"{base_name}-{suffix}"
|
|
153
|
+
|
|
154
|
+
if not sys.stdin.isatty():
|
|
155
|
+
from polite_scraper import ui
|
|
156
|
+
|
|
157
|
+
ui.info(f"Output folder '{base_name}' already exists — using '{auto_name}'")
|
|
158
|
+
return config.OUTPUT_ROOT / auto_name
|
|
159
|
+
|
|
160
|
+
# Interactive: let the user rename or accept the auto-suffix
|
|
161
|
+
from polite_scraper import ui
|
|
162
|
+
|
|
163
|
+
ui.warn(f"Output folder '{base_name}' already exists.")
|
|
164
|
+
while True:
|
|
165
|
+
answer = input(
|
|
166
|
+
f" New folder name [{auto_name}]: "
|
|
167
|
+
).strip()
|
|
168
|
+
answer = re.sub(r"[^a-zA-Z0-9_-]+", "-", answer).strip("-")
|
|
169
|
+
if not answer:
|
|
170
|
+
return config.OUTPUT_ROOT / auto_name
|
|
171
|
+
if not (config.OUTPUT_ROOT / answer).exists():
|
|
172
|
+
return config.OUTPUT_ROOT / answer
|
|
173
|
+
ui.warn(f"'{answer}' also exists — pick another name")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def ensure_url_provided(args: argparse.Namespace) -> bool:
|
|
177
|
+
from polite_scraper import ui
|
|
178
|
+
|
|
179
|
+
if args.url:
|
|
180
|
+
return True
|
|
181
|
+
ui.console.print(
|
|
182
|
+
"[bold red]✗[/] No website given.\n"
|
|
183
|
+
" [dim]Usage:[/] polite-scraper [bold]<url>[/] [dim][options][/] \n"
|
|
184
|
+
)
|
|
185
|
+
ui.console.print(USAGE_EXAMPLES)
|
|
186
|
+
return False
|
polite_scraper/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Config:
|
|
7
|
+
USER_AGENT: str = os.getenv(
|
|
8
|
+
"USER_AGENT",
|
|
9
|
+
"PoliteScraper/1.0 (+https://github.com/callmekriztal/polite_scapper)"
|
|
10
|
+
)
|
|
11
|
+
TIMEOUT: int = int(os.getenv("TIMEOUT", "10"))
|
|
12
|
+
MIN_DELAY_SECONDS: float = float(os.getenv("MIN_DELAY_SECONDS", "0.5"))
|
|
13
|
+
|
|
14
|
+
# Data paths are relative to the working directory, not the package location,
|
|
15
|
+
# so an installed copy (site-packages) never writes inside itself.
|
|
16
|
+
# From the repo root this resolves to the same <repo>/data layout as before.
|
|
17
|
+
CACHE_DIR: Path = field(default_factory=lambda: Path.cwd() / "data" / "cache")
|
|
18
|
+
# Root for per-run output folders (e.g. data/runs/books-toscrape)
|
|
19
|
+
OUTPUT_ROOT: Path = field(default_factory=lambda: Path.cwd() / "data" / "runs")
|
|
20
|
+
# The active run's output folder, resolved at runtime from the website name
|
|
21
|
+
OUTPUT_DIR: Path = field(default_factory=lambda: Path.cwd() / "data" / "runs")
|
|
22
|
+
|
|
23
|
+
BASE_URL: str = "https://books.toscrape.com/"
|
|
24
|
+
START_URL: str = "https://books.toscrape.com/catalogue/page-1.html"
|
|
25
|
+
MAX_CATALOGUE_PAGES: int = 3
|
|
26
|
+
|
|
27
|
+
# Generic-mode settings (set by the CLI)
|
|
28
|
+
FORCE_REFRESH: bool = False
|
|
29
|
+
MAX_LINKS_PER_PAGE: int = 8
|
|
30
|
+
|
|
31
|
+
# Simulation hook for broken detail page testing
|
|
32
|
+
BROKEN_TEST_URL: str | None = os.getenv("BROKEN_TEST_URL", None)
|
|
33
|
+
|
|
34
|
+
def toscrape_start_url(self) -> str:
|
|
35
|
+
"""The Books to Scrape catalogue root, used when the user passes the site root."""
|
|
36
|
+
return self.BASE_URL.rstrip("/") + "/catalogue/page-1.html"
|
|
37
|
+
|
|
38
|
+
def ensure_directories(self) -> None:
|
|
39
|
+
self.CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
self.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from bs4 import BeautifulSoup
|
|
2
|
+
from polite_scraper.config import Config
|
|
3
|
+
from polite_scraper.fetcher import Fetcher
|
|
4
|
+
from polite_scraper.utils import make_absolute_url
|
|
5
|
+
|
|
6
|
+
class DiscoveryResult:
|
|
7
|
+
def __init__(self, catalogue_pages_count: int, discovered_count: int, book_urls: list[dict]):
|
|
8
|
+
self.catalogue_pages_count = catalogue_pages_count
|
|
9
|
+
self.discovered_count = discovered_count
|
|
10
|
+
self.book_urls = book_urls # List of dicts: {"product_url": ..., "source_page": ...}
|
|
11
|
+
|
|
12
|
+
class CatalogueCrawler:
|
|
13
|
+
"""Discovers book URLs by crawling catalogue pagination."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: Config, fetcher: Fetcher):
|
|
16
|
+
self.config = config
|
|
17
|
+
self.fetcher = fetcher
|
|
18
|
+
|
|
19
|
+
def discover_books(self) -> DiscoveryResult:
|
|
20
|
+
current_url = self.config.START_URL
|
|
21
|
+
pages_processed = 0
|
|
22
|
+
discovered_entries = []
|
|
23
|
+
seen_urls = set()
|
|
24
|
+
|
|
25
|
+
while current_url and pages_processed < self.config.MAX_CATALOGUE_PAGES:
|
|
26
|
+
html_content, _ = self.fetcher.fetch(current_url)
|
|
27
|
+
pages_processed += 1
|
|
28
|
+
|
|
29
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
30
|
+
|
|
31
|
+
# Find all product links in the current catalogue page
|
|
32
|
+
# Each book card is in an <article class="product_pod">
|
|
33
|
+
product_pods = soup.select("article.product_pod")
|
|
34
|
+
for pod in product_pods:
|
|
35
|
+
a_tag = pod.select_one("h3 a")
|
|
36
|
+
if a_tag and a_tag.get("href"):
|
|
37
|
+
rel_url = a_tag["href"]
|
|
38
|
+
abs_url = make_absolute_url(current_url, rel_url)
|
|
39
|
+
|
|
40
|
+
if abs_url not in seen_urls:
|
|
41
|
+
seen_urls.add(abs_url)
|
|
42
|
+
discovered_entries.append({
|
|
43
|
+
"product_url": abs_url,
|
|
44
|
+
"source_page": current_url
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
# Check for "next" page link
|
|
48
|
+
next_li = soup.select_one("li.next a")
|
|
49
|
+
if next_li and next_li.get("href"):
|
|
50
|
+
next_rel = next_li["href"]
|
|
51
|
+
current_url = make_absolute_url(current_url, next_rel)
|
|
52
|
+
else:
|
|
53
|
+
current_url = None
|
|
54
|
+
|
|
55
|
+
return DiscoveryResult(
|
|
56
|
+
catalogue_pages_count=pages_processed,
|
|
57
|
+
discovered_count=len(discovered_entries),
|
|
58
|
+
book_urls=discovered_entries
|
|
59
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
import requests
|
|
3
|
+
from bs4 import BeautifulSoup
|
|
4
|
+
from polite_scraper.config import Config
|
|
5
|
+
from polite_scraper.fetcher import Fetcher
|
|
6
|
+
from polite_scraper.utils import (
|
|
7
|
+
clean_text,
|
|
8
|
+
normalize_availability,
|
|
9
|
+
normalize_price,
|
|
10
|
+
normalize_rating
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
class BookExtractor:
|
|
14
|
+
"""Extracts raw and normalized product data from book detail pages."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config: Config, fetcher: Fetcher):
|
|
17
|
+
self.config = config
|
|
18
|
+
self.fetcher = fetcher
|
|
19
|
+
|
|
20
|
+
def extract_book(self, product_url: str, source_page: str) -> dict:
|
|
21
|
+
"""
|
|
22
|
+
Fetch book detail page and extract fields.
|
|
23
|
+
Raises Exception if page retrieval or parsing fails.
|
|
24
|
+
"""
|
|
25
|
+
# Simulation hook for broken test URL
|
|
26
|
+
if self.config.BROKEN_TEST_URL and product_url == self.config.BROKEN_TEST_URL:
|
|
27
|
+
raise requests.HTTPError(f"Simulated broken page error for {product_url}")
|
|
28
|
+
|
|
29
|
+
fetched_at = datetime.now(timezone.utc).isoformat()
|
|
30
|
+
html_content, _ = self.fetcher.fetch(product_url)
|
|
31
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
32
|
+
|
|
33
|
+
# Specific selector for product main area
|
|
34
|
+
product_main = soup.select_one("div.product_main")
|
|
35
|
+
if not product_main:
|
|
36
|
+
raise ValueError(f"Could not find product_main area in {product_url}")
|
|
37
|
+
|
|
38
|
+
# Title
|
|
39
|
+
h1_tag = product_main.select_one("h1")
|
|
40
|
+
title_raw = h1_tag.get_text() if h1_tag else ""
|
|
41
|
+
title = clean_text(title_raw) or ""
|
|
42
|
+
|
|
43
|
+
# Price text
|
|
44
|
+
price_tag = product_main.select_one("p.price_color")
|
|
45
|
+
price_text = clean_text(price_tag.get_text()) if price_tag else ""
|
|
46
|
+
|
|
47
|
+
# Availability text
|
|
48
|
+
avail_tag = product_main.select_one("p.instock.availability")
|
|
49
|
+
availability_text = clean_text(avail_tag.get_text()) if avail_tag else ""
|
|
50
|
+
|
|
51
|
+
# Rating text
|
|
52
|
+
rating_tag = product_main.select_one("p.star-rating")
|
|
53
|
+
rating_text = ""
|
|
54
|
+
if rating_tag:
|
|
55
|
+
classes = rating_tag.get("class", [])
|
|
56
|
+
for c in classes:
|
|
57
|
+
if c.lower() != "star-rating":
|
|
58
|
+
rating_text = c
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
# Description (located after #product_description div header if present)
|
|
62
|
+
desc_header = soup.select_one("#product_description")
|
|
63
|
+
description = None
|
|
64
|
+
if desc_header:
|
|
65
|
+
desc_p = desc_header.find_next_sibling("p")
|
|
66
|
+
if desc_p:
|
|
67
|
+
description = clean_text(desc_p.get_text())
|
|
68
|
+
|
|
69
|
+
# Construct raw record
|
|
70
|
+
raw_record = {
|
|
71
|
+
"title": title,
|
|
72
|
+
"product_url": product_url,
|
|
73
|
+
"price_text": price_text,
|
|
74
|
+
"availability_text": availability_text,
|
|
75
|
+
"rating_text": rating_text,
|
|
76
|
+
"description": description,
|
|
77
|
+
"source_page": source_page,
|
|
78
|
+
"fetched_at": fetched_at,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Normalize fields (Stage 4)
|
|
82
|
+
price_gbp = normalize_price(price_text)
|
|
83
|
+
rating_int = normalize_rating(rating_text)
|
|
84
|
+
availability_norm = normalize_availability(availability_text)
|
|
85
|
+
|
|
86
|
+
raw_record.update({
|
|
87
|
+
"price_gbp": price_gbp,
|
|
88
|
+
"rating_int": rating_int,
|
|
89
|
+
"availability_normalized": availability_norm
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
return raw_record
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from urllib.robotparser import RobotFileParser
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from polite_scraper import ui
|
|
7
|
+
from polite_scraper.config import Config
|
|
8
|
+
from polite_scraper.utils import url_to_cache_filename
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Fetcher:
|
|
12
|
+
"""HTTP fetcher with polite rate-limiting, status checking, and HTML disk caching."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config: Config):
|
|
15
|
+
self.config = config
|
|
16
|
+
self.session = requests.Session()
|
|
17
|
+
self.session.headers.update({
|
|
18
|
+
"User-Agent": self.config.USER_AGENT
|
|
19
|
+
})
|
|
20
|
+
self.cache_hits = 0
|
|
21
|
+
self.network_fetches = 0
|
|
22
|
+
self.last_request_time = 0.0
|
|
23
|
+
|
|
24
|
+
def fetch(self, url: str, force_refresh: bool = False) -> tuple[str, bool]:
|
|
25
|
+
"""
|
|
26
|
+
Fetch HTML from URL or cache.
|
|
27
|
+
Returns tuple of (html_content, was_cached).
|
|
28
|
+
Raises requests.HTTPError if response status != 200.
|
|
29
|
+
"""
|
|
30
|
+
force_refresh = force_refresh or self.config.FORCE_REFRESH
|
|
31
|
+
self.config.ensure_directories()
|
|
32
|
+
cache_filename = url_to_cache_filename(url)
|
|
33
|
+
cache_path = self.config.CACHE_DIR / cache_filename
|
|
34
|
+
|
|
35
|
+
# Check local cache first unless force_refresh is True
|
|
36
|
+
if not force_refresh and cache_path.is_file():
|
|
37
|
+
try:
|
|
38
|
+
html_content = cache_path.read_text(encoding="utf-8")
|
|
39
|
+
self.cache_hits += 1
|
|
40
|
+
size_bytes = len(html_content.encode("utf-8"))
|
|
41
|
+
ui.debug(f"CACHE HIT {url} ({size_bytes} bytes)")
|
|
42
|
+
return html_content, True
|
|
43
|
+
except Exception as e:
|
|
44
|
+
ui.debug(f"Cache read error for {cache_path}: {e}. Falling back to network fetch.")
|
|
45
|
+
|
|
46
|
+
html_content = self._network_get(url)
|
|
47
|
+
cache_path.write_text(html_content, encoding="utf-8")
|
|
48
|
+
return html_content, False
|
|
49
|
+
|
|
50
|
+
def fetch_robots_txt(self, base_url: str) -> RobotFileParser | None:
|
|
51
|
+
"""
|
|
52
|
+
Fetch robots.txt for a site. Returns a parser, or None if no robots.txt
|
|
53
|
+
exists / it cannot be fetched (treated as "no rules").
|
|
54
|
+
"""
|
|
55
|
+
robots_url = base_url.rstrip("/") + "/robots.txt"
|
|
56
|
+
try:
|
|
57
|
+
response = self.session.get(robots_url, timeout=self.config.TIMEOUT)
|
|
58
|
+
except requests.RequestException as e:
|
|
59
|
+
ui.debug(f"robots.txt fetch failed for {robots_url}: {e}")
|
|
60
|
+
return None
|
|
61
|
+
if response.status_code != 200:
|
|
62
|
+
ui.debug(f"robots.txt not available ({response.status_code}) at {robots_url}")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
parser = RobotFileParser()
|
|
66
|
+
parser.parse(response.text.splitlines())
|
|
67
|
+
return parser
|
|
68
|
+
|
|
69
|
+
def _network_get(self, url: str) -> str:
|
|
70
|
+
# Respect rate limit of at least MIN_DELAY_SECONDS between network requests
|
|
71
|
+
now = time.time()
|
|
72
|
+
elapsed = now - self.last_request_time
|
|
73
|
+
if elapsed < self.config.MIN_DELAY_SECONDS:
|
|
74
|
+
time_to_sleep = self.config.MIN_DELAY_SECONDS - elapsed
|
|
75
|
+
time.sleep(time_to_sleep)
|
|
76
|
+
|
|
77
|
+
# Make network request
|
|
78
|
+
ui.debug(f"FETCH {url} ...")
|
|
79
|
+
self.last_request_time = time.time()
|
|
80
|
+
response = self.session.get(url, timeout=self.config.TIMEOUT)
|
|
81
|
+
response.encoding = "utf-8" # Ensure proper UTF-8 decoding for currency symbols
|
|
82
|
+
self.network_fetches += 1
|
|
83
|
+
|
|
84
|
+
# Only treat HTTP 200 as successful per Stage 1 requirement
|
|
85
|
+
if response.status_code != 200:
|
|
86
|
+
raise requests.HTTPError(
|
|
87
|
+
f"HTTP fetch failed for {url} with status code {response.status_code}",
|
|
88
|
+
response=response
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
html_content = response.text
|
|
92
|
+
size_bytes = len(html_content.encode("utf-8"))
|
|
93
|
+
ui.debug(f"FETCH OK {url} ({size_bytes} bytes)")
|
|
94
|
+
return html_content
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Generic site scraper.
|
|
2
|
+
|
|
3
|
+
Works on any website: BFS-crawls same-domain links (rate-limited and
|
|
4
|
+
robots.txt-checked) and extracts useful page metadata from each page.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections import deque
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from urllib.parse import urljoin, urlparse
|
|
10
|
+
|
|
11
|
+
from bs4 import BeautifulSoup
|
|
12
|
+
|
|
13
|
+
from polite_scraper import ui
|
|
14
|
+
from polite_scraper.config import Config
|
|
15
|
+
from polite_scraper.fetcher import Fetcher
|
|
16
|
+
from polite_scraper.utils import clean_text, make_absolute_url
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GenericExtractor:
|
|
20
|
+
"""Extracts page metadata from arbitrary HTML."""
|
|
21
|
+
|
|
22
|
+
HEADING_TAGS = ["h1", "h2", "h3", "h4", "h5", "h6"]
|
|
23
|
+
|
|
24
|
+
def extract_page(self, page_url: str, html_content: str) -> dict:
|
|
25
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
26
|
+
|
|
27
|
+
# Title: <title> falls back to first <h1>
|
|
28
|
+
title = clean_text(soup.title.get_text()) if soup.title else None
|
|
29
|
+
if not title:
|
|
30
|
+
h1 = soup.find("h1")
|
|
31
|
+
title = clean_text(h1.get_text()) if h1 else ""
|
|
32
|
+
|
|
33
|
+
# First H1
|
|
34
|
+
h1 = soup.find("h1")
|
|
35
|
+
h1_text = clean_text(h1.get_text()) if h1 else None
|
|
36
|
+
|
|
37
|
+
# Meta description
|
|
38
|
+
meta_desc = None
|
|
39
|
+
meta_tag = soup.find("meta", attrs={"name": "description"})
|
|
40
|
+
if meta_tag and meta_tag.get("content"):
|
|
41
|
+
meta_desc = clean_text(meta_tag["content"])
|
|
42
|
+
|
|
43
|
+
# Headings (up to 10, non-empty)
|
|
44
|
+
headings = []
|
|
45
|
+
for tag in soup.find_all(self.HEADING_TAGS):
|
|
46
|
+
text = clean_text(tag.get_text())
|
|
47
|
+
if text:
|
|
48
|
+
headings.append(text)
|
|
49
|
+
if len(headings) >= 10:
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
# Same-domain links
|
|
53
|
+
origin = urlparse(page_url)
|
|
54
|
+
same_links = []
|
|
55
|
+
for a in soup.find_all("a", href=True):
|
|
56
|
+
abs_url = make_absolute_url(page_url, a["href"])
|
|
57
|
+
parsed = urlparse(abs_url)
|
|
58
|
+
if parsed.netloc == origin.netloc and parsed.scheme in ("http", "https"):
|
|
59
|
+
same_links.append(abs_url.split("#")[0])
|
|
60
|
+
unique_links = list(dict.fromkeys(same_links))
|
|
61
|
+
|
|
62
|
+
# Word count of visible-ish text
|
|
63
|
+
for junk in soup(["script", "style", "noscript"]):
|
|
64
|
+
junk.decompose()
|
|
65
|
+
body_text = soup.get_text(separator=" ", strip=True)
|
|
66
|
+
word_count = len(body_text.split())
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"url": page_url,
|
|
70
|
+
"title": title or "",
|
|
71
|
+
"h1": h1_text,
|
|
72
|
+
"meta_description": meta_desc,
|
|
73
|
+
"headings": headings,
|
|
74
|
+
"same_domain_links": unique_links[:50],
|
|
75
|
+
"link_count": len(unique_links),
|
|
76
|
+
"image_count": len(soup.find_all("img")),
|
|
77
|
+
"word_count": word_count,
|
|
78
|
+
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GenericSiteScraper:
|
|
83
|
+
"""BFS crawler + extractor for arbitrary websites."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, config: Config, fetcher: Fetcher, extractor: GenericExtractor | None = None):
|
|
86
|
+
self.config = config
|
|
87
|
+
self.fetcher = fetcher
|
|
88
|
+
self.extractor = extractor or GenericExtractor()
|
|
89
|
+
self.robots_parser = None
|
|
90
|
+
|
|
91
|
+
def check_robots(self, start_url: str) -> bool:
|
|
92
|
+
"""Politeness check: consult robots.txt for our User-Agent. Returns True if allowed."""
|
|
93
|
+
base = f"{urlparse(start_url).scheme}://{urlparse(start_url).netloc}"
|
|
94
|
+
self.robots_parser = self.fetcher.fetch_robots_txt(base)
|
|
95
|
+
if self.robots_parser is None:
|
|
96
|
+
ui.info("robots.txt: not available (no rules) — proceeding politely anyway")
|
|
97
|
+
return True
|
|
98
|
+
allowed = self.robots_parser.can_fetch(self.config.USER_AGENT, start_url)
|
|
99
|
+
if allowed:
|
|
100
|
+
ui.success("robots.txt: crawl allowed for our User-Agent")
|
|
101
|
+
else:
|
|
102
|
+
ui.error("robots.txt disallows scraping this URL for our User-Agent — aborting")
|
|
103
|
+
return allowed
|
|
104
|
+
|
|
105
|
+
def scrape(self) -> tuple[list[dict], list[dict]]:
|
|
106
|
+
"""Returns (raw_page_records, error_records)."""
|
|
107
|
+
start_url = self.config.START_URL
|
|
108
|
+
max_pages = self.config.MAX_CATALOGUE_PAGES
|
|
109
|
+
max_links = self.config.MAX_LINKS_PER_PAGE
|
|
110
|
+
|
|
111
|
+
raw_records: list[dict] = []
|
|
112
|
+
error_records: list[dict] = []
|
|
113
|
+
|
|
114
|
+
queue: deque[str] = deque([start_url])
|
|
115
|
+
seen: set[str] = {start_url.split("#")[0]}
|
|
116
|
+
pages_fetched = 0
|
|
117
|
+
|
|
118
|
+
while queue and pages_fetched < max_pages:
|
|
119
|
+
url = queue.popleft()
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
html_content, _ = self.fetcher.fetch(url)
|
|
123
|
+
except Exception as ex:
|
|
124
|
+
error_records.append({"url": url, "reason": f"Fetch failed: {ex}"})
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
pages_fetched += 1
|
|
128
|
+
raw = self.extractor.extract_page(url, html_content)
|
|
129
|
+
|
|
130
|
+
if not raw["title"]:
|
|
131
|
+
error_records.append({
|
|
132
|
+
"url": url,
|
|
133
|
+
"reason": "No page title found (skipped record)",
|
|
134
|
+
"raw_record": raw,
|
|
135
|
+
})
|
|
136
|
+
else:
|
|
137
|
+
raw_records.append(raw)
|
|
138
|
+
|
|
139
|
+
# Enqueue new same-domain links (up to max_links per page)
|
|
140
|
+
for link in raw["same_domain_links"]:
|
|
141
|
+
if pages_fetched + len(queue) >= max_pages and len(queue) >= max_links:
|
|
142
|
+
break
|
|
143
|
+
if link not in seen:
|
|
144
|
+
seen.add(link)
|
|
145
|
+
if self._robots_allows(link):
|
|
146
|
+
queue.append(link)
|
|
147
|
+
|
|
148
|
+
return raw_records, error_records
|
|
149
|
+
|
|
150
|
+
def _robots_allows(self, url: str) -> bool:
|
|
151
|
+
if self.robots_parser is None:
|
|
152
|
+
return True
|
|
153
|
+
return self.robots_parser.can_fetch(self.config.USER_AGENT, url)
|