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.
190 lines
5.6 KiB
TypeScript
190 lines
5.6 KiB
TypeScript
/**
|
|
* Built In — tech job board (JSON-LD ItemList embedded in SSR HTML).
|
|
*
|
|
* https://builtin.com/jobs
|
|
*/
|
|
|
|
import type {
|
|
ExtractorManifest,
|
|
ExtractorRunResult,
|
|
} from "@shared/types/extractors";
|
|
import type { CreateJobInput } from "@shared/types/jobs";
|
|
|
|
const ORIGIN = "https://builtin.com";
|
|
|
|
interface BuiltinListItem {
|
|
title: string;
|
|
url: string;
|
|
description?: string;
|
|
}
|
|
|
|
function asString(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function decodeJsonString(value: string): string {
|
|
return value
|
|
.replace(/\\"/g, '"')
|
|
.replace(/\\n/g, "\n")
|
|
.replace(/\\t/g, "\t")
|
|
.replace(/\\\\/g, "\\");
|
|
}
|
|
|
|
function readMaxPages(raw: string | undefined): number {
|
|
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
if (!Number.isFinite(parsed)) return 3;
|
|
return Math.min(Math.max(parsed, 1), 15);
|
|
}
|
|
|
|
function parseListItems(html: string): BuiltinListItem[] {
|
|
const items: BuiltinListItem[] = [];
|
|
const pattern =
|
|
/\{"@type":"ListItem","position":\d+,"name":"((?:\\.|[^"\\])*)","url":"(https:\/\/builtin\.com\/job\/[^"]+)"(?:,"description":"((?:\\.|[^"\\])*)")?\}/g;
|
|
|
|
for (const match of html.matchAll(pattern)) {
|
|
const title = decodeJsonString(match[1] ?? "");
|
|
const url = match[2];
|
|
if (!title || !url) continue;
|
|
items.push({
|
|
title,
|
|
url,
|
|
description: match[3] ? decodeJsonString(match[3]) : undefined,
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
function slugToWords(slug: string): string {
|
|
return slug
|
|
.split("-")
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function employerFromUrl(jobUrl: string): string {
|
|
const match = jobUrl.match(/\/job\/[^/]+\/(\d+)/);
|
|
if (!match) return "Unknown Employer";
|
|
const slugMatch = jobUrl.match(/\/job\/([^/]+)\/\d+/);
|
|
const slug = slugMatch?.[1] ?? "";
|
|
const parts = slug.split("-");
|
|
const maybeId = parts[parts.length - 1];
|
|
if (/^\d+$/.test(maybeId ?? "")) parts.pop();
|
|
const trimmed = parts.slice(-2);
|
|
if (trimmed.length === 0) return slugToWords(slug);
|
|
return slugToWords(trimmed.join("-"));
|
|
}
|
|
|
|
function searchPath(term: string | null): string {
|
|
if (!term) return "/jobs/remote";
|
|
const query = encodeURIComponent(term.trim());
|
|
return `/jobs/remote?search=${query}`;
|
|
}
|
|
|
|
function matchesTerm(item: BuiltinListItem, term: string): boolean {
|
|
const lower = term.toLowerCase();
|
|
if (item.title.toLowerCase().includes(lower)) return true;
|
|
if (item.description?.toLowerCase().includes(lower)) return true;
|
|
return false;
|
|
}
|
|
|
|
async function fetchPage(path: string, page: number): Promise<string> {
|
|
const separator = path.includes("?") ? "&" : "?";
|
|
const url =
|
|
page <= 1 ? `${ORIGIN}${path}` : `${ORIGIN}${path}${separator}page=${page}`;
|
|
const response = await fetch(url, {
|
|
headers: { Accept: "text/html", "User-Agent": "JobOps/1.0" },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Built In request failed (${response.status}) for ${url}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
export const manifest: ExtractorManifest = {
|
|
id: "builtin",
|
|
displayName: "Built In",
|
|
providesSources: ["builtin"],
|
|
async run(context): Promise<ExtractorRunResult> {
|
|
if (context.shouldCancel?.()) return { success: true, jobs: [] };
|
|
|
|
const maxJobs = context.settings.builtinMaxJobsPerTerm
|
|
? Number.parseInt(context.settings.builtinMaxJobsPerTerm, 10)
|
|
: 100;
|
|
const cap = Number.isFinite(maxJobs)
|
|
? Math.min(Math.max(maxJobs, 1), 500)
|
|
: 100;
|
|
const maxPages = readMaxPages(context.settings.builtinMaxPagesPerTerm);
|
|
|
|
const terms = context.searchTerms.length > 0 ? context.searchTerms : [null];
|
|
|
|
const seen = new Set<string>();
|
|
const out: CreateJobInput[] = [];
|
|
|
|
try {
|
|
for (let termIndex = 0; termIndex < terms.length; termIndex += 1) {
|
|
if (context.shouldCancel?.()) break;
|
|
const term = terms[termIndex];
|
|
const path = searchPath(term);
|
|
|
|
for (let page = 1; page <= maxPages; page += 1) {
|
|
if (context.shouldCancel?.()) break;
|
|
if (out.length >= cap) break;
|
|
|
|
context.onProgress?.({
|
|
phase: "list",
|
|
termsProcessed: termIndex,
|
|
termsTotal: terms.length,
|
|
currentUrl: `${ORIGIN}${path}`,
|
|
detail: `Built In: term ${termIndex + 1}/${terms.length}, page ${page}`,
|
|
});
|
|
|
|
const html = await fetchPage(path, page);
|
|
const items = parseListItems(html);
|
|
if (items.length === 0) break;
|
|
|
|
for (const item of items) {
|
|
if (out.length >= cap) break;
|
|
if (term && !matchesTerm(item, term)) continue;
|
|
|
|
const key = item.url;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
|
|
out.push({
|
|
source: "builtin",
|
|
sourceJobId: item.url.split("/").pop(),
|
|
title: item.title,
|
|
employer: employerFromUrl(item.url),
|
|
jobUrl: item.url,
|
|
applicationLink: item.url,
|
|
location: "Remote",
|
|
isRemote: true,
|
|
jobDescription: item.description,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
context.onProgress?.({
|
|
phase: "list",
|
|
termsProcessed: terms.length,
|
|
termsTotal: terms.length,
|
|
currentUrl: `${ORIGIN}/jobs`,
|
|
jobPagesProcessed: out.length,
|
|
detail: `Built In: ${out.length} jobs`,
|
|
});
|
|
|
|
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;
|