@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.
Files changed (68) hide show
  1. package/dist/adapter.js +17 -0
  2. package/dist/agibot.js +399 -0
  3. package/dist/alibaba.js +509 -0
  4. package/dist/antgroup.js +397 -0
  5. package/dist/apply.js +1373 -0
  6. package/dist/baichuan.js +49 -0
  7. package/dist/baidu.js +452 -0
  8. package/dist/bilibili.js +455 -0
  9. package/dist/byd.js +412 -0
  10. package/dist/bytedance.js +619 -0
  11. package/dist/cainiao.js +56 -0
  12. package/dist/cambricon.js +33 -0
  13. package/dist/cdp.js +237 -0
  14. package/dist/cicc.js +56 -0
  15. package/dist/coverage.js +60 -0
  16. package/dist/deepseek.js +25 -0
  17. package/dist/didi.js +381 -0
  18. package/dist/feishu.js +577 -0
  19. package/dist/galaxyuniversal.js +24 -0
  20. package/dist/geely.js +35 -0
  21. package/dist/greenhouse.js +432 -0
  22. package/dist/hikvision.js +58 -0
  23. package/dist/horizonrobotics.js +46 -0
  24. package/dist/hoyoverse.js +26 -0
  25. package/dist/huawei.js +537 -0
  26. package/dist/iflytek.js +380 -0
  27. package/dist/index.js +1828 -0
  28. package/dist/iqiyi.js +494 -0
  29. package/dist/jd.js +559 -0
  30. package/dist/kuaishou.js +496 -0
  31. package/dist/lever.js +455 -0
  32. package/dist/liauto.js +393 -0
  33. package/dist/liepin.js +357 -0
  34. package/dist/lilith.js +300 -0
  35. package/dist/megvii.js +27 -0
  36. package/dist/meituan.js +633 -0
  37. package/dist/memory.js +76 -0
  38. package/dist/mihoyo.js +308 -0
  39. package/dist/minimax.js +32 -0
  40. package/dist/moka.js +473 -0
  41. package/dist/moonshot.js +24 -0
  42. package/dist/netease.js +424 -0
  43. package/dist/nio.js +24 -0
  44. package/dist/oppo.js +285 -0
  45. package/dist/pdd.js +614 -0
  46. package/dist/pingan.js +493 -0
  47. package/dist/sensetime.js +51 -0
  48. package/dist/sf.js +310 -0
  49. package/dist/stepfun.js +24 -0
  50. package/dist/tencent.js +770 -0
  51. package/dist/trip.js +396 -0
  52. package/dist/unitree.js +418 -0
  53. package/dist/vivo.js +361 -0
  54. package/dist/webank.js +55 -0
  55. package/dist/wecruit.js +438 -0
  56. package/dist/weibo.js +337 -0
  57. package/dist/weride.js +29 -0
  58. package/dist/xiaohongshu.js +480 -0
  59. package/dist/xiaomi.js +529 -0
  60. package/dist/xpeng.js +34 -0
  61. package/dist/zerooneai.js +42 -0
  62. package/dist/zhipu.js +478 -0
  63. package/extension/README.md +79 -0
  64. package/extension/background.js +177 -0
  65. package/extension/manifest.json +55 -0
  66. package/extension/popup.html +37 -0
  67. package/extension/popup.js +54 -0
  68. 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
+ });