@reconcrap/boss-recommend-mcp 1.3.39 → 2.0.1
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/README.md +86 -33
- package/package.json +62 -9
- package/skills/boss-chat/SKILL.md +5 -4
- package/skills/boss-recommend-pipeline/SKILL.md +21 -31
- package/skills/boss-recruit-pipeline/README.md +17 -0
- package/skills/boss-recruit-pipeline/SKILL.md +55 -0
- package/src/chat-mcp.js +1333 -0
- package/src/chat-runtime-config.js +559 -0
- package/src/cli.js +1254 -225
- package/src/core/browser/index.js +378 -0
- package/src/core/capture/index.js +298 -0
- package/src/core/cv-acquisition/index.js +219 -0
- package/src/core/greet-quota/index.js +54 -0
- package/src/core/infinite-list/index.js +459 -0
- package/src/core/reporting/legacy-csv.js +332 -0
- package/src/core/run/index.js +286 -0
- package/src/core/screening/index.js +1166 -0
- package/src/core/self-heal/index.js +848 -0
- package/src/domains/chat/cards.js +129 -0
- package/src/domains/chat/constants.js +183 -0
- package/src/domains/chat/detail.js +1369 -0
- package/src/domains/chat/index.js +7 -0
- package/src/domains/chat/jobs.js +334 -0
- package/src/domains/chat/page-guard.js +88 -0
- package/src/domains/chat/roots.js +56 -0
- package/src/domains/chat/run-service.js +1101 -0
- package/src/domains/recommend/actions.js +457 -0
- package/src/domains/recommend/cards.js +228 -0
- package/src/domains/recommend/constants.js +141 -0
- package/src/domains/recommend/detail.js +341 -0
- package/src/domains/recommend/filters.js +581 -0
- package/src/domains/recommend/index.js +10 -0
- package/src/domains/recommend/jobs.js +232 -0
- package/src/domains/recommend/refresh.js +204 -0
- package/src/domains/recommend/roots.js +78 -0
- package/src/domains/recommend/run-service.js +903 -0
- package/src/domains/recommend/scopes.js +245 -0
- package/src/domains/recruit/actions.js +277 -0
- package/src/domains/recruit/cards.js +66 -0
- package/src/domains/recruit/constants.js +130 -0
- package/src/domains/recruit/detail.js +414 -0
- package/src/domains/recruit/index.js +9 -0
- package/src/domains/recruit/instruction-parser.js +451 -0
- package/src/domains/recruit/refresh.js +40 -0
- package/src/domains/recruit/roots.js +67 -0
- package/src/domains/recruit/run-service.js +580 -0
- package/src/domains/recruit/search.js +1149 -0
- package/src/index.js +578 -419
- package/src/recommend-mcp.js +1257 -0
- package/src/recruit-mcp.js +1035 -0
- package/src/adapters.js +0 -3079
- package/src/boss-chat.js +0 -1037
- package/src/pipeline.js +0 -2249
- package/src/recommend-healing-config.js +0 -131
- package/src/recommend-healing-rules.json +0 -261
- package/src/self-heal.js +0 -2237
- package/src/test-adapters-runtime.js +0 -628
- package/src/test-boss-chat.js +0 -3196
- package/src/test-index-async.js +0 -498
- package/src/test-parser.js +0 -742
- package/src/test-pipeline.js +0 -2703
- package/src/test-run-state.js +0 -152
- package/src/test-self-heal.js +0 -224
- package/vendor/boss-chat-cli/README.md +0 -134
- package/vendor/boss-chat-cli/package.json +0 -53
- package/vendor/boss-chat-cli/src/app.js +0 -1501
- package/vendor/boss-chat-cli/src/browser/chat-page.js +0 -3562
- package/vendor/boss-chat-cli/src/cli.js +0 -1713
- package/vendor/boss-chat-cli/src/mcp/server.js +0 -149
- package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +0 -193
- package/vendor/boss-chat-cli/src/runtime/async-run-state.js +0 -260
- package/vendor/boss-chat-cli/src/runtime/interaction.js +0 -102
- package/vendor/boss-chat-cli/src/runtime/run-control.js +0 -102
- package/vendor/boss-chat-cli/src/services/chrome-client.js +0 -107
- package/vendor/boss-chat-cli/src/services/llm.js +0 -1292
- package/vendor/boss-chat-cli/src/services/llm.test.js +0 -326
- package/vendor/boss-chat-cli/src/services/profile-store.js +0 -173
- package/vendor/boss-chat-cli/src/services/report-store.js +0 -317
- package/vendor/boss-chat-cli/src/services/resume-capture.js +0 -469
- package/vendor/boss-chat-cli/src/services/resume-network.js +0 -727
- package/vendor/boss-chat-cli/src/services/state-store.js +0 -90
- package/vendor/boss-chat-cli/src/utils/customer-key.js +0 -82
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +0 -7072
- package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +0 -817
- package/vendor/boss-recommend-screen-cli/scripts/stitch_resume_chunks.py +0 -141
- package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +0 -2423
- package/vendor/boss-recommend-search-cli/src/cli.js +0 -1698
- package/vendor/boss-recommend-search-cli/src/test-job-selection.js +0 -211
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clickPoint,
|
|
3
|
+
getFrameDocumentNodeId,
|
|
4
|
+
getAttributesMap,
|
|
5
|
+
getNodeBox,
|
|
6
|
+
getOuterHTML,
|
|
7
|
+
querySelectorAll,
|
|
8
|
+
sleep
|
|
9
|
+
} from "../../core/browser/index.js";
|
|
10
|
+
import {
|
|
11
|
+
htmlToText,
|
|
12
|
+
normalizeText
|
|
13
|
+
} from "../../core/screening/index.js";
|
|
14
|
+
import {
|
|
15
|
+
assertGreetQuotaAvailable,
|
|
16
|
+
parseGreetQuota
|
|
17
|
+
} from "../../core/greet-quota/index.js";
|
|
18
|
+
import {
|
|
19
|
+
FAVORITE_BUTTON_SELECTORS,
|
|
20
|
+
GREET_BUTTON_RECOMMEND_SELECTORS
|
|
21
|
+
} from "./constants.js";
|
|
22
|
+
import { waitForRecommendDetail } from "./detail.js";
|
|
23
|
+
import { getRecommendRoots } from "./roots.js";
|
|
24
|
+
|
|
25
|
+
const POST_ACTIONS = new Set(["none", "favorite", "greet"]);
|
|
26
|
+
const GREET_EXACT_LABEL_PATTERN = /^(?:打招呼|聊一聊|立即沟通(?:[\((]\d+\s*[//]\s*\d+[\))])?|沟通)$/i;
|
|
27
|
+
export const RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS = Object.freeze([
|
|
28
|
+
"button",
|
|
29
|
+
".btn",
|
|
30
|
+
'[role="button"]',
|
|
31
|
+
"a",
|
|
32
|
+
"span",
|
|
33
|
+
"div"
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function uniqueSelectors(...selectorGroups) {
|
|
37
|
+
return [...new Set(selectorGroups.flat().filter(Boolean))];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function uniqueByNode(candidates = []) {
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const result = [];
|
|
43
|
+
for (const item of candidates) {
|
|
44
|
+
const key = `${item.kind}:${item.root}:${item.node_id}`;
|
|
45
|
+
if (seen.has(key)) continue;
|
|
46
|
+
seen.add(key);
|
|
47
|
+
result.push(item);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function lowerText(...parts) {
|
|
53
|
+
return normalizeText(parts.filter(Boolean).join(" ")).toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasActiveClass(text) {
|
|
57
|
+
return /(?:^|\s)(?:active|curr|current|selected|checked)(?:\s|$)/i.test(text);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasDisabledSignal(text) {
|
|
61
|
+
return /(?:^|\s)(?:disabled|disable|forbidden|is-disabled)(?:\s|$)/i.test(text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function rectArea(control) {
|
|
65
|
+
const rect = control?.rect || {};
|
|
66
|
+
return Math.max(0, Number(rect.width) || 0) * Math.max(0, Number(rect.height) || 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isCompactLabel(control, limit = 80) {
|
|
70
|
+
const label = normalizeText(control?.label || "");
|
|
71
|
+
return label.length > 0 && label.length <= limit;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function controlRank(control, exactLabelPattern) {
|
|
75
|
+
const label = normalizeText(control?.label || "");
|
|
76
|
+
const selector = String(control?.selector || "");
|
|
77
|
+
const className = String(control?.class_name || "");
|
|
78
|
+
let score = 0;
|
|
79
|
+
if (exactLabelPattern.test(label)) score -= 1000;
|
|
80
|
+
if (/button|\[role=|\.btn/.test(selector) || /btn|button/i.test(className)) score -= 250;
|
|
81
|
+
if (selector === "div") score += 300;
|
|
82
|
+
if (!isCompactLabel(control)) score += 500;
|
|
83
|
+
score += Math.min(rectArea(control), 100000) / 1000;
|
|
84
|
+
score += label.length / 10;
|
|
85
|
+
return score;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function bestControl(controls, exactLabelPattern) {
|
|
89
|
+
return [...controls].sort((left, right) => (
|
|
90
|
+
controlRank(left, exactLabelPattern) - controlRank(right, exactLabelPattern)
|
|
91
|
+
))[0] || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function normalizeRecommendPostAction(value) {
|
|
95
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
96
|
+
if (["", "none", "skip", "no", "不执行", "无"].includes(normalized)) return "none";
|
|
97
|
+
if (["favorite", "fav", "collect", "收藏", "感兴趣"].includes(normalized)) return "favorite";
|
|
98
|
+
if (["greet", "chat", "打招呼", "直接沟通", "沟通"].includes(normalized)) return "greet";
|
|
99
|
+
return POST_ACTIONS.has(normalized) ? normalized : "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveRecommendPostAction({
|
|
103
|
+
postAction = "none",
|
|
104
|
+
greetCount = 0,
|
|
105
|
+
maxGreetCount = null
|
|
106
|
+
} = {}) {
|
|
107
|
+
const requested = normalizeRecommendPostAction(postAction) || "none";
|
|
108
|
+
const currentGreetCount = Number.isInteger(greetCount) && greetCount >= 0 ? greetCount : 0;
|
|
109
|
+
const limit = Number.isInteger(maxGreetCount) && maxGreetCount > 0 ? maxGreetCount : null;
|
|
110
|
+
if (requested === "greet" && limit !== null && currentGreetCount >= limit) {
|
|
111
|
+
return {
|
|
112
|
+
requested,
|
|
113
|
+
effective: "favorite",
|
|
114
|
+
reason: "greet_limit_reached",
|
|
115
|
+
greet_count: currentGreetCount,
|
|
116
|
+
max_greet_count: limit
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
requested,
|
|
121
|
+
effective: requested,
|
|
122
|
+
reason: "requested_action",
|
|
123
|
+
greet_count: currentGreetCount,
|
|
124
|
+
max_greet_count: limit
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function classifyFavoriteControl({
|
|
129
|
+
outerHTML = "",
|
|
130
|
+
attributes = {}
|
|
131
|
+
} = {}) {
|
|
132
|
+
const label = htmlToText(outerHTML);
|
|
133
|
+
const labelText = normalizeText(label);
|
|
134
|
+
const className = normalizeText(attributes.class || "");
|
|
135
|
+
const title = normalizeText(attributes.title || attributes["aria-label"] || "");
|
|
136
|
+
const combined = lowerText(className, title);
|
|
137
|
+
const labelMatches = /^(?:收藏|已收藏|感兴趣|已感兴趣)$/.test(labelText);
|
|
138
|
+
const classMatches = /favorite|collect|interest|like/.test(combined);
|
|
139
|
+
const matches = labelMatches || classMatches;
|
|
140
|
+
const active = (
|
|
141
|
+
/已收藏|已感兴趣/.test(label)
|
|
142
|
+
|| /like-icon-active|favorite-active|collect-active/i.test(outerHTML)
|
|
143
|
+
|| hasActiveClass(className)
|
|
144
|
+
);
|
|
145
|
+
const disabled = (
|
|
146
|
+
Object.prototype.hasOwnProperty.call(attributes, "disabled")
|
|
147
|
+
|| hasDisabledSignal(className)
|
|
148
|
+
|| /disabled/i.test(outerHTML)
|
|
149
|
+
);
|
|
150
|
+
return {
|
|
151
|
+
kind: "favorite",
|
|
152
|
+
matches,
|
|
153
|
+
active,
|
|
154
|
+
disabled,
|
|
155
|
+
label: label || title || null,
|
|
156
|
+
class_name: className || null
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function classifyGreetControl({
|
|
161
|
+
outerHTML = "",
|
|
162
|
+
attributes = {}
|
|
163
|
+
} = {}) {
|
|
164
|
+
const label = htmlToText(outerHTML);
|
|
165
|
+
const labelText = normalizeText(label);
|
|
166
|
+
const className = normalizeText(attributes.class || "");
|
|
167
|
+
const title = normalizeText(attributes.title || attributes["aria-label"] || "");
|
|
168
|
+
const combined = lowerText(className, title);
|
|
169
|
+
const continueChat = labelText.length <= 40 && /继续沟通/.test(labelText);
|
|
170
|
+
const greetQuota = parseGreetQuota(labelText || title);
|
|
171
|
+
const greetEntry = (
|
|
172
|
+
GREET_EXACT_LABEL_PATTERN.test(labelText)
|
|
173
|
+
|| greetQuota.found
|
|
174
|
+
|| /greet/i.test(combined)
|
|
175
|
+
);
|
|
176
|
+
const disabled = (
|
|
177
|
+
Object.prototype.hasOwnProperty.call(attributes, "disabled")
|
|
178
|
+
|| hasDisabledSignal(className)
|
|
179
|
+
|| /disabled/i.test(outerHTML)
|
|
180
|
+
);
|
|
181
|
+
return {
|
|
182
|
+
kind: "greet",
|
|
183
|
+
matches: greetEntry || continueChat,
|
|
184
|
+
available: greetEntry && !continueChat && !disabled,
|
|
185
|
+
continue_chat: continueChat,
|
|
186
|
+
disabled,
|
|
187
|
+
label: label || title || null,
|
|
188
|
+
greet_quota: greetQuota.found ? greetQuota : null,
|
|
189
|
+
class_name: className || null
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function readActionNode(client, {
|
|
194
|
+
root,
|
|
195
|
+
selector,
|
|
196
|
+
nodeId,
|
|
197
|
+
kind
|
|
198
|
+
}) {
|
|
199
|
+
const [attributes, outerHTML] = await Promise.all([
|
|
200
|
+
getAttributesMap(client, nodeId),
|
|
201
|
+
getOuterHTML(client, nodeId)
|
|
202
|
+
]);
|
|
203
|
+
let box = null;
|
|
204
|
+
let visible = false;
|
|
205
|
+
try {
|
|
206
|
+
box = await getNodeBox(client, nodeId);
|
|
207
|
+
visible = box.rect.width > 2 && box.rect.height > 2;
|
|
208
|
+
} catch {}
|
|
209
|
+
const classification = kind === "favorite"
|
|
210
|
+
? classifyFavoriteControl({ outerHTML, attributes })
|
|
211
|
+
: classifyGreetControl({ outerHTML, attributes });
|
|
212
|
+
return {
|
|
213
|
+
kind,
|
|
214
|
+
root: root.name,
|
|
215
|
+
root_node_id: root.nodeId,
|
|
216
|
+
selector,
|
|
217
|
+
node_id: nodeId,
|
|
218
|
+
visible,
|
|
219
|
+
center: box?.center || null,
|
|
220
|
+
rect: box?.rect || null,
|
|
221
|
+
attributes,
|
|
222
|
+
outer_html_length: outerHTML.length,
|
|
223
|
+
html_preview: outerHTML.slice(0, 500),
|
|
224
|
+
...classification
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function collectRecommendActionControls(client, roots, {
|
|
229
|
+
favoriteSelectors = FAVORITE_BUTTON_SELECTORS,
|
|
230
|
+
greetSelectors = GREET_BUTTON_RECOMMEND_SELECTORS,
|
|
231
|
+
detailTextFallback = false
|
|
232
|
+
} = {}) {
|
|
233
|
+
const candidates = [];
|
|
234
|
+
const favoriteScanSelectors = detailTextFallback
|
|
235
|
+
? uniqueSelectors(favoriteSelectors, RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS)
|
|
236
|
+
: favoriteSelectors;
|
|
237
|
+
const greetScanSelectors = detailTextFallback
|
|
238
|
+
? uniqueSelectors(greetSelectors, RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS)
|
|
239
|
+
: greetSelectors;
|
|
240
|
+
for (const root of roots) {
|
|
241
|
+
if (!root?.nodeId) continue;
|
|
242
|
+
for (const [kind, selectors] of [
|
|
243
|
+
["favorite", favoriteScanSelectors],
|
|
244
|
+
["greet", greetScanSelectors]
|
|
245
|
+
]) {
|
|
246
|
+
for (const selector of selectors) {
|
|
247
|
+
const nodeIds = await querySelectorAll(client, root.nodeId, selector);
|
|
248
|
+
for (const nodeId of nodeIds) {
|
|
249
|
+
candidates.push(await readActionNode(client, {
|
|
250
|
+
root,
|
|
251
|
+
selector,
|
|
252
|
+
nodeId,
|
|
253
|
+
kind
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return uniqueByNode(candidates);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function summarizeRecommendActionControls(controls = []) {
|
|
263
|
+
const visibleControls = controls.filter((item) => item.visible && item.matches);
|
|
264
|
+
const favoriteControls = visibleControls.filter((item) => item.kind === "favorite");
|
|
265
|
+
const greetControls = visibleControls.filter((item) => item.kind === "greet");
|
|
266
|
+
const favorite = bestControl(
|
|
267
|
+
favoriteControls.filter((item) => item.matches),
|
|
268
|
+
/^(?:收藏|已收藏|感兴趣|已感兴趣)$/i
|
|
269
|
+
);
|
|
270
|
+
const greet = bestControl(
|
|
271
|
+
greetControls.filter((item) => item.available),
|
|
272
|
+
GREET_EXACT_LABEL_PATTERN
|
|
273
|
+
) || bestControl(
|
|
274
|
+
greetControls.filter((item) => item.continue_chat),
|
|
275
|
+
/^继续沟通$/i
|
|
276
|
+
);
|
|
277
|
+
return {
|
|
278
|
+
favorite: favorite
|
|
279
|
+
? {
|
|
280
|
+
found: true,
|
|
281
|
+
active: favorite.active,
|
|
282
|
+
disabled: favorite.disabled,
|
|
283
|
+
label: favorite.label,
|
|
284
|
+
selector: favorite.selector,
|
|
285
|
+
root: favorite.root,
|
|
286
|
+
node_id: favorite.node_id,
|
|
287
|
+
center: favorite.center
|
|
288
|
+
}
|
|
289
|
+
: { found: false },
|
|
290
|
+
greet: greet
|
|
291
|
+
? {
|
|
292
|
+
found: true,
|
|
293
|
+
available: greet.available,
|
|
294
|
+
continue_chat: greet.continue_chat,
|
|
295
|
+
disabled: greet.disabled,
|
|
296
|
+
label: greet.label,
|
|
297
|
+
greet_quota: greet.greet_quota || null,
|
|
298
|
+
selector: greet.selector,
|
|
299
|
+
root: greet.root,
|
|
300
|
+
node_id: greet.node_id,
|
|
301
|
+
center: greet.center
|
|
302
|
+
}
|
|
303
|
+
: { found: false },
|
|
304
|
+
counts: {
|
|
305
|
+
total: controls.length,
|
|
306
|
+
visible_matching: visibleControls.length,
|
|
307
|
+
favorite: favoriteControls.length,
|
|
308
|
+
greet: greetControls.length
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function discoverRecommendActionControls(client, {
|
|
314
|
+
roots = null,
|
|
315
|
+
selectors = {},
|
|
316
|
+
detailTextFallback = false
|
|
317
|
+
} = {}) {
|
|
318
|
+
const rootState = roots ? { roots } : await getRecommendRoots(client);
|
|
319
|
+
const controls = await collectRecommendActionControls(client, rootState.roots, {
|
|
320
|
+
...selectors,
|
|
321
|
+
detailTextFallback
|
|
322
|
+
});
|
|
323
|
+
return {
|
|
324
|
+
controls,
|
|
325
|
+
summary: summarizeRecommendActionControls(controls)
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function waitForRecommendActionControls(client, {
|
|
330
|
+
timeoutMs = 6000,
|
|
331
|
+
intervalMs = 250,
|
|
332
|
+
requireAny = true,
|
|
333
|
+
...discoveryOptions
|
|
334
|
+
} = {}) {
|
|
335
|
+
const started = Date.now();
|
|
336
|
+
let lastDiscovery = null;
|
|
337
|
+
while (Date.now() - started <= timeoutMs) {
|
|
338
|
+
lastDiscovery = await discoverRecommendActionControls(client, discoveryOptions);
|
|
339
|
+
const hasControl = Boolean(
|
|
340
|
+
lastDiscovery.summary.favorite.found
|
|
341
|
+
|| lastDiscovery.summary.greet.found
|
|
342
|
+
);
|
|
343
|
+
if (!requireAny || hasControl) {
|
|
344
|
+
return {
|
|
345
|
+
...lastDiscovery,
|
|
346
|
+
elapsed_ms: Date.now() - started,
|
|
347
|
+
timed_out: false
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
await sleep(intervalMs);
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
...(lastDiscovery || { controls: [], summary: summarizeRecommendActionControls([]) }),
|
|
354
|
+
elapsed_ms: Date.now() - started,
|
|
355
|
+
timed_out: true
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function getRecommendDetailActionRoots(client, detailState) {
|
|
360
|
+
const roots = [];
|
|
361
|
+
if (detailState?.popup?.node_id) {
|
|
362
|
+
roots.push({
|
|
363
|
+
name: `${detailState.popup.root || "unknown"}:detail-popup`,
|
|
364
|
+
nodeId: detailState.popup.node_id
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (detailState?.resumeIframe?.node_id) {
|
|
368
|
+
try {
|
|
369
|
+
roots.push({
|
|
370
|
+
name: `${detailState.resumeIframe.root || "unknown"}:resume-iframe-document`,
|
|
371
|
+
nodeId: await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id)
|
|
372
|
+
});
|
|
373
|
+
} catch {
|
|
374
|
+
roots.push({
|
|
375
|
+
name: `${detailState.resumeIframe.root || "unknown"}:resume-iframe-node`,
|
|
376
|
+
nodeId: detailState.resumeIframe.node_id
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return roots;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function waitForRecommendDetailActionControls(client, {
|
|
384
|
+
timeoutMs = 8000,
|
|
385
|
+
intervalMs = 350,
|
|
386
|
+
selectors = {},
|
|
387
|
+
requireAny = true
|
|
388
|
+
} = {}) {
|
|
389
|
+
const started = Date.now();
|
|
390
|
+
let lastDiscovery = null;
|
|
391
|
+
let lastError = null;
|
|
392
|
+
let lastRootCount = 0;
|
|
393
|
+
while (Date.now() - started <= timeoutMs) {
|
|
394
|
+
const detailState = await waitForRecommendDetail(client, {
|
|
395
|
+
timeoutMs: Math.min(intervalMs, 500),
|
|
396
|
+
intervalMs: 100
|
|
397
|
+
});
|
|
398
|
+
const roots = await getRecommendDetailActionRoots(client, detailState);
|
|
399
|
+
lastRootCount = roots.length;
|
|
400
|
+
if (roots.length) {
|
|
401
|
+
try {
|
|
402
|
+
lastDiscovery = await discoverRecommendActionControls(client, {
|
|
403
|
+
roots,
|
|
404
|
+
selectors,
|
|
405
|
+
detailTextFallback: true
|
|
406
|
+
});
|
|
407
|
+
const hasControl = Boolean(
|
|
408
|
+
lastDiscovery.summary.favorite.found
|
|
409
|
+
|| lastDiscovery.summary.greet.found
|
|
410
|
+
);
|
|
411
|
+
if (!requireAny || hasControl) {
|
|
412
|
+
return {
|
|
413
|
+
...lastDiscovery,
|
|
414
|
+
elapsed_ms: Date.now() - started,
|
|
415
|
+
timed_out: false,
|
|
416
|
+
detail_root_count: roots.length
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
} catch (error) {
|
|
420
|
+
lastError = error?.message || String(error);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
await sleep(intervalMs);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
...(lastDiscovery || { controls: [], summary: summarizeRecommendActionControls([]) }),
|
|
427
|
+
elapsed_ms: Date.now() - started,
|
|
428
|
+
timed_out: true,
|
|
429
|
+
detail_root_count: lastRootCount,
|
|
430
|
+
last_error: lastError
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function clickRecommendActionControl(client, control, {
|
|
435
|
+
allowDisabled = false
|
|
436
|
+
} = {}) {
|
|
437
|
+
if (!control?.center) {
|
|
438
|
+
throw new Error("Action control has no clickable center");
|
|
439
|
+
}
|
|
440
|
+
const greetQuota = control.kind === "greet"
|
|
441
|
+
? assertGreetQuotaAvailable(control.greet_quota || control.label || "")
|
|
442
|
+
: null;
|
|
443
|
+
if (control.disabled && !allowDisabled) {
|
|
444
|
+
throw new Error(`Action control is disabled: ${control.kind}`);
|
|
445
|
+
}
|
|
446
|
+
await clickPoint(client, control.center.x, control.center.y);
|
|
447
|
+
return {
|
|
448
|
+
clicked: true,
|
|
449
|
+
kind: control.kind,
|
|
450
|
+
label: control.label,
|
|
451
|
+
greet_quota: greetQuota?.found ? greetQuota : null,
|
|
452
|
+
selector: control.selector,
|
|
453
|
+
root: control.root,
|
|
454
|
+
node_id: control.node_id,
|
|
455
|
+
center: control.center
|
|
456
|
+
};
|
|
457
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clickNodeCenter,
|
|
3
|
+
getAttributesMap,
|
|
4
|
+
getNodeBox,
|
|
5
|
+
getOuterHTML,
|
|
6
|
+
querySelectorAll,
|
|
7
|
+
sleep
|
|
8
|
+
} from "../../core/browser/index.js";
|
|
9
|
+
import {
|
|
10
|
+
htmlToText,
|
|
11
|
+
normalizeCandidateFromHtml,
|
|
12
|
+
normalizeText
|
|
13
|
+
} from "../../core/screening/index.js";
|
|
14
|
+
import {
|
|
15
|
+
RECOMMEND_CARD_SELECTOR,
|
|
16
|
+
RECOMMEND_END_REFRESH_SELECTOR
|
|
17
|
+
} from "./constants.js";
|
|
18
|
+
|
|
19
|
+
function uniqueNodeIds(nodeIds = []) {
|
|
20
|
+
return Array.from(new Set(nodeIds.filter(Boolean)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeRefreshButtonLabel(outerHTML = "") {
|
|
24
|
+
return normalizeText(htmlToText(outerHTML)).replace(/\s+/g, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isRefreshButtonLabel(label = "") {
|
|
28
|
+
const normalized = String(label || "").trim();
|
|
29
|
+
if (!normalized || normalized.length > 80) return false;
|
|
30
|
+
return /刷新|refresh/i.test(normalized);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function refreshButtonRank(candidate) {
|
|
34
|
+
const label = String(candidate.label || "").toLowerCase();
|
|
35
|
+
if (label === "刷新" || label === "refresh") return 0;
|
|
36
|
+
if (/^刷新$|^refresh$/i.test(label)) return 0;
|
|
37
|
+
if (/刷新/.test(label) || /refresh/i.test(label)) return 1;
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function searchTextNodeIds(client, query, {
|
|
42
|
+
maxResults = 200
|
|
43
|
+
} = {}) {
|
|
44
|
+
if (typeof client?.DOM?.performSearch !== "function") return [];
|
|
45
|
+
const search = await client.DOM.performSearch({
|
|
46
|
+
query,
|
|
47
|
+
includeUserAgentShadowDOM: false
|
|
48
|
+
});
|
|
49
|
+
const searchId = search.searchId;
|
|
50
|
+
const resultCount = Math.min(search.resultCount || 0, maxResults);
|
|
51
|
+
if (!searchId || resultCount <= 0) return [];
|
|
52
|
+
try {
|
|
53
|
+
const results = await client.DOM.getSearchResults({
|
|
54
|
+
searchId,
|
|
55
|
+
fromIndex: 0,
|
|
56
|
+
toIndex: resultCount
|
|
57
|
+
});
|
|
58
|
+
return results.nodeIds || [];
|
|
59
|
+
} finally {
|
|
60
|
+
await client.DOM.discardSearchResults({ searchId });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function findRecommendCardNodeIds(client, frameNodeId, {
|
|
65
|
+
selector = RECOMMEND_CARD_SELECTOR
|
|
66
|
+
} = {}) {
|
|
67
|
+
return querySelectorAll(client, frameNodeId, selector);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function waitForRecommendCardNodeIds(client, frameNodeId, {
|
|
71
|
+
selector = RECOMMEND_CARD_SELECTOR,
|
|
72
|
+
timeoutMs = 10000,
|
|
73
|
+
intervalMs = 300
|
|
74
|
+
} = {}) {
|
|
75
|
+
const started = Date.now();
|
|
76
|
+
let nodeIds = [];
|
|
77
|
+
while (Date.now() - started <= timeoutMs) {
|
|
78
|
+
nodeIds = await findRecommendCardNodeIds(client, frameNodeId, { selector });
|
|
79
|
+
if (nodeIds.length) return nodeIds;
|
|
80
|
+
await sleep(intervalMs);
|
|
81
|
+
}
|
|
82
|
+
return nodeIds;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function readRecommendCardCandidate(client, cardNodeId, {
|
|
86
|
+
targetUrl = "",
|
|
87
|
+
source = "recommend-domain-card",
|
|
88
|
+
metadata = {}
|
|
89
|
+
} = {}) {
|
|
90
|
+
const [attributes, outerHTML] = await Promise.all([
|
|
91
|
+
getAttributesMap(client, cardNodeId),
|
|
92
|
+
getOuterHTML(client, cardNodeId)
|
|
93
|
+
]);
|
|
94
|
+
return normalizeCandidateFromHtml({
|
|
95
|
+
domain: "recommend",
|
|
96
|
+
source,
|
|
97
|
+
html: outerHTML,
|
|
98
|
+
attributes,
|
|
99
|
+
metadata: {
|
|
100
|
+
target_url: targetUrl,
|
|
101
|
+
card_node_id: cardNodeId,
|
|
102
|
+
...metadata
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function readFirstRecommendCardCandidate(client, frameNodeId, options = {}) {
|
|
108
|
+
const cardNodeIds = await findRecommendCardNodeIds(client, frameNodeId, options);
|
|
109
|
+
if (!cardNodeIds.length) {
|
|
110
|
+
throw new Error("No recommend candidate cards found");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const candidate = await readRecommendCardCandidate(client, cardNodeIds[0], options);
|
|
114
|
+
return {
|
|
115
|
+
card_count: cardNodeIds.length,
|
|
116
|
+
first_card_node_id: cardNodeIds[0],
|
|
117
|
+
card_node_ids: cardNodeIds,
|
|
118
|
+
candidate
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function findRecommendEndRefreshButtons(client, frameNodeId, {
|
|
123
|
+
selector = RECOMMEND_END_REFRESH_SELECTOR,
|
|
124
|
+
maxCandidates = 1200
|
|
125
|
+
} = {}) {
|
|
126
|
+
const textNodeIds = [
|
|
127
|
+
...await searchTextNodeIds(client, "刷新", { maxResults: 200 }),
|
|
128
|
+
...await searchTextNodeIds(client, "refresh", { maxResults: 50 })
|
|
129
|
+
];
|
|
130
|
+
const selectorNodeIds = textNodeIds.length
|
|
131
|
+
? await querySelectorAll(client, frameNodeId, selector)
|
|
132
|
+
: [];
|
|
133
|
+
const nodeIds = uniqueNodeIds([...textNodeIds, ...selectorNodeIds]).slice(0, maxCandidates);
|
|
134
|
+
const candidates = [];
|
|
135
|
+
for (let index = 0; index < nodeIds.length; index += 1) {
|
|
136
|
+
const nodeId = nodeIds[index];
|
|
137
|
+
let outerHTML = "";
|
|
138
|
+
try {
|
|
139
|
+
outerHTML = await getOuterHTML(client, nodeId);
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const label = normalizeRefreshButtonLabel(outerHTML);
|
|
144
|
+
if (!isRefreshButtonLabel(label)) continue;
|
|
145
|
+
|
|
146
|
+
let box = null;
|
|
147
|
+
try {
|
|
148
|
+
box = await getNodeBox(client, nodeId);
|
|
149
|
+
} catch {
|
|
150
|
+
// Some text matches can be hidden or stale. Keep the label out of the click set.
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
candidates.push({
|
|
154
|
+
node_id: nodeId,
|
|
155
|
+
index,
|
|
156
|
+
label,
|
|
157
|
+
box,
|
|
158
|
+
rank: refreshButtonRank({ label })
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return candidates.sort((left, right) => {
|
|
163
|
+
const rankDiff = left.rank - right.rank;
|
|
164
|
+
if (rankDiff !== 0) return rankDiff;
|
|
165
|
+
return (right.box?.rect?.y || 0) - (left.box?.rect?.y || 0);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function clickRecommendEndRefreshButton(client, frameNodeId, {
|
|
170
|
+
settleMs = 5000
|
|
171
|
+
} = {}) {
|
|
172
|
+
const beforeCardCount = (await findRecommendCardNodeIds(client, frameNodeId)).length;
|
|
173
|
+
const candidates = await findRecommendEndRefreshButtons(client, frameNodeId);
|
|
174
|
+
if (!candidates.length) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
method: "end_refresh_button",
|
|
178
|
+
reason: "refresh_button_not_found",
|
|
179
|
+
before_card_count: beforeCardCount,
|
|
180
|
+
candidates: []
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const attempts = [];
|
|
185
|
+
for (const candidate of candidates) {
|
|
186
|
+
try {
|
|
187
|
+
const box = await clickNodeCenter(client, candidate.node_id, { scrollIntoView: true });
|
|
188
|
+
if (settleMs > 0) await sleep(settleMs);
|
|
189
|
+
const afterCardCount = (await findRecommendCardNodeIds(client, frameNodeId)).length;
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
method: "end_refresh_button",
|
|
193
|
+
clicked: true,
|
|
194
|
+
node_id: candidate.node_id,
|
|
195
|
+
label: candidate.label,
|
|
196
|
+
box,
|
|
197
|
+
before_card_count: beforeCardCount,
|
|
198
|
+
after_card_count: afterCardCount,
|
|
199
|
+
settle_ms: settleMs,
|
|
200
|
+
candidates: candidates.map((item) => ({
|
|
201
|
+
node_id: item.node_id,
|
|
202
|
+
label: item.label,
|
|
203
|
+
y: item.box?.rect?.y || null
|
|
204
|
+
})).slice(0, 10),
|
|
205
|
+
attempts
|
|
206
|
+
};
|
|
207
|
+
} catch (error) {
|
|
208
|
+
attempts.push({
|
|
209
|
+
node_id: candidate.node_id,
|
|
210
|
+
label: candidate.label,
|
|
211
|
+
error: error?.message || String(error)
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
method: "end_refresh_button",
|
|
219
|
+
reason: "refresh_button_click_failed",
|
|
220
|
+
before_card_count: beforeCardCount,
|
|
221
|
+
attempts,
|
|
222
|
+
candidates: candidates.map((item) => ({
|
|
223
|
+
node_id: item.node_id,
|
|
224
|
+
label: item.label,
|
|
225
|
+
y: item.box?.rect?.y || null
|
|
226
|
+
})).slice(0, 10)
|
|
227
|
+
};
|
|
228
|
+
}
|