lib/scan-history-columns.mjs
Rama main · réplica de lectura del repositorio original.
/**
* scan-history-columns.mjs — the column layout of `scan-history.tsv`, and the
* way to read a row by column name instead of by position.
*
* The file is positional and append-only. Every scanner appends one row per
* posting it has seen; a column added later goes at the END of every newer
* row, and rows written before it existed simply stop short. The header is
* written only when the file is created and never rewritten, so an existing
* file can carry an older, shorter header than its newest rows — or none at
* all. That makes the order declared here, not the file's own header, the
* authority on what each position means.
*
* What each column holds is documented in the Scan History table of
* `modes/scan.md`. Adding a column: append its name to
* {@link SCAN_HISTORY_COLUMNS}, fill it in the scanner's row writer
* (`formatScanHistoryRow` in `scan.mjs`), and add a row to that table.
*
* Neither helper recognises the header row: its `url` cell is the literal
* `url`, and callers skip it.
*
* Cells are stored with reversible spreadsheet-formula escaping
* (`lib/tsv-formula-escape.mjs`), which {@link parseScanHistoryLine} undoes. A
* reader that splits lines itself sees the stored form.
*
* No dependency beyond `lib/tsv-formula-escape.mjs`, so any script can import
* it without loading the scanner.
*/
import { unescapeFormulaCell } from './tsv-formula-escape.mjs';
/**
* The columns of `scan-history.tsv`, in file order — shared by the scanner's
* row writer, the header of a freshly created file and every reader that goes
* through {@link parseScanHistoryLine}.
*
* Append only: a new name goes at the END, never in between, and existing
* names never move. Readers that still index the file by position rely on
* that; list every scan-history reader with
* `grep -rln "scan-history" --include=*.mjs --include=*.ts .`.
*/
export const SCAN_HISTORY_COLUMNS = Object.freeze([
'url',
'first_seen',
'portal',
'title',
'company',
'status',
'location',
'fingerprint',
'posted_at',
'trust_score',
'trust_flags',
'normalized_company',
'requisition_id',
'language',
'listing_key',
'closes_at',
]);
/**
* Statuses recorded for VISIBILITY only, which must never pin a URL for dedup.
*
* Every other skipped status describes the posting: a dead URL stays dead
* until the configured recheck window releases it (see
* RECHECKABLE_SCAN_HISTORY_STATUSES), a blocked host stays blocked, so pinning
* saves a later scan the work. These four describe something else.
*
* `skipped_title`, `skipped_location` and `skipped_age` describe the user's
* CONFIG — `title_filter`, `location_filter` and `max_posting_age_days` are
* settings they edit. Pinning would mean a role dropped under the old setting
* never resurfaces under the new one, which is the opposite of what recording
* the drop is for: a keyword added to `title_filter.positive` has to hand back
* the titles it now matches. For those three, not pinning is also free: each
* cut runs on data the provider already returned, before any liveness
* verification, so a re-scan of one of these URLs costs no extra request.
*
* `skipped_no_apply_control` describes the CHECK: the browser classifier did
* not see an Apply control, which is not proof the posting is closed. On
* boards whose button it does not recognise, pinning would drop a live posting
* once and never offer it again (#4832). Unlike the other two, this verdict
* comes from a browser load, so every `--verify` scan re-opens the posting for
* as long as the provider lists it. That is the accepted price: a slower
* `--verify` beats a posting lost for good, and it shrinks as the classifier
* learns each board, since a posting that reads `active` gets an `added` row
* and stops coming back.
*
* `skipped_content`, `skipped_visa` and `skipped_country_eligibility` are
* scan-dayforce.mjs's rows for its `content_filter`, visa and
* `country_eligibility_filter` cuts: CONFIG again, by the same reasoning as the
* first three. They were missing here, so they fell through to the permanent
* pin, and widening one of those filters never handed back a Dayforce posting
* it had dropped. (scan.mjs counts the same cuts and records no row for them.)
*
* `collectSeenCompanyRoles` needs no companion change — it already seeds from
* `added` rows alone.
*/
export const OBSERVATIONAL_SCAN_HISTORY_STATUSES = new Set([
'skipped_title',
'skipped_location',
'skipped_age',
'skipped_no_apply_control',
'skipped_content',
'skipped_visa',
'skipped_country_eligibility',
'skipped_experience',
]);
/**
* The data lines of a scan-history.tsv text, with line 0 dropped only when it
* IS the header. A headerless legacy file starts with a data row, and a reader
* that always dropped line 0 lost that row. #4807 fixed this in scan.mjs's
* collectSeenUrls; the other readers kept the blind `.slice(1)`, so one file
* could be read with its first row in one place and without it in another.
*
* @param {string|null|undefined} text - Raw scan-history.tsv content.
* @returns {string[]}
*/
export function scanHistoryDataLines(text) {
const lines = String(text ?? '').split('\n');
if (lines[0].startsWith('url\t')) lines.shift();
return lines;
}
/**
* One scan-history line as a record keyed by {@link SCAN_HISTORY_COLUMNS},
* each cell with the formula escaping undone. A cell the line is too short to
* carry is ''.
*
* @param {string} line - One raw line of scan-history.tsv.
* @returns {Record<string, string>}
*/
export function parseScanHistoryLine(line) {
const cells = String(line ?? '').split('\t');
const row = {};
for (let i = 0; i < SCAN_HISTORY_COLUMNS.length; i++) {
row[SCAN_HISTORY_COLUMNS[i]] = unescapeFormulaCell(cells[i] ?? '');
}
return row;
}
/**
* Every data row of a scan-history file, parsed once.
*
* Callers that need several views (seen URLs, posting attributes, company
* roles, fingerprints) share this array. Parsing the text again would repeat
* the split and the formula unescape for every view. The header rule stays in
* {@link scanHistoryDataLines}, so a headerless file still keeps its first row.
*
* @param {string|null|undefined} text - Raw scan-history.tsv content.
* @returns {Array<Record<string, string>>}
*/
export function parsedScanHistory(text) {
const rows = [];
for (const line of scanHistoryDataLines(text)) {
rows.push(parseScanHistoryLine(line));
}
return rows;
}
/**
* Whether a scan-history line reaches the named column at all (the cell itself
* may still be empty). {@link parseScanHistoryLine} reads a missing cell as '',
* so a reader that must tell "absent" from "empty" asks this instead.
*
* @param {string} line - One raw line of scan-history.tsv.
* @param {string} column - A name from {@link SCAN_HISTORY_COLUMNS}.
* @returns {boolean}
*/
export function scanHistoryLineHasColumn(line, column) {
const index = SCAN_HISTORY_COLUMNS.indexOf(column);
if (index === -1) throw new Error(`scan-history-columns: unknown column "${column}"`);
return String(line ?? '').split('\t').length > index;
}