fastbrowser_cli 1.0.37 → 1.0.39

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 (98) hide show
  1. package/dist/contribs/_shared/fastbrowser_helper.d.ts +13 -0
  2. package/dist/contribs/_shared/fastbrowser_helper.d.ts.map +1 -0
  3. package/dist/contribs/_shared/fastbrowser_helper.js +39 -0
  4. package/dist/contribs/_shared/fastbrowser_helper.js.map +1 -0
  5. package/dist/contribs/linkedin_cli/src/cli.d.ts +3 -0
  6. package/dist/contribs/linkedin_cli/src/cli.d.ts.map +1 -0
  7. package/dist/contribs/linkedin_cli/src/cli.js +299 -0
  8. package/dist/contribs/linkedin_cli/src/cli.js.map +1 -0
  9. package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.d.ts +73 -0
  10. package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.d.ts.map +1 -0
  11. package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.js +866 -0
  12. package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.js.map +1 -0
  13. package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.d.ts +61 -0
  14. package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.d.ts.map +1 -0
  15. package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.js +885 -0
  16. package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.js.map +1 -0
  17. package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.d.ts +11 -0
  18. package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.d.ts.map +1 -0
  19. package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.js +145 -0
  20. package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.js.map +1 -0
  21. package/dist/contribs/twitter_cli/src/cli.d.ts +3 -0
  22. package/dist/contribs/twitter_cli/src/cli.d.ts.map +1 -0
  23. package/dist/contribs/twitter_cli/src/cli.js +273 -0
  24. package/dist/contribs/twitter_cli/src/cli.js.map +1 -0
  25. package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.d.ts +28 -0
  26. package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.d.ts.map +1 -0
  27. package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.js +274 -0
  28. package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.js.map +1 -0
  29. package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.d.ts +43 -0
  30. package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.d.ts.map +1 -0
  31. package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.js +519 -0
  32. package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.js.map +1 -0
  33. package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.d.ts +11 -0
  34. package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.d.ts.map +1 -0
  35. package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.js +213 -0
  36. package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.js.map +1 -0
  37. package/dist/fastbrowser_cli/fastbrowser_cli.js +43 -0
  38. package/dist/fastbrowser_cli/fastbrowser_cli.js.map +1 -1
  39. package/dist/fastbrowser_httpd/libs/tool-schemas.d.ts +4 -0
  40. package/dist/fastbrowser_httpd/libs/tool-schemas.d.ts.map +1 -1
  41. package/dist/fastbrowser_httpd/libs/tool-schemas.js +4 -0
  42. package/dist/fastbrowser_httpd/libs/tool-schemas.js.map +1 -1
  43. package/dist/fastbrowser_mcp/fastbrowser_mcp.js +36 -2
  44. package/dist/fastbrowser_mcp/fastbrowser_mcp.js.map +1 -1
  45. package/dist/fastbrowser_mcp/libs/mcp_target_helper.d.ts +2 -0
  46. package/dist/fastbrowser_mcp/libs/mcp_target_helper.d.ts.map +1 -1
  47. package/dist/fastbrowser_mcp/libs/mcp_target_helper.js +12 -0
  48. package/dist/fastbrowser_mcp/libs/mcp_target_helper.js.map +1 -1
  49. package/dist/fastbrowser_mcp/libs/response_formatter.d.ts +1 -0
  50. package/dist/fastbrowser_mcp/libs/response_formatter.d.ts.map +1 -1
  51. package/dist/fastbrowser_mcp/libs/response_formatter.js +27 -0
  52. package/dist/fastbrowser_mcp/libs/response_formatter.js.map +1 -1
  53. package/dist/shared/fastbrowser_helper.d.ts +13 -0
  54. package/dist/shared/fastbrowser_helper.d.ts.map +1 -0
  55. package/dist/shared/fastbrowser_helper.js +39 -0
  56. package/dist/shared/fastbrowser_helper.js.map +1 -0
  57. package/examples/linkedin_cli_TOREMOVE/README.md +7 -0
  58. package/examples/{linkedin_cli → linkedin_cli_TOREMOVE}/linkedin_dm.sh +8 -4
  59. package/examples/linkedin_cli_TOREMOVE/linkedin_dm.ts +326 -0
  60. package/examples/linkedin_cli_TOREMOVE/linkedin_dm_messages.ts +279 -0
  61. package/examples/linkedin_cli_TOREMOVE/linkedin_full_cycle.sh +5 -0
  62. package/examples/{linkedin_cli → linkedin_cli_TOREMOVE}/linkedin_post.sh +3 -0
  63. package/examples/linkedin_cli_TOREMOVE/message_thread.a11y.txt +252 -0
  64. package/listitem +4 -0
  65. package/package.json +7 -3
  66. package/skills/fastbrowser/SKILL.md +33 -25
  67. package/src/contribs/_shared/fastbrowser_helper.ts +49 -0
  68. package/src/contribs/linkedin_cli/README.md +80 -0
  69. package/src/contribs/linkedin_cli/data/linkedin_posts_jeromeetienne.a11y.txt +2364 -0
  70. package/src/contribs/linkedin_cli/data/linkedin_posts_jontwigge.a11y.txt +2740 -0
  71. package/src/contribs/linkedin_cli/data/linkedin_posts_julien_guezennec.a11y.txt +2073 -0
  72. package/src/contribs/linkedin_cli/data/linkedin_profile_jeromeetienne.a11y.txt +1863 -0
  73. package/src/contribs/linkedin_cli/data/linkedin_profile_jontwigge.a11y.txt +1738 -0
  74. package/src/contribs/linkedin_cli/data/linkedin_profile_julien_guezennec.a11y.txt +2182 -0
  75. package/src/contribs/linkedin_cli/src/cli.ts +345 -0
  76. package/src/contribs/linkedin_cli/src/libs/linkedin_profile_helper.ts +964 -0
  77. package/src/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.ts +982 -0
  78. package/src/contribs/linkedin_cli/src/libs/linkedin_thread_helper.ts +171 -0
  79. package/src/contribs/twitter_cli/README.md +79 -0
  80. package/src/contribs/twitter_cli/data/twitter_chat.a11y.txt +215 -0
  81. package/src/contribs/twitter_cli/data/twitter_home.a11y.txt +467 -0
  82. package/src/contribs/twitter_cli/data/twitter_profile.a11y.txt +418 -0
  83. package/src/contribs/twitter_cli/data/twitter_profile_jontwigge.a11y.txt +484 -0
  84. package/src/contribs/twitter_cli/data/twitter_profile_molokoloco.a11y.txt +483 -0
  85. package/src/contribs/twitter_cli/src/cli.ts +315 -0
  86. package/src/contribs/twitter_cli/src/libs/twitter_profile_helper.ts +328 -0
  87. package/src/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.ts +607 -0
  88. package/src/contribs/twitter_cli/src/libs/twitter_thread_helper.ts +240 -0
  89. package/src/fastbrowser_cli/fastbrowser_cli.ts +51 -0
  90. package/src/fastbrowser_httpd/libs/tool-schemas.ts +6 -0
  91. package/src/fastbrowser_mcp/fastbrowser_mcp.ts +46 -3
  92. package/src/fastbrowser_mcp/libs/mcp_target_helper.ts +11 -0
  93. package/src/fastbrowser_mcp/libs/response_formatter.ts +29 -0
  94. package/src/shared/fastbrowser_helper.ts +49 -0
  95. package/tsconfig.json +1 -1
  96. package/examples/mcp_client_playwright.ts +0 -34
  97. /package/examples/{linkedin_cli → linkedin_cli_TOREMOVE}/linkedin.snapshot.txt +0 -0
  98. /package/examples/{twitter_cli → twitter_cli_TOREMOVE}/twitter_post.sh +0 -0
@@ -0,0 +1,607 @@
1
+ // npm imports
2
+ import { A11yQuery, A11yTree, AxNode } from 'a11y_parse';
3
+
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ //
7
+ ///////////////////////////////////////////////////////////////////////////////
8
+ ///////////////////////////////////////////////////////////////////////////////
9
+
10
+ export type TwitterPostMedia = 'image' | 'video';
11
+
12
+ export type TwitterPost = {
13
+ url: string | null;
14
+ statusId: string | null;
15
+ authorHandle: string | null;
16
+ authorDisplayName: string | null;
17
+ timestamp: string | null;
18
+ text: string;
19
+ isPinned: boolean;
20
+ isRepost: boolean;
21
+ repostedBy: string | null;
22
+ replyCount: number | null;
23
+ repostCount: number | null;
24
+ likeCount: number | null;
25
+ bookmarkCount: number | null;
26
+ viewCount: number | null;
27
+ hasMedia: boolean;
28
+ mediaTypes: TwitterPostMedia[];
29
+ translatedFrom: string | null;
30
+ };
31
+
32
+ const NOISE_LITERALS = new Set<string>([
33
+ '·',
34
+ 'Pinned',
35
+ 'Show more',
36
+ 'Show original',
37
+ 'Made with AI',
38
+ 'Verified account',
39
+ 'Embedded video',
40
+ 'Image',
41
+ 'Play Video',
42
+ 'reposted',
43
+ 'Quote',
44
+ 'Translation',
45
+ ]);
46
+
47
+ const TIME_LINK_SELECTOR = 'link[url*="/status/"]:has(> time[value])';
48
+
49
+ export class TwitterRecentPostsHelper {
50
+
51
+ ///////////////////////////////////////////////////////////////////////////////
52
+ ///////////////////////////////////////////////////////////////////////////////
53
+ //
54
+ ///////////////////////////////////////////////////////////////////////////////
55
+ ///////////////////////////////////////////////////////////////////////////////
56
+
57
+ static parsePosts(rawSnapshot: string, handle: string): TwitterPost[] {
58
+ const treeText = TwitterRecentPostsHelper.extractAxTreeText(rawSnapshot);
59
+ if (treeText.length === 0) {
60
+ return [];
61
+ }
62
+ const root = A11yTree.parse(treeText);
63
+ const timeLinks = A11yQuery.querySelectorAll(root, TIME_LINK_SELECTOR);
64
+ const seen = new Set<string>();
65
+ const posts: TwitterPost[] = [];
66
+ for (const timeLink of timeLinks) {
67
+ const url = timeLink.attributes['url'];
68
+ if (url === undefined) {
69
+ continue;
70
+ }
71
+ if (seen.has(url) === true) {
72
+ continue;
73
+ }
74
+ const container = TwitterRecentPostsHelper.walkUpToPostContainer(timeLink);
75
+ if (container === undefined) {
76
+ continue;
77
+ }
78
+ seen.add(url);
79
+ posts.push(TwitterRecentPostsHelper.extractPost(container, timeLink, handle));
80
+ }
81
+ return posts;
82
+ }
83
+
84
+ ///////////////////////////////////////////////////////////////////////////////
85
+ ///////////////////////////////////////////////////////////////////////////////
86
+ //
87
+ ///////////////////////////////////////////////////////////////////////////////
88
+ ///////////////////////////////////////////////////////////////////////////////
89
+
90
+ static formatMarkdown(posts: TwitterPost[]): string {
91
+ if (posts.length === 0) {
92
+ return '_no posts found_';
93
+ }
94
+ const sections: string[] = [];
95
+ for (const post of posts) {
96
+ sections.push(TwitterRecentPostsHelper.formatPostMarkdown(post));
97
+ }
98
+ return sections.join('\n\n---\n\n');
99
+ }
100
+
101
+ ///////////////////////////////////////////////////////////////////////////////
102
+ ///////////////////////////////////////////////////////////////////////////////
103
+ // Tree filtering / post container walk
104
+ ///////////////////////////////////////////////////////////////////////////////
105
+ ///////////////////////////////////////////////////////////////////////////////
106
+
107
+ private static extractAxTreeText(rawOutput: string): string {
108
+ const lines: string[] = [];
109
+ for (const line of rawOutput.split('\n')) {
110
+ if (/^\s*uid=/.test(line) === true) {
111
+ lines.push(line);
112
+ }
113
+ }
114
+ return lines.join('\n');
115
+ }
116
+
117
+ private static walkUpToPostContainer(timeLink: AxNode): AxNode | undefined {
118
+ let cursor: AxNode | undefined = timeLink.parent;
119
+ let lastValid: AxNode | undefined = undefined;
120
+ let foundGroup = false;
121
+ for (let depth = 0; depth < 16; depth++) {
122
+ if (cursor === undefined) {
123
+ break;
124
+ }
125
+ const timeLinks = A11yQuery.querySelectorAll(cursor, TIME_LINK_SELECTOR);
126
+ if (timeLinks.length !== 1) {
127
+ break;
128
+ }
129
+ if (foundGroup === false) {
130
+ const groups = A11yQuery.querySelectorAll(cursor, 'group[name]');
131
+ if (groups.length >= 1) {
132
+ foundGroup = true;
133
+ }
134
+ }
135
+ if (foundGroup === true) {
136
+ lastValid = cursor;
137
+ }
138
+ cursor = cursor.parent;
139
+ }
140
+ return lastValid;
141
+ }
142
+
143
+ ///////////////////////////////////////////////////////////////////////////////
144
+ ///////////////////////////////////////////////////////////////////////////////
145
+ // Per-post extraction
146
+ ///////////////////////////////////////////////////////////////////////////////
147
+ ///////////////////////////////////////////////////////////////////////////////
148
+
149
+ private static extractPost(container: AxNode, timeLink: AxNode, viewingHandle: string): TwitterPost {
150
+ const url = timeLink.attributes['url'];
151
+ const parsedUrl = TwitterRecentPostsHelper.parseStatusUrl(url);
152
+ const timestamp = TwitterRecentPostsHelper.extractTimestamp(timeLink);
153
+ const isRepost = A11yQuery.querySelector(container, 'link[name$=" reposted"]') !== undefined;
154
+ const isPinned = A11yQuery.querySelector(container, 'generic[value="Pinned"]') !== undefined;
155
+ const authorHandle = parsedUrl !== null ? parsedUrl.authorHandle : null;
156
+ const authorDisplayName = TwitterRecentPostsHelper.extractAuthorDisplayName(container, authorHandle);
157
+ const translatedFrom = TwitterRecentPostsHelper.extractTranslatedFrom(container);
158
+ const counts = TwitterRecentPostsHelper.extractCounts(container);
159
+ const media = TwitterRecentPostsHelper.extractMedia(container);
160
+ const text = TwitterRecentPostsHelper.extractBodyText(container, {
161
+ authorHandle,
162
+ authorDisplayName,
163
+ timestamp,
164
+ });
165
+ return {
166
+ url: parsedUrl !== null ? parsedUrl.absoluteUrl : null,
167
+ statusId: parsedUrl !== null ? parsedUrl.statusId : null,
168
+ authorHandle,
169
+ authorDisplayName,
170
+ timestamp,
171
+ text,
172
+ isPinned,
173
+ isRepost,
174
+ repostedBy: isRepost === true ? viewingHandle : null,
175
+ replyCount: counts.replyCount,
176
+ repostCount: counts.repostCount,
177
+ likeCount: counts.likeCount,
178
+ bookmarkCount: counts.bookmarkCount,
179
+ viewCount: counts.viewCount,
180
+ hasMedia: media.length > 0,
181
+ mediaTypes: media,
182
+ translatedFrom,
183
+ };
184
+ }
185
+
186
+ private static parseStatusUrl(url: string | undefined): {
187
+ absoluteUrl: string;
188
+ authorHandle: string;
189
+ statusId: string;
190
+ } | null {
191
+ if (url === undefined) {
192
+ return null;
193
+ }
194
+ const match = url.match(/^\/([^/]+)\/status\/(\d+)/);
195
+ if (match === null) {
196
+ return null;
197
+ }
198
+ const authorHandle = match[1];
199
+ const statusId = match[2];
200
+ return {
201
+ absoluteUrl: `https://x.com/${authorHandle}/status/${statusId}`,
202
+ authorHandle,
203
+ statusId,
204
+ };
205
+ }
206
+
207
+ private static extractTimestamp(timeLink: AxNode): string | null {
208
+ for (const child of timeLink.children) {
209
+ if (child.role !== 'time') {
210
+ continue;
211
+ }
212
+ const value = child.attributes['value'];
213
+ if (value === undefined) {
214
+ continue;
215
+ }
216
+ const trimmed = value.trim();
217
+ if (trimmed.length === 0) {
218
+ continue;
219
+ }
220
+ return trimmed;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ private static extractAuthorDisplayName(container: AxNode, authorHandle: string | null): string | null {
226
+ if (authorHandle === null) {
227
+ return null;
228
+ }
229
+ const escaped = authorHandle.replace(/"/g, '\\"');
230
+ const handleNode = A11yQuery.querySelector(container, `generic[value="@${escaped}"]`);
231
+ if (handleNode === undefined) {
232
+ return null;
233
+ }
234
+ const handleValue = `@${authorHandle}`;
235
+ let cursor: AxNode | undefined = handleNode;
236
+ for (let depth = 0; depth < 6; depth++) {
237
+ if (cursor === undefined) {
238
+ break;
239
+ }
240
+ const prev = A11yTree.previousSibling(cursor);
241
+ if (prev !== undefined) {
242
+ const found = TwitterRecentPostsHelper.findFirstValue(prev, handleValue);
243
+ if (found !== null) {
244
+ return found;
245
+ }
246
+ }
247
+ cursor = cursor.parent;
248
+ }
249
+ return null;
250
+ }
251
+
252
+ private static findFirstValue(node: AxNode, exclude: string): string | null {
253
+ for (const descendant of A11yTree.walk(node)) {
254
+ const value = descendant.attributes['value'];
255
+ if (value !== undefined) {
256
+ const trimmed = value.trim();
257
+ if (trimmed.length > 0 && trimmed !== exclude && trimmed !== 'Verified account') {
258
+ return trimmed;
259
+ }
260
+ }
261
+ if (descendant.role === 'StaticText' && descendant.name !== undefined) {
262
+ const trimmed = descendant.name.trim();
263
+ if (trimmed.length > 0 && trimmed !== exclude && trimmed !== 'Verified account') {
264
+ return trimmed;
265
+ }
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+
271
+ private static extractTranslatedFrom(container: AxNode): string | null {
272
+ const node = A11yQuery.querySelector(container, 'generic[value^="Translated from "]');
273
+ if (node === undefined) {
274
+ return null;
275
+ }
276
+ const value = node.attributes['value'];
277
+ if (value === undefined) {
278
+ return null;
279
+ }
280
+ return value.slice('Translated from '.length).trim();
281
+ }
282
+
283
+ private static extractMedia(container: AxNode): TwitterPostMedia[] {
284
+ const media: TwitterPostMedia[] = [];
285
+ const hasImage = A11yQuery.querySelector(container, 'link[url*="/photo/"]') !== undefined
286
+ || A11yQuery.querySelector(container, 'generic[name="Image"]') !== undefined;
287
+ if (hasImage === true) {
288
+ media.push('image');
289
+ }
290
+ const hasVideo = A11yQuery.querySelector(container, 'generic[name="Embedded video"]') !== undefined
291
+ || A11yQuery.querySelector(container, 'button[name="Play Video"]') !== undefined;
292
+ if (hasVideo === true) {
293
+ media.push('video');
294
+ }
295
+ return media;
296
+ }
297
+
298
+ ///////////////////////////////////////////////////////////////////////////////
299
+ ///////////////////////////////////////////////////////////////////////////////
300
+ // Counts
301
+ ///////////////////////////////////////////////////////////////////////////////
302
+ ///////////////////////////////////////////////////////////////////////////////
303
+
304
+ private static extractCounts(container: AxNode): {
305
+ replyCount: number | null;
306
+ repostCount: number | null;
307
+ likeCount: number | null;
308
+ bookmarkCount: number | null;
309
+ viewCount: number | null;
310
+ } {
311
+ const counts: {
312
+ replyCount: number | null;
313
+ repostCount: number | null;
314
+ likeCount: number | null;
315
+ bookmarkCount: number | null;
316
+ viewCount: number | null;
317
+ } = {
318
+ replyCount: null,
319
+ repostCount: null,
320
+ likeCount: null,
321
+ bookmarkCount: null,
322
+ viewCount: null,
323
+ };
324
+ const group = A11yQuery.querySelector(container, 'group[name]');
325
+ if (group !== undefined && group.name !== undefined) {
326
+ const summary = group.name;
327
+ counts.replyCount = TwitterRecentPostsHelper.matchCount(summary, /(\d+(?:[.,]\d+)?)\s+repl(?:y|ies)/i);
328
+ counts.repostCount = TwitterRecentPostsHelper.matchCount(summary, /(\d+(?:[.,]\d+)?)\s+repost/i);
329
+ counts.likeCount = TwitterRecentPostsHelper.matchCount(summary, /(\d+(?:[.,]\d+)?)\s+like/i);
330
+ counts.bookmarkCount = TwitterRecentPostsHelper.matchCount(summary, /(\d+(?:[.,]\d+)?)\s+bookmark/i);
331
+ counts.viewCount = TwitterRecentPostsHelper.matchCount(summary, /(\d+(?:[.,]\d+)?)\s+view/i);
332
+ }
333
+ if (counts.replyCount === null) {
334
+ counts.replyCount = TwitterRecentPostsHelper.fallbackCount(
335
+ container,
336
+ 'button:has(> generic[value]):is([name$="Replies. Reply"], [name$="Reply. Reply"])',
337
+ );
338
+ }
339
+ if (counts.repostCount === null) {
340
+ counts.repostCount = TwitterRecentPostsHelper.fallbackCount(
341
+ container,
342
+ 'button:has(> generic[value]):is([name$=" reposts. Repost"], [name$=" reposts. Reposted"], [name$=" repost. Repost"], [name$=" repost. Reposted"])',
343
+ );
344
+ }
345
+ if (counts.likeCount === null) {
346
+ counts.likeCount = TwitterRecentPostsHelper.fallbackCount(
347
+ container,
348
+ 'button:has(> generic[value]):is([name$=" Likes. Like"], [name$=" Likes. Liked"], [name$=" Like. Like"], [name$=" Like. Liked"])',
349
+ );
350
+ }
351
+ if (counts.viewCount === null) {
352
+ counts.viewCount = TwitterRecentPostsHelper.fallbackCount(
353
+ container,
354
+ 'link:has(> generic[value])[name$="View post analytics"]',
355
+ );
356
+ }
357
+ return counts;
358
+ }
359
+
360
+ private static matchCount(summary: string, regex: RegExp): number | null {
361
+ const match = summary.match(regex);
362
+ if (match === null) {
363
+ return null;
364
+ }
365
+ const cleaned = match[1].replace(/[.,]/g, '');
366
+ const parsed = parseInt(cleaned, 10);
367
+ if (Number.isNaN(parsed) === true) {
368
+ return null;
369
+ }
370
+ return parsed;
371
+ }
372
+
373
+ private static fallbackCount(container: AxNode, selector: string): number | null {
374
+ const button = A11yQuery.querySelector(container, selector);
375
+ if (button === undefined) {
376
+ return null;
377
+ }
378
+ const valueNode = A11yQuery.querySelector(button, 'generic[value]');
379
+ if (valueNode === undefined) {
380
+ return null;
381
+ }
382
+ const raw = valueNode.attributes['value'];
383
+ if (raw === undefined) {
384
+ return null;
385
+ }
386
+ return TwitterRecentPostsHelper.parseShortNumber(raw);
387
+ }
388
+
389
+ private static parseShortNumber(s: string): number | null {
390
+ const trimmed = s.trim();
391
+ if (trimmed.length === 0) {
392
+ return null;
393
+ }
394
+ const match = trimmed.match(/^(\d+(?:[.,]\d+)?)([KMB])?$/i);
395
+ if (match === null) {
396
+ return null;
397
+ }
398
+ const base = parseFloat(match[1].replace(',', '.'));
399
+ if (Number.isNaN(base) === true) {
400
+ return null;
401
+ }
402
+ if (match[2] === undefined) {
403
+ return Math.round(base);
404
+ }
405
+ const suffix = match[2].toUpperCase();
406
+ if (suffix === 'K') {
407
+ return Math.round(base * 1_000);
408
+ }
409
+ if (suffix === 'M') {
410
+ return Math.round(base * 1_000_000);
411
+ }
412
+ if (suffix === 'B') {
413
+ return Math.round(base * 1_000_000_000);
414
+ }
415
+ return null;
416
+ }
417
+
418
+ ///////////////////////////////////////////////////////////////////////////////
419
+ ///////////////////////////////////////////////////////////////////////////////
420
+ // Body text extraction
421
+ ///////////////////////////////////////////////////////////////////////////////
422
+ ///////////////////////////////////////////////////////////////////////////////
423
+
424
+ private static extractBodyText(container: AxNode, header: {
425
+ authorHandle: string | null;
426
+ authorDisplayName: string | null;
427
+ timestamp: string | null;
428
+ }): string {
429
+ const skipUids = TwitterRecentPostsHelper.collectSkipUids(container);
430
+ const noise = new Set<string>(NOISE_LITERALS);
431
+ if (header.authorHandle !== null) {
432
+ noise.add(`@${header.authorHandle}`);
433
+ }
434
+ if (header.authorDisplayName !== null) {
435
+ noise.add(header.authorDisplayName);
436
+ }
437
+ if (header.timestamp !== null) {
438
+ noise.add(header.timestamp);
439
+ }
440
+ const fragments: string[] = [];
441
+ for (const node of A11yTree.walk(container)) {
442
+ if (skipUids.has(node.uid) === true) {
443
+ continue;
444
+ }
445
+ const fragment = TwitterRecentPostsHelper.fragmentForNode(node);
446
+ if (fragment === null) {
447
+ continue;
448
+ }
449
+ if (noise.has(fragment) === true) {
450
+ continue;
451
+ }
452
+ if (fragment.startsWith('Translated from ') === true) {
453
+ continue;
454
+ }
455
+ fragments.push(fragment);
456
+ }
457
+ const deduped: string[] = [];
458
+ for (const fragment of fragments) {
459
+ if (deduped.length > 0 && deduped[deduped.length - 1] === fragment) {
460
+ continue;
461
+ }
462
+ deduped.push(fragment);
463
+ }
464
+ const joined = deduped.join(' ').replace(/\s+/g, ' ').trim();
465
+ return joined;
466
+ }
467
+
468
+ private static collectSkipUids(container: AxNode): Set<string> {
469
+ const skipRoots: AxNode[] = [];
470
+ for (const node of A11yTree.walk(container)) {
471
+ if (node.role === 'button') {
472
+ skipRoots.push(node);
473
+ continue;
474
+ }
475
+ if (node.role === 'group' && node.name !== undefined) {
476
+ skipRoots.push(node);
477
+ continue;
478
+ }
479
+ if (node.role === 'link' && node.name !== undefined && node.name.endsWith(' reposted') === true) {
480
+ skipRoots.push(node);
481
+ continue;
482
+ }
483
+ if (node.role === 'generic' && node.attributes['value'] === 'Quote' && node.parent !== undefined) {
484
+ skipRoots.push(node.parent);
485
+ }
486
+ }
487
+ const skipUids = new Set<string>();
488
+ for (const root of skipRoots) {
489
+ for (const descendant of A11yTree.walk(root)) {
490
+ skipUids.add(descendant.uid);
491
+ }
492
+ }
493
+ return skipUids;
494
+ }
495
+
496
+ private static fragmentForNode(node: AxNode): string | null {
497
+ if (node.role === 'StaticText' && node.name !== undefined) {
498
+ const trimmed = node.name.trim();
499
+ if (trimmed.length === 0) {
500
+ return null;
501
+ }
502
+ return trimmed;
503
+ }
504
+ const value = node.attributes['value'];
505
+ if (value !== undefined) {
506
+ const trimmed = value.trim();
507
+ if (trimmed.length === 0) {
508
+ return null;
509
+ }
510
+ return trimmed;
511
+ }
512
+ if (node.role === 'link' && node.name !== undefined) {
513
+ const trimmed = node.name.trim();
514
+ if (trimmed.length === 0) {
515
+ return null;
516
+ }
517
+ if (TwitterRecentPostsHelper.linkHasTextChild(node) === true) {
518
+ return null;
519
+ }
520
+ const url = node.attributes['url'];
521
+ if (trimmed.startsWith('@') === true || trimmed.startsWith('#') === true) {
522
+ return trimmed;
523
+ }
524
+ if (url !== undefined && (url.startsWith('https://t.co/') === true || url.startsWith('http://') === true || url.startsWith('https://') === true)) {
525
+ return trimmed;
526
+ }
527
+ }
528
+ return null;
529
+ }
530
+
531
+ private static linkHasTextChild(linkNode: AxNode): boolean {
532
+ for (const descendant of A11yTree.walk(linkNode)) {
533
+ if (descendant.uid === linkNode.uid) {
534
+ continue;
535
+ }
536
+ if (descendant.role === 'StaticText' && descendant.name !== undefined && descendant.name.trim().length > 0) {
537
+ return true;
538
+ }
539
+ const value = descendant.attributes['value'];
540
+ if (value !== undefined && value.trim().length > 0) {
541
+ return true;
542
+ }
543
+ }
544
+ return false;
545
+ }
546
+
547
+ ///////////////////////////////////////////////////////////////////////////////
548
+ ///////////////////////////////////////////////////////////////////////////////
549
+ // Markdown rendering
550
+ ///////////////////////////////////////////////////////////////////////////////
551
+ ///////////////////////////////////////////////////////////////////////////////
552
+
553
+ private static formatPostMarkdown(post: TwitterPost): string {
554
+ const headerName = post.authorDisplayName !== null ? post.authorDisplayName : (post.authorHandle !== null ? post.authorHandle : 'unknown');
555
+ const handleSuffix = post.authorHandle !== null ? ` (@${post.authorHandle})` : '';
556
+ const headerParts: string[] = [`${headerName}${handleSuffix}`];
557
+ if (post.timestamp !== null) {
558
+ headerParts.push(post.timestamp);
559
+ }
560
+ if (post.isPinned === true) {
561
+ headerParts.push('pinned');
562
+ }
563
+ if (post.isRepost === true && post.repostedBy !== null) {
564
+ headerParts.push(`reposted by @${post.repostedBy}`);
565
+ }
566
+ if (post.translatedFrom !== null) {
567
+ headerParts.push(`translated from ${post.translatedFrom}`);
568
+ }
569
+ const lines: string[] = [];
570
+ lines.push(`## ${headerParts.join(' · ')}`);
571
+ lines.push('');
572
+ if (post.text.length === 0) {
573
+ lines.push('_(no text)_');
574
+ } else {
575
+ lines.push(post.text);
576
+ }
577
+ if (post.mediaTypes.length > 0) {
578
+ lines.push('');
579
+ lines.push(`media: ${post.mediaTypes.join(', ')}`);
580
+ }
581
+ const countParts: string[] = [];
582
+ if (post.replyCount !== null) {
583
+ countParts.push(`replies: ${post.replyCount}`);
584
+ }
585
+ if (post.repostCount !== null) {
586
+ countParts.push(`reposts: ${post.repostCount}`);
587
+ }
588
+ if (post.likeCount !== null) {
589
+ countParts.push(`likes: ${post.likeCount}`);
590
+ }
591
+ if (post.bookmarkCount !== null) {
592
+ countParts.push(`bookmarks: ${post.bookmarkCount}`);
593
+ }
594
+ if (post.viewCount !== null) {
595
+ countParts.push(`views: ${post.viewCount}`);
596
+ }
597
+ if (countParts.length > 0) {
598
+ lines.push('');
599
+ lines.push(countParts.join(' · '));
600
+ }
601
+ if (post.url !== null) {
602
+ lines.push('');
603
+ lines.push(post.url);
604
+ }
605
+ return lines.join('\n');
606
+ }
607
+ }