langchain-webextract 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ dist/
3
+ dist-local/
4
+ .venv/
5
+ __pycache__/
6
+ *.egg-info/
7
+ .env
8
+ .env.*
9
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TAINCHENG, L.L.C.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-webextract
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for WebExtract: extract_url (Markdown + product/price/tables JSON) and page_markdown. Pay per call with x402, prepaid key, or free tier.
5
+ Project-URL: Homepage, https://webextract.dev
6
+ Project-URL: Documentation, https://webextract.dev/docs
7
+ Project-URL: Source, https://github.com/nyx464664816-stack/webextract
8
+ Project-URL: Issues, https://github.com/nyx464664816-stack/webextract/issues
9
+ Author: TAINCHENG, L.L.C.
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: langchain,markdown,tools,web-scraping,webextract,x402
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: langchain-core<2,>=0.3
19
+ Requires-Dist: webextract-sdk<0.2,>=0.1.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # langchain-webextract
23
+
24
+ LangChain tools for [WebExtract](https://webextract.dev). The tool names and outputs match the WebExtract MCP tools:
25
+
26
+ - `extract_url`: clean Markdown plus fixed-schema JSON (metadata, product and price with `price_detail`, tables). $0.005 or 1 credit.
27
+ - `page_markdown`: clean Markdown only. $0.002 or 0.4 credit.
28
+
29
+ ```bash
30
+ pip install langchain-webextract
31
+ ```
32
+
33
+ This also installs the core client from PyPI `webextract-sdk` (imported as `webextract`). For x402 pay per call add `pip install "webextract-sdk[x402]"`.
34
+
35
+ ```python
36
+ from langchain_webextract import WebExtractExtractUrl, WebExtractPageMarkdown, get_webextract_tools
37
+ from webextract import WebExtract
38
+
39
+ tools = get_webextract_tools() # free tier, or WEBEXTRACT_API_KEY if set
40
+ tools = get_webextract_tools(api_key="lx_...") # prepaid credits (SecretStr field)
41
+ tools = get_webextract_tools(client=WebExtract(api_key="lx_...")) # prepaid credits
42
+ print(WebExtractPageMarkdown().invoke({"url": "https://example.com"}))
43
+ ```
44
+
45
+ **x402 pay per call (async).** Build the paying client with the official x402 SDK, then hand it over. This package never sees the private key.
46
+
47
+ ```python
48
+ from webextract import AsyncWebExtract
49
+ tool = WebExtractExtractUrl(async_client=AsyncWebExtract(payment_client=x402_httpx_client))
50
+ await tool.ainvoke({"url": "https://web-scraping.dev/product/1"})
51
+ ```
52
+
53
+ The tools return a JSON string. By default, API errors come back to the model as `{"ok": false, "error": {"error", "status", "message", "charged", "reason", "hint", ...}}`, so an agent can react to `free_limit_reached`, `robots_disallowed` and so on. Set `on_error="raise"` to raise `webextract.WebExtractError` subclasses instead.
54
+
55
+ ## Tests
56
+
57
+ ```bash
58
+ pytest --disable-socket --allow-unix-socket tests/unit_tests # mocks + langchain-tests ToolsUnitTests
59
+ pytest --disable-socket --allow-unix-socket tests/integration_tests # ToolsIntegrationTests, offline (mocked API)
60
+ WEBEXTRACT_SANDBOX_TESTS=1 X402_TESTNET_ENV_FILE=/path/to/testnet.env \
61
+ pytest tests/integration_tests/test_standard_sandbox.py # live sandbox, Base Sepolia TEST USDC only
62
+ ```
63
+
64
+ License: MIT © TAINCHENG, L.L.C.
@@ -0,0 +1,43 @@
1
+ # langchain-webextract
2
+
3
+ LangChain tools for [WebExtract](https://webextract.dev). The tool names and outputs match the WebExtract MCP tools:
4
+
5
+ - `extract_url`: clean Markdown plus fixed-schema JSON (metadata, product and price with `price_detail`, tables). $0.005 or 1 credit.
6
+ - `page_markdown`: clean Markdown only. $0.002 or 0.4 credit.
7
+
8
+ ```bash
9
+ pip install langchain-webextract
10
+ ```
11
+
12
+ This also installs the core client from PyPI `webextract-sdk` (imported as `webextract`). For x402 pay per call add `pip install "webextract-sdk[x402]"`.
13
+
14
+ ```python
15
+ from langchain_webextract import WebExtractExtractUrl, WebExtractPageMarkdown, get_webextract_tools
16
+ from webextract import WebExtract
17
+
18
+ tools = get_webextract_tools() # free tier, or WEBEXTRACT_API_KEY if set
19
+ tools = get_webextract_tools(api_key="lx_...") # prepaid credits (SecretStr field)
20
+ tools = get_webextract_tools(client=WebExtract(api_key="lx_...")) # prepaid credits
21
+ print(WebExtractPageMarkdown().invoke({"url": "https://example.com"}))
22
+ ```
23
+
24
+ **x402 pay per call (async).** Build the paying client with the official x402 SDK, then hand it over. This package never sees the private key.
25
+
26
+ ```python
27
+ from webextract import AsyncWebExtract
28
+ tool = WebExtractExtractUrl(async_client=AsyncWebExtract(payment_client=x402_httpx_client))
29
+ await tool.ainvoke({"url": "https://web-scraping.dev/product/1"})
30
+ ```
31
+
32
+ The tools return a JSON string. By default, API errors come back to the model as `{"ok": false, "error": {"error", "status", "message", "charged", "reason", "hint", ...}}`, so an agent can react to `free_limit_reached`, `robots_disallowed` and so on. Set `on_error="raise"` to raise `webextract.WebExtractError` subclasses instead.
33
+
34
+ ## Tests
35
+
36
+ ```bash
37
+ pytest --disable-socket --allow-unix-socket tests/unit_tests # mocks + langchain-tests ToolsUnitTests
38
+ pytest --disable-socket --allow-unix-socket tests/integration_tests # ToolsIntegrationTests, offline (mocked API)
39
+ WEBEXTRACT_SANDBOX_TESTS=1 X402_TESTNET_ENV_FILE=/path/to/testnet.env \
40
+ pytest tests/integration_tests/test_standard_sandbox.py # live sandbox, Base Sepolia TEST USDC only
41
+ ```
42
+
43
+ License: MIT © TAINCHENG, L.L.C.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-webextract"
7
+ version = "0.1.0"
8
+ description = "LangChain tools for WebExtract: extract_url (Markdown + product/price/tables JSON) and page_markdown. Pay per call with x402, prepaid key, or free tier."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "TAINCHENG, L.L.C." }]
14
+ keywords = ["langchain", "tools", "webextract", "web-scraping", "markdown", "x402"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Internet :: WWW/HTTP",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = ["webextract-sdk>=0.1.0,<0.2", "langchain-core>=0.3,<2"]
22
+
23
+
24
+ [project.urls]
25
+ Homepage = "https://webextract.dev"
26
+ Documentation = "https://webextract.dev/docs"
27
+ Source = "https://github.com/nyx464664816-stack/webextract"
28
+ Issues = "https://github.com/nyx464664816-stack/webextract/issues"
29
+
30
+ [tool.hatch.build.targets.sdist]
31
+ include = ["src/langchain_webextract", "tests/**/*.py", "README.md", "LICENSE", "pyproject.toml"]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/langchain_webextract"]
35
+
36
+ [tool.pytest.ini_options]
37
+ asyncio_mode = "auto"
38
+ testpaths = ["tests"]
39
+
40
+ [dependency-groups]
41
+ test = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-socket>=0.7", "langchain-tests>=1.1,<2"]
42
+ test_integration = ["x402[evm,httpx,requests]>=2.25,<3"]
@@ -0,0 +1,8 @@
1
+ """LangChain tools for WebExtract (https://webextract.dev)."""
2
+ from webextract import AsyncWebExtract, WebExtract, WebExtractError
3
+
4
+ from .tools import WebExtractExtractUrl, WebExtractInput, WebExtractPageMarkdown, get_webextract_tools
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["WebExtractExtractUrl", "WebExtractPageMarkdown", "WebExtractInput", "get_webextract_tools",
8
+ "WebExtract", "AsyncWebExtract", "WebExtractError", "__version__"]
@@ -0,0 +1,97 @@
1
+ """LangChain tools for WebExtract. Tool names and outputs match the MCP tools (extract_url, page_markdown)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any, Dict, List, Literal, Optional, Type
6
+
7
+ from langchain_core.callbacks import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
8
+ from langchain_core.tools import BaseTool
9
+ from langchain_core.utils import secret_from_env
10
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, SecretStr, field_validator
11
+
12
+ from webextract import TOOL_DESCRIPTIONS, AsyncWebExtract, WebExtract, WebExtractError
13
+
14
+
15
+ class WebExtractInput(BaseModel):
16
+ """Input for the WebExtract tools."""
17
+
18
+ url: str = Field(description=TOOL_DESCRIPTIONS["url"])
19
+
20
+ @field_validator("url")
21
+ @classmethod
22
+ def _http_url(cls, v: str) -> str:
23
+ v = v.strip()
24
+ if not (v.startswith("http://") or v.startswith("https://")):
25
+ raise ValueError("url must be an absolute http(s) URL")
26
+ return v
27
+
28
+
29
+ class _WebExtractBaseTool(BaseTool):
30
+ """Shared plumbing. Pass ``client`` (sync) and/or ``async_client``; with neither, a free-tier sync client
31
+ is created (X-Billing: free, 50 calls/day per IP). For x402 pay-per-call pass
32
+ ``async_client=AsyncWebExtract(payment_client=x402HttpxClient(...))``."""
33
+
34
+ model_config = ConfigDict(arbitrary_types_allowed=True)
35
+ args_schema: Type[BaseModel] = WebExtractInput
36
+ client: Optional[WebExtract] = None
37
+ async_client: Optional[AsyncWebExtract] = None
38
+ api_key: Optional[SecretStr] = Field(default_factory=secret_from_env("WEBEXTRACT_API_KEY", default=None))
39
+ """Prepaid key (Stripe credits). Defaults to the WEBEXTRACT_API_KEY env var. Only used when no client is given."""
40
+ billing: Optional[Literal["key", "free", "x402", "x402-testnet"]] = None
41
+ """Billing for the built-in client (default: key if api_key is set, else free). x402 needs a client with payment_client."""
42
+ base_url: Optional[str] = None
43
+ on_error: Literal["return", "raise"] = "return"
44
+ """"return": API errors (402 reason, failed fetch, bad url) come back to the model as a JSON string
45
+ {"ok": false, "error": {...}}. "raise": re-raise the WebExtractError."""
46
+ _endpoint: str = PrivateAttr(default="")
47
+
48
+ def _sync(self) -> WebExtract:
49
+ if self.client is None:
50
+ if self.async_client is not None:
51
+ raise RuntimeError("only async_client is configured; call the tool with ainvoke()")
52
+ use_key = self.api_key is not None and self.billing in (None, "key")
53
+ self.client = WebExtract(api_key=self.api_key.get_secret_value() if use_key else None,
54
+ billing=self.billing or ("key" if use_key else "free"), base_url=self.base_url)
55
+ return self.client
56
+
57
+ def _fail(self, e: WebExtractError) -> str:
58
+ if self.on_error == "raise":
59
+ raise e
60
+ return json.dumps({"ok": False, "error": e.to_dict()})
61
+
62
+ def _run(self, url: str, run_manager: Optional[CallbackManagerForToolRun] = None) -> str:
63
+ try:
64
+ r = getattr(self._sync(), self._endpoint)(url)
65
+ except WebExtractError as e:
66
+ return self._fail(e)
67
+ return json.dumps(r.data, ensure_ascii=False)
68
+
69
+ async def _arun(self, url: str, run_manager: Optional[AsyncCallbackManagerForToolRun] = None) -> str:
70
+ if self.async_client is None:
71
+ return await super()._arun(url, run_manager=run_manager) # runs _run in a thread
72
+ try:
73
+ r = await getattr(self.async_client, self._endpoint)(url)
74
+ except WebExtractError as e:
75
+ return self._fail(e)
76
+ return json.dumps(r.data, ensure_ascii=False)
77
+
78
+
79
+ class WebExtractExtractUrl(_WebExtractBaseTool):
80
+ """Markdown + fixed-schema JSON (metadata, product/price incl. price_detail, tables) for a URL. $0.005 or 1 credit."""
81
+
82
+ name: str = "extract_url"
83
+ description: str = TOOL_DESCRIPTIONS["extract_url"]
84
+ _endpoint: str = PrivateAttr(default="extract")
85
+
86
+
87
+ class WebExtractPageMarkdown(_WebExtractBaseTool):
88
+ """Clean Markdown for a URL. $0.002 or 0.4 credit."""
89
+
90
+ name: str = "page_markdown"
91
+ description: str = TOOL_DESCRIPTIONS["page_markdown"]
92
+ _endpoint: str = PrivateAttr(default="markdown")
93
+
94
+
95
+ def get_webextract_tools(**kwargs: Any) -> List[BaseTool]:
96
+ """Both tools sharing the same clients/options, e.g. ``get_webextract_tools(client=WebExtract(api_key=...))``."""
97
+ return [WebExtractExtractUrl(**kwargs), WebExtractPageMarkdown(**kwargs)]
File without changes
@@ -0,0 +1,39 @@
1
+ """Canned API responses (shapes copied from the live API, schema_version 1.0 + 2026-10-21 additive fields)."""
2
+ import json
3
+
4
+ import httpx
5
+
6
+ from webextract import AsyncWebExtract, WebExtract
7
+
8
+ EXTRACT = {
9
+ "url": "https://web-scraping.dev/product/1", "final_url": "https://web-scraping.dev/product/1",
10
+ "fetched_at": "2026-10-21T11:00:00.000Z", "schema_version": "1.0", "content_source": "raw_html",
11
+ "markdown": "# Box of Chocolate Candy\n\n...", "markdown_truncated": False, "word_count": 120,
12
+ "metadata": {"title": "Box of Chocolate Candy", "description": None, "authors": [], "published_at": None, "modified_at": None,
13
+ "site_name": None, "language": "en", "canonical_url": None, "image": None, "type": "product"},
14
+ "product": {"name": "Box of Chocolate Candy", "brand": None, "sku": None, "gtin": None, "description": None, "image": None,
15
+ "price": 9.99, "price_high": None, "currency": "USD", "availability": "InStock", "rating": None, "source": "json-ld",
16
+ "price_detail": {"status": "resolved", "currency": "USD", "currency_basis": "declared",
17
+ "prices": [{"type": "sale", "amount": 9.99, "currency": "USD", "method": "dom"},
18
+ {"type": "list", "amount": 12.99, "currency": "USD", "method": "dom"}],
19
+ "unresolved": None, "warnings": []}},
20
+ "tables": [],
21
+ }
22
+ MARKDOWN = {"url": "https://example.com", "final_url": "https://example.com/", "fetched_at": "2026-10-21T11:00:00.000Z",
23
+ "content_source": "raw_html", "title": "Example Domain", "markdown": "# Example Domain\n\n...", "markdown_truncated": False,
24
+ "word_count": 25}
25
+ FREE_LIMIT = {"error": "payment_required", "reason": "free_limit_reached", "message": "Free daily limit reached"}
26
+
27
+
28
+ def _handler(status=200, body=None):
29
+ def h(req: httpx.Request) -> httpx.Response:
30
+ payload = body if body is not None else (MARKDOWN if req.url.path.endswith("/markdown") else EXTRACT)
31
+ headers = {"x-payment-reason": payload["reason"]} if status == 402 else {}
32
+ return httpx.Response(status, content=json.dumps(payload), headers={"content-type": "application/json", **headers})
33
+ return h
34
+
35
+
36
+ def mock_clients(status=200, body=None):
37
+ t = _handler(status, body)
38
+ return {"client": WebExtract(http_client=httpx.Client(transport=httpx.MockTransport(t))),
39
+ "async_client": AsyncWebExtract(http_client=httpx.AsyncClient(transport=httpx.MockTransport(t)))}
@@ -0,0 +1,51 @@
1
+ """LangChain standard integration tests (ToolsIntegrationTests) run OFFLINE: the WebExtract HTTP API is
2
+ replaced by httpx.MockTransport with real response shapes. Includes an error variant (402 free_limit_reached)
3
+ to prove the tool never raises on API errors by default."""
4
+ from typing import Any, Dict, Type
5
+
6
+ from langchain_tests.integration_tests import ToolsIntegrationTests
7
+
8
+ from langchain_webextract import WebExtractExtractUrl, WebExtractPageMarkdown
9
+ from tests._fixtures import FREE_LIMIT, mock_clients
10
+
11
+
12
+ class TestExtractUrlOffline(ToolsIntegrationTests):
13
+ @property
14
+ def tool_constructor(self) -> Type[WebExtractExtractUrl]:
15
+ return WebExtractExtractUrl
16
+
17
+ @property
18
+ def tool_constructor_params(self) -> Dict[str, Any]:
19
+ return mock_clients()
20
+
21
+ @property
22
+ def tool_invoke_params_example(self) -> Dict[str, Any]:
23
+ return {"url": "https://web-scraping.dev/product/1"}
24
+
25
+
26
+ class TestPageMarkdownOffline(ToolsIntegrationTests):
27
+ @property
28
+ def tool_constructor(self) -> Type[WebExtractPageMarkdown]:
29
+ return WebExtractPageMarkdown
30
+
31
+ @property
32
+ def tool_constructor_params(self) -> Dict[str, Any]:
33
+ return mock_clients()
34
+
35
+ @property
36
+ def tool_invoke_params_example(self) -> Dict[str, Any]:
37
+ return {"url": "https://example.com"}
38
+
39
+
40
+ class TestExtractUrlOffline402(ToolsIntegrationTests):
41
+ @property
42
+ def tool_constructor(self) -> Type[WebExtractExtractUrl]:
43
+ return WebExtractExtractUrl
44
+
45
+ @property
46
+ def tool_constructor_params(self) -> Dict[str, Any]:
47
+ return mock_clients(402, FREE_LIMIT)
48
+
49
+ @property
50
+ def tool_invoke_params_example(self) -> Dict[str, Any]:
51
+ return {"url": "https://web-scraping.dev/product/1"}
@@ -0,0 +1,87 @@
1
+ """LangChain standard integration tests against the LIVE WebExtract SANDBOX (Base Sepolia test USDC only).
2
+
3
+ Opt-in: set WEBEXTRACT_SANDBOX_TESTS=1 and X402_TESTNET_ENV_FILE=<path to a mode-600 .env with
4
+ X402_TESTNET_PRIVATE_KEY>. Only eip155:84532 is registered with the x402 SDK and billing is x402-testnet,
5
+ so nothing can be signed for a mainnet requirement. The key is never printed. Each run makes 4 test payments
6
+ per tool (sync + async, with and without ToolCall)."""
7
+ import json
8
+ import os
9
+ import stat
10
+ import time
11
+ from typing import Any, Dict, Type
12
+
13
+ import pytest
14
+ from langchain_core.messages import ToolCall
15
+ from langchain_core.tools import BaseTool
16
+ from langchain_tests.integration_tests import ToolsIntegrationTests
17
+
18
+ from langchain_webextract import WebExtractExtractUrl, WebExtractPageMarkdown
19
+ from webextract import AsyncWebExtract, WebExtract
20
+
21
+ NET = "eip155:84532"
22
+ pytestmark = pytest.mark.skipif(os.environ.get("WEBEXTRACT_SANDBOX_TESTS") != "1", reason="set WEBEXTRACT_SANDBOX_TESTS=1 to run live sandbox tests")
23
+
24
+
25
+ def _key() -> str:
26
+ path = os.environ["X402_TESTNET_ENV_FILE"]
27
+ if os.stat(path).st_mode & (stat.S_IRWXG | stat.S_IRWXO):
28
+ raise RuntimeError("testnet .env must be mode 600")
29
+ with open(path) as f:
30
+ return next(l.split("=", 1)[1].strip() for l in f if l.startswith("X402_TESTNET_PRIVATE_KEY="))
31
+
32
+
33
+ def sandbox_clients() -> Dict[str, Any]:
34
+ from eth_account import Account
35
+ from x402 import x402Client, x402ClientSync
36
+ from x402.http.clients import x402_requests, x402HttpxClient
37
+ from x402.mechanisms.evm.exact import ExactEvmScheme
38
+
39
+ acct = Account.from_key(_key())
40
+ sync_x, async_x = x402ClientSync(), x402Client()
41
+ sync_x.register(NET, ExactEvmScheme(signer=acct))
42
+ async_x.register(NET, ExactEvmScheme(signer=acct))
43
+ return {"client": WebExtract(payment_client=x402_requests(sync_x), billing="x402-testnet", timeout=60),
44
+ "async_client": AsyncWebExtract(payment_client=x402HttpxClient(async_x, timeout=60), billing="x402-testnet", timeout=60)}
45
+
46
+
47
+ @pytest.fixture(autouse=True)
48
+ def _pace():
49
+ """The sandbox allows 20 requests/minute per IP and every x402 call is 2 HTTP requests
50
+ (unpaid 402, then the signed retry), so stay at <= ~9 paid calls per minute."""
51
+ yield
52
+ time.sleep(float(os.environ.get("WEBEXTRACT_SANDBOX_PACE_S", "7")))
53
+
54
+
55
+ class _Live(ToolsIntegrationTests):
56
+ @property
57
+ def tool_constructor_params(self) -> Dict[str, Any]:
58
+ # on_error="raise": any 402/422/429 from the sandbox fails the standard tests instead of being
59
+ # returned to the "model" as an error object, so a green run means every call was paid and served.
60
+ return {**sandbox_clients(), "on_error": "raise"}
61
+
62
+ def test_live_result_is_success(self, tool: BaseTool) -> None:
63
+ """Extra (not in the standard suite): the live sandbox call must succeed, not come back as an error object."""
64
+ msg = tool.invoke(ToolCall(name=tool.name, args=self.tool_invoke_params_example, id="1", type="tool_call"))
65
+ out = json.loads(msg.content)
66
+ assert out.get("ok") is not False, out
67
+ assert out["markdown"] and out.get("content_source") in (None, "raw_html")
68
+
69
+
70
+ class TestPageMarkdownSandbox(_Live):
71
+ @property
72
+ def tool_constructor(self) -> Type[WebExtractPageMarkdown]:
73
+ return WebExtractPageMarkdown
74
+
75
+ @property
76
+ def tool_invoke_params_example(self) -> Dict[str, Any]:
77
+ return {"url": "https://example.com"}
78
+
79
+
80
+ class TestExtractUrlSandbox(_Live):
81
+ @property
82
+ def tool_constructor(self) -> Type[WebExtractExtractUrl]:
83
+ return WebExtractExtractUrl
84
+
85
+ @property
86
+ def tool_invoke_params_example(self) -> Dict[str, Any]:
87
+ return {"url": "https://web-scraping.dev/product/1"}
@@ -0,0 +1,41 @@
1
+ """LangChain standard unit tests (langchain-tests ToolsUnitTests). No network."""
2
+ from typing import Any, Dict, Tuple, Type
3
+
4
+ from langchain_tests.unit_tests import ToolsUnitTests
5
+
6
+ from langchain_webextract import WebExtractExtractUrl, WebExtractPageMarkdown
7
+
8
+ EXAMPLE = {"url": "https://web-scraping.dev/product/1"}
9
+ ENV = ({"WEBEXTRACT_API_KEY": "lx_test_env_key"}, {}, {"api_key": "lx_test_env_key"})
10
+
11
+
12
+ class TestExtractUrlStandard(ToolsUnitTests):
13
+ @property
14
+ def tool_constructor(self) -> Type[WebExtractExtractUrl]:
15
+ return WebExtractExtractUrl
16
+
17
+ @property
18
+ def tool_constructor_params(self) -> Dict[str, Any]:
19
+ return {}
20
+
21
+ @property
22
+ def tool_invoke_params_example(self) -> Dict[str, Any]:
23
+ return EXAMPLE
24
+
25
+ @property
26
+ def init_from_env_params(self) -> Tuple[Dict[str, str], Dict[str, Any], Dict[str, Any]]:
27
+ return ENV
28
+
29
+
30
+ class TestPageMarkdownStandard(ToolsUnitTests):
31
+ @property
32
+ def tool_constructor(self) -> Type[WebExtractPageMarkdown]:
33
+ return WebExtractPageMarkdown
34
+
35
+ @property
36
+ def tool_invoke_params_example(self) -> Dict[str, Any]:
37
+ return {"url": "https://example.com"}
38
+
39
+ @property
40
+ def init_from_env_params(self) -> Tuple[Dict[str, str], Dict[str, Any], Dict[str, Any]]:
41
+ return ENV
@@ -0,0 +1,82 @@
1
+ """Unit tests with mocked HTTP. No network, no money."""
2
+ import asyncio
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+ from pydantic import ValidationError
8
+
9
+ from langchain_webextract import WebExtractExtractUrl, WebExtractPageMarkdown, get_webextract_tools
10
+ from webextract import AsyncWebExtract, TargetFetchError, WebExtract
11
+
12
+ MD = {"url": "https://a.test/", "final_url": "https://a.test/", "title": "A", "markdown": "# A"}
13
+ EX = {"url": "u", "final_url": "u", "markdown": "# A", "metadata": {}, "tables": [], "product": None, "product_unresolved": {"reason": "listing_page"}}
14
+
15
+
16
+ def transport(status, body, headers=None, calls=None):
17
+ def h(req):
18
+ if calls is not None:
19
+ calls.append(req)
20
+ return httpx.Response(status, json=body, headers=headers or {})
21
+ return httpx.MockTransport(h)
22
+
23
+
24
+ def sync(status, body, headers=None, calls=None, **kw):
25
+ return WebExtract(http_client=httpx.Client(transport=transport(status, body, headers, calls)), **kw)
26
+
27
+
28
+ def test_names_and_schema():
29
+ a, b = get_webextract_tools(client=sync(200, MD))
30
+ assert (a.name, b.name) == ("extract_url", "page_markdown")
31
+ assert "url" in a.args and "Markdown" in a.description
32
+ assert a.tool_call_schema.model_json_schema()["required"] == ["url"]
33
+
34
+
35
+ def test_extract_invoke_returns_json_string():
36
+ calls = []
37
+ out = json.loads(WebExtractExtractUrl(client=sync(200, EX, None, calls)).invoke({"url": "https://a.test/"}))
38
+ assert out["product_unresolved"]["reason"] == "listing_page" and calls[0].url.path == "/v1/extract"
39
+
40
+
41
+ def test_markdown_invoke_with_key():
42
+ calls = []
43
+ out = json.loads(WebExtractPageMarkdown(client=sync(200, MD, None, calls, api_key="lx_k")).invoke({"url": "https://a.test/"}))
44
+ assert out["markdown"] == "# A" and calls[0].headers["authorization"] == "Bearer lx_k"
45
+
46
+
47
+ def test_invalid_url_rejected_before_http():
48
+ calls = []
49
+ with pytest.raises(ValidationError):
50
+ WebExtractPageMarkdown(client=sync(200, MD, None, calls)).invoke({"url": "nope"})
51
+ assert calls == []
52
+
53
+
54
+ def test_402_returned_as_error_json():
55
+ t = WebExtractExtractUrl(client=sync(402, {"error": "payment_required", "reason": "free_limit_reached", "message": "limit"}, {"x-payment-reason": "free_limit_reached"}))
56
+ out = json.loads(t.invoke({"url": "https://a.test/"}))
57
+ assert out["ok"] is False and out["error"]["reason"] == "free_limit_reached" and out["error"]["charged"] is False
58
+
59
+
60
+ def test_on_error_raise():
61
+ t = WebExtractExtractUrl(client=sync(451, {"error": "robots_disallowed", "message": "robots", "charged": False}), on_error="raise")
62
+ with pytest.raises(TargetFetchError):
63
+ t.invoke({"url": "https://a.test/"})
64
+
65
+
66
+ def test_async_client_used_by_ainvoke():
67
+ calls = []
68
+ ac = AsyncWebExtract(http_client=httpx.AsyncClient(transport=transport(200, MD, None, calls)))
69
+ out = json.loads(asyncio.run(WebExtractPageMarkdown(async_client=ac).ainvoke({"url": "https://a.test/"})))
70
+ assert out["markdown"] == "# A" and calls[0].url.path == "/v1/markdown"
71
+
72
+
73
+ def test_sync_invoke_with_only_async_client_is_clear_error():
74
+ ac = AsyncWebExtract(http_client=httpx.AsyncClient(transport=transport(200, MD)))
75
+ with pytest.raises(RuntimeError, match="ainvoke"):
76
+ WebExtractPageMarkdown(async_client=ac).invoke({"url": "https://a.test/"})
77
+
78
+
79
+ def test_ainvoke_falls_back_to_sync_client():
80
+ calls = []
81
+ out = json.loads(asyncio.run(WebExtractPageMarkdown(client=sync(200, MD, None, calls)).ainvoke({"url": "https://a.test/"})))
82
+ assert out["markdown"] == "# A" and len(calls) == 1