openings 0.1.14 → 0.1.16
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/crawler.ts +3 -3
- package/src/intent-validation.ts +2 -1
- package/src/job-coverage.ts +2 -2
- package/src/job-matching.ts +5 -4
- package/src/job-search-preparation.ts +3 -3
- package/src/runtime.ts +1 -1
- package/src/tools.ts +5 -1
- package/src/types.ts +8 -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.16",
|
|
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/crawler.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FetchJobsObserver } from "./catalog.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { partitionFor, type Company, type CrawlFailure, type CrawlReport, type CrawlSourceResult, type Job, type JobPartition, type JobSnapshot } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
export interface SnapshotStore {
|
|
5
5
|
read(): Promise<JobSnapshot | null>;
|
|
@@ -66,9 +66,9 @@ export function createCrawler(options: CrawlerOptions): Crawler {
|
|
|
66
66
|
const considered = sources.length;
|
|
67
67
|
const cutoff = Date.parse(startedAt) - sourceFreshnessMs;
|
|
68
68
|
const eligibleSources = (sourceFreshnessMs === 0 ? sources : sources.filter((source) => {
|
|
69
|
-
const fetchedAt = Date.parse(partitions
|
|
69
|
+
const fetchedAt = Date.parse(partitionFor(partitions, source.slug)?.fetchedAt ?? "");
|
|
70
70
|
return !Number.isFinite(fetchedAt) || fetchedAt <= cutoff;
|
|
71
|
-
})).sort((left, right) => partitionTime(partitions
|
|
71
|
+
})).sort((left, right) => partitionTime(partitionFor(partitions, left.slug)?.fetchedAt) - partitionTime(partitionFor(partitions, right.slug)?.fetchedAt));
|
|
72
72
|
const selectedSources = sourceLimit > 0 ? eligibleSources.slice(0, sourceLimit) : eligibleSources;
|
|
73
73
|
let pending = selectedSources;
|
|
74
74
|
let finalFailures: CrawlFailure[] = [];
|
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-coverage.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isEligibleForCountry } from "./locations.ts";
|
|
2
2
|
import type { SnapshotStore } from "./crawler.ts";
|
|
3
|
-
import type
|
|
3
|
+
import { partitionFor, type Company, type JobSnapshot } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
export interface CountryJobCoverage {
|
|
6
6
|
country: string;
|
|
@@ -28,7 +28,7 @@ export function createJobCoverageReader(options: { sources: Company[]; store: Sn
|
|
|
28
28
|
export function projectJobCoverage(sources: Company[], snapshot: JobSnapshot, countries: string[]): JobCoverageSummary {
|
|
29
29
|
const normalizedCountries = [...new Set(countries.map(normalizeCountry))];
|
|
30
30
|
const indexedSources = sources.flatMap((source) => {
|
|
31
|
-
const partition = snapshot.partitions
|
|
31
|
+
const partition = partitionFor(snapshot.partitions, source.slug);
|
|
32
32
|
return partition ? [{ source, jobs: partition.jobs }] : [];
|
|
33
33
|
});
|
|
34
34
|
|
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;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SnapshotStore } from "./crawler.ts";
|
|
2
2
|
import { projectJobCoverage, type JobCoverageSummary } from "./job-coverage.ts";
|
|
3
3
|
import type { CrawlScope } from "./local-jobs.ts";
|
|
4
|
-
import type
|
|
4
|
+
import { partitionFor, type Company, type CrawlReport, type JobSnapshot } from "./types.ts";
|
|
5
5
|
|
|
6
6
|
export interface PrepareJobSearchResult {
|
|
7
7
|
status: "ready" | "partial";
|
|
@@ -61,7 +61,7 @@ export function createJobSearchPreparer(options: {
|
|
|
61
61
|
function pendingSources(sources: Company[], snapshot: Awaited<ReturnType<SnapshotStore["read"]>>, now: Date, freshnessMs: number): Company[] {
|
|
62
62
|
const cutoff = now.getTime() - freshnessMs;
|
|
63
63
|
return sources.filter((source) => {
|
|
64
|
-
const fetchedAt = Date.parse(snapshot
|
|
64
|
+
const fetchedAt = Date.parse((snapshot && partitionFor(snapshot.partitions, source.slug))?.fetchedAt ?? "");
|
|
65
65
|
return !Number.isFinite(fetchedAt) || fetchedAt < cutoff;
|
|
66
66
|
});
|
|
67
67
|
}
|
|
@@ -71,7 +71,7 @@ function sourceState(sources: Company[], snapshot: NonNullable<Awaited<ReturnTyp
|
|
|
71
71
|
let missing = 0;
|
|
72
72
|
let stale = 0;
|
|
73
73
|
for (const source of sources) {
|
|
74
|
-
const partition = snapshot.partitions
|
|
74
|
+
const partition = partitionFor(snapshot.partitions, source.slug);
|
|
75
75
|
if (!partition) missing += 1;
|
|
76
76
|
else {
|
|
77
77
|
const fetchedAt = Date.parse(partition.fetchedAt);
|
package/src/runtime.ts
CHANGED
|
@@ -47,7 +47,7 @@ export function createRuntime(options: { dataDir?: string; concurrency?: number;
|
|
|
47
47
|
workdayCountries,
|
|
48
48
|
onCrawled,
|
|
49
49
|
});
|
|
50
|
-
const preparation = createJobSearchPreparer({ sources: companies, store, crawl: preparationLocal.crawl, seed: aggregatorUrl ? (countries) => fetchSeedSnapshot(aggregatorUrl, globalThis.fetch,
|
|
50
|
+
const preparation = createJobSearchPreparer({ sources: companies, store, crawl: preparationLocal.crawl, seed: aggregatorUrl ? (countries) => fetchSeedSnapshot(aggregatorUrl, globalThis.fetch, 60_000, countries) : undefined });
|
|
51
51
|
const getSelectedJob = createSelectedJobLookup({
|
|
52
52
|
getSnapshotJob: async (id) => (await local.get(id, { offline: true, staleDays: 14 })).job,
|
|
53
53
|
getDetailedJob: (id) => liveCatalog.get(id),
|
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
|
|
|
@@ -87,11 +88,18 @@ export interface Job extends JobSummary {
|
|
|
87
88
|
description: string;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/** Partition lookup that ignores inherited properties, so a slug such as "constructor" never resolves to Object.prototype. */
|
|
92
|
+
export function partitionFor<T>(partitions: Record<string, T>, slug: string): T | undefined {
|
|
93
|
+
return Object.hasOwn(partitions, slug) ? partitions[slug] : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
90
96
|
export interface SearchQuery {
|
|
91
97
|
query?: string;
|
|
92
98
|
location?: string;
|
|
93
99
|
country?: string;
|
|
94
100
|
remote?: boolean;
|
|
101
|
+
/** Only jobs posted within this many days; undated jobs are excluded when set. */
|
|
102
|
+
maxAgeDays?: number;
|
|
95
103
|
limit?: number;
|
|
96
104
|
}
|
|
97
105
|
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.16";
|