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.
This commit is contained in:
Gurnoor Kaur
2026-08-07 15:50:23 +02:00
committed by GitHub
parent a7ac6fea75
commit b167efae3b
7 changed files with 113 additions and 2 deletions
+4
View File
@@ -50,6 +50,7 @@ Key flags:
- `--location <text>` / `-l <text>`**required.** A LinkedIn place string, e.g. `"Mumbai, Maharashtra, India"`, `"Berlin, Germany"`, `"London, United Kingdom"`, or `"Remote"`.
- `--query <text>` / `-q <text>` — keyword search (title, skill, role). Recommended.
- `--jobage <days>` — posted within N days: `1`, `7`, `14`, `30`. Omit for all postings.
- `--jobage-minutes <n>` — posted within N minutes (sub-day precision, e.g. `30`). Conflicts with `--jobage` — pass only one.
- `--remote <mode>``remote`, `hybrid`, or `onsite` (workplace-type filter).
- `--page <n>` — page number (1-indexed, 10 results per page).
- `--limit <n>` / `-n <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
```
@@ -47,6 +47,7 @@ SEARCH FLAGS
"Berlin, Germany", "London, United Kingdom", or "Remote".
--query, -q <text> Keywords (job title, skill, or role). Recommended.
--jobage <days> Posted within N days: 1, 7, 14, 30. Default: all.
--jobage-minutes <n> Posted within N minutes (sub-day precision). Conflicts with --jobage.
--remote <mode> remote | hybrid | onsite. Filter by workplace type.
--page <n> 1-indexed page (10 results/page). Default 1.
--limit, -n <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<number> {
}
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<number> {
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<number> {
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,
@@ -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)
@@ -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()) {
@@ -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"]);
@@ -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();
});
});
@@ -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");
});
});