openings 0.1.32 → 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 +2 -5
- package/src/crawl-reporting.ts +2 -1
- package/src/crawler.ts +4 -1
- package/src/experience.ts +12 -7
- package/src/jobposting-site.ts +2 -2
- package/src/providers.ts +11 -1
- 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
|
|
|
@@ -518,10 +518,7 @@ function stripHtml(value: string): string {
|
|
|
518
518
|
.replace(/<br\s*\/?\s*>/gi, "\n")
|
|
519
519
|
.replace(/<\/p>/gi, "\n\n")
|
|
520
520
|
.replace(/<[^>]+>/g, "")
|
|
521
|
-
.replace(/&
|
|
522
|
-
.replace(/&/g, "&")
|
|
523
|
-
.replace(/</g, "<")
|
|
524
|
-
.replace(/>/g, ">")
|
|
521
|
+
.replace(/&[#a-z0-9]+;/gi, (entity) => decodeEntities(entity))
|
|
525
522
|
.replace(/\n{3,}/g, "\n\n")
|
|
526
523
|
.trim();
|
|
527
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
|
@@ -159,7 +159,10 @@ export function createCrawler(options: CrawlerOptions): Crawler {
|
|
|
159
159
|
await options.store.write({ version: 1, updatedAt: finishedAt, partitions, lastCrawl: report });
|
|
160
160
|
if (options.onCrawled) {
|
|
161
161
|
const crawled = selectedSources.filter((source) => metrics.get(source.slug)!.status === "succeeded");
|
|
162
|
-
|
|
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));
|
|
163
166
|
}
|
|
164
167
|
return report;
|
|
165
168
|
},
|
package/src/experience.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import { decodeEntities } from "./providers.ts";
|
|
2
|
+
|
|
1
3
|
/** Years of experience a posting asks for, read from its own text. */
|
|
2
4
|
export interface Experience { min: number; max?: number }
|
|
3
5
|
|
|
4
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 };
|
|
5
7
|
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?)(?:'
|
|
8
|
+
const RANGE = String.raw`(?<![\d.])${NUM}\s*\+?\s*(?:(?:-|–|—|to)\s*${NUM}\s*)?\+?\s*(?:years?|yrs?)\+?(?:['’]s?)?`;
|
|
7
9
|
// "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,
|
|
9
|
-
const EXPERIENCE_THEN_YEARS = new RegExp(String.raw`\b(?:
|
|
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");
|
|
10
14
|
// Company history reads the same way ("our 135 years of experience"); skip matches that talk about the employer.
|
|
11
|
-
const ABOUT_EMPLOYER = /\b(?:
|
|
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;
|
|
12
16
|
const MIN_PREFIX = /\b(?:minimum|min\.?|at least|atleast)\s*(?:of\s*)?$/i;
|
|
13
17
|
|
|
14
18
|
function value(text: string | undefined): number | undefined {
|
|
@@ -17,16 +21,17 @@ function value(text: string | undefined): number | undefined {
|
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/** The first experience requirement the text states, or null when it states none. */
|
|
20
|
-
export function statedExperience(
|
|
24
|
+
export function statedExperience(description: string): Experience | null {
|
|
25
|
+
const text = decodeEntities(description);
|
|
21
26
|
const found: Array<{ index: number; experience: Experience }> = [];
|
|
22
|
-
for (const pattern of [YEARS_THEN_EXPERIENCE, EXPERIENCE_THEN_YEARS]) {
|
|
27
|
+
for (const pattern of [YEARS_THEN_EXPERIENCE, EXPERIENCE_THEN_YEARS, MINIMUM_YEARS]) {
|
|
23
28
|
for (const match of text.matchAll(pattern)) {
|
|
24
29
|
const [low, high] = [value(match[1]), value(match[2])];
|
|
25
30
|
if (low === undefined || !Number.isFinite(low)) continue;
|
|
26
31
|
// Requirements rarely pass 20 years; bigger numbers are nearly always an employer's history.
|
|
27
32
|
if (low > 20 || (high !== undefined && (high < low || high > 40))) continue;
|
|
28
33
|
const before = text.slice(Math.max(0, match.index - 60), match.index);
|
|
29
|
-
if (ABOUT_EMPLOYER.test(before) && !MIN_PREFIX.test(before)) continue;
|
|
34
|
+
if (pattern !== MINIMUM_YEARS && ABOUT_EMPLOYER.test(before) && !MIN_PREFIX.test(before)) continue;
|
|
30
35
|
found.push({ index: match.index, experience: high !== undefined && high !== low ? { min: low, max: high } : { min: low } });
|
|
31
36
|
}
|
|
32
37
|
}
|
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/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/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.33";
|