diff --git a/README.md b/README.md index 4963bce..21c2958 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,12 @@ Every command supports these flags: | `--no-color` | Disable colors | | `--account "Name"` | Use a specific account for this command | +List flags documented as comma-separated split their value on every comma. When a value contains a comma itself – `Chicago, Illinois`, `Technology, Information and Internet`, `Example Co., Ltd.` – pass the whole list as a JSON array instead: + +```bash +linkedin person search --term "designer" --locations '["Chicago, Illinois","London, United Kingdom"]' --json +``` + --- ## Command Reference @@ -154,7 +160,8 @@ linkedin person search [flags] | Flag | Type | Description | |------|------|-------------| -| `--term` | string | Search keyword or phrase | +| `--term` | string | Search keyword or phrase; required unless `--url` is given | +| `--url` | string | LinkedIn people search URL (`linkedin.com/search/results/people/...`), such as the `searchUrl` of an anonymous viewer from `stats viewers`; overrides `--term` and the filters | | `--limit` | integer | Max results to return | | `--first-name` | string | Filter by first name | | `--last-name` | string | Filter by last name | @@ -167,8 +174,9 @@ linkedin person search [flags] ```bash linkedin person search --term "revops engineer" --locations "San Francisco" -linkedin person search --current-companies "Linked API" --position "Engineer" --json -linkedin person search --schools "MIT" --industries "Software Development" --limit 20 --json +linkedin person search --term "engineer" --current-companies "Linked API" --json +linkedin person search --term "founder" --schools "MIT" --industries "Software Development" --limit 20 --json +linkedin person search --url "https://www.linkedin.com/search/results/people/?keywords=CTO" --json ``` --- @@ -229,7 +237,8 @@ linkedin company search [flags] | Flag | Type | Description | |------|------|-------------| -| `--term` | string | Search keyword or phrase | +| `--term` | string | Search keyword or phrase; required unless `--url` is given | +| `--url` | string | LinkedIn companies search URL (`linkedin.com/search/results/companies/...`); overrides `--term` and the filters | | `--limit` | integer | Max results to return | | `--sizes` | string | Company sizes, comma-separated (`1-10`, `11-50`, `51-200`, `201-500`, `501-1000`, `1001-5000`, `5001-10000`, `10001+`) | | `--locations` | string | Comma-separated locations | @@ -237,7 +246,7 @@ linkedin company search [flags] ```bash linkedin company search --term "fintech" --sizes "11-50,51-200" --json -linkedin company search --industries "Software Development" --locations "Berlin" --json +linkedin company search --term "payments" --industries "Software Development" --locations "Berlin" --json ``` --- @@ -733,7 +742,8 @@ linkedin navigator person search [flags] | Flag | Type | Description | |------|------|-------------| -| `--term` | string | Search keyword or phrase | +| `--term` | string | Search keyword or phrase; required unless `--url` is given | +| `--url` | string | Sales Navigator people search URL (`linkedin.com/sales/search/people/...`); overrides `--term` and the filters | | `--limit` | integer | Max results to return | | `--first-name` | string | Filter by first name | | `--last-name` | string | Filter by last name | @@ -747,7 +757,7 @@ linkedin navigator person search [flags] ```bash linkedin navigator person search --term "VP Marketing" --locations "United States" -linkedin navigator person search --years-of-experience "moreThanTen" --position "CEO" --json +linkedin navigator person search --term "CEO" --years-of-experience "moreThanTen" --json ``` #### `navigator company fetch` @@ -792,7 +802,8 @@ linkedin navigator company search [flags] | Flag | Type | Description | |------|------|-------------| -| `--term` | string | Search keyword or phrase | +| `--term` | string | Search keyword or phrase; required unless `--url` is given | +| `--url` | string | Sales Navigator companies search URL (`linkedin.com/sales/search/company/...`); overrides `--term` and the filters | | `--limit` | integer | Max results to return | | `--sizes` | string | Company sizes, comma-separated (`1-10`, `11-50`, `51-200`, `201-500`, `501-1000`, `1001-5000`, `5001-10000`, `10001+`) | | `--locations` | string | Comma-separated locations | @@ -802,7 +813,7 @@ linkedin navigator company search [flags] ```bash linkedin navigator company search --term "fintech" --sizes "11-50,51-200" -linkedin navigator company search --revenue-min 10 --revenue-max 100 --locations "United States" --json +linkedin navigator company search --term "logistics" --revenue-min 10 --revenue-max 100 --locations "United States" --json ``` #### `navigator message send` diff --git a/package-lock.json b/package-lock.json index ea2e4c6..d318e48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@linkedapi/linkedin-cli", - "version": "1.2.23", + "version": "1.2.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@linkedapi/linkedin-cli", - "version": "1.2.23", + "version": "1.2.24", "license": "MIT", "dependencies": { "@linkedapi/node": "^2.3.21", diff --git a/package.json b/package.json index 8bc2ef8..2385182 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@linkedapi/linkedin-cli", - "version": "1.2.23", + "version": "1.2.24", "description": "AI-agent-friendly CLI for controlling LinkedIn accounts and retrieving real-time data.", "author": "Linked API", "license": "MIT", diff --git a/src/commands/company/fetch.ts b/src/commands/company/fetch.ts index d159391..3bf1883 100644 --- a/src/commands/company/fetch.ts +++ b/src/commands/company/fetch.ts @@ -3,6 +3,7 @@ import { Args, Flags } from '@oclif/core'; import { BaseCommand } from '@base-command'; import { formatOutput } from '@core/output/formatter'; import { runWorkflow } from '@core/workflow/workflow-runner'; +import { splitListFlag } from '@utils/split-list-flag'; export default class CompanyFetch extends BaseCommand { static override description = 'Fetch a LinkedIn company profile'; @@ -41,13 +42,16 @@ export default class CompanyFetch extends BaseCommand { description: 'Filter employees by position', }), 'employees-locations': Flags.string({ - description: 'Filter employees by locations (comma-separated)', + description: + 'Filter employees by locations (comma-separated, or a JSON array if a value contains a comma)', }), 'employees-industries': Flags.string({ - description: 'Filter employees by industries (comma-separated)', + description: + 'Filter employees by industries (comma-separated, or a JSON array if a value contains a comma)', }), 'employees-schools': Flags.string({ - description: 'Filter employees by schools (comma-separated)', + description: + 'Filter employees by schools (comma-separated, or a JSON array if a value contains a comma)', }), 'employees-connection-degrees': Flags.string({ description: 'Filter employees by your connection degree (comma-separated: 1st,2nd,3rd+)', @@ -90,15 +94,12 @@ export default class CompanyFetch extends BaseCommand { if (flags['employees-last-name']) filter.lastName = flags['employees-last-name']; if (flags['employees-position']) filter.position = flags['employees-position']; if (flags['employees-locations']) - filter.locations = flags['employees-locations'].split(',').map((s) => s.trim()); + filter.locations = splitListFlag(flags['employees-locations']); if (flags['employees-industries']) - filter.industries = flags['employees-industries'].split(',').map((s) => s.trim()); - if (flags['employees-schools']) - filter.schools = flags['employees-schools'].split(',').map((s) => s.trim()); + filter.industries = splitListFlag(flags['employees-industries']); + if (flags['employees-schools']) filter.schools = splitListFlag(flags['employees-schools']); if (flags['employees-connection-degrees']) - filter.connectionDegrees = flags['employees-connection-degrees'] - .split(',') - .map((s) => s.trim()); + filter.connectionDegrees = splitListFlag(flags['employees-connection-degrees']); if (Object.keys(filter).length > 0) config.filter = filter; if (Object.keys(config).length > 0) params.employeesRetrievalConfig = config; diff --git a/src/commands/company/search.ts b/src/commands/company/search.ts index 41fc751..78ddd44 100644 --- a/src/commands/company/search.ts +++ b/src/commands/company/search.ts @@ -1,8 +1,10 @@ import { Flags } from '@oclif/core'; import { BaseCommand } from '@base-command'; +import { EXIT_CODE } from '@core/errors/exit-codes'; import { formatOutput } from '@core/output/formatter'; import { runWorkflow } from '@core/workflow/workflow-runner'; +import { splitListFlag } from '@utils/split-list-flag'; export default class CompanySearch extends BaseCommand { static override description = 'Search for companies on LinkedIn'; @@ -10,7 +12,11 @@ export default class CompanySearch extends BaseCommand { static override flags = { ...BaseCommand.baseFlags, term: Flags.string({ - description: 'Search keyword or phrase', + description: 'Search keyword or phrase (required unless --url is given)', + }), + url: Flags.string({ + description: + 'LinkedIn companies search URL (linkedin.com/search/results/companies/...). Overrides --term and the filters', }), limit: Flags.integer({ description: 'Max results to return', @@ -19,31 +25,40 @@ export default class CompanySearch extends BaseCommand { description: 'Filter by company sizes (comma-separated, e.g. "1-10,11-50,51-200")', }), locations: Flags.string({ - description: 'Filter by locations (comma-separated)', + description: + 'Filter by locations (comma-separated, or a JSON array if a value contains a comma)', }), industries: Flags.string({ - description: 'Filter by industries (comma-separated)', + description: + 'Filter by industries (comma-separated, or a JSON array if a value contains a comma)', }), }; static override examples = [ '<%= config.bin %> company search --term "fintech" --sizes "11-50,51-200"', - '<%= config.bin %> company search --industries "Software Development" --json', + '<%= config.bin %> company search --term "payments" --industries "Software Development" --json', ]; public async run(): Promise { const { flags } = await this.parse(CompanySearch); + if (!flags.term && !flags.url) { + this.error('Either --term or --url is required; filters alone are not accepted.', { + exit: EXIT_CODE.VALIDATION, + }); + } + const client = await this.buildAuthenticatedClient(); const params: Record = {}; if (flags.term) params.term = flags.term; + if (flags.url) params.customSearchUrl = flags.url; if (flags.limit) params.limit = flags.limit; const filter: Record = {}; - if (flags.sizes) filter.sizes = flags.sizes.split(',').map((s) => s.trim()); - if (flags.locations) filter.locations = flags.locations.split(',').map((s) => s.trim()); - if (flags.industries) filter.industries = flags.industries.split(',').map((s) => s.trim()); + if (flags.sizes) filter.sizes = splitListFlag(flags.sizes); + if (flags.locations) filter.locations = splitListFlag(flags.locations); + if (flags.industries) filter.industries = splitListFlag(flags.industries); if (Object.keys(filter).length > 0) { params.filter = filter; diff --git a/src/commands/connection/list.ts b/src/commands/connection/list.ts index 01bbd29..b7e0417 100644 --- a/src/commands/connection/list.ts +++ b/src/commands/connection/list.ts @@ -3,6 +3,7 @@ import { Flags } from '@oclif/core'; import { BaseCommand } from '@base-command'; import { formatOutput } from '@core/output/formatter'; import { runWorkflow } from '@core/workflow/workflow-runner'; +import { splitListFlag } from '@utils/split-list-flag'; export default class ConnectionList extends BaseCommand { static override description = 'List your LinkedIn connections'; @@ -25,19 +26,24 @@ export default class ConnectionList extends BaseCommand { description: 'Filter by job position', }), locations: Flags.string({ - description: 'Filter by locations (comma-separated)', + description: + 'Filter by locations (comma-separated, or a JSON array if a value contains a comma)', }), industries: Flags.string({ - description: 'Filter by industries (comma-separated)', + description: + 'Filter by industries (comma-separated, or a JSON array if a value contains a comma)', }), 'current-companies': Flags.string({ - description: 'Filter by current companies (comma-separated)', + description: + 'Filter by current companies (comma-separated, or a JSON array if a value contains a comma)', }), 'previous-companies': Flags.string({ - description: 'Filter by previous companies (comma-separated)', + description: + 'Filter by previous companies (comma-separated, or a JSON array if a value contains a comma)', }), schools: Flags.string({ - description: 'Filter by schools (comma-separated)', + description: + 'Filter by schools (comma-separated, or a JSON array if a value contains a comma)', }), }; @@ -59,13 +65,13 @@ export default class ConnectionList extends BaseCommand { if (flags['first-name']) filter.firstName = flags['first-name']; if (flags['last-name']) filter.lastName = flags['last-name']; if (flags.position) filter.position = flags.position; - if (flags.locations) filter.locations = flags.locations.split(',').map((s) => s.trim()); - if (flags.industries) filter.industries = flags.industries.split(',').map((s) => s.trim()); + if (flags.locations) filter.locations = splitListFlag(flags.locations); + if (flags.industries) filter.industries = splitListFlag(flags.industries); if (flags['current-companies']) - filter.currentCompanies = flags['current-companies'].split(',').map((s) => s.trim()); + filter.currentCompanies = splitListFlag(flags['current-companies']); if (flags['previous-companies']) - filter.previousCompanies = flags['previous-companies'].split(',').map((s) => s.trim()); - if (flags.schools) filter.schools = flags.schools.split(',').map((s) => s.trim()); + filter.previousCompanies = splitListFlag(flags['previous-companies']); + if (flags.schools) filter.schools = splitListFlag(flags.schools); if (Object.keys(filter).length > 0) { params.filter = filter; diff --git a/src/commands/jobs/search.ts b/src/commands/jobs/search.ts index 200d975..38dd02c 100644 --- a/src/commands/jobs/search.ts +++ b/src/commands/jobs/search.ts @@ -4,6 +4,7 @@ import { BaseCommand } from '@base-command'; import { EXIT_CODE } from '@core/errors/exit-codes'; import { formatOutput } from '@core/output/formatter'; import { runWorkflow } from '@core/workflow/workflow-runner'; +import { splitListFlag } from '@utils/split-list-flag'; export default class JobsSearch extends BaseCommand { static override description = 'Search for jobs on LinkedIn'; @@ -39,13 +40,16 @@ export default class JobsSearch extends BaseCommand { description: 'Filter by workplace types (comma-separated: onSite, remote, hybrid)', }), companies: Flags.string({ - description: 'Filter by company names (comma-separated)', + description: + 'Filter by company names (comma-separated, or a JSON array if a value contains a comma)', }), industries: Flags.string({ - description: 'Filter by industries (comma-separated)', + description: + 'Filter by industries (comma-separated, or a JSON array if a value contains a comma)', }), 'job-functions': Flags.string({ - description: 'Filter by job functions (comma-separated)', + description: + 'Filter by job functions (comma-separated, or a JSON array if a value contains a comma)', }), 'easy-apply': Flags.boolean({ description: 'Only jobs with Easy Apply', @@ -80,7 +84,8 @@ export default class JobsSearch extends BaseCommand { 'AI-powered search: prefer employment types (comma-separated: fullTime, partTime, contract, internship, volunteer)', }), 'pref-companies': Flags.string({ - description: 'AI-powered search: prefer company names (comma-separated)', + description: + 'AI-powered search: prefer company names (comma-separated, or a JSON array if a value contains a comma)', }), 'pref-remote': Flags.boolean({ description: 'AI-powered search: prefer remote jobs', @@ -100,7 +105,7 @@ export default class JobsSearch extends BaseCommand { }), 'pref-keywords': Flags.string({ description: - 'AI-powered search: prefer skills, technologies or topics (comma-separated), such as AWS or Fintech', + 'AI-powered search: prefer skills, technologies or topics (comma-separated, or a JSON array if a value contains a comma), such as AWS or Fintech', }), }; @@ -122,12 +127,13 @@ export default class JobsSearch extends BaseCommand { const filter: Record = {}; if (flags['date-posted']) filter.datePosted = flags['date-posted']; if (flags['experience-levels']) - filter.experienceLevels = splitCsv(flags['experience-levels']); - if (flags['employment-types']) filter.employmentTypes = splitCsv(flags['employment-types']); - if (flags['workplace-types']) filter.workplaceTypes = splitCsv(flags['workplace-types']); - if (flags.companies) filter.companies = splitCsv(flags.companies); - if (flags.industries) filter.industries = splitCsv(flags.industries); - if (flags['job-functions']) filter.jobFunctions = splitCsv(flags['job-functions']); + filter.experienceLevels = splitListFlag(flags['experience-levels']); + if (flags['employment-types']) + filter.employmentTypes = splitListFlag(flags['employment-types']); + if (flags['workplace-types']) filter.workplaceTypes = splitListFlag(flags['workplace-types']); + if (flags.companies) filter.companies = splitListFlag(flags.companies); + if (flags.industries) filter.industries = splitListFlag(flags.industries); + if (flags['job-functions']) filter.jobFunctions = splitListFlag(flags['job-functions']); if (flags['easy-apply']) filter.easyApply = true; if (flags['has-verifications']) filter.hasVerifications = true; if (flags['under-10-applicants']) filter.under10Applicants = true; @@ -137,15 +143,15 @@ export default class JobsSearch extends BaseCommand { const preferences: Record = {}; if (flags['pref-date-posted']) preferences.datePosted = flags['pref-date-posted']; if (flags['pref-experience-levels']) - preferences.experienceLevels = splitCsv(flags['pref-experience-levels']); + preferences.experienceLevels = splitListFlag(flags['pref-experience-levels']); if (flags['pref-employment-types']) - preferences.employmentTypes = splitCsv(flags['pref-employment-types']); - if (flags['pref-companies']) preferences.companies = splitCsv(flags['pref-companies']); + preferences.employmentTypes = splitListFlag(flags['pref-employment-types']); + if (flags['pref-companies']) preferences.companies = splitListFlag(flags['pref-companies']); if (flags['pref-remote']) preferences.remote = true; if (flags['pref-easy-apply']) preferences.easyApply = true; if (flags['pref-under-10-applicants']) preferences.under10Applicants = true; if (flags['pref-in-your-network']) preferences.inYourNetwork = true; - if (flags['pref-keywords']) preferences.keywords = splitCsv(flags['pref-keywords']); + if (flags['pref-keywords']) preferences.keywords = splitListFlag(flags['pref-keywords']); if (Object.keys(filter).length > 0 && Object.keys(preferences).length > 0) { this.error( @@ -181,7 +187,3 @@ export default class JobsSearch extends BaseCommand { } } } - -function splitCsv(value: string): Array { - return value.split(',').map((s) => s.trim()); -} diff --git a/src/commands/navigator/company/fetch.ts b/src/commands/navigator/company/fetch.ts index 96b48e3..2649f63 100644 --- a/src/commands/navigator/company/fetch.ts +++ b/src/commands/navigator/company/fetch.ts @@ -3,6 +3,7 @@ import { Args, Flags } from '@oclif/core'; import { BaseCommand } from '@base-command'; import { formatOutput } from '@core/output/formatter'; import { runWorkflow } from '@core/workflow/workflow-runner'; +import { splitListFlag } from '@utils/split-list-flag'; export default class NavigatorCompanyFetch extends BaseCommand { static override description = 'Fetch a company profile via Sales Navigator'; @@ -34,16 +35,20 @@ export default class NavigatorCompanyFetch extends BaseCommand { description: 'Filter employees by last name', }), 'employees-positions': Flags.string({ - description: 'Filter employees by positions (comma-separated)', + description: + 'Filter employees by positions (comma-separated, or a JSON array if a value contains a comma)', }), 'employees-locations': Flags.string({ - description: 'Filter employees by locations (comma-separated)', + description: + 'Filter employees by locations (comma-separated, or a JSON array if a value contains a comma)', }), 'employees-industries': Flags.string({ - description: 'Filter employees by industries (comma-separated)', + description: + 'Filter employees by industries (comma-separated, or a JSON array if a value contains a comma)', }), 'employees-schools': Flags.string({ - description: 'Filter employees by schools (comma-separated)', + description: + 'Filter employees by schools (comma-separated, or a JSON array if a value contains a comma)', }), 'employees-years-of-experience': Flags.string({ description: 'Filter employees by experience ranges (comma-separated)', @@ -81,21 +86,16 @@ export default class NavigatorCompanyFetch extends BaseCommand { if (flags['employees-first-name']) filter.firstName = flags['employees-first-name']; if (flags['employees-last-name']) filter.lastName = flags['employees-last-name']; if (flags['employees-positions']) - filter.positions = flags['employees-positions'].split(',').map((s) => s.trim()); + filter.positions = splitListFlag(flags['employees-positions']); if (flags['employees-locations']) - filter.locations = flags['employees-locations'].split(',').map((s) => s.trim()); + filter.locations = splitListFlag(flags['employees-locations']); if (flags['employees-industries']) - filter.industries = flags['employees-industries'].split(',').map((s) => s.trim()); - if (flags['employees-schools']) - filter.schools = flags['employees-schools'].split(',').map((s) => s.trim()); + filter.industries = splitListFlag(flags['employees-industries']); + if (flags['employees-schools']) filter.schools = splitListFlag(flags['employees-schools']); if (flags['employees-years-of-experience']) - filter.yearsOfExperiences = flags['employees-years-of-experience'] - .split(',') - .map((s) => s.trim()); + filter.yearsOfExperiences = splitListFlag(flags['employees-years-of-experience']); if (flags['employees-connection-degrees']) - filter.connectionDegrees = flags['employees-connection-degrees'] - .split(',') - .map((s) => s.trim()); + filter.connectionDegrees = splitListFlag(flags['employees-connection-degrees']); if (Object.keys(filter).length > 0) config.filter = filter; if (Object.keys(config).length > 0) params.employeesRetrievalConfig = config; diff --git a/src/commands/navigator/company/search.ts b/src/commands/navigator/company/search.ts index 4e7dd42..0036152 100644 --- a/src/commands/navigator/company/search.ts +++ b/src/commands/navigator/company/search.ts @@ -1,8 +1,10 @@ import { Flags } from '@oclif/core'; import { BaseCommand } from '@base-command'; +import { EXIT_CODE } from '@core/errors/exit-codes'; import { formatOutput } from '@core/output/formatter'; import { runWorkflow } from '@core/workflow/workflow-runner'; +import { splitListFlag } from '@utils/split-list-flag'; export default class NavigatorCompanySearch extends BaseCommand { static override description = 'Search for companies via Sales Navigator'; @@ -10,7 +12,11 @@ export default class NavigatorCompanySearch extends BaseCommand { static override flags = { ...BaseCommand.baseFlags, term: Flags.string({ - description: 'Search keyword or phrase', + description: 'Search keyword or phrase (required unless --url is given)', + }), + url: Flags.string({ + description: + 'Sales Navigator companies search URL (linkedin.com/sales/search/company/...). Overrides --term and the filters', }), limit: Flags.integer({ description: 'Max results to return', @@ -19,10 +25,12 @@ export default class NavigatorCompanySearch extends BaseCommand { description: 'Filter by company sizes (comma-separated)', }), locations: Flags.string({ - description: 'Filter by locations (comma-separated)', + description: + 'Filter by locations (comma-separated, or a JSON array if a value contains a comma)', }), industries: Flags.string({ - description: 'Filter by industries (comma-separated)', + description: + 'Filter by industries (comma-separated, or a JSON array if a value contains a comma)', }), 'revenue-min': Flags.string({ description: 'Minimum annual revenue in million USD (0, 0.5, 1, 2.5, 5, 10, 20, 50, 100, 500, 1000)', @@ -39,16 +47,23 @@ export default class NavigatorCompanySearch extends BaseCommand { public async run(): Promise { const { flags } = await this.parse(NavigatorCompanySearch); + if (!flags.term && !flags.url) { + this.error('Either --term or --url is required; filters alone are not accepted.', { + exit: EXIT_CODE.VALIDATION, + }); + } + const client = await this.buildAuthenticatedClient(); const params: Record = {}; if (flags.term) params.term = flags.term; + if (flags.url) params.customSearchUrl = flags.url; if (flags.limit) params.limit = flags.limit; const filter: Record = {}; - if (flags.sizes) filter.sizes = flags.sizes.split(',').map((s) => s.trim()); - if (flags.locations) filter.locations = flags.locations.split(',').map((s) => s.trim()); - if (flags.industries) filter.industries = flags.industries.split(',').map((s) => s.trim()); + if (flags.sizes) filter.sizes = splitListFlag(flags.sizes); + if (flags.locations) filter.locations = splitListFlag(flags.locations); + if (flags.industries) filter.industries = splitListFlag(flags.industries); if (flags['revenue-min'] || flags['revenue-max']) { const revenue: Record = {}; if (flags['revenue-min']) revenue.min = flags['revenue-min']; diff --git a/src/commands/navigator/person/search.ts b/src/commands/navigator/person/search.ts index 25092cb..76cd5a0 100644 --- a/src/commands/navigator/person/search.ts +++ b/src/commands/navigator/person/search.ts @@ -1,8 +1,10 @@ import { Flags } from '@oclif/core'; import { BaseCommand } from '@base-command'; +import { EXIT_CODE } from '@core/errors/exit-codes'; import { formatOutput } from '@core/output/formatter'; import { runWorkflow } from '@core/workflow/workflow-runner'; +import { splitListFlag } from '@utils/split-list-flag'; export default class NavigatorPersonSearch extends BaseCommand { static override description = 'Search for people via Sales Navigator'; @@ -10,7 +12,11 @@ export default class NavigatorPersonSearch extends BaseCommand { static override flags = { ...BaseCommand.baseFlags, term: Flags.string({ - description: 'Search keyword or phrase', + description: 'Search keyword or phrase (required unless --url is given)', + }), + url: Flags.string({ + description: + 'Sales Navigator people search URL (linkedin.com/sales/search/people/...). Overrides --term and the filters', }), limit: Flags.integer({ description: 'Max results to return', @@ -25,19 +31,24 @@ export default class NavigatorPersonSearch extends BaseCommand { description: 'Filter by job position', }), locations: Flags.string({ - description: 'Filter by locations (comma-separated)', + description: + 'Filter by locations (comma-separated, or a JSON array if a value contains a comma)', }), industries: Flags.string({ - description: 'Filter by industries (comma-separated)', + description: + 'Filter by industries (comma-separated, or a JSON array if a value contains a comma)', }), 'current-companies': Flags.string({ - description: 'Filter by current companies (comma-separated)', + description: + 'Filter by current companies (comma-separated, or a JSON array if a value contains a comma)', }), 'previous-companies': Flags.string({ - description: 'Filter by previous companies (comma-separated)', + description: + 'Filter by previous companies (comma-separated, or a JSON array if a value contains a comma)', }), schools: Flags.string({ - description: 'Filter by schools (comma-separated)', + description: + 'Filter by schools (comma-separated, or a JSON array if a value contains a comma)', }), 'years-of-experience': Flags.string({ description: 'Filter by experience ranges (comma-separated: lessThanOne,oneToTwo,threeToFive,sixToTen,moreThanTen)', @@ -54,26 +65,34 @@ export default class NavigatorPersonSearch extends BaseCommand { public async run(): Promise { const { flags } = await this.parse(NavigatorPersonSearch); + if (!flags.term && !flags.url) { + this.error('Either --term or --url is required; filters alone are not accepted.', { + exit: EXIT_CODE.VALIDATION, + }); + } + const client = await this.buildAuthenticatedClient(); const params: Record = {}; if (flags.term) params.term = flags.term; + if (flags.url) params.customSearchUrl = flags.url; if (flags.limit) params.limit = flags.limit; const filter: Record = {}; if (flags['first-name']) filter.firstName = flags['first-name']; if (flags['last-name']) filter.lastName = flags['last-name']; if (flags.position) filter.position = flags.position; - if (flags.locations) filter.locations = splitCsv(flags.locations); - if (flags.industries) filter.industries = splitCsv(flags.industries); - if (flags['current-companies']) filter.currentCompanies = splitCsv(flags['current-companies']); + if (flags.locations) filter.locations = splitListFlag(flags.locations); + if (flags.industries) filter.industries = splitListFlag(flags.industries); + if (flags['current-companies']) + filter.currentCompanies = splitListFlag(flags['current-companies']); if (flags['previous-companies']) - filter.previousCompanies = splitCsv(flags['previous-companies']); - if (flags.schools) filter.schools = splitCsv(flags.schools); + filter.previousCompanies = splitListFlag(flags['previous-companies']); + if (flags.schools) filter.schools = splitListFlag(flags.schools); if (flags['years-of-experience']) - filter.yearsOfExperiences = splitCsv(flags['years-of-experience']); + filter.yearsOfExperiences = splitListFlag(flags['years-of-experience']); if (flags['connection-degrees']) - filter.connectionDegrees = splitCsv(flags['connection-degrees']); + filter.connectionDegrees = splitListFlag(flags['connection-degrees']); if (Object.keys(filter).length > 0) { params.filter = filter; @@ -96,7 +115,3 @@ export default class NavigatorPersonSearch extends BaseCommand { } } } - -function splitCsv(value: string): Array { - return value.split(',').map((s) => s.trim()); -} diff --git a/src/commands/person/search.ts b/src/commands/person/search.ts index 0322e4a..72e091b 100644 --- a/src/commands/person/search.ts +++ b/src/commands/person/search.ts @@ -1,8 +1,10 @@ import { Flags } from '@oclif/core'; import { BaseCommand } from '@base-command'; +import { EXIT_CODE } from '@core/errors/exit-codes'; import { formatOutput } from '@core/output/formatter'; import { runWorkflow } from '@core/workflow/workflow-runner'; +import { splitListFlag } from '@utils/split-list-flag'; export default class PersonSearch extends BaseCommand { static override description = 'Search for people on LinkedIn'; @@ -10,7 +12,11 @@ export default class PersonSearch extends BaseCommand { static override flags = { ...BaseCommand.baseFlags, term: Flags.string({ - description: 'Search keyword or phrase', + description: 'Search keyword or phrase (required unless --url is given)', + }), + url: Flags.string({ + description: + 'LinkedIn people search URL (linkedin.com/search/results/people/...), such as the searchUrl of an anonymous viewer from `stats viewers`. Overrides --term and the filters', }), limit: Flags.integer({ description: 'Max results to return', @@ -25,19 +31,24 @@ export default class PersonSearch extends BaseCommand { description: 'Filter by job position', }), locations: Flags.string({ - description: 'Filter by locations (comma-separated)', + description: + 'Filter by locations (comma-separated, or a JSON array if a value contains a comma)', }), industries: Flags.string({ - description: 'Filter by industries (comma-separated)', + description: + 'Filter by industries (comma-separated, or a JSON array if a value contains a comma)', }), 'current-companies': Flags.string({ - description: 'Filter by current companies (comma-separated)', + description: + 'Filter by current companies (comma-separated, or a JSON array if a value contains a comma)', }), 'previous-companies': Flags.string({ - description: 'Filter by previous companies (comma-separated)', + description: + 'Filter by previous companies (comma-separated, or a JSON array if a value contains a comma)', }), schools: Flags.string({ - description: 'Filter by schools (comma-separated)', + description: + 'Filter by schools (comma-separated, or a JSON array if a value contains a comma)', }), 'connection-degrees': Flags.string({ description: 'Filter by your connection degree (comma-separated: 1st,2nd,3rd+)', @@ -46,31 +57,40 @@ export default class PersonSearch extends BaseCommand { static override examples = [ '<%= config.bin %> person search --term "software engineer" --locations "San Francisco"', - '<%= config.bin %> person search --current-companies Google,Meta --json', + '<%= config.bin %> person search --term "engineer" --current-companies Google,Meta --json', '<%= config.bin %> person search --term "founder" --connection-degrees 2nd,3rd+ --json', + '<%= config.bin %> person search --url "https://www.linkedin.com/search/results/people/?keywords=CTO" --json', ]; public async run(): Promise { const { flags } = await this.parse(PersonSearch); + if (!flags.term && !flags.url) { + this.error('Either --term or --url is required; filters alone are not accepted.', { + exit: EXIT_CODE.VALIDATION, + }); + } + const client = await this.buildAuthenticatedClient(); const params: Record = {}; if (flags.term) params.term = flags.term; + if (flags.url) params.customSearchUrl = flags.url; if (flags.limit) params.limit = flags.limit; const filter: Record = {}; if (flags['first-name']) filter.firstName = flags['first-name']; if (flags['last-name']) filter.lastName = flags['last-name']; if (flags.position) filter.position = flags.position; - if (flags.locations) filter.locations = splitCsv(flags.locations); - if (flags.industries) filter.industries = splitCsv(flags.industries); - if (flags['current-companies']) filter.currentCompanies = splitCsv(flags['current-companies']); + if (flags.locations) filter.locations = splitListFlag(flags.locations); + if (flags.industries) filter.industries = splitListFlag(flags.industries); + if (flags['current-companies']) + filter.currentCompanies = splitListFlag(flags['current-companies']); if (flags['previous-companies']) - filter.previousCompanies = splitCsv(flags['previous-companies']); - if (flags.schools) filter.schools = splitCsv(flags.schools); + filter.previousCompanies = splitListFlag(flags['previous-companies']); + if (flags.schools) filter.schools = splitListFlag(flags.schools); if (flags['connection-degrees']) - filter.connectionDegrees = splitCsv(flags['connection-degrees']); + filter.connectionDegrees = splitListFlag(flags['connection-degrees']); if (Object.keys(filter).length > 0) { params.filter = filter; @@ -93,7 +113,3 @@ export default class PersonSearch extends BaseCommand { } } } - -function splitCsv(value: string): Array { - return value.split(',').map((s) => s.trim()); -} diff --git a/src/commands/post/search.ts b/src/commands/post/search.ts index 6845fac..863de1e 100644 --- a/src/commands/post/search.ts +++ b/src/commands/post/search.ts @@ -4,6 +4,7 @@ import { BaseCommand } from '@base-command'; import { formatOutput } from '@core/output/formatter'; import { runWorkflow } from '@core/workflow/workflow-runner'; import { parsePostSearchActorFilter } from '@utils/post-search-actor-filter'; +import { splitListFlag } from '@utils/split-list-flag'; export default class PostSearch extends BaseCommand { static override description = 'Search for posts on LinkedIn'; @@ -34,26 +35,27 @@ export default class PostSearch extends BaseCommand { }), 'from-members': Flags.string({ description: - 'Filter by people whose posts to keep (comma-separated name or name:identifier, where the identifier is a member URN or profile URL)', + 'Filter by people whose posts to keep (comma-separated, or a JSON array if a value contains a comma, of name or name:identifier, where the identifier is a member URN or profile URL)', }), 'from-companies': Flags.string({ description: - 'Filter by companies whose posts to keep (comma-separated name or name:identifier, where the identifier is an organization URN or company URL)', + 'Filter by companies whose posts to keep (comma-separated, or a JSON array if a value contains a comma, of name or name:identifier, where the identifier is an organization URN or company URL)', }), 'mentioning-members': Flags.string({ description: - 'Filter by people the post must mention (comma-separated name or name:identifier, where the identifier is a member URN or profile URL)', + 'Filter by people the post must mention (comma-separated, or a JSON array if a value contains a comma, of name or name:identifier, where the identifier is a member URN or profile URL)', }), 'mentioning-companies': Flags.string({ description: - 'Filter by companies the post must mention (comma-separated name or name:identifier, where the identifier is an organization URN or company URL)', + 'Filter by companies the post must mention (comma-separated, or a JSON array if a value contains a comma, of name or name:identifier, where the identifier is an organization URN or company URL)', }), 'author-companies': Flags.string({ description: - 'Filter by companies the author works at (comma-separated name or name:identifier, where the identifier is an organization URN or company URL)', + 'Filter by companies the author works at (comma-separated, or a JSON array if a value contains a comma, of name or name:identifier, where the identifier is an organization URN or company URL)', }), 'author-industries': Flags.string({ - description: 'Filter by industries the author works in (comma-separated)', + description: + 'Filter by industries the author works in (comma-separated, or a JSON array if a value contains a comma)', }), }; @@ -77,7 +79,7 @@ export default class PostSearch extends BaseCommand { if (flags.sort) filter.sort = flags.sort; if (flags['date-posted']) filter.datePosted = flags['date-posted']; if (flags['content-type']) filter.contentType = flags['content-type']; - if (flags['posted-by']) filter.postedBy = splitCsv(flags['posted-by']); + if (flags['posted-by']) filter.postedBy = splitListFlag(flags['posted-by']); if (flags['from-members']) filter.fromMembers = parsePostSearchActorFilter({ value: flags['from-members'], @@ -103,7 +105,8 @@ export default class PostSearch extends BaseCommand { value: flags['author-companies'], actorKind: 'company', }); - if (flags['author-industries']) filter.authorIndustries = splitCsv(flags['author-industries']); + if (flags['author-industries']) + filter.authorIndustries = splitListFlag(flags['author-industries']); if (Object.keys(filter).length > 0) { params.filter = filter; @@ -126,7 +129,3 @@ export default class PostSearch extends BaseCommand { } } } - -function splitCsv(value: string): Array { - return value.split(',').map((s) => s.trim()); -} diff --git a/src/utils/post-search-actor-filter.ts b/src/utils/post-search-actor-filter.ts index 5f8021b..89d9ccc 100644 --- a/src/utils/post-search-actor-filter.ts +++ b/src/utils/post-search-actor-filter.ts @@ -1,4 +1,5 @@ import { LINKEDIN_IDENTIFIER_PATTERN } from './linkedin-identifier-pattern'; +import { splitListFlag } from './split-list-flag'; const { startSource, @@ -31,8 +32,8 @@ interface TActorFilterWithId { type TActorFilterEntry = string | TActorFilterWithId; /** - * Parses a comma-separated `post search` actor flag, where each element is `name` or - * `name:identifier`. + * Parses a `post search` actor flag – a list as `splitListFlag` reads it – where each element is + * `name` or `name:identifier`. * * An element without an identifier stays the plain string it always was, so existing calls send * exactly what they sent before. An identifier must name the flag's kind of entity: a person for @@ -45,7 +46,7 @@ export function parsePostSearchActorFilter({ value: string; actorKind: TActorKind; }): Array { - return value.split(',').map((element) => parseElement({ element: element.trim(), actorKind })); + return splitListFlag(value).map((element) => parseElement({ element, actorKind })); } function parseElement({ diff --git a/src/utils/split-list-flag.ts b/src/utils/split-list-flag.ts new file mode 100644 index 0000000..b380578 --- /dev/null +++ b/src/utils/split-list-flag.ts @@ -0,0 +1,37 @@ +/** + * Splits a list flag into its values: comma-separated, or a JSON array of strings when the flag + * starts with `[`. + * + * The JSON form exists for values that contain a comma themselves – a "City, Region" location, an + * industry such as "Technology, Information and Internet", a company such as "Example Co., Ltd." – + * which a comma list would silently split into separate filters. + */ +export function splitListFlag(value: string): Array { + const trimmedValue = value.trim(); + + if (!trimmedValue.startsWith('[')) { + return value.split(',').map((element) => element.trim()); + } + + return parseJsonList(trimmedValue); +} + +function parseJsonList(value: string): Array { + const parsedValue = parseJson(value); + + if (!Array.isArray(parsedValue) || !parsedValue.every((element) => typeof element === 'string')) { + throw new Error( + `Invalid list value: ${value}. Expected comma-separated values or a JSON array of strings, e.g. '["Chicago, Illinois","Denver"]'`, + ); + } + + return parsedValue.map((element) => element.trim()); +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +}