langchain-xdataapi 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_xdataapi/__init__.py +54 -0
- langchain_xdataapi/client.py +149 -0
- langchain_xdataapi/toolkit.py +64 -0
- langchain_xdataapi/tools.py +348 -0
- langchain_xdataapi-0.1.0.dist-info/METADATA +125 -0
- langchain_xdataapi-0.1.0.dist-info/RECORD +8 -0
- langchain_xdataapi-0.1.0.dist-info/WHEEL +4 -0
- langchain_xdataapi-0.1.0.dist-info/licenses/LICENSE +9 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""LangChain tools for xdataapi.io: read-only public X (Twitter) data.
|
|
2
|
+
|
|
3
|
+
from langchain_xdataapi import XdataapiToolkit
|
|
4
|
+
|
|
5
|
+
tools = XdataapiToolkit.from_api_key().get_tools()
|
|
6
|
+
|
|
7
|
+
Read-only by design. No posting, no likes, no direct messages, no private data.
|
|
8
|
+
Docs: https://xdataapi.io/docs
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import XdataapiAuthError, XdataapiClient, XdataapiError
|
|
12
|
+
from .toolkit import XdataapiToolkit
|
|
13
|
+
from .tools import (
|
|
14
|
+
ALL_TOOLS,
|
|
15
|
+
GetBalance,
|
|
16
|
+
GetFollowerIds,
|
|
17
|
+
GetFollowers,
|
|
18
|
+
GetFollowing,
|
|
19
|
+
GetQuotes,
|
|
20
|
+
GetReplies,
|
|
21
|
+
GetRetweeters,
|
|
22
|
+
GetThread,
|
|
23
|
+
GetTweet,
|
|
24
|
+
GetTweets,
|
|
25
|
+
GetUser,
|
|
26
|
+
GetUserTweets,
|
|
27
|
+
GetUsers,
|
|
28
|
+
SearchTweets,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ALL_TOOLS",
|
|
35
|
+
"GetBalance",
|
|
36
|
+
"GetFollowerIds",
|
|
37
|
+
"GetFollowers",
|
|
38
|
+
"GetFollowing",
|
|
39
|
+
"GetQuotes",
|
|
40
|
+
"GetReplies",
|
|
41
|
+
"GetRetweeters",
|
|
42
|
+
"GetThread",
|
|
43
|
+
"GetTweet",
|
|
44
|
+
"GetTweets",
|
|
45
|
+
"GetUser",
|
|
46
|
+
"GetUserTweets",
|
|
47
|
+
"GetUsers",
|
|
48
|
+
"SearchTweets",
|
|
49
|
+
"XdataapiAuthError",
|
|
50
|
+
"XdataapiClient",
|
|
51
|
+
"XdataapiError",
|
|
52
|
+
"XdataapiToolkit",
|
|
53
|
+
"__version__",
|
|
54
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""HTTP client for the xdataapi.io REST API.
|
|
2
|
+
|
|
3
|
+
The tools hand the model the JSON the API returns, unchanged, so it sees
|
|
4
|
+
``credits_charged`` and ``balance_remaining`` on every call and can stop before
|
|
5
|
+
the balance runs out.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
BASE_URL = "https://api.xdataapi.io"
|
|
16
|
+
|
|
17
|
+
#: Failures the model can act on: try another handle, ask for a smaller page,
|
|
18
|
+
#: tell the user the balance is empty. These come back as data.
|
|
19
|
+
_REPORTED = frozenset({400, 402, 403, 404})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class XdataapiError(RuntimeError):
|
|
23
|
+
"""A call failed in a way the model cannot repair."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, *, code: str = "", status: int = 0, request_id: str = "") -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.code = code
|
|
28
|
+
self.status = status
|
|
29
|
+
self.request_id = request_id
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class XdataapiAuthError(XdataapiError):
|
|
33
|
+
"""The key is missing, unknown or revoked. A configuration fault."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _problem(response: httpx.Response) -> dict[str, Any]:
|
|
37
|
+
try:
|
|
38
|
+
body = response.json()
|
|
39
|
+
except ValueError:
|
|
40
|
+
body = {}
|
|
41
|
+
return body if isinstance(body, dict) else {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class XdataapiClient:
|
|
45
|
+
"""Thin wrapper over the REST API. Sync and async, one key.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
api_key: Key from https://xdataapi.io/dashboard. Read from
|
|
49
|
+
``XDATAAPI_KEY`` when not given.
|
|
50
|
+
base_url: Override for testing or for a private deployment.
|
|
51
|
+
timeout: Seconds per request.
|
|
52
|
+
max_results: Ceiling on ``count`` for every paging tool. Set it to cap
|
|
53
|
+
what one agent step can spend. ``None`` leaves the API default.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
api_key: str | None = None,
|
|
59
|
+
*,
|
|
60
|
+
base_url: str = BASE_URL,
|
|
61
|
+
timeout: float = 30.0,
|
|
62
|
+
max_results: int | None = None,
|
|
63
|
+
client: httpx.Client | None = None,
|
|
64
|
+
async_client: httpx.AsyncClient | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
key = api_key or os.environ.get("XDATAAPI_KEY")
|
|
67
|
+
if not key:
|
|
68
|
+
raise XdataapiAuthError(
|
|
69
|
+
"No API key. Pass api_key=... or set XDATAAPI_KEY. "
|
|
70
|
+
"Get one at https://xdataapi.io/dashboard."
|
|
71
|
+
)
|
|
72
|
+
self.api_key = key
|
|
73
|
+
self.base_url = base_url.rstrip("/")
|
|
74
|
+
self.timeout = timeout
|
|
75
|
+
self.max_results = max_results
|
|
76
|
+
self._client = client
|
|
77
|
+
self._async_client = async_client
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def _headers(self) -> dict[str, str]:
|
|
81
|
+
return {
|
|
82
|
+
"x-api-key": self.api_key,
|
|
83
|
+
"accept": "application/json",
|
|
84
|
+
"user-agent": "langchain-xdataapi",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def cap(self, count: int | None) -> int | None:
|
|
88
|
+
"""Apply ``max_results`` to a requested page size."""
|
|
89
|
+
if count is None:
|
|
90
|
+
return self.max_results
|
|
91
|
+
if self.max_results is None:
|
|
92
|
+
return count
|
|
93
|
+
return min(count, self.max_results)
|
|
94
|
+
|
|
95
|
+
def _handle(self, response: httpx.Response) -> dict[str, Any]:
|
|
96
|
+
if response.status_code < 400:
|
|
97
|
+
return _problem(response)
|
|
98
|
+
|
|
99
|
+
body = _problem(response)
|
|
100
|
+
error = body.get("error") or {}
|
|
101
|
+
code = str(error.get("code") or "")
|
|
102
|
+
message = str(error.get("message") or response.reason_phrase or "request failed")
|
|
103
|
+
request_id = str(body.get("request_id") or "")
|
|
104
|
+
|
|
105
|
+
if response.status_code == 401:
|
|
106
|
+
raise XdataapiAuthError(
|
|
107
|
+
f"{message} (code {code or 'invalid_key'}). "
|
|
108
|
+
"Check XDATAAPI_KEY against https://xdataapi.io/dashboard.",
|
|
109
|
+
code=code,
|
|
110
|
+
status=401,
|
|
111
|
+
request_id=request_id,
|
|
112
|
+
)
|
|
113
|
+
if response.status_code in _REPORTED:
|
|
114
|
+
# Nothing was charged for these. Let the model read and decide.
|
|
115
|
+
return body
|
|
116
|
+
raise XdataapiError(
|
|
117
|
+
f"{message} (HTTP {response.status_code}, code {code}, request {request_id})",
|
|
118
|
+
code=code,
|
|
119
|
+
status=response.status_code,
|
|
120
|
+
request_id=request_id,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _clean(params: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
125
|
+
return {k: v for k, v in (params or {}).items() if v is not None}
|
|
126
|
+
|
|
127
|
+
def get(self, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
128
|
+
client = self._client
|
|
129
|
+
if client is None:
|
|
130
|
+
with httpx.Client(timeout=self.timeout) as fresh:
|
|
131
|
+
return self._handle(
|
|
132
|
+
fresh.get(f"{self.base_url}{path}", params=self._clean(params), headers=self._headers)
|
|
133
|
+
)
|
|
134
|
+
return self._handle(
|
|
135
|
+
client.get(f"{self.base_url}{path}", params=self._clean(params), headers=self._headers)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def aget(self, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
139
|
+
client = self._async_client
|
|
140
|
+
if client is None:
|
|
141
|
+
async with httpx.AsyncClient(timeout=self.timeout) as fresh:
|
|
142
|
+
return self._handle(
|
|
143
|
+
await fresh.get(
|
|
144
|
+
f"{self.base_url}{path}", params=self._clean(params), headers=self._headers
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return self._handle(
|
|
148
|
+
await client.get(f"{self.base_url}{path}", params=self._clean(params), headers=self._headers)
|
|
149
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The whole read surface as one toolkit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
from langchain_core.tools import BaseTool
|
|
8
|
+
from langchain_core.tools.base import BaseToolkit
|
|
9
|
+
from pydantic import ConfigDict
|
|
10
|
+
|
|
11
|
+
from .client import XdataapiClient
|
|
12
|
+
from .tools import ALL_TOOLS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class XdataapiToolkit(BaseToolkit):
|
|
16
|
+
"""Every read tool, sharing one client and one credit budget.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
>>> from langchain_xdataapi import XdataapiToolkit
|
|
20
|
+
>>> toolkit = XdataapiToolkit.from_api_key(max_results=25)
|
|
21
|
+
>>> tools = toolkit.get_tools()
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
client: A configured :class:`XdataapiClient`.
|
|
25
|
+
include: Tool names to keep. ``None`` keeps them all.
|
|
26
|
+
exclude: Tool names to drop, applied after ``include``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
30
|
+
|
|
31
|
+
client: XdataapiClient
|
|
32
|
+
include: tuple[str, ...] | None = None
|
|
33
|
+
exclude: tuple[str, ...] = ()
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_api_key(
|
|
37
|
+
cls,
|
|
38
|
+
api_key: str | None = None,
|
|
39
|
+
*,
|
|
40
|
+
max_results: int | None = None,
|
|
41
|
+
include: Iterable[str] | None = None,
|
|
42
|
+
exclude: Iterable[str] = (),
|
|
43
|
+
**client_kwargs: object,
|
|
44
|
+
) -> "XdataapiToolkit":
|
|
45
|
+
"""Build a toolkit from a key, or from ``XDATAAPI_KEY``.
|
|
46
|
+
|
|
47
|
+
``max_results`` caps the page size of every paging tool, which caps what
|
|
48
|
+
one agent step can spend.
|
|
49
|
+
"""
|
|
50
|
+
return cls(
|
|
51
|
+
client=XdataapiClient(api_key, max_results=max_results, **client_kwargs), # type: ignore[arg-type]
|
|
52
|
+
include=tuple(include) if include is not None else None,
|
|
53
|
+
exclude=tuple(exclude),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def get_tools(self) -> list[BaseTool]:
|
|
57
|
+
tools = [tool(client=self.client) for tool in ALL_TOOLS]
|
|
58
|
+
if self.include is not None:
|
|
59
|
+
wanted = set(self.include)
|
|
60
|
+
tools = [t for t in tools if t.name in wanted]
|
|
61
|
+
if self.exclude:
|
|
62
|
+
unwanted = set(self.exclude)
|
|
63
|
+
tools = [t for t in tools if t.name not in unwanted]
|
|
64
|
+
return tools
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""LangChain tools over the xdataapi.io read endpoints.
|
|
2
|
+
|
|
3
|
+
Every tool is read-only. The credit cost is written into each description, so a
|
|
4
|
+
model that plans a multi-step read knows what the plan costs before it runs it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Literal, Sequence
|
|
11
|
+
|
|
12
|
+
from langchain_core.callbacks import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
|
|
13
|
+
from langchain_core.tools import BaseTool
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
15
|
+
|
|
16
|
+
from .client import XdataapiClient
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _handle(value: str) -> str:
|
|
20
|
+
return value.strip().lstrip("@")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _dump(payload: dict[str, Any]) -> str:
|
|
24
|
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Base(BaseTool):
|
|
28
|
+
"""Shared plumbing: one client, JSON in and JSON out."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
31
|
+
|
|
32
|
+
client: XdataapiClient
|
|
33
|
+
response_format: str = "content"
|
|
34
|
+
|
|
35
|
+
def _call(self, **kwargs: Any) -> tuple[str, dict[str, Any] | None]:
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
def _run(self, run_manager: CallbackManagerForToolRun | None = None, **kwargs: Any) -> str:
|
|
39
|
+
path, params = self._call(**kwargs)
|
|
40
|
+
return _dump(self.client.get(path, params))
|
|
41
|
+
|
|
42
|
+
async def _arun(self, run_manager: AsyncCallbackManagerForToolRun | None = None, **kwargs: Any) -> str:
|
|
43
|
+
path, params = self._call(**kwargs)
|
|
44
|
+
return _dump(await self.client.aget(path, params))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- users ---------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _UserArgs(BaseModel):
|
|
51
|
+
handle: str = Field(description="X handle, with or without the leading @.")
|
|
52
|
+
fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GetUser(_Base):
|
|
56
|
+
name: str = "x_get_user"
|
|
57
|
+
description: str = (
|
|
58
|
+
"Public profile of one X (Twitter) user by handle: bio, counts, verified state, "
|
|
59
|
+
"created date. Costs 1 credit."
|
|
60
|
+
)
|
|
61
|
+
args_schema: type[BaseModel] = _UserArgs
|
|
62
|
+
|
|
63
|
+
def _call(self, handle: str, fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
|
|
64
|
+
return f"/v1/users/{_handle(handle)}", {"fresh": fresh}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _UsersArgs(BaseModel):
|
|
68
|
+
handles: Sequence[str] = Field(description="Up to 100 handles in one call.")
|
|
69
|
+
fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class GetUsers(_Base):
|
|
73
|
+
name: str = "x_get_users"
|
|
74
|
+
description: str = (
|
|
75
|
+
"Profiles of up to 100 X users in ONE call. Prefer this over repeated single reads: "
|
|
76
|
+
"it is one request and one rate-limit slot. Costs 1 credit per profile found; "
|
|
77
|
+
"handles that do not exist are listed in errors and cost nothing."
|
|
78
|
+
)
|
|
79
|
+
args_schema: type[BaseModel] = _UsersArgs
|
|
80
|
+
|
|
81
|
+
def _call(self, handles: Sequence[str], fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
|
|
82
|
+
return "/v1/users/batch", {"handles": ",".join(_handle(h) for h in handles), "fresh": fresh}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _PageArgs(BaseModel):
|
|
86
|
+
user: str = Field(description="X handle (with or without @) or numeric user id.")
|
|
87
|
+
count: int | None = Field(default=None, description="Results on this page. 1 to 50.")
|
|
88
|
+
cursor: str | None = Field(default=None, description="next_cursor from the previous page.")
|
|
89
|
+
fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GetUserTweets(_Base):
|
|
93
|
+
name: str = "x_get_user_tweets"
|
|
94
|
+
description: str = (
|
|
95
|
+
"Latest posts of one X user, newest first. Pages with next_cursor. Costs 1 credit per post."
|
|
96
|
+
)
|
|
97
|
+
args_schema: type[BaseModel] = _PageArgs
|
|
98
|
+
|
|
99
|
+
def _call(
|
|
100
|
+
self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
101
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
102
|
+
return f"/v1/users/{_handle(user)}/tweets", {
|
|
103
|
+
"count": self.client.cap(count),
|
|
104
|
+
"cursor": cursor,
|
|
105
|
+
"fresh": fresh,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class GetFollowers(_Base):
|
|
110
|
+
name: str = "x_get_followers"
|
|
111
|
+
description: str = (
|
|
112
|
+
"Followers of an X user, as full profiles. Costs 0.1 credit per profile. "
|
|
113
|
+
"Some accounts refuse this read; a 403 is free. Use x_get_follower_ids when ids are enough."
|
|
114
|
+
)
|
|
115
|
+
args_schema: type[BaseModel] = _PageArgs
|
|
116
|
+
|
|
117
|
+
def _call(
|
|
118
|
+
self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
119
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
120
|
+
return f"/v1/users/{_handle(user)}/followers", {
|
|
121
|
+
"count": self.client.cap(count),
|
|
122
|
+
"cursor": cursor,
|
|
123
|
+
"fresh": fresh,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class GetFollowerIds(_Base):
|
|
128
|
+
name: str = "x_get_follower_ids"
|
|
129
|
+
description: str = (
|
|
130
|
+
"Numeric ids of an X user's followers, without the profiles. Costs 0.02 credit per id, "
|
|
131
|
+
"five times less than x_get_followers. Use it to size or intersect an audience."
|
|
132
|
+
)
|
|
133
|
+
args_schema: type[BaseModel] = _PageArgs
|
|
134
|
+
|
|
135
|
+
def _call(
|
|
136
|
+
self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
137
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
138
|
+
return f"/v1/users/{_handle(user)}/followers/ids", {
|
|
139
|
+
"count": self.client.cap(count),
|
|
140
|
+
"cursor": cursor,
|
|
141
|
+
"fresh": fresh,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class GetFollowing(_Base):
|
|
146
|
+
name: str = "x_get_following"
|
|
147
|
+
description: str = (
|
|
148
|
+
"Accounts an X user follows. Costs 0.1 credit per profile. Some accounts refuse this read; "
|
|
149
|
+
"a 403 is free."
|
|
150
|
+
)
|
|
151
|
+
args_schema: type[BaseModel] = _PageArgs
|
|
152
|
+
|
|
153
|
+
def _call(
|
|
154
|
+
self, user: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
155
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
156
|
+
return f"/v1/users/{_handle(user)}/following", {
|
|
157
|
+
"count": self.client.cap(count),
|
|
158
|
+
"cursor": cursor,
|
|
159
|
+
"fresh": fresh,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --- tweets --------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _SearchArgs(BaseModel):
|
|
167
|
+
q: str = Field(
|
|
168
|
+
description=(
|
|
169
|
+
"Query in x.com search syntax. Operators: from:handle, to:handle, since:YYYY-MM-DD, "
|
|
170
|
+
'until:YYYY-MM-DD, min_faves:N, lang:xx, "exact phrase", OR, and - to exclude. '
|
|
171
|
+
'Example: from:vercel since:2026-09-01 -filter:replies'
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
product: Literal["Latest", "Top", "People", "Photos", "Videos"] | None = Field(
|
|
175
|
+
default=None,
|
|
176
|
+
description=(
|
|
177
|
+
"Latest (default, always available), Top (may answer 403, free), People (returns "
|
|
178
|
+
"profiles), Photos, Videos."
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
count: int | None = Field(default=None, description="Results on this page. 1 to 50.")
|
|
182
|
+
cursor: str | None = Field(default=None, description="next_cursor from the previous page.")
|
|
183
|
+
fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class SearchTweets(_Base):
|
|
187
|
+
name: str = "x_search_tweets"
|
|
188
|
+
description: str = (
|
|
189
|
+
"Search public X posts with the x.com operators. The main entry point when you do not "
|
|
190
|
+
"already know a handle or a post id. Costs 1 credit per result returned; an empty result "
|
|
191
|
+
"is free. Pages with next_cursor."
|
|
192
|
+
)
|
|
193
|
+
args_schema: type[BaseModel] = _SearchArgs
|
|
194
|
+
|
|
195
|
+
def _call(
|
|
196
|
+
self,
|
|
197
|
+
q: str,
|
|
198
|
+
product: str | None = None,
|
|
199
|
+
count: int | None = None,
|
|
200
|
+
cursor: str | None = None,
|
|
201
|
+
fresh: bool | None = None,
|
|
202
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
203
|
+
return "/v1/tweets/search", {
|
|
204
|
+
"q": q,
|
|
205
|
+
"product": product,
|
|
206
|
+
"count": self.client.cap(count),
|
|
207
|
+
"cursor": cursor,
|
|
208
|
+
"fresh": fresh,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _TweetArgs(BaseModel):
|
|
213
|
+
id: str = Field(description="Numeric post id, as a string.")
|
|
214
|
+
fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class GetTweet(_Base):
|
|
218
|
+
name: str = "x_get_tweet"
|
|
219
|
+
description: str = "One X post by numeric id, with its author. Costs 1 credit."
|
|
220
|
+
args_schema: type[BaseModel] = _TweetArgs
|
|
221
|
+
|
|
222
|
+
def _call(self, id: str, fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
|
|
223
|
+
return f"/v1/tweets/{id}", {"fresh": fresh}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _TweetsArgs(BaseModel):
|
|
227
|
+
ids: Sequence[str] = Field(description="Up to 100 numeric post ids in one call.")
|
|
228
|
+
fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class GetTweets(_Base):
|
|
232
|
+
name: str = "x_get_tweets"
|
|
233
|
+
description: str = (
|
|
234
|
+
"Up to 100 X posts by id in ONE call. Prefer this over repeated single reads. "
|
|
235
|
+
"Costs 1 credit per post found; deleted or private posts are listed in errors and cost nothing."
|
|
236
|
+
)
|
|
237
|
+
args_schema: type[BaseModel] = _TweetsArgs
|
|
238
|
+
|
|
239
|
+
def _call(self, ids: Sequence[str], fresh: bool | None = None) -> tuple[str, dict[str, Any] | None]:
|
|
240
|
+
return "/v1/tweets/batch", {"ids": ",".join(str(i) for i in ids), "fresh": fresh}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class _ThreadArgs(BaseModel):
|
|
244
|
+
id: str = Field(description="Numeric post id, as a string.")
|
|
245
|
+
cursor: str | None = Field(default=None, description="next_cursor from the previous page.")
|
|
246
|
+
fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class GetThread(_Base):
|
|
250
|
+
name: str = "x_get_thread"
|
|
251
|
+
description: str = (
|
|
252
|
+
"One X post with its thread and replies, the focal post first. Use it to read a whole "
|
|
253
|
+
"conversation in one call. Costs 1 credit per post returned."
|
|
254
|
+
)
|
|
255
|
+
args_schema: type[BaseModel] = _ThreadArgs
|
|
256
|
+
|
|
257
|
+
def _call(
|
|
258
|
+
self, id: str, cursor: str | None = None, fresh: bool | None = None
|
|
259
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
260
|
+
return f"/v1/tweets/{id}/thread", {"cursor": cursor, "fresh": fresh}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class GetReplies(_Base):
|
|
264
|
+
name: str = "x_get_replies"
|
|
265
|
+
description: str = "Direct replies to one X post, one page. Costs 1 credit per reply."
|
|
266
|
+
args_schema: type[BaseModel] = _ThreadArgs
|
|
267
|
+
|
|
268
|
+
def _call(
|
|
269
|
+
self, id: str, cursor: str | None = None, fresh: bool | None = None
|
|
270
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
271
|
+
return f"/v1/tweets/{id}/replies", {"cursor": cursor, "fresh": fresh}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class _CountedTweetArgs(BaseModel):
|
|
275
|
+
id: str = Field(description="Numeric post id, as a string.")
|
|
276
|
+
count: int | None = Field(default=None, description="Results on this page. 1 to 50.")
|
|
277
|
+
cursor: str | None = Field(default=None, description="next_cursor from the previous page.")
|
|
278
|
+
fresh: bool | None = Field(default=None, description="Skip the cache. Costs double.")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class GetQuotes(_Base):
|
|
282
|
+
name: str = "x_get_quotes"
|
|
283
|
+
description: str = "Posts that quote one X post, newest first. Costs 1 credit per post."
|
|
284
|
+
args_schema: type[BaseModel] = _CountedTweetArgs
|
|
285
|
+
|
|
286
|
+
def _call(
|
|
287
|
+
self, id: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
288
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
289
|
+
return f"/v1/tweets/{id}/quotes", {
|
|
290
|
+
"count": self.client.cap(count),
|
|
291
|
+
"cursor": cursor,
|
|
292
|
+
"fresh": fresh,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class GetRetweeters(_Base):
|
|
297
|
+
name: str = "x_get_retweeters"
|
|
298
|
+
description: str = (
|
|
299
|
+
"Accounts that reposted one X post. Costs 0.5 credit per profile. Some accounts refuse "
|
|
300
|
+
"this read; a 403 is free."
|
|
301
|
+
)
|
|
302
|
+
args_schema: type[BaseModel] = _CountedTweetArgs
|
|
303
|
+
|
|
304
|
+
def _call(
|
|
305
|
+
self, id: str, count: int | None = None, cursor: str | None = None, fresh: bool | None = None
|
|
306
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
307
|
+
return f"/v1/tweets/{id}/retweeters", {
|
|
308
|
+
"count": self.client.cap(count),
|
|
309
|
+
"cursor": cursor,
|
|
310
|
+
"fresh": fresh,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# --- account -------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class _NoArgs(BaseModel):
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class GetBalance(_Base):
|
|
322
|
+
name: str = "x_get_balance"
|
|
323
|
+
description: str = (
|
|
324
|
+
"Credits left on this key and its rate limit. Free. Call it before a large read to check "
|
|
325
|
+
"the budget covers the plan."
|
|
326
|
+
)
|
|
327
|
+
args_schema: type[BaseModel] = _NoArgs
|
|
328
|
+
|
|
329
|
+
def _call(self) -> tuple[str, dict[str, Any] | None]:
|
|
330
|
+
return "/v1/me", None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
ALL_TOOLS: tuple[type[_Base], ...] = (
|
|
334
|
+
SearchTweets,
|
|
335
|
+
GetUser,
|
|
336
|
+
GetUsers,
|
|
337
|
+
GetUserTweets,
|
|
338
|
+
GetTweet,
|
|
339
|
+
GetTweets,
|
|
340
|
+
GetThread,
|
|
341
|
+
GetReplies,
|
|
342
|
+
GetQuotes,
|
|
343
|
+
GetRetweeters,
|
|
344
|
+
GetFollowers,
|
|
345
|
+
GetFollowerIds,
|
|
346
|
+
GetFollowing,
|
|
347
|
+
GetBalance,
|
|
348
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-xdataapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain tools for xdataapi.io: read-only public X (Twitter) profiles, tweets, threads, followers and search.
|
|
5
|
+
Project-URL: Homepage, https://xdataapi.io
|
|
6
|
+
Project-URL: Documentation, https://xdataapi.io/docs
|
|
7
|
+
Project-URL: API reference, https://xdataapi.io/reference
|
|
8
|
+
Project-URL: Source code, https://github.com/xdataapi/xdataapi
|
|
9
|
+
Project-URL: Issue tracker, https://github.com/xdataapi/xdataapi/issues
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,langchain,langgraph,llm,mcp,tools,twitter,twitter-api,x,x-api,xdataapi
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx<0.29.0,>=0.27.0
|
|
19
|
+
Requires-Dist: langchain-core<2.0.0,>=0.3.0
|
|
20
|
+
Requires-Dist: pydantic<3.0.0,>=2.7.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# langchain-xdataapi
|
|
24
|
+
|
|
25
|
+
LangChain tools for [xdataapi.io](https://xdataapi.io): read-only public X (Twitter) data.
|
|
26
|
+
Profiles, posts, threads, replies, quotes, reposters, followers, and search with the x.com operators.
|
|
27
|
+
|
|
28
|
+
Read-only by design. No posting, no likes, no direct messages, no private data.
|
|
29
|
+
Not affiliated with X Corp.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install langchain-xdataapi
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Use
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from langchain_xdataapi import XdataapiToolkit
|
|
39
|
+
|
|
40
|
+
# Reads XDATAAPI_KEY from the environment.
|
|
41
|
+
tools = XdataapiToolkit.from_api_key().get_tools()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
With an agent:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from langchain.agents import create_agent
|
|
48
|
+
from langchain_xdataapi import XdataapiToolkit
|
|
49
|
+
|
|
50
|
+
agent = create_agent(
|
|
51
|
+
"anthropic:claude-opus-5",
|
|
52
|
+
tools=XdataapiToolkit.from_api_key(max_results=25).get_tools(),
|
|
53
|
+
)
|
|
54
|
+
result = agent.invoke({"messages": [{"role": "user", "content": "What is @vercel posting about this week?"}]})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
One tool on its own:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from langchain_xdataapi import SearchTweets, XdataapiClient
|
|
61
|
+
|
|
62
|
+
search = SearchTweets(client=XdataapiClient())
|
|
63
|
+
print(search.invoke({"q": "from:vercel since:2026-09-01", "count": 10}))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Tools
|
|
67
|
+
|
|
68
|
+
| Tool | Reads | Credits |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `x_search_tweets` | Search with the x.com operators | 1 per result |
|
|
71
|
+
| `x_get_user` | One profile | 1 |
|
|
72
|
+
| `x_get_users` | Up to 100 profiles in one call | 1 per profile |
|
|
73
|
+
| `x_get_user_tweets` | Latest posts of a user | 1 per post |
|
|
74
|
+
| `x_get_tweet` | One post | 1 |
|
|
75
|
+
| `x_get_tweets` | Up to 100 posts in one call | 1 per post |
|
|
76
|
+
| `x_get_thread` | A post with its thread and replies | 1 per post |
|
|
77
|
+
| `x_get_replies` | Direct replies to a post | 1 per reply |
|
|
78
|
+
| `x_get_quotes` | Posts that quote a post | 1 per post |
|
|
79
|
+
| `x_get_retweeters` | Accounts that reposted a post | 0.5 per profile |
|
|
80
|
+
| `x_get_followers` | Followers, as profiles | 0.1 per profile |
|
|
81
|
+
| `x_get_follower_ids` | Follower ids only | 0.02 per id |
|
|
82
|
+
| `x_get_following` | Accounts a user follows | 0.1 per profile |
|
|
83
|
+
| `x_get_balance` | Credits left and rate limit | free |
|
|
84
|
+
|
|
85
|
+
Empty results and failed requests cost nothing. A repeated read inside the cache window costs half.
|
|
86
|
+
Every response carries `credits_charged` and `balance_remaining`, so the model can see the budget shrink
|
|
87
|
+
and stop on its own.
|
|
88
|
+
|
|
89
|
+
## Spending guards
|
|
90
|
+
|
|
91
|
+
`max_results` caps the page size of every paging tool, so one agent step cannot ask for more than you allow:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
toolkit = XdataapiToolkit.from_api_key(max_results=25)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Narrow the surface with `include` or `exclude`:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
toolkit = XdataapiToolkit.from_api_key(include=["x_search_tweets", "x_get_user", "x_get_balance"])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Errors
|
|
104
|
+
|
|
105
|
+
A failure the model can act on — no credits, not found, a search product X refused — comes back as JSON
|
|
106
|
+
with `error.code`, so the model can change plan. None of those are charged.
|
|
107
|
+
A missing or revoked key raises `XdataapiAuthError`, because no model can repair it.
|
|
108
|
+
Rate limits and upstream faults raise `XdataapiError`, so your framework's retry policy sees them.
|
|
109
|
+
|
|
110
|
+
## MCP instead
|
|
111
|
+
|
|
112
|
+
An agent that speaks MCP needs no package. Connect the hosted server:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
claude mcp add --transport http xdataapi https://api.xdataapi.io/mcp
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It signs in with OAuth on first use. It is on the official MCP Registry as `io.xdataapi/xdataapi`.
|
|
119
|
+
|
|
120
|
+
## Keys and pricing
|
|
121
|
+
|
|
122
|
+
Get a key at [xdataapi.io/dashboard](https://xdataapi.io/dashboard). 5,000 credits to start, no card.
|
|
123
|
+
Prepaid packs from $10; credits never expire. Live status: [xdataapi.io/status](https://xdataapi.io/status).
|
|
124
|
+
|
|
125
|
+
MIT licensed.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
langchain_xdataapi/__init__.py,sha256=HLnmTon5HpYn84Bob43gKq_ioJ2p6v0Tfup39nzHR4g,1093
|
|
2
|
+
langchain_xdataapi/client.py,sha256=KtF0vuJY3tqIva1I8wpZ00pC4-nFWg4LDITW2TZ583o,5291
|
|
3
|
+
langchain_xdataapi/toolkit.py,sha256=i9-Z9HEdF6lcwRC1m8f3oqvPBTI5-jmoWMq_7iN355o,2069
|
|
4
|
+
langchain_xdataapi/tools.py,sha256=i8asFjFd6rpKf9s2JRVUXlwARIrM0KOQF-yRYLkyIYU,12350
|
|
5
|
+
langchain_xdataapi-0.1.0.dist-info/METADATA,sha256=UBMXUfvs7YPYcMx2Z_SPdFNr2NSjBbCHg0xdGWmDp5Y,4478
|
|
6
|
+
langchain_xdataapi-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
langchain_xdataapi-0.1.0.dist-info/licenses/LICENSE,sha256=ofNnkOMu8ExGLAsm0rkR0RBN1EaDBv7WJBkKzovqK4o,1068
|
|
8
|
+
langchain_xdataapi-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xdataapi.io
|
|
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.
|