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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/langchain-hydrafetch)](https://pypi.org/project/langchain-hydrafetch/)
29
+ [![CI](https://github.com/Hydrafetch/langchain-hydrafetch/actions/workflows/ci.yml/badge.svg)](https://github.com/Hydrafetch/langchain-hydrafetch/actions/workflows/ci.yml)
30
+ [![Python](https://img.shields.io/pypi/pyversions/langchain-hydrafetch)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.