@reconcrap/boss-recommend-mcp 1.2.1 → 1.2.3

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.
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -13,11 +13,52 @@ const RESUME_CAPTURE_WAIT_MS = 60000;
13
13
  const RESUME_CAPTURE_MAX_ATTEMPTS = 4;
14
14
  const RESUME_CAPTURE_RETRY_DELAY_MS = 1200;
15
15
  const MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES = 10;
16
+ const DEFAULT_VISION_MAX_IMAGE_PIXELS = 36000000;
17
+ const DEFAULT_VISION_RETRY_MAX_IMAGE_PIXELS = 30000000;
18
+ let visionSharpFactory = null;
16
19
  const PAGE_SCOPE_TAB_STATUS = {
17
20
  recommend: "0",
18
21
  latest: "1",
19
22
  featured: "3"
20
23
  };
24
+ const BOTTOM_HINT_KEYWORDS = ["没有更多", "已显示全部", "已经到底", "暂无更多", "推荐完了", "没有更多人选"];
25
+ const LOAD_MORE_HINT_KEYWORDS = ["滚动加载更多", "下滑加载更多", "继续下滑", "继续滑动", "滑动加载", "正在加载", "加载中"];
26
+
27
+ function classifyFinishedWrapState(finishedWrapText, refreshButtonVisible = false) {
28
+ const normalizedText = normalizeText(finishedWrapText);
29
+ const matchedBottomKeyword = BOTTOM_HINT_KEYWORDS.find((keyword) => normalizedText.includes(keyword)) || null;
30
+ if (matchedBottomKeyword) {
31
+ return {
32
+ isBottom: true,
33
+ reason: matchedBottomKeyword,
34
+ matched_bottom_keyword: matchedBottomKeyword,
35
+ matched_load_more_keyword: null
36
+ };
37
+ }
38
+ const matchedLoadMoreKeyword = LOAD_MORE_HINT_KEYWORDS.find((keyword) => normalizedText.includes(keyword)) || null;
39
+ if (matchedLoadMoreKeyword) {
40
+ return {
41
+ isBottom: false,
42
+ reason: null,
43
+ matched_bottom_keyword: null,
44
+ matched_load_more_keyword: matchedLoadMoreKeyword
45
+ };
46
+ }
47
+ if (refreshButtonVisible) {
48
+ return {
49
+ isBottom: true,
50
+ reason: "refresh_button_visible",
51
+ matched_bottom_keyword: null,
52
+ matched_load_more_keyword: null
53
+ };
54
+ }
55
+ return {
56
+ isBottom: false,
57
+ reason: null,
58
+ matched_bottom_keyword: null,
59
+ matched_load_more_keyword: null
60
+ };
61
+ }
21
62
 
22
63
  function getCodexHome() {
23
64
  return process.env.CODEX_HOME
@@ -65,6 +106,35 @@ function parsePositiveInteger(raw) {
65
106
  return Number.isFinite(value) && value > 0 ? value : null;
66
107
  }
67
108
 
109
+ function resolveVisionPixelLimitFromEnv(envName, fallback) {
110
+ const parsed = parsePositiveInteger(process.env[envName]);
111
+ return parsed || fallback;
112
+ }
113
+
114
+ function resolveVisionRetryPixelLimit(primaryLimit) {
115
+ const safePrimary = parsePositiveInteger(primaryLimit) || DEFAULT_VISION_MAX_IMAGE_PIXELS;
116
+ const fallback = Math.max(1, Math.floor(safePrimary * 0.8));
117
+ const parsed = resolveVisionPixelLimitFromEnv("BOSS_RECOMMEND_VISION_RETRY_MAX_IMAGE_PIXELS", DEFAULT_VISION_RETRY_MAX_IMAGE_PIXELS);
118
+ const candidate = parsePositiveInteger(parsed) || fallback;
119
+ return Math.min(Math.max(1, candidate), Math.max(1, safePrimary - 1));
120
+ }
121
+
122
+ function loadVisionSharp() {
123
+ if (!visionSharpFactory) {
124
+ visionSharpFactory = require("sharp");
125
+ }
126
+ return visionSharpFactory;
127
+ }
128
+
129
+ function isVisionImageSizeLimitMessage(message) {
130
+ const text = normalizeText(message).toLowerCase();
131
+ if (!text) return false;
132
+ return (
133
+ /(像素|pixel|pixels|too large|image size|image dimension|too many pixels|max(?:imum)?[^a-z0-9]{0,8}(?:pixel|image)|超过|超出|上限)/i.test(text)
134
+ || (text.includes("image") && text.includes("limit"))
135
+ );
136
+ }
137
+
68
138
  function normalizePostAction(value) {
69
139
  const normalized = normalizeText(value).toLowerCase();
70
140
  if (!normalized) return null;
@@ -620,6 +690,121 @@ function parseGeekIdFromUrl(url) {
620
690
  return null;
621
691
  }
622
692
 
693
+ function collectGeekIdsFromPayload(payload, fallbackGeekId = null) {
694
+ if (!payload || typeof payload !== "object") return [];
695
+ const geekDetail = payload?.geekDetail || payload;
696
+ const baseInfo = geekDetail?.geekBaseInfo || {};
697
+ const ids = [
698
+ fallbackGeekId,
699
+ baseInfo.geekId,
700
+ baseInfo.encryptGeekId,
701
+ baseInfo.securityId,
702
+ geekDetail?.geekId,
703
+ geekDetail?.encryptGeekId,
704
+ geekDetail?.securityId,
705
+ payload?.geekId,
706
+ payload?.encryptGeekId,
707
+ payload?.securityId
708
+ ].map((value) => normalizeText(value)).filter(Boolean);
709
+ return Array.from(new Set(ids));
710
+ }
711
+
712
+ function hasResumePayloadShape(payload) {
713
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
714
+ const geekDetail = payload?.geekDetail && typeof payload.geekDetail === "object"
715
+ ? payload.geekDetail
716
+ : payload;
717
+ const baseInfo = geekDetail?.geekBaseInfo || {};
718
+ const hasIdentity = Boolean(
719
+ normalizeText(
720
+ baseInfo?.name
721
+ || geekDetail?.geekName
722
+ || payload?.geekName
723
+ || baseInfo?.geekId
724
+ || baseInfo?.encryptGeekId
725
+ || baseInfo?.securityId
726
+ || geekDetail?.geekId
727
+ || geekDetail?.encryptGeekId
728
+ || geekDetail?.securityId
729
+ || payload?.geekId
730
+ || payload?.encryptGeekId
731
+ || payload?.securityId
732
+ || ""
733
+ )
734
+ );
735
+ const hasResumeSections = [
736
+ geekDetail?.geekExpectList,
737
+ geekDetail?.geekWorkExpList,
738
+ geekDetail?.geekProjExpList,
739
+ geekDetail?.geekEduExpList,
740
+ geekDetail?.geekEducationList,
741
+ geekDetail?.geekSkillList
742
+ ].some((section) => Array.isArray(section) && section.length > 0);
743
+ const hasResumeTextFields = Boolean(
744
+ normalizeText(
745
+ geekDetail?.geekAdvantage
746
+ || baseInfo?.userDesc
747
+ || baseInfo?.userDescription
748
+ || ""
749
+ )
750
+ );
751
+ return hasIdentity && (hasResumeSections || hasResumeTextFields);
752
+ }
753
+
754
+ function findResumePayloadInObject(root, maxDepth = 4, visited = new Set()) {
755
+ if (root === null || root === undefined || maxDepth < 0) return null;
756
+ if (typeof root !== "object") return null;
757
+ if (visited.has(root)) return null;
758
+ visited.add(root);
759
+
760
+ if (hasResumePayloadShape(root)) {
761
+ return root;
762
+ }
763
+
764
+ if (maxDepth === 0) return null;
765
+
766
+ if (Array.isArray(root)) {
767
+ for (const item of root) {
768
+ const found = findResumePayloadInObject(item, maxDepth - 1, visited);
769
+ if (found) return found;
770
+ }
771
+ return null;
772
+ }
773
+
774
+ const priorityKeys = [
775
+ "zpData",
776
+ "data",
777
+ "result",
778
+ "geekDetail",
779
+ "detail",
780
+ "info"
781
+ ];
782
+ for (const key of priorityKeys) {
783
+ if (!(key in root)) continue;
784
+ const found = findResumePayloadInObject(root[key], maxDepth - 1, visited);
785
+ if (found) return found;
786
+ }
787
+
788
+ for (const value of Object.values(root)) {
789
+ const found = findResumePayloadInObject(value, maxDepth - 1, visited);
790
+ if (found) return found;
791
+ }
792
+ return null;
793
+ }
794
+
795
+ function extractResumePayloadFromResponseBody(parsedBody) {
796
+ return findResumePayloadInObject(parsedBody, 4) || null;
797
+ }
798
+
799
+ function isResumeInfoRequestUrl(url) {
800
+ const normalizedUrl = normalizeText(url).toLowerCase();
801
+ if (!normalizedUrl || !normalizedUrl.includes("/wapi/")) return false;
802
+ if (!normalizedUrl.includes("geek") || !normalizedUrl.includes("info")) return false;
803
+ if (/\/boss\/[^?#]*\/geek\/info\b/.test(normalizedUrl)) return true;
804
+ if (/\/geek\/info\b/.test(normalizedUrl)) return true;
805
+ return /[?&](?:geekid|geek_id|encryptgeekid|securityid)=/.test(normalizedUrl);
806
+ }
807
+
623
808
  function formatResumeApiData(data) {
624
809
  const parts = [];
625
810
  const geekDetail = data?.geekDetail || data || {};
@@ -1041,7 +1226,8 @@ const jsDetectBottom = `(() => {
1041
1226
  const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
1042
1227
  const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
1043
1228
  .find((el) => isVisible(el)) || null;
1044
- const keywords = ['没有更多', '已显示全部', '已经到底', '暂无更多', '推荐完了', '没有更多人选'];
1229
+ const keywords = ${JSON.stringify(BOTTOM_HINT_KEYWORDS)};
1230
+ const loadMoreKeywords = ${JSON.stringify(LOAD_MORE_HINT_KEYWORDS)};
1045
1231
  const elements = Array.from(doc.querySelectorAll('div,span,p'));
1046
1232
  for (const el of elements) {
1047
1233
  if (el.offsetParent === null) continue;
@@ -1059,12 +1245,21 @@ const jsDetectBottom = `(() => {
1059
1245
  }
1060
1246
  }
1061
1247
  }
1248
+ const finishedWrapText = finishedWrap ? String(finishedWrap.textContent || '').replace(/\s+/g, ' ').trim() : '';
1249
+ const matchedBottomKeyword = keywords.find((keyword) => finishedWrapText.includes(keyword)) || null;
1250
+ const matchedLoadMoreKeyword = loadMoreKeywords.find((keyword) => finishedWrapText.includes(keyword)) || null;
1251
+ const inferredBottom = matchedBottomKeyword
1252
+ ? true
1253
+ : (Boolean(refreshButton) && !matchedLoadMoreKeyword);
1062
1254
  return {
1063
- isBottom: Boolean(finishedWrap),
1064
- reason: finishedWrap ? 'finished-wrap' : null,
1255
+ isBottom: inferredBottom,
1256
+ reason: matchedBottomKeyword || (inferredBottom ? 'refresh_button_visible' : null),
1065
1257
  finished_wrap_visible: Boolean(finishedWrap),
1258
+ finished_wrap_text: finishedWrapText || null,
1066
1259
  refresh_button_visible: Boolean(refreshButton),
1067
- refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null
1260
+ refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null,
1261
+ matched_bottom_keyword: matchedBottomKeyword,
1262
+ matched_load_more_keyword: matchedLoadMoreKeyword
1068
1263
  };
1069
1264
  })()`;
1070
1265
  const jsWaitForDetail = `(() => {
@@ -1994,14 +2189,8 @@ class RecommendScreenCli {
1994
2189
  if (!payload || typeof payload !== "object") return;
1995
2190
  const geekDetail = payload.geekDetail || payload;
1996
2191
  const baseInfo = geekDetail.geekBaseInfo || {};
1997
- const geekId = normalizeText(
1998
- fallbackGeekId
1999
- || baseInfo.geekId
2000
- || baseInfo.encryptGeekId
2001
- || geekDetail.geekId
2002
- || payload.geekId
2003
- || ""
2004
- ) || null;
2192
+ const geekIds = collectGeekIdsFromPayload(payload, fallbackGeekId);
2193
+ const geekId = geekIds[0] || null;
2005
2194
  const candidateInfo = {
2006
2195
  name: baseInfo.name || geekDetail.geekName || payload.geekName || "",
2007
2196
  school: (geekDetail.geekEduExpList && geekDetail.geekEduExpList[0]?.school)
@@ -2013,17 +2202,18 @@ class RecommendScreenCli {
2013
2202
  company: (geekDetail.geekWorkExpList && geekDetail.geekWorkExpList[0]?.company) || "",
2014
2203
  position: (geekDetail.geekWorkExpList && geekDetail.geekWorkExpList[0]?.positionName) || "",
2015
2204
  resumeText: formatResumeApiData(payload),
2016
- alreadyInterested: payload.alreadyInterested === true
2205
+ alreadyInterested: payload.alreadyInterested === true || geekDetail.alreadyInterested === true
2017
2206
  };
2018
2207
  const wrapped = {
2019
2208
  ts: Date.now(),
2020
2209
  geekId: geekId || null,
2210
+ geekIds,
2021
2211
  data: payload,
2022
2212
  candidateInfo
2023
2213
  };
2024
2214
  this.latestResumeNetworkPayload = wrapped;
2025
- if (geekId) {
2026
- this.resumeNetworkByGeekId.set(geekId, wrapped);
2215
+ for (const id of geekIds) {
2216
+ this.resumeNetworkByGeekId.set(id, wrapped);
2027
2217
  }
2028
2218
  }
2029
2219
 
@@ -2056,7 +2246,7 @@ class RecommendScreenCli {
2056
2246
  handleNetworkRequestWillBeSent(params) {
2057
2247
  const url = normalizeText(params?.request?.url || "");
2058
2248
  if (!url) return;
2059
- if (url.includes("/wapi/zpitem/web/boss/search/geek/info")) {
2249
+ if (isResumeInfoRequestUrl(url)) {
2060
2250
  const geekId = parseGeekIdFromUrl(url);
2061
2251
  this.resumeNetworkRequests.set(params.requestId, {
2062
2252
  ts: Date.now(),
@@ -2126,9 +2316,13 @@ class RecommendScreenCli {
2126
2316
  try {
2127
2317
  const responseBody = await this.Network.getResponseBody({ requestId: params.requestId });
2128
2318
  if (!responseBody?.body) return;
2129
- const parsed = JSON.parse(responseBody.body);
2130
- if (parsed?.zpData) {
2131
- this.cacheResumeNetworkPayload(parsed.zpData, requestMeta.geekId);
2319
+ const rawBody = responseBody.base64Encoded
2320
+ ? Buffer.from(responseBody.body, "base64").toString("utf8")
2321
+ : responseBody.body;
2322
+ const parsed = JSON.parse(rawBody);
2323
+ const resumePayload = extractResumePayloadFromResponseBody(parsed);
2324
+ if (resumePayload) {
2325
+ this.cacheResumeNetworkPayload(resumePayload, requestMeta.geekId);
2132
2326
  }
2133
2327
  } catch {}
2134
2328
  }
@@ -2615,15 +2809,21 @@ class RecommendScreenCli {
2615
2809
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
2616
2810
  }
2617
2811
  const doc = frame.contentDocument;
2618
- const inner = Array.from(doc.querySelectorAll('.card-inner[data-geekid]'))
2812
+ const recommendInner = Array.from(doc.querySelectorAll('.card-inner[data-geekid]'))
2619
2813
  .find((item) => (item.getAttribute('data-geekid') || '') === String(candidateKey)) || null;
2620
- const featuredAnchor = inner
2814
+ const latestInner = recommendInner
2815
+ ? null
2816
+ : Array.from(doc.querySelectorAll('.candidate-card-wrap .card-inner[data-geek], .candidate-card-wrap [data-geek]'))
2817
+ .find((item) => (item.getAttribute('data-geek') || '') === String(candidateKey)) || null;
2818
+ const featuredAnchor = (recommendInner || latestInner)
2621
2819
  ? null
2622
2820
  : Array.from(doc.querySelectorAll('li.geek-info-card a[data-geekid], a[data-geekid]'))
2623
2821
  .find((item) => (item.getAttribute('data-geekid') || '') === String(candidateKey)) || null;
2624
- const card = inner
2625
- ? (inner.closest('li.card-item') || inner.closest('.card-item'))
2626
- : (featuredAnchor ? (featuredAnchor.closest('li.geek-info-card') || featuredAnchor.closest('.geek-info-card')) : null);
2822
+ const card = recommendInner
2823
+ ? (recommendInner.closest('li.card-item') || recommendInner.closest('.card-item'))
2824
+ : latestInner
2825
+ ? (latestInner.closest('.candidate-card-wrap') || latestInner.closest('li.card-item') || latestInner.closest('.card-item'))
2826
+ : (featuredAnchor ? (featuredAnchor.closest('li.geek-info-card') || featuredAnchor.closest('.geek-info-card')) : null);
2627
2827
  if (!card) {
2628
2828
  return { ok: false, error: 'CANDIDATE_CARD_NOT_FOUND' };
2629
2829
  }
@@ -2716,6 +2916,130 @@ class RecommendScreenCli {
2716
2916
  }
2717
2917
 
2718
2918
  async callVisionModel(imagePath) {
2919
+ const primaryLimit = resolveVisionPixelLimitFromEnv(
2920
+ "BOSS_RECOMMEND_VISION_MAX_IMAGE_PIXELS",
2921
+ DEFAULT_VISION_MAX_IMAGE_PIXELS
2922
+ );
2923
+ const retryLimit = resolveVisionRetryPixelLimit(primaryLimit);
2924
+ const preparedPrimary = await this.prepareVisionImageForModel(imagePath, primaryLimit, "primary");
2925
+ try {
2926
+ return await this.requestVisionModel(preparedPrimary.imagePath);
2927
+ } catch (error) {
2928
+ if (!isVisionImageSizeLimitMessage(error?.message || "")) {
2929
+ throw error;
2930
+ }
2931
+ log(
2932
+ `[VISION] 检测到图片尺寸超限,准备降采样重试: ` +
2933
+ `primary_limit=${primaryLimit} source=${preparedPrimary.source} ` +
2934
+ `source_pixels=${preparedPrimary.sourcePixels ?? "unknown"}`
2935
+ );
2936
+ }
2937
+ const preparedRetry = await this.prepareVisionImageForModel(imagePath, retryLimit, "retry");
2938
+ try {
2939
+ return await this.requestVisionModel(preparedRetry.imagePath);
2940
+ } catch (retryError) {
2941
+ if (!isVisionImageSizeLimitMessage(retryError?.message || "")) {
2942
+ throw retryError;
2943
+ }
2944
+ throw this.buildError(
2945
+ "VISION_IMAGE_SIZE_LIMIT_EXCEEDED",
2946
+ `Vision model still rejected image after retry downscale; ` +
2947
+ `primary_limit=${primaryLimit}; retry_limit=${retryLimit}; ` +
2948
+ `source_pixels=${preparedRetry.sourcePixels ?? "unknown"}; ` +
2949
+ `retry_pixels=${preparedRetry.currentPixels ?? "unknown"}; ` +
2950
+ `last_error=${normalizeText(retryError?.message || retryError)}`
2951
+ );
2952
+ }
2953
+ }
2954
+
2955
+ async prepareVisionImageForModel(imagePath, maxPixels, attemptTag = "primary") {
2956
+ const resolvedMaxPixels = parsePositiveInteger(maxPixels);
2957
+ if (!resolvedMaxPixels) {
2958
+ return {
2959
+ imagePath,
2960
+ source: "no_limit",
2961
+ sourcePixels: null,
2962
+ currentPixels: null
2963
+ };
2964
+ }
2965
+ let sharp;
2966
+ try {
2967
+ sharp = loadVisionSharp();
2968
+ } catch (error) {
2969
+ log(`[VISION] 加载 sharp 失败,跳过预缩放: ${error?.message || error}`);
2970
+ return {
2971
+ imagePath,
2972
+ source: "sharp_unavailable",
2973
+ sourcePixels: null,
2974
+ currentPixels: null
2975
+ };
2976
+ }
2977
+ let metadata;
2978
+ try {
2979
+ metadata = await sharp(imagePath).metadata();
2980
+ } catch (error) {
2981
+ log(`[VISION] 读取图片尺寸失败,跳过预缩放: ${error?.message || error}`);
2982
+ return {
2983
+ imagePath,
2984
+ source: "metadata_error",
2985
+ sourcePixels: null,
2986
+ currentPixels: null
2987
+ };
2988
+ }
2989
+ const width = Number(metadata?.width || 0);
2990
+ const height = Number(metadata?.height || 0);
2991
+ const sourcePixels = width > 0 && height > 0 ? width * height : null;
2992
+ if (!sourcePixels || sourcePixels <= resolvedMaxPixels) {
2993
+ return {
2994
+ imagePath,
2995
+ source: "within_limit",
2996
+ sourcePixels,
2997
+ currentPixels: sourcePixels
2998
+ };
2999
+ }
3000
+ const scale = Math.sqrt(resolvedMaxPixels / sourcePixels);
3001
+ const targetWidth = Math.max(1, Math.floor(width * scale));
3002
+ const targetHeight = Math.max(1, Math.floor(height * scale));
3003
+ const parsedPath = path.parse(imagePath);
3004
+ const resizedPath = path.join(
3005
+ parsedPath.dir,
3006
+ `${parsedPath.name}.${attemptTag}.max${resolvedMaxPixels}.png`
3007
+ );
3008
+ try {
3009
+ await sharp(imagePath)
3010
+ .resize({
3011
+ width: targetWidth,
3012
+ height: targetHeight,
3013
+ fit: "inside",
3014
+ withoutEnlargement: true
3015
+ })
3016
+ .png()
3017
+ .toFile(resizedPath);
3018
+ const resizedMeta = await sharp(resizedPath).metadata();
3019
+ const resizedPixels = Number(resizedMeta?.width || 0) * Number(resizedMeta?.height || 0);
3020
+ log(
3021
+ `[VISION] 图片预缩放完成: ${width}x${height}(${sourcePixels}) -> ` +
3022
+ `${resizedMeta?.width || "?"}x${resizedMeta?.height || "?"}(${resizedPixels || "?"}); ` +
3023
+ `limit=${resolvedMaxPixels}; attempt=${attemptTag}`
3024
+ );
3025
+ return {
3026
+ imagePath: resizedPath,
3027
+ source: "resized",
3028
+ sourcePixels,
3029
+ currentPixels: resizedPixels || null
3030
+ };
3031
+ } catch (error) {
3032
+ log(`[VISION] 预缩放失败,继续使用原图: ${error?.message || error}`);
3033
+ return {
3034
+ imagePath,
3035
+ source: "resize_failed",
3036
+ sourcePixels,
3037
+ currentPixels: sourcePixels
3038
+ };
3039
+ }
3040
+ }
3041
+
3042
+ async requestVisionModel(imagePath) {
2719
3043
  const imageBase64 = fs.readFileSync(imagePath, "base64");
2720
3044
  const rawBaseUrl = this.args.baseUrl;
2721
3045
  log(`[callVisionModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
@@ -3247,10 +3571,11 @@ class RecommendScreenCli {
3247
3571
  }
3248
3572
 
3249
3573
  const isFeaturedScope = this.args.pageScope === "featured";
3574
+ const allowImageFallback = !isFeaturedScope;
3250
3575
  let capture = null;
3251
3576
  let screening = null;
3252
3577
  let resumeSource = isFeaturedScope ? "network" : "image_fallback";
3253
- const networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(nextCandidate, 2400);
3578
+ const networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(nextCandidate, allowImageFallback ? 4200 : 2400);
3254
3579
  const candidateProfile = {
3255
3580
  name: networkCandidateInfo?.name || nextCandidate.name || "",
3256
3581
  school: networkCandidateInfo?.school || nextCandidate.school || "",
@@ -3269,6 +3594,10 @@ class RecommendScreenCli {
3269
3594
  screening = await this.callTextModel(networkCandidateInfo.resumeText);
3270
3595
  resumeSource = "network";
3271
3596
  this.resumeSourceStats.network += 1;
3597
+ } else if (networkCandidateInfo?.resumeText) {
3598
+ screening = await this.callTextModel(networkCandidateInfo.resumeText);
3599
+ resumeSource = "network";
3600
+ this.resumeSourceStats.network += 1;
3272
3601
  } else {
3273
3602
  capture = await this.captureResumeImage(nextCandidate);
3274
3603
  screening = await this.callVisionModel(capture.stitchedImage);
@@ -3361,9 +3690,28 @@ class RecommendScreenCli {
3361
3690
  } else {
3362
3691
  this.resetResumeCaptureFailureStreak();
3363
3692
  }
3364
- if (["VISION_MODEL_FAILED", "TEXT_MODEL_FAILED"].includes(error.code)) {
3693
+ if (error.code === "TEXT_MODEL_FAILED") {
3365
3694
  throw error;
3366
3695
  }
3696
+ if (error.code === "VISION_MODEL_FAILED") {
3697
+ if (isVisionImageSizeLimitMessage(error?.message || "")) {
3698
+ log(
3699
+ `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} 触发视觉模型像素限制,` +
3700
+ "已在本轮跳过并继续处理下一位。"
3701
+ );
3702
+ } else {
3703
+ log(
3704
+ `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} 视觉模型调用失败,` +
3705
+ "已在本轮跳过并继续处理下一位。"
3706
+ );
3707
+ }
3708
+ }
3709
+ if (error.code === "VISION_IMAGE_SIZE_LIMIT_EXCEEDED") {
3710
+ log(
3711
+ `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} 触发视觉模型像素限制,` +
3712
+ "已完成预缩放和重试,仍失败,继续处理下一位。"
3713
+ );
3714
+ }
3367
3715
  } finally {
3368
3716
  const closed = await this.closeDetailPage();
3369
3717
  if (!closed) {
@@ -3485,7 +3833,8 @@ if (require.main === module) {
3485
3833
  parseFavoriteActionFromKnownRequest,
3486
3834
  parseFavoriteActionFromActionLog,
3487
3835
  parseFavoriteActionFromWsPayload,
3488
- isRecoverablePostActionError
3836
+ isRecoverablePostActionError,
3837
+ classifyFinishedWrapState
3489
3838
  }
3490
3839
  };
3491
3840
  }
@@ -338,29 +338,28 @@ async function testFeaturedShouldUseNetworkResumeOnly() {
338
338
  assert.equal(result.result.resume_source, "network");
339
339
  }
340
340
 
341
- async function testRecommendShouldKeepImageCaptureEvenWhenNetworkResumeExists() {
342
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-recommend-image-main-"));
343
- const candidate = { key: "img-main-1", geek_id: "img-main-1", name: "recommend image main candidate" };
341
+ async function testRecommendShouldPreferNetworkResumeWhenAvailable() {
342
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-recommend-network-main-"));
343
+ const candidate = { key: "net-main-1", geek_id: "net-main-1", name: "recommend network main candidate" };
344
344
  const cli = new FakeRecommendScreenCli(createArgs(tempDir), {
345
- candidates: [candidate],
346
- captureOutcomes: new Map([
347
- ["img-main-1", { stitchedImage: path.join(tempDir, "img-main-1.png") }]
348
- ]),
349
- screeningByKey: new Map([
350
- ["img-main-1", { passed: true, reason: "image path used", summary: "image path used" }]
351
- ])
345
+ candidates: [candidate]
352
346
  });
353
347
  cli.waitForNetworkResumeCandidateInfo = async () => ({
354
- resumeText: "这段 network 文本在 recommend 页面不应被用于筛选"
348
+ resumeText: "这段 network 文本在 recommend 页面应优先用于筛选"
355
349
  });
356
- cli.callTextModel = async () => {
357
- throw new Error("text model should not be called for recommend scope");
350
+ cli.callTextModel = async () => ({
351
+ passed: true,
352
+ reason: "network used",
353
+ summary: "network used"
354
+ });
355
+ cli.captureResumeImage = async () => {
356
+ throw new Error("capture should not be called when recommend network resume exists");
358
357
  };
359
358
 
360
359
  const result = await cli.run();
361
360
  assert.equal(result.status, "COMPLETED");
362
361
  assert.equal(result.result.passed_count, 1);
363
- assert.equal(result.result.resume_source, "image_fallback");
362
+ assert.equal(result.result.resume_source, "network");
364
363
  }
365
364
 
366
365
  async function testNetworkMissShouldFallbackToImageCapture() {
@@ -382,6 +381,88 @@ async function testNetworkMissShouldFallbackToImageCapture() {
382
381
  assert.equal(result.result.resume_source, "image_fallback");
383
382
  }
384
383
 
384
+ async function testLatestShouldPreferNetworkResumeWhenAvailable() {
385
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-latest-network-main-"));
386
+ const args = createArgs(tempDir);
387
+ args.pageScope = "latest";
388
+ const candidate = { key: "latest-net-1", geek_id: "latest-net-1", name: "latest network candidate" };
389
+ const cli = new FakeRecommendScreenCli(args, {
390
+ candidates: [candidate]
391
+ });
392
+ cli.waitForNetworkResumeCandidateInfo = async () => ({
393
+ resumeText: "最新页 network 简历可用"
394
+ });
395
+ cli.callTextModel = async () => ({
396
+ passed: true,
397
+ reason: "network used",
398
+ summary: "network used"
399
+ });
400
+ cli.captureResumeImage = async () => {
401
+ throw new Error("capture should not be called when latest network resume exists");
402
+ };
403
+
404
+ const result = await cli.run();
405
+ assert.equal(result.status, "COMPLETED");
406
+ assert.equal(result.result.passed_count, 1);
407
+ assert.equal(result.result.resume_source, "network");
408
+ }
409
+
410
+ async function testLatestNetworkMissShouldFallbackToImageCapture() {
411
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-latest-network-fallback-"));
412
+ const args = createArgs(tempDir);
413
+ args.pageScope = "latest";
414
+ const candidate = { key: "latest-img-1", geek_id: "latest-img-1", name: "latest image candidate" };
415
+ const cli = new FakeRecommendScreenCli(args, {
416
+ candidates: [candidate],
417
+ captureOutcomes: new Map([
418
+ ["latest-img-1", { stitchedImage: path.join(tempDir, "latest-img-1.png") }]
419
+ ]),
420
+ screeningByKey: new Map([
421
+ ["latest-img-1", { passed: false, reason: "image fallback used", summary: "image fallback used" }]
422
+ ])
423
+ });
424
+ cli.waitForNetworkResumeCandidateInfo = async () => null;
425
+
426
+ const result = await cli.run();
427
+ assert.equal(result.status, "COMPLETED");
428
+ assert.equal(result.result.resume_source, "image_fallback");
429
+ }
430
+
431
+ async function testVisionModelFailureShouldSkipCandidateAndContinue() {
432
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-vision-failure-skip-"));
433
+ const first = { key: "vision-fail-1", geek_id: "vision-fail-1", name: "vision-fail-1" };
434
+ const second = { key: "vision-pass-2", geek_id: "vision-pass-2", name: "vision-pass-2" };
435
+ const cli = new FakeRecommendScreenCli(createArgs(tempDir), {
436
+ candidates: [first, second],
437
+ captureOutcomes: new Map([
438
+ ["vision-fail-1", { stitchedImage: path.join(tempDir, "vision-fail-1.png") }],
439
+ ["vision-pass-2", { stitchedImage: path.join(tempDir, "vision-pass-2.png") }]
440
+ ]),
441
+ screeningByKey: new Map([
442
+ ["vision-pass-2", { passed: true, reason: "ok", summary: "ok" }]
443
+ ])
444
+ });
445
+
446
+ cli.callVisionModel = async () => {
447
+ if (cli.lastCapturedCandidateKey === "vision-fail-1") {
448
+ const error = new Error("model backend timeout");
449
+ error.code = "VISION_MODEL_FAILED";
450
+ throw error;
451
+ }
452
+ return {
453
+ passed: true,
454
+ reason: "ok",
455
+ summary: "ok"
456
+ };
457
+ };
458
+
459
+ const result = await cli.run();
460
+ assert.equal(result.status, "COMPLETED");
461
+ assert.equal(result.result.processed_count, 2);
462
+ assert.equal(result.result.passed_count, 1);
463
+ assert.equal(result.result.skipped_count, 1);
464
+ }
465
+
385
466
  async function testFeaturedNetworkMissShouldSkipWithoutImageCapture() {
386
467
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-featured-network-only-"));
387
468
  const args = createArgs(tempDir);
@@ -551,6 +632,50 @@ function testFavoriteActionParserShouldOnlyTrustKnownRequestShapes() {
551
632
  assert.equal(userMark, "add");
552
633
  }
553
634
 
635
+ function testFinishedWrapClassifierShouldNotTreatLoadMoreAsBottom() {
636
+ const loadMore = __testables.classifyFinishedWrapState("滚动加载更多", false);
637
+ const loading = __testables.classifyFinishedWrapState("正在加载数据...", false);
638
+ const noMore = __testables.classifyFinishedWrapState("没有更多人选", false);
639
+ const refreshOnly = __testables.classifyFinishedWrapState("", true);
640
+
641
+ assert.equal(loadMore.isBottom, false);
642
+ assert.equal(loadMore.matched_load_more_keyword, "滚动加载更多");
643
+ assert.equal(loading.isBottom, false);
644
+ assert.equal(loading.matched_load_more_keyword, "正在加载");
645
+ assert.equal(noMore.isBottom, true);
646
+ assert.equal(noMore.matched_bottom_keyword, "没有更多");
647
+ assert.equal(refreshOnly.isBottom, true);
648
+ assert.equal(refreshOnly.reason, "refresh_button_visible");
649
+ }
650
+
651
+ async function testGetCenteredCandidateClickPointShouldSupportLatestSelector() {
652
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-latest-click-locator-"));
653
+ const args = createArgs(tempDir);
654
+ args.pageScope = "latest";
655
+ const cli = new RecommendScreenCli(args);
656
+
657
+ let expressionCaptured = "";
658
+ cli.evaluate = async (expression) => {
659
+ expressionCaptured = String(expression || "");
660
+ return {
661
+ ok: true,
662
+ x: 100,
663
+ y: 100,
664
+ width: 120,
665
+ height: 64
666
+ };
667
+ };
668
+
669
+ const result = await cli.getCenteredCandidateClickPoint({
670
+ key: "latest-test-key",
671
+ geek_id: "latest-test-key"
672
+ });
673
+
674
+ assert.equal(result.ok, true);
675
+ assert.equal(expressionCaptured.includes(".candidate-card-wrap .card-inner[data-geek]"), true);
676
+ assert.equal(expressionCaptured.includes("getAttribute('data-geek')"), true);
677
+ }
678
+
554
679
  async function testFeaturedPostActionFailureShouldStillRecordPassedCandidate() {
555
680
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-featured-action-failure-"));
556
681
  const args = createArgs(tempDir);
@@ -724,8 +849,11 @@ async function main() {
724
849
  await testPageExhaustedBeforeTargetShouldRaiseRecoverableError();
725
850
  await testPageExhaustedWithoutTargetShouldStillComplete();
726
851
  await testFeaturedShouldUseNetworkResumeOnly();
727
- await testRecommendShouldKeepImageCaptureEvenWhenNetworkResumeExists();
852
+ await testRecommendShouldPreferNetworkResumeWhenAvailable();
728
853
  await testNetworkMissShouldFallbackToImageCapture();
854
+ await testLatestShouldPreferNetworkResumeWhenAvailable();
855
+ await testLatestNetworkMissShouldFallbackToImageCapture();
856
+ await testVisionModelFailureShouldSkipCandidateAndContinue();
729
857
  await testFeaturedNetworkMissShouldSkipWithoutImageCapture();
730
858
  await testFeaturedFavoriteShouldNotUseDomFallback();
731
859
  await testFeaturedFavoriteShouldSkipClickWhenAlreadyInterested();
@@ -735,6 +863,8 @@ async function main() {
735
863
  testFavoriteActionParserShouldSupportFallbackRequestShape();
736
864
  testFavoriteActionParserShouldSupportWebSocketPayload();
737
865
  testFavoriteActionParserShouldOnlyTrustKnownRequestShapes();
866
+ testFinishedWrapClassifierShouldNotTreatLoadMoreAsBottom();
867
+ await testGetCenteredCandidateClickPointShouldSupportLatestSelector();
738
868
  await testFeaturedPostActionFailureShouldStillRecordPassedCandidate();
739
869
  await testStitchWithSharpShouldComposeExpectedImage();
740
870
  testStitchWithAvailablePythonShouldFallbackToPython();