pi-twitterapi.io 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,957 @@
1
+ import {
2
+ TWITTERAPI_BASE_URL,
3
+ isObject,
4
+ type FetchLike,
5
+ type SearchTermination,
6
+ type Tweet,
7
+ type TwitterApiSearchParams,
8
+ type UserProfile,
9
+ } from "./core.js";
10
+ import { asTweet, asUser, tweetIdFromInput } from "./tweet.js";
11
+ import {
12
+ CancelledError,
13
+ DEFAULT_MAX_PAGES_CEILING,
14
+ MAX_PAGE_BOUND,
15
+ boundedCount,
16
+ ensureSuccessfulPayload,
17
+ requestWithRetry,
18
+ resolveRequestSettings,
19
+ sleepAbortable,
20
+ type TwitterApiRequestOptions,
21
+ } from "./http.js";
22
+ import { advanceOrStop, isTruncated } from "./search.js";
23
+
24
+ export const USER_SEARCH_PATH = "/twitter/user/search";
25
+ export const THREAD_CONTEXT_PATH = "/twitter/tweet/thread_context";
26
+
27
+ export interface UserSearchDetails {
28
+ query: string;
29
+ users: UserProfile[];
30
+ pagesFetched: number;
31
+ /** Why pagination stopped. */
32
+ stoppedBy: SearchTermination;
33
+ /** True when accounts remained upstream (`page-cap` or `cursor-cycle`). */
34
+ truncated: boolean;
35
+ }
36
+
37
+ export interface SearchUsersOptions extends TwitterApiRequestOptions {
38
+ /** Max pages to fetch (default 3). Each page holds up to 20 accounts. */
39
+ maxPages?: number;
40
+ /** Max accounts to collect (default 20, max 50). */
41
+ count?: number;
42
+ }
43
+
44
+ /** Search X accounts by keyword via `/twitter/user/search`. */
45
+ export async function searchUsers(
46
+ query: string,
47
+ apiKey: string,
48
+ fetcher: FetchLike = fetch,
49
+ options: SearchUsersOptions = {},
50
+ ): Promise<UserSearchDetails> {
51
+ const trimmed = query?.trim();
52
+ if (!trimmed) throw new Error("twitter query must not be empty");
53
+ const settings = resolveRequestSettings(options);
54
+ const maxPages = boundedCount(options.maxPages, 3, MAX_PAGE_BOUND, "maxPages");
55
+ const target = boundedCount(options.count, 20, 50, "count");
56
+ const collected: UserProfile[] = [];
57
+ // Both identifiers, like the post path: an account can come back with an id on
58
+ // one page and only a handle (or a different case) on another.
59
+ const seenIds = new Set<string>();
60
+ const seenHandles = new Set<string>();
61
+ const seenCursors = new Set<string>();
62
+ let cursor = "";
63
+ let pages = 0;
64
+ let lastRequestAt: number | undefined;
65
+ let stoppedBy: SearchTermination = "page-cap";
66
+
67
+ while (collected.length < target && pages < maxPages) {
68
+ if (settings.signal?.aborted) throw new CancelledError();
69
+ if (settings.minRequestIntervalMs > 0 && lastRequestAt !== undefined) {
70
+ const wait = settings.minRequestIntervalMs - (settings.now() - lastRequestAt);
71
+ if (wait > 0) await sleepAbortable(wait, settings.signal, settings.sleep);
72
+ }
73
+ pages += 1;
74
+ const url = new URL(TWITTERAPI_BASE_URL + USER_SEARCH_PATH);
75
+ url.searchParams.set("query", trimmed);
76
+ if (cursor) url.searchParams.set("cursor", cursor);
77
+
78
+ const { response, body, attempts, bodyError } = await requestWithRetry(url.toString(), apiKey, fetcher, settings);
79
+ lastRequestAt = settings.now();
80
+ const payload = ensureSuccessfulPayload(response, body, bodyError, attempts);
81
+ if (!Array.isArray(payload.users)) {
82
+ throw new Error("twitterapi.io returned a malformed response (missing users array)");
83
+ }
84
+
85
+ for (const raw of payload.users) {
86
+ const user = asUser(raw);
87
+ if (!user) continue;
88
+ // Register both identifiers even when this occurrence is itself a
89
+ // duplicate, so an alias chain cannot let a third form through.
90
+ const handleKey = user.handle.toLowerCase();
91
+ const duplicate = (user.id !== undefined && seenIds.has(user.id)) || seenHandles.has(handleKey);
92
+ if (user.id) seenIds.add(user.id);
93
+ seenHandles.add(handleKey);
94
+ if (duplicate) continue;
95
+ collected.push(user);
96
+ if (collected.length >= target) break;
97
+ }
98
+
99
+ const step = advanceOrStop(payload, seenCursors);
100
+ if (typeof step === "string") {
101
+ stoppedBy = step;
102
+ break;
103
+ }
104
+ cursor = step.cursor;
105
+ }
106
+
107
+ if (collected.length >= target) stoppedBy = "target";
108
+ return {
109
+ query: trimmed,
110
+ users: collected,
111
+ pagesFetched: pages,
112
+ stoppedBy,
113
+ truncated: isTruncated(stoppedBy),
114
+ };
115
+ }
116
+
117
+ // -------------------------------------------------------------------- thread
118
+
119
+ export interface ThreadDetails {
120
+ tweetId: string;
121
+ tweets: Tweet[];
122
+ pagesFetched: number;
123
+ /** Why pagination stopped. */
124
+ stoppedBy: SearchTermination;
125
+ /** True when the thread continued upstream (`page-cap` or `cursor-cycle`). */
126
+ truncated: boolean;
127
+ }
128
+
129
+ export interface FetchThreadOptions extends TwitterApiRequestOptions {
130
+ /**
131
+ * Max pages of thread context (default 4). The upstream page size is not
132
+ * settable and is not fixed, so this bounds requests rather than posts.
133
+ */
134
+ maxPages?: number;
135
+ }
136
+
137
+ /**
138
+ * Fetch a post's thread context (the root post plus its replies).
139
+ */
140
+ export async function fetchThread(
141
+ tweetId: string,
142
+ apiKey: string,
143
+ fetcher: FetchLike = fetch,
144
+ options: FetchThreadOptions = {},
145
+ ): Promise<ThreadDetails> {
146
+ const id = tweetIdFromInput(tweetId);
147
+ if (!id) throw new Error(`twitter tweet must be a numeric post id or an X permalink (got "${tweetId}")`);
148
+ const settings = resolveRequestSettings(options);
149
+ const maxPages = boundedCount(options.maxPages, 4, MAX_PAGE_BOUND, "maxPages");
150
+ const collected: Tweet[] = [];
151
+ const seenIds = new Set<string>();
152
+ const seenUrls = new Set<string>();
153
+ const seenCursors = new Set<string>();
154
+ let cursor = "";
155
+ let pages = 0;
156
+ let lastRequestAt: number | undefined;
157
+ let stoppedBy: SearchTermination = "page-cap";
158
+
159
+ while (pages < maxPages) {
160
+ if (settings.signal?.aborted) throw new CancelledError();
161
+ if (settings.minRequestIntervalMs > 0 && lastRequestAt !== undefined) {
162
+ const wait = settings.minRequestIntervalMs - (settings.now() - lastRequestAt);
163
+ if (wait > 0) await sleepAbortable(wait, settings.signal, settings.sleep);
164
+ }
165
+ pages += 1;
166
+ const url = new URL(TWITTERAPI_BASE_URL + THREAD_CONTEXT_PATH);
167
+ url.searchParams.set("tweetId", id);
168
+ if (cursor) url.searchParams.set("cursor", cursor);
169
+
170
+ const { response, body, attempts, bodyError } = await requestWithRetry(url.toString(), apiKey, fetcher, settings);
171
+ lastRequestAt = settings.now();
172
+ const payload = ensureSuccessfulPayload(response, body, bodyError, attempts);
173
+ if (!Array.isArray(payload.tweets)) {
174
+ throw new Error("twitterapi.io returned a malformed response (missing tweets array)");
175
+ }
176
+
177
+ for (const raw of payload.tweets) {
178
+ const tweet = asTweet(raw);
179
+ if (!tweet) continue;
180
+ const duplicate =
181
+ (tweet.id !== undefined && seenIds.has(tweet.id)) ||
182
+ (tweet.url !== undefined && seenUrls.has(tweet.url));
183
+ if (tweet.id) seenIds.add(tweet.id);
184
+ if (tweet.url) seenUrls.add(tweet.url);
185
+ if (duplicate) continue;
186
+ collected.push(tweet);
187
+ }
188
+
189
+ // A genuinely EMPTY page means the walk is over: upstream warns that
190
+ // `has_next_page` can be true with no further data. A page that merely
191
+ // repeated posts we already hold is different — pagination overlap is
192
+ // normal, and stopping there would drop later posts while claiming the
193
+ // thread was fully read. Duplicate-only pages therefore continue under the
194
+ // page bounds, and running out of pages is reported as truncation.
195
+ if (payload.tweets.length === 0) {
196
+ stoppedBy = "exhausted";
197
+ break;
198
+ }
199
+ const step = advanceOrStop(payload, seenCursors);
200
+ if (typeof step === "string") {
201
+ stoppedBy = step;
202
+ break;
203
+ }
204
+ cursor = step.cursor;
205
+ }
206
+
207
+ return {
208
+ tweetId: id,
209
+ tweets: collected,
210
+ pagesFetched: pages,
211
+ stoppedBy,
212
+ truncated: isTruncated(stoppedBy),
213
+ };
214
+ }
215
+
216
+ // ----------------------------------------------------- generic tweet paging
217
+
218
+ /** Options shared by the read endpoints that walk `{ tweets, has_next_page, next_cursor }`. */
219
+ export interface TweetPagingOptions extends TwitterApiRequestOptions {
220
+ /** Base page budget (default 3). Each page holds up to ~20 posts. */
221
+ maxPages?: number;
222
+ /** Hard ceiling the base budget is clamped to (default 20). */
223
+ maxPagesCeiling?: number;
224
+ /** Stop once this many unique posts are collected. */
225
+ limit?: number;
226
+ }
227
+
228
+ export interface TweetCollection {
229
+ tweets: Tweet[];
230
+ pagesFetched: number;
231
+ stoppedBy: SearchTermination;
232
+ /** True when posts remained upstream (`page-cap` or `cursor-cycle`). */
233
+ truncated: boolean;
234
+ }
235
+
236
+ /**
237
+ * Cursor-paginate any endpoint returning `{ tweets, has_next_page, next_cursor }`.
238
+ *
239
+ * Shared by the account, reply, quote and mention reads. It keeps the same
240
+ * honesty rules as the search path: pacing between requests, retries on 429/503,
241
+ * duplicate suppression, and an explicit reason when the walk stopped short of
242
+ * exhausting the upstream.
243
+ */
244
+ async function walkTweets(
245
+ path: string,
246
+ apiKey: string,
247
+ fetcher: FetchLike,
248
+ options: TweetPagingOptions & {
249
+ params?: Record<string, string | number | boolean | undefined>;
250
+ /** Where this endpoint puts its tweet array; defaults to the top level. */
251
+ extract?: (payload: Record<string, unknown>) => unknown[] | undefined;
252
+ },
253
+ ): Promise<TweetCollection> {
254
+ const settings = resolveRequestSettings(options);
255
+ const ceiling = boundedCount(options.maxPagesCeiling, DEFAULT_MAX_PAGES_CEILING, MAX_PAGE_BOUND, "maxPagesCeiling");
256
+ const maxPages = Math.min(boundedCount(options.maxPages, 3, MAX_PAGE_BOUND, "maxPages"), ceiling);
257
+ const limit = options.limit === undefined ? undefined : boundedCount(options.limit, 20, 1_000, "limit");
258
+ const collected: Tweet[] = [];
259
+ const seenIds = new Set<string>();
260
+ const seenUrls = new Set<string>();
261
+ const seenCursors = new Set<string>();
262
+ let cursor = "";
263
+ let pages = 0;
264
+ let lastRequestAt: number | undefined;
265
+ let stoppedBy: SearchTermination = "page-cap";
266
+
267
+ while (pages < maxPages && (limit === undefined || collected.length < limit)) {
268
+ if (settings.signal?.aborted) throw new CancelledError();
269
+ if (settings.minRequestIntervalMs > 0 && lastRequestAt !== undefined) {
270
+ const wait = settings.minRequestIntervalMs - (settings.now() - lastRequestAt);
271
+ if (wait > 0) await sleepAbortable(wait, settings.signal, settings.sleep);
272
+ }
273
+ pages += 1;
274
+ const url = new URL(TWITTERAPI_BASE_URL + path);
275
+ for (const [key, value] of Object.entries(options.params ?? {})) {
276
+ if (value === undefined || value === "") continue;
277
+ url.searchParams.set(key, String(value));
278
+ }
279
+ if (cursor) url.searchParams.set("cursor", cursor);
280
+
281
+ const { response, body, attempts, bodyError } = await requestWithRetry(url.toString(), apiKey, fetcher, settings);
282
+ lastRequestAt = settings.now();
283
+ const payload = ensureSuccessfulPayload(response, body, bodyError, attempts);
284
+ const rawTweets = options.extract ? options.extract(payload) : payload.tweets;
285
+ if (!Array.isArray(rawTweets)) {
286
+ throw new Error("twitterapi.io returned a malformed response (missing tweets array)");
287
+ }
288
+
289
+ for (const raw of rawTweets) {
290
+ const tweet = asTweet(raw);
291
+ if (!tweet) continue;
292
+ const duplicate =
293
+ (tweet.id !== undefined && seenIds.has(tweet.id)) ||
294
+ (tweet.url !== undefined && seenUrls.has(tweet.url));
295
+ if (tweet.id) seenIds.add(tweet.id);
296
+ if (tweet.url) seenUrls.add(tweet.url);
297
+ if (duplicate) continue;
298
+ collected.push(tweet);
299
+ if (limit !== undefined && collected.length >= limit) break;
300
+ }
301
+
302
+ const step = advanceOrStop(payload, seenCursors);
303
+ if (typeof step === "string") {
304
+ stoppedBy = step;
305
+ break;
306
+ }
307
+ cursor = step.cursor;
308
+ }
309
+ if (limit !== undefined && collected.length >= limit) stoppedBy = "target";
310
+
311
+ return { tweets: collected, pagesFetched: pages, stoppedBy, truncated: isTruncated(stoppedBy) };
312
+ }
313
+
314
+ // -------------------------------------------------- account timeline (P1)
315
+
316
+ export const USER_LAST_TWEETS_PATH = "/twitter/user/last_tweets";
317
+ export const TWEET_REPLIES_PATH = "/twitter/tweet/replies/v2";
318
+ export const TWEET_QUOTES_PATH = "/twitter/tweet/quotes";
319
+ export const TRENDS_PATH = "/twitter/trends";
320
+
321
+ export interface UserTweetsDetails extends TweetCollection {
322
+ userName?: string;
323
+ userId?: string;
324
+ }
325
+
326
+ export interface FetchUserTweetsOptions extends TweetPagingOptions {
327
+ /** Include the account's replies in addition to top-level posts. */
328
+ includeReplies?: boolean;
329
+ }
330
+
331
+ /** Fetch an account's most recent posts via `/twitter/user/last_tweets`. */
332
+ export async function fetchUserTweets(
333
+ ref: { userName?: string; userId?: string },
334
+ apiKey: string,
335
+ fetcher: FetchLike = fetch,
336
+ options: FetchUserTweetsOptions = {},
337
+ ): Promise<UserTweetsDetails> {
338
+ const userName = ref.userName?.trim().replace(/^@+/, "");
339
+ const userId = ref.userId?.trim();
340
+ if (!userName && !userId) throw new Error('twitter mode "user" needs a userName or userId');
341
+ const { includeReplies, ...paging } = options;
342
+ const result = await walkTweets(USER_LAST_TWEETS_PATH, apiKey, fetcher, {
343
+ ...paging,
344
+ params: { userName, userId, includeReplies: includeReplies === true ? "true" : undefined },
345
+ // `/twitter/user/last_tweets` returns `{ data: { tweets, pin_tweet } }`,
346
+ // unlike the other reads that put `tweets` at the top level.
347
+ extract: (payload) => {
348
+ const data = payload.data;
349
+ if (typeof data === "object" && data !== null && Array.isArray((data as Record<string, unknown>).tweets)) {
350
+ return (data as Record<string, unknown>).tweets as unknown[];
351
+ }
352
+ return Array.isArray(payload.tweets) ? payload.tweets : undefined;
353
+ },
354
+ });
355
+ return { ...result, userName, userId };
356
+ }
357
+
358
+ // ------------------------------------------------------- replies (P1)
359
+
360
+ export type ReplySort = "Relevance" | "Latest" | "Likes";
361
+
362
+ export interface TweetRepliesDetails extends TweetCollection {
363
+ tweetId: string;
364
+ }
365
+
366
+ export interface FetchTweetRepliesOptions extends TweetPagingOptions {
367
+ /** Sort order for replies (default Relevance). */
368
+ queryType?: ReplySort;
369
+ }
370
+
371
+ /** Fetch replies to a post via `/twitter/tweet/replies/v2`. */
372
+ export async function fetchTweetReplies(
373
+ tweet: string,
374
+ apiKey: string,
375
+ fetcher: FetchLike = fetch,
376
+ options: FetchTweetRepliesOptions = {},
377
+ ): Promise<TweetRepliesDetails> {
378
+ const id = tweetIdFromInput(tweet);
379
+ if (!id) throw new Error(`twitter tweet must be a numeric post id or an X permalink (got "${tweet}")`);
380
+ const { queryType, ...paging } = options;
381
+ if (queryType !== undefined && queryType !== "Relevance" && queryType !== "Latest" && queryType !== "Likes") {
382
+ throw new Error(`twitter reply queryType must be "Relevance", "Latest" or "Likes" (got "${queryType}")`);
383
+ }
384
+ const result = await walkTweets(TWEET_REPLIES_PATH, apiKey, fetcher, {
385
+ ...paging,
386
+ params: { tweetId: id, queryType },
387
+ });
388
+ return { ...result, tweetId: id };
389
+ }
390
+
391
+ // -------------------------------------------------------- quotes (P1)
392
+
393
+ export interface TweetQuotesDetails extends TweetCollection {
394
+ tweetId: string;
395
+ }
396
+
397
+ export interface FetchTweetQuotesOptions extends TweetPagingOptions {
398
+ /** Only quotes on or after this unix timestamp (seconds). */
399
+ sinceTime?: number;
400
+ /** Only quotes before this unix timestamp (seconds). */
401
+ untilTime?: number;
402
+ /** Include replies among the quotes (upstream default true). */
403
+ includeReplies?: boolean;
404
+ }
405
+
406
+ /** Fetch quote-posts of a post via `/twitter/tweet/quotes`. */
407
+ export async function fetchTweetQuotes(
408
+ tweet: string,
409
+ apiKey: string,
410
+ fetcher: FetchLike = fetch,
411
+ options: FetchTweetQuotesOptions = {},
412
+ ): Promise<TweetQuotesDetails> {
413
+ const id = tweetIdFromInput(tweet);
414
+ if (!id) throw new Error(`twitter tweet must be a numeric post id or an X permalink (got "${tweet}")`);
415
+ const { sinceTime, untilTime, includeReplies, ...paging } = options;
416
+ for (const [name, value] of [["sinceTime", sinceTime], ["untilTime", untilTime]] as const) {
417
+ if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
418
+ throw new Error(`twitter ${name} must be a non-negative unix timestamp in seconds`);
419
+ }
420
+ }
421
+ if (sinceTime !== undefined && untilTime !== undefined && sinceTime > untilTime) {
422
+ throw new Error("twitter sinceTime must be before or equal to untilTime");
423
+ }
424
+ const result = await walkTweets(TWEET_QUOTES_PATH, apiKey, fetcher, {
425
+ ...paging,
426
+ params: {
427
+ tweetId: id,
428
+ sinceTime,
429
+ untilTime,
430
+ includeReplies: includeReplies === undefined ? undefined : includeReplies ? "true" : "false",
431
+ },
432
+ });
433
+ return { ...result, tweetId: id };
434
+ }
435
+
436
+ // -------------------------------------------------------- trends (P1)
437
+
438
+ export interface Trend {
439
+ name: string;
440
+ rank?: number;
441
+ /** The search expression X uses for this trend (e.g. `#elonmusk`). */
442
+ query?: string;
443
+ /** Human-readable volume hint such as "17.7K posts" when upstream provides it. */
444
+ metaDescription?: string;
445
+ }
446
+
447
+ export interface TrendsDetails {
448
+ woeid: number;
449
+ trends: Trend[];
450
+ }
451
+
452
+ function asTrend(raw: unknown): Trend | undefined {
453
+ if (typeof raw !== "object" || raw === null) return undefined;
454
+ const outer = raw as Record<string, unknown>;
455
+ // Upstream wraps each item as `{ trend: { name, target, rank } }`; a flat item
456
+ // is also accepted so a shape change does not silently drop every trend.
457
+ const record =
458
+ typeof outer.trend === "object" && outer.trend !== null
459
+ ? (outer.trend as Record<string, unknown>)
460
+ : outer;
461
+ const name = typeof record.name === "string" ? record.name.trim() : "";
462
+ if (!name) return undefined;
463
+ const target = record.target;
464
+ const query =
465
+ typeof target === "object" && target !== null && typeof (target as Record<string, unknown>).query === "string"
466
+ ? ((target as Record<string, unknown>).query as string).trim()
467
+ : undefined;
468
+ const trend: Trend = { name };
469
+ if (typeof record.rank === "number") trend.rank = record.rank;
470
+ if (query) trend.query = query;
471
+ const metaDescription =
472
+ typeof record.meta_description === "string" ? record.meta_description : outer.meta_description;
473
+ if (typeof metaDescription === "string" && metaDescription.trim()) {
474
+ trend.metaDescription = metaDescription.trim();
475
+ }
476
+ return trend;
477
+ }
478
+
479
+ export interface FetchTrendsOptions extends TwitterApiRequestOptions {
480
+ /** Number of trends to return. Upstream default 30; minimum 30. */
481
+ count?: number;
482
+ }
483
+
484
+ /** Fetch trending topics for a location via `/twitter/trends`. */
485
+ export async function fetchTrends(
486
+ woeid: number,
487
+ apiKey: string,
488
+ fetcher: FetchLike = fetch,
489
+ options: FetchTrendsOptions = {},
490
+ ): Promise<TrendsDetails> {
491
+ if (!Number.isInteger(woeid)) throw new Error(`twitter woeid must be an integer (got ${String(woeid)})`);
492
+ const settings = resolveRequestSettings(options);
493
+ const url = new URL(TWITTERAPI_BASE_URL + TRENDS_PATH);
494
+ url.searchParams.set("woeid", String(woeid));
495
+ if (options.count !== undefined) {
496
+ if (!Number.isInteger(options.count) || options.count < 30) {
497
+ throw new Error("twitter trend count must be an integer >= 30");
498
+ }
499
+ url.searchParams.set("count", String(options.count));
500
+ }
501
+ const { response, body, attempts, bodyError } = await requestWithRetry(url.toString(), apiKey, fetcher, settings);
502
+ const payload = ensureSuccessfulPayload(response, body, bodyError, attempts);
503
+ if (!Array.isArray(payload.trends)) {
504
+ throw new Error("twitterapi.io returned a malformed response (missing trends array)");
505
+ }
506
+ const trends: Trend[] = [];
507
+ const seen = new Set<string>();
508
+ for (const raw of payload.trends) {
509
+ const trend = asTrend(raw);
510
+ if (!trend) continue;
511
+ const key = trend.name.toLowerCase();
512
+ if (seen.has(key)) continue;
513
+ seen.add(key);
514
+ trends.push(trend);
515
+ }
516
+ return { woeid, trends };
517
+ }
518
+
519
+ // -------------------------------------------- accounts & lookups (P2)
520
+
521
+ export const USER_MENTIONS_PATH = "/twitter/user/mentions";
522
+ export const USER_FOLLOWERS_PATH = "/twitter/user/followers";
523
+ export const USER_FOLLOWINGS_PATH = "/twitter/user/followings";
524
+ export const USER_INFO_PATH = "/twitter/user/info";
525
+ export const TWEETS_BY_IDS_PATH = "/twitter/tweets";
526
+
527
+ export interface UserCollection {
528
+ users: UserProfile[];
529
+ pagesFetched: number;
530
+ stoppedBy: SearchTermination;
531
+ /** True when accounts remained upstream (`page-cap` or `cursor-cycle`). */
532
+ truncated: boolean;
533
+ }
534
+
535
+ /** Cursor-paginate an endpoint that returns an account array under `arrayKey`. */
536
+ async function walkUsers(
537
+ path: string,
538
+ apiKey: string,
539
+ fetcher: FetchLike,
540
+ options: TweetPagingOptions & {
541
+ arrayKey: string;
542
+ params?: Record<string, string | number | boolean | undefined>;
543
+ },
544
+ ): Promise<UserCollection> {
545
+ const settings = resolveRequestSettings(options);
546
+ const ceiling = boundedCount(options.maxPagesCeiling, DEFAULT_MAX_PAGES_CEILING, MAX_PAGE_BOUND, "maxPagesCeiling");
547
+ const maxPages = Math.min(boundedCount(options.maxPages, 3, MAX_PAGE_BOUND, "maxPages"), ceiling);
548
+ const limit = options.limit === undefined ? undefined : boundedCount(options.limit, 20, 1_000, "limit");
549
+ const collected: UserProfile[] = [];
550
+ const seenIds = new Set<string>();
551
+ const seenHandles = new Set<string>();
552
+ const seenCursors = new Set<string>();
553
+ let cursor = "";
554
+ let pages = 0;
555
+ let lastRequestAt: number | undefined;
556
+ let stoppedBy: SearchTermination = "page-cap";
557
+
558
+ while (pages < maxPages && (limit === undefined || collected.length < limit)) {
559
+ if (settings.signal?.aborted) throw new CancelledError();
560
+ if (settings.minRequestIntervalMs > 0 && lastRequestAt !== undefined) {
561
+ const wait = settings.minRequestIntervalMs - (settings.now() - lastRequestAt);
562
+ if (wait > 0) await sleepAbortable(wait, settings.signal, settings.sleep);
563
+ }
564
+ pages += 1;
565
+ const url = new URL(TWITTERAPI_BASE_URL + path);
566
+ for (const [key, value] of Object.entries(options.params ?? {})) {
567
+ if (value === undefined || value === "") continue;
568
+ url.searchParams.set(key, String(value));
569
+ }
570
+ if (cursor) url.searchParams.set("cursor", cursor);
571
+
572
+ const { response, body, attempts, bodyError } = await requestWithRetry(url.toString(), apiKey, fetcher, settings);
573
+ lastRequestAt = settings.now();
574
+ const payload = ensureSuccessfulPayload(response, body, bodyError, attempts);
575
+ if (!Array.isArray(payload[options.arrayKey])) {
576
+ throw new Error(`twitterapi.io returned a malformed response (missing ${options.arrayKey} array)`);
577
+ }
578
+
579
+ for (const raw of payload[options.arrayKey] as unknown[]) {
580
+ const user = asUser(raw);
581
+ if (!user) continue;
582
+ const handleKey = user.handle.toLowerCase();
583
+ const duplicate = (user.id !== undefined && seenIds.has(user.id)) || seenHandles.has(handleKey);
584
+ if (user.id) seenIds.add(user.id);
585
+ seenHandles.add(handleKey);
586
+ if (duplicate) continue;
587
+ collected.push(user);
588
+ if (limit !== undefined && collected.length >= limit) break;
589
+ }
590
+
591
+ const step = advanceOrStop(payload, seenCursors);
592
+ if (typeof step === "string") {
593
+ stoppedBy = step;
594
+ break;
595
+ }
596
+ cursor = step.cursor;
597
+ }
598
+ if (limit !== undefined && collected.length >= limit) stoppedBy = "target";
599
+
600
+ return { users: collected, pagesFetched: pages, stoppedBy, truncated: isTruncated(stoppedBy) };
601
+ }
602
+
603
+ /** Validate an optional unix-seconds bound. */
604
+ function unixSeconds(value: number | undefined, name: string): void {
605
+ if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
606
+ throw new Error(`twitter ${name} must be a non-negative unix timestamp in seconds`);
607
+ }
608
+ }
609
+
610
+ function requireUserName(userName: string | undefined, mode: string): string {
611
+ const handle = userName?.trim().replace(/^@+/, "");
612
+ if (!handle) throw new Error(`twitter mode "${mode}" needs a userName`);
613
+ return handle;
614
+ }
615
+
616
+ export interface UserMentionsDetails extends TweetCollection {
617
+ userName: string;
618
+ }
619
+
620
+ export interface FetchUserMentionsOptions extends TweetPagingOptions {
621
+ sinceTime?: number;
622
+ untilTime?: number;
623
+ }
624
+
625
+ /** Fetch posts that mention an account via `/twitter/user/mentions`. */
626
+ export async function fetchUserMentions(
627
+ userName: string,
628
+ apiKey: string,
629
+ fetcher: FetchLike = fetch,
630
+ options: FetchUserMentionsOptions = {},
631
+ ): Promise<UserMentionsDetails> {
632
+ const handle = requireUserName(userName, "mentions");
633
+ const { sinceTime, untilTime, ...paging } = options;
634
+ unixSeconds(sinceTime, "sinceTime");
635
+ unixSeconds(untilTime, "untilTime");
636
+ if (sinceTime !== undefined && untilTime !== undefined && sinceTime > untilTime) {
637
+ throw new Error("twitter sinceTime must be before or equal to untilTime");
638
+ }
639
+ const result = await walkTweets(USER_MENTIONS_PATH, apiKey, fetcher, {
640
+ ...paging,
641
+ params: { userName: handle, sinceTime, untilTime },
642
+ });
643
+ return { ...result, userName: handle };
644
+ }
645
+
646
+ export interface FollowersDetails extends UserCollection {
647
+ userName: string;
648
+ }
649
+
650
+ export interface FetchFollowOptions extends TweetPagingOptions {
651
+ /** Accounts per page, upstream accepts 20–200. */
652
+ pageSize?: number;
653
+ }
654
+
655
+ function followOptions(
656
+ handle: string,
657
+ options: FetchFollowOptions,
658
+ ): TweetPagingOptions & { params: Record<string, string | number | undefined> } {
659
+ const { pageSize, ...paging } = options;
660
+ if (pageSize !== undefined && (!Number.isInteger(pageSize) || pageSize < 20 || pageSize > 200)) {
661
+ throw new Error("twitter pageSize must be an integer between 20 and 200");
662
+ }
663
+ return { ...paging, params: { userName: handle, pageSize } };
664
+ }
665
+
666
+ /** Fetch an account's followers via `/twitter/user/followers`. */
667
+ export async function fetchFollowers(
668
+ userName: string,
669
+ apiKey: string,
670
+ fetcher: FetchLike = fetch,
671
+ options: FetchFollowOptions = {},
672
+ ): Promise<FollowersDetails> {
673
+ const handle = requireUserName(userName, "followers");
674
+ const result = await walkUsers(USER_FOLLOWERS_PATH, apiKey, fetcher, {
675
+ ...followOptions(handle, options),
676
+ arrayKey: "followers",
677
+ });
678
+ return { ...result, userName: handle };
679
+ }
680
+
681
+ /** Fetch the accounts an account follows via `/twitter/user/followings`. */
682
+ export async function fetchFollowings(
683
+ userName: string,
684
+ apiKey: string,
685
+ fetcher: FetchLike = fetch,
686
+ options: FetchFollowOptions = {},
687
+ ): Promise<FollowersDetails> {
688
+ const handle = requireUserName(userName, "followings");
689
+ const result = await walkUsers(USER_FOLLOWINGS_PATH, apiKey, fetcher, {
690
+ ...followOptions(handle, options),
691
+ arrayKey: "followings",
692
+ });
693
+ return { ...result, userName: handle };
694
+ }
695
+
696
+ /** Fetch a single profile via `/twitter/user/info`. */
697
+ export async function fetchUserProfile(
698
+ userName: string,
699
+ apiKey: string,
700
+ fetcher: FetchLike = fetch,
701
+ options: TwitterApiRequestOptions = {},
702
+ ): Promise<UserProfile> {
703
+ const handle = requireUserName(userName, "profile");
704
+ const settings = resolveRequestSettings(options);
705
+ const url = new URL(TWITTERAPI_BASE_URL + USER_INFO_PATH);
706
+ url.searchParams.set("userName", handle);
707
+ const { response, body, attempts, bodyError } = await requestWithRetry(url.toString(), apiKey, fetcher, settings);
708
+ const payload = ensureSuccessfulPayload(response, body, bodyError, attempts);
709
+ const user = asUser(payload.data);
710
+ if (!user) throw new Error(`twitterapi.io returned no profile for "${handle}"`);
711
+ return user;
712
+ }
713
+
714
+ /** Fetch specific posts by id via `/twitter/tweets` (max 100 ids). */
715
+ export async function fetchTweetsByIds(
716
+ ids: readonly string[],
717
+ apiKey: string,
718
+ fetcher: FetchLike = fetch,
719
+ options: TwitterApiRequestOptions = {},
720
+ ): Promise<TweetCollection> {
721
+ // The tool accepts ids or permalinks, so every entry is canonicalised to a
722
+ // post id before the paid endpoint is called; an unusable reference is
723
+ // rejected rather than sent upstream.
724
+ const cleaned: string[] = [];
725
+ const requested = new Set<string>();
726
+ for (const raw of ids) {
727
+ const id = tweetIdFromInput(raw);
728
+ if (!id) throw new Error(`twitter mode "tweets" needs numeric post ids or X permalinks (got "${raw}")`);
729
+ if (requested.has(id)) continue;
730
+ requested.add(id);
731
+ cleaned.push(id);
732
+ }
733
+ if (cleaned.length === 0) throw new Error("twitter mode \"tweets\" needs at least one id in `ids`");
734
+ if (cleaned.length > 100) throw new Error("twitter mode \"tweets\" accepts at most 100 ids");
735
+ const settings = resolveRequestSettings(options);
736
+ const url = new URL(TWITTERAPI_BASE_URL + TWEETS_BY_IDS_PATH);
737
+ url.searchParams.set("tweet_ids", cleaned.join(","));
738
+ const { response, body, attempts, bodyError } = await requestWithRetry(url.toString(), apiKey, fetcher, settings);
739
+ const payload = ensureSuccessfulPayload(response, body, bodyError, attempts);
740
+ if (!Array.isArray(payload.tweets)) {
741
+ throw new Error("twitterapi.io returned a malformed response (missing tweets array)");
742
+ }
743
+ const tweets: Tweet[] = [];
744
+ // Register both identifiers, like the paginated walkers: a post returned with
745
+ // an id and again with only its permalink is the same post.
746
+ const seenIds = new Set<string>();
747
+ const seenUrls = new Set<string>();
748
+ for (const raw of payload.tweets) {
749
+ const tweet = asTweet(raw);
750
+ if (!tweet) continue;
751
+ const duplicate =
752
+ (tweet.id !== undefined && seenIds.has(tweet.id)) ||
753
+ (tweet.url !== undefined && seenUrls.has(tweet.url));
754
+ if (tweet.id) seenIds.add(tweet.id);
755
+ if (tweet.url) seenUrls.add(tweet.url);
756
+ if (duplicate) continue;
757
+ tweets.push(tweet);
758
+ }
759
+ return { tweets, pagesFetched: 1, stoppedBy: "exhausted", truncated: false };
760
+ }
761
+
762
+ // --------------------------------------- communities, lists, spaces (P3)
763
+
764
+ export const COMMUNITY_TWEETS_PATH = "/twitter/community/tweets";
765
+ export const LIST_TWEETS_PATH = "/twitter/list/tweets_timeline";
766
+ export const SPACE_DETAIL_PATH = "/twitter/spaces/detail";
767
+
768
+ /** Fetch posts from a community via `/twitter/community/tweets`. */
769
+ export async function fetchCommunityTweets(
770
+ communityId: string,
771
+ apiKey: string,
772
+ fetcher: FetchLike = fetch,
773
+ options: TweetPagingOptions = {},
774
+ ): Promise<TweetCollection> {
775
+ const id = communityId?.trim();
776
+ if (!id) throw new Error('twitter mode "community" needs a communityId');
777
+ return walkTweets(COMMUNITY_TWEETS_PATH, apiKey, fetcher, { ...options, params: { community_id: id } });
778
+ }
779
+
780
+ /** Fetch posts from a list via `/twitter/list/tweets_timeline`. */
781
+ export async function fetchListTweets(
782
+ listId: string,
783
+ apiKey: string,
784
+ fetcher: FetchLike = fetch,
785
+ options: TweetPagingOptions = {},
786
+ ): Promise<TweetCollection> {
787
+ const id = listId?.trim();
788
+ if (!id) throw new Error('twitter mode "list" needs a listId');
789
+ return walkTweets(LIST_TWEETS_PATH, apiKey, fetcher, { ...options, params: { listId: id } });
790
+ }
791
+
792
+ /** A Space's detail payload (`/twitter/spaces/detail` nests it under `data`). */
793
+ export interface SpaceDetails {
794
+ id: string;
795
+ data: Record<string, unknown>;
796
+ }
797
+
798
+ /** Fetch an X Space's detail via `/twitter/spaces/detail`. */
799
+ export async function fetchSpaceDetail(
800
+ spaceId: string,
801
+ apiKey: string,
802
+ fetcher: FetchLike = fetch,
803
+ options: TwitterApiRequestOptions = {},
804
+ ): Promise<SpaceDetails> {
805
+ const id = spaceId?.trim();
806
+ if (!id) throw new Error('twitter mode "space" needs a spaceId');
807
+ const settings = resolveRequestSettings(options);
808
+ const url = new URL(TWITTERAPI_BASE_URL + SPACE_DETAIL_PATH);
809
+ url.searchParams.set("space_id", id);
810
+ const { response, body, attempts, bodyError } = await requestWithRetry(url.toString(), apiKey, fetcher, settings);
811
+ const payload = ensureSuccessfulPayload(response, body, bodyError, attempts);
812
+ // Live responses nest the object under `detail` (the docs say `data`), and use
813
+ // a string there to report "not found". Accept both envelopes.
814
+ const raw = payload.detail ?? payload.data;
815
+ if (typeof raw === "string" && raw.trim()) {
816
+ throw new Error(`twitterapi.io space lookup failed: ${raw.trim()}`);
817
+ }
818
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
819
+ throw new Error("twitterapi.io returned no space detail");
820
+ }
821
+ return { id, data: raw as Record<string, unknown> };
822
+ }
823
+
824
+ // --------------------------------------- about & retweeters (P2b)
825
+
826
+ export const USER_ABOUT_PATH = "/twitter/user_about";
827
+ export const TWEET_RETWEETERS_PATH = "/twitter/tweet/retweeters";
828
+
829
+ /** Extended profile-page metadata from `/twitter/user_about`. */
830
+ export interface UserAbout {
831
+ id?: string;
832
+ /** Handle without "@". */
833
+ handle: string;
834
+ name?: string;
835
+ bio?: string;
836
+ createdAt?: string;
837
+ profilePicture?: string;
838
+ verified?: boolean;
839
+ protected?: boolean;
840
+ /** Country/region hint from the about page, when provided. */
841
+ accountBasedIn?: string;
842
+ /** Client/region the account was created from, when provided. */
843
+ source?: string;
844
+ locationAccurate?: boolean;
845
+ createdCountryAccurate?: boolean;
846
+ /** Handle-change count and when the last change happened (epoch ms). */
847
+ usernameChanges?: { count?: number; lastChangedAtMs?: number };
848
+ /** Identity-verification state reported on the about page. */
849
+ identityVerified?: boolean;
850
+ /** When identity verification was granted (epoch ms), when provided. */
851
+ verifiedSinceMsec?: number;
852
+ /** Always constructed: `https://x.com/<handle>`. */
853
+ profileUrl: string;
854
+ }
855
+
856
+ /** Parse a finite number from a number or a numeric string (upstream mixes both). */
857
+ function finiteNumber(value: unknown): number | undefined {
858
+ if (typeof value === "number" && Number.isFinite(value)) return value;
859
+ if (typeof value === "string" && value.trim() !== "") {
860
+ const parsed = Number(value);
861
+ if (Number.isFinite(parsed)) return parsed;
862
+ }
863
+ return undefined;
864
+ }
865
+
866
+ function asUserAbout(raw: unknown): UserAbout | undefined {
867
+ if (!isObject(raw)) return undefined;
868
+ const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
869
+ const bool = (v: unknown) => (typeof v === "boolean" ? v : undefined);
870
+ const handle = str(raw.screen_name) ?? str(raw.userName) ?? str(raw.username);
871
+ if (!handle) return undefined;
872
+ const about = isObject(raw.about_profile) ? raw.about_profile : undefined;
873
+ const usernameChangesRaw = about && isObject(about.username_changes) ? about.username_changes : undefined;
874
+ const verification = isObject(raw.verification_info) ? raw.verification_info : undefined;
875
+ const reason = verification && isObject(verification.reason) ? verification.reason : undefined;
876
+
877
+ const user: UserAbout = { handle, profileUrl: `https://x.com/${handle}` };
878
+ const id = str(raw.id);
879
+ if (id) user.id = id;
880
+ const name = str(raw.name);
881
+ if (name) user.name = name;
882
+ const bio = str(raw.description) ?? str(raw.bio);
883
+ if (bio) user.bio = bio;
884
+ const createdAt = str(raw.createdAt) ?? str(raw.created_at);
885
+ if (createdAt) user.createdAt = createdAt;
886
+ const profilePicture = str(raw.profilePicture) ?? str(raw.profile_picture);
887
+ if (profilePicture) user.profilePicture = profilePicture;
888
+ // Booleans are preserved when supplied, including `false`: a false
889
+ // verification, protected or accuracy flag is a real answer, and dropping it
890
+ // would make "not verified" indistinguishable from "not reported".
891
+ if ([raw.isBlueVerified, raw.isVerified, raw.verified].some((value) => typeof value === "boolean")) {
892
+ user.verified = raw.isBlueVerified === true || raw.isVerified === true || raw.verified === true;
893
+ }
894
+ const protectedFlag = bool(raw.protected);
895
+ if (protectedFlag !== undefined) user.protected = protectedFlag;
896
+ const accountBasedIn = about ? str(about.account_based_in) : undefined;
897
+ if (accountBasedIn) user.accountBasedIn = accountBasedIn;
898
+ const source = about ? str(about.source) : undefined;
899
+ if (source) user.source = source;
900
+ const locationAccurate = about ? bool(about.location_accurate) : undefined;
901
+ if (locationAccurate !== undefined) user.locationAccurate = locationAccurate;
902
+ const createdCountryAccurate = about ? bool(about.created_country_accurate) : undefined;
903
+ if (createdCountryAccurate !== undefined) user.createdCountryAccurate = createdCountryAccurate;
904
+ if (usernameChangesRaw) {
905
+ const count = finiteNumber(usernameChangesRaw.count);
906
+ const lastChangedAtMs = finiteNumber(usernameChangesRaw.last_changed_at_msec);
907
+ if (count !== undefined || lastChangedAtMs !== undefined) {
908
+ user.usernameChanges = {};
909
+ if (count !== undefined) user.usernameChanges.count = count;
910
+ if (lastChangedAtMs !== undefined) user.usernameChanges.lastChangedAtMs = lastChangedAtMs;
911
+ }
912
+ }
913
+ const identityVerified = verification ? bool(verification.is_identity_verified) : undefined;
914
+ if (identityVerified !== undefined) user.identityVerified = identityVerified;
915
+ const verifiedSince = reason ? finiteNumber(reason.verified_since_msec) : undefined;
916
+ if (verifiedSince !== undefined) user.verifiedSinceMsec = verifiedSince;
917
+ return user;
918
+ }
919
+
920
+ /** Fetch a user's extended "about" page metadata via `/twitter/user_about`. */
921
+ export async function fetchUserAbout(
922
+ userName: string,
923
+ apiKey: string,
924
+ fetcher: FetchLike = fetch,
925
+ options: TwitterApiRequestOptions = {},
926
+ ): Promise<UserAbout> {
927
+ const handle = requireUserName(userName, "about");
928
+ const settings = resolveRequestSettings(options);
929
+ const url = new URL(TWITTERAPI_BASE_URL + USER_ABOUT_PATH);
930
+ url.searchParams.set("userName", handle);
931
+ const { response, body, attempts, bodyError } = await requestWithRetry(url.toString(), apiKey, fetcher, settings);
932
+ const payload = ensureSuccessfulPayload(response, body, bodyError, attempts);
933
+ const user = asUserAbout(payload.data ?? payload);
934
+ if (!user) throw new Error(`twitterapi.io returned no about data for "${handle}"`);
935
+ return user;
936
+ }
937
+
938
+ export interface RetweetersDetails extends UserCollection {
939
+ tweetId: string;
940
+ }
941
+
942
+ /** Fetch users who retweeted a post via `/twitter/tweet/retweeters`. */
943
+ export async function fetchTweetRetweeters(
944
+ tweet: string,
945
+ apiKey: string,
946
+ fetcher: FetchLike = fetch,
947
+ options: TweetPagingOptions = {},
948
+ ): Promise<RetweetersDetails> {
949
+ const id = tweetIdFromInput(tweet);
950
+ if (!id) throw new Error(`twitter tweet must be a numeric post id or an X permalink (got "${tweet}")`);
951
+ const result = await walkUsers(TWEET_RETWEETERS_PATH, apiKey, fetcher, {
952
+ ...options,
953
+ params: { tweetId: id },
954
+ arrayKey: "users",
955
+ });
956
+ return { ...result, tweetId: id };
957
+ }