添加whatshub工单

This commit is contained in:
root
2026-06-20 04:47:34 +08:00
parent a6c87e8e25
commit d5a0ffa6db
271 changed files with 9377 additions and 303 deletions
+244 -52
View File
@@ -15,63 +15,255 @@ class CountryIso
* @var array<string, string>
*/
private const COUNTRIES = [
'CN' => '中国',
'HK' => '中国香港',
'MO' => '中国澳门',
'TW' => '中国台湾',
'US' => '美国',
'CA' => '加拿大',
'GB' => '英国',
'DE' => '德国',
'FR' => '法国',
'IT' => '意大利',
'ES' => '西班牙',
'NL' => '荷兰',
'BE' => '比利时',
'CH' => '瑞士',
'AT' => '奥地利',
'SE' => '瑞典',
'NO' => '挪威',
'DK' => '丹麦',
'FI' => '芬兰',
'IE' => '爱尔兰',
'PT' => '葡萄牙',
'PL' => '波兰',
'CZ' => '捷克',
'HU' => '匈牙利',
'RO' => '罗马尼亚',
'GR' => '希腊',
'RU' => '俄罗斯',
'UA' => '乌克兰',
'TR' => '土耳其',
'IL' => '以色列',
'SA' => '沙特阿拉伯',
'AE' => '阿联酋',
'QA' => '卡塔尔',
'KW' => '科威特',
'IN' => '印度',
'PK' => '巴基斯坦',
'BD' => '孟加拉国',
'TH' => '泰国',
'VN' => '越南',
'MY' => '马来西亚',
'SG' => '新加坡',
'ID' => '印度尼西亚',
'PH' => '菲律宾',
'JP' => '日本',
'KR' => '韩国',
'AU' => '澳大利亚',
'NZ' => '新西兰',
'BR' => '巴西',
'MX' => '墨西哥',
'AD' => '安道尔',
'AE' => '阿拉伯联合酋长国',
'AF' => '阿富汗',
'AG' => '安提瓜和巴布达',
'AI' => '安圭拉',
'AL' => '阿尔巴尼亚',
'AM' => '亚美尼亚',
'AO' => '安哥拉',
'AQ' => '南极洲',
'AR' => '阿根廷',
'AS' => '美属萨摩亚',
'AT' => '奥地利',
'AU' => '澳大利亚',
'AW' => '阿鲁巴',
'AX' => '奥兰群岛',
'AZ' => '阿塞拜疆',
'BA' => '波斯尼亚和黑塞哥维那',
'BB' => '巴巴多斯',
'BD' => '孟加拉国',
'BE' => '比利时',
'BF' => '布基纳法索',
'BG' => '保加利亚',
'BH' => '巴林',
'BI' => '布隆迪',
'BJ' => '贝宁',
'BL' => '圣巴泰勒米',
'BM' => '百慕大',
'BN' => '文莱',
'BO' => '玻利维亚',
'BQ' => '荷兰加勒比区',
'BR' => '巴西',
'BS' => '巴哈马',
'BT' => '不丹',
'BV' => '布韦岛',
'BW' => '博茨瓦纳',
'BY' => '白俄罗斯',
'BZ' => '伯利兹',
'CA' => '加拿大',
'CC' => '科科斯(基林)群岛',
'CD' => '刚果(金)',
'CF' => '中非共和国',
'CG' => '刚果(布)',
'CH' => '瑞士',
'CI' => '科特迪瓦',
'CK' => '库克群岛',
'CL' => '智利',
'CM' => '喀麦隆',
'CN' => '中国',
'CO' => '哥伦比亚',
'PE' => '秘鲁',
'ZA' => '南非',
'CR' => '哥斯达黎加',
'CU' => '古巴',
'CV' => '佛得角',
'CW' => '库拉索',
'CX' => '圣诞岛',
'CY' => '塞浦路斯',
'CZ' => '捷克',
'DE' => '德国',
'DJ' => '吉布提',
'DK' => '丹麦',
'DM' => '多米尼克',
'DO' => '多米尼加共和国',
'DZ' => '阿尔及利亚',
'EC' => '厄瓜多尔',
'EE' => '爱沙尼亚',
'EG' => '埃及',
'NG' => '尼日利亚',
'EH' => '西撒哈拉',
'ER' => '厄立特里亚',
'ES' => '西班牙',
'ET' => '埃塞俄比亚',
'FI' => '芬兰',
'FJ' => '斐济',
'FK' => '福克兰群岛(马尔维纳斯)',
'FM' => '密克罗尼西亚联邦',
'FO' => '法罗群岛',
'FR' => '法国',
'GA' => '加蓬',
'GB' => '英国',
'GD' => '格林纳达',
'GE' => '格鲁吉亚',
'GF' => '法属圭亚那',
'GG' => '根西岛',
'GH' => '加纳',
'GI' => '直布罗陀',
'GL' => '格陵兰',
'GM' => '冈比亚',
'GN' => '几内亚',
'GP' => '瓜德罗普',
'GQ' => '赤道几内亚',
'GR' => '希腊',
'GS' => '南乔治亚和南桑威奇群岛',
'GT' => '危地马拉',
'GU' => '关岛',
'GW' => '几内亚比绍',
'GY' => '圭亚那',
'HK' => '香港',
'HM' => '赫德岛和麦克唐纳群岛',
'HN' => '洪都拉斯',
'HR' => '克罗地亚',
'HT' => '海地',
'HU' => '匈牙利',
'ID' => '印度尼西亚',
'IE' => '爱尔兰',
'IL' => '以色列',
'IM' => '马恩岛',
'IN' => '印度',
'IO' => '英属印度洋领地',
'IQ' => '伊拉克',
'IR' => '伊朗',
'IS' => '冰岛',
'IT' => '意大利',
'JE' => '泽西岛',
'JM' => '牙买加',
'JO' => '约旦',
'JP' => '日本',
'KE' => '肯尼亚',
'KG' => '吉尔吉斯斯坦',
'KH' => '柬埔寨',
'KI' => '基里巴斯',
'KM' => '科摩罗',
'KN' => '圣基茨和尼维斯',
'KP' => '朝鲜',
'KR' => '韩国',
'KW' => '科威特',
'KY' => '开曼群岛',
'KZ' => '哈萨克斯坦',
'LA' => '老挝',
'LB' => '黎巴嫩',
'LC' => '圣卢西亚',
'LI' => '列支敦士登',
'LK' => '斯里兰卡',
'LR' => '利比里亚',
'LS' => '莱索托',
'LT' => '立陶宛',
'LU' => '卢森堡',
'LV' => '拉脱维亚',
'LY' => '利比亚',
'MA' => '摩洛哥',
'MC' => '摩纳哥',
'MD' => '摩尔多瓦',
'ME' => '黑山',
'MF' => '法属圣马丁',
'MG' => '马达加斯加',
'MH' => '马绍尔群岛',
'MK' => '北马其顿',
'ML' => '马里',
'MM' => '缅甸',
'MN' => '蒙古',
'MO' => '澳门',
'MP' => '北马里亚纳群岛',
'MQ' => '马提尼克',
'MR' => '毛里塔尼亚',
'MS' => '蒙特塞拉特',
'MT' => '马耳他',
'MU' => '毛里求斯',
'MV' => '马尔代夫',
'MW' => '马拉维',
'MX' => '墨西哥',
'MY' => '马来西亚',
'MZ' => '莫桑比克',
'NA' => '纳米比亚',
'NC' => '新喀里多尼亚',
'NE' => '尼日尔',
'NF' => '诺福克岛',
'NG' => '尼日利亚',
'NI' => '尼加拉瓜',
'NL' => '荷兰',
'NO' => '挪威',
'NP' => '尼泊尔',
'NR' => '瑙鲁',
'NU' => '纽埃',
'NZ' => '新西兰',
'OM' => '阿曼',
'PA' => '巴拿马',
'PE' => '秘鲁',
'PF' => '法属波利尼西亚',
'PG' => '巴布亚新几内亚',
'PH' => '菲律宾',
'PK' => '巴基斯坦',
'PL' => '波兰',
'PM' => '圣皮埃尔和密克隆',
'PN' => '皮特凯恩群岛',
'PR' => '波多黎各',
'PS' => '巴勒斯坦',
'PT' => '葡萄牙',
'PW' => '帕劳',
'PY' => '巴拉圭',
'QA' => '卡塔尔',
'RE' => '留尼汪',
'RO' => '罗马尼亚',
'RS' => '塞尔维亚',
'RU' => '俄罗斯',
'RW' => '卢旺达',
'SA' => '沙特阿拉伯',
'SB' => '所罗门群岛',
'SC' => '塞舌尔',
'SD' => '苏丹',
'SE' => '瑞典',
'SG' => '新加坡',
'SH' => '圣赫勒拿、阿森松和特里斯坦-达库尼亚',
'SI' => '斯洛文尼亚',
'SJ' => '斯瓦尔巴和扬马延',
'SK' => '斯洛伐克',
'SL' => '塞拉利昂',
'SM' => '圣马力诺',
'SN' => '塞内加尔',
'SO' => '索马里',
'SR' => '苏里南',
'SS' => '南苏丹',
'ST' => '圣多美和普林西比',
'SV' => '萨尔瓦多',
'SX' => '荷属圣马丁',
'SY' => '叙利亚',
'SZ' => '斯威士兰(斯瓦帝尼)',
'TC' => '特克斯和凯科斯群岛',
'TD' => '乍得',
'TF' => '法属南部领地',
'TG' => '多哥',
'TH' => '泰国',
'TJ' => '塔吉克斯坦',
'TK' => '托克劳',
'TL' => '东帝汶',
'TM' => '土库曼斯坦',
'TN' => '突尼斯',
'TO' => '汤加',
'TR' => '土耳其',
'TT' => '特立尼达和多巴哥',
'TV' => '图瓦卢',
'TW' => '台湾',
'TZ' => '坦桑尼亚',
'UA' => '乌克兰',
'UG' => '乌干达',
'UM' => '美国本土外小岛屿',
'US' => '美国',
'UY' => '乌拉圭',
'UZ' => '乌兹别克斯坦',
'VA' => '梵蒂冈',
'VC' => '圣文森特和格林纳丁斯',
'VE' => '委内瑞拉',
'VG' => '英属维尔京群岛',
'VI' => '美属维尔京群岛',
'VN' => '越南',
'VU' => '瓦努阿图',
'WF' => '瓦利斯和富图纳',
'WS' => '萨摩亚',
'YE' => '也门',
'YT' => '马约特',
'ZA' => '南非',
'ZM' => '赞比亚',
'ZW' => '津巴布韦'
];
/**
+107 -5
View File
@@ -67,22 +67,36 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
$apiUrlsToIntercept[] = $countApi;
}
$antiBot = $config['antiBot'] ?? null;
$initResult = $this->requestNode('/api/auth-and-intercept', [
'pageUrl' => $config['pageUrl'],
'apiUrls' => $apiUrlsToIntercept,
'authActions' => $config['authActions'] ?? [],
'antiBot' => $antiBot,
]);
if (empty($initResult['success'])) {
$cfCode = (string) ($initResult['code'] ?? '');
SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [
'error' => $initResult['error'] ?? '未知',
'error' => $initResult['error'] ?? '未知',
'code' => $cfCode !== '' ? $cfCode : null,
'challengeType' => $initResult['challengeType'] ?? null,
'stage' => $initResult['stage'] ?? null,
'cf' => $initResult['cf'] ?? null,
]);
throw new Exception('初始化失败: ' . ($initResult['error'] ?? '未知'));
$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),
'intercepted' => array_keys($interceptedApis),
'finalPageUrl' => $finalPageUrl,
]);
$cookies = $initResult['cookies'];
@@ -121,6 +135,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
'method' => $config['listMethod'] ?? 'GET',
]],
'cookies' => $cookies,
'antiBot' => $antiBot,
], 120);
if (!empty($fetchResult['success'])) {
@@ -138,12 +153,14 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
$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'])) {
@@ -170,13 +187,43 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @return array<string, mixed>
*/
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,
'payload' => $payload,
'attempt' => $attempt,
'payload' => $logPayload,
]);
$ch = curl_init($url);
@@ -184,6 +231,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
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);
@@ -194,6 +242,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [
'httpCode' => $httpCode,
'elapsedMs' => $elapsedMs,
'attempt' => $attempt,
'error' => $err,
]);
throw new Exception($err);
@@ -204,9 +253,62 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([
'httpCode' => $httpCode,
'elapsedMs' => $elapsedMs,
'attempt' => $attempt,
'responseSize' => strlen((string) $response),
], $summary));
return is_array($decoded) ? $decoded : [];
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<string, mixed> $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<string, mixed> $payload
* @return array<string, mixed>
*/
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;
}
/**
View File
View File
View File
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\UnifiedScrmData;
/**
* Chatknow SCRM 云控蜘蛛(UI 翻页 + 列表 API 拦截)
*/
class ChatknowSpider extends AbstractScrmSpider
{
private const API_LIST = '/user/user/UserInfoChildChannel/list';
private const DEFAULT_PER_PAGE_COUNT = 10;
private string $pageUrl;
private string $account;
private string $password;
private UnifiedScrmData $unifiedData;
public function __construct(
string $pageUrl,
string $account = '',
string $password = '',
string $nodeHost = 'http://127.0.0.1:3001'
) {
parent::__construct($nodeHost);
$this->pageUrl = $pageUrl;
$this->account = $account;
$this->password = $password;
$this->unifiedData = new UnifiedScrmData();
}
/** @return array<string, mixed> */
protected function getSpiderConfig(): array
{
return [
'pageUrl' => $this->pageUrl,
'listApi' => self::API_LIST,
'listMethod' => 'GET',
'paginationMode' => self::MODE_UI,
'authActions' => [
['type' => 'wait', 'ms' => 4000],
],
];
}
/**
* @param array<string, mixed>|null $listFirstPageData
* @param array<string, mixed>|null $countData
*/
protected function extractListTotalPages($listFirstPageData, $countData = null)
{
$total = (int) ($listFirstPageData['total'] ?? 0);
$this->unifiedData->total = $total;
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['data']['today_num'] ?? 0);
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
return 1;
}
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
}
/** @return array<string, mixed> */
protected function buildListPageParams(int $page): array
{
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
}
/** @return array<string, mixed> */
protected function getUiPaginationConfig(): array
{
return [
'nextBtnSelector' => '.tabs-content .arco-pagination-item-next',
'waitMs' => 2000,
];
}
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
{
$unifiedData = $this->unifiedData;
foreach ($allListPagesData as $pageRaw) {
if (!is_array($pageRaw)) {
continue;
}
$records = $pageRaw['rows'] ?? [];
if (!is_array($records)) {
continue;
}
foreach ($records as $item) {
if (!is_array($item) || empty($item['username'])) {
continue;
}
$number = (string) $item['username'];
$isOnline = isset($item['state']) && (int) $item['state'] === 1;
$unifiedData->addNumber($number, $isOnline, (int) ($item['today_num'] ?? 0));
}
}
return $unifiedData;
}
}
View File
View File
View File
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\UnifiedScrmData;
/**
* Whatshub 云控蜘蛛(Real Browser + Turnstile 过盾 + UI 翻页)
*
* 时序:Cloudflare Turnstile → 密码弹窗 authActions → API 拦截 → UI 翻页
*/
class WhatshubSpider extends AbstractScrmSpider
{
/** 列表 API 路径 */
private const API_LIST = '/api/whatshub-counter/workShare/open/detail';
/** 详情/统计 API 路径 */
private const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics';
/** 默认每页条数 */
private const DEFAULT_PER_PAGE_COUNT = 20;
private string $pageUrl;
private string $account;
private string $password;
private UnifiedScrmData $unifiedData;
public function __construct(
string $pageUrl,
string $account = '',
string $password = '',
string $nodeHost = 'http://127.0.0.1:3001'
) {
parent::__construct($nodeHost);
$this->pageUrl = $pageUrl;
$this->account = $account;
$this->password = $password;
$this->unifiedData = new UnifiedScrmData();
}
/** @return array<string, mixed> */
protected function getSpiderConfig(): array
{
$host = (string) parse_url($this->pageUrl, PHP_URL_HOST);
return [
'pageUrl' => $this->pageUrl,
'listApi' => self::API_LIST,
'detailApi' => self::API_DETAILS,
'listMethod' => 'POST',
'paginationMode' => self::MODE_UI,
'authActions' => [
// Element Plus 密码弹窗:仅注入可见 input
['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password],
['type' => 'wait', 'ms' => 500],
['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'],
['type' => 'wait', 'ms' => 2200],
],
// Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用
'antiBot' => [
'enabled' => true,
'profile' => 'real',
'turnstile' => true,
'solverFallback' => true,
'sessionKey' => 'whatshub:' . $host,
'challengeTimeoutMs' => 60000,
],
];
}
/**
* @param array<string, mixed>|null $listFirstPageData
* @param array<string, mixed>|null $countData
*/
protected function extractListTotalPages($listFirstPageData, $countData = null)
{
$defaultPerPage = self::DEFAULT_PER_PAGE_COUNT;
$this->unifiedData->total = (int) ($listFirstPageData['data']['total'] ?? 0);
if ($this->unifiedData->total <= $defaultPerPage) {
return 1;
}
return (int) ceil($this->unifiedData->total / $defaultPerPage);
}
/** @return array<string, mixed> */
protected function buildListPageParams(int $page): array
{
return ['pageNum' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
}
/** @return array<string, mixed> */
protected function getUiPaginationConfig(): array
{
return [
'nextBtnSelector' => '.vxe-pager--next-btn',
'waitMs' => 1500,
];
}
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
{
$unifiedData = $this->unifiedData;
if (is_array($detailData)) {
$unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0);
}
foreach ($allListPagesData as $pageRaw) {
if (!is_array($pageRaw)) {
continue;
}
$records = $pageRaw['data']['rows'] ?? [];
if (!is_array($records)) {
continue;
}
foreach ($records as $item) {
if (!is_array($item) || empty($item['account'])) {
continue;
}
$number = (string) $item['account'];
$isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1;
$unifiedData->addNumber($number, $isOnline, (int) ($item['dayNewFans'] ?? 0));
}
}
$unifiedData->total = count($unifiedData->numbers);
return $unifiedData;
}
}
+188 -37
View File
@@ -4,83 +4,152 @@ declare(strict_types=1);
namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\ScrmSpiderInterface;
use app\common\library\scrm\UnifiedScrmData;
use RuntimeException;
/**
* 星河云控蜘蛛
* 星河云控蜘蛛(纯 PHP cURL 抓取,不依赖 Node Puppeteer
*
* 流程:访问带 token 的授权页建立 Cookie 会话 → 分页请求列表 API → 清洗为 UnifiedScrmData
*/
class XingheSpider extends AbstractScrmSpider
class XingheSpider implements ScrmSpiderInterface
{
private const API_LIST = '/share/share/api_yinliu_count.html';
private const API_PATH = '/share/share/api_yinliu_count.html';
private const DEFAULT_PER_PAGE_COUNT = 10;
private const CURL_TIMEOUT_SECONDS = 15;
/** @var resource|\CurlHandle|null */
private $ch = null;
/** @var string|null 临时 Cookie 文件路径,仅在 run() 成功后赋值 */
private ?string $cookieFile = null;
private string $pageUrl;
private string $account;
private string $password;
private string $baseUrl;
private UnifiedScrmData $unifiedData;
/**
* @param string $pageUrl 带 token 的授权页地址
* @param string $account 账号(星河云控当前未使用,保留与工厂签名一致)
* @param string $password 密码(星河云控当前未使用,保留与工厂签名一致)
* @param string $nodeHost Node 地址(星河云控不使用,忽略)
*/
public function __construct(
string $pageUrl,
string $account = '',
string $password = '',
string $nodeHost = 'http://127.0.0.1:3001'
string $nodeHost = ''
) {
parent::__construct($nodeHost);
unset($nodeHost);
$this->pageUrl = $pageUrl;
$this->account = $account;
$this->password = $password;
$this->baseUrl = $this->resolveBaseUrl($pageUrl);
$this->unifiedData = new UnifiedScrmData();
}
protected function getSpiderConfig(): array
/**
* 执行抓取并返回统一数据
*
* @throws RuntimeException
*/
public function run(): UnifiedScrmData
{
return [
'pageUrl' => $this->pageUrl,
'listApi' => self::API_LIST,
'listMethod' => 'GET',
'paginationMode' => self::MODE_FETCH,
'authActions' => [
['type' => 'wait', 'ms' => 2000],
],
];
}
protected function extractListTotalPages($listFirstPageData, $countData = null)
{
$total = (int) ($listFirstPageData['count'] ?? 0);
$this->unifiedData->total = $total;
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0);
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
return 1;
$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();
}
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
}
protected function buildListPageParams(int $page): array
/**
* 访问授权页面,建立会话 Cookie
*/
private function authenticate(): void
{
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
$this->sendRequest($this->pageUrl);
}
protected function getUiPaginationConfig(): array
/**
* 请求 API 并解析 JSON
*
* @return array<string, mixed>
*/
private function fetchApiData(string $apiPath): array
{
return [
'nextBtnSelector' => '.layui-laypage-next',
'waitMs' => 2000,
];
$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;
}
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
/**
* @param list<mixed> $allListPagesData
*/
private function parseToUnifiedData(array $allListPagesData): UnifiedScrmData
{
$unifiedData = $this->unifiedData;
foreach ($allListPagesData as $pageRaw) {
$records = $pageRaw['data'] ?? [];
foreach ($allListPagesData as $records) {
if (!is_array($records)) {
continue;
}
foreach ($records as $item) {
if (empty($item['user'])) {
if (!is_array($item) || empty($item['user'])) {
continue;
}
$number = (string) $item['user'];
@@ -88,6 +157,88 @@ class XingheSpider extends AbstractScrmSpider
$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();
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\UnifiedScrmData;
/**
* 星河云控蜘蛛
*/
class XingheSpider extends AbstractScrmSpider
{
private const API_LIST = '/share/share/api_yinliu_count.html';
private const DEFAULT_PER_PAGE_COUNT = 10;
private string $pageUrl;
private string $account;
private string $password;
private UnifiedScrmData $unifiedData;
public function __construct(
string $pageUrl,
string $account = '',
string $password = '',
string $nodeHost = 'http://127.0.0.1:3001'
) {
parent::__construct($nodeHost);
$this->pageUrl = $pageUrl;
$this->account = $account;
$this->password = $password;
$this->unifiedData = new UnifiedScrmData();
}
protected function getSpiderConfig(): array
{
return [
'pageUrl' => $this->pageUrl,
'listApi' => self::API_LIST,
'listMethod' => 'GET',
'paginationMode' => self::MODE_FETCH,
'authActions' => [
['type' => 'wait', 'ms' => 2000],
],
];
}
protected function extractListTotalPages($listFirstPageData, $countData = null)
{
$total = (int) ($listFirstPageData['count'] ?? 0);
$this->unifiedData->total = $total;
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0);
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
return 1;
}
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
}
protected function buildListPageParams(int $page): array
{
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
}
protected function getUiPaginationConfig(): array
{
return [
'nextBtnSelector' => '.layui-laypage-next',
'waitMs' => 2000,
];
}
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
{
$unifiedData = $this->unifiedData;
foreach ($allListPagesData as $pageRaw) {
$records = $pageRaw['data'] ?? [];
foreach ($records as $item) {
if (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;
}
}