langchain-hydrafetch 0.1.0__py3-none-any.whl
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_hydrafetch/__init__.py +20 -0
- langchain_hydrafetch/_common.py +93 -0
- langchain_hydrafetch/document_loaders.py +98 -0
- langchain_hydrafetch/py.typed +0 -0
- langchain_hydrafetch/retrievers.py +110 -0
- langchain_hydrafetch/tools.py +180 -0
- langchain_hydrafetch-0.1.0.dist-info/METADATA +264 -0
- langchain_hydrafetch-0.1.0.dist-info/RECORD +10 -0
- langchain_hydrafetch-0.1.0.dist-info/WHEEL +4 -0
- langchain_hydrafetch-0.1.0.dist-info/licenses/LICENSE +9 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""LangChain integration for the Hydrafetch web data API."""
|
|
2
|
+
|
|
3
|
+
from .document_loaders import HydrafetchLoader
|
|
4
|
+
from .retrievers import HydrafetchSearchRetriever
|
|
5
|
+
from .tools import (
|
|
6
|
+
HydrafetchBrandTool,
|
|
7
|
+
HydrafetchExtractTool,
|
|
8
|
+
HydrafetchScrapeTool,
|
|
9
|
+
HydrafetchSearchTool,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"HydrafetchBrandTool",
|
|
14
|
+
"HydrafetchExtractTool",
|
|
15
|
+
"HydrafetchLoader",
|
|
16
|
+
"HydrafetchScrapeTool",
|
|
17
|
+
"HydrafetchSearchRetriever",
|
|
18
|
+
"HydrafetchSearchTool",
|
|
19
|
+
]
|
|
20
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from hydrafetch import AsyncHydrafetch, Hydrafetch
|
|
7
|
+
from langchain_core.documents import Document
|
|
8
|
+
|
|
9
|
+
CONTENT_FORMATS = ("markdown", "html", "rawHtml", "summary")
|
|
10
|
+
|
|
11
|
+
_METADATA_KEYS = (
|
|
12
|
+
"title",
|
|
13
|
+
"description",
|
|
14
|
+
"language",
|
|
15
|
+
"siteName",
|
|
16
|
+
"author",
|
|
17
|
+
"publishedTime",
|
|
18
|
+
"wordCount",
|
|
19
|
+
"pageType",
|
|
20
|
+
"image",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_client(
|
|
25
|
+
api_key: str | None,
|
|
26
|
+
base_url: str | None,
|
|
27
|
+
timeout: float | None,
|
|
28
|
+
max_retries: int | None,
|
|
29
|
+
) -> Hydrafetch:
|
|
30
|
+
return Hydrafetch(api_key, **_client_kwargs(base_url, timeout, max_retries))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_async_client(
|
|
34
|
+
api_key: str | None,
|
|
35
|
+
base_url: str | None,
|
|
36
|
+
timeout: float | None,
|
|
37
|
+
max_retries: int | None,
|
|
38
|
+
) -> AsyncHydrafetch:
|
|
39
|
+
return AsyncHydrafetch(api_key, **_client_kwargs(base_url, timeout, max_retries))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _client_kwargs(
|
|
43
|
+
base_url: str | None, timeout: float | None, max_retries: int | None
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
kwargs: dict[str, Any] = {}
|
|
46
|
+
if base_url is not None:
|
|
47
|
+
kwargs["base_url"] = base_url
|
|
48
|
+
if timeout is not None:
|
|
49
|
+
kwargs["timeout"] = timeout
|
|
50
|
+
if max_retries is not None:
|
|
51
|
+
kwargs["max_retries"] = max_retries
|
|
52
|
+
return kwargs
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unwrap(payload: Any) -> dict[str, Any]:
|
|
56
|
+
if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
|
|
57
|
+
return payload["data"]
|
|
58
|
+
return payload if isinstance(payload, dict) else {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def page_to_document(data: Mapping[str, Any], content_format: str) -> Document:
|
|
62
|
+
content = data.get(content_format)
|
|
63
|
+
if content is None and content_format != "markdown":
|
|
64
|
+
content = data.get("markdown")
|
|
65
|
+
if content is None:
|
|
66
|
+
structured = data.get("structured") or data.get("json")
|
|
67
|
+
content = str(structured) if structured is not None else ""
|
|
68
|
+
|
|
69
|
+
metadata: dict[str, Any] = {"source": data.get("url") or data.get("finalUrl") or ""}
|
|
70
|
+
if data.get("finalUrl") and data.get("finalUrl") != metadata["source"]:
|
|
71
|
+
metadata["final_url"] = data["finalUrl"]
|
|
72
|
+
if data.get("status") is not None:
|
|
73
|
+
metadata["status"] = data["status"]
|
|
74
|
+
|
|
75
|
+
raw_metadata = data.get("metadata")
|
|
76
|
+
if isinstance(raw_metadata, Mapping):
|
|
77
|
+
for key in _METADATA_KEYS:
|
|
78
|
+
value = raw_metadata.get(key)
|
|
79
|
+
if value is not None:
|
|
80
|
+
metadata[_snake(key)] = value
|
|
81
|
+
|
|
82
|
+
return Document(page_content=str(content), metadata=metadata)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _snake(key: str) -> str:
|
|
86
|
+
out: list[str] = []
|
|
87
|
+
for char in key:
|
|
88
|
+
if char.isupper():
|
|
89
|
+
out.append("_")
|
|
90
|
+
out.append(char.lower())
|
|
91
|
+
else:
|
|
92
|
+
out.append(char)
|
|
93
|
+
return "".join(out)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from langchain_core.document_loaders import BaseLoader
|
|
7
|
+
from langchain_core.documents import Document
|
|
8
|
+
|
|
9
|
+
from ._common import build_client, page_to_document, unwrap
|
|
10
|
+
|
|
11
|
+
Mode = Literal["scrape", "crawl", "map"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HydrafetchLoader(BaseLoader):
|
|
15
|
+
"""Load web pages as LangChain documents via the Hydrafetch API."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
url: str,
|
|
20
|
+
*,
|
|
21
|
+
mode: Mode = "scrape",
|
|
22
|
+
api_key: str | None = None,
|
|
23
|
+
base_url: str | None = None,
|
|
24
|
+
timeout: float | None = None,
|
|
25
|
+
max_retries: int | None = None,
|
|
26
|
+
content_format: str = "markdown",
|
|
27
|
+
raise_for_status: bool = True,
|
|
28
|
+
params: dict[str, Any] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
if mode not in ("scrape", "crawl", "map"):
|
|
31
|
+
raise ValueError(f"mode must be 'scrape', 'crawl' or 'map', got {mode!r}")
|
|
32
|
+
self.url = url
|
|
33
|
+
self.mode = mode
|
|
34
|
+
self.content_format = content_format
|
|
35
|
+
self.raise_for_status = raise_for_status
|
|
36
|
+
self.params = params or {}
|
|
37
|
+
self._client = build_client(api_key, base_url, timeout, max_retries)
|
|
38
|
+
|
|
39
|
+
def lazy_load(self) -> Iterator[Document]:
|
|
40
|
+
if self.mode == "scrape":
|
|
41
|
+
yield from self._load_scrape()
|
|
42
|
+
elif self.mode == "map":
|
|
43
|
+
yield from self._load_map()
|
|
44
|
+
else:
|
|
45
|
+
yield from self._load_crawl()
|
|
46
|
+
|
|
47
|
+
def _load_scrape(self) -> Iterator[Document]:
|
|
48
|
+
data = unwrap(self._client.scrape(self.url, **self._scrape_params()))
|
|
49
|
+
if self.raise_for_status and not _ok(data):
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"{self.url} returned HTTP {data.get('status')}. The body of an error page is "
|
|
52
|
+
"not the page you asked for; pass raise_for_status=False to load it anyway."
|
|
53
|
+
)
|
|
54
|
+
yield page_to_document(data, self.content_format)
|
|
55
|
+
|
|
56
|
+
def _load_map(self) -> Iterator[Document]:
|
|
57
|
+
data = unwrap(self._client.map(self.url, **self.params))
|
|
58
|
+
for entry in data.get("links") or []:
|
|
59
|
+
if not isinstance(entry, dict):
|
|
60
|
+
continue
|
|
61
|
+
found = entry.get("url")
|
|
62
|
+
if not found:
|
|
63
|
+
continue
|
|
64
|
+
metadata: dict[str, Any] = {"source": found}
|
|
65
|
+
if entry.get("lastmod"):
|
|
66
|
+
metadata["lastmod"] = entry["lastmod"]
|
|
67
|
+
yield Document(page_content=found, metadata=metadata)
|
|
68
|
+
|
|
69
|
+
def _load_crawl(self) -> Iterator[Document]:
|
|
70
|
+
job = unwrap(self._client.crawl(self.url, **self._scrape_params()))
|
|
71
|
+
for page in job.get("pages") or []:
|
|
72
|
+
if not isinstance(page, dict):
|
|
73
|
+
continue
|
|
74
|
+
data = page.get("data")
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
continue
|
|
77
|
+
data.setdefault("url", page.get("url") or page.get("requestedUrl"))
|
|
78
|
+
if self.raise_for_status and not _ok(data):
|
|
79
|
+
continue
|
|
80
|
+
document = page_to_document(data, self.content_format)
|
|
81
|
+
if page.get("depth") is not None:
|
|
82
|
+
document.metadata["depth"] = page["depth"]
|
|
83
|
+
yield document
|
|
84
|
+
|
|
85
|
+
def _scrape_params(self) -> dict[str, Any]:
|
|
86
|
+
params = dict(self.params)
|
|
87
|
+
params.setdefault("formats", [self.content_format])
|
|
88
|
+
return params
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ok(data: dict[str, Any]) -> bool:
|
|
92
|
+
status = data.get("status")
|
|
93
|
+
if status is None:
|
|
94
|
+
return True
|
|
95
|
+
try:
|
|
96
|
+
return 200 <= int(status) < 300
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from langchain_core.callbacks import (
|
|
6
|
+
AsyncCallbackManagerForRetrieverRun,
|
|
7
|
+
CallbackManagerForRetrieverRun,
|
|
8
|
+
)
|
|
9
|
+
from langchain_core.documents import Document
|
|
10
|
+
from langchain_core.retrievers import BaseRetriever
|
|
11
|
+
from pydantic import ConfigDict, Field, PrivateAttr
|
|
12
|
+
|
|
13
|
+
from ._common import build_async_client, build_client, unwrap
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HydrafetchSearchRetriever(BaseRetriever):
|
|
17
|
+
"""Retrieve web results for a query using Hydrafetch's first-party search."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
20
|
+
|
|
21
|
+
k: int = 5
|
|
22
|
+
scrape_content: bool = False
|
|
23
|
+
content_format: str = "markdown"
|
|
24
|
+
search_params: dict[str, Any] = Field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
api_key: str | None = None
|
|
27
|
+
base_url: str | None = None
|
|
28
|
+
timeout: float | None = None
|
|
29
|
+
max_retries: int | None = None
|
|
30
|
+
|
|
31
|
+
_client: Any = PrivateAttr(default=None)
|
|
32
|
+
_aclient: Any = PrivateAttr(default=None)
|
|
33
|
+
|
|
34
|
+
def _sync(self) -> Any:
|
|
35
|
+
if self._client is None:
|
|
36
|
+
self._client = build_client(self.api_key, self.base_url, self.timeout, self.max_retries)
|
|
37
|
+
return self._client
|
|
38
|
+
|
|
39
|
+
def _async(self) -> Any:
|
|
40
|
+
if self._aclient is None:
|
|
41
|
+
self._aclient = build_async_client(
|
|
42
|
+
self.api_key, self.base_url, self.timeout, self.max_retries
|
|
43
|
+
)
|
|
44
|
+
return self._aclient
|
|
45
|
+
|
|
46
|
+
def _params(self, k: int | None) -> dict[str, Any]:
|
|
47
|
+
params = dict(self.search_params)
|
|
48
|
+
params.setdefault("limit", k or self.k)
|
|
49
|
+
return params
|
|
50
|
+
|
|
51
|
+
def _get_relevant_documents(
|
|
52
|
+
self,
|
|
53
|
+
query: str,
|
|
54
|
+
*,
|
|
55
|
+
run_manager: CallbackManagerForRetrieverRun,
|
|
56
|
+
k: int | None = None,
|
|
57
|
+
**kwargs: Any,
|
|
58
|
+
) -> list[Document]:
|
|
59
|
+
data = unwrap(self._sync().search(query, **self._params(k)))
|
|
60
|
+
documents = [_result_to_document(item) for item in _results(data)]
|
|
61
|
+
if not self.scrape_content:
|
|
62
|
+
return documents[: k or self.k]
|
|
63
|
+
return [self._with_content(doc) for doc in documents[: k or self.k]]
|
|
64
|
+
|
|
65
|
+
async def _aget_relevant_documents(
|
|
66
|
+
self,
|
|
67
|
+
query: str,
|
|
68
|
+
*,
|
|
69
|
+
run_manager: AsyncCallbackManagerForRetrieverRun,
|
|
70
|
+
k: int | None = None,
|
|
71
|
+
**kwargs: Any,
|
|
72
|
+
) -> list[Document]:
|
|
73
|
+
data = unwrap(await self._async().search(query, **self._params(k)))
|
|
74
|
+
documents = [_result_to_document(item) for item in _results(data)]
|
|
75
|
+
if not self.scrape_content:
|
|
76
|
+
return documents[: k or self.k]
|
|
77
|
+
return [await self._awith_content(doc) for doc in documents[: k or self.k]]
|
|
78
|
+
|
|
79
|
+
def _with_content(self, document: Document) -> Document:
|
|
80
|
+
source = document.metadata.get("source")
|
|
81
|
+
if not source:
|
|
82
|
+
return document
|
|
83
|
+
page = unwrap(self._sync().scrape(source, formats=[self.content_format]))
|
|
84
|
+
content = page.get(self.content_format)
|
|
85
|
+
if content:
|
|
86
|
+
document.page_content = str(content)
|
|
87
|
+
return document
|
|
88
|
+
|
|
89
|
+
async def _awith_content(self, document: Document) -> Document:
|
|
90
|
+
source = document.metadata.get("source")
|
|
91
|
+
if not source:
|
|
92
|
+
return document
|
|
93
|
+
page = unwrap(await self._async().scrape(source, formats=[self.content_format]))
|
|
94
|
+
content = page.get(self.content_format)
|
|
95
|
+
if content:
|
|
96
|
+
document.page_content = str(content)
|
|
97
|
+
return document
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _results(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
101
|
+
return [item for item in (data.get("results") or []) if isinstance(item, dict)]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _result_to_document(item: dict[str, Any]) -> Document:
|
|
105
|
+
metadata: dict[str, Any] = {"source": item.get("url") or ""}
|
|
106
|
+
if item.get("title"):
|
|
107
|
+
metadata["title"] = item["title"]
|
|
108
|
+
if item.get("rank") is not None:
|
|
109
|
+
metadata["rank"] = item["rank"]
|
|
110
|
+
return Document(page_content=str(item.get("snippet") or ""), metadata=metadata)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from langchain_core.callbacks import (
|
|
7
|
+
AsyncCallbackManagerForToolRun,
|
|
8
|
+
CallbackManagerForToolRun,
|
|
9
|
+
)
|
|
10
|
+
from langchain_core.tools import BaseTool
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
|
12
|
+
|
|
13
|
+
from ._common import build_async_client, build_client, unwrap
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _HydrafetchTool(BaseTool):
|
|
17
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
18
|
+
|
|
19
|
+
api_key: str | None = None
|
|
20
|
+
base_url: str | None = None
|
|
21
|
+
timeout: float | None = None
|
|
22
|
+
max_retries: int | None = None
|
|
23
|
+
|
|
24
|
+
_client: Any = PrivateAttr(default=None)
|
|
25
|
+
_aclient: Any = PrivateAttr(default=None)
|
|
26
|
+
|
|
27
|
+
def _sync(self) -> Any:
|
|
28
|
+
if self._client is None:
|
|
29
|
+
self._client = build_client(self.api_key, self.base_url, self.timeout, self.max_retries)
|
|
30
|
+
return self._client
|
|
31
|
+
|
|
32
|
+
def _async(self) -> Any:
|
|
33
|
+
if self._aclient is None:
|
|
34
|
+
self._aclient = build_async_client(
|
|
35
|
+
self.api_key, self.base_url, self.timeout, self.max_retries
|
|
36
|
+
)
|
|
37
|
+
return self._aclient
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ScrapeInput(BaseModel):
|
|
41
|
+
url: str = Field(description="The absolute URL of the page to read.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HydrafetchScrapeTool(_HydrafetchTool):
|
|
45
|
+
"""Read one web page and return it as clean markdown."""
|
|
46
|
+
|
|
47
|
+
name: str = "hydrafetch_scrape"
|
|
48
|
+
description: str = (
|
|
49
|
+
"Read a single web page and return its main content as clean markdown. "
|
|
50
|
+
"Use this when you already have the URL of the page you need."
|
|
51
|
+
)
|
|
52
|
+
args_schema: type[BaseModel] = ScrapeInput
|
|
53
|
+
|
|
54
|
+
def _run(self, url: str, run_manager: CallbackManagerForToolRun | None = None) -> str:
|
|
55
|
+
data = unwrap(self._sync().scrape(url, formats=["markdown"]))
|
|
56
|
+
return str(data.get("markdown") or "")
|
|
57
|
+
|
|
58
|
+
async def _arun(
|
|
59
|
+
self, url: str, run_manager: AsyncCallbackManagerForToolRun | None = None
|
|
60
|
+
) -> str:
|
|
61
|
+
data = unwrap(await self._async().scrape(url, formats=["markdown"]))
|
|
62
|
+
return str(data.get("markdown") or "")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SearchInput(BaseModel):
|
|
66
|
+
query: str = Field(description="What to search the web for.")
|
|
67
|
+
limit: int = Field(default=5, description="How many results to return.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HydrafetchSearchTool(_HydrafetchTool):
|
|
71
|
+
"""Search the web and return titles, URLs and snippets."""
|
|
72
|
+
|
|
73
|
+
name: str = "hydrafetch_search"
|
|
74
|
+
description: str = (
|
|
75
|
+
"Search the web and return a list of results with title, url and snippet. "
|
|
76
|
+
"Use this when you need to find pages rather than read a known URL."
|
|
77
|
+
)
|
|
78
|
+
args_schema: type[BaseModel] = SearchInput
|
|
79
|
+
|
|
80
|
+
def _run(
|
|
81
|
+
self, query: str, limit: int = 5, run_manager: CallbackManagerForToolRun | None = None
|
|
82
|
+
) -> str:
|
|
83
|
+
data = unwrap(self._sync().search(query, limit=limit))
|
|
84
|
+
return _dump(_trim_results(data, limit))
|
|
85
|
+
|
|
86
|
+
async def _arun(
|
|
87
|
+
self,
|
|
88
|
+
query: str,
|
|
89
|
+
limit: int = 5,
|
|
90
|
+
run_manager: AsyncCallbackManagerForToolRun | None = None,
|
|
91
|
+
) -> str:
|
|
92
|
+
data = unwrap(await self._async().search(query, limit=limit))
|
|
93
|
+
return _dump(_trim_results(data, limit))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ExtractInput(BaseModel):
|
|
97
|
+
urls: list[str] = Field(description="Absolute URLs to extract from.")
|
|
98
|
+
json_schema: dict[str, Any] | None = Field(
|
|
99
|
+
default=None, description="JSON Schema describing the shape to extract."
|
|
100
|
+
)
|
|
101
|
+
prompt: str | None = Field(
|
|
102
|
+
default=None, description="Plain-language description of what to extract."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class HydrafetchExtractTool(_HydrafetchTool):
|
|
107
|
+
"""Extract structured JSON from one or more pages."""
|
|
108
|
+
|
|
109
|
+
name: str = "hydrafetch_extract"
|
|
110
|
+
description: str = (
|
|
111
|
+
"Extract structured JSON from one or more web pages, either against a JSON Schema "
|
|
112
|
+
"or from a plain-language description of the fields you want."
|
|
113
|
+
)
|
|
114
|
+
args_schema: type[BaseModel] = ExtractInput
|
|
115
|
+
|
|
116
|
+
def _run(
|
|
117
|
+
self,
|
|
118
|
+
urls: list[str],
|
|
119
|
+
json_schema: dict[str, Any] | None = None,
|
|
120
|
+
prompt: str | None = None,
|
|
121
|
+
run_manager: CallbackManagerForToolRun | None = None,
|
|
122
|
+
) -> str:
|
|
123
|
+
return _dump(unwrap(self._sync().extract(urls, **_extract_options(json_schema, prompt))))
|
|
124
|
+
|
|
125
|
+
async def _arun(
|
|
126
|
+
self,
|
|
127
|
+
urls: list[str],
|
|
128
|
+
json_schema: dict[str, Any] | None = None,
|
|
129
|
+
prompt: str | None = None,
|
|
130
|
+
run_manager: AsyncCallbackManagerForToolRun | None = None,
|
|
131
|
+
) -> str:
|
|
132
|
+
payload = await self._async().extract(urls, **_extract_options(json_schema, prompt))
|
|
133
|
+
return _dump(unwrap(payload))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class BrandInput(BaseModel):
|
|
137
|
+
domain: str = Field(description="A company domain, for example 'stripe.com'.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class HydrafetchBrandTool(_HydrafetchTool):
|
|
141
|
+
"""Resolve a company domain to its brand and firmographic record."""
|
|
142
|
+
|
|
143
|
+
name: str = "hydrafetch_brand"
|
|
144
|
+
description: str = (
|
|
145
|
+
"Given a company domain, return its brand record: name, description, logo, "
|
|
146
|
+
"colours, and social profiles. Use this to enrich a company from its website."
|
|
147
|
+
)
|
|
148
|
+
args_schema: type[BaseModel] = BrandInput
|
|
149
|
+
|
|
150
|
+
def _run(self, domain: str, run_manager: CallbackManagerForToolRun | None = None) -> str:
|
|
151
|
+
return _dump(unwrap(self._sync().brand(domain)))
|
|
152
|
+
|
|
153
|
+
async def _arun(
|
|
154
|
+
self, domain: str, run_manager: AsyncCallbackManagerForToolRun | None = None
|
|
155
|
+
) -> str:
|
|
156
|
+
return _dump(unwrap(await self._async().brand(domain)))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _extract_options(json_schema: dict[str, Any] | None, prompt: str | None) -> dict[str, Any]:
|
|
160
|
+
options: dict[str, Any] = {}
|
|
161
|
+
if json_schema is not None:
|
|
162
|
+
options["schema"] = json_schema
|
|
163
|
+
if prompt is not None:
|
|
164
|
+
options["prompt"] = prompt
|
|
165
|
+
return options
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _trim_results(data: dict[str, Any], limit: int) -> dict[str, Any]:
|
|
169
|
+
results = [item for item in (data.get("results") or []) if isinstance(item, dict)]
|
|
170
|
+
return {
|
|
171
|
+
"query": data.get("query"),
|
|
172
|
+
"results": [
|
|
173
|
+
{k: item.get(k) for k in ("title", "url", "snippet") if item.get(k) is not None}
|
|
174
|
+
for item in results[:limit]
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _dump(payload: Any) -> str:
|
|
180
|
+
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-hydrafetch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain integration for Hydrafetch. Load, search and extract clean web data for agents and RAG.
|
|
5
|
+
Project-URL: Homepage, https://hydrafetch.com
|
|
6
|
+
Project-URL: Documentation, https://docs.hydrafetch.com
|
|
7
|
+
Project-URL: Source, https://github.com/Hydrafetch/langchain-hydrafetch
|
|
8
|
+
Project-URL: Issues, https://github.com/Hydrafetch/langchain-hydrafetch/issues
|
|
9
|
+
Author-email: Hydrafetch <team@hydrafetch.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,document-loader,hydrafetch,langchain,llm,markdown,rag,web-scraping
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: hydrafetch>=0.1.1
|
|
22
|
+
Requires-Dist: langchain-core<2,>=1.0
|
|
23
|
+
Requires-Dist: pydantic>=2
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# langchain-hydrafetch
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/langchain-hydrafetch/)
|
|
29
|
+
[](https://github.com/Hydrafetch/langchain-hydrafetch/actions/workflows/ci.yml)
|
|
30
|
+
[](https://pypi.org/project/langchain-hydrafetch/)
|
|
31
|
+
|
|
32
|
+
LangChain integration for the [Hydrafetch](https://hydrafetch.com) web data API.
|
|
33
|
+
|
|
34
|
+
Load pages as documents, search the web as a retriever, and give agents four tools that read, extract and enrich from the live web. Sync and async, fully typed.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install langchain-hydrafetch
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Create a key at [app.hydrafetch.com](https://app.hydrafetch.com) and set it:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export HYDRAFETCH_API_KEY="hf_..."
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Everything below reads that variable when no `api_key=` is passed.
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from langchain_hydrafetch import HydrafetchLoader
|
|
54
|
+
|
|
55
|
+
docs = HydrafetchLoader("https://example.com/article").load()
|
|
56
|
+
print(docs[0].page_content)
|
|
57
|
+
print(docs[0].metadata["title"])
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Document loader
|
|
61
|
+
|
|
62
|
+
Three modes. `scrape` loads one page, `crawl` walks a site and loads every page it finds, and `map` returns one document per discovered URL without fetching the bodies.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
HydrafetchLoader("https://example.com/article").load()
|
|
66
|
+
HydrafetchLoader("https://example.com", mode="crawl", params={"limit": 50}).load()
|
|
67
|
+
HydrafetchLoader("https://example.com", mode="map").load()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`content_format` chooses what lands in `page_content` — `markdown` by default, or `html`, `rawHtml`, `summary`. `params` is forwarded to the API untouched, so anything the endpoint accepts works here:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
HydrafetchLoader(
|
|
74
|
+
"https://example.com/article",
|
|
75
|
+
content_format="markdown",
|
|
76
|
+
params={"onlyMainContent": True, "preferStructure": True, "blockAds": True},
|
|
77
|
+
).load()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Documents carry `source` and `status`, plus whatever page metadata was found: `title`, `description`, `language`, `site_name`, `author`, `published_time`, `word_count`, `page_type`, `image`. A redirect adds `final_url`; a crawled page adds `depth`; a mapped URL adds `lastmod` when the sitemap declares one.
|
|
81
|
+
|
|
82
|
+
Use `lazy_load()` to stream a large crawl instead of building the whole list in memory:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
for doc in HydrafetchLoader("https://example.com", mode="crawl").lazy_load():
|
|
86
|
+
index.add(doc)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Error pages are refused, not loaded
|
|
90
|
+
|
|
91
|
+
A URL that answers with an error status raises instead of returning a document, because the body of a 404 page is not the page you asked for and a retrieval index should not quietly absorb one.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
HydrafetchLoader("https://example.com/gone").load()
|
|
95
|
+
# ValueError: https://example.com/gone returned HTTP 404. The body of an error
|
|
96
|
+
# page is not the page you asked for; pass raise_for_status=False to load it anyway.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
In `crawl` mode a single dead page must not throw away the whole job, so error pages are dropped and the rest of the crawl is kept. Pass `raise_for_status=False` to load error pages in either mode.
|
|
100
|
+
|
|
101
|
+
## Retriever
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from langchain_hydrafetch import HydrafetchSearchRetriever
|
|
105
|
+
|
|
106
|
+
retriever = HydrafetchSearchRetriever(k=5)
|
|
107
|
+
docs = retriever.invoke("best open source vector databases")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Each document holds the result snippet, with `source`, `title` and `rank` in metadata. Pass `scrape_content=True` to fetch and return the full page body for every result instead:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
HydrafetchSearchRetriever(k=3, scrape_content=True).invoke("...")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
That costs one extra credit per result. `k` works as a constructor argument or per call, and `search_params` is forwarded to the search endpoint:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
retriever.invoke("...", k=2)
|
|
120
|
+
HydrafetchSearchRetriever(search_params={"country": "us"})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Agent tools
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from langchain_hydrafetch import (
|
|
127
|
+
HydrafetchBrandTool,
|
|
128
|
+
HydrafetchExtractTool,
|
|
129
|
+
HydrafetchScrapeTool,
|
|
130
|
+
HydrafetchSearchTool,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
tools = [
|
|
134
|
+
HydrafetchSearchTool(),
|
|
135
|
+
HydrafetchScrapeTool(),
|
|
136
|
+
HydrafetchExtractTool(),
|
|
137
|
+
HydrafetchBrandTool(),
|
|
138
|
+
]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
| tool | argument | returns |
|
|
142
|
+
| --- | --- | --- |
|
|
143
|
+
| `hydrafetch_search` | `query`, `limit` | JSON with `query` and `results` of title, url, snippet |
|
|
144
|
+
| `hydrafetch_scrape` | `url` | the page as markdown |
|
|
145
|
+
| `hydrafetch_extract` | `urls`, `json_schema` or `prompt` | JSON with one entry per URL |
|
|
146
|
+
| `hydrafetch_brand` | `domain` | JSON brand record: name, description, tagline, logo assets, colours, fonts, socials |
|
|
147
|
+
|
|
148
|
+
Search finds pages, scrape reads a page you already have. Giving an agent both is the usual setup.
|
|
149
|
+
|
|
150
|
+
Structured extraction takes either a schema or a plain-language description:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
HydrafetchExtractTool().invoke({
|
|
154
|
+
"urls": ["https://example.com/pricing"],
|
|
155
|
+
"json_schema": {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"properties": {"plans": {"type": "array", "items": {"type": "string"}}},
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
HydrafetchExtractTool().invoke({
|
|
162
|
+
"urls": ["https://example.com/about"],
|
|
163
|
+
"prompt": "the founding year and the headquarters city",
|
|
164
|
+
})
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Async
|
|
168
|
+
|
|
169
|
+
The retriever and every tool support `ainvoke`, and the loader supports `alazy_load` and `aload`:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
docs = await HydrafetchSearchRetriever().ainvoke("...")
|
|
173
|
+
text = await HydrafetchScrapeTool().ainvoke({"url": "https://example.com"})
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Error handling
|
|
177
|
+
|
|
178
|
+
Failures raise `HydrafetchError` from the underlying client, carrying the API's error code, HTTP status and request id.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from hydrafetch import HydrafetchError, HydrafetchTimeout
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
docs = HydrafetchLoader(url).load()
|
|
185
|
+
except HydrafetchTimeout:
|
|
186
|
+
raise
|
|
187
|
+
except HydrafetchError as err:
|
|
188
|
+
if err.is_out_of_credits:
|
|
189
|
+
top_up()
|
|
190
|
+
elif err.is_retryable:
|
|
191
|
+
enqueue(url)
|
|
192
|
+
else:
|
|
193
|
+
raise
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
| Status | Meaning | Retried |
|
|
197
|
+
| --- | --- | --- |
|
|
198
|
+
| 400, 422 | invalid request | no |
|
|
199
|
+
| 401, 403 | invalid or missing key | no |
|
|
200
|
+
| 402 | out of credits | no |
|
|
201
|
+
| 429 | rate limited | yes, twice with backoff |
|
|
202
|
+
| 5xx | upstream failure | yes, twice with backoff |
|
|
203
|
+
|
|
204
|
+
A page that loads but answers with an error status raises `ValueError` from the loader instead — that is a bad URL, not a failed request.
|
|
205
|
+
|
|
206
|
+
## Configuration
|
|
207
|
+
|
|
208
|
+
Every class accepts the same connection options, all optional:
|
|
209
|
+
|
|
210
|
+
| option | default | meaning |
|
|
211
|
+
| --- | --- | --- |
|
|
212
|
+
| `api_key` | `HYDRAFETCH_API_KEY` | your API key |
|
|
213
|
+
| `base_url` | `https://api.hydrafetch.com` | API base URL |
|
|
214
|
+
| `timeout` | `120.0` | per-request timeout in seconds |
|
|
215
|
+
| `max_retries` | `2` | retries on 429 and 5xx |
|
|
216
|
+
|
|
217
|
+
## Credits
|
|
218
|
+
|
|
219
|
+
| call | credits |
|
|
220
|
+
| --- | --- |
|
|
221
|
+
| loader, `scrape` mode | 1 |
|
|
222
|
+
| loader, `map` mode | 1 |
|
|
223
|
+
| loader, `crawl` mode | 1 per page |
|
|
224
|
+
| retriever | 1, plus 1 per result with `scrape_content=True` |
|
|
225
|
+
| `hydrafetch_scrape` | 1 |
|
|
226
|
+
| `hydrafetch_search` | 1 |
|
|
227
|
+
| `hydrafetch_extract` | 5 per URL |
|
|
228
|
+
| `hydrafetch_brand` | 5 |
|
|
229
|
+
|
|
230
|
+
Failed requests are not billed. Pricing does not vary with page difficulty, so there is no render, stealth or proxy option to set.
|
|
231
|
+
|
|
232
|
+
## Implementation notes
|
|
233
|
+
|
|
234
|
+
- Prefer `map` then a batch of `scrape` loads over a broad `crawl`. Fetching a whole site and discarding most of it is the most common source of wasted credits.
|
|
235
|
+
- `params` in `crawl` mode is forwarded to the crawl endpoint, so per-page options belong under `scrapeOptions`. At the top level they are ignored.
|
|
236
|
+
- `preferStructure` is off by default. Turn it on when headings, lists and tables matter; leave it off for raw article text.
|
|
237
|
+
- The loader constructs its client eagerly, so a missing API key fails at construction rather than at `load()`.
|
|
238
|
+
- The retriever and tools construct their clients lazily on first use, which keeps them cheap to build and safe to define at import time.
|
|
239
|
+
- Metadata keys are snake_cased on the way out, so the API's `siteName` becomes `site_name`.
|
|
240
|
+
- Scraped content is untrusted input. Do not pass it to a model as instructions, and keep `metadata["source"]` with anything extracted from it.
|
|
241
|
+
|
|
242
|
+
## Development
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
uv sync
|
|
246
|
+
uv run ruff check src tests
|
|
247
|
+
uv run ruff format --check src tests
|
|
248
|
+
uv run pytest -q
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Unit tests run offline against fake clients. The integration tests are LangChain's own `langchain-tests` standard suite; they are skipped unless `HYDRAFETCH_API_KEY` is set, and they spend real credits.
|
|
252
|
+
|
|
253
|
+
## Links
|
|
254
|
+
|
|
255
|
+
- [Documentation](https://docs.hydrafetch.com)
|
|
256
|
+
- [OpenAPI specification](https://api.hydrafetch.com/openapi.json)
|
|
257
|
+
- [MCP server and editor setup](https://hydrafetch.com/mcp)
|
|
258
|
+
- [Python client](https://github.com/Hydrafetch/python-sdk) — the client this integration wraps
|
|
259
|
+
- [LlamaIndex reader](https://github.com/Hydrafetch/llama-index-readers-hydrafetch)
|
|
260
|
+
- Other clients: [Node](https://github.com/Hydrafetch/node-sdk) · [Go](https://github.com/Hydrafetch/go-sdk) · [Ruby](https://github.com/Hydrafetch/ruby-sdk) · [Rust](https://github.com/Hydrafetch/rust-sdk) · [PHP](https://github.com/Hydrafetch/php-sdk)
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
langchain_hydrafetch/__init__.py,sha256=nyiv9PNoZHeacnBQ9zW6lgshGjnLNkWEO3iEd43rgng,492
|
|
2
|
+
langchain_hydrafetch/_common.py,sha256=h7XGXDqE9WQSun3LSJokT-y45M259S_nuiDprwLsHrA,2683
|
|
3
|
+
langchain_hydrafetch/document_loaders.py,sha256=Or5ukoCTB5ki_yYQI40wN1uuxhHcxhssKz9J5uAYdw4,3544
|
|
4
|
+
langchain_hydrafetch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
langchain_hydrafetch/retrievers.py,sha256=ZRFL99hG53ZA8_EbtR8UCmvkc3cPdtY6s24a34F-eUo,3882
|
|
6
|
+
langchain_hydrafetch/tools.py,sha256=BuLYa3ZTgrQy8dmrDHLmUGaLvmURY9_5EdEiv8570GU,6137
|
|
7
|
+
langchain_hydrafetch-0.1.0.dist-info/METADATA,sha256=0VwuJaWgUPRJwDfPpSO0AEp77AiemJdcYPUXgTVFwXk,9992
|
|
8
|
+
langchain_hydrafetch-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
langchain_hydrafetch-0.1.0.dist-info/licenses/LICENSE,sha256=yEyqX8ttI5_YWUzo5l0UqsOSYz5ujIKPeLQsq8ME3NE,1067
|
|
10
|
+
langchain_hydrafetch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hydrafetch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|