fix(fetch): surface HTTP errors and missing content instead of answering NA
A page that could not be scraped as intended was indistinguishable from one that could. FetchNode's default path (ChromiumLoader -> ascrape_playwright) dropped the Response returned by page.goto(), so a 404, 403, 500, captcha wall or login redirect reached the LLM as ordinary content and the model answered "NA" with nothing in the logs to explain why. Reported in #1102, where en.wikipedia.org/wiki/Timpson_(company) 404s (the article is at Timpson_(retailer)) and the run still looked clean. Two deterministic, LLM-free guards, both warnings so existing behaviour is unchanged for anyone deliberately scraping error pages: - ChromiumLoader keeps the Response from every page.goto() call site (ascrape_playwright, ascrape_playwright_scroll, ascrape_with_js_support) and warns on status >= 400. This mirrors what the opt-in use_soup=True path in FetchNode has always done. - ParseNode warns when the parsed content contains none of the terms the user asked about — schema field names plus the significant words of the prompt. A 200 response can still reach the LLM without the requested data: content behind JavaScript that never rendered, a field inside a <script> blob the parser drops, or a document truncated beyond the model window. Zero matches is a deliberately conservative bar, so the warning stays quiet when the page simply phrases the answer differently. The graphs now pass their schema to ParseNode so it has the field names available. Verified against the URLs from the issue: the 404 now logs "Received HTTP 404 for .../Timpson_(company); the scraped content is likely an error page" before returning NA, while the corrected URL stays silent and answers 1865. Also drops three dead imports from smart_scraper_multi_batch_graph.py, which ruff blocks on now that the file is touched. Fixes #1102 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,7 @@ jobs:
|
||||
tests/test_batch_api.py
|
||||
tests/test_csv_scraper_multi_graph.py
|
||||
tests/test_depth_search_graph.py
|
||||
tests/test_error_page_detection.py
|
||||
tests/test_json_scraper_graph.py
|
||||
tests/test_minimax_models.py
|
||||
tests/test_scrape_do.py
|
||||
|
||||
@@ -10,6 +10,32 @@ from ..utils import Proxy, dynamic_import, get_logger, parse_or_search_proxy
|
||||
logger = get_logger("web-loader")
|
||||
|
||||
|
||||
def _warn_on_error_status(response: Any, url: str) -> None:
|
||||
"""Log a warning when a navigation returned an HTTP error status.
|
||||
|
||||
Playwright's ``page.goto()`` returns the main-frame ``Response``, but the
|
||||
scrapers only keep ``page.content()``. Without this check an error page
|
||||
(404, 403, 500, a captcha wall, a login redirect) is indistinguishable
|
||||
from the intended document once it reaches the LLM, which then produces a
|
||||
confidently wrong answer with no signal that anything went wrong.
|
||||
|
||||
This mirrors the behaviour of the ``use_soup=True`` path in ``FetchNode``:
|
||||
it warns rather than raising, so scraping error pages on purpose keeps
|
||||
working.
|
||||
|
||||
Args:
|
||||
response: The ``Response`` returned by ``page.goto()``; may be ``None``
|
||||
(for example on a same-document navigation) or lack a usable status.
|
||||
url: The URL that was requested, used in the warning message.
|
||||
"""
|
||||
status = getattr(response, "status", None)
|
||||
if isinstance(status, int) and status >= 400:
|
||||
logger.warning(
|
||||
f"Received HTTP {status} for {url}; the scraped content is likely "
|
||||
"an error page, not the intended document."
|
||||
)
|
||||
|
||||
|
||||
class ChromiumLoader:
|
||||
"""Scrapes HTML pages from URLs using a (headless) instance of the
|
||||
Chromium web driver with proxy protection.
|
||||
@@ -251,7 +277,8 @@ class ChromiumLoader:
|
||||
context = await browser.new_context()
|
||||
await Malenia.apply_stealth(context)
|
||||
page = await context.new_page()
|
||||
await page.goto(url, wait_until="domcontentloaded")
|
||||
response = await page.goto(url, wait_until="domcontentloaded")
|
||||
_warn_on_error_status(response, url)
|
||||
await page.wait_for_load_state(self.load_state)
|
||||
|
||||
previous_height = None
|
||||
@@ -364,7 +391,8 @@ class ChromiumLoader:
|
||||
)
|
||||
await Malenia.apply_stealth(context)
|
||||
page = await context.new_page()
|
||||
await page.goto(url, wait_until="domcontentloaded")
|
||||
response = await page.goto(url, wait_until="domcontentloaded")
|
||||
_warn_on_error_status(response, url)
|
||||
await page.wait_for_load_state(self.load_state)
|
||||
results = await page.content()
|
||||
logger.info("Content scraped")
|
||||
@@ -421,7 +449,8 @@ class ChromiumLoader:
|
||||
storage_state=self.storage_state
|
||||
)
|
||||
page = await context.new_page()
|
||||
await page.goto(url, wait_until="networkidle")
|
||||
response = await page.goto(url, wait_until="networkidle")
|
||||
_warn_on_error_status(response, url)
|
||||
results = await page.content()
|
||||
logger.info("Content scraped after JavaScript rendering")
|
||||
return results
|
||||
|
||||
@@ -93,7 +93,11 @@ class CodeGeneratorGraph(AbstractGraph):
|
||||
parse_node = ParseNode(
|
||||
input="doc",
|
||||
output=["parsed_doc"],
|
||||
node_config={"llm_model": self.llm_model, "chunk_size": self.model_token},
|
||||
node_config={
|
||||
"llm_model": self.llm_model,
|
||||
"chunk_size": self.model_token,
|
||||
"schema": self.schema,
|
||||
},
|
||||
)
|
||||
|
||||
generate_validation_answer_node = GenerateAnswerNode(
|
||||
|
||||
@@ -76,6 +76,7 @@ class DocumentScraperGraph(AbstractGraph):
|
||||
"parse_html": False,
|
||||
"chunk_size": self.model_token,
|
||||
"llm_model": self.llm_model,
|
||||
"schema": self.schema,
|
||||
},
|
||||
)
|
||||
generate_answer_node = GenerateAnswerNode(
|
||||
|
||||
@@ -83,6 +83,7 @@ class OmniScraperGraph(AbstractGraph):
|
||||
"chunk_size": self.model_token,
|
||||
"parse_urls": True,
|
||||
"llm_model": self.llm_model,
|
||||
"schema": self.schema,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ class ScriptCreatorGraph(AbstractGraph):
|
||||
"chunk_size": self.model_token,
|
||||
"parse_html": False,
|
||||
"llm_model": self.llm_model,
|
||||
"schema": self.schema,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -110,7 +110,11 @@ class SmartScraperGraph(AbstractGraph):
|
||||
parse_node = ParseNode(
|
||||
input="doc",
|
||||
output=["parsed_doc"],
|
||||
node_config={"llm_model": self.llm_model, "chunk_size": self.model_token},
|
||||
node_config={
|
||||
"llm_model": self.llm_model,
|
||||
"chunk_size": self.model_token,
|
||||
"schema": self.schema,
|
||||
},
|
||||
)
|
||||
|
||||
generate_answer_node = GenerateAnswerNode(
|
||||
@@ -152,6 +156,7 @@ class SmartScraperGraph(AbstractGraph):
|
||||
node_config={
|
||||
"llm_model": self.llm_model,
|
||||
"chunk_size": self.model_token,
|
||||
"schema": self.schema,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -74,7 +74,11 @@ class SmartScraperLiteGraph(AbstractGraph):
|
||||
parse_node = ParseNode(
|
||||
input="doc",
|
||||
output=["parsed_doc"],
|
||||
node_config={"llm_model": self.llm_model, "chunk_size": self.model_token},
|
||||
node_config={
|
||||
"llm_model": self.llm_model,
|
||||
"chunk_size": self.model_token,
|
||||
"schema": self.schema,
|
||||
},
|
||||
)
|
||||
|
||||
return BaseGraph(
|
||||
|
||||
@@ -5,9 +5,8 @@ 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 typing import List, Optional, Type
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -17,7 +16,6 @@ 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):
|
||||
@@ -57,6 +55,7 @@ class _FetchParseOnlyGraph(AbstractGraph):
|
||||
node_config={
|
||||
"llm_model": self.llm_model,
|
||||
"chunk_size": self.model_token,
|
||||
"schema": self.schema,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,11 @@ class SpeechGraph(AbstractGraph):
|
||||
parse_node = ParseNode(
|
||||
input="doc",
|
||||
output=["parsed_doc"],
|
||||
node_config={"chunk_size": self.model_token, "llm_model": self.llm_model},
|
||||
node_config={
|
||||
"chunk_size": self.model_token,
|
||||
"llm_model": self.llm_model,
|
||||
"schema": self.schema,
|
||||
},
|
||||
)
|
||||
|
||||
generate_answer_node = GenerateAnswerNode(
|
||||
|
||||
@@ -3,7 +3,7 @@ ParseNode Module
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import List, Optional, Set, Tuple, get_args
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from langchain_community.document_transformers import Html2TextTransformer
|
||||
@@ -37,6 +37,93 @@ class ParseNode(BaseNode):
|
||||
)
|
||||
relative_url_pattern = re.compile(r"[\(](/[^\(\)\s]*)")
|
||||
|
||||
# Words carrying no discriminative signal, dropped before checking whether the
|
||||
# parsed document contains any evidence of what the user asked for.
|
||||
prompt_stopwords = frozenset(
|
||||
{
|
||||
"about",
|
||||
"after",
|
||||
"all",
|
||||
"also",
|
||||
"and",
|
||||
"any",
|
||||
"are",
|
||||
"been",
|
||||
"both",
|
||||
"but",
|
||||
"can",
|
||||
"content",
|
||||
"data",
|
||||
"does",
|
||||
"each",
|
||||
"every",
|
||||
"extract",
|
||||
"find",
|
||||
"for",
|
||||
"from",
|
||||
"get",
|
||||
"give",
|
||||
"has",
|
||||
"have",
|
||||
"here",
|
||||
"how",
|
||||
"html",
|
||||
"info",
|
||||
"information",
|
||||
"into",
|
||||
"its",
|
||||
"json",
|
||||
"list",
|
||||
"many",
|
||||
"more",
|
||||
"much",
|
||||
"must",
|
||||
"not",
|
||||
"only",
|
||||
"out",
|
||||
"page",
|
||||
"please",
|
||||
"provide",
|
||||
"retrieve",
|
||||
"return",
|
||||
"scrape",
|
||||
"site",
|
||||
"some",
|
||||
"such",
|
||||
"text",
|
||||
"that",
|
||||
"the",
|
||||
"their",
|
||||
"them",
|
||||
"then",
|
||||
"there",
|
||||
"these",
|
||||
"they",
|
||||
"this",
|
||||
"those",
|
||||
"url",
|
||||
"was",
|
||||
"web",
|
||||
"webpage",
|
||||
"website",
|
||||
"were",
|
||||
"what",
|
||||
"when",
|
||||
"where",
|
||||
"which",
|
||||
"who",
|
||||
"why",
|
||||
"will",
|
||||
"with",
|
||||
"would",
|
||||
"you",
|
||||
"your",
|
||||
}
|
||||
)
|
||||
|
||||
word_pattern = re.compile(r"[a-zA-Z][a-zA-Z0-9]{2,}")
|
||||
camel_case_pattern = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input: str,
|
||||
@@ -58,6 +145,7 @@ class ParseNode(BaseNode):
|
||||
|
||||
self.llm_model = node_config.get("llm_model")
|
||||
self.chunk_size = node_config.get("chunk_size")
|
||||
self.schema = node_config.get("schema")
|
||||
|
||||
def execute(self, state: dict) -> dict:
|
||||
"""
|
||||
@@ -119,6 +207,8 @@ class ParseNode(BaseNode):
|
||||
text=docs_transformed, chunk_size=chunk_size
|
||||
)
|
||||
|
||||
self._warn_if_content_lacks_requested_fields(chunks, state.get("user_prompt"))
|
||||
|
||||
state.update({self.output[0]: chunks})
|
||||
state.update({"parsed_doc": chunks})
|
||||
|
||||
@@ -128,6 +218,170 @@ class ParseNode(BaseNode):
|
||||
|
||||
return state
|
||||
|
||||
def _warn_if_content_lacks_requested_fields(
|
||||
self, chunks: List[str], user_prompt: Optional[str]
|
||||
) -> None:
|
||||
"""
|
||||
Warns when the parsed content holds no trace of what the user asked for.
|
||||
|
||||
An HTTP status check catches error pages, but a perfectly valid 200 page
|
||||
can still reach the LLM without the requested data: content behind
|
||||
JavaScript that never rendered, a field living in a ``<script>`` blob the
|
||||
parser drops, or a document truncated beyond the model window. In each
|
||||
case the LLM answers ``NA`` and the run looks clean.
|
||||
|
||||
This is a deterministic, LLM-free check: it collects the terms the user
|
||||
asked about (schema field names and the significant words of the prompt)
|
||||
and warns only when *none* of them appear in the parsed text. Zero
|
||||
matches is a deliberately conservative bar, so the warning stays quiet
|
||||
for legitimate runs where the answer is phrased differently from the
|
||||
question.
|
||||
|
||||
Args:
|
||||
chunks (List[str]): The parsed content chunks about to be handed downstream.
|
||||
user_prompt (Optional[str]): The user's request, when available in the state.
|
||||
"""
|
||||
texts = [chunk for chunk in chunks if isinstance(chunk, str)]
|
||||
total_length = sum(len(text) for text in texts)
|
||||
|
||||
if not any(text.strip() for text in texts):
|
||||
self.logger.warning(
|
||||
"The parsed content is empty; the model will be asked to answer "
|
||||
"from nothing. Check that the source was fetched correctly."
|
||||
)
|
||||
return
|
||||
|
||||
expected_terms = self._collect_expected_terms(user_prompt)
|
||||
if not expected_terms:
|
||||
return
|
||||
|
||||
# Chunks overlap, so a term split across a boundary is still found in one
|
||||
# of them; searching chunk by chunk avoids rebuilding the whole document.
|
||||
for text in texts:
|
||||
lowered = text.lower()
|
||||
if any(term in lowered for term in expected_terms):
|
||||
return
|
||||
|
||||
self.logger.warning(
|
||||
f"None of the requested terms {sorted(expected_terms)} appear in the "
|
||||
f"parsed content ({total_length} chars). The source may be an error "
|
||||
"page, may render its content with JavaScript, or the relevant "
|
||||
"section may have been dropped while parsing; the model will most "
|
||||
"likely answer NA."
|
||||
)
|
||||
|
||||
def _collect_expected_terms(self, user_prompt: Optional[str]) -> Set[str]:
|
||||
"""
|
||||
Builds the set of lowercase terms that evidence the user's request.
|
||||
|
||||
Args:
|
||||
user_prompt (Optional[str]): The user's request, when available.
|
||||
|
||||
Returns:
|
||||
Set[str]: Significant terms drawn from the schema field names and the
|
||||
prompt; empty when nothing discriminative could be derived.
|
||||
"""
|
||||
terms = self._schema_field_terms(self.schema)
|
||||
|
||||
if isinstance(user_prompt, str):
|
||||
terms |= self._significant_words(user_prompt)
|
||||
|
||||
return terms
|
||||
|
||||
@classmethod
|
||||
def _significant_words(cls, text: str) -> Set[str]:
|
||||
"""
|
||||
Extracts the discriminative words of a piece of text.
|
||||
|
||||
Args:
|
||||
text (str): The text to tokenize.
|
||||
|
||||
Returns:
|
||||
Set[str]: Lowercase words at least three characters long that are not
|
||||
generic scraping vocabulary.
|
||||
"""
|
||||
words = cls.word_pattern.findall(cls.camel_case_pattern.sub(" ", text))
|
||||
return {
|
||||
word.lower() for word in words if word.lower() not in cls.prompt_stopwords
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _schema_field_terms(cls, schema, _depth: int = 0) -> Set[str]:
|
||||
"""
|
||||
Extracts the field names of an output schema, recursing into nested ones.
|
||||
|
||||
Supports the schema flavours the library accepts: Pydantic models, plain
|
||||
JSON Schema dictionaries, and lists of either.
|
||||
|
||||
Args:
|
||||
schema: The output schema, in any of the supported forms; may be None.
|
||||
_depth (int): Internal recursion guard for deeply nested schemas.
|
||||
|
||||
Returns:
|
||||
Set[str]: Lowercase terms derived from the field names, with
|
||||
``snake_case`` and ``camelCase`` names split into their parts.
|
||||
"""
|
||||
if schema is None or _depth > 5:
|
||||
return set()
|
||||
|
||||
names: List[str] = []
|
||||
terms: Set[str] = set()
|
||||
|
||||
if isinstance(schema, dict):
|
||||
properties = schema.get("properties")
|
||||
if isinstance(properties, dict):
|
||||
names.extend(str(key) for key in properties)
|
||||
for value in properties.values():
|
||||
terms |= cls._schema_field_terms(value, _depth + 1)
|
||||
items = schema.get("items")
|
||||
if items is not None:
|
||||
terms |= cls._schema_field_terms(items, _depth + 1)
|
||||
elif isinstance(schema, (list, tuple, set)):
|
||||
for entry in schema:
|
||||
terms |= cls._schema_field_terms(entry, _depth + 1)
|
||||
else:
|
||||
model_fields = getattr(schema, "model_fields", None) # pydantic v2
|
||||
if model_fields is None:
|
||||
model_fields = getattr(schema, "__fields__", None) # pydantic v1
|
||||
if isinstance(model_fields, dict):
|
||||
names.extend(str(key) for key in model_fields)
|
||||
for field in model_fields.values():
|
||||
annotation = getattr(field, "annotation", None)
|
||||
for nested in cls._nested_models(annotation):
|
||||
terms |= cls._schema_field_terms(nested, _depth + 1)
|
||||
|
||||
for name in names:
|
||||
terms |= cls._significant_words(name.replace("_", " "))
|
||||
|
||||
return terms
|
||||
|
||||
@staticmethod
|
||||
def _nested_models(annotation) -> List:
|
||||
"""
|
||||
Finds the Pydantic models reachable from a field annotation.
|
||||
|
||||
Unwraps the typing containers schemas commonly use — ``List[Item]``,
|
||||
``Optional[Item]``, ``Dict[str, Item]`` — so nested field names are not
|
||||
lost.
|
||||
|
||||
Args:
|
||||
annotation: The annotation of a Pydantic field; may be None.
|
||||
|
||||
Returns:
|
||||
List: The Pydantic model classes found in the annotation.
|
||||
"""
|
||||
if annotation is None:
|
||||
return []
|
||||
|
||||
if hasattr(annotation, "model_fields") or hasattr(annotation, "__fields__"):
|
||||
return [annotation]
|
||||
|
||||
return [
|
||||
arg
|
||||
for arg in get_args(annotation)
|
||||
if hasattr(arg, "model_fields") or hasattr(arg, "__fields__")
|
||||
]
|
||||
|
||||
def _extract_urls(self, text: str, source: str) -> Tuple[List[str], List[str]]:
|
||||
"""
|
||||
Extracts URLs from the given text.
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Tests for the silent-error-page guards added for issue #1102.
|
||||
|
||||
Two independent signals are covered:
|
||||
|
||||
1. ``ChromiumLoader`` warns when ``page.goto()`` reports an HTTP error status,
|
||||
so a 404/403/500 page is no longer handed to the LLM as if it were the
|
||||
intended document.
|
||||
2. ``ParseNode`` warns when the parsed content contains no trace of what the
|
||||
user asked for, which also catches 200 pages whose content never rendered.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import List
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from langchain_core.documents import Document
|
||||
from pydantic import BaseModel
|
||||
|
||||
from scrapegraphai.docloaders.chromium import ChromiumLoader, _warn_on_error_status
|
||||
from scrapegraphai.nodes import ParseNode
|
||||
from scrapegraphai.utils.logging import set_propagation, unset_propagation
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ChromiumLoader: HTTP status awareness on every page.goto() call site
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _MockPage:
|
||||
def __init__(self, status):
|
||||
response = MagicMock()
|
||||
response.status = status
|
||||
self.goto = AsyncMock(return_value=response)
|
||||
self.wait_for_load_state = AsyncMock()
|
||||
self.content = AsyncMock(return_value="<html>error page</html>")
|
||||
self.evaluate = AsyncMock(return_value=1000)
|
||||
self.mouse = MagicMock()
|
||||
self.mouse.wheel = AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def playwright_with_status():
|
||||
"""Patch playwright so page.goto() returns a response with a given status."""
|
||||
|
||||
def _factory(status):
|
||||
page = _MockPage(status)
|
||||
context = MagicMock()
|
||||
context.new_page = AsyncMock(return_value=page)
|
||||
browser = MagicMock()
|
||||
browser.new_context = AsyncMock(return_value=context)
|
||||
browser.close = AsyncMock()
|
||||
pw = MagicMock()
|
||||
pw.chromium.launch = AsyncMock(return_value=browser)
|
||||
pw.firefox.launch = AsyncMock(return_value=browser)
|
||||
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=pw)
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
return cm, page
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [404, 403, 500, 503])
|
||||
def test_ascrape_playwright_warns_on_error_status(
|
||||
playwright_with_status, status, caplog
|
||||
):
|
||||
cm, _ = playwright_with_status(status)
|
||||
loader = ChromiumLoader(["https://example.com/missing"], backend="playwright")
|
||||
|
||||
with (
|
||||
patch("playwright.async_api.async_playwright", return_value=cm),
|
||||
patch("undetected_playwright.Malenia.apply_stealth", new=AsyncMock()),
|
||||
caplog.at_level("WARNING"),
|
||||
):
|
||||
asyncio.run(loader.ascrape_playwright("https://example.com/missing"))
|
||||
|
||||
assert f"Received HTTP {status}" in caplog.text
|
||||
assert "likely an error page" in caplog.text
|
||||
|
||||
|
||||
def test_ascrape_playwright_silent_on_success(playwright_with_status, caplog):
|
||||
cm, _ = playwright_with_status(200)
|
||||
loader = ChromiumLoader(["https://example.com"], backend="playwright")
|
||||
|
||||
with (
|
||||
patch("playwright.async_api.async_playwright", return_value=cm),
|
||||
patch("undetected_playwright.Malenia.apply_stealth", new=AsyncMock()),
|
||||
caplog.at_level("WARNING"),
|
||||
):
|
||||
asyncio.run(loader.ascrape_playwright("https://example.com"))
|
||||
|
||||
assert "Received HTTP" not in caplog.text
|
||||
|
||||
|
||||
def test_ascrape_with_js_support_warns_on_error_status(playwright_with_status, caplog):
|
||||
cm, _ = playwright_with_status(404)
|
||||
loader = ChromiumLoader(
|
||||
["https://example.com/missing"], backend="playwright", requires_js_support=True
|
||||
)
|
||||
|
||||
with (
|
||||
patch("playwright.async_api.async_playwright", return_value=cm),
|
||||
caplog.at_level("WARNING"),
|
||||
):
|
||||
asyncio.run(loader.ascrape_with_js_support("https://example.com/missing"))
|
||||
|
||||
assert "Received HTTP 404" in caplog.text
|
||||
|
||||
|
||||
def test_ascrape_playwright_scroll_warns_on_error_status(
|
||||
playwright_with_status, caplog
|
||||
):
|
||||
cm, page = playwright_with_status(404)
|
||||
# Stop the scroll loop immediately: same height twice means "bottom reached".
|
||||
page.evaluate = AsyncMock(return_value=1000)
|
||||
loader = ChromiumLoader(["https://example.com/missing"], backend="playwright")
|
||||
|
||||
with (
|
||||
patch("playwright.async_api.async_playwright", return_value=cm),
|
||||
patch("undetected_playwright.Malenia.apply_stealth", new=AsyncMock()),
|
||||
caplog.at_level("WARNING"),
|
||||
):
|
||||
asyncio.run(
|
||||
loader.ascrape_playwright_scroll(
|
||||
"https://example.com/missing", scroll=5000, sleep=0.01, timeout=1
|
||||
)
|
||||
)
|
||||
|
||||
assert "Received HTTP 404" in caplog.text
|
||||
|
||||
|
||||
def test_warn_on_error_status_tolerates_missing_response(caplog):
|
||||
"""page.goto() returns None for same-document navigations; that is not an error."""
|
||||
with caplog.at_level("WARNING"):
|
||||
_warn_on_error_status(None, "https://example.com")
|
||||
_warn_on_error_status(MagicMock(status=None), "https://example.com")
|
||||
|
||||
assert "Received HTTP" not in caplog.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ParseNode: warn when the parsed content holds no trace of the request
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library_logs_propagate():
|
||||
"""Let caplog see records from the library root logger.
|
||||
|
||||
``scrapegraphai`` disables propagation by default so it does not pollute the
|
||||
host application's logging; the nodes log through that root logger.
|
||||
"""
|
||||
set_propagation()
|
||||
yield
|
||||
unset_propagation()
|
||||
|
||||
|
||||
class Company(BaseModel):
|
||||
company_name: str
|
||||
foundingYear: int
|
||||
|
||||
|
||||
class Employee(BaseModel):
|
||||
employee_name: str
|
||||
salary: str
|
||||
|
||||
|
||||
class Payroll(BaseModel):
|
||||
employees: List[Employee]
|
||||
|
||||
|
||||
def _parse_node(**node_config):
|
||||
config = {"chunk_size": 4096, "verbose": False}
|
||||
config.update(node_config)
|
||||
return ParseNode(input="doc", output=["parsed_doc"], node_config=config)
|
||||
|
||||
|
||||
def _run(node, html, user_prompt):
|
||||
state = {"doc": [Document(page_content=html)], "user_prompt": user_prompt}
|
||||
return node.execute(state)
|
||||
|
||||
|
||||
WIKIPEDIA_404 = (
|
||||
"<html><body><p>Jump to content. Main menu. Navigation. "
|
||||
"Wikipedia does not have an article with this exact name.</p></body></html>"
|
||||
)
|
||||
|
||||
TIMPSON_PAGE = (
|
||||
"<html><body><p>Timpson is a British retailer founded in 1865 "
|
||||
"by William Timpson.</p></body></html>"
|
||||
)
|
||||
|
||||
# A shell page whose real content is rendered client-side: HTTP 200, no error,
|
||||
# and nothing for the LLM to work with.
|
||||
JS_SHELL_PAGE = "<html><body><div id='root'>Loading...</div></body></html>"
|
||||
|
||||
STRUCTURED_PAGE = (
|
||||
"<html><body><p>Company name: Timpson</p>"
|
||||
"<p>Founding year: 1865</p></body></html>"
|
||||
)
|
||||
|
||||
|
||||
def test_warns_when_no_requested_term_is_present(library_logs_propagate, caplog):
|
||||
node = _parse_node()
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_run(node, WIKIPEDIA_404, "What is the founding year of Timpson?")
|
||||
|
||||
assert "None of the requested terms" in caplog.text
|
||||
|
||||
|
||||
def test_silent_when_the_content_holds_the_answer(library_logs_propagate, caplog):
|
||||
node = _parse_node()
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_run(node, TIMPSON_PAGE, "What is the founding year of Timpson?")
|
||||
|
||||
assert "None of the requested terms" not in caplog.text
|
||||
|
||||
|
||||
def test_schema_field_names_count_as_requested_terms(library_logs_propagate, caplog):
|
||||
"""A page that never rendered is a 200, so only the schema can flag it."""
|
||||
node = _parse_node(schema=Company)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_run(node, JS_SHELL_PAGE, None)
|
||||
|
||||
assert "None of the requested terms" in caplog.text
|
||||
# snake_case and camelCase names are split into their parts
|
||||
assert "founding" in caplog.text
|
||||
assert "company" in caplog.text
|
||||
assert "year" in caplog.text
|
||||
|
||||
|
||||
def test_schema_match_keeps_the_check_quiet(library_logs_propagate, caplog):
|
||||
node = _parse_node(schema=Company)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_run(node, STRUCTURED_PAGE, None)
|
||||
|
||||
assert "None of the requested terms" not in caplog.text
|
||||
|
||||
|
||||
def test_schema_terms_can_rescue_an_unspecific_prompt(library_logs_propagate, caplog):
|
||||
"""The union of prompt and schema terms only ever makes the check quieter."""
|
||||
node = _parse_node(schema=Company)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_run(node, STRUCTURED_PAGE, "Extract everything you can find")
|
||||
|
||||
assert "None of the requested terms" not in caplog.text
|
||||
|
||||
|
||||
def test_nested_pydantic_models_are_unwrapped(library_logs_propagate, caplog):
|
||||
"""List[Item] and friends must not hide the nested field names."""
|
||||
node = _parse_node(schema=Payroll)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_run(node, JS_SHELL_PAGE, None)
|
||||
|
||||
assert "salary" in caplog.text
|
||||
assert "employee" in caplog.text
|
||||
|
||||
|
||||
def test_json_schema_dict_is_supported(library_logs_propagate, caplog):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"founding_year": {"type": "integer"},
|
||||
"locations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"postcode": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
node = _parse_node(schema=schema)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_run(node, JS_SHELL_PAGE, None)
|
||||
|
||||
assert "postcode" in caplog.text
|
||||
assert "locations" in caplog.text
|
||||
|
||||
|
||||
def test_no_prompt_and_no_schema_produces_no_warning(library_logs_propagate, caplog):
|
||||
node = _parse_node()
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_run(node, WIKIPEDIA_404, None)
|
||||
|
||||
assert "None of the requested terms" not in caplog.text
|
||||
|
||||
|
||||
def test_generic_prompt_words_alone_do_not_trigger_the_warning(
|
||||
library_logs_propagate, caplog
|
||||
):
|
||||
"""A prompt made only of scraping vocabulary carries no signal to check."""
|
||||
node = _parse_node()
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_run(node, WIKIPEDIA_404, "Extract all the information from this webpage")
|
||||
|
||||
assert "None of the requested terms" not in caplog.text
|
||||
|
||||
|
||||
def test_empty_parsed_content_is_reported(library_logs_propagate, caplog):
|
||||
node = _parse_node()
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_run(node, "", "What is the founding year of Timpson?")
|
||||
|
||||
assert "parsed content is empty" in caplog.text
|
||||
|
||||
|
||||
def test_state_is_unchanged_by_the_guard():
|
||||
"""The guard only logs; the parsed chunks must reach the state as before."""
|
||||
node = _parse_node(schema=Company)
|
||||
state = _run(node, TIMPSON_PAGE, "What is the founding year of Timpson?")
|
||||
|
||||
assert state["parsed_doc"]
|
||||
assert "1865" in "".join(state["parsed_doc"])
|
||||
Reference in New Issue
Block a user