pryscraper/pipeline.py
cryptorugmunch 8d25702eca chore(license): re-license to dual MIT (core) + BSL 1.1 (stealth)
Squashed from chore/license-relicense. Full message preserved in the
original branch commit bb77eb5. See ADR-0002 for the decision rationale.

Refs: ADR-0002, commit bb77eb5
2026-07-02 19:59:18 +02:00

166 lines
5 KiB
Python

"""Pry — pipeline hook system for the scraping workflow.
Allows plugins and custom transformations at every stage."""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import logging
from collections.abc import Awaitable, Callable
from typing import Any, cast
logger = logging.getLogger(__name__)
# Hook point definitions
HOOK_POINTS = [
"before_scrape",
"after_response",
"before_parse",
"after_parse",
"before_extract",
"after_extract",
"before_return",
"on_error",
]
HookFn = Callable[..., Awaitable[dict[str, Any]]]
SyncHookFn = Callable[..., dict[str, Any]]
class Pipeline:
"""Scraping pipeline with pluggable hooks at each stage.
Usage:
pipeline = Pipeline()
pipeline.register("before_scrape", my_async_hook)
pipeline.register("after_response", my_sync_hook)
result = await pipeline.run("before_scrape", url=url)
"""
def __init__(self) -> None:
self._hooks: dict[str, list[HookFn | SyncHookFn]] = {p: [] for p in HOOK_POINTS}
def register(
self,
hook_point: str,
fn: HookFn | SyncHookFn,
priority: int = 0,
) -> None:
"""Register a hook function at a hook point.
Args:
hook_point: One of HOOK_POINTS
fn: Async or sync function that receives **kwargs and returns a dict
priority: Lower runs first (default 0)
"""
if hook_point not in self._hooks:
raise ValueError(f"Unknown hook point: {hook_point}. Valid: {HOOK_POINTS}")
self._hooks[hook_point].append(fn)
logger.debug("hook_registered", extra={"point": hook_point, "fn": fn.__name__})
async def run(self, hook_point: str, **kwargs: Any) -> dict[str, Any]:
"""Run all hooks at a hook point, passing kwargs through the chain.
Each hook receives the output of the previous hook as input.
Returns the final merged context.
"""
context = dict(kwargs)
for fn in self._hooks.get(hook_point, []):
try:
if _is_async(fn):
result = await cast(HookFn, fn)(**context)
else:
result = cast(SyncHookFn, fn)(**context)
if isinstance(result, dict):
context.update(result)
except Exception as e:
logger.exception(
"hook_failed",
extra={"point": hook_point, "fn": getattr(fn, "__name__", str(fn))},
)
if hook_point == "on_error":
context["error"] = str(e)
else:
context.setdefault("errors", []).append(str(e))
return context
def clear(self, hook_point: str | None = None) -> None:
"""Clear hooks at a point, or all hooks if point is None."""
if hook_point:
self._hooks[hook_point] = []
else:
for p in HOOK_POINTS:
self._hooks[p] = []
def list_hooks(self) -> dict[str, list[str]]:
"""List all registered hooks by hook point."""
return {
p: [getattr(fn, "__name__", str(fn)) for fn in fns] for p, fns in self._hooks.items()
}
def _is_async(fn: Any) -> bool:
import asyncio
import inspect
return asyncio.iscoroutinefunction(fn) or inspect.iscoroutinefunction(fn)
# ── Built-in hooks ──
async def log_request(**kwargs: Any) -> dict[str, Any]:
"""Log scraping requests."""
logger.info(
"pipeline_scrape",
extra={"url": kwargs.get("url", ""), "hook": kwargs.get("hook_point", "")},
)
return {}
async def strip_html_comments(**kwargs: Any) -> dict[str, Any]:
"""Remove HTML comments from raw content."""
import re
html = kwargs.get("html", "")
if html:
cleaned = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
return {"html": cleaned}
return {}
async def extract_all_links(**kwargs: Any) -> dict[str, Any]:
"""Extract all href links from HTML (runs at after_response)."""
from lxml import html as lxml_html
html_content = kwargs.get("html", "")
if not html_content:
return {}
try:
tree = lxml_html.fromstring(html_content)
links = tree.xpath("//a/@href")
return {"extracted_links": links}
except Exception:
return {}
# Global pipeline singleton
_pipeline: Pipeline | None = None
def get_pipeline() -> Pipeline:
"""Get or create the global pipeline singleton."""
global _pipeline
if _pipeline is None:
_pipeline = Pipeline()
# Register built-in hooks
_pipeline.register("after_response", extract_all_links, priority=100)
return _pipeline
async def run_pipeline(hook_point: str, **kwargs: Any) -> dict[str, Any]:
"""Convenience: run a hook point on the global pipeline."""
return await get_pipeline().run(hook_point, **kwargs)