job-pro 0.7.0 → 0.7.2

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/pdd.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Thin client for 拼多多 (PDD / Pinduoduo) campus-recruiting portal at careers.pinduoduo.com.
2
2
  //
3
3
  // ============================================================
4
- // API discovery (probed 2026-05, Next.js bundle 4SRcl1zmo3aLPqQ_wcJ6T):
4
+ // API discovery (re-probed 2026-05; bundles under pfile.pddpic.com/ei-pub):
5
5
  //
6
6
  // The frontend is a Next.js SPA hosted at careers.pinduoduo.com/campus/.
7
7
  // All XHR calls go through a sourceSDK.wrappedRequest helper (module 96211)
@@ -9,52 +9,56 @@
9
9
  //
10
10
  // https://careers.pinduoduo.com/api/careers/<relative-url>
11
11
  //
12
- // ---- Probed endpoints and their accessibility (2026-05) ----
12
+ // ---- PUBLIC endpoints (no auth required, verified 2026-05) ----
13
13
  //
14
- // PUBLIC (no auth required):
15
- // POST /api/careers/api/campus/moment/list
16
- // Body: { pageSize:<int>, page:<int> }
17
- // Response: { success:true, result:{ list:[MomentItem], total:<int> } }
18
- // MomentItem: { guid, momentTitle, momentLabel:[string], publishDate:<ms>, topFlag:bool }
14
+ // POST /api/careers/api/recruit/position/list
15
+ // Body: { pageSize:<int>, page:<int> } (keyword/recruitType IGNORED server-side)
16
+ // Response: { success:true, result:{ list:[Position], total:"<int>" } }
17
+ // Position: { id (uuid), name, code, workLocation, workLocationName,
18
+ // job (eng key), jobName (zh), releaseTime (ms),
19
+ // jobDuty (full JD HTML/text), labelList[], recruitTypeName }
20
+ // Pagination: page size is FIXED at 10 regardless of pageSize param.
21
+ // Total returned across all pages (e.g. 30 for grad, 7 for intern).
19
22
  //
20
- // POST /api/careers/api/campus/moment/detail
21
- // Body: { guid:<string> }
22
- // Response: { success:true, result:{ guid, momentTitle, momentContent:<html> } }
23
+ // POST /api/careers/api/recruit/position/train/list
24
+ // Same shape as /list but only intern (实习生 / 2027届研发实习生) positions.
23
25
  //
24
- // POST /api/careers/api/recruit/qa/common/list
25
- // Body: {} (no params needed)
26
- // Response: { success:true, result:[{ question, questionCode }] }
27
- // Note: only 2 FAQ items are currently surfaced publicly (2026-05).
26
+ // POST /api/careers/api/recruit/position/detail/type
27
+ // Body: {} returns the job-type dictionary
28
+ // Result: [{job:"technology", jobName:"技术"}, {job:"general", jobName:"职能"},
29
+ // {job:"product", jobName:"产品"}, {job:"language", jobName:"语言"},
30
+ // {job:"market", jobName:"市场营销"}, {job:"visual", jobName:"视觉类"},
31
+ // {job:"investment", jobName:"运营"}, {job:"vegetable", jobName:"区域业务"}]
28
32
  //
29
- // POST /api/careers/api/campus/careers/enum
30
- // Body: {} { success:true, result:{ enumMap:{} } } (empty in public response)
33
+ // POST /api/careers/api/campus/moment/list (notices / 公告)
34
+ // Body: { pageSize:<int>, page:<int> }
35
+ // Response: { success:true, result:{ list:[MomentItem], total:<int> } }
36
+ // MomentItem: { guid, momentTitle, momentLabel:[string], publishDate:<ms>, topFlag:bool }
31
37
  //
32
- // AUTH-REQUIRED (401 Unauthorized without valid login token):
33
- // POST /api/careers/api/recruit/position/queryPosition ← main job list
34
- // POST /api/careers/api/recruit/position/querySecondPosition
35
- // POST /api/careers/api/recruit/site/query
36
- // POST /api/careers/api/campus/area/full/list
37
- // POST /api/careers/api/campus/education/major/query
38
- // POST /api/careers/api/recruit/qa/list
39
- // POST /api/careers/api/recruit/queryByShortLink
38
+ // POST /api/careers/api/campus/moment/detail
39
+ // Body: { guid:<string> }
40
+ // Response: { success:true, result:{ guid, momentTitle, momentContent:<html> } }
40
41
  //
41
- // ANTI-BOT BLOCKED (403 without Anti-Content token):
42
- // The old /api/* paths (without /api/careers/ prefix) return 403
43
- // from PDD's yak/openresty CDN layer. These require a dynamically
44
- // generated Anti-Content header produced by PDD's JS risk-control SDK.
42
+ // POST /api/careers/api/campus/trip/list (校招行程 / on-campus events)
43
+ // Body: {} { tripList:null|[...], explainTrip:{ recruitmentTripType, tripContent:<html> } }
45
44
  //
46
- // ---- Conclusion ----
47
- // The position search/detail APIs require a registered PDD account login
48
- // token. There is no publicly accessible job listing endpoint. This adapter
49
- // provides honest stubs for positions and implements real data for notices
50
- // (moment/list) and the FAQ (qa/common/list).
45
+ // POST /api/careers/api/recruit/qa/common/list (FAQ)
46
+ // Body: {} [{ question, questionCode }] (only ~2 items publicly)
47
+ //
48
+ // POST /api/careers/api/campus/careers/enum (enum map, empty for anon)
49
+ //
50
+ // ---- AUTH-REQUIRED endpoints (HTTP 401 without login token) ----
51
+ // /api/recruit/position/queryPosition (rich search with all filters)
52
+ // /api/recruit/position/querySecondPosition
53
+ // /api/recruit/site/query, /campus/area/full/list, /campus/education/major/query
54
+ // /api/recruit/qa/list, /api/recruit/queryByShortLink
51
55
  //
52
56
  // ---- RecruitType taxonomy (from JS bundle, probed 2026-05) ----
53
57
  // headquarters = 管培生 (headquarters management trainee)
54
58
  // region = 区域业务管培生 (regional business management trainee)
55
59
  // technical_session = 技术专场 (technical session / R&D)
56
60
  // warehouse_trainee = 仓储类管培生 (warehouse management trainee)
57
- // yunhu_plan = 云弧计划 (LLM / elite tech talent program, equivalent to ByteDance 顶尖)
61
+ // yunhu_plan = 云弧计划 (LLM / elite tech talent program)
58
62
  // intern = 实习生 (intern, 2027届)
59
63
  //
60
64
  // ---- Campus batches active at time of probing (2026-05) ----
@@ -63,6 +67,17 @@
63
67
  // 云弧计划 (elite LLM/AI talent program, 2026) → route /grad?recruitType=yunhu_plan
64
68
  //
65
69
  // ============================================================
70
+ // IMPLEMENTATION NOTES
71
+ // - searchPositions: uses /api/recruit/position/list (grad track). Server
72
+ // ignores `keyword` and `pageSize`; we filter client-side by keyword and
73
+ // slice the response to honour the requested pageSize. Server returns
74
+ // fixed pages of 10.
75
+ // - fetchAllPositions: paginates through both /list and /train/list to
76
+ // surface every public position.
77
+ // - fetchPositionDetail: there is no per-position detail endpoint; the
78
+ // /list response already includes the full jobDuty. We scan the list to
79
+ // locate the matching record by id.
80
+ // ============================================================
66
81
  import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
67
82
  export { checkResume };
68
83
  const API_ROOT = "https://careers.pinduoduo.com/api/careers";
@@ -120,70 +135,158 @@ async function call(path, body) {
120
135
  message: ok ? "ok" : (payload.errorMsg || `errorCode ${payload.errorCode}`),
121
136
  };
122
137
  }
138
+ const DETAIL_URL = (id) => `${GRAD_PAGE}?id=${encodeURIComponent(id)}`;
139
+ function summarizePosition(raw) {
140
+ const id = raw.id ?? "";
141
+ return {
142
+ post_id: id,
143
+ title: raw.name ?? "",
144
+ project: raw.jobName ?? "",
145
+ recruit_label: raw.recruitTypeName ?? "",
146
+ bgs: (raw.labelList ?? []).join(" / "),
147
+ work_cities: raw.workLocationName ?? raw.workLocation ?? "",
148
+ apply_url: id ? DETAIL_URL(id) : GRAD_PAGE,
149
+ };
150
+ }
151
+ // Server returns 10 items/page regardless of pageSize. Treat that as the upstream chunk.
152
+ const UPSTREAM_PAGE_SIZE = 10;
153
+ function asNumber(value) {
154
+ if (typeof value === "number")
155
+ return value;
156
+ if (typeof value === "string") {
157
+ const n = parseInt(value, 10);
158
+ return Number.isFinite(n) ? n : 0;
159
+ }
160
+ return 0;
161
+ }
123
162
  // ---------- searchPositions ----------
124
- // PDD's position search API (queryPosition) requires a logged-in user token.
125
- // All calls return HTTP 401 without a valid session. This stub documents the
126
- // attempted endpoint and returns ok:false so callers can degrade gracefully.
163
+ // Uses the public /api/recruit/position/list endpoint. The server ignores
164
+ // `keyword` and `pageSize` (always returns 10 items/page in releaseTime order),
165
+ // so we paginate upstream, then filter+slice client-side to honour the
166
+ // caller-requested keyword/pageSize.
127
167
  export async function searchPositions(opts = {}) {
128
168
  const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
129
169
  const page = Math.max(1, opts.page ?? 1);
130
- const keyword = (opts.keyword ?? "").trim().slice(0, 60);
131
- const payload = {
132
- pageSize,
133
- page,
134
- ...(keyword ? { keyword } : {}),
135
- ...(opts.recruitType ? { recruitType: opts.recruitType } : {}),
136
- };
137
- // Attempt the real API — expected to return 401 without auth token.
138
- const response = await call("/api/recruit/position/queryPosition", payload);
139
- // If the server starts accepting unauthenticated requests in the future,
140
- // this block will handle the data. For now it always falls through to the
141
- // stub error return below.
142
- if (response.ok && response.data) {
143
- // Future-proofing: if API becomes public, parse and return positions.
144
- return {
145
- ok: true,
146
- source: "careers.pinduoduo.com",
147
- query: payload,
148
- page,
149
- page_size: pageSize,
150
- total: 0,
151
- positions: [],
152
- note: "Position data returned by server but field mapping not yet implemented — please file an issue.",
153
- };
170
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60).toLowerCase();
171
+ const includeIntern = opts.recruitType === "intern";
172
+ // Walk upstream until we have enough filtered rows to satisfy the requested page.
173
+ const collected = [];
174
+ let upstreamTotal = 0;
175
+ const need = page * pageSize;
176
+ const maxUpstreamPages = 10; // safety cap (10 pages × 10 = 100 positions)
177
+ const endpoints = includeIntern
178
+ ? ["/api/recruit/position/train/list"]
179
+ : ["/api/recruit/position/list"];
180
+ for (const path of endpoints) {
181
+ for (let p = 1; p <= maxUpstreamPages; p++) {
182
+ const response = await call(path, {
183
+ pageSize: UPSTREAM_PAGE_SIZE,
184
+ page: p,
185
+ });
186
+ if (!response.ok || !response.data) {
187
+ // Surface auth-style failures as ok:false.
188
+ if (collected.length === 0) {
189
+ return {
190
+ ok: false,
191
+ source: "careers.pinduoduo.com",
192
+ query: { pageSize, page, keyword: keyword || undefined, recruitType: opts.recruitType },
193
+ page,
194
+ page_size: pageSize,
195
+ total: 0,
196
+ positions: [],
197
+ message: response.message,
198
+ apply_at: GRAD_PAGE,
199
+ };
200
+ }
201
+ break;
202
+ }
203
+ upstreamTotal = Math.max(upstreamTotal, asNumber(response.data.total));
204
+ const batch = response.data.list ?? [];
205
+ if (!batch.length)
206
+ break;
207
+ for (const item of batch) {
208
+ if (!keyword) {
209
+ collected.push(item);
210
+ }
211
+ else {
212
+ const hay = `${item.name ?? ""} ${item.recruitTypeName ?? ""} ${item.jobName ?? ""} ${item.workLocationName ?? ""} ${item.jobDuty ?? ""}`.toLowerCase();
213
+ if (hay.includes(keyword))
214
+ collected.push(item);
215
+ }
216
+ }
217
+ // Continue paginating until we have enough or exhausted the upstream pool.
218
+ if (collected.length >= need && p * UPSTREAM_PAGE_SIZE >= upstreamTotal)
219
+ break;
220
+ if (batch.length < UPSTREAM_PAGE_SIZE)
221
+ break;
222
+ }
154
223
  }
155
- const isAuth = response.httpStatus === 401;
224
+ const start = (page - 1) * pageSize;
225
+ const slice = collected.slice(start, start + pageSize);
156
226
  return {
157
- ok: false,
227
+ ok: true,
158
228
  source: "careers.pinduoduo.com",
159
- query: payload,
229
+ query: { pageSize, page, keyword: keyword || undefined, recruitType: opts.recruitType },
160
230
  page,
161
231
  page_size: pageSize,
162
- total: 0,
163
- positions: [],
164
- message: isAuth
165
- ? "PDD position API requires a registered account login token (HTTP 401). " +
166
- "The endpoint POST /api/careers/api/recruit/position/queryPosition was reached " +
167
- "but rejected unauthenticated requests. Apply at: " + GRAD_PAGE
168
- : response.message,
232
+ total: keyword ? collected.length : upstreamTotal || collected.length,
233
+ positions: slice.map(summarizePosition),
169
234
  apply_at: GRAD_PAGE,
170
235
  };
171
236
  }
172
237
  // ---------- fetchAllPositions ----------
238
+ // Walks both grad (/list) and intern (/train/list) tracks until exhaustion.
173
239
  export async function fetchAllPositions(opts = {}) {
174
- // Delegates to searchPositions; inherits the auth stub.
175
- const result = await searchPositions(opts);
240
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 50));
241
+ const maxPages = Math.max(1, opts.maxPages ?? 20);
242
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60).toLowerCase();
243
+ const bucket = [];
244
+ const seen = new Set();
245
+ let total = 0;
246
+ for (const path of ["/api/recruit/position/list", "/api/recruit/position/train/list"]) {
247
+ let pathTotal = 0;
248
+ for (let p = 1; p <= maxPages; p++) {
249
+ const response = await call(path, {
250
+ pageSize: UPSTREAM_PAGE_SIZE,
251
+ page: p,
252
+ });
253
+ if (!response.ok || !response.data)
254
+ break;
255
+ pathTotal = Math.max(pathTotal, asNumber(response.data.total));
256
+ const batch = response.data.list ?? [];
257
+ if (!batch.length)
258
+ break;
259
+ for (const item of batch) {
260
+ const id = item.id ?? "";
261
+ if (!id || seen.has(id))
262
+ continue;
263
+ seen.add(id);
264
+ if (keyword) {
265
+ const hay = `${item.name ?? ""} ${item.jobName ?? ""} ${item.recruitTypeName ?? ""} ${item.workLocationName ?? ""} ${item.jobDuty ?? ""}`.toLowerCase();
266
+ if (!hay.includes(keyword))
267
+ continue;
268
+ }
269
+ bucket.push(item);
270
+ }
271
+ if (batch.length < UPSTREAM_PAGE_SIZE)
272
+ break;
273
+ }
274
+ total += pathTotal;
275
+ if (bucket.length >= pageSize * maxPages)
276
+ break;
277
+ }
176
278
  return {
177
- ok: result.ok,
279
+ ok: true,
178
280
  source: "careers.pinduoduo.com",
179
- total: result.total ?? 0,
180
- fetched: 0,
181
- positions: [],
182
- message: result.message,
281
+ total: keyword ? bucket.length : total || bucket.length,
282
+ fetched: bucket.length,
283
+ positions: bucket.map(summarizePosition),
183
284
  apply_at: GRAD_PAGE,
184
285
  };
185
286
  }
186
287
  // ---------- fetchPositionDetail ----------
288
+ // There is no per-position detail endpoint; the list response already carries
289
+ // the full jobDuty. We scan grad + intern lists for the matching uuid.
187
290
  export async function fetchPositionDetail(postId) {
188
291
  const id = (postId ?? "").trim();
189
292
  if (!id) {
@@ -193,28 +296,55 @@ export async function fetchPositionDetail(postId) {
193
296
  message: "post_id is required",
194
297
  };
195
298
  }
196
- // There is no public detail endpoint; queryPosition (which lists with detail)
197
- // also requires auth. Return a stub with the closest apply URL.
299
+ for (const path of ["/api/recruit/position/list", "/api/recruit/position/train/list"]) {
300
+ for (let p = 1; p <= 10; p++) {
301
+ const response = await call(path, {
302
+ pageSize: UPSTREAM_PAGE_SIZE,
303
+ page: p,
304
+ });
305
+ if (!response.ok || !response.data)
306
+ break;
307
+ const batch = response.data.list ?? [];
308
+ if (!batch.length)
309
+ break;
310
+ const hit = batch.find((row) => row.id === id);
311
+ if (hit) {
312
+ const summary = summarizePosition(hit);
313
+ return {
314
+ ok: true,
315
+ source: "careers.pinduoduo.com",
316
+ ...summary,
317
+ description: hit.jobDuty ?? "",
318
+ requirements: "",
319
+ work_cities: hit.workLocationName ? [hit.workLocationName] : [],
320
+ release_time: hit.releaseTime
321
+ ? new Date(hit.releaseTime).toISOString().slice(0, 10)
322
+ : "",
323
+ code: hit.code ?? "",
324
+ labels: hit.labelList ?? [],
325
+ };
326
+ }
327
+ if (batch.length < UPSTREAM_PAGE_SIZE)
328
+ break;
329
+ }
330
+ }
198
331
  return {
199
332
  ok: false,
200
333
  source: "careers.pinduoduo.com",
201
334
  post_id: id,
202
- description: "",
203
- requirements: "",
204
- work_cities: [],
205
335
  apply_url: GRAD_PAGE,
206
- message: "PDD position detail API requires account auth (HTTP 401). " +
207
- `Visit ${GRAD_PAGE} to browse positions.`,
336
+ message: "Position not found in current public list. The position may have been " +
337
+ "closed or moved to an auth-only track. " +
338
+ `Visit ${GRAD_PAGE} to browse current openings.`,
208
339
  };
209
340
  }
210
- // ---------- fetchDictionaries ----------
211
- // The /api/careers/api/campus/careers/enum endpoint is publicly accessible
212
- // but returns an empty enumMap. Recruit types are documented in the header
213
- // comment above (extracted from the JS bundle).
214
341
  export async function fetchDictionaries() {
215
- const response = await call("/api/campus/careers/enum", {});
342
+ const [enumResp, typeResp] = await Promise.all([
343
+ call("/api/campus/careers/enum", {}),
344
+ call("/api/recruit/position/detail/type", {}),
345
+ ]);
216
346
  return {
217
- ok: response.ok,
347
+ ok: enumResp.ok || typeResp.ok,
218
348
  source: "careers.pinduoduo.com",
219
349
  api_host: API_ROOT,
220
350
  verified_at: new Date().toISOString(),
@@ -226,12 +356,14 @@ export async function fetchDictionaries() {
226
356
  { value: "technical_session", label: "技术专场", note: "Technical session / R&D" },
227
357
  { value: "warehouse_trainee", label: "仓储类管培生", note: "Warehouse management trainee" },
228
358
  { value: "yunhu_plan", label: "云弧计划", note: "LLM/AI elite talent program (≈ ByteDance 顶尖实习)" },
359
+ { value: "intern", label: "实习生", note: "Intern (2027届)" },
229
360
  ],
361
+ job_types: (typeResp.data ?? []).map((t) => ({ value: t.job ?? "", label: t.jobName ?? "" })),
230
362
  current_batch: "2026届春季校招 / 2027届研发实习生",
231
363
  grad_page: GRAD_PAGE,
232
364
  intern_page: INTERN_PAGE,
233
- enum_map: response.data?.enumMap ?? {},
234
- note: "Position search requires account login; dict shows static bundle data + live enum endpoint.",
365
+ enum_map: enumResp.data?.enumMap ?? {},
366
+ note: "Position list is public via /api/recruit/position/list see header comment.",
235
367
  };
236
368
  }
237
369
  function formatNotice(item) {
@@ -375,11 +507,11 @@ export async function findNoticesByQuestion(question, opts = {}) {
375
507
  };
376
508
  }
377
509
  // ---------- matchResume ----------
378
- // Mirrors bytedance.ts/tencent.ts algo but acknowledges that position data
379
- // is unavailable. Returns extracted signals and the apply URL for the user
380
- // to manually browse matching positions.
510
+ // Pulls every public position (grad + intern), scores each against the resume's
511
+ // extracted terms and city preferences, and returns the top-N matches.
381
512
  export async function matchResume(text, opts = {}) {
382
513
  const { terms, cities } = extractResumeSignals(text ?? "");
514
+ const topN = Math.max(1, opts.topN ?? 5);
383
515
  if (!terms.length) {
384
516
  return {
385
517
  ok: false,
@@ -388,46 +520,63 @@ export async function matchResume(text, opts = {}) {
388
520
  preview: (text ?? "").slice(0, 120),
389
521
  };
390
522
  }
391
- // We cannot search positions (auth required), so we return a best-effort
392
- // recommendation: which PDD recruit type best matches the resume signals,
393
- // with a direct link to the appropriate page.
394
- const isTechnical = terms.some((t) => ["python", "java", "go", "golang", "c++", "cpp", "rust", "typescript",
395
- "javascript", "react", "vue", "spring", "pytorch", "tensorflow",
396
- "kubernetes", "docker", "linux", "llm", "rag", "transformer",
397
- "后端", "前端", "算法", "推荐", "搜索", "大模型", "测试", "运维", "安全"].includes(t.toLowerCase()));
398
- const isLLM = terms.some((t) => ["llm", "rag", "transformer", "bert", "gpt", "diffusion", "大模型", "强化学习", "多模态"].includes(t.toLowerCase()));
399
- const suggested = [];
400
- if (isLLM) {
401
- suggested.push({
402
- recruitType: "yunhu_plan",
403
- label: "云弧计划 (LLM Elite)",
404
- url: `${GRAD_PAGE}?recruitType=yunhu_plan`,
405
- reason: "LLM/AI signals detected: " + terms.filter((t) => ["llm", "rag", "transformer", "bert", "gpt", "diffusion", "大模型"].includes(t.toLowerCase())).slice(0, 3).join(", "),
406
- });
523
+ const all = await fetchAllPositions({ maxPages: 10, pageSize: UPSTREAM_PAGE_SIZE });
524
+ if (!all.ok) {
525
+ return {
526
+ ok: false,
527
+ source: "careers.pinduoduo.com",
528
+ extracted_terms: terms,
529
+ city_preferences: cities,
530
+ matches: [],
531
+ message: all.message ?? "failed to fetch positions",
532
+ apply_at: GRAD_PAGE,
533
+ };
407
534
  }
408
- if (isTechnical) {
409
- suggested.push({
410
- recruitType: "technical_session",
411
- label: "技术专场 (R&D)",
412
- url: `${GRAD_PAGE}?recruitType=technical_session`,
413
- reason: "Technical signals detected: " + terms.slice(0, 3).join(", "),
414
- });
535
+ // Score against the raw positions we already have via fetchAllPositions's caller.
536
+ // Re-pull as raw to retain jobDuty for scoring.
537
+ const rawCorpus = [];
538
+ for (const path of ["/api/recruit/position/list", "/api/recruit/position/train/list"]) {
539
+ for (let p = 1; p <= 10; p++) {
540
+ const response = await call(path, {
541
+ pageSize: UPSTREAM_PAGE_SIZE,
542
+ page: p,
543
+ });
544
+ if (!response.ok || !response.data)
545
+ break;
546
+ const batch = response.data.list ?? [];
547
+ if (!batch.length)
548
+ break;
549
+ rawCorpus.push(...batch);
550
+ if (batch.length < UPSTREAM_PAGE_SIZE)
551
+ break;
552
+ }
415
553
  }
416
- suggested.push({
417
- recruitType: "headquarters",
418
- label: "管培生 (Management Trainee)",
419
- url: `${GRAD_PAGE}?recruitType=headquarters`,
420
- reason: "General campus track",
421
- });
554
+ const scored = [];
555
+ for (const raw of rawCorpus) {
556
+ const hay = `${raw.name ?? ""} ${raw.jobName ?? ""} ${raw.recruitTypeName ?? ""} ${raw.jobDuty ?? ""}`.toLowerCase();
557
+ const matched = terms.filter((t) => hay.includes(t.toLowerCase()));
558
+ const overlap = scoreOverlap(hay, terms, cities).score;
559
+ const city_match = cities.length === 0 ? false :
560
+ cities.some((c) => (raw.workLocationName ?? raw.workLocation ?? "").includes(c));
561
+ if (!matched.length && !city_match)
562
+ continue;
563
+ const score = overlap * 100 + matched.length * 5 + (city_match ? 8 : 0);
564
+ scored.push({ raw, score, matched_terms: matched, city_match });
565
+ }
566
+ scored.sort((a, b) => b.score - a.score);
567
+ const matches = scored.slice(0, topN).map((s) => ({
568
+ ...summarizePosition(s.raw),
569
+ score: s.score,
570
+ matched_terms: s.matched_terms,
571
+ city_match: s.city_match,
572
+ }));
422
573
  return {
423
574
  ok: true,
424
575
  source: "careers.pinduoduo.com",
425
576
  extracted_terms: terms,
426
577
  city_preferences: cities,
427
- note: "PDD position search API requires account auth — cannot rank individual JDs. " +
428
- "Suggested tracks are derived from resume signals against static recruit-type taxonomy.",
429
- suggested_tracks: suggested.slice(0, opts.topN ?? 3),
430
- matches: [],
578
+ total_scanned: rawCorpus.length,
579
+ matches,
431
580
  apply_at: GRAD_PAGE,
432
581
  };
433
582
  }