/** * Factorial HR public career sites — tenant HTML job_posting pages. * * https://{tenant}.factorialhr.com/ */ import type { ExtractorManifest, ExtractorRunResult, } from "@shared/types/extractors"; import type { CreateJobInput } from "@shared/types/jobs"; function asString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed ? trimmed : undefined; } function readTenants(raw: string | undefined): string[] { if (!raw) return []; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { return parsed .map((entry) => (typeof entry === "string" ? entry.trim() : "")) .filter(Boolean); } } catch { // fall through } return raw .split(/[\n,;|]+/) .map((entry) => entry.trim()) .filter(Boolean); } function decodeHtmlEntities(value: string): string { return value .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'"); } function stripHtml(html: string): string { return decodeHtmlEntities(html) .replace(/<[^>]+>/g, " ") .replace(/\s+/g, " ") .trim(); } function parseJobLinks(html: string, origin: string): string[] { const links = new Set(); for (const match of html.matchAll( /(?:href|data-job-postings-url)=['"]([^'"]*\/job_posting\/[^'"]+)['"]/g, )) { const raw = match[1]; const path = raw.startsWith("http") ? raw.replace(origin, "") : raw.startsWith("/") ? raw : `/${raw}`; links.add(path); } return [...links]; } function parseJobDetail(html: string): { title?: string; description?: string; } { const titleMatch = html.match(/property='og:title'[^>]*content='([^']+)'/) ?? html.match(/]*>([^<]+)<\/h1>/); const bodyMatch = html.match( /
[\s\S]*?
[\s\S]*?<\/h1>([\s\S]*?)<\/div>\s*<\/div>/, ); return { title: titleMatch?.[1]?.trim(), description: bodyMatch?.[1] ? stripHtml(bodyMatch[1]) : undefined, }; } function matchesTerm(values: string[], term: string): boolean { const lower = term.toLowerCase(); return values.some((value) => value.toLowerCase().includes(lower)); } async function fetchText(url: string): Promise { const response = await fetch(url, { headers: { Accept: "text/html", "User-Agent": "JobOps/1.0" }, }); if (!response.ok) { throw new Error(`Factorial request failed (${response.status}) for ${url}`); } return response.text(); } function tenantOrigin(tenant: string): string { const host = tenant.includes(".") ? tenant : `${tenant}.factorialhr.com`; return host.startsWith("http") ? host : `https://${host}`; } function employerLabel(tenant: string, pageHtml: string): string { const ogSiteMatch = pageHtml.match( /property=['"]og:site_name['"][^>]*content=['"]([^'"]+)['"]/i, ); if (ogSiteMatch?.[1]) { const cleaned = ogSiteMatch[1].split(/\s[-|–]\s/)[0]?.trim(); if (cleaned) return cleaned; } return tenant .split(".")[0] .split(/[-_]/) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } export const manifest: ExtractorManifest = { id: "factorial", displayName: "Factorial (ATS)", providesSources: ["factorial"], async run(context): Promise { if (context.shouldCancel?.()) return { success: true, jobs: [] }; const tenants = readTenants(context.settings.factorialTenants); if (tenants.length === 0) { return { success: true, jobs: [], error: "No Factorial tenants configured. Set FACTORIAL_TENANTS or factorialTenants (comma- or newline-separated subdomains, e.g. yourbourse).", }; } const terms = context.searchTerms.length > 0 ? context.searchTerms : []; const seen = new Set(); const out: CreateJobInput[] = []; try { for (let i = 0; i < tenants.length; i += 1) { if (context.shouldCancel?.()) break; const tenant = tenants[i]; const origin = tenantOrigin(tenant); context.onProgress?.({ phase: "list", termsProcessed: i, termsTotal: tenants.length, currentUrl: origin, detail: `Factorial: ${tenant} (${i + 1}/${tenants.length})`, }); const indexHtml = await fetchText(origin); const employer = employerLabel(tenant, indexHtml); const links = parseJobLinks(indexHtml, origin); for (const link of links) { if (context.shouldCancel?.()) break; const jobUrl = `${origin}${link}`; let title = link.split("/").pop()?.replace(/-\d+$/, "").replace(/-/g, " ") ?? "Unknown Title"; title = title .split(" ") .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); let jobDescription: string | undefined; try { const detailHtml = await fetchText(jobUrl); const detail = parseJobDetail(detailHtml); if (detail.title) title = detail.title.replace(/\s+$/, ""); if (detail.description) jobDescription = detail.description; } catch { // keep index-derived row when detail fetch fails } const haystack = [title, jobDescription ?? ""]; if ( terms.length > 0 && !terms.some((term) => matchesTerm(haystack, term)) ) { continue; } const key = jobUrl; if (seen.has(key)) continue; seen.add(key); out.push({ source: "factorial", sourceJobId: link.split("/").pop(), title, employer, jobUrl, applicationLink: jobUrl, location: "Unknown", jobDescription, }); } } return { success: true, jobs: out }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return { success: false, jobs: out, error: message }; } }, }; export default manifest;