/**
* 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(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/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(
/
]*>([^<]+));
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 {
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 {
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();
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;