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/adapter.js +17 -0
- package/dist/alibaba.js +2 -2
- package/dist/baichuan.js +38 -123
- package/dist/bilibili.js +43 -0
- package/dist/byd.js +333 -112
- package/dist/cambricon.js +379 -39
- package/dist/deepseek.js +373 -25
- package/dist/galaxyuniversal.js +375 -23
- package/dist/horizonrobotics.js +40 -61
- package/dist/iflytek.js +299 -57
- package/dist/index.js +55 -50
- package/dist/megvii.js +387 -150
- package/dist/mihoyo.js +241 -67
- package/dist/moonshot.js +387 -54
- package/dist/oppo.js +212 -56
- package/dist/pdd.js +278 -129
- package/dist/sensetime.js +47 -183
- package/dist/sf.js +249 -36
- package/dist/stepfun.js +333 -105
- package/dist/vivo.js +291 -41
- package/dist/wecruit.js +385 -0
- package/dist/weibo.js +259 -88
- package/dist/zerooneai.js +40 -37
- package/package.json +3 -2
package/dist/sensetime.js
CHANGED
|
@@ -1,186 +1,50 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 商汤 (SenseTime) careers adapter for `job-pro`.
|
|
2
2
|
//
|
|
3
3
|
// ============================================================
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// positonNum: N } }
|
|
40
|
-
//
|
|
41
|
-
// POST /positionInfo/listSearchTerm/{channelId}
|
|
42
|
-
// Returns filter taxonomies (work cities, departments, job types)
|
|
43
|
-
//
|
|
44
|
-
// POST /positionInfo/listPositionDetail/{channelId}
|
|
45
|
-
// Payload: { postId: str, recruitType: N }
|
|
46
|
-
// Returns full JD for a single posting
|
|
47
|
-
//
|
|
48
|
-
// POST /positionInfo/UnassignedPostDetail/{channelId}
|
|
49
|
-
// Returns detail for positions with unassigned departments
|
|
50
|
-
//
|
|
51
|
-
// GET /suite/post/search/condition/{channelId}
|
|
52
|
-
// Returns search filter configuration
|
|
53
|
-
//
|
|
54
|
-
// Constructed API base:
|
|
55
|
-
// https://hr.sensetime.com/{channelId}/pb/{apiPath}/{channelId}
|
|
56
|
-
// (the Nginx proxy at /SU.../pb/ maps sub-paths to the backend)
|
|
57
|
-
//
|
|
58
|
-
// ============================================================
|
|
59
|
-
// WHY THIS IS A STUB (unauthenticated access is impossible):
|
|
60
|
-
//
|
|
61
|
-
// Every POST request to the above paths returns HTTP 405 Method Not Allowed,
|
|
62
|
-
// regardless of Origin, Referer, Content-Type, or User-Agent headers.
|
|
63
|
-
// GET requests return the SPA HTML shell (client-side routing catch-all).
|
|
64
|
-
//
|
|
65
|
-
// The Nginx WAF at hr.sensetime.com blocks all unauthenticated POST requests.
|
|
66
|
-
// The API requires a valid session cookie / JWT obtained via:
|
|
67
|
-
// POST /login/ or POST /ssoLogin
|
|
68
|
-
// These are enterprise SSO flows (phone OTP, WeChat OAuth, or SAML enterprise SSO)
|
|
69
|
-
// that cannot be automated without a real account.
|
|
70
|
-
//
|
|
71
|
-
// This is fundamentally different from ByteDance/Tencent/Feishu portals, which
|
|
72
|
-
// allow anonymous POST to their search endpoints without any session cookie.
|
|
73
|
-
//
|
|
74
|
-
// Recommendation: Monitor for:
|
|
75
|
-
// (a) A future public campus API at campus.sensetime.com
|
|
76
|
-
// (b) A Feishu Recruiting migration (SenseTime does use Feishu internally)
|
|
77
|
-
// (c) Third-party job boards (牛客, 实习僧) that scrape SenseTime listings
|
|
78
|
-
//
|
|
79
|
-
// ============================================================
|
|
80
|
-
// STUB CONTRACT: All functions return ok:false with STUB_MESSAGE.
|
|
81
|
-
// checkResume is re-exported from tencent.ts (works offline on resume text).
|
|
82
|
-
// When/if SenseTime opens a public API, rewrite this file — the export shape
|
|
83
|
-
// is already locked in by the PositionSummary interface below.
|
|
84
|
-
import { extractResumeSignals, checkResume } from "./tencent.js";
|
|
85
|
-
export { checkResume };
|
|
86
|
-
const SOURCE = "hr.sensetime.com";
|
|
87
|
-
const CAMPUS_URL = "https://hr.sensetime.com/SU6710d7c21c240e54e1f82a1b/pb/school.html";
|
|
88
|
-
const STUB_MESSAGE = "SenseTime (商汤): no public job API — hr.sensetime.com POSTs are blocked by WAF (HTTP 405) " +
|
|
89
|
-
"without a valid session cookie; campus.sensetime.com and careers.sensetime.com are " +
|
|
90
|
-
"geo-blocked (SSL failure). The HRIS platform (PB/PushB, channel SU6710d7c21c240e54e1f82a1b) " +
|
|
91
|
-
"requires enterprise SSO (phone OTP / WeChat OAuth). " +
|
|
92
|
-
"Documented in cli/src/sensetime.ts header.";
|
|
93
|
-
// ---- searchPositions ----
|
|
94
|
-
export async function searchPositions(_opts = {}) {
|
|
95
|
-
return {
|
|
96
|
-
ok: false,
|
|
97
|
-
source: SOURCE,
|
|
98
|
-
message: STUB_MESSAGE,
|
|
99
|
-
// Expose the discovered endpoint so callers can see what we would have hit
|
|
100
|
-
endpoint: `POST https://hr.sensetime.com/SU6710d7c21c240e54e1f82a1b/pb/positionInfo/listPosition/SU6710d7c21c240e54e1f82a1b`,
|
|
101
|
-
query: {
|
|
102
|
-
isFrompb: true,
|
|
103
|
-
recruitType: _opts.recruitType ?? 1,
|
|
104
|
-
pageSize: _opts.pageSize ?? 20,
|
|
105
|
-
currentPage: _opts.page ?? 1,
|
|
106
|
-
...(_opts.keyword ? { postKey: _opts.keyword } : {}),
|
|
4
|
+
// API DISCOVERY (probed 2026-05-16 via puppeteer-core network capture)
|
|
5
|
+
//
|
|
6
|
+
// hr.sensetime.com hosts a Beisen Wecruit (北森招聘云) tenant. The published
|
|
7
|
+
// SPA bundles at `/SU…/pb/<channel>.html` ALWAYS return nginx 405 on
|
|
8
|
+
// anonymous POST, regardless of headers; that path is GET-only at the LB.
|
|
9
|
+
//
|
|
10
|
+
// The SPA's real XHR target (uncovered by intercepting page traffic in a
|
|
11
|
+
// headless Chrome instance) is on a sibling `/wecruit/...` prefix:
|
|
12
|
+
//
|
|
13
|
+
// POST https://hr.sensetime.com/wecruit/positionInfo/listPosition/<SU…>
|
|
14
|
+
// ?iSaJAx=isAjax&request_locale=zh_CN&t=<unix-ms>
|
|
15
|
+
//
|
|
16
|
+
// Content-Type: application/x-www-form-urlencoded (NOT JSON)
|
|
17
|
+
// Body: isFrompb=true&recruitType=2&pageSize=15¤tPage=1
|
|
18
|
+
//
|
|
19
|
+
// Anonymous, no token, no cookie, no captcha. Probed 2026-05-16: the
|
|
20
|
+
// social channel `SU60fa3bdabef57c1023fc1cbc` returns ~89 pages × 12 ≈
|
|
21
|
+
// 1068 active social-hire positions across SenseTime and its subsidiaries.
|
|
22
|
+
//
|
|
23
|
+
// hr.sensetime.com root redirects to the social channel (302); the campus
|
|
24
|
+
// SU referenced in earlier reconnaissance notes (`SU6710d7c21c240e54e1f82a1b`)
|
|
25
|
+
// has been reassigned to a different tenant ("安徽新华发行集团" appears in
|
|
26
|
+
// its responses), so we only wire the social channel. If SenseTime
|
|
27
|
+
// rebroadcasts a campus channel later, add it to the `channels` array.
|
|
28
|
+
//
|
|
29
|
+
// See cli/src/wecruit.ts for the shared factory.
|
|
30
|
+
import { createAdapter } from "./wecruit.js";
|
|
31
|
+
const adapter = createAdapter({
|
|
32
|
+
host: "hr.sensetime.com",
|
|
33
|
+
label: "SenseTime",
|
|
34
|
+
channels: [
|
|
35
|
+
{
|
|
36
|
+
channelId: "SU60fa3bdabef57c1023fc1cbc",
|
|
37
|
+
recruitType: "social",
|
|
38
|
+
pagePath: "social",
|
|
107
39
|
},
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
export
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
fetched: 0,
|
|
120
|
-
positions: [],
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
// ---- fetchPositionDetail ----
|
|
124
|
-
export async function fetchPositionDetail(postId) {
|
|
125
|
-
return {
|
|
126
|
-
ok: false,
|
|
127
|
-
source: SOURCE,
|
|
128
|
-
message: STUB_MESSAGE,
|
|
129
|
-
post_id: postId,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
// ---- fetchDictionaries ----
|
|
133
|
-
//
|
|
134
|
-
// When accessible, POST /positionInfo/listSearchTerm/{channelId} returns:
|
|
135
|
-
// { state: "200", data: { projectList, provinceList, orgList, postTypeList, salaryList } }
|
|
136
|
-
export async function fetchDictionaries() {
|
|
137
|
-
return {
|
|
138
|
-
ok: false,
|
|
139
|
-
source: SOURCE,
|
|
140
|
-
message: STUB_MESSAGE,
|
|
141
|
-
note: "When API becomes accessible: POST /positionInfo/listSearchTerm/{channelId}",
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
// ---- notices (no public endpoint) ----
|
|
145
|
-
export async function listNotices() {
|
|
146
|
-
return {
|
|
147
|
-
ok: false,
|
|
148
|
-
source: SOURCE,
|
|
149
|
-
message: "SenseTime: no public notices endpoint",
|
|
150
|
-
notices: [],
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
export async function getNotice(noticeId) {
|
|
154
|
-
return {
|
|
155
|
-
ok: false,
|
|
156
|
-
source: SOURCE,
|
|
157
|
-
message: "SenseTime: no public notices endpoint",
|
|
158
|
-
notice_id: noticeId,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
export async function findNoticesByQuestion(question, _opts = {}) {
|
|
162
|
-
return {
|
|
163
|
-
ok: false,
|
|
164
|
-
source: SOURCE,
|
|
165
|
-
question,
|
|
166
|
-
message: "SenseTime: no public notices endpoint",
|
|
167
|
-
matches: [],
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
// ---- matchResume ----
|
|
171
|
-
//
|
|
172
|
-
// Because the position search API is inaccessible, we cannot retrieve live listings
|
|
173
|
-
// to score against the resume. Return ok:false with the extracted signals so the
|
|
174
|
-
// caller can display what terms were parsed (useful for debugging the resume text).
|
|
175
|
-
export async function matchResume(text, _opts = {}) {
|
|
176
|
-
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
177
|
-
return {
|
|
178
|
-
ok: false,
|
|
179
|
-
source: SOURCE,
|
|
180
|
-
extracted_terms: terms,
|
|
181
|
-
city_preferences: cities,
|
|
182
|
-
matches: [],
|
|
183
|
-
message: STUB_MESSAGE,
|
|
184
|
-
apply_url: CAMPUS_URL,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
export const searchPositions = adapter.searchPositions;
|
|
43
|
+
export const fetchAllPositions = adapter.fetchAllPositions;
|
|
44
|
+
export const fetchPositionDetail = adapter.fetchPositionDetail;
|
|
45
|
+
export const fetchDictionaries = adapter.fetchDictionaries;
|
|
46
|
+
export const listNotices = adapter.listNotices;
|
|
47
|
+
export const getNotice = adapter.getNotice;
|
|
48
|
+
export const findNoticesByQuestion = adapter.findNoticesByQuestion;
|
|
49
|
+
export const matchResume = adapter.matchResume;
|
|
50
|
+
export const checkResume = adapter.checkResume;
|
package/dist/sf.js
CHANGED
|
@@ -1,65 +1,278 @@
|
|
|
1
|
-
// 顺丰 (SF Express) campus
|
|
2
|
-
//
|
|
3
|
-
// STATUS: stub-only. The campus portal lives at campus.sf-express.com but
|
|
4
|
-
// the JSON job-list endpoint is gated behind a Spring Security 401 for any
|
|
5
|
-
// request lacking a logged-in user session bound to a GeeTest v4 captcha token.
|
|
1
|
+
// 顺丰 (SF Express) campus-recruiting adapter for `job-pro`.
|
|
6
2
|
//
|
|
7
3
|
// ============================================================
|
|
8
|
-
//
|
|
4
|
+
// API DISCOVERY (probed 2026-05-15)
|
|
5
|
+
//
|
|
6
|
+
// campus.sf-express.com is a Vue SPA built with Webpack. The campus-recruiting
|
|
7
|
+
// flow was originally believed to be GeeTest-gated (POST /api/zp/jobList → 401),
|
|
8
|
+
// but the SPA's actual position-listing chunk (cr/static/js/25.aa149bcb...js)
|
|
9
|
+
// calls a different, fully anonymous route:
|
|
10
|
+
//
|
|
11
|
+
// GET /api/web/position/query?pageNum=&pageSize=&keyword=…
|
|
9
12
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// GET https://campus.sf-express.com/cr/api/zp/jobList → openresty 404
|
|
13
|
+
// Required headers: a normal browser UA plus the `cr-service` header that the
|
|
14
|
+
// SPA's axios interceptor adds to every request. The interceptor sets
|
|
15
|
+
// cr-service: <url-encoded current location>
|
|
16
|
+
// and the gateway uses it instead of a JWT to scope the response. With both
|
|
17
|
+
// in place the endpoint returns paginated JSON without any captcha or login.
|
|
16
18
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
19
|
+
// Endpoint inventory (anonymous GET unless noted):
|
|
20
|
+
// GET /api/web/position/query → paginated positions (campus + intern + mgmt)
|
|
21
|
+
// GET /api/web/position/findById/<id>→ single posting (via /api/position/findById/<id>)
|
|
20
22
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
23
|
+
// `positionType` filter values seen in the wild:
|
|
24
|
+
// "consulting" 管理咨询生
|
|
25
|
+
// "managetraniee" 管培生类
|
|
26
|
+
// "" (omitted) 全部
|
|
24
27
|
//
|
|
25
|
-
//
|
|
26
|
-
// https://campus.sf-express.com/ for the official campus portal.
|
|
28
|
+
// ============================================================
|
|
27
29
|
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
28
30
|
export { checkResume };
|
|
29
31
|
const SOURCE = "campus.sf-express.com";
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
const API_ROOT = "https://campus.sf-express.com";
|
|
33
|
+
const SITE_ROOT = "https://campus.sf-express.com/";
|
|
34
|
+
const DETAIL_PAGE = (id) => `https://campus.sf-express.com/#/postDetail/${encodeURIComponent(id)}`;
|
|
35
|
+
const CR_SERVICE = "https%3A%2F%2Fcampus.sf-express.com%2F";
|
|
36
|
+
const DEFAULT_HEADERS = {
|
|
37
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
|
38
|
+
Accept: "application/json, text/plain, */*",
|
|
39
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
40
|
+
Referer: SITE_ROOT,
|
|
41
|
+
Origin: API_ROOT,
|
|
42
|
+
"cr-service": CR_SERVICE,
|
|
43
|
+
};
|
|
44
|
+
async function call(path, query = {}) {
|
|
45
|
+
const params = new URLSearchParams();
|
|
46
|
+
for (const [k, v] of Object.entries(query)) {
|
|
47
|
+
if (v !== undefined && v !== "")
|
|
48
|
+
params.set(k, String(v));
|
|
49
|
+
}
|
|
50
|
+
const qs = params.toString();
|
|
51
|
+
const url = `${API_ROOT}${path}${qs ? `?${qs}` : ""}`;
|
|
52
|
+
let response;
|
|
53
|
+
try {
|
|
54
|
+
response = await fetch(url, { method: "GET", headers: DEFAULT_HEADERS });
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
|
|
64
|
+
}
|
|
65
|
+
// SF returns the payload object directly (PageHelper shape: {list, total, …})
|
|
66
|
+
let payload;
|
|
67
|
+
try {
|
|
68
|
+
payload = (await response.json());
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, data: payload.list, total: payload.total, message: "ok" };
|
|
74
|
+
}
|
|
75
|
+
function summarize(item) {
|
|
76
|
+
const id = String(item.id ?? "");
|
|
77
|
+
const city = (item.demandCity ?? item.recruitCity ?? "").toString().trim();
|
|
78
|
+
return {
|
|
79
|
+
post_id: id,
|
|
80
|
+
title: (item.positionName ?? "").trim(),
|
|
81
|
+
project: (item.orgSourceName ?? item.orgSource ?? "").trim(),
|
|
82
|
+
recruit_label: item.seasonType === "1"
|
|
83
|
+
? "校招"
|
|
84
|
+
: item.seasonType === "2"
|
|
85
|
+
? "实习"
|
|
86
|
+
: item.seasonType === "3"
|
|
87
|
+
? "管培"
|
|
88
|
+
: "",
|
|
89
|
+
bgs: (item.positionTypeName ?? "").trim(),
|
|
90
|
+
work_cities: city,
|
|
91
|
+
apply_url: id ? DETAIL_PAGE(id) : SITE_ROOT,
|
|
92
|
+
};
|
|
35
93
|
}
|
|
36
|
-
|
|
37
|
-
|
|
94
|
+
// ---------- searchPositions ----------
|
|
95
|
+
export async function searchPositions(opts = {}) {
|
|
96
|
+
const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
|
|
97
|
+
const page = Math.max(1, opts.page ?? 1);
|
|
98
|
+
const query = {
|
|
99
|
+
pageNum: page,
|
|
100
|
+
pageSize,
|
|
101
|
+
};
|
|
102
|
+
if (opts.keyword)
|
|
103
|
+
query.positionName = opts.keyword.trim().slice(0, 60);
|
|
104
|
+
if (opts.positionType)
|
|
105
|
+
query.positionType = opts.positionType;
|
|
106
|
+
if (opts.seasonType)
|
|
107
|
+
query.seasonType = opts.seasonType;
|
|
108
|
+
const r = await call("/api/web/position/query", query);
|
|
109
|
+
if (!r.ok) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
source: SOURCE,
|
|
113
|
+
message: r.message,
|
|
114
|
+
query,
|
|
115
|
+
positions: [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const rows = r.data ?? [];
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
source: SOURCE,
|
|
122
|
+
query,
|
|
123
|
+
page,
|
|
124
|
+
page_size: pageSize,
|
|
125
|
+
total: r.total ?? rows.length,
|
|
126
|
+
positions: rows.map(summarize),
|
|
127
|
+
};
|
|
38
128
|
}
|
|
129
|
+
// ---------- fetchAllPositions ----------
|
|
130
|
+
export async function fetchAllPositions(opts = {}) {
|
|
131
|
+
const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 50));
|
|
132
|
+
const maxPages = Math.max(1, opts.maxPages ?? 20);
|
|
133
|
+
const bucket = [];
|
|
134
|
+
let total;
|
|
135
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
136
|
+
const r = await searchPositions({ keyword: opts.keyword, page, pageSize });
|
|
137
|
+
if (!r.ok) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
source: SOURCE,
|
|
141
|
+
message: r.message,
|
|
142
|
+
total: 0,
|
|
143
|
+
fetched: bucket.length,
|
|
144
|
+
positions: bucket,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (total === undefined)
|
|
148
|
+
total = r.total;
|
|
149
|
+
if (!r.positions.length)
|
|
150
|
+
break;
|
|
151
|
+
bucket.push(...r.positions);
|
|
152
|
+
if (total !== undefined && bucket.length >= total)
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
source: SOURCE,
|
|
158
|
+
total: total ?? bucket.length,
|
|
159
|
+
fetched: bucket.length,
|
|
160
|
+
positions: bucket,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// ---------- fetchPositionDetail ----------
|
|
39
164
|
export async function fetchPositionDetail(postId) {
|
|
40
|
-
|
|
165
|
+
const id = (postId ?? "").trim();
|
|
166
|
+
if (!id)
|
|
167
|
+
return { ok: false, source: SOURCE, message: "post_id is required", post_id: id };
|
|
168
|
+
// Some SF builds expose details via /api/position/findById/<id>, others via the
|
|
169
|
+
// SPA's "findById" route — both share the same backend. We always hit /api/...
|
|
170
|
+
const url = `${API_ROOT}/api/position/findById/${encodeURIComponent(id)}`;
|
|
171
|
+
let response;
|
|
172
|
+
try {
|
|
173
|
+
response = await fetch(url, { method: "GET", headers: DEFAULT_HEADERS });
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
source: SOURCE,
|
|
179
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
180
|
+
post_id: id,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
source: SOURCE,
|
|
187
|
+
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
188
|
+
post_id: id,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
let raw;
|
|
192
|
+
try {
|
|
193
|
+
raw = (await response.json());
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
source: SOURCE,
|
|
199
|
+
message: `bad JSON: ${err instanceof Error ? err.message : err}`,
|
|
200
|
+
post_id: id,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
source: SOURCE,
|
|
206
|
+
post_id: String(raw.id ?? id),
|
|
207
|
+
title: raw.positionName ?? "",
|
|
208
|
+
project: raw.orgSourceName ?? raw.orgSource ?? "",
|
|
209
|
+
position_type: raw.positionTypeName ?? "",
|
|
210
|
+
description: (raw.postDuty ?? "").toString().trim(),
|
|
211
|
+
requirements: (raw.jobRequirement ?? "").toString().trim(),
|
|
212
|
+
work_city: raw.demandCity ?? "",
|
|
213
|
+
interview_city: raw.recruitCity ?? "",
|
|
214
|
+
education: raw.educationName ?? raw.education ?? "",
|
|
215
|
+
intern_type: raw.internTypeName ?? raw.internType ?? "",
|
|
216
|
+
create_date: raw.createDate ?? "",
|
|
217
|
+
apply_url: DETAIL_PAGE(id),
|
|
218
|
+
};
|
|
41
219
|
}
|
|
220
|
+
// ---------- fetchDictionaries (no public dict endpoint) ----------
|
|
42
221
|
export async function fetchDictionaries() {
|
|
43
|
-
return {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
source: SOURCE,
|
|
225
|
+
message: "SF Express does not expose a public filter taxonomy endpoint; positions API accepts " +
|
|
226
|
+
"positionName / positionType / seasonType query params directly.",
|
|
227
|
+
api_host: API_ROOT,
|
|
228
|
+
known_filters: {
|
|
229
|
+
positionType: ["consulting", "managetraniee"],
|
|
230
|
+
seasonType: { "1": "校招", "2": "实习", "3": "管培" },
|
|
231
|
+
},
|
|
232
|
+
};
|
|
44
233
|
}
|
|
234
|
+
// ---------- notices (no public notices endpoint) ----------
|
|
235
|
+
const NO_NOTICES = "SF Express campus does not expose a public notices/announcements endpoint.";
|
|
45
236
|
export async function listNotices() {
|
|
46
|
-
return { ok: false, source: SOURCE, message:
|
|
237
|
+
return { ok: false, source: SOURCE, message: NO_NOTICES, notices: [] };
|
|
47
238
|
}
|
|
48
239
|
export async function getNotice(noticeId) {
|
|
49
|
-
return { ok: false, source: SOURCE, message:
|
|
240
|
+
return { ok: false, source: SOURCE, message: NO_NOTICES, notice_id: noticeId };
|
|
50
241
|
}
|
|
51
242
|
export async function findNoticesByQuestion(question, _opts = {}) {
|
|
52
|
-
return { ok: false, source: SOURCE, question, message:
|
|
243
|
+
return { ok: false, source: SOURCE, question, message: NO_NOTICES, matches: [] };
|
|
53
244
|
}
|
|
54
|
-
|
|
245
|
+
// ---------- matchResume ----------
|
|
246
|
+
export async function matchResume(text, opts = {}) {
|
|
55
247
|
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
248
|
+
const topN = Math.max(1, opts.topN ?? 5);
|
|
249
|
+
const candidates = Math.max(topN, opts.candidates ?? 200);
|
|
250
|
+
const all = await fetchAllPositions({ pageSize: 50, maxPages: Math.ceil(candidates / 50) });
|
|
251
|
+
if (!all.ok) {
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
source: SOURCE,
|
|
255
|
+
message: all.message,
|
|
256
|
+
extracted_terms: terms,
|
|
257
|
+
city_preferences: cities,
|
|
258
|
+
matches: [],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const scored = [];
|
|
262
|
+
for (const p of all.positions) {
|
|
263
|
+
const haystack = `${p.title} ${p.project} ${p.bgs} ${p.work_cities}`;
|
|
264
|
+
const score = scoreOverlap(haystack, terms, cities).score;
|
|
265
|
+
if (score > 0)
|
|
266
|
+
scored.push({ score, position: p });
|
|
267
|
+
}
|
|
268
|
+
scored.sort((a, b) => b.score - a.score);
|
|
56
269
|
return {
|
|
57
|
-
ok:
|
|
270
|
+
ok: true,
|
|
58
271
|
source: SOURCE,
|
|
59
272
|
extracted_terms: terms,
|
|
60
273
|
city_preferences: cities,
|
|
61
|
-
|
|
62
|
-
|
|
274
|
+
candidate_pool: all.positions.length,
|
|
275
|
+
matches: scored.slice(0, topN).map((s) => s.position),
|
|
63
276
|
};
|
|
64
277
|
}
|
|
65
278
|
export { extractResumeSignals, scoreOverlap };
|