From a9cc1b6d0bc57ea8a8e9f1e9ac0db4b7aa815dce Mon Sep 17 00:00:00 2001 From: Hamza Tahir Date: Fri, 10 Jul 2026 16:57:21 -0700 Subject: [PATCH 01/10] Add Firecrawl web monitoring example Turns each Firecrawl monitor.page webhook into a ZenML pipeline run that versions the raw event, normalized diff, change analysis, and final report as named artifacts. Analysis uses an OpenAI model when a ZenML secret is provided and falls back to Firecrawl's own meaningful-change judgment otherwise; a configured stack alerter can post meaningful changes to Slack. Includes a FastAPI webhook receiver, a monitor provisioning script, and an artifact history utility. Co-Authored-By: Claude Fable 5 --- examples/firecrawl_web_monitor/README.md | 122 ++++++++++++++++++ examples/firecrawl_web_monitor/analysis.py | 104 +++++++++++++++ .../firecrawl_web_monitor/configs/dev.yaml | 3 + .../firecrawl_web_monitor/configs/prod.yaml | 3 + examples/firecrawl_web_monitor/constants.py | 6 + .../create_firecrawl_monitor.py | 91 +++++++++++++ examples/firecrawl_web_monitor/history.py | 27 ++++ examples/firecrawl_web_monitor/models.py | 113 ++++++++++++++++ .../pipelines/__init__.py | 1 + .../pipelines/monitoring.py | 52 ++++++++ examples/firecrawl_web_monitor/pyproject.toml | 37 ++++++ examples/firecrawl_web_monitor/run.py | 60 +++++++++ .../firecrawl_web_monitor/sample_payload.json | 38 ++++++ .../firecrawl_web_monitor/steps/__init__.py | 1 + .../steps/analyze_page_change.py | 66 ++++++++++ .../steps/build_monitoring_report.py | 38 ++++++ .../steps/ingest_firecrawl_event.py | 41 ++++++ .../steps/normalize_page_change.py | 39 ++++++ .../steps/optionally_notify_slack.py | 42 ++++++ .../tests/test_analysis.py | 83 ++++++++++++ .../firecrawl_web_monitor/webhook_server.py | 59 +++++++++ 21 files changed, 1026 insertions(+) create mode 100644 examples/firecrawl_web_monitor/README.md create mode 100644 examples/firecrawl_web_monitor/analysis.py create mode 100644 examples/firecrawl_web_monitor/configs/dev.yaml create mode 100644 examples/firecrawl_web_monitor/configs/prod.yaml create mode 100644 examples/firecrawl_web_monitor/constants.py create mode 100644 examples/firecrawl_web_monitor/create_firecrawl_monitor.py create mode 100644 examples/firecrawl_web_monitor/history.py create mode 100644 examples/firecrawl_web_monitor/models.py create mode 100644 examples/firecrawl_web_monitor/pipelines/__init__.py create mode 100644 examples/firecrawl_web_monitor/pipelines/monitoring.py create mode 100644 examples/firecrawl_web_monitor/pyproject.toml create mode 100644 examples/firecrawl_web_monitor/run.py create mode 100644 examples/firecrawl_web_monitor/sample_payload.json create mode 100644 examples/firecrawl_web_monitor/steps/__init__.py create mode 100644 examples/firecrawl_web_monitor/steps/analyze_page_change.py create mode 100644 examples/firecrawl_web_monitor/steps/build_monitoring_report.py create mode 100644 examples/firecrawl_web_monitor/steps/ingest_firecrawl_event.py create mode 100644 examples/firecrawl_web_monitor/steps/normalize_page_change.py create mode 100644 examples/firecrawl_web_monitor/steps/optionally_notify_slack.py create mode 100644 examples/firecrawl_web_monitor/tests/test_analysis.py create mode 100644 examples/firecrawl_web_monitor/webhook_server.py diff --git a/examples/firecrawl_web_monitor/README.md b/examples/firecrawl_web_monitor/README.md new file mode 100644 index 00000000000..1bf1051de0f --- /dev/null +++ b/examples/firecrawl_web_monitor/README.md @@ -0,0 +1,122 @@ +# Firecrawl Web Monitoring with ZenML + +This example turns each Firecrawl `monitor.page` webhook into a ZenML pipeline run. Firecrawl monitors and scrapes the page; ZenML preserves the event, diff, analysis, and report as independently versioned artifacts. An LLM interprets the change when an OpenAI key is stored in a ZenML secret and passed via `--llm-secret` (or `LLM_SECRET_NAME` for the webhook receiver), and a configured ZenML alerter can optionally post meaningful changes to Slack. + +```text +Firecrawl webhook + | + v +raw event -> normalized diff -> LLM analysis -> report -> optional Slack + v1 v1 v1 v1 + v2 v2 v2 v2 +``` + +The stable artifact names make every run easy to compare in the ZenML dashboard: + +- `firecrawl_monitor_event` stores the source webhook. +- `web_page_change` stores the normalized diff and Firecrawl scrape IDs. +- `web_change_analysis` stores the business interpretation. +- `web_monitoring_report` joins the evidence and analysis for downstream use. + +## Quick start + +This example assumes that a [ZenML stack](https://docs.zenml.io/stacks) is already configured. From this directory, install the small set of example dependencies and run the bundled realistic event: + +```bash +uv pip install -e ".[dev]" +zenml init +python run.py +``` + +Run `zenml init` and all commands from this directory: it sets the ZenML source root, and the pipeline's Docker settings reference the `pyproject.toml` here, so remote image builds pick up the example's dependencies. + +If the active stack uses an S3 artifact store, install its client integration in the same environment once: + +```bash +zenml integration install s3 --uv +``` + +No Firecrawl, OpenAI, or Slack credentials are required for that first run. Without an LLM secret, the analysis uses Firecrawl's meaningful-change judgment and clearly labels itself as a fallback. Store the OpenAI key in ZenML to run the same pipeline with an LLM on both local and remote orchestrators: + +```bash +zenml secret create firecrawl-monitoring \ + --OPENAI_API_KEY= \ + --OPENAI_MODEL=gpt-5-mini +python run.py --llm-secret firecrawl-monitoring +``` + +Run the example twice, optionally editing `sample_payload.json`, then inspect the artifact versions in the dashboard or from the terminal: + +```bash +python history.py +``` + +## Receive Firecrawl webhooks locally + +Start the synchronous FastAPI receiver: + +```bash +export FIRECRAWL_WEBHOOK_TOKEN= +uvicorn webhook_server:app --host 0.0.0.0 --port 8000 +``` + +Expose port 8000 through your preferred development tunnel, then add the public endpoint to a Firecrawl monitor. The authorization header is optional locally but recommended for every public endpoint: + +```bash +export FIRECRAWL_API_KEY= +python create_firecrawl_monitor.py \ + --target-url https://example.com/pricing \ + --webhook-url https://your-public-host/webhooks/firecrawl \ + --goal "Alert when a competitor changes price or packaging" +``` + +The script creates an hourly Firecrawl `scrape` monitor. Change the cadence with `--schedule`, for example `--schedule daily`. It uses `FIRECRAWL_WEBHOOK_TOKEN` for the receiver authorization header when that environment variable is set. + +The equivalent webhook portion of the monitor request is: + +```json +{ + "webhook": { + "url": "https://your-public-host/webhooks/firecrawl", + "headers": { + "Authorization": "Bearer " + }, + "metadata": { + "environment": "trial" + }, + "events": ["monitor.page", "monitor.check.completed"] + } +} +``` + +The receiver ignores `monitor.check.completed` because that event contains only aggregate counts. Each `monitor.page` event starts one pipeline run and carries the page-level diff needed by the analysis. + +For production, deploy the receiver behind an authenticated HTTPS endpoint and run ZenML with a [Kubernetes orchestrator](https://docs.zenml.io/stacks/stack-components/orchestrators/kubernetes). The pipeline includes Docker settings sourced from `pyproject.toml`, so the code and artifact contracts do not change when moving from the local orchestrator. + +## Optional Slack alerts + +Attach a Slack alerter to the active ZenML stack, then enable notifications for CLI runs: + +```bash +python run.py --notify-slack +``` + +For the webhook receiver, set `NOTIFY_SLACK=true` and optionally `LLM_SECRET_NAME=firecrawl-monitoring`. Only reports marked meaningful are sent. If notification is enabled but the active stack has no alerter, the step logs a warning and the run still succeeds. + +## Use a real payload directly + +Save any Firecrawl `monitor.page` body and pass it to the runner: + +```bash +python run.py \ + --payload payload.json \ + --goal "Alert when a competitor changes price or packaging" +``` + +Firecrawl can emit both unified markdown diffs and structured JSON field diffs. This example preserves both, so a monitor configured for JSON change tracking can compare fields such as prices or availability without parsing prose. + +## Tests + +```bash +pytest tests/test_analysis.py +``` diff --git a/examples/firecrawl_web_monitor/analysis.py b/examples/firecrawl_web_monitor/analysis.py new file mode 100644 index 00000000000..c934499c368 --- /dev/null +++ b/examples/firecrawl_web_monitor/analysis.py @@ -0,0 +1,104 @@ +"""Analysis helpers kept separate from ZenML orchestration for testability.""" + +import json +from typing import Any, Dict, Optional + +from models import ChangeAnalysis, PageChange + +DEFAULT_MODEL = "gpt-5-mini" + + +def analyze_change( + change: PageChange, + goal: str, + api_key: Optional[str] = None, + model: str = DEFAULT_MODEL, +) -> ChangeAnalysis: + """Analyze a change with OpenAI or a deterministic local fallback. + + Args: + change: Normalized page change. + goal: Business goal that defines which changes matter. + api_key: Optional OpenAI API key loaded from a ZenML secret. + model: OpenAI model name. + + Returns: + A structured analysis suitable for alerting and later comparison. + """ + if api_key: + return _analyze_with_openai( + change=change, goal=goal, api_key=api_key, model=model + ) + return _analyze_without_llm(change=change) + + +def _analyze_with_openai( + change: PageChange, goal: str, api_key: str, model: str +) -> ChangeAnalysis: + """Ask OpenAI for a structured interpretation of the diff.""" + from openai import OpenAI + + prompt = ( + "Analyze this website change against the monitoring goal. " + "Return JSON with summary, impact, recommended_actions (a list), " + "meaningful (boolean), and confidence (low, medium, or high).\n\n" + f"Goal: {goal}\nURL: {change.url}\nStatus: {change.status}\n" + f"Unified diff:\n{change.unified_diff[:12000]}\n" + f"Structured diff:\n{json.dumps(change.structured_diff)[:6000]}" + ) + response = OpenAI(api_key=api_key).chat.completions.create( + model=model, + messages=[ + { + "role": "system", + "content": ( + "You are a precise web-change analyst. Base conclusions " + "only on the supplied evidence and return valid JSON." + ), + }, + {"role": "user", "content": prompt}, + ], + response_format={"type": "json_object"}, + ) + content = response.choices[0].message.content + if not content: + raise RuntimeError("The LLM returned an empty analysis.") + result: Dict[str, Any] = json.loads(content) + result.pop("analyzer", None) + return ChangeAnalysis( + **result, + analyzer=model, + ) + + +def _analyze_without_llm(change: PageChange) -> ChangeAnalysis: + """Produce a useful offline result from Firecrawl's own evidence.""" + judgment = change.firecrawl_judgment + if judgment: + actions = [item.reason for item in judgment.meaningful_changes] + return ChangeAnalysis( + summary=judgment.reason, + impact=( + "The change matches the configured monitoring goal." + if judgment.meaningful + else "The change appears to be noise for the monitoring goal." + ), + recommended_actions=actions or ["Review the recorded diff."], + meaningful=judgment.meaningful, + confidence=judgment.confidence, + analyzer="firecrawl-judgment-fallback", + ) + + changed_lines = [ + line + for line in change.unified_diff.splitlines() + if line.startswith(("+", "-")) and not line.startswith(("+++", "---")) + ] + return ChangeAnalysis( + summary=f"Firecrawl reported {change.status} with {len(changed_lines)} changed lines.", + impact="An LLM was not configured, so business impact was not inferred.", + recommended_actions=["Review the recorded diff."], + meaningful=change.status in {"changed", "new", "removed"}, + confidence="low", + analyzer="deterministic-fallback", + ) diff --git a/examples/firecrawl_web_monitor/configs/dev.yaml b/examples/firecrawl_web_monitor/configs/dev.yaml new file mode 100644 index 00000000000..52ea4ced17a --- /dev/null +++ b/examples/firecrawl_web_monitor/configs/dev.yaml @@ -0,0 +1,3 @@ +enable_cache: false +tags: + - local diff --git a/examples/firecrawl_web_monitor/configs/prod.yaml b/examples/firecrawl_web_monitor/configs/prod.yaml new file mode 100644 index 00000000000..51c6532a9b4 --- /dev/null +++ b/examples/firecrawl_web_monitor/configs/prod.yaml @@ -0,0 +1,3 @@ +enable_cache: false +tags: + - production diff --git a/examples/firecrawl_web_monitor/constants.py b/examples/firecrawl_web_monitor/constants.py new file mode 100644 index 00000000000..aeb8152f2f6 --- /dev/null +++ b/examples/firecrawl_web_monitor/constants.py @@ -0,0 +1,6 @@ +"""Shared defaults for the Firecrawl monitoring example.""" + +DEFAULT_GOAL = ( + "Identify product, pricing, positioning, or release changes that require " + "a response from our product or go-to-market teams." +) diff --git a/examples/firecrawl_web_monitor/create_firecrawl_monitor.py b/examples/firecrawl_web_monitor/create_firecrawl_monitor.py new file mode 100644 index 00000000000..b1b630ded8e --- /dev/null +++ b/examples/firecrawl_web_monitor/create_firecrawl_monitor.py @@ -0,0 +1,91 @@ +"""Create a Firecrawl page monitor that sends diffs to the local receiver.""" + +import argparse +import os +from typing import Any, Dict, Optional + +import requests +from constants import DEFAULT_GOAL + + +def build_monitor_request( + target_url: str, + webhook_url: str, + goal: str, + schedule: str, + webhook_token: Optional[str], +) -> Dict[str, Any]: + """Build a Firecrawl v2 monitor request. + + Args: + target_url: Page that Firecrawl should monitor and scrape. + webhook_url: Public receiver URL. + goal: Meaningful-change goal. + schedule: Firecrawl natural-language schedule. + webhook_token: Optional shared receiver token. + + Returns: + Firecrawl monitor request body. + """ + headers = ( + {"Authorization": f"Bearer {webhook_token}"} if webhook_token else {} + ) + return { + "name": f"ZenML monitor: {target_url}", + "schedule": {"text": schedule, "timezone": "UTC"}, + "goal": goal, + "targets": [ + { + "type": "scrape", + "urls": [target_url], + "scrapeOptions": { + "formats": ["markdown"], + "onlyMainContent": True, + }, + } + ], + "webhook": { + "url": webhook_url, + "headers": headers, + "metadata": {"source": "zenml-firecrawl-example"}, + "events": ["monitor.page", "monitor.check.completed"], + }, + } + + +def main() -> None: + """Create a monitor using credentials from the environment.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-url", required=True) + parser.add_argument("--webhook-url", required=True) + parser.add_argument("--goal", default=DEFAULT_GOAL) + parser.add_argument("--schedule", default="hourly") + args = parser.parse_args() + + api_key = os.getenv("FIRECRAWL_API_KEY") + if not api_key: + raise RuntimeError("Set FIRECRAWL_API_KEY before creating a monitor.") + + response = requests.post( + "https://api.firecrawl.dev/v2/monitor", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=build_monitor_request( + target_url=args.target_url, + webhook_url=args.webhook_url, + goal=args.goal, + schedule=args.schedule, + webhook_token=os.getenv("FIRECRAWL_WEBHOOK_TOKEN"), + ), + timeout=30, + ) + response.raise_for_status() + body = response.json() + monitor_id = body.get("id") or body.get("data", {}).get("id", "") + print(f"Created Firecrawl monitor: {monitor_id}") + + +if __name__ == "__main__": + main() diff --git a/examples/firecrawl_web_monitor/history.py b/examples/firecrawl_web_monitor/history.py new file mode 100644 index 00000000000..90d1df976a7 --- /dev/null +++ b/examples/firecrawl_web_monitor/history.py @@ -0,0 +1,27 @@ +"""Print the recent version history of the monitoring report artifact.""" + +from models import MonitoringReport + +from zenml.client import Client + + +def main() -> None: + """Load and summarize recent report artifact versions.""" + versions = Client().list_artifact_versions( + name="web_monitoring_report", sort_by="desc:created", size=10 + ) + if not versions: + print("No monitoring reports found. Run `python run.py` first.") + return + + for version in versions: + report: MonitoringReport = version.load() + print( + f"v{version.version}: {report.status} | " + f"meaningful={report.analysis.meaningful} | {report.url}" + ) + print(f" {report.analysis.summary}") + + +if __name__ == "__main__": + main() diff --git a/examples/firecrawl_web_monitor/models.py b/examples/firecrawl_web_monitor/models.py new file mode 100644 index 00000000000..d079e41a26d --- /dev/null +++ b/examples/firecrawl_web_monitor/models.py @@ -0,0 +1,113 @@ +"""Typed data contracts for the Firecrawl monitoring example.""" + +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +ChangeStatus = Literal["same", "changed", "new", "removed", "error"] + + +class MeaningfulChange(BaseModel): + """A single meaningful change identified by Firecrawl.""" + + type: str + before: Optional[str] = None + after: Optional[str] = None + reason: str + + +class FirecrawlJudgment(BaseModel): + """Firecrawl's optional meaningful-change judgment.""" + + meaningful: bool + confidence: str + reason: str + meaningful_changes: List[MeaningfulChange] = Field( + default_factory=list, alias="meaningfulChanges" + ) + + model_config = ConfigDict(populate_by_name=True) + + +class FirecrawlDiff(BaseModel): + """Text and structured diffs emitted by Firecrawl.""" + + text: str = "" + structured: Dict[str, Any] = Field(default_factory=dict, alias="json") + + model_config = ConfigDict(populate_by_name=True) + + +class FirecrawlPageData(BaseModel): + """One page result from a ``monitor.page`` webhook.""" + + monitor_id: str = Field(alias="monitorId") + check_id: str = Field(alias="checkId") + url: str + status: ChangeStatus + previous_scrape_id: Optional[str] = Field( + default=None, alias="previousScrapeId" + ) + current_scrape_id: Optional[str] = Field( + default=None, alias="currentScrapeId" + ) + error: Optional[str] = None + is_meaningful: Optional[bool] = Field(default=None, alias="isMeaningful") + judgment: Optional[FirecrawlJudgment] = None + diff: FirecrawlDiff = Field(default_factory=FirecrawlDiff) + + model_config = ConfigDict(populate_by_name=True) + + +class FirecrawlWebhook(BaseModel): + """Firecrawl webhook envelope used to start the pipeline.""" + + success: bool + type: str + id: str + webhook_id: str = Field(alias="webhookId") + data: List[Dict[str, Any]] + metadata: Dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(populate_by_name=True) + + +class PageChange(BaseModel): + """Normalized page change consumed by downstream analysis.""" + + event_id: str + monitor_id: str + check_id: str + url: str + status: ChangeStatus + previous_scrape_id: Optional[str] = None + current_scrape_id: Optional[str] = None + unified_diff: str = "" + structured_diff: Dict[str, Any] = Field(default_factory=dict) + is_meaningful: Optional[bool] = None + firecrawl_judgment: Optional[FirecrawlJudgment] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class ChangeAnalysis(BaseModel): + """Structured business analysis of a monitored page change.""" + + summary: str + impact: str + recommended_actions: List[str] + meaningful: bool + confidence: str + analyzer: str + + +class MonitoringReport(BaseModel): + """Final report joining source evidence with its analysis.""" + + url: str + status: ChangeStatus + goal: str + analysis: ChangeAnalysis + unified_diff: str + monitor_id: str + check_id: str + event_id: str diff --git a/examples/firecrawl_web_monitor/pipelines/__init__.py b/examples/firecrawl_web_monitor/pipelines/__init__.py new file mode 100644 index 00000000000..7f541babf16 --- /dev/null +++ b/examples/firecrawl_web_monitor/pipelines/__init__.py @@ -0,0 +1 @@ +"""Pipelines for the Firecrawl web monitoring example.""" diff --git a/examples/firecrawl_web_monitor/pipelines/monitoring.py b/examples/firecrawl_web_monitor/pipelines/monitoring.py new file mode 100644 index 00000000000..408b67205e5 --- /dev/null +++ b/examples/firecrawl_web_monitor/pipelines/monitoring.py @@ -0,0 +1,52 @@ +"""ZenML pipeline for analyzing Firecrawl monitoring events.""" + +from typing import Any, Dict, Optional + +from models import MonitoringReport +from steps.analyze_page_change import analyze_page_change +from steps.build_monitoring_report import build_monitoring_report +from steps.ingest_firecrawl_event import ingest_firecrawl_event +from steps.normalize_page_change import normalize_page_change +from steps.optionally_notify_slack import optionally_notify_slack + +from zenml import pipeline +from zenml.config import DockerSettings + +docker_settings = DockerSettings( + pyproject_path="pyproject.toml", + python_package_installer="uv", +) + + +@pipeline( + enable_cache=False, + tags=["web-monitoring", "firecrawl"], + settings={"docker": docker_settings}, +) +def firecrawl_web_monitor_pipeline( + payload: Dict[str, Any], + goal: str, + notify_slack: bool = False, + llm_secret_name: Optional[str] = None, +) -> MonitoringReport: + """Version, analyze, and optionally alert on a Firecrawl page event. + + Args: + payload: A ``monitor.page`` webhook payload. + goal: Business goal used to judge the diff. + notify_slack: Whether to use the active stack's alerter. + llm_secret_name: Optional ZenML secret containing an OpenAI key. + + Returns: + The final monitoring report. + """ + event = ingest_firecrawl_event(payload=payload) + change = normalize_page_change(event=event) + analysis = analyze_page_change( + change=change, goal=goal, llm_secret_name=llm_secret_name + ) + report = build_monitoring_report( + change=change, analysis=analysis, goal=goal + ) + optionally_notify_slack(report=report, notify_slack=notify_slack) + return report diff --git a/examples/firecrawl_web_monitor/pyproject.toml b/examples/firecrawl_web_monitor/pyproject.toml new file mode 100644 index 00000000000..1fa1d3af257 --- /dev/null +++ b/examples/firecrawl_web_monitor/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "zenml-firecrawl-web-monitor" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.110,<1.0.0", + "openai>=1.0.0,<3.0.0", + "requests>=2.27.11,<3.0.0", + "uvicorn>=0.30.0,<1.0.0", + "zenml>=0.93", +] + +[project.optional-dependencies] +dev = [ + "mypy", + "pytest", + "ruff", +] + +[tool.setuptools] +py-modules = [ + "analysis", + "constants", + "create_firecrawl_monitor", + "history", + "models", + "run", + "webhook_server", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["pipelines*", "steps*"] diff --git a/examples/firecrawl_web_monitor/run.py b/examples/firecrawl_web_monitor/run.py new file mode 100644 index 00000000000..4f64966feab --- /dev/null +++ b/examples/firecrawl_web_monitor/run.py @@ -0,0 +1,60 @@ +"""Run the Firecrawl monitoring pipeline from a JSON payload.""" + +import argparse +import json +from pathlib import Path +from typing import Any, Dict + +from constants import DEFAULT_GOAL +from pipelines.monitoring import firecrawl_web_monitor_pipeline + + +def load_payload(path: Path) -> Dict[str, Any]: + """Load a Firecrawl webhook payload. + + Args: + path: JSON payload path. + + Returns: + Parsed JSON object. + """ + with path.open(encoding="utf-8") as payload_file: + payload: Dict[str, Any] = json.load(payload_file) + return payload + + +def main() -> None: + """Parse CLI arguments and start the pipeline.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "--config", + type=Path, + default=Path(__file__).parent / "configs/dev.yaml", + ) + parser.add_argument( + "--payload", + type=Path, + default=Path(__file__).with_name("sample_payload.json"), + ) + parser.add_argument("--goal", default=DEFAULT_GOAL) + parser.add_argument("--notify-slack", action="store_true") + parser.add_argument( + "--llm-secret", + default=None, + help="ZenML secret containing OPENAI_API_KEY and optional OPENAI_MODEL.", + ) + args = parser.parse_args() + + pipeline_instance = firecrawl_web_monitor_pipeline.with_options( + config_path=str(args.config), enable_cache=False + ) + pipeline_instance( + payload=load_payload(args.payload), + goal=args.goal, + notify_slack=args.notify_slack, + llm_secret_name=args.llm_secret, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/firecrawl_web_monitor/sample_payload.json b/examples/firecrawl_web_monitor/sample_payload.json new file mode 100644 index 00000000000..a3876417072 --- /dev/null +++ b/examples/firecrawl_web_monitor/sample_payload.json @@ -0,0 +1,38 @@ +{ + "success": true, + "type": "monitor.page", + "id": "019df960-5f2a-75fb-a98b-bd2d32ca67d4", + "webhookId": "f1e2d3c4-0000-0000-0000-000000000000", + "data": [ + { + "monitorId": "019df960-06e7-7383-9d89-82c0113dc31a", + "checkId": "019df960-5f2a-75fb-a98b-bd2d32ca67d4", + "url": "https://example.com/pricing", + "status": "changed", + "previousScrapeId": "019df94f-82c3-7e41-81f0-00c72b2d9c52", + "currentScrapeId": "019df960-73ee-7ac2-97a9-fb0e442c21f1", + "error": null, + "isMeaningful": true, + "judgment": { + "meaningful": true, + "confidence": "high", + "reason": "The Starter plan price increased from $19 to $24 per month.", + "meaningfulChanges": [ + { + "type": "changed", + "before": "Starter — $19/mo", + "after": "Starter — $24/mo", + "reason": "A competitor price change may require a positioning review." + } + ] + }, + "diff": { + "text": "--- previous\n+++ current\n@@ -1,3 +1,3 @@\n # Pricing\n-Starter — $19/mo\n+Starter — $24/mo\n" + } + } + ], + "metadata": { + "environment": "demo" + } +} + diff --git a/examples/firecrawl_web_monitor/steps/__init__.py b/examples/firecrawl_web_monitor/steps/__init__.py new file mode 100644 index 00000000000..1833fc71abc --- /dev/null +++ b/examples/firecrawl_web_monitor/steps/__init__.py @@ -0,0 +1 @@ +"""Steps for the Firecrawl web monitoring pipeline.""" diff --git a/examples/firecrawl_web_monitor/steps/analyze_page_change.py b/examples/firecrawl_web_monitor/steps/analyze_page_change.py new file mode 100644 index 00000000000..c24d0a56893 --- /dev/null +++ b/examples/firecrawl_web_monitor/steps/analyze_page_change.py @@ -0,0 +1,66 @@ +"""Analyze a normalized website change.""" + +from typing import Annotated, Optional + +from analysis import DEFAULT_MODEL, analyze_change +from models import ChangeAnalysis, PageChange + +from zenml import ArtifactConfig, log_metadata, step +from zenml.client import Client +from zenml.logger import get_logger + +logger = get_logger(__name__) + + +def _load_llm_credentials( + secret_name: Optional[str], +) -> tuple[Optional[str], str]: + """Load optional LLM credentials from the ZenML secret store.""" + if not secret_name: + return None, DEFAULT_MODEL + try: + values = Client().get_secret(secret_name).secret_values + except (KeyError, RuntimeError, ValueError): + logger.warning( + "ZenML secret '%s' was not found; using offline analysis.", + secret_name, + ) + return None, DEFAULT_MODEL + return values.get("OPENAI_API_KEY"), values.get( + "OPENAI_MODEL", DEFAULT_MODEL + ) + + +@step +def analyze_page_change( + change: PageChange, goal: str, llm_secret_name: Optional[str] +) -> Annotated[ + ChangeAnalysis, + ArtifactConfig(name="web_change_analysis", tags=["analysis", "llm"]), +]: + """Interpret the diff in terms of a business goal. + + Args: + change: Normalized web page change. + goal: Business goal for monitoring. + llm_secret_name: ZenML secret containing ``OPENAI_API_KEY``. + + Returns: + Structured change analysis. + """ + api_key, model = _load_llm_credentials(llm_secret_name) + analysis = analyze_change( + change=change, goal=goal, api_key=api_key, model=model + ) + log_metadata( + { + "change_analysis": { + "analyzer": analysis.analyzer, + "meaningful": analysis.meaningful, + "confidence": analysis.confidence, + "status": change.status, + "url": change.url, + } + } + ) + return analysis diff --git a/examples/firecrawl_web_monitor/steps/build_monitoring_report.py b/examples/firecrawl_web_monitor/steps/build_monitoring_report.py new file mode 100644 index 00000000000..5486591e8f4 --- /dev/null +++ b/examples/firecrawl_web_monitor/steps/build_monitoring_report.py @@ -0,0 +1,38 @@ +"""Build the final website monitoring report.""" + +from typing import Annotated + +from models import ChangeAnalysis, MonitoringReport, PageChange + +from zenml import ArtifactConfig, step + + +@step +def build_monitoring_report( + change: PageChange, analysis: ChangeAnalysis, goal: str +) -> Annotated[ + MonitoringReport, + ArtifactConfig( + name="web_monitoring_report", tags=["report", "monitoring"] + ), +]: + """Join evidence and analysis into a final versioned report. + + Args: + change: Source page change. + analysis: Interpretation of the change. + goal: Business monitoring goal. + + Returns: + Complete monitoring report. + """ + return MonitoringReport( + url=change.url, + status=change.status, + goal=goal, + analysis=analysis, + unified_diff=change.unified_diff, + monitor_id=change.monitor_id, + check_id=change.check_id, + event_id=change.event_id, + ) diff --git a/examples/firecrawl_web_monitor/steps/ingest_firecrawl_event.py b/examples/firecrawl_web_monitor/steps/ingest_firecrawl_event.py new file mode 100644 index 00000000000..db93b901ce7 --- /dev/null +++ b/examples/firecrawl_web_monitor/steps/ingest_firecrawl_event.py @@ -0,0 +1,41 @@ +"""Ingest and validate a Firecrawl webhook event.""" + +from typing import Annotated, Any, Dict + +from models import FirecrawlWebhook + +from zenml import ArtifactConfig, log_metadata, step + + +@step +def ingest_firecrawl_event( + payload: Dict[str, Any], +) -> Annotated[ + FirecrawlWebhook, + ArtifactConfig(name="firecrawl_monitor_event", tags=["firecrawl", "raw"]), +]: + """Validate and version the raw webhook envelope. + + Args: + payload: Firecrawl webhook JSON. + + Returns: + The validated webhook event. + """ + event = FirecrawlWebhook.model_validate(payload) + if event.type != "monitor.page": + raise ValueError( + f"Expected a 'monitor.page' event, received '{event.type}'." + ) + if not event.data: + raise ValueError("The Firecrawl event does not contain a page result.") + log_metadata( + { + "firecrawl_event": { + "event_id": event.id, + "event_type": event.type, + "page_count": len(event.data), + } + } + ) + return event diff --git a/examples/firecrawl_web_monitor/steps/normalize_page_change.py b/examples/firecrawl_web_monitor/steps/normalize_page_change.py new file mode 100644 index 00000000000..caa8eafb5b9 --- /dev/null +++ b/examples/firecrawl_web_monitor/steps/normalize_page_change.py @@ -0,0 +1,39 @@ +"""Normalize a Firecrawl page event.""" + +from typing import Annotated + +from models import FirecrawlPageData, FirecrawlWebhook, PageChange + +from zenml import ArtifactConfig, step + + +@step +def normalize_page_change( + event: FirecrawlWebhook, +) -> Annotated[ + PageChange, + ArtifactConfig(name="web_page_change", tags=["firecrawl", "diff"]), +]: + """Convert a page result into a stable internal contract. + + Args: + event: Validated Firecrawl event. + + Returns: + A normalized page change. + """ + page = FirecrawlPageData.model_validate(event.data[0]) + return PageChange( + event_id=event.id, + monitor_id=page.monitor_id, + check_id=page.check_id, + url=page.url, + status=page.status, + previous_scrape_id=page.previous_scrape_id, + current_scrape_id=page.current_scrape_id, + unified_diff=page.diff.text, + structured_diff=page.diff.structured, + is_meaningful=page.is_meaningful, + firecrawl_judgment=page.judgment, + metadata=event.metadata, + ) diff --git a/examples/firecrawl_web_monitor/steps/optionally_notify_slack.py b/examples/firecrawl_web_monitor/steps/optionally_notify_slack.py new file mode 100644 index 00000000000..af4e55e6234 --- /dev/null +++ b/examples/firecrawl_web_monitor/steps/optionally_notify_slack.py @@ -0,0 +1,42 @@ +"""Optionally send meaningful change reports to Slack.""" + +from models import MonitoringReport + +from zenml import step +from zenml.client import Client +from zenml.logger import get_logger + +logger = get_logger(__name__) + + +@step(enable_cache=False) +def optionally_notify_slack( + report: MonitoringReport, notify_slack: bool +) -> None: + """Post meaningful reports through the active ZenML alerter. + + Args: + report: Final monitoring report. + notify_slack: Whether notification is enabled for this run. + """ + if not notify_slack or not report.analysis.meaningful: + return + + alerter = Client().active_stack.alerter + if alerter is None: + logger.warning( + "Slack notification requested, but the active stack has no alerter." + ) + return + + actions = "\n".join( + f"- {action}" for action in report.analysis.recommended_actions + ) + alerter.post( + message=( + f"Website change detected: {report.url}\n" + f"*Summary:* {report.analysis.summary}\n" + f"*Impact:* {report.analysis.impact}\n" + f"*Recommended actions:*\n{actions}" + ) + ) diff --git a/examples/firecrawl_web_monitor/tests/test_analysis.py b/examples/firecrawl_web_monitor/tests/test_analysis.py new file mode 100644 index 00000000000..a3b8bb0847e --- /dev/null +++ b/examples/firecrawl_web_monitor/tests/test_analysis.py @@ -0,0 +1,83 @@ +"""Tests for the Firecrawl monitoring analysis helpers.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from analysis import analyze_change # noqa: E402 +from create_firecrawl_monitor import build_monitor_request # noqa: E402 +from models import ( # noqa: E402 + FirecrawlJudgment, + MeaningfulChange, + PageChange, +) + + +def test_analysis_uses_firecrawl_judgment() -> None: + """Firecrawl evidence should provide a useful no-credential fallback.""" + change = PageChange( + event_id="event", + monitor_id="monitor", + check_id="check", + url="https://example.com/pricing", + status="changed", + firecrawl_judgment=FirecrawlJudgment( + meaningful=True, + confidence="high", + reason="The price changed.", + meaningful_changes=[ + MeaningfulChange( + type="changed", + before="$19", + after="$24", + reason="Review pricing position.", + ) + ], + ), + ) + + result = analyze_change(change=change, goal="Track price changes") + + assert result.meaningful is True + assert result.analyzer == "firecrawl-judgment-fallback" + assert result.recommended_actions == ["Review pricing position."] + + +def test_analysis_counts_changed_lines_without_judgment() -> None: + """Raw diffs should still generate a transparent offline report.""" + change = PageChange( + event_id="event", + monitor_id="monitor", + check_id="check", + url="https://example.com", + status="changed", + unified_diff="--- old\n+++ new\n-before\n+after\n", + ) + + result = analyze_change(change=change, goal="Track changes") + + assert result.meaningful is True + assert "2 changed lines" in result.summary + assert result.analyzer == "deterministic-fallback" + + +def test_monitor_request_configures_scraping_and_webhooks() -> None: + """Provisioning should request page scrapes and both monitor events.""" + request = build_monitor_request( + target_url="https://example.com/pricing", + webhook_url="https://hooks.example.com/webhooks/firecrawl", + goal="Track prices", + schedule="hourly", + webhook_token="shared-secret", + ) + + assert request["targets"][0]["type"] == "scrape" + assert request["targets"][0]["scrapeOptions"]["formats"] == ["markdown"] + assert request["webhook"]["events"] == [ + "monitor.page", + "monitor.check.completed", + ] + assert request["webhook"]["headers"]["Authorization"] == ( + "Bearer shared-secret" + ) diff --git a/examples/firecrawl_web_monitor/webhook_server.py b/examples/firecrawl_web_monitor/webhook_server.py new file mode 100644 index 00000000000..f6010dcc8b0 --- /dev/null +++ b/examples/firecrawl_web_monitor/webhook_server.py @@ -0,0 +1,59 @@ +"""Local FastAPI receiver that starts a ZenML run per Firecrawl page event.""" + +import os +import secrets +from typing import Annotated, Any, Dict + +from constants import DEFAULT_GOAL +from fastapi import FastAPI, Header, HTTPException, status +from models import FirecrawlWebhook +from pipelines.monitoring import firecrawl_web_monitor_pipeline +from pydantic import BaseModel + + +class WebhookResponse(BaseModel): + """Response returned after handling a Firecrawl webhook.""" + + status: str + event_id: str + + +def create_app() -> FastAPI: + """Create the webhook receiver without module-level mutable state. + + Returns: + Configured FastAPI application. + """ + app = FastAPI(title="Firecrawl to ZenML webhook") + + @app.post("/webhooks/firecrawl") + def receive_firecrawl_webhook( + event: FirecrawlWebhook, + authorization: Annotated[str | None, Header()] = None, + ) -> WebhookResponse: + expected_token = os.getenv("FIRECRAWL_WEBHOOK_TOKEN") + if expected_token and not secrets.compare_digest( + authorization or "", f"Bearer {expected_token}" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid webhook token.", + ) + if event.type != "monitor.page": + return WebhookResponse(status="ignored", event_id=event.id) + + payload: Dict[str, Any] = event.model_dump( + by_alias=True, exclude_none=True + ) + firecrawl_web_monitor_pipeline( + payload=payload, + goal=os.getenv("MONITORING_GOAL", DEFAULT_GOAL), + notify_slack=os.getenv("NOTIFY_SLACK", "false").lower() == "true", + llm_secret_name=os.getenv("LLM_SECRET_NAME"), + ) + return WebhookResponse(status="completed", event_id=event.id) + + return app + + +app = create_app() From 0395c757445993faef54c96729970965d851e21e Mon Sep 17 00:00:00 2001 From: Hamza Tahir Date: Fri, 10 Jul 2026 17:08:18 -0700 Subject: [PATCH 02/10] Install no modules from the example's pyproject Socket flagged the example branch because its manifest parser read the setuptools py-modules list as PyPI dependencies and matched "constants" against the obfuscated `constants` package on PyPI. The example never depended on that package, but installing generic top-level module names (constants, models, run) also shadows other projects in shared venvs. The project now installs dependencies only; all code runs from the example directory as the README already instructs. Co-Authored-By: Claude Fable 5 --- examples/firecrawl_web_monitor/pyproject.toml | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/examples/firecrawl_web_monitor/pyproject.toml b/examples/firecrawl_web_monitor/pyproject.toml index 1fa1d3af257..a82acd4a72e 100644 --- a/examples/firecrawl_web_monitor/pyproject.toml +++ b/examples/firecrawl_web_monitor/pyproject.toml @@ -21,17 +21,11 @@ dev = [ "ruff", ] +# Nothing is installed into site-packages: this project exists only to +# declare dependencies (also exported into Docker images via the pipeline's +# DockerSettings). All code runs from this directory, and installing +# modules with generic names like `constants` or `models` would shadow +# other projects and confuse supply-chain scanners. [tool.setuptools] -py-modules = [ - "analysis", - "constants", - "create_firecrawl_monitor", - "history", - "models", - "run", - "webhook_server", -] - -[tool.setuptools.packages.find] -where = ["."] -include = ["pipelines*", "steps*"] +py-modules = [] +packages = [] From 353b030815f1c22a8d08aaf53d1ab426dfe2e676 Mon Sep 17 00:00:00 2001 From: Hamza Tahir Date: Fri, 10 Jul 2026 17:21:23 -0700 Subject: [PATCH 03/10] Replace the webhook receiver with API check pulling Firecrawl's monitoring API supports reading check results directly (GET /v2/monitor/{id}/checks), so the local flow does not need webhook infrastructure: `run.py --monitor-id` pulls the latest completed check and runs the pipeline once per page result. The custom FastAPI receiver is removed along with the fastapi/uvicorn dependencies; event-driven production setups should trigger a ZenML pipeline snapshot via the REST API instead, which the README now documents. Monitor creation makes the webhook optional for the same reason. Co-Authored-By: Claude Fable 5 --- examples/firecrawl_web_monitor/README.md | 45 +++------- .../create_firecrawl_monitor.py | 41 +++++---- .../fetch_firecrawl_check.py | 85 +++++++++++++++++++ examples/firecrawl_web_monitor/pyproject.toml | 2 - examples/firecrawl_web_monitor/run.py | 58 +++++++++++-- .../tests/test_analysis.py | 54 ++++++++++-- .../firecrawl_web_monitor/webhook_server.py | 59 ------------- 7 files changed, 220 insertions(+), 124 deletions(-) create mode 100644 examples/firecrawl_web_monitor/fetch_firecrawl_check.py delete mode 100644 examples/firecrawl_web_monitor/webhook_server.py diff --git a/examples/firecrawl_web_monitor/README.md b/examples/firecrawl_web_monitor/README.md index 1bf1051de0f..0ff9676ba90 100644 --- a/examples/firecrawl_web_monitor/README.md +++ b/examples/firecrawl_web_monitor/README.md @@ -1,9 +1,9 @@ # Firecrawl Web Monitoring with ZenML -This example turns each Firecrawl `monitor.page` webhook into a ZenML pipeline run. Firecrawl monitors and scrapes the page; ZenML preserves the event, diff, analysis, and report as independently versioned artifacts. An LLM interprets the change when an OpenAI key is stored in a ZenML secret and passed via `--llm-secret` (or `LLM_SECRET_NAME` for the webhook receiver), and a configured ZenML alerter can optionally post meaningful changes to Slack. +This example turns each Firecrawl `monitor.page` event into a ZenML pipeline run. Firecrawl monitors and scrapes the page; ZenML preserves the event, diff, analysis, and report as independently versioned artifacts. An LLM interprets the change when an OpenAI key is stored in a ZenML secret and passed via `--llm-secret`, and a configured ZenML alerter can optionally post meaningful changes to Slack. ```text -Firecrawl webhook +Firecrawl monitor.page event (pulled via API or delivered by webhook) | v raw event -> normalized diff -> LLM analysis -> report -> optional Slack @@ -51,47 +51,28 @@ Run the example twice, optionally editing `sample_payload.json`, then inspect th python history.py ``` -## Receive Firecrawl webhooks locally +## Monitor a real page -Start the synchronous FastAPI receiver: - -```bash -export FIRECRAWL_WEBHOOK_TOKEN= -uvicorn webhook_server:app --host 0.0.0.0 --port 8000 -``` - -Expose port 8000 through your preferred development tunnel, then add the public endpoint to a Firecrawl monitor. The authorization header is optional locally but recommended for every public endpoint: +Create an hourly Firecrawl `scrape` monitor for the page you care about — no webhook infrastructure needed: ```bash export FIRECRAWL_API_KEY= python create_firecrawl_monitor.py \ --target-url https://example.com/pricing \ - --webhook-url https://your-public-host/webhooks/firecrawl \ --goal "Alert when a competitor changes price or packaging" ``` -The script creates an hourly Firecrawl `scrape` monitor. Change the cadence with `--schedule`, for example `--schedule daily`. It uses `FIRECRAWL_WEBHOOK_TOKEN` for the receiver authorization header when that environment variable is set. - -The equivalent webhook portion of the monitor request is: - -```json -{ - "webhook": { - "url": "https://your-public-host/webhooks/firecrawl", - "headers": { - "Authorization": "Bearer " - }, - "metadata": { - "environment": "trial" - }, - "events": ["monitor.page", "monitor.check.completed"] - } -} +Change the cadence with `--schedule`, for example `--schedule daily`. Once at least one check has completed, pull it straight from the Firecrawl API and analyze it — one pipeline run per monitored page: + +```bash +python run.py --monitor-id ``` -The receiver ignores `monitor.check.completed` because that event contains only aggregate counts. Each `monitor.page` event starts one pipeline run and carries the page-level diff needed by the analysis. +This fetches the latest completed check (or a specific one with `--check-id`) and feeds each page result through the same pipeline as the bundled sample, so the artifact history mixes local experiments and real checks seamlessly. + +## Production: trigger a pipeline snapshot -For production, deploy the receiver behind an authenticated HTTPS endpoint and run ZenML with a [Kubernetes orchestrator](https://docs.zenml.io/stacks/stack-components/orchestrators/kubernetes). The pipeline includes Docker settings sourced from `pyproject.toml`, so the code and artifact contracts do not change when moving from the local orchestrator. +For an event-driven setup, ZenML supports this natively — no custom receiver required. Publish the pipeline as a [snapshot](https://docs.zenml.io/concepts/snapshots) on a remote stack (for example a [Kubernetes orchestrator](https://docs.zenml.io/stacks/stack-components/orchestrators/kubernetes)); the pipeline's Docker settings are already sourced from this `pyproject.toml`, so the code and artifact contracts do not change. Snapshots can then be [triggered from external systems](https://docs.zenml.io/user-guides/tutorial/trigger-pipelines-from-external-systems) with a single authenticated REST call, which is where Firecrawl's webhook plugs in. ## Optional Slack alerts @@ -101,7 +82,7 @@ Attach a Slack alerter to the active ZenML stack, then enable notifications for python run.py --notify-slack ``` -For the webhook receiver, set `NOTIFY_SLACK=true` and optionally `LLM_SECRET_NAME=firecrawl-monitoring`. Only reports marked meaningful are sent. If notification is enabled but the active stack has no alerter, the step logs a warning and the run still succeeds. +Only reports marked meaningful are sent. If notification is enabled but the active stack has no alerter, the step logs a warning and the run still succeeds. ## Use a real payload directly diff --git a/examples/firecrawl_web_monitor/create_firecrawl_monitor.py b/examples/firecrawl_web_monitor/create_firecrawl_monitor.py index b1b630ded8e..b3001f22c00 100644 --- a/examples/firecrawl_web_monitor/create_firecrawl_monitor.py +++ b/examples/firecrawl_web_monitor/create_firecrawl_monitor.py @@ -1,4 +1,4 @@ -"""Create a Firecrawl page monitor that sends diffs to the local receiver.""" +"""Create a Firecrawl page monitor whose checks the pipeline can pull.""" import argparse import os @@ -10,27 +10,25 @@ def build_monitor_request( target_url: str, - webhook_url: str, goal: str, schedule: str, - webhook_token: Optional[str], + webhook_url: Optional[str] = None, + webhook_token: Optional[str] = None, ) -> Dict[str, Any]: """Build a Firecrawl v2 monitor request. Args: target_url: Page that Firecrawl should monitor and scrape. - webhook_url: Public receiver URL. goal: Meaningful-change goal. schedule: Firecrawl natural-language schedule. - webhook_token: Optional shared receiver token. + webhook_url: Optional delivery endpoint. Without it, checks are + pulled via the API (``run.py --monitor-id``). + webhook_token: Optional bearer token for the webhook endpoint. Returns: Firecrawl monitor request body. """ - headers = ( - {"Authorization": f"Bearer {webhook_token}"} if webhook_token else {} - ) - return { + request: Dict[str, Any] = { "name": f"ZenML monitor: {target_url}", "schedule": {"text": schedule, "timezone": "UTC"}, "goal": goal, @@ -44,22 +42,35 @@ def build_monitor_request( }, } ], - "webhook": { + } + if webhook_url: + headers = ( + {"Authorization": f"Bearer {webhook_token}"} + if webhook_token + else {} + ) + request["webhook"] = { "url": webhook_url, "headers": headers, "metadata": {"source": "zenml-firecrawl-example"}, - "events": ["monitor.page", "monitor.check.completed"], - }, - } + "events": ["monitor.page"], + } + return request def main() -> None: """Create a monitor using credentials from the environment.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--target-url", required=True) - parser.add_argument("--webhook-url", required=True) parser.add_argument("--goal", default=DEFAULT_GOAL) parser.add_argument("--schedule", default="hourly") + parser.add_argument( + "--webhook-url", + default=None, + help="Optional endpoint Firecrawl should POST monitor.page events " + "to, e.g. a relay that triggers a ZenML pipeline snapshot. Omit it " + "to pull checks with `run.py --monitor-id` instead.", + ) args = parser.parse_args() api_key = os.getenv("FIRECRAWL_API_KEY") @@ -74,9 +85,9 @@ def main() -> None: }, json=build_monitor_request( target_url=args.target_url, - webhook_url=args.webhook_url, goal=args.goal, schedule=args.schedule, + webhook_url=args.webhook_url, webhook_token=os.getenv("FIRECRAWL_WEBHOOK_TOKEN"), ), timeout=30, diff --git a/examples/firecrawl_web_monitor/fetch_firecrawl_check.py b/examples/firecrawl_web_monitor/fetch_firecrawl_check.py new file mode 100644 index 00000000000..38479e5eb5e --- /dev/null +++ b/examples/firecrawl_web_monitor/fetch_firecrawl_check.py @@ -0,0 +1,85 @@ +"""Pull Firecrawl monitor checks and convert them into pipeline payloads.""" + +from typing import Any, Dict, List, Optional + +import requests + +FIRECRAWL_API_URL = "https://api.firecrawl.dev/v2" + + +def build_page_payloads(check: Dict[str, Any]) -> List[Dict[str, Any]]: + """Convert a check-details response into per-page pipeline payloads. + + The per-page objects returned by the checks API match the ``data`` + entries of a ``monitor.page`` webhook, so each page becomes one payload + with the same envelope a webhook delivery would carry. ``monitorId`` and + ``checkId`` live on the parent check object in API responses, so they + are merged into each page. + + Args: + check: The ``data`` object of a check-details response. + + Returns: + One ``monitor.page``-style payload per page result. + """ + payloads: List[Dict[str, Any]] = [] + for page in check.get("pages", []): + page_data = { + "monitorId": check["monitorId"], + "checkId": check["id"], + **page, + } + payloads.append( + { + "success": True, + "type": "monitor.page", + "id": check["id"], + "webhookId": "api-pull", + "data": [page_data], + "metadata": {"source": "fetch_firecrawl_check"}, + } + ) + return payloads + + +def fetch_check( + api_key: str, monitor_id: str, check_id: Optional[str] = None +) -> Dict[str, Any]: + """Fetch a monitor check, defaulting to the latest completed one. + + Args: + api_key: Firecrawl API key. + monitor_id: Monitor to read checks from. + check_id: Specific check to fetch. Defaults to the most recent + completed check. + + Raises: + RuntimeError: If the monitor has no completed checks yet. + + Returns: + The ``data`` object of the check-details response. + """ + headers = {"Authorization": f"Bearer {api_key}"} + if check_id is None: + response = requests.get( + f"{FIRECRAWL_API_URL}/monitor/{monitor_id}/checks", + headers=headers, + params={"status": "completed"}, + timeout=30, + ) + response.raise_for_status() + checks = response.json().get("data", []) + if not checks: + raise RuntimeError( + f"Monitor {monitor_id} has no completed checks yet." + ) + check_id = checks[0]["id"] + + response = requests.get( + f"{FIRECRAWL_API_URL}/monitor/{monitor_id}/checks/{check_id}", + headers=headers, + timeout=30, + ) + response.raise_for_status() + data: Dict[str, Any] = response.json()["data"] + return data diff --git a/examples/firecrawl_web_monitor/pyproject.toml b/examples/firecrawl_web_monitor/pyproject.toml index a82acd4a72e..85b8444d1b2 100644 --- a/examples/firecrawl_web_monitor/pyproject.toml +++ b/examples/firecrawl_web_monitor/pyproject.toml @@ -7,10 +7,8 @@ name = "zenml-firecrawl-web-monitor" version = "0.1.0" requires-python = ">=3.10" dependencies = [ - "fastapi>=0.110,<1.0.0", "openai>=1.0.0,<3.0.0", "requests>=2.27.11,<3.0.0", - "uvicorn>=0.30.0,<1.0.0", "zenml>=0.93", ] diff --git a/examples/firecrawl_web_monitor/run.py b/examples/firecrawl_web_monitor/run.py index 4f64966feab..760e2cfcc6c 100644 --- a/examples/firecrawl_web_monitor/run.py +++ b/examples/firecrawl_web_monitor/run.py @@ -1,11 +1,13 @@ -"""Run the Firecrawl monitoring pipeline from a JSON payload.""" +"""Run the Firecrawl monitoring pipeline from a JSON payload or the API.""" import argparse import json +import os from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List from constants import DEFAULT_GOAL +from fetch_firecrawl_check import build_page_payloads, fetch_check from pipelines.monitoring import firecrawl_web_monitor_pipeline @@ -23,6 +25,33 @@ def load_payload(path: Path) -> Dict[str, Any]: return payload +def collect_payloads(args: argparse.Namespace) -> List[Dict[str, Any]]: + """Gather one payload per page, from the Firecrawl API or a local file. + + Args: + args: Parsed CLI arguments. + + Raises: + RuntimeError: If ``--monitor-id`` is used without FIRECRAWL_API_KEY. + + Returns: + Payloads to run the pipeline on, one per page result. + """ + if args.monitor_id: + api_key = os.getenv("FIRECRAWL_API_KEY") + if not api_key: + raise RuntimeError( + "Set FIRECRAWL_API_KEY to pull checks with --monitor-id." + ) + check = fetch_check( + api_key=api_key, + monitor_id=args.monitor_id, + check_id=args.check_id, + ) + return build_page_payloads(check) + return [load_payload(args.payload)] + + def main() -> None: """Parse CLI arguments and start the pipeline.""" parser = argparse.ArgumentParser() @@ -36,6 +65,18 @@ def main() -> None: type=Path, default=Path(__file__).with_name("sample_payload.json"), ) + parser.add_argument( + "--monitor-id", + default=None, + help="Pull the latest completed check of this Firecrawl monitor " + "instead of reading --payload. Requires FIRECRAWL_API_KEY.", + ) + parser.add_argument( + "--check-id", + default=None, + help="Specific check to pull with --monitor-id. Defaults to the " + "latest completed check.", + ) parser.add_argument("--goal", default=DEFAULT_GOAL) parser.add_argument("--notify-slack", action="store_true") parser.add_argument( @@ -48,12 +89,13 @@ def main() -> None: pipeline_instance = firecrawl_web_monitor_pipeline.with_options( config_path=str(args.config), enable_cache=False ) - pipeline_instance( - payload=load_payload(args.payload), - goal=args.goal, - notify_slack=args.notify_slack, - llm_secret_name=args.llm_secret, - ) + for payload in collect_payloads(args): + pipeline_instance( + payload=payload, + goal=args.goal, + notify_slack=args.notify_slack, + llm_secret_name=args.llm_secret, + ) if __name__ == "__main__": diff --git a/examples/firecrawl_web_monitor/tests/test_analysis.py b/examples/firecrawl_web_monitor/tests/test_analysis.py index a3b8bb0847e..5dd86b2e0d0 100644 --- a/examples/firecrawl_web_monitor/tests/test_analysis.py +++ b/examples/firecrawl_web_monitor/tests/test_analysis.py @@ -7,8 +7,10 @@ from analysis import analyze_change # noqa: E402 from create_firecrawl_monitor import build_monitor_request # noqa: E402 +from fetch_firecrawl_check import build_page_payloads # noqa: E402 from models import ( # noqa: E402 FirecrawlJudgment, + FirecrawlWebhook, MeaningfulChange, PageChange, ) @@ -62,22 +64,58 @@ def test_analysis_counts_changed_lines_without_judgment() -> None: assert result.analyzer == "deterministic-fallback" -def test_monitor_request_configures_scraping_and_webhooks() -> None: - """Provisioning should request page scrapes and both monitor events.""" +def test_monitor_request_defaults_to_pull_without_webhook() -> None: + """Provisioning should request page scrapes and omit webhook config.""" request = build_monitor_request( target_url="https://example.com/pricing", - webhook_url="https://hooks.example.com/webhooks/firecrawl", goal="Track prices", schedule="hourly", - webhook_token="shared-secret", ) assert request["targets"][0]["type"] == "scrape" assert request["targets"][0]["scrapeOptions"]["formats"] == ["markdown"] - assert request["webhook"]["events"] == [ - "monitor.page", - "monitor.check.completed", - ] + assert "webhook" not in request + + +def test_monitor_request_supports_optional_webhook() -> None: + """An explicit webhook endpoint should be configured with its token.""" + request = build_monitor_request( + target_url="https://example.com/pricing", + goal="Track prices", + schedule="hourly", + webhook_url="https://hooks.example.com/webhooks/firecrawl", + webhook_token="shared-secret", + ) + + assert request["webhook"]["events"] == ["monitor.page"] assert request["webhook"]["headers"]["Authorization"] == ( "Bearer shared-secret" ) + + +def test_check_pages_become_webhook_style_payloads() -> None: + """Pulled check pages should validate as monitor.page payloads.""" + check = { + "id": "check-1", + "monitorId": "monitor-1", + "status": "completed", + "pages": [ + { + "url": "https://example.com/pricing", + "status": "changed", + "diff": {"text": "-old\n+new"}, + }, + { + "url": "https://example.com/blog", + "status": "same", + }, + ], + } + + payloads = build_page_payloads(check) + + assert len(payloads) == 2 + events = [FirecrawlWebhook.model_validate(p) for p in payloads] + assert all(e.type == "monitor.page" for e in events) + assert events[0].data[0]["monitorId"] == "monitor-1" + assert events[0].data[0]["checkId"] == "check-1" diff --git a/examples/firecrawl_web_monitor/webhook_server.py b/examples/firecrawl_web_monitor/webhook_server.py deleted file mode 100644 index f6010dcc8b0..00000000000 --- a/examples/firecrawl_web_monitor/webhook_server.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Local FastAPI receiver that starts a ZenML run per Firecrawl page event.""" - -import os -import secrets -from typing import Annotated, Any, Dict - -from constants import DEFAULT_GOAL -from fastapi import FastAPI, Header, HTTPException, status -from models import FirecrawlWebhook -from pipelines.monitoring import firecrawl_web_monitor_pipeline -from pydantic import BaseModel - - -class WebhookResponse(BaseModel): - """Response returned after handling a Firecrawl webhook.""" - - status: str - event_id: str - - -def create_app() -> FastAPI: - """Create the webhook receiver without module-level mutable state. - - Returns: - Configured FastAPI application. - """ - app = FastAPI(title="Firecrawl to ZenML webhook") - - @app.post("/webhooks/firecrawl") - def receive_firecrawl_webhook( - event: FirecrawlWebhook, - authorization: Annotated[str | None, Header()] = None, - ) -> WebhookResponse: - expected_token = os.getenv("FIRECRAWL_WEBHOOK_TOKEN") - if expected_token and not secrets.compare_digest( - authorization or "", f"Bearer {expected_token}" - ): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid webhook token.", - ) - if event.type != "monitor.page": - return WebhookResponse(status="ignored", event_id=event.id) - - payload: Dict[str, Any] = event.model_dump( - by_alias=True, exclude_none=True - ) - firecrawl_web_monitor_pipeline( - payload=payload, - goal=os.getenv("MONITORING_GOAL", DEFAULT_GOAL), - notify_slack=os.getenv("NOTIFY_SLACK", "false").lower() == "true", - llm_secret_name=os.getenv("LLM_SECRET_NAME"), - ) - return WebhookResponse(status="completed", event_id=event.id) - - return app - - -app = create_app() From 431cf3c2df713b626677e908400aa0e93c204311 Mon Sep 17 00:00:00 2001 From: Hamza Tahir Date: Fri, 10 Jul 2026 17:33:19 -0700 Subject: [PATCH 04/10] Harden history.py and document stack selection after zenml init Versions written from another environment (e.g. an S3 store without the s3 integration installed locally) crashed history.py; they are now reported and skipped. The README notes that zenml init scopes the active stack to the directory and resets it to default, which silently sent fresh-clone runs to the local store, and that S3-backed stacks need the s3 integration installed before the first run. Co-Authored-By: Claude Fable 5 --- examples/firecrawl_web_monitor/README.md | 12 ++++++++---- examples/firecrawl_web_monitor/history.py | 9 ++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/examples/firecrawl_web_monitor/README.md b/examples/firecrawl_web_monitor/README.md index 0ff9676ba90..35b28bc73b8 100644 --- a/examples/firecrawl_web_monitor/README.md +++ b/examples/firecrawl_web_monitor/README.md @@ -28,9 +28,13 @@ zenml init python run.py ``` -Run `zenml init` and all commands from this directory: it sets the ZenML source root, and the pipeline's Docker settings reference the `pyproject.toml` here, so remote image builds pick up the example's dependencies. +Run `zenml init` and all commands from this directory: it sets the ZenML source root, and the pipeline's Docker settings reference the `pyproject.toml` here, so remote image builds pick up the example's dependencies. Note that `zenml init` also scopes the active stack to this directory and resets it to `default`, so select your stack afterwards: -If the active stack uses an S3 artifact store, install its client integration in the same environment once: +```bash +zenml stack set +``` + +If that stack uses an S3 artifact store, install its client integration in the same environment once, before running the pipeline: ```bash zenml integration install s3 --uv @@ -58,8 +62,8 @@ Create an hourly Firecrawl `scrape` monitor for the page you care about — no w ```bash export FIRECRAWL_API_KEY= python create_firecrawl_monitor.py \ - --target-url https://example.com/pricing \ - --goal "Alert when a competitor changes price or packaging" + --target-url https://news.ycombinator.com \ + --goal "Alert when new AI or developer-tooling stories reach the front page" ``` Change the cadence with `--schedule`, for example `--schedule daily`. Once at least one check has completed, pull it straight from the Firecrawl API and analyze it — one pipeline run per monitored page: diff --git a/examples/firecrawl_web_monitor/history.py b/examples/firecrawl_web_monitor/history.py index 90d1df976a7..ded0991ff5f 100644 --- a/examples/firecrawl_web_monitor/history.py +++ b/examples/firecrawl_web_monitor/history.py @@ -15,7 +15,14 @@ def main() -> None: return for version in versions: - report: MonitoringReport = version.load() + try: + report: MonitoringReport = version.load() + except RuntimeError as error: + print( + f"v{version.version}: not loadable from this environment " + f"({error})" + ) + continue print( f"v{version.version}: {report.status} | " f"meaningful={report.analysis.meaningful} | {report.url}" From 98e7ac4f69849954a3b905a31270a47c44443f1c Mon Sep 17 00:00:00 2001 From: Hamza Tahir Date: Fri, 10 Jul 2026 17:53:38 -0700 Subject: [PATCH 05/10] Accept null diffs from baseline Firecrawl checks The first check of a monitor reports pages with status 'new' and an explicit null diff, which failed FirecrawlPageData validation when pulling real checks via the API. Found by running the pipeline against a live monitor; the bundled sample payload never exercised this. Co-Authored-By: Claude Fable 5 --- examples/firecrawl_web_monitor/models.py | 15 ++++++++++++++- .../tests/test_analysis.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/examples/firecrawl_web_monitor/models.py b/examples/firecrawl_web_monitor/models.py index d079e41a26d..16b8441bd04 100644 --- a/examples/firecrawl_web_monitor/models.py +++ b/examples/firecrawl_web_monitor/models.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator ChangeStatus = Literal["same", "changed", "new", "removed", "error"] @@ -58,6 +58,19 @@ class FirecrawlPageData(BaseModel): model_config = ConfigDict(populate_by_name=True) + @field_validator("diff", mode="before") + @classmethod + def _default_null_diff(cls, value: object) -> object: + """Firecrawl sends an explicit null diff for baseline 'new' pages. + + Args: + value: Raw ``diff`` value from the payload. + + Returns: + An empty diff object when the payload contains null. + """ + return value if value is not None else {} + class FirecrawlWebhook(BaseModel): """Firecrawl webhook envelope used to start the pipeline.""" diff --git a/examples/firecrawl_web_monitor/tests/test_analysis.py b/examples/firecrawl_web_monitor/tests/test_analysis.py index 5dd86b2e0d0..9ba49667f8e 100644 --- a/examples/firecrawl_web_monitor/tests/test_analysis.py +++ b/examples/firecrawl_web_monitor/tests/test_analysis.py @@ -10,6 +10,7 @@ from fetch_firecrawl_check import build_page_payloads # noqa: E402 from models import ( # noqa: E402 FirecrawlJudgment, + FirecrawlPageData, FirecrawlWebhook, MeaningfulChange, PageChange, @@ -93,6 +94,23 @@ def test_monitor_request_supports_optional_webhook() -> None: ) +def test_baseline_page_with_null_diff_validates() -> None: + """Baseline 'new' checks deliver an explicit null diff over the API.""" + page = FirecrawlPageData.model_validate( + { + "monitorId": "monitor-1", + "checkId": "check-1", + "url": "https://news.ycombinator.com/newest", + "status": "new", + "diff": None, + "judgment": None, + } + ) + + assert page.diff.text == "" + assert page.diff.structured == {} + + def test_check_pages_become_webhook_style_payloads() -> None: """Pulled check pages should validate as monitor.page payloads.""" check = { From b328d304009668360fe386e28ddce16816e20a46 Mon Sep 17 00:00:00 2001 From: Hamza Tahir Date: Fri, 10 Jul 2026 17:55:20 -0700 Subject: [PATCH 06/10] Drop example tests and dev extras Examples in this repo do not ship test suites; the pipeline is validated by running it. Removing the tests also removes the need for the dev optional dependencies. Co-Authored-By: Claude Fable 5 --- examples/firecrawl_web_monitor/README.md | 41 ++++-- .../configs/snapshot.yaml | 33 +++++ examples/firecrawl_web_monitor/pyproject.toml | 8 - .../tests/test_analysis.py | 139 ------------------ 4 files changed, 62 insertions(+), 159 deletions(-) create mode 100644 examples/firecrawl_web_monitor/configs/snapshot.yaml delete mode 100644 examples/firecrawl_web_monitor/tests/test_analysis.py diff --git a/examples/firecrawl_web_monitor/README.md b/examples/firecrawl_web_monitor/README.md index 35b28bc73b8..55baad7d139 100644 --- a/examples/firecrawl_web_monitor/README.md +++ b/examples/firecrawl_web_monitor/README.md @@ -23,7 +23,7 @@ The stable artifact names make every run easy to compare in the ZenML dashboard: This example assumes that a [ZenML stack](https://docs.zenml.io/stacks) is already configured. From this directory, install the small set of example dependencies and run the bundled realistic event: ```bash -uv pip install -e ".[dev]" +uv pip install -e . zenml init python run.py ``` @@ -62,11 +62,12 @@ Create an hourly Firecrawl `scrape` monitor for the page you care about — no w ```bash export FIRECRAWL_API_KEY= python create_firecrawl_monitor.py \ - --target-url https://news.ycombinator.com \ - --goal "Alert when new AI or developer-tooling stories reach the front page" + --target-url https://news.ycombinator.com/newest \ + --goal "Alert when new AI or developer-tooling stories are posted" \ + --schedule "every 5 minutes" ``` -Change the cadence with `--schedule`, for example `--schedule daily`. Once at least one check has completed, pull it straight from the Firecrawl API and analyze it — one pipeline run per monitored page: +A fast-changing page with a short cadence produces meaningful diffs within minutes — ideal for a first demo; dial it back with `--schedule hourly` or `--schedule daily` for real monitoring. Once at least one check has completed, pull it straight from the Firecrawl API and analyze it — one pipeline run per monitored page: ```bash python run.py --monitor-id @@ -76,7 +77,29 @@ This fetches the latest completed check (or a specific one with `--check-id`) an ## Production: trigger a pipeline snapshot -For an event-driven setup, ZenML supports this natively — no custom receiver required. Publish the pipeline as a [snapshot](https://docs.zenml.io/concepts/snapshots) on a remote stack (for example a [Kubernetes orchestrator](https://docs.zenml.io/stacks/stack-components/orchestrators/kubernetes)); the pipeline's Docker settings are already sourced from this `pyproject.toml`, so the code and artifact contracts do not change. Snapshots can then be [triggered from external systems](https://docs.zenml.io/user-guides/tutorial/trigger-pipelines-from-external-systems) with a single authenticated REST call, which is where Firecrawl's webhook plugs in. +For an event-driven setup, ZenML supports this natively — no custom receiver required. Publish the pipeline as a [snapshot](https://docs.zenml.io/concepts/snapshots) on a remote stack with a remote orchestrator, a container registry, and a remote artifact store (for example a [Kubernetes orchestrator](https://docs.zenml.io/stacks/stack-components/orchestrators/kubernetes)); the pipeline's Docker settings are already sourced from this `pyproject.toml`, so the code and artifact contracts do not change: + +```bash +zenml stack set +zenml pipeline snapshot create pipelines.monitoring.firecrawl_web_monitor_pipeline \ + --name firecrawl-web-monitor \ + --config configs/snapshot.yaml +``` + +`configs/snapshot.yaml` provides the default parameters baked into the snapshot (the bundled sample payload); every trigger can override them. Trigger a run from Python: + +```python +from zenml.client import Client + +Client().trigger_pipeline( + snapshot_name_or_id="firecrawl-web-monitor", + run_configuration={"parameters": {"payload": }}, +) +``` + +Snapshots can also be [triggered from external systems](https://docs.zenml.io/user-guides/tutorial/trigger-pipelines-from-external-systems) with a single authenticated REST call, which is where Firecrawl's webhook plugs in. + +Tip for Apple Silicon: images build for `linux/amd64` under emulation, where `uv` can crash. If the build fails with exit code 139, override the installer with `settings: {docker: {python_package_installer: pip}}` in the snapshot config. ## Optional Slack alerts @@ -98,10 +121,4 @@ python run.py \ --goal "Alert when a competitor changes price or packaging" ``` -Firecrawl can emit both unified markdown diffs and structured JSON field diffs. This example preserves both, so a monitor configured for JSON change tracking can compare fields such as prices or availability without parsing prose. - -## Tests - -```bash -pytest tests/test_analysis.py -``` +Firecrawl can emit both unified markdown diffs and structured JSON field diffs. This example preserves both, so a monitor configured for JSON change tracking can compare fields such as prices or availability without parsing prose. \ No newline at end of file diff --git a/examples/firecrawl_web_monitor/configs/snapshot.yaml b/examples/firecrawl_web_monitor/configs/snapshot.yaml new file mode 100644 index 00000000000..199ce3c4fe4 --- /dev/null +++ b/examples/firecrawl_web_monitor/configs/snapshot.yaml @@ -0,0 +1,33 @@ +parameters: + payload: + success: true + type: monitor.page + id: 019df960-5f2a-75fb-a98b-bd2d32ca67d4 + webhookId: f1e2d3c4-0000-0000-0000-000000000000 + data: + - monitorId: 019df960-06e7-7383-9d89-82c0113dc31a + checkId: 019df960-5f2a-75fb-a98b-bd2d32ca67d4 + url: https://example.com/pricing + status: changed + previousScrapeId: 019df94f-82c3-7e41-81f0-00c72b2d9c52 + currentScrapeId: 019df960-73ee-7ac2-97a9-fb0e442c21f1 + error: null + isMeaningful: true + judgment: + meaningful: true + confidence: high + reason: The Starter plan price increased from $19 to $24 per month. + meaningfulChanges: + - type: changed + before: "Starter \u2014 $19/mo" + after: "Starter \u2014 $24/mo" + reason: A competitor price change may require a positioning review. + diff: + text: "--- previous\n+++ current\n@@ -1,3 +1,3 @@\n # Pricing\n-Starter \u2014\ + \ $19/mo\n+Starter \u2014 $24/mo\n" + metadata: + environment: demo + goal: Alert when new AI or developer-tooling stories reach the front page + notify_slack: false + llm_secret_name: null +enable_cache: false diff --git a/examples/firecrawl_web_monitor/pyproject.toml b/examples/firecrawl_web_monitor/pyproject.toml index 85b8444d1b2..f0a1beac5b9 100644 --- a/examples/firecrawl_web_monitor/pyproject.toml +++ b/examples/firecrawl_web_monitor/pyproject.toml @@ -11,14 +11,6 @@ dependencies = [ "requests>=2.27.11,<3.0.0", "zenml>=0.93", ] - -[project.optional-dependencies] -dev = [ - "mypy", - "pytest", - "ruff", -] - # Nothing is installed into site-packages: this project exists only to # declare dependencies (also exported into Docker images via the pipeline's # DockerSettings). All code runs from this directory, and installing diff --git a/examples/firecrawl_web_monitor/tests/test_analysis.py b/examples/firecrawl_web_monitor/tests/test_analysis.py deleted file mode 100644 index 9ba49667f8e..00000000000 --- a/examples/firecrawl_web_monitor/tests/test_analysis.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Tests for the Firecrawl monitoring analysis helpers.""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parents[1])) - -from analysis import analyze_change # noqa: E402 -from create_firecrawl_monitor import build_monitor_request # noqa: E402 -from fetch_firecrawl_check import build_page_payloads # noqa: E402 -from models import ( # noqa: E402 - FirecrawlJudgment, - FirecrawlPageData, - FirecrawlWebhook, - MeaningfulChange, - PageChange, -) - - -def test_analysis_uses_firecrawl_judgment() -> None: - """Firecrawl evidence should provide a useful no-credential fallback.""" - change = PageChange( - event_id="event", - monitor_id="monitor", - check_id="check", - url="https://example.com/pricing", - status="changed", - firecrawl_judgment=FirecrawlJudgment( - meaningful=True, - confidence="high", - reason="The price changed.", - meaningful_changes=[ - MeaningfulChange( - type="changed", - before="$19", - after="$24", - reason="Review pricing position.", - ) - ], - ), - ) - - result = analyze_change(change=change, goal="Track price changes") - - assert result.meaningful is True - assert result.analyzer == "firecrawl-judgment-fallback" - assert result.recommended_actions == ["Review pricing position."] - - -def test_analysis_counts_changed_lines_without_judgment() -> None: - """Raw diffs should still generate a transparent offline report.""" - change = PageChange( - event_id="event", - monitor_id="monitor", - check_id="check", - url="https://example.com", - status="changed", - unified_diff="--- old\n+++ new\n-before\n+after\n", - ) - - result = analyze_change(change=change, goal="Track changes") - - assert result.meaningful is True - assert "2 changed lines" in result.summary - assert result.analyzer == "deterministic-fallback" - - -def test_monitor_request_defaults_to_pull_without_webhook() -> None: - """Provisioning should request page scrapes and omit webhook config.""" - request = build_monitor_request( - target_url="https://example.com/pricing", - goal="Track prices", - schedule="hourly", - ) - - assert request["targets"][0]["type"] == "scrape" - assert request["targets"][0]["scrapeOptions"]["formats"] == ["markdown"] - assert "webhook" not in request - - -def test_monitor_request_supports_optional_webhook() -> None: - """An explicit webhook endpoint should be configured with its token.""" - request = build_monitor_request( - target_url="https://example.com/pricing", - goal="Track prices", - schedule="hourly", - webhook_url="https://hooks.example.com/webhooks/firecrawl", - webhook_token="shared-secret", - ) - - assert request["webhook"]["events"] == ["monitor.page"] - assert request["webhook"]["headers"]["Authorization"] == ( - "Bearer shared-secret" - ) - - -def test_baseline_page_with_null_diff_validates() -> None: - """Baseline 'new' checks deliver an explicit null diff over the API.""" - page = FirecrawlPageData.model_validate( - { - "monitorId": "monitor-1", - "checkId": "check-1", - "url": "https://news.ycombinator.com/newest", - "status": "new", - "diff": None, - "judgment": None, - } - ) - - assert page.diff.text == "" - assert page.diff.structured == {} - - -def test_check_pages_become_webhook_style_payloads() -> None: - """Pulled check pages should validate as monitor.page payloads.""" - check = { - "id": "check-1", - "monitorId": "monitor-1", - "status": "completed", - "pages": [ - { - "url": "https://example.com/pricing", - "status": "changed", - "diff": {"text": "-old\n+new"}, - }, - { - "url": "https://example.com/blog", - "status": "same", - }, - ], - } - - payloads = build_page_payloads(check) - - assert len(payloads) == 2 - events = [FirecrawlWebhook.model_validate(p) for p in payloads] - assert all(e.type == "monitor.page" for e in events) - assert events[0].data[0]["monitorId"] == "monitor-1" - assert events[0].data[0]["checkId"] == "check-1" From 143d04f3d3ac0076a08f652a4d40ece155a0b0c2 Mon Sep 17 00:00:00 2001 From: Hamza Tahir Date: Fri, 10 Jul 2026 22:24:48 -0700 Subject: [PATCH 07/10] Correct snapshot trigger docs to step-level parameters Server-triggered runs of static pipelines reject pipeline-level parameters; verified against a live snapshot trigger. Co-Authored-By: Claude Fable 5 --- examples/firecrawl_web_monitor/README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/firecrawl_web_monitor/README.md b/examples/firecrawl_web_monitor/README.md index 55baad7d139..20c29ab8be4 100644 --- a/examples/firecrawl_web_monitor/README.md +++ b/examples/firecrawl_web_monitor/README.md @@ -86,14 +86,20 @@ zenml pipeline snapshot create pipelines.monitoring.firecrawl_web_monitor_pipeli --config configs/snapshot.yaml ``` -`configs/snapshot.yaml` provides the default parameters baked into the snapshot (the bundled sample payload); every trigger can override them. Trigger a run from Python: +`configs/snapshot.yaml` provides the default parameters baked into the snapshot (the bundled sample payload); triggers can override them per step. Note that server-triggered runs of static pipelines take step-level parameters, not pipeline-level ones. Trigger a run from Python: ```python from zenml.client import Client Client().trigger_pipeline( snapshot_name_or_id="firecrawl-web-monitor", - run_configuration={"parameters": {"payload": }}, + run_configuration={ + "steps": { + "ingest_firecrawl_event": { + "parameters": {"payload": } + } + } + }, ) ``` From 358d2cdfbb5a8a4b7f7bace0827ed10fb8af9c2d Mon Sep 17 00:00:00 2001 From: Hamza Tahir Date: Fri, 10 Jul 2026 22:45:45 -0700 Subject: [PATCH 08/10] Tighten the example README and drop unused config Fold the stack-set ordering into the quick-start block, remove the redundant raw-payload section (superseded by --monitor-id), and delete configs/prod.yaml, which nothing referenced. Co-Authored-By: Claude Fable 5 --- examples/firecrawl_web_monitor/README.md | 30 ++++--------------- .../firecrawl_web_monitor/configs/prod.yaml | 3 -- 2 files changed, 5 insertions(+), 28 deletions(-) delete mode 100644 examples/firecrawl_web_monitor/configs/prod.yaml diff --git a/examples/firecrawl_web_monitor/README.md b/examples/firecrawl_web_monitor/README.md index 20c29ab8be4..6f12246b555 100644 --- a/examples/firecrawl_web_monitor/README.md +++ b/examples/firecrawl_web_monitor/README.md @@ -20,25 +20,16 @@ The stable artifact names make every run easy to compare in the ZenML dashboard: ## Quick start -This example assumes that a [ZenML stack](https://docs.zenml.io/stacks) is already configured. From this directory, install the small set of example dependencies and run the bundled realistic event: +This example assumes that a [ZenML stack](https://docs.zenml.io/stacks) is already configured. From this directory, install the example dependencies and run the bundled realistic event: ```bash uv pip install -e . zenml init -python run.py -``` - -Run `zenml init` and all commands from this directory: it sets the ZenML source root, and the pipeline's Docker settings reference the `pyproject.toml` here, so remote image builds pick up the example's dependencies. Note that `zenml init` also scopes the active stack to this directory and resets it to `default`, so select your stack afterwards: - -```bash zenml stack set +python run.py ``` -If that stack uses an S3 artifact store, install its client integration in the same environment once, before running the pipeline: - -```bash -zenml integration install s3 --uv -``` +Run everything from this directory: `zenml init` sets the ZenML source root here (and the pipeline's Docker settings reference this `pyproject.toml`, so remote image builds pick up the right dependencies). Set the stack *after* `zenml init` — init scopes the active stack to this directory and resets it to `default`. If your stack uses an S3 artifact store, also run `zenml integration install s3 --uv` once before the first run. No Firecrawl, OpenAI, or Slack credentials are required for that first run. Without an LLM secret, the analysis uses Firecrawl's meaningful-change judgment and clearly labels itself as a fallback. Store the OpenAI key in ZenML to run the same pipeline with an LLM on both local and remote orchestrators: @@ -57,7 +48,7 @@ python history.py ## Monitor a real page -Create an hourly Firecrawl `scrape` monitor for the page you care about — no webhook infrastructure needed: +Create a Firecrawl `scrape` monitor for the page you care about — no webhook infrastructure needed: ```bash export FIRECRAWL_API_KEY= @@ -73,7 +64,7 @@ A fast-changing page with a short cadence produces meaningful diffs within minut python run.py --monitor-id ``` -This fetches the latest completed check (or a specific one with `--check-id`) and feeds each page result through the same pipeline as the bundled sample, so the artifact history mixes local experiments and real checks seamlessly. +This fetches the latest completed check (or a specific one with `--check-id`) and feeds each page result through the same pipeline as the bundled sample, so the artifact history mixes local experiments and real checks seamlessly. Firecrawl emits both unified markdown diffs and structured JSON field diffs; the pipeline preserves both, so a monitor configured for JSON change tracking can compare fields such as prices or availability without parsing prose. ## Production: trigger a pipeline snapshot @@ -117,14 +108,3 @@ python run.py --notify-slack Only reports marked meaningful are sent. If notification is enabled but the active stack has no alerter, the step logs a warning and the run still succeeds. -## Use a real payload directly - -Save any Firecrawl `monitor.page` body and pass it to the runner: - -```bash -python run.py \ - --payload payload.json \ - --goal "Alert when a competitor changes price or packaging" -``` - -Firecrawl can emit both unified markdown diffs and structured JSON field diffs. This example preserves both, so a monitor configured for JSON change tracking can compare fields such as prices or availability without parsing prose. \ No newline at end of file diff --git a/examples/firecrawl_web_monitor/configs/prod.yaml b/examples/firecrawl_web_monitor/configs/prod.yaml deleted file mode 100644 index 51c6532a9b4..00000000000 --- a/examples/firecrawl_web_monitor/configs/prod.yaml +++ /dev/null @@ -1,3 +0,0 @@ -enable_cache: false -tags: - - production From 0f4d3909b95ff88780e222ef0f09b334502e5491 Mon Sep 17 00:00:00 2001 From: Hamza Tahir Date: Fri, 10 Jul 2026 22:53:25 -0700 Subject: [PATCH 09/10] Explain what the Slack alerter is in the README Co-Authored-By: Claude Fable 5 --- examples/firecrawl_web_monitor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/firecrawl_web_monitor/README.md b/examples/firecrawl_web_monitor/README.md index 6f12246b555..097151e21d2 100644 --- a/examples/firecrawl_web_monitor/README.md +++ b/examples/firecrawl_web_monitor/README.md @@ -100,7 +100,7 @@ Tip for Apple Silicon: images build for `linux/amd64` under emulation, where `uv ## Optional Slack alerts -Attach a Slack alerter to the active ZenML stack, then enable notifications for CLI runs: +ZenML's [Slack alerter](https://docs.zenml.io/stacks/stack-components/alerters/slack) is a stack component registered once with a Slack bot token and channel; any pipeline on the stack can then post to Slack without handling Slack credentials in code. Attach one to the active stack, then enable notifications for CLI runs: ```bash python run.py --notify-slack From 7118879857d7c12ed2c631930b1624d9cbbc2e33 Mon Sep 17 00:00:00 2001 From: Hamza Tahir Date: Fri, 17 Jul 2026 11:33:45 +0200 Subject: [PATCH 10/10] Address review feedback on Firecrawl web monitor example Fix demo correctness and honesty issues raised in review of #5065: - Align configs/snapshot.yaml goal with the baked pricing payload so a default snapshot run no longer analyzes a price diff against an unrelated "AI stories" goal. - Select the newest monitor check by timestamp instead of assuming the checks API returns newest-first, falling back to the first entry when no recognized timestamp field is present. - Warn when a monitor.page event carries multiple pages, since only the first is normalized; a multi-page event would otherwise drop pages silently. - Document ongoing Firecrawl/OpenAI cost and add a curl snippet to delete a monitor after a demo, and note the pip fallback for the install step. Co-Authored-By: Claude Fable 5 --- examples/firecrawl_web_monitor/README.md | 11 ++++++++-- .../configs/snapshot.yaml | 3 ++- .../fetch_firecrawl_check.py | 22 ++++++++++++++++++- .../steps/normalize_page_change.py | 13 +++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/examples/firecrawl_web_monitor/README.md b/examples/firecrawl_web_monitor/README.md index 097151e21d2..9e41d199ade 100644 --- a/examples/firecrawl_web_monitor/README.md +++ b/examples/firecrawl_web_monitor/README.md @@ -23,7 +23,7 @@ The stable artifact names make every run easy to compare in the ZenML dashboard: This example assumes that a [ZenML stack](https://docs.zenml.io/stacks) is already configured. From this directory, install the example dependencies and run the bundled realistic event: ```bash -uv pip install -e . +uv pip install -e . # or: pip install -e . zenml init zenml stack set python run.py @@ -58,7 +58,7 @@ python create_firecrawl_monitor.py \ --schedule "every 5 minutes" ``` -A fast-changing page with a short cadence produces meaningful diffs within minutes — ideal for a first demo; dial it back with `--schedule hourly` or `--schedule daily` for real monitoring. Once at least one check has completed, pull it straight from the Firecrawl API and analyze it — one pipeline run per monitored page: +A fast-changing page with a short cadence produces meaningful diffs within minutes — ideal for a first demo; dial it back with `--schedule hourly` or `--schedule daily` for real monitoring. A live monitor keeps running and consuming Firecrawl credits on every scheduled check until you delete it, and `--llm-secret` runs spend OpenAI tokens per analyzed change — so remember to clean up after a demo (see below). Once at least one check has completed, pull it straight from the Firecrawl API and analyze it — one pipeline run per monitored page: ```bash python run.py --monitor-id @@ -66,6 +66,13 @@ python run.py --monitor-id This fetches the latest completed check (or a specific one with `--check-id`) and feeds each page result through the same pipeline as the bundled sample, so the artifact history mixes local experiments and real checks seamlessly. Firecrawl emits both unified markdown diffs and structured JSON field diffs; the pipeline preserves both, so a monitor configured for JSON change tracking can compare fields such as prices or availability without parsing prose. +When you are done, delete the monitor so it stops running checks and billing: + +```bash +curl -X DELETE https://api.firecrawl.dev/v2/monitor/ \ + -H "Authorization: Bearer $FIRECRAWL_API_KEY" +``` + ## Production: trigger a pipeline snapshot For an event-driven setup, ZenML supports this natively — no custom receiver required. Publish the pipeline as a [snapshot](https://docs.zenml.io/concepts/snapshots) on a remote stack with a remote orchestrator, a container registry, and a remote artifact store (for example a [Kubernetes orchestrator](https://docs.zenml.io/stacks/stack-components/orchestrators/kubernetes)); the pipeline's Docker settings are already sourced from this `pyproject.toml`, so the code and artifact contracts do not change: diff --git a/examples/firecrawl_web_monitor/configs/snapshot.yaml b/examples/firecrawl_web_monitor/configs/snapshot.yaml index 199ce3c4fe4..82675b46f7e 100644 --- a/examples/firecrawl_web_monitor/configs/snapshot.yaml +++ b/examples/firecrawl_web_monitor/configs/snapshot.yaml @@ -27,7 +27,8 @@ parameters: \ $19/mo\n+Starter \u2014 $24/mo\n" metadata: environment: demo - goal: Alert when new AI or developer-tooling stories reach the front page + goal: Identify product, pricing, positioning, or release changes that require + a response from our product or go-to-market teams. notify_slack: false llm_secret_name: null enable_cache: false diff --git a/examples/firecrawl_web_monitor/fetch_firecrawl_check.py b/examples/firecrawl_web_monitor/fetch_firecrawl_check.py index 38479e5eb5e..b42c0f50a3f 100644 --- a/examples/firecrawl_web_monitor/fetch_firecrawl_check.py +++ b/examples/firecrawl_web_monitor/fetch_firecrawl_check.py @@ -6,6 +6,26 @@ FIRECRAWL_API_URL = "https://api.firecrawl.dev/v2" +# Firecrawl timestamps a completed check under one of these keys; used to pick +# the newest check without relying on the API's response ordering. +_CHECK_TIMESTAMP_KEYS = ("completedAt", "updatedAt", "createdAt", "startedAt") + + +def _latest_check(checks: List[Dict[str, Any]]) -> Dict[str, Any]: + """Return the most recent check by timestamp, ignoring response order. + + Args: + checks: Completed checks as returned by the checks API. + + Returns: + The check with the newest timestamp, or the first entry if none of + the checks carry a recognized timestamp field. + """ + for key in _CHECK_TIMESTAMP_KEYS: + if all(key in check for check in checks): + return max(checks, key=lambda check: check[key]) + return checks[0] + def build_page_payloads(check: Dict[str, Any]) -> List[Dict[str, Any]]: """Convert a check-details response into per-page pipeline payloads. @@ -73,7 +93,7 @@ def fetch_check( raise RuntimeError( f"Monitor {monitor_id} has no completed checks yet." ) - check_id = checks[0]["id"] + check_id = _latest_check(checks)["id"] response = requests.get( f"{FIRECRAWL_API_URL}/monitor/{monitor_id}/checks/{check_id}", diff --git a/examples/firecrawl_web_monitor/steps/normalize_page_change.py b/examples/firecrawl_web_monitor/steps/normalize_page_change.py index caa8eafb5b9..28b641e78e7 100644 --- a/examples/firecrawl_web_monitor/steps/normalize_page_change.py +++ b/examples/firecrawl_web_monitor/steps/normalize_page_change.py @@ -5,6 +5,9 @@ from models import FirecrawlPageData, FirecrawlWebhook, PageChange from zenml import ArtifactConfig, step +from zenml.logger import get_logger + +logger = get_logger(__name__) @step @@ -22,6 +25,16 @@ def normalize_page_change( Returns: A normalized page change. """ + # Firecrawl delivers one monitor.page event per page, and the API-pull + # path emits one single-page payload per page. A multi-page event would + # therefore be a misconfiguration; warn so the dropped pages are visible. + if len(event.data) > 1: + logger.warning( + "Firecrawl event %s carries %d pages; only the first is " + "normalized.", + event.id, + len(event.data), + ) page = FirecrawlPageData.model_validate(event.data[0]) return PageChange( event_id=event.id,