@nijaru/pi-search 0.0.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/THIRD_PARTY_NOTICES.md +31 -0
  4. package/package.json +69 -0
  5. package/src/anthropic.ts +350 -0
  6. package/src/anydoc-worker.mjs +35 -0
  7. package/src/anydoc.ts +161 -0
  8. package/src/brave-answers.ts +396 -0
  9. package/src/brave.ts +441 -0
  10. package/src/codex.ts +314 -0
  11. package/src/contracts.ts +644 -0
  12. package/src/direct-transport.ts +141 -0
  13. package/src/errors.ts +185 -0
  14. package/src/exa.ts +204 -0
  15. package/src/fetch-errors.ts +134 -0
  16. package/src/fetch-extractor-worker.mjs +85 -0
  17. package/src/fetch-tool.ts +166 -0
  18. package/src/fetcher.ts +451 -0
  19. package/src/fixtures/sample.csv +2 -0
  20. package/src/fixtures/sample.docx +0 -0
  21. package/src/fixtures/sample.epub +0 -0
  22. package/src/fixtures/sample.odt +0 -0
  23. package/src/fixtures/sample.pdf +32 -0
  24. package/src/fixtures/sample.pptx +0 -0
  25. package/src/fixtures/sample.rtf +1 -0
  26. package/src/fixtures/sample.xlsx +0 -0
  27. package/src/gemini.ts +298 -0
  28. package/src/grounding.ts +167 -0
  29. package/src/http.ts +78 -0
  30. package/src/index.ts +101 -0
  31. package/src/local-llama-compat.ts +134 -0
  32. package/src/meta.ts +137 -0
  33. package/src/model-selection.ts +134 -0
  34. package/src/openai.ts +850 -0
  35. package/src/parallel-responses.ts +260 -0
  36. package/src/parallel.ts +173 -0
  37. package/src/pdf.ts +153 -0
  38. package/src/provider-availability.ts +35 -0
  39. package/src/provider-http.ts +203 -0
  40. package/src/render-text.ts +8 -0
  41. package/src/research-tool.ts +487 -0
  42. package/src/router.ts +302 -0
  43. package/src/search-cleanup.ts +219 -0
  44. package/src/search-tool.ts +551 -0
  45. package/src/search.ts +420 -0
  46. package/src/ssrf.ts +302 -0
  47. package/src/url-rendering.ts +21 -0
  48. package/src/x.ts +188 -0
  49. package/src/xai.ts +254 -0
  50. package/src/youtube.ts +224 -0
package/src/ssrf.ts ADDED
@@ -0,0 +1,302 @@
1
+ import { lookup as dnsLookup } from "node:dns/promises";
2
+ import net from "node:net";
3
+ import { SafeFetchError } from "./fetch-errors";
4
+ import { directTransport, type DirectTransport } from "./direct-transport";
5
+
6
+ /*
7
+ * The validation and redirect structure is adapted from pi-web-access's
8
+ * ssrf-protection.ts (MIT; Copyright (c) 2025 Nico Bailon). See
9
+ * THIRD_PARTY_NOTICES.md for the retained attribution.
10
+ */
11
+
12
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
13
+ export const DEFAULT_MAX_REDIRECTS = 5;
14
+ export const MAX_URL_LENGTH = 8_192;
15
+
16
+ export interface LookupAddress {
17
+ readonly address: string;
18
+ readonly family: number;
19
+ }
20
+
21
+ export type Lookup = (hostname: string) => Promise<LookupAddress[]>;
22
+
23
+ export interface ValidatedRemoteUrl {
24
+ readonly url: URL;
25
+ /** Address selected from DNS and pinned by the direct transport. */
26
+ readonly address: string;
27
+ readonly family: 4 | 6;
28
+ }
29
+
30
+ export interface ResponseBody extends AsyncIterable<Uint8Array> {
31
+ readonly cancel?: () => void | Promise<void>;
32
+ readonly destroy?: () => void;
33
+ }
34
+
35
+ export interface TransportResponse {
36
+ readonly status: number;
37
+ readonly statusText: string;
38
+ readonly headers: Headers;
39
+ readonly body: ResponseBody | null;
40
+ }
41
+
42
+ export interface DirectTransportInit {
43
+ readonly signal: AbortSignal;
44
+ readonly headers?: Readonly<Record<string, string>>;
45
+ }
46
+
47
+ export interface ValidateRemoteUrlOptions {
48
+ readonly lookup?: Lookup;
49
+ readonly signal?: AbortSignal;
50
+ }
51
+
52
+ export interface FetchRemoteOptions extends ValidateRemoteUrlOptions {
53
+ readonly transport?: DirectTransport;
54
+ readonly maxRedirects?: number;
55
+ readonly headers?: Readonly<Record<string, string>>;
56
+ }
57
+
58
+ const defaultLookup: Lookup = async (hostname) => dnsLookup(hostname, { all: true, verbatim: true });
59
+
60
+ function throwIfAborted(signal?: AbortSignal): void {
61
+ if (signal?.aborted) {
62
+ throw new SafeFetchError({ kind: "canceled", message: "Fetch canceled" });
63
+ }
64
+ }
65
+
66
+ async function lookupWithCancellation(
67
+ lookup: Lookup,
68
+ hostname: string,
69
+ signal?: AbortSignal,
70
+ ): Promise<LookupAddress[]> {
71
+ throwIfAborted(signal);
72
+ const lookupPromise = lookup(hostname);
73
+ if (!signal) return lookupPromise;
74
+ return new Promise<LookupAddress[]>((resolve, reject) => {
75
+ const onAbort = () => reject(new SafeFetchError({ kind: "canceled", message: "Fetch canceled" }));
76
+ signal.addEventListener("abort", onAbort, { once: true });
77
+ lookupPromise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
78
+ });
79
+ }
80
+
81
+ /** Resolve and reject non-global targets before a socket is opened. */
82
+ export async function validateRemoteUrl(
83
+ rawUrl: string | URL,
84
+ options: ValidateRemoteUrlOptions = {},
85
+ ): Promise<ValidatedRemoteUrl> {
86
+ throwIfAborted(options.signal);
87
+ let url: URL;
88
+ try {
89
+ url = rawUrl instanceof URL ? new URL(rawUrl.href) : new URL(rawUrl);
90
+ } catch (error) {
91
+ throw new SafeFetchError({ kind: "invalidRequest", message: "URL is not valid", cause: error });
92
+ }
93
+ if (url.href.length > MAX_URL_LENGTH) {
94
+ throw new SafeFetchError({ kind: "invalidRequest", message: `URL exceeds the ${MAX_URL_LENGTH}-character limit` });
95
+ }
96
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
97
+ throw new SafeFetchError({ kind: "invalidRequest", message: "Only HTTP and HTTPS URLs can be fetched" });
98
+ }
99
+ if (url.username || url.password) {
100
+ throw new SafeFetchError({ kind: "invalidRequest", message: "URLs with embedded credentials are not allowed" });
101
+ }
102
+ // Fragments are not sent over HTTP. Dropping them makes the final URL
103
+ // deterministic without turning ordinary citation URLs into failures.
104
+ url.hash = "";
105
+
106
+ const hostname = normalizeHostname(url.hostname);
107
+ if (!hostname) {
108
+ throw new SafeFetchError({ kind: "invalidRequest", message: "URL must include a hostname" });
109
+ }
110
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) {
111
+ throw new SafeFetchError({ kind: "ssrf", message: "Blocked internal hostname" });
112
+ }
113
+
114
+ const literalFamily = net.isIP(hostname);
115
+ if (literalFamily === 4 || literalFamily === 6) {
116
+ assertPublicAddress(hostname, hostname);
117
+ return { url, address: hostname, family: literalFamily };
118
+ }
119
+
120
+ let addresses: LookupAddress[];
121
+ try {
122
+ addresses = await lookupWithCancellation(options.lookup ?? defaultLookup, hostname, options.signal);
123
+ } catch (error) {
124
+ if (error instanceof SafeFetchError) throw error;
125
+ throw new SafeFetchError({
126
+ kind: "network",
127
+ message: "Failed to resolve remote hostname",
128
+ retryable: true,
129
+ cause: error,
130
+ });
131
+ }
132
+ if (addresses.length === 0) {
133
+ throw new SafeFetchError({ kind: "network", message: "Remote hostname returned no addresses", retryable: true });
134
+ }
135
+ for (const entry of addresses) {
136
+ const address = normalizeHostname(entry.address);
137
+ const actualFamily = net.isIP(address);
138
+ if (actualFamily !== 4 && actualFamily !== 6 || entry.family !== actualFamily) {
139
+ throw new SafeFetchError({ kind: "ssrf", message: "Resolved address family is invalid" });
140
+ }
141
+ assertPublicAddress(address, hostname);
142
+ }
143
+ const first = addresses[0];
144
+ const firstAddress = normalizeHostname(first.address);
145
+ const firstFamily = net.isIP(firstAddress);
146
+ if (firstFamily !== 4 && firstFamily !== 6) {
147
+ throw new SafeFetchError({ kind: "network", message: "Resolved address is invalid", retryable: true });
148
+ }
149
+ return { url, address: firstAddress, family: firstFamily };
150
+ }
151
+
152
+ /** Fetch one response and manually revalidate every redirect target. */
153
+ export async function fetchRemoteUrl(
154
+ rawUrl: string | URL,
155
+ options: FetchRemoteOptions = {},
156
+ ): Promise<{ response: TransportResponse; url: ValidatedRemoteUrl; redirectCount: number }> {
157
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
158
+ if (!Number.isInteger(maxRedirects) || maxRedirects < 0 || maxRedirects > 20) {
159
+ throw new SafeFetchError({ kind: "invalidRequest", message: "maxRedirects must be an integer between 0 and 20" });
160
+ }
161
+ const transport = options.transport ?? directTransport;
162
+ let current = await validateRemoteUrl(rawUrl, options);
163
+
164
+ for (let redirects = 0; redirects <= maxRedirects; redirects += 1) {
165
+ throwIfAborted(options.signal);
166
+ const response = await transport(current, {
167
+ signal: options.signal ?? new AbortController().signal,
168
+ headers: options.headers,
169
+ });
170
+ if (!REDIRECT_STATUSES.has(response.status)) {
171
+ return { response, url: current, redirectCount: redirects };
172
+ }
173
+ const location = response.headers.get("location");
174
+ if (!location) return { response, url: current, redirectCount: redirects };
175
+ await closeResponseBody(response.body);
176
+ if (redirects === maxRedirects) {
177
+ throw new SafeFetchError({ kind: "redirect", message: "Too many redirects" });
178
+ }
179
+ let nextUrl: URL;
180
+ try {
181
+ nextUrl = new URL(location, current.url);
182
+ } catch (error) {
183
+ throw new SafeFetchError({ kind: "redirect", message: "Redirect location is not a valid URL", cause: error });
184
+ }
185
+ current = await validateRemoteUrl(nextUrl, options);
186
+ }
187
+
188
+ throw new SafeFetchError({ kind: "redirect", message: "Too many redirects" });
189
+ }
190
+
191
+ export async function closeResponseBody(body: ResponseBody | null): Promise<void> {
192
+ if (!body) return;
193
+ try {
194
+ if (body.cancel) await body.cancel();
195
+ } finally {
196
+ body.destroy?.();
197
+ }
198
+ }
199
+
200
+ function normalizeHostname(hostname: string): string {
201
+ return hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
202
+ }
203
+
204
+ function assertPublicAddress(address: string, hostname: string): void {
205
+ const normalized = normalizeHostname(address);
206
+ const family = net.isIP(normalized);
207
+ if (family !== 4 && family !== 6) {
208
+ throw new SafeFetchError({ kind: "ssrf", message: "Resolved non-IP address" });
209
+ }
210
+ if ((family === 4 && isBlockedIPv4(normalized)) || (family === 6 && isBlockedIPv6(normalized))) {
211
+ throw new SafeFetchError({ kind: "ssrf", message: "Blocked internal address" });
212
+ }
213
+ }
214
+
215
+ function isBlockedIPv4(address: string): boolean {
216
+ const parts = address.split(".").map(Number);
217
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
218
+ const [a, b, c, d] = parts;
219
+ return a === 0 ||
220
+ a === 10 ||
221
+ a === 127 ||
222
+ (a === 100 && b >= 64 && b <= 127) ||
223
+ (a === 169 && b === 254) ||
224
+ (a === 172 && b >= 16 && b <= 31) ||
225
+ (a === 192 && b === 0) ||
226
+ (a === 192 && b === 168) ||
227
+ (a === 192 && b === 88 && c === 99) ||
228
+ (a === 192 && b === 0 && c === 2) ||
229
+ (a === 198 && b >= 18 && b <= 19) ||
230
+ (a === 198 && b === 51 && c === 100) ||
231
+ (a === 203 && b === 0 && c === 113) ||
232
+ a >= 224 ||
233
+ (a === 255 && b === 255 && c === 255 && d === 255);
234
+ }
235
+
236
+ function isBlockedIPv6(address: string): boolean {
237
+ const groups = parseIPv6(address);
238
+ if (!groups) return true;
239
+ const first = groups[0];
240
+ if (groups.slice(0, 6).every((group) => group === 0)) return true; // IPv4-compatible, unspecified, and loopback
241
+ if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) return true; // IPv4-mapped
242
+ if (groups.slice(0, 4).every((group) => group === 0) && groups[4] === 0xffff && groups[5] === 0) return true; // IPv4-translated
243
+ if ((first & 0xfe00) === 0xfc00) return true; // ULA
244
+ if ((first & 0xffc0) === 0xfe80 || (first & 0xffc0) === 0xfec0) return true; // link-local and deprecated site-local
245
+ if ((first & 0xff00) === 0xff00) return true; // multicast
246
+
247
+ // IANA special-purpose and transition prefixes. Rejecting these avoids
248
+ // treating NAT64, 6to4, Teredo, documentation, benchmarking, and other
249
+ // non-global IPv6 destinations as ordinary public hosts.
250
+ const specialPrefixes: readonly [readonly number[], number][] = [
251
+ [[0x0064, 0xff9b, 0x0000, 0x0000, 0x0000, 0x0000], 96], // 64:ff9b::/96 (NAT64 well-known)
252
+ [[0x0064, 0xff9b, 0x0001], 48], // 64:ff9b:1::/48 (NAT64 local-use)
253
+ [[0x0100], 64], // 100::/64 discard-only
254
+ [[0x2001, 0x0000], 23], // 2001::/23 IETF protocol assignments
255
+ [[0x2001, 0x0000], 32], // Teredo
256
+ [[0x2001, 0x0001], 32], // PCP anycast / special use
257
+ [[0x2001, 0x0002], 48], // benchmarking
258
+ [[0x2001, 0x0003], 32], // AMT
259
+ [[0x2001, 0x0004], 48], // AS112-v4
260
+ [[0x2001, 0x0005], 48], // AS112-v6
261
+ [[0x2001, 0x0008], 32], // ORCHID
262
+ [[0x2001, 0x0010], 28], // ORCHIDv2 range
263
+ [[0x2001, 0x0020], 28], // ORCHIDv2 range
264
+ [[0x2001, 0x0db8], 32], // documentation
265
+ [[0x2002], 16], // 6to4
266
+ [[0x3fff, 0x0000], 20], // documentation
267
+ [[0x0100, 0x0000, 0x0000, 0x0001], 64], // dummy / discarded prefix
268
+ [[0x5f00], 16], // Segment Routing local-use
269
+ ];
270
+ return specialPrefixes.some(([prefix, bits]) => hasIPv6Prefix(groups, prefix, bits));
271
+ }
272
+
273
+ function hasIPv6Prefix(groups: readonly number[], prefix: readonly number[], bits: number): boolean {
274
+ const fullGroups = Math.floor(bits / 16);
275
+ for (let index = 0; index < fullGroups; index += 1) {
276
+ if (groups[index] !== (prefix[index] ?? 0)) return false;
277
+ }
278
+ const remaining = bits % 16;
279
+ return remaining === 0 || (groups[fullGroups] >> (16 - remaining)) === ((prefix[fullGroups] ?? 0) >> (16 - remaining));
280
+ }
281
+
282
+ function parseIPv6(address: string): number[] | null {
283
+ if (address.includes(".")) {
284
+ const lastColon = address.lastIndexOf(":");
285
+ const ipv4 = address.slice(lastColon + 1);
286
+ if (net.isIP(ipv4) !== 4) return null;
287
+ const octets = ipv4.split(".").map(Number);
288
+ address = `${address.slice(0, lastColon)}:${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
289
+ }
290
+ const pieces = address.split("::");
291
+ if (pieces.length > 2) return null;
292
+ const left = pieces[0] ? pieces[0].split(":") : [];
293
+ const right = pieces.length === 2 && pieces[1] ? pieces[1].split(":") : [];
294
+ const missing = 8 - left.length - right.length;
295
+ if (pieces.length === 1 && missing !== 0) return null;
296
+ if (pieces.length === 2 && missing < 0) return null;
297
+ const groups = [...left, ...Array(missing).fill("0"), ...right].map((part) => {
298
+ if (!/^[0-9a-f]{1,4}$/i.test(part)) return -1;
299
+ return Number.parseInt(part, 16);
300
+ });
301
+ return groups.length === 8 && groups.every((group) => group >= 0 && group <= 0xffff) ? groups : null;
302
+ }
@@ -0,0 +1,21 @@
1
+ import { normalizeSearchUrl } from "./search-cleanup";
2
+
3
+ /** Keep displayed URLs useful without presenting a shortened value as exact. */
4
+ export const MAX_DISPLAY_URL_LENGTH = 2_048;
5
+ const DISPLAY_URL_NOTICE = " [URL shortened; full URL is in structured details]";
6
+
7
+ function displayCandidate(value: string): string {
8
+ return normalizeSearchUrl(value)?.url ?? value.replace(/\s+/g, " ").trim();
9
+ }
10
+
11
+ export function isDisplayUrlShortened(value: string): boolean {
12
+ return displayCandidate(value).length > MAX_DISPLAY_URL_LENGTH;
13
+ }
14
+
15
+ export function renderSafeUrl(value: string, maxLength = MAX_DISPLAY_URL_LENGTH): string {
16
+ const canonical = displayCandidate(value);
17
+ const limit = Math.max(DISPLAY_URL_NOTICE.length + 2, Math.floor(maxLength));
18
+ if (canonical.length <= limit) return canonical;
19
+ const suffix = `…${DISPLAY_URL_NOTICE}`;
20
+ return `${canonical.slice(0, limit - suffix.length)}${suffix}`;
21
+ }
package/src/x.ts ADDED
@@ -0,0 +1,188 @@
1
+ import type {
2
+ Provider,
3
+ ProviderCapabilities,
4
+ ProviderContext,
5
+ ProviderProfile,
6
+ ProviderRateLimitInfo,
7
+ SearchOption,
8
+ SearchRequest,
9
+ SearchResponse,
10
+ SearchResult,
11
+ SearchWarning,
12
+ } from "./contracts";
13
+ import { createProviderError } from "./errors";
14
+ import { getJson, objectValue, optionalString, optionalTimestamp, requireApiKey, type SearchHttpFetch } from "./provider-http";
15
+ import { validateSearchRequest } from "./search";
16
+
17
+ export const X_RECENT_SEARCH_ENDPOINT = "https://api.x.com/2/tweets/search/recent";
18
+ export const DEFAULT_X_RESPONSE_BYTES = 4 * 1024 * 1024;
19
+ export const X_MAX_RESULTS = 20;
20
+ export const X_MIN_REQUEST_RESULTS = 10;
21
+
22
+ export interface XAdapterOptions {
23
+ readonly bearerToken?: string;
24
+ readonly endpoint?: string;
25
+ readonly fetchImpl?: SearchHttpFetch;
26
+ readonly maxResponseBytes?: number;
27
+ }
28
+
29
+ const capabilities: ProviderCapabilities = {
30
+ keyword: true,
31
+ freshness: true,
32
+ excerpts: true,
33
+ social: true,
34
+ dateFilter: true,
35
+ handleFilter: true,
36
+ };
37
+
38
+ const profile: ProviderProfile = {
39
+ auth: "environment",
40
+ costModel: "usage-based",
41
+ };
42
+
43
+ export interface XRequestPlan {
44
+ readonly url: string;
45
+ readonly appliedOptions: readonly SearchOption[];
46
+ readonly warnings: readonly SearchWarning[];
47
+ }
48
+
49
+ function malformed(message: string): never {
50
+ throw createProviderError({ provider: "x", kind: "malformed", message: `X API returned a malformed response (${message})`, retryable: false });
51
+ }
52
+
53
+ function postUrl(id: string): string {
54
+ return `https://x.com/i/web/status/${encodeURIComponent(id)}`;
55
+ }
56
+
57
+ function buildQuery(request: SearchRequest): string {
58
+ const terms = [request.query];
59
+ const included = request.social?.includeHandles ?? [];
60
+ if (included.length === 1) terms.push(`from:${included[0]}`);
61
+ if (included.length > 1) terms.push(`(${included.map((handle) => `from:${handle}`).join(" OR ")})`);
62
+ for (const handle of request.social?.excludeHandles ?? []) terms.push(`-from:${handle}`);
63
+ return terms.join(" ");
64
+ }
65
+
66
+ function xDate(value: string, endOfDay: boolean): string {
67
+ if (value.includes("T")) return value;
68
+ return `${value}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z`;
69
+ }
70
+
71
+ /** Build one bounded recent-search request. Pagination is intentionally explicit and absent. */
72
+ export function buildXRequest(request: SearchRequest, endpoint = X_RECENT_SEARCH_ENDPOINT): XRequestPlan {
73
+ const normalized = validateSearchRequest(request);
74
+ if (normalized.domains?.include?.length || normalized.domains?.exclude?.length) {
75
+ throw createProviderError({ provider: "x", kind: "unsupported", message: "X API post search does not provide web-domain filters", retryable: false });
76
+ }
77
+ if (normalized.social?.understandImages === true || normalized.social?.understandVideos === true) {
78
+ throw createProviderError({ provider: "x", kind: "unsupported", message: "X API post search does not provide media understanding", retryable: false });
79
+ }
80
+ const params = new URLSearchParams({
81
+ query: buildQuery(normalized),
82
+ // X requires at least 10 for recent search; the normalized boundary
83
+ // slices the response back to the caller's requested maxResults.
84
+ max_results: String(Math.max(X_MIN_REQUEST_RESULTS, Math.min(normalized.maxResults ?? 10, X_MAX_RESULTS))),
85
+ tweet_fields: "created_at,author_id,public_metrics",
86
+ expansions: "author_id",
87
+ user_fields: "username,name",
88
+ ...(normalized.dateRange?.from === undefined ? {} : { start_time: xDate(normalized.dateRange.from, false) }),
89
+ ...(normalized.dateRange?.to === undefined ? {} : { end_time: xDate(normalized.dateRange.to, true) }),
90
+ });
91
+ const appliedOptions: SearchOption[] = ["maxResults", "mode", ...(normalized.dateRange === undefined ? [] : ["dateRange" as const]), ...(normalized.social?.includeHandles === undefined && normalized.social?.excludeHandles === undefined ? [] : ["social" as const])];
92
+ const warnings: SearchWarning[] = [];
93
+ if (normalized.mode === "fresh") {
94
+ warnings.push({ code: "unsupported-option", option: "mode", message: "X recent search is time-bounded by the endpoint; exact freshness ranking is not guaranteed" });
95
+ }
96
+ return { url: `${endpoint}?${params.toString()}`, appliedOptions, warnings };
97
+ }
98
+
99
+ function normalizeXResponse(payload: unknown, request: SearchRequest, metadata: { readonly requestId?: string; readonly rateLimits?: ProviderRateLimitInfo }): SearchResponse {
100
+ const normalized = validateSearchRequest(request);
101
+ const root = objectValue(payload, "response", "x");
102
+ if (root.data !== undefined && !Array.isArray(root.data)) return malformed("data is not an array");
103
+ const users = new Map<string, string>();
104
+ if (root.includes !== undefined) {
105
+ const includes = objectValue(root.includes, "includes", "x");
106
+ if (includes.users !== undefined) {
107
+ if (!Array.isArray(includes.users)) return malformed("includes.users is not an array");
108
+ for (const value of includes.users) {
109
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
110
+ const user = value as Record<string, unknown>;
111
+ const id = optionalString(user.id, 100);
112
+ const username = optionalString(user.username, 100);
113
+ if (id !== undefined && username !== undefined) users.set(id, username);
114
+ }
115
+ }
116
+ }
117
+ const results: SearchResult[] = [];
118
+ for (const value of (root.data as unknown[] | undefined) ?? []) {
119
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
120
+ const post = value as Record<string, unknown>;
121
+ const id = optionalString(post.id, 100);
122
+ if (id === undefined) continue;
123
+ const text = optionalString(post.text, 4_000);
124
+ const publishedAt = optionalTimestamp(post.created_at);
125
+ const authorId = optionalString(post.author_id, 100);
126
+ const username = authorId === undefined ? undefined : users.get(authorId);
127
+ results.push({
128
+ url: postUrl(id),
129
+ ...(username === undefined ? {} : { title: `@${username}` }),
130
+ domain: "x.com",
131
+ ...(publishedAt === undefined ? {} : { publishedAt }),
132
+ ...(text === undefined ? {} : { excerpt: text }),
133
+ provider: "x",
134
+ searchQuery: normalized.query,
135
+ sourceId: id,
136
+ });
137
+ }
138
+ if (results.length === 0 && root.data !== undefined && root.data.length > 0) return malformed("data contained no usable posts");
139
+ const usage = {
140
+ ...(results.length === 0 ? {} : { billedUnits: results.length, billedUnit: "posts" }),
141
+ ...(metadata.rateLimits === undefined ? {} : { rateLimits: metadata.rateLimits }),
142
+ };
143
+ return {
144
+ query: normalized.query,
145
+ results: results.slice(0, normalized.maxResults),
146
+ provider: "x",
147
+ appliedOptions: [],
148
+ warnings: [],
149
+ ...(metadata.requestId === undefined ? {} : { requestId: metadata.requestId }),
150
+ ...(Object.keys(usage).length === 0 ? {} : { usage }),
151
+ };
152
+ }
153
+
154
+ export class XProvider implements Provider {
155
+ readonly id = "x" as const;
156
+ readonly capabilities = capabilities;
157
+ readonly profile = profile;
158
+ private readonly bearerToken?: string;
159
+ private readonly endpoint: string;
160
+ private readonly fetchImpl: SearchHttpFetch;
161
+ private readonly maxResponseBytes: number;
162
+
163
+ constructor(options: XAdapterOptions) {
164
+ this.bearerToken = options.bearerToken;
165
+ this.endpoint = options.endpoint ?? X_RECENT_SEARCH_ENDPOINT;
166
+ this.fetchImpl = options.fetchImpl ?? (fetch as SearchHttpFetch);
167
+ this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_X_RESPONSE_BYTES;
168
+ }
169
+
170
+ async search(request: SearchRequest, signal: AbortSignal, _context: ProviderContext): Promise<SearchResponse> {
171
+ const normalized = validateSearchRequest(request);
172
+ const plan = buildXRequest(normalized, this.endpoint);
173
+ const result = await getJson({
174
+ provider: this.id,
175
+ url: plan.url,
176
+ headers: { authorization: `Bearer ${requireApiKey(this.id, this.bearerToken)}` },
177
+ signal,
178
+ fetchImpl: this.fetchImpl,
179
+ maxResponseBytes: this.maxResponseBytes,
180
+ });
181
+ const response = normalizeXResponse(result.payload, normalized, result);
182
+ return { ...response, appliedOptions: plan.appliedOptions, warnings: [...plan.warnings, ...response.warnings] };
183
+ }
184
+ }
185
+
186
+ export function createXProvider(options: XAdapterOptions): XProvider {
187
+ return new XProvider(options);
188
+ }