@reconcrap/boss-recommend-mcp 1.1.4 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/adapters.js CHANGED
@@ -1392,6 +1392,221 @@ function pickBossRecommendReloadTarget(tabs = []) {
1392
1392
  ) || null;
1393
1393
  }
1394
1394
 
1395
+ async function evaluateCdpExpression(client, expression) {
1396
+ const result = await client.Runtime.evaluate({
1397
+ expression,
1398
+ returnByValue: true,
1399
+ awaitPromise: true
1400
+ });
1401
+ if (result.exceptionDetails) {
1402
+ throw new Error(result.exceptionDetails.exception?.description || "Runtime.evaluate failed");
1403
+ }
1404
+ return result.result?.value;
1405
+ }
1406
+
1407
+ function buildRecommendRefreshStateExpression() {
1408
+ return `(() => {
1409
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
1410
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1411
+ || document.querySelector('iframe');
1412
+ if (!frame || !frame.contentDocument) {
1413
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1414
+ }
1415
+ const doc = frame.contentDocument;
1416
+ const isVisible = (el) => {
1417
+ if (!el) return false;
1418
+ const win = doc.defaultView;
1419
+ if (!win) return el.offsetParent !== null;
1420
+ const style = win.getComputedStyle(el);
1421
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.02) {
1422
+ return false;
1423
+ }
1424
+ const rect = el.getBoundingClientRect();
1425
+ return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
1426
+ };
1427
+ const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
1428
+ const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
1429
+ .find((el) => isVisible(el)) || null;
1430
+ const cards = Array.from(doc.querySelectorAll('ul.card-list > li.card-item'));
1431
+ const candidateCards = cards.filter((card) => card.querySelector('.card-inner[data-geekid]'));
1432
+ const finishedText = finishedWrap ? String(finishedWrap.textContent || '').replace(/\\s+/g, ' ').trim() : '';
1433
+ const buttonText = refreshButton ? String(refreshButton.textContent || '').replace(/\\s+/g, ' ').trim() : '';
1434
+ return {
1435
+ ok: true,
1436
+ frame_url: (() => {
1437
+ try { return String(frame.contentWindow.location.href || ''); } catch { return ''; }
1438
+ })(),
1439
+ finished_wrap_visible: Boolean(finishedWrap),
1440
+ finished_wrap_text: finishedText || null,
1441
+ refresh_button_visible: Boolean(refreshButton),
1442
+ refresh_button_text: buttonText || null,
1443
+ candidate_count: candidateCards.length,
1444
+ total_card_count: cards.length,
1445
+ list_ready: candidateCards.length > 0
1446
+ };
1447
+ })()`;
1448
+ }
1449
+
1450
+ function buildRecommendRefreshClickExpression() {
1451
+ return `(() => {
1452
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
1453
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1454
+ || document.querySelector('iframe');
1455
+ if (!frame || !frame.contentDocument) {
1456
+ return { ok: false, state: 'NO_RECOMMEND_IFRAME' };
1457
+ }
1458
+ const doc = frame.contentDocument;
1459
+ const isVisible = (el) => {
1460
+ if (!el) return false;
1461
+ const win = doc.defaultView;
1462
+ if (!win) return el.offsetParent !== null;
1463
+ const style = win.getComputedStyle(el);
1464
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.02) {
1465
+ return false;
1466
+ }
1467
+ const rect = el.getBoundingClientRect();
1468
+ return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
1469
+ };
1470
+ const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
1471
+ .find((el) => isVisible(el)) || null;
1472
+ if (!refreshButton) {
1473
+ return { ok: false, state: 'REFRESH_BUTTON_NOT_FOUND' };
1474
+ }
1475
+ try {
1476
+ refreshButton.click();
1477
+ return {
1478
+ ok: true,
1479
+ state: 'REFRESH_BUTTON_CLICKED',
1480
+ refresh_button_text: String(refreshButton.textContent || '').replace(/\\s+/g, ' ').trim() || null
1481
+ };
1482
+ } catch (error) {
1483
+ return {
1484
+ ok: false,
1485
+ state: 'REFRESH_BUTTON_CLICK_FAILED',
1486
+ message: error?.message || String(error)
1487
+ };
1488
+ }
1489
+ })()`;
1490
+ }
1491
+
1492
+ export async function refreshBossRecommendList(workspaceRoot, options = {}) {
1493
+ const debugPort = Number.isFinite(options.port)
1494
+ ? options.port
1495
+ : resolveWorkspaceDebugPort(workspaceRoot);
1496
+ const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 600;
1497
+ const reloadTimeoutMs = Number.isFinite(options.reloadTimeoutMs) ? options.reloadTimeoutMs : 10000;
1498
+
1499
+ let client = null;
1500
+ try {
1501
+ const tabs = await listChromeTabs(debugPort);
1502
+ const target = pickBossRecommendReloadTarget(tabs);
1503
+ if (!target) {
1504
+ return {
1505
+ ok: false,
1506
+ action: "in_page_refresh",
1507
+ debug_port: debugPort,
1508
+ state: "BOSS_TAB_NOT_FOUND",
1509
+ message: "未找到可操作的 Boss recommend 标签页。",
1510
+ before_state: null,
1511
+ after_state: null
1512
+ };
1513
+ }
1514
+
1515
+ client = await CDP({ port: debugPort, target });
1516
+ const { Page, Runtime } = client;
1517
+ if (Runtime && typeof Runtime.enable === "function") {
1518
+ await Runtime.enable();
1519
+ }
1520
+ if (Page && typeof Page.enable === "function") {
1521
+ await Page.enable();
1522
+ }
1523
+ if (Page && typeof Page.bringToFront === "function") {
1524
+ await Page.bringToFront();
1525
+ }
1526
+
1527
+ const beforeState = await evaluateCdpExpression(client, buildRecommendRefreshStateExpression());
1528
+ if (!beforeState?.ok) {
1529
+ return {
1530
+ ok: false,
1531
+ action: "in_page_refresh",
1532
+ debug_port: debugPort,
1533
+ state: beforeState?.error || "NO_RECOMMEND_IFRAME",
1534
+ message: "未能读取 recommend iframe,无法执行页内刷新。",
1535
+ before_state: beforeState || null,
1536
+ after_state: null
1537
+ };
1538
+ }
1539
+ if (!beforeState.refresh_button_visible) {
1540
+ return {
1541
+ ok: false,
1542
+ action: "in_page_refresh",
1543
+ debug_port: debugPort,
1544
+ state: "REFRESH_BUTTON_NOT_FOUND",
1545
+ message: "推荐列表到底后未发现可点击的刷新按钮。",
1546
+ before_state: beforeState,
1547
+ after_state: beforeState
1548
+ };
1549
+ }
1550
+
1551
+ const clickResult = await evaluateCdpExpression(client, buildRecommendRefreshClickExpression());
1552
+ if (!clickResult?.ok) {
1553
+ return {
1554
+ ok: false,
1555
+ action: "in_page_refresh",
1556
+ debug_port: debugPort,
1557
+ state: clickResult?.state || "REFRESH_BUTTON_CLICK_FAILED",
1558
+ message: clickResult?.message || "页内刷新按钮点击失败。",
1559
+ before_state: beforeState,
1560
+ after_state: null
1561
+ };
1562
+ }
1563
+
1564
+ const deadline = Date.now() + reloadTimeoutMs;
1565
+ let lastState = beforeState;
1566
+ while (Date.now() < deadline) {
1567
+ await sleep(pollMs);
1568
+ lastState = await evaluateCdpExpression(client, buildRecommendRefreshStateExpression());
1569
+ if (lastState?.ok && lastState.finished_wrap_visible === false && lastState.list_ready === true) {
1570
+ return {
1571
+ ok: true,
1572
+ action: "in_page_refresh",
1573
+ debug_port: debugPort,
1574
+ state: "RECOMMEND_READY",
1575
+ message: "已点击页内刷新按钮并重新拿到候选人列表。",
1576
+ before_state: beforeState,
1577
+ after_state: lastState
1578
+ };
1579
+ }
1580
+ }
1581
+
1582
+ return {
1583
+ ok: false,
1584
+ action: "in_page_refresh",
1585
+ debug_port: debugPort,
1586
+ state: "LIST_NOT_RELOADED",
1587
+ message: "已点击页内刷新按钮,但候选人列表未在超时内重新就绪。",
1588
+ before_state: beforeState,
1589
+ after_state: lastState
1590
+ };
1591
+ } catch (error) {
1592
+ return {
1593
+ ok: false,
1594
+ action: "in_page_refresh",
1595
+ debug_port: debugPort,
1596
+ state: "REFRESH_BUTTON_CLICK_FAILED",
1597
+ message: error?.message || "页内刷新失败。",
1598
+ before_state: null,
1599
+ after_state: null
1600
+ };
1601
+ } finally {
1602
+ if (client) {
1603
+ try {
1604
+ await client.close();
1605
+ } catch {}
1606
+ }
1607
+ }
1608
+ }
1609
+
1395
1610
  export async function reloadBossRecommendPage(workspaceRoot, options = {}) {
1396
1611
  const debugPort = Number.isFinite(options.port)
1397
1612
  ? options.port
package/src/pipeline.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  attemptPipelineAutoRepair,
5
5
  ensureBossRecommendPageReady,
6
6
  listRecommendJobs,
7
+ refreshBossRecommendList,
7
8
  reloadBossRecommendPage,
8
9
  runPipelinePreflight,
9
10
  runRecommendSearchCli,
@@ -499,6 +500,7 @@ const defaultDependencies = {
499
500
  parseRecommendInstruction,
500
501
  ensureBossRecommendPageReady,
501
502
  listRecommendJobs,
503
+ refreshBossRecommendList,
502
504
  reloadBossRecommendPage,
503
505
  runPipelinePreflight,
504
506
  runRecommendSearchCli,
@@ -517,6 +519,7 @@ export async function runRecommendPipeline(
517
519
  parseRecommendInstruction: parseInstruction,
518
520
  ensureBossRecommendPageReady: ensureRecommendPageReady,
519
521
  listRecommendJobs: listJobs,
522
+ refreshBossRecommendList: refreshRecommendList,
520
523
  reloadBossRecommendPage: reloadRecommendPage,
521
524
  runPipelinePreflight: runPreflight,
522
525
  runRecommendSearchCli: searchCli,
@@ -946,43 +949,156 @@ export async function runRecommendPipeline(
946
949
  });
947
950
  }
948
951
  if (!screenResult.ok) {
952
+ const screenErrorCode = String(screenResult.error?.code || "");
949
953
  const partialScreenResult = screenResult.summary || screenResult.structured?.result || null;
950
954
  const resumeOutputCsv = normalizeText(partialScreenResult?.output_csv || currentResumeConfig.output_csv || "");
951
955
  const hasCheckpointForRecovery = Boolean(normalizeText(currentResumeConfig.checkpoint_path || ""));
952
- const shouldAutoRecover = (
953
- screenResult.error?.code === "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT"
954
- && screenAutoRecoveryCount < MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS
955
- && hasCheckpointForRecovery
956
- && Boolean(resumeOutputCsv)
957
- );
956
+ const screenPartialForRecovery = partialScreenResult
957
+ ? {
958
+ processed_count: partialScreenResult.processed_count ?? null,
959
+ passed_count: partialScreenResult.passed_count ?? null,
960
+ skipped_count: partialScreenResult.skipped_count ?? null,
961
+ output_csv: partialScreenResult.output_csv || currentResumeConfig.output_csv || null,
962
+ checkpoint_path: partialScreenResult.checkpoint_path || currentResumeConfig.checkpoint_path || null,
963
+ completion_reason: partialScreenResult.completion_reason || null
964
+ }
965
+ : null;
966
+ const isResumeCaptureRecovery = screenErrorCode === "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT";
967
+ const isPageExhaustedRecovery = screenErrorCode === "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED";
968
+ const isRecoverableScreenFailure = isResumeCaptureRecovery || isPageExhaustedRecovery;
969
+ const canRecoverSafely = hasCheckpointForRecovery && Boolean(resumeOutputCsv);
970
+ const hasRecoveryAttemptsRemaining = screenAutoRecoveryCount < MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS;
971
+
972
+ if (isRecoverableScreenFailure && !canRecoverSafely) {
973
+ return buildFailedResponse(
974
+ "SCREEN_AUTO_RECOVERY_UNSAFE",
975
+ "检测到 recommend 自动恢复触发,但缺少 checkpoint 或 output_csv,无法安全续跑。",
976
+ {
977
+ search_params: effectiveSearchParams,
978
+ screen_params: parsed.screenParams,
979
+ selected_job: selectedJob,
980
+ partial_result: partialScreenResult,
981
+ diagnostics: {
982
+ debug_port: preflight.debug_port,
983
+ stdout: screenResult.stdout?.slice(-1000),
984
+ stderr: screenResult.stderr?.slice(-1000),
985
+ result: screenResult.structured || null,
986
+ auto_recovery: {
987
+ trigger: screenErrorCode,
988
+ attempt: screenAutoRecoveryCount,
989
+ max_attempts: MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS,
990
+ original_recent_not_view: parsed.searchParams.recent_not_view,
991
+ effective_recent_not_view: effectiveSearchParams.recent_not_view,
992
+ partial_result: screenPartialForRecovery
993
+ }
994
+ }
995
+ }
996
+ );
997
+ }
998
+
999
+ if (isRecoverableScreenFailure && !hasRecoveryAttemptsRemaining) {
1000
+ return buildFailedResponse(
1001
+ screenResult.error?.code || "RECOMMEND_SCREEN_FAILED",
1002
+ `${screenResult.error?.message || "推荐页筛选执行失败。"} 已达到自动恢复上限 ${MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS} 次。`,
1003
+ {
1004
+ search_params: effectiveSearchParams,
1005
+ screen_params: parsed.screenParams,
1006
+ selected_job: selectedJob,
1007
+ partial_result: partialScreenResult,
1008
+ diagnostics: {
1009
+ debug_port: preflight.debug_port,
1010
+ stdout: screenResult.stdout?.slice(-1000),
1011
+ stderr: screenResult.stderr?.slice(-1000),
1012
+ result: screenResult.structured || null,
1013
+ auto_recovery: lastAutoRecovery
1014
+ }
1015
+ }
1016
+ );
1017
+ }
958
1018
 
959
- if (shouldAutoRecover) {
1019
+ if (isRecoverableScreenFailure && canRecoverSafely && hasRecoveryAttemptsRemaining) {
960
1020
  screenAutoRecoveryCount += 1;
961
- effectiveSearchParams = {
962
- ...effectiveSearchParams,
963
- recent_not_view: FORCED_RECENT_NOT_VIEW_ON_SCREEN_RECOVERY
964
- };
965
1021
  lastAutoRecovery = {
966
- trigger: "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT",
1022
+ trigger: screenErrorCode,
967
1023
  attempt: screenAutoRecoveryCount,
968
1024
  max_attempts: MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS,
969
1025
  original_recent_not_view: parsed.searchParams.recent_not_view,
970
1026
  effective_recent_not_view: effectiveSearchParams.recent_not_view,
971
- partial_result: partialScreenResult
972
- ? {
973
- processed_count: partialScreenResult.processed_count ?? null,
974
- passed_count: partialScreenResult.passed_count ?? null,
975
- skipped_count: partialScreenResult.skipped_count ?? null,
976
- output_csv: partialScreenResult.output_csv || currentResumeConfig.output_csv || null
977
- }
978
- : null
1027
+ partial_result: screenPartialForRecovery,
1028
+ page_exhaustion: screenResult.error?.page_exhaustion || null
979
1029
  };
980
1030
 
981
- runtimeHooks.setStage(
982
- "screen_recovery",
983
- `screen 连续截图失败,开始自动恢复(第 ${screenAutoRecoveryCount} 次):刷新 recommend 页面并重跑 search。`
984
- );
985
- runtimeHooks.heartbeat("screen_recovery", lastAutoRecovery);
1031
+ if (isPageExhaustedRecovery) {
1032
+ runtimeHooks.setStage(
1033
+ "screen_recovery",
1034
+ `推荐列表已到底但未达目标,开始自动补货(第 ${screenAutoRecoveryCount} 次):优先尝试页内刷新。`
1035
+ );
1036
+ runtimeHooks.heartbeat("screen_recovery", lastAutoRecovery);
1037
+
1038
+ const refreshResult = typeof refreshRecommendList === "function"
1039
+ ? await refreshRecommendList(workspaceRoot, {
1040
+ port: preflight.debug_port
1041
+ })
1042
+ : {
1043
+ ok: false,
1044
+ action: "in_page_refresh",
1045
+ state: "REFRESH_ADAPTER_MISSING",
1046
+ message: "缺少页内刷新适配器。"
1047
+ };
1048
+ ensurePipelineNotAborted(runtimeHooks.signal);
1049
+
1050
+ lastAutoRecovery = {
1051
+ ...lastAutoRecovery,
1052
+ refresh: refreshResult
1053
+ ? {
1054
+ ok: refreshResult.ok,
1055
+ state: refreshResult.state || null,
1056
+ message: refreshResult.message || null,
1057
+ before_state: refreshResult.before_state || null,
1058
+ after_state: refreshResult.after_state || null
1059
+ }
1060
+ : null
1061
+ };
1062
+
1063
+ if (refreshResult?.ok) {
1064
+ lastAutoRecovery = {
1065
+ ...lastAutoRecovery,
1066
+ action: "in_page_refresh"
1067
+ };
1068
+ currentResumeConfig = {
1069
+ checkpoint_path: currentResumeConfig.checkpoint_path || null,
1070
+ pause_control_path: currentResumeConfig.pause_control_path || null,
1071
+ output_csv: resumeOutputCsv || null,
1072
+ resume: true,
1073
+ require_checkpoint: true
1074
+ };
1075
+ shouldRunSearch = false;
1076
+ searchSummary = null;
1077
+ continue;
1078
+ }
1079
+
1080
+ runtimeHooks.setStage(
1081
+ "screen_recovery",
1082
+ `页内刷新不可用(${refreshResult?.state || "unknown"}),改为刷新 recommend 页面并重跑 search。`
1083
+ );
1084
+ runtimeHooks.heartbeat("screen_recovery", lastAutoRecovery);
1085
+ } else {
1086
+ runtimeHooks.setStage(
1087
+ "screen_recovery",
1088
+ `screen 连续截图失败,开始自动恢复(第 ${screenAutoRecoveryCount} 次):刷新 recommend 页面并重跑 search。`
1089
+ );
1090
+ runtimeHooks.heartbeat("screen_recovery", lastAutoRecovery);
1091
+ }
1092
+
1093
+ effectiveSearchParams = {
1094
+ ...effectiveSearchParams,
1095
+ recent_not_view: FORCED_RECENT_NOT_VIEW_ON_SCREEN_RECOVERY
1096
+ };
1097
+ lastAutoRecovery = {
1098
+ ...lastAutoRecovery,
1099
+ action: "reload_page_and_rerun_search",
1100
+ effective_recent_not_view: effectiveSearchParams.recent_not_view
1101
+ };
986
1102
 
987
1103
  const reloadResult = typeof reloadRecommendPage === "function"
988
1104
  ? await reloadRecommendPage(workspaceRoot, {
@@ -354,6 +354,505 @@ async function testConsecutiveResumeCaptureFailuresShouldRefreshAndRerunSearchWi
354
354
  assert.equal(result.search_params.recent_not_view, "近14天没有");
355
355
  }
356
356
 
357
+ async function testPageExhaustedBeforeTargetShouldRefreshInPageAndResumeScreen() {
358
+ const searchCalls = [];
359
+ const screenCalls = [];
360
+ let refreshCalls = 0;
361
+ let reloadCalls = 0;
362
+ let pageReadyCalls = 0;
363
+ const parsed = createParsed();
364
+ parsed.searchParams = {
365
+ ...parsed.searchParams,
366
+ recent_not_view: "不限"
367
+ };
368
+ const result = await runRecommendPipeline(
369
+ {
370
+ workspaceRoot: process.cwd(),
371
+ instruction: "test",
372
+ confirmation: createJobConfirmedConfirmation(),
373
+ overrides: {},
374
+ resume: {
375
+ resume: false,
376
+ output_csv: "C:/temp/resume.csv",
377
+ checkpoint_path: "C:/temp/checkpoint.json",
378
+ pause_control_path: "C:/temp/run.json",
379
+ previous_completion_reason: ""
380
+ }
381
+ },
382
+ {
383
+ parseRecommendInstruction: () => parsed,
384
+ runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
385
+ ensureBossRecommendPageReady: async () => {
386
+ pageReadyCalls += 1;
387
+ return { ok: true, state: "RECOMMEND_READY", page_state: { state: "RECOMMEND_READY" } };
388
+ },
389
+ listRecommendJobs: async () => createJobListResult(),
390
+ refreshBossRecommendList: async () => {
391
+ refreshCalls += 1;
392
+ return {
393
+ ok: true,
394
+ action: "in_page_refresh",
395
+ state: "RECOMMEND_READY",
396
+ before_state: {
397
+ finished_wrap_visible: true,
398
+ refresh_button_visible: true
399
+ },
400
+ after_state: {
401
+ finished_wrap_visible: false,
402
+ list_ready: true
403
+ }
404
+ };
405
+ },
406
+ reloadBossRecommendPage: async () => {
407
+ reloadCalls += 1;
408
+ return {
409
+ ok: true,
410
+ state: "RECOMMEND_READY",
411
+ reloaded_url: "https://www.zhipin.com/web/chat/recommend"
412
+ };
413
+ },
414
+ runRecommendSearchCli: async ({ searchParams }) => {
415
+ searchCalls.push({ ...searchParams });
416
+ return {
417
+ ok: true,
418
+ summary: {
419
+ candidate_count: 9,
420
+ applied_filters: searchParams,
421
+ page_state: { state: "RECOMMEND_READY" }
422
+ }
423
+ };
424
+ },
425
+ runRecommendScreenCli: async ({ resume }) => {
426
+ screenCalls.push({ ...resume });
427
+ if (screenCalls.length === 1) {
428
+ return {
429
+ ok: false,
430
+ error: {
431
+ code: "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
432
+ message: "推荐列表已到底,但尚未达到目标数。",
433
+ page_exhaustion: {
434
+ reason: "bottom_reached",
435
+ bottom: {
436
+ isBottom: true,
437
+ finished_wrap_visible: true,
438
+ refresh_button_visible: true
439
+ }
440
+ }
441
+ },
442
+ summary: {
443
+ processed_count: 4,
444
+ passed_count: 1,
445
+ skipped_count: 3,
446
+ output_csv: "C:/temp/resume.csv",
447
+ checkpoint_path: "C:/temp/checkpoint.json",
448
+ completion_reason: "page_exhausted_before_target_count"
449
+ }
450
+ };
451
+ }
452
+ return {
453
+ ok: true,
454
+ summary: {
455
+ processed_count: 10,
456
+ passed_count: 3,
457
+ skipped_count: 7,
458
+ output_csv: "C:/temp/resume.csv",
459
+ checkpoint_path: "C:/temp/checkpoint.json",
460
+ completion_reason: "target_count_reached"
461
+ }
462
+ };
463
+ }
464
+ }
465
+ );
466
+
467
+ assert.equal(result.status, "COMPLETED");
468
+ assert.equal(searchCalls.length, 1);
469
+ assert.equal(searchCalls[0].recent_not_view, "不限");
470
+ assert.equal(screenCalls.length, 2);
471
+ assert.equal(screenCalls[0].resume, false);
472
+ assert.equal(screenCalls[1].resume, true);
473
+ assert.equal(screenCalls[1].require_checkpoint, true);
474
+ assert.equal(screenCalls[1].output_csv, "C:/temp/resume.csv");
475
+ assert.equal(refreshCalls, 1);
476
+ assert.equal(reloadCalls, 0);
477
+ assert.equal(pageReadyCalls, 1);
478
+ assert.equal(result.result.candidate_count, null);
479
+ assert.equal(result.result.completion_reason, "target_count_reached");
480
+ assert.equal(result.result.auto_recovery.action, "in_page_refresh");
481
+ assert.equal(result.result.auto_recovery.refresh.ok, true);
482
+ assert.equal(result.result.auto_recovery.page_exhaustion.reason, "bottom_reached");
483
+ assert.equal(result.search_params.recent_not_view, "不限");
484
+ }
485
+
486
+ async function testPageExhaustedBeforeTargetShouldReloadWhenRefreshButtonMissing() {
487
+ const searchCalls = [];
488
+ const screenCalls = [];
489
+ let refreshCalls = 0;
490
+ let reloadCalls = 0;
491
+ let pageReadyCalls = 0;
492
+ const parsed = createParsed();
493
+ parsed.searchParams = {
494
+ ...parsed.searchParams,
495
+ recent_not_view: "不限"
496
+ };
497
+ const result = await runRecommendPipeline(
498
+ {
499
+ workspaceRoot: process.cwd(),
500
+ instruction: "test",
501
+ confirmation: createJobConfirmedConfirmation(),
502
+ overrides: {},
503
+ resume: {
504
+ resume: false,
505
+ output_csv: "C:/temp/resume.csv",
506
+ checkpoint_path: "C:/temp/checkpoint.json",
507
+ pause_control_path: "C:/temp/run.json",
508
+ previous_completion_reason: ""
509
+ }
510
+ },
511
+ {
512
+ parseRecommendInstruction: () => parsed,
513
+ runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
514
+ ensureBossRecommendPageReady: async () => {
515
+ pageReadyCalls += 1;
516
+ return { ok: true, state: "RECOMMEND_READY", page_state: { state: "RECOMMEND_READY" } };
517
+ },
518
+ listRecommendJobs: async () => createJobListResult(),
519
+ refreshBossRecommendList: async () => {
520
+ refreshCalls += 1;
521
+ return {
522
+ ok: false,
523
+ action: "in_page_refresh",
524
+ state: "REFRESH_BUTTON_NOT_FOUND",
525
+ message: "未找到页内刷新按钮。"
526
+ };
527
+ },
528
+ reloadBossRecommendPage: async () => {
529
+ reloadCalls += 1;
530
+ return {
531
+ ok: true,
532
+ state: "RECOMMEND_READY",
533
+ reloaded_url: "https://www.zhipin.com/web/chat/recommend"
534
+ };
535
+ },
536
+ runRecommendSearchCli: async ({ searchParams }) => {
537
+ searchCalls.push({ ...searchParams });
538
+ return {
539
+ ok: true,
540
+ summary: {
541
+ candidate_count: searchCalls.length === 1 ? 9 : 12,
542
+ applied_filters: searchParams,
543
+ page_state: { state: "RECOMMEND_READY" }
544
+ }
545
+ };
546
+ },
547
+ runRecommendScreenCli: async ({ resume }) => {
548
+ screenCalls.push({ ...resume });
549
+ if (screenCalls.length === 1) {
550
+ return {
551
+ ok: false,
552
+ error: {
553
+ code: "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
554
+ message: "推荐列表已到底,但尚未达到目标数。",
555
+ page_exhaustion: {
556
+ reason: "bottom_reached",
557
+ bottom: {
558
+ isBottom: true,
559
+ finished_wrap_visible: true,
560
+ refresh_button_visible: false
561
+ }
562
+ }
563
+ },
564
+ summary: {
565
+ processed_count: 4,
566
+ passed_count: 1,
567
+ skipped_count: 3,
568
+ output_csv: "C:/temp/resume.csv",
569
+ checkpoint_path: "C:/temp/checkpoint.json",
570
+ completion_reason: "page_exhausted_before_target_count"
571
+ }
572
+ };
573
+ }
574
+ return {
575
+ ok: true,
576
+ summary: {
577
+ processed_count: 10,
578
+ passed_count: 3,
579
+ skipped_count: 7,
580
+ output_csv: "C:/temp/resume.csv",
581
+ checkpoint_path: "C:/temp/checkpoint.json",
582
+ completion_reason: "target_count_reached"
583
+ }
584
+ };
585
+ }
586
+ }
587
+ );
588
+
589
+ assert.equal(result.status, "COMPLETED");
590
+ assert.equal(searchCalls.length, 2);
591
+ assert.equal(searchCalls[0].recent_not_view, "不限");
592
+ assert.equal(searchCalls[1].recent_not_view, "近14天没有");
593
+ assert.equal(screenCalls.length, 2);
594
+ assert.equal(screenCalls[0].resume, false);
595
+ assert.equal(screenCalls[1].resume, true);
596
+ assert.equal(screenCalls[1].require_checkpoint, true);
597
+ assert.equal(refreshCalls, 1);
598
+ assert.equal(reloadCalls, 1);
599
+ assert.equal(pageReadyCalls, 2);
600
+ assert.equal(result.result.candidate_count, 12);
601
+ assert.equal(result.result.auto_recovery.action, "reload_page_and_rerun_search");
602
+ assert.equal(result.result.auto_recovery.refresh.state, "REFRESH_BUTTON_NOT_FOUND");
603
+ assert.equal(result.result.auto_recovery.reload.ok, true);
604
+ assert.equal(result.search_params.recent_not_view, "近14天没有");
605
+ }
606
+
607
+ async function testPageExhaustedBeforeTargetShouldReloadWhenRefreshDoesNotRecoverList() {
608
+ const searchCalls = [];
609
+ const screenCalls = [];
610
+ let refreshCalls = 0;
611
+ let reloadCalls = 0;
612
+ const parsed = createParsed();
613
+ parsed.searchParams = {
614
+ ...parsed.searchParams,
615
+ recent_not_view: "不限"
616
+ };
617
+ const result = await runRecommendPipeline(
618
+ {
619
+ workspaceRoot: process.cwd(),
620
+ instruction: "test",
621
+ confirmation: createJobConfirmedConfirmation(),
622
+ overrides: {},
623
+ resume: {
624
+ resume: false,
625
+ output_csv: "C:/temp/resume.csv",
626
+ checkpoint_path: "C:/temp/checkpoint.json",
627
+ pause_control_path: "C:/temp/run.json",
628
+ previous_completion_reason: ""
629
+ }
630
+ },
631
+ {
632
+ parseRecommendInstruction: () => parsed,
633
+ runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
634
+ ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: { state: "RECOMMEND_READY" } }),
635
+ listRecommendJobs: async () => createJobListResult(),
636
+ refreshBossRecommendList: async () => {
637
+ refreshCalls += 1;
638
+ return {
639
+ ok: false,
640
+ action: "in_page_refresh",
641
+ state: "LIST_NOT_RELOADED",
642
+ message: "点击刷新后列表没有重新就绪。"
643
+ };
644
+ },
645
+ reloadBossRecommendPage: async () => {
646
+ reloadCalls += 1;
647
+ return {
648
+ ok: true,
649
+ state: "RECOMMEND_READY",
650
+ reloaded_url: "https://www.zhipin.com/web/chat/recommend"
651
+ };
652
+ },
653
+ runRecommendSearchCli: async ({ searchParams }) => {
654
+ searchCalls.push({ ...searchParams });
655
+ return {
656
+ ok: true,
657
+ summary: {
658
+ candidate_count: searchCalls.length === 1 ? 9 : 11,
659
+ applied_filters: searchParams,
660
+ page_state: { state: "RECOMMEND_READY" }
661
+ }
662
+ };
663
+ },
664
+ runRecommendScreenCli: async ({ resume }) => {
665
+ screenCalls.push({ ...resume });
666
+ if (screenCalls.length === 1) {
667
+ return {
668
+ ok: false,
669
+ error: {
670
+ code: "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
671
+ message: "推荐列表已到底,但尚未达到目标数。"
672
+ },
673
+ summary: {
674
+ processed_count: 4,
675
+ passed_count: 1,
676
+ skipped_count: 3,
677
+ output_csv: "C:/temp/resume.csv",
678
+ checkpoint_path: "C:/temp/checkpoint.json",
679
+ completion_reason: "page_exhausted_before_target_count"
680
+ }
681
+ };
682
+ }
683
+ return {
684
+ ok: true,
685
+ summary: {
686
+ processed_count: 10,
687
+ passed_count: 3,
688
+ skipped_count: 7,
689
+ output_csv: "C:/temp/resume.csv",
690
+ checkpoint_path: "C:/temp/checkpoint.json",
691
+ completion_reason: "target_count_reached"
692
+ }
693
+ };
694
+ }
695
+ }
696
+ );
697
+
698
+ assert.equal(result.status, "COMPLETED");
699
+ assert.equal(searchCalls.length, 2);
700
+ assert.equal(searchCalls[1].recent_not_view, "近14天没有");
701
+ assert.equal(screenCalls.length, 2);
702
+ assert.equal(screenCalls[1].resume, true);
703
+ assert.equal(refreshCalls, 1);
704
+ assert.equal(reloadCalls, 1);
705
+ assert.equal(result.result.candidate_count, 11);
706
+ assert.equal(result.result.auto_recovery.action, "reload_page_and_rerun_search");
707
+ assert.equal(result.result.auto_recovery.refresh.state, "LIST_NOT_RELOADED");
708
+ assert.equal(result.search_params.recent_not_view, "近14天没有");
709
+ }
710
+
711
+ async function testPageExhaustedBeforeTargetShouldFailAfterFiveRecoveryAttempts() {
712
+ const searchCalls = [];
713
+ const screenCalls = [];
714
+ let refreshCalls = 0;
715
+ const parsed = createParsed();
716
+ parsed.searchParams = {
717
+ ...parsed.searchParams,
718
+ recent_not_view: "不限"
719
+ };
720
+ const result = await runRecommendPipeline(
721
+ {
722
+ workspaceRoot: process.cwd(),
723
+ instruction: "test",
724
+ confirmation: createJobConfirmedConfirmation(),
725
+ overrides: {},
726
+ resume: {
727
+ resume: false,
728
+ output_csv: "C:/temp/resume.csv",
729
+ checkpoint_path: "C:/temp/checkpoint.json",
730
+ pause_control_path: "C:/temp/run.json",
731
+ previous_completion_reason: ""
732
+ }
733
+ },
734
+ {
735
+ parseRecommendInstruction: () => parsed,
736
+ runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
737
+ ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: { state: "RECOMMEND_READY" } }),
738
+ listRecommendJobs: async () => createJobListResult(),
739
+ refreshBossRecommendList: async () => {
740
+ refreshCalls += 1;
741
+ return {
742
+ ok: true,
743
+ action: "in_page_refresh",
744
+ state: "RECOMMEND_READY",
745
+ before_state: {
746
+ finished_wrap_visible: true,
747
+ refresh_button_visible: true
748
+ },
749
+ after_state: {
750
+ finished_wrap_visible: false,
751
+ list_ready: true
752
+ }
753
+ };
754
+ },
755
+ runRecommendSearchCli: async ({ searchParams }) => {
756
+ searchCalls.push({ ...searchParams });
757
+ return {
758
+ ok: true,
759
+ summary: {
760
+ candidate_count: 9,
761
+ applied_filters: searchParams,
762
+ page_state: { state: "RECOMMEND_READY" }
763
+ }
764
+ };
765
+ },
766
+ runRecommendScreenCli: async ({ resume }) => {
767
+ screenCalls.push({ ...resume });
768
+ return {
769
+ ok: false,
770
+ error: {
771
+ code: "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
772
+ message: "推荐列表已到底,但尚未达到目标数。",
773
+ page_exhaustion: {
774
+ reason: "bottom_reached",
775
+ bottom: {
776
+ isBottom: true,
777
+ finished_wrap_visible: true,
778
+ refresh_button_visible: true
779
+ }
780
+ }
781
+ },
782
+ summary: {
783
+ processed_count: 4,
784
+ passed_count: 1,
785
+ skipped_count: 3,
786
+ output_csv: "C:/temp/resume.csv",
787
+ checkpoint_path: "C:/temp/checkpoint.json",
788
+ completion_reason: "page_exhausted_before_target_count"
789
+ }
790
+ };
791
+ }
792
+ }
793
+ );
794
+
795
+ assert.equal(result.status, "FAILED");
796
+ assert.equal(result.error.code, "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED");
797
+ assert.match(result.error.message, /已达到自动恢复上限 5 次/);
798
+ assert.equal(searchCalls.length, 1);
799
+ assert.equal(screenCalls.length, 6);
800
+ assert.equal(refreshCalls, 5);
801
+ assert.equal(result.partial_result.output_csv, "C:/temp/resume.csv");
802
+ assert.equal(result.diagnostics.auto_recovery.attempt, 5);
803
+ assert.equal(result.diagnostics.auto_recovery.action, "in_page_refresh");
804
+ assert.equal(result.diagnostics.auto_recovery.partial_result.checkpoint_path, "C:/temp/checkpoint.json");
805
+ }
806
+
807
+ async function testNullTargetCountShouldKeepPageExhaustedCompletion() {
808
+ const parsed = createParsed();
809
+ parsed.screenParams = {
810
+ ...parsed.screenParams,
811
+ target_count: null
812
+ };
813
+ let receivedScreenParams = null;
814
+ const result = await runRecommendPipeline(
815
+ {
816
+ workspaceRoot: process.cwd(),
817
+ instruction: "test",
818
+ confirmation: createJobConfirmedConfirmation(),
819
+ overrides: {}
820
+ },
821
+ {
822
+ parseRecommendInstruction: () => parsed,
823
+ runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
824
+ ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
825
+ listRecommendJobs: async () => createJobListResult(),
826
+ runRecommendSearchCli: async () => ({
827
+ ok: true,
828
+ summary: {
829
+ candidate_count: 9,
830
+ applied_filters: {},
831
+ page_state: { state: "RECOMMEND_READY" }
832
+ }
833
+ }),
834
+ runRecommendScreenCli: async ({ screenParams }) => {
835
+ receivedScreenParams = screenParams;
836
+ return {
837
+ ok: true,
838
+ summary: {
839
+ processed_count: 4,
840
+ passed_count: 1,
841
+ skipped_count: 3,
842
+ output_csv: "C:/temp/resume.csv",
843
+ completion_reason: "page_exhausted"
844
+ }
845
+ };
846
+ }
847
+ }
848
+ );
849
+
850
+ assert.equal(receivedScreenParams.target_count, null);
851
+ assert.equal(result.status, "COMPLETED");
852
+ assert.equal(result.result.completion_reason, "page_exhausted");
853
+ assert.equal(result.result.auto_recovery, null);
854
+ }
855
+
357
856
  async function testNeedConfirmationGate() {
358
857
  let preflightCalled = false;
359
858
  const result = await runRecommendPipeline(
@@ -1036,6 +1535,11 @@ async function main() {
1036
1535
  await testResumeFromScreenPauseShouldSkipSearch();
1037
1536
  await testResumeFromPausedBeforeScreenShouldRerunSearch();
1038
1537
  await testConsecutiveResumeCaptureFailuresShouldRefreshAndRerunSearchWithForcedRecentFilter();
1538
+ await testPageExhaustedBeforeTargetShouldRefreshInPageAndResumeScreen();
1539
+ await testPageExhaustedBeforeTargetShouldReloadWhenRefreshButtonMissing();
1540
+ await testPageExhaustedBeforeTargetShouldReloadWhenRefreshDoesNotRecoverList();
1541
+ await testPageExhaustedBeforeTargetShouldFailAfterFiveRecoveryAttempts();
1542
+ await testNullTargetCountShouldKeepPageExhaustedCompletion();
1039
1543
  await testNeedConfirmationGate();
1040
1544
  await testNeedSchoolTagConfirmationGate();
1041
1545
  await testNeedTargetCountConfirmationGate();
@@ -450,6 +450,20 @@ const jsDetectBottom = `(() => {
450
450
  return { isBottom: false, error: 'NO_RECOMMEND_IFRAME' };
451
451
  }
452
452
  const doc = frame.contentDocument;
453
+ const isVisible = (el) => {
454
+ if (!el) return false;
455
+ const win = doc.defaultView;
456
+ if (!win) return el.offsetParent !== null;
457
+ const style = win.getComputedStyle(el);
458
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.02) {
459
+ return false;
460
+ }
461
+ const rect = el.getBoundingClientRect();
462
+ return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
463
+ };
464
+ const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
465
+ const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
466
+ .find((el) => isVisible(el)) || null;
453
467
  const keywords = ['没有更多', '已显示全部', '已经到底', '暂无更多', '推荐完了', '没有更多人选'];
454
468
  const elements = Array.from(doc.querySelectorAll('div,span,p'));
455
469
  for (const el of elements) {
@@ -458,12 +472,24 @@ const jsDetectBottom = `(() => {
458
472
  if (!text || text.length > 40) continue;
459
473
  for (const keyword of keywords) {
460
474
  if (text.includes(keyword)) {
461
- return { isBottom: true, reason: keyword };
475
+ return {
476
+ isBottom: true,
477
+ reason: keyword,
478
+ finished_wrap_visible: Boolean(finishedWrap),
479
+ refresh_button_visible: Boolean(refreshButton),
480
+ refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null
481
+ };
462
482
  }
463
483
  }
464
484
  }
465
- return { isBottom: false };
466
- })()`;
485
+ return {
486
+ isBottom: Boolean(finishedWrap),
487
+ reason: finishedWrap ? 'finished-wrap' : null,
488
+ finished_wrap_visible: Boolean(finishedWrap),
489
+ refresh_button_visible: Boolean(refreshButton),
490
+ refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null
491
+ };
492
+ })()`;
467
493
  const jsWaitForDetail = `(() => {
468
494
  const frame = document.querySelector('iframe[name="recommendFrame"]')
469
495
  || document.querySelector('iframe[src*="/web/frame/recommend/"]')
@@ -1113,6 +1139,24 @@ class RecommendScreenCli {
1113
1139
  };
1114
1140
  }
1115
1141
 
1142
+ buildProgressSnapshot(completionReason = null) {
1143
+ const snapshot = {
1144
+ processed_count: this.processedCount,
1145
+ passed_count: this.passedCandidates.length,
1146
+ skipped_count: this.skippedCount,
1147
+ output_csv: this.args.output,
1148
+ checkpoint_path: this.checkpointPath,
1149
+ post_action: this.args.postAction,
1150
+ max_greet_count: this.args.postAction === "greet" ? this.args.maxGreetCount : null,
1151
+ greet_count: this.greetCount,
1152
+ greet_limit_fallback_count: this.greetLimitFallbackCount
1153
+ };
1154
+ if (completionReason) {
1155
+ snapshot.completion_reason = completionReason;
1156
+ }
1157
+ return snapshot;
1158
+ }
1159
+
1116
1160
  resetResumeCaptureFailureStreak() {
1117
1161
  this.consecutiveResumeCaptureFailures = 0;
1118
1162
  this.resumeCaptureFailureStreakKeys = [];
@@ -1916,24 +1960,14 @@ class RecommendScreenCli {
1916
1960
  this.sortCandidateQueue();
1917
1961
  }
1918
1962
 
1963
+ let pageExhaustion = null;
1919
1964
  while (!this.args.targetCount || this.processedCount < this.args.targetCount) {
1920
1965
  if (this.shouldPauseAtBoundary()) {
1921
1966
  this.saveCsv();
1922
1967
  this.saveCheckpoint();
1923
1968
  return {
1924
1969
  status: "PAUSED",
1925
- result: {
1926
- processed_count: this.processedCount,
1927
- passed_count: this.passedCandidates.length,
1928
- skipped_count: this.skippedCount,
1929
- output_csv: this.args.output,
1930
- checkpoint_path: this.checkpointPath,
1931
- completion_reason: "paused",
1932
- post_action: this.args.postAction,
1933
- max_greet_count: this.args.postAction === "greet" ? this.args.maxGreetCount : null,
1934
- greet_count: this.greetCount,
1935
- greet_limit_fallback_count: this.greetLimitFallbackCount
1936
- }
1970
+ result: this.buildProgressSnapshot("paused")
1937
1971
  };
1938
1972
  }
1939
1973
  const periodicDiscovery = await this.discoverCandidates();
@@ -1958,6 +1992,18 @@ class RecommendScreenCli {
1958
1992
  const didScroll = Number(scroll.after?.scrollTop || 0) !== Number(scroll.before?.scrollTop || 0)
1959
1993
  || Number(scroll.after?.scrollHeight || 0) !== Number(scroll.before?.scrollHeight || 0);
1960
1994
  if (scroll.bottom?.isBottom) {
1995
+ pageExhaustion = {
1996
+ reason: "bottom_reached",
1997
+ bottom: scroll.bottom || null,
1998
+ scroll: scroll.scrollResult || null,
1999
+ before: scroll.before || null,
2000
+ after: scroll.after || null,
2001
+ discovery: {
2002
+ added: discovery.added ?? 0,
2003
+ candidate_count: discovery.candidate_count ?? null,
2004
+ total_cards: discovery.total_cards ?? null
2005
+ }
2006
+ };
1961
2007
  break;
1962
2008
  }
1963
2009
  if (didGrow || didDiscover) {
@@ -1967,12 +2013,36 @@ class RecommendScreenCli {
1967
2013
  if (!didScroll) {
1968
2014
  this.scrollRetryCount += 1;
1969
2015
  if (this.scrollRetryCount >= this.maxScrollRetries) {
2016
+ pageExhaustion = {
2017
+ reason: "scroll_stalled",
2018
+ bottom: scroll.bottom || null,
2019
+ scroll: scroll.scrollResult || null,
2020
+ before: scroll.before || null,
2021
+ after: scroll.after || null,
2022
+ discovery: {
2023
+ added: discovery.added ?? 0,
2024
+ candidate_count: discovery.candidate_count ?? null,
2025
+ total_cards: discovery.total_cards ?? null
2026
+ }
2027
+ };
1970
2028
  break;
1971
2029
  }
1972
2030
  continue;
1973
2031
  }
1974
2032
  this.scrollRetryCount += 1;
1975
2033
  if (this.scrollRetryCount >= this.maxScrollRetries) {
2034
+ pageExhaustion = {
2035
+ reason: "scroll_retry_exhausted",
2036
+ bottom: scroll.bottom || null,
2037
+ scroll: scroll.scrollResult || null,
2038
+ before: scroll.before || null,
2039
+ after: scroll.after || null,
2040
+ discovery: {
2041
+ added: discovery.added ?? 0,
2042
+ candidate_count: discovery.candidate_count ?? null,
2043
+ total_cards: discovery.total_cards ?? null
2044
+ }
2045
+ };
1976
2046
  break;
1977
2047
  }
1978
2048
  continue;
@@ -2076,6 +2146,18 @@ class RecommendScreenCli {
2076
2146
  }
2077
2147
  }
2078
2148
 
2149
+ if (this.args.targetCount && this.processedCount < this.args.targetCount) {
2150
+ throw this.buildError(
2151
+ "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
2152
+ `推荐列表已到底,但当前仅处理 ${this.processedCount} 位,尚未达到目标 ${this.args.targetCount} 位。`,
2153
+ true,
2154
+ {
2155
+ partial_result: this.buildProgressSnapshot("page_exhausted_before_target_count"),
2156
+ page_exhaustion: pageExhaustion
2157
+ }
2158
+ );
2159
+ }
2160
+
2079
2161
  this.saveCsv();
2080
2162
  try {
2081
2163
  this.saveCheckpoint();
@@ -2085,17 +2167,14 @@ class RecommendScreenCli {
2085
2167
  return {
2086
2168
  status: "COMPLETED",
2087
2169
  result: {
2088
- processed_count: this.processedCount,
2089
- passed_count: this.passedCandidates.length,
2090
- skipped_count: this.skippedCount,
2091
- output_csv: this.args.output,
2170
+ ...this.buildProgressSnapshot(
2171
+ this.args.targetCount && this.processedCount >= this.args.targetCount
2172
+ ? "target_count_reached"
2173
+ : "page_exhausted"
2174
+ ),
2092
2175
  completion_reason: this.args.targetCount && this.processedCount >= this.args.targetCount
2093
2176
  ? "target_count_reached"
2094
2177
  : "page_exhausted",
2095
- post_action: this.args.postAction,
2096
- max_greet_count: this.args.postAction === "greet" ? this.args.maxGreetCount : null,
2097
- greet_count: this.greetCount,
2098
- greet_limit_fallback_count: this.greetLimitFallbackCount
2099
2178
  }
2100
2179
  };
2101
2180
  } catch (error) {
@@ -2110,12 +2189,7 @@ class RecommendScreenCli {
2110
2189
  log(`[保存checkpoint失败] ${checkpointError.message || checkpointError}`);
2111
2190
  }
2112
2191
  if (!error.partial_result) {
2113
- error.partial_result = {
2114
- processed_count: this.processedCount,
2115
- passed_count: this.passedCandidates.length,
2116
- skipped_count: this.skippedCount,
2117
- output_csv: this.args.output
2118
- };
2192
+ error.partial_result = this.buildProgressSnapshot();
2119
2193
  }
2120
2194
  throw error;
2121
2195
  } finally {
@@ -2144,13 +2218,18 @@ async function main() {
2144
2218
 
2145
2219
  if (require.main === module) {
2146
2220
  main().catch((error) => {
2221
+ const errorPayload = {
2222
+ code: error.code || "RECOMMEND_SCREEN_FAILED",
2223
+ message: error.message || "推荐页筛选执行失败。",
2224
+ retryable: error.retryable !== false
2225
+ };
2226
+ for (const [key, value] of Object.entries(error || {})) {
2227
+ if (["code", "message", "retryable", "partial_result", "stack"].includes(key)) continue;
2228
+ errorPayload[key] = value;
2229
+ }
2147
2230
  const payload = {
2148
2231
  status: "FAILED",
2149
- error: {
2150
- code: error.code || "RECOMMEND_SCREEN_FAILED",
2151
- message: error.message || "推荐页筛选执行失败。",
2152
- retryable: error.retryable !== false
2153
- },
2232
+ error: errorPayload,
2154
2233
  result: error.partial_result || null
2155
2234
  };
2156
2235
  console.log(JSON.stringify(payload));
@@ -232,10 +232,83 @@ async function testConsecutiveResumeCaptureFailuresStillAbort() {
232
232
  );
233
233
  }
234
234
 
235
+ async function testPageExhaustedBeforeTargetShouldRaiseRecoverableError() {
236
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-page-exhausted-"));
237
+ const args = createArgs(tempDir);
238
+ args.targetCount = 5;
239
+ const cli = new FakeRecommendScreenCli(args);
240
+ cli.scrollAndLoadMore = async () => ({
241
+ before: {
242
+ candidateCount: 0,
243
+ scrollTop: 120,
244
+ scrollHeight: 900
245
+ },
246
+ after: {
247
+ candidateCount: 0,
248
+ scrollTop: 900,
249
+ scrollHeight: 900
250
+ },
251
+ bottom: {
252
+ isBottom: true,
253
+ finished_wrap_visible: true,
254
+ refresh_button_visible: true,
255
+ refresh_button_text: "刷新"
256
+ }
257
+ });
258
+
259
+ await assert.rejects(
260
+ () => cli.run(),
261
+ (error) => {
262
+ assert.equal(error.code, "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED");
263
+ assert.equal(error.retryable, true);
264
+ assert.equal(error.partial_result?.processed_count, 0);
265
+ assert.equal(error.partial_result?.output_csv, args.output);
266
+ assert.equal(error.partial_result?.checkpoint_path, args.checkpointPath);
267
+ assert.equal(error.partial_result?.completion_reason, "page_exhausted_before_target_count");
268
+ assert.equal(error.page_exhaustion?.reason, "bottom_reached");
269
+ assert.equal(error.page_exhaustion?.bottom?.finished_wrap_visible, true);
270
+ assert.equal(error.page_exhaustion?.bottom?.refresh_button_visible, true);
271
+ return true;
272
+ }
273
+ );
274
+ }
275
+
276
+ async function testPageExhaustedWithoutTargetShouldStillComplete() {
277
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-page-complete-"));
278
+ const cli = new FakeRecommendScreenCli(createArgs(tempDir));
279
+ cli.scrollAndLoadMore = async () => ({
280
+ before: {
281
+ candidateCount: 0,
282
+ scrollTop: 120,
283
+ scrollHeight: 900
284
+ },
285
+ after: {
286
+ candidateCount: 0,
287
+ scrollTop: 900,
288
+ scrollHeight: 900
289
+ },
290
+ bottom: {
291
+ isBottom: true,
292
+ finished_wrap_visible: true,
293
+ refresh_button_visible: true,
294
+ refresh_button_text: "刷新"
295
+ }
296
+ });
297
+
298
+ const result = await cli.run();
299
+ assert.equal(result.status, "COMPLETED");
300
+ assert.equal(result.result.processed_count, 0);
301
+ assert.equal(result.result.output_csv, cli.args.output);
302
+ assert.equal(result.result.checkpoint_path, cli.args.checkpointPath);
303
+ assert.equal(result.result.completion_reason, "page_exhausted");
304
+ }
305
+
235
306
  async function main() {
236
307
  testShouldAbortResumeProbeEarly();
237
308
  await testSingleResumeCaptureFailureIsSkipped();
238
309
  await testConsecutiveResumeCaptureFailuresStillAbort();
310
+ await testPageExhaustedBeforeTargetShouldRaiseRecoverableError();
311
+ await testPageExhaustedWithoutTargetShouldStillComplete();
239
312
  console.log("recoverable resume failure tests passed");
240
313
  }
241
314