Ctrl K
ComunidadGuías…

history-ats-seeds.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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()];
}