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

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;