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.
222 lines
6.3 KiB
TypeScript
222 lines
6.3 KiB
TypeScript
/**
|
|
* Huntflow public career sites — SSR HTML listing + optional vacancy detail pages.
|
|
*
|
|
* https://{tenant}.huntflow.io/
|
|
*/
|
|
|
|
import type {
|
|
ExtractorManifest,
|
|
ExtractorRunResult,
|
|
} from "@shared/types/extractors";
|
|
import type { CreateJobInput } from "@shared/types/jobs";
|
|
|
|
interface HuntflowListing {
|
|
slug: string;
|
|
title: string;
|
|
location?: string;
|
|
}
|
|
|
|
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 parseListings(html: string): HuntflowListing[] {
|
|
const listings: HuntflowListing[] = [];
|
|
const pattern =
|
|
/<article class="_item_[^"]*">[\s\S]*?<a href="(\/vacancy\/[^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<div class="_info_[^"]*">([^<]*)<\/div>/g;
|
|
|
|
for (const match of html.matchAll(pattern)) {
|
|
const slug = match[1]?.replace(/^\/vacancy\//, "").replace(/\/$/, "");
|
|
const title = match[2]?.trim();
|
|
if (!slug || !title) continue;
|
|
listings.push({
|
|
slug,
|
|
title: decodeHtmlEntities(title),
|
|
location: match[3]?.trim() || undefined,
|
|
});
|
|
}
|
|
|
|
return listings;
|
|
}
|
|
|
|
function parseVacancyDetail(html: string): {
|
|
description?: string;
|
|
location?: string;
|
|
} {
|
|
const positionMatch = html.match(/<h1 class="_position_[^"]*">([^<]+)<\/h1>/);
|
|
const infoMatch = html.match(
|
|
/<div class="_infoWrapper_[^"]*">[\s\S]*?<div>([^<]+)<\/div>/,
|
|
);
|
|
|
|
const sections: string[] = [];
|
|
for (const match of html.matchAll(
|
|
/<div class="_content_1phzm_2">[\s\S]*?<!--\[-->([\s\S]*?)<!--\]-->/g,
|
|
)) {
|
|
const text = stripHtml(match[1] ?? "");
|
|
if (text) sections.push(text);
|
|
}
|
|
|
|
return {
|
|
location: infoMatch?.[1]?.trim() || undefined,
|
|
description:
|
|
sections.length > 0
|
|
? sections.join("\n\n")
|
|
: positionMatch?.[1]?.trim() || 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(`Huntflow request failed (${response.status}) for ${url}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
function tenantOrigin(tenant: string): string {
|
|
const host = tenant.includes(".") ? tenant : `${tenant}.huntflow.io`;
|
|
return host.startsWith("http") ? host : `https://${host}`;
|
|
}
|
|
|
|
export const manifest: ExtractorManifest = {
|
|
id: "huntflow",
|
|
displayName: "Huntflow (ATS)",
|
|
providesSources: ["huntflow"],
|
|
async run(context): Promise<ExtractorRunResult> {
|
|
if (context.shouldCancel?.()) return { success: true, jobs: [] };
|
|
|
|
const tenants = readTenants(context.settings.huntflowTenants);
|
|
if (tenants.length === 0) {
|
|
return {
|
|
success: true,
|
|
jobs: [],
|
|
error:
|
|
"No Huntflow tenants configured. Set HUNTFLOW_TENANTS or huntflowTenants (comma- or newline-separated subdomains, e.g. apicworld).",
|
|
};
|
|
}
|
|
|
|
const terms = context.searchTerms.length > 0 ? context.searchTerms : [];
|
|
const enrichDetails = true;
|
|
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: `Huntflow: ${tenant} (${i + 1}/${tenants.length})`,
|
|
});
|
|
|
|
const indexHtml = await fetchText(`${origin}/`);
|
|
const employer = tenant
|
|
.split(".")[0]
|
|
.split(/[-_]/)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
|
|
for (const listing of parseListings(indexHtml)) {
|
|
if (context.shouldCancel?.()) break;
|
|
|
|
const haystack = [listing.title, listing.location ?? ""];
|
|
if (
|
|
terms.length > 0 &&
|
|
!terms.some((term) => matchesTerm(haystack, term))
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const jobUrl = `${origin}/vacancy/${listing.slug}`;
|
|
let location = listing.location ?? "Unknown";
|
|
let jobDescription: string | undefined;
|
|
let isRemote = location.toLowerCase().includes("remote");
|
|
|
|
if (enrichDetails) {
|
|
try {
|
|
const detailHtml = await fetchText(jobUrl);
|
|
const detail = parseVacancyDetail(detailHtml);
|
|
if (detail.location) location = detail.location;
|
|
if (detail.description) jobDescription = detail.description;
|
|
isRemote = location.toLowerCase().includes("remote");
|
|
} catch {
|
|
// keep listing row when detail fetch fails
|
|
}
|
|
}
|
|
|
|
const key = jobUrl;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
|
|
out.push({
|
|
source: "huntflow",
|
|
sourceJobId: listing.slug,
|
|
title: listing.title,
|
|
employer,
|
|
jobUrl,
|
|
applicationLink: jobUrl,
|
|
location,
|
|
isRemote,
|
|
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;
|