Files
wehub-resource-sync fbfefa28d3
CodeQL / Analyze (python) (push) Failing after 0s
Release / Build (push) Failing after 1s
Release / Release (push) Waiting to run
Test Suite / Unit Tests (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:18:10 +08:00

217 lines
7.5 KiB
Python

"""
SmartScraperMultiBatchGraph Module
A scraping pipeline that uses the OpenAI Batch API for LLM calls,
providing 50% cost savings compared to real-time API calls.
"""
import asyncio
from copy import deepcopy
from typing import Dict, List, Optional, Type
from pydantic import BaseModel
from ..nodes import FetchNode, GraphIteratorNode, ParseNode
from ..nodes.batch_generate_answer_node import BatchGenerateAnswerNode
from ..nodes.merge_answers_node import MergeAnswersNode
from ..utils.copy import safe_deepcopy
from .abstract_graph import AbstractGraph
from .base_graph import BaseGraph
from .smart_scraper_graph import SmartScraperGraph
class _FetchParseOnlyGraph(AbstractGraph):
"""Internal graph that only fetches and parses a URL (no LLM generation).
This is used to separate the fetch/parse phase from the LLM generation
phase, allowing all LLM calls to be batched together.
"""
def __init__(
self,
prompt: str,
source: str,
config: dict,
schema: Optional[Type[BaseModel]] = None,
):
super().__init__(prompt, config, source, schema)
self.input_key = "url" if source.startswith("http") else "local_dir"
def _create_graph(self) -> BaseGraph:
fetch_node = FetchNode(
input="url | local_dir",
output=["doc"],
node_config={
"llm_model": self.llm_model,
"force": self.config.get("force", False),
"cut": self.config.get("cut", True),
"loader_kwargs": self.config.get("loader_kwargs", {}),
"browser_base": self.config.get("browser_base"),
"scrape_do": self.config.get("scrape_do"),
"storage_state": self.config.get("storage_state"),
},
)
parse_node = ParseNode(
input="doc",
output=["parsed_doc"],
node_config={
"llm_model": self.llm_model,
"chunk_size": self.model_token,
},
)
return BaseGraph(
nodes=[fetch_node, parse_node],
edges=[(fetch_node, parse_node)],
entry_point=fetch_node,
graph_name=self.__class__.__name__,
)
def run(self) -> str:
inputs = {"user_prompt": self.prompt, self.input_key: self.source}
self.final_state, self.execution_info = self.graph.execute(inputs)
return self.final_state.get("parsed_doc", "")
class SmartScraperMultiBatchGraph(AbstractGraph):
"""A scraping pipeline that uses OpenAI Batch API for cost savings.
Similar to SmartScraperMultiGraph, but instead of making individual
LLM calls per URL, it:
1. Fetches and parses all URLs concurrently (Phase 1)
2. Collects all prompts and submits them as a single OpenAI Batch (Phase 2)
3. Polls for batch completion (Phase 3)
4. Merges all results into a final answer (Phase 4)
This provides ~50% cost savings on OpenAI API calls at the expense
of higher latency (up to 24 hours for batch completion).
Attributes:
prompt (str): The user prompt for scraping.
source (List[str]): List of URLs to scrape.
config (dict): Configuration including 'llm' and optional 'batch_api' settings.
schema (Optional[BaseModel]): Optional Pydantic schema for structured output.
Config options under 'batch_api':
poll_interval (int): Seconds between batch status checks (default: 30).
max_wait_time (int): Maximum wait time in seconds (default: 86400 = 24h).
model (str): Override model for batch requests (optional).
temperature (float): Temperature for batch requests (default: 0.0).
Example:
>>> graph = SmartScraperMultiBatchGraph(
... prompt="Extract the main topic and key points",
... source=[
... "https://example.com/page1",
... "https://example.com/page2",
... ],
... config={
... "llm": {"model": "openai/gpt-4o-mini"},
... "batch_api": {
... "poll_interval": 30,
... "max_wait_time": 3600,
... },
... }
... )
>>> result = graph.run()
"""
def __init__(
self,
prompt: str,
source: List[str],
config: dict,
schema: Optional[Type[BaseModel]] = None,
):
self.copy_config = safe_deepcopy(config)
self.copy_schema = deepcopy(schema)
self.batch_config = config.get("batch_api", {})
# Validate that the model is OpenAI-based
model_str = config.get("llm", {}).get("model", "")
if "/" in model_str:
provider = model_str.split("/")[0]
else:
provider = ""
if provider and provider != "openai":
raise ValueError(
f"SmartScraperMultiBatchGraph only supports OpenAI models. "
f"Got provider '{provider}'. "
f"Use SmartScraperMultiGraph for other providers."
)
super().__init__(prompt, config, source, schema)
def _create_graph(self) -> BaseGraph:
"""Creates the graph of nodes for the batch scraping pipeline.
The graph has two phases:
1. GraphIteratorNode runs _FetchParseOnlyGraph per URL (concurrent)
2. BatchGenerateAnswerNode submits all prompts via Batch API
3. MergeAnswersNode combines the results
Returns:
BaseGraph: A graph instance representing the batch scraping workflow.
"""
# Phase 1: Fetch and parse all URLs concurrently
graph_iterator_node = GraphIteratorNode(
input="user_prompt & urls",
output=["parsed_docs"],
node_config={
"graph_instance": _FetchParseOnlyGraph,
"scraper_config": self.copy_config,
},
schema=self.copy_schema,
)
# Phase 2: Submit all prompts to OpenAI Batch API
batch_generate_node = BatchGenerateAnswerNode(
input="user_prompt & parsed_docs",
output=["results"],
node_config={
"llm_model": self.llm_model,
"schema": self.copy_schema,
"batch_config": self.batch_config,
},
)
# Phase 3: Merge all results
merge_answers_node = MergeAnswersNode(
input="user_prompt & results",
output=["answer"],
node_config={
"llm_model": self.llm_model,
"schema": self.copy_schema,
},
)
return BaseGraph(
nodes=[
graph_iterator_node,
batch_generate_node,
merge_answers_node,
],
edges=[
(graph_iterator_node, batch_generate_node),
(batch_generate_node, merge_answers_node),
],
entry_point=graph_iterator_node,
graph_name=self.__class__.__name__,
)
def run(self) -> str:
"""Executes the full batch scraping pipeline.
This will:
1. Fetch and parse all URLs concurrently
2. Submit all LLM prompts as an OpenAI Batch
3. Poll until the batch completes (may take minutes to hours)
4. Merge results into a final answer
Returns:
str: The merged answer from all scraped URLs.
"""
inputs = {"user_prompt": self.prompt, "urls": self.source}
self.final_state, self.execution_info = self.graph.execute(inputs)
return self.final_state.get("answer", "No answer found.")