linkedin-automation-cli 1.0.0

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 (314) hide show
  1. package/.env.example +12 -0
  2. package/.github/workflows/ci.yml +66 -0
  3. package/.github/workflows/publish.yml +48 -0
  4. package/.husky/pre-commit +6 -0
  5. package/.prettierignore +4 -0
  6. package/.prettierrc +10 -0
  7. package/AGENTS.md +294 -0
  8. package/CHANGELOG.md +40 -0
  9. package/GIT_RELEASE.md +167 -0
  10. package/LICENSE +21 -0
  11. package/Makefile +30 -0
  12. package/NPM_PUBLISHING.md +230 -0
  13. package/PYEOF +0 -0
  14. package/README.md +295 -0
  15. package/TESTING-GUIDE.md +151 -0
  16. package/cmd/linkedin/main.go +9 -0
  17. package/dist/agent/action-executor.d.ts +81 -0
  18. package/dist/agent/action-executor.d.ts.map +1 -0
  19. package/dist/agent/action-executor.js +170 -0
  20. package/dist/agent/action-executor.js.map +1 -0
  21. package/dist/agent/action-executor.test.d.ts +2 -0
  22. package/dist/agent/action-executor.test.d.ts.map +1 -0
  23. package/dist/agent/action-executor.test.js +366 -0
  24. package/dist/agent/action-executor.test.js.map +1 -0
  25. package/dist/agent/claude-client.d.ts +74 -0
  26. package/dist/agent/claude-client.d.ts.map +1 -0
  27. package/dist/agent/claude-client.js +314 -0
  28. package/dist/agent/claude-client.js.map +1 -0
  29. package/dist/agent/claude-client.test.d.ts +2 -0
  30. package/dist/agent/claude-client.test.d.ts.map +1 -0
  31. package/dist/agent/claude-client.test.js +590 -0
  32. package/dist/agent/claude-client.test.js.map +1 -0
  33. package/dist/agent/dom-extractor.d.ts +50 -0
  34. package/dist/agent/dom-extractor.d.ts.map +1 -0
  35. package/dist/agent/dom-extractor.js +374 -0
  36. package/dist/agent/dom-extractor.js.map +1 -0
  37. package/dist/agent/dom-extractor.test.d.ts +7 -0
  38. package/dist/agent/dom-extractor.test.d.ts.map +1 -0
  39. package/dist/agent/dom-extractor.test.js +504 -0
  40. package/dist/agent/dom-extractor.test.js.map +1 -0
  41. package/dist/agent/extension-client.d.ts +75 -0
  42. package/dist/agent/extension-client.d.ts.map +1 -0
  43. package/dist/agent/extension-client.js +245 -0
  44. package/dist/agent/extension-client.js.map +1 -0
  45. package/dist/agent/index.d.ts +8 -0
  46. package/dist/agent/index.d.ts.map +1 -0
  47. package/dist/agent/index.js +16 -0
  48. package/dist/agent/index.js.map +1 -0
  49. package/dist/agent/page-agent.d.ts +76 -0
  50. package/dist/agent/page-agent.d.ts.map +1 -0
  51. package/dist/agent/page-agent.js +236 -0
  52. package/dist/agent/page-agent.js.map +1 -0
  53. package/dist/agent/types.d.ts +236 -0
  54. package/dist/agent/types.d.ts.map +1 -0
  55. package/dist/agent/types.js +37 -0
  56. package/dist/agent/types.js.map +1 -0
  57. package/dist/cli/agent-commands.d.ts +3 -0
  58. package/dist/cli/agent-commands.d.ts.map +1 -0
  59. package/dist/cli/agent-commands.js +250 -0
  60. package/dist/cli/agent-commands.js.map +1 -0
  61. package/dist/cli/auth.d.ts +3 -0
  62. package/dist/cli/auth.d.ts.map +1 -0
  63. package/dist/cli/auth.js +288 -0
  64. package/dist/cli/auth.js.map +1 -0
  65. package/dist/cli/company.d.ts +3 -0
  66. package/dist/cli/company.d.ts.map +1 -0
  67. package/dist/cli/company.js +55 -0
  68. package/dist/cli/company.js.map +1 -0
  69. package/dist/cli/connection.d.ts +3 -0
  70. package/dist/cli/connection.d.ts.map +1 -0
  71. package/dist/cli/connection.js +79 -0
  72. package/dist/cli/connection.js.map +1 -0
  73. package/dist/cli/index.d.ts +7 -0
  74. package/dist/cli/index.d.ts.map +1 -0
  75. package/dist/cli/index.js +17 -0
  76. package/dist/cli/index.js.map +1 -0
  77. package/dist/cli/messages.d.ts +3 -0
  78. package/dist/cli/messages.d.ts.map +1 -0
  79. package/dist/cli/messages.js +268 -0
  80. package/dist/cli/messages.js.map +1 -0
  81. package/dist/cli/profile.d.ts +3 -0
  82. package/dist/cli/profile.d.ts.map +1 -0
  83. package/dist/cli/profile.js +81 -0
  84. package/dist/cli/profile.js.map +1 -0
  85. package/dist/cli/profile.test.d.ts +2 -0
  86. package/dist/cli/profile.test.d.ts.map +1 -0
  87. package/dist/cli/profile.test.js +15 -0
  88. package/dist/cli/profile.test.js.map +1 -0
  89. package/dist/cli/reply.d.ts +3 -0
  90. package/dist/cli/reply.d.ts.map +1 -0
  91. package/dist/cli/reply.js +129 -0
  92. package/dist/cli/reply.js.map +1 -0
  93. package/dist/core/audit.d.ts +17 -0
  94. package/dist/core/audit.d.ts.map +1 -0
  95. package/dist/core/audit.js +121 -0
  96. package/dist/core/audit.js.map +1 -0
  97. package/dist/core/audit.test.d.ts +2 -0
  98. package/dist/core/audit.test.d.ts.map +1 -0
  99. package/dist/core/audit.test.js +142 -0
  100. package/dist/core/audit.test.js.map +1 -0
  101. package/dist/core/browser-cookies.d.ts +19 -0
  102. package/dist/core/browser-cookies.d.ts.map +1 -0
  103. package/dist/core/browser-cookies.js +181 -0
  104. package/dist/core/browser-cookies.js.map +1 -0
  105. package/dist/core/browser.d.ts +50 -0
  106. package/dist/core/browser.d.ts.map +1 -0
  107. package/dist/core/browser.js +318 -0
  108. package/dist/core/browser.js.map +1 -0
  109. package/dist/core/config.d.ts +20 -0
  110. package/dist/core/config.d.ts.map +1 -0
  111. package/dist/core/config.js +103 -0
  112. package/dist/core/config.js.map +1 -0
  113. package/dist/core/config.test.d.ts +2 -0
  114. package/dist/core/config.test.d.ts.map +1 -0
  115. package/dist/core/config.test.js +111 -0
  116. package/dist/core/config.test.js.map +1 -0
  117. package/dist/core/storage.d.ts +19 -0
  118. package/dist/core/storage.d.ts.map +1 -0
  119. package/dist/core/storage.js +124 -0
  120. package/dist/core/storage.js.map +1 -0
  121. package/dist/core/storage.test.d.ts +2 -0
  122. package/dist/core/storage.test.d.ts.map +1 -0
  123. package/dist/core/storage.test.js +142 -0
  124. package/dist/core/storage.test.js.map +1 -0
  125. package/dist/index.d.ts +3 -0
  126. package/dist/index.d.ts.map +1 -0
  127. package/dist/index.js +63 -0
  128. package/dist/index.js.map +1 -0
  129. package/dist/linkedin/auth.d.ts +22 -0
  130. package/dist/linkedin/auth.d.ts.map +1 -0
  131. package/dist/linkedin/auth.js +167 -0
  132. package/dist/linkedin/auth.js.map +1 -0
  133. package/dist/linkedin/company-extractor.d.ts +36 -0
  134. package/dist/linkedin/company-extractor.d.ts.map +1 -0
  135. package/dist/linkedin/company-extractor.js +211 -0
  136. package/dist/linkedin/company-extractor.js.map +1 -0
  137. package/dist/linkedin/company-extractor.test.d.ts +2 -0
  138. package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
  139. package/dist/linkedin/company-extractor.test.js +52 -0
  140. package/dist/linkedin/company-extractor.test.js.map +1 -0
  141. package/dist/linkedin/connector.d.ts +45 -0
  142. package/dist/linkedin/connector.d.ts.map +1 -0
  143. package/dist/linkedin/connector.js +245 -0
  144. package/dist/linkedin/connector.js.map +1 -0
  145. package/dist/linkedin/message-sender.d.ts +32 -0
  146. package/dist/linkedin/message-sender.d.ts.map +1 -0
  147. package/dist/linkedin/message-sender.js +112 -0
  148. package/dist/linkedin/message-sender.js.map +1 -0
  149. package/dist/linkedin/messages.d.ts +78 -0
  150. package/dist/linkedin/messages.d.ts.map +1 -0
  151. package/dist/linkedin/messages.js +745 -0
  152. package/dist/linkedin/messages.js.map +1 -0
  153. package/dist/linkedin/profile.d.ts +37 -0
  154. package/dist/linkedin/profile.d.ts.map +1 -0
  155. package/dist/linkedin/profile.js +268 -0
  156. package/dist/linkedin/profile.js.map +1 -0
  157. package/dist/linkedin/profile.test.d.ts +2 -0
  158. package/dist/linkedin/profile.test.d.ts.map +1 -0
  159. package/dist/linkedin/profile.test.js +68 -0
  160. package/dist/linkedin/profile.test.js.map +1 -0
  161. package/dist/linkedin/reply.d.ts +21 -0
  162. package/dist/linkedin/reply.d.ts.map +1 -0
  163. package/dist/linkedin/reply.js +76 -0
  164. package/dist/linkedin/reply.js.map +1 -0
  165. package/dist/linkedin/selector-engine.d.ts +69 -0
  166. package/dist/linkedin/selector-engine.d.ts.map +1 -0
  167. package/dist/linkedin/selector-engine.js +339 -0
  168. package/dist/linkedin/selector-engine.js.map +1 -0
  169. package/dist/linkedin/selector-engine.test.d.ts +2 -0
  170. package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
  171. package/dist/linkedin/selector-engine.test.js +135 -0
  172. package/dist/linkedin/selector-engine.test.js.map +1 -0
  173. package/dist/linkedin/selectors.d.ts +65 -0
  174. package/dist/linkedin/selectors.d.ts.map +1 -0
  175. package/dist/linkedin/selectors.js +261 -0
  176. package/dist/linkedin/selectors.js.map +1 -0
  177. package/dist/templates/engine.d.ts +37 -0
  178. package/dist/templates/engine.d.ts.map +1 -0
  179. package/dist/templates/engine.js +215 -0
  180. package/dist/templates/engine.js.map +1 -0
  181. package/dist/templates/engine.test.d.ts +2 -0
  182. package/dist/templates/engine.test.d.ts.map +1 -0
  183. package/dist/templates/engine.test.js +212 -0
  184. package/dist/templates/engine.test.js.map +1 -0
  185. package/dist/templates/index.d.ts +2 -0
  186. package/dist/templates/index.d.ts.map +1 -0
  187. package/dist/templates/index.js +7 -0
  188. package/dist/templates/index.js.map +1 -0
  189. package/dist/types/index.d.ts +113 -0
  190. package/dist/types/index.d.ts.map +1 -0
  191. package/dist/types/index.js +3 -0
  192. package/dist/types/index.js.map +1 -0
  193. package/dist/types/index.test.d.ts +2 -0
  194. package/dist/types/index.test.d.ts.map +1 -0
  195. package/dist/types/index.test.js +90 -0
  196. package/dist/types/index.test.js.map +1 -0
  197. package/dist/utils/paths.d.ts +8 -0
  198. package/dist/utils/paths.d.ts.map +1 -0
  199. package/dist/utils/paths.js +68 -0
  200. package/dist/utils/paths.js.map +1 -0
  201. package/dist/utils/rate-limiter.d.ts +22 -0
  202. package/dist/utils/rate-limiter.d.ts.map +1 -0
  203. package/dist/utils/rate-limiter.js +57 -0
  204. package/dist/utils/rate-limiter.js.map +1 -0
  205. package/dist/utils/retry.d.ts +18 -0
  206. package/dist/utils/retry.d.ts.map +1 -0
  207. package/dist/utils/retry.js +49 -0
  208. package/dist/utils/retry.js.map +1 -0
  209. package/docs/connection-command.md +52 -0
  210. package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
  211. package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
  212. package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
  213. package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
  214. package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
  215. package/docs/plans/2026-02-28-messages-show-design.md +243 -0
  216. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
  217. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
  218. package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
  219. package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
  220. package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
  221. package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
  222. package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
  223. package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
  224. package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
  225. package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
  226. package/eslint.config.mjs +58 -0
  227. package/go.mod +9 -0
  228. package/go.sum +10 -0
  229. package/import-cookies.js +376 -0
  230. package/internal/cmd/actions.go +123 -0
  231. package/internal/cmd/auth.go +108 -0
  232. package/internal/cmd/connect.go +42 -0
  233. package/internal/cmd/message.go +44 -0
  234. package/internal/cmd/people.go +454 -0
  235. package/internal/cmd/profiles.go +121 -0
  236. package/internal/cmd/root.go +89 -0
  237. package/internal/cmd/sequence.go +192 -0
  238. package/internal/config/config.go +187 -0
  239. package/internal/config/config_test.go +121 -0
  240. package/internal/config/profile.go +65 -0
  241. package/internal/linkedin/navigator.go +195 -0
  242. package/internal/linkedin/selectors.go +39 -0
  243. package/internal/linkedin/validator.go +69 -0
  244. package/internal/pinchtab/client.go +183 -0
  245. package/internal/pinchtab/client_test.go +67 -0
  246. package/internal/pinchtab/types.go +50 -0
  247. package/internal/ratelimit/limiter.go +115 -0
  248. package/internal/ratelimit/limits.go +32 -0
  249. package/package.json +67 -0
  250. package/release.sh +66 -0
  251. package/scripts/debug-linkedin.js +156 -0
  252. package/scripts/debug-login.js +193 -0
  253. package/scripts/extract-from-edge.js +96 -0
  254. package/scripts/import-cookies.js +101 -0
  255. package/scripts/poc-show-data.js +205 -0
  256. package/scripts/proof-of-access.js +87 -0
  257. package/scripts/prove-connection.js +110 -0
  258. package/scripts/show-linkedin-data.js +173 -0
  259. package/src/agent/action-executor.test.ts +464 -0
  260. package/src/agent/action-executor.ts +203 -0
  261. package/src/agent/claude-client.test.ts +707 -0
  262. package/src/agent/claude-client.ts +422 -0
  263. package/src/agent/dom-extractor.test.ts +574 -0
  264. package/src/agent/dom-extractor.ts +437 -0
  265. package/src/agent/extension-client.ts +306 -0
  266. package/src/agent/index.ts +28 -0
  267. package/src/agent/page-agent.ts +292 -0
  268. package/src/agent/types.ts +288 -0
  269. package/src/cli/agent-commands.ts +274 -0
  270. package/src/cli/auth.ts +343 -0
  271. package/src/cli/company.ts +66 -0
  272. package/src/cli/connection.ts +89 -0
  273. package/src/cli/index.ts +7 -0
  274. package/src/cli/messages.ts +338 -0
  275. package/src/cli/profile.test.ts +14 -0
  276. package/src/cli/profile.ts +95 -0
  277. package/src/cli/reply.ts +110 -0
  278. package/src/core/audit.test.ts +134 -0
  279. package/src/core/audit.ts +98 -0
  280. package/src/core/browser-cookies.ts +203 -0
  281. package/src/core/browser.ts +304 -0
  282. package/src/core/config.test.ts +90 -0
  283. package/src/core/config.ts +81 -0
  284. package/src/core/storage.test.ts +129 -0
  285. package/src/core/storage.ts +100 -0
  286. package/src/index.ts +70 -0
  287. package/src/linkedin/auth.ts +218 -0
  288. package/src/linkedin/company-extractor.test.ts +58 -0
  289. package/src/linkedin/company-extractor.ts +222 -0
  290. package/src/linkedin/connector.ts +336 -0
  291. package/src/linkedin/message-sender.ts +141 -0
  292. package/src/linkedin/messages.ts +894 -0
  293. package/src/linkedin/profile.test.ts +79 -0
  294. package/src/linkedin/profile.ts +314 -0
  295. package/src/linkedin/reply.ts +96 -0
  296. package/src/linkedin/selector-engine.test.ts +167 -0
  297. package/src/linkedin/selector-engine.ts +393 -0
  298. package/src/linkedin/selectors.ts +268 -0
  299. package/src/templates/defaults/followup.txt +14 -0
  300. package/src/templates/defaults/meeting.txt +16 -0
  301. package/src/templates/defaults/welcome.txt +14 -0
  302. package/src/templates/engine.test.ts +228 -0
  303. package/src/templates/engine.ts +208 -0
  304. package/src/templates/index.ts +1 -0
  305. package/src/types/index.test.ts +94 -0
  306. package/src/types/index.ts +143 -0
  307. package/src/types/sql.js.d.ts +23 -0
  308. package/src/utils/paths.ts +33 -0
  309. package/src/utils/rate-limiter.ts +75 -0
  310. package/src/utils/retry.ts +78 -0
  311. package/test-cli.sh +85 -0
  312. package/test-real-data.sh +97 -0
  313. package/tsconfig.json +23 -0
  314. package/vitest.config.ts +35 -0
@@ -0,0 +1,894 @@
1
+ import type { Page, ElementHandle } from 'playwright';
2
+ import type { Message, Thread } from '../types';
3
+ import { SelectorEngine } from './selector-engine';
4
+ import { SELECTORS } from './selectors';
5
+ import type { BrowserController } from '../core/browser';
6
+
7
+ export interface MessageListOptions {
8
+ limit?: number;
9
+ unreadOnly?: boolean;
10
+ }
11
+
12
+ export interface ThreadOptions {
13
+ threadId: string;
14
+ limit?: number;
15
+ }
16
+
17
+ export class LinkedInMessages {
18
+ private page: Page;
19
+ private selectorEngine: SelectorEngine;
20
+
21
+ constructor(page: Page, _browser?: BrowserController) {
22
+ this.page = page;
23
+ this.selectorEngine = new SelectorEngine(page);
24
+ }
25
+
26
+ /**
27
+ * Navigate to LinkedIn messaging page
28
+ */
29
+ async navigateToMessages(): Promise<void> {
30
+ await this.page.goto('https://www.linkedin.com/messaging/', {
31
+ waitUntil: 'domcontentloaded',
32
+ timeout: 10000,
33
+ });
34
+ await this.page.waitForTimeout(1000);
35
+ }
36
+
37
+ /**
38
+ * Get list of conversation threads
39
+ */
40
+ async getThreads(options: MessageListOptions = {}): Promise<Thread[]> {
41
+ // Navigation is now handled by the caller - we're already on the messaging page
42
+
43
+ const limit = options.limit ?? 20;
44
+ const threads: Thread[] = [];
45
+
46
+ // Wait for conversation list to load
47
+ const listResult = await this.selectorEngine.findElement('messages', 'conversationList', {
48
+ timeout: 10000,
49
+ });
50
+
51
+ if (!listResult.element) {
52
+ console.warn('Could not find conversation list');
53
+ return threads;
54
+ }
55
+
56
+ // Get all conversation items using Playwright locators (more reliable than CSS selectors)
57
+ // LinkedIn uses dynamic class names, so we use multiple strategies
58
+
59
+ // Strategy 1: Look for li elements with msg-conversation-listitem class (the actual list items)
60
+ const listItems = await this.page.$$('[class*="msg-conversation-listitem"]');
61
+
62
+ // Get the conversation card div inside each list item
63
+ let conversationItems: ElementHandle[] = [];
64
+ for (const listItem of listItems) {
65
+ const card = await listItem.$('.msg-conversation-card');
66
+ if (card) {
67
+ conversationItems.push(card);
68
+ }
69
+ }
70
+
71
+ // Strategy 2: Try data-testid
72
+ if (conversationItems.length === 0) {
73
+ conversationItems = await this.page.$$('[data-testid="conversation-card"]');
74
+ }
75
+
76
+ // Strategy 3: Try by ID pattern (conversation-card-ember*)
77
+ if (conversationItems.length === 0) {
78
+ conversationItems = await this.page.$$('[id^="conversation-card-"]');
79
+ }
80
+
81
+ // Strategy 4: Try role-based listitem query
82
+ if (conversationItems.length === 0) {
83
+ const allListItems = await this.page.$$('[role="listitem"]');
84
+ for (const item of allListItems) {
85
+ const text = await item.evaluate((el) => el.textContent || '');
86
+ // Conversation cards typically have participant names and message previews
87
+ if (text.length > 5 && (text.includes(':') || text.match(/\d+[mhd]/))) {
88
+ conversationItems.push(item);
89
+ }
90
+ }
91
+ }
92
+
93
+ // Final fallback: get all children of the conversation list that look like cards
94
+ if (conversationItems.length === 0 && listResult.element) {
95
+ const allChildren = await listResult.element.$$('* > *');
96
+ for (const child of allChildren) {
97
+ const className = await child.evaluate((el) => {
98
+ // Handle both SVG and HTML elements
99
+ if ('className' in el && typeof el.className === 'string') {
100
+ return el.className;
101
+ }
102
+ // For SVG elements, className might be an object
103
+ if ('className' in el && el.className && 'baseVal' in el.className) {
104
+ return (el.className as any).baseVal;
105
+ }
106
+ return '';
107
+ });
108
+ // LinkedIn conversation cards typically have specific patterns
109
+ if (
110
+ className &&
111
+ (className.includes('msg-') ||
112
+ className.includes('conversation') ||
113
+ className.includes('artdeco'))
114
+ ) {
115
+ conversationItems.push(child);
116
+ }
117
+ }
118
+ }
119
+
120
+ for (let i = 0; i < Math.min(conversationItems.length, limit); i++) {
121
+ const item = conversationItems[i];
122
+ const thread = await this.parseThreadItem(item);
123
+
124
+ if (thread) {
125
+ // Filter by unread if requested
126
+ if (options.unreadOnly && thread.unreadCount === 0) {
127
+ continue;
128
+ }
129
+ threads.push(thread);
130
+ }
131
+ }
132
+
133
+ return threads;
134
+ }
135
+
136
+ /**
137
+ * Parse a single conversation item element into a Thread
138
+ */
139
+ private async parseThreadItem(item: ElementHandle): Promise<Thread | null> {
140
+ try {
141
+ // Extract thread ID from data attribute or URL
142
+ const threadId = await item.evaluate((el) => {
143
+ const element = el as Element;
144
+
145
+ // Strategy 1: Try to get from data-conversation-urn attribute
146
+ const dataId = element.getAttribute('data-conversation-urn');
147
+ if (dataId) return dataId;
148
+
149
+ // Strategy 2: Try data-urn variant
150
+ const dataUrn = element.getAttribute('data-urn');
151
+ if (dataUrn) {
152
+ const match = dataUrn.match(/thread:(\d+)/);
153
+ if (match) return match[1];
154
+ }
155
+
156
+ // Strategy 3: Look for data-event-urn in descendants (contains thread ID)
157
+ // Format: urn:li:msg_message:(urn:li:fsd_profile:...,2-<threadId>)
158
+ const eventUrnEl = element.querySelector('[data-event-urn]');
159
+ if (eventUrnEl) {
160
+ const eventUrn = eventUrnEl.getAttribute('data-event-urn');
161
+ if (eventUrn) {
162
+ // Extract thread ID from format: urn:li:msg_message:(...,2-<base64id>)
163
+ const match = eventUrn.match(/,2-([A-Za-z0-9+/=]+)/);
164
+ if (match) {
165
+ return `2-${match[1]}`;
166
+ }
167
+ }
168
+ }
169
+
170
+ // Strategy 4: Try to extract from link inside the element
171
+ const link = element.querySelector('a[href*="/messaging/thread/"]');
172
+ if (link) {
173
+ const href = link.getAttribute('href');
174
+ if (href) {
175
+ const match = href.match(/\/messaging\/thread\/([^/]+)/);
176
+ if (match) return match[1];
177
+ }
178
+ }
179
+
180
+ // Strategy 5: Try to get from parent link
181
+ const parentLink = element.closest('a[href*="/messaging/thread/"]');
182
+ if (parentLink) {
183
+ const href = parentLink.getAttribute('href');
184
+ if (href) {
185
+ const match = href.match(/\/messaging\/thread\/([^/]+)/);
186
+ if (match) return match[1];
187
+ }
188
+ }
189
+
190
+ // Strategy 6: Extract from element ID (conversation-card-ember50 -> ember50)
191
+ const elemId = element.id;
192
+ if (elemId && elemId.includes('conversation-card')) {
193
+ // Use the ember ID as a fallback
194
+ return elemId.replace('conversation-card-', '');
195
+ }
196
+
197
+ return null;
198
+ });
199
+
200
+ if (!threadId) {
201
+ return null;
202
+ }
203
+
204
+ // Extract participant names using multiple selector strategies
205
+ const participantNames = await this.extractTextArrayFromElement(item, [
206
+ '.msg-conversation-card__participant-names',
207
+ '.msg-conversation-listitem__participant-names',
208
+ '[class*="participant-name"]',
209
+ '[class*="participant"]',
210
+ ]);
211
+
212
+ // Fallback: get participant name from image alt attribute
213
+ if (participantNames.length === 0) {
214
+ const imgAlt = await item.evaluate((el: Element) => {
215
+ const img = el.querySelector('img[alt]');
216
+ return img?.getAttribute('alt') || '';
217
+ });
218
+ if (imgAlt) {
219
+ participantNames.push(imgAlt);
220
+ }
221
+ }
222
+
223
+ // Extract last message preview
224
+ const lastMessageText = await this.extractTextFromElementSingle(item, [
225
+ '.msg-conversation-card__message-snippet',
226
+ '.msg-conversation-card__message-preview',
227
+ '.msg-conversation-listitem__message-preview',
228
+ '[class*="message-snippet"]',
229
+ '[class*="message-preview"]',
230
+ '[class*="last-message"]',
231
+ ]);
232
+
233
+ // Check for unread indicator using multiple strategies
234
+ const hasUnread = await this.checkForUnreadIndicator(item);
235
+
236
+ // Get timestamp if available
237
+ const timestampText = await this.extractTextFromElementSingle(item, [
238
+ 'time',
239
+ '[class*="timestamp"]',
240
+ '[class*="time"]',
241
+ ]);
242
+
243
+ // Also try to get timestamp from datetime attribute
244
+ let lastActivity: Date;
245
+ const datetimeAttr = await item.evaluate((el: Element) => {
246
+ const timeEl = el.querySelector('time');
247
+ return timeEl?.getAttribute('datetime') || null;
248
+ });
249
+
250
+ if (datetimeAttr) {
251
+ lastActivity = new Date(datetimeAttr);
252
+ } else if (timestampText) {
253
+ lastActivity = this.parseRelativeTimestamp(timestampText);
254
+ } else {
255
+ lastActivity = new Date();
256
+ }
257
+
258
+ // Create partial thread (without full messages)
259
+ const thread: Thread = {
260
+ id: threadId,
261
+ participants:
262
+ participantNames.length > 0
263
+ ? participantNames.map((name) => ({ name, profileUrl: undefined }))
264
+ : [{ name: 'Unknown', profileUrl: undefined }],
265
+ messages: [
266
+ {
267
+ id: `preview-${threadId}`,
268
+ threadId,
269
+ sender: { name: participantNames[0] || 'Unknown', isMe: false },
270
+ content: lastMessageText,
271
+ timestamp: lastActivity,
272
+ isRead: !hasUnread,
273
+ },
274
+ ],
275
+ lastActivity,
276
+ unreadCount: hasUnread ? 1 : 0,
277
+ };
278
+
279
+ return thread;
280
+ } catch (error) {
281
+ console.warn('Failed to parse thread item:', error);
282
+ return null;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Extract multiple text values from an element - returns array of strings
288
+ */
289
+ private async extractTextArrayFromElement(
290
+ element: ElementHandle,
291
+ selectors: string[]
292
+ ): Promise<string[]> {
293
+ for (const selector of selectors) {
294
+ try {
295
+ const texts = await element.$$eval(selector, (els) =>
296
+ els.map((el) => el.textContent?.trim()).filter(Boolean)
297
+ );
298
+ if (texts.length > 0) return texts;
299
+ } catch {
300
+ // Try next selector
301
+ }
302
+ }
303
+ return [];
304
+ }
305
+
306
+ /**
307
+ * Extract single text value from an element - returns string
308
+ */
309
+ private async extractTextFromElementSingle(
310
+ element: ElementHandle,
311
+ selectors: string[]
312
+ ): Promise<string> {
313
+ for (const selector of selectors) {
314
+ try {
315
+ const text = await element.$eval(selector, (el) => el.textContent?.trim() || '');
316
+ if (text) return text;
317
+ } catch {
318
+ // Try next selector
319
+ }
320
+ }
321
+ return '';
322
+ }
323
+
324
+ /**
325
+ * Check if a conversation item has unread messages
326
+ */
327
+ private async checkForUnreadIndicator(item: ElementHandle): Promise<boolean> {
328
+ // Check for unread indicator element
329
+ const selectors = [
330
+ '.msg-conversation-card__unread-indicator',
331
+ '.msg-conversation-listitem__unread-indicator',
332
+ '[class*="unread"]',
333
+ ];
334
+
335
+ for (const selector of selectors) {
336
+ try {
337
+ const unreadEl = await item.$(selector);
338
+ if (unreadEl) return true;
339
+ } catch {
340
+ // Continue
341
+ }
342
+ }
343
+
344
+ // Check for bold/heavy font weight (unread messages often have bold text)
345
+ const fontWeight = await item.evaluate((el: Element) => {
346
+ const nameEl =
347
+ el.querySelector('[class*="participant-name"]') ||
348
+ el.querySelector('[class*="conversation-card__title"]');
349
+ if (nameEl) {
350
+ const style = window.getComputedStyle(nameEl);
351
+ return parseInt(style.fontWeight) >= 600; // semibold or bold
352
+ }
353
+ return false;
354
+ });
355
+
356
+ return fontWeight;
357
+ }
358
+
359
+ /**
360
+ * Parse relative timestamps like "5m", "2h", "Yesterday"
361
+ */
362
+ private parseRelativeTimestamp(text: string): Date {
363
+ const trimmed = text.trim().toLowerCase();
364
+ const now = new Date();
365
+
366
+ // Match patterns like "5m", "2h", "3d", "1w"
367
+ const relativeMatch = trimmed.match(/^(\d+)([mhdw])$/);
368
+ if (relativeMatch) {
369
+ const value = parseInt(relativeMatch[1]);
370
+ const unit = relativeMatch[2];
371
+
372
+ switch (unit) {
373
+ case 'm':
374
+ return new Date(now.getTime() - value * 60 * 1000);
375
+ case 'h':
376
+ return new Date(now.getTime() - value * 60 * 60 * 1000);
377
+ case 'd':
378
+ return new Date(now.getTime() - value * 24 * 60 * 60 * 1000);
379
+ case 'w':
380
+ return new Date(now.getTime() - value * 7 * 24 * 60 * 60 * 1000);
381
+ }
382
+ }
383
+
384
+ // Check for "Yesterday"
385
+ if (trimmed === 'yesterday') {
386
+ return new Date(now.getTime() - 24 * 60 * 60 * 1000);
387
+ }
388
+
389
+ // Fallback to current time
390
+ return now;
391
+ }
392
+
393
+ /**
394
+ * Get full thread with all messages
395
+ */
396
+ async getThread(options: ThreadOptions): Promise<Thread | null> {
397
+ const { threadId, limit = 50 } = options;
398
+
399
+ // Navigate to thread
400
+ await this.page.goto(`https://www.linkedin.com/messaging/thread/${threadId}/`, {
401
+ waitUntil: 'networkidle',
402
+ timeout: 30000,
403
+ });
404
+
405
+ // Wait for messages to load
406
+ const messagesResult = await this.selectorEngine.findElement('messages', 'messageBubble', {
407
+ timeout: 10000,
408
+ });
409
+
410
+ if (!messagesResult.element) {
411
+ console.warn('Could not find message bubbles');
412
+ return null;
413
+ }
414
+
415
+ // Get all message bubbles
416
+ const messageElements = await this.page.$$(SELECTORS.messages.messageBubble.join(', '));
417
+ const messages: Message[] = [];
418
+
419
+ for (let i = 0; i < Math.min(messageElements.length, limit); i++) {
420
+ const message = await this.parseMessage(
421
+ messageElements[i] as ElementHandle<HTMLElement>,
422
+ threadId
423
+ );
424
+ if (message) {
425
+ messages.push(message);
426
+ }
427
+ }
428
+
429
+ // Get participant info
430
+ const participants = await this.getThreadParticipants();
431
+
432
+ return {
433
+ id: threadId,
434
+ participants,
435
+ messages,
436
+ lastActivity: messages[messages.length - 1]?.timestamp || new Date(),
437
+ unreadCount: messages.filter((m) => !m.isRead).length,
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Show a thread by clicking on a conversation card (for ember IDs) or direct navigation
443
+ * Returns the thread with messages, handling both ember IDs and real thread IDs
444
+ */
445
+ async showThread(threadId: string, limit: number = 50): Promise<Thread | null> {
446
+ // Check if this is an ember ID (e.g., "ember50") or a real thread ID
447
+ const isEmberId = threadId.startsWith('ember');
448
+
449
+ if (isEmberId) {
450
+ // Navigate to messaging page first
451
+ await this.navigateToMessages();
452
+
453
+ // Find and click the conversation card to get the real thread ID
454
+ const realThreadId = await this.clickAndGetThreadId(threadId);
455
+ if (!realThreadId) {
456
+ console.warn(`Could not find conversation card with ID: ${threadId}`);
457
+ return null;
458
+ }
459
+ threadId = realThreadId;
460
+ } else {
461
+ // Check if already on the correct thread URL
462
+ const currentUrl = this.page.url();
463
+ const targetUrl = `https://www.linkedin.com/messaging/thread/${threadId}/`;
464
+
465
+ if (!currentUrl.includes(threadId)) {
466
+ // Navigate directly to the thread
467
+ await this.page.goto(targetUrl, {
468
+ waitUntil: 'domcontentloaded',
469
+ timeout: 30000,
470
+ });
471
+ } else {
472
+ console.log('Already on correct thread URL');
473
+ }
474
+ }
475
+
476
+ // Wait for messages to load
477
+ await this.page.waitForTimeout(2000);
478
+
479
+ // Scrape messages
480
+ const messages = await this.scrapeMessages(threadId, limit);
481
+ const participants = await this.getThreadParticipants();
482
+
483
+ if (messages.length === 0) {
484
+ console.warn('No messages found in thread');
485
+ return null;
486
+ }
487
+
488
+ return {
489
+ id: threadId,
490
+ participants,
491
+ messages,
492
+ lastActivity: messages[messages.length - 1]?.timestamp || new Date(),
493
+ unreadCount: messages.filter((m) => !m.isRead).length,
494
+ };
495
+ }
496
+
497
+ /**
498
+ * Click on a conversation card and extract the real thread ID from the URL
499
+ */
500
+ private async clickAndGetThreadId(emberId: string): Promise<string | null> {
501
+ try {
502
+ // Wait for conversation list to load
503
+ await this.page.waitForTimeout(3000);
504
+
505
+ // Strategy 1: Try to find the card by ID
506
+ let card = await this.page.$(`#conversation-card-${emberId}`);
507
+
508
+ // Strategy 2: Find by index using msg-conversation-card class
509
+ if (!card) {
510
+ const listItems = await this.page.$$('[class*="msg-conversation-listitem"]');
511
+ let conversationCards: ElementHandle<HTMLElement>[] = [];
512
+
513
+ for (const listItem of listItems) {
514
+ const c = await listItem.$('.msg-conversation-card');
515
+ if (c) conversationCards.push(c as ElementHandle<HTMLElement>);
516
+ }
517
+
518
+ // Fallback to direct class match
519
+ if (conversationCards.length === 0) {
520
+ conversationCards = (await this.page.$$(
521
+ '.msg-conversation-card'
522
+ )) as ElementHandle<HTMLElement>[];
523
+ }
524
+
525
+ // The ember ID number (e.g., ember50 -> 50) doesn't match DOM position
526
+ // We need to find the card that has this ember ID in its element ID
527
+ const targetEmberNumber = emberId.replace('ember', '');
528
+ for (let i = 0; i < conversationCards.length; i++) {
529
+ const cardId = await conversationCards[i].evaluate((el: Element) => el.id);
530
+ if (cardId && cardId.includes(targetEmberNumber)) {
531
+ card = conversationCards[i];
532
+ break;
533
+ }
534
+ }
535
+
536
+ // If still not found, try using the first card as a test
537
+ if (!card && conversationCards.length > 0) {
538
+ card = conversationCards[0];
539
+ }
540
+ }
541
+
542
+ // Strategy 3: Try data-testid
543
+ if (!card) {
544
+ const cards = await this.page.$$('[data-testid="conversation-card"]');
545
+ const index = parseInt(emberId.replace('ember', ''));
546
+ if (!isNaN(index) && cards.length > index) {
547
+ card = cards[index] as ElementHandle<HTMLElement>;
548
+ }
549
+ }
550
+
551
+ if (!card) {
552
+ return null;
553
+ }
554
+
555
+ // Click the card - LinkedIn SPA doesn't trigger navigation event
556
+ await card.click();
557
+
558
+ // Wait for URL to change (SPA navigation)
559
+ await this.page.waitForTimeout(2000);
560
+
561
+ // Extract thread ID from the new URL
562
+ const currentUrl = this.page.url();
563
+ const match = currentUrl.match(/\/messaging\/thread\/([^/]+)/);
564
+ if (match) {
565
+ return match[1];
566
+ }
567
+
568
+ return null;
569
+ } catch (error) {
570
+ console.warn('Failed to click conversation card:', error);
571
+ return null;
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Scrape messages from a loaded thread page
577
+ */
578
+ private async scrapeMessages(threadId: string, limit: number = 50): Promise<Message[]> {
579
+ const messages: Message[] = [];
580
+
581
+ // Wait for messages to load
582
+ await this.page.waitForTimeout(3000);
583
+
584
+ // Look for actual message content in the conversation
585
+ // LinkedIn uses event list items for messages
586
+ const messageElements = (await this.page.$$(
587
+ '.msg-s-event-listitem'
588
+ )) as ElementHandle<HTMLElement>[];
589
+
590
+ for (const msgEl of messageElements) {
591
+ if (messages.length >= limit) break;
592
+
593
+ // Get sender name
594
+ const senderName = await msgEl
595
+ .$eval('.msg-s-message-group__name', (el) => el.textContent?.trim() || '')
596
+ .catch(() => '');
597
+
598
+ // Get timestamp
599
+ let timestamp = new Date();
600
+ const datetimeAttr = await msgEl
601
+ .$eval('time', (el) => el.getAttribute('datetime'))
602
+ .catch(() => null);
603
+ if (datetimeAttr) {
604
+ timestamp = new Date(datetimeAttr);
605
+ }
606
+
607
+ // Get message content
608
+ const content = await msgEl
609
+ .$eval(
610
+ '.msg-s-event-listitem__body, [class*="event-listitem__body"]',
611
+ (el) => el.textContent?.trim() || ''
612
+ )
613
+ .catch(() => '');
614
+
615
+ // Skip if no content
616
+ if (!content || content.length < 2) continue;
617
+
618
+ // Skip UI noise
619
+ if (
620
+ content.includes('Open Emoji Keyboard') ||
621
+ content.includes('Maximize compose') ||
622
+ content.includes('Send to') ||
623
+ content === 'Download'
624
+ ) {
625
+ continue;
626
+ }
627
+
628
+ // Check if from current user
629
+ const isMe = await msgEl.evaluate((el) => {
630
+ return (
631
+ el.classList.contains('msg-s-message-group--me') ||
632
+ el.classList.contains('sent') ||
633
+ el.closest('.msg-s-message-group--me') !== null
634
+ );
635
+ });
636
+
637
+ messages.push({
638
+ id: `msg-${threadId}-${messages.length}`,
639
+ threadId,
640
+ sender: {
641
+ name: senderName || (isMe ? 'You' : 'Unknown'),
642
+ isMe,
643
+ },
644
+ content,
645
+ timestamp,
646
+ isRead: true,
647
+ });
648
+ }
649
+
650
+ // If no messages found with event list items, try alternative approach
651
+ if (messages.length === 0) {
652
+ const messageContainers = (await this.page.$$(
653
+ '[class*="message"]'
654
+ )) as ElementHandle<HTMLElement>[];
655
+ const seenContent = new Set<string>();
656
+
657
+ for (const container of messageContainers) {
658
+ if (messages.length >= limit) break;
659
+
660
+ const content = await container.evaluate((el) => {
661
+ // Skip meta elements
662
+ if (
663
+ el.classList.contains('msg-s-message-group__meta') ||
664
+ el.classList.contains('msg-s-message-group__name') ||
665
+ el.classList.contains('msg-s-message-group__timestamp') ||
666
+ el.tagName.toLowerCase() === 'time'
667
+ ) {
668
+ return '';
669
+ }
670
+
671
+ const text = el.textContent?.trim() || '';
672
+
673
+ // Skip short or UI text
674
+ if (
675
+ text.length < 5 ||
676
+ text.includes('Open Emoji Keyboard') ||
677
+ text.includes('Download') ||
678
+ text.includes('MB') ||
679
+ text.includes('AM') ||
680
+ text.includes('PM')
681
+ ) {
682
+ return '';
683
+ }
684
+
685
+ return text;
686
+ });
687
+
688
+ if (content && !seenContent.has(content)) {
689
+ seenContent.add(content);
690
+
691
+ const isMe = await container.evaluate((el) => {
692
+ return el.closest('.msg-s-message-group--me') !== null;
693
+ });
694
+
695
+ messages.push({
696
+ id: `msg-${threadId}-${messages.length}`,
697
+ threadId,
698
+ sender: {
699
+ name: isMe ? 'You' : 'Other',
700
+ isMe,
701
+ },
702
+ content,
703
+ timestamp: new Date(),
704
+ isRead: true,
705
+ });
706
+ }
707
+ }
708
+ }
709
+
710
+ return messages;
711
+ }
712
+
713
+ /**
714
+ * Format thread messages as human-readable text (Style A)
715
+ */
716
+ formatThreadText(thread: Thread): string {
717
+ const lines: string[] = [];
718
+ const participantNames = thread.participants.map((p) => p.name).join(', ');
719
+
720
+ lines.push(`Conversations with ${participantNames}`);
721
+ lines.push('─'.repeat(60));
722
+
723
+ let lastDate = '';
724
+
725
+ for (const message of thread.messages) {
726
+ const date = message.timestamp.toLocaleDateString();
727
+ if (date !== lastDate) {
728
+ lines.push('');
729
+ lines.push(`─── ${date} ───`);
730
+ lastDate = date;
731
+ }
732
+
733
+ const time = message.timestamp.toLocaleTimeString([], {
734
+ hour: '2-digit',
735
+ minute: '2-digit',
736
+ });
737
+ const sender = message.sender.isMe ? 'You' : message.sender.name;
738
+
739
+ lines.push(`${time} ${sender}: ${message.content}`);
740
+ }
741
+
742
+ return lines.join('\n');
743
+ }
744
+
745
+ /**
746
+ * Format thread messages as JSON for LLM processing
747
+ */
748
+ formatThreadJson(thread: Thread): string {
749
+ return JSON.stringify(
750
+ {
751
+ threadId: thread.id,
752
+ participants: thread.participants,
753
+ messages: thread.messages.map((m) => ({
754
+ id: m.id,
755
+ sender: {
756
+ name: m.sender.name,
757
+ isMe: m.sender.isMe,
758
+ },
759
+ content: m.content,
760
+ timestamp: m.timestamp.toISOString(),
761
+ })),
762
+ },
763
+ null,
764
+ 2
765
+ );
766
+ }
767
+
768
+ /**
769
+ * Parse a single message element
770
+ */
771
+ private async parseMessage(
772
+ element: ElementHandle<HTMLElement>,
773
+ threadId: string
774
+ ): Promise<Message | null> {
775
+ try {
776
+ // Extract message ID
777
+ const messageId = await element.evaluate((el) => {
778
+ const element = el as Element;
779
+ return (
780
+ element.getAttribute('data-urn') || element.id || `msg-${Date.now()}-${Math.random()}`
781
+ );
782
+ });
783
+
784
+ // Extract sender info - look for the sender name element
785
+ const senderInfo = await element
786
+ .$eval('.msg-s-message-group__name', (el) => ({
787
+ name: el.textContent?.trim() || '',
788
+ profileUrl: el.getAttribute('href') || undefined,
789
+ }))
790
+ .catch(() => ({ name: '', profileUrl: undefined }));
791
+
792
+ // Extract message content - look for the message body
793
+ let content = await element
794
+ .$eval('.msg-s-event-listitem__body', (el) => el.textContent?.trim() || '')
795
+ .catch(() => '');
796
+
797
+ // Fallback: try other content selectors
798
+ if (!content) {
799
+ content = await element
800
+ .$eval('.msg-s-message-group__message-body', (el) => el.textContent?.trim() || '')
801
+ .catch(() => '');
802
+ }
803
+
804
+ // Fallback: try any element that looks like message content
805
+ if (!content) {
806
+ const contentEl = await element.$('[class*="message-body"]');
807
+ if (contentEl) {
808
+ content = await contentEl.evaluate((el) => el.textContent?.trim() || '');
809
+ }
810
+ }
811
+
812
+ // Skip if no meaningful content
813
+ if (!content || content.length < 2) {
814
+ return null;
815
+ }
816
+
817
+ // Extract timestamp
818
+ let timestamp = new Date();
819
+ const datetimeAttr = await element
820
+ .$eval('time', (el) => el.getAttribute('datetime'))
821
+ .catch(() => null);
822
+
823
+ if (datetimeAttr) {
824
+ timestamp = new Date(datetimeAttr);
825
+ } else {
826
+ // Try to get timestamp from text content
827
+ const timeText = await element
828
+ .$eval('.msg-s-message-group__timestamp', (el) => el.textContent?.trim() || '')
829
+ .catch(() => '');
830
+
831
+ if (timeText) {
832
+ // Try to parse relative time
833
+ const now = new Date();
834
+ if (timeText.toLowerCase().includes('today')) {
835
+ timestamp = now;
836
+ } else if (timeText.toLowerCase().includes('yesterday')) {
837
+ timestamp = new Date(now.getTime() - 24 * 60 * 60 * 1000);
838
+ }
839
+ }
840
+ }
841
+
842
+ // Check if message is from current user
843
+ const isMe = await element.evaluate((el) => {
844
+ const element = el as Element;
845
+ return (
846
+ element.classList.contains('msg-s-message-group--me') ||
847
+ element.classList.contains('sent') ||
848
+ element.closest('.msg-s-message-group--me') !== null
849
+ );
850
+ });
851
+
852
+ return {
853
+ id: messageId,
854
+ threadId,
855
+ sender: {
856
+ name: senderInfo.name || (isMe ? 'You' : 'Unknown'),
857
+ profileUrl: senderInfo.profileUrl,
858
+ isMe,
859
+ },
860
+ content,
861
+ timestamp,
862
+ isRead: true,
863
+ };
864
+ } catch (error) {
865
+ console.warn('Failed to parse message:', error);
866
+ return null;
867
+ }
868
+ }
869
+
870
+ /**
871
+ * Get thread participant information
872
+ */
873
+ private async getThreadParticipants(): Promise<Array<{ name: string; profileUrl?: string }>> {
874
+ try {
875
+ // Look for participant names in the thread header
876
+ const participants = await this.page.$$eval(
877
+ '.msg-entity-lockup__entity-title, [class*="participant-name"], [class*="thread-title"]',
878
+ (elements) =>
879
+ elements.map((el) => ({
880
+ name: el.textContent?.trim() || 'Unknown',
881
+ profileUrl: el.getAttribute('href') || undefined,
882
+ }))
883
+ );
884
+
885
+ if (participants.length > 0) {
886
+ return participants;
887
+ }
888
+ } catch {
889
+ // Fall through to default
890
+ }
891
+
892
+ return [{ name: 'Unknown', profileUrl: undefined }];
893
+ }
894
+ }