openings 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Find evidence-grounded jobs, including relevant roles you may not have searched for, without accounts or API keys.",
5
5
  "author": { "name": "Openings contributors" },
6
6
  "license": "MIT",
package/README.md CHANGED
@@ -37,7 +37,7 @@ Then ask your agent something like:
37
37
 
38
38
  > Show me what Openings covers in India. If that looks useful, use my resume to find backend roles, including good jobs whose titles I would not have searched for. Rank by evidence and explain every gap.
39
39
 
40
- On first use the agent builds your private index in resumable batches of ten sources, reports real coverage, and only then asks for a resume. Results come back in three buckets: direct title matches, hidden roles found through grounded title families, and stretch roles, each with separate evidence and keyword scores. The index lives under `~/.openings`.
40
+ On first use the agent downloads the shared index of every verified source in one call, reports real coverage, and only then asks for a resume; only missing or stale sources are crawled, in batches of 25. Results come back in three buckets: direct title matches, hidden roles found through grounded title families, and stretch roles, each with separate evidence and keyword scores. The index lives under `~/.openings`.
41
41
 
42
42
  The [job-seeker quickstart](docs/job-seeker-quickstart.md) has sample prompts, an example conversation, privacy details, and common errors.
43
43
 
@@ -86,7 +86,7 @@ In a client that accepts MCP configuration, add this stdio server:
86
86
  }
87
87
  ```
88
88
 
89
- The first `prepare_job_search` call starts a private index under `~/.openings` from verified structured job sources. Each call handles at most ten missing or stale sources, gives each source one bounded 90-second attempt, and returns `nextAction`, allowing the agent to continue across MCP requests. It crawls every currently missing verified source because a source's country eligibility is known only after its jobs are indexed; the requested countries control the coverage returned to the candidate, not which employers are assumed to belong to a country. Successful batches are cached, failures are reported, and later calls rotate past failed sources so the rest of the catalog can progress. Partitions older than 14 days are refreshed. Once all sources are fresh, the setup call makes no network request.
89
+ The first `prepare_job_search` call starts a private index under `~/.openings` from verified structured job sources. The first call seeds the index from the shared aggregator and is normally ready at once; each later call handles at most 25 missing or stale sources, gives each source one bounded 90-second attempt, and returns `nextAction`, allowing the agent to continue across MCP requests. It crawls every currently missing verified source because a source's country eligibility is known only after its jobs are indexed; the requested countries control the coverage returned to the candidate, not which employers are assumed to belong to a country. Successful batches are cached, failures are reported, and later calls rotate past failed sources so the rest of the catalog can progress. Partitions older than 14 days are refreshed. Once all sources are fresh, the setup call makes no network request.
90
90
 
91
91
  For development from a source checkout, point the server at the absolute `src/mcp.ts` path and set `OPENINGS_DATA_DIR` to the repository's absolute `.openings` directory:
92
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "A free, candidate-safe job-search substrate for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/abhay-avagama/hiring-agent.git" },
@@ -0,0 +1,63 @@
1
+ import { atomicJson } from "./atomic-file.ts";
2
+
3
+ /**
4
+ * Adzuna as an employer signal for one country: which employers have posted recently and how much. Jobs are never
5
+ * ingested from it; names and dates feed the employer resolver. The trial plan allows 25 hits a minute and 250 a day,
6
+ * so every run takes a hit budget and paces itself.
7
+ */
8
+ export interface AdzunaEmployer { companyName: string; jobs: number; newestCreated?: string; categories: string[]; locations: string[] }
9
+ export interface AdzunaSignalReport { generatedAt: string; country: string; hits: number; jobsSeen: number; totalReported: number; employers: AdzunaEmployer[] }
10
+ type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
11
+
12
+ export const ADZUNA_KEYWORDS = ["", "engineer", "developer", "software", "manager", "analyst", "sales", "marketing", "executive", "accountant", "finance", "designer", "data", "support", "operations", "recruiter", "consultant", "nurse", "teacher", "technician", "intern", "product", "quality", "customer", "associate", "java", "python", "react", "devops", "testing"];
13
+
14
+ export async function collectAdzunaSignals(appId: string, appKey: string, outputPath: string, options: { country?: string; keywords?: string[]; maxHits?: number; maxPages?: number; fetcher?: Fetch; delayMs?: number; sleep?: (ms: number) => Promise<void>; maxDaysOld?: number } = {}): Promise<AdzunaSignalReport> {
15
+ const fetcher = options.fetcher ?? globalThis.fetch;
16
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
17
+ const country = (options.country ?? "in").toLowerCase();
18
+ const maxHits = options.maxHits ?? 200;
19
+ const employers = new Map<string, AdzunaEmployer>();
20
+ const seen = new Set<string>();
21
+ let hits = 0; let totalReported = 0;
22
+ outer: for (const keywords of options.keywords ?? ADZUNA_KEYWORDS) {
23
+ for (let page = 1; page <= (options.maxPages ?? 10); page += 1) {
24
+ if (hits >= maxHits) break outer;
25
+ if (hits > 0) await sleep(options.delayMs ?? 2_600); // 25 hits a minute
26
+ hits += 1;
27
+ const url = new URL(`https://api.adzuna.com/v1/api/jobs/${country}/search/${page}`);
28
+ url.searchParams.set("app_id", appId); url.searchParams.set("app_key", appKey);
29
+ url.searchParams.set("results_per_page", "50"); url.searchParams.set("content-type", "application/json");
30
+ url.searchParams.set("max_days_old", String(options.maxDaysOld ?? 30));
31
+ if (keywords) url.searchParams.set("what", keywords);
32
+ const response = await fetcher(url.href, { signal: AbortSignal.timeout(30_000) });
33
+ if (response.status === 429) break outer;
34
+ if (!response.ok) break;
35
+ const body = await response.json() as { count?: number; results?: Array<Record<string, unknown>> };
36
+ if (page === 1) totalReported = Math.max(totalReported, body.count ?? 0);
37
+ const results = body.results ?? [];
38
+ for (const job of results) {
39
+ const id = String(job.id ?? job.redirect_url ?? "");
40
+ if (!id || seen.has(id)) continue;
41
+ seen.add(id);
42
+ const company = isRecord(job.company) ? String(job.company.display_name ?? "").trim() : "";
43
+ if (company.length < 2) continue;
44
+ const key = company.toLowerCase();
45
+ const entry = employers.get(key) ?? { companyName: company, jobs: 0, categories: [], locations: [] };
46
+ entry.jobs += 1;
47
+ const created = typeof job.created === "string" ? job.created : undefined;
48
+ if (created && (!entry.newestCreated || created > entry.newestCreated)) entry.newestCreated = created;
49
+ const category = isRecord(job.category) ? String(job.category.label ?? "") : "";
50
+ if (category && !entry.categories.includes(category)) entry.categories.push(category);
51
+ const area = isRecord(job.location) && Array.isArray(job.location.area) ? String(job.location.area[job.location.area.length - 1] ?? "") : "";
52
+ if (area && !entry.locations.includes(area) && entry.locations.length < 5) entry.locations.push(area);
53
+ employers.set(key, entry);
54
+ }
55
+ if (results.length < 50) break;
56
+ }
57
+ }
58
+ const report: AdzunaSignalReport = { generatedAt: new Date().toISOString(), country, hits, jobsSeen: seen.size, totalReported, employers: [...employers.values()].sort((left, right) => right.jobs - left.jobs) };
59
+ await atomicJson(outputPath, report);
60
+ return report;
61
+ }
62
+
63
+ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
package/src/catalog.ts CHANGED
@@ -111,10 +111,14 @@ export function createCatalog(options: CatalogOptions): Catalog {
111
111
  };
112
112
  }
113
113
 
114
+ export const DEFAULT_MAX_AGE_DAYS = 30;
114
115
  export function searchJobs(jobs: Job[], query: SearchQuery, now: number = Date.now()): JobSummary[] {
115
- const since = query.maxAgeDays ? now - query.maxAgeDays * 86_400_000 : undefined;
116
+ const explicit = query.maxAgeDays !== undefined;
117
+ const days = explicit ? query.maxAgeDays! : DEFAULT_MAX_AGE_DAYS;
118
+ const since = days > 0 ? now - days * 86_400_000 : undefined;
119
+ const fresh = (job: Job) => since === undefined || postedTime(job) >= since || (!explicit && postedTime(job) === 0);
116
120
  return jobs
117
- .filter((job) => matches(job, query) && (since === undefined || postedTime(job) >= since))
121
+ .filter((job) => matches(job, query) && fresh(job))
118
122
  .sort((left, right) => postedTime(right) - postedTime(left))
119
123
  .slice(0, query.limit ?? 50).map(toSummary);
120
124
  }
package/src/cli.ts CHANGED
@@ -20,6 +20,9 @@ import { probeJobPostingJsonLd } from "./jobposting-probe.ts";
20
20
  import { probeSites } from "./site-probe.ts";
21
21
  import { admitSites } from "./site-admission.ts";
22
22
  import { kekaTenantCandidates } from "./keka-tenants.ts";
23
+ import { collectJoobleSignals } from "./jooble-signals.ts";
24
+ import { collectAdzunaSignals } from "./adzuna-signals.ts";
25
+ import { resolveEmployers } from "./employer-resolver.ts";
23
26
  import { mergeAttemptedRoundLeads, prepareRecruiteeRoundArtifacts } from "./recruitee-round.ts";
24
27
 
25
28
  const HELP = `Openings — search public company job boards
@@ -41,6 +44,9 @@ Usage:
41
44
  openings sources probe-sites SEEDS.json [--sample N] [--limit N] [--concurrency N] [--max-pages N] [--delay-ms N] [--report FILE]
42
45
  openings sources admit-sites REPORT.json [--catalog FILE] [--min-postings N]
43
46
  openings sources keka-tenants HOSTS.txt [--output FILE] [--registry FILE] [--concurrency N]
47
+ openings sources jooble-signals [--location PLACE] [--max-pages N] [--output FILE] (key from JOOBLE_API_KEY)
48
+ openings sources adzuna-signals [--country in] [--max-hits N] [--max-days-old N] [--output FILE] (ADZUNA_APP_ID and ADZUNA_APP_KEY)
49
+ openings sources resolve-employers SIGNALS.json [--catalog FILE] [--registry FILE] [--min-jobs N] [--limit N] [--concurrency N] [--report FILE]
44
50
  openings sources prepare-recruitee-round IDENTITIES.json [--catalog FILE] [--artifacts DIR]
45
51
  openings sources merge-attempted-round-leads ISOLATED_REGISTRY [--registry FILE]
46
52
  openings search [words] [--country CODE|--india] [--location PLACE] [--remote|--onsite]
@@ -131,6 +137,33 @@ export async function run(args: string[]): Promise<number> {
131
137
  console.log(JSON.stringify({ ...summary, topSites: sites.slice(0, 15) }, null, 2));
132
138
  return 0;
133
139
  }
140
+ if (rest[0] === "resolve-employers") {
141
+ const args = rest.slice(1);
142
+ const signalsPath = args[0];
143
+ if (!signalsPath || signalsPath.startsWith("--")) return fail("sources resolve-employers requires a signals JSON file");
144
+ const value = (flag: string) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; };
145
+ const report = await resolveEmployers(signalsPath, { catalogPath: value("--catalog") ?? "data/companies.json", registryPath: value("--registry"), reportPath: value("--report") ?? ".openings/employer-resolver.json", minJobs: value("--min-jobs") ? Number(value("--min-jobs")) : undefined, limit: value("--limit") ? Number(value("--limit")) : undefined, concurrency: value("--concurrency") ? Number(value("--concurrency")) : undefined });
146
+ console.log(JSON.stringify({ ...report, leads: report.leads.slice(0, 20), unresolved: report.unresolved.length }, null, 2));
147
+ return 0;
148
+ }
149
+ if (rest[0] === "adzuna-signals") {
150
+ const args = rest.slice(1);
151
+ const appId = process.env.ADZUNA_APP_ID; const appKey = process.env.ADZUNA_APP_KEY;
152
+ if (!appId || !appKey) return fail("sources adzuna-signals requires ADZUNA_APP_ID and ADZUNA_APP_KEY in the environment");
153
+ const value = (flag: string) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; };
154
+ const report = await collectAdzunaSignals(appId, appKey, value("--output") ?? ".openings/adzuna-signals.json", { country: value("--country"), maxHits: value("--max-hits") ? Number(value("--max-hits")) : undefined, maxDaysOld: value("--max-days-old") ? Number(value("--max-days-old")) : undefined });
155
+ console.log(JSON.stringify({ country: report.country, hits: report.hits, jobsSeen: report.jobsSeen, totalReported: report.totalReported, employers: report.employers.length, top: report.employers.slice(0, 10) }, null, 2));
156
+ return 0;
157
+ }
158
+ if (rest[0] === "jooble-signals") {
159
+ const args = rest.slice(1);
160
+ const apiKey = process.env.JOOBLE_API_KEY;
161
+ if (!apiKey) return fail("sources jooble-signals requires JOOBLE_API_KEY in the environment");
162
+ const value = (flag: string) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; };
163
+ const report = await collectJoobleSignals(apiKey, value("--output") ?? ".openings/jooble-signals.json", { location: value("--location"), maxPages: value("--max-pages") ? Number(value("--max-pages")) : undefined, delayMs: 500 });
164
+ console.log(JSON.stringify({ location: report.location, queries: report.queries, jobsSeen: report.jobsSeen, employers: report.employers.length, seeds: report.seeds.length, top: report.employers.slice(0, 10) }, null, 2));
165
+ return 0;
166
+ }
134
167
  if (rest[0] === "keka-tenants") {
135
168
  const args = rest.slice(1);
136
169
  const hostsPath = args[0];
@@ -0,0 +1,78 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { atomicJson } from "./atomic-file.ts";
3
+ import { mergeEnrichmentLeads, type EnrichmentLead } from "./enrichment-registry.ts";
4
+ import { fetchSafeHead, type HeadTransport, type ResolveHost } from "./safe-head.ts";
5
+ import { resolveSource } from "./source-verification.ts";
6
+
7
+ /**
8
+ * Employer names from a hiring signal (Adzuna, Jooble) become board leads by guessing the provider token from the name
9
+ * and letting the provider confirm it exists. Nothing is admitted here: every hit is a lead for the board tier, which
10
+ * verifies identity against the provider's own record before anything is crawled.
11
+ */
12
+ export interface ResolverSignal { companyName: string; jobs: number }
13
+ export interface ResolverReport { generatedAt: string; employers: number; probed: number; requests: number; found: number; byProvider: Record<string, number>; leads: Array<{ companyName: string; sourceUrl: string; ats: string }>; unresolved: string[] }
14
+ type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
15
+
16
+ const WORKDAY_HOSTS = ["wd1", "wd3", "wd5", "wd12", "wd103", "wd108", "wd10", "wd2"];
17
+ const STOP = new Set(["the", "inc", "llc", "ltd", "limited", "pvt", "private", "corp", "corporation", "company", "co", "group", "india", "technologies", "technology", "solutions", "services", "global", "international", "bank", "plc", "lp", "llp", "and", "of"]);
18
+
19
+ /** Token guesses in order of likelihood: full compact name, hyphenated name, distinctive first word. */
20
+ export function tokenGuesses(name: string): string[] {
21
+ const words = name.toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9 ]+/g, " ").split(/\s+/).filter(Boolean);
22
+ const core = words.filter((word) => !STOP.has(word));
23
+ const base = core.length ? core : words;
24
+ const guesses = [base.join(""), base.join("-"), base[0] ?? ""].filter((guess) => guess.length >= 3);
25
+ if (base.length > 2) guesses.push(base.slice(0, 2).join(""));
26
+ return [...new Set(guesses)];
27
+ }
28
+
29
+ export async function resolveEmployers(signalsPath: string, options: { catalogPath?: string; registryPath?: string; reportPath?: string; minJobs?: number; limit?: number; concurrency?: number; fetcher?: Fetch; headTransport?: HeadTransport; resolveHost?: ResolveHost; timeoutMs?: number } = {}): Promise<ResolverReport> {
30
+ const fetcher = options.fetcher ?? globalThis.fetch;
31
+ const signals = JSON.parse(await readFile(signalsPath, "utf8")) as { employers: ResolverSignal[] };
32
+ const known = new Set<string>();
33
+ if (options.catalogPath) for (const entry of Object.values(JSON.parse(await readFile(options.catalogPath, "utf8")) as Record<string, { name: string; companyDomain?: string }>)) { known.add(compact(entry.name)); if (entry.companyDomain) known.add(compact(entry.companyDomain.split(".")[0]!)); }
34
+ let employers = signals.employers.filter((entry) => entry.jobs >= (options.minJobs ?? 1) && !known.has(compact(entry.companyName)));
35
+ if (options.limit) employers = employers.slice(0, options.limit);
36
+ const report: ResolverReport = { generatedAt: new Date().toISOString(), employers: signals.employers.length, probed: employers.length, requests: 0, found: 0, byProvider: {}, leads: [], unresolved: [] };
37
+ const timeout = options.timeoutMs ?? 12_000;
38
+ const seenSources = new Set<string>();
39
+ const ok = async (url: string, init?: RequestInit) => { report.requests += 1; try { const reply = await fetcher(url, { ...init, signal: AbortSignal.timeout(timeout) }); await reply.body?.cancel().catch(() => undefined); return reply; } catch { return null; } };
40
+ const probes: Array<(guess: string) => Promise<string | null>> = [
41
+ async (guess) => { for (const host of WORKDAY_HOSTS) { report.requests += 1; try { const head = await fetchSafeHead(`https://${guess}.${host}.myworkdayjobs.com/`, { timeoutMs: timeout, transport: options.headTransport, resolveHost: options.resolveHost }); if (head.response.ok && resolveSource(head.finalUrl)?.ats === "workday") return head.finalUrl; } catch { /* no such tenant */ } } return null; },
42
+ async (guess) => { const reply = await ok(`https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(guess)}/jobs`); return reply?.ok ? `https://job-boards.greenhouse.io/${guess}` : null; },
43
+ async (guess) => { const reply = await ok(`https://api.lever.co/v0/postings/${encodeURIComponent(guess)}?mode=json`); return reply?.ok ? `https://jobs.lever.co/${guess}` : null; },
44
+ async (guess) => { const reply = await ok(`https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(guess)}`); return reply?.ok ? `https://jobs.ashbyhq.com/${guess}` : null; },
45
+ async (guess) => { // SmartRecruiters answers 200 with an empty list for any name, so only a posting proves the company exists
46
+ report.requests += 1;
47
+ try { const reply = await fetcher(`https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(guess)}/postings?limit=1`, { signal: AbortSignal.timeout(timeout) }); if (!reply.ok) { await reply.body?.cancel().catch(() => undefined); return null; } const body = await reply.json() as { totalFound?: number }; return (body.totalFound ?? 0) > 0 ? `https://jobs.smartrecruiters.com/${guess}` : null; } catch { return null; }
48
+ },
49
+ async (guess) => { const reply = await ok(`https://apply.workable.com/api/v1/widget/accounts/${encodeURIComponent(guess)}`); return reply?.ok ? `https://apply.workable.com/${guess}/` : null; },
50
+ async (guess) => { if (!/^[a-z0-9-]+$/.test(guess)) return null; const reply = await ok(`https://${guess}.keka.com/careers`); if (!reply?.ok) return null; try { const shell = await (await fetcher(`https://${guess}.keka.com/careers`, { signal: AbortSignal.timeout(timeout) })).text(); const org = /\/ats\/documents\/([0-9a-f-]{36})\//i.exec(shell)?.[1]; return org ? `https://${guess}.keka.com/careers/api/embedjobs/default/active/${org.toLowerCase()}` : null; } catch { return null; } },
51
+ ];
52
+ const leads: EnrichmentLead[] = [];
53
+ let cursor = 0;
54
+ async function worker() {
55
+ while (cursor < employers.length) {
56
+ const employer = employers[cursor++]!;
57
+ let hit: string | null = null;
58
+ for (const guess of tokenGuesses(employer.companyName)) {
59
+ for (const probe of probes) { hit = await probe(guess); if (hit) break; }
60
+ if (hit) break;
61
+ }
62
+ const source = hit ? resolveSource(hit) : null;
63
+ if (!source) { report.unresolved.push(employer.companyName); continue; }
64
+ const sourceKey = `${source.ats}:${source.token.toLowerCase()}`;
65
+ if (seenSources.has(sourceKey)) continue;
66
+ seenSources.add(sourceKey);
67
+ report.found += 1; report.byProvider[source.ats] = (report.byProvider[source.ats] ?? 0) + 1;
68
+ report.leads.push({ companyName: employer.companyName, sourceUrl: source.canonicalSourceUrl, ats: source.ats });
69
+ leads.push({ sourceKey, sourceUrl: source.canonicalSourceUrl, ats: source.ats, token: source.token, discoveredFrom: [{ channel: "search", reference: `hiring-signal:${employer.companyName}` }], companyMatches: [], identityEvidence: [], attempts: [] });
70
+ }
71
+ }
72
+ await Promise.all(Array.from({ length: Math.max(1, options.concurrency ?? 6) }, worker));
73
+ if (options.registryPath && leads.length) await mergeEnrichmentLeads(options.registryPath, leads);
74
+ if (options.reportPath) await atomicJson(options.reportPath, report);
75
+ return report;
76
+ }
77
+
78
+ function compact(value: string): string { return value.toLowerCase().replace(/\b(inc|llc|ltd|limited|pvt|private|corp|corporation|company|group|india|the)\b/g, "").replace(/[^a-z0-9]/g, ""); }
@@ -17,7 +17,7 @@ export function validateCandidateIntent(value: unknown, invalid: InvalidInput, o
17
17
  if (Array.isArray(countries) && !countries.every((country) => typeof country === "string" && /^[A-Za-z]{2}$/.test(country))) throw invalid(`intent.${field}`, `${field} must contain two-letter country codes`);
18
18
  }
19
19
  if (value.remote !== undefined && typeof value.remote !== "boolean") throw invalid("intent.remote", "remote must be a boolean");
20
- if (value.maxAgeDays !== undefined && (!Number.isInteger(value.maxAgeDays) || (value.maxAgeDays as number) < 1 || (value.maxAgeDays as number) > 365)) throw invalid("intent.maxAgeDays", "maxAgeDays must be an integer between 1 and 365");
20
+ if (value.maxAgeDays !== undefined && (!Number.isInteger(value.maxAgeDays) || (value.maxAgeDays as number) < 0 || (value.maxAgeDays as number) > 365)) throw invalid("intent.maxAgeDays", "maxAgeDays must be an integer between 0 and 365");
21
21
  return value as unknown as CandidateIntent;
22
22
  }
23
23
 
@@ -2,7 +2,7 @@ import { validateCandidateProfileEvidence, type CandidateProfile } from "./candi
2
2
  import { isEligibleForCountry, normalizeLocation } from "./locations.ts";
3
3
  import { detectRequirementTerms, findTransferability, matchesExactSkillEvidence, requiresExactSkillEvidence, type TransferabilityKind } from "./requirement-vocabulary.ts";
4
4
  import type { Job } from "./types.ts";
5
- import { postedTime } from "./catalog.ts";
5
+ import { DEFAULT_MAX_AGE_DAYS, postedTime } from "./catalog.ts";
6
6
  import { evaluateScreeningRequirements, type ScreeningRequirement } from "./screening-requirements.ts";
7
7
 
8
8
  export interface CandidateIntent {
@@ -16,7 +16,7 @@ export interface CandidateIntent {
16
16
  excludedCountries?: string[];
17
17
  excludedLocations?: string[];
18
18
  excludedRoles?: string[];
19
- /** Only roles posted within this many days; undated roles are dropped when set. */
19
+ /** Only roles posted within this many days. Default 30 keeps undated roles; an explicit value drops them too; 0 includes everything. */
20
20
  maxAgeDays?: number;
21
21
  }
22
22
 
@@ -49,6 +49,8 @@ export interface JobMatch {
49
49
  transferable: TransferableRequirement[];
50
50
  gaps: string[];
51
51
  discovery: { category: "direct" | "hidden" | "stretch"; titleExpansions: TitleExpansion[] };
52
+ /** The employer's own posting, repeated at the top level so it is never dropped from a summary. */
53
+ applyUrl: string;
52
54
  }
53
55
 
54
56
  export interface FilteredJob { jobId: string; reasons: string[] }
@@ -115,6 +117,7 @@ export function matchJobs(profile: CandidateProfile, intent: CandidateIntent, jo
115
117
  transferable,
116
118
  gaps,
117
119
  discovery: { category, titleExpansions },
120
+ applyUrl: job.url,
118
121
  score,
119
122
  index,
120
123
  });
@@ -229,7 +232,9 @@ function hardFilterReasons(job: Job, intent: CandidateIntent): string[] {
229
232
  if (intent.locations?.length && !intent.locations.some((location) => normalizeLocation(job.location).includes(normalizeLocation(location)))) reasons.push("location_mismatch");
230
233
  for (const location of intent.excludedLocations ?? []) if (normalizeLocation(job.location).includes(normalizeLocation(location))) reasons.push(`location_excluded:${location}`);
231
234
  for (const role of intent.excludedRoles ?? []) if (includesPhrase(job.title, role) || tokenOverlap(role, job.title) === 1) reasons.push(`role_excluded:${role}`);
232
- if (intent.maxAgeDays && postedTime(job) < Date.now() - intent.maxAgeDays * 86_400_000) reasons.push(`posted_too_old:${intent.maxAgeDays}d`);
235
+ const explicitAge = intent.maxAgeDays !== undefined;
236
+ const maxAgeDays = explicitAge ? intent.maxAgeDays! : DEFAULT_MAX_AGE_DAYS;
237
+ if (maxAgeDays > 0 && (postedTime(job) > 0 || explicitAge) && postedTime(job) < Date.now() - maxAgeDays * 86_400_000) reasons.push(`posted_too_old:${maxAgeDays}d`);
233
238
  if (intent.remote === true && job.workMode !== "remote") reasons.push("remote_required");
234
239
  if (intent.remote === false && (job.workMode === "remote" || job.workMode === "unknown")) reasons.push("non_remote_required");
235
240
  const searchable = `${job.title}\n${job.company}\n${job.location}\n${job.description}`;
@@ -6,6 +6,7 @@ import { partitionFor, type Company, type CrawlReport, type JobSnapshot } from "
6
6
  export interface PrepareJobSearchResult {
7
7
  status: "ready" | "partial";
8
8
  nextAction: "ready" | "call_again" | "retry_later";
9
+ note?: string;
9
10
  continuation?: string;
10
11
  networkAttempted: boolean;
11
12
  sources: { catalog: number; indexed: number; fresh: number; stale: number; missing: number; pending: number };
@@ -25,7 +26,7 @@ export function createJobSearchPreparer(options: {
25
26
  }) {
26
27
  const now = options.now ?? (() => new Date());
27
28
  const freshnessMs = (options.freshnessDays ?? 14) * 86_400_000;
28
- const batchSize = Math.max(1, Math.min(10, Math.trunc(options.batchSize ?? 10)));
29
+ const batchSize = Math.max(1, Math.min(25, Math.trunc(options.batchSize ?? 25)));
29
30
  return {
30
31
  async prepare(value: unknown): Promise<PrepareJobSearchResult> {
31
32
  const input = validateInput(value, options.sources);
@@ -44,10 +45,13 @@ export function createJobSearchPreparer(options: {
44
45
  const state = sourceState(options.sources, snapshot, now(), freshnessMs);
45
46
  const attempted = new Set([...input.attempted, ...selected]);
46
47
  const hasUnattemptedPending = pendingSources(options.sources, snapshot, now(), freshnessMs).some((source) => !attempted.has(source.slug));
47
- const nextAction = state.pending === 0 ? "ready" : hasUnattemptedPending ? "call_again" : "retry_later";
48
+ // The shared seed normally covers the whole catalog on the first call; a few missing or stale sources never justify another round trip.
49
+ const covered = state.fresh / Math.max(1, options.sources.length);
50
+ const nextAction = state.pending === 0 || covered >= 0.95 ? "ready" : hasUnattemptedPending ? "call_again" : "retry_later";
48
51
  return {
49
52
  status: state.pending === 0 ? "ready" : "partial",
50
53
  nextAction,
54
+ ...(nextAction === "ready" && state.pending > 0 ? { note: `${state.indexed} of ${options.sources.length} sources are indexed; the remaining ${state.pending} are optional and refresh in the background of later calls.` } : {}),
51
55
  ...(nextAction === "call_again" ? { continuation: encodeContinuation(countries, attempted) } : {}),
52
56
  networkAttempted: Boolean(crawl),
53
57
  sources: { catalog: options.sources.length, ...state },
@@ -0,0 +1,65 @@
1
+ import { atomicJson } from "./atomic-file.ts";
2
+
3
+ /**
4
+ * Jooble as an employer signal, never as a job source: who is hiring right now, and on which site the posting originated.
5
+ * When the origin site is a company-owned host it doubles as the employer's domain, which the site and board tracers verify.
6
+ */
7
+ export interface JoobleSignal { companyName: string; jobs: number; newestUpdated?: string; sources: string[]; companyDomain?: string }
8
+ export interface JoobleSignalReport { generatedAt: string; location: string; queries: number; jobsSeen: number; employers: JoobleSignal[]; seeds: Array<{ companyName: string; companyDomain: string }> }
9
+ type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
10
+
11
+ const AGGREGATOR_HOSTS = /jooble|indeed|linkedin|glassdoor|naukri|monster|shine|timesjobs|foundit|hireskys|decentrajobs|jobrapido|talent\.com|adzuna|careerjet|neuvoo|whatjobs|jobisjob|trovit|mitula|jora|recruit\.net|simplyhired|ziprecruiter|lensa|bebee|learn4good|expertini|workable|greenhouse|lever|ashby|smartrecruiters|myworkdayjobs|recruitee|breezy|freshteam|keka|zohorecruit|jobvite|icims|taleo|successfactors|bamboohr|applytojob|jazzhr/i;
12
+ export const DEFAULT_KEYWORDS = ["", "engineer", "developer", "software", "manager", "analyst", "sales", "marketing", "executive", "accountant", "finance", "designer", "data", "support", "operations", "hr", "consultant", "nurse", "teacher", "technician", "intern", "product", "quality", "customer", "associate"];
13
+
14
+ export async function collectJoobleSignals(apiKey: string, outputPath: string, options: { location?: string; keywords?: string[]; maxPages?: number; fetcher?: Fetch; delayMs?: number; sleep?: (ms: number) => Promise<void> } = {}): Promise<JoobleSignalReport> {
15
+ const fetcher = options.fetcher ?? globalThis.fetch;
16
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
17
+ const location = options.location ?? "India";
18
+ const employers = new Map<string, JoobleSignal>();
19
+ let queries = 0; let jobsSeen = 0;
20
+ const seen = new Set<string>();
21
+ for (const keywords of options.keywords ?? DEFAULT_KEYWORDS) {
22
+ for (let page = 1; page <= (options.maxPages ?? 10); page += 1) {
23
+ if (queries > 0 && options.delayMs) await sleep(options.delayMs);
24
+ queries += 1;
25
+ const response = await fetcher(`https://jooble.org/api/${encodeURIComponent(apiKey)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ keywords, location, page, ResultOnPage: 100 }), signal: AbortSignal.timeout(30_000) });
26
+ if (!response.ok) break;
27
+ const body = await response.json() as { totalCount?: number; jobs?: Array<Record<string, unknown>> };
28
+ const jobs = body.jobs ?? [];
29
+ for (const job of jobs) {
30
+ const id = String(job.id ?? job.link ?? "");
31
+ if (!id || seen.has(id)) continue;
32
+ seen.add(id); jobsSeen += 1;
33
+ const name = String(job.company ?? "").trim();
34
+ if (!name || name.length < 2) continue;
35
+ const key = name.toLowerCase();
36
+ const entry = employers.get(key) ?? { companyName: name, jobs: 0, sources: [] };
37
+ entry.jobs += 1;
38
+ const updated = typeof job.updated === "string" ? job.updated : undefined;
39
+ if (updated && (!entry.newestUpdated || updated > entry.newestUpdated)) entry.newestUpdated = updated;
40
+ const source = String(job.source ?? "").toLowerCase().replace(/^www\./, "");
41
+ if (source && !entry.sources.includes(source)) entry.sources.push(source);
42
+ employers.set(key, entry);
43
+ }
44
+ if (jobs.length < 100 || seen.size >= (body.totalCount ?? 0)) break;
45
+ }
46
+ }
47
+ for (const entry of employers.values()) {
48
+ // Company-owned only when the site's name shares a token with the employer's name; unknown aggregators never qualify.
49
+ const tokens = entry.companyName.toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter((token) => token.length >= 3 && !["the", "ltd", "limited", "inc", "pvt", "private", "group", "technologies", "solutions", "services", "india", "bank", "corporation", "company"].includes(token));
50
+ const owned = entry.sources.map(registrable).find((domain) => !AGGREGATOR_HOSTS.test(domain) && /^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain) && tokens.some((token) => domain.split(".")[0]!.replace(/-/g, "").includes(token)));
51
+ if (owned) entry.companyDomain = owned;
52
+ }
53
+ const list = [...employers.values()].sort((left, right) => right.jobs - left.jobs);
54
+ const report: JoobleSignalReport = { generatedAt: new Date().toISOString(), location, queries, jobsSeen, employers: list, seeds: list.filter((entry) => entry.companyDomain).map((entry) => ({ companyName: entry.companyName, companyDomain: entry.companyDomain! })) };
55
+ await atomicJson(outputPath, report);
56
+ return report;
57
+ }
58
+
59
+ /** careers-inc.nttdata.com -> nttdata.com; keeps two labels under a two-letter country suffix such as co.in. */
60
+ function registrable(host: string): string {
61
+ const parts = host.split(".");
62
+ if (parts.length <= 2) return host;
63
+ const last = parts[parts.length - 1]!; const second = parts[parts.length - 2]!;
64
+ return last.length === 2 && ["co", "com", "org", "net", "ac", "gov", "edu"].includes(second) ? parts.slice(-3).join(".") : parts.slice(-2).join(".");
65
+ }
@@ -59,7 +59,27 @@ function educationRequirements(profile: CandidateProfile, description: string):
59
59
  const requirement = level.startsWith("bachelor") ? "Bachelor's degree" : level.startsWith("master") ? "Master's degree" : "Doctoral degree";
60
60
  return [{ kind: "education", requirement, status: fact ? "supported" : "unsupported", factIds: fact ? [fact.id] : [] }];
61
61
  });
62
- return [...fieldSpecific, ...generic];
62
+ // "Qualification: Polymer Science, Chemistry, Materials Science or a related technical discipline" names the disciplines without the word degree.
63
+ const disciplinePattern = /\b(?:qualifications?|degree|graduate|graduation|b\.?\s?tech|b\.?\s?e\.?|m\.?\s?tech|m\.?\s?sc|b\.?\s?sc|bachelors?|masters?)\b[^.\n:]{0,30}?(?:\bin\b|:)\s+([A-Za-z][^.\n]{3,140})/gi;
64
+ const named = [...description.matchAll(disciplinePattern)].flatMap<ScreeningRequirement>((match) => {
65
+ if (!isMandatory(description, match.index!, match[0]) || !namedDisciplines(match[1]!).length) return [];
66
+ const requirement = clean(match[0]);
67
+ if (fieldSpecific.some((item) => item.requirement === requirement)) return [];
68
+ const fact = profile.facts.find((value) => value.kind === "education" && educationFieldMatches(match[1]!, value.value));
69
+ return [{ kind: "education", requirement, status: fact ? "supported" : "unsupported", factIds: fact ? [fact.id] : [] }];
70
+ });
71
+ return dedupe([...fieldSpecific, ...generic, ...named]);
72
+ }
73
+
74
+ function dedupe(items: ScreeningRequirement[]): ScreeningRequirement[] {
75
+ const seen = new Set<string>();
76
+ return items.filter((item) => { const key = item.requirement.toLocaleLowerCase(); if (seen.has(key)) return false; seen.add(key); return true; });
77
+ }
78
+
79
+ /** Specific disciplines in a requirement, with "or a related technical discipline" style tails removed. */
80
+ function namedDisciplines(field: string): string[] {
81
+ return field.replace(/\b(?:or|and)?\s*(?:a|an)?\s*(?:related|relevant|similar|equivalent|other)\b[^,;/]*$/i, "").split(/,|;|\/|\bor\b|\band\b/i)
82
+ .map((part) => part.trim().toLocaleLowerCase()).filter((part) => part.length >= 3 && !/^(?:related|relevant|equivalent|similar|field|discipline|technical|any)\b/.test(part));
63
83
  }
64
84
 
65
85
  function isMandatory(description: string, start: number, value: string): boolean {
@@ -91,8 +111,12 @@ function educationLevelMatches(level: string, value: string): boolean {
91
111
  return /\b(?:ph\.?d|doctorate)\b/i.test(value);
92
112
  }
93
113
 
114
+ const GENERIC_DISCIPLINE_WORDS = new Set(["science", "sciences", "technology", "engineering", "studies", "management", "arts", "field", "degree", "related", "discipline"]);
94
115
  function educationFieldMatches(requiredField: string, value: string): boolean {
95
116
  const candidate = value.toLocaleLowerCase();
117
+ // Named disciplines are the bar; a broad "related technical discipline" clause only widens it when nothing specific is named.
118
+ const named = namedDisciplines(requiredField).filter((part) => !/\b(?:technical|business)\b/.test(part) || part.split(/\s+/).length > 1);
119
+ if (named.length) return named.some((part) => candidate.includes(part) || part.split(/\s+/).filter((word) => word.length > 3 && !GENERIC_DISCIPLINE_WORDS.has(word)).some((word) => candidate.includes(word)));
96
120
  if (/\b(?:computer science|computing|software|information technology|engineering)\b/i.test(requiredField) && /\b(?:computer science|computing|software|information technology|engineering|b\.?tech|b\.?e\.?|m\.?tech|m\.?e\.?)\b/i.test(candidate)) return true;
97
121
  if (/\b(?:technical|business)\b/i.test(requiredField) && /\b(?:technology|technical|engineering|computer|software|information systems|business|commerce|bba|mba|b\.?tech|m\.?tech)\b/i.test(candidate)) return true;
98
122
  return false;
package/src/tools.ts CHANGED
@@ -24,7 +24,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
24
24
  const definitions: ToolDefinition[] = [
25
25
  {
26
26
  name: "prepare_job_search",
27
- description: "Initialize or refresh the local job index from verified public sources in resumable batches of at most ten, then report current coverage and whether to call again. This may use the network and write only job data under the local Openings data directory; it never processes a resume.",
27
+ description: "Initialize the local job index: the first call downloads the shared index of every verified source (thousands of employers) and returns ready; only missing or stale sources are crawled, in batches of at most 25. Report coverage from the result rather than calling again once nextAction is ready. This may use the network and write only job data under the local Openings data directory; it never processes a resume.",
28
28
  inputSchema: {
29
29
  type: "object",
30
30
  properties: {
@@ -107,7 +107,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
107
107
  location: { type: "string", description: "Case-insensitive location substring" },
108
108
  country: { type: "string", pattern: "^[A-Za-z]{2}$", description: "Two-letter country code for job eligibility, such as IN or DE" },
109
109
  remote: { type: "boolean", description: "True for remote-only; false for non-remote-only" },
110
- maxAgeDays: { type: "integer", minimum: 1, maximum: 365, description: "Only roles posted within this many days; results are newest first" },
110
+ maxAgeDays: { type: "integer", minimum: 0, maximum: 365, description: "Only roles posted within this many days, newest first. Default 30 (undated roles kept, listed last); an explicit value also drops undated roles; 0 includes older roles" },
111
111
  limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
112
112
  },
113
113
  additionalProperties: false,
@@ -145,7 +145,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
145
145
  if (input.query !== undefined && typeof input.query !== "string") throw new Error("query must be a string");
146
146
  if (input.location !== undefined && typeof input.location !== "string") throw new Error("location must be a string");
147
147
  if (input.remote !== undefined && typeof input.remote !== "boolean") throw new Error("remote must be a boolean");
148
- if (input.maxAgeDays !== undefined && (!Number.isInteger(input.maxAgeDays) || (input.maxAgeDays as number) < 1 || (input.maxAgeDays as number) > 365)) throw new Error("maxAgeDays must be an integer between 1 and 365");
148
+ if (input.maxAgeDays !== undefined && (!Number.isInteger(input.maxAgeDays) || (input.maxAgeDays as number) < 0 || (input.maxAgeDays as number) > 365)) throw new Error("maxAgeDays must be an integer between 0 and 365");
149
149
  if (input.limit !== undefined && (!Number.isInteger(input.limit) || (input.limit as number) < 1 || (input.limit as number) > 100)) throw new Error("limit must be an integer between 1 and 100");
150
150
  const query: SearchQuery = {};
151
151
  if (typeof input.query === "string") query.query = input.query;
@@ -184,7 +184,7 @@ function intentSchema(): Record<string, unknown> {
184
184
  roles: stringArray(), countries: countryArray(), locations: stringArray(), remote: { type: "boolean" }, seniority: stringArray(),
185
185
  requiredSkills: stringArray(), excludedTerms: stringArray(),
186
186
  excludedCountries: countryArray(), excludedLocations: stringArray(), excludedRoles: stringArray(),
187
- maxAgeDays: { type: "integer", minimum: 1, maximum: 365, description: "Only roles posted within this many days" },
187
+ maxAgeDays: { type: "integer", minimum: 0, maximum: 365, description: "Only roles posted within this many days. Default 30 (undated roles kept); an explicit value also drops undated roles; 0 includes older roles" },
188
188
  },
189
189
  additionalProperties: false,
190
190
  };
package/src/types.ts CHANGED
@@ -99,7 +99,7 @@ export interface SearchQuery {
99
99
  location?: string;
100
100
  country?: string;
101
101
  remote?: boolean;
102
- /** Only jobs posted within this many days; undated jobs are excluded when set. */
102
+ /** Only jobs posted within this many days. Default 30 keeps undated jobs (sorted last); an explicit value also drops undated jobs; 0 includes everything. */
103
103
  maxAgeDays?: number;
104
104
  limit?: number;
105
105
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.21";
1
+ export const VERSION = "0.1.22";