feat(qajobsboard): fetch job detail pages for concrete location text
CI / Linting (Biome) (push) Failing after 40s
CI / Tests (push) Successful in 5m13s
CI / Type Check (adzuna-extractor) (push) Successful in 1m10s
CI / Type Check (gradcracker-extractor) (push) Successful in 1m14s
CI / Type Check (hiringcafe-extractor) (push) Successful in 1m11s
CI / Type Check (orchestrator) (push) Successful in 1m30s
CI / Type Check (startupjobs-extractor) (push) Successful in 1m12s
CI / Type Check (ukvisajobs-extractor) (push) Successful in 1m13s
CI / Documentation (push) Successful in 1m57s
CI / Linting (Biome) (push) Failing after 40s
CI / Tests (push) Successful in 5m13s
CI / Type Check (adzuna-extractor) (push) Successful in 1m10s
CI / Type Check (gradcracker-extractor) (push) Successful in 1m14s
CI / Type Check (hiringcafe-extractor) (push) Successful in 1m11s
CI / Type Check (orchestrator) (push) Successful in 1m30s
CI / Type Check (startupjobs-extractor) (push) Successful in 1m12s
CI / Type Check (ukvisajobs-extractor) (push) Successful in 1m13s
CI / Documentation (push) Successful in 1m57s
The jobs.json feed often labels roles Remote/Worldwide while the public job page JSON-LD and description include the real city (e.g. Mumbai/Nagpur). Enrich vague rows by reading each QAJobsBoard detail URL before import. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,11 @@ import type {
|
||||
ExtractorRunResult,
|
||||
} from "@shared/types/extractors";
|
||||
import type { CreateJobInput } from "@shared/types/jobs";
|
||||
import {
|
||||
extractJobLocationFromText,
|
||||
fetchQaJobDetailEnrichment,
|
||||
stripHtml,
|
||||
} from "./src/detail-page.js";
|
||||
|
||||
const JOBS_URL = "https://qajobsboard.jobboardly.com/jobs.json";
|
||||
|
||||
@@ -46,21 +51,6 @@ function asString(value: unknown): string | undefined {
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ");
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
const noTags = html.replace(/<[^>]+>/g, " ");
|
||||
return decodeHtmlEntities(noTags).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function salaryLabel(raw: SalaryBand | undefined): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const schedule = raw.schedule ? `${raw.schedule}: ` : "";
|
||||
@@ -93,19 +83,6 @@ function isVagueLocationLabel(value: string): boolean {
|
||||
return VAGUE_LOCATION_LABELS.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function extractJobLocationFromDescription(
|
||||
html: string | undefined,
|
||||
): string | undefined {
|
||||
if (!html) return undefined;
|
||||
const text = stripHtml(html);
|
||||
const match = text.match(
|
||||
/\bjob\s+location\s*:\s*([^\n.]{2,120})/i,
|
||||
);
|
||||
if (!match?.[1]) return undefined;
|
||||
const extracted = match[1].trim();
|
||||
return extracted || undefined;
|
||||
}
|
||||
|
||||
function locationLabel(job: QaJobBoardlyJob): string {
|
||||
const limits = Array.isArray(job.location_limits)
|
||||
? job.location_limits
|
||||
@@ -117,9 +94,9 @@ function locationLabel(job: QaJobBoardlyJob): string {
|
||||
const loc = asString(job.location);
|
||||
if (loc && !isVagueLocationLabel(loc)) return loc;
|
||||
|
||||
const fromDescription = extractJobLocationFromDescription(
|
||||
job.description?.html,
|
||||
);
|
||||
const fromDescription = job.description?.html
|
||||
? extractJobLocationFromText(stripHtml(job.description.html))
|
||||
: undefined;
|
||||
if (fromDescription) return fromDescription;
|
||||
|
||||
if (loc) return loc;
|
||||
@@ -179,6 +156,47 @@ function mapJob(raw: QaJobBoardlyJob): CreateJobInput | null {
|
||||
};
|
||||
}
|
||||
|
||||
const DETAIL_ENRICH_CONCURRENCY = 4;
|
||||
|
||||
function needsDetailEnrichment(location: string | undefined): boolean {
|
||||
if (!location?.trim()) return true;
|
||||
return isVagueLocationLabel(location);
|
||||
}
|
||||
|
||||
async function enrichJobsFromDetailPages(
|
||||
jobs: CreateJobInput[],
|
||||
shouldCancel?: () => boolean,
|
||||
): Promise<CreateJobInput[]> {
|
||||
const enriched = jobs.map((job) => ({ ...job }));
|
||||
const targets = enriched
|
||||
.map((job, index) => ({ job, index }))
|
||||
.filter(({ job }) => needsDetailEnrichment(job.location));
|
||||
|
||||
for (let offset = 0; offset < targets.length; offset += DETAIL_ENRICH_CONCURRENCY) {
|
||||
if (shouldCancel?.()) break;
|
||||
const batch = targets.slice(offset, offset + DETAIL_ENRICH_CONCURRENCY);
|
||||
await Promise.all(
|
||||
batch.map(async ({ job, index }) => {
|
||||
try {
|
||||
const detail = await fetchQaJobDetailEnrichment(job.jobUrl);
|
||||
if (!detail) return;
|
||||
enriched[index] = {
|
||||
...job,
|
||||
...(detail.location ? { location: detail.location } : {}),
|
||||
...(detail.jobDescription
|
||||
? { jobDescription: detail.jobDescription }
|
||||
: {}),
|
||||
};
|
||||
} catch {
|
||||
// keep feed row when detail page fetch fails
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
export const manifest: ExtractorManifest = {
|
||||
id: "qajobsboard",
|
||||
displayName: "QAJobsBoard",
|
||||
@@ -230,16 +248,21 @@ export const manifest: ExtractorManifest = {
|
||||
out.push(mapped);
|
||||
}
|
||||
|
||||
const withDetails = await enrichJobsFromDetailPages(
|
||||
out,
|
||||
context.shouldCancel,
|
||||
);
|
||||
|
||||
context.onProgress?.({
|
||||
phase: "list",
|
||||
termsProcessed: 1,
|
||||
termsTotal: 1,
|
||||
currentUrl: JOBS_URL,
|
||||
jobPagesProcessed: out.length,
|
||||
detail: `QAJobsBoard: ${out.length} matched (${rows.length} total listings)`,
|
||||
jobPagesProcessed: withDetails.length,
|
||||
detail: `QAJobsBoard: ${withDetails.length} matched (${rows.length} total listings, detail pages for vague locations)`,
|
||||
});
|
||||
|
||||
return { success: true, jobs: out };
|
||||
return { success: true, jobs: withDetails };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, jobs: [], error: message };
|
||||
|
||||
Reference in New Issue
Block a user