feat(extractors): expand catalog, smoke coverage, and sourcing docs
CI / Linting (Biome) (push) Failing after 40s
CI / Tests (push) Successful in 5m54s
CI / Type Check (adzuna-extractor) (push) Successful in 1m8s
CI / Type Check (gradcracker-extractor) (push) Successful in 1m11s
CI / Type Check (hiringcafe-extractor) (push) Successful in 1m8s
CI / Type Check (orchestrator) (push) Successful in 1m23s
CI / Type Check (startupjobs-extractor) (push) Successful in 1m6s
CI / Type Check (ukvisajobs-extractor) (push) Successful in 1m7s
CI / Documentation (push) Successful in 1m54s

Adds Arc.dev, BC T-Net, Eluta, iCIMS tenants, QAJobsBoard, and SmartRecruiters
manifests with registry/settings/UI wiring; registers full extractor list in
smoke-extractors and documents supplementary board access paths. Aligns Careerjet
v4 with the url query parameter and fixes strict typing in QAJobsBoard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-15 22:36:23 -04:00
co-authored by Cursor
parent 67508d56ea
commit c840f289e1
50 changed files with 2926 additions and 101 deletions
+15
View File
@@ -0,0 +1,15 @@
# arcdev-extractor
Reads Arc remote-job listings from **SSR HTML**: each page embeds `__NEXT_DATA__` with `arcJobs` (Arc talent network) and `externalJobs` (partner postings).
Configure **`arcRemoteJobsPaths`** as URL paths on `https://arc.dev`, for example:
- `/remote-jobs/playwright`
- `/remote-jobs/cypress`
- `/remote-jobs/selenium`
Or set `ARC_REMOTE_JOBS_PATHS` (comma/newline-separated). Defaults include Playwright and Cypress stacks.
**Employer names:** External jobs include `company.name`. Arc-managed listings omit company names in the payload — those rows use employer `"Arc talent network"` while preserving titles and skill categories.
Cap merged matches per configuration fetch via `arcMaxJobsPerPath` (applied separately per path, default `120`).
+329
View File
@@ -0,0 +1,329 @@
/**
* Arc.dev remote jobs — parse embedded Next.js __NEXT_DATA__ from SSR HTML.
*
* Listing URLs look like https://arc.dev/remote-jobs/playwright
*/
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import type { CreateJobInput } from "@shared/types/jobs";
const ORIGIN = "https://arc.dev";
interface ArcCategory {
name?: string;
urlString?: string;
}
interface ArcCompanyJson {
randomKey?: string | null;
urlString?: string;
name?: string;
}
interface ArcJobJson {
randomKey?: string;
title?: string;
jobType?: string;
jobRole?: string;
urlString?: string;
postedAt?: number;
company?: ArcCompanyJson;
categories?: ArcCategory[];
requiredCountries?: string[];
minAnnualSalary?: number | null;
maxAnnualSalary?: number | null;
minHourlyRate?: number | null;
maxHourlyRate?: number | null;
timeZone?: string | null;
positionType?: string;
experienceLevel?: string;
experienceLevels?: string[];
}
function readPaths(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 defaultArcPaths(): string[] {
const raw =
typeof process !== "undefined" ? process.env.ARC_REMOTE_JOBS_PATHS : "";
const parsed = readPaths(raw);
return parsed.length > 0
? parsed
: ["/remote-jobs/playwright", "/remote-jobs/cypress"];
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const t = value.trim();
return t ? t : undefined;
}
function categoryHaystack(job: ArcJobJson): string {
if (!Array.isArray(job.categories)) return "";
return job.categories
.map((c) => `${c.name ?? ""} ${c.urlString ?? ""}`)
.join(" ")
.toLowerCase();
}
function matchesTerm(job: ArcJobJson, term: string): boolean {
const lower = term.toLowerCase();
if (job.title?.toLowerCase().includes(lower)) return true;
if (categoryHaystack(job).includes(lower)) return true;
if (job.jobRole?.toLowerCase().includes(lower)) return true;
if (job.positionType?.toLowerCase().includes(lower)) return true;
if (
Array.isArray(job.experienceLevels) &&
job.experienceLevels.some((l) => l.toLowerCase().includes(lower))
)
return true;
if (job.experienceLevel?.toLowerCase().includes(lower)) return true;
return false;
}
function salaryParts(job: ArcJobJson): string | undefined {
const bits: string[] = [];
if (
typeof job.minAnnualSalary === "number" &&
typeof job.maxAnnualSalary === "number"
) {
bits.push(`USD ${job.minAnnualSalary}${job.maxAnnualSalary} / yr`);
} else if (typeof job.minAnnualSalary === "number") {
bits.push(`USD ${job.minAnnualSalary}+ / yr`);
}
if (
typeof job.minHourlyRate === "number" ||
typeof job.maxHourlyRate === "number"
) {
bits.push(`$${job.minHourlyRate ?? "?"}${job.maxHourlyRate ?? "?"} / hr`);
}
return bits.length > 0 ? bits.join("; ") : undefined;
}
function locationLine(job: ArcJobJson): string {
if (
Array.isArray(job.requiredCountries) &&
job.requiredCountries.length > 0
) {
return job.requiredCountries.join(", ");
}
if (job.timeZone) return job.timeZone;
return "Remote";
}
function postedIso(postedAt: number | undefined): string | undefined {
if (typeof postedAt !== "number" || !Number.isFinite(postedAt))
return undefined;
return new Date(postedAt * 1000).toISOString();
}
function parseNextPageProps(html: string): {
arcJobs: ArcJobJson[];
externalJobs: ArcJobJson[];
} | null {
const match = html.match(
/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/,
);
if (!match?.[1]) return null;
try {
const parsed = JSON.parse(match[1]) as {
props?: { pageProps?: unknown };
};
const pageProps = parsed.props?.pageProps as
| {
arcJobs?: ArcJobJson[];
externalJobs?: ArcJobJson[];
}
| undefined;
if (!pageProps) return null;
return {
arcJobs: Array.isArray(pageProps.arcJobs) ? pageProps.arcJobs : [],
externalJobs: Array.isArray(pageProps.externalJobs)
? pageProps.externalJobs
: [],
};
} catch {
return null;
}
}
function mapExternalJob(job: ArcJobJson): CreateJobInput | null {
const rk = asString(job.randomKey);
const slug = asString(job.urlString);
if (!rk || !slug) return null;
const jobUrl = `${ORIGIN}/remote-jobs/j/${slug}-${rk}`;
const employer = asString(job.company?.name)?.trim() || "Unknown employer";
const disciplines = Array.isArray(job.categories)
? job.categories
.map((c) => c.name?.trim())
.filter((v): v is string => Boolean(v))
.join(", ")
: undefined;
return {
source: "arcdev",
sourceJobId: slug,
title: asString(job.title) ?? "Unknown Title",
employer,
jobUrl,
applicationLink: jobUrl,
location: locationLine(job),
datePosted: postedIso(job.postedAt),
jobType: asString(job.jobType),
salary: salaryParts(job),
disciplines,
jobLevel:
job.experienceLevels?.join(", ") ??
asString(job.experienceLevel) ??
undefined,
isRemote: true,
};
}
function mapArcManagedJob(job: ArcJobJson): CreateJobInput | null {
const rk = asString(job.randomKey);
const slug = asString(job.urlString);
if (!rk || !slug) return null;
const jobUrl = `${ORIGIN}/remote-jobs/details/${slug}-${rk}`;
const disciplines = Array.isArray(job.categories)
? job.categories
.map((c) => c.name?.trim())
.filter((v): v is string => Boolean(v))
.join(", ")
: undefined;
const employer = "Arc talent network";
return {
source: "arcdev",
sourceJobId: `${slug}-${rk}`,
title: asString(job.title) ?? "Unknown Title",
employer,
jobUrl,
applicationLink: jobUrl,
location: locationLine(job),
datePosted: postedIso(job.postedAt),
jobType: asString(job.jobType),
salary: salaryParts(job),
disciplines,
jobLevel: asString(job.experienceLevel),
jobFunction: asString(job.jobRole),
isRemote: true,
};
}
export const manifest: ExtractorManifest = {
id: "arcdev",
displayName: "Arc.dev (remote)",
providesSources: ["arcdev"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
let paths = readPaths(context.settings.arcRemoteJobsPaths);
if (paths.length === 0) paths = defaultArcPaths();
paths = paths.map((p) => (p.startsWith("/") ? p : `/${p}`));
const maxPerPath = context.settings.arcMaxJobsPerPath
? Number.parseInt(context.settings.arcMaxJobsPerPath, 10)
: 120;
const cap = Number.isFinite(maxPerPath)
? Math.min(Math.max(maxPerPath, 1), 300)
: 120;
const terms = context.searchTerms.length > 0 ? context.searchTerms : [];
const seen = new Set<string>();
const out: CreateJobInput[] = [];
try {
for (let i = 0; i < paths.length; i += 1) {
if (context.shouldCancel?.()) break;
const path = paths[i];
const pageUrl = `${ORIGIN}${path}`;
context.onProgress?.({
phase: "list",
termsProcessed: i,
termsTotal: paths.length,
currentUrl: pageUrl,
detail: `Arc.dev: fetching (${i + 1}/${paths.length}) ${path}`,
});
const response = await fetch(pageUrl, {
headers: {
Accept: "text/html",
"User-Agent":
"Mozilla/5.0 (compatible; JobOps/1.0; +https://github.com)",
},
});
if (!response.ok) {
throw new Error(
`Arc.dev "${path}" failed with status ${response.status}`,
);
}
const html = await response.text();
const payload = parseNextPageProps(html);
if (!payload) {
throw new Error(`Arc.dev "${path}": missing __NEXT_DATA__ payload`);
}
let pathAdded = 0;
const labeled = [
...payload.arcJobs.map((job) => ({ job, kind: "arc" as const })),
...payload.externalJobs.map((job) => ({ job, kind: "ext" as const })),
];
for (const { job: raw, kind } of labeled) {
if (pathAdded >= cap) break;
if (terms.length > 0 && !terms.some((t) => matchesTerm(raw, t))) {
continue;
}
const mapped =
kind === "arc" ? mapArcManagedJob(raw) : mapExternalJob(raw);
if (!mapped) continue;
if (seen.has(mapped.jobUrl)) continue;
seen.add(mapped.jobUrl);
out.push(mapped);
pathAdded += 1;
}
context.onProgress?.({
phase: "list",
termsProcessed: i + 1,
termsTotal: paths.length,
currentUrl: pageUrl,
jobPagesProcessed: out.length,
detail: `Arc.dev: ${path}${pathAdded} kept (${payload.arcJobs.length} arc + ${payload.externalJobs.length} external rows)`,
});
}
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;
+17
View File
@@ -0,0 +1,17 @@
{
"name": "arcdev-extractor",
"version": "0.0.1",
"type": "module",
"description": "Arc.dev remote jobs extractor (__NEXT_DATA__ SSR)",
"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"
}
}
+17
View File
@@ -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/**/*"]
}