history-ats-seeds.mjs
Rama main · réplica de lectura del repositorio original.
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { atsVendorOf } from './ats-vendor.mjs';
import { parseScanHistoryLine } from './lib/scan-history-columns.mjs';
import { detectColumns, extractCellUrl, isHeaderRow, isSeparatorRow, linkedReportNumbers, reportUrlForNumbers as reportUrlFromReports } from './tracker-parse.mjs';
import { getCareerOpsRoot, resolveTrackerPath } from './path-resolver.mjs';
import { hireHiveTenantOf } from './providers/hirehive.mjs';
import { resolveHibobTenant } from './providers/hibob.mjs';
import { resolveBoard as resolveTalentlinkBoard } from './providers/talentlink.mjs';
import { resolveWorkspaceRoot } from './tracker-utils.mjs';
const WEB_URL_RE = /https?:\/\/[^\s|)>]+/i;
function webUrlFrom(value) {
const match = String(value ?? '').match(WEB_URL_RE);
return match?.[0] ?? null;
}
/**
* Convert a historical posting URL into the board-shaped URL expected by the
* corresponding provider. Unknown vendors keep the original URL: they remain
* visible in the derived seed set and become scannable automatically once a
* provider with the same id exists.
*/
export function atsBoardUrlOf(rawUrl, vendor = atsVendorOf(rawUrl)) {
let u;
try { u = new URL(rawUrl); } catch { return null; }
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
const parts = u.pathname.split('/').filter(Boolean);
if (vendor === 'greenhouse') {
// Older Greenhouse postings use boards.greenhouse.io, but the current
// provider detects the public boards API at job-boards.greenhouse.io.
if (u.hostname.toLowerCase() === 'boards.greenhouse.io') {
u.hostname = 'job-boards.greenhouse.io';
}
if (parts[0]) u.pathname = `/${parts[0]}`;
} else if (vendor === 'lever' || vendor === 'ashby' || vendor === 'smartrecruiters') {
if (parts[0]) u.pathname = `/${parts[0]}`;
} else if (vendor === 'workday') {
const withoutLocale = /^[a-z]{2}-[A-Z]{2}$/.test(parts[0] || '') ? parts.slice(1) : parts;
if (withoutLocale[0]) u.pathname = `/${withoutLocale[0]}`;
} else if (vendor === 'successfactors') {
const endpointAt = parts.findIndex((part) => /^(?:job|search|tile-search-results|services)$/i.test(part));
u.pathname = endpointAt > 0 ? `/${parts.slice(0, endpointAt).join('/')}` : '/';
} else if (vendor === 'icims' || vendor === 'bamboohr') {
u.pathname = '/';
} else if (vendor === 'hibob') {
// Seeds are sampled under --limit before detect() runs, so a host the
// provider refuses is dropped here instead of taking a real board's slot.
if (!resolveHibobTenant(rawUrl)) return null;
u.pathname = '/';
} else if (vendor === 'hirehive') {
// Seeds are sampled under --limit before detect() runs, so a host the
// provider refuses is dropped here instead of taking a real board's slot.
if (u.protocol !== 'https:' || !hireHiveTenantOf(u.hostname)) return null;
u.pathname = '/';
} else if (vendor === 'talentlink') {
// The site path (`/internal`, `/FO/<code>/components`) is part of the
// board; only the page file (`details.html`, `index.html`) is dropped.
// Seeds are sampled under --limit before detect() runs, so a URL the
// provider refuses is dropped here instead of taking a real board's slot.
const board = resolveTalentlinkBoard(rawUrl);
if (!board) return null;
u = new URL(board.boardDir);
}
u.search = '';
u.hash = '';
return u.href.replace(/\/$/, '');
}
function seedOf(company, rawUrl, source) {
const url = webUrlFrom(rawUrl);
const vendor = atsVendorOf(url);
if (!url || !vendor) return null;
const careersUrl = atsBoardUrlOf(url, vendor);
if (!careersUrl) return null;
let host;
try { host = new URL(careersUrl).hostname; } catch { return null; }
return {
company: String(company ?? '').trim() || host,
vendor,
careersUrl,
source,
};
}
export function reportUrlForNumbers(reportNumbers, reportsRoot, accept = atsVendorOf) {
return reportUrlFromReports(reportNumbers, reportsRoot, accept);
}
export function parseTrackerAtsSeeds(text, { reportsRoot } = {}) {
if (typeof text !== 'string' || !text) return [];
const lines = text.split(/\r?\n/);
const columns = detectColumns(lines);
if (!columns || columns.company == null) return [];
const seeds = [];
for (const line of lines) {
if (!line.startsWith('|') || isHeaderRow(line) || isSeparatorRow(line)) continue;
const cells = line.split('|').map((cell) => cell.trim());
// The canonical nine-column tracker has no URL column. Its Report cell
// links to the evaluation report that owns the posting's **URL:** field.
// Custom trackers may carry URL directly; prefer it but never scrape an
// arbitrary Notes URL, which may be unrelated evidence.
// The URL cell may be a markdown link (#3516): read its HREF through the
// shared extractor rather than relying on WEB_URL_RE happening to stop
// at the link's closing paren — the same contract every tracker reader
// follows, and one regex edit away from breaking if left implicit.
const directUrl = columns.url == null ? null : extractCellUrl(cells[columns.url]);
const reportNumbers = linkedReportNumbers(
columns.report == null ? '' : cells[columns.report],
columns.notes == null ? '' : cells[columns.notes],
);
const url = webUrlFrom(directUrl) || reportUrlForNumbers(reportNumbers, reportsRoot);
const seed = seedOf(cells[columns.company], url, 'tracker');
if (seed) seeds.push(seed);
}
return seeds;
}
export function parseScanHistoryAtsSeeds(text) {
if (typeof text !== 'string' || !text) return [];
const seeds = [];
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue;
const row = parseScanHistoryLine(line);
if (row.url.trim().toLowerCase() === 'url') continue;
const seed = seedOf(row.company, row.url, 'scan-history');
if (seed) seeds.push(seed);
}
return seeds;
}
function readIfPresent(file) {
if (!file || !existsSync(file)) return '';
try { return readFileSync(file, 'utf-8'); } catch { return ''; }
}
/**
* Read the user's tracker and scan history without writing either one.
* Duplicate postings from the same board collapse to one provider fetch.
*/
export function loadHistoryAtsSeeds({
dataRoot = getCareerOpsRoot(),
trackerPath = resolveTrackerPath(dataRoot),
scanHistoryPath = process.env.CAREER_OPS_SCAN_HISTORY || path.join(dataRoot, 'data/scan-history.tsv'),
} = {}) {
const reportsRoot = path.join(resolveWorkspaceRoot(trackerPath), 'reports');
const combined = [
...parseTrackerAtsSeeds(readIfPresent(trackerPath), { reportsRoot }),
...parseScanHistoryAtsSeeds(readIfPresent(scanHistoryPath)),
];
const unique = new Map();
for (const seed of combined) {
const key = `${seed.vendor}\t${seed.careersUrl}`;
if (!unique.has(key)) unique.set(key, seed);
}
return [...unique.values()];
}