* fix(linkedin-search): require a value for --query instead of dropping the keywords
parseFlags stores a bare -q as true and -q "" as an empty string, and the
search took --query only when it was a non-empty string. A bare flag, an
empty argument or a blank query therefore returned every posting in the
location with exit 0. Reject a present --query without keywords with
BAD_ARG before any request; omitting the flag stays a location-only search,
matching the Danish portals (#573) and freehire-search (#568).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* changelog: place the #646 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
* fix(linkedin-search): reject an unknown --remote mode instead of searching every workplace type
workTypeFlag() maps a mode it does not recognise to null, and the search URL
then carries no f_WT. So --remote remote,hybrid, a misspelt mode or a bare
--remote returned onsite postings too, with exit 0 and an empty stderr.
Validate the mode before dispatch and exit 1 with BAD_ARG; remote, hybrid,
onsite and the accepted on-site spelling keep their f_WT values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* changelog: place the #645 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
* fix(salary): skip prose notes tabs instead of listing sentences as companies
A workbook's notes tab ("Each row is one company in the survey.") has no
table header, so the strict header pass finds nothing and the fallback
takes the first line mentioning a company word. With no salary column
beside it, every following sentence was emitted as a company entry and
showed up in --list-all and lookups. A fallback-detected header with no
salary columns now skips the sheet with a warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): record fix for #644
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* changelog: place the #644 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
* fix(salary): collapse line breaks in wrapped workbook headers
A wrapped header cell ("Median\nsalary", "Løn-\nindeks") kept its line
break, so the converter derived a category name containing a newline:
the column no longer paired with its count column, and salary_lookup
printed the category label across two lines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): record fix for #643
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* changelog: place the #643 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
* fix(rank): keep every --exclude key out of the sweep when the list has spaces
`sweep --exclude "a_b, c_d"` split on bare commas, so " c_d" matched no
entry and every key after the first was swept. On an --all run that
expired a re-scored entry whose fresh deadline apply then re-ranked, so
the report listed it as both expired and shortlisted. Strip each key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* changelog: place the #641 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
* fix(rank): report a tracker that is not UTF-8 instead of a traceback
A job_search_tracker.csv saved with a spreadsheet's plain "CSV" type is
written in the ANSI code page, so one accented company name raised a
UnicodeDecodeError traceback in `rank_state.py candidates` and stopped
/rank at Step 1. Exit 1 with a one-line message instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* changelog: place the #639 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
* fix(rank): report malformed scoring results in apply instead of a traceback
A result that is not an object, a non-string key or a non-object scores value
raised AttributeError/TypeError and discarded the batch. They now become
errors rows and valid results in the same file still apply.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* changelog: place the #635 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
* fix(job-key): report a non-string url in --audit instead of a traceback
A numeric, list or object url crashed the duplicate-URL pass with an
AttributeError on .rstrip. Report it with a one-line error naming the key,
as the other malformed-state branches do.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* changelog: place the #634 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
* fix(freehire-search): reject --description-format on detail instead of ignoring it
The flag was on detail's known-flag list, but runDetail never reads it and
always prints the detail endpoint's HTML body stripped to plain text, so
detail <slug> --description-format markdown passed the unknown-flag guard,
exited 0 and printed text. Drop it from detail's list so it fails with
UNKNOWN_FLAG before any request; search keeps the flag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* changelog: place the #648 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
doc.get("seen", doc) raised AttributeError on a top-level array, and
entry.get("url") raised it on an entry that was not an object, so the
existing "expected an object of job entries" message was never reached.
Guard both and exit 1 with a one-line message.
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(jobnet-search): preserve full occupation identifiers in tables
The documented ESCO concept URI is 72 characters long, but the table renderer cut every identifier to 70, losing the last two UUID characters. Size the identifier column from its contents, retaining the existing minimum width and readable label alignment.
Exercise the actual documented occupations CLI with the README API response served over loopback. Table output must retain the same complete URI and UUID as JSON and plain output; no live portal requests.
* test(jobnet-search): cover Windows occupation fixture newlines
* docs(jobindex-search): align search examples with the actual CLI contract
The posted-today skill example omits the query that the shipped CLI requires, so copying it exits MISSING_REQUIRED. Supply a keyword and make the required flag explicit. Replace stale JSON-endpoint/card-parser search documentation with the implemented HTML Stash route and emitted field behavior.
Run the example read directly from SKILL.md through a real CLI subprocess with loopback HTTP fixtures. Keep a separate query/page routing control; no live portal requests or production-code changes.
* test(jobindex-search): cover Windows documentation newlines
* fix(portal-clis): reject missing Danish option values before dispatch
The Bunli parser discards known bare options before schema validation, silently dropping filters or applying defaults. Extend the existing pre-dispatch guards across the four Danish portals while preserving built-in switches, valued and equals-form arguments, and existing unknown-flag errors.
Actual CLI subprocess regressions deny and record every fetch to demonstrate that missing filters reach request construction on master. Valued-option and help controls keep accepted command forms covered without contacting any portal.
* docs(changelog): record fix for #573
* test(portal-clis): isolate valid flag controls from live services
The LinkedIn and Freehire positive controls spawned live search CLIs and only checked for the absence of BAD_ARG, so attempted portal requests still left a green suite. Use loopback responses and assert successful output plus the constructed freshness, pagination and result-limit contract.
LinkedIn cards are verbatim excerpts from portapulito's actual captured response in PR #153; fixture provenance and original hashes are retained. Four Danish validation suites already stop at required-argument guards and need no change. This isolates these controls rather than claiming a whole-CI egress policy.
* docs(changelog): record fix for #581
* fix(linkedin-search): preserve description paragraph and list breaks
Clean rendered description lines separately so whitespace normalization does not erase paragraph and list boundaries in JSON and plain output. Exercise the documented detail CLI against a loopback-served captured response, with field and closed-status controls.
Credits: portapulito diagnosed this behavior and supplied the captured guest-response fixture in upstream PR #153, which was closed as an accidental personalized-fork submission. This independent implementation revives only that correctness fix and its fixture evidence.
* docs(changelog): record fix for #567
* fix(jobbank-search): retain all documented repeated filter values
Declare the six array-capable search options repeatable in Bunli metadata. Without it, the CLI parser overwrites earlier occurrences before the handler receives the array, so documented OR filters only search the final value.
Exercise actual search CLI subprocesses through loopback HTTP and assert every RSS and total-count request retains repeated values. Cover all six filters, mixed equals/comma input, and unchanged single-value input; no live portal requests.
* docs(changelog): record fix for #577