from __future__ import annotations import json import re from datetime import datetime, timezone from email.utils import parsedate_to_datetime from typing import Any from urllib.parse import parse_qs, urljoin, urlparse from zoneinfo import ZoneInfo from bs4 import BeautifulSoup, Tag class ConfigError(ValueError): pass def normalize_config(value: dict[str, Any]) -> dict[str, Any]: if not isinstance(value, dict) or not value: raise ConfigError("config is required and cannot be empty") if "discovery" not in value: follow_links = value.get("follow_links", False) config = { "version": 1, "discovery": {"type": "rss", "limit": value.get("max_items", 20)}, "detail": { "enabled": follow_links, "always": follow_links, "root_selector": value.get("content_selector", ""), "fields": { "text": { "selector": value.get("text_selector") or ":root", "extract": "text", "required": True, } }, "media": [ {"type": "photo", "selector": "img", "attributes": ["src", "data-src"]}, {"type": "video", "selector": "iframe, video, source", "attributes": ["src", "data-src"]}, ], }, "access": {"type": value.get("access", "auto")}, "retry": {"attempts": 1, "delay_seconds": 0}, "min_text_length": value.get("min_text_length", 0), } else: config = dict(value) if config.get("version", 1) != 1: raise ConfigError("only config.version=1 is supported") discovery = config.get("discovery") if not isinstance(discovery, dict) or discovery.get("type") not in {"rss", "html"}: raise ConfigError("discovery.type must be rss or html") try: limit = int(discovery.get("limit", 20)) except (TypeError, ValueError) as exc: raise ConfigError("discovery.limit must be an integer") from exc if not 1 <= limit <= 100: raise ConfigError("discovery.limit must be between 1 and 100") discovery["limit"] = limit if discovery["type"] == "html" and not str(discovery.get("item_selector") or "").strip(): raise ConfigError("discovery.item_selector is required for html discovery") access = config.get("access", {"type": "auto"}) if isinstance(access, str): access = {"type": access} if not isinstance(access, dict) or access.get("type", "auto") not in {"auto", "http", "browser", "cloudflare"}: raise ConfigError("access.type must be auto, http, browser or cloudflare") config["access"] = access detail = config.get("detail") or {"enabled": False} if not isinstance(detail, dict): raise ConfigError("detail must be an object") detail["enabled"] = bool(detail.get("enabled", False)) detail["always"] = bool(detail.get("always", detail["enabled"])) if detail["enabled"]: if not str(detail.get("root_selector") or "").strip(): raise ConfigError("detail.root_selector is required when detail is enabled") if not isinstance(detail.get("fields") or {}, dict): raise ConfigError("detail.fields must be an object") if not isinstance(detail.get("media") or [], list): raise ConfigError("detail.media must be an array") if detail.get("transport", "browser") not in {"http", "fetch", "browser"}: raise ConfigError("detail.transport must be http, fetch or browser") detail["transport"] = detail.get("transport", "browser") config["detail"] = detail retry = config.get("retry") or {} try: attempts = int(retry.get("attempts", 2)) delay = float(retry.get("delay_seconds", 2)) timeout = float(retry.get("timeout_seconds", 90)) except (TypeError, ValueError) as exc: raise ConfigError("retry values must be numeric") from exc if not 1 <= attempts <= 5 or not 0 <= delay <= 60 or not 10 <= timeout <= 300: raise ConfigError("retry attempts must be 1..5, delay_seconds 0..60 and timeout_seconds 10..300") config["retry"] = {"attempts": attempts, "delay_seconds": delay, "timeout_seconds": timeout} try: min_length = int(config.get("min_text_length", 0)) except (TypeError, ValueError) as exc: raise ConfigError("min_text_length must be an integer") from exc if not 0 <= min_length <= 100_000: raise ConfigError("min_text_length must be between 0 and 100000") config["min_text_length"] = min_length return config def nested_value(value: Any, path: str) -> Any: current = value for part in path.split("."): if isinstance(current, dict): current = current.get(part) else: return None return current def json_path_values(value: Any, path: str) -> list[Any]: parts = path.split(".") found: list[Any] = [] def visit(node: Any, index: int) -> None: if index == len(parts): found.append(node) return if isinstance(node, list): for entry in node: visit(entry, index) elif isinstance(node, dict): if parts[index] in node: visit(node[parts[index]], index + 1) for entry in node.values(): if isinstance(entry, (dict, list)): visit(entry, index) visit(value, 0) return found def candidate_elements(root: Tag | BeautifulSoup, candidate: dict[str, Any]) -> list[Tag]: selectors = candidate.get("selectors") or candidate.get("selector") or [] if isinstance(selectors, str): selectors = [selectors] elements: list[Tag] = [] for selector in selectors: if selector == ":root": elements.append(root) elif str(selector).strip(): elements.extend(root.select(str(selector))) return elements def element_value(element: Tag, candidate: dict[str, Any]) -> Any: mode = str(candidate.get("extract") or "text") if mode == "text": return element.get_text("\n", strip=True) if mode == "html": return element.decode_contents() if mode == "attr": attributes = candidate.get("attributes") or candidate.get("attribute") or [] if isinstance(attributes, str): attributes = [attributes] for attribute in attributes: value = element.get(str(attribute)) if value: return value return None if mode == "json": try: payload = json.loads(element.string or element.get_text("", strip=True)) except (TypeError, json.JSONDecodeError): return None values = json_path_values(payload, str(candidate.get("path") or "")) return next((entry for entry in values if entry is not None and entry != ""), None) raise ConfigError(f"unsupported extract mode: {mode}") def parse_date(value: Any, candidate: dict[str, Any]) -> str | None: if not value: return None raw = str(value).strip() try: parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: try: parsed = parsedate_to_datetime(raw) except (TypeError, ValueError, OverflowError): parsed = None if parsed is None: formats = candidate.get("formats") or candidate.get("date_format") or [] if isinstance(formats, str): formats = [formats] for date_format in formats: try: parsed = datetime.strptime(raw, str(date_format)) break except ValueError: continue if parsed is None: return None if parsed.tzinfo is None: try: parsed = parsed.replace(tzinfo=ZoneInfo(str(candidate.get("timezone") or "UTC"))) except Exception: parsed = parsed.replace(tzinfo=timezone.utc) return parsed.astimezone(timezone.utc).isoformat() def apply_regex(value: Any, candidate: dict[str, Any]) -> Any: pattern = candidate.get("regex") if not pattern or value is None: return value match = re.search(str(pattern), str(value), flags=re.DOTALL) if not match: return None group = candidate.get("group", 1 if match.lastindex else 0) try: return match.group(group) except (IndexError, KeyError): return None def extract_field( root: Tag | BeautifulSoup | None, rule: dict[str, Any], source: dict[str, Any] | None = None, *, field_name: str = "", ) -> Any: candidates = rule.get("candidates") or [rule] for candidate in candidates: if not isinstance(candidate, dict): continue values: list[Any] = [] if candidate.get("source"): values.append(nested_value(source or {}, str(candidate["source"]))) elif root is not None: values.extend(element_value(element, candidate) for element in candidate_elements(root, candidate)) for value in values: value = apply_regex(value, candidate) if value is None or (isinstance(value, str) and not value.strip()): continue if field_name == "published_at" or candidate.get("type") == "date": value = parse_date(value, candidate) if not value: continue if isinstance(value, str): value = value.strip() if len(str(value)) < int(rule.get("min_length", 0)): continue return value return None def extract_fields( root: Tag | BeautifulSoup | None, fields: dict[str, Any], source: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], list[dict[str, str]]]: values: dict[str, Any] = {} errors: list[dict[str, str]] = [] for name, rule in fields.items(): if not isinstance(rule, dict): errors.append({"field": name, "error": "field rule must be an object"}) continue try: value = extract_field(root, rule, source, field_name=name) except Exception as exc: errors.append({"field": name, "error": str(exc)}) continue if value is None and rule.get("required"): errors.append({"field": name, "error": "required field not found"}) elif value is not None: values[name] = value return values, errors def youtube_url(value: str, base_url: str = "") -> str | None: url = urljoin(base_url, str(value or "").strip()) parsed = urlparse(url) host = (parsed.hostname or "").lower().removeprefix("www.").removeprefix("m.") video_id = "" if host == "youtu.be": video_id = parsed.path.strip("/").split("/", 1)[0] elif host in {"youtube.com", "youtube-nocookie.com"}: if parsed.path == "/watch": video_id = (parse_qs(parsed.query).get("v") or [""])[0] elif parsed.path.startswith(("/embed/", "/shorts/", "/live/")): video_id = parsed.path.strip("/").split("/", 1)[1] if not re.fullmatch(r"[A-Za-z0-9_-]{6,20}", video_id): return None return f"https://www.youtube.com/watch?v={video_id}" def srcset_urls(value: str) -> list[str]: return [entry.strip().split()[0] for entry in value.split(",") if entry.strip()] def extract_media(root: Tag | BeautifulSoup, specs: list[dict[str, Any]], base_url: str) -> list[dict[str, str]]: media: list[dict[str, str]] = [] for spec in specs: if not isinstance(spec, dict): continue media_type = str(spec.get("type") or "photo") attributes = spec.get("attributes") or spec.get("attribute") or ["src"] if isinstance(attributes, str): attributes = [attributes] for element in candidate_elements(root, spec): raw_values: list[str] = [] for attribute in attributes: raw = element.get(str(attribute)) if not raw: continue raw_values.extend(srcset_urls(str(raw)) if attribute == "srcset" else [str(raw)]) break for raw in raw_values: url = urljoin(base_url, raw.strip()) if not url.startswith(("http://", "https://")): continue item_type = media_type provider = "" if media_type == "video": normalized = youtube_url(url) if normalized: url, provider = normalized, "youtube" else: host = (urlparse(url).hostname or "").lower() provider = "twitch" if "twitch.tv" in host else host if element.name in {"iframe", "a"} or provider == "twitch": item_type = "external_video" item = {"type": item_type, "url": url} if provider: item["provider"] = provider media.append(item) return list({(item["type"], item["url"]): item for item in media}.values()) def detail_from_html( html: str, url: str, config: dict[str, Any], discovery_values: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], list[dict[str, str]]]: soup = BeautifulSoup(html, "html.parser") detail = config["detail"] root = soup.select_one(str(detail["root_selector"])) if root is None: return {}, [{"field": "detail", "error": f"root selector not found: {detail['root_selector']}"}] for selector in detail.get("remove_selectors") or []: for element in root.select(str(selector)): element.decompose() values, errors = extract_fields(root, detail.get("fields") or {}, discovery_values) values["media"] = extract_media(root, detail.get("media") or [], url) values["html"] = str(root) return values, errors