@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
|
@@ -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:
|
|
1064
|
-
reason:
|
|
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
|
|
1998
|
-
|
|
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
|
-
|
|
2026
|
-
this.resumeNetworkByGeekId.set(
|
|
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
|
|
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
|
|
2130
|
-
|
|
2131
|
-
|
|
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
|
|
2812
|
+
const recommendInner = Array.from(doc.querySelectorAll('.card-inner[data-geekid]'))
|
|
2619
2813
|
.find((item) => (item.getAttribute('data-geekid') || '') === String(candidateKey)) || null;
|
|
2620
|
-
const
|
|
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 =
|
|
2625
|
-
? (
|
|
2626
|
-
:
|
|
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 (
|
|
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
|
|
342
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-recommend-
|
|
343
|
-
const candidate = { key: "
|
|
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
|
-
|
|
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, "
|
|
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
|
|
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();
|