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.
- package/.env.example +12 -0
- package/.github/workflows/ci.yml +66 -0
- package/.github/workflows/publish.yml +48 -0
- package/.husky/pre-commit +6 -0
- package/.prettierignore +4 -0
- package/.prettierrc +10 -0
- package/AGENTS.md +294 -0
- package/CHANGELOG.md +40 -0
- package/GIT_RELEASE.md +167 -0
- package/LICENSE +21 -0
- package/Makefile +30 -0
- package/NPM_PUBLISHING.md +230 -0
- package/PYEOF +0 -0
- package/README.md +295 -0
- package/TESTING-GUIDE.md +151 -0
- package/cmd/linkedin/main.go +9 -0
- package/dist/agent/action-executor.d.ts +81 -0
- package/dist/agent/action-executor.d.ts.map +1 -0
- package/dist/agent/action-executor.js +170 -0
- package/dist/agent/action-executor.js.map +1 -0
- package/dist/agent/action-executor.test.d.ts +2 -0
- package/dist/agent/action-executor.test.d.ts.map +1 -0
- package/dist/agent/action-executor.test.js +366 -0
- package/dist/agent/action-executor.test.js.map +1 -0
- package/dist/agent/claude-client.d.ts +74 -0
- package/dist/agent/claude-client.d.ts.map +1 -0
- package/dist/agent/claude-client.js +314 -0
- package/dist/agent/claude-client.js.map +1 -0
- package/dist/agent/claude-client.test.d.ts +2 -0
- package/dist/agent/claude-client.test.d.ts.map +1 -0
- package/dist/agent/claude-client.test.js +590 -0
- package/dist/agent/claude-client.test.js.map +1 -0
- package/dist/agent/dom-extractor.d.ts +50 -0
- package/dist/agent/dom-extractor.d.ts.map +1 -0
- package/dist/agent/dom-extractor.js +374 -0
- package/dist/agent/dom-extractor.js.map +1 -0
- package/dist/agent/dom-extractor.test.d.ts +7 -0
- package/dist/agent/dom-extractor.test.d.ts.map +1 -0
- package/dist/agent/dom-extractor.test.js +504 -0
- package/dist/agent/dom-extractor.test.js.map +1 -0
- package/dist/agent/extension-client.d.ts +75 -0
- package/dist/agent/extension-client.d.ts.map +1 -0
- package/dist/agent/extension-client.js +245 -0
- package/dist/agent/extension-client.js.map +1 -0
- package/dist/agent/index.d.ts +8 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +16 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/page-agent.d.ts +76 -0
- package/dist/agent/page-agent.d.ts.map +1 -0
- package/dist/agent/page-agent.js +236 -0
- package/dist/agent/page-agent.js.map +1 -0
- package/dist/agent/types.d.ts +236 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +37 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/cli/agent-commands.d.ts +3 -0
- package/dist/cli/agent-commands.d.ts.map +1 -0
- package/dist/cli/agent-commands.js +250 -0
- package/dist/cli/agent-commands.js.map +1 -0
- package/dist/cli/auth.d.ts +3 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +288 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/company.d.ts +3 -0
- package/dist/cli/company.d.ts.map +1 -0
- package/dist/cli/company.js +55 -0
- package/dist/cli/company.js.map +1 -0
- package/dist/cli/connection.d.ts +3 -0
- package/dist/cli/connection.d.ts.map +1 -0
- package/dist/cli/connection.js +79 -0
- package/dist/cli/connection.js.map +1 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +17 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/messages.d.ts +3 -0
- package/dist/cli/messages.d.ts.map +1 -0
- package/dist/cli/messages.js +268 -0
- package/dist/cli/messages.js.map +1 -0
- package/dist/cli/profile.d.ts +3 -0
- package/dist/cli/profile.d.ts.map +1 -0
- package/dist/cli/profile.js +81 -0
- package/dist/cli/profile.js.map +1 -0
- package/dist/cli/profile.test.d.ts +2 -0
- package/dist/cli/profile.test.d.ts.map +1 -0
- package/dist/cli/profile.test.js +15 -0
- package/dist/cli/profile.test.js.map +1 -0
- package/dist/cli/reply.d.ts +3 -0
- package/dist/cli/reply.d.ts.map +1 -0
- package/dist/cli/reply.js +129 -0
- package/dist/cli/reply.js.map +1 -0
- package/dist/core/audit.d.ts +17 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +121 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/audit.test.d.ts +2 -0
- package/dist/core/audit.test.d.ts.map +1 -0
- package/dist/core/audit.test.js +142 -0
- package/dist/core/audit.test.js.map +1 -0
- package/dist/core/browser-cookies.d.ts +19 -0
- package/dist/core/browser-cookies.d.ts.map +1 -0
- package/dist/core/browser-cookies.js +181 -0
- package/dist/core/browser-cookies.js.map +1 -0
- package/dist/core/browser.d.ts +50 -0
- package/dist/core/browser.d.ts.map +1 -0
- package/dist/core/browser.js +318 -0
- package/dist/core/browser.js.map +1 -0
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +103 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/config.test.d.ts +2 -0
- package/dist/core/config.test.d.ts.map +1 -0
- package/dist/core/config.test.js +111 -0
- package/dist/core/config.test.js.map +1 -0
- package/dist/core/storage.d.ts +19 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +124 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/storage.test.d.ts +2 -0
- package/dist/core/storage.test.d.ts.map +1 -0
- package/dist/core/storage.test.js +142 -0
- package/dist/core/storage.test.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/linkedin/auth.d.ts +22 -0
- package/dist/linkedin/auth.d.ts.map +1 -0
- package/dist/linkedin/auth.js +167 -0
- package/dist/linkedin/auth.js.map +1 -0
- package/dist/linkedin/company-extractor.d.ts +36 -0
- package/dist/linkedin/company-extractor.d.ts.map +1 -0
- package/dist/linkedin/company-extractor.js +211 -0
- package/dist/linkedin/company-extractor.js.map +1 -0
- package/dist/linkedin/company-extractor.test.d.ts +2 -0
- package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
- package/dist/linkedin/company-extractor.test.js +52 -0
- package/dist/linkedin/company-extractor.test.js.map +1 -0
- package/dist/linkedin/connector.d.ts +45 -0
- package/dist/linkedin/connector.d.ts.map +1 -0
- package/dist/linkedin/connector.js +245 -0
- package/dist/linkedin/connector.js.map +1 -0
- package/dist/linkedin/message-sender.d.ts +32 -0
- package/dist/linkedin/message-sender.d.ts.map +1 -0
- package/dist/linkedin/message-sender.js +112 -0
- package/dist/linkedin/message-sender.js.map +1 -0
- package/dist/linkedin/messages.d.ts +78 -0
- package/dist/linkedin/messages.d.ts.map +1 -0
- package/dist/linkedin/messages.js +745 -0
- package/dist/linkedin/messages.js.map +1 -0
- package/dist/linkedin/profile.d.ts +37 -0
- package/dist/linkedin/profile.d.ts.map +1 -0
- package/dist/linkedin/profile.js +268 -0
- package/dist/linkedin/profile.js.map +1 -0
- package/dist/linkedin/profile.test.d.ts +2 -0
- package/dist/linkedin/profile.test.d.ts.map +1 -0
- package/dist/linkedin/profile.test.js +68 -0
- package/dist/linkedin/profile.test.js.map +1 -0
- package/dist/linkedin/reply.d.ts +21 -0
- package/dist/linkedin/reply.d.ts.map +1 -0
- package/dist/linkedin/reply.js +76 -0
- package/dist/linkedin/reply.js.map +1 -0
- package/dist/linkedin/selector-engine.d.ts +69 -0
- package/dist/linkedin/selector-engine.d.ts.map +1 -0
- package/dist/linkedin/selector-engine.js +339 -0
- package/dist/linkedin/selector-engine.js.map +1 -0
- package/dist/linkedin/selector-engine.test.d.ts +2 -0
- package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
- package/dist/linkedin/selector-engine.test.js +135 -0
- package/dist/linkedin/selector-engine.test.js.map +1 -0
- package/dist/linkedin/selectors.d.ts +65 -0
- package/dist/linkedin/selectors.d.ts.map +1 -0
- package/dist/linkedin/selectors.js +261 -0
- package/dist/linkedin/selectors.js.map +1 -0
- package/dist/templates/engine.d.ts +37 -0
- package/dist/templates/engine.d.ts.map +1 -0
- package/dist/templates/engine.js +215 -0
- package/dist/templates/engine.js.map +1 -0
- package/dist/templates/engine.test.d.ts +2 -0
- package/dist/templates/engine.test.d.ts.map +1 -0
- package/dist/templates/engine.test.js +212 -0
- package/dist/templates/engine.test.js.map +1 -0
- package/dist/templates/index.d.ts +2 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +7 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/types/index.d.ts +113 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/index.test.d.ts +2 -0
- package/dist/types/index.test.d.ts.map +1 -0
- package/dist/types/index.test.js +90 -0
- package/dist/types/index.test.js.map +1 -0
- package/dist/utils/paths.d.ts +8 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +68 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +22 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +57 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/dist/utils/retry.d.ts +18 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +49 -0
- package/dist/utils/retry.js.map +1 -0
- package/docs/connection-command.md +52 -0
- package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
- package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
- package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
- package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
- package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
- package/docs/plans/2026-02-28-messages-show-design.md +243 -0
- package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
- package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
- package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
- package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
- package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
- package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
- package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
- package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
- package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
- package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
- package/eslint.config.mjs +58 -0
- package/go.mod +9 -0
- package/go.sum +10 -0
- package/import-cookies.js +376 -0
- package/internal/cmd/actions.go +123 -0
- package/internal/cmd/auth.go +108 -0
- package/internal/cmd/connect.go +42 -0
- package/internal/cmd/message.go +44 -0
- package/internal/cmd/people.go +454 -0
- package/internal/cmd/profiles.go +121 -0
- package/internal/cmd/root.go +89 -0
- package/internal/cmd/sequence.go +192 -0
- package/internal/config/config.go +187 -0
- package/internal/config/config_test.go +121 -0
- package/internal/config/profile.go +65 -0
- package/internal/linkedin/navigator.go +195 -0
- package/internal/linkedin/selectors.go +39 -0
- package/internal/linkedin/validator.go +69 -0
- package/internal/pinchtab/client.go +183 -0
- package/internal/pinchtab/client_test.go +67 -0
- package/internal/pinchtab/types.go +50 -0
- package/internal/ratelimit/limiter.go +115 -0
- package/internal/ratelimit/limits.go +32 -0
- package/package.json +67 -0
- package/release.sh +66 -0
- package/scripts/debug-linkedin.js +156 -0
- package/scripts/debug-login.js +193 -0
- package/scripts/extract-from-edge.js +96 -0
- package/scripts/import-cookies.js +101 -0
- package/scripts/poc-show-data.js +205 -0
- package/scripts/proof-of-access.js +87 -0
- package/scripts/prove-connection.js +110 -0
- package/scripts/show-linkedin-data.js +173 -0
- package/src/agent/action-executor.test.ts +464 -0
- package/src/agent/action-executor.ts +203 -0
- package/src/agent/claude-client.test.ts +707 -0
- package/src/agent/claude-client.ts +422 -0
- package/src/agent/dom-extractor.test.ts +574 -0
- package/src/agent/dom-extractor.ts +437 -0
- package/src/agent/extension-client.ts +306 -0
- package/src/agent/index.ts +28 -0
- package/src/agent/page-agent.ts +292 -0
- package/src/agent/types.ts +288 -0
- package/src/cli/agent-commands.ts +274 -0
- package/src/cli/auth.ts +343 -0
- package/src/cli/company.ts +66 -0
- package/src/cli/connection.ts +89 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/messages.ts +338 -0
- package/src/cli/profile.test.ts +14 -0
- package/src/cli/profile.ts +95 -0
- package/src/cli/reply.ts +110 -0
- package/src/core/audit.test.ts +134 -0
- package/src/core/audit.ts +98 -0
- package/src/core/browser-cookies.ts +203 -0
- package/src/core/browser.ts +304 -0
- package/src/core/config.test.ts +90 -0
- package/src/core/config.ts +81 -0
- package/src/core/storage.test.ts +129 -0
- package/src/core/storage.ts +100 -0
- package/src/index.ts +70 -0
- package/src/linkedin/auth.ts +218 -0
- package/src/linkedin/company-extractor.test.ts +58 -0
- package/src/linkedin/company-extractor.ts +222 -0
- package/src/linkedin/connector.ts +336 -0
- package/src/linkedin/message-sender.ts +141 -0
- package/src/linkedin/messages.ts +894 -0
- package/src/linkedin/profile.test.ts +79 -0
- package/src/linkedin/profile.ts +314 -0
- package/src/linkedin/reply.ts +96 -0
- package/src/linkedin/selector-engine.test.ts +167 -0
- package/src/linkedin/selector-engine.ts +393 -0
- package/src/linkedin/selectors.ts +268 -0
- package/src/templates/defaults/followup.txt +14 -0
- package/src/templates/defaults/meeting.txt +16 -0
- package/src/templates/defaults/welcome.txt +14 -0
- package/src/templates/engine.test.ts +228 -0
- package/src/templates/engine.ts +208 -0
- package/src/templates/index.ts +1 -0
- package/src/types/index.test.ts +94 -0
- package/src/types/index.ts +143 -0
- package/src/types/sql.js.d.ts +23 -0
- package/src/utils/paths.ts +33 -0
- package/src/utils/rate-limiter.ts +75 -0
- package/src/utils/retry.ts +78 -0
- package/test-cli.sh +85 -0
- package/test-real-data.sh +97 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +35 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
|
|
5
|
+
export interface Config {
|
|
6
|
+
headless: boolean;
|
|
7
|
+
rateLimit: number;
|
|
8
|
+
sessionTimeout: number;
|
|
9
|
+
dryRun: boolean;
|
|
10
|
+
dataDir: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG: Config = {
|
|
14
|
+
headless: true,
|
|
15
|
+
rateLimit: 5000,
|
|
16
|
+
sessionTimeout: 86400000,
|
|
17
|
+
dryRun: false,
|
|
18
|
+
dataDir: path.join(os.homedir(), '.linkedin-cli'),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class ConfigManager {
|
|
22
|
+
private config: Config;
|
|
23
|
+
private configPath: string;
|
|
24
|
+
|
|
25
|
+
constructor() {
|
|
26
|
+
this.configPath = path.join(os.homedir(), '.linkedin-cli', 'config.json');
|
|
27
|
+
this.config = this.loadConfig();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private loadConfig(): Config {
|
|
31
|
+
try {
|
|
32
|
+
if (fs.existsSync(this.configPath)) {
|
|
33
|
+
const data = fs.readFileSync(this.configPath, 'utf-8');
|
|
34
|
+
const parsed = JSON.parse(data);
|
|
35
|
+
return { ...DEFAULT_CONFIG, ...parsed };
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.warn('Failed to load config, using defaults:', error);
|
|
39
|
+
}
|
|
40
|
+
return { ...DEFAULT_CONFIG };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private saveConfig(): void {
|
|
44
|
+
try {
|
|
45
|
+
const dir = path.dirname(this.configPath);
|
|
46
|
+
if (!fs.existsSync(dir)) {
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('Failed to save config:', error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get(): Config {
|
|
56
|
+
return { ...this.config };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getValue<K extends keyof Config>(key: K): Config[K] {
|
|
60
|
+
return this.config[key];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
set<K extends keyof Config>(key: K, value: Config[K]): void {
|
|
64
|
+
this.config[key] = value;
|
|
65
|
+
this.saveConfig();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
reset(): void {
|
|
69
|
+
this.config = { ...DEFAULT_CONFIG };
|
|
70
|
+
this.saveConfig();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let configManager: ConfigManager | null = null;
|
|
75
|
+
|
|
76
|
+
export function getConfig(): ConfigManager {
|
|
77
|
+
if (!configManager) {
|
|
78
|
+
configManager = new ConfigManager();
|
|
79
|
+
}
|
|
80
|
+
return configManager;
|
|
81
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
|
|
6
|
+
// Mock the paths module
|
|
7
|
+
vi.mock('../utils/paths', () => {
|
|
8
|
+
const testDir = path.join(os.tmpdir(), 'linkedin-cli-test');
|
|
9
|
+
return {
|
|
10
|
+
getConfigDir: () => testDir,
|
|
11
|
+
getSessionsDir: () => path.join(testDir, 'sessions'),
|
|
12
|
+
getTemplatesDir: () => path.join(testDir, 'templates'),
|
|
13
|
+
getDefaultsDir: () => path.join(testDir, 'templates', 'defaults'),
|
|
14
|
+
getCustomDir: () => path.join(testDir, 'templates', 'custom'),
|
|
15
|
+
getConfigFile: () => path.join(testDir, 'config.json'),
|
|
16
|
+
getLogFile: () => path.join(testDir, 'audit.log'),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import { SecureStorage, encrypt, decrypt } from './storage';
|
|
21
|
+
|
|
22
|
+
describe('SecureStorage', () => {
|
|
23
|
+
let storage: SecureStorage;
|
|
24
|
+
const testDir = path.join(os.tmpdir(), 'linkedin-cli-test');
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
// Clean up test directory
|
|
28
|
+
if (fs.existsSync(testDir)) {
|
|
29
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
storage = new SecureStorage();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
// Clean up after tests
|
|
36
|
+
if (fs.existsSync(testDir)) {
|
|
37
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('save and load', () => {
|
|
42
|
+
it('should save and retrieve data', () => {
|
|
43
|
+
const testData = 'test-secret-data';
|
|
44
|
+
storage.save('test-key', testData);
|
|
45
|
+
|
|
46
|
+
const loaded = storage.load('test-key');
|
|
47
|
+
expect(loaded).toBe(testData);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should return null for non-existent key', () => {
|
|
51
|
+
const loaded = storage.load('non-existent-key');
|
|
52
|
+
expect(loaded).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should overwrite existing key', () => {
|
|
56
|
+
storage.save('test-key', 'first-value');
|
|
57
|
+
storage.save('test-key', 'second-value');
|
|
58
|
+
|
|
59
|
+
const loaded = storage.load('test-key');
|
|
60
|
+
expect(loaded).toBe('second-value');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('delete', () => {
|
|
65
|
+
it('should delete existing key and return true', () => {
|
|
66
|
+
storage.save('test-key', 'test-data');
|
|
67
|
+
const deleted = storage.delete('test-key');
|
|
68
|
+
|
|
69
|
+
expect(deleted).toBe(true);
|
|
70
|
+
expect(storage.load('test-key')).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should return false for non-existent key', () => {
|
|
74
|
+
const deleted = storage.delete('non-existent-key');
|
|
75
|
+
expect(deleted).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('exists', () => {
|
|
80
|
+
it('should return true for existing key', () => {
|
|
81
|
+
storage.save('test-key', 'test-data');
|
|
82
|
+
expect(storage.exists('test-key')).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should return false for non-existent key', () => {
|
|
86
|
+
expect(storage.exists('non-existent-key')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('encryption', () => {
|
|
92
|
+
it('should encrypt and decrypt data correctly', () => {
|
|
93
|
+
const originalData = 'sensitive-information';
|
|
94
|
+
const encrypted = encrypt(originalData);
|
|
95
|
+
|
|
96
|
+
expect(encrypted).toHaveProperty('encrypted');
|
|
97
|
+
expect(encrypted).toHaveProperty('iv');
|
|
98
|
+
expect(encrypted).toHaveProperty('salt');
|
|
99
|
+
expect(encrypted).toHaveProperty('tag');
|
|
100
|
+
|
|
101
|
+
const decrypted = decrypt(encrypted);
|
|
102
|
+
expect(decrypted).toBe(originalData);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should produce different encrypted output each time', () => {
|
|
106
|
+
const data = 'test-data';
|
|
107
|
+
const encrypted1 = encrypt(data);
|
|
108
|
+
const encrypted2 = encrypt(data);
|
|
109
|
+
|
|
110
|
+
// Salt should be different (random)
|
|
111
|
+
expect(encrypted1.salt).not.toBe(encrypted2.salt);
|
|
112
|
+
// But both should decrypt to the same value
|
|
113
|
+
expect(decrypt(encrypted1)).toBe(data);
|
|
114
|
+
expect(decrypt(encrypted2)).toBe(data);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should throw on tampered data', () => {
|
|
118
|
+
const originalData = 'important-data';
|
|
119
|
+
const encrypted = encrypt(originalData);
|
|
120
|
+
|
|
121
|
+
// Tamper with the encrypted data
|
|
122
|
+
const tampered = {
|
|
123
|
+
...encrypted,
|
|
124
|
+
encrypted: encrypted.encrypted.split('').reverse().join(''),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
expect(() => decrypt(tampered)).toThrow();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as crypto from 'crypto';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { getSessionsDir } from '../utils/paths';
|
|
5
|
+
|
|
6
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
7
|
+
const KEY_LENGTH = 32;
|
|
8
|
+
const IV_LENGTH = 16;
|
|
9
|
+
const SALT_LENGTH = 32;
|
|
10
|
+
|
|
11
|
+
function deriveKey(salt: Buffer): Buffer {
|
|
12
|
+
const machineData = [
|
|
13
|
+
process.env.USER || process.env.USERNAME || 'unknown',
|
|
14
|
+
process.env.HOME || process.env.USERPROFILE || 'unknown',
|
|
15
|
+
process.platform,
|
|
16
|
+
].join('|');
|
|
17
|
+
return crypto.pbkdf2Sync(machineData, salt, 100000, KEY_LENGTH, 'sha256');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EncryptedData {
|
|
21
|
+
encrypted: string;
|
|
22
|
+
iv: string;
|
|
23
|
+
salt: string;
|
|
24
|
+
tag: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function encrypt(data: string): EncryptedData {
|
|
28
|
+
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
29
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
30
|
+
const key = deriveKey(salt);
|
|
31
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
32
|
+
const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
|
|
33
|
+
const tag = cipher.getAuthTag();
|
|
34
|
+
return {
|
|
35
|
+
encrypted: encrypted.toString('base64'),
|
|
36
|
+
iv: iv.toString('base64'),
|
|
37
|
+
salt: salt.toString('base64'),
|
|
38
|
+
tag: tag.toString('base64'),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decrypt(data: EncryptedData): string {
|
|
43
|
+
const salt = Buffer.from(data.salt, 'base64');
|
|
44
|
+
const iv = Buffer.from(data.iv, 'base64');
|
|
45
|
+
const key = deriveKey(salt);
|
|
46
|
+
const encrypted = Buffer.from(data.encrypted, 'base64');
|
|
47
|
+
const tag = Buffer.from(data.tag, 'base64');
|
|
48
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
49
|
+
decipher.setAuthTag(tag);
|
|
50
|
+
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
51
|
+
return decrypted.toString('utf8');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SecureStorage {
|
|
55
|
+
private baseDir: string;
|
|
56
|
+
|
|
57
|
+
constructor() {
|
|
58
|
+
this.baseDir = getSessionsDir();
|
|
59
|
+
this.ensureDir();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private ensureDir(): void {
|
|
63
|
+
if (!fs.existsSync(this.baseDir)) {
|
|
64
|
+
fs.mkdirSync(this.baseDir, { recursive: true, mode: 0o700 });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
save(key: string, data: string): void {
|
|
69
|
+
const encrypted = encrypt(data);
|
|
70
|
+
const filePath = path.join(this.baseDir, `${key}.json`);
|
|
71
|
+
fs.writeFileSync(filePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
load(key: string): string | null {
|
|
75
|
+
const filePath = path.join(this.baseDir, `${key}.json`);
|
|
76
|
+
if (!fs.existsSync(filePath)) return null;
|
|
77
|
+
const encrypted = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
78
|
+
return decrypt(encrypted);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
delete(key: string): boolean {
|
|
82
|
+
const filePath = path.join(this.baseDir, `${key}.json`);
|
|
83
|
+
if (fs.existsSync(filePath)) {
|
|
84
|
+
fs.unlinkSync(filePath);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
exists(key: string): boolean {
|
|
91
|
+
const filePath = path.join(this.baseDir, `${key}.json`);
|
|
92
|
+
return fs.existsSync(filePath);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let secureStorage: SecureStorage | null = null;
|
|
97
|
+
export function getSecureStorage(): SecureStorage {
|
|
98
|
+
if (!secureStorage) secureStorage = new SecureStorage();
|
|
99
|
+
return secureStorage;
|
|
100
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import {
|
|
5
|
+
registerAuthCommands,
|
|
6
|
+
registerMessageCommands,
|
|
7
|
+
registerReplyCommands,
|
|
8
|
+
registerConnectionCommands,
|
|
9
|
+
registerCompanyCommands,
|
|
10
|
+
registerProfileCommands,
|
|
11
|
+
} from './cli';
|
|
12
|
+
import { getConfig } from './core/config';
|
|
13
|
+
|
|
14
|
+
const packageJson = require('../package.json');
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.name('linkedin-cli')
|
|
18
|
+
.description('LinkedIn automation CLI for sales representatives')
|
|
19
|
+
.version(packageJson.version);
|
|
20
|
+
|
|
21
|
+
// Register command groups
|
|
22
|
+
registerAuthCommands(program);
|
|
23
|
+
registerMessageCommands(program);
|
|
24
|
+
registerReplyCommands(program);
|
|
25
|
+
registerConnectionCommands(program);
|
|
26
|
+
registerCompanyCommands(program);
|
|
27
|
+
registerProfileCommands(program);
|
|
28
|
+
|
|
29
|
+
// Config command
|
|
30
|
+
program
|
|
31
|
+
.command('config')
|
|
32
|
+
.description('Manage configuration')
|
|
33
|
+
.option('--get', 'Show current configuration')
|
|
34
|
+
.option('--set <key=value>', 'Set a configuration value')
|
|
35
|
+
.action((options) => {
|
|
36
|
+
const config = getConfig();
|
|
37
|
+
|
|
38
|
+
if (options.get || !options.set) {
|
|
39
|
+
console.log(chalk.bold('Current Configuration:'));
|
|
40
|
+
console.log(JSON.stringify(config.get(), null, 2));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (options.set) {
|
|
45
|
+
const [key, value] = options.set.split('=');
|
|
46
|
+
if (!key || value === undefined) {
|
|
47
|
+
console.error(chalk.red('Invalid format. Use: --set key=value'));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Try to parse as boolean or number
|
|
52
|
+
let parsedValue: string | boolean | number = value;
|
|
53
|
+
if (value === 'true') parsedValue = true;
|
|
54
|
+
else if (value === 'false') parsedValue = false;
|
|
55
|
+
else if (!isNaN(Number(value))) parsedValue = Number(value);
|
|
56
|
+
|
|
57
|
+
config.set(key as any, parsedValue as any);
|
|
58
|
+
console.log(chalk.green(`✓ Set ${key} = ${parsedValue}`));
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Global error handler
|
|
63
|
+
program.exitOverride();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
program.parse();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { BrowserController } from '../core/browser';
|
|
2
|
+
import type { Session } from '../types';
|
|
3
|
+
import { SELECTORS } from './selectors';
|
|
4
|
+
|
|
5
|
+
export interface LoginCredentials {
|
|
6
|
+
email: string;
|
|
7
|
+
password: string;
|
|
8
|
+
totpCode?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LoginResult {
|
|
12
|
+
success: boolean;
|
|
13
|
+
session?: Session;
|
|
14
|
+
error?: string;
|
|
15
|
+
requires2FA?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class LinkedInAuth {
|
|
19
|
+
private browser: BrowserController;
|
|
20
|
+
private debug: boolean;
|
|
21
|
+
|
|
22
|
+
constructor(browser: BrowserController, debug: boolean = false) {
|
|
23
|
+
this.browser = browser;
|
|
24
|
+
this.debug = debug;
|
|
25
|
+
}
|
|
26
|
+
private async findElementWithFallbacks(
|
|
27
|
+
selectors: readonly string[],
|
|
28
|
+
elementName: string
|
|
29
|
+
): Promise<{
|
|
30
|
+
element: Awaited<ReturnType<import('playwright').Page['$']>>;
|
|
31
|
+
selectorUsed: string;
|
|
32
|
+
} | null> {
|
|
33
|
+
const page = this.browser.getPage();
|
|
34
|
+
if (!page) return null;
|
|
35
|
+
|
|
36
|
+
for (const selector of selectors) {
|
|
37
|
+
try {
|
|
38
|
+
const element = await page.$(selector);
|
|
39
|
+
if (element) {
|
|
40
|
+
console.log(`Found ${elementName} using selector: ${selector}`);
|
|
41
|
+
if (this.debug) this.browser.debugLog(`Found ${elementName} using selector: ${selector}`);
|
|
42
|
+
return { element, selectorUsed: selector };
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Continue to next selector
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async login(credentials: LoginCredentials): Promise<LoginResult> {
|
|
53
|
+
const page = this.browser.getPage();
|
|
54
|
+
if (!page) {
|
|
55
|
+
return { success: false, error: 'Browser not initialized' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Navigate to LinkedIn login
|
|
60
|
+
console.log('Navigating to LinkedIn login page...');
|
|
61
|
+
await page.goto('https://www.linkedin.com/login', {
|
|
62
|
+
waitUntil: 'domcontentloaded',
|
|
63
|
+
timeout: 30000,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const currentUrl = page.url();
|
|
67
|
+
console.log(`Current page URL: ${currentUrl}`);
|
|
68
|
+
|
|
69
|
+
// Debug: Log all input elements on the page
|
|
70
|
+
console.log('Scanning for form elements...');
|
|
71
|
+
const inputs = await page.$$('input');
|
|
72
|
+
console.log(`Found ${inputs.length} input elements`);
|
|
73
|
+
for (let i = 0; i < Math.min(inputs.length, 5); i++) {
|
|
74
|
+
const type = await inputs[i].getAttribute('type');
|
|
75
|
+
const id = await inputs[i].getAttribute('id');
|
|
76
|
+
const name = await inputs[i].getAttribute('name');
|
|
77
|
+
console.log(` Input ${i}: type=${type}, id=${id}, name=${name}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Find and fill email input
|
|
81
|
+
console.log('Looking for email input...');
|
|
82
|
+
const emailResult = await this.findElementWithFallbacks(
|
|
83
|
+
SELECTORS.login.emailInput,
|
|
84
|
+
'email input'
|
|
85
|
+
);
|
|
86
|
+
if (!emailResult) {
|
|
87
|
+
return {
|
|
88
|
+
success: false,
|
|
89
|
+
error:
|
|
90
|
+
'Could not find email input field - LinkedIn may have changed their page structure',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
await page.fill(emailResult.selectorUsed, credentials.email);
|
|
94
|
+
console.log('Email filled successfully');
|
|
95
|
+
|
|
96
|
+
// Find and fill password input
|
|
97
|
+
console.log('Looking for password input...');
|
|
98
|
+
const passwordResult = await this.findElementWithFallbacks(
|
|
99
|
+
SELECTORS.login.passwordInput,
|
|
100
|
+
'password input'
|
|
101
|
+
);
|
|
102
|
+
if (!passwordResult) {
|
|
103
|
+
return {
|
|
104
|
+
success: false,
|
|
105
|
+
error:
|
|
106
|
+
'Could not find password input field - LinkedIn may have changed their page structure',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
await page.fill(passwordResult.selectorUsed, credentials.password);
|
|
110
|
+
console.log('Password filled successfully');
|
|
111
|
+
|
|
112
|
+
// Find and click submit button
|
|
113
|
+
console.log('Looking for submit button...');
|
|
114
|
+
const submitResult = await this.findElementWithFallbacks(
|
|
115
|
+
SELECTORS.login.submitButton,
|
|
116
|
+
'submit button'
|
|
117
|
+
);
|
|
118
|
+
if (!submitResult) {
|
|
119
|
+
return {
|
|
120
|
+
success: false,
|
|
121
|
+
error: 'Could not find submit button - LinkedIn may have changed their page structure',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (!submitResult.element) {
|
|
125
|
+
return {
|
|
126
|
+
success: false,
|
|
127
|
+
error: 'Submit button element is null',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
await submitResult.element.click();
|
|
131
|
+
console.log('Submit button clicked');
|
|
132
|
+
|
|
133
|
+
// Wait for navigation or 2FA prompt
|
|
134
|
+
console.log('Waiting for page to load...');
|
|
135
|
+
await page.waitForLoadState('networkidle');
|
|
136
|
+
|
|
137
|
+
// Check for 2FA prompt
|
|
138
|
+
console.log('Checking for 2FA prompt...');
|
|
139
|
+
const has2FA = await this.findElementWithFallbacks(SELECTORS.twoFA.pinInput, '2FA pin input');
|
|
140
|
+
if (has2FA) {
|
|
141
|
+
console.log('2FA prompt detected');
|
|
142
|
+
if (credentials.totpCode) {
|
|
143
|
+
// Fill in TOTP code
|
|
144
|
+
await page.fill(has2FA.selectorUsed, credentials.totpCode);
|
|
145
|
+
const submit2FA = await this.findElementWithFallbacks(
|
|
146
|
+
SELECTORS.twoFA.submitButton,
|
|
147
|
+
'2FA submit button'
|
|
148
|
+
);
|
|
149
|
+
if (submit2FA && submit2FA.element) {
|
|
150
|
+
await submit2FA.element.click();
|
|
151
|
+
}
|
|
152
|
+
await page.waitForLoadState('networkidle');
|
|
153
|
+
} else {
|
|
154
|
+
return { success: false, requires2FA: true, error: '2FA required - provide TOTP code' };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check if login succeeded (look for feed or profile element)
|
|
159
|
+
console.log('Checking if login was successful...');
|
|
160
|
+
const navElement = await this.findElementWithFallbacks(
|
|
161
|
+
SELECTORS.nav.globalNav,
|
|
162
|
+
'global navigation'
|
|
163
|
+
);
|
|
164
|
+
if (!navElement) {
|
|
165
|
+
// Check for error message
|
|
166
|
+
const errorElement = await page.$('div[role="alert"] div, div.error, .alert-content');
|
|
167
|
+
const errorText = errorElement ? await errorElement.textContent() : null;
|
|
168
|
+
|
|
169
|
+
if (errorText) {
|
|
170
|
+
return { success: false, error: `LinkedIn error: ${errorText.trim()}` };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Take a screenshot for debugging
|
|
174
|
+
console.log('Login may have failed - no global nav found');
|
|
175
|
+
return { success: false, error: 'Login failed - could not verify successful login' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log('Login successful! Saving session...');
|
|
179
|
+
|
|
180
|
+
// Save session
|
|
181
|
+
const session = await this.browser.saveSession();
|
|
182
|
+
if (!session) {
|
|
183
|
+
return { success: false, error: 'Failed to save session' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log('Session saved successfully');
|
|
187
|
+
return { success: true, session };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
190
|
+
console.error('Login error:', errorMessage);
|
|
191
|
+
return {
|
|
192
|
+
success: false,
|
|
193
|
+
error: `Login error: ${errorMessage}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async isSessionValid(): Promise<boolean> {
|
|
199
|
+
const page = this.browser.getPage();
|
|
200
|
+
if (!page) return false;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
// Navigate to LinkedIn and check if we're logged in
|
|
204
|
+
await page.goto('https://www.linkedin.com/feed', {
|
|
205
|
+
waitUntil: 'domcontentloaded',
|
|
206
|
+
timeout: 10000,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const navElement = await this.findElementWithFallbacks(
|
|
210
|
+
SELECTORS.nav.globalNav,
|
|
211
|
+
'global navigation'
|
|
212
|
+
);
|
|
213
|
+
return !!navElement;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isValidCompanyUrl, parseFollowerCount, parseSpecialties } from './company-extractor';
|
|
3
|
+
|
|
4
|
+
describe('isValidCompanyUrl', () => {
|
|
5
|
+
it('should accept valid HTTPS company URLs', () => {
|
|
6
|
+
expect(isValidCompanyUrl('https://www.linkedin.com/company/openai/')).toBe(true);
|
|
7
|
+
expect(isValidCompanyUrl('https://www.linkedin.com/company/microsoft')).toBe(true);
|
|
8
|
+
expect(isValidCompanyUrl('https://www.linkedin.com/company/123company/')).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should reject invalid URLs', () => {
|
|
12
|
+
expect(isValidCompanyUrl('http://www.linkedin.com/company/openai/')).toBe(false);
|
|
13
|
+
expect(isValidCompanyUrl('https://linkedin.com/company/openai/')).toBe(false);
|
|
14
|
+
expect(isValidCompanyUrl('https://www.linkedin.com/in/openai/')).toBe(false);
|
|
15
|
+
expect(isValidCompanyUrl('not-a-url')).toBe(false);
|
|
16
|
+
expect(isValidCompanyUrl('')).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('parseFollowerCount', () => {
|
|
21
|
+
it('should parse K suffix', () => {
|
|
22
|
+
expect(parseFollowerCount('12K followers')).toBe(12000);
|
|
23
|
+
expect(parseFollowerCount('5k')).toBe(5000);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should parse M suffix', () => {
|
|
27
|
+
expect(parseFollowerCount('2.5M followers')).toBe(2500000);
|
|
28
|
+
expect(parseFollowerCount('1.2m')).toBe(1200000);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should parse plain numbers with commas', () => {
|
|
32
|
+
expect(parseFollowerCount('1,234 followers')).toBe(1234);
|
|
33
|
+
expect(parseFollowerCount('500+ followers')).toBe(500);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should return null for invalid input', () => {
|
|
37
|
+
expect(parseFollowerCount(null)).toBe(null);
|
|
38
|
+
expect(parseFollowerCount('')).toBe(null);
|
|
39
|
+
expect(parseFollowerCount('no numbers')).toBe(null);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('parseSpecialties', () => {
|
|
44
|
+
it('should split by comma and trim', () => {
|
|
45
|
+
expect(parseSpecialties('AI, ML, Research')).toEqual(['AI', 'ML', 'Research']);
|
|
46
|
+
expect(parseSpecialties('Artificial Intelligence')).toEqual(['Artificial Intelligence']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should handle whitespace', () => {
|
|
50
|
+
expect(parseSpecialties(' AI , ML ')).toEqual(['AI', 'ML']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should return null for empty input', () => {
|
|
54
|
+
expect(parseSpecialties(null)).toBe(null);
|
|
55
|
+
expect(parseSpecialties('')).toBe(null);
|
|
56
|
+
expect(parseSpecialties(' ')).toBe(null);
|
|
57
|
+
});
|
|
58
|
+
});
|