feat: add Google Jobs extractor and fix scraper reliability gaps
CI / skip-ci-check (pull_request) Successful in 8s
CI / docker-ci (pull_request) Successful in 10s
CI / secret-scan (pull_request) Successful in 7s

Ship a Camoufox-backed Google Jobs source, restore Glassdoor results discarded by a python-jobspy GraphQL quirk, and fix Himalayas/Gradcracker zero-job failures. Also harden prior-skip dismiss matching and multi-profile basic-auth switching for CA/US runs.
This commit is contained in:
2026-07-09 15:11:53 -04:00
parent 84e6835b11
commit 40c7cdece3
47 changed files with 1989 additions and 87 deletions
+7
View File
@@ -26,6 +26,7 @@ export const EXTRACTOR_SOURCE_IDS = [
"testdevjobs",
"wellfound",
"builtin",
"google-jobs",
// --- Public ATS / career-page sources ---
"ashby",
"lever",
@@ -217,6 +218,12 @@ export const EXTRACTOR_SOURCE_METADATA: Record<
category: "pipeline",
region: "global",
},
"google-jobs": {
label: "Google Jobs",
order: 199,
category: "pipeline",
region: "global",
},
ashby: {
label: "Ashby (ATS)",
order: 210,
+51
View File
@@ -3,8 +3,11 @@ import {
buildJobContentFingerprint,
buildJobDescriptionFingerprint,
collectJobDedupKeys,
employersMatchForDismiss,
jobsMatchEmployerAndTitleDismiss,
normalizeEmployerForFingerprint,
normalizeTitleForFingerprint,
titlesMatchForDismiss,
} from "./job-fingerprint";
describe("buildJobContentFingerprint", () => {
@@ -96,10 +99,58 @@ describe("buildJobContentFingerprint", () => {
expect(normalizeEmployerForFingerprint("Acme GmbH")).toBe("acme");
});
it("normalizeEmployerForFingerprint strips leading punctuation", () => {
expect(normalizeEmployerForFingerprint(". CGI IT UK Limited")).toBe(
"cgiituklimited",
);
});
it("normalizeTitleForFingerprint drops leading repost markers", () => {
expect(normalizeTitleForFingerprint("[Reposted] Software Engineer")).toBe(
"softwareengineer",
);
});
});
describe("prior skip dismiss matching", () => {
it("employersMatchForDismiss allows prefix matches", () => {
expect(
employersMatchForDismiss("cgi", normalizeEmployerForFingerprint("CGI")),
).toBe(true);
expect(
employersMatchForDismiss(
normalizeEmployerForFingerprint("CGI IT UK Limited"),
normalizeEmployerForFingerprint("CGI"),
),
).toBe(true);
});
it("titlesMatchForDismiss allows plural variants", () => {
expect(
titlesMatchForDismiss(
normalizeTitleForFingerprint("Automation Test Engineer"),
normalizeTitleForFingerprint("Automation Test Engineers"),
),
).toBe(true);
});
it("jobsMatchEmployerAndTitleDismiss requires same title family", () => {
expect(
jobsMatchEmployerAndTitleDismiss({
employerA: "CGI IT UK Limited",
titleA: "Automation Test Engineers",
employerB: "CGI",
titleB: "Automation Test Engineer",
}),
).toBe(true);
expect(
jobsMatchEmployerAndTitleDismiss({
employerA: "Cognizant",
titleA: "AI Automation Test Engineer",
employerB: "Cognizant",
titleB: "Automation Test Engineer",
}),
).toBe(false);
});
});
});
+67
View File
@@ -29,6 +29,9 @@ const TRAILING_CITY_REGION_RE = /\s+[-|–—]\s+[^,\-|–—]+,\s*[^,\-|–—]
const COMPANY_LEGAL_SUFFIX_RE =
/\b(?:inc|inc\.|ltd|ltd\.|llc|gmbh|s\.a\.|s\.r\.l|sa|nv|bv|plc|corp|corporation|co|company|holdings|holding)\b/g;
/** Hide rediscovered roles that match a prior skip/apply within this window. */
export const PRIOR_SKIP_DISMISS_LOOKBACK_MS = 90 * 24 * 60 * 60 * 1000;
function stripDiacritics(input: string): string {
return input.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
}
@@ -38,6 +41,7 @@ export function normalizeEmployerForFingerprint(
): string {
if (!employer) return "";
let value = stripDiacritics(employer.toLowerCase()).trim();
value = value.replace(/^[^a-z0-9]+/g, "");
value = value.replace(PARENS_RE, " ");
value = value.replace(COMPANY_LEGAL_SUFFIX_RE, " ");
value = value.replace(PUNCTUATION_RE, " ");
@@ -103,6 +107,69 @@ export function buildJobDescriptionFingerprint(args: {
return `${employer}::desc::${description}`;
}
/**
* Whether two normalized employers refer to the same company for dismiss matching.
* Allows prefix matches so `cgi` matches `cgiituklimited`.
*/
export function employersMatchForDismiss(
normalizedEmployerA: string,
normalizedEmployerB: string,
): boolean {
if (!normalizedEmployerA || !normalizedEmployerB) return false;
if (normalizedEmployerA === normalizedEmployerB) return true;
const [short, long] =
normalizedEmployerA.length <= normalizedEmployerB.length
? [normalizedEmployerA, normalizedEmployerB]
: [normalizedEmployerB, normalizedEmployerA];
if (short.length < 3) return false;
return long.startsWith(short);
}
/**
* Whether two normalized titles are the same role for dismiss matching.
* Allows a trailing plural `s` variant (`engineer` vs `engineers`).
*/
export function titlesMatchForDismiss(
normalizedTitleA: string,
normalizedTitleB: string,
): boolean {
if (!normalizedTitleA || !normalizedTitleB) return false;
if (normalizedTitleA === normalizedTitleB) return true;
if (
normalizedTitleA.length > 4 &&
normalizedTitleA.endsWith("s") &&
normalizedTitleA.slice(0, -1) === normalizedTitleB
) {
return true;
}
if (
normalizedTitleB.length > 4 &&
normalizedTitleB.endsWith("s") &&
normalizedTitleB.slice(0, -1) === normalizedTitleA
) {
return true;
}
return false;
}
/** Same company + same job title (normalized), for prior-skip hide rules. */
export function jobsMatchEmployerAndTitleDismiss(args: {
employerA: string | null | undefined;
titleA: string | null | undefined;
employerB: string | null | undefined;
titleB: string | null | undefined;
}): boolean {
const employerA = normalizeEmployerForFingerprint(args.employerA);
const employerB = normalizeEmployerForFingerprint(args.employerB);
const titleA = normalizeTitleForFingerprint(args.titleA);
const titleB = normalizeTitleForFingerprint(args.titleB);
if (!employerA || !employerB || !titleA || !titleB) return false;
return (
employersMatchForDismiss(employerA, employerB) &&
titlesMatchForDismiss(titleA, titleB)
);
}
export function collectJobDedupKeys(args: {
employer: string | null | undefined;
title: string | null | undefined;
+11
View File
@@ -480,6 +480,13 @@ export const settingsRegistry = {
parse: parseIntOrNull,
serialize: serializeNullableNumber,
},
googleJobsMaxJobsPerTerm: {
kind: "typed" as const,
schema: z.number().int().min(1).max(150),
default: (): number => 30,
parse: parseIntOrNull,
serialize: serializeNullableNumber,
},
fourdayweekMaxJobsPerTerm: {
kind: "typed" as const,
schema: z.number().int().min(1).max(1000),
@@ -965,6 +972,10 @@ export const settingsRegistry = {
kind: "string" as const,
schema: z.string().trim().max(200),
},
basicAuthActiveProfileByUser: {
kind: "string" as const,
schema: z.string().trim().max(10000),
},
rxresumeBaseResumeId: {
kind: "string" as const,
schema: z.string().trim().max(200),
+2
View File
@@ -182,6 +182,7 @@ export const createAppSettings = (
override: null,
},
activeProfileId: null,
basicAuthActiveProfileByUser: null,
rxresumeBaseResumeId: null,
rxresumeBaseResumeIdV4: null,
rxresumeBaseResumeIdV5: null,
@@ -206,6 +207,7 @@ export const createAppSettings = (
builtinMaxJobsPerTerm: { value: 100, default: 100, override: null },
builtinMaxPagesPerTerm: { value: 3, default: 3, override: null },
wellfoundMaxJobsPerTerm: { value: 50, default: 50, override: null },
googleJobsMaxJobsPerTerm: { value: 30, default: 30, override: null },
fourdayweekMaxJobsPerTerm: { value: 50, default: 50, override: null },
qajobsboardMaxJobsPerTerm: { value: 100, default: 100, override: null },
arcRemoteJobsPaths: {
+3
View File
@@ -218,6 +218,7 @@ export interface AppSettings {
builtinMaxJobsPerTerm: Resolved<number>;
builtinMaxPagesPerTerm: Resolved<number>;
wellfoundMaxJobsPerTerm: Resolved<number>;
googleJobsMaxJobsPerTerm: Resolved<number>;
fourdayweekMaxJobsPerTerm: Resolved<number>;
qajobsboardMaxJobsPerTerm: Resolved<number>;
arcRemoteJobsPaths: Resolved<string[]>;
@@ -270,6 +271,8 @@ export interface AppSettings {
// Simple strings:
activeProfileId: string | null;
/** JSON map of basic-auth username → search profile id when one login has multiple profiles. */
basicAuthActiveProfileByUser: string | null;
rxresumeBaseResumeId: string | null;
rxresumeBaseResumeIdV4: string | null;
rxresumeBaseResumeIdV5: string | null;