@ha7ch/job-pro 1.0.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.js +17 -0
- package/dist/agibot.js +399 -0
- package/dist/alibaba.js +509 -0
- package/dist/antgroup.js +397 -0
- package/dist/apply.js +1373 -0
- package/dist/baichuan.js +49 -0
- package/dist/baidu.js +452 -0
- package/dist/bilibili.js +455 -0
- package/dist/byd.js +412 -0
- package/dist/bytedance.js +619 -0
- package/dist/cainiao.js +56 -0
- package/dist/cambricon.js +33 -0
- package/dist/cdp.js +237 -0
- package/dist/cicc.js +56 -0
- package/dist/coverage.js +60 -0
- package/dist/deepseek.js +25 -0
- package/dist/didi.js +381 -0
- package/dist/feishu.js +577 -0
- package/dist/galaxyuniversal.js +24 -0
- package/dist/geely.js +35 -0
- package/dist/greenhouse.js +432 -0
- package/dist/hikvision.js +58 -0
- package/dist/horizonrobotics.js +46 -0
- package/dist/hoyoverse.js +26 -0
- package/dist/huawei.js +537 -0
- package/dist/iflytek.js +380 -0
- package/dist/index.js +1828 -0
- package/dist/iqiyi.js +494 -0
- package/dist/jd.js +559 -0
- package/dist/kuaishou.js +496 -0
- package/dist/lever.js +455 -0
- package/dist/liauto.js +393 -0
- package/dist/liepin.js +357 -0
- package/dist/lilith.js +300 -0
- package/dist/megvii.js +27 -0
- package/dist/meituan.js +633 -0
- package/dist/memory.js +76 -0
- package/dist/mihoyo.js +308 -0
- package/dist/minimax.js +32 -0
- package/dist/moka.js +473 -0
- package/dist/moonshot.js +24 -0
- package/dist/netease.js +424 -0
- package/dist/nio.js +24 -0
- package/dist/oppo.js +285 -0
- package/dist/pdd.js +614 -0
- package/dist/pingan.js +493 -0
- package/dist/sensetime.js +51 -0
- package/dist/sf.js +310 -0
- package/dist/stepfun.js +24 -0
- package/dist/tencent.js +770 -0
- package/dist/trip.js +396 -0
- package/dist/unitree.js +418 -0
- package/dist/vivo.js +361 -0
- package/dist/webank.js +55 -0
- package/dist/wecruit.js +438 -0
- package/dist/weibo.js +337 -0
- package/dist/weride.js +29 -0
- package/dist/xiaohongshu.js +480 -0
- package/dist/xiaomi.js +529 -0
- package/dist/xpeng.js +34 -0
- package/dist/zerooneai.js +42 -0
- package/dist/zhipu.js +478 -0
- package/extension/README.md +79 -0
- package/extension/background.js +177 -0
- package/extension/manifest.json +55 -0
- package/extension/popup.html +37 -0
- package/extension/popup.js +54 -0
- package/package.json +61 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1828 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import * as tencent from "./tencent.js";
|
|
4
|
+
import * as bytedance from "./bytedance.js";
|
|
5
|
+
import * as alibaba from "./alibaba.js";
|
|
6
|
+
import * as meituan from "./meituan.js";
|
|
7
|
+
import * as xiaohongshu from "./xiaohongshu.js";
|
|
8
|
+
import * as jd from "./jd.js";
|
|
9
|
+
import * as kuaishou from "./kuaishou.js";
|
|
10
|
+
import * as xiaomi from "./xiaomi.js";
|
|
11
|
+
import * as baidu from "./baidu.js";
|
|
12
|
+
import * as netease from "./netease.js";
|
|
13
|
+
import * as didi from "./didi.js";
|
|
14
|
+
import * as bilibili from "./bilibili.js";
|
|
15
|
+
import * as pdd from "./pdd.js";
|
|
16
|
+
import * as nio from "./nio.js";
|
|
17
|
+
import * as minimax from "./minimax.js";
|
|
18
|
+
import * as huawei from "./huawei.js";
|
|
19
|
+
import * as weibo from "./weibo.js";
|
|
20
|
+
import * as mihoyo from "./mihoyo.js";
|
|
21
|
+
import * as pingan from "./pingan.js";
|
|
22
|
+
import * as sensetime from "./sensetime.js";
|
|
23
|
+
import * as trip from "./trip.js";
|
|
24
|
+
import * as unitree from "./unitree.js";
|
|
25
|
+
import * as byd from "./byd.js";
|
|
26
|
+
import * as antgroup from "./antgroup.js";
|
|
27
|
+
import * as liauto from "./liauto.js";
|
|
28
|
+
import * as moonshot from "./moonshot.js";
|
|
29
|
+
import * as zhipu from "./zhipu.js";
|
|
30
|
+
import * as hikvision from "./hikvision.js";
|
|
31
|
+
import * as iqiyi from "./iqiyi.js";
|
|
32
|
+
import * as megvii from "./megvii.js";
|
|
33
|
+
import * as lilith from "./lilith.js";
|
|
34
|
+
import * as agibot from "./agibot.js";
|
|
35
|
+
import * as deepseek from "./deepseek.js";
|
|
36
|
+
import * as zerooneai from "./zerooneai.js";
|
|
37
|
+
import * as galaxyuniversal from "./galaxyuniversal.js";
|
|
38
|
+
import * as stepfun from "./stepfun.js";
|
|
39
|
+
import * as cicc from "./cicc.js";
|
|
40
|
+
import * as baichuan from "./baichuan.js";
|
|
41
|
+
import * as xpeng from "./xpeng.js";
|
|
42
|
+
import * as weride from "./weride.js";
|
|
43
|
+
import * as hoyoverse from "./hoyoverse.js";
|
|
44
|
+
import * as iflytek from "./iflytek.js";
|
|
45
|
+
import * as oppo from "./oppo.js";
|
|
46
|
+
import * as vivo from "./vivo.js";
|
|
47
|
+
import * as sf from "./sf.js";
|
|
48
|
+
import * as cainiao from "./cainiao.js";
|
|
49
|
+
import * as geely from "./geely.js";
|
|
50
|
+
import * as webank from "./webank.js";
|
|
51
|
+
import * as horizonrobotics from "./horizonrobotics.js";
|
|
52
|
+
import * as cambricon from "./cambricon.js";
|
|
53
|
+
import { loadProfile, loadProfileRaw, loadSession, profileTemplate, saveProfile, stageApplication, submitApplication, executeFeishu3Step, executeMokaApply, executeBeisenWecruit, executeBeisenITalent, executeCdpRealBrowser, buildFormTemplate, applyFormFile, promptUnansweredFields, formatStaged, } from "./apply.js";
|
|
54
|
+
import { createInterface } from "node:readline";
|
|
55
|
+
import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
|
|
56
|
+
import { writeFileSync, mkdirSync, existsSync, readdirSync, statSync, mkdtempSync, rmSync } from "node:fs";
|
|
57
|
+
import { dirname, join } from "node:path";
|
|
58
|
+
import { fileURLToPath } from "node:url";
|
|
59
|
+
import { homedir, tmpdir } from "node:os";
|
|
60
|
+
import { createRequire as require_createRequire } from "node:module";
|
|
61
|
+
function require_module() {
|
|
62
|
+
return { createRequire: require_createRequire };
|
|
63
|
+
}
|
|
64
|
+
// Read version from package.json at module load so it can never drift
|
|
65
|
+
// from the publish. Tries the bundled package.json (cli/package.json
|
|
66
|
+
// next to dist/) first, then falls back to a hardcoded sentinel.
|
|
67
|
+
const VERSION = (() => {
|
|
68
|
+
try {
|
|
69
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
// cli/dist/index.js → cli/package.json is two levels up
|
|
71
|
+
const candidates = [
|
|
72
|
+
join(here, "..", "package.json"),
|
|
73
|
+
join(here, "..", "..", "package.json"),
|
|
74
|
+
];
|
|
75
|
+
for (const p of candidates) {
|
|
76
|
+
if (existsSync(p)) {
|
|
77
|
+
const pkg = JSON.parse(readFileSync(p, "utf8"));
|
|
78
|
+
if (pkg.name === "job-pro" && typeof pkg.version === "string")
|
|
79
|
+
return pkg.version;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch { /* fall through */ }
|
|
84
|
+
return "unknown";
|
|
85
|
+
})();
|
|
86
|
+
const COMPANIES = [
|
|
87
|
+
{ key: "tencent", family: "Bespoke", source: "join.qq.com", label: "Tencent / 腾讯" },
|
|
88
|
+
{ key: "bytedance", family: "Bespoke", source: "jobs.bytedance.com", label: "ByteDance / 字节跳动" },
|
|
89
|
+
{ key: "alibaba", family: "Bespoke", source: "campus-talent.alibaba.com", label: "Alibaba / 阿里巴巴" },
|
|
90
|
+
{ key: "meituan", family: "Bespoke", source: "zhaopin.meituan.com", label: "Meituan / 美团" },
|
|
91
|
+
{ key: "xiaohongshu", family: "Bespoke", source: "job.xiaohongshu.com", label: "Xiaohongshu / 小红书" },
|
|
92
|
+
{ key: "jd", family: "Bespoke", source: "campus.jd.com", label: "JD / 京东" },
|
|
93
|
+
{ key: "kuaishou", family: "Bespoke", source: "campus.kuaishou.cn", label: "Kuaishou / 快手" },
|
|
94
|
+
{ key: "baidu", family: "Bespoke", source: "talent.baidu.com", label: "Baidu / 百度" },
|
|
95
|
+
{ key: "netease", family: "Bespoke", source: "hr.163.com", label: "NetEase / 网易" },
|
|
96
|
+
{ key: "didi", family: "Bespoke", source: "talent.didiglobal.com", label: "Didi / 滴滴" },
|
|
97
|
+
{ key: "bilibili", family: "Bespoke", source: "jobs.bilibili.com", label: "Bilibili / 哔哩哔哩" },
|
|
98
|
+
{ key: "pdd", family: "Bespoke", source: "careers.pinduoduo.com", label: "PDD / 拼多多" },
|
|
99
|
+
{ key: "huawei", family: "Bespoke", source: "career.huawei.com", label: "Huawei / 华为" },
|
|
100
|
+
{ key: "weibo", family: "Bespoke", source: "career.sina.com.cn", label: "Weibo / 微博" },
|
|
101
|
+
{ key: "mihoyo", family: "Bespoke", source: "ats.openout.mihoyo.com", label: "miHoYo / 米哈游" },
|
|
102
|
+
{ key: "pingan", family: "Bespoke", source: "campus.pingan.com", label: "Ping An / 平安" },
|
|
103
|
+
{ key: "trip", family: "Bespoke", source: "careers.ctrip.com", label: "Trip.com / 携程" },
|
|
104
|
+
{ key: "unitree", family: "Bespoke", source: "www.unitree.com", label: "Unitree / 宇树科技" },
|
|
105
|
+
{ key: "byd", family: "Bespoke", source: "job.byd.com", label: "BYD / 比亚迪" },
|
|
106
|
+
{ key: "antgroup", family: "Bespoke", source: "hrcareersweb.antgroup.com", label: "Ant Group / 蚂蚁集团" },
|
|
107
|
+
{ key: "liauto", family: "Bespoke", source: "www.lixiang.com", label: "Li Auto / 理想汽车" },
|
|
108
|
+
{ key: "sf", family: "Bespoke", source: "campus.sf-express.com", label: "SF Express / 顺丰" },
|
|
109
|
+
{ key: "oppo", family: "Bespoke", source: "careers.oppo.com", label: "OPPO" },
|
|
110
|
+
{ key: "xiaomi", family: "Feishu", source: "xiaomi.jobs.f.mioffice.cn", label: "Xiaomi / 小米" },
|
|
111
|
+
{ key: "nio", family: "Feishu", source: "nio.jobs.feishu.cn", label: "NIO / 蔚来" },
|
|
112
|
+
{ key: "minimax", family: "Feishu", source: "vrfi1sk8a0.jobs.feishu.cn", label: "MiniMax" },
|
|
113
|
+
{ key: "moonshot", family: "Moka", source: "app.mokahr.com/moonshot", label: "Moonshot / 月之暗面" },
|
|
114
|
+
{ key: "zhipu", family: "Feishu", source: "zhipu-ai.jobs.feishu.cn", label: "Zhipu / 智谱AI" },
|
|
115
|
+
{ key: "iqiyi", family: "Feishu", source: "careers.iqiyi.com", label: "iQIYI / 爱奇艺" },
|
|
116
|
+
{ key: "agibot", family: "Feishu", source: "agirobot.jobs.feishu.cn", label: "Agibot / 智元机器人" },
|
|
117
|
+
{ key: "lilith", family: "Feishu", source: "lilithgames.jobs.feishu.cn", label: "Lilith Games / 莉莉丝 — needs local Chrome" },
|
|
118
|
+
{ key: "zerooneai", family: "Feishu", source: "01ai.jobs.feishu.cn", label: "01.AI / 零一万物" },
|
|
119
|
+
{ key: "baichuan", family: "Feishu", source: "cq6qe6bvfr6.jobs.feishu.cn", label: "Baichuan / 百川智能" },
|
|
120
|
+
{ key: "sensetime", family: "Beisen Wecruit", source: "hr.sensetime.com", label: "SenseTime / 商汤" },
|
|
121
|
+
{ key: "horizonrobotics", family: "Beisen Wecruit", source: "wecruit.hotjob.cn", label: "Horizon Robotics / 地平线" },
|
|
122
|
+
{ key: "vivo", family: "Beisen iTalent", source: "vivo.zhiye.com", label: "vivo" },
|
|
123
|
+
{ key: "iflytek", family: "Beisen iTalent", source: "iflytek.zhiye.com", label: "iFlytek / 科大讯飞" },
|
|
124
|
+
{ key: "megvii", family: "Moka", source: "app.mokahr.com/megviihr", label: "Megvii / 旷视" },
|
|
125
|
+
{ key: "deepseek", family: "Moka", source: "app.mokahr.com/high-flyer", label: "DeepSeek / 深度求索" },
|
|
126
|
+
{ key: "galaxyuniversal", family: "Moka", source: "app.mokahr.com/yinhetongyong", label: "Galaxy Universal / 银河通用" },
|
|
127
|
+
{ key: "stepfun", family: "Moka", source: "app.mokahr.com/step", label: "StepFun / 阶跃星辰" },
|
|
128
|
+
{ key: "cambricon", family: "Moka", source: "app.mokahr.com/cambricon", label: "Cambricon / 寒武纪" },
|
|
129
|
+
{ key: "geely", family: "Moka", source: "app.mokahr.com/geely", label: "Geely / 吉利" },
|
|
130
|
+
{ key: "xpeng", family: "Greenhouse / Lever (intl arm)", source: "boards.greenhouse.io/xpengmotors", label: "XPeng / 小鹏汽车 — US AI" },
|
|
131
|
+
{ key: "weride", family: "Greenhouse / Lever (intl arm)", source: "jobs.lever.co/weride", label: "WeRide / 文远知行 — US / 广州" },
|
|
132
|
+
{ key: "hoyoverse", family: "Greenhouse / Lever (intl arm)", source: "boards.greenhouse.io/hoyoverse", label: "HoYoverse / 米哈游国际" },
|
|
133
|
+
{ key: "hikvision", family: "Liepin (third-party)", source: "api-c.liepin.com", label: "Hikvision / 海康威视" },
|
|
134
|
+
{ key: "cicc", family: "Liepin (third-party)", source: "api-c.liepin.com", label: "CICC / 中金" },
|
|
135
|
+
{ key: "cainiao", family: "Liepin (third-party)", source: "api-c.liepin.com", label: "Cainiao / 菜鸟" },
|
|
136
|
+
{ key: "webank", family: "Liepin (third-party)", source: "api-c.liepin.com", label: "WeBank / 微众银行" },
|
|
137
|
+
];
|
|
138
|
+
// Family → default submit_kind. Mirrors what fetchApplicationSchema returns
|
|
139
|
+
// for each adapter today; kept here as the static source of truth used by
|
|
140
|
+
// `list` output and `find`'s apply-status derivation.
|
|
141
|
+
const SUBMIT_KIND_BY_FAMILY = {
|
|
142
|
+
"Bespoke": "multipart-session",
|
|
143
|
+
"Feishu": "feishu-3-step",
|
|
144
|
+
"Moka": "moka-aes",
|
|
145
|
+
"Beisen Wecruit": "beisen-wecruit",
|
|
146
|
+
"Beisen iTalent": "beisen-italent",
|
|
147
|
+
"Greenhouse / Lever (intl arm)": "multipart-anon",
|
|
148
|
+
"Liepin (third-party)": "external",
|
|
149
|
+
};
|
|
150
|
+
// Adapter-level deviations from their family default.
|
|
151
|
+
const SUBMIT_KIND_OVERRIDES = {
|
|
152
|
+
unitree: "external", // Bespoke family, but WeChat-QR — no API submit.
|
|
153
|
+
lilith: "cdp-real-browser", // Feishu tenant, but needs ByteDance _signature bypass.
|
|
154
|
+
bytedance: "feishu-3-step", // Bespoke family but jobs.bytedance.com is atsx-throne.
|
|
155
|
+
weibo: "moka-aes", // Sina careers proxies to Moka (app.mokahr.com/sina).
|
|
156
|
+
};
|
|
157
|
+
function submitKindFor(adapterKey, family) {
|
|
158
|
+
return SUBMIT_KIND_OVERRIDES[adapterKey] ?? SUBMIT_KIND_BY_FAMILY[family];
|
|
159
|
+
}
|
|
160
|
+
// Mirrors `endpoint_verified: true` in each adapter's schema. Extracted to
|
|
161
|
+
// its own module so unit tests can assert against it without spawning the
|
|
162
|
+
// CLI. See cli/src/coverage.ts.
|
|
163
|
+
import { ENDPOINT_VERIFIED } from "./coverage.js";
|
|
164
|
+
const HELP = `
|
|
165
|
+
job-pro — query Chinese big-tech campus recruiting from your terminal
|
|
166
|
+
(job.ha7ch.com)
|
|
167
|
+
|
|
168
|
+
USAGE
|
|
169
|
+
job-pro <company> <verb> [options]
|
|
170
|
+
job-pro list [--compact] list all 50 companies + source family
|
|
171
|
+
job-pro status [--compact] survey profile / sessions / memory / chrome
|
|
172
|
+
job-pro selftest [--compact] end-to-end check: search → schema → echo-submit
|
|
173
|
+
job-pro recon [--companies a,b,c] probe every adapter's submit_endpoint
|
|
174
|
+
(classifies as verified-real / 404 /
|
|
175
|
+
html-fallthrough / external)
|
|
176
|
+
[--summary] for tally only
|
|
177
|
+
[--compact] for JSON
|
|
178
|
+
job-pro profile init [--interactive] [--force]
|
|
179
|
+
write ~/.jobpro/profile.json
|
|
180
|
+
--interactive fills it via prompts.
|
|
181
|
+
job-pro profile show print the loaded profile
|
|
182
|
+
job-pro profile lint validate format of every field
|
|
183
|
+
(exits 1 on any FAIL — scriptable)
|
|
184
|
+
job-pro find <keyword> search ALL 50 companies in parallel
|
|
185
|
+
[--limit N] [--companies a,b,c]
|
|
186
|
+
[--timeout ms] [--apply-ready]
|
|
187
|
+
[--compact | --text]
|
|
188
|
+
job-pro extension print extension/ path + install steps
|
|
189
|
+
job-pro extension path just the absolute path (scriptable)
|
|
190
|
+
job-pro --version
|
|
191
|
+
job-pro help
|
|
192
|
+
|
|
193
|
+
50 companies, all live. Run \`job-pro list\` for the full table grouped
|
|
194
|
+
by ATS family (Bespoke / Feishu / Beisen Wecruit / Beisen iTalent / Moka
|
|
195
|
+
/ Greenhouse-Lever / Liepin). Coverage summary at job.ha7ch.com.
|
|
196
|
+
|
|
197
|
+
PHASE 2 (auto-apply) — 50/50 schema-ok, **45/50 endpoint-verified**:
|
|
198
|
+
✅ multipart-anon (3) — xpeng / weride / hoyoverse. Anon submit, no session.
|
|
199
|
+
✅ multipart-session (20) — tencent / alibaba / pdd / meituan / mihoyo /
|
|
200
|
+
liauto / jd / oppo / trip / baidu / xiaohongshu /
|
|
201
|
+
netease / didi / pingan / sf / byd / bilibili /
|
|
202
|
+
kuaishou / huawei / antgroup. Needs session.
|
|
203
|
+
✅ feishu-3-step (9) — xiaomi / nio / minimax / zhipu / iqiyi / agibot /
|
|
204
|
+
zerooneai / baichuan / bytedance. atsx-throne tenant.
|
|
205
|
+
✅ moka-aes (8) — moonshot / megvii / deepseek / galaxyuniversal /
|
|
206
|
+
stepfun / cambricon / geely / weibo (proxies to Moka).
|
|
207
|
+
✅ beisen-italent (2) — iflytek / vivo.
|
|
208
|
+
✅ beisen-wecruit (2) — sensetime / horizonrobotics.
|
|
209
|
+
✅ cdp-real-browser (1) — lilith (puppeteer for ByteDance _signature bypass).
|
|
210
|
+
⛔ external (5) — hikvision / cicc / cainiao / webank (Liepin chat),
|
|
211
|
+
unitree (WeChat QR). Structurally non-API.
|
|
212
|
+
\`apply <postId>\` dry-runs the staged POST. \`--really-submit\` runs the
|
|
213
|
+
4-layer safety gate (env attest + staged.ready + endpoint_verified +
|
|
214
|
+
session<30d). Run \`job-pro list\` for the ✓ column or \`job-pro recon\`
|
|
215
|
+
for the live probe matrix. See docs/auto-apply.md.
|
|
216
|
+
|
|
217
|
+
VERBS (same surface for every company)
|
|
218
|
+
search <kw> search openings (free text)
|
|
219
|
+
detail <post_id> show full JD for one job
|
|
220
|
+
all [<kw>] paginate every job (filter by kw if given)
|
|
221
|
+
dicts dump filter dictionaries (where supported)
|
|
222
|
+
notices list official announcements (where supported)
|
|
223
|
+
notice <id> show one announcement's content (tencent only)
|
|
224
|
+
flow <question> answer using best-matching notices (tencent only)
|
|
225
|
+
match <resume-text-or--> rank jobs by overlap with resume text
|
|
226
|
+
pass "-" to read resume from stdin
|
|
227
|
+
resume-check <resume-text-or--> structural sanity check on a resume
|
|
228
|
+
apply <post_id> stage an application (Phase 2 dry-run)
|
|
229
|
+
--schema dump raw schema (no profile needed)
|
|
230
|
+
--print-form emit a fillable JSON template
|
|
231
|
+
--form-file <path> merge per-job answers
|
|
232
|
+
--interactive prompt for unanswered fields
|
|
233
|
+
--remember + persist answers to profile.custom
|
|
234
|
+
--batch <file|-> apply to many post_ids (one/line)
|
|
235
|
+
--debug-submit-to <url> verify wire format
|
|
236
|
+
--debug-submit ↑ shorthand → httpbin.org/post
|
|
237
|
+
--really-submit actually fire (env-gated)
|
|
238
|
+
--via-cdp drive a puppeteer browser through
|
|
239
|
+
the SPA's apply form (DOM-based,
|
|
240
|
+
bypasses API body-shape uncertainty)
|
|
241
|
+
--allow-stale-session bypass 30-day session-age gate
|
|
242
|
+
memory list | get <k> | set k=v | event <kind> [payload] | clear
|
|
243
|
+
|
|
244
|
+
OUTPUT
|
|
245
|
+
Add --compact for one-line JSON (good for piping to jq / claude).
|
|
246
|
+
|
|
247
|
+
EXAMPLES
|
|
248
|
+
job-pro tencent search "后台开发" --page-size 5
|
|
249
|
+
job-pro bytedance search "前端" --page-size 5
|
|
250
|
+
job-pro alibaba search "AI" --page-size 5
|
|
251
|
+
job-pro tencent detail 1200791473415778304
|
|
252
|
+
job-pro bytedance detail 7638940721068099893
|
|
253
|
+
job-pro alibaba detail 199903220038
|
|
254
|
+
job-pro tencent notices
|
|
255
|
+
job-pro tencent flow "腾讯2026实习什么时候开始投递" --question-time 2026-05-13
|
|
256
|
+
cat my-resume.md | job-pro tencent match -
|
|
257
|
+
job-pro tencent memory set "stack=Go,Python" "target_city=深圳"
|
|
258
|
+
job-pro bytedance memory event applied "ByteDance 前端 7638940721068099893"
|
|
259
|
+
|
|
260
|
+
COMPANION
|
|
261
|
+
Pairs with cv.ha7ch.com — draft the resume you pipe into \`match\` /
|
|
262
|
+
\`resume-check\` and paste into \`apply --interactive\`.
|
|
263
|
+
|
|
264
|
+
DOCS
|
|
265
|
+
https://job.ha7ch.com
|
|
266
|
+
https://github.com/HA7CH/job-pro
|
|
267
|
+
`.trim();
|
|
268
|
+
function die(msg) {
|
|
269
|
+
console.error(`Error: ${msg}`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
function popCompactFlag(args) {
|
|
273
|
+
const compact = args.includes("--compact");
|
|
274
|
+
return { args: args.filter((a) => a !== "--compact"), compact };
|
|
275
|
+
}
|
|
276
|
+
function popFlagValue(args, name) {
|
|
277
|
+
const out = [...args];
|
|
278
|
+
const i = out.indexOf(name);
|
|
279
|
+
if (i === -1)
|
|
280
|
+
return { args: out, value: undefined };
|
|
281
|
+
const value = out[i + 1];
|
|
282
|
+
out.splice(i, 2);
|
|
283
|
+
return { args: out, value };
|
|
284
|
+
}
|
|
285
|
+
// Generic flag harvester: walk the remaining args, pull every `--<flag> <value>`
|
|
286
|
+
// pair into an options bag (kebab-case → camelCase), parse CSVs to arrays and
|
|
287
|
+
// integer-looking values to numbers, and return the positional args left over.
|
|
288
|
+
// This is what lets adapter-specific filters like `--bg-ids 956,29294`,
|
|
289
|
+
// `--cities 北京,上海`, `--recruitment-id-list 201,202`, `--batch-id 100000560002`,
|
|
290
|
+
// `--recruit-type social` flow straight into the adapter's SearchOptions.
|
|
291
|
+
function kebabToCamel(s) {
|
|
292
|
+
return s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
293
|
+
}
|
|
294
|
+
function parseScalar(v) {
|
|
295
|
+
if (v === "true")
|
|
296
|
+
return true;
|
|
297
|
+
if (v === "false")
|
|
298
|
+
return false;
|
|
299
|
+
if (/^-?\d+$/.test(v))
|
|
300
|
+
return Number(v);
|
|
301
|
+
return v;
|
|
302
|
+
}
|
|
303
|
+
function parseValue(v) {
|
|
304
|
+
if (v.includes(","))
|
|
305
|
+
return v.split(",").map((p) => parseScalar(p.trim()));
|
|
306
|
+
return parseScalar(v);
|
|
307
|
+
}
|
|
308
|
+
// Adapter SearchOptions whose names look like plurals / id lists must always
|
|
309
|
+
// receive an array, so `--bg-ids 29294` (single value) becomes `[29294]`,
|
|
310
|
+
// not `29294`. Multi-value via CSV (`--bg-ids 29294,956`) already arrays.
|
|
311
|
+
function maybeArrayWrap(key, value) {
|
|
312
|
+
if (Array.isArray(value))
|
|
313
|
+
return value;
|
|
314
|
+
if (/(?:Ids|IdList|List|Codes|Categories|Regions|Cities|Departments)$/.test(key)) {
|
|
315
|
+
return [value];
|
|
316
|
+
}
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
function popAllOpts(args) {
|
|
320
|
+
const out = [];
|
|
321
|
+
const opts = {};
|
|
322
|
+
let i = 0;
|
|
323
|
+
while (i < args.length) {
|
|
324
|
+
const a = args[i];
|
|
325
|
+
if (a.startsWith("--") && a.length > 2) {
|
|
326
|
+
const key = kebabToCamel(a.slice(2));
|
|
327
|
+
const next = args[i + 1];
|
|
328
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
329
|
+
opts[key] = maybeArrayWrap(key, parseValue(next));
|
|
330
|
+
i += 2;
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
opts[key] = true;
|
|
334
|
+
i += 1;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
out.push(a);
|
|
339
|
+
i += 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { args: out, opts };
|
|
343
|
+
}
|
|
344
|
+
function emit(value, compact) {
|
|
345
|
+
if (compact) {
|
|
346
|
+
console.log(JSON.stringify(value));
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
console.log(JSON.stringify(value, null, 2));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function readResumeArg(arg) {
|
|
353
|
+
if (!arg)
|
|
354
|
+
die("expected resume text or '-' for stdin");
|
|
355
|
+
if (arg === "-") {
|
|
356
|
+
try {
|
|
357
|
+
return readFileSync(0, "utf8");
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
die("could not read resume text from stdin");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// if it looks like a file path that exists, read it; otherwise treat as
|
|
364
|
+
// the resume text itself
|
|
365
|
+
try {
|
|
366
|
+
return readFileSync(arg, "utf8");
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return arg;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Every company adapter exposes the same set of functions, so one dispatcher
|
|
373
|
+
// can route verbs against any of them. New companies plug in by adding an
|
|
374
|
+
// `import * as <name>` and a line in `ADAPTERS`. The `satisfies` clause
|
|
375
|
+
// makes any contract drift (missing verb, wrong signature) a compile error
|
|
376
|
+
// instead of a silent runtime hazard.
|
|
377
|
+
const ADAPTERS = {
|
|
378
|
+
tencent,
|
|
379
|
+
bytedance,
|
|
380
|
+
alibaba,
|
|
381
|
+
meituan,
|
|
382
|
+
xiaohongshu,
|
|
383
|
+
jd,
|
|
384
|
+
kuaishou,
|
|
385
|
+
xiaomi,
|
|
386
|
+
baidu,
|
|
387
|
+
netease,
|
|
388
|
+
didi,
|
|
389
|
+
bilibili,
|
|
390
|
+
pdd,
|
|
391
|
+
nio,
|
|
392
|
+
minimax,
|
|
393
|
+
huawei,
|
|
394
|
+
weibo,
|
|
395
|
+
mihoyo,
|
|
396
|
+
pingan,
|
|
397
|
+
sensetime,
|
|
398
|
+
trip,
|
|
399
|
+
unitree,
|
|
400
|
+
byd,
|
|
401
|
+
antgroup,
|
|
402
|
+
liauto,
|
|
403
|
+
moonshot,
|
|
404
|
+
zhipu,
|
|
405
|
+
hikvision,
|
|
406
|
+
iqiyi,
|
|
407
|
+
megvii,
|
|
408
|
+
lilith,
|
|
409
|
+
agibot,
|
|
410
|
+
deepseek,
|
|
411
|
+
zerooneai,
|
|
412
|
+
galaxyuniversal,
|
|
413
|
+
stepfun,
|
|
414
|
+
cicc,
|
|
415
|
+
baichuan,
|
|
416
|
+
xpeng,
|
|
417
|
+
weride,
|
|
418
|
+
hoyoverse,
|
|
419
|
+
iflytek,
|
|
420
|
+
oppo,
|
|
421
|
+
vivo,
|
|
422
|
+
sf,
|
|
423
|
+
cainiao,
|
|
424
|
+
geely,
|
|
425
|
+
webank,
|
|
426
|
+
horizonrobotics,
|
|
427
|
+
cambricon,
|
|
428
|
+
};
|
|
429
|
+
async function runCompany(adapter, company, rawArgs) {
|
|
430
|
+
const [verb, ...rest] = rawArgs;
|
|
431
|
+
if (!verb)
|
|
432
|
+
die(`expected a verb. Try \`job-pro help\`.`);
|
|
433
|
+
const { args, compact } = popCompactFlag(rest);
|
|
434
|
+
if (verb === "search") {
|
|
435
|
+
const { args: positional, opts } = popAllOpts(args);
|
|
436
|
+
const keyword = positional.join(" ").trim();
|
|
437
|
+
return emit(await adapter.searchPositions({
|
|
438
|
+
keyword,
|
|
439
|
+
...opts,
|
|
440
|
+
}), compact);
|
|
441
|
+
}
|
|
442
|
+
if (verb === "detail") {
|
|
443
|
+
const postId = args[0];
|
|
444
|
+
if (!postId)
|
|
445
|
+
die(`usage: job-pro ${company} detail <post_id>`);
|
|
446
|
+
return emit(await adapter.fetchPositionDetail(postId), compact);
|
|
447
|
+
}
|
|
448
|
+
if (verb === "all") {
|
|
449
|
+
const { args: positional, opts } = popAllOpts(args);
|
|
450
|
+
const keyword = positional.join(" ").trim();
|
|
451
|
+
return emit(await adapter.fetchAllPositions({
|
|
452
|
+
keyword,
|
|
453
|
+
...opts,
|
|
454
|
+
}), compact);
|
|
455
|
+
}
|
|
456
|
+
if (verb === "dicts") {
|
|
457
|
+
return emit(await adapter.fetchDictionaries(), compact);
|
|
458
|
+
}
|
|
459
|
+
if (verb === "notices") {
|
|
460
|
+
return emit(await adapter.listNotices(), compact);
|
|
461
|
+
}
|
|
462
|
+
if (verb === "notice") {
|
|
463
|
+
const id = args[0];
|
|
464
|
+
if (!id)
|
|
465
|
+
die(`usage: job-pro ${company} notice <id>`);
|
|
466
|
+
return emit(await adapter.getNotice(id), compact);
|
|
467
|
+
}
|
|
468
|
+
if (verb === "flow") {
|
|
469
|
+
const { args: a, value: questionTime } = popFlagValue(args, "--question-time");
|
|
470
|
+
const { args: a2, value: topK } = popFlagValue(a, "--top-k");
|
|
471
|
+
const question = a2.join(" ").trim();
|
|
472
|
+
if (!question)
|
|
473
|
+
die(`usage: job-pro ${company} flow <question> [--question-time YYYY-MM-DD] [--top-k N]`);
|
|
474
|
+
return emit(await adapter.findNoticesByQuestion(question, {
|
|
475
|
+
questionTime,
|
|
476
|
+
topK: topK ? Number(topK) : undefined,
|
|
477
|
+
}), compact);
|
|
478
|
+
}
|
|
479
|
+
if (verb === "match") {
|
|
480
|
+
const { args: a, value: topN } = popFlagValue(args, "--top-n");
|
|
481
|
+
const { args: a2, value: candidates } = popFlagValue(a, "--candidates");
|
|
482
|
+
const text = readResumeArg(a2[0]);
|
|
483
|
+
return emit(await adapter.matchResume(text, {
|
|
484
|
+
topN: topN ? Number(topN) : undefined,
|
|
485
|
+
candidates: candidates ? Number(candidates) : undefined,
|
|
486
|
+
}), compact);
|
|
487
|
+
}
|
|
488
|
+
if (verb === "resume-check") {
|
|
489
|
+
const text = readResumeArg(args[0]);
|
|
490
|
+
return emit(adapter.checkResume(text), compact);
|
|
491
|
+
}
|
|
492
|
+
if (verb === "apply") {
|
|
493
|
+
const reallySubmit = args.includes("--really-submit");
|
|
494
|
+
const viaCdp = args.includes("--via-cdp");
|
|
495
|
+
const printForm = args.includes("--print-form");
|
|
496
|
+
const schemaOnly = args.includes("--schema");
|
|
497
|
+
const interactive = args.includes("--interactive");
|
|
498
|
+
const remember = args.includes("--remember");
|
|
499
|
+
let { args: aDebug, value: debugUrl } = popFlagValue(args, "--debug-submit-to");
|
|
500
|
+
// Shorthand: `--debug-submit` without URL → default httpbin echo.
|
|
501
|
+
if (!debugUrl && aDebug.includes("--debug-submit")) {
|
|
502
|
+
aDebug = aDebug.filter((a) => a !== "--debug-submit");
|
|
503
|
+
debugUrl = "https://httpbin.org/post";
|
|
504
|
+
}
|
|
505
|
+
const { args: aForm, value: formFilePath } = popFlagValue(aDebug, "--form-file");
|
|
506
|
+
const { args: aBatch, value: batchPath } = popFlagValue(aForm, "--batch");
|
|
507
|
+
// Batch mode: read post_ids from a file (or stdin if "-"). Each non-empty,
|
|
508
|
+
// non-`#`-prefixed line is a post_id. Output is a JSON array of
|
|
509
|
+
// { post_id, result } so downstream tooling can iterate.
|
|
510
|
+
if (batchPath) {
|
|
511
|
+
if (reallySubmit) {
|
|
512
|
+
die(`--batch + --really-submit is intentionally refused. Submitting to ` +
|
|
513
|
+
`multiple jobs at once is the exact failure mode this CLI is designed to ` +
|
|
514
|
+
`prevent. Drop --really-submit and use --debug-submit-to <url> for batch ` +
|
|
515
|
+
`verification, or run apply one job at a time.`);
|
|
516
|
+
}
|
|
517
|
+
let rawLines;
|
|
518
|
+
try {
|
|
519
|
+
rawLines = batchPath === "-" ? readFileSync(0, "utf8") : readFileSync(batchPath, "utf8");
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
die(`could not read batch file ${batchPath}: ${err instanceof Error ? err.message : err}`);
|
|
523
|
+
}
|
|
524
|
+
const postIds = rawLines
|
|
525
|
+
.split("\n")
|
|
526
|
+
.map((l) => l.trim())
|
|
527
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
528
|
+
if (postIds.length === 0)
|
|
529
|
+
die(`batch file ${batchPath} contains no post_ids`);
|
|
530
|
+
// We need the schema fetcher / profile / session ONCE, not per-job.
|
|
531
|
+
const fetchSchema = adapter.fetchApplicationSchema;
|
|
532
|
+
if (typeof fetchSchema !== "function") {
|
|
533
|
+
return emit({ ok: false, source: company, message: `apply: not wired for "${company}"` }, compact);
|
|
534
|
+
}
|
|
535
|
+
const prof = loadProfile();
|
|
536
|
+
if (!prof.ok)
|
|
537
|
+
die(prof.message);
|
|
538
|
+
let effectiveProfile = prof.profile;
|
|
539
|
+
if (formFilePath) {
|
|
540
|
+
const merged = applyFormFile(effectiveProfile, formFilePath);
|
|
541
|
+
if (!merged.ok)
|
|
542
|
+
die(merged.message);
|
|
543
|
+
effectiveProfile = merged.profile;
|
|
544
|
+
}
|
|
545
|
+
const session = loadSession(company);
|
|
546
|
+
const out = [];
|
|
547
|
+
// Progress to stderr (so stdout JSON stays clean for pipes). Only when
|
|
548
|
+
// not --compact AND not piping stdout (interactive TTY).
|
|
549
|
+
const showProgress = !compact && process.stderr.isTTY && postIds.length > 1;
|
|
550
|
+
let progressIdx = 0;
|
|
551
|
+
for (const id of postIds) {
|
|
552
|
+
progressIdx++;
|
|
553
|
+
if (showProgress) {
|
|
554
|
+
process.stderr.write(`\r[${progressIdx}/${postIds.length}] ${id.padEnd(28)}\x1b[K`);
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
const schemaResult = (await fetchSchema.call(adapter, id));
|
|
558
|
+
if (!schemaResult.ok || !schemaResult.schema) {
|
|
559
|
+
out.push({ post_id: id, ok: false, message: schemaResult.message ?? "schema fetch failed" });
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const staged = stageApplication(schemaResult.schema, effectiveProfile);
|
|
563
|
+
if (debugUrl) {
|
|
564
|
+
const kind = schemaResult.schema.submit_kind ?? "multipart-anon";
|
|
565
|
+
const debugExecutor = kind === "feishu-3-step" ? executeFeishu3Step :
|
|
566
|
+
kind === "moka-aes" ? executeMokaApply :
|
|
567
|
+
kind === "beisen-wecruit" ? executeBeisenWecruit :
|
|
568
|
+
kind === "beisen-italent" ? executeBeisenITalent :
|
|
569
|
+
kind === "cdp-real-browser" ? executeCdpRealBrowser :
|
|
570
|
+
null;
|
|
571
|
+
const result = debugExecutor
|
|
572
|
+
? await debugExecutor(staged, session, { kind: "debug", url: debugUrl })
|
|
573
|
+
: await submitApplication(staged, { kind: "debug", url: debugUrl });
|
|
574
|
+
out.push({ post_id: id, ok: result.ok, ready: staged.ready, submit_kind: kind, debug_result: result });
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
out.push({
|
|
578
|
+
post_id: id,
|
|
579
|
+
ok: staged.ready,
|
|
580
|
+
ready: staged.ready,
|
|
581
|
+
submit_kind: schemaResult.schema.submit_kind,
|
|
582
|
+
message: staged.ready ? "staged ok" : `${staged.unanswered_required.length} required field(s) unfilled`,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
out.push({ post_id: id, ok: false, message: err instanceof Error ? err.message : String(err) });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (showProgress)
|
|
591
|
+
process.stderr.write(`\r\x1b[K`);
|
|
592
|
+
const okCount = out.filter((r) => r.ok).length;
|
|
593
|
+
return emit({ mode: debugUrl ? "batch-debug" : "batch-dry-run", company, total: out.length, ok_count: okCount, results: out }, compact);
|
|
594
|
+
}
|
|
595
|
+
void aBatch;
|
|
596
|
+
const postId = args[0];
|
|
597
|
+
if (!postId)
|
|
598
|
+
die(`usage: job-pro ${company} apply <post_id> [--schema | --print-form | --form-file <path> | --interactive [--remember] | --batch <file>] [--debug-submit-to <url> | --really-submit]`);
|
|
599
|
+
const fetchSchema = adapter.fetchApplicationSchema;
|
|
600
|
+
if (typeof fetchSchema !== "function") {
|
|
601
|
+
return emit({
|
|
602
|
+
ok: false,
|
|
603
|
+
source: company,
|
|
604
|
+
post_id: postId,
|
|
605
|
+
message: `apply: Phase 2 not yet wired for "${company}". Only Greenhouse + Lever ` +
|
|
606
|
+
`boards (xpeng / hoyoverse / weride) expose an application schema today. ` +
|
|
607
|
+
`See docs/auto-apply.md for the rollout plan.`,
|
|
608
|
+
}, compact);
|
|
609
|
+
}
|
|
610
|
+
// Note: we DON'T early-return on reallySubmit here — we fall through
|
|
611
|
+
// to stage the application first, then re-gate before actually posting.
|
|
612
|
+
// This lets the user verify the staged payload one last time even
|
|
613
|
+
// when they pass --really-submit by accident.
|
|
614
|
+
const schemaResult = await fetchSchema.call(adapter, postId);
|
|
615
|
+
const sr = schemaResult;
|
|
616
|
+
if (!sr.ok || !sr.schema) {
|
|
617
|
+
return emit({ ok: false, source: company, post_id: postId, message: sr.message ?? "unknown error" }, compact);
|
|
618
|
+
}
|
|
619
|
+
// --schema short-circuits everything (and crucially doesn't need a
|
|
620
|
+
// profile). Useful for recon: "what fields does this job ask?".
|
|
621
|
+
if (schemaOnly) {
|
|
622
|
+
return emit({ ok: true, source: company, post_id: postId, schema: sr.schema }, compact);
|
|
623
|
+
}
|
|
624
|
+
const prof = loadProfile();
|
|
625
|
+
if (!prof.ok) {
|
|
626
|
+
return emit({
|
|
627
|
+
ok: false,
|
|
628
|
+
source: company,
|
|
629
|
+
post_id: postId,
|
|
630
|
+
schema: sr.schema,
|
|
631
|
+
message: prof.message,
|
|
632
|
+
hint: `run \`job-pro profile init\` to create a template.`,
|
|
633
|
+
}, compact);
|
|
634
|
+
}
|
|
635
|
+
// --print-form short-circuits everything else: emit a fillable
|
|
636
|
+
// template specific to this job's schema and exit.
|
|
637
|
+
if (printForm) {
|
|
638
|
+
const template = buildFormTemplate(sr.schema, prof.profile);
|
|
639
|
+
return emit(template, compact);
|
|
640
|
+
}
|
|
641
|
+
// --form-file merges per-job overrides into profile.custom.
|
|
642
|
+
let effectiveProfile = prof.profile;
|
|
643
|
+
if (formFilePath) {
|
|
644
|
+
const merged = applyFormFile(effectiveProfile, formFilePath);
|
|
645
|
+
if (!merged.ok) {
|
|
646
|
+
return emit({
|
|
647
|
+
ok: false,
|
|
648
|
+
source: company,
|
|
649
|
+
post_id: postId,
|
|
650
|
+
message: merged.message,
|
|
651
|
+
}, compact);
|
|
652
|
+
}
|
|
653
|
+
effectiveProfile = merged.profile;
|
|
654
|
+
// --remember + --form-file: persist the merged answers back to profile.
|
|
655
|
+
if (remember && !compact) {
|
|
656
|
+
const before = JSON.stringify(prof.profile.custom ?? {});
|
|
657
|
+
const after = JSON.stringify(effectiveProfile.custom ?? {});
|
|
658
|
+
if (before !== after) {
|
|
659
|
+
const saved = saveProfile(effectiveProfile);
|
|
660
|
+
if (saved.ok) {
|
|
661
|
+
console.log(`Saved form-file answers to ${saved.path} (custom.*).`);
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
console.error(`--remember failed: ${saved.message}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// --interactive: prompt stdin for each unanswered required field.
|
|
670
|
+
// Skipped in --compact mode (we'd be polluting JSON output with prompts).
|
|
671
|
+
if (interactive && !compact) {
|
|
672
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
673
|
+
const io = {
|
|
674
|
+
write: (s) => process.stdout.write(s),
|
|
675
|
+
read: () => new Promise((resolve) => {
|
|
676
|
+
rl.once("close", () => resolve(null));
|
|
677
|
+
rl.question("", (a) => resolve(a));
|
|
678
|
+
}),
|
|
679
|
+
};
|
|
680
|
+
console.log(`\nInteractive mode — fill the required fields for "${sr.schema.job_title || postId}".`);
|
|
681
|
+
console.log(`Type \`q\` or Ctrl-D to abort. Hit Enter to skip an optional field.`);
|
|
682
|
+
const overrides = await promptUnansweredFields(sr.schema, effectiveProfile, io);
|
|
683
|
+
rl.close();
|
|
684
|
+
// Merge into effectiveProfile.custom for the rest of the flow.
|
|
685
|
+
effectiveProfile = {
|
|
686
|
+
...effectiveProfile,
|
|
687
|
+
custom: { ...(effectiveProfile.custom ?? {}), ...overrides },
|
|
688
|
+
};
|
|
689
|
+
const collectedCount = Object.keys(overrides).length;
|
|
690
|
+
console.log(`\nCollected ${collectedCount} answer(s). Staging now…\n`);
|
|
691
|
+
if (remember && collectedCount > 0) {
|
|
692
|
+
const saved = saveProfile(effectiveProfile);
|
|
693
|
+
if (saved.ok) {
|
|
694
|
+
console.log(`Saved ${collectedCount} answer(s) to ${saved.path} (custom.*).\n`);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
console.error(`--remember failed: ${saved.message}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const staged = stageApplication(sr.schema, effectiveProfile);
|
|
702
|
+
const session = loadSession(company);
|
|
703
|
+
// Mode selection: --debug-submit-to <url> overrides everything.
|
|
704
|
+
if (debugUrl) {
|
|
705
|
+
// Route through the family-specific executor where appropriate so the
|
|
706
|
+
// user can verify each step's wire format against their echo server.
|
|
707
|
+
// --via-cdp forces the puppeteer DOM path even in debug mode (CDP's
|
|
708
|
+
// debug mode just navigates the apply_url and pauses for 3s without
|
|
709
|
+
// submitting — useful to verify the SPA loads correctly).
|
|
710
|
+
const kindForDebug = sr.schema.submit_kind ?? "multipart-anon";
|
|
711
|
+
const debugExecutor = viaCdp ? executeCdpRealBrowser :
|
|
712
|
+
kindForDebug === "feishu-3-step" ? executeFeishu3Step :
|
|
713
|
+
kindForDebug === "moka-aes" ? executeMokaApply :
|
|
714
|
+
kindForDebug === "beisen-wecruit" ? executeBeisenWecruit :
|
|
715
|
+
kindForDebug === "beisen-italent" ? executeBeisenITalent :
|
|
716
|
+
kindForDebug === "cdp-real-browser" ? executeCdpRealBrowser :
|
|
717
|
+
null;
|
|
718
|
+
if (debugExecutor) {
|
|
719
|
+
const result = await debugExecutor(staged, session, { kind: "debug", url: debugUrl });
|
|
720
|
+
return emit({ mode: "debug-submit", staged, submit_kind: kindForDebug, via_cdp: viaCdp, result }, compact);
|
|
721
|
+
}
|
|
722
|
+
const result = await submitApplication(staged, { kind: "debug", url: debugUrl });
|
|
723
|
+
return emit({ mode: "debug-submit", staged, submit_kind: kindForDebug, result }, compact);
|
|
724
|
+
}
|
|
725
|
+
// Session staleness gate (applies to any --really-submit that uses a
|
|
726
|
+
// captured session). Sessions for non-anon adapters generally expire
|
|
727
|
+
// around the 30-day mark; firing a stale cookie just nets a 401 with
|
|
728
|
+
// no diagnostic. Catch it before the submit fires.
|
|
729
|
+
const allowStaleSession = args.includes("--allow-stale-session");
|
|
730
|
+
const maxAgeDays = Number(process.env.JOB_PRO_SESSION_MAX_AGE_DAYS ?? 30);
|
|
731
|
+
function sessionAgeDays(s) {
|
|
732
|
+
if (!s?.exported_at)
|
|
733
|
+
return null;
|
|
734
|
+
const ts = Date.parse(s.exported_at);
|
|
735
|
+
if (!Number.isFinite(ts))
|
|
736
|
+
return null;
|
|
737
|
+
return Math.floor((Date.now() - ts) / 86_400_000);
|
|
738
|
+
}
|
|
739
|
+
// --really-submit: actually hit the upstream endpoint. Guarded by both
|
|
740
|
+
// an env-var attestation and (for non-anon adapters) a session.json.
|
|
741
|
+
if (reallySubmit) {
|
|
742
|
+
const understood = process.env.JOB_PRO_I_UNDERSTAND_REAL_SUBMIT === "yes";
|
|
743
|
+
if (!understood) {
|
|
744
|
+
return emit({
|
|
745
|
+
ok: false,
|
|
746
|
+
source: company,
|
|
747
|
+
post_id: postId,
|
|
748
|
+
mode: "really-submit-blocked",
|
|
749
|
+
staged,
|
|
750
|
+
message: `--really-submit is gated by an env-var attestation. To unlock, set ` +
|
|
751
|
+
`JOB_PRO_I_UNDERSTAND_REAL_SUBMIT=yes in your shell. This submission will ` +
|
|
752
|
+
`POST a real application to ${staged.submit_endpoint}; doing so without a ` +
|
|
753
|
+
`valid resume / answers is spam against the company's recruiters.`,
|
|
754
|
+
}, compact);
|
|
755
|
+
}
|
|
756
|
+
if (!staged.ready) {
|
|
757
|
+
return emit({
|
|
758
|
+
ok: false,
|
|
759
|
+
source: company,
|
|
760
|
+
post_id: postId,
|
|
761
|
+
mode: "really-submit-blocked",
|
|
762
|
+
staged,
|
|
763
|
+
message: `${staged.unanswered_required.length} required field(s) still unanswered; refusing to submit incomplete application`,
|
|
764
|
+
}, compact);
|
|
765
|
+
}
|
|
766
|
+
// Speculative-endpoint gate (4th safety layer). 19 of 22 bespoke
|
|
767
|
+
// multipart-session endpoints returned 404 on no-auth probe — the
|
|
768
|
+
// inferred URLs are wrong guesses. Refusing by default prevents
|
|
769
|
+
// accidental fires against broken endpoints; users who *want* to
|
|
770
|
+
// shake out what the real endpoint should be opt in via env.
|
|
771
|
+
const allowSpeculative = process.env.JOB_PRO_ALLOW_SPECULATIVE_ENDPOINT === "yes";
|
|
772
|
+
if (staged.submit_kind !== "external" && staged.submit_kind !== "multipart-anon" && staged.endpoint_verified !== true && !allowSpeculative) {
|
|
773
|
+
return emit({
|
|
774
|
+
ok: false,
|
|
775
|
+
source: company,
|
|
776
|
+
post_id: postId,
|
|
777
|
+
mode: "really-submit-blocked",
|
|
778
|
+
staged,
|
|
779
|
+
message: `submit_endpoint for ${company} is speculative — inferred from JS-bundle recon, ` +
|
|
780
|
+
`not end-to-end verified. Most such endpoints (19 of 22 probed) are wrong and ` +
|
|
781
|
+
`would 4xx. Verify with \`apply ${postId} --debug-submit-to <your-echo-url>\` first, ` +
|
|
782
|
+
`or set \`JOB_PRO_ALLOW_SPECULATIVE_ENDPOINT=yes\` if you're knowingly probing.`,
|
|
783
|
+
}, compact);
|
|
784
|
+
}
|
|
785
|
+
// Submission flow selection by submit_kind. Only the generic
|
|
786
|
+
// multipart families are wired to actually fire today; everything
|
|
787
|
+
// else gets a useful refusal message.
|
|
788
|
+
const kind = (sr.schema.submit_kind ?? "multipart-anon");
|
|
789
|
+
const isAnonMultipart = kind === "multipart-anon";
|
|
790
|
+
const isSessionMultipart = kind === "multipart-session";
|
|
791
|
+
const isGenericMultipart = isAnonMultipart || isSessionMultipart;
|
|
792
|
+
if (kind === "external") {
|
|
793
|
+
return emit({
|
|
794
|
+
ok: false,
|
|
795
|
+
source: company,
|
|
796
|
+
post_id: postId,
|
|
797
|
+
mode: "really-submit-external",
|
|
798
|
+
staged,
|
|
799
|
+
submit_kind: kind,
|
|
800
|
+
apply_url: staged.apply_url,
|
|
801
|
+
message: `${company} has no programmatic submit API — recruiting is mediated ` +
|
|
802
|
+
`via WeChat mini-program / Liepin recruiter chat / other IM channel. ` +
|
|
803
|
+
`Open apply_url in your browser to start the actual application flow.`,
|
|
804
|
+
}, compact);
|
|
805
|
+
}
|
|
806
|
+
// Family executors: each takes (staged, session, target) and returns
|
|
807
|
+
// a MultiStepResult. All gate on session.json existing.
|
|
808
|
+
// --via-cdp forces the puppeteer DOM-driven path for any adapter,
|
|
809
|
+
// bypassing the API endpoint and any body-shape uncertainty. The
|
|
810
|
+
// CDP executor walks the SPA's apply form like a human: click
|
|
811
|
+
// "投递", fill name/email/phone, upload resume, click submit.
|
|
812
|
+
// Slower + needs Chrome, but reliable when API is uncertain.
|
|
813
|
+
const familyExecutor = viaCdp ? executeCdpRealBrowser :
|
|
814
|
+
kind === "feishu-3-step" ? executeFeishu3Step :
|
|
815
|
+
kind === "moka-aes" ? executeMokaApply :
|
|
816
|
+
kind === "beisen-wecruit" ? executeBeisenWecruit :
|
|
817
|
+
kind === "beisen-italent" ? executeBeisenITalent :
|
|
818
|
+
kind === "cdp-real-browser" ? executeCdpRealBrowser :
|
|
819
|
+
null;
|
|
820
|
+
if (familyExecutor) {
|
|
821
|
+
// --via-cdp on multipart-anon (xpeng/weride/hoyoverse) doesn't
|
|
822
|
+
// need a session — Greenhouse/Lever forms accept anon submits.
|
|
823
|
+
// The CDP executor's own check is relaxed for multipart-anon.
|
|
824
|
+
const sessionRequired = !(viaCdp && kind === "multipart-anon");
|
|
825
|
+
if (sessionRequired && !session) {
|
|
826
|
+
return emit({
|
|
827
|
+
ok: false,
|
|
828
|
+
source: company,
|
|
829
|
+
post_id: postId,
|
|
830
|
+
mode: "really-submit-blocked",
|
|
831
|
+
staged,
|
|
832
|
+
submit_kind: kind,
|
|
833
|
+
message: `${kind} submission requires a captured session at ` +
|
|
834
|
+
`~/.jobpro/${company}.session.json. Run \`job-pro extension\` for ` +
|
|
835
|
+
`the bundled MV3 path + Chrome install walkthrough.`,
|
|
836
|
+
}, compact);
|
|
837
|
+
}
|
|
838
|
+
const age = sessionAgeDays(session);
|
|
839
|
+
if (age !== null && age > maxAgeDays && !allowStaleSession) {
|
|
840
|
+
return emit({
|
|
841
|
+
ok: false,
|
|
842
|
+
source: company,
|
|
843
|
+
post_id: postId,
|
|
844
|
+
mode: "really-submit-blocked",
|
|
845
|
+
staged,
|
|
846
|
+
submit_kind: kind,
|
|
847
|
+
session_age_days: age,
|
|
848
|
+
message: `session at ~/.jobpro/${company}.session.json is ${age} days old (limit ${maxAgeDays}); ` +
|
|
849
|
+
`careers-site sessions usually expire around 30d and a stale cookie would yield ` +
|
|
850
|
+
`an inscrutable 401. Re-capture via the extension, or pass --allow-stale-session ` +
|
|
851
|
+
`(also: JOB_PRO_SESSION_MAX_AGE_DAYS env).`,
|
|
852
|
+
}, compact);
|
|
853
|
+
}
|
|
854
|
+
const result = await familyExecutor(staged, session, { kind: "upstream" });
|
|
855
|
+
if (result.ok) {
|
|
856
|
+
memoryEvent("applied", `${company} ${postId} — ${staged.job_title}`);
|
|
857
|
+
}
|
|
858
|
+
return emit({ mode: "really-submit", staged, submit_kind: kind, session_used: true, result }, compact);
|
|
859
|
+
}
|
|
860
|
+
if (!isGenericMultipart) {
|
|
861
|
+
// Reachable only if a schema returns a SubmitKind that isn't in the
|
|
862
|
+
// current taxonomy (multipart-anon/session/feishu-3-step/moka-aes/
|
|
863
|
+
// beisen-wecruit/beisen-italent/cdp-real-browser/external). The
|
|
864
|
+
// SubmitKind type permits `(string & {})` extensibility, so a future
|
|
865
|
+
// contributor adding a new family without wiring it would land here.
|
|
866
|
+
return emit({
|
|
867
|
+
ok: false,
|
|
868
|
+
source: company,
|
|
869
|
+
post_id: postId,
|
|
870
|
+
mode: "really-submit-blocked",
|
|
871
|
+
staged,
|
|
872
|
+
submit_kind: kind,
|
|
873
|
+
submit_notes: sr.schema.submit_notes,
|
|
874
|
+
message: `submit_kind="${kind}" is unknown — no wired executor. The 7 ` +
|
|
875
|
+
`current families are: multipart-anon, multipart-session, ` +
|
|
876
|
+
`feishu-3-step, moka-aes, beisen-wecruit, beisen-italent, ` +
|
|
877
|
+
`cdp-real-browser. To add a new family, wire an executor in ` +
|
|
878
|
+
`cli/src/apply.ts. Use --debug-submit-to <url> in the meantime ` +
|
|
879
|
+
`to verify the wire format you'd want.`,
|
|
880
|
+
}, compact);
|
|
881
|
+
}
|
|
882
|
+
// Non-anon multipart families need session.json.
|
|
883
|
+
if (!isAnonMultipart && !session) {
|
|
884
|
+
return emit({
|
|
885
|
+
ok: false,
|
|
886
|
+
source: company,
|
|
887
|
+
post_id: postId,
|
|
888
|
+
mode: "really-submit-blocked",
|
|
889
|
+
staged,
|
|
890
|
+
message: `no captured session at ~/.jobpro/${company}.session.json. Run ` +
|
|
891
|
+
`\`job-pro extension\` for the bundled MV3 path + Chrome install ` +
|
|
892
|
+
`walkthrough; log into the careers site, click Export, then ` +
|
|
893
|
+
`mv ~/Downloads/jobpro/${company}.session.json ~/.jobpro/`,
|
|
894
|
+
}, compact);
|
|
895
|
+
}
|
|
896
|
+
if (!isAnonMultipart) {
|
|
897
|
+
const age = sessionAgeDays(session);
|
|
898
|
+
if (age !== null && age > maxAgeDays && !allowStaleSession) {
|
|
899
|
+
return emit({
|
|
900
|
+
ok: false,
|
|
901
|
+
source: company,
|
|
902
|
+
post_id: postId,
|
|
903
|
+
mode: "really-submit-blocked",
|
|
904
|
+
staged,
|
|
905
|
+
submit_kind: kind,
|
|
906
|
+
session_age_days: age,
|
|
907
|
+
message: `session at ~/.jobpro/${company}.session.json is ${age} days old (limit ${maxAgeDays}); ` +
|
|
908
|
+
`re-capture via the extension or pass --allow-stale-session.`,
|
|
909
|
+
}, compact);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const result = await submitApplication(staged, { kind: "upstream" }, { session });
|
|
913
|
+
if (result.ok) {
|
|
914
|
+
memoryEvent("applied", `${company} ${postId} — ${staged.job_title}`);
|
|
915
|
+
}
|
|
916
|
+
return emit({ mode: "really-submit", staged, submit_kind: kind, session_used: !!session, result }, compact);
|
|
917
|
+
}
|
|
918
|
+
// Default: dry-run print, no network.
|
|
919
|
+
if (compact) {
|
|
920
|
+
return emit({ mode: "dry-run", staged, has_session: !!session }, compact);
|
|
921
|
+
}
|
|
922
|
+
console.log(formatStaged(staged));
|
|
923
|
+
if (session) {
|
|
924
|
+
console.log(`\nSession captured (~/.jobpro/${company}.session.json): ${session.cookies.length} cookies + ${Object.keys(session.headers).length} auth headers.`);
|
|
925
|
+
}
|
|
926
|
+
if (!staged.ready) {
|
|
927
|
+
console.log(`\nFill the unanswered required fields. Easiest path:\n` +
|
|
928
|
+
` 1. job-pro ${company} apply ${postId} --print-form > form.json\n` +
|
|
929
|
+
` 2. Edit form.json — set each \`value\` for required fields.\n` +
|
|
930
|
+
` 3. job-pro ${company} apply ${postId} --form-file form.json\n` +
|
|
931
|
+
`Or paste the following into ${profileTemplate().path} under \`custom\`:`);
|
|
932
|
+
// Emit a copy-pasteable JSON snippet listing each unanswered required.
|
|
933
|
+
const snippet = {};
|
|
934
|
+
for (const f of staged.unanswered_required)
|
|
935
|
+
snippet[f.name] = "";
|
|
936
|
+
console.log(JSON.stringify({ custom: snippet }, null, 2));
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
const isAnon = staged.source.startsWith("boards-api.greenhouse.io/") ||
|
|
940
|
+
staged.source.startsWith("api.lever.co/");
|
|
941
|
+
console.log(`\nDry-run complete. To actually submit:\n` +
|
|
942
|
+
` • --debug-submit-to https://httpbin.org/post — verify wire format\n` +
|
|
943
|
+
` • JOB_PRO_I_UNDERSTAND_REAL_SUBMIT=yes job-pro ${company} apply ${postId} --really-submit\n` +
|
|
944
|
+
(isAnon
|
|
945
|
+
? ` ${company} is Greenhouse/Lever (anonymous submission, no session needed).\n`
|
|
946
|
+
: ` ${company} needs ~/.jobpro/${company}.session.json — capture via the browser extension.\n`));
|
|
947
|
+
}
|
|
948
|
+
void aDebug; // silence "unused" — `args` flow goes through popFlagValue
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (verb === "memory") {
|
|
952
|
+
const [sub, ...subArgs] = args;
|
|
953
|
+
if (!sub)
|
|
954
|
+
die(`usage: job-pro ${company} memory <list|get|set|event|clear>`);
|
|
955
|
+
if (sub === "list")
|
|
956
|
+
return emit(memoryList(), compact);
|
|
957
|
+
if (sub === "get") {
|
|
958
|
+
const key = subArgs[0];
|
|
959
|
+
if (!key)
|
|
960
|
+
die(`usage: job-pro ${company} memory get <key>`);
|
|
961
|
+
return emit(memoryGet(key), compact);
|
|
962
|
+
}
|
|
963
|
+
if (sub === "set") {
|
|
964
|
+
return emit(memorySet(subArgs), compact);
|
|
965
|
+
}
|
|
966
|
+
if (sub === "event") {
|
|
967
|
+
const [kind, ...payload] = subArgs;
|
|
968
|
+
return emit(memoryEvent(kind, payload.join(" ")), compact);
|
|
969
|
+
}
|
|
970
|
+
if (sub === "clear")
|
|
971
|
+
return emit(memoryClear(), compact);
|
|
972
|
+
die(`unknown memory subcommand: ${sub}`);
|
|
973
|
+
}
|
|
974
|
+
die(`unknown verb: ${verb}. Try \`job-pro help\`.`);
|
|
975
|
+
}
|
|
976
|
+
function buildStatusReport() {
|
|
977
|
+
const homeDir = process.env.JOBPRO_HOME ?? join(homedir(), ".jobpro");
|
|
978
|
+
const profilePath = process.env.JOB_PRO_PROFILE_PATH ?? join(homeDir, "profile.json");
|
|
979
|
+
const sessionDir = process.env.JOB_PRO_SESSION_DIR ?? homeDir;
|
|
980
|
+
// Profile state.
|
|
981
|
+
const filled = [];
|
|
982
|
+
const missing = [];
|
|
983
|
+
let customKeys = 0;
|
|
984
|
+
let profileExists = false;
|
|
985
|
+
if (existsSync(profilePath)) {
|
|
986
|
+
profileExists = true;
|
|
987
|
+
try {
|
|
988
|
+
const p = JSON.parse(readFileSync(profilePath, "utf8"));
|
|
989
|
+
for (const key of ["first_name", "last_name", "email", "phone", "resume_path"]) {
|
|
990
|
+
const v = p[key];
|
|
991
|
+
if (typeof v === "string" && v.length > 0)
|
|
992
|
+
filled.push(key);
|
|
993
|
+
else
|
|
994
|
+
missing.push(key);
|
|
995
|
+
}
|
|
996
|
+
customKeys = p.custom && typeof p.custom === "object" ? Object.keys(p.custom).length : 0;
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
missing.push("(profile JSON is malformed)");
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
missing.push("first_name", "last_name", "email", "phone", "resume_path");
|
|
1004
|
+
}
|
|
1005
|
+
// Captured sessions in ~/.jobpro/*.session.json
|
|
1006
|
+
const sessions = [];
|
|
1007
|
+
if (existsSync(sessionDir)) {
|
|
1008
|
+
try {
|
|
1009
|
+
for (const f of readdirSync(sessionDir)) {
|
|
1010
|
+
if (!f.endsWith(".session.json"))
|
|
1011
|
+
continue;
|
|
1012
|
+
const adapter = f.slice(0, -".session.json".length);
|
|
1013
|
+
const full = join(sessionDir, f);
|
|
1014
|
+
const stat = statSync(full);
|
|
1015
|
+
const age = (Date.now() - stat.mtimeMs) / (24 * 3600 * 1000);
|
|
1016
|
+
let host;
|
|
1017
|
+
let cookieCount = 0;
|
|
1018
|
+
let headerCount = 0;
|
|
1019
|
+
let capturedAt;
|
|
1020
|
+
try {
|
|
1021
|
+
const j = JSON.parse(readFileSync(full, "utf8"));
|
|
1022
|
+
host = j.host;
|
|
1023
|
+
cookieCount = Array.isArray(j.cookies) ? j.cookies.length : 0;
|
|
1024
|
+
headerCount = j.headers ? Object.keys(j.headers).length : 0;
|
|
1025
|
+
capturedAt = j.exported_at;
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
/* malformed — still surface the file */
|
|
1029
|
+
}
|
|
1030
|
+
sessions.push({
|
|
1031
|
+
adapter,
|
|
1032
|
+
path: full,
|
|
1033
|
+
host,
|
|
1034
|
+
captured_at: capturedAt,
|
|
1035
|
+
age_days: Math.round(age * 10) / 10,
|
|
1036
|
+
cookies: cookieCount,
|
|
1037
|
+
headers: headerCount,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
/* ignore */
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// Memory snapshot.
|
|
1046
|
+
const memSummary = {
|
|
1047
|
+
field_keys: [],
|
|
1048
|
+
recent_events: [],
|
|
1049
|
+
total_events: 0,
|
|
1050
|
+
};
|
|
1051
|
+
try {
|
|
1052
|
+
const memList = memoryList();
|
|
1053
|
+
if (memList?.path)
|
|
1054
|
+
memSummary.path = memList.path;
|
|
1055
|
+
if (memList?.fields)
|
|
1056
|
+
memSummary.field_keys = Object.keys(memList.fields);
|
|
1057
|
+
if (Array.isArray(memList?.events)) {
|
|
1058
|
+
memSummary.total_events = memList.events.length;
|
|
1059
|
+
memSummary.recent_events = memList.events.slice(-5).reverse();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
catch {
|
|
1063
|
+
/* ignore */
|
|
1064
|
+
}
|
|
1065
|
+
// Chrome / puppeteer-core availability.
|
|
1066
|
+
const CHROME_CANDIDATES = [
|
|
1067
|
+
process.env.JOB_PRO_CHROME,
|
|
1068
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
1069
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
1070
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
1071
|
+
"/usr/bin/google-chrome",
|
|
1072
|
+
"/usr/bin/google-chrome-stable",
|
|
1073
|
+
"/usr/bin/chromium",
|
|
1074
|
+
"/usr/bin/chromium-browser",
|
|
1075
|
+
].filter((p) => typeof p === "string" && p.length > 0);
|
|
1076
|
+
const chromePath = CHROME_CANDIDATES.find((p) => existsSync(p));
|
|
1077
|
+
// puppeteer-core is a runtime dep, but a user could have done --omit=optional
|
|
1078
|
+
// or be running from a fresh checkout. Probe via createRequire because
|
|
1079
|
+
// we're an ESM module without a CJS `require`.
|
|
1080
|
+
let hasPuppeteer = false;
|
|
1081
|
+
try {
|
|
1082
|
+
const { createRequire } = require_module();
|
|
1083
|
+
const req = createRequire(import.meta.url);
|
|
1084
|
+
req.resolve("puppeteer-core");
|
|
1085
|
+
hasPuppeteer = true;
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
hasPuppeteer = false;
|
|
1089
|
+
}
|
|
1090
|
+
return {
|
|
1091
|
+
profile: {
|
|
1092
|
+
path: profilePath,
|
|
1093
|
+
exists: profileExists,
|
|
1094
|
+
filled_standard: filled,
|
|
1095
|
+
missing_standard: missing,
|
|
1096
|
+
custom_keys: customKeys,
|
|
1097
|
+
},
|
|
1098
|
+
sessions,
|
|
1099
|
+
memory: memSummary,
|
|
1100
|
+
chrome: { found: !!chromePath, path: chromePath, puppeteer_core: hasPuppeteer },
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
function printStatus(compact) {
|
|
1104
|
+
const r = buildStatusReport();
|
|
1105
|
+
if (compact) {
|
|
1106
|
+
console.log(JSON.stringify(r));
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
console.log(`job-pro status (${VERSION})`);
|
|
1110
|
+
console.log();
|
|
1111
|
+
// Profile
|
|
1112
|
+
const filledColor = (r.profile.missing_standard.length === 0 && r.profile.exists) ? "✓" : "✗";
|
|
1113
|
+
console.log(`Profile ${filledColor} ${r.profile.path}`);
|
|
1114
|
+
if (!r.profile.exists) {
|
|
1115
|
+
console.log(` not found — run \`job-pro profile init\``);
|
|
1116
|
+
}
|
|
1117
|
+
else {
|
|
1118
|
+
console.log(` filled: ${r.profile.filled_standard.join(", ") || "(none)"}`);
|
|
1119
|
+
if (r.profile.missing_standard.length > 0) {
|
|
1120
|
+
console.log(` missing: ${r.profile.missing_standard.join(", ")}`);
|
|
1121
|
+
}
|
|
1122
|
+
if (r.profile.custom_keys > 0) {
|
|
1123
|
+
console.log(` custom: ${r.profile.custom_keys} keys`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
console.log();
|
|
1127
|
+
// Sessions
|
|
1128
|
+
if (r.sessions.length === 0) {
|
|
1129
|
+
console.log(`Sessions ✗ no session.json files captured`);
|
|
1130
|
+
console.log(` run \`job-pro extension\` for the bundled MV3 extension path + Chrome install steps.`);
|
|
1131
|
+
}
|
|
1132
|
+
else {
|
|
1133
|
+
console.log(`Sessions ✓ ${r.sessions.length} captured`);
|
|
1134
|
+
for (const s of r.sessions) {
|
|
1135
|
+
const stale = (s.age_days ?? 0) > 30 ? " (STALE — sessions usually expire ~30 days)" : "";
|
|
1136
|
+
console.log(` ${s.adapter.padEnd(18)} ${s.cookies ?? 0}c+${s.headers ?? 0}h age=${s.age_days}d${stale}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
console.log();
|
|
1140
|
+
// Memory
|
|
1141
|
+
console.log(`Memory ${r.memory.total_events > 0 ? "✓" : "·"} ${r.memory.path ?? "(none)"}`);
|
|
1142
|
+
console.log(` fields=${r.memory.field_keys.length} events=${r.memory.total_events}`);
|
|
1143
|
+
for (const e of r.memory.recent_events.slice(0, 5)) {
|
|
1144
|
+
console.log(` ${e.ts} ${e.kind.padEnd(12)} ${(e.payload ?? "").slice(0, 60)}`);
|
|
1145
|
+
}
|
|
1146
|
+
console.log();
|
|
1147
|
+
// Chrome
|
|
1148
|
+
const ch = r.chrome.found && r.chrome.puppeteer_core ? "✓" : "✗";
|
|
1149
|
+
console.log(`Chrome ${ch} ${r.chrome.path ?? "(not found)"}`);
|
|
1150
|
+
console.log(` puppeteer-core: ${r.chrome.puppeteer_core ? "installed" : "missing"}`);
|
|
1151
|
+
if (!r.chrome.found || !r.chrome.puppeteer_core) {
|
|
1152
|
+
console.log(` needed for: lilith adapter, --proxy-server geo-bypass (hikvision).`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
async function runProfileInitInteractive(template) {
|
|
1156
|
+
if (!process.stdin.isTTY) {
|
|
1157
|
+
die("profile init --interactive needs a TTY (got a piped stdin). " +
|
|
1158
|
+
"Either run from a real terminal, or drop --interactive and edit " +
|
|
1159
|
+
"the JSON file directly.");
|
|
1160
|
+
}
|
|
1161
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1162
|
+
const ask = (prompt) => new Promise((resolve, reject) => {
|
|
1163
|
+
let answered = false;
|
|
1164
|
+
const onClose = () => {
|
|
1165
|
+
if (!answered)
|
|
1166
|
+
reject(new Error("stdin closed before answer"));
|
|
1167
|
+
};
|
|
1168
|
+
rl.once("close", onClose);
|
|
1169
|
+
rl.question(prompt, (a) => {
|
|
1170
|
+
answered = true;
|
|
1171
|
+
rl.off("close", onClose);
|
|
1172
|
+
resolve(a);
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
const filled = { ...template };
|
|
1176
|
+
console.log(`\nProfile setup — fill in 5 fields (Ctrl-C to abort).\n`);
|
|
1177
|
+
try {
|
|
1178
|
+
filled.first_name = await prompt("First name: ", ask, (v) => v.trim().length > 0 || "(required)");
|
|
1179
|
+
filled.last_name = await prompt("Last name: ", ask, (v) => v.trim().length > 0 || "(required)");
|
|
1180
|
+
filled.email = await prompt("Email: ", ask, (v) => (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()) ? true : "(must look like name@domain.tld)"));
|
|
1181
|
+
filled.phone = await prompt("Phone (with country code, e.g. +86 13800138000): ", ask, (v) => (/^[+]?[\d\s\-()]{7,}$/.test(v.trim()) ? true : "(digits + optional spaces/dashes; min 7)"));
|
|
1182
|
+
filled.resume_path = await prompt("Resume file path (absolute, PDF/DOCX): ", ask, (v) => {
|
|
1183
|
+
const p = v.trim();
|
|
1184
|
+
if (!p)
|
|
1185
|
+
return "(required — pass an absolute path to your résumé)";
|
|
1186
|
+
if (!existsSync(p))
|
|
1187
|
+
return `(file not found: ${p})`;
|
|
1188
|
+
return true;
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
finally {
|
|
1192
|
+
rl.close();
|
|
1193
|
+
}
|
|
1194
|
+
return filled;
|
|
1195
|
+
}
|
|
1196
|
+
async function prompt(q, ask, validate) {
|
|
1197
|
+
while (true) {
|
|
1198
|
+
const v = (await ask(q)).trim();
|
|
1199
|
+
const res = validate(v);
|
|
1200
|
+
if (res === true)
|
|
1201
|
+
return v;
|
|
1202
|
+
console.log(` ${res}`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
function printCompanyList(compact) {
|
|
1206
|
+
// Validate the directory still matches the ADAPTERS map. If a company
|
|
1207
|
+
// appears in only one place, treat it as a bug.
|
|
1208
|
+
const adapterKeys = new Set(Object.keys(ADAPTERS));
|
|
1209
|
+
const dirKeys = new Set(COMPANIES.map((c) => c.key));
|
|
1210
|
+
const missingInDir = [...adapterKeys].filter((k) => !dirKeys.has(k));
|
|
1211
|
+
const missingInAdapters = [...dirKeys].filter((k) => !adapterKeys.has(k));
|
|
1212
|
+
if (missingInDir.length || missingInAdapters.length) {
|
|
1213
|
+
console.error("INTERNAL: COMPANIES directory diverged from ADAPTERS map.\n" +
|
|
1214
|
+
(missingInDir.length ? ` missing from directory: ${missingInDir.join(", ")}\n` : "") +
|
|
1215
|
+
(missingInAdapters.length ? ` missing from adapters: ${missingInAdapters.join(", ")}\n` : ""));
|
|
1216
|
+
}
|
|
1217
|
+
if (compact) {
|
|
1218
|
+
// Machine-readable: emit a JSON array of { key, family, source, label,
|
|
1219
|
+
// submit_kind } — submit_kind derived from the family map + overrides.
|
|
1220
|
+
console.log(JSON.stringify(COMPANIES.map((c) => ({
|
|
1221
|
+
...c,
|
|
1222
|
+
submit_kind: submitKindFor(c.key, c.family),
|
|
1223
|
+
endpoint_verified: ENDPOINT_VERIFIED.has(c.key),
|
|
1224
|
+
}))));
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
// Human-readable: group by family, fixed-width left column.
|
|
1228
|
+
const byFamily = new Map();
|
|
1229
|
+
for (const c of COMPANIES) {
|
|
1230
|
+
if (!byFamily.has(c.family))
|
|
1231
|
+
byFamily.set(c.family, []);
|
|
1232
|
+
byFamily.get(c.family).push(c);
|
|
1233
|
+
}
|
|
1234
|
+
const order = [
|
|
1235
|
+
"Bespoke",
|
|
1236
|
+
"Feishu",
|
|
1237
|
+
"Beisen Wecruit",
|
|
1238
|
+
"Beisen iTalent",
|
|
1239
|
+
"Moka",
|
|
1240
|
+
"Greenhouse / Lever (intl arm)",
|
|
1241
|
+
"Liepin (third-party)",
|
|
1242
|
+
];
|
|
1243
|
+
const keyWidth = Math.max(...COMPANIES.map((c) => c.key.length));
|
|
1244
|
+
const srcWidth = Math.max(...COMPANIES.map((c) => c.source.length));
|
|
1245
|
+
const kindWidth = Math.max(...COMPANIES.map((c) => submitKindFor(c.key, c.family).length));
|
|
1246
|
+
console.log(`job-pro — 50 companies, all live. ATS-family breakdown:`);
|
|
1247
|
+
for (const family of order) {
|
|
1248
|
+
const entries = byFamily.get(family);
|
|
1249
|
+
if (!entries)
|
|
1250
|
+
continue;
|
|
1251
|
+
const kindForFamily = SUBMIT_KIND_BY_FAMILY[family];
|
|
1252
|
+
console.log(`\n${family} (${entries.length}) — submit_kind=${kindForFamily}`);
|
|
1253
|
+
for (const c of entries) {
|
|
1254
|
+
const kind = submitKindFor(c.key, c.family);
|
|
1255
|
+
const kindCol = kind === kindForFamily ? "".padEnd(kindWidth) : kind.padEnd(kindWidth);
|
|
1256
|
+
const verifiedTag = ENDPOINT_VERIFIED.has(c.key) ? " ✓" : " ";
|
|
1257
|
+
console.log(` ${c.key.padEnd(keyWidth)} ${verifiedTag} ${kindCol} ${c.source.padEnd(srcWidth)} ${c.label}`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
console.log(`\nTotal: ${COMPANIES.length}. Run \`job-pro <key> search "…"\` against any of them.`);
|
|
1261
|
+
}
|
|
1262
|
+
async function main() {
|
|
1263
|
+
const args = process.argv.slice(2);
|
|
1264
|
+
const cmd = args[0];
|
|
1265
|
+
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
1266
|
+
console.log(HELP);
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
1270
|
+
console.log(VERSION);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (cmd === "list" || cmd === "companies") {
|
|
1274
|
+
const compact = args.includes("--compact");
|
|
1275
|
+
printCompanyList(compact);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (cmd === "status") {
|
|
1279
|
+
const compact = args.includes("--compact");
|
|
1280
|
+
printStatus(compact);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (cmd === "recon") {
|
|
1284
|
+
// Probe every adapter's submit_endpoint anonymously and classify the
|
|
1285
|
+
// response. Catches upstream URL drift (the path went 404 because
|
|
1286
|
+
// upstream renamed it) and is the same probe routine I used by hand
|
|
1287
|
+
// to populate endpoint_verified for the 15 verified adapters.
|
|
1288
|
+
const compact = args.includes("--compact");
|
|
1289
|
+
const summary = args.includes("--summary");
|
|
1290
|
+
const { args: aCompanies, value: companiesStr } = popFlagValue(args, "--companies");
|
|
1291
|
+
void aCompanies;
|
|
1292
|
+
const scope = companiesStr
|
|
1293
|
+
? companiesStr.split(",").map((s) => s.trim()).filter(Boolean)
|
|
1294
|
+
: Object.keys(ADAPTERS);
|
|
1295
|
+
function classify(status, body, contentType) {
|
|
1296
|
+
const isHTML = contentType.includes("html") || body.trim().startsWith("<");
|
|
1297
|
+
// 5xx + any body = handler threw on us, route exists. IIS / Spring
|
|
1298
|
+
// generic 500 templates are HTML but still real-route signals.
|
|
1299
|
+
if (status >= 500)
|
|
1300
|
+
return "verified-real";
|
|
1301
|
+
// 405 + any body = method-not-allowed = the routing table has this
|
|
1302
|
+
// URL; just the method/body is wrong. Real route. Nginx's HTML 405
|
|
1303
|
+
// page is one common form, hence the explicit handling here.
|
|
1304
|
+
if (status === 405)
|
|
1305
|
+
return "verified-real";
|
|
1306
|
+
if (status === 404)
|
|
1307
|
+
return isHTML ? "html-fallthrough" : "speculative-404";
|
|
1308
|
+
if (isHTML)
|
|
1309
|
+
return "html-fallthrough";
|
|
1310
|
+
// 401/403/200-with-error-body/4xx-with-business-error = real route
|
|
1311
|
+
return "verified-real";
|
|
1312
|
+
}
|
|
1313
|
+
function withTimeout(p, ms) {
|
|
1314
|
+
return Promise.race([
|
|
1315
|
+
p,
|
|
1316
|
+
new Promise((resolve) => setTimeout(() => resolve(null), ms)),
|
|
1317
|
+
]);
|
|
1318
|
+
}
|
|
1319
|
+
const results = await Promise.all(scope.map(async (company) => {
|
|
1320
|
+
// lilith uses CDP (puppeteer launches Chrome) — its withTimeout
|
|
1321
|
+
// returns but the browser handle keeps the event loop alive, so
|
|
1322
|
+
// the process never exits during a 50-company sweep. Skip when
|
|
1323
|
+
// we're running a default/broad sweep; only probe lilith if the
|
|
1324
|
+
// user explicitly scoped --companies and lilith is the ONLY one
|
|
1325
|
+
// (so they're knowingly waiting for a CDP launch).
|
|
1326
|
+
const lilithScopedExplicit = scope.length === 1 && scope[0] === "lilith";
|
|
1327
|
+
if (company === "lilith" && !lilithScopedExplicit) {
|
|
1328
|
+
// lilith is in ENDPOINT_VERIFIED but we skip the probe (would
|
|
1329
|
+
// hang puppeteer). Surface the already_verified status so the
|
|
1330
|
+
// icon shows ⚠ ("schema verified, probe skipped") not "?".
|
|
1331
|
+
return { company, classification: "probe-error", detail: "skipped — CDP adapter (puppeteer); pass --companies=lilith alone to probe", already_verified: true };
|
|
1332
|
+
}
|
|
1333
|
+
const adapter = ADAPTERS[company];
|
|
1334
|
+
if (!adapter)
|
|
1335
|
+
return { company, classification: "probe-error", detail: "unknown adapter" };
|
|
1336
|
+
if (typeof adapter.fetchApplicationSchema !== "function") {
|
|
1337
|
+
return { company, classification: "probe-error", detail: "no fetchApplicationSchema" };
|
|
1338
|
+
}
|
|
1339
|
+
// Use a placeholder post_id so we don't have to search.
|
|
1340
|
+
// Per-step timeout protects against slow / hung adapters.
|
|
1341
|
+
let schema = null;
|
|
1342
|
+
try {
|
|
1343
|
+
const r = await withTimeout(adapter.fetchApplicationSchema("recon-probe"), 10000);
|
|
1344
|
+
if (r?.ok && r.schema)
|
|
1345
|
+
schema = r.schema;
|
|
1346
|
+
}
|
|
1347
|
+
catch { }
|
|
1348
|
+
if (!schema) {
|
|
1349
|
+
try {
|
|
1350
|
+
const list = await withTimeout(adapter.searchPositions({ pageSize: 1 }), 10000);
|
|
1351
|
+
const pid = list?.positions?.[0]?.post_id;
|
|
1352
|
+
if (pid) {
|
|
1353
|
+
const r = await withTimeout(adapter.fetchApplicationSchema(pid), 10000);
|
|
1354
|
+
if (r?.ok && r.schema)
|
|
1355
|
+
schema = r.schema;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
catch { }
|
|
1359
|
+
}
|
|
1360
|
+
if (!schema)
|
|
1361
|
+
return { company, classification: "probe-error", detail: "schema unavailable" };
|
|
1362
|
+
if (schema.submit_kind === "external") {
|
|
1363
|
+
return { company, submit_kind: schema.submit_kind, classification: "external", detail: "structurally external (Liepin / WeChat)" };
|
|
1364
|
+
}
|
|
1365
|
+
const url = schema.submit_endpoint ?? "";
|
|
1366
|
+
if (!url)
|
|
1367
|
+
return { company, submit_kind: schema.submit_kind, classification: "no-endpoint", detail: "no submit_endpoint in schema" };
|
|
1368
|
+
try {
|
|
1369
|
+
const r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
1370
|
+
const body = await r.text();
|
|
1371
|
+
const ct = r.headers.get("content-type") ?? "";
|
|
1372
|
+
return {
|
|
1373
|
+
company,
|
|
1374
|
+
submit_kind: schema.submit_kind,
|
|
1375
|
+
submit_endpoint: url,
|
|
1376
|
+
status: r.status,
|
|
1377
|
+
classification: classify(r.status, body, ct),
|
|
1378
|
+
detail: body.slice(0, 80).replace(/\s+/g, " "),
|
|
1379
|
+
already_verified: schema.endpoint_verified === true,
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
catch (err) {
|
|
1383
|
+
return { company, submit_kind: schema.submit_kind, submit_endpoint: url, classification: "probe-error", detail: err instanceof Error ? err.message : String(err), already_verified: schema.endpoint_verified === true };
|
|
1384
|
+
}
|
|
1385
|
+
}));
|
|
1386
|
+
if (compact) {
|
|
1387
|
+
console.log(JSON.stringify({ probed: results.length, results }));
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const tally = new Map();
|
|
1391
|
+
for (const r of results)
|
|
1392
|
+
tally.set(r.classification, (tally.get(r.classification) ?? 0) + 1);
|
|
1393
|
+
const width = Math.max(...results.map((r) => r.company.length));
|
|
1394
|
+
const ICON = {
|
|
1395
|
+
"verified-real": "✓",
|
|
1396
|
+
"speculative-404": "✗",
|
|
1397
|
+
"html-fallthrough": "✗",
|
|
1398
|
+
"external": "⛔",
|
|
1399
|
+
"no-endpoint": "·",
|
|
1400
|
+
"probe-error": "?",
|
|
1401
|
+
};
|
|
1402
|
+
console.log(`\njob-pro recon — endpoint probe across ${results.length} adapters`);
|
|
1403
|
+
if (!summary) {
|
|
1404
|
+
console.log(` (anon POST with {} body; schema-verified ✓ 🟢, session captured 🔐)\n`);
|
|
1405
|
+
for (const r of results) {
|
|
1406
|
+
const tag = r.status ? `${r.status}` : "—";
|
|
1407
|
+
const vTag = r.already_verified ? " 🟢" : "";
|
|
1408
|
+
// Session presence (~/.jobpro/<co>.session.json). 🔐 means user
|
|
1409
|
+
// already captured; 🚫 means they need to run `job-pro extension`.
|
|
1410
|
+
// multipart-anon adapters don't need a session so no tag.
|
|
1411
|
+
const isAnon = r.submit_kind === "multipart-anon";
|
|
1412
|
+
const isExternal = r.classification === "external";
|
|
1413
|
+
const sessTag = isAnon || isExternal ? " " : (loadSession(r.company) ? "🔐" : "🚫");
|
|
1414
|
+
const probeOK = r.classification === "verified-real" || r.classification === "external";
|
|
1415
|
+
const icon = r.already_verified && !probeOK ? "⚠" : ICON[r.classification];
|
|
1416
|
+
console.log(` ${icon} ${r.company.padEnd(width)} ${sessTag} ${tag.padEnd(4)} ${r.classification.padEnd(17)}${vTag} ${r.detail}`);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
console.log(`\n Tally:`);
|
|
1420
|
+
for (const [k, v] of [...tally.entries()].sort()) {
|
|
1421
|
+
console.log(` ${k.padEnd(20)} ${v}`);
|
|
1422
|
+
}
|
|
1423
|
+
// Some adapters (cdp/lilith via puppeteer) keep the event loop alive
|
|
1424
|
+
// after their probe resolves. Explicit exit guarantees the CLI returns.
|
|
1425
|
+
process.exit(0);
|
|
1426
|
+
}
|
|
1427
|
+
if (cmd === "selftest") {
|
|
1428
|
+
// Three end-to-end checks against the easiest adapter (xpeng, anon-submit):
|
|
1429
|
+
// 1. searchPositions returns >0 hits
|
|
1430
|
+
// 2. fetchApplicationSchema for the first hit returns ok:true with questions
|
|
1431
|
+
// 3. submitApplication(staged, {kind:"debug", url:httpbin}) returns 200
|
|
1432
|
+
// Total ~3-5s. No profile / no session needed. Useful right after install
|
|
1433
|
+
// to confirm the CLI can actually round-trip end-to-end.
|
|
1434
|
+
const compact = args.includes("--compact");
|
|
1435
|
+
const xpengAdapter = ADAPTERS.xpeng;
|
|
1436
|
+
const checks = [];
|
|
1437
|
+
async function run(name, fn) {
|
|
1438
|
+
const t0 = Date.now();
|
|
1439
|
+
try {
|
|
1440
|
+
const r = await fn();
|
|
1441
|
+
checks.push({ name, ok: true, detail: "", ms: Date.now() - t0 });
|
|
1442
|
+
return r;
|
|
1443
|
+
}
|
|
1444
|
+
catch (err) {
|
|
1445
|
+
checks.push({ name, ok: false, detail: err instanceof Error ? err.message : String(err), ms: Date.now() - t0 });
|
|
1446
|
+
return null;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
// Step 1
|
|
1450
|
+
const list = await run("search xpeng", async () => {
|
|
1451
|
+
const r = (await xpengAdapter.searchPositions({ pageSize: 1 }));
|
|
1452
|
+
if (!r.ok || !r.positions?.[0]?.post_id)
|
|
1453
|
+
throw new Error("no positions returned");
|
|
1454
|
+
return r;
|
|
1455
|
+
});
|
|
1456
|
+
let postId = null;
|
|
1457
|
+
let title = "";
|
|
1458
|
+
if (list && list.positions?.[0]) {
|
|
1459
|
+
postId = String(list.positions[0].post_id ?? "");
|
|
1460
|
+
title = String(list.positions[0].title ?? "").trim();
|
|
1461
|
+
}
|
|
1462
|
+
// Step 2
|
|
1463
|
+
let schema = null;
|
|
1464
|
+
if (postId && typeof xpengAdapter.fetchApplicationSchema === "function") {
|
|
1465
|
+
schema = await run("fetch schema", async () => {
|
|
1466
|
+
const r = (await xpengAdapter.fetchApplicationSchema(postId));
|
|
1467
|
+
if (!r.ok || !r.schema)
|
|
1468
|
+
throw new Error(r.message ?? "schema fetch failed");
|
|
1469
|
+
return r.schema;
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
else if (!postId) {
|
|
1473
|
+
checks.push({ name: "fetch schema", ok: false, detail: "skipped — no post_id from search", ms: 0 });
|
|
1474
|
+
}
|
|
1475
|
+
// Step 3
|
|
1476
|
+
if (schema) {
|
|
1477
|
+
const tmp = mkdtempSync(join(tmpdir(), "jobpro-selftest-"));
|
|
1478
|
+
const resumePath = join(tmp, "resume.pdf");
|
|
1479
|
+
writeFileSync(resumePath, "%PDF\n");
|
|
1480
|
+
const profile = {
|
|
1481
|
+
first_name: "Self", last_name: "Test", email: "selftest@example.com",
|
|
1482
|
+
phone: "+86 13800138000", resume_path: resumePath, cover_letter_text: "",
|
|
1483
|
+
custom: {},
|
|
1484
|
+
};
|
|
1485
|
+
// Auto-fill required: first allowed value for selects, "N/A" for text.
|
|
1486
|
+
for (const q of schema.questions) {
|
|
1487
|
+
if (!q.required)
|
|
1488
|
+
continue;
|
|
1489
|
+
const f = q.fields[0];
|
|
1490
|
+
if (!f)
|
|
1491
|
+
continue;
|
|
1492
|
+
if (["input_text", "textarea"].includes(f.type))
|
|
1493
|
+
profile.custom[f.name] = "N/A (selftest)";
|
|
1494
|
+
else if (f.type.includes("select")) {
|
|
1495
|
+
const first = f.values?.[0];
|
|
1496
|
+
if (first && typeof first.value !== "undefined")
|
|
1497
|
+
profile.custom[f.name] = String(first.value);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
const staged = stageApplication(schema, profile);
|
|
1501
|
+
if (!staged.ready) {
|
|
1502
|
+
checks.push({ name: "debug-submit echo", ok: false, detail: `staged not ready: ${staged.unanswered_required.slice(0, 3).join(", ")}`, ms: 0 });
|
|
1503
|
+
}
|
|
1504
|
+
else {
|
|
1505
|
+
await run("debug-submit echo", async () => {
|
|
1506
|
+
const r = (await submitApplication(staged, { kind: "debug", url: "https://httpbin.org/post" }));
|
|
1507
|
+
if (r.ok !== true || r.status !== 200)
|
|
1508
|
+
throw new Error(`echo failed: ok=${r.ok} status=${r.status} msg=${r.message}`);
|
|
1509
|
+
return r;
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
1513
|
+
}
|
|
1514
|
+
const fails = checks.filter((c) => !c.ok).length;
|
|
1515
|
+
if (compact) {
|
|
1516
|
+
console.log(JSON.stringify({ ok: fails === 0, checks }));
|
|
1517
|
+
}
|
|
1518
|
+
else {
|
|
1519
|
+
console.log(`\njob-pro selftest — using xpeng (anon Greenhouse board)\n`);
|
|
1520
|
+
for (const c of checks) {
|
|
1521
|
+
const icon = c.ok ? "✓" : "✗";
|
|
1522
|
+
const detail = c.detail ? ` ${c.detail}` : "";
|
|
1523
|
+
console.log(` ${icon} ${c.name.padEnd(20)} ${c.ms}ms${detail}`);
|
|
1524
|
+
}
|
|
1525
|
+
console.log(`\n ${checks.length - fails} pass / ${fails} fail / ${checks.length} total${title ? ` — sampled "${title}"` : ""}`);
|
|
1526
|
+
if (fails === 0)
|
|
1527
|
+
console.log(`\n Setup looks good. Run \`job-pro find "<keyword>"\` to scan all 50 companies.`);
|
|
1528
|
+
}
|
|
1529
|
+
if (fails > 0)
|
|
1530
|
+
process.exit(1);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (cmd === "extension") {
|
|
1534
|
+
// Locate the extension/ directory. The package ships it as a sibling of
|
|
1535
|
+
// dist/, so __dirname is cli/dist and the extension lives at ../extension.
|
|
1536
|
+
// For a `npx job-pro` run, that lands in the npm cache; for a global
|
|
1537
|
+
// install, in the prefix. For local dev, the repo's top-level extension/.
|
|
1538
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1539
|
+
const candidates = [
|
|
1540
|
+
join(here, "..", "extension"),
|
|
1541
|
+
join(here, "..", "..", "extension"),
|
|
1542
|
+
];
|
|
1543
|
+
const extPath = candidates.find((p) => existsSync(join(p, "manifest.json"))) ?? null;
|
|
1544
|
+
const sub = args[1];
|
|
1545
|
+
if (sub === "path") {
|
|
1546
|
+
if (!extPath)
|
|
1547
|
+
die("extension/ not found — please reinstall job-pro@latest");
|
|
1548
|
+
console.log(extPath);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
// Default: print install walkthrough.
|
|
1552
|
+
if (!extPath)
|
|
1553
|
+
die("extension/ not found — please reinstall job-pro@latest");
|
|
1554
|
+
console.log(`
|
|
1555
|
+
job-pro session-capture extension
|
|
1556
|
+
=================================
|
|
1557
|
+
|
|
1558
|
+
Path: ${extPath}
|
|
1559
|
+
|
|
1560
|
+
Install (Chrome / Edge / Brave):
|
|
1561
|
+
1. Open chrome://extensions
|
|
1562
|
+
2. Enable "Developer mode" (top-right toggle)
|
|
1563
|
+
3. Click "Load unpacked"
|
|
1564
|
+
4. Pick the path above
|
|
1565
|
+
5. Browse a careers site (e.g. jobs.bytedance.com), log in, then click
|
|
1566
|
+
the extension's popup → "Export session" to drop
|
|
1567
|
+
~/Downloads/jobpro/<adapter>.session.json
|
|
1568
|
+
6. Move it under ~/.jobpro/<adapter>.session.json — \`job-pro <co> apply\`
|
|
1569
|
+
will pick it up automatically.
|
|
1570
|
+
|
|
1571
|
+
Or copy the path to clipboard (macOS):
|
|
1572
|
+
echo "${extPath}" | pbcopy
|
|
1573
|
+
`);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
if (cmd === "find") {
|
|
1577
|
+
const compact = args.includes("--compact");
|
|
1578
|
+
const textMode = args.includes("--text");
|
|
1579
|
+
const applyReadyOnly = args.includes("--apply-ready");
|
|
1580
|
+
const keyword = args[1];
|
|
1581
|
+
if (!keyword || keyword.startsWith("--")) {
|
|
1582
|
+
die(`usage: job-pro find <keyword> [--limit N] [--companies a,b,c] [--timeout ms] [--apply-ready] [--compact | --text]`);
|
|
1583
|
+
}
|
|
1584
|
+
// Apply-readiness derives from the canonical SUBMIT_KIND_BY_FAMILY map
|
|
1585
|
+
// (single source of truth shared with `list`). multipart-anon is fire-
|
|
1586
|
+
// and-go; external is structurally blocked; everything else needs a
|
|
1587
|
+
// captured session.
|
|
1588
|
+
const applyStatusFor = (adapterKey) => {
|
|
1589
|
+
const dirEntry = COMPANIES.find((c) => c.key === adapterKey);
|
|
1590
|
+
if (!dirEntry)
|
|
1591
|
+
return "missing-session";
|
|
1592
|
+
const kind = submitKindFor(adapterKey, dirEntry.family);
|
|
1593
|
+
if (kind === "external")
|
|
1594
|
+
return "external";
|
|
1595
|
+
if (kind === "multipart-anon")
|
|
1596
|
+
return "anon";
|
|
1597
|
+
return loadSession(adapterKey) ? "session" : "missing-session";
|
|
1598
|
+
};
|
|
1599
|
+
const { args: aLimit, value: limitStr } = popFlagValue(args, "--limit");
|
|
1600
|
+
const { args: aCompanies, value: companiesStr } = popFlagValue(aLimit, "--companies");
|
|
1601
|
+
const { args: aTimeout, value: timeoutStr } = popFlagValue(aCompanies, "--timeout");
|
|
1602
|
+
void aTimeout;
|
|
1603
|
+
const limit = limitStr ? Math.max(1, parseInt(limitStr, 10)) : 3;
|
|
1604
|
+
const timeout = timeoutStr ? Math.max(1000, parseInt(timeoutStr, 10)) : 8000;
|
|
1605
|
+
const scope = companiesStr
|
|
1606
|
+
? companiesStr.split(",").map((s) => s.trim()).filter(Boolean)
|
|
1607
|
+
: Object.keys(ADAPTERS);
|
|
1608
|
+
const unknown = scope.filter((c) => !(c in ADAPTERS));
|
|
1609
|
+
if (unknown.length > 0)
|
|
1610
|
+
die(`unknown company in --companies: ${unknown.join(", ")}`);
|
|
1611
|
+
const startedAt = Date.now();
|
|
1612
|
+
const settled = await Promise.all(scope.map(async (company) => {
|
|
1613
|
+
const adapter = ADAPTERS[company];
|
|
1614
|
+
const t0 = Date.now();
|
|
1615
|
+
let timer = null;
|
|
1616
|
+
try {
|
|
1617
|
+
const timeoutP = new Promise((resolve) => {
|
|
1618
|
+
timer = setTimeout(() => resolve({ timedOut: true }), timeout);
|
|
1619
|
+
});
|
|
1620
|
+
const searchP = adapter
|
|
1621
|
+
.searchPositions({ keyword, pageSize: limit })
|
|
1622
|
+
.then((r) => ({ ok: true, value: r }));
|
|
1623
|
+
const raced = await Promise.race([timeoutP, searchP]);
|
|
1624
|
+
const elapsed = Date.now() - t0;
|
|
1625
|
+
if ("timedOut" in raced) {
|
|
1626
|
+
return { company, ok: false, count: 0, positions: [], message: `timeout after ${timeout}ms`, elapsed_ms: elapsed };
|
|
1627
|
+
}
|
|
1628
|
+
const r = raced.value;
|
|
1629
|
+
if (r?.ok === false) {
|
|
1630
|
+
return { company, ok: false, count: 0, positions: [], message: r.message ?? "search failed", elapsed_ms: elapsed };
|
|
1631
|
+
}
|
|
1632
|
+
const positions = Array.isArray(r?.positions) ? r.positions.slice(0, limit) : [];
|
|
1633
|
+
return { company, ok: true, count: positions.length, positions, apply_status: applyStatusFor(company), elapsed_ms: elapsed };
|
|
1634
|
+
}
|
|
1635
|
+
catch (err) {
|
|
1636
|
+
const elapsed = Date.now() - t0;
|
|
1637
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1638
|
+
return { company, ok: false, count: 0, positions: [], message, elapsed_ms: elapsed };
|
|
1639
|
+
}
|
|
1640
|
+
finally {
|
|
1641
|
+
if (timer)
|
|
1642
|
+
clearTimeout(timer);
|
|
1643
|
+
}
|
|
1644
|
+
}));
|
|
1645
|
+
const totalMs = Date.now() - startedAt;
|
|
1646
|
+
const allHits = settled.filter((r) => r.count > 0);
|
|
1647
|
+
const withHits = applyReadyOnly
|
|
1648
|
+
? allHits.filter((r) => r.apply_status === "anon" || r.apply_status === "session")
|
|
1649
|
+
: allHits;
|
|
1650
|
+
const total = withHits.reduce((s, r) => s + r.count, 0);
|
|
1651
|
+
const failed = settled.filter((r) => !r.ok).map((r) => ({ company: r.company, message: r.message }));
|
|
1652
|
+
if (textMode) {
|
|
1653
|
+
const STATUS_ICON = {
|
|
1654
|
+
anon: "✅",
|
|
1655
|
+
session: "🟢",
|
|
1656
|
+
"missing-session": "🟡",
|
|
1657
|
+
external: "⛔",
|
|
1658
|
+
};
|
|
1659
|
+
const filterNote = applyReadyOnly ? " [apply-ready only]" : "";
|
|
1660
|
+
console.log(`\nfind "${keyword}" — ${total} hit(s) across ${withHits.length}/${scope.length} companies (${totalMs}ms)${filterNote}\n`);
|
|
1661
|
+
for (const r of withHits) {
|
|
1662
|
+
const icon = STATUS_ICON[r.apply_status ?? ""] ?? "?";
|
|
1663
|
+
console.log(`${icon} ${r.company} (${r.count}) — ${r.apply_status}`);
|
|
1664
|
+
for (const p of r.positions) {
|
|
1665
|
+
const title = (p.title ?? "").trim().replace(/\s+/g, " ");
|
|
1666
|
+
const loc = (p.work_cities ?? "").trim();
|
|
1667
|
+
console.log(` ${p.post_id ?? "?"} ${title}${loc ? ` — ${loc}` : ""}`);
|
|
1668
|
+
if (p.apply_url)
|
|
1669
|
+
console.log(` ${p.apply_url}`);
|
|
1670
|
+
}
|
|
1671
|
+
console.log("");
|
|
1672
|
+
}
|
|
1673
|
+
if (applyReadyOnly) {
|
|
1674
|
+
const hiddenBuckets = allHits.filter((r) => r.apply_status === "missing-session" || r.apply_status === "external");
|
|
1675
|
+
if (hiddenBuckets.length > 0) {
|
|
1676
|
+
const missing = hiddenBuckets
|
|
1677
|
+
.filter((r) => r.apply_status === "missing-session")
|
|
1678
|
+
.map((r) => `${r.company}(${r.count})`);
|
|
1679
|
+
const external = hiddenBuckets
|
|
1680
|
+
.filter((r) => r.apply_status === "external")
|
|
1681
|
+
.map((r) => `${r.company}(${r.count})`);
|
|
1682
|
+
console.log(`Hidden by --apply-ready:`);
|
|
1683
|
+
if (missing.length)
|
|
1684
|
+
console.log(` 🟡 missing-session (run \`job-pro extension\`): ${missing.join(" ")}`);
|
|
1685
|
+
if (external.length)
|
|
1686
|
+
console.log(` ⛔ external (IM-mediated): ${external.join(" ")}`);
|
|
1687
|
+
console.log("");
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
if (failed.length > 0) {
|
|
1691
|
+
console.log(`Failed (${failed.length}):`);
|
|
1692
|
+
for (const f of failed)
|
|
1693
|
+
console.log(` ${f.company}: ${f.message}`);
|
|
1694
|
+
}
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
emit({
|
|
1698
|
+
ok: true,
|
|
1699
|
+
keyword,
|
|
1700
|
+
total,
|
|
1701
|
+
company_count: withHits.length,
|
|
1702
|
+
scanned_companies: scope.length,
|
|
1703
|
+
elapsed_ms: totalMs,
|
|
1704
|
+
results: withHits,
|
|
1705
|
+
failed,
|
|
1706
|
+
}, compact);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
if (cmd === "profile") {
|
|
1710
|
+
const sub = args[1];
|
|
1711
|
+
if (sub === "init") {
|
|
1712
|
+
const { path, template } = profileTemplate();
|
|
1713
|
+
if (existsSync(path) && !args.includes("--force")) {
|
|
1714
|
+
console.error(`profile already exists at ${path}; pass --force to overwrite.`);
|
|
1715
|
+
process.exit(1);
|
|
1716
|
+
}
|
|
1717
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1718
|
+
const interactive = args.includes("--interactive");
|
|
1719
|
+
const filled = interactive ? await runProfileInitInteractive(template) : template;
|
|
1720
|
+
writeFileSync(path, JSON.stringify(filled, null, 2) + "\n", "utf8");
|
|
1721
|
+
if (interactive) {
|
|
1722
|
+
console.log(`\nWrote ${path}. Run \`job-pro status\` to confirm, then \`job-pro <co> apply <id>\` to start.`);
|
|
1723
|
+
}
|
|
1724
|
+
else {
|
|
1725
|
+
console.log(`Wrote ${path}. Fill in first_name / last_name / email / phone / resume_path before running \`job-pro <co> apply\`. (Tip: pass --interactive to fill it in the terminal now.)`);
|
|
1726
|
+
}
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
if (sub === "show") {
|
|
1730
|
+
const r = loadProfile();
|
|
1731
|
+
if (!r.ok) {
|
|
1732
|
+
console.error(r.message);
|
|
1733
|
+
process.exit(1);
|
|
1734
|
+
}
|
|
1735
|
+
console.log(JSON.stringify(r.profile, null, 2));
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (sub === "lint") {
|
|
1739
|
+
const compact = args.includes("--compact");
|
|
1740
|
+
const r = loadProfileRaw();
|
|
1741
|
+
if (!r.ok) {
|
|
1742
|
+
console.error(r.message);
|
|
1743
|
+
process.exit(1);
|
|
1744
|
+
}
|
|
1745
|
+
const p = r.profile;
|
|
1746
|
+
const findings = [];
|
|
1747
|
+
// first_name / last_name
|
|
1748
|
+
for (const k of ["first_name", "last_name"]) {
|
|
1749
|
+
const v = (p[k] ?? "").trim();
|
|
1750
|
+
if (!v)
|
|
1751
|
+
findings.push({ level: "FAIL", check: k, message: "missing" });
|
|
1752
|
+
else
|
|
1753
|
+
findings.push({ level: "PASS", check: k, message: v });
|
|
1754
|
+
}
|
|
1755
|
+
// email
|
|
1756
|
+
const email = (p.email ?? "").trim();
|
|
1757
|
+
if (!email)
|
|
1758
|
+
findings.push({ level: "FAIL", check: "email", message: "missing" });
|
|
1759
|
+
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
|
1760
|
+
findings.push({ level: "FAIL", check: "email", message: `"${email}" doesn't look like a valid address` });
|
|
1761
|
+
else
|
|
1762
|
+
findings.push({ level: "PASS", check: "email", message: email });
|
|
1763
|
+
// phone
|
|
1764
|
+
const phone = (p.phone ?? "").trim();
|
|
1765
|
+
if (!phone)
|
|
1766
|
+
findings.push({ level: "FAIL", check: "phone", message: "missing" });
|
|
1767
|
+
else {
|
|
1768
|
+
const digitCount = phone.replace(/\D/g, "").length;
|
|
1769
|
+
if (digitCount < 7)
|
|
1770
|
+
findings.push({ level: "FAIL", check: "phone", message: `"${phone}" has ${digitCount} digit(s); need 7+` });
|
|
1771
|
+
else if (!phone.startsWith("+"))
|
|
1772
|
+
findings.push({ level: "WARN", check: "phone", message: `"${phone}" missing country code (recommended for non-anon adapters; e.g. +86 / +1)` });
|
|
1773
|
+
else
|
|
1774
|
+
findings.push({ level: "PASS", check: "phone", message: phone });
|
|
1775
|
+
}
|
|
1776
|
+
// resume_path
|
|
1777
|
+
const rp = (p.resume_path ?? "").trim();
|
|
1778
|
+
if (!rp)
|
|
1779
|
+
findings.push({ level: "FAIL", check: "resume_path", message: "missing" });
|
|
1780
|
+
else if (!existsSync(rp))
|
|
1781
|
+
findings.push({ level: "FAIL", check: "resume_path", message: `file not found: ${rp}` });
|
|
1782
|
+
else {
|
|
1783
|
+
const lower = rp.toLowerCase();
|
|
1784
|
+
if (!/\.(pdf|docx?|md|txt|rtf)$/i.test(lower))
|
|
1785
|
+
findings.push({ level: "WARN", check: "resume_path", message: `unusual extension: ${rp} (most ATS expect .pdf or .docx)` });
|
|
1786
|
+
else
|
|
1787
|
+
findings.push({ level: "PASS", check: "resume_path", message: rp });
|
|
1788
|
+
}
|
|
1789
|
+
// custom
|
|
1790
|
+
const customCount = Object.keys(p.custom ?? {}).length;
|
|
1791
|
+
if (customCount > 0) {
|
|
1792
|
+
const emptyValues = Object.entries(p.custom ?? {})
|
|
1793
|
+
.filter(([, v]) => typeof v !== "string" || v.trim() === "")
|
|
1794
|
+
.map(([k]) => k);
|
|
1795
|
+
if (emptyValues.length > 0)
|
|
1796
|
+
findings.push({ level: "WARN", check: "custom", message: `${emptyValues.length} empty value(s): ${emptyValues.slice(0, 5).join(", ")}` });
|
|
1797
|
+
else
|
|
1798
|
+
findings.push({ level: "PASS", check: "custom", message: `${customCount} answer(s)` });
|
|
1799
|
+
}
|
|
1800
|
+
const fails = findings.filter((f) => f.level === "FAIL").length;
|
|
1801
|
+
const warns = findings.filter((f) => f.level === "WARN").length;
|
|
1802
|
+
if (compact) {
|
|
1803
|
+
console.log(JSON.stringify({ ok: fails === 0, fails, warns, findings }));
|
|
1804
|
+
}
|
|
1805
|
+
else {
|
|
1806
|
+
const ICON = { PASS: "✓", WARN: "!", FAIL: "✗" };
|
|
1807
|
+
for (const f of findings)
|
|
1808
|
+
console.log(` ${ICON[f.level]} ${f.check.padEnd(13)} ${f.message}`);
|
|
1809
|
+
console.log(`\n ${fails} fail / ${warns} warn / ${findings.length - fails - warns} pass`);
|
|
1810
|
+
}
|
|
1811
|
+
if (fails > 0)
|
|
1812
|
+
process.exit(1);
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
die(`usage: job-pro profile <init [--interactive] [--force] | show | lint>`);
|
|
1816
|
+
}
|
|
1817
|
+
const adapter = ADAPTERS[cmd];
|
|
1818
|
+
if (adapter) {
|
|
1819
|
+
await runCompany(adapter, cmd, args.slice(1));
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
die(`unknown company: ${cmd}. Try \`job-pro list\` for the full list, ` +
|
|
1823
|
+
`or \`job-pro help\` for usage.`);
|
|
1824
|
+
}
|
|
1825
|
+
main().catch((err) => {
|
|
1826
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
1827
|
+
process.exit(1);
|
|
1828
|
+
});
|