/** * 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( /
]*>([^<]+)]*>([^<]+)]*>([^<]+)]*>([^<]+)]*>([^<]+) 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;