@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.
@@ -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 seenUrls = seen.map((j) => j.url);
2728
- const seenStmts = [];
2729
- const strippedStmts = [];
2730
- for (let i = 0; i < seenUrls.length; i += 90) {
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
- seenStmts.push(
2734
- db.prepare(`UPDATE jobs SET last_seen_at = ? WHERE url IN (${inList})`).bind(now, ...chunk)
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
- strippedStmts.push(
2737
- db.prepare(`SELECT url FROM jobs WHERE url IN (${inList}) AND description = ''`).bind(...chunk)
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
- seenStmts.push(
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
- startTime(c, "seen");
2750
- if (seenStmts.length) await db.batch(seenStmts);
2751
- endTime(c, "seen");
2752
- startTime(c, "restore");
2753
- if (strippedStmts.length) {
2754
- const found = await db.batch(strippedStmts);
2755
- const stripped = new Set(found.flatMap((r) => r.results.map((row) => row.url)));
2756
- const restores = seen.filter((j) => stripped.has(j.url) && j.description !== "").map(
2757
- (j) => db.prepare(`UPDATE jobs SET description = ? WHERE url = ? AND description = ''`).bind(j.description, j.url)
2758
- );
2759
- for (let i = 0; i < restores.length; i += INGEST_BATCH_CHUNK) {
2760
- await db.batch(restores.slice(i, i + INGEST_BATCH_CHUNK));
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
- endTime(c, "restore");
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;AAwB9E,UAAU,YAAY;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE;QAAE,WAAW,EAAE,iBAAiB,CAAA;KAAE,CAAC;CAC9C;AAkiCD,eAAO,MAAM,YAAY,oCAAM,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,3 @@
1
+ import { type JobsApp } from './shared.js';
2
+ export declare function registerImportRoute(app: JobsApp): void;
3
+ //# sourceMappingURL=importUrl.d.ts.map
@@ -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;AAuChD,eAAO,MAAM,SAAS,oCAAM,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.37",
4
- "gitHead": "808b4a95842c638f0d1840d97203dcc06383d710",
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",