@nijaru/pi-search 0.0.7 → 0.0.8
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/README.md +65 -0
- package/package.json +1 -1
- package/src/contracts.ts +6 -2
- package/src/fetch-extractor-worker.mjs +14 -0
- package/src/fetch-tool.ts +3 -1
- package/src/fetcher.ts +109 -50
- package/src/index.ts +40 -4
- package/src/x-browser-page.ts +191 -0
- package/src/x-browser.ts +181 -0
- package/src/x-cdp.ts +101 -0
- package/src/x-dom.d.mts +7 -0
- package/src/x-dom.mjs +77 -0
- package/src/x-public.ts +396 -0
package/src/x-public.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { SafeFetchError } from "./fetch-errors";
|
|
2
|
+
|
|
3
|
+
export interface XUrl {
|
|
4
|
+
url: string;
|
|
5
|
+
postId?: string;
|
|
6
|
+
articleId?: string;
|
|
7
|
+
kind: "post" | "article";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface XReadResult {
|
|
11
|
+
url: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
content: string;
|
|
14
|
+
warnings: readonly string[];
|
|
15
|
+
postCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ID = /^\d{2,20}$/;
|
|
19
|
+
const HANDLE = /^[A-Za-z0-9_]{1,15}$/;
|
|
20
|
+
const CONTROLS = /[\u0000-\u0020\u007f-\u009f]/;
|
|
21
|
+
const MAX_POSTS = 100;
|
|
22
|
+
const MAX_BLOCKS = 2_000;
|
|
23
|
+
type ObjectValue = Record<string, unknown>;
|
|
24
|
+
type Reader = (url: string) => Promise<{ bytes: Uint8Array; url: string; status: number; redirectCount: number }>;
|
|
25
|
+
|
|
26
|
+
export function parseXUrl(value: string): XUrl | undefined {
|
|
27
|
+
if (CONTROLS.test(value) || value.includes("\\")) return undefined;
|
|
28
|
+
// Check the literal authority too: URL parsing otherwise accepts encoded hostnames.
|
|
29
|
+
if (!/^https?:\/\/(?:x\.com|www\.x\.com|twitter\.com|www\.twitter\.com)(?::\d+)?(?:\/|$|[?#])/i.test(value)) return undefined;
|
|
30
|
+
let parsed: URL;
|
|
31
|
+
try { parsed = new URL(value); } catch { return undefined; }
|
|
32
|
+
if (parsed.username || parsed.password || parsed.port) return undefined;
|
|
33
|
+
const path = parsed.pathname;
|
|
34
|
+
const bare = /^\/i\/article\/(\d{2,20})\/?$/.exec(path);
|
|
35
|
+
if (bare) return { url: `https://x.com/i/article/${bare[1]}`, articleId: bare[1], kind: "article" };
|
|
36
|
+
const web = /^\/i\/web\/status\/(\d{2,20})(?:\/(?:photo|video)\/\d+)?\/?$/.exec(path);
|
|
37
|
+
if (web) return { url: `https://x.com/i/web/status/${web[1]}`, postId: web[1], kind: "post" };
|
|
38
|
+
const post = /^\/([A-Za-z0-9_]{1,15})\/(status|article)\/(\d{2,20})(?:\/(?:photo|video)\/\d+)?\/?$/.exec(path);
|
|
39
|
+
if (!post || post[1].toLowerCase() === "i") return undefined;
|
|
40
|
+
return { url: `https://x.com/${post[1]}/${post[2]}/${post[3]}`, postId: post[3], kind: post[2] === "article" ? "article" : "post" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extraction(message = "FxTwitter returned unavailable or invalid public X content"): SafeFetchError {
|
|
44
|
+
return new SafeFetchError({ kind: "extraction", message });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function object(value: unknown): ObjectValue | undefined {
|
|
48
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as ObjectValue : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function string(value: unknown): string | undefined {
|
|
52
|
+
return typeof value === "string" ? value : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function id(value: unknown): string | undefined {
|
|
56
|
+
return typeof value === "string" && ID.test(value) ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clean(value: string): string {
|
|
60
|
+
return value.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Remote prose is literal, not Markdown instructions, HTML, or reference definitions. */
|
|
64
|
+
function literal(value: string): string {
|
|
65
|
+
return clean(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
66
|
+
.replace(/[\\`*_{}\[\]()#+.!|~\-]/g, "\\$&");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function external(value: unknown): string | undefined {
|
|
70
|
+
if (typeof value !== "string" || value.length > 8192 || CONTROLS.test(value) || /\\|%(?:0[0-9a-f]|1[0-9a-f]|7f|8[0-9a-f]|9[0-9a-f])/i.test(value)) return undefined;
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(value);
|
|
73
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password) return undefined;
|
|
74
|
+
return url.href.replace(/[<>"()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
75
|
+
} catch { return undefined; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function link(label: string, value: unknown): string {
|
|
79
|
+
const url = external(value);
|
|
80
|
+
return url ? `[${literal(label)}](<${url}>)` : literal(label);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fence(value: string, language = ""): string {
|
|
84
|
+
const text = clean(value);
|
|
85
|
+
let width = 3;
|
|
86
|
+
for (const run of text.matchAll(/`+/g)) width = Math.max(width, run[0].length + 1);
|
|
87
|
+
const delimiter = "`".repeat(width);
|
|
88
|
+
return `${delimiter}${/^[\w+-]{0,32}$/.test(language) ? language : ""}\n${text}\n${delimiter}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function markdownEntity(value: string): string {
|
|
92
|
+
// Preserve code, but never trust an atomic entity as arbitrary Markdown/HTML.
|
|
93
|
+
const match = /^(`{3,})([\w+-]{0,32})\n([\s\S]*?)\n\1\s*$/.exec(clean(value));
|
|
94
|
+
return match ? fence(match[3], match[2]) : literal(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Bound the entire untrusted tree before any specialized traversal. No recursive descent. */
|
|
98
|
+
function validateTree(root: unknown): void {
|
|
99
|
+
const pending: { value: unknown; depth: number }[] = [{ value: root, depth: 0 }];
|
|
100
|
+
let nodes = 0;
|
|
101
|
+
while (pending.length) {
|
|
102
|
+
const { value, depth } = pending.pop()!;
|
|
103
|
+
if (++nodes > 100_000 || depth > 32) throw extraction("FxTwitter content exceeds safe structure limits");
|
|
104
|
+
if (value !== null && typeof value === "object") {
|
|
105
|
+
const children = Object.values(value);
|
|
106
|
+
if (children.length > 20_000) throw extraction("FxTwitter content exceeds safe structure limits");
|
|
107
|
+
for (const child of children) pending.push({ value: child, depth: depth + 1 });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function usablePost(value: unknown): value is ObjectValue {
|
|
113
|
+
const post = object(value);
|
|
114
|
+
return !!post && post.type === "status" && id(post.id) !== undefined && authorKey(post) !== undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function authorKey(post: ObjectValue): string | undefined {
|
|
118
|
+
const author = object(post.author);
|
|
119
|
+
const handle = string(author?.screen_name);
|
|
120
|
+
return id(author?.id) ?? (handle && HANDLE.test(handle) ? handle.toLowerCase() : undefined);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parentId(post: ObjectValue): string | undefined {
|
|
124
|
+
return id(object(post.replying_to)?.status);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function postUrl(post: ObjectValue): string {
|
|
128
|
+
const handle = string(object(post.author)?.screen_name);
|
|
129
|
+
return `https://x.com/${handle && HANDLE.test(handle) ? handle : "i/web"}/status/${post.id}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function fetchPayload(postId: string, endpoint: "thread" | "status", read: Reader): Promise<ObjectValue> {
|
|
133
|
+
let response: Awaited<ReturnType<Reader>>;
|
|
134
|
+
try { response = await read(`https://api.fxtwitter.com/2/${endpoint}/${postId}`); }
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof SafeFetchError) throw new SafeFetchError({ kind: error.kind, status: error.status, message: "FxTwitter public X request failed", retryable: error.retryable });
|
|
137
|
+
throw extraction("FxTwitter public X request failed");
|
|
138
|
+
}
|
|
139
|
+
if (response.status < 200 || response.status >= 300) throw new SafeFetchError({ kind: "http", status: response.status, message: "FxTwitter public X request failed" });
|
|
140
|
+
let value: unknown;
|
|
141
|
+
try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(response.bytes)); }
|
|
142
|
+
catch { throw extraction("FxTwitter returned invalid JSON"); }
|
|
143
|
+
validateTree(value);
|
|
144
|
+
const payload = object(value);
|
|
145
|
+
if (payload?.code !== 200 || !usablePost(payload.status) || payload.status.id !== postId) throw extraction();
|
|
146
|
+
if (payload.thread !== undefined && payload.thread !== null && !Array.isArray(payload.thread)) throw extraction();
|
|
147
|
+
return payload;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface Range { start: number; end: number; value: ObjectValue }
|
|
151
|
+
function ranges(value: unknown, length: number): Range[] {
|
|
152
|
+
if (value === undefined) return [];
|
|
153
|
+
if (!Array.isArray(value) || value.length > 256) throw extraction("FxTwitter article has invalid text ranges");
|
|
154
|
+
return value.map((raw) => {
|
|
155
|
+
const item = object(raw);
|
|
156
|
+
const start = item?.offset;
|
|
157
|
+
const size = item?.length;
|
|
158
|
+
if (!item || typeof start !== "number" || typeof size !== "number" || !Number.isInteger(start) || !Number.isInteger(size) || start < 0 || size < 0 || start + size > length) throw extraction("FxTwitter article has invalid text ranges");
|
|
159
|
+
return { start, end: start + size, value: item };
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function articleMedia(media: ObjectValue): string[] {
|
|
164
|
+
const info = object(media.media_info);
|
|
165
|
+
const urls: unknown[] = [info?.original_img_url, info?.media_url_https];
|
|
166
|
+
const variants = info?.variants ?? object(info?.video_info)?.variants;
|
|
167
|
+
if (Array.isArray(variants)) for (const variant of variants) urls.push(object(variant)?.url);
|
|
168
|
+
return [...new Set(urls.map(external).filter((url): url is string => !!url))];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function articleBody(article: ObjectValue): string {
|
|
172
|
+
const content = object(article.content);
|
|
173
|
+
if (!Array.isArray(content?.blocks) || !content.blocks.length || content.blocks.length > MAX_BLOCKS) throw extraction("FxTwitter did not provide the full Article body");
|
|
174
|
+
const entities = new Map<string, ObjectValue>();
|
|
175
|
+
if (Array.isArray(content.entityMap)) {
|
|
176
|
+
if (content.entityMap.length > 4_000) throw extraction("FxTwitter article exceeds entity limits");
|
|
177
|
+
for (const entry of content.entityMap) {
|
|
178
|
+
const item = object(entry);
|
|
179
|
+
const entity = object(item?.value);
|
|
180
|
+
if (!item || !entity || typeof item.key !== "string" || entities.has(item.key)) throw extraction();
|
|
181
|
+
entities.set(item.key, entity);
|
|
182
|
+
}
|
|
183
|
+
} else if (object(content.entityMap)) {
|
|
184
|
+
const entries = Object.entries(content.entityMap as ObjectValue);
|
|
185
|
+
if (entries.length > 4_000) throw extraction();
|
|
186
|
+
for (const [key, value] of entries) {
|
|
187
|
+
const entity = object(value);
|
|
188
|
+
if (!entity) throw extraction();
|
|
189
|
+
entities.set(key, entity);
|
|
190
|
+
}
|
|
191
|
+
} else if (content.entityMap !== undefined) throw extraction();
|
|
192
|
+
const media = new Map<string, ObjectValue>();
|
|
193
|
+
if (Array.isArray(article.media_entities)) for (const item of article.media_entities) {
|
|
194
|
+
const entry = object(item);
|
|
195
|
+
if (entry && typeof entry.media_id === "string") media.set(entry.media_id, entry);
|
|
196
|
+
}
|
|
197
|
+
let substantive = false;
|
|
198
|
+
let renderedLength = 0;
|
|
199
|
+
const bounded = (text: string): string => {
|
|
200
|
+
renderedLength += text.length;
|
|
201
|
+
if (renderedLength > 32_000_000) throw extraction("FxTwitter Article exceeds safe rendering limits");
|
|
202
|
+
return text;
|
|
203
|
+
};
|
|
204
|
+
const parts = content.blocks.map((raw) => {
|
|
205
|
+
const block = object(raw);
|
|
206
|
+
if (!block || typeof block.text !== "string" || typeof block.type !== "string") throw extraction();
|
|
207
|
+
const text = block.text;
|
|
208
|
+
const entityRanges = ranges(block.entityRanges, text.length);
|
|
209
|
+
const styles = ranges(block.inlineStyleRanges, text.length);
|
|
210
|
+
if (block.type === "atomic") {
|
|
211
|
+
if (!entityRanges.length) throw extraction("FxTwitter Article contains an unavailable body block");
|
|
212
|
+
const renderEntity = (range: Range): string => {
|
|
213
|
+
const entity = entities.get(String(range.value.key));
|
|
214
|
+
const data = object(entity?.data);
|
|
215
|
+
if (!entity || !data) throw extraction();
|
|
216
|
+
switch (entity.type) {
|
|
217
|
+
case "DIVIDER": return "---";
|
|
218
|
+
case "MARKDOWN": {
|
|
219
|
+
if (typeof data.markdown !== "string" || !data.markdown.trim()) throw extraction();
|
|
220
|
+
substantive = true;
|
|
221
|
+
return markdownEntity(data.markdown);
|
|
222
|
+
}
|
|
223
|
+
case "TWEET": {
|
|
224
|
+
const tweetId = id(data.tweetId);
|
|
225
|
+
if (!tweetId) throw extraction();
|
|
226
|
+
substantive = true;
|
|
227
|
+
return link("Embedded post", `https://x.com/i/web/status/${tweetId}`);
|
|
228
|
+
}
|
|
229
|
+
case "MEDIA": {
|
|
230
|
+
if (!Array.isArray(data.mediaItems) || !data.mediaItems.length || data.mediaItems.length > 100) throw extraction();
|
|
231
|
+
const rendered: string[] = [];
|
|
232
|
+
let mediaLength = 0;
|
|
233
|
+
for (const item of data.mediaItems) {
|
|
234
|
+
const entry = media.get(String(object(item)?.mediaId));
|
|
235
|
+
if (!entry) throw extraction("FxTwitter Article media is unavailable");
|
|
236
|
+
for (const url of articleMedia(entry)) {
|
|
237
|
+
const piece = link("Article media", url);
|
|
238
|
+
mediaLength += piece.length;
|
|
239
|
+
if (mediaLength + renderedLength > 32_000_000) throw extraction("FxTwitter Article exceeds safe rendering limits");
|
|
240
|
+
rendered.push(piece);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (!rendered.length) throw extraction("FxTwitter Article media is unavailable");
|
|
244
|
+
substantive = true;
|
|
245
|
+
return rendered.join("\n\n");
|
|
246
|
+
}
|
|
247
|
+
default: throw extraction("FxTwitter Article contains an unsupported body block");
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
return entityRanges.map((range) => bounded(renderEntity(range))).join("\n\n");
|
|
251
|
+
}
|
|
252
|
+
if (text.trim()) substantive = true;
|
|
253
|
+
// Segment on range boundaries. Each segment is escaped independently, so ranges
|
|
254
|
+
// cannot inject syntax, even when styles and links overlap.
|
|
255
|
+
const boundaries = [...new Set([0, text.length, ...entityRanges.flatMap((r) => [r.start, r.end]), ...styles.flatMap((r) => [r.start, r.end])])].sort((a, b) => a - b);
|
|
256
|
+
let rendered = "";
|
|
257
|
+
for (let index = 0; index < boundaries.length - 1; index++) {
|
|
258
|
+
const start = boundaries[index];
|
|
259
|
+
const end = boundaries[index + 1];
|
|
260
|
+
const segment = text.slice(start, end);
|
|
261
|
+
let piece = literal(segment);
|
|
262
|
+
for (const style of styles.filter((r) => r.start <= start && r.end >= end)) {
|
|
263
|
+
const name = String(style.value.style).toLowerCase();
|
|
264
|
+
const marker = name === "bold" ? "**" : name === "italic" ? "*" : name === "strikethrough" ? "~~" : undefined;
|
|
265
|
+
if (marker) piece = `${marker}${piece}${marker}`;
|
|
266
|
+
}
|
|
267
|
+
const range = entityRanges.find((r) => r.start <= start && r.end >= end);
|
|
268
|
+
if (range) {
|
|
269
|
+
const entity = entities.get(String(range.value.key));
|
|
270
|
+
if (!entity || entity.type !== "LINK") throw extraction("FxTwitter Article contains an unsupported inline entity");
|
|
271
|
+
const url = external(object(entity.data)?.url);
|
|
272
|
+
if (url) piece = `[${piece}](<${url}>)`;
|
|
273
|
+
}
|
|
274
|
+
rendered += piece;
|
|
275
|
+
}
|
|
276
|
+
bounded(rendered);
|
|
277
|
+
const depth = block.depth ?? 0;
|
|
278
|
+
if (typeof depth !== "number" || !Number.isInteger(depth) || depth < 0 || depth > 8) throw extraction();
|
|
279
|
+
const heading = ["header-one", "header-two", "header-three", "header-four", "header-five", "header-six"].indexOf(block.type);
|
|
280
|
+
if (heading >= 0) return `${"#".repeat(heading + 1)} ${rendered}`;
|
|
281
|
+
if (block.type === "blockquote") return rendered.split("\n").map((line) => `> ${line}`).join("\n");
|
|
282
|
+
if (block.type === "code-block") return fence(text);
|
|
283
|
+
if (block.type.endsWith("list-item")) {
|
|
284
|
+
const prefix = `${" ".repeat(depth)}${block.type === "ordered-list-item" ? "1." : "-"} `;
|
|
285
|
+
return prefix + rendered.replace(/\n/g, `\n${" ".repeat(prefix.length)}`);
|
|
286
|
+
}
|
|
287
|
+
return rendered;
|
|
288
|
+
});
|
|
289
|
+
if (!substantive) throw extraction("FxTwitter did not provide the full Article body");
|
|
290
|
+
return parts.join("\n\n");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function mediaLinks(post: ObjectValue): string[] {
|
|
294
|
+
const media = object(post.media);
|
|
295
|
+
const items = Array.isArray(media?.all) ? media.all : [...(Array.isArray(media?.photos) ? media.photos : []), ...(Array.isArray(media?.videos) ? media.videos : [])];
|
|
296
|
+
const urls = items.map((item) => external(object(item)?.url));
|
|
297
|
+
const externalMedia = external(object(media?.external)?.url);
|
|
298
|
+
if (externalMedia) urls.push(externalMedia);
|
|
299
|
+
return [...new Set(urls.filter((url): url is string => !!url))].map((url) => link("Media", url));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderPost(post: ObjectValue, warnings: Set<string>): string {
|
|
303
|
+
const author = object(post.author)!;
|
|
304
|
+
const name = string(author.name) ?? string(author.screen_name) ?? "X author";
|
|
305
|
+
const handle = string(author.screen_name);
|
|
306
|
+
const date = string(post.created_at);
|
|
307
|
+
const heading = `## ${literal(name)}${handle ? ` (@${literal(handle)})` : ""}`;
|
|
308
|
+
const metadata = [link("Source", postUrl(post)), date ? literal(date) : ""].filter(Boolean).join(" · ");
|
|
309
|
+
const article = object(post.article);
|
|
310
|
+
if (post.article !== undefined && post.article !== null && !article) throw extraction();
|
|
311
|
+
const text = string(post.text) ?? string(object(post.raw_text)?.text);
|
|
312
|
+
if (!article && !text?.trim() && !mediaLinks(post).length && !post.quote) throw extraction();
|
|
313
|
+
const body = article ? `${typeof article.title === "string" ? `# ${literal(article.title)}\n\n` : ""}${articleBody(article)}` : literal(text ?? "");
|
|
314
|
+
const parts = [heading, metadata, body, ...mediaLinks(post)];
|
|
315
|
+
if (post.quote !== undefined && post.quote !== null) {
|
|
316
|
+
if (usablePost(post.quote)) {
|
|
317
|
+
const quote = post.quote;
|
|
318
|
+
parts.push(`### Quoted post\n\n${link("Quote source", postUrl(quote))}\n\n${literal(string(quote.text) ?? string(object(quote.raw_text)?.text) ?? "")}\n\n${mediaLinks(quote).join("\n\n")}`);
|
|
319
|
+
} else {
|
|
320
|
+
warnings.add("Partial result: a quoted post is unavailable.");
|
|
321
|
+
const quoteId = id(object(post.quote)?.id);
|
|
322
|
+
parts.push(quoteId ? link("Unavailable quoted post", `https://x.com/i/web/status/${quoteId}`) : "Quoted post unavailable.");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return parts.filter(Boolean).join("\n\n");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Only connected posts by the focal author belong to a thread, never arbitrary replies. */
|
|
329
|
+
function orderedThread(posts: Map<string, ObjectValue>, focal: ObjectValue): ObjectValue[] {
|
|
330
|
+
const key = authorKey(focal);
|
|
331
|
+
const selected = new Set<string>([String(focal.id)]);
|
|
332
|
+
let ancestor = focal;
|
|
333
|
+
while (selected.size < MAX_POSTS) {
|
|
334
|
+
const parent = posts.get(parentId(ancestor) ?? "");
|
|
335
|
+
if (!parent || authorKey(parent) !== key || selected.has(String(parent.id))) break;
|
|
336
|
+
selected.add(String(parent.id));
|
|
337
|
+
ancestor = parent;
|
|
338
|
+
}
|
|
339
|
+
for (let changed = true; changed && selected.size < MAX_POSTS;) {
|
|
340
|
+
changed = false;
|
|
341
|
+
for (const [postId, post] of posts) {
|
|
342
|
+
if (!selected.has(postId) && authorKey(post) === key && selected.has(parentId(post) ?? "")) {
|
|
343
|
+
selected.add(postId);
|
|
344
|
+
changed = true;
|
|
345
|
+
if (selected.size === MAX_POSTS) break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const result: ObjectValue[] = [];
|
|
350
|
+
const remaining = new Set(selected);
|
|
351
|
+
while (remaining.size) {
|
|
352
|
+
const ready = [...remaining].filter((postId) => !remaining.has(parentId(posts.get(postId)!) ?? "")).sort((a, b) => a.length - b.length || a.localeCompare(b));
|
|
353
|
+
if (!ready.length) throw extraction("FxTwitter returned an invalid thread relationship");
|
|
354
|
+
for (const postId of ready) { result.push(posts.get(postId)!); remaining.delete(postId); }
|
|
355
|
+
}
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function readFxTwitter(target: XUrl, read: Reader): Promise<XReadResult> {
|
|
360
|
+
if (!target.postId) throw extraction("Article link requires public /handle/article/POSTID or publishing post URL");
|
|
361
|
+
const parsed = parseXUrl(target.url);
|
|
362
|
+
if (!ID.test(target.postId) || !parsed || parsed.postId !== target.postId || parsed.kind !== target.kind || target.articleId !== undefined) throw extraction("Invalid public X post target");
|
|
363
|
+
const payload = await fetchPayload(target.postId, target.kind === "article" ? "status" : "thread", read);
|
|
364
|
+
const focal = payload.status as ObjectValue;
|
|
365
|
+
if (target.kind === "article" && !object(focal.article)) throw extraction("FxTwitter did not provide the full Article body");
|
|
366
|
+
const warnings = new Set<string>(focal.article ? [] : ["Thread completeness is unknown; FxTwitter may omit posts."]);
|
|
367
|
+
const posts = new Map<string, ObjectValue>([[target.postId, focal]]);
|
|
368
|
+
const merge = (response: ObjectValue): void => {
|
|
369
|
+
for (const entry of (response.thread as unknown[] | null | undefined) ?? []) {
|
|
370
|
+
if (!usablePost(entry)) { warnings.add("Partial thread: one or more posts are unavailable."); continue; }
|
|
371
|
+
if (authorKey(entry) !== authorKey(focal)) continue;
|
|
372
|
+
// Keep bounded-tree candidates until relationships are resolved: capping the
|
|
373
|
+
// incoming array first can discard the root's children in reverse-order feeds.
|
|
374
|
+
if (!posts.has(String(entry.id))) posts.set(String(entry.id), entry);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
if (!focal.article && target.kind === "post") merge(payload);
|
|
378
|
+
let ordered = orderedThread(posts, focal);
|
|
379
|
+
if (!focal.article && ordered.length > 1 && ordered.length < MAX_POSTS) {
|
|
380
|
+
const tail = ordered[ordered.length - 1];
|
|
381
|
+
if (tail.id !== target.postId) {
|
|
382
|
+
try { merge(await fetchPayload(String(tail.id), "thread", read)); }
|
|
383
|
+
catch (error) {
|
|
384
|
+
if (!(error instanceof SafeFetchError) || !["http", "extraction"].includes(error.kind)) throw error;
|
|
385
|
+
warnings.add("Partial thread: the tail continuation could not be read.");
|
|
386
|
+
}
|
|
387
|
+
ordered = orderedThread(posts, focal);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (ordered.length === MAX_POSTS) warnings.add("Partial thread: the 100-post limit was reached.");
|
|
391
|
+
if (ordered.some((post) => parentId(post) && !posts.has(parentId(post)!))) warnings.add("Partial thread: a parent post is missing.");
|
|
392
|
+
if (ordered.length < MAX_POSTS && [...posts.values()].some((post) => !ordered.includes(post))) warnings.add("Partial thread: disconnected posts were omitted because their parents are missing.");
|
|
393
|
+
const content = ordered.map((post) => renderPost(post, warnings)).join("\n\n---\n\n");
|
|
394
|
+
const title = string(object(focal.article)?.title) ?? `X post by @${string(object(focal.author)?.screen_name) ?? "unknown"}`;
|
|
395
|
+
return { url: parsed.url, title: clean(title), content, warnings: [...warnings], postCount: ordered.length };
|
|
396
|
+
}
|