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.
217 lines
6.1 KiB
TypeScript
217 lines
6.1 KiB
TypeScript
/**
|
||
* 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<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;
|