Files
Jobber/extractors/factorial/manifest.ts
T
ilia 84e6835b11
CI / skip-ci-check (push) Successful in 10s
CI / secret-scan (push) Successful in 14s
CI / docker-ci (push) Successful in 17s
feat: keyword sets, sponsorship signals, and extractor/profile fixes
Add per-profile keyword sets, job source settings, sponsorship signal pills,
and several ATS extractors. Fix Hiring Cafe discovery via Next.js SSR search,
profile activate for comma-separated basicAuthUser aliases, and resume path
backfill migrations. Update settings and hiring-cafe docs; localhost compose
overlay for loopback-only deploys.
2026-06-11 10:23:39 -04:00

217 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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<string>();
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[^>]*>([^<]+)<\/h1>/);
const bodyMatch = html.match(
/<div class='mb-12'>[\s\S]*?<div class='mb-2 sm:mb-4'>[\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<string> {
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<ExtractorRunResult> {
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<string>();
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;