2026-06-09 03:36:30 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace app\common\library\scrm\spider;
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
use app\common\library\scrm\ScrmSpiderInterface;
|
2026-06-09 03:36:30 +08:00
|
|
|
|
use app\common\library\scrm\UnifiedScrmData;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
use RuntimeException;
|
2026-06-09 03:36:30 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-20 04:47:34 +08:00
|
|
|
|
* 星河云控蜘蛛(纯 PHP cURL 抓取,不依赖 Node Puppeteer)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 流程:访问带 token 的授权页建立 Cookie 会话 → 分页请求列表 API → 清洗为 UnifiedScrmData
|
2026-06-09 03:36:30 +08:00
|
|
|
|
*/
|
2026-06-20 04:47:34 +08:00
|
|
|
|
class XingheSpider implements ScrmSpiderInterface
|
2026-06-09 03:36:30 +08:00
|
|
|
|
{
|
2026-06-20 04:47:34 +08:00
|
|
|
|
private const API_PATH = '/share/share/api_yinliu_count.html';
|
2026-06-09 03:36:30 +08:00
|
|
|
|
|
|
|
|
|
|
private const DEFAULT_PER_PAGE_COUNT = 10;
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
private const CURL_TIMEOUT_SECONDS = 15;
|
|
|
|
|
|
|
|
|
|
|
|
/** @var resource|\CurlHandle|null */
|
|
|
|
|
|
private $ch = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** @var string|null 临时 Cookie 文件路径,仅在 run() 成功后赋值 */
|
|
|
|
|
|
private ?string $cookieFile = null;
|
|
|
|
|
|
|
2026-06-09 03:36:30 +08:00
|
|
|
|
private string $pageUrl;
|
|
|
|
|
|
|
|
|
|
|
|
private string $account;
|
|
|
|
|
|
|
|
|
|
|
|
private string $password;
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
private string $baseUrl;
|
|
|
|
|
|
|
2026-06-09 03:36:30 +08:00
|
|
|
|
private UnifiedScrmData $unifiedData;
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @param string $pageUrl 带 token 的授权页地址
|
|
|
|
|
|
* @param string $account 账号(星河云控当前未使用,保留与工厂签名一致)
|
|
|
|
|
|
* @param string $password 密码(星河云控当前未使用,保留与工厂签名一致)
|
|
|
|
|
|
* @param string $nodeHost Node 地址(星河云控不使用,忽略)
|
|
|
|
|
|
*/
|
2026-06-09 03:36:30 +08:00
|
|
|
|
public function __construct(
|
|
|
|
|
|
string $pageUrl,
|
|
|
|
|
|
string $account = '',
|
|
|
|
|
|
string $password = '',
|
2026-06-20 04:47:34 +08:00
|
|
|
|
string $nodeHost = ''
|
2026-06-09 03:36:30 +08:00
|
|
|
|
) {
|
2026-06-20 04:47:34 +08:00
|
|
|
|
unset($nodeHost);
|
|
|
|
|
|
|
2026-06-09 03:36:30 +08:00
|
|
|
|
$this->pageUrl = $pageUrl;
|
|
|
|
|
|
$this->account = $account;
|
|
|
|
|
|
$this->password = $password;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
$this->baseUrl = $this->resolveBaseUrl($pageUrl);
|
2026-06-09 03:36:30 +08:00
|
|
|
|
$this->unifiedData = new UnifiedScrmData();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 执行抓取并返回统一数据
|
|
|
|
|
|
*
|
|
|
|
|
|
* @throws RuntimeException
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function run(): UnifiedScrmData
|
2026-06-09 03:36:30 +08:00
|
|
|
|
{
|
2026-06-20 04:47:34 +08:00
|
|
|
|
$cookieFile = tempnam(sys_get_temp_dir(), 'xinghe_spider_cookie_');
|
|
|
|
|
|
if ($cookieFile === false) {
|
|
|
|
|
|
throw new RuntimeException('无法在系统临时目录创建 Cookie 文件');
|
|
|
|
|
|
}
|
2026-06-09 03:36:30 +08:00
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
$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();
|
2026-06-09 03:36:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 访问授权页面,建立会话 Cookie
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function authenticate(): void
|
2026-06-09 03:36:30 +08:00
|
|
|
|
{
|
2026-06-20 04:47:34 +08:00
|
|
|
|
$this->sendRequest($this->pageUrl);
|
2026-06-09 03:36:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 请求 API 并解析 JSON
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array<string, mixed>
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function fetchApiData(string $apiPath): array
|
2026-06-09 03:36:30 +08:00
|
|
|
|
{
|
2026-06-20 04:47:34 +08:00
|
|
|
|
$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;
|
2026-06-09 03:36:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @param list<mixed> $allListPagesData
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function parseToUnifiedData(array $allListPagesData): UnifiedScrmData
|
2026-06-09 03:36:30 +08:00
|
|
|
|
{
|
|
|
|
|
|
$unifiedData = $this->unifiedData;
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
foreach ($allListPagesData as $records) {
|
|
|
|
|
|
if (!is_array($records)) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-06-09 03:36:30 +08:00
|
|
|
|
foreach ($records as $item) {
|
2026-06-20 04:47:34 +08:00
|
|
|
|
if (!is_array($item) || empty($item['user'])) {
|
2026-06-09 03:36:30 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$number = (string) $item['user'];
|
|
|
|
|
|
$isOnline = isset($item['online']) && (int) $item['online'] === 1;
|
|
|
|
|
|
$unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-06-09 03:36:30 +08:00
|
|
|
|
return $unifiedData;
|
|
|
|
|
|
}
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送 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();
|
|
|
|
|
|
}
|
2026-06-09 03:36:30 +08:00
|
|
|
|
}
|