mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
fix(linkedin-search): reject detail URLs on other hosts instead of fetching the posting with that number (#500)
normalizeId took the first 6+-digit path segment from ANY URL, so a Greenhouse or Lever apply link - the kind a posting's own page hands out and a user pastes back into detail - was reduced to that number and the handler fetched jobPosting/<number> from LinkedIn: whatever job carried that id came back with exit 0, or NOT_FOUND if none did, never an error about the input. Driving the real handler with a stubbed fetch, https://boards.greenhouse.io/acme/jobs/4567890 requested .../jobPosting/4567890. Every other portal CLI rejects an off-host detail URL with BAD_ID (the #447 shape); linkedin was the one still trusting the digits. Parse URLs for real: a linkedin.com host (apex or any subdomain, scheme optional) plus a /jobs/view/<slug->id path yields the id; other hosts, look-alike and userinfo hosts, and linkedin.com profile or search URLs exit 1 with the stderr-JSON BAD_ID contract before any request. Bare ids, URNs, and slash-free title slugs are unchanged. Four new normalizeId cases and a new detail-input.test.ts that drives runDetail and the CLI with fetch stubbed; five cases fail on the old pattern.
This commit is contained in:
1 parent
a62af6a068
commit
8cbab6823e
4 files changed
+147
-7
No files matched your search
@@ -5,15 +5,42 @@ export interface DetailOpts {
|
|||||||
format: "json" | "plain"
|
format: "json" | "plain"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Accept a raw job ID, a job-view URL, or a job URN. */
|
const LINKEDIN_HOST = /(^|\.)linkedin\.com$/i
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a raw job ID, a job URN, a LinkedIn job-view URL (any linkedin.com
|
||||||
|
* host, with or without scheme), or a bare title slug ending in the ID.
|
||||||
|
*
|
||||||
|
* A URL on any other host is rejected. The previous pattern took the first
|
||||||
|
* 6+-digit path segment from ANY URL, so a Greenhouse or Lever apply link -
|
||||||
|
* the kind a posting's own page hands out - fetched whatever LinkedIn
|
||||||
|
* posting happened to carry that number and printed it with exit 0. Host and
|
||||||
|
* path are read through real URL parsing, so look-alike hosts
|
||||||
|
* (linkedin.com.evil.io) and userinfo tricks (linkedin.com@evil.io) fall on
|
||||||
|
* the reject side too.
|
||||||
|
*/
|
||||||
export function normalizeId(input: string): string | null {
|
export function normalizeId(input: string): string | null {
|
||||||
const urn = input.match(/urn:li:jobPosting:(\d+)/)
|
const trimmed = input.trim()
|
||||||
|
const urn = trimmed.match(/urn:li:jobPosting:(\d+)/)
|
||||||
if (urn) return urn[1]
|
if (urn) return urn[1]
|
||||||
const url = input.match(/-(\d{6,})(?:[\/?]|$)/) || input.match(/\/(\d{6,})(?:[\/?]|$)/)
|
if (/^\d{6,}$/.test(trimmed)) return trimmed
|
||||||
if (url) return url[1]
|
|
||||||
const bare = input.match(/^\d{6,}$/)
|
const hasScheme = /^https?:\/\//i.test(trimmed)
|
||||||
if (bare) return input
|
if (hasScheme || /^[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(trimmed)) {
|
||||||
return null
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(hasScheme ? trimmed : `https://${trimmed}`)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!LINKEDIN_HOST.test(parsed.hostname)) return null
|
||||||
|
const path = parsed.pathname.match(/\/jobs\/view\/(?:[^/]*-)?(\d{6,})\/?$/)
|
||||||
|
return path ? path[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// A scheme- and slash-free slug such as "software-engineer-1234567890".
|
||||||
|
const slug = trimmed.match(/^[a-z0-9-]*-(\d{6,})$/i)
|
||||||
|
return slug ? slug[1] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runDetail(opts: DetailOpts): Promise<number> {
|
export async function runDetail(opts: DetailOpts): Promise<number> {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { runDetail } from "../src/commands/detail";
|
||||||
|
import { DETAIL_URL } from "../src/helpers";
|
||||||
|
import { runCLI } from "./helpers";
|
||||||
|
|
||||||
|
// The real command path, not the parser in isolation: `detail` must never
|
||||||
|
// send a request for a URL that is not a LinkedIn job view. Before the host
|
||||||
|
// check, a Greenhouse apply link pasted from a posting was reduced to its
|
||||||
|
// 7-digit path segment and the handler fetched LinkedIn posting 4567890 -
|
||||||
|
// an unrelated job, printed with exit 0. fetch is stubbed throughout; no
|
||||||
|
// test here touches the network.
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalStderrWrite = process.stderr.write;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
process.stderr.write = originalStderrWrite;
|
||||||
|
});
|
||||||
|
|
||||||
|
function recordingFetch(): string[] {
|
||||||
|
const requested: string[] = [];
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||||
|
requested.push(String(input));
|
||||||
|
return new Response("", { status: 404 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureStderr(): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||||
|
out.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
}) as typeof process.stderr.write;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("detail input on the real handler path", () => {
|
||||||
|
test("a foreign job-board URL is rejected with BAD_ID before any request", async () => {
|
||||||
|
const requested = recordingFetch();
|
||||||
|
const stderr = captureStderr();
|
||||||
|
|
||||||
|
const rc = await runDetail({ id: "https://boards.greenhouse.io/acme/jobs/4567890", format: "json" });
|
||||||
|
|
||||||
|
expect(rc).toBe(1);
|
||||||
|
expect(requested).toEqual([]);
|
||||||
|
expect(JSON.parse(stderr.join("").trim())).toMatchObject({ code: "BAD_ID" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a LinkedIn job URL builds the fetch URL from the extracted id", async () => {
|
||||||
|
const requested = recordingFetch();
|
||||||
|
captureStderr();
|
||||||
|
|
||||||
|
const rc = await runDetail({
|
||||||
|
id: "https://dk.linkedin.com/jobs/view/data-scientist-9876543210/?trackingId=x",
|
||||||
|
format: "json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rc).toBe(1); // the stub answers 404 -> NOT_FOUND, after the request was made
|
||||||
|
expect(requested).toEqual([`${DETAIL_URL}/9876543210`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detail input through the CLI", () => {
|
||||||
|
test("exits 1 with the stderr-JSON BAD_ID contract for a foreign URL", async () => {
|
||||||
|
const result = await runCLI(["detail", "https://jobs.lever.co/acme/1234567"]);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(JSON.parse(result.stderr)).toMatchObject({ code: "BAD_ID" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -267,6 +267,30 @@ describe("normalizeId", () => {
|
|||||||
expect(normalizeId("https://dk.linkedin.com/jobs/view/data-scientist-9876543210/")).toBe("9876543210");
|
expect(normalizeId("https://dk.linkedin.com/jobs/view/data-scientist-9876543210/")).toBe("9876543210");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts a scheme-less linkedin.com job URL and a bare title slug", () => {
|
||||||
|
expect(normalizeId("www.linkedin.com/jobs/view/1234567890")).toBe("1234567890");
|
||||||
|
expect(normalizeId("software-engineer-1234567890")).toBe("1234567890");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a job URL on any other host instead of extracting its digits", () => {
|
||||||
|
// The old pattern took the first 6+-digit path segment from any URL, so an
|
||||||
|
// ATS apply link fetched an unrelated LinkedIn posting with that number.
|
||||||
|
expect(normalizeId("https://boards.greenhouse.io/acme/jobs/4567890")).toBeNull();
|
||||||
|
expect(normalizeId("https://jobs.lever.co/acme/1234567")).toBeNull();
|
||||||
|
expect(normalizeId("https://example.com/jobs/view/1234567890")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects look-alike and userinfo hosts", () => {
|
||||||
|
expect(normalizeId("https://linkedin.com.evil.io/jobs/view/1234567890")).toBeNull();
|
||||||
|
expect(normalizeId("https://notlinkedin.com/jobs/view/1234567890")).toBeNull();
|
||||||
|
expect(normalizeId("https://www.linkedin.com@evil.io/jobs/view/1234567890")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a linkedin.com URL that is not a job view", () => {
|
||||||
|
expect(normalizeId("https://www.linkedin.com/in/someone-1234567890/")).toBeNull();
|
||||||
|
expect(normalizeId("https://www.linkedin.com/jobs/search/?currentJobId=1234567890")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("returns null for non-job URLs and invalid strings", () => {
|
test("returns null for non-job URLs and invalid strings", () => {
|
||||||
expect(normalizeId("https://www.linkedin.com/feed/")).toBeNull();
|
expect(normalizeId("https://www.linkedin.com/feed/")).toBeNull();
|
||||||
expect(normalizeId("not-a-url")).toBeNull();
|
expect(normalizeId("not-a-url")).toBeNull();
|
||||||
|
|||||||
@@ -84,6 +84,23 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **`linkedin-search detail` no longer fetches an unrelated posting for a URL on another
|
||||||
|
host** - `normalizeId` took the first 6+-digit path segment from *any* URL, so a
|
||||||
|
Greenhouse or Lever apply link (the kind a posting's own page hands out, and what a user
|
||||||
|
pastes back into `detail`) was reduced to that number and the handler fetched
|
||||||
|
`jobPosting/<number>` from LinkedIn: whatever job carried that id came back, printed with
|
||||||
|
exit 0, or `NOT_FOUND` if none did - never an error about the input. Demonstrated by driving
|
||||||
|
the real handler with a stubbed fetch: `https://boards.greenhouse.io/acme/jobs/4567890`
|
||||||
|
requested `.../jobPosting/4567890`. Every other portal CLI rejects an off-host detail URL
|
||||||
|
with `BAD_ID` (the #447 shape); linkedin was the one still trusting the digits. URLs are
|
||||||
|
now parsed for real: a `linkedin.com` host (apex or any subdomain, scheme optional) plus a
|
||||||
|
`/jobs/view/<slug-><id>` path yields the id, and anything else - other hosts, look-alike and
|
||||||
|
userinfo hosts, a linkedin.com profile or search URL - exits 1 with the stderr-JSON
|
||||||
|
`BAD_ID` contract before any request. Bare ids, URNs, and slash-free title slugs are
|
||||||
|
unchanged. Pinned by four new `normalizeId` cases and a new `detail-input.test.ts` that
|
||||||
|
drives `runDetail` and the CLI with fetch stubbed; the off-host cases fail on the old
|
||||||
|
pattern.
|
||||||
|
|
||||||
- **`/rank` tracker matching preserves Unicode company and role names**
|
- **`/rank` tracker matching preserves Unicode company and role names**
|
||||||
(`tools/rank_state.py`, `tests/test_rank_state.py`) - ASCII-only normalization
|
(`tools/rank_state.py`, `tests/test_rank_state.py`) - ASCII-only normalization
|
||||||
collapsed distinct non-Latin roles to the same empty value and dropped
|
collapsed distinct non-Latin roles to the same empty value and dropped
|
||||||
|
|||||||
Reference in new issue
Block a user