@mazjq/zhiro 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +228 -2
- package/README.zh-CN.md +188 -0
- package/agent-skills/zhiro/SKILL.md +89 -0
- package/bin/zhiro.cjs +21 -0
- package/dist/acp-bridge.js +87 -0
- package/dist/chunk-2SSYR7VW.js +558 -0
- package/dist/chunk-2XJ45GUM.js +3795 -0
- package/dist/chunk-4EPNDXRS.js +542 -0
- package/dist/chunk-4JJEOOW2.js +107 -0
- package/dist/chunk-4UWMDKMH.js +2605 -0
- package/dist/chunk-7IV2NG2D.js +328 -0
- package/dist/chunk-A722HITH.js +680 -0
- package/dist/chunk-GRZ4VMSO.js +180 -0
- package/dist/chunk-H3EFVYGD.js +6 -0
- package/dist/chunk-OOHBM3BT.js +6 -0
- package/dist/chunk-VOUILWMI.js +848 -0
- package/dist/chunk-ZKD2BKN3.js +1158 -0
- package/dist/chunk-ZUUYRVX6.js +44 -0
- package/dist/cli-LUWVZ5NG.js +131 -0
- package/dist/login-W5QYFWTY.js +75 -0
- package/dist/main-FTWFKWZH.js +8029 -0
- package/dist/research-data.js +46 -0
- package/dist/stdio-FZZLZI4P.js +32 -0
- package/dist/vault-JUTREQJK.js +117 -0
- package/dist/zhiro.js +62 -0
- package/package.json +57 -4
- package/ui/assets/core-CwxXejkd.js +1 -0
- package/ui/assets/index-B7GrU8uc.js +39 -0
- package/ui/assets/index-Dt8b-A97.css +2 -0
- package/ui/index.html +15 -0
- package/ui/tauri.svg +6 -0
- package/ui/vite.svg +1 -0
|
@@ -0,0 +1,2605 @@
|
|
|
1
|
+
import {
|
|
2
|
+
json,
|
|
3
|
+
now,
|
|
4
|
+
tx
|
|
5
|
+
} from "./chunk-4EPNDXRS.js";
|
|
6
|
+
import {
|
|
7
|
+
updateConfig
|
|
8
|
+
} from "./chunk-4JJEOOW2.js";
|
|
9
|
+
import {
|
|
10
|
+
__name
|
|
11
|
+
} from "./chunk-2SSYR7VW.js";
|
|
12
|
+
|
|
13
|
+
// apps/daemon/src/data/http.ts
|
|
14
|
+
function httpContext(log = () => {
|
|
15
|
+
}, signal) {
|
|
16
|
+
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36";
|
|
17
|
+
const withTimeout = /* @__PURE__ */ __name(async (url, opts = {}) => {
|
|
18
|
+
const ctl = new AbortController();
|
|
19
|
+
const t = setTimeout(() => ctl.abort(), opts.timeoutMs ?? 2e4);
|
|
20
|
+
signal?.addEventListener("abort", () => ctl.abort(), { once: true });
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(url, { ...opts, headers: { "user-agent": ua, ...opts.headers ?? {} }, signal: ctl.signal }).catch((e) => {
|
|
23
|
+
throw new Error(`${ctl.signal.aborted ? "TIMEOUT" : "SITE_DOWN"}: ${e?.cause?.code ?? e?.message ?? e} ${url}`);
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) throw new Error(`${res.status === 401 ? "LOGIN_REQUIRED" : res.status === 403 || res.status === 429 ? "BLOCKED" : "SITE_DOWN"}: HTTP ${res.status} ${url}`);
|
|
26
|
+
if (opts.expectJson !== false && /fetchJson/.test(opts.__kind ?? "") && !/json/.test(res.headers.get("content-type") ?? "")) throw new Error(`SCHEMA_CHANGED: 没返回 JSON ${url}`);
|
|
27
|
+
return res;
|
|
28
|
+
} finally {
|
|
29
|
+
clearTimeout(t);
|
|
30
|
+
}
|
|
31
|
+
}, "withTimeout");
|
|
32
|
+
return {
|
|
33
|
+
fetchJson: /* @__PURE__ */ __name((url, opts) => withTimeout(url, { ...opts ?? {}, __kind: "fetchJson" }).then((r) => r.json()), "fetchJson"),
|
|
34
|
+
fetchText: /* @__PURE__ */ __name((url, opts) => withTimeout(url, opts).then((r) => r.text()), "fetchText"),
|
|
35
|
+
sleep: /* @__PURE__ */ __name((ms) => new Promise((r) => setTimeout(r, ms)), "sleep"),
|
|
36
|
+
signal,
|
|
37
|
+
log
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
__name(httpContext, "httpContext");
|
|
41
|
+
var PRIVATE_HOST = /^(localhost|127\.|10\.|192\.168\.|169\.254\.|0\.|\[::1\]|\[fe80|\[fc|\[fd|172\.(1[6-9]|2\d|3[01])\.)/i;
|
|
42
|
+
var publicUrl = /* @__PURE__ */ __name((u) => {
|
|
43
|
+
try {
|
|
44
|
+
const x = new URL(u);
|
|
45
|
+
return /^https?:$/.test(x.protocol) && !PRIVATE_HOST.test(x.hostname) && !/^\d+$/.test(x.hostname);
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}, "publicUrl");
|
|
50
|
+
var isLocalPath = /* @__PURE__ */ __name((s) => /^([a-zA-Z]:[\\/]|\\\\|\/)/.test(s.trim()), "isLocalPath");
|
|
51
|
+
|
|
52
|
+
// apps/daemon/src/data/readers/career-ops/mokahr.mjs
|
|
53
|
+
import { createDecipheriv } from "crypto";
|
|
54
|
+
|
|
55
|
+
// apps/daemon/src/data/readers/career-ops/_html-entities.mjs
|
|
56
|
+
var NAMED_ENTITIES = {
|
|
57
|
+
amp: "&",
|
|
58
|
+
lt: "<",
|
|
59
|
+
gt: ">",
|
|
60
|
+
quot: '"',
|
|
61
|
+
apos: "'",
|
|
62
|
+
nbsp: " ",
|
|
63
|
+
// French / Portuguese / Spanish / German / Nordic letters, lower and upper.
|
|
64
|
+
agrave: "à",
|
|
65
|
+
aacute: "á",
|
|
66
|
+
acirc: "â",
|
|
67
|
+
atilde: "ã",
|
|
68
|
+
auml: "ä",
|
|
69
|
+
aring: "å",
|
|
70
|
+
aelig: "æ",
|
|
71
|
+
ccedil: "ç",
|
|
72
|
+
egrave: "è",
|
|
73
|
+
eacute: "é",
|
|
74
|
+
ecirc: "ê",
|
|
75
|
+
euml: "ë",
|
|
76
|
+
igrave: "ì",
|
|
77
|
+
iacute: "í",
|
|
78
|
+
icirc: "î",
|
|
79
|
+
iuml: "ï",
|
|
80
|
+
ntilde: "ñ",
|
|
81
|
+
ograve: "ò",
|
|
82
|
+
oacute: "ó",
|
|
83
|
+
ocirc: "ô",
|
|
84
|
+
otilde: "õ",
|
|
85
|
+
ouml: "ö",
|
|
86
|
+
oslash: "ø",
|
|
87
|
+
ugrave: "ù",
|
|
88
|
+
uacute: "ú",
|
|
89
|
+
ucirc: "û",
|
|
90
|
+
uuml: "ü",
|
|
91
|
+
yacute: "ý",
|
|
92
|
+
yuml: "ÿ",
|
|
93
|
+
szlig: "ß",
|
|
94
|
+
Agrave: "À",
|
|
95
|
+
Aacute: "Á",
|
|
96
|
+
Acirc: "Â",
|
|
97
|
+
Atilde: "Ã",
|
|
98
|
+
Auml: "Ä",
|
|
99
|
+
Aring: "Å",
|
|
100
|
+
AElig: "Æ",
|
|
101
|
+
Ccedil: "Ç",
|
|
102
|
+
Egrave: "È",
|
|
103
|
+
Eacute: "É",
|
|
104
|
+
Ecirc: "Ê",
|
|
105
|
+
Euml: "Ë",
|
|
106
|
+
Igrave: "Ì",
|
|
107
|
+
Iacute: "Í",
|
|
108
|
+
Icirc: "Î",
|
|
109
|
+
Iuml: "Ï",
|
|
110
|
+
Ntilde: "Ñ",
|
|
111
|
+
Ograve: "Ò",
|
|
112
|
+
Oacute: "Ó",
|
|
113
|
+
Ocirc: "Ô",
|
|
114
|
+
Otilde: "Õ",
|
|
115
|
+
Ouml: "Ö",
|
|
116
|
+
Oslash: "Ø",
|
|
117
|
+
Ugrave: "Ù",
|
|
118
|
+
Uacute: "Ú",
|
|
119
|
+
Ucirc: "Û",
|
|
120
|
+
Uuml: "Ü",
|
|
121
|
+
Yacute: "Ý",
|
|
122
|
+
// Punctuation these same pages emit around titles.
|
|
123
|
+
deg: "°",
|
|
124
|
+
hellip: "…",
|
|
125
|
+
laquo: "«",
|
|
126
|
+
raquo: "»",
|
|
127
|
+
ndash: "–",
|
|
128
|
+
mdash: "—",
|
|
129
|
+
lsquo: "‘",
|
|
130
|
+
rsquo: "’",
|
|
131
|
+
ldquo: "“",
|
|
132
|
+
rdquo: "”",
|
|
133
|
+
middot: "·",
|
|
134
|
+
euro: "€"
|
|
135
|
+
};
|
|
136
|
+
var CASE_INSENSITIVE_NAMES = /* @__PURE__ */ new Set(["amp", "lt", "gt", "quot", "apos", "nbsp"]);
|
|
137
|
+
function isEmittableCodePoint(code) {
|
|
138
|
+
return code === 9 || code === 10 || code === 13 || code >= 32 && code <= 55295 || code >= 57344 && code <= 65533 || code >= 65536 && code <= 1114111;
|
|
139
|
+
}
|
|
140
|
+
__name(isEmittableCodePoint, "isEmittableCodePoint");
|
|
141
|
+
function decodeEntities(s) {
|
|
142
|
+
return s.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (m, body) => {
|
|
143
|
+
if (body[0] === "#") {
|
|
144
|
+
const isHex = body[1] === "x" || body[1] === "X";
|
|
145
|
+
const code = parseInt(body.slice(isHex ? 2 : 1), isHex ? 16 : 10);
|
|
146
|
+
return isEmittableCodePoint(code) ? String.fromCodePoint(code) : m;
|
|
147
|
+
}
|
|
148
|
+
if (Object.hasOwn(NAMED_ENTITIES, body)) return NAMED_ENTITIES[body];
|
|
149
|
+
const lower = body.toLowerCase();
|
|
150
|
+
return CASE_INSENSITIVE_NAMES.has(lower) ? NAMED_ENTITIES[lower] : m;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
__name(decodeEntities, "decodeEntities");
|
|
154
|
+
|
|
155
|
+
// apps/daemon/src/data/readers/career-ops/_html-to-text.mjs
|
|
156
|
+
var DESCRIPTION_CAP = 4e3;
|
|
157
|
+
var HTML_TAG_RE = /<(?:[^>"']|"[^"]*"|'[^']*')+>/g;
|
|
158
|
+
var HTML_MEDIA_RE = /<(script|style)\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1\s*>/gi;
|
|
159
|
+
function stripMarkup(content) {
|
|
160
|
+
return content.replace(HTML_MEDIA_RE, " ").replace(HTML_TAG_RE, " ");
|
|
161
|
+
}
|
|
162
|
+
__name(stripMarkup, "stripMarkup");
|
|
163
|
+
function htmlToText(content) {
|
|
164
|
+
if (typeof content !== "string" || !content) return "";
|
|
165
|
+
const decoded = decodeEntities(stripMarkup(content));
|
|
166
|
+
const decodedTwice = decodeEntities(stripMarkup(decoded));
|
|
167
|
+
return stripMarkup(decodedTwice).replace(/<(?=\/?[a-z!?])/gi, "").replace(/\s+/g, " ").trim().slice(0, DESCRIPTION_CAP);
|
|
168
|
+
}
|
|
169
|
+
__name(htmlToText, "htmlToText");
|
|
170
|
+
|
|
171
|
+
// apps/daemon/src/data/readers/career-ops/mokahr.mjs
|
|
172
|
+
var API = "https://app.mokahr.com/api/outer/ats-apply/website/jobs/v2";
|
|
173
|
+
var DETAIL_HOST = "app.mokahr.com";
|
|
174
|
+
var AES_IV = Buffer.from("de7c21ed8d6f50fe", "utf8");
|
|
175
|
+
var MAX_LIMIT = 50;
|
|
176
|
+
var DEFAULT_KEYWORDS = [""];
|
|
177
|
+
var DEFAULT_MAX_PAGES = 10;
|
|
178
|
+
var INTER_PAGE_DELAY_MS = 400;
|
|
179
|
+
var TENANT_PATH_RE = /^\/(?:social-recruitment|campus-recruitment|apply)\/([^/]+)\/(\d+)\/?$/;
|
|
180
|
+
var ROBOTS_EXCLUDED_PATHS = /* @__PURE__ */ new Set([
|
|
181
|
+
"/social-recruitment/lingjuninvest/46355",
|
|
182
|
+
"/social-recruitment/shopee/74378"
|
|
183
|
+
]);
|
|
184
|
+
function parseTenantUrl(url) {
|
|
185
|
+
let u;
|
|
186
|
+
try {
|
|
187
|
+
u = new URL(url);
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (u.protocol !== "https:" || u.hostname !== DETAIL_HOST) return null;
|
|
192
|
+
const m = TENANT_PATH_RE.exec(u.pathname);
|
|
193
|
+
if (!m) return null;
|
|
194
|
+
const siteId = Number(m[2]);
|
|
195
|
+
if (!Number.isSafeInteger(siteId) || siteId <= 0) return null;
|
|
196
|
+
const pathname = u.pathname.replace(/\/$/, "");
|
|
197
|
+
if (ROBOTS_EXCLUDED_PATHS.has(pathname)) return null;
|
|
198
|
+
return { orgId: m[1], siteId, baseUrl: `${u.origin}${pathname}` };
|
|
199
|
+
}
|
|
200
|
+
__name(parseTenantUrl, "parseTenantUrl");
|
|
201
|
+
function decryptMokaHrEnvelope(envelope) {
|
|
202
|
+
if (!envelope?.data || !envelope?.necromancer) {
|
|
203
|
+
throw new Error("mokahr: response missing data/necromancer — not the expected envelope shape");
|
|
204
|
+
}
|
|
205
|
+
const key = Buffer.from(envelope.necromancer, "utf8");
|
|
206
|
+
if (key.length !== 16) {
|
|
207
|
+
throw new Error(`mokahr: necromancer key is ${key.length} bytes, expected 16 (aes-128-cbc)`);
|
|
208
|
+
}
|
|
209
|
+
const ciphertext = Buffer.from(envelope.data, "base64");
|
|
210
|
+
const decipher = createDecipheriv("aes-128-cbc", key, AES_IV);
|
|
211
|
+
const plain2 = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
212
|
+
return JSON.parse(plain2.toString("utf8"));
|
|
213
|
+
}
|
|
214
|
+
__name(decryptMokaHrEnvelope, "decryptMokaHrEnvelope");
|
|
215
|
+
function parseMokaHrJobs(decrypted, companyName, tenantBaseUrl) {
|
|
216
|
+
const list = decrypted?.data?.jobs;
|
|
217
|
+
if (!Array.isArray(list)) return [];
|
|
218
|
+
const jobs = [];
|
|
219
|
+
for (const j of list) {
|
|
220
|
+
const title = j?.title;
|
|
221
|
+
const id = j?.id;
|
|
222
|
+
if (!title || id == null) continue;
|
|
223
|
+
const encodedId = encodeURIComponent(String(id));
|
|
224
|
+
const cities = Array.isArray(j.locations) ? j.locations.map((l) => [l?.provinceName, l?.cityName].filter(Boolean).join(" ")).filter(Boolean).join("/") : "";
|
|
225
|
+
const createdAt = typeof j.createdAt === "string" ? j.createdAt.trim() : "";
|
|
226
|
+
const ts = /(?:Z|[+-]\d{2}:\d{2})$/i.test(createdAt) ? Date.parse(createdAt) : NaN;
|
|
227
|
+
jobs.push({
|
|
228
|
+
title,
|
|
229
|
+
// The tenant frontend links each posting through its hash router. The
|
|
230
|
+
// scanner/tracker normalizers recognize this exact Moka route and promote
|
|
231
|
+
// its ID into their internal comparison keys before dropping the fragment.
|
|
232
|
+
url: `${tenantBaseUrl}#/job/${encodedId}`,
|
|
233
|
+
company: companyName,
|
|
234
|
+
location: cities,
|
|
235
|
+
description: [
|
|
236
|
+
j.commitment && `类型: ${j.commitment}`,
|
|
237
|
+
j.department?.name && `部门: ${j.department.name}`,
|
|
238
|
+
htmlToText(j.jobDescription)
|
|
239
|
+
].filter(Boolean).join("\n").slice(0, 4e3),
|
|
240
|
+
postedAt: Number.isFinite(ts) ? ts : void 0
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return jobs;
|
|
244
|
+
}
|
|
245
|
+
__name(parseMokaHrJobs, "parseMokaHrJobs");
|
|
246
|
+
var mokahr_default = {
|
|
247
|
+
id: "mokahr",
|
|
248
|
+
detect(entry) {
|
|
249
|
+
const url = entry.careers_url;
|
|
250
|
+
if (typeof url !== "string") return null;
|
|
251
|
+
if (!parseTenantUrl(url)) return null;
|
|
252
|
+
return { url };
|
|
253
|
+
},
|
|
254
|
+
async fetch(entry, ctx) {
|
|
255
|
+
const tenant = parseTenantUrl(entry.careers_url);
|
|
256
|
+
if (!tenant) {
|
|
257
|
+
throw new Error("mokahr: careers_url must be an allowed HTTPS app.mokahr.com tenant URL with a positive site ID");
|
|
258
|
+
}
|
|
259
|
+
const keywords = Array.isArray(entry.keywords) && entry.keywords.length ? entry.keywords : DEFAULT_KEYWORDS;
|
|
260
|
+
const entryMaxPages = Number(entry.max_pages) > 0 ? Number(entry.max_pages) : DEFAULT_MAX_PAGES;
|
|
261
|
+
const maxPages = Math.min(entryMaxPages, Number(ctx?.maxPages) > 0 ? Number(ctx.maxPages) : Infinity);
|
|
262
|
+
const seen = /* @__PURE__ */ new Map();
|
|
263
|
+
const sleep = /* @__PURE__ */ __name((ms) => typeof ctx?.sleep === "function" ? ctx.sleep(ms) : new Promise((r) => setTimeout(r, ms)), "sleep");
|
|
264
|
+
let firstRequest = true;
|
|
265
|
+
let succeededOnce = false;
|
|
266
|
+
for (const keyword of keywords) {
|
|
267
|
+
for (let page2 = 1; page2 <= maxPages; page2++) {
|
|
268
|
+
if (firstRequest) firstRequest = false;
|
|
269
|
+
else await sleep(INTER_PAGE_DELAY_MS);
|
|
270
|
+
const offset = (page2 - 1) * MAX_LIMIT;
|
|
271
|
+
let envelope;
|
|
272
|
+
try {
|
|
273
|
+
envelope = /** @type {any} */
|
|
274
|
+
await ctx.fetchJson(API, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: { "content-type": "application/json" },
|
|
277
|
+
body: JSON.stringify({
|
|
278
|
+
siteId: tenant.siteId,
|
|
279
|
+
orgId: tenant.orgId,
|
|
280
|
+
locale: "zh-CN",
|
|
281
|
+
limit: MAX_LIMIT,
|
|
282
|
+
offset,
|
|
283
|
+
...keyword ? { keyword } : {}
|
|
284
|
+
}),
|
|
285
|
+
redirect: "error"
|
|
286
|
+
});
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (!succeededOnce) throw err;
|
|
289
|
+
console.error(` ⚠ mokahr: keyword "${keyword}" page ${page2} failed (${err.message}) — keeping the ${seen.size} jobs collected so far`);
|
|
290
|
+
return [...seen.values()];
|
|
291
|
+
}
|
|
292
|
+
let decrypted;
|
|
293
|
+
try {
|
|
294
|
+
decrypted = decryptMokaHrEnvelope(envelope);
|
|
295
|
+
if (decrypted?.success === false) {
|
|
296
|
+
throw new Error(`API error: ${decrypted.msg || decrypted.code || "success=false"}`);
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (!succeededOnce) throw err;
|
|
300
|
+
console.error(` ⚠ mokahr: keyword "${keyword}" page ${page2} failed (${err.message}) — keeping the ${seen.size} jobs collected so far`);
|
|
301
|
+
return [...seen.values()];
|
|
302
|
+
}
|
|
303
|
+
succeededOnce = true;
|
|
304
|
+
const rawJobs = Array.isArray(decrypted?.data?.jobs) ? decrypted.data.jobs : [];
|
|
305
|
+
if (rawJobs.length === 0) break;
|
|
306
|
+
const jobs = parseMokaHrJobs(decrypted, entry.name || tenant.orgId, tenant.baseUrl);
|
|
307
|
+
for (const job of jobs) {
|
|
308
|
+
if (!seen.has(job.url)) seen.set(job.url, job);
|
|
309
|
+
}
|
|
310
|
+
if (rawJobs.length < MAX_LIMIT) break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return [...seen.values()];
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// apps/daemon/src/data/readers/moka.ts
|
|
318
|
+
var provider = /* @__PURE__ */ __name(async () => mokahr_default, "provider");
|
|
319
|
+
var TENANT = /^https:\/\/(?:app\.mokahr\.com\/(campus-recruitment|social-recruitment|apply)|[^/]+\/(campus-recruitment|social-recruitment))\/([^/?#]+)\/(\d+)/;
|
|
320
|
+
function mokaTenant(url) {
|
|
321
|
+
const m = TENANT.exec(url);
|
|
322
|
+
return m ? { orgId: m[3], siteId: m[4], canonical: `https://app.mokahr.com/${m[1] ?? m[2]}/${m[3]}/${m[4]}` } : null;
|
|
323
|
+
}
|
|
324
|
+
__name(mokaTenant, "mokaTenant");
|
|
325
|
+
function toListing(j, cfg) {
|
|
326
|
+
const t = mokaTenant(cfg.url ?? "");
|
|
327
|
+
const idMatch = /#\/job\/([^/?#]+)/.exec(j.url);
|
|
328
|
+
const desc = j.description ?? "";
|
|
329
|
+
const dept = /部门:\s*([^\n]+)/.exec(desc)?.[1]?.trim();
|
|
330
|
+
const commitment = /类型:\s*([^\n]+)/.exec(desc)?.[1]?.trim();
|
|
331
|
+
const degree = /(博士|硕士及以上|硕士|本科及以上|本科|大专)/.exec(desc)?.[1];
|
|
332
|
+
return {
|
|
333
|
+
externalId: idMatch ? decodeURIComponent(idMatch[1]) : j.url,
|
|
334
|
+
url: j.url,
|
|
335
|
+
title: j.title,
|
|
336
|
+
company: cfg.name || j.company || t.orgId,
|
|
337
|
+
orgUnit: dept,
|
|
338
|
+
locations: j.location ? j.location.split("/").map((s) => s.trim()).filter(Boolean) : [],
|
|
339
|
+
description: desc,
|
|
340
|
+
employmentType: commitment,
|
|
341
|
+
datePosted: j.postedAt ? new Date(j.postedAt).toISOString() : void 0,
|
|
342
|
+
degreeReq: degree,
|
|
343
|
+
cohort: cfg.cohort,
|
|
344
|
+
batch: cfg.batch ?? (/campus/.test(cfg.url ?? "") ? "校招" : "社招"),
|
|
345
|
+
kind: cfg.kind ?? (/campus/.test(cfg.url ?? "") ? degree === "博士" ? "博士专项" : "校招" : "社招"),
|
|
346
|
+
publisher: `moka:${t.orgId}`,
|
|
347
|
+
grade: "官方",
|
|
348
|
+
isOriginal: true,
|
|
349
|
+
confidence: 0.9,
|
|
350
|
+
raw: j
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
__name(toListing, "toListing");
|
|
354
|
+
var moka = {
|
|
355
|
+
id: "moka",
|
|
356
|
+
name: "Moka",
|
|
357
|
+
input: "app.mokahr.com/campus-recruitment/<公司>/<站点>(公司自有域名上的同样路径也认)",
|
|
358
|
+
mode: "pull",
|
|
359
|
+
describe: "Moka 招聘站(app.mokahr.com 校招/社招租户),走站点前端自己的接口,零 token",
|
|
360
|
+
detect: /* @__PURE__ */ __name((url) => !!mokaTenant(url), "detect"),
|
|
361
|
+
siteKey: /* @__PURE__ */ __name((url) => {
|
|
362
|
+
const t = mokaTenant(url);
|
|
363
|
+
return `${t.orgId}/${t.siteId}`;
|
|
364
|
+
}, "siteKey"),
|
|
365
|
+
async fetch(cfg, ctx) {
|
|
366
|
+
const tenant = cfg.url ? mokaTenant(cfg.url) : null;
|
|
367
|
+
if (!tenant) throw new Error(`BAD_URL: Moka:认不出租户 ${cfg.url}`);
|
|
368
|
+
const p = await provider();
|
|
369
|
+
const jobs = await p.fetch(
|
|
370
|
+
{ name: cfg.name ?? "", careers_url: tenant.canonical, keywords: cfg.keywords?.length ? cfg.keywords : [""], max_pages: cfg.maxPages ?? 10 },
|
|
371
|
+
{ fetchJson: ctx.fetchJson, fetchText: ctx.fetchText, sleep: ctx.sleep, maxPages: ctx.maxPages, transport: "http" }
|
|
372
|
+
);
|
|
373
|
+
ctx.log(`moka ${cfg.name ?? cfg.url}: ${jobs.length} jobs`);
|
|
374
|
+
return jobs.map((j) => toListing(j, cfg));
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// apps/daemon/src/data/readers/beisen.ts
|
|
379
|
+
var HOST = /^https:\/\/([a-z0-9-]+)\.zhiye\.com(?:\/|$)/i;
|
|
380
|
+
function beisenTenant(url) {
|
|
381
|
+
const m = HOST.exec(url);
|
|
382
|
+
return m ? m[1] : null;
|
|
383
|
+
}
|
|
384
|
+
__name(beisenTenant, "beisenTenant");
|
|
385
|
+
var ZERO_DATE = /^0001-01-01/;
|
|
386
|
+
var okDate = /* @__PURE__ */ __name((s) => s && !ZERO_DATE.test(s) ? new Date(s).toISOString() : void 0, "okDate");
|
|
387
|
+
var clean = /* @__PURE__ */ __name((s) => (s ?? "").replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim(), "clean");
|
|
388
|
+
function apiRowToListing(r, tenant, cfg) {
|
|
389
|
+
const desc = [r.Duty && `岗位职责
|
|
390
|
+
${clean(r.Duty)}`, r.Require && `任职要求
|
|
391
|
+
${clean(r.Require)}`].filter(Boolean).join("\n\n");
|
|
392
|
+
const degree = r.Degree || /(博士|硕士及以上|硕士|本科及以上|本科|大专)/.exec(desc)?.[1] || void 0;
|
|
393
|
+
return {
|
|
394
|
+
externalId: r.Id,
|
|
395
|
+
url: `https://${tenant}.zhiye.com/campus/jobs/${r.Id}`,
|
|
396
|
+
title: clean(r.JobAdName),
|
|
397
|
+
company: cfg.name || tenant,
|
|
398
|
+
orgUnit: clean(r.Org) || void 0,
|
|
399
|
+
locations: (r.LocNames ?? []).map((s) => s.replace(/·/g, " ").trim()).filter(Boolean),
|
|
400
|
+
description: desc,
|
|
401
|
+
employmentType: r.Kind || void 0,
|
|
402
|
+
datePosted: okDate(r.PostDate),
|
|
403
|
+
validThrough: okDate(r.EndTime),
|
|
404
|
+
degreeReq: degree,
|
|
405
|
+
cohort: cfg.cohort,
|
|
406
|
+
batch: cfg.batch ?? "校招",
|
|
407
|
+
kind: cfg.kind ?? (degree === "博士" ? "博士专项" : "校招"),
|
|
408
|
+
publisher: `beisen:${tenant}`,
|
|
409
|
+
grade: "官方",
|
|
410
|
+
isOriginal: true,
|
|
411
|
+
confidence: 0.9,
|
|
412
|
+
raw: r
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
__name(apiRowToListing, "apiRowToListing");
|
|
416
|
+
function parseCmsList(html) {
|
|
417
|
+
const out = [];
|
|
418
|
+
const re = /<a\s+title="([^"]*)"\s+href="\/zpdetail\/(\d+)(?:\?[^"]*)?"[^>]*>[\s\S]*?<\/a>\s*<\/td>([\s\S]*?)<\/tr>/g;
|
|
419
|
+
for (const m of html.matchAll(re)) {
|
|
420
|
+
const cells = [...m[3].matchAll(/<td(?:\s+title="([^"]*)")?[^>]*>([\s\S]*?)<\/td>/g)].map((c) => clean(c[1] ?? c[2]));
|
|
421
|
+
out.push({ id: m[2], title: clean(m[1]), dept: cells[0] ?? "", loc: cells[1] ?? "", date: cells[2] ?? "" });
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
__name(parseCmsList, "parseCmsList");
|
|
426
|
+
function parseCmsDetail(html) {
|
|
427
|
+
const body = html.replace(/<script[\s\S]*?<\/script>/g, "").replace(/<style[\s\S]*?<\/style>/g, "");
|
|
428
|
+
const i = body.search(/岗位职责|职位描述|工作职责|任职要求|岗位要求/);
|
|
429
|
+
const slice = i >= 0 ? body.slice(i - 100, i + 6e3) : body;
|
|
430
|
+
return clean(slice).slice(0, 4e3);
|
|
431
|
+
}
|
|
432
|
+
__name(parseCmsDetail, "parseCmsDetail");
|
|
433
|
+
async function tryApi(tenant, cfg, ctx) {
|
|
434
|
+
const categories = cfg.categories ?? ["2"];
|
|
435
|
+
const rows = [];
|
|
436
|
+
const pageSize = 50;
|
|
437
|
+
for (let page2 = 0; page2 < (cfg.maxPages ?? 10); page2++) {
|
|
438
|
+
let res;
|
|
439
|
+
try {
|
|
440
|
+
res = await ctx.fetchJson(`https://${tenant}.zhiye.com/api/Jobad/GetJobAdPageList`, {
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers: { "content-type": "application/json" },
|
|
443
|
+
body: JSON.stringify({ PageIndex: page2, PageSize: pageSize, Category: categories, KeyWords: cfg.keywords?.[0] ?? "", SpecialType: 0, PortalId: "", DisplayFields: ["Category", "Kind", "LocId", "Org"] }),
|
|
444
|
+
redirect: "manual"
|
|
445
|
+
});
|
|
446
|
+
} catch {
|
|
447
|
+
return page2 === 0 ? null : rows;
|
|
448
|
+
}
|
|
449
|
+
if (!res || res.Code !== 200 || !Array.isArray(res.Data)) return page2 === 0 ? null : rows;
|
|
450
|
+
rows.push(...res.Data);
|
|
451
|
+
if (rows.length >= (res.Count ?? 0) || res.Data.length < pageSize) break;
|
|
452
|
+
await ctx.sleep(400);
|
|
453
|
+
}
|
|
454
|
+
return rows;
|
|
455
|
+
}
|
|
456
|
+
__name(tryApi, "tryApi");
|
|
457
|
+
async function cmsFallback(tenant, cfg, ctx) {
|
|
458
|
+
const base = `https://${tenant}.zhiye.com`;
|
|
459
|
+
const listPath = cfg.listPath ?? "/campus";
|
|
460
|
+
const seen = /* @__PURE__ */ new Map();
|
|
461
|
+
for (let page2 = 0; page2 < (cfg.maxPages ?? 10); page2++) {
|
|
462
|
+
const html = await ctx.fetchText(page2 === 0 ? `${base}${listPath}` : `${base}${listPath}?PageIndex=${page2}`);
|
|
463
|
+
const rows = parseCmsList(html);
|
|
464
|
+
if (rows.length === 0 || rows.every((r) => seen.has(r.id))) break;
|
|
465
|
+
for (const r of rows) seen.set(r.id, r);
|
|
466
|
+
await ctx.sleep(400);
|
|
467
|
+
}
|
|
468
|
+
const out = [];
|
|
469
|
+
const detailCap = cfg.detailCap ?? 60;
|
|
470
|
+
let fetched = 0;
|
|
471
|
+
for (const r of seen.values()) {
|
|
472
|
+
let description = "";
|
|
473
|
+
if (fetched < detailCap) {
|
|
474
|
+
try {
|
|
475
|
+
description = parseCmsDetail(await ctx.fetchText(`${base}/zpdetail/${r.id}`));
|
|
476
|
+
fetched++;
|
|
477
|
+
await ctx.sleep(300);
|
|
478
|
+
} catch {
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const degree = /(博士|硕士及以上|硕士|本科及以上|本科|大专)/.exec(description)?.[1];
|
|
482
|
+
out.push({
|
|
483
|
+
externalId: r.id,
|
|
484
|
+
url: `${base}/zpdetail/${r.id}`,
|
|
485
|
+
title: r.title,
|
|
486
|
+
company: cfg.name || tenant,
|
|
487
|
+
orgUnit: r.dept || void 0,
|
|
488
|
+
locations: r.loc.split(/[,,]/).map((s) => s.trim()).filter(Boolean),
|
|
489
|
+
description,
|
|
490
|
+
datePosted: /^\d{4}-\d{2}-\d{2}$/.test(r.date) ? new Date(r.date).toISOString() : void 0,
|
|
491
|
+
degreeReq: degree,
|
|
492
|
+
cohort: cfg.cohort,
|
|
493
|
+
batch: cfg.batch ?? "校招",
|
|
494
|
+
kind: cfg.kind ?? (degree === "博士" ? "博士专项" : "校招"),
|
|
495
|
+
publisher: `beisen:${tenant}`,
|
|
496
|
+
grade: "官方",
|
|
497
|
+
isOriginal: true,
|
|
498
|
+
confidence: 0.85,
|
|
499
|
+
raw: r
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
__name(cmsFallback, "cmsFallback");
|
|
505
|
+
var beisen = {
|
|
506
|
+
id: "beisen",
|
|
507
|
+
name: "北森",
|
|
508
|
+
input: "<公司>.zhiye.com",
|
|
509
|
+
mode: "pull",
|
|
510
|
+
describe: "北森招聘门户(<租户>.zhiye.com):新版走 GetJobAdPageList 接口,老版解析 /campus 列表页;零 token",
|
|
511
|
+
detect: /* @__PURE__ */ __name((url) => !!beisenTenant(url), "detect"),
|
|
512
|
+
siteKey: /* @__PURE__ */ __name((url) => beisenTenant(url), "siteKey"),
|
|
513
|
+
async fetch(cfg, ctx) {
|
|
514
|
+
const tenant = beisenTenant(cfg.url ?? "");
|
|
515
|
+
if (!tenant) throw new Error(`BAD_URL: 北森:认不出租户 ${cfg.url}`);
|
|
516
|
+
const rows = await tryApi(tenant, cfg, ctx);
|
|
517
|
+
if (rows) {
|
|
518
|
+
ctx.log(`beisen ${tenant}: api ${rows.length} rows`);
|
|
519
|
+
return rows.map((r) => apiRowToListing(r, tenant, cfg));
|
|
520
|
+
}
|
|
521
|
+
const listings = await cmsFallback(tenant, cfg, ctx);
|
|
522
|
+
ctx.log(`beisen ${tenant}: cms ${listings.length} rows`);
|
|
523
|
+
return listings;
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// apps/daemon/src/data/readers/feishu-base.ts
|
|
528
|
+
import { gunzipSync } from "node:zlib";
|
|
529
|
+
|
|
530
|
+
// apps/daemon/src/data/catalog.ts
|
|
531
|
+
import { ulid } from "ulid";
|
|
532
|
+
import { createHash } from "node:crypto";
|
|
533
|
+
var genericCompany = /* @__PURE__ */ __name((name) => /^(本|我|该|某|贵|总)?(公司|司|单位|集团|企业|院|所)$|^(unknown|n\/a|null|none|未知|无|暂无)$/i.test(name.trim()), "genericCompany");
|
|
534
|
+
function companyKey(name) {
|
|
535
|
+
return name.normalize("NFKC").replace(/\s+/g, "").replace(/(有限责任公司|有限公司)$/, "").toLowerCase();
|
|
536
|
+
}
|
|
537
|
+
__name(companyKey, "companyKey");
|
|
538
|
+
function sha(s) {
|
|
539
|
+
return createHash("sha256").update(s).digest("hex");
|
|
540
|
+
}
|
|
541
|
+
__name(sha, "sha");
|
|
542
|
+
function campaignKey(sourceId, publisher, company, cohort, batch) {
|
|
543
|
+
return sha(`${sourceId}|${publisher}|${company}|${cohort}|${batch}`);
|
|
544
|
+
}
|
|
545
|
+
__name(campaignKey, "campaignKey");
|
|
546
|
+
var findCompany = /* @__PURE__ */ __name((db, name) => db.prepare("select id from company where name = ?").get(name) ?? db.prepare("select id from company where norm = ? or aliases like ?").get(companyKey(name), `%${JSON.stringify(name)}%`), "findCompany");
|
|
547
|
+
function upsertCompany(db, name, type = "", industry = "") {
|
|
548
|
+
const found = findCompany(db, name);
|
|
549
|
+
if (found) {
|
|
550
|
+
if (type || industry) db.prepare("update company set type = coalesce(nullif(?, ''), type), industry = coalesce(nullif(?, ''), industry) where id = ?").run(type, industry, found.id);
|
|
551
|
+
return found.id;
|
|
552
|
+
}
|
|
553
|
+
const id = ulid();
|
|
554
|
+
db.prepare("insert into company(id, name, norm, type, industry, created_at) values (?,?,?,?,?,?)").run(id, name, companyKey(name), type, industry, now());
|
|
555
|
+
return id;
|
|
556
|
+
}
|
|
557
|
+
__name(upsertCompany, "upsertCompany");
|
|
558
|
+
function upsertCampaign(db, c) {
|
|
559
|
+
const found = db.prepare("select id from campaign where external_key = ?").get(c.externalKey);
|
|
560
|
+
const t = now();
|
|
561
|
+
if (found) {
|
|
562
|
+
db.prepare(`update campaign set kind=?, cohort=?, batch=?, cities=?, degree_req=?, major_req=?, opens_at=?, closes_at=?, deadline_kind=?, deadline_text=?,
|
|
563
|
+
apply_url=?, notice_url=?, source_grade=?, source_kind=?, has_written_test=?, notes=?, last_verified_at=coalesce(?, last_verified_at), updated_at=? where id=?`).run(
|
|
564
|
+
c.kind,
|
|
565
|
+
c.cohort,
|
|
566
|
+
c.batch,
|
|
567
|
+
json.str(c.cities),
|
|
568
|
+
c.degreeReq,
|
|
569
|
+
c.majorReq,
|
|
570
|
+
c.opensAt,
|
|
571
|
+
c.closesAt,
|
|
572
|
+
c.deadlineKind,
|
|
573
|
+
c.deadlineText,
|
|
574
|
+
c.applyUrl,
|
|
575
|
+
c.noticeUrl,
|
|
576
|
+
c.sourceGrade,
|
|
577
|
+
c.sourceKind,
|
|
578
|
+
c.hasWrittenTest,
|
|
579
|
+
c.notes,
|
|
580
|
+
c.lastVerifiedAt ?? null,
|
|
581
|
+
t,
|
|
582
|
+
found.id
|
|
583
|
+
);
|
|
584
|
+
return { id: found.id, created: false };
|
|
585
|
+
}
|
|
586
|
+
const id = ulid();
|
|
587
|
+
db.prepare(`insert into campaign(id, company_id, external_key, kind, cohort, batch, cities, degree_req, major_req, opens_at, closes_at, deadline_kind, deadline_text,
|
|
588
|
+
apply_url, notice_url, source_grade, source_kind, has_written_test, notes, last_verified_at, created_at, updated_at) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
|
|
589
|
+
id,
|
|
590
|
+
c.companyId,
|
|
591
|
+
c.externalKey,
|
|
592
|
+
c.kind,
|
|
593
|
+
c.cohort,
|
|
594
|
+
c.batch,
|
|
595
|
+
json.str(c.cities),
|
|
596
|
+
c.degreeReq,
|
|
597
|
+
c.majorReq,
|
|
598
|
+
c.opensAt,
|
|
599
|
+
c.closesAt,
|
|
600
|
+
c.deadlineKind,
|
|
601
|
+
c.deadlineText,
|
|
602
|
+
c.applyUrl,
|
|
603
|
+
c.noticeUrl,
|
|
604
|
+
c.sourceGrade,
|
|
605
|
+
c.sourceKind,
|
|
606
|
+
c.hasWrittenTest,
|
|
607
|
+
c.notes,
|
|
608
|
+
c.lastVerifiedAt ?? null,
|
|
609
|
+
t,
|
|
610
|
+
t
|
|
611
|
+
);
|
|
612
|
+
return { id, created: true };
|
|
613
|
+
}
|
|
614
|
+
__name(upsertCampaign, "upsertCampaign");
|
|
615
|
+
var findPostByKey = /* @__PURE__ */ __name((db, campaignId, key) => db.prepare("select id from position where campaign_id = ? and source_key = ?").get(campaignId, key), "findPostByKey");
|
|
616
|
+
var findPostByTitle = /* @__PURE__ */ __name((db, campaignId, title, keyed) => db.prepare(`select id from position where campaign_id = ? and title = ? ${keyed ? "and source_key = ''" : "order by source_key = '' desc"} limit 1`).get(campaignId, title), "findPostByTitle");
|
|
617
|
+
var UPDATE_POST = `update position set title = ?, jd_text = coalesce(nullif(?, ''), jd_text), major_req = coalesce(nullif(?, ''), major_req), degree_req = coalesce(nullif(?, ''), degree_req), closes_at = coalesce(?, closes_at),
|
|
618
|
+
cities = case when ? != '[]' then ? else cities end, source_key = case when ? != '' then ? else source_key end, status = 'open', last_seen_at = ?, updated_at = ? where id = ?`;
|
|
619
|
+
var INSERT_POST = "insert into position(id, campaign_id, title, jd_text, cities, degree_req, major_req, closes_at, status, direction_tags, last_seen_at, created_at, updated_at, source_key) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
|
|
620
|
+
var updatePost = /* @__PURE__ */ __name((db, id, title, extra, t) => {
|
|
621
|
+
const key = extra.sourceKey ?? "", cities = json.str(extra.cities ?? []);
|
|
622
|
+
db.prepare(UPDATE_POST).run(title, extra.jdText ?? "", extra.majorReq ?? "", extra.degreeReq ?? "", extra.closesAt ?? null, cities, cities, key, key, t, t, id);
|
|
623
|
+
}, "updatePost");
|
|
624
|
+
var insertPost = /* @__PURE__ */ __name((db, campaignId, title, extra, t) => {
|
|
625
|
+
const id = ulid();
|
|
626
|
+
db.prepare(INSERT_POST).run(id, campaignId, title, extra.jdText ?? "", json.str(extra.cities ?? []), extra.degreeReq ?? "", extra.majorReq ?? "", extra.closesAt ?? null, "open", json.str(extra.directionTags ?? []), t, t, t, extra.sourceKey ?? "");
|
|
627
|
+
return id;
|
|
628
|
+
}, "insertPost");
|
|
629
|
+
function upsertPosition(db, campaignId, title, extra = {}) {
|
|
630
|
+
const key = extra.sourceKey ?? "";
|
|
631
|
+
const found = (key ? findPostByKey(db, campaignId, key) : void 0) ?? findPostByTitle(db, campaignId, title, !!key);
|
|
632
|
+
const t = now();
|
|
633
|
+
if (found) {
|
|
634
|
+
updatePost(db, found.id, title, extra, t);
|
|
635
|
+
return { id: found.id, created: false };
|
|
636
|
+
}
|
|
637
|
+
return { id: insertPost(db, campaignId, title, extra, t), created: true };
|
|
638
|
+
}
|
|
639
|
+
__name(upsertPosition, "upsertPosition");
|
|
640
|
+
var CAMPAIGN_COLS = "id, external_key, kind, cohort, batch, cities, degree_req, major_req, opens_at, closes_at, deadline_kind, deadline_text, apply_url, notice_url, source_grade, source_kind, has_written_test, notes, last_verified_at";
|
|
641
|
+
var POST_COLS = "id, campaign_id, title, source_key, jd_text, major_req, degree_req, closes_at, cities, status";
|
|
642
|
+
var inChunks = /* @__PURE__ */ __name((xs, f) => {
|
|
643
|
+
for (let i = 0; i < xs.length; i += 500) {
|
|
644
|
+
const part = xs.slice(i, i + 500);
|
|
645
|
+
f(part, part.map(() => "?").join(","));
|
|
646
|
+
}
|
|
647
|
+
}, "inChunks");
|
|
648
|
+
var sameCampaign = /* @__PURE__ */ __name((r, c) => r.kind === c.kind && r.cohort === c.cohort && r.batch === c.batch && r.cities === json.str(c.cities) && r.degree_req === c.degreeReq && r.major_req === c.majorReq && r.opens_at === (c.opensAt ?? null) && r.closes_at === (c.closesAt ?? null) && r.deadline_kind === c.deadlineKind && r.deadline_text === c.deadlineText && r.apply_url === c.applyUrl && r.notice_url === c.noticeUrl && r.source_grade === c.sourceGrade && r.source_kind === c.sourceKind && r.has_written_test === c.hasWrittenTest && r.notes === c.notes, "sameCampaign");
|
|
649
|
+
var Posts = class {
|
|
650
|
+
static {
|
|
651
|
+
__name(this, "Posts");
|
|
652
|
+
}
|
|
653
|
+
rows = [];
|
|
654
|
+
byTitle = /* @__PURE__ */ new Map();
|
|
655
|
+
byKey = /* @__PURE__ */ new Map();
|
|
656
|
+
add(r) {
|
|
657
|
+
this.rows.push(r);
|
|
658
|
+
this.put(r);
|
|
659
|
+
}
|
|
660
|
+
put(r) {
|
|
661
|
+
(this.byTitle.get(r.title) ?? this.byTitle.set(r.title, []).get(r.title)).push(r);
|
|
662
|
+
(this.byKey.get(r.source_key) ?? this.byKey.set(r.source_key, []).get(r.source_key)).push(r);
|
|
663
|
+
}
|
|
664
|
+
drop(r) {
|
|
665
|
+
for (const xs of [this.byTitle.get(r.title), this.byKey.get(r.source_key)]) xs.splice(xs.indexOf(r), 1);
|
|
666
|
+
}
|
|
667
|
+
titled(t) {
|
|
668
|
+
return this.byTitle.get(t) ?? [];
|
|
669
|
+
}
|
|
670
|
+
keyed(k) {
|
|
671
|
+
return this.byKey.get(k) ?? [];
|
|
672
|
+
}
|
|
673
|
+
/** the row as the database now holds it (title and site id included, so the lookups follow) */
|
|
674
|
+
set(r, next) {
|
|
675
|
+
this.drop(r);
|
|
676
|
+
Object.assign(r, next);
|
|
677
|
+
this.put(r);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
var CatalogBatch = class {
|
|
681
|
+
constructor(db, externalKeys) {
|
|
682
|
+
this.db = db;
|
|
683
|
+
this.st = {
|
|
684
|
+
name: db.prepare("select id from company where name = ?"),
|
|
685
|
+
company: db.prepare("select type, industry from company where id = ?"),
|
|
686
|
+
campaign: db.prepare(`select ${CAMPAIGN_COLS} from campaign where external_key = ?`),
|
|
687
|
+
postsOf: db.prepare(`select ${POST_COLS} from position where campaign_id = ?`),
|
|
688
|
+
post: db.prepare(`select ${POST_COLS} from position where id = ?`)
|
|
689
|
+
};
|
|
690
|
+
inChunks([...new Set(externalKeys)], (part, q) => {
|
|
691
|
+
for (const r of db.prepare(`select ${CAMPAIGN_COLS} from campaign where external_key in (${q})`).all(...part)) this.campaigns.set(r.external_key, r);
|
|
692
|
+
});
|
|
693
|
+
const ids = [...this.campaigns.values()].map((c) => c.id);
|
|
694
|
+
for (const id of ids) this.posts.set(id, new Posts());
|
|
695
|
+
inChunks(ids, (part, q) => {
|
|
696
|
+
for (const r of db.prepare(`select ${POST_COLS} from position where campaign_id in (${q})`).all(...part)) this.posts.get(r.campaign_id).add(r);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
db;
|
|
700
|
+
static {
|
|
701
|
+
__name(this, "CatalogBatch");
|
|
702
|
+
}
|
|
703
|
+
/** company name → id; one found by its norm or an alias may resolve elsewhere once a company is added */
|
|
704
|
+
companies = /* @__PURE__ */ new Map();
|
|
705
|
+
byNorm = /* @__PURE__ */ new Set();
|
|
706
|
+
companyRows = /* @__PURE__ */ new Map();
|
|
707
|
+
campaigns = /* @__PURE__ */ new Map();
|
|
708
|
+
// external_key → row
|
|
709
|
+
targets = /* @__PURE__ */ new Map();
|
|
710
|
+
// campaign id → what its last row says
|
|
711
|
+
posts = /* @__PURE__ */ new Map();
|
|
712
|
+
// campaign id → its posts
|
|
713
|
+
seen = /* @__PURE__ */ new Set();
|
|
714
|
+
// posts listed again exactly as they are
|
|
715
|
+
/** companies, campaigns and posts already there that this batch changed (a new one is not counted) */
|
|
716
|
+
updated = 0;
|
|
717
|
+
added = /* @__PURE__ */ new Set();
|
|
718
|
+
// ids this batch created: what a later row changes in them is part of being new
|
|
719
|
+
edit(id) {
|
|
720
|
+
if (!this.added.has(id)) this.updated++;
|
|
721
|
+
}
|
|
722
|
+
st;
|
|
723
|
+
/** The campaigns this batch knows of so far (read up front, or added). */
|
|
724
|
+
campaignIds() {
|
|
725
|
+
return [...this.campaigns.values()].map((c) => c.id);
|
|
726
|
+
}
|
|
727
|
+
company(name, type = "", industry = "") {
|
|
728
|
+
let id = this.companies.get(name);
|
|
729
|
+
if (id === void 0) {
|
|
730
|
+
const exact = this.st.name.get(name);
|
|
731
|
+
const found = exact ?? findCompany(this.db, name);
|
|
732
|
+
if (!found) {
|
|
733
|
+
id = upsertCompany(this.db, name, type, industry);
|
|
734
|
+
for (const n of this.byNorm) this.companies.delete(n);
|
|
735
|
+
this.byNorm.clear();
|
|
736
|
+
this.companies.set(name, id);
|
|
737
|
+
this.companyRows.set(id, { type, industry });
|
|
738
|
+
this.added.add(id);
|
|
739
|
+
return id;
|
|
740
|
+
}
|
|
741
|
+
id = found.id;
|
|
742
|
+
this.companies.set(name, id);
|
|
743
|
+
if (!exact) this.byNorm.add(name);
|
|
744
|
+
}
|
|
745
|
+
if (type || industry) {
|
|
746
|
+
const cur = this.companyRows.get(id) ?? { ...this.st.company.get(id) };
|
|
747
|
+
this.companyRows.set(id, cur);
|
|
748
|
+
const t = type || cur.type, i = industry || cur.industry;
|
|
749
|
+
if (t !== cur.type || i !== cur.industry) {
|
|
750
|
+
this.db.prepare("update company set type = ?, industry = ? where id = ?").run(t, i, id);
|
|
751
|
+
cur.type = t;
|
|
752
|
+
cur.industry = i;
|
|
753
|
+
this.edit(id);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return id;
|
|
757
|
+
}
|
|
758
|
+
/** The campaign's id. Its fields are written at `finish`, from its last row — as the full rewrite leaves them. */
|
|
759
|
+
campaign(c) {
|
|
760
|
+
let cur = this.campaigns.get(c.externalKey);
|
|
761
|
+
if (!cur) {
|
|
762
|
+
cur = this.st.campaign.get(c.externalKey);
|
|
763
|
+
if (!cur) {
|
|
764
|
+
const { id } = upsertCampaign(this.db, c);
|
|
765
|
+
this.campaigns.set(c.externalKey, this.db.prepare(`select ${CAMPAIGN_COLS} from campaign where id = ?`).get(id));
|
|
766
|
+
this.posts.set(id, new Posts());
|
|
767
|
+
this.added.add(id);
|
|
768
|
+
return { id, created: true };
|
|
769
|
+
}
|
|
770
|
+
this.campaigns.set(c.externalKey, cur);
|
|
771
|
+
}
|
|
772
|
+
this.targets.set(cur.id, c);
|
|
773
|
+
return { id: cur.id, created: false };
|
|
774
|
+
}
|
|
775
|
+
position(campaignId, title, extra = {}) {
|
|
776
|
+
let posts = this.posts.get(campaignId);
|
|
777
|
+
if (!posts) {
|
|
778
|
+
this.posts.set(campaignId, posts = new Posts());
|
|
779
|
+
for (const r of this.st.postsOf.all(campaignId)) posts.add(r);
|
|
780
|
+
}
|
|
781
|
+
const key = extra.sourceKey ?? "";
|
|
782
|
+
const asked = /* @__PURE__ */ __name((r) => r ? posts.rows.find((x) => x.id === r.id) : void 0, "asked");
|
|
783
|
+
let found;
|
|
784
|
+
if (key) {
|
|
785
|
+
const m = posts.keyed(key);
|
|
786
|
+
found = m.length > 1 ? asked(findPostByKey(this.db, campaignId, key)) : m[0];
|
|
787
|
+
}
|
|
788
|
+
if (!found) {
|
|
789
|
+
const m = posts.titled(title);
|
|
790
|
+
if (key) found = m.find((r) => r.source_key === "");
|
|
791
|
+
else found = m.find((r) => r.source_key === "") ?? (m.length > 1 ? asked(findPostByTitle(this.db, campaignId, title, false)) : m[0]);
|
|
792
|
+
}
|
|
793
|
+
if (!found) {
|
|
794
|
+
const id = insertPost(this.db, campaignId, title, extra, now());
|
|
795
|
+
posts.add(this.st.post.get(id));
|
|
796
|
+
this.added.add(id);
|
|
797
|
+
return { id, created: true };
|
|
798
|
+
}
|
|
799
|
+
const cities = json.str(extra.cities ?? []);
|
|
800
|
+
const next = {
|
|
801
|
+
title,
|
|
802
|
+
jd_text: extra.jdText || found.jd_text,
|
|
803
|
+
major_req: extra.majorReq || found.major_req,
|
|
804
|
+
degree_req: extra.degreeReq || found.degree_req,
|
|
805
|
+
closes_at: extra.closesAt ?? found.closes_at,
|
|
806
|
+
cities: cities !== "[]" ? cities : found.cities,
|
|
807
|
+
source_key: key !== "" ? key : found.source_key,
|
|
808
|
+
status: "open"
|
|
809
|
+
};
|
|
810
|
+
if (Object.keys(next).some((k) => next[k] !== found[k])) {
|
|
811
|
+
updatePost(this.db, found.id, title, extra, now());
|
|
812
|
+
posts.set(found, next);
|
|
813
|
+
this.seen.delete(found.id);
|
|
814
|
+
this.edit(found.id);
|
|
815
|
+
} else this.seen.add(found.id);
|
|
816
|
+
return { id: found.id, created: false };
|
|
817
|
+
}
|
|
818
|
+
/** Write each campaign from its last row; mark the posts listed again unchanged as seen. */
|
|
819
|
+
finish() {
|
|
820
|
+
const verified = /* @__PURE__ */ new Map();
|
|
821
|
+
for (const cur of this.campaigns.values()) {
|
|
822
|
+
const c = this.targets.get(cur.id);
|
|
823
|
+
if (!c) continue;
|
|
824
|
+
if (!sameCampaign(cur, c)) {
|
|
825
|
+
upsertCampaign(this.db, c);
|
|
826
|
+
this.edit(cur.id);
|
|
827
|
+
} else if (c.lastVerifiedAt != null && c.lastVerifiedAt !== cur.last_verified_at) (verified.get(c.lastVerifiedAt) ?? verified.set(c.lastVerifiedAt, []).get(c.lastVerifiedAt)).push(cur.id);
|
|
828
|
+
}
|
|
829
|
+
for (const [stamp, ids] of verified) this.db.prepare("update campaign set last_verified_at = ? where id in (select value from json_each(?))").run(stamp, JSON.stringify(ids));
|
|
830
|
+
if (this.seen.size) this.db.prepare("update position set last_seen_at = ? where id in (select value from json_each(?))").run(now(), JSON.stringify([...this.seen]));
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// apps/daemon/src/data/sheet.ts
|
|
835
|
+
function parseCsv(text3) {
|
|
836
|
+
const rows = [];
|
|
837
|
+
let row = [];
|
|
838
|
+
let field = "";
|
|
839
|
+
let inQ = false;
|
|
840
|
+
const s = text3.replace(/^/, "");
|
|
841
|
+
for (let i = 0; i < s.length; i++) {
|
|
842
|
+
const c = s[i];
|
|
843
|
+
if (inQ) {
|
|
844
|
+
if (c === '"') {
|
|
845
|
+
if (s[i + 1] === '"') {
|
|
846
|
+
field += '"';
|
|
847
|
+
i++;
|
|
848
|
+
} else inQ = false;
|
|
849
|
+
} else field += c;
|
|
850
|
+
} else if (c === '"') inQ = true;
|
|
851
|
+
else if (c === ",") {
|
|
852
|
+
row.push(field);
|
|
853
|
+
field = "";
|
|
854
|
+
} else if (c === "\n" || c === "\r") {
|
|
855
|
+
if (c === "\r" && s[i + 1] === "\n") i++;
|
|
856
|
+
row.push(field);
|
|
857
|
+
field = "";
|
|
858
|
+
rows.push(row);
|
|
859
|
+
row = [];
|
|
860
|
+
} else field += c;
|
|
861
|
+
}
|
|
862
|
+
if (field.length || row.length) {
|
|
863
|
+
row.push(field);
|
|
864
|
+
rows.push(row);
|
|
865
|
+
}
|
|
866
|
+
return rows.filter((r) => r.some((f) => f.trim() !== ""));
|
|
867
|
+
}
|
|
868
|
+
__name(parseCsv, "parseCsv");
|
|
869
|
+
function parseDeadline(text3) {
|
|
870
|
+
const t = (text3 ?? "").trim();
|
|
871
|
+
if (!t) return { closesAt: null, kind: "unknown" };
|
|
872
|
+
if (/招满|长期|滚动|持续|尽快|随时/.test(t)) return { closesAt: null, kind: /滚动|持续|尽快|随时/.test(t) ? "rolling" : "until_filled" };
|
|
873
|
+
const m = t.match(/(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})/);
|
|
874
|
+
if (m) return { closesAt: `${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")}`, kind: "date" };
|
|
875
|
+
const md = t.match(/(\d{1,2})月(\d{1,2})日?/);
|
|
876
|
+
if (md) {
|
|
877
|
+
const y = (/* @__PURE__ */ new Date()).getFullYear();
|
|
878
|
+
return { closesAt: `${y}-${md[1].padStart(2, "0")}-${md[2].padStart(2, "0")}`, kind: "date" };
|
|
879
|
+
}
|
|
880
|
+
return { closesAt: null, kind: "unknown" };
|
|
881
|
+
}
|
|
882
|
+
__name(parseDeadline, "parseDeadline");
|
|
883
|
+
function splitList(s) {
|
|
884
|
+
return (s ?? "").split(/[,,、;;|\s]+/).map((x) => x.trim()).filter(Boolean);
|
|
885
|
+
}
|
|
886
|
+
__name(splitList, "splitList");
|
|
887
|
+
function gradeFromSource(source, noticeUrl) {
|
|
888
|
+
if (/官网|官方|校园招聘网|careers|mokahr|beisen|zhiye/i.test(source + noticeUrl)) return "官方";
|
|
889
|
+
if (/就业|高校|大学|学院/.test(source)) return "校方转载";
|
|
890
|
+
if (/mp\.weixin|公众号/.test(source + noticeUrl)) return "校方转载";
|
|
891
|
+
return "聚合";
|
|
892
|
+
}
|
|
893
|
+
__name(gradeFromSource, "gradeFromSource");
|
|
894
|
+
var FIELDS = {
|
|
895
|
+
company: "公司",
|
|
896
|
+
positions: "岗位",
|
|
897
|
+
deadline: "截止时间",
|
|
898
|
+
applyUrl: "投递链接",
|
|
899
|
+
noticeUrl: "公告链接",
|
|
900
|
+
cities: "工作地点",
|
|
901
|
+
degree: "学历要求",
|
|
902
|
+
major: "专业要求",
|
|
903
|
+
cohort: "届次",
|
|
904
|
+
batch: "批次",
|
|
905
|
+
companyType: "企业类型",
|
|
906
|
+
industry: "行业",
|
|
907
|
+
source: "信息来源"
|
|
908
|
+
};
|
|
909
|
+
var BUILTIN_COLUMNS = {
|
|
910
|
+
company: "公司",
|
|
911
|
+
positions: "招聘岗位",
|
|
912
|
+
deadline: "截止时间",
|
|
913
|
+
applyUrl: "简历投递链接",
|
|
914
|
+
noticeUrl: "公告链接",
|
|
915
|
+
cities: "工作地点",
|
|
916
|
+
degree: "学历要求",
|
|
917
|
+
major: "专业要求",
|
|
918
|
+
cohort: "招聘届次",
|
|
919
|
+
batch: "批次",
|
|
920
|
+
companyType: "企业类型",
|
|
921
|
+
industry: "行业类别",
|
|
922
|
+
source: "公告来源"
|
|
923
|
+
};
|
|
924
|
+
var ALIASES = {
|
|
925
|
+
company: ["公司", "公司名称", "企业", "企业名称", "单位", "单位名称", "用人单位", "招聘单位", "company", "employer"],
|
|
926
|
+
positions: ["招聘岗位", "岗位", "岗位名称", "职位", "职位名称", "招聘职位", "岗位类别", "position", "title", "job"],
|
|
927
|
+
deadline: ["截止时间", "截止日期", "截止", "网申截止", "投递截止", "报名截止", "ddl", "deadline"],
|
|
928
|
+
applyUrl: ["简历投递链接", "投递链接", "网申链接", "网申地址", "投递地址", "申请链接", "投递方式", "apply"],
|
|
929
|
+
noticeUrl: ["公告链接", "招聘公告", "原文链接", "详情链接", "公告", "链接", "notice", "url", "link"],
|
|
930
|
+
cities: ["工作地点", "地点", "城市", "工作城市", "location", "city"],
|
|
931
|
+
degree: ["学历要求", "学历", "degree"],
|
|
932
|
+
major: ["专业要求", "专业", "major"],
|
|
933
|
+
cohort: ["招聘届次", "届次", "届别", "毕业年份", "cohort"],
|
|
934
|
+
batch: ["批次", "招聘批次", "招聘类型", "batch"],
|
|
935
|
+
companyType: ["企业类型", "公司类型", "单位性质", "企业性质"],
|
|
936
|
+
industry: ["行业类别", "行业", "所属行业", "industry"],
|
|
937
|
+
source: ["公告来源", "信息来源", "来源", "source"]
|
|
938
|
+
};
|
|
939
|
+
var plain = /* @__PURE__ */ __name((s) => s.normalize("NFKC").replace(/[\s::()()_\-/]/g, "").toLowerCase(), "plain");
|
|
940
|
+
function guessColumns(headers) {
|
|
941
|
+
const out = {}, taken = /* @__PURE__ */ new Set();
|
|
942
|
+
for (const exact of [true, false]) for (const f of Object.keys(ALIASES)) {
|
|
943
|
+
if (out[f]) continue;
|
|
944
|
+
const h = headers.find((h2) => !taken.has(h2) && ALIASES[f].some((a) => exact ? plain(h2) === plain(a) : plain(h2).includes(plain(a))));
|
|
945
|
+
if (h) {
|
|
946
|
+
out[f] = h;
|
|
947
|
+
taken.add(h);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return out;
|
|
951
|
+
}
|
|
952
|
+
__name(guessColumns, "guessColumns");
|
|
953
|
+
function splitCell(s) {
|
|
954
|
+
const cjk = /[\u3400-\u9fff]/.test(s ?? "");
|
|
955
|
+
return (s ?? "").split(cjk ? /[,,、;;|\s]+/ : /[,,、;;|\n\r]+/).map((x) => x.trim()).filter(Boolean);
|
|
956
|
+
}
|
|
957
|
+
__name(splitCell, "splitCell");
|
|
958
|
+
function tableToListings(table, columns, cfg, today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), publisher = "汇总表") {
|
|
959
|
+
const get = /* @__PURE__ */ __name((row, f, want) => columns[f] ? row.get(columns[f], want) : "", "get");
|
|
960
|
+
const wantCohort = (cfg.cohort ?? "").replace(/\D/g, "");
|
|
961
|
+
const split = columns === BUILTIN_COLUMNS ? splitList : splitCell;
|
|
962
|
+
const out = [];
|
|
963
|
+
for (const row of table.rows) {
|
|
964
|
+
const company = get(row, "company").trim();
|
|
965
|
+
const cohort = get(row, "cohort");
|
|
966
|
+
if (!company || wantCohort && columns.cohort && !cohort.includes(wantCohort)) continue;
|
|
967
|
+
const deadlineText = get(row, "deadline");
|
|
968
|
+
const dl = parseDeadline(deadlineText);
|
|
969
|
+
if (dl.closesAt && dl.closesAt < today) continue;
|
|
970
|
+
const batch = get(row, "batch"), sourceText = get(row, "source");
|
|
971
|
+
const noticeUrl = get(row, "noticeUrl", "link") || get(row, "noticeUrl");
|
|
972
|
+
const applyUrl = get(row, "applyUrl", "link") || get(row, "applyUrl");
|
|
973
|
+
const titles = split(get(row, "positions")).slice(0, 40);
|
|
974
|
+
const g = gradeFromSource(sourceText, noticeUrl);
|
|
975
|
+
const rowId = row.id || sha(`${company}|${cohort}|${batch}`).slice(0, 16);
|
|
976
|
+
for (const title of titles.length ? titles : [batch || "校招"]) {
|
|
977
|
+
out.push({
|
|
978
|
+
externalId: `${rowId}|${title}`,
|
|
979
|
+
url: applyUrl || noticeUrl,
|
|
980
|
+
noticeUrl,
|
|
981
|
+
title,
|
|
982
|
+
company,
|
|
983
|
+
companyType: get(row, "companyType"),
|
|
984
|
+
industry: get(row, "industry"),
|
|
985
|
+
locations: split(get(row, "cities")),
|
|
986
|
+
deadlineText,
|
|
987
|
+
degreeReq: get(row, "degree"),
|
|
988
|
+
majorReq: get(row, "major"),
|
|
989
|
+
cohort,
|
|
990
|
+
batch,
|
|
991
|
+
kind: /博士/.test(batch + title) ? "博士专项" : /实习/.test(batch) ? "实习" : "校招",
|
|
992
|
+
publisher: cfg.name || publisher,
|
|
993
|
+
grade: g === "官方" ? "聚合" : g,
|
|
994
|
+
isOriginal: false,
|
|
995
|
+
confidence: 0.7
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return out;
|
|
1000
|
+
}
|
|
1001
|
+
__name(tableToListings, "tableToListings");
|
|
1002
|
+
function listingsFromTable(table, cfg, publisher) {
|
|
1003
|
+
const columns = cfg.columns ?? BUILTIN_COLUMNS;
|
|
1004
|
+
if (!columns.company || !table.headers.includes(columns.company)) throw new Error(`NO_COMPANY_COLUMN: 表里没有「${columns.company ?? "公司"}」这一列(现有的列:${table.headers.slice(0, 12).join("、")})`);
|
|
1005
|
+
return tableToListings(table, columns, cfg, void 0, publisher);
|
|
1006
|
+
}
|
|
1007
|
+
__name(listingsFromTable, "listingsFromTable");
|
|
1008
|
+
function csvToTable(text3) {
|
|
1009
|
+
const [head = [], ...rows] = parseCsv(text3);
|
|
1010
|
+
const headers = head.map((h) => h.trim());
|
|
1011
|
+
const at = new Map(headers.map((h, i) => [h, i]));
|
|
1012
|
+
return { headers, rows: rows.map((r) => ({ id: "", get: /* @__PURE__ */ __name((h) => (r[at.get(h) ?? -1] ?? "").trim(), "get") })) };
|
|
1013
|
+
}
|
|
1014
|
+
__name(csvToTable, "csvToTable");
|
|
1015
|
+
|
|
1016
|
+
// apps/daemon/src/data/readers/feishu-base.ts
|
|
1017
|
+
var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
|
|
1018
|
+
var PAGE = 3e3;
|
|
1019
|
+
function parseBaseUrl(url) {
|
|
1020
|
+
try {
|
|
1021
|
+
const u = new URL(url);
|
|
1022
|
+
const m = /^\/base\/([A-Za-z0-9]+)/.exec(u.pathname);
|
|
1023
|
+
const table = u.searchParams.get("table");
|
|
1024
|
+
if (!/\.(feishu\.cn|larksuite\.com)$/.test(u.hostname) || !m || !table) return null;
|
|
1025
|
+
return { origin: u.origin, base: m[1], table, view: u.searchParams.get("view") ?? "" };
|
|
1026
|
+
} catch {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
__name(parseBaseUrl, "parseBaseUrl");
|
|
1031
|
+
async function anonymousCookies(url, signal) {
|
|
1032
|
+
const jar = /* @__PURE__ */ new Map();
|
|
1033
|
+
let next = url;
|
|
1034
|
+
for (let i = 0; i < 12; i++) {
|
|
1035
|
+
const res = await fetch(next, { redirect: "manual", signal, headers: { "user-agent": UA, "accept-language": "zh-CN,zh", cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; ") } });
|
|
1036
|
+
for (const c of res.headers.getSetCookie()) {
|
|
1037
|
+
const [kv] = c.split(";");
|
|
1038
|
+
const eq = kv.indexOf("=");
|
|
1039
|
+
jar.set(kv.slice(0, eq).trim(), kv.slice(eq + 1).trim());
|
|
1040
|
+
}
|
|
1041
|
+
const loc = res.headers.get("location");
|
|
1042
|
+
if (res.status < 300 || res.status >= 400 || !loc) break;
|
|
1043
|
+
next = new URL(loc, next).href;
|
|
1044
|
+
}
|
|
1045
|
+
if (!jar.has("session")) throw new Error("NOT_PUBLIC: 这张表没有开「互联网上获得链接的人可阅读」,或链接已失效");
|
|
1046
|
+
return [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
1047
|
+
}
|
|
1048
|
+
__name(anonymousCookies, "anonymousCookies");
|
|
1049
|
+
var unzip = /* @__PURE__ */ __name((s) => JSON.parse(gunzipSync(Buffer.from(s, "base64")).toString("utf8")), "unzip");
|
|
1050
|
+
function toTable(fieldMap, recordMap) {
|
|
1051
|
+
const byName = Object.fromEntries(Object.entries(fieldMap).map(([id, f]) => [f.name.trim(), { id, ...f }]));
|
|
1052
|
+
const cell = /* @__PURE__ */ __name((rec, name, want = "text") => {
|
|
1053
|
+
const f = byName[name];
|
|
1054
|
+
const v = f && rec[f.id]?.value;
|
|
1055
|
+
if (v == null) return "";
|
|
1056
|
+
const opt = /* @__PURE__ */ __name((id) => f.property?.options?.find((o) => o.id === id)?.name ?? "", "opt");
|
|
1057
|
+
if (typeof v === "number") return new Date(v + 8 * 36e5).toISOString().slice(0, 10);
|
|
1058
|
+
if (typeof v === "string") return opt(v) || v;
|
|
1059
|
+
if (Array.isArray(v)) return v.map((x) => typeof x === "string" ? opt(x) : want === "link" ? x?.link ?? "" : x?.text ?? "").filter(Boolean).join(typeof v[0] === "string" ? "、" : "");
|
|
1060
|
+
return "";
|
|
1061
|
+
}, "cell");
|
|
1062
|
+
return { headers: Object.keys(byName), rows: Object.entries(recordMap).map(([id, rec]) => ({ id, get: /* @__PURE__ */ __name((h, want) => cell(rec, h, want), "get") })) };
|
|
1063
|
+
}
|
|
1064
|
+
__name(toTable, "toTable");
|
|
1065
|
+
var feishuBase = {
|
|
1066
|
+
id: "feishu-base",
|
|
1067
|
+
name: "飞书多维表格",
|
|
1068
|
+
input: "公开的 feishu.cn/base/…?table=… 链接",
|
|
1069
|
+
aggregate: true,
|
|
1070
|
+
mode: "pull",
|
|
1071
|
+
describe: "飞书多维表格(公开链接,匿名读取):按订阅里存的列对应读成岗位,没有对应时按内置 17 列格式;按届次过滤,跳过已截止的行",
|
|
1072
|
+
detect: /* @__PURE__ */ __name((url) => !!parseBaseUrl(url), "detect"),
|
|
1073
|
+
siteKey: /* @__PURE__ */ __name((url) => {
|
|
1074
|
+
const u = parseBaseUrl(url);
|
|
1075
|
+
return `${u.base}/${u.table}`;
|
|
1076
|
+
}, "siteKey"),
|
|
1077
|
+
async table(config, ctx) {
|
|
1078
|
+
const { fieldMap, records } = await readBase(config.url ?? "", (m) => ctx.log(`feishu-base ${config.id}: ${m}`), ctx.signal);
|
|
1079
|
+
return toTable(fieldMap, records);
|
|
1080
|
+
},
|
|
1081
|
+
async fetch(config, ctx) {
|
|
1082
|
+
return listingsFromTable(await this.table(config, ctx), config, "飞书汇总表");
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
async function readBase(url, log = () => {
|
|
1086
|
+
}, signal) {
|
|
1087
|
+
const u = parseBaseUrl(url);
|
|
1088
|
+
if (!u) throw new Error(`BAD_URL: 需要 https://<tenant>.feishu.cn/base/<token>?table=…,收到 ${url}`);
|
|
1089
|
+
const cookie = await anonymousCookies(url, signal);
|
|
1090
|
+
const api2 = `${u.origin}/space/api/v1/bitable/${u.base}`;
|
|
1091
|
+
const get = /* @__PURE__ */ __name(async (path) => {
|
|
1092
|
+
const res = await fetch(api2 + path, { signal: signal ?? AbortSignal.timeout(9e4), headers: { cookie, "user-agent": UA, referer: `${u.origin}/base/${u.base}` } });
|
|
1093
|
+
const j = await res.json();
|
|
1094
|
+
if (j.code !== 0) throw new Error(`FAILED: 飞书接口 ${j.code}:${j.msg}`);
|
|
1095
|
+
return j.data;
|
|
1096
|
+
}, "get");
|
|
1097
|
+
const q = `tableID=${u.table}&viewID=${u.view}`;
|
|
1098
|
+
const head = unzip((await get(`/clientvars?${q}&recordLimit=1&ondemandLimit=1&needBase=false&viewLazyLoad=true&openType=0`)).table);
|
|
1099
|
+
const total = head.meta?.recordsNum ?? 0;
|
|
1100
|
+
const records = {};
|
|
1101
|
+
for (let offset = 0; offset < Math.max(total, 1); offset += PAGE) {
|
|
1102
|
+
const page2 = unzip((await get(`/records?${q}&offset=${offset}&limit=${PAGE}`)).records).recordMap ?? {};
|
|
1103
|
+
Object.assign(records, page2);
|
|
1104
|
+
log(`${Object.keys(records).length}/${total}`);
|
|
1105
|
+
if (Object.keys(page2).length < PAGE) break;
|
|
1106
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
1107
|
+
}
|
|
1108
|
+
if (total && Object.keys(records).length < total * 0.9) throw new Error(`FAILED: 只拿到 ${Object.keys(records).length}/${total} 行`);
|
|
1109
|
+
return { fieldMap: head.fieldMap, records };
|
|
1110
|
+
}
|
|
1111
|
+
__name(readBase, "readBase");
|
|
1112
|
+
|
|
1113
|
+
// apps/daemon/src/data/readers/feishu-jobs.ts
|
|
1114
|
+
var HOST2 = /^https:\/\/([a-z0-9-]+)\.jobs\.feishu\.cn(?:\/([^/?#]*))?/i;
|
|
1115
|
+
var SOCIAL_PATH = /^(experienced|social|society|shezhao)/i;
|
|
1116
|
+
function feishuTenant(url) {
|
|
1117
|
+
const m = HOST2.exec(url);
|
|
1118
|
+
if (!m || m[2] && SOCIAL_PATH.test(m[2])) return null;
|
|
1119
|
+
const path = m[2] && !/^(position|api)$/i.test(m[2]) ? m[2] : "index";
|
|
1120
|
+
const project = (() => {
|
|
1121
|
+
try {
|
|
1122
|
+
return new URL(url).searchParams.get("project") ?? void 0;
|
|
1123
|
+
} catch {
|
|
1124
|
+
return void 0;
|
|
1125
|
+
}
|
|
1126
|
+
})();
|
|
1127
|
+
return { tenant: m[1], path, project };
|
|
1128
|
+
}
|
|
1129
|
+
__name(feishuTenant, "feishuTenant");
|
|
1130
|
+
var clean2 = /* @__PURE__ */ __name((s) => (s ?? "").replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(), "clean");
|
|
1131
|
+
function postToListing(p, t, cfg) {
|
|
1132
|
+
const desc = [clean2(p.description) && `岗位职责
|
|
1133
|
+
${clean2(p.description)}`, clean2(p.requirement) && `任职要求
|
|
1134
|
+
${clean2(p.requirement)}`].filter(Boolean).join("\n\n");
|
|
1135
|
+
const degree = /(博士|硕士及以上|硕士|本科及以上|本科|大专)/.exec(desc)?.[1];
|
|
1136
|
+
const family = p.recruit_type?.parent?.name ?? p.recruit_type?.name ?? "";
|
|
1137
|
+
const subject = p.job_subject?.name?.zh_cn ?? p.job_subject?.name?.i18n ?? "";
|
|
1138
|
+
const cohortIn = /(\d{2,4})\s*届/.exec(subject)?.[1];
|
|
1139
|
+
const cities = (p.city_list ?? []).map((c) => c.name ?? "").filter(Boolean);
|
|
1140
|
+
return {
|
|
1141
|
+
externalId: p.id,
|
|
1142
|
+
url: `https://${t.tenant}.jobs.feishu.cn/${t.path}/position/${p.id}/detail/`,
|
|
1143
|
+
title: clean2(p.title),
|
|
1144
|
+
company: cfg.name || t.tenant,
|
|
1145
|
+
orgUnit: p.job_function?.name || void 0,
|
|
1146
|
+
locations: cities.length ? cities : p.city_info?.name ? [p.city_info.name] : [],
|
|
1147
|
+
description: desc,
|
|
1148
|
+
employmentType: p.recruit_type?.name || void 0,
|
|
1149
|
+
datePosted: p.publish_time ? new Date(p.publish_time).toISOString() : void 0,
|
|
1150
|
+
degreeReq: degree,
|
|
1151
|
+
cohort: cfg.cohort ?? (cohortIn ? `${cohortIn.length === 2 ? "20" + cohortIn : cohortIn}届` : void 0),
|
|
1152
|
+
batch: cfg.batch ?? (subject || (family.includes("校招") ? "校招" : "社招")),
|
|
1153
|
+
kind: cfg.kind ?? (family.includes("校招") || /实习/.test(p.recruit_type?.name ?? "") ? degree === "博士" ? "博士专项" : "校招" : "社招"),
|
|
1154
|
+
publisher: `feishu-jobs:${t.tenant}`,
|
|
1155
|
+
grade: "官方",
|
|
1156
|
+
isOriginal: true,
|
|
1157
|
+
confidence: 0.9,
|
|
1158
|
+
raw: p
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
__name(postToListing, "postToListing");
|
|
1162
|
+
var UA_HEADERS = { "content-type": "application/json", "portal-channel": "official", "portal-platform": "pc", "accept-language": "zh-CN" };
|
|
1163
|
+
var feishuJobs = {
|
|
1164
|
+
id: "feishu-jobs",
|
|
1165
|
+
name: "飞书招聘",
|
|
1166
|
+
input: "<公司>.jobs.feishu.cn(校招路径)",
|
|
1167
|
+
mode: "pull",
|
|
1168
|
+
describe: "飞书招聘门户(<租户>.jobs.feishu.cn):走门户前端自己的搜索接口,零 token",
|
|
1169
|
+
detect: /* @__PURE__ */ __name((url) => !!feishuTenant(url), "detect"),
|
|
1170
|
+
// "/", "/index" and "/campus" of one tenant are one portal unless a 招聘项目 narrows it
|
|
1171
|
+
siteKey: /* @__PURE__ */ __name((url) => {
|
|
1172
|
+
const t = feishuTenant(url);
|
|
1173
|
+
return `${t.tenant}${t.project ? "/" + t.project : ""}`;
|
|
1174
|
+
}, "siteKey"),
|
|
1175
|
+
async fetch(cfg, ctx) {
|
|
1176
|
+
const t = feishuTenant(cfg.url ?? "");
|
|
1177
|
+
if (!t) throw new Error(`BAD_URL: 不是飞书招聘的校招站:${cfg.url}`);
|
|
1178
|
+
const base = `https://${t.tenant}.jobs.feishu.cn/api/v1`;
|
|
1179
|
+
const csrf = await ctx.fetchJson(`${base}/csrf/token`, { method: "POST", headers: { ...UA_HEADERS, "website-path": t.path }, body: "{}" });
|
|
1180
|
+
const token = csrf?.data?.token ?? "";
|
|
1181
|
+
if (!token) throw new Error("SCHEMA_CHANGED: 飞书招聘:没拿到 csrf token");
|
|
1182
|
+
const headers = { ...UA_HEADERS, "website-path": t.path, "x-csrf-token": token, cookie: `atsx-csrf-token=${encodeURIComponent(token)}` };
|
|
1183
|
+
const out = [];
|
|
1184
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1185
|
+
const limit = 100;
|
|
1186
|
+
for (let page2 = 0; page2 < (cfg.maxPages ?? 10); page2++) {
|
|
1187
|
+
const body = {
|
|
1188
|
+
keyword: cfg.keywords?.[0] ?? "",
|
|
1189
|
+
limit,
|
|
1190
|
+
offset: page2 * limit,
|
|
1191
|
+
job_category_id_list: [],
|
|
1192
|
+
tag_id_list: [],
|
|
1193
|
+
location_code_list: [],
|
|
1194
|
+
subject_id_list: t.project ? [t.project] : [],
|
|
1195
|
+
recruitment_id_list: [],
|
|
1196
|
+
portal_type: 3,
|
|
1197
|
+
job_function_id_list: [],
|
|
1198
|
+
storefront_id_list: [],
|
|
1199
|
+
portal_entrance: 1
|
|
1200
|
+
};
|
|
1201
|
+
const res = await ctx.fetchJson(`${base}/search/job/posts`, { method: "POST", headers, body: JSON.stringify(body) });
|
|
1202
|
+
if (res?.code !== 0) throw new Error(`SCHEMA_CHANGED: 飞书招聘:${res?.message ?? "接口返回异常"}`);
|
|
1203
|
+
const posts = res.data?.job_post_list ?? [];
|
|
1204
|
+
for (const p of posts) {
|
|
1205
|
+
if (seen.has(p.id)) continue;
|
|
1206
|
+
seen.add(p.id);
|
|
1207
|
+
const l = postToListing(p, t, cfg);
|
|
1208
|
+
if (l.kind === "社招" && cfg.kind !== "社招") continue;
|
|
1209
|
+
out.push(l);
|
|
1210
|
+
}
|
|
1211
|
+
ctx.log(`feishu-jobs ${t.tenant}/${t.path}: page ${page2 + 1} +${posts.length} (${out.length}/${res.data?.count ?? "?"})`);
|
|
1212
|
+
if (posts.length < limit || out.length >= (res.data?.count ?? 0)) break;
|
|
1213
|
+
await ctx.sleep(400);
|
|
1214
|
+
}
|
|
1215
|
+
return out;
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
// apps/daemon/src/data/types.ts
|
|
1220
|
+
var recentPulls = /* @__PURE__ */ new Map();
|
|
1221
|
+
async function pullOnce(key, pull) {
|
|
1222
|
+
const hit = recentPulls.get(key);
|
|
1223
|
+
if (hit && Date.now() - hit.at < 6 * 36e5) return hit.listings;
|
|
1224
|
+
const listings = await pull();
|
|
1225
|
+
recentPulls.set(key, { at: Date.now(), listings });
|
|
1226
|
+
return listings;
|
|
1227
|
+
}
|
|
1228
|
+
__name(pullOnce, "pullOnce");
|
|
1229
|
+
|
|
1230
|
+
// apps/daemon/src/data/readers/zhilian.ts
|
|
1231
|
+
var API2 = "https://fe.zhaopin.com/grace/api/dsc/search-job-list";
|
|
1232
|
+
var JOB = /(C[CZ]L?\d+J\d+)/;
|
|
1233
|
+
function zhilianUrl(url) {
|
|
1234
|
+
let u;
|
|
1235
|
+
try {
|
|
1236
|
+
u = new URL(url);
|
|
1237
|
+
} catch {
|
|
1238
|
+
return null;
|
|
1239
|
+
}
|
|
1240
|
+
if (!/(^|\.)zhaopin\.com$/.test(u.hostname) || u.hostname === "fe.zhaopin.com") return null;
|
|
1241
|
+
const job = JOB.exec(u.pathname)?.[1];
|
|
1242
|
+
if (!job && /^(www\.|m\.)?zhaopin\.com$|^(xiaoyuan|m|webapp)\.zhaopin\.com$/.test(u.hostname) && !/\/company\/KA|\/\d{4}\//.test(u.pathname)) return null;
|
|
1243
|
+
return { host: u.hostname, job };
|
|
1244
|
+
}
|
|
1245
|
+
__name(zhilianUrl, "zhilianUrl");
|
|
1246
|
+
var ID = /(?:xiaozhaoId|companyXzId|companyOutId|rootCompanyId|orgNumbers?)["']?[:=]\[?["']?(\d{5,7})\b|globalData\.companyId,[\w$]+="?(\d{5,7})\b/g;
|
|
1247
|
+
var idsIn = /* @__PURE__ */ __name((text3) => [...new Set([...text3.matchAll(ID)].map((m) => m[1] || m[2]))], "idsIn");
|
|
1248
|
+
async function count(ctx, id) {
|
|
1249
|
+
const r = await ctx.fetchJson(API2, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ orgNumbers: [id], jobSource: 2, pageIndex: 1, pageSize: 1 }) }).catch(() => null);
|
|
1250
|
+
return r?.data?.pageInfo?.totalNum ?? 0;
|
|
1251
|
+
}
|
|
1252
|
+
__name(count, "count");
|
|
1253
|
+
async function page(ctx, url) {
|
|
1254
|
+
for (let i = 0; ; i++) {
|
|
1255
|
+
const t = await ctx.fetchText(url);
|
|
1256
|
+
if (!/EO_Bot_Ssid/.test(t) || t.length > 5e3 || i >= 3) return t;
|
|
1257
|
+
await ctx.sleep(1500 * (i + 1));
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
__name(page, "page");
|
|
1261
|
+
var roots = /* @__PURE__ */ new Map();
|
|
1262
|
+
async function rootIdsOf(url, ctx) {
|
|
1263
|
+
const z = zhilianUrl(url);
|
|
1264
|
+
const key = z?.job && /^(xiaoyuan|m|www|webapp)\./.test(z.host) ? z.job : new URL(url).origin + (url.includes("webapp.zhaopin.com") ? new URL(url).pathname.split("/").slice(0, 4).join("/") : "");
|
|
1265
|
+
if (roots.has(key)) return roots.get(key);
|
|
1266
|
+
let ids = [];
|
|
1267
|
+
if (z?.job) ids = [/"companyRootId":(\d+)/.exec(await page(ctx, `https://xiaoyuan.zhaopin.com/job/${z.job}`))?.[1] ?? ""].filter(Boolean);
|
|
1268
|
+
else {
|
|
1269
|
+
const u = new URL(url), base = u.origin + (u.host.startsWith("webapp.") ? u.pathname.split("/").slice(0, 4).join("/") : "") + "/";
|
|
1270
|
+
const pages = [url], seen = new Set(pages), js = [];
|
|
1271
|
+
let all = "";
|
|
1272
|
+
for (let i = 0; i < pages.length && i < 8; i++) {
|
|
1273
|
+
const html = await page(ctx, pages[i]).catch(() => "");
|
|
1274
|
+
all += html;
|
|
1275
|
+
for (const m of html.matchAll(/(?:src|href)="([^"]+\.js)"/g)) {
|
|
1276
|
+
const s = new URL(m[1], pages[i]).href;
|
|
1277
|
+
if (/\/assets\/|\/static\//.test(s) && !seen.has(s)) {
|
|
1278
|
+
seen.add(s);
|
|
1279
|
+
js.push(s);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
for (let j = 0; j < js.length && j < 40; j++) {
|
|
1283
|
+
const src = js[j];
|
|
1284
|
+
if (src === "") continue;
|
|
1285
|
+
js[j] = "";
|
|
1286
|
+
const t = await ctx.fetchText(src).catch(() => "");
|
|
1287
|
+
all += t;
|
|
1288
|
+
for (const m of t.matchAll(/["'`](\.{1,2}\/[\w./-]+?\.js)(?=["'`])/g)) {
|
|
1289
|
+
const s = new URL(m[1], src).href;
|
|
1290
|
+
if (!seen.has(s)) {
|
|
1291
|
+
seen.add(s);
|
|
1292
|
+
js.push(s);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
for (const m of all.matchAll(/nativeHref:"(\w+)"/g)) {
|
|
1297
|
+
const p = base + (m[1] === "index" ? "" : `${m[1]}/index.html`);
|
|
1298
|
+
if (!seen.has(p)) {
|
|
1299
|
+
seen.add(p);
|
|
1300
|
+
pages.push(p);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
ids = idsIn(all);
|
|
1305
|
+
}
|
|
1306
|
+
const live = [];
|
|
1307
|
+
for (const id of ids) if (await count(ctx, id) > 0) live.push(id);
|
|
1308
|
+
if (!live.length) throw new Error(`ZERO_POSTS: 智联:没找到有岗位的校招机构号 ${url}`);
|
|
1309
|
+
roots.set(key, live);
|
|
1310
|
+
return live;
|
|
1311
|
+
}
|
|
1312
|
+
__name(rootIdsOf, "rootIdsOf");
|
|
1313
|
+
function itemToListing(it, root, cfg) {
|
|
1314
|
+
const j = it.job ?? {}, c = it.company ?? {};
|
|
1315
|
+
if (!j.jobNumber || !j.title) return null;
|
|
1316
|
+
const detail = (j.detail ?? "").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
|
1317
|
+
return {
|
|
1318
|
+
externalId: j.jobNumber,
|
|
1319
|
+
url: j.url || `https://xiaoyuan.zhaopin.com/job/${j.jobNumber}`,
|
|
1320
|
+
title: j.title.trim(),
|
|
1321
|
+
company: c.campusOrgName?.trim() || cfg.name || `智联 ${root}`,
|
|
1322
|
+
orgUnit: c.campusDepartment?.name || void 0,
|
|
1323
|
+
locations: j.cityName ? [j.cityName] : [],
|
|
1324
|
+
description: detail,
|
|
1325
|
+
datePosted: j.modifiedTime ? new Date(j.modifiedTime).toISOString() : void 0,
|
|
1326
|
+
degreeReq: j.minEducationName || void 0,
|
|
1327
|
+
majorReq: j.needMajor || void 0,
|
|
1328
|
+
cohort: cfg.cohort ?? "2027届",
|
|
1329
|
+
batch: cfg.batch ?? "校招",
|
|
1330
|
+
kind: cfg.kind ?? (j.minEducationName === "博士" ? "博士专项" : "校招"),
|
|
1331
|
+
publisher: `zhilian:${root}`,
|
|
1332
|
+
grade: "官方",
|
|
1333
|
+
isOriginal: true,
|
|
1334
|
+
confidence: 0.9
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
__name(itemToListing, "itemToListing");
|
|
1338
|
+
var zhilian = {
|
|
1339
|
+
id: "zhilian",
|
|
1340
|
+
name: "智联校园",
|
|
1341
|
+
input: "xiaoyuan.zhaopin.com 岗位/公司页、集团专属站 <集团>.zhaopin.com",
|
|
1342
|
+
mode: "pull",
|
|
1343
|
+
describe: "智联校园(xiaoyuan.zhaopin.com、集团专属站 <集团>.zhaopin.com):走站点前端的搜索接口,按集团校招机构号拉全部单位的岗位,零 token",
|
|
1344
|
+
detect: /* @__PURE__ */ __name((url) => !!zhilianUrl(url), "detect"),
|
|
1345
|
+
// ponytail: the root id needs a request, so two job links of one group are two subscriptions (same campaigns, a scan wasted)
|
|
1346
|
+
siteKey: /* @__PURE__ */ __name((url) => {
|
|
1347
|
+
const z = zhilianUrl(url);
|
|
1348
|
+
return z.job && /^(xiaoyuan|m|www|webapp)\./.test(z.host) ? `job/${z.job}` : z.host;
|
|
1349
|
+
}, "siteKey"),
|
|
1350
|
+
async fetch(cfg, ctx) {
|
|
1351
|
+
const ids = cfg.rootId ? [String(cfg.rootId)] : await rootIdsOf(cfg.url ?? "", ctx);
|
|
1352
|
+
const root = ids.join(",");
|
|
1353
|
+
return pullOnce(`zhilian:${root}:${cfg.keywords?.[0] ?? ""}`, async () => {
|
|
1354
|
+
const out = [];
|
|
1355
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1356
|
+
for (let page2 = 1; page2 <= (cfg.maxPages ?? 20); page2++) {
|
|
1357
|
+
const r = await ctx.fetchJson(API2, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ orgNumbers: ids, jobSource: 2, pageIndex: page2, pageSize: 200, keyword: cfg.keywords?.[0] ?? "" }) });
|
|
1358
|
+
if (r?.code !== 200) throw new Error(`SCHEMA_CHANGED: 智联:${r?.message ?? "接口返回异常"}`);
|
|
1359
|
+
for (const it of r.data?.jobList ?? []) {
|
|
1360
|
+
const l = itemToListing(it, root, cfg);
|
|
1361
|
+
if (l && !seen.has(l.externalId)) {
|
|
1362
|
+
seen.add(l.externalId);
|
|
1363
|
+
out.push(l);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
ctx.log(`zhilian ${root}: page ${page2} (${out.length}/${r.data?.pageInfo?.totalNum ?? "?"})`);
|
|
1367
|
+
if (page2 >= (r.data?.pageInfo?.totalPage ?? 1)) break;
|
|
1368
|
+
await ctx.sleep(400);
|
|
1369
|
+
}
|
|
1370
|
+
return out;
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1375
|
+
// apps/daemon/src/data/readers/iguopin.ts
|
|
1376
|
+
var G = "https://gp-api.iguopin.com";
|
|
1377
|
+
var H = { "content-type": "application/json", Device: "pc", Version: "5.0.0", Subsite: "iguopin" };
|
|
1378
|
+
var CAMPUS = "115xW5oQ";
|
|
1379
|
+
function iguopinUrl(url) {
|
|
1380
|
+
let u;
|
|
1381
|
+
try {
|
|
1382
|
+
u = new URL(url);
|
|
1383
|
+
} catch {
|
|
1384
|
+
return null;
|
|
1385
|
+
}
|
|
1386
|
+
if (!/(^|\.)iguopin\.com$/.test(u.hostname)) return null;
|
|
1387
|
+
const sub = u.hostname.split(".")[0], id = u.searchParams.get("id") ?? void 0;
|
|
1388
|
+
if (sub !== "www" && sub !== "iguopin" && !/^(m|api\d*|gp-api)$/.test(sub)) return { tenant: sub, path: u.pathname, kind: "tenant" };
|
|
1389
|
+
if (/^\/job\/detail/.test(u.pathname) && id) return { path: u.pathname, id, kind: "job" };
|
|
1390
|
+
if (/^\/company/.test(u.pathname) && id) return { path: u.pathname, id, kind: "company" };
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
__name(iguopinUrl, "iguopinUrl");
|
|
1394
|
+
async function api(ctx, path, body) {
|
|
1395
|
+
const r = await ctx.fetchJson(G + path, body ? { method: "POST", headers: H, body: JSON.stringify(body) } : { headers: H });
|
|
1396
|
+
if (r?.code !== 200) throw new Error(`SCHEMA_CHANGED: 国聘 ${path}:${r?.code} ${r?.msg ?? ""}`);
|
|
1397
|
+
return r.data;
|
|
1398
|
+
}
|
|
1399
|
+
__name(api, "api");
|
|
1400
|
+
function pickNav(nav, path) {
|
|
1401
|
+
const jobs = nav.filter((n) => n?.props?.projectId || n?.props?.nature);
|
|
1402
|
+
return jobs.find((n) => n.route === path) ?? jobs.find((n) => /校/.test(n.title ?? "")) ?? jobs[0];
|
|
1403
|
+
}
|
|
1404
|
+
__name(pickNav, "pickNav");
|
|
1405
|
+
async function queryOf(url, ctx) {
|
|
1406
|
+
const t = iguopinUrl(url);
|
|
1407
|
+
if (!t) throw new Error(`BAD_URL: 不是国聘链接:${url}`);
|
|
1408
|
+
if (t.kind === "company") return { projectIds: [], companyId: t.id, nature: [CAMPUS] };
|
|
1409
|
+
if (t.kind === "job") {
|
|
1410
|
+
const j = await api(ctx, `/api/jobs/v1/info?id=${t.id}`);
|
|
1411
|
+
return { projectIds: [], companyId: String(j.company_id), nature: [CAMPUS] };
|
|
1412
|
+
}
|
|
1413
|
+
const d = await api(ctx, `/api/activity/exclusive/v1/info?domain=${t.tenant}`);
|
|
1414
|
+
const nav = pickNav(JSON.parse(d.content ?? "{}")?.params?.nav ?? [], t.path);
|
|
1415
|
+
const projectIds = String(nav?.props?.projectId ?? "").split(",").filter(Boolean);
|
|
1416
|
+
return { projectIds, companyId: String(d.company_id), nature: String(nav?.props?.nature ?? CAMPUS).split(",").filter(Boolean) };
|
|
1417
|
+
}
|
|
1418
|
+
__name(queryOf, "queryOf");
|
|
1419
|
+
function postToListing2(j, cfg) {
|
|
1420
|
+
if (!j?.job_id || !j.job_name) return null;
|
|
1421
|
+
const end = String(j.end_time ?? "").slice(0, 10);
|
|
1422
|
+
return {
|
|
1423
|
+
externalId: String(j.job_id),
|
|
1424
|
+
url: `https://www.iguopin.com/job/detail?id=${j.job_id}`,
|
|
1425
|
+
title: String(j.job_name).trim(),
|
|
1426
|
+
company: String(j.company_name ?? "").trim() || cfg.name || "国聘",
|
|
1427
|
+
orgUnit: j.department_cn || void 0,
|
|
1428
|
+
industry: j.company_info?.industry_cn || void 0,
|
|
1429
|
+
locations: [...new Set((j.district_list ?? []).map((d) => String(d.area_cn ?? "").split("-")[0]).filter(Boolean))],
|
|
1430
|
+
description: String(j.contents ?? "").trim(),
|
|
1431
|
+
datePosted: j.start_time ? String(j.start_time).replace(" ", "T") + "+08:00" : void 0,
|
|
1432
|
+
validThrough: /^\d{4}-\d{2}-\d{2}$/.test(end) ? end : void 0,
|
|
1433
|
+
deadlineText: j.end_time ? String(j.end_time) : void 0,
|
|
1434
|
+
degreeReq: j.education_cn || void 0,
|
|
1435
|
+
majorReq: Array.isArray(j.major_cn) ? j.major_cn.join("、") : void 0,
|
|
1436
|
+
cohort: cfg.cohort ?? "2027届",
|
|
1437
|
+
batch: cfg.batch ?? "校招",
|
|
1438
|
+
kind: cfg.kind ?? (j.education_cn === "博士" ? "博士专项" : /实习/.test(j.nature_cn ?? "") ? "实习" : "校招"),
|
|
1439
|
+
publisher: "iguopin",
|
|
1440
|
+
grade: "官方",
|
|
1441
|
+
isOriginal: true,
|
|
1442
|
+
confidence: 0.9
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
__name(postToListing2, "postToListing");
|
|
1446
|
+
async function discover(cfg, ctx) {
|
|
1447
|
+
const out = /* @__PURE__ */ new Map();
|
|
1448
|
+
for (const keyword of (cfg.keywords ?? []).slice(0, 8)) {
|
|
1449
|
+
for (let page2 = 1; page2 <= (cfg.maxPages ?? 10); page2++) {
|
|
1450
|
+
const d = await api(ctx, "/api/jobs/v1/list", { page: page2, page_size: 200, nature: [CAMPUS], keyword, source: "s_job_list" });
|
|
1451
|
+
const list = d?.list ?? [];
|
|
1452
|
+
for (const j of list) {
|
|
1453
|
+
const l = postToListing2(j, { ...cfg, name: "" });
|
|
1454
|
+
if (l && !out.has(l.externalId)) out.set(l.externalId, l);
|
|
1455
|
+
}
|
|
1456
|
+
ctx.log(`iguopin 发现「${keyword}」: page ${page2} +${list.length} (${out.size})`);
|
|
1457
|
+
if (list.length < 200) break;
|
|
1458
|
+
await ctx.sleep(400);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
return [...out.values()];
|
|
1462
|
+
}
|
|
1463
|
+
__name(discover, "discover");
|
|
1464
|
+
var iguopin = {
|
|
1465
|
+
id: "iguopin",
|
|
1466
|
+
name: "国聘",
|
|
1467
|
+
input: "<集团>.iguopin.com 专场、www.iguopin.com/company?id=、岗位详情页",
|
|
1468
|
+
mode: "pull",
|
|
1469
|
+
describe: "国聘(<集团>.iguopin.com 专场、www.iguopin.com/company?id=):走站点前端接口,按招聘项目或公司树拉校招岗位(含截止),零 token",
|
|
1470
|
+
detect: /* @__PURE__ */ __name((url) => !!iguopinUrl(url), "detect"),
|
|
1471
|
+
siteKey: /* @__PURE__ */ __name((url) => {
|
|
1472
|
+
const t = iguopinUrl(url);
|
|
1473
|
+
return t.kind === "tenant" ? t.tenant : `${t.kind}/${t.id}`;
|
|
1474
|
+
}, "siteKey"),
|
|
1475
|
+
async fetch(cfg, ctx) {
|
|
1476
|
+
if (cfg.discover) return discover(cfg, ctx);
|
|
1477
|
+
const q = await queryOf(cfg.url ?? "", ctx);
|
|
1478
|
+
return pullOnce(`iguopin:${JSON.stringify(q)}`, async () => {
|
|
1479
|
+
const out = [];
|
|
1480
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1481
|
+
for (const mode of q.projectIds.length ? ["project", "company"] : ["company"]) {
|
|
1482
|
+
for (let page2 = 1; page2 <= (cfg.maxPages ?? 20); page2++) {
|
|
1483
|
+
const d = mode === "project" ? await api(ctx, "/api/jobs/v1/project-job", { page: page2, page_size: 200, project_id: q.projectIds }) : await api(ctx, "/api/jobs/v1/list", { page: page2, page_size: 200, company_id_with_sub: q.companyId, nature: q.nature, source: "s_job_list" });
|
|
1484
|
+
const list = d?.list ?? [];
|
|
1485
|
+
for (const j of list) {
|
|
1486
|
+
const l = postToListing2(j, cfg);
|
|
1487
|
+
if (l && !seen.has(l.externalId)) {
|
|
1488
|
+
seen.add(l.externalId);
|
|
1489
|
+
out.push(l);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
ctx.log(`iguopin ${mode}: page ${page2} +${list.length} (${out.length})`);
|
|
1493
|
+
if (list.length < 200) break;
|
|
1494
|
+
await ctx.sleep(400);
|
|
1495
|
+
}
|
|
1496
|
+
if (out.length) break;
|
|
1497
|
+
}
|
|
1498
|
+
return out;
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
// apps/daemon/src/data/readers/chnenergy.ts
|
|
1504
|
+
var BASE = "https://zhaopin.chnenergy.com.cn";
|
|
1505
|
+
var FORM = { "content-type": "application/x-www-form-urlencoded" };
|
|
1506
|
+
var CAMPUS2 = /毕业生|校园|校招|管培|专项|博士后|应届/;
|
|
1507
|
+
var strip = /* @__PURE__ */ __name((s) => s.replace(/<!--[\s\S]*?-->/g, "").replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/\s+/g, " ").trim(), "strip");
|
|
1508
|
+
function parseAnnouncements(html) {
|
|
1509
|
+
return [...html.matchAll(/showgg\?id=([\w-]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(\d{4})年(\d{2})月(\d{2})日/g)].map((m) => ({ id: m[1], title: strip(m[2]), date: `${m[3]}-${m[4]}-${m[5]}` }));
|
|
1510
|
+
}
|
|
1511
|
+
__name(parseAnnouncements, "parseAnnouncements");
|
|
1512
|
+
function parseStations(html) {
|
|
1513
|
+
const pages = Number(/共<b>\d+<\/b>条,共<b>(\d+)<\/b>页/.exec(html)?.[1] ?? 0);
|
|
1514
|
+
const rows = [...html.matchAll(/showgw\?id=([\w-]+)"[^>]*title="([^"]*)"[\s\S]*?<small>([^<]*)<\/small>[\s\S]*?<p class="list-group-item-text">([\s\S]*?)<\/p>/g)].map((m) => {
|
|
1515
|
+
const [unit, major, degree, city, headcount] = strip(m[4].replace(/<span title="([^"]*)">[^<]*<\/span>/g, "$1")).split(/\s*\|\s*/);
|
|
1516
|
+
return { id: m[1], title: strip(m[2]), category: strip(m[3]), unit: unit ?? "", major: major ?? "", degree: degree ?? "", city: city ?? "", headcount: headcount ?? "" };
|
|
1517
|
+
});
|
|
1518
|
+
return { pages, rows };
|
|
1519
|
+
}
|
|
1520
|
+
__name(parseStations, "parseStations");
|
|
1521
|
+
async function retry(ctx, f) {
|
|
1522
|
+
for (let i = 0; ; i++) {
|
|
1523
|
+
try {
|
|
1524
|
+
return await f();
|
|
1525
|
+
} catch (e) {
|
|
1526
|
+
if (i >= 3) throw e;
|
|
1527
|
+
await ctx.sleep(2e3 * (i + 1));
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
__name(retry, "retry");
|
|
1532
|
+
async function post(ctx, path, body) {
|
|
1533
|
+
return retry(ctx, () => ctx.fetchText(BASE + path, { method: "POST", headers: FORM, body: new URLSearchParams(body).toString() }));
|
|
1534
|
+
}
|
|
1535
|
+
__name(post, "post");
|
|
1536
|
+
var chnenergy = {
|
|
1537
|
+
id: "chnenergy",
|
|
1538
|
+
name: "国家能源集团招聘网",
|
|
1539
|
+
input: "zhaopin.chnenergy.com.cn 任一页面(整站校招公告)",
|
|
1540
|
+
notices: true,
|
|
1541
|
+
mode: "pull",
|
|
1542
|
+
describe: "国家能源集团招聘网(zhaopin.chnenergy.com.cn):整站当前校招公告(统招、管培生、专项、博士后)的全部岗位,零 token",
|
|
1543
|
+
detect: /* @__PURE__ */ __name((url) => /^https?:\/\/zhaopin\.chnenergy\.com\.cn/i.test(url), "detect"),
|
|
1544
|
+
siteKey: /* @__PURE__ */ __name(() => "zhaopin.chnenergy.com.cn", "siteKey"),
|
|
1545
|
+
async fetch(cfg, ctx) {
|
|
1546
|
+
const anns = [];
|
|
1547
|
+
for (let p = 0; p < 5; p++) {
|
|
1548
|
+
const got = parseAnnouncements(await post(ctx, "/annc/annclist", { pagenum: String(p), ggtype: "1" }));
|
|
1549
|
+
anns.push(...got.filter((a) => !anns.some((x) => x.id === a.id)));
|
|
1550
|
+
if (got.length < 10) break;
|
|
1551
|
+
await ctx.sleep(300);
|
|
1552
|
+
}
|
|
1553
|
+
const year = Number((cfg.cohort ?? "2027").replace(/\D/g, "").slice(0, 4)) || 2027;
|
|
1554
|
+
const campus = anns.filter((a) => CAMPUS2.test(a.title) && !/社会招聘/.test(a.title) && (a.title.includes(String(year)) || a.date >= `${year - 1}-07-01`));
|
|
1555
|
+
const out = [];
|
|
1556
|
+
for (const a of campus) {
|
|
1557
|
+
let deadline;
|
|
1558
|
+
for (let page2 = 0, pages = 1; page2 < pages && page2 < (cfg.maxPages ?? 400); page2++) {
|
|
1559
|
+
const r = parseStations(await post(ctx, "/annc/showggStationList", { id: a.id, pagenum: String(page2) }));
|
|
1560
|
+
pages = r.pages;
|
|
1561
|
+
if (!deadline && r.rows[0]) {
|
|
1562
|
+
const d = /截止时间:<strong>(\d{4})年(\d{2})月(\d{2})日/.exec(await retry(ctx, () => ctx.fetchText(`${BASE}/annc/showgw?id=${r.rows[0].id}`)));
|
|
1563
|
+
deadline = d ? `${d[1]}-${d[2]}-${d[3]}` : "";
|
|
1564
|
+
if (deadline && deadline < a.date) {
|
|
1565
|
+
ctx.log(`chnenergy ${a.title.slice(0, 24)}: 岗位列表截止 ${deadline} 早于公告发布 ${a.date},判为旧列表,跳过`);
|
|
1566
|
+
break;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
for (const s of r.rows) out.push({
|
|
1570
|
+
// one title is hired by dozens of plants (「电气检修」): the pool keys positions by title, so the unit goes in it
|
|
1571
|
+
externalId: s.id,
|
|
1572
|
+
url: `${BASE}/annc/showgw?id=${s.id}`,
|
|
1573
|
+
noticeUrl: `${BASE}/annc/showgg?id=${a.id}`,
|
|
1574
|
+
title: s.unit ? `${s.title}(${s.unit})` : s.title,
|
|
1575
|
+
company: "国家能源集团",
|
|
1576
|
+
orgUnit: s.unit || void 0,
|
|
1577
|
+
locations: s.city ? s.city.split(/[,,、]/).filter(Boolean) : [],
|
|
1578
|
+
description: `${s.category}岗;${s.unit};专业:${s.major};${s.degree};${s.headcount}`,
|
|
1579
|
+
majorReq: s.major,
|
|
1580
|
+
degreeReq: s.degree,
|
|
1581
|
+
validThrough: deadline || void 0,
|
|
1582
|
+
cohort: cfg.cohort ?? `${year}届`,
|
|
1583
|
+
batch: a.title.replace(/^国家能源投资集团有限责任公司|^国家能源集团/, "").slice(0, 40),
|
|
1584
|
+
kind: /博士后/.test(a.title) ? "博士后" : /管培/.test(a.title) ? "管培生" : s.degree.startsWith("博士") ? "博士专项" : "校招",
|
|
1585
|
+
publisher: "chnenergy",
|
|
1586
|
+
grade: "官方",
|
|
1587
|
+
isOriginal: true,
|
|
1588
|
+
confidence: 0.9
|
|
1589
|
+
});
|
|
1590
|
+
ctx.log(`chnenergy ${a.title.slice(0, 20)}: page ${page2 + 1}/${pages} (${out.length})`);
|
|
1591
|
+
await ctx.sleep(200);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return out;
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
|
|
1598
|
+
// apps/daemon/src/data/readers/chng.ts
|
|
1599
|
+
import { createCipheriv } from "node:crypto";
|
|
1600
|
+
var BASE2 = "https://zhaopin.chng.com.cn";
|
|
1601
|
+
var EDU = { "3": "专科", "4": "本科", "5": "硕士研究生", "6": "博士研究生" };
|
|
1602
|
+
var ROUTE = ["CampusRecruit", "InternRecruit", "ManagementTraineeRecruit"];
|
|
1603
|
+
async function keys(ctx) {
|
|
1604
|
+
const html = await ctx.fetchText(BASE2 + "/");
|
|
1605
|
+
for (const m of html.matchAll(/src="([^"]+\.js)"/g)) {
|
|
1606
|
+
const js = await ctx.fetchText(new URL(m[1], BASE2 + "/").toString());
|
|
1607
|
+
const s = [...js.matchAll(/Utf8\.parse\("([^"]+)"\)/g)].map((x) => x[1]);
|
|
1608
|
+
const key = s.find((x) => x.length === 32), iv = s.find((x) => x.length === 16);
|
|
1609
|
+
if (key && iv) return { key: Buffer.from(key), iv: Buffer.from(iv) };
|
|
1610
|
+
}
|
|
1611
|
+
throw new Error("SCHEMA_CHANGED: 华能:页面脚本里没找到接口密钥");
|
|
1612
|
+
}
|
|
1613
|
+
__name(keys, "keys");
|
|
1614
|
+
var text = /* @__PURE__ */ __name((h) => (h ?? "").replace(/<\/p>|<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/\n{3,}/g, "\n\n").trim(), "text");
|
|
1615
|
+
function positionToListing(p, a, cohort) {
|
|
1616
|
+
if (!p?.id || !p.positionName) return null;
|
|
1617
|
+
const end = String(p.endDate ?? a.endTime ?? "").slice(0, 10);
|
|
1618
|
+
return {
|
|
1619
|
+
externalId: String(p.id),
|
|
1620
|
+
url: `${BASE2}/${ROUTE[a.taskType] ?? "CampusRecruit"}?positionId=${p.id}`,
|
|
1621
|
+
noticeUrl: `${BASE2}/${ROUTE[a.taskType] ?? "CampusRecruit"}?aId=${a.id}`,
|
|
1622
|
+
title: String(p.positionName).trim(),
|
|
1623
|
+
company: String(p.recruitCompanyName || a.recruitCompanyName || "中国华能").trim(),
|
|
1624
|
+
locations: p.address ? String(p.address).split(/[,,、\s]+/).filter(Boolean) : [],
|
|
1625
|
+
description: text(p.jobReq),
|
|
1626
|
+
validThrough: /^\d{4}-\d{2}-\d{2}$/.test(end) ? end : void 0,
|
|
1627
|
+
deadlineText: p.endDate ?? void 0,
|
|
1628
|
+
degreeReq: EDU[String(p.eduType)] ?? void 0,
|
|
1629
|
+
majorReq: (p.majors ?? []).map((m) => m.majorName).filter(Boolean).join("、") || void 0,
|
|
1630
|
+
cohort,
|
|
1631
|
+
batch: a.taskType === 2 ? "管培生" : a.taskType === 1 ? "实习" : "校招",
|
|
1632
|
+
kind: a.taskType === 1 ? "实习" : String(p.eduType) === "6" ? "博士专项" : "校招",
|
|
1633
|
+
publisher: "chng",
|
|
1634
|
+
grade: "官方",
|
|
1635
|
+
isOriginal: true,
|
|
1636
|
+
confidence: 0.9
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
__name(positionToListing, "positionToListing");
|
|
1640
|
+
var chng = {
|
|
1641
|
+
id: "chng",
|
|
1642
|
+
name: "华能招聘网",
|
|
1643
|
+
input: "zhaopin.chng.com.cn 任一页面(整站校招/管培公告)",
|
|
1644
|
+
notices: true,
|
|
1645
|
+
mode: "pull",
|
|
1646
|
+
describe: "中国华能招聘网(zhaopin.chng.com.cn):整站当前校招/管培/实习公告的全部岗位(含专业与截止),零 token",
|
|
1647
|
+
detect: /* @__PURE__ */ __name((url) => /^https?:\/\/zhaopin\.chng\.com\.cn/i.test(url), "detect"),
|
|
1648
|
+
siteKey: /* @__PURE__ */ __name(() => "zhaopin.chng.com.cn", "siteKey"),
|
|
1649
|
+
async fetch(cfg, ctx) {
|
|
1650
|
+
const { key, iv } = await keys(ctx);
|
|
1651
|
+
const enc = /* @__PURE__ */ __name((s) => {
|
|
1652
|
+
const c = createCipheriv("aes-256-cbc", key, iv);
|
|
1653
|
+
return c.update(s, "utf8", "hex") + c.final("hex");
|
|
1654
|
+
}, "enc");
|
|
1655
|
+
const get = /* @__PURE__ */ __name(async (rest, params) => {
|
|
1656
|
+
const r = await ctx.fetchJson(`${BASE2}/app-api/recruit/${enc(`${rest}?${new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString()}`)}`, { headers: { referer: BASE2 + "/" } });
|
|
1657
|
+
if (r?.code !== 0) throw new Error(`SCHEMA_CHANGED: 华能 ${rest}:${r?.msg ?? "接口返回异常"}`);
|
|
1658
|
+
return r.data;
|
|
1659
|
+
}, "get");
|
|
1660
|
+
const year = (cfg.cohort ?? "2027").replace(/\D/g, "").slice(0, 4) || "2027";
|
|
1661
|
+
const anns = [];
|
|
1662
|
+
for (let p = 1; p <= 10; p++) {
|
|
1663
|
+
const d = await get("announcement/index", { pageNo: p, pageSize: 100, recruitType: 1 });
|
|
1664
|
+
anns.push(...d?.list ?? []);
|
|
1665
|
+
if (!d?.list?.length || anns.length >= (d.total ?? 0)) break;
|
|
1666
|
+
await ctx.sleep(300);
|
|
1667
|
+
}
|
|
1668
|
+
const out = [];
|
|
1669
|
+
for (const a of anns.filter((x) => !x.recruitYear || String(x.recruitYear) >= year)) {
|
|
1670
|
+
const full = await get("announcement/select", { aId: a.id });
|
|
1671
|
+
for (const p of full?.positions ?? []) {
|
|
1672
|
+
const l = positionToListing(p, { ...a, ...full }, cfg.cohort ?? `${year}届`);
|
|
1673
|
+
if (l) out.push(l);
|
|
1674
|
+
}
|
|
1675
|
+
ctx.log(`chng ${String(a.title ?? "").slice(0, 20)}: ${out.length}`);
|
|
1676
|
+
await ctx.sleep(300);
|
|
1677
|
+
}
|
|
1678
|
+
return out;
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
|
|
1682
|
+
// apps/daemon/src/data/readers/dayee.ts
|
|
1683
|
+
function dayeeSuite(url) {
|
|
1684
|
+
const m = /^https?:\/\/[^/]+\/(SU[0-9a-f]{24})(?:[/?#]|$)/i.exec(url);
|
|
1685
|
+
if (!m) return null;
|
|
1686
|
+
let q;
|
|
1687
|
+
try {
|
|
1688
|
+
q = new URL(url).searchParams;
|
|
1689
|
+
} catch {
|
|
1690
|
+
q = new URLSearchParams();
|
|
1691
|
+
}
|
|
1692
|
+
return { su: m[1], recruitType: /interns?\.html/.test(url) ? "12" : q.get("recruitType") ?? "1", postId: q.get("postId") ?? void 0 };
|
|
1693
|
+
}
|
|
1694
|
+
__name(dayeeSuite, "dayeeSuite");
|
|
1695
|
+
var named = /* @__PURE__ */ __name((x) => x && !genericCompany(String(x)) ? String(x) : "", "named");
|
|
1696
|
+
var details = /* @__PURE__ */ new Map();
|
|
1697
|
+
function rowToListing(p, d, su, cfg, today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) {
|
|
1698
|
+
if (!p?.postId || !p.postName) return null;
|
|
1699
|
+
const end = String(p.endDate ?? "").slice(0, 10);
|
|
1700
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(end) && end < today) return null;
|
|
1701
|
+
const year = Number((cfg.cohort ?? "2027").replace(/\D/g, "").slice(0, 4)) || 2027;
|
|
1702
|
+
const projYear = Number(/(20\d\d)/.exec(p.projectName ?? "")?.[1] ?? 0);
|
|
1703
|
+
if (projYear && projYear < year) return null;
|
|
1704
|
+
const ownDomain = !!cfg.url && !/\/\/wecruit\.hotjob\.cn\//i.test(cfg.url);
|
|
1705
|
+
const jd = [d?.workContent && `岗位职责
|
|
1706
|
+
${String(d.workContent).trim()}`, d?.serviceCondition && `任职要求
|
|
1707
|
+
${String(d.serviceCondition).trim()}`].filter(Boolean).join("\n\n");
|
|
1708
|
+
const places = (d?.workPlaceList ?? []).map((w) => w.name).concat(p.workPlaceStr ? String(p.workPlaceStr).split(/[,,;;]/) : []);
|
|
1709
|
+
return {
|
|
1710
|
+
externalId: String(p.postId),
|
|
1711
|
+
url: `https://wecruit.hotjob.cn/${su}/pb/posDetail.html?postId=${p.postId}&postType=campus`,
|
|
1712
|
+
title: String(p.postName).trim(),
|
|
1713
|
+
// on the shared host a group lists its member companies in `company` (中国电科's 研究所); a tenant on its own domain is
|
|
1714
|
+
// one company and the column holds its departments (荣耀: 「Marketing与销售服务部」)
|
|
1715
|
+
// … and some tenants fill it with the literal word 「公司」 (three sites did): that is no name, the subscription's is
|
|
1716
|
+
company: String(ownDomain && cfg.name || named(p.company) || named(d?.orgName) || cfg.name || "").trim() || "大易招聘",
|
|
1717
|
+
orgUnit: (ownDomain && cfg.name ? p.company : p.department) || void 0,
|
|
1718
|
+
locations: [...new Set(places.map((x) => x.split("-")[0].trim()).filter(Boolean))],
|
|
1719
|
+
description: jd || void 0,
|
|
1720
|
+
validThrough: /^\d{4}-\d{2}-\d{2}$/.test(end) ? end : void 0,
|
|
1721
|
+
datePosted: p.publishDate ? String(p.publishDate).slice(0, 10) : void 0,
|
|
1722
|
+
degreeReq: p.educationStr || d?.education || void 0,
|
|
1723
|
+
majorReq: p.subject || void 0,
|
|
1724
|
+
cohort: cfg.cohort ?? `${year}届`,
|
|
1725
|
+
batch: cfg.batch ?? (p.projectName || "校招"),
|
|
1726
|
+
kind: cfg.kind ?? (/实习/.test(p.projectName ?? "") ? "实习" : "校招"),
|
|
1727
|
+
publisher: `dayee:${su}`,
|
|
1728
|
+
grade: "官方",
|
|
1729
|
+
isOriginal: true,
|
|
1730
|
+
confidence: 0.9
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
__name(rowToListing, "rowToListing");
|
|
1734
|
+
var dayee = {
|
|
1735
|
+
id: "dayee",
|
|
1736
|
+
name: "大易",
|
|
1737
|
+
input: "wecruit.hotjob.cn/SU…(学校招聘、岗位详情页;公司自有域名上的同样路径也认)",
|
|
1738
|
+
mode: "pull",
|
|
1739
|
+
describe: "大易新版校招站(wecruit.hotjob.cn/SU…):走页面自己的接口,列表 + 每个岗位的职责与要求,零 token",
|
|
1740
|
+
detect: /* @__PURE__ */ __name((url) => !!dayeeSuite(url), "detect"),
|
|
1741
|
+
siteKey: /* @__PURE__ */ __name((url) => dayeeSuite(url).su, "siteKey"),
|
|
1742
|
+
async fetch(cfg, ctx) {
|
|
1743
|
+
const t = dayeeSuite(cfg.url ?? "");
|
|
1744
|
+
if (!t) throw new Error(`BAD_URL: 不是大易新版校招站:${cfg.url}`);
|
|
1745
|
+
const call = /* @__PURE__ */ __name(async (op, form) => {
|
|
1746
|
+
const r = await ctx.fetchJson(`https://wecruit.hotjob.cn/wecruit/positionInfo/${op}/${t.su}?iSaJAx=isAjax&request_locale=zh_CN&t=${Date.now()}`, {
|
|
1747
|
+
method: "POST",
|
|
1748
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1749
|
+
body: new URLSearchParams(Object.entries(form).map(([k, v]) => [k, String(v)])).toString()
|
|
1750
|
+
});
|
|
1751
|
+
if (String(r?.state) !== "200") throw new Error(`SCHEMA_CHANGED: 大易 ${op}:${r?.Exception?.message ?? JSON.stringify(r).slice(0, 120)}`);
|
|
1752
|
+
await ctx.sleep(250);
|
|
1753
|
+
return r.data;
|
|
1754
|
+
}, "call");
|
|
1755
|
+
return pullOnce(`dayee:${t.su}:${t.recruitType}`, async () => {
|
|
1756
|
+
const base = { isFrompb: "true", recruitType: t.recruitType };
|
|
1757
|
+
const rows = /* @__PURE__ */ new Map();
|
|
1758
|
+
const add = /* @__PURE__ */ __name((d) => {
|
|
1759
|
+
for (const p of d?.pageForm?.pageData ?? []) rows.set(p.postId, p);
|
|
1760
|
+
}, "add");
|
|
1761
|
+
const first = await call("listPosition", { ...base, pageSize: 10, currentPage: 1 });
|
|
1762
|
+
add(first);
|
|
1763
|
+
const total = Number(first?.pageForm?.dataCount ?? first?.positonNum ?? 0);
|
|
1764
|
+
for (let size = 1; size < 50 && size < total; size *= 2) add(await call("listPosition", { ...base, pageSize: size, currentPage: 2 }));
|
|
1765
|
+
for (let page2 = 2; (page2 - 1) * 50 < total && page2 <= (cfg.maxPages ?? 40); page2++) add(await call("listPosition", { ...base, pageSize: 50, currentPage: page2 }));
|
|
1766
|
+
ctx.log(`dayee ${t.su}: ${rows.size}/${total} rows`);
|
|
1767
|
+
const out = [];
|
|
1768
|
+
for (const p of rows.values()) {
|
|
1769
|
+
if (!rowToListing(p, null, t.su, cfg)) continue;
|
|
1770
|
+
if (!details.has(p.postId)) details.set(p.postId, await call("listPositionDetail", { postId: p.postId, recruitType: t.recruitType }).catch(() => null));
|
|
1771
|
+
const l = rowToListing(p, details.get(p.postId), t.su, cfg);
|
|
1772
|
+
if (l) out.push(l);
|
|
1773
|
+
}
|
|
1774
|
+
return out;
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
// apps/daemon/src/data/readers/job51.ts
|
|
1780
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1781
|
+
var GUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
1782
|
+
var md5 = /* @__PURE__ */ __name((s) => createHash2("md5").update(s, "utf8").digest("hex"), "md5");
|
|
1783
|
+
function job51Url(url) {
|
|
1784
|
+
let u;
|
|
1785
|
+
try {
|
|
1786
|
+
u = new URL(url);
|
|
1787
|
+
} catch {
|
|
1788
|
+
return false;
|
|
1789
|
+
}
|
|
1790
|
+
return /^(campus|ideal|xym|xyz)\.51job\.com$/.test(u.hostname);
|
|
1791
|
+
}
|
|
1792
|
+
__name(job51Url, "job51Url");
|
|
1793
|
+
function literalToJson(s) {
|
|
1794
|
+
let o = "", i = 0;
|
|
1795
|
+
while (i < s.length) {
|
|
1796
|
+
const c = s[i];
|
|
1797
|
+
if (c === "'" || c === '"') {
|
|
1798
|
+
let v = "";
|
|
1799
|
+
i++;
|
|
1800
|
+
while (i < s.length && s[i] !== c) {
|
|
1801
|
+
if (s[i] === "\\") {
|
|
1802
|
+
const n = s[i + 1];
|
|
1803
|
+
v += { n: "\n", r: "\r", t: " " }[n] ?? n;
|
|
1804
|
+
i += 2;
|
|
1805
|
+
} else v += s[i++];
|
|
1806
|
+
}
|
|
1807
|
+
i++;
|
|
1808
|
+
o += JSON.stringify(v);
|
|
1809
|
+
} else if (/[A-Za-z_$]/.test(c)) {
|
|
1810
|
+
let w = "";
|
|
1811
|
+
while (i < s.length && /[\w$]/.test(s[i])) w += s[i++];
|
|
1812
|
+
o += /^(true|false|null)$/.test(w) ? w : JSON.stringify(w);
|
|
1813
|
+
} else {
|
|
1814
|
+
o += c;
|
|
1815
|
+
i++;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return o;
|
|
1819
|
+
}
|
|
1820
|
+
__name(literalToJson, "literalToJson");
|
|
1821
|
+
function siteLinks(text3, base) {
|
|
1822
|
+
const root = new URL(".", base).href, out = [];
|
|
1823
|
+
for (const m of text3.matchAll(/["']((?:\.\/)?[\w./-]+\.(?:html?|js))(?:\?[^"']*)?["']/g)) {
|
|
1824
|
+
const p = new URL(m[1], base).href;
|
|
1825
|
+
if (p.startsWith(root) && !out.includes(p) && !/jquery|swiper|jweixin|\.min\.js/.test(p)) out.push(p);
|
|
1826
|
+
}
|
|
1827
|
+
return out;
|
|
1828
|
+
}
|
|
1829
|
+
__name(siteLinks, "siteLinks");
|
|
1830
|
+
async function guidOf(url, ctx) {
|
|
1831
|
+
const direct = /[?&]id=([0-9a-f-]{36})/i.exec(url) ?? /CtmID=([0-9a-f-]{36})/i.exec(url);
|
|
1832
|
+
if (direct) return direct[1];
|
|
1833
|
+
let ctmid = /ctmid=(\d+)/i.exec(url)?.[1];
|
|
1834
|
+
if (!ctmid) {
|
|
1835
|
+
const base = /\/$|\.html?$/.test(url) ? url : url + "/";
|
|
1836
|
+
const pages = [base, new URL("js/job.js", base).href, new URL("js/jobs.js", base).href];
|
|
1837
|
+
for (let i = 0; i < pages.length && i < 16 && !ctmid; i++) {
|
|
1838
|
+
const t = await ctx.fetchText(pages[i]).catch(() => "");
|
|
1839
|
+
ctmid = /ctmid["']?\s*[:=]\s*["']?(\d{5,})/i.exec(t)?.[1];
|
|
1840
|
+
for (const p of siteLinks(t, base)) if (!pages.includes(p)) p.endsWith(".js") ? pages.splice(i + 1, 0, p) : pages.push(p);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (!ctmid) throw new Error(`SCHEMA_CHANGED: 51job:页面里没找到企业编号 ${url}`);
|
|
1844
|
+
const r = await fetch(`https://xyz.51job.com/External/Apply.aspx?CtmID=${ctmid}`, { redirect: "manual", headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/128.0 Safari/537.36" }, signal: AbortSignal.timeout(2e4) }).catch(() => null);
|
|
1845
|
+
const g = GUID.exec(r?.headers.get("location") ?? "")?.[0] ?? (await directory(ctx)).get(ctmid);
|
|
1846
|
+
if (!g) throw new Error(`SCHEMA_CHANGED: 51job:企业编号 ${ctmid} 没有换到招聘号`);
|
|
1847
|
+
return g;
|
|
1848
|
+
}
|
|
1849
|
+
__name(guidOf, "guidOf");
|
|
1850
|
+
var dir = null;
|
|
1851
|
+
async function directory(ctx) {
|
|
1852
|
+
if (dir && Date.now() - dir.at < 864e5) return dir.map;
|
|
1853
|
+
const t = await ctx.fetchText("https://xym.51job.com/jobs/jobs_ctmlist.aspx").catch(() => "");
|
|
1854
|
+
const map = new Map([...t.matchAll(/\{ctmid:'([0-9a-f-]{36})',mname:'[^']*',ehireid:'(\d+)'/g)].map((m) => [m[2], m[1]]));
|
|
1855
|
+
dir = { at: Date.now(), map };
|
|
1856
|
+
return map;
|
|
1857
|
+
}
|
|
1858
|
+
__name(directory, "directory");
|
|
1859
|
+
var details2 = /* @__PURE__ */ new Map();
|
|
1860
|
+
function jobToListing(j, d, cfg, employer) {
|
|
1861
|
+
if (!j?.JobID || !j.CjobName) return null;
|
|
1862
|
+
const units = String(d?.OrgName ?? "").split("/").filter(Boolean);
|
|
1863
|
+
const jd = String(d?.CJobInfo ?? "").replace(/<br\s*\/?>|<\/p>/gi, "\n").replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
|
1864
|
+
return {
|
|
1865
|
+
externalId: String(j.JobID),
|
|
1866
|
+
url: d?.Url ? `https:${String(d.Url).replace(/^https?:/, "")}` : `https://xym.51job.com/wechat/vuectmjobs/#/index?id=${j.JobID}`,
|
|
1867
|
+
title: String(j.CjobName).trim(),
|
|
1868
|
+
company: units[1] || units[0] || employer || cfg.name || "51job",
|
|
1869
|
+
orgUnit: units.slice(2).join("/") || void 0,
|
|
1870
|
+
locations: j.JobAreaname ? [String(j.JobAreaname)] : [],
|
|
1871
|
+
description: jd || void 0,
|
|
1872
|
+
degreeReq: j.DegreeFrom || d?.DegreeName || void 0,
|
|
1873
|
+
cohort: cfg.cohort ?? "2027届",
|
|
1874
|
+
batch: cfg.batch ?? "校招",
|
|
1875
|
+
kind: cfg.kind ?? (/实习/.test(String(j.Term ?? "")) ? "实习" : "校招"),
|
|
1876
|
+
publisher: "job51",
|
|
1877
|
+
grade: "官方",
|
|
1878
|
+
isOriginal: true,
|
|
1879
|
+
confidence: 0.85
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
__name(jobToListing, "jobToListing");
|
|
1883
|
+
var job51 = {
|
|
1884
|
+
id: "job51",
|
|
1885
|
+
name: "前程无忧",
|
|
1886
|
+
input: "campus / ideal / xym / xyz.51job.com(无截止日期)",
|
|
1887
|
+
mode: "pull",
|
|
1888
|
+
describe: "前程无忧校招页(campus / ideal / xym / xyz.51job.com):按雇主号走微信职位页的接口,列表 + 岗位详情,零 token;无截止日期",
|
|
1889
|
+
detect: job51Url,
|
|
1890
|
+
siteKey: /* @__PURE__ */ __name((url) => GUID.exec(url)?.[0] ?? /ctmid=(\d+)/i.exec(url)?.[1] ?? new URL(url).host + new URL(url).pathname.split("/").slice(0, 2).join("/"), "siteKey"),
|
|
1891
|
+
async fetch(cfg, ctx) {
|
|
1892
|
+
const id = await guidOf(cfg.url ?? "", ctx);
|
|
1893
|
+
const token = md5(md5(id));
|
|
1894
|
+
const api2 = /* @__PURE__ */ __name(async (func, body) => {
|
|
1895
|
+
const t = await ctx.fetchText(`https://xym.51job.com/VueData/Jobs.ashx?func=${func}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(Object.entries(body).map(([k, v]) => [k, String(v)])).toString() });
|
|
1896
|
+
const r = JSON.parse(literalToJson(t));
|
|
1897
|
+
if (r?.status !== "ok" || typeof r.data !== "object") throw new Error(`SCHEMA_CHANGED: 51job ${func}:${t.slice(0, 80)}`);
|
|
1898
|
+
await ctx.sleep(200);
|
|
1899
|
+
return r.data;
|
|
1900
|
+
}, "api");
|
|
1901
|
+
return pullOnce(`job51:${id}`, async () => {
|
|
1902
|
+
const info = await api2("getctminfo", { CtmID: id, Token: token }).catch(() => ({}));
|
|
1903
|
+
const jobs = [];
|
|
1904
|
+
for (let p = 1; p <= (cfg.maxPages ?? 200); p++) {
|
|
1905
|
+
const d = await api2("getjobslistbyarea", { CtmID: id, PageIndex: p, areaname: "", keyword: "", funtypecode: "", token });
|
|
1906
|
+
jobs.push(...d.records ?? []);
|
|
1907
|
+
if (p >= Number(d.pagecount ?? 1)) break;
|
|
1908
|
+
}
|
|
1909
|
+
ctx.log(`job51 ${info?.ShowName ?? id}: ${jobs.length} jobs`);
|
|
1910
|
+
const out = [];
|
|
1911
|
+
for (const j of jobs) {
|
|
1912
|
+
if (!details2.has(j.JobID)) details2.set(j.JobID, await api2("getJobInfo", { CtmID: id, JobID: j.JobID, Token: token }).catch(() => null));
|
|
1913
|
+
const l = jobToListing(j, details2.get(j.JobID), cfg, String(info?.ShowName ?? "").replace(/20\d\d.*$/, ""));
|
|
1914
|
+
if (l) out.push(l);
|
|
1915
|
+
}
|
|
1916
|
+
return out;
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
|
|
1921
|
+
// apps/daemon/src/data/readers/own-sites.ts
|
|
1922
|
+
var clean3 = /* @__PURE__ */ __name((s) => (s ?? "").replace(/\r/g, "").replace(/\n{3,}/g, "\n\n").trim(), "clean");
|
|
1923
|
+
var degreeIn = /* @__PURE__ */ __name((s) => /(博士|硕士及以上|硕士|本科及以上|本科|大专)/.exec(s)?.[1], "degreeIn");
|
|
1924
|
+
var JSON_POST = { method: "POST", headers: { "content-type": "application/json", accept: "application/json" } };
|
|
1925
|
+
var KS = "https://campus.kuaishou.cn/recruit/campus/e/api/v1";
|
|
1926
|
+
function ksListing(p, projects, cfg) {
|
|
1927
|
+
const desc = [clean3(p.description) && `岗位职责
|
|
1928
|
+
${clean3(p.description)}`, clean3(p.positionDemand) && `任职要求
|
|
1929
|
+
${clean3(p.positionDemand)}`].filter(Boolean).join("\n\n");
|
|
1930
|
+
const batch = projects.get(p.recruitSubProjectCode ?? "") ?? "校招", intern = p.positionNatureCode === "intern" || /实习/.test(batch);
|
|
1931
|
+
return {
|
|
1932
|
+
externalId: String(p.id),
|
|
1933
|
+
url: `https://campus.kuaishou.cn/#/campus/job-info/${p.id}`,
|
|
1934
|
+
title: clean3(p.name),
|
|
1935
|
+
company: cfg.name || "快手",
|
|
1936
|
+
locations: (p.workLocationDicts ?? []).map((w) => w.name).filter(Boolean),
|
|
1937
|
+
description: desc,
|
|
1938
|
+
degreeReq: degreeIn(desc),
|
|
1939
|
+
datePosted: p.releaseTime ? (/* @__PURE__ */ new Date(p.releaseTime.replace(" ", "T") + "+08:00")).toISOString() : void 0,
|
|
1940
|
+
cohort: cfg.cohort ?? (/(\d{4})/.exec(batch)?.[1] ? `${/(\d{4})/.exec(batch)[1]}届` : void 0),
|
|
1941
|
+
batch,
|
|
1942
|
+
kind: cfg.kind ?? (intern ? "实习" : "校招"),
|
|
1943
|
+
publisher: "kuaishou",
|
|
1944
|
+
grade: "官方",
|
|
1945
|
+
isOriginal: true,
|
|
1946
|
+
confidence: 0.9,
|
|
1947
|
+
raw: p
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
__name(ksListing, "ksListing");
|
|
1951
|
+
var kuaishou = {
|
|
1952
|
+
id: "kuaishou",
|
|
1953
|
+
name: "快手校招",
|
|
1954
|
+
input: "campus.kuaishou.cn",
|
|
1955
|
+
mode: "pull",
|
|
1956
|
+
describe: "快手校招官网(campus.kuaishou.cn):当届应届生与实习生项目的全部岗位",
|
|
1957
|
+
detect: /* @__PURE__ */ __name((url) => /^https:\/\/campus\.kuaishou\.cn(\/|$)/i.test(url), "detect"),
|
|
1958
|
+
siteKey: /* @__PURE__ */ __name(() => "campus.kuaishou.cn", "siteKey"),
|
|
1959
|
+
async fetch(cfg, ctx) {
|
|
1960
|
+
const dict = await ctx.fetchJson(`${KS}/dictionary/batch?types=recruitSubProject`);
|
|
1961
|
+
const all = dict?.result?.recruitSubProject ?? [];
|
|
1962
|
+
if (dict?.code !== 0 || !all.length) throw new Error("SCHEMA_CHANGED: 快手校招没返回招聘项目列表");
|
|
1963
|
+
const year = /(\d{4})/.exec(cfg.cohort ?? "")?.[1] ?? String((/* @__PURE__ */ new Date()).getFullYear() + ((/* @__PURE__ */ new Date()).getMonth() >= 6 ? 1 : 0));
|
|
1964
|
+
const live = all.filter((x) => x.name.includes(year));
|
|
1965
|
+
if (!live.length) throw new Error(`ZERO_POSTS: 快手校招还没有 ${year} 届的招聘项目`);
|
|
1966
|
+
const projects = new Map(all.map((x) => [x.code, x.name]));
|
|
1967
|
+
const out = [];
|
|
1968
|
+
for (let page2 = 1; page2 <= (cfg.maxPages ?? 20); page2++) {
|
|
1969
|
+
const res = await ctx.fetchJson(`${KS}/open/positions/simple`, { ...JSON_POST, body: JSON.stringify({ recruitSubProjectCodes: live.map((x) => x.code), pageSize: 100, pageNum: page2 }) });
|
|
1970
|
+
if (res?.code !== 0 || !Array.isArray(res.result?.list)) throw new Error(`SCHEMA_CHANGED: 快手校招岗位接口返回异常(${res?.message ?? "无 list"})`);
|
|
1971
|
+
out.push(...res.result.list.map((p) => ksListing(p, projects, cfg)));
|
|
1972
|
+
ctx.log(`kuaishou: page ${page2} (${out.length}/${res.result.total})`);
|
|
1973
|
+
if (res.result.list.length < 100 || out.length >= res.result.total) break;
|
|
1974
|
+
await ctx.sleep(400);
|
|
1975
|
+
}
|
|
1976
|
+
return out;
|
|
1977
|
+
}
|
|
1978
|
+
};
|
|
1979
|
+
var XHS = "https://job.xiaohongshu.com/websiterecruit/position/pageQueryPosition";
|
|
1980
|
+
function xhsListing(p, cfg) {
|
|
1981
|
+
const desc = [clean3(p.duty) && `岗位职责
|
|
1982
|
+
${clean3(p.duty)}`, clean3(p.qualification) && `任职要求
|
|
1983
|
+
${clean3(p.qualification)}`].filter(Boolean).join("\n\n");
|
|
1984
|
+
const batch = p.jobProjectName && p.jobProjectName !== "其他项目" ? p.jobProjectName : /实习/.test(p.positionName) ? "实习" : "校招";
|
|
1985
|
+
return {
|
|
1986
|
+
externalId: String(p.positionId),
|
|
1987
|
+
url: `https://job.xiaohongshu.com/campus/position/${p.positionId}`,
|
|
1988
|
+
title: clean3(p.positionName),
|
|
1989
|
+
company: cfg.name || "小红书",
|
|
1990
|
+
orgUnit: p.jobType || void 0,
|
|
1991
|
+
locations: (p.workplace ?? "").split(/[,,、]/).map((s) => s.trim().replace(/市$/, "")).filter(Boolean),
|
|
1992
|
+
description: desc,
|
|
1993
|
+
degreeReq: degreeIn(desc),
|
|
1994
|
+
datePosted: p.publishTime ? (/* @__PURE__ */ new Date(p.publishTime + "T00:00:00+08:00")).toISOString() : void 0,
|
|
1995
|
+
cohort: cfg.cohort ?? (/(\d{4})/.exec(batch)?.[1] ? `${/(\d{4})/.exec(batch)[1]}届` : void 0),
|
|
1996
|
+
batch,
|
|
1997
|
+
kind: cfg.kind ?? (/实习/.test(p.positionName + batch) ? "实习" : "校招"),
|
|
1998
|
+
publisher: "xiaohongshu",
|
|
1999
|
+
grade: "官方",
|
|
2000
|
+
isOriginal: true,
|
|
2001
|
+
confidence: 0.9,
|
|
2002
|
+
raw: p
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
__name(xhsListing, "xhsListing");
|
|
2006
|
+
var xiaohongshu = {
|
|
2007
|
+
id: "xiaohongshu",
|
|
2008
|
+
name: "小红书校招",
|
|
2009
|
+
input: "job.xiaohongshu.com/campus",
|
|
2010
|
+
mode: "pull",
|
|
2011
|
+
describe: "小红书校招官网(job.xiaohongshu.com/campus):校园招聘、REDstar 与实习的全部岗位",
|
|
2012
|
+
detect: /* @__PURE__ */ __name((url) => /^https:\/\/job\.xiaohongshu\.com\/campus(\/|$|\?)/i.test(url), "detect"),
|
|
2013
|
+
siteKey: /* @__PURE__ */ __name(() => "job.xiaohongshu.com/campus", "siteKey"),
|
|
2014
|
+
async fetch(cfg, ctx) {
|
|
2015
|
+
const out = [];
|
|
2016
|
+
for (let page2 = 1; page2 <= (cfg.maxPages ?? 20); page2++) {
|
|
2017
|
+
const res = await ctx.fetchJson(XHS, { method: "POST", headers: { ...JSON_POST.headers, origin: "https://job.xiaohongshu.com", referer: "https://job.xiaohongshu.com/campus/position" }, body: JSON.stringify({ positionName: "", pageNum: page2, pageSize: 50, recruitType: "campus" }) });
|
|
2018
|
+
if (!Array.isArray(res?.data?.list)) throw new Error(`SCHEMA_CHANGED: 小红书校招岗位接口返回异常(${res?.alertMsg ?? "无 list"})`);
|
|
2019
|
+
out.push(...res.data.list.filter((p) => !p.recruitStatus || p.recruitStatus === "in_recruitment").map((p) => xhsListing(p, cfg)));
|
|
2020
|
+
ctx.log(`xiaohongshu: page ${page2}/${res.data.totalPage} (${out.length}/${res.data.total})`);
|
|
2021
|
+
if (page2 >= (res.data.totalPage ?? 1) || !res.data.list.length) break;
|
|
2022
|
+
await ctx.sleep(400);
|
|
2023
|
+
}
|
|
2024
|
+
return out;
|
|
2025
|
+
}
|
|
2026
|
+
};
|
|
2027
|
+
|
|
2028
|
+
// apps/daemon/src/data/readers/csv.ts
|
|
2029
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2030
|
+
import { resolve } from "node:path";
|
|
2031
|
+
var UA2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
|
|
2032
|
+
var isCsvPath = /* @__PURE__ */ __name((s) => isLocalPath(s) && /\.csv$/i.test(s.trim()), "isCsvPath");
|
|
2033
|
+
function isCsvLink(s) {
|
|
2034
|
+
try {
|
|
2035
|
+
const u = new URL(s);
|
|
2036
|
+
if (!/^https?:$/.test(u.protocol)) return false;
|
|
2037
|
+
return /\.csv$/i.test(u.pathname) || ["output", "format", "exportFormat"].some((k) => u.searchParams.get(k)?.toLowerCase() === "csv") || /out:csv/i.test(u.searchParams.get("tqx") ?? "");
|
|
2038
|
+
} catch {
|
|
2039
|
+
return false;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
__name(isCsvLink, "isCsvLink");
|
|
2043
|
+
function decode(bytes) {
|
|
2044
|
+
const utf8 = new TextDecoder("utf-8").decode(bytes);
|
|
2045
|
+
return utf8.includes("�") ? new TextDecoder("gbk").decode(bytes) : utf8;
|
|
2046
|
+
}
|
|
2047
|
+
__name(decode, "decode");
|
|
2048
|
+
async function read(where, signal) {
|
|
2049
|
+
if (isLocalPath(where)) {
|
|
2050
|
+
if (!existsSync(where)) throw new Error(`FILE_NOT_FOUND: 找不到这个文件:${where}`);
|
|
2051
|
+
return readFileSync(where);
|
|
2052
|
+
}
|
|
2053
|
+
if (!publicUrl(where)) throw new Error(`BLOCKED: 只读公网上的链接,不读本机或内网地址:${where}`);
|
|
2054
|
+
const res = await fetch(where, { signal: signal ?? AbortSignal.timeout(6e4), headers: { "user-agent": UA2 } });
|
|
2055
|
+
if (!publicUrl(res.url)) throw new Error(`BLOCKED: 链接跳到了本机或内网地址:${res.url}`);
|
|
2056
|
+
if (res.status === 401 || res.status === 403) throw new Error(`NOT_PUBLIC: 这个链接要登录才能看(HTTP ${res.status}),请换成任何人都能打开的链接`);
|
|
2057
|
+
if (!res.ok) throw new Error(`SITE_DOWN: 这个链接打不开(HTTP ${res.status})。检查链接是否完整、是否还有效`);
|
|
2058
|
+
if (/text\/html/i.test(res.headers.get("content-type") ?? "")) throw new Error("NOT_PUBLIC: 这个链接打开的是网页而不是 CSV(可能要登录,或者不是导出 / 发布链接)");
|
|
2059
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
2060
|
+
}
|
|
2061
|
+
__name(read, "read");
|
|
2062
|
+
var csv = {
|
|
2063
|
+
id: "csv",
|
|
2064
|
+
name: "CSV 表格",
|
|
2065
|
+
input: "以 .csv 结尾或带 output=csv 的链接;或本机 .csv 文件的完整路径",
|
|
2066
|
+
aggregate: true,
|
|
2067
|
+
mode: "pull",
|
|
2068
|
+
describe: "CSV 表格(链接或本机文件):按订阅里存的列对应读成岗位;每次按周期重读整张表",
|
|
2069
|
+
detect: /* @__PURE__ */ __name((url) => isCsvPath(url) || isCsvLink(url), "detect"),
|
|
2070
|
+
siteKey: /* @__PURE__ */ __name((url) => isLocalPath(url) ? resolve(url.trim()).toLowerCase() : url.split("#")[0], "siteKey"),
|
|
2071
|
+
async table(config, ctx) {
|
|
2072
|
+
return csvToTable(decode(await read((config.url ?? "").trim(), ctx.signal)));
|
|
2073
|
+
},
|
|
2074
|
+
async fetch(config, ctx) {
|
|
2075
|
+
return listingsFromTable(await this.table(config, ctx), config, "CSV 表格");
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
|
|
2079
|
+
// apps/daemon/src/data/readers/gaoxiaojob.ts
|
|
2080
|
+
var M = "https://m.gaoxiaojob.com";
|
|
2081
|
+
var W = "https://www.gaoxiaojob.com";
|
|
2082
|
+
var UA3 = { "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148" };
|
|
2083
|
+
var DAY = 864e5;
|
|
2084
|
+
var LONG = /长期有效|长期招聘|常年|招满为止|招满即止|额满为止/;
|
|
2085
|
+
var columnOf = /* @__PURE__ */ __name((url) => /^https?:\/\/(?:www\.|m\.)?gaoxiaojob\.com\/column\/(\d+)\.html/.exec(url.trim())?.[1] ?? null, "columnOf");
|
|
2086
|
+
function dayOf(mmdd, today = /* @__PURE__ */ new Date()) {
|
|
2087
|
+
const m = /^(\d{1,2})-(\d{1,2})$/.exec(mmdd.trim());
|
|
2088
|
+
if (!m) return null;
|
|
2089
|
+
const y = today.getUTCFullYear(), mm = m[1].padStart(2, "0"), dd = m[2].padStart(2, "0");
|
|
2090
|
+
const d = `${y}-${mm}-${dd}`;
|
|
2091
|
+
return Date.parse(d) > today.getTime() + DAY ? `${y - 1}-${mm}-${dd}` : d;
|
|
2092
|
+
}
|
|
2093
|
+
__name(dayOf, "dayOf");
|
|
2094
|
+
var text2 = /* @__PURE__ */ __name((html) => html.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/g, " ").replace(/<br\s*\/?>|<\/p>|<\/div>|<\/li>/g, "\n").replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/[ \t\r]+/g, " ").replace(/\n\s*/g, "\n"), "text");
|
|
2095
|
+
function parseNotice(id, title, html) {
|
|
2096
|
+
const t = text2(html);
|
|
2097
|
+
const flat = t.replace(/\s+/g, " ");
|
|
2098
|
+
const field = /* @__PURE__ */ __name((k) => new RegExp(`${k}[::]\\s*(.*?)\\s+(?:截止日期|学历要求|所属省份|工作地点|报名方式|栏目分类|需求学科|发布时间)`).exec(flat)?.[1]?.trim() ?? "", "field");
|
|
2099
|
+
const posted = /发布时间[::]\s*(\d{4}-\d{2}-\d{2})/.exec(flat)?.[1] ?? null;
|
|
2100
|
+
const deadlineText = field("截止日期");
|
|
2101
|
+
const full = /(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})/.exec(deadlineText);
|
|
2102
|
+
const closesAt = full ? `${full[1]}-${full[2].padStart(2, "0")}-${full[3].padStart(2, "0")}` : null;
|
|
2103
|
+
const source = /来源链接[::]\s*(https?:\/\/\S+)/.exec(flat)?.[1] ?? "";
|
|
2104
|
+
const a = t.indexOf("公告详情"), b = t.indexOf("来源于网络", a);
|
|
2105
|
+
const body = (a >= 0 ? t.slice(a + 4, b > a ? b : a + 8e3) : "").trim().slice(0, 8e3);
|
|
2106
|
+
return { id, title, posted, deadlineText, closesAt, place: field("工作地点"), source, body };
|
|
2107
|
+
}
|
|
2108
|
+
__name(parseNotice, "parseNotice");
|
|
2109
|
+
function stillOpen(n, listedOn, today = /* @__PURE__ */ new Date()) {
|
|
2110
|
+
if (n.closesAt) return Date.parse(`${n.closesAt}T23:59:59+08:00`) >= today.getTime();
|
|
2111
|
+
const on = n.posted ?? listedOn;
|
|
2112
|
+
if (!on) return false;
|
|
2113
|
+
const age = (today.getTime() - Date.parse(on)) / DAY;
|
|
2114
|
+
return age <= (LONG.test(n.deadlineText) ? 180 : 90);
|
|
2115
|
+
}
|
|
2116
|
+
__name(stillOpen, "stillOpen");
|
|
2117
|
+
function noticeListings(n, jobs, cfg) {
|
|
2118
|
+
const company = (jobs.find((j) => j.companyName)?.companyName ?? "").replace(/#+$/, "").trim() || unitFrom(n.title);
|
|
2119
|
+
const base = {
|
|
2120
|
+
company,
|
|
2121
|
+
noticeUrl: `${W}/announcement/detail/${n.id}.html`,
|
|
2122
|
+
datePosted: n.posted ?? void 0,
|
|
2123
|
+
validThrough: n.closesAt ?? void 0,
|
|
2124
|
+
deadlineText: n.deadlineText || void 0,
|
|
2125
|
+
cohort: cfg.cohort,
|
|
2126
|
+
batch: n.title.slice(0, 80),
|
|
2127
|
+
// one announcement, one campaign (campaigns group by publisher|company|cohort|batch)
|
|
2128
|
+
publisher: "gaoxiaojob",
|
|
2129
|
+
grade: "聚合",
|
|
2130
|
+
isOriginal: false,
|
|
2131
|
+
confidence: 0.7
|
|
2132
|
+
// a reprint: below the 0.85–0.9 the official readers give
|
|
2133
|
+
};
|
|
2134
|
+
const intern = /* @__PURE__ */ __name((s) => /实习/.test(s) ? "实习" : void 0, "intern");
|
|
2135
|
+
const jd = [n.body, n.source ? `原公告:${n.source}` : ""].filter(Boolean).join("\n\n");
|
|
2136
|
+
if (!jobs.length) return [{ ...base, externalId: `ann:${n.id}`, url: base.noticeUrl, title: n.title, locations: n.place ? [n.place] : [], description: jd, kind: intern(n.title) }];
|
|
2137
|
+
return jobs.map((j) => ({
|
|
2138
|
+
...base,
|
|
2139
|
+
externalId: `job:${j.jobId}`,
|
|
2140
|
+
url: j.url ? `${W}${j.url}` : base.noticeUrl,
|
|
2141
|
+
title: j.jobName,
|
|
2142
|
+
locations: j.city ? [j.city] : n.place ? [n.place] : [],
|
|
2143
|
+
description: jd,
|
|
2144
|
+
degreeReq: j.education || void 0,
|
|
2145
|
+
majorReq: j.major || void 0,
|
|
2146
|
+
kind: intern(`${j.jobName} ${n.title}`)
|
|
2147
|
+
}));
|
|
2148
|
+
}
|
|
2149
|
+
__name(noticeListings, "noticeListings");
|
|
2150
|
+
var unitFrom = /* @__PURE__ */ __name((title) => /^(.+?)(?:\d{4}|诚聘|招聘|公开|引进|招收)/.exec(title)?.[1]?.trim() || title.slice(0, 30), "unitFrom");
|
|
2151
|
+
async function jobsOf(ctx, id) {
|
|
2152
|
+
const out = [];
|
|
2153
|
+
for (let page2 = 1; page2 <= 20; page2++) {
|
|
2154
|
+
const d = await ctx.fetchJson(`${M}/announcement/get-job-list?announcementId=${id}&page=${page2}`, { headers: UA3 });
|
|
2155
|
+
const list = d?.data?.list ?? [];
|
|
2156
|
+
out.push(...list);
|
|
2157
|
+
if (!list.length || out.length >= Number(d?.data?.count ?? 0)) return out;
|
|
2158
|
+
await ctx.sleep(pause());
|
|
2159
|
+
}
|
|
2160
|
+
return out;
|
|
2161
|
+
}
|
|
2162
|
+
__name(jobsOf, "jobsOf");
|
|
2163
|
+
var pause = /* @__PURE__ */ __name(() => 600 + Math.floor(Math.random() * 800), "pause");
|
|
2164
|
+
var gaoxiaojob = {
|
|
2165
|
+
id: "gaoxiaojob",
|
|
2166
|
+
name: "高校人才网",
|
|
2167
|
+
input: "www.gaoxiaojob.com/column/<栏目编号>.html(如武汉 48、武汉科研院所 509、武汉企业 511)",
|
|
2168
|
+
mode: "pull",
|
|
2169
|
+
describe: "高校人才网的一个栏目(研究院、实验室、高校团队、事业单位、企业):读手机版公开列表和每条公告的岗位清单,只收仍有效的;转载来源,记为聚合,零 token",
|
|
2170
|
+
detect: /* @__PURE__ */ __name((url) => !!columnOf(url), "detect"),
|
|
2171
|
+
siteKey: /* @__PURE__ */ __name((url) => `column/${columnOf(url)}`, "siteKey"),
|
|
2172
|
+
async fetch(cfg, ctx) {
|
|
2173
|
+
const col = columnOf(cfg.url ?? "");
|
|
2174
|
+
if (!col) throw new Error("SCHEMA_CHANGED: 不是高校人才网的栏目链接");
|
|
2175
|
+
const maxPages = cfg.maxPages ?? 60;
|
|
2176
|
+
const today = /* @__PURE__ */ new Date();
|
|
2177
|
+
const out = [];
|
|
2178
|
+
let oldPages = 0, page2 = 1;
|
|
2179
|
+
for (; page2 <= maxPages; page2++) {
|
|
2180
|
+
const d = await ctx.fetchJson(`${M}/home/get-announcement-list?columnId=${col}&isEstablishment=0&announcementHeat=0&page=${page2}`, { headers: UA3 });
|
|
2181
|
+
if (d?.code !== 200) throw new Error(`SCHEMA_CHANGED: 列表接口返回 ${d?.code ?? "?"} ${d?.msg ?? ""}`);
|
|
2182
|
+
const items = d?.data ?? [];
|
|
2183
|
+
if (!items.length) break;
|
|
2184
|
+
let fresh = 0;
|
|
2185
|
+
for (const it of items) {
|
|
2186
|
+
const listedOn = dayOf(it.refreshTime ?? "", today);
|
|
2187
|
+
if (listedOn && (today.getTime() - Date.parse(listedOn)) / DAY > 180) continue;
|
|
2188
|
+
fresh++;
|
|
2189
|
+
await ctx.sleep(pause());
|
|
2190
|
+
const n = parseNotice(
|
|
2191
|
+
String(it.id),
|
|
2192
|
+
it.title,
|
|
2193
|
+
await ctx.fetchText(`${W}/announcement/detail/${it.id}.html`)
|
|
2194
|
+
/* the desktop page: its 基本信息 block is what parseNotice reads */
|
|
2195
|
+
);
|
|
2196
|
+
if (!stillOpen(n, listedOn, today)) continue;
|
|
2197
|
+
await ctx.sleep(pause());
|
|
2198
|
+
out.push(...noticeListings(n, await jobsOf(ctx, n.id), cfg));
|
|
2199
|
+
}
|
|
2200
|
+
ctx.log(`gaoxiaojob ${col}: page ${page2} ${items.length} 条,在窗口内 ${fresh}(累计 ${out.length})`);
|
|
2201
|
+
oldPages = fresh ? 0 : oldPages + 1;
|
|
2202
|
+
if (oldPages >= 2) break;
|
|
2203
|
+
await ctx.sleep(pause());
|
|
2204
|
+
}
|
|
2205
|
+
if (page2 > maxPages) throw new Error(`TIMEOUT: 读到第 ${maxPages} 页还没读完这个栏目,这次不算(读一半会把另一半标成下架)`);
|
|
2206
|
+
return out;
|
|
2207
|
+
}
|
|
2208
|
+
};
|
|
2209
|
+
|
|
2210
|
+
// apps/daemon/src/data/readers/index.ts
|
|
2211
|
+
var capturePush = { id: "capture", name: "任意页面抓取", mode: "push", describe: "任意招聘页/简章:书签一键捕获,或给 URL 由 daemon 抓取(公众号/就业网/官网),模型抽取岗位" };
|
|
2212
|
+
var sources = Object.fromEntries([moka, beisen, feishuBase, feishuJobs, zhilian, iguopin, chnenergy, chng, dayee, job51, kuaishou, xiaohongshu, gaoxiaojob, csv, capturePush].map((s) => [s.id, s]));
|
|
2213
|
+
function sourceFor(url) {
|
|
2214
|
+
return Object.values(sources).find((s) => s.detect?.(url));
|
|
2215
|
+
}
|
|
2216
|
+
__name(sourceFor, "sourceFor");
|
|
2217
|
+
function siteKeyOf(url) {
|
|
2218
|
+
const src = sourceFor(url);
|
|
2219
|
+
return src ? `${src.id}:${src.siteKey?.(url) ?? url}` : url;
|
|
2220
|
+
}
|
|
2221
|
+
__name(siteKeyOf, "siteKeyOf");
|
|
2222
|
+
var aggregateIds = /* @__PURE__ */ __name(() => [...Object.values(sources).filter((s) => s.aggregate).map((s) => s.id), "sheet"], "aggregateIds");
|
|
2223
|
+
var isAggregate = /* @__PURE__ */ __name((sourceId) => aggregateIds().includes(sourceId), "isAggregate");
|
|
2224
|
+
var aggregateSql = /* @__PURE__ */ __name(() => aggregateIds().map((id) => `'${id}'`).join(","), "aggregateSql");
|
|
2225
|
+
var isNotices = /* @__PURE__ */ __name((sourceId) => !!sources[sourceId]?.notices, "isNotices");
|
|
2226
|
+
|
|
2227
|
+
// apps/daemon/src/data/ingest.ts
|
|
2228
|
+
import { ulid as ulid2 } from "ulid";
|
|
2229
|
+
import { mkdirSync, writeFileSync, readdirSync, rmSync, statSync, readFileSync as readFileSync2 } from "node:fs";
|
|
2230
|
+
import { join } from "node:path";
|
|
2231
|
+
function campaignUrl(url) {
|
|
2232
|
+
const hash = url.indexOf("#");
|
|
2233
|
+
const base = hash < 0 ? url : url.slice(0, hash), frag = hash < 0 ? "" : url.slice(hash + 1);
|
|
2234
|
+
const moka2 = base.includes("mokahr.com/campus-recruitment/") || base.includes("mokahr.com/social-recruitment/");
|
|
2235
|
+
if (frag.startsWith("/job/") || moka2 && (frag === "" || frag === "/")) return moka2 ? base + "#/jobs" : base;
|
|
2236
|
+
return url;
|
|
2237
|
+
}
|
|
2238
|
+
__name(campaignUrl, "campaignUrl");
|
|
2239
|
+
var sourceKey = /* @__PURE__ */ __name((aggregate, l) => aggregate || /#\d+$/.test(l.externalId) || l.externalId === l.url || l.externalId === l.title ? "" : l.externalId, "sourceKey");
|
|
2240
|
+
var ownSnapshots = /* @__PURE__ */ __name((dir2, configId) => readdirSync(dir2).filter((f) => f.slice(25) === `${configId}.json`).sort(), "ownSnapshots");
|
|
2241
|
+
var SNAPSHOT_KEEP = 7;
|
|
2242
|
+
function keepSnapshot(vault, sourceId, configId, listings) {
|
|
2243
|
+
const dir2 = join(vault, "snapshots", sourceId);
|
|
2244
|
+
mkdirSync(dir2, { recursive: true });
|
|
2245
|
+
const at = now(), body = JSON.stringify({ sourceId, configId, at, count: listings.length, listings }, null, 1);
|
|
2246
|
+
const prior = ownSnapshots(dir2, configId);
|
|
2247
|
+
const last = prior.length ? join(dir2, prior[prior.length - 1]) : "";
|
|
2248
|
+
const tail = /* @__PURE__ */ __name((s) => {
|
|
2249
|
+
const i = s.indexOf('\n "count"');
|
|
2250
|
+
return i > 0 ? s.slice(i) : null;
|
|
2251
|
+
}, "tail");
|
|
2252
|
+
try {
|
|
2253
|
+
if (last && statSync(last).size === Buffer.byteLength(body) && tail(readFileSync2(last, "utf8")) === tail(body)) return last;
|
|
2254
|
+
} catch {
|
|
2255
|
+
}
|
|
2256
|
+
const path = join(dir2, `${at.replace(/[:.]/g, "-")}-${configId}.json`);
|
|
2257
|
+
writeFileSync(path, body, "utf8");
|
|
2258
|
+
for (const old of [...prior, path.slice(dir2.length + 1)].sort().slice(0, -SNAPSHOT_KEEP)) rmSync(join(dir2, old), { force: true });
|
|
2259
|
+
return path;
|
|
2260
|
+
}
|
|
2261
|
+
__name(keepSnapshot, "keepSnapshot");
|
|
2262
|
+
function ingest(store, vault, sourceId, configId, listings, opts = {}) {
|
|
2263
|
+
const db = store.db;
|
|
2264
|
+
const runId = ulid2();
|
|
2265
|
+
const started = now();
|
|
2266
|
+
const snapshotPath = keepSnapshot(vault, sourceId, configId, listings);
|
|
2267
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2268
|
+
const groupOf = listings.map((l) => {
|
|
2269
|
+
const k = `${l.publisher}|${l.company}|${l.cohort ?? ""}|${l.batch ?? ""}`;
|
|
2270
|
+
const g = groups.get(k) ?? groups.set(k, { listings: [] }).get(k);
|
|
2271
|
+
g.listings.push(l);
|
|
2272
|
+
return g;
|
|
2273
|
+
});
|
|
2274
|
+
const filed = /* @__PURE__ */ __name((l) => !!l.title && !!l.company && !genericCompany(l.company), "filed");
|
|
2275
|
+
const ofGroup = /* @__PURE__ */ __name((g, l) => {
|
|
2276
|
+
let w = g.whole;
|
|
2277
|
+
if (!w) {
|
|
2278
|
+
const group = g.listings;
|
|
2279
|
+
const d = group.find((x) => x.validThrough || x.deadlineText) ?? l;
|
|
2280
|
+
const perPost = new Set(group.map((x) => x.validThrough?.slice(0, 10)).filter(Boolean)).size > 1;
|
|
2281
|
+
const dl = perPost ? { closesAt: null, kind: "unknown" } : d.validThrough ? { closesAt: d.validThrough.slice(0, 10), kind: "date" } : parseDeadline(d.deadlineText ?? "");
|
|
2282
|
+
g.whole = w = {
|
|
2283
|
+
externalKey: campaignKey(sourceId, l.publisher, l.company, l.cohort ?? "", l.batch ?? ""),
|
|
2284
|
+
cohort: l.cohort ?? "",
|
|
2285
|
+
batch: l.batch ?? "",
|
|
2286
|
+
cities: Array.from(new Set(group.flatMap((x) => x.locations))).slice(0, 20),
|
|
2287
|
+
degreeReq: group.find((x) => x.degreeReq)?.degreeReq ?? "",
|
|
2288
|
+
majorReq: group.find((x) => x.majorReq)?.majorReq ?? "",
|
|
2289
|
+
opensAt: null,
|
|
2290
|
+
closesAt: dl.closesAt,
|
|
2291
|
+
deadlineKind: dl.kind,
|
|
2292
|
+
deadlineText: perPost ? "各岗位截止不同" : d.deadlineText ?? "",
|
|
2293
|
+
sourceKind: sourceId,
|
|
2294
|
+
hasWrittenTest: "",
|
|
2295
|
+
notes: ""
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
return w;
|
|
2299
|
+
}, "ofGroup");
|
|
2300
|
+
const campaigns = /* @__PURE__ */ new Set();
|
|
2301
|
+
const listedRows = /* @__PURE__ */ new Map();
|
|
2302
|
+
let positionsNew = 0;
|
|
2303
|
+
let positionsSeen = 0;
|
|
2304
|
+
let positionsClosed = 0;
|
|
2305
|
+
let updated = 0;
|
|
2306
|
+
tx(db, () => {
|
|
2307
|
+
const batch = new CatalogBatch(db, listings.flatMap((l, i) => filed(l) ? [ofGroup(groupOf[i], l).externalKey] : []));
|
|
2308
|
+
const verifiedAt = now();
|
|
2309
|
+
const aggregate = isAggregate(sourceId);
|
|
2310
|
+
const rows = /* @__PURE__ */ new Map();
|
|
2311
|
+
const rowKey = /* @__PURE__ */ __name((externalId, positionId) => `${externalId}\0${positionId}`, "rowKey");
|
|
2312
|
+
inChunks(batch.campaignIds(), (part, q) => {
|
|
2313
|
+
for (const r of db.prepare(`select id, external_id, position_id, extracted_json from source_listing where campaign_id in (${q}) and +provider = ? order by rowid`).all(...part, sourceId)) {
|
|
2314
|
+
const k = rowKey(r.external_id, r.position_id);
|
|
2315
|
+
if (!rows.has(k)) rows.set(k, { id: r.id, extracted_json: r.extracted_json });
|
|
2316
|
+
}
|
|
2317
|
+
});
|
|
2318
|
+
const findRow = db.prepare("select id, extracted_json from source_listing where provider = ? and external_id = ? and position_id = ?");
|
|
2319
|
+
const updateRow = db.prepare("update source_listing set url = ?, extracted_json = ?, confidence = ?, content_path = ?, fetched_at = ? where id = ?");
|
|
2320
|
+
const insertRow = db.prepare("insert into source_listing(id, position_id, campaign_id, provider, tenant, external_id, url, publisher, grade, is_original, extracted_json, confidence, content_path, fetched_at) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
|
2321
|
+
for (const [i, l] of listings.entries()) {
|
|
2322
|
+
if (!filed(l)) continue;
|
|
2323
|
+
const companyId = batch.company(l.company, l.companyType ?? "", l.industry ?? "");
|
|
2324
|
+
const { id: campaignId } = batch.campaign({
|
|
2325
|
+
...ofGroup(groupOf[i], l),
|
|
2326
|
+
companyId,
|
|
2327
|
+
kind: l.kind ?? "校招",
|
|
2328
|
+
sourceGrade: l.grade,
|
|
2329
|
+
applyUrl: campaignUrl(l.url),
|
|
2330
|
+
noticeUrl: campaignUrl(l.noticeUrl ?? l.url),
|
|
2331
|
+
lastVerifiedAt: verifiedAt
|
|
2332
|
+
});
|
|
2333
|
+
campaigns.add(campaignId);
|
|
2334
|
+
const p = batch.position(campaignId, l.title, { jdText: l.description ?? "", cities: l.locations, degreeReq: l.degreeReq ?? "", majorReq: l.majorReq ?? "", closesAt: l.validThrough ? l.validThrough.slice(0, 10) : null, sourceKey: sourceKey(aggregate, l) });
|
|
2335
|
+
p.created ? positionsNew++ : positionsSeen++;
|
|
2336
|
+
const said = json.str({ ...l, raw: void 0 }), k = rowKey(l.externalId, p.id);
|
|
2337
|
+
listedRows.set(k, p.id);
|
|
2338
|
+
const existing = rows.get(k) ?? findRow.get(sourceId, l.externalId, p.id);
|
|
2339
|
+
if (existing) {
|
|
2340
|
+
if (existing.extracted_json !== said) {
|
|
2341
|
+
updateRow.run(l.url, said, l.confidence, snapshotPath, now(), existing.id);
|
|
2342
|
+
rows.set(k, { id: existing.id, extracted_json: said });
|
|
2343
|
+
}
|
|
2344
|
+
} else {
|
|
2345
|
+
const id = ulid2();
|
|
2346
|
+
insertRow.run(id, p.id, campaignId, sourceId, l.publisher, l.externalId, l.url, l.publisher, l.grade, l.isOriginal ? 1 : 0, said, l.confidence, snapshotPath, now());
|
|
2347
|
+
rows.set(k, { id, extracted_json: said });
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
batch.finish();
|
|
2351
|
+
updated = batch.updated;
|
|
2352
|
+
if (opts.closeUnseen && opts.complete !== false && listings.length && campaigns.size) {
|
|
2353
|
+
const ids = [...campaigns];
|
|
2354
|
+
inChunks(ids, (part, q) => {
|
|
2355
|
+
positionsClosed += Number(db.prepare(`update position set status = 'closed', updated_at = ? where campaign_id in (${q})
|
|
2356
|
+
and status = 'open' and last_seen_at < ? and id not in (select position_id from application where state != 'cancelled')`).run(now(), ...part, started).changes);
|
|
2357
|
+
});
|
|
2358
|
+
const gone = db.prepare("select distinct campaign_id id from source_listing where provider = ? and content_path like ? and campaign_id is not null").all(sourceId, `%-${configId}.json`).map((r) => r.id).filter((id) => !campaigns.has(id));
|
|
2359
|
+
for (let i = 0; i < gone.length; i += 200) {
|
|
2360
|
+
const part = gone.slice(i, i + 200);
|
|
2361
|
+
positionsClosed += Number(db.prepare(`update position set status = 'closed', updated_at = ? where campaign_id in (${part.map(() => "?").join(",")})
|
|
2362
|
+
and status = 'open' and id not in (select position_id from application where state != 'cancelled')`).run(now(), ...part).changes);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
db.prepare("insert into scan_run(id, source, config_id, started_at, finished_at, complete, found, new_positions, error, snapshot_path) values (?,?,?,?,?,?,?,?,?,?)").run(runId, sourceId, configId, started, now(), opts.complete === false ? 0 : 1, listings.length, positionsNew, opts.error ?? "", snapshotPath);
|
|
2366
|
+
});
|
|
2367
|
+
return { runId, received: listings.length, campaigns: campaigns.size, positionsNew, positionsSeen, positionsClosed, updated, snapshotPath, listed: { campaignIds: [...campaigns], positionIds: [...listedRows.values()] } };
|
|
2368
|
+
}
|
|
2369
|
+
__name(ingest, "ingest");
|
|
2370
|
+
function recordFailedRun(store, sourceId, configId, error) {
|
|
2371
|
+
store.db.prepare("insert into scan_run(id, source, config_id, started_at, finished_at, complete, found, new_positions, error, snapshot_path) values (?,?,?,?,?,?,?,?,?,?)").run(ulid2(), sourceId, configId, now(), now(), 0, 0, 0, error, "");
|
|
2372
|
+
}
|
|
2373
|
+
__name(recordFailedRun, "recordFailedRun");
|
|
2374
|
+
|
|
2375
|
+
// apps/daemon/src/data/sync.ts
|
|
2376
|
+
var SYNC_REASONS = {
|
|
2377
|
+
NO_OFFICIAL_LINK: "还没有这家公司的官网招聘链接",
|
|
2378
|
+
UNSUPPORTED_PLATFORM: "官网用的招聘系统还读不了",
|
|
2379
|
+
TIMEOUT: "官网超时没响应",
|
|
2380
|
+
LOGIN_REQUIRED: "官网要登录才能看",
|
|
2381
|
+
BLOCKED: "被官网拦了(验证码或访问限制)",
|
|
2382
|
+
SCHEMA_CHANGED: "官网改版,读取方式要更新",
|
|
2383
|
+
DECRYPT_FAILED: "官网返回的数据解不开",
|
|
2384
|
+
ZERO_POSTS: "官网读到了,但一个岗位都没有",
|
|
2385
|
+
SITE_DOWN: "官网打不开",
|
|
2386
|
+
FAILED: "读取失败",
|
|
2387
|
+
NOT_PUBLIC: "表格没有公开,读不到",
|
|
2388
|
+
BAD_URL: "链接不是这类招聘站的地址",
|
|
2389
|
+
NO_COMPANY_COLUMN: "没认出哪一列是公司",
|
|
2390
|
+
EMPTY_TABLE: "表格读到了,但没有一行能成岗位",
|
|
2391
|
+
FILE_NOT_FOUND: "找不到这个文件"
|
|
2392
|
+
};
|
|
2393
|
+
function reasonOf(error) {
|
|
2394
|
+
const code = /^([A-Z_]{4,}):/.exec(error)?.[1];
|
|
2395
|
+
if (code && code in SYNC_REASONS) return code;
|
|
2396
|
+
if (/timeout|timed out|ETIMEDOUT|aborted|超时/i.test(error)) return "TIMEOUT";
|
|
2397
|
+
if (/\b401\b|login|登录/i.test(error)) return "LOGIN_REQUIRED";
|
|
2398
|
+
if (/\b(403|429)\b|captcha|验证码|forbidden/i.test(error)) return "BLOCKED";
|
|
2399
|
+
if (/decrypt|解密/i.test(error)) return "DECRYPT_FAILED";
|
|
2400
|
+
if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|fetch failed|\b5\d\d\b/i.test(error)) return "SITE_DOWN";
|
|
2401
|
+
if (/没找到有岗位|没有岗位/.test(error)) return "ZERO_POSTS";
|
|
2402
|
+
if (/未找到对应的官网|\b404\b/.test(error)) return "SITE_DOWN";
|
|
2403
|
+
if (/JSON|Unexpected token|返回异常|not iterable|undefined|parameter error|没找到企业编号|没有换到/i.test(error)) return "SCHEMA_CHANGED";
|
|
2404
|
+
return "FAILED";
|
|
2405
|
+
}
|
|
2406
|
+
__name(reasonOf, "reasonOf");
|
|
2407
|
+
var running = /* @__PURE__ */ new Map();
|
|
2408
|
+
async function runSource(ctx, configId, opts = {}) {
|
|
2409
|
+
const cfg = ctx.sources.find((c) => c.id === configId);
|
|
2410
|
+
if (!cfg) return { ok: false, error: `NOT_FOUND: source config ${configId}` };
|
|
2411
|
+
const src = sources[cfg.source];
|
|
2412
|
+
if (!src?.fetch) return { ok: false, error: `NOT_FOUND: source kind ${cfg.source} is not a pull source` };
|
|
2413
|
+
const progress = { stage: "reading", since: (/* @__PURE__ */ new Date()).toISOString(), done: 0, total: 0 };
|
|
2414
|
+
running.set(cfg.id, progress);
|
|
2415
|
+
ctx.onPull?.(cfg.id, true);
|
|
2416
|
+
const log = /* @__PURE__ */ __name((m) => {
|
|
2417
|
+
console.log(`[scan] ${m}`);
|
|
2418
|
+
const n = /: (\d+)\/(\d+)$/.exec(m);
|
|
2419
|
+
if (n) {
|
|
2420
|
+
progress.done = Number(n[1]);
|
|
2421
|
+
progress.total = Number(n[2]);
|
|
2422
|
+
}
|
|
2423
|
+
}, "log");
|
|
2424
|
+
try {
|
|
2425
|
+
const listings = await src.fetch(cfg, httpContext(log));
|
|
2426
|
+
if (!listings.length && !cfg.discover && !cfg.keywords?.length) throw new Error(src.table ? "EMPTY_TABLE: 表格读到了,但没有一行能成岗位(没有公司、已过截止、或届次对不上的行都不算)" : "ZERO_POSTS: 官网读到了,但一个岗位都没有");
|
|
2427
|
+
Object.assign(progress, { stage: "filing", done: 0, total: listings.length });
|
|
2428
|
+
await new Promise((ok) => setImmediate(ok));
|
|
2429
|
+
const { listed, ...result } = ingest(ctx, ctx.vault, src.id, cfg.id, listings, { closeUnseen: true });
|
|
2430
|
+
if (!opts.quiet && (result.positionsNew || result.positionsClosed)) ctx.onDataChanged?.();
|
|
2431
|
+
else if (result.updated) ctx.onPostsEdited?.();
|
|
2432
|
+
return { ok: true, result, listed };
|
|
2433
|
+
} catch (e) {
|
|
2434
|
+
const error = String(e?.message ?? e);
|
|
2435
|
+
recordFailedRun(ctx, src.id, cfg.id, error);
|
|
2436
|
+
return { ok: false, error };
|
|
2437
|
+
} finally {
|
|
2438
|
+
if (running.get(cfg.id) === progress) running.delete(cfg.id);
|
|
2439
|
+
ctx.onPull?.(cfg.id, false);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
__name(runSource, "runSource");
|
|
2443
|
+
function listSources(ctx) {
|
|
2444
|
+
const kinds = Object.values(sources).map((x) => ({ id: x.id, mode: x.mode, describe: x.describe }));
|
|
2445
|
+
const configured = ctx.sources.map((c) => {
|
|
2446
|
+
const runs = ctx.db.prepare("select started_at, found, new_positions, complete, error from scan_run where config_id = ? order by started_at desc limit 10").all(c.id);
|
|
2447
|
+
const r = runs[0], firstOk = runs.findIndex((x) => !x.error);
|
|
2448
|
+
return {
|
|
2449
|
+
id: c.id,
|
|
2450
|
+
source: c.source,
|
|
2451
|
+
name: c.name ?? "",
|
|
2452
|
+
url: c.url ?? "",
|
|
2453
|
+
enabled: c.enabled !== false,
|
|
2454
|
+
everyHours: c.everyHours ?? 24,
|
|
2455
|
+
auto: c.auto === true,
|
|
2456
|
+
lastRun: r ? { at: r.started_at, found: r.found, newPositions: r.new_positions, complete: !!r.complete, error: r.error } : null,
|
|
2457
|
+
// health from what the runs themselves say: when it last worked, how many times in a row it has not, and why
|
|
2458
|
+
health: { lastOkAt: runs[firstOk]?.started_at ?? null, failures: firstOk < 0 ? runs.length : firstOk, reason: r?.error ? reasonOf(r.error) : "" },
|
|
2459
|
+
...running.has(c.id) ? { running: { ...running.get(c.id) } } : {}
|
|
2460
|
+
};
|
|
2461
|
+
});
|
|
2462
|
+
return { kinds, configured };
|
|
2463
|
+
}
|
|
2464
|
+
__name(listSources, "listSources");
|
|
2465
|
+
var RETRY_SOON_MS = 60 * 6e4;
|
|
2466
|
+
var SOON_TRIES = 3;
|
|
2467
|
+
function sourceScan(ctx) {
|
|
2468
|
+
return {
|
|
2469
|
+
kind: "source_scan",
|
|
2470
|
+
order: ["pull"],
|
|
2471
|
+
steps: {
|
|
2472
|
+
pull: /* @__PURE__ */ __name(async ({ subjectId, checkpoint }) => {
|
|
2473
|
+
const cfg = ctx.sources.find((c) => c.id === subjectId);
|
|
2474
|
+
if (!cfg || cfg.enabled === false) return { type: "done" };
|
|
2475
|
+
const r = await runSource(ctx, subjectId);
|
|
2476
|
+
const hours = cfg.everyHours ?? 24;
|
|
2477
|
+
if (hours <= 0) return { type: "done" };
|
|
2478
|
+
const pace = hours * 36e5;
|
|
2479
|
+
if (!r.ok) {
|
|
2480
|
+
const failures = (checkpoint?.failures ?? 0) + 1;
|
|
2481
|
+
return { type: "sleep", until: new Date(Date.now() + (failures <= SOON_TRIES ? Math.min(RETRY_SOON_MS, pace) : pace)).toISOString(), checkpoint: { ...checkpoint, failures, lastError: r.error } };
|
|
2482
|
+
}
|
|
2483
|
+
return { type: "sleep", until: new Date(Date.now() + pace).toISOString(), checkpoint: { lastRunId: r.result.runId } };
|
|
2484
|
+
}, "pull")
|
|
2485
|
+
}
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
__name(sourceScan, "sourceScan");
|
|
2489
|
+
function scheduleSources(ctx) {
|
|
2490
|
+
let n = 0;
|
|
2491
|
+
for (const c of ctx.sources) {
|
|
2492
|
+
if (c.enabled === false || (c.everyHours ?? 24) <= 0) continue;
|
|
2493
|
+
ctx.workflows?.ensure("source_scan", c.id, `source_scan:${c.id}`);
|
|
2494
|
+
n++;
|
|
2495
|
+
}
|
|
2496
|
+
return n;
|
|
2497
|
+
}
|
|
2498
|
+
__name(scheduleSources, "scheduleSources");
|
|
2499
|
+
var PREVIEW_KEEP_MS = 3 * 6e4;
|
|
2500
|
+
var previewed = null;
|
|
2501
|
+
async function previewSource(ctx, p) {
|
|
2502
|
+
const url = p.url.trim();
|
|
2503
|
+
const src = sourceFor(url);
|
|
2504
|
+
if (!src?.fetch) throw Object.assign(new Error(`UNSUPPORTED_URL: 还认不出这个链接是哪种招聘站或表格:${url}`), { code: "UNSUPPORTED_URL" });
|
|
2505
|
+
const subscribed = ctx.sources.find((c) => c.url && siteKeyOf(c.url) === siteKeyOf(url))?.id ?? null;
|
|
2506
|
+
const fields = Object.keys(FIELDS).map((field) => ({ field, label: FIELDS[field], required: field === "company" }));
|
|
2507
|
+
const base = { source: src.id, name: src.name, subscribed, fields };
|
|
2508
|
+
if (!src.table) return { ...base, table: false, headers: [], columns: {}, rows: 0, posts: 0, companies: 0, sample: [], problem: "" };
|
|
2509
|
+
const key = `${src.id}:${url}`;
|
|
2510
|
+
const table = previewed && previewed.key === key && Date.now() - previewed.at < PREVIEW_KEEP_MS ? previewed.table : await src.table({ id: "preview", source: src.id, url }, httpContext());
|
|
2511
|
+
previewed = { key, at: previewed?.key === key ? previewed.at : Date.now(), table };
|
|
2512
|
+
const columns = Object.fromEntries(Object.entries(p.columns ?? guessColumns(table.headers)).filter(([f, h]) => f in FIELDS && table.headers.includes(h)));
|
|
2513
|
+
const listings = columns.company ? tableToListings(table, columns, { cohort: p.cohort }) : [];
|
|
2514
|
+
const problem = !table.rows.length ? "表是空的" : !columns.company ? `没认出哪一列是公司。现有的列:${table.headers.slice(0, 20).join("、")}` : !listings.length ? "没有一行能成岗位:公司为空、已过截止、或届次对不上的行都不算" : "";
|
|
2515
|
+
return {
|
|
2516
|
+
...base,
|
|
2517
|
+
table: true,
|
|
2518
|
+
headers: table.headers,
|
|
2519
|
+
columns,
|
|
2520
|
+
rows: table.rows.length,
|
|
2521
|
+
posts: listings.length,
|
|
2522
|
+
companies: new Set(listings.map((l) => l.company)).size,
|
|
2523
|
+
sample: listings.slice(0, 3).map((l) => ({ company: l.company, title: l.title, deadline: l.deadlineText ?? "", cities: l.locations, url: l.url })),
|
|
2524
|
+
problem
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
__name(previewSource, "previewSource");
|
|
2528
|
+
|
|
2529
|
+
// apps/daemon/src/data/subscriptions.ts
|
|
2530
|
+
function write(vault, list) {
|
|
2531
|
+
updateConfig(vault, (doc) => {
|
|
2532
|
+
doc.sources = list;
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
__name(write, "write");
|
|
2536
|
+
function saveSources(ctx, list) {
|
|
2537
|
+
write(ctx.vault, list);
|
|
2538
|
+
const kept = new Set(list.map((c) => c.id));
|
|
2539
|
+
for (const c of ctx.sources) if (!kept.has(c.id)) ctx.workflows?.end(`source_scan:${c.id}`);
|
|
2540
|
+
ctx.sources.splice(0, ctx.sources.length, ...list);
|
|
2541
|
+
ctx.onSourcesChanged?.();
|
|
2542
|
+
}
|
|
2543
|
+
__name(saveSources, "saveSources");
|
|
2544
|
+
function addSource(ctx, p) {
|
|
2545
|
+
p = { ...p, url: p.url.trim() };
|
|
2546
|
+
const src = sourceFor(p.url);
|
|
2547
|
+
if (!src) throw Object.assign(new Error(`UNSUPPORTED_URL: no pull source detects ${p.url}`), { code: "UNSUPPORTED_URL" });
|
|
2548
|
+
const key = siteKeyOf(p.url);
|
|
2549
|
+
const same = ctx.sources.find((c) => c.url && siteKeyOf(c.url) === key);
|
|
2550
|
+
if (same) return { id: same.id, source: same.source };
|
|
2551
|
+
const base = `${src.id}-${p.name.replace(/[^\w一-鿿]+/g, "").slice(0, 20) || "src"}`;
|
|
2552
|
+
let id = base;
|
|
2553
|
+
for (let i = 2; ctx.sources.some((c) => c.id === id); i++) id = `${base}-${i}`;
|
|
2554
|
+
const entry = { id, source: src.id, name: p.name, url: p.url, cohort: p.cohort, batch: p.batch, everyHours: p.everyHours ?? 24, enabled: true, ...p.auto ? { auto: true } : {}, ...p.columns && src.table ? { columns: p.columns } : {} };
|
|
2555
|
+
write(ctx.vault, [...ctx.sources, entry]);
|
|
2556
|
+
ctx.sources.push(entry);
|
|
2557
|
+
ctx.onSourcesChanged?.();
|
|
2558
|
+
return { id, source: src.id };
|
|
2559
|
+
}
|
|
2560
|
+
__name(addSource, "addSource");
|
|
2561
|
+
function removeSource(ctx, id) {
|
|
2562
|
+
const cfg = ctx.sources.find((c) => c.id === id);
|
|
2563
|
+
if (!cfg) throw Object.assign(new Error(`NOT_FOUND: 没有这个订阅:${id}`), { code: "NOT_FOUND" });
|
|
2564
|
+
saveSources(ctx, ctx.sources.filter((c) => c.id !== id));
|
|
2565
|
+
return cfg;
|
|
2566
|
+
}
|
|
2567
|
+
__name(removeSource, "removeSource");
|
|
2568
|
+
|
|
2569
|
+
export {
|
|
2570
|
+
genericCompany,
|
|
2571
|
+
companyKey,
|
|
2572
|
+
sha,
|
|
2573
|
+
campaignKey,
|
|
2574
|
+
upsertCompany,
|
|
2575
|
+
upsertCampaign,
|
|
2576
|
+
upsertPosition,
|
|
2577
|
+
parseCsv,
|
|
2578
|
+
parseDeadline,
|
|
2579
|
+
splitList,
|
|
2580
|
+
gradeFromSource,
|
|
2581
|
+
parseBaseUrl,
|
|
2582
|
+
readBase,
|
|
2583
|
+
siteLinks,
|
|
2584
|
+
httpContext,
|
|
2585
|
+
publicUrl,
|
|
2586
|
+
isLocalPath,
|
|
2587
|
+
sources,
|
|
2588
|
+
sourceFor,
|
|
2589
|
+
siteKeyOf,
|
|
2590
|
+
isAggregate,
|
|
2591
|
+
aggregateSql,
|
|
2592
|
+
isNotices,
|
|
2593
|
+
ingest,
|
|
2594
|
+
recordFailedRun,
|
|
2595
|
+
SYNC_REASONS,
|
|
2596
|
+
reasonOf,
|
|
2597
|
+
runSource,
|
|
2598
|
+
listSources,
|
|
2599
|
+
sourceScan,
|
|
2600
|
+
scheduleSources,
|
|
2601
|
+
previewSource,
|
|
2602
|
+
saveSources,
|
|
2603
|
+
addSource,
|
|
2604
|
+
removeSource
|
|
2605
|
+
};
|