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.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "huntflow-extractor",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"description": "Huntflow public career-site extractor",
|
||||
"main": "manifest.ts",
|
||||
"dependencies": {
|
||||
"job-ops-shared": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "~5.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"check:types": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ES2022",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@shared/*": ["../../shared/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["./manifest.ts", "./src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user