pageUrl = $pageUrl; $this->account = $account; $this->password = $password; $this->baseUrl = $this->resolveBaseUrl($pageUrl); $this->unifiedData = new UnifiedScrmData(); } /** * 执行抓取并返回统一数据 * * @throws RuntimeException */ public function run(): UnifiedScrmData { $cookieFile = tempnam(sys_get_temp_dir(), 'xinghe_spider_cookie_'); if ($cookieFile === false) { throw new RuntimeException('无法在系统临时目录创建 Cookie 文件'); } $this->cookieFile = $cookieFile; $this->ch = curl_init(); if ($this->ch === false) { throw new RuntimeException('cURL 初始化失败'); } try { curl_setopt_array($this->ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false, CURLOPT_TIMEOUT => self::CURL_TIMEOUT_SECONDS, CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_COOKIEFILE => $this->cookieFile, ]); $this->authenticate(); $firstPageData = $this->fetchApiData($this->buildApiUrl(1)); $this->unifiedData->total = (int) ($firstPageData['count'] ?? 0); $this->unifiedData->todayNewCount = (int) ($firstPageData['totalRow']['day_sum'] ?? 0); $totalPages = (int) ceil($this->unifiedData->total / self::DEFAULT_PER_PAGE_COUNT); $allListPagesData = [$firstPageData['data'] ?? []]; for ($page = 2; $page <= $totalPages; $page++) { $pageData = $this->fetchApiData($this->buildApiUrl($page)); $allListPagesData[] = $pageData['data'] ?? []; } return $this->parseToUnifiedData($allListPagesData); } finally { $this->cleanup(); } } /** * 访问授权页面,建立会话 Cookie */ private function authenticate(): void { $this->sendRequest($this->pageUrl); } /** * 请求 API 并解析 JSON * * @return array */ private function fetchApiData(string $apiPath): array { $response = $this->sendRequest($this->baseUrl . $apiPath); $data = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new RuntimeException( 'JSON 解析失败: ' . json_last_error_msg() . '。原始响应: ' . mb_substr($response, 0, 500, 'UTF-8') ); } if (!is_array($data)) { throw new RuntimeException('API 返回数据格式无效'); } return $data; } /** * @param list $allListPagesData */ private function parseToUnifiedData(array $allListPagesData): UnifiedScrmData { $unifiedData = $this->unifiedData; foreach ($allListPagesData as $records) { if (!is_array($records)) { continue; } foreach ($records as $item) { if (!is_array($item) || empty($item['user'])) { continue; } $number = (string) $item['user']; $isOnline = isset($item['online']) && (int) $item['online'] === 1; $unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0)); } } return $unifiedData; } /** * 发送 cURL 请求 */ private function sendRequest(string $url): string { if ($this->ch === null) { throw new RuntimeException('cURL 句柄未初始化'); } curl_setopt($this->ch, CURLOPT_URL, $url); $response = curl_exec($this->ch); if ($response === false) { throw new RuntimeException(sprintf('请求失败 [%s]: %s', $url, curl_error($this->ch))); } $httpCode = (int) curl_getinfo($this->ch, CURLINFO_HTTP_CODE); if ($httpCode >= 400) { throw new RuntimeException(sprintf('HTTP 请求异常 [%s],状态码: %d', $url, $httpCode)); } return (string) $response; } /** * 构建分页 API 路径(相对 baseUrl) */ private function buildApiUrl(int $page): string { return sprintf( '%s?page=%d&limit=%d&id=&class_id=&is_repet=1&start_time=&end_time=', self::API_PATH, $page, self::DEFAULT_PER_PAGE_COUNT ); } /** * 从授权页 URL 解析站点根地址 */ private function resolveBaseUrl(string $pageUrl): string { $parsedUrl = parse_url($pageUrl); if ($parsedUrl === false || empty($parsedUrl['host'])) { throw new RuntimeException('工单链接格式无效,无法解析域名'); } $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : 'http://'; $port = isset($parsedUrl['port']) ? $parsedUrl['port'] : ''; if($port !== '' && $port !== 80 && $port !== 443) { $port = ':' . $port; } return $scheme . $parsedUrl['host'] . $port; } /** * 释放 cURL 资源并删除临时 Cookie 文件 */ private function cleanup(): void { if ($this->ch !== null) { if (is_resource($this->ch) || $this->ch instanceof \CurlHandle) { curl_close($this->ch); } $this->ch = null; } if ($this->cookieFile !== null && $this->cookieFile !== '' && is_file($this->cookieFile)) { @unlink($this->cookieFile); } $this->cookieFile = null; } /** * 兜底清理:防止 run() 异常退出时资源泄漏 */ public function __destruct() { $this->cleanup(); } }