- Add LICENSE file (public domain, permacomputer values)
- Add license headers to all 27 Python source files
- Restore full about_ch9_p2 with "terminated 🟣" content
- Translate about_ch9_p2 to all 26 languages
The permacomputer is community-owned infrastructure optimized around:
TRUTH, FREEDOM, HARMONY, LOVE
2773 lines
117 KiB
Python
2773 lines
117 KiB
Python
#!/usr/bin/env python3
|
|
# PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
|
|
#
|
|
# This is free public domain 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, and 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
|
|
#
|
|
# Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
|
|
# software, either in source code form or as a compiled binary, for any purpose,
|
|
# commercial or non-commercial, and by any means.
|
|
#
|
|
# NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
|
|
#
|
|
# Copyright 2025 TimeHexOn & foxhop & russell@unturf
|
|
# https://git.unturf.com/engineering/unturf/pig.py
|
|
|
|
# Side quest 10/21: robots.txt is just a suggestion. A polite one.
|
|
"""
|
|
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 <a title="..."> attribute."""
|
|
self._add_unique(self.link_titles, title)
|
|
|
|
def add_link_text(self, text: str) -> None:
|
|
"""Add <a> 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:
|
|
- <img src="..."> and <img srcset="...">
|
|
- <picture><source srcset="..."></picture>
|
|
- <video src="..."> and <video poster="...">
|
|
- <source src="..."> (within video/audio)
|
|
- <audio src="...">
|
|
- CSS background-image: url(...)
|
|
- <a href="..."> 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, # <a title="..."> attribute
|
|
}
|
|
|
|
# Check if inside a <figure> with <figcaption>
|
|
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 <a> 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 <a> 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'): # <a 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 <a> 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'), # <a 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 <img> 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 <picture><source> 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 <video> tags
|
|
if collect_videos:
|
|
for video in soup.find_all('video'):
|
|
src = video.get('src')
|
|
poster = video.get('poster')
|
|
title = video.get('title', '')
|
|
|
|
if src:
|
|
add_media(src, 'video', title=title, element=video)
|
|
if poster and collect_images:
|
|
add_media(poster, 'image', title=f"Video poster: {title}", element=video)
|
|
|
|
# Sources within video
|
|
for source in video.find_all('source'):
|
|
src = source.get('src')
|
|
if src:
|
|
add_media(src, 'video', title=title)
|
|
|
|
# Extract from <audio> tags
|
|
if collect_audio:
|
|
for audio in soup.find_all('audio'):
|
|
src = audio.get('src')
|
|
title = audio.get('title', '')
|
|
|
|
if src:
|
|
add_media(src, 'audio', title=title)
|
|
|
|
for source in audio.find_all('source'):
|
|
src = source.get('src')
|
|
if src:
|
|
add_media(src, 'audio', title=title)
|
|
|
|
# Extract from <a href="..."> 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 <link> 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 <script src="..."> for JavaScript files
|
|
for script in soup.find_all('script', src=True):
|
|
src = script.get('src')
|
|
if src:
|
|
add_media(src, 'code', alt_text='script')
|
|
|
|
# Extract font URLs from @font-face in <style> blocks
|
|
font_face_pattern = re.compile(r'@font-face\s*\{[^}]*url\(["\']?([^"\')\s]+)["\']?\)', re.IGNORECASE | re.DOTALL)
|
|
for style_tag in soup.find_all('style'):
|
|
if style_tag.string:
|
|
for match in font_face_pattern.findall(style_tag.string):
|
|
add_media(match, 'font', alt_text='font-face')
|
|
|
|
# Extract from CSS background-image: url(...)
|
|
if collect_images:
|
|
bg_pattern = re.compile(r'background(?:-image)?\s*:\s*url\(["\']?([^"\')\s]+)["\']?\)', re.IGNORECASE)
|
|
|
|
# Inline styles
|
|
for elem in soup.find_all(style=True):
|
|
style = elem.get('style', '')
|
|
for match in bg_pattern.findall(style):
|
|
add_media(match, 'image')
|
|
|
|
# <style> blocks
|
|
for style_tag in soup.find_all('style'):
|
|
if style_tag.string:
|
|
for match in bg_pattern.findall(style_tag.string):
|
|
add_media(match, 'image')
|
|
|
|
# Extract from Open Graph and Twitter meta tags
|
|
# Also look for og:title and og:image:alt for metadata
|
|
og_title = None
|
|
og_image_alt = None
|
|
media_filename = None
|
|
media_url = None
|
|
media_type_meta = None
|
|
|
|
for meta in soup.find_all('meta'):
|
|
prop = meta.get('property', '') or meta.get('name', '')
|
|
content = meta.get('content', '')
|
|
prop_lower = prop.lower()
|
|
|
|
# Extract metadata for richer media info
|
|
if prop_lower == 'og:title':
|
|
og_title = content
|
|
elif prop_lower == 'og:image:alt':
|
|
og_image_alt = content
|
|
elif prop_lower == 'media:filename':
|
|
media_filename = content
|
|
elif prop_lower == 'media:url':
|
|
media_url = content
|
|
elif prop_lower == 'media:type':
|
|
media_type_meta = content
|
|
|
|
# Process OG/Twitter image tags with enriched metadata
|
|
if collect_images:
|
|
for meta in soup.find_all('meta'):
|
|
prop = meta.get('property', '') or meta.get('name', '')
|
|
content = meta.get('content', '')
|
|
|
|
if content and prop.lower() in ('og:image', 'og:image:url', 'twitter:image', 'twitter:image:src'):
|
|
title = og_title or media_filename or 'Open Graph/Twitter image'
|
|
alt = og_image_alt or og_title or media_filename
|
|
add_media(content, 'image', alt_text=alt, title=title)
|
|
|
|
if collect_videos:
|
|
for meta in soup.find_all('meta'):
|
|
prop = meta.get('property', '') or meta.get('name', '')
|
|
content = meta.get('content', '')
|
|
|
|
if content and prop.lower() in ('og:video', 'og:video:url', 'twitter:player'):
|
|
title = og_title or media_filename or 'Open Graph/Twitter video'
|
|
add_media(content, 'video', title=title)
|
|
|
|
# Also check custom media:url meta tag (PyraFiles specific)
|
|
if media_url:
|
|
detected_type = media_type_meta or 'image' # Default to image
|
|
if detected_type in ('image', 'video', 'audio'):
|
|
should_add = (
|
|
(detected_type == 'image' and collect_images) or
|
|
(detected_type == 'video' and collect_videos) or
|
|
(detected_type == 'audio' and collect_audio)
|
|
)
|
|
if should_add:
|
|
title = og_title or media_filename
|
|
add_media(media_url, detected_type, alt_text=og_title, title=title)
|
|
|
|
logger.debug(f"Extracted {len(media_items)} media items from {base_url}")
|
|
return media_items
|
|
|
|
|
|
# ============================================================================
|
|
# Hydra Mode: Feed/Sitemap Discovery and Parsing
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class FeedItem:
|
|
"""Represents an item discovered from RSS/Atom/Sitemap."""
|
|
url: str
|
|
title: Optional[str] = None
|
|
published: Optional[str] = None # ISO date string
|
|
updated: Optional[str] = None
|
|
priority: float = 0.5 # Sitemap priority (0.0-1.0)
|
|
|
|
|
|
def discover_feeds(html: str, base_url: str) -> List[str]:
|
|
"""
|
|
Discover RSS/Atom feed and sitemap URLs from HTML.
|
|
|
|
Looks for:
|
|
- <link rel="alternate" type="application/rss+xml">
|
|
- <link rel="alternate" type="application/atom+xml">
|
|
- <link rel="sitemap" href="...">
|
|
- Common sitemap locations: /sitemap.xml, /sitemap_index.xml
|
|
"""
|
|
feeds = []
|
|
soup = BeautifulSoup(html, 'html.parser')
|
|
|
|
# RSS/Atom feeds from link tags
|
|
for link in soup.find_all('link', rel='alternate'):
|
|
link_type = link.get('type', '')
|
|
href = link.get('href', '')
|
|
if href and ('rss' in link_type or 'atom' in link_type):
|
|
feeds.append(urljoin(base_url, href))
|
|
|
|
# Sitemap from link tag
|
|
for link in soup.find_all('link', rel='sitemap'):
|
|
href = link.get('href', '')
|
|
if href:
|
|
feeds.append(urljoin(base_url, href))
|
|
|
|
# Common sitemap locations
|
|
parsed = Uri(base_url)
|
|
base = f"{parsed.scheme}://{parsed.hostname}"
|
|
if parsed.port and parsed.port not in (80, 443):
|
|
base += f":{parsed.port}"
|
|
|
|
common_sitemaps = [
|
|
'/sitemap.xml',
|
|
'/sitemap_index.xml',
|
|
'/sitemap-index.xml',
|
|
'/sitemaps.xml',
|
|
]
|
|
for path in common_sitemaps:
|
|
sitemap_url = base + path
|
|
if sitemap_url not in feeds:
|
|
feeds.append(sitemap_url)
|
|
|
|
return feeds
|
|
|
|
|
|
def parse_rss(xml_content: str, base_url: str) -> List[FeedItem]:
|
|
"""Parse RSS feed and extract items."""
|
|
items = []
|
|
try:
|
|
soup = BeautifulSoup(xml_content, 'xml')
|
|
for item in soup.find_all('item'):
|
|
link = item.find('link')
|
|
title = item.find('title')
|
|
pub_date = item.find('pubDate')
|
|
|
|
if link:
|
|
url = link.get_text().strip() if link.get_text() else link.get('href', '')
|
|
if url:
|
|
items.append(FeedItem(
|
|
url=urljoin(base_url, url),
|
|
title=title.get_text().strip() if title else None,
|
|
published=pub_date.get_text().strip() if pub_date else None,
|
|
))
|
|
except Exception as e:
|
|
logger.debug(f"RSS parse error: {e}")
|
|
return items
|
|
|
|
|
|
def parse_atom(xml_content: str, base_url: str) -> List[FeedItem]:
|
|
"""Parse Atom feed and extract entries."""
|
|
items = []
|
|
try:
|
|
soup = BeautifulSoup(xml_content, 'xml')
|
|
for entry in soup.find_all('entry'):
|
|
# Atom uses <link href="..."> or <link>url</link>
|
|
link = entry.find('link')
|
|
title = entry.find('title')
|
|
published = entry.find('published')
|
|
updated = entry.find('updated')
|
|
|
|
url = None
|
|
if link:
|
|
url = link.get('href') or link.get_text().strip()
|
|
|
|
if url:
|
|
items.append(FeedItem(
|
|
url=urljoin(base_url, url),
|
|
title=title.get_text().strip() if title else None,
|
|
published=published.get_text().strip() if published else None,
|
|
updated=updated.get_text().strip() if updated else None,
|
|
))
|
|
except Exception as e:
|
|
logger.debug(f"Atom parse error: {e}")
|
|
return items
|
|
|
|
|
|
def parse_sitemap(xml_content: str, base_url: str) -> Tuple[List[FeedItem], List[str]]:
|
|
"""
|
|
Parse sitemap and extract URLs.
|
|
|
|
Returns:
|
|
Tuple of (items, sub_sitemaps) - items are page URLs, sub_sitemaps are nested sitemap URLs
|
|
"""
|
|
items = []
|
|
sub_sitemaps = []
|
|
try:
|
|
soup = BeautifulSoup(xml_content, 'xml')
|
|
|
|
# Check for sitemap index (contains other sitemaps)
|
|
for sitemap in soup.find_all('sitemap'):
|
|
loc = sitemap.find('loc')
|
|
if loc:
|
|
sub_sitemaps.append(loc.get_text().strip())
|
|
|
|
# Regular sitemap URLs
|
|
for url_tag in soup.find_all('url'):
|
|
loc = url_tag.find('loc')
|
|
lastmod = url_tag.find('lastmod')
|
|
priority = url_tag.find('priority')
|
|
|
|
if loc:
|
|
url = loc.get_text().strip()
|
|
items.append(FeedItem(
|
|
url=urljoin(base_url, url),
|
|
updated=lastmod.get_text().strip() if lastmod else None,
|
|
priority=float(priority.get_text().strip()) if priority else 0.5,
|
|
))
|
|
except Exception as e:
|
|
logger.debug(f"Sitemap parse error: {e}")
|
|
return items, sub_sitemaps
|
|
|
|
|
|
def parse_feed(content: str, url: str) -> Tuple[List[FeedItem], List[str]]:
|
|
"""
|
|
Auto-detect feed type and parse.
|
|
|
|
Returns:
|
|
Tuple of (items, sub_feeds) - sub_feeds for sitemap indexes
|
|
"""
|
|
content_lower = content[:1000].lower()
|
|
|
|
if '<urlset' in content_lower or '<sitemapindex' in content_lower:
|
|
return parse_sitemap(content, url)
|
|
elif '<feed' in content_lower:
|
|
return parse_atom(content, url), []
|
|
elif '<rss' in content_lower or '<channel' in content_lower:
|
|
return parse_rss(content, url), []
|
|
else:
|
|
logger.debug(f"Unknown feed format: {url}")
|
|
return [], []
|
|
|
|
|
|
class AsyncWebFetcher:
|
|
"""
|
|
Async web fetcher that respects robots.txt and crawl delays.
|
|
Designed for use with Discord bot's async event loop.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
user_agent: str = "uncloseai.com/1.42 (ethical web crawler; +https://uncloseai.com)",
|
|
default_crawl_delay: float = DEFAULT_CRAWL_DELAY,
|
|
fast_mode: bool = False
|
|
):
|
|
self.user_agent = user_agent
|
|
self.default_crawl_delay = default_crawl_delay
|
|
self.fast_mode = fast_mode
|
|
|
|
# Timeouts: 5s in fast mode, 60s normally
|
|
self.media_timeout = 5 if fast_mode else 60
|
|
self.page_timeout = 5 if fast_mode else 15
|
|
|
|
# Parallel workers for page fetching: 3x CPU in fast mode, 1 otherwise
|
|
import multiprocessing
|
|
self.max_page_workers = multiprocessing.cpu_count() * 3 if fast_mode else 1
|
|
|
|
# Caches for robots.txt and crawl delays per domain
|
|
self.robot_parsers: Dict[str, Optional[RobotFileParser]] = {}
|
|
self.domain_crawl_delays: Dict[str, float] = {}
|
|
self.domain_last_fetched: Dict[str, float] = {}
|
|
|
|
# Page cache: {url: (html, links, timestamp)}
|
|
self.page_cache: Dict[str, Tuple[str, List, float]] = {}
|
|
|
|
# Domain skip list: domains with too many consecutive timeouts
|
|
self.skip_domains: Set[str] = set()
|
|
self.domain_timeout_counts: Dict[str, int] = {}
|
|
self.MAX_CONSECUTIVE_TIMEOUTS = 5
|
|
|
|
logger.info(f"AsyncWebFetcher initialized with user-agent: {self.user_agent}")
|
|
|
|
def _get_domain(self, url: str) -> str:
|
|
"""Extract domain from URL"""
|
|
parsed = Uri(url)
|
|
return parsed.hostname
|
|
|
|
async def _fetch_robots_txt(self, domain: str) -> Optional[RobotFileParser]:
|
|
"""
|
|
Fetch and parse robots.txt for a domain.
|
|
Returns RobotFileParser or None if not available.
|
|
"""
|
|
if domain in self.robot_parsers:
|
|
logger.debug(f"Using cached robots.txt for {domain}")
|
|
return self.robot_parsers[domain]
|
|
|
|
logger.info(f"Fetching robots.txt for {domain}")
|
|
robots_url = f"https://{domain}/robots.txt"
|
|
parser = RobotFileParser()
|
|
parser.set_url(robots_url)
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(
|
|
robots_url,
|
|
headers={"User-Agent": self.user_agent},
|
|
timeout=aiohttp.ClientTimeout(total=5)
|
|
) as response:
|
|
if response.status == 200:
|
|
text = await response.text()
|
|
parser.parse(text.splitlines())
|
|
self.robot_parsers[domain] = parser
|
|
|
|
# Extract crawl delay (ignored in fast mode)
|
|
if self.fast_mode:
|
|
self.domain_crawl_delays[domain] = 0.0
|
|
else:
|
|
delay = parser.crawl_delay(self.user_agent)
|
|
self.domain_crawl_delays[domain] = (
|
|
delay if delay is not None else self.default_crawl_delay
|
|
)
|
|
|
|
logger.info(f"Parsed robots.txt for {domain}, crawl delay: {self.domain_crawl_delays[domain]}s")
|
|
return parser
|
|
else:
|
|
logger.info(f"No robots.txt for {domain} (status {response.status})")
|
|
self.robot_parsers[domain] = None
|
|
self.domain_crawl_delays[domain] = self.default_crawl_delay
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(f"Could not fetch robots.txt for {domain}: {e}")
|
|
self.robot_parsers[domain] = None
|
|
self.domain_crawl_delays[domain] = self.default_crawl_delay
|
|
return None
|
|
|
|
async def _can_fetch(self, url: str) -> bool:
|
|
"""Check if URL can be fetched according to robots.txt"""
|
|
domain = self._get_domain(url)
|
|
parser = await self._fetch_robots_txt(domain)
|
|
|
|
if parser is None:
|
|
# No robots.txt means we can fetch
|
|
return True
|
|
|
|
can_fetch = parser.can_fetch(self.user_agent, url)
|
|
if not can_fetch:
|
|
logger.warning(f"Blocked by robots.txt: {url}")
|
|
|
|
return can_fetch
|
|
|
|
async def _enforce_crawl_delay(self, domain: str):
|
|
"""Enforce crawl delay for a domain"""
|
|
delay = self.domain_crawl_delays.get(domain, self.default_crawl_delay)
|
|
last_fetched = self.domain_last_fetched.get(domain, 0)
|
|
elapsed = time.time() - last_fetched
|
|
|
|
if elapsed < delay:
|
|
sleep_time = delay - elapsed
|
|
logger.info(f"Enforcing crawl delay for {domain}: sleeping {sleep_time:.2f}s")
|
|
await asyncio.sleep(sleep_time)
|
|
|
|
self.domain_last_fetched[domain] = time.time()
|
|
|
|
async def check_media_url(
|
|
self,
|
|
url: str,
|
|
session: Optional[aiohttp.ClientSession] = None
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Check if a URL points to media content using HEAD request.
|
|
|
|
Useful for extensionless URLs where we can't determine media type from path.
|
|
|
|
Args:
|
|
url: URL to check
|
|
session: Optional aiohttp session to reuse
|
|
|
|
Returns:
|
|
Dict with 'media_type', 'mime_type', 'content_length' if media, None otherwise
|
|
"""
|
|
try:
|
|
close_session = session is None
|
|
if session is None:
|
|
session = aiohttp.ClientSession()
|
|
|
|
try:
|
|
async with session.head(
|
|
url,
|
|
headers={"User-Agent": self.user_agent},
|
|
timeout=aiohttp.ClientTimeout(total=10),
|
|
allow_redirects=True
|
|
) as response:
|
|
if response.status != 200:
|
|
return None
|
|
|
|
content_type = response.headers.get('Content-Type', '').lower()
|
|
mime_type = content_type.split(';')[0].strip()
|
|
media_type = get_media_type_from_mime(mime_type)
|
|
|
|
if media_type:
|
|
content_length = response.headers.get('Content-Length')
|
|
return {
|
|
'media_type': media_type,
|
|
'mime_type': mime_type,
|
|
'content_length': int(content_length) if content_length else None,
|
|
'url': str(response.url) # Final URL after redirects
|
|
}
|
|
return None
|
|
finally:
|
|
if close_session:
|
|
await session.close()
|
|
|
|
except Exception as e:
|
|
logger.debug(f"HEAD check failed for {url}: {e}")
|
|
return None
|
|
|
|
async def resolve_canonical_image(
|
|
self,
|
|
detail_page_url: str,
|
|
thumbnail_url: str,
|
|
embedding_title: Optional[str] = None,
|
|
session: Optional[aiohttp.ClientSession] = None
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Fetch a detail page and extract the canonical (full-res) image URL.
|
|
|
|
Universal Algorithm - no hardcoded strings, purely structural detection:
|
|
|
|
1. REST pattern: If an <img> src matches detail_page_url minus last path segment
|
|
2. Wrapped links: <a> tags wrapping <img> where href has query params (versioned)
|
|
3. Download links: <a download> pointing to media
|
|
4. Path similarity: <img> src sharing path structure with detail page
|
|
5. og:image fallback
|
|
|
|
Title: Prefers embedding_title (from listing page where image was found).
|
|
Falls back to detail page metadata only if embedding_title not provided.
|
|
|
|
Args:
|
|
detail_page_url: URL of the detail page to fetch
|
|
thumbnail_url: Original thumbnail URL (to avoid returning same URL)
|
|
embedding_title: Title from the page that linked here (preferred)
|
|
session: Optional aiohttp session to reuse
|
|
|
|
Returns:
|
|
Dict with 'canonical_url', 'title', 'description', 'og_image' or None
|
|
"""
|
|
try:
|
|
close_session = session is None
|
|
if session is None:
|
|
session = aiohttp.ClientSession()
|
|
|
|
try:
|
|
# Check robots.txt
|
|
if not await self._can_fetch(detail_page_url):
|
|
return None
|
|
|
|
# Enforce crawl delay
|
|
domain = self._get_domain(detail_page_url)
|
|
await self._enforce_crawl_delay(domain)
|
|
|
|
async with session.get(
|
|
detail_page_url,
|
|
headers={"User-Agent": self.user_agent},
|
|
timeout=aiohttp.ClientTimeout(total=15),
|
|
allow_redirects=True
|
|
) as response:
|
|
if response.status != 200:
|
|
return None
|
|
|
|
html = await response.text()
|
|
|
|
# Parse detail page in thread to not block event loop
|
|
def _parse_detail_page():
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
|
|
result = {
|
|
'detail_page_url': detail_page_url,
|
|
'canonical_url': None,
|
|
'embedding_title': embedding_title, # from listing page
|
|
'detail_title': None, # from detail page
|
|
'detail_content': None, # body text from detail page
|
|
'title': None, # best available
|
|
'description': None,
|
|
'og_image': None,
|
|
}
|
|
|
|
# ========================================
|
|
# COLLECT BOTH TITLES (skeleton key approach)
|
|
# ========================================
|
|
|
|
# Always extract detail page title
|
|
detail_title = None
|
|
|
|
# 1. og:title
|
|
og_title = soup.find('meta', property='og:title')
|
|
if og_title:
|
|
detail_title = og_title.get('content', '').strip()
|
|
|
|
# 2. First image alt text
|
|
if not detail_title:
|
|
for img in soup.find_all('img', alt=True):
|
|
alt = img.get('alt', '').strip()
|
|
if alt and len(alt) > 2:
|
|
detail_title = alt
|
|
break
|
|
|
|
# 3. h1 tag
|
|
if not detail_title:
|
|
h1_tag = soup.find('h1')
|
|
if h1_tag:
|
|
detail_title = h1_tag.get_text(strip=True)
|
|
|
|
# 4. title tag
|
|
if not detail_title:
|
|
title_tag = soup.find('title')
|
|
if title_tag:
|
|
detail_title = title_tag.get_text(strip=True)
|
|
|
|
result['detail_title'] = detail_title
|
|
# Primary title: prefer embedding, fallback to detail
|
|
result['title'] = embedding_title or detail_title
|
|
|
|
# ========================================
|
|
# DESCRIPTION EXTRACTION
|
|
# ========================================
|
|
og_desc = soup.find('meta', property='og:description')
|
|
if og_desc:
|
|
result['description'] = og_desc.get('content', '')
|
|
if not result['description']:
|
|
meta_desc = soup.find('meta', attrs={'name': 'description'})
|
|
if meta_desc:
|
|
result['description'] = meta_desc.get('content', '')
|
|
|
|
# Extract og:image
|
|
og_image = soup.find('meta', property='og:image')
|
|
if og_image:
|
|
result['og_image'] = og_image.get('content', '')
|
|
|
|
# ========================================
|
|
# DETAIL CONTENT EXTRACTION (body text)
|
|
# ========================================
|
|
# Extract body text from detail page for full-text searchability
|
|
# This enables Pinterest-style galleries where detail pages
|
|
# have richer descriptions than thumbnails on listing pages
|
|
body = soup.find('body')
|
|
if body:
|
|
# Remove non-content elements
|
|
for tag in body.find_all(['script', 'style', 'nav', 'footer', 'header', 'aside']):
|
|
tag.decompose()
|
|
detail_content = body.get_text(separator=' ', strip=True)[:10000]
|
|
result['detail_content'] = detail_content
|
|
|
|
# ========================================
|
|
# CANONICAL URL DETECTION (universal patterns)
|
|
# ========================================
|
|
canonical_url = None
|
|
detail_parsed = Uri(detail_page_url)
|
|
|
|
# Pattern 1: REST-style - check if img src matches URL minus last segment
|
|
# e.g., /media/ID/details has img pointing to /media/ID
|
|
path_segments = detail_parsed.path.rstrip('/').split('/')
|
|
if len(path_segments) > 1:
|
|
parent_path = '/'.join(path_segments[:-1])
|
|
parent_url = f"{detail_parsed.scheme}://{detail_parsed.hostname}{parent_path}"
|
|
for img in soup.find_all('img', src=True):
|
|
src = img.get('src', '')
|
|
full_src = urljoin(detail_page_url, src)
|
|
if full_src == parent_url or full_src.rstrip('/') == parent_url:
|
|
if full_src != thumbnail_url:
|
|
canonical_url = full_src
|
|
break
|
|
|
|
# Pattern 2: Find <a> tags wrapping images with query params (versioned URLs)
|
|
# The href with ?param=value suggests a cache-busted/versioned canonical
|
|
if not canonical_url:
|
|
for a_tag in soup.find_all('a', href=True):
|
|
href = a_tag.get('href', '')
|
|
full_href = urljoin(detail_page_url, href)
|
|
# Must have query params (indicates versioned/timestamped)
|
|
if '?' not in full_href:
|
|
continue
|
|
# Must wrap or be near an image
|
|
img_inside = a_tag.find('img')
|
|
if not img_inside:
|
|
continue
|
|
# Check it's a media URL or same-domain endpoint
|
|
href_parsed = Uri(full_href)
|
|
media_type = get_media_type_from_extension(full_href)
|
|
if media_type == 'image':
|
|
canonical_url = full_href
|
|
break
|
|
# Same domain with query params - likely image endpoint
|
|
if href_parsed.hostname == detail_parsed.hostname or not href_parsed.hostname:
|
|
canonical_url = full_href
|
|
break
|
|
|
|
# Pattern 3: <a download> attribute - semantic HTML for downloadable content
|
|
if not canonical_url:
|
|
for a_tag in soup.find_all('a', href=True, download=True):
|
|
href = a_tag.get('href', '')
|
|
if href:
|
|
full_href = urljoin(detail_page_url, href)
|
|
media_type = get_media_type_from_extension(full_href)
|
|
if media_type == 'image':
|
|
canonical_url = full_href
|
|
break
|
|
|
|
# Pattern 4: First image sharing path structure with detail page
|
|
if not canonical_url:
|
|
for img in soup.find_all('img', src=True):
|
|
src = img.get('src', '')
|
|
full_src = urljoin(detail_page_url, src)
|
|
if full_src == thumbnail_url:
|
|
continue
|
|
img_parsed = Uri(full_src)
|
|
# Same host
|
|
if img_parsed.hostname == detail_parsed.hostname:
|
|
# Count shared path segments
|
|
detail_parts = detail_parsed.path.rstrip('/').split('/')
|
|
img_parts = img_parsed.path.rstrip('/').split('/')
|
|
common = sum(1 for d, i in zip(detail_parts, img_parts) if d == i)
|
|
# At least 2 shared segments suggests same resource
|
|
if common >= 2:
|
|
canonical_url = full_src
|
|
break
|
|
|
|
# Pattern 5: og:image fallback (if different from thumbnail)
|
|
if not canonical_url and result['og_image']:
|
|
if result['og_image'] != thumbnail_url:
|
|
canonical_url = result['og_image']
|
|
|
|
result['canonical_url'] = canonical_url
|
|
return result
|
|
|
|
result = await asyncio.to_thread(_parse_detail_page)
|
|
logger.info(f"Resolved canonical image from {detail_page_url}: {result['canonical_url']}, title: {result['title'][:50] if result['title'] else 'None'}")
|
|
return result
|
|
|
|
finally:
|
|
if close_session:
|
|
await session.close()
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Failed to resolve canonical image from {detail_page_url}: {e}")
|
|
return None
|
|
|
|
def _record_timeout(self, domain: str):
|
|
"""Record a timeout for a domain. After MAX_CONSECUTIVE_TIMEOUTS, add to skip list."""
|
|
self.domain_timeout_counts[domain] = self.domain_timeout_counts.get(domain, 0) + 1
|
|
if self.domain_timeout_counts[domain] >= self.MAX_CONSECUTIVE_TIMEOUTS:
|
|
if domain not in self.skip_domains:
|
|
self.skip_domains.add(domain)
|
|
logger.warning(f"Skipping domain {domain} after {self.MAX_CONSECUTIVE_TIMEOUTS} consecutive timeouts")
|
|
|
|
def _record_success(self, domain: str):
|
|
"""Record a successful fetch, resetting timeout count."""
|
|
self.domain_timeout_counts[domain] = 0
|
|
|
|
async def fetch_media(
|
|
self,
|
|
url: str,
|
|
session: Optional[aiohttp.ClientSession] = None,
|
|
max_size: int = 100 * 1024 * 1024 # 100MB default limit
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Fetch media content and compute MD5 hash.
|
|
|
|
Args:
|
|
url: URL of media to fetch
|
|
session: Optional aiohttp session to reuse
|
|
max_size: Maximum file size in bytes (default 100MB)
|
|
|
|
Returns:
|
|
Dict with 'data', 'md5_hash', 'mime_type', 'size', 'url' or None on failure
|
|
"""
|
|
global LAST_FETCH_ERROR
|
|
|
|
# Check if domain is in skip list
|
|
domain = self._get_domain(url)
|
|
if domain in self.skip_domains:
|
|
LAST_FETCH_ERROR = {'type': 'skip_domain', 'details': f'Domain {domain} skipped (too many timeouts)', 'url': url}
|
|
logger.debug(f"Skipping {url}: domain {domain} in skip list")
|
|
return None
|
|
|
|
# Check robots.txt
|
|
if not await self._can_fetch(url):
|
|
LAST_FETCH_ERROR = {'type': 'robots_txt', 'details': 'Blocked by robots.txt', 'url': url}
|
|
return None
|
|
|
|
# Enforce crawl delay
|
|
await self._enforce_crawl_delay(domain)
|
|
|
|
try:
|
|
close_session = session is None
|
|
if session is None:
|
|
session = aiohttp.ClientSession()
|
|
|
|
try:
|
|
async with session.get(
|
|
url,
|
|
headers={"User-Agent": self.user_agent},
|
|
timeout=aiohttp.ClientTimeout(total=self.media_timeout),
|
|
allow_redirects=True
|
|
) as response:
|
|
if response.status != 200:
|
|
LAST_FETCH_ERROR = {'type': 'http_error', 'details': f'HTTP {response.status}', 'url': url}
|
|
return None
|
|
|
|
# Check content length before downloading
|
|
content_length = response.headers.get('Content-Length')
|
|
if content_length and int(content_length) > max_size:
|
|
LAST_FETCH_ERROR = {'type': 'too_large', 'details': f'File too large: {int(content_length)} bytes', 'url': url}
|
|
logger.warning(f"Skipping {url}: file too large ({int(content_length)} bytes)")
|
|
return None
|
|
|
|
content_type = response.headers.get('Content-Type', '').lower()
|
|
mime_type = content_type.split(';')[0].strip()
|
|
|
|
# Download content
|
|
data = await response.read()
|
|
|
|
if len(data) > max_size:
|
|
LAST_FETCH_ERROR = {'type': 'too_large', 'details': f'File too large: {len(data)} bytes', 'url': url}
|
|
return None
|
|
|
|
# Compute MD5 hash
|
|
md5_hash = hashlib.md5(data).hexdigest()
|
|
|
|
# Determine media type
|
|
media_type = get_media_type_from_mime(mime_type) or get_media_type_from_extension(url)
|
|
|
|
# Success - reset timeout count
|
|
self._record_success(domain)
|
|
|
|
logger.info(f"Fetched media {url}: {len(data)} bytes, MD5: {md5_hash}, type: {media_type}")
|
|
|
|
return {
|
|
'data': data,
|
|
'md5_hash': md5_hash,
|
|
'mime_type': mime_type,
|
|
'media_type': media_type,
|
|
'size': len(data),
|
|
'url': str(response.url) # Final URL after redirects
|
|
}
|
|
|
|
finally:
|
|
if close_session:
|
|
await session.close()
|
|
|
|
except asyncio.TimeoutError:
|
|
self._record_timeout(domain)
|
|
LAST_FETCH_ERROR = {'type': 'timeout', 'details': f'Download timeout ({self.media_timeout}s)', 'url': url}
|
|
logger.error(f"Timeout fetching media {url} ({self.media_timeout}s)")
|
|
return None
|
|
except Exception as e:
|
|
LAST_FETCH_ERROR = {'type': 'unknown', 'details': str(e), 'url': url}
|
|
logger.error(f"Error fetching media {url}: {e}")
|
|
return None
|
|
|
|
async def fetch_webpage(
|
|
self,
|
|
url: str,
|
|
extract_links: bool = False,
|
|
extract_anchor_text: bool = False,
|
|
cache_check_callback = None
|
|
) -> Tuple[Optional[str], List]:
|
|
"""
|
|
Fetch a webpage, respecting robots.txt and crawl delays.
|
|
Uses cache to avoid redundant fetches and delays.
|
|
|
|
Args:
|
|
url: URL to fetch
|
|
extract_links: Whether to extract links from the page
|
|
extract_anchor_text: Whether to extract anchor text with links
|
|
cache_check_callback: Optional async callback to check external cache (e.g., SQLite3)
|
|
Should return (html, links) tuple if cached, None otherwise
|
|
|
|
Returns:
|
|
If extract_anchor_text=True: Tuple of (html_content, list of dicts with 'url' and 'anchor_text')
|
|
If extract_anchor_text=False: Tuple of (html_content, list of URLs)
|
|
Returns (None, []) if fetch fails
|
|
"""
|
|
global LAST_FETCH_ERROR
|
|
|
|
# Validate URL
|
|
if not url.startswith(("http://", "https://")):
|
|
logger.error(f"Invalid URL scheme: {url}")
|
|
return None, []
|
|
|
|
# Check external cache first (e.g., SQLite3 via WebCacheManager)
|
|
if cache_check_callback:
|
|
try:
|
|
cached_result = await cache_check_callback(url)
|
|
if cached_result:
|
|
html, links = cached_result
|
|
# If we need links but cache has none, re-extract from HTML (in thread to not block)
|
|
if extract_links and not links and html:
|
|
logger.info(f"💾 Cache hit but no links cached, re-extracting from HTML: {url}")
|
|
def _reextract_links():
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
result = []
|
|
for a_tag in soup.find_all("a", href=True):
|
|
href = a_tag["href"]
|
|
if href.startswith(("javascript:", "#")):
|
|
continue
|
|
rel = a_tag.get("rel", [])
|
|
if isinstance(rel, str):
|
|
rel = rel.split()
|
|
if "nofollow" in rel:
|
|
continue
|
|
absolute_url = strip_url_fragment(urljoin(url, href))
|
|
parsed = Uri(absolute_url)
|
|
if parsed.scheme in ("http", "https"):
|
|
if extract_anchor_text:
|
|
anchor_text = a_tag.get_text(strip=True)
|
|
result.append({"url": absolute_url, "anchor_text": anchor_text})
|
|
else:
|
|
result.append(absolute_url)
|
|
return result
|
|
links = await asyncio.to_thread(_reextract_links)
|
|
logger.info(f"Re-extracted {len(links)} links from cached HTML")
|
|
else:
|
|
logger.info(f"💾 Using SQLite3 cached page: {url} (skipping robots.txt + crawl delay)")
|
|
return html, links
|
|
except Exception as e:
|
|
logger.warning(f"Cache check callback failed for {url}: {e}")
|
|
|
|
# Check in-memory cache (fallback for when no external cache available)
|
|
if url in self.page_cache:
|
|
html, cached_links, cached_time = self.page_cache[url]
|
|
# If we need links but cache has none, re-extract from HTML (in thread to not block)
|
|
if extract_links and not cached_links and html:
|
|
logger.info(f"💾 In-memory cache hit but no links, re-extracting from HTML: {url}")
|
|
def _reextract_cached_links():
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
result = []
|
|
for a_tag in soup.find_all("a", href=True):
|
|
href = a_tag["href"]
|
|
if href.startswith(("javascript:", "#")):
|
|
continue
|
|
rel = a_tag.get("rel", [])
|
|
if isinstance(rel, str):
|
|
rel = rel.split()
|
|
if "nofollow" in rel:
|
|
continue
|
|
absolute_url = strip_url_fragment(urljoin(url, href))
|
|
parsed_link = Uri(absolute_url)
|
|
if parsed_link.scheme in ("http", "https"):
|
|
if extract_anchor_text:
|
|
anchor_text = a_tag.get_text(strip=True)
|
|
result.append({"url": absolute_url, "anchor_text": anchor_text})
|
|
else:
|
|
result.append(absolute_url)
|
|
return result
|
|
cached_links = await asyncio.to_thread(_reextract_cached_links)
|
|
logger.info(f"Re-extracted {len(cached_links)} links from in-memory cached HTML")
|
|
else:
|
|
logger.info(f"💾 Using in-memory cached page: {url} (cached {time.time() - cached_time:.0f}s ago, skipping crawl delay)")
|
|
return html, cached_links
|
|
|
|
# Check robots.txt
|
|
if not await self._can_fetch(url):
|
|
LAST_FETCH_ERROR = {'type': 'robots_txt', 'details': 'Blocked by robots.txt', 'url': url}
|
|
return None, []
|
|
|
|
# Enforce crawl delay (only for fresh fetches)
|
|
domain = self._get_domain(url)
|
|
await self._enforce_crawl_delay(domain)
|
|
|
|
# Fetch the page
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
# HEAD request first to check content type without downloading
|
|
try:
|
|
async with session.head(
|
|
url,
|
|
headers={"User-Agent": self.user_agent},
|
|
timeout=aiohttp.ClientTimeout(total=10),
|
|
allow_redirects=True
|
|
) as head_response:
|
|
head_content_type = head_response.headers.get('Content-Type', '').lower()
|
|
mime_type = head_content_type.split(';')[0].strip()
|
|
logger.info(f"HEAD {url} -> {head_response.status}, Content-Type: {mime_type}")
|
|
|
|
# Skip images, video, audio - these need vision mode, not text fetch
|
|
if mime_type.startswith(('image/', 'video/', 'audio/')):
|
|
LAST_FETCH_ERROR = {
|
|
'type': 'binary_content',
|
|
'details': f'Content is {mime_type} - requires vision mode for images',
|
|
'url': url,
|
|
'content_type': mime_type
|
|
}
|
|
logger.info(f"Skipping {mime_type} content (not text-extractable): {url}")
|
|
return None, []
|
|
except Exception as e:
|
|
# HEAD failed, continue with GET anyway
|
|
logger.debug(f"HEAD request failed for {url}: {e}, continuing with GET")
|
|
|
|
async with session.get(
|
|
url,
|
|
headers={"User-Agent": self.user_agent},
|
|
timeout=aiohttp.ClientTimeout(total=15),
|
|
allow_redirects=True,
|
|
max_redirects=3
|
|
) as response:
|
|
if response.status != 200:
|
|
LAST_FETCH_ERROR = {'type': 'http_error', 'details': f'HTTP {response.status}', 'url': url, 'status_code': response.status}
|
|
logger.warning(f"Failed to fetch {url}: HTTP {response.status}")
|
|
return None, []
|
|
|
|
# Check content type for binary files (PDF, etc.)
|
|
content_type = response.headers.get('Content-Type', '').lower()
|
|
mime_type = content_type.split(';')[0].strip()
|
|
|
|
# Double-check for binary content that HEAD might have missed
|
|
if mime_type.startswith(('image/', 'video/', 'audio/')):
|
|
LAST_FETCH_ERROR = {
|
|
'type': 'binary_content',
|
|
'details': f'Content is {mime_type} - requires vision mode for images',
|
|
'url': url,
|
|
'content_type': mime_type
|
|
}
|
|
logger.info(f"Skipping {mime_type} content (not text-extractable): {url}")
|
|
return None, []
|
|
|
|
is_pdf = 'application/pdf' in content_type or url.lower().endswith('.pdf')
|
|
|
|
if is_pdf:
|
|
# Handle PDF: read as binary and extract text
|
|
pdf_bytes = await response.read()
|
|
logger.info(f"Fetched PDF {url} ({len(pdf_bytes)} bytes)")
|
|
pdf_text = extract_text_from_pdf(pdf_bytes)
|
|
if pdf_text:
|
|
# Wrap extracted text in minimal HTML for consistent processing
|
|
# Extract filename from URL for title
|
|
pdf_filename = url.split('/')[-1].split('?')[0] or "PDF Document"
|
|
html = f"<html><head><title>{pdf_filename}</title></head><body><pre>{pdf_text}</pre></body></html>"
|
|
logger.info(f"Extracted {len(pdf_text)} chars of text from PDF")
|
|
else:
|
|
LAST_FETCH_ERROR = {'type': 'pdf_extraction_failed', 'details': 'Could not extract text from PDF', 'url': url}
|
|
logger.warning(f"Failed to extract text from PDF: {url}")
|
|
return None, []
|
|
else:
|
|
html = await response.text()
|
|
logger.info(f"Successfully fetched {url} ({len(html)} bytes)")
|
|
|
|
# Extract links if requested (run in thread to not block event loop)
|
|
links = []
|
|
if extract_links:
|
|
def _extract_links():
|
|
result = []
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
for a_tag in soup.find_all("a", href=True):
|
|
href = a_tag["href"]
|
|
if href.startswith(("javascript:", "#")):
|
|
continue
|
|
rel = a_tag.get("rel", [])
|
|
if isinstance(rel, str):
|
|
rel = rel.split()
|
|
if "nofollow" in rel:
|
|
continue
|
|
absolute_url = strip_url_fragment(urljoin(url, href))
|
|
parsed = Uri(absolute_url)
|
|
if parsed.scheme in ("http", "https"):
|
|
if extract_anchor_text:
|
|
anchor_text = a_tag.get_text(strip=True)
|
|
result.append({"url": absolute_url, "anchor_text": anchor_text})
|
|
else:
|
|
result.append(absolute_url)
|
|
return result
|
|
|
|
links = await asyncio.to_thread(_extract_links)
|
|
logger.info(f"Extracted {len(links)} links from {url}")
|
|
|
|
# Cache in memory for future fetches in this session
|
|
self.page_cache[url] = (html, links, time.time())
|
|
|
|
return html, links
|
|
|
|
except asyncio.TimeoutError:
|
|
LAST_FETCH_ERROR = {'type': 'timeout', 'details': 'Connection timeout (>15 seconds)', 'url': url}
|
|
logger.error(f"Timeout fetching {url}")
|
|
return None, []
|
|
except Exception as e:
|
|
error_str = str(e)
|
|
if 'SSL' in error_str or 'certificate' in error_str.lower():
|
|
LAST_FETCH_ERROR = {'type': 'ssl', 'details': str(e), 'url': url}
|
|
elif 'Name or service not known' in error_str or 'DNS' in error_str:
|
|
LAST_FETCH_ERROR = {'type': 'dns', 'details': 'DNS resolution failed', 'url': url}
|
|
else:
|
|
LAST_FETCH_ERROR = {'type': 'unknown', 'details': str(e), 'url': url}
|
|
logger.error(f"Error fetching {url}: {e}")
|
|
return None, []
|
|
|
|
def extract_text_content(self, html: str) -> str:
|
|
"""
|
|
Extract main text content from HTML using BeautifulSoup.
|
|
|
|
Args:
|
|
html: HTML content
|
|
|
|
Returns:
|
|
Extracted text
|
|
"""
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
|
|
# Remove script and style elements
|
|
for script in soup(["script", "style"]):
|
|
script.extract()
|
|
|
|
# Block-level elements that should create paragraph breaks
|
|
import re
|
|
block_tags = {'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
'section', 'article', 'header', 'footer', 'main',
|
|
'li', 'tr', 'blockquote', 'pre', 'br', 'hr'}
|
|
|
|
# Insert markers before/after block elements
|
|
for tag in soup.find_all(block_tags):
|
|
tag.insert_before('\n\n')
|
|
tag.insert_after('\n\n')
|
|
|
|
# Get text with spaces between inline elements
|
|
text = soup.get_text(separator=' ')
|
|
|
|
# Clean up: split on newlines, strip lines, filter empties
|
|
lines = []
|
|
for line in text.split('\n'):
|
|
# Collapse multiple spaces to single space
|
|
cleaned = re.sub(r' +', ' ', line.strip())
|
|
if cleaned:
|
|
lines.append(cleaned)
|
|
|
|
# Join with single newlines, then collapse 2+ to paragraph breaks
|
|
text = '\n'.join(lines)
|
|
text = re.sub(r'\n{2,}', '\n\n', text)
|
|
|
|
return text
|
|
|
|
def extract_title(self, html: str) -> str:
|
|
"""Extract title from HTML"""
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
if soup.title and soup.title.string:
|
|
return soup.title.string.strip()
|
|
return "Untitled"
|
|
|
|
def _simple_stem(self, word: str) -> str:
|
|
"""
|
|
Simple suffix-stripping stemmer for common English suffixes.
|
|
|
|
Args:
|
|
word: Word to stem
|
|
|
|
Returns:
|
|
Stemmed word
|
|
"""
|
|
word = word.lower()
|
|
# Common suffixes in order of longest first
|
|
suffixes = ['ings', 'ing', 'ed', 'es', 's', 'ly', 'er', 'est', 'tion', 'sion', 'ness', 'ment', 'able', 'ible']
|
|
for suffix in suffixes:
|
|
if word.endswith(suffix) and len(word) > len(suffix) + 2: # Keep at least 3 chars in stem
|
|
return word[:-len(suffix)]
|
|
return word
|
|
|
|
def _score_link(self, link_url: str, anchor_text: str, query_keywords: Optional[List[str]] = None, keyword_variations: Optional[List[str]] = None) -> float:
|
|
"""
|
|
Score a link based on URL and anchor text keyword matches.
|
|
Uses both exact and stemmed matching with different scoring weights.
|
|
|
|
Scoring weights:
|
|
- Exact primary keyword match: 10.0 (URL) / 5.0 (anchor)
|
|
- Stemmed primary keyword match: 7.0 (URL) / 3.5 (anchor) [70% of exact]
|
|
- Exact variation match: 6.0 (URL) / 3.0 (anchor) [60% of exact primary]
|
|
- Stemmed variation match: 4.0 (URL) / 2.0 (anchor) [40% of exact primary]
|
|
|
|
Args:
|
|
link_url: URL of the link
|
|
anchor_text: Anchor text of the link
|
|
query_keywords: Primary keywords to match against (full score)
|
|
keyword_variations: Keyword variations/synonyms (reduced score)
|
|
|
|
Returns:
|
|
Float score (higher = more relevant to follow)
|
|
"""
|
|
if not query_keywords and not keyword_variations:
|
|
return 0.0
|
|
|
|
score = 0.0
|
|
url_lower = link_url.lower()
|
|
anchor_lower = anchor_text.lower()
|
|
|
|
# Score primary keywords (exact and stemmed matching)
|
|
if query_keywords:
|
|
for keyword in query_keywords:
|
|
keyword_lower = keyword.lower()
|
|
keyword_stem = self._simple_stem(keyword_lower)
|
|
|
|
# Exact match for keywords in URL path
|
|
if keyword_lower in url_lower:
|
|
score += 10.0
|
|
logger.debug(f"Link URL contains exact primary keyword '{keyword_lower}': {link_url}")
|
|
# Stemmed match for keywords in URL path (70% of exact)
|
|
elif keyword_stem in url_lower and keyword_stem != keyword_lower:
|
|
score += 7.0
|
|
logger.debug(f"Link URL contains stemmed primary keyword '{keyword_stem}' (from '{keyword_lower}'): {link_url}")
|
|
|
|
# Exact match for keywords in anchor text
|
|
if keyword_lower in anchor_lower:
|
|
score += 5.0
|
|
logger.debug(f"Link anchor contains exact primary keyword '{keyword_lower}': {anchor_text}")
|
|
# Stemmed match for keywords in anchor text (70% of exact)
|
|
elif keyword_stem in anchor_lower and keyword_stem != keyword_lower:
|
|
score += 3.5
|
|
logger.debug(f"Link anchor contains stemmed primary keyword '{keyword_stem}' (from '{keyword_lower}'): {anchor_text}")
|
|
|
|
# Score keyword variations (exact and stemmed matching, reduced weight)
|
|
if keyword_variations:
|
|
for variation in keyword_variations:
|
|
variation_lower = variation.lower()
|
|
variation_stem = self._simple_stem(variation_lower)
|
|
|
|
# Exact match for variations in URL path (60% of exact primary)
|
|
if variation_lower in url_lower:
|
|
score += 6.0
|
|
logger.debug(f"Link URL contains exact keyword variation '{variation_lower}': {link_url}")
|
|
# Stemmed match for variations in URL path (40% of exact primary)
|
|
elif variation_stem in url_lower and variation_stem != variation_lower:
|
|
score += 4.0
|
|
logger.debug(f"Link URL contains stemmed keyword variation '{variation_stem}' (from '{variation_lower}'): {link_url}")
|
|
|
|
# Exact match for variations in anchor text (60% of exact primary)
|
|
if variation_lower in anchor_lower:
|
|
score += 3.0
|
|
logger.debug(f"Link anchor contains exact keyword variation '{variation_lower}': {anchor_text}")
|
|
# Stemmed match for variations in anchor text (40% of exact primary)
|
|
elif variation_stem in anchor_lower and variation_stem != variation_lower:
|
|
score += 2.0
|
|
logger.debug(f"Link anchor contains stemmed keyword variation '{variation_stem}' (from '{variation_lower}'): {anchor_text}")
|
|
|
|
return score
|
|
|
|
def _score_page(self, page_data: Dict[str, str], query_keywords: Optional[List[str]] = None) -> float:
|
|
"""
|
|
Score a page based on content quality metrics and optional query relevance.
|
|
Higher scores indicate more valuable content.
|
|
|
|
Scoring factors:
|
|
- Content length (more content = higher score)
|
|
- Title quality (descriptive titles = higher score)
|
|
- URL quality (cleaner URLs = higher score)
|
|
- Content density (unique words = higher score)
|
|
- Query relevance (if keywords provided, matching content scores higher)
|
|
|
|
Args:
|
|
page_data: Dict with 'text', 'title', 'url' keys
|
|
query_keywords: Optional list of keywords from user's query for relevance scoring
|
|
|
|
Returns:
|
|
Float score (0-100+, can exceed 100 with relevance bonus)
|
|
"""
|
|
score = 0.0
|
|
|
|
# Factor 1: Content length (0-30 points - reduced from 40 to make room for relevance)
|
|
# Award points for substantial content, max at 5000 chars
|
|
text_len = len(page_data.get('text', ''))
|
|
score += min(30, text_len / 167) # 5000 chars = 30 points
|
|
|
|
# Factor 2: Title quality (0-15 points - reduced from 20)
|
|
# Descriptive titles with meaningful length get higher scores
|
|
title = page_data.get('title', '')
|
|
if title and title != "Untitled":
|
|
title_len = len(title)
|
|
if 10 <= title_len <= 100: # Sweet spot for titles
|
|
score += 15
|
|
elif 5 <= title_len < 10 or 100 < title_len <= 150:
|
|
score += 8
|
|
else:
|
|
score += 3
|
|
|
|
# Factor 3: URL quality (0-15 points - reduced from 20)
|
|
# Prefer shorter, cleaner URLs over long query-string heavy URLs
|
|
url = page_data.get('url', '')
|
|
if url:
|
|
# Penalize query strings and fragments
|
|
if '?' in url:
|
|
score += 3 # Query strings often mean dynamic/less important content
|
|
elif '#' in url:
|
|
score += 8 # Fragments are slightly better
|
|
else:
|
|
score += 15 # Clean URLs are best
|
|
|
|
# Bonus for human-readable paths
|
|
path_parts = url.split('/')
|
|
if any(len(part) > 3 and part.replace('-', '').replace('_', '').isalnum() for part in path_parts):
|
|
score += 3 # Readable path segments
|
|
|
|
# Factor 4: Content density (0-15 points - reduced from 20)
|
|
# Reward pages with substantial, non-repetitive content
|
|
words = [] # Initialize words for use in Factor 5
|
|
if text_len > 0:
|
|
# Check for unique words (simple heuristic for content quality)
|
|
words = page_data.get('text', '').lower().split()
|
|
unique_words = len(set(words))
|
|
if len(words) > 0:
|
|
uniqueness_ratio = unique_words / len(words)
|
|
score += uniqueness_ratio * 15 # Higher uniqueness = better content
|
|
|
|
# Factor 5: Query relevance (0-40 points - NEW!)
|
|
# If user's query keywords are provided, boost pages that contain them
|
|
if query_keywords and len(query_keywords) > 0:
|
|
text_lower = page_data.get('text', '').lower()
|
|
title_lower = title.lower()
|
|
url_lower = url.lower()
|
|
|
|
keyword_matches = 0
|
|
keyword_density = 0.0
|
|
|
|
for keyword in query_keywords:
|
|
keyword_lower = keyword.lower()
|
|
|
|
# Count occurrences in different sections with different weights
|
|
text_count = text_lower.count(keyword_lower)
|
|
title_count = title_lower.count(keyword_lower)
|
|
url_count = url_lower.count(keyword_lower)
|
|
|
|
if text_count > 0:
|
|
keyword_matches += 1
|
|
keyword_density += text_count
|
|
|
|
# Bonus for keywords in title (very relevant)
|
|
if title_count > 0:
|
|
score += 5 * title_count # Up to 5 points per title match
|
|
|
|
# HIGH BONUS for keywords in URL (strong signal for topic-specific pages)
|
|
# URLs are hand-crafted structure, keywords there mean this page is ABOUT that topic
|
|
if url_count > 0:
|
|
score += 15 * url_count # Up to 15 points per URL match (5x higher than before!)
|
|
logger.debug(f"URL contains keyword '{keyword_lower}' ({url_count}x) - boosting score by {15 * url_count} points")
|
|
|
|
# Award points based on what percentage of query keywords matched
|
|
if len(query_keywords) > 0:
|
|
match_ratio = keyword_matches / len(query_keywords)
|
|
score += match_ratio * 20 # Up to 20 points for matching all keywords
|
|
|
|
# Award points for keyword density (how often keywords appear)
|
|
if len(words) > 0 and keyword_density > 0:
|
|
density_score = min(10, (keyword_density / len(words)) * 1000) # Up to 10 points
|
|
score += density_score
|
|
|
|
return score
|
|
|
|
async def fetch_feeds(
|
|
self,
|
|
base_url: str,
|
|
html: str = None,
|
|
max_sitemaps: int = 10,
|
|
feed_urls: List[str] = None,
|
|
) -> List[FeedItem]:
|
|
"""
|
|
Hydra mode: Fetch RSS/Atom/Sitemap feeds.
|
|
|
|
Args:
|
|
base_url: Base URL to discover feeds from
|
|
html: Optional HTML content (if already fetched)
|
|
max_sitemaps: Maximum nested sitemaps to follow
|
|
feed_urls: Optional list of known feed URLs to fetch directly
|
|
|
|
Returns:
|
|
List of FeedItems with discovered URLs
|
|
"""
|
|
all_items = []
|
|
processed_feeds = set()
|
|
|
|
# Use provided feed URLs or discover them
|
|
if feed_urls:
|
|
# Use known feeds directly
|
|
feeds_to_check = list(feed_urls)
|
|
self._last_discovered_feeds = [] # No new discovery
|
|
elif html:
|
|
feeds_to_check = discover_feeds(html, base_url)
|
|
self._last_discovered_feeds = list(feeds_to_check) # Track discovered
|
|
else:
|
|
# Try common sitemap locations
|
|
parsed = Uri(base_url)
|
|
base = f"{parsed.scheme}://{parsed.hostname}"
|
|
if parsed.port and parsed.port not in (80, 443):
|
|
base += f":{parsed.port}"
|
|
feeds_to_check = [
|
|
base + '/sitemap.xml',
|
|
base + '/sitemap_index.xml',
|
|
base + '/feed/',
|
|
base + '/rss/',
|
|
base + '/atom.xml',
|
|
]
|
|
self._last_discovered_feeds = [] # Will track which ones actually work
|
|
|
|
# Queue for processing (includes nested sitemaps)
|
|
to_process = list(feeds_to_check)
|
|
sitemap_count = 0
|
|
|
|
while to_process and sitemap_count < max_sitemaps:
|
|
feed_url = to_process.pop(0)
|
|
if feed_url in processed_feeds:
|
|
continue
|
|
processed_feeds.add(feed_url)
|
|
|
|
try:
|
|
# Fetch feed content
|
|
async with self.session.get(
|
|
feed_url,
|
|
headers={'User-Agent': self.user_agent},
|
|
timeout=aiohttp.ClientTimeout(total=30),
|
|
allow_redirects=True,
|
|
) as response:
|
|
if response.status != 200:
|
|
logger.debug(f"Feed {feed_url} returned {response.status}")
|
|
continue
|
|
|
|
content = await response.text()
|
|
|
|
# Parse feed
|
|
items, sub_feeds = parse_feed(content, feed_url)
|
|
all_items.extend(items)
|
|
|
|
# Add nested sitemaps to queue
|
|
for sub_feed in sub_feeds:
|
|
if sub_feed not in processed_feeds:
|
|
to_process.append(sub_feed)
|
|
|
|
if items:
|
|
logger.info(f"Hydra: Found {len(items)} URLs in {feed_url}")
|
|
# Track working feeds for persistence
|
|
if not feed_urls and feed_url not in self._last_discovered_feeds:
|
|
self._last_discovered_feeds.append(feed_url)
|
|
|
|
sitemap_count += 1
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.debug(f"Timeout fetching feed: {feed_url}")
|
|
except Exception as e:
|
|
logger.debug(f"Error fetching feed {feed_url}: {e}")
|
|
|
|
logger.info(f"Hydra mode: Discovered {len(all_items)} URLs from {len(processed_feeds)} feeds")
|
|
return all_items
|
|
|
|
async def fetch_with_depth(
|
|
self,
|
|
start_url: str,
|
|
depth: int = 2,
|
|
max_pages: int = 10,
|
|
query_keywords: Optional[List[str]] = None,
|
|
keyword_variations: Optional[List[str]] = None,
|
|
progress_callback = None,
|
|
cache_check_callback = None,
|
|
should_continue_callback = None, # Deprecated - kept for compatibility
|
|
user_query: Optional[str] = None, # Deprecated - kept for compatibility
|
|
mode: CrawlMode = CrawlMode.TEXT,
|
|
media_callback = None, # Callback for discovered media: async fn(media_item: Dict) -> None
|
|
page_callback = None, # Callback for page HTML: async fn(url: str, html: str) -> None
|
|
uris_total_callback = None, # Callback for URI total updates: fn(total: int) -> None
|
|
initial_visited: Optional[Set[str]] = None, # Pre-visited URLs for resume support
|
|
hydra_urls: Optional[List[str]] = None, # Priority URLs from feeds/sitemaps (hydra mode)
|
|
) -> List[Dict[str, str]]:
|
|
"""
|
|
Intelligent keyword-driven crawl strategy with domain prioritization.
|
|
|
|
Supports multiple crawl modes:
|
|
- TEXT (default): Extract text content only
|
|
- IMAGES: Collect images from pages
|
|
- VIDEOS: Collect videos from pages
|
|
- MEDIA: Collect all media (images + videos + audio)
|
|
- ALL: Uber crawl - text + all media
|
|
|
|
Crawl strategy:
|
|
1. Always fetch target URI first (the URL user asked about) and extract ALL links
|
|
2. Register links in link_registry with seen_count (boost for repeated links)
|
|
3. Sort links: ALL same-domain before ANY cross-domain
|
|
4. Crawl high-scoring links automatically up to max_pages
|
|
5. Cross-domain links only crawled if score >= 30 (exceptionally high relevance)
|
|
|
|
Example: Searching "dropbox encryption" on https://dropbox.com
|
|
- Target page fetched → extracts links (same-domain and cross-domain)
|
|
- Same-domain links with keyword matches crawled first
|
|
- Cross-domain links only crawled if score >= 30
|
|
- Continues until max_pages or runs out of high-scoring links
|
|
|
|
Args:
|
|
start_url: Starting URL
|
|
depth: How many levels deep to crawl (0 = just this page, 1 = page + links, etc.)
|
|
Use -1 for unlimited depth (full domain crawl)
|
|
max_pages: Maximum number of pages to fetch (default 10, use -1 for unlimited)
|
|
query_keywords: Optional list of primary keywords from user's query (full scoring weight)
|
|
keyword_variations: Optional list of keyword variations/synonyms (60% scoring weight)
|
|
progress_callback: Optional callback for progress updates
|
|
cache_check_callback: Optional callback for cache checking (not currently used)
|
|
should_continue_callback: DEPRECATED - no longer used
|
|
user_query: DEPRECATED - no longer used
|
|
mode: CrawlMode - what type of content to extract (default: TEXT)
|
|
media_callback: Optional async callback for discovered media items.
|
|
Called with dict containing 'url', 'media_type', 'source_page', etc.
|
|
Use this to process/store media as it's discovered.
|
|
|
|
Returns:
|
|
List of dicts with keys: url, title, text, fetched_at, score, depth, links_from_page
|
|
If mode includes media, each page dict also contains 'media' list
|
|
"""
|
|
# Handle unlimited depth/pages
|
|
unlimited_depth = (depth == -1)
|
|
unlimited_pages = (max_pages == -1)
|
|
if unlimited_depth:
|
|
depth = 999999 # Effectively unlimited
|
|
if unlimited_pages:
|
|
max_pages = 999999 # Effectively unlimited
|
|
all_pages = []
|
|
visited = set(initial_visited) if initial_visited else set()
|
|
base_domain = self._get_domain(start_url)
|
|
|
|
# Log resume info
|
|
if initial_visited:
|
|
logger.info(f"Resuming with {len(initial_visited)} previously visited pages")
|
|
|
|
# Track all discovered links: {url: {'anchor_texts': [str], 'seen_count': int, 'total_link_score': float, 'domain': str, 'is_same_domain': bool}}
|
|
link_registry = {}
|
|
|
|
# Hydra mode: Inject priority URLs from feeds/sitemaps
|
|
if hydra_urls:
|
|
logger.info(f"Hydra mode: Injecting {len(hydra_urls)} priority URLs from feeds/sitemaps")
|
|
for url in hydra_urls:
|
|
if url not in visited:
|
|
domain = self._get_domain(url)
|
|
is_same = (domain == base_domain)
|
|
link_registry[url] = {
|
|
'anchor_texts': ['[hydra]'],
|
|
'seen_count': 100, # High priority
|
|
'total_link_score': 100.0, # High score to ensure they're crawled first
|
|
'domain': domain,
|
|
'is_same_domain': is_same,
|
|
'from_hydra': True,
|
|
}
|
|
|
|
# Log if using query-aware scoring
|
|
if query_keywords or keyword_variations:
|
|
logger.info(f"Using query-aware scoring with keywords: {query_keywords}, variations: {keyword_variations}")
|
|
|
|
logger.info(f"Starting intelligent crawl with depth={depth}, max_pages={max_pages}, primary_domain={base_domain}")
|
|
|
|
# PHASE 1: Always fetch target URI first
|
|
logger.info(f"Phase 1: Fetching target URI: {start_url}")
|
|
html, links = await self.fetch_webpage(
|
|
start_url,
|
|
extract_links=True,
|
|
extract_anchor_text=bool(query_keywords), # Extract anchor text only if we have keywords
|
|
cache_check_callback=cache_check_callback # Pass through SQLite3 cache callback
|
|
)
|
|
|
|
if html is None:
|
|
logger.error(f"Failed to fetch target URI {start_url}")
|
|
return []
|
|
|
|
# TEMPORARY: If HTML is empty but we got it from cache, we can still extract text/title from it
|
|
# Eventually we should store raw HTML in cache for all pages to enable proper virtual crawling
|
|
if not html:
|
|
logger.warning(f"Got empty HTML for {start_url} (likely from cache without raw_html), cannot extract links for virtual crawl")
|
|
# For now, continue with empty HTML - we'll at least process the target page even if we can't crawl links
|
|
# TODO: Store raw_html in cache for all pages to enable full virtual crawling
|
|
|
|
# Detect and skip RSS/XML feeds
|
|
if start_url.endswith(('.xml', '.rss', '.atom', 'feed', 'feeds')) or \
|
|
'/feed' in start_url or '/rss' in start_url or 'atom.xml' in start_url:
|
|
logger.warning(f"Skipping RSS/XML feed: {start_url} (detected feed pattern)")
|
|
return []
|
|
|
|
if html.strip().startswith('<?xml') or '<rss' in html[:500] or '<feed' in html[:500]:
|
|
logger.warning(f"Skipping RSS/XML feed: {start_url} (detected XML content)")
|
|
return []
|
|
|
|
# Extract target page content (in thread to not block event loop)
|
|
def _extract_content():
|
|
return self.extract_title(html), self.extract_text_content(html)
|
|
title, text = await asyncio.to_thread(_extract_content)
|
|
|
|
target_page = {
|
|
"url": start_url,
|
|
"title": title,
|
|
"text": text,
|
|
"html": html, # Include raw HTML for cache storage (enables virtual crawling)
|
|
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
|
"depth": 0,
|
|
"links_from_page": [l['url'] if isinstance(l, dict) else l for l in links] # Store for CSV
|
|
}
|
|
|
|
# Call page callback to archive raw HTML
|
|
if page_callback and html:
|
|
try:
|
|
await page_callback(start_url, html)
|
|
except Exception as e:
|
|
logger.warning(f"Page callback failed for {start_url}: {e}")
|
|
|
|
# Extract media if mode requires it
|
|
if mode in (CrawlMode.IMAGES, CrawlMode.VIDEOS, CrawlMode.MEDIA, CrawlMode.ALL):
|
|
media_items = await asyncio.to_thread(extract_media_from_html, html, start_url, mode)
|
|
target_page["media"] = media_items
|
|
logger.info(f"Extracted {len(media_items)} media items from target page")
|
|
|
|
# Call media callbacks concurrently (don't block on each one)
|
|
if media_callback:
|
|
await asyncio.gather(*[media_callback(item) for item in media_items], return_exceptions=True)
|
|
|
|
# Score the target page
|
|
target_page["score"] = self._score_page(target_page, query_keywords=query_keywords)
|
|
all_pages.append(target_page)
|
|
visited.add(start_url)
|
|
|
|
media_count = len(target_page.get("media", []))
|
|
logger.info(f"Crawled target page (depth 0, score {target_page['score']:.1f}): {len(text)} chars, {len(links)} links, {media_count} media extracted")
|
|
|
|
# Update progress callback
|
|
if progress_callback:
|
|
await progress_callback(f"📄 **Fetched page 1**\n{title[:50]}\n<{start_url}>\n~{len(text)//4} tokens (estimated)")
|
|
|
|
# PHASE 2: Register ALL links from target page (both same-domain and cross-domain)
|
|
logger.info(f"Phase 2: Registering {len(links)} links from target page")
|
|
same_domain_count = 0
|
|
cross_domain_count = 0
|
|
|
|
for link in links:
|
|
link_url, anchor_text = normalize_link(link)
|
|
|
|
# Filter: not already visited
|
|
if link_url in visited:
|
|
continue
|
|
|
|
# Track domain
|
|
link_domain = self._get_domain(link_url)
|
|
is_same_domain = (link_domain == base_domain)
|
|
|
|
if is_same_domain:
|
|
same_domain_count += 1
|
|
else:
|
|
cross_domain_count += 1
|
|
|
|
# Register or update link
|
|
if link_url not in link_registry:
|
|
link_score = self._score_link(link_url, anchor_text, query_keywords, keyword_variations) if (query_keywords or keyword_variations) else 0.0
|
|
link_registry[link_url] = {
|
|
'anchor_texts': [anchor_text],
|
|
'seen_count': 1,
|
|
'total_link_score': link_score,
|
|
'domain': link_domain,
|
|
'is_same_domain': is_same_domain
|
|
}
|
|
else:
|
|
# Link seen again - increment count and update score
|
|
link_registry[link_url]['anchor_texts'].append(anchor_text)
|
|
link_registry[link_url]['seen_count'] += 1
|
|
if query_keywords or keyword_variations:
|
|
link_score = self._score_link(link_url, anchor_text, query_keywords, keyword_variations)
|
|
link_registry[link_url]['total_link_score'] += link_score
|
|
|
|
logger.info(f"Registered {len(link_registry)} unique links from target page (same-domain: {same_domain_count}, cross-domain: {cross_domain_count})")
|
|
if uris_total_callback:
|
|
uris_total_callback(len(link_registry) + 1) # +1 for target page
|
|
|
|
# PHASE 3: Decide whether to crawl deeper
|
|
# If depth=0 or we're at max_pages, stop
|
|
if depth == 0 or (max_pages > 0 and len(all_pages) >= max_pages):
|
|
logger.info(f"Stopping crawl (depth={depth}, pages={len(all_pages)})")
|
|
return self._finalize_results(all_pages)
|
|
|
|
# If target page has high score (good keyword matches), maybe we're done
|
|
# But if it has low score, we should explore links
|
|
# Threshold: if target page scores < 50, explore links
|
|
if target_page['score'] >= 50 and query_keywords:
|
|
logger.info(f"Target page has high score ({target_page['score']:.1f}), may not need to crawl deeper")
|
|
# Still crawl a few top links if they have strong keyword matches
|
|
else:
|
|
logger.info(f"Target page has low score ({target_page['score']:.1f}), will explore keyword-matching links")
|
|
|
|
# PHASE 4: Sort links by combined score (keyword_score + seen_count_boost)
|
|
# STRATEGY: Prioritize same-domain links, only allow cross-domain if exceptionally high score
|
|
# seen_count_boost = seen_count * 2 (each additional sighting adds 2 points)
|
|
scored_links = []
|
|
for link_url, meta in link_registry.items():
|
|
seen_count_boost = (meta['seen_count'] - 1) * 2 # First sighting doesn't get boost
|
|
combined_score = meta['total_link_score'] + seen_count_boost
|
|
scored_links.append({
|
|
'url': link_url,
|
|
'anchor_texts': meta['anchor_texts'],
|
|
'seen_count': meta['seen_count'],
|
|
'link_score': meta['total_link_score'],
|
|
'combined_score': combined_score,
|
|
'domain': meta['domain'],
|
|
'is_same_domain': meta['is_same_domain']
|
|
})
|
|
|
|
# Sort by: same_domain first (True sorts before False), then by combined_score
|
|
# This ensures ALL same-domain links are processed before ANY cross-domain links
|
|
scored_links.sort(key=lambda x: (not x['is_same_domain'], -x['combined_score']))
|
|
|
|
# Log top scoring links by domain
|
|
same_domain_links = [l for l in scored_links if l['is_same_domain'] and l['combined_score'] > 0][:10]
|
|
cross_domain_links = [l for l in scored_links if not l['is_same_domain'] and l['combined_score'] > 0][:5]
|
|
|
|
if same_domain_links:
|
|
logger.info(f"Top {len(same_domain_links)} same-domain scoring links:")
|
|
for i, link in enumerate(same_domain_links, 1):
|
|
logger.info(f" {i}. [{link['combined_score']:.1f}] {link['url']} (keyword={link['link_score']:.1f}, seen={link['seen_count']}x)")
|
|
|
|
if cross_domain_links:
|
|
logger.info(f"Top {len(cross_domain_links)} cross-domain scoring links (will only crawl if score > 30):")
|
|
for i, link in enumerate(cross_domain_links, 1):
|
|
logger.info(f" {i}. [{link['combined_score']:.1f}] {link['url']} ({link['domain']}, keyword={link['link_score']:.1f})")
|
|
|
|
# PHASE 5: Crawl links iteratively for each depth level (1 to depth)
|
|
# Handle unlimited depth (-1) by using large upper bound
|
|
max_depth = depth if depth > 0 else 10000
|
|
for current_depth in range(1, max_depth + 1):
|
|
if max_pages > 0 and len(all_pages) >= max_pages:
|
|
break
|
|
|
|
remaining = "unlimited" if max_pages < 0 else str(max_pages - len(all_pages))
|
|
logger.info(f"Phase 5.{current_depth}: Crawling depth-{current_depth} links (max {remaining} more pages)")
|
|
|
|
# Re-score and sort all unvisited links
|
|
candidates = []
|
|
for link_url, meta in link_registry.items():
|
|
if link_url in visited:
|
|
continue
|
|
seen_count_boost = (meta['seen_count'] - 1) * 2
|
|
combined_score = meta['total_link_score'] + seen_count_boost
|
|
candidates.append({
|
|
'url': link_url,
|
|
'anchor_texts': meta['anchor_texts'],
|
|
'seen_count': meta['seen_count'],
|
|
'link_score': meta['total_link_score'],
|
|
'combined_score': combined_score,
|
|
'domain': meta['domain'],
|
|
'is_same_domain': meta['is_same_domain']
|
|
})
|
|
|
|
if not candidates:
|
|
logger.info(f"No more unvisited links at depth {current_depth}")
|
|
break
|
|
|
|
# Sort: same_domain first, then by combined_score
|
|
candidates.sort(key=lambda x: (not x['is_same_domain'], -x['combined_score']))
|
|
|
|
# Log top candidates for this depth
|
|
top_same = [l for l in candidates if l['is_same_domain'] and l['combined_score'] > 0][:10]
|
|
if top_same:
|
|
logger.info(f"Top {len(top_same)} depth-{current_depth} same-domain candidates:")
|
|
for i, link in enumerate(top_same, 1):
|
|
logger.info(f" {i}. [{link['combined_score']:.1f}] {link['url']} (keyword={link['link_score']:.1f}, seen={link['seen_count']}x)")
|
|
|
|
# Track pages added at this depth level
|
|
pages_at_this_depth = 0
|
|
|
|
# Pre-filter candidates to get valid URLs
|
|
valid_candidates = []
|
|
for link_data in candidates:
|
|
if max_pages > 0 and len(all_pages) + len(valid_candidates) >= max_pages:
|
|
break
|
|
# Apply score thresholds
|
|
if link_data['is_same_domain']:
|
|
if query_keywords and link_data['link_score'] <= 0:
|
|
continue
|
|
else:
|
|
if link_data['combined_score'] < 30:
|
|
continue
|
|
link_url = link_data['url']
|
|
if link_url in visited:
|
|
continue
|
|
valid_candidates.append(link_data)
|
|
visited.add(link_url) # Mark as visited to prevent duplicates in batch
|
|
|
|
if not valid_candidates:
|
|
logger.info(f"No valid candidates at depth {current_depth}")
|
|
break
|
|
|
|
# Parallel fetching with semaphore (fast_mode uses multiple workers)
|
|
semaphore = asyncio.Semaphore(self.max_page_workers)
|
|
if self.fast_mode and self.max_page_workers > 1:
|
|
logger.info(f"Parallel fetch: {len(valid_candidates)} pages with {self.max_page_workers} workers")
|
|
|
|
async def fetch_one(link_data):
|
|
"""Fetch a single page with semaphore control."""
|
|
async with semaphore:
|
|
link_url = link_data['url']
|
|
try:
|
|
html, child_links = await self.fetch_webpage(
|
|
link_url,
|
|
extract_links=True,
|
|
extract_anchor_text=bool(query_keywords),
|
|
cache_check_callback=cache_check_callback
|
|
)
|
|
return {'url': link_url, 'html': html, 'child_links': child_links, 'link_data': link_data, 'error': None}
|
|
except Exception as e:
|
|
return {'url': link_url, 'html': None, 'child_links': [], 'link_data': link_data, 'error': str(e)}
|
|
|
|
# Fetch pages in parallel, process each as it completes (fractal/streaming)
|
|
fetch_tasks = [asyncio.create_task(fetch_one(c)) for c in valid_candidates]
|
|
logger.info(f"Streaming {len(fetch_tasks)} fetches (processing as each completes)")
|
|
|
|
# Process results as they arrive (not waiting for all to complete)
|
|
processed_count = 0
|
|
for coro in asyncio.as_completed(fetch_tasks):
|
|
result = await coro
|
|
if max_pages > 0 and len(all_pages) >= max_pages:
|
|
logger.info(f"Reached max_pages limit ({max_pages}), stopping")
|
|
break
|
|
|
|
link_url = result['url']
|
|
html = result['html']
|
|
child_links = result['child_links']
|
|
link_data = result['link_data']
|
|
processed_count += 1
|
|
|
|
if not html:
|
|
if result['error']:
|
|
logger.warning(f"Skipping {link_url} (fetch failed: {result['error']})")
|
|
else:
|
|
logger.warning(f"Skipping {link_url} (fetch failed)")
|
|
continue
|
|
|
|
# Skip RSS/XML feeds
|
|
if link_url.endswith(('.xml', '.rss', '.atom', 'feed', 'feeds')) or \
|
|
'/feed' in link_url or '/rss' in link_url or 'atom.xml' in link_url:
|
|
logger.warning(f"Skipping RSS/XML feed: {link_url}")
|
|
continue
|
|
if html.strip().startswith('<?xml') or '<rss' in html[:500] or '<feed' in html[:500]:
|
|
logger.warning(f"Skipping RSS/XML feed: {link_url}")
|
|
continue
|
|
|
|
# Extract content (in thread to not block event loop)
|
|
def _extract_page_content():
|
|
return self.extract_title(html), self.extract_text_content(html)
|
|
title, text = await asyncio.to_thread(_extract_page_content)
|
|
|
|
page_data = {
|
|
"url": link_url,
|
|
"title": title,
|
|
"text": text,
|
|
"html": html,
|
|
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
|
"depth": current_depth,
|
|
"links_from_page": [l['url'] if isinstance(l, dict) else l for l in child_links]
|
|
}
|
|
|
|
# Call page callback to archive raw HTML
|
|
if page_callback and html:
|
|
try:
|
|
await page_callback(link_url, html)
|
|
except Exception as e:
|
|
logger.warning(f"Page callback failed for {link_url}: {e}")
|
|
|
|
# Extract media if mode requires it
|
|
if mode in (CrawlMode.IMAGES, CrawlMode.VIDEOS, CrawlMode.MEDIA, CrawlMode.ALL):
|
|
media_items = await asyncio.to_thread(extract_media_from_html, html, link_url, mode)
|
|
page_data["media"] = media_items
|
|
|
|
# Call media callbacks concurrently (don't block on each one)
|
|
if media_callback:
|
|
await asyncio.gather(*[media_callback(item) for item in media_items], return_exceptions=True)
|
|
|
|
page_data["score"] = self._score_page(page_data, query_keywords=query_keywords)
|
|
all_pages.append(page_data)
|
|
pages_at_this_depth += 1
|
|
|
|
media_count = len(page_data.get("media", []))
|
|
logger.info(f"Crawled {link_url} (depth {current_depth}, score {page_data['score']:.1f}): {len(text)} chars, {len(child_links)} links, {media_count} media")
|
|
|
|
if progress_callback:
|
|
await progress_callback(f"📄 **Fetched page {len(all_pages)}** (depth {current_depth})\n{title[:50]}\n<{link_url}>")
|
|
|
|
# Register newly discovered links for next depth level
|
|
for link in child_links:
|
|
child_url, anchor_text = normalize_link(link)
|
|
|
|
if child_url in visited:
|
|
continue
|
|
|
|
child_domain = self._get_domain(child_url)
|
|
is_same_domain = (child_domain == base_domain)
|
|
|
|
if child_url not in link_registry:
|
|
link_score = self._score_link(child_url, anchor_text, query_keywords, keyword_variations) if (query_keywords or keyword_variations) else 0.0
|
|
link_registry[child_url] = {
|
|
'anchor_texts': [anchor_text],
|
|
'seen_count': 1,
|
|
'total_link_score': link_score,
|
|
'domain': child_domain,
|
|
'is_same_domain': is_same_domain
|
|
}
|
|
else:
|
|
link_registry[child_url]['anchor_texts'].append(anchor_text)
|
|
link_registry[child_url]['seen_count'] += 1
|
|
if query_keywords or keyword_variations:
|
|
link_score = self._score_link(child_url, anchor_text, query_keywords, keyword_variations)
|
|
link_registry[child_url]['total_link_score'] += link_score
|
|
|
|
# Update total after processing child links
|
|
if uris_total_callback:
|
|
uris_total_callback(len(link_registry) + 1)
|
|
|
|
logger.info(f"Depth {current_depth} complete: processed {processed_count}/{len(fetch_tasks)} results, crawled {pages_at_this_depth} pages")
|
|
|
|
# Stop if no pages were crawled at this depth (all links exhausted or skipped)
|
|
if pages_at_this_depth == 0:
|
|
logger.info("No pages crawled at this depth - stopping crawl")
|
|
break
|
|
|
|
return self._finalize_results(all_pages)
|
|
|
|
def _finalize_results(self, all_pages: List[Dict]) -> List[Dict]:
|
|
"""Sort and finalize crawl results"""
|
|
# Sort by depth first (main page first), then by score within each depth
|
|
all_pages.sort(key=lambda p: (p.get("depth", 0), -p["score"]))
|
|
|
|
logger.info(f"Crawl complete: fetched {len(all_pages)} pages")
|
|
if len(all_pages) > 1:
|
|
logger.info(f"Score range: {all_pages[-1]['score']:.1f} to {all_pages[0]['score']:.1f}")
|
|
|
|
return all_pages
|
|
|
|
def select_pages_by_token_budget(
|
|
self,
|
|
pages: list,
|
|
token_budget: int,
|
|
reserve_tokens: int = 1000
|
|
) -> list:
|
|
"""
|
|
Select highest-value pages that fit within token budget using greedy knapsack algorithm.
|
|
|
|
Args:
|
|
pages: List of page dicts with 'score', 'tokens', 'url', 'title', 'text' keys
|
|
token_budget: Maximum tokens available for page content
|
|
reserve_tokens: Tokens to reserve for output and system prompts
|
|
|
|
Returns:
|
|
List of selected pages (sorted by score descending) that fit in budget
|
|
"""
|
|
if not pages:
|
|
return []
|
|
|
|
# Adjust budget for reserved tokens
|
|
effective_budget = max(0, token_budget - reserve_tokens)
|
|
|
|
if effective_budget <= 0:
|
|
logger.warning(f"Token budget too low after reserving {reserve_tokens} tokens")
|
|
return []
|
|
|
|
# Calculate value/token ratio for each page (efficiency metric)
|
|
pages_with_ratio = []
|
|
for page in pages:
|
|
tokens = page.get('tokens', len(page.get('text', '')) // 4) # Fallback estimate
|
|
score = page.get('score', 0)
|
|
if tokens > 0:
|
|
ratio = score / tokens
|
|
pages_with_ratio.append({
|
|
**page,
|
|
'tokens': tokens,
|
|
'value_per_token': ratio
|
|
})
|
|
|
|
if not pages_with_ratio:
|
|
return []
|
|
|
|
# Greedy algorithm: Sort by value/token ratio (descending)
|
|
# This maximizes value while minimizing token usage
|
|
pages_with_ratio.sort(key=lambda p: p['value_per_token'], reverse=True)
|
|
|
|
# Select pages greedily until budget exhausted
|
|
selected = []
|
|
total_tokens = 0
|
|
|
|
for page in pages_with_ratio:
|
|
page_tokens = page['tokens']
|
|
if total_tokens + page_tokens <= effective_budget:
|
|
selected.append(page)
|
|
total_tokens += page_tokens
|
|
logger.debug(f"Selected page: {page['title']} (score={page['score']:.1f}, tokens={page_tokens}, ratio={page['value_per_token']:.3f})")
|
|
else:
|
|
logger.debug(f"Skipped page: {page['title']} (would exceed budget: {total_tokens + page_tokens} > {effective_budget})")
|
|
|
|
# Sort selected pages by score (highest first) for presentation
|
|
selected.sort(key=lambda p: p['score'], reverse=True)
|
|
|
|
logger.info(f"Selected {len(selected)}/{len(pages)} pages using {total_tokens}/{effective_budget} tokens (budget utilization: {total_tokens/effective_budget*100:.1f}%)")
|
|
|
|
return selected
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Test the async web fetcher
|
|
async def test():
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
fetcher = AsyncWebFetcher()
|
|
|
|
# Test single page fetch
|
|
print("Testing single page fetch...")
|
|
results = await fetcher.fetch_with_depth("https://example.com", depth=0)
|
|
|
|
if results:
|
|
print(f"\nFetched: {results[0]['title']}")
|
|
print(f"Content length: {len(results[0]['text'])} chars")
|
|
print(f"Preview: {results[0]['text'][:200]}...")
|
|
else:
|
|
print("Failed to fetch")
|
|
|
|
asyncio.run(test())
|