openings 0.1.36 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "A free, candidate-safe job-search substrate for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/abhay-avagama/hiring-agent.git" },
package/src/catalog.ts CHANGED
@@ -2,7 +2,7 @@ import { CASCADE_MINIMUM, CASCADE_WINDOWS, type Company, type Job, type JobAge,
2
2
  import { decodeEntities, providerSpec, type JsonGet } from "./providers.ts";
3
3
  import { crawlSite, sitePostingsToJobs } from "./jobposting-site.ts";
4
4
  import { classifyJob, isEligibleForCountry, normalizeLocation } from "./locations.ts";
5
- import { statedExperience } from "./experience.ts";
5
+ import { experienceMatches, normalizeJobExperience } from "./experience.ts";
6
6
 
7
7
  type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
8
8
 
@@ -102,7 +102,7 @@ export function createCatalog(options: CatalogOptions): Catalog {
102
102
  let job = (await fetchJobs(company)).find((candidate) => candidate.id === id) ?? null;
103
103
  if (job && !job.description.trim()) job = { ...job, description: await fetchJobDescription(company, job, fetcher) };
104
104
  if (job && !job.description.trim()) throw new Error(`Full description unavailable for job: ${id}`);
105
- return job;
105
+ return job ? normalizeJobExperience(job) : null;
106
106
  },
107
107
  };
108
108
  }
@@ -119,10 +119,10 @@ export function withinWindow(job: Pick<Job, "updatedAt">, days: number, now: num
119
119
 
120
120
  /** Keyword search newest first. Without maxAgeDays it walks 7, 14, 30, then everything until CASCADE_MINIMUM results appear; the window used rides on the result. */
121
121
  export function searchJobs(jobs: Job[], query: SearchQuery, now: number = Date.now()): SearchResult {
122
- const matched = jobs.filter((job) => matches(job, query)).filter((job) => {
122
+ const matched = jobs.filter((job) => matches(job, query)).map(normalizeJobExperience).filter((job) => {
123
123
  if (query.experienceYears === undefined) return true;
124
- const experience = job.experience === undefined ? statedExperience(job.description) : job.experience;
125
- return experience ? query.experienceYears >= experience.min && (experience.max === undefined || query.experienceYears <= experience.max) : query.includeUnknownExperience === true;
124
+ const experience = job.experience;
125
+ return experience ? experienceMatches(experience, query.experienceYears) : query.includeUnknownExperience === true;
126
126
  });
127
127
  const run = (days: number) => matched.filter((job) => withinWindow(job, days, now)).sort((left, right) => postedTime(right) - postedTime(left) || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
128
128
  const steps: SearchWindow["steps"] = [];
@@ -133,7 +133,7 @@ export function searchJobs(jobs: Job[], query: SearchQuery, now: number = Date.n
133
133
  if (found.length >= CASCADE_MINIMUM) break;
134
134
  }
135
135
  const offset = query.offset ?? 0, limit = query.limit ?? 50;
136
- const result = found.slice(offset, offset + limit).map((job) => toSummary(query.experienceYears !== undefined && job.experience === undefined ? { ...job, experience: statedExperience(job.description) } : job, now)) as SearchResult;
136
+ const result = found.slice(offset, offset + limit).map((job) => toSummary(job, now)) as SearchResult;
137
137
  Object.defineProperty(result, "pagination", { value: { offset, limit, total: found.length, nextOffset: offset + limit < found.length ? offset + limit : null }, enumerable: false });
138
138
  Object.defineProperty(result, "window", { value: { daysUsed: steps[steps.length - 1]!.days, widened: steps.length > 1, steps } satisfies SearchWindow, enumerable: false });
139
139
  return result;
package/src/crawler.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FetchJobsObserver } from "./catalog.ts";
2
- import { statedExperience } from "./experience.ts";
2
+ import { EXPERIENCE_VERSION, normalizeJobExperience } from "./experience.ts";
3
3
  import { partitionFor, type Company, type CrawlFailure, type CrawlReport, type CrawlSourceResult, type Job, type JobPartition, type JobSnapshot } from "./types.ts";
4
4
 
5
5
  export interface SnapshotStore {
@@ -49,23 +49,22 @@ export function createCrawler(options: CrawlerOptions): Crawler {
49
49
 
50
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
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]));
52
+ const known = new Map((previous ?? []).map(normalizeJobExperience).filter((job) => job.experienceVersion === EXPERIENCE_VERSION && job.experience !== undefined).map((job) => [job.id, job]));
53
53
  const since = now().getTime() - 30 * 86_400_000;
54
54
  const deadline = Date.now() + DESCRIBE_BUDGET_MS;
55
55
  let budget = DESCRIBE_LIMIT;
56
56
  const out: Job[] = [];
57
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) });
58
+ if (job.description.trim() || job.experienceVersion === EXPERIENCE_VERSION) out.push(normalizeJobExperience(job));
59
+ else if (known.has(job.id)) out.push({ ...job, description: known.get(job.id)!.description, experience: known.get(job.id)!.experience, experienceVersion: EXPERIENCE_VERSION });
61
60
  else if (options.describe && budget > 0 && Date.now() < deadline && job.eligibleCountries.some((code) => describeCountries.has(code)) && Date.parse(job.updatedAt ?? "") >= since) {
62
61
  budget -= 1;
63
62
  try {
64
63
  if (options.workdayPageDelayMs) await pacingSleep(options.workdayPageDelayMs);
65
64
  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);
65
+ out.push(normalizeJobExperience({ ...job, description }));
66
+ } catch { out.push(normalizeJobExperience(job)); } // unread; the next crawl tries again
67
+ } else out.push(normalizeJobExperience(job));
69
68
  }
70
69
  return out;
71
70
  }
package/src/experience.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { decodeEntities } from "./providers.ts";
1
+ import { plainText } from "./providers.ts";
2
2
 
3
3
  /** Years of experience a posting asks for, read from its own text. */
4
- export interface Experience { min: number; max?: number }
4
+ export interface Experience { min: number; max?: number; maxOpen?: boolean }
5
+ export const EXPERIENCE_VERSION = 2;
5
6
 
6
7
  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 };
7
8
  const NUM = String.raw`(\d{1,2}(?:\.\d)?|${Object.keys(WORDS).join("|")})`;
@@ -11,6 +12,7 @@ const YEARS_THEN_EXPERIENCE = new RegExp(String.raw`${RANGE}(?:\s+of)?(?:\s+[\w/
11
12
  const EXPERIENCE_THEN_YEARS = new RegExp(String.raw`\b(?:experiences?|exp)\b[^.\n\d]{0,30}?${RANGE}`, "gi");
12
13
  // "minimum 15+ years" with no word "experience" after it.
13
14
  const MINIMUM_YEARS = new RegExp(String.raw`\b(?:minimum|min\.?|at least|atleast)\s*(?:of\s*)?${RANGE}`, "gi");
15
+ const STANDALONE_YEARS = new RegExp(String.raw`^[ \t]*(?:[•·-][ \t]*)?${RANGE}[ \t]*[.!]?[ \t]*$`, "gmi");
14
16
  // Company history reads the same way ("our 135 years of experience"); skip matches that talk about the employer.
15
17
  const ABOUT_EMPLOYER = /\b(?:our (?:company|firm|group|organi[sz]ation|brands?|history|legacy)|(?:company|firm|group) (?:has|with)|founded|established|history|legacy|heritage)\b[^.\n?!:;•·-]*$/i;
16
18
  const MIN_PREFIX = /\b(?:minimum|min\.?|at least|atleast)\s*(?:of\s*)?$/i;
@@ -20,11 +22,15 @@ function value(text: string | undefined): number | undefined {
20
22
  return WORDS[text.toLowerCase()] ?? Number(text);
21
23
  }
22
24
 
23
- /** The first experience requirement the text states, or null when it states none. */
25
+ /** Prefer overall/total experience and standalone role ranges to skill-specific requirements. */
24
26
  export function statedExperience(description: string): Experience | null {
25
- const text = decodeEntities(description);
26
- const found: Array<{ index: number; experience: Experience }> = [];
27
- for (const pattern of [YEARS_THEN_EXPERIENCE, EXPERIENCE_THEN_YEARS, MINIMUM_YEARS]) {
27
+ return readExperience(description)?.experience ?? null;
28
+ }
29
+
30
+ export function readExperience(description: string): { index: number; quote: string; priority: number; experience: Experience } | null {
31
+ const text = plainText(description);
32
+ const found: Array<{ index: number; quote: string; priority: number; experience: Experience }> = [];
33
+ for (const pattern of [YEARS_THEN_EXPERIENCE, EXPERIENCE_THEN_YEARS, MINIMUM_YEARS, STANDALONE_YEARS]) {
28
34
  for (const match of text.matchAll(pattern)) {
29
35
  const [low, high] = [value(match[1]), value(match[2])];
30
36
  if (low === undefined || !Number.isFinite(low)) continue;
@@ -32,11 +38,15 @@ export function statedExperience(description: string): Experience | null {
32
38
  if (low > 20 || (high !== undefined && (high < low || high > 40))) continue;
33
39
  const before = text.slice(Math.max(0, match.index - 60), match.index);
34
40
  if (pattern !== MINIMUM_YEARS && ABOUT_EMPLOYER.test(before) && !MIN_PREFIX.test(before)) continue;
35
- found.push({ index: match.index, experience: high !== undefined && high !== low ? { min: low, max: high } : { min: low } });
41
+ const lineStart = Math.max(text.lastIndexOf("\n", match.index - 1), text.lastIndexOf(".", match.index - 1)) + 1;
42
+ const context = text.slice(lineStart, match.index) + match[0];
43
+ const priority = /\b(?:overall|total)\b/i.test(context) ? 3 : pattern === STANDALONE_YEARS ? 2 : 1;
44
+ const maxOpen = high !== undefined && /(?:\+\s*(?:years?|yrs?)|(?:years?|yrs?)\s*\+)/i.test(match[0]);
45
+ found.push({ index: match.index, quote: match[0], priority, experience: high !== undefined && high !== low ? { min: low, max: high, ...(maxOpen ? { maxOpen: true } : {}) } : { min: low } });
36
46
  }
37
47
  }
38
- found.sort((left, right) => left.index - right.index);
39
- return found[0]?.experience ?? null;
48
+ found.sort((left, right) => right.priority - left.priority || right.experience.min - left.experience.min || left.index - right.index);
49
+ return found[0] ?? null;
40
50
  }
41
51
 
42
52
  /** Entry-level titles (intern, trainee, fresher, graduate) as a rough 0–1 year estimate when the posting states nothing. */
@@ -46,5 +56,17 @@ export function titleExperience(title: string): Experience | undefined {
46
56
 
47
57
  /** "2–5 yrs", "5+ yrs". */
48
58
  export function experienceLabel(experience: Experience): string {
49
- return experience.max === undefined ? `${experience.min}+ yrs` : `${experience.min}–${experience.max} yrs`;
59
+ return experience.max === undefined ? `${experience.min}+ yrs` : `${experience.min}–${experience.max}${experience.maxOpen ? "+" : ""} yrs`;
60
+ }
61
+
62
+ export function experienceMatches(experience: Experience, years: number): boolean {
63
+ return years >= experience.min && (experience.max === undefined || experience.maxOpen === true || years <= experience.max);
64
+ }
65
+
66
+ /** Old cached readings without their source text cannot be safely corrected: make them unknown. */
67
+ export function normalizeJobExperience<T extends { description: string; experience?: Experience | null; experienceVersion?: number }>(job: T): Omit<T, "experience" | "experienceVersion"> & { experience?: Experience | null; experienceVersion?: number } {
68
+ if (job.description.trim()) return { ...job, experience: statedExperience(job.description), experienceVersion: EXPERIENCE_VERSION };
69
+ if (job.experienceVersion === EXPERIENCE_VERSION) return job;
70
+ const { experience: _experience, experienceVersion: _version, ...rest } = job;
71
+ return rest;
50
72
  }
package/src/index.ts CHANGED
@@ -17,7 +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
+ export { EXPERIENCE_VERSION, experienceLabel, experienceMatches, normalizeJobExperience, statedExperience, titleExperience, type Experience } from "./experience.ts";
21
21
  export type { Catalog } from "./catalog.ts";
22
22
  export type { Ats, Company, CrawlReport, Job, JobPartition, JobSnapshot, JobSummary, SearchQuery } from "./types.ts";
23
23
  export { createCrawlReporter, fetchSeedSnapshot, resolveAggregatorUrl } from "./crawl-reporting.ts";
@@ -1,4 +1,4 @@
1
- import { experienceLabel } from "./experience.ts";
1
+ import { experienceLabel, normalizeJobExperience } from "./experience.ts";
2
2
  import { validateCandidateProfileEvidence, type CandidateProfile } from "./candidate-profile.ts";
3
3
  import { isEligibleForCountry, normalizeLocation } from "./locations.ts";
4
4
  import { detectRequirementTerms, findTransferability, matchesExactSkillEvidence, requiresExactSkillEvidence, type TransferabilityKind } from "./requirement-vocabulary.ts";
@@ -71,7 +71,8 @@ export function matchJobs(profile: CandidateProfile, intent: CandidateIntent, jo
71
71
  const roleFamilies = deriveRoleFamilies(profile, intent);
72
72
  const filteredOut: FilteredJob[] = [];
73
73
  const ranked: Array<JobMatch & { score: number; index: number }> = [];
74
- for (const [index, job] of jobs.entries()) {
74
+ for (const [index, rawJob] of jobs.entries()) {
75
+ const job = normalizeJobExperience(rawJob);
75
76
  const rejectionReasons = hardFilterReasons(job, intent);
76
77
  if (rejectionReasons.length) {
77
78
  filteredOut.push({ jobId: job.id, reasons: rejectionReasons });
@@ -263,7 +264,7 @@ function seniorityAlignment(profile: CandidateProfile, intent: CandidateIntent,
263
264
  if (job.experience && typeof resumeYears === "number" && !intent.seniority?.length) {
264
265
  // 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
266
  const asked = experienceLabel(job.experience);
266
- const fits = resumeYears >= job.experience.min - 1 && (job.experience.max === undefined || resumeYears <= job.experience.max + 3);
267
+ const fits = resumeYears >= job.experience.min - 1 && (job.experience.max === undefined || job.experience.maxOpen === true || resumeYears <= job.experience.max + 3);
267
268
  return { score: fits ? 3 : -3, reason: `posting asks for ${asked}; resume shows about ${resumeYears} years` };
268
269
  }
269
270
  const jobSeniority = seniorityTerms.find((term) => includesPhrase(job.title, term));
package/src/runtime.ts CHANGED
@@ -5,6 +5,7 @@ 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";
8
+ import { normalizeJobExperience } from "./experience.ts";
8
9
  import { catalog as liveCatalog, companies } from "./index.ts";
9
10
  import { createLocalJobs } from "./local-jobs.ts";
10
11
  import { createJobRecommender } from "./job-recommendations.ts";
@@ -82,7 +83,7 @@ export function createHostedRuntime(options: { sources: Company[]; store: Snapsh
82
83
  if (!company) return job;
83
84
  try {
84
85
  const description = await fetchJobDescription(company, job, globalThis.fetch);
85
- return description.trim() ? { ...job, description } : job;
86
+ return description.trim() ? normalizeJobExperience({ ...job, description }) : job;
86
87
  } catch { return job; } // the employer's board is unreachable; the summary is still useful
87
88
  }
88
89
  const snapshotJob = async (id: string) => (await local.get(id, { offline: true, staleDays: 3650 })).job;
@@ -1,5 +1,7 @@
1
1
  import type { CandidateProfile } from "./candidate-profile.ts";
2
2
  import type { Job } from "./types.ts";
3
+ import { readExperience } from "./experience.ts";
4
+ import { plainText } from "./providers.ts";
3
5
 
4
6
  export interface ScreeningRequirement {
5
7
  kind: "experience" | "education";
@@ -14,6 +16,12 @@ export function evaluateScreeningRequirements(profile: CandidateProfile, job: Jo
14
16
  ...experienceRequirements(profile, description),
15
17
  ...educationRequirements(profile, description),
16
18
  ];
19
+ const text = plainText(job.description);
20
+ const reading = readExperience(text);
21
+ if (reading && (isMandatory(text, reading.index, reading.quote) || (reading.priority === 2 && lastSectionKind(text.slice(0, reading.index)) !== "optional")) && !requirements.some((item) => item.kind === "experience" && Number(item.requirement.match(/\d+/)?.[0]) === reading.experience.min)) {
22
+ const inference = profile.inferences.find((value) => value.kind === "approximate_experience_years");
23
+ requirements.push({ kind: "experience", requirement: clean(reading.quote), status: !inference ? "unsupported" : inference.value >= reading.experience.min ? "supported" : "partial", factIds: inference?.derivedFromFactIds ?? [] });
24
+ }
17
25
  return [...new Map(requirements.map((item) => [`${item.kind}:${item.requirement.toLocaleLowerCase()}`, item])).values()];
18
26
  }
19
27
 
@@ -1,4 +1,5 @@
1
1
  import type { Job } from "./types.ts";
2
+ import { normalizeJobExperience } from "./experience.ts";
2
3
 
3
4
  export interface SelectedJobLookupOptions {
4
5
  getSnapshotJob(id: string): Promise<Job | null>;
@@ -8,7 +9,8 @@ export interface SelectedJobLookupOptions {
8
9
  export function createSelectedJobLookup(options: SelectedJobLookupOptions) {
9
10
  return async (id: string): Promise<Job | null> => {
10
11
  const snapshot = await options.getSnapshotJob(id);
11
- if (snapshot?.description.trim()) return snapshot;
12
- return options.getDetailedJob(id);
12
+ if (snapshot?.description.trim()) return normalizeJobExperience(snapshot);
13
+ const detailed = await options.getDetailedJob(id);
14
+ return detailed ? normalizeJobExperience(detailed) : null;
13
15
  };
14
16
  }
@@ -1,5 +1,6 @@
1
1
  import { searchJobs, type Catalog } from "./catalog.ts";
2
2
  import type { SnapshotStore } from "./crawler.ts";
3
+ import { normalizeJobExperience } from "./experience.ts";
3
4
 
4
5
  export function createSnapshotCatalog(store: SnapshotStore): Catalog {
5
6
  return {
@@ -13,7 +14,7 @@ export function createSnapshotCatalog(store: SnapshotStore): Catalog {
13
14
  if (!snapshot) return null;
14
15
  for (const partition of Object.values(snapshot.partitions)) {
15
16
  const job = partition.jobs.find((candidate) => candidate.id === id);
16
- if (job) return job;
17
+ if (job) return normalizeJobExperience(job);
17
18
  }
18
19
  return null;
19
20
  },
package/src/types.ts CHANGED
@@ -87,6 +87,7 @@ export interface JobSummary {
87
87
  updatedAt?: string;
88
88
  /** Years of experience the posting states, read from its description. null: the description states none. Absent: not read yet. */
89
89
  experience?: Experience | null;
90
+ experienceVersion?: number;
90
91
  /** Days since the posting date at the time of the search; absent when the board gave no date. */
91
92
  postedDaysAgo?: number;
92
93
  /** 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.36";
1
+ export const VERSION = "0.1.37";