* feat(linkedin-search): add active status verification for job postings
* fix(linkedin-search): scope closed-posting detection to the top card, pin with tests (#280)
The first version matched five markers against the whole document, so
recruiter boilerplate quoting 'no longer accepting applications' in a
description flagged a live job CLOSED. Detection now stops where the
description markup begins and matches only the two markers real closed
pages carry (closed-job__flavor and the banner text, verified against
live guest pages); the three speculative phrases are dropped. Four new
fixture tests pin both directions plus the two description false-positive
cases - the false-positive pair fails on the unscoped version.
* feat(scrape): mark closed-at-source LinkedIn postings expired, never drop (#280)
/scrape Step 2 now consumes linkedin-search detail's isActive: a job whose
posting page renders the closed banner is written to seen_jobs.json with
status expired rather than silently dropped, per the /rank marking pattern -
the fix for the ghost-jobs class in #331. isActive: true is documented as
absence of the banner, not proof the posting is open.
---------
Co-authored-by: Navakanth Reddy Dumpa <navkanthr@gmail.com>
Silently discarded flags produced silently wrong results: jobdanmark
with --query (its real flag is --text) returned all 13,862 jobs as if
they matched, exit 0, empty stderr - indistinguishable from a real
result set. The four bunli CLIs get an argv preflight built from each
command's own options object; linkedin and freehire validate parsed
flags against per-command known sets. help/version still pass, and
add-portal.md's existing bogus-flag-exits-1 contract now holds for the
reference implementations contributors copy. One linkedin pin updated:
"--jobage-minutes -5" now fails as UNKNOWN_FLAG (the stray -5 token)
rather than BAD_ARG - same loud-failure invariant, earlier gate. Review
finding F13 (2026-08-19), decision approved by Mads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The regex required class= before href= within one tag; LinkedIn's real
markup puts href first, so applyUrl was null on every live posting while
SKILL.md claimed the command returns an apply link. Fixing the regex
would only yield the job-view URL - a duplicate of url - so the field is
removed rather than repaired, and a test pins the removal. Review
finding F19 (2026-08-19), decision approved by Mads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The linkedin fixture was purpose-built for entity decoding and had no
<time> or location element, so removing the date extraction - a /scrape
contract field on a default-ON portal - survived the suite. jobindex's
parseSearchPage (the Stash parser behind every search) had zero tests,
so meta.total silently dropping hitcount survived too. Both mutations
now fail exactly the new tests. The ASAP deadline branch is deliberately
left to the F12 fix, which changes its behaviour to null. Review finding
F35 (2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last portal CLI still sending a full Chrome spoof after #283 and
4551346. Live-verified: search and detail endpoints serve identical
responses to Mozilla/5.0 (compatible; linkedin-search-cli/1.0).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jobageToTPR() only emits whole-day f_TPR windows, so a search can't be
restricted to postings from the last N minutes. LinkedIn's f_TPR filters
server-side down to one-second granularity (confirmed empirically), so
this is a pure window-construction change via a new minutesToTPR()
helper - no HTML parsing changes needed.
--jobage-minutes and --jobage both express a freshness window; passing
both is rejected with CONFLICTING_AGE_FLAGS rather than one silently
overriding the other.
* test(cli): pin the 429/5xx retry contract in all six portal CLIs
The portal-skill contract requires backoff on 429/5xx, and every CLI
implements it - a retry loop with exponential delay and jitter - but
nothing verified the loops actually retry, stop retrying on plain
4xx, or give up after the documented attempt budget. A regression
here is invisible: a CLI that stops retrying still works on every
healthy request.
Each CLI gains tests/retry-backoff.test.ts, network-free, using the
request-timeout.test.ts pattern from #197 (import the fetch wrapper,
stub globalThis.fetch): a stubbed fetch counts attempts, and a
stubbed setTimeout fires immediately so the exhaustion case does not
sleep through the real 500ms -> 5s/8s backoff schedule (tests run in
milliseconds, not ~17s).
Three assertions per fetch wrapper, adapted to each CLI's documented
semantics:
- a 429 is retried and the next attempt's result is returned
- a plain 4xx is not retried (jobbank's fetchWithUA RETURNS the
response for callers to handle - pinned as such; linkedin's
htmlFetch returns "" on 404; freehire's apiGet returns null)
- persistent 5xx gives up after the initial attempt plus six
retries (7 fetch calls) with the status in the error
freehire additionally pins its documented graceful-degradation
contract: a connection failure fails fast with no retry. jobdanmark
exercises both apiFetch and apiPost, which carry separate copies of
the loop that could drift apart.
Mutation-checked: changing maxRetries in jobindex makes the
exhaustion test fail, so the tests distinguish the current behavior
from a silently altered one.
Verified: bun test green in all six CLIs (jobindex 19, jobnet 20,
jobbank 20, jobdanmark 21, linkedin 21, freehire 31 - 0 fail);
tsc --noEmit clean in all six; python3 tools/lint_skills.py OK.
* test(jobindex): pin apiFetch's retry loop alongside htmlFetch's
Review parity gap: jobindex carries two separate copies of the retry
loop and only htmlFetch was exercised, so apiFetch's retry budget
could drift silently - the same situation jobdanmark's test already
handles for its apiFetch/apiPost pair.
apiFetch gets the same three assertions, adapted to its documented
semantics (JSON return on success, throw on plain 4xx): a 429 is
retried and the next attempt's parsed body returned, a 400 is not
retried, persistent 5xx gives up after the initial attempt plus six
retries (7 calls).
Mutation-checked on the new axis: changing apiFetch's maxRetries
(the file's first copy of the loop) fails its exhaustion test while
htmlFetch's tests stay green, so each wrapper is now pinned
independently.
Verified: bun test 30 pass / 0 fail (full jobindex suite);
tsc --noEmit clean.
CI runs `bun install` (not --frozen-lockfile) and the CLIs' package.json
pinned @types/bun, @bunli/core, and @bunli/utils to "latest", so each fresh
install could resolve a different version than the lockfile. When a "latest"
bun-types resolved that didn't satisfy the tsconfig (lib: ["ESNext"] with no
DOM, types: ["bun-types"] as the only source of Response/URL/fetch globals),
`bun run typecheck` failed across every .ts file - a transient red on PRs that
never touched TypeScript (observed on #207, which changes only SKILL.md).
Pin the three previously-floating dev/framework deps to the versions the
lockfiles already resolve, so behavior is unchanged and the drift class is
gone:
- @types/bun: latest -> 1.3.14 (all 6 CLIs)
- @bunli/core: latest -> 0.9.1 (jobbank, jobdanmark, jobindex, jobnet)
- @bunli/utils: latest -> 0.6.0 (jobbank, jobdanmark, jobindex, jobnet)
The ^-ranged deps (node-html-parser, zod, typescript) are left as-is; they are
semver-guarded and were not the cause. No lockfiles committed (bun.lock stays
gitignored per existing policy). Verified: all 6 CLIs install and `bun run
typecheck` clean with the pins.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The jobindex and linkedin detail parsers matched description containers with a non-greedy regex that stops at the first inner </div>, so any posting whose description contains nested divs was silently truncated (jobindex dropped later sections; linkedin dropped everything after the first block). Replaces the regex with a depth-tracked extractDivContent scanner that walks div open/close markers to the matching close. Verified: truncation bug reproduced against real markup fixtures, depth arithmetic correct (no off-by-one/infinite-loop), 28 tests pass network-free, no regression on non-nested divs. Malformed-HTML over-grabs rather than truncates - the safer failure, cleaned by downstream stripTags/decode.
By @oscarbol09.
The freehire and linkedin CLIs called main().then(code => process.exit(code)) with no .catch(). If main() throws or returns a rejected promise, the .then() never runs, process.exit() is never called, and the runtime terminates with exit code 0 - a runtime failure becomes indistinguishable from success (and would suppress the Step 1c WebSearch fallback, which keys on non-zero exit). Adds a .catch() that writes the same JSON error shape the rest of each file already uses ({error, code: INTERNAL_ERROR} to stderr) and exits 1. Scoped to the two self-contained CLIs only; the bunli portals catch internally via defineCommand.
By @oscarbol09.
None of the board CLIs set a fetch timeout, and the retry loops react only to HTTP status codes, not to a connection that is accepted then never responds (black-holed TCP, hung TLS, stalled proxy) - so await fetch(...) never settles and the command hangs with no output and no exit. freehire's helper even documented a fast-degrade contract its try/catch didn't deliver on a mid-flight stall. Adds signal: AbortSignal.timeout(15000) to every fetch across all six CLIs, with network-free tests asserting the signal is present on each request wrapper.
By @thejesh23. Verified: 8 timeout tests pass locally with fetch stubbed (no network), and would fail on the pre-fix code.
Closes#196
decodeHtmlEntities only handled decimal numeric character references
(é); the equally valid hexadecimal form (é) fell through
undecoded and surfaced as raw text in titles, companies, locations and
descriptions. It also used String.fromCharCode, which corrupts
supplementary-plane code points (e.g. emoji, U+1F600).
Add a hexadecimal numeric-entity rule and route both decimal and hex
through a fromCodePoint-based helper with a valid-range guard. Add
network-free unit tests covering hex, uppercase-X hex, decimal
(regression) and astral code points via the exported parse functions.
* fix: silent zero output in salary converter and NaN filter bypass in LinkedIn CLI
Bug #10 (convert_salary_excel.py):
openpyxl ws[row_index] random access fails silently under read_only=True,
leaving headers empty and producing no output. Fix: save the header row
values during the existing iter_rows scan so ws[header_row] is never called.
Bug #5 (.agents/skills/linkedin-search/cli/src/cli.ts):
parseInt on --jobage/--page/--limit flags returns NaN on non-numeric input.
NaN propagates silently — the jobage filter is dropped, page/limit are broken.
Fix: validate each parsed int, exit 1 with a structured BAD_ARG error on NaN.
Also adds:
- tests/test_bug10_salary_converter.py: 10 scenarios, 31 assertions (all green)
- tests/test_bug5_linkedin_cli.sh: 8 scenarios, 16 assertions (all green)
- docs/bugfixes.md: root cause, impact, and fix explanation for both bugs,
plus a note on the pre-existing detect_column_type "n" pattern issue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address PR review — drop salary converter change, move test to bun
- Drop tools/convert_salary_excel.py change (bug not reproducible on
modern openpyxl; reviewer confirmed master works correctly)
- Drop tests/test_bug10_salary_converter.py and top-level tests/ dir
- Drop docs/bugfixes.md (analysis belongs in PR description, not repo)
- Replace tests/test_bug5_linkedin_cli.sh with a proper bun test file
at .agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts
following the jobindex-search/cli/tests/ convention (runCLI/parseJSON
helpers, describe/test/expect, descriptive names)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
A general-purpose, field-agnostic job-search skill built on LinkedIn's public
jobs-guest endpoints. Works for any market out of the box — location is an
explicit required flag (no country default). Zero runtime dependencies (bun only);
search + detail commands.
Includes a personal-use / Terms-of-Service note (automated access is against
LinkedIn's ToS — keep volume low, non-commercial).
(Pairs with the .gitignore fix in #21, which lets skills under .agents/ be tracked.)
Co-authored-by: Akhil Tripathi <kodabear@Akhils-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>