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.
- package/CHANGELOG.md +51 -0
- package/LICENSE +22 -0
- package/README.md +278 -0
- package/docs/x-search-comparison.md +119 -0
- package/package.json +56 -0
- package/src/backend/media.ts +101 -0
- package/src/backend/model.ts +111 -0
- package/src/backend/runs.ts +687 -0
- package/src/backend/synthesis.ts +344 -0
- package/src/backend.ts +11 -0
- package/src/config.ts +78 -0
- package/src/format.ts +28 -0
- package/src/index.ts +131 -0
- package/src/settings.ts +66 -0
- package/src/synthesize.ts +731 -0
- package/src/tool.ts +491 -0
- package/src/twitterapi/core.ts +124 -0
- package/src/twitterapi/endpoints.ts +957 -0
- package/src/twitterapi/http.ts +340 -0
- package/src/twitterapi/params.ts +81 -0
- package/src/twitterapi/search.ts +233 -0
- package/src/twitterapi/tweet.ts +112 -0
- package/src/twitterapi/window.ts +184 -0
- package/src/twitterapi.ts +16 -0
- package/src/types.ts +16 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import type { TwitterConfig } from "../config.js";
|
|
2
|
+
import { formatTwitterResults } from "../format.js";
|
|
3
|
+
import type { TwitterSearchDetails } from "../types.js";
|
|
4
|
+
import {
|
|
5
|
+
synthesizeAnswer,
|
|
6
|
+
synthesizeDocument,
|
|
7
|
+
synthesizeTrends,
|
|
8
|
+
synthesizeUserAnswer,
|
|
9
|
+
} from "../synthesize.js";
|
|
10
|
+
import {
|
|
11
|
+
fetchCommunityTweets,
|
|
12
|
+
fetchFollowers,
|
|
13
|
+
fetchFollowings,
|
|
14
|
+
fetchListTweets,
|
|
15
|
+
fetchSpaceDetail,
|
|
16
|
+
fetchThread,
|
|
17
|
+
fetchTrends,
|
|
18
|
+
fetchTweetQuotes,
|
|
19
|
+
fetchTweetReplies,
|
|
20
|
+
fetchTweetsByIds,
|
|
21
|
+
fetchTweetRetweeters,
|
|
22
|
+
fetchUserAbout,
|
|
23
|
+
fetchUserMentions,
|
|
24
|
+
fetchUserProfile,
|
|
25
|
+
fetchUserTweets,
|
|
26
|
+
normalizeParams,
|
|
27
|
+
searchTweets,
|
|
28
|
+
searchUsers,
|
|
29
|
+
type ReplySort,
|
|
30
|
+
type Tweet,
|
|
31
|
+
type TwitterApiSearchParams,
|
|
32
|
+
type UserProfile,
|
|
33
|
+
} from "../twitterapi.js";
|
|
34
|
+
import { toSynthesisModel, type TwitterApiSynthesisOptions } from "./model.js";
|
|
35
|
+
import { createFetchMedia } from "./media.js";
|
|
36
|
+
import { applyFallbackNote, resolveSynthesisBackend, type SynthesisBackend } from "./synthesis.js";
|
|
37
|
+
|
|
38
|
+
export interface TwitterApiRunOptions extends TwitterApiSynthesisOptions {
|
|
39
|
+
params: TwitterApiSearchParams;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TwitterApiUserSearchOptions extends TwitterApiSynthesisOptions {
|
|
43
|
+
/** Keyword to match against account names, handles and bios. */
|
|
44
|
+
query: string;
|
|
45
|
+
/** Max accounts to collect (default 20, max 50). */
|
|
46
|
+
count?: number;
|
|
47
|
+
/** Max pages to fetch (default 3). */
|
|
48
|
+
maxPages?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TwitterApiThreadOptions extends TwitterApiSynthesisOptions {
|
|
52
|
+
/** A post id or an X permalink of any post in the thread. */
|
|
53
|
+
tweet: string;
|
|
54
|
+
/**
|
|
55
|
+
* The user's actual question about the thread. Kept separate from `tweet`
|
|
56
|
+
* because the reference only locates the thread — using it as the synthesis
|
|
57
|
+
* question would leave the model unable to answer what was asked.
|
|
58
|
+
*/
|
|
59
|
+
query: string;
|
|
60
|
+
/** Max pages of thread context (default: `config.maxPages`). */
|
|
61
|
+
maxPages?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a page budget: an explicit value is validated first, then clamped to
|
|
66
|
+
* the configured ceiling. Clamping before validating would turn `Infinity` or
|
|
67
|
+
* `20.5` into a valid `20` and hide the caller's mistake.
|
|
68
|
+
*/
|
|
69
|
+
function pageBudget(explicit: number | undefined, config: TwitterConfig): number {
|
|
70
|
+
if (explicit !== undefined && (!Number.isInteger(explicit) || explicit < 1)) {
|
|
71
|
+
throw new Error(`twitter maxPages must be a positive integer (got ${String(explicit)})`);
|
|
72
|
+
}
|
|
73
|
+
return Math.min(explicit ?? config.maxPages, config.maxPagesCeiling);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Why retrieval stopped short of exhausting the upstream, or undefined. */
|
|
77
|
+
function incompleteReason(stoppedBy: string | undefined, pages: number): string | undefined {
|
|
78
|
+
if (stoppedBy === "cursor-cycle") return "the upstream cursor began repeating";
|
|
79
|
+
if (stoppedBy === "cursor-missing") return "the upstream reported more results without a cursor to fetch them";
|
|
80
|
+
if (stoppedBy === "page-cap") return `the ${pages}-page fetch limit was reached`;
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Retrieve posts from twitterapi.io and synthesize the answer, returning the
|
|
86
|
+
* shared `{ markdown, details }` shape.
|
|
87
|
+
*/
|
|
88
|
+
export async function runTwitterApiSearch(
|
|
89
|
+
options: TwitterApiRunOptions,
|
|
90
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
91
|
+
const backend = resolveSynthesisBackend(options);
|
|
92
|
+
const { fetcher, apiKey, model, complete } = backend;
|
|
93
|
+
|
|
94
|
+
const params = normalizeParams(options.params);
|
|
95
|
+
const search = await searchTweets(params, apiKey, fetcher, {
|
|
96
|
+
signal: options.signal,
|
|
97
|
+
minRequestIntervalMs: options.config.minRequestIntervalMs,
|
|
98
|
+
retryBaseDelayMs: options.config.retryBaseDelayMs,
|
|
99
|
+
maxPages: options.config.maxPages,
|
|
100
|
+
maxPagesCeiling: options.config.maxPagesCeiling,
|
|
101
|
+
localUtcOffsetMinutes: options.localUtcOffsetMinutes,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Retrieval that stopped short of the upstream's last page is incomplete, and
|
|
105
|
+
// must not be presented as an exhaustive answer to the query.
|
|
106
|
+
const incomplete = incompleteReason(search.stoppedBy, search.pagesFetched ?? 0);
|
|
107
|
+
|
|
108
|
+
const details = await synthesizeAnswer({
|
|
109
|
+
query: params.query,
|
|
110
|
+
tweets: search.tweets,
|
|
111
|
+
config: options.config,
|
|
112
|
+
model: toSynthesisModel(model),
|
|
113
|
+
signal: options.signal,
|
|
114
|
+
incomplete,
|
|
115
|
+
deps: {
|
|
116
|
+
complete,
|
|
117
|
+
fetchMedia: createFetchMedia(fetcher, options.signal),
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (search.window?.shortfallHours) {
|
|
122
|
+
details.notes = [
|
|
123
|
+
...(details.notes ?? []),
|
|
124
|
+
`Date window coverage: the upstream search stops ${search.window.shortfallHours}h before the end of the requested local window.`,
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
if (incomplete) {
|
|
128
|
+
details.notes = [
|
|
129
|
+
...(details.notes ?? []),
|
|
130
|
+
`Retrieval stopped early (${incomplete}) while the upstream still had more posts, so these results may be incomplete.`,
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
// Explain pages that bought nothing: east of UTC-4 the upstream day boundary
|
|
134
|
+
// sits past local midnight, so the newest posts of the scan are always outside
|
|
135
|
+
// the requested day and must be paged past before the real results begin.
|
|
136
|
+
if (search.trimmedNewer) {
|
|
137
|
+
const band = search.window?.trimHours;
|
|
138
|
+
details.notes = [
|
|
139
|
+
...(details.notes ?? []),
|
|
140
|
+
`${search.trimmedNewer} newer post(s) outside the requested local window were skipped while paging to it` +
|
|
141
|
+
(band ? ` (twitterapi.io resolves date bounds at 04:00 UTC, ${band}h past local midnight at this offset)` : "") +
|
|
142
|
+
".",
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
if (search.trimmedOlder) {
|
|
146
|
+
details.notes = [
|
|
147
|
+
...(details.notes ?? []),
|
|
148
|
+
`${search.trimmedOlder} older post(s) before the requested local window were skipped ` +
|
|
149
|
+
"(the upstream window is padded back a day so the local day's start is covered).",
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
applyFallbackNote(backend, details);
|
|
153
|
+
|
|
154
|
+
return { markdown: formatTwitterResults(details), details };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Search X accounts by keyword and summarize them, with profile URLs as the
|
|
159
|
+
* sources.
|
|
160
|
+
*/
|
|
161
|
+
export async function runTwitterApiUserSearch(
|
|
162
|
+
options: TwitterApiUserSearchOptions,
|
|
163
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
164
|
+
const backend = resolveSynthesisBackend(options);
|
|
165
|
+
const { fetcher, apiKey, model, complete } = backend;
|
|
166
|
+
|
|
167
|
+
const search = await searchUsers(options.query, apiKey, fetcher, {
|
|
168
|
+
signal: options.signal,
|
|
169
|
+
count: options.count,
|
|
170
|
+
// Configured budgets apply here too: the ceiling is a hard cap, and the base
|
|
171
|
+
// budget is the configured one rather than this endpoint's own default.
|
|
172
|
+
maxPages: pageBudget(options.maxPages, options.config),
|
|
173
|
+
minRequestIntervalMs: options.config.minRequestIntervalMs,
|
|
174
|
+
retryBaseDelayMs: options.config.retryBaseDelayMs,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const incomplete = incompleteReason(search.stoppedBy, search.pagesFetched);
|
|
178
|
+
const details = await synthesizeUserAnswer({
|
|
179
|
+
query: search.query,
|
|
180
|
+
users: search.users,
|
|
181
|
+
config: options.config,
|
|
182
|
+
model: toSynthesisModel(model),
|
|
183
|
+
signal: options.signal,
|
|
184
|
+
incomplete,
|
|
185
|
+
deps: { complete },
|
|
186
|
+
});
|
|
187
|
+
if (incomplete) {
|
|
188
|
+
details.notes = [
|
|
189
|
+
...(details.notes ?? []),
|
|
190
|
+
`Retrieval stopped early (${incomplete}) while the upstream still had more accounts, so this list may be incomplete.`,
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
// Measured: the endpoint matches a sub-string with no word boundaries, and a
|
|
194
|
+
// multi-word query can return nothing at all ("pi coding agent" returned zero
|
|
195
|
+
// accounts while "coding" returned twenty). Say so instead of leaving an empty
|
|
196
|
+
// answer unexplained.
|
|
197
|
+
if (search.users.length === 0 && search.query.trim().split(/\s+/).length > 1) {
|
|
198
|
+
details.notes = [
|
|
199
|
+
...(details.notes ?? []),
|
|
200
|
+
"twitterapi.io matches account search against a sub-string of names, handles and bios with no word boundaries, " +
|
|
201
|
+
'and a multi-word query can match nothing; retry with a single distinctive keyword (for example "coding").',
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
applyFallbackNote(backend, details);
|
|
205
|
+
return { markdown: formatTwitterResults(details), details };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Fetch a post's thread context and answer from it.
|
|
210
|
+
*/
|
|
211
|
+
export async function runTwitterApiThread(
|
|
212
|
+
options: TwitterApiThreadOptions,
|
|
213
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
214
|
+
const backend = resolveSynthesisBackend(options);
|
|
215
|
+
const { fetcher, apiKey, model, complete } = backend;
|
|
216
|
+
// Validated before the request, like the post and account paths: a blank
|
|
217
|
+
// question would otherwise still pay for retrieval and synthesis.
|
|
218
|
+
const question = options.query?.trim();
|
|
219
|
+
if (!question) throw new Error("twitter query must not be empty");
|
|
220
|
+
|
|
221
|
+
const thread = await fetchThread(options.tweet, apiKey, fetcher, {
|
|
222
|
+
signal: options.signal,
|
|
223
|
+
maxPages: pageBudget(options.maxPages, options.config),
|
|
224
|
+
minRequestIntervalMs: options.config.minRequestIntervalMs,
|
|
225
|
+
retryBaseDelayMs: options.config.retryBaseDelayMs,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const incomplete = incompleteReason(thread.stoppedBy, thread.pagesFetched);
|
|
229
|
+
const details = await synthesizeAnswer({
|
|
230
|
+
// The question, not the reference: the reference only located the thread.
|
|
231
|
+
query: question,
|
|
232
|
+
tweets: thread.tweets,
|
|
233
|
+
config: options.config,
|
|
234
|
+
model: toSynthesisModel(model),
|
|
235
|
+
signal: options.signal,
|
|
236
|
+
incomplete,
|
|
237
|
+
deps: { complete, fetchMedia: createFetchMedia(fetcher, options.signal) },
|
|
238
|
+
});
|
|
239
|
+
details.notes = [
|
|
240
|
+
...(details.notes ?? []),
|
|
241
|
+
`Answered from the thread context of post ${thread.tweetId} (${thread.tweets.length} post(s), ${thread.pagesFetched} page(s)).`,
|
|
242
|
+
];
|
|
243
|
+
if (incomplete) {
|
|
244
|
+
details.notes = [
|
|
245
|
+
...(details.notes ?? []),
|
|
246
|
+
`Retrieval stopped early (${incomplete}) while the thread continued, so these results may be incomplete.`,
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
applyFallbackNote(backend, details);
|
|
250
|
+
return { markdown: formatTwitterResults(details), details };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ------------------------------------------------ extended reads (P1)
|
|
254
|
+
|
|
255
|
+
/** Shared synthesis hop for the tweet-shaped reads (timeline, replies, quotes). */
|
|
256
|
+
async function completeTweetAnswer(
|
|
257
|
+
backend: SynthesisBackend,
|
|
258
|
+
options: TwitterApiSynthesisOptions,
|
|
259
|
+
input: { query: string; tweets: Tweet[]; incomplete?: string; notes: string[] },
|
|
260
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
261
|
+
const details = await synthesizeAnswer({
|
|
262
|
+
query: input.query,
|
|
263
|
+
tweets: input.tweets,
|
|
264
|
+
config: options.config,
|
|
265
|
+
model: toSynthesisModel(backend.model),
|
|
266
|
+
signal: options.signal,
|
|
267
|
+
incomplete: input.incomplete,
|
|
268
|
+
deps: { complete: backend.complete, fetchMedia: createFetchMedia(backend.fetcher, options.signal) },
|
|
269
|
+
});
|
|
270
|
+
details.notes = [...(details.notes ?? []), ...input.notes];
|
|
271
|
+
applyFallbackNote(backend, details);
|
|
272
|
+
return { markdown: formatTwitterResults(details), details };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Page/pacing options every extended read takes from the config. */
|
|
276
|
+
function tweetReadOptions(options: TwitterApiSynthesisOptions) {
|
|
277
|
+
return {
|
|
278
|
+
signal: options.signal,
|
|
279
|
+
maxPages: options.config.maxPages,
|
|
280
|
+
maxPagesCeiling: options.config.maxPagesCeiling,
|
|
281
|
+
minRequestIntervalMs: options.config.minRequestIntervalMs,
|
|
282
|
+
retryBaseDelayMs: options.config.retryBaseDelayMs,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface TwitterApiUserTimelineOptions extends TwitterApiSynthesisOptions {
|
|
287
|
+
query: string;
|
|
288
|
+
userName?: string;
|
|
289
|
+
userId?: string;
|
|
290
|
+
includeReplies?: boolean;
|
|
291
|
+
limit?: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Fetch an account's recent posts and synthesize an answer from them. */
|
|
295
|
+
export async function runTwitterApiUserTimeline(
|
|
296
|
+
options: TwitterApiUserTimelineOptions,
|
|
297
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
298
|
+
const backend = resolveSynthesisBackend(options);
|
|
299
|
+
const timeline = await fetchUserTweets(
|
|
300
|
+
{ userName: options.userName, userId: options.userId },
|
|
301
|
+
backend.apiKey,
|
|
302
|
+
backend.fetcher,
|
|
303
|
+
{ ...tweetReadOptions(options), includeReplies: options.includeReplies, limit: options.limit },
|
|
304
|
+
);
|
|
305
|
+
const incomplete = incompleteReason(timeline.stoppedBy, timeline.pagesFetched);
|
|
306
|
+
const who = options.userName ? `@${options.userName.replace(/^@+/, "")}` : (options.userId ?? "the account");
|
|
307
|
+
const notes = [`Answered from the recent timeline of ${who} (${timeline.tweets.length} post(s)).`];
|
|
308
|
+
if (incomplete) {
|
|
309
|
+
notes.push(`Retrieval stopped early (${incomplete}) while the timeline continued, so these results may be incomplete.`);
|
|
310
|
+
}
|
|
311
|
+
return completeTweetAnswer(backend, options, { query: options.query, tweets: timeline.tweets, incomplete, notes });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export interface TwitterApiRepliesOptions extends TwitterApiSynthesisOptions {
|
|
315
|
+
query: string;
|
|
316
|
+
tweet: string;
|
|
317
|
+
queryType?: ReplySort;
|
|
318
|
+
limit?: number;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Fetch replies to a post and synthesize an answer from them. */
|
|
322
|
+
export async function runTwitterApiReplies(
|
|
323
|
+
options: TwitterApiRepliesOptions,
|
|
324
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
325
|
+
const backend = resolveSynthesisBackend(options);
|
|
326
|
+
const replies = await fetchTweetReplies(options.tweet, backend.apiKey, backend.fetcher, {
|
|
327
|
+
...tweetReadOptions(options),
|
|
328
|
+
queryType: options.queryType,
|
|
329
|
+
limit: options.limit,
|
|
330
|
+
});
|
|
331
|
+
const incomplete = incompleteReason(replies.stoppedBy, replies.pagesFetched);
|
|
332
|
+
const count = replies.tweets.length;
|
|
333
|
+
const notes = [`Answered from ${count} repl${count === 1 ? "y" : "ies"} to post ${replies.tweetId}.`];
|
|
334
|
+
if (incomplete) {
|
|
335
|
+
notes.push(`Retrieval stopped early (${incomplete}) while more replies remained, so these results may be incomplete.`);
|
|
336
|
+
}
|
|
337
|
+
return completeTweetAnswer(backend, options, { query: options.query, tweets: replies.tweets, incomplete, notes });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface TwitterApiQuotesOptions extends TwitterApiSynthesisOptions {
|
|
341
|
+
query: string;
|
|
342
|
+
tweet: string;
|
|
343
|
+
sinceTime?: number;
|
|
344
|
+
untilTime?: number;
|
|
345
|
+
includeReplies?: boolean;
|
|
346
|
+
limit?: number;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Fetch quote-posts of a post and synthesize an answer from them. */
|
|
350
|
+
export async function runTwitterApiQuotes(
|
|
351
|
+
options: TwitterApiQuotesOptions,
|
|
352
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
353
|
+
const backend = resolveSynthesisBackend(options);
|
|
354
|
+
const quotes = await fetchTweetQuotes(options.tweet, backend.apiKey, backend.fetcher, {
|
|
355
|
+
...tweetReadOptions(options),
|
|
356
|
+
sinceTime: options.sinceTime,
|
|
357
|
+
untilTime: options.untilTime,
|
|
358
|
+
includeReplies: options.includeReplies,
|
|
359
|
+
limit: options.limit,
|
|
360
|
+
});
|
|
361
|
+
const incomplete = incompleteReason(quotes.stoppedBy, quotes.pagesFetched);
|
|
362
|
+
const count = quotes.tweets.length;
|
|
363
|
+
const notes = [`Answered from ${count} quote-post${count === 1 ? "" : "s"} of post ${quotes.tweetId}.`];
|
|
364
|
+
if (incomplete) {
|
|
365
|
+
notes.push(`Retrieval stopped early (${incomplete}) while more quotes remained, so these results may be incomplete.`);
|
|
366
|
+
}
|
|
367
|
+
return completeTweetAnswer(backend, options, { query: options.query, tweets: quotes.tweets, incomplete, notes });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export interface TwitterApiTrendsOptions extends TwitterApiSynthesisOptions {
|
|
371
|
+
query: string;
|
|
372
|
+
woeid: number;
|
|
373
|
+
count?: number;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Fetch a location's trending topics and synthesize an answer from them. */
|
|
377
|
+
export async function runTwitterApiTrends(
|
|
378
|
+
options: TwitterApiTrendsOptions,
|
|
379
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
380
|
+
const backend = resolveSynthesisBackend(options);
|
|
381
|
+
const { trends } = await fetchTrends(options.woeid, backend.apiKey, backend.fetcher, {
|
|
382
|
+
signal: options.signal,
|
|
383
|
+
count: options.count,
|
|
384
|
+
minRequestIntervalMs: options.config.minRequestIntervalMs,
|
|
385
|
+
retryBaseDelayMs: options.config.retryBaseDelayMs,
|
|
386
|
+
});
|
|
387
|
+
const details = await synthesizeTrends({
|
|
388
|
+
query: options.query,
|
|
389
|
+
trends,
|
|
390
|
+
model: toSynthesisModel(backend.model),
|
|
391
|
+
signal: options.signal,
|
|
392
|
+
deps: { complete: backend.complete },
|
|
393
|
+
});
|
|
394
|
+
applyFallbackNote(backend, details);
|
|
395
|
+
return { markdown: formatTwitterResults(details), details };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ------------------------------------------------ accounts & lookups (P2)
|
|
399
|
+
|
|
400
|
+
/** Shared synthesis hop for the account-shaped reads (followers, profile). */
|
|
401
|
+
async function completeUserAnswer(
|
|
402
|
+
backend: SynthesisBackend,
|
|
403
|
+
options: TwitterApiSynthesisOptions,
|
|
404
|
+
input: { query: string; users: UserProfile[]; incomplete?: string; notes: string[] },
|
|
405
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
406
|
+
const details = await synthesizeUserAnswer({
|
|
407
|
+
query: input.query,
|
|
408
|
+
users: input.users,
|
|
409
|
+
config: options.config,
|
|
410
|
+
model: toSynthesisModel(backend.model),
|
|
411
|
+
signal: options.signal,
|
|
412
|
+
incomplete: input.incomplete,
|
|
413
|
+
deps: { complete: backend.complete },
|
|
414
|
+
});
|
|
415
|
+
details.notes = [...(details.notes ?? []), ...input.notes];
|
|
416
|
+
applyFallbackNote(backend, details);
|
|
417
|
+
return { markdown: formatTwitterResults(details), details };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Retry/pacing options every lookup takes from the config. */
|
|
421
|
+
function lookupOptions(options: TwitterApiSynthesisOptions) {
|
|
422
|
+
return {
|
|
423
|
+
signal: options.signal,
|
|
424
|
+
minRequestIntervalMs: options.config.minRequestIntervalMs,
|
|
425
|
+
retryBaseDelayMs: options.config.retryBaseDelayMs,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export interface TwitterApiMentionsOptions extends TwitterApiSynthesisOptions {
|
|
430
|
+
query: string;
|
|
431
|
+
userName: string;
|
|
432
|
+
sinceTime?: number;
|
|
433
|
+
untilTime?: number;
|
|
434
|
+
limit?: number;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Fetch posts that mention an account and synthesize an answer from them. */
|
|
438
|
+
export async function runTwitterApiMentions(
|
|
439
|
+
options: TwitterApiMentionsOptions,
|
|
440
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
441
|
+
const backend = resolveSynthesisBackend(options);
|
|
442
|
+
const mentions = await fetchUserMentions(options.userName, backend.apiKey, backend.fetcher, {
|
|
443
|
+
...tweetReadOptions(options),
|
|
444
|
+
sinceTime: options.sinceTime,
|
|
445
|
+
untilTime: options.untilTime,
|
|
446
|
+
limit: options.limit,
|
|
447
|
+
});
|
|
448
|
+
const incomplete = incompleteReason(mentions.stoppedBy, mentions.pagesFetched);
|
|
449
|
+
const notes = [`Answered from ${mentions.tweets.length} mention(s) of @${mentions.userName}.`];
|
|
450
|
+
if (incomplete) {
|
|
451
|
+
notes.push(`Retrieval stopped early (${incomplete}) while more mentions remained, so these results may be incomplete.`);
|
|
452
|
+
}
|
|
453
|
+
return completeTweetAnswer(backend, options, { query: options.query, tweets: mentions.tweets, incomplete, notes });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export interface TwitterApiFollowOptions extends TwitterApiSynthesisOptions {
|
|
457
|
+
query: string;
|
|
458
|
+
userName: string;
|
|
459
|
+
pageSize?: number;
|
|
460
|
+
limit?: number;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function runFollow(
|
|
464
|
+
options: TwitterApiFollowOptions,
|
|
465
|
+
direction: "followers" | "followings",
|
|
466
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
467
|
+
const backend = resolveSynthesisBackend(options);
|
|
468
|
+
const fetchFn = direction === "followers" ? fetchFollowers : fetchFollowings;
|
|
469
|
+
const result = await fetchFn(options.userName, backend.apiKey, backend.fetcher, {
|
|
470
|
+
...tweetReadOptions(options),
|
|
471
|
+
pageSize: options.pageSize,
|
|
472
|
+
limit: options.limit,
|
|
473
|
+
});
|
|
474
|
+
const incomplete = incompleteReason(result.stoppedBy, result.pagesFetched);
|
|
475
|
+
const notes = [`Answered from ${result.users.length} ${direction} of @${result.userName}.`];
|
|
476
|
+
if (incomplete) {
|
|
477
|
+
notes.push(`Retrieval stopped early (${incomplete}) while more ${direction} remained, so these results may be incomplete.`);
|
|
478
|
+
}
|
|
479
|
+
return completeUserAnswer(backend, options, { query: options.query, users: result.users, incomplete, notes });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Fetch an account's followers and synthesize an answer from the profiles. */
|
|
483
|
+
export async function runTwitterApiFollowers(
|
|
484
|
+
options: TwitterApiFollowOptions,
|
|
485
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
486
|
+
return runFollow(options, "followers");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Fetch the accounts an account follows and synthesize an answer. */
|
|
490
|
+
export async function runTwitterApiFollowings(
|
|
491
|
+
options: TwitterApiFollowOptions,
|
|
492
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
493
|
+
return runFollow(options, "followings");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export interface TwitterApiProfileOptions extends TwitterApiSynthesisOptions {
|
|
497
|
+
query: string;
|
|
498
|
+
userName: string;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Fetch one account profile and synthesize an answer from it. */
|
|
502
|
+
export async function runTwitterApiProfile(
|
|
503
|
+
options: TwitterApiProfileOptions,
|
|
504
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
505
|
+
const backend = resolveSynthesisBackend(options);
|
|
506
|
+
const user = await fetchUserProfile(options.userName, backend.apiKey, backend.fetcher, lookupOptions(options));
|
|
507
|
+
return completeUserAnswer(backend, options, {
|
|
508
|
+
query: options.query,
|
|
509
|
+
users: [user],
|
|
510
|
+
notes: [`Answered from the profile of @${user.handle}.`],
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export interface TwitterApiTweetsByIdsOptions extends TwitterApiSynthesisOptions {
|
|
515
|
+
query: string;
|
|
516
|
+
ids: string[];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Fetch specific posts by id and synthesize an answer from them. */
|
|
520
|
+
export async function runTwitterApiTweetsByIds(
|
|
521
|
+
options: TwitterApiTweetsByIdsOptions,
|
|
522
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
523
|
+
const backend = resolveSynthesisBackend(options);
|
|
524
|
+
const result = await fetchTweetsByIds(options.ids, backend.apiKey, backend.fetcher, lookupOptions(options));
|
|
525
|
+
return completeTweetAnswer(backend, options, {
|
|
526
|
+
query: options.query,
|
|
527
|
+
tweets: result.tweets,
|
|
528
|
+
notes: [`Answered from ${result.tweets.length} post(s) fetched by id.`],
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export interface TwitterApiAboutOptions extends TwitterApiSynthesisOptions {
|
|
533
|
+
query: string;
|
|
534
|
+
userName: string;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Fetch a user's extended "about" page metadata and synthesize an answer. */
|
|
538
|
+
export async function runTwitterApiAbout(
|
|
539
|
+
options: TwitterApiAboutOptions,
|
|
540
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
541
|
+
const backend = resolveSynthesisBackend(options);
|
|
542
|
+
const about = await fetchUserAbout(options.userName, backend.apiKey, backend.fetcher, lookupOptions(options));
|
|
543
|
+
const fields = flattenObject(about as unknown as Record<string, unknown>);
|
|
544
|
+
const notes = [`Answered from the about page of @${about.handle}.`];
|
|
545
|
+
if (fields.length > 200) {
|
|
546
|
+
notes.push(`The about data was long; only the first 200 of ${fields.length} fields were used, so some may be omitted.`);
|
|
547
|
+
}
|
|
548
|
+
const details = await synthesizeDocument({
|
|
549
|
+
query: options.query,
|
|
550
|
+
title: `About @${about.handle}`,
|
|
551
|
+
body: fields.slice(0, 200).join("\n"),
|
|
552
|
+
citations: [about.profileUrl],
|
|
553
|
+
model: toSynthesisModel(backend.model),
|
|
554
|
+
signal: options.signal,
|
|
555
|
+
deps: { complete: backend.complete },
|
|
556
|
+
notes,
|
|
557
|
+
});
|
|
558
|
+
applyFallbackNote(backend, details);
|
|
559
|
+
return { markdown: formatTwitterResults(details), details };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export interface TwitterApiRetweetersOptions extends TwitterApiSynthesisOptions {
|
|
563
|
+
query: string;
|
|
564
|
+
tweet: string;
|
|
565
|
+
limit?: number;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Fetch users who retweeted a post and synthesize an answer from their profiles. */
|
|
569
|
+
export async function runTwitterApiRetweeters(
|
|
570
|
+
options: TwitterApiRetweetersOptions,
|
|
571
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
572
|
+
const backend = resolveSynthesisBackend(options);
|
|
573
|
+
const result = await fetchTweetRetweeters(options.tweet, backend.apiKey, backend.fetcher, {
|
|
574
|
+
...tweetReadOptions(options),
|
|
575
|
+
limit: options.limit,
|
|
576
|
+
});
|
|
577
|
+
const incomplete = incompleteReason(result.stoppedBy, result.pagesFetched);
|
|
578
|
+
const notes = [`Answered from ${result.users.length} retweeter(s) of post ${result.tweetId}.`];
|
|
579
|
+
if (incomplete) {
|
|
580
|
+
notes.push(`Retrieval stopped early (${incomplete}) while more retweeters remained, so these results may be incomplete.`);
|
|
581
|
+
}
|
|
582
|
+
return completeUserAnswer(backend, options, { query: options.query, users: result.users, incomplete, notes });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// -------------------------------------- communities, lists, spaces (P3)
|
|
586
|
+
|
|
587
|
+
export interface TwitterApiCommunityOptions extends TwitterApiSynthesisOptions {
|
|
588
|
+
query: string;
|
|
589
|
+
communityId: string;
|
|
590
|
+
limit?: number;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Fetch a community's posts and synthesize an answer from them. */
|
|
594
|
+
export async function runTwitterApiCommunity(
|
|
595
|
+
options: TwitterApiCommunityOptions,
|
|
596
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
597
|
+
const backend = resolveSynthesisBackend(options);
|
|
598
|
+
const result = await fetchCommunityTweets(options.communityId, backend.apiKey, backend.fetcher, {
|
|
599
|
+
...tweetReadOptions(options),
|
|
600
|
+
limit: options.limit,
|
|
601
|
+
});
|
|
602
|
+
const incomplete = incompleteReason(result.stoppedBy, result.pagesFetched);
|
|
603
|
+
const notes = [`Answered from ${result.tweets.length} post(s) in community ${options.communityId}.`];
|
|
604
|
+
if (incomplete) {
|
|
605
|
+
notes.push(`Retrieval stopped early (${incomplete}) while more community posts remained, so these results may be incomplete.`);
|
|
606
|
+
}
|
|
607
|
+
return completeTweetAnswer(backend, options, { query: options.query, tweets: result.tweets, incomplete, notes });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export interface TwitterApiListOptions extends TwitterApiSynthesisOptions {
|
|
611
|
+
query: string;
|
|
612
|
+
listId: string;
|
|
613
|
+
limit?: number;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Fetch a list's timeline and synthesize an answer from it. */
|
|
617
|
+
export async function runTwitterApiList(
|
|
618
|
+
options: TwitterApiListOptions,
|
|
619
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
620
|
+
const backend = resolveSynthesisBackend(options);
|
|
621
|
+
const result = await fetchListTweets(options.listId, backend.apiKey, backend.fetcher, {
|
|
622
|
+
...tweetReadOptions(options),
|
|
623
|
+
limit: options.limit,
|
|
624
|
+
});
|
|
625
|
+
const incomplete = incompleteReason(result.stoppedBy, result.pagesFetched);
|
|
626
|
+
const notes = [`Answered from ${result.tweets.length} post(s) in list ${options.listId}.`];
|
|
627
|
+
if (incomplete) {
|
|
628
|
+
notes.push(`Retrieval stopped early (${incomplete}) while more list posts remained, so these results may be incomplete.`);
|
|
629
|
+
}
|
|
630
|
+
return completeTweetAnswer(backend, options, { query: options.query, tweets: result.tweets, incomplete, notes });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Flatten a nested object into bounded `key: value` lines for synthesis. */
|
|
634
|
+
function flattenObject(data: Record<string, unknown>, prefix = ""): string[] {
|
|
635
|
+
const lines: string[] = [];
|
|
636
|
+
for (const [key, value] of Object.entries(data)) {
|
|
637
|
+
const label = prefix ? `${prefix}.${key}` : key;
|
|
638
|
+
if (value === null || value === undefined || value === "") continue;
|
|
639
|
+
if (Array.isArray(value)) {
|
|
640
|
+
// Index each member so an identity inside an object array (for example a
|
|
641
|
+
// Space speaker) survives instead of collapsing to an item count.
|
|
642
|
+
value.forEach((item, index) => {
|
|
643
|
+
if (item !== null && typeof item === "object") {
|
|
644
|
+
lines.push(...flattenObject(item as Record<string, unknown>, `${label}[${index}]`));
|
|
645
|
+
} else {
|
|
646
|
+
lines.push(`${label}[${index}]: ${String(item)}`);
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
} else if (typeof value === "object") {
|
|
650
|
+
lines.push(...flattenObject(value as Record<string, unknown>, label));
|
|
651
|
+
} else {
|
|
652
|
+
lines.push(`${label}: ${String(value)}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return lines;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export interface TwitterApiSpaceOptions extends TwitterApiSynthesisOptions {
|
|
659
|
+
query: string;
|
|
660
|
+
spaceId: string;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Fetch an X Space's detail and synthesize an answer from it. */
|
|
664
|
+
export async function runTwitterApiSpace(
|
|
665
|
+
options: TwitterApiSpaceOptions,
|
|
666
|
+
): Promise<{ markdown: string; details: TwitterSearchDetails }> {
|
|
667
|
+
const backend = resolveSynthesisBackend(options);
|
|
668
|
+
const space = await fetchSpaceDetail(options.spaceId, backend.apiKey, backend.fetcher, lookupOptions(options));
|
|
669
|
+
const fields = flattenObject(space.data);
|
|
670
|
+
const body = fields.slice(0, 200).join("\n");
|
|
671
|
+
const notes =
|
|
672
|
+
fields.length > 200
|
|
673
|
+
? [`The Space detail was long; only the first 200 of ${fields.length} fields were used, so some fields may be omitted.`]
|
|
674
|
+
: [];
|
|
675
|
+
const details = await synthesizeDocument({
|
|
676
|
+
query: options.query,
|
|
677
|
+
title: `X Space ${space.id}`,
|
|
678
|
+
body,
|
|
679
|
+
citations: [`https://x.com/i/spaces/${space.id}`],
|
|
680
|
+
model: toSynthesisModel(backend.model),
|
|
681
|
+
signal: options.signal,
|
|
682
|
+
deps: { complete: backend.complete },
|
|
683
|
+
notes,
|
|
684
|
+
});
|
|
685
|
+
applyFallbackNote(backend, details);
|
|
686
|
+
return { markdown: formatTwitterResults(details), details };
|
|
687
|
+
}
|