Files
Jobber/extractors/huntflow/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

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(/&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 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;