@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.
Files changed (88) hide show
  1. package/README.md +86 -33
  2. package/package.json +62 -9
  3. package/skills/boss-chat/SKILL.md +5 -4
  4. package/skills/boss-recommend-pipeline/SKILL.md +21 -31
  5. package/skills/boss-recruit-pipeline/README.md +17 -0
  6. package/skills/boss-recruit-pipeline/SKILL.md +55 -0
  7. package/src/chat-mcp.js +1333 -0
  8. package/src/chat-runtime-config.js +559 -0
  9. package/src/cli.js +1254 -225
  10. package/src/core/browser/index.js +378 -0
  11. package/src/core/capture/index.js +298 -0
  12. package/src/core/cv-acquisition/index.js +219 -0
  13. package/src/core/greet-quota/index.js +54 -0
  14. package/src/core/infinite-list/index.js +459 -0
  15. package/src/core/reporting/legacy-csv.js +332 -0
  16. package/src/core/run/index.js +286 -0
  17. package/src/core/screening/index.js +1166 -0
  18. package/src/core/self-heal/index.js +848 -0
  19. package/src/domains/chat/cards.js +129 -0
  20. package/src/domains/chat/constants.js +183 -0
  21. package/src/domains/chat/detail.js +1369 -0
  22. package/src/domains/chat/index.js +7 -0
  23. package/src/domains/chat/jobs.js +334 -0
  24. package/src/domains/chat/page-guard.js +88 -0
  25. package/src/domains/chat/roots.js +56 -0
  26. package/src/domains/chat/run-service.js +1101 -0
  27. package/src/domains/recommend/actions.js +457 -0
  28. package/src/domains/recommend/cards.js +228 -0
  29. package/src/domains/recommend/constants.js +141 -0
  30. package/src/domains/recommend/detail.js +341 -0
  31. package/src/domains/recommend/filters.js +581 -0
  32. package/src/domains/recommend/index.js +10 -0
  33. package/src/domains/recommend/jobs.js +232 -0
  34. package/src/domains/recommend/refresh.js +204 -0
  35. package/src/domains/recommend/roots.js +78 -0
  36. package/src/domains/recommend/run-service.js +903 -0
  37. package/src/domains/recommend/scopes.js +245 -0
  38. package/src/domains/recruit/actions.js +277 -0
  39. package/src/domains/recruit/cards.js +66 -0
  40. package/src/domains/recruit/constants.js +130 -0
  41. package/src/domains/recruit/detail.js +414 -0
  42. package/src/domains/recruit/index.js +9 -0
  43. package/src/domains/recruit/instruction-parser.js +451 -0
  44. package/src/domains/recruit/refresh.js +40 -0
  45. package/src/domains/recruit/roots.js +67 -0
  46. package/src/domains/recruit/run-service.js +580 -0
  47. package/src/domains/recruit/search.js +1149 -0
  48. package/src/index.js +578 -419
  49. package/src/recommend-mcp.js +1257 -0
  50. package/src/recruit-mcp.js +1035 -0
  51. package/src/adapters.js +0 -3079
  52. package/src/boss-chat.js +0 -1037
  53. package/src/pipeline.js +0 -2249
  54. package/src/recommend-healing-config.js +0 -131
  55. package/src/recommend-healing-rules.json +0 -261
  56. package/src/self-heal.js +0 -2237
  57. package/src/test-adapters-runtime.js +0 -628
  58. package/src/test-boss-chat.js +0 -3196
  59. package/src/test-index-async.js +0 -498
  60. package/src/test-parser.js +0 -742
  61. package/src/test-pipeline.js +0 -2703
  62. package/src/test-run-state.js +0 -152
  63. package/src/test-self-heal.js +0 -224
  64. package/vendor/boss-chat-cli/README.md +0 -134
  65. package/vendor/boss-chat-cli/package.json +0 -53
  66. package/vendor/boss-chat-cli/src/app.js +0 -1501
  67. package/vendor/boss-chat-cli/src/browser/chat-page.js +0 -3562
  68. package/vendor/boss-chat-cli/src/cli.js +0 -1713
  69. package/vendor/boss-chat-cli/src/mcp/server.js +0 -149
  70. package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +0 -193
  71. package/vendor/boss-chat-cli/src/runtime/async-run-state.js +0 -260
  72. package/vendor/boss-chat-cli/src/runtime/interaction.js +0 -102
  73. package/vendor/boss-chat-cli/src/runtime/run-control.js +0 -102
  74. package/vendor/boss-chat-cli/src/services/chrome-client.js +0 -107
  75. package/vendor/boss-chat-cli/src/services/llm.js +0 -1292
  76. package/vendor/boss-chat-cli/src/services/llm.test.js +0 -326
  77. package/vendor/boss-chat-cli/src/services/profile-store.js +0 -173
  78. package/vendor/boss-chat-cli/src/services/report-store.js +0 -317
  79. package/vendor/boss-chat-cli/src/services/resume-capture.js +0 -469
  80. package/vendor/boss-chat-cli/src/services/resume-network.js +0 -727
  81. package/vendor/boss-chat-cli/src/services/state-store.js +0 -90
  82. package/vendor/boss-chat-cli/src/utils/customer-key.js +0 -82
  83. package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +0 -7072
  84. package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +0 -817
  85. package/vendor/boss-recommend-screen-cli/scripts/stitch_resume_chunks.py +0 -141
  86. package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +0 -2423
  87. package/vendor/boss-recommend-search-cli/src/cli.js +0 -1698
  88. package/vendor/boss-recommend-search-cli/src/test-job-selection.js +0 -211
package/src/self-heal.js DELETED
@@ -1,2237 +0,0 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import process from "node:process";
5
- import { randomUUID } from "node:crypto";
6
- import CDP from "chrome-remote-interface";
7
- import { getScreenConfigResolution } from "./adapters.js";
8
- import {
9
- buildFirstSelectorLookupExpression,
10
- buildSelectorCollectionExpression,
11
- findFirstMatchingPattern,
12
- getRecommendHealingRulesPath,
13
- getRecommendNetworkRule,
14
- getRecommendSelectorRule,
15
- loadRecommendHealingRules,
16
- saveRecommendHealingRules
17
- } from "./recommend-healing-config.js";
18
-
19
- const RECOMMEND_URL_FRAGMENT = "/web/chat/recommend";
20
- const TOOL_NAME = "run_recommend_self_heal";
21
- const PROFILE_SAFE = "safe";
22
- const PROFILE_FULL = "full";
23
- const MODE_SCAN = "scan";
24
- const MODE_APPLY = "apply";
25
- const NON_PROMOTABLE_SELECTOR_RULE_IDS = new Set([
26
- "detail_close_fallback_candidates"
27
- ]);
28
-
29
- function normalizeText(value) {
30
- return String(value || "").replace(/\s+/g, " ").trim();
31
- }
32
-
33
- function getStateHome() {
34
- return process.env.BOSS_RECOMMEND_HOME
35
- ? path.resolve(process.env.BOSS_RECOMMEND_HOME)
36
- : path.join(os.homedir(), ".boss-recommend-mcp");
37
- }
38
-
39
- function getSelfHealSessionsDir() {
40
- return path.join(getStateHome(), "self-heal-sessions");
41
- }
42
-
43
- function ensureDir(targetPath) {
44
- fs.mkdirSync(targetPath, { recursive: true });
45
- }
46
-
47
- function readJsonFile(filePath) {
48
- try {
49
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
50
- } catch {
51
- return null;
52
- }
53
- }
54
-
55
- function writeJsonFile(filePath, payload) {
56
- ensureDir(path.dirname(filePath));
57
- fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
58
- }
59
-
60
- function getSessionPath(repairSessionId) {
61
- return path.join(getSelfHealSessionsDir(), `${repairSessionId}.json`);
62
- }
63
-
64
- function loadScreenConfigDebugPort(workspaceRoot) {
65
- const configResolution = getScreenConfigResolution(workspaceRoot);
66
- const configPath = configResolution.resolved_path;
67
- if (!configPath) return null;
68
- const parsed = readJsonFile(configPath);
69
- const port = Number.parseInt(String(parsed?.debugPort || ""), 10);
70
- return Number.isFinite(port) && port > 0 ? port : null;
71
- }
72
-
73
- function resolveDebugPort(workspaceRoot, args = {}) {
74
- const explicit = Number.parseInt(String(args.port || ""), 10);
75
- if (Number.isFinite(explicit) && explicit > 0) return explicit;
76
- const fromEnv = Number.parseInt(String(process.env.BOSS_RECOMMEND_CHROME_PORT || ""), 10);
77
- if (Number.isFinite(fromEnv) && fromEnv > 0) return fromEnv;
78
- return loadScreenConfigDebugPort(workspaceRoot) || 9222;
79
- }
80
-
81
- function normalizeMode(value) {
82
- const normalized = normalizeText(value).toLowerCase();
83
- return normalized === MODE_APPLY ? MODE_APPLY : MODE_SCAN;
84
- }
85
-
86
- function normalizeScope(value) {
87
- const normalized = normalizeText(value).toLowerCase();
88
- if (["full", "selectors_only", "search_screen"].includes(normalized)) return normalized;
89
- return "full";
90
- }
91
-
92
- function normalizeValidationProfile(value) {
93
- const normalized = normalizeText(value).toLowerCase();
94
- return normalized === PROFILE_SAFE ? PROFILE_SAFE : PROFILE_FULL;
95
- }
96
-
97
- function dedupeRepairs(repairs = []) {
98
- const result = [];
99
- const seen = new Set();
100
- for (const repair of repairs) {
101
- if (!repair || typeof repair !== "object") continue;
102
- const key = JSON.stringify({ type: repair.type, path: repair.path, value: repair.value });
103
- if (seen.has(key)) continue;
104
- seen.add(key);
105
- result.push(repair);
106
- }
107
- return result;
108
- }
109
-
110
- function getSelectorScanDefinitions(scope = "full") {
111
- const shared = [
112
- { rule_id: "recommend_iframe", path: ["top", "recommend_iframe"], root: "top", required: true, report_on_no_match: true },
113
- { rule_id: "tab_items", path: ["frame", "tab_items"], root: "frame", required: true, report_on_no_match: true },
114
- { rule_id: "filter_trigger", path: ["frame", "filter_trigger"], root: "frame", required: true, report_on_no_match: true },
115
- { rule_id: "filter_panel", path: ["frame", "filter_panel"], root: "frame", required: false, report_on_no_match: false },
116
- { rule_id: "filter_confirm_button", path: ["frame", "filter_confirm_button"], root: "frame", required: false, report_on_no_match: false },
117
- { rule_id: "filter_confirm_candidates", path: ["frame", "filter_confirm_candidates"], root: "frame", required: false, report_on_no_match: false },
118
- { rule_id: "filter_group_container", path: ["frame", "filter_group_container"], root: "frame", required: false, report_on_no_match: false },
119
- { rule_id: "filter_group_school", path: ["frame", "filter_group_school"], root: "frame", required: false, report_on_no_match: false },
120
- { rule_id: "filter_group_degree", path: ["frame", "filter_group_degree"], root: "frame", required: false, report_on_no_match: false },
121
- { rule_id: "filter_group_gender", path: ["frame", "filter_group_gender"], root: "frame", required: false, report_on_no_match: false },
122
- { rule_id: "filter_group_recent_not_view", path: ["frame", "filter_group_recent_not_view"], root: "frame", required: false, report_on_no_match: false },
123
- { rule_id: "filter_option", path: ["frame", "filter_option"], root: "frame", required: false, report_on_no_match: false },
124
- { rule_id: "filter_option_all", path: ["frame", "filter_option_all"], root: "frame", required: false, report_on_no_match: false },
125
- { rule_id: "filter_option_active", path: ["frame", "filter_option_active"], root: "frame", required: false, report_on_no_match: false },
126
- { rule_id: "filter_scroll_container", path: ["frame", "filter_scroll_container"], root: "frame", required: false, report_on_no_match: false },
127
- { rule_id: "job_dropdown_trigger", path: ["frame", "job_dropdown_trigger"], root: "frame", required: true, report_on_no_match: true },
128
- { rule_id: "job_search_input", path: ["frame", "job_search_input"], root: "frame", required: false, report_on_no_match: false },
129
- { rule_id: "job_item_label", path: ["frame", "job_item_label"], root: "frame", required: false, report_on_no_match: false },
130
- { rule_id: "job_selected_label", path: ["frame", "job_selected_label"], root: "frame", required: false, report_on_no_match: false },
131
- { rule_id: "recommend_cards", path: ["frame", "recommend_cards"], root: "frame", required: true, report_on_no_match: false },
132
- { rule_id: "recommend_card_inner", path: ["frame", "recommend_card_inner"], root: "frame", required: false, report_on_no_match: false },
133
- { rule_id: "featured_cards", path: ["frame", "featured_cards"], root: "frame", required: false, report_on_no_match: false },
134
- { rule_id: "featured_card_anchor", path: ["frame", "featured_card_anchor"], root: "frame", required: false, report_on_no_match: false },
135
- { rule_id: "latest_cards", path: ["frame", "latest_cards"], root: "frame", required: false, report_on_no_match: false },
136
- { rule_id: "latest_card_inner", path: ["frame", "latest_card_inner"], root: "frame", required: false, report_on_no_match: false },
137
- { rule_id: "refresh_finished_wrap", path: ["frame", "refresh_finished_wrap"], root: "frame", required: false, report_on_no_match: false },
138
- { rule_id: "refresh_button", path: ["frame", "refresh_button"], root: "frame", required: false, report_on_no_match: false }
139
- ];
140
- if (scope === "selectors_only") return shared;
141
- return shared.concat([
142
- { rule_id: "job_list_items", path: ["frame", "job_list_items"], root: "frame", required: false, report_on_no_match: false },
143
- { rule_id: "detail_popup", path: ["detail", "popup"], root: "detail", required: false, report_on_no_match: true },
144
- { rule_id: "detail_close_button", path: ["detail", "close_button"], root: "detail", required: false, report_on_no_match: true },
145
- { rule_id: "detail_close_fallback_candidates", path: ["detail", "close_fallback_candidates"], root: "detail", required: false, report_on_no_match: true },
146
- { rule_id: "detail_ack_button", path: ["detail", "ack_button"], root: "detail", required: false, report_on_no_match: false },
147
- { rule_id: "detail_resume_iframe", path: ["detail", "resume_iframe"], root: "detail", required: false, report_on_no_match: true },
148
- { rule_id: "detail_favorite_button", path: ["detail", "favorite_button"], root: "detail", required: false, report_on_no_match: true },
149
- { rule_id: "detail_greet_button_recommend", path: ["detail", "greet_button_recommend"], root: "detail", required: false, report_on_no_match: true },
150
- { rule_id: "detail_greet_button_featured", path: ["detail", "greet_button_featured"], root: "detail", required: false, report_on_no_match: true }
151
- ]);
152
- }
153
-
154
- function analyzeSelectorChecks(selectorChecks = []) {
155
- const drifts = [];
156
- for (const check of selectorChecks) {
157
- if (!check || check.skipped === true) continue;
158
- const matches = Array.isArray(check.matches) ? check.matches : [];
159
- const matched = matches.find((item) => Number(item.count || 0) > 0) || null;
160
- if (!matched) {
161
- if (check.required !== true && check.report_on_no_match !== true) {
162
- continue;
163
- }
164
- drifts.push({
165
- kind: "selector",
166
- rule_id: check.rule_id,
167
- path: check.path,
168
- reason: check.no_match_reason || "no_selector_matched",
169
- confidence: Number.isFinite(Number(check.no_match_confidence)) ? Number(check.no_match_confidence) : 0.35,
170
- matches,
171
- validation_context: check.validation_context || null,
172
- auto_repairable: false
173
- });
174
- continue;
175
- }
176
- if (matched.index > 0) {
177
- if (NON_PROMOTABLE_SELECTOR_RULE_IDS.has(String(check.rule_id || ""))) {
178
- drifts.push({
179
- kind: "selector",
180
- rule_id: check.rule_id,
181
- path: check.path,
182
- reason: "fallback_selector_matched_non_promotable",
183
- confidence: 0.7,
184
- matches,
185
- selected_value: matched.selector,
186
- auto_repairable: false
187
- });
188
- continue;
189
- }
190
- drifts.push({
191
- kind: "selector",
192
- rule_id: check.rule_id,
193
- path: check.path,
194
- reason: "fallback_selector_matched",
195
- confidence: 0.98,
196
- matches,
197
- selected_value: matched.selector,
198
- auto_repairable: true,
199
- proposed_repair: {
200
- type: "promote_selector",
201
- path: ["selectors", ...check.path],
202
- value: matched.selector,
203
- confidence: 0.98
204
- }
205
- });
206
- }
207
- }
208
- return drifts;
209
- }
210
-
211
- function analyzeNetworkChecks(networkChecks = []) {
212
- const drifts = [];
213
- for (const check of networkChecks) {
214
- if (!check || check.skipped === true) continue;
215
- if (check.ok === true) continue;
216
- const confidence = Number.isFinite(Number(check.confidence)) ? Number(check.confidence) : 0.4;
217
- const drift = {
218
- kind: "network",
219
- rule_id: check.rule_id,
220
- path: check.path,
221
- reason: check.reason || "network_rule_drift",
222
- confidence,
223
- observed_value: check.observed_value || null,
224
- observed_items: check.observed_items || [],
225
- auto_repairable: false
226
- };
227
- if (check.matched_pattern && Array.isArray(check.patterns) && check.patterns.indexOf(check.matched_pattern) > 0) {
228
- drift.auto_repairable = confidence >= 0.9;
229
- drift.proposed_repair = {
230
- type: "promote_regex",
231
- path: ["network", ...check.path],
232
- value: check.matched_pattern,
233
- confidence
234
- };
235
- }
236
- drifts.push(drift);
237
- }
238
- return drifts;
239
- }
240
-
241
- function createRepairSession({ args, selectorChecks, networkChecks, drifts, proposedRepairs }) {
242
- const repairSessionId = randomUUID();
243
- const payload = {
244
- repair_session_id: repairSessionId,
245
- created_at: new Date().toISOString(),
246
- tool: TOOL_NAME,
247
- args,
248
- rules_path: getRecommendHealingRulesPath(),
249
- selector_checks: selectorChecks,
250
- network_checks: networkChecks,
251
- drifts,
252
- proposed_repairs: proposedRepairs,
253
- applied: false
254
- };
255
- writeJsonFile(getSessionPath(repairSessionId), payload);
256
- return payload;
257
- }
258
-
259
- function promoteArrayValue(values = [], targetValue) {
260
- const normalizedTarget = String(targetValue || "");
261
- const next = values.filter((item) => String(item) !== normalizedTarget);
262
- return [normalizedTarget, ...next];
263
- }
264
-
265
- function applyRepairToRules(rules, repair) {
266
- if (!repair || typeof repair !== "object" || !Array.isArray(repair.path) || repair.path.length < 3) {
267
- return false;
268
- }
269
- const [rootKey] = repair.path;
270
- if (!["selectors", "network"].includes(rootKey)) {
271
- return false;
272
- }
273
- let parent = rules;
274
- for (let index = 0; index < repair.path.length - 1; index += 1) {
275
- const part = repair.path[index];
276
- if (!parent[part] || typeof parent[part] !== "object" || Array.isArray(parent[part])) {
277
- return false;
278
- }
279
- parent = parent[part];
280
- }
281
- const leafKey = repair.path[repair.path.length - 1];
282
- const current = parent[leafKey];
283
- if (!Array.isArray(current) || current.length === 0) return false;
284
- if (!current.includes(repair.value)) return false;
285
- parent[leafKey] = promoteArrayValue(current, repair.value);
286
- return true;
287
- }
288
-
289
- function applyRepairSession(repairSessionId, confirmApply) {
290
- const sessionPath = getSessionPath(repairSessionId);
291
- const session = readJsonFile(sessionPath);
292
- if (!session) {
293
- return {
294
- status: "FAILED",
295
- error: {
296
- code: "SELF_HEAL_SESSION_NOT_FOUND",
297
- message: `未找到 repair_session_id=${repairSessionId}。`,
298
- retryable: false
299
- }
300
- };
301
- }
302
- if (confirmApply !== true) {
303
- return {
304
- status: "FAILED",
305
- error: {
306
- code: "SELF_HEAL_CONFIRMATION_REQUIRED",
307
- message: "apply 模式必须显式传入 confirm_apply=true。",
308
- retryable: false
309
- }
310
- };
311
- }
312
- const rules = loadRecommendHealingRules({ fresh: true });
313
- const appliedRepairs = [];
314
- for (const repair of dedupeRepairs(session.proposed_repairs || [])) {
315
- const confidence = Number.isFinite(Number(repair?.confidence)) ? Number(repair.confidence) : 0;
316
- if (confidence < 0.9) continue;
317
- if (applyRepairToRules(rules, repair)) {
318
- appliedRepairs.push(repair);
319
- }
320
- }
321
- if (appliedRepairs.length === 0) {
322
- return {
323
- status: "FAILED",
324
- error: {
325
- code: "SELF_HEAL_NOTHING_TO_APPLY",
326
- message: "当前 repair session 没有可自动应用的高置信度修复项。",
327
- retryable: false
328
- }
329
- };
330
- }
331
- const rulesPath = saveRecommendHealingRules(rules);
332
- const updatedSession = {
333
- ...session,
334
- applied: true,
335
- applied_at: new Date().toISOString(),
336
- applied_repairs: appliedRepairs,
337
- rules_path: rulesPath
338
- };
339
- writeJsonFile(sessionPath, updatedSession);
340
- return {
341
- status: "REPAIRED",
342
- repair_session_id: repairSessionId,
343
- rules_path: rulesPath,
344
- applied_repairs: appliedRepairs,
345
- message: "已将高置信度修复写回 recommend-healing-rules.json。"
346
- };
347
- }
348
-
349
- async function evaluate(client, expression) {
350
- const result = await client.Runtime.evaluate({
351
- expression,
352
- returnByValue: true,
353
- awaitPromise: true
354
- });
355
- if (result.exceptionDetails) {
356
- throw new Error(result.exceptionDetails.exception?.description || "Runtime.evaluate failed");
357
- }
358
- return result.result?.value;
359
- }
360
-
361
- async function pickRecommendTarget(port) {
362
- const targets = await CDP.List({ port });
363
- return targets.find((item) => typeof item?.url === "string" && item.url.includes(RECOMMEND_URL_FRAGMENT))
364
- || targets.find((item) => item?.type === "page")
365
- || null;
366
- }
367
-
368
- function buildRecommendFrameExpression() {
369
- return buildFirstSelectorLookupExpression(getRecommendSelectorRule(["top", "recommend_iframe"]));
370
- }
371
-
372
- function buildRootListExpression(root = "top") {
373
- if (root === "top") return "[document]";
374
- if (root === "frame") return `[${buildRecommendFrameExpression()}?.contentDocument].filter(Boolean)`;
375
- if (root === "detail") {
376
- return `(() => {
377
- const roots = [];
378
- const frame = ${buildRecommendFrameExpression()};
379
- if (frame?.contentDocument) roots.push(frame.contentDocument);
380
- if (document) roots.push(document);
381
- return roots;
382
- })()`;
383
- }
384
- return "[]";
385
- }
386
-
387
- function buildSelectorCheckExpression(selectors = [], root = "top") {
388
- return `(() => {
389
- const roots = ${buildRootListExpression(root)};
390
- if (!Array.isArray(roots) || roots.length === 0) {
391
- return { ok: false, error: "ROOT_NOT_AVAILABLE" };
392
- }
393
- const selectors = ${JSON.stringify(selectors)};
394
- const matches = selectors.map((selector, index) => {
395
- try {
396
- const count = roots.reduce((sum, currentRoot) => {
397
- try {
398
- return sum + currentRoot.querySelectorAll(selector).length;
399
- } catch {
400
- return sum;
401
- }
402
- }, 0);
403
- return { selector, index, count };
404
- } catch {
405
- return { selector, index, count: 0 };
406
- }
407
- });
408
- return { ok: true, matches };
409
- })()`;
410
- }
411
-
412
- function buildClickFirstExpression(selectors = [], root = "frame") {
413
- return `(() => {
414
- const roots = ${buildRootListExpression(root)};
415
- if (!Array.isArray(roots) || roots.length === 0) return { ok: false, error: "ROOT_NOT_AVAILABLE" };
416
- const nodes = roots.flatMap((root) => ${buildSelectorCollectionExpression(selectors, "root")});
417
- const target = nodes.find((node) => node && typeof node.click === "function");
418
- if (!target) return { ok: false, error: "TARGET_NOT_FOUND" };
419
- try {
420
- target.click();
421
- return { ok: true };
422
- } catch (error) {
423
- return { ok: false, error: error?.message || "CLICK_FAILED" };
424
- }
425
- })()`;
426
- }
427
-
428
- function buildDetailStateExpression() {
429
- const popupSelectors = getRecommendSelectorRule(["detail", "popup"]);
430
- const resumeIframeSelectors = getRecommendSelectorRule(["detail", "resume_iframe"]);
431
- return `(() => {
432
- const roots = ${buildRootListExpression("detail")};
433
- if (!Array.isArray(roots) || roots.length === 0) return { ok: false, open: false, error: "NO_RECOMMEND_IFRAME" };
434
- const popupNodes = roots.flatMap((doc) => ${buildSelectorCollectionExpression(popupSelectors, "doc")});
435
- const resumeNodes = roots.flatMap((doc) => ${buildSelectorCollectionExpression(resumeIframeSelectors, "doc")});
436
- return {
437
- ok: true,
438
- open: popupNodes.length > 0 || resumeNodes.length > 0,
439
- popup_count: popupNodes.length,
440
- resume_iframe_count: resumeNodes.length
441
- };
442
- })()`;
443
- }
444
-
445
- function buildDetailCloseExpression() {
446
- const closeSelectors = getRecommendSelectorRule(["detail", "close_button"]);
447
- return `(() => {
448
- const roots = ${buildRootListExpression("detail")};
449
- const closeTargets = Array.isArray(roots)
450
- ? roots.flatMap((doc) => ${buildSelectorCollectionExpression(closeSelectors, "doc")})
451
- : [];
452
- const target = closeTargets.find((node) => node && typeof node.click === "function");
453
- if (!target) return { ok: false, error: "DETAIL_CLOSE_TARGET_NOT_FOUND" };
454
- try {
455
- target.click();
456
- return { ok: true };
457
- } catch (error) {
458
- return { ok: false, error: error?.message || "DETAIL_CLOSE_FAILED" };
459
- }
460
- })()`;
461
- }
462
-
463
- function buildActionButtonExpression(selectors = []) {
464
- return `(() => {
465
- const roots = ${buildRootListExpression("detail")};
466
- if (!Array.isArray(roots) || roots.length === 0) return { ok: false, error: "NO_RECOMMEND_IFRAME" };
467
- const nodes = roots.flatMap((doc) => ${buildSelectorCollectionExpression(selectors, "doc")});
468
- const target = nodes.find((node) => node && typeof node.click === "function");
469
- if (!target) return { ok: false, error: "ACTION_BUTTON_NOT_FOUND" };
470
- const className = String(target.className || "");
471
- const text = String(target.textContent || "").replace(/\\s+/g, " ").trim();
472
- return { ok: true, class_name: className, text };
473
- })()`;
474
- }
475
-
476
- function buildActionClickExpression(selectors = []) {
477
- return `(() => {
478
- const roots = ${buildRootListExpression("detail")};
479
- if (!Array.isArray(roots) || roots.length === 0) return { ok: false, error: "NO_RECOMMEND_IFRAME" };
480
- const nodes = roots.flatMap((doc) => ${buildSelectorCollectionExpression(selectors, "doc")});
481
- const target = nodes.find((node) => node && typeof node.click === "function");
482
- if (!target) return { ok: false, error: "ACTION_BUTTON_NOT_FOUND" };
483
- try {
484
- target.click();
485
- return { ok: true };
486
- } catch (error) {
487
- return { ok: false, error: error?.message || "ACTION_CLICK_FAILED" };
488
- }
489
- })()`;
490
- }
491
-
492
- function buildFilterPanelStateExpression() {
493
- const panelSelectors = getRecommendSelectorRule(["frame", "filter_panel"]);
494
- const confirmSelectors = getRecommendSelectorRule(["frame", "filter_confirm_button"]);
495
- const groupSelectors = getRecommendSelectorRule(["frame", "filter_group_container"]);
496
- const optionSelectors = getRecommendSelectorRule(["frame", "filter_option"]);
497
- return `(() => {
498
- const frame = ${buildRecommendFrameExpression()};
499
- if (!frame || !frame.contentDocument) {
500
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
501
- }
502
- const doc = frame.contentDocument;
503
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
504
- const isVisible = (el) => {
505
- if (!el) return false;
506
- const style = getComputedStyle(el);
507
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) return false;
508
- const rect = el.getBoundingClientRect();
509
- return rect.width > 2 && rect.height > 2;
510
- };
511
- const collect = (selectors, root = doc) => selectors.flatMap((selector) => {
512
- try { return Array.from(root.querySelectorAll(selector)); } catch { return []; }
513
- });
514
- const panels = collect(${JSON.stringify(panelSelectors)}).filter(isVisible);
515
- const panelRoot = panels[0] || null;
516
- const confirmButtons = panelRoot
517
- ? collect(${JSON.stringify(confirmSelectors)}, panelRoot).filter((item) => isVisible(item))
518
- : [];
519
- const groups = panelRoot
520
- ? collect(${JSON.stringify(groupSelectors)}, panelRoot).filter((item) => isVisible(item))
521
- : [];
522
- const options = panelRoot
523
- ? collect(${JSON.stringify(optionSelectors)}, panelRoot).filter((item) => isVisible(item))
524
- : [];
525
- const confirmButton = confirmButtons.find((item) => /确定|确认|完成|应用/.test(normalize(item.textContent || ''))) || confirmButtons[0] || null;
526
- return {
527
- ok: true,
528
- visible: Boolean(panelRoot),
529
- panel_count: panels.length,
530
- confirm_button_visible: Boolean(confirmButton),
531
- confirm_button_text: confirmButton ? normalize(confirmButton.textContent || '') : null,
532
- group_count: groups.length,
533
- option_count: options.length
534
- };
535
- })()`;
536
- }
537
-
538
- function buildFilterTriggerClickExpression() {
539
- const triggerSelectors = getRecommendSelectorRule(["frame", "filter_trigger"]);
540
- return `(() => {
541
- const frame = ${buildRecommendFrameExpression()};
542
- if (!frame || !frame.contentDocument) {
543
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
544
- }
545
- const doc = frame.contentDocument;
546
- const isVisible = (el) => {
547
- if (!el) return false;
548
- const style = getComputedStyle(el);
549
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) return false;
550
- const rect = el.getBoundingClientRect();
551
- return rect.width > 2 && rect.height > 2;
552
- };
553
- for (const selector of ${JSON.stringify(triggerSelectors)}) {
554
- const node = (() => { try { return doc.querySelector(selector); } catch { return null; } })();
555
- if (!node || !isVisible(node)) continue;
556
- try {
557
- node.click();
558
- return { ok: true, selector };
559
- } catch (error) {
560
- return { ok: false, error: error?.message || 'FILTER_TRIGGER_CLICK_FAILED', selector };
561
- }
562
- }
563
- return { ok: false, error: 'FILTER_TRIGGER_NOT_FOUND' };
564
- })()`;
565
- }
566
-
567
- function buildFilterConfirmClickExpression() {
568
- const confirmSelectors = getRecommendSelectorRule(["frame", "filter_confirm_button"]);
569
- const panelSelectors = getRecommendSelectorRule(["frame", "filter_panel"]);
570
- return `(() => {
571
- const frame = ${buildRecommendFrameExpression()};
572
- if (!frame || !frame.contentDocument) {
573
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
574
- }
575
- const doc = frame.contentDocument;
576
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
577
- const isVisible = (el) => {
578
- if (!el) return false;
579
- const style = getComputedStyle(el);
580
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) return false;
581
- const rect = el.getBoundingClientRect();
582
- return rect.width > 2 && rect.height > 2;
583
- };
584
- const panels = ${buildSelectorCollectionExpression(panelSelectors, "doc")}.filter((item) => isVisible(item));
585
- const panel = panels[0] || null;
586
- if (!panel) return { ok: false, error: 'FILTER_PANEL_NOT_FOUND' };
587
- const buttons = ${buildSelectorCollectionExpression(confirmSelectors, "panel")}.filter((item) => isVisible(item));
588
- const target = buttons.find((item) => /确定|确认|完成|应用/.test(normalize(item.textContent || ''))) || buttons[0] || null;
589
- if (!target) return { ok: false, error: 'FILTER_CONFIRM_BUTTON_NOT_FOUND' };
590
- try {
591
- target.click();
592
- return { ok: true, text: normalize(target.textContent || '') || null };
593
- } catch (error) {
594
- return { ok: false, error: error?.message || 'FILTER_CONFIRM_CLICK_FAILED' };
595
- }
596
- })()`;
597
- }
598
-
599
- function buildFilterGroupProbeExpression(groupClass) {
600
- const groupContainerSelectors = getRecommendSelectorRule(["frame", "filter_group_container"]);
601
- const optionSelectors = getRecommendSelectorRule(["frame", "filter_option_all"], getRecommendSelectorRule(["frame", "filter_option"]));
602
- const groupSelectorMap = {
603
- school: getRecommendSelectorRule(["frame", "filter_group_school"], [".check-box.school"]),
604
- degree: getRecommendSelectorRule(["frame", "filter_group_degree"], [".check-box.degree"]),
605
- gender: getRecommendSelectorRule(["frame", "filter_group_gender"], [".check-box.gender"]),
606
- recentNotView: getRecommendSelectorRule(["frame", "filter_group_recent_not_view"], [".check-box.recentNotView"])
607
- };
608
- const scrollContainerSelectors = getRecommendSelectorRule(["frame", "filter_scroll_container"]);
609
- return `((groupClass) => {
610
- const frame = ${buildRecommendFrameExpression()};
611
- if (!frame || !frame.contentDocument) {
612
- return { ok: false, error: 'NO_RECOMMEND_IFRAME', group_class: groupClass };
613
- }
614
- const doc = frame.contentDocument;
615
- const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
616
- const collect = (selectors, root = doc) => selectors.flatMap((selector) => {
617
- try { return Array.from(root.querySelectorAll(selector)); } catch { return []; }
618
- });
619
- const pickVisibleGroup = (groups) => {
620
- const isVisible = (el) => {
621
- if (!el) return false;
622
- const style = getComputedStyle(el);
623
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) return false;
624
- const rect = el.getBoundingClientRect();
625
- return rect.width > 2 && rect.height > 2;
626
- };
627
- return groups.find((group) => isVisible(group)) || groups[0] || null;
628
- };
629
- const optionSetOf = (group) => new Set(
630
- collect(${JSON.stringify(optionSelectors)}, group)
631
- .map((node) => normalize(node.textContent || ''))
632
- .filter(Boolean)
633
- );
634
- const groups = collect(${JSON.stringify(groupContainerSelectors)});
635
- const directSelectors = (${JSON.stringify(groupSelectorMap)})[groupClass] || [];
636
- let group = pickVisibleGroup(collect(directSelectors));
637
- let matchedBy = group ? 'direct' : null;
638
- if (!group) {
639
- if (groupClass === 'school') {
640
- group = groups.find((item) => {
641
- const set = optionSetOf(item);
642
- return set.has('985') || set.has('211') || set.has('双一流院校');
643
- }) || null;
644
- } else if (groupClass === 'degree') {
645
- group = groups.find((item) => {
646
- const set = optionSetOf(item);
647
- return set.has('大专') || set.has('本科') || set.has('硕士') || set.has('博士');
648
- }) || null;
649
- } else if (groupClass === 'gender') {
650
- group = groups.find((item) => {
651
- const set = optionSetOf(item);
652
- return set.has('男') || set.has('女');
653
- }) || null;
654
- } else if (groupClass === 'recentNotView') {
655
- group = groups.find((item) => {
656
- const set = optionSetOf(item);
657
- return set.has('近14天没有');
658
- }) || null;
659
- }
660
- if (group) matchedBy = 'option-signature';
661
- }
662
- let usedScroll = false;
663
- let scrollerMatchedSelector = null;
664
- if (!group) {
665
- const scrollerMatch = ${JSON.stringify(scrollContainerSelectors)}
666
- .map((selector) => ({ selector, node: (() => { try { return doc.querySelector(selector); } catch { return null; } })() }))
667
- .find((item) => item.node) || null;
668
- const scroller = scrollerMatch?.node || null;
669
- if (scroller) {
670
- scrollerMatchedSelector = scrollerMatch.selector;
671
- const maxScrollTop = Math.max(0, Number(scroller.scrollHeight || 0) - Number(scroller.clientHeight || 0));
672
- const steps = 14;
673
- for (let index = 0; index <= steps; index += 1) {
674
- const nextTop = maxScrollTop <= 0 ? 0 : Math.round((maxScrollTop * index) / steps);
675
- scroller.scrollTop = nextTop;
676
- usedScroll = true;
677
- const refreshedGroups = collect(${JSON.stringify(groupContainerSelectors)});
678
- if (groupClass === 'school') {
679
- group = refreshedGroups.find((item) => {
680
- const set = optionSetOf(item);
681
- return set.has('985') || set.has('211') || set.has('双一流院校');
682
- }) || null;
683
- } else if (groupClass === 'degree') {
684
- group = refreshedGroups.find((item) => {
685
- const set = optionSetOf(item);
686
- return set.has('大专') || set.has('本科') || set.has('硕士') || set.has('博士');
687
- }) || null;
688
- } else if (groupClass === 'gender') {
689
- group = refreshedGroups.find((item) => {
690
- const set = optionSetOf(item);
691
- return set.has('男') || set.has('女');
692
- }) || null;
693
- } else if (groupClass === 'recentNotView') {
694
- group = refreshedGroups.find((item) => {
695
- const set = optionSetOf(item);
696
- return set.has('近14天没有');
697
- }) || null;
698
- }
699
- if (group) {
700
- matchedBy = 'option-signature-after-scroll';
701
- break;
702
- }
703
- }
704
- }
705
- }
706
- const optionCount = group ? collect(${JSON.stringify(optionSelectors)}, group).length : 0;
707
- return {
708
- ok: Boolean(group),
709
- group_class: groupClass,
710
- matched_by: matchedBy,
711
- used_scroll: usedScroll,
712
- scroll_container_selector: scrollerMatchedSelector,
713
- group_option_count: optionCount
714
- };
715
- })(${JSON.stringify(groupClass)})`;
716
- }
717
-
718
- function buildAckButtonExpression() {
719
- const ackSelectors = getRecommendSelectorRule(["detail", "ack_button"]);
720
- return `(() => {
721
- const roots = ${buildRootListExpression("detail")};
722
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
723
- if (!Array.isArray(roots) || roots.length === 0) return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
724
- for (const doc of roots) {
725
- for (const selector of ${JSON.stringify(ackSelectors)}) {
726
- const nodes = (() => { try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; } })();
727
- const target = nodes.find((item) => item && item.offsetParent !== null && normalize(item.textContent || '') === '知道了') || null;
728
- if (target) {
729
- return { ok: true, selector, text: normalize(target.textContent || '') };
730
- }
731
- }
732
- }
733
- return { ok: false, error: 'ACK_BUTTON_NOT_FOUND' };
734
- })()`;
735
- }
736
-
737
- function buildAckButtonClickExpression() {
738
- const ackSelectors = getRecommendSelectorRule(["detail", "ack_button"]);
739
- return `(() => {
740
- const roots = ${buildRootListExpression("detail")};
741
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
742
- if (!Array.isArray(roots) || roots.length === 0) return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
743
- for (const doc of roots) {
744
- for (const selector of ${JSON.stringify(ackSelectors)}) {
745
- const nodes = (() => { try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; } })();
746
- const target = nodes.find((item) => item && item.offsetParent !== null && normalize(item.textContent || '') === '知道了') || null;
747
- if (!target) continue;
748
- try {
749
- target.click();
750
- return { ok: true, selector };
751
- } catch (error) {
752
- return { ok: false, error: error?.message || 'ACK_BUTTON_CLICK_FAILED', selector };
753
- }
754
- }
755
- }
756
- return { ok: false, error: 'ACK_BUTTON_NOT_FOUND' };
757
- })()`;
758
- }
759
-
760
- function buildDetailClosedStateExpression() {
761
- const popupSelectors = getRecommendSelectorRule(["detail", "popup"]);
762
- const resumeSelectors = getRecommendSelectorRule(["detail", "resume_iframe"]);
763
- const favoriteSelectors = getRecommendSelectorRule(["detail", "favorite_button"]);
764
- const greetSelectors = [
765
- ...getRecommendSelectorRule(["detail", "greet_button_recommend"]),
766
- ...getRecommendSelectorRule(["detail", "greet_button_featured"])
767
- ];
768
- return `(() => {
769
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
770
- const pickVisibleKnowButton = (rootDoc) => {
771
- if (!rootDoc) return null;
772
- const buttons = Array.from(rootDoc.querySelectorAll('button.btn-v2.btn-sure-v2, button.btn'));
773
- return buttons.find((item) => normalize(item.textContent || '') === '知道了' && item.offsetParent !== null) || null;
774
- };
775
- const topKnow = pickVisibleKnowButton(document);
776
- if (topKnow) return { closed: false, reason: 'top know button visible' };
777
- const topPopups = ${buildSelectorCollectionExpression(popupSelectors, "document")}.filter((item) => item && item.offsetParent !== null);
778
- if (topPopups.length > 0) return { closed: false, reason: 'top popup visible' };
779
- const frame = ${buildRecommendFrameExpression()};
780
- if (!frame || !frame.contentDocument) return { closed: true, reason: 'NO_RECOMMEND_IFRAME' };
781
- const doc = frame.contentDocument;
782
- const frameKnow = pickVisibleKnowButton(doc);
783
- if (frameKnow) return { closed: false, reason: 'frame know button visible' };
784
- const popupNodes = ${buildSelectorCollectionExpression(popupSelectors, "doc")}.filter((item) => item && item.offsetParent !== null);
785
- if (popupNodes.length > 0) return { closed: false, reason: 'popup visible' };
786
- const detailSignals = [
787
- ...${JSON.stringify(resumeSelectors)},
788
- ...${JSON.stringify(favoriteSelectors)},
789
- ...${JSON.stringify(greetSelectors)}
790
- ];
791
- for (const selector of detailSignals) {
792
- const node = (() => { try { return doc.querySelector(selector); } catch { return null; } })();
793
- if (node && node.offsetParent !== null) return { closed: false, reason: 'detail signal visible: ' + selector };
794
- }
795
- return { closed: true, reason: 'no popup or detail signal visible' };
796
- })()`;
797
- }
798
-
799
- function buildRecommendTabStateExpression() {
800
- const tabSelectors = getRecommendSelectorRule(["frame", "tab_items"]);
801
- const recommendCardSelectors = getRecommendSelectorRule(["frame", "recommend_cards"]);
802
- const featuredCardSelectors = getRecommendSelectorRule(["frame", "featured_cards"]);
803
- const latestCardSelectors = getRecommendSelectorRule(["frame", "latest_cards"]);
804
- return `(() => {
805
- const frame = ${buildRecommendFrameExpression()};
806
- if (!frame || !frame.contentDocument) {
807
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
808
- }
809
- const doc = frame.contentDocument;
810
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
811
- const tabs = Array.from(new Set(${JSON.stringify(tabSelectors)}
812
- .flatMap((selector) => {
813
- try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
814
- }))).map((node) => {
815
- const status = normalize(node.getAttribute('data-status'));
816
- const className = normalize(node.className);
817
- const active = (
818
- /(?:^|\\s)(?:curr|current|active|selected)(?:\\s|$)/i.test(className)
819
- || normalize(node.getAttribute('aria-selected')) === 'true'
820
- || normalize(node.getAttribute('data-selected')) === 'true'
821
- );
822
- return {
823
- status: status || null,
824
- title: normalize(node.getAttribute('title')) || null,
825
- label: normalize(node.textContent) || null,
826
- active,
827
- class_name: className || null
828
- };
829
- });
830
- const countBy = (selectors) => selectors.reduce((sum, selector) => {
831
- try { return sum + doc.querySelectorAll(selector).length; } catch { return sum; }
832
- }, 0);
833
- const recommendCount = countBy(${JSON.stringify(recommendCardSelectors)});
834
- const featuredCount = countBy(${JSON.stringify(featuredCardSelectors)});
835
- const latestCount = countBy(${JSON.stringify(latestCardSelectors)});
836
- const activeTab = tabs.find((item) => item.active && item.status) || null;
837
- let inferredStatus = activeTab?.status || null;
838
- if (!inferredStatus) {
839
- if (featuredCount > 0 && recommendCount === 0 && latestCount === 0) inferredStatus = '3';
840
- else if (latestCount > 0 && featuredCount === 0 && recommendCount === 0) inferredStatus = '1';
841
- else if (recommendCount > 0 && featuredCount === 0 && latestCount === 0) inferredStatus = '0';
842
- }
843
- return {
844
- ok: true,
845
- active_status: inferredStatus,
846
- tabs,
847
- layout: {
848
- recommend_count: recommendCount,
849
- featured_count: featuredCount,
850
- latest_count: latestCount
851
- }
852
- };
853
- })()`;
854
- }
855
-
856
- function buildRecommendTabSwitchExpression(targetStatus) {
857
- const tabSelectors = getRecommendSelectorRule(["frame", "tab_items"]);
858
- return `((targetStatus) => {
859
- const frame = ${buildRecommendFrameExpression()};
860
- if (!frame || !frame.contentDocument) {
861
- return { ok: false, state: 'NO_RECOMMEND_IFRAME' };
862
- }
863
- const doc = frame.contentDocument;
864
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
865
- const tabs = Array.from(new Set(${JSON.stringify(tabSelectors)}
866
- .flatMap((selector) => {
867
- try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
868
- })));
869
- const target = tabs.find((node) => normalize(node.getAttribute('data-status')) === String(targetStatus)) || null;
870
- if (!target) {
871
- return { ok: false, state: 'TAB_NOT_FOUND', target_status: String(targetStatus) };
872
- }
873
- try {
874
- target.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
875
- } catch {}
876
- try {
877
- target.click();
878
- return { ok: true, state: 'TAB_CLICKED', target_status: String(targetStatus) };
879
- } catch (error) {
880
- return {
881
- ok: false,
882
- state: 'TAB_CLICK_FAILED',
883
- message: error?.message || String(error),
884
- target_status: String(targetStatus)
885
- };
886
- }
887
- })(${JSON.stringify(String(targetStatus))})`;
888
- }
889
-
890
- function buildCandidateSurfaceStateExpression() {
891
- const specific = {
892
- recommend: getRecommendSelectorRule(["frame", "recommend_cards"]),
893
- latest: getRecommendSelectorRule(["frame", "latest_cards"]),
894
- featured: getRecommendSelectorRule(["frame", "featured_cards"])
895
- };
896
- const genericSelectors = [
897
- ...specific.recommend,
898
- ...specific.latest,
899
- ...specific.featured,
900
- ...getRecommendSelectorRule(["frame", "recommend_card_inner"]),
901
- ...getRecommendSelectorRule(["frame", "latest_card_inner"]),
902
- ...getRecommendSelectorRule(["frame", "featured_card_anchor"])
903
- ];
904
- return `(() => {
905
- const frame = ${buildRecommendFrameExpression()};
906
- if (!frame || !frame.contentDocument) {
907
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
908
- }
909
- const doc = frame.contentDocument;
910
- const countBy = (selectors) => selectors.reduce((sum, selector) => {
911
- try { return sum + doc.querySelectorAll(selector).length; } catch { return sum; }
912
- }, 0);
913
- const uniqueNodes = Array.from(new Set(${JSON.stringify(genericSelectors)}
914
- .flatMap((selector) => {
915
- try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
916
- })));
917
- const tabState = ${buildRecommendTabStateExpression()};
918
- return {
919
- ok: true,
920
- active_status: tabState?.active_status || null,
921
- counts: {
922
- recommend: countBy(${JSON.stringify(specific.recommend)}),
923
- latest: countBy(${JSON.stringify(specific.latest)}),
924
- featured: countBy(${JSON.stringify(specific.featured)}),
925
- generic: uniqueNodes.length
926
- }
927
- };
928
- })()`;
929
- }
930
-
931
- function buildCandidateCountStateExpression() {
932
- const recommendCardSelectors = getRecommendSelectorRule(["frame", "recommend_cards"]);
933
- const recommendInnerSelectors = getRecommendSelectorRule(["frame", "recommend_card_inner"]);
934
- const featuredCardSelectors = getRecommendSelectorRule(["frame", "featured_cards"]);
935
- const featuredAnchorSelectors = getRecommendSelectorRule(["frame", "featured_card_anchor"]);
936
- const latestCardSelectors = getRecommendSelectorRule(["frame", "latest_cards"]);
937
- const latestInnerSelectors = getRecommendSelectorRule(["frame", "latest_card_inner"]);
938
- const tabSelectors = getRecommendSelectorRule(["frame", "tab_items"]);
939
- return `(() => {
940
- const frame = ${buildRecommendFrameExpression()};
941
- if (!frame || !frame.contentDocument) {
942
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
943
- }
944
- const doc = frame.contentDocument;
945
- const collect = (selectors) => selectors.flatMap((selector) => {
946
- try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
947
- });
948
- const cards = collect(${JSON.stringify(recommendCardSelectors)});
949
- const recommendCandidates = cards.filter((card) => ${buildSelectorCollectionExpression(recommendInnerSelectors, "card")}.length > 0);
950
- const featuredCards = collect(${JSON.stringify(featuredCardSelectors)});
951
- const featuredCandidates = featuredCards.filter((card) => ${buildSelectorCollectionExpression(featuredAnchorSelectors, "card")}.length > 0);
952
- const latestCards = collect(${JSON.stringify(latestCardSelectors)});
953
- const latestCandidates = latestCards.filter((card) => ${buildSelectorCollectionExpression(latestInnerSelectors, "card")}.length > 0);
954
- const tabs = collect(${JSON.stringify(tabSelectors)});
955
- const activeTab = tabs.find((node) => {
956
- const className = String(node.className || '');
957
- const selected = String(node.getAttribute('aria-selected') || '').toLowerCase() === 'true';
958
- return /(?:^|\\s)(?:curr|current|active|selected)(?:\\s|$)/i.test(className) || selected;
959
- }) || null;
960
- const activeTabStatus = activeTab ? String(activeTab.getAttribute('data-status') || '') : '';
961
- const inferredStatus = activeTabStatus
962
- || (featuredCandidates.length > 0 && recommendCandidates.length === 0 && latestCandidates.length === 0
963
- ? '3'
964
- : latestCandidates.length > 0 && recommendCandidates.length === 0 && featuredCandidates.length === 0
965
- ? '1'
966
- : recommendCandidates.length > 0 && featuredCandidates.length === 0 && latestCandidates.length === 0
967
- ? '0'
968
- : '');
969
- const effectiveCount = inferredStatus === '3'
970
- ? featuredCandidates.length
971
- : inferredStatus === '1'
972
- ? latestCandidates.length
973
- : inferredStatus === '0'
974
- ? recommendCandidates.length
975
- : Math.max(recommendCandidates.length, featuredCandidates.length, latestCandidates.length);
976
- return {
977
- ok: true,
978
- candidateCount: effectiveCount,
979
- recommendCandidateCount: recommendCandidates.length,
980
- featuredCandidateCount: featuredCandidates.length,
981
- latestCandidateCount: latestCandidates.length,
982
- activeTabStatus: inferredStatus || null
983
- };
984
- })()`;
985
- }
986
-
987
- function buildDetailOpenExpression() {
988
- const recommendInnerSelectors = getRecommendSelectorRule(["frame", "recommend_card_inner"]);
989
- const latestInnerSelectors = getRecommendSelectorRule(["frame", "latest_card_inner"]);
990
- const featuredAnchorSelectors = getRecommendSelectorRule(["frame", "featured_card_anchor"]);
991
- const recommendCardSelectors = getRecommendSelectorRule(["frame", "recommend_cards"]);
992
- const latestCardSelectors = getRecommendSelectorRule(["frame", "latest_cards"]);
993
- const featuredCardSelectors = getRecommendSelectorRule(["frame", "featured_cards"]);
994
- return `(() => {
995
- const frame = ${buildRecommendFrameExpression()};
996
- if (!frame || !frame.contentDocument) {
997
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
998
- }
999
- const doc = frame.contentDocument;
1000
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
1001
- const isVisible = (el) => {
1002
- if (!el) return false;
1003
- const style = getComputedStyle(el);
1004
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
1005
- return false;
1006
- }
1007
- const rect = el.getBoundingClientRect();
1008
- return rect.width > 2 && rect.height > 2;
1009
- };
1010
- const collect = (selectors, root = doc) => selectors.flatMap((selector) => {
1011
- try { return Array.from(root.querySelectorAll(selector)); } catch { return []; }
1012
- });
1013
- const tabs = collect(${JSON.stringify(getRecommendSelectorRule(["frame", "tab_items"]))});
1014
- const activeTab = tabs.find((node) => {
1015
- const className = String(node.className || '');
1016
- const selected = String(node.getAttribute('aria-selected') || '').toLowerCase() === 'true';
1017
- return /(?:^|\\s)(?:curr|current|active|selected)(?:\\s|$)/i.test(className) || selected;
1018
- }) || null;
1019
- const recommendTargetsRaw = collect(${JSON.stringify(recommendCardSelectors)}).flatMap((card) => {
1020
- const inner = collect(${JSON.stringify(recommendInnerSelectors)}, card);
1021
- return [...inner, card];
1022
- });
1023
- const latestTargetsRaw = collect(${JSON.stringify(latestCardSelectors)}).flatMap((card) => {
1024
- const inner = collect(${JSON.stringify(latestInnerSelectors)}, card);
1025
- return [...inner, card];
1026
- });
1027
- const featuredTargetsRaw = collect(${JSON.stringify(featuredCardSelectors)}).flatMap((card) => {
1028
- const inner = collect(${JSON.stringify(featuredAnchorSelectors)}, card);
1029
- return [...inner, card];
1030
- });
1031
- const recommendVisible = recommendTargetsRaw.filter((node) => isVisible(node));
1032
- const latestVisible = latestTargetsRaw.filter((node) => isVisible(node));
1033
- const featuredVisible = featuredTargetsRaw.filter((node) => isVisible(node));
1034
- let activeStatus = normalize(activeTab?.getAttribute('data-status') || '');
1035
- if (!activeStatus) {
1036
- if (featuredVisible.length > 0 && recommendVisible.length === 0 && latestVisible.length === 0) activeStatus = '3';
1037
- else if (latestVisible.length > 0 && recommendVisible.length === 0 && featuredVisible.length === 0) activeStatus = '1';
1038
- else if (recommendVisible.length > 0 && latestVisible.length === 0 && featuredVisible.length === 0) activeStatus = '0';
1039
- }
1040
- let targets = [];
1041
- if (activeStatus === '3') {
1042
- targets = featuredTargetsRaw;
1043
- } else if (activeStatus === '1') {
1044
- targets = latestTargetsRaw;
1045
- } else {
1046
- targets = recommendTargetsRaw;
1047
- }
1048
- const target = targets.find((node) => node && typeof node.click === 'function' && isVisible(node));
1049
- if (!target) return { ok: false, error: 'TARGET_NOT_FOUND', active_status: activeStatus || null };
1050
- try {
1051
- target.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
1052
- } catch {}
1053
- try {
1054
- target.click();
1055
- return { ok: true, active_status: activeStatus || null };
1056
- } catch (error) {
1057
- return { ok: false, error: error?.message || 'CLICK_FAILED', active_status: activeStatus || null };
1058
- }
1059
- })()`;
1060
- }
1061
-
1062
- function buildJobListStateExpression() {
1063
- const jobItemSelectors = getRecommendSelectorRule(["frame", "job_list_items"]);
1064
- const selectedLabelSelectors = getRecommendSelectorRule(["frame", "job_selected_label"]);
1065
- return `(() => {
1066
- const frame = ${buildRecommendFrameExpression()};
1067
- if (!frame || !frame.contentDocument) {
1068
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1069
- }
1070
- const doc = frame.contentDocument;
1071
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
1072
- const normalizeTitle = (value) => {
1073
- const text = normalize(value);
1074
- if (!text) return '';
1075
- const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
1076
- const strippedRange = byGap
1077
- .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
1078
- .trim();
1079
- const strippedSingle = strippedRange
1080
- .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
1081
- .trim();
1082
- return strippedSingle || byGap;
1083
- };
1084
- const isVisible = (el) => {
1085
- if (!el) return false;
1086
- const style = getComputedStyle(el);
1087
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
1088
- return false;
1089
- }
1090
- const rect = el.getBoundingClientRect();
1091
- return rect.width > 2 && rect.height > 2;
1092
- };
1093
- const items = ${JSON.stringify(jobItemSelectors)}
1094
- .flatMap((selector) => {
1095
- try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
1096
- });
1097
- const jobs = [];
1098
- const seen = new Set();
1099
- for (const item of items) {
1100
- const label = normalize(item.querySelector('.label')?.textContent || item.textContent || '');
1101
- const title = normalizeTitle(label);
1102
- const value = normalize(item.getAttribute('value') || item.dataset?.value || '');
1103
- const dedupeKey = value || title || label;
1104
- if (!dedupeKey || seen.has(dedupeKey)) continue;
1105
- seen.add(dedupeKey);
1106
- jobs.push({
1107
- value: value || null,
1108
- title: title || label || null,
1109
- label: label || null,
1110
- current: item.classList.contains('curr') || item.classList.contains('active'),
1111
- visible: isVisible(item)
1112
- });
1113
- }
1114
- const selectedLabelNode = ${JSON.stringify(selectedLabelSelectors)}
1115
- .map((selector) => {
1116
- try { return doc.querySelector(selector); } catch { return null; }
1117
- })
1118
- .find((node) => node) || null;
1119
- return {
1120
- ok: true,
1121
- jobs,
1122
- selected_label: normalize(selectedLabelNode ? selectedLabelNode.textContent : '')
1123
- };
1124
- })()`;
1125
- }
1126
-
1127
- function buildJobDropdownClickExpression() {
1128
- const dropdownSelectors = getRecommendSelectorRule(["frame", "job_dropdown_trigger"]);
1129
- return `(() => {
1130
- const frame = ${buildRecommendFrameExpression()};
1131
- if (!frame || !frame.contentDocument) {
1132
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1133
- }
1134
- const doc = frame.contentDocument;
1135
- const isVisible = (el) => {
1136
- if (!el) return false;
1137
- const style = getComputedStyle(el);
1138
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
1139
- return false;
1140
- }
1141
- const rect = el.getBoundingClientRect();
1142
- return rect.width > 2 && rect.height > 2;
1143
- };
1144
- for (const selector of ${JSON.stringify(dropdownSelectors)}) {
1145
- const el = doc.querySelector(selector);
1146
- if (el && isVisible(el)) {
1147
- el.click();
1148
- return { ok: true };
1149
- }
1150
- }
1151
- return { ok: false, error: 'JOB_TRIGGER_NOT_FOUND' };
1152
- })()`;
1153
- }
1154
-
1155
- function buildJobSelectExpression(job) {
1156
- const jobItemSelectors = getRecommendSelectorRule(["frame", "job_list_items"]);
1157
- return `((job) => {
1158
- const frame = ${buildRecommendFrameExpression()};
1159
- if (!frame || !frame.contentDocument) {
1160
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1161
- }
1162
- const doc = frame.contentDocument;
1163
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
1164
- const normalizeTitle = (value) => {
1165
- const text = normalize(value);
1166
- if (!text) return '';
1167
- const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
1168
- const strippedRange = byGap
1169
- .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
1170
- .trim();
1171
- const strippedSingle = strippedRange
1172
- .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
1173
- .trim();
1174
- return strippedSingle || byGap;
1175
- };
1176
- const items = ${JSON.stringify(jobItemSelectors)}
1177
- .flatMap((selector) => {
1178
- try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
1179
- });
1180
- const target = items.find((item) => {
1181
- const value = normalize(item.getAttribute('value') || item.dataset?.value || '');
1182
- const label = normalize(item.querySelector('.label')?.textContent || item.textContent || '');
1183
- const title = normalizeTitle(label);
1184
- const matchValue = job.value && value && value === normalize(job.value);
1185
- const matchTitle = job.title && title && title === normalize(job.title);
1186
- const matchLabel = job.label && label && label === normalize(job.label);
1187
- return matchValue || matchTitle || matchLabel;
1188
- });
1189
- if (!target) {
1190
- return { ok: false, error: 'JOB_OPTION_NOT_FOUND' };
1191
- }
1192
- target.click();
1193
- return { ok: true };
1194
- })(${JSON.stringify(job)})`;
1195
- }
1196
-
1197
- function upsertSelectorCheck(selectorChecks, nextCheck) {
1198
- const index = selectorChecks.findIndex((item) => item?.rule_id === nextCheck?.rule_id);
1199
- if (index >= 0) selectorChecks[index] = nextCheck;
1200
- else selectorChecks.push(nextCheck);
1201
- }
1202
-
1203
- async function waitForRecommendTabStatus(client, targetStatus, rounds = 12) {
1204
- const normalizedTarget = String(targetStatus);
1205
- for (let attempt = 0; attempt < rounds; attempt += 1) {
1206
- const state = await evaluate(client, buildRecommendTabStateExpression());
1207
- if (state?.ok && String(state.active_status || "") === normalizedTarget) {
1208
- return state;
1209
- }
1210
- await new Promise((resolve) => setTimeout(resolve, 220 + attempt * 40));
1211
- }
1212
- return await evaluate(client, buildRecommendTabStateExpression());
1213
- }
1214
-
1215
- async function waitForCandidateCountStable(client, expectedTabStatus = null, rounds = 10) {
1216
- let lastCount = null;
1217
- let stableRounds = 0;
1218
- let latest = null;
1219
- for (let index = 0; index < rounds; index += 1) {
1220
- latest = await evaluate(client, buildCandidateCountStateExpression());
1221
- const status = normalizeText(latest?.activeTabStatus || "");
1222
- const current = latest?.candidateCount ?? null;
1223
- if (expectedTabStatus && status && status !== String(expectedTabStatus)) {
1224
- stableRounds = 0;
1225
- lastCount = current;
1226
- await new Promise((resolve) => setTimeout(resolve, 350 + index * 50));
1227
- continue;
1228
- }
1229
- if (current !== null && current === lastCount) {
1230
- stableRounds += 1;
1231
- const shouldKeepWaitingForZero = Number(current) === 0 && index < Math.min(rounds - 1, 5);
1232
- if (stableRounds >= 2 && !shouldKeepWaitingForZero) {
1233
- return latest;
1234
- }
1235
- } else {
1236
- stableRounds = 0;
1237
- }
1238
- lastCount = current;
1239
- await new Promise((resolve) => setTimeout(resolve, 350 + index * 50));
1240
- }
1241
- return latest || await evaluate(client, buildCandidateCountStateExpression());
1242
- }
1243
-
1244
- async function waitForFilterPanelVisible(client, expectedVisible, rounds = 10) {
1245
- let latest = null;
1246
- for (let index = 0; index < rounds; index += 1) {
1247
- latest = await evaluate(client, buildFilterPanelStateExpression());
1248
- if (Boolean(latest?.visible) === Boolean(expectedVisible)) return latest;
1249
- await new Promise((resolve) => setTimeout(resolve, 150 + index * 40));
1250
- }
1251
- return latest || await evaluate(client, buildFilterPanelStateExpression());
1252
- }
1253
-
1254
- async function validateFilterScopedSelectors(client, selectorChecks, extraDrifts) {
1255
- const openResult = await evaluate(client, buildFilterTriggerClickExpression());
1256
- if (!openResult?.ok) {
1257
- extraDrifts.push({
1258
- kind: "validation",
1259
- rule_id: "filter_panel_activation",
1260
- path: ["frame", "filter_panel"],
1261
- reason: "filter_panel_open_failed",
1262
- confidence: 0.9,
1263
- auto_repairable: false,
1264
- details: openResult?.error || null
1265
- });
1266
- return;
1267
- }
1268
- const panelState = await waitForFilterPanelVisible(client, true, 12);
1269
- const panelVisible = panelState?.visible === true;
1270
- const defs = [
1271
- { rule_id: "filter_panel", path: ["frame", "filter_panel"], contextCount: Number(panelState?.panel_count || 0) },
1272
- { rule_id: "filter_confirm_button", path: ["frame", "filter_confirm_button"], contextCount: Number(panelState?.confirm_button_visible ? 1 : 0) },
1273
- { rule_id: "filter_confirm_candidates", path: ["frame", "filter_confirm_candidates"], contextCount: Number(panelState?.confirm_button_visible ? 1 : 0) },
1274
- { rule_id: "filter_group_container", path: ["frame", "filter_group_container"], contextCount: Number(panelState?.group_count || 0) },
1275
- { rule_id: "filter_option", path: ["frame", "filter_option"], contextCount: Number(panelState?.option_count || 0) },
1276
- { rule_id: "filter_option_all", path: ["frame", "filter_option_all"], contextCount: Number(panelState?.option_count || 0) },
1277
- { rule_id: "filter_option_active", path: ["frame", "filter_option_active"], contextCount: Number(panelState?.option_count || 0) },
1278
- { rule_id: "filter_scroll_container", path: ["frame", "filter_scroll_container"], contextCount: Number(panelState?.visible ? 1 : 0) }
1279
- ];
1280
- for (const definition of defs) {
1281
- const response = await evaluate(client, buildSelectorCheckExpression(getRecommendSelectorRule(definition.path), "frame"));
1282
- upsertSelectorCheck(selectorChecks, {
1283
- rule_id: definition.rule_id,
1284
- path: definition.path,
1285
- root: "frame",
1286
- required: false,
1287
- report_on_no_match: panelVisible && definition.contextCount > 0,
1288
- no_match_reason: "no_selector_matched_after_filter_panel_open",
1289
- no_match_confidence: 0.87,
1290
- validation_context: {
1291
- filter_panel_visible: panelVisible,
1292
- context_count: definition.contextCount
1293
- },
1294
- skipped: response?.error === "ROOT_NOT_AVAILABLE",
1295
- matches: Array.isArray(response?.matches) ? response.matches : []
1296
- });
1297
- }
1298
-
1299
- const groupValidations = [
1300
- { groupClass: "school", rule_id: "filter_group_school", path: ["frame", "filter_group_school"] },
1301
- { groupClass: "degree", rule_id: "filter_group_degree", path: ["frame", "filter_group_degree"] },
1302
- { groupClass: "gender", rule_id: "filter_group_gender", path: ["frame", "filter_group_gender"] },
1303
- { groupClass: "recentNotView", rule_id: "filter_group_recent_not_view", path: ["frame", "filter_group_recent_not_view"] }
1304
- ];
1305
- for (const groupValidation of groupValidations) {
1306
- const probe = await evaluate(client, buildFilterGroupProbeExpression(groupValidation.groupClass));
1307
- const response = await evaluate(client, buildSelectorCheckExpression(getRecommendSelectorRule(groupValidation.path), "frame"));
1308
- upsertSelectorCheck(selectorChecks, {
1309
- rule_id: groupValidation.rule_id,
1310
- path: groupValidation.path,
1311
- root: "frame",
1312
- required: false,
1313
- report_on_no_match: probe?.ok === true,
1314
- no_match_reason: "no_selector_matched_after_group_probe",
1315
- no_match_confidence: 0.9,
1316
- validation_context: {
1317
- group_class: groupValidation.groupClass,
1318
- group_probe_ok: probe?.ok === true,
1319
- matched_by: probe?.matched_by || null,
1320
- used_scroll: probe?.used_scroll === true,
1321
- scroll_container_selector: probe?.scroll_container_selector || null,
1322
- group_option_count: Number(probe?.group_option_count || 0)
1323
- },
1324
- skipped: response?.error === "ROOT_NOT_AVAILABLE",
1325
- matches: Array.isArray(response?.matches) ? response.matches : []
1326
- });
1327
- if (probe?.ok !== true) {
1328
- extraDrifts.push({
1329
- kind: "validation",
1330
- rule_id: `${groupValidation.rule_id}_activation`,
1331
- path: groupValidation.path,
1332
- reason: "filter_group_probe_failed",
1333
- confidence: 0.86,
1334
- auto_repairable: false,
1335
- details: probe?.error || null
1336
- });
1337
- }
1338
- }
1339
-
1340
- const closeResult = await evaluate(client, buildFilterConfirmClickExpression());
1341
- if (!closeResult?.ok) {
1342
- extraDrifts.push({
1343
- kind: "validation",
1344
- rule_id: "filter_panel_close",
1345
- path: ["frame", "filter_confirm_button"],
1346
- reason: "filter_panel_close_failed",
1347
- confidence: 0.88,
1348
- auto_repairable: false,
1349
- details: closeResult?.error || null
1350
- });
1351
- return;
1352
- }
1353
- const closedState = await waitForFilterPanelVisible(client, false, 12);
1354
- if (closedState?.visible === true) {
1355
- extraDrifts.push({
1356
- kind: "validation",
1357
- rule_id: "filter_panel_close",
1358
- path: ["frame", "filter_confirm_button"],
1359
- reason: "filter_panel_still_visible_after_confirm",
1360
- confidence: 0.88,
1361
- auto_repairable: false
1362
- });
1363
- }
1364
- }
1365
-
1366
- async function ensureRecommendTabActive(client, targetStatus) {
1367
- const before = await evaluate(client, buildRecommendTabStateExpression());
1368
- if (before?.ok && String(before.active_status || "") === String(targetStatus)) {
1369
- return { ok: true, before, after: before, switched: false };
1370
- }
1371
- const clickResult = await evaluate(client, buildRecommendTabSwitchExpression(targetStatus));
1372
- if (!clickResult?.ok) {
1373
- return { ok: false, before, click_result: clickResult };
1374
- }
1375
- const after = await waitForRecommendTabStatus(client, targetStatus, 12);
1376
- return {
1377
- ok: after?.ok === true && String(after.active_status || "") === String(targetStatus),
1378
- before,
1379
- after,
1380
- click_result: clickResult,
1381
- switched: true
1382
- };
1383
- }
1384
-
1385
- async function ensureJobListReady(client) {
1386
- let lastError = "JOB_LIST_NOT_FOUND";
1387
- for (let attempt = 0; attempt < 4; attempt += 1) {
1388
- const state = await evaluate(client, buildJobListStateExpression());
1389
- if (state?.ok && Array.isArray(state.jobs) && state.jobs.length > 0) {
1390
- return state;
1391
- }
1392
- lastError = state?.error || lastError;
1393
- const clickResult = await evaluate(client, buildJobDropdownClickExpression());
1394
- if (!clickResult?.ok) {
1395
- lastError = clickResult?.error || lastError;
1396
- }
1397
- await new Promise((resolve) => setTimeout(resolve, 220 + attempt * 80));
1398
- }
1399
- throw new Error(lastError);
1400
- }
1401
-
1402
- async function waitForJobSelected(client, job, rounds = 10) {
1403
- const selectedValue = normalizeText(job?.value || "");
1404
- const selectedTitle = normalizeText(job?.title || "");
1405
- const selectedLabel = normalizeText(job?.label || "");
1406
- for (let index = 0; index < rounds; index += 1) {
1407
- const state = await evaluate(client, buildJobListStateExpression());
1408
- if (state?.ok) {
1409
- const current = (state.jobs || []).find((item) => item.current);
1410
- if (current) {
1411
- const sameValue = selectedValue && normalizeText(current.value || "") === selectedValue;
1412
- const sameTitle = selectedTitle && normalizeText(current.title || "") === selectedTitle;
1413
- const sameLabel = selectedLabel && normalizeText(current.label || "") === selectedLabel;
1414
- if (sameValue || sameTitle || sameLabel) return { ok: true, state };
1415
- }
1416
- const selectedText = normalizeText(state.selected_label || "");
1417
- if (selectedTitle && selectedText && (selectedText === selectedTitle || selectedText.includes(selectedTitle))) {
1418
- return { ok: true, state };
1419
- }
1420
- if (selectedLabel && selectedText && (selectedText === selectedLabel || selectedText.includes(selectedLabel))) {
1421
- return { ok: true, state };
1422
- }
1423
- }
1424
- await new Promise((resolve) => setTimeout(resolve, 150 + index * 40));
1425
- }
1426
- return { ok: false, state: await evaluate(client, buildJobListStateExpression()) };
1427
- }
1428
-
1429
- async function validateTabScopedCardSelectors(client, selectorChecks, extraDrifts, originalTabStatus) {
1430
- const validations = [
1431
- {
1432
- rule_id: "recommend_cards",
1433
- path: ["frame", "recommend_cards"],
1434
- inner_rule_id: "recommend_card_inner",
1435
- inner_path: ["frame", "recommend_card_inner"],
1436
- target_status: "0",
1437
- tab_name: "recommend"
1438
- },
1439
- {
1440
- rule_id: "latest_cards",
1441
- path: ["frame", "latest_cards"],
1442
- inner_rule_id: "latest_card_inner",
1443
- inner_path: ["frame", "latest_card_inner"],
1444
- target_status: "1",
1445
- tab_name: "latest"
1446
- },
1447
- {
1448
- rule_id: "featured_cards",
1449
- path: ["frame", "featured_cards"],
1450
- inner_rule_id: "featured_card_anchor",
1451
- inner_path: ["frame", "featured_card_anchor"],
1452
- target_status: "3",
1453
- tab_name: "featured"
1454
- }
1455
- ];
1456
-
1457
- for (const validation of validations) {
1458
- const activation = await ensureRecommendTabActive(client, validation.target_status);
1459
- if (!activation.ok) {
1460
- extraDrifts.push({
1461
- kind: "validation",
1462
- rule_id: `${validation.rule_id}_activation`,
1463
- path: validation.path,
1464
- reason: "tab_activation_failed",
1465
- confidence: 0.9,
1466
- auto_repairable: false,
1467
- details: activation.click_result || activation.after || null
1468
- });
1469
- continue;
1470
- }
1471
- const stableCount = await waitForCandidateCountStable(client, validation.target_status, 10);
1472
- const response = await evaluate(client, buildSelectorCheckExpression(getRecommendSelectorRule(validation.path), "frame"));
1473
- const innerResponse = await evaluate(client, buildSelectorCheckExpression(getRecommendSelectorRule(validation.inner_path), "frame"));
1474
- const surface = await evaluate(client, buildCandidateSurfaceStateExpression());
1475
- const activeStatus = String(stableCount?.activeTabStatus || surface?.active_status || "");
1476
- const tabCount = validation.target_status === "0"
1477
- ? Number(stableCount?.recommendCandidateCount || surface?.counts?.recommend || 0)
1478
- : validation.target_status === "1"
1479
- ? Number(stableCount?.latestCandidateCount || surface?.counts?.latest || 0)
1480
- : Number(stableCount?.featuredCandidateCount || surface?.counts?.featured || 0);
1481
- upsertSelectorCheck(selectorChecks, {
1482
- rule_id: validation.rule_id,
1483
- path: validation.path,
1484
- root: "frame",
1485
- required: validation.rule_id === "recommend_cards",
1486
- report_on_no_match: activeStatus === validation.target_status && tabCount > 0,
1487
- no_match_reason: "no_selector_matched_after_tab_activation",
1488
- no_match_confidence: 0.88,
1489
- validation_context: {
1490
- target_tab: validation.tab_name,
1491
- target_status: validation.target_status,
1492
- active_status: activeStatus || null,
1493
- candidate_count: tabCount
1494
- },
1495
- skipped: response?.error === "ROOT_NOT_AVAILABLE",
1496
- matches: Array.isArray(response?.matches) ? response.matches : []
1497
- });
1498
- upsertSelectorCheck(selectorChecks, {
1499
- rule_id: validation.inner_rule_id,
1500
- path: validation.inner_path,
1501
- root: "frame",
1502
- required: false,
1503
- report_on_no_match: activeStatus === validation.target_status && tabCount > 0,
1504
- no_match_reason: "no_inner_selector_matched_after_tab_activation",
1505
- no_match_confidence: 0.89,
1506
- validation_context: {
1507
- target_tab: validation.tab_name,
1508
- target_status: validation.target_status,
1509
- active_status: activeStatus || null,
1510
- candidate_count: tabCount
1511
- },
1512
- skipped: innerResponse?.error === "ROOT_NOT_AVAILABLE",
1513
- matches: Array.isArray(innerResponse?.matches) ? innerResponse.matches : []
1514
- });
1515
- }
1516
-
1517
- if (originalTabStatus) {
1518
- await ensureRecommendTabActive(client, originalTabStatus);
1519
- }
1520
- }
1521
-
1522
- async function validateJobScopedSelectors(client, selectorChecks, extraDrifts) {
1523
- let initialState = null;
1524
- try {
1525
- initialState = await ensureJobListReady(client);
1526
- } catch (error) {
1527
- extraDrifts.push({
1528
- kind: "validation",
1529
- rule_id: "job_list_items_activation",
1530
- path: ["frame", "job_list_items"],
1531
- reason: "job_dropdown_activation_failed",
1532
- confidence: 0.9,
1533
- auto_repairable: false,
1534
- details: error?.message || String(error)
1535
- });
1536
- return;
1537
- }
1538
-
1539
- upsertSelectorCheck(selectorChecks, {
1540
- rule_id: "job_list_items",
1541
- path: ["frame", "job_list_items"],
1542
- root: "frame",
1543
- required: false,
1544
- report_on_no_match: Array.isArray(initialState.jobs) && initialState.jobs.length > 0,
1545
- no_match_reason: "no_selector_matched_after_job_dropdown_open",
1546
- no_match_confidence: 0.86,
1547
- validation_context: {
1548
- job_count: Array.isArray(initialState.jobs) ? initialState.jobs.length : 0
1549
- },
1550
- skipped: false,
1551
- matches: (await evaluate(client, buildSelectorCheckExpression(getRecommendSelectorRule(["frame", "job_list_items"]), "frame")))?.matches || []
1552
- });
1553
- upsertSelectorCheck(selectorChecks, {
1554
- rule_id: "job_item_label",
1555
- path: ["frame", "job_item_label"],
1556
- root: "frame",
1557
- required: false,
1558
- report_on_no_match: Array.isArray(initialState.jobs) && initialState.jobs.length > 0,
1559
- no_match_reason: "no_selector_matched_for_job_item_label_after_dropdown_open",
1560
- no_match_confidence: 0.86,
1561
- validation_context: {
1562
- job_count: Array.isArray(initialState.jobs) ? initialState.jobs.length : 0
1563
- },
1564
- skipped: false,
1565
- matches: (await evaluate(client, buildSelectorCheckExpression(getRecommendSelectorRule(["frame", "job_item_label"]), "frame")))?.matches || []
1566
- });
1567
- upsertSelectorCheck(selectorChecks, {
1568
- rule_id: "job_search_input",
1569
- path: ["frame", "job_search_input"],
1570
- root: "frame",
1571
- required: false,
1572
- report_on_no_match: Array.isArray(initialState.jobs) && initialState.jobs.length > 0,
1573
- no_match_reason: "no_selector_matched_for_job_search_input_after_dropdown_open",
1574
- no_match_confidence: 0.88,
1575
- validation_context: {
1576
- job_count: Array.isArray(initialState.jobs) ? initialState.jobs.length : 0
1577
- },
1578
- skipped: false,
1579
- matches: (await evaluate(client, buildSelectorCheckExpression(getRecommendSelectorRule(["frame", "job_search_input"]), "frame")))?.matches || []
1580
- });
1581
-
1582
- const currentJob = (initialState.jobs || []).find((item) => item.current) || null;
1583
- const targetJob = (initialState.jobs || []).find((item) => !item.current && item.visible)
1584
- || (initialState.jobs || []).find((item) => !item.current)
1585
- || currentJob
1586
- || (initialState.jobs || [])[0]
1587
- || null;
1588
-
1589
- if (!targetJob) {
1590
- extraDrifts.push({
1591
- kind: "validation",
1592
- rule_id: "job_selected_label_activation",
1593
- path: ["frame", "job_selected_label"],
1594
- reason: "job_option_missing_for_validation",
1595
- confidence: 0.82,
1596
- auto_repairable: false
1597
- });
1598
- return;
1599
- }
1600
-
1601
- const clickResult = await evaluate(client, buildJobSelectExpression(targetJob));
1602
- if (!clickResult?.ok) {
1603
- extraDrifts.push({
1604
- kind: "validation",
1605
- rule_id: "job_selected_label_activation",
1606
- path: ["frame", "job_selected_label"],
1607
- reason: "job_option_click_failed",
1608
- confidence: 0.9,
1609
- auto_repairable: false,
1610
- details: clickResult?.error || null
1611
- });
1612
- return;
1613
- }
1614
-
1615
- const selectionWait = await waitForJobSelected(client, targetJob, 10);
1616
- const labelResponse = await evaluate(client, buildSelectorCheckExpression(getRecommendSelectorRule(["frame", "job_selected_label"]), "frame"));
1617
- upsertSelectorCheck(selectorChecks, {
1618
- rule_id: "job_selected_label",
1619
- path: ["frame", "job_selected_label"],
1620
- root: "frame",
1621
- required: false,
1622
- report_on_no_match: selectionWait.ok === true,
1623
- no_match_reason: "no_selector_matched_after_job_selection",
1624
- no_match_confidence: 0.9,
1625
- validation_context: {
1626
- selected_job: {
1627
- value: targetJob.value || null,
1628
- title: targetJob.title || null,
1629
- label: targetJob.label || null
1630
- },
1631
- selection_applied: selectionWait.ok === true,
1632
- selected_label: selectionWait.state?.selected_label || null
1633
- },
1634
- skipped: false,
1635
- matches: Array.isArray(labelResponse?.matches) ? labelResponse.matches : []
1636
- });
1637
-
1638
- if (!selectionWait.ok) {
1639
- extraDrifts.push({
1640
- kind: "validation",
1641
- rule_id: "job_selected_label_activation",
1642
- path: ["frame", "job_selected_label"],
1643
- reason: "job_selection_not_applied",
1644
- confidence: 0.9,
1645
- auto_repairable: false
1646
- });
1647
- }
1648
-
1649
- if (currentJob && (normalizeText(currentJob.value || currentJob.title || currentJob.label || "") !== normalizeText(targetJob.value || targetJob.title || targetJob.label || ""))) {
1650
- const restoreClick = await evaluate(client, buildJobSelectExpression(currentJob));
1651
- if (restoreClick?.ok) {
1652
- await waitForJobSelected(client, currentJob, 8);
1653
- }
1654
- }
1655
- }
1656
-
1657
- async function closeDetailSurface(client, maxRetries = 3) {
1658
- for (let retry = 0; retry < maxRetries; retry += 1) {
1659
- const closedBefore = await evaluate(client, buildDetailClosedStateExpression());
1660
- if (closedBefore?.closed) return { ok: true, method: "already_closed", state: closedBefore };
1661
-
1662
- const ackProbe = await evaluate(client, buildAckButtonExpression());
1663
- if (ackProbe?.ok) {
1664
- const ackClick = await evaluate(client, buildAckButtonClickExpression());
1665
- if (ackClick?.ok) {
1666
- await new Promise((resolve) => setTimeout(resolve, 350));
1667
- }
1668
- }
1669
-
1670
- const closeAttempt = await evaluate(client, buildDetailCloseExpression());
1671
- if (closeAttempt?.ok) {
1672
- await new Promise((resolve) => setTimeout(resolve, 400));
1673
- }
1674
-
1675
- const closedAfter = await evaluate(client, buildDetailClosedStateExpression());
1676
- if (closedAfter?.closed) {
1677
- return { ok: true, method: closeAttempt?.ok ? "close_button" : (ackProbe?.ok ? "ack_button" : "post_check"), state: closedAfter };
1678
- }
1679
-
1680
- try {
1681
- await client.Input.dispatchKeyEvent({ type: "keyDown", windowsVirtualKeyCode: 27, key: "Escape", code: "Escape" });
1682
- await client.Input.dispatchKeyEvent({ type: "keyUp", windowsVirtualKeyCode: 27, key: "Escape", code: "Escape" });
1683
- } catch {}
1684
- await new Promise((resolve) => setTimeout(resolve, 350));
1685
- const closedAfterEsc = await evaluate(client, buildDetailClosedStateExpression());
1686
- if (closedAfterEsc?.closed) return { ok: true, method: "escape", state: closedAfterEsc };
1687
- }
1688
- return { ok: false, state: await evaluate(client, buildDetailClosedStateExpression()) };
1689
- }
1690
-
1691
- function extractObservedNetworkMatch(observedValues = [], patterns = []) {
1692
- for (const observedValue of observedValues) {
1693
- const matchedPattern = findFirstMatchingPattern(observedValue, patterns);
1694
- if (matchedPattern) {
1695
- return { observed_value: observedValue, matched_pattern: matchedPattern };
1696
- }
1697
- }
1698
- return null;
1699
- }
1700
-
1701
- function createNetworkCheck(ruleId, path, patterns, observedItems = [], confidence = 0.98) {
1702
- const observedValues = observedItems.map((item) => item.match_target).filter(Boolean);
1703
- const match = extractObservedNetworkMatch(observedValues, patterns);
1704
- if (match) {
1705
- return {
1706
- rule_id: ruleId,
1707
- path,
1708
- ok: patterns[0] === match.matched_pattern,
1709
- reason: patterns[0] === match.matched_pattern ? "matched_primary_pattern" : "matched_fallback_pattern",
1710
- confidence,
1711
- patterns,
1712
- matched_pattern: match.matched_pattern,
1713
- observed_value: match.observed_value,
1714
- observed_items: observedItems
1715
- };
1716
- }
1717
- return {
1718
- rule_id: ruleId,
1719
- path,
1720
- ok: false,
1721
- reason: observedItems.length > 0 ? "observed_unmatched_network" : "network_signal_missing",
1722
- confidence: observedItems.length > 0 ? 0.45 : 0.25,
1723
- patterns,
1724
- observed_items: observedItems,
1725
- observed_value: observedValues[0] || null
1726
- };
1727
- }
1728
-
1729
- async function scanRuntimeSurface({ workspaceRoot, args }) {
1730
- const port = resolveDebugPort(workspaceRoot, args);
1731
- const scope = normalizeScope(args.scope);
1732
- const validationProfile = normalizeValidationProfile(args.validation_profile);
1733
- const selectorDefinitions = getSelectorScanDefinitions(scope);
1734
- const deferredRuleIds = new Set([
1735
- "filter_panel",
1736
- "filter_confirm_button",
1737
- "filter_confirm_candidates",
1738
- "filter_group_container",
1739
- "filter_group_school",
1740
- "filter_group_degree",
1741
- "filter_group_gender",
1742
- "filter_group_recent_not_view",
1743
- "filter_option",
1744
- "filter_option_all",
1745
- "filter_option_active",
1746
- "filter_scroll_container",
1747
- "recommend_cards",
1748
- "recommend_card_inner",
1749
- "latest_cards",
1750
- "latest_card_inner",
1751
- "featured_cards",
1752
- "featured_card_anchor",
1753
- "job_list_items",
1754
- "job_item_label",
1755
- "job_search_input",
1756
- "job_selected_label"
1757
- ]);
1758
- const selectorChecks = [];
1759
- const networkEvents = [];
1760
- const extraDrifts = [];
1761
- const sideEffectSummary = {
1762
- opened_candidate_detail: false,
1763
- detail_opened_tabs: [],
1764
- toggled_favorite_twice: false,
1765
- triggered_greet: false,
1766
- favorite_validation_attempted: false,
1767
- favorite_validation_completed: false,
1768
- greet_validation_attempted: false,
1769
- greet_validation_completed: false,
1770
- detail_popup_closed: false,
1771
- detail_popup_closed_tabs: []
1772
- };
1773
- let client = null;
1774
- try {
1775
- const target = await pickRecommendTarget(port);
1776
- if (!target) {
1777
- return {
1778
- selector_checks: [],
1779
- network_checks: [],
1780
- side_effect_summary: sideEffectSummary,
1781
- extra_drifts: [
1782
- {
1783
- kind: "page",
1784
- rule_id: "recommend_page_target",
1785
- reason: "boss_recommend_tab_not_found",
1786
- confidence: 0.2,
1787
- auto_repairable: false
1788
- }
1789
- ]
1790
- };
1791
- }
1792
-
1793
- client = await CDP({ port, target });
1794
- const { Runtime, Page, Network } = client;
1795
- await Runtime.enable();
1796
- await Page.enable();
1797
- if (Network && typeof Network.enable === "function") {
1798
- await Network.enable();
1799
- if (typeof Network.requestWillBeSent === "function") {
1800
- Network.requestWillBeSent((params) => {
1801
- const url = normalizeText(params?.request?.url || "");
1802
- const postData = normalizeText(params?.request?.postData || "");
1803
- const payload = `${url} ${postData}`.trim();
1804
- networkEvents.push({
1805
- kind: "request",
1806
- ts: Date.now(),
1807
- url,
1808
- postData,
1809
- match_target: payload || url
1810
- });
1811
- });
1812
- }
1813
- if (typeof Network.webSocketFrameSent === "function") {
1814
- Network.webSocketFrameSent((params) => {
1815
- const payload = normalizeText(params?.response?.payloadData || "");
1816
- if (!payload) return;
1817
- networkEvents.push({
1818
- kind: "websocket_sent",
1819
- ts: Date.now(),
1820
- url: "",
1821
- postData: payload,
1822
- match_target: payload
1823
- });
1824
- });
1825
- }
1826
- if (typeof Network.webSocketFrameReceived === "function") {
1827
- Network.webSocketFrameReceived((params) => {
1828
- const payload = normalizeText(params?.response?.payloadData || "");
1829
- if (!payload) return;
1830
- networkEvents.push({
1831
- kind: "websocket_received",
1832
- ts: Date.now(),
1833
- url: "",
1834
- postData: payload,
1835
- match_target: payload
1836
- });
1837
- });
1838
- }
1839
- }
1840
-
1841
- for (const definition of selectorDefinitions.filter((item) => item.root !== "detail" && !deferredRuleIds.has(item.rule_id))) {
1842
- const selectors = getRecommendSelectorRule(definition.path);
1843
- const response = await evaluate(client, buildSelectorCheckExpression(selectors, definition.root));
1844
- selectorChecks.push({
1845
- rule_id: definition.rule_id,
1846
- path: definition.path,
1847
- root: definition.root,
1848
- required: definition.required === true,
1849
- report_on_no_match: definition.report_on_no_match === true,
1850
- skipped: response?.error === "ROOT_NOT_AVAILABLE",
1851
- matches: Array.isArray(response?.matches) ? response.matches : []
1852
- });
1853
- }
1854
-
1855
- const initialTabState = await evaluate(client, buildRecommendTabStateExpression());
1856
- const originalTabStatus = normalizeText(initialTabState?.active_status || "") || "0";
1857
- await validateFilterScopedSelectors(client, selectorChecks, extraDrifts);
1858
- await validateTabScopedCardSelectors(client, selectorChecks, extraDrifts, originalTabStatus);
1859
- await validateJobScopedSelectors(client, selectorChecks, extraDrifts);
1860
- await waitForCandidateCountStable(client, originalTabStatus, 10);
1861
- const networkChecks = [];
1862
- const resumePatterns = getRecommendNetworkRule(["resume", "info_url_patterns"], []);
1863
- const resumeKeywords = getRecommendNetworkRule(["resume", "related_keywords"], []);
1864
- let fullActionCompleted = false;
1865
- let detailCloseFailed = false;
1866
- const detailTabValidations = [
1867
- { tab_name: "recommend", tab_status: "0" },
1868
- { tab_name: "latest", tab_status: "1" },
1869
- { tab_name: "featured", tab_status: "3" }
1870
- ];
1871
- for (const tabValidation of detailTabValidations) {
1872
- const activation = await ensureRecommendTabActive(client, tabValidation.tab_status);
1873
- if (!activation?.ok) {
1874
- extraDrifts.push({
1875
- kind: "validation",
1876
- rule_id: "detail_tab_activation",
1877
- path: ["frame", "tab_items"],
1878
- reason: "detail_tab_activation_failed",
1879
- confidence: 0.9,
1880
- auto_repairable: false,
1881
- details: {
1882
- tab_name: tabValidation.tab_name,
1883
- tab_status: tabValidation.tab_status,
1884
- activation: activation?.click_result || activation?.after || null
1885
- }
1886
- });
1887
- continue;
1888
- }
1889
- const stableCount = await waitForCandidateCountStable(client, tabValidation.tab_status, 10);
1890
- const tabCandidateCount = tabValidation.tab_status === "0"
1891
- ? Number(stableCount?.recommendCandidateCount || 0)
1892
- : tabValidation.tab_status === "1"
1893
- ? Number(stableCount?.latestCandidateCount || 0)
1894
- : Number(stableCount?.featuredCandidateCount || 0);
1895
- const tabSurfaceState = await evaluate(client, buildCandidateSurfaceStateExpression());
1896
- const tabOuterCount = tabValidation.tab_status === "0"
1897
- ? Number(tabSurfaceState?.counts?.recommend || 0)
1898
- : tabValidation.tab_status === "1"
1899
- ? Number(tabSurfaceState?.counts?.latest || 0)
1900
- : Number(tabSurfaceState?.counts?.featured || 0);
1901
- const effectiveTabCount = Math.max(tabCandidateCount, tabOuterCount);
1902
- if (!(effectiveTabCount > 0)) {
1903
- extraDrifts.push({
1904
- kind: "validation",
1905
- rule_id: "detail_tab_candidate_presence",
1906
- path: ["frame", "recommend_cards"],
1907
- reason: "detail_tab_has_no_candidate_surface",
1908
- confidence: 0.72,
1909
- auto_repairable: false,
1910
- details: {
1911
- tab_name: tabValidation.tab_name,
1912
- tab_status: tabValidation.tab_status,
1913
- candidate_count: tabCandidateCount,
1914
- outer_count: tabOuterCount
1915
- }
1916
- });
1917
- continue;
1918
- }
1919
-
1920
- const openCandidateResult = await evaluate(client, buildDetailOpenExpression());
1921
- if (!openCandidateResult?.ok) {
1922
- extraDrifts.push({
1923
- kind: "validation",
1924
- rule_id: "detail_open_validation",
1925
- path: ["detail", "popup"],
1926
- reason: "candidate_detail_open_click_failed",
1927
- confidence: 0.92,
1928
- auto_repairable: false,
1929
- details: {
1930
- tab_name: tabValidation.tab_name,
1931
- tab_status: tabValidation.tab_status,
1932
- error: openCandidateResult?.error || "TARGET_NOT_FOUND"
1933
- }
1934
- });
1935
- continue;
1936
- }
1937
- sideEffectSummary.opened_candidate_detail = true;
1938
- if (!sideEffectSummary.detail_opened_tabs.includes(tabValidation.tab_name)) {
1939
- sideEffectSummary.detail_opened_tabs.push(tabValidation.tab_name);
1940
- }
1941
- await new Promise((resolve) => setTimeout(resolve, 1800));
1942
-
1943
- const detailState = await evaluate(client, buildDetailStateExpression());
1944
- if (!detailState?.open) {
1945
- extraDrifts.push({
1946
- kind: "validation",
1947
- rule_id: "detail_open_validation",
1948
- path: ["detail", "popup"],
1949
- reason: "candidate_detail_not_detected_after_click",
1950
- confidence: 0.9,
1951
- auto_repairable: false,
1952
- details: {
1953
- tab_name: tabValidation.tab_name,
1954
- tab_status: tabValidation.tab_status
1955
- }
1956
- });
1957
- continue;
1958
- }
1959
-
1960
- for (const definition of selectorDefinitions.filter((item) => item.root === "detail")) {
1961
- if (definition.rule_id === "detail_ack_button") continue;
1962
- let reportOnNoMatch = definition.report_on_no_match === true;
1963
- if (definition.rule_id === "detail_greet_button_recommend") {
1964
- reportOnNoMatch = tabValidation.tab_status !== "3";
1965
- }
1966
- if (definition.rule_id === "detail_greet_button_featured") {
1967
- reportOnNoMatch = tabValidation.tab_status === "3";
1968
- }
1969
- const selectors = getRecommendSelectorRule(definition.path);
1970
- const response = await evaluate(client, buildSelectorCheckExpression(selectors, definition.root));
1971
- selectorChecks.push({
1972
- rule_id: definition.rule_id,
1973
- path: definition.path,
1974
- root: definition.root,
1975
- required: definition.required === true,
1976
- report_on_no_match: reportOnNoMatch,
1977
- validation_context: {
1978
- tab_name: tabValidation.tab_name,
1979
- tab_status: tabValidation.tab_status
1980
- },
1981
- skipped: false,
1982
- matches: Array.isArray(response?.matches) ? response.matches : []
1983
- });
1984
- }
1985
-
1986
- if (validationProfile === PROFILE_FULL && !fullActionCompleted) {
1987
- const favoriteSelectors = getRecommendSelectorRule(["detail", "favorite_button"]);
1988
- sideEffectSummary.favorite_validation_attempted = true;
1989
- const favoriteProbe = await evaluate(client, buildActionButtonExpression(favoriteSelectors));
1990
- if (favoriteProbe?.ok) {
1991
- const favoriteStartedAt = Date.now();
1992
- const firstClick = await evaluate(client, buildActionClickExpression(favoriteSelectors));
1993
- await new Promise((resolve) => setTimeout(resolve, 1200));
1994
- const secondClick = await evaluate(client, buildActionClickExpression(favoriteSelectors));
1995
- await new Promise((resolve) => setTimeout(resolve, 1200));
1996
- sideEffectSummary.toggled_favorite_twice = firstClick?.ok === true && secondClick?.ok === true;
1997
- sideEffectSummary.favorite_validation_completed = sideEffectSummary.toggled_favorite_twice;
1998
- if (!sideEffectSummary.toggled_favorite_twice) {
1999
- extraDrifts.push({
2000
- kind: "validation",
2001
- rule_id: "favorite_roundtrip_validation",
2002
- path: ["detail", "favorite_button"],
2003
- reason: "favorite_toggle_roundtrip_incomplete",
2004
- confidence: 0.92,
2005
- auto_repairable: false,
2006
- details: {
2007
- tab_name: tabValidation.tab_name,
2008
- tab_status: tabValidation.tab_status,
2009
- first_click_ok: firstClick?.ok === true,
2010
- second_click_ok: secondClick?.ok === true
2011
- }
2012
- });
2013
- }
2014
- const favoriteObserved = networkEvents.filter((item) => Number(item.ts || 0) >= favoriteStartedAt);
2015
- networkChecks.push(createNetworkCheck("favorite_request_add", ["favorite", "add_patterns"], getRecommendNetworkRule(["favorite", "add_patterns"], []), favoriteObserved, 0.95));
2016
- networkChecks.push(createNetworkCheck("favorite_request_remove", ["favorite", "remove_patterns"], getRecommendNetworkRule(["favorite", "remove_patterns"], []), favoriteObserved, 0.95));
2017
- } else {
2018
- extraDrifts.push({
2019
- kind: "validation",
2020
- rule_id: "favorite_roundtrip_validation",
2021
- path: ["detail", "favorite_button"],
2022
- reason: "favorite_button_not_found_for_full_validation",
2023
- confidence: 0.9,
2024
- auto_repairable: false,
2025
- details: {
2026
- tab_name: tabValidation.tab_name,
2027
- tab_status: tabValidation.tab_status
2028
- }
2029
- });
2030
- }
2031
-
2032
- const greetSelectors = [
2033
- ...getRecommendSelectorRule(["detail", "greet_button_recommend"]),
2034
- ...getRecommendSelectorRule(["detail", "greet_button_featured"])
2035
- ];
2036
- sideEffectSummary.greet_validation_attempted = true;
2037
- const greetProbe = await evaluate(client, buildActionButtonExpression(greetSelectors));
2038
- if (greetProbe?.ok) {
2039
- const greetStartedAt = Date.now();
2040
- const greetClick = await evaluate(client, buildActionClickExpression(greetSelectors));
2041
- await new Promise((resolve) => setTimeout(resolve, 1600));
2042
- sideEffectSummary.triggered_greet = greetClick?.ok === true;
2043
- sideEffectSummary.greet_validation_completed = sideEffectSummary.triggered_greet;
2044
- const ackResponse = await evaluate(client, buildSelectorCheckExpression(getRecommendSelectorRule(["detail", "ack_button"]), "detail"));
2045
- selectorChecks.push({
2046
- rule_id: "detail_ack_button",
2047
- path: ["detail", "ack_button"],
2048
- root: "detail",
2049
- required: false,
2050
- report_on_no_match: false,
2051
- validation_context: {
2052
- tab_name: tabValidation.tab_name,
2053
- tab_status: tabValidation.tab_status,
2054
- greet_triggered: sideEffectSummary.triggered_greet
2055
- },
2056
- skipped: false,
2057
- matches: Array.isArray(ackResponse?.matches) ? ackResponse.matches : []
2058
- });
2059
- if (!sideEffectSummary.triggered_greet) {
2060
- extraDrifts.push({
2061
- kind: "validation",
2062
- rule_id: "greet_validation",
2063
- path: ["detail", "greet_button_recommend"],
2064
- reason: "greet_click_failed_during_full_validation",
2065
- confidence: 0.92,
2066
- auto_repairable: false,
2067
- details: {
2068
- tab_name: tabValidation.tab_name,
2069
- tab_status: tabValidation.tab_status
2070
- }
2071
- });
2072
- }
2073
- const greetObserved = networkEvents.filter((item) => Number(item.ts || 0) >= greetStartedAt);
2074
- networkChecks.push(createNetworkCheck("greet_request", ["greet", "url_patterns"], getRecommendNetworkRule(["greet", "url_patterns"], []), greetObserved, 0.92));
2075
- } else {
2076
- extraDrifts.push({
2077
- kind: "validation",
2078
- rule_id: "greet_validation",
2079
- path: ["detail", "greet_button_recommend"],
2080
- reason: "greet_button_not_found_for_full_validation",
2081
- confidence: 0.9,
2082
- auto_repairable: false,
2083
- details: {
2084
- tab_name: tabValidation.tab_name,
2085
- tab_status: tabValidation.tab_status
2086
- }
2087
- });
2088
- }
2089
- fullActionCompleted = true;
2090
- }
2091
-
2092
- const closeResult = await closeDetailSurface(client, 4);
2093
- if (closeResult?.ok) {
2094
- if (!sideEffectSummary.detail_popup_closed_tabs.includes(tabValidation.tab_name)) {
2095
- sideEffectSummary.detail_popup_closed_tabs.push(tabValidation.tab_name);
2096
- }
2097
- } else {
2098
- detailCloseFailed = true;
2099
- extraDrifts.push({
2100
- kind: "validation",
2101
- rule_id: "detail_close_validation",
2102
- path: ["detail", "close_button"],
2103
- reason: "detail_popup_not_closed_after_validation",
2104
- confidence: 0.93,
2105
- auto_repairable: false,
2106
- details: {
2107
- tab_name: tabValidation.tab_name,
2108
- tab_status: tabValidation.tab_status,
2109
- close_state: closeResult?.state || null
2110
- }
2111
- });
2112
- }
2113
- }
2114
-
2115
- if (validationProfile === PROFILE_FULL && !fullActionCompleted) {
2116
- extraDrifts.push({
2117
- kind: "validation",
2118
- rule_id: "full_validation_gate",
2119
- path: ["detail", "popup"],
2120
- reason: "full_validation_skipped_because_no_detail_open_on_any_tab",
2121
- confidence: 0.9,
2122
- auto_repairable: false
2123
- });
2124
- }
2125
- sideEffectSummary.detail_popup_closed = sideEffectSummary.detail_popup_closed_tabs.length > 0 && !detailCloseFailed;
2126
- const resumeObserved = networkEvents.filter((item) => {
2127
- const matchTarget = normalizeText(item.match_target || "").toLowerCase();
2128
- return matchTarget.includes("/wapi/") && resumeKeywords.some((keyword) => matchTarget.includes(String(keyword).toLowerCase()));
2129
- });
2130
- networkChecks.push(createNetworkCheck("resume_info_request", ["resume", "info_url_patterns"], resumePatterns, resumeObserved, 0.97));
2131
- if (originalTabStatus) {
2132
- await ensureRecommendTabActive(client, originalTabStatus);
2133
- }
2134
-
2135
- return {
2136
- selector_checks: selectorChecks,
2137
- network_checks: networkChecks,
2138
- side_effect_summary: sideEffectSummary,
2139
- extra_drifts: extraDrifts
2140
- };
2141
- } finally {
2142
- if (client) {
2143
- try {
2144
- await client.close();
2145
- } catch {}
2146
- }
2147
- }
2148
- }
2149
-
2150
- const defaultDependencies = { scanRuntimeSurface };
2151
-
2152
- export async function runRecommendSelfHeal({ workspaceRoot, args = {} }, dependencies = defaultDependencies) {
2153
- const normalizedArgs = {
2154
- mode: normalizeMode(args.mode),
2155
- scope: normalizeScope(args.scope),
2156
- validation_profile: normalizeValidationProfile(args.validation_profile),
2157
- port: args.port,
2158
- repair_session_id: normalizeText(args.repair_session_id || "") || null,
2159
- confirm_apply: args.confirm_apply === true
2160
- };
2161
-
2162
- if (normalizedArgs.mode === MODE_APPLY) {
2163
- if (!normalizedArgs.repair_session_id) {
2164
- return {
2165
- status: "FAILED",
2166
- error: {
2167
- code: "SELF_HEAL_SESSION_REQUIRED",
2168
- message: "apply 模式必须提供 repair_session_id。",
2169
- retryable: false
2170
- }
2171
- };
2172
- }
2173
- return applyRepairSession(normalizedArgs.repair_session_id, normalizedArgs.confirm_apply);
2174
- }
2175
-
2176
- const scanResult = await dependencies.scanRuntimeSurface({ workspaceRoot, args: normalizedArgs });
2177
- const selectorChecks = Array.isArray(scanResult?.selector_checks) ? scanResult.selector_checks : [];
2178
- const networkChecks = Array.isArray(scanResult?.network_checks) ? scanResult.network_checks : [];
2179
- const selectorDrifts = analyzeSelectorChecks(selectorChecks);
2180
- const networkDrifts = analyzeNetworkChecks(networkChecks);
2181
- const extraDrifts = Array.isArray(scanResult?.extra_drifts) ? scanResult.extra_drifts : [];
2182
- const drifts = [...selectorDrifts, ...networkDrifts, ...extraDrifts];
2183
- if (drifts.length === 0) {
2184
- return {
2185
- status: "HEALTHY",
2186
- scope: normalizedArgs.scope,
2187
- validation_profile: normalizedArgs.validation_profile,
2188
- rules_path: getRecommendHealingRulesPath(),
2189
- selector_checks: selectorChecks,
2190
- network_checks: networkChecks,
2191
- side_effect_summary: scanResult?.side_effect_summary || null,
2192
- message: "未发现 selector / network 规则漂移。"
2193
- };
2194
- }
2195
- const proposedRepairs = dedupeRepairs(
2196
- drifts
2197
- .map((item) => item?.proposed_repair || null)
2198
- .filter((item) => item && Number(item.confidence || 0) >= 0.9)
2199
- );
2200
- const session = createRepairSession({
2201
- args: normalizedArgs,
2202
- selectorChecks,
2203
- networkChecks,
2204
- drifts,
2205
- proposedRepairs
2206
- });
2207
- return {
2208
- status: "NEED_CONFIRMATION",
2209
- scope: normalizedArgs.scope,
2210
- validation_profile: normalizedArgs.validation_profile,
2211
- rules_path: getRecommendHealingRulesPath(),
2212
- repair_session_id: session.repair_session_id,
2213
- selector_checks: selectorChecks,
2214
- network_checks: networkChecks,
2215
- drifts,
2216
- proposed_repairs: proposedRepairs,
2217
- side_effect_summary: scanResult?.side_effect_summary || null,
2218
- message: proposedRepairs.length > 0
2219
- ? "检测到可修复的 selector / network 漂移,请确认是否应用修复。"
2220
- : "检测到漂移,但当前没有高置信度自动修复项。"
2221
- };
2222
- }
2223
-
2224
- export const __testables = {
2225
- analyzeSelectorChecks,
2226
- analyzeNetworkChecks,
2227
- applyRepairSession,
2228
- applyRepairToRules,
2229
- createNetworkCheck,
2230
- createRepairSession,
2231
- getSelfHealSessionsDir,
2232
- normalizeMode,
2233
- normalizeScope,
2234
- normalizeValidationProfile,
2235
- resolveDebugPort,
2236
- scanRuntimeSurface
2237
- };