x2llm 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.
- x2llm/__init__.py +5 -0
- x2llm/__main__.py +3 -0
- x2llm/cli.py +145 -0
- x2llm/client.py +133 -0
- x2llm/config.py +73 -0
- x2llm/cores.py +218 -0
- x2llm/endpoints.py +6 -0
- x2llm/mcp.py +173 -0
- x2llm/parse.py +120 -0
- x2llm-0.1.0.dist-info/METADATA +8 -0
- x2llm-0.1.0.dist-info/RECORD +13 -0
- x2llm-0.1.0.dist-info/WHEEL +4 -0
- x2llm-0.1.0.dist-info/entry_points.txt +2 -0
x2llm/__init__.py
ADDED
x2llm/__main__.py
ADDED
x2llm/cli.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Click CLI. Every command prints one JSON document; errors print {"error": ...} and exit 1."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import cores
|
|
11
|
+
from .client import XApiError, XClient
|
|
12
|
+
from .config import Config, ConfigError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def out(obj) -> None:
|
|
16
|
+
print(json.dumps(obj, default=str, ensure_ascii=False, indent=1))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _run(make_coro: Callable[[XClient], Awaitable]) -> object:
|
|
20
|
+
config = Config.from_env()
|
|
21
|
+
client = XClient(config)
|
|
22
|
+
try:
|
|
23
|
+
return await make_coro(client)
|
|
24
|
+
finally:
|
|
25
|
+
await client.aclose()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def invoke(make_coro) -> None:
|
|
29
|
+
try:
|
|
30
|
+
result = asyncio.run(_run(make_coro))
|
|
31
|
+
except (ConfigError, XApiError) as e:
|
|
32
|
+
out({"error": str(e)})
|
|
33
|
+
raise SystemExit(1) from None
|
|
34
|
+
out(result)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group()
|
|
38
|
+
@click.version_option()
|
|
39
|
+
def cli() -> None:
|
|
40
|
+
"""x2llm - read-only x.com (Twitter) API client for agents. JSON out."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@cli.command()
|
|
44
|
+
@click.option("--limit", type=int, default=20, show_default=True)
|
|
45
|
+
@click.option("--cursor", default=None, help="Pagination cursor from a previous page.")
|
|
46
|
+
@click.option("--tab", type=click.Choice(["for_you", "following"]), default="for_you", show_default=True)
|
|
47
|
+
def home(limit: int, cursor: str | None, tab: str) -> None:
|
|
48
|
+
"""Your home timeline (for_you or following); scroll with the returned cursor."""
|
|
49
|
+
invoke(lambda c: cores.home(c, limit=limit, cursor=cursor, tab=tab))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.command()
|
|
53
|
+
def badges() -> None:
|
|
54
|
+
"""Unread notification/DM counts. Cheapest auth check."""
|
|
55
|
+
invoke(lambda c: cores.badges(c))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@cli.command()
|
|
59
|
+
@click.argument("screen_name")
|
|
60
|
+
def user(screen_name: str) -> None:
|
|
61
|
+
"""Profile of a user by screen name: bio, follower counts, verification."""
|
|
62
|
+
invoke(lambda c: cores.user(c, screen_name))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@cli.command()
|
|
66
|
+
def me() -> None:
|
|
67
|
+
"""Viewer identity + follower counts."""
|
|
68
|
+
invoke(lambda c: cores.me(c))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@cli.command()
|
|
72
|
+
@click.argument("query")
|
|
73
|
+
def suggest(query: str) -> None:
|
|
74
|
+
"""Search typeahead: topic and user suggestions for a prefix."""
|
|
75
|
+
invoke(lambda c: cores.suggest(c, query))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@cli.command()
|
|
79
|
+
@click.argument("query")
|
|
80
|
+
@click.option("--limit", type=int, default=20, show_default=True)
|
|
81
|
+
@click.option("--product", type=click.Choice(["Top", "Latest", "Media"]), default="Top", show_default=True)
|
|
82
|
+
@click.option("--cursor", default=None, help="Pagination cursor from a previous page.")
|
|
83
|
+
def search(query: str, limit: int, product: str, cursor: str | None) -> None:
|
|
84
|
+
"""Full search. Supports operators: from:user, since:, lang:, min_faves: ..."""
|
|
85
|
+
invoke(lambda c: cores.search(c, query, limit=limit, product=product, cursor=cursor))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@cli.command()
|
|
89
|
+
@click.argument("tweet_id")
|
|
90
|
+
@click.option("--cursor", default=None, help="Pagination cursor for more replies.")
|
|
91
|
+
def tweet(tweet_id: str, cursor: str | None) -> None:
|
|
92
|
+
"""One tweet plus its reply thread."""
|
|
93
|
+
invoke(lambda c: cores.tweet(c, tweet_id, cursor=cursor))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@cli.command()
|
|
97
|
+
@click.option("--limit", type=int, default=20, show_default=True)
|
|
98
|
+
@click.option("--cursor", default=None, help="Pagination cursor from a previous page.")
|
|
99
|
+
def notifications(limit: int, cursor: str | None) -> None:
|
|
100
|
+
"""Your notification tab."""
|
|
101
|
+
invoke(lambda c: cores.notifications(c, limit=limit, cursor=cursor))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@cli.command()
|
|
105
|
+
@click.option("--days", type=int, default=7, show_default=True, help="Window length (1-28).")
|
|
106
|
+
def analytics(days: int) -> None:
|
|
107
|
+
"""Your engagement statistics (impressions, likes, follows ...)."""
|
|
108
|
+
invoke(lambda c: cores.analytics(c, days=days))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@cli.command()
|
|
112
|
+
@click.argument("user")
|
|
113
|
+
@click.option("--limit", type=int, default=20, show_default=True)
|
|
114
|
+
@click.option("--cursor", default=None, help="Pagination cursor from a previous page.")
|
|
115
|
+
def following(user: str, limit: int, cursor: str | None) -> None:
|
|
116
|
+
"""Accounts USER follows. USER is a numeric id or a screen name."""
|
|
117
|
+
invoke(lambda c: cores.following(c, user, limit=limit, cursor=cursor))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@cli.command()
|
|
121
|
+
def mcp() -> None:
|
|
122
|
+
"""Run as a stdio MCP server (same tool surface, read-only)."""
|
|
123
|
+
from .mcp import serve
|
|
124
|
+
|
|
125
|
+
asyncio.run(serve())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv: list[str] | None = None) -> int:
|
|
129
|
+
try:
|
|
130
|
+
cli(args=argv, standalone_mode=True)
|
|
131
|
+
except click.exceptions.Abort:
|
|
132
|
+
return 130
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@cli.command()
|
|
138
|
+
@click.argument("tweet_id")
|
|
139
|
+
def like(tweet_id: str) -> None:
|
|
140
|
+
"""Like a tweet. REAL action: publicly visible, notifies the author."""
|
|
141
|
+
invoke(lambda c: cores.like(c, tweet_id))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
raise SystemExit(main())
|
x2llm/client.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""XClient: the only module that knows how x.com RPC works.
|
|
2
|
+
|
|
3
|
+
Everything above this layer deals in plain dicts. Three primitives:
|
|
4
|
+
|
|
5
|
+
- graphql(key, variables) -> dict (GET /i/api/graphql/<queryId>/<Op>)
|
|
6
|
+
- graphql_post(key, variables) -> dict (POST /i/api/graphql/... — feeds + writes)
|
|
7
|
+
- rest(path, params) -> dict (GET /i/api/1.1/... legacy REST)
|
|
8
|
+
|
|
9
|
+
Auth = public web bearer + cookie session (auth_token + ct0) + x-csrf-token
|
|
10
|
+
mirroring ct0. Some endpoints additionally validate x-client-transaction-id
|
|
11
|
+
(per operation in the X_TXIDS env var, path-bound, expire server-side).
|
|
12
|
+
|
|
13
|
+
A client-side rate limiter spaces requests at most X_RATE_LIMIT per minute
|
|
14
|
+
(default 30, 0 disables) so the tool stays inside power-user territory.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from .config import Config
|
|
25
|
+
from .endpoints import GRAPHQL
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class XApiError(Exception):
|
|
29
|
+
def __init__(self, message: str, status: int | None = None):
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status = status
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class XClient:
|
|
35
|
+
def __init__(self, config: Config, transport: httpx.AsyncBaseTransport | None = None):
|
|
36
|
+
self.config = config
|
|
37
|
+
self._http = httpx.AsyncClient(timeout=config.timeout, transport=transport)
|
|
38
|
+
self._min_interval = 60.0 / config.rate_per_min if config.rate_per_min > 0 else 0.0
|
|
39
|
+
self._rl_lock = asyncio.Lock()
|
|
40
|
+
self._next_slot = 0.0
|
|
41
|
+
|
|
42
|
+
async def aclose(self) -> None:
|
|
43
|
+
await self._http.aclose()
|
|
44
|
+
|
|
45
|
+
async def graphql(self, key: str, variables: dict) -> dict:
|
|
46
|
+
"""Read: GET with variables (+ per-endpoint features/fieldToggles) in the query string."""
|
|
47
|
+
ep = GRAPHQL[key]
|
|
48
|
+
params = {"variables": json.dumps(variables, separators=(",", ":"))}
|
|
49
|
+
if ep.get("features"):
|
|
50
|
+
params["features"] = json.dumps(ep["features"], separators=(",", ":"))
|
|
51
|
+
if ep.get("toggles"):
|
|
52
|
+
params["fieldToggles"] = json.dumps(ep["toggles"], separators=(",", ":"))
|
|
53
|
+
await self._throttle()
|
|
54
|
+
response = await self._http.get(
|
|
55
|
+
f"{self.config.api_base}/graphql/{ep['query_id']}/{ep['name']}",
|
|
56
|
+
params=params,
|
|
57
|
+
headers=self._headers(self.config.txids.get(key)),
|
|
58
|
+
)
|
|
59
|
+
return self._json(response, ep["name"])
|
|
60
|
+
|
|
61
|
+
async def graphql_post(self, key: str, variables: dict) -> dict:
|
|
62
|
+
"""POST GraphQL: body carries variables+queryId (+features/fieldToggles
|
|
63
|
+
from the registry). Used for POST-fed timelines (home) and the like write."""
|
|
64
|
+
ep = GRAPHQL[key]
|
|
65
|
+
body = {"variables": variables, "queryId": ep["query_id"]}
|
|
66
|
+
if ep.get("features"):
|
|
67
|
+
body["features"] = ep["features"]
|
|
68
|
+
if ep.get("toggles"):
|
|
69
|
+
body["fieldToggles"] = ep["toggles"]
|
|
70
|
+
await self._throttle()
|
|
71
|
+
response = await self._http.post(
|
|
72
|
+
f"{self.config.api_base}/graphql/{ep['query_id']}/{ep['name']}",
|
|
73
|
+
json=body,
|
|
74
|
+
headers=self._headers(self.config.txids.get(key)),
|
|
75
|
+
)
|
|
76
|
+
return self._json(response, ep["name"])
|
|
77
|
+
|
|
78
|
+
async def rest(self, path: str, params: dict) -> dict:
|
|
79
|
+
await self._throttle()
|
|
80
|
+
response = await self._http.get(
|
|
81
|
+
f"{self.config.api_base}{path}", params=params, headers=self._headers(None)
|
|
82
|
+
)
|
|
83
|
+
return self._json(response, path)
|
|
84
|
+
|
|
85
|
+
async def _throttle(self) -> None:
|
|
86
|
+
if not self._min_interval:
|
|
87
|
+
return
|
|
88
|
+
async with self._rl_lock:
|
|
89
|
+
now = time.monotonic()
|
|
90
|
+
wait = self._next_slot - now
|
|
91
|
+
if wait > 0:
|
|
92
|
+
await asyncio.sleep(wait)
|
|
93
|
+
now = time.monotonic()
|
|
94
|
+
self._next_slot = now + self._min_interval
|
|
95
|
+
|
|
96
|
+
def _headers(self, txid: str | None) -> dict[str, str]:
|
|
97
|
+
headers = {
|
|
98
|
+
"authorization": self.config.bearer,
|
|
99
|
+
"cookie": self.config.cookie_header,
|
|
100
|
+
"x-csrf-token": self.config.ct0,
|
|
101
|
+
"x-twitter-auth-type": "OAuth2Session",
|
|
102
|
+
"x-twitter-active-user": "yes",
|
|
103
|
+
"x-twitter-client-language": "en",
|
|
104
|
+
"user-agent": self.config.user_agent,
|
|
105
|
+
"accept": "*/*",
|
|
106
|
+
}
|
|
107
|
+
if txid:
|
|
108
|
+
headers["x-client-transaction-id"] = txid
|
|
109
|
+
return headers
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _json(response: httpx.Response, what: str) -> dict:
|
|
113
|
+
if response.status_code != 200:
|
|
114
|
+
hint = ""
|
|
115
|
+
if response.status_code == 404 and not response.content:
|
|
116
|
+
hint = (
|
|
117
|
+
" | hint: empty 404 usually means this endpoint's "
|
|
118
|
+
"x-client-transaction-id expired - refresh X_TXIDS from a "
|
|
119
|
+
"fresh capture (README - Refreshing captured tokens)"
|
|
120
|
+
)
|
|
121
|
+
elif response.status_code in (401, 403):
|
|
122
|
+
hint = (
|
|
123
|
+
" | hint: credentials rejected - check X_AUTH_TOKEN / X_CT0, "
|
|
124
|
+
"or set X_COOKIE to the full browser Cookie header "
|
|
125
|
+
"(incl. cf_clearance)"
|
|
126
|
+
)
|
|
127
|
+
elif response.status_code == 429:
|
|
128
|
+
hint = " | hint: rate limited by x.com - slow down or raise the wait"
|
|
129
|
+
raise XApiError(
|
|
130
|
+
f"{what}: HTTP {response.status_code} {response.text[:200]}{hint}",
|
|
131
|
+
status=response.status_code,
|
|
132
|
+
)
|
|
133
|
+
return response.json()
|
x2llm/config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Environment-driven configuration (12-factor: all config from the env)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from .endpoints import BEARER
|
|
10
|
+
|
|
11
|
+
DEFAULT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"
|
|
12
|
+
REQUIRED = ("X_AUTH_TOKEN", "X_CT0")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_txids(raw: str | None) -> dict[str, str]:
|
|
20
|
+
if not raw:
|
|
21
|
+
return {}
|
|
22
|
+
raw = raw.strip()
|
|
23
|
+
if len(raw) > 1 and raw[0] == raw[-1] and raw[0] in "'\"":
|
|
24
|
+
raw = raw[1:-1] # tolerate quoting from `source .env` / make -include
|
|
25
|
+
try:
|
|
26
|
+
parsed = json.loads(raw)
|
|
27
|
+
except ValueError as e:
|
|
28
|
+
raise ConfigError(f"X_TXIDS is not valid JSON: {e}") from e
|
|
29
|
+
if not isinstance(parsed, dict) or not all(
|
|
30
|
+
isinstance(k, str) and isinstance(v, str) for k, v in parsed.items()
|
|
31
|
+
):
|
|
32
|
+
raise ConfigError("X_TXIDS must be a JSON object of {operation: transaction-id}")
|
|
33
|
+
return parsed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Config:
|
|
38
|
+
auth_token: str
|
|
39
|
+
ct0: str
|
|
40
|
+
cookie: str | None
|
|
41
|
+
bearer: str
|
|
42
|
+
user_agent: str
|
|
43
|
+
api_base: str
|
|
44
|
+
timeout: float
|
|
45
|
+
rate_per_min: int
|
|
46
|
+
txids: dict[str, str]
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> Config:
|
|
50
|
+
env = os.environ if env is None else env
|
|
51
|
+
missing = [k for k in REQUIRED if not env.get(k)]
|
|
52
|
+
if missing:
|
|
53
|
+
raise ConfigError(
|
|
54
|
+
f"missing environment variables: {', '.join(missing)} "
|
|
55
|
+
"(see README - Credentials)"
|
|
56
|
+
)
|
|
57
|
+
return cls(
|
|
58
|
+
auth_token=env["X_AUTH_TOKEN"],
|
|
59
|
+
ct0=env["X_CT0"],
|
|
60
|
+
cookie=env.get("X_COOKIE") or None,
|
|
61
|
+
bearer=env.get("X_BEARER") or BEARER,
|
|
62
|
+
user_agent=env.get("X_USER_AGENT") or DEFAULT_USER_AGENT,
|
|
63
|
+
api_base=env.get("X_API_BASE", "https://x.com/i/api"),
|
|
64
|
+
timeout=float(env.get("X_TIMEOUT", "20")),
|
|
65
|
+
rate_per_min=int(env.get("X_RATE_LIMIT", "30")),
|
|
66
|
+
txids=_parse_txids(env.get("X_TXIDS")),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def cookie_header(self) -> str:
|
|
71
|
+
if self.cookie:
|
|
72
|
+
return self.cookie
|
|
73
|
+
return f"auth_token={self.auth_token}; ct0={self.ct0}"
|
x2llm/cores.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Service cores - shared by CLI and MCP server. Reads + the like mutation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
from .client import XApiError
|
|
8
|
+
from .parse import collect_cursors, collect_tweets, notification_rows, user_row
|
|
9
|
+
|
|
10
|
+
DAY_MS = 86_400_000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def badges(c) -> dict:
|
|
14
|
+
data = await c.graphql("badges", {})
|
|
15
|
+
result = data["data"]["viewer_v2"]["user_results"]["result"]
|
|
16
|
+
return {"badges": result.get("badge_counts") or {}}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def suggest(c, query: str, lang: str = "en") -> dict:
|
|
20
|
+
data = await c.rest(
|
|
21
|
+
"/1.1/search/typeahead.json", {"q": query, "src": "search_box", "lang": lang}
|
|
22
|
+
)
|
|
23
|
+
return {
|
|
24
|
+
"topics": [t.get("topic") for t in data.get("topics") or []],
|
|
25
|
+
"users": [
|
|
26
|
+
{
|
|
27
|
+
"id": u.get("id_str"),
|
|
28
|
+
"screen_name": u.get("screen_name"),
|
|
29
|
+
"name": u.get("name"),
|
|
30
|
+
}
|
|
31
|
+
for u in data.get("users") or []
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def search(
|
|
37
|
+
c, query: str, limit: int = 20, product: str = "Top", cursor: str | None = None
|
|
38
|
+
) -> dict:
|
|
39
|
+
variables = {
|
|
40
|
+
"rawQuery": query,
|
|
41
|
+
"count": limit,
|
|
42
|
+
"querySource": "typed_query",
|
|
43
|
+
"product": product,
|
|
44
|
+
"withGrokTranslatedBio": False,
|
|
45
|
+
"withQuickPromoteEligibilityTweetFields": False,
|
|
46
|
+
}
|
|
47
|
+
if cursor:
|
|
48
|
+
variables["cursor"] = cursor
|
|
49
|
+
data = await c.graphql("search", variables)
|
|
50
|
+
return {
|
|
51
|
+
"tweets": collect_tweets(data)[:limit],
|
|
52
|
+
"cursor": collect_cursors(data).get("bottom"),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def tweet(c, tweet_id: str, cursor: str | None = None) -> dict:
|
|
57
|
+
variables = {
|
|
58
|
+
"focalTweetId": str(tweet_id),
|
|
59
|
+
"referrer": "search",
|
|
60
|
+
"with_rux_injections": False,
|
|
61
|
+
"rankingMode": "Relevance",
|
|
62
|
+
"includePromotedContent": True,
|
|
63
|
+
"withCommunity": True,
|
|
64
|
+
"withQuickPromoteEligibilityTweetFields": True,
|
|
65
|
+
"withBirdwatchNotes": True,
|
|
66
|
+
"withVoice": True,
|
|
67
|
+
}
|
|
68
|
+
if cursor:
|
|
69
|
+
variables["cursor"] = cursor
|
|
70
|
+
data = await c.graphql("tweet", variables)
|
|
71
|
+
tweets = collect_tweets(data)
|
|
72
|
+
return {
|
|
73
|
+
"tweet": tweets[0] if tweets else None,
|
|
74
|
+
"replies": tweets[1:],
|
|
75
|
+
"cursor": collect_cursors(data).get("bottom"),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def notifications(c, limit: int = 20, cursor: str | None = None) -> dict:
|
|
80
|
+
variables = {"timeline_type": "All", "count": limit}
|
|
81
|
+
if cursor:
|
|
82
|
+
variables["cursor"] = cursor
|
|
83
|
+
data = await c.graphql("notifications", variables)
|
|
84
|
+
return {
|
|
85
|
+
"notifications": notification_rows(data)[:limit],
|
|
86
|
+
"cursor": collect_cursors(data).get("bottom"),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _analytics_variables(days: int) -> dict:
|
|
91
|
+
now = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
92
|
+
to_ms, span = int(now.timestamp() * 1000), days * DAY_MS
|
|
93
|
+
prev_to_ms = to_ms - span
|
|
94
|
+
|
|
95
|
+
def iso(ms: int) -> str:
|
|
96
|
+
return datetime.fromtimestamp(ms / 1000, tz=UTC).isoformat().replace("+00:00", "Z")
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"current_from": to_ms - span,
|
|
100
|
+
"current_from_iso": iso(to_ms - span),
|
|
101
|
+
"current_to": to_ms,
|
|
102
|
+
"current_to_iso": now.isoformat().replace("+00:00", "Z"),
|
|
103
|
+
"prev_from": prev_to_ms - span,
|
|
104
|
+
"prev_from_iso": iso(prev_to_ms - span),
|
|
105
|
+
"prev_to": prev_to_ms,
|
|
106
|
+
"prev_to_iso": iso(prev_to_ms),
|
|
107
|
+
"backfill_from": to_ms - 2 * DAY_MS,
|
|
108
|
+
"backfill_to": to_ms,
|
|
109
|
+
"show_realtime_active_followers": True,
|
|
110
|
+
"show_verified_followers": True,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _viewer_id(encoded: str) -> str | None:
|
|
115
|
+
try:
|
|
116
|
+
decoded = base64.b64decode(encoded).decode()
|
|
117
|
+
return decoded.split(":")[-1]
|
|
118
|
+
except Exception: # noqa: BLE001 - malformed id must not kill analytics
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def analytics(c, days: int = 7) -> dict:
|
|
123
|
+
days = max(1, min(days, 28))
|
|
124
|
+
data = await c.graphql("analytics", _analytics_variables(days))
|
|
125
|
+
user_results = data["data"]["viewer_v2"]["user_results"]
|
|
126
|
+
result = user_results.get("result") or {}
|
|
127
|
+
series: dict[str, int] = {}
|
|
128
|
+
for point in result.get("current_time_series") or []:
|
|
129
|
+
kind = point.get("engagement_type")
|
|
130
|
+
if kind:
|
|
131
|
+
series[kind] = series.get(kind, 0) + (point.get("count") or 0)
|
|
132
|
+
relationship = result.get("relationship_counts") or {}
|
|
133
|
+
return {
|
|
134
|
+
"viewer": {
|
|
135
|
+
"id": _viewer_id(user_results.get("id") or ""),
|
|
136
|
+
"followers": relationship.get("followers"),
|
|
137
|
+
"verified_followers": result.get("verified_follower_count"),
|
|
138
|
+
"realtime_active_followers": (result.get("realtime_active_followers") or {}).get(
|
|
139
|
+
"active_count"
|
|
140
|
+
),
|
|
141
|
+
},
|
|
142
|
+
"series": dict(sorted(series.items())),
|
|
143
|
+
"window_days": days,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def me(c) -> dict:
|
|
148
|
+
return (await analytics(c, days=1))["viewer"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def following(
|
|
152
|
+
c,
|
|
153
|
+
user: str,
|
|
154
|
+
limit: int = 20,
|
|
155
|
+
cursor: str | None = None,
|
|
156
|
+
) -> dict:
|
|
157
|
+
params: dict[str, str | int] = {"count": limit, "skip_status": "1", "cursor": cursor or "-1"}
|
|
158
|
+
if str(user).isdigit():
|
|
159
|
+
params["user_id"] = user
|
|
160
|
+
else:
|
|
161
|
+
params["screen_name"] = user
|
|
162
|
+
data = await c.rest("/1.1/friends/following/list.json", params)
|
|
163
|
+
users = [
|
|
164
|
+
{
|
|
165
|
+
"id": u.get("id_str"),
|
|
166
|
+
"screen_name": u.get("screen_name"),
|
|
167
|
+
"name": u.get("name"),
|
|
168
|
+
"followers": u.get("followers_count"),
|
|
169
|
+
}
|
|
170
|
+
for u in data.get("users") or []
|
|
171
|
+
]
|
|
172
|
+
next_cursor = data.get("next_cursor")
|
|
173
|
+
return {"users": users, "cursor": str(next_cursor) if next_cursor else None}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def like(c, tweet_id: str) -> dict:
|
|
177
|
+
data = await c.graphql_post("like", {"tweet_id": str(tweet_id)})
|
|
178
|
+
errors = data.get("errors") or []
|
|
179
|
+
if errors:
|
|
180
|
+
return {
|
|
181
|
+
"liked": str(tweet_id),
|
|
182
|
+
"result": "error",
|
|
183
|
+
"detail": errors[0].get("message"),
|
|
184
|
+
}
|
|
185
|
+
return {"liked": str(tweet_id), "result": (data.get("data") or {}).get("favorite_tweet")}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def user(c, screen_name: str) -> dict:
|
|
189
|
+
name = str(screen_name).lstrip("@")
|
|
190
|
+
data = await c.graphql("user", {"screen_name": name, "withGrokTranslatedBio": True})
|
|
191
|
+
result = ((data.get("data") or {}).get("user") or {}).get("result")
|
|
192
|
+
if not result:
|
|
193
|
+
raise XApiError(f"user not found: {name}")
|
|
194
|
+
return user_row(result)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def home(c, limit: int = 20, cursor: str | None = None, tab: str = "for_you") -> dict:
|
|
198
|
+
if tab == "following":
|
|
199
|
+
key, variables = "home_following", {
|
|
200
|
+
"count": limit,
|
|
201
|
+
"enableRanking": True,
|
|
202
|
+
"includePromotedContent": True,
|
|
203
|
+
}
|
|
204
|
+
else:
|
|
205
|
+
key, variables = "home", {
|
|
206
|
+
"count": limit,
|
|
207
|
+
"includePromotedContent": True,
|
|
208
|
+
"withCommunity": True,
|
|
209
|
+
"requestContext": "ptr",
|
|
210
|
+
}
|
|
211
|
+
if cursor:
|
|
212
|
+
variables["cursor"] = cursor
|
|
213
|
+
data = await c.graphql_post(key, variables)
|
|
214
|
+
return {
|
|
215
|
+
"tab": tab,
|
|
216
|
+
"tweets": collect_tweets(data)[:limit],
|
|
217
|
+
"cursor": collect_cursors(data).get("bottom"),
|
|
218
|
+
}
|
x2llm/endpoints.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# GENERATED by scripts/har_to_endpoints.py from browser HAR captures. Do not edit.
|
|
2
|
+
# Public, session-independent constants only (query ids, features, toggles).
|
|
3
|
+
# Session-scoped values (transaction ids) live in the X_TXIDS env var.
|
|
4
|
+
# Regenerate: uv run python scripts/har_to_endpoints.py <archive.har> [...]
|
|
5
|
+
BEARER = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'
|
|
6
|
+
GRAPHQL = {'badges': {'query_id': 'q4Npr1-FYRWyXsRzPckwEA', 'name': 'ViewerBadgeCounts', 'method': 'get'}, 'notifications': {'query_id': '4TuDRWeusve2-BH2IqldBg', 'name': 'NotificationsTimeline', 'method': 'get', 'features': {'rweb_video_screen_enabled': False, 'rweb_cashtags_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True, 'premium_content_api_read_enabled': False, 'communities_web_enable_tweet_community_results_fetch': True, 'c9s_tweet_anatomy_moderator_badge_enabled': True, 'responsive_web_grok_analyze_button_fetch_trends_enabled': False, 'responsive_web_grok_analyze_post_followups_enabled': True, 'rweb_cashtags_composer_attachment_enabled': True, 'responsive_web_jetfuel_frame': True, 'rweb_sports_post_context_enabled': True, 'responsive_web_grok_share_attachment_enabled': True, 'responsive_web_grok_annotations_enabled': True, 'articles_preview_enabled': True, 'responsive_web_edit_tweet_api_enabled': True, 'rweb_conversational_replies_downvote_enabled': False, 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, 'view_counts_everywhere_api_enabled': True, 'longform_notetweets_consumption_enabled': True, 'responsive_web_twitter_article_tweet_consumption_enabled': True, 'content_disclosure_indicator_enabled': True, 'content_disclosure_ai_generated_indicator_enabled': True, 'responsive_web_grok_show_grok_translated_post': True, 'responsive_web_grok_analysis_button_from_backend': True, 'post_ctas_fetch_enabled': False, 'freedom_of_speech_not_reach_fetch_enabled': True, 'standardized_nudges_misinfo': True, 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, 'longform_notetweets_rich_text_read_enabled': True, 'longform_notetweets_inline_media_enabled': False, 'responsive_web_nested_quote_preview_enabled': True, 'responsive_web_grok_image_annotation_enabled': True, 'responsive_web_grok_imagine_annotation_enabled': True, 'responsive_web_grok_community_note_auto_translation_is_enabled': True, 'responsive_web_enhance_cards_enabled': False}}, 'home': {'query_id': 'V0wMxbYBxdrkfmV3kJSyRQ', 'name': 'HomeTimeline', 'method': 'post', 'features': {'rweb_video_screen_enabled': False, 'rweb_cashtags_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True, 'premium_content_api_read_enabled': False, 'communities_web_enable_tweet_community_results_fetch': True, 'c9s_tweet_anatomy_moderator_badge_enabled': True, 'responsive_web_grok_analyze_button_fetch_trends_enabled': False, 'responsive_web_grok_analyze_post_followups_enabled': True, 'rweb_cashtags_composer_attachment_enabled': True, 'responsive_web_jetfuel_frame': True, 'rweb_sports_post_context_enabled': True, 'responsive_web_grok_share_attachment_enabled': True, 'responsive_web_grok_annotations_enabled': True, 'articles_preview_enabled': True, 'responsive_web_edit_tweet_api_enabled': True, 'rweb_conversational_replies_downvote_enabled': False, 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, 'view_counts_everywhere_api_enabled': True, 'longform_notetweets_consumption_enabled': True, 'responsive_web_twitter_article_tweet_consumption_enabled': True, 'content_disclosure_indicator_enabled': True, 'content_disclosure_ai_generated_indicator_enabled': True, 'responsive_web_grok_show_grok_translated_post': True, 'responsive_web_grok_analysis_button_from_backend': True, 'post_ctas_fetch_enabled': False, 'freedom_of_speech_not_reach_fetch_enabled': True, 'standardized_nudges_misinfo': True, 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, 'longform_notetweets_rich_text_read_enabled': True, 'longform_notetweets_inline_media_enabled': False, 'responsive_web_nested_quote_preview_enabled': True, 'responsive_web_grok_image_annotation_enabled': True, 'responsive_web_grok_imagine_annotation_enabled': True, 'responsive_web_grok_community_note_auto_translation_is_enabled': True, 'responsive_web_enhance_cards_enabled': False}, 'toggles': {'withPayments': False}}, 'search': {'query_id': 'ph2fARFabkwfxqmSKQ1OPw', 'name': 'SearchTimeline', 'method': 'get', 'features': {'rweb_video_screen_enabled': False, 'rweb_cashtags_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True, 'premium_content_api_read_enabled': False, 'communities_web_enable_tweet_community_results_fetch': True, 'c9s_tweet_anatomy_moderator_badge_enabled': True, 'responsive_web_grok_analyze_button_fetch_trends_enabled': False, 'responsive_web_grok_analyze_post_followups_enabled': True, 'rweb_cashtags_composer_attachment_enabled': True, 'responsive_web_jetfuel_frame': True, 'rweb_sports_post_context_enabled': True, 'responsive_web_grok_share_attachment_enabled': True, 'responsive_web_grok_annotations_enabled': True, 'articles_preview_enabled': True, 'responsive_web_edit_tweet_api_enabled': True, 'rweb_conversational_replies_downvote_enabled': False, 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, 'view_counts_everywhere_api_enabled': True, 'longform_notetweets_consumption_enabled': True, 'responsive_web_twitter_article_tweet_consumption_enabled': True, 'content_disclosure_indicator_enabled': True, 'content_disclosure_ai_generated_indicator_enabled': True, 'responsive_web_grok_show_grok_translated_post': True, 'responsive_web_grok_analysis_button_from_backend': True, 'post_ctas_fetch_enabled': False, 'freedom_of_speech_not_reach_fetch_enabled': True, 'standardized_nudges_misinfo': True, 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, 'longform_notetweets_rich_text_read_enabled': True, 'longform_notetweets_inline_media_enabled': False, 'responsive_web_nested_quote_preview_enabled': True, 'responsive_web_grok_image_annotation_enabled': True, 'responsive_web_grok_imagine_annotation_enabled': True, 'responsive_web_grok_community_note_auto_translation_is_enabled': True, 'responsive_web_enhance_cards_enabled': False}}, 'tweet': {'query_id': 'z-3ZLa-NQ8Sp09diHkJNBg', 'name': 'TweetDetail', 'method': 'get', 'features': {'rweb_video_screen_enabled': False, 'rweb_cashtags_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True, 'premium_content_api_read_enabled': False, 'communities_web_enable_tweet_community_results_fetch': True, 'c9s_tweet_anatomy_moderator_badge_enabled': True, 'responsive_web_grok_analyze_button_fetch_trends_enabled': False, 'responsive_web_grok_analyze_post_followups_enabled': True, 'rweb_cashtags_composer_attachment_enabled': True, 'responsive_web_jetfuel_frame': True, 'rweb_sports_post_context_enabled': True, 'responsive_web_grok_share_attachment_enabled': True, 'responsive_web_grok_annotations_enabled': True, 'articles_preview_enabled': True, 'responsive_web_edit_tweet_api_enabled': True, 'rweb_conversational_replies_downvote_enabled': False, 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, 'view_counts_everywhere_api_enabled': True, 'longform_notetweets_consumption_enabled': True, 'responsive_web_twitter_article_tweet_consumption_enabled': True, 'content_disclosure_indicator_enabled': True, 'content_disclosure_ai_generated_indicator_enabled': True, 'responsive_web_grok_show_grok_translated_post': True, 'responsive_web_grok_analysis_button_from_backend': True, 'post_ctas_fetch_enabled': False, 'freedom_of_speech_not_reach_fetch_enabled': True, 'standardized_nudges_misinfo': True, 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, 'longform_notetweets_rich_text_read_enabled': True, 'longform_notetweets_inline_media_enabled': False, 'responsive_web_nested_quote_preview_enabled': True, 'responsive_web_grok_image_annotation_enabled': True, 'responsive_web_grok_imagine_annotation_enabled': True, 'responsive_web_grok_community_note_auto_translation_is_enabled': True, 'responsive_web_enhance_cards_enabled': False}, 'toggles': {'withPayments': False, 'withDmBlocks': False, 'withArticleRichContentState': True, 'withArticlePlainText': False, 'withArticleSummaryText': True, 'withArticleVoiceOver': True, 'withGrokAnalyze': False, 'withDisallowedReplyControls': False}}, 'analytics': {'query_id': '2hqAR3h2xhN1cUrUBZypyg', 'name': 'accountOverviewDailyQuery', 'method': 'get'}, 'like': {'query_id': 'lI07N6Otwv1PhnEgXILM7A', 'name': 'FavoriteTweet', 'method': 'post'}, 'user': {'query_id': 'AMIBMjtxEEATh4z8V9GtRg', 'name': 'UserByScreenName', 'method': 'get', 'features': {'hidden_profile_subscriptions_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'subscriptions_verification_info_is_identity_verified_enabled': True, 'subscriptions_verification_info_verified_since_enabled': True, 'highlights_tweets_tab_ui_enabled': True, 'responsive_web_twitter_article_notes_tab_enabled': True, 'subscriptions_feature_can_gift_premium': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True}, 'toggles': {'withPayments': False, 'withAuxiliaryUserLabels': True}}, 'home_following': {'query_id': '5URyiXQyz6_8NZnoV37OVQ', 'name': 'HomeLatestTimeline', 'method': 'post', 'features': {'rweb_video_screen_enabled': False, 'rweb_cashtags_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True, 'premium_content_api_read_enabled': False, 'communities_web_enable_tweet_community_results_fetch': True, 'c9s_tweet_anatomy_moderator_badge_enabled': True, 'responsive_web_grok_analyze_button_fetch_trends_enabled': False, 'responsive_web_grok_analyze_post_followups_enabled': True, 'rweb_cashtags_composer_attachment_enabled': True, 'responsive_web_jetfuel_frame': True, 'rweb_sports_post_context_enabled': True, 'responsive_web_grok_share_attachment_enabled': True, 'responsive_web_grok_annotations_enabled': True, 'articles_preview_enabled': True, 'responsive_web_edit_tweet_api_enabled': True, 'rweb_conversational_replies_downvote_enabled': False, 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, 'view_counts_everywhere_api_enabled': True, 'longform_notetweets_consumption_enabled': True, 'responsive_web_twitter_article_tweet_consumption_enabled': True, 'content_disclosure_indicator_enabled': True, 'content_disclosure_ai_generated_indicator_enabled': True, 'responsive_web_grok_show_grok_translated_post': True, 'responsive_web_grok_analysis_button_from_backend': True, 'post_ctas_fetch_enabled': False, 'freedom_of_speech_not_reach_fetch_enabled': True, 'standardized_nudges_misinfo': True, 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, 'longform_notetweets_rich_text_read_enabled': True, 'longform_notetweets_inline_media_enabled': False, 'responsive_web_nested_quote_preview_enabled': True, 'responsive_web_grok_image_annotation_enabled': True, 'responsive_web_grok_imagine_annotation_enabled': True, 'responsive_web_grok_community_note_auto_translation_is_enabled': True, 'responsive_web_enhance_cards_enabled': False}, 'toggles': {'withPayments': False}}}
|
x2llm/mcp.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Stdio MCP server exposing the same cores as the CLI (reads + like_tweet)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
from . import cores
|
|
8
|
+
from .client import XApiError, XClient
|
|
9
|
+
from .config import Config, ConfigError
|
|
10
|
+
|
|
11
|
+
MCP_INSTRUCTIONS = (
|
|
12
|
+
"Access to a logged-in x.com (Twitter) account via the private web API. "
|
|
13
|
+
"Credentials come from X_AUTH_TOKEN/X_CT0 environment variables - if they "
|
|
14
|
+
"expire every tool returns an auth error. All tools are read-only except "
|
|
15
|
+
"like_tweet, which performs a REAL, publicly visible action and notifies "
|
|
16
|
+
"the author - never call it unless the user explicitly asked to like that "
|
|
17
|
+
"specific post. search supports X operators (from:user, "
|
|
18
|
+
"since:YYYY-MM-DD, lang:, min_faves:, min_retweets:). get_tweet returns "
|
|
19
|
+
"the tweet plus its reply thread; paginate long threads and searches with "
|
|
20
|
+
"the returned cursor. analytics returns the account's own engagement "
|
|
21
|
+
"statistics. If search or like fails with an empty 404, the captured "
|
|
22
|
+
"x-client-transaction-id expired - see the x2llm README."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# (tool name, description, core, read_only)
|
|
26
|
+
MCP_TOOLS = [
|
|
27
|
+
(
|
|
28
|
+
"get_home_timeline",
|
|
29
|
+
(
|
|
30
|
+
"The account's home timeline. tab: 'for_you' (algorithmic) or "
|
|
31
|
+
"'following' (chronological). Scroll deeper by passing the "
|
|
32
|
+
"returned cursor."
|
|
33
|
+
),
|
|
34
|
+
cores.home,
|
|
35
|
+
True,
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
"get_user",
|
|
39
|
+
(
|
|
40
|
+
"Profile of a user by screen name: bio, url, follower/following "
|
|
41
|
+
"counts, tweet counts, verification. Use to qualify authors "
|
|
42
|
+
"before engaging."
|
|
43
|
+
),
|
|
44
|
+
cores.user,
|
|
45
|
+
True,
|
|
46
|
+
),
|
|
47
|
+
(
|
|
48
|
+
"get_badges",
|
|
49
|
+
("Unread notification and DM counts. Cheapest way to verify credentials work."),
|
|
50
|
+
cores.badges,
|
|
51
|
+
True,
|
|
52
|
+
),
|
|
53
|
+
(
|
|
54
|
+
"me",
|
|
55
|
+
(
|
|
56
|
+
"Viewer identity: numeric user id, follower, verified-follower and "
|
|
57
|
+
"realtime-active-follower counts."
|
|
58
|
+
),
|
|
59
|
+
cores.me,
|
|
60
|
+
True,
|
|
61
|
+
),
|
|
62
|
+
(
|
|
63
|
+
"suggest",
|
|
64
|
+
("Search typeahead for a prefix: matching topics and users (with ids)."),
|
|
65
|
+
cores.suggest,
|
|
66
|
+
True,
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"search",
|
|
70
|
+
(
|
|
71
|
+
"Search posts. Supports X operators: from:user, since:YYYY-MM-DD, "
|
|
72
|
+
"lang:, min_faves:, min_retweets:. Returns posts and a cursor for the "
|
|
73
|
+
"next page."
|
|
74
|
+
),
|
|
75
|
+
cores.search,
|
|
76
|
+
True,
|
|
77
|
+
),
|
|
78
|
+
(
|
|
79
|
+
"get_tweet",
|
|
80
|
+
("One post by id plus its reply thread; cursor paginates deeper replies."),
|
|
81
|
+
cores.tweet,
|
|
82
|
+
True,
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
"get_notifications",
|
|
86
|
+
("The account's notification tab (mentions, likes, follows, reposts)."),
|
|
87
|
+
cores.notifications,
|
|
88
|
+
True,
|
|
89
|
+
),
|
|
90
|
+
(
|
|
91
|
+
"get_analytics",
|
|
92
|
+
(
|
|
93
|
+
"The account's own engagement statistics aggregated over a window "
|
|
94
|
+
"(impressions, likes, reposts, follows, ...). days: 1-28."
|
|
95
|
+
),
|
|
96
|
+
cores.analytics,
|
|
97
|
+
True,
|
|
98
|
+
),
|
|
99
|
+
(
|
|
100
|
+
"get_following",
|
|
101
|
+
(
|
|
102
|
+
"Accounts a user follows. user is a numeric id or screen name; cursor paginates."
|
|
103
|
+
),
|
|
104
|
+
cores.following,
|
|
105
|
+
True,
|
|
106
|
+
),
|
|
107
|
+
(
|
|
108
|
+
"like_tweet",
|
|
109
|
+
(
|
|
110
|
+
"Like a post as the logged-in user. REAL action: publicly visible, "
|
|
111
|
+
"notifies the author, not undoable via this tool. Only call when "
|
|
112
|
+
"the user explicitly asked to like that specific post."
|
|
113
|
+
),
|
|
114
|
+
cores.like,
|
|
115
|
+
False,
|
|
116
|
+
),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
_MCP_CLIENT: XClient | None = None
|
|
120
|
+
_MCP_LOCK = asyncio.Lock()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def mcp_client() -> XClient:
|
|
124
|
+
global _MCP_CLIENT
|
|
125
|
+
async with _MCP_LOCK:
|
|
126
|
+
if _MCP_CLIENT is None:
|
|
127
|
+
try:
|
|
128
|
+
_MCP_CLIENT = XClient(Config.from_env())
|
|
129
|
+
except ConfigError as e:
|
|
130
|
+
raise RuntimeError(str(e)) from e
|
|
131
|
+
return _MCP_CLIENT
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def bind_tool(fn):
|
|
135
|
+
sig = inspect.signature(fn)
|
|
136
|
+
params = list(sig.parameters.values())[1:]
|
|
137
|
+
|
|
138
|
+
async def bound(**kwargs):
|
|
139
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return await fn(await mcp_client(), **kwargs)
|
|
143
|
+
except XApiError as e:
|
|
144
|
+
raise ToolError(str(e)) from e
|
|
145
|
+
except TypeError as e:
|
|
146
|
+
raise ToolError(f"bad arguments: {e}") from e
|
|
147
|
+
|
|
148
|
+
bound.__signature__ = inspect.Signature(params, return_annotation=dict)
|
|
149
|
+
return bound
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_mcp_server():
|
|
153
|
+
from mcp.server.mcpserver import MCPServer
|
|
154
|
+
from mcp.types import ToolAnnotations
|
|
155
|
+
|
|
156
|
+
server = MCPServer(name="x2llm", instructions=MCP_INSTRUCTIONS)
|
|
157
|
+
for name, description, fn, read_only in MCP_TOOLS:
|
|
158
|
+
server.add_tool(
|
|
159
|
+
bind_tool(fn),
|
|
160
|
+
name=name,
|
|
161
|
+
description=description,
|
|
162
|
+
annotations=ToolAnnotations(read_only_hint=read_only),
|
|
163
|
+
)
|
|
164
|
+
return server
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def serve() -> None:
|
|
168
|
+
server = build_mcp_server()
|
|
169
|
+
try:
|
|
170
|
+
await server.run_stdio_async()
|
|
171
|
+
finally:
|
|
172
|
+
if _MCP_CLIENT is not None:
|
|
173
|
+
await _MCP_CLIENT.aclose()
|
x2llm/parse.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Parsers: x.com timeline/negotiation blobs -> flat LLM-friendly dicts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def walk(obj: Any) -> Iterator[dict]:
|
|
9
|
+
if isinstance(obj, dict):
|
|
10
|
+
yield obj
|
|
11
|
+
for v in obj.values():
|
|
12
|
+
yield from walk(v)
|
|
13
|
+
elif isinstance(obj, list):
|
|
14
|
+
for v in obj:
|
|
15
|
+
yield from walk(v)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _tweet_result(wrapper: dict | None) -> dict | None:
|
|
19
|
+
result = (wrapper or {}).get("result") or {}
|
|
20
|
+
if result.get("__typename") == "TweetWithVisibilityResults":
|
|
21
|
+
result = result.get("tweet") or {}
|
|
22
|
+
if result.get("__typename") != "Tweet" or not result.get("rest_id"):
|
|
23
|
+
return None
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _user_fields(user: dict) -> dict:
|
|
28
|
+
core = user.get("core") or {}
|
|
29
|
+
legacy = user.get("legacy") or {}
|
|
30
|
+
return {
|
|
31
|
+
"author": core.get("screen_name") or legacy.get("screen_name"),
|
|
32
|
+
"author_name": core.get("name") or legacy.get("name"),
|
|
33
|
+
"author_id": user.get("rest_id"),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def tweet_row(result: dict) -> dict:
|
|
38
|
+
legacy = result.get("legacy") or {}
|
|
39
|
+
user = ((result.get("core") or {}).get("user_results") or {}).get("result") or {}
|
|
40
|
+
note = ((result.get("note_tweet") or {}).get("note_tweet_results") or {}).get("result") or {}
|
|
41
|
+
views = (result.get("views") or {}).get("count")
|
|
42
|
+
return {
|
|
43
|
+
"id": result.get("rest_id"),
|
|
44
|
+
"created_at": legacy.get("created_at"),
|
|
45
|
+
**_user_fields(user),
|
|
46
|
+
"text": note.get("text") or legacy.get("full_text"),
|
|
47
|
+
"lang": legacy.get("lang"),
|
|
48
|
+
"replies": legacy.get("reply_count"),
|
|
49
|
+
"reposts": legacy.get("retweet_count"),
|
|
50
|
+
"likes": legacy.get("favorite_count"),
|
|
51
|
+
"views": int(views) if isinstance(views, str) and views.isdigit() else views,
|
|
52
|
+
"bookmarks": legacy.get("bookmark_count"),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def collect_tweets(obj: Any) -> list[dict]:
|
|
57
|
+
rows: list[dict] = []
|
|
58
|
+
seen: set[str] = set()
|
|
59
|
+
for node in walk(obj):
|
|
60
|
+
if "tweet_results" not in node:
|
|
61
|
+
continue
|
|
62
|
+
result = _tweet_result(node["tweet_results"])
|
|
63
|
+
if result and result["rest_id"] not in seen:
|
|
64
|
+
seen.add(result["rest_id"])
|
|
65
|
+
rows.append(tweet_row(result))
|
|
66
|
+
return rows
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def collect_cursors(obj: Any) -> dict[str, str]:
|
|
70
|
+
cursors: dict[str, str] = {}
|
|
71
|
+
for node in walk(obj):
|
|
72
|
+
if node.get("entryType") == "TimelineTimelineCursor" and node.get("value"):
|
|
73
|
+
kind = node.get("cursorType")
|
|
74
|
+
if kind in ("Top", "Bottom"):
|
|
75
|
+
cursors[kind.lower()] = node["value"]
|
|
76
|
+
return cursors
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def notification_rows(obj: Any) -> list[dict]:
|
|
80
|
+
rows: list[dict] = []
|
|
81
|
+
seen: set[str] = set()
|
|
82
|
+
for node in walk(obj):
|
|
83
|
+
item = node.get("itemContent")
|
|
84
|
+
if not isinstance(item, dict) or item.get("__typename") != "TimelineNotification":
|
|
85
|
+
continue
|
|
86
|
+
if item.get("id") in seen:
|
|
87
|
+
continue
|
|
88
|
+
seen.add(item.get("id"))
|
|
89
|
+
rich = item.get("rich_message") or {}
|
|
90
|
+
rows.append(
|
|
91
|
+
{
|
|
92
|
+
"id": item.get("id"),
|
|
93
|
+
"icon": item.get("notification_icon"),
|
|
94
|
+
"text": rich.get("text"),
|
|
95
|
+
"url": (item.get("notification_url") or {}).get("url"),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return rows
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def user_row(result: dict) -> dict:
|
|
102
|
+
core = result.get("core") or {}
|
|
103
|
+
bio = result.get("profile_bio") or {}
|
|
104
|
+
relationship = result.get("relationship_counts") or {}
|
|
105
|
+
counts = result.get("tweet_counts") or {}
|
|
106
|
+
urls = ((bio.get("entities") or {}).get("url") or {}).get("urls") or []
|
|
107
|
+
return {
|
|
108
|
+
"id": result.get("rest_id"),
|
|
109
|
+
"screen_name": core.get("screen_name"),
|
|
110
|
+
"name": core.get("name"),
|
|
111
|
+
"created_at": core.get("created_at"),
|
|
112
|
+
"bio": bio.get("description"),
|
|
113
|
+
"url": urls[0].get("expanded_url") if urls else None,
|
|
114
|
+
"location": (result.get("location") or {}).get("location"),
|
|
115
|
+
"followers": relationship.get("followers"),
|
|
116
|
+
"following": relationship.get("following"),
|
|
117
|
+
"tweets": counts.get("tweets"),
|
|
118
|
+
"media_tweets": counts.get("media_tweets"),
|
|
119
|
+
"blue_verified": result.get("is_blue_verified"),
|
|
120
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
x2llm/__init__.py,sha256=8LIrJ89MZnx-iwrs5_EfBaw_IFFUhdbtiXXQcb9Funo,213
|
|
2
|
+
x2llm/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
x2llm/cli.py,sha256=EFhLfc6Vnb2qTLHeYULse-ysW6oc40hYk2IXkK0RKr0,4586
|
|
4
|
+
x2llm/client.py,sha256=czvikbPQhUY_HcXhVfSEszRXH0ApMlt7BhjIw-mCNBI,5377
|
|
5
|
+
x2llm/config.py,sha256=8KZNRui14bqHFxNxWejB8_myffoiYzPa7b4cECCLfOE,2311
|
|
6
|
+
x2llm/cores.py,sha256=AXiHTwmxiRHh7_TiMYcJPPkFJ9wmaEfQC1hNHKFdUKQ,7069
|
|
7
|
+
x2llm/endpoints.py,sha256=Wvlvj_zrm4PU2DKeF6KszWdwr2P7oCD1sYh-vl0LvVs,12734
|
|
8
|
+
x2llm/mcp.py,sha256=WmZeZW8lpu7h2Jq8eJCBKAwAOVH3DnAk5v3uCu1KsOc,5117
|
|
9
|
+
x2llm/parse.py,sha256=nlzpVy6aR4usHn40xgrIcwr0RAR1mz8mh9_2mB-6qB0,4211
|
|
10
|
+
x2llm-0.1.0.dist-info/METADATA,sha256=QUBMPsS_Qkyqula4PL3T2xhtU9z7WU_E-Pjqy7_DfPY,243
|
|
11
|
+
x2llm-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
x2llm-0.1.0.dist-info/entry_points.txt,sha256=Sbp0vDeQrQDW2yT12_h10jz98wyhq_Bp2Oq6F6P4mTc,41
|
|
13
|
+
x2llm-0.1.0.dist-info/RECORD,,
|