@hadoku/jobplatform-worker 2.32.37 → 2.32.39
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/dist/clients/scraper.d.ts +15 -0
- package/dist/clients/scraper.d.ts.map +1 -1
- package/dist/index.js +307 -91
- package/dist/ingestWrite.d.ts +41 -0
- package/dist/ingestWrite.d.ts.map +1 -0
- package/dist/routes/ingest.d.ts.map +1 -1
- package/dist/routes/jobs/importUrl.d.ts +3 -0
- package/dist/routes/jobs/importUrl.d.ts.map +1 -0
- package/dist/routes/jobs/index.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -57,5 +57,20 @@ export declare function matchCompanies(env: AppEnv, names: string[], providers?:
|
|
|
57
57
|
* worth revisiting if registry grows.
|
|
58
58
|
*/
|
|
59
59
|
export declare function triggerSearch(env: AppEnv): Promise<void>;
|
|
60
|
+
/** One posting read from its URL by the scraper, in the ingest-webhook shape. */
|
|
61
|
+
export interface ExtractedPosting {
|
|
62
|
+
job: unknown;
|
|
63
|
+
ats: string | null;
|
|
64
|
+
board_slug: string | null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Read one job posting from a URL a person pasted (scraper
|
|
68
|
+
* `POST /api/v1/jobboards/extract`). Writes nothing on either side.
|
|
69
|
+
*
|
|
70
|
+
* Throws ScraperClientError carrying the scraper's status and its own words —
|
|
71
|
+
* "LinkedIn postings can't be imported…", "That posting no longer exists" —
|
|
72
|
+
* which are written for the person and passed through to them.
|
|
73
|
+
*/
|
|
74
|
+
export declare function extractPosting(env: AppEnv, url: string, greenhouseSlug?: string): Promise<ExtractedPosting>;
|
|
60
75
|
export {};
|
|
61
76
|
//# sourceMappingURL=scraper.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scraper.d.ts","sourceRoot":"","sources":["../../src/clients/scraper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAI1C,+EAA+E;AAC/E,UAAU,WAAW;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,WAAW,EAAE,CAAC;CACpB;AAQD,8EAA8E;AAC9E,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAWD,qBAAa,kBAAmB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAEV,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CAMzD;AA0BD;;;;;GAKG;AACH,wBAAsB,UAAU,CAC/B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EAAE,EACf,SAAS,CAAC,EAAE,MAAM,EAAE,GAClB,OAAO,CAAC,eAAe,EAAE,CAAC,CAqB5B;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CACnC,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EAAE,EACf,SAAS,CAAC,EAAE,MAAM,EAAE,GAClB,OAAO,CAAC,YAAY,EAAE,CAAC,CAqBzB;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAY9D"}
|
|
1
|
+
{"version":3,"file":"scraper.d.ts","sourceRoot":"","sources":["../../src/clients/scraper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAI1C,+EAA+E;AAC/E,UAAU,WAAW;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,WAAW,EAAE,CAAC;CACpB;AAQD,8EAA8E;AAC9E,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAWD,qBAAa,kBAAmB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAEV,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CAMzD;AA0BD;;;;;GAKG;AACH,wBAAsB,UAAU,CAC/B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EAAE,EACf,SAAS,CAAC,EAAE,MAAM,EAAE,GAClB,OAAO,CAAC,eAAe,EAAE,CAAC,CAqB5B;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CACnC,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EAAE,EACf,SAAS,CAAC,EAAE,MAAM,EAAE,GAClB,OAAO,CAAC,YAAY,EAAE,CAAC,CAqBzB;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAY9D;AAED,iFAAiF;AACjF,MAAM,WAAW,gBAAgB;IAChC,GAAG,EAAE,OAAO,CAAC;IACb,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAcD;;;;;;;GAOG;AACH,wBAAsB,cAAc,CACnC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,cAAc,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,gBAAgB,CAAC,CAkB3B"}
|
package/dist/index.js
CHANGED
|
@@ -2581,6 +2581,74 @@ function scheduleRankBuild(c, db, profileId, profile) {
|
|
|
2581
2581
|
rebuildRank(db, profileId, profile).finally(() => building.delete(profileId))
|
|
2582
2582
|
);
|
|
2583
2583
|
}
|
|
2584
|
+
function normalizeWorkplaceType(wt) {
|
|
2585
|
+
if (wt === "on_site") return "onsite";
|
|
2586
|
+
return wt;
|
|
2587
|
+
}
|
|
2588
|
+
function insertJobStatement(db, job, { source, board_slug, now }) {
|
|
2589
|
+
const workplaceType = normalizeWorkplaceType(job.workplace_type);
|
|
2590
|
+
const structuredMin = job.salary?.min ?? null;
|
|
2591
|
+
const structuredMax = job.salary?.max ?? null;
|
|
2592
|
+
const prose = structuredMin === null && structuredMax === null ? parseSalaryRange(job.description) : null;
|
|
2593
|
+
const salaryMin = structuredMin ?? prose?.min ?? null;
|
|
2594
|
+
const salaryMax = structuredMax ?? prose?.max ?? null;
|
|
2595
|
+
const parsed = parseAtsSlug(job.url);
|
|
2596
|
+
const ats = parsed.ats ?? (board_slug ? source : null);
|
|
2597
|
+
const slug2 = board_slug ?? parsed.slug;
|
|
2598
|
+
const slugSource = slug2 === null ? null : board_slug ? "scraped" : "guessed";
|
|
2599
|
+
const role = classifyRole(job.title, job.description);
|
|
2600
|
+
return db.prepare(
|
|
2601
|
+
`INSERT OR IGNORE INTO jobs (
|
|
2602
|
+
id, url, source_site, title, company, location,
|
|
2603
|
+
job_type, workplace_type, salary_min, salary_max,
|
|
2604
|
+
description, posted_date, application_url, department,
|
|
2605
|
+
scraper_used, raw, scraped_at, last_seen_at, ats, slug,
|
|
2606
|
+
role_track, role_level, slug_source, geo_keys
|
|
2607
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2608
|
+
).bind(
|
|
2609
|
+
job.id,
|
|
2610
|
+
job.url,
|
|
2611
|
+
job.source_site,
|
|
2612
|
+
job.title,
|
|
2613
|
+
job.company,
|
|
2614
|
+
job.location,
|
|
2615
|
+
job.job_type,
|
|
2616
|
+
workplaceType,
|
|
2617
|
+
salaryMin,
|
|
2618
|
+
salaryMax,
|
|
2619
|
+
job.description,
|
|
2620
|
+
job.posted_date ?? null,
|
|
2621
|
+
job.application_url ?? null,
|
|
2622
|
+
job.department ?? null,
|
|
2623
|
+
job.scraper_used ?? null,
|
|
2624
|
+
JSON.stringify(job.raw),
|
|
2625
|
+
now,
|
|
2626
|
+
now,
|
|
2627
|
+
ats,
|
|
2628
|
+
slug2,
|
|
2629
|
+
role.track,
|
|
2630
|
+
role.level,
|
|
2631
|
+
slugSource,
|
|
2632
|
+
geoKeys(job.location)
|
|
2633
|
+
);
|
|
2634
|
+
}
|
|
2635
|
+
async function rankNewJobs(db) {
|
|
2636
|
+
const ranked = await db.prepare("SELECT profile_id, criteria_hash, max_job_rowid FROM job_profile_rank_state").all();
|
|
2637
|
+
if (ranked.results.length === 0) return;
|
|
2638
|
+
const head2 = await jobsHead(db);
|
|
2639
|
+
for (const state of ranked.results) {
|
|
2640
|
+
const from = Number(state.max_job_rowid);
|
|
2641
|
+
if (from >= head2) continue;
|
|
2642
|
+
const profile = await loadScorableProfile(db, state.profile_id);
|
|
2643
|
+
if (criteriaHash(profile) !== state.criteria_hash) continue;
|
|
2644
|
+
const rows = await db.prepare(
|
|
2645
|
+
`SELECT id, title, location, workplace_type, salary_max, role_level
|
|
2646
|
+
FROM jobs WHERE rowid > ? AND rowid <= ?`
|
|
2647
|
+
).bind(from, head2).all();
|
|
2648
|
+
await writeRanks(db, state.profile_id, profile, rows.results);
|
|
2649
|
+
await markRankBuilt(db, state.profile_id, profile, head2);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2584
2652
|
const app$4 = new OpenAPIHono();
|
|
2585
2653
|
const CULL_DAYS = 60;
|
|
2586
2654
|
const CULL_BATCH = 500;
|
|
@@ -2614,23 +2682,6 @@ async function stripStaleDescriptions(db) {
|
|
|
2614
2682
|
).bind(cutoff, cutoff).run();
|
|
2615
2683
|
return res.meta?.changes ?? 0;
|
|
2616
2684
|
}
|
|
2617
|
-
async function rankNewJobs(db) {
|
|
2618
|
-
const ranked = await db.prepare("SELECT profile_id, criteria_hash, max_job_rowid FROM job_profile_rank_state").all();
|
|
2619
|
-
if (ranked.results.length === 0) return;
|
|
2620
|
-
const head2 = await jobsHead(db);
|
|
2621
|
-
for (const state of ranked.results) {
|
|
2622
|
-
const from = Number(state.max_job_rowid);
|
|
2623
|
-
if (from >= head2) continue;
|
|
2624
|
-
const profile = await loadScorableProfile(db, state.profile_id);
|
|
2625
|
-
if (criteriaHash(profile) !== state.criteria_hash) continue;
|
|
2626
|
-
const rows = await db.prepare(
|
|
2627
|
-
`SELECT id, title, location, workplace_type, salary_max, role_level
|
|
2628
|
-
FROM jobs WHERE rowid > ? AND rowid <= ?`
|
|
2629
|
-
).bind(from, head2).all();
|
|
2630
|
-
await writeRanks(db, state.profile_id, profile, rows.results);
|
|
2631
|
-
await markRankBuilt(db, state.profile_id, profile, head2);
|
|
2632
|
-
}
|
|
2633
|
-
}
|
|
2634
2685
|
app$4.use("/ingest", requireMinTier("friend"));
|
|
2635
2686
|
app$4.use("/ingest/backfill-slugs", requireMinTier("friend"));
|
|
2636
2687
|
app$4.use("/ingest/backfill-roles", requireMinTier("friend"));
|
|
@@ -2640,10 +2691,6 @@ app$4.use("/ingest/cull", requireMinTier("friend"));
|
|
|
2640
2691
|
app$4.use("/ingest/rebuild-rank", requireMinTier("friend"));
|
|
2641
2692
|
app$4.use("/ingest/reconcile-mail", requireMinTier("friend"));
|
|
2642
2693
|
app$4.use("/directives", requireMinTier("friend"));
|
|
2643
|
-
function normalizeWorkplaceType(wt) {
|
|
2644
|
-
if (wt === "on_site") return "onsite";
|
|
2645
|
-
return wt;
|
|
2646
|
-
}
|
|
2647
2694
|
const ingestRoute = createRoute({
|
|
2648
2695
|
method: "post",
|
|
2649
2696
|
path: "/ingest",
|
|
@@ -2667,53 +2714,7 @@ app$4.openapi(ingestRoute, async (c) => {
|
|
|
2667
2714
|
const { jobs, batch_number, is_final, source, board_slug } = c.req.valid("json");
|
|
2668
2715
|
const db = c.env.JOB_PLATFORM_DB;
|
|
2669
2716
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2670
|
-
const stmts = jobs.map((job) => {
|
|
2671
|
-
const workplaceType = normalizeWorkplaceType(job.workplace_type);
|
|
2672
|
-
const structuredMin = job.salary?.min ?? null;
|
|
2673
|
-
const structuredMax = job.salary?.max ?? null;
|
|
2674
|
-
const prose = structuredMin === null && structuredMax === null ? parseSalaryRange(job.description) : null;
|
|
2675
|
-
const salaryMin = structuredMin ?? prose?.min ?? null;
|
|
2676
|
-
const salaryMax = structuredMax ?? prose?.max ?? null;
|
|
2677
|
-
const parsed = parseAtsSlug(job.url);
|
|
2678
|
-
const ats = parsed.ats ?? (board_slug ? source : null);
|
|
2679
|
-
const slug2 = board_slug ?? parsed.slug;
|
|
2680
|
-
const slugSource = slug2 === null ? null : board_slug ? "scraped" : "guessed";
|
|
2681
|
-
const role = classifyRole(job.title, job.description);
|
|
2682
|
-
return db.prepare(
|
|
2683
|
-
`INSERT OR IGNORE INTO jobs (
|
|
2684
|
-
id, url, source_site, title, company, location,
|
|
2685
|
-
job_type, workplace_type, salary_min, salary_max,
|
|
2686
|
-
description, posted_date, application_url, department,
|
|
2687
|
-
scraper_used, raw, scraped_at, last_seen_at, ats, slug,
|
|
2688
|
-
role_track, role_level, slug_source, geo_keys
|
|
2689
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2690
|
-
).bind(
|
|
2691
|
-
job.id,
|
|
2692
|
-
job.url,
|
|
2693
|
-
job.source_site,
|
|
2694
|
-
job.title,
|
|
2695
|
-
job.company,
|
|
2696
|
-
job.location,
|
|
2697
|
-
job.job_type,
|
|
2698
|
-
workplaceType,
|
|
2699
|
-
salaryMin,
|
|
2700
|
-
salaryMax,
|
|
2701
|
-
job.description,
|
|
2702
|
-
job.posted_date ?? null,
|
|
2703
|
-
job.application_url ?? null,
|
|
2704
|
-
job.department ?? null,
|
|
2705
|
-
job.scraper_used ?? null,
|
|
2706
|
-
JSON.stringify(job.raw),
|
|
2707
|
-
now,
|
|
2708
|
-
now,
|
|
2709
|
-
ats,
|
|
2710
|
-
slug2,
|
|
2711
|
-
role.track,
|
|
2712
|
-
role.level,
|
|
2713
|
-
slugSource,
|
|
2714
|
-
geoKeys(job.location)
|
|
2715
|
-
);
|
|
2716
|
-
});
|
|
2717
|
+
const stmts = jobs.map((job) => insertJobStatement(db, job, { source, board_slug, now }));
|
|
2717
2718
|
const inserted = [];
|
|
2718
2719
|
startTime(c, "insert");
|
|
2719
2720
|
for (let i = 0; i < stmts.length; i += INGEST_BATCH_CHUNK) {
|
|
@@ -2724,20 +2725,34 @@ app$4.openapi(ingestRoute, async (c) => {
|
|
|
2724
2725
|
const accepted = inserted.filter(Boolean).length;
|
|
2725
2726
|
const skipped = jobs.length - accepted;
|
|
2726
2727
|
const seen = jobs.filter((_, i) => !inserted[i]);
|
|
2727
|
-
const
|
|
2728
|
-
const
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
const chunk = seenUrls.slice(i, i + 90);
|
|
2728
|
+
const SEEN_CHUNK = 45;
|
|
2729
|
+
const storedReads = [];
|
|
2730
|
+
for (let i = 0; i < seen.length; i += SEEN_CHUNK) {
|
|
2731
|
+
const chunk = seen.slice(i, i + SEEN_CHUNK);
|
|
2732
2732
|
const inList = chunk.map(() => "?").join(",");
|
|
2733
|
-
|
|
2734
|
-
db.prepare(
|
|
2733
|
+
storedReads.push(
|
|
2734
|
+
db.prepare(
|
|
2735
|
+
`SELECT id, url, description = '' AS blank FROM jobs
|
|
2736
|
+
WHERE url IN (${inList}) OR id IN (${inList})`
|
|
2737
|
+
).bind(...chunk.map((j) => j.url), ...chunk.map((j) => j.id))
|
|
2735
2738
|
);
|
|
2736
|
-
|
|
2737
|
-
|
|
2739
|
+
}
|
|
2740
|
+
startTime(c, "seen");
|
|
2741
|
+
const stored = storedReads.length ? (await db.batch(storedReads)).flatMap(
|
|
2742
|
+
(r) => r.results
|
|
2743
|
+
) : [];
|
|
2744
|
+
const byUrl = new Map(stored.map((r) => [r.url, r]));
|
|
2745
|
+
const byId = new Map(stored.map((r) => [r.id, r]));
|
|
2746
|
+
const writes = [];
|
|
2747
|
+
const sameUrl = seen.filter((j) => byUrl.has(j.url)).map((j) => j.url);
|
|
2748
|
+
for (let i = 0; i < sameUrl.length; i += 90) {
|
|
2749
|
+
const chunk = sameUrl.slice(i, i + 90);
|
|
2750
|
+
const inList = chunk.map(() => "?").join(",");
|
|
2751
|
+
writes.push(
|
|
2752
|
+
db.prepare(`UPDATE jobs SET last_seen_at = ? WHERE url IN (${inList})`).bind(now, ...chunk)
|
|
2738
2753
|
);
|
|
2739
2754
|
if (board_slug) {
|
|
2740
|
-
|
|
2755
|
+
writes.push(
|
|
2741
2756
|
db.prepare(
|
|
2742
2757
|
`UPDATE jobs SET slug = ?, slug_source = 'scraped'
|
|
2743
2758
|
WHERE url IN (${inList})
|
|
@@ -2746,21 +2761,24 @@ app$4.openapi(ingestRoute, async (c) => {
|
|
|
2746
2761
|
);
|
|
2747
2762
|
}
|
|
2748
2763
|
}
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
const
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2764
|
+
for (const j of seen) {
|
|
2765
|
+
const row = byId.get(j.id);
|
|
2766
|
+
if (!row || row.url === j.url) continue;
|
|
2767
|
+
writes.push(db.prepare("UPDATE jobs SET last_seen_at = ? WHERE id = ?").bind(now, row.id));
|
|
2768
|
+
writes.push(db.prepare("UPDATE OR IGNORE jobs SET url = ? WHERE id = ?").bind(j.url, row.id));
|
|
2769
|
+
}
|
|
2770
|
+
for (const j of seen) {
|
|
2771
|
+
const row = byUrl.get(j.url) ?? byId.get(j.id);
|
|
2772
|
+
if (row?.blank && j.description !== "") {
|
|
2773
|
+
writes.push(
|
|
2774
|
+
db.prepare(`UPDATE jobs SET description = ? WHERE id = ? AND description = ''`).bind(j.description, row.id)
|
|
2775
|
+
);
|
|
2761
2776
|
}
|
|
2762
2777
|
}
|
|
2763
|
-
|
|
2778
|
+
for (let i = 0; i < writes.length; i += INGEST_BATCH_CHUNK) {
|
|
2779
|
+
await db.batch(writes.slice(i, i + INGEST_BATCH_CHUNK));
|
|
2780
|
+
}
|
|
2781
|
+
endTime(c, "seen");
|
|
2764
2782
|
if (accepted > 0) {
|
|
2765
2783
|
startTime(c, "rank");
|
|
2766
2784
|
await rankNewJobs(db);
|
|
@@ -3401,6 +3419,34 @@ async function triggerSearch(env) {
|
|
|
3401
3419
|
);
|
|
3402
3420
|
}
|
|
3403
3421
|
}
|
|
3422
|
+
function scraperMessage(body) {
|
|
3423
|
+
try {
|
|
3424
|
+
const parsed = JSON.parse(body);
|
|
3425
|
+
if (typeof parsed.detail === "string") return parsed.detail;
|
|
3426
|
+
if (typeof parsed.error?.message === "string") return parsed.error.message;
|
|
3427
|
+
} catch {
|
|
3428
|
+
}
|
|
3429
|
+
return null;
|
|
3430
|
+
}
|
|
3431
|
+
async function extractPosting(env, url, greenhouseSlug) {
|
|
3432
|
+
const response = await scraperFetch(env, "/api/v1/jobboards/extract", {
|
|
3433
|
+
method: "POST",
|
|
3434
|
+
body: JSON.stringify(greenhouseSlug ? { url, greenhouse_slug: greenhouseSlug } : { url })
|
|
3435
|
+
});
|
|
3436
|
+
const text = await response.text();
|
|
3437
|
+
if (!response.ok) {
|
|
3438
|
+
throw new ScraperClientError(
|
|
3439
|
+
scraperMessage(text) ?? `scraper /extract returned ${response.status}`,
|
|
3440
|
+
response.status,
|
|
3441
|
+
text
|
|
3442
|
+
);
|
|
3443
|
+
}
|
|
3444
|
+
const json = JSON.parse(text);
|
|
3445
|
+
if (!json.success || !json.data) {
|
|
3446
|
+
throw new ScraperClientError("scraper /extract reported failure", 502, text);
|
|
3447
|
+
}
|
|
3448
|
+
return json.data;
|
|
3449
|
+
}
|
|
3404
3450
|
const app$3 = new OpenAPIHono();
|
|
3405
3451
|
app$3.use("/profiles", requireMinTier("friend"));
|
|
3406
3452
|
app$3.use("/profiles/*", requireMinTier("friend"));
|
|
@@ -7106,6 +7152,175 @@ function registerLocationsRoute(app2) {
|
|
|
7106
7152
|
}
|
|
7107
7153
|
);
|
|
7108
7154
|
}
|
|
7155
|
+
const ImportJobSchema = z.object({
|
|
7156
|
+
url: z.string().trim().url().max(2e3),
|
|
7157
|
+
/** Where it lands in the pipeline when it is new to the caller. */
|
|
7158
|
+
state: JobStateSchema.exclude(["dismissed"]).default("saved")
|
|
7159
|
+
}).openapi("ImportJob");
|
|
7160
|
+
const ImportedSchema = z.object({
|
|
7161
|
+
success: z.literal(true),
|
|
7162
|
+
data: z.object({
|
|
7163
|
+
job: z.object({
|
|
7164
|
+
id: z.string(),
|
|
7165
|
+
title: z.string(),
|
|
7166
|
+
company: z.string(),
|
|
7167
|
+
location: z.string(),
|
|
7168
|
+
url: z.string()
|
|
7169
|
+
}),
|
|
7170
|
+
/** The posting was new to the corpus (false: it was already there). */
|
|
7171
|
+
created: z.boolean(),
|
|
7172
|
+
/** The caller's state for it now. */
|
|
7173
|
+
state: JobStateSchema,
|
|
7174
|
+
/** The caller already tracked it, so its state was left alone. */
|
|
7175
|
+
already_tracked: z.boolean()
|
|
7176
|
+
})
|
|
7177
|
+
}).openapi("ImportedJob");
|
|
7178
|
+
function failure(status, message) {
|
|
7179
|
+
return {
|
|
7180
|
+
success: false,
|
|
7181
|
+
error: status === 422 ? "Unprocessable" : "Bad gateway",
|
|
7182
|
+
message
|
|
7183
|
+
};
|
|
7184
|
+
}
|
|
7185
|
+
const JOB_COLS = "id, title, company, location, url";
|
|
7186
|
+
async function greenhouseSlugFor(db, url) {
|
|
7187
|
+
const parsed = new URL(url);
|
|
7188
|
+
if (!parsed.searchParams.has("gh_jid")) return void 0;
|
|
7189
|
+
const prefix = `${parsed.protocol}//${parsed.host}/`;
|
|
7190
|
+
const row = await db.prepare(
|
|
7191
|
+
`SELECT slug FROM jobs
|
|
7192
|
+
WHERE url >= ? AND url < ? AND ats = 'greenhouse' AND slug IS NOT NULL
|
|
7193
|
+
AND slug_source = 'scraped'
|
|
7194
|
+
LIMIT 1`
|
|
7195
|
+
).bind(prefix, `${prefix.slice(0, -1)}0`).first();
|
|
7196
|
+
return row?.slug;
|
|
7197
|
+
}
|
|
7198
|
+
function registerImportRoute(app2) {
|
|
7199
|
+
app2.post("/jobs/import", gateAuthed);
|
|
7200
|
+
app2.openapi(
|
|
7201
|
+
createRoute({
|
|
7202
|
+
method: "post",
|
|
7203
|
+
path: "/jobs/import",
|
|
7204
|
+
tags: ["Pipeline"],
|
|
7205
|
+
summary: "Import a posting from its URL into the corpus and the caller’s pipeline",
|
|
7206
|
+
request: {
|
|
7207
|
+
query: ownerNameQuery,
|
|
7208
|
+
body: { content: { "application/json": { schema: ImportJobSchema } } }
|
|
7209
|
+
},
|
|
7210
|
+
responses: {
|
|
7211
|
+
200: {
|
|
7212
|
+
description: "Imported (or found, and added to the pipeline)",
|
|
7213
|
+
content: { "application/json": { schema: ImportedSchema } }
|
|
7214
|
+
},
|
|
7215
|
+
403: {
|
|
7216
|
+
description: "Forbidden",
|
|
7217
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
7218
|
+
},
|
|
7219
|
+
404: {
|
|
7220
|
+
description: "The board no longer lists that posting, or no such owner name",
|
|
7221
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
7222
|
+
},
|
|
7223
|
+
409: {
|
|
7224
|
+
description: "That owner name has never signed in",
|
|
7225
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
7226
|
+
},
|
|
7227
|
+
422: {
|
|
7228
|
+
description: "No posting could be read from that page (or it is a LinkedIn URL)",
|
|
7229
|
+
content: { "application/json": { schema: ErrorResponseSchema } }
|
|
7230
|
+
},
|
|
7231
|
+
502: {
|
|
7232
|
+
description: "The site, or the scraper, could not be reached",
|
|
7233
|
+
content: { "application/json": { schema: ErrorResponseSchema } }
|
|
7234
|
+
},
|
|
7235
|
+
503: {
|
|
7236
|
+
description: "Identity could not be resolved right now — retry",
|
|
7237
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
7238
|
+
}
|
|
7239
|
+
}
|
|
7240
|
+
}),
|
|
7241
|
+
async (c) => {
|
|
7242
|
+
const { url, state } = c.req.valid("json");
|
|
7243
|
+
const { ownerName } = c.req.valid("query");
|
|
7244
|
+
const who = await effectiveUserId(c, ownerName);
|
|
7245
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
7246
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
7247
|
+
let row = await db.prepare(`SELECT ${JOB_COLS} FROM jobs WHERE url = ?`).bind(url).first();
|
|
7248
|
+
let created = false;
|
|
7249
|
+
if (!row) {
|
|
7250
|
+
let extracted;
|
|
7251
|
+
try {
|
|
7252
|
+
extracted = await extractPosting(c.env, url, await greenhouseSlugFor(db, url));
|
|
7253
|
+
} catch (err) {
|
|
7254
|
+
if (!(err instanceof ScraperClientError)) throw err;
|
|
7255
|
+
if (err.status === 404) {
|
|
7256
|
+
return c.json(
|
|
7257
|
+
{ success: false, error: "Not found", message: err.message },
|
|
7258
|
+
404
|
|
7259
|
+
);
|
|
7260
|
+
}
|
|
7261
|
+
if (err.status === 400 || err.status === 422) {
|
|
7262
|
+
return c.json(failure(422, err.message), 422);
|
|
7263
|
+
}
|
|
7264
|
+
return c.json(failure(502, `Could not read that posting: ${err.message}`), 502);
|
|
7265
|
+
}
|
|
7266
|
+
const job = IngestJobSchema.safeParse(extracted.job);
|
|
7267
|
+
if (!job.success) {
|
|
7268
|
+
return c.json(
|
|
7269
|
+
failure(
|
|
7270
|
+
502,
|
|
7271
|
+
`The scraper returned a posting this API cannot store: ${job.error.message}`
|
|
7272
|
+
),
|
|
7273
|
+
502
|
|
7274
|
+
);
|
|
7275
|
+
}
|
|
7276
|
+
const now2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
7277
|
+
const [res] = await db.batch([
|
|
7278
|
+
insertJobStatement(db, job.data, {
|
|
7279
|
+
source: extracted.ats ?? "import",
|
|
7280
|
+
board_slug: extracted.board_slug ?? void 0,
|
|
7281
|
+
now: now2
|
|
7282
|
+
})
|
|
7283
|
+
]);
|
|
7284
|
+
created = (res.meta?.changes ?? 0) > 0;
|
|
7285
|
+
row = await db.prepare(`SELECT ${JOB_COLS} FROM jobs WHERE url = ? OR id = ? LIMIT 1`).bind(job.data.url, job.data.id).first();
|
|
7286
|
+
if (!row) throw new Error(`import: ${job.data.id} neither inserted nor found`);
|
|
7287
|
+
if (created) await rankNewJobs(db);
|
|
7288
|
+
}
|
|
7289
|
+
const prev = await db.prepare("SELECT state FROM job_states WHERE job_id = ? AND user_id = ?").bind(row.id, who.userId).first();
|
|
7290
|
+
if (prev) {
|
|
7291
|
+
const kept = JobStateSchema.safeParse(prev.state);
|
|
7292
|
+
return c.json(
|
|
7293
|
+
{
|
|
7294
|
+
success: true,
|
|
7295
|
+
data: {
|
|
7296
|
+
job: row,
|
|
7297
|
+
created,
|
|
7298
|
+
state: kept.success ? kept.data : state,
|
|
7299
|
+
already_tracked: true
|
|
7300
|
+
}
|
|
7301
|
+
},
|
|
7302
|
+
200
|
|
7303
|
+
);
|
|
7304
|
+
}
|
|
7305
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7306
|
+
await db.batch([
|
|
7307
|
+
db.prepare(
|
|
7308
|
+
`INSERT INTO job_events (job_id, user_id, kind, from_state, to_state, occurred_at, created_at)
|
|
7309
|
+
SELECT ?, ?, 'state', NULL, ?, ?, ?
|
|
7310
|
+
WHERE NOT EXISTS (SELECT 1 FROM job_states WHERE job_id = ? AND user_id = ?)`
|
|
7311
|
+
).bind(row.id, who.userId, state, now, now, row.id, who.userId),
|
|
7312
|
+
db.prepare(
|
|
7313
|
+
`INSERT INTO job_states (job_id, user_id, state, updated_at) VALUES (?, ?, ?, ?)
|
|
7314
|
+
ON CONFLICT (job_id, user_id) DO NOTHING`
|
|
7315
|
+
).bind(row.id, who.userId, state, now)
|
|
7316
|
+
]);
|
|
7317
|
+
return c.json(
|
|
7318
|
+
{ success: true, data: { job: row, created, state, already_tracked: false } },
|
|
7319
|
+
200
|
|
7320
|
+
);
|
|
7321
|
+
}
|
|
7322
|
+
);
|
|
7323
|
+
}
|
|
7109
7324
|
const app$2 = new OpenAPIHono();
|
|
7110
7325
|
registerFeedRoute(app$2);
|
|
7111
7326
|
registerPreflightRoute(app$2);
|
|
@@ -7121,6 +7336,7 @@ registerFillRoutes(app$2);
|
|
|
7121
7336
|
registerBlockedRoutes(app$2);
|
|
7122
7337
|
registerInsightsRoute(app$2);
|
|
7123
7338
|
registerLocationsRoute(app$2);
|
|
7339
|
+
registerImportRoute(app$2);
|
|
7124
7340
|
registerAnswerRoutes(app$2);
|
|
7125
7341
|
registerOwnerRoutes(app$2);
|
|
7126
7342
|
const jobRoutes = app$2;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing postings into the corpus — the one path both POST /ingest (the
|
|
3
|
+
* scraper's nightly batches) and POST /jobs/import (one posting a person
|
|
4
|
+
* pasted) go through, so an imported posting is classified, salaried, placed
|
|
5
|
+
* and ranked exactly as a scraped one is.
|
|
6
|
+
*/
|
|
7
|
+
import type { z } from '@hono/zod-openapi';
|
|
8
|
+
import type { AppEnv } from './types.js';
|
|
9
|
+
import type { IngestJobSchema } from './schemas.js';
|
|
10
|
+
type DB = AppEnv['JOB_PLATFORM_DB'];
|
|
11
|
+
export type IngestJob = z.infer<typeof IngestJobSchema>;
|
|
12
|
+
/**
|
|
13
|
+
* The INSERT OR IGNORE for one posting. Ignored when its url (UNIQUE) or id
|
|
14
|
+
* is already stored — an already-known posting keeps the fields it has.
|
|
15
|
+
*
|
|
16
|
+
* `source` is the batch label; `board_slug` the board the scraper actually
|
|
17
|
+
* fetched, when it says so.
|
|
18
|
+
*/
|
|
19
|
+
export declare function insertJobStatement(db: DB, job: IngestJob, { source, board_slug, now }: {
|
|
20
|
+
source: string;
|
|
21
|
+
board_slug?: string;
|
|
22
|
+
now: string;
|
|
23
|
+
}): import("@cloudflare/workers-types").D1PreparedStatement;
|
|
24
|
+
/**
|
|
25
|
+
* Extend every current ranking to cover the jobs inserted since it was built.
|
|
26
|
+
*
|
|
27
|
+
* Selected by rowid past each profile's marker rather than from the payload:
|
|
28
|
+
* ingest only ever INSERTs (an already-known posting is ignored, its fields
|
|
29
|
+
* untouched), so a job that was ranked once keeps the same bound and needs no
|
|
30
|
+
* new write. Re-ranking the whole payload whenever any of it was new used to
|
|
31
|
+
* upsert ~25 rows per profile per batch for one new job — 35% of the
|
|
32
|
+
* database's daily writes. The rowid range also picks up jobs a previous,
|
|
33
|
+
* failed extension missed, which a payload-only pass never would.
|
|
34
|
+
*
|
|
35
|
+
* A profile whose criteria changed since its ranking was built is left alone:
|
|
36
|
+
* extending it under the new criteria and moving its marker would bless the
|
|
37
|
+
* old rows as current. The feed sees the stale hash and rebuilds it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function rankNewJobs(db: AppEnv['JOB_PLATFORM_DB']): Promise<void>;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=ingestWrite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ingestWrite.d.ts","sourceRoot":"","sources":["../src/ingestWrite.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,mBAAmB,CAAC;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAQpD,KAAK,EAAE,GAAG,MAAM,CAAC,iBAAiB,CAAC,CAAC;AACpC,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAQxD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CACjC,EAAE,EAAE,EAAE,EACN,GAAG,EAAE,SAAS,EACd,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,EAAE,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,2DA6DjF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,CAAC,iBAAiB,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAyB9E"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/routes/ingest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAkB,MAAM,mBAAmB,CAAC;AAEhE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAkB,KAAK,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/routes/ingest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAkB,MAAM,mBAAmB,CAAC;AAEhE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAkB,KAAK,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAiB9E,UAAU,YAAY;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE;QAAE,WAAW,EAAE,iBAAiB,CAAA;KAAE,CAAC;CAC9C;AAq9BD,eAAO,MAAM,YAAY,oCAAM,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"importUrl.d.ts","sourceRoot":"","sources":["../../../src/routes/jobs/importUrl.ts"],"names":[],"mappings":"AA2BA,OAAO,EAKN,KAAK,OAAO,EACZ,MAAM,aAAa,CAAC;AA2ErB,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAuJtD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/routes/jobs/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/routes/jobs/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAyChD,eAAO,MAAM,SAAS,oCAAM,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hadoku/jobplatform-worker",
|
|
3
|
-
"version": "2.32.
|
|
4
|
-
"gitHead": "
|
|
3
|
+
"version": "2.32.39",
|
|
4
|
+
"gitHead": "84b5623c402aac9159148daa3d454529dfea4407",
|
|
5
5
|
"description": "Job listings aggregation and tracking dashboard - Worker package",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|