#!/usr/bin/env python3 # This is free software for the public good of a permacomputer hosted at # permacomputer.com, an always-on computer by the people, for the people. # One which is durable, easy to repair, & distributed like tap water # for machine learning intelligence. # # The permacomputer is community-owned infrastructure optimized around # four values: # # TRUTH First principles, math & science, open source code freely distributed # FREEDOM Voluntary partnerships, freedom from tyranny & corporate control # HARMONY Minimal waste, self-renewing systems with diverse thriving connections # LOVE Be yourself without hurting others, cooperation through natural law # # This software contributes to that vision by archiving the web, preserving digital knowledge before it disappears. # Code is seeds to sprout on any abandoned technology. """ Async Web Fetcher for Discord Bot Ethical web scraping with robots.txt compliance, async/await compatible Supports multiple crawl modes: - text: Extract text content (default, current behavior) - images: Collect images only - videos: Collect videos only - media: Collect all media (images + videos + audio) - all: Uber crawl - text + all media, full domain slurp # inputs > process > outputs # The universe is logical. - The Sign Maker """ import os import io import hashlib import logging import aiohttp import asyncio import re from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import Optional, Dict, List, Tuple, Set, Any from urllib.parse import urljoin from urllib.robotparser import RobotFileParser from bs4 import BeautifulSoup import time from miniuri import Uri class CrawlMode(Enum): """Crawl modes for different content types.""" TEXT = "text" # Extract text content (default) IMAGES = "images" # Collect images only VIDEOS = "videos" # Collect videos only MEDIA = "media" # All media (images + videos + audio) ALL = "all" # Uber crawl: text + all media # File extensions by media type IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico', '.tiff', '.tif', '.avif'} VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v', '.ogv', '.flv', '.wmv'} AUDIO_EXTENSIONS = {'.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac', '.wma'} # Code file extensions CODE_EXTENSIONS = { '.py', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', # Python, JavaScript, TypeScript '.rs', '.go', '.rb', '.php', '.pl', '.pm', # Rust, Go, Ruby, PHP, Perl '.java', '.kt', '.kts', '.scala', '.groovy', # JVM languages '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', # C/C++ '.cs', '.fs', '.fsx', # .NET '.swift', '.m', '.mm', # Apple '.lua', '.r', '.R', '.jl', # Lua, R, Julia '.sh', '.bash', '.zsh', '.fish', '.ps1', # Shell '.sql', '.graphql', '.gql', # Query languages '.yaml', '.yml', '.toml', '.json', '.xml', # Config '.md', '.rst', '.txt', # Docs '.zig', '.nim', '.d', '.v', # Modern systems langs '.ex', '.exs', '.erl', '.hrl', # Erlang/Elixir '.clj', '.cljs', '.cljc', '.edn', # Clojure '.hs', '.lhs', # Haskell '.ml', '.mli', '.re', '.rei', # OCaml/ReasonML '.lisp', '.cl', '.el', '.scm', '.rkt', # Lisps '.f90', '.f95', '.f03', '.for', # Fortran '.asm', '.s', # Assembly '.cob', '.cbl', # COBOL '.pro', # Prolog '.tcl', # Tcl '.dart', # Dart '.raku', '.p6', # Raku '.cr', # Crystal '.vue', '.svelte', # Frontend frameworks '.tf', '.hcl', # Terraform '.dockerfile', '.makefile', # Build files } # Font file extensions FONT_EXTENSIONS = {'.woff', '.woff2', '.ttf', '.otf', '.eot', '.sfnt'} # Style file extensions STYLE_EXTENSIONS = {'.css', '.scss', '.sass', '.less', '.styl'} # MIME types by media type IMAGE_MIME_PREFIXES = ('image/',) VIDEO_MIME_PREFIXES = ('video/',) AUDIO_MIME_PREFIXES = ('audio/',) CODE_MIME_TYPES = { 'text/x-python', 'application/x-python', 'text/x-python-script', 'text/javascript', 'application/javascript', 'application/x-javascript', 'text/typescript', 'application/typescript', 'text/x-rust', 'text/x-go', 'text/x-ruby', 'application/x-ruby', 'text/x-java-source', 'text/x-kotlin', 'text/x-scala', 'text/x-c', 'text/x-c++', 'text/x-csrc', 'text/x-c++src', 'text/x-csharp', 'text/x-fsharp', 'text/x-swift', 'text/x-objective-c', 'text/x-lua', 'text/x-r', 'text/x-julia', 'text/x-shellscript', 'application/x-sh', 'text/x-bash', 'application/sql', 'application/graphql', 'application/json', 'application/xml', 'text/xml', 'text/yaml', 'application/x-yaml', 'text/x-yaml', 'text/markdown', 'text/x-markdown', 'text/plain', # Often used for code } FONT_MIME_TYPES = { 'font/woff', 'font/woff2', 'font/ttf', 'font/otf', 'font/sfnt', 'application/font-woff', 'application/font-woff2', 'application/x-font-ttf', 'application/x-font-otf', 'application/vnd.ms-fontobject', } STYLE_MIME_TYPES = {'text/css', 'text/x-scss', 'text/x-sass', 'text/x-less'} @dataclass class MediaItem: """Represents a discovered media item.""" url: str source_page: str media_type: str # 'image', 'video', 'audio' mime_type: Optional[str] = None md5_hash: Optional[str] = None file_size: Optional[int] = None alt_text: Optional[str] = None title: Optional[str] = None width: Optional[int] = None height: Optional[int] = None discovered_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) class MediaMetadata: """ Accumulates metadata from all sources without clobbering. "Never clobber, always append" - collects ALL metadata from: - img.alt, img.title - a.title, a.text (link text) - figcaption - nearby headings - page title/h1 Produces a combined searchable_text for full-text search. """ def __init__(self): self.titles: List[str] = [] self.alt_texts: List[str] = [] self.descriptions: List[str] = [] self.captions: List[str] = [] self.headings: List[str] = [] self.link_texts: List[str] = [] self.link_titles: List[str] = [] def _add_unique(self, lst: List[str], value: str, max_len: int = 500) -> None: """Add value to list if non-empty and not duplicate.""" if value and value.strip(): clean = value.strip()[:max_len] if clean not in lst: lst.append(clean) def add_img_alt(self, alt: str) -> None: """Add img alt attribute.""" self._add_unique(self.alt_texts, alt) def add_img_title(self, title: str) -> None: """Add img title attribute.""" self._add_unique(self.titles, title) def add_link_title(self, title: str) -> None: """Add attribute.""" self._add_unique(self.link_titles, title) def add_link_text(self, text: str) -> None: """Add inner text.""" self._add_unique(self.link_texts, text) def add_figcaption(self, caption: str) -> None: """Add figcaption text.""" self._add_unique(self.captions, caption) def add_heading(self, heading: str) -> None: """Add nearby heading (h1-h6).""" self._add_unique(self.headings, heading) def add_description(self, desc: str) -> None: """Add description (og:description, meta description, etc.).""" self._add_unique(self.descriptions, desc) def add_page_title(self, title: str) -> None: """Add page title.""" self._add_unique(self.titles, title) def get_best_title(self) -> Optional[str]: """Get best title for display (first non-empty).""" for lst in [self.titles, self.alt_texts, self.link_titles, self.captions, self.link_texts, self.headings]: if lst: return lst[0] return None def get_best_alt(self) -> Optional[str]: """Get best alt text for accessibility.""" if self.alt_texts: return self.alt_texts[0] return self.get_best_title() def to_searchable_text(self) -> str: """ Combine ALL collected metadata into searchable text. This enables finding images by ANY associated text: - "find images of cats" matches img alt="cute cat" - "find images from blog post about python" matches page content """ all_parts = [] # Dedupe while preserving order seen = set() for lst in [self.titles, self.alt_texts, self.descriptions, self.captions, self.headings, self.link_texts, self.link_titles]: for item in lst: if item and item not in seen: all_parts.append(item) seen.add(item) return ' | '.join(all_parts) def to_dict(self) -> Dict[str, Any]: """Export all collected metadata as dict.""" return { 'titles': self.titles, 'alt_texts': self.alt_texts, 'descriptions': self.descriptions, 'captions': self.captions, 'headings': self.headings, 'link_texts': self.link_texts, 'link_titles': self.link_titles, 'searchable_text': self.to_searchable_text(), 'best_title': self.get_best_title(), 'best_alt': self.get_best_alt(), } # PDF text extraction try: from pypdf import PdfReader PDF_SUPPORT = True except ImportError: PDF_SUPPORT = False logger = logging.getLogger(__name__) # Default crawl delay DEFAULT_CRAWL_DELAY = 2.0 # Global last error storage (for error reporting without log parsing) # Format: {'type': 'robots_txt'|'http_error'|'timeout'|'dns'|'ssl'|'unknown', 'details': str, 'url': str} LAST_FETCH_ERROR = None def get_last_fetch_error() -> Optional[Dict]: """Get the last fetch error, if any.""" return LAST_FETCH_ERROR def strip_uri_fragment(uri_str: str) -> str: """ Strip the fragment (#anchor) from a URI using miniuri. Args: uri_str: URI that may contain a fragment Returns: URI without fragment """ if not uri_str: return uri_str uri = Uri(uri_str) # Reconstruct URI without fragment result = f"{uri.scheme}://{uri.authority}{uri.path or ''}" if uri.query: result += f"?{uri.query}" return result def normalize_link(link) -> Tuple[str, str]: """ Extract URI and anchor text from a link, stripping fragments. Args: link: Either a dict with 'url' and optional 'anchor_text', or a string URI Returns: Tuple of (normalized_uri, anchor_text) """ if isinstance(link, dict): uri = strip_uri_fragment(link.get('url', '')) anchor_text = link.get('anchor_text', '') else: uri = strip_uri_fragment(link) anchor_text = '' return uri, anchor_text # Alias for backwards compatibility strip_url_fragment = strip_uri_fragment def extract_text_from_pdf(pdf_bytes: bytes) -> Optional[str]: """ Extract text from PDF binary data. Args: pdf_bytes: Raw PDF file bytes Returns: Extracted text or None if extraction fails """ if not PDF_SUPPORT: logger.warning("PDF support not available (pypdf not installed)") return None try: reader = PdfReader(io.BytesIO(pdf_bytes)) text_parts = [] for page in reader.pages: page_text = page.extract_text() if page_text: text_parts.append(page_text) if text_parts: return "\n\n".join(text_parts) return None except Exception as e: logger.error(f"Failed to extract text from PDF: {e}") return None def clear_last_fetch_error(): """Clear the last fetch error.""" global LAST_FETCH_ERROR LAST_FETCH_ERROR = None async def fetch_robots_txt(url: str, user_agent: str = "uncloseai.com/1.42") -> Optional[str]: """ Fetch raw robots.txt content for analysis and error reporting. Args: url: Any URL on the domain (robots.txt will be fetched from root) user_agent: User agent string Returns: Raw robots.txt content or None if not available """ parsed = Uri(url) robots_url = f"{parsed.scheme}://{parsed.hostname}/robots.txt" try: async with aiohttp.ClientSession() as session: async with session.get( robots_url, headers={"User-Agent": user_agent}, timeout=aiohttp.ClientTimeout(total=5) ) as response: if response.status == 200: return await response.text() except Exception as e: logger.warning(f"Could not fetch robots.txt for error analysis: {e}") return None def analyze_robots_txt_blocking(robots_txt: str, blocked_url: str, user_agent: str = "uncloseai.com/1.42") -> str: """ Analyze robots.txt to explain why a URL is blocked. Args: robots_txt: Raw robots.txt content blocked_url: The URL that was blocked user_agent: User agent to check against Returns: Human-readable explanation of the blocking rule """ parsed = Uri(blocked_url) path = parsed.path or "/" # Parse robots.txt manually for detailed analysis lines = robots_txt.strip().split('\n') current_agent = None blocking_rule = None all_rules = [] for line in lines: line = line.strip() if not line or line.startswith('#'): continue if ':' in line: key, value = line.split(':', 1) key = key.strip().lower() value = value.strip() if key == 'user-agent': current_agent = value elif key == 'disallow' and current_agent: # Check if this rule applies to us agent_matches = ( current_agent == '*' or user_agent.lower().startswith(current_agent.lower()) or current_agent.lower() in user_agent.lower() ) if agent_matches: all_rules.append(f"Disallow: {value}") # Check if this rule blocks our path if value and path.startswith(value): blocking_rule = f"Disallow: {value}" if blocking_rule: result = f"**Blocked by rule:** `{blocking_rule}`\n" result += f"**Path requested:** `{path}`\n" result += f"**User-Agent:** `{user_agent}`\n\n" # Show relevant rules if all_rules: result += "**Applicable rules:**\n" for rule in all_rules[:10]: # Limit to first 10 result += f" • `{rule}`\n" return result elif all_rules: return f"Blocked by robots.txt (exact rule unclear). Rules found:\n" + "\n".join(f" • `{r}`" for r in all_rules[:10]) else: return "Blocked by robots.txt (no matching disallow rule found - may be a catch-all)" def get_media_type_from_extension(url: str) -> Optional[str]: """ Determine media type from URL extension. Returns: 'image', 'video', 'audio', 'code', 'font', 'style', or None """ parsed = Uri(url) if not parsed.path: return None path = parsed.path.lower() # Check for extension match for ext in IMAGE_EXTENSIONS: if path.endswith(ext): return 'image' for ext in VIDEO_EXTENSIONS: if path.endswith(ext): return 'video' for ext in AUDIO_EXTENSIONS: if path.endswith(ext): return 'audio' for ext in CODE_EXTENSIONS: if path.endswith(ext): return 'code' for ext in FONT_EXTENSIONS: if path.endswith(ext): return 'font' for ext in STYLE_EXTENSIONS: if path.endswith(ext): return 'style' return None def get_media_type_from_mime(mime_type: str) -> Optional[str]: """ Determine media type from MIME type. Returns: 'image', 'video', 'audio', 'code', 'font', 'style', or None """ if not mime_type: return None mime_lower = mime_type.lower() # Check prefixes first if mime_lower.startswith(IMAGE_MIME_PREFIXES): return 'image' if mime_lower.startswith(VIDEO_MIME_PREFIXES): return 'video' if mime_lower.startswith(AUDIO_MIME_PREFIXES): return 'audio' # Check exact matches for code/font/style if mime_lower in CODE_MIME_TYPES: return 'code' if mime_lower in FONT_MIME_TYPES: return 'font' if mime_lower in STYLE_MIME_TYPES: return 'style' return None def extract_media_from_html(html: str, base_url: str, mode: CrawlMode = CrawlMode.MEDIA) -> List[Dict[str, Any]]: """ Extract media URLs from HTML content. Extracts from: - and - - pointing to media files - data-src, data-lazy-src (lazy loading) - Open Graph and Twitter card meta tags Args: html: HTML content base_url: Base URL for resolving relative URLs mode: CrawlMode to filter what types to extract Returns: List of dicts with 'url', 'media_type', 'alt_text', 'title', etc. """ media_items = [] seen_urls: Set[str] = set() # Determine which types to collect based on mode collect_images = mode in (CrawlMode.IMAGES, CrawlMode.MEDIA, CrawlMode.ALL) collect_videos = mode in (CrawlMode.VIDEOS, CrawlMode.MEDIA, CrawlMode.ALL) collect_audio = mode in (CrawlMode.MEDIA, CrawlMode.ALL) soup = BeautifulSoup(html, "html.parser") # Extract page-level metadata for inference page_title = '' title_tag = soup.find('title') if title_tag: page_title = title_tag.get_text(strip=True) # Get h1 as fallback title h1_tag = soup.find('h1') page_h1 = h1_tag.get_text(strip=True) if h1_tag else '' # Extract meta description and keywords for searchability page_description = '' page_keywords = '' for meta in soup.find_all('meta'): name = (meta.get('name') or meta.get('property') or '').lower() content = meta.get('content', '') if name == 'description' or name == 'og:description': page_description = content[:500] # Limit length elif name == 'keywords': page_keywords = content[:500] # Extract page content (body text) for full-text search # This enables blog images to be searchable by post content # IMPORTANT: Parse a fresh copy so we don't remove header/nav images from the soup # that will be used for media extraction below page_content = '' body = soup.find('body') if body: body_soup = BeautifulSoup(str(body), 'html.parser') # Remove script, style, nav, footer elements from the COPY only for tag in body_soup.find_all(['script', 'style', 'nav', 'footer', 'header', 'aside']): tag.decompose() page_content = body_soup.get_text(separator=' ', strip=True)[:10000] # Limit to 10k chars def get_context_for_element(element) -> dict: """Extract contextual metadata from surrounding HTML elements.""" context = { 'page_title': page_title, 'page_h1': page_h1, 'figure_caption': None, 'nearby_heading': None, 'link_text': None, 'link_title': None, # attribute } # Check if inside a
with
figure = element.find_parent('figure') if figure: figcaption = figure.find('figcaption') if figcaption: context['figure_caption'] = figcaption.get_text(strip=True)[:200] # Check if inside an tag with text and/or title link = element.find_parent('a') if link: link_text = link.get_text(strip=True) if link_text and link_text != element.get('alt', ''): context['link_text'] = link_text[:200] # Also extract title attribute from tag (tooltip text) link_title = link.get('title', '').strip() if link_title: context['link_title'] = link_title[:200] # Find nearest heading (h1-h6) before this element for heading_tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']: # Look for preceding siblings and parents for prev in element.find_all_previous(heading_tag, limit=3): heading_text = prev.get_text(strip=True) if heading_text: context['nearby_heading'] = heading_text[:200] break if context['nearby_heading']: break return context def infer_title(alt_text: str, title: str, context: dict) -> str: """Infer best title from available metadata.""" # Priority: explicit title > alt text > link title > figure caption > link text > nearby heading > page h1 > page title if title and title.strip(): return title.strip() if alt_text and alt_text.strip() and len(alt_text) > 3: return alt_text.strip() if context.get('link_title'): # attribute return context['link_title'] if context.get('figure_caption'): return context['figure_caption'] if context.get('link_text'): return context['link_text'] if context.get('nearby_heading'): return context['nearby_heading'] if context.get('page_h1'): return context['page_h1'] if context.get('page_title'): return context['page_title'] return None def add_media(url: str, media_type: str, alt_text: str = None, title: str = None, width: int = None, height: int = None, element=None, detail_page_url: str = None): """Helper to add media item if not already seen.""" if not url or url in seen_urls: return if url.startswith('data:'): # Skip data URIs for now return # Resolve relative URLs absolute_url = urljoin(base_url, url) if absolute_url in seen_urls: return seen_urls.add(absolute_url) # Get context from surrounding elements context = get_context_for_element(element) if element else {'page_title': page_title, 'page_h1': page_h1} # Build metadata accumulator - "never clobber, always append" metadata = MediaMetadata() # Add from img element if alt_text: metadata.add_img_alt(alt_text) if title: metadata.add_img_title(title) # Add from context if context.get('figure_caption'): metadata.add_figcaption(context['figure_caption']) if context.get('link_text'): metadata.add_link_text(context['link_text']) if context.get('link_title'): metadata.add_link_title(context['link_title']) if context.get('nearby_heading'): metadata.add_heading(context['nearby_heading']) if context.get('page_title'): metadata.add_page_title(context['page_title']) if context.get('page_h1'): metadata.add_heading(context['page_h1']) # Add page-level metadata if page_description: metadata.add_description(page_description) # Get best values for backward compatibility best_title = metadata.get_best_title() best_alt = metadata.get_best_alt() # Check if this image is wrapped in an tag pointing to a detail page # (Pinterest-style galleries where thumbnail links to detail page with canonical image) resolved_detail_url = None if element and not detail_page_url: parent_link = element.find_parent('a', href=True) if parent_link: href = parent_link.get('href', '') # Only consider internal links (not direct image links) if href and not get_media_type_from_extension(href): resolved_detail_url = urljoin(base_url, href) # Only track same-domain detail pages base_domain = Uri(base_url).hostname detail_domain = Uri(resolved_detail_url).hostname if base_domain != detail_domain: resolved_detail_url = None elif detail_page_url: resolved_detail_url = urljoin(base_url, detail_page_url) media_items.append({ 'url': absolute_url, 'media_type': media_type, 'alt_text': best_alt, 'title': best_title, 'width': width, 'height': height, 'source_page': base_url, 'page_title': page_title, 'page_description': page_description, 'page_keywords': page_keywords, 'page_content': page_content, # Full text for blog post searchability 'figure_caption': context.get('figure_caption'), 'nearby_heading': context.get('nearby_heading'), 'link_text': context.get('link_text'), 'link_title': context.get('link_title'), # attribute 'detail_page_url': resolved_detail_url, # URL to fetch for canonical image + richer metadata # Accumulated metadata for full-text search 'searchable_text': metadata.to_searchable_text(), 'metadata': metadata.to_dict(), # Full breakdown for debugging/analysis }) # Extract from tags if collect_images: for img in soup.find_all('img'): src = img.get('src') alt = img.get('alt', '') title = img.get('title', '') width = img.get('width') height = img.get('height') if src: add_media(src, 'image', alt, title, int(width) if width and width.isdigit() else None, int(height) if height and height.isdigit() else None, element=img) # Handle srcset srcset = img.get('srcset', '') for src_entry in srcset.split(','): src_entry = src_entry.strip() if src_entry: src_url = src_entry.split()[0] # Get URL part, ignore size descriptor add_media(src_url, 'image', alt, title, element=img) # Lazy loading attributes for attr in ['data-src', 'data-lazy-src', 'data-original', 'data-srcset']: lazy_src = img.get(attr) if lazy_src: if attr.endswith('srcset'): for src_entry in lazy_src.split(','): src_entry = src_entry.strip() if src_entry: add_media(src_entry.split()[0], 'image', alt, title, element=img) else: add_media(lazy_src, 'image', alt, title, element=img) # Extract from tags if collect_images: for picture in soup.find_all('picture'): for source in picture.find_all('source'): srcset = source.get('srcset', '') for src_entry in srcset.split(','): src_entry = src_entry.strip() if src_entry: add_media(src_entry.split()[0], 'image') # Extract from pointing to media/code/font/style files for a in soup.find_all('a', href=True): href = a['href'] media_type = get_media_type_from_extension(href) if media_type: if (media_type == 'image' and collect_images) or \ (media_type == 'video' and collect_videos) or \ (media_type == 'audio' and collect_audio) or \ media_type in ('code', 'font', 'style'): # Always collect code/font/style add_media(href, media_type, alt_text=a.get_text(strip=True)[:100]) # Extract from tags for stylesheets and fonts for link in soup.find_all('link', href=True): href = link.get('href') rel = link.get('rel', []) as_attr = link.get('as', '') if 'stylesheet' in rel: add_media(href, 'style', alt_text='stylesheet') elif 'preload' in rel and as_attr == 'font': add_media(href, 'font', alt_text='preload font') elif 'preload' in rel and as_attr == 'style': add_media(href, 'style', alt_text='preload style') else: # Check by extension media_type = get_media_type_from_extension(href) if media_type in ('font', 'style'): add_media(href, media_type, alt_text=f'link {media_type}') # Extract from