feat: workplace filter, job dedup, company skip docs, deploy notes
- Add remote/orchestrator filter by workplace (remote, not remote, unknown) with URL param - Expose isRemote on job list API; canonicalize URLs and source_job_id dedup on import - Onboarding: optional VITE_SKIP_RXRESUME_ONBOARDING for RxResume-free onboarding - Scoring UI + docs for company skip list; pipeline-run dedup note - Vitest: TZ=UTC for stable time-based tests - DEPLOY_GITEA_VM_CRON_TELEGRAM.md for VM/cron/Telegram ops Made-with: Cursor
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export * from "./extractors";
|
||||
export * from "./job-url-canonical";
|
||||
export * from "./location-support";
|
||||
export * from "./types";
|
||||
export * from "./utils/type-conversion";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalizeJobUrl } from "./job-url-canonical";
|
||||
|
||||
describe("canonicalizeJobUrl", () => {
|
||||
it("strips tracking query params and normalizes host", () => {
|
||||
const a =
|
||||
"https://www.example.com/jobs/123?utm_source=linkedin&role=eng&utm_medium=social";
|
||||
const b = "http://example.com/jobs/123?role=eng";
|
||||
expect(canonicalizeJobUrl(a)).toBe(canonicalizeJobUrl(b));
|
||||
});
|
||||
|
||||
it("removes trailing slash on path", () => {
|
||||
expect(canonicalizeJobUrl("https://example.com/path/")).toBe(
|
||||
"https://example.com/path",
|
||||
);
|
||||
});
|
||||
|
||||
it("sorts query params for stable comparison", () => {
|
||||
const a = "https://example.com/x?b=2&a=1";
|
||||
const b = "https://example.com/x?a=1&b=2";
|
||||
expect(canonicalizeJobUrl(a)).toBe(canonicalizeJobUrl(b));
|
||||
});
|
||||
|
||||
it("returns trimmed non-URL strings unchanged", () => {
|
||||
expect(canonicalizeJobUrl(" not a url ")).toBe("not a url");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Normalize job listing URLs so the same role is not stored twice when only
|
||||
* tracking params, scheme, or trivial path differences differ.
|
||||
*/
|
||||
|
||||
const TRACKING_QUERY_PREFIXES = ["utm_", "stm_"] as const;
|
||||
|
||||
const DROP_QUERY_KEYS = new Set([
|
||||
"ref",
|
||||
"src",
|
||||
"fbclid",
|
||||
"gclid",
|
||||
"mc_eid",
|
||||
"icid",
|
||||
]);
|
||||
|
||||
export function canonicalizeJobUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
try {
|
||||
const u = new URL(trimmed);
|
||||
u.hash = "";
|
||||
|
||||
let host = u.hostname.toLowerCase();
|
||||
if (host.startsWith("www.")) host = host.slice(4);
|
||||
u.hostname = host;
|
||||
u.protocol = "https:";
|
||||
|
||||
for (const key of [...u.searchParams.keys()]) {
|
||||
const lower = key.toLowerCase();
|
||||
if (
|
||||
DROP_QUERY_KEYS.has(lower) ||
|
||||
TRACKING_QUERY_PREFIXES.some((prefix) => lower.startsWith(prefix))
|
||||
) {
|
||||
u.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedKeys = [...u.searchParams.keys()].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
const next = new URLSearchParams();
|
||||
for (const k of sortedKeys) {
|
||||
for (const v of u.searchParams.getAll(k)) {
|
||||
next.append(k, v);
|
||||
}
|
||||
}
|
||||
u.search = next.toString() ? `?${next.toString()}` : "";
|
||||
|
||||
let path = u.pathname;
|
||||
if (path.length > 1 && path.endsWith("/")) {
|
||||
path = path.slice(0, -1);
|
||||
}
|
||||
u.pathname = path || "/";
|
||||
|
||||
return u.toString();
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,7 @@ export type JobListItem = Pick<
|
||||
| "salaryMinAmount"
|
||||
| "salaryMaxAmount"
|
||||
| "salaryCurrency"
|
||||
| "isRemote"
|
||||
| "discoveredAt"
|
||||
| "appliedAt"
|
||||
| "updatedAt"
|
||||
|
||||
Reference in New Issue
Block a user