@reconcrap/boss-recommend-mcp 2.0.53 → 2.0.54

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.
@@ -1,1668 +1,1853 @@
1
- import {
2
- clearFocusedInput,
3
- clickNodeCenter,
4
- clickPoint,
5
- DETERMINISTIC_CLICK_OPTIONS,
6
- getFrameDocumentNodeId,
7
- getAttributesMap,
8
- getNodeBox,
9
- getOuterHTML,
10
- insertText,
11
- pressKey,
12
- querySelectorAll,
13
- sleep
14
- } from "../../core/browser/index.js";
15
- import {
16
- buildScreeningCandidateFromDetail,
17
- htmlToText
18
- } from "../../core/screening/index.js";
19
- import {
20
- CHAT_ACTIVE_CANDIDATE_SELECTORS,
21
- CHAT_ASK_RESUME_BUTTON_SELECTORS,
22
- CHAT_ATTACHMENT_RESUME_BUTTON_SELECTORS,
23
- CHAT_CONFIRM_REQUEST_RESUME_SELECTORS,
24
- CHAT_EDITOR_SELECTORS,
25
- CHAT_MESSAGE_FILTER_SELECTORS,
26
- CHAT_MESSAGE_LIST_SELECTORS,
27
- CHAT_ONLINE_RESUME_BUTTON_SELECTORS,
28
- CHAT_PRIMARY_LABEL_SELECTORS,
29
- CHAT_PROFILE_NETWORK_PATTERNS,
30
- CHAT_RESUME_CLOSE_SELECTORS,
31
- CHAT_RESUME_CONTENT_SELECTORS,
32
- CHAT_RESUME_FAST_MODAL_SELECTORS,
33
- CHAT_RESUME_IFRAME_SELECTORS,
34
- CHAT_RESUME_MODAL_SELECTORS,
35
- CHAT_SEND_BUTTON_SELECTORS
36
- } from "./constants.js";
37
- import {
38
- getChatRoots,
39
- queryFirstAcrossChatRoots
40
- } from "./roots.js";
41
- import {
42
- assertChatShellNotResumeTopLevel,
43
- getChatTopLevelState,
44
- isForbiddenChatResumeTopLevelUrl,
45
- makeForbiddenChatResumeNavigationError
46
- } from "./page-guard.js";
47
-
48
- export const CHAT_UNSAFE_ONLINE_RESUME_LINK_CODE = "CHAT_UNSAFE_ONLINE_RESUME_LINK";
49
-
50
- const CHAT_CONVERSATION_CONTROL_SCOPE_SELECTORS = Object.freeze([
51
- ".conversation-main",
52
- ".conversation-editor",
53
- ".chat-message-list",
54
- ".toolbar-box-right",
55
- ".operate-exchange-left",
56
- ".operate-icon-item",
57
- ".exchange-tooltip",
58
- ".boss-popup__wrapper",
59
- ".boss-dialog",
60
- ".dialog-wrap.active",
61
- ".geek-detail-modal"
62
- ]);
63
-
64
- const CHAT_REQUESTED_RESUME_SCOPE_SELECTORS = Object.freeze([
65
- ".chat-message-list",
66
- ".conversation-editor",
67
- ".conversation-main",
68
- ".toolbar-box-right",
69
- ".operate-exchange-left",
70
- ".operate-icon-item",
71
- ".exchange-tooltip",
72
- ".boss-popup__wrapper",
73
- ".boss-dialog",
74
- ".dialog-wrap.active"
75
- ]);
76
-
77
- export function matchesChatProfileNetwork(url) {
78
- return CHAT_PROFILE_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
79
- }
80
-
81
- function looksLikeForbiddenChatResumePath(value = "") {
82
- const normalized = String(value || "");
83
- return isForbiddenChatResumeTopLevelUrl(normalized)
84
- || /(?:^|["'\s=])(?:https?:\/\/[^"'\s>]*zhipin\.com)?\/web\/frame\/c-resume(?:[/?#"' >]|$)/i
85
- .test(normalized);
86
- }
87
-
88
- function extractFirstHtmlAttribute(html = "", names = []) {
89
- const source = String(html || "");
90
- for (const name of names) {
91
- const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92
- const regex = new RegExp(`${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i");
93
- const match = source.match(regex);
94
- if (match) return match[1] ?? match[2] ?? match[3] ?? "";
95
- }
96
- return "";
97
- }
98
-
99
- export function isUnsafeChatOnlineResumeTarget(target = {}, buttonHTML = "") {
100
- const attributes = target?.attributes || {};
101
- const href = attributes.href
102
- || attributes["data-href"]
103
- || attributes["data-url"]
104
- || attributes.url
105
- || extractFirstHtmlAttribute(buttonHTML, ["href", "data-href", "data-url", "url"]);
106
- return looksLikeForbiddenChatResumePath(href)
107
- || looksLikeForbiddenChatResumePath(buttonHTML);
108
- }
109
-
110
- export function makeUnsafeChatOnlineResumeLinkError(target = {}, buttonHTML = "") {
111
- const href = target?.attributes?.href
112
- || target?.attributes?.["data-href"]
113
- || target?.attributes?.["data-url"]
114
- || extractFirstHtmlAttribute(buttonHTML, ["href", "data-href", "data-url", "url"])
115
- || null;
116
- const error = new Error("CHAT_UNSAFE_ONLINE_RESUME_LINK: refusing to click an online resume link that can navigate the chat tab to /web/frame/c-resume/");
117
- error.code = CHAT_UNSAFE_ONLINE_RESUME_LINK_CODE;
118
- error.href = href;
119
- error.button_selector = target?.selector || null;
120
- error.button_text = htmlToText(buttonHTML).slice(0, 120);
121
- error.button_html_length = String(buttonHTML || "").length;
122
- return error;
123
- }
124
-
125
- export function isUnsafeChatOnlineResumeLinkError(error) {
126
- return error?.code === CHAT_UNSAFE_ONLINE_RESUME_LINK_CODE
127
- || /CHAT_UNSAFE_ONLINE_RESUME_LINK/i.test(String(error?.message || error || ""));
128
- }
129
-
130
- export function createChatProfileNetworkRecorder(client) {
131
- const events = [];
132
- client.Network.responseReceived((event) => {
133
- const url = event?.response?.url || "";
134
- if (!matchesChatProfileNetwork(url)) return;
135
- events.push({
136
- requestId: event.requestId,
137
- url,
138
- status: event.response?.status,
139
- mimeType: event.response?.mimeType,
140
- type: event.type
141
- });
142
- });
143
- if (typeof client.Network.loadingFinished === "function") {
144
- client.Network.loadingFinished((event) => {
145
- const found = events.find((item) => item.requestId === event.requestId);
146
- if (!found) return;
147
- found.loading_finished = true;
148
- found.encodedDataLength = event.encodedDataLength;
149
- });
150
- }
151
- if (typeof client.Network.loadingFailed === "function") {
152
- client.Network.loadingFailed((event) => {
153
- const found = events.find((item) => item.requestId === event.requestId);
154
- if (!found) return;
155
- found.loading_failed = true;
156
- found.loading_error = event.errorText || event.blockedReason || "Network loading failed";
157
- });
158
- }
159
- return {
160
- events,
161
- clear() {
162
- events.length = 0;
163
- }
164
- };
165
- }
166
-
167
- export async function waitForChatProfileNetworkEvents(recorder, {
168
- minCount = 1,
169
- requireLoaded = true,
170
- timeoutMs = 8000,
171
- intervalMs = 120
172
- } = {}) {
173
- const started = Date.now();
174
- const events = Array.isArray(recorder) ? recorder : recorder?.events || [];
175
- let matching = [];
176
- while (Date.now() - started <= timeoutMs) {
177
- matching = events.filter((event) => (
178
- !requireLoaded
179
- || event.loading_finished === true
180
- || event.loading_failed === true
181
- ));
182
- if (matching.length >= minCount) {
183
- return {
184
- ok: true,
185
- elapsed_ms: Date.now() - started,
186
- count: matching.length,
187
- events: matching
188
- };
189
- }
190
- await sleep(intervalMs);
191
- }
192
- return {
193
- ok: false,
194
- elapsed_ms: Date.now() - started,
195
- count: matching.length,
196
- events: matching,
197
- total_event_count: events.length
198
- };
199
- }
200
-
201
- export async function readChatProfileNetworkBodies(client, events = [], {
202
- limit = 20
203
- } = {}) {
204
- const bodies = [];
205
- for (const event of events.slice(0, limit)) {
206
- try {
207
- const body = await client.Network.getResponseBody({ requestId: event.requestId });
208
- bodies.push({
209
- ...event,
210
- body,
211
- body_length: String(body?.body || "").length
212
- });
213
- } catch (error) {
214
- bodies.push({
215
- ...event,
216
- body_error: error?.message || String(error)
217
- });
218
- }
219
- }
220
- return bodies;
221
- }
222
-
223
- function normalizeDetailText(value = "") {
224
- return String(value || "").replace(/\s+/g, " ").trim();
225
- }
226
-
227
- function chatCandidateIdFromAttributes(attributes = {}) {
228
- return normalizeDetailText(
229
- attributes["data-id"]
230
- || attributes["data-geekid"]
231
- || attributes["data-geek"]
232
- || attributes["data-uid"]
233
- || attributes.key
234
- || attributes.id
235
- || ""
236
- );
237
- }
238
-
239
- async function hydrateActiveChatCandidate(client, activeCandidate = null) {
240
- if (!activeCandidate?.node_id) return activeCandidate;
241
- let attributes = {};
242
- let outerHTML = "";
243
- try {
244
- [attributes, outerHTML] = await Promise.all([
245
- getAttributesMap(client, activeCandidate.node_id),
246
- getOuterHTML(client, activeCandidate.node_id)
247
- ]);
248
- } catch {}
249
- return {
250
- ...activeCandidate,
251
- attributes,
252
- candidate_id: chatCandidateIdFromAttributes(attributes) || null,
253
- label: normalizeDetailText(htmlToText(outerHTML)),
254
- outer_html_length: outerHTML.length
255
- };
256
- }
257
-
258
- export async function waitForChatOnlineResumeButton(client, {
259
- timeoutMs = 12000,
260
- intervalMs = 250,
261
- expectedCandidateId = ""
262
- } = {}) {
263
- const started = Date.now();
264
- let lastState = null;
265
- const expectedId = chatCandidateIdFromAttributes({ "data-id": expectedCandidateId });
266
- while (Date.now() - started <= timeoutMs) {
267
- const topLevelState = await getChatTopLevelState(client);
268
- if (topLevelState.is_forbidden_resume_top_level) {
269
- return {
270
- forbidden_top_level_navigation: true,
271
- top_level_state: topLevelState
272
- };
273
- }
274
- const rootState = await getChatRoots(client);
275
- const target = await findVisibleTarget(client, rootState.roots, CHAT_ONLINE_RESUME_BUTTON_SELECTORS);
276
- const activeCandidate = await hydrateActiveChatCandidate(
277
- client,
278
- await queryFirstAcrossChatRoots(client, rootState.roots, CHAT_ACTIVE_CANDIDATE_SELECTORS)
279
- );
280
- const activeCandidateId = activeCandidate?.candidate_id || "";
281
- const candidateSelectionVerified = expectedId
282
- ? activeCandidateId === expectedId
283
- : undefined;
284
- lastState = {
285
- roots: rootState.roots,
286
- target,
287
- activeCandidate,
288
- expected_candidate_id: expectedId || null,
289
- active_candidate_id: activeCandidateId || null,
290
- candidate_selection_verified: candidateSelectionVerified
291
- };
292
- if (target && (!expectedId || candidateSelectionVerified)) {
293
- return {
294
- ok: true,
295
- elapsed_ms: Date.now() - started,
296
- ...lastState
297
- };
298
- }
299
- await sleep(intervalMs);
300
- }
301
- return {
302
- ok: false,
303
- reason: expectedId && lastState?.candidate_selection_verified === false
304
- ? "active_candidate_mismatch"
305
- : "online_resume_button_unavailable",
306
- elapsed_ms: Date.now() - started,
307
- ...lastState
308
- };
309
- }
310
-
311
- export async function selectChatCandidate(client, cardNodeId, {
312
- timeoutMs = 12000,
313
- settleMs = 1200
314
- } = {}) {
315
- const cardBox = await clickNodeCenter(client, cardNodeId, {
316
- scrollIntoView: true
317
- });
318
- if (settleMs > 0) await sleep(settleMs);
319
- const ready = await waitForChatOnlineResumeButton(client, { timeoutMs });
320
- return {
321
- card_box: cardBox,
322
- ready
323
- };
324
- }
325
-
326
- function hasActiveSignal(attributes = {}, outerHTML = "") {
327
- return /\b(active|selected|current|curr)\b/i.test(String(attributes.class || ""))
328
- || normalizeDetailText(attributes["aria-selected"]).toLowerCase() === "true"
329
- || normalizeDetailText(attributes["data-active"]).toLowerCase() === "true"
330
- || /\b(active|selected|current|curr)\b/i.test(String(outerHTML || "").slice(0, 500));
331
- }
332
-
333
- function isDisabledSignal(attributes = {}, outerHTML = "") {
334
- return attributes.disabled !== undefined
335
- || normalizeDetailText(attributes["aria-disabled"]).toLowerCase() === "true"
336
- || /\b(disabled|disable|is-disabled)\b/i.test([
337
- attributes.class,
338
- String(outerHTML || "").slice(0, 500)
339
- ].join(" "));
340
- }
341
-
342
- function isAskResumeText(text = "") {
343
- const normalized = normalizeDetailText(text);
344
- return Boolean(
345
- normalized === "求简历"
346
- || normalized === "索要简历"
347
- || normalized === "求附件简历"
348
- || normalized.includes("求简历")
349
- || normalized.includes("索要简历")
350
- || normalized.includes("求附件简历")
351
- );
352
- }
353
-
354
- function isRequestedResumeText(text = "") {
355
- const normalized = normalizeDetailText(text);
356
- return Boolean(
357
- normalized === "已求简历"
358
- || normalized === "已索要简历"
359
- || normalized.includes("已求简历")
360
- || normalized.includes("已索要简历")
361
- || normalized.includes("简历请求已发送")
362
- || normalized.includes("已发送简历")
363
- || (normalized.includes("已申请") && normalized.includes("简历"))
364
- );
365
- }
366
-
367
- function isResumeRequestSentMessageText(text = "") {
368
- const normalized = normalizeDetailText(text);
369
- return Boolean(
370
- normalized.includes("简历请求已发送")
371
- || normalized.includes("已发送简历")
372
- || normalized.includes("已求简历")
373
- || normalized.includes("已索要简历")
374
- || normalized.includes("已发送")
375
- );
376
- }
377
-
378
- function countTextOccurrences(text = "", needle = "") {
379
- if (!needle) return 0;
380
- let count = 0;
381
- let index = 0;
382
- while (index < text.length) {
383
- const found = text.indexOf(needle, index);
384
- if (found < 0) break;
385
- count += 1;
386
- index = found + needle.length;
387
- }
388
- return count;
389
- }
390
-
391
- function countResumeRequestSentMessageMarkers(lines = []) {
392
- const markers = ["简历请求已发送", "已发送简历", "已求简历", "已索要简历", "已发送"];
393
- return lines.reduce((total, line) => (
394
- total + markers.reduce((lineTotal, marker) => (
395
- lineTotal + countTextOccurrences(line, marker)
396
- ), 0)
397
- ), 0);
398
- }
399
-
400
- function isResumeAttachmentMessageText(text = "") {
401
- const normalized = normalizeDetailText(text);
402
- return Boolean(
403
- /点击.*附件简历/.test(normalized)
404
- || /预览附件简历/.test(normalized)
405
- || /查看附件简历/.test(normalized)
406
- || /(?:简历|resume)[^\s]*\.(?:pdf|docx?|jpg|jpeg|png)\b/i.test(normalized)
407
- );
408
- }
409
-
410
- function countResumeAttachmentMessageMarkers(lines = []) {
411
- return lines.reduce((total, line) => total + (isResumeAttachmentMessageText(line) ? 1 : 0), 0);
412
- }
413
-
414
- function isRequestedResumeControlTarget(target = {}) {
415
- const label = normalizeDetailText(target.label);
416
- const className = String(target.attributes?.class || "");
417
- const selector = String(target.selector || "");
418
- const controlLike = /\boperate-btn\b|operate|resume|button|btn/i.test(`${selector} ${className}`);
419
- if (isRequestedResumeText(label)) return true;
420
- return controlLike && Boolean(
421
- label === "已申请"
422
- || label === "已发送"
423
- || label.includes("已申请")
424
- || label.includes("已发送")
425
- );
426
- }
427
-
428
- function isAttachmentResumeText(text = "") {
429
- const normalized = normalizeDetailText(text);
430
- return Boolean(
431
- normalized === "附件简历"
432
- || (normalized.includes("附件简历") && !normalized.includes("求附件简历"))
433
- );
434
- }
435
-
436
- function isAttachmentResumeTarget(target = {}) {
437
- return isAttachmentResumeText(target.label)
438
- || /resume-btn-file/i.test(String(target.attributes?.class || target.selector || ""));
439
- }
440
-
441
- function isConfirmText(text = "") {
442
- const normalized = normalizeDetailText(text);
443
- return Boolean(
444
- normalized === "确定"
445
- || normalized === "确认"
446
- || normalized === "提交"
447
- || normalized === "继续"
448
- || normalized.includes("确定")
449
- || normalized.includes("确认")
450
- );
451
- }
452
-
453
- function isSendText(text = "") {
454
- const normalized = normalizeDetailText(text);
455
- return normalized === "发送" || normalized.includes("发送");
456
- }
457
-
458
- function isRecoverableNodeError(error) {
459
- return /(?:Could not find node|No node with given id|Cannot find node|Could not compute box model)/i
460
- .test(String(error?.message || error || ""));
461
- }
462
-
463
- async function readTarget(client, root, selector, nodeId) {
464
- let attributes = {};
465
- let outerHTML = "";
466
- let readError = "";
467
- try {
468
- [attributes, outerHTML] = await Promise.all([
469
- getAttributesMap(client, nodeId),
470
- getOuterHTML(client, nodeId)
471
- ]);
472
- } catch (error) {
473
- readError = error?.message || String(error);
474
- }
475
- const label = normalizeDetailText(htmlToText(outerHTML));
476
- let box = null;
477
- try {
478
- box = await getNodeBox(client, nodeId);
479
- } catch {}
480
- return {
481
- root: root.name,
482
- root_node_id: root.nodeId,
483
- selector,
484
- node_id: nodeId,
485
- label,
486
- attributes,
487
- disabled: isDisabledSignal(attributes, outerHTML),
488
- active: hasActiveSignal(attributes, outerHTML),
489
- visible: Boolean(box && box.rect.width > 2 && box.rect.height > 2),
490
- center: box?.center || null,
491
- rect: box?.rect || null,
492
- outer_html_length: outerHTML.length,
493
- read_error: readError || null
494
- };
495
- }
496
-
497
- async function findVisibleMatchingTarget(client, roots, selectors, predicate) {
498
- for (const root of roots) {
499
- if (!root?.nodeId) continue;
500
- for (const selector of selectors) {
501
- const nodeIds = await querySelectorAll(client, root.nodeId, selector);
502
- for (const nodeId of nodeIds) {
503
- const target = await readTarget(client, root, selector, nodeId);
504
- if (!target.visible) continue;
505
- if (predicate(target)) return target;
506
- }
507
- }
508
- }
509
- return null;
510
- }
511
-
512
- async function resolveScopedRoots(client, roots = [], selectors = [], {
513
- fallbackToRoots = true
514
- } = {}) {
515
- const scoped = [];
516
- const seen = new Set();
517
- for (const root of roots) {
518
- if (!root?.nodeId) continue;
519
- for (const selector of selectors) {
520
- let nodeIds = [];
521
- try {
522
- nodeIds = await querySelectorAll(client, root.nodeId, selector);
523
- } catch {
524
- nodeIds = [];
525
- }
526
- for (const nodeId of nodeIds) {
527
- const key = `${root.name}:${nodeId}`;
528
- if (seen.has(key)) continue;
529
- seen.add(key);
530
- scoped.push({
531
- name: `${root.name}:${selector}`,
532
- nodeId
533
- });
534
- }
535
- }
536
- }
537
- if (scoped.length || !fallbackToRoots) return scoped;
538
- return roots;
539
- }
540
-
541
- export async function selectChatPrimaryLabel(client, {
542
- label = "全部",
543
- timeoutMs = 8000,
544
- intervalMs = 300,
545
- settleMs = 700
546
- } = {}) {
547
- const started = Date.now();
548
- let lastCandidates = [];
549
- while (Date.now() - started <= timeoutMs) {
550
- const rootState = await getChatRoots(client);
551
- const candidates = [];
552
- for (const root of rootState.roots) {
553
- for (const selector of CHAT_PRIMARY_LABEL_SELECTORS) {
554
- const nodeIds = await querySelectorAll(client, root.nodeId, selector);
555
- for (const nodeId of nodeIds) {
556
- const target = await readTarget(client, root, selector, nodeId);
557
- if (target.visible) candidates.push(target);
558
- }
559
- }
560
- }
561
- lastCandidates = candidates;
562
- const matched = candidates.find((target) => (
563
- target.label === label || target.label.startsWith(`${label}(`)
564
- ));
565
- if (matched?.active) {
566
- return {
567
- ok: true,
568
- changed: false,
569
- verified: true,
570
- active_label: matched.label,
571
- control: matched
572
- };
573
- }
574
- if (matched) {
575
- if (matched.center) {
576
- await clickPoint(client, matched.center.x, matched.center.y, DETERMINISTIC_CLICK_OPTIONS);
577
- } else {
578
- await clickNodeCenter(client, matched.node_id, {
579
- ...DETERMINISTIC_CLICK_OPTIONS,
580
- scrollIntoView: true
581
- });
582
- }
583
- if (settleMs > 0) await sleep(settleMs);
584
- return {
585
- ok: true,
586
- changed: true,
587
- verified: true,
588
- active_label: label,
589
- control: matched
590
- };
591
- }
592
- await sleep(intervalMs);
593
- }
594
- return {
595
- ok: false,
596
- error: `CHAT_PRIMARY_LABEL_NOT_FOUND:${label}`,
597
- candidates: lastCandidates.map((item) => ({
598
- label: item.label,
599
- selector: item.selector,
600
- active: item.active
601
- }))
602
- };
603
- }
604
-
605
- export async function selectChatMessageFilter(client, {
606
- startFrom = "unread",
607
- timeoutMs = 8000,
608
- intervalMs = 300,
609
- settleMs = 900
610
- } = {}) {
611
- const label = startFrom === "all" ? "全部" : "未读";
612
- const started = Date.now();
613
- let lastCandidates = [];
614
- while (Date.now() - started <= timeoutMs) {
615
- const rootState = await getChatRoots(client);
616
- const candidates = [];
617
- for (const root of rootState.roots) {
618
- for (const selector of CHAT_MESSAGE_FILTER_SELECTORS) {
619
- const nodeIds = await querySelectorAll(client, root.nodeId, selector);
620
- for (const nodeId of nodeIds) {
621
- const target = await readTarget(client, root, selector, nodeId);
622
- if (target.visible && target.label === label) candidates.push(target);
623
- }
624
- }
625
- }
626
- lastCandidates = candidates;
627
- const active = candidates.find((target) => target.active);
628
- if (active) {
629
- return {
630
- ok: true,
631
- changed: false,
632
- verified: true,
633
- active_label: active.label,
634
- control: active
635
- };
636
- }
637
- const matched = candidates[0];
638
- if (matched) {
639
- if (matched.center) {
640
- await clickPoint(client, matched.center.x, matched.center.y, DETERMINISTIC_CLICK_OPTIONS);
641
- } else {
642
- await clickNodeCenter(client, matched.node_id, {
643
- ...DETERMINISTIC_CLICK_OPTIONS,
644
- scrollIntoView: true
645
- });
646
- }
647
- if (settleMs > 0) await sleep(settleMs);
648
- return {
649
- ok: true,
650
- changed: true,
651
- verified: true,
652
- active_label: label,
653
- control: matched
654
- };
655
- }
656
- await sleep(intervalMs);
657
- }
658
- return {
659
- ok: false,
660
- error: `CHAT_MESSAGE_FILTER_NOT_FOUND:${label}`,
661
- candidates: lastCandidates.map((item) => ({
662
- label: item.label,
663
- selector: item.selector,
664
- active: item.active
665
- }))
666
- };
667
- }
668
-
669
- export async function waitForChatResumeModal(client, {
670
- timeoutMs = 12000,
671
- intervalMs = 250
672
- } = {}) {
673
- const started = Date.now();
674
- let lastState = null;
675
- while (Date.now() - started <= timeoutMs) {
676
- const topLevelState = await getChatTopLevelState(client);
677
- if (topLevelState.is_forbidden_resume_top_level) {
678
- return {
679
- forbidden_top_level_navigation: true,
680
- top_level_state: topLevelState
681
- };
682
- }
683
- const rootState = await getChatRoots(client);
684
- const popup = await findVisibleTarget(client, rootState.roots, CHAT_RESUME_MODAL_SELECTORS);
685
- const content = await findVisibleTarget(client, rootState.roots, CHAT_RESUME_CONTENT_SELECTORS);
686
- const resumeIframe = await findVisibleTarget(client, rootState.roots, CHAT_RESUME_IFRAME_SELECTORS);
687
- lastState = {
688
- roots: rootState.roots,
689
- popup,
690
- content,
691
- resumeIframe
692
- };
693
- if (popup || content || resumeIframe) return lastState;
694
- await sleep(intervalMs);
695
- }
696
- return lastState;
697
- }
698
-
699
- export async function quickChatResumeModalOpenProbe(client, {
700
- selectors = CHAT_RESUME_FAST_MODAL_SELECTORS
701
- } = {}) {
702
- const rootState = await getChatRoots(client);
703
- for (const root of rootState.roots) {
704
- if (!root?.nodeId) continue;
705
- for (const selector of selectors) {
706
- let nodeIds = [];
707
- try {
708
- nodeIds = await querySelectorAll(client, root.nodeId, selector);
709
- } catch {
710
- nodeIds = [];
711
- }
712
- for (const nodeId of nodeIds.slice(0, 4)) {
713
- try {
714
- const box = await getNodeBox(client, nodeId);
715
- if (box?.rect?.width > 8 && box?.rect?.height > 8) {
716
- return {
717
- open: true,
718
- root: root.name,
719
- selector,
720
- node_id: nodeId,
721
- rect: box.rect,
722
- center: box.center
723
- };
724
- }
725
- } catch {
726
- // Hidden or stale modal probes are ignored.
727
- }
728
- }
729
- }
730
- }
731
- return {
732
- open: false
733
- };
734
- }
735
-
736
- export async function readChatResumeHtml(client, resumeState) {
737
- let popupHTML = "";
738
- let contentHTML = "";
739
- let resumeIframeHTML = "";
740
- let resumeIframeDocumentNodeId = null;
741
-
742
- if (resumeState?.popup?.node_id) {
743
- popupHTML = await getOuterHTML(client, resumeState.popup.node_id);
744
- }
745
-
746
- if (resumeState?.content?.node_id && resumeState.content.node_id !== resumeState?.popup?.node_id) {
747
- contentHTML = await getOuterHTML(client, resumeState.content.node_id);
748
- }
749
-
750
- if (resumeState?.resumeIframe?.node_id) {
751
- resumeIframeDocumentNodeId = await getFrameDocumentNodeId(client, resumeState.resumeIframe.node_id);
752
- resumeIframeHTML = await getOuterHTML(client, resumeIframeDocumentNodeId);
753
- }
754
-
755
- return {
756
- popupHTML,
757
- contentHTML,
758
- resumeIframeHTML,
759
- resumeIframeDocumentNodeId,
760
- popupText: htmlToText(popupHTML),
761
- contentText: htmlToText(contentHTML),
762
- resumeIframeText: htmlToText(resumeIframeHTML)
763
- };
764
- }
765
-
766
- function emptyChatResumeHtml(readError = null) {
767
- return {
768
- popupHTML: "",
769
- contentHTML: "",
770
- resumeIframeHTML: "",
771
- resumeIframeDocumentNodeId: null,
772
- popupText: "",
773
- contentText: "",
774
- resumeIframeText: "",
775
- readError: readError?.message || null
776
- };
777
- }
778
-
779
- export async function waitForChatResumeContent(client, {
780
- minTextLength = 120,
781
- timeoutMs = 15000,
782
- intervalMs = 250
783
- } = {}) {
784
- const started = Date.now();
785
- let lastState = null;
786
- let lastHtml = null;
787
- let lastError = null;
788
- while (Date.now() - started <= timeoutMs) {
789
- try {
790
- lastState = await waitForChatResumeModal(client, {
791
- timeoutMs: 700,
792
- intervalMs: 100
793
- });
794
- if (lastState?.popup || lastState?.content || lastState?.resumeIframe) {
795
- lastHtml = await readChatResumeHtml(client, lastState);
796
- const textLength = [
797
- lastHtml.popupText,
798
- lastHtml.contentText,
799
- lastHtml.resumeIframeText
800
- ].join("\n").length;
801
- if (textLength >= minTextLength) {
802
- return {
803
- ok: true,
804
- elapsed_ms: Date.now() - started,
805
- text_length: textLength,
806
- resume_state: lastState,
807
- resume_html: lastHtml
808
- };
809
- }
810
- }
811
- } catch (error) {
812
- lastError = error;
813
- }
814
- await sleep(intervalMs);
815
- }
816
-
817
- const textLength = [
818
- lastHtml?.popupText,
819
- lastHtml?.contentText,
820
- lastHtml?.resumeIframeText
821
- ].filter(Boolean).join("\n").length;
822
- return {
823
- ok: false,
824
- elapsed_ms: Date.now() - started,
825
- text_length: textLength,
826
- resume_state: lastState,
827
- resume_html: lastHtml,
828
- error: lastError?.message || null
829
- };
830
- }
831
-
832
- export async function openChatOnlineResume(client, {
833
- timeoutMs = 15000,
834
- attemptsLimit = 3,
835
- settleMs = 1200
836
- } = {}) {
837
- const attempts = [];
838
- for (let index = 0; index < attemptsLimit; index += 1) {
839
- if (settleMs > 0) await sleep(settleMs);
840
- await assertChatShellNotResumeTopLevel(client, {
841
- context: "openChatOnlineResume:before_existing_modal_check"
842
- });
843
- const existingResumeState = await waitForChatResumeModal(client, {
844
- timeoutMs: 500,
845
- intervalMs: 100
846
- });
847
- if (existingResumeState?.forbidden_top_level_navigation) {
848
- throw makeForbiddenChatResumeNavigationError(existingResumeState.top_level_state);
849
- }
850
- if (
851
- existingResumeState?.popup
852
- || existingResumeState?.content
853
- || existingResumeState?.resumeIframe
854
- ) {
855
- attempts.push({
856
- attempt: index + 1,
857
- ok: true,
858
- reused_existing_modal: true,
859
- resume_popup_selector: existingResumeState?.popup?.selector || null,
860
- resume_content_selector: existingResumeState?.content?.selector || null,
861
- resume_iframe_selector: existingResumeState?.resumeIframe?.selector || null
862
- });
863
- return {
864
- button: null,
865
- button_html: "",
866
- resume_state: existingResumeState,
867
- attempts
868
- };
869
- }
870
-
871
- const buttonState = await waitForChatOnlineResumeButton(client, {
872
- timeoutMs: Math.min(timeoutMs, 8000)
873
- });
874
- if (!buttonState?.target?.node_id) {
875
- attempts.push({
876
- attempt: index + 1,
877
- ok: false,
878
- error: "ONLINE_RESUME_BUTTON_NOT_FOUND"
879
- });
880
- continue;
881
- }
882
-
883
- let buttonHTML = "";
884
- try {
885
- buttonHTML = await getOuterHTML(client, buttonState.target.node_id);
886
- } catch {}
887
-
888
- if (isUnsafeChatOnlineResumeTarget(buttonState.target, buttonHTML)) {
889
- const error = makeUnsafeChatOnlineResumeLinkError(buttonState.target, buttonHTML);
890
- attempts.push({
891
- attempt: index + 1,
892
- ok: false,
893
- error: error.code,
894
- blocked_pre_click: true,
895
- button_selector: buttonState.target.selector,
896
- button_text: error.button_text,
897
- button_href: error.href,
898
- button_html_length: buttonHTML.length
899
- });
900
- error.attempts = attempts;
901
- throw error;
902
- }
903
-
904
- try {
905
- if (buttonState.target.center) {
906
- await clickPoint(client, buttonState.target.center.x, buttonState.target.center.y);
907
- } else {
908
- await clickNodeCenter(client, buttonState.target.node_id, {
909
- scrollIntoView: true
910
- });
911
- }
912
- } catch (error) {
913
- attempts.push({
914
- attempt: index + 1,
915
- ok: false,
916
- error: error?.message || String(error),
917
- recoverable_stale_node: isRecoverableNodeError(error),
918
- button_selector: buttonState.target.selector,
919
- button_text: htmlToText(buttonHTML).slice(0, 120),
920
- button_html_length: buttonHTML.length
921
- });
922
- if (isRecoverableNodeError(error)) {
923
- await sleep(350);
924
- continue;
925
- }
926
- throw error;
927
- }
928
- await assertChatShellNotResumeTopLevel(client, {
929
- context: "openChatOnlineResume:after_online_resume_click"
930
- });
931
- const resumeState = await waitForChatResumeModal(client, {
932
- timeoutMs: Math.max(2500, Math.floor(timeoutMs / attemptsLimit))
933
- });
934
- if (resumeState?.forbidden_top_level_navigation) {
935
- throw makeForbiddenChatResumeNavigationError(resumeState.top_level_state);
936
- }
937
- attempts.push({
938
- attempt: index + 1,
939
- ok: Boolean(resumeState?.popup || resumeState?.content || resumeState?.resumeIframe),
940
- button_selector: buttonState.target.selector,
941
- button_text: htmlToText(buttonHTML).slice(0, 120),
942
- button_html_length: buttonHTML.length,
943
- resume_popup_selector: resumeState?.popup?.selector || null,
944
- resume_content_selector: resumeState?.content?.selector || null,
945
- resume_iframe_selector: resumeState?.resumeIframe?.selector || null
946
- });
947
- if (resumeState?.popup || resumeState?.content || resumeState?.resumeIframe) {
948
- return {
949
- button: buttonState.target,
950
- button_html: buttonHTML,
951
- resume_state: resumeState,
952
- attempts
953
- };
954
- }
955
- }
956
-
957
- const error = new Error("Chat online resume modal did not open");
958
- error.attempts = attempts;
959
- throw error;
960
- }
961
-
962
- export async function readChatConversationReadyState(client) {
963
- const rootState = await getChatRoots(client);
964
- const scopedControlRoots = await resolveScopedRoots(
965
- client,
966
- rootState.roots,
967
- CHAT_CONVERSATION_CONTROL_SCOPE_SELECTORS,
968
- { fallbackToRoots: false }
969
- );
970
- const scopedRequestedRoots = await resolveScopedRoots(
971
- client,
972
- rootState.roots,
973
- CHAT_REQUESTED_RESUME_SCOPE_SELECTORS,
974
- { fallbackToRoots: false }
975
- );
976
- const controlRoots = scopedControlRoots.length ? scopedControlRoots : rootState.roots;
977
- const requestedRoots = scopedRequestedRoots.length ? scopedRequestedRoots : rootState.roots;
978
- const onlineResume = await findVisibleMatchingTarget(
979
- client,
980
- controlRoots,
981
- CHAT_ONLINE_RESUME_BUTTON_SELECTORS,
982
- (target) => target.label.includes("在线简历") && !target.disabled
983
- );
984
- const attachmentResume = await findVisibleMatchingTarget(
985
- client,
986
- controlRoots,
987
- CHAT_ATTACHMENT_RESUME_BUTTON_SELECTORS,
988
- (target) => isAttachmentResumeText(target.label)
989
- );
990
- const askResume = await findVisibleMatchingTarget(
991
- client,
992
- controlRoots,
993
- CHAT_ASK_RESUME_BUTTON_SELECTORS,
994
- (target) => isAskResumeText(target.label) && !isAttachmentResumeTarget(target)
995
- );
996
- const requestedResume = await findVisibleMatchingTarget(
997
- client,
998
- requestedRoots,
999
- CHAT_ASK_RESUME_BUTTON_SELECTORS,
1000
- (target) => isRequestedResumeControlTarget(target)
1001
- );
1002
- const editor = await findVisibleMatchingTarget(
1003
- client,
1004
- controlRoots,
1005
- CHAT_EDITOR_SELECTORS,
1006
- () => true
1007
- );
1008
- const sendButton = await findVisibleMatchingTarget(
1009
- client,
1010
- controlRoots,
1011
- CHAT_SEND_BUTTON_SELECTORS,
1012
- (target) => isSendText(target.label) || /submit/i.test(String(target.attributes?.class || ""))
1013
- );
1014
- const resumeState = await waitForChatResumeModal(client, { timeoutMs: 300 });
1015
- return {
1016
- has_online_resume: Boolean(onlineResume),
1017
- online_resume: onlineResume,
1018
- has_ask_resume: Boolean(askResume),
1019
- ask_resume: askResume,
1020
- already_requested_resume: Boolean(requestedResume),
1021
- requested_resume: requestedResume,
1022
- has_attachment_resume: Boolean(attachmentResume),
1023
- attachment_resume_enabled: Boolean(attachmentResume && !attachmentResume.disabled),
1024
- attachment_resume: attachmentResume,
1025
- editor_visible: Boolean(editor),
1026
- editor,
1027
- send_button_visible: Boolean(sendButton),
1028
- send_button: sendButton,
1029
- resume_modal_open: Boolean(resumeState?.popup || resumeState?.content || resumeState?.resumeIframe),
1030
- panels_closed: !Boolean(resumeState?.popup || resumeState?.content || resumeState?.resumeIframe)
1031
- };
1032
- }
1033
-
1034
- export async function setChatEditorMessage(client, message, {
1035
- timeoutMs = 8000
1036
- } = {}) {
1037
- const started = Date.now();
1038
- let lastState = null;
1039
- while (Date.now() - started <= timeoutMs) {
1040
- const state = await readChatConversationReadyState(client);
1041
- lastState = state;
1042
- if (state.editor?.node_id) {
1043
- try {
1044
- if (state.editor.center) {
1045
- await clickPoint(client, state.editor.center.x, state.editor.center.y);
1046
- } else {
1047
- await clickNodeCenter(client, state.editor.node_id, { scrollIntoView: true });
1048
- }
1049
- await sleep(120);
1050
- await clearFocusedInput(client);
1051
- await sleep(80);
1052
- await insertText(client, message);
1053
- await sleep(250);
1054
- const afterState = await readChatConversationReadyState(client);
1055
- const editorText = normalizeDetailText(afterState.editor?.label || "");
1056
- if (editorText.includes(normalizeDetailText(message))) {
1057
- return {
1058
- ok: true,
1059
- value: editorText,
1060
- editor: afterState.editor || state.editor
1061
- };
1062
- }
1063
- lastState = {
1064
- ...afterState,
1065
- editor_message_mismatch: true,
1066
- editor_text: editorText
1067
- };
1068
- } catch (error) {
1069
- if (!isRecoverableNodeError(error)) throw error;
1070
- lastState = {
1071
- ...state,
1072
- recoverable_error: error?.message || String(error),
1073
- recoverable_phase: "set_editor_message"
1074
- };
1075
- }
1076
- }
1077
- await sleep(250);
1078
- }
1079
- return {
1080
- ok: false,
1081
- error: "CHAT_EDITOR_NOT_FOUND",
1082
- state: lastState
1083
- };
1084
- }
1085
-
1086
- export async function sendChatMessage(client, expectedText = "", {
1087
- timeoutMs = 8000,
1088
- settleMs = 800
1089
- } = {}) {
1090
- const started = Date.now();
1091
- let lastState = null;
1092
- while (Date.now() - started <= timeoutMs) {
1093
- const state = await readChatConversationReadyState(client);
1094
- lastState = state;
1095
- if (state.send_button?.node_id && !state.send_button.disabled) {
1096
- try {
1097
- if (state.send_button.center) {
1098
- await clickPoint(client, state.send_button.center.x, state.send_button.center.y);
1099
- } else {
1100
- await clickNodeCenter(client, state.send_button.node_id, { scrollIntoView: true });
1101
- }
1102
- if (settleMs > 0) await sleep(settleMs);
1103
- return {
1104
- sent: true,
1105
- method: "send-button",
1106
- control: state.send_button,
1107
- expected_text: expectedText
1108
- };
1109
- } catch (error) {
1110
- if (!isRecoverableNodeError(error)) throw error;
1111
- lastState = {
1112
- ...state,
1113
- recoverable_error: error?.message || String(error),
1114
- recoverable_phase: "send_button_click"
1115
- };
1116
- await sleep(250);
1117
- continue;
1118
- }
1119
- }
1120
- if (state.editor?.node_id) {
1121
- await pressKey(client, "Enter", {
1122
- code: "Enter",
1123
- windowsVirtualKeyCode: 13,
1124
- nativeVirtualKeyCode: 13
1125
- });
1126
- if (settleMs > 0) await sleep(settleMs);
1127
- return {
1128
- sent: true,
1129
- method: "enter",
1130
- expected_text: expectedText
1131
- };
1132
- }
1133
- await sleep(250);
1134
- }
1135
- return {
1136
- sent: false,
1137
- method: "none",
1138
- error: "CHAT_SEND_CONTROL_NOT_FOUND",
1139
- state: lastState
1140
- };
1141
- }
1142
-
1143
- export async function clickChatAskResume(client, {
1144
- timeoutMs = 8000,
1145
- settleMs = 700
1146
- } = {}) {
1147
- const started = Date.now();
1148
- let lastState = null;
1149
- let lastDisabledAskResume = null;
1150
- while (Date.now() - started <= timeoutMs) {
1151
- const state = await readChatConversationReadyState(client);
1152
- lastState = state;
1153
- if (state.attachment_resume_enabled) {
1154
- return {
1155
- ok: true,
1156
- already_requested: true,
1157
- attachment_resume_available: true,
1158
- control: state.attachment_resume
1159
- };
1160
- }
1161
- if (state.ask_resume?.node_id && !state.ask_resume.disabled) {
1162
- try {
1163
- if (state.ask_resume.center) {
1164
- await clickPoint(client, state.ask_resume.center.x, state.ask_resume.center.y);
1165
- } else {
1166
- await clickNodeCenter(client, state.ask_resume.node_id, { scrollIntoView: true });
1167
- }
1168
- if (settleMs > 0) await sleep(settleMs);
1169
- return {
1170
- ok: true,
1171
- already_requested: false,
1172
- control: state.ask_resume
1173
- };
1174
- } catch (error) {
1175
- if (!isRecoverableNodeError(error)) throw error;
1176
- lastState = {
1177
- ...state,
1178
- recoverable_error: error?.message || String(error),
1179
- recoverable_phase: "ask_resume_click"
1180
- };
1181
- }
1182
- }
1183
- if (state.ask_resume?.node_id && state.ask_resume.disabled) {
1184
- lastDisabledAskResume = state.ask_resume;
1185
- }
1186
- if (state.already_requested_resume) {
1187
- return {
1188
- ok: true,
1189
- already_requested: true,
1190
- control: state.requested_resume
1191
- };
1192
- }
1193
- await sleep(250);
1194
- }
1195
- if (lastDisabledAskResume) {
1196
- return {
1197
- ok: false,
1198
- already_requested: true,
1199
- request_pending: true,
1200
- error: "ASK_RESUME_BUTTON_DISABLED",
1201
- control: lastDisabledAskResume,
1202
- state: lastState
1203
- };
1204
- }
1205
- return {
1206
- ok: false,
1207
- error: "ASK_RESUME_BUTTON_NOT_FOUND",
1208
- state: lastState
1209
- };
1210
- }
1211
-
1212
- export async function clickChatConfirmRequestResume(client, {
1213
- timeoutMs = 8000,
1214
- settleMs = 900
1215
- } = {}) {
1216
- const started = Date.now();
1217
- let lastTarget = null;
1218
- let lastState = null;
1219
- while (Date.now() - started <= timeoutMs) {
1220
- lastState = await readChatConversationReadyState(client);
1221
- const rootState = await getChatRoots(client);
1222
- const confirmRoots = await resolveScopedRoots(
1223
- client,
1224
- rootState.roots,
1225
- CHAT_CONVERSATION_CONTROL_SCOPE_SELECTORS
1226
- );
1227
- const target = await findVisibleMatchingTarget(
1228
- client,
1229
- confirmRoots,
1230
- CHAT_CONFIRM_REQUEST_RESUME_SELECTORS,
1231
- (item) => isConfirmText(item.label) && !item.disabled
1232
- );
1233
- lastTarget = target;
1234
- if (target?.node_id) {
1235
- try {
1236
- if (target.center) {
1237
- await clickPoint(client, target.center.x, target.center.y);
1238
- } else {
1239
- await clickNodeCenter(client, target.node_id, { scrollIntoView: true });
1240
- }
1241
- if (settleMs > 0) await sleep(settleMs);
1242
- const afterState = await readChatConversationReadyState(client);
1243
- return {
1244
- confirmed: true,
1245
- assumed_requested: Boolean(afterState.already_requested_resume),
1246
- control: target,
1247
- state: afterState
1248
- };
1249
- } catch (error) {
1250
- if (!isRecoverableNodeError(error)) throw error;
1251
- lastTarget = {
1252
- ...target,
1253
- recoverable_error: error?.message || String(error),
1254
- recoverable_phase: "confirm_request_resume_click"
1255
- };
1256
- }
1257
- }
1258
- await sleep(250);
1259
- }
1260
- return {
1261
- confirmed: false,
1262
- error: "CONFIRM_BUTTON_NOT_FOUND",
1263
- control: lastTarget,
1264
- state: lastState
1265
- };
1266
- }
1267
-
1268
- export async function getChatResumeRequestMessageState(client) {
1269
- const rootState = await getChatRoots(client);
1270
- let messageRoot = null;
1271
- for (const root of rootState.roots) {
1272
- for (const selector of CHAT_MESSAGE_LIST_SELECTORS) {
1273
- const nodeIds = await querySelectorAll(client, root.nodeId, selector);
1274
- if (nodeIds.length) {
1275
- messageRoot = {
1276
- root,
1277
- selector,
1278
- node_id: nodeIds[0]
1279
- };
1280
- break;
1281
- }
1282
- }
1283
- if (messageRoot) break;
1284
- }
1285
- const nodeId = messageRoot?.node_id || rootState.rootNodes.top;
1286
- let text = "";
1287
- try {
1288
- text = htmlToText(await getOuterHTML(client, nodeId));
1289
- } catch {}
1290
- const lines = text.split(/\r?\n/).map(normalizeDetailText).filter(Boolean);
1291
- const matching = lines.filter((line) => isResumeRequestSentMessageText(line));
1292
- const attachmentMatching = lines.filter((line) => isResumeAttachmentMessageText(line));
1293
- const count = countResumeRequestSentMessageMarkers(lines);
1294
- const resumeAttachmentCount = countResumeAttachmentMessageMarkers(lines);
1295
- return {
1296
- ok: Boolean(text),
1297
- selector: messageRoot?.selector || "top",
1298
- count,
1299
- resume_attachment_count: resumeAttachmentCount,
1300
- success_count: count + resumeAttachmentCount,
1301
- last_text: matching[matching.length - 1] || lines[lines.length - 1] || "",
1302
- last_resume_attachment_text: attachmentMatching[attachmentMatching.length - 1] || "",
1303
- last_success_text: matching[matching.length - 1] || attachmentMatching[attachmentMatching.length - 1] || "",
1304
- recent: lines.slice(-12)
1305
- };
1306
- }
1307
-
1308
- export async function waitForChatResumeRequestMessage(client, {
1309
- baselineCount = 0,
1310
- baselineResumeAttachmentCount = 0,
1311
- timeoutMs = 6500,
1312
- intervalMs = 260
1313
- } = {}) {
1314
- const started = Date.now();
1315
- let state = null;
1316
- while (Date.now() - started <= timeoutMs) {
1317
- state = await getChatResumeRequestMessageState(client);
1318
- const observed = (
1319
- state.count > baselineCount
1320
- || state.resume_attachment_count > baselineResumeAttachmentCount
1321
- );
1322
- if (observed) {
1323
- return {
1324
- observed: true,
1325
- elapsed_ms: Date.now() - started,
1326
- state
1327
- };
1328
- }
1329
- await sleep(intervalMs);
1330
- }
1331
- return {
1332
- observed: false,
1333
- elapsed_ms: Date.now() - started,
1334
- state
1335
- };
1336
- }
1337
-
1338
- export async function requestChatResumeForPassedCandidate(client, {
1339
- greetingText = "Hi同学,能麻烦发下简历吗?",
1340
- maxAttempts = 3,
1341
- askResumeTimeoutMs = 8000,
1342
- dryRun = false
1343
- } = {}) {
1344
- const effectiveGreetingText = normalizeDetailText(greetingText) || "Hi同学,能麻烦发下简历吗?";
1345
- const initialState = await readChatConversationReadyState(client);
1346
- if (initialState.attachment_resume_enabled) {
1347
- return {
1348
- requested: false,
1349
- skipped: true,
1350
- reason: "attachment_resume_already_available",
1351
- initial_state: initialState
1352
- };
1353
- }
1354
- if (dryRun) {
1355
- return {
1356
- requested: false,
1357
- skipped: false,
1358
- reason: "dry_run",
1359
- initial_state: initialState,
1360
- would_send_greeting: true,
1361
- would_click_ask_resume: true
1362
- };
1363
- }
1364
-
1365
- const closeBeforeGreeting = await closeChatResumeModal(client, { attemptsLimit: 3 });
1366
- if (!closeBeforeGreeting.closed) {
1367
- return {
1368
- requested: false,
1369
- skipped: true,
1370
- reason: "resume_modal_close_failed_before_request",
1371
- initial_state: initialState,
1372
- close_before_greeting: closeBeforeGreeting
1373
- };
1374
- }
1375
- const editorState = await setChatEditorMessage(client, effectiveGreetingText);
1376
- if (!editorState.ok) {
1377
- throw new Error("CHAT_EDITOR_MESSAGE_MISMATCH");
1378
- }
1379
- const sendResult = await sendChatMessage(client, effectiveGreetingText);
1380
- if (!sendResult.sent) {
1381
- throw new Error(`CHAT_GREETING_SEND_FAILED:${sendResult.error || sendResult.method || "unknown"}`);
1382
- }
1383
-
1384
- const attempts = [];
1385
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1386
- const before = await getChatResumeRequestMessageState(client);
1387
- const askResult = await clickChatAskResume(client, {
1388
- timeoutMs: askResumeTimeoutMs
1389
- });
1390
- let confirmResult = {
1391
- confirmed: false,
1392
- assumed_requested: Boolean(askResult.already_requested),
1393
- skipped: true,
1394
- reason: askResult.attachment_resume_available
1395
- ? "attachment_resume_already_available"
1396
- : askResult.request_pending
1397
- ? "resume_request_already_pending"
1398
- : askResult.ok
1399
- ? "already_requested"
1400
- : (askResult.error || "ask_resume_not_clicked")
1401
- };
1402
- if (askResult.attachment_resume_available) {
1403
- attempts.push({
1404
- attempt: attempt + 1,
1405
- ask_result: askResult,
1406
- confirm_result: confirmResult,
1407
- message_before_count: before.count,
1408
- message_after_count: before.count,
1409
- message_observed: false,
1410
- message_last_text: before.last_text || ""
1411
- });
1412
- return {
1413
- requested: false,
1414
- skipped: true,
1415
- reason: "attachment_resume_already_available",
1416
- initial_state: initialState,
1417
- close_before_greeting: closeBeforeGreeting,
1418
- greeting_sent: true,
1419
- greeting_send_result: sendResult,
1420
- attempts
1421
- };
1422
- }
1423
- if (askResult.request_pending || askResult.already_requested) {
1424
- attempts.push({
1425
- attempt: attempt + 1,
1426
- ask_result: askResult,
1427
- confirm_result: confirmResult,
1428
- message_before_count: before.count,
1429
- message_after_count: before.count,
1430
- resume_attachment_before_count: before.resume_attachment_count || 0,
1431
- resume_attachment_after_count: before.resume_attachment_count || 0,
1432
- message_observed: false,
1433
- message_last_text: before.last_success_text || before.last_text || ""
1434
- });
1435
- return {
1436
- requested: false,
1437
- skipped: true,
1438
- reason: "resume_request_already_pending",
1439
- initial_state: initialState,
1440
- close_before_greeting: closeBeforeGreeting,
1441
- greeting_sent: true,
1442
- greeting_send_result: sendResult,
1443
- attempts
1444
- };
1445
- }
1446
- if (askResult.ok && !askResult.already_requested) {
1447
- confirmResult = await clickChatConfirmRequestResume(client);
1448
- }
1449
- const messageCheck = await waitForChatResumeRequestMessage(client, {
1450
- baselineCount: before.count,
1451
- baselineResumeAttachmentCount: before.resume_attachment_count
1452
- });
1453
- const messageObserved = Boolean(messageCheck.observed);
1454
- attempts.push({
1455
- attempt: attempt + 1,
1456
- ask_result: askResult,
1457
- confirm_result: confirmResult,
1458
- message_before_count: before.count,
1459
- message_after_count: messageCheck.state?.count || 0,
1460
- resume_attachment_before_count: before.resume_attachment_count || 0,
1461
- resume_attachment_after_count: messageCheck.state?.resume_attachment_count || 0,
1462
- message_observed: messageObserved,
1463
- message_last_text: messageCheck.state?.last_success_text || messageCheck.state?.last_text || ""
1464
- });
1465
- if (messageObserved) {
1466
- return {
1467
- requested: true,
1468
- skipped: false,
1469
- reason: "requested",
1470
- initial_state: initialState,
1471
- close_before_greeting: closeBeforeGreeting,
1472
- greeting_sent: true,
1473
- greeting_send_result: sendResult,
1474
- attempts
1475
- };
1476
- }
1477
- await sleep(900);
1478
- }
1479
-
1480
- return {
1481
- requested: false,
1482
- skipped: false,
1483
- reason: "resume_request_message_not_observed",
1484
- initial_state: initialState,
1485
- close_before_greeting: closeBeforeGreeting,
1486
- greeting_sent: true,
1487
- greeting_send_result: sendResult,
1488
- attempts
1489
- };
1490
- }
1491
-
1492
- export async function closeChatResumeModal(client, {
1493
- attemptsLimit = 3
1494
- } = {}) {
1495
- const attempts = [];
1496
- for (let index = 0; index < attemptsLimit; index += 1) {
1497
- const existingState = await waitForChatResumeModal(client, { timeoutMs: 500 });
1498
- if (!existingState?.popup && !existingState?.content && !existingState?.resumeIframe) {
1499
- return {
1500
- closed: true,
1501
- attempts
1502
- };
1503
- }
1504
-
1505
- const rootState = await getChatRoots(client);
1506
- const closeTarget = await findVisibleTarget(client, rootState.roots, CHAT_RESUME_CLOSE_SELECTORS);
1507
- if (closeTarget) {
1508
- try {
1509
- await clickPoint(client, closeTarget.center.x, closeTarget.center.y, DETERMINISTIC_CLICK_OPTIONS);
1510
- attempts.push({
1511
- mode: "close-selector",
1512
- selector: closeTarget.selector,
1513
- root: closeTarget.root
1514
- });
1515
- } catch (error) {
1516
- attempts.push({
1517
- mode: "close-selector-error",
1518
- selector: closeTarget.selector,
1519
- root: closeTarget.root,
1520
- error: error?.message || String(error)
1521
- });
1522
- }
1523
- await sleep(700);
1524
- } else {
1525
- await pressEscape(client);
1526
- attempts.push({ mode: "Escape" });
1527
- await sleep(700);
1528
- }
1529
-
1530
- let state = await waitForChatResumeModal(client, { timeoutMs: 1000 });
1531
- if (!state?.popup && !state?.content && !state?.resumeIframe) {
1532
- return {
1533
- closed: true,
1534
- attempts
1535
- };
1536
- }
1537
-
1538
- await pressEscape(client);
1539
- attempts.push({ mode: "Escape-fallback" });
1540
- await sleep(700);
1541
-
1542
- state = await waitForChatResumeModal(client, { timeoutMs: 1000 });
1543
- if (!state?.popup && !state?.content && !state?.resumeIframe) {
1544
- return {
1545
- closed: true,
1546
- attempts
1547
- };
1548
- }
1549
- }
1550
-
1551
- return {
1552
- closed: false,
1553
- attempts
1554
- };
1555
- }
1556
-
1557
- export async function extractChatProfileCandidate(client, {
1558
- cardCandidate,
1559
- cardNodeId,
1560
- resumeState,
1561
- resumeHtml: providedResumeHtml = null,
1562
- networkEvents = [],
1563
- targetUrl = "",
1564
- closeResume = true,
1565
- networkParseRetryMs = 1800,
1566
- networkParseIntervalMs = 250
1567
- } = {}) {
1568
- let resumeHtml = providedResumeHtml || null;
1569
- if (!resumeHtml) {
1570
- try {
1571
- resumeHtml = await readChatResumeHtml(client, resumeState);
1572
- } catch (error) {
1573
- if (!networkEvents.length) throw error;
1574
- resumeHtml = emptyChatResumeHtml(error);
1575
- }
1576
- }
1577
- const detailText = [
1578
- resumeHtml.popupText,
1579
- resumeHtml.contentText,
1580
- resumeHtml.resumeIframeText
1581
- ].filter(Boolean).join("\n\n");
1582
-
1583
- const parseStarted = Date.now();
1584
- let networkBodies = [];
1585
- let detailCandidateResult = null;
1586
- do {
1587
- networkBodies = await readChatProfileNetworkBodies(client, networkEvents);
1588
- detailCandidateResult = buildScreeningCandidateFromDetail({
1589
- domain: "chat",
1590
- source: "chat-live-cdp-profile",
1591
- cardCandidate,
1592
- detailText,
1593
- networkBodies,
1594
- metadata: {
1595
- target_url: targetUrl,
1596
- card_node_id: cardNodeId,
1597
- resume_popup_selector: resumeState?.popup?.selector || null,
1598
- resume_content_selector: resumeState?.content?.selector || null,
1599
- resume_iframe_selector: resumeState?.resumeIframe?.selector || null,
1600
- resume_iframe_document_node_id: resumeHtml.resumeIframeDocumentNodeId
1601
- }
1602
- });
1603
- if (detailCandidateResult.parsed_network_profiles.some((item) => item.ok)) break;
1604
- if (Date.now() - parseStarted >= Math.max(0, Number(networkParseRetryMs) || 0)) break;
1605
- await sleep(Math.max(50, Number(networkParseIntervalMs) || 250));
1606
- } while (true);
1607
-
1608
- let closeResult = null;
1609
- if (closeResume) {
1610
- closeResult = await closeChatResumeModal(client);
1611
- }
1612
-
1613
- return {
1614
- candidate: detailCandidateResult.candidate,
1615
- parsed_network_profiles: detailCandidateResult.parsed_network_profiles,
1616
- network_bodies: networkBodies,
1617
- network_parse_retry_elapsed_ms: Date.now() - parseStarted,
1618
- network_event_count: networkEvents.length,
1619
- detail: {
1620
- popup_text: resumeHtml.popupText,
1621
- content_text: resumeHtml.contentText,
1622
- resume_iframe_text: resumeHtml.resumeIframeText,
1623
- popup_html_length: resumeHtml.popupHTML.length,
1624
- content_html_length: resumeHtml.contentHTML.length,
1625
- resume_iframe_html_length: resumeHtml.resumeIframeHTML.length
1626
- },
1627
- resume_html_read_error: resumeHtml.readError || null,
1628
- close_result: closeResult
1629
- };
1630
- }
1631
-
1632
- async function findVisibleTarget(client, roots, selectors) {
1633
- let fallback = null;
1634
- for (const root of roots) {
1635
- if (!root?.nodeId) continue;
1636
- for (const selector of selectors) {
1637
- const nodeIds = await querySelectorAll(client, root.nodeId, selector);
1638
- for (const nodeId of nodeIds) {
1639
- const target = {
1640
- root: root.name,
1641
- root_node_id: root.nodeId,
1642
- selector,
1643
- node_id: nodeId
1644
- };
1645
- if (!fallback) fallback = target;
1646
- try {
1647
- const box = await getNodeBox(client, nodeId);
1648
- if (box.rect.width > 2 && box.rect.height > 2) {
1649
- return {
1650
- ...target,
1651
- center: box.center,
1652
- rect: box.rect
1653
- };
1654
- }
1655
- } catch {}
1656
- }
1657
- }
1658
- }
1659
- return fallback;
1660
- }
1661
-
1662
- async function pressEscape(client) {
1663
- await pressKey(client, "Escape", {
1664
- code: "Escape",
1665
- windowsVirtualKeyCode: 27,
1666
- nativeVirtualKeyCode: 27
1667
- });
1668
- }
1
+ import {
2
+ clearFocusedInput,
3
+ clickNodeCenter,
4
+ clickPoint,
5
+ DETERMINISTIC_CLICK_OPTIONS,
6
+ getFrameDocumentNodeId,
7
+ getAttributesMap,
8
+ getNodeBox,
9
+ getOuterHTML,
10
+ insertText,
11
+ pressKey,
12
+ querySelectorAll,
13
+ sleep
14
+ } from "../../core/browser/index.js";
15
+ import {
16
+ buildScreeningCandidateFromDetail,
17
+ htmlToText
18
+ } from "../../core/screening/index.js";
19
+ import {
20
+ CHAT_ACTIVE_CANDIDATE_SELECTORS,
21
+ CHAT_ASK_RESUME_BUTTON_SELECTORS,
22
+ CHAT_ATTACHMENT_RESUME_BUTTON_SELECTORS,
23
+ CHAT_BLOCKING_PANEL_CLOSE_SELECTORS,
24
+ CHAT_BLOCKING_PANEL_TEXT_QUERIES,
25
+ CHAT_CONFIRM_REQUEST_RESUME_SELECTORS,
26
+ CHAT_EDITOR_SELECTORS,
27
+ CHAT_MESSAGE_FILTER_SELECTORS,
28
+ CHAT_MESSAGE_LIST_SELECTORS,
29
+ CHAT_ONLINE_RESUME_BUTTON_SELECTORS,
30
+ CHAT_PRIMARY_LABEL_SELECTORS,
31
+ CHAT_PROFILE_NETWORK_PATTERNS,
32
+ CHAT_RESUME_CLOSE_SELECTORS,
33
+ CHAT_RESUME_CONTENT_SELECTORS,
34
+ CHAT_RESUME_FAST_MODAL_SELECTORS,
35
+ CHAT_RESUME_IFRAME_SELECTORS,
36
+ CHAT_RESUME_MODAL_SELECTORS,
37
+ CHAT_SEND_BUTTON_SELECTORS
38
+ } from "./constants.js";
39
+ import {
40
+ getChatRoots,
41
+ queryFirstAcrossChatRoots
42
+ } from "./roots.js";
43
+ import {
44
+ assertChatShellNotResumeTopLevel,
45
+ getChatTopLevelState,
46
+ isForbiddenChatResumeTopLevelUrl,
47
+ makeForbiddenChatResumeNavigationError
48
+ } from "./page-guard.js";
49
+
50
+ export const CHAT_UNSAFE_ONLINE_RESUME_LINK_CODE = "CHAT_UNSAFE_ONLINE_RESUME_LINK";
51
+
52
+ const CHAT_CONVERSATION_CONTROL_SCOPE_SELECTORS = Object.freeze([
53
+ ".conversation-main",
54
+ ".conversation-editor",
55
+ ".chat-message-list",
56
+ ".toolbar-box-right",
57
+ ".operate-exchange-left",
58
+ ".operate-icon-item",
59
+ ".exchange-tooltip",
60
+ ".boss-popup__wrapper",
61
+ ".boss-dialog",
62
+ ".dialog-wrap.active",
63
+ ".geek-detail-modal"
64
+ ]);
65
+
66
+ const CHAT_REQUESTED_RESUME_SCOPE_SELECTORS = Object.freeze([
67
+ ".chat-message-list",
68
+ ".conversation-editor",
69
+ ".conversation-main",
70
+ ".toolbar-box-right",
71
+ ".operate-exchange-left",
72
+ ".operate-icon-item",
73
+ ".exchange-tooltip",
74
+ ".boss-popup__wrapper",
75
+ ".boss-dialog",
76
+ ".dialog-wrap.active"
77
+ ]);
78
+
79
+ export function matchesChatProfileNetwork(url) {
80
+ return CHAT_PROFILE_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
81
+ }
82
+
83
+ function looksLikeForbiddenChatResumePath(value = "") {
84
+ const normalized = String(value || "");
85
+ return isForbiddenChatResumeTopLevelUrl(normalized)
86
+ || /(?:^|["'\s=])(?:https?:\/\/[^"'\s>]*zhipin\.com)?\/web\/frame\/c-resume(?:[/?#"' >]|$)/i
87
+ .test(normalized);
88
+ }
89
+
90
+ function extractFirstHtmlAttribute(html = "", names = []) {
91
+ const source = String(html || "");
92
+ for (const name of names) {
93
+ const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
+ const regex = new RegExp(`${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i");
95
+ const match = source.match(regex);
96
+ if (match) return match[1] ?? match[2] ?? match[3] ?? "";
97
+ }
98
+ return "";
99
+ }
100
+
101
+ export function isUnsafeChatOnlineResumeTarget(target = {}, buttonHTML = "") {
102
+ const attributes = target?.attributes || {};
103
+ const href = attributes.href
104
+ || attributes["data-href"]
105
+ || attributes["data-url"]
106
+ || attributes.url
107
+ || extractFirstHtmlAttribute(buttonHTML, ["href", "data-href", "data-url", "url"]);
108
+ return looksLikeForbiddenChatResumePath(href)
109
+ || looksLikeForbiddenChatResumePath(buttonHTML);
110
+ }
111
+
112
+ export function makeUnsafeChatOnlineResumeLinkError(target = {}, buttonHTML = "") {
113
+ const href = target?.attributes?.href
114
+ || target?.attributes?.["data-href"]
115
+ || target?.attributes?.["data-url"]
116
+ || extractFirstHtmlAttribute(buttonHTML, ["href", "data-href", "data-url", "url"])
117
+ || null;
118
+ const error = new Error("CHAT_UNSAFE_ONLINE_RESUME_LINK: refusing to click an online resume link that can navigate the chat tab to /web/frame/c-resume/");
119
+ error.code = CHAT_UNSAFE_ONLINE_RESUME_LINK_CODE;
120
+ error.href = href;
121
+ error.button_selector = target?.selector || null;
122
+ error.button_text = htmlToText(buttonHTML).slice(0, 120);
123
+ error.button_html_length = String(buttonHTML || "").length;
124
+ return error;
125
+ }
126
+
127
+ export function isUnsafeChatOnlineResumeLinkError(error) {
128
+ return error?.code === CHAT_UNSAFE_ONLINE_RESUME_LINK_CODE
129
+ || /CHAT_UNSAFE_ONLINE_RESUME_LINK/i.test(String(error?.message || error || ""));
130
+ }
131
+
132
+ export function createChatProfileNetworkRecorder(client) {
133
+ const events = [];
134
+ client.Network.responseReceived((event) => {
135
+ const url = event?.response?.url || "";
136
+ if (!matchesChatProfileNetwork(url)) return;
137
+ events.push({
138
+ requestId: event.requestId,
139
+ url,
140
+ status: event.response?.status,
141
+ mimeType: event.response?.mimeType,
142
+ type: event.type
143
+ });
144
+ });
145
+ if (typeof client.Network.loadingFinished === "function") {
146
+ client.Network.loadingFinished((event) => {
147
+ const found = events.find((item) => item.requestId === event.requestId);
148
+ if (!found) return;
149
+ found.loading_finished = true;
150
+ found.encodedDataLength = event.encodedDataLength;
151
+ });
152
+ }
153
+ if (typeof client.Network.loadingFailed === "function") {
154
+ client.Network.loadingFailed((event) => {
155
+ const found = events.find((item) => item.requestId === event.requestId);
156
+ if (!found) return;
157
+ found.loading_failed = true;
158
+ found.loading_error = event.errorText || event.blockedReason || "Network loading failed";
159
+ });
160
+ }
161
+ return {
162
+ events,
163
+ clear() {
164
+ events.length = 0;
165
+ }
166
+ };
167
+ }
168
+
169
+ export async function waitForChatProfileNetworkEvents(recorder, {
170
+ minCount = 1,
171
+ requireLoaded = true,
172
+ timeoutMs = 8000,
173
+ intervalMs = 120
174
+ } = {}) {
175
+ const started = Date.now();
176
+ const events = Array.isArray(recorder) ? recorder : recorder?.events || [];
177
+ let matching = [];
178
+ while (Date.now() - started <= timeoutMs) {
179
+ matching = events.filter((event) => (
180
+ !requireLoaded
181
+ || event.loading_finished === true
182
+ || event.loading_failed === true
183
+ ));
184
+ if (matching.length >= minCount) {
185
+ return {
186
+ ok: true,
187
+ elapsed_ms: Date.now() - started,
188
+ count: matching.length,
189
+ events: matching
190
+ };
191
+ }
192
+ await sleep(intervalMs);
193
+ }
194
+ return {
195
+ ok: false,
196
+ elapsed_ms: Date.now() - started,
197
+ count: matching.length,
198
+ events: matching,
199
+ total_event_count: events.length
200
+ };
201
+ }
202
+
203
+ export async function readChatProfileNetworkBodies(client, events = [], {
204
+ limit = 20
205
+ } = {}) {
206
+ const bodies = [];
207
+ for (const event of events.slice(0, limit)) {
208
+ try {
209
+ const body = await client.Network.getResponseBody({ requestId: event.requestId });
210
+ bodies.push({
211
+ ...event,
212
+ body,
213
+ body_length: String(body?.body || "").length
214
+ });
215
+ } catch (error) {
216
+ bodies.push({
217
+ ...event,
218
+ body_error: error?.message || String(error)
219
+ });
220
+ }
221
+ }
222
+ return bodies;
223
+ }
224
+
225
+ function normalizeDetailText(value = "") {
226
+ return String(value || "").replace(/\s+/g, " ").trim();
227
+ }
228
+
229
+ function chatCandidateIdFromAttributes(attributes = {}) {
230
+ return normalizeDetailText(
231
+ attributes["data-id"]
232
+ || attributes["data-geekid"]
233
+ || attributes["data-geek"]
234
+ || attributes["data-uid"]
235
+ || attributes.key
236
+ || attributes.id
237
+ || ""
238
+ );
239
+ }
240
+
241
+ async function hydrateActiveChatCandidate(client, activeCandidate = null) {
242
+ if (!activeCandidate?.node_id) return activeCandidate;
243
+ let attributes = {};
244
+ let outerHTML = "";
245
+ try {
246
+ [attributes, outerHTML] = await Promise.all([
247
+ getAttributesMap(client, activeCandidate.node_id),
248
+ getOuterHTML(client, activeCandidate.node_id)
249
+ ]);
250
+ } catch {}
251
+ return {
252
+ ...activeCandidate,
253
+ attributes,
254
+ candidate_id: chatCandidateIdFromAttributes(attributes) || null,
255
+ label: normalizeDetailText(htmlToText(outerHTML)),
256
+ outer_html_length: outerHTML.length
257
+ };
258
+ }
259
+
260
+ export async function waitForChatOnlineResumeButton(client, {
261
+ timeoutMs = 12000,
262
+ intervalMs = 250,
263
+ expectedCandidateId = ""
264
+ } = {}) {
265
+ const started = Date.now();
266
+ let lastState = null;
267
+ const expectedId = chatCandidateIdFromAttributes({ "data-id": expectedCandidateId });
268
+ while (Date.now() - started <= timeoutMs) {
269
+ const topLevelState = await getChatTopLevelState(client);
270
+ if (topLevelState.is_forbidden_resume_top_level) {
271
+ return {
272
+ forbidden_top_level_navigation: true,
273
+ top_level_state: topLevelState
274
+ };
275
+ }
276
+ const rootState = await getChatRoots(client);
277
+ const target = await findVisibleTarget(client, rootState.roots, CHAT_ONLINE_RESUME_BUTTON_SELECTORS);
278
+ const activeCandidate = await hydrateActiveChatCandidate(
279
+ client,
280
+ await queryFirstAcrossChatRoots(client, rootState.roots, CHAT_ACTIVE_CANDIDATE_SELECTORS)
281
+ );
282
+ const activeCandidateId = activeCandidate?.candidate_id || "";
283
+ const candidateSelectionVerified = expectedId
284
+ ? activeCandidateId === expectedId
285
+ : undefined;
286
+ lastState = {
287
+ roots: rootState.roots,
288
+ target,
289
+ activeCandidate,
290
+ expected_candidate_id: expectedId || null,
291
+ active_candidate_id: activeCandidateId || null,
292
+ candidate_selection_verified: candidateSelectionVerified
293
+ };
294
+ if (target && (!expectedId || candidateSelectionVerified)) {
295
+ return {
296
+ ok: true,
297
+ elapsed_ms: Date.now() - started,
298
+ ...lastState
299
+ };
300
+ }
301
+ await sleep(intervalMs);
302
+ }
303
+ return {
304
+ ok: false,
305
+ reason: expectedId && lastState?.candidate_selection_verified === false
306
+ ? "active_candidate_mismatch"
307
+ : "online_resume_button_unavailable",
308
+ elapsed_ms: Date.now() - started,
309
+ ...lastState
310
+ };
311
+ }
312
+
313
+ export async function selectChatCandidate(client, cardNodeId, {
314
+ timeoutMs = 12000,
315
+ settleMs = 1200
316
+ } = {}) {
317
+ const cardBox = await clickNodeCenter(client, cardNodeId, {
318
+ scrollIntoView: true
319
+ });
320
+ if (settleMs > 0) await sleep(settleMs);
321
+ const ready = await waitForChatOnlineResumeButton(client, { timeoutMs });
322
+ return {
323
+ card_box: cardBox,
324
+ ready
325
+ };
326
+ }
327
+
328
+ function hasActiveSignal(attributes = {}, outerHTML = "") {
329
+ return /\b(active|selected|current|curr)\b/i.test(String(attributes.class || ""))
330
+ || normalizeDetailText(attributes["aria-selected"]).toLowerCase() === "true"
331
+ || normalizeDetailText(attributes["data-active"]).toLowerCase() === "true"
332
+ || /\b(active|selected|current|curr)\b/i.test(String(outerHTML || "").slice(0, 500));
333
+ }
334
+
335
+ function isDisabledSignal(attributes = {}, outerHTML = "") {
336
+ return attributes.disabled !== undefined
337
+ || normalizeDetailText(attributes["aria-disabled"]).toLowerCase() === "true"
338
+ || /\b(disabled|disable|is-disabled)\b/i.test([
339
+ attributes.class,
340
+ String(outerHTML || "").slice(0, 500)
341
+ ].join(" "));
342
+ }
343
+
344
+ function isAskResumeText(text = "") {
345
+ const normalized = normalizeDetailText(text);
346
+ return Boolean(
347
+ normalized === "求简历"
348
+ || normalized === "索要简历"
349
+ || normalized === "求附件简历"
350
+ || normalized.includes("求简历")
351
+ || normalized.includes("索要简历")
352
+ || normalized.includes("求附件简历")
353
+ );
354
+ }
355
+
356
+ function isRequestedResumeText(text = "") {
357
+ const normalized = normalizeDetailText(text);
358
+ return Boolean(
359
+ normalized === "已求简历"
360
+ || normalized === "已索要简历"
361
+ || normalized.includes("已求简历")
362
+ || normalized.includes("已索要简历")
363
+ || normalized.includes("简历请求已发送")
364
+ || normalized.includes("已发送简历")
365
+ || (normalized.includes("已申请") && normalized.includes("简历"))
366
+ );
367
+ }
368
+
369
+ function isResumeRequestSentMessageText(text = "") {
370
+ const normalized = normalizeDetailText(text);
371
+ return Boolean(
372
+ normalized.includes("简历请求已发送")
373
+ || normalized.includes("已发送简历")
374
+ || normalized.includes("已求简历")
375
+ || normalized.includes("已索要简历")
376
+ || normalized.includes("已发送")
377
+ );
378
+ }
379
+
380
+ function countTextOccurrences(text = "", needle = "") {
381
+ if (!needle) return 0;
382
+ let count = 0;
383
+ let index = 0;
384
+ while (index < text.length) {
385
+ const found = text.indexOf(needle, index);
386
+ if (found < 0) break;
387
+ count += 1;
388
+ index = found + needle.length;
389
+ }
390
+ return count;
391
+ }
392
+
393
+ function countResumeRequestSentMessageMarkers(lines = []) {
394
+ const markers = ["简历请求已发送", "已发送简历", "已求简历", "已索要简历", "已发送"];
395
+ return lines.reduce((total, line) => (
396
+ total + markers.reduce((lineTotal, marker) => (
397
+ lineTotal + countTextOccurrences(line, marker)
398
+ ), 0)
399
+ ), 0);
400
+ }
401
+
402
+ function isResumeAttachmentMessageText(text = "") {
403
+ const normalized = normalizeDetailText(text);
404
+ return Boolean(
405
+ /点击.*附件简历/.test(normalized)
406
+ || /预览附件简历/.test(normalized)
407
+ || /查看附件简历/.test(normalized)
408
+ || /(?:简历|resume)[^\s]*\.(?:pdf|docx?|jpg|jpeg|png)\b/i.test(normalized)
409
+ );
410
+ }
411
+
412
+ function countResumeAttachmentMessageMarkers(lines = []) {
413
+ return lines.reduce((total, line) => total + (isResumeAttachmentMessageText(line) ? 1 : 0), 0);
414
+ }
415
+
416
+ function isRequestedResumeControlTarget(target = {}) {
417
+ const label = normalizeDetailText(target.label);
418
+ const className = String(target.attributes?.class || "");
419
+ const selector = String(target.selector || "");
420
+ const controlLike = /\boperate-btn\b|operate|resume|button|btn/i.test(`${selector} ${className}`);
421
+ if (isRequestedResumeText(label)) return true;
422
+ return controlLike && Boolean(
423
+ label === "已申请"
424
+ || label === "已发送"
425
+ || label.includes("已申请")
426
+ || label.includes("已发送")
427
+ );
428
+ }
429
+
430
+ function isAttachmentResumeText(text = "") {
431
+ const normalized = normalizeDetailText(text);
432
+ return Boolean(
433
+ normalized === "附件简历"
434
+ || (normalized.includes("附件简历") && !normalized.includes("求附件简历"))
435
+ );
436
+ }
437
+
438
+ function isAttachmentResumeTarget(target = {}) {
439
+ return isAttachmentResumeText(target.label)
440
+ || /resume-btn-file/i.test(String(target.attributes?.class || target.selector || ""));
441
+ }
442
+
443
+ function isConfirmText(text = "") {
444
+ const normalized = normalizeDetailText(text);
445
+ return Boolean(
446
+ normalized === "确定"
447
+ || normalized === "确认"
448
+ || normalized === "提交"
449
+ || normalized === "继续"
450
+ || normalized.includes("确定")
451
+ || normalized.includes("确认")
452
+ );
453
+ }
454
+
455
+ function isSendText(text = "") {
456
+ const normalized = normalizeDetailText(text);
457
+ return normalized === "发送" || normalized.includes("发送");
458
+ }
459
+
460
+ function isRecoverableNodeError(error) {
461
+ return /(?:Could not find node|No node with given id|Cannot find node|Could not compute box model)/i
462
+ .test(String(error?.message || error || ""));
463
+ }
464
+
465
+ async function readTarget(client, root, selector, nodeId) {
466
+ let attributes = {};
467
+ let outerHTML = "";
468
+ let readError = "";
469
+ try {
470
+ [attributes, outerHTML] = await Promise.all([
471
+ getAttributesMap(client, nodeId),
472
+ getOuterHTML(client, nodeId)
473
+ ]);
474
+ } catch (error) {
475
+ readError = error?.message || String(error);
476
+ }
477
+ const label = normalizeDetailText(htmlToText(outerHTML));
478
+ let box = null;
479
+ try {
480
+ box = await getNodeBox(client, nodeId);
481
+ } catch {}
482
+ return {
483
+ root: root.name,
484
+ root_node_id: root.nodeId,
485
+ selector,
486
+ node_id: nodeId,
487
+ label,
488
+ attributes,
489
+ disabled: isDisabledSignal(attributes, outerHTML),
490
+ active: hasActiveSignal(attributes, outerHTML),
491
+ visible: Boolean(box && box.rect.width > 2 && box.rect.height > 2),
492
+ center: box?.center || null,
493
+ rect: box?.rect || null,
494
+ outer_html_length: outerHTML.length,
495
+ read_error: readError || null
496
+ };
497
+ }
498
+
499
+ async function findVisibleMatchingTarget(client, roots, selectors, predicate) {
500
+ for (const root of roots) {
501
+ if (!root?.nodeId) continue;
502
+ for (const selector of selectors) {
503
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
504
+ for (const nodeId of nodeIds) {
505
+ const target = await readTarget(client, root, selector, nodeId);
506
+ if (!target.visible) continue;
507
+ if (predicate(target)) return target;
508
+ }
509
+ }
510
+ }
511
+ return null;
512
+ }
513
+
514
+ async function resolveScopedRoots(client, roots = [], selectors = [], {
515
+ fallbackToRoots = true
516
+ } = {}) {
517
+ const scoped = [];
518
+ const seen = new Set();
519
+ for (const root of roots) {
520
+ if (!root?.nodeId) continue;
521
+ for (const selector of selectors) {
522
+ let nodeIds = [];
523
+ try {
524
+ nodeIds = await querySelectorAll(client, root.nodeId, selector);
525
+ } catch {
526
+ nodeIds = [];
527
+ }
528
+ for (const nodeId of nodeIds) {
529
+ const key = `${root.name}:${nodeId}`;
530
+ if (seen.has(key)) continue;
531
+ seen.add(key);
532
+ scoped.push({
533
+ name: `${root.name}:${selector}`,
534
+ nodeId
535
+ });
536
+ }
537
+ }
538
+ }
539
+ if (scoped.length || !fallbackToRoots) return scoped;
540
+ return roots;
541
+ }
542
+
543
+ export async function selectChatPrimaryLabel(client, {
544
+ label = "全部",
545
+ timeoutMs = 8000,
546
+ intervalMs = 300,
547
+ settleMs = 700
548
+ } = {}) {
549
+ const started = Date.now();
550
+ let lastCandidates = [];
551
+ while (Date.now() - started <= timeoutMs) {
552
+ const rootState = await getChatRoots(client);
553
+ const candidates = [];
554
+ for (const root of rootState.roots) {
555
+ for (const selector of CHAT_PRIMARY_LABEL_SELECTORS) {
556
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
557
+ for (const nodeId of nodeIds) {
558
+ const target = await readTarget(client, root, selector, nodeId);
559
+ if (target.visible) candidates.push(target);
560
+ }
561
+ }
562
+ }
563
+ lastCandidates = candidates;
564
+ const matched = candidates.find((target) => (
565
+ target.label === label || target.label.startsWith(`${label}(`)
566
+ ));
567
+ if (matched?.active) {
568
+ return {
569
+ ok: true,
570
+ changed: false,
571
+ verified: true,
572
+ active_label: matched.label,
573
+ control: matched
574
+ };
575
+ }
576
+ if (matched) {
577
+ if (matched.center) {
578
+ await clickPoint(client, matched.center.x, matched.center.y, DETERMINISTIC_CLICK_OPTIONS);
579
+ } else {
580
+ await clickNodeCenter(client, matched.node_id, {
581
+ ...DETERMINISTIC_CLICK_OPTIONS,
582
+ scrollIntoView: true
583
+ });
584
+ }
585
+ if (settleMs > 0) await sleep(settleMs);
586
+ return {
587
+ ok: true,
588
+ changed: true,
589
+ verified: true,
590
+ active_label: label,
591
+ control: matched
592
+ };
593
+ }
594
+ await sleep(intervalMs);
595
+ }
596
+ return {
597
+ ok: false,
598
+ error: `CHAT_PRIMARY_LABEL_NOT_FOUND:${label}`,
599
+ candidates: lastCandidates.map((item) => ({
600
+ label: item.label,
601
+ selector: item.selector,
602
+ active: item.active
603
+ }))
604
+ };
605
+ }
606
+
607
+ export async function selectChatMessageFilter(client, {
608
+ startFrom = "unread",
609
+ timeoutMs = 8000,
610
+ intervalMs = 300,
611
+ settleMs = 900
612
+ } = {}) {
613
+ const label = startFrom === "all" ? "全部" : "未读";
614
+ const started = Date.now();
615
+ let lastCandidates = [];
616
+ while (Date.now() - started <= timeoutMs) {
617
+ const rootState = await getChatRoots(client);
618
+ const candidates = [];
619
+ for (const root of rootState.roots) {
620
+ for (const selector of CHAT_MESSAGE_FILTER_SELECTORS) {
621
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
622
+ for (const nodeId of nodeIds) {
623
+ const target = await readTarget(client, root, selector, nodeId);
624
+ if (target.visible && target.label === label) candidates.push(target);
625
+ }
626
+ }
627
+ }
628
+ lastCandidates = candidates;
629
+ const active = candidates.find((target) => target.active);
630
+ if (active) {
631
+ return {
632
+ ok: true,
633
+ changed: false,
634
+ verified: true,
635
+ active_label: active.label,
636
+ control: active
637
+ };
638
+ }
639
+ const matched = candidates[0];
640
+ if (matched) {
641
+ if (matched.center) {
642
+ await clickPoint(client, matched.center.x, matched.center.y, DETERMINISTIC_CLICK_OPTIONS);
643
+ } else {
644
+ await clickNodeCenter(client, matched.node_id, {
645
+ ...DETERMINISTIC_CLICK_OPTIONS,
646
+ scrollIntoView: true
647
+ });
648
+ }
649
+ if (settleMs > 0) await sleep(settleMs);
650
+ return {
651
+ ok: true,
652
+ changed: true,
653
+ verified: true,
654
+ active_label: label,
655
+ control: matched
656
+ };
657
+ }
658
+ await sleep(intervalMs);
659
+ }
660
+ return {
661
+ ok: false,
662
+ error: `CHAT_MESSAGE_FILTER_NOT_FOUND:${label}`,
663
+ candidates: lastCandidates.map((item) => ({
664
+ label: item.label,
665
+ selector: item.selector,
666
+ active: item.active
667
+ }))
668
+ };
669
+ }
670
+
671
+ export async function waitForChatResumeModal(client, {
672
+ timeoutMs = 12000,
673
+ intervalMs = 250
674
+ } = {}) {
675
+ const started = Date.now();
676
+ let lastState = null;
677
+ while (Date.now() - started <= timeoutMs) {
678
+ const topLevelState = await getChatTopLevelState(client);
679
+ if (topLevelState.is_forbidden_resume_top_level) {
680
+ return {
681
+ forbidden_top_level_navigation: true,
682
+ top_level_state: topLevelState
683
+ };
684
+ }
685
+ const rootState = await getChatRoots(client);
686
+ const popup = await findVisibleTarget(client, rootState.roots, CHAT_RESUME_MODAL_SELECTORS);
687
+ const content = await findVisibleTarget(client, rootState.roots, CHAT_RESUME_CONTENT_SELECTORS);
688
+ const resumeIframe = await findVisibleTarget(client, rootState.roots, CHAT_RESUME_IFRAME_SELECTORS);
689
+ lastState = {
690
+ roots: rootState.roots,
691
+ popup,
692
+ content,
693
+ resumeIframe
694
+ };
695
+ if (popup || content || resumeIframe) return lastState;
696
+ await sleep(intervalMs);
697
+ }
698
+ return lastState;
699
+ }
700
+
701
+ export async function quickChatResumeModalOpenProbe(client, {
702
+ selectors = CHAT_RESUME_FAST_MODAL_SELECTORS
703
+ } = {}) {
704
+ const rootState = await getChatRoots(client);
705
+ for (const root of rootState.roots) {
706
+ if (!root?.nodeId) continue;
707
+ for (const selector of selectors) {
708
+ let nodeIds = [];
709
+ try {
710
+ nodeIds = await querySelectorAll(client, root.nodeId, selector);
711
+ } catch {
712
+ nodeIds = [];
713
+ }
714
+ for (const nodeId of nodeIds.slice(0, 4)) {
715
+ try {
716
+ const box = await getNodeBox(client, nodeId);
717
+ if (box?.rect?.width > 8 && box?.rect?.height > 8) {
718
+ return {
719
+ open: true,
720
+ root: root.name,
721
+ selector,
722
+ node_id: nodeId,
723
+ rect: box.rect,
724
+ center: box.center
725
+ };
726
+ }
727
+ } catch {
728
+ // Hidden or stale modal probes are ignored.
729
+ }
730
+ }
731
+ }
732
+ }
733
+ return {
734
+ open: false
735
+ };
736
+ }
737
+
738
+ async function performDomTextSearch(client, query, {
739
+ limit = 6
740
+ } = {}) {
741
+ if (typeof client?.DOM?.performSearch !== "function"
742
+ || typeof client?.DOM?.getSearchResults !== "function") {
743
+ return [];
744
+ }
745
+ const searchOnce = async () => {
746
+ let searchId = "";
747
+ try {
748
+ const search = await client.DOM.performSearch({
749
+ query,
750
+ includeUserAgentShadowDOM: true
751
+ });
752
+ searchId = search?.searchId || "";
753
+ const resultCount = Math.min(Number(search?.resultCount) || 0, Math.max(0, Number(limit) || 0));
754
+ if (!searchId || resultCount <= 0) return [];
755
+ const results = await client.DOM.getSearchResults({
756
+ searchId,
757
+ fromIndex: 0,
758
+ toIndex: resultCount
759
+ });
760
+ return results?.nodeIds || [];
761
+ } catch {
762
+ return [];
763
+ } finally {
764
+ if (searchId && typeof client?.DOM?.discardSearchResults === "function") {
765
+ try {
766
+ await client.DOM.discardSearchResults({ searchId });
767
+ } catch {
768
+ // Best-effort cleanup only.
769
+ }
770
+ }
771
+ }
772
+ };
773
+
774
+ const firstPass = await searchOnce();
775
+ if (!firstPass.length) return [];
776
+ const firstPassNodeIds = firstPass.filter((nodeId) => Number(nodeId) > 0);
777
+ if (firstPassNodeIds.length || typeof client?.DOM?.getDocument !== "function") return firstPassNodeIds;
778
+ try {
779
+ await client.DOM.getDocument({
780
+ depth: -1,
781
+ pierce: true
782
+ });
783
+ } catch {
784
+ return firstPassNodeIds;
785
+ }
786
+ return (await searchOnce()).filter((nodeId) => Number(nodeId) > 0);
787
+ }
788
+
789
+ export async function findChatBlockingPanel(client, {
790
+ textQueries = CHAT_BLOCKING_PANEL_TEXT_QUERIES
791
+ } = {}) {
792
+ for (const query of textQueries || []) {
793
+ const nodeIds = await performDomTextSearch(client, query);
794
+ for (const nodeId of nodeIds) {
795
+ try {
796
+ const box = await getNodeBox(client, nodeId);
797
+ if (box?.rect?.width > 2 && box?.rect?.height > 2) {
798
+ return {
799
+ open: true,
800
+ reason: "blocking_panel_text_visible",
801
+ query,
802
+ node_id: nodeId,
803
+ rect: box.rect,
804
+ center: box.center
805
+ };
806
+ }
807
+ } catch {
808
+ // Hidden or stale text hits are ignored.
809
+ }
810
+ }
811
+ }
812
+ return {
813
+ open: false
814
+ };
815
+ }
816
+
817
+ function blockingPanelOutsideClickPoint(probe = {}) {
818
+ const rect = probe?.rect || {};
819
+ // Click the empty lower-left sidebar area. It is outside the rights drawer
820
+ // and avoids top nav, chat rows, message controls, and candidate actions.
821
+ const x = 84;
822
+ const y = Number.isFinite(Number(rect.y))
823
+ ? Math.max(560, Math.min(680, Number(rect.y) + 600))
824
+ : 660;
825
+ return {
826
+ x,
827
+ y,
828
+ mode: "empty-lower-left-sidebar"
829
+ };
830
+ }
831
+
832
+ export async function closeChatBlockingPanels(client, {
833
+ attemptsLimit = 2
834
+ } = {}) {
835
+ const attempts = [];
836
+ let probe = await findChatBlockingPanel(client);
837
+ if (!probe.open) {
838
+ return {
839
+ closed: true,
840
+ already_closed: true,
841
+ probe,
842
+ attempts
843
+ };
844
+ }
845
+
846
+ for (let index = 0; index < attemptsLimit; index += 1) {
847
+ const outsidePoint = blockingPanelOutsideClickPoint(probe);
848
+ try {
849
+ await clickPoint(client, outsidePoint.x, outsidePoint.y, DETERMINISTIC_CLICK_OPTIONS);
850
+ attempts.push({
851
+ mode: "outside-click",
852
+ point: outsidePoint
853
+ });
854
+ } catch (error) {
855
+ attempts.push({
856
+ mode: "outside-click-error",
857
+ point: outsidePoint,
858
+ error: error?.message || String(error)
859
+ });
860
+ }
861
+ await sleep(700);
862
+
863
+ probe = await findChatBlockingPanel(client);
864
+ if (!probe.open) {
865
+ return {
866
+ closed: true,
867
+ already_closed: false,
868
+ probe,
869
+ attempts
870
+ };
871
+ }
872
+
873
+ const rootState = await getChatRoots(client);
874
+ const closeTarget = await findVisibleTarget(client, rootState.roots, CHAT_BLOCKING_PANEL_CLOSE_SELECTORS);
875
+ if (closeTarget) {
876
+ try {
877
+ await clickPoint(client, closeTarget.center.x, closeTarget.center.y, DETERMINISTIC_CLICK_OPTIONS);
878
+ attempts.push({
879
+ mode: "close-selector",
880
+ selector: closeTarget.selector,
881
+ root: closeTarget.root
882
+ });
883
+ } catch (error) {
884
+ attempts.push({
885
+ mode: "close-selector-error",
886
+ selector: closeTarget.selector,
887
+ root: closeTarget.root,
888
+ error: error?.message || String(error)
889
+ });
890
+ }
891
+ } else {
892
+ await pressEscape(client);
893
+ attempts.push({ mode: "Escape" });
894
+ }
895
+ await sleep(700);
896
+
897
+ probe = await findChatBlockingPanel(client);
898
+ if (!probe.open) {
899
+ return {
900
+ closed: true,
901
+ already_closed: false,
902
+ probe,
903
+ attempts
904
+ };
905
+ }
906
+ }
907
+
908
+ return {
909
+ closed: false,
910
+ already_closed: false,
911
+ probe,
912
+ attempts
913
+ };
914
+ }
915
+
916
+ export async function readChatResumeHtml(client, resumeState) {
917
+ let popupHTML = "";
918
+ let contentHTML = "";
919
+ let resumeIframeHTML = "";
920
+ let resumeIframeDocumentNodeId = null;
921
+
922
+ if (resumeState?.popup?.node_id) {
923
+ popupHTML = await getOuterHTML(client, resumeState.popup.node_id);
924
+ }
925
+
926
+ if (resumeState?.content?.node_id && resumeState.content.node_id !== resumeState?.popup?.node_id) {
927
+ contentHTML = await getOuterHTML(client, resumeState.content.node_id);
928
+ }
929
+
930
+ if (resumeState?.resumeIframe?.node_id) {
931
+ resumeIframeDocumentNodeId = await getFrameDocumentNodeId(client, resumeState.resumeIframe.node_id);
932
+ resumeIframeHTML = await getOuterHTML(client, resumeIframeDocumentNodeId);
933
+ }
934
+
935
+ return {
936
+ popupHTML,
937
+ contentHTML,
938
+ resumeIframeHTML,
939
+ resumeIframeDocumentNodeId,
940
+ popupText: htmlToText(popupHTML),
941
+ contentText: htmlToText(contentHTML),
942
+ resumeIframeText: htmlToText(resumeIframeHTML)
943
+ };
944
+ }
945
+
946
+ function emptyChatResumeHtml(readError = null) {
947
+ return {
948
+ popupHTML: "",
949
+ contentHTML: "",
950
+ resumeIframeHTML: "",
951
+ resumeIframeDocumentNodeId: null,
952
+ popupText: "",
953
+ contentText: "",
954
+ resumeIframeText: "",
955
+ readError: readError?.message || null
956
+ };
957
+ }
958
+
959
+ export async function waitForChatResumeContent(client, {
960
+ minTextLength = 120,
961
+ timeoutMs = 15000,
962
+ intervalMs = 250
963
+ } = {}) {
964
+ const started = Date.now();
965
+ let lastState = null;
966
+ let lastHtml = null;
967
+ let lastError = null;
968
+ while (Date.now() - started <= timeoutMs) {
969
+ try {
970
+ lastState = await waitForChatResumeModal(client, {
971
+ timeoutMs: 700,
972
+ intervalMs: 100
973
+ });
974
+ if (lastState?.popup || lastState?.content || lastState?.resumeIframe) {
975
+ lastHtml = await readChatResumeHtml(client, lastState);
976
+ const textLength = [
977
+ lastHtml.popupText,
978
+ lastHtml.contentText,
979
+ lastHtml.resumeIframeText
980
+ ].join("\n").length;
981
+ if (textLength >= minTextLength) {
982
+ return {
983
+ ok: true,
984
+ elapsed_ms: Date.now() - started,
985
+ text_length: textLength,
986
+ resume_state: lastState,
987
+ resume_html: lastHtml
988
+ };
989
+ }
990
+ }
991
+ } catch (error) {
992
+ lastError = error;
993
+ }
994
+ await sleep(intervalMs);
995
+ }
996
+
997
+ const textLength = [
998
+ lastHtml?.popupText,
999
+ lastHtml?.contentText,
1000
+ lastHtml?.resumeIframeText
1001
+ ].filter(Boolean).join("\n").length;
1002
+ return {
1003
+ ok: false,
1004
+ elapsed_ms: Date.now() - started,
1005
+ text_length: textLength,
1006
+ resume_state: lastState,
1007
+ resume_html: lastHtml,
1008
+ error: lastError?.message || null
1009
+ };
1010
+ }
1011
+
1012
+ export async function openChatOnlineResume(client, {
1013
+ timeoutMs = 15000,
1014
+ attemptsLimit = 3,
1015
+ settleMs = 1200
1016
+ } = {}) {
1017
+ const attempts = [];
1018
+ for (let index = 0; index < attemptsLimit; index += 1) {
1019
+ if (settleMs > 0) await sleep(settleMs);
1020
+ await assertChatShellNotResumeTopLevel(client, {
1021
+ context: "openChatOnlineResume:before_existing_modal_check"
1022
+ });
1023
+ const existingResumeState = await waitForChatResumeModal(client, {
1024
+ timeoutMs: 500,
1025
+ intervalMs: 100
1026
+ });
1027
+ if (existingResumeState?.forbidden_top_level_navigation) {
1028
+ throw makeForbiddenChatResumeNavigationError(existingResumeState.top_level_state);
1029
+ }
1030
+ if (
1031
+ existingResumeState?.popup
1032
+ || existingResumeState?.content
1033
+ || existingResumeState?.resumeIframe
1034
+ ) {
1035
+ attempts.push({
1036
+ attempt: index + 1,
1037
+ ok: true,
1038
+ reused_existing_modal: true,
1039
+ resume_popup_selector: existingResumeState?.popup?.selector || null,
1040
+ resume_content_selector: existingResumeState?.content?.selector || null,
1041
+ resume_iframe_selector: existingResumeState?.resumeIframe?.selector || null
1042
+ });
1043
+ return {
1044
+ button: null,
1045
+ button_html: "",
1046
+ resume_state: existingResumeState,
1047
+ attempts
1048
+ };
1049
+ }
1050
+
1051
+ const buttonState = await waitForChatOnlineResumeButton(client, {
1052
+ timeoutMs: Math.min(timeoutMs, 8000)
1053
+ });
1054
+ if (!buttonState?.target?.node_id) {
1055
+ attempts.push({
1056
+ attempt: index + 1,
1057
+ ok: false,
1058
+ error: "ONLINE_RESUME_BUTTON_NOT_FOUND"
1059
+ });
1060
+ continue;
1061
+ }
1062
+
1063
+ let buttonHTML = "";
1064
+ try {
1065
+ buttonHTML = await getOuterHTML(client, buttonState.target.node_id);
1066
+ } catch {}
1067
+
1068
+ if (isUnsafeChatOnlineResumeTarget(buttonState.target, buttonHTML)) {
1069
+ const error = makeUnsafeChatOnlineResumeLinkError(buttonState.target, buttonHTML);
1070
+ attempts.push({
1071
+ attempt: index + 1,
1072
+ ok: false,
1073
+ error: error.code,
1074
+ blocked_pre_click: true,
1075
+ button_selector: buttonState.target.selector,
1076
+ button_text: error.button_text,
1077
+ button_href: error.href,
1078
+ button_html_length: buttonHTML.length
1079
+ });
1080
+ error.attempts = attempts;
1081
+ throw error;
1082
+ }
1083
+
1084
+ try {
1085
+ if (buttonState.target.center) {
1086
+ await clickPoint(client, buttonState.target.center.x, buttonState.target.center.y);
1087
+ } else {
1088
+ await clickNodeCenter(client, buttonState.target.node_id, {
1089
+ scrollIntoView: true
1090
+ });
1091
+ }
1092
+ } catch (error) {
1093
+ attempts.push({
1094
+ attempt: index + 1,
1095
+ ok: false,
1096
+ error: error?.message || String(error),
1097
+ recoverable_stale_node: isRecoverableNodeError(error),
1098
+ button_selector: buttonState.target.selector,
1099
+ button_text: htmlToText(buttonHTML).slice(0, 120),
1100
+ button_html_length: buttonHTML.length
1101
+ });
1102
+ if (isRecoverableNodeError(error)) {
1103
+ await sleep(350);
1104
+ continue;
1105
+ }
1106
+ throw error;
1107
+ }
1108
+ await assertChatShellNotResumeTopLevel(client, {
1109
+ context: "openChatOnlineResume:after_online_resume_click"
1110
+ });
1111
+ const resumeState = await waitForChatResumeModal(client, {
1112
+ timeoutMs: Math.max(2500, Math.floor(timeoutMs / attemptsLimit))
1113
+ });
1114
+ if (resumeState?.forbidden_top_level_navigation) {
1115
+ throw makeForbiddenChatResumeNavigationError(resumeState.top_level_state);
1116
+ }
1117
+ attempts.push({
1118
+ attempt: index + 1,
1119
+ ok: Boolean(resumeState?.popup || resumeState?.content || resumeState?.resumeIframe),
1120
+ button_selector: buttonState.target.selector,
1121
+ button_text: htmlToText(buttonHTML).slice(0, 120),
1122
+ button_html_length: buttonHTML.length,
1123
+ resume_popup_selector: resumeState?.popup?.selector || null,
1124
+ resume_content_selector: resumeState?.content?.selector || null,
1125
+ resume_iframe_selector: resumeState?.resumeIframe?.selector || null
1126
+ });
1127
+ if (resumeState?.popup || resumeState?.content || resumeState?.resumeIframe) {
1128
+ return {
1129
+ button: buttonState.target,
1130
+ button_html: buttonHTML,
1131
+ resume_state: resumeState,
1132
+ attempts
1133
+ };
1134
+ }
1135
+ }
1136
+
1137
+ const error = new Error("Chat online resume modal did not open");
1138
+ error.attempts = attempts;
1139
+ throw error;
1140
+ }
1141
+
1142
+ export async function readChatConversationReadyState(client) {
1143
+ const rootState = await getChatRoots(client);
1144
+ const scopedControlRoots = await resolveScopedRoots(
1145
+ client,
1146
+ rootState.roots,
1147
+ CHAT_CONVERSATION_CONTROL_SCOPE_SELECTORS,
1148
+ { fallbackToRoots: false }
1149
+ );
1150
+ const scopedRequestedRoots = await resolveScopedRoots(
1151
+ client,
1152
+ rootState.roots,
1153
+ CHAT_REQUESTED_RESUME_SCOPE_SELECTORS,
1154
+ { fallbackToRoots: false }
1155
+ );
1156
+ const controlRoots = scopedControlRoots.length ? scopedControlRoots : rootState.roots;
1157
+ const requestedRoots = scopedRequestedRoots.length ? scopedRequestedRoots : rootState.roots;
1158
+ const onlineResume = await findVisibleMatchingTarget(
1159
+ client,
1160
+ controlRoots,
1161
+ CHAT_ONLINE_RESUME_BUTTON_SELECTORS,
1162
+ (target) => target.label.includes("在线简历") && !target.disabled
1163
+ );
1164
+ const attachmentResume = await findVisibleMatchingTarget(
1165
+ client,
1166
+ controlRoots,
1167
+ CHAT_ATTACHMENT_RESUME_BUTTON_SELECTORS,
1168
+ (target) => isAttachmentResumeText(target.label)
1169
+ );
1170
+ const askResume = await findVisibleMatchingTarget(
1171
+ client,
1172
+ controlRoots,
1173
+ CHAT_ASK_RESUME_BUTTON_SELECTORS,
1174
+ (target) => isAskResumeText(target.label) && !isAttachmentResumeTarget(target)
1175
+ );
1176
+ const requestedResume = await findVisibleMatchingTarget(
1177
+ client,
1178
+ requestedRoots,
1179
+ CHAT_ASK_RESUME_BUTTON_SELECTORS,
1180
+ (target) => isRequestedResumeControlTarget(target)
1181
+ );
1182
+ const editor = await findVisibleMatchingTarget(
1183
+ client,
1184
+ controlRoots,
1185
+ CHAT_EDITOR_SELECTORS,
1186
+ () => true
1187
+ );
1188
+ const sendButton = await findVisibleMatchingTarget(
1189
+ client,
1190
+ controlRoots,
1191
+ CHAT_SEND_BUTTON_SELECTORS,
1192
+ (target) => isSendText(target.label) || /submit/i.test(String(target.attributes?.class || ""))
1193
+ );
1194
+ const resumeState = await waitForChatResumeModal(client, { timeoutMs: 300 });
1195
+ const blockingPanel = await findChatBlockingPanel(client);
1196
+ const resumeModalOpen = Boolean(resumeState?.popup || resumeState?.content || resumeState?.resumeIframe);
1197
+ const blockingPanelOpen = Boolean(blockingPanel?.open);
1198
+ return {
1199
+ has_online_resume: Boolean(onlineResume),
1200
+ online_resume: onlineResume,
1201
+ has_ask_resume: Boolean(askResume),
1202
+ ask_resume: askResume,
1203
+ already_requested_resume: Boolean(requestedResume),
1204
+ requested_resume: requestedResume,
1205
+ has_attachment_resume: Boolean(attachmentResume),
1206
+ attachment_resume_enabled: Boolean(attachmentResume && !attachmentResume.disabled),
1207
+ attachment_resume: attachmentResume,
1208
+ editor_visible: Boolean(editor),
1209
+ editor,
1210
+ send_button_visible: Boolean(sendButton),
1211
+ send_button: sendButton,
1212
+ resume_modal_open: resumeModalOpen,
1213
+ blocking_panel_open: blockingPanelOpen,
1214
+ blocking_panel: blockingPanelOpen ? blockingPanel : null,
1215
+ panels_closed: !resumeModalOpen && !blockingPanelOpen
1216
+ };
1217
+ }
1218
+
1219
+ export async function setChatEditorMessage(client, message, {
1220
+ timeoutMs = 8000
1221
+ } = {}) {
1222
+ const started = Date.now();
1223
+ let lastState = null;
1224
+ while (Date.now() - started <= timeoutMs) {
1225
+ const state = await readChatConversationReadyState(client);
1226
+ lastState = state;
1227
+ if (state.editor?.node_id) {
1228
+ try {
1229
+ if (state.editor.center) {
1230
+ await clickPoint(client, state.editor.center.x, state.editor.center.y);
1231
+ } else {
1232
+ await clickNodeCenter(client, state.editor.node_id, { scrollIntoView: true });
1233
+ }
1234
+ await sleep(120);
1235
+ await clearFocusedInput(client);
1236
+ await sleep(80);
1237
+ await insertText(client, message);
1238
+ await sleep(250);
1239
+ const afterState = await readChatConversationReadyState(client);
1240
+ const editorText = normalizeDetailText(afterState.editor?.label || "");
1241
+ if (editorText.includes(normalizeDetailText(message))) {
1242
+ return {
1243
+ ok: true,
1244
+ value: editorText,
1245
+ editor: afterState.editor || state.editor
1246
+ };
1247
+ }
1248
+ lastState = {
1249
+ ...afterState,
1250
+ editor_message_mismatch: true,
1251
+ editor_text: editorText
1252
+ };
1253
+ } catch (error) {
1254
+ if (!isRecoverableNodeError(error)) throw error;
1255
+ lastState = {
1256
+ ...state,
1257
+ recoverable_error: error?.message || String(error),
1258
+ recoverable_phase: "set_editor_message"
1259
+ };
1260
+ }
1261
+ }
1262
+ await sleep(250);
1263
+ }
1264
+ return {
1265
+ ok: false,
1266
+ error: "CHAT_EDITOR_NOT_FOUND",
1267
+ state: lastState
1268
+ };
1269
+ }
1270
+
1271
+ export async function sendChatMessage(client, expectedText = "", {
1272
+ timeoutMs = 8000,
1273
+ settleMs = 800
1274
+ } = {}) {
1275
+ const started = Date.now();
1276
+ let lastState = null;
1277
+ while (Date.now() - started <= timeoutMs) {
1278
+ const state = await readChatConversationReadyState(client);
1279
+ lastState = state;
1280
+ if (state.send_button?.node_id && !state.send_button.disabled) {
1281
+ try {
1282
+ if (state.send_button.center) {
1283
+ await clickPoint(client, state.send_button.center.x, state.send_button.center.y);
1284
+ } else {
1285
+ await clickNodeCenter(client, state.send_button.node_id, { scrollIntoView: true });
1286
+ }
1287
+ if (settleMs > 0) await sleep(settleMs);
1288
+ return {
1289
+ sent: true,
1290
+ method: "send-button",
1291
+ control: state.send_button,
1292
+ expected_text: expectedText
1293
+ };
1294
+ } catch (error) {
1295
+ if (!isRecoverableNodeError(error)) throw error;
1296
+ lastState = {
1297
+ ...state,
1298
+ recoverable_error: error?.message || String(error),
1299
+ recoverable_phase: "send_button_click"
1300
+ };
1301
+ await sleep(250);
1302
+ continue;
1303
+ }
1304
+ }
1305
+ if (state.editor?.node_id) {
1306
+ await pressKey(client, "Enter", {
1307
+ code: "Enter",
1308
+ windowsVirtualKeyCode: 13,
1309
+ nativeVirtualKeyCode: 13
1310
+ });
1311
+ if (settleMs > 0) await sleep(settleMs);
1312
+ return {
1313
+ sent: true,
1314
+ method: "enter",
1315
+ expected_text: expectedText
1316
+ };
1317
+ }
1318
+ await sleep(250);
1319
+ }
1320
+ return {
1321
+ sent: false,
1322
+ method: "none",
1323
+ error: "CHAT_SEND_CONTROL_NOT_FOUND",
1324
+ state: lastState
1325
+ };
1326
+ }
1327
+
1328
+ export async function clickChatAskResume(client, {
1329
+ timeoutMs = 8000,
1330
+ settleMs = 700
1331
+ } = {}) {
1332
+ const started = Date.now();
1333
+ let lastState = null;
1334
+ let lastDisabledAskResume = null;
1335
+ while (Date.now() - started <= timeoutMs) {
1336
+ const state = await readChatConversationReadyState(client);
1337
+ lastState = state;
1338
+ if (state.attachment_resume_enabled) {
1339
+ return {
1340
+ ok: true,
1341
+ already_requested: true,
1342
+ attachment_resume_available: true,
1343
+ control: state.attachment_resume
1344
+ };
1345
+ }
1346
+ if (state.ask_resume?.node_id && !state.ask_resume.disabled) {
1347
+ try {
1348
+ if (state.ask_resume.center) {
1349
+ await clickPoint(client, state.ask_resume.center.x, state.ask_resume.center.y);
1350
+ } else {
1351
+ await clickNodeCenter(client, state.ask_resume.node_id, { scrollIntoView: true });
1352
+ }
1353
+ if (settleMs > 0) await sleep(settleMs);
1354
+ return {
1355
+ ok: true,
1356
+ already_requested: false,
1357
+ control: state.ask_resume
1358
+ };
1359
+ } catch (error) {
1360
+ if (!isRecoverableNodeError(error)) throw error;
1361
+ lastState = {
1362
+ ...state,
1363
+ recoverable_error: error?.message || String(error),
1364
+ recoverable_phase: "ask_resume_click"
1365
+ };
1366
+ }
1367
+ }
1368
+ if (state.ask_resume?.node_id && state.ask_resume.disabled) {
1369
+ lastDisabledAskResume = state.ask_resume;
1370
+ }
1371
+ if (state.already_requested_resume) {
1372
+ return {
1373
+ ok: true,
1374
+ already_requested: true,
1375
+ control: state.requested_resume
1376
+ };
1377
+ }
1378
+ await sleep(250);
1379
+ }
1380
+ if (lastDisabledAskResume) {
1381
+ return {
1382
+ ok: false,
1383
+ already_requested: true,
1384
+ request_pending: true,
1385
+ error: "ASK_RESUME_BUTTON_DISABLED",
1386
+ control: lastDisabledAskResume,
1387
+ state: lastState
1388
+ };
1389
+ }
1390
+ return {
1391
+ ok: false,
1392
+ error: "ASK_RESUME_BUTTON_NOT_FOUND",
1393
+ state: lastState
1394
+ };
1395
+ }
1396
+
1397
+ export async function clickChatConfirmRequestResume(client, {
1398
+ timeoutMs = 8000,
1399
+ settleMs = 900
1400
+ } = {}) {
1401
+ const started = Date.now();
1402
+ let lastTarget = null;
1403
+ let lastState = null;
1404
+ while (Date.now() - started <= timeoutMs) {
1405
+ lastState = await readChatConversationReadyState(client);
1406
+ const rootState = await getChatRoots(client);
1407
+ const confirmRoots = await resolveScopedRoots(
1408
+ client,
1409
+ rootState.roots,
1410
+ CHAT_CONVERSATION_CONTROL_SCOPE_SELECTORS
1411
+ );
1412
+ const target = await findVisibleMatchingTarget(
1413
+ client,
1414
+ confirmRoots,
1415
+ CHAT_CONFIRM_REQUEST_RESUME_SELECTORS,
1416
+ (item) => isConfirmText(item.label) && !item.disabled
1417
+ );
1418
+ lastTarget = target;
1419
+ if (target?.node_id) {
1420
+ try {
1421
+ if (target.center) {
1422
+ await clickPoint(client, target.center.x, target.center.y);
1423
+ } else {
1424
+ await clickNodeCenter(client, target.node_id, { scrollIntoView: true });
1425
+ }
1426
+ if (settleMs > 0) await sleep(settleMs);
1427
+ const afterState = await readChatConversationReadyState(client);
1428
+ return {
1429
+ confirmed: true,
1430
+ assumed_requested: Boolean(afterState.already_requested_resume),
1431
+ control: target,
1432
+ state: afterState
1433
+ };
1434
+ } catch (error) {
1435
+ if (!isRecoverableNodeError(error)) throw error;
1436
+ lastTarget = {
1437
+ ...target,
1438
+ recoverable_error: error?.message || String(error),
1439
+ recoverable_phase: "confirm_request_resume_click"
1440
+ };
1441
+ }
1442
+ }
1443
+ await sleep(250);
1444
+ }
1445
+ return {
1446
+ confirmed: false,
1447
+ error: "CONFIRM_BUTTON_NOT_FOUND",
1448
+ control: lastTarget,
1449
+ state: lastState
1450
+ };
1451
+ }
1452
+
1453
+ export async function getChatResumeRequestMessageState(client) {
1454
+ const rootState = await getChatRoots(client);
1455
+ let messageRoot = null;
1456
+ for (const root of rootState.roots) {
1457
+ for (const selector of CHAT_MESSAGE_LIST_SELECTORS) {
1458
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
1459
+ if (nodeIds.length) {
1460
+ messageRoot = {
1461
+ root,
1462
+ selector,
1463
+ node_id: nodeIds[0]
1464
+ };
1465
+ break;
1466
+ }
1467
+ }
1468
+ if (messageRoot) break;
1469
+ }
1470
+ const nodeId = messageRoot?.node_id || rootState.rootNodes.top;
1471
+ let text = "";
1472
+ try {
1473
+ text = htmlToText(await getOuterHTML(client, nodeId));
1474
+ } catch {}
1475
+ const lines = text.split(/\r?\n/).map(normalizeDetailText).filter(Boolean);
1476
+ const matching = lines.filter((line) => isResumeRequestSentMessageText(line));
1477
+ const attachmentMatching = lines.filter((line) => isResumeAttachmentMessageText(line));
1478
+ const count = countResumeRequestSentMessageMarkers(lines);
1479
+ const resumeAttachmentCount = countResumeAttachmentMessageMarkers(lines);
1480
+ return {
1481
+ ok: Boolean(text),
1482
+ selector: messageRoot?.selector || "top",
1483
+ count,
1484
+ resume_attachment_count: resumeAttachmentCount,
1485
+ success_count: count + resumeAttachmentCount,
1486
+ last_text: matching[matching.length - 1] || lines[lines.length - 1] || "",
1487
+ last_resume_attachment_text: attachmentMatching[attachmentMatching.length - 1] || "",
1488
+ last_success_text: matching[matching.length - 1] || attachmentMatching[attachmentMatching.length - 1] || "",
1489
+ recent: lines.slice(-12)
1490
+ };
1491
+ }
1492
+
1493
+ export async function waitForChatResumeRequestMessage(client, {
1494
+ baselineCount = 0,
1495
+ baselineResumeAttachmentCount = 0,
1496
+ timeoutMs = 6500,
1497
+ intervalMs = 260
1498
+ } = {}) {
1499
+ const started = Date.now();
1500
+ let state = null;
1501
+ while (Date.now() - started <= timeoutMs) {
1502
+ state = await getChatResumeRequestMessageState(client);
1503
+ const observed = (
1504
+ state.count > baselineCount
1505
+ || state.resume_attachment_count > baselineResumeAttachmentCount
1506
+ );
1507
+ if (observed) {
1508
+ return {
1509
+ observed: true,
1510
+ elapsed_ms: Date.now() - started,
1511
+ state
1512
+ };
1513
+ }
1514
+ await sleep(intervalMs);
1515
+ }
1516
+ return {
1517
+ observed: false,
1518
+ elapsed_ms: Date.now() - started,
1519
+ state
1520
+ };
1521
+ }
1522
+
1523
+ export async function requestChatResumeForPassedCandidate(client, {
1524
+ greetingText = "Hi同学,能麻烦发下简历吗?",
1525
+ maxAttempts = 3,
1526
+ askResumeTimeoutMs = 8000,
1527
+ dryRun = false
1528
+ } = {}) {
1529
+ const effectiveGreetingText = normalizeDetailText(greetingText) || "Hi同学,能麻烦发下简历吗?";
1530
+ const initialState = await readChatConversationReadyState(client);
1531
+ if (initialState.attachment_resume_enabled) {
1532
+ return {
1533
+ requested: false,
1534
+ skipped: true,
1535
+ reason: "attachment_resume_already_available",
1536
+ initial_state: initialState
1537
+ };
1538
+ }
1539
+ if (dryRun) {
1540
+ return {
1541
+ requested: false,
1542
+ skipped: false,
1543
+ reason: "dry_run",
1544
+ initial_state: initialState,
1545
+ would_send_greeting: true,
1546
+ would_click_ask_resume: true
1547
+ };
1548
+ }
1549
+
1550
+ const closeBeforeGreeting = await closeChatResumeModal(client, { attemptsLimit: 3 });
1551
+ if (!closeBeforeGreeting.closed) {
1552
+ return {
1553
+ requested: false,
1554
+ skipped: true,
1555
+ reason: "resume_modal_close_failed_before_request",
1556
+ initial_state: initialState,
1557
+ close_before_greeting: closeBeforeGreeting
1558
+ };
1559
+ }
1560
+ const editorState = await setChatEditorMessage(client, effectiveGreetingText);
1561
+ if (!editorState.ok) {
1562
+ throw new Error("CHAT_EDITOR_MESSAGE_MISMATCH");
1563
+ }
1564
+ const sendResult = await sendChatMessage(client, effectiveGreetingText);
1565
+ if (!sendResult.sent) {
1566
+ throw new Error(`CHAT_GREETING_SEND_FAILED:${sendResult.error || sendResult.method || "unknown"}`);
1567
+ }
1568
+
1569
+ const attempts = [];
1570
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1571
+ const before = await getChatResumeRequestMessageState(client);
1572
+ const askResult = await clickChatAskResume(client, {
1573
+ timeoutMs: askResumeTimeoutMs
1574
+ });
1575
+ let confirmResult = {
1576
+ confirmed: false,
1577
+ assumed_requested: Boolean(askResult.already_requested),
1578
+ skipped: true,
1579
+ reason: askResult.attachment_resume_available
1580
+ ? "attachment_resume_already_available"
1581
+ : askResult.request_pending
1582
+ ? "resume_request_already_pending"
1583
+ : askResult.ok
1584
+ ? "already_requested"
1585
+ : (askResult.error || "ask_resume_not_clicked")
1586
+ };
1587
+ if (askResult.attachment_resume_available) {
1588
+ attempts.push({
1589
+ attempt: attempt + 1,
1590
+ ask_result: askResult,
1591
+ confirm_result: confirmResult,
1592
+ message_before_count: before.count,
1593
+ message_after_count: before.count,
1594
+ message_observed: false,
1595
+ message_last_text: before.last_text || ""
1596
+ });
1597
+ return {
1598
+ requested: false,
1599
+ skipped: true,
1600
+ reason: "attachment_resume_already_available",
1601
+ initial_state: initialState,
1602
+ close_before_greeting: closeBeforeGreeting,
1603
+ greeting_sent: true,
1604
+ greeting_send_result: sendResult,
1605
+ attempts
1606
+ };
1607
+ }
1608
+ if (askResult.request_pending || askResult.already_requested) {
1609
+ attempts.push({
1610
+ attempt: attempt + 1,
1611
+ ask_result: askResult,
1612
+ confirm_result: confirmResult,
1613
+ message_before_count: before.count,
1614
+ message_after_count: before.count,
1615
+ resume_attachment_before_count: before.resume_attachment_count || 0,
1616
+ resume_attachment_after_count: before.resume_attachment_count || 0,
1617
+ message_observed: false,
1618
+ message_last_text: before.last_success_text || before.last_text || ""
1619
+ });
1620
+ return {
1621
+ requested: false,
1622
+ skipped: true,
1623
+ reason: "resume_request_already_pending",
1624
+ initial_state: initialState,
1625
+ close_before_greeting: closeBeforeGreeting,
1626
+ greeting_sent: true,
1627
+ greeting_send_result: sendResult,
1628
+ attempts
1629
+ };
1630
+ }
1631
+ if (askResult.ok && !askResult.already_requested) {
1632
+ confirmResult = await clickChatConfirmRequestResume(client);
1633
+ }
1634
+ const messageCheck = await waitForChatResumeRequestMessage(client, {
1635
+ baselineCount: before.count,
1636
+ baselineResumeAttachmentCount: before.resume_attachment_count
1637
+ });
1638
+ const messageObserved = Boolean(messageCheck.observed);
1639
+ attempts.push({
1640
+ attempt: attempt + 1,
1641
+ ask_result: askResult,
1642
+ confirm_result: confirmResult,
1643
+ message_before_count: before.count,
1644
+ message_after_count: messageCheck.state?.count || 0,
1645
+ resume_attachment_before_count: before.resume_attachment_count || 0,
1646
+ resume_attachment_after_count: messageCheck.state?.resume_attachment_count || 0,
1647
+ message_observed: messageObserved,
1648
+ message_last_text: messageCheck.state?.last_success_text || messageCheck.state?.last_text || ""
1649
+ });
1650
+ if (messageObserved) {
1651
+ return {
1652
+ requested: true,
1653
+ skipped: false,
1654
+ reason: "requested",
1655
+ initial_state: initialState,
1656
+ close_before_greeting: closeBeforeGreeting,
1657
+ greeting_sent: true,
1658
+ greeting_send_result: sendResult,
1659
+ attempts
1660
+ };
1661
+ }
1662
+ await sleep(900);
1663
+ }
1664
+
1665
+ return {
1666
+ requested: false,
1667
+ skipped: false,
1668
+ reason: "resume_request_message_not_observed",
1669
+ initial_state: initialState,
1670
+ close_before_greeting: closeBeforeGreeting,
1671
+ greeting_sent: true,
1672
+ greeting_send_result: sendResult,
1673
+ attempts
1674
+ };
1675
+ }
1676
+
1677
+ export async function closeChatResumeModal(client, {
1678
+ attemptsLimit = 3
1679
+ } = {}) {
1680
+ const attempts = [];
1681
+ for (let index = 0; index < attemptsLimit; index += 1) {
1682
+ const existingState = await waitForChatResumeModal(client, { timeoutMs: 500 });
1683
+ if (!existingState?.popup && !existingState?.content && !existingState?.resumeIframe) {
1684
+ return {
1685
+ closed: true,
1686
+ attempts
1687
+ };
1688
+ }
1689
+
1690
+ const rootState = await getChatRoots(client);
1691
+ const closeTarget = await findVisibleTarget(client, rootState.roots, CHAT_RESUME_CLOSE_SELECTORS);
1692
+ if (closeTarget) {
1693
+ try {
1694
+ await clickPoint(client, closeTarget.center.x, closeTarget.center.y, DETERMINISTIC_CLICK_OPTIONS);
1695
+ attempts.push({
1696
+ mode: "close-selector",
1697
+ selector: closeTarget.selector,
1698
+ root: closeTarget.root
1699
+ });
1700
+ } catch (error) {
1701
+ attempts.push({
1702
+ mode: "close-selector-error",
1703
+ selector: closeTarget.selector,
1704
+ root: closeTarget.root,
1705
+ error: error?.message || String(error)
1706
+ });
1707
+ }
1708
+ await sleep(700);
1709
+ } else {
1710
+ await pressEscape(client);
1711
+ attempts.push({ mode: "Escape" });
1712
+ await sleep(700);
1713
+ }
1714
+
1715
+ let state = await waitForChatResumeModal(client, { timeoutMs: 1000 });
1716
+ if (!state?.popup && !state?.content && !state?.resumeIframe) {
1717
+ return {
1718
+ closed: true,
1719
+ attempts
1720
+ };
1721
+ }
1722
+
1723
+ await pressEscape(client);
1724
+ attempts.push({ mode: "Escape-fallback" });
1725
+ await sleep(700);
1726
+
1727
+ state = await waitForChatResumeModal(client, { timeoutMs: 1000 });
1728
+ if (!state?.popup && !state?.content && !state?.resumeIframe) {
1729
+ return {
1730
+ closed: true,
1731
+ attempts
1732
+ };
1733
+ }
1734
+ }
1735
+
1736
+ return {
1737
+ closed: false,
1738
+ attempts
1739
+ };
1740
+ }
1741
+
1742
+ export async function extractChatProfileCandidate(client, {
1743
+ cardCandidate,
1744
+ cardNodeId,
1745
+ resumeState,
1746
+ resumeHtml: providedResumeHtml = null,
1747
+ networkEvents = [],
1748
+ targetUrl = "",
1749
+ closeResume = true,
1750
+ networkParseRetryMs = 1800,
1751
+ networkParseIntervalMs = 250
1752
+ } = {}) {
1753
+ let resumeHtml = providedResumeHtml || null;
1754
+ if (!resumeHtml) {
1755
+ try {
1756
+ resumeHtml = await readChatResumeHtml(client, resumeState);
1757
+ } catch (error) {
1758
+ if (!networkEvents.length) throw error;
1759
+ resumeHtml = emptyChatResumeHtml(error);
1760
+ }
1761
+ }
1762
+ const detailText = [
1763
+ resumeHtml.popupText,
1764
+ resumeHtml.contentText,
1765
+ resumeHtml.resumeIframeText
1766
+ ].filter(Boolean).join("\n\n");
1767
+
1768
+ const parseStarted = Date.now();
1769
+ let networkBodies = [];
1770
+ let detailCandidateResult = null;
1771
+ do {
1772
+ networkBodies = await readChatProfileNetworkBodies(client, networkEvents);
1773
+ detailCandidateResult = buildScreeningCandidateFromDetail({
1774
+ domain: "chat",
1775
+ source: "chat-live-cdp-profile",
1776
+ cardCandidate,
1777
+ detailText,
1778
+ networkBodies,
1779
+ metadata: {
1780
+ target_url: targetUrl,
1781
+ card_node_id: cardNodeId,
1782
+ resume_popup_selector: resumeState?.popup?.selector || null,
1783
+ resume_content_selector: resumeState?.content?.selector || null,
1784
+ resume_iframe_selector: resumeState?.resumeIframe?.selector || null,
1785
+ resume_iframe_document_node_id: resumeHtml.resumeIframeDocumentNodeId
1786
+ }
1787
+ });
1788
+ if (detailCandidateResult.parsed_network_profiles.some((item) => item.ok)) break;
1789
+ if (Date.now() - parseStarted >= Math.max(0, Number(networkParseRetryMs) || 0)) break;
1790
+ await sleep(Math.max(50, Number(networkParseIntervalMs) || 250));
1791
+ } while (true);
1792
+
1793
+ let closeResult = null;
1794
+ if (closeResume) {
1795
+ closeResult = await closeChatResumeModal(client);
1796
+ }
1797
+
1798
+ return {
1799
+ candidate: detailCandidateResult.candidate,
1800
+ parsed_network_profiles: detailCandidateResult.parsed_network_profiles,
1801
+ network_bodies: networkBodies,
1802
+ network_parse_retry_elapsed_ms: Date.now() - parseStarted,
1803
+ network_event_count: networkEvents.length,
1804
+ detail: {
1805
+ popup_text: resumeHtml.popupText,
1806
+ content_text: resumeHtml.contentText,
1807
+ resume_iframe_text: resumeHtml.resumeIframeText,
1808
+ popup_html_length: resumeHtml.popupHTML.length,
1809
+ content_html_length: resumeHtml.contentHTML.length,
1810
+ resume_iframe_html_length: resumeHtml.resumeIframeHTML.length
1811
+ },
1812
+ resume_html_read_error: resumeHtml.readError || null,
1813
+ close_result: closeResult
1814
+ };
1815
+ }
1816
+
1817
+ async function findVisibleTarget(client, roots, selectors) {
1818
+ let fallback = null;
1819
+ for (const root of roots) {
1820
+ if (!root?.nodeId) continue;
1821
+ for (const selector of selectors) {
1822
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
1823
+ for (const nodeId of nodeIds) {
1824
+ const target = {
1825
+ root: root.name,
1826
+ root_node_id: root.nodeId,
1827
+ selector,
1828
+ node_id: nodeId
1829
+ };
1830
+ if (!fallback) fallback = target;
1831
+ try {
1832
+ const box = await getNodeBox(client, nodeId);
1833
+ if (box.rect.width > 2 && box.rect.height > 2) {
1834
+ return {
1835
+ ...target,
1836
+ center: box.center,
1837
+ rect: box.rect
1838
+ };
1839
+ }
1840
+ } catch {}
1841
+ }
1842
+ }
1843
+ }
1844
+ return fallback;
1845
+ }
1846
+
1847
+ async function pressEscape(client) {
1848
+ await pressKey(client, "Escape", {
1849
+ code: "Escape",
1850
+ windowsVirtualKeyCode: 27,
1851
+ nativeVirtualKeyCode: 27
1852
+ });
1853
+ }