openings 0.1.13 → 0.1.15
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/package.json +1 -1
- package/src/catalog.ts +27 -4
- package/src/cli.ts +7 -3
- package/src/intent-validation.ts +2 -1
- package/src/job-matching.ts +5 -4
- package/src/runtime.ts +2 -1
- package/src/tools.ts +5 -1
- package/src/types.ts +3 -0
- package/src/version.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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
|
@@ -50,7 +50,7 @@ The [job-seeker quickstart](docs/job-seeker-quickstart.md) has sample prompts, a
|
|
|
50
50
|
| `recommend_jobs` | Ranks jobs against a resume and explicit intent. Returns direct, hidden, and stretch results with evidence. Performs at most one scoped refresh; `refresh.policy: "never"` guarantees no crawl. |
|
|
51
51
|
| `analyze_job_fit` | Explains one job against verbatim resume evidence: supported, transferable, unsupported, and screening risks. |
|
|
52
52
|
| `optimize_resume` | Proposes grounded suggestions, an additive diff, or revised Markdown. Never invents experience. |
|
|
53
|
-
| `search_jobs` | Plain keyword, location, country, and remote search over the index. `country` takes a two-letter code such as `IN
|
|
53
|
+
| `search_jobs` | Plain keyword, location, country, and remote search over the index, newest first. `country` takes a two-letter code such as `IN`; `maxAgeDays` keeps only roles posted within that window. |
|
|
54
54
|
| `get_job` | Returns one job with its full description. |
|
|
55
55
|
|
|
56
56
|
There is deliberately no form-fill, apply, or submit tool. The only thing Openings writes is your own job index.
|
package/package.json
CHANGED
package/src/catalog.ts
CHANGED
|
@@ -20,6 +20,7 @@ interface GreenhouseJob {
|
|
|
20
20
|
title: string;
|
|
21
21
|
location: { name: string };
|
|
22
22
|
absolute_url: string;
|
|
23
|
+
first_published?: string;
|
|
23
24
|
updated_at?: string;
|
|
24
25
|
content?: string;
|
|
25
26
|
}
|
|
@@ -109,8 +110,18 @@ export function createCatalog(options: CatalogOptions): Catalog {
|
|
|
109
110
|
};
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
export function searchJobs(jobs: Job[], query: SearchQuery): JobSummary[] {
|
|
113
|
-
|
|
113
|
+
export function searchJobs(jobs: Job[], query: SearchQuery, now: number = Date.now()): JobSummary[] {
|
|
114
|
+
const since = query.maxAgeDays ? now - query.maxAgeDays * 86_400_000 : undefined;
|
|
115
|
+
return jobs
|
|
116
|
+
.filter((job) => matches(job, query) && (since === undefined || postedTime(job) >= since))
|
|
117
|
+
.sort((left, right) => postedTime(right) - postedTime(left))
|
|
118
|
+
.slice(0, query.limit ?? 50).map(toSummary);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Posting time in ms for sorting; undated jobs sort last. */
|
|
122
|
+
export function postedTime(job: Pick<Job, "updatedAt">): number {
|
|
123
|
+
const time = job.updatedAt ? Date.parse(job.updatedAt) : Number.NaN;
|
|
124
|
+
return Number.isFinite(time) ? time : 0;
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
export interface FetchJobsObserver {
|
|
@@ -293,10 +304,22 @@ function normalizeWorkday(company: Company, source: ReturnType<typeof parseWorkd
|
|
|
293
304
|
workMode: /remote/i.test(location) ? "remote" : "unknown",
|
|
294
305
|
eligibleCountries: [], excludedCountries: [], eligibleRegions: [], eligibilityConfidence: "unknown",
|
|
295
306
|
url: `https://${source.host}/en-US/${source.site}${job.externalPath}`,
|
|
307
|
+
...(workdayPostedAt(job.postedOn) ? { updatedAt: workdayPostedAt(job.postedOn) } : {}),
|
|
296
308
|
description: "",
|
|
297
309
|
});
|
|
298
310
|
}
|
|
299
311
|
|
|
312
|
+
/** Workday lists only a relative label ("Posted Today", "Posted 3 Days Ago", "Posted 30+ Days Ago"); turn it into an approximate posting date. */
|
|
313
|
+
export function workdayPostedAt(label: string | undefined, now: number = Date.now()): string | undefined {
|
|
314
|
+
const text = label?.trim().toLowerCase() ?? "";
|
|
315
|
+
let days: number | undefined;
|
|
316
|
+
if (/\btoday\b/.test(text)) days = 0;
|
|
317
|
+
else if (/\byesterday\b/.test(text)) days = 1;
|
|
318
|
+
else { const match = /(\d+)\+?\s*days?\s*ago/.exec(text); if (match) days = Number(match[1]) + (text.includes("+") ? 1 : 0); }
|
|
319
|
+
if (days === undefined) return undefined;
|
|
320
|
+
return new Date(now - days * 86_400_000).toISOString().slice(0, 10);
|
|
321
|
+
}
|
|
322
|
+
|
|
300
323
|
async function fetchWorkdayDescription(company: Company, jobUrl: string, fetcher: Fetch): Promise<string> {
|
|
301
324
|
const source = parseWorkdayToken(company.token);
|
|
302
325
|
const path = new URL(jobUrl).pathname.replace(new RegExp(`^/en-US/${escapeRegExp(source.site)}`), "");
|
|
@@ -367,7 +390,7 @@ function normalizeGreenhouse(company: Company, job: GreenhouseJob): Job {
|
|
|
367
390
|
eligibleRegions: [],
|
|
368
391
|
eligibilityConfidence: "unknown",
|
|
369
392
|
url: job.absolute_url,
|
|
370
|
-
updatedAt: job.updated_at,
|
|
393
|
+
updatedAt: job.first_published ?? job.updated_at,
|
|
371
394
|
description: stripHtml(job.content ?? ""),
|
|
372
395
|
});
|
|
373
396
|
}
|
|
@@ -376,7 +399,7 @@ function normalizeRecruitee(company: Company, job: RecruiteeJob): Job {
|
|
|
376
399
|
const location = [job.city, job.state_name, job.country_code?.toUpperCase()].filter(Boolean).join(", ") || "Unspecified";
|
|
377
400
|
const translation = job.translations?.en ?? Object.values(job.translations ?? {})[0];
|
|
378
401
|
const description = [translation?.description ?? job.description, translation?.requirements ?? job.requirements].filter(Boolean).map((value) => stripHtml(value!)).join("\n\n");
|
|
379
|
-
const updatedAt = job.
|
|
402
|
+
const updatedAt = job.published_at ?? job.updated_at;
|
|
380
403
|
return classifyJob({
|
|
381
404
|
id: `recruitee:${company.slug}:${job.guid}`,
|
|
382
405
|
company: company.name,
|
package/src/cli.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { mergeAttemptedRoundLeads, prepareRecruiteeRoundArtifacts } from "./recr
|
|
|
22
22
|
const HELP = `Openings — search public company job boards
|
|
23
23
|
|
|
24
24
|
Usage:
|
|
25
|
-
openings crawl [--workday-countries IN,US] [--country CODE | --companies FILE] [--concurrency N] [--source-cache-hours N] [--source-limit N] [--delay-ms N] [--workday-page-delay-ms N] [--data-dir PATH]
|
|
25
|
+
openings crawl [--workday-countries IN,US] [--country CODE | --companies FILE] [--concurrency N] [--source-cache-hours N] [--source-limit N] [--delay-ms N] [--workday-page-delay-ms N] [--timeout-ms N] [--data-dir PATH]
|
|
26
26
|
openings snapshot export [--input FILE] [--output-dir PATH]
|
|
27
27
|
openings coverage report --country CODE [--snapshot FILE] [--catalog FILE] [--candidates FILE] [--registry FILE] [--output FILE] [--as-of ISO]
|
|
28
28
|
openings sources discover-subdomains SEEDS.json [--output FILE] [--limit N] [--delay-ms N] [--report FILE]
|
|
@@ -69,7 +69,7 @@ export async function run(args: string[]): Promise<number> {
|
|
|
69
69
|
if (typeof parsed === "string") return fail(parsed);
|
|
70
70
|
const runtime = createRuntime({
|
|
71
71
|
dataDir: parsed.dataDir, concurrency: parsed.concurrency, sourceCacheHours: parsed.sourceCacheHours,
|
|
72
|
-
sourceLimit: parsed.sourceLimit, crawlDelayMs: parsed.delayMs, workdayPageDelayMs: parsed.workdayPageDelayMs, workdayCountries: parsed.workdayCountries,
|
|
72
|
+
sourceLimit: parsed.sourceLimit, crawlDelayMs: parsed.delayMs, workdayPageDelayMs: parsed.workdayPageDelayMs, workdayCountries: parsed.workdayCountries, timeoutMs: parsed.timeoutMs,
|
|
73
73
|
});
|
|
74
74
|
const slugs = parsed.companiesFile ? await readCompanyFile(parsed.companiesFile) : undefined;
|
|
75
75
|
console.log(JSON.stringify(await runtime.crawl({ country: parsed.country, slugs }), null, 2));
|
|
@@ -514,6 +514,7 @@ function parseCrawl(args: string[]) {
|
|
|
514
514
|
let concurrency = 10;
|
|
515
515
|
let delayMs = 0;
|
|
516
516
|
let workdayPageDelayMs = 0;
|
|
517
|
+
let timeoutMs: number | undefined;
|
|
517
518
|
let workdayCountries: string[] | undefined;
|
|
518
519
|
let sourceCacheHours = 24;
|
|
519
520
|
let sourceLimit = 0;
|
|
@@ -537,6 +538,9 @@ function parseCrawl(args: string[]) {
|
|
|
537
538
|
} else if (arg === "--workday-page-delay-ms") {
|
|
538
539
|
workdayPageDelayMs = Number(args[++index]);
|
|
539
540
|
if (!Number.isInteger(workdayPageDelayMs) || workdayPageDelayMs < 0 || workdayPageDelayMs > 60_000) return "--workday-page-delay-ms must be an integer from 0 to 60000";
|
|
541
|
+
} else if (arg === "--timeout-ms") {
|
|
542
|
+
timeoutMs = Number(args[++index]);
|
|
543
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 1_800_000) return "--timeout-ms must be an integer from 1000 to 1800000";
|
|
540
544
|
} else if (arg === "--source-cache-hours") {
|
|
541
545
|
sourceCacheHours = Number(args[++index]);
|
|
542
546
|
if (!Number.isFinite(sourceCacheHours) || sourceCacheHours < 0 || sourceCacheHours > 8760) return "--source-cache-hours must be a number from 0 to 8760";
|
|
@@ -548,7 +552,7 @@ function parseCrawl(args: string[]) {
|
|
|
548
552
|
if (country && companiesFile) return "Use either --country or --companies, not both";
|
|
549
553
|
if (args.includes("--companies") && !companiesFile) return "--companies requires a file";
|
|
550
554
|
if (args.includes("--data-dir") && !dataDir) return "--data-dir requires a value";
|
|
551
|
-
return { country, companiesFile, dataDir, concurrency, delayMs, workdayPageDelayMs, sourceCacheHours, sourceLimit
|
|
555
|
+
return { country, companiesFile, dataDir, concurrency, delayMs, workdayPageDelayMs, sourceCacheHours, sourceLimit, workdayCountries, timeoutMs };
|
|
552
556
|
}
|
|
553
557
|
|
|
554
558
|
function parseCountry(value: string | undefined): string | undefined {
|
package/src/intent-validation.ts
CHANGED
|
@@ -6,7 +6,7 @@ const arrayFields = ["roles", "countries", "locations", "seniority", "requiredSk
|
|
|
6
6
|
export function validateCandidateIntent(value: unknown, invalid: InvalidInput, options: { optional?: boolean } = {}): CandidateIntent {
|
|
7
7
|
if (value === undefined && options.optional) return {};
|
|
8
8
|
if (!isRecord(value)) throw invalid("intent", "Candidate intent must be an object");
|
|
9
|
-
assertKnownKeys(value, [...arrayFields, "remote"], "intent", invalid);
|
|
9
|
+
assertKnownKeys(value, [...arrayFields, "remote", "maxAgeDays"], "intent", invalid);
|
|
10
10
|
for (const field of arrayFields) {
|
|
11
11
|
const candidate = value[field];
|
|
12
12
|
if (candidate !== undefined && (!Array.isArray(candidate) || !candidate.every((item) => typeof item === "string" && item.trim().length > 0))) throw invalid(`intent.${field}`, `${field} must be an array of non-empty strings`);
|
|
@@ -17,6 +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
21
|
return value as unknown as CandidateIntent;
|
|
21
22
|
}
|
|
22
23
|
|
package/src/job-matching.ts
CHANGED
|
@@ -2,6 +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
6
|
import { evaluateScreeningRequirements, type ScreeningRequirement } from "./screening-requirements.ts";
|
|
6
7
|
|
|
7
8
|
export interface CandidateIntent {
|
|
@@ -15,6 +16,8 @@ export interface CandidateIntent {
|
|
|
15
16
|
excludedCountries?: string[];
|
|
16
17
|
excludedLocations?: string[];
|
|
17
18
|
excludedRoles?: string[];
|
|
19
|
+
/** Only roles posted within this many days; undated roles are dropped when set. */
|
|
20
|
+
maxAgeDays?: number;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export interface SupportedRequirement {
|
|
@@ -226,6 +229,7 @@ function hardFilterReasons(job: Job, intent: CandidateIntent): string[] {
|
|
|
226
229
|
if (intent.locations?.length && !intent.locations.some((location) => normalizeLocation(job.location).includes(normalizeLocation(location)))) reasons.push("location_mismatch");
|
|
227
230
|
for (const location of intent.excludedLocations ?? []) if (normalizeLocation(job.location).includes(normalizeLocation(location))) reasons.push(`location_excluded:${location}`);
|
|
228
231
|
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`);
|
|
229
233
|
if (intent.remote === true && job.workMode !== "remote") reasons.push("remote_required");
|
|
230
234
|
if (intent.remote === false && (job.workMode === "remote" || job.workMode === "unknown")) reasons.push("non_remote_required");
|
|
231
235
|
const searchable = `${job.title}\n${job.company}\n${job.location}\n${job.description}`;
|
|
@@ -439,7 +443,4 @@ function tokenOverlap(left: string, right: string): number {
|
|
|
439
443
|
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
|
|
440
444
|
}
|
|
441
445
|
|
|
442
|
-
|
|
443
|
-
const timestamp = job.updatedAt ? Date.parse(job.updatedAt) : Number.NaN;
|
|
444
|
-
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
445
|
-
}
|
|
446
|
+
const freshness = postedTime;
|
package/src/runtime.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { createFileSnapshotStore } from "./snapshot-store.ts";
|
|
|
13
13
|
import { createJobCoverageReader } from "./job-coverage.ts";
|
|
14
14
|
import { createJobSearchPreparer } from "./job-search-preparation.ts";
|
|
15
15
|
|
|
16
|
-
export function createRuntime(options: { dataDir?: string; concurrency?: number; crawlDelayMs?: number; workdayPageDelayMs?: number; workdayCountries?: string[]; sourceCacheHours?: number; sourceLimit?: number } = {}) {
|
|
16
|
+
export function createRuntime(options: { dataDir?: string; concurrency?: number; crawlDelayMs?: number; workdayPageDelayMs?: number; workdayCountries?: string[]; sourceCacheHours?: number; sourceLimit?: number; timeoutMs?: number } = {}) {
|
|
17
17
|
const workdayCountries = options.workdayCountries ?? (process.env.OPENINGS_WORKDAY_COUNTRIES ?? "IN,US").split(",").map((code) => code.trim().toUpperCase()).filter((code) => /^[A-Z]{2}$/.test(code));
|
|
18
18
|
const dataDir = options.dataDir ?? process.env.OPENINGS_DATA_DIR ?? join(process.cwd(), ".openings");
|
|
19
19
|
const store = createFileSnapshotStore(join(dataDir, "snapshot.json"));
|
|
@@ -25,6 +25,7 @@ export function createRuntime(options: { dataDir?: string; concurrency?: number;
|
|
|
25
25
|
store,
|
|
26
26
|
fetchJobs: (source, signal, observer) => fetchSourceJobs(source, globalThis.fetch, signal, observer),
|
|
27
27
|
concurrency: options.concurrency,
|
|
28
|
+
timeoutMs: options.timeoutMs,
|
|
28
29
|
sourceStartDelayMs: options.crawlDelayMs,
|
|
29
30
|
sourceFreshnessMs: (options.sourceCacheHours ?? 0) * 60 * 60 * 1000,
|
|
30
31
|
sourceLimit: options.sourceLimit,
|
package/src/tools.ts
CHANGED
|
@@ -107,6 +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
111
|
limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
|
|
111
112
|
},
|
|
112
113
|
additionalProperties: false,
|
|
@@ -140,10 +141,11 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
140
141
|
if (name === "analyze_job_fit") return workflows.analyzeJobFit(input);
|
|
141
142
|
if (name === "optimize_resume") return workflows.optimizeResume(input);
|
|
142
143
|
if (name === "search_jobs") {
|
|
143
|
-
assertToolKeys(input, ["query", "location", "country", "remote", "limit"], "search_jobs");
|
|
144
|
+
assertToolKeys(input, ["query", "location", "country", "remote", "maxAgeDays", "limit"], "search_jobs");
|
|
144
145
|
if (input.query !== undefined && typeof input.query !== "string") throw new Error("query must be a string");
|
|
145
146
|
if (input.location !== undefined && typeof input.location !== "string") throw new Error("location must be a string");
|
|
146
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");
|
|
147
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");
|
|
148
150
|
const query: SearchQuery = {};
|
|
149
151
|
if (typeof input.query === "string") query.query = input.query;
|
|
@@ -151,6 +153,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
151
153
|
if (typeof input.country === "string" && /^[a-z]{2}$/i.test(input.country)) query.country = input.country.toUpperCase();
|
|
152
154
|
else if (input.country !== undefined) throw new Error("country must be a two-letter code");
|
|
153
155
|
if (typeof input.remote === "boolean") query.remote = input.remote;
|
|
156
|
+
if (typeof input.maxAgeDays === "number") query.maxAgeDays = input.maxAgeDays;
|
|
154
157
|
if (typeof input.limit === "number") query.limit = input.limit;
|
|
155
158
|
return { jobs: await catalog.search(query) };
|
|
156
159
|
}
|
|
@@ -181,6 +184,7 @@ function intentSchema(): Record<string, unknown> {
|
|
|
181
184
|
roles: stringArray(), countries: countryArray(), locations: stringArray(), remote: { type: "boolean" }, seniority: stringArray(),
|
|
182
185
|
requiredSkills: stringArray(), excludedTerms: stringArray(),
|
|
183
186
|
excludedCountries: countryArray(), excludedLocations: stringArray(), excludedRoles: stringArray(),
|
|
187
|
+
maxAgeDays: { type: "integer", minimum: 1, maximum: 365, description: "Only roles posted within this many days" },
|
|
184
188
|
},
|
|
185
189
|
additionalProperties: false,
|
|
186
190
|
};
|
package/src/types.ts
CHANGED
|
@@ -80,6 +80,7 @@ export interface JobSummary {
|
|
|
80
80
|
eligibleRegions: string[];
|
|
81
81
|
eligibilityConfidence: EligibilityConfidence;
|
|
82
82
|
url: string;
|
|
83
|
+
/** Posting date when the board exposes one (Workday's relative label is approximate), otherwise the board's last-update time. */
|
|
83
84
|
updatedAt?: string;
|
|
84
85
|
}
|
|
85
86
|
|
|
@@ -92,6 +93,8 @@ export interface SearchQuery {
|
|
|
92
93
|
location?: string;
|
|
93
94
|
country?: string;
|
|
94
95
|
remote?: boolean;
|
|
96
|
+
/** Only jobs posted within this many days; undated jobs are excluded when set. */
|
|
97
|
+
maxAgeDays?: number;
|
|
95
98
|
limit?: number;
|
|
96
99
|
}
|
|
97
100
|
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.15";
|