#!/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
-
-