# /// script # requires-python = ">=3.12" # dependencies = [ # "requests", # "beautifulsoup4", # ] # /// """ Generic scraper. Saves all files flat in ./scraped_/ with sanitized filenames. Writes structured error log to ./scraped_/scrape_errors.jsonl. Usage: uv run scraper.py https://www.logting.fo uv run scraper.py https://taks.fo """ import hashlib import json import sys import time from collections import deque from datetime import datetime, timezone from pathlib import Path from urllib.parse import urljoin, urlparse import requests from bs4 import BeautifulSoup DELAY = 0.1 TIMEOUT = 60 MAX_RETRIES = 3 RETRY_BACKOFF = 5.0 USER_AGENT = "web-scraper/1.0 (research)" CONTENT_TYPE_EXT = { "text/html": ".html", "application/pdf": ".pdf", "application/json": ".json", "application/xml": ".xml", "text/xml": ".xml", "text/plain": ".txt", "text/css": ".css", "application/javascript": ".js", "text/javascript": ".js", "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/svg+xml": ".svg", "image/webp": ".webp", "application/vnd.ms-excel": ".xls", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", "application/msword": ".doc", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", "application/zip": ".zip", "application/octet-stream": ".bin", } SEEN: set[str] = set() def log_error( log_path: Path, url: str, error_type: str, message: str, status_code: int | None = None, ) -> None: entry = { "timestamp": datetime.now(timezone.utc).isoformat(), "url": url, "error_type": error_type, "message": message, "status_code": status_code, } with open(log_path, "a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") print(f" LOGGED ERROR [{error_type}]: {message}") def derive_base_domain(url: str) -> str: parsed = urlparse(url) hostname = parsed.hostname if not hostname: print(f"Could not parse hostname from {url}") sys.exit(1) parts = hostname.split(".") if len(parts) >= 2: return ".".join(parts[-2:]) return hostname def is_internal(url: str, base_domain: str) -> bool: parsed = urlparse(url) hostname = parsed.hostname if not hostname: return False return hostname == base_domain or hostname.endswith(f".{base_domain}") def normalize_url(url: str) -> str: url = url.split("#")[0].rstrip("?&") parsed = urlparse(url) scheme = parsed.scheme.lower() netloc = parsed.netloc.lower() path = parsed.path query = parsed.query if query: return f"{scheme}://{netloc}{path}?{query}" return f"{scheme}://{netloc}{path}" def get_extension(url: str, content_type: str) -> str: path_suffix = Path(urlparse(url).path).suffix.lower() if path_suffix: return path_suffix ct = content_type.lower().split(";")[0].strip() if ct in CONTENT_TYPE_EXT: return CONTENT_TYPE_EXT[ct] return ".bin" def url_to_filename(url: str, content_type: str = "") -> str: parsed = urlparse(url) if len(url) > 200: hash_part = hashlib.sha256(url.encode()).hexdigest()[:16] ext = get_extension(url, content_type) return f"{hash_part}{ext}" host_prefix = parsed.netloc.replace(".", "_").replace(":", "_") path = parsed.path.lstrip("/") or "index" # Split extension from path so query hash goes before it path_obj = Path(path) stem = path_obj.stem ext = path_obj.suffix.lower() if not ext: ext = get_extension(url, content_type) query_suffix = "" if parsed.query: query_hash = hashlib.md5(parsed.query.encode()).hexdigest()[:8] query_suffix = f"_{query_hash}" filename = f"{host_prefix}_{stem}{query_suffix}{ext}" filename = "".join(c if c.isalnum() or c in "._-" else "_" for c in filename) return filename def save(output_dir: Path, url: str, content: bytes, content_type: str = "") -> None: filename = url_to_filename(url, content_type) filepath = output_dir / filename filepath.write_bytes(content) print(f" saved -> {filepath.name}") def extract_links(base_url: str, html: str, base_domain: str) -> set[str]: soup = BeautifulSoup(html, "html.parser") links: set[str] = set() for tag in soup.find_all("a", href=True): href = tag["href"].strip() if not href or href.startswith(("mailto:", "tel:", "javascript:")): continue full_url = urljoin(base_url, href) full_url = normalize_url(full_url) if is_internal(full_url, base_domain): links.add(full_url) return links def fetch(url: str, log_path: Path) -> requests.Response | None: for attempt in range(1, MAX_RETRIES + 1): try: return requests.get( url, timeout=TIMEOUT, headers={"User-Agent": USER_AGENT}, allow_redirects=True, ) except requests.Timeout: if attempt < MAX_RETRIES: wait = RETRY_BACKOFF * attempt print(f" timeout, retry {attempt}/{MAX_RETRIES} in {wait}s...") time.sleep(wait) else: log_error( log_path, url, "timeout", f"Timed out after {MAX_RETRIES} retries ({TIMEOUT}s each)", ) return None except requests.ConnectionError as e: if attempt < MAX_RETRIES: wait = RETRY_BACKOFF * attempt print( f" connection error, retry {attempt}/{MAX_RETRIES} in {wait}s..." ) time.sleep(wait) else: log_error(log_path, url, "connection_error", str(e)) return None except requests.RequestException as e: log_error(log_path, url, "request_error", str(e)) return None return None def main() -> None: if len(sys.argv) < 2: print("Usage: uv run scraper.py ") print("Example: uv run scraper.py https://www.logting.fo") sys.exit(1) start_url = sys.argv[1] base_domain = derive_base_domain(start_url) output_dir = Path(f"./scraped_{base_domain.replace('.', '_')}") log_dir = output_dir / "logs" log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / "scrape_errors.jsonl" output_dir.mkdir(parents=True, exist_ok=True) log_path.unlink(missing_ok=True) queue: deque[str] = deque([start_url]) count = 0 error_count = 0 print(f"Scraping: {start_url}") print(f"Base domain: {base_domain}") print(f"Output dir: {output_dir}") print() while queue: url = normalize_url(queue.popleft()) if url in SEEN: continue SEEN.add(url) print(f"[{count}] fetching: {url}") resp = fetch(url, log_path) if resp is None: error_count += 1 time.sleep(DELAY) continue if resp.status_code != 200: log_error( log_path, url, "http_error", f"HTTP {resp.status_code}", resp.status_code, ) error_count += 1 time.sleep(DELAY) continue final_url = normalize_url(resp.url) content_type = resp.headers.get("Content-Type", "") if "text/html" in content_type: try: html = resp.text save(output_dir, final_url, html.encode("utf-8"), content_type) links = extract_links(final_url, html, base_domain) new_links = [u for u in links if u not in SEEN] queue.extend(new_links) print(f" found {len(links)} links ({len(new_links)} new)") except Exception as e: log_error(log_path, final_url, "parse_error", str(e)) error_count += 1 else: try: save(output_dir, final_url, resp.content, content_type) print(f" binary: {content_type}") except Exception as e: log_error(log_path, final_url, "save_error", str(e)) error_count += 1 count += 1 time.sleep(DELAY) print( f"\nDone. Fetched {count} URLs ({len(SEEN)} seen total). Errors: {error_count}." ) print(f"Error log: {log_path}") if __name__ == "__main__": main()