feat(jobs): suppress duplicate postings after skip or apply
CI / Linting (Biome) (push) Failing after 41s
CI / Tests (push) Successful in 5m25s
CI / Type Check (adzuna-extractor) (push) Successful in 1m8s
CI / Type Check (gradcracker-extractor) (push) Successful in 1m12s
CI / Type Check (hiringcafe-extractor) (push) Successful in 1m9s
CI / Type Check (orchestrator) (push) Successful in 1m25s
CI / Type Check (startupjobs-extractor) (push) Successful in 1m9s
CI / Type Check (ukvisajobs-extractor) (push) Successful in 1m9s
CI / Documentation (push) Failing after 1m56s

Dedup by employer+title and description at import; cascade skip on dismiss; hide repeats in the job list. Document product scope and duplicate detection in docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-16 18:50:11 -04:00
co-authored by Cursor
parent 5401f384c1
commit 17c4d4490a
15 changed files with 471 additions and 19 deletions
@@ -0,0 +1,32 @@
import { createJob } from "@shared/testing/factories";
import { describe, expect, it } from "vitest";
import { buildDuplicateDismissHints } from "./job-dedup";
describe("buildDuplicateDismissHints", () => {
it("flags open jobs that match a skipped posting", () => {
const jobs = [
createJob({
id: "skipped-1",
employer: "Acme",
title: "SDET",
status: "skipped",
}),
createJob({
id: "open-1",
employer: "Acme Inc.",
title: "SDET (Remote)",
status: "discovered",
}),
createJob({
id: "open-2",
employer: "Contoso",
title: "QA Engineer",
status: "discovered",
}),
];
const hints = buildDuplicateDismissHints(jobs);
expect(hints.get("open-1")).toBe("skipped");
expect(hints.has("open-2")).toBe(false);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { collectJobDedupKeys } from "@shared/job-fingerprint";
import type { JobListItem, JobStatus } from "@shared/types";
export type DuplicateDismissReason = "skipped" | "applied";
/**
* Map open jobs to a prior skip/apply when employer+title or description matches.
*/
export function buildDuplicateDismissHints(
jobs: readonly JobListItem[],
): Map<string, DuplicateDismissReason> {
const dismissedKeys = new Map<string, DuplicateDismissReason>();
for (const job of jobs) {
if (job.status !== "skipped" && job.status !== "applied") continue;
const reason: DuplicateDismissReason =
job.status === "applied" ? "applied" : "skipped";
for (const key of collectJobDedupKeys({
employer: job.employer,
title: job.title,
})) {
if (!dismissedKeys.has(key)) dismissedKeys.set(key, reason);
}
}
const hints = new Map<string, DuplicateDismissReason>();
const openStatuses = new Set<JobStatus>([
"discovered",
"ready",
"processing",
]);
for (const job of jobs) {
if (!openStatuses.has(job.status)) continue;
for (const key of collectJobDedupKeys({
employer: job.employer,
title: job.title,
})) {
const reason = dismissedKeys.get(key);
if (reason) {
hints.set(job.id, reason);
break;
}
}
}
return hints;
}
export { collectJobDedupKeys };
@@ -1,4 +1,5 @@
import { useSettings } from "@client/hooks/useSettings";
import { buildDuplicateDismissHints } from "@client/lib/job-dedup";
import { inferCountryKeyFromSearchGeography } from "@shared/search-cities";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -167,6 +168,11 @@ export const OrchestratorPage: React.FC = () => {
[settings?.searchCities?.value],
);
const duplicateDismissHints = useMemo(
() => buildDuplicateDismissHints(jobs),
[jobs],
);
const jobListFilterExtras = useMemo(
() => ({
foundAfterYmd,
@@ -177,6 +183,7 @@ export const OrchestratorPage: React.FC = () => {
? settingsSkipEmployerKeywords
: [],
searchGeographyCountryKey,
duplicateDismissHints,
}),
[
foundAfterYmd,
@@ -186,6 +193,7 @@ export const OrchestratorPage: React.FC = () => {
applySettingsCompanySkipList,
settingsSkipEmployerKeywords,
searchGeographyCountryKey,
duplicateDismissHints,
],
);
@@ -1,3 +1,4 @@
import type { DuplicateDismissReason } from "@client/lib/job-dedup";
import { jobMatchesAllowedCountry } from "@shared/blocked-countries";
import { textMatchesKeyword } from "@shared/keyword-match";
import type { JobListItem, JobSource } from "@shared/types";
@@ -19,6 +20,8 @@ export type JobListFilterExtras = {
settingsBlockedEmployerKeywords: string[];
/** When settings search geography is a country (e.g. Canada), hide other countries. */
searchGeographyCountryKey?: string | null;
/** Hide open jobs that match a prior skip/apply (same company + title/description). */
duplicateDismissHints?: ReadonlyMap<string, DuplicateDismissReason>;
};
const startOfLocalDayMs = (ymd: string): number =>
@@ -64,6 +67,7 @@ export const useFilteredJobs = (
employerExclude: [],
settingsBlockedEmployerKeywords: [],
searchGeographyCountryKey: null,
duplicateDismissHints: undefined,
},
) =>
useMemo(() => {
@@ -96,6 +100,11 @@ export const useFilteredJobs = (
filtered = filtered.filter((job) => job.closedAt == null);
}
const duplicateHints = listExtras.duplicateDismissHints;
if (duplicateHints && duplicateHints.size > 0) {
filtered = filtered.filter((job) => !duplicateHints.has(job.id));
}
if (sourcesFilter.length > 0) {
const allow = new Set(sourcesFilter);
filtered = filtered.filter((job) => allow.has(job.source));
@@ -389,6 +389,22 @@ async function executeJobActionForJob(
});
}
const alsoSkipped = await jobsRepo.skipOpenJobsWithMatchingDedupKeys(
{
employer: updated.employer,
title: updated.title,
jobDescription: updated.jobDescription,
},
updated.ownerProfileId,
updated.id,
);
if (alsoSkipped > 0) {
logger.info("Auto-skipped duplicate open jobs", {
jobId: updated.id,
alsoSkipped,
});
}
return { jobId, ok: true, job: updated };
}
@@ -1383,6 +1399,22 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
return fail(res, notFound("Job not found"));
}
const alsoSkipped = await jobsRepo.skipOpenJobsWithMatchingDedupKeys(
{
employer: updatedJob.employer,
title: updatedJob.title,
jobDescription: updatedJob.jobDescription,
},
updatedJob.ownerProfileId,
updatedJob.id,
);
if (alsoSkipped > 0) {
logger.info("Auto-skipped duplicate open jobs after apply", {
jobId: updatedJob.id,
alsoSkipped,
});
}
res.json({ success: true, data: updatedJob });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
+138 -9
View File
@@ -5,9 +5,11 @@
import { randomUUID } from "node:crypto";
import { getJobOwnerProfileId } from "@infra/request-context";
import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import { buildJobContentFingerprint } from "@shared/job-fingerprint";
import {
buildJobContentFingerprint,
collectJobDedupKeys,
} from "@shared/job-fingerprint";
import { canonicalizeJobUrl } from "@shared/job-url-canonical";
import { normalizeIsRemote } from "@shared/work-arrangement";
import type {
CreateJobInput,
Job,
@@ -16,6 +18,7 @@ import type {
JobsRevisionResponse,
UpdateJobInput,
} from "@shared/types";
import { normalizeIsRemote } from "@shared/work-arrangement";
import { and, desc, eq, inArray, isNull, lt, ne, sql } from "drizzle-orm";
import { db, schema } from "../db/index";
@@ -39,10 +42,13 @@ function resolveOwnerForCreate(input: CreateJobInput): string {
return getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID;
}
const OPEN_JOB_STATUSES: JobStatus[] = ["discovered", "ready"];
async function loadJobDedupIndexes(ownerProfileId: string): Promise<{
existingCanonicalSet: Set<string>;
existingSourceJobKeySet: Set<string>;
existingContentFingerprintSet: Set<string>;
dismissedDedupKeySet: Set<string>;
}> {
const rows = await db
.select({
@@ -52,6 +58,8 @@ async function loadJobDedupIndexes(ownerProfileId: string): Promise<{
contentFingerprint: jobs.contentFingerprint,
employer: jobs.employer,
title: jobs.title,
jobDescription: jobs.jobDescription,
status: jobs.status,
})
.from(jobs)
.where(eq(jobs.ownerProfileId, ownerProfileId));
@@ -70,27 +78,128 @@ async function loadJobDedupIndexes(ownerProfileId: string): Promise<{
// recomputing it from (employer, title) so legacy rows participate in
// dedup until they're rewritten.
const existingContentFingerprintSet = new Set<string>();
const dismissedDedupKeySet = new Set<string>();
for (const row of rows) {
const stored = row.contentFingerprint?.trim();
if (stored) {
existingContentFingerprintSet.add(stored);
continue;
} else {
const recomputed = buildJobContentFingerprint({
employer: row.employer,
title: row.title,
});
if (recomputed) {
existingContentFingerprintSet.add(recomputed);
}
}
const recomputed = buildJobContentFingerprint({
employer: row.employer,
title: row.title,
});
if (recomputed) {
existingContentFingerprintSet.add(recomputed);
if (row.status === "skipped" || row.status === "applied") {
for (const key of collectJobDedupKeys({
employer: row.employer,
title: row.title,
jobDescription: row.jobDescription,
})) {
dismissedDedupKeySet.add(key);
}
}
}
return {
existingCanonicalSet,
existingSourceJobKeySet,
existingContentFingerprintSet,
dismissedDedupKeySet,
};
}
function inputMatchesDismissedDedupKeys(
input: CreateJobInput,
dismissedDedupKeySet: Set<string>,
): boolean {
if (dismissedDedupKeySet.size === 0) return false;
const keys = collectJobDedupKeys({
employer: input.employer,
title: input.title,
jobDescription: input.jobDescription,
});
return keys.some((key) => dismissedDedupKeySet.has(key));
}
async function findDismissedJobByDedupKeys(
keys: string[],
ownerProfileId: string,
): Promise<Job | null> {
if (keys.length === 0) return null;
const rows = await db
.select()
.from(jobs)
.where(
and(
eq(jobs.ownerProfileId, ownerProfileId),
inArray(jobs.status, ["skipped", "applied"]),
),
);
for (const row of rows) {
const rowKeys = collectJobDedupKeys({
employer: row.employer,
title: row.title,
jobDescription: row.jobDescription,
});
if (rowKeys.some((key) => keys.includes(key))) {
return mapRowToJob(row);
}
}
return null;
}
/**
* Skip other open jobs that match the same employer/title or description keys.
*/
export async function skipOpenJobsWithMatchingDedupKeys(
anchor: {
employer: string;
title: string;
jobDescription?: string | null;
},
ownerProfileId: string,
excludeJobId: string,
): Promise<number> {
const anchorKeys = collectJobDedupKeys(anchor);
if (anchorKeys.length === 0) return 0;
const rows = await db
.select({
id: jobs.id,
employer: jobs.employer,
title: jobs.title,
jobDescription: jobs.jobDescription,
})
.from(jobs)
.where(
and(
eq(jobs.ownerProfileId, ownerProfileId),
inArray(jobs.status, OPEN_JOB_STATUSES),
ne(jobs.id, excludeJobId),
),
);
let skipped = 0;
for (const row of rows) {
const rowKeys = collectJobDedupKeys({
employer: row.employer,
title: row.title,
jobDescription: row.jobDescription,
});
if (!rowKeys.some((key) => anchorKeys.includes(key))) continue;
const updated = await updateJob(
row.id,
{ status: "skipped" },
ownerProfileId,
);
if (updated) skipped += 1;
}
return skipped;
}
async function findJobByCanonicalUrl(
canonical: string,
ownerProfileId: string,
@@ -480,8 +589,23 @@ export async function createJobs(
existingCanonicalSet,
existingSourceJobKeySet,
existingContentFingerprintSet,
dismissedDedupKeySet,
} = await loadJobDedupIndexes(ownerProfileId);
if (
inputMatchesDismissedDedupKeys(normalizedWithOwner, dismissedDedupKeySet)
) {
const existing = await findDismissedJobByDedupKeys(
collectJobDedupKeys({
employer: normalized.employer,
title: normalized.title,
jobDescription: normalized.jobDescription,
}),
ownerProfileId,
);
if (existing) return existing;
}
const sid = normalized.sourceJobId?.trim();
if (sid) {
const sk = sourceJobKey(normalized.source, sid);
@@ -537,6 +661,7 @@ export async function createJobs(
existingCanonicalSet,
existingSourceJobKeySet,
existingContentFingerprintSet,
dismissedDedupKeySet,
} = await loadJobDedupIndexes(ownerProfileId);
const batchBuckets = new Map<
@@ -582,6 +707,10 @@ export async function createJobs(
const sid = input.sourceJobId?.trim();
const sk = sid ? sourceJobKey(input.source, sid) : null;
if (inputMatchesDismissedDedupKeys(input, dismissedDedupKeySet)) {
skipped += count;
continue;
}
if (sk && existingSourceJobKeySet.has(sk)) {
skipped += count;
continue;