@reconcrap/boss-recommend-mcp 1.1.11 → 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.11",
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",
@@ -91,6 +91,234 @@ function parseBoolean(value) {
91
91
  return null;
92
92
  }
93
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
+
94
322
  function loadCalibrationPosition(filePath) {
95
323
  try {
96
324
  const resolved = path.resolve(String(filePath || ""));
@@ -162,69 +390,71 @@ function parseArgs(argv) {
162
390
  };
163
391
 
164
392
  for (let index = 0; index < argv.length; index += 1) {
165
- const token = argv[index];
393
+ const normalizedToken = normalizeCliOptionToken(argv[index]);
394
+ const token = normalizedToken.token;
166
395
  const next = argv[index + 1];
167
- if (token === "--baseurl" && next) {
168
- parsed.baseUrl = next;
396
+ const inlineValue = normalizedToken.inlineValue;
397
+ if ((token === "--baseurl" || token === "--base-url") && (inlineValue || next)) {
398
+ parsed.baseUrl = inlineValue || next;
169
399
  parsed.__provided.baseUrl = true;
170
- index += 1;
171
- } else if (token === "--apikey" && next) {
172
- parsed.apiKey = next;
400
+ if (!inlineValue) index += 1;
401
+ } else if ((token === "--apikey" || token === "--api-key") && (inlineValue || next)) {
402
+ parsed.apiKey = inlineValue || next;
173
403
  parsed.__provided.apiKey = true;
174
- index += 1;
175
- } else if (token === "--model" && next) {
176
- parsed.model = next;
404
+ if (!inlineValue) index += 1;
405
+ } else if (token === "--model" && (inlineValue || next)) {
406
+ parsed.model = inlineValue || next;
177
407
  parsed.__provided.model = true;
178
- index += 1;
179
- } else if (token === "--openai-organization" && next) {
180
- parsed.openaiOrganization = next;
181
- index += 1;
182
- } else if (token === "--openai-project" && next) {
183
- parsed.openaiProject = next;
184
- index += 1;
185
- } else if (token === "--criteria" && next) {
186
- 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;
187
417
  parsed.__provided.criteria = true;
188
- index += 1;
189
- } else if (token === "--targetCount" && next) {
190
- 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);
191
421
  parsed.__provided.targetCount = true;
192
- index += 1;
193
- } else if (token === "--max-greet-count" && next) {
194
- 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);
195
425
  parsed.__provided.maxGreetCount = true;
196
- index += 1;
197
- } else if (token === "--page-scope" && next) {
198
- 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";
199
429
  parsed.__provided.pageScope = true;
200
- index += 1;
201
- } else if (token === "--calibration" && next) {
202
- 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);
203
433
  parsed.__provided.calibrationPath = true;
204
- index += 1;
205
- } else if (token === "--port" && next) {
206
- 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;
207
437
  parsed.__provided.port = true;
208
- index += 1;
209
- } else if (token === "--output" && next) {
210
- parsed.output = path.resolve(next);
211
- index += 1;
212
- } else if (token === "--checkpoint-path" && next) {
213
- parsed.checkpointPath = path.resolve(next);
214
- index += 1;
215
- } else if (token === "--pause-control-path" && next) {
216
- parsed.pauseControlPath = path.resolve(next);
217
- 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;
218
448
  } else if (token === "--resume") {
219
449
  parsed.resume = true;
220
- } else if (token === "--post-action" && next) {
221
- parsed.postAction = normalizePostAction(next);
450
+ } else if ((token === "--post-action" || token === "--postAction") && (inlineValue || next)) {
451
+ parsed.postAction = normalizePostAction(inlineValue || next);
222
452
  parsed.__provided.postAction = true;
223
- index += 1;
224
- } else if (token === "--post-action-confirmed" && next) {
225
- 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);
226
456
  parsed.__provided.postActionConfirmed = true;
227
- index += 1;
457
+ if (!inlineValue) index += 1;
228
458
  } else if (token === "--help" || token === "-h") {
229
459
  parsed.help = true;
230
460
  }
@@ -1546,6 +1776,8 @@ class RecommendScreenCli {
1546
1776
  this.latestResumeNetworkPayload = null;
1547
1777
  this.favoriteActionEvents = [];
1548
1778
  this.favoriteClickPendingSince = 0;
1779
+ this.favoriteNetworkTraces = [];
1780
+ this.webSocketByRequestId = new Map();
1549
1781
  this.resumeSourceStats = {
1550
1782
  network: 0,
1551
1783
  image_fallback: 0
@@ -1640,6 +1872,7 @@ class RecommendScreenCli {
1640
1872
 
1641
1873
  markFavoriteClickPending() {
1642
1874
  this.favoriteClickPendingSince = Date.now();
1875
+ this.favoriteNetworkTraces = [];
1643
1876
  }
1644
1877
 
1645
1878
  consumeFavoriteActionResult(since = 0) {
@@ -1656,6 +1889,30 @@ class RecommendScreenCli {
1656
1889
  return matched.action || null;
1657
1890
  }
1658
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
+
1659
1916
  cacheResumeNetworkPayload(payload, fallbackGeekId = null) {
1660
1917
  if (!payload || typeof payload !== "object") return;
1661
1918
  const geekDetail = payload.geekDetail || payload;
@@ -1734,31 +1991,55 @@ class RecommendScreenCli {
1734
1991
 
1735
1992
  if (this.favoriteClickPendingSince <= 0) return;
1736
1993
  const requestTs = Date.now();
1737
- if (requestTs < this.favoriteClickPendingSince - 1000) return;
1738
-
1739
- if (url.includes("userMark")) {
1740
- const action = /\/add(?:\/|$)|[?&]action=add/i.test(url)
1741
- ? "add"
1742
- : /\/del(?:\/|$)|[?&]action=del/i.test(url)
1743
- ? "del"
1744
- : null;
1745
- if (action) {
1746
- this.favoriteActionEvents.push({ action, ts: requestTs, source: "userMark", url });
1747
- }
1748
- return;
1749
- }
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
+ }
1750
2013
 
1751
- if (url.includes("actionLog/common.json")) {
1752
- try {
1753
- const payload = JSON.parse(params?.request?.postData || "{}");
1754
- if (payload?.action === "star-interest-click") {
1755
- const action = Number(payload?.p3) === 1 ? "add" : Number(payload?.p3) === 0 ? "del" : null;
1756
- if (action) {
1757
- this.favoriteActionEvents.push({ action, ts: requestTs, source: "actionLog", url });
1758
- }
1759
- }
1760
- } catch {}
1761
- }
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
+ });
1762
2043
  }
1763
2044
 
1764
2045
  async handleNetworkLoadingFinished(params) {
@@ -1921,6 +2202,27 @@ class RecommendScreenCli {
1921
2202
  } catch {}
1922
2203
  });
1923
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
+ }
1924
2226
  if (typeof this.Network.loadingFinished === "function") {
1925
2227
  this.Network.loadingFinished((params) => {
1926
2228
  this.handleNetworkLoadingFinished(params).catch(() => {});
@@ -2444,7 +2746,8 @@ class RecommendScreenCli {
2444
2746
  async favoriteCandidate(options = {}) {
2445
2747
  if (this.args.pageScope === "featured") {
2446
2748
  if (options.alreadyInterested === true) {
2447
- return { actionTaken: "already_favorited" };
2749
+ log("[FAVORITE] network profile indicates alreadyInterested=true,跳过点击以避免误取消收藏。");
2750
+ return { actionTaken: "already_favorited", source: "network_profile" };
2448
2751
  }
2449
2752
  if (!this.featuredCalibration?.position) {
2450
2753
  throw this.buildError(
@@ -2459,6 +2762,7 @@ class RecommendScreenCli {
2459
2762
 
2460
2763
  const base = this.featuredCalibration.position;
2461
2764
  const maxClicks = 5;
2765
+ let detectedAlreadyFavoritedByNetwork = false;
2462
2766
  for (let clickIndex = 0; clickIndex < maxClicks; clickIndex += 1) {
2463
2767
  const clickStartedAt = Date.now();
2464
2768
  this.markFavoriteClickPending();
@@ -2474,22 +2778,38 @@ class RecommendScreenCli {
2474
2778
  }
2475
2779
 
2476
2780
  let sawDel = false;
2477
- for (let index = 0; index < 10; index += 1) {
2781
+ for (let index = 0; index < 14; index += 1) {
2478
2782
  await sleep(humanDelay(260, 80));
2479
2783
  const networkAction = this.consumeFavoriteActionResult(clickStartedAt);
2480
2784
  if (networkAction === "add") {
2481
- return { actionTaken: "favorite" };
2785
+ return detectedAlreadyFavoritedByNetwork
2786
+ ? { actionTaken: "already_favorited", re_favorited: true }
2787
+ : { actionTaken: "favorite" };
2482
2788
  }
2483
2789
  if (networkAction === "del") {
2790
+ detectedAlreadyFavoritedByNetwork = true;
2791
+ log("[FAVORITE] 检测到 network=del,推断该人选原本已收藏,继续点击恢复为收藏状态。");
2484
2792
  sawDel = true;
2485
2793
  break;
2486
2794
  }
2487
2795
  }
2488
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
+ }
2489
2803
  break;
2490
2804
  }
2491
2805
  }
2492
2806
 
2807
+ if (detectedAlreadyFavoritedByNetwork) {
2808
+ throw this.buildError(
2809
+ "FAVORITE_BUTTON_FAILED",
2810
+ "精选页检测到 network del(原本已收藏),但后续未检测到 network add(恢复收藏)成功信号。"
2811
+ );
2812
+ }
2493
2813
  throw this.buildError("FAVORITE_BUTTON_FAILED", "精选页收藏未检测到 network add 成功信号。");
2494
2814
  }
2495
2815
 
@@ -2700,6 +3020,10 @@ class RecommendScreenCli {
2700
3020
  if (!this.args.baseUrl || !this.args.apiKey || !this.args.model) {
2701
3021
  throw this.buildError("SCREEN_CONFIG_ERROR", "Missing baseUrl/apiKey/model", false);
2702
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
+ );
2703
3027
 
2704
3028
  if (!(this.args.postActionConfirmed === true && this.args.postAction)) {
2705
3029
  this.args.postAction = await promptPostAction();
@@ -2888,23 +3212,41 @@ class RecommendScreenCli {
2888
3212
  effectiveAction = "favorite";
2889
3213
  this.greetLimitFallbackCount += 1;
2890
3214
  }
2891
- const actionResult = effectiveAction === "favorite"
2892
- ? await this.favoriteCandidate({
2893
- alreadyInterested: networkCandidateInfo?.alreadyInterested === true
2894
- })
2895
- : effectiveAction === "greet"
2896
- ? await this.greetCandidate()
2897
- : { 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
+ }
2898
3235
  if (actionResult.actionTaken === "greet") {
2899
3236
  this.greetCount += 1;
2900
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;
2901
3243
  this.passedCandidates.push({
2902
3244
  name: candidateProfile.name,
2903
3245
  school: candidateProfile.school,
2904
3246
  major: candidateProfile.major,
2905
3247
  company: candidateProfile.company,
2906
3248
  position: candidateProfile.position,
2907
- reason: screening.reason || screening.summary || "",
3249
+ reason: mergedReason,
2908
3250
  action: actionResult.actionTaken,
2909
3251
  geekId: nextCandidate.geek_id,
2910
3252
  summary: screening.summary,
@@ -3060,8 +3402,13 @@ if (require.main === module) {
3060
3402
  __testables: {
3061
3403
  MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES,
3062
3404
  RESUME_CAPTURE_MAX_ATTEMPTS,
3063
- RESUME_CAPTURE_WAIT_MS
3405
+ RESUME_CAPTURE_WAIT_MS,
3406
+ parseFavoriteActionFromPostData,
3407
+ parseFavoriteActionFromRequest,
3408
+ parseFavoriteActionFromKnownRequest,
3409
+ parseFavoriteActionFromActionLog,
3410
+ parseFavoriteActionFromWsPayload,
3411
+ isRecoverablePostActionError
3064
3412
  }
3065
3413
  };
3066
3414
  }
3067
-
@@ -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