ytptube/app/features/tasks/definitions/handlers/generic.py
2026-01-23 23:38:59 +03:00

838 lines
29 KiB
Python

"""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 Mapping
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin
import jmespath
from parsel import Selector
from yt_dlp.utils.networking import random_user_agent
from app.features.tasks.definitions.schemas import (
ExtractionRule,
TaskDefinition,
)
from app.library.cache import Cache
from app.library.config import Config
from app.library.httpx_client import async_client
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
from app.library.Utils import fetch_info, get_archive_id
from ._base_handler import BaseHandler
if TYPE_CHECKING:
from pathlib import Path
import httpx
from parsel.selector import SelectorList
LOG: logging.Logger = logging.getLogger(__name__)
CACHE: Cache = Cache()
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
async 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.
"""
if cls._definitions and not force:
return cls._definitions
try:
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
from app.features.tasks.definitions.utils import model_to_schema
repo = TaskDefinitionsRepository.get_instance()
models = await repo.list()
definitions: list[TaskDefinition] = []
for model in models:
td = model_to_schema(model)
definitions.append(td)
cls._definitions = definitions
return cls._definitions
except Exception as exc:
LOG.error(f"Failed to load task definitions from database: {exc}")
return []
@classmethod
async 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.
"""
await cls.refresh_definitions()
for definition in cls._definitions:
if not definition.enabled:
continue
try:
for matcher in definition.match_url:
pattern_str = None
if matcher.startswith("/") and matcher.endswith("/") and len(matcher) > 2:
pattern_str: str = matcher[1:-1]
else:
pattern_str = fnmatch.translate(matcher)
if pattern_str and re.match(pattern_str, url):
return definition
except Exception as exc:
LOG.error(f"Error while matching definition '{definition.name}': {exc}")
return None
@staticmethod
async 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 = await 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 = await 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.definition.request.url or task.url
LOG.debug(f"{task.name!r}: Fetching '{target_url}' using engine '{definition.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.definition.response.type and json_data is None:
return TaskFailure(message="Expected JSON response but decoding failed.")
if "json" != definition.definition.response.type 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
from urllib import parse
return 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
id_dict: dict[str, str | None] = get_archive_id(url=url)
archive_id: str | None = id_dict.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 = await fetch_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.definition.response.type,
},
)
@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 (TaskDefinitionRuntimeSchema): 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.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 (TaskDefinitionRuntimeSchema): 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.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.definition.request.timeout or ytdlp_opts.get("socket_timeout", 120)
async with async_client(**client_options) as client:
response: httpx.Response = await client.request(
method=definition.definition.request.method.upper(),
url=url,
params=definition.definition.request.params or None,
data=definition.definition.request.data,
json=definition.definition.request.json_data,
timeout=timeout_value,
)
response.raise_for_status()
if "json" == definition.definition.response.type:
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 (TaskDefinitionRuntimeSchema): 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, None)
options_map: dict[str, Any] = definition.definition.engine.options
command_executor_value = options_map.get("url")
command_executor: str = (
str(command_executor_value)
if isinstance(command_executor_value, str) and command_executor_value
else "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, 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") or 15)
page_load_timeout = float(options_map.get("page_load_timeout") or 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 (TaskDefinitionRuntimeSchema): 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.definition.response.type:
return GenericTaskHandler._parse_json_items(definition, json_data, base_url)
selector = Selector(text=html)
if definition.definition.parse.get("items"):
return GenericTaskHandler._parse_with_container(
definition=definition,
selector=selector,
html=html,
base_url=base_url,
)
extracted: dict[str, list[str]] = {}
for _field, rule_data in definition.definition.parse.field_items():
if not isinstance(rule_data, dict):
continue
rule = ExtractionRule.model_validate(rule_data)
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.definition.parse.get("items"):
return GenericTaskHandler._parse_json_with_container(definition, json_data, base_url)
items: list[dict[str, str]] = []
entry: dict[str, str] = {}
for _field, rule_data in definition.definition.parse.field_items():
if not isinstance(rule_data, dict):
continue
rule = ExtractionRule.model_validate(rule_data)
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: dict[str, Any] | None = definition.definition.parse.get("items")
if not container:
return []
container_type = container.get("type", "css")
container_selector = container.get("selector") or container.get("expression") or ""
if not container_selector:
LOG.error(f"Container missing selector/expression. Definition '{definition.name}'.")
return []
container_fields = container.get("fields", {})
selection: SelectorList[Selector] = (
selector.css(container_selector) if "css" == container_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_data in container_fields.items():
rule = ExtractionRule.model_validate(rule_data)
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: dict[str, Any] | None = definition.definition.parse.get("items")
if not container:
return []
container_type = container.get("type", "css")
container_selector = container.get("selector") or container.get("expression", "")
container_fields = container.get("fields", {})
if "jsonpath" != container_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_data in container_fields.items():
rule = ExtractionRule.model_validate(rule_data)
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 (ExtractionRuleSchema): 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] | None = 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: dict[str, str] | None = 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 (ExtractionRuleSchema): 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:
# Apply post-filter inline (removed helper method)
try:
pattern = re.compile(rule.post_filter.filter)
match = pattern.search(cleaned)
if not match:
return None
if rule.post_filter.value:
try:
return match.group(rule.post_filter.value)
except (IndexError, KeyError):
return None
if match.groupdict():
# Prefer first named group when available
for group_value in match.groupdict().values():
if group_value is not None:
return group_value
if match.groups():
return match.group(1)
return match.group(0)
except re.error:
return None
return cleaned or None