diff --git a/application/admin/command/Install/split_number_cleanup_deferred.sql b/application/admin/command/Install/split_number_cleanup_deferred.sql old mode 100644 new mode 100755 diff --git a/application/admin/command/Install/split_number_ratio_deferred.sql b/application/admin/command/Install/split_number_ratio_deferred.sql old mode 100644 new mode 100755 diff --git a/application/common/library/scrm/AntiBotConfigBuilder.php b/application/common/library/scrm/AntiBotConfigBuilder.php old mode 100755 new mode 100644 index aa88dda..4b47a68 --- a/application/common/library/scrm/AntiBotConfigBuilder.php +++ b/application/common/library/scrm/AntiBotConfigBuilder.php @@ -31,6 +31,10 @@ class AntiBotConfigBuilder $landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl); } + if ($ticketType === 'whatshub' && $landingUrlHint === '') { + $landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl); + } + $sessionScope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint); if ($sessionScope === '') { return null; @@ -58,7 +62,7 @@ class AntiBotConfigBuilder } /** - * 会话 scope:A2C 业务域+分享 id;火箭长链用动态 host、短链用 share.{token} + * 会话 scope:A2C 业务域+分享 id;Whatshub host+shareCode/workId;火箭长链用动态 host */ public static function resolveSessionScope(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string { @@ -66,6 +70,10 @@ class AntiBotConfigBuilder return self::resolveA2cSessionScope($pageUrl, $landingUrlHint); } + if ($ticketType === 'whatshub') { + return self::resolveWhatshubSessionScope($pageUrl, $landingUrlHint); + } + if ($ticketType === 'huojian') { return self::resolveHuojianSessionScope($pageUrl, $landingUrlHint); } @@ -92,6 +100,10 @@ class AntiBotConfigBuilder $landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl); } + if ($ticketType === 'whatshub' && $landingUrlHint === '') { + $landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl); + } + $scope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint); return $ticketType . ':' . ($scope !== '' ? $scope : 'unknown'); @@ -137,7 +149,8 @@ class AntiBotConfigBuilder return [ 'postCfReadyUrlContains' => 'work-order-sharing', 'postCfReadySelector' => '.el-input__inner', - 'postCfReadyTimeoutMs' => 30000, + 'postCfReadyApiUrl' => '/api/whatshub-counter/workShare/open/statistics', + 'postCfReadyTimeoutMs' => 60000, 'postCfSettleMs' => 500, 'postCfSpaWaitMs' => 4000, ]; @@ -179,7 +192,7 @@ class AntiBotConfigBuilder private static function defaultCfClearanceRequired(string $ticketType): bool { - if ($ticketType === 'a2c') { + if ($ticketType === 'a2c' || $ticketType === 'whatshub') { return false; } @@ -187,11 +200,11 @@ class AntiBotConfigBuilder } /** - * A2C 等为 CF Managed Challenge(5s 盾),非独立 Turnstile 挂件 + * A2C / Whatshub 等为 CF Managed Challenge(5s 盾),非独立 Turnstile 挂件 */ private static function defaultCfChallengeMode(string $ticketType): string { - if ($ticketType === 'a2c') { + if ($ticketType === 'a2c' || $ticketType === 'whatshub') { return 'managed'; } @@ -203,7 +216,7 @@ class AntiBotConfigBuilder */ private static function defaultCfCloudflareSolver(string $ticketType): bool { - return $ticketType === 'a2c'; + return $ticketType === 'a2c' || $ticketType === 'whatshub'; } /** @@ -233,6 +246,69 @@ class AntiBotConfigBuilder return ''; } + /** + * Whatshub 会话 scope:host + shareCode 或 workId,避免多工单共用温池 Browser + */ + private static function resolveWhatshubSessionScope(string $pageUrl, string $landingUrlHint = ''): string + { + $host = strtolower(trim((string) parse_url($pageUrl, PHP_URL_HOST))); + if ($host === '' && $landingUrlHint !== '') { + $host = strtolower(trim((string) parse_url($landingUrlHint, PHP_URL_HOST))); + } + if ($host === '') { + return ''; + } + + $shareCode = self::extractWhatshubShareCode($pageUrl); + if ($shareCode === '' && $landingUrlHint !== '') { + $shareCode = self::extractWhatshubShareCode($landingUrlHint); + } + if ($shareCode !== '') { + return $host . ':' . $shareCode; + } + + $workId = self::extractWhatshubWorkId($pageUrl); + if ($workId === '' && $landingUrlHint !== '') { + $workId = self::extractWhatshubWorkId($landingUrlHint); + } + if ($workId !== '') { + return $host . ':' . $workId; + } + + return $host; + } + + /** + * 从 Whatshub 短链 /m/{shareCode}/ 提取 shareCode + */ + private static function extractWhatshubShareCode(string $url): string + { + $path = (string) parse_url($url, PHP_URL_PATH); + if ($path === '') { + return ''; + } + if (preg_match('#/m/([^/]+)/#', $path, $matches)) { + return trim($matches[1]); + } + + return ''; + } + + /** + * 从 Whatshub 长链 work-order-sharing?workId= 提取 workId + */ + private static function extractWhatshubWorkId(string $url): string + { + $query = (string) parse_url($url, PHP_URL_QUERY); + if ($query === '') { + return ''; + } + parse_str($query, $params); + $workId = isset($params['workId']) ? trim((string) $params['workId']) : ''; + + return $workId !== '' ? $workId : ''; + } + private static function resolveA2cSessionHost(string $pageUrl, string $landingUrlHint = ''): string { $candidates = []; diff --git a/application/common/library/scrm/spider/WhatshubSpider.php b/application/common/library/scrm/spider/WhatshubSpider.php old mode 100755 new mode 100644 index 6742aec..b139bcc --- a/application/common/library/scrm/spider/WhatshubSpider.php +++ b/application/common/library/scrm/spider/WhatshubSpider.php @@ -12,7 +12,7 @@ use app\common\service\SplitTicketSyncLogger; /** * Whatshub 云控蜘蛛 * - * 时序:detailByShareCode API 直抓(优先)→ 失败则 Real Browser + Turnstile + UI 翻页 + * 时序:cURL detailByShareCode 拉号码 → Node 监听 statistics 算完成量 → 失败则完整 Node 爬虫 */ class WhatshubSpider extends AbstractScrmSpider { @@ -21,7 +21,7 @@ class WhatshubSpider extends AbstractScrmSpider /** 详情/统计 API 路径 */ private const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics'; - + /** shareCode + password 直连接口(一次返回全量号码,无需翻页) */ private const API_DETAIL_BY_SHARE_CODE = '/api/whatshub-counter/workShare/open/detailByShareCode'; @@ -52,25 +52,81 @@ class WhatshubSpider extends AbstractScrmSpider } /** - * API 优先:detailByShareCode 直抓;失败则回退 Node 爬虫 + * 混合同步:cURL 拉号码 + Node 监听 statistics 算完成量;任一失败则完整 Node fallback */ public function run(): UnifiedScrmData { $apiResult = $this->tryFetchViaShareCodeApi(); - if ($apiResult instanceof UnifiedScrmData) { - SplitTicketSyncLogger::log('spider', 'whatshub api path ok', [ - 'count' => count($apiResult->numbers), + $statsCount = $this->tryFetchStatisticsViaNode(); + + if ($apiResult instanceof UnifiedScrmData && $statsCount !== null) { + $apiResult->todayNewCount = $statsCount; + SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok', [ + 'numberCount' => count($apiResult->numbers), + 'todayNewCount' => $statsCount, ]); return $apiResult; } - SplitTicketSyncLogger::log('spider', 'whatshub api skipped or failed, fallback node', [ - 'pageUrl' => $this->pageUrl, + SplitTicketSyncLogger::log('spider', 'whatshub hybrid incomplete, fallback full node', [ + 'pageUrl' => $this->pageUrl, + 'apiOk' => $apiResult instanceof UnifiedScrmData, + 'statisticsOk' => $statsCount !== null, ]); return parent::run(); } + /** + * Node 监听 statistics API,返回 dayNewFans + reDayNewFans 总和;失败返回 null + */ + private function tryFetchStatisticsViaNode(): ?int + { + $config = $this->getSpiderConfig(); + $antiBot = $config['antiBot'] ?? null; + + SplitTicketSyncLogger::log('spider', 'whatshub stats node request', [ + 'pageUrl' => $this->pageUrl, + 'api' => self::API_DETAILS, + ]); + + $result = $this->requestNode('/api/auth-and-intercept', [ + 'pageUrl' => $config['pageUrl'], + 'apiUrls' => [self::API_DETAILS], + 'authActions' => $config['authActions'] ?? [], + 'antiBot' => $antiBot, + ], $this->resolveAuthInterceptTimeout(is_array($antiBot) ? $antiBot : null)); + + if (empty($result['success'])) { + SplitTicketSyncLogger::log('spider', 'whatshub stats node failed', [ + 'error' => $result['error'] ?? '未知', + 'code' => $result['code'] ?? null, + ]); + return null; + } + + $intercepted = $result['interceptedApis'] ?? []; + if (!isset($intercepted[self::API_DETAILS]['data']) || !is_array($intercepted[self::API_DETAILS]['data'])) { + SplitTicketSyncLogger::log('spider', 'whatshub stats node missing payload', [ + 'intercepted' => array_keys(is_array($intercepted) ? $intercepted : []), + ]); + return null; + } + + $detailData = $intercepted[self::API_DETAILS]['data']; + $dayNewFans = (int) ($detailData['data']['dayNewFans'] ?? 0); + $reDayNewFans = (int) ($detailData['data']['reDayNewFans'] ?? 0); + $total = $dayNewFans + $reDayNewFans; + + SplitTicketSyncLogger::log('spider', 'whatshub stats node ok', [ + 'dayNewFans' => $dayNewFans, + 'reDayNewFans' => $reDayNewFans, + 'total' => $total, + ]); + + return $total; + } + /** * 从短链 pageUrl 解析 shareCode,例如 /m/KMYBBInp2066/1 → KMYBBInp2066 */ @@ -202,7 +258,6 @@ class WhatshubSpider extends AbstractScrmSpider private function parseShareCodeApiResponse(array $payload): UnifiedScrmData { $unifiedData = new UnifiedScrmData(); - $todayNewSum = 0; foreach ($payload['data'] as $item) { if (!is_array($item) || empty($item['account'])) { @@ -213,11 +268,10 @@ class WhatshubSpider extends AbstractScrmSpider $isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1; $dayNewFans = (int) ($item['dayNewFans'] ?? 0); + // 号码同步仅使用 dayNewFans,不含 reDayNewFans;完成量由 Node statistics 提供 $unifiedData->addNumber($number, $isOnline, $dayNewFans); - $todayNewSum += $dayNewFans; } - $unifiedData->todayNewCount = $todayNewSum; $unifiedData->total = count($unifiedData->numbers); return $unifiedData; @@ -279,7 +333,7 @@ class WhatshubSpider extends AbstractScrmSpider $unifiedData = $this->unifiedData; if (is_array($detailData)) { - $unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0); + $unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0) + (int) ($detailData['data']['reDayNewFans'] ?? 0); } foreach ($allListPagesData as $pageRaw) { diff --git a/application/common/service/SplitNumberRatioDeferredService.php b/application/common/service/SplitNumberRatioDeferredService.php index 44b51b3..6891165 100644 --- a/application/common/service/SplitNumberRatioDeferredService.php +++ b/application/common/service/SplitNumberRatioDeferredService.php @@ -56,6 +56,9 @@ class SplitNumberRatioDeferredService /** * 同步侧:根据 visit / inbound 与上次同步检查点重算 streak * + * 访问侧(recordVisitAfterRedirect)已按次 +1 累计 streak,此处不得再叠加 delta,否则首次同步会双倍计次导致提前关号。 + * 取 max(访问侧 streak, 自上次同步以来的点击增量) 作为裁决值,并兜底访问钩子未触发的场景。 + * * @param array|Number $number */ public function computeStreakForSync($number, int $currentStreak): int @@ -74,7 +77,9 @@ class SplitNumberRatioDeferredService } if ($visitCount > $lastVisit) { - return $currentStreak + ($visitCount - $lastVisit); + $deltaSinceSync = $visitCount - $lastVisit; + + return max($currentStreak, $deltaSinceSync); } return $currentStreak; diff --git a/application/common/service/SplitNumberVisitRuleService.php b/application/common/service/SplitNumberVisitRuleService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitScrmSpiderFactory.php b/application/common/service/SplitScrmSpiderFactory.php old mode 100755 new mode 100644 index 90a5f0c..074fe76 --- a/application/common/service/SplitScrmSpiderFactory.php +++ b/application/common/service/SplitScrmSpiderFactory.php @@ -36,12 +36,11 @@ class SplitScrmSpiderFactory /** * API 优先同步类型:常态走 PHP cURL,不占 Node Real Browser 槽位 * - * 调度层据此跳过 Node 队列门禁并允许并行投递;蜘蛛实现本身不在此修改。 + * Whatshub 已改为混合同步(cURL 号码 + Node statistics),需走 Node 队列门禁。 * * @var list */ private const API_FIRST_SYNC_TYPES = [ - 'whatshub', ]; /** diff --git a/application/extra/site.php b/application/extra/site.php index a34ccb2..33bf3ab 100755 --- a/application/extra/site.php +++ b/application/extra/site.php @@ -4,7 +4,7 @@ return array ( 'name' => 'link管理系统', 'beian' => '', 'cdnurl' => '', - 'version' => '1.0.1', + 'version' => '1.0.9', 'timezone' => 'Asia/Shanghai', 'forbiddenip' => '', 'languages' => @@ -42,7 +42,7 @@ return array ( 'category2' => 'Category2', 'custom' => 'Custom', ), - 'split_platform_domain' => '', + 'split_platform_domain' => 'flowerbells.top', 'split_scrm_node_host' => 'http://127.0.0.1:3001', 'split_sync_interval_a2c' => '3', 'split_sync_interval_haiwang' => '3', @@ -53,14 +53,14 @@ return array ( 'split_sync_interval_taiji' => '0', 'split_sync_interval_ss_channel' => '0', 'split_sync_interval_yifafa' => '0', - 'split_sync_interval_whatshub' => '0', + 'split_sync_interval_whatshub' => '3', 'split_sync_interval_sihai' => '0', - 'split_sync_fail_pause_threshold' => '5', - 'split_sync_interval_chatknow' => '3', - 'split_sync_max_per_cron' => '5', - 'split_sync_node_candidate_multiplier' => '10', - 'split_sync_cron_lock_ttl' => '1800', - 'split_sync_node_queue_threshold' => '2', + 'split_sync_fail_pause_threshold' => '6', + 'split_sync_interval_chatknow' => '5', + 'split_sync_max_per_cron' => '27', + 'split_sync_node_candidate_multiplier' => '13', + 'split_sync_cron_lock_ttl' => '680', + 'split_sync_node_queue_threshold' => '8', 'split_scrm_antibot_types' => 'whatshub,chatknow,a2c,huojian', 'split_scrm_session_ttl_minutes' => '120', ); diff --git a/patches/application/admin/command/Install/split_config_node_channel.sql b/patches/application/admin/command/Install/split_config_node_channel.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_number_cleanup_deferred.sql b/patches/application/admin/command/Install/split_number_cleanup_deferred.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_number_ratio_deferred.sql b/patches/application/admin/command/Install/split_number_ratio_deferred.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_ticket_sync_log_enhance.sql b/patches/application/admin/command/Install/split_ticket_sync_log_enhance.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/SplitSyncTickets.php b/patches/application/admin/command/SplitSyncTickets.php index 63ea1fe..6cdebeb 100755 --- a/patches/application/admin/command/SplitSyncTickets.php +++ b/patches/application/admin/command/SplitSyncTickets.php @@ -18,7 +18,6 @@ use think\console\Output; * 用法: * php think split:sync-tickets * php think split:sync-tickets --ticket=12 - * php think split:sync-tickets --ticket=12 --mode=auto */ class SplitSyncTickets extends Command { @@ -26,24 +25,21 @@ class SplitSyncTickets extends Command { $this->setName('split:sync-tickets') ->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '') - ->addOption('mode', 'm', Option::VALUE_OPTIONAL, '同步方式: auto=定时自动, manual=手动触发', 'manual') ->setDescription('同步分流工单云控数据'); } protected function execute(Input $input, Output $output): void { set_time_limit(0); - $syncMode = $this->resolveSyncMode($input); SplitTicketSyncLogger::log('cli', 'command start', [ 'ticketOption' => trim((string) $input->getOption('ticket')), - 'syncMode' => $syncMode, 'appDebug' => SplitTicketSyncLogger::isEnabled(), ]); $service = new SplitTicketSyncService(); $ticketId = trim((string) $input->getOption('ticket')); if ($ticketId !== '' && ctype_digit($ticketId)) { - $result = $service->syncOne((int) $ticketId, true, false, $syncMode); + $result = $service->syncOne((int) $ticketId, true); if (!empty($result['skipped'])) { $output->writeln('跳过: ' . ($result['message'] ?? '') . ''); return; @@ -75,14 +71,4 @@ class SplitSyncTickets extends Command $output->writeln('详细调试日志已写入 runtime/log/split_sync.log'); } } - - /** - * 解析 CLI 同步方式,供 sync_success_mode 落库 - */ - private function resolveSyncMode(Input $input): string - { - $mode = strtolower(trim((string) $input->getOption('mode'))); - - return in_array($mode, ['auto', 'manual'], true) ? $mode : 'manual'; - } } diff --git a/patches/application/common/library/scrm/AntiBotConfigBuilder.php b/patches/application/common/library/scrm/AntiBotConfigBuilder.php index aa88dda..4b47a68 100644 --- a/patches/application/common/library/scrm/AntiBotConfigBuilder.php +++ b/patches/application/common/library/scrm/AntiBotConfigBuilder.php @@ -31,6 +31,10 @@ class AntiBotConfigBuilder $landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl); } + if ($ticketType === 'whatshub' && $landingUrlHint === '') { + $landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl); + } + $sessionScope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint); if ($sessionScope === '') { return null; @@ -58,7 +62,7 @@ class AntiBotConfigBuilder } /** - * 会话 scope:A2C 业务域+分享 id;火箭长链用动态 host、短链用 share.{token} + * 会话 scope:A2C 业务域+分享 id;Whatshub host+shareCode/workId;火箭长链用动态 host */ public static function resolveSessionScope(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string { @@ -66,6 +70,10 @@ class AntiBotConfigBuilder return self::resolveA2cSessionScope($pageUrl, $landingUrlHint); } + if ($ticketType === 'whatshub') { + return self::resolveWhatshubSessionScope($pageUrl, $landingUrlHint); + } + if ($ticketType === 'huojian') { return self::resolveHuojianSessionScope($pageUrl, $landingUrlHint); } @@ -92,6 +100,10 @@ class AntiBotConfigBuilder $landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl); } + if ($ticketType === 'whatshub' && $landingUrlHint === '') { + $landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl); + } + $scope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint); return $ticketType . ':' . ($scope !== '' ? $scope : 'unknown'); @@ -137,7 +149,8 @@ class AntiBotConfigBuilder return [ 'postCfReadyUrlContains' => 'work-order-sharing', 'postCfReadySelector' => '.el-input__inner', - 'postCfReadyTimeoutMs' => 30000, + 'postCfReadyApiUrl' => '/api/whatshub-counter/workShare/open/statistics', + 'postCfReadyTimeoutMs' => 60000, 'postCfSettleMs' => 500, 'postCfSpaWaitMs' => 4000, ]; @@ -179,7 +192,7 @@ class AntiBotConfigBuilder private static function defaultCfClearanceRequired(string $ticketType): bool { - if ($ticketType === 'a2c') { + if ($ticketType === 'a2c' || $ticketType === 'whatshub') { return false; } @@ -187,11 +200,11 @@ class AntiBotConfigBuilder } /** - * A2C 等为 CF Managed Challenge(5s 盾),非独立 Turnstile 挂件 + * A2C / Whatshub 等为 CF Managed Challenge(5s 盾),非独立 Turnstile 挂件 */ private static function defaultCfChallengeMode(string $ticketType): string { - if ($ticketType === 'a2c') { + if ($ticketType === 'a2c' || $ticketType === 'whatshub') { return 'managed'; } @@ -203,7 +216,7 @@ class AntiBotConfigBuilder */ private static function defaultCfCloudflareSolver(string $ticketType): bool { - return $ticketType === 'a2c'; + return $ticketType === 'a2c' || $ticketType === 'whatshub'; } /** @@ -233,6 +246,69 @@ class AntiBotConfigBuilder return ''; } + /** + * Whatshub 会话 scope:host + shareCode 或 workId,避免多工单共用温池 Browser + */ + private static function resolveWhatshubSessionScope(string $pageUrl, string $landingUrlHint = ''): string + { + $host = strtolower(trim((string) parse_url($pageUrl, PHP_URL_HOST))); + if ($host === '' && $landingUrlHint !== '') { + $host = strtolower(trim((string) parse_url($landingUrlHint, PHP_URL_HOST))); + } + if ($host === '') { + return ''; + } + + $shareCode = self::extractWhatshubShareCode($pageUrl); + if ($shareCode === '' && $landingUrlHint !== '') { + $shareCode = self::extractWhatshubShareCode($landingUrlHint); + } + if ($shareCode !== '') { + return $host . ':' . $shareCode; + } + + $workId = self::extractWhatshubWorkId($pageUrl); + if ($workId === '' && $landingUrlHint !== '') { + $workId = self::extractWhatshubWorkId($landingUrlHint); + } + if ($workId !== '') { + return $host . ':' . $workId; + } + + return $host; + } + + /** + * 从 Whatshub 短链 /m/{shareCode}/ 提取 shareCode + */ + private static function extractWhatshubShareCode(string $url): string + { + $path = (string) parse_url($url, PHP_URL_PATH); + if ($path === '') { + return ''; + } + if (preg_match('#/m/([^/]+)/#', $path, $matches)) { + return trim($matches[1]); + } + + return ''; + } + + /** + * 从 Whatshub 长链 work-order-sharing?workId= 提取 workId + */ + private static function extractWhatshubWorkId(string $url): string + { + $query = (string) parse_url($url, PHP_URL_QUERY); + if ($query === '') { + return ''; + } + parse_str($query, $params); + $workId = isset($params['workId']) ? trim((string) $params['workId']) : ''; + + return $workId !== '' ? $workId : ''; + } + private static function resolveA2cSessionHost(string $pageUrl, string $landingUrlHint = ''): string { $candidates = []; diff --git a/patches/application/common/library/scrm/HuojianUrlHelper.php b/patches/application/common/library/scrm/HuojianUrlHelper.php old mode 100644 new mode 100755 diff --git a/patches/application/common/library/scrm/spider/WhatshubSpider.php b/patches/application/common/library/scrm/spider/WhatshubSpider.php old mode 100755 new mode 100644 index d6dfae5..b139bcc --- a/patches/application/common/library/scrm/spider/WhatshubSpider.php +++ b/patches/application/common/library/scrm/spider/WhatshubSpider.php @@ -12,7 +12,7 @@ use app\common\service\SplitTicketSyncLogger; /** * Whatshub 云控蜘蛛 * - * 时序:detailByShareCode API 直抓(优先)→ 失败则 Real Browser + Turnstile + UI 翻页 + * 时序:cURL detailByShareCode 拉号码 → Node 监听 statistics 算完成量 → 失败则完整 Node 爬虫 */ class WhatshubSpider extends AbstractScrmSpider { @@ -52,25 +52,81 @@ class WhatshubSpider extends AbstractScrmSpider } /** - * API 优先:detailByShareCode 直抓;失败则回退 Node 爬虫 + * 混合同步:cURL 拉号码 + Node 监听 statistics 算完成量;任一失败则完整 Node fallback */ public function run(): UnifiedScrmData { $apiResult = $this->tryFetchViaShareCodeApi(); - if ($apiResult instanceof UnifiedScrmData) { - SplitTicketSyncLogger::log('spider', 'whatshub api path ok', [ - 'count' => count($apiResult->numbers), + $statsCount = $this->tryFetchStatisticsViaNode(); + + if ($apiResult instanceof UnifiedScrmData && $statsCount !== null) { + $apiResult->todayNewCount = $statsCount; + SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok', [ + 'numberCount' => count($apiResult->numbers), + 'todayNewCount' => $statsCount, ]); return $apiResult; } - SplitTicketSyncLogger::log('spider', 'whatshub api skipped or failed, fallback node', [ - 'pageUrl' => $this->pageUrl, + SplitTicketSyncLogger::log('spider', 'whatshub hybrid incomplete, fallback full node', [ + 'pageUrl' => $this->pageUrl, + 'apiOk' => $apiResult instanceof UnifiedScrmData, + 'statisticsOk' => $statsCount !== null, ]); return parent::run(); } + /** + * Node 监听 statistics API,返回 dayNewFans + reDayNewFans 总和;失败返回 null + */ + private function tryFetchStatisticsViaNode(): ?int + { + $config = $this->getSpiderConfig(); + $antiBot = $config['antiBot'] ?? null; + + SplitTicketSyncLogger::log('spider', 'whatshub stats node request', [ + 'pageUrl' => $this->pageUrl, + 'api' => self::API_DETAILS, + ]); + + $result = $this->requestNode('/api/auth-and-intercept', [ + 'pageUrl' => $config['pageUrl'], + 'apiUrls' => [self::API_DETAILS], + 'authActions' => $config['authActions'] ?? [], + 'antiBot' => $antiBot, + ], $this->resolveAuthInterceptTimeout(is_array($antiBot) ? $antiBot : null)); + + if (empty($result['success'])) { + SplitTicketSyncLogger::log('spider', 'whatshub stats node failed', [ + 'error' => $result['error'] ?? '未知', + 'code' => $result['code'] ?? null, + ]); + return null; + } + + $intercepted = $result['interceptedApis'] ?? []; + if (!isset($intercepted[self::API_DETAILS]['data']) || !is_array($intercepted[self::API_DETAILS]['data'])) { + SplitTicketSyncLogger::log('spider', 'whatshub stats node missing payload', [ + 'intercepted' => array_keys(is_array($intercepted) ? $intercepted : []), + ]); + return null; + } + + $detailData = $intercepted[self::API_DETAILS]['data']; + $dayNewFans = (int) ($detailData['data']['dayNewFans'] ?? 0); + $reDayNewFans = (int) ($detailData['data']['reDayNewFans'] ?? 0); + $total = $dayNewFans + $reDayNewFans; + + SplitTicketSyncLogger::log('spider', 'whatshub stats node ok', [ + 'dayNewFans' => $dayNewFans, + 'reDayNewFans' => $reDayNewFans, + 'total' => $total, + ]); + + return $total; + } + /** * 从短链 pageUrl 解析 shareCode,例如 /m/KMYBBInp2066/1 → KMYBBInp2066 */ @@ -202,7 +258,6 @@ class WhatshubSpider extends AbstractScrmSpider private function parseShareCodeApiResponse(array $payload): UnifiedScrmData { $unifiedData = new UnifiedScrmData(); - $todayNewSum = 0; foreach ($payload['data'] as $item) { if (!is_array($item) || empty($item['account'])) { @@ -213,11 +268,10 @@ class WhatshubSpider extends AbstractScrmSpider $isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1; $dayNewFans = (int) ($item['dayNewFans'] ?? 0); + // 号码同步仅使用 dayNewFans,不含 reDayNewFans;完成量由 Node statistics 提供 $unifiedData->addNumber($number, $isOnline, $dayNewFans); - $todayNewSum += $dayNewFans; } - $unifiedData->todayNewCount = $todayNewSum; $unifiedData->total = count($unifiedData->numbers); return $unifiedData; @@ -279,7 +333,7 @@ class WhatshubSpider extends AbstractScrmSpider $unifiedData = $this->unifiedData; if (is_array($detailData)) { - $unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0); + $unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0) + (int) ($detailData['data']['reDayNewFans'] ?? 0); } foreach ($allListPagesData as $pageRaw) { diff --git a/patches/application/common/service/SplitNumberRatioDeferredService.php b/patches/application/common/service/SplitNumberRatioDeferredService.php old mode 100644 new mode 100755 index 44b51b3..6891165 --- a/patches/application/common/service/SplitNumberRatioDeferredService.php +++ b/patches/application/common/service/SplitNumberRatioDeferredService.php @@ -56,6 +56,9 @@ class SplitNumberRatioDeferredService /** * 同步侧:根据 visit / inbound 与上次同步检查点重算 streak * + * 访问侧(recordVisitAfterRedirect)已按次 +1 累计 streak,此处不得再叠加 delta,否则首次同步会双倍计次导致提前关号。 + * 取 max(访问侧 streak, 自上次同步以来的点击增量) 作为裁决值,并兜底访问钩子未触发的场景。 + * * @param array|Number $number */ public function computeStreakForSync($number, int $currentStreak): int @@ -74,7 +77,9 @@ class SplitNumberRatioDeferredService } if ($visitCount > $lastVisit) { - return $currentStreak + ($visitCount - $lastVisit); + $deltaSinceSync = $visitCount - $lastVisit; + + return max($currentStreak, $deltaSinceSync); } return $currentStreak; diff --git a/patches/application/common/service/SplitNumberVisitRuleService.php b/patches/application/common/service/SplitNumberVisitRuleService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitPageUrlResolver.php b/patches/application/common/service/SplitPageUrlResolver.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitScrmSpiderFactory.php b/patches/application/common/service/SplitScrmSpiderFactory.php old mode 100755 new mode 100644 index 90a5f0c..074fe76 --- a/patches/application/common/service/SplitScrmSpiderFactory.php +++ b/patches/application/common/service/SplitScrmSpiderFactory.php @@ -36,12 +36,11 @@ class SplitScrmSpiderFactory /** * API 优先同步类型:常态走 PHP cURL,不占 Node Real Browser 槽位 * - * 调度层据此跳过 Node 队列门禁并允许并行投递;蜘蛛实现本身不在此修改。 + * Whatshub 已改为混合同步(cURL 号码 + Node statistics),需走 Node 队列门禁。 * * @var list */ private const API_FIRST_SYNC_TYPES = [ - 'whatshub', ]; /** diff --git a/patches/application/common/service/SplitTicketSyncDispatchService.php b/patches/application/common/service/SplitTicketSyncDispatchService.php old mode 100644 new mode 100755 index 71faa83..c39d351 --- a/patches/application/common/service/SplitTicketSyncDispatchService.php +++ b/patches/application/common/service/SplitTicketSyncDispatchService.php @@ -373,13 +373,11 @@ class SplitTicketSyncDispatchService $logDir = $this->resolveLogDir(); $logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log'; - $syncMode = $this->normalizeSyncModeForCli($source); $inner = sprintf( - '%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1', + '%s %s split:sync-tickets --ticket=%d >> %s 2>&1', escapeshellarg($php), escapeshellarg($think), $ticketId, - $syncMode, escapeshellarg($logFile) ); @@ -405,13 +403,11 @@ class SplitTicketSyncDispatchService continue; } $logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log'; - $syncMode = $this->normalizeSyncModeForCli($source); $parts[] = sprintf( - '%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1', + '%s %s split:sync-tickets --ticket=%d >> %s 2>&1', escapeshellarg($php), escapeshellarg($think), $ticketId, - $syncMode, escapeshellarg($logFile) ); } @@ -556,12 +552,4 @@ class SplitTicketSyncDispatchService { return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true); } - - /** - * 投递 CLI 时携带 --mode,供 sync_success_mode 区分自动/手动 - */ - private function normalizeSyncModeForCli(string $source): string - { - return in_array($source, ['auto', 'manual'], true) ? $source : 'manual'; - } } diff --git a/patches/public/assets/js/backend/split/number.js b/patches/public/assets/js/backend/split/number.js index 07ca9ed..d9046b6 100755 --- a/patches/public/assets/js/backend/split/number.js +++ b/patches/public/assets/js/backend/split/number.js @@ -64,7 +64,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin visible: false, searchable: true, operate: '=', - searchList: Controller.api.buildRatioDeferredSearchList() + searchList: Config.ratioDeferredList || {} }, searchSelectMeta(false) ), @@ -160,16 +160,6 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin } return ext; }, - /** - * PHP json_encode 会把 ['0'=>..,'1'=>..] 压成数组,FastAdmin 通用搜索会误用文案作 option value - */ - buildRatioDeferredSearchList: function () { - var list = Config.ratioDeferredList; - if ($.isArray(list)) { - return {0: list[0], 1: list[1]}; - } - return list || {}; - }, initCommonSearchSelectpicker: function (tbl) { var $form = $('form.form-commonsearch', tbl.$container); if ($form.length) { diff --git a/patches/puppeteer-api/.env.example b/patches/puppeteer-api/.env.example old mode 100644 new mode 100755 diff --git a/patches/puppeteer-api/lib/browser-pool.js b/patches/puppeteer-api/lib/browser-pool.js old mode 100644 new mode 100755 diff --git a/patches/puppeteer-api/lib/captcha-proxy.js b/patches/puppeteer-api/lib/captcha-proxy.js old mode 100644 new mode 100755 diff --git a/patches/puppeteer-api/lib/cf-api-fallback.js b/patches/puppeteer-api/lib/cf-api-fallback.js old mode 100644 new mode 100755 diff --git a/patches/puppeteer-api/lib/huojian-url.js b/patches/puppeteer-api/lib/huojian-url.js old mode 100644 new mode 100755 diff --git a/patches/puppeteer-api/lib/post-cf-ready.js b/patches/puppeteer-api/lib/post-cf-ready.js old mode 100644 new mode 100755 diff --git a/patches/puppeteer-api/lib/profile-dir.js b/patches/puppeteer-api/lib/profile-dir.js old mode 100644 new mode 100755 diff --git a/patches/puppeteer-api/lib/session-context.js b/patches/puppeteer-api/lib/session-context.js old mode 100644 new mode 100755 diff --git a/patches/puppeteer-api/lib/sitekey-capture.js b/patches/puppeteer-api/lib/sitekey-capture.js old mode 100644 new mode 100755 diff --git a/patches/puppeteer-api/lib/ui-pagination-runner.js b/patches/puppeteer-api/lib/ui-pagination-runner.js old mode 100644 new mode 100755 diff --git a/patches/puppeteer-api/server.js b/patches/puppeteer-api/server.js index 266bc99..170d849 100755 --- a/patches/puppeteer-api/server.js +++ b/patches/puppeteer-api/server.js @@ -227,17 +227,21 @@ function applyWhatshubPostCfDefaults(antiBot, pageUrl) { } try { const parsed = new URL(pageUrl || ''); - if (!parsed.hostname.includes('whatshub.cc') || !parsed.pathname.includes('/m/')) { + const isWhatshubHost = parsed.hostname.includes('whatshub.cc'); + const isShortLink = parsed.pathname.includes('/m/'); + const isLongLink = pageUrl.includes('work-order-sharing'); + if (!isWhatshubHost || (!isShortLink && !isLongLink)) { return antiBot; } - console.log('[CF] Whatshub 短链自动启用 postCfReady 默认配置'); + console.log('[CF] Whatshub 自动启用 postCfReady 默认配置'); return { ...antiBot, postCfReadyUrlContains: 'work-order-sharing', postCfReadySelector: '.el-input__inner', - postCfReadyTimeoutMs: antiBot.postCfReadyTimeoutMs || 30000, + postCfReadyApiUrl: '/api/whatshub-counter/workShare/open/statistics', + postCfReadyTimeoutMs: antiBot.postCfReadyTimeoutMs || 60000, postCfSettleMs: antiBot.postCfSettleMs || 500, - postCfSpaWaitMs: antiBot.postCfSpaWaitMs || 8000, + postCfSpaWaitMs: antiBot.postCfSpaWaitMs || 4000, }; } catch (_) { return antiBot; @@ -1187,11 +1191,17 @@ async function runCloudflareWithBusinessGuard(page, antiBot, waitForUrlSettled, cfResult = await handleCloudflareChallengeSafe(page, antiBot, waitForUrlSettled, storedSession); } - // 短链跨域落地(如 yyk.ink → user.a2c.chat)后,业务域可能仍有 CF + // 短链跨域落地(如 yyk.ink → user.a2c.chat、Whatshub /m/ → work-order-sharing)后,业务域可能仍有 CF if (cfResult.success && !(await isCfTrulyComplete(page, antiBot))) { const crossState = await detectCloudflare(page, antiBot); if (crossState.blocked || urlIndicatesCfChallenge(page.url())) { - console.log(`[CF] 跨域落地后 CF 未完成,二次过盾 url=${page.url()}`); + const isWhatshubLongCf = + urlNeedle === 'work-order-sharing' && urlIndicatesCfChallenge(page.url()); + if (isWhatshubLongCf) { + console.log(`[CF] Whatshub 长链二次过盾 url=${page.url()}`); + } else { + console.log(`[CF] 跨域落地后 CF 未完成,二次过盾 url=${page.url()}`); + } page = await maybeEnsureProxy(page); cfResult = await finalizeCfResult( page,