#!/usr/bin/env python3 """ Anno 117 Web Scraper Scrapes pages listed in scraping.md, extracts game data, discovers new pages, and updates the scraping list. """ import argparse import re import json import time from pathlib import Path from urllib.parse import urljoin, urlparse import requests from bs4 import BeautifulSoup # Project paths PROJECT_ROOT = Path(__file__).parent.parent SCRAPING_MD = PROJECT_ROOT / "scraping.md" PROCESSED_MD = PROJECT_ROOT / "processed.md" OUTPUT_DIR = PROJECT_ROOT / "scraped_data" def read_scraping_md() -> tuple[list[str], list[str], str]: """ Read scraping.md and return (unchecked_urls, checked_urls, full_content). """ content = SCRAPING_MD.read_text(encoding="utf-8") unchecked = re.findall(r"^- \[ \] (https?://[^\s]+)", content, re.MULTILINE) checked = re.findall(r"^- \[x\] (https?://[^\s]+)", content, re.MULTILINE) return unchecked, checked, content def mark_url_as_done(url: str) -> None: """Mark a URL as scraped in scraping.md.""" content = SCRAPING_MD.read_text(encoding="utf-8") # Use regex to match the exact URL (followed by newline or end of string) # This prevents partial URL matches escaped_url = re.escape(url) pattern = rf"^- \[ \] {escaped_url}$" replacement = f"- [x] {url}" content = re.sub(pattern, replacement, content, flags=re.MULTILINE) SCRAPING_MD.write_text(content, encoding="utf-8") print(f"Marked as done: {url}") def add_new_urls(new_urls: list[str]) -> None: """Add newly discovered URLs to scraping.md if not already present.""" unchecked, checked, content = read_scraping_md() existing = set(unchecked + checked) urls_to_add = [url for url in new_urls if url not in existing] if urls_to_add: # Append new URLs at the end additions = "\n".join(f"- [ ] {url}" for url in urls_to_add) content = content.rstrip() + "\n" + additions + "\n" SCRAPING_MD.write_text(content, encoding="utf-8") print(f"Added {len(urls_to_add)} new URLs to scraping.md") for url in urls_to_add: print(f" + {url}") def extract_links(soup: BeautifulSoup, base_url: str) -> list[str]: """Extract relevant anno.land links from the page.""" links = [] base_domain = urlparse(base_url).netloc for a in soup.find_all("a", href=True): href = a["href"] full_url = urljoin(base_url, href) parsed = urlparse(full_url) # Only keep links from the same domain and relevant paths if parsed.netloc == base_domain: # Filter for Anno 117 related content if any(keyword in parsed.path for keyword in [ "anno-117", "goods", "buildings", "specialists", "skills", "guides", "tools", "datenbank" ]): # Clean the URL (remove fragments and query params for deduplication) clean_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" if clean_url.endswith("/"): clean_url = clean_url.rstrip("/") + "/" else: clean_url = clean_url + "/" if not parsed.path.endswith((".html", ".php")) else clean_url if clean_url not in links: links.append(clean_url) return links def scrape_page(url: str) -> dict: """ Scrape a single page and extract relevant content. Returns a dict with the scraped data. """ print(f"Scraping: {url}") headers = { "User-Agent": "Anno117DocBot/1.0 (One-time scrape for AI agent docs; https://git.blijnder.com/jivanrij/anno-117-docs; Contact: jivanrij@gmail.com)", "X-Bot-Purpose": "Creating a markdown file with Anno 117 data for my AI agent (personal use, non-commercial). This way the agent doesn't need to query your website multiple times - each page is only fetched once. Result: https://git.blijnder.com/jivanrij/anno-117-docs/raw/branch/main/docs.md - Thank you for anno.land!", "From": "jivanrij@gmail.com", } response = requests.get(url, headers=headers, timeout=30) response.raise_for_status() soup = BeautifulSoup(response.text, "html.parser") # Extract page title title = soup.find("title") title_text = title.get_text(strip=True) if title else "Unknown" # Extract main content (adjust selectors based on site structure) main_content = soup.find("main") or soup.find("article") or soup.find("div", class_="content") # Extract all text content if main_content: text_content = main_content.get_text(separator="\n", strip=True) else: # Fallback to body body = soup.find("body") text_content = body.get_text(separator="\n", strip=True) if body else "" # Extract tables (common for game data) tables = [] for table in soup.find_all("table"): table_data = [] for row in table.find_all("tr"): cells = [cell.get_text(strip=True) for cell in row.find_all(["td", "th"])] if cells: table_data.append(cells) if table_data: tables.append(table_data) # Extract images (for item/building icons) images = [] for img in soup.find_all("img"): src = img.get("src", "") alt = img.get("alt", "") if src and any(keyword in src.lower() for keyword in ["icon", "item", "building", "good"]): images.append({"src": urljoin(url, src), "alt": alt}) # Extract discovered links discovered_links = extract_links(soup, url) # Extract all images (not just filtered ones) all_images = [] for img in soup.find_all("img"): src = img.get("src", "") alt = img.get("alt", "") if src: all_images.append({"src": urljoin(url, src), "alt": alt}) return { "url": url, "title": title_text, "text_content": text_content, # Full content for Claude to process "tables": tables, "images": images, # Filtered icons "all_images": all_images, # All images on page "discovered_links": discovered_links, "full_html_length": len(response.text) } def save_scraped_data(data: dict, url: str) -> Path: """Save scraped data to a JSON file.""" OUTPUT_DIR.mkdir(exist_ok=True) # Create filename from URL parsed = urlparse(url) path_parts = [p for p in parsed.path.split("/") if p] filename = "_".join(path_parts) if path_parts else "index" filename = re.sub(r"[^\w\-]", "_", filename) output_file = OUTPUT_DIR / f"{filename}.json" with open(output_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) print(f"Saved to: {output_file}") return output_file def add_pending_file(json_filename: str) -> None: """Add a JSON file to the pending section of processed.md.""" content = PROCESSED_MD.read_text(encoding="utf-8") # Check if file is already listed if json_filename in content: return # Add to pending section pending_marker = "## Pending Files" if pending_marker in content: content = content.replace( pending_marker, f"{pending_marker}\n- [ ] {json_filename}" ) PROCESSED_MD.write_text(content, encoding="utf-8") print(f"Added to processed.md pending: {json_filename}") def scrape_one(url: str) -> bool: """Scrape a single URL. Returns True on success, False on failure.""" try: # Scrape the page data = scrape_page(url) # Save the data output_file = save_scraped_data(data, url) # Track in processed.md as pending add_pending_file(output_file.name) # Print summary print(f"\n--- Summary ---") print(f"Title: {data['title']}") print(f"Content length: {len(data['text_content'])} chars") print(f"Tables found: {len(data['tables'])}") print(f"Images found: {len(data['images'])}") print(f"Links discovered: {len(data['discovered_links'])}") # Add discovered links to scraping.md if data["discovered_links"]: add_new_urls(data["discovered_links"]) # Mark this URL as done mark_url_as_done(url) print(f"\nSuccessfully scraped: {url}") return True except requests.RequestException as e: print(f"Error scraping {url}: {e}") return False def main(): """Main entry point.""" parser = argparse.ArgumentParser(description="Scrape Anno 117 pages from scraping.md") parser.add_argument( "-n", "--count", type=int, default=1, help="Number of URLs to scrape (default: 1)" ) args = parser.parse_args() unchecked, checked, _ = read_scraping_md() if not unchecked: print("No unchecked URLs found in scraping.md") return print(f"Found {len(unchecked)} unchecked URLs") print(f"Already scraped: {len(checked)} URLs") print(f"Will scrape: {min(args.count, len(unchecked))} URLs\n") scraped = 0 failed = 0 urls_to_scrape = unchecked[:args.count] total = len(urls_to_scrape) for i, url in enumerate(urls_to_scrape): print(f"[{i + 1}/{total}] ", end="") if scrape_one(url): scraped += 1 else: failed += 1 print() # Be nice to the server - wait 3 seconds between requests if i < total - 1: print("Waiting 3 seconds...") time.sleep(3) print(f"Done! Scraped: {scraped}, Failed: {failed}") if __name__ == "__main__": main()