nodeHost = rtrim($nodeHost, '/'); } /** @return array */ abstract protected function getSpiderConfig(): array; /** * @param array|null $countData */ abstract protected function extractListTotalPages($listFirstPageData, $countData = null); /** * @return array */ abstract protected function buildListPageParams(int $page): array; /** @return array */ abstract protected function getUiPaginationConfig(): array; /** * @param mixed $detailData * @param array $allListPagesData */ abstract protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData; public function run(): UnifiedScrmData { $config = $this->getSpiderConfig(); SplitTicketSyncLogger::log('spider', 'run start', [ 'nodeHost' => $this->nodeHost, 'pageUrl' => $config['pageUrl'] ?? '', 'listApi' => $config['listApi'] ?? '', 'paginationMode' => $config['paginationMode'] ?? self::MODE_FETCH, ]); $listApi = (string) ($config['listApi'] ?? ''); $detailApi = $config['detailApi'] ?? null; $countApi = $config['countApi'] ?? null; $apiUrlsToIntercept = [$listApi]; if ($detailApi) { $apiUrlsToIntercept[] = $detailApi; } if ($countApi) { $apiUrlsToIntercept[] = $countApi; } $antiBot = $config['antiBot'] ?? null; $mode = $config['paginationMode'] ?? self::MODE_FETCH; if ($this->shouldUseUnifiedBrowserPath($antiBot, $mode)) { return $this->runUnifiedUiPath($config, $antiBot, $apiUrlsToIntercept, $listApi, $detailApi, $countApi); } $initResult = $this->requestNode('/api/auth-and-intercept', [ 'pageUrl' => $config['pageUrl'], 'apiUrls' => $apiUrlsToIntercept, 'authActions' => $config['authActions'] ?? [], 'antiBot' => $antiBot, ], $this->resolveAuthInterceptTimeout($antiBot)); if (empty($initResult['success'])) { $cfCode = (string) ($initResult['code'] ?? ''); SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [ 'error' => $initResult['error'] ?? '未知', 'code' => $cfCode !== '' ? $cfCode : null, 'challengeType' => $initResult['challengeType'] ?? null, 'stage' => $initResult['stage'] ?? null, 'cf' => $initResult['cf'] ?? null, ]); $errMsg = (string) ($initResult['error'] ?? '未知'); if ($cfCode === 'CF_TURNSTILE_FAILED') { throw new Exception('Cloudflare Turnstile 验证失败: ' . ($initResult['stage'] ?? 'timeout')); } throw new Exception('初始化失败: ' . $errMsg); } $interceptedApis = $initResult['interceptedApis']; $finalPageUrl = (string) ($initResult['finalPageUrl'] ?? ($config['pageUrl'] ?? '')); SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [ 'intercepted' => array_keys($interceptedApis), 'finalPageUrl' => $finalPageUrl, 'cfStage' => $initResult['cf']['turnstileStage'] ?? ($initResult['cf']['stage'] ?? null), 'browserReused' => $initResult['browserReused'] ?? null, ]); $cookies = $initResult['cookies']; if (!isset($interceptedApis[$listApi])) { throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]"); } $detailData = $detailApi && isset($interceptedApis[$detailApi]) ? $interceptedApis[$detailApi]['data'] : null; $countData = $countApi && isset($interceptedApis[$countApi]) ? $interceptedApis[$countApi]['data'] : null; $listApiNode = $interceptedApis[$listApi]; $allListPagesData = [$listApiNode['data']]; $totalPages = $this->extractListTotalPages($listApiNode['data'], $countData); SplitTicketSyncLogger::log('spider', 'pagination plan', [ 'totalPages' => $totalPages, 'mode' => $mode, ]); if ($totalPages > 1 || $totalPages === null) { if ($mode === self::MODE_FETCH && $totalPages !== null) { $paramList = []; for ($page = 2; $page <= $totalPages; $page++) { $paramList[] = $this->buildListPageParams($page); } $fetchResult = $this->requestNode('/api/batch-fetch', [ 'tasks' => [[ 'apiPath' => $listApi, 'fullUrl' => $listApiNode['url'], 'headers' => $listApiNode['headers'] ?? '', 'paramList' => $paramList, 'method' => $config['listMethod'] ?? 'GET', ]], 'cookies' => $cookies, 'antiBot' => $antiBot, ], 120); if (!empty($fetchResult['success'])) { foreach ($fetchResult['results'][$listApi] as $pResult) { if (!empty($pResult['success'])) { $allListPagesData[] = $pResult['data']; } } } } elseif ($mode === self::MODE_UI) { $uiConfig = $this->getUiPaginationConfig(); $firstPageData = $listApiNode['data']; $clicksToPerform = ($totalPages === null) ? 9999 : ($totalPages - 1); $uiResult = $this->requestNode('/api/ui-pagination', [ 'apiUrl' => $listApi, 'pageUrl' => $config['pageUrl'], 'finalPageUrl' => $finalPageUrl, 'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '', 'waitMs' => $uiConfig['waitMs'] ?? 2000, 'clicksToPerform' => $clicksToPerform, 'cookies' => $cookies, 'firstPageData' => $firstPageData, 'authActions' => $config['authActions'] ?? [], 'antiBot' => $antiBot, ], 1200); if (!empty($uiResult['success']) && !empty($uiResult['data'])) { foreach ($uiResult['data'] as $pageData) { $allListPagesData[] = $pageData; } } } } $result = $this->parseToUnifiedData($detailData, $allListPagesData); SplitTicketSyncLogger::log('spider', 'run done', [ 'todayNewCount' => $result->todayNewCount, 'totalOnline' => $result->totalOnline, 'totalOffline' => $result->totalOffline, 'total' => $result->total, 'numberCount' => count($result->numbers), ]); return $result; } /** * 单 Browser 路径:auth + 拦截 + UI 翻页(需 antiBot.sessionKey) * * @param array $config * @param array|null $antiBot * @param list $apiUrlsToIntercept */ private function runUnifiedUiPath( array $config, ?array $antiBot, array $apiUrlsToIntercept, string $listApi, ?string $detailApi, ?string $countApi ): UnifiedScrmData { $uiConfig = $this->getUiPaginationConfig(); $sessionKey = is_array($antiBot) ? (string) ($antiBot['sessionKey'] ?? '') : ''; SplitTicketSyncLogger::log('spider', 'unified browser path', [ 'sessionKey' => $sessionKey, 'listApi' => $listApi, ]); $mergedResult = $this->requestNode('/api/auth-intercept-and-paginate', [ 'pageUrl' => $config['pageUrl'], 'apiUrls' => $apiUrlsToIntercept, 'authActions' => $config['authActions'] ?? [], 'antiBot' => $antiBot, 'pagination' => [ 'apiUrl' => $listApi, 'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '', 'waitMs' => $uiConfig['waitMs'] ?? 2000, 'clicksToPerform' => 9999, ], ], 1200); if (empty($mergedResult['success'])) { $cfCode = (string) ($mergedResult['code'] ?? ''); SplitTicketSyncLogger::log('spider', 'auth-intercept-and-paginate failed', [ 'error' => $mergedResult['error'] ?? '未知', 'code' => $cfCode !== '' ? $cfCode : null, 'stage' => $mergedResult['stage'] ?? null, 'cf' => $mergedResult['cf'] ?? null, 'sessionKey' => $sessionKey, ]); if ($cfCode === 'CF_TURNSTILE_FAILED') { throw new Exception('Cloudflare Turnstile 验证失败: ' . ($mergedResult['stage'] ?? 'timeout')); } throw new Exception('合并同步失败: ' . (string) ($mergedResult['error'] ?? '未知')); } SplitTicketSyncLogger::log('spider', 'auth-intercept-and-paginate ok', [ 'intercepted' => array_keys($mergedResult['interceptedApis'] ?? []), 'extraPages' => count($mergedResult['extraPages'] ?? []), 'cfStage' => $mergedResult['cf']['turnstileStage'] ?? ($mergedResult['cf']['stage'] ?? null), 'browserReused' => $mergedResult['browserReused'] ?? null, 'sessionKey' => $sessionKey, ]); $interceptedApis = $mergedResult['interceptedApis'] ?? []; if (!isset($interceptedApis[$listApi])) { throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]"); } $detailData = $detailApi && isset($interceptedApis[$detailApi]) ? $interceptedApis[$detailApi]['data'] : null; $countData = $countApi && isset($interceptedApis[$countApi]) ? $interceptedApis[$countApi]['data'] : null; $listApiNode = $interceptedApis[$listApi]; $allListPagesData = [$listApiNode['data']]; if (!empty($mergedResult['extraPages']) && is_array($mergedResult['extraPages'])) { foreach ($mergedResult['extraPages'] as $pageData) { $allListPagesData[] = $pageData; } } $this->extractListTotalPages($listApiNode['data'], $countData); $result = $this->parseToUnifiedData($detailData, $allListPagesData); SplitTicketSyncLogger::log('spider', 'run done', [ 'todayNewCount' => $result->todayNewCount, 'totalOnline' => $result->totalOnline, 'totalOffline' => $result->totalOffline, 'total' => $result->total, 'numberCount' => count($result->numbers), 'unifiedPath' => true, ]); return $result; } /** * @param array|null $antiBot */ protected function shouldUseUnifiedBrowserPath(?array $antiBot, string $mode): bool { if ($mode !== self::MODE_UI) { return false; } if (!is_array($antiBot) || empty($antiBot['enabled'])) { return false; } return !empty($antiBot['sessionKey']); } /** * Real Browser + Turnstile 首屏耗时较长,单独放宽 auth-and-intercept 超时 * * @param array|null $antiBot */ protected function resolveAuthInterceptTimeout(?array $antiBot): int { if (!is_array($antiBot) || empty($antiBot['enabled'])) { return 120; } return 180; } /** * @param array $payload * @return array */ protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array { $maxAttempts = 3; $lastDecoded = []; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { $lastDecoded = $this->doRequestNode($endpoint, $payload, $timeout, $attempt); if (!empty($lastDecoded['success'])) { return $lastDecoded; } $error = (string) ($lastDecoded['error'] ?? ''); if (!$this->shouldRetryNodeResponse($lastDecoded, $error) || $attempt >= $maxAttempts) { return $lastDecoded; } $delaySeconds = $attempt; SplitTicketSyncLogger::log('node_retry', 'retry ' . $endpoint, [ 'attempt' => $attempt + 1, 'error' => $error, 'delay' => $delaySeconds, ]); sleep($delaySeconds); } return $lastDecoded; } /** * @param array $payload * @return array */ private function doRequestNode(string $endpoint, array $payload, int $timeout, int $attempt): array { $url = $this->nodeHost . $endpoint; $started = microtime(true); $logPayload = SplitTicketSyncLogger::isEnabled() ? $payload : self::summarizeNodePayload($payload); SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [ 'url' => $url, 'timeout' => $timeout, 'attempt' => $attempt, 'payload' => $logPayload, ]); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); $response = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $elapsedMs = (int) round((microtime(true) - $started) * 1000); if (curl_errno($ch)) { $err = curl_error($ch); curl_close($ch); SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [ 'httpCode' => $httpCode, 'elapsedMs' => $elapsedMs, 'attempt' => $attempt, 'error' => $err, ]); throw new Exception($err); } curl_close($ch); $decoded = json_decode((string) $response, true); $summary = is_array($decoded) ? self::summarizeNodeResponse($decoded) : ['raw' => mb_substr((string) $response, 0, 300, 'UTF-8')]; SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([ 'httpCode' => $httpCode, 'elapsedMs' => $elapsedMs, 'attempt' => $attempt, 'responseSize' => strlen((string) $response), ], $summary)); if (!is_array($decoded)) { return ['success' => false, 'error' => 'Node 返回非 JSON', 'httpCode' => $httpCode]; } if ($httpCode === 503) { $decoded['success'] = false; $decoded['error'] = (string) ($decoded['error'] ?? '服务繁忙'); $decoded['httpCode'] = 503; } return $decoded; } /** * @param array $decoded */ private function shouldRetryNodeResponse(array $decoded, string $error): bool { if ((int) ($decoded['httpCode'] ?? 0) === 503) { return true; } $lower = mb_strtolower($error, 'UTF-8'); foreach (['排队超时', 'queue_timeout', 'timeout', 'net::', 'navigation', 'connection'] as $needle) { if ($needle !== '' && mb_strpos($lower, mb_strtolower($needle, 'UTF-8')) !== false) { return true; } } return false; } /** * @param array $payload * @return array */ private static function summarizeNodePayload(array $payload): array { $summary = $payload; if (isset($summary['tasks']) && is_array($summary['tasks'])) { $summary['tasks'] = array_map(static function ($task) { if (!is_array($task)) { return $task; } $copy = $task; if (isset($copy['paramList']) && is_array($copy['paramList'])) { $copy['paramList_count'] = count($copy['paramList']); unset($copy['paramList']); } return $copy; }, $summary['tasks']); } if (isset($summary['cookies']) && is_array($summary['cookies'])) { $summary['cookies_count'] = count($summary['cookies']); unset($summary['cookies']); } return $summary; } /** * @param array $decoded * @return array */ private static function summarizeNodeResponse(array $decoded): array { $summary = [ 'success' => $decoded['success'] ?? null, 'error' => $decoded['error'] ?? null, ]; if (isset($decoded['interceptedApis']) && is_array($decoded['interceptedApis'])) { $summary['interceptedApis'] = array_keys($decoded['interceptedApis']); } if (isset($decoded['results']) && is_array($decoded['results'])) { $summary['resultApis'] = array_keys($decoded['results']); } if (isset($decoded['data']) && is_array($decoded['data'])) { $summary['dataPages'] = count($decoded['data']); } return $summary; } }