@reconcrap/boss-recommend-mcp 1.1.10 → 1.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -36,6 +36,29 @@ function normalizeText(value) {
36
36
  return String(value || "").replace(/\s+/g, " ").trim();
37
37
  }
38
38
 
39
+ function sanitizeUrl(value) {
40
+ const raw = String(value || "").replace(/\s+/g, " ").trim();
41
+ const cleaned = raw
42
+ .replace(/^\uFEFF/, "")
43
+ .replace(/[\u200B-\u200F\u2028-\u202F\u2060-\u2064\uFEFF]/g, "")
44
+ .replace(/^["']|["']$/g, "");
45
+ return cleaned.replace(/\/+$/, "");
46
+ }
47
+
48
+ function validateUrlString(raw) {
49
+ const sanitized = sanitizeUrl(raw);
50
+ if (!sanitized) return { ok: false, error: "baseUrl 为空" };
51
+ try {
52
+ const url = new URL(sanitized);
53
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
54
+ return { ok: false, error: `协议无效: ${url.protocol} (期望 http 或 https)` };
55
+ }
56
+ return { ok: true, sanitized, full: sanitized };
57
+ } catch (e) {
58
+ return { ok: false, error: `URL 格式无效: ${e.message}`, raw };
59
+ }
60
+ }
61
+
39
62
  function parsePositiveInteger(raw) {
40
63
  const value = Number.parseInt(String(raw || ""), 10);
41
64
  return Number.isFinite(value) && value > 0 ? value : null;
@@ -68,6 +91,234 @@ function parseBoolean(value) {
68
91
  return null;
69
92
  }
70
93
 
94
+ function normalizeCliOptionToken(rawToken) {
95
+ const token = String(rawToken || "").trim();
96
+ if (!token) {
97
+ return { token: "", inlineValue: null };
98
+ }
99
+ const normalizedDashes = token.replace(/^[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]+/, "--");
100
+ const eqIndex = normalizedDashes.indexOf("=");
101
+ if (normalizedDashes.startsWith("--") && eqIndex > 2) {
102
+ return {
103
+ token: normalizedDashes.slice(0, eqIndex),
104
+ inlineValue: normalizedDashes.slice(eqIndex + 1)
105
+ };
106
+ }
107
+ return { token: normalizedDashes, inlineValue: null };
108
+ }
109
+
110
+ function parseFavoriteActionValue(rawValue) {
111
+ if (rawValue === null || rawValue === undefined) return null;
112
+ if (typeof rawValue === "boolean") return rawValue ? "add" : "del";
113
+ if (typeof rawValue === "number") {
114
+ if (rawValue === 1) return "add";
115
+ if (rawValue === 0) return "del";
116
+ }
117
+ const normalized = normalizeText(rawValue).toLowerCase();
118
+ if (!normalized) return null;
119
+ if (
120
+ ["1", "add", "favorite", "collect", "interested", "true", "yes", "on"].includes(normalized)
121
+ || /(?:^|[_\W])(add|favorite|collect|interest(?:ed)?)(?:$|[_\W])/.test(normalized)
122
+ ) {
123
+ return "add";
124
+ }
125
+ if (
126
+ ["0", "del", "delete", "remove", "cancel", "unfavorite", "uncollect", "false", "no", "off"].includes(normalized)
127
+ || /(?:^|[_\W])(del|delete|remove|cancel|unfavorite|uncollect|uninterest)(?:$|[_\W])/.test(normalized)
128
+ ) {
129
+ return "del";
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function parseFavoriteActionFromObject(payload, visited = new Set()) {
135
+ if (!payload || typeof payload !== "object") return null;
136
+ if (visited.has(payload)) return null;
137
+ visited.add(payload);
138
+
139
+ if (Array.isArray(payload)) {
140
+ for (const item of payload) {
141
+ const action = parseFavoriteActionFromObject(item, visited);
142
+ if (action) return action;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ const keys = Object.keys(payload);
148
+ const strongSignalKey = (key) => /p3|status|state|favorite|collect|interested|markstatus|isfavorite|iscollect/.test(key);
149
+ const weakSignalKey = (key) => /action|op|operation|type|mode|mark|interest/.test(key);
150
+ for (const key of keys) {
151
+ const value = payload[key];
152
+ const normalizedKey = normalizeText(key).toLowerCase();
153
+ if (strongSignalKey(normalizedKey)) {
154
+ const action = parseFavoriteActionValue(value);
155
+ if (action) return action;
156
+ }
157
+ }
158
+ for (const key of keys) {
159
+ const value = payload[key];
160
+ const normalizedKey = normalizeText(key).toLowerCase();
161
+ if (weakSignalKey(normalizedKey)) {
162
+ const action = parseFavoriteActionValue(value);
163
+ if (action) return action;
164
+ }
165
+ }
166
+
167
+ for (const key of keys) {
168
+ const value = payload[key];
169
+ if (value && typeof value === "object") {
170
+ const action = parseFavoriteActionFromObject(value, visited);
171
+ if (action) return action;
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+
177
+ function parseFavoriteActionFromPostData(rawPostData) {
178
+ const postData = normalizeText(rawPostData);
179
+ if (!postData) return null;
180
+
181
+ try {
182
+ const parsed = JSON.parse(postData);
183
+ const action = parseFavoriteActionFromObject(parsed);
184
+ if (action) return action;
185
+ } catch {}
186
+
187
+ try {
188
+ const params = new URLSearchParams(postData);
189
+ const strongEntries = [];
190
+ const weakEntries = [];
191
+ for (const [key, value] of params.entries()) {
192
+ const normalizedKey = normalizeText(key).toLowerCase();
193
+ if (/p3|status|state|favorite|collect|interested|markstatus|isfavorite|iscollect/.test(normalizedKey)) {
194
+ strongEntries.push(value);
195
+ } else if (/action|op|operation|type|mode|mark|interest/.test(normalizedKey)) {
196
+ weakEntries.push(value);
197
+ }
198
+ }
199
+ for (const value of strongEntries) {
200
+ const action = parseFavoriteActionValue(value);
201
+ if (action) return action;
202
+ }
203
+ for (const value of weakEntries) {
204
+ const action = parseFavoriteActionValue(value);
205
+ if (action) return action;
206
+ }
207
+ } catch {}
208
+
209
+ const fallback = parseFavoriteActionValue(postData);
210
+ if (fallback) return fallback;
211
+
212
+ if (/star-interest-click/i.test(postData)) {
213
+ if (/(?:^|[?&"'\s])p3(?:["'\s:=]){1,3}1(?:$|[&"'\s,}])/i.test(postData)) return "add";
214
+ if (/(?:^|[?&"'\s])p3(?:["'\s:=]){1,3}0(?:$|[&"'\s,}])/i.test(postData)) return "del";
215
+ }
216
+ return null;
217
+ }
218
+
219
+ function parseFavoriteActionFromRequest(url, postData = "") {
220
+ const normalizedUrl = normalizeText(url).toLowerCase();
221
+ if (!normalizedUrl) return null;
222
+
223
+ if (/\/add(?:\/|$)|[?&](?:action|op|operation|type)=add\b|[?&](?:status|p3|favorite|collect|interested)=1\b/i.test(normalizedUrl)) {
224
+ return "add";
225
+ }
226
+ if (/\/del(?:\/|$)|[?&](?:action|op|operation|type)=del\b|[?&](?:status|p3|favorite|collect|interested)=0\b/i.test(normalizedUrl)) {
227
+ return "del";
228
+ }
229
+
230
+ return parseFavoriteActionFromPostData(postData);
231
+ }
232
+
233
+ function parseFavoriteActionFromActionLog(postData = "") {
234
+ const raw = normalizeText(postData);
235
+ if (!raw) return null;
236
+ try {
237
+ const payload = JSON.parse(raw);
238
+ if (normalizeText(payload?.action).toLowerCase() !== "star-interest-click") return null;
239
+ return parseFavoriteActionValue(payload?.p3);
240
+ } catch {}
241
+
242
+ try {
243
+ const params = new URLSearchParams(raw);
244
+ const actionName = normalizeText(params.get("action")).toLowerCase();
245
+ if (actionName !== "star-interest-click") return null;
246
+ return parseFavoriteActionValue(params.get("p3"));
247
+ } catch {}
248
+ return null;
249
+ }
250
+
251
+ function parseFavoriteActionFromKnownRequest(url, postData = "") {
252
+ const normalizedUrl = normalizeText(url).toLowerCase();
253
+ if (!normalizedUrl) return null;
254
+
255
+ if (normalizedUrl.includes("usermark")) {
256
+ if (/\/add(?:\/|$)|[?&](?:action|op|operation|type)=add\b/i.test(normalizedUrl)) {
257
+ return "add";
258
+ }
259
+ if (/\/del(?:\/|$)|[?&](?:action|op|operation|type)=del\b/i.test(normalizedUrl)) {
260
+ return "del";
261
+ }
262
+ return null;
263
+ }
264
+
265
+ if (normalizedUrl.includes("actionlog/common.json")) {
266
+ return parseFavoriteActionFromActionLog(postData);
267
+ }
268
+
269
+ return null;
270
+ }
271
+
272
+ function parseFavoriteActionFromWsPayload(payload, depth = 0) {
273
+ if (depth > 3 || payload === null || payload === undefined) return null;
274
+
275
+ if (typeof payload === "object") {
276
+ if (normalizeText(payload?.action).toLowerCase() === "star-interest-click") {
277
+ const strictAction = parseFavoriteActionValue(payload?.p3);
278
+ if (strictAction) return strictAction;
279
+ }
280
+ const nestedCandidates = [
281
+ payload.data,
282
+ payload.payload,
283
+ payload.body,
284
+ payload.message,
285
+ payload.msg
286
+ ];
287
+ for (const nested of nestedCandidates) {
288
+ const action = parseFavoriteActionFromWsPayload(nested, depth + 1);
289
+ if (action) return action;
290
+ }
291
+ return null;
292
+ }
293
+
294
+ const text = normalizeText(payload);
295
+ if (!text) return null;
296
+
297
+ try {
298
+ const parsed = JSON.parse(text);
299
+ const action = parseFavoriteActionFromWsPayload(parsed, depth + 1);
300
+ if (action) return action;
301
+ } catch {}
302
+
303
+ const actionFromActionLog = parseFavoriteActionFromActionLog(text);
304
+ if (actionFromActionLog) return actionFromActionLog;
305
+ if (/usermark/i.test(text)) {
306
+ if (/\/add(?:\/|$)|[?&](?:action|op|operation|type)=add\b/i.test(text)) return "add";
307
+ if (/\/del(?:\/|$)|[?&](?:action|op|operation|type)=del\b/i.test(text)) return "del";
308
+ }
309
+
310
+ return null;
311
+ }
312
+
313
+ function isRecoverablePostActionError(error, action) {
314
+ const normalizedAction = normalizeText(action).toLowerCase();
315
+ const normalizedCode = normalizeText(error?.code).toUpperCase();
316
+ if (!normalizedAction || !normalizedCode) return false;
317
+ if (normalizedAction === "favorite" && normalizedCode === "FAVORITE_BUTTON_FAILED") return true;
318
+ if (normalizedAction === "greet" && normalizedCode === "GREET_BUTTON_FAILED") return true;
319
+ return false;
320
+ }
321
+
71
322
  function loadCalibrationPosition(filePath) {
72
323
  try {
73
324
  const resolved = path.resolve(String(filePath || ""));
@@ -139,69 +390,71 @@ function parseArgs(argv) {
139
390
  };
140
391
 
141
392
  for (let index = 0; index < argv.length; index += 1) {
142
- const token = argv[index];
393
+ const normalizedToken = normalizeCliOptionToken(argv[index]);
394
+ const token = normalizedToken.token;
143
395
  const next = argv[index + 1];
144
- if (token === "--baseurl" && next) {
145
- parsed.baseUrl = next;
396
+ const inlineValue = normalizedToken.inlineValue;
397
+ if ((token === "--baseurl" || token === "--base-url") && (inlineValue || next)) {
398
+ parsed.baseUrl = inlineValue || next;
146
399
  parsed.__provided.baseUrl = true;
147
- index += 1;
148
- } else if (token === "--apikey" && next) {
149
- parsed.apiKey = next;
400
+ if (!inlineValue) index += 1;
401
+ } else if ((token === "--apikey" || token === "--api-key") && (inlineValue || next)) {
402
+ parsed.apiKey = inlineValue || next;
150
403
  parsed.__provided.apiKey = true;
151
- index += 1;
152
- } else if (token === "--model" && next) {
153
- parsed.model = next;
404
+ if (!inlineValue) index += 1;
405
+ } else if (token === "--model" && (inlineValue || next)) {
406
+ parsed.model = inlineValue || next;
154
407
  parsed.__provided.model = true;
155
- index += 1;
156
- } else if (token === "--openai-organization" && next) {
157
- parsed.openaiOrganization = next;
158
- index += 1;
159
- } else if (token === "--openai-project" && next) {
160
- parsed.openaiProject = next;
161
- index += 1;
162
- } else if (token === "--criteria" && next) {
163
- parsed.criteria = next;
408
+ if (!inlineValue) index += 1;
409
+ } else if (token === "--openai-organization" && (inlineValue || next)) {
410
+ parsed.openaiOrganization = inlineValue || next;
411
+ if (!inlineValue) index += 1;
412
+ } else if (token === "--openai-project" && (inlineValue || next)) {
413
+ parsed.openaiProject = inlineValue || next;
414
+ if (!inlineValue) index += 1;
415
+ } else if (token === "--criteria" && (inlineValue || next)) {
416
+ parsed.criteria = inlineValue || next;
164
417
  parsed.__provided.criteria = true;
165
- index += 1;
166
- } else if (token === "--targetCount" && next) {
167
- parsed.targetCount = parsePositiveInteger(next);
418
+ if (!inlineValue) index += 1;
419
+ } else if ((token === "--targetCount" || token === "--target-count") && (inlineValue || next)) {
420
+ parsed.targetCount = parsePositiveInteger(inlineValue || next);
168
421
  parsed.__provided.targetCount = true;
169
- index += 1;
170
- } else if (token === "--max-greet-count" && next) {
171
- parsed.maxGreetCount = parsePositiveInteger(next);
422
+ if (!inlineValue) index += 1;
423
+ } else if ((token === "--max-greet-count" || token === "--maxGreetCount") && (inlineValue || next)) {
424
+ parsed.maxGreetCount = parsePositiveInteger(inlineValue || next);
172
425
  parsed.__provided.maxGreetCount = true;
173
- index += 1;
174
- } else if (token === "--page-scope" && next) {
175
- parsed.pageScope = normalizePageScope(next) || "recommend";
426
+ if (!inlineValue) index += 1;
427
+ } else if ((token === "--page-scope" || token === "--pageScope" || token === "--page_scope") && (inlineValue || next)) {
428
+ parsed.pageScope = normalizePageScope(inlineValue || next) || "recommend";
176
429
  parsed.__provided.pageScope = true;
177
- index += 1;
178
- } else if (token === "--calibration" && next) {
179
- parsed.calibrationPath = path.resolve(next);
430
+ if (!inlineValue) index += 1;
431
+ } else if ((token === "--calibration" || token === "--calibration-path") && (inlineValue || next)) {
432
+ parsed.calibrationPath = path.resolve(inlineValue || next);
180
433
  parsed.__provided.calibrationPath = true;
181
- index += 1;
182
- } else if (token === "--port" && next) {
183
- parsed.port = parsePositiveInteger(next) || DEFAULT_PORT;
434
+ if (!inlineValue) index += 1;
435
+ } else if (token === "--port" && (inlineValue || next)) {
436
+ parsed.port = parsePositiveInteger(inlineValue || next) || DEFAULT_PORT;
184
437
  parsed.__provided.port = true;
185
- index += 1;
186
- } else if (token === "--output" && next) {
187
- parsed.output = path.resolve(next);
188
- index += 1;
189
- } else if (token === "--checkpoint-path" && next) {
190
- parsed.checkpointPath = path.resolve(next);
191
- index += 1;
192
- } else if (token === "--pause-control-path" && next) {
193
- parsed.pauseControlPath = path.resolve(next);
194
- index += 1;
438
+ if (!inlineValue) index += 1;
439
+ } else if (token === "--output" && (inlineValue || next)) {
440
+ parsed.output = path.resolve(inlineValue || next);
441
+ if (!inlineValue) index += 1;
442
+ } else if (token === "--checkpoint-path" && (inlineValue || next)) {
443
+ parsed.checkpointPath = path.resolve(inlineValue || next);
444
+ if (!inlineValue) index += 1;
445
+ } else if (token === "--pause-control-path" && (inlineValue || next)) {
446
+ parsed.pauseControlPath = path.resolve(inlineValue || next);
447
+ if (!inlineValue) index += 1;
195
448
  } else if (token === "--resume") {
196
449
  parsed.resume = true;
197
- } else if (token === "--post-action" && next) {
198
- parsed.postAction = normalizePostAction(next);
450
+ } else if ((token === "--post-action" || token === "--postAction") && (inlineValue || next)) {
451
+ parsed.postAction = normalizePostAction(inlineValue || next);
199
452
  parsed.__provided.postAction = true;
200
- index += 1;
201
- } else if (token === "--post-action-confirmed" && next) {
202
- parsed.postActionConfirmed = parseBoolean(next);
453
+ if (!inlineValue) index += 1;
454
+ } else if ((token === "--post-action-confirmed" || token === "--postActionConfirmed") && (inlineValue || next)) {
455
+ parsed.postActionConfirmed = parseBoolean(inlineValue || next);
203
456
  parsed.__provided.postActionConfirmed = true;
204
- index += 1;
457
+ if (!inlineValue) index += 1;
205
458
  } else if (token === "--help" || token === "-h") {
206
459
  parsed.help = true;
207
460
  }
@@ -1487,6 +1740,13 @@ const jsReloadRecommendFrame = `(() => {
1487
1740
  class RecommendScreenCli {
1488
1741
  constructor(args) {
1489
1742
  this.args = args;
1743
+ const baseUrlCheck = validateUrlString(this.args.baseUrl);
1744
+ if (this.args.baseUrl && !baseUrlCheck.ok) {
1745
+ log(`[警告] baseUrl 校验失败: ${baseUrlCheck.error}, 原始值=${JSON.stringify(this.args.baseUrl)}`);
1746
+ }
1747
+ if (baseUrlCheck.sanitized) {
1748
+ this.args.baseUrl = baseUrlCheck.sanitized;
1749
+ }
1490
1750
  this.client = null;
1491
1751
  this.Runtime = null;
1492
1752
  this.Input = null;
@@ -1516,6 +1776,8 @@ class RecommendScreenCli {
1516
1776
  this.latestResumeNetworkPayload = null;
1517
1777
  this.favoriteActionEvents = [];
1518
1778
  this.favoriteClickPendingSince = 0;
1779
+ this.favoriteNetworkTraces = [];
1780
+ this.webSocketByRequestId = new Map();
1519
1781
  this.resumeSourceStats = {
1520
1782
  network: 0,
1521
1783
  image_fallback: 0
@@ -1610,6 +1872,7 @@ class RecommendScreenCli {
1610
1872
 
1611
1873
  markFavoriteClickPending() {
1612
1874
  this.favoriteClickPendingSince = Date.now();
1875
+ this.favoriteNetworkTraces = [];
1613
1876
  }
1614
1877
 
1615
1878
  consumeFavoriteActionResult(since = 0) {
@@ -1626,6 +1889,30 @@ class RecommendScreenCli {
1626
1889
  return matched.action || null;
1627
1890
  }
1628
1891
 
1892
+ recordFavoriteNetworkTrace(entry) {
1893
+ const trace = {
1894
+ ts: Date.now(),
1895
+ ...entry
1896
+ };
1897
+ this.favoriteNetworkTraces.push(trace);
1898
+ if (this.favoriteNetworkTraces.length > 60) {
1899
+ this.favoriteNetworkTraces = this.favoriteNetworkTraces.slice(-60);
1900
+ }
1901
+ }
1902
+
1903
+ summarizeFavoriteNetworkTrace(since = 0) {
1904
+ const timestamp = Number.isFinite(since) ? since : 0;
1905
+ return this.favoriteNetworkTraces
1906
+ .filter((item) => Number(item?.ts || 0) >= timestamp)
1907
+ .slice(-12)
1908
+ .map((item) => {
1909
+ if (item.kind === "ws") {
1910
+ return `[ws:${item.direction}] ${item.url || "unknown"} payload=${item.payload || ""}`;
1911
+ }
1912
+ return `[http] ${item.method || "GET"} ${item.url || ""} body=${item.postData || ""}`;
1913
+ });
1914
+ }
1915
+
1629
1916
  cacheResumeNetworkPayload(payload, fallbackGeekId = null) {
1630
1917
  if (!payload || typeof payload !== "object") return;
1631
1918
  const geekDetail = payload.geekDetail || payload;
@@ -1704,31 +1991,55 @@ class RecommendScreenCli {
1704
1991
 
1705
1992
  if (this.favoriteClickPendingSince <= 0) return;
1706
1993
  const requestTs = Date.now();
1707
- if (requestTs < this.favoriteClickPendingSince - 1000) return;
1708
-
1709
- if (url.includes("userMark")) {
1710
- const action = /\/add(?:\/|$)|[?&]action=add/i.test(url)
1711
- ? "add"
1712
- : /\/del(?:\/|$)|[?&]action=del/i.test(url)
1713
- ? "del"
1714
- : null;
1715
- if (action) {
1716
- this.favoriteActionEvents.push({ action, ts: requestTs, source: "userMark", url });
1717
- }
1718
- return;
1719
- }
1994
+ if (requestTs < this.favoriteClickPendingSince) return;
1995
+ const method = normalizeText(params?.request?.method || "").toUpperCase() || "GET";
1996
+ const postData = params?.request?.postData || "";
1997
+ this.recordFavoriteNetworkTrace({
1998
+ ts: requestTs,
1999
+ kind: "http",
2000
+ method,
2001
+ url: url.slice(0, 240),
2002
+ postData: normalizeText(postData).slice(0, 200)
2003
+ });
2004
+ const action = parseFavoriteActionFromKnownRequest(url, postData);
2005
+ if (!action) return;
2006
+ const source = url.includes("userMark")
2007
+ ? "userMark"
2008
+ : url.includes("actionLog/common.json")
2009
+ ? "actionLog"
2010
+ : "favorite";
2011
+ this.favoriteActionEvents.push({ action, ts: requestTs, source, url });
2012
+ }
1720
2013
 
1721
- if (url.includes("actionLog/common.json")) {
1722
- try {
1723
- const payload = JSON.parse(params?.request?.postData || "{}");
1724
- if (payload?.action === "star-interest-click") {
1725
- const action = Number(payload?.p3) === 1 ? "add" : Number(payload?.p3) === 0 ? "del" : null;
1726
- if (action) {
1727
- this.favoriteActionEvents.push({ action, ts: requestTs, source: "actionLog", url });
1728
- }
1729
- }
1730
- } catch {}
1731
- }
2014
+ handleNetworkWebSocketCreated(params) {
2015
+ const requestId = normalizeText(params?.requestId || "");
2016
+ if (!requestId) return;
2017
+ const url = normalizeText(params?.url || "");
2018
+ this.webSocketByRequestId.set(requestId, url || "");
2019
+ }
2020
+
2021
+ handleNetworkWebSocketFrame(params, direction = "sent") {
2022
+ if (this.favoriteClickPendingSince <= 0) return;
2023
+ const ts = Date.now();
2024
+ if (ts < this.favoriteClickPendingSince) return;
2025
+ const requestId = normalizeText(params?.requestId || "");
2026
+ const payloadData = normalizeText(params?.response?.payloadData || "");
2027
+ const wsUrl = this.webSocketByRequestId.get(requestId) || "";
2028
+ this.recordFavoriteNetworkTrace({
2029
+ ts,
2030
+ kind: "ws",
2031
+ direction,
2032
+ url: wsUrl ? wsUrl.slice(0, 240) : requestId ? `ws:${requestId}` : "ws",
2033
+ payload: payloadData.slice(0, 200)
2034
+ });
2035
+ const action = parseFavoriteActionFromWsPayload(payloadData);
2036
+ if (!action) return;
2037
+ this.favoriteActionEvents.push({
2038
+ action,
2039
+ ts,
2040
+ source: `websocket_${direction}`,
2041
+ url: wsUrl || (requestId ? `ws:${requestId}` : "websocket")
2042
+ });
1732
2043
  }
1733
2044
 
1734
2045
  async handleNetworkLoadingFinished(params) {
@@ -1891,6 +2202,27 @@ class RecommendScreenCli {
1891
2202
  } catch {}
1892
2203
  });
1893
2204
  }
2205
+ if (typeof this.Network.webSocketCreated === "function") {
2206
+ this.Network.webSocketCreated((params) => {
2207
+ try {
2208
+ this.handleNetworkWebSocketCreated(params);
2209
+ } catch {}
2210
+ });
2211
+ }
2212
+ if (typeof this.Network.webSocketFrameSent === "function") {
2213
+ this.Network.webSocketFrameSent((params) => {
2214
+ try {
2215
+ this.handleNetworkWebSocketFrame(params, "sent");
2216
+ } catch {}
2217
+ });
2218
+ }
2219
+ if (typeof this.Network.webSocketFrameReceived === "function") {
2220
+ this.Network.webSocketFrameReceived((params) => {
2221
+ try {
2222
+ this.handleNetworkWebSocketFrame(params, "received");
2223
+ } catch {}
2224
+ });
2225
+ }
1894
2226
  if (typeof this.Network.loadingFinished === "function") {
1895
2227
  this.Network.loadingFinished((params) => {
1896
2228
  this.handleNetworkLoadingFinished(params).catch(() => {});
@@ -1988,7 +2320,7 @@ class RecommendScreenCli {
1988
2320
  async pressEsc() {
1989
2321
  await this.Input.dispatchKeyEvent({ type: "keyDown", windowsVirtualKeyCode: 27, key: "Escape", code: "Escape" });
1990
2322
  await this.Input.dispatchKeyEvent({ type: "keyUp", windowsVirtualKeyCode: 27, key: "Escape", code: "Escape" });
1991
- }
2323
+ }
1992
2324
  async ensureDetailOpen() {
1993
2325
  for (let index = 0; index < 20; index += 1) {
1994
2326
  const state = await this.evaluate(jsWaitForDetail);
@@ -2308,7 +2640,9 @@ class RecommendScreenCli {
2308
2640
 
2309
2641
  async callVisionModel(imagePath) {
2310
2642
  const imageBase64 = fs.readFileSync(imagePath, "base64");
2311
- const baseUrl = this.args.baseUrl.replace(/\/$/, "");
2643
+ const rawBaseUrl = this.args.baseUrl;
2644
+ log(`[callVisionModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
2645
+ const baseUrl = String(rawBaseUrl || "").replace(/\/$/, "");
2312
2646
  const payload = {
2313
2647
  model: this.args.model,
2314
2648
  temperature: 0.1,
@@ -2364,7 +2698,9 @@ class RecommendScreenCli {
2364
2698
 
2365
2699
  async callTextModel(resumeText) {
2366
2700
  const safeResumeText = String(resumeText || "").slice(0, 28000);
2367
- const baseUrl = this.args.baseUrl.replace(/\/$/, "");
2701
+ const rawBaseUrl = this.args.baseUrl;
2702
+ log(`[callTextModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
2703
+ const baseUrl = String(rawBaseUrl || "").replace(/\/$/, "");
2368
2704
  const payload = {
2369
2705
  model: this.args.model,
2370
2706
  temperature: 0.1,
@@ -2410,7 +2746,8 @@ class RecommendScreenCli {
2410
2746
  async favoriteCandidate(options = {}) {
2411
2747
  if (this.args.pageScope === "featured") {
2412
2748
  if (options.alreadyInterested === true) {
2413
- return { actionTaken: "already_favorited" };
2749
+ log("[FAVORITE] network profile indicates alreadyInterested=true,跳过点击以避免误取消收藏。");
2750
+ return { actionTaken: "already_favorited", source: "network_profile" };
2414
2751
  }
2415
2752
  if (!this.featuredCalibration?.position) {
2416
2753
  throw this.buildError(
@@ -2425,6 +2762,7 @@ class RecommendScreenCli {
2425
2762
 
2426
2763
  const base = this.featuredCalibration.position;
2427
2764
  const maxClicks = 5;
2765
+ let detectedAlreadyFavoritedByNetwork = false;
2428
2766
  for (let clickIndex = 0; clickIndex < maxClicks; clickIndex += 1) {
2429
2767
  const clickStartedAt = Date.now();
2430
2768
  this.markFavoriteClickPending();
@@ -2440,22 +2778,38 @@ class RecommendScreenCli {
2440
2778
  }
2441
2779
 
2442
2780
  let sawDel = false;
2443
- for (let index = 0; index < 10; index += 1) {
2781
+ for (let index = 0; index < 14; index += 1) {
2444
2782
  await sleep(humanDelay(260, 80));
2445
2783
  const networkAction = this.consumeFavoriteActionResult(clickStartedAt);
2446
2784
  if (networkAction === "add") {
2447
- return { actionTaken: "favorite" };
2785
+ return detectedAlreadyFavoritedByNetwork
2786
+ ? { actionTaken: "already_favorited", re_favorited: true }
2787
+ : { actionTaken: "favorite" };
2448
2788
  }
2449
2789
  if (networkAction === "del") {
2790
+ detectedAlreadyFavoritedByNetwork = true;
2791
+ log("[FAVORITE] 检测到 network=del,推断该人选原本已收藏,继续点击恢复为收藏状态。");
2450
2792
  sawDel = true;
2451
2793
  break;
2452
2794
  }
2453
2795
  }
2454
2796
  if (!sawDel && clickIndex === maxClicks - 1) {
2797
+ const traceSummary = this.summarizeFavoriteNetworkTrace(clickStartedAt);
2798
+ if (traceSummary.length > 0) {
2799
+ log(`[FAVORITE_NETWORK_TRACE] ${traceSummary.join(" | ")}`);
2800
+ } else {
2801
+ log("[FAVORITE_NETWORK_TRACE] 点击后未捕获到可识别的 HTTP/WS 网络信号。");
2802
+ }
2455
2803
  break;
2456
2804
  }
2457
2805
  }
2458
2806
 
2807
+ if (detectedAlreadyFavoritedByNetwork) {
2808
+ throw this.buildError(
2809
+ "FAVORITE_BUTTON_FAILED",
2810
+ "精选页检测到 network del(原本已收藏),但后续未检测到 network add(恢复收藏)成功信号。"
2811
+ );
2812
+ }
2459
2813
  throw this.buildError("FAVORITE_BUTTON_FAILED", "精选页收藏未检测到 network add 成功信号。");
2460
2814
  }
2461
2815
 
@@ -2666,6 +3020,10 @@ class RecommendScreenCli {
2666
3020
  if (!this.args.baseUrl || !this.args.apiKey || !this.args.model) {
2667
3021
  throw this.buildError("SCREEN_CONFIG_ERROR", "Missing baseUrl/apiKey/model", false);
2668
3022
  }
3023
+ log(
3024
+ `[ARGS] page_scope=${this.args.pageScope} target_count=${this.args.targetCount ?? "none"} ` +
3025
+ `post_action=${this.args.postAction || "unset"} port=${this.args.port}`
3026
+ );
2669
3027
 
2670
3028
  if (!(this.args.postActionConfirmed === true && this.args.postAction)) {
2671
3029
  this.args.postAction = await promptPostAction();
@@ -2854,23 +3212,41 @@ class RecommendScreenCli {
2854
3212
  effectiveAction = "favorite";
2855
3213
  this.greetLimitFallbackCount += 1;
2856
3214
  }
2857
- const actionResult = effectiveAction === "favorite"
2858
- ? await this.favoriteCandidate({
2859
- alreadyInterested: networkCandidateInfo?.alreadyInterested === true
2860
- })
2861
- : effectiveAction === "greet"
2862
- ? await this.greetCandidate()
2863
- : { actionTaken: "none" };
3215
+ let actionResult = { actionTaken: "none" };
3216
+ try {
3217
+ actionResult = effectiveAction === "favorite"
3218
+ ? await this.favoriteCandidate({
3219
+ alreadyInterested: networkCandidateInfo?.alreadyInterested === true
3220
+ })
3221
+ : effectiveAction === "greet"
3222
+ ? await this.greetCandidate()
3223
+ : { actionTaken: "none" };
3224
+ } catch (postActionError) {
3225
+ if (!isRecoverablePostActionError(postActionError, effectiveAction)) {
3226
+ throw postActionError;
3227
+ }
3228
+ log(`[POST_ACTION_WARN] ${effectiveAction} 失败,继续写入通过候选人: ${postActionError.message || postActionError}`);
3229
+ actionResult = {
3230
+ actionTaken: `${effectiveAction}_failed`,
3231
+ errorCode: postActionError.code || "POST_ACTION_FAILED",
3232
+ errorMessage: normalizeText(postActionError.message || "post action failed")
3233
+ };
3234
+ }
2864
3235
  if (actionResult.actionTaken === "greet") {
2865
3236
  this.greetCount += 1;
2866
3237
  }
3238
+ const screeningReason = normalizeText(screening.reason || screening.summary || "");
3239
+ const actionErrorMessage = normalizeText(actionResult.errorMessage || "");
3240
+ const mergedReason = actionErrorMessage
3241
+ ? `${screeningReason}${screeningReason ? " | " : ""}[${effectiveAction}失败] ${actionErrorMessage}`
3242
+ : screeningReason;
2867
3243
  this.passedCandidates.push({
2868
3244
  name: candidateProfile.name,
2869
3245
  school: candidateProfile.school,
2870
3246
  major: candidateProfile.major,
2871
3247
  company: candidateProfile.company,
2872
3248
  position: candidateProfile.position,
2873
- reason: screening.reason || screening.summary || "",
3249
+ reason: mergedReason,
2874
3250
  action: actionResult.actionTaken,
2875
3251
  geekId: nextCandidate.geek_id,
2876
3252
  summary: screening.summary,
@@ -3026,8 +3402,13 @@ if (require.main === module) {
3026
3402
  __testables: {
3027
3403
  MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES,
3028
3404
  RESUME_CAPTURE_MAX_ATTEMPTS,
3029
- RESUME_CAPTURE_WAIT_MS
3405
+ RESUME_CAPTURE_WAIT_MS,
3406
+ parseFavoriteActionFromPostData,
3407
+ parseFavoriteActionFromRequest,
3408
+ parseFavoriteActionFromKnownRequest,
3409
+ parseFavoriteActionFromActionLog,
3410
+ parseFavoriteActionFromWsPayload,
3411
+ isRecoverablePostActionError
3030
3412
  }
3031
3413
  };
3032
3414
  }
3033
-
@@ -4,7 +4,7 @@ const os = require("node:os");
4
4
  const path = require("node:path");
5
5
  const sharp = require("sharp");
6
6
 
7
- const { RecommendScreenCli, __testables } = require("./boss-recommend-screen-cli.cjs");
7
+ const { RecommendScreenCli, parseArgs, __testables } = require("./boss-recommend-screen-cli.cjs");
8
8
  const { __testables: captureTestables } = require("./scripts/capture-full-resume-canvas.cjs");
9
9
 
10
10
  class FakeRecommendScreenCli extends RecommendScreenCli {
@@ -434,6 +434,62 @@ async function testFeaturedFavoriteShouldNotUseDomFallback() {
434
434
  assert.equal(evaluateCalls, 0);
435
435
  }
436
436
 
437
+ async function testFeaturedFavoriteShouldSkipClickWhenAlreadyInterested() {
438
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-featured-favorite-already-"));
439
+ const args = createArgs(tempDir);
440
+ args.pageScope = "featured";
441
+ const calibrationPath = path.join(tempDir, "favorite-calibration.json");
442
+ fs.writeFileSync(calibrationPath, JSON.stringify({
443
+ favoritePosition: {
444
+ pageX: 120,
445
+ pageY: 220,
446
+ canvasX: 0,
447
+ canvasY: 0
448
+ }
449
+ }, null, 2));
450
+ args.calibrationPath = calibrationPath;
451
+ const cli = new RecommendScreenCli(args);
452
+ let clickCalls = 0;
453
+ cli.simulateHumanClick = async () => {
454
+ clickCalls += 1;
455
+ };
456
+ const result = await cli.favoriteCandidate({ alreadyInterested: true });
457
+ assert.equal(result.actionTaken, "already_favorited");
458
+ assert.equal(result.source, "network_profile");
459
+ assert.equal(clickCalls, 0);
460
+ }
461
+
462
+ async function testFeaturedFavoriteShouldRecognizeAlreadyFavoritedByDelThenAdd() {
463
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-featured-favorite-del-add-"));
464
+ const args = createArgs(tempDir);
465
+ args.pageScope = "featured";
466
+ const calibrationPath = path.join(tempDir, "favorite-calibration.json");
467
+ fs.writeFileSync(calibrationPath, JSON.stringify({
468
+ favoritePosition: {
469
+ pageX: 120,
470
+ pageY: 220,
471
+ canvasX: 0,
472
+ canvasY: 0
473
+ }
474
+ }, null, 2));
475
+ args.calibrationPath = calibrationPath;
476
+ const cli = new RecommendScreenCli(args);
477
+ let clickCalls = 0;
478
+ cli.simulateHumanClick = async () => {
479
+ clickCalls += 1;
480
+ cli.favoriteActionEvents.push({
481
+ action: clickCalls === 1 ? "del" : "add",
482
+ ts: Date.now(),
483
+ source: "test",
484
+ url: clickCalls === 1 ? "userMark/del" : "userMark/add"
485
+ });
486
+ };
487
+ const result = await cli.favoriteCandidate();
488
+ assert.equal(result.actionTaken, "already_favorited");
489
+ assert.equal(result.re_favorited, true);
490
+ assert.equal(clickCalls, 2);
491
+ }
492
+
437
493
  async function testFeaturedFavoriteWithoutCalibrationShouldFail() {
438
494
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-featured-favorite-missing-cal-"));
439
495
  const args = createArgs(tempDir);
@@ -449,6 +505,91 @@ async function testFeaturedFavoriteWithoutCalibrationShouldFail() {
449
505
  );
450
506
  }
451
507
 
508
+ function testFavoriteActionParserShouldSupportBodySignals() {
509
+ const addFromJson = __testables.parseFavoriteActionFromPostData(JSON.stringify({
510
+ action: "star-interest-click",
511
+ p3: 1
512
+ }));
513
+ const delFromForm = __testables.parseFavoriteActionFromPostData("action=star-interest-click&p3=0");
514
+ assert.equal(addFromJson, "add");
515
+ assert.equal(delFromForm, "del");
516
+ }
517
+
518
+ function testFavoriteActionParserShouldSupportFallbackRequestShape() {
519
+ const action = __testables.parseFavoriteActionFromRequest(
520
+ "https://www.zhipin.com/wapi/zpgeek/favorite/operate",
521
+ JSON.stringify({ op: "add", geekId: "abc" })
522
+ );
523
+ assert.equal(action, "add");
524
+ }
525
+
526
+ function testFavoriteActionParserShouldSupportWebSocketPayload() {
527
+ const addFromWsJson = __testables.parseFavoriteActionFromWsPayload(JSON.stringify({
528
+ action: "star-interest-click",
529
+ p3: 1
530
+ }));
531
+ const delFromWsForm = __testables.parseFavoriteActionFromWsPayload("action=star-interest-click&p3=0");
532
+ assert.equal(addFromWsJson, "add");
533
+ assert.equal(delFromWsForm, "del");
534
+ }
535
+
536
+ function testFavoriteActionParserShouldOnlyTrustKnownRequestShapes() {
537
+ const unknown = __testables.parseFavoriteActionFromKnownRequest(
538
+ "https://www.zhipin.com/wapi/other/metrics",
539
+ JSON.stringify({ action: "add", p3: 1 })
540
+ );
541
+ const actionLog = __testables.parseFavoriteActionFromKnownRequest(
542
+ "https://www.zhipin.com/wapi/zplog/actionLog/common.json",
543
+ JSON.stringify({ action: "star-interest-click", p3: 1 })
544
+ );
545
+ const userMark = __testables.parseFavoriteActionFromKnownRequest(
546
+ "https://www.zhipin.com/wapi/zpgeek/userMark/add",
547
+ ""
548
+ );
549
+ assert.equal(unknown, null);
550
+ assert.equal(actionLog, "add");
551
+ assert.equal(userMark, "add");
552
+ }
553
+
554
+ async function testFeaturedPostActionFailureShouldStillRecordPassedCandidate() {
555
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-featured-action-failure-"));
556
+ const args = createArgs(tempDir);
557
+ args.pageScope = "featured";
558
+ args.postAction = "favorite";
559
+ const candidate = { key: "featured-fav-fail", geek_id: "featured-fav-fail", name: "featured candidate" };
560
+ const cli = new FakeRecommendScreenCli(args, {
561
+ candidates: [candidate]
562
+ });
563
+
564
+ cli.waitForNetworkResumeCandidateInfo = async () => ({
565
+ name: "featured candidate",
566
+ school: "测试大学",
567
+ major: "人工智能",
568
+ company: "测试公司",
569
+ position: "算法工程师",
570
+ resumeText: "满足测试标准"
571
+ });
572
+ cli.callTextModel = async () => ({
573
+ passed: true,
574
+ reason: "通过",
575
+ summary: "通过"
576
+ });
577
+ cli.favoriteCandidate = async () => {
578
+ const error = new Error("精选页收藏未检测到 network add 成功信号。");
579
+ error.code = "FAVORITE_BUTTON_FAILED";
580
+ throw error;
581
+ };
582
+
583
+ const result = await cli.run();
584
+ assert.equal(result.status, "COMPLETED");
585
+ assert.equal(result.result.processed_count, 1);
586
+ assert.equal(result.result.passed_count, 1);
587
+ assert.equal(result.result.skipped_count, 0);
588
+ assert.equal(cli.passedCandidates.length, 1);
589
+ assert.equal(cli.passedCandidates[0].action, "favorite_failed");
590
+ assert.match(cli.passedCandidates[0].reason, /\[favorite失败]/);
591
+ }
592
+
452
593
  async function testStitchWithSharpShouldComposeExpectedImage() {
453
594
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-sharp-stitch-"));
454
595
  const chunkA = path.join(tempDir, "chunk_000.png");
@@ -540,6 +681,27 @@ function testStitchWithAvailablePythonShouldFailWhenScriptMissing() {
540
681
  assert.equal(result.attempts[0].command, "python3");
541
682
  }
542
683
 
684
+ function testParseArgsShouldSupportFeaturedAliasesAndInlinePort() {
685
+ const parsed = parseArgs([
686
+ "--criteria", "test criteria",
687
+ "--baseurl", "https://example.com/v1",
688
+ "--apikey", "key",
689
+ "--model", "test-model",
690
+ "--target-count", "3",
691
+ "--pageScope", "featured",
692
+ "--port=9222",
693
+ "--postAction", "favorite",
694
+ "--postActionConfirmed", "true"
695
+ ]);
696
+ assert.equal(parsed.pageScope, "featured");
697
+ assert.equal(parsed.port, 9222);
698
+ assert.equal(parsed.targetCount, 3);
699
+ assert.equal(parsed.postAction, "favorite");
700
+ assert.equal(parsed.postActionConfirmed, true);
701
+ assert.equal(parsed.__provided.pageScope, true);
702
+ assert.equal(parsed.__provided.port, true);
703
+ }
704
+
543
705
  async function main() {
544
706
  testShouldAbortResumeProbeEarly();
545
707
  await testSingleResumeCaptureFailureIsSkipped();
@@ -551,10 +713,18 @@ async function main() {
551
713
  await testNetworkMissShouldFallbackToImageCapture();
552
714
  await testFeaturedNetworkMissShouldSkipWithoutImageCapture();
553
715
  await testFeaturedFavoriteShouldNotUseDomFallback();
716
+ await testFeaturedFavoriteShouldSkipClickWhenAlreadyInterested();
717
+ await testFeaturedFavoriteShouldRecognizeAlreadyFavoritedByDelThenAdd();
554
718
  await testFeaturedFavoriteWithoutCalibrationShouldFail();
719
+ testFavoriteActionParserShouldSupportBodySignals();
720
+ testFavoriteActionParserShouldSupportFallbackRequestShape();
721
+ testFavoriteActionParserShouldSupportWebSocketPayload();
722
+ testFavoriteActionParserShouldOnlyTrustKnownRequestShapes();
723
+ await testFeaturedPostActionFailureShouldStillRecordPassedCandidate();
555
724
  await testStitchWithSharpShouldComposeExpectedImage();
556
725
  testStitchWithAvailablePythonShouldFallbackToPython();
557
726
  testStitchWithAvailablePythonShouldFailWhenScriptMissing();
727
+ testParseArgsShouldSupportFeaturedAliasesAndInlinePort();
558
728
  console.log("recoverable resume failure tests passed");
559
729
  }
560
730