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.
@@ -0,0 +1,3 @@
1
+ """The Polite Scraper — a rate-limited, cache-first web scraper."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ from polite_scraper.main import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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
@@ -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)