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,340 @@
1
+ import { isObject, type FetchLike } from "./core.js";
2
+
3
+ /**
4
+ * Upper bound on any single retry delay. A server-supplied `Retry-After` longer
5
+ * than this is not clamped-and-retried-early — that would spend the remaining
6
+ * attempts while the limit is still in force — the call fails visibly instead.
7
+ */
8
+ export const MAX_RETRY_DELAY_MS = 60_000;
9
+
10
+ /** Default ceiling for the adaptive page budget (see `maxPagesCeiling`). */
11
+ export const DEFAULT_MAX_PAGES_CEILING = 20;
12
+
13
+ /** Parse `Retry-After` in either HTTP form (delta-seconds or HTTP-date). */
14
+ export function parseRetryAfter(value: string | null, nowMs: number = Date.now()): number | undefined {
15
+ if (!value) return undefined;
16
+ const trimmed = value.trim();
17
+ if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1_000;
18
+ const when = Date.parse(trimmed);
19
+ if (Number.isNaN(when)) return undefined;
20
+ return Math.max(0, when - nowMs);
21
+ }
22
+
23
+ function retryDelayMs(attempt: number, baseDelayMs: number, retryAfterMs: number | undefined): number {
24
+ if (retryAfterMs !== undefined) {
25
+ // Never shorten a server-supplied delay: retrying earlier than asked just
26
+ // spends the remaining attempts while the limit is still in force. If the
27
+ // request is beyond what we are willing to wait, stop and say so.
28
+ if (retryAfterMs > MAX_RETRY_DELAY_MS) {
29
+ throw new Error(
30
+ `twitterapi.io asked to retry in ${Math.ceil(retryAfterMs / 1_000)}s, beyond the ` +
31
+ `${MAX_RETRY_DELAY_MS / 1_000}s cap; not retrying. Try again later or lower the request rate.`,
32
+ );
33
+ }
34
+ // Floor at the backoff base so `Retry-After: 0` cannot become a hot loop.
35
+ return Math.max(retryAfterMs, baseDelayMs);
36
+ }
37
+ const backoff = baseDelayMs * 2 ** attempt;
38
+ return Math.min(backoff + Math.random() * baseDelayMs, MAX_RETRY_DELAY_MS);
39
+ }
40
+
41
+ interface AttemptResult {
42
+ response: Response;
43
+ body: unknown;
44
+ /** Attempts spent to obtain this result (1 unless retries were used). */
45
+ attempts: number;
46
+ /** Set when the response arrived but its body could not be read. */
47
+ bodyError?: string;
48
+ }
49
+
50
+ /**
51
+ * Marks a transport-level failure (fetch rejection, stalled/socket error while
52
+ * reading the body). Distinguished from a caller cancellation, a timeout, and
53
+ * a JSON syntax error so the retry decision does not depend on message text.
54
+ */
55
+ export class TransportError extends Error {
56
+ constructor(message: string, options?: { cause?: unknown }) {
57
+ super(message, options);
58
+ this.name = "TransportError";
59
+ }
60
+ }
61
+
62
+ /** Marks a per-request timeout. Distinct from cancellation, and retryable. */
63
+ export class TimeoutError extends Error {
64
+ constructor(message: string, options?: { cause?: unknown }) {
65
+ super(message, options);
66
+ this.name = "TimeoutError";
67
+ }
68
+ }
69
+
70
+ /** Marks a caller cancellation. Never retried. */
71
+ export class CancelledError extends Error {
72
+ constructor(message = "twitterapi.io search was cancelled") {
73
+ super(message);
74
+ this.name = "CancelledError";
75
+ }
76
+ }
77
+
78
+ /** One request attempt with its own timeout; returns the parsed body alongside the response. */
79
+ async function requestOnce(
80
+ url: string,
81
+ apiKey: string,
82
+ fetcher: FetchLike,
83
+ timeoutMs: number,
84
+ outerSignal: AbortSignal | undefined,
85
+ ): Promise<AttemptResult> {
86
+ const controller = new AbortController();
87
+ const onAbort = () => controller.abort(outerSignal?.reason);
88
+ if (outerSignal?.aborted) onAbort();
89
+ else outerSignal?.addEventListener("abort", onAbort, { once: true });
90
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
91
+ // The deadline is enforced here rather than delegated to the fetcher: a
92
+ // fetcher or body stream that ignores the signal must still not hang the call.
93
+ const aborted = new Promise<never>((_resolve, reject) => {
94
+ const rejectAbort = () => reject(new Error("twitterapi.io request aborted"));
95
+ if (controller.signal.aborted) rejectAbort();
96
+ else controller.signal.addEventListener("abort", rejectAbort, { once: true });
97
+ });
98
+ aborted.catch(() => {}); // settled-after-race rejections are not unhandled errors
99
+ try {
100
+ const response = await Promise.race([
101
+ fetcher(url, { headers: { "X-API-Key": apiKey }, signal: controller.signal }),
102
+ aborted,
103
+ ]);
104
+ let body: unknown;
105
+ let bodyError: string | undefined;
106
+ try {
107
+ body = await Promise.race([response.json(), aborted]);
108
+ } catch (error) {
109
+ // A stalled body trips the same abort as a stalled fetch, so distinguish
110
+ // transport/cancellation failures from a genuine JSON syntax error.
111
+ if (outerSignal?.aborted) throw new CancelledError();
112
+ // The response ARRIVED; only its body stalled. This must NOT surface as a
113
+ // retryable timeout: the page may already have been billed, so the status
114
+ // decides what happens next rather than the retry classifier.
115
+ if (controller.signal.aborted) {
116
+ bodyError = `the response body did not finish within ${timeoutMs}ms`;
117
+ } else if (!(error instanceof SyntaxError)) {
118
+ // A JSON syntax failure means "no usable body"; any other read failure is
119
+ // recorded for the same reason.
120
+ bodyError = error instanceof Error ? error.message : String(error);
121
+ }
122
+ body = undefined;
123
+ }
124
+ if (controller.signal.aborted && !bodyError) {
125
+ if (outerSignal?.aborted) throw new CancelledError();
126
+ throw new TimeoutError(`twitterapi.io request timed out after ${timeoutMs}ms`);
127
+ }
128
+ return { response, body, attempts: 1, bodyError };
129
+ } catch (error) {
130
+ if (outerSignal?.aborted) throw new CancelledError();
131
+ if (controller.signal.aborted) throw new TimeoutError(`twitterapi.io request timed out after ${timeoutMs}ms`);
132
+ if (error instanceof TransportError || error instanceof TimeoutError) throw error;
133
+ throw new TransportError(error instanceof Error ? error.message : String(error), { cause: error });
134
+ } finally {
135
+ clearTimeout(timeout);
136
+ outerSignal?.removeEventListener("abort", onAbort);
137
+ }
138
+ }
139
+
140
+ /** Only transport failures and timeouts are retried; cancellations never are. */
141
+ function isRetryableError(error: Error): boolean {
142
+ return error instanceof TransportError || error instanceof TimeoutError;
143
+ }
144
+
145
+ /** Backoff timer that clears itself when the caller cancels. */
146
+ export function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
147
+ return new Promise<void>((resolve, reject) => {
148
+ const cleanup = () => {
149
+ clearTimeout(timer);
150
+ signal?.removeEventListener("abort", onAbort);
151
+ };
152
+ const onAbort = () => {
153
+ cleanup();
154
+ reject(new CancelledError());
155
+ };
156
+ const timer = setTimeout(() => {
157
+ cleanup();
158
+ resolve();
159
+ }, ms);
160
+ // Installed before any await so an already-aborted signal settles at once.
161
+ if (signal?.aborted) onAbort();
162
+ else signal?.addEventListener("abort", onAbort, { once: true });
163
+ });
164
+ }
165
+
166
+ /** Abortable backoff: a cancelled tool call must not sit out the remaining delay. */
167
+ export async function sleepAbortable(
168
+ ms: number,
169
+ signal: AbortSignal | undefined,
170
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>,
171
+ ): Promise<void> {
172
+ if (signal?.aborted) throw new CancelledError();
173
+ await sleep(ms, signal);
174
+ }
175
+
176
+ /**
177
+ * The documented safe retry scope: 429 and 503 only. Retrying other statuses
178
+ * repeats a request the upstream may already have billed, and the provider's
179
+ * own guidance names these two. Widening this is a deliberate policy change.
180
+ */
181
+ export function isRetryableStatus(status: number): boolean {
182
+ return status === 429 || status === 503;
183
+ }
184
+
185
+ /**
186
+ * Detail text from the several error envelopes the upstream returns in the
187
+ * wild: `{detail}` and `{msg}` for endpoint errors, and
188
+ * `{error, message}` for account-level ones — a 402 out of credits arrives as
189
+ * `{"error":"Unauthorized","message":"Credits is not enough.Please recharge"}`.
190
+ * Without the last two keys, a billing failure reports only "HTTP 402".
191
+ */
192
+ export function errorDetail(body: Record<string, unknown>): string | undefined {
193
+ for (const key of ["detail", "msg", "message", "error"]) {
194
+ const value = body[key];
195
+ if (typeof value === "string" && value.trim()) return value.trim();
196
+ }
197
+ return undefined;
198
+ }
199
+
200
+ /**
201
+ * Validate a payload that may have failed, in the order that preserves the most
202
+ * useful error: HTTP status first (an unsuccessful response keeps its status and
203
+ * retry context even when the body carries a semantic envelope), then an
204
+ * unreadable body, then shape, then a semantic `status: "error"` envelope.
205
+ */
206
+ export function ensureSuccessfulPayload(
207
+ response: Response,
208
+ body: unknown,
209
+ bodyError: string | undefined,
210
+ attempts: number,
211
+ ): Record<string, unknown> {
212
+ if (!response.ok) {
213
+ throw new Error(describeHttpFailure(response.status, isObject(body) ? errorDetail(body) : undefined, attempts));
214
+ }
215
+ // A successful status whose body could not be read is NOT retried: the
216
+ // upstream may already have billed the page, so retrying risks paying twice.
217
+ if (bodyError) {
218
+ throw new Error(
219
+ `twitterapi.io returned HTTP ${response.status} but its body could not be read (${bodyError}); ` +
220
+ "not retrying because the request may already have been billed.",
221
+ );
222
+ }
223
+ if (!isObject(body)) {
224
+ throw new Error(`twitterapi.io returned a malformed response (HTTP ${response.status})`);
225
+ }
226
+ // A semantic failure can also arrive with HTTP 200.
227
+ if (body.status === "error") {
228
+ throw new Error(`twitterapi.io error: ${errorDetail(body) ?? "unknown"}`);
229
+ }
230
+ return body;
231
+ }
232
+
233
+ /** Options shared by every twitterapi.io endpoint: retry, pacing, cancellation. */
234
+ export interface TwitterApiRequestOptions {
235
+ /** Per-request timeout in ms (default 30_000). */
236
+ timeoutMs?: number;
237
+ /** Retry attempts for 429/503 responses (default 3). */
238
+ maxRetries?: number;
239
+ /** Base delay for exponential backoff in ms (default 5_000, the unpaid-tier floor). */
240
+ retryBaseDelayMs?: number;
241
+ /** Minimum spacing between successive upstream requests in ms (default 5_000). */
242
+ minRequestIntervalMs?: number;
243
+ /** Injected clock, for tests. */
244
+ now?: () => number;
245
+ /** Injected sleep, for tests. Receives the cancellation signal so a custom sleep can honor it. */
246
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
247
+ /** Caller cancellation (Pi passes the tool's AbortSignal). */
248
+ signal?: AbortSignal;
249
+ }
250
+
251
+ /** Resolved retry/pacing settings, so each endpoint builds them identically. */
252
+ interface RequestSettings {
253
+ timeoutMs: number;
254
+ maxRetries: number;
255
+ retryBaseDelayMs: number;
256
+ minRequestIntervalMs: number;
257
+ now: () => number;
258
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
259
+ signal?: AbortSignal;
260
+ }
261
+
262
+ export function resolveRequestSettings(options: TwitterApiRequestOptions): RequestSettings {
263
+ const timeoutMs = options.timeoutMs ?? 30_000;
264
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("timeoutMs must be a positive number");
265
+ return {
266
+ timeoutMs,
267
+ maxRetries: Math.max(0, options.maxRetries ?? 3),
268
+ retryBaseDelayMs: Math.max(0, options.retryBaseDelayMs ?? 5_000),
269
+ minRequestIntervalMs: Math.max(0, options.minRequestIntervalMs ?? 5_000),
270
+ now: options.now ?? Date.now,
271
+ sleep: options.sleep ?? defaultSleep,
272
+ signal: options.signal,
273
+ };
274
+ }
275
+
276
+ /** Human-readable HTTP failure that keeps the status, detail, and retry context. */
277
+ export function describeHttpFailure(status: number, detail: string | undefined, attempts: number): string {
278
+ const lead = status === 429
279
+ ? "rate limited"
280
+ : status === 503
281
+ ? "temporarily unavailable"
282
+ : status === 402
283
+ ? "payment required"
284
+ : "error";
285
+ const suffix = attempts > 1 ? ` after ${attempts} attempts` : "";
286
+ return `twitterapi.io ${lead}${detail ? `: ${detail}` : ""} (HTTP ${status}${suffix})`;
287
+ }
288
+
289
+ /** Fetch with retry/backoff on 429/503 (the free tier rate-limits bursts). */
290
+ export async function requestWithRetry(
291
+ url: string,
292
+ apiKey: string,
293
+ fetcher: FetchLike,
294
+ options: {
295
+ maxRetries: number;
296
+ retryBaseDelayMs: number;
297
+ timeoutMs: number;
298
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
299
+ signal?: AbortSignal;
300
+ },
301
+ ): Promise<AttemptResult> {
302
+ let lastError: Error | undefined;
303
+ for (let attempt = 0; attempt <= options.maxRetries; attempt += 1) {
304
+ let result: AttemptResult;
305
+ try {
306
+ result = await requestOnce(url, apiKey, fetcher, options.timeoutMs, options.signal);
307
+ } catch (error) {
308
+ lastError = error instanceof Error ? error : new Error(String(error));
309
+ if (options.signal?.aborted || attempt === options.maxRetries || !isRetryableError(lastError)) {
310
+ throw lastError;
311
+ }
312
+ await sleepAbortable(retryDelayMs(attempt, options.retryBaseDelayMs, undefined), options.signal, options.sleep);
313
+ continue;
314
+ }
315
+
316
+ if (!isRetryableStatus(result.response.status) || attempt === options.maxRetries) {
317
+ return { ...result, attempts: attempt + 1 };
318
+ }
319
+ lastError = new Error(`twitterapi.io error: HTTP ${result.response.status}`);
320
+ const retryAfterMs = parseRetryAfter(result.response.headers?.get?.("retry-after") ?? null);
321
+ await sleepAbortable(retryDelayMs(attempt, options.retryBaseDelayMs, retryAfterMs), options.signal, options.sleep);
322
+ }
323
+ throw lastError ?? new Error("twitterapi.io request failed");
324
+ }
325
+
326
+ /** Page bound shared with the configuration range, so a valid config cannot fail here. */
327
+ export const MAX_PAGE_BOUND = 100;
328
+
329
+ /**
330
+ * Page/count bounds. An absent value takes the default; a supplied one that is
331
+ * out of range is an error rather than a silent fallback, so a caller asking for
332
+ * 0 pages is told instead of quietly getting the default.
333
+ */
334
+ export function boundedCount(value: unknown, fallback: number, max: number, name: string): number {
335
+ if (value === undefined) return fallback;
336
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > max) {
337
+ throw new Error(`twitter ${name} must be an integer between 1 and ${max} (got ${String(value)})`);
338
+ }
339
+ return value;
340
+ }
@@ -0,0 +1,81 @@
1
+ import type { NormalizedSearchParams, TwitterApiSearchParams } from "./core.js";
2
+
3
+ const HANDLE_RE = /^@?([A-Za-z0-9_]{1,15})$/;
4
+
5
+ function normalizeHandles(handles: string[] | undefined, field: string): string[] {
6
+ if (!handles) return [];
7
+ if (handles.length > 20) throw new Error(`twitter ${field} accepts at most 20 handles (got ${handles.length})`);
8
+ return handles.map((handle) => {
9
+ const match = HANDLE_RE.exec(handle.trim());
10
+ if (!match) throw new Error(`twitter ${field} must be valid X handles without spaces (got "${handle}")`);
11
+ return match[1];
12
+ });
13
+ }
14
+
15
+ function normalizeDate(date: string | undefined, field: string): string | undefined {
16
+ if (!date) return undefined;
17
+ const trimmed = date.trim();
18
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
19
+ if (!match) throw new Error(`twitter ${field} must be YYYY-MM-DD (got "${date}")`);
20
+ const year = Number(match[1]);
21
+ const month = Number(match[2]);
22
+ const day = Number(match[3]);
23
+ const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
24
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth[month - 1]) {
25
+ throw new Error(`twitter ${field} is not a real calendar date (got "${date}")`);
26
+ }
27
+ return trimmed;
28
+ }
29
+
30
+ function isLeapYear(year: number): boolean {
31
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
32
+ }
33
+
34
+ export function normalizeParams(params: TwitterApiSearchParams): NormalizedSearchParams {
35
+ const query = params.query?.trim();
36
+ if (!query) throw new Error("twitter query must not be empty");
37
+
38
+ const allowed = normalizeHandles(params.allowed_x_handles, "allowed_x_handles");
39
+ const excluded = normalizeHandles(params.excluded_x_handles, "excluded_x_handles");
40
+ if (allowed.length > 0 && excluded.length > 0) {
41
+ throw new Error("twitter allowed_x_handles and excluded_x_handles cannot be set together");
42
+ }
43
+
44
+ const fromDate = normalizeDate(params.from_date, "from_date");
45
+ const toDate = normalizeDate(params.to_date, "to_date");
46
+ if (fromDate && toDate && fromDate > toDate) {
47
+ throw new Error("twitter from_date must be before or equal to to_date");
48
+ }
49
+
50
+ const count = params.count ?? 10;
51
+ if (!Number.isInteger(count) || count < 1 || count > 50) {
52
+ throw new Error("twitter count must be an integer between 1 and 50");
53
+ }
54
+
55
+ const queryType = params.queryType ?? "Latest";
56
+ if (queryType !== "Latest" && queryType !== "Top") {
57
+ throw new Error('twitter queryType must be "Latest" or "Top"');
58
+ }
59
+
60
+ return { query, allowed_x_handles: allowed, excluded_x_handles: excluded, from_date: fromDate, to_date: toDate, queryType, count };
61
+ }
62
+
63
+ /** Build the X advanced-search expression sent to twitterapi.io. */
64
+ export function buildExpression(params: NormalizedSearchParams): string {
65
+ const constraints: string[] = [];
66
+ if (params.allowed_x_handles?.length) {
67
+ constraints.push(params.allowed_x_handles.length === 1
68
+ ? `from:${params.allowed_x_handles[0]}`
69
+ : `(${params.allowed_x_handles.map((h) => `from:${h}`).join(" OR ")})`);
70
+ }
71
+ if (params.excluded_x_handles?.length) {
72
+ for (const h of params.excluded_x_handles) constraints.push(`-from:${h}`);
73
+ }
74
+ if (params.from_date) constraints.push(`since:${params.from_date}`);
75
+ if (params.to_date) constraints.push(`until:${params.to_date}`);
76
+ // Group the user query so appended AND-constraints cannot leak into
77
+ // an OR branch (e.g. `cats OR dogs` + handle must not leave `cats` unscoped).
78
+ if (constraints.length === 0) return params.query;
79
+ return `(${params.query}) ${constraints.join(" ")}`;
80
+ }
81
+
@@ -0,0 +1,233 @@
1
+ import {
2
+ ADVANCED_SEARCH_PATH,
3
+ TWITTERAPI_BASE_URL,
4
+ type FetchLike,
5
+ type NormalizedSearchParams,
6
+ type SearchDetails,
7
+ type SearchTermination,
8
+ type Tweet,
9
+ } from "./core.js";
10
+ import { buildExpression } from "./params.js";
11
+ import { parseTweetDate, resolveLocalWindow, withPaddedStart } from "./window.js";
12
+ import {
13
+ CancelledError,
14
+ DEFAULT_MAX_PAGES_CEILING,
15
+ defaultSleep,
16
+ ensureSuccessfulPayload,
17
+ requestWithRetry,
18
+ sleepAbortable,
19
+ type TwitterApiRequestOptions,
20
+ } from "./http.js";
21
+ import { asTweet } from "./tweet.js";
22
+
23
+ export interface SearchTweetsOptions {
24
+ /** Max pages to fetch (default 5). Bounds total requests when pages come back empty. */
25
+ maxPages?: number;
26
+ /**
27
+ * Hard ceiling on pages fetched in one search (default 20). `maxPages` is
28
+ * clamped to it, so an explicit ceiling can never be exceeded.
29
+ */
30
+ maxPagesCeiling?: number;
31
+ /** Per-request timeout in ms (default 30_000). */
32
+ timeoutMs?: number;
33
+ /** Retry attempts for 429/503 responses (default 3). */
34
+ maxRetries?: number;
35
+ /** Base delay for exponential backoff in ms (default 5_000, the unpaid-tier floor). */
36
+ retryBaseDelayMs?: number;
37
+ /**
38
+ * Minimum spacing between successive upstream requests in ms (default
39
+ * 5_000). twitterapi.io rate-limits per API key — brand-new unpaid accounts
40
+ * allow 0.2 QPS, i.e. one request every 5 seconds — and pagination would
41
+ * otherwise fire requests back to back. Raise it for a higher tier, or set it
42
+ * to 0 to disable pacing.
43
+ */
44
+ minRequestIntervalMs?: number;
45
+ /** Injected clock, for tests. */
46
+ now?: () => number;
47
+ /** Injected sleep, for tests. Receives the cancellation signal so a custom sleep can honor it. */
48
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
49
+ /**
50
+ * Caller cancellation (Pi passes the tool's AbortSignal). When set, in-flight
51
+ * requests and backoff sleeps are abandoned as soon as it aborts.
52
+ */
53
+ signal?: AbortSignal;
54
+ /**
55
+ * Fixed local UTC offset in minutes. Omit to use the host timezone, which
56
+ * resolves each boundary with its own DST-correct offset.
57
+ */
58
+ localUtcOffsetMinutes?: number;
59
+ }
60
+ /** True when retrieval stopped while the upstream still held more results. */
61
+ export function isTruncated(stoppedBy: SearchTermination): boolean {
62
+ return stoppedBy === "page-cap" || stoppedBy === "cursor-cycle" || stoppedBy === "cursor-missing";
63
+ }
64
+
65
+ /**
66
+ * Decide the next page, or record an honest reason for stopping.
67
+ *
68
+ * `has_next_page !== true` genuinely means "no more results". Reporting the same
69
+ * when upstream says there ARE more pages but hands us no cursor would present a
70
+ * partial result as complete, so that case counts as truncation instead.
71
+ */
72
+ export function advanceOrStop(
73
+ payload: Record<string, unknown>,
74
+ seenCursors: Set<string>,
75
+ ): { cursor: string } | SearchTermination {
76
+ if (payload.has_next_page !== true) return "exhausted";
77
+ if (typeof payload.next_cursor !== "string" || !payload.next_cursor) return "cursor-missing";
78
+ if (seenCursors.has(payload.next_cursor)) return "cursor-cycle";
79
+ seenCursors.add(payload.next_cursor);
80
+ return { cursor: payload.next_cursor };
81
+ }
82
+
83
+ /** Run paginated advanced_search until `count` tweets or no more pages. */
84
+ export async function searchTweets(
85
+ params: NormalizedSearchParams,
86
+ apiKey: string,
87
+ fetcher: FetchLike = fetch,
88
+ options: SearchTweetsOptions = {},
89
+ ): Promise<SearchDetails> {
90
+ const window = resolveLocalWindow(params, options.localUtcOffsetMinutes);
91
+ const requestParams = withPaddedStart(params, window);
92
+ const expression = buildExpression(requestParams);
93
+ const target = params.count ?? 10;
94
+ // A ceiling is a hard cap: the base budget is clamped to it, never the other
95
+ // way round, and non-integer or infinite values fall back to the defaults
96
+ // rather than removing the termination bound.
97
+ const requestedCeiling = options.maxPagesCeiling ?? DEFAULT_MAX_PAGES_CEILING;
98
+ const pageCeiling = Number.isInteger(requestedCeiling) && requestedCeiling >= 1
99
+ ? requestedCeiling
100
+ : DEFAULT_MAX_PAGES_CEILING;
101
+ const requestedBase = options.maxPages ?? 5;
102
+ const basePageBudget = Math.min(
103
+ Number.isInteger(requestedBase) && requestedBase >= 1 ? requestedBase : 5,
104
+ pageCeiling,
105
+ );
106
+ // Free pages are granted only for posts NEWER than the window: those form the
107
+ // upstream 04:00 UTC band sitting in front of the requested day. Older ones
108
+ // (the start padding) sit at the tail, so paging further gains nothing.
109
+ const trimBandHours = window?.trimHours ?? 0;
110
+ let trimmedNewer = 0;
111
+ let trimmedOlder = 0;
112
+ // Pages charged to the base budget. A page spent crossing the newer band is
113
+ // not charged — it was not answering the query — which is what stops a busy
114
+ // topic from returning nothing. Totals stay bounded by `pageCeiling`.
115
+ let pagesOnBudget = 0;
116
+ const maxRetries = Math.max(0, options.maxRetries ?? 3);
117
+ const retryBaseDelayMs = Math.max(0, options.retryBaseDelayMs ?? 5_000);
118
+ const minRequestIntervalMs = Math.max(0, options.minRequestIntervalMs ?? 5_000);
119
+ const now = options.now ?? Date.now;
120
+ const timeoutMs = options.timeoutMs ?? 30_000;
121
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("timeoutMs must be a positive number");
122
+ const sleep = options.sleep ?? defaultSleep;
123
+ const signal = options.signal;
124
+ const collected: Tweet[] = [];
125
+ // Keyed on both identifiers: the same post can come back with an id on one
126
+ // page and only a permalink on another, which a single `id ?? url` key misses.
127
+ const seenIds = new Set<string>();
128
+ const seenUrls = new Set<string>();
129
+ const seenCursors = new Set<string>();
130
+ let cursor = "";
131
+ let pages = 0;
132
+ let lastRequestAt: number | undefined;
133
+ // Only an explicit break changes this; falling out of the loop means the page
134
+ // cap was reached, which is exactly the case that must be disclosed.
135
+ let stoppedBy: SearchTermination = "page-cap";
136
+
137
+ while (collected.length < target && pagesOnBudget < basePageBudget && pages < pageCeiling) {
138
+ if (signal?.aborted) throw new CancelledError();
139
+ // Pace successive requests: the per-key QPS ceiling is low enough (0.2 QPS
140
+ // on an unpaid account) that unpaced pagination can rate-limit itself.
141
+ if (minRequestIntervalMs > 0 && lastRequestAt !== undefined) {
142
+ const wait = minRequestIntervalMs - (now() - lastRequestAt);
143
+ if (wait > 0) await sleepAbortable(wait, signal, sleep);
144
+ }
145
+ pages += 1;
146
+ let url: URL;
147
+ try {
148
+ url = new URL(TWITTERAPI_BASE_URL + ADVANCED_SEARCH_PATH);
149
+ } catch {
150
+ throw new Error("twitterapi.io: invalid search URL (internal error)");
151
+ }
152
+ url.searchParams.set("query", expression);
153
+ url.searchParams.set("queryType", params.queryType ?? "Latest");
154
+ if (cursor) url.searchParams.set("cursor", cursor);
155
+
156
+ const { response, body, attempts, bodyError } = await requestWithRetry(url.toString(), apiKey, fetcher, {
157
+ maxRetries,
158
+ retryBaseDelayMs,
159
+ timeoutMs,
160
+ sleep,
161
+ signal,
162
+ });
163
+ lastRequestAt = now();
164
+
165
+ // Errors are validated in one place, in the order that preserves the most
166
+ // useful signal: status, then unreadable body, then shape, then semantics.
167
+ const payload = ensureSuccessfulPayload(response, body, bodyError, attempts);
168
+ if (!Array.isArray(payload.tweets)) {
169
+ throw new Error("twitterapi.io returned a malformed response (missing tweets array)");
170
+ }
171
+
172
+ let pageTrimmedNewer = 0;
173
+ for (const raw of payload.tweets) {
174
+ const tweet = asTweet(raw);
175
+ if (!tweet) continue;
176
+ // Enforce the caller's real local day range: the upstream window may be
177
+ // padded at the start and stops short at the end. Unparseable dates are
178
+ // kept (fail-open).
179
+ if (window) {
180
+ const at = parseTweetDate(tweet.createdAt);
181
+ if (at !== undefined) {
182
+ if (at >= window.endMs) {
183
+ pageTrimmedNewer += 1;
184
+ trimmedNewer += 1;
185
+ continue;
186
+ }
187
+ if (at < window.startMs) {
188
+ trimmedOlder += 1;
189
+ continue;
190
+ }
191
+ }
192
+ }
193
+ // Register both identifiers even when this occurrence is itself a
194
+ // duplicate: otherwise an alias chain ((id=1,url=A), (id=1,url=B),
195
+ // (no id,url=B)) lets the third representation through as a new post.
196
+ const duplicate =
197
+ (tweet.id !== undefined && seenIds.has(tweet.id)) ||
198
+ (tweet.url !== undefined && seenUrls.has(tweet.url));
199
+ if (tweet.id) seenIds.add(tweet.id);
200
+ if (tweet.url) seenUrls.add(tweet.url);
201
+ if (duplicate) continue;
202
+ collected.push(tweet);
203
+ if (collected.length >= target) break;
204
+ }
205
+
206
+ // Only pages spent crossing the newer band are free. A page that merely ran
207
+ // past the end of the window (older start-padding posts) is charged, since
208
+ // nothing further ahead can be in-window.
209
+ if (trimBandHours === 0 || pageTrimmedNewer === 0) pagesOnBudget += 1;
210
+
211
+ const step = advanceOrStop(payload, seenCursors);
212
+ if (typeof step === "string") {
213
+ stoppedBy = step;
214
+ break;
215
+ }
216
+ cursor = step.cursor;
217
+ }
218
+ if (collected.length >= target) stoppedBy = "target";
219
+
220
+ return {
221
+ query: params.query,
222
+ expression,
223
+ queryType: params.queryType ?? "Latest",
224
+ tweets: collected,
225
+ pagesFetched: pages,
226
+ window,
227
+ stoppedBy,
228
+ truncated: isTruncated(stoppedBy),
229
+ trimmedNewer: trimmedNewer > 0 ? trimmedNewer : undefined,
230
+ trimmedOlder: trimmedOlder > 0 ? trimmedOlder : undefined,
231
+ };
232
+ }
233
+