Skip to content

Commit 63ae0d8

Browse files
authored
feat(linkedin): add company command (#2088)
* feat(linkedin): add company command to read a company page Adds `opencli linkedin company <name>` — reads a LinkedIn company's About page: industry, size, headquarters, founded, website, specialties, follower count, and about text. - Accepts a bare universal name (`nvidia`), a `/company/<name>` path, or a full company URL; navigates to the About page and scrapes the dt/dd fact list + follower count (same DOM-extraction style as profile-read). - Typed errors: AuthRequiredError via assertLinkedInAuthenticated, CommandExecutionError on malformed payload / missing company name. Live-verified end-to-end (NVIDIA: 42M followers, Computer Hardware Manufacturing, founded 1993; Databricks via full URL). 4 tests; audits new=0. * fix(linkedin): harden company identity output
1 parent c4e6aab commit 63ae0d8

3 files changed

Lines changed: 286 additions & 0 deletions

File tree

cli-manifest.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21707,6 +21707,40 @@
2170721707
"modulePath": "lichess/user.js",
2170821708
"sourceFile": "lichess/user.js"
2170921709
},
21710+
{
21711+
"site": "linkedin",
21712+
"name": "company",
21713+
"description": "Read a LinkedIn company page: industry, size, HQ, founded, website, followers, about",
21714+
"access": "read",
21715+
"domain": "www.linkedin.com",
21716+
"strategy": "cookie",
21717+
"browser": true,
21718+
"args": [
21719+
{
21720+
"name": "company",
21721+
"type": "string",
21722+
"required": true,
21723+
"positional": true,
21724+
"help": "Company universal name (nvidia), /company/<name> path, or full URL"
21725+
}
21726+
],
21727+
"columns": [
21728+
"name",
21729+
"industry",
21730+
"size",
21731+
"headquarters",
21732+
"founded",
21733+
"website",
21734+
"specialties",
21735+
"followers",
21736+
"about",
21737+
"url"
21738+
],
21739+
"type": "js",
21740+
"modulePath": "linkedin/company.js",
21741+
"sourceFile": "linkedin/company.js",
21742+
"navigateBefore": "https://www.linkedin.com"
21743+
},
2171021744
{
2171121745
"site": "linkedin",
2171221746
"name": "connect",

clis/linkedin/company.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import { CommandExecutionError } from '@jackwener/opencli/errors';
3+
import {
4+
LINKEDIN_DOMAIN,
5+
assertLinkedInAuthenticated,
6+
normalizeWhitespace,
7+
unwrapEvaluateResult,
8+
} from './shared.js';
9+
10+
const SLUG_RE = /^[A-Za-z0-9%._-]+$/;
11+
const COMPANY_URL_RE = /^\/company\/([^/?#]+)/;
12+
const LINKEDIN_COMPANY_HOSTS = new Set(['linkedin.com', LINKEDIN_DOMAIN]);
13+
14+
// Accept a bare universal name (`nvidia`), a `/company/<slug>` path, or a full
15+
// company URL, and return the canonical about-page URL.
16+
function normalizeCompanyUrl(value) {
17+
const raw = normalizeWhitespace(value || '');
18+
if (!raw) {
19+
throw new CommandExecutionError('LinkedIn company requires a company universal name or URL');
20+
}
21+
let slug = raw;
22+
if (/^https?:\/\//i.test(raw) || raw.startsWith('/company/')) {
23+
let parsed;
24+
try {
25+
parsed = raw.startsWith('/') ? new URL(raw, `https://${LINKEDIN_DOMAIN}`) : new URL(raw);
26+
} catch {
27+
throw new CommandExecutionError(`LinkedIn company received a malformed URL: ${raw}`);
28+
}
29+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port || !LINKEDIN_COMPANY_HOSTS.has(parsed.hostname.toLowerCase())) {
30+
throw new CommandExecutionError('LinkedIn company URL must point to linkedin.com');
31+
}
32+
const m = parsed.pathname.match(COMPANY_URL_RE);
33+
if (!m) throw new CommandExecutionError('LinkedIn company URL must look like /company/<name>');
34+
try {
35+
slug = decodeURIComponent(m[1]);
36+
} catch {
37+
throw new CommandExecutionError(`LinkedIn company URL has a malformed company slug: ${m[1]}`);
38+
}
39+
}
40+
if (!SLUG_RE.test(slug)) {
41+
throw new CommandExecutionError(`LinkedIn company name has unexpected characters: ${slug}`);
42+
}
43+
return `https://www.linkedin.com/company/${encodeURIComponent(slug)}/about/`;
44+
}
45+
46+
function buildCompanyExtractionScript() {
47+
return String.raw`(() => {
48+
const clean = (s) => String(s || '').replace(/[  ]+/g, ' ').replace(/\s+/g, ' ').trim();
49+
const facts = {};
50+
for (const dt of Array.from(document.querySelectorAll('dt'))) {
51+
const key = clean(dt.innerText || dt.textContent || '').toLowerCase().replace(/:$/, '');
52+
const dd = dt.nextElementSibling;
53+
const val = dd ? clean(dd.innerText || dd.textContent || '') : '';
54+
if (key && val && !(key in facts)) facts[key] = val;
55+
}
56+
const name = clean((document.querySelector('main h1') || document.querySelector('h1'))?.innerText || '');
57+
const bodyText = clean(document.body ? (document.body.innerText || '') : '');
58+
const followersMatch = bodyText.match(/([\d,]+)\s+followers/i);
59+
const aboutHeading = Array.from(document.querySelectorAll('main h2, section h2')).find((el) => /^About$|^Overview$/i.test(clean(el.innerText || '')));
60+
const aboutSection = aboutHeading ? aboutHeading.closest('section') : null;
61+
const about = aboutSection ? clean((aboutSection.innerText || '').replace(/^About\s*/i, '').replace(/^Overview\s*/i, '')) : '';
62+
return {
63+
url: window.location.href,
64+
name,
65+
industry: facts['industry'] || '',
66+
size: facts['company size'] || '',
67+
headquarters: facts['headquarters'] || '',
68+
founded: facts['founded'] || '',
69+
website: facts['website'] || '',
70+
specialties: facts['specialties'] || '',
71+
followers: followersMatch ? followersMatch[1].replace(/,/g, '') : '',
72+
about: about.slice(0, 2000),
73+
};
74+
})()`;
75+
}
76+
77+
function normalizeCompanyOutputUrl(value, fallbackUrl) {
78+
const raw = normalizeWhitespace(value || fallbackUrl);
79+
let parsed;
80+
try {
81+
parsed = new URL(raw, `https://${LINKEDIN_DOMAIN}`);
82+
} catch {
83+
throw new CommandExecutionError('LinkedIn company extraction returned a malformed current URL');
84+
}
85+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port || !LINKEDIN_COMPANY_HOSTS.has(parsed.hostname.toLowerCase())) {
86+
throw new CommandExecutionError('LinkedIn company extraction ended on a non-LinkedIn page');
87+
}
88+
const match = parsed.pathname.match(COMPANY_URL_RE);
89+
if (!match?.[1]) {
90+
throw new CommandExecutionError('LinkedIn company extraction ended outside a company page');
91+
}
92+
let slug;
93+
try {
94+
slug = decodeURIComponent(match[1]);
95+
} catch {
96+
throw new CommandExecutionError('LinkedIn company extraction returned a malformed company slug');
97+
}
98+
return `https://${LINKEDIN_DOMAIN}/company/${encodeURIComponent(slug)}/about/`;
99+
}
100+
101+
function normalizeCompanyInfo(info, targetUrl) {
102+
if (!info || typeof info !== 'object' || Array.isArray(info)) {
103+
throw new CommandExecutionError('LinkedIn company extraction returned a malformed payload');
104+
}
105+
if (!info.name) {
106+
throw new CommandExecutionError('LinkedIn company page rendered but no company name was found (layout drift or company not found)');
107+
}
108+
let followers = 0;
109+
if (info.followers) {
110+
followers = Number(info.followers);
111+
if (!Number.isFinite(followers)) {
112+
throw new CommandExecutionError('LinkedIn company extraction returned a malformed followers count');
113+
}
114+
}
115+
return {
116+
name: String(info.name),
117+
industry: String(info.industry || ''),
118+
size: String(info.size || ''),
119+
headquarters: String(info.headquarters || ''),
120+
founded: String(info.founded || ''),
121+
website: String(info.website || ''),
122+
specialties: String(info.specialties || ''),
123+
followers,
124+
about: String(info.about || ''),
125+
url: normalizeCompanyOutputUrl(info.url, targetUrl),
126+
};
127+
}
128+
129+
cli({
130+
site: 'linkedin',
131+
name: 'company',
132+
access: 'read',
133+
description: 'Read a LinkedIn company page: industry, size, HQ, founded, website, followers, about',
134+
domain: LINKEDIN_DOMAIN,
135+
strategy: Strategy.COOKIE,
136+
browser: true,
137+
args: [
138+
{ name: 'company', type: 'string', required: true, positional: true, help: 'Company universal name (nvidia), /company/<name> path, or full URL' },
139+
],
140+
columns: ['name', 'industry', 'size', 'headquarters', 'founded', 'website', 'specialties', 'followers', 'about', 'url'],
141+
func: async (page, kwargs) => {
142+
const targetUrl = normalizeCompanyUrl(kwargs.company);
143+
await page.goto(targetUrl);
144+
await page.wait(2);
145+
await assertLinkedInAuthenticated(page, 'linkedin company');
146+
const info = unwrapEvaluateResult(await page.evaluate(buildCompanyExtractionScript()));
147+
return [normalizeCompanyInfo(info, targetUrl)];
148+
},
149+
});
150+
151+
export const __test__ = { normalizeCompanyUrl, normalizeCompanyInfo, buildCompanyExtractionScript };

clis/linkedin/company.test.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import { CommandExecutionError } from '@jackwener/opencli/errors';
4+
import './company.js';
5+
6+
const { normalizeCompanyInfo, normalizeCompanyUrl } = await import('./company.js').then((m) => m.__test__);
7+
8+
function makePage(evaluateResult) {
9+
return {
10+
goto: vi.fn().mockResolvedValue(undefined),
11+
wait: vi.fn().mockResolvedValue(undefined),
12+
// assertLinkedInAuthenticated + the extraction both call evaluate; the
13+
// first returns the auth-wall boolean (false = authed), the second the info.
14+
evaluate: vi.fn()
15+
.mockResolvedValueOnce(false)
16+
.mockResolvedValueOnce(evaluateResult),
17+
};
18+
}
19+
20+
describe('linkedin company', () => {
21+
it('normalizes bare name, path, and URL to the about page', () => {
22+
expect(normalizeCompanyUrl('nvidia')).toBe('https://www.linkedin.com/company/nvidia/about/');
23+
expect(normalizeCompanyUrl('/company/nvidia')).toBe('https://www.linkedin.com/company/nvidia/about/');
24+
expect(normalizeCompanyUrl('https://www.linkedin.com/company/databricks/')).toBe('https://www.linkedin.com/company/databricks/about/');
25+
});
26+
27+
it('rejects empty or malformed company identifiers', () => {
28+
expect(() => normalizeCompanyUrl('')).toThrow(CommandExecutionError);
29+
expect(() => normalizeCompanyUrl('bad name!')).toThrow(CommandExecutionError);
30+
expect(() => normalizeCompanyUrl('https://www.linkedin.com/in/someone/')).toThrow(CommandExecutionError);
31+
expect(() => normalizeCompanyUrl('https://evil.example/company/nvidia/')).toThrow(CommandExecutionError);
32+
expect(() => normalizeCompanyUrl('https://www.linkedin.com/company/%E0%A4%A')).toThrow(CommandExecutionError);
33+
});
34+
35+
it('maps extracted company facts to a row', async () => {
36+
const cmd = getRegistry().get('linkedin/company');
37+
expect(cmd?.func).toBeTypeOf('function');
38+
const page = makePage({
39+
url: 'https://www.linkedin.com/company/nvidia/about/',
40+
name: 'NVIDIA',
41+
industry: 'Computer Hardware Manufacturing',
42+
size: '10,001+ employees',
43+
headquarters: 'Santa Clara, CA',
44+
founded: '1993',
45+
website: 'http://www.nvidia.com',
46+
specialties: 'GPU, AI',
47+
followers: '42040089',
48+
about: 'Accelerated computing.',
49+
});
50+
await expect(cmd.func(page, { company: 'nvidia' })).resolves.toEqual([
51+
{
52+
name: 'NVIDIA',
53+
industry: 'Computer Hardware Manufacturing',
54+
size: '10,001+ employees',
55+
headquarters: 'Santa Clara, CA',
56+
founded: '1993',
57+
website: 'http://www.nvidia.com',
58+
specialties: 'GPU, AI',
59+
followers: 42040089,
60+
about: 'Accelerated computing.',
61+
url: 'https://www.linkedin.com/company/nvidia/about/',
62+
},
63+
]);
64+
});
65+
66+
it('throws when no company name is found', async () => {
67+
const cmd = getRegistry().get('linkedin/company');
68+
const page = makePage({ url: 'x', name: '', industry: '' });
69+
await expect(cmd.func(page, { company: 'ghost' })).rejects.toBeInstanceOf(CommandExecutionError);
70+
});
71+
72+
it('normalizes the emitted URL to a LinkedIn company about URL', () => {
73+
expect(normalizeCompanyInfo({
74+
url: 'https://www.linkedin.com/company/nvidia/posts/?trk=public_profile',
75+
name: 'NVIDIA',
76+
followers: '123',
77+
}, 'https://www.linkedin.com/company/nvidia/about/')).toMatchObject({
78+
url: 'https://www.linkedin.com/company/nvidia/about/',
79+
followers: 123,
80+
});
81+
});
82+
83+
it('throws when extraction ends outside a LinkedIn company page', () => {
84+
expect(() => normalizeCompanyInfo({
85+
url: 'https://www.linkedin.com/in/not-a-company/',
86+
name: 'NVIDIA',
87+
}, 'https://www.linkedin.com/company/nvidia/about/')).toThrow(CommandExecutionError);
88+
expect(() => normalizeCompanyInfo({
89+
url: 'https://evil.example/company/nvidia/',
90+
name: 'NVIDIA',
91+
}, 'https://www.linkedin.com/company/nvidia/about/')).toThrow(CommandExecutionError);
92+
});
93+
94+
it('throws on malformed follower counts instead of emitting NaN', () => {
95+
expect(() => normalizeCompanyInfo({
96+
url: 'https://www.linkedin.com/company/nvidia/about/',
97+
name: 'NVIDIA',
98+
followers: 'many',
99+
}, 'https://www.linkedin.com/company/nvidia/about/')).toThrow(CommandExecutionError);
100+
});
101+
});

0 commit comments

Comments
 (0)