feat: add Google Jobs extractor and fix scraper reliability gaps
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user