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.
- package/dist/contribs/_shared/fastbrowser_helper.d.ts +13 -0
- package/dist/contribs/_shared/fastbrowser_helper.d.ts.map +1 -0
- package/dist/contribs/_shared/fastbrowser_helper.js +39 -0
- package/dist/contribs/_shared/fastbrowser_helper.js.map +1 -0
- package/dist/contribs/linkedin_cli/src/cli.d.ts +3 -0
- package/dist/contribs/linkedin_cli/src/cli.d.ts.map +1 -0
- package/dist/contribs/linkedin_cli/src/cli.js +299 -0
- package/dist/contribs/linkedin_cli/src/cli.js.map +1 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.d.ts +73 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.d.ts.map +1 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.js +866 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_profile_helper.js.map +1 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.d.ts +61 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.d.ts.map +1 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.js +885 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.js.map +1 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.d.ts +11 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.d.ts.map +1 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.js +145 -0
- package/dist/contribs/linkedin_cli/src/libs/linkedin_thread_helper.js.map +1 -0
- package/dist/contribs/twitter_cli/src/cli.d.ts +3 -0
- package/dist/contribs/twitter_cli/src/cli.d.ts.map +1 -0
- package/dist/contribs/twitter_cli/src/cli.js +273 -0
- package/dist/contribs/twitter_cli/src/cli.js.map +1 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.d.ts +28 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.d.ts.map +1 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.js +274 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_profile_helper.js.map +1 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.d.ts +43 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.d.ts.map +1 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.js +519 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.js.map +1 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.d.ts +11 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.d.ts.map +1 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.js +213 -0
- package/dist/contribs/twitter_cli/src/libs/twitter_thread_helper.js.map +1 -0
- package/dist/fastbrowser_cli/fastbrowser_cli.js +43 -0
- package/dist/fastbrowser_cli/fastbrowser_cli.js.map +1 -1
- package/dist/fastbrowser_httpd/libs/tool-schemas.d.ts +4 -0
- package/dist/fastbrowser_httpd/libs/tool-schemas.d.ts.map +1 -1
- package/dist/fastbrowser_httpd/libs/tool-schemas.js +4 -0
- package/dist/fastbrowser_httpd/libs/tool-schemas.js.map +1 -1
- package/dist/fastbrowser_mcp/fastbrowser_mcp.js +36 -2
- package/dist/fastbrowser_mcp/fastbrowser_mcp.js.map +1 -1
- package/dist/fastbrowser_mcp/libs/mcp_target_helper.d.ts +2 -0
- package/dist/fastbrowser_mcp/libs/mcp_target_helper.d.ts.map +1 -1
- package/dist/fastbrowser_mcp/libs/mcp_target_helper.js +12 -0
- package/dist/fastbrowser_mcp/libs/mcp_target_helper.js.map +1 -1
- package/dist/fastbrowser_mcp/libs/response_formatter.d.ts +1 -0
- package/dist/fastbrowser_mcp/libs/response_formatter.d.ts.map +1 -1
- package/dist/fastbrowser_mcp/libs/response_formatter.js +27 -0
- package/dist/fastbrowser_mcp/libs/response_formatter.js.map +1 -1
- package/dist/shared/fastbrowser_helper.d.ts +13 -0
- package/dist/shared/fastbrowser_helper.d.ts.map +1 -0
- package/dist/shared/fastbrowser_helper.js +39 -0
- package/dist/shared/fastbrowser_helper.js.map +1 -0
- package/examples/linkedin_cli_TOREMOVE/README.md +7 -0
- package/examples/{linkedin_cli → linkedin_cli_TOREMOVE}/linkedin_dm.sh +8 -4
- package/examples/linkedin_cli_TOREMOVE/linkedin_dm.ts +326 -0
- package/examples/linkedin_cli_TOREMOVE/linkedin_dm_messages.ts +279 -0
- package/examples/linkedin_cli_TOREMOVE/linkedin_full_cycle.sh +5 -0
- package/examples/{linkedin_cli → linkedin_cli_TOREMOVE}/linkedin_post.sh +3 -0
- package/examples/linkedin_cli_TOREMOVE/message_thread.a11y.txt +252 -0
- package/listitem +4 -0
- package/package.json +7 -3
- package/skills/fastbrowser/SKILL.md +33 -25
- package/src/contribs/_shared/fastbrowser_helper.ts +49 -0
- package/src/contribs/linkedin_cli/README.md +80 -0
- package/src/contribs/linkedin_cli/data/linkedin_posts_jeromeetienne.a11y.txt +2364 -0
- package/src/contribs/linkedin_cli/data/linkedin_posts_jontwigge.a11y.txt +2740 -0
- package/src/contribs/linkedin_cli/data/linkedin_posts_julien_guezennec.a11y.txt +2073 -0
- package/src/contribs/linkedin_cli/data/linkedin_profile_jeromeetienne.a11y.txt +1863 -0
- package/src/contribs/linkedin_cli/data/linkedin_profile_jontwigge.a11y.txt +1738 -0
- package/src/contribs/linkedin_cli/data/linkedin_profile_julien_guezennec.a11y.txt +2182 -0
- package/src/contribs/linkedin_cli/src/cli.ts +345 -0
- package/src/contribs/linkedin_cli/src/libs/linkedin_profile_helper.ts +964 -0
- package/src/contribs/linkedin_cli/src/libs/linkedin_recent_posts_helper.ts +982 -0
- package/src/contribs/linkedin_cli/src/libs/linkedin_thread_helper.ts +171 -0
- package/src/contribs/twitter_cli/README.md +79 -0
- package/src/contribs/twitter_cli/data/twitter_chat.a11y.txt +215 -0
- package/src/contribs/twitter_cli/data/twitter_home.a11y.txt +467 -0
- package/src/contribs/twitter_cli/data/twitter_profile.a11y.txt +418 -0
- package/src/contribs/twitter_cli/data/twitter_profile_jontwigge.a11y.txt +484 -0
- package/src/contribs/twitter_cli/data/twitter_profile_molokoloco.a11y.txt +483 -0
- package/src/contribs/twitter_cli/src/cli.ts +315 -0
- package/src/contribs/twitter_cli/src/libs/twitter_profile_helper.ts +328 -0
- package/src/contribs/twitter_cli/src/libs/twitter_recent_posts_helper.ts +607 -0
- package/src/contribs/twitter_cli/src/libs/twitter_thread_helper.ts +240 -0
- package/src/fastbrowser_cli/fastbrowser_cli.ts +51 -0
- package/src/fastbrowser_httpd/libs/tool-schemas.ts +6 -0
- package/src/fastbrowser_mcp/fastbrowser_mcp.ts +46 -3
- package/src/fastbrowser_mcp/libs/mcp_target_helper.ts +11 -0
- package/src/fastbrowser_mcp/libs/response_formatter.ts +29 -0
- package/src/shared/fastbrowser_helper.ts +49 -0
- package/tsconfig.json +1 -1
- package/examples/mcp_client_playwright.ts +0 -34
- /package/examples/{linkedin_cli → linkedin_cli_TOREMOVE}/linkedin.snapshot.txt +0 -0
- /package/examples/{twitter_cli → twitter_cli_TOREMOVE}/twitter_post.sh +0 -0
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
// npm imports
|
|
2
|
+
import { A11yQuery, A11yTree, AxNode } from 'a11y_parse';
|
|
3
|
+
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
//
|
|
7
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
8
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
9
|
+
|
|
10
|
+
export type LinkedinPostMedia = 'image' | 'video' | 'article' | 'document';
|
|
11
|
+
|
|
12
|
+
export type LinkedinPost = {
|
|
13
|
+
url: string | null;
|
|
14
|
+
activityId: string | null;
|
|
15
|
+
authorSlug: string | null;
|
|
16
|
+
authorDisplayName: string | null;
|
|
17
|
+
authorHeadline: string | null;
|
|
18
|
+
timestamp: string | null;
|
|
19
|
+
isEdited: boolean;
|
|
20
|
+
text: string;
|
|
21
|
+
isRepost: boolean;
|
|
22
|
+
repostedBy: string | null;
|
|
23
|
+
reactionCount: number | null;
|
|
24
|
+
commentCount: number | null;
|
|
25
|
+
repostCount: number | null;
|
|
26
|
+
impressionCount: number | null;
|
|
27
|
+
hasMedia: boolean;
|
|
28
|
+
mediaTypes: LinkedinPostMedia[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const TIMESTAMP_SHORT_REGEXP = /^(\d+[smhdw]|\d+\s+(?:second|minute|hour|day|week|month|year)s?)\s*•/i;
|
|
32
|
+
const TIMESTAMP_LONG_REGEXP = /^\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago\b/i;
|
|
33
|
+
const ACTIVITY_ID_REGEXP = /urn:li:activity:(\d+)/;
|
|
34
|
+
const COUNT_LEADING_REGEXP = /^(\d+(?:[.,]\d+)?)/;
|
|
35
|
+
const ANALYTICS_LINK_NAME_REGEXP = /^(\d+(?:[.,]\d+)?)\s+impressions/i;
|
|
36
|
+
|
|
37
|
+
const NOISE_LITERALS = new Set<string>([
|
|
38
|
+
'·',
|
|
39
|
+
'•',
|
|
40
|
+
'Like',
|
|
41
|
+
'Comment',
|
|
42
|
+
'Repost',
|
|
43
|
+
'Send',
|
|
44
|
+
'Boost',
|
|
45
|
+
'Follow',
|
|
46
|
+
'hashtag',
|
|
47
|
+
'Activate to view larger image,',
|
|
48
|
+
'See content credentials',
|
|
49
|
+
'…more',
|
|
50
|
+
'…',
|
|
51
|
+
'Show translation',
|
|
52
|
+
'reposted this',
|
|
53
|
+
'• You',
|
|
54
|
+
'• 1st',
|
|
55
|
+
'• 2nd',
|
|
56
|
+
'• 3rd',
|
|
57
|
+
'• 3rd+',
|
|
58
|
+
'Verified',
|
|
59
|
+
'Premium',
|
|
60
|
+
'graphical user interface',
|
|
61
|
+
'graphical user interface, application',
|
|
62
|
+
'No alternative text description for this image',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const NOISE_PREFIXES = [
|
|
66
|
+
'Promote this post to reach people',
|
|
67
|
+
'Activate to view larger image',
|
|
68
|
+
'Verified •',
|
|
69
|
+
'Premium •',
|
|
70
|
+
'• ',
|
|
71
|
+
'Visible to anyone',
|
|
72
|
+
'Translated from ',
|
|
73
|
+
'This post is not eligible',
|
|
74
|
+
'Open control menu',
|
|
75
|
+
'View ',
|
|
76
|
+
'Loaded ',
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
80
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
81
|
+
//
|
|
82
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
83
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
84
|
+
|
|
85
|
+
export class LinkedinRecentPostsHelper {
|
|
86
|
+
|
|
87
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
88
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
89
|
+
//
|
|
90
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
91
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
92
|
+
|
|
93
|
+
static parsePosts(rawSnapshot: string, viewingSlug: string): LinkedinPost[] {
|
|
94
|
+
const treeText = LinkedinRecentPostsHelper.extractAxTreeText(rawSnapshot);
|
|
95
|
+
if (treeText.length === 0) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const root = A11yTree.parse(treeText);
|
|
99
|
+
const headings = A11yQuery.querySelectorAll(root, 'heading[level="2"]');
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
const posts: LinkedinPost[] = [];
|
|
102
|
+
for (const heading of headings) {
|
|
103
|
+
if (heading.name === undefined) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (heading.name.startsWith('Feed post number ') === false) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const container = LinkedinRecentPostsHelper.walkUpToPostContainer(heading);
|
|
110
|
+
if (container === undefined) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (seen.has(container.uid) === true) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
seen.add(container.uid);
|
|
117
|
+
posts.push(LinkedinRecentPostsHelper.extractPost(container, heading, viewingSlug));
|
|
118
|
+
}
|
|
119
|
+
return posts;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
123
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
124
|
+
//
|
|
125
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
126
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
127
|
+
|
|
128
|
+
static formatMarkdown(posts: LinkedinPost[]): string {
|
|
129
|
+
if (posts.length === 0) {
|
|
130
|
+
return '_no posts found_';
|
|
131
|
+
}
|
|
132
|
+
const sections: string[] = [];
|
|
133
|
+
for (const post of posts) {
|
|
134
|
+
sections.push(LinkedinRecentPostsHelper.formatPostMarkdown(post));
|
|
135
|
+
}
|
|
136
|
+
return sections.join('\n\n---\n\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
140
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
141
|
+
// Tree filtering / post container walk
|
|
142
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
143
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
144
|
+
|
|
145
|
+
private static extractAxTreeText(rawOutput: string): string {
|
|
146
|
+
const lines: string[] = [];
|
|
147
|
+
for (const line of rawOutput.split('\n')) {
|
|
148
|
+
if (/^\s*uid=/.test(line) === true) {
|
|
149
|
+
lines.push(line);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return lines.join('\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private static walkUpToPostContainer(heading: AxNode): AxNode | undefined {
|
|
156
|
+
let cursor: AxNode | undefined = heading.parent;
|
|
157
|
+
for (let depth = 0; depth < 16; depth++) {
|
|
158
|
+
if (cursor === undefined) {
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (cursor.role === 'listitem') {
|
|
162
|
+
return cursor;
|
|
163
|
+
}
|
|
164
|
+
cursor = cursor.parent;
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
170
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
171
|
+
// Per-post extraction
|
|
172
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
173
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
174
|
+
|
|
175
|
+
private static extractPost(container: AxNode, heading: AxNode, viewingSlug: string): LinkedinPost {
|
|
176
|
+
const repost = LinkedinRecentPostsHelper.detectRepost(container);
|
|
177
|
+
const author = LinkedinRecentPostsHelper.extractAuthor(container);
|
|
178
|
+
const timestamp = LinkedinRecentPostsHelper.extractTimestamp(container);
|
|
179
|
+
const activity = LinkedinRecentPostsHelper.extractActivity(container);
|
|
180
|
+
const counts = LinkedinRecentPostsHelper.extractCounts(container);
|
|
181
|
+
const mediaTypes = LinkedinRecentPostsHelper.extractMedia(container);
|
|
182
|
+
const text = LinkedinRecentPostsHelper.extractBodyText(container, heading, {
|
|
183
|
+
authorDisplayName: author.displayName,
|
|
184
|
+
authorHeadline: author.headline,
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
url: activity !== null ? activity.url : null,
|
|
188
|
+
activityId: activity !== null ? activity.activityId : null,
|
|
189
|
+
authorSlug: author.slug,
|
|
190
|
+
authorDisplayName: author.displayName,
|
|
191
|
+
authorHeadline: author.headline,
|
|
192
|
+
timestamp: timestamp.timestamp,
|
|
193
|
+
isEdited: timestamp.isEdited,
|
|
194
|
+
text,
|
|
195
|
+
isRepost: repost.isRepost,
|
|
196
|
+
repostedBy: repost.isRepost === true ? (repost.repostedBy ?? viewingSlug) : null,
|
|
197
|
+
reactionCount: counts.reactionCount,
|
|
198
|
+
commentCount: counts.commentCount,
|
|
199
|
+
repostCount: counts.repostCount,
|
|
200
|
+
impressionCount: counts.impressionCount,
|
|
201
|
+
hasMedia: mediaTypes.length > 0,
|
|
202
|
+
mediaTypes,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
207
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
208
|
+
// Repost detection
|
|
209
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
210
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
211
|
+
|
|
212
|
+
private static detectRepost(container: AxNode): { isRepost: boolean; repostedBy: string | null; } {
|
|
213
|
+
const repostedNode = LinkedinRecentPostsHelper.findRepostedThis(container);
|
|
214
|
+
if (repostedNode === undefined) {
|
|
215
|
+
return { isRepost: false, repostedBy: null };
|
|
216
|
+
}
|
|
217
|
+
let repostedBy: string | null = null;
|
|
218
|
+
let cursor: AxNode | undefined = repostedNode.parent;
|
|
219
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
220
|
+
if (cursor === undefined) {
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
const link = A11yQuery.querySelector(cursor, 'link[url^="/in/"]');
|
|
224
|
+
if (link !== undefined) {
|
|
225
|
+
const url = link.attributes['url'];
|
|
226
|
+
if (url !== undefined) {
|
|
227
|
+
repostedBy = LinkedinRecentPostsHelper.slugFromInUrl(url);
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
cursor = cursor.parent;
|
|
232
|
+
}
|
|
233
|
+
return { isRepost: true, repostedBy };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private static findRepostedThis(container: AxNode): AxNode | undefined {
|
|
237
|
+
for (const node of A11yTree.walk(container)) {
|
|
238
|
+
if (node.role !== 'StaticText') {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (node.name === undefined) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (node.name.trim() === 'reposted this') {
|
|
245
|
+
return node;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
252
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
253
|
+
// Author extraction
|
|
254
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
255
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
256
|
+
|
|
257
|
+
private static extractAuthor(container: AxNode): {
|
|
258
|
+
slug: string | null;
|
|
259
|
+
displayName: string | null;
|
|
260
|
+
headline: string | null;
|
|
261
|
+
} {
|
|
262
|
+
const authorLink = LinkedinRecentPostsHelper.findAuthorLink(container);
|
|
263
|
+
if (authorLink === undefined) {
|
|
264
|
+
return { slug: null, displayName: null, headline: null };
|
|
265
|
+
}
|
|
266
|
+
const url = authorLink.attributes['url'];
|
|
267
|
+
const slug = url !== undefined ? LinkedinRecentPostsHelper.slugFromMiniProfileUrl(url) : null;
|
|
268
|
+
const headerScope = LinkedinRecentPostsHelper.findAuthorHeaderScope(authorLink);
|
|
269
|
+
const values = LinkedinRecentPostsHelper.collectGenericValues(headerScope);
|
|
270
|
+
const dedup = LinkedinRecentPostsHelper.dedupeAdjacent(values);
|
|
271
|
+
const filtered = LinkedinRecentPostsHelper.filterAuthorValues(dedup);
|
|
272
|
+
const displayName = filtered.length >= 1 ? filtered[0] : null;
|
|
273
|
+
const headline = filtered.length >= 2 ? filtered[1] : null;
|
|
274
|
+
return { slug, displayName, headline };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private static findAuthorLink(container: AxNode): AxNode | undefined {
|
|
278
|
+
const links = A11yQuery.querySelectorAll(container, 'link[url*="miniProfileUrn"]');
|
|
279
|
+
for (const link of links) {
|
|
280
|
+
const name = link.name;
|
|
281
|
+
if (name === undefined) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (name.startsWith('View ') === true && name.endsWith('graphic link') === true) {
|
|
285
|
+
return link;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (links.length > 0) {
|
|
289
|
+
return links[0];
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private static findAuthorHeaderScope(authorLink: AxNode): AxNode {
|
|
295
|
+
// The author's display name, connection indicator, headline, and timestamp all
|
|
296
|
+
// live in a sibling generic of the profile link. Walk up two levels — the
|
|
297
|
+
// profile link is wrapped twice — to land on the generic that contains all of them.
|
|
298
|
+
let cursor: AxNode | undefined = authorLink.parent;
|
|
299
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
300
|
+
if (cursor === undefined) {
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
cursor = cursor.parent;
|
|
304
|
+
}
|
|
305
|
+
return cursor ?? authorLink;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private static collectGenericValues(node: AxNode): string[] {
|
|
309
|
+
const out: string[] = [];
|
|
310
|
+
for (const descendant of A11yTree.walk(node)) {
|
|
311
|
+
if (descendant.role !== 'generic') {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const value = descendant.attributes['value'];
|
|
315
|
+
if (value === undefined) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const trimmed = value.trim();
|
|
319
|
+
if (trimmed.length === 0) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
out.push(trimmed);
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private static dedupeAdjacent(values: string[]): string[] {
|
|
328
|
+
const out: string[] = [];
|
|
329
|
+
for (const value of values) {
|
|
330
|
+
if (out.length > 0 && out[out.length - 1] === value) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
out.push(value);
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private static filterAuthorValues(values: string[]): string[] {
|
|
339
|
+
const out: string[] = [];
|
|
340
|
+
for (const value of values) {
|
|
341
|
+
if (LinkedinRecentPostsHelper.isAuthorNoiseValue(value) === true) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
out.push(value);
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private static isAuthorNoiseValue(value: string): boolean {
|
|
350
|
+
if (value.startsWith('•') === true) {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
if (value.startsWith('Verified •') === true) {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
if (value.startsWith('Premium •') === true) {
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
if (TIMESTAMP_SHORT_REGEXP.test(value) === true) {
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
if (TIMESTAMP_LONG_REGEXP.test(value) === true) {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (value === 'Visit my website') {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
if (value === 'Follow') {
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
if (value === 'You') {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
if (/^\d+\s+followers$/.test(value) === true) {
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
// Connection-degree indicators: "1st", "2nd", "3rd", "3rd+".
|
|
378
|
+
if (/^\d+(st|nd|rd|th)\+?$/.test(value) === true) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private static slugFromMiniProfileUrl(url: string): string | null {
|
|
385
|
+
const match = url.match(/\/in\/([^/?#]+)/);
|
|
386
|
+
if (match === null) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
return match[1];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private static slugFromInUrl(url: string): string | null {
|
|
393
|
+
const match = url.match(/^\/in\/([^/?#]+)/);
|
|
394
|
+
if (match === null) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
return match[1];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
401
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
402
|
+
// Timestamp extraction
|
|
403
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
404
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
405
|
+
|
|
406
|
+
private static extractTimestamp(container: AxNode): { timestamp: string | null; isEdited: boolean; } {
|
|
407
|
+
// Prefer the short StaticText (e.g. "1d •" / "12h • Edited •"); fall back to
|
|
408
|
+
// the long generic value (e.g. "1 day ago • Visible to anyone…").
|
|
409
|
+
for (const node of A11yTree.walk(container)) {
|
|
410
|
+
if (node.role !== 'StaticText') {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (node.name === undefined) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const trimmed = node.name.trim();
|
|
417
|
+
if (TIMESTAMP_SHORT_REGEXP.test(trimmed) === false) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
return LinkedinRecentPostsHelper.normalizeTimestamp(trimmed);
|
|
421
|
+
}
|
|
422
|
+
for (const node of A11yTree.walk(container)) {
|
|
423
|
+
if (node.role !== 'generic') {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const value = node.attributes['value'];
|
|
427
|
+
if (value === undefined) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const trimmed = value.trim();
|
|
431
|
+
if (TIMESTAMP_LONG_REGEXP.test(trimmed) === false) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
return LinkedinRecentPostsHelper.normalizeTimestamp(trimmed);
|
|
435
|
+
}
|
|
436
|
+
return { timestamp: null, isEdited: false };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private static normalizeTimestamp(raw: string): { timestamp: string; isEdited: boolean; } {
|
|
440
|
+
const isEdited = /\bEdited\b/.test(raw);
|
|
441
|
+
// Drop the trailing " • Edited • Visible…" segment — keep just the time portion.
|
|
442
|
+
const segments = raw.split('•').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
443
|
+
let timestamp = segments.length > 0 ? segments[0] : raw.trim();
|
|
444
|
+
// If long form ("1 day ago"), keep as-is. If short form ("1d"), keep as-is.
|
|
445
|
+
timestamp = timestamp.replace(/\s+/g, ' ').trim();
|
|
446
|
+
return { timestamp, isEdited };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
450
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
451
|
+
// Activity URL / id extraction
|
|
452
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
453
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
454
|
+
|
|
455
|
+
private static extractActivity(container: AxNode): { activityId: string; url: string; } | null {
|
|
456
|
+
const analyticsLink = A11yQuery.querySelector(
|
|
457
|
+
container,
|
|
458
|
+
'link[url^="/analytics/post-summary/urn:li:activity:"]',
|
|
459
|
+
);
|
|
460
|
+
if (analyticsLink !== undefined) {
|
|
461
|
+
const result = LinkedinRecentPostsHelper.activityFromUrl(analyticsLink.attributes['url']);
|
|
462
|
+
if (result !== null) {
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const updateLinks = A11yQuery.querySelectorAll(container, 'link[url^="/feed/update/urn:li:activity:"]');
|
|
467
|
+
for (const link of updateLinks) {
|
|
468
|
+
const result = LinkedinRecentPostsHelper.activityFromUrl(link.attributes['url']);
|
|
469
|
+
if (result !== null) {
|
|
470
|
+
return result;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private static activityFromUrl(url: string | undefined): { activityId: string; url: string; } | null {
|
|
477
|
+
if (url === undefined) {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
const match = url.match(ACTIVITY_ID_REGEXP);
|
|
481
|
+
if (match === null) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
const activityId = match[1];
|
|
485
|
+
return {
|
|
486
|
+
activityId,
|
|
487
|
+
url: `https://www.linkedin.com/feed/update/urn:li:activity:${activityId}/`,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
492
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
493
|
+
// Counts
|
|
494
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
495
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
496
|
+
|
|
497
|
+
private static extractCounts(container: AxNode): {
|
|
498
|
+
reactionCount: number | null;
|
|
499
|
+
commentCount: number | null;
|
|
500
|
+
repostCount: number | null;
|
|
501
|
+
impressionCount: number | null;
|
|
502
|
+
} {
|
|
503
|
+
return {
|
|
504
|
+
reactionCount: LinkedinRecentPostsHelper.extractReactionCount(container),
|
|
505
|
+
commentCount: LinkedinRecentPostsHelper.extractCommentCount(container),
|
|
506
|
+
repostCount: LinkedinRecentPostsHelper.extractRepostCount(container),
|
|
507
|
+
impressionCount: LinkedinRecentPostsHelper.extractImpressionCount(container),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private static extractReactionCount(container: AxNode): number | null {
|
|
512
|
+
// The reactions list is `list > listitem > button` where the count lives in a
|
|
513
|
+
// nested `generic[value]` matching /^\d+$/ (e.g. button > generic > generic[value="4"]).
|
|
514
|
+
// Pick the first one. The button's name (e.g. "1 reaction" or "Julien and 3 others")
|
|
515
|
+
// is also a fallback when the value isn't present.
|
|
516
|
+
const buttons = A11yQuery.querySelectorAll(container, 'list > listitem > button');
|
|
517
|
+
for (const button of buttons) {
|
|
518
|
+
for (const descendant of A11yTree.walk(button)) {
|
|
519
|
+
if (descendant.uid === button.uid) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (descendant.role !== 'generic') {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const value = descendant.attributes['value'];
|
|
526
|
+
if (value === undefined) {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const trimmed = value.trim();
|
|
530
|
+
if (/^\d+$/.test(trimmed) === false) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const parsed = parseInt(trimmed, 10);
|
|
534
|
+
if (Number.isNaN(parsed) === true) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
return parsed;
|
|
538
|
+
}
|
|
539
|
+
if (button.name !== undefined) {
|
|
540
|
+
const match = button.name.match(/^(\d+)\s+reactions?$/i);
|
|
541
|
+
if (match !== null) {
|
|
542
|
+
const parsed = parseInt(match[1], 10);
|
|
543
|
+
if (Number.isNaN(parsed) === false) {
|
|
544
|
+
return parsed;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private static extractCommentCount(container: AxNode): number | null {
|
|
553
|
+
return LinkedinRecentPostsHelper.findLeadingCount(container, /\bcomments?\s+on\b/i);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private static extractRepostCount(container: AxNode): number | null {
|
|
557
|
+
return LinkedinRecentPostsHelper.findLeadingCount(container, /\breposts?\s+of\b/i);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private static findLeadingCount(container: AxNode, namePattern: RegExp): number | null {
|
|
561
|
+
for (const node of A11yTree.walk(container)) {
|
|
562
|
+
if (node.role !== 'button') {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (node.name === undefined) {
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (namePattern.test(node.name) === false) {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const match = node.name.match(COUNT_LEADING_REGEXP);
|
|
572
|
+
if (match === null) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const cleaned = match[1].replace(/[.,]/g, '');
|
|
576
|
+
const parsed = parseInt(cleaned, 10);
|
|
577
|
+
if (Number.isNaN(parsed) === true) {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
return parsed;
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private static extractImpressionCount(container: AxNode): number | null {
|
|
586
|
+
const link = A11yQuery.querySelector(container, 'link[url^="/analytics/post-summary/urn:li:activity:"]');
|
|
587
|
+
if (link === undefined) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
if (link.name === undefined) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
const match = link.name.match(ANALYTICS_LINK_NAME_REGEXP);
|
|
594
|
+
if (match === null) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
const cleaned = match[1].replace(/[.,]/g, '');
|
|
598
|
+
const parsed = parseInt(cleaned, 10);
|
|
599
|
+
if (Number.isNaN(parsed) === true) {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
return parsed;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
606
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
607
|
+
// Media detection
|
|
608
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
609
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
610
|
+
|
|
611
|
+
private static extractMedia(container: AxNode): LinkedinPostMedia[] {
|
|
612
|
+
const media: LinkedinPostMedia[] = [];
|
|
613
|
+
const hasImage = A11yQuery.querySelector(container, 'button[name^="Activate to view larger image"]') !== undefined;
|
|
614
|
+
if (hasImage === true) {
|
|
615
|
+
media.push('image');
|
|
616
|
+
}
|
|
617
|
+
const hasDocument = LinkedinRecentPostsHelper.detectDocumentCarousel(container);
|
|
618
|
+
if (hasDocument === true) {
|
|
619
|
+
media.push('document');
|
|
620
|
+
}
|
|
621
|
+
const hasVideo = LinkedinRecentPostsHelper.detectVideo(container);
|
|
622
|
+
if (hasVideo === true) {
|
|
623
|
+
media.push('video');
|
|
624
|
+
}
|
|
625
|
+
const hasArticle = LinkedinRecentPostsHelper.detectArticleShare(container);
|
|
626
|
+
if (hasArticle === true) {
|
|
627
|
+
media.push('article');
|
|
628
|
+
}
|
|
629
|
+
return media;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private static detectDocumentCarousel(container: AxNode): boolean {
|
|
633
|
+
for (const node of A11yTree.walk(container)) {
|
|
634
|
+
if (node.role !== 'iframe') {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const progress = A11yQuery.querySelector(node, 'progressbar');
|
|
638
|
+
if (progress !== undefined) {
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private static detectVideo(container: AxNode): boolean {
|
|
646
|
+
for (const node of A11yTree.walk(container)) {
|
|
647
|
+
if (node.role !== 'button') {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (node.name === undefined) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (/play\s+video/i.test(node.name) === true) {
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private static detectArticleShare(container: AxNode): boolean {
|
|
661
|
+
// LinkedIn renders article previews two ways:
|
|
662
|
+
// (a) a nested `article` element (long-form post / pulse article)
|
|
663
|
+
// (b) an outbound `link[url^="http"]` paired with a sibling `generic[value]`
|
|
664
|
+
// containing the publisher domain (newsletter / blog share)
|
|
665
|
+
const outerArticle = A11yQuery.querySelector(container, 'article');
|
|
666
|
+
const articles = A11yQuery.querySelectorAll(container, 'article');
|
|
667
|
+
for (const article of articles) {
|
|
668
|
+
if (outerArticle !== undefined && article.uid === outerArticle.uid) {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
const links = A11yQuery.querySelectorAll(container, 'link');
|
|
674
|
+
for (const link of links) {
|
|
675
|
+
const url = link.attributes['url'];
|
|
676
|
+
if (url === undefined) {
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (url.startsWith('http://') === false && url.startsWith('https://') === false) {
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (LinkedinRecentPostsHelper.isInternalUrl(url) === true) {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (LinkedinRecentPostsHelper.linkLooksLikeShareCard(link) === true) {
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private static isInternalUrl(url: string): boolean {
|
|
693
|
+
if (url.startsWith('https://www.linkedin.com/') === true) {
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
if (url.startsWith('https://lnkd.in/') === true) {
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private static linkLooksLikeShareCard(link: AxNode): boolean {
|
|
703
|
+
for (const child of link.children) {
|
|
704
|
+
if (child.role !== 'generic') {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
for (const grand of child.children) {
|
|
708
|
+
if (grand.role !== 'generic') {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const value = grand.attributes['value'];
|
|
712
|
+
if (value === undefined) {
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (/^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i.test(value.trim()) === true) {
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
724
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
725
|
+
// Body text extraction
|
|
726
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
727
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
728
|
+
|
|
729
|
+
private static extractBodyText(
|
|
730
|
+
container: AxNode,
|
|
731
|
+
heading: AxNode,
|
|
732
|
+
header: { authorDisplayName: string | null; authorHeadline: string | null; },
|
|
733
|
+
): string {
|
|
734
|
+
const skipUids = LinkedinRecentPostsHelper.collectBodySkipUids(container, heading);
|
|
735
|
+
const noise = new Set<string>(NOISE_LITERALS);
|
|
736
|
+
if (header.authorDisplayName !== null) {
|
|
737
|
+
noise.add(header.authorDisplayName);
|
|
738
|
+
}
|
|
739
|
+
if (header.authorHeadline !== null) {
|
|
740
|
+
noise.add(header.authorHeadline);
|
|
741
|
+
}
|
|
742
|
+
const fragments: string[] = [];
|
|
743
|
+
for (const node of A11yTree.walk(container)) {
|
|
744
|
+
if (skipUids.has(node.uid) === true) {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const fragment = LinkedinRecentPostsHelper.fragmentForNode(node);
|
|
748
|
+
if (fragment === null) {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (noise.has(fragment) === true) {
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (LinkedinRecentPostsHelper.matchesNoisePrefix(fragment) === true) {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
fragments.push(fragment);
|
|
758
|
+
}
|
|
759
|
+
const deduped = LinkedinRecentPostsHelper.dedupeAdjacent(fragments);
|
|
760
|
+
return deduped.join(' ').replace(/\s+/g, ' ').trim();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private static collectBodySkipUids(container: AxNode, heading: AxNode): Set<string> {
|
|
764
|
+
const skipRoots: AxNode[] = [];
|
|
765
|
+
// Skip the heading subtree itself.
|
|
766
|
+
skipRoots.push(heading);
|
|
767
|
+
// Skip the author header block — it's the second child of the heading's parent.
|
|
768
|
+
const headingParent = heading.parent;
|
|
769
|
+
if (headingParent !== undefined) {
|
|
770
|
+
const headingIdx = headingParent.children.indexOf(heading);
|
|
771
|
+
if (headingIdx !== -1) {
|
|
772
|
+
const next = headingParent.children[headingIdx + 1];
|
|
773
|
+
if (next !== undefined && next.children.length > 0) {
|
|
774
|
+
const headerBlock = next.children[0];
|
|
775
|
+
skipRoots.push(headerBlock);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// Repost banner — the StaticText "reposted this" sits in a generic that
|
|
780
|
+
// includes the page-owner's profile link. Skip the surrounding generic so the
|
|
781
|
+
// link name doesn't leak into the body text.
|
|
782
|
+
const repostedNode = LinkedinRecentPostsHelper.findRepostedThis(container);
|
|
783
|
+
if (repostedNode !== undefined) {
|
|
784
|
+
let cursor: AxNode | undefined = repostedNode.parent;
|
|
785
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
786
|
+
if (cursor === undefined) {
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
const ownerLink = A11yQuery.querySelector(cursor, 'link[url^="/in/"]');
|
|
790
|
+
const photoLink = A11yQuery.querySelector(cursor, 'link[url*="miniProfileUrn"]');
|
|
791
|
+
if (ownerLink !== undefined && photoLink !== undefined) {
|
|
792
|
+
skipRoots.push(cursor);
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
cursor = cursor.parent;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// Skip subtrees rooted at any button, list, iframe, or nested article.
|
|
799
|
+
const outerArticle = A11yQuery.querySelector(container, 'article');
|
|
800
|
+
for (const node of A11yTree.walk(container)) {
|
|
801
|
+
if (node.role === 'button') {
|
|
802
|
+
skipRoots.push(node);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (node.role === 'list') {
|
|
806
|
+
skipRoots.push(node);
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
if (node.role === 'iframe') {
|
|
810
|
+
skipRoots.push(node);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (node.role === 'alert') {
|
|
814
|
+
skipRoots.push(node);
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
if (node.role === 'article' && outerArticle !== undefined && node.uid !== outerArticle.uid) {
|
|
818
|
+
skipRoots.push(node);
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
// Profile graphic-link inside the post body (author header was already
|
|
822
|
+
// skipped, but inline mentions like `link "Benoit Raphael" url="/in/.../"`
|
|
823
|
+
// should be kept — so don't skip those).
|
|
824
|
+
if (node.role === 'link' && node.attributes['url'] !== undefined) {
|
|
825
|
+
const url = node.attributes['url'];
|
|
826
|
+
if (url.startsWith('/analytics/post-summary/') === true) {
|
|
827
|
+
skipRoots.push(node);
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const skipUids = new Set<string>();
|
|
833
|
+
for (const root of skipRoots) {
|
|
834
|
+
for (const descendant of A11yTree.walk(root)) {
|
|
835
|
+
skipUids.add(descendant.uid);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return skipUids;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private static fragmentForNode(node: AxNode): string | null {
|
|
842
|
+
if (node.role === 'StaticText' && node.name !== undefined) {
|
|
843
|
+
const trimmed = node.name.trim();
|
|
844
|
+
if (trimmed.length === 0) {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
return trimmed;
|
|
848
|
+
}
|
|
849
|
+
const value = node.attributes['value'];
|
|
850
|
+
if (value !== undefined) {
|
|
851
|
+
const trimmed = value.trim();
|
|
852
|
+
if (trimmed.length === 0) {
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
// Hashtag links wrap a generic value="hashtag" + generic value="#X" — keep
|
|
856
|
+
// only the # variant.
|
|
857
|
+
if (trimmed === 'hashtag') {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
return trimmed;
|
|
861
|
+
}
|
|
862
|
+
if (node.role === 'link' && node.name !== undefined) {
|
|
863
|
+
const trimmed = node.name.trim();
|
|
864
|
+
if (trimmed.length === 0) {
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
// If the link wraps a hashtag (its first descendant is generic value="hashtag"),
|
|
868
|
+
// rely on the inner generic value="#X" instead — return null here.
|
|
869
|
+
if (LinkedinRecentPostsHelper.linkIsHashtag(node) === true) {
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
// If the link has any text descendants (StaticText / generic[value]), they will
|
|
873
|
+
// emit their own fragments — skip the link's own name to avoid duplicates.
|
|
874
|
+
if (LinkedinRecentPostsHelper.linkHasTextDescendant(node) === true) {
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
return trimmed;
|
|
878
|
+
}
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private static linkIsHashtag(link: AxNode): boolean {
|
|
883
|
+
for (const child of link.children) {
|
|
884
|
+
if (child.role !== 'generic') {
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
const value = child.attributes['value'];
|
|
888
|
+
if (value === undefined) {
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (value.trim() === 'hashtag') {
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
private static linkHasTextDescendant(link: AxNode): boolean {
|
|
899
|
+
for (const descendant of A11yTree.walk(link)) {
|
|
900
|
+
if (descendant.uid === link.uid) {
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
if (descendant.role === 'StaticText' && descendant.name !== undefined && descendant.name.trim().length > 0) {
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
const value = descendant.attributes['value'];
|
|
907
|
+
if (value !== undefined && value.trim().length > 0) {
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private static matchesNoisePrefix(fragment: string): boolean {
|
|
915
|
+
for (const prefix of NOISE_PREFIXES) {
|
|
916
|
+
if (fragment.startsWith(prefix) === true) {
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
924
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
925
|
+
// Markdown rendering
|
|
926
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
927
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
928
|
+
|
|
929
|
+
private static formatPostMarkdown(post: LinkedinPost): string {
|
|
930
|
+
const headerName = post.authorDisplayName !== null
|
|
931
|
+
? post.authorDisplayName
|
|
932
|
+
: (post.authorSlug !== null ? post.authorSlug : 'unknown');
|
|
933
|
+
const slugSuffix = post.authorSlug !== null ? ` (${post.authorSlug})` : '';
|
|
934
|
+
const headerParts: string[] = [`${headerName}${slugSuffix}`];
|
|
935
|
+
if (post.timestamp !== null) {
|
|
936
|
+
headerParts.push(post.timestamp);
|
|
937
|
+
}
|
|
938
|
+
if (post.isEdited === true) {
|
|
939
|
+
headerParts.push('edited');
|
|
940
|
+
}
|
|
941
|
+
if (post.isRepost === true && post.repostedBy !== null) {
|
|
942
|
+
headerParts.push(`reposted by ${post.repostedBy}`);
|
|
943
|
+
}
|
|
944
|
+
const lines: string[] = [];
|
|
945
|
+
lines.push(`## ${headerParts.join(' · ')}`);
|
|
946
|
+
if (post.authorHeadline !== null) {
|
|
947
|
+
lines.push(`_${post.authorHeadline}_`);
|
|
948
|
+
}
|
|
949
|
+
lines.push('');
|
|
950
|
+
if (post.text.length === 0) {
|
|
951
|
+
lines.push('_(no text)_');
|
|
952
|
+
} else {
|
|
953
|
+
lines.push(post.text);
|
|
954
|
+
}
|
|
955
|
+
if (post.mediaTypes.length > 0) {
|
|
956
|
+
lines.push('');
|
|
957
|
+
lines.push(`media: ${post.mediaTypes.join(', ')}`);
|
|
958
|
+
}
|
|
959
|
+
const countParts: string[] = [];
|
|
960
|
+
if (post.reactionCount !== null) {
|
|
961
|
+
countParts.push(`reactions: ${post.reactionCount}`);
|
|
962
|
+
}
|
|
963
|
+
if (post.commentCount !== null) {
|
|
964
|
+
countParts.push(`comments: ${post.commentCount}`);
|
|
965
|
+
}
|
|
966
|
+
if (post.repostCount !== null) {
|
|
967
|
+
countParts.push(`reposts: ${post.repostCount}`);
|
|
968
|
+
}
|
|
969
|
+
if (post.impressionCount !== null) {
|
|
970
|
+
countParts.push(`impressions: ${post.impressionCount}`);
|
|
971
|
+
}
|
|
972
|
+
if (countParts.length > 0) {
|
|
973
|
+
lines.push('');
|
|
974
|
+
lines.push(countParts.join(' · '));
|
|
975
|
+
}
|
|
976
|
+
if (post.url !== null) {
|
|
977
|
+
lines.push('');
|
|
978
|
+
lines.push(post.url);
|
|
979
|
+
}
|
|
980
|
+
return lines.join('\n');
|
|
981
|
+
}
|
|
982
|
+
}
|