openings 0.1.29 → 0.1.30
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/data/companies.json +23 -0
- package/package.json +1 -1
- package/src/adzuna-market.ts +103 -0
- package/src/cli.ts +14 -0
- package/src/common-crawl-discovery.ts +1 -1
- package/src/portals.ts +33 -1
- package/src/types.ts +1 -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.30",
|
|
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/data/companies.json
CHANGED
|
@@ -108176,5 +108176,28 @@
|
|
|
108176
108176
|
"payloadVersion": "capgemini-jobstream:v1",
|
|
108177
108177
|
"jobCount": 922
|
|
108178
108178
|
}
|
|
108179
|
+
},
|
|
108180
|
+
"amazon-india": {
|
|
108181
|
+
"name": "Amazon",
|
|
108182
|
+
"ats": "amazon",
|
|
108183
|
+
"token": "IND",
|
|
108184
|
+
"companyDomain": "amazon.jobs",
|
|
108185
|
+
"sourceUrl": "https://www.amazon.jobs/en/search?normalized_country_code%5B%5D=IND",
|
|
108186
|
+
"cohorts": [
|
|
108187
|
+
"IN"
|
|
108188
|
+
],
|
|
108189
|
+
"discoveredFrom": {
|
|
108190
|
+
"channel": "career_page",
|
|
108191
|
+
"reference": "https://www.amazon.jobs/en/search?normalized_country_code%5B%5D=IND"
|
|
108192
|
+
},
|
|
108193
|
+
"verification": {
|
|
108194
|
+
"checkedAt": "2026-09-11T06:43:09.522600+00:00",
|
|
108195
|
+
"canonicalSourceUrl": "https://www.amazon.jobs/en/search?normalized_country_code%5B%5D=IND",
|
|
108196
|
+
"observedCompanyName": "Amazon",
|
|
108197
|
+
"identityEvidence": "company_site",
|
|
108198
|
+
"contentType": "application/json",
|
|
108199
|
+
"payloadVersion": "amazon-jobs-search:v1",
|
|
108200
|
+
"jobCount": 2368
|
|
108201
|
+
}
|
|
108179
108202
|
}
|
|
108180
108203
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { atomicJson } from "./atomic-file.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A rolling 30-day map of who is hiring in a country, built from Adzuna within its search allowance. Each run
|
|
6
|
+
* samples pages spread evenly across the last day's postings (newest first), so bursts from one employer do not
|
|
7
|
+
* crowd out the rest, and merges them into a state file keyed by Adzuna's posting id. Jobs are never ingested;
|
|
8
|
+
* the map only says which employers are hiring, how much, and whether our catalog covers them.
|
|
9
|
+
*/
|
|
10
|
+
export interface MarketEmployer { name: string; postings: number; firstSeen: string; lastSeen: string; cities: Record<string, number>; categories: Record<string, number> }
|
|
11
|
+
export interface MarketState { country: string; updatedAt: string; runs: number; hitsUsed: number; seen: Record<string, { employer: string; created: string }>; employers: Record<string, MarketEmployer> }
|
|
12
|
+
export interface MarketCoverage { employers: number; postings: number; coveredEmployers: number; coveredPostings: number; uncovered: Array<{ name: string; postings: number; cities: string[] }> }
|
|
13
|
+
type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
14
|
+
|
|
15
|
+
export async function sampleAdzunaMarket(appId: string, appKey: string, statePath: string, options: { country?: string; maxHits?: number; fetcher?: Fetch; sleep?: (ms: number) => Promise<void>; now?: () => Date; delayMs?: number } = {}): Promise<{ state: MarketState; hits: number; dayTotal: number; newPostings: number; newEmployers: number }> {
|
|
16
|
+
const fetcher = options.fetcher ?? globalThis.fetch;
|
|
17
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
18
|
+
const now = options.now ?? (() => new Date());
|
|
19
|
+
const country = (options.country ?? "in").toLowerCase();
|
|
20
|
+
const state: MarketState = await readFile(statePath, "utf8").then((text) => JSON.parse(text) as MarketState).catch(() => ({ country, updatedAt: "", runs: 0, hitsUsed: 0, seen: {}, employers: {} }));
|
|
21
|
+
const maxHits = Math.max(1, options.maxHits ?? 75);
|
|
22
|
+
const search = async (page: number) => {
|
|
23
|
+
const url = new URL(`https://api.adzuna.com/v1/api/jobs/${country}/search/${page}`);
|
|
24
|
+
for (const [key, value] of Object.entries({ app_id: appId, app_key: appKey, results_per_page: "50", "content-type": "application/json", max_days_old: "1", sort_by: "date" })) url.searchParams.set(key, value);
|
|
25
|
+
const response = await fetcher(url.href, { signal: AbortSignal.timeout(30_000) });
|
|
26
|
+
if (response.status === 429) throw new Error("Adzuna allowance reached");
|
|
27
|
+
if (!response.ok) throw new Error(`Adzuna HTTP ${response.status}`);
|
|
28
|
+
return await response.json() as { count?: number; results?: Array<Record<string, unknown>> };
|
|
29
|
+
};
|
|
30
|
+
let hits = 0; let newPostings = 0; let newEmployers = 0;
|
|
31
|
+
const absorb = (results: Array<Record<string, unknown>>) => {
|
|
32
|
+
for (const job of results) {
|
|
33
|
+
const id = String(job.id ?? ""); if (!id || state.seen[id]) continue;
|
|
34
|
+
const name = isRecord(job.company) ? String(job.company.display_name ?? "").trim() : "";
|
|
35
|
+
if (name.length < 2) continue;
|
|
36
|
+
const key = name.toLowerCase();
|
|
37
|
+
const created = typeof job.created === "string" ? job.created : now().toISOString();
|
|
38
|
+
state.seen[id] = { employer: key, created }; newPostings += 1;
|
|
39
|
+
const employer = state.employers[key] ?? (newEmployers += 1, { name, postings: 0, firstSeen: created, lastSeen: created, cities: {}, categories: {} });
|
|
40
|
+
employer.postings += 1;
|
|
41
|
+
if (created > employer.lastSeen) employer.lastSeen = created;
|
|
42
|
+
const area = isRecord(job.location) && Array.isArray(job.location.area) ? String(job.location.area[job.location.area.length - 1] ?? "") : "";
|
|
43
|
+
if (area) employer.cities[area] = (employer.cities[area] ?? 0) + 1;
|
|
44
|
+
const category = isRecord(job.category) ? String(job.category.label ?? "") : "";
|
|
45
|
+
if (category) employer.categories[category] = (employer.categories[category] ?? 0) + 1;
|
|
46
|
+
state.employers[key] = employer;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const first = await search(1); hits += 1; absorb(first.results ?? []);
|
|
50
|
+
const dayTotal = first.count ?? 0;
|
|
51
|
+
const pages = Math.max(1, Math.ceil(dayTotal / 50));
|
|
52
|
+
// Spread the remaining allowance evenly across the day's pages.
|
|
53
|
+
const picks = new Set<number>();
|
|
54
|
+
for (let index = 1; index < Math.min(maxHits, pages); index += 1) picks.add(1 + Math.round((index * (pages - 1)) / Math.max(1, Math.min(maxHits, pages) - 1)));
|
|
55
|
+
picks.delete(1);
|
|
56
|
+
for (const page of [...picks].sort((left, right) => left - right)) {
|
|
57
|
+
if (hits >= maxHits) break;
|
|
58
|
+
await sleep(options.delayMs ?? 2_600); // Adzuna allows 25 searches a minute
|
|
59
|
+
try { absorb((await search(page)).results ?? []); hits += 1; }
|
|
60
|
+
catch (error) { hits += 1; if (String(error).includes("allowance")) break; }
|
|
61
|
+
}
|
|
62
|
+
// Keep a rolling 30 days: postings older than that drop out of both the dedupe set and the employer counts.
|
|
63
|
+
const cutoff = new Date(now().getTime() - 30 * 86_400_000).toISOString();
|
|
64
|
+
for (const [id, entry] of Object.entries(state.seen)) {
|
|
65
|
+
if (entry.created >= cutoff) continue;
|
|
66
|
+
delete state.seen[id];
|
|
67
|
+
const employer = state.employers[entry.employer];
|
|
68
|
+
if (employer && --employer.postings <= 0) delete state.employers[entry.employer];
|
|
69
|
+
}
|
|
70
|
+
state.updatedAt = now().toISOString(); state.runs += 1; state.hitsUsed += hits;
|
|
71
|
+
await atomicJson(statePath, state);
|
|
72
|
+
return { state, hits, dayTotal, newPostings, newEmployers };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const STOP = new Set(["the", "inc", "llc", "ltd", "limited", "pvt", "private", "corp", "corporation", "company", "co", "group", "india", "technologies", "technology", "solutions", "services", "global", "international", "plc", "llp", "and", "of", "com"]);
|
|
76
|
+
function keysFor(name: string): string[] {
|
|
77
|
+
const words = name.toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9 ]+/g, " ").split(/\s+/).filter(Boolean);
|
|
78
|
+
const core = words.filter((word) => !STOP.has(word));
|
|
79
|
+
const base = core.length ? core : words;
|
|
80
|
+
const full = base.join("");
|
|
81
|
+
// A short whole name (JLL, MSD, ABB) is still a name; only a lone first word needs four letters to avoid false matches.
|
|
82
|
+
return [...new Set([full, words.join(""), ...(ALIASES[full] ?? []), ...(base[0] && base[0].length >= 4 ? [base[0]] : [])].filter((key) => key.length >= 2))];
|
|
83
|
+
}
|
|
84
|
+
/** Employers our catalog knows under a provider tenant that looks nothing like the name Adzuna shows. */
|
|
85
|
+
const ALIASES: Record<string, string[]> = { pricewaterhousecoopers: ["pwc"], deutschebank: ["db"], standardchartered: ["standard"], spglobal: ["spgi"], jonesianglasalle: ["jll"], merck: ["msd"], johnsonandjohnson: ["jj"], wellsfargo: ["wf"], northropgrumman: ["ngc"] };
|
|
86
|
+
|
|
87
|
+
/** Which of the market's employers the catalog covers, matched on slugs, names, provider tenants, and company domains. */
|
|
88
|
+
export function marketCoverage(state: MarketState, catalog: Record<string, { name: string; token: string; companyDomain?: string }>, limit = 50): MarketCoverage {
|
|
89
|
+
const known = new Set<string>();
|
|
90
|
+
const squash = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
91
|
+
for (const [slug, entry] of Object.entries(catalog)) for (const key of [slug, entry.name, entry.token.split("/")[0]!.split(".")[0]!, (entry.companyDomain ?? "").split(".")[0]!]) { const value = squash(key); if (value.length >= 3) known.add(value); }
|
|
92
|
+
const employers = Object.values(state.employers);
|
|
93
|
+
const covered = employers.filter((employer) => keysFor(employer.name).some((key) => known.has(key)));
|
|
94
|
+
const coveredSet = new Set(covered);
|
|
95
|
+
return {
|
|
96
|
+
employers: employers.length, postings: employers.reduce((sum, employer) => sum + employer.postings, 0),
|
|
97
|
+
coveredEmployers: covered.length, coveredPostings: covered.reduce((sum, employer) => sum + employer.postings, 0),
|
|
98
|
+
uncovered: employers.filter((employer) => !coveredSet.has(employer)).sort((left, right) => right.postings - left.postings).slice(0, limit)
|
|
99
|
+
.map((employer) => ({ name: employer.name, postings: employer.postings, cities: Object.entries(employer.cities).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([city]) => city) })),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
package/src/cli.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { kekaTenantCandidates } from "./keka-tenants.ts";
|
|
|
23
23
|
import { collectJoobleSignals } from "./jooble-signals.ts";
|
|
24
24
|
import { collectAdzunaSignals } from "./adzuna-signals.ts";
|
|
25
25
|
import { resolveEmployers } from "./employer-resolver.ts";
|
|
26
|
+
import { marketCoverage, sampleAdzunaMarket } from "./adzuna-market.ts";
|
|
26
27
|
import { mergeAttemptedRoundLeads, prepareRecruiteeRoundArtifacts } from "./recruitee-round.ts";
|
|
27
28
|
|
|
28
29
|
const HELP = `Openings — search public company job boards
|
|
@@ -46,6 +47,7 @@ Usage:
|
|
|
46
47
|
openings sources keka-tenants HOSTS.txt [--output FILE] [--registry FILE] [--concurrency N]
|
|
47
48
|
openings sources jooble-signals [--location PLACE] [--max-pages N] [--output FILE] (key from JOOBLE_API_KEY)
|
|
48
49
|
openings sources adzuna-signals [--country in] [--max-hits N] [--max-days-old N] [--output FILE] (ADZUNA_APP_ID and ADZUNA_APP_KEY)
|
|
50
|
+
openings sources adzuna-market [--country in] [--max-hits 75] [--state FILE] [--catalog FILE] (daily sample of who is hiring; ADZUNA_APP_ID and ADZUNA_APP_KEY)
|
|
49
51
|
openings sources resolve-employers SIGNALS.json [--catalog FILE] [--registry FILE] [--min-jobs N] [--limit N] [--concurrency N] [--report FILE]
|
|
50
52
|
openings sources prepare-recruitee-round IDENTITIES.json [--catalog FILE] [--artifacts DIR]
|
|
51
53
|
openings sources merge-attempted-round-leads ISOLATED_REGISTRY [--registry FILE]
|
|
@@ -137,6 +139,18 @@ export async function run(args: string[]): Promise<number> {
|
|
|
137
139
|
console.log(JSON.stringify({ ...summary, topSites: sites.slice(0, 15) }, null, 2));
|
|
138
140
|
return 0;
|
|
139
141
|
}
|
|
142
|
+
if (rest[0] === "adzuna-market") {
|
|
143
|
+
const args = rest.slice(1);
|
|
144
|
+
const appId = process.env.ADZUNA_APP_ID; const appKey = process.env.ADZUNA_APP_KEY;
|
|
145
|
+
if (!appId || !appKey) return fail("sources adzuna-market requires ADZUNA_APP_ID and ADZUNA_APP_KEY in the environment");
|
|
146
|
+
const value = (flag: string) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; };
|
|
147
|
+
const statePath = value("--state") ?? ".openings/adzuna-market.json";
|
|
148
|
+
const run = await sampleAdzunaMarket(appId, appKey, statePath, { country: value("--country"), maxHits: value("--max-hits") ? Number(value("--max-hits")) : undefined });
|
|
149
|
+
const catalog = JSON.parse(await readFile(value("--catalog") ?? "data/companies.json", "utf8"));
|
|
150
|
+
const coverage = marketCoverage(run.state, catalog, 40);
|
|
151
|
+
console.log(JSON.stringify({ run: { hits: run.hits, postingsInLastDay: run.dayTotal, newPostings: run.newPostings, newEmployers: run.newEmployers, runs: run.state.runs, hitsUsed: run.state.hitsUsed }, market: { employers: coverage.employers, postings: coverage.postings, coveredEmployers: coverage.coveredEmployers, coveredPostings: coverage.coveredPostings, coveredShare: coverage.postings ? Math.round((100 * coverage.coveredPostings) / coverage.postings) : 0 }, uncovered: coverage.uncovered }, null, 2));
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
140
154
|
if (rest[0] === "resolve-employers") {
|
|
141
155
|
const args = rest.slice(1);
|
|
142
156
|
const signalsPath = args[0];
|
|
@@ -39,7 +39,7 @@ export interface CommonCrawlDiscoveryReport extends ReportMeta {
|
|
|
39
39
|
|
|
40
40
|
const providerPatterns: Record<Ats, string[]> = {
|
|
41
41
|
greenhouse: ["job-boards.greenhouse.io/*", "boards.greenhouse.io/*"], lever: ["jobs.lever.co/*"], ashby: ["jobs.ashbyhq.com/*"], workday: ["*.myworkdayjobs.com/*"], recruitee: ["*.recruitee.com/*"],
|
|
42
|
-
...Object.fromEntries(PROVIDERS.map((spec) => [spec.ats, spec.crawlPatterns])) as Record<"smartrecruiters" | "workable" | "breezy" | "freshteam" | "keka" | "zohorecruit" | "accenture" | "infosys" | "capgemini", string[]>,
|
|
42
|
+
...Object.fromEntries(PROVIDERS.map((spec) => [spec.ats, spec.crawlPatterns])) as Record<"smartrecruiters" | "workable" | "breezy" | "freshteam" | "keka" | "zohorecruit" | "accenture" | "infosys" | "capgemini" | "amazon", string[]>,
|
|
43
43
|
jobposting: [], // company sites are found by probing seeds, never by URL pattern
|
|
44
44
|
};
|
|
45
45
|
const patterns = Object.values(providerPatterns).flat();
|
package/src/portals.ts
CHANGED
|
@@ -117,4 +117,36 @@ const capgemini: ProviderSpec = {
|
|
|
117
117
|
},
|
|
118
118
|
};
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
/** Amazon's public job search (amazon.jobs/en/search.json; robots.txt disallows only /internal). Token is the ISO-3 country code. */
|
|
121
|
+
const amazon: ProviderSpec = {
|
|
122
|
+
ats: "amazon", label: "Amazon jobs", hosts: ["amazon.jobs"], crawlPatterns: [],
|
|
123
|
+
resolve(url) { return /(^|\.)amazon\.jobs$/i.test(url.hostname) ? (url.searchParams.get("normalized_country_code[]") ?? null) : null; },
|
|
124
|
+
canonicalUrl: (token) => `https://www.amazon.jobs/en/search?normalized_country_code%5B%5D=${encodeURIComponent(token)}`,
|
|
125
|
+
endpoint: (token) => `https://www.amazon.jobs/en/search.json?normalized_country_code%5B%5D=${encodeURIComponent(token)}&result_limit=100&sort=recent&offset=0`,
|
|
126
|
+
jobsFromBody: (body) => (isRecord(body) ? asRecords(body.jobs) : null),
|
|
127
|
+
providerName: () => "Amazon",
|
|
128
|
+
payloadVersion: () => "amazon-jobs-search:v1",
|
|
129
|
+
async fetchAll(token, get) {
|
|
130
|
+
const records: Rec[] = [];
|
|
131
|
+
for (let offset = 0; offset < 10_000; offset += 100) {
|
|
132
|
+
const reply = await get(`https://www.amazon.jobs/en/search.json?normalized_country_code%5B%5D=${encodeURIComponent(token)}&result_limit=100&sort=recent&offset=${offset}`);
|
|
133
|
+
const page = amazon.jobsFromBody(reply) ?? [];
|
|
134
|
+
records.push(...page.map((row) => Object.fromEntries(AMAZON_FIELDS.map((key) => [key, row[key]]))));
|
|
135
|
+
const total = isRecord(reply) ? Number(reply.hits) : 0;
|
|
136
|
+
if (page.length < 100 || records.length >= total) break;
|
|
137
|
+
}
|
|
138
|
+
return records;
|
|
139
|
+
},
|
|
140
|
+
normalize(company, record) {
|
|
141
|
+
const posted = Date.parse(`${str(record.posted_date)} 00:00:00 UTC`);
|
|
142
|
+
const description = [plainText(str(record.description_short)), str(record.basic_qualifications) && `Basic qualifications:\n${plainText(str(record.basic_qualifications))}`, str(record.preferred_qualifications) && `Preferred qualifications:\n${plainText(str(record.preferred_qualifications))}`].filter(Boolean).join("\n\n").slice(0, 3_000);
|
|
143
|
+
const country = str(record.country_code) === "IND" ? "India" : str(record.country_code) === "USA" ? "United States" : str(record.country_code);
|
|
144
|
+
return job(company, str(record.id_icims) || str(record.id), {
|
|
145
|
+
title: str(record.title), location: [str(record.city), str(record.state), country].filter(Boolean).join(", "),
|
|
146
|
+
url: `https://www.amazon.jobs${str(record.job_path)}`, ...(Number.isFinite(posted) ? { updatedAt: iso(posted) } : {}), description,
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
const AMAZON_FIELDS = ["id", "id_icims", "title", "city", "state", "country_code", "posted_date", "job_path", "description_short", "basic_qualifications", "preferred_qualifications"];
|
|
151
|
+
|
|
152
|
+
export const PORTALS: ProviderSpec[] = [accenture, infosys, capgemini, amazon];
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** ATS boards plus "jobposting": a company's own career site read only through its schema.org JobPosting markup (token = careers URL). */
|
|
2
|
-
export const ALL_PROVIDERS = ["greenhouse", "lever", "ashby", "workday", "recruitee", "smartrecruiters", "workable", "breezy", "freshteam", "keka", "zohorecruit", "jobposting", "accenture", "infosys", "capgemini"] as const;
|
|
2
|
+
export const ALL_PROVIDERS = ["greenhouse", "lever", "ashby", "workday", "recruitee", "smartrecruiters", "workable", "breezy", "freshteam", "keka", "zohorecruit", "jobposting", "accenture", "infosys", "capgemini", "amazon"] as const;
|
|
3
3
|
export type Ats = (typeof ALL_PROVIDERS)[number];
|
|
4
4
|
|
|
5
5
|
export interface DomainEvidence {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.30";
|