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,98 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+
5
+ export interface AuditEntry {
6
+ timestamp: string;
7
+ action: string;
8
+ details: Record<string, unknown>;
9
+ success: boolean;
10
+ }
11
+
12
+ export class AuditLogger {
13
+ private logPath: string;
14
+ private consoleEnabled: boolean;
15
+
16
+ constructor() {
17
+ const dataDir = path.join(os.homedir(), '.linkedin-cli');
18
+ this.logPath = path.join(dataDir, 'audit.log');
19
+ this.consoleEnabled = process.env.DEBUG === 'true';
20
+ this.ensureLogDirectory();
21
+ }
22
+
23
+ private ensureLogDirectory(): void {
24
+ const dir = path.dirname(this.logPath);
25
+ if (!fs.existsSync(dir)) {
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ }
28
+ }
29
+
30
+ log(action: string, details: Record<string, unknown> = {}, success: boolean = true): void {
31
+ const entry: AuditEntry = {
32
+ timestamp: new Date().toISOString(),
33
+ action,
34
+ details,
35
+ success,
36
+ };
37
+
38
+ try {
39
+ const logLine = JSON.stringify(entry) + '\n';
40
+ fs.appendFileSync(this.logPath, logLine);
41
+ } catch (error) {
42
+ console.warn('Failed to write audit log:', error);
43
+ }
44
+
45
+ if (this.consoleEnabled) {
46
+ const status = success ? 'OK' : 'FAIL';
47
+ console.log('[AUDIT] ' + status + ' ' + action, details);
48
+ }
49
+ }
50
+
51
+ readRecent(count: number = 100): AuditEntry[] {
52
+ try {
53
+ if (!fs.existsSync(this.logPath)) {
54
+ return [];
55
+ }
56
+
57
+ const content = fs.readFileSync(this.logPath, 'utf-8');
58
+ const lines = content
59
+ .trim()
60
+ .split('\n')
61
+ .filter((line) => line.length > 0);
62
+
63
+ const entries: AuditEntry[] = [];
64
+ for (let i = Math.max(0, lines.length - count); i < lines.length; i++) {
65
+ try {
66
+ const entry = JSON.parse(lines[i]) as AuditEntry;
67
+ entries.push(entry);
68
+ } catch {
69
+ // Skip invalid lines
70
+ }
71
+ }
72
+
73
+ return entries;
74
+ } catch (error) {
75
+ console.error('Failed to read audit log:', error);
76
+ return [];
77
+ }
78
+ }
79
+
80
+ clear(): void {
81
+ try {
82
+ if (fs.existsSync(this.logPath)) {
83
+ fs.writeFileSync(this.logPath, '');
84
+ }
85
+ } catch (error) {
86
+ console.error('Failed to clear audit log:', error);
87
+ }
88
+ }
89
+ }
90
+
91
+ let auditLogger: AuditLogger | null = null;
92
+
93
+ export function getAuditLogger(): AuditLogger {
94
+ if (!auditLogger) {
95
+ auditLogger = new AuditLogger();
96
+ }
97
+ return auditLogger;
98
+ }
@@ -0,0 +1,203 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import initSqlJs, { Database } from 'sql.js';
5
+
6
+ export interface BrowserCookie {
7
+ name: string;
8
+ value: string;
9
+ domain: string;
10
+ path: string;
11
+ expires?: number;
12
+ httpOnly?: boolean;
13
+ secure?: boolean;
14
+ sameSite?: 'Strict' | 'Lax' | 'None';
15
+ }
16
+
17
+ interface BrowserProfile {
18
+ name: string;
19
+ cookiePath: string;
20
+ }
21
+
22
+ const BROWSER_PROFILES: Record<string, BrowserProfile[]> = {
23
+ chrome: [
24
+ {
25
+ name: 'Chrome Default',
26
+ cookiePath: path.join(
27
+ os.homedir(),
28
+ 'Library/Application Support/Google/Chrome/Default/Cookies'
29
+ ),
30
+ },
31
+ {
32
+ name: 'Chrome Profile 1',
33
+ cookiePath: path.join(
34
+ os.homedir(),
35
+ 'Library/Application Support/Google/Chrome/Profile 1/Cookies'
36
+ ),
37
+ },
38
+ ],
39
+ edge: [
40
+ {
41
+ name: 'Edge Default',
42
+ cookiePath: path.join(
43
+ os.homedir(),
44
+ 'Library/Application Support/Microsoft Edge/Default/Cookies'
45
+ ),
46
+ },
47
+ {
48
+ name: 'Edge Profile 1',
49
+ cookiePath: path.join(
50
+ os.homedir(),
51
+ 'Library/Application Support/Microsoft Edge/Profile 1/Cookies'
52
+ ),
53
+ },
54
+ ],
55
+ arc: [
56
+ {
57
+ name: 'Arc Default',
58
+ cookiePath: path.join(
59
+ os.homedir(),
60
+ 'Library/Application Support/Arc/User Data/Default/Cookies'
61
+ ),
62
+ },
63
+ ],
64
+ brave: [
65
+ {
66
+ name: 'Brave Default',
67
+ cookiePath: path.join(
68
+ os.homedir(),
69
+ 'Library/Application Support/BraveSoftware/Brave-Browser/Default/Cookies'
70
+ ),
71
+ },
72
+ ],
73
+ };
74
+
75
+ /**
76
+ * Copy cookie database to temp location (Chrome locks the original)
77
+ */
78
+ function copyCookieDatabase(sourcePath: string): string {
79
+ const tempPath = path.join(os.tmpdir(), `linkedin-cli-cookies-${Date.now()}.db`);
80
+
81
+ // Copy the file
82
+ fs.copyFileSync(sourcePath, tempPath);
83
+
84
+ return tempPath;
85
+ }
86
+
87
+ /**
88
+ * Extract LinkedIn cookies from browser's cookie database
89
+ */
90
+ export async function extractCookiesFromBrowser(browserName: string): Promise<BrowserCookie[]> {
91
+ const profiles = BROWSER_PROFILES[browserName.toLowerCase()];
92
+ if (!profiles) {
93
+ throw new Error(
94
+ `Unsupported browser: ${browserName}. Supported: ${Object.keys(BROWSER_PROFILES).join(', ')}`
95
+ );
96
+ }
97
+
98
+ // Find existing cookie database
99
+ let cookiePath: string | null = null;
100
+ let profileName: string | null = null;
101
+
102
+ for (const profile of profiles) {
103
+ if (fs.existsSync(profile.cookiePath)) {
104
+ cookiePath = profile.cookiePath;
105
+ profileName = profile.name;
106
+ break;
107
+ }
108
+ }
109
+
110
+ if (!cookiePath) {
111
+ throw new Error(
112
+ `Cookie database not found for ${browserName}. Make sure the browser is installed and you're logged into LinkedIn.`
113
+ );
114
+ }
115
+
116
+ console.log(`Found ${browserName} profile: ${profileName}`);
117
+
118
+ // Copy to temp location (original is locked by browser)
119
+ const tempPath = copyCookieDatabase(cookiePath);
120
+
121
+ try {
122
+ // Initialize sql.js
123
+ const SQL = await initSqlJs();
124
+
125
+ // Open the copied database
126
+ const fileBuffer = fs.readFileSync(tempPath);
127
+ const db: Database = new SQL.Database(fileBuffer);
128
+
129
+ // Check schema - older Chrome versions don't have same_site
130
+ const tableInfo = db.exec('PRAGMA table_info(cookies)');
131
+ const hasSameSite =
132
+ tableInfo.length > 0 && tableInfo[0].values.some((row: any[]) => row[1] === 'same_site');
133
+
134
+ // Query LinkedIn cookies (adapt to schema)
135
+ const query = hasSameSite
136
+ ? `SELECT name, value, host_key, path, expires_utc, is_secure, is_httponly, same_site FROM cookies WHERE host_key LIKE '%linkedin.com%'`
137
+ : `SELECT name, value, host_key, path, expires_utc, is_secure, is_httponly FROM cookies WHERE host_key LIKE '%linkedin.com%'`;
138
+
139
+ const rows = db.exec(query);
140
+
141
+ db.close();
142
+
143
+ if (rows.length === 0 || rows[0].values.length === 0) {
144
+ db.close();
145
+ return [];
146
+ }
147
+
148
+ const columns = rows[0].columns;
149
+ const values = rows[0].values;
150
+
151
+ return values.map((row: any[]) => {
152
+ const data: any = {};
153
+ columns.forEach((col: string, i: number) => {
154
+ data[col] = row[i];
155
+ });
156
+
157
+ return {
158
+ name: data.name as string,
159
+ value: data.value as string,
160
+ domain: (data.host_key as string).startsWith('.') ? data.host_key : `.${data.host_key}`,
161
+ path: data.path as string,
162
+ expires:
163
+ (data.expires_utc as number) > 0
164
+ ? Math.floor((data.expires_utc as number) / 1000000 - 11644473600)
165
+ : undefined,
166
+ httpOnly: (data.is_httponly as number) === 1,
167
+ secure: (data.is_secure as number) === 1,
168
+ sameSite:
169
+ data.same_site !== undefined
170
+ ? sameSiteFromValue(data.same_site as number)
171
+ : ('Lax' as const),
172
+ } as BrowserCookie;
173
+ });
174
+ } finally {
175
+ // Clean up temp file
176
+ if (fs.existsSync(tempPath)) {
177
+ fs.unlinkSync(tempPath);
178
+ }
179
+ }
180
+ }
181
+
182
+ function sameSiteFromValue(value: number): 'Strict' | 'Lax' | 'None' {
183
+ switch (value) {
184
+ case 1:
185
+ return 'Lax';
186
+ case 2:
187
+ return 'Strict';
188
+ case 3:
189
+ return 'None';
190
+ default:
191
+ return 'Lax';
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Extract LinkedIn cookies and return as Playwright-compatible format
197
+ */
198
+ export async function getLinkedinCookies(browserName: string): Promise<BrowserCookie[]> {
199
+ const cookies = await extractCookiesFromBrowser(browserName);
200
+
201
+ // Get all LinkedIn cookies - we need more than just auth cookies
202
+ return cookies.filter(() => true);
203
+ }
@@ -0,0 +1,304 @@
1
+ import { chromium, Browser, BrowserContext, Page, type LaunchOptions } from 'playwright';
2
+ import ora from 'ora';
3
+ import { getConfig } from './config';
4
+ import type { Session } from '../types';
5
+
6
+ export interface BrowserOptions {
7
+ headless?: boolean;
8
+ slowMo?: number;
9
+ viewport?: { width: number; height: number };
10
+ debug?: boolean;
11
+ }
12
+
13
+ export class BrowserController {
14
+ private browser: Browser | null = null;
15
+ private context: BrowserContext | null = null;
16
+ private page: Page | null = null;
17
+ private options: BrowserOptions;
18
+ private debugDir: string;
19
+ private isUsingExistingBrowser: boolean = false;
20
+
21
+ constructor(options: BrowserOptions = {}) {
22
+ const config = getConfig().get();
23
+ this.options = {
24
+ headless: options.headless ?? config.headless,
25
+ slowMo: options.slowMo ?? 100,
26
+ viewport: options.viewport ?? { width: 1280, height: 720 },
27
+ debug: options.debug ?? false,
28
+ };
29
+ this.debugDir = `${process.env.HOME || process.env.USERPROFILE || '.'}/.linkedin-cli/debug`;
30
+ }
31
+
32
+ async launch(): Promise<Page> {
33
+ const spinner = ora('Launching browser...').start();
34
+
35
+ try {
36
+ // Try to connect to existing Chrome/Edge via CDP first
37
+ const cdpBrowser = await this.connectToExistingBrowser();
38
+ if (cdpBrowser) {
39
+ spinner.succeed('Connected to existing browser');
40
+ return this.page!;
41
+ }
42
+
43
+ // Fall back to launching Chromium
44
+ const launchOptions: LaunchOptions = {
45
+ headless: this.options.headless,
46
+ slowMo: this.options.slowMo,
47
+ args: [
48
+ '--disable-blink-features=AutomationControlled',
49
+ '--disable-web-security',
50
+ '--disable-features=IsolateOrigins,site-per-process',
51
+ ],
52
+ };
53
+
54
+ this.browser = await chromium.launch(launchOptions);
55
+
56
+ this.context = await this.browser.newContext({
57
+ viewport: this.options.viewport,
58
+ userAgent:
59
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
60
+ extraHTTPHeaders: {
61
+ 'Accept-Language': 'en-US,en;q=0.9',
62
+ },
63
+ });
64
+
65
+ // Add stealth script to hide Playwright detection
66
+ await this.context.addInitScript(`
67
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
68
+ window.chrome = { runtime: {} };
69
+ const originalQuery = window.navigator.permissions.query;
70
+ window.navigator.permissions.query = (parameters) =>
71
+ parameters.name === 'notifications'
72
+ ? Promise.resolve({ state: Notification.permission })
73
+ : originalQuery(parameters);
74
+ `);
75
+
76
+ this.page = await this.context.newPage();
77
+ spinner.succeed('Browser launched');
78
+ return this.page;
79
+ } catch (error) {
80
+ spinner.fail('Failed to launch browser');
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ async restoreSession(session: Session): Promise<boolean> {
86
+ if (!this.context) throw new Error('Browser not launched');
87
+ try {
88
+ if (session.cookies?.length > 0) {
89
+ await this.context.addCookies(session.cookies);
90
+ }
91
+ await this.page?.evaluate((data) => {
92
+ Object.entries(data).forEach(([key, value]) =>
93
+ window.localStorage.setItem(key, value as string)
94
+ );
95
+ }, session.localStorage || {});
96
+ return true;
97
+ } catch (error) {
98
+ console.error('Failed to restore session:', error);
99
+ return false;
100
+ }
101
+ }
102
+
103
+ async saveSession(): Promise<Session | null> {
104
+ if (!this.context || !this.page) return null;
105
+ try {
106
+ const cookies = await this.context.cookies();
107
+ const localStorage = await this.page.evaluate(() => {
108
+ const data: Record<string, string> = {};
109
+ const storage = window.localStorage;
110
+ for (let i = 0; i < storage.length; i++) {
111
+ const key = storage.key(i);
112
+ if (key) data[key] = storage.getItem(key) || '';
113
+ }
114
+ return data;
115
+ });
116
+ return { cookies, localStorage, timestamp: new Date() };
117
+ } catch (error) {
118
+ console.error('Failed to save session:', error);
119
+ return null;
120
+ }
121
+ }
122
+
123
+ async close(): Promise<void> {
124
+ if (this.browser) {
125
+ if (this.isUsingExistingBrowser) {
126
+ // Don't close the browser - just disconnect from CDP
127
+ // The user's browser stays open with all cookies intact
128
+ console.log('Disconnecting from existing browser (keeping your session)');
129
+ this.browser = null;
130
+ this.context = null;
131
+ this.page = null;
132
+ } else {
133
+ // Close the browser we launched
134
+ await this.browser.close();
135
+ this.browser = null;
136
+ this.context = null;
137
+ this.page = null;
138
+ }
139
+ }
140
+ }
141
+
142
+ getPage(): Page | null {
143
+ return this.page;
144
+ }
145
+ isLaunched(): boolean {
146
+ return this.browser !== null && this.page !== null;
147
+ }
148
+ private async ensureDebugDir(): Promise<void> {
149
+ const fs = await import('fs/promises');
150
+ await fs.mkdir(this.debugDir, { recursive: true });
151
+ }
152
+
153
+ async takeScreenshot(stepName: string): Promise<void> {
154
+ if (!this.options.debug || !this.page) return;
155
+
156
+ try {
157
+ await this.ensureDebugDir();
158
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
159
+ const filename = `${this.debugDir}/${timestamp}-${stepName}.png`;
160
+ await this.page.screenshot({ path: filename, fullPage: true });
161
+ console.log(`[DEBUG] Screenshot saved: ${filename}`);
162
+ } catch (error) {
163
+ console.error('[DEBUG] Failed to save screenshot:', error);
164
+ }
165
+ }
166
+
167
+ async saveHtmlSnapshot(stepName: string): Promise<void> {
168
+ if (!this.options.debug || !this.page) return;
169
+
170
+ try {
171
+ await this.ensureDebugDir();
172
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
173
+ const filename = `${this.debugDir}/${timestamp}-${stepName}.html`;
174
+ const html = await this.page.content();
175
+ const fs = await import('fs/promises');
176
+ await fs.writeFile(filename, html, 'utf-8');
177
+ console.log(`[DEBUG] HTML snapshot saved: ${filename}`);
178
+ } catch (error) {
179
+ console.error('[DEBUG] Failed to save HTML snapshot:', error);
180
+ }
181
+ }
182
+
183
+ debugLog(message: string): void {
184
+ if (this.options.debug) {
185
+ console.log(`[DEBUG] ${message}`);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Try to connect to an existing Chrome/Edge browser via CDP
191
+ * Returns true if connected to existing browser with LinkedIn session
192
+ */
193
+ private async connectToExistingBrowser(): Promise<boolean> {
194
+ try {
195
+ // Try common CDP ports for Chrome and Edge
196
+ const ports = [9222, 9223, 9224, 9225];
197
+
198
+ for (const port of ports) {
199
+ try {
200
+ const response = await fetch(`http://localhost:${port}/json/version`);
201
+ if (response.ok) {
202
+ const data = await response.json();
203
+ console.log(`Found browser at port ${port}: ${data.Browser}`);
204
+
205
+ // Connect via CDP
206
+ this.browser = await chromium.connectOverCDP(`http://localhost:${port}`);
207
+
208
+ // Get the default context (first one is the user's actual browser session)
209
+ const contexts = this.browser.contexts();
210
+ this.context = contexts.length > 0 ? contexts[0] : await this.browser.newContext();
211
+
212
+ // Wait a moment for pages to be available
213
+ await new Promise((resolve) => setTimeout(resolve, 500));
214
+
215
+ // Find a page/tab that's already on LinkedIn
216
+ const pages = this.context.pages();
217
+ console.log(`Found ${pages.length} pages in browser context`);
218
+
219
+ if (pages.length > 0) {
220
+ // Check if any tab is on LinkedIn - use the first LinkedIn tab we find
221
+ // Support both linkedin.com and linkedin.cn (China's LinkedIn)
222
+ for (const pg of pages) {
223
+ try {
224
+ const url = pg.url();
225
+ console.log(` Page URL: ${url}`);
226
+ if (url.includes('linkedin.com') || url.includes('linkedin.cn')) {
227
+ console.log(`Found LinkedIn tab: ${url}`);
228
+ this.page = pg;
229
+ this.isUsingExistingBrowser = true;
230
+ return true; // Connected to existing LinkedIn session
231
+ }
232
+ } catch (e) {
233
+ // Skip pages that can't be accessed
234
+ console.log(` Page access error: ${e}`);
235
+ continue;
236
+ }
237
+ }
238
+ }
239
+
240
+ // No LinkedIn tab found, create a new tab in the existing context
241
+ console.log('Creating new tab in existing browser session...');
242
+ this.page = await this.context.newPage();
243
+ this.isUsingExistingBrowser = true;
244
+
245
+ return true; // Connected to browser (but may need to navigate to LinkedIn)
246
+ }
247
+ } catch {
248
+ // Port not available, try next
249
+ continue;
250
+ }
251
+ }
252
+ } catch (error) {
253
+ console.log('Could not connect to browser via CDP:', error);
254
+ }
255
+
256
+ return false;
257
+ }
258
+
259
+ /**
260
+ * Check if connected to existing browser via CDP
261
+ */
262
+ isUsingCDP(): boolean {
263
+ return this.browser !== null && this.page !== null && this.context !== null;
264
+ }
265
+
266
+ /**
267
+ * Check if the current page is on linkedin.cn (which has limited functionality)
268
+ * Returns true if on linkedin.cn, false if on linkedin.com or not on LinkedIn
269
+ */
270
+ isOnLinkedInCN(): boolean {
271
+ if (!this.page) return false;
272
+ try {
273
+ const currentUrl = this.page.url();
274
+ return currentUrl.includes('linkedin.cn');
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Navigate to LinkedIn in the current page
282
+ */
283
+ async ensureLinkedIn(): Promise<void> {
284
+ if (!this.page) throw new Error('Browser not launched');
285
+
286
+ const currentUrl = this.page.url();
287
+ if (currentUrl.includes('linkedin.com') || currentUrl.includes('linkedin.cn')) {
288
+ console.log('Already on LinkedIn:', currentUrl);
289
+ return;
290
+ }
291
+
292
+ console.log('Navigating to LinkedIn...');
293
+ await this.page.goto('https://www.linkedin.com/feed', {
294
+ waitUntil: 'domcontentloaded',
295
+ timeout: 30000,
296
+ });
297
+ }
298
+ }
299
+
300
+ export async function createBrowser(options?: BrowserOptions): Promise<BrowserController> {
301
+ const controller = new BrowserController(options);
302
+ await controller.launch();
303
+ return controller;
304
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+
6
+ import { ConfigManager, getConfig } from './config';
7
+
8
+ describe('ConfigManager', () => {
9
+ let configManager: ConfigManager;
10
+ const configPath = path.join(os.homedir(), '.linkedin-cli', 'config.json');
11
+
12
+ beforeEach(() => {
13
+ // Clean up before test
14
+ if (fs.existsSync(configPath)) {
15
+ fs.rmSync(configPath, { force: true });
16
+ }
17
+ configManager = new ConfigManager();
18
+ });
19
+
20
+ afterEach(() => {
21
+ // Clean up after test
22
+ if (fs.existsSync(configPath)) {
23
+ fs.rmSync(configPath, { force: true });
24
+ }
25
+ });
26
+
27
+ describe('initialization', () => {
28
+ it('should load with default values', () => {
29
+ const config = configManager.get();
30
+ expect(config.headless).toBe(true);
31
+ expect(config.rateLimit).toBe(5000);
32
+ expect(config.sessionTimeout).toBe(86400000);
33
+ expect(config.dryRun).toBe(false);
34
+ expect(config.dataDir).toContain('.linkedin-cli');
35
+ });
36
+ });
37
+
38
+ describe('get', () => {
39
+ it('should return a copy of config', () => {
40
+ const config1 = configManager.get();
41
+ const config2 = configManager.get();
42
+
43
+ // Should be equal but not the same reference
44
+ expect(config1).toEqual(config2);
45
+ expect(config1).not.toBe(config2);
46
+ });
47
+ });
48
+
49
+ describe('getValue', () => {
50
+ it('should return specific config value', () => {
51
+ expect(configManager.getValue('headless')).toBe(true);
52
+ expect(configManager.getValue('rateLimit')).toBe(5000);
53
+ });
54
+ });
55
+
56
+ describe('set', () => {
57
+ it('should set a single config value', () => {
58
+ configManager.set('headless', false);
59
+ expect(configManager.getValue('headless')).toBe(false);
60
+ });
61
+
62
+ it('should persist to file', () => {
63
+ configManager.set('headless', false);
64
+
65
+ // Create new instance to verify persistence
66
+ const newManager = new ConfigManager();
67
+ expect(newManager.getValue('headless')).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe('reset', () => {
72
+ it('should reset to default values', () => {
73
+ configManager.set('headless', false);
74
+ configManager.set('rateLimit', 10000);
75
+
76
+ configManager.reset();
77
+
78
+ expect(configManager.getValue('headless')).toBe(true);
79
+ expect(configManager.getValue('rateLimit')).toBe(5000);
80
+ });
81
+ });
82
+ });
83
+
84
+ describe('getConfig singleton', () => {
85
+ it('should return same instance on multiple calls', () => {
86
+ const config1 = getConfig();
87
+ const config2 = getConfig();
88
+ expect(config1).toBe(config2);
89
+ });
90
+ });