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.
- langchain_webextract-0.1.0/.gitignore +9 -0
- langchain_webextract-0.1.0/LICENSE +21 -0
- langchain_webextract-0.1.0/PKG-INFO +64 -0
- langchain_webextract-0.1.0/README.md +43 -0
- langchain_webextract-0.1.0/pyproject.toml +42 -0
- langchain_webextract-0.1.0/src/langchain_webextract/__init__.py +8 -0
- langchain_webextract-0.1.0/src/langchain_webextract/py.typed +0 -0
- langchain_webextract-0.1.0/src/langchain_webextract/tools.py +97 -0
- langchain_webextract-0.1.0/tests/__init__.py +0 -0
- langchain_webextract-0.1.0/tests/_fixtures.py +39 -0
- langchain_webextract-0.1.0/tests/integration_tests/__init__.py +0 -0
- langchain_webextract-0.1.0/tests/integration_tests/test_standard_offline.py +51 -0
- langchain_webextract-0.1.0/tests/integration_tests/test_standard_sandbox.py +87 -0
- langchain_webextract-0.1.0/tests/unit_tests/__init__.py +0 -0
- langchain_webextract-0.1.0/tests/unit_tests/test_standard.py +41 -0
- langchain_webextract-0.1.0/tests/unit_tests/test_tools.py +82 -0
|
@@ -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__"]
|
|
File without changes
|
|
@@ -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)))}
|
|
File without changes
|
|
@@ -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"}
|
|
File without changes
|
|
@@ -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
|