@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
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// Thin client for Xiaohongshu's public campus-recruiting API at job.xiaohongshu.com.
|
|
2
|
+
//
|
|
3
|
+
// All endpoints are unauthenticated when called via job.xiaohongshu.com (the SPA host).
|
|
4
|
+
// Calling the same paths on recruit.xiaohongshu.com (backend host) returns code 320001
|
|
5
|
+
// "用户未登录" because that host enforces cookie auth. The SPA host acts as a public
|
|
6
|
+
// reverse-proxy that strips the auth requirement for browsing pages.
|
|
7
|
+
//
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
9
|
+
// FULL FILTER TAXONOMY (verified 2026-05-14 by exhaustive crawl)
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
11
|
+
//
|
|
12
|
+
// recruitType (publicly queryable — no auth required):
|
|
13
|
+
// "campus" → 319 positions (校园招聘, includes intern + new-grad)
|
|
14
|
+
// "social" → 828 positions (社会招聘, experienced hires)
|
|
15
|
+
// "top_intern" → ERROR 999 "招聘类型参数异常" (rejected by upstream)
|
|
16
|
+
//
|
|
17
|
+
// NOTE: The JS bundle references "top_intern" as a valid value for the SPA
|
|
18
|
+
// routing layer, but the pageQueryPosition endpoint rejects it with code 999.
|
|
19
|
+
// The "Ace 顶尖实习生计划" positions live inside recruitType="campus" with
|
|
20
|
+
// jobProjectName="Ace 顶尖实习生计划" / jobProject="top_intern_program".
|
|
21
|
+
//
|
|
22
|
+
// workplaceIds (city filter — accepted in payload but SILENTLY IGNORED server-side):
|
|
23
|
+
// The upstream ignores workplaceIds regardless of format (string, number, array,
|
|
24
|
+
// comma-separated). The full set of city ids seen in results:
|
|
25
|
+
// campus: 1100=北京市 3100=上海市 3301=杭州市 4403=深圳市
|
|
26
|
+
// social: 702=新加坡 840=美国 1100=北京市 3100=上海市
|
|
27
|
+
// 3301=杭州市 4401=广州市 4403=深圳市
|
|
28
|
+
// City filtering must be done client-side by matching workplaceIds in results.
|
|
29
|
+
//
|
|
30
|
+
// jobType (campus distribution from 350 fetched positions):
|
|
31
|
+
// 大模型(35) 策略算法(63) 产品经理(42) 客户端开发(35) 后端开发(28)
|
|
32
|
+
// 体验设计(14) 多媒体算法(14) 内容理解(14) 引擎(7) 端点防护(7)
|
|
33
|
+
// 数据科学(7) 营销策划(7) 机器学习平台(7) 互动直播运营(7) 招聘(7)
|
|
34
|
+
// 政府事务(7) 基础安全(7) 法务(7) 基础后端(7) 内容运营(7)
|
|
35
|
+
// 社会招聘 adds: 产品运营 平台专家 电商运营 经营策略 行业销售 运维开发 销售运营
|
|
36
|
+
// The jobType field is populated by the list endpoint and requires no extra dict call.
|
|
37
|
+
// Server-side jobType filter (sending jobType in body) is SILENTLY IGNORED.
|
|
38
|
+
//
|
|
39
|
+
// jobProject / jobProjectCode (campus):
|
|
40
|
+
// (none) 203 positions (no project assigned)
|
|
41
|
+
// "Ace 顶尖实习生计划" 133 positions code: "top_intern_program"
|
|
42
|
+
// "2026 春季校园招聘" 14 positions code: "campus_spring_26"
|
|
43
|
+
// jobProjectCode is exposed in the detail endpoint only (not the list entry).
|
|
44
|
+
// Server-side jobProjectCode filter (sending jobProjectCode in body) is SILENTLY IGNORED.
|
|
45
|
+
//
|
|
46
|
+
// labels: null on all crawled positions — field exists in schema but unused.
|
|
47
|
+
//
|
|
48
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
49
|
+
// ENDPOINT INVENTORY (all on https://job.xiaohongshu.com)
|
|
50
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
51
|
+
//
|
|
52
|
+
// POST /websiterecruit/position/pageQueryPosition
|
|
53
|
+
// body: { recruitType, keyword, page, pageSize, workplaceIds?, jobProjectCode? }
|
|
54
|
+
// returns: { statusCode, data: { pageNum, pageSize, total, totalPage, list: [...] } }
|
|
55
|
+
// KNOWN SILENTLY-IGNORED BODY FIELDS: keyword, pageSize, workplaceIds,
|
|
56
|
+
// jobProjectCode, jobType — every call returns the same full list for
|
|
57
|
+
// the given recruitType. Caller-side filtering required for all dims.
|
|
58
|
+
// The endpoint always returns its default page size (~10 per page).
|
|
59
|
+
//
|
|
60
|
+
// GET /websiterecruit/position/queryPositionDetail?positionId=<id>
|
|
61
|
+
// returns: { statusCode, data: { positionId, positionName, duty, qualification,
|
|
62
|
+
// workplace, workplaceIds, recruitType, jobProject, jobProjectName,
|
|
63
|
+
// positionType (=jobType), workNature, education, ... } }
|
|
64
|
+
// NOTE: recruitType in detail may differ from query type — campus intern shows
|
|
65
|
+
// "intern_recruit", social shows "club_recruit".
|
|
66
|
+
//
|
|
67
|
+
// GET /websiterecruit/position/project/<recruitType>
|
|
68
|
+
// returns { statusCode, data: null } for all three types — no project tree exposed.
|
|
69
|
+
//
|
|
70
|
+
// DICT ENDPOINTS PROBED — ALL RETURN 404:
|
|
71
|
+
// /websiterecruit/position/cities /websiterecruit/position/cityList
|
|
72
|
+
// /websiterecruit/position/jobTypes /websiterecruit/labels
|
|
73
|
+
// /websiterecruit/position/projects /websiterecruit/dict/jobType
|
|
74
|
+
// /websiterecruit/dict/city /websiterecruit/dict /websiterecruit/position/jobProjectList
|
|
75
|
+
// /websiterecruit/position/filterOptions /websiterecruit/position/config
|
|
76
|
+
// /websiterecruit/position/workplaceList /websiterecruit/position/jobTypeList
|
|
77
|
+
// → No public filter-taxonomy API exists. All taxonomy is derived by crawling positions.
|
|
78
|
+
//
|
|
79
|
+
// API DISCOVERY NOTES:
|
|
80
|
+
// - campus.xiaohongshu.com → 302 → job.xiaohongshu.com/campus (same SPA)
|
|
81
|
+
// - hr.xiaohongshu.com → TLS error (not Moka-hosted)
|
|
82
|
+
// - xiaohongshu.app.mokahr.com → TLS error (Moka subdomain does not exist for XHS)
|
|
83
|
+
// - recruit.xiaohongshu.com → code 320001 auth required on all paths
|
|
84
|
+
// - "social" recruitType IS publicly queryable (828 results, no auth required)
|
|
85
|
+
//
|
|
86
|
+
// PositionSummary field mapping from Xiaohongshu raw list entry:
|
|
87
|
+
// post_id ← positionId (number → string)
|
|
88
|
+
// title ← positionName
|
|
89
|
+
// project ← jobProjectName
|
|
90
|
+
// recruit_label ← jobType (e.g. "大模型", "策略算法", "引擎"; null → "")
|
|
91
|
+
// bgs ← "" (Xiaohongshu does not expose a BU / business-line field
|
|
92
|
+
// in the list or detail API; the raw entry has no department,
|
|
93
|
+
// businessLine, team, or bu key — checked 2026-05-14)
|
|
94
|
+
// work_cities ← workplace (already a human-readable string, e.g. "北京市,上海市")
|
|
95
|
+
// apply_url ← DETAIL_PAGE(positionId)
|
|
96
|
+
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
97
|
+
export { checkResume };
|
|
98
|
+
const API_ROOT = "https://job.xiaohongshu.com";
|
|
99
|
+
const CAMPUS_PAGE = "https://job.xiaohongshu.com/campus/position";
|
|
100
|
+
const DETAIL_PAGE = (positionId) => `https://job.xiaohongshu.com/campus/position?id=${encodeURIComponent(String(positionId))}`;
|
|
101
|
+
const DEFAULT_HEADERS = {
|
|
102
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
|
103
|
+
Accept: "application/json, text/plain, */*",
|
|
104
|
+
Origin: "https://job.xiaohongshu.com",
|
|
105
|
+
};
|
|
106
|
+
// ---------- call helper ----------
|
|
107
|
+
async function call(method, path, opts = {}) {
|
|
108
|
+
const url = `${API_ROOT}${path}`;
|
|
109
|
+
const headers = {
|
|
110
|
+
...DEFAULT_HEADERS,
|
|
111
|
+
Referer: opts.referer ?? CAMPUS_PAGE,
|
|
112
|
+
};
|
|
113
|
+
let body;
|
|
114
|
+
if (opts.body !== undefined) {
|
|
115
|
+
body = JSON.stringify(opts.body);
|
|
116
|
+
headers["Content-Type"] = "application/json";
|
|
117
|
+
}
|
|
118
|
+
let response;
|
|
119
|
+
try {
|
|
120
|
+
response = await fetch(url, { method, headers, body });
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
|
|
130
|
+
}
|
|
131
|
+
let payload;
|
|
132
|
+
try {
|
|
133
|
+
payload = (await response.json());
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
|
|
137
|
+
}
|
|
138
|
+
const code = payload.statusCode ?? payload.errorCode ?? 0;
|
|
139
|
+
const ok = payload.success === true || code === 200;
|
|
140
|
+
return {
|
|
141
|
+
ok,
|
|
142
|
+
data: payload.data,
|
|
143
|
+
message: payload.alertMsg || payload.errorMsg || (ok ? "ok" : "upstream error"),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// ---------- dictionaries ----------
|
|
147
|
+
// CITY_MAP: workplaceId → city name, derived by crawling all campus + social positions.
|
|
148
|
+
// campus (4 cities): Beijing, Shanghai, Hangzhou, Shenzhen.
|
|
149
|
+
// social (7 cities): adds Singapore, USA, Guangzhou.
|
|
150
|
+
// No public /cities API endpoint exists — all 404.
|
|
151
|
+
export const CITY_MAP = {
|
|
152
|
+
"702": "新加坡",
|
|
153
|
+
"840": "美国",
|
|
154
|
+
"1100": "北京市",
|
|
155
|
+
"3100": "上海市",
|
|
156
|
+
"3301": "杭州市",
|
|
157
|
+
"4401": "广州市",
|
|
158
|
+
"4403": "深圳市",
|
|
159
|
+
};
|
|
160
|
+
// PROJECT_MAP: jobProject code → human name (campus only; social has no projects).
|
|
161
|
+
// Discovered via detail endpoint — the list entry only exposes jobProjectName, not the code.
|
|
162
|
+
// Server-side jobProjectCode filtering is silently ignored; use client-side matching.
|
|
163
|
+
export const PROJECT_MAP = {
|
|
164
|
+
"top_intern_program": "Ace 顶尖实习生计划", // 133 campus positions
|
|
165
|
+
"campus_spring_26": "2026 春季校园招聘", // 14 campus positions
|
|
166
|
+
};
|
|
167
|
+
// JOB_TYPES: full set of jobType strings seen across campus + social.
|
|
168
|
+
// campus (20 types): 体验设计 大模型 引擎 策略算法 多媒体算法 端点防护 客户端开发
|
|
169
|
+
// 产品经理 内容理解 数据科学 营销策划 机器学习平台 后端开发 招聘 政府事务
|
|
170
|
+
// 互动直播运营 基础安全 法务 基础后端 内容运营
|
|
171
|
+
// social adds (7 types): 产品运营 平台专家 电商运营 经营策略 行业销售 运维开发 销售运营
|
|
172
|
+
// NOTE: server-side jobType filter (sending jobType in payload) is silently ignored.
|
|
173
|
+
export const JOB_TYPES = {
|
|
174
|
+
campus: [
|
|
175
|
+
"大模型", "策略算法", "产品经理", "客户端开发", "后端开发",
|
|
176
|
+
"体验设计", "多媒体算法", "内容理解", "引擎", "端点防护",
|
|
177
|
+
"数据科学", "营销策划", "机器学习平台", "互动直播运营", "招聘",
|
|
178
|
+
"政府事务", "基础安全", "法务", "基础后端", "内容运营",
|
|
179
|
+
],
|
|
180
|
+
social: [
|
|
181
|
+
"大模型", "策略算法", "产品经理", "客户端开发", "后端开发",
|
|
182
|
+
"体验设计", "多媒体算法", "内容理解", "产品运营", "平台专家",
|
|
183
|
+
"电商运营", "经营策略", "行业销售", "运维开发", "销售运营",
|
|
184
|
+
"互动直播运营", "内容运营",
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
export async function fetchDictionaries() {
|
|
188
|
+
// No live API call needed — taxonomy is fully derived from exhaustive position crawl.
|
|
189
|
+
// All /websiterecruit/position/cities, /cityList, /jobTypes, /labels etc. return 404.
|
|
190
|
+
// /project/<type> returns statusCode 200 but data: null for all three types.
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
source: "job.xiaohongshu.com",
|
|
194
|
+
note: [
|
|
195
|
+
"Taxonomy derived by crawling all campus (319) and social (828) positions — no public dict API.",
|
|
196
|
+
"recruitType='top_intern' is rejected by pageQueryPosition (error 999); top-intern positions",
|
|
197
|
+
"live inside campus with jobProjectName='Ace 顶尖实习生计划' (jobProject='top_intern_program').",
|
|
198
|
+
"All server-side filters (workplaceIds, jobType, jobProjectCode, keyword, pageSize) are silently ignored.",
|
|
199
|
+
"Client-side filtering is required for all dimensions.",
|
|
200
|
+
].join(" "),
|
|
201
|
+
recruit_types: {
|
|
202
|
+
campus: { total: 319, description: "校园招聘 — intern + new-grad, publicly queryable" },
|
|
203
|
+
social: { total: 828, description: "社会招聘 — experienced hires, publicly queryable, no auth needed" },
|
|
204
|
+
top_intern: { total: null, description: "INVALID for pageQueryPosition — returns error 999; use campus + project filter" },
|
|
205
|
+
},
|
|
206
|
+
cities: CITY_MAP,
|
|
207
|
+
projects: PROJECT_MAP,
|
|
208
|
+
job_types: JOB_TYPES,
|
|
209
|
+
campus_city_breakdown: {
|
|
210
|
+
"1100 北京市": 287,
|
|
211
|
+
"3100 上海市": 266,
|
|
212
|
+
"3301 杭州市": 140,
|
|
213
|
+
"4403 深圳市": 28,
|
|
214
|
+
"note": "counts overlap (multi-city positions counted once per city); 350 unique positions fetched",
|
|
215
|
+
},
|
|
216
|
+
campus_project_breakdown: {
|
|
217
|
+
"(none)": 203,
|
|
218
|
+
"Ace 顶尖实习生计划": 133,
|
|
219
|
+
"2026 春季校园招聘": 14,
|
|
220
|
+
},
|
|
221
|
+
campus_jobtype_breakdown: {
|
|
222
|
+
"策略算法": 63, "产品经理": 42, "大模型": 35, "客户端开发": 35,
|
|
223
|
+
"后端开发": 28, "体验设计": 14, "多媒体算法": 14, "内容理解": 14,
|
|
224
|
+
"(none)": 21,
|
|
225
|
+
"other_7_each": ["引擎", "端点防护", "数据科学", "营销策划", "机器学习平台", "互动直播运营", "招聘", "政府事务", "基础安全", "法务", "基础后端", "内容运营"],
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function summarizePosition(item) {
|
|
230
|
+
const postId = String(item.positionId ?? "");
|
|
231
|
+
return {
|
|
232
|
+
post_id: postId,
|
|
233
|
+
title: item.positionName ?? "",
|
|
234
|
+
project: item.jobProjectName ?? "",
|
|
235
|
+
recruit_label: (item.jobType ?? "").trim(),
|
|
236
|
+
// Xiaohongshu does not expose a BU / business-unit field in the list API.
|
|
237
|
+
// The raw entry contains no department, businessLine, team, or bu key.
|
|
238
|
+
bgs: "",
|
|
239
|
+
work_cities: (item.workplace ?? "").trim(),
|
|
240
|
+
apply_url: postId ? DETAIL_PAGE(postId) : CAMPUS_PAGE,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
export async function searchPositions(opts = {}) {
|
|
244
|
+
const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
|
|
245
|
+
const page = Math.max(1, opts.page ?? 1);
|
|
246
|
+
// "top_intern" is rejected by the upstream API (error 999). The caller may pass it
|
|
247
|
+
// for intent documentation, but we map it to "campus" and note the caveat.
|
|
248
|
+
const recruitType = opts.recruitType === "top_intern" ? "campus" : (opts.recruitType ?? "campus");
|
|
249
|
+
const body = {
|
|
250
|
+
recruitType,
|
|
251
|
+
keyword: (opts.keyword ?? "").trim().slice(0, 50),
|
|
252
|
+
page,
|
|
253
|
+
pageSize,
|
|
254
|
+
};
|
|
255
|
+
// workplaceIds and jobProjectCode are forwarded for completeness but are silently
|
|
256
|
+
// ignored by the upstream — all server-side filtering must be done client-side.
|
|
257
|
+
if (opts.workplaceIds !== undefined && opts.workplaceIds !== null) {
|
|
258
|
+
body.workplaceIds = Array.isArray(opts.workplaceIds)
|
|
259
|
+
? opts.workplaceIds.join(",")
|
|
260
|
+
: String(opts.workplaceIds);
|
|
261
|
+
}
|
|
262
|
+
if (opts.jobProjectCode)
|
|
263
|
+
body.jobProjectCode = opts.jobProjectCode;
|
|
264
|
+
const response = await call("POST", "/websiterecruit/position/pageQueryPosition", { body });
|
|
265
|
+
if (!response.ok || !response.data) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
message: response.message,
|
|
269
|
+
query: body,
|
|
270
|
+
positions: [],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const rows = response.data.list ?? [];
|
|
274
|
+
// The upstream API appears to ignore pageSize and always returns its default
|
|
275
|
+
// page size (~10). Enforce the caller's requested pageSize by slicing here.
|
|
276
|
+
const trimmed = rows.slice(0, pageSize);
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
source: "job.xiaohongshu.com",
|
|
280
|
+
query: body,
|
|
281
|
+
page,
|
|
282
|
+
page_size: pageSize,
|
|
283
|
+
total: response.data.total ?? rows.length,
|
|
284
|
+
positions: trimmed.map(summarizePosition),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
export async function fetchAllPositions(opts = {}) {
|
|
288
|
+
const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 100));
|
|
289
|
+
const maxPages = Math.max(1, opts.maxPages ?? 20);
|
|
290
|
+
const bucket = [];
|
|
291
|
+
let total;
|
|
292
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
293
|
+
const result = await searchPositions({ keyword: opts.keyword, recruitType: opts.recruitType, page, pageSize });
|
|
294
|
+
if (!result.ok) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
message: result.message,
|
|
298
|
+
fetched: bucket.length,
|
|
299
|
+
positions: bucket,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (total === undefined)
|
|
303
|
+
total = result.total;
|
|
304
|
+
if (!result.positions.length)
|
|
305
|
+
break;
|
|
306
|
+
bucket.push(...result.positions);
|
|
307
|
+
if (total !== undefined && bucket.length >= total)
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
source: "job.xiaohongshu.com",
|
|
313
|
+
total: total ?? bucket.length,
|
|
314
|
+
fetched: bucket.length,
|
|
315
|
+
positions: bucket,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
export async function fetchPositionDetail(postId) {
|
|
319
|
+
const id = String(postId ?? "").trim();
|
|
320
|
+
if (!id)
|
|
321
|
+
return { ok: false, message: "post_id is required" };
|
|
322
|
+
const response = await call("GET", `/websiterecruit/position/queryPositionDetail?positionId=${encodeURIComponent(id)}`, { referer: DETAIL_PAGE(id) });
|
|
323
|
+
if (!response.ok || !response.data) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
message: response.message || "no detail returned",
|
|
327
|
+
post_id: id,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const raw = response.data;
|
|
331
|
+
return {
|
|
332
|
+
ok: true,
|
|
333
|
+
source: "job.xiaohongshu.com",
|
|
334
|
+
post_id: String(raw.positionId ?? id),
|
|
335
|
+
title: raw.positionName ?? "",
|
|
336
|
+
direction: raw.jobType ?? "",
|
|
337
|
+
project: raw.jobProjectName ?? "",
|
|
338
|
+
recruit_label: raw.recruitType ?? "",
|
|
339
|
+
description: (raw.duty ?? "").trim(),
|
|
340
|
+
requirements: (raw.qualification ?? "").trim(),
|
|
341
|
+
work_cities: (raw.workplace ?? "").split(/[,,]/).map((s) => s.trim()).filter(Boolean),
|
|
342
|
+
recruit_cities: (raw.workplace ?? "").split(/[,,]/).map((s) => s.trim()).filter(Boolean),
|
|
343
|
+
apply_url: DETAIL_PAGE(raw.positionId ?? id),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
// ---------- notices (stub) ----------
|
|
347
|
+
//
|
|
348
|
+
// Xiaohongshu's campus notice page (job.xiaohongshu.com/campus/notice) is rendered
|
|
349
|
+
// server-side as static content; there is no public notice list API endpoint discovered
|
|
350
|
+
// in the JS bundle (unlike Tencent's /noticeDynamic/getNoticeDynamicList). These stubs
|
|
351
|
+
// maintain interface parity with tencent.ts.
|
|
352
|
+
export async function listNotices() {
|
|
353
|
+
return {
|
|
354
|
+
ok: true,
|
|
355
|
+
source: "job.xiaohongshu.com",
|
|
356
|
+
count: 0,
|
|
357
|
+
notices: [],
|
|
358
|
+
note: "No public campus notice API discovered for Xiaohongshu; check job.xiaohongshu.com/campus/notice in a browser.",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
export async function getNotice(noticeId) {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
message: `Xiaohongshu: no public notice detail API — notice id ${noticeId} not retrievable programmatically`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
export async function findNoticesByQuestion(question, _opts = {}) {
|
|
368
|
+
// Stub contract: align with listNotices (ok: true, empty results) so callers
|
|
369
|
+
// treating "no public endpoint" as a soft success — same as Tencent when the
|
|
370
|
+
// notice list happens to be empty — get a uniform shape.
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
source: "job.xiaohongshu.com",
|
|
374
|
+
question,
|
|
375
|
+
matches: [],
|
|
376
|
+
note: "No public campus notice API discovered for Xiaohongshu; flow returns no matches by design.",
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
// ---------- resume matching ----------
|
|
380
|
+
export async function matchResume(text, opts = {}) {
|
|
381
|
+
const topN = Math.max(1, opts.topN ?? 5);
|
|
382
|
+
const candidates = Math.max(topN, opts.candidates ?? 20);
|
|
383
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
384
|
+
if (!terms.length) {
|
|
385
|
+
return {
|
|
386
|
+
ok: false,
|
|
387
|
+
message: "could not extract any technical signals from the text",
|
|
388
|
+
preview: (text ?? "").slice(0, 120),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const keyword = terms.slice(0, 3).join(" ");
|
|
392
|
+
const list = await searchPositions({ keyword, page: 1, pageSize: 100 });
|
|
393
|
+
if (!list.ok)
|
|
394
|
+
return { ok: false, message: list.message, positions: [] };
|
|
395
|
+
const pre = [];
|
|
396
|
+
for (const p of list.positions) {
|
|
397
|
+
const blob = [p.title, p.project, p.recruit_label, p.bgs, p.work_cities].join(" ");
|
|
398
|
+
const { score, reasons } = scoreOverlap(blob, terms, cities);
|
|
399
|
+
if (score > 0)
|
|
400
|
+
pre.push({ score, position: p, reasons });
|
|
401
|
+
}
|
|
402
|
+
pre.sort((a, b) => b.score - a.score);
|
|
403
|
+
let shortlist = pre.slice(0, Math.max(topN, candidates));
|
|
404
|
+
if (!shortlist.length) {
|
|
405
|
+
shortlist = list.positions.slice(0, candidates).map((position) => ({
|
|
406
|
+
score: 0,
|
|
407
|
+
position,
|
|
408
|
+
reasons: [],
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
const enriched = [];
|
|
412
|
+
for (const { score: baseScore, position, reasons: baseReasons } of shortlist.slice(0, candidates)) {
|
|
413
|
+
const detail = await fetchPositionDetail(position.post_id);
|
|
414
|
+
if (!detail.ok)
|
|
415
|
+
continue;
|
|
416
|
+
const jdBlob = [
|
|
417
|
+
detail.title,
|
|
418
|
+
detail.direction,
|
|
419
|
+
detail.description,
|
|
420
|
+
detail.requirements,
|
|
421
|
+
(detail.work_cities ?? []).join(" "),
|
|
422
|
+
].join(" ");
|
|
423
|
+
const { score: extraScore, reasons: extraReasons } = scoreOverlap(jdBlob, terms, cities);
|
|
424
|
+
const combined = [...new Set([...baseReasons, ...extraReasons])].slice(0, 5);
|
|
425
|
+
if (!combined.length)
|
|
426
|
+
combined.push("no specific keyword overlap — surfaced from initial keyword search");
|
|
427
|
+
enriched.push({
|
|
428
|
+
score: baseScore + extraScore,
|
|
429
|
+
row: {
|
|
430
|
+
...position,
|
|
431
|
+
title_detail: detail.title,
|
|
432
|
+
direction: detail.direction,
|
|
433
|
+
description: detail.description,
|
|
434
|
+
requirements: detail.requirements,
|
|
435
|
+
match_reasons: combined,
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
enriched.sort((a, b) => b.score - a.score);
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
source: "job.xiaohongshu.com",
|
|
443
|
+
extracted_terms: terms,
|
|
444
|
+
city_preferences: cities,
|
|
445
|
+
matches: enriched.slice(0, topN).map((e) => e.row),
|
|
446
|
+
note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
|
|
447
|
+
"The only authority on selection is HR.",
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
import { buildBespokeApplySchema as _buildBespokeApplySchema_xiaohongshu } from "./apply.js";
|
|
451
|
+
export async function fetchApplicationSchema(postId) {
|
|
452
|
+
const id = (postId ?? "").trim();
|
|
453
|
+
if (!id)
|
|
454
|
+
return { ok: false, source: "job.xiaohongshu.com", message: "post_id is required" };
|
|
455
|
+
let title = "";
|
|
456
|
+
let applyUrl = "https://job.xiaohongshu.com";
|
|
457
|
+
try {
|
|
458
|
+
const detail = (await fetchPositionDetail(id));
|
|
459
|
+
if (detail?.ok === false) {
|
|
460
|
+
return { ok: false, source: "job.xiaohongshu.com", message: detail.message ?? "post not found" };
|
|
461
|
+
}
|
|
462
|
+
title = detail?.title ?? "";
|
|
463
|
+
if (detail?.apply_url)
|
|
464
|
+
applyUrl = detail.apply_url;
|
|
465
|
+
}
|
|
466
|
+
catch { }
|
|
467
|
+
return {
|
|
468
|
+
ok: true,
|
|
469
|
+
schema: _buildBespokeApplySchema_xiaohongshu({
|
|
470
|
+
source: "job.xiaohongshu.com",
|
|
471
|
+
postId: id,
|
|
472
|
+
jobTitle: title,
|
|
473
|
+
applyUrl,
|
|
474
|
+
submitEndpoint: "https://job.xiaohongshu.com/recruit/apply",
|
|
475
|
+
submitKind: "multipart-session",
|
|
476
|
+
endpointVerified: true,
|
|
477
|
+
submitNotes: "Xiaohongshu — POST /recruit/apply (no /api/ prefix) with session cookie. Endpoint anon-probed → HTTP 401 + {success:false, errorCode:401, alertMsg:\"请登录\"} (real apply route; the /api/* prefix returns 404 HTML, but the path lives at the host root). Body shape still needs validation.",
|
|
478
|
+
}),
|
|
479
|
+
};
|
|
480
|
+
}
|