添加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
View File
@@ -0,0 +1,6 @@
-- Chatknow SCRM 工单云控自动同步周期(分钟,0=不自动同步)
SET NAMES utf8mb4;
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_interval_chatknow', 'split', 'Chatknow SCRM同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_chatknow' LIMIT 1);
@@ -0,0 +1,14 @@
-- 修复 SS 云控同步周期配置键名拼写错误:ss_custome -> ss_customer
SET NAMES utf8mb4;
UPDATE `fa_config`
SET `name` = 'split_sync_interval_ss_customer'
WHERE `name` = 'split_sync_interval_ss_custome'
AND NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer') AS `t`);
UPDATE `fa_config` AS `correct`
INNER JOIN `fa_config` AS `wrong` ON `wrong`.`name` = 'split_sync_interval_ss_custome'
SET `correct`.`value` = `wrong`.`value`
WHERE `correct`.`name` = 'split_sync_interval_ss_customer';
DELETE FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_custome';
+29
View File
@@ -29,6 +29,10 @@ INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `va
SELECT 'split_sync_interval_ss_customer', 'split', 'SS云控(Customer)同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_interval_chatknow', 'split', 'Chatknow SCRM同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_chatknow' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_interval_ceo_scrm', 'split', 'CEO SCRM同步周期(分钟)', '0 表示不自动同步(蜘蛛未实现)', 'number', '', '0', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ceo_scrm' LIMIT 1);
@@ -52,3 +56,28 @@ FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_interval_sihai', 'split', '四海云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_sihai' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_max_per_cron', 'split', '单次定时同步上限', '每分钟 cron 最多处理几条到期工单,防止打满 Node Browser 槽位', 'number', '', '2', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_cron_lock_ttl', 'split', '定时同步全局锁TTL(秒)', '异常退出后自动释放全局锁,避免后续 cron 永久跳过', 'number', '', '1800', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_cron_lock_ttl' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_node_queue_threshold', 'split', 'Node排队跳过阈值', '排队数达到该值时本轮 cron 不再提交新任务', 'number', '', '2', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_node_queue_threshold' LIMIT 1);
-- 修复历史拼写错误:split_sync_interval_ss_custome -> split_sync_interval_ss_customer
UPDATE `fa_config`
SET `name` = 'split_sync_interval_ss_customer'
WHERE `name` = 'split_sync_interval_ss_custome'
AND NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer') AS `t`);
UPDATE `fa_config` AS `correct`
INNER JOIN `fa_config` AS `wrong` ON `wrong`.`name` = 'split_sync_interval_ss_custome'
SET `correct`.`value` = `wrong`.`value`
WHERE `correct`.`name` = 'split_sync_interval_ss_customer';
DELETE FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_custome';
View File
View File
View File
View File
+7
View File
@@ -71,6 +71,13 @@ WHERE m.name = 'split.number' AND m.ismenu = 1
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchupdate' LIMIT 1)
LIMIT 1;
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
SELECT 'file', m.id, 'split.number/batchoperate', '批量操作号码', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
FROM `fa_auth_rule` m
WHERE m.name = 'split.number' AND m.ismenu = 1
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchoperate' LIMIT 1)
LIMIT 1;
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
SELECT 'file', m.id, 'split.number/multi', '批量更新', 'fa fa-circle-o', '', '列表状态开关', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
FROM `fa_auth_rule` m
View File
View File
View File
View File
View File
View File
View File
+15 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\admin\command;
use app\common\service\SplitCronLockService;
use app\common\service\SplitTicketSyncLogger;
use app\common\service\SplitTicketSyncService;
use think\console\Command;
@@ -51,8 +52,20 @@ class SplitSyncTickets extends Command
return;
}
$count = $service->syncDueTickets();
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
$cronLock = new SplitCronLockService();
if (!$cronLock->acquire()) {
SplitTicketSyncLogger::log('cli', 'cron lock busy, skip');
$output->writeln('<comment>上一轮同步仍在执行,跳过本次</comment>');
return;
}
try {
$count = $service->syncDueTickets();
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
} finally {
$cronLock->release();
}
if (SplitTicketSyncLogger::isEnabled()) {
$output->writeln('<comment>调试日志已写入 runtime/log/split_sync.log</comment>');
}
+25
View File
@@ -64,10 +64,35 @@ class Link extends Backend
$this->assignconfig('ipProtectList', $this->model->getIpProtectList());
$this->assignconfig('randomShuffleList', $this->model->getRandomShuffleList());
$this->assignconfig('statusList', $this->model->getStatusList());
$this->assignconfig('linkFilterList', $this->buildLinkFilterList());
$this->setupPatchFrontend();
}
/**
* 分流链接列表筛选下拉(id => 链接码 - 描述)
*
* @return array<string, string>
*/
private function buildLinkFilterList(): array
{
$query = $this->model->order('id', 'desc');
if ($this->dataLimit) {
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$query->where('admin_id', 'in', $adminIds);
}
}
$list = [];
foreach ($query->field('id,link_code,description')->select() as $row) {
$code = (string) $row['link_code'];
$desc = (string) $row['description'];
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
$list[(string) $row['id']] = $label;
}
return $list;
}
/**
* 未部署 JS 到 public 时,或 patches 版本较新时,jsname 指向 script 接口
*/
+130
View File
@@ -29,6 +29,9 @@ class Number extends Backend
protected $dataLimit = 'personal';
/** 关联 splitLink 预载入时需为筛选/排序字段加表别名,避免 status 等列歧义 */
protected $relationSearch = true;
protected $modelValidate = true;
protected $modelSceneValidate = true;
@@ -62,6 +65,8 @@ class Number extends Backend
$this->assignconfig('statusList', $this->model->getStatusList());
$this->assignconfig('manualManageList', $this->model->getManualManageList());
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
$this->setupPatchFrontend();
}
@@ -111,6 +116,52 @@ class Number extends Backend
return $list;
}
/**
* 已有号码关联的分流链接(供列表筛选下拉)
*
* @return array<string, string>
*/
private function buildNumberSplitLinkFilterList(): array
{
$numberTable = $this->model->getTable();
$linkTable = (new LinkModel())->getTable();
$query = Db::table($numberTable)
->alias('n')
->join($linkTable . ' l', 'n.split_link_id = l.id')
->where('n.split_link_id', '>', 0)
->group('n.split_link_id')
->field('l.id,l.link_code,l.description')
->order('l.id', 'desc');
if ($this->dataLimit) {
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$query->where('n.admin_id', 'in', $adminIds);
}
}
$list = [];
foreach ($query->select() as $row) {
$code = (string) $row['link_code'];
$desc = (string) $row['description'];
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
$list[(string) $row['id']] = $label;
}
return $list;
}
/**
* 全部分流链接(供批量操作弹窗可搜索下拉)
*
* @return array<string, string>
*/
private function buildSplitLinkSelectConfig(): array
{
$list = [];
foreach ($this->buildSplitLinkList() as $row) {
$list[(string) $row['id']] = (string) $row['label'];
}
return $list;
}
private function fetchPatch(string $template): string
{
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
@@ -395,6 +446,85 @@ class Number extends Backend
$this->success(__('Batch update success'));
}
/**
* 按分流链接 + 号码文本批量开启/关闭/删除
*/
public function batchoperate(): void
{
if (false === $this->request->isPost()) {
$this->error(__('Invalid parameters'));
}
$splitLinkId = (int) $this->request->post('split_link_id', 0);
$numbersText = (string) $this->request->post('numbers', '');
$action = (string) $this->request->post('action', '');
if ($splitLinkId <= 0) {
$this->error(__('Please select split link'));
}
$numberList = NumberModel::parseNumbersText($numbersText);
if ($numberList === []) {
$this->error(__('Please fill at least one number'));
}
$allowedActions = ['enable', 'disable', 'delete'];
if (!in_array($action, $allowedActions, true)) {
$this->error(__('Invalid batch operate action'));
}
$linkQuery = (new LinkModel())->where('id', $splitLinkId)->where('status', 'normal');
if ($this->dataLimit) {
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$linkQuery->where('admin_id', 'in', $adminIds);
}
}
if (!$linkQuery->find()) {
$this->error(__('No Results were found'));
}
$query = $this->model
->where('split_link_id', $splitLinkId)
->where('number', 'in', $numberList);
if ($this->dataLimit) {
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$query->where('admin_id', 'in', $adminIds);
}
}
$count = 0;
Db::startTrans();
try {
if ($action === 'delete') {
$rows = $query->select();
foreach ($rows as $row) {
if ($row->delete()) {
$count++;
}
}
} else {
$status = $action === 'enable' ? 'normal' : 'hidden';
$manualManage = $action === 'enable' ? 0 : 1;
$count = (int) $query->update([
'status' => $status,
'manual_manage' => $manualManage,
]);
}
Db::commit();
} catch (PDOException|Exception $e) {
Db::rollback();
$this->error($e->getMessage());
}
if ($count <= 0) {
$this->error(__('No matching numbers found'));
}
$this->success(sprintf(__('Batch operate success'), $count));
}
/**
* 排除不可由表单提交的字段
*
+129 -1
View File
@@ -58,6 +58,7 @@ class Ticket extends Backend
$this->assignconfig('ticketTypeList', $this->model->getTicketTypeList());
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
$this->assignconfig('statusList', $this->model->getStatusList());
$this->assignconfig('splitLinkFilterList', $this->buildTicketSplitLinkFilterList());
$this->assignconfig([
'syncConfirmMsg' => __('Sync confirm'),
'syncBackgroundStartedMsg' => __('Sync background started'),
@@ -142,6 +143,71 @@ class Ticket extends Backend
return $list;
}
/**
* 已有工单关联的分流链接(供列表筛选下拉)
*
* @return array<string, string> id => label
*/
private function buildTicketSplitLinkFilterList(): array
{
$ticketTable = $this->model->getTable();
$linkTable = (new LinkModel())->getTable();
$query = Db::table($ticketTable)
->alias('t')
->join($linkTable . ' l', 't.split_link_id = l.id')
->where('t.split_link_id', '>', 0)
->group('t.split_link_id')
->field('l.id,l.link_code,l.description')
->order('l.id', 'desc');
if ($this->dataLimit) {
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$query->where('t.admin_id', 'in', $adminIds);
}
}
$list = [];
foreach ($query->select() as $row) {
$code = (string) $row['link_code'];
$desc = (string) $row['description'];
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
$list[(string) $row['id']] = $label;
}
return $list;
}
/**
* 按当前筛选条件汇总列表统计列
*
* @param callable|array<mixed> $where buildparams() 返回的闭包或条件数组
* @return array<string, int|float|string>
*/
private function buildListSummary($where): array
{
$stats = $this->model
->where($where)
->fieldRaw(
'COALESCE(SUM(ticket_total), 0) AS sum_ticket_total,'
. ' COALESCE(SUM(complete_count), 0) AS sum_complete_count,'
. ' COALESCE(SUM(speed_per_hour), 0) AS sum_speed_per_hour,'
. ' COALESCE(AVG(CASE WHEN ticket_total > 0 THEN complete_count * 100.0 / ticket_total ELSE 0 END), 0) AS avg_ticket_progress'
)
->find();
if (!$stats) {
return [
'ticket_total' => 0,
'complete_count' => 0,
'speed_per_hour' => '0.00',
'ticket_progress_text' => '0.00%',
];
}
return [
'ticket_total' => (int) ($stats['sum_ticket_total'] ?? 0),
'complete_count' => (int) ($stats['sum_complete_count'] ?? 0),
'speed_per_hour' => number_format((float) ($stats['sum_speed_per_hour'] ?? 0), 2, '.', ''),
'ticket_progress_text' => number_format((float) ($stats['avg_ticket_progress'] ?? 0), 2, '.', '') . '%',
];
}
private function fetchPatch(string $template): string
{
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
@@ -179,7 +245,11 @@ class Ticket extends Backend
->where($where)
->order($sort, $order)
->paginate($limit);
$result = ['total' => $list->total(), 'rows' => $list->items()];
$result = [
'total' => $list->total(),
'rows' => $list->items(),
'summary' => $this->buildListSummary($where),
];
return json($result);
}
@@ -331,6 +401,10 @@ class Ticket extends Backend
public function add()
{
if (false === $this->request->isPost()) {
$copyRow = $this->buildCopyRowData();
if ($copyRow !== null) {
$this->view->assign('row', $copyRow);
}
return $this->fetchPatch('add');
}
$params = $this->request->post('row/a', []);
@@ -367,6 +441,60 @@ class Ticket extends Backend
$this->success('', null, ['id' => (int) $this->model->id]);
}
/**
* 从 copy_id 构建添加工单表单的预填数据(仅复制业务字段,不含同步统计)
*
* @return array<string, mixed>|null
*/
private function buildCopyRowData(): ?array
{
$copyId = (int) $this->request->get('copy_id/d', 0);
if ($copyId <= 0) {
return null;
}
$row = $this->model->get($copyId);
if (!$row) {
return null;
}
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
return null;
}
$data = $row->toArray();
$copyFields = [
'ticket_type',
'ticket_name',
'ticket_url',
'ticket_total',
'split_link_id',
'number_type',
'number_type_custom',
'order_limit',
'assign_ratio',
'account',
'password',
];
$copyRow = [];
foreach ($copyFields as $field) {
if (array_key_exists($field, $data)) {
$copyRow[$field] = $data[$field];
}
}
$copyRow['start_time'] = $data['start_time_text'] ?? '';
$copyRow['end_time'] = $data['end_time_text'] ?? '';
$name = trim((string) ($copyRow['ticket_name'] ?? ''));
if ($name !== '' && mb_strpos($name, ' (副本)') === false) {
$copyRow['ticket_name'] = $name . ' (副本)';
}
return $copyRow;
}
/**
* @param string|null $ids
* @return string
View File
+1 -1
View File
@@ -37,7 +37,7 @@ return [
'System config' => '系统配置',
'Auto reply' => '自动回复',
'Reply statements' => '回复语句',
'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联',
'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联;跳转 WhatsApp / Telegram 时随机附加为预填消息',
'Auto reply saved' => '自动回复已保存',
'Reply statements column' => '回复语',
'NS' => 'NS',
+12
View File
@@ -30,6 +30,18 @@ return [
'Batch selected count' => '已选号码',
'Batch update status' => '更新状态',
'Batch update btn' => '更新状态',
'Batch operate btn' => '批量操作',
'Batch operate title' => '批量操作',
'Batch operate action' => '操作',
'Batch operate enable' => '开启状态',
'Batch operate disable' => '关闭状态',
'Batch operate delete' => '删除号码',
'Batch operate success' => '批量操作成功,共处理 %d 条',
'Invalid batch operate action' => '操作类型无效',
'No matching numbers found' => '未找到匹配的号码',
'Please select split link' => '请选择分流链接',
'Numbers one per line' => '一行一个号码',
'Batch operate delete confirm' => '确定要删除这些号码吗?',
'Please fill at least one number' => '请至少填写一个有效号码',
'All numbers already exist for this link' => '该链接下号码均已存在,未新增任何记录',
'Number already exists for this link' => '该链接下已存在相同号码',
+3
View File
@@ -38,6 +38,8 @@ return [
'Sync display pending' => '待同步',
'Sync display error' => '同步异常',
'Createtime' => '创建时间',
'Copy' => '拷贝',
'Summary row' => '汇总',
'Section basic' => '基础信息',
'Section time rule' => '时间与规则',
'Section account' => '账号信息',
@@ -51,6 +53,7 @@ return [
'Ticket type yifafa' => '译发发云控',
'Ticket type a2c' => 'A2C云控',
'Ticket type ceo_scrm' => 'CEO SCRM',
'Ticket type chatknow' => 'Chatknow SCRM',
'Ticket type whatshub' => 'Whatshub云控',
'Ticket type sihai' => '四海云控',
'End time must after start' => '到期时间必须晚于开始时间',
View File
+1 -1
View File
@@ -98,7 +98,7 @@ class Number extends Model
*/
public function splitLink()
{
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(0);
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(1);
}
public function setNumberTypeCustomAttr($value): string
+1
View File
@@ -50,6 +50,7 @@ class Ticket extends Model
'yifafa' => __('Ticket type yifafa'),
'a2c' => __('Ticket type a2c'),
'ceo_scrm' => __('Ticket type ceo_scrm'),
'chatknow' => __('Ticket type chatknow'),
'whatshub' => __('Ticket type whatshub'),
'sihai' => __('Ticket type sihai'),
];
View File
View File
View File
View File
View File
View File
View File
+1
View File
@@ -18,6 +18,7 @@
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
</div>
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
data-operate-edit="{:$auth->check('split.number/edit')}"
View File
View File
View File
+51
View File
@@ -17,12 +17,56 @@
text-align: left;
animation: split-ticket-sync-dots 1.4s steps(4, end) infinite;
}
.split-ticket-url-group {
width: 200px;
max-width: 100%;
margin: 0;
}
.split-ticket-url-group .form-control {
cursor: default;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
background: #fff;
height: 30px;
}
.split-ticket-url-group .btn {
height: 30px;
padding: 5px 10px;
}
@keyframes split-ticket-sync-dots {
0%, 20% { content: ''; }
40% { content: '.'; }
60% { content: '..'; }
80%, 100% { content: '...'; }
}
.split-ticket-summary-bar {
margin: 0 0 10px;
padding: 10px 14px;
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
border: 1px solid #93c5fd;
border-radius: 4px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 24px;
font-size: 13px;
line-height: 1.5;
}
.split-ticket-summary-bar .split-ticket-summary-label {
font-weight: 700;
color: #1e40af;
margin-right: 4px;
}
.split-ticket-summary-bar .split-ticket-summary-item em {
font-style: normal;
color: #475569;
margin-right: 6px;
}
.split-ticket-summary-bar .split-ticket-summary-item b {
font-size: 14px;
color: #0f172a;
}
</style>
<div class="panel panel-default panel-intro">
<div class="panel-heading">
@@ -39,6 +83,13 @@
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.ticket/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
<a href="javascript:;" class="btn btn-info btn-sync btn-disabled disabled {:$auth->check('split.ticket/sync')?'':'hide'}" title="{:__('Sync_status_btn')}"><i class="fa fa-refresh"></i> {:__('Sync_status_btn')}</a>
</div>
<div id="split-ticket-summary" class="split-ticket-summary-bar hide">
<span class="split-ticket-summary-label">{:__('Summary row')}</span>
<span class="split-ticket-summary-item"><em>{:__('Ticket_total')}</em><b data-sum="ticket_total">0</b></span>
<span class="split-ticket-summary-item"><em>{:__('Complete_count')}</em><b data-sum="complete_count">0</b></span>
<span class="split-ticket-summary-item"><em>{:__('Ticket_progress')}</em><b data-sum="ticket_progress_text">0.00%</b></span>
<span class="split-ticket-summary-item"><em>{:__('Speed_per_hour')}</em><b data-sum="speed_per_hour">0.00</b></span>
</div>
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
data-operate-edit="{:$auth->check('split.ticket/edit')}"
data-operate-del="{:$auth->check('split.ticket/del')}"
View File
+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
@@ -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
@@ -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;
}
}
View File
View File
View File
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 定时同步全局互斥锁,防止每分钟 cron 重叠执行打满 Node 队列
*/
class SplitCronLockService
{
private const LOCK_FILE = 'cron.lock';
/**
* 尝试获取全局 cron
*/
public function acquire(): bool
{
$path = $this->lockPath();
if ($this->isStaleLock($path)) {
@unlink($path);
}
if (is_file($path)) {
return false;
}
$payload = json_encode([
'pid' => getmypid(),
'time' => time(),
], JSON_UNESCAPED_UNICODE);
$written = @file_put_contents($path, $payload, LOCK_EX);
return $written !== false;
}
/**
* 释放全局 cron
*/
public function release(): void
{
$path = $this->lockPath();
if (is_file($path)) {
@unlink($path);
}
}
private function lockPath(): string
{
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
$dir = $runtime . 'split_ticket_sync/';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return $dir . self::LOCK_FILE;
}
private function isStaleLock(string $path): bool
{
if (!is_file($path)) {
return false;
}
$mtime = (int) @filemtime($path);
$ttl = SplitSyncConfigService::getCronLockTtlSeconds();
return $mtime > 0 && (time() - $mtime) > $ttl;
}
}
+13 -7
View File
@@ -12,13 +12,13 @@ class SplitFriendUrlBuilder
/**
* 构建跳转 URL;无法构建时返回空字符串
*
* @param string $whatsAppReplyText WhatsApp 类型使用,预填消息文案(urlencode 在内部处理
* @param string $replyText WhatsApp / Telegram 预填消息文案(内部 rawurlencode
*/
public static function build(
string $numberType,
string $number,
string $numberTypeCustom = '',
string $whatsAppReplyText = ''
string $replyText = ''
): string {
$number = trim($number);
if ($number === '') {
@@ -27,9 +27,9 @@ class SplitFriendUrlBuilder
switch ($numberType) {
case 'whatsapp':
return self::buildWhatsApp($number, $whatsAppReplyText);
return self::buildWhatsApp($number, $replyText);
case 'telegram':
return self::buildTelegram($number);
return self::buildTelegram($number, $replyText);
case 'line':
return self::buildLine($number);
case 'custom':
@@ -59,16 +59,22 @@ class SplitFriendUrlBuilder
}
/**
* Telegramhttps://t.me/+ 号码(去掉前导 +
* Telegramhttps://t.me/+ 号码(去掉前导 +,可选 ?text= 预填消息(须 URL 编码)
*/
private static function buildTelegram(string $number): string
private static function buildTelegram(string $number, string $replyText = ''): string
{
$id = ltrim($number, '+');
if ($id === '') {
return '';
}
return 'https://t.me/+' . $id;
$url = 'https://t.me/+' . $id;
$replyText = trim($replyText);
if ($replyText !== '') {
$url .= '?text=' . rawurlencode($replyText);
}
return $url;
}
/**
View File
View File
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* Node Puppeteer 服务健康与队列负载探测
*/
class SplitNodeHealthService
{
/**
* 拉取 /api/stats,失败返回 null(不阻断同步,由后续请求快速失败)
*
* @return array<string, mixed>|null
*/
public static function fetchStats(): ?array
{
$url = SplitSyncConfigService::getNodeHost() . '/api/stats';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
$response = curl_exec($ch);
if (curl_errno($ch)) {
curl_close($ch);
return null;
}
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
return null;
}
$decoded = json_decode((string) $response, true);
return is_array($decoded) ? $decoded : null;
}
/**
* 当前是否适合向 Node 提交新 Browser 任务
*/
public static function canAcceptWork(): bool
{
$stats = self::fetchStats();
if ($stats === null) {
return true;
}
if (!empty($stats['shuttingDown'])) {
return false;
}
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
$queued = (int) ($queue['queued'] ?? 0);
$active = (int) ($queue['active'] ?? 0);
$max = max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4)));
$threshold = SplitSyncConfigService::getNodeQueueSkipThreshold();
if ($queued >= $threshold) {
return false;
}
return $active < $max;
}
/**
* @return array{queued:int,active:int,max:int}
*/
public static function getQueueSnapshot(): array
{
$stats = self::fetchStats();
if ($stats === null) {
return ['queued' => 0, 'active' => 0, 'max' => 0];
}
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
return [
'queued' => (int) ($queue['queued'] ?? 0),
'active' => (int) ($queue['active'] ?? 0),
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))),
];
}
/**
* Node Real Browser / Captcha 是否就绪(供同步失败日志可读性)
*
* @return array{realBrowserReady:bool,captchaConfigured:bool}
*/
public static function getAntiBotSnapshot(): array
{
$stats = self::fetchStats();
if ($stats === null) {
return ['realBrowserReady' => false, 'captchaConfigured' => false];
}
return [
'realBrowserReady' => !empty($stats['realBrowserReady']),
'captchaConfigured' => !empty($stats['captchaConfigured']),
];
}
}
View File
View File
View File
View File
View File
+4 -4
View File
@@ -72,16 +72,16 @@ class SplitRedirectService
? (string) ($picked['number_type_custom'] ?? '')
: (string) $picked->getAttr('number_type_custom');
$whatsAppReplyText = '';
if ($numberType === 'whatsapp') {
$whatsAppReplyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply'));
$replyText = '';
if (in_array($numberType, ['whatsapp', 'telegram'], true)) {
$replyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply'));
}
$redirectUrl = SplitFriendUrlBuilder::build(
$numberType,
$numberValue,
$numberCustom,
$whatsAppReplyText
$replyText
);
if ($redirectUrl === '') {
return null;
View File
+14
View File
@@ -6,9 +6,11 @@ namespace app\common\service;
use app\common\library\scrm\ScrmSpiderInterface;
use app\common\library\scrm\spider\A2cSpider;
use app\common\library\scrm\spider\ChatknowSpider;
use app\common\library\scrm\spider\HaiwangSpider;
use app\common\library\scrm\spider\HuojianSpider;
use app\common\library\scrm\spider\SsCustomerSpider;
use app\common\library\scrm\spider\WhatshubSpider;
use app\common\library\scrm\spider\XingheSpider;
/**
@@ -25,6 +27,8 @@ class SplitScrmSpiderFactory
'huojian' => HuojianSpider::class,
'xinghe' => XingheSpider::class,
'ss_customer' => SsCustomerSpider::class,
'chatknow' => ChatknowSpider::class,
'whatshub' => WhatshubSpider::class,
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
];
@@ -45,6 +49,16 @@ class SplitScrmSpiderFactory
return self::resolveClass($ticketType) !== null;
}
/**
* 已注册且已实现蜘蛛的类型列表
*
* @return list<string>
*/
public static function listSupportedTypes(): array
{
return array_keys(self::MAP);
}
/**
* @return ScrmSpiderInterface|null
*/
+36
View File
@@ -50,6 +50,42 @@ class SplitSyncConfigService
return max(0, (int) $value);
}
/**
* 单次 cron 最多处理几条到期工单,避免一分钟内打满 Node Browser 槽位
*/
public static function getMaxTicketsPerCronRun(): int
{
$value = self::getConfigValue('split_sync_max_per_cron');
if ($value === '') {
return 2;
}
return max(1, min(20, (int) $value));
}
/**
* 全局 cron 锁过期秒数,防止异常退出后永久占锁
*/
public static function getCronLockTtlSeconds(): int
{
$value = self::getConfigValue('split_sync_cron_lock_ttl');
if ($value === '') {
return 1800;
}
return max(300, (int) $value);
}
/**
* Node 排队数达到该阈值时,本轮 cron 不再提交新任务
*/
public static function getNodeQueueSkipThreshold(): int
{
$value = self::getConfigValue('split_sync_node_queue_threshold');
if ($value === '') {
return 2;
}
return max(0, (int) $value);
}
private static function getConfigValue(string $name): string
{
$site = Config::get('site.' . $name);
View File
View File
View File
View File
+55 -7
View File
@@ -30,9 +30,10 @@ class SplitTicketSyncService
/**
* 同步单条工单
*
* @param bool $dueValidated true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入)
* @return array{success:bool,message:string,skipped?:bool}
*/
public function syncOne(int $ticketId, bool $force = false): array
public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false): array
{
$ticket = Ticket::get($ticketId);
if (!$ticket) {
@@ -50,7 +51,7 @@ class SplitTicketSyncService
'nodeHost' => SplitSyncConfigService::getNodeHost(),
]);
if (!$force) {
if (!$force && !$dueValidated) {
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => $skip]);
@@ -76,23 +77,47 @@ class SplitTicketSyncService
}
/**
* 扫描到期工单并同步
* 扫描到期工单并同步(限流 + Node 队列感知)
*/
public function syncDueTickets(): int
{
$count = 0;
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
if ($autoSyncTypes === []) {
SplitTicketSyncLogger::log('cron', 'scan skip', ['reason' => 'no auto-sync ticket types']);
return 0;
}
if (!SplitNodeHealthService::canAcceptWork()) {
SplitTicketSyncLogger::log('cron', 'scan skip', [
'reason' => 'node queue busy',
'queue' => SplitNodeHealthService::getQueueSnapshot(),
]);
return 0;
}
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
$query = Ticket::where('status', 'normal');
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
if ($failThreshold > 0) {
$query->where('sync_fail_count', '<', $failThreshold);
}
$list = $query->select();
// 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
->limit($maxPerRun * 5)
->select();
SplitTicketSyncLogger::log('cron', 'scan start', [
'candidateCount' => count($list),
'maxPerRun' => $maxPerRun,
'autoSyncTypes' => $autoSyncTypes,
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
]);
$count = 0;
foreach ($list as $ticket) {
if ($count >= $maxPerRun) {
break;
}
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
@@ -102,7 +127,14 @@ class SplitTicketSyncService
]);
continue;
}
$result = $this->syncOne((int) $ticket['id'], false);
if (!SplitNodeHealthService::canAcceptWork()) {
SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [
'processed' => $count,
'queue' => SplitNodeHealthService::getQueueSnapshot(),
]);
break;
}
$result = $this->syncOne((int) $ticket['id'], false, true);
if (!empty($result['skipped'])) {
continue;
}
@@ -113,6 +145,22 @@ class SplitTicketSyncService
return $count;
}
/**
* 已配置自动同步周期且已实现蜘蛛的工单类型
*
* @return list<string>
*/
private function resolveAutoSyncTicketTypes(): array
{
$types = [];
foreach (SplitScrmSpiderFactory::listSupportedTypes() as $ticketType) {
if (SplitSyncConfigService::getIntervalMinutes($ticketType) > 0) {
$types[] = $ticketType;
}
}
return $types;
}
/**
* @return array{success:bool,message:string}
*/
View File
View File
View File
View File
View File
View File
View File
View File
+54 -4
View File
@@ -14,6 +14,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
});
var table = $("#table");
var searchSelect = Controller.api.searchSelectColumn;
var searchSelectMeta = Controller.api.searchSelectMeta;
table.bootstrapTable({
url: $.fn.bootstrapTable.defaults.extend.index_url,
@@ -25,11 +27,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
columns: [
[
{checkbox: true},
searchSelect('id', __('Link_code'), Config.linkFilterList || {}, true),
{field: 'countries_text', title: __('Countries'), operate: false, formatter: Table.api.formatter.content},
{
field: 'link_code',
title: __('Link_code'),
operate: 'LIKE',
operate: false,
formatter: Controller.api.formatter.linkCode
},
{field: 'description', title: __('Description'), operate: 'LIKE', class: 'autocontent', formatter: Table.api.formatter.content},
@@ -39,9 +42,18 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
operate: false,
formatter: Controller.api.formatter.autoReplyText
},
{field: 'ip_protect', title: __('Ip_protect'), searchList: Config.ipProtectList, formatter: Table.api.formatter.status},
{field: 'random_shuffle', title: __('Random_shuffle'), searchList: Config.randomShuffleList, formatter: Table.api.formatter.status},
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.status},
$.extend(
{field: 'ip_protect', title: __('Ip_protect'), searchList: Config.ipProtectList, formatter: Table.api.formatter.status},
searchSelectMeta(false)
),
$.extend(
{field: 'random_shuffle', title: __('Random_shuffle'), searchList: Config.randomShuffleList, formatter: Table.api.formatter.status},
searchSelectMeta(false)
),
$.extend(
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.status},
searchSelectMeta(false)
),
{field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true},
{
field: 'operate',
@@ -117,6 +129,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
Controller.api.bindAutoReplyPreviewTips(table);
table.on('post-common-search.bs.table', function (e, tbl) {
Controller.api.initCommonSearchSelectpicker(tbl);
});
Table.api.bindevent(table);
},
add: function () {
@@ -127,6 +143,40 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
Controller.api.bindevent();
},
api: {
searchSelectColumn: function (field, title, searchList, liveSearch) {
return {
field: field,
title: title,
visible: false,
searchList: searchList || {},
operate: '=',
addclass: 'selectpicker',
extend: Controller.api.searchSelectExtend(liveSearch !== false)
};
},
searchSelectMeta: function (liveSearch) {
return {
addclass: 'selectpicker',
extend: Controller.api.searchSelectExtend(liveSearch !== false)
};
},
searchSelectExtend: function (liveSearch) {
var text = __('Please select');
if (!text || text === 'Please select' || text === 'Please Select') {
text = '请选择';
}
var ext = 'data-none-selected-text="' + text + '" title="' + text + '"';
if (liveSearch) {
ext += ' data-live-search="true"';
}
return ext;
},
initCommonSearchSelectpicker: function (tbl) {
var $form = $('form.form-commonsearch', tbl.$container);
if ($form.length) {
Form.events.selectpicker($form);
}
},
/**
* 规范化后台跨模块跳转 URL避免在 split.link 页面内相对解析成 split.link/domain
*
+156 -20
View File
@@ -14,6 +14,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
});
var table = $("#table");
var searchSelect = Controller.api.searchSelectColumn;
var searchSelectMeta = Controller.api.searchSelectMeta;
table.bootstrapTable({
url: $.fn.bootstrapTable.defaults.extend.index_url,
@@ -22,9 +24,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
sortOrder: 'desc',
fixedColumns: true,
fixedRightNumber: 1,
searchFormVisible: true,
columns: [
[
{checkbox: true},
searchSelect('split_link_id', __('Link_url'), Config.splitLinkFilterList || {}, true),
{field: 'id', title: __('Id'), sortable: true},
{
field: 'link_url_text',
@@ -34,28 +38,20 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
},
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
{field: 'number', title: __('Number'), operate: 'LIKE'},
{
field: 'number_type',
title: __('Number_type'),
searchList: Config.numberTypeList,
formatter: Table.api.formatter.normal
},
$.extend(
{field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal},
searchSelectMeta(false)
),
{field: 'visit_count', title: __('Visit_count'), operate: false, sortable: true},
{field: 'inbound_count', title: __('Inbound_count'), operate: false, sortable: true},
{
field: 'platform_status',
title: __('Platform_status'),
searchList: Config.platformStatusList,
formatter: Controller.api.formatter.platformStatus
},
{
field: 'status',
title: __('Status'),
searchList: Config.statusList,
formatter: Table.api.formatter.toggle,
yes: 'normal',
no: 'hidden'
},
$.extend(
{field: 'platform_status', title: __('Platform_status'), searchList: Config.platformStatusList, formatter: Controller.api.formatter.platformStatus},
searchSelectMeta(false)
),
$.extend(
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
searchSelectMeta(false)
),
{
field: 'createtime',
title: __('Createtime'),
@@ -95,6 +91,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
Controller.api.openBatchUpdateModal(ids, table);
});
$('.btn-batch-operate').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
Controller.api.openBatchOperateModal(table);
});
table.on('post-common-search.bs.table', function (e, tbl) {
Controller.api.initCommonSearchSelectpicker(tbl);
});
Table.api.bindevent(table);
},
add: function () {
@@ -104,6 +110,41 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
Controller.api.bindevent();
},
api: {
searchSelectColumn: function (field, title, searchList, liveSearch) {
return {
field: field,
title: title,
visible: false,
searchList: searchList || {},
operate: '=',
addclass: 'selectpicker',
extend: Controller.api.searchSelectExtend(liveSearch !== false)
};
},
searchSelectMeta: function (liveSearch) {
return {
addclass: 'selectpicker',
extend: Controller.api.searchSelectExtend(liveSearch !== false)
};
},
searchSelectExtend: function (liveSearch) {
var text = __('Please select');
if (!text || text === 'Please select' || text === 'Please Select') {
text = '请选择';
}
var ext = 'data-none-selected-text="' + text + '" title="' + text + '"';
if (liveSearch) {
ext += ' data-live-search="true"';
}
return ext;
},
initCommonSearchSelectpicker: function (tbl) {
var $form = $('form.form-commonsearch', tbl.$container);
if ($form.length) {
Form.events.selectpicker($form);
}
},
formatter: {
platformStatus: function (value, row) {
var text = row.platform_status_text != null && row.platform_status_text !== ''
@@ -214,6 +255,101 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
return false;
}
});
},
openBatchOperateModal: function (table) {
var pleaseSelect = __('Please select');
if (!pleaseSelect || pleaseSelect === 'Please select' || pleaseSelect === 'Please Select') {
pleaseSelect = '请选择';
}
var linkOptions = ['<option value="">' + Fast.api.escape(pleaseSelect) + '</option>'];
$.each(Config.splitLinkSelectList || {}, function (id, label) {
linkOptions.push('<option value="' + Fast.api.escape(String(id)) + '">' + Fast.api.escape(String(label)) + '</option>');
});
var html = [
'<div class="split-batch-operate-modal" style="padding:18px 22px;">',
' <div class="form-group">',
' <label class="control-label">' + __('Link_url') + '<span class="text-danger">*</span></label>',
' <select id="batch-operate-split-link" class="form-control selectpicker" data-live-search="true" data-none-selected-text="' + pleaseSelect + '" title="' + pleaseSelect + '">',
linkOptions.join(''),
' </select>',
' </div>',
' <div class="form-group">',
' <label class="control-label">' + __('Number') + '<span class="text-danger">*</span></label>',
' <textarea id="batch-operate-numbers" class="form-control" rows="8" placeholder="' + Fast.api.escape(__('Numbers one per line')) + '"></textarea>',
' </div>',
' <div class="form-group">',
' <label class="control-label">' + __('Batch operate action') + '<span class="text-danger">*</span></label>',
' <select id="batch-operate-action" class="form-control selectpicker" data-none-selected-text="' + pleaseSelect + '" title="' + pleaseSelect + '">',
' <option value="enable">' + Fast.api.escape(__('Batch operate enable')) + '</option>',
' <option value="disable">' + Fast.api.escape(__('Batch operate disable')) + '</option>',
' <option value="delete">' + Fast.api.escape(__('Batch operate delete')) + '</option>',
' </select>',
' </div>',
' <div class="form-group" style="margin-bottom:0;text-align:right;">',
' <button type="button" class="btn btn-primary btn-batch-operate-confirm">' + __('OK') + '</button> ',
' <button type="button" class="btn btn-default btn-batch-operate-cancel">' + __('Cancel') + '</button>',
' </div>',
'</div>'
].join('');
var layerIndex = Layer.open({
type: 1,
title: __('Batch operate title'),
area: ['520px', 'auto'],
shadeClose: false,
content: html,
success: function (layero, index) {
Form.events.selectpicker(layero);
layero.find('.btn-batch-operate-cancel').on('click', function () {
Layer.close(index);
});
layero.find('.btn-batch-operate-confirm').on('click', function () {
var splitLinkId = $.trim(layero.find('#batch-operate-split-link').val());
var numbers = $.trim(layero.find('#batch-operate-numbers').val());
var action = $.trim(layero.find('#batch-operate-action').val());
if (!splitLinkId) {
Toastr.warning(__('Please select split link'));
return false;
}
if (!numbers) {
Toastr.warning(__('Please fill at least one number'));
return false;
}
if (!action) {
Toastr.error(__('Invalid batch operate action'));
return false;
}
var submit = function () {
Fast.api.ajax({
url: 'split.number/batchoperate',
type: 'post',
data: {
split_link_id: splitLinkId,
numbers: numbers,
action: action
}
}, function (data, ret) {
Layer.close(index);
table.bootstrapTable('refresh');
Toastr.success(ret.msg || __('Batch operate success'));
});
};
if (action === 'delete') {
Layer.confirm(__('Batch operate delete confirm'), {icon: 3, title: __('Warning')}, function (confirmIndex) {
Layer.close(confirmIndex);
submit();
});
return false;
}
submit();
return false;
});
}
});
}
}
};
+144 -11
View File
@@ -14,6 +14,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
});
var table = $("#table");
var searchSelect = Controller.api.searchSelectColumn;
var searchSelectMeta = Controller.api.searchSelectMeta;
table.bootstrapTable({
url: $.fn.bootstrapTable.defaults.extend.index_url,
@@ -25,6 +27,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
columns: [
[
{checkbox: true},
searchSelect('split_link_id', __('Split_link_id'), Config.splitLinkFilterList || {}, true),
{
field: 'ticket_type',
title: __('Ticket_type'),
@@ -39,6 +42,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
operate: false,
formatter: Controller.api.formatter.splitLinkCode
},
{
field: 'ticket_url',
title: __('Ticket_url'),
operate: 'LIKE',
formatter: Controller.api.formatter.ticketUrlLink
},
{
field: 'start_time_text',
title: __('Start_time'),
@@ -89,14 +98,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
operate: false,
formatter: Controller.api.formatter.syncDisplay
},
{
field: 'status',
title: __('Status'),
searchList: Config.statusList,
formatter: Table.api.formatter.toggle,
yes: 'normal',
no: 'hidden'
},
$.extend(
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
searchSelectMeta(false)
),
{
field: 'createtime',
title: __('Createtime'),
@@ -110,18 +115,27 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
field: 'operate',
title: __('Operate'),
table: table,
events: Table.api.events.operate,
formatter: Table.api.formatter.operate
events: Controller.api.events.operate,
formatter: Controller.api.formatter.operate
}
]
]
});
table.on('post-common-search.bs.table', function (e, tbl) {
Controller.api.initCommonSearchSelectpicker(tbl);
});
Table.api.bindevent(table);
Controller.api.syncingTicketIds = [];
window.__splitTicketPendingPostAddSyncIds = window.__splitTicketPendingPostAddSyncIds || [];
table.on('load-success.bs.table', function () {
table.on('click', '.split-ticket-url-group input, .split-ticket-url-group a', function (e) {
e.stopPropagation();
});
table.on('load-success.bs.table', function (e, res) {
Controller.api.renderSummaryRow(res);
var pendingIds = window.__splitTicketPendingPostAddSyncIds;
if (!pendingIds || !pendingIds.length) {
return;
@@ -185,9 +199,67 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
Controller.api.bindevent();
},
api: {
searchSelectColumn: function (field, title, searchList, liveSearch) {
return {
field: field,
title: title,
visible: false,
searchList: searchList || {},
operate: '=',
addclass: 'selectpicker',
extend: Controller.api.searchSelectExtend(liveSearch !== false)
};
},
searchSelectMeta: function (liveSearch) {
return {
addclass: 'selectpicker',
extend: Controller.api.searchSelectExtend(liveSearch !== false)
};
},
searchSelectExtend: function (liveSearch) {
var text = __('Please select');
if (!text || text === 'Please select' || text === 'Please Select') {
text = '请选择';
}
var ext = 'data-none-selected-text="' + text + '" title="' + text + '"';
if (liveSearch) {
ext += ' data-live-search="true"';
}
return ext;
},
initCommonSearchSelectpicker: function (tbl) {
var $form = $('form.form-commonsearch', tbl.$container);
if ($form.length) {
Form.events.selectpicker($form);
}
},
/** @type {number[]} 正在手动同步的工单 ID */
syncingTicketIds: [],
/**
* 渲染筛选结果汇总行全量筛选数据非当前页
*
* @param {object} res 列表接口响应
*/
renderSummaryRow: function (res) {
var $bar = $('#split-ticket-summary');
if (!$bar.length) {
return;
}
var total = res && res.total ? parseInt(res.total, 10) : 0;
var summary = res && res.summary ? res.summary : null;
if (!summary || total <= 0) {
$bar.addClass('hide');
return;
}
$bar.removeClass('hide');
$bar.find('[data-sum="ticket_total"]').text(summary.ticket_total != null ? summary.ticket_total : 0);
$bar.find('[data-sum="complete_count"]').text(summary.complete_count != null ? summary.complete_count : 0);
$bar.find('[data-sum="ticket_progress_text"]').text(summary.ticket_progress_text || '0.00%');
$bar.find('[data-sum="speed_per_hour"]').text(summary.speed_per_hour != null ? summary.speed_per_hour : '0.00');
},
/**
* 后台同步标记同步中并请求 sync 接口
*
@@ -243,6 +315,30 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
},
formatter: {
/**
* 操作列编辑 拷贝 删除
*/
operate: function (value, row, index) {
var column = this;
var table = this.table;
var options = table.bootstrapTable('getOptions');
var buttons = [];
if (options.extend.edit_url) {
buttons.push($.extend({}, Table.button.edit, {url: options.extend.edit_url}));
}
buttons.push({
name: 'copy',
icon: 'fa fa-copy',
title: __('Copy'),
classname: 'btn btn-xs btn-info btn-copyone',
url: 'javascript:;',
extend: 'data-toggle="tooltip" data-container="body"',
});
if (options.extend.del_url) {
buttons.push($.extend({}, Table.button.del));
}
return Table.api.buttonlink(column, buttons, value, row, index, 'operate');
},
/**
* 工单类型纯文本展示无链接/标签样式
*/
@@ -267,6 +363,27 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
return '<span class="split-ticket-link-badge" style="display:inline-block;max-width:100%;padding:2px 8px;font-size:12px;line-height:1.5;color:#555;background:#f5f5f5;border:1px solid #ddd;border-radius:3px;word-break:break-all;">'
+ safe + '</span>';
},
/**
* 工单链接只读 input + 外链图标按钮
*/
ticketUrlLink: function (value) {
value = value == null ? '' : String(value).trim();
if (value === '') {
return '<span class="text-muted">-</span>';
}
var href = value;
if (!/^https?:\/\//i.test(href)) {
href = 'https://' + href;
}
var safeValue = Fast.api.escape(value);
var safeHref = Fast.api.escape(href);
return '<div class="input-group input-group-sm split-ticket-url-group">'
+ '<input type="text" class="form-control input-sm" readonly value="' + safeValue + '" title="' + safeValue + '">'
+ '<span class="input-group-btn">'
+ '<a href="' + safeHref + '" target="_blank" rel="noopener noreferrer" class="btn btn-default btn-sm" title="' + safeValue + '">'
+ '<i class="fa fa-link"></i></a>'
+ '</span></div>';
},
speedPerHour: function (value) {
var num = parseFloat(value);
if (isNaN(num)) {
@@ -308,6 +425,22 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
+ '</span>';
}
},
events: {
operate: $.extend({}, Table.api.events.operate, {
'click .btn-copyone': function (e, value, row, index) {
e.stopPropagation();
e.preventDefault();
var table = $(this).closest('table');
var options = table.bootstrapTable('getOptions');
var id = row[options.pk];
Fast.api.open(
Fast.api.fixurl('split.ticket/add?copy_id=' + id),
__('Copy'),
$(this).data() || {}
);
}
})
},
bindevent: function () {
Form.api.bindevent($('form[role=form]'));
Controller.api.fixSelectPlaceholder();
Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
/**
* 将发往字面量 IPv4 https 请求改写为 http
* 用于云控面板 HTTP 入口对浏览器返回 307 升级 HTTPS IP 443 不可达的场景
*/
'use strict';
function rewriteHttpsToHttpForLiteralIp(url) {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') {
return url;
}
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(parsed.hostname)) {
return url;
}
parsed.protocol = 'http:';
return parsed.toString();
} catch (_) {
return url;
}
}
async function attachHttpIpRewriteInterceptor(page, contextLabel = 'page', options = {}) {
const shareToken = typeof options.shareToken === 'string' ? options.shareToken : '';
const blockHeavyResources = options.blockHeavyResources !== false;
await page.setRequestInterception(true);
page.on('request', (req) => {
if (req.isInterceptResolutionHandled()) {
return;
}
if (blockHeavyResources) {
const resourceType = req.resourceType();
if (['image', 'media', 'font'].includes(resourceType)) {
req.abort();
return;
}
}
const originalUrl = req.url();
const rewrittenUrl = rewriteHttpsToHttpForLiteralIp(originalUrl);
if (rewrittenUrl !== originalUrl) {
const continueOptions = { url: rewrittenUrl };
if (shareToken) {
const headers = { ...req.headers() };
const existingCookie = headers.cookie || headers.Cookie || '';
if (!existingCookie.includes('share_token=')) {
headers.cookie = existingCookie
? `${existingCookie}; share_token=${shareToken}`
: `share_token=${shareToken}`;
}
continueOptions.headers = headers;
}
req.continue(continueOptions);
return;
}
req.continue();
});
}
module.exports = {
rewriteHttpsToHttpForLiteralIp,
attachHttpIpRewriteInterceptor,
};
+160
View File
@@ -0,0 +1,160 @@
/**
* BrowserFactorystandard / real 双轨启动 + 独立并发限流
*/
const {
DEFAULT_UA,
MAX_CONCURRENT_BROWSERS,
MAX_CONCURRENT_BROWSERS_REAL,
QUEUE_TIMEOUT_MS,
} = require('./constants');
const { BrowserConcurrencyLimiter } = require('./concurrency');
const { launchStandardBrowser } = require('./launch-standard');
const { launchRealBrowser, isRealBrowserAvailable } = require('./launch-real-browser');
const standardLimiter = new BrowserConcurrencyLimiter(MAX_CONCURRENT_BROWSERS);
const realLimiter = new BrowserConcurrencyLimiter(MAX_CONCURRENT_BROWSERS_REAL);
/**
* @typedef {Object} AntiBotConfig
* @property {boolean} [enabled]
* @property {'standard'|'real'} [profile]
* @property {boolean} [turnstile]
* @property {boolean} [solverFallback]
* @property {string} [sessionKey]
* @property {number} [challengeTimeoutMs]
*/
/**
* 规范化 antiBot 配置未传则走 standard
* @param {AntiBotConfig|null|undefined} antiBot
* @returns {AntiBotConfig}
*/
function normalizeAntiBot(antiBot) {
if (!antiBot || !antiBot.enabled) {
return { enabled: false, profile: 'standard' };
}
return {
enabled: true,
profile: antiBot.profile === 'real' ? 'real' : 'standard',
turnstile: antiBot.turnstile !== false,
solverFallback: antiBot.solverFallback !== false,
sessionKey: antiBot.sessionKey || '',
challengeTimeoutMs: antiBot.challengeTimeoutMs || 60000,
};
}
/**
* profile 获取限流器
* @param {'standard'|'real'} profile
*/
function getLimiter(profile) {
return profile === 'real' ? realLimiter : standardLimiter;
}
/**
* 创建 Browser 会话
* @param {{ profile?: 'standard'|'real', extraArgs?: string[] }} options
* @returns {Promise<{ browser: import('puppeteer').Browser, page: import('puppeteer').Page|null, profile: string, cleanup: () => Promise<void> }>}
*/
async function createBrowserSession(options = {}) {
const profile = options.profile === 'real' ? 'real' : 'standard';
const extraArgs = options.extraArgs || [];
if (profile === 'real') {
const { browser, page } = await launchRealBrowser(extraArgs);
return {
browser,
page,
profile: 'real',
cleanup: async () => {
try {
await browser.close();
} catch (_) {
/* ignore */
}
},
};
}
const browser = await launchStandardBrowser(extraArgs);
return {
browser,
page: null,
profile: 'standard',
cleanup: async () => {
try {
await browser.close();
} catch (_) {
/* ignore */
}
},
};
}
/**
* 在并发槽位内执行 Browser 任务
* @template T
* @param {string} taskName
* @param {'standard'|'real'} profile
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/
async function runWithProfileLimit(taskName, profile, fn) {
const limiter = getLimiter(profile);
try {
return await limiter.run(taskName, fn);
} catch (err) {
if (err.message === 'QUEUE_TIMEOUT') err.code = 'QUEUE_TIMEOUT';
throw err;
}
}
/**
* 创建 Page 并设置 UA / viewport / cookies
* @param {import('puppeteer').Browser} browser
* @param {import('puppeteer').Page|null} existingPage
* @param {{ userAgent?: string, viewport?: object, cookies?: Array<object> }} [options]
*/
async function createManagedPage(browser, existingPage, options = {}) {
const page = existingPage || await browser.newPage();
page.setDefaultNavigationTimeout(30000);
page.setDefaultTimeout(15000);
const userAgent = options.userAgent || DEFAULT_UA;
await page.setUserAgent(userAgent);
if (options.viewport) {
await page.setViewport(options.viewport);
}
if (options.cookies && options.cookies.length > 0) {
await page.setCookie(...options.cookies);
}
page.on('error', (err) => console.error('[Page Error]', err.message));
page.on('pageerror', (err) => console.error('[Page JS Error]', err.message));
return page;
}
/**
* @returns {{ standard: object, real: object, realBrowserReady: boolean }}
*/
function getFactoryStats() {
return {
standard: standardLimiter.getStats(),
real: realLimiter.getStats(),
realBrowserReady: isRealBrowserAvailable(),
};
}
module.exports = {
normalizeAntiBot,
getLimiter,
createBrowserSession,
runWithProfileLimit,
createManagedPage,
getFactoryStats,
standardLimiter,
realLimiter,
QUEUE_TIMEOUT_MS,
};
+153
View File
@@ -0,0 +1,153 @@
/**
* 付费 Captcha API 兜底CapSolver / 2Captcha 统一 Turnstile 求解接口
*/
const {
CAPTCHA_PROVIDER,
CAPTCHA_API_KEY,
CAPTCHA_MAX_WAIT_MS,
} = require('./constants');
/**
* @param {string} url
* @param {Record<string, string>} [headers]
* @returns {Promise<any>}
*/
async function httpJsonPost(url, body, headers = {}) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
});
const text = await res.text();
try {
return JSON.parse(text);
} catch (_) {
throw new Error(`Captcha API 返回非 JSON: ${text.slice(0, 200)}`);
}
}
/**
* @param {string} url
* @returns {Promise<any>}
*/
async function httpJsonGet(url) {
const res = await fetch(url);
const text = await res.text();
try {
return JSON.parse(text);
} catch (_) {
throw new Error(`Captcha API 返回非 JSON: ${text.slice(0, 200)}`);
}
}
/**
* CapSolver Turnstile 求解
* @param {{ pageUrl: string, sitekey: string, apiKey: string }} params
* @returns {Promise<string>}
*/
async function solveViaCapSolver({ pageUrl, sitekey, apiKey }) {
const create = await httpJsonPost('https://api.capsolver.com/createTask', {
clientKey: apiKey,
task: {
type: 'AntiTurnstileTaskProxyLess',
websiteURL: pageUrl,
websiteKey: sitekey,
},
});
if (create.errorId !== 0 || !create.taskId) {
throw new Error(`CapSolver createTask 失败: ${create.errorDescription || create.errorCode || 'unknown'}`);
}
const deadline = Date.now() + CAPTCHA_MAX_WAIT_MS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 3000));
const result = await httpJsonPost('https://api.capsolver.com/getTaskResult', {
clientKey: apiKey,
taskId: create.taskId,
});
if (result.status === 'ready' && result.solution && result.solution.token) {
return result.solution.token;
}
if (result.errorId !== 0) {
throw new Error(`CapSolver getTaskResult 失败: ${result.errorDescription || result.errorCode}`);
}
}
throw new Error('CapSolver 求解超时');
}
/**
* 2Captcha Turnstile 求解
* @param {{ pageUrl: string, sitekey: string, apiKey: string }} params
* @returns {Promise<string>}
*/
async function solveVia2Captcha({ pageUrl, sitekey, apiKey }) {
const params = new URLSearchParams({
key: apiKey,
method: 'turnstile',
sitekey,
pageurl: pageUrl,
json: '1',
});
const create = await httpJsonGet(`https://2captcha.com/in.php?${params.toString()}`);
if (create.status !== 1 || !create.request) {
throw new Error(`2Captcha in.php 失败: ${create.request || create.error_text || 'unknown'}`);
}
const taskId = create.request;
const deadline = Date.now() + CAPTCHA_MAX_WAIT_MS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5000));
const result = await httpJsonGet(
`https://2captcha.com/res.php?key=${encodeURIComponent(apiKey)}&action=get&id=${encodeURIComponent(taskId)}&json=1`
);
if (result.status === 1 && result.request) {
return result.request;
}
if (result.request && result.request !== 'CAPCHA_NOT_READY') {
throw new Error(`2Captcha res.php 失败: ${result.request}`);
}
}
throw new Error('2Captcha 求解超时');
}
/**
* 统一 Turnstile 求解入口
* @param {{ pageUrl: string, sitekey: string, provider?: string, apiKey?: string }} params
* @returns {Promise<{ token: string, provider: string }>}
*/
async function solveTurnstile({ pageUrl, sitekey, provider, apiKey }) {
const resolvedProvider = (provider || CAPTCHA_PROVIDER).toLowerCase();
const resolvedKey = apiKey || CAPTCHA_API_KEY;
if (!sitekey) {
throw new Error('无法提取 Turnstile sitekey');
}
if (!resolvedKey) {
throw new Error('未配置 CAPTCHA_API_KEY,无法使用付费 Captcha 兜底');
}
let token;
if (resolvedProvider === '2captcha') {
token = await solveVia2Captcha({ pageUrl, sitekey, apiKey: resolvedKey });
} else {
token = await solveViaCapSolver({ pageUrl, sitekey, apiKey: resolvedKey });
}
return { token, provider: resolvedProvider };
}
/**
* Captcha API 是否已配置
* @returns {boolean}
*/
function isCaptchaConfigured() {
return CAPTCHA_API_KEY.length > 0;
}
module.exports = {
solveTurnstile,
isCaptchaConfigured,
};
+97
View File
@@ -0,0 +1,97 @@
/**
* Cloudflare / Turnstile 挑战页检测
*/
/**
* @typedef {Object} CloudflareState
* @property {boolean} blocked 是否处于 CF 挑战中
* @property {string|null} challengeType turnstile|js_challenge|unknown|null
* @property {boolean} hasCfClearance 是否已有 cf_clearance cookie
* @property {string} url 当前页面 URL
* @property {string} title 页面 title
*/
/**
* 检测页面是否被 Cloudflare 拦截
* @param {import('puppeteer').Page} page
* @returns {Promise<CloudflareState>}
*/
async function detectCloudflare(page) {
const url = page.url();
const title = await page.title().catch(() => '');
const cookies = await page.cookies().catch(() => []);
const hasCfClearance = cookies.some((c) => c.name === 'cf_clearance');
const domSignals = await page.evaluate(() => {
const hasTurnstileInput = !!document.querySelector('[name="cf-turnstile-response"]');
const hasTurnstileWidget = !!document.querySelector('.cf-turnstile, [data-sitekey]');
const hasChallengeRunning = !!document.querySelector('#challenge-running, #cf-challenge-running');
const titleText = document.title || '';
const bodyText = (document.body && document.body.innerText) ? document.body.innerText.slice(0, 500) : '';
return {
hasTurnstileInput,
hasTurnstileWidget,
hasChallengeRunning,
titleHasJustAMoment: titleText.includes('Just a moment'),
bodyHasJustAMoment: bodyText.includes('Just a moment') || bodyText.includes('Checking your browser'),
};
}).catch(() => ({
hasTurnstileInput: false,
hasTurnstileWidget: false,
hasChallengeRunning: false,
titleHasJustAMoment: false,
bodyHasJustAMoment: false,
}));
const urlChallenge = url.includes('/cdn-cgi/challenge-platform') || url.includes('/cdn-cgi/challenge');
let challengeType = null;
if (domSignals.hasTurnstileInput || domSignals.hasTurnstileWidget) {
challengeType = 'turnstile';
} else if (urlChallenge || domSignals.titleHasJustAMoment || domSignals.bodyHasJustAMoment) {
challengeType = 'js_challenge';
}
const blocked = !hasCfClearance && (
urlChallenge
|| domSignals.titleHasJustAMoment
|| domSignals.bodyHasJustAMoment
|| domSignals.hasTurnstileInput
|| domSignals.hasTurnstileWidget
|| domSignals.hasChallengeRunning
|| title.includes('Just a moment')
);
return {
blocked,
challengeType,
hasCfClearance,
url,
title,
};
}
/**
* 检测 Turnstile 是否已通过
* @param {import('puppeteer').Page} page
* @returns {Promise<boolean>}
*/
async function isTurnstileSolved(page) {
const cookies = await page.cookies().catch(() => []);
if (cookies.some((c) => c.name === 'cf_clearance')) {
return true;
}
const tokenLen = await page.evaluate(() => {
const input = document.querySelector('[name="cf-turnstile-response"]');
return input && input.value ? input.value.length : 0;
}).catch(() => 0);
return tokenLen > 20;
}
module.exports = {
detectCloudflare,
isTurnstileSolved,
};
+121
View File
@@ -0,0 +1,121 @@
/**
* Cloudflare 挑战统一处理检测 内置等待 Captcha API 兜底
*/
const { detectCloudflare } = require('./cf-detector');
const { waitForTurnstile, extractTurnstileSitekey, injectTurnstileToken } = require('./turnstile-handler');
const { solveTurnstile, isCaptchaConfigured } = require('./captcha-solver');
/**
* @typedef {Object} CfHandleResult
* @property {boolean} success
* @property {string|null} code
* @property {string|null} challengeType
* @property {string|null} stage
* @property {boolean} solverUsed
* @property {number} elapsedMs
* @property {boolean} cfDetected
*/
/**
* 处理 Cloudflare / Turnstile 挑战 authActions 之前调用
* @param {import('puppeteer').Page} page
* @param {{ turnstile?: boolean, solverFallback?: boolean, challengeTimeoutMs?: number }} antiBot
* @param {(page: import('puppeteer').Page) => Promise<string>} [waitForUrlSettled]
* @returns {Promise<CfHandleResult>}
*/
async function handleCloudflareChallenge(page, antiBot, waitForUrlSettled) {
const started = Date.now();
const timeoutMs = antiBot.challengeTimeoutMs || 60000;
let cfState = await detectCloudflare(page);
if (!cfState.blocked) {
return {
success: true,
code: null,
challengeType: null,
stage: null,
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: false,
};
}
console.log(`[CF] 检测到挑战 type=${cfState.challengeType} url=${cfState.url}`);
if (antiBot.turnstile !== false) {
const builtIn = await waitForTurnstile(page, { timeoutMs, useBuiltInClick: true });
if (builtIn.success) {
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
cfState = await detectCloudflare(page);
if (!cfState.blocked) {
return {
success: true,
code: null,
challengeType: cfState.challengeType,
stage: 'built_in',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
}
}
if (antiBot.solverFallback !== false && isCaptchaConfigured()) {
try {
const sitekey = await extractTurnstileSitekey(page);
const pageUrl = page.url();
const { token, provider } = await solveTurnstile({ pageUrl, sitekey });
console.log(`[CF] Captcha API (${provider}) 返回 token,注入页面`);
await injectTurnstileToken(page, token);
const apiWaitMs = Math.min(timeoutMs, 30000);
await waitForTurnstile(page, { timeoutMs: apiWaitMs });
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
cfState = await detectCloudflare(page);
if (!cfState.blocked) {
return {
success: true,
code: null,
challengeType: 'turnstile',
stage: 'api',
solverUsed: true,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
} catch (apiErr) {
console.error('[CF] Captcha API 兜底失败:', apiErr.message);
return {
success: false,
code: 'CF_TURNSTILE_FAILED',
challengeType: cfState.challengeType || 'turnstile',
stage: 'api',
solverUsed: true,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
}
return {
success: false,
code: 'CF_TURNSTILE_FAILED',
challengeType: cfState.challengeType || 'unknown',
stage: antiBot.solverFallback !== false && !isCaptchaConfigured() ? 'built_in' : 'timeout',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
module.exports = {
handleCloudflareChallenge,
isCaptchaConfigured,
};
+82
View File
@@ -0,0 +1,82 @@
/**
* Browser 并发槽位限流器standard / real 各自独立队列
*/
const { QUEUE_TIMEOUT_MS } = require('./constants');
class BrowserConcurrencyLimiter {
/**
* @param {number} max 最大并发 Browser
*/
constructor(max) {
this.max = max;
this.active = 0;
this.waiters = [];
}
/** @returns {{ max: number, active: number, queued: number }} */
getStats() {
return { max: this.max, active: this.active, queued: this.waiters.length };
}
/**
* @param {string} taskName
* @param {number} [timeoutMs]
*/
async acquire(taskName, timeoutMs = QUEUE_TIMEOUT_MS) {
if (this.active < this.max) {
this.active++;
console.log(`[并发槽位] ${taskName} 立即获取 (${this.active}/${this.max})`);
return;
}
console.log(`[并发槽位] ${taskName} 排队等待 (队列 ${this.waiters.length + 1})`);
await new Promise((resolve, reject) => {
const entry = {
resolve: () => {
this.active++;
console.log(`[并发槽位] ${taskName} 出队执行 (${this.active}/${this.max})`);
resolve();
},
reject,
};
if (timeoutMs > 0) {
entry.timer = setTimeout(() => {
const idx = this.waiters.indexOf(entry);
if (idx >= 0) {
this.waiters.splice(idx, 1);
reject(new Error('QUEUE_TIMEOUT'));
}
}, timeoutMs);
}
this.waiters.push(entry);
});
}
/** @param {string} taskName */
release(taskName) {
this.active = Math.max(0, this.active - 1);
const next = this.waiters.shift();
if (next) {
if (next.timer) clearTimeout(next.timer);
next.resolve();
} else {
console.log(`[并发槽位] ${taskName} 释放 (${this.active}/${this.max})`);
}
}
/**
* @template T
* @param {string} taskName
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/
async run(taskName, fn) {
await this.acquire(taskName);
try {
return await fn();
} finally {
this.release(taskName);
}
}
}
module.exports = { BrowserConcurrencyLimiter };
+140
View File
@@ -0,0 +1,140 @@
/**
* puppeteer-api 共享常量与 Chrome 启动环境工具
*/
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer-extra');
/** 默认 Chrome 可执行文件路径(生产环境可通过环境变量覆盖) */
const DEFAULT_CHROME_EXECUTABLE = '/www/wwwroot/puppeteer-api/.cache/puppeteer/chrome/linux-149.0.7827.22/chrome-linux64/chrome';
/** 默认 User-Agent,与 standard / real 双轨保持一致 */
const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
/** Chrome 启动基础参数:多实例 headless 场景下复用 */
const BASE_LAUNCH_ARGS = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-extensions',
'--disable-background-networking',
'--disable-software-rasterizer',
'--no-first-run',
'--no-default-browser-check',
'--disable-breakpad',
'--disable-crash-reporter',
];
/** puppeteer-api 专用运行时目录(与 server.js 同级 .runtime */
const RUNTIME_DIR = path.join(__dirname, '..', '.runtime');
/**
* 确保 .runtime 子目录存在且 www 用户可写
* @param {string} name
* @returns {string}
*/
function ensureRuntimeSubdir(name) {
const dir = path.join(RUNTIME_DIR, name);
fs.mkdirSync(dir, { recursive: true, mode: 0o777 });
return dir;
}
/**
* 解析 Chrome 可执行文件路径
* @returns {string}
*/
function resolveChromeExecutable() {
const candidates = [
process.env.PUPPETEER_EXECUTABLE_PATH,
process.env.CHROME_EXECUTABLE,
DEFAULT_CHROME_EXECUTABLE,
].filter(Boolean);
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
try {
const bundled = puppeteer.executablePath();
if (bundled && fs.existsSync(bundled)) {
return bundled;
}
} catch (_) {
/* puppeteer 未内置浏览器时忽略 */
}
return candidates[0] || DEFAULT_CHROME_EXECUTABLE;
}
/**
* 生产 Linux 常注入无效 DBUS 地址删除而非设为 /dev/null
* @returns {NodeJS.ProcessEnv}
*/
function buildBrowserLaunchEnv() {
const env = { ...process.env };
delete env.DBUS_SESSION_BUS_ADDRESS;
delete env.DBUS_SYSTEM_BUS_ADDRESS;
const xdgRuntime = ensureRuntimeSubdir('xdg-runtime');
env.XDG_RUNTIME_DIR = xdgRuntime;
env.XDG_CONFIG_HOME = ensureRuntimeSubdir('xdg-config');
env.XDG_CACHE_HOME = ensureRuntimeSubdir('xdg-cache');
env.TMPDIR = ensureRuntimeSubdir('tmp');
return env;
}
/**
* 校验 Chrome 可执行文件存在
* @returns {string}
*/
function assertChromeExecutable() {
const executablePath = resolveChromeExecutable();
if (!fs.existsSync(executablePath)) {
throw new Error(
`Chrome 可执行文件不存在: ${executablePath}。请在 puppeteer-api 目录执行: npx puppeteer browsers install chrome`
);
}
return executablePath;
}
/** 并发与超时配置(可通过环境变量覆盖) */
const MAX_CONCURRENT_BROWSERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_BROWSERS || '4', 10));
const MAX_CONCURRENT_BROWSERS_REAL = Math.max(1, parseInt(process.env.MAX_CONCURRENT_BROWSERS_REAL || '2', 10));
const QUEUE_TIMEOUT_MS = Math.max(5000, parseInt(process.env.QUEUE_TIMEOUT_MS || '120000', 10));
const API_INTERCEPT_TIMEOUT_MS = Math.max(10000, parseInt(process.env.API_INTERCEPT_TIMEOUT_MS || '30000', 10));
const API_INTERCEPT_TIMEOUT_REAL_MS = Math.max(
API_INTERCEPT_TIMEOUT_MS,
parseInt(process.env.API_INTERCEPT_TIMEOUT_REAL_MS || '90000', 10)
);
/** Captcha API 配置 */
const CAPTCHA_PROVIDER = (process.env.CAPTCHA_PROVIDER || 'capsolver').toLowerCase();
const CAPTCHA_API_KEY = process.env.CAPTCHA_API_KEY || '';
const CAPTCHA_MAX_WAIT_MS = Math.max(30000, parseInt(process.env.CAPTCHA_MAX_WAIT_MS || '120000', 10));
/** 会话 Cookie 持久化 TTL(毫秒) */
const SESSION_TTL_MS = Math.max(60000, parseInt(process.env.SESSION_TTL_MS || '1800000', 10));
module.exports = {
DEFAULT_CHROME_EXECUTABLE,
DEFAULT_UA,
BASE_LAUNCH_ARGS,
RUNTIME_DIR,
ensureRuntimeSubdir,
resolveChromeExecutable,
buildBrowserLaunchEnv,
assertChromeExecutable,
MAX_CONCURRENT_BROWSERS,
MAX_CONCURRENT_BROWSERS_REAL,
QUEUE_TIMEOUT_MS,
API_INTERCEPT_TIMEOUT_MS,
API_INTERCEPT_TIMEOUT_REAL_MS,
CAPTCHA_PROVIDER,
CAPTCHA_API_KEY,
CAPTCHA_MAX_WAIT_MS,
SESSION_TTL_MS,
};
+70
View File
@@ -0,0 +1,70 @@
/**
* Real Browser 启动puppeteer-real-browser Cloudflare Turnstile
* 注意不与 puppeteer-extra 混用在同一 Browser 实例
*/
const {
BASE_LAUNCH_ARGS,
resolveChromeExecutable,
} = require('./constants');
/** @type {boolean|null} */
let realBrowserModuleChecked = null;
/** @type {boolean} */
let realBrowserAvailable = false;
/**
* 检测 puppeteer-real-browser 是否已安装
* @returns {boolean}
*/
function isRealBrowserAvailable() {
if (realBrowserModuleChecked !== null) {
return realBrowserAvailable;
}
try {
require.resolve('puppeteer-real-browser');
realBrowserAvailable = true;
} catch (_) {
realBrowserAvailable = false;
}
realBrowserModuleChecked = true;
return realBrowserAvailable;
}
/**
* 启动 real profile Browserheadless:false + Xvfb + turnstile 内置点击
* @param {string[]} [extraArgs]
* @returns {Promise<{ browser: import('puppeteer').Browser, page: import('puppeteer').Page }>}
*/
async function launchRealBrowser(extraArgs = []) {
if (!isRealBrowserAvailable()) {
throw new Error(
'puppeteer-real-browser 未安装。请在 puppeteer-api 目录执行: npm install puppeteer-real-browser@1.4.4'
);
}
const { connect } = require('puppeteer-real-browser');
const chromePath = resolveChromeExecutable();
const result = await connect({
headless: false,
turnstile: true,
disableXvfb: false,
customConfig: { chromePath },
args: [...BASE_LAUNCH_ARGS, '--mute-audio', ...extraArgs],
});
const browser = result.browser;
const page = result.page;
if (!browser || !page) {
throw new Error('puppeteer-real-browser connect 未返回 browser/page');
}
return { browser, page };
}
module.exports = {
isRealBrowserAvailable,
launchRealBrowser,
};
+58
View File
@@ -0,0 +1,58 @@
/**
* Standard Browser 启动puppeteer-extra + StealthPlugin
*/
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const {
BASE_LAUNCH_ARGS,
assertChromeExecutable,
buildBrowserLaunchEnv,
ensureRuntimeSubdir,
} = require('./constants');
// Stealth 必须在 launch 之前注册
puppeteer.use(StealthPlugin());
/**
* 启动 standard profile Browser
* @param {string[]} [extraArgs]
* @returns {Promise<import('puppeteer').Browser>}
*/
async function launchStandardBrowser(extraArgs = []) {
const executablePath = assertChromeExecutable();
const userDataDir = fs.mkdtempSync(path.join(ensureRuntimeSubdir('chrome-profiles'), 'run-'));
try {
const browser = await puppeteer.launch({
executablePath,
headless: 'new',
args: [...BASE_LAUNCH_ARGS, ...extraArgs],
env: buildBrowserLaunchEnv(),
userDataDir,
});
const originalClose = browser.close.bind(browser);
browser.close = async () => {
try {
await originalClose();
} finally {
try {
fs.rmSync(userDataDir, { recursive: true, force: true });
} catch (_) {
/* 清理失败不影响主流程 */
}
}
};
return browser;
} catch (err) {
try {
fs.rmSync(userDataDir, { recursive: true, force: true });
} catch (_) {
/* ignore */
}
throw err;
}
}
module.exports = { launchStandardBrowser };
+121
View File
@@ -0,0 +1,121 @@
/**
* 会话 Cookie 持久化 sessionKey 保存 cf_clearance 降低重复过盾概率
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { RUNTIME_DIR, SESSION_TTL_MS } = require('./constants');
const SESSIONS_DIR = path.join(RUNTIME_DIR, 'sessions');
/**
* 确保 sessions 目录存在
*/
function ensureSessionsDir() {
fs.mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o777 });
}
/**
* sessionKey 转安全文件名
* @param {string} sessionKey
* @returns {string}
*/
function keyToFilename(sessionKey) {
const hash = crypto.createHash('md5').update(sessionKey).digest('hex');
return `${hash}.json`;
}
/**
* @typedef {Object} StoredSession
* @property {string} sessionKey
* @property {number} savedAt
* @property {number} expiresAt
* @property {Array<{name:string,value:string,domain?:string,path?:string}>} cookies
*/
/**
* 加载已保存的会话 cookies
* @param {string} sessionKey
* @returns {StoredSession|null}
*/
function loadSession(sessionKey) {
if (!sessionKey) return null;
ensureSessionsDir();
const filePath = path.join(SESSIONS_DIR, keyToFilename(sessionKey));
if (!fs.existsSync(filePath)) return null;
try {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (!raw || !Array.isArray(raw.cookies)) return null;
if (raw.expiresAt && Date.now() > raw.expiresAt) {
fs.unlinkSync(filePath);
return null;
}
return raw;
} catch (_) {
return null;
}
}
/**
* 保存会话 cookies优先保留 CF 相关 cookie
* @param {string} sessionKey
* @param {Array<{name:string,value:string,domain?:string,path?:string}>} cookies
* @param {number} [ttlMs]
*/
function saveSession(sessionKey, cookies, ttlMs = SESSION_TTL_MS) {
if (!sessionKey || !Array.isArray(cookies) || cookies.length === 0) return;
ensureSessionsDir();
const cfRelated = cookies.filter((c) =>
['cf_clearance', '__cf_bm', 'cf_chl_2'].includes(c.name)
|| c.name.startsWith('__cf')
);
const toSave = cfRelated.length > 0 ? cfRelated : cookies;
const now = Date.now();
const payload = {
sessionKey,
savedAt: now,
expiresAt: now + ttlMs,
cookies: toSave.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path || '/',
})),
};
const filePath = path.join(SESSIONS_DIR, keyToFilename(sessionKey));
fs.writeFileSync(filePath, JSON.stringify(payload, null, 0), { mode: 0o666 });
}
/**
* 将会话 cookies 转为 puppeteer setCookie 格式
* @param {StoredSession|null} session
* @param {string} pageUrl
* @returns {Array<{name:string,value:string,domain?:string,path?:string,url?:string}>}
*/
function sessionCookiesForPage(session, pageUrl) {
if (!session || !Array.isArray(session.cookies)) return [];
let hostname = '';
try {
hostname = new URL(pageUrl).hostname;
} catch (_) {
return session.cookies;
}
return session.cookies.map((c) => {
const cookie = { ...c };
if (!cookie.domain && hostname) {
cookie.url = `https://${hostname}/`;
}
return cookie;
});
}
module.exports = {
loadSession,
saveSession,
sessionCookiesForPage,
};
+90
View File
@@ -0,0 +1,90 @@
/**
* Turnstile 内置求解等待 widget 自动完成 + token 轮询
*/
const { detectCloudflare, isTurnstileSolved } = require('./cf-detector');
/**
* @typedef {Object} TurnstileResult
* @property {boolean} success
* @property {string} stage built_in|timeout|already_clear
* @property {number} elapsedMs
*/
/**
* 等待 Turnstile 通过内置点击 / 自动跳转
* @param {import('puppeteer').Page} page
* @param {{ timeoutMs?: number, pollMs?: number, useBuiltInClick?: boolean }} [options]
* @returns {Promise<TurnstileResult>}
*/
async function waitForTurnstile(page, options = {}) {
const timeoutMs = options.timeoutMs ?? 60000;
const pollMs = options.pollMs ?? 500;
const started = Date.now();
const initial = await detectCloudflare(page);
if (!initial.blocked || initial.hasCfClearance) {
return { success: true, stage: 'already_clear', elapsedMs: Date.now() - started };
}
// real browser 已开启 turnstile:true 内置点击;此处轮询等待结果
while (Date.now() - started < timeoutMs) {
if (await isTurnstileSolved(page)) {
return { success: true, stage: 'built_in', elapsedMs: Date.now() - started };
}
const state = await detectCloudflare(page);
if (!state.blocked) {
return { success: true, stage: 'built_in', elapsedMs: Date.now() - started };
}
await new Promise((r) => setTimeout(r, pollMs));
}
return { success: false, stage: 'timeout', elapsedMs: Date.now() - started };
}
/**
* 从页面提取 Turnstile sitekey
* @param {import('puppeteer').Page} page
* @returns {Promise<string|null>}
*/
async function extractTurnstileSitekey(page) {
return page.evaluate(() => {
const widget = document.querySelector('.cf-turnstile[data-sitekey], [data-sitekey]');
if (widget) {
return widget.getAttribute('data-sitekey');
}
const iframe = document.querySelector('iframe[src*="challenges.cloudflare.com"]');
if (iframe && iframe.src) {
const match = iframe.src.match(/[?&]sitekey=([^&]+)/);
if (match) return decodeURIComponent(match[1]);
}
return null;
}).catch(() => null);
}
/**
* Captcha API 返回的 token 注入页面
* @param {import('puppeteer').Page} page
* @param {string} token
*/
async function injectTurnstileToken(page, token) {
await page.evaluate((turnstileToken) => {
const input = document.querySelector('[name="cf-turnstile-response"]');
if (input) {
input.value = turnstileToken;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
const form = document.querySelector('form');
if (form) {
form.submit();
}
}, token);
}
module.exports = {
waitForTurnstile,
extractTurnstileSitekey,
injectTurnstileToken,
};
+3103
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More