@ha7ch/job-pro 1.0.93
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/adapter.js +17 -0
- package/dist/agibot.js +399 -0
- package/dist/alibaba.js +509 -0
- package/dist/antgroup.js +397 -0
- package/dist/apply.js +1373 -0
- package/dist/baichuan.js +49 -0
- package/dist/baidu.js +452 -0
- package/dist/bilibili.js +455 -0
- package/dist/byd.js +412 -0
- package/dist/bytedance.js +619 -0
- package/dist/cainiao.js +56 -0
- package/dist/cambricon.js +33 -0
- package/dist/cdp.js +237 -0
- package/dist/cicc.js +56 -0
- package/dist/coverage.js +60 -0
- package/dist/deepseek.js +25 -0
- package/dist/didi.js +381 -0
- package/dist/feishu.js +577 -0
- package/dist/galaxyuniversal.js +24 -0
- package/dist/geely.js +35 -0
- package/dist/greenhouse.js +432 -0
- package/dist/hikvision.js +58 -0
- package/dist/horizonrobotics.js +46 -0
- package/dist/hoyoverse.js +26 -0
- package/dist/huawei.js +537 -0
- package/dist/iflytek.js +380 -0
- package/dist/index.js +1828 -0
- package/dist/iqiyi.js +494 -0
- package/dist/jd.js +559 -0
- package/dist/kuaishou.js +496 -0
- package/dist/lever.js +455 -0
- package/dist/liauto.js +393 -0
- package/dist/liepin.js +357 -0
- package/dist/lilith.js +300 -0
- package/dist/megvii.js +27 -0
- package/dist/meituan.js +633 -0
- package/dist/memory.js +76 -0
- package/dist/mihoyo.js +308 -0
- package/dist/minimax.js +32 -0
- package/dist/moka.js +473 -0
- package/dist/moonshot.js +24 -0
- package/dist/netease.js +424 -0
- package/dist/nio.js +24 -0
- package/dist/oppo.js +285 -0
- package/dist/pdd.js +614 -0
- package/dist/pingan.js +493 -0
- package/dist/sensetime.js +51 -0
- package/dist/sf.js +310 -0
- package/dist/stepfun.js +24 -0
- package/dist/tencent.js +770 -0
- package/dist/trip.js +396 -0
- package/dist/unitree.js +418 -0
- package/dist/vivo.js +361 -0
- package/dist/webank.js +55 -0
- package/dist/wecruit.js +438 -0
- package/dist/weibo.js +337 -0
- package/dist/weride.js +29 -0
- package/dist/xiaohongshu.js +480 -0
- package/dist/xiaomi.js +529 -0
- package/dist/xpeng.js +34 -0
- package/dist/zerooneai.js +42 -0
- package/dist/zhipu.js +478 -0
- package/extension/README.md +79 -0
- package/extension/background.js +177 -0
- package/extension/manifest.json +55 -0
- package/extension/popup.html +37 -0
- package/extension/popup.js +54 -0
- package/package.json +61 -0
package/dist/pdd.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
// Thin client for 拼多多 (PDD / Pinduoduo) campus-recruiting portal at careers.pinduoduo.com.
|
|
2
|
+
//
|
|
3
|
+
// ============================================================
|
|
4
|
+
// API discovery (re-probed 2026-05; bundles under pfile.pddpic.com/ei-pub):
|
|
5
|
+
//
|
|
6
|
+
// The frontend is a Next.js SPA hosted at careers.pinduoduo.com/campus/.
|
|
7
|
+
// All XHR calls go through a sourceSDK.wrappedRequest helper (module 96211)
|
|
8
|
+
// that prepends the prefix Fh = "api/careers/" to every relative URL, giving:
|
|
9
|
+
//
|
|
10
|
+
// https://careers.pinduoduo.com/api/careers/<relative-url>
|
|
11
|
+
//
|
|
12
|
+
// ---- PUBLIC endpoints (no auth required, verified 2026-05) ----
|
|
13
|
+
//
|
|
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).
|
|
22
|
+
//
|
|
23
|
+
// POST /api/careers/api/recruit/position/train/list
|
|
24
|
+
// Same shape as /list but only intern (实习生 / 2027届研发实习生) positions.
|
|
25
|
+
//
|
|
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:"区域业务"}]
|
|
32
|
+
//
|
|
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 }
|
|
37
|
+
//
|
|
38
|
+
// POST /api/careers/api/campus/moment/detail
|
|
39
|
+
// Body: { guid:<string> }
|
|
40
|
+
// Response: { success:true, result:{ guid, momentTitle, momentContent:<html> } }
|
|
41
|
+
//
|
|
42
|
+
// POST /api/careers/api/campus/trip/list (校招行程 / on-campus events)
|
|
43
|
+
// Body: {} → { tripList:null|[...], explainTrip:{ recruitmentTripType, tripContent:<html> } }
|
|
44
|
+
//
|
|
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
|
|
55
|
+
//
|
|
56
|
+
// ---- RecruitType taxonomy (from JS bundle, probed 2026-05) ----
|
|
57
|
+
// headquarters = 管培生 (headquarters management trainee)
|
|
58
|
+
// region = 区域业务管培生 (regional business management trainee)
|
|
59
|
+
// technical_session = 技术专场 (technical session / R&D)
|
|
60
|
+
// warehouse_trainee = 仓储类管培生 (warehouse management trainee)
|
|
61
|
+
// yunhu_plan = 云弧计划 (LLM / elite tech talent program)
|
|
62
|
+
// intern = 实习生 (intern, 2027届)
|
|
63
|
+
//
|
|
64
|
+
// ---- Campus batches active at time of probing (2026-05) ----
|
|
65
|
+
// 2026届春季校招 (grad, 2026 batch spring) → route /grad
|
|
66
|
+
// 2027届研发实习生 (intern, 2027 batch) → route /intern
|
|
67
|
+
// 云弧计划 (elite LLM/AI talent program, 2026) → route /grad?recruitType=yunhu_plan
|
|
68
|
+
//
|
|
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
|
+
// ============================================================
|
|
81
|
+
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
82
|
+
export { checkResume };
|
|
83
|
+
const API_ROOT = "https://careers.pinduoduo.com/api/careers";
|
|
84
|
+
const CAMPUS_PAGE = "https://careers.pinduoduo.com/campus";
|
|
85
|
+
const GRAD_PAGE = `${CAMPUS_PAGE}/grad`;
|
|
86
|
+
const INTERN_PAGE = `${CAMPUS_PAGE}/intern`;
|
|
87
|
+
const DEFAULT_HEADERS = {
|
|
88
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
89
|
+
Accept: "application/json, text/plain, */*",
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
Referer: GRAD_PAGE,
|
|
92
|
+
Origin: "https://careers.pinduoduo.com",
|
|
93
|
+
};
|
|
94
|
+
async function call(path, body) {
|
|
95
|
+
const url = `${API_ROOT}${path}`;
|
|
96
|
+
let response;
|
|
97
|
+
try {
|
|
98
|
+
response = await fetch(url, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: DEFAULT_HEADERS,
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
let errBody = "";
|
|
112
|
+
try {
|
|
113
|
+
errBody = await response.text();
|
|
114
|
+
}
|
|
115
|
+
catch (_) {
|
|
116
|
+
// ignore
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
httpStatus: response.status,
|
|
121
|
+
message: `HTTP ${response.status}: ${response.statusText}${errBody ? " — " + errBody.slice(0, 120) : ""}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
let payload;
|
|
125
|
+
try {
|
|
126
|
+
payload = (await response.json());
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : String(err)}` };
|
|
130
|
+
}
|
|
131
|
+
const ok = payload.success === true;
|
|
132
|
+
return {
|
|
133
|
+
ok,
|
|
134
|
+
data: payload.result,
|
|
135
|
+
message: ok ? "ok" : (payload.errorMsg || `errorCode ${payload.errorCode}`),
|
|
136
|
+
};
|
|
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
|
+
}
|
|
162
|
+
// ---------- searchPositions ----------
|
|
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.
|
|
167
|
+
export async function searchPositions(opts = {}) {
|
|
168
|
+
const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
|
|
169
|
+
const page = Math.max(1, opts.page ?? 1);
|
|
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
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const start = (page - 1) * pageSize;
|
|
225
|
+
const slice = collected.slice(start, start + pageSize);
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
source: "careers.pinduoduo.com",
|
|
229
|
+
query: { pageSize, page, keyword: keyword || undefined, recruitType: opts.recruitType },
|
|
230
|
+
page,
|
|
231
|
+
page_size: pageSize,
|
|
232
|
+
total: keyword ? collected.length : upstreamTotal || collected.length,
|
|
233
|
+
positions: slice.map(summarizePosition),
|
|
234
|
+
apply_at: GRAD_PAGE,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
// ---------- fetchAllPositions ----------
|
|
238
|
+
// Walks both grad (/list) and intern (/train/list) tracks until exhaustion.
|
|
239
|
+
export async function fetchAllPositions(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
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
source: "careers.pinduoduo.com",
|
|
281
|
+
total: keyword ? bucket.length : total || bucket.length,
|
|
282
|
+
fetched: bucket.length,
|
|
283
|
+
positions: bucket.map(summarizePosition),
|
|
284
|
+
apply_at: GRAD_PAGE,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
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.
|
|
290
|
+
export async function fetchPositionDetail(postId) {
|
|
291
|
+
const id = (postId ?? "").trim();
|
|
292
|
+
if (!id) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
source: "careers.pinduoduo.com",
|
|
296
|
+
message: "post_id is required",
|
|
297
|
+
};
|
|
298
|
+
}
|
|
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
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
source: "careers.pinduoduo.com",
|
|
334
|
+
post_id: id,
|
|
335
|
+
apply_url: GRAD_PAGE,
|
|
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.`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
export async function fetchDictionaries() {
|
|
342
|
+
const [enumResp, typeResp] = await Promise.all([
|
|
343
|
+
call("/api/campus/careers/enum", {}),
|
|
344
|
+
call("/api/recruit/position/detail/type", {}),
|
|
345
|
+
]);
|
|
346
|
+
return {
|
|
347
|
+
ok: enumResp.ok || typeResp.ok,
|
|
348
|
+
source: "careers.pinduoduo.com",
|
|
349
|
+
api_host: API_ROOT,
|
|
350
|
+
verified_at: new Date().toISOString(),
|
|
351
|
+
campus_only: true,
|
|
352
|
+
// Static taxonomy extracted from JS bundle (2026-05)
|
|
353
|
+
recruit_types: [
|
|
354
|
+
{ value: "headquarters", label: "管培生", note: "Headquarters management trainee" },
|
|
355
|
+
{ value: "region", label: "区域业务管培生", note: "Regional business management trainee" },
|
|
356
|
+
{ value: "technical_session", label: "技术专场", note: "Technical session / R&D" },
|
|
357
|
+
{ value: "warehouse_trainee", label: "仓储类管培生", note: "Warehouse management trainee" },
|
|
358
|
+
{ value: "yunhu_plan", label: "云弧计划", note: "LLM/AI elite talent program (≈ ByteDance 顶尖实习)" },
|
|
359
|
+
{ value: "intern", label: "实习生", note: "Intern (2027届)" },
|
|
360
|
+
],
|
|
361
|
+
job_types: (typeResp.data ?? []).map((t) => ({ value: t.job ?? "", label: t.jobName ?? "" })),
|
|
362
|
+
current_batch: "2026届春季校招 / 2027届研发实习生",
|
|
363
|
+
grad_page: GRAD_PAGE,
|
|
364
|
+
intern_page: INTERN_PAGE,
|
|
365
|
+
enum_map: enumResp.data?.enumMap ?? {},
|
|
366
|
+
note: "Position list is public via /api/recruit/position/list — see header comment.",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function formatNotice(item) {
|
|
370
|
+
const publish_time = item.publishDate
|
|
371
|
+
? new Date(item.publishDate).toISOString().replace("T", " ").slice(0, 10)
|
|
372
|
+
: "";
|
|
373
|
+
return {
|
|
374
|
+
id: item.guid ?? "",
|
|
375
|
+
title: item.momentTitle ?? "",
|
|
376
|
+
publish_time,
|
|
377
|
+
tags: item.momentLabel ?? [],
|
|
378
|
+
top: Boolean(item.topFlag),
|
|
379
|
+
detail_url: item.guid
|
|
380
|
+
? `${CAMPUS_PAGE}/announcements?guid=${item.guid}`
|
|
381
|
+
: `${CAMPUS_PAGE}/announcements`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
export async function listNotices() {
|
|
385
|
+
const response = await call("/api/campus/moment/list", {
|
|
386
|
+
pageSize: 50,
|
|
387
|
+
page: 1,
|
|
388
|
+
});
|
|
389
|
+
if (!response.ok || !response.data) {
|
|
390
|
+
return { ok: false, source: "careers.pinduoduo.com", message: response.message, notices: [] };
|
|
391
|
+
}
|
|
392
|
+
const items = response.data.list ?? [];
|
|
393
|
+
return {
|
|
394
|
+
ok: true,
|
|
395
|
+
source: "careers.pinduoduo.com",
|
|
396
|
+
total: response.data.total ?? items.length,
|
|
397
|
+
count: items.length,
|
|
398
|
+
notices: items.map(formatNotice),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
export async function getNotice(noticeId) {
|
|
402
|
+
const guid = (noticeId ?? "").trim();
|
|
403
|
+
if (!guid)
|
|
404
|
+
return { ok: false, source: "careers.pinduoduo.com", message: "notice_id (guid) is required" };
|
|
405
|
+
const response = await call("/api/campus/moment/detail", { guid });
|
|
406
|
+
if (!response.ok || !response.data) {
|
|
407
|
+
return { ok: false, source: "careers.pinduoduo.com", message: response.message };
|
|
408
|
+
}
|
|
409
|
+
const raw = response.data;
|
|
410
|
+
const base = formatNotice(raw);
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
source: "careers.pinduoduo.com",
|
|
414
|
+
...base,
|
|
415
|
+
content_html: raw.momentContent ?? "",
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
// ---------- findNoticesByQuestion ----------
|
|
419
|
+
function tokenizeQuestion(text) {
|
|
420
|
+
const out = [];
|
|
421
|
+
const seen = new Set();
|
|
422
|
+
const trimmed = (text ?? "").trim();
|
|
423
|
+
if (!trimmed)
|
|
424
|
+
return out;
|
|
425
|
+
for (const m of trimmed.match(/[A-Za-z0-9]{2,}/g) ?? []) {
|
|
426
|
+
const k = m.toLowerCase();
|
|
427
|
+
if (!seen.has(k)) {
|
|
428
|
+
seen.add(k);
|
|
429
|
+
out.push(k);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
for (const run of trimmed.match(/[一-鿿]+/g) ?? []) {
|
|
433
|
+
for (let i = 0; i < run.length - 1; i++) {
|
|
434
|
+
const bigram = run.slice(i, i + 2);
|
|
435
|
+
if (!seen.has(bigram)) {
|
|
436
|
+
seen.add(bigram);
|
|
437
|
+
out.push(bigram);
|
|
438
|
+
}
|
|
439
|
+
if (out.length >= 40)
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
function parseQuestionTime(value) {
|
|
446
|
+
if (!value)
|
|
447
|
+
return undefined;
|
|
448
|
+
const v = value.trim();
|
|
449
|
+
for (const candidate of [v, v.replace(" ", "T"), `${v}T00:00:00`]) {
|
|
450
|
+
const ts = Date.parse(candidate);
|
|
451
|
+
if (!Number.isNaN(ts))
|
|
452
|
+
return ts;
|
|
453
|
+
}
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
export async function findNoticesByQuestion(question, opts = {}) {
|
|
457
|
+
const listing = await listNotices();
|
|
458
|
+
if (!listing.ok) {
|
|
459
|
+
return { ok: false, source: "careers.pinduoduo.com", message: listing.message, matches: [] };
|
|
460
|
+
}
|
|
461
|
+
const cutoff = parseQuestionTime(opts.questionTime);
|
|
462
|
+
const tokens = tokenizeQuestion(question);
|
|
463
|
+
const topK = Math.max(1, opts.topK ?? 3);
|
|
464
|
+
const scored = [];
|
|
465
|
+
for (const notice of listing.notices) {
|
|
466
|
+
const haystack = `${notice.title} ${notice.tags.join(" ")}`.toLowerCase();
|
|
467
|
+
const hits = tokens.filter((t) => haystack.includes(t)).length;
|
|
468
|
+
if (!hits)
|
|
469
|
+
continue;
|
|
470
|
+
let score = hits * 10;
|
|
471
|
+
const publishedAt = parseQuestionTime(notice.publish_time);
|
|
472
|
+
if (cutoff !== undefined && publishedAt !== undefined) {
|
|
473
|
+
if (publishedAt <= cutoff) {
|
|
474
|
+
const monthsBefore = (cutoff - publishedAt) / (86_400_000 * 30);
|
|
475
|
+
score += Math.max(0, 5 - monthsBefore);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
score -= 1;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
scored.push({ score, notice });
|
|
482
|
+
}
|
|
483
|
+
scored.sort((a, b) => b.score - a.score);
|
|
484
|
+
const stripHtml = (html) => html
|
|
485
|
+
.replace(/<[^>]+>/g, "")
|
|
486
|
+
.replace(/ /g, " ")
|
|
487
|
+
.replace(/&/g, "&")
|
|
488
|
+
.replace(/</g, "<")
|
|
489
|
+
.replace(/>/g, ">")
|
|
490
|
+
.replace(/"/g, '"')
|
|
491
|
+
.replace(/\s+/g, " ")
|
|
492
|
+
.trim()
|
|
493
|
+
.slice(0, 400);
|
|
494
|
+
const matches = [];
|
|
495
|
+
for (const { notice } of scored.slice(0, topK)) {
|
|
496
|
+
const full = await getNotice(notice.id);
|
|
497
|
+
const excerpt = full.ok ? stripHtml(full.content_html ?? "") : "";
|
|
498
|
+
matches.push({ ...notice, excerpt });
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
ok: true,
|
|
502
|
+
source: "careers.pinduoduo.com",
|
|
503
|
+
question,
|
|
504
|
+
question_time: opts.questionTime,
|
|
505
|
+
matched_tokens: tokens,
|
|
506
|
+
matches,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// ---------- matchResume ----------
|
|
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.
|
|
512
|
+
export async function matchResume(text, opts = {}) {
|
|
513
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
514
|
+
const topN = Math.max(1, opts.topN ?? 5);
|
|
515
|
+
if (!terms.length) {
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
source: "careers.pinduoduo.com",
|
|
519
|
+
message: "could not extract any technical signals from the text",
|
|
520
|
+
preview: (text ?? "").slice(0, 120),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
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
|
+
};
|
|
534
|
+
}
|
|
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
|
+
}
|
|
553
|
+
}
|
|
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
|
+
}));
|
|
573
|
+
return {
|
|
574
|
+
ok: true,
|
|
575
|
+
source: "careers.pinduoduo.com",
|
|
576
|
+
extracted_terms: terms,
|
|
577
|
+
city_preferences: cities,
|
|
578
|
+
total_scanned: rawCorpus.length,
|
|
579
|
+
matches,
|
|
580
|
+
apply_at: GRAD_PAGE,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
export { extractResumeSignals, scoreOverlap };
|
|
584
|
+
import { buildBespokeApplySchema as _buildBespokeApplySchema_pdd } from "./apply.js";
|
|
585
|
+
export async function fetchApplicationSchema(postId) {
|
|
586
|
+
const id = (postId ?? "").trim();
|
|
587
|
+
if (!id)
|
|
588
|
+
return { ok: false, source: "careers.pinduoduo.com", message: "post_id is required" };
|
|
589
|
+
let title = "";
|
|
590
|
+
let applyUrl = "https://careers.pinduoduo.com";
|
|
591
|
+
try {
|
|
592
|
+
const detail = (await fetchPositionDetail(id));
|
|
593
|
+
if (detail?.ok === false) {
|
|
594
|
+
return { ok: false, source: "careers.pinduoduo.com", message: detail.message ?? "post not found" };
|
|
595
|
+
}
|
|
596
|
+
title = detail?.title ?? "";
|
|
597
|
+
if (detail?.apply_url)
|
|
598
|
+
applyUrl = detail.apply_url;
|
|
599
|
+
}
|
|
600
|
+
catch { }
|
|
601
|
+
return {
|
|
602
|
+
ok: true,
|
|
603
|
+
schema: _buildBespokeApplySchema_pdd({
|
|
604
|
+
source: "careers.pinduoduo.com",
|
|
605
|
+
postId: id,
|
|
606
|
+
jobTitle: title,
|
|
607
|
+
applyUrl,
|
|
608
|
+
submitEndpoint: "https://careers.pinduoduo.com/api/recruit/v1/position/apply",
|
|
609
|
+
submitKind: "multipart-session",
|
|
610
|
+
endpointVerified: true,
|
|
611
|
+
submitNotes: "PDD — POST /api/recruit/v1/position/apply with session cookie. Endpoint anon-probed → returns {error_code: 40003} (real business error, not 404) — route confirmed; body shape still needs validation.",
|
|
612
|
+
}),
|
|
613
|
+
};
|
|
614
|
+
}
|