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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/docs/job-seeker-quickstart.md +1 -1
- package/package.json +1 -1
- package/src/adzuna-signals.ts +63 -0
- package/src/catalog.ts +6 -2
- package/src/cli.ts +33 -0
- package/src/employer-resolver.ts +78 -0
- package/src/intent-validation.ts +1 -1
- package/src/job-matching.ts +8 -3
- package/src/job-search-preparation.ts +6 -2
- package/src/jooble-signals.ts +65 -0
- package/src/screening-requirements.ts +25 -1
- package/src/tools.ts +4 -4
- package/src/types.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openings",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
|
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) && (
|
|
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, ""); }
|
package/src/intent-validation.ts
CHANGED
|
@@ -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) <
|
|
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
|
|
package/src/job-matching.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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) <
|
|
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:
|
|
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
|
|
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.
|
|
1
|
+
export const VERSION = "0.1.22";
|