openings 0.1.31 → 0.1.33
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 +10 -11
- package/src/crawl-reporting.ts +2 -1
- package/src/crawler.ts +39 -2
- package/src/experience.ts +50 -0
- package/src/index.ts +1 -0
- package/src/job-matching.ts +8 -0
- package/src/jobposting-site.ts +2 -2
- package/src/local-jobs.ts +2 -0
- package/src/mcp.ts +1 -0
- package/src/providers.ts +11 -1
- 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.33",
|
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CASCADE_MINIMUM, CASCADE_WINDOWS, type Company, type Job, type JobAge, type JobSummary, type SearchQuery, type SearchWindow } from "./types.ts";
|
|
2
|
-
import { providerSpec, type JsonGet } from "./providers.ts";
|
|
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
5
|
|
|
@@ -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)}`), "");
|
|
@@ -516,10 +518,7 @@ function stripHtml(value: string): string {
|
|
|
516
518
|
.replace(/<br\s*\/?\s*>/gi, "\n")
|
|
517
519
|
.replace(/<\/p>/gi, "\n\n")
|
|
518
520
|
.replace(/<[^>]+>/g, "")
|
|
519
|
-
.replace(/&
|
|
520
|
-
.replace(/&/g, "&")
|
|
521
|
-
.replace(/</g, "<")
|
|
522
|
-
.replace(/>/g, ">")
|
|
521
|
+
.replace(/&[#a-z0-9]+;/gi, (entity) => decodeEntities(entity))
|
|
523
522
|
.replace(/\n{3,}/g, "\n\n")
|
|
524
523
|
.trim();
|
|
525
524
|
}
|
package/src/crawl-reporting.ts
CHANGED
|
@@ -36,7 +36,8 @@ export function createCrawlReporter(options: { url: string; fetcher?: Fetch; tim
|
|
|
36
36
|
method: "POST",
|
|
37
37
|
headers: { "content-type": "application/json", "content-encoding": "gzip" },
|
|
38
38
|
body: Bun.gzipSync(JSON.stringify(payload)),
|
|
39
|
-
|
|
39
|
+
// Big employers send tens of megabytes and the aggregator ingests one report at a time; give them room.
|
|
40
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 120_000),
|
|
40
41
|
});
|
|
41
42
|
if (!response.ok) throw new Error(`Aggregator rejected crawl report: HTTP ${response.status}`);
|
|
42
43
|
};
|
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";
|
|
@@ -129,13 +159,20 @@ export function createCrawler(options: CrawlerOptions): Crawler {
|
|
|
129
159
|
await options.store.write({ version: 1, updatedAt: finishedAt, partitions, lastCrawl: report });
|
|
130
160
|
if (options.onCrawled) {
|
|
131
161
|
const crawled = selectedSources.filter((source) => metrics.get(source.slug)!.status === "succeeded");
|
|
132
|
-
|
|
162
|
+
// Four at a time: sending a thousand reports at once made the largest ones time out while the aggregator queued them.
|
|
163
|
+
let next = 0;
|
|
164
|
+
const send = async () => { while (next < crawled.length) { const source = crawled[next++]!; await Promise.resolve().then(() => options.onCrawled!(source, partitions[source.slug]!)).catch(() => undefined); } };
|
|
165
|
+
await Promise.all(Array.from({ length: Math.min(4, crawled.length) }, send));
|
|
133
166
|
}
|
|
134
167
|
return report;
|
|
135
168
|
},
|
|
136
169
|
};
|
|
137
170
|
}
|
|
138
171
|
|
|
172
|
+
// ponytail: per-source caps keep one crawl bounded; a big backlog (first run) drains over a few nights.
|
|
173
|
+
const DESCRIBE_LIMIT = 150;
|
|
174
|
+
const DESCRIBE_BUDGET_MS = 180_000;
|
|
175
|
+
|
|
139
176
|
function partitionTime(value: string | undefined): number {
|
|
140
177
|
const timestamp = Date.parse(value ?? "");
|
|
141
178
|
return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { decodeEntities } from "./providers.ts";
|
|
2
|
+
|
|
3
|
+
/** Years of experience a posting asks for, read from its own text. */
|
|
4
|
+
export interface Experience { min: number; max?: number }
|
|
5
|
+
|
|
6
|
+
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
|
+
const NUM = String.raw`(\d{1,2}(?:\.\d)?|${Object.keys(WORDS).join("|")})`;
|
|
8
|
+
const RANGE = String.raw`(?<![\d.])${NUM}\s*\+?\s*(?:(?:-|–|—|to)\s*${NUM}\s*)?\+?\s*(?:years?|yrs?)\+?(?:['’]s?)?`;
|
|
9
|
+
// "5+ years of hands-on Java experience" or "Experience: 2-5 years" / "Years of experience 6 to 8 years".
|
|
10
|
+
const YEARS_THEN_EXPERIENCE = new RegExp(String.raw`${RANGE}(?:\s+of)?(?:\s+[\w/&,'’()-]+){0,10}?\s+(?:experiences?|exp)\b`, "gi");
|
|
11
|
+
const EXPERIENCE_THEN_YEARS = new RegExp(String.raw`\b(?:experiences?|exp)\b[^.\n\d]{0,30}?${RANGE}`, "gi");
|
|
12
|
+
// "minimum 15+ years" with no word "experience" after it.
|
|
13
|
+
const MINIMUM_YEARS = new RegExp(String.raw`\b(?:minimum|min\.?|at least|atleast)\s*(?:of\s*)?${RANGE}`, "gi");
|
|
14
|
+
// Company history reads the same way ("our 135 years of experience"); skip matches that talk about the employer.
|
|
15
|
+
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
|
+
const MIN_PREFIX = /\b(?:minimum|min\.?|at least|atleast)\s*(?:of\s*)?$/i;
|
|
17
|
+
|
|
18
|
+
function value(text: string | undefined): number | undefined {
|
|
19
|
+
if (!text) return undefined;
|
|
20
|
+
return WORDS[text.toLowerCase()] ?? Number(text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The first experience requirement the text states, or null when it states none. */
|
|
24
|
+
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]) {
|
|
28
|
+
for (const match of text.matchAll(pattern)) {
|
|
29
|
+
const [low, high] = [value(match[1]), value(match[2])];
|
|
30
|
+
if (low === undefined || !Number.isFinite(low)) continue;
|
|
31
|
+
// Requirements rarely pass 20 years; bigger numbers are nearly always an employer's history.
|
|
32
|
+
if (low > 20 || (high !== undefined && (high < low || high > 40))) continue;
|
|
33
|
+
const before = text.slice(Math.max(0, match.index - 60), match.index);
|
|
34
|
+
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 } });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
found.sort((left, right) => left.index - right.index);
|
|
39
|
+
return found[0]?.experience ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Entry-level titles (intern, trainee, fresher, graduate) as a rough 0–1 year estimate when the posting states nothing. */
|
|
43
|
+
export function titleExperience(title: string): Experience | undefined {
|
|
44
|
+
return /\b(?:intern|internship|trainee|freshers?|graduate|apprentice(?:ship)?|entry[- ]level)\b/i.test(title) ? { min: 0, max: 1 } : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** "2–5 yrs", "5+ yrs". */
|
|
48
|
+
export function experienceLabel(experience: Experience): string {
|
|
49
|
+
return experience.max === undefined ? `${experience.min}+ yrs` : `${experience.min}–${experience.max} yrs`;
|
|
50
|
+
}
|
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/jobposting-site.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { classifyJob } from "./locations.ts";
|
|
2
|
-
import { countryLabel } from "./providers.ts";
|
|
2
|
+
import { countryLabel, decodeEntities } from "./providers.ts";
|
|
3
3
|
import { extractLinks, fetchSafePage, robotsAllows, type PageTransport, type ResolveHost } from "./safe-head.ts";
|
|
4
4
|
import type { Job } from "./types.ts";
|
|
5
5
|
|
|
@@ -158,5 +158,5 @@ export function sitePostingsToJobs(company: { slug: string; name: string }, post
|
|
|
158
158
|
function hash(value: string): string { let h = 2166136261; for (const ch of value) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0; } return h.toString(16); }
|
|
159
159
|
function text(value: unknown): string { return typeof value === "string" ? value.trim() : typeof value === "number" ? String(value) : ""; }
|
|
160
160
|
function isoDate(value: unknown): string | undefined { const time = Date.parse(text(value)); return Number.isFinite(time) ? new Date(time).toISOString() : undefined; }
|
|
161
|
-
function stripHtml(value: string): string { return value.replace(/<[^>]+>/g, " ")
|
|
161
|
+
function stripHtml(value: string): string { return decodeEntities(value.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); }
|
|
162
162
|
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
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/providers.ts
CHANGED
|
@@ -294,10 +294,20 @@ function validToken(value: string | undefined): string | null {
|
|
|
294
294
|
return value && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !RESERVED_TOKENS.has(value.toLowerCase()) ? value : null;
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
const NAMED_ENTITIES: Record<string, string> = { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", ndash: "–", mdash: "—", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", bull: "•", hellip: "…" };
|
|
298
|
+
/** Decode HTML entities in one pass (Workday sends "4+ years"), so "&#43;" stays "+" instead of becoming "+". */
|
|
299
|
+
export function decodeEntities(value: string): string {
|
|
300
|
+
return value.replace(/&(#\d{1,7}|#x[0-9a-f]{1,6}|[a-z]{2,8});/gi, (entity, code: string) => {
|
|
301
|
+
if (code[0] !== "#") return NAMED_ENTITIES[code.toLowerCase()] ?? entity;
|
|
302
|
+
const point = code[1] === "x" || code[1] === "X" ? parseInt(code.slice(2), 16) : Number(code.slice(1));
|
|
303
|
+
return point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : entity;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
297
307
|
export function plainText(value: string): string {
|
|
298
308
|
return value
|
|
299
309
|
.replace(/<br\s*\/?\s*>/gi, "\n").replace(/<\/(p|li|div|h[1-6])>/gi, "\n").replace(/<[^>]+>/g, "")
|
|
300
|
-
.replace(/&
|
|
310
|
+
.replace(/&[#a-z0-9]+;/gi, (entity) => decodeEntities(entity))
|
|
301
311
|
.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
302
312
|
}
|
|
303
313
|
|
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.33";
|