openings 0.1.31 → 0.1.32
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/package.json +1 -1
- package/src/catalog.ts +8 -6
- package/src/crawler.ts +35 -1
- package/src/experience.ts +45 -0
- package/src/index.ts +1 -0
- package/src/job-matching.ts +8 -0
- package/src/local-jobs.ts +2 -0
- package/src/mcp.ts +1 -0
- package/src/runtime.ts +4 -1
- package/src/types.ts +4 -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.32",
|
|
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/package.json
CHANGED
package/src/catalog.ts
CHANGED
|
@@ -99,12 +99,7 @@ export function createCatalog(options: CatalogOptions): Catalog {
|
|
|
99
99
|
const company = options.companies.find((candidate) => candidate.ats === ats && candidate.slug === slug);
|
|
100
100
|
if (!company) return null;
|
|
101
101
|
let job = (await fetchJobs(company)).find((candidate) => candidate.id === id) ?? null;
|
|
102
|
-
if (job &&
|
|
103
|
-
const description = await fetchWorkdayDescription(company, job.url, fetcher);
|
|
104
|
-
job = { ...job, description };
|
|
105
|
-
}
|
|
106
|
-
const spec = job && !job.description.trim() ? providerSpec(company.ats) : undefined;
|
|
107
|
-
if (job && spec?.detail) job = { ...job, description: await spec.detail(company, job, jsonGetter(fetcher, company.name)) };
|
|
102
|
+
if (job && !job.description.trim()) job = { ...job, description: await fetchJobDescription(company, job, fetcher) };
|
|
108
103
|
if (job && !job.description.trim()) throw new Error(`Full description unavailable for job: ${id}`);
|
|
109
104
|
return job;
|
|
110
105
|
},
|
|
@@ -402,6 +397,13 @@ export function workdayPostedAt(label: string | undefined, now: number = Date.no
|
|
|
402
397
|
return new Date(now - days * 86_400_000).toISOString().slice(0, 10);
|
|
403
398
|
}
|
|
404
399
|
|
|
400
|
+
/** The full description for a job whose listing carried none (Workday and providers with a detail endpoint); "" when there is no detail source. */
|
|
401
|
+
export async function fetchJobDescription(company: Company, job: Job, fetcher: Fetch): Promise<string> {
|
|
402
|
+
if (company.ats === "workday") return fetchWorkdayDescription(company, job.url, fetcher);
|
|
403
|
+
const spec = providerSpec(company.ats);
|
|
404
|
+
return spec?.detail ? spec.detail(company, job, jsonGetter(fetcher, company.name)) : "";
|
|
405
|
+
}
|
|
406
|
+
|
|
405
407
|
async function fetchWorkdayDescription(company: Company, jobUrl: string, fetcher: Fetch): Promise<string> {
|
|
406
408
|
const source = parseWorkdayToken(company.token);
|
|
407
409
|
const path = new URL(jobUrl).pathname.replace(new RegExp(`^/en-US/${escapeRegExp(source.site)}`), "");
|
package/src/crawler.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FetchJobsObserver } from "./catalog.ts";
|
|
2
|
+
import { statedExperience } from "./experience.ts";
|
|
2
3
|
import { partitionFor, type Company, type CrawlFailure, type CrawlReport, type CrawlSourceResult, type Job, type JobPartition, type JobSnapshot } from "./types.ts";
|
|
3
4
|
|
|
4
5
|
export interface SnapshotStore {
|
|
@@ -17,6 +18,10 @@ interface CrawlerOptions {
|
|
|
17
18
|
sourceLimit?: number;
|
|
18
19
|
workdayPageDelayMs?: number;
|
|
19
20
|
workdayCountries?: string[];
|
|
21
|
+
/** Fetch a job's full description when its listing had none; used to read required experience. */
|
|
22
|
+
describe?(source: Company, job: Job): Promise<string>;
|
|
23
|
+
/** Only roles open to these countries, posted in the last 30 days, get a description fetch. Empty: none do. */
|
|
24
|
+
describeCountries?: string[];
|
|
20
25
|
pacingNow?: () => number;
|
|
21
26
|
pacingSleep?: (delayMs: number) => Promise<void>;
|
|
22
27
|
now?: () => Date;
|
|
@@ -40,6 +45,30 @@ export function createCrawler(options: CrawlerOptions): Crawler {
|
|
|
40
45
|
const pacingSleep = options.pacingSleep ?? ((delayMs: number) => new Promise<void>((resolve) => setTimeout(resolve, delayMs)));
|
|
41
46
|
let previousStart: number | undefined;
|
|
42
47
|
let pacingGate = Promise.resolve();
|
|
48
|
+
const describeCountries = new Set(options.describeCountries ?? []);
|
|
49
|
+
|
|
50
|
+
/** Required experience for every job: from its description, the last crawl's reading, or a paced detail fetch for recent roles in describeCountries. */
|
|
51
|
+
async function withExperience(source: Company, jobs: Job[], previous: Job[] | undefined): Promise<Job[]> {
|
|
52
|
+
const known = new Map((previous ?? []).filter((job) => job.experience !== undefined).map((job) => [job.id, job.experience]));
|
|
53
|
+
const since = now().getTime() - 30 * 86_400_000;
|
|
54
|
+
const deadline = Date.now() + DESCRIBE_BUDGET_MS;
|
|
55
|
+
let budget = DESCRIBE_LIMIT;
|
|
56
|
+
const out: Job[] = [];
|
|
57
|
+
for (const job of jobs) {
|
|
58
|
+
if (job.experience !== undefined) out.push(job);
|
|
59
|
+
else if (job.description.trim()) out.push({ ...job, experience: statedExperience(job.description) });
|
|
60
|
+
else if (known.has(job.id)) out.push({ ...job, experience: known.get(job.id) });
|
|
61
|
+
else if (options.describe && budget > 0 && Date.now() < deadline && job.eligibleCountries.some((code) => describeCountries.has(code)) && Date.parse(job.updatedAt ?? "") >= since) {
|
|
62
|
+
budget -= 1;
|
|
63
|
+
try {
|
|
64
|
+
if (options.workdayPageDelayMs) await pacingSleep(options.workdayPageDelayMs);
|
|
65
|
+
const description = await options.describe(source, job);
|
|
66
|
+
out.push(description.trim() ? { ...job, experience: statedExperience(description) } : job);
|
|
67
|
+
} catch { out.push(job); } // unread; the next crawl tries again
|
|
68
|
+
} else out.push(job);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
43
72
|
|
|
44
73
|
async function paceSourceStart() {
|
|
45
74
|
if (!sourceStartDelayMs) return;
|
|
@@ -92,11 +121,12 @@ export function createCrawler(options: CrawlerOptions): Crawler {
|
|
|
92
121
|
const controller = new AbortController();
|
|
93
122
|
const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
94
123
|
try {
|
|
95
|
-
const
|
|
124
|
+
const listed = await options.fetchJobs(source, controller.signal, {
|
|
96
125
|
onBackoff: ({ status, delayMs }) => { metric.backoffMs += delayMs; if (status === 429) metric.throttles += 1; },
|
|
97
126
|
workdayPageDelayMs: options.workdayPageDelayMs,
|
|
98
127
|
workdayCountries: options.workdayCountries,
|
|
99
128
|
});
|
|
129
|
+
const jobs = await withExperience(source, listed, partitionFor(partitions, source.slug)?.jobs);
|
|
100
130
|
partitions[source.slug] = { fetchedAt: now().toISOString(), jobs };
|
|
101
131
|
succeeded += 1;
|
|
102
132
|
metric.status = "succeeded";
|
|
@@ -136,6 +166,10 @@ export function createCrawler(options: CrawlerOptions): Crawler {
|
|
|
136
166
|
};
|
|
137
167
|
}
|
|
138
168
|
|
|
169
|
+
// ponytail: per-source caps keep one crawl bounded; a big backlog (first run) drains over a few nights.
|
|
170
|
+
const DESCRIBE_LIMIT = 150;
|
|
171
|
+
const DESCRIBE_BUDGET_MS = 180_000;
|
|
172
|
+
|
|
139
173
|
function partitionTime(value: string | undefined): number {
|
|
140
174
|
const timestamp = Date.parse(value ?? "");
|
|
141
175
|
return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Years of experience a posting asks for, read from its own text. */
|
|
2
|
+
export interface Experience { min: number; max?: number }
|
|
3
|
+
|
|
4
|
+
const WORDS: Record<string, number> = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, twelve: 12, fifteen: 15 };
|
|
5
|
+
const NUM = String.raw`(\d{1,2}(?:\.\d)?|${Object.keys(WORDS).join("|")})`;
|
|
6
|
+
const RANGE = String.raw`(?<![\d.])${NUM}\s*\+?\s*(?:(?:-|–|—|to)\s*${NUM}\s*)?\+?\s*(?:years?|yrs?)(?:'|’)?`;
|
|
7
|
+
// "5+ years of hands-on Java experience" or "Experience: 2-5 years" / "Years of experience 6 to 8 years".
|
|
8
|
+
const YEARS_THEN_EXPERIENCE = new RegExp(String.raw`${RANGE}(?:\s+of)?(?:\s+[\w/&,'’()-]+){0,6}?\s+(?:experience|exp)\b`, "gi");
|
|
9
|
+
const EXPERIENCE_THEN_YEARS = new RegExp(String.raw`\b(?:experience|exp)\b[^.\n\d]{0,30}?${RANGE}`, "gi");
|
|
10
|
+
// Company history reads the same way ("our 135 years of experience"); skip matches that talk about the employer.
|
|
11
|
+
const ABOUT_EMPLOYER = /\b(?:we|our|company|firm|founded|established|history|legacy|heritage)\b[^.\n?!:;•·-]*$/i;
|
|
12
|
+
const MIN_PREFIX = /\b(?:minimum|min\.?|at least|atleast)\s*(?:of\s*)?$/i;
|
|
13
|
+
|
|
14
|
+
function value(text: string | undefined): number | undefined {
|
|
15
|
+
if (!text) return undefined;
|
|
16
|
+
return WORDS[text.toLowerCase()] ?? Number(text);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The first experience requirement the text states, or null when it states none. */
|
|
20
|
+
export function statedExperience(text: string): Experience | null {
|
|
21
|
+
const found: Array<{ index: number; experience: Experience }> = [];
|
|
22
|
+
for (const pattern of [YEARS_THEN_EXPERIENCE, EXPERIENCE_THEN_YEARS]) {
|
|
23
|
+
for (const match of text.matchAll(pattern)) {
|
|
24
|
+
const [low, high] = [value(match[1]), value(match[2])];
|
|
25
|
+
if (low === undefined || !Number.isFinite(low)) continue;
|
|
26
|
+
// Requirements rarely pass 20 years; bigger numbers are nearly always an employer's history.
|
|
27
|
+
if (low > 20 || (high !== undefined && (high < low || high > 40))) continue;
|
|
28
|
+
const before = text.slice(Math.max(0, match.index - 60), match.index);
|
|
29
|
+
if (ABOUT_EMPLOYER.test(before) && !MIN_PREFIX.test(before)) continue;
|
|
30
|
+
found.push({ index: match.index, experience: high !== undefined && high !== low ? { min: low, max: high } : { min: low } });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
found.sort((left, right) => left.index - right.index);
|
|
34
|
+
return found[0]?.experience ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Entry-level titles (intern, trainee, fresher, graduate) as a rough 0–1 year estimate when the posting states nothing. */
|
|
38
|
+
export function titleExperience(title: string): Experience | undefined {
|
|
39
|
+
return /\b(?:intern|internship|trainee|freshers?|graduate|apprentice(?:ship)?|entry[- ]level)\b/i.test(title) ? { min: 0, max: 1 } : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** "2–5 yrs", "5+ yrs". */
|
|
43
|
+
export function experienceLabel(experience: Experience): string {
|
|
44
|
+
return experience.max === undefined ? `${experience.min}+ yrs` : `${experience.min}–${experience.max} yrs`;
|
|
45
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export const companies: Company[] = Object.entries(companyData).map(([slug, valu
|
|
|
17
17
|
|
|
18
18
|
export const catalog = createCatalog({ companies });
|
|
19
19
|
export { createCatalog } from "./catalog.ts";
|
|
20
|
+
export { experienceLabel, statedExperience, titleExperience, type Experience } from "./experience.ts";
|
|
20
21
|
export type { Catalog } from "./catalog.ts";
|
|
21
22
|
export type { Ats, Company, CrawlReport, Job, JobPartition, JobSnapshot, JobSummary, SearchQuery } from "./types.ts";
|
|
22
23
|
export { createCrawlReporter, fetchSeedSnapshot, resolveAggregatorUrl } from "./crawl-reporting.ts";
|
package/src/job-matching.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceLabel } from "./experience.ts";
|
|
1
2
|
import { validateCandidateProfileEvidence, type CandidateProfile } from "./candidate-profile.ts";
|
|
2
3
|
import { isEligibleForCountry, normalizeLocation } from "./locations.ts";
|
|
3
4
|
import { detectRequirementTerms, findTransferability, matchesExactSkillEvidence, requiresExactSkillEvidence, type TransferabilityKind } from "./requirement-vocabulary.ts";
|
|
@@ -258,6 +259,13 @@ function supportedRequirements(profile: CandidateProfile, requirements: string[]
|
|
|
258
259
|
const seniorityTerms = ["manager", "principal", "staff", "lead", "senior", "mid", "junior", "intern"] as const;
|
|
259
260
|
function seniorityAlignment(profile: CandidateProfile, intent: CandidateIntent, job: Job): { score: number; reason?: string } {
|
|
260
261
|
const requested = intent.seniority?.map((value) => value.toLocaleLowerCase()) ?? profile.inferences.filter((inference) => inference.kind === "seniority").map((inference) => inference.value);
|
|
262
|
+
const resumeYears = profile.inferences.find((inference) => inference.kind === "approximate_experience_years")?.value;
|
|
263
|
+
if (job.experience && typeof resumeYears === "number" && !intent.seniority?.length) {
|
|
264
|
+
// The posting's own range is better evidence than a seniority word in the title. A year short or a few over still fits.
|
|
265
|
+
const asked = experienceLabel(job.experience);
|
|
266
|
+
const fits = resumeYears >= job.experience.min - 1 && (job.experience.max === undefined || resumeYears <= job.experience.max + 3);
|
|
267
|
+
return { score: fits ? 3 : -3, reason: `posting asks for ${asked}; resume shows about ${resumeYears} years` };
|
|
268
|
+
}
|
|
261
269
|
const jobSeniority = seniorityTerms.find((term) => includesPhrase(job.title, term));
|
|
262
270
|
if (!jobSeniority) return { score: 0 };
|
|
263
271
|
if (!requested.length) {
|
package/src/local-jobs.ts
CHANGED
|
@@ -15,6 +15,8 @@ interface LocalJobsOptions {
|
|
|
15
15
|
sourceLimit?: number;
|
|
16
16
|
workdayPageDelayMs?: number;
|
|
17
17
|
workdayCountries?: string[];
|
|
18
|
+
describe?(source: Company, job: Job): Promise<string>;
|
|
19
|
+
describeCountries?: string[];
|
|
18
20
|
now?: () => Date;
|
|
19
21
|
onCrawled?(source: Company, partition: JobPartition): void | Promise<void>;
|
|
20
22
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -21,6 +21,7 @@ interface ToolHandler {
|
|
|
21
21
|
export const FLOW_INSTRUCTIONS = [
|
|
22
22
|
"Openings flow: 1) call prepare_job_search for the person's countries; it is normally ready in one call. 2) Ask for the resume before exploring roles, because recommend_jobs ranks by evidence from it. 3) If the person declines a resume, use search_jobs with their keywords. 4) If they decline that too, use your judgement.",
|
|
23
23
|
"Freshness: results start with roles posted in the last 7 days and widen to 14, 30, then everything only when fewer than 5 appear; each result carries age (new, older, stale, undated) and postedDaysAgo. Say which window results came from and present older or stale roles as possibly still open, never as current.",
|
|
24
|
+
"Experience: a job's experience field is the years its posting states ({ min, max }); null means the posting states none, and absent means it was not read. Quote it as the employer's requirement.",
|
|
24
25
|
"Every match carries applyUrl, the employer's own posting; include it. When recommend_jobs reports no_matches, relay its explanation and nextMoves instead of searching silently.",
|
|
25
26
|
].join("\n");
|
|
26
27
|
|
package/src/runtime.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { SnapshotStore } from "./crawler.ts";
|
|
3
3
|
import type { Company, SearchQuery } from "./types.ts";
|
|
4
|
-
import { fetchSourceJobs } from "./catalog.ts";
|
|
4
|
+
import { fetchJobDescription, fetchSourceJobs } from "./catalog.ts";
|
|
5
5
|
import { createCrawlReporter, fetchSeedSnapshot, resolveAggregatorUrl } from "./crawl-reporting.ts";
|
|
6
6
|
import { createUsageReporter, type UsageReporter } from "./usage.ts";
|
|
7
7
|
import { VERSION } from "./version.ts";
|
|
@@ -33,6 +33,9 @@ export function createRuntime(options: { dataDir?: string; concurrency?: number;
|
|
|
33
33
|
sourceLimit: options.sourceLimit,
|
|
34
34
|
workdayPageDelayMs: options.workdayPageDelayMs,
|
|
35
35
|
workdayCountries,
|
|
36
|
+
// Server crawls set OPENINGS_DESCRIBE_COUNTRIES (IN) to read required experience from Workday detail pages; installs skip the extra requests.
|
|
37
|
+
describe: (source, job) => fetchJobDescription(source, job, globalThis.fetch),
|
|
38
|
+
describeCountries: (process.env.OPENINGS_DESCRIBE_COUNTRIES ?? "").split(",").map((code) => code.trim().toUpperCase()).filter((code) => /^[A-Z]{2}$/.test(code)),
|
|
36
39
|
onCrawled,
|
|
37
40
|
});
|
|
38
41
|
const recommender = createJobRecommender({ sources: companies, store, crawl: local.crawl });
|
package/src/types.ts
CHANGED
|
@@ -66,6 +66,8 @@ export interface SourceVerificationResult {
|
|
|
66
66
|
rejected: RejectedSource[];
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
import type { Experience } from "./experience.ts";
|
|
70
|
+
|
|
69
71
|
export type WorkMode = "remote" | "hybrid" | "onsite" | "unknown";
|
|
70
72
|
export type EligibilityConfidence = "explicit" | "inferred" | "unknown";
|
|
71
73
|
|
|
@@ -83,6 +85,8 @@ export interface JobSummary {
|
|
|
83
85
|
url: string;
|
|
84
86
|
/** Posting date when the board exposes one (Workday's relative label is approximate), otherwise the board's last-update time. */
|
|
85
87
|
updatedAt?: string;
|
|
88
|
+
/** Years of experience the posting states, read from its description. null: the description states none. Absent: not read yet. */
|
|
89
|
+
experience?: Experience | null;
|
|
86
90
|
/** Days since the posting date at the time of the search; absent when the board gave no date. */
|
|
87
91
|
postedDaysAgo?: number;
|
|
88
92
|
/** new = 7 days or less, older = 8 to 30, stale = beyond 30, undated = no posting date. Present it as such; a stale listing may still be open. */
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.32";
|