"""Generic task handler driven by JSON definitions.""" from __future__ import annotations import asyncio import fnmatch import hashlib import json import logging import re from collections.abc import Iterable, Mapping, MutableMapping from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Literal from urllib.parse import urljoin import httpx import jmespath from parsel import Selector from parsel.selector import SelectorList from yt_dlp.utils.networking import random_user_agent from app.library.cache import Cache from app.library.config import Config from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult from app.library.Utils import extract_info, get_archive_id from ._base_handler import BaseHandler if TYPE_CHECKING: from parsel.selector import SelectorList LOG: logging.Logger = logging.getLogger(__name__) CACHE: Cache = Cache() @dataclass(slots=True) class MatchRule: """Represents a single URL matcher compiled to regex.""" source: str """Original source pattern (regex or glob).""" regex: re.Pattern[str] """Compiled regex pattern.""" @classmethod def from_value(cls, value: str | Mapping[str, Any]) -> MatchRule | None: """ Create a MatchRule from a string or mapping. Args: value (str|Mapping[str, Any]): A string (treated as glob) or a mapping with 'regex' or 'glob' keys. Returns: (MatchRule|None): A MatchRule instance if successful, None otherwise. """ if isinstance(value, Mapping): pattern: str | None = value.get("regex") glob_pattern: str | None = value.get("glob") raw: str | None = None if isinstance(pattern, str) and pattern: raw = pattern try: compiled: re.Pattern[str] = re.compile(pattern) except re.error as exc: LOG.error(f"Invalid regex pattern '{pattern}': {exc}") return None return cls(source=raw, regex=compiled) if isinstance(glob_pattern, str) and glob_pattern: raw = glob_pattern compiled = re.compile(fnmatch.translate(glob_pattern)) return cls(source=raw, regex=compiled) LOG.error("Matcher mapping must include 'regex' or 'glob' key with a string value.") return None if not isinstance(value, str) or not value: LOG.error(f"Matcher value must be a non-empty string, got '{value!r}'.") return None # Treat plain string as glob pattern for convenience. compiled = re.compile(fnmatch.translate(value)) return cls(source=value, regex=compiled) def matches(self, url: str) -> bool: """ Check if the given URL matches this rule. Args: url (str): The URL to check. Returns: (bool): True if the URL matches, False otherwise. """ return bool(self.regex.match(url)) @dataclass(slots=True) class PostFilter: """Regex post-filter applied on extracted values.""" pattern: re.Pattern[str] """Compiled regex pattern.""" value_key: str | None = None """Optional group name or index to extract from the match.""" @classmethod def from_mapping(cls, mapping: Mapping[str, Any]) -> PostFilter | None: """ Create a PostFilter from a mapping. Args: mapping (Mapping[str,Any]): A mapping with 'filter' (regex pattern) and optional 'value' (group name or index) keys. Returns: (PostFilter|None): A PostFilter instance if successful, None otherwise. """ pattern: str | None = mapping.get("filter") if not isinstance(pattern, str) or not pattern: LOG.error("post_filter requires a non-empty 'filter' string.") return None try: compiled: re.Pattern[str] = re.compile(pattern) except re.error as exc: LOG.error(f"Invalid post_filter regex '{pattern}': {exc}") return None value_key: str | None = mapping.get("value") if value_key is not None and not isinstance(value_key, str): LOG.error("post_filter 'value' must be a string if provided.") return None return cls(pattern=compiled, value_key=value_key) def apply(self, candidate: str) -> str | None: """ Apply the post-filter to the candidate string. Args: candidate (str): The string to filter. Returns: (str|None): The filtered value if matched, None otherwise. """ match: re.Match[str] | None = self.pattern.search(candidate) if not match: return None if self.value_key: try: return match.group(self.value_key) except IndexError: LOG.warning( f"post_filter value index '{self.value_key}' not present in pattern {self.pattern.pattern!r}." ) except KeyError: LOG.warning( f"post_filter value key '{self.value_key}' not present in pattern {self.pattern.pattern!r}." ) return None if match.groupdict(): # Prefer first named group when available. key, value = next(iter(match.groupdict().items())) if value is not None: LOG.debug(f"post_filter using named group '{key}'.") return value if match.groups(): return match.group(1) return match.group(0) @dataclass(slots=True) class ExtractionRule: """Single field extraction description.""" type: Literal["css", "xpath", "regex"] """Type of extraction to perform.""" expression: str """CSS selector, XPath expression or regex pattern.""" attribute: str | None = None """Optional attribute to extract (e.g. 'href', 'src', 'text', etc.).""" post_filter: PostFilter | None = None """Optional post-filter to apply on extracted values.""" @dataclass(slots=True) class EngineConfig: """Engine selection to fetch the page.""" type: Literal["httpx", "selenium"] = "httpx" """Engine type to use.""" options: dict[str, Any] = field(default_factory=dict) """Engine-specific options.""" @dataclass(slots=True) class RequestConfig: """HTTP request configuration.""" method: str = "GET" """HTTP method to use.""" headers: dict[str, str] = field(default_factory=dict) """HTTP headers to include.""" params: dict[str, Any] = field(default_factory=dict) """Query parameters to include.""" data: Any | None = None """Request body data to include.""" json: Any | None = None """Request body JSON data to include.""" timeout: float | None = None """Request timeout in seconds.""" url: str | None = None """Optional URL to use instead of the task URL.""" def normalized_method(self) -> str: """ Get the HTTP method in uppercase. Returns: (str): The HTTP method in uppercase. """ return self.method.upper() if isinstance(self.method, str) else "GET" @dataclass(slots=True) class ResponseConfig: """Defines how to interpret the response body returned by the fetch engine.""" format: Literal["html", "json"] = "html" """Body format. Defaults to HTML.""" @dataclass(slots=True) class ContainerDefinition: """Defines a repeating element with nested field extraction.""" selector_type: Literal["css", "xpath", "jsonpath"] """Type of selector to use for locating container elements.""" selector: str """Selector expression for locating container elements.""" fields: dict[str, ExtractionRule] """Field extraction rules relative to the container.""" @dataclass(slots=True) class TaskDefinition: """Full task definition as loaded from disk.""" name: str """Human-readable name of the task definition.""" source: Path """Path to the source JSON file.""" matchers: list[MatchRule] """List of URL matchers.""" engine: EngineConfig """Engine configuration.""" request: RequestConfig """Request configuration.""" parsers: dict[str, ExtractionRule] """Field extraction rules.""" container: ContainerDefinition | None = None """Optional container definition for repeating elements.""" response: ResponseConfig = field(default_factory=ResponseConfig) """Response configuration.""" def matches(self, url: str) -> bool: """ Check if the given URL matches any of the defined matchers. Args: url (str): The URL to check. Returns: (bool): True if any matcher matches the URL, False otherwise. """ return any(rule.matches(url) for rule in self.matchers) def _build_extraction_rule(field: str, raw: Mapping[str, Any], *, source: Path) -> ExtractionRule | None: """ Build an ExtractionRule from a raw mapping. Args: field (str): The name of the field being defined. raw (Mapping[str, Any]): The raw mapping defining the extraction rule. source (Path): Path to the source JSON file for logging context. Returns: (ExtractionRule|None): An ExtractionRule instance if successful, None otherwise. """ type_value: str | None = raw.get("type") expression: str | None = raw.get("expression") if not isinstance(type_value, str): LOG.error(f"[{source.name}] Field '{field}' is missing a valid 'type'.") return None if type_value not in {"css", "xpath", "regex", "jsonpath"}: LOG.error(f"[{source.name}] Field '{field}' has unsupported type '{type_value}'.") return None if not isinstance(expression, str) or not expression: LOG.error(f"[{source.name}] Field '{field}' requires non-empty 'expression'.") return None attribute: str | None = raw.get("attribute") if attribute is not None and not isinstance(attribute, str): LOG.error(f"[{source.name}] Field '{field}' attribute must be a string if provided.") return None post_filter: PostFilter | None = None if isinstance(raw.get("post_filter"), Mapping): post_filter = PostFilter.from_mapping(raw["post_filter"]) return ExtractionRule(type=type_value, expression=expression, attribute=attribute, post_filter=post_filter) def _build_matchers(raw_match: Iterable[Any], *, source: Path) -> list[MatchRule]: """ Build a list of MatchRule instances from raw match definitions. Args: raw_match (Iterable[Any]): An iterable of raw match definitions (strings or mappings). source (Path): Path to the source JSON file for logging context. Returns: (list[MatchRule]): A list of MatchRule instances. """ matchers: list[MatchRule] = [] for value in raw_match: rule: MatchRule | None = MatchRule.from_value(value) if rule: matchers.append(rule) if not matchers: LOG.error(f"[{source.name}] No valid match rules found.") return matchers def _normalize_mapping(value: Any) -> MutableMapping[str, Any]: """ Ensure the value is a mutable mapping. Args: value (Any): The value to check. Returns: (MutableMapping[str, Any]): The value if it's a mutable mapping. """ if isinstance(value, MutableMapping): return value msg = "Expected a mapping for parse/request/engine sections." raise ValueError(msg) def load_task_definitions(config: Config | None = None) -> list[TaskDefinition]: """ Load JSON task definitions from the configured tasks directory. Args: config (Config|None): Optional Config instance. If None, the singleton instance is used. Returns: (list[TaskDefinition]): A list of loaded TaskDefinition instances. """ cfg: Config = config or Config.get_instance() tasks_dir: Path = Path(cfg.config_path) / "tasks" if not tasks_dir.exists(): return [] definitions: list[TaskDefinition] = [] for path in sorted(tasks_dir.glob("*.json")): try: content: str = path.read_text(encoding="utf-8") except Exception as exc: LOG.error(f"Failed to read task configuration '{path}': {exc}") continue try: raw = json.loads(content) except Exception as exc: LOG.error(f"Failed to parse JSON for '{path}': {exc}") continue if not isinstance(raw, Mapping): LOG.error(f"Task definition in '{path}' must be a JSON object.") continue name: str | None = raw.get("name") if not isinstance(name, str) or not name.strip(): LOG.error(f"Task definition '{path}' missing a valid 'name'.") continue match_value: list[str] | None = raw.get("match") if not isinstance(match_value, Iterable) or isinstance(match_value, (str, bytes)): LOG.error(f"[{path.name}] 'match' must be a list of patterns.") continue matchers: list[MatchRule] = _build_matchers(match_value, source=path) if not matchers: continue engine_raw: Any = raw.get("engine", {}) try: engine_map: MutableMapping[str, Any] = _normalize_mapping(engine_raw) except ValueError: LOG.error(f"[{path.name}] 'engine' must be a JSON object when provided.") continue engine_type: str | None = engine_map.get("type", "httpx") if engine_type not in ("httpx", "selenium"): LOG.error(f"[{path.name}] Unsupported engine type '{engine_type}'.") continue engine_options: Any = engine_map.get("options") if isinstance(engine_map.get("options"), Mapping) else {} engine = EngineConfig(type=engine_type, options=dict(engine_options)) request_raw: Any = raw.get("request", {}) try: request_map: MutableMapping[str, Any] = _normalize_mapping(request_raw) except ValueError: LOG.error(f"[{path.name}] 'request' must be a JSON object when provided.") continue request = RequestConfig( method=str(request_map.get("method", "GET")), headers=dict(request_map.get("headers", {})) if isinstance(request_map.get("headers"), Mapping) else {}, params=dict(request_map.get("params", {})) if isinstance(request_map.get("params"), Mapping) else {}, data=request_map.get("data"), json=request_map.get("json"), timeout=float(request_map.get("timeout")) if request_map.get("timeout") is not None else None, url=str(request_map.get("url")) if isinstance(request_map.get("url"), str) else None, ) response_raw: Any = raw.get("response", {}) response_config = ResponseConfig() if response_raw: if not isinstance(response_raw, Mapping): LOG.error(f"[{path.name}] 'response' must be an object when provided.") continue response_type: str = str(response_raw.get("type", "html")).lower() if response_type not in ("html", "json"): LOG.error(f"[{path.name}] Unsupported response type '{response_type}'.") continue response_config = ResponseConfig(format=response_type) # type: ignore[arg-type] parse_raw: Mapping | None = raw.get("parse") if not isinstance(parse_raw, Mapping): LOG.error(f"[{path.name}] 'parse' must be a JSON object mapping fields to instructions.") continue container_definition: ContainerDefinition | None = None parsers: dict[str, ExtractionRule] = {} items_block: Mapping | None = parse_raw.get("items") if isinstance(items_block, Mapping): raw_fields: Mapping | None = items_block.get("fields") if not isinstance(raw_fields, Mapping): LOG.error(f"[{path.name}] 'items.fields' must be a mapping of field definitions.") continue container_fields: dict[str, ExtractionRule] = {} for _field, rule in raw_fields.items(): if not isinstance(_field, str): LOG.error(f"[{path.name}] Container field names must be strings, got {_field!r}.") continue if not isinstance(rule, Mapping): LOG.error(f"[{path.name}] Container definition for '{_field}' must be an object.") continue extraction_rule: ExtractionRule | None = _build_extraction_rule(_field, rule, source=path) if extraction_rule: container_fields[_field] = extraction_rule if "link" not in container_fields: LOG.error(f"[{path.name}] Container definition is missing required 'link' field.") continue selector_value: str | None = items_block.get("selector") or items_block.get("expression") if not isinstance(selector_value, str) or not selector_value: LOG.error(f"[{path.name}] 'items.selector' must be a non-empty string.") continue selector_type = str(items_block.get("type", "css")) if selector_type not in ("css", "xpath", "jsonpath"): LOG.error(f"[{path.name}] Unsupported container selector type '{selector_type}'.") continue container_definition = ContainerDefinition( selector_type=selector_type, selector=selector_value, fields=container_fields, ) for _field, rule in parse_raw.items(): if "items" == _field: continue if not isinstance(_field, str): LOG.error(f"[{path.name}] Parser field names must be strings, got {_field!r}.") continue if not isinstance(rule, Mapping): LOG.error(f"[{path.name}] Parser definition for '{_field}' must be an object.") continue extraction_rule = _build_extraction_rule(_field, rule, source=path) if extraction_rule: parsers[_field] = extraction_rule if container_definition is None and "link" not in parsers: LOG.error(f"[{path.name}] Missing required 'link' parser definition.") continue definition = TaskDefinition( name=name.strip(), source=path, matchers=matchers, engine=engine, request=request, parsers=parsers, container=container_definition, response=response_config, ) definitions.append(definition) return definitions class GenericTaskHandler(BaseHandler): """Handler that scrapes arbitrary web pages based on JSON task definitions.""" _definitions: list[TaskDefinition] = [] """Cached loaded task definitions.""" _sources_mtime: dict[Path, float] = {} """Modification times of source files to detect changes.""" @classmethod def _tasks_dir(cls) -> Path: """ Get the path to the tasks directory. Returns: (Path): Path to the tasks directory. """ return Path(Config.get_instance().config_path) / "tasks" @classmethod def _refresh_definitions(cls, force: bool = False) -> None: """ Refresh the cached task definitions if source files have changed. Args: force (bool): If True, force reload even if no changes detected. """ tasks_dir: Path = cls._tasks_dir() if not tasks_dir.exists(): if cls._definitions or cls._sources_mtime: cls._definitions = [] cls._sources_mtime = {} return current: dict[Path, float] = {} for path in tasks_dir.glob("*.json"): try: current[path] = path.stat().st_mtime except OSError: LOG.warning(f"Unable to stat task definition '{path}'.") if force or not cls._definitions or current != cls._sources_mtime: cls._definitions = load_task_definitions() cls._sources_mtime = current @classmethod def refresh_definitions(cls, force: bool = False) -> None: """Public helper to refresh cached task definitions.""" cls._refresh_definitions(force=force) @classmethod def _find_definition(cls, url: str) -> TaskDefinition | None: """ Find a task definition that matches the given URL. Args: url (str): The URL to match. Returns: (TaskDefinition|None): A matching TaskDefinition if found, None otherwise. """ cls._refresh_definitions() for definition in cls._definitions: try: if definition.matches(url): return definition except Exception as exc: LOG.error(f"Error while matching definition '{definition.name}': {exc}") return None @staticmethod def can_handle(task: Task) -> bool: """ Determine if this handler can process the given task. Args: task (Task): The task to check. Returns: (bool): True if the handler can process the task, False otherwise. """ definition: TaskDefinition | None = GenericTaskHandler._find_definition(task.url) if definition: LOG.debug(f"'{task.name}': Matched generic task definition '{definition.name}'.") return True return False @staticmethod async def extract(task: Task, config: Config | None = None) -> TaskResult | TaskFailure: # noqa: ARG004 definition: TaskDefinition | None = GenericTaskHandler._find_definition(task.url) if not definition: return TaskFailure(message="No generic task definition matched the provided URL.") ytdlp_opts: dict[str, Any] = task.get_ytdlp_opts().get_all() target_url: str = definition.request.url or task.url LOG.debug(f"{task.name!r}: Fetching '{target_url}' using engine '{definition.engine.type}'.") try: body_text, json_data = await GenericTaskHandler._fetch_content( url=target_url, definition=definition, ytdlp_opts=ytdlp_opts ) except Exception as exc: LOG.exception(exc) return TaskFailure(message="Failed to fetch target URL.", error=str(exc)) if "json" == definition.response.format and json_data is None: return TaskFailure(message="Expected JSON response but decoding failed.") if "json" != definition.response.format and not body_text: return TaskFailure(message="Received empty response body.") raw_items: list[dict[str, str]] = GenericTaskHandler._parse_items( definition=definition, html=body_text or "", base_url=target_url, json_data=json_data ) task_items: list[TaskItem] = [] def _generic_id(url): import os import urllib return urllib.parse.unquote(os.path.splitext(url.rstrip("/").split("/")[-1])[0]) for entry in raw_items: if not isinstance(entry, dict): continue if not (url := entry.get("link") or entry.get("url")): continue idDict: str | None = get_archive_id(url=url) archive_id: str | None = idDict.get("archive_id") if not archive_id: cache_key: str = hashlib.sha256(f"{task.name}-{url}".encode()).hexdigest() if CACHE.has(cache_key): archive_id = CACHE.get(cache_key) if not archive_id: continue else: LOG.warning( f"[{definition.name}]: '{task.name}': Unable to generate static archive id for '{url}' in feed. Doing real request to fetch yt-dlp archive id." ) info = extract_info( config=task.get_ytdlp_opts().get_all(), url=url, no_archive=True, no_log=True, ) if not info: LOG.error(f"[{definition.name}]: '{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping.") CACHE.set(cache_key, None) continue if not info.get("id") or not info.get("extractor_key"): LOG.error(f"[{definition.name}]: '{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping.") CACHE.set(cache_key, None) continue archive_id = f"{str(info.get('extractor_key', '')).lower()} {info.get('id')}" CACHE.set(cache_key, archive_id) metadata: dict[str, str] = { k: v for k, v in entry.items() if k not in {"link", "url", "title", "published", "archive_id"} } task_items.append( TaskItem( url=url, title=entry.get("title"), archive_id=archive_id, metadata={"published": entry.get("published"), **metadata}, ) ) return TaskResult( items=task_items, metadata={ "definition": definition.name, "response_format": definition.response.format, }, ) @staticmethod async def _fetch_content( url: str, definition: TaskDefinition, ytdlp_opts: dict[str, Any], ) -> tuple[str | None, Any | None]: """ Fetch the content of the given URL using the specified engine. Args: url (str): The URL to fetch. definition (TaskDefinition): The task definition specifying the engine and request details. ytdlp_opts (dict[str, Any]): yt-dlp options that may influence fetching Returns: (str|None): The fetched HTML content if successful, None otherwise. """ if "selenium" == definition.engine.type: return await GenericTaskHandler._fetch_with_selenium(url=url, definition=definition) return await GenericTaskHandler._fetch_with_httpx(url=url, definition=definition, ytdlp_opts=ytdlp_opts) @staticmethod async def _fetch_with_httpx( url: str, definition: TaskDefinition, ytdlp_opts: dict[str, Any], ) -> tuple[str | None, Any | None]: """ Fetch the content using httpx. Args: url (str): The URL to fetch. definition (TaskDefinition): The task definition specifying the request details. ytdlp_opts (dict[str, Any]): yt-dlp options that may influence fetching Returns: (str|None): The fetched HTML content if successful, None otherwise. """ headers: dict[str, str] = {**definition.request.headers} client_options: dict[str, Any] = { "headers": { "User-Agent": random_user_agent(), } } try: from httpx_curl_cffi import AsyncCurlTransport, CurlOpt client_options["transport"] = AsyncCurlTransport( impersonate="chrome", default_headers=True, curl_options={CurlOpt.FRESH_CONNECT: True}, ) client_options["headers"].pop("User-Agent", None) except Exception: pass if headers: client_options["headers"].update(headers) if proxy := ytdlp_opts.get("proxy"): client_options["proxy"] = proxy timeout_value: float | Any = definition.request.timeout or ytdlp_opts.get("socket_timeout", 120) async with httpx.AsyncClient(**client_options) as client: response: httpx.Response = await client.request( method=definition.request.normalized_method(), url=url, params=definition.request.params or None, data=definition.request.data, json=definition.request.json, timeout=timeout_value, ) response.raise_for_status() if "json" == definition.response.format: try: json_data: dict[str, Any] = response.json() except Exception as exc: LOG.error(f"Failed to decode JSON response from '{url}': {exc}") return response.text, None return response.text, json_data return response.text, None @staticmethod async def _fetch_with_selenium( url: str, definition: TaskDefinition, ) -> tuple[str | None, Any | None]: """ Fetch the content using a Selenium WebDriver. Args: url (str): The URL to fetch. definition (TaskDefinition): The task definition specifying the engine options. Returns: (str|None): The fetched HTML content if successful, None otherwise. """ try: from selenium import webdriver from selenium.webdriver.chrome.options import Options as ChromeOptions from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait except ImportError as exc: LOG.error(f"Selenium engine requested but selenium is not installed: {exc!s}.") return None options_map: dict[str, Any] = definition.engine.options command_executor: str | None = options_map.get("url", "http://localhost:4444/wd/hub") browser: str = str(options_map.get("browser", "chrome")).lower() if "chrome" != browser: LOG.error(f"Unsupported selenium browser '{browser}'. Only 'chrome' is supported.") return None arguments: list[str] | str = options_map.get("arguments", ["--headless", "--disable-gpu"]) if isinstance(arguments, str): arguments = [arguments] wait_for: Mapping | None = ( options_map.get("wait_for") if isinstance(options_map.get("wait_for"), Mapping) else None ) wait_timeout = float(options_map.get("wait_timeout", 15)) page_load_timeout = float(options_map.get("page_load_timeout", 60)) def load_page() -> str | None: chrome_options = ChromeOptions() for arg in arguments: chrome_options.add_argument(str(arg)) driver = webdriver.Remote(command_executor=command_executor, options=chrome_options) try: driver.set_page_load_timeout(page_load_timeout) driver.get(url) if wait_for: wait_type: str = str(wait_for.get("type", "css")).lower() expression: str | None = wait_for.get("expression") or wait_for.get("value") if isinstance(expression, str) and expression: locator = None locator = (By.XPATH, expression) if "xpath" == wait_type else (By.CSS_SELECTOR, expression) WebDriverWait(driver, wait_timeout).until(EC.presence_of_element_located(locator)) return driver.page_source finally: driver.quit() html: str | None = await asyncio.to_thread(load_page) return html, None @staticmethod def _parse_items( definition: TaskDefinition, html: str, base_url: str, json_data: Any | None = None, ) -> list[dict[str, str]]: """ Parse the HTML content and extract items based on the definition. Args: definition (TaskDefinition): The task definition specifying the parsers. html (str): The HTML content to parse. base_url (str): The base URL to resolve relative links. json_data (Any|None): The JSON data to parse if applicable. Returns: (list[dict[str, str]]): A list of extracted items as dictionaries. """ if "json" == definition.response.format: return GenericTaskHandler._parse_json_items(definition, json_data, base_url) selector = Selector(text=html) if definition.container: return GenericTaskHandler._parse_with_container( definition=definition, selector=selector, html=html, base_url=base_url, ) extracted: dict[str, list[str]] = {} for _field, rule in definition.parsers.items(): values: list[str] = GenericTaskHandler._execute_rule(field=_field, selector=selector, html=html, rule=rule) extracted[_field] = values link_values: list[str] = extracted.get("link", []) if not link_values: LOG.debug(f"Definition '{definition.name}' produced no link values.") return [] total_items: int = len(link_values) items: list[dict[str, str]] = [] for index in range(total_items): entry: dict[str, str] = {} link_value: str = link_values[index] if not link_value: continue entry["link"] = urljoin(base_url, link_value) for _field, values in extracted.items(): if "link" == _field: continue value: str | None = values[index] if index < len(values) else None if value is None: continue entry[_field] = value items.append(entry) return items @staticmethod def _parse_json_items( definition: TaskDefinition, json_data: Any | None, base_url: str, ) -> list[dict[str, str]]: if json_data is None: LOG.debug(f"Definition '{definition.name}' expects JSON but no data was parsed.") return [] if definition.container: return GenericTaskHandler._parse_json_with_container(definition, json_data, base_url) items: list[dict[str, str]] = [] entry: dict[str, str] = {} for _field, rule in definition.parsers.items(): values: list[str] = GenericTaskHandler._execute_json_rule(_field, json_data, rule) if values: if "link" == _field: entry["link"] = urljoin(base_url, values[0]) else: entry[_field] = values[0] if "link" in entry: items.append(entry) return items @staticmethod def _parse_with_container( definition: TaskDefinition, selector: Selector, html: str, base_url: str, ) -> list[dict[str, str]]: container: ContainerDefinition | None = definition.container if not container: return [] if "jsonpath" == container.selector_type: LOG.error( f"Container selector type 'jsonpath' requires response type 'json'. Definition '{definition.name}'." ) return [] selection: SelectorList[Selector] = ( selector.css(container.selector) if "css" == container.selector_type else selector.xpath(container.selector) ) items: list[dict[str, str]] = [] for node in selection: node_html: Any | str = node.get() or html entry: dict[str, str] = {} for _field, rule in container.fields.items(): values: list[str] = GenericTaskHandler._execute_rule( field=_field, selector=node, html=node_html, rule=rule, ) value: str | None = values[0] if values else None if value is None: continue if "link" == _field: entry["link"] = urljoin(base_url, value) else: entry[_field] = value if "link" not in entry: continue items.append(entry) return items @staticmethod def _parse_json_with_container( definition: TaskDefinition, json_data: Any, base_url: str, ) -> list[dict[str, str]]: container: ContainerDefinition | None = definition.container if not container: return [] if "jsonpath" != container.selector_type: LOG.error(f"JSON response requires container selector type 'jsonpath'. Definition '{definition.name}'.") return [] nodes: Any = GenericTaskHandler._json_search(json_data, container.selector) if nodes is None: return [] if not isinstance(nodes, list): nodes = [nodes] items: list[dict[str, str]] = [] for node in nodes: entry: dict[str, str] = {} for _field, rule in container.fields.items(): values: list[str] = GenericTaskHandler._execute_json_rule(_field, node, rule) if not values: continue if "link" == _field: entry["link"] = urljoin(base_url, values[0]) else: entry[_field] = values[0] if "link" not in entry: continue items.append(entry) return items @staticmethod def _execute_json_rule(field: str, data: Any, rule: ExtractionRule) -> list[str]: values: list[str] = [] if "jsonpath" == rule.type: result: Any = GenericTaskHandler._json_search(data, rule.expression) candidates: list | list[Any] = result if isinstance(result, list) else [result] for candidate in candidates: if candidate is None: continue text: str = GenericTaskHandler._coerce_to_string(candidate) processed: str | None = GenericTaskHandler._apply_post_filter(text, rule) if processed is not None: values.append(processed) return values if "regex" == rule.type: target: str = GenericTaskHandler._coerce_to_string(data) try: pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL) except re.error as exc: LOG.error(f"Invalid regex expression '{rule.expression}': {exc}") return values for match in pattern.finditer(target): raw: str | None = GenericTaskHandler._regex_value(match=match, attribute=rule.attribute) processed = GenericTaskHandler._apply_post_filter(raw, rule) if processed is not None: values.append(processed) return values LOG.error(f"Unsupported extraction type '{rule.type}' for JSON data in field '{field}'.") return values @staticmethod def _json_search(data: Any, expression: str) -> Any: try: return jmespath.search(expression, data) except Exception as exc: LOG.error(f"JSONPath search failed for expression '{expression}': {exc}") return None @staticmethod def _coerce_to_string(value: Any) -> str: if isinstance(value, str): return value if isinstance(value, (int, float, bool)) or value is None: return "" if value is None else str(value) try: return json.dumps(value, ensure_ascii=False) except Exception: return str(value) @staticmethod def _execute_rule(field: str, selector: Selector, html: str, rule: ExtractionRule) -> list[str]: """ Execute a single extraction rule and return the list of extracted values. Args: field (str): The name of the field being extracted. selector (Selector): The parsel Selector for the HTML content. html (str): The raw HTML content. rule (ExtractionRule): The extraction rule to execute. Returns: (list[str]): A list of extracted values. """ values: list[str] = [] if "regex" == rule.type: try: pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL) except re.error as exc: LOG.error(f"Invalid regex expression '{rule.expression}': {exc}") return values for match in pattern.finditer(html): raw: str | None = GenericTaskHandler._regex_value(match=match, attribute=rule.attribute) processed: str | None = GenericTaskHandler._apply_post_filter(raw, rule) if processed is not None: values.append(processed) return values if "jsonpath" == rule.type: LOG.error("Extraction type 'jsonpath' is only valid for JSON responses.") return values selection: SelectorList[Selector] = ( selector.css(rule.expression) if "css" == rule.type else selector.xpath(rule.expression) ) for sel in selection: raw = GenericTaskHandler._selector_value(field, sel, rule.attribute) processed = GenericTaskHandler._apply_post_filter(raw, rule) if processed is not None: values.append(processed) return values @staticmethod def _regex_value(match: re.Match[str], attribute: str | None) -> str | None: """ Extract a value from a regex match based on the attribute. Args: match (re.Match[str]): The regex match object. attribute (str|None): Optional group name or index to extract. Returns: (str|None): The extracted value if found, None otherwise. """ if attribute: try: return match.group(attribute) except (IndexError, KeyError): LOG.debug(f"Regex group '{attribute}' not found in pattern '{match.re.pattern}'.") return None if match.groupdict(): return next((value for value in match.groupdict().values() if value), None) if match.groups(): return match.group(1) return match.group(0) @staticmethod def _selector_value(field: str, sel: Selector, attribute: str | None) -> str | None: """ Extract a value from a parsel Selector based on the attribute. Args: field (str): The name of the field being extracted. sel (Selector): The parsel Selector object. attribute (str|None): Optional attribute to extract (e.g. 'href', 'src', 'text', etc.). Returns: (str|None): The extracted value if found, None otherwise. """ attr: str | None = attribute.lower() if isinstance(attribute, str) else None if attr in {"text", "inner_text"}: return sel.xpath("normalize-space()").get() if attr in {"html", "outer_html"}: value: Any = sel.get() return value if value is not None else None if attr and attr not in {"html", "outer_html", "text", "inner_text"}: try: attributes: dict[str, str] = sel.attrib except AttributeError: attributes = None if attributes and attr in attributes: return attributes.get(attr) attr_value: str | None = sel.xpath(f"@{attr}").get() if attr_value is not None: return attr_value if attr is None and "link" == field.lower(): href = None try: attributes = sel.attrib except AttributeError: attributes = None if attributes and "href" in attributes: href: str | None = attributes.get("href") if not href: href: str | None = sel.xpath("@href").get() if href: return href if attr is None: text_value: str | None = sel.xpath("normalize-space()").get() if text_value: return text_value value = sel.get() return value if value is not None else None @staticmethod def _apply_post_filter(value: str | None, rule: ExtractionRule) -> str | None: """ Apply the post-filter to the extracted value if defined. Args: value (str|None): The extracted value to filter. rule (ExtractionRule): The extraction rule containing the post-filter. Returns: (str|None): The filtered value if applicable, None otherwise. """ if value is None: return None cleaned: str = value.strip() if rule.post_filter: return rule.post_filter.apply(cleaned) return cleaned or None