@reconcrap/boss-recommend-mcp 1.2.1 → 1.2.2

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.2.1",
3
+ "version": "1.2.2",
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;
@@ -1041,7 +1111,8 @@ const jsDetectBottom = `(() => {
1041
1111
  const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
1042
1112
  const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
1043
1113
  .find((el) => isVisible(el)) || null;
1044
- const keywords = ['没有更多', '已显示全部', '已经到底', '暂无更多', '推荐完了', '没有更多人选'];
1114
+ const keywords = ${JSON.stringify(BOTTOM_HINT_KEYWORDS)};
1115
+ const loadMoreKeywords = ${JSON.stringify(LOAD_MORE_HINT_KEYWORDS)};
1045
1116
  const elements = Array.from(doc.querySelectorAll('div,span,p'));
1046
1117
  for (const el of elements) {
1047
1118
  if (el.offsetParent === null) continue;
@@ -1059,12 +1130,21 @@ const jsDetectBottom = `(() => {
1059
1130
  }
1060
1131
  }
1061
1132
  }
1133
+ const finishedWrapText = finishedWrap ? String(finishedWrap.textContent || '').replace(/\s+/g, ' ').trim() : '';
1134
+ const matchedBottomKeyword = keywords.find((keyword) => finishedWrapText.includes(keyword)) || null;
1135
+ const matchedLoadMoreKeyword = loadMoreKeywords.find((keyword) => finishedWrapText.includes(keyword)) || null;
1136
+ const inferredBottom = matchedBottomKeyword
1137
+ ? true
1138
+ : (Boolean(refreshButton) && !matchedLoadMoreKeyword);
1062
1139
  return {
1063
- isBottom: Boolean(finishedWrap),
1064
- reason: finishedWrap ? 'finished-wrap' : null,
1140
+ isBottom: inferredBottom,
1141
+ reason: matchedBottomKeyword || (inferredBottom ? 'refresh_button_visible' : null),
1065
1142
  finished_wrap_visible: Boolean(finishedWrap),
1143
+ finished_wrap_text: finishedWrapText || null,
1066
1144
  refresh_button_visible: Boolean(refreshButton),
1067
- refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null
1145
+ refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null,
1146
+ matched_bottom_keyword: matchedBottomKeyword,
1147
+ matched_load_more_keyword: matchedLoadMoreKeyword
1068
1148
  };
1069
1149
  })()`;
1070
1150
  const jsWaitForDetail = `(() => {
@@ -2615,15 +2695,21 @@ class RecommendScreenCli {
2615
2695
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
2616
2696
  }
2617
2697
  const doc = frame.contentDocument;
2618
- const inner = Array.from(doc.querySelectorAll('.card-inner[data-geekid]'))
2698
+ const recommendInner = Array.from(doc.querySelectorAll('.card-inner[data-geekid]'))
2619
2699
  .find((item) => (item.getAttribute('data-geekid') || '') === String(candidateKey)) || null;
2620
- const featuredAnchor = inner
2700
+ const latestInner = recommendInner
2701
+ ? null
2702
+ : Array.from(doc.querySelectorAll('.candidate-card-wrap .card-inner[data-geek], .candidate-card-wrap [data-geek]'))
2703
+ .find((item) => (item.getAttribute('data-geek') || '') === String(candidateKey)) || null;
2704
+ const featuredAnchor = (recommendInner || latestInner)
2621
2705
  ? null
2622
2706
  : Array.from(doc.querySelectorAll('li.geek-info-card a[data-geekid], a[data-geekid]'))
2623
2707
  .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);
2708
+ const card = recommendInner
2709
+ ? (recommendInner.closest('li.card-item') || recommendInner.closest('.card-item'))
2710
+ : latestInner
2711
+ ? (latestInner.closest('.candidate-card-wrap') || latestInner.closest('li.card-item') || latestInner.closest('.card-item'))
2712
+ : (featuredAnchor ? (featuredAnchor.closest('li.geek-info-card') || featuredAnchor.closest('.geek-info-card')) : null);
2627
2713
  if (!card) {
2628
2714
  return { ok: false, error: 'CANDIDATE_CARD_NOT_FOUND' };
2629
2715
  }
@@ -2716,6 +2802,130 @@ class RecommendScreenCli {
2716
2802
  }
2717
2803
 
2718
2804
  async callVisionModel(imagePath) {
2805
+ const primaryLimit = resolveVisionPixelLimitFromEnv(
2806
+ "BOSS_RECOMMEND_VISION_MAX_IMAGE_PIXELS",
2807
+ DEFAULT_VISION_MAX_IMAGE_PIXELS
2808
+ );
2809
+ const retryLimit = resolveVisionRetryPixelLimit(primaryLimit);
2810
+ const preparedPrimary = await this.prepareVisionImageForModel(imagePath, primaryLimit, "primary");
2811
+ try {
2812
+ return await this.requestVisionModel(preparedPrimary.imagePath);
2813
+ } catch (error) {
2814
+ if (!isVisionImageSizeLimitMessage(error?.message || "")) {
2815
+ throw error;
2816
+ }
2817
+ log(
2818
+ `[VISION] 检测到图片尺寸超限,准备降采样重试: ` +
2819
+ `primary_limit=${primaryLimit} source=${preparedPrimary.source} ` +
2820
+ `source_pixels=${preparedPrimary.sourcePixels ?? "unknown"}`
2821
+ );
2822
+ }
2823
+ const preparedRetry = await this.prepareVisionImageForModel(imagePath, retryLimit, "retry");
2824
+ try {
2825
+ return await this.requestVisionModel(preparedRetry.imagePath);
2826
+ } catch (retryError) {
2827
+ if (!isVisionImageSizeLimitMessage(retryError?.message || "")) {
2828
+ throw retryError;
2829
+ }
2830
+ throw this.buildError(
2831
+ "VISION_IMAGE_SIZE_LIMIT_EXCEEDED",
2832
+ `Vision model still rejected image after retry downscale; ` +
2833
+ `primary_limit=${primaryLimit}; retry_limit=${retryLimit}; ` +
2834
+ `source_pixels=${preparedRetry.sourcePixels ?? "unknown"}; ` +
2835
+ `retry_pixels=${preparedRetry.currentPixels ?? "unknown"}; ` +
2836
+ `last_error=${normalizeText(retryError?.message || retryError)}`
2837
+ );
2838
+ }
2839
+ }
2840
+
2841
+ async prepareVisionImageForModel(imagePath, maxPixels, attemptTag = "primary") {
2842
+ const resolvedMaxPixels = parsePositiveInteger(maxPixels);
2843
+ if (!resolvedMaxPixels) {
2844
+ return {
2845
+ imagePath,
2846
+ source: "no_limit",
2847
+ sourcePixels: null,
2848
+ currentPixels: null
2849
+ };
2850
+ }
2851
+ let sharp;
2852
+ try {
2853
+ sharp = loadVisionSharp();
2854
+ } catch (error) {
2855
+ log(`[VISION] 加载 sharp 失败,跳过预缩放: ${error?.message || error}`);
2856
+ return {
2857
+ imagePath,
2858
+ source: "sharp_unavailable",
2859
+ sourcePixels: null,
2860
+ currentPixels: null
2861
+ };
2862
+ }
2863
+ let metadata;
2864
+ try {
2865
+ metadata = await sharp(imagePath).metadata();
2866
+ } catch (error) {
2867
+ log(`[VISION] 读取图片尺寸失败,跳过预缩放: ${error?.message || error}`);
2868
+ return {
2869
+ imagePath,
2870
+ source: "metadata_error",
2871
+ sourcePixels: null,
2872
+ currentPixels: null
2873
+ };
2874
+ }
2875
+ const width = Number(metadata?.width || 0);
2876
+ const height = Number(metadata?.height || 0);
2877
+ const sourcePixels = width > 0 && height > 0 ? width * height : null;
2878
+ if (!sourcePixels || sourcePixels <= resolvedMaxPixels) {
2879
+ return {
2880
+ imagePath,
2881
+ source: "within_limit",
2882
+ sourcePixels,
2883
+ currentPixels: sourcePixels
2884
+ };
2885
+ }
2886
+ const scale = Math.sqrt(resolvedMaxPixels / sourcePixels);
2887
+ const targetWidth = Math.max(1, Math.floor(width * scale));
2888
+ const targetHeight = Math.max(1, Math.floor(height * scale));
2889
+ const parsedPath = path.parse(imagePath);
2890
+ const resizedPath = path.join(
2891
+ parsedPath.dir,
2892
+ `${parsedPath.name}.${attemptTag}.max${resolvedMaxPixels}.png`
2893
+ );
2894
+ try {
2895
+ await sharp(imagePath)
2896
+ .resize({
2897
+ width: targetWidth,
2898
+ height: targetHeight,
2899
+ fit: "inside",
2900
+ withoutEnlargement: true
2901
+ })
2902
+ .png()
2903
+ .toFile(resizedPath);
2904
+ const resizedMeta = await sharp(resizedPath).metadata();
2905
+ const resizedPixels = Number(resizedMeta?.width || 0) * Number(resizedMeta?.height || 0);
2906
+ log(
2907
+ `[VISION] 图片预缩放完成: ${width}x${height}(${sourcePixels}) -> ` +
2908
+ `${resizedMeta?.width || "?"}x${resizedMeta?.height || "?"}(${resizedPixels || "?"}); ` +
2909
+ `limit=${resolvedMaxPixels}; attempt=${attemptTag}`
2910
+ );
2911
+ return {
2912
+ imagePath: resizedPath,
2913
+ source: "resized",
2914
+ sourcePixels,
2915
+ currentPixels: resizedPixels || null
2916
+ };
2917
+ } catch (error) {
2918
+ log(`[VISION] 预缩放失败,继续使用原图: ${error?.message || error}`);
2919
+ return {
2920
+ imagePath,
2921
+ source: "resize_failed",
2922
+ sourcePixels,
2923
+ currentPixels: sourcePixels
2924
+ };
2925
+ }
2926
+ }
2927
+
2928
+ async requestVisionModel(imagePath) {
2719
2929
  const imageBase64 = fs.readFileSync(imagePath, "base64");
2720
2930
  const rawBaseUrl = this.args.baseUrl;
2721
2931
  log(`[callVisionModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
@@ -3361,9 +3571,28 @@ class RecommendScreenCli {
3361
3571
  } else {
3362
3572
  this.resetResumeCaptureFailureStreak();
3363
3573
  }
3364
- if (["VISION_MODEL_FAILED", "TEXT_MODEL_FAILED"].includes(error.code)) {
3574
+ if (error.code === "TEXT_MODEL_FAILED") {
3365
3575
  throw error;
3366
3576
  }
3577
+ if (error.code === "VISION_MODEL_FAILED") {
3578
+ if (isVisionImageSizeLimitMessage(error?.message || "")) {
3579
+ log(
3580
+ `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} 触发视觉模型像素限制,` +
3581
+ "已在本轮跳过并继续处理下一位。"
3582
+ );
3583
+ } else {
3584
+ log(
3585
+ `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} 视觉模型调用失败,` +
3586
+ "已在本轮跳过并继续处理下一位。"
3587
+ );
3588
+ }
3589
+ }
3590
+ if (error.code === "VISION_IMAGE_SIZE_LIMIT_EXCEEDED") {
3591
+ log(
3592
+ `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} 触发视觉模型像素限制,` +
3593
+ "已完成预缩放和重试,仍失败,继续处理下一位。"
3594
+ );
3595
+ }
3367
3596
  } finally {
3368
3597
  const closed = await this.closeDetailPage();
3369
3598
  if (!closed) {
@@ -3485,7 +3714,8 @@ if (require.main === module) {
3485
3714
  parseFavoriteActionFromKnownRequest,
3486
3715
  parseFavoriteActionFromActionLog,
3487
3716
  parseFavoriteActionFromWsPayload,
3488
- isRecoverablePostActionError
3717
+ isRecoverablePostActionError,
3718
+ classifyFinishedWrapState
3489
3719
  }
3490
3720
  };
3491
3721
  }
@@ -382,6 +382,41 @@ async function testNetworkMissShouldFallbackToImageCapture() {
382
382
  assert.equal(result.result.resume_source, "image_fallback");
383
383
  }
384
384
 
385
+ async function testVisionModelFailureShouldSkipCandidateAndContinue() {
386
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-vision-failure-skip-"));
387
+ const first = { key: "vision-fail-1", geek_id: "vision-fail-1", name: "vision-fail-1" };
388
+ const second = { key: "vision-pass-2", geek_id: "vision-pass-2", name: "vision-pass-2" };
389
+ const cli = new FakeRecommendScreenCli(createArgs(tempDir), {
390
+ candidates: [first, second],
391
+ captureOutcomes: new Map([
392
+ ["vision-fail-1", { stitchedImage: path.join(tempDir, "vision-fail-1.png") }],
393
+ ["vision-pass-2", { stitchedImage: path.join(tempDir, "vision-pass-2.png") }]
394
+ ]),
395
+ screeningByKey: new Map([
396
+ ["vision-pass-2", { passed: true, reason: "ok", summary: "ok" }]
397
+ ])
398
+ });
399
+
400
+ cli.callVisionModel = async () => {
401
+ if (cli.lastCapturedCandidateKey === "vision-fail-1") {
402
+ const error = new Error("model backend timeout");
403
+ error.code = "VISION_MODEL_FAILED";
404
+ throw error;
405
+ }
406
+ return {
407
+ passed: true,
408
+ reason: "ok",
409
+ summary: "ok"
410
+ };
411
+ };
412
+
413
+ const result = await cli.run();
414
+ assert.equal(result.status, "COMPLETED");
415
+ assert.equal(result.result.processed_count, 2);
416
+ assert.equal(result.result.passed_count, 1);
417
+ assert.equal(result.result.skipped_count, 1);
418
+ }
419
+
385
420
  async function testFeaturedNetworkMissShouldSkipWithoutImageCapture() {
386
421
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-featured-network-only-"));
387
422
  const args = createArgs(tempDir);
@@ -551,6 +586,50 @@ function testFavoriteActionParserShouldOnlyTrustKnownRequestShapes() {
551
586
  assert.equal(userMark, "add");
552
587
  }
553
588
 
589
+ function testFinishedWrapClassifierShouldNotTreatLoadMoreAsBottom() {
590
+ const loadMore = __testables.classifyFinishedWrapState("滚动加载更多", false);
591
+ const loading = __testables.classifyFinishedWrapState("正在加载数据...", false);
592
+ const noMore = __testables.classifyFinishedWrapState("没有更多人选", false);
593
+ const refreshOnly = __testables.classifyFinishedWrapState("", true);
594
+
595
+ assert.equal(loadMore.isBottom, false);
596
+ assert.equal(loadMore.matched_load_more_keyword, "滚动加载更多");
597
+ assert.equal(loading.isBottom, false);
598
+ assert.equal(loading.matched_load_more_keyword, "正在加载");
599
+ assert.equal(noMore.isBottom, true);
600
+ assert.equal(noMore.matched_bottom_keyword, "没有更多");
601
+ assert.equal(refreshOnly.isBottom, true);
602
+ assert.equal(refreshOnly.reason, "refresh_button_visible");
603
+ }
604
+
605
+ async function testGetCenteredCandidateClickPointShouldSupportLatestSelector() {
606
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-latest-click-locator-"));
607
+ const args = createArgs(tempDir);
608
+ args.pageScope = "latest";
609
+ const cli = new RecommendScreenCli(args);
610
+
611
+ let expressionCaptured = "";
612
+ cli.evaluate = async (expression) => {
613
+ expressionCaptured = String(expression || "");
614
+ return {
615
+ ok: true,
616
+ x: 100,
617
+ y: 100,
618
+ width: 120,
619
+ height: 64
620
+ };
621
+ };
622
+
623
+ const result = await cli.getCenteredCandidateClickPoint({
624
+ key: "latest-test-key",
625
+ geek_id: "latest-test-key"
626
+ });
627
+
628
+ assert.equal(result.ok, true);
629
+ assert.equal(expressionCaptured.includes(".candidate-card-wrap .card-inner[data-geek]"), true);
630
+ assert.equal(expressionCaptured.includes("getAttribute('data-geek')"), true);
631
+ }
632
+
554
633
  async function testFeaturedPostActionFailureShouldStillRecordPassedCandidate() {
555
634
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-featured-action-failure-"));
556
635
  const args = createArgs(tempDir);
@@ -726,6 +805,7 @@ async function main() {
726
805
  await testFeaturedShouldUseNetworkResumeOnly();
727
806
  await testRecommendShouldKeepImageCaptureEvenWhenNetworkResumeExists();
728
807
  await testNetworkMissShouldFallbackToImageCapture();
808
+ await testVisionModelFailureShouldSkipCandidateAndContinue();
729
809
  await testFeaturedNetworkMissShouldSkipWithoutImageCapture();
730
810
  await testFeaturedFavoriteShouldNotUseDomFallback();
731
811
  await testFeaturedFavoriteShouldSkipClickWhenAlreadyInterested();
@@ -735,6 +815,8 @@ async function main() {
735
815
  testFavoriteActionParserShouldSupportFallbackRequestShape();
736
816
  testFavoriteActionParserShouldSupportWebSocketPayload();
737
817
  testFavoriteActionParserShouldOnlyTrustKnownRequestShapes();
818
+ testFinishedWrapClassifierShouldNotTreatLoadMoreAsBottom();
819
+ await testGetCenteredCandidateClickPointShouldSupportLatestSelector();
738
820
  await testFeaturedPostActionFailureShouldStillRecordPassedCandidate();
739
821
  await testStitchWithSharpShouldComposeExpectedImage();
740
822
  testStitchWithAvailablePythonShouldFallbackToPython();