job-pro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { fetchDictionaries, searchPositions, fetchPositionDetail, fetchAllPositions, listNotices, getNotice, findNoticesByQuestion, matchResume, checkResume, } from "./tencent.js";
4
+ import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
5
+ const VERSION = "0.1.0";
6
+ const HELP = `
7
+ job-pro — query Chinese big-tech campus recruiting from your terminal
8
+ (job.ha7ch.com)
9
+
10
+ USAGE
11
+ job-pro <company> <verb> [options]
12
+ job-pro --version
13
+ job-pro help
14
+
15
+ COMPANIES (v0.1)
16
+ tencent join.qq.com (Tencent / 腾讯)
17
+
18
+ VERBS (for tencent)
19
+ search <kw> search openings (kw is free text, <=30 chars)
20
+ detail <post_id> show full JD for one job
21
+ all [<kw>] paginate every job (filter by kw if given)
22
+ dicts dump filter dictionaries (BG, city, family…)
23
+ notices list official announcements
24
+ notice <id> show one announcement's full content
25
+ flow <question> answer a question using best-matching notices
26
+ match <resume-text-or--> rank jobs by overlap with resume text
27
+ pass "-" to read resume from stdin
28
+ resume-check <resume-text-or--> structural sanity check on a resume
29
+ memory list | get <k> | set k=v | event <kind> [payload] | clear
30
+
31
+ OUTPUT
32
+ Add --compact for one-line JSON (good for piping to jq / claude).
33
+
34
+ EXAMPLES
35
+ job-pro tencent search "后台开发" --page-size 5
36
+ job-pro tencent detail 1200791473415778304
37
+ job-pro tencent notices
38
+ job-pro tencent flow "腾讯2026实习什么时候开始投递" --question-time 2026-05-13
39
+ cat my-resume.md | job-pro tencent match -
40
+ job-pro tencent memory set "stack=Go,Python" "target_city=深圳"
41
+ job-pro tencent memory event applied "腾讯后台 1200791473415778304"
42
+
43
+ DOCS
44
+ https://job.ha7ch.com
45
+ https://github.com/HA7CH/job-pro
46
+ `.trim();
47
+ function die(msg) {
48
+ console.error(`Error: ${msg}`);
49
+ process.exit(1);
50
+ }
51
+ function popCompactFlag(args) {
52
+ const compact = args.includes("--compact");
53
+ return { args: args.filter((a) => a !== "--compact"), compact };
54
+ }
55
+ function popFlagValue(args, name) {
56
+ const out = [...args];
57
+ const i = out.indexOf(name);
58
+ if (i === -1)
59
+ return { args: out, value: undefined };
60
+ const value = out[i + 1];
61
+ out.splice(i, 2);
62
+ return { args: out, value };
63
+ }
64
+ function emit(result, compact) {
65
+ const text = compact
66
+ ? JSON.stringify(result)
67
+ : JSON.stringify(result, null, 2);
68
+ console.log(text);
69
+ const ok = typeof result === "object" && result !== null && "ok" in result
70
+ ? Boolean(result.ok)
71
+ : true;
72
+ process.exit(ok ? 0 : 1);
73
+ }
74
+ function readResumeArg(arg) {
75
+ if (!arg)
76
+ die("expected resume text or '-' for stdin");
77
+ if (arg === "-") {
78
+ try {
79
+ return readFileSync(0, "utf8");
80
+ }
81
+ catch {
82
+ die("could not read resume text from stdin");
83
+ }
84
+ }
85
+ // if it looks like a file path that exists, read it; otherwise treat as
86
+ // the resume text itself
87
+ try {
88
+ return readFileSync(arg, "utf8");
89
+ }
90
+ catch {
91
+ return arg;
92
+ }
93
+ }
94
+ async function runTencent(rawArgs) {
95
+ const [verb, ...rest] = rawArgs;
96
+ if (!verb)
97
+ die("expected a verb. Try `job-pro help`.");
98
+ const { args, compact } = popCompactFlag(rest);
99
+ if (verb === "search") {
100
+ let { args: a, value: page } = popFlagValue(args, "--page");
101
+ let { args: a2, value: pageSize } = popFlagValue(a, "--page-size");
102
+ const keyword = a2.join(" ").trim();
103
+ return emit(await searchPositions({
104
+ keyword,
105
+ page: page ? Number(page) : undefined,
106
+ pageSize: pageSize ? Number(pageSize) : undefined,
107
+ }), compact);
108
+ }
109
+ if (verb === "detail") {
110
+ const postId = args[0];
111
+ if (!postId)
112
+ die("usage: job-pro tencent detail <post_id>");
113
+ return emit(await fetchPositionDetail(postId), compact);
114
+ }
115
+ if (verb === "all") {
116
+ let { args: a, value: maxPages } = popFlagValue(args, "--max-pages");
117
+ let { args: a2, value: pageSize } = popFlagValue(a, "--page-size");
118
+ const keyword = a2.join(" ").trim();
119
+ return emit(await fetchAllPositions({
120
+ keyword,
121
+ maxPages: maxPages ? Number(maxPages) : undefined,
122
+ pageSize: pageSize ? Number(pageSize) : undefined,
123
+ }), compact);
124
+ }
125
+ if (verb === "dicts") {
126
+ return emit(await fetchDictionaries(), compact);
127
+ }
128
+ if (verb === "notices") {
129
+ return emit(await listNotices(), compact);
130
+ }
131
+ if (verb === "notice") {
132
+ const id = args[0];
133
+ if (!id)
134
+ die("usage: job-pro tencent notice <id>");
135
+ return emit(await getNotice(id), compact);
136
+ }
137
+ if (verb === "flow") {
138
+ const { args: a, value: questionTime } = popFlagValue(args, "--question-time");
139
+ const { args: a2, value: topK } = popFlagValue(a, "--top-k");
140
+ const question = a2.join(" ").trim();
141
+ if (!question)
142
+ die("usage: job-pro tencent flow <question> [--question-time YYYY-MM-DD] [--top-k N]");
143
+ return emit(await findNoticesByQuestion(question, {
144
+ questionTime,
145
+ topK: topK ? Number(topK) : undefined,
146
+ }), compact);
147
+ }
148
+ if (verb === "match") {
149
+ const { args: a, value: topN } = popFlagValue(args, "--top-n");
150
+ const { args: a2, value: candidates } = popFlagValue(a, "--candidates");
151
+ const text = readResumeArg(a2[0]);
152
+ return emit(await matchResume(text, {
153
+ topN: topN ? Number(topN) : undefined,
154
+ candidates: candidates ? Number(candidates) : undefined,
155
+ }), compact);
156
+ }
157
+ if (verb === "resume-check") {
158
+ const text = readResumeArg(args[0]);
159
+ return emit(checkResume(text), compact);
160
+ }
161
+ if (verb === "memory") {
162
+ const [sub, ...subArgs] = args;
163
+ if (!sub)
164
+ die("usage: job-pro tencent memory <list|get|set|event|clear>");
165
+ if (sub === "list")
166
+ return emit(memoryList(), compact);
167
+ if (sub === "get") {
168
+ const key = subArgs[0];
169
+ if (!key)
170
+ die("usage: job-pro tencent memory get <key>");
171
+ return emit(memoryGet(key), compact);
172
+ }
173
+ if (sub === "set") {
174
+ return emit(memorySet(subArgs), compact);
175
+ }
176
+ if (sub === "event") {
177
+ const [kind, ...payload] = subArgs;
178
+ return emit(memoryEvent(kind, payload.join(" ")), compact);
179
+ }
180
+ if (sub === "clear")
181
+ return emit(memoryClear(), compact);
182
+ die(`unknown memory subcommand: ${sub}`);
183
+ }
184
+ die(`unknown verb: ${verb}. Try \`job-pro help\`.`);
185
+ }
186
+ async function main() {
187
+ const args = process.argv.slice(2);
188
+ const cmd = args[0];
189
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
190
+ console.log(HELP);
191
+ return;
192
+ }
193
+ if (cmd === "--version" || cmd === "-v") {
194
+ console.log(VERSION);
195
+ return;
196
+ }
197
+ if (cmd === "tencent") {
198
+ await runTencent(args.slice(1));
199
+ return;
200
+ }
201
+ die(`unknown company: ${cmd}. Supported in v0.1: tencent. Try \`job-pro help\`.`);
202
+ }
203
+ main().catch((err) => {
204
+ console.error("Error:", err instanceof Error ? err.message : err);
205
+ process.exit(1);
206
+ });
package/dist/memory.js ADDED
@@ -0,0 +1,76 @@
1
+ // Tiny JSON-backed key/value + event log, stored at $JOBPRO_HOME/tencent-memory.json
2
+ // (defaults to ~/.jobpro/). Intentionally schema-loose so future companies
3
+ // can share the same file with their own namespaced keys.
4
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ function memoryPath() {
8
+ const base = process.env.JOBPRO_HOME ?? join(homedir(), ".jobpro");
9
+ mkdirSync(base, { recursive: true });
10
+ return join(base, "tencent-memory.json");
11
+ }
12
+ function load() {
13
+ const path = memoryPath();
14
+ if (!existsSync(path))
15
+ return { fields: {}, events: [] };
16
+ try {
17
+ const raw = JSON.parse(readFileSync(path, "utf8"));
18
+ return {
19
+ fields: raw.fields ?? {},
20
+ events: raw.events ?? [],
21
+ };
22
+ }
23
+ catch {
24
+ return { fields: {}, events: [] };
25
+ }
26
+ }
27
+ function save(data) {
28
+ writeFileSync(memoryPath(), JSON.stringify(data, null, 2), "utf8");
29
+ }
30
+ export function memoryList() {
31
+ return { ok: true, path: memoryPath(), ...load() };
32
+ }
33
+ export function memoryGet(key) {
34
+ return { ok: true, key, value: load().fields[key] };
35
+ }
36
+ export function memorySet(pairs) {
37
+ if (!pairs.length) {
38
+ return { ok: false, message: "no key=value pairs provided" };
39
+ }
40
+ const data = load();
41
+ const applied = {};
42
+ for (const pair of pairs) {
43
+ const idx = pair.indexOf("=");
44
+ if (idx <= 0) {
45
+ return { ok: false, message: `expected key=value, got: ${JSON.stringify(pair)}` };
46
+ }
47
+ const key = pair.slice(0, idx).trim();
48
+ const value = pair.slice(idx + 1);
49
+ if (!key) {
50
+ return { ok: false, message: `empty key in ${JSON.stringify(pair)}` };
51
+ }
52
+ data.fields[key] = value;
53
+ applied[key] = value;
54
+ }
55
+ save(data);
56
+ return { ok: true, applied, path: memoryPath() };
57
+ }
58
+ export function memoryEvent(kind, payload = "") {
59
+ if (!kind)
60
+ return { ok: false, message: "event kind is required" };
61
+ const data = load();
62
+ const entry = {
63
+ ts: new Date().toISOString().slice(0, 19),
64
+ kind,
65
+ payload,
66
+ };
67
+ data.events.push(entry);
68
+ save(data);
69
+ return { ok: true, event: entry, total_events: data.events.length };
70
+ }
71
+ export function memoryClear() {
72
+ const path = memoryPath();
73
+ if (existsSync(path))
74
+ rmSync(path);
75
+ return { ok: true, path, message: "memory cleared" };
76
+ }
@@ -0,0 +1,606 @@
1
+ // Thin client for Tencent's public campus-recruiting API at join.qq.com.
2
+ //
3
+ // All endpoints are unauthenticated; the server just checks Referer/Origin
4
+ // to discourage cross-site embedding. Endpoint inventory:
5
+ //
6
+ // GET /api/v1/position/getAllProject
7
+ // GET /api/v1/position/getPositionFamily?lang=zh-cn
8
+ // GET /api/v1/position/getPositionWorkCities?lang=zh-cn
9
+ // GET /api/v1/position/getRecruitCity?lang=zh-cn
10
+ // GET /api/v1/dictionary/?types=RecruitType,BusinessGroup,RecruitProjectPostList
11
+ // POST /api/v1/position/searchPosition
12
+ // GET /api/v1/jobDetails/getJobDetailsByPostId?postId=<id>
13
+ // GET /api/v1/noticeDynamic/getNoticeDynamicList
14
+ // GET /api/v1/noticeDynamic/getNoticeDynamicById?id=<id>
15
+ const API_ROOT = "https://join.qq.com/api/v1";
16
+ const POSTS_PAGE = "https://join.qq.com/post.html";
17
+ const NOTICE_PAGE = "https://join.qq.com/notice.html";
18
+ const DETAIL_PAGE = (postId) => `https://join.qq.com/post_detail.html?postid=${encodeURIComponent(postId)}`;
19
+ const DEFAULT_HEADERS = {
20
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
21
+ Accept: "application/json, text/plain, */*",
22
+ Origin: "https://join.qq.com",
23
+ };
24
+ async function call(method, path, opts = {}) {
25
+ const sep = path.includes("?") ? "&" : "?";
26
+ const url = `${API_ROOT}${path}${sep}timestamp=${Date.now()}`;
27
+ const headers = {
28
+ ...DEFAULT_HEADERS,
29
+ Referer: opts.referer ?? POSTS_PAGE,
30
+ };
31
+ let body;
32
+ if (opts.body !== undefined) {
33
+ body = JSON.stringify(opts.body);
34
+ headers["Content-Type"] = "application/json;charset=UTF-8";
35
+ }
36
+ let response;
37
+ try {
38
+ response = await fetch(url, { method, headers, body });
39
+ }
40
+ catch (err) {
41
+ return {
42
+ ok: false,
43
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
44
+ };
45
+ }
46
+ if (!response.ok) {
47
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
48
+ }
49
+ let payload;
50
+ try {
51
+ payload = (await response.json());
52
+ }
53
+ catch (err) {
54
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
55
+ }
56
+ return {
57
+ ok: payload.status === 0,
58
+ data: payload.data,
59
+ message: payload.message || (payload.status === 0 ? "ok" : "upstream error"),
60
+ };
61
+ }
62
+ // ---------- dictionaries ----------
63
+ export async function fetchDictionaries() {
64
+ const [projects, families, workCities, recruitCities, shared] = await Promise.all([
65
+ call("GET", "/position/getAllProject"),
66
+ call("GET", "/position/getPositionFamily?lang=zh-cn"),
67
+ call("GET", "/position/getPositionWorkCities?lang=zh-cn"),
68
+ call("GET", "/position/getRecruitCity?lang=zh-cn"),
69
+ call("GET", "/dictionary/?types=RecruitType,BusinessGroup,RecruitProjectPostList"),
70
+ ]);
71
+ return {
72
+ ok: [projects, families, workCities, recruitCities, shared].every((r) => r.ok),
73
+ source: "join.qq.com",
74
+ projects: projects.data,
75
+ position_families: families.data,
76
+ work_cities: workCities.data,
77
+ recruit_cities: recruitCities.data,
78
+ shared: shared.data,
79
+ };
80
+ }
81
+ export async function collectAllProjectIds() {
82
+ const response = await call("GET", "/position/getAllProject");
83
+ if (!response.ok || !response.data)
84
+ return [];
85
+ const leaves = [];
86
+ const walk = (nodes) => {
87
+ for (const node of nodes) {
88
+ const kids = node.subDictionary ?? [];
89
+ if (kids.length) {
90
+ walk(kids);
91
+ }
92
+ else if (node.code !== undefined) {
93
+ const id = Number(node.code);
94
+ if (!Number.isNaN(id))
95
+ leaves.push(id);
96
+ }
97
+ }
98
+ };
99
+ walk(response.data);
100
+ return [...new Set(leaves)].sort((a, b) => a - b);
101
+ }
102
+ function summarizePosition(item) {
103
+ const postId = String(item.postId ?? "");
104
+ return {
105
+ post_id: postId,
106
+ title: item.positionTitle ?? "",
107
+ project: item.projectName ?? "",
108
+ recruit_label: item.recruitLabelName ?? "",
109
+ bgs: (item.bgs ?? "").trim(),
110
+ work_cities: (item.workCities ?? "").trim(),
111
+ apply_url: postId ? DETAIL_PAGE(postId) : POSTS_PAGE,
112
+ };
113
+ }
114
+ export async function searchPositions(opts = {}) {
115
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
116
+ const page = Math.max(1, opts.page ?? 1);
117
+ const projectIds = opts.projectIds ?? (await collectAllProjectIds());
118
+ const body = {
119
+ projectIdList: projectIds,
120
+ keyword: (opts.keyword ?? "").trim().slice(0, 30),
121
+ bgList: [],
122
+ workCountryType: 0,
123
+ workCityList: [],
124
+ recruitCityList: [],
125
+ positionFidList: [],
126
+ pageIndex: page,
127
+ pageSize,
128
+ };
129
+ const response = await call("POST", "/position/searchPosition", { body });
130
+ if (!response.ok || !response.data) {
131
+ return {
132
+ ok: false,
133
+ message: response.message,
134
+ query: body,
135
+ positions: [],
136
+ };
137
+ }
138
+ const rows = response.data.positionList ?? [];
139
+ return {
140
+ ok: true,
141
+ source: "join.qq.com",
142
+ query: body,
143
+ page,
144
+ page_size: pageSize,
145
+ total: response.data.count ?? rows.length,
146
+ positions: rows.map(summarizePosition),
147
+ };
148
+ }
149
+ export async function fetchAllPositions(opts = {}) {
150
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 100));
151
+ const maxPages = Math.max(1, opts.maxPages ?? 20);
152
+ const projectIds = await collectAllProjectIds();
153
+ const bucket = [];
154
+ let total;
155
+ for (let page = 1; page <= maxPages; page++) {
156
+ const result = await searchPositions({
157
+ keyword: opts.keyword,
158
+ projectIds,
159
+ page,
160
+ pageSize,
161
+ });
162
+ if (!result.ok) {
163
+ return { ok: false, message: result.message, fetched: bucket.length, positions: bucket };
164
+ }
165
+ if (total === undefined)
166
+ total = result.total;
167
+ if (!result.positions.length)
168
+ break;
169
+ bucket.push(...result.positions);
170
+ if (total !== undefined && bucket.length >= total)
171
+ break;
172
+ }
173
+ return {
174
+ ok: true,
175
+ source: "join.qq.com",
176
+ total: total ?? bucket.length,
177
+ fetched: bucket.length,
178
+ positions: bucket,
179
+ };
180
+ }
181
+ export async function fetchPositionDetail(postId) {
182
+ const id = (postId ?? "").trim();
183
+ if (!id)
184
+ return { ok: false, message: "post_id is required" };
185
+ const response = await call("GET", `/jobDetails/getJobDetailsByPostId?postId=${encodeURIComponent(id)}`, { referer: DETAIL_PAGE(id) });
186
+ if (!response.ok || !response.data) {
187
+ return { ok: false, message: response.message || "no detail returned", post_id: id };
188
+ }
189
+ const raw = response.data;
190
+ const first = (...keys) => {
191
+ for (const key of keys) {
192
+ const v = raw[key];
193
+ if (typeof v === "string" && v.trim())
194
+ return v.trim();
195
+ }
196
+ return "";
197
+ };
198
+ return {
199
+ ok: true,
200
+ source: "join.qq.com",
201
+ post_id: String(raw.postId ?? id),
202
+ title: raw.title ?? "",
203
+ direction: raw.tidName ?? "",
204
+ project: raw.projectName ?? "",
205
+ recruit_label: raw.recruitLabelName ?? "",
206
+ description: first("desc", "topicDetail", "introduction"),
207
+ requirements: first("request", "topicRequirement"),
208
+ work_cities: raw.workCityList ?? [],
209
+ recruit_cities: raw.recruitCityList ?? [],
210
+ is_qingyun: Boolean(raw.isQingyun),
211
+ apply_url: DETAIL_PAGE(String(raw.postId ?? id)),
212
+ };
213
+ }
214
+ export async function listNotices() {
215
+ const response = await call("GET", "/noticeDynamic/getNoticeDynamicList", { referer: NOTICE_PAGE });
216
+ if (!response.ok)
217
+ return { ok: false, message: response.message, notices: [] };
218
+ const items = response.data?.list ?? [];
219
+ return {
220
+ ok: true,
221
+ source: "join.qq.com",
222
+ count: items.length,
223
+ notices: items.map((n) => ({
224
+ id: n.id,
225
+ title: n.title ?? "",
226
+ publish_time: n.publisheTimeTxt || n.publisheTime || "",
227
+ tag: n.noticeTag ?? "",
228
+ detail_url: `https://join.qq.com/detail.html?id=${n.id}`,
229
+ })),
230
+ };
231
+ }
232
+ export async function getNotice(noticeId) {
233
+ const id = String(noticeId ?? "").trim();
234
+ if (!id)
235
+ return { ok: false, message: "notice_id is required" };
236
+ const response = await call("GET", `/noticeDynamic/getNoticeDynamicById?id=${encodeURIComponent(id)}`, { referer: NOTICE_PAGE });
237
+ if (!response.ok || !response.data) {
238
+ return { ok: false, message: response.message || "no notice returned" };
239
+ }
240
+ const raw = response.data;
241
+ return {
242
+ ok: true,
243
+ source: "join.qq.com",
244
+ id: raw.id ?? Number(id),
245
+ title: raw.title ?? "",
246
+ publish_time: raw.publisheTimeTxt || raw.publisheTime || "",
247
+ tag: raw.noticeTag ?? "",
248
+ content_html: raw.cont ?? "",
249
+ detail_url: `https://join.qq.com/detail.html?id=${raw.id ?? id}`,
250
+ };
251
+ }
252
+ // ---------- flow (question-aware notice retrieval) ----------
253
+ function tokenizeQuestion(text) {
254
+ const out = [];
255
+ const seen = new Set();
256
+ const trimmed = (text ?? "").trim();
257
+ if (!trimmed)
258
+ return out;
259
+ for (const m of trimmed.match(/[A-Za-z0-9]{2,}/g) ?? []) {
260
+ const k = m.toLowerCase();
261
+ if (!seen.has(k)) {
262
+ seen.add(k);
263
+ out.push(k);
264
+ }
265
+ }
266
+ for (const run of trimmed.match(/[一-鿿]+/g) ?? []) {
267
+ for (let i = 0; i < run.length - 1; i++) {
268
+ const bigram = run.slice(i, i + 2);
269
+ if (!seen.has(bigram)) {
270
+ seen.add(bigram);
271
+ out.push(bigram);
272
+ }
273
+ if (out.length >= 40)
274
+ return out;
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+ function parseQuestionTime(value) {
280
+ if (!value)
281
+ return undefined;
282
+ const v = value.trim();
283
+ const candidates = [v, v.replace(" ", "T"), `${v}T00:00:00`];
284
+ for (const candidate of candidates) {
285
+ const ts = Date.parse(candidate);
286
+ if (!Number.isNaN(ts))
287
+ return ts;
288
+ }
289
+ return undefined;
290
+ }
291
+ export async function findNoticesByQuestion(question, opts = {}) {
292
+ const listing = await listNotices();
293
+ if (!listing.ok)
294
+ return { ok: false, message: listing.message, matches: [] };
295
+ const cutoff = parseQuestionTime(opts.questionTime);
296
+ const tokens = tokenizeQuestion(question);
297
+ const topK = Math.max(1, opts.topK ?? 3);
298
+ const scored = [];
299
+ for (const notice of listing.notices) {
300
+ const haystack = `${notice.title} ${notice.tag}`.toLowerCase();
301
+ const hits = tokens.filter((t) => haystack.includes(t)).length;
302
+ if (!hits)
303
+ continue;
304
+ let score = hits * 10;
305
+ const publishedAt = parseQuestionTime(notice.publish_time);
306
+ if (cutoff !== undefined && publishedAt !== undefined) {
307
+ if (publishedAt <= cutoff) {
308
+ const monthsBefore = (cutoff - publishedAt) / (86_400_000 * 30);
309
+ score += Math.max(0, 5 - monthsBefore);
310
+ }
311
+ else {
312
+ score -= 1;
313
+ }
314
+ }
315
+ scored.push({ score, notice });
316
+ }
317
+ scored.sort((a, b) => b.score - a.score);
318
+ const stripHtml = (html) => html
319
+ .replace(/<[^>]+>/g, "")
320
+ .replace(/&nbsp;/g, " ")
321
+ .replace(/&amp;/g, "&")
322
+ .replace(/&lt;/g, "<")
323
+ .replace(/&gt;/g, ">")
324
+ .replace(/&quot;/g, '"')
325
+ .replace(/\s+/g, " ")
326
+ .trim()
327
+ .slice(0, 400);
328
+ const matches = [];
329
+ for (const { notice } of scored.slice(0, topK)) {
330
+ const full = await getNotice(String(notice.id));
331
+ const excerpt = full.ok ? stripHtml(full.content_html ?? "") : "";
332
+ matches.push({ ...notice, excerpt });
333
+ }
334
+ return {
335
+ ok: true,
336
+ source: "join.qq.com",
337
+ question,
338
+ question_time: opts.questionTime,
339
+ matched_tokens: tokens,
340
+ matches,
341
+ };
342
+ }
343
+ // ---------- resume matching ----------
344
+ const TECH_VOCAB = new Set([
345
+ // languages
346
+ "python", "java", "go", "golang", "c", "c++", "cpp", "rust", "kotlin",
347
+ "swift", "scala", "javascript", "typescript", "php", "ruby", "lua",
348
+ // web / mobile
349
+ "react", "vue", "angular", "next", "nuxt", "webpack", "vite", "tailwind",
350
+ "flutter", "android", "ios", "react-native",
351
+ // backend
352
+ "spring", "springboot", "django", "flask", "fastapi", "express", "nestjs",
353
+ "grpc", "rest", "graphql", "websocket",
354
+ // data / db
355
+ "mysql", "postgresql", "postgres", "redis", "mongodb", "kafka",
356
+ "rabbitmq", "elasticsearch", "spark", "hadoop", "flink", "clickhouse",
357
+ "hive", "presto",
358
+ // infra
359
+ "docker", "kubernetes", "k8s", "linux", "aws", "gcp", "azure",
360
+ "terraform", "nginx", "envoy",
361
+ // ml / ai
362
+ "pytorch", "tensorflow", "huggingface", "llm", "rag", "transformer",
363
+ "bert", "gpt", "diffusion", "cv", "nlp", "embedding",
364
+ // chinese stack terms
365
+ "后台", "后端", "前端", "服务端", "客户端", "测试", "运维", "安全", "算法",
366
+ "推荐", "搜索", "大模型", "微服务", "分布式", "高并发", "数据库", "数据",
367
+ "机器学习", "深度学习", "强化学习", "多模态", "计算机视觉", "自然语言",
368
+ ]);
369
+ const CITY_VOCAB = new Set([
370
+ "深圳", "北京", "上海", "广州", "杭州", "成都", "武汉", "南京", "苏州",
371
+ "西安", "合肥", "天津", "厦门", "香港", "remote", "远程",
372
+ ]);
373
+ function extractResumeSignals(text) {
374
+ const lower = (text ?? "").toLowerCase();
375
+ const terms = [];
376
+ const seen = new Set();
377
+ for (const term of TECH_VOCAB) {
378
+ if (lower.includes(term) && !seen.has(term)) {
379
+ terms.push(term);
380
+ seen.add(term);
381
+ }
382
+ }
383
+ // Latin tokens not already captured by the vocab
384
+ for (const tok of text.match(/[A-Za-z][A-Za-z0-9+#.\-]{1,15}/g) ?? []) {
385
+ const norm = tok.toLowerCase();
386
+ if (seen.has(norm) || terms.length >= 30)
387
+ continue;
388
+ if (norm.length < 3)
389
+ continue;
390
+ const stop = new Set(["the", "and", "for", "with", "via", "from", "able", "this", "that", "have"]);
391
+ if (stop.has(norm))
392
+ continue;
393
+ terms.push(tok);
394
+ seen.add(norm);
395
+ }
396
+ const cities = [];
397
+ for (const c of CITY_VOCAB) {
398
+ if (text.includes(c))
399
+ cities.push(c);
400
+ if (cities.length >= 6)
401
+ break;
402
+ }
403
+ return { terms: terms.slice(0, 30), cities };
404
+ }
405
+ function scoreOverlap(haystack, terms, cities) {
406
+ const hay = haystack.toLowerCase();
407
+ let score = 0;
408
+ const reasons = [];
409
+ for (const t of terms) {
410
+ if (!t)
411
+ continue;
412
+ if (hay.includes(t.toLowerCase())) {
413
+ score += t.length > 2 ? 3 : 1;
414
+ if (reasons.length < 4)
415
+ reasons.push(`matched: ${t}`);
416
+ }
417
+ }
418
+ for (const c of cities) {
419
+ if (haystack.includes(c)) {
420
+ score += 2;
421
+ if (reasons.length < 4)
422
+ reasons.push(`city: ${c}`);
423
+ }
424
+ }
425
+ return { score, reasons };
426
+ }
427
+ export async function matchResume(text, opts = {}) {
428
+ const topN = Math.max(1, opts.topN ?? 5);
429
+ const candidates = Math.max(topN, opts.candidates ?? 20);
430
+ const { terms, cities } = extractResumeSignals(text ?? "");
431
+ if (!terms.length) {
432
+ return {
433
+ ok: false,
434
+ message: "could not extract any technical signals from the text",
435
+ preview: (text ?? "").slice(0, 120),
436
+ };
437
+ }
438
+ const keyword = terms.slice(0, 3).join(" ");
439
+ const list = await searchPositions({ keyword, page: 1, pageSize: 100 });
440
+ if (!list.ok)
441
+ return { ok: false, message: list.message, positions: [] };
442
+ const pre = [];
443
+ for (const p of list.positions) {
444
+ const blob = [p.title, p.project, p.recruit_label, p.bgs, p.work_cities].join(" ");
445
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
446
+ if (score > 0)
447
+ pre.push({ score, position: p, reasons });
448
+ }
449
+ pre.sort((a, b) => b.score - a.score);
450
+ let shortlist = pre.slice(0, Math.max(topN, candidates));
451
+ if (!shortlist.length) {
452
+ shortlist = list.positions.slice(0, candidates).map((position) => ({
453
+ score: 0,
454
+ position,
455
+ reasons: [],
456
+ }));
457
+ }
458
+ const enriched = [];
459
+ for (const { score: baseScore, position, reasons: baseReasons } of shortlist.slice(0, candidates)) {
460
+ const detail = await fetchPositionDetail(position.post_id);
461
+ if (!detail.ok)
462
+ continue;
463
+ const jdBlob = [
464
+ detail.title,
465
+ detail.direction,
466
+ detail.description,
467
+ detail.requirements,
468
+ (detail.work_cities ?? []).join(" "),
469
+ ].join(" ");
470
+ const { score: extraScore, reasons: extraReasons } = scoreOverlap(jdBlob, terms, cities);
471
+ const combined = [...new Set([...baseReasons, ...extraReasons])].slice(0, 5);
472
+ if (!combined.length)
473
+ combined.push("no specific keyword overlap — surfaced from initial keyword search");
474
+ enriched.push({
475
+ score: baseScore + extraScore,
476
+ row: {
477
+ ...position,
478
+ title_detail: detail.title,
479
+ direction: detail.direction,
480
+ description: detail.description,
481
+ requirements: detail.requirements,
482
+ match_reasons: combined,
483
+ },
484
+ });
485
+ }
486
+ enriched.sort((a, b) => b.score - a.score);
487
+ return {
488
+ ok: true,
489
+ source: "join.qq.com",
490
+ extracted_terms: terms,
491
+ city_preferences: cities,
492
+ matches: enriched.slice(0, topN).map((e) => e.row),
493
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
494
+ "The only authority on selection is HR.",
495
+ };
496
+ }
497
+ // ---------- resume self-check ----------
498
+ const PUFFERY = [
499
+ "精通", "唯一", "完美", "顶尖", "领先", "100%",
500
+ "expert", "perfect", "world-class", "best in class",
501
+ ];
502
+ export function checkResume(text) {
503
+ if (!text || !text.trim()) {
504
+ return { ok: false, message: "empty resume text", checks: [] };
505
+ }
506
+ const checks = [];
507
+ const email = /[\w.+-]+@[\w-]+\.[\w.-]+/.test(text);
508
+ const phone = /(?:\+?86[-\s]?)?1[3-9]\d{9}/.test(text);
509
+ if (email || phone) {
510
+ const seen = [email && "email", phone && "phone"].filter(Boolean).join(", ");
511
+ checks.push({ name: "contact-info", status: "pass", hint: `found: ${seen}` });
512
+ }
513
+ else {
514
+ checks.push({
515
+ name: "contact-info",
516
+ status: "fail",
517
+ hint: "no email or 中国大陆 mobile number found — recruiters can't reach you",
518
+ });
519
+ }
520
+ const gradYear = /(20\d{2})\s*(?:年|\/|-)?\s*(?:6|7|9|June|July)/.test(text);
521
+ const school = /(大学|学院|University|College)/.test(text);
522
+ const major = /(专业|major|本科|硕士|博士|学士|bachelor|master|phd)/i.test(text);
523
+ const eduOk = Number(school) + Number(major) + Number(gradYear);
524
+ if (eduOk === 3) {
525
+ checks.push({
526
+ name: "education",
527
+ status: "pass",
528
+ hint: "school, major, graduation year all present",
529
+ });
530
+ }
531
+ else if (eduOk >= 1) {
532
+ const missing = [
533
+ !school && "school",
534
+ !major && "major",
535
+ !gradYear && "graduation year",
536
+ ].filter(Boolean).join(", ");
537
+ checks.push({ name: "education", status: "warn", hint: `missing: ${missing}` });
538
+ }
539
+ else {
540
+ checks.push({
541
+ name: "education",
542
+ status: "fail",
543
+ hint: "no school / major / graduation year detectable",
544
+ });
545
+ }
546
+ const exp = /(项目|项目经历|实习|实习经历|工作经历|project|internship|experience)/i.test(text);
547
+ if (exp) {
548
+ checks.push({
549
+ name: "experience",
550
+ status: "pass",
551
+ hint: "at least one project or internship section found",
552
+ });
553
+ }
554
+ else {
555
+ checks.push({
556
+ name: "experience",
557
+ status: "fail",
558
+ hint: "no project / internship / experience header — even fresh grads need something here",
559
+ });
560
+ }
561
+ const quant = (text.match(/\d+(?:\.\d+)?\s*(?:%|倍|w|万|k|qps|ms|百万|million|users)/gi) ?? [])
562
+ .length;
563
+ if (quant >= 2) {
564
+ checks.push({
565
+ name: "quantitative-evidence",
566
+ status: "pass",
567
+ hint: `${quant} measurable outcomes found`,
568
+ });
569
+ }
570
+ else if (quant === 1) {
571
+ checks.push({
572
+ name: "quantitative-evidence",
573
+ status: "warn",
574
+ hint: "only one quantified result — recruiters want numbers (latency, QPS, users, savings)",
575
+ });
576
+ }
577
+ else {
578
+ checks.push({
579
+ name: "quantitative-evidence",
580
+ status: "fail",
581
+ hint: "no numeric outcomes — every bullet should answer 'how much / how many / how fast'",
582
+ });
583
+ }
584
+ const flagged = PUFFERY.filter((w) => text.includes(w));
585
+ if (flagged.length) {
586
+ checks.push({
587
+ name: "puffery",
588
+ status: "warn",
589
+ hint: `vague superlatives detected: ${flagged.slice(0, 5).join(", ")} — replace with concrete evidence or remove`,
590
+ });
591
+ }
592
+ else {
593
+ checks.push({ name: "puffery", status: "pass", hint: "no obvious superlative claims" });
594
+ }
595
+ const order = { fail: 0, warn: 1, pass: 2 };
596
+ checks.sort((a, b) => order[a.status] - order[b.status]);
597
+ const summary = { pass: 0, warn: 0, fail: 0 };
598
+ for (const c of checks)
599
+ summary[c.status]++;
600
+ return {
601
+ ok: true,
602
+ summary,
603
+ checks,
604
+ note: "Heuristics only — they don't judge content quality, just whether the skeleton is intact.",
605
+ };
606
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "job-pro",
3
+ "version": "0.1.0",
4
+ "description": "Query Chinese big-tech campus recruiting from your terminal. Talks straight to join.qq.com (Tencent) and friends — no signup, no token, no server.",
5
+ "homepage": "https://job.ha7ch.com",
6
+ "repository": "https://github.com/HA7CH/job-pro",
7
+ "license": "MIT",
8
+ "type": "module",
9
+ "bin": {
10
+ "job-pro": "./dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "keywords": [
16
+ "campus-recruiting",
17
+ "校招",
18
+ "tencent",
19
+ "job-search",
20
+ "cli",
21
+ "claude-code"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "dev": "tsx src/index.ts",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20",
30
+ "tsx": "^4",
31
+ "typescript": "^5"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ }
36
+ }