langchain-nestdaddy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ unit:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python-version: ["3.9", "3.11", "3.13"]
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: ${{ matrix.python-version }}
18
+ - run: pip install -e ".[test]"
19
+ - run: pytest tests/unit_tests --disable-socket --allow-unix-socket -q
20
+
21
+ integration:
22
+ runs-on: ubuntu-latest
23
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: "3.12"
29
+ - run: pip install -e ".[test]"
30
+ - run: pytest -m integration tests/integration_tests -q
31
+ env:
32
+ NESTDADDY_API_KEY: ${{ secrets.NESTDADDY_API_KEY }}
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NestDaddy
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,129 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-nestdaddy
3
+ Version: 0.1.0
4
+ Summary: LangChain integration for NestDaddy: give your AI today's internet. Live web search, real-time news and page reading.
5
+ Project-URL: Homepage, https://nestdaddy.com/mcp
6
+ Project-URL: Documentation, https://github.com/nestdaddy/langchain-nestdaddy#readme
7
+ Project-URL: Source, https://github.com/nestdaddy/langchain-nestdaddy
8
+ Project-URL: API reference, https://nestdaddy.com/apis
9
+ Author-email: NestDaddy <hello@nestdaddy.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,langchain,nestdaddy,news,rag,search,web search
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 :: Indexing/Search
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: httpx<1.0.0,>=0.25.0
21
+ Requires-Dist: langchain-core<2.0.0,>=0.3.15
22
+ Requires-Dist: pydantic<3.0,>=2.7
23
+ Provides-Extra: test
24
+ Requires-Dist: langchain-tests>=0.3.0; extra == 'test'
25
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
26
+ Requires-Dist: pytest-socket>=0.7; extra == 'test'
27
+ Requires-Dist: pytest>=8; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # langchain-nestdaddy
31
+
32
+ **Give your AI today's internet.** Live web search, real-time breaking news from around the world and full page
33
+ reading for LangChain agents and RAG pipelines, from [NestDaddy](https://nestdaddy.com)'s own index.
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/langchain-nestdaddy)](https://pypi.org/project/langchain-nestdaddy/)
36
+ [![tests](https://github.com/nestdaddy/langchain-nestdaddy/actions/workflows/tests.yml/badge.svg)](https://github.com/nestdaddy/langchain-nestdaddy/actions/workflows/tests.yml)
37
+
38
+ ```bash
39
+ pip install -U langchain-nestdaddy
40
+ export NESTDADDY_API_KEY="your-key" # free trial key: https://nestdaddy.com/mcp
41
+ ```
42
+
43
+ | Class | What it does |
44
+ |---|---|
45
+ | `NestDaddySearch` | Web search on NestDaddy's own index (title, url, snippet, domain) |
46
+ | `NestDaddyNewsSearch` | Real-time news by key words, optional country and time window |
47
+ | `NestDaddyReadPage` | Clean main text of any page or article, no menus or ads |
48
+ | `NestDaddyNewsRetriever` | News as LangChain `Document`s for RAG, optionally with the full article text |
49
+
50
+ ## Give an agent live search and news
51
+
52
+ ```python
53
+ from langchain.agents import create_agent
54
+ from langchain_nestdaddy import NestDaddyNewsSearch, NestDaddyReadPage, NestDaddySearch
55
+
56
+ agent = create_agent(
57
+ "anthropic:claude-sonnet-5-5",
58
+ tools=[NestDaddySearch(max_results=5), NestDaddyNewsSearch(days=7), NestDaddyReadPage()],
59
+ system_prompt="Use news search for anything recent, web search for everything else. "
60
+ "Read a page in full before you explain its details.",
61
+ )
62
+
63
+ result = agent.invoke({"messages": [{"role": "user", "content": "What did the Fed decide this week?"}]})
64
+ print(result["messages"][-1].content)
65
+ ```
66
+
67
+ Any chat model that supports tool calling works the same way.
68
+
69
+ ## Use the tools directly
70
+
71
+ ```python
72
+ from langchain_nestdaddy import NestDaddyNewsSearch, NestDaddySearch, NestDaddyReadPage
73
+
74
+ NestDaddySearch(max_results=3).invoke({"query": "model context protocol"})
75
+ # {'query': 'model context protocol', 'count': 3, 'results': [{'title': ..., 'url': ..., 'snippet': ...}]}
76
+
77
+ NestDaddyNewsSearch().invoke({"query": "interest rates", "country": "US", "days": 7})
78
+ # {'query': 'interest rates', 'count': 10, 'total': ..., 'results': [{'title': ..., 'source_domain': ...,
79
+ # 'published_timestamp': ..., 'summary': ...}]}
80
+
81
+ NestDaddyReadPage(max_chars=8000).invoke({"url": "https://example.com/some-article"})
82
+ # {'title': ..., 'final_url': ..., 'text': ...}
83
+ ```
84
+
85
+ Local news works in the local language: `NestDaddyNewsSearch().invoke({"query": "banjir Jakarta", "country": "ID"})`.
86
+
87
+ ## RAG over today's news
88
+
89
+ ```python
90
+ from langchain_nestdaddy import NestDaddyNewsRetriever
91
+
92
+ retriever = NestDaddyNewsRetriever(k=5, days=3, full_text=True)
93
+ docs = retriever.invoke("chip export rules")
94
+
95
+ for d in docs:
96
+ print(d.metadata["title"], d.metadata["url"], d.metadata["published_timestamp"])
97
+ print(d.page_content[:300])
98
+ ```
99
+
100
+ With `full_text=True` each article is read in full (one extra API call per article). If an article cannot be
101
+ read, its summary is kept so you still get `k` documents.
102
+
103
+ ## Async
104
+
105
+ Every tool and the retriever support `ainvoke`; the retriever reads full articles in parallel.
106
+
107
+ ```python
108
+ docs = await NestDaddyNewsRetriever(k=5, full_text=True).ainvoke("AI regulation")
109
+ ```
110
+
111
+ ## Errors
112
+
113
+ API errors raise `NestDaddyAPIError` with the HTTP status and a hint (a bad key points to the dashboard, a plan
114
+ limit to the plans page). Your key is stored as a `SecretStr` and never printed.
115
+
116
+ ## Prefer MCP?
117
+
118
+ The same tools are available as a remote MCP server for Claude, Cursor, VS Code and n8n:
119
+ `https://mcp.nestdaddy.com/mcp`. Setup: [nestdaddy.com/mcp](https://nestdaddy.com/mcp).
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ pip install -e ".[test]"
125
+ pytest tests/unit_tests --disable-socket --allow-unix-socket # offline
126
+ NESTDADDY_API_KEY=... pytest -m integration tests/integration_tests
127
+ ```
128
+
129
+ MIT licensed.
@@ -0,0 +1,100 @@
1
+ # langchain-nestdaddy
2
+
3
+ **Give your AI today's internet.** Live web search, real-time breaking news from around the world and full page
4
+ reading for LangChain agents and RAG pipelines, from [NestDaddy](https://nestdaddy.com)'s own index.
5
+
6
+ [![PyPI](https://img.shields.io/pypi/v/langchain-nestdaddy)](https://pypi.org/project/langchain-nestdaddy/)
7
+ [![tests](https://github.com/nestdaddy/langchain-nestdaddy/actions/workflows/tests.yml/badge.svg)](https://github.com/nestdaddy/langchain-nestdaddy/actions/workflows/tests.yml)
8
+
9
+ ```bash
10
+ pip install -U langchain-nestdaddy
11
+ export NESTDADDY_API_KEY="your-key" # free trial key: https://nestdaddy.com/mcp
12
+ ```
13
+
14
+ | Class | What it does |
15
+ |---|---|
16
+ | `NestDaddySearch` | Web search on NestDaddy's own index (title, url, snippet, domain) |
17
+ | `NestDaddyNewsSearch` | Real-time news by key words, optional country and time window |
18
+ | `NestDaddyReadPage` | Clean main text of any page or article, no menus or ads |
19
+ | `NestDaddyNewsRetriever` | News as LangChain `Document`s for RAG, optionally with the full article text |
20
+
21
+ ## Give an agent live search and news
22
+
23
+ ```python
24
+ from langchain.agents import create_agent
25
+ from langchain_nestdaddy import NestDaddyNewsSearch, NestDaddyReadPage, NestDaddySearch
26
+
27
+ agent = create_agent(
28
+ "anthropic:claude-sonnet-5-5",
29
+ tools=[NestDaddySearch(max_results=5), NestDaddyNewsSearch(days=7), NestDaddyReadPage()],
30
+ system_prompt="Use news search for anything recent, web search for everything else. "
31
+ "Read a page in full before you explain its details.",
32
+ )
33
+
34
+ result = agent.invoke({"messages": [{"role": "user", "content": "What did the Fed decide this week?"}]})
35
+ print(result["messages"][-1].content)
36
+ ```
37
+
38
+ Any chat model that supports tool calling works the same way.
39
+
40
+ ## Use the tools directly
41
+
42
+ ```python
43
+ from langchain_nestdaddy import NestDaddyNewsSearch, NestDaddySearch, NestDaddyReadPage
44
+
45
+ NestDaddySearch(max_results=3).invoke({"query": "model context protocol"})
46
+ # {'query': 'model context protocol', 'count': 3, 'results': [{'title': ..., 'url': ..., 'snippet': ...}]}
47
+
48
+ NestDaddyNewsSearch().invoke({"query": "interest rates", "country": "US", "days": 7})
49
+ # {'query': 'interest rates', 'count': 10, 'total': ..., 'results': [{'title': ..., 'source_domain': ...,
50
+ # 'published_timestamp': ..., 'summary': ...}]}
51
+
52
+ NestDaddyReadPage(max_chars=8000).invoke({"url": "https://example.com/some-article"})
53
+ # {'title': ..., 'final_url': ..., 'text': ...}
54
+ ```
55
+
56
+ Local news works in the local language: `NestDaddyNewsSearch().invoke({"query": "banjir Jakarta", "country": "ID"})`.
57
+
58
+ ## RAG over today's news
59
+
60
+ ```python
61
+ from langchain_nestdaddy import NestDaddyNewsRetriever
62
+
63
+ retriever = NestDaddyNewsRetriever(k=5, days=3, full_text=True)
64
+ docs = retriever.invoke("chip export rules")
65
+
66
+ for d in docs:
67
+ print(d.metadata["title"], d.metadata["url"], d.metadata["published_timestamp"])
68
+ print(d.page_content[:300])
69
+ ```
70
+
71
+ With `full_text=True` each article is read in full (one extra API call per article). If an article cannot be
72
+ read, its summary is kept so you still get `k` documents.
73
+
74
+ ## Async
75
+
76
+ Every tool and the retriever support `ainvoke`; the retriever reads full articles in parallel.
77
+
78
+ ```python
79
+ docs = await NestDaddyNewsRetriever(k=5, full_text=True).ainvoke("AI regulation")
80
+ ```
81
+
82
+ ## Errors
83
+
84
+ API errors raise `NestDaddyAPIError` with the HTTP status and a hint (a bad key points to the dashboard, a plan
85
+ limit to the plans page). Your key is stored as a `SecretStr` and never printed.
86
+
87
+ ## Prefer MCP?
88
+
89
+ The same tools are available as a remote MCP server for Claude, Cursor, VS Code and n8n:
90
+ `https://mcp.nestdaddy.com/mcp`. Setup: [nestdaddy.com/mcp](https://nestdaddy.com/mcp).
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ pip install -e ".[test]"
96
+ pytest tests/unit_tests --disable-socket --allow-unix-socket # offline
97
+ NESTDADDY_API_KEY=... pytest -m integration tests/integration_tests
98
+ ```
99
+
100
+ MIT licensed.
@@ -0,0 +1,26 @@
1
+ """LangChain integration for NestDaddy: give your AI today's internet.
2
+
3
+ Live web search, real-time breaking news from around the world and full page reading, from NestDaddy's own
4
+ index. Get a key at https://nestdaddy.com/mcp and set ``NESTDADDY_API_KEY``.
5
+ """
6
+
7
+ from importlib import metadata
8
+
9
+ from langchain_nestdaddy._client import NestDaddyAPIError, NestDaddyAPIWrapper
10
+ from langchain_nestdaddy.retrievers import NestDaddyNewsRetriever
11
+ from langchain_nestdaddy.tools import NestDaddyNewsSearch, NestDaddyReadPage, NestDaddySearch
12
+
13
+ try:
14
+ __version__ = metadata.version(__package__ or "langchain_nestdaddy")
15
+ except metadata.PackageNotFoundError:
16
+ __version__ = ""
17
+
18
+ __all__ = [
19
+ "NestDaddyAPIError",
20
+ "NestDaddyAPIWrapper",
21
+ "NestDaddyNewsRetriever",
22
+ "NestDaddyNewsSearch",
23
+ "NestDaddyReadPage",
24
+ "NestDaddySearch",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,89 @@
1
+ """Small HTTP client for the NestDaddy REST API, shared by the tools and the retriever."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Dict, Optional
7
+
8
+ import httpx
9
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
10
+
11
+ DEFAULT_BASE_URL = "https://nestdaddy.com"
12
+ USER_AGENT = "langchain-nestdaddy/0.1.0"
13
+
14
+
15
+ class NestDaddyAPIError(RuntimeError):
16
+ """Raised when the NestDaddy API answers with an error (bad key, plan limit, bad input)."""
17
+
18
+ def __init__(self, status_code: int, error: str, message: str) -> None:
19
+ self.status_code = status_code
20
+ self.error = error
21
+ hint = ""
22
+ if status_code in (401, 403):
23
+ hint = " Check your key at https://nestdaddy.com/dashboard"
24
+ elif status_code == 429:
25
+ hint = " You reached your plan's limit: https://nestdaddy.com/mcp"
26
+ super().__init__(f"NestDaddy API error {status_code} ({error}): {message}.{hint}")
27
+
28
+
29
+ class NestDaddyAPIWrapper(BaseModel):
30
+ """Calls the NestDaddy API with your key.
31
+
32
+ The key is read from ``api_key`` or the ``NESTDADDY_API_KEY`` environment variable.
33
+ Get one at https://nestdaddy.com/mcp (free trial).
34
+ """
35
+
36
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
37
+
38
+ api_key: Optional[SecretStr] = Field(default=None)
39
+ base_url: str = DEFAULT_BASE_URL
40
+ timeout: float = 30.0
41
+ transport: Optional[Any] = Field(default=None, exclude=True, repr=False)
42
+ """Optional httpx transport (used by tests). Leave unset in normal use."""
43
+
44
+ @model_validator(mode="after")
45
+ def _resolve_key(self) -> "NestDaddyAPIWrapper":
46
+ if self.api_key is None:
47
+ env = os.environ.get("NESTDADDY_API_KEY", "").strip()
48
+ if env:
49
+ self.api_key = SecretStr(env)
50
+ if self.api_key is None or not self.api_key.get_secret_value():
51
+ raise ValueError(
52
+ "NestDaddy API key not found. Pass api_key=... or set NESTDADDY_API_KEY. "
53
+ "Get a key at https://nestdaddy.com/mcp"
54
+ )
55
+ return self
56
+
57
+ # ------------------------------------------------------------------ internals
58
+ def _headers(self) -> Dict[str, str]:
59
+ assert self.api_key is not None
60
+ return {
61
+ "X-API-Key": self.api_key.get_secret_value(),
62
+ "Accept": "application/json",
63
+ "User-Agent": USER_AGENT,
64
+ }
65
+
66
+ @staticmethod
67
+ def _clean(params: Dict[str, Any]) -> Dict[str, Any]:
68
+ return {k: v for k, v in params.items() if v not in (None, "")}
69
+
70
+ @staticmethod
71
+ def _parse(response: httpx.Response) -> Dict[str, Any]:
72
+ try:
73
+ data = response.json()
74
+ except ValueError:
75
+ raise NestDaddyAPIError(response.status_code, "invalid_response",
76
+ "the API did not return JSON") from None
77
+ if response.status_code != 200:
78
+ raise NestDaddyAPIError(response.status_code, str(data.get("error") or "error"),
79
+ str(data.get("message") or data.get("error") or response.reason_phrase))
80
+ return data
81
+
82
+ def get(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
83
+ with httpx.Client(base_url=self.base_url, timeout=self.timeout, transport=self.transport) as client:
84
+ return self._parse(client.get(path, params=self._clean(params), headers=self._headers()))
85
+
86
+ async def aget(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
87
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout,
88
+ transport=self.transport) as client:
89
+ return self._parse(await client.get(path, params=self._clean(params), headers=self._headers()))
@@ -0,0 +1,108 @@
1
+ """NestDaddy news retriever: real-time news as LangChain Documents, optionally with the full article text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from langchain_core.callbacks import AsyncCallbackManagerForRetrieverRun, CallbackManagerForRetrieverRun
9
+ from langchain_core.documents import Document
10
+ from langchain_core.retrievers import BaseRetriever
11
+ from pydantic import Field, model_validator
12
+
13
+ from langchain_nestdaddy._client import NestDaddyAPIError, NestDaddyAPIWrapper
14
+
15
+ META_FIELDS = ("title", "url", "source_domain", "published_timestamp", "country")
16
+
17
+
18
+ class NestDaddyNewsRetriever(BaseRetriever):
19
+ """Retrieve real-time news from around the world for a query.
20
+
21
+ Each article becomes a ``Document``: the summary (or, with ``full_text=True``, the article text) is the
22
+ ``page_content``; title, url, source, publish time and country are in ``metadata``.
23
+
24
+ Setup:
25
+ .. code-block:: bash
26
+
27
+ pip install -U langchain-nestdaddy
28
+ export NESTDADDY_API_KEY="your-key"
29
+
30
+ Instantiate:
31
+ .. code-block:: python
32
+
33
+ from langchain_nestdaddy import NestDaddyNewsRetriever
34
+
35
+ retriever = NestDaddyNewsRetriever(k=5, days=7, full_text=True)
36
+
37
+ Usage:
38
+ .. code-block:: python
39
+
40
+ docs = retriever.invoke("chip export rules")
41
+ """
42
+
43
+ api_wrapper: NestDaddyAPIWrapper
44
+ k: int = Field(default=5, ge=1, le=50)
45
+ """How many articles to return."""
46
+ days: int = Field(default=30, ge=1, le=120)
47
+ """Only news from the last N days."""
48
+ country: Optional[str] = None
49
+ """Optional two-letter country code, e.g. US, GB, IN, SG, ID."""
50
+ full_text: bool = False
51
+ """Read each article in full (one extra API call per article) instead of using its summary."""
52
+ max_chars: int = Field(default=6000, ge=500, le=30000)
53
+ """Characters kept from each full article."""
54
+
55
+ @model_validator(mode="before")
56
+ @classmethod
57
+ def _build_wrapper(cls, values: Any) -> Any:
58
+ if isinstance(values, dict) and "api_wrapper" not in values:
59
+ kwargs = {k: values.pop(k) for k in ("api_key", "base_url") if k in values}
60
+ values["api_wrapper"] = NestDaddyAPIWrapper(**kwargs)
61
+ return values
62
+
63
+ # ------------------------------------------------------------------ helpers
64
+ def _params(self, query: str) -> Dict[str, Any]:
65
+ return {"q": query, "days": self.days, "limit": self.k,
66
+ "country": self.country.upper() if self.country else None}
67
+
68
+ @staticmethod
69
+ def _to_doc(item: Dict[str, Any], text: Optional[str] = None) -> Document:
70
+ content = text or item.get("summary") or item.get("description") or item.get("title") or ""
71
+ meta = {f: item[f] for f in META_FIELDS if item.get(f) not in (None, "")}
72
+ meta["full_text"] = bool(text)
73
+ return Document(page_content=content, metadata=meta)
74
+
75
+ def _items(self, data: Dict[str, Any]) -> List[Dict[str, Any]]:
76
+ return [r for r in data.get("results") or [] if isinstance(r, dict) and r.get("url")][: self.k]
77
+
78
+ def _read(self, url: str) -> Optional[str]:
79
+ try:
80
+ page = self.api_wrapper.get("/api/v1/extract", {"url": url, "max_chars": self.max_chars})
81
+ except NestDaddyAPIError:
82
+ return None # keep the summary when one article cannot be read
83
+ text = page.get("text") or ""
84
+ return text if len(text) > 200 else None
85
+
86
+ async def _aread(self, url: str) -> Optional[str]:
87
+ try:
88
+ page = await self.api_wrapper.aget("/api/v1/extract", {"url": url, "max_chars": self.max_chars})
89
+ except NestDaddyAPIError:
90
+ return None
91
+ text = page.get("text") or ""
92
+ return text if len(text) > 200 else None
93
+
94
+ # ------------------------------------------------------------------ BaseRetriever
95
+ def _get_relevant_documents(self, query: str, *,
96
+ run_manager: CallbackManagerForRetrieverRun) -> List[Document]:
97
+ items = self._items(self.api_wrapper.get("/api/v1/news/search", self._params(query)))
98
+ texts = [self._read(i["url"]) if self.full_text else None for i in items]
99
+ return [self._to_doc(i, t) for i, t in zip(items, texts)]
100
+
101
+ async def _aget_relevant_documents(self, query: str, *,
102
+ run_manager: AsyncCallbackManagerForRetrieverRun) -> List[Document]:
103
+ items = self._items(await self.api_wrapper.aget("/api/v1/news/search", self._params(query)))
104
+ if self.full_text:
105
+ texts = await asyncio.gather(*(self._aread(i["url"]) for i in items))
106
+ else:
107
+ texts = [None] * len(items)
108
+ return [self._to_doc(i, t) for i, t in zip(items, texts)]
@@ -0,0 +1,221 @@
1
+ """NestDaddy tools for LangChain agents: web search, real-time news search and full page reading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional, Tuple, Type
6
+
7
+ from langchain_core.callbacks import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
8
+ from langchain_core.tools import BaseTool
9
+ from pydantic import BaseModel, Field, model_validator
10
+
11
+ from langchain_nestdaddy._client import NestDaddyAPIWrapper
12
+
13
+ SNIPPET_CHARS = 400
14
+ NEWS_FIELDS = ("title", "url", "source_domain", "published_timestamp", "summary", "description", "country")
15
+ WEB_FIELDS = ("title", "url", "snippet", "domain", "type")
16
+
17
+
18
+ def _pick(item: Dict[str, Any], fields: Tuple[str, ...]) -> Dict[str, Any]:
19
+ out: Dict[str, Any] = {}
20
+ for f in fields:
21
+ v = item.get(f)
22
+ if v in (None, "", [], {}):
23
+ continue
24
+ if isinstance(v, str) and len(v) > SNIPPET_CHARS:
25
+ v = v[:SNIPPET_CHARS].rstrip() + "..."
26
+ out[f] = v
27
+ return out
28
+
29
+
30
+ class _NestDaddyTool(BaseTool):
31
+ """Shared setup: every tool owns an API wrapper, built from ``api_key`` or ``NESTDADDY_API_KEY``."""
32
+
33
+ api_wrapper: NestDaddyAPIWrapper
34
+
35
+ @model_validator(mode="before")
36
+ @classmethod
37
+ def _build_wrapper(cls, values: Any) -> Any:
38
+ if isinstance(values, dict) and "api_wrapper" not in values:
39
+ kwargs = {k: values.pop(k) for k in ("api_key", "base_url") if k in values}
40
+ values["api_wrapper"] = NestDaddyAPIWrapper(**kwargs)
41
+ return values
42
+
43
+
44
+ # ---------------------------------------------------------------------------------------------- web search
45
+ class NestDaddySearchInput(BaseModel):
46
+ query: str = Field(description="Short key words to search the web for, not a whole sentence.")
47
+ max_results: Optional[int] = Field(default=None, ge=1, le=50,
48
+ description="How many results to return (1-50).")
49
+
50
+
51
+ class NestDaddySearch(_NestDaddyTool):
52
+ """Search the web with NestDaddy's own index.
53
+
54
+ Setup:
55
+ .. code-block:: bash
56
+
57
+ pip install -U langchain-nestdaddy
58
+ export NESTDADDY_API_KEY="your-key"
59
+
60
+ Instantiate:
61
+ .. code-block:: python
62
+
63
+ from langchain_nestdaddy import NestDaddySearch
64
+
65
+ tool = NestDaddySearch(max_results=5)
66
+
67
+ Invoke:
68
+ .. code-block:: python
69
+
70
+ tool.invoke({"query": "model context protocol"})
71
+ """
72
+
73
+ name: str = "nestdaddy_search"
74
+ description: str = (
75
+ "Search the web with NestDaddy's own index. Returns the title, url, snippet and domain of each page. "
76
+ "For time-sensitive questions fresh news articles are mixed in. Use short key words. "
77
+ "Call nestdaddy_read_page on a url when the snippet is not enough."
78
+ )
79
+ args_schema: Type[BaseModel] = NestDaddySearchInput
80
+ max_results: int = 10
81
+
82
+ def _params(self, query: str, max_results: Optional[int]) -> Dict[str, Any]:
83
+ return {"q": query, "limit": max_results or self.max_results}
84
+
85
+ @staticmethod
86
+ def _format(data: Dict[str, Any]) -> Dict[str, Any]:
87
+ results = [_pick(r, WEB_FIELDS) for r in data.get("results") or [] if isinstance(r, dict)]
88
+ return {"query": data.get("query"), "count": len(results), "results": results}
89
+
90
+ def _run(self, query: str, max_results: Optional[int] = None,
91
+ run_manager: Optional[CallbackManagerForToolRun] = None) -> Dict[str, Any]:
92
+ return self._format(self.api_wrapper.get("/api/v1/search", self._params(query, max_results)))
93
+
94
+ async def _arun(self, query: str, max_results: Optional[int] = None,
95
+ run_manager: Optional[AsyncCallbackManagerForToolRun] = None) -> Dict[str, Any]:
96
+ return self._format(await self.api_wrapper.aget("/api/v1/search", self._params(query, max_results)))
97
+
98
+
99
+ # ---------------------------------------------------------------------------------------------- news search
100
+ class NestDaddyNewsSearchInput(BaseModel):
101
+ query: str = Field(description="Key words a headline would use, in the local language for local news.")
102
+ country: Optional[str] = Field(default=None, min_length=2, max_length=2,
103
+ description="Optional two-letter country code, e.g. US, GB, IN, SG, ID.")
104
+ days: Optional[int] = Field(default=None, ge=1, le=120, description="Only news from the last N days (1-120).")
105
+ max_results: Optional[int] = Field(default=None, ge=1, le=50,
106
+ description="How many articles to return (1-50).")
107
+
108
+
109
+ class NestDaddyNewsSearch(_NestDaddyTool):
110
+ """Search real-time news from around the world by key words, newest and best match first.
111
+
112
+ Setup:
113
+ .. code-block:: bash
114
+
115
+ pip install -U langchain-nestdaddy
116
+ export NESTDADDY_API_KEY="your-key"
117
+
118
+ Instantiate:
119
+ .. code-block:: python
120
+
121
+ from langchain_nestdaddy import NestDaddyNewsSearch
122
+
123
+ tool = NestDaddyNewsSearch(days=7, max_results=5)
124
+
125
+ Invoke:
126
+ .. code-block:: python
127
+
128
+ tool.invoke({"query": "interest rates", "country": "US"})
129
+ """
130
+
131
+ name: str = "nestdaddy_news_search"
132
+ description: str = (
133
+ "Search real-time breaking news from around the world by key words: title, url, source, publish time and "
134
+ "summary of each article, newest and best match first. The best tool for questions about recent events, "
135
+ "people, companies and places. Optional country (two-letter code) and days (1-120)."
136
+ )
137
+ args_schema: Type[BaseModel] = NestDaddyNewsSearchInput
138
+ max_results: int = 10
139
+ days: int = 30
140
+
141
+ def _params(self, query: str, country: Optional[str], days: Optional[int],
142
+ max_results: Optional[int]) -> Dict[str, Any]:
143
+ return {"q": query, "country": country.upper() if country else None, "days": days or self.days,
144
+ "limit": max_results or self.max_results}
145
+
146
+ @staticmethod
147
+ def _format(data: Dict[str, Any]) -> Dict[str, Any]:
148
+ results = [_pick(r, NEWS_FIELDS) for r in data.get("results") or [] if isinstance(r, dict)]
149
+ return {"query": data.get("query"), "count": len(results), "total": data.get("total"), "results": results}
150
+
151
+ def _run(self, query: str, country: Optional[str] = None, days: Optional[int] = None,
152
+ max_results: Optional[int] = None,
153
+ run_manager: Optional[CallbackManagerForToolRun] = None) -> Dict[str, Any]:
154
+ return self._format(self.api_wrapper.get("/api/v1/news/search",
155
+ self._params(query, country, days, max_results)))
156
+
157
+ async def _arun(self, query: str, country: Optional[str] = None, days: Optional[int] = None,
158
+ max_results: Optional[int] = None,
159
+ run_manager: Optional[AsyncCallbackManagerForToolRun] = None) -> Dict[str, Any]:
160
+ return self._format(await self.api_wrapper.aget("/api/v1/news/search",
161
+ self._params(query, country, days, max_results)))
162
+
163
+
164
+ # ---------------------------------------------------------------------------------------------- read page
165
+ class NestDaddyReadPageInput(BaseModel):
166
+ url: str = Field(description="The full http(s) url of the page or article to read.")
167
+ max_chars: Optional[int] = Field(default=None, ge=500, le=30000,
168
+ description="Maximum characters of text to return (500-30000).")
169
+
170
+
171
+ class NestDaddyReadPage(_NestDaddyTool):
172
+ """Read the clean main text of any web page or news article (no menus, no ads).
173
+
174
+ Setup:
175
+ .. code-block:: bash
176
+
177
+ pip install -U langchain-nestdaddy
178
+ export NESTDADDY_API_KEY="your-key"
179
+
180
+ Instantiate:
181
+ .. code-block:: python
182
+
183
+ from langchain_nestdaddy import NestDaddyReadPage
184
+
185
+ tool = NestDaddyReadPage(max_chars=8000)
186
+
187
+ Invoke:
188
+ .. code-block:: python
189
+
190
+ tool.invoke({"url": "https://en.wikipedia.org/wiki/Model_Context_Protocol"})
191
+ """
192
+
193
+ name: str = "nestdaddy_read_page"
194
+ description: str = (
195
+ "Read the full readable text of one web page or news article: title, author, date and main text, "
196
+ "without menus or ads. Use it after a search when you need the details behind a result."
197
+ )
198
+ args_schema: Type[BaseModel] = NestDaddyReadPageInput
199
+ max_chars: int = 8000
200
+
201
+ @staticmethod
202
+ def _format(data: Dict[str, Any]) -> Dict[str, Any]:
203
+ keep = ("title", "final_url", "author", "published", "text", "truncated", "length")
204
+ return {k: data[k] for k in keep if data.get(k) not in (None, "")}
205
+
206
+ def _run(self, url: str, max_chars: Optional[int] = None,
207
+ run_manager: Optional[CallbackManagerForToolRun] = None) -> Dict[str, Any]:
208
+ return self._format(self.api_wrapper.get("/api/v1/extract",
209
+ {"url": url, "max_chars": max_chars or self.max_chars}))
210
+
211
+ async def _arun(self, url: str, max_chars: Optional[int] = None,
212
+ run_manager: Optional[AsyncCallbackManagerForToolRun] = None) -> Dict[str, Any]:
213
+ return self._format(await self.api_wrapper.aget("/api/v1/extract",
214
+ {"url": url, "max_chars": max_chars or self.max_chars}))
215
+
216
+
217
+ __all__: List[str] = [
218
+ "NestDaddySearch", "NestDaddySearchInput",
219
+ "NestDaddyNewsSearch", "NestDaddyNewsSearchInput",
220
+ "NestDaddyReadPage", "NestDaddyReadPageInput",
221
+ ]
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-nestdaddy"
7
+ version = "0.1.0"
8
+ description = "LangChain integration for NestDaddy: give your AI today's internet. Live web search, real-time news and page reading."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "NestDaddy", email = "hello@nestdaddy.com" }]
13
+ keywords = ["langchain", "search", "news", "web search", "rag", "agents", "nestdaddy"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ ]
22
+ dependencies = [
23
+ "langchain-core>=0.3.15,<2.0.0",
24
+ "httpx>=0.25.0,<1.0.0",
25
+ "pydantic>=2.7,<3.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ test = [
30
+ "pytest>=8",
31
+ "pytest-asyncio>=0.23",
32
+ "pytest-socket>=0.7",
33
+ "langchain-tests>=0.3.0",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://nestdaddy.com/mcp"
38
+ Documentation = "https://github.com/nestdaddy/langchain-nestdaddy#readme"
39
+ Source = "https://github.com/nestdaddy/langchain-nestdaddy"
40
+ "API reference" = "https://nestdaddy.com/apis"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["langchain_nestdaddy"]
44
+
45
+ [tool.pytest.ini_options]
46
+ asyncio_mode = "auto"
47
+ markers = ["integration: calls the live NestDaddy API (needs NESTDADDY_API_KEY)"]
48
+ addopts = "-m 'not integration'"
File without changes
@@ -0,0 +1,32 @@
1
+ """Live tests against the real NestDaddy API. Run with: NESTDADDY_API_KEY=... pytest -m integration"""
2
+
3
+ import os
4
+
5
+ import pytest
6
+
7
+ from langchain_nestdaddy import NestDaddyNewsRetriever, NestDaddyNewsSearch, NestDaddyReadPage, NestDaddySearch
8
+
9
+ pytestmark = [
10
+ pytest.mark.integration,
11
+ pytest.mark.skipif(not os.environ.get("NESTDADDY_API_KEY"), reason="NESTDADDY_API_KEY not set"),
12
+ ]
13
+
14
+
15
+ def test_live_web_search() -> None:
16
+ out = NestDaddySearch(max_results=3).invoke({"query": "model context protocol"})
17
+ assert out["count"] >= 1 and out["results"][0]["url"].startswith("http")
18
+
19
+
20
+ def test_live_news_search() -> None:
21
+ out = NestDaddyNewsSearch(max_results=3, days=7).invoke({"query": "AI"})
22
+ assert out["count"] >= 1 and out["results"][0]["title"]
23
+
24
+
25
+ def test_live_read_page() -> None:
26
+ out = NestDaddyReadPage(max_chars=1000).invoke({"url": "https://en.wikipedia.org/wiki/Web_search_engine"})
27
+ assert len(out.get("text", "")) > 200
28
+
29
+
30
+ def test_live_retriever() -> None:
31
+ docs = NestDaddyNewsRetriever(k=2, days=7).invoke("AI")
32
+ assert docs and docs[0].metadata["url"].startswith("http")
File without changes
@@ -0,0 +1,138 @@
1
+ """Behaviour tests with a fake NestDaddy API (httpx.MockTransport), sync and async."""
2
+
3
+ import json
4
+ from typing import Any, Dict, List
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from langchain_nestdaddy import (
10
+ NestDaddyAPIError,
11
+ NestDaddyAPIWrapper,
12
+ NestDaddyNewsRetriever,
13
+ NestDaddyNewsSearch,
14
+ NestDaddyReadPage,
15
+ NestDaddySearch,
16
+ )
17
+
18
+ LONG = "Full article text. " * 40
19
+
20
+
21
+ def fake_api(seen: List[httpx.Request]) -> httpx.MockTransport:
22
+ def handler(request: httpx.Request) -> httpx.Response:
23
+ seen.append(request)
24
+ if request.headers.get("X-API-Key") != "test-key":
25
+ return httpx.Response(401, json={"error": "Invalid API key", "message": "The key is not valid"})
26
+ path, q = request.url.path, dict(request.url.params)
27
+ if path == "/api/v1/search":
28
+ return httpx.Response(200, json={"query": q["q"], "results": [
29
+ {"title": "MCP spec", "url": "https://modelcontextprotocol.io", "snippet": "x" * 900,
30
+ "domain": "modelcontextprotocol.io", "type": "web", "internal_score": 9}]})
31
+ if path == "/api/v1/news/search":
32
+ return httpx.Response(200, json={"query": q["q"], "total": 2, "results": [
33
+ {"title": "Fed holds rates", "url": "https://news.example/a", "source_domain": "news.example",
34
+ "published_timestamp": "2026-11-13T10:00:00Z", "summary": "The Fed held rates.", "country": "US"},
35
+ {"title": "Markets rise", "url": "https://news.example/b", "source_domain": "news.example",
36
+ "published_timestamp": "2026-11-13T09:00:00Z", "summary": "Stocks rose.", "country": "US"}]})
37
+ if path == "/api/v1/extract":
38
+ if q["url"].endswith("/b"):
39
+ return httpx.Response(422, json={"error": "Unreadable", "message": "No article text"})
40
+ return httpx.Response(200, json={"title": "Fed holds rates", "final_url": q["url"],
41
+ "text": LONG, "length": len(LONG), "truncated": False})
42
+ return httpx.Response(404, json={"error": "Not found", "message": path})
43
+ return httpx.MockTransport(handler)
44
+
45
+
46
+ def wrapper(seen: List[httpx.Request], key: str = "test-key") -> NestDaddyAPIWrapper:
47
+ return NestDaddyAPIWrapper(api_key=key, transport=fake_api(seen))
48
+
49
+
50
+ def test_missing_key_is_a_clear_error(monkeypatch: pytest.MonkeyPatch) -> None:
51
+ monkeypatch.delenv("NESTDADDY_API_KEY", raising=False)
52
+ with pytest.raises(ValueError, match="NESTDADDY_API_KEY"):
53
+ NestDaddySearch()
54
+
55
+
56
+ def test_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
57
+ monkeypatch.setenv("NESTDADDY_API_KEY", "env-key")
58
+ assert NestDaddySearch().api_wrapper.api_key.get_secret_value() == "env-key"
59
+
60
+
61
+ def test_key_is_not_printed() -> None:
62
+ w = NestDaddyAPIWrapper(api_key="secret-value")
63
+ assert "secret-value" not in repr(w) and "secret-value" not in str(w.model_dump())
64
+
65
+
66
+ def test_web_search_trims_and_picks_fields() -> None:
67
+ seen: List[httpx.Request] = []
68
+ out = NestDaddySearch(api_wrapper=wrapper(seen), max_results=3).invoke({"query": "mcp"})
69
+ assert seen[0].url.params["limit"] == "3" and seen[0].url.params["q"] == "mcp"
70
+ r = out["results"][0]
71
+ assert set(r) == {"title", "url", "snippet", "domain", "type"} # internal fields dropped
72
+ assert len(r["snippet"]) <= 403
73
+
74
+
75
+ def test_news_search_params_and_output() -> None:
76
+ seen: List[httpx.Request] = []
77
+ out = NestDaddyNewsSearch(api_wrapper=wrapper(seen), days=7).invoke(
78
+ {"query": "fed", "country": "us", "max_results": 2})
79
+ p = seen[0].url.params
80
+ assert (p["country"], p["days"], p["limit"]) == ("US", "7", "2")
81
+ assert out["count"] == 2 and out["total"] == 2 and out["results"][0]["source_domain"] == "news.example"
82
+
83
+
84
+ def test_optional_params_are_not_sent() -> None:
85
+ seen: List[httpx.Request] = []
86
+ NestDaddyNewsSearch(api_wrapper=wrapper(seen)).invoke({"query": "fed"})
87
+ assert "country" not in seen[0].url.params
88
+
89
+
90
+ def test_read_page() -> None:
91
+ seen: List[httpx.Request] = []
92
+ out = NestDaddyReadPage(api_wrapper=wrapper(seen), max_chars=2000).invoke({"url": "https://news.example/a"})
93
+ assert seen[0].url.params["max_chars"] == "2000" and out["text"].startswith("Full article text")
94
+
95
+
96
+ def test_api_error_is_raised_with_hint() -> None:
97
+ seen: List[httpx.Request] = []
98
+ with pytest.raises(NestDaddyAPIError, match="dashboard") as e:
99
+ wrapper(seen, key="wrong").get("/api/v1/search", {"q": "x"})
100
+ assert e.value.status_code == 401
101
+
102
+
103
+ def test_tool_as_agent_tool_message() -> None:
104
+ """Tools called by an agent return a ToolMessage whose content is the JSON result."""
105
+ seen: List[httpx.Request] = []
106
+ tool = NestDaddyNewsSearch(api_wrapper=wrapper(seen))
107
+ msg = tool.invoke({"type": "tool_call", "id": "call_1", "name": tool.name, "args": {"query": "fed"}})
108
+ assert msg.tool_call_id == "call_1" and "Fed holds rates" in msg.content
109
+
110
+
111
+ def test_retriever_summaries() -> None:
112
+ seen: List[httpx.Request] = []
113
+ docs = NestDaddyNewsRetriever(api_wrapper=wrapper(seen), k=2).invoke("fed")
114
+ assert [d.page_content for d in docs] == ["The Fed held rates.", "Stocks rose."]
115
+ assert docs[0].metadata["url"] == "https://news.example/a" and docs[0].metadata["full_text"] is False
116
+ assert len(seen) == 1
117
+
118
+
119
+ def test_retriever_full_text_keeps_summary_when_unreadable() -> None:
120
+ seen: List[httpx.Request] = []
121
+ docs = NestDaddyNewsRetriever(api_wrapper=wrapper(seen), k=2, full_text=True).invoke("fed")
122
+ assert docs[0].page_content.startswith("Full article text") and docs[0].metadata["full_text"] is True
123
+ assert docs[1].page_content == "Stocks rose." and docs[1].metadata["full_text"] is False
124
+ assert len(seen) == 3
125
+
126
+
127
+ async def test_async_tool_and_retriever() -> None:
128
+ seen: List[httpx.Request] = []
129
+ out = await NestDaddySearch(api_wrapper=wrapper(seen)).ainvoke({"query": "mcp"})
130
+ assert out["count"] == 1
131
+ docs = await NestDaddyNewsRetriever(api_wrapper=wrapper(seen), k=2, full_text=True).ainvoke("fed")
132
+ assert docs[0].metadata["full_text"] is True and len(docs) == 2
133
+
134
+
135
+ def test_output_is_json_serialisable() -> None:
136
+ seen: List[httpx.Request] = []
137
+ out: Dict[str, Any] = NestDaddyNewsSearch(api_wrapper=wrapper(seen)).invoke({"query": "fed"})
138
+ json.dumps(out)
@@ -0,0 +1,53 @@
1
+ """LangChain standard unit tests for the NestDaddy tools (no network)."""
2
+
3
+ from typing import Type
4
+
5
+ from langchain_tests.unit_tests import ToolsUnitTests
6
+
7
+ from langchain_nestdaddy import NestDaddyNewsSearch, NestDaddyReadPage, NestDaddySearch
8
+
9
+
10
+ class TestNestDaddySearchUnit(ToolsUnitTests):
11
+ @property
12
+ def tool_constructor(self) -> Type[NestDaddySearch]:
13
+ return NestDaddySearch
14
+
15
+ @property
16
+ def tool_constructor_params(self) -> dict:
17
+ return {"api_key": "test-key", "max_results": 3}
18
+
19
+ @property
20
+ def tool_invoke_params_example(self) -> dict:
21
+ return {"query": "model context protocol"}
22
+
23
+ @property
24
+ def init_from_env_params(self) -> tuple:
25
+ return ({"NESTDADDY_API_KEY": "env-key"}, {}, {})
26
+
27
+
28
+ class TestNestDaddyNewsSearchUnit(ToolsUnitTests):
29
+ @property
30
+ def tool_constructor(self) -> Type[NestDaddyNewsSearch]:
31
+ return NestDaddyNewsSearch
32
+
33
+ @property
34
+ def tool_constructor_params(self) -> dict:
35
+ return {"api_key": "test-key"}
36
+
37
+ @property
38
+ def tool_invoke_params_example(self) -> dict:
39
+ return {"query": "interest rates", "country": "US", "days": 7}
40
+
41
+
42
+ class TestNestDaddyReadPageUnit(ToolsUnitTests):
43
+ @property
44
+ def tool_constructor(self) -> Type[NestDaddyReadPage]:
45
+ return NestDaddyReadPage
46
+
47
+ @property
48
+ def tool_constructor_params(self) -> dict:
49
+ return {"api_key": "test-key"}
50
+
51
+ @property
52
+ def tool_invoke_params_example(self) -> dict:
53
+ return {"url": "https://example.com/article"}