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

260 lines
7.5 KiB
TypeScript

/**
* TestDevJobs — QA-focused board (Gridsome SSR HTML + embedded job state).
*
* https://testdevjobs.com/software-testing-jobs/
*/
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import type { CreateJobInput } from "@shared/types/jobs";
const ORIGIN = "https://testdevjobs.com";
const LIST_PATH = "/software-testing-jobs/";
interface ListingJob {
path: string;
title: string;
employer: string;
datePosted?: string;
location?: string;
jobType?: string;
}
interface TestDevJobState {
id?: string;
jobTitle?: string;
path?: string;
jobDescription?: string;
joblocation?: string;
jobType?: string;
salary?: string;
isRemote?: boolean;
applyLink?: string;
companyName?: string;
jobPosted?: string;
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
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 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), 20);
}
function parseListingJobs(html: string): ListingJob[] {
const jobs: ListingJob[] = [];
const blocks = html.match(
/<div class="job-tile-wrapper[\s\S]*?<\/div>\s*<\/div>\s*<\/div>/g,
);
for (const block of blocks ?? []) {
const pathMatch = block.match(/href="(\/job\/[^"]+)"/);
const titleMatch = block.match(/class="ml-5 jobtitle[^"]*"[^>]*>([^<]+)</);
const employerMatch = block.match(
/class="mb-0 comptitle[^"]*"[^>]*>([^<]+)</,
);
const dateMatch = block.match(/itemprop="datePosted"[^>]*>([^<]+)</);
const locationMatch = block.match(
/itemprop="addressLocality"[^>]*>([^<]+)</,
);
const typeMatch = block.match(/itemprop="employmentType"[^>]*>([^<]+)</);
const path = pathMatch?.[1];
const title = titleMatch?.[1]?.trim();
if (!path || !title) continue;
jobs.push({
path,
title: decodeHtmlEntities(title),
employer: decodeHtmlEntities(
employerMatch?.[1]?.trim() ?? "Unknown Employer",
),
datePosted: dateMatch?.[1]?.trim(),
location: locationMatch?.[1]?.trim(),
jobType: typeMatch?.[1]?.trim(),
});
}
return jobs;
}
function parseInitialState(html: string): TestDevJobState | null {
const match = html.match(/window\.__INITIAL_STATE__=({[\s\S]*?});/);
if (!match?.[1]) return null;
try {
const parsed = JSON.parse(match[1]) as {
data?: { job?: TestDevJobState };
};
return parsed.data?.job ?? null;
} catch {
return null;
}
}
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(
`TestDevJobs request failed (${response.status}) for ${url}`,
);
}
return response.text();
}
function listingUrl(page: number): string {
if (page <= 1) return `${ORIGIN}${LIST_PATH}`;
return `${ORIGIN}${LIST_PATH}${page}/`;
}
export const manifest: ExtractorManifest = {
id: "testdevjobs",
displayName: "TestDevJobs",
providesSources: ["testdevjobs"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
const maxJobs = context.settings.testdevjobsMaxJobsPerTerm
? Number.parseInt(context.settings.testdevjobsMaxJobsPerTerm, 10)
: 100;
const cap = Number.isFinite(maxJobs)
? Math.min(Math.max(maxJobs, 1), 500)
: 100;
const maxPages = readMaxPages(context.settings.testdevjobsMaxPages);
const terms = context.searchTerms.length > 0 ? context.searchTerms : [];
const enrichDetails = true;
const seen = new Set<string>();
const out: CreateJobInput[] = [];
try {
for (let page = 1; page <= maxPages; page += 1) {
if (context.shouldCancel?.()) break;
if (out.length >= cap) break;
const url = listingUrl(page);
context.onProgress?.({
phase: "list",
termsProcessed: page - 1,
termsTotal: maxPages,
currentUrl: url,
detail: `TestDevJobs: listing page ${page}/${maxPages}`,
});
const html = await fetchText(url);
const listings = parseListingJobs(html);
if (listings.length === 0) break;
for (const listing of listings) {
if (context.shouldCancel?.()) break;
if (out.length >= cap) break;
const haystack = [
listing.title,
listing.employer,
listing.location ?? "",
listing.jobType ?? "",
];
if (
terms.length > 0 &&
!terms.some((term) => matchesTerm(haystack, term))
) {
continue;
}
let jobUrl = `${ORIGIN}${listing.path}`;
let applicationLink = jobUrl;
let jobDescription: string | undefined;
let location = listing.location;
let jobType = listing.jobType;
let datePosted = listing.datePosted;
let isRemote = listing.location?.toLowerCase().includes("remote");
if (enrichDetails) {
try {
const detailHtml = await fetchText(jobUrl);
const state = parseInitialState(detailHtml);
if (state) {
if (state.path) jobUrl = `${ORIGIN}${state.path}`;
if (state.applyLink) applicationLink = state.applyLink;
if (state.jobDescription) jobDescription = state.jobDescription;
if (state.joblocation) location = state.joblocation;
if (state.jobType) jobType = state.jobType;
if (state.jobPosted) datePosted = state.jobPosted;
if (typeof state.isRemote === "boolean")
isRemote = state.isRemote;
}
} catch {
// keep listing row when detail fetch fails
}
}
const key = listing.path;
if (seen.has(key)) continue;
seen.add(key);
out.push({
source: "testdevjobs",
sourceJobId: key.split("/").filter(Boolean).pop(),
title: listing.title,
employer: listing.employer,
jobUrl,
applicationLink,
location: location ?? "Unknown",
isRemote,
datePosted,
jobDescription,
jobType,
});
}
}
context.onProgress?.({
phase: "list",
termsProcessed: maxPages,
termsTotal: maxPages,
currentUrl: `${ORIGIN}${LIST_PATH}`,
jobPagesProcessed: out.length,
detail: `TestDevJobs: ${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;