diff --git a/cli-manifest.json b/cli-manifest.json index 58a0bb0d0..dd7729ad2 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -21707,6 +21707,40 @@ "modulePath": "lichess/user.js", "sourceFile": "lichess/user.js" }, + { + "site": "linkedin", + "name": "company", + "description": "Read a LinkedIn company page: industry, size, HQ, founded, website, followers, about", + "access": "read", + "domain": "www.linkedin.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "company", + "type": "string", + "required": true, + "positional": true, + "help": "Company universal name (nvidia), /company/ path, or full URL" + } + ], + "columns": [ + "name", + "industry", + "size", + "headquarters", + "founded", + "website", + "specialties", + "followers", + "about", + "url" + ], + "type": "js", + "modulePath": "linkedin/company.js", + "sourceFile": "linkedin/company.js", + "navigateBefore": "https://www.linkedin.com" + }, { "site": "linkedin", "name": "connect", diff --git a/clis/linkedin/company.js b/clis/linkedin/company.js new file mode 100644 index 000000000..65aee8f8b --- /dev/null +++ b/clis/linkedin/company.js @@ -0,0 +1,151 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { + LINKEDIN_DOMAIN, + assertLinkedInAuthenticated, + normalizeWhitespace, + unwrapEvaluateResult, +} from './shared.js'; + +const SLUG_RE = /^[A-Za-z0-9%._-]+$/; +const COMPANY_URL_RE = /^\/company\/([^/?#]+)/; +const LINKEDIN_COMPANY_HOSTS = new Set(['linkedin.com', LINKEDIN_DOMAIN]); + +// Accept a bare universal name (`nvidia`), a `/company/` path, or a full +// company URL, and return the canonical about-page URL. +function normalizeCompanyUrl(value) { + const raw = normalizeWhitespace(value || ''); + if (!raw) { + throw new CommandExecutionError('LinkedIn company requires a company universal name or URL'); + } + let slug = raw; + if (/^https?:\/\//i.test(raw) || raw.startsWith('/company/')) { + let parsed; + try { + parsed = raw.startsWith('/') ? new URL(raw, `https://${LINKEDIN_DOMAIN}`) : new URL(raw); + } catch { + throw new CommandExecutionError(`LinkedIn company received a malformed URL: ${raw}`); + } + if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port || !LINKEDIN_COMPANY_HOSTS.has(parsed.hostname.toLowerCase())) { + throw new CommandExecutionError('LinkedIn company URL must point to linkedin.com'); + } + const m = parsed.pathname.match(COMPANY_URL_RE); + if (!m) throw new CommandExecutionError('LinkedIn company URL must look like /company/'); + try { + slug = decodeURIComponent(m[1]); + } catch { + throw new CommandExecutionError(`LinkedIn company URL has a malformed company slug: ${m[1]}`); + } + } + if (!SLUG_RE.test(slug)) { + throw new CommandExecutionError(`LinkedIn company name has unexpected characters: ${slug}`); + } + return `https://www.linkedin.com/company/${encodeURIComponent(slug)}/about/`; +} + +function buildCompanyExtractionScript() { + return String.raw`(() => { + const clean = (s) => String(s || '').replace(/[  ]+/g, ' ').replace(/\s+/g, ' ').trim(); + const facts = {}; + for (const dt of Array.from(document.querySelectorAll('dt'))) { + const key = clean(dt.innerText || dt.textContent || '').toLowerCase().replace(/:$/, ''); + const dd = dt.nextElementSibling; + const val = dd ? clean(dd.innerText || dd.textContent || '') : ''; + if (key && val && !(key in facts)) facts[key] = val; + } + const name = clean((document.querySelector('main h1') || document.querySelector('h1'))?.innerText || ''); + const bodyText = clean(document.body ? (document.body.innerText || '') : ''); + const followersMatch = bodyText.match(/([\d,]+)\s+followers/i); + const aboutHeading = Array.from(document.querySelectorAll('main h2, section h2')).find((el) => /^About$|^Overview$/i.test(clean(el.innerText || ''))); + const aboutSection = aboutHeading ? aboutHeading.closest('section') : null; + const about = aboutSection ? clean((aboutSection.innerText || '').replace(/^About\s*/i, '').replace(/^Overview\s*/i, '')) : ''; + return { + url: window.location.href, + name, + industry: facts['industry'] || '', + size: facts['company size'] || '', + headquarters: facts['headquarters'] || '', + founded: facts['founded'] || '', + website: facts['website'] || '', + specialties: facts['specialties'] || '', + followers: followersMatch ? followersMatch[1].replace(/,/g, '') : '', + about: about.slice(0, 2000), + }; + })()`; +} + +function normalizeCompanyOutputUrl(value, fallbackUrl) { + const raw = normalizeWhitespace(value || fallbackUrl); + let parsed; + try { + parsed = new URL(raw, `https://${LINKEDIN_DOMAIN}`); + } catch { + throw new CommandExecutionError('LinkedIn company extraction returned a malformed current URL'); + } + if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port || !LINKEDIN_COMPANY_HOSTS.has(parsed.hostname.toLowerCase())) { + throw new CommandExecutionError('LinkedIn company extraction ended on a non-LinkedIn page'); + } + const match = parsed.pathname.match(COMPANY_URL_RE); + if (!match?.[1]) { + throw new CommandExecutionError('LinkedIn company extraction ended outside a company page'); + } + let slug; + try { + slug = decodeURIComponent(match[1]); + } catch { + throw new CommandExecutionError('LinkedIn company extraction returned a malformed company slug'); + } + return `https://${LINKEDIN_DOMAIN}/company/${encodeURIComponent(slug)}/about/`; +} + +function normalizeCompanyInfo(info, targetUrl) { + if (!info || typeof info !== 'object' || Array.isArray(info)) { + throw new CommandExecutionError('LinkedIn company extraction returned a malformed payload'); + } + if (!info.name) { + throw new CommandExecutionError('LinkedIn company page rendered but no company name was found (layout drift or company not found)'); + } + let followers = 0; + if (info.followers) { + followers = Number(info.followers); + if (!Number.isFinite(followers)) { + throw new CommandExecutionError('LinkedIn company extraction returned a malformed followers count'); + } + } + return { + name: String(info.name), + industry: String(info.industry || ''), + size: String(info.size || ''), + headquarters: String(info.headquarters || ''), + founded: String(info.founded || ''), + website: String(info.website || ''), + specialties: String(info.specialties || ''), + followers, + about: String(info.about || ''), + url: normalizeCompanyOutputUrl(info.url, targetUrl), + }; +} + +cli({ + site: 'linkedin', + name: 'company', + access: 'read', + description: 'Read a LinkedIn company page: industry, size, HQ, founded, website, followers, about', + domain: LINKEDIN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'company', type: 'string', required: true, positional: true, help: 'Company universal name (nvidia), /company/ path, or full URL' }, + ], + columns: ['name', 'industry', 'size', 'headquarters', 'founded', 'website', 'specialties', 'followers', 'about', 'url'], + func: async (page, kwargs) => { + const targetUrl = normalizeCompanyUrl(kwargs.company); + await page.goto(targetUrl); + await page.wait(2); + await assertLinkedInAuthenticated(page, 'linkedin company'); + const info = unwrapEvaluateResult(await page.evaluate(buildCompanyExtractionScript())); + return [normalizeCompanyInfo(info, targetUrl)]; + }, +}); + +export const __test__ = { normalizeCompanyUrl, normalizeCompanyInfo, buildCompanyExtractionScript }; diff --git a/clis/linkedin/company.test.js b/clis/linkedin/company.test.js new file mode 100644 index 000000000..f4745fd74 --- /dev/null +++ b/clis/linkedin/company.test.js @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import './company.js'; + +const { normalizeCompanyInfo, normalizeCompanyUrl } = await import('./company.js').then((m) => m.__test__); + +function makePage(evaluateResult) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + // assertLinkedInAuthenticated + the extraction both call evaluate; the + // first returns the auth-wall boolean (false = authed), the second the info. + evaluate: vi.fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(evaluateResult), + }; +} + +describe('linkedin company', () => { + it('normalizes bare name, path, and URL to the about page', () => { + expect(normalizeCompanyUrl('nvidia')).toBe('https://www.linkedin.com/company/nvidia/about/'); + expect(normalizeCompanyUrl('/company/nvidia')).toBe('https://www.linkedin.com/company/nvidia/about/'); + expect(normalizeCompanyUrl('https://www.linkedin.com/company/databricks/')).toBe('https://www.linkedin.com/company/databricks/about/'); + }); + + it('rejects empty or malformed company identifiers', () => { + expect(() => normalizeCompanyUrl('')).toThrow(CommandExecutionError); + expect(() => normalizeCompanyUrl('bad name!')).toThrow(CommandExecutionError); + expect(() => normalizeCompanyUrl('https://www.linkedin.com/in/someone/')).toThrow(CommandExecutionError); + expect(() => normalizeCompanyUrl('https://evil.example/company/nvidia/')).toThrow(CommandExecutionError); + expect(() => normalizeCompanyUrl('https://www.linkedin.com/company/%E0%A4%A')).toThrow(CommandExecutionError); + }); + + it('maps extracted company facts to a row', async () => { + const cmd = getRegistry().get('linkedin/company'); + expect(cmd?.func).toBeTypeOf('function'); + const page = makePage({ + url: 'https://www.linkedin.com/company/nvidia/about/', + name: 'NVIDIA', + industry: 'Computer Hardware Manufacturing', + size: '10,001+ employees', + headquarters: 'Santa Clara, CA', + founded: '1993', + website: 'http://www.nvidia.com', + specialties: 'GPU, AI', + followers: '42040089', + about: 'Accelerated computing.', + }); + await expect(cmd.func(page, { company: 'nvidia' })).resolves.toEqual([ + { + name: 'NVIDIA', + industry: 'Computer Hardware Manufacturing', + size: '10,001+ employees', + headquarters: 'Santa Clara, CA', + founded: '1993', + website: 'http://www.nvidia.com', + specialties: 'GPU, AI', + followers: 42040089, + about: 'Accelerated computing.', + url: 'https://www.linkedin.com/company/nvidia/about/', + }, + ]); + }); + + it('throws when no company name is found', async () => { + const cmd = getRegistry().get('linkedin/company'); + const page = makePage({ url: 'x', name: '', industry: '' }); + await expect(cmd.func(page, { company: 'ghost' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('normalizes the emitted URL to a LinkedIn company about URL', () => { + expect(normalizeCompanyInfo({ + url: 'https://www.linkedin.com/company/nvidia/posts/?trk=public_profile', + name: 'NVIDIA', + followers: '123', + }, 'https://www.linkedin.com/company/nvidia/about/')).toMatchObject({ + url: 'https://www.linkedin.com/company/nvidia/about/', + followers: 123, + }); + }); + + it('throws when extraction ends outside a LinkedIn company page', () => { + expect(() => normalizeCompanyInfo({ + url: 'https://www.linkedin.com/in/not-a-company/', + name: 'NVIDIA', + }, 'https://www.linkedin.com/company/nvidia/about/')).toThrow(CommandExecutionError); + expect(() => normalizeCompanyInfo({ + url: 'https://evil.example/company/nvidia/', + name: 'NVIDIA', + }, 'https://www.linkedin.com/company/nvidia/about/')).toThrow(CommandExecutionError); + }); + + it('throws on malformed follower counts instead of emitting NaN', () => { + expect(() => normalizeCompanyInfo({ + url: 'https://www.linkedin.com/company/nvidia/about/', + name: 'NVIDIA', + followers: 'many', + }, 'https://www.linkedin.com/company/nvidia/about/')).toThrow(CommandExecutionError); + }); +});