添加whatshub工单
This commit is contained in:
Regular → Executable
@@ -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';
|
||||
Regular → Executable
+29
@@ -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';
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+7
@@ -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
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+15
-2
@@ -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>');
|
||||
}
|
||||
|
||||
Regular → Executable
+25
@@ -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 接口
|
||||
*/
|
||||
|
||||
Regular → Executable
+130
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 排除不可由表单提交的字段
|
||||
*
|
||||
|
||||
Regular → Executable
+129
-1
@@ -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
|
||||
|
||||
Regular → Executable
Regular → Executable
+1
-1
@@ -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',
|
||||
|
||||
Regular → Executable
+12
@@ -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' => '该链接下已存在相同号码',
|
||||
|
||||
Regular → Executable
+3
@@ -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' => '到期时间必须晚于开始时间',
|
||||
|
||||
Regular → Executable
Regular → Executable
+1
-1
@@ -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
|
||||
|
||||
Regular → Executable
+1
@@ -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'),
|
||||
];
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
@@ -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')}"
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+51
@@ -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')}"
|
||||
|
||||
Regular → Executable
Regular → Executable
+107
-5
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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;
|
||||
}
|
||||
}
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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;
|
||||
}
|
||||
}
|
||||
Regular → Executable
+188
-37
@@ -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;
|
||||
}
|
||||
}
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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;
|
||||
}
|
||||
}
|
||||
Regular → Executable
+13
-7
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram:https://t.me/+ 号码(去掉前导 +)
|
||||
* Telegram:https://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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+4
-4
@@ -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;
|
||||
|
||||
Regular → Executable
Regular → Executable
+14
@@ -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
|
||||
*/
|
||||
|
||||
Regular → Executable
+36
@@ -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);
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+55
-7
@@ -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}
|
||||
*/
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+54
-4
@@ -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
|
||||
*
|
||||
|
||||
Regular → Executable
+156
-20
@@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Regular → Executable
+144
-11
@@ -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();
|
||||
|
||||
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
+62
@@ -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,
|
||||
};
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* BrowserFactory:standard / 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,
|
||||
};
|
||||
Executable
+153
@@ -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,
|
||||
};
|
||||
Executable
+97
@@ -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,
|
||||
};
|
||||
Executable
+121
@@ -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,
|
||||
};
|
||||
Executable
+82
@@ -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 };
|
||||
Executable
+140
@@ -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
@@ -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 Browser(headless: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,
|
||||
};
|
||||
Executable
+58
@@ -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 };
|
||||
Executable
+121
@@ -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
@@ -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
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
Reference in New Issue
Block a user