x-relay-mcp 1.2.0 → 1.3.0
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.
- package/.claude/skills/x-relay/SKILL.md +32 -0
- package/dist/{cli-B4ohNfa7.d.ts → cli-DDbxyp9v.d.ts} +31 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +272 -45
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +272 -45
- package/dist/index.js.map +1 -1
- package/dist/mcp-shim.js +253 -21
- package/dist/mcp-shim.js.map +1 -1
- package/package.json +2 -1
|
@@ -116,6 +116,8 @@ xrelay quoters <id|url> [--limit N] # tweets quoting a tweet (reactions;
|
|
|
116
116
|
xrelay trends [--woeid N] [--limit N] # what's hot now (woeid 1 = worldwide, default)
|
|
117
117
|
xrelay article <id|url> # a long-form X Article → Markdown
|
|
118
118
|
xrelay media <id|url> [--out <dir>] # a tweet's image/video URLs; --out downloads the files
|
|
119
|
+
xrelay community <community-id> [--limit N] # a community's tweet feed (topical, moderated sub-network)
|
|
120
|
+
xrelay community-info <community-id> # community metadata: name, members, rules, topic, creator
|
|
119
121
|
```
|
|
120
122
|
|
|
121
123
|
- **`retweeters`/`followers`/`following`** return `{ users:[<profile>...], nextCursor }` — the
|
|
@@ -127,6 +129,11 @@ xrelay media <id|url> [--out <dir>] # a tweet's image/video URLs; --out
|
|
|
127
129
|
- **`article`** returns `{ id, title, markdown, url }` — the full long-form read for a finalist.
|
|
128
130
|
- **`media`** returns `{ tweetId, media:[{type,url,...}], files? }`; `--out <dir>` saves the actual
|
|
129
131
|
image/video files (for OCR / transcription / multimodal analysis).
|
|
132
|
+
- **`community`** returns the community's feed as a normal `{ tweets[], nextCursor }` — a focused,
|
|
133
|
+
on-topic corpus that's often higher-signal than open search for a niche. **`community-info`** returns
|
|
134
|
+
`{ name, description, memberCount, moderatorCount, rules[], topic, tags[], creator, url }`. Get the
|
|
135
|
+
`community-id` from a community URL (`x.com/i/communities/<id>`). Note: X exposes no stable endpoint for
|
|
136
|
+
a full member roster or within-community search, so those aren't provided — use the feed + `search`.
|
|
130
137
|
|
|
131
138
|
---
|
|
132
139
|
|
|
@@ -154,3 +161,28 @@ npm i -g x-relay-mcp
|
|
|
154
161
|
```
|
|
155
162
|
Cookies are read automatically from your logged-in browser (macOS Keychain). The first run may show a one-time
|
|
156
163
|
Keychain "Always Allow" prompt. Assumes a residential IP (run locally); datacenter IPs are blocked by X.
|
|
164
|
+
|
|
165
|
+
### Account pool + proxy (optional — for heavy/sustained use)
|
|
166
|
+
|
|
167
|
+
X rate-limits per account and blocks datacenter IPs. For deep sweeps, give the tool **several sessions, each
|
|
168
|
+
behind its own residential proxy**; it transparently fails over to the next session when one hits a rate-limit
|
|
169
|
+
(429) or its cookies expire — so a long research run survives a single account getting throttled. All optional;
|
|
170
|
+
the default single-browser-session path needs none of this.
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
# Several accounts, each pinned to its own proxy (JSON array — the robust form):
|
|
174
|
+
export XRELAY_ACCOUNTS='[
|
|
175
|
+
{"cookies":"auth_token=..; ct0=..","proxy":"http://user:pass@host1:port","label":"main"},
|
|
176
|
+
{"cookies":"auth_token=..; ct0=..","proxy":"socks5://user:pass@host2:port"}
|
|
177
|
+
]'
|
|
178
|
+
|
|
179
|
+
# Or a simpler newline list of cookie strings, with proxies round-robined onto them:
|
|
180
|
+
export XRELAY_ACCOUNTS=$'auth_token=..; ct0=..\nauth_token=..; ct0=..'
|
|
181
|
+
export XRELAY_PROXIES='http://host1:port, http://host2:port'
|
|
182
|
+
|
|
183
|
+
# Single browser session, just routed through one proxy:
|
|
184
|
+
export XRELAY_PROXY='http://user:pass@host:port'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Rotation triggers on `RATE_LIMITED` / `AUTH_FAILED` only; other errors fail fast. Pair an equal number of
|
|
188
|
+
accounts and proxies for a clean 1:1 mapping. http(s) and socks proxies are both supported.
|
|
@@ -115,6 +115,24 @@ type Article = {
|
|
|
115
115
|
url: string;
|
|
116
116
|
author?: Author;
|
|
117
117
|
};
|
|
118
|
+
/** An X Community's metadata (the `community-info` command). */
|
|
119
|
+
type Community = {
|
|
120
|
+
id: string;
|
|
121
|
+
name: string;
|
|
122
|
+
description?: string;
|
|
123
|
+
memberCount?: number;
|
|
124
|
+
moderatorCount?: number;
|
|
125
|
+
/** ISO timestamp (X reports created_at as epoch ms). */
|
|
126
|
+
createdAt?: string;
|
|
127
|
+
/** The viewer's role in the community (Member / Moderator / Admin / NonMember). */
|
|
128
|
+
role?: string;
|
|
129
|
+
joinPolicy?: string;
|
|
130
|
+
topic?: string;
|
|
131
|
+
rules?: string[];
|
|
132
|
+
tags?: string[];
|
|
133
|
+
creator?: Author;
|
|
134
|
+
url: string;
|
|
135
|
+
};
|
|
118
136
|
|
|
119
137
|
/** The two load-bearing cookies, plus any others the jar carries. */
|
|
120
138
|
type Cookies = {
|
|
@@ -197,6 +215,14 @@ declare const OPS: {
|
|
|
197
215
|
readonly queryId: "Xl5pC_lBk_gcO2ItU39DQw";
|
|
198
216
|
readonly operationName: "TweetResultByRestId";
|
|
199
217
|
};
|
|
218
|
+
readonly CommunityByRestId: {
|
|
219
|
+
readonly queryId: "vLS7mhOqMLtGZdXqFP1DEg";
|
|
220
|
+
readonly operationName: "CommunityByRestId";
|
|
221
|
+
};
|
|
222
|
+
readonly CommunityTweetsTimeline: {
|
|
223
|
+
readonly queryId: "pXYASW5kVylF3YMrGJovLg";
|
|
224
|
+
readonly operationName: "CommunityTweetsTimeline";
|
|
225
|
+
};
|
|
200
226
|
};
|
|
201
227
|
type OpName = keyof typeof OPS;
|
|
202
228
|
type Vars = Record<string, unknown>;
|
|
@@ -266,6 +292,8 @@ interface Engine {
|
|
|
266
292
|
}): Promise<Trend[]>;
|
|
267
293
|
article(tweetId: string): Promise<Article | null>;
|
|
268
294
|
media(tweetId: string): Promise<MediaItem[]>;
|
|
295
|
+
community(communityId: string, opts?: PageOpts): Promise<TweetPage>;
|
|
296
|
+
communityInfo(communityId: string): Promise<Community | null>;
|
|
269
297
|
/** The authenticated user's own @handle (from the session), or null. Memoized. */
|
|
270
298
|
me(): Promise<string | null>;
|
|
271
299
|
}
|
|
@@ -277,6 +305,8 @@ interface EngineDeps {
|
|
|
277
305
|
sleep?: (ms: number) => Promise<void>;
|
|
278
306
|
/** Injectable transport (tests). Defaults to a real client over the X API. */
|
|
279
307
|
client?: EngineClient;
|
|
308
|
+
/** Injectable transport lanes (tests) — drives account-pool rotation. Overrides `client`. */
|
|
309
|
+
clients?: EngineClient[];
|
|
280
310
|
/** Injectable transaction provider (tests). Defaults to the xctid generator. */
|
|
281
311
|
transaction?: TransactionProvider;
|
|
282
312
|
}
|
|
@@ -293,4 +323,4 @@ declare function parseArgs(argv: string[]): ParsedArgs;
|
|
|
293
323
|
declare function dispatch(parsed: ParsedArgs, engine: Engine): Promise<Envelope<unknown>>;
|
|
294
324
|
declare function run(argv: string[], engine?: Engine): Promise<number>;
|
|
295
325
|
|
|
296
|
-
export { type Article as A, type Cookies as C, type Err as E, type MediaItem as M, type Ok as O, type PageOpts as P, type SearchResult as S, type Tweet as T, type UserPage as U, type Engine as a, type Envelope as b, type TweetPage as c, type SearchProduct as d, type ThreadResult as e, type Trend as f, type UserProfile as g, type Author as h, type
|
|
326
|
+
export { type Article as A, type Cookies as C, type Err as E, type MediaItem as M, type Ok as O, type PageOpts as P, type SearchResult as S, type Tweet as T, type UserPage as U, type Engine as a, type Envelope as b, type TweetPage as c, type SearchProduct as d, type ThreadResult as e, type Trend as f, type UserProfile as g, type Author as h, type Community as i, type EngineDeps as j, EngineError as k, type MediaKind as l, type Metrics as m, type SearchOpts as n, type SearchProduct$1 as o, type UserTweetsOpts as p, createEngine as q, dispatch as r, parseArgs as s, parseCookies as t, run as u, type ParsedArgs as v };
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
export {
|
|
2
|
+
export { v as ParsedArgs, r as dispatch, s as parseArgs, u as run } from './cli-DDbxyp9v.js';
|
package/dist/cli.js
CHANGED
|
@@ -106,6 +106,18 @@ var COMMANDS = [
|
|
|
106
106
|
cost: "medium",
|
|
107
107
|
summary: "A tweet's image/video assets (URLs); --out <dir> downloads the files.",
|
|
108
108
|
usage: "xrelay media <id|url> [--out <dir>]"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "community",
|
|
112
|
+
cost: "medium",
|
|
113
|
+
summary: "A community's tweet feed (a topical, moderated sub-network).",
|
|
114
|
+
usage: "xrelay community <community-id> [--limit N]"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "community-info",
|
|
118
|
+
cost: "1 call",
|
|
119
|
+
summary: "Community metadata: name, description, member/mod counts, rules, topic, creator.",
|
|
120
|
+
usage: "xrelay community-info <community-id>"
|
|
109
121
|
}
|
|
110
122
|
];
|
|
111
123
|
var commandNames = COMMANDS.map((c) => c.name);
|
|
@@ -364,7 +376,12 @@ var OPS = {
|
|
|
364
376
|
Retweeters: { queryId: "TZsWuSj7vGmncVnq7KWDUQ", operationName: "Retweeters" },
|
|
365
377
|
Favoriters: { queryId: "LLkw5EcVutJL6y-2gkz22A", operationName: "Favoriters" },
|
|
366
378
|
GenericTimelineById: { queryId: "_dGVIf1cY6xFanFNPsAzPQ", operationName: "GenericTimelineById" },
|
|
367
|
-
TweetResultByRestId: { queryId: "Xl5pC_lBk_gcO2ItU39DQw", operationName: "TweetResultByRestId" }
|
|
379
|
+
TweetResultByRestId: { queryId: "Xl5pC_lBk_gcO2ItU39DQw", operationName: "TweetResultByRestId" },
|
|
380
|
+
CommunityByRestId: { queryId: "vLS7mhOqMLtGZdXqFP1DEg", operationName: "CommunityByRestId" },
|
|
381
|
+
CommunityTweetsTimeline: {
|
|
382
|
+
queryId: "pXYASW5kVylF3YMrGJovLg",
|
|
383
|
+
operationName: "CommunityTweetsTimeline"
|
|
384
|
+
}
|
|
368
385
|
};
|
|
369
386
|
var FEATURES = {
|
|
370
387
|
rweb_video_screen_enabled: false,
|
|
@@ -535,6 +552,24 @@ function tweetResultRequest(params) {
|
|
|
535
552
|
fieldToggles: { withArticleRichContentState: true, withArticlePlainText: true }
|
|
536
553
|
};
|
|
537
554
|
}
|
|
555
|
+
function communityRequest(params) {
|
|
556
|
+
const { communityId, kv, ft } = params;
|
|
557
|
+
return {
|
|
558
|
+
variables: { communityId, ...kv },
|
|
559
|
+
features: { ...FEATURES, ...ft }
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function communityTweetsRequest(params) {
|
|
563
|
+
const { communityId, count = 20, cursor, rankingMode = "Relevance", kv, ft } = params;
|
|
564
|
+
const variables = withCursor(
|
|
565
|
+
{ communityId, count, displayLocation: "Community", rankingMode, withCommunity: true },
|
|
566
|
+
cursor
|
|
567
|
+
);
|
|
568
|
+
return {
|
|
569
|
+
variables: { ...variables, ...kv },
|
|
570
|
+
features: { ...FEATURES, ...ft }
|
|
571
|
+
};
|
|
572
|
+
}
|
|
538
573
|
function stableStringify(value) {
|
|
539
574
|
if (Array.isArray(value)) {
|
|
540
575
|
return `[${value.map(stableStringify).join(",")}]`;
|
|
@@ -1394,6 +1429,118 @@ function articleAuthor(tweetResult) {
|
|
|
1394
1429
|
return void 0;
|
|
1395
1430
|
}
|
|
1396
1431
|
}
|
|
1432
|
+
function authorFromProfile2(profile) {
|
|
1433
|
+
const author = {
|
|
1434
|
+
id: profile.id,
|
|
1435
|
+
handle: profile.handle,
|
|
1436
|
+
name: profile.name,
|
|
1437
|
+
verified: profile.verified,
|
|
1438
|
+
followers: profile.followers
|
|
1439
|
+
};
|
|
1440
|
+
if (profile.avatar !== void 0) author.avatar = profile.avatar;
|
|
1441
|
+
return author;
|
|
1442
|
+
}
|
|
1443
|
+
function ruleNames(raw) {
|
|
1444
|
+
if (!Array.isArray(raw)) return void 0;
|
|
1445
|
+
const out = [];
|
|
1446
|
+
for (const rule of raw) {
|
|
1447
|
+
const name = isRecord2(rule) ? asString2(rule.name) : void 0;
|
|
1448
|
+
if (name !== void 0) out.push(name);
|
|
1449
|
+
}
|
|
1450
|
+
return out.length > 0 ? out : void 0;
|
|
1451
|
+
}
|
|
1452
|
+
function stringArray(raw) {
|
|
1453
|
+
if (!Array.isArray(raw)) return void 0;
|
|
1454
|
+
const out = raw.filter((v) => typeof v === "string");
|
|
1455
|
+
return out.length > 0 ? out : void 0;
|
|
1456
|
+
}
|
|
1457
|
+
function communityCreator(result) {
|
|
1458
|
+
const node = child2(result, "creator_results")?.result;
|
|
1459
|
+
const profile = parseUserResult(node);
|
|
1460
|
+
return profile ? authorFromProfile2(profile) : void 0;
|
|
1461
|
+
}
|
|
1462
|
+
function applyCommunityOptionals(community, result) {
|
|
1463
|
+
const description = asString2(result.description);
|
|
1464
|
+
if (description !== void 0) community.description = description;
|
|
1465
|
+
const memberCount = asNumber2(result.member_count);
|
|
1466
|
+
if (memberCount !== void 0) community.memberCount = memberCount;
|
|
1467
|
+
const moderatorCount = asNumber2(result.moderator_count);
|
|
1468
|
+
if (moderatorCount !== void 0) community.moderatorCount = moderatorCount;
|
|
1469
|
+
const createdMs = asNumber2(result.created_at);
|
|
1470
|
+
if (createdMs !== void 0) community.createdAt = new Date(createdMs).toISOString();
|
|
1471
|
+
const role = asString2(result.role);
|
|
1472
|
+
if (role !== void 0) community.role = role;
|
|
1473
|
+
const joinPolicy = asString2(result.join_policy);
|
|
1474
|
+
if (joinPolicy !== void 0) community.joinPolicy = joinPolicy;
|
|
1475
|
+
const topic = asString2(child2(result, "primary_community_topic")?.topic_name);
|
|
1476
|
+
if (topic !== void 0) community.topic = topic;
|
|
1477
|
+
const rules = ruleNames(result.rules);
|
|
1478
|
+
if (rules !== void 0) community.rules = rules;
|
|
1479
|
+
const tags = stringArray(result.search_tags);
|
|
1480
|
+
if (tags !== void 0) community.tags = tags;
|
|
1481
|
+
const creator = communityCreator(result);
|
|
1482
|
+
if (creator !== void 0) community.creator = creator;
|
|
1483
|
+
}
|
|
1484
|
+
function parseCommunity(json) {
|
|
1485
|
+
const wrapper = findDict(json, "communityResults", true)[0];
|
|
1486
|
+
const result = isRecord2(wrapper) ? wrapper.result : void 0;
|
|
1487
|
+
if (!isRecord2(result)) return null;
|
|
1488
|
+
const id = asString2(result.id_str) ?? asString2(result.rest_id);
|
|
1489
|
+
const name = asString2(result.name);
|
|
1490
|
+
if (id === void 0 || name === void 0) return null;
|
|
1491
|
+
const community = { id, name, url: `https://x.com/i/communities/${id}` };
|
|
1492
|
+
applyCommunityOptionals(community, result);
|
|
1493
|
+
return community;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// src/engine/pool.ts
|
|
1497
|
+
function parseProxyList(raw) {
|
|
1498
|
+
return raw.split(/[\n,]/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1499
|
+
}
|
|
1500
|
+
function specFromJsonItem(item, index) {
|
|
1501
|
+
const cookieStr = typeof item === "string" ? item : String(item?.cookies ?? "");
|
|
1502
|
+
const spec = {
|
|
1503
|
+
cookies: parseCookies(cookieStr),
|
|
1504
|
+
label: typeof item === "object" && item !== null && typeof item.label === "string" ? String(item.label) : `acct${index + 1}`
|
|
1505
|
+
};
|
|
1506
|
+
const proxy = typeof item === "object" && item !== null ? item.proxy : void 0;
|
|
1507
|
+
if (typeof proxy === "string" && proxy.length > 0) spec.proxy = proxy;
|
|
1508
|
+
return spec;
|
|
1509
|
+
}
|
|
1510
|
+
function parseAccounts(raw) {
|
|
1511
|
+
const trimmed = raw.trim();
|
|
1512
|
+
if (trimmed.length === 0) return [];
|
|
1513
|
+
if (trimmed.startsWith("[")) {
|
|
1514
|
+
const parsed = JSON.parse(trimmed);
|
|
1515
|
+
if (!Array.isArray(parsed)) throw new Error("XRELAY_ACCOUNTS JSON must be an array");
|
|
1516
|
+
return parsed.map(specFromJsonItem);
|
|
1517
|
+
}
|
|
1518
|
+
return trimmed.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).map((line, i) => ({ cookies: parseCookies(line), label: `acct${i + 1}` }));
|
|
1519
|
+
}
|
|
1520
|
+
function assignProxies(specs, proxies) {
|
|
1521
|
+
if (proxies.length === 0) return specs;
|
|
1522
|
+
return specs.map((spec, i) => {
|
|
1523
|
+
if (spec.proxy !== void 0) return spec;
|
|
1524
|
+
const proxy = proxies[i % proxies.length];
|
|
1525
|
+
return proxy === void 0 ? spec : { ...spec, proxy };
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
function makeFetch(proxy, base) {
|
|
1529
|
+
if (proxy === void 0 || proxy.length === 0) return base;
|
|
1530
|
+
let dispatcher;
|
|
1531
|
+
const getDispatcher = () => {
|
|
1532
|
+
if (dispatcher === void 0) {
|
|
1533
|
+
dispatcher = import("undici").then((m) => new m.ProxyAgent(proxy));
|
|
1534
|
+
}
|
|
1535
|
+
return dispatcher;
|
|
1536
|
+
};
|
|
1537
|
+
const proxied = async (input, init) => {
|
|
1538
|
+
const d = await getDispatcher();
|
|
1539
|
+
const widened = { ...init ?? {}, dispatcher: d };
|
|
1540
|
+
return base(input, widened);
|
|
1541
|
+
};
|
|
1542
|
+
return proxied;
|
|
1543
|
+
}
|
|
1397
1544
|
|
|
1398
1545
|
// src/engine/xctid/transaction.ts
|
|
1399
1546
|
import { createHash } from "crypto";
|
|
@@ -1870,6 +2017,7 @@ function idLte(a, b) {
|
|
|
1870
2017
|
return a.length === b.length ? a <= b : a.length < b.length;
|
|
1871
2018
|
}
|
|
1872
2019
|
}
|
|
2020
|
+
var ROTATE_CODES = /* @__PURE__ */ new Set(["RATE_LIMITED", "AUTH_FAILED"]);
|
|
1873
2021
|
var MAX_NOTFOUND_RETRIES = 2;
|
|
1874
2022
|
var RETRY_BACKOFF_MS = 400;
|
|
1875
2023
|
function createTransactionProvider(fetchImpl) {
|
|
@@ -1948,37 +2096,72 @@ async function paginateUsers(fetchPage, limit) {
|
|
|
1948
2096
|
return out;
|
|
1949
2097
|
}
|
|
1950
2098
|
function createEngine(deps) {
|
|
1951
|
-
const
|
|
2099
|
+
const baseFetch = deps.fetchImpl ?? fetch;
|
|
1952
2100
|
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1953
2101
|
const txn = deps.transaction ? { provider: deps.transaction, refresh: async () => {
|
|
1954
2102
|
} } : createTransactionProvider(deps.fetchImpl);
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2103
|
+
function resolveSpecs() {
|
|
2104
|
+
const accountsRaw = (process.env.XRELAY_ACCOUNTS ?? "").trim();
|
|
2105
|
+
const proxies = parseProxyList(process.env.XRELAY_PROXIES ?? "");
|
|
2106
|
+
let specs = accountsRaw.length > 0 ? parseAccounts(accountsRaw) : [];
|
|
2107
|
+
if (specs.length === 0) {
|
|
2108
|
+
const spec = { cookies: deps.cookies ?? getCookies(), label: "default" };
|
|
2109
|
+
const singleProxy = (process.env.XRELAY_PROXY ?? "").trim();
|
|
2110
|
+
if (singleProxy.length > 0) spec.proxy = singleProxy;
|
|
2111
|
+
specs = [spec];
|
|
2112
|
+
}
|
|
2113
|
+
return assignProxies(specs, proxies);
|
|
2114
|
+
}
|
|
2115
|
+
function buildLanes() {
|
|
2116
|
+
const injected = deps.clients ?? (deps.client ? [deps.client] : void 0);
|
|
2117
|
+
const fallbackCookies = deps.cookies ?? { authToken: "", ct0: "" };
|
|
2118
|
+
if (injected !== void 0) {
|
|
2119
|
+
return injected.map((client) => ({ client, cookies: fallbackCookies, fetchImpl: baseFetch }));
|
|
2120
|
+
}
|
|
2121
|
+
return resolveSpecs().map((spec) => {
|
|
2122
|
+
const fetchImpl = makeFetch(spec.proxy, baseFetch);
|
|
2123
|
+
return {
|
|
2124
|
+
client: createClient({ cookies: spec.cookies, transaction: txn.provider, fetchImpl }),
|
|
2125
|
+
cookies: spec.cookies,
|
|
2126
|
+
fetchImpl
|
|
2127
|
+
};
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
const lanes = buildLanes();
|
|
2131
|
+
let current = 0;
|
|
2132
|
+
const activeLane = () => lanes[current] ?? lanes[0] ?? {};
|
|
2133
|
+
async function callLane(lane, op, request, attempt = 0) {
|
|
2134
|
+
const res = await lane.client.get(op, request);
|
|
2135
|
+
if (res.ok) return res;
|
|
1968
2136
|
if (res.error.code === "NOT_FOUND" && attempt < MAX_NOTFOUND_RETRIES) {
|
|
1969
2137
|
await txn.refresh();
|
|
1970
2138
|
await sleep(RETRY_BACKOFF_MS * (attempt + 1));
|
|
1971
|
-
return
|
|
2139
|
+
return callLane(lane, op, request, attempt + 1);
|
|
1972
2140
|
}
|
|
1973
|
-
|
|
2141
|
+
return res;
|
|
2142
|
+
}
|
|
2143
|
+
async function call(op, request) {
|
|
2144
|
+
let lastError = {
|
|
2145
|
+
code: "FETCH_FAILED",
|
|
2146
|
+
message: "no lanes available"
|
|
2147
|
+
};
|
|
2148
|
+
for (let laneTry = 0; laneTry < lanes.length; laneTry++) {
|
|
2149
|
+
const res = await callLane(activeLane(), op, request);
|
|
2150
|
+
if (res.ok) return res.value;
|
|
2151
|
+
lastError = res.error;
|
|
2152
|
+
if (!ROTATE_CODES.has(res.error.code)) break;
|
|
2153
|
+
current = (current + 1) % lanes.length;
|
|
2154
|
+
}
|
|
2155
|
+
throw new EngineError(lastError.code, lastError.message, lastError.status);
|
|
1974
2156
|
}
|
|
1975
2157
|
let mePromise;
|
|
1976
2158
|
async function fetchMe() {
|
|
1977
2159
|
const path = "/1.1/account/settings.json";
|
|
1978
2160
|
try {
|
|
2161
|
+
const lane = activeLane();
|
|
1979
2162
|
const txid = await txn.provider("GET", path);
|
|
1980
|
-
const res = await fetchImpl(`https://api.x.com${path}`, {
|
|
1981
|
-
headers: buildHeaders({ cookies:
|
|
2163
|
+
const res = await lane.fetchImpl(`https://api.x.com${path}`, {
|
|
2164
|
+
headers: buildHeaders({ cookies: lane.cookies, transactionId: txid })
|
|
1982
2165
|
});
|
|
1983
2166
|
if (!res.ok) return null;
|
|
1984
2167
|
const data = await res.json();
|
|
@@ -2098,9 +2281,10 @@ function createEngine(deps) {
|
|
|
2098
2281
|
async trends(opts) {
|
|
2099
2282
|
const woeid = opts?.woeid ?? 1;
|
|
2100
2283
|
const path = "/1.1/trends/place.json";
|
|
2284
|
+
const lane = activeLane();
|
|
2101
2285
|
const txid = await txn.provider("GET", path);
|
|
2102
|
-
const res = await fetchImpl(`https://api.x.com${path}?id=${woeid}`, {
|
|
2103
|
-
headers: buildHeaders({ cookies:
|
|
2286
|
+
const res = await lane.fetchImpl(`https://api.x.com${path}?id=${woeid}`, {
|
|
2287
|
+
headers: buildHeaders({ cookies: lane.cookies, transactionId: txid })
|
|
2104
2288
|
});
|
|
2105
2289
|
if (!res.ok) {
|
|
2106
2290
|
throw new EngineError("FETCH_FAILED", `trends request failed (${res.status})`, res.status);
|
|
@@ -2126,6 +2310,24 @@ function createEngine(deps) {
|
|
|
2126
2310
|
const value = await call("TweetResultByRestId", tweetResultRequest({ tweetId }));
|
|
2127
2311
|
const result = findDict(value, "result", true)[0];
|
|
2128
2312
|
return extractTweetMedia(result);
|
|
2313
|
+
},
|
|
2314
|
+
async community(communityId, opts) {
|
|
2315
|
+
const limit = opts?.limit ?? DEFAULT_LIMIT2;
|
|
2316
|
+
return paginate(
|
|
2317
|
+
async (cursor) => {
|
|
2318
|
+
const value = await call(
|
|
2319
|
+
"CommunityTweetsTimeline",
|
|
2320
|
+
communityTweetsRequest({ communityId, cursor })
|
|
2321
|
+
);
|
|
2322
|
+
return parseTimeline(value);
|
|
2323
|
+
},
|
|
2324
|
+
limit,
|
|
2325
|
+
opts?.stopAtId
|
|
2326
|
+
);
|
|
2327
|
+
},
|
|
2328
|
+
async communityInfo(communityId) {
|
|
2329
|
+
const value = await call("CommunityByRestId", communityRequest({ communityId }));
|
|
2330
|
+
return parseCommunity(value);
|
|
2129
2331
|
}
|
|
2130
2332
|
};
|
|
2131
2333
|
function usersByHandle(op, handle, limit) {
|
|
@@ -2251,6 +2453,20 @@ function runTrends(engine, opts = {}) {
|
|
|
2251
2453
|
})
|
|
2252
2454
|
);
|
|
2253
2455
|
}
|
|
2456
|
+
function runCommunity(engine, communityId, limit) {
|
|
2457
|
+
if (!communityId)
|
|
2458
|
+
return Promise.resolve(err("community", "INVALID_INPUT", "missing community id"));
|
|
2459
|
+
return guard("community", () => engine.community(communityId, lim(limit)));
|
|
2460
|
+
}
|
|
2461
|
+
function runCommunityInfo(engine, communityId) {
|
|
2462
|
+
if (!communityId)
|
|
2463
|
+
return Promise.resolve(err("community-info", "INVALID_INPUT", "missing community id"));
|
|
2464
|
+
return guard("community-info", async () => {
|
|
2465
|
+
const info = await engine.communityInfo(communityId);
|
|
2466
|
+
if (!info) throw new EngineError("NOT_FOUND", "community not found");
|
|
2467
|
+
return info;
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2254
2470
|
function runArticle(engine, tweetId) {
|
|
2255
2471
|
if (!tweetId) return Promise.resolve(err("article", "INVALID_INPUT", "missing tweet id/url"));
|
|
2256
2472
|
return guard("article", async () => {
|
|
@@ -2509,6 +2725,40 @@ function buildSearchOpts(parsed) {
|
|
|
2509
2725
|
...parsed.flags.filter ? { filter: parsed.flags.filter } : {}
|
|
2510
2726
|
};
|
|
2511
2727
|
}
|
|
2728
|
+
function dispatchReadOps(parsed, engine, command, target) {
|
|
2729
|
+
const limit = num(parsed, "limit");
|
|
2730
|
+
switch (command) {
|
|
2731
|
+
case "list":
|
|
2732
|
+
return runList(engine, target, limit);
|
|
2733
|
+
case "user-media":
|
|
2734
|
+
return runUserMedia(engine, extractHandle(target) ?? target, limit);
|
|
2735
|
+
case "followers":
|
|
2736
|
+
return runFollowers(engine, extractHandle(target) ?? target, limit);
|
|
2737
|
+
case "following":
|
|
2738
|
+
return runFollowing(engine, extractHandle(target) ?? target, limit);
|
|
2739
|
+
case "retweeters":
|
|
2740
|
+
return runRetweeters(engine, extractTweetId(target) ?? target, limit);
|
|
2741
|
+
case "likers":
|
|
2742
|
+
return runLikers(engine, extractTweetId(target) ?? target, limit);
|
|
2743
|
+
case "quoters":
|
|
2744
|
+
return runQuoters(engine, extractTweetId(target) ?? target, limit);
|
|
2745
|
+
case "trends":
|
|
2746
|
+
return runTrends(engine, {
|
|
2747
|
+
...num(parsed, "woeid") !== void 0 ? { woeid: num(parsed, "woeid") } : {},
|
|
2748
|
+
...limit !== void 0 ? { limit } : {}
|
|
2749
|
+
});
|
|
2750
|
+
case "article":
|
|
2751
|
+
return runArticle(engine, extractTweetId(target) ?? target);
|
|
2752
|
+
case "media":
|
|
2753
|
+
return runMedia(engine, extractTweetId(target) ?? target, first(parsed, "out"));
|
|
2754
|
+
case "community":
|
|
2755
|
+
return runCommunity(engine, target, limit);
|
|
2756
|
+
case "community-info":
|
|
2757
|
+
return runCommunityInfo(engine, target);
|
|
2758
|
+
default:
|
|
2759
|
+
return null;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2512
2762
|
async function dispatch(parsed, engine) {
|
|
2513
2763
|
const { command } = parsed;
|
|
2514
2764
|
const target = parsed.positionals[0] ?? "";
|
|
@@ -2541,31 +2791,8 @@ async function dispatch(parsed, engine) {
|
|
|
2541
2791
|
...num(parsed, "max") !== void 0 ? { max: num(parsed, "max") } : {}
|
|
2542
2792
|
});
|
|
2543
2793
|
}
|
|
2544
|
-
case "list":
|
|
2545
|
-
return runList(engine, target, num(parsed, "limit"));
|
|
2546
|
-
case "user-media":
|
|
2547
|
-
return runUserMedia(engine, extractHandle(target) ?? target, num(parsed, "limit"));
|
|
2548
|
-
case "followers":
|
|
2549
|
-
return runFollowers(engine, extractHandle(target) ?? target, num(parsed, "limit"));
|
|
2550
|
-
case "following":
|
|
2551
|
-
return runFollowing(engine, extractHandle(target) ?? target, num(parsed, "limit"));
|
|
2552
|
-
case "retweeters":
|
|
2553
|
-
return runRetweeters(engine, extractTweetId(target) ?? target, num(parsed, "limit"));
|
|
2554
|
-
case "likers":
|
|
2555
|
-
return runLikers(engine, extractTweetId(target) ?? target, num(parsed, "limit"));
|
|
2556
|
-
case "quoters":
|
|
2557
|
-
return runQuoters(engine, extractTweetId(target) ?? target, num(parsed, "limit"));
|
|
2558
|
-
case "trends":
|
|
2559
|
-
return runTrends(engine, {
|
|
2560
|
-
...num(parsed, "woeid") !== void 0 ? { woeid: num(parsed, "woeid") } : {},
|
|
2561
|
-
...num(parsed, "limit") !== void 0 ? { limit: num(parsed, "limit") } : {}
|
|
2562
|
-
});
|
|
2563
|
-
case "article":
|
|
2564
|
-
return runArticle(engine, extractTweetId(target) ?? target);
|
|
2565
|
-
case "media":
|
|
2566
|
-
return runMedia(engine, extractTweetId(target) ?? target, first(parsed, "out"));
|
|
2567
2794
|
default:
|
|
2568
|
-
return err("cli", "UNKNOWN_COMMAND", `unknown command: ${command ?? "(none)"}`);
|
|
2795
|
+
return dispatchReadOps(parsed, engine, command, target) ?? Promise.resolve(err("cli", "UNKNOWN_COMMAND", `unknown command: ${command ?? "(none)"}`));
|
|
2569
2796
|
}
|
|
2570
2797
|
}
|
|
2571
2798
|
async function run(argv, engine) {
|