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
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user