返回 last30days-skill
twitter-client-utils.js
根目录 / skills / last30days / scripts / lib / vendor / bird-search / lib / twitter-client-utils.js
1 export function normalizeQuoteDepth(value) {
2 if (value === undefined || value === null) {
3 return 1;
4 }
5 if (!Number.isFinite(value)) {
6 return 1;
7 }
8 return Math.max(0, Math.floor(value));
9 }
10 export function firstText(...values) {
11 for (const value of values) {
12 if (typeof value === 'string') {
13 const trimmed = value.trim();
14 if (trimmed) {
15 return trimmed;
16 }
17 }
18 }
19 return undefined;
20 }
21 export function collectTextFields(value, keys, output) {
22 if (!value) {
23 return;
24 }
25 if (typeof value === 'string') {
26 return;
27 }
28 if (Array.isArray(value)) {
29 for (const item of value) {
30 collectTextFields(item, keys, output);
31 }
32 return;
33 }
34 if (typeof value === 'object') {
35 for (const [key, nested] of Object.entries(value)) {
36 if (keys.has(key)) {
37 if (typeof nested === 'string') {
38 const trimmed = nested.trim();
39 if (trimmed) {
40 output.push(trimmed);
41 }
42 continue;
43 }
44 }
45 collectTextFields(nested, keys, output);
46 }
47 }
48 }
49 export function uniqueOrdered(values) {
50 const seen = new Set();
51 const result = [];
52 for (const value of values) {
53 if (seen.has(value)) {
54 continue;
55 }
56 seen.add(value);
57 result.push(value);
58 }
59 return result;
60 }
61 /**
62 * Renders a Draft.js content_state into readable markdown/text format.
63 * Handles blocks (paragraphs, headers, lists) and entities (code blocks, links, tweets, dividers).
64 */
65 export function renderContentState(contentState) {
66 if (!contentState?.blocks || contentState.blocks.length === 0) {
67 return undefined;
68 }
69 // Build entity lookup map from array/object formats
70 const entityMap = new Map();
71 const rawEntityMap = contentState.entityMap ?? [];
72 if (Array.isArray(rawEntityMap)) {
73 for (const entry of rawEntityMap) {
74 const key = Number.parseInt(entry.key, 10);
75 if (!Number.isNaN(key)) {
76 entityMap.set(key, entry.value);
77 }
78 }
79 }
80 else {
81 for (const [key, value] of Object.entries(rawEntityMap)) {
82 const keyNumber = Number.parseInt(key, 10);
83 if (!Number.isNaN(keyNumber)) {
84 entityMap.set(keyNumber, value);
85 }
86 }
87 }
88 const outputLines = [];
89 let orderedListCounter = 0;
90 let previousBlockType;
91 for (const block of contentState.blocks) {
92 // Reset ordered list counter when leaving ordered list context
93 if (block.type !== 'ordered-list-item' && previousBlockType === 'ordered-list-item') {
94 orderedListCounter = 0;
95 }
96 switch (block.type) {
97 case 'unstyled': {
98 // Plain paragraph - just output text with any inline formatting
99 const text = renderBlockText(block, entityMap);
100 if (text) {
101 outputLines.push(text);
102 }
103 break;
104 }
105 case 'header-one': {
106 const text = renderBlockText(block, entityMap);
107 if (text) {
108 outputLines.push(`# ${text}`);
109 }
110 break;
111 }
112 case 'header-two': {
113 const text = renderBlockText(block, entityMap);
114 if (text) {
115 outputLines.push(`## ${text}`);
116 }
117 break;
118 }
119 case 'header-three': {
120 const text = renderBlockText(block, entityMap);
121 if (text) {
122 outputLines.push(`### ${text}`);
123 }
124 break;
125 }
126 case 'unordered-list-item': {
127 const text = renderBlockText(block, entityMap);
128 if (text) {
129 outputLines.push(`- ${text}`);
130 }
131 break;
132 }
133 case 'ordered-list-item': {
134 orderedListCounter++;
135 const text = renderBlockText(block, entityMap);
136 if (text) {
137 outputLines.push(`${orderedListCounter}. ${text}`);
138 }
139 break;
140 }
141 case 'blockquote': {
142 const text = renderBlockText(block, entityMap);
143 if (text) {
144 outputLines.push(`> ${text}`);
145 }
146 break;
147 }
148 case 'atomic': {
149 // Atomic blocks are placeholders for embedded entities
150 const entityContent = renderAtomicBlock(block, entityMap);
151 if (entityContent) {
152 outputLines.push(entityContent);
153 }
154 break;
155 }
156 default: {
157 // Fallback: just output the text
158 const text = renderBlockText(block, entityMap);
159 if (text) {
160 outputLines.push(text);
161 }
162 }
163 }
164 previousBlockType = block.type;
165 }
166 const result = outputLines.join('\n\n');
167 return result.trim() || undefined;
168 }
169 /**
170 * Renders text content of a block, applying inline link entities.
171 */
172 function renderBlockText(block, entityMap) {
173 let text = block.text;
174 // Handle LINK entities by appending URL in markdown format
175 // Process in reverse order to not mess up offsets
176 const linkRanges = (block.entityRanges ?? [])
177 .filter((range) => {
178 const entity = entityMap.get(range.key);
179 return entity?.type === 'LINK' && entity.data.url;
180 })
181 .sort((a, b) => b.offset - a.offset);
182 for (const range of linkRanges) {
183 const entity = entityMap.get(range.key);
184 if (entity?.data.url) {
185 const linkText = text.slice(range.offset, range.offset + range.length);
186 const markdownLink = `[${linkText}](${entity.data.url})`;
187 text = text.slice(0, range.offset) + markdownLink + text.slice(range.offset + range.length);
188 }
189 }
190 return text.trim();
191 }
192 /**
193 * Renders an atomic block by looking up its entity and returning appropriate content.
194 */
195 function renderAtomicBlock(block, entityMap) {
196 const entityRanges = block.entityRanges ?? [];
197 if (entityRanges.length === 0) {
198 return undefined;
199 }
200 const entityKey = entityRanges[0].key;
201 const entity = entityMap.get(entityKey);
202 if (!entity) {
203 return undefined;
204 }
205 switch (entity.type) {
206 case 'MARKDOWN':
207 // Code blocks and other markdown content - output as-is
208 return entity.data.markdown?.trim();
209 case 'DIVIDER':
210 return '---';
211 case 'TWEET':
212 if (entity.data.tweetId) {
213 return `[Embedded Tweet: https://x.com/i/status/${entity.data.tweetId}]`;
214 }
215 return undefined;
216 case 'LINK':
217 if (entity.data.url) {
218 return `[Link: ${entity.data.url}]`;
219 }
220 return undefined;
221 case 'IMAGE':
222 // Images in atomic blocks - could extract URL if available
223 return '[Image]';
224 default:
225 return undefined;
226 }
227 }
228 export function extractArticleText(result) {
229 const article = result?.article;
230 if (!article) {
231 return undefined;
232 }
233 const articleResult = article.article_results?.result ?? article;
234 if (process.env.BIRD_DEBUG_ARTICLE === '1') {
235 console.error('[bird][debug][article] payload:', JSON.stringify({
236 rest_id: result?.rest_id,
237 article: articleResult,
238 note_tweet: result?.note_tweet?.note_tweet_results?.result ?? null,
239 }, null, 2));
240 }
241 const title = firstText(articleResult.title, article.title);
242 // Try to render from rich content_state first (Draft.js format with blocks + entityMap)
243 // This preserves code blocks, embedded tweets, markdown, etc.
244 const contentState = article.article_results?.result?.content_state;
245 const richBody = renderContentState(contentState);
246 if (richBody) {
247 // Rich content found - prepend title if not already included
248 if (title) {
249 const normalizedTitle = title.trim();
250 const trimmedBody = richBody.trimStart();
251 const headingMatches = [`# ${normalizedTitle}`, `## ${normalizedTitle}`, `### ${normalizedTitle}`];
252 const hasTitle = trimmedBody === normalizedTitle ||
253 trimmedBody.startsWith(`${normalizedTitle}\n`) ||
254 headingMatches.some((heading) => trimmedBody.startsWith(heading));
255 if (!hasTitle) {
256 return `${title}\n\n${richBody}`;
257 }
258 }
259 return richBody;
260 }
261 // Fallback to plain text extraction for articles without rich content_state
262 let body = firstText(articleResult.plain_text, article.plain_text, articleResult.body?.text, articleResult.body?.richtext?.text, articleResult.body?.rich_text?.text, articleResult.content?.text, articleResult.content?.richtext?.text, articleResult.content?.rich_text?.text, articleResult.text, articleResult.richtext?.text, articleResult.rich_text?.text, article.body?.text, article.body?.richtext?.text, article.body?.rich_text?.text, article.content?.text, article.content?.richtext?.text, article.content?.rich_text?.text, article.text, article.richtext?.text, article.rich_text?.text);
263 if (body && title && body.trim() === title.trim()) {
264 body = undefined;
265 }
266 if (!body) {
267 const collected = [];
268 collectTextFields(articleResult, new Set(['text', 'title']), collected);
269 collectTextFields(article, new Set(['text', 'title']), collected);
270 const unique = uniqueOrdered(collected);
271 const filtered = title ? unique.filter((value) => value !== title) : unique;
272 if (filtered.length > 0) {
273 body = filtered.join('\n\n');
274 }
275 }
276 if (title && body && !body.startsWith(title)) {
277 return `${title}\n\n${body}`;
278 }
279 return body ?? title;
280 }
281 export function extractNoteTweetText(result) {
282 const note = result?.note_tweet?.note_tweet_results?.result;
283 if (!note) {
284 return undefined;
285 }
286 return firstText(note.text, note.richtext?.text, note.rich_text?.text, note.content?.text, note.content?.richtext?.text, note.content?.rich_text?.text);
287 }
288 export function extractTweetText(result) {
289 return extractArticleText(result) ?? extractNoteTweetText(result) ?? firstText(result?.legacy?.full_text);
290 }
291 export function extractArticleMetadata(result) {
292 const article = result?.article;
293 if (!article) {
294 return undefined;
295 }
296 const articleResult = article.article_results?.result ?? article;
297 const title = firstText(articleResult.title, article.title);
298 if (!title) {
299 return undefined;
300 }
301 // preview_text is available in home timeline responses
302 const previewText = firstText(articleResult.preview_text, article.preview_text);
303 return { title, previewText };
304 }
305 export function extractMedia(result) {
306 // Prefer extended_entities (has video info), fall back to entities
307 const rawMedia = result?.legacy?.extended_entities?.media ?? result?.legacy?.entities?.media;
308 if (!rawMedia || rawMedia.length === 0) {
309 return undefined;
310 }
311 const media = [];
312 for (const item of rawMedia) {
313 if (!item.type || !item.media_url_https) {
314 continue;
315 }
316 const mediaItem = {
317 type: item.type,
318 url: item.media_url_https,
319 };
320 // Get dimensions from largest available size
321 const sizes = item.sizes;
322 if (sizes?.large) {
323 mediaItem.width = sizes.large.w;
324 mediaItem.height = sizes.large.h;
325 }
326 else if (sizes?.medium) {
327 mediaItem.width = sizes.medium.w;
328 mediaItem.height = sizes.medium.h;
329 }
330 // For thumbnails/previews
331 if (sizes?.small) {
332 mediaItem.previewUrl = `${item.media_url_https}:small`;
333 }
334 // Extract video URL for video/animated_gif
335 if ((item.type === 'video' || item.type === 'animated_gif') && item.video_info?.variants) {
336 // Prefer highest bitrate MP4, fall back to first MP4 when bitrate is missing.
337 const mp4Variants = item.video_info.variants.filter((v) => v.content_type === 'video/mp4' && typeof v.url === 'string');
338 const mp4WithBitrate = mp4Variants
339 .filter((v) => typeof v.bitrate === 'number')
340 .sort((a, b) => b.bitrate - a.bitrate);
341 const selectedVariant = mp4WithBitrate[0] ?? mp4Variants[0];
342 if (selectedVariant) {
343 mediaItem.videoUrl = selectedVariant.url;
344 }
345 if (typeof item.video_info.duration_millis === 'number') {
346 mediaItem.durationMs = item.video_info.duration_millis;
347 }
348 }
349 media.push(mediaItem);
350 }
351 return media.length > 0 ? media : undefined;
352 }
353 export function unwrapTweetResult(result) {
354 if (!result) {
355 return undefined;
356 }
357 if (result.tweet) {
358 return result.tweet;
359 }
360 return result;
361 }
362 export function mapTweetResult(result, quoteDepthOrOptions) {
363 const options = typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
364 const { quoteDepth, includeRaw = false } = options;
365 const userResult = result?.core?.user_results?.result;
366 const userLegacy = userResult?.legacy;
367 const userCore = userResult?.core;
368 const username = userLegacy?.screen_name ?? userCore?.screen_name;
369 const name = userLegacy?.name ?? userCore?.name ?? username;
370 const userId = userResult?.rest_id;
371 if (!result?.rest_id || !username) {
372 return undefined;
373 }
374 const text = extractTweetText(result);
375 if (!text) {
376 return undefined;
377 }
378 let quotedTweet;
379 if (quoteDepth > 0) {
380 const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
381 if (quotedResult) {
382 quotedTweet = mapTweetResult(quotedResult, { quoteDepth: quoteDepth - 1, includeRaw });
383 }
384 }
385 const media = extractMedia(result);
386 const article = extractArticleMetadata(result);
387 const tweetData = {
388 id: result.rest_id,
389 text,
390 createdAt: result.legacy?.created_at,
391 replyCount: result.legacy?.reply_count,
392 retweetCount: result.legacy?.retweet_count,
393 likeCount: result.legacy?.favorite_count,
394 conversationId: result.legacy?.conversation_id_str,
395 inReplyToStatusId: result.legacy?.in_reply_to_status_id_str ?? undefined,
396 author: {
397 username,
398 name: name || username,
399 },
400 authorId: userId,
401 quotedTweet,
402 media,
403 article,
404 };
405 if (includeRaw) {
406 tweetData._raw = result;
407 }
408 return tweetData;
409 }
410 export function findTweetInInstructions(instructions, tweetId) {
411 if (!instructions) {
412 return undefined;
413 }
414 for (const instruction of instructions) {
415 for (const entry of instruction.entries || []) {
416 const result = entry.content?.itemContent?.tweet_results?.result;
417 if (result?.rest_id === tweetId) {
418 return result;
419 }
420 }
421 }
422 return undefined;
423 }
424 export function collectTweetResultsFromEntry(entry) {
425 const results = [];
426 const pushResult = (result) => {
427 if (result?.rest_id) {
428 results.push(result);
429 }
430 };
431 const content = entry.content;
432 pushResult(content?.itemContent?.tweet_results?.result);
433 pushResult(content?.item?.itemContent?.tweet_results?.result);
434 for (const item of content?.items ?? []) {
435 pushResult(item?.item?.itemContent?.tweet_results?.result);
436 pushResult(item?.itemContent?.tweet_results?.result);
437 pushResult(item?.content?.itemContent?.tweet_results?.result);
438 }
439 return results;
440 }
441 export function parseTweetsFromInstructions(instructions, quoteDepthOrOptions) {
442 const options = typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
443 const { quoteDepth, includeRaw = false } = options;
444 const tweets = [];
445 const seen = new Set();
446 for (const instruction of instructions ?? []) {
447 for (const entry of instruction.entries ?? []) {
448 const results = collectTweetResultsFromEntry(entry);
449 for (const result of results) {
450 const mapped = mapTweetResult(result, { quoteDepth, includeRaw });
451 if (!mapped || seen.has(mapped.id)) {
452 continue;
453 }
454 seen.add(mapped.id);
455 tweets.push(mapped);
456 }
457 }
458 }
459 return tweets;
460 }
461 export function extractCursorFromInstructions(instructions, cursorType = 'Bottom') {
462 for (const instruction of instructions ?? []) {
463 for (const entry of instruction.entries ?? []) {
464 const content = entry.content;
465 if (content?.cursorType === cursorType && typeof content.value === 'string' && content.value.length > 0) {
466 return content.value;
467 }
468 }
469 }
470 return undefined;
471 }
472 export function parseUsersFromInstructions(instructions) {
473 if (!instructions) {
474 return [];
475 }
476 const users = [];
477 for (const instruction of instructions) {
478 if (!instruction.entries) {
479 continue;
480 }
481 for (const entry of instruction.entries) {
482 const content = entry?.content;
483 const rawUserResult = content?.itemContent?.user_results?.result;
484 const userResult = rawUserResult?.__typename === 'UserWithVisibilityResults' && rawUserResult.user
485 ? rawUserResult.user
486 : rawUserResult;
487 if (!userResult || userResult.__typename !== 'User') {
488 continue;
489 }
490 const legacy = userResult.legacy;
491 const core = userResult.core;
492 const username = legacy?.screen_name ?? core?.screen_name;
493 if (!userResult.rest_id || !username) {
494 continue;
495 }
496 users.push({
497 id: userResult.rest_id,
498 username,
499 name: legacy?.name ?? core?.name ?? username,
500 description: legacy?.description,
501 followersCount: legacy?.followers_count,
502 followingCount: legacy?.friends_count,
503 isBlueVerified: userResult.is_blue_verified,
504 profileImageUrl: legacy?.profile_image_url_https ?? userResult.avatar?.image_url,
505 createdAt: legacy?.created_at ?? core?.created_at,
506 });
507 }
508 }
509 return users;
510 }
511 //# sourceMappingURL=twitter-client-utils.js.map
511 lines JAVASCRIPT