@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/zhipu.js ADDED
@@ -0,0 +1,478 @@
1
+ // Thin client for 智谱AI (ZhipuAI / GLM) careers, hosted on Feishu Recruiting (ATSX).
2
+ //
3
+ // ============================================================
4
+ // Discovery notes (probed 2026-05):
5
+ //
6
+ // Attempted endpoints:
7
+ // https://zhipuai.cn/careers — SSL/network error (not reachable from CLI)
8
+ // https://careers.zhipuai.cn/ — SSL/network error
9
+ // https://hr.zhipuai.cn/ — SSL/network error
10
+ // https://zhipu.jobs.feishu.cn/ — HTTP 404 (tenant not configured)
11
+ // https://zhipuai.jobs.feishu.cn/ — HTTP 404 (tenant not configured)
12
+ //
13
+ // Live endpoint: https://zhipu-ai.jobs.feishu.cn/
14
+ // Host: zhipu-ai.jobs.feishu.cn
15
+ // Channel: "index" (from window.js-websiteInfo → website_info.path)
16
+ // Tenant: 北京智谱华章科技股份有限公司 (tenant_id_md5: 71bfc100479a8c605e8529cddf3ccf2b)
17
+ // Type: 社招官网 (social / experienced hire only — no campus portal found)
18
+ // Total: ~222 active positions (probed 2026-05)
19
+ //
20
+ // ============================================================
21
+ // Endpoint inventory (verified 2026-05):
22
+ //
23
+ // POST https://zhipu-ai.jobs.feishu.cn/api/v1/search/job/posts
24
+ // Payload: { keyword, limit, offset, portal_type:3, portal_entrance:1, language:"zh",
25
+ // recruitment_id_list?, job_category_id_list?, location_code_list? }
26
+ // Headers: portal-channel: index, portal-platform: pc, website-path: index
27
+ // Response: { code:0, data:{ job_post_list:[...], count:<int> }, message:"ok" }
28
+ //
29
+ // GET https://zhipu-ai.jobs.feishu.cn/api/v1/config/job/filters/index
30
+ // Returns filter taxonomy: job_type_list (5 categories), city_list (6 cities),
31
+ // recruitment_type_list (only "1" = 社招)
32
+ //
33
+ // ============================================================
34
+ // Filter taxonomy (probed 2026-05):
35
+ //
36
+ // DIMENSION 1 — job_type_list / job_category_id_list (职位类别)
37
+ // 研发 id: 6791702736615426317
38
+ // 产品 / 策划 / 项目 id: 6791702736615409933
39
+ // 销售 id: 6791702736615360781
40
+ // 设计 id: 6791702736615344397
41
+ // 市场 id: 6791702736615377165
42
+ //
43
+ // DIMENSION 2 — city_list / location_code_list (城市)
44
+ // 北京 CT_11 上海 CT_125 深圳 CT_128
45
+ // 杭州 CT_52 成都 CT_22
46
+ // (+ additional cities may appear in posts not listed in filters)
47
+ //
48
+ // DIMENSION 3 — recruitment_type_list
49
+ // "1" = 社招 (experienced hire) — the only portal available
50
+ // child "101" = 全职 (full-time), "102" = 实习 (intern if exists)
51
+ //
52
+ // ============================================================
53
+ // ---- PositionSummary field mapping (Zhipu → canonical) ----
54
+ // post_id ← String(item.id)
55
+ // title ← item.title
56
+ // project ← item.job_category.name (job_function.name as fallback)
57
+ // recruit_label ← item.recruit_type.name (e.g. "全职")
58
+ // bgs ← "" (not exposed in public search)
59
+ // work_cities ← city_list joined " / " (city_info used as fallback)
60
+ // apply_url ← https://zhipu-ai.jobs.feishu.cn/index/position/${id}/detail
61
+ import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
62
+ export { checkResume };
63
+ const HOST = "zhipu-ai.jobs.feishu.cn";
64
+ const CHANNEL = "index";
65
+ const API_ROOT = `https://${HOST}/api/v1`;
66
+ const POSITION_PAGE = `https://${HOST}/${CHANNEL}/position`;
67
+ const DETAIL_PAGE = (id) => `https://${HOST}/${CHANNEL}/position/${encodeURIComponent(id)}/detail`;
68
+ const SOURCE = HOST;
69
+ const DEFAULT_HEADERS = {
70
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
71
+ Accept: "application/json, text/plain, */*",
72
+ "Content-Type": "application/json",
73
+ "portal-channel": CHANNEL,
74
+ "portal-platform": "pc",
75
+ "website-path": CHANNEL,
76
+ Referer: POSITION_PAGE,
77
+ };
78
+ async function call(path, body) {
79
+ const url = `${API_ROOT}${path}`;
80
+ let response;
81
+ try {
82
+ response = await fetch(url, {
83
+ method: "POST",
84
+ headers: DEFAULT_HEADERS,
85
+ body: JSON.stringify(body),
86
+ });
87
+ }
88
+ catch (err) {
89
+ return {
90
+ ok: false,
91
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
92
+ };
93
+ }
94
+ if (!response.ok) {
95
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
96
+ }
97
+ let payload;
98
+ try {
99
+ payload = (await response.json());
100
+ }
101
+ catch (err) {
102
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
103
+ }
104
+ return {
105
+ ok: payload.code === 0,
106
+ data: payload.data,
107
+ message: payload.message || (payload.code === 0 ? "ok" : "upstream error"),
108
+ };
109
+ }
110
+ function summarizePosition(item) {
111
+ const id = String(item.id ?? "");
112
+ const cityList = item.city_list ?? [];
113
+ let work_cities;
114
+ if (cityList.length > 1) {
115
+ work_cities = cityList.map((c) => c.name ?? "").filter(Boolean).join(" / ");
116
+ }
117
+ else {
118
+ work_cities = cityList[0]?.name ?? item.city_info?.name ?? "";
119
+ }
120
+ // ZhipuAI: job_category is usually populated; job_function is null in public search
121
+ const project = item.job_category?.name ?? item.job_function?.name ?? "";
122
+ return {
123
+ post_id: id,
124
+ title: item.title ?? "",
125
+ project,
126
+ recruit_label: item.recruit_type?.name ?? "",
127
+ bgs: "",
128
+ work_cities,
129
+ apply_url: id ? DETAIL_PAGE(id) : POSITION_PAGE,
130
+ };
131
+ }
132
+ // ---------- searchPositions ----------
133
+ export async function searchPositions(opts = {}) {
134
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
135
+ const page = Math.max(1, opts.page ?? 1);
136
+ const offset = (page - 1) * pageSize;
137
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60);
138
+ const asStringList = (v) => {
139
+ if (v === undefined)
140
+ return undefined;
141
+ const arr = Array.isArray(v) ? v : [v];
142
+ return arr.map(String);
143
+ };
144
+ const payload = {
145
+ keyword,
146
+ limit: pageSize,
147
+ offset,
148
+ portal_type: 3,
149
+ portal_entrance: 1,
150
+ language: "zh",
151
+ };
152
+ const recruitmentIdList = asStringList(opts.recruitmentIdList);
153
+ if (recruitmentIdList?.length) {
154
+ payload.recruitment_id_list = recruitmentIdList;
155
+ }
156
+ const jobCategoryIdList = asStringList(opts.jobCategoryIdList);
157
+ if (jobCategoryIdList?.length) {
158
+ payload.job_category_id_list = jobCategoryIdList;
159
+ }
160
+ const cityIdList = asStringList(opts.cityIdList);
161
+ if (cityIdList?.length) {
162
+ payload.location_code_list = cityIdList;
163
+ }
164
+ const response = await call("/search/job/posts", payload);
165
+ if (!response.ok || !response.data) {
166
+ return {
167
+ ok: false,
168
+ message: response.message,
169
+ source: SOURCE,
170
+ query: payload,
171
+ positions: [],
172
+ };
173
+ }
174
+ const rows = response.data.job_post_list ?? [];
175
+ return {
176
+ ok: true,
177
+ source: SOURCE,
178
+ query: payload,
179
+ page,
180
+ page_size: pageSize,
181
+ total: response.data.count ?? rows.length,
182
+ positions: rows.map(summarizePosition),
183
+ };
184
+ }
185
+ // ---------- fetchAllPositions ----------
186
+ export async function fetchAllPositions(opts = {}) {
187
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 100));
188
+ const maxPages = Math.max(1, opts.maxPages ?? 5);
189
+ const bucket = [];
190
+ let total;
191
+ for (let page = 1; page <= maxPages; page++) {
192
+ const result = await searchPositions({ ...opts, page, pageSize });
193
+ if (!result.ok) {
194
+ return {
195
+ ok: false,
196
+ message: result.message,
197
+ source: SOURCE,
198
+ fetched: bucket.length,
199
+ positions: bucket,
200
+ };
201
+ }
202
+ if (total === undefined)
203
+ total = result.total;
204
+ if (!result.positions.length)
205
+ break;
206
+ bucket.push(...result.positions);
207
+ if (total !== undefined && bucket.length >= total)
208
+ break;
209
+ }
210
+ return {
211
+ ok: true,
212
+ source: SOURCE,
213
+ total: total ?? bucket.length,
214
+ fetched: bucket.length,
215
+ positions: bucket,
216
+ };
217
+ }
218
+ // ---------- fetchPositionDetail ----------
219
+ // Zhipu/Feishu has no public per-post detail REST endpoint.
220
+ // Paginate the search and filter by id.
221
+ export async function fetchPositionDetail(postId) {
222
+ const id = (postId ?? "").trim();
223
+ if (!id)
224
+ return { ok: false, source: SOURCE, message: "post_id is required" };
225
+ const pageSize = 100;
226
+ const maxPages = 5;
227
+ for (let page = 1; page <= maxPages; page++) {
228
+ const offset = (page - 1) * pageSize;
229
+ const payload = {
230
+ keyword: "",
231
+ limit: pageSize,
232
+ offset,
233
+ portal_type: 3,
234
+ portal_entrance: 1,
235
+ language: "zh",
236
+ };
237
+ const response = await call("/search/job/posts", payload);
238
+ if (!response.ok || !response.data)
239
+ break;
240
+ const posts = response.data.job_post_list ?? [];
241
+ const found = posts.find((p) => String(p.id) === id);
242
+ if (found) {
243
+ const summary = summarizePosition(found);
244
+ return {
245
+ ok: true,
246
+ source: SOURCE,
247
+ post_id: id,
248
+ title: found.title ?? "",
249
+ direction: found.sub_title ?? "",
250
+ description: found.description ?? "",
251
+ requirements: found.requirement ?? "",
252
+ work_cities: found.city_list ?? (found.city_info ? [found.city_info] : []),
253
+ apply_url: summary.apply_url,
254
+ };
255
+ }
256
+ if (posts.length < pageSize)
257
+ break;
258
+ }
259
+ return {
260
+ ok: false,
261
+ source: SOURCE,
262
+ post_id: id,
263
+ message: `post ${id} not found in public search results (searched up to ${maxPages * pageSize} posts)`,
264
+ };
265
+ }
266
+ let _filterCache = null;
267
+ export async function fetchDictionaries() {
268
+ if (_filterCache !== null)
269
+ return _filterCache;
270
+ const url = `${API_ROOT}/config/job/filters/${CHANNEL}`;
271
+ let response;
272
+ try {
273
+ response = await fetch(url, { headers: DEFAULT_HEADERS });
274
+ }
275
+ catch (err) {
276
+ const r = {
277
+ ok: false,
278
+ source: SOURCE,
279
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
280
+ };
281
+ _filterCache = r;
282
+ return r;
283
+ }
284
+ if (!response.ok) {
285
+ const r = {
286
+ ok: false,
287
+ source: SOURCE,
288
+ message: `HTTP ${response.status}`,
289
+ };
290
+ _filterCache = r;
291
+ return r;
292
+ }
293
+ let payload;
294
+ try {
295
+ payload = await response.json();
296
+ }
297
+ catch (err) {
298
+ const r = {
299
+ ok: false,
300
+ source: SOURCE,
301
+ message: `bad JSON: ${err instanceof Error ? err.message : String(err)}`,
302
+ };
303
+ _filterCache = r;
304
+ return r;
305
+ }
306
+ if (payload.code !== 0 || !payload.data) {
307
+ const r = {
308
+ ok: false,
309
+ source: SOURCE,
310
+ message: payload.message ?? "upstream error",
311
+ };
312
+ _filterCache = r;
313
+ return r;
314
+ }
315
+ const d = payload.data;
316
+ const jobCategories = (d.job_type_list ?? []).map((cat) => ({
317
+ id: cat.id ?? "",
318
+ name: cat.name ?? "",
319
+ en_name: cat.en_name ?? "",
320
+ depth: cat.depth ?? 1,
321
+ parent_id: cat.parent?.id ?? null,
322
+ }));
323
+ const cities = (d.city_list ?? []).map((c) => ({
324
+ code: c.code ?? "",
325
+ name: c.name ?? "",
326
+ en_name: c.en_name ?? "",
327
+ }));
328
+ // Flatten recruitment_type_list (parent + children)
329
+ const recruitmentTypes = [];
330
+ const seen = new Set();
331
+ const walkRT = (items) => {
332
+ for (const rt of items ?? []) {
333
+ if (rt.id && !seen.has(rt.id)) {
334
+ seen.add(rt.id);
335
+ recruitmentTypes.push({
336
+ id: rt.id,
337
+ name: rt.name ?? "",
338
+ en_name: rt.en_name ?? "",
339
+ });
340
+ }
341
+ if (rt.children?.length)
342
+ walkRT(rt.children);
343
+ }
344
+ };
345
+ walkRT(d.recruitment_type_list);
346
+ // Fallback: known static values when API omits them
347
+ if (!recruitmentTypes.length) {
348
+ recruitmentTypes.push({ id: "1", name: "社招", en_name: "Experienced" }, { id: "101", name: "全职", en_name: "Full-time" });
349
+ }
350
+ const result = {
351
+ ok: true,
352
+ source: SOURCE,
353
+ jobCategories,
354
+ cities,
355
+ recruitmentTypes,
356
+ };
357
+ _filterCache = result;
358
+ return result;
359
+ }
360
+ // ---------- stub notices ----------
361
+ const STUB_NOTICES = {
362
+ ok: false,
363
+ source: SOURCE,
364
+ message: "ZhipuAI: no public notices endpoint",
365
+ };
366
+ export async function listNotices() {
367
+ return STUB_NOTICES;
368
+ }
369
+ export async function getNotice(_id) {
370
+ return {
371
+ ok: false,
372
+ source: SOURCE,
373
+ message: "ZhipuAI: no public notices endpoint",
374
+ };
375
+ }
376
+ export async function findNoticesByQuestion(_question, _opts = {}) {
377
+ return {
378
+ ok: false,
379
+ source: SOURCE,
380
+ message: "ZhipuAI: no public notices endpoint",
381
+ };
382
+ }
383
+ // ---------- matchResume ----------
384
+ export async function matchResume(text, opts = {}) {
385
+ const topN = Math.max(1, opts.topN ?? 5);
386
+ const candidates = Math.max(topN, opts.candidates ?? 20);
387
+ const { terms, cities } = extractResumeSignals(text ?? "");
388
+ if (!terms.length) {
389
+ return {
390
+ ok: false,
391
+ source: SOURCE,
392
+ message: "could not extract any technical signals from the text",
393
+ preview: (text ?? "").slice(0, 120),
394
+ };
395
+ }
396
+ const keyword = terms.slice(0, 3).join(" ");
397
+ const list = await searchPositions({ keyword, page: 1, pageSize: 100 });
398
+ if (!list.ok) {
399
+ return { ok: false, source: SOURCE, message: list.message, positions: [] };
400
+ }
401
+ // Re-fetch raw posts to access description + requirement fields for scoring
402
+ const payload = {
403
+ keyword,
404
+ limit: 100,
405
+ offset: 0,
406
+ portal_type: 3,
407
+ portal_entrance: 1,
408
+ language: "zh",
409
+ };
410
+ const raw = await call("/search/job/posts", payload);
411
+ const rawPosts = raw.ok ? (raw.data?.job_post_list ?? []) : [];
412
+ const rawById = new Map();
413
+ for (const p of rawPosts) {
414
+ rawById.set(String(p.id ?? ""), p);
415
+ }
416
+ const scored = [];
417
+ for (const p of list.positions) {
418
+ const rp = rawById.get(p.post_id);
419
+ const blob = [
420
+ p.title,
421
+ p.project,
422
+ p.recruit_label,
423
+ p.work_cities,
424
+ rp?.description ?? "",
425
+ rp?.requirement ?? "",
426
+ ].join(" ");
427
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
428
+ if (score > 0) {
429
+ scored.push({
430
+ score,
431
+ position: p,
432
+ reasons,
433
+ description: rp?.description,
434
+ requirements: rp?.requirement,
435
+ });
436
+ }
437
+ }
438
+ scored.sort((a, b) => b.score - a.score);
439
+ let shortlist = scored.slice(0, Math.max(topN, candidates));
440
+ if (!shortlist.length) {
441
+ shortlist = list.positions.slice(0, candidates).map((position) => ({
442
+ score: 0,
443
+ position,
444
+ reasons: [],
445
+ description: rawById.get(position.post_id)?.description,
446
+ requirements: rawById.get(position.post_id)?.requirement,
447
+ }));
448
+ }
449
+ const matches = shortlist.slice(0, topN).map((s) => {
450
+ const mr = s.reasons.length > 0
451
+ ? s.reasons.slice(0, 5)
452
+ : ["no specific keyword overlap — surfaced from initial keyword search"];
453
+ return {
454
+ ...s.position,
455
+ description: s.description,
456
+ requirements: s.requirements,
457
+ match_reasons: mr,
458
+ };
459
+ });
460
+ return {
461
+ ok: true,
462
+ source: SOURCE,
463
+ extracted_terms: terms,
464
+ city_preferences: cities,
465
+ matches,
466
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
467
+ "The only authority on selection is HR.",
468
+ };
469
+ }
470
+ // ---------- Phase 2: fetchApplicationSchema ----------
471
+ import { makeFeishuApplyFn } from "./feishu.js";
472
+ export const fetchApplicationSchema = makeFeishuApplyFn({
473
+ host: "zhipu-ai.jobs.feishu.cn",
474
+ source: "zhipu-ai.jobs.feishu.cn",
475
+ channel: "index",
476
+ applyUrlPrefix: "https://zhipu-ai.jobs.feishu.cn/index/position",
477
+ fetchTitle: (id) => fetchPositionDetail(id),
478
+ });
@@ -0,0 +1,79 @@
1
+ # job-pro session bridge — Chrome extension
2
+
3
+ Manifest v3 extension that captures careers-site session cookies + CSRF/XSRF
4
+ headers for use by the CLI's auto-apply (Phase 2.1). Unlike the simple
5
+ Greenhouse / Lever boards (where the apply form is open-access and the CLI
6
+ can submit anonymously via `--debug-submit-to`), most Chinese ATS tenants
7
+ gate `apply` behind a logged-in candidate session. This extension lets
8
+ users log in once in their normal browser, then export the captured
9
+ session into `~/.jobpro/<adapter>.session.json` for the CLI to re-use.
10
+
11
+ ## Install (developer mode, local)
12
+
13
+ ```bash
14
+ # Repo root
15
+ cd extension/
16
+ # Optional: generate placeholder icons (just colored squares).
17
+ # The extension still loads without them; popup just won't have an icon.
18
+ ```
19
+
20
+ 1. Open Chrome → `chrome://extensions/` → enable **Developer mode**.
21
+ 2. Click **Load unpacked** → select the `extension/` directory.
22
+ 3. Pin the puzzle-piece icon to the toolbar for quick access.
23
+
24
+ ## Capture a session
25
+
26
+ 1. Log into any supported careers site (e.g. `talent.antgroup.com`,
27
+ `iflytek.zhiye.com`, `app.mokahr.com/.../<org>/<siteId>`).
28
+ 2. Browse around — view a job, open the apply modal — so the SPA fires
29
+ its auth-bearing XHRs. The extension listens for `Cookie`,
30
+ `X-Xsrf-Token`, `Authorization`, and Feishu/Beisen-style headers
31
+ (`X-Fscp-Std-Info`, `langtype`, etc.) and caches them by adapter key.
32
+ 3. Click the toolbar icon → **Export** on the captured row. The
33
+ extension downloads `jobpro/<adapter>.session.json` via Chrome's
34
+ download manager.
35
+ 4. Move the file:
36
+ ```bash
37
+ mkdir -p ~/.jobpro
38
+ mv ~/Downloads/jobpro/<adapter>.session.json ~/.jobpro/
39
+ ```
40
+
41
+ ## What's in the JSON
42
+
43
+ ```json
44
+ {
45
+ "adapter": "antgroup",
46
+ "host": "talent.antgroup.com",
47
+ "exported_at": "2026-05-16T08:00:00.000Z",
48
+ "headers": {
49
+ "x-xsrf-token": "VSQK2wSZQC-DRAZxaQevxQ",
50
+ "x-fscp-std-info": "{\"client_id\": \"40108\"}",
51
+ "cookie": "<full cookie header>",
52
+ "...": "..."
53
+ },
54
+ "cookies": [
55
+ { "name": "XSRF-TOKEN", "value": "…", "domain": ".liepin.com", "path": "/", … }
56
+ ]
57
+ }
58
+ ```
59
+
60
+ The CLI doesn't read this file yet — Phase 2.1 wires that in.
61
+ Today the file is the deliverable; future iterations land the
62
+ `<adapter>.applyWithSession(sessionPath, postId)` flow.
63
+
64
+ ## Why MV3, not a content script injection
65
+
66
+ We need `chrome.cookies` to dump HttpOnly cookies (used by every Chinese
67
+ ATS we've probed). Only background service workers can call
68
+ `chrome.cookies.getAll()`. The popup just talks to the worker via
69
+ `chrome.runtime.sendMessage`.
70
+
71
+ ## Scope (privacy)
72
+
73
+ * Only captures headers from hosts explicitly listed in
74
+ `manifest.json#host_permissions`. Browsing anywhere else is invisible
75
+ to the extension.
76
+ * Storage is `chrome.storage.local` — never synced, never sent to any
77
+ remote.
78
+ * Exports are user-triggered downloads to `~/Downloads/jobpro/`. No
79
+ network egress.