@reconcrap/boss-recommend-mcp 1.2.6 → 1.2.8

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.
@@ -15,6 +15,9 @@ const RESUME_CAPTURE_RETRY_DELAY_MS = 1200;
15
15
  const MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES = 10;
16
16
  const DEFAULT_VISION_MAX_IMAGE_PIXELS = 36000000;
17
17
  const DEFAULT_VISION_RETRY_MAX_IMAGE_PIXELS = 30000000;
18
+ const DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS = 24000;
19
+ const DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS = 1200;
20
+ const DEFAULT_TEXT_MODEL_MAX_CHUNKS = 12;
18
21
  let visionSharpFactory = null;
19
22
  const PAGE_SCOPE_TAB_STATUS = {
20
23
  recommend: "0",
@@ -24,6 +27,204 @@ const PAGE_SCOPE_TAB_STATUS = {
24
27
  const BOTTOM_HINT_KEYWORDS = ["没有更多", "已显示全部", "已经到底", "暂无更多", "推荐完了", "没有更多人选"];
25
28
  const LOAD_MORE_HINT_KEYWORDS = ["滚动加载更多", "下滑加载更多", "继续下滑", "继续滑动", "滑动加载", "正在加载", "加载中"];
26
29
 
30
+ function getHealingRulesPath() {
31
+ const fromEnv = normalizeText(process.env.BOSS_RECOMMEND_HEALING_RULES_FILE || "");
32
+ return fromEnv
33
+ ? path.resolve(fromEnv)
34
+ : path.resolve(__dirname, "..", "..", "src", "recommend-healing-rules.json");
35
+ }
36
+
37
+ function loadHealingRules() {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(getHealingRulesPath(), "utf8"));
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ function getHealingValue(root, pathParts, fallback) {
46
+ let current = root;
47
+ for (const part of pathParts) {
48
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
49
+ current = undefined;
50
+ break;
51
+ }
52
+ current = current[part];
53
+ }
54
+ if (Array.isArray(current) && current.length > 0) {
55
+ return current.map((item) => String(item));
56
+ }
57
+ if (current && typeof current === "object" && !Array.isArray(current)) {
58
+ return JSON.parse(JSON.stringify(current));
59
+ }
60
+ if (typeof current === "string") return current;
61
+ return fallback;
62
+ }
63
+
64
+ function compilePatternList(patterns = []) {
65
+ return (Array.isArray(patterns) ? patterns : [])
66
+ .map((pattern) => {
67
+ try {
68
+ return new RegExp(String(pattern), "i");
69
+ } catch {
70
+ return null;
71
+ }
72
+ })
73
+ .filter(Boolean);
74
+ }
75
+
76
+ function firstMatchingPattern(text, patterns = []) {
77
+ const normalized = String(text || "");
78
+ for (const pattern of compilePatternList(patterns)) {
79
+ if (pattern.test(normalized)) return pattern.source;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ function buildFirstSelectorLookupExpression(selectors = [], rootExpr = "document") {
85
+ return `(() => {
86
+ const selectors = ${JSON.stringify(selectors)};
87
+ for (const selector of selectors) {
88
+ try {
89
+ const node = ${rootExpr}.querySelector(selector);
90
+ if (node) return node;
91
+ } catch {}
92
+ }
93
+ return null;
94
+ })()`;
95
+ }
96
+
97
+ function buildSelectorCollectionExpression(selectors = [], rootExpr = "document") {
98
+ return `(() => {
99
+ const selectors = ${JSON.stringify(selectors)};
100
+ const nodes = [];
101
+ for (const selector of selectors) {
102
+ try {
103
+ nodes.push(...Array.from(${rootExpr}.querySelectorAll(selector)));
104
+ } catch {}
105
+ }
106
+ return Array.from(new Set(nodes));
107
+ })()`;
108
+ }
109
+
110
+ const HEALING_RULES = loadHealingRules();
111
+ const RECOMMEND_IFRAME_SELECTORS = getHealingValue(
112
+ HEALING_RULES,
113
+ ["selectors", "top", "recommend_iframe"],
114
+ ['iframe[name="recommendFrame"]', 'iframe[src*="/web/frame/recommend/"]', "iframe"]
115
+ );
116
+ const RECOMMEND_CARD_SELECTORS = getHealingValue(HEALING_RULES, ["selectors", "frame", "recommend_cards"], ["ul.card-list > li.card-item"]);
117
+ const FEATURED_CARD_SELECTORS = getHealingValue(HEALING_RULES, ["selectors", "frame", "featured_cards"], ["li.geek-info-card"]);
118
+ const LATEST_CARD_SELECTORS = getHealingValue(HEALING_RULES, ["selectors", "frame", "latest_cards"], [".candidate-card-wrap"]);
119
+ const RECOMMEND_TAB_SELECTORS = getHealingValue(
120
+ HEALING_RULES,
121
+ ["selectors", "frame", "tab_items"],
122
+ ["li.tab-item[data-status]", 'li[data-status][class*="tab"]']
123
+ );
124
+ const DETAIL_POPUP_SELECTORS = getHealingValue(
125
+ HEALING_RULES,
126
+ ["selectors", "detail", "popup"],
127
+ [
128
+ ".boss-popup__wrapper",
129
+ ".boss-popup_wrapper",
130
+ ".boss-dialog_wrapper",
131
+ ".dialog-wrap.active",
132
+ ".boss-dialog",
133
+ ".geek-detail-modal",
134
+ ".resume-item-detail"
135
+ ]
136
+ );
137
+ const DETAIL_RESUME_IFRAME_SELECTORS = getHealingValue(
138
+ HEALING_RULES,
139
+ ["selectors", "detail", "resume_iframe"],
140
+ ['iframe[src*="/web/frame/c-resume/"]', 'iframe[name*="resume"]']
141
+ );
142
+ const DETAIL_CLOSE_SELECTORS = getHealingValue(
143
+ HEALING_RULES,
144
+ ["selectors", "detail", "close_button"],
145
+ [
146
+ ".boss-popup__close",
147
+ ".popup-close",
148
+ ".modal-close",
149
+ ".dialog-close",
150
+ ".close-btn",
151
+ 'button[aria-label*="关闭"]',
152
+ 'button[title*="关闭"]',
153
+ ".icon-close"
154
+ ]
155
+ );
156
+ const FAVORITE_BUTTON_SELECTORS = getHealingValue(
157
+ HEALING_RULES,
158
+ ["selectors", "detail", "favorite_button"],
159
+ [".like-icon-and-text"]
160
+ );
161
+ const GREET_BUTTON_RECOMMEND_SELECTORS = getHealingValue(
162
+ HEALING_RULES,
163
+ ["selectors", "detail", "greet_button_recommend"],
164
+ [
165
+ "button.btn-v2.btn-sure-v2.btn-greet",
166
+ ".resume-footer.item-operate button.btn-v2",
167
+ ".resume-footer-wrap button.btn-v2",
168
+ ".resume-footer.item-operate button",
169
+ ".resume-footer-wrap button"
170
+ ]
171
+ );
172
+ const GREET_BUTTON_FEATURED_SELECTORS = getHealingValue(
173
+ HEALING_RULES,
174
+ ["selectors", "detail", "greet_button_featured"],
175
+ [
176
+ "button.btn-v2.position-rights.btn-sure-v2",
177
+ "button.btn-v2.btn-sure-v2.position-rights",
178
+ ".resume-footer.item-operate button.btn-v2",
179
+ ".resume-footer-wrap button.btn-v2",
180
+ ".resume-footer.item-operate button",
181
+ ".resume-footer-wrap button"
182
+ ]
183
+ );
184
+ const REFRESH_FINISHED_WRAP_SELECTORS = getHealingValue(HEALING_RULES, ["selectors", "frame", "refresh_finished_wrap"], [".finished-wrap"]);
185
+ const REFRESH_BUTTON_SELECTORS = getHealingValue(
186
+ HEALING_RULES,
187
+ ["selectors", "frame", "refresh_button"],
188
+ [".finished-wrap .btn.btn-refresh", ".finished-wrap .btn-refresh", ".no-data-refresh .btn-refresh"]
189
+ );
190
+ const RESUME_INFO_URL_PATTERNS = getHealingValue(
191
+ HEALING_RULES,
192
+ ["network", "resume", "info_url_patterns"],
193
+ [
194
+ "\\/wapi\\/zpjob\\/view\\/geek\\/info\\b",
195
+ "\\/wapi\\/zpitem\\/web\\/boss\\/[^?#]*\\/geek\\/info\\b",
196
+ "\\/boss\\/[^?#]*\\/geek\\/info\\b",
197
+ "\\/geek\\/info\\b",
198
+ "[?&](?:geekid|geek_id|encryptgeekid|encryptjid|jid|securityid)="
199
+ ]
200
+ );
201
+ const RESUME_RELATED_KEYWORDS = getHealingValue(
202
+ HEALING_RULES,
203
+ ["network", "resume", "related_keywords"],
204
+ ["geek", "resume", "candidate", "friend"]
205
+ );
206
+ const FAVORITE_ADD_PATTERNS = getHealingValue(
207
+ HEALING_RULES,
208
+ ["network", "favorite", "add_patterns"],
209
+ [
210
+ "\\/add(?:\\/|$)|[?&](?:action|op|operation|type)=add\\b|[?&](?:status|p3|favorite|collect|interested)=1\\b",
211
+ "(?:^|[_\\W])(add|favorite|collect|interest(?:ed)?)(?:$|[_\\W])"
212
+ ]
213
+ );
214
+ const FAVORITE_REMOVE_PATTERNS = getHealingValue(
215
+ HEALING_RULES,
216
+ ["network", "favorite", "remove_patterns"],
217
+ [
218
+ "\\/del(?:\\/|$)|[?&](?:action|op|operation|type)=del\\b|[?&](?:status|p3|favorite|collect|interested)=0\\b",
219
+ "(?:^|[_\\W])(del|delete|remove|cancel|unfavorite|uncollect|uninterest)(?:$|[_\\W])"
220
+ ]
221
+ );
222
+ const FAVORITE_ACTIONLOG_NAME = getHealingValue(
223
+ HEALING_RULES,
224
+ ["network", "favorite", "actionlog_action_name"],
225
+ "star-interest-click"
226
+ );
227
+
27
228
  function classifyFinishedWrapState(finishedWrapText, refreshButtonVisible = false) {
28
229
  const normalizedText = normalizeText(finishedWrapText);
29
230
  const matchedBottomKeyword = BOTTOM_HINT_KEYWORDS.find((keyword) => normalizedText.includes(keyword)) || null;
@@ -135,6 +336,60 @@ function isVisionImageSizeLimitMessage(message) {
135
336
  );
136
337
  }
137
338
 
339
+ function isTextContextLimitMessage(message) {
340
+ const text = normalizeText(message).toLowerCase();
341
+ if (!text) return false;
342
+ return (
343
+ /context length|maximum context|too many tokens|max(?:imum)? token|prompt is too long|input is too long|token limit|上下文|超出.*token|超过.*token|输入过长/i.test(text)
344
+ );
345
+ }
346
+
347
+ function toStringArray(value, maxItems = 8) {
348
+ if (!Array.isArray(value)) return [];
349
+ const normalized = [];
350
+ for (const item of value) {
351
+ const text = normalizeText(item);
352
+ if (!text) continue;
353
+ normalized.push(text);
354
+ if (normalized.length >= maxItems) break;
355
+ }
356
+ return normalized;
357
+ }
358
+
359
+ function splitTextByChunks(text, chunkSize, overlap, maxChunks) {
360
+ const source = String(text || "");
361
+ if (!source) return [];
362
+
363
+ const safeChunkSize = Math.max(1000, parsePositiveInteger(chunkSize) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS);
364
+ const safeOverlap = Math.max(0, Math.min(safeChunkSize - 1, parsePositiveInteger(overlap) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS));
365
+ const safeMaxChunks = Math.max(1, parsePositiveInteger(maxChunks) || DEFAULT_TEXT_MODEL_MAX_CHUNKS);
366
+
367
+ const chunks = [];
368
+ let start = 0;
369
+ while (start < source.length && chunks.length < safeMaxChunks) {
370
+ const end = Math.min(source.length, start + safeChunkSize);
371
+ chunks.push({
372
+ text: source.slice(start, end),
373
+ start,
374
+ end
375
+ });
376
+ if (end >= source.length) break;
377
+ start = Math.max(0, end - safeOverlap);
378
+ }
379
+
380
+ if (chunks.length > 0) {
381
+ const last = chunks[chunks.length - 1];
382
+ if (last.end < source.length) {
383
+ chunks[chunks.length - 1] = {
384
+ text: source.slice(last.start),
385
+ start: last.start,
386
+ end: source.length
387
+ };
388
+ }
389
+ }
390
+ return chunks;
391
+ }
392
+
138
393
  function normalizePostAction(value) {
139
394
  const normalized = normalizeText(value).toLowerCase();
140
395
  if (!normalized) return null;
@@ -292,10 +547,10 @@ function parseFavoriteActionFromRequest(url, postData = "") {
292
547
  const normalizedUrl = normalizeText(url).toLowerCase();
293
548
  if (!normalizedUrl) return null;
294
549
 
295
- if (/\/add(?:\/|$)|[?&](?:action|op|operation|type)=add\b|[?&](?:status|p3|favorite|collect|interested)=1\b/i.test(normalizedUrl)) {
550
+ if (firstMatchingPattern(normalizedUrl, FAVORITE_ADD_PATTERNS)) {
296
551
  return "add";
297
552
  }
298
- if (/\/del(?:\/|$)|[?&](?:action|op|operation|type)=del\b|[?&](?:status|p3|favorite|collect|interested)=0\b/i.test(normalizedUrl)) {
553
+ if (firstMatchingPattern(normalizedUrl, FAVORITE_REMOVE_PATTERNS)) {
299
554
  return "del";
300
555
  }
301
556
 
@@ -307,14 +562,14 @@ function parseFavoriteActionFromActionLog(postData = "") {
307
562
  if (!raw) return null;
308
563
  try {
309
564
  const payload = JSON.parse(raw);
310
- if (normalizeText(payload?.action).toLowerCase() !== "star-interest-click") return null;
565
+ if (normalizeText(payload?.action).toLowerCase() !== normalizeText(FAVORITE_ACTIONLOG_NAME).toLowerCase()) return null;
311
566
  return parseFavoriteActionValue(payload?.p3);
312
567
  } catch {}
313
568
 
314
569
  try {
315
570
  const params = new URLSearchParams(raw);
316
571
  const actionName = normalizeText(params.get("action")).toLowerCase();
317
- if (actionName !== "star-interest-click") return null;
572
+ if (actionName !== normalizeText(FAVORITE_ACTIONLOG_NAME).toLowerCase()) return null;
318
573
  return parseFavoriteActionValue(params.get("p3"));
319
574
  } catch {}
320
575
  return null;
@@ -345,7 +600,7 @@ function parseFavoriteActionFromWsPayload(payload, depth = 0) {
345
600
  if (depth > 3 || payload === null || payload === undefined) return null;
346
601
 
347
602
  if (typeof payload === "object") {
348
- if (normalizeText(payload?.action).toLowerCase() === "star-interest-click") {
603
+ if (normalizeText(payload?.action).toLowerCase() === normalizeText(FAVORITE_ACTIONLOG_NAME).toLowerCase()) {
349
604
  const strictAction = parseFavoriteActionValue(payload?.p3);
350
605
  if (strictAction) return strictAction;
351
606
  }
@@ -597,7 +852,7 @@ async function promptMissingInputs(args) {
597
852
  if (args.targetCount === null) {
598
853
  const targetCount = await askWithValidation(
599
854
  ask,
600
- "请输入目标筛选人数(--targetCount,可留空表示不设上限): ",
855
+ "请输入目标通过人数(--targetCount,可留空表示不设上限): ",
601
856
  (value) => parsePositiveInteger(value),
602
857
  { allowEmpty: true }
603
858
  );
@@ -830,23 +1085,13 @@ function extractResumePayloadFromResponseBody(parsedBody) {
830
1085
  function isResumeInfoRequestUrl(url) {
831
1086
  const normalizedUrl = normalizeText(url).toLowerCase();
832
1087
  if (!normalizedUrl || !normalizedUrl.includes("/wapi/")) return false;
833
- if (!normalizedUrl.includes("geek") || !normalizedUrl.includes("info")) return false;
834
- if (/\/wapi\/zpjob\/view\/geek\/info\b/.test(normalizedUrl)) return true;
835
- if (/\/wapi\/zpitem\/web\/boss\/[^?#]*\/geek\/info\b/.test(normalizedUrl)) return true;
836
- if (/\/boss\/[^?#]*\/geek\/info\b/.test(normalizedUrl)) return true;
837
- if (/\/geek\/info\b/.test(normalizedUrl)) return true;
838
- return /[?&](?:geekid|geek_id|encryptgeekid|encryptjid|jid|securityid)=/.test(normalizedUrl);
1088
+ return Boolean(firstMatchingPattern(normalizedUrl, RESUME_INFO_URL_PATTERNS));
839
1089
  }
840
1090
 
841
1091
  function isResumeRelatedWapiUrl(url) {
842
1092
  const normalizedUrl = normalizeText(url).toLowerCase();
843
1093
  if (!normalizedUrl || !normalizedUrl.includes("/wapi/")) return false;
844
- return (
845
- normalizedUrl.includes("geek")
846
- || normalizedUrl.includes("resume")
847
- || normalizedUrl.includes("candidate")
848
- || normalizedUrl.includes("friend")
849
- );
1094
+ return RESUME_RELATED_KEYWORDS.some((keyword) => normalizedUrl.includes(String(keyword).toLowerCase()));
850
1095
  }
851
1096
 
852
1097
  function formatResumeApiData(data) {
@@ -994,20 +1239,18 @@ async function promptMaxGreetCount() {
994
1239
 
995
1240
  function buildListCandidatesExpr(processedKeys) {
996
1241
  return `((processedKeys) => {
997
- const frame = document.querySelector('iframe[name="recommendFrame"]')
998
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
999
- || document.querySelector('iframe');
1242
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1000
1243
  if (!frame || !frame.contentDocument) {
1001
1244
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1002
1245
  }
1003
1246
  const doc = frame.contentDocument;
1004
1247
  const frameRect = frame.getBoundingClientRect();
1005
1248
  const processed = new Set(processedKeys || []);
1006
- const cards = Array.from(doc.querySelectorAll('ul.card-list > li.card-item'));
1007
- const featuredCards = Array.from(doc.querySelectorAll('li.geek-info-card'));
1008
- const latestCards = Array.from(doc.querySelectorAll('.candidate-card-wrap'));
1249
+ const cards = ${buildSelectorCollectionExpression(RECOMMEND_CARD_SELECTORS, "doc")};
1250
+ const featuredCards = ${buildSelectorCollectionExpression(FEATURED_CARD_SELECTORS, "doc")};
1251
+ const latestCards = ${buildSelectorCollectionExpression(LATEST_CARD_SELECTORS, "doc")};
1009
1252
  const textOf = (el) => String(el ? el.textContent : '').replace(/\s+/g, ' ').trim();
1010
- const tabs = Array.from(doc.querySelectorAll('li.tab-item[data-status], li[data-status][class*="tab"]'));
1253
+ const tabs = ${buildSelectorCollectionExpression(RECOMMEND_TAB_SELECTORS, "doc")};
1011
1254
  const activeTab = tabs.find((node) => /(?:^|\\s)(?:curr|current|active|selected)(?:\\s|$)/i.test(String(node.className || ''))) || null;
1012
1255
  const activeStatus = activeTab ? String(activeTab.getAttribute('data-status') || '') : '';
1013
1256
  const recommendCandidates = cards.map((card, index) => {
@@ -1142,22 +1385,20 @@ function buildListCandidatesExpr(processedKeys) {
1142
1385
  }
1143
1386
 
1144
1387
  const jsGetListState = `(() => {
1145
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1146
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1147
- || document.querySelector('iframe');
1388
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1148
1389
  if (!frame || !frame.contentDocument) {
1149
1390
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1150
1391
  }
1151
1392
  const doc = frame.contentDocument;
1152
1393
  const body = doc.body;
1153
1394
  const frameRect = frame.getBoundingClientRect();
1154
- const cards = Array.from(doc.querySelectorAll('ul.card-list > li.card-item'));
1395
+ const cards = ${buildSelectorCollectionExpression(RECOMMEND_CARD_SELECTORS, "doc")};
1155
1396
  const candidateCards = cards.filter((card) => card.querySelector('.card-inner[data-geekid]'));
1156
- const featuredCards = Array.from(doc.querySelectorAll('li.geek-info-card'));
1397
+ const featuredCards = ${buildSelectorCollectionExpression(FEATURED_CARD_SELECTORS, "doc")};
1157
1398
  const featuredCandidates = featuredCards.filter((card) => card.querySelector('a[data-geekid]'));
1158
- const latestCards = Array.from(doc.querySelectorAll('.candidate-card-wrap'));
1399
+ const latestCards = ${buildSelectorCollectionExpression(LATEST_CARD_SELECTORS, "doc")};
1159
1400
  const latestCandidates = latestCards.filter((card) => card.querySelector('.card-inner[data-geek], [data-geek]'));
1160
- const tabs = Array.from(doc.querySelectorAll('li.tab-item[data-status], li[data-status][class*="tab"]'));
1401
+ const tabs = ${buildSelectorCollectionExpression(RECOMMEND_TAB_SELECTORS, "doc")};
1161
1402
  const activeTab = tabs.find((node) => /(?:^|\\s)(?:curr|current|active|selected)(?:\\s|$)/i.test(String(node.className || ''))) || null;
1162
1403
  const activeTabStatus = activeTab ? String(activeTab.getAttribute('data-status') || '') : '';
1163
1404
  const inferredStatus = activeTabStatus
@@ -1201,18 +1442,16 @@ const jsGetListState = `(() => {
1201
1442
  })()`;
1202
1443
 
1203
1444
  const jsScrollList = `(() => {
1204
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1205
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1206
- || document.querySelector('iframe');
1445
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1207
1446
  if (!frame || !frame.contentDocument) {
1208
1447
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1209
1448
  }
1210
1449
  const doc = frame.contentDocument;
1211
1450
  const body = doc.body;
1212
- const recommendCards = Array.from(doc.querySelectorAll('ul.card-list > li.card-item')).filter((card) => card.querySelector('.card-inner[data-geekid]'));
1213
- const featuredCards = Array.from(doc.querySelectorAll('li.geek-info-card')).filter((card) => card.querySelector('a[data-geekid]'));
1214
- const latestCards = Array.from(doc.querySelectorAll('.candidate-card-wrap')).filter((card) => card.querySelector('.card-inner[data-geek], [data-geek]'));
1215
- const tabs = Array.from(doc.querySelectorAll('li.tab-item[data-status], li[data-status][class*="tab"]'));
1451
+ const recommendCards = ${buildSelectorCollectionExpression(RECOMMEND_CARD_SELECTORS, "doc")}.filter((card) => card.querySelector('.card-inner[data-geekid]'));
1452
+ const featuredCards = ${buildSelectorCollectionExpression(FEATURED_CARD_SELECTORS, "doc")}.filter((card) => card.querySelector('a[data-geekid]'));
1453
+ const latestCards = ${buildSelectorCollectionExpression(LATEST_CARD_SELECTORS, "doc")}.filter((card) => card.querySelector('.card-inner[data-geek], [data-geek]'));
1454
+ const tabs = ${buildSelectorCollectionExpression(RECOMMEND_TAB_SELECTORS, "doc")};
1216
1455
  const activeTab = tabs.find((node) => /(?:^|\\s)(?:curr|current|active|selected)(?:\\s|$)/i.test(String(node.className || ''))) || null;
1217
1456
  const activeStatus = activeTab ? String(activeTab.getAttribute('data-status') || '') : '';
1218
1457
  const inferredStatus = activeStatus
@@ -1249,9 +1488,7 @@ const jsScrollList = `(() => {
1249
1488
  })()`;
1250
1489
 
1251
1490
  const jsDetectBottom = `(() => {
1252
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1253
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1254
- || document.querySelector('iframe');
1491
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1255
1492
  if (!frame || !frame.contentDocument) {
1256
1493
  return { isBottom: false, error: 'NO_RECOMMEND_IFRAME' };
1257
1494
  }
@@ -1267,8 +1504,9 @@ const jsDetectBottom = `(() => {
1267
1504
  const rect = el.getBoundingClientRect();
1268
1505
  return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
1269
1506
  };
1270
- const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
1271
- const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
1507
+ const finishedWrap = ${buildSelectorCollectionExpression(REFRESH_FINISHED_WRAP_SELECTORS, "doc")}
1508
+ .find((el) => isVisible(el)) || null;
1509
+ const refreshButton = ${buildSelectorCollectionExpression(REFRESH_BUTTON_SELECTORS, "doc")}
1272
1510
  .find((el) => isVisible(el)) || null;
1273
1511
  const keywords = ${JSON.stringify(BOTTOM_HINT_KEYWORDS)};
1274
1512
  const loadMoreKeywords = ${JSON.stringify(LOAD_MORE_HINT_KEYWORDS)};
@@ -1316,13 +1554,7 @@ const jsWaitForDetail = `(() => {
1316
1554
  const rect = el.getBoundingClientRect();
1317
1555
  return rect.width > 2 && rect.height > 2;
1318
1556
  };
1319
- const topSignals = [
1320
- '.dialog-wrap.active',
1321
- '.boss-popup__wrapper',
1322
- '.boss-popup_wrapper',
1323
- 'iframe[src*="/web/frame/c-resume/"]',
1324
- 'iframe[name*="resume"]'
1325
- ];
1557
+ const topSignals = ${JSON.stringify([...DETAIL_POPUP_SELECTORS, ...DETAIL_RESUME_IFRAME_SELECTORS])};
1326
1558
  for (const sel of topSignals) {
1327
1559
  const nodes = Array.from(document.querySelectorAll(sel));
1328
1560
  for (const node of nodes) {
@@ -1331,9 +1563,7 @@ const jsWaitForDetail = `(() => {
1331
1563
  }
1332
1564
  }
1333
1565
  }
1334
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1335
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1336
- || document.querySelector('iframe');
1566
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1337
1567
  if (!frame || !frame.contentDocument) {
1338
1568
  return { open: false, error: 'NO_RECOMMEND_IFRAME' };
1339
1569
  }
@@ -1356,10 +1586,10 @@ const jsWaitForDetail = `(() => {
1356
1586
  if (viewportWidth <= 0 || viewportHeight <= 0) return el.offsetParent !== null;
1357
1587
  return rect.right > 0 && rect.bottom > 0 && rect.left < viewportWidth && rect.top < viewportHeight;
1358
1588
  };
1359
- const close = doc.querySelector('.boss-popup__close');
1360
- const favorite = doc.querySelector('.like-icon-and-text');
1361
- const greet = doc.querySelector('button.btn-v2.btn-sure-v2.btn-greet');
1362
- const resumeFrame = doc.querySelector('iframe[src*="/web/frame/c-resume/"], iframe[name*="resume"]');
1589
+ const close = ${buildFirstSelectorLookupExpression(DETAIL_CLOSE_SELECTORS, "doc")};
1590
+ const favorite = ${buildFirstSelectorLookupExpression(FAVORITE_BUTTON_SELECTORS, "doc")};
1591
+ const greet = ${buildFirstSelectorLookupExpression(GREET_BUTTON_RECOMMEND_SELECTORS, "doc")};
1592
+ const resumeFrame = ${buildFirstSelectorLookupExpression(DETAIL_RESUME_IFRAME_SELECTORS, "doc")};
1363
1593
  const open = Boolean(
1364
1594
  isVisibleInViewport(close)
1365
1595
  || isVisibleInViewport(favorite)
@@ -1392,16 +1622,7 @@ const jsCloseDetail = `(() => {
1392
1622
  const rect = el.getBoundingClientRect();
1393
1623
  return rect.width > 2 && rect.height > 2;
1394
1624
  };
1395
- const topCloseSelectors = [
1396
- '.boss-popup__close',
1397
- '.popup-close',
1398
- '.modal-close',
1399
- '.dialog-close',
1400
- '.close-btn',
1401
- 'button[aria-label*="关闭"]',
1402
- 'button[title*="关闭"]',
1403
- '.icon-close'
1404
- ];
1625
+ const topCloseSelectors = ${JSON.stringify(DETAIL_CLOSE_SELECTORS)};
1405
1626
  for (const sel of topCloseSelectors) {
1406
1627
  const nodes = Array.from(document.querySelectorAll(sel));
1407
1628
  for (const node of nodes) {
@@ -1413,9 +1634,7 @@ const jsCloseDetail = `(() => {
1413
1634
  }
1414
1635
  }
1415
1636
 
1416
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1417
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1418
- || document.querySelector('iframe');
1637
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1419
1638
  if (!frame || !frame.contentDocument) {
1420
1639
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1421
1640
  }
@@ -1445,16 +1664,7 @@ const jsCloseDetail = `(() => {
1445
1664
  return rect.right > 0 && rect.bottom > 0 && rect.left < viewportWidth && rect.top < viewportHeight;
1446
1665
  };
1447
1666
 
1448
- const directCloseSelectors = [
1449
- '.boss-popup__close',
1450
- '.popup-close',
1451
- '.modal-close',
1452
- '.dialog-close',
1453
- '.close-btn',
1454
- 'button[aria-label*="关闭"]',
1455
- 'button[title*="关闭"]',
1456
- '.icon-close'
1457
- ];
1667
+ const directCloseSelectors = ${JSON.stringify(DETAIL_CLOSE_SELECTORS)};
1458
1668
  for (const sel of directCloseSelectors) {
1459
1669
  const nodes = Array.from(doc.querySelectorAll(sel));
1460
1670
  for (const node of nodes) {
@@ -1532,15 +1742,7 @@ const jsIsDetailClosed = `(() => {
1532
1742
  return { closed: false, reason: 'top know button visible' };
1533
1743
  }
1534
1744
 
1535
- const topPopupSelectors = [
1536
- '.boss-popup__wrapper',
1537
- '.boss-popup_wrapper',
1538
- '.boss-dialog_wrapper',
1539
- '.dialog-wrap.active',
1540
- '.boss-dialog',
1541
- '[class*="popup"][class*="wrapper"]',
1542
- '[class*="dialog"][class*="wrapper"]'
1543
- ];
1745
+ const topPopupSelectors = ${JSON.stringify(DETAIL_POPUP_SELECTORS)};
1544
1746
  for (const sel of topPopupSelectors) {
1545
1747
  const nodes = Array.from(document.querySelectorAll(sel));
1546
1748
  for (const node of nodes) {
@@ -1552,9 +1754,7 @@ const jsIsDetailClosed = `(() => {
1552
1754
  }
1553
1755
  }
1554
1756
 
1555
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1556
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1557
- || document.querySelector('iframe');
1757
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1558
1758
  if (!frame || !frame.contentDocument) {
1559
1759
  return { closed: true, reason: 'NO_RECOMMEND_IFRAME' };
1560
1760
  }
@@ -1579,17 +1779,7 @@ const jsIsDetailClosed = `(() => {
1579
1779
  return rect.right > 0 && rect.bottom > 0 && rect.left < viewportWidth && rect.top < viewportHeight;
1580
1780
  };
1581
1781
 
1582
- const popupSelectors = [
1583
- '.boss-popup__wrapper',
1584
- '.boss-popup_wrapper',
1585
- '.boss-dialog_wrapper',
1586
- '.dialog-wrap.active',
1587
- '.boss-dialog',
1588
- '[class*="popup"][class*="wrapper"]',
1589
- '[class*="dialog"][class*="wrapper"]',
1590
- '.geek-detail-modal',
1591
- '.resume-item-detail'
1592
- ];
1782
+ const popupSelectors = ${JSON.stringify(DETAIL_POPUP_SELECTORS)};
1593
1783
  for (const sel of popupSelectors) {
1594
1784
  const nodes = Array.from(doc.querySelectorAll(sel));
1595
1785
  for (const node of nodes) {
@@ -1599,12 +1789,7 @@ const jsIsDetailClosed = `(() => {
1599
1789
  }
1600
1790
  }
1601
1791
 
1602
- const detailSignals = [
1603
- '.like-icon-and-text',
1604
- 'button.btn-v2.btn-sure-v2.btn-greet',
1605
- 'iframe[src*="/web/frame/c-resume/"]',
1606
- 'iframe[name*="resume"]'
1607
- ];
1792
+ const detailSignals = ${JSON.stringify([...FAVORITE_BUTTON_SELECTORS, ...GREET_BUTTON_RECOMMEND_SELECTORS, ...DETAIL_RESUME_IFRAME_SELECTORS])};
1608
1793
  for (const sel of detailSignals) {
1609
1794
  const node = doc.querySelector(sel);
1610
1795
  if (isVisible(node)) {
@@ -1629,7 +1814,8 @@ const jsGetFavoriteState = `(() => {
1629
1814
  };
1630
1815
  const resolveFavorite = (doc, offsetX, offsetY, scope) => {
1631
1816
  if (!doc) return null;
1632
- const direct = Array.from(doc.querySelectorAll('.like-icon-and-text')).find((node) => isVisible(doc, node)) || null;
1817
+ const direct = ${buildSelectorCollectionExpression(FAVORITE_BUTTON_SELECTORS, "doc")}
1818
+ .find((node) => isVisible(doc, node)) || null;
1633
1819
  const footer = doc.querySelector('.resume-footer.item-operate, .resume-footer-wrap, .resume-footer');
1634
1820
  const fromFooter = footer
1635
1821
  ? Array.from(footer.querySelectorAll('[class*="collect"], [class*="favorite"], button, .btn, span'))
@@ -1661,9 +1847,7 @@ const jsGetFavoriteState = `(() => {
1661
1847
  const topResult = resolveFavorite(document, 0, 0, 'top');
1662
1848
  if (topResult) return topResult;
1663
1849
 
1664
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1665
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1666
- || document.querySelector('iframe');
1850
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1667
1851
  if (!frame || !frame.contentDocument) {
1668
1852
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1669
1853
  }
@@ -1674,12 +1858,10 @@ const jsGetFavoriteState = `(() => {
1674
1858
  })()`;
1675
1859
 
1676
1860
  const jsClickFavoriteFallback = `(() => {
1677
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1678
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1679
- || document.querySelector('iframe');
1861
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1680
1862
  if (!frame || !frame.contentDocument) return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1681
1863
  const doc = frame.contentDocument;
1682
- const root = doc.querySelector('.like-icon-and-text');
1864
+ const root = ${buildFirstSelectorLookupExpression(FAVORITE_BUTTON_SELECTORS, "doc")};
1683
1865
  if (!root || root.offsetParent === null) return { ok: false, error: 'FAVORITE_BUTTON_NOT_FOUND' };
1684
1866
  root.click();
1685
1867
  return { ok: true };
@@ -1697,14 +1879,10 @@ const jsGetGreetStateRecommend = `(() => {
1697
1879
  const rect = el.getBoundingClientRect();
1698
1880
  return rect.width > 2 && rect.height > 2;
1699
1881
  };
1700
- const resolveGreet = (doc, offsetX, offsetY, scope) => {
1701
- if (!doc) return null;
1702
- const candidates = [
1703
- ...Array.from(doc.querySelectorAll('button.btn-v2.btn-sure-v2.btn-greet')),
1704
- ...Array.from(doc.querySelectorAll('.resume-footer.item-operate button.btn-v2, .resume-footer-wrap button.btn-v2')),
1705
- ...Array.from(doc.querySelectorAll('.resume-footer.item-operate button, .resume-footer-wrap button'))
1706
- ];
1707
- const button = candidates.find((item) => isVisible(doc, item) && /沟通|打招呼|聊一聊/.test(normalize(item.textContent))) || null;
1882
+ const resolveGreet = (doc, offsetX, offsetY, scope) => {
1883
+ if (!doc) return null;
1884
+ const candidates = ${buildSelectorCollectionExpression(GREET_BUTTON_RECOMMEND_SELECTORS, "doc")};
1885
+ const button = candidates.find((item) => isVisible(doc, item) && /沟通|打招呼|聊一聊/.test(normalize(item.textContent))) || null;
1708
1886
  if (!button) return null;
1709
1887
  const rect = button.getBoundingClientRect();
1710
1888
  return {
@@ -1718,9 +1896,7 @@ const jsGetGreetStateRecommend = `(() => {
1718
1896
  const topResult = resolveGreet(document, 0, 0, 'top');
1719
1897
  if (topResult) return topResult;
1720
1898
 
1721
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1722
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1723
- || document.querySelector('iframe');
1899
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1724
1900
  if (!frame || !frame.contentDocument) {
1725
1901
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1726
1902
  }
@@ -1737,12 +1913,10 @@ const jsClickGreetFallbackRecommend = `(() => {
1737
1913
  topButton.click();
1738
1914
  return { ok: true, scope: 'top' };
1739
1915
  }
1740
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1741
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1742
- || document.querySelector('iframe');
1916
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1743
1917
  if (!frame || !frame.contentDocument) return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1744
1918
  const doc = frame.contentDocument;
1745
- const button = doc.querySelector('button.btn-v2.btn-sure-v2.btn-greet');
1919
+ const button = ${buildFirstSelectorLookupExpression(GREET_BUTTON_RECOMMEND_SELECTORS, "doc")};
1746
1920
  if (!button || button.offsetParent === null) return { ok: false, error: 'GREET_BUTTON_NOT_FOUND' };
1747
1921
  button.click();
1748
1922
  return { ok: true };
@@ -1760,15 +1934,10 @@ const jsGetGreetStateFeatured = `(() => {
1760
1934
  const rect = el.getBoundingClientRect();
1761
1935
  return rect.width > 2 && rect.height > 2;
1762
1936
  };
1763
- const resolveGreet = (doc, offsetX, offsetY, scope) => {
1764
- if (!doc) return null;
1765
- const candidates = [
1766
- ...Array.from(doc.querySelectorAll('button.btn-v2.position-rights.btn-sure-v2')),
1767
- ...Array.from(doc.querySelectorAll('button.btn-v2.btn-sure-v2.position-rights')),
1768
- ...Array.from(doc.querySelectorAll('.resume-footer.item-operate button.btn-v2, .resume-footer-wrap button.btn-v2')),
1769
- ...Array.from(doc.querySelectorAll('.resume-footer.item-operate button, .resume-footer-wrap button'))
1770
- ];
1771
- const button = candidates.find((item) => isVisible(doc, item) && /立即沟通|沟通|打招呼|聊一聊/.test(normalize(item.textContent))) || null;
1937
+ const resolveGreet = (doc, offsetX, offsetY, scope) => {
1938
+ if (!doc) return null;
1939
+ const candidates = ${buildSelectorCollectionExpression(GREET_BUTTON_FEATURED_SELECTORS, "doc")};
1940
+ const button = candidates.find((item) => isVisible(doc, item) && /立即沟通|沟通|打招呼|聊一聊/.test(normalize(item.textContent))) || null;
1772
1941
  if (!button) return null;
1773
1942
  const rect = button.getBoundingClientRect();
1774
1943
  return {
@@ -1782,9 +1951,7 @@ const jsGetGreetStateFeatured = `(() => {
1782
1951
  const topResult = resolveGreet(document, 0, 0, 'top');
1783
1952
  if (topResult) return topResult;
1784
1953
 
1785
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1786
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1787
- || document.querySelector('iframe');
1954
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1788
1955
  if (!frame || !frame.contentDocument) {
1789
1956
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1790
1957
  }
@@ -1801,12 +1968,10 @@ const jsClickGreetFallbackFeatured = `(() => {
1801
1968
  topButton.click();
1802
1969
  return { ok: true, scope: 'top' };
1803
1970
  }
1804
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1805
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1806
- || document.querySelector('iframe');
1971
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1807
1972
  if (!frame || !frame.contentDocument) return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1808
1973
  const doc = frame.contentDocument;
1809
- const button = doc.querySelector('button.btn-v2.position-rights.btn-sure-v2, button.btn-v2.btn-sure-v2.position-rights');
1974
+ const button = ${buildFirstSelectorLookupExpression(GREET_BUTTON_FEATURED_SELECTORS, "doc")};
1810
1975
  if (!button || button.offsetParent === null) return { ok: false, error: 'GREET_BUTTON_NOT_FOUND' };
1811
1976
  button.click();
1812
1977
  return { ok: true };
@@ -3016,9 +3181,7 @@ class RecommendScreenCli {
3016
3181
  return { ok: false, error: "CANDIDATE_KEY_MISSING" };
3017
3182
  }
3018
3183
  return this.evaluate(`((candidateKey) => {
3019
- const frame = document.querySelector('iframe[name="recommendFrame"]')
3020
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
3021
- || document.querySelector('iframe');
3184
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
3022
3185
  if (!frame || !frame.contentDocument) {
3023
3186
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
3024
3187
  }
@@ -3027,11 +3190,11 @@ class RecommendScreenCli {
3027
3190
  .find((item) => (item.getAttribute('data-geekid') || '') === String(candidateKey)) || null;
3028
3191
  const latestInner = recommendInner
3029
3192
  ? null
3030
- : Array.from(doc.querySelectorAll('.candidate-card-wrap .card-inner[data-geek], .candidate-card-wrap [data-geek]'))
3193
+ : ${buildSelectorCollectionExpression([".candidate-card-wrap .card-inner[data-geek]", ".candidate-card-wrap [data-geek]"], "doc")}
3031
3194
  .find((item) => (item.getAttribute('data-geek') || '') === String(candidateKey)) || null;
3032
3195
  const featuredAnchor = (recommendInner || latestInner)
3033
3196
  ? null
3034
- : Array.from(doc.querySelectorAll('li.geek-info-card a[data-geekid], a[data-geekid]'))
3197
+ : ${buildSelectorCollectionExpression(["li.geek-info-card a[data-geekid]", "a[data-geekid]"], "doc")}
3035
3198
  .find((item) => (item.getAttribute('data-geekid') || '') === String(candidateKey)) || null;
3036
3199
  const card = recommendInner
3037
3200
  ? (recommendInner.closest('li.card-item') || recommendInner.closest('.card-item'))
@@ -3135,9 +3298,9 @@ class RecommendScreenCli {
3135
3298
  DEFAULT_VISION_MAX_IMAGE_PIXELS
3136
3299
  );
3137
3300
  const retryLimit = resolveVisionRetryPixelLimit(primaryLimit);
3138
- const preparedPrimary = await this.prepareVisionImageForModel(imagePath, primaryLimit, "primary");
3301
+ const preparedPrimary = await this.prepareVisionImageSegmentsForModel(imagePath, primaryLimit, "primary");
3139
3302
  try {
3140
- return await this.requestVisionModel(preparedPrimary.imagePath);
3303
+ return await this.requestVisionModel(preparedPrimary.imagePaths);
3141
3304
  } catch (error) {
3142
3305
  if (!isVisionImageSizeLimitMessage(error?.message || "")) {
3143
3306
  throw error;
@@ -3145,12 +3308,13 @@ class RecommendScreenCli {
3145
3308
  log(
3146
3309
  `[VISION] 检测到图片尺寸超限,准备降采样重试: ` +
3147
3310
  `primary_limit=${primaryLimit} source=${preparedPrimary.source} ` +
3148
- `source_pixels=${preparedPrimary.sourcePixels ?? "unknown"}`
3311
+ `source_pixels=${preparedPrimary.sourcePixels ?? "unknown"} ` +
3312
+ `segments=${preparedPrimary.imagePaths?.length || 1}`
3149
3313
  );
3150
3314
  }
3151
- const preparedRetry = await this.prepareVisionImageForModel(imagePath, retryLimit, "retry");
3315
+ const preparedRetry = await this.prepareVisionImageSegmentsForModel(imagePath, retryLimit, "retry");
3152
3316
  try {
3153
- return await this.requestVisionModel(preparedRetry.imagePath);
3317
+ return await this.requestVisionModel(preparedRetry.imagePaths);
3154
3318
  } catch (retryError) {
3155
3319
  if (!isVisionImageSizeLimitMessage(retryError?.message || "")) {
3156
3320
  throw retryError;
@@ -3161,11 +3325,106 @@ class RecommendScreenCli {
3161
3325
  `primary_limit=${primaryLimit}; retry_limit=${retryLimit}; ` +
3162
3326
  `source_pixels=${preparedRetry.sourcePixels ?? "unknown"}; ` +
3163
3327
  `retry_pixels=${preparedRetry.currentPixels ?? "unknown"}; ` +
3328
+ `segments=${preparedRetry.imagePaths?.length || 1}; ` +
3164
3329
  `last_error=${normalizeText(retryError?.message || retryError)}`
3165
3330
  );
3166
3331
  }
3167
3332
  }
3168
3333
 
3334
+ async prepareVisionImageSegmentsForModel(imagePath, maxPixels, attemptTag = "primary") {
3335
+ const resolvedMaxPixels = parsePositiveInteger(maxPixels);
3336
+ if (!resolvedMaxPixels) {
3337
+ return {
3338
+ imagePaths: [imagePath],
3339
+ source: "no_limit",
3340
+ sourcePixels: null,
3341
+ currentPixels: null
3342
+ };
3343
+ }
3344
+
3345
+ let sharp;
3346
+ try {
3347
+ sharp = loadVisionSharp();
3348
+ } catch (error) {
3349
+ log(`[VISION] 加载 sharp 失败,回退到单图模式: ${error?.message || error}`);
3350
+ const single = await this.prepareVisionImageForModel(imagePath, resolvedMaxPixels, attemptTag);
3351
+ return {
3352
+ imagePaths: [single.imagePath],
3353
+ source: `single_${single.source}`,
3354
+ sourcePixels: single.sourcePixels ?? null,
3355
+ currentPixels: single.currentPixels ?? null
3356
+ };
3357
+ }
3358
+
3359
+ let metadata;
3360
+ try {
3361
+ metadata = await sharp(imagePath).metadata();
3362
+ } catch (error) {
3363
+ log(`[VISION] 读取图片尺寸失败,回退到单图模式: ${error?.message || error}`);
3364
+ const single = await this.prepareVisionImageForModel(imagePath, resolvedMaxPixels, attemptTag);
3365
+ return {
3366
+ imagePaths: [single.imagePath],
3367
+ source: `single_${single.source}`,
3368
+ sourcePixels: single.sourcePixels ?? null,
3369
+ currentPixels: single.currentPixels ?? null
3370
+ };
3371
+ }
3372
+
3373
+ const width = Number(metadata?.width || 0);
3374
+ const height = Number(metadata?.height || 0);
3375
+ const sourcePixels = width > 0 && height > 0 ? width * height : null;
3376
+ if (!sourcePixels || sourcePixels <= resolvedMaxPixels) {
3377
+ return {
3378
+ imagePaths: [imagePath],
3379
+ source: "within_limit",
3380
+ sourcePixels,
3381
+ currentPixels: sourcePixels
3382
+ };
3383
+ }
3384
+
3385
+ const maxTileHeight = Math.floor(resolvedMaxPixels / Math.max(1, width));
3386
+ if (!Number.isFinite(maxTileHeight) || maxTileHeight < 64) {
3387
+ const single = await this.prepareVisionImageForModel(imagePath, resolvedMaxPixels, attemptTag);
3388
+ return {
3389
+ imagePaths: [single.imagePath],
3390
+ source: `single_${single.source}`,
3391
+ sourcePixels: single.sourcePixels ?? sourcePixels,
3392
+ currentPixels: single.currentPixels ?? sourcePixels
3393
+ };
3394
+ }
3395
+
3396
+ const parsedPath = path.parse(imagePath);
3397
+ const imagePaths = [];
3398
+ for (let top = 0, index = 0; top < height; top += maxTileHeight, index += 1) {
3399
+ const segmentHeight = Math.min(maxTileHeight, height - top);
3400
+ const segmentPath = path.join(
3401
+ parsedPath.dir,
3402
+ `${parsedPath.name}.${attemptTag}.seg${String(index + 1).padStart(3, "0")}.png`
3403
+ );
3404
+ await sharp(imagePath)
3405
+ .extract({
3406
+ left: 0,
3407
+ top,
3408
+ width,
3409
+ height: segmentHeight
3410
+ })
3411
+ .png()
3412
+ .toFile(segmentPath);
3413
+ imagePaths.push(segmentPath);
3414
+ }
3415
+
3416
+ log(
3417
+ `[VISION] 长简历按分段输入模型: ${width}x${height}(${sourcePixels}) ` +
3418
+ `-> segments=${imagePaths.length}, max_pixels_per_segment=${resolvedMaxPixels}, attempt=${attemptTag}`
3419
+ );
3420
+ return {
3421
+ imagePaths,
3422
+ source: "segmented",
3423
+ sourcePixels,
3424
+ currentPixels: resolvedMaxPixels
3425
+ };
3426
+ }
3427
+
3169
3428
  async prepareVisionImageForModel(imagePath, maxPixels, attemptTag = "primary") {
3170
3429
  const resolvedMaxPixels = parsePositiveInteger(maxPixels);
3171
3430
  if (!resolvedMaxPixels) {
@@ -3254,7 +3513,38 @@ class RecommendScreenCli {
3254
3513
  }
3255
3514
 
3256
3515
  async requestVisionModel(imagePath) {
3257
- const imageBase64 = fs.readFileSync(imagePath, "base64");
3516
+ const imagePaths = Array.isArray(imagePath) ? imagePath.filter(Boolean) : [imagePath].filter(Boolean);
3517
+ if (imagePaths.length <= 0) {
3518
+ throw this.buildError("VISION_MODEL_FAILED", "No vision image input provided.");
3519
+ }
3520
+ const userContent = [
3521
+ {
3522
+ type: "text",
3523
+ text:
3524
+ "请根据以下标准判断候选人是否通过筛选。\n\n" +
3525
+ `筛选标准:\n${this.args.criteria}\n\n` +
3526
+ "你将收到候选人完整简历的一个或多个顺序分段图片。必须完整阅读全部分段后再判断," +
3527
+ "严禁编造任何不存在的经历、项目、学校、公司或时间线;证据不足时必须判定为不通过。\n\n" +
3528
+ "请返回严格 JSON: " +
3529
+ "{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
3530
+ }
3531
+ ];
3532
+ for (let index = 0; index < imagePaths.length; index += 1) {
3533
+ const segmentPath = imagePaths[index];
3534
+ const imageBase64 = fs.readFileSync(segmentPath, "base64");
3535
+ if (imagePaths.length > 1) {
3536
+ userContent.push({
3537
+ type: "text",
3538
+ text: `简历分段 ${index + 1}/${imagePaths.length}`
3539
+ });
3540
+ }
3541
+ userContent.push({
3542
+ type: "image_url",
3543
+ image_url: {
3544
+ url: `data:image/png;base64,${imageBase64}`
3545
+ }
3546
+ });
3547
+ }
3258
3548
  const rawBaseUrl = this.args.baseUrl;
3259
3549
  log(`[callVisionModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
3260
3550
  const baseUrl = String(rawBaseUrl || "").replace(/\/$/, "");
@@ -3264,22 +3554,13 @@ class RecommendScreenCli {
3264
3554
  messages: [
3265
3555
  {
3266
3556
  role: "system",
3267
- content: "你是一位严谨的招聘筛选助手。你只能返回 JSON,不要输出任何额外文字。"
3557
+ content:
3558
+ "你是一位严谨的招聘筛选助手。必须完整阅读所有输入材料,严禁臆造不存在的简历经历。" +
3559
+ "只能返回 JSON,不要输出任何额外文字。"
3268
3560
  },
3269
3561
  {
3270
3562
  role: "user",
3271
- content: [
3272
- {
3273
- type: "text",
3274
- text: `请根据以下标准判断候选人是否通过筛选。\n\n筛选标准:\n${this.args.criteria}\n\n你看到的是整份候选人简历长图。请返回严格 JSON: {\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\"}`
3275
- },
3276
- {
3277
- type: "image_url",
3278
- image_url: {
3279
- url: `data:image/png;base64,${imageBase64}`
3280
- }
3281
- }
3282
- ]
3563
+ content: userContent
3283
3564
  }
3284
3565
  ]
3285
3566
  };
@@ -3304,15 +3585,79 @@ class RecommendScreenCli {
3304
3585
  ? json.choices[0].message.content.map((item) => item?.text || "").join("\n")
3305
3586
  : json?.choices?.[0]?.message?.content || "";
3306
3587
  const parsed = extractJsonObject(content);
3588
+ const reason = normalizeText(parsed.reason);
3589
+ const summary = normalizeText(parsed.summary || reason);
3590
+ const evidence = toStringArray(parsed.evidence);
3307
3591
  return {
3308
3592
  passed: parsed.passed === true,
3309
- reason: normalizeText(parsed.reason),
3310
- summary: normalizeText(parsed.summary)
3593
+ reason: reason || "未满足筛选标准。",
3594
+ summary: summary || reason || "未满足筛选标准。",
3595
+ evidence
3311
3596
  };
3312
3597
  }
3313
3598
 
3314
3599
  async callTextModel(resumeText) {
3315
- const safeResumeText = String(resumeText || "").slice(0, 28000);
3600
+ const fullResumeText = String(resumeText || "");
3601
+ if (!normalizeText(fullResumeText)) {
3602
+ throw this.buildError("TEXT_MODEL_FAILED", "Resume text is empty.");
3603
+ }
3604
+ try {
3605
+ return await this.requestTextModel(fullResumeText, {
3606
+ chunkIndex: 1,
3607
+ chunkTotal: 1
3608
+ });
3609
+ } catch (error) {
3610
+ if (!isTextContextLimitMessage(error?.message || "")) {
3611
+ throw error;
3612
+ }
3613
+ log("[TEXT_MODEL] 检测到上下文长度限制,启用分段筛选模式。");
3614
+ }
3615
+
3616
+ const chunkSize = parsePositiveInteger(process.env.BOSS_RECOMMEND_TEXT_CHUNK_SIZE_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS;
3617
+ const overlap = parsePositiveInteger(process.env.BOSS_RECOMMEND_TEXT_CHUNK_OVERLAP_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS;
3618
+ const maxChunks = parsePositiveInteger(process.env.BOSS_RECOMMEND_TEXT_MAX_CHUNKS) || DEFAULT_TEXT_MODEL_MAX_CHUNKS;
3619
+ const chunks = splitTextByChunks(fullResumeText, chunkSize, overlap, maxChunks);
3620
+ if (!chunks.length) {
3621
+ throw this.buildError("TEXT_MODEL_FAILED", "Resume text is empty after chunk split.");
3622
+ }
3623
+
3624
+ const chunkResults = [];
3625
+ for (let index = 0; index < chunks.length; index += 1) {
3626
+ const chunk = chunks[index];
3627
+ const result = await this.requestTextModel(chunk.text, {
3628
+ chunkIndex: index + 1,
3629
+ chunkTotal: chunks.length
3630
+ });
3631
+ chunkResults.push(result);
3632
+ }
3633
+
3634
+ const passedChunks = chunkResults.filter((item) => item?.passed === true);
3635
+ if (passedChunks.length > 0) {
3636
+ const best = passedChunks[0];
3637
+ return {
3638
+ passed: true,
3639
+ reason: best.reason || `分段筛选命中(${best.chunkIndex}/${chunks.length})。`,
3640
+ summary: best.summary || best.reason || "分段筛选命中",
3641
+ evidence: Array.isArray(best.evidence) ? best.evidence : []
3642
+ };
3643
+ }
3644
+
3645
+ const firstReason = chunkResults.map((item) => normalizeText(item?.reason)).find(Boolean);
3646
+ return {
3647
+ passed: false,
3648
+ reason: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
3649
+ summary: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
3650
+ evidence: []
3651
+ };
3652
+ }
3653
+
3654
+ async requestTextModel(resumeText, options = {}) {
3655
+ const safeResumeText = String(resumeText || "");
3656
+ const chunkIndex = Number.isInteger(options.chunkIndex) && options.chunkIndex > 0 ? options.chunkIndex : 1;
3657
+ const chunkTotal = Number.isInteger(options.chunkTotal) && options.chunkTotal > 0 ? options.chunkTotal : 1;
3658
+ const chunkHint = chunkTotal > 1
3659
+ ? `\n\n当前输入是简历分段 ${chunkIndex}/${chunkTotal}。请严格基于本分段文本判断;如果本分段证据不足,必须返回 passed=false。`
3660
+ : "";
3316
3661
  const rawBaseUrl = this.args.baseUrl;
3317
3662
  log(`[callTextModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
3318
3663
  const baseUrl = String(rawBaseUrl || "").replace(/\/$/, "");
@@ -3322,11 +3667,21 @@ class RecommendScreenCli {
3322
3667
  messages: [
3323
3668
  {
3324
3669
  role: "system",
3325
- content: "你是一位严谨的招聘筛选助手。你只能返回 JSON,不要输出任何额外文字。"
3670
+ content:
3671
+ "你是一位严谨的招聘筛选助手。必须完整阅读输入内容,严禁编造不存在的简历经历。" +
3672
+ "只能返回 JSON,不要输出任何额外文字。"
3326
3673
  },
3327
3674
  {
3328
3675
  role: "user",
3329
- content: `请根据以下标准判断候选人是否通过筛选。\n\n筛选标准:\n${this.args.criteria}\n\n简历内容:\n${safeResumeText}\n\n请返回严格 JSON: {"passed": true/false, "reason": "...", "summary": "..."}`
3676
+ content:
3677
+ `请根据以下标准判断候选人是否通过筛选。\n\n筛选标准:\n${this.args.criteria}\n\n` +
3678
+ `简历内容:\n${safeResumeText}${chunkHint}\n\n` +
3679
+ "要求:\n" +
3680
+ "1) 必须完整阅读上面的全部简历文本。\n" +
3681
+ "2) 只能依据简历中真实出现的信息判断,严禁编造不存在的经历/项目/学历/公司。\n" +
3682
+ "3) 若证据不足,必须返回 passed=false。\n\n" +
3683
+ "请返回严格 JSON: " +
3684
+ "{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
3330
3685
  }
3331
3686
  ]
3332
3687
  };
@@ -3351,10 +3706,28 @@ class RecommendScreenCli {
3351
3706
  ? json.choices[0].message.content.map((item) => item?.text || "").join("\n")
3352
3707
  : json?.choices?.[0]?.message?.content || "";
3353
3708
  const parsed = extractJsonObject(content);
3709
+ const reason = normalizeText(parsed.reason);
3710
+ const summary = normalizeText(parsed.summary || reason);
3711
+ const normalizedResume = normalizeText(safeResumeText);
3712
+ const parsedEvidence = toStringArray(parsed.evidence);
3713
+ const evidence = parsedEvidence.filter((item) => {
3714
+ const normalizedEvidence = normalizeText(item);
3715
+ if (!normalizedEvidence) return false;
3716
+ return safeResumeText.includes(item) || normalizedResume.includes(normalizedEvidence);
3717
+ });
3718
+ let passed = parsed.passed === true;
3719
+ let finalReason = reason || (passed ? "满足筛选标准。" : "不满足筛选标准。");
3720
+ if (passed && evidence.length <= 0) {
3721
+ passed = false;
3722
+ finalReason = `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`;
3723
+ }
3354
3724
  return {
3355
- passed: parsed.passed === true,
3356
- reason: normalizeText(parsed.reason),
3357
- summary: normalizeText(parsed.summary)
3725
+ passed,
3726
+ reason: finalReason,
3727
+ summary: summary || finalReason,
3728
+ evidence,
3729
+ chunkIndex,
3730
+ chunkTotal
3358
3731
  };
3359
3732
  }
3360
3733
 
@@ -3580,8 +3953,13 @@ class RecommendScreenCli {
3580
3953
  }
3581
3954
 
3582
3955
  state = await this.getDetailClosedState();
3583
- log(`[关闭详情] 连续 ESC 后仍未确认关闭(${state?.reason || "unknown"}),按策略视为检测误差并继续下一位。`);
3584
- return true;
3956
+ const listState = await this.evaluate(jsGetListState);
3957
+ if (listState?.ok) {
3958
+ log(`[关闭详情] 连续 ESC 后仍未确认关闭(${state?.reason || "unknown"}),但候选人列表已可用,按就绪状态继续。`);
3959
+ return true;
3960
+ }
3961
+ log(`[关闭详情] 连续 ESC 后仍未确认关闭(${state?.reason || "unknown"}),且候选人列表未恢复,判定关闭失败。`);
3962
+ return false;
3585
3963
  }
3586
3964
 
3587
3965
  async waitForListReady(maxRounds = 30) {
@@ -3650,7 +4028,10 @@ class RecommendScreenCli {
3650
4028
 
3651
4029
  const restoredFromCheckpoint = this.loadCheckpointIfNeeded();
3652
4030
  if (restoredFromCheckpoint) {
3653
- log(`[恢复] 已从 checkpoint 恢复,已处理 ${this.processedCount} 位候选人。`);
4031
+ log(
4032
+ `[恢复] 已从 checkpoint 恢复,已处理 ${this.processedCount} 位候选人,` +
4033
+ `其中通过 ${this.passedCandidates.length} 位。`
4034
+ );
3654
4035
  }
3655
4036
 
3656
4037
  await this.connect();
@@ -3684,7 +4065,7 @@ class RecommendScreenCli {
3684
4065
  }
3685
4066
 
3686
4067
  let pageExhaustion = null;
3687
- while (!this.args.targetCount || this.processedCount < this.args.targetCount) {
4068
+ while (!this.args.targetCount || this.passedCandidates.length < this.args.targetCount) {
3688
4069
  if (this.shouldPauseAtBoundary()) {
3689
4070
  this.saveCsv();
3690
4071
  this.saveCheckpoint();
@@ -3942,10 +4323,10 @@ class RecommendScreenCli {
3942
4323
  }
3943
4324
  }
3944
4325
 
3945
- if (this.args.targetCount && this.processedCount < this.args.targetCount) {
4326
+ if (this.args.targetCount && this.passedCandidates.length < this.args.targetCount) {
3946
4327
  throw this.buildError(
3947
4328
  "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
3948
- `推荐列表已到底,但当前仅处理 ${this.processedCount} 位,尚未达到目标 ${this.args.targetCount} 位。`,
4329
+ `推荐列表已到底,但当前仅通过 ${this.passedCandidates.length} 位,尚未达到目标 ${this.args.targetCount} 位。`,
3949
4330
  true,
3950
4331
  {
3951
4332
  partial_result: this.buildProgressSnapshot("page_exhausted_before_target_count"),
@@ -3964,11 +4345,11 @@ class RecommendScreenCli {
3964
4345
  status: "COMPLETED",
3965
4346
  result: {
3966
4347
  ...this.buildProgressSnapshot(
3967
- this.args.targetCount && this.processedCount >= this.args.targetCount
4348
+ this.args.targetCount && this.passedCandidates.length >= this.args.targetCount
3968
4349
  ? "target_count_reached"
3969
4350
  : "page_exhausted"
3970
4351
  ),
3971
- completion_reason: this.args.targetCount && this.processedCount >= this.args.targetCount
4352
+ completion_reason: this.args.targetCount && this.passedCandidates.length >= this.args.targetCount
3972
4353
  ? "target_count_reached"
3973
4354
  : "page_exhausted",
3974
4355
  }