From b167efae3bb8b343816c4d88892815ad201c4676 Mon Sep 17 00:00:00 2001 From: Gurnoor Kaur <85249027+kgurnoor@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:20:23 +0530 Subject: [PATCH] feat(linkedin-search): add --jobage-minutes for sub-day freshness windows (#302) jobageToTPR() only emits whole-day f_TPR windows, so a search can't be restricted to postings from the last N minutes. LinkedIn's f_TPR filters server-side down to one-second granularity (confirmed empirically), so this is a pure window-construction change via a new minutesToTPR() helper - no HTML parsing changes needed. --jobage-minutes and --jobage both express a freshness window; passing both is rejected with CONFLICTING_AGE_FLAGS rather than one silently overriding the other. --- .agents/skills/linkedin-search/SKILL.md | 4 ++ .agents/skills/linkedin-search/cli/src/cli.ts | 25 +++++++++++ .../cli/src/commands/search.ts | 4 +- .../skills/linkedin-search/cli/src/helpers.ts | 6 +++ .../cli/tests/cli-flag-validation.test.ts | 42 +++++++++++++++++++ .../linkedin-search/cli/tests/parsing.test.ts | 15 ++++++- .../linkedin-search/cli/tests/search.test.ts | 19 +++++++++ 7 files changed, 113 insertions(+), 2 deletions(-) diff --git a/.agents/skills/linkedin-search/SKILL.md b/.agents/skills/linkedin-search/SKILL.md index fc8134e..2a9b7bd 100644 --- a/.agents/skills/linkedin-search/SKILL.md +++ b/.agents/skills/linkedin-search/SKILL.md @@ -50,6 +50,7 @@ Key flags: - `--location ` / `-l ` — **required.** A LinkedIn place string, e.g. `"Mumbai, Maharashtra, India"`, `"Berlin, Germany"`, `"London, United Kingdom"`, or `"Remote"`. - `--query ` / `-q ` — keyword search (title, skill, role). Recommended. - `--jobage ` — posted within N days: `1`, `7`, `14`, `30`. Omit for all postings. +- `--jobage-minutes ` — posted within N minutes (sub-day precision, e.g. `30`). Conflicts with `--jobage` — pass only one. - `--remote ` — `remote`, `hybrid`, or `onsite` (workplace-type filter). - `--page ` — page number (1-indexed, 10 results per page). - `--limit ` / `-n ` — cap total results emitted (client-side). @@ -77,6 +78,9 @@ bun run .agents/skills/linkedin-search/cli/src/cli.ts search -q "product manager # Any role, fully remote bun run .agents/skills/linkedin-search/cli/src/cli.ts search -q "paralegal" -l "Remote" --format table +# Engineer roles, remote, posted in the last 30 minutes +bun run .agents/skills/linkedin-search/cli/src/cli.ts search -q "engineer" -l "Remote" --jobage-minutes 30 --format table + # Full details for a specific job bun run .agents/skills/linkedin-search/cli/src/cli.ts detail 4426311357 --format plain ``` diff --git a/.agents/skills/linkedin-search/cli/src/cli.ts b/.agents/skills/linkedin-search/cli/src/cli.ts index 3f68c07..52ada1a 100644 --- a/.agents/skills/linkedin-search/cli/src/cli.ts +++ b/.agents/skills/linkedin-search/cli/src/cli.ts @@ -47,6 +47,7 @@ SEARCH FLAGS "Berlin, Germany", "London, United Kingdom", or "Remote". --query, -q Keywords (job title, skill, or role). Recommended. --jobage Posted within N days: 1, 7, 14, 30. Default: all. + --jobage-minutes Posted within N minutes (sub-day precision). Conflicts with --jobage. --remote remote | hybrid | onsite. Filter by workplace type. --page 1-indexed page (10 results/page). Default 1. --limit, -n Cap results emitted (client-side). @@ -56,6 +57,7 @@ EXAMPLES bun run src/cli.ts search -q "data engineer" -l "Bengaluru, Karnataka, India" --jobage 30 --format table bun run src/cli.ts search -q "product manager" -l "Berlin, Germany" --remote remote --format table bun run src/cli.ts search -q "paralegal" -l "Remote" --format table + bun run src/cli.ts search -q "engineer" -l "Remote" --jobage-minutes 30 --format table bun run src/cli.ts detail 4300011451 --format plain Personal use only — uses LinkedIn's public pages; keep volume low (LinkedIn ToS). @@ -84,6 +86,16 @@ async function main(): Promise { } const fmt = (flags.format as string) || "json" + if (flags.jobage !== undefined && flags["jobage-minutes"] !== undefined) { + process.stderr.write( + JSON.stringify({ + error: "--jobage and --jobage-minutes both set a freshness window; pass only one", + code: "CONFLICTING_AGE_FLAGS", + }) + "\n", + ) + return 1 + } + const parseIntFlag = (name: string, raw: string | boolean | string[]): number | null => { const val = parseInt(raw as string, 10) if (isNaN(val)) { @@ -98,6 +110,18 @@ async function main(): Promise { if (v === null) return 1 flags.jobage = String(v) } + if (flags["jobage-minutes"] !== undefined) { + const raw = flags["jobage-minutes"] + const v = parseIntFlag("jobage-minutes", raw) + if (v === null) return 1 + if (v <= 0) { + process.stderr.write( + JSON.stringify({ error: `--jobage-minutes must be a positive number, got "${raw}"`, code: "BAD_ARG" }) + "\n", + ) + return 1 + } + flags["jobage-minutes"] = String(v) + } if (flags.page !== undefined) { const v = parseIntFlag("page", flags.page) if (v === null) return 1 @@ -113,6 +137,7 @@ async function main(): Promise { query: typeof flags.query === "string" ? flags.query : undefined, location, jobage: flags.jobage ? parseInt(flags.jobage as string, 10) : 9999, + jobageMinutes: flags["jobage-minutes"] ? parseInt(flags["jobage-minutes"] as string, 10) : undefined, remote: typeof flags.remote === "string" ? flags.remote : undefined, page: flags.page ? Math.max(1, parseInt(flags.page as string, 10)) : 1, limit: flags.limit ? parseInt(flags.limit as string, 10) : undefined, diff --git a/.agents/skills/linkedin-search/cli/src/commands/search.ts b/.agents/skills/linkedin-search/cli/src/commands/search.ts index 74061d9..37eaac2 100644 --- a/.agents/skills/linkedin-search/cli/src/commands/search.ts +++ b/.agents/skills/linkedin-search/cli/src/commands/search.ts @@ -3,6 +3,7 @@ import { htmlFetch, parseJobCards, jobageToTPR, + minutesToTPR, workTypeFlag, writeError, type JobCard, @@ -12,6 +13,7 @@ export interface SearchOpts { query?: string location: string jobage: number + jobageMinutes?: number remote?: string // "remote" | "hybrid" | "onsite" page: number limit?: number @@ -22,7 +24,7 @@ function buildUrl(opts: SearchOpts): string { const params = new URLSearchParams() if (opts.query) params.set("keywords", opts.query) if (opts.location) params.set("location", opts.location) - const tpr = jobageToTPR(opts.jobage) + const tpr = opts.jobageMinutes !== undefined ? minutesToTPR(opts.jobageMinutes) : jobageToTPR(opts.jobage) if (tpr) params.set("f_TPR", tpr) const wt = workTypeFlag(opts.remote) if (wt) params.set("f_WT", wt) diff --git a/.agents/skills/linkedin-search/cli/src/helpers.ts b/.agents/skills/linkedin-search/cli/src/helpers.ts index 8d2046a..f4dc18a 100644 --- a/.agents/skills/linkedin-search/cli/src/helpers.ts +++ b/.agents/skills/linkedin-search/cli/src/helpers.ts @@ -256,6 +256,12 @@ export function jobageToTPR(days: number): string | null { return `r${days * 86400}` } +/** Convert a job-age in minutes to LinkedIn's f_TPR seconds value (sub-day precision). */ +export function minutesToTPR(minutes: number): string | null { + if (!minutes || minutes <= 0) return null + return `r${minutes * 60}` +} + /** Workplace-type flag: on-site=1, remote=2, hybrid=3. */ export function workTypeFlag(mode: string | undefined): string | null { switch ((mode || "").toLowerCase()) { diff --git a/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts index 19bfce9..87c1123 100644 --- a/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts +++ b/.agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts @@ -47,6 +47,48 @@ describe("LinkedIn CLI flag validation", () => { }); }); + describe("--jobage-minutes validation", () => { + test("non-numeric string exits 1 with BAD_ARG", async () => { + const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "foo"]); + expect(result.exitCode).not.toBe(0); + const err = parsedStderr(result.stderr); + expect(err.code).toBe("BAD_ARG"); + expect(err.error).toMatch(/jobage-minutes/); + }); + + test("zero exits 1 with BAD_ARG", async () => { + const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "0"]); + expect(result.exitCode).not.toBe(0); + const err = parsedStderr(result.stderr); + expect(err.code).toBe("BAD_ARG"); + expect(err.error).toMatch(/jobage-minutes/); + }); + + test("negative value is parsed as a missing value and exits 1 with BAD_ARG", async () => { + // parseFlags in cli.ts treats a next-token starting with "-" as absent + // (`next.startsWith("-")` → flag becomes boolean `true`), and there is no + // `--flag=value` syntax. So "-5" never reaches --jobage-minutes as a value; + // parseInt("true") is NaN, and BAD_ARG comes from the NaN branch, not the + // `v <= 0` guard. Negatives are unreachable through the CLI as currently parsed. + const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "-5"]); + expect(result.exitCode).not.toBe(0); + const err = parsedStderr(result.stderr); + expect(err.code).toBe("BAD_ARG"); + expect(err.error).toMatch(/jobage-minutes/); + }); + }); + + describe("--jobage / --jobage-minutes conflict", () => { + test("both set exits 1 with CONFLICTING_AGE_FLAGS", async () => { + const result = await runCLI([ + "search", "-l", LOCATION, "--jobage", "7", "--jobage-minutes", "30", + ]); + expect(result.exitCode).not.toBe(0); + const err = parsedStderr(result.stderr); + expect(err.code).toBe("CONFLICTING_AGE_FLAGS"); + }); + }); + describe("--page NaN validation", () => { test("non-numeric string exits 1 with BAD_ARG", async () => { const result = await runCLI(["search", "-l", LOCATION, "--page", "abc"]); diff --git a/.agents/skills/linkedin-search/cli/tests/parsing.test.ts b/.agents/skills/linkedin-search/cli/tests/parsing.test.ts index 792659c..93b44d1 100644 --- a/.agents/skills/linkedin-search/cli/tests/parsing.test.ts +++ b/.agents/skills/linkedin-search/cli/tests/parsing.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { parseJobCards, parseJobDetail, extractDivContent } from "../src/helpers"; +import { parseJobCards, parseJobDetail, extractDivContent, minutesToTPR } from "../src/helpers"; // Minimal search-card markup: parseJobCards splits on the job-posting URN and // needs an id, a base-search-card__title, and a full-link. Everything else is @@ -111,3 +111,16 @@ describe("extractDivContent", () => { expect(job.description).toContain("We are hiring!"); }); }); + +describe("minutesToTPR", () => { + test("converts minutes to an f_TPR seconds window", () => { + expect(minutesToTPR(30)).toBe("r1800"); + expect(minutesToTPR(1)).toBe("r60"); + expect(minutesToTPR(1440)).toBe("r86400"); // matches jobageToTPR(1) + }); + + test("returns null for non-positive input", () => { + expect(minutesToTPR(0)).toBeNull(); + expect(minutesToTPR(-5)).toBeNull(); + }); +}); diff --git a/.agents/skills/linkedin-search/cli/tests/search.test.ts b/.agents/skills/linkedin-search/cli/tests/search.test.ts index 9f47251..0d2e3ee 100644 --- a/.agents/skills/linkedin-search/cli/tests/search.test.ts +++ b/.agents/skills/linkedin-search/cli/tests/search.test.ts @@ -39,4 +39,23 @@ describe("runSearch", () => { expect(code).toBe(0); expect(JSON.parse(stdout).results).toHaveLength(0); }); + + test("--jobage-minutes 30 constructs f_TPR=r1800 in the request URL", async () => { + let capturedUrl = ""; + globalThis.fetch = (async (input: RequestInfo | URL) => { + capturedUrl = typeof input === "string" ? input : input.toString(); + return new Response(""); + }) as typeof fetch; + + const code = await runSearch({ + location: "Remote", + jobage: 9999, + jobageMinutes: 30, + page: 1, + format: "json", + }); + + expect(code).toBe(0); + expect(capturedUrl).toContain("f_TPR=r1800"); + }); });