Files
jackwener__opencli/clis/indeed/utils.js
jakevin 29b4869efd feat(indeed): add search and job adapters (US site) (#1298)
* feat(indeed): add `search` and `job` adapters (US site)

Adds an Indeed adapter that fills the US job-search gap (alongside
existing 51job / boss-zhipin / linkedin coverage). Both commands run
through a real browser session because Indeed sits behind Cloudflare
and answers bare HTTP fetches with `403` + `cf-mitigated: challenge`.

## Commands

- `indeed search <query>` — keyword job search
  - args: `query`, `--location`, `--fromage`, `--sort`, `--start`, `--limit`
  - columns: `rank, id, title, company, location, salary, tags, url`
- `indeed job <jk>` (alias `detail`, `view`) — full job posting
  - args: `id` (positional, the 16-char hex `jk` from `search`)
  - columns: `id, title, company, location, salary, job_type, description, url`

## Listing↔detail id pairing

`search.id` is the Indeed `jk` (job key, 16-char lowercase hex). It feeds
directly into `indeed job <jk>`. Conforms to the listing↔detail id
pairing convention proposed in #1297.

## CF challenge handling

The adapter polls the result selectors for up to 15s after navigation,
giving the browser time to clear the Cloudflare interstitial. If the
challenge is still up after the wait, the adapter throws a
`CommandExecutionError` with a hint pointing the user at the connected
browser to clear it once. Subsequent calls reuse the warmed cookies via
`Strategy.COOKIE`, mirroring the v2ex / boss / linkedin patterns.

## Validation

`utils.js` keeps argument validation pure and unit-testable:

- `requireJobKey` rejects anything that isn't a 16-char lowercase hex
- `requireFromage` only accepts `1` / `3` / `7` / `14` (Indeed's enum)
- `requireSort` only accepts `relevance` / `date`
- `requireBoundedInt(limit, default=15, max=25)` — Indeed serves at most
  one page (10 jobs/page); ArgumentError on out-of-range, no silent
  clamping, per the typed-error feedback in #1289.

## Tests

18 unit tests in `clis/indeed/indeed.test.js` cover registration,
validators, URL builders, and DOM-card normalizers. Browser-driven
verification stays out of CI by design (CF challenge is interactive).

## Docs

- `docs/adapters/browser/indeed.md` — full adapter doc with prerequisite
  CF-challenge notes and listing↔detail id pairing callout.
- Sidebar entry + adapter index row.

* fix(indeed): tighten timeout fail-fast and runtime tests

* fix(indeed): align readiness with search parser
2026-05-04 20:52:16 +08:00

153 lines
5.1 KiB
JavaScript

/**
* Indeed adapter utilities.
*
* Indeed sits behind Cloudflare and answers bare fetches with HTTP 403
* (`cf-mitigated: challenge`). The whole adapter therefore runs through a
* real browser session (Strategy.COOKIE), and DOM extraction lives inside
* `page.evaluate` blocks. The helpers below are mostly arg-validation +
* pure normalizers so they stay unit-testable without a browser.
*/
import { ArgumentError } from '@jackwener/opencli/errors';
export const INDEED_ORIGIN = 'https://www.indeed.com';
/** Job key (jk) shape — 16-char lowercase hex. */
const JK_PATTERN = /^[a-f0-9]{16}$/;
const FROMAGE_VALUES = new Set(['1', '3', '7', '14']);
const SORT_VALUES = new Set(['relevance', 'date']);
/**
* Coerce a value to a strict integer. Accepts numeric strings, rejects
* floats / non-numeric / NaN. Returns NaN on invalid input so callers can
* decide on the right typed error.
*/
export function coerceInt(value) {
if (value === undefined || value === null || value === '') return NaN;
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) && Number.isInteger(n) ? n : NaN;
}
export function requireBoundedInt(value, defaultValue, maxValue, label) {
const raw = value ?? defaultValue;
const n = coerceInt(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`indeed ${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`indeed ${label} must be <= ${maxValue}`);
}
return n;
}
export function requireNonNegativeInt(value, defaultValue, label) {
const raw = value ?? defaultValue;
const n = coerceInt(raw);
if (!Number.isInteger(n) || n < 0) {
throw new ArgumentError(`indeed ${label} must be a non-negative integer`);
}
return n;
}
export function requireJobKey(value) {
const id = String(value ?? '').trim().toLowerCase();
if (!id) {
throw new ArgumentError('indeed job id is required');
}
if (!JK_PATTERN.test(id)) {
throw new ArgumentError(`indeed job id "${value}" is not a valid jk (expected 16-char lowercase hex)`);
}
return id;
}
export function requireQuery(value, label = 'query') {
const q = String(value ?? '').trim();
if (!q) {
throw new ArgumentError(`indeed ${label} cannot be empty`);
}
return q;
}
/** "1" / "3" / "7" / "14" — accepted by Indeed's `fromage` filter. */
export function requireFromage(value) {
if (value === undefined || value === null || value === '') return '';
const v = String(value).trim();
if (!FROMAGE_VALUES.has(v)) {
throw new ArgumentError(`indeed fromage must be one of 1/3/7/14 (days), got "${value}"`);
}
return v;
}
export function requireSort(value, defaultValue = 'relevance') {
const v = String(value ?? defaultValue).trim().toLowerCase();
if (!SORT_VALUES.has(v)) {
throw new ArgumentError(`indeed sort must be "relevance" or "date", got "${value}"`);
}
return v;
}
/**
* Build an Indeed search URL with only the user-supplied filters set.
* Indeed treats absent params as defaults; we never pass empty strings
* because the page will echo them back into the query and the URL
* stays cleaner for round-tripping.
*/
export function buildSearchUrl({ query, location, fromage, sort, start }) {
const params = new URLSearchParams();
params.set('q', query);
if (location) params.set('l', location);
if (fromage) params.set('fromage', fromage);
if (sort && sort !== 'relevance') params.set('sort', sort);
if (start && start > 0) params.set('start', String(start));
return `${INDEED_ORIGIN}/jobs?${params.toString()}`;
}
export function buildJobUrl(jk) {
return `${INDEED_ORIGIN}/viewjob?jk=${jk}`;
}
/** Strip a salary-snippet duplicate out of the metadata pill list. */
export function dedupeTags(tags, salary) {
const out = [];
for (const t of tags) {
const trimmed = String(t || '').trim();
if (!trimmed) continue;
if (salary && trimmed === salary) continue;
if (out.includes(trimmed)) continue;
out.push(trimmed);
}
return out.join(' · ');
}
/**
* Normalize a parsed search-card object into the row shape declared by
* `SEARCH_COLUMNS`. Drops nulls into empty strings, defends against
* Indeed surface drift by treating missing fields as empty rather than
* silently mislabeling them.
*/
export function searchCardToRow(card, rank) {
const jk = String(card?.jk ?? '').trim();
const salary = String(card?.salary ?? '').trim();
const tags = Array.isArray(card?.tags) ? card.tags : [];
return {
rank,
id: jk,
title: String(card?.title ?? '').replace(/\s+/g, ' ').trim(),
company: String(card?.company ?? '').replace(/\s+/g, ' ').trim(),
location: String(card?.location ?? '').replace(/\s+/g, ' ').trim(),
salary,
tags: dedupeTags(tags, salary),
url: jk ? buildJobUrl(jk) : '',
};
}
export const SEARCH_COLUMNS = [
'rank', 'id', 'title', 'company', 'location', 'salary', 'tags', 'url',
];
export const JOB_COLUMNS = [
'id', 'title', 'company', 'location', 'salary', 'job_type', 'description', 'url',
];