Compare commits

..

82 Commits

Author SHA1 Message Date
www
0096805a49 debug 2026-05-26 17:21:51 +08:00
www
7f910ee22f debug 2026-05-26 17:21:51 +08:00
Your Name
97aefcf86b debug 2026-05-26 16:53:21 +08:00
www
b764e7555d fix(seo): normalize GPT sitemap push URLs 2026-05-09 11:10:00 +08:00
Your Name
04242363a8 debug 2026-04-23 18:12:23 +08:00
Your Name
c1ac050c6a debug 2026-04-22 13:39:28 +08:00
Your Name
b943ea2ab7 debug 2026-04-21 01:02:21 +08:00
root
e568577ce0 add old template seo review for 2026-04-20 2026-04-20 16:17:53 +08:00
www
66a8086a17 docs: update domestic spider progress for 2026-04-20 2026-04-20 16:03:57 +08:00
www
328946f1a5 docs: add private runtime storage rules 2026-04-19 21:37:48 +08:00
www
2b2b822631 feat: secure storage artifact preview 2026-04-19 21:29:51 +08:00
www
566e966582 refactor: move private seo artifacts into storage 2026-04-19 21:10:02 +08:00
Your Name
53a1d7e4fd debug 2026-04-19 20:54:57 +08:00
www
d469c4b93d fix: stabilize spider md github sync 2026-04-19 20:27:09 +08:00
www
fccd2bfe5b feat: add github spider md push and async task logging 2026-04-19 19:38:38 +08:00
Your Name
e8688443e5 debug 2026-04-19 13:49:04 +08:00
root
da09dbd64d docs: update daily mainline index 2026-04-18 23:09:25 +08:00
root
23b9a719d8 docs: add php and vue rollout guide 2026-04-18 20:38:35 +08:00
root
cec1fdbc69 feat: add metadata workbench and shared seo rules 2026-04-18 20:23:30 +08:00
Your Name
2ea87050a5 fix: prioritize home seo defaults for openai domains 2026-04-18 15:06:34 +08:00
Your Name
02a65d97ee feat: streamline openai tkd defaults 2026-04-18 14:43:26 +08:00
Your Name
2a5685605b refactor: improve template notes readability 2026-04-18 14:23:54 +08:00
Your Name
a87a4a41cc feat: improve gpt import templates and seo docs 2026-04-18 14:11:21 +08:00
root
5cb11b4476 feat: sync latest GPT SEO routing and docs 2026-04-18 01:31:44 +08:00
Your Name
c8fb6956ef 老模板优化 2026-04-17 21:09:06 +08:00
Your Name
4dc78e3366 docs(handoff): add next-round spider tracking prompt 2026-04-17 18:11:44 +08:00
Your Name
309ef80383 docs(spider): add 1810 bytespider window note 2026-04-17 18:10:43 +08:00
Your Name
eb33bb7d91 docs(spider): clarify 1800 probing window interpretation 2026-04-17 18:10:01 +08:00
Your Name
6d73d61e49 docs(handoff): add delivery checklist for current round 2026-04-17 18:09:09 +08:00
Your Name
186dd80dd0 docs(spider): confirm bytespider lacks deep page samples 2026-04-17 18:08:16 +08:00
Your Name
84f1d18526 docs(spider): add bytespider current phase assessment 2026-04-17 18:07:32 +08:00
Your Name
aa406cc35c docs(spider): add consecutive sogou recovery window 2026-04-17 18:06:40 +08:00
Your Name
8297cf342c docs(handoff): add current phase one-page summary 2026-04-17 17:57:30 +08:00
Your Name
b49d67df52 docs(spider): add 1730 domestic bot breakdown 2026-04-17 17:56:33 +08:00
Your Name
db249204fb docs(spider): note latest summary field format change 2026-04-17 17:55:52 +08:00
Your Name
5aaa44f7a2 docs(nginx): clarify managed spider example vs live state 2026-04-17 17:55:02 +08:00
Your Name
87b1caaedb docs(nginx): record search route detection samples 2026-04-17 17:53:57 +08:00
Your Name
56ab9a95a6 docs(nginx): add frontend cluster config directory guide 2026-04-17 17:44:26 +08:00
Your Name
62e3eed6f4 docs(spider): record get-index and get-index slash variants 2026-04-17 17:40:37 +08:00
root
9a7cbfa3b7 docs(ops): add nginx rollout retest template 2026-04-17 17:37:24 +08:00
root
16962332fd docs(handoff): add codex startup prompt templates 2026-04-17 17:36:16 +08:00
root
586ca4d5a2 docs(handoff): add session checklist template 2026-04-17 17:35:24 +08:00
root
f9fc2b658a docs(spider): add quick verification commands 2026-04-17 17:34:14 +08:00
root
875c106908 docs(readme): add docs entrypoint index 2026-04-17 17:33:34 +08:00
root
49d8e39773 docs(handoff): add must-read rules for new codex sessions 2026-04-17 17:32:42 +08:00
root
72cc7cb504 docs(spider): add short ops summary 2026-04-17 17:31:31 +08:00
root
3c6a5f6a20 docs(spider): add bytespider current assessment 2026-04-17 17:30:23 +08:00
root
57730ec6b0 docs(spider): classify hyjssb as stale-route case 2026-04-17 17:28:54 +08:00
root
008f27a4c0 docs(spider): record current high-frequency host retests 2026-04-17 17:27:37 +08:00
root
00f9a0d97b docs(spider): record domestic recovery turning point 2026-04-17 16:51:48 +08:00
root
101084ff80 docs(spider): record latest clean domestic snapshot 2026-04-17 16:23:06 +08:00
root
b6c36bc914 docs(nginx): record multi-domain live verification 2026-04-17 16:21:45 +08:00
root
178ec7ea6f docs(spider): update sogou live verification notes 2026-04-17 16:19:36 +08:00
root
cab7d979af docs(spider): record sogou managed spider finding 2026-04-17 16:17:47 +08:00
root
a3d732b9ea docs(spider): record domestic bot recovery signals 2026-04-17 16:15:54 +08:00
root
7aaeb9d462 docs(liangzuan): record entry and asset gap findings 2026-04-17 16:13:02 +08:00
root
ee21d21a8a docs(routes): correct chuanjiafeng frontier route findings 2026-04-17 16:12:12 +08:00
root
3513dbe089 docs(seo): add frontier sampling notes for domains 2026-04-17 16:11:16 +08:00
root
570f36e2c2 docs(nginx): record managed cache rollout verification 2026-04-17 16:07:48 +08:00
root
8a6ae1bf66 docs(cache): record jpjdxs detail stale html finding 2026-04-17 13:54:47 +08:00
root
ebb6ed4bb9 docs(seo): classify runtime verification results 2026-04-17 13:49:09 +08:00
root
b3cfbad62c fix(seo-copy): enrich legacy detail play payloads 2026-04-17 13:46:07 +08:00
root
35b59d68ec fix(seo-copy): restore default detail and play copy 2026-04-17 13:41:57 +08:00
root
2fb8896268 docs(seo): record liangzuan routing mismatch findings 2026-04-17 13:39:51 +08:00
root
598b42e4e4 docs(seo): record chuanjiafeng multi-scene recovery 2026-04-17 13:35:13 +08:00
root
e5228a1734 docs(seo): record full-scene source restore findings 2026-04-17 13:33:33 +08:00
root
28e2a93563 feat(seo-copy): restore all source scenes 2026-04-17 13:16:03 +08:00
root
0b9a45e5c4 fix(router): restore legacy rank entry aliases 2026-04-17 13:05:50 +08:00
root
0c534d2903 docs(seo): record recovery findings and add seo copy tools 2026-04-17 13:02:13 +08:00
root
56644e7f50 fix(videoGpt1): normalize seo pages and fallback services 2026-04-17 13:02:00 +08:00
root
dd3cbf09cc fix(videoGpt1): restore detail and play url chain 2026-04-17 13:01:51 +08:00
root
fc61444993 update codex handoff doc after seo pipeline restore 2026-04-16 22:45:57 +08:00
root
2a905bbf0f restore gpt seo copy pipeline and stabilize seo tkd output 2026-04-16 22:45:00 +08:00
root
09b2137fa6 docs require reading 1270 before codex work starts 2026-04-16 17:05:59 +08:00
root
fe374bbbc9 docs require local analysis findings to flow back 2026-04-16 16:52:05 +08:00
root
82d2d1691d docs add storage analysis rules for project-local notes 2026-04-16 16:26:21 +08:00
Your Name
af0b09daee debug 2026-04-16 16:14:20 +08:00
root
327c5f506c docs sync collaboration guides into repository docs 2026-04-16 15:20:36 +08:00
root
71b986a4e0 docs add codex collaboration handoff guide 2026-04-16 14:52:08 +08:00
root
b17bce24e9 fix mongo fallback sampling for deep links 2026-04-16 14:12:37 +08:00
root
7fff1a9944 restore admin helper chain and stabilize seo routes 2026-04-15 18:51:27 +08:00
root
bd073c6795 fix id-first deep links for videoGpt1 seo 2026-04-15 16:06:43 +08:00
312 changed files with 44524 additions and 1633 deletions

5
code/.gitignore vendored
View File

@@ -17,4 +17,9 @@ Thumbs.db
public/static/css/compiled/*
public/static/js/compiled/*
public/static/favicon/generated/*
/public/_seo_copy_release/*
/app/public/_admin_templates/video-metadata-missing-task-pool/*
/app/public/_admin_templates/video-metadata-missing-workbench/*
/data/seo_copy_published/*
/data/seo_resource/keyword_feedback/*
/storage/*

View File

@@ -7,6 +7,7 @@ use think\exception\Handle;
use think\exception\HttpException;
use think\exception\HttpResponseException;
use think\exception\ValidateException;
use think\facade\Log;
use think\Response;
use Throwable;
@@ -36,6 +37,8 @@ class ExceptionHandle extends Handle
*/
public function report(Throwable $exception): void
{
$this->reportFrontendDetailException($exception);
// 使用内置的方式记录异常日志
parent::report($exception);
}
@@ -55,4 +58,63 @@ class ExceptionHandle extends Handle
// 其他错误交给系统处理
return parent::render($request, $e);
}
protected function reportFrontendDetailException(Throwable $exception): void
{
try {
if (!function_exists('request')) {
return;
}
$request = request();
if (!$request) {
return;
}
$path = '/' . ltrim((string)$request->pathinfo(), '/');
$normalizedPath = strtolower($path);
$isDetailLike = str_starts_with($normalizedPath, '/detail/')
|| str_starts_with($normalizedPath, '/voddetail/')
|| str_starts_with($normalizedPath, '/video-detail/')
|| str_starts_with($normalizedPath, '/vodinfo/')
|| str_starts_with($normalizedPath, '/video-info/')
|| str_starts_with($normalizedPath, '/film/')
|| str_starts_with($normalizedPath, '/movie/')
|| str_starts_with($normalizedPath, '/player/')
|| str_starts_with($normalizedPath, '/vodplay/')
|| str_starts_with($normalizedPath, '/video-bofang/')
|| str_starts_with($normalizedPath, '/bf-');
if (!$isDetailLike) {
return;
}
$route = method_exists($request, 'route') ? $request->route() : null;
$routeVars = [];
foreach (['strPinyin', 'intVId', 'intVForgeId', 'strPlayType', 'intPlayIndex'] as $key) {
try {
$routeVars[$key] = $route ? $route->getRuleParam($key) : null;
} catch (Throwable) {
$routeVars[$key] = null;
}
}
Log::error('frontend_detail_exception', [
'host' => (string)$request->host(true),
'method' => (string)$request->method(),
'url' => (string)$request->url(true),
'path' => $path,
'ip' => (string)$request->ip(),
'user_agent' => (string)$request->server('HTTP_USER_AGENT', ''),
'query' => $request->get(),
'route' => $routeVars,
'exception_class' => get_class($exception),
'exception_message' => $exception->getMessage(),
'exception_file' => $exception->getFile(),
'exception_line' => $exception->getLine(),
]);
} catch (Throwable) {
// 避免异常上报链路再抛错,影响原始异常处理。
}
}
}

View File

@@ -3,14 +3,18 @@
declare(strict_types=1);
use app\admin\controller\AdminUser;
use app\admin\controller\Index;
use app\admin\controller\Domain;
use app\admin\controller\SeoSupply;
use app\admin\controller\Novel;
use app\admin\controller\ServerNode;
use app\admin\controller\Site;
use app\admin\controller\SystemConfig;
use app\admin\controller\Video;
use app\admin\middleware\AdminAuth;
use think\facade\Route;
use think\facade\Route;
Route::get("/", [Index::class, "index"])->name("Index@index")->middleware(AdminAuth::class);
Route::group("/guest", function () {
Route::post("/login", "Guest/Login")->name("Guest@Login");
@@ -19,17 +23,22 @@ Route::group("/guest", function () {
# 系统配置
Route::group("/system", function () {
Route::get("/user/info", [AdminUser::class, "info"])->name("AdminUser@info");
Route::get("/user/list", [AdminUser::class, "getAdminUserList"])->name("AdminUser@getAdminUserList");
Route::post("/user/save", [AdminUser::class, "saveAdminUser"])->name("AdminUser@saveAdminUser");
Route::post("/user/del", [AdminUser::class, "delAdminUser"])->name("AdminUser@delAdminUser");
Route::get("/config/list", [SystemConfig::class, "getSystemConfigList"])->name("SystemConfig@getSystemConfigList");
Route::post("/config/save", [SystemConfig::class, "saveSystemConfig"])->name("SystemConfig@saveSystemConfig");
Route::get("/cjpz/list", [SystemConfig::class, "getCaiJiPeiZhiList"])->name("SystemConfig@getCaiJiPeiZhiList");
Route::post("/cjpz/save", [SystemConfig::class, "saveCaiJiPeiZhi"])->name("SystemConfig@saveCaiJiPeiZhi");
Route::group("/system", function () {
Route::get("/user/info", [AdminUser::class, "info"])->name("AdminUser@info");
Route::get("/user/list", [AdminUser::class, "getAdminUserList"])->name("AdminUser@getAdminUserList");
Route::post("/user/save", [AdminUser::class, "saveAdminUser"])->name("AdminUser@saveAdminUser");
Route::post("/user/del", [AdminUser::class, "delAdminUser"])->name("AdminUser@delAdminUser");
Route::get("/config/list", [SystemConfig::class, "getSystemConfigList"])->name("SystemConfig@getSystemConfigList");
Route::post("/config/save", [SystemConfig::class, "saveSystemConfig"])->name("SystemConfig@saveSystemConfig");
Route::post("/config/seo-ai-rules/reset", [SystemConfig::class, "resetSeoAiRules"])->name("SystemConfig@resetSeoAiRules");
Route::get("/storage/file", [SystemConfig::class, "previewStorageFile"])->name("SystemConfig@previewStorageFile");
Route::get("/node/list", [ServerNode::class, "getServerNodeList"])->name("ServerNode@getServerNodeList");
Route::post("/node/save", [ServerNode::class, "saveServerNode"])->name("ServerNode@saveServerNode");
Route::post("/node/del", [ServerNode::class, "delServerNode"])->name("ServerNode@delServerNode");
Route::get("/cjpz/list", [SystemConfig::class, "getCaiJiPeiZhiList"])->name("SystemConfig@getCaiJiPeiZhiList");
Route::post("/cjpz/save", [SystemConfig::class, "saveCaiJiPeiZhi"])->name("SystemConfig@saveCaiJiPeiZhi");
Route::get("/plan/list", [SystemConfig::class, "getPlanTaskList"])->name("SystemConfig@getPlanTaskList");
Route::post("/plan/save", [SystemConfig::class, "savePlanTask"])->name("SystemConfig@savePlanTask");
@@ -44,12 +53,26 @@ Route::group("/novel", function () {
})->middleware(AdminAuth::class);
# 视频
Route::group("/video", function () {
Route::get("/list", [Video::class, "getVideoList"])->name("Domain@getVideoList");
Route::post("/level/set", [Video::class, "updateVideoLevel"])->name("Video@updateVideoLevel");
Route::post("/boxoffice/set", [Video::class, "updateBoxOffice"])->name("Video@updateBoxOffice");
Route::get("/category/list", [Video::class, "getCategory"])->name("Video@getCategory");
})->middleware(AdminAuth::class);
Route::group("/video", function () {
Route::get("/list", [Video::class, "getVideoList"])->name("Domain@getVideoList");
Route::post("/level/set", [Video::class, "updateVideoLevel"])->name("Video@updateVideoLevel");
Route::post("/boxoffice/set", [Video::class, "updateBoxOffice"])->name("Video@updateBoxOffice");
Route::get("/category/list", [Video::class, "getCategory"])->name("Video@getCategory");
Route::get("/metadata/missing/workbench", [Video::class, "getMetadataMissingWorkbenchSummary"])->name("Video@getMetadataMissingWorkbenchSummary");
Route::get("/metadata/missing/workbench/runs", [Video::class, "getMetadataMissingWorkbenchRuns"])->name("Video@getMetadataMissingWorkbenchRuns");
Route::get("/metadata/missing/prompt", [Video::class, "getMetadataMissingCodexPrompt"])->name("Video@getMetadataMissingCodexPrompt");
Route::post("/metadata/missing/workbench/run", [Video::class, "runMetadataMissingWorkbench"])->name("Video@runMetadataMissingWorkbench");
Route::get("/metadata/missing/task-pool", [Video::class, "getMetadataMissingTaskPool"])->name("Video@getMetadataMissingTaskPool");
Route::get("/metadata/missing/task-pool/status", [Video::class, "getMetadataMissingTaskPoolStatus"])->name("Video@getMetadataMissingTaskPoolStatus");
Route::get("/metadata/missing/task-pool/ops", [Video::class, "getMetadataMissingTaskPoolOps"])->name("Video@getMetadataMissingTaskPoolOps");
Route::get("/metadata/missing/task-pool/plan-task/status", [Video::class, "getMetadataMissingTaskPoolPlanTaskStatus"])->name("Video@getMetadataMissingTaskPoolPlanTaskStatus");
Route::get("/metadata/missing/task-pool/batch/detail", [Video::class, "getMetadataMissingTaskPoolBatchDetail"])->name("Video@getMetadataMissingTaskPoolBatchDetail");
Route::get("/metadata/missing/task-pool/batch/prompt", [Video::class, "getMetadataMissingTaskPoolBatchPrompt"])->name("Video@getMetadataMissingTaskPoolBatchPrompt");
Route::get("/metadata/missing/task-pool/batch/history", [Video::class, "getMetadataMissingTaskPoolBatchHistory"])->name("Video@getMetadataMissingTaskPoolBatchHistory");
Route::post("/metadata/missing/task-pool/run", [Video::class, "runMetadataMissingTaskPool"])->name("Video@runMetadataMissingTaskPool");
Route::post("/metadata/missing/task-pool/batch/state/save", [Video::class, "saveMetadataMissingTaskPoolBatchState"])->name("Video@saveMetadataMissingTaskPoolBatchState");
Route::post("/metadata/missing/task-pool/plan-task/status/save", [Video::class, "saveMetadataMissingTaskPoolPlanTaskStatus"])->name("Video@saveMetadataMissingTaskPoolPlanTaskStatus");
})->middleware(AdminAuth::class);
# 广告
Route::group("/guanggao", function () {
@@ -157,9 +180,11 @@ Route::group("/site", function () {
Route::get("/bootstrap/workbench/summary", [Site::class, "getBootstrapWorkbenchSummary"])->name("Site@getBootstrapWorkbenchSummary");
Route::post("/bootstrap/env/template/preview", [Site::class, "previewBootstrapEnvTemplate"])->name("Site@previewBootstrapEnvTemplate");
Route::post("/bootstrap/env/template/apply", [Site::class, "applyBootstrapEnvTemplate"])->name("Site@applyBootstrapEnvTemplate");
Route::post("/bootstrap/env/template/history/restore/preview", [Site::class, "previewBootstrapEnvHistoryRestore"])->name("Site@previewBootstrapEnvHistoryRestore");
Route::post("/bootstrap/env/template/history/restore/apply", [Site::class, "applyBootstrapEnvHistoryRestore"])->name("Site@applyBootstrapEnvHistoryRestore");
# TKD 参数模板
Route::get("/tkdarg/list", [Site::class, "getTKDArgList"])->name("Site@getTKDArgList");
Route::get("/tkdarg/list", [Site::class, "getTKDArgList"])->name("Site@getTKDArgList");
# 视图模板
Route::get("/template/list", [Site::class, "getTemplateList"])->name("Site@getTKDArgList");

View File

@@ -1,12 +1,12 @@
<?php
declare (strict_types = 1);
declare(strict_types=1);
namespace app\admin\controller;
class Index
{
public function index()
public function index(): string
{
return '您好!这是一个[admin]示例应用';
return 'SEONexus Admin frontend has been migrated to the Vue console. Please open the Vue admin entry instead of this PHP page.';
}
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace app\admin\controller;
use app\admin\BaseController;
use app\model\AdminUserModel;
use app\model\ServerNodeModel;
use app\Request;
use think\Response;
class ServerNode extends BaseController
{
/**
* 前端服务器节点列表
*/
public function getServerNodeList(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$arrData = [
'page' => (int)$Request->get('page', 1),
'limit' => (int)$Request->get('limit', 10),
'key' => trim((string)$Request->get('key', '')),
];
$this->validate($arrData, [
'page' => 'require|number',
'limit' => 'require|number',
]);
$ServerNodeModel = ServerNodeModel::alias('sn')
->order('sn.sn_status', 'desc')
->order('sn.updated_at', 'desc')
->order('sn.sn_id', 'desc');
if ($arrData['key'] !== '') {
$strKey = $arrData['key'];
$ServerNodeModel->where(function ($query) use ($strKey) {
$query->whereOr([
['sn.sn_name', 'like', '%' . $strKey . '%'],
['sn.sn_code', 'like', '%' . $strKey . '%'],
['sn.sn_host', 'like', '%' . $strKey . '%'],
['sn.sn_username', 'like', '%' . $strKey . '%'],
['sn.sn_region', 'like', '%' . $strKey . '%'],
['sn.sn_purpose', 'like', '%' . $strKey . '%'],
['sn.sn_project_dir', 'like', '%' . $strKey . '%'],
['sn.sn_log_dir', 'like', '%' . $strKey . '%'],
['sn.sn_nginx_dir', 'like', '%' . $strKey . '%'],
]);
});
}
$Paginate = $ServerNodeModel->paginate([
'list_rows' => $arrData['limit'],
'page' => $arrData['page'],
]);
$arrItems = [];
foreach ($Paginate->items() as $arrItem) {
$arrItems[] = $this->serializeServerNodeRow($arrItem);
}
return $this->success([
'items' => $arrItems,
'total' => $Paginate->total(),
'current_page' => $Paginate->currentPage(),
'total_pages' => $Paginate->lastPage(),
]);
}
/**
* 保存前端服务器节点
*/
public function saveServerNode(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$arrData = [
'sn_id' => $Request->post('sn_id'),
'sn_name' => trim((string)$Request->post('sn_name', '')),
'sn_code' => $this->normalizeNullableString($Request->post('sn_code')),
'sn_host' => trim((string)$Request->post('sn_host', '')),
'sn_port' => (int)$Request->post('sn_port', 22),
'sn_username' => trim((string)$Request->post('sn_username', '')),
'sn_auth_type' => trim((string)$Request->post('sn_auth_type', 'password')),
'sn_password' => trim((string)$Request->post('sn_password', '')),
'sn_private_key' => trim((string)$Request->post('sn_private_key', '')),
'sn_passphrase' => trim((string)$Request->post('sn_passphrase', '')),
'sn_region' => $this->normalizeNullableString($Request->post('sn_region')),
'sn_purpose' => $this->normalizeNullableString($Request->post('sn_purpose')),
'sn_project_dir' => $this->normalizeNullableString($Request->post('sn_project_dir')),
'sn_log_dir' => $this->normalizeNullableString($Request->post('sn_log_dir')),
'sn_nginx_dir' => $this->normalizeNullableString($Request->post('sn_nginx_dir')),
'sn_note' => $this->normalizeNullableString($Request->post('sn_note')),
'sn_status' => (int)$Request->post('sn_status', 1),
];
$this->validate($arrData, [
'sn_name' => 'require|max:255',
'sn_host' => 'require|max:255',
'sn_port' => 'require|number|between:1,65535',
'sn_username' => 'require|max:100',
'sn_auth_type' => 'require|in:password,private_key',
'sn_status' => 'require|in:0,1',
]);
$boolIsCreate = empty($arrData['sn_id']);
if ($boolIsCreate) {
$ServerNodeModel = new ServerNodeModel();
} else {
$ServerNodeModel = ServerNodeModel::where('sn_id', (int)$arrData['sn_id'])->find();
if (empty($ServerNodeModel)) {
return $this->error('9993', '服务器节点不存在');
}
}
if ($arrData['sn_code'] !== null) {
$DuplicateModel = ServerNodeModel::where('sn_code', $arrData['sn_code']);
if (!$boolIsCreate) {
$DuplicateModel->where('sn_id', '<>', (int)$arrData['sn_id']);
}
if ($DuplicateModel->find() !== null) {
return $this->error('9993', '节点编码已存在,请更换后重试');
}
}
$strCurrentAuthType = trim((string)($ServerNodeModel->sn_auth_type ?? ''));
$boolHasExistingPassword = trim((string)($ServerNodeModel->sn_password ?? '')) !== '';
$boolHasExistingPrivateKey = trim((string)($ServerNodeModel->sn_private_key ?? '')) !== '';
if ($arrData['sn_auth_type'] === 'password') {
if ($arrData['sn_password'] === '') {
if ($boolIsCreate || $strCurrentAuthType !== 'password' || !$boolHasExistingPassword) {
return $this->error('9993', '当前登录方式为密码,请填写 SSH 密码');
}
unset($arrData['sn_password']);
}
$arrData['sn_private_key'] = '';
$arrData['sn_passphrase'] = '';
} else {
if ($arrData['sn_private_key'] === '') {
if ($boolIsCreate || $strCurrentAuthType !== 'private_key' || !$boolHasExistingPrivateKey) {
return $this->error('9993', '当前登录方式为私钥,请填写 SSH 私钥');
}
unset($arrData['sn_private_key']);
if ($arrData['sn_passphrase'] === '') {
unset($arrData['sn_passphrase']);
}
}
$arrData['sn_password'] = '';
}
$ServerNodeModel->fill($arrData);
$ServerNodeModel->save();
return $this->success([
'sn_id' => (int)$ServerNodeModel->sn_id,
], $boolIsCreate ? '服务器节点新增成功' : '服务器节点保存成功');
}
/**
* 删除前端服务器节点
*/
public function delServerNode(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$arrData = [
'sn_id' => trim((string)$Request->post('sn_id', '')),
];
$this->validate($arrData, [
'sn_id' => 'require',
]);
$arrIds = array_values(array_unique(array_filter(array_map('intval', explode(',', $arrData['sn_id'])), static function (int $intId): bool {
return $intId > 0;
})));
if ($arrIds === []) {
return $this->error('9993', '未识别到可删除的服务器节点');
}
ServerNodeModel::whereIn('sn_id', $arrIds)->delete();
return $this->success();
}
/**
* 屏蔽列表接口里的敏感 SSH 凭证,只返回配置状态。
*/
protected function serializeServerNodeRow($arrItem): array
{
$arrRow = is_array($arrItem) ? $arrItem : $arrItem->toArray();
$arrRow['sn_port'] = (int)($arrRow['sn_port'] ?? 22);
$arrRow['sn_status'] = (int)($arrRow['sn_status'] ?? 1);
$arrRow['sn_password_configured'] = trim((string)($arrRow['sn_password'] ?? '')) !== '';
$arrRow['sn_private_key_configured'] = trim((string)($arrRow['sn_private_key'] ?? '')) !== '';
$arrRow['sn_passphrase_configured'] = trim((string)($arrRow['sn_passphrase'] ?? '')) !== '';
unset($arrRow['sn_password'], $arrRow['sn_private_key'], $arrRow['sn_passphrase']);
return $arrRow;
}
protected function normalizeNullableString($value): ?string
{
$strValue = trim((string)$value);
return $strValue === '' ? null : $strValue;
}
}

View File

@@ -7,11 +7,15 @@ namespace app\admin\controller;
use app\admin\BaseController;
use app\common\helper\DomainBatchImportTemplateHelper;
use app\common\helper\DomainImportProbeRunHelper;
use app\common\helper\DomainImportAsyncJobHelper;
use app\common\helper\DomainImportFailedQueueHelper;
use app\common\helper\DomainSitemapGenerationHelper;
use app\common\helper\DomainSpiderMdRunHelper;
use app\common\helper\DomainImportHealthTrendHelper;
use app\common\helper\DomainImportRunIndexHelper;
use app\common\helper\DomainImportHealthWorkbenchHelper;
use app\common\helper\DomainImportHealthWorkbenchIndexHelper;
use app\common\helper\DomainImportHealthWorkbenchTrendHelper;
use app\common\helper\DomainImportExternalSeoClosureHelper;
use app\common\helper\DomainImportExternalSeoClosureActionHelper;
use app\common\helper\DomainImportExternalSeoClosureActionIndexHelper;
@@ -22,6 +26,8 @@ use app\common\helper\DomainImportManualAttentionHandleIndexHelper;
use app\common\helper\DomainImportSelfHealingIndexHelper;
use app\common\helper\DomainImportRemediationIndexHelper;
use app\common\helper\DomainImportRerunIndexHelper;
use app\common\helper\DomainImportRerunRunHelper;
use app\common\helper\DomainAutoSampleHelper;
use app\common\helper\DomainExternalSeoSummaryHelper;
use app\common\helper\DomainExternalSeoFailedQueueHelper;
use app\common\helper\DomainExternalSeoTrendHelper;
@@ -57,8 +63,10 @@ use app\common\helper\DomainExternalSeoCollectorStatusHelper;
use app\common\helper\DomainSupplyBatchHelper;
use app\common\helper\DomainSupplyRunIndexHelper;
use app\common\helper\DomainSupplyBatchTemplateHelper;
use app\common\helper\DomainSeoNamingHelper;
use app\common\helper\DomainBootstrapEnvTemplateHelper;
use app\common\helper\DomainSpiderCrawlWorkbenchHelper;
use app\common\helper\DomainTrajectoryProbeHelper;
use app\common\helper\SeoCopyAiProviderHelper;
use app\common\helper\SeoCopyGenerationHelper;
use app\common\helper\SeoCopyPortalHomeHelper;
@@ -67,8 +75,9 @@ use app\common\helper\SeoCopyStore;
use app\common\helper\SpiderMdConfigHelper;
use app\model\AdminUserModel;
use app\model\ConverterMovel;
use app\model\DomainModel;
use app\model\SubjectFomartGroupModel;
use app\model\DomainModel;
use app\model\PlanTaskModel;
use app\model\SubjectFomartGroupModel;
use app\model\SubjectFomartModel;
use app\model\TemplatesModel;
use app\Request;
@@ -1048,7 +1057,7 @@ class Site extends BaseController
protected function domainExternalSeoSummaryRoot(): string
{
return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-seo-external/latest';
return $this->bootstrapCodeRoot() . '/storage/external-seo/latest';
}
protected function domainExternalSeoManualImportRoot(): string
@@ -1058,12 +1067,12 @@ class Site extends BaseController
protected function domainExternalSeoSnapshotRunRoot(): string
{
return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-seo-external/snapshot-runs';
return $this->bootstrapCodeRoot() . '/storage/external-seo/snapshot-runs';
}
protected function domainExternalSeoSnapshotValidateRunRoot(): string
{
return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-seo-external/snapshot-validate-runs';
return $this->bootstrapCodeRoot() . '/storage/external-seo/snapshot-validate-runs';
}
protected function domainExternalSeoStorageRoot(): string
@@ -1113,6 +1122,18 @@ class Site extends BaseController
return $strPath;
}
protected function storageRelativePath(string $strPath): string
{
$strStorageRoot = rtrim($this->bootstrapCodeRoot() . '/storage/', '/');
$strPath = str_replace('\\', '/', $strPath);
$strStorageRoot = str_replace('\\', '/', $strStorageRoot);
if (str_starts_with($strPath, $strStorageRoot . '/')) {
return substr($strPath, strlen($strStorageRoot . '/'));
}
return $strPath;
}
protected function bootstrapTemplateKey(?string $strTemplate): string
{
$strTemplate = strtolower(trim((string)$strTemplate));
@@ -1223,9 +1244,14 @@ class Site extends BaseController
$Spreadsheet = IOFactory::load($FilePath);
$Sheet = $Spreadsheet->getActiveSheet();
$arrData = $Sheet->toArray();
$arrData = $Sheet->toArray(null, true, true, false);
array_shift($arrData);
$arrHeaders = array_map(
static function ($strHeader): string {
return DomainBatchImportTemplateHelper::normalizeHeader(trim((string)$strHeader));
},
(array)array_shift($arrData)
);
$strRunRoot = DomainImportProbeRunHelper::createRunRoot($this->domainImportRunRoot(), 'excel_import');
$strUploadedExcelPath = $strRunRoot . '/uploaded.' . $strExt;
@@ -1245,34 +1271,74 @@ class Site extends BaseController
foreach ($arrData as $arrRows) {
$intRowsTotal++;
$strStrategyProfile = $this->normalizeStrategyProfile($arrRows[19] ?? DomainModel::SEO_STRATEGY_PROFILE_STANDARD);
$strTkdMode = DomainModel::normalizeTkdMode((string)($arrRows[20] ?? ''));
$strTkdProvider = DomainModel::normalizeTkdProvider((string)($arrRows[21] ?? DomainModel::TKD_PROVIDER_LOCAL));
$arrRowAssoc = [];
foreach ($arrHeaders as $index => $strHeader) {
if ($strHeader === '') {
continue;
}
$arrRowAssoc[$strHeader] = trim((string)($arrRows[$index] ?? ''));
}
if (empty(array_filter($arrRowAssoc, static fn($value): bool => trim((string)$value) !== ''))) {
continue;
}
$strStrategyProfile = $this->normalizeStrategyProfile($arrRowAssoc['strategy_profile'] ?? DomainModel::SEO_STRATEGY_PROFILE_STANDARD);
$strRawTkdMode = trim((string)($arrRowAssoc['tkd_mode'] ?? ''));
$strTkdProvider = DomainModel::normalizeTkdProvider((string)($arrRowAssoc['tkd_provider'] ?? DomainModel::TKD_PROVIDER_LOCAL));
$strTkdMode = $strRawTkdMode !== ''
? DomainModel::normalizeTkdMode($strRawTkdMode)
: '';
if ($strTkdMode === '' && $strTkdProvider === DomainModel::TKD_PROVIDER_OPENAI) {
$strTkdMode = DomainModel::TKD_MODE_AI_OPTIMIZE;
}
$arrDomain = [
'd_domain' => $this->normalizeStoredDomain($arrRows[0] ?? '', $arrRows[14] ?? DomainModel::MATCH_TYPE_EXACT),
't_id' => $arrRows[1],
'd_name' => $arrRows[2],
'd_keywords' => $arrRows[3],
'd_index_title' => $arrRows[4],
'd_index_keywords' => $arrRows[5],
'd_index_description' => $arrRows[6],
'd_description' => $arrRows[7],
'd_statis' => $arrRows[8],
'd_logo_type' => (int)$arrRows[9],
'd_text_logo' => $arrRows[10],
'd_img_logo' => $arrRows[11],
'd_content_encode' => $arrRows[12],
'info_id' => $arrRows[13],
'd_match_type' => $this->normalizeMatchType($arrRows[14] ?? DomainModel::MATCH_TYPE_EXACT),
'd_parent_domain' => $this->normalizeParentDomain($arrRows[15] ?? ''),
'd_seed_scope' => $this->normalizeSeedScope($arrRows[16] ?? DomainModel::SEED_SCOPE_DOMAIN),
'd_baidu_token' => $arrRows[17] ?? null,
'd_domain' => $this->normalizeStoredDomain($arrRowAssoc['d_domain'] ?? '', $arrRowAssoc['d_match_type'] ?? DomainModel::MATCH_TYPE_EXACT),
't_id' => $arrRowAssoc['t_id'] ?? 1,
'd_name' => $arrRowAssoc['d_name'] ?? '',
'd_keywords' => $arrRowAssoc['d_keywords'] ?? '',
'd_index_title' => $arrRowAssoc['d_index_title'] ?? '',
'd_index_keywords' => $arrRowAssoc['d_index_keywords'] ?? '',
'd_index_description' => $arrRowAssoc['d_index_description'] ?? '',
'd_description' => $arrRowAssoc['d_description'] ?? '',
'd_statis' => $arrRowAssoc['d_statis'] ?? '',
'd_logo_type' => (int)($arrRowAssoc['d_logo_type'] ?? 0),
'd_text_logo' => $arrRowAssoc['d_text_logo'] ?? '',
'd_img_logo' => $arrRowAssoc['d_img_logo'] ?? '',
'd_content_encode' => $arrRowAssoc['d_content_encode'] ?? 0,
'info_id' => $arrRowAssoc['info_id'] ?? 0,
'd_match_type' => $this->normalizeMatchType($arrRowAssoc['d_match_type'] ?? DomainModel::MATCH_TYPE_EXACT),
'd_parent_domain' => $this->normalizeParentDomain($arrRowAssoc['d_parent_domain'] ?? ''),
'd_seed_scope' => $this->normalizeSeedScope($arrRowAssoc['d_seed_scope'] ?? DomainModel::SEED_SCOPE_DOMAIN),
'd_baidu_token' => $arrRowAssoc['d_baidu_token'] ?? null,
];
$arrDomain['d_seo_cfg'] = $this->applySeoStrategyProfileForDomainData($arrRows[18] ?? null, $arrDomain, $strStrategyProfile);
if (DomainSeoNamingHelper::shouldOptimizeForOpenAi($strTkdProvider, $strTkdMode)) {
$arrSeoDefaults = DomainSeoNamingHelper::buildSeoDefaults(
(string)($arrDomain['d_name'] ?? ''),
(string)($arrDomain['d_domain'] ?? ''),
(string)($arrDomain['d_index_title'] ?? ''),
(string)($arrDomain['d_index_keywords'] ?? ''),
(string)($arrDomain['d_index_description'] ?? '')
);
$arrDomain['d_name'] = $arrSeoDefaults['site_name'];
$arrDomain['d_index_title'] = $arrSeoDefaults['index_title'];
$arrDomain['d_index_keywords'] = $arrSeoDefaults['index_keywords'];
$arrDomain['d_index_description'] = $arrSeoDefaults['index_description'];
if (trim((string)($arrDomain['d_keywords'] ?? '')) === '') {
$arrDomain['d_keywords'] = $arrSeoDefaults['index_keywords'];
}
if (trim((string)($arrDomain['d_description'] ?? '')) === '') {
$arrDomain['d_description'] = $arrSeoDefaults['index_description'];
}
if (trim((string)($arrDomain['d_text_logo'] ?? '')) === '') {
$arrDomain['d_text_logo'] = $arrSeoDefaults['site_name'];
}
}
$arrDomain['d_seo_cfg'] = $this->applySeoStrategyProfileForDomainData($arrRowAssoc['d_seo_cfg'] ?? null, $arrDomain, $strStrategyProfile);
$arrDomain['d_seo_cfg']['tkd'] = array_merge(
(array)($arrDomain['d_seo_cfg']['tkd'] ?? []),
[
'mode' => $strTkdMode !== '' ? $strTkdMode : ((trim((string)($arrRows[4] ?? '')) !== '' || trim((string)($arrRows[5] ?? '')) !== '' || trim((string)($arrRows[6] ?? '')) !== '') ? DomainModel::TKD_MODE_FORCE_IMPORT : DomainModel::TKD_MODE_AUTO_GENERATE),
'mode' => $strTkdMode !== '' ? $strTkdMode : ((trim((string)($arrRowAssoc['d_index_title'] ?? '')) !== '' || trim((string)($arrRowAssoc['d_index_keywords'] ?? '')) !== '' || trim((string)($arrRowAssoc['d_index_description'] ?? '')) !== '') ? DomainModel::TKD_MODE_FORCE_IMPORT : DomainModel::TKD_MODE_AUTO_GENERATE),
'provider' => $strTkdProvider,
]
);
@@ -1324,11 +1390,16 @@ class Site extends BaseController
$DomainModel->save();
SeoCopyGenerationHelper::initializeForDomain($DomainModel);
SeoResourcePoolHelper::initSitePositioning((string)$DomainModel->d_domain, 1, true);
DomainSitemapGenerationHelper::queueForDomains([(string)$DomainModel->d_domain]);
$intInsertedCount++;
$arrInsertedDomains[] = [
'd_id' => (int)$DomainModel->d_id,
'd_domain' => (string)$DomainModel->d_domain,
'd_name' => (string)$DomainModel->d_name,
't_id' => (int)$DomainModel->t_id,
'strategy_profile' => $strStrategyProfile,
'tkd_provider' => $strTkdProvider,
'tkd_mode' => (string)($arrDomain['d_seo_cfg']['tkd']['mode'] ?? ''),
];
$arrInsertedHosts[] = (string)$DomainModel->d_domain;
$arrStrategyProfileCounts[$strStrategyProfile] = (int)($arrStrategyProfileCounts[$strStrategyProfile] ?? 0) + 1;
@@ -1349,6 +1420,8 @@ class Site extends BaseController
]);
}
$arrPostImportWorkflow = $this->buildPostImportWorkflow($arrInsertedDomains, $arrImportProbeSummary);
return $this->success([
'rows_total' => $intRowsTotal,
'inserted_count' => $intInsertedCount,
@@ -1367,6 +1440,9 @@ class Site extends BaseController
'probe_failed_count' => (int)($arrImportProbeSummary['failed_count'] ?? 0),
'summary_json_path' => $this->publicRelativePath((string)($arrImportProbeSummary['summary_json_path'] ?? '')),
'summary_html_path' => $this->publicRelativePath((string)($arrImportProbeSummary['summary_html_path'] ?? '')),
'post_import_workflow' => $arrPostImportWorkflow,
'next_step_notice' => array_values((array)($arrPostImportWorkflow['notices'] ?? [])),
'codex_continue_prompt' => (string)($arrPostImportWorkflow['codex']['prompt'] ?? ''),
]);
} catch (\Exception $e) {
return $this->error("9999", '导入失败: ' . $e->getMessage());
@@ -1378,11 +1454,15 @@ class Site extends BaseController
try {
$arrTemplate = DomainBatchImportTemplateHelper::writeTemplate($this->domainImportTemplateRoot());
$strRelativePath = str_replace($this->bootstrapCodeRoot() . '/public/', '', $arrTemplate['file_path']);
$arrSummary = (array)($arrTemplate['summary'] ?? []);
return $this->success([
'excel_template_path' => $strRelativePath,
'template_file_name' => $arrTemplate['file_name'],
'template_summary' => $arrTemplate['summary'],
'template_summary' => $arrSummary,
'template_display_headers' => array_values((array)($arrSummary['display_headers'] ?? [])),
'template_operator_notice' => array_values((array)($arrSummary['operator_notice'] ?? [])),
'template_filling_guidance' => (array)($arrSummary['filling_guidance'] ?? []),
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', $Throwable->getMessage());
@@ -1394,11 +1474,15 @@ class Site extends BaseController
try {
$arrTemplate = DomainSupplyBatchTemplateHelper::writeTemplate($this->domainSupplyTemplateRoot());
$strRelativePath = $this->publicRelativePath((string)$arrTemplate['file_path']);
$arrSummary = (array)($arrTemplate['summary'] ?? []);
return $this->success([
'excel_template_path' => $strRelativePath,
'template_file_name' => $arrTemplate['file_name'],
'template_summary' => $arrTemplate['summary'],
'template_summary' => $arrSummary,
'template_display_headers' => array_values((array)($arrSummary['display_headers'] ?? [])),
'template_operator_notice' => array_values((array)($arrSummary['operator_notice'] ?? [])),
'template_filling_guidance' => (array)($arrSummary['filling_guidance'] ?? []),
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '生成一体化供给模板失败:' . $Throwable->getMessage());
@@ -1512,9 +1596,17 @@ class Site extends BaseController
$intWindowHours,
$strBotScope,
$mixSelectedBots
));
))->header([
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma' => 'no-cache',
'Expires' => '0',
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取蜘蛛抓取概览失败:' . $Throwable->getMessage());
return $this->error('9999', '读取蜘蛛抓取概览失败:' . $Throwable->getMessage())->header([
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma' => 'no-cache',
'Expires' => '0',
]);
}
}
@@ -2100,8 +2192,8 @@ class Site extends BaseController
$intLimit
);
$arrSummary = DomainExternalSeoSummaryHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
@@ -2135,8 +2227,8 @@ class Site extends BaseController
$intLimit
);
$arrSummary = DomainExternalSeoTrendHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
@@ -2210,11 +2302,11 @@ class Site extends BaseController
public function getDomainExternalSeoSearchConsolePropertyMapTemplate(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$strOutputDir = $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-seo-external/search-console';
$strOutputDir = $this->bootstrapCodeRoot() . '/storage/external-seo/search-console';
$arrTemplate = DomainExternalSeoSearchConsoleConfigHelper::writeTemplate($strOutputDir);
return $this->success([
'file_name' => (string)($arrTemplate['file_name'] ?? ''),
'file_path' => $this->publicRelativePath((string)($arrTemplate['file_path'] ?? '')),
'file_path' => $this->storageRelativePath((string)($arrTemplate['file_path'] ?? '')),
'summary' => (array)($arrTemplate['summary'] ?? []),
]);
} catch (\Throwable $Throwable) {
@@ -2254,8 +2346,8 @@ class Site extends BaseController
'mapped_hosts_count' => (int)($arrResult['mapped_hosts_count'] ?? 0),
'provider_summary' => $arrProviderSummary,
'real_summary_status' => (string)($arrRealSummary['status'] ?? ''),
'summary_json_path' => $this->publicRelativePath((string)($arrRealSummary['summary_json_path'] ?? '')),
'summary_html_path' => $this->publicRelativePath((string)($arrRealSummary['summary_html_path'] ?? '')),
'summary_json_path' => $this->storageRelativePath((string)($arrRealSummary['summary_json_path'] ?? '')),
'summary_html_path' => $this->storageRelativePath((string)($arrRealSummary['summary_html_path'] ?? '')),
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '上传 Search Console property map 失败:' . $Throwable->getMessage());
@@ -2268,8 +2360,8 @@ class Site extends BaseController
$intDays = max(1, min(30, (int)$Request->get('days', 7)));
$arrSummary = DomainExternalSeoSearchConsoleProviderPlanHelper::buildSummary($this->bootstrapCodeRoot(), $intDays);
$arrSummary = DomainExternalSeoSearchConsoleProviderPlanHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取 Search Console provider 计划失败:' . $Throwable->getMessage());
@@ -2282,8 +2374,8 @@ class Site extends BaseController
$intDays = max(1, min(30, (int)$Request->get('days', 7)));
$arrSummary = DomainExternalSeoSearchConsoleFetchHelper::buildSummary($this->bootstrapCodeRoot(), $intDays);
$arrSummary = DomainExternalSeoSearchConsoleFetchHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取 Search Console fetch 摘要失败:' . $Throwable->getMessage());
@@ -2295,8 +2387,8 @@ class Site extends BaseController
try {
$arrSummary = DomainExternalSeoBaiduProviderPlanHelper::buildSummary($this->bootstrapCodeRoot());
$arrSummary = DomainExternalSeoBaiduProviderPlanHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取百度 provider 计划失败:' . $Throwable->getMessage());
@@ -2309,8 +2401,8 @@ class Site extends BaseController
$intLimit = max(1, min(200, (int)$Request->get('limit', 50)));
$arrSummary = DomainExternalSeoBaiduPushSummaryHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit);
$arrSummary = DomainExternalSeoBaiduPushSummaryHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取百度推送观察摘要失败:' . $Throwable->getMessage());
@@ -2359,8 +2451,8 @@ class Site extends BaseController
$intLimit = max(1, min(300, (int)$Request->get('limit', 100)));
$arrQueue = DomainExternalSeoBaiduPushAttentionQueueHelper::buildQueue($this->bootstrapCodeRoot(), $intLimit);
$arrQueue = DomainExternalSeoBaiduPushAttentionQueueHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrQueue);
$arrQueue['summary_json_path'] = $this->publicRelativePath((string)($arrQueue['summary_json_path'] ?? ''));
$arrQueue['summary_html_path'] = $this->publicRelativePath((string)($arrQueue['summary_html_path'] ?? ''));
$arrQueue['summary_json_path'] = $this->storageRelativePath((string)($arrQueue['summary_json_path'] ?? ''));
$arrQueue['summary_html_path'] = $this->storageRelativePath((string)($arrQueue['summary_html_path'] ?? ''));
return $this->success($arrQueue);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取百度推送异常队列失败:' . $Throwable->getMessage());
@@ -2373,8 +2465,8 @@ class Site extends BaseController
$intLimit = max(1, min(100, (int)$Request->get('limit', 20)));
$arrSummary = DomainExternalSeoBaiduPushTrendHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit);
$arrSummary = DomainExternalSeoBaiduPushTrendHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取百度推送趋势摘要失败:' . $Throwable->getMessage());
@@ -2387,8 +2479,8 @@ class Site extends BaseController
$intLimit = max(1, min(100, (int)$Request->get('limit', 20)));
$arrSummary = DomainExternalSeoBaiduFeedbackSummaryHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit);
$arrSummary = DomainExternalSeoBaiduFeedbackSummaryHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取百度反馈摘要失败:' . $Throwable->getMessage());
@@ -2401,8 +2493,8 @@ class Site extends BaseController
$intLimit = max(1, min(100, (int)$Request->get('limit', 20)));
$arrSummary = DomainExternalSeoSiteQueryProbeHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit);
$arrSummary = DomainExternalSeoSiteQueryProbeHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取百度 site: 收录探测失败:' . $Throwable->getMessage());
@@ -2429,8 +2521,8 @@ class Site extends BaseController
$intLimit = max(1, min(300, (int)$Request->get('limit', 100)));
$arrQueue = DomainExternalSeoAizhanKeywordAttentionQueueHelper::buildQueue($this->bootstrapCodeRoot(), $intLimit);
$arrQueue = DomainExternalSeoAizhanKeywordAttentionQueueHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrQueue);
$arrQueue['summary_json_path'] = $this->publicRelativePath((string)($arrQueue['summary_json_path'] ?? ''));
$arrQueue['summary_html_path'] = $this->publicRelativePath((string)($arrQueue['summary_html_path'] ?? ''));
$arrQueue['summary_json_path'] = $this->storageRelativePath((string)($arrQueue['summary_json_path'] ?? ''));
$arrQueue['summary_html_path'] = $this->storageRelativePath((string)($arrQueue['summary_html_path'] ?? ''));
return $this->success($arrQueue);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取爱站关键词异常队列失败:' . $Throwable->getMessage());
@@ -2443,8 +2535,8 @@ class Site extends BaseController
$intLimit = max(1, min(100, (int)$Request->get('limit', 20)));
$arrSummary = DomainExternalSeoAizhanKeywordTrendHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit);
$arrSummary = DomainExternalSeoAizhanKeywordTrendHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取爱站关键词趋势摘要失败:' . $Throwable->getMessage());
@@ -2488,40 +2580,40 @@ class Site extends BaseController
$arrResult = DomainExternalSeoSnapshotHelper::ingestSnapshots((array)$arrSnapshots, $strSource);
$strRunRoot = DomainExternalSeoSnapshotRunHelper::createRunRoot($this->domainExternalSeoSnapshotRunRoot(), 'snapshot_push');
$arrResult = DomainExternalSeoSnapshotRunHelper::writeRunArtifacts(
$this->bootstrapCodeRoot() . '/public',
$this->bootstrapCodeRoot() . '/storage',
$strRunRoot,
$arrResult,
(array)$arrSnapshots
);
$arrResult['run_root'] = $this->publicRelativePath($strRunRoot);
$arrResult['summary_json_path'] = $this->publicRelativePath((string)($arrResult['summary_json_path'] ?? ''));
$arrResult['summary_html_path'] = $this->publicRelativePath((string)($arrResult['summary_html_path'] ?? ''));
$arrResult['payload_json_path'] = $this->publicRelativePath((string)($arrResult['payload_json_path'] ?? ''));
$arrResult['run_root'] = $this->storageRelativePath($strRunRoot);
$arrResult['summary_json_path'] = $this->storageRelativePath((string)($arrResult['summary_json_path'] ?? ''));
$arrResult['summary_html_path'] = $this->storageRelativePath((string)($arrResult['summary_html_path'] ?? ''));
$arrResult['payload_json_path'] = $this->storageRelativePath((string)($arrResult['payload_json_path'] ?? ''));
$arrOverview = DomainExternalSeoCnOverviewHelper::buildOverview(14, 10);
$arrOverview = DomainExternalSeoCnOverviewHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrOverview);
$arrOverview['summary_json_path'] = $this->publicRelativePath((string)($arrOverview['summary_json_path'] ?? ''));
$arrOverview['summary_html_path'] = $this->publicRelativePath((string)($arrOverview['summary_html_path'] ?? ''));
$arrOverview['summary_json_path'] = $this->storageRelativePath((string)($arrOverview['summary_json_path'] ?? ''));
$arrOverview['summary_html_path'] = $this->storageRelativePath((string)($arrOverview['summary_html_path'] ?? ''));
$arrSnapshotTrend = DomainExternalSeoSnapshotTrendHelper::buildSummary($this->domainExternalSeoSnapshotRunRoot(), 10);
$arrSnapshotTrend = DomainExternalSeoSnapshotTrendHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSnapshotTrend);
$arrSnapshotTrend['summary_json_path'] = $this->publicRelativePath((string)($arrSnapshotTrend['summary_json_path'] ?? ''));
$arrSnapshotTrend['summary_html_path'] = $this->publicRelativePath((string)($arrSnapshotTrend['summary_html_path'] ?? ''));
$arrSnapshotTrend['summary_json_path'] = $this->storageRelativePath((string)($arrSnapshotTrend['summary_json_path'] ?? ''));
$arrSnapshotTrend['summary_html_path'] = $this->storageRelativePath((string)($arrSnapshotTrend['summary_html_path'] ?? ''));
$arrResult['post_ingest_overview'] = $arrOverview;
$arrResult['post_ingest_snapshot_trend'] = $arrSnapshotTrend;
$arrResult = DomainExternalSeoSnapshotRunHelper::writeRunArtifacts(
$this->bootstrapCodeRoot() . '/public',
$this->bootstrapCodeRoot() . '/storage',
$strRunRoot,
$arrResult,
(array)$arrSnapshots
);
$arrResult['summary_json_path'] = $this->publicRelativePath((string)($arrResult['summary_json_path'] ?? ''));
$arrResult['summary_html_path'] = $this->publicRelativePath((string)($arrResult['summary_html_path'] ?? ''));
$arrResult['payload_json_path'] = $this->publicRelativePath((string)($arrResult['payload_json_path'] ?? ''));
$arrResult['summary_json_path'] = $this->storageRelativePath((string)($arrResult['summary_json_path'] ?? ''));
$arrResult['summary_html_path'] = $this->storageRelativePath((string)($arrResult['summary_html_path'] ?? ''));
$arrResult['payload_json_path'] = $this->storageRelativePath((string)($arrResult['payload_json_path'] ?? ''));
$arrSnapshotTrend = DomainExternalSeoSnapshotTrendHelper::buildSummary($this->domainExternalSeoSnapshotRunRoot(), 10);
$arrSnapshotTrend = DomainExternalSeoSnapshotTrendHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSnapshotTrend);
$arrSnapshotTrend['summary_json_path'] = $this->publicRelativePath((string)($arrSnapshotTrend['summary_json_path'] ?? ''));
$arrSnapshotTrend['summary_html_path'] = $this->publicRelativePath((string)($arrSnapshotTrend['summary_html_path'] ?? ''));
$arrSnapshotTrend['summary_json_path'] = $this->storageRelativePath((string)($arrSnapshotTrend['summary_json_path'] ?? ''));
$arrSnapshotTrend['summary_html_path'] = $this->storageRelativePath((string)($arrSnapshotTrend['summary_html_path'] ?? ''));
$arrResult['post_ingest_snapshot_trend'] = $arrSnapshotTrend;
return $this->success($arrResult);
} catch (\Throwable $Throwable) {
@@ -2571,15 +2663,15 @@ class Site extends BaseController
$arrResult = DomainExternalSeoSnapshotSchemaHelper::validateSnapshots((array)$arrSnapshots);
$strRunRoot = DomainExternalSeoSnapshotValidateRunHelper::createRunRoot($this->domainExternalSeoSnapshotValidateRunRoot(), 'snapshot_validate');
$arrResult = DomainExternalSeoSnapshotValidateRunHelper::writeRunArtifacts(
$this->bootstrapCodeRoot() . '/public',
$this->bootstrapCodeRoot() . '/storage',
$strRunRoot,
$arrResult,
(array)$arrSnapshots
);
$arrResult['run_root'] = $this->publicRelativePath($strRunRoot);
$arrResult['summary_json_path'] = $this->publicRelativePath((string)($arrResult['summary_json_path'] ?? ''));
$arrResult['summary_html_path'] = $this->publicRelativePath((string)($arrResult['summary_html_path'] ?? ''));
$arrResult['payload_json_path'] = $this->publicRelativePath((string)($arrResult['payload_json_path'] ?? ''));
$arrResult['run_root'] = $this->storageRelativePath($strRunRoot);
$arrResult['summary_json_path'] = $this->storageRelativePath((string)($arrResult['summary_json_path'] ?? ''));
$arrResult['summary_html_path'] = $this->storageRelativePath((string)($arrResult['summary_html_path'] ?? ''));
$arrResult['payload_json_path'] = $this->storageRelativePath((string)($arrResult['payload_json_path'] ?? ''));
return $this->success($arrResult);
} catch (\Throwable $Throwable) {
@@ -2864,8 +2956,8 @@ class Site extends BaseController
$intDays = max(1, min(30, (int)$Request->get('days', 7)));
$arrSummary = DomainExternalSeoRealSummaryHelper::buildSummary($this->bootstrapCodeRoot(), $intDays);
$arrSummary = DomainExternalSeoRealSummaryHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取真实站外 summary 骨架失败:' . $Throwable->getMessage());
@@ -2878,7 +2970,7 @@ class Site extends BaseController
$arrTemplate = DomainExternalSeoManualImportHelper::writeTemplate($this->domainExternalSeoManualImportRoot());
return $this->success([
'file_name' => (string)($arrTemplate['file_name'] ?? ''),
'file_path' => $this->publicRelativePath((string)($arrTemplate['file_path'] ?? '')),
'file_path' => $this->storageRelativePath((string)($arrTemplate['file_path'] ?? '')),
'summary' => $arrTemplate['summary'] ?? [],
]);
} catch (\Throwable $Throwable) {
@@ -2929,20 +3021,20 @@ class Site extends BaseController
}
return $this->success([
'uploaded_excel_path' => $this->publicRelativePath($strUploadedPath),
'uploaded_report_path' => $this->publicRelativePath($strUploadedPath),
'csv_path' => $this->publicRelativePath((string)($arrResult['csv_path'] ?? '')),
'run_root' => $this->publicRelativePath($strRunRoot),
'run_csv_path' => $this->publicRelativePath((string)($arrResult['run_csv_path'] ?? '')),
'run_uploaded_report_path' => $this->publicRelativePath($strRunUploadedPath),
'uploaded_excel_path' => $this->storageRelativePath($strUploadedPath),
'uploaded_report_path' => $this->storageRelativePath($strUploadedPath),
'csv_path' => $this->storageRelativePath((string)($arrResult['csv_path'] ?? '')),
'run_root' => $this->storageRelativePath($strRunRoot),
'run_csv_path' => $this->storageRelativePath((string)($arrResult['run_csv_path'] ?? '')),
'run_uploaded_report_path' => $this->storageRelativePath($strRunUploadedPath),
'rows_total' => (int)($arrResult['rows_total'] ?? 0),
'imported_count' => (int)($arrResult['imported_count'] ?? 0),
'real_summary_status' => (string)($arrRealSummary['status'] ?? ''),
'real_metrics_available' => !empty($arrRealSummary['real_metrics_available']),
'real_summary_provider' => (string)($arrRealSummary['provider_key'] ?? ''),
'metrics' => (array)($arrRealSummary['metrics'] ?? []),
'summary_json_path' => $this->publicRelativePath((string)($arrRealSummary['summary_json_path'] ?? '')),
'summary_html_path' => $this->publicRelativePath((string)($arrRealSummary['summary_html_path'] ?? '')),
'summary_json_path' => $this->storageRelativePath((string)($arrRealSummary['summary_json_path'] ?? '')),
'summary_html_path' => $this->storageRelativePath((string)($arrRealSummary['summary_html_path'] ?? '')),
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '导入站外手工报表失败:' . $Throwable->getMessage());
@@ -3148,10 +3240,282 @@ class Site extends BaseController
'reasons' => array_values(array_unique($arrReasons)),
];
}
/**
* 添加|修改 域名
protected function buildPostImportWorkflow(array $arrInsertedDomains, array $arrImportProbeSummary = []): array
{
$arrInsertedDomains = array_values(array_filter(
$arrInsertedDomains,
static fn ($arrItem): bool => is_array($arrItem) && !empty($arrItem['d_domain'])
));
if (empty($arrInsertedDomains)) {
return [
'enabled' => false,
'notices' => [],
];
}
$arrHosts = array_values(array_unique(array_filter(array_map(
static fn (array $arrItem): string => DomainModel::normalizeHost((string)($arrItem['d_domain'] ?? '')),
$arrInsertedDomains
))));
$arrIds = array_values(array_unique(array_filter(array_map(
static fn (array $arrItem): int => (int)($arrItem['d_id'] ?? 0),
$arrInsertedDomains
))));
$arrOpenAiDomains = array_values(array_filter($arrInsertedDomains, static function (array $arrItem): bool {
return DomainSeoNamingHelper::shouldOptimizeForOpenAi(
(string)($arrItem['tkd_provider'] ?? ''),
(string)($arrItem['tkd_mode'] ?? '')
);
}));
$boolContainsOpenAiDomains = !empty($arrOpenAiDomains);
$strProbeStatus = (string)($arrImportProbeSummary['status'] ?? 'skipped');
$strProbeSummaryHtmlPath = $this->publicRelativePath((string)($arrImportProbeSummary['summary_html_path'] ?? ''));
$strProbeSummaryJsonPath = $this->publicRelativePath((string)($arrImportProbeSummary['summary_json_path'] ?? ''));
$arrArtifactAudit = $this->buildPostImportArtifactAudit($arrInsertedDomains);
$arrNotices = [
'这批域名已经导入成功,但系统不会直接自动开始 AI 生成,请先确认页面探测结果。',
'如果由运营继续处理,请先看探测摘要,再手动点击后台 AI 生成功能。',
'如果由技术继续处理,请复制 Codex 提示词,把这批域名交给 Codex 接管。',
];
if ($strProbeStatus !== '') {
$arrNotices[] = '本批次导入探测状态:' . $strProbeStatus . '。建议先确认首页、分类页、详情页、播放页链路正常,再进入下一步。';
}
if ($boolContainsOpenAiDomains) {
$arrNotices[] = '这批域名里包含 OpenAI 模式站点,默认建议运营走后台 AI 按钮,技术走 Codex 接管。';
}
return [
'enabled' => true,
'auto_ai_after_import' => false,
'summary' => '导入已完成。请先看探测结果,再明确选择“运营继续”或“技术继续”,系统不会自动偷偷调用 OpenAI。',
'inserted_domain_count' => count($arrInsertedDomains),
'inserted_domain_hosts' => $arrHosts,
'contains_openai_domains' => $boolContainsOpenAiDomains,
'artifact_audit' => $arrArtifactAudit,
'recommended_for_operator' => 'openai_continue',
'recommended_for_technical' => 'codex_continue',
'notices' => $arrNotices,
'choices' => [
[
'key' => 'openai_continue',
'label' => 'OpenAI继续',
'audience' => '运营',
'description' => '适合运营同学。先确认探测正常,再把这批新站提交到后台 AI 生成队列。',
],
[
'key' => 'codex_continue',
'label' => 'Codex继续',
'audience' => '技术',
'description' => '适合技术同学。复制提示词给 Codex由 Codex 接手 SEO 优化、复测、记录和收口。',
],
],
'openai' => [
'domain_ids' => $arrIds,
'domain_id_csv' => implode(',', $arrIds),
'hosts' => $arrHosts,
'probe_status' => $strProbeStatus,
'probe_summary_html_path' => $strProbeSummaryHtmlPath,
'probe_summary_json_path' => $strProbeSummaryJsonPath,
'operator_steps' => [
'先打开本次导入探测摘要,确认首页、分类页、详情页、播放页没有明显报错。',
'确认页面链路正常后,回到后台站点列表,筛选或勾选本批域名。',
'如果这批站点准备走运营 AI 链路,再点击 AI 生成或 AI 重优化按钮,显式触发 OpenAI 队列。',
],
],
'codex' => [
'workspace_path' => $this->bootstrapCodeRoot(),
'open_in_vscode_hint' => '请先在 VS Code 打开当前项目工作区,再把下面的提示词完整发给 Codex。Codex 接手时不会自动消耗 OpenAI API。',
'prompt' => $this->buildPostImportCodexPrompt($arrInsertedDomains, $arrImportProbeSummary),
],
'ui_suggestion' => [
'message' => '建议在导入成功弹窗里直接展示两个按钮Codex继续、OpenAI继续并支持一键复制 Codex 提示词。',
'button_labels' => ['Codex继续', 'OpenAI继续', '复制Codex提示词'],
],
];
}
protected function buildPostImportArtifactAudit(array $arrInsertedDomains): array
{
$arrInsertedDomains = array_values(array_filter(
$arrInsertedDomains,
static fn ($arrItem): bool => is_array($arrItem) && !empty($arrItem['d_domain'])
));
if (empty($arrInsertedDomains)) {
return [
'summary' => [
'total' => 0,
'state_ready_count' => 0,
'manifest_ready_count' => 0,
'positioning_ready_count' => 0,
'published_copy_ready_count' => 0,
'sitemap_generated_count' => 0,
'ai_pending_count' => 0,
],
'items' => [],
];
}
$arrItems = [];
$intStateReadyCount = 0;
$intManifestReadyCount = 0;
$intPositioningReadyCount = 0;
$intPublishedCopyReadyCount = 0;
$intSitemapGeneratedCount = 0;
$intAiPendingCount = 0;
foreach ($arrInsertedDomains as $arrItem) {
$strHost = DomainModel::normalizeHost((string)($arrItem['d_domain'] ?? ''));
if ($strHost === '') {
continue;
}
$strHostKey = SeoCopyStore::buildPageKey($strHost, 'host');
$strStatePath = SeoCopyGenerationHelper::resolveStatePath($strHost);
$strManifestPath = SeoCopyGenerationHelper::resolveManifestPath($strHost);
$strPositioningPath = SeoResourcePoolHelper::resourceRoot() . '/site_positioning/by_host/' . SeoResourcePoolHelper::hostFileKey($strHost) . '.json';
$strPublishedRoot = rtrim(SeoCopyStore::publishedRoot(), '/') . '/' . $strHostKey;
$strSitemapRoot = rtrim((string)root_path('storage/SiteMap'), '/');
$strSitemapDir = $strSitemapRoot . '/' . $strHost;
$boolStateExists = is_file($strStatePath);
$boolManifestExists = is_file($strManifestPath);
$boolPositioningExists = is_file($strPositioningPath);
$boolPublishedCopyExists = !empty(glob($strPublishedRoot . '/*/*.json') ?: []);
$arrState = $boolStateExists ? SeoCopyGenerationHelper::readState($strHost) : [];
$strAiStatus = (string)($arrState['status'] ?? '');
$boolAiPending = $strAiStatus === SeoCopyGenerationHelper::STATUS_AI_PENDING;
$boolWaitForAi = !empty($arrState['wait_for_ai_before_publish']);
$arrSitemapCandidates = [
$strSitemapDir . '/sitemap_index.xml',
$strSitemapDir . '/sitemap-main.xml',
$strSitemapDir . '/sitemap-videos-1.xml',
];
$arrExistingSitemaps = array_values(array_filter($arrSitemapCandidates, static fn (string $strPath): bool => is_file($strPath)));
$boolSitemapGenerated = !empty($arrExistingSitemaps);
if ($boolStateExists) {
$intStateReadyCount++;
}
if ($boolManifestExists) {
$intManifestReadyCount++;
}
if ($boolPositioningExists) {
$intPositioningReadyCount++;
}
if ($boolPublishedCopyExists) {
$intPublishedCopyReadyCount++;
}
if ($boolSitemapGenerated) {
$intSitemapGeneratedCount++;
}
if ($boolAiPending) {
$intAiPendingCount++;
}
$arrItems[] = [
'host' => $strHost,
'site_name' => (string)($arrItem['d_name'] ?? ''),
'state_exists' => $boolStateExists,
'manifest_exists' => $boolManifestExists,
'site_positioning_exists' => $boolPositioningExists,
'published_copy_exists' => $boolPublishedCopyExists,
'ai_status' => $strAiStatus !== '' ? $strAiStatus : 'unknown',
'wait_for_ai_before_publish' => $boolWaitForAi,
'sitemap_status' => $boolSitemapGenerated ? 'generated' : 'queued_pending',
'existing_sitemaps' => array_map(static fn (string $strPath): string => basename($strPath), $arrExistingSitemaps),
'notes' => [
$boolStateExists ? 'state.json 已生成' : 'state.json 缺失',
$boolManifestExists ? 'manifest.json 已生成' : 'manifest.json 缺失',
$boolPositioningExists ? 'site_positioning 已生成' : 'site_positioning 缺失',
$boolPublishedCopyExists ? 'starter copy 已发布' : 'starter copy 未落盘',
$boolSitemapGenerated ? '已检测到 sitemap 文件' : '已请求 sitemap 生成,当前尚未看到落盘文件',
],
];
}
return [
'summary' => [
'total' => count($arrItems),
'state_ready_count' => $intStateReadyCount,
'manifest_ready_count' => $intManifestReadyCount,
'positioning_ready_count' => $intPositioningReadyCount,
'published_copy_ready_count' => $intPublishedCopyReadyCount,
'sitemap_generated_count' => $intSitemapGeneratedCount,
'ai_pending_count' => $intAiPendingCount,
],
'items' => $arrItems,
];
}
protected function buildPostImportCodexPrompt(array $arrInsertedDomains, array $arrImportProbeSummary = []): string
{
$arrLines = [
'请接手这批刚导入的新站,并继续完成导入后的 SEO 收口工作。',
'',
'本批域名:',
];
foreach ($arrInsertedDomains as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$strDomain = DomainModel::normalizeHost((string)($arrItem['d_domain'] ?? ''));
if ($strDomain === '') {
continue;
}
$arrLines[] = sprintf(
'- %s | 站点名=%s | 模板ID=%d | TKD来源=%s | TKD模式=%s | 策略=%s',
$strDomain,
trim((string)($arrItem['d_name'] ?? '')) !== '' ? (string)$arrItem['d_name'] : '未命名',
(int)($arrItem['t_id'] ?? 0),
(string)($arrItem['tkd_provider'] ?? DomainModel::TKD_PROVIDER_LOCAL),
(string)($arrItem['tkd_mode'] ?? DomainModel::TKD_MODE_AUTO_GENERATE),
(string)($arrItem['strategy_profile'] ?? DomainModel::SEO_STRATEGY_PROFILE_STANDARD)
);
}
$strProbeStatus = (string)($arrImportProbeSummary['status'] ?? 'skipped');
$strProbeSummaryHtmlPath = $this->publicRelativePath((string)($arrImportProbeSummary['summary_html_path'] ?? ''));
$strProbeSummaryJsonPath = $this->publicRelativePath((string)($arrImportProbeSummary['summary_json_path'] ?? ''));
$arrLines[] = '';
$arrLines[] = '已知背景:';
$arrLines[] = '- 导入成功后,不要默认静默调用 OpenAI API。';
$arrLines[] = '- 如果后台明确点击 AI 优化,再走 OpenAI 队列;否则由 Codex 直接接管人工优化。';
$arrLines[] = '- 请优先以 SEO 效果、可收录、可复测、可持续跟踪为目标。';
$arrLines[] = '';
$arrLines[] = '请执行:';
$arrLines[] = '1. 复查首页、搜索页、分类页、详情页、播放页是否可访问,确认没有 404、500 或模板错位。';
$arrLines[] = '2. 检查导入后的自动产物是否齐全,包括 SEO copy、探测报告、sitemap 队列、站点定位资源。';
$arrLines[] = '3. 如果这批站点配置为 OpenAI 模式,但当前是 Codex 接管,请直接做人审优化,不要自动消耗 API。';
$arrLines[] = '4. 输出这批站点的 Day0 基线,包含标题、关键词、描述、收录入口、主要风险。';
$arrLines[] = '5. 把处理记录写到 GPT 模板专属文档目录,并在公共主线文档里做简短汇总。';
if ($strProbeStatus !== '' || $strProbeSummaryHtmlPath !== '' || $strProbeSummaryJsonPath !== '') {
$arrLines[] = '';
$arrLines[] = '导入探测信息:';
$arrLines[] = '- probe_status: ' . ($strProbeStatus !== '' ? $strProbeStatus : 'skipped');
if ($strProbeSummaryHtmlPath !== '') {
$arrLines[] = '- probe_summary_html_path: ' . $strProbeSummaryHtmlPath;
}
if ($strProbeSummaryJsonPath !== '') {
$arrLines[] = '- probe_summary_json_path: ' . $strProbeSummaryJsonPath;
}
}
return implode(PHP_EOL, $arrLines);
}
/**
* 添加|修改 域名
*
* @param Request $Request
* @param AdminUserModel|null $AdminUserModel
@@ -3223,6 +3587,7 @@ class Site extends BaseController
$DomainModel->save();
DomainModel::flushAllDomianOnCache();
SeoCopyGenerationHelper::initializeForDomain($DomainModel);
DomainSitemapGenerationHelper::queueForDomains([(string)$DomainModel->d_domain]);
return $this->success();
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace app\admin\controller;
use app\admin\BaseController;
use app\common\helper\SeoCopyAiRuleConfigHelper;
use app\common\helper\SpiderMdConfigHelper;
use app\model\AdminUserModel;
use app\model\BaoYangRuZhuModel;
use app\model\CaiJiPeiZhiModel;
@@ -23,6 +25,44 @@ use think\Response;
class SystemConfig extends BaseController
{
public function previewStorageFile(Request $Request, ?AdminUserModel $AdminUserModel)
{
$strRelativePath = trim(str_replace('\\', '/', (string)$Request->get('path', '')));
if ($strRelativePath === '') {
return response('storage path is required', 400, ['Content-Type' => 'text/plain; charset=UTF-8']);
}
$strRelativePath = ltrim($strRelativePath, '/');
if (str_starts_with($strRelativePath, 'storage/')) {
$strRelativePath = substr($strRelativePath, strlen('storage/'));
}
$strStorageRoot = rtrim(str_replace('\\', '/', (string)root_path()), '/') . '/storage';
$strStorageRootReal = realpath($strStorageRoot);
$strCandidatePath = $strStorageRoot . '/' . $strRelativePath;
$strCandidateReal = realpath($strCandidatePath);
if ($strStorageRootReal === false || $strCandidateReal === false || !is_file($strCandidateReal)) {
return response('storage file not found', 404, ['Content-Type' => 'text/plain; charset=UTF-8']);
}
$strStorageRootReal = str_replace('\\', '/', $strStorageRootReal);
$strCandidateReal = str_replace('\\', '/', $strCandidateReal);
if (!str_starts_with($strCandidateReal, $strStorageRootReal . '/')) {
return response('storage file is out of allowed root', 403, ['Content-Type' => 'text/plain; charset=UTF-8']);
}
$strMimeType = (string)(mime_content_type($strCandidateReal) ?: 'application/octet-stream');
$strFilename = basename($strCandidateReal);
$strContent = (string)file_get_contents($strCandidateReal);
return response($strContent, 200, [
'Content-Type' => $strMimeType,
'Content-Disposition' => 'inline; filename="' . addslashes($strFilename) . '"',
'X-Storage-Relative-Path' => $strRelativePath,
]);
}
/**
* 采集配置列表
@@ -120,6 +160,9 @@ class SystemConfig extends BaseController
*/
public function getSystemConfigList(Request $Request, ?AdminUserModel $AdminUserModel)
{
SeoCopyAiRuleConfigHelper::ensureSystemConfigDefaults();
SpiderMdConfigHelper::ensureSystemConfigDefaults();
$arrData = [
"page" => $Request->get('page', 1),
"limit" => $Request->get('limit', 10),
@@ -196,6 +239,25 @@ class SystemConfig extends BaseController
return $this->success();
}
public function resetSeoAiRules(Request $Request, ?AdminUserModel $AdminUserModel)
{
$arrData = [
'sc_code' => trim((string)$Request->post('sc_code', '')),
];
if ($arrData['sc_code'] !== '' && !in_array($arrData['sc_code'], SeoCopyAiRuleConfigHelper::editableCodes(), true)) {
return $this->error('9993', '无效的 SEO AI 规则编码');
}
$arrResult = SeoCopyAiRuleConfigHelper::resetToDefault($arrData['sc_code'] !== '' ? $arrData['sc_code'] : null);
return $this->success([
'codes' => $arrResult['codes'] ?? [],
'count' => (int)($arrResult['count'] ?? 0),
'scope' => $arrData['sc_code'] !== '' ? 'single' : 'all',
]);
}
/**
* 资源端点列表

View File

@@ -5,7 +5,13 @@ declare(strict_types=1);
namespace app\admin\controller;
use app\admin\BaseController;
use app\common\helper\VideoMetadataMissingWorkbenchHelper;
use app\common\helper\VideoMetadataMissingWorkbenchIndexHelper;
use app\common\helper\VideoMetadataMissingTaskPoolHelper;
use app\common\helper\VideoMetadataMissingTaskPoolStateHelper;
use app\common\helper\VideoMetadataMissingTaskPoolStatusHelper;
use app\model\AdminUserModel;
use app\model\PlanTaskModel;
use app\model\VideoModel;
use app\model\VideoCategoryModel;
@@ -131,4 +137,363 @@ class Video extends BaseController
$arrResult = VideoCategoryModel::$arrCategory;
return $this->success($arrResult);
}
public function getMetadataMissingWorkbenchSummary(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$boolRefresh = (int)$Request->get('refresh', 0) === 1;
$intSample = max(1, min(100, (int)$Request->get('sample', 20)));
$intQueueLimit = max(1, min(500, (int)$Request->get('queue_limit', 100)));
$intPromptLimit = max(1, min(100, (int)$Request->get('prompt_limit', 20)));
$arrSummary = $boolRefresh ? [] : VideoMetadataMissingWorkbenchHelper::readLatestSummary($this->metadataMissingWorkbenchRoot());
if (empty($arrSummary)) {
$arrSummary = VideoMetadataMissingWorkbenchHelper::buildSummary($intSample, $intQueueLimit, $intPromptLimit);
$arrSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($this->metadataMissingWorkbenchRoot(), $arrSummary);
}
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['prompt_markdown_path'] = $this->storageRelativePath((string)($arrSummary['prompt_markdown_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取视频缺失字段工作台失败:' . $Throwable->getMessage());
}
}
public function getMetadataMissingCodexPrompt(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$arrSummary = VideoMetadataMissingWorkbenchHelper::readLatestSummary($this->metadataMissingWorkbenchRoot());
if (empty($arrSummary)) {
$intSample = max(1, min(100, (int)$Request->get('sample', 20)));
$intQueueLimit = max(1, min(500, (int)$Request->get('queue_limit', 100)));
$intPromptLimit = max(1, min(100, (int)$Request->get('prompt_limit', 20)));
$arrSummary = VideoMetadataMissingWorkbenchHelper::buildSummary($intSample, $intQueueLimit, $intPromptLimit);
$arrSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($this->metadataMissingWorkbenchRoot(), $arrSummary);
}
return $this->success([
'prompt' => (string)($arrSummary['codex_dispatch_prompt'] ?? ''),
'prompt_markdown_path' => $this->storageRelativePath((string)($arrSummary['prompt_markdown_path'] ?? '')),
'generated_at' => (string)($arrSummary['generated_at'] ?? ''),
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取视频缺失字段提示词失败:' . $Throwable->getMessage());
}
}
public function getMetadataMissingWorkbenchRuns(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$intLimit = max(1, min(50, (int)$Request->get('limit', 20)));
$arrSummary = VideoMetadataMissingWorkbenchIndexHelper::buildSummary($this->metadataMissingWorkbenchRunRoot(), $intLimit);
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取视频缺失字段工作台运行记录失败:' . $Throwable->getMessage());
}
}
public function getMetadataMissingTaskPool(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$intBatchSize = max(5, min(100, (int)$Request->get('batch_size', 20)));
$intBatchLimit = max(1, min(50, (int)$Request->get('batch_limit', 10)));
$boolRefresh = (int)$Request->get('refresh', 0) === 1;
$arrSummary = $boolRefresh ? [] : VideoMetadataMissingTaskPoolHelper::readLatestSummary($this->metadataMissingTaskPoolRoot());
if (empty($arrSummary)) {
$arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::readLatestSummary($this->metadataMissingWorkbenchRoot());
if (empty($arrWorkbenchSummary)) {
$arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::buildSummary(20, 100, 20);
$arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($this->metadataMissingWorkbenchRoot(), $arrWorkbenchSummary);
}
$arrSummary = VideoMetadataMissingTaskPoolHelper::buildSummary($arrWorkbenchSummary, $intBatchSize, $intBatchLimit);
$arrSummary = VideoMetadataMissingTaskPoolHelper::writeArtifacts($this->metadataMissingTaskPoolRoot(), $arrSummary);
}
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取视频缺字段待处理池失败:' . $Throwable->getMessage());
}
}
public function getMetadataMissingTaskPoolStatus(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$arrSummary = VideoMetadataMissingTaskPoolStatusHelper::buildSummary(
$this->metadataMissingWorkbenchRoot(),
$this->metadataMissingTaskPoolRoot()
);
$arrSummary['workbench']['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['workbench']['summary_json_path'] ?? ''));
$arrSummary['workbench']['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['workbench']['summary_html_path'] ?? ''));
$arrSummary['task_pool']['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['task_pool']['summary_json_path'] ?? ''));
$arrSummary['task_pool']['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['task_pool']['summary_html_path'] ?? ''));
return $this->success($arrSummary);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取视频缺字段任务池状态失败:' . $Throwable->getMessage());
}
}
public function getMetadataMissingTaskPoolOps(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$arrSummary = VideoMetadataMissingTaskPoolStatusHelper::buildSummary(
$this->metadataMissingWorkbenchRoot(),
$this->metadataMissingTaskPoolRoot()
);
$arrSummary['workbench']['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['workbench']['summary_json_path'] ?? ''));
$arrSummary['workbench']['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['workbench']['summary_html_path'] ?? ''));
$arrSummary['task_pool']['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['task_pool']['summary_json_path'] ?? ''));
$arrSummary['task_pool']['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['task_pool']['summary_html_path'] ?? ''));
return $this->success([
'summary' => $arrSummary,
'ops' => VideoMetadataMissingTaskPoolStatusHelper::buildOpsSummary($arrSummary),
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取视频缺字段任务池运维页失败:' . $Throwable->getMessage());
}
}
public function getMetadataMissingTaskPoolPlanTaskStatus(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
return $this->success(VideoMetadataMissingTaskPoolStatusHelper::buildSummary(
$this->metadataMissingWorkbenchRoot(),
$this->metadataMissingTaskPoolRoot()
));
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取视频缺字段计划任务状态失败:' . $Throwable->getMessage());
}
}
public function saveMetadataMissingTaskPoolPlanTaskStatus(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$arrData = [
'pt_enable' => (int)$Request->post('pt_enable', 0),
'pt_limit' => (int)$Request->post('pt_limit', 86400),
];
$this->validate($arrData, [
'pt_enable' => 'require|number|in:0,1',
'pt_limit' => 'require|number|egt:0',
]);
$PlanTaskModel = PlanTaskModel::where('pt_code', 'REFRESH_VIDEO_METADATA_TASK_POOL')->find();
if (!$PlanTaskModel) {
return $this->error('9999', '未找到 REFRESH_VIDEO_METADATA_TASK_POOL 任务配置,请先执行 plan:seed:video-metadata-missing');
}
$PlanTaskModel->pt_enable = $arrData['pt_enable'];
$PlanTaskModel->pt_limit = $arrData['pt_limit'];
$PlanTaskModel->save();
return $this->success(VideoMetadataMissingTaskPoolStatusHelper::buildSummary(
$this->metadataMissingWorkbenchRoot(),
$this->metadataMissingTaskPoolRoot()
));
} catch (\Throwable $Throwable) {
return $this->error('9999', '保存视频缺字段计划任务状态失败:' . $Throwable->getMessage());
}
}
public function getMetadataMissingTaskPoolBatchDetail(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$strBatchId = trim((string)$Request->get('batch_id', ''));
if ($strBatchId === '') {
return $this->error('9999', 'batch_id 不能为空');
}
$arrSummary = VideoMetadataMissingTaskPoolHelper::readLatestSummary($this->metadataMissingTaskPoolRoot());
if (empty($arrSummary)) {
return $this->error('9999', '当前还没有可用的缺字段待处理池');
}
$arrBatch = VideoMetadataMissingTaskPoolHelper::findBatchById($arrSummary, $strBatchId);
if (empty($arrBatch)) {
return $this->error('9999', '没有找到对应批次');
}
return $this->success([
'batch' => $arrBatch,
'history' => VideoMetadataMissingTaskPoolStateHelper::readBatchHistory(
$this->metadataMissingTaskPoolRoot(),
$strBatchId,
50
),
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取视频缺字段批次详情失败:' . $Throwable->getMessage());
}
}
public function getMetadataMissingTaskPoolBatchPrompt(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$strBatchId = trim((string)$Request->get('batch_id', ''));
if ($strBatchId === '') {
return $this->error('9999', 'batch_id 不能为空');
}
$arrSummary = VideoMetadataMissingTaskPoolHelper::readLatestSummary($this->metadataMissingTaskPoolRoot());
if (empty($arrSummary)) {
return $this->error('9999', '当前还没有可用的缺字段待处理池');
}
$arrBatch = VideoMetadataMissingTaskPoolHelper::findBatchById($arrSummary, $strBatchId);
if (empty($arrBatch)) {
return $this->error('9999', '没有找到对应批次');
}
return $this->success([
'batch_id' => $strBatchId,
'batch_label' => (string)($arrBatch['batch_label'] ?? ''),
'focus_field' => (string)($arrBatch['focus_field'] ?? ''),
'prompt' => (string)($arrBatch['codex_prompt'] ?? ''),
'status' => (string)($arrBatch['status'] ?? ''),
'status_label' => (string)($arrBatch['status_label'] ?? ''),
'owner' => (string)($arrBatch['owner'] ?? ''),
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取视频缺字段批次提示词失败:' . $Throwable->getMessage());
}
}
public function runMetadataMissingTaskPool(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$intBatchSize = max(5, min(100, (int)$Request->post('batch_size', 20)));
$intBatchLimit = max(1, min(50, (int)$Request->post('batch_limit', 10)));
$arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::readLatestSummary($this->metadataMissingWorkbenchRoot());
if (empty($arrWorkbenchSummary)) {
$arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::buildSummary(20, 100, 20);
$arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($this->metadataMissingWorkbenchRoot(), $arrWorkbenchSummary);
}
$arrSummary = VideoMetadataMissingTaskPoolHelper::buildSummary($arrWorkbenchSummary, $intBatchSize, $intBatchLimit);
$arrSummary = VideoMetadataMissingTaskPoolHelper::writeArtifacts($this->metadataMissingTaskPoolRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
return $this->success([
'status' => 'success',
'summary' => $arrSummary,
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '生成视频缺字段待处理池失败:' . $Throwable->getMessage());
}
}
public function saveMetadataMissingTaskPoolBatchState(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$strBatchId = trim((string)$Request->post('batch_id', ''));
$strStatus = trim((string)$Request->post('status', VideoMetadataMissingTaskPoolStateHelper::STATUS_PENDING));
$strOwner = trim((string)$Request->post('owner', ''));
$strNote = trim((string)$Request->post('note', ''));
if ($strBatchId === '') {
return $this->error('9999', 'batch_id 不能为空');
}
$arrState = VideoMetadataMissingTaskPoolStateHelper::saveBatchState(
$this->metadataMissingTaskPoolRoot(),
$strBatchId,
$strStatus,
$strOwner,
$strNote
);
$arrSummary = VideoMetadataMissingTaskPoolHelper::readLatestSummary($this->metadataMissingTaskPoolRoot());
return $this->success([
'state' => $arrState,
'status_label' => VideoMetadataMissingTaskPoolStateHelper::statusLabel((string)($arrState['status'] ?? 'pending')),
'summary' => $arrSummary,
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '保存视频缺字段批次状态失败:' . $Throwable->getMessage());
}
}
public function getMetadataMissingTaskPoolBatchHistory(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$strBatchId = trim((string)$Request->get('batch_id', ''));
$intLimit = max(1, min(200, (int)$Request->get('limit', 50)));
if ($strBatchId === '') {
return $this->error('9999', 'batch_id 不能为空');
}
return $this->success([
'batch_id' => $strBatchId,
'items' => VideoMetadataMissingTaskPoolStateHelper::readBatchHistory(
$this->metadataMissingTaskPoolRoot(),
$strBatchId,
$intLimit
),
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '读取视频缺字段批次历史失败:' . $Throwable->getMessage());
}
}
public function runMetadataMissingWorkbench(Request $Request, ?AdminUserModel $AdminUserModel)
{
try {
$intSample = max(1, min(100, (int)$Request->post('sample', 20)));
$intQueueLimit = max(1, min(500, (int)$Request->post('queue_limit', 100)));
$intPromptLimit = max(1, min(100, (int)$Request->post('prompt_limit', 20)));
$arrSummary = VideoMetadataMissingWorkbenchHelper::buildSummary($intSample, $intQueueLimit, $intPromptLimit);
$arrSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($this->metadataMissingWorkbenchRoot(), $arrSummary);
$arrSummary['summary_json_path'] = $this->storageRelativePath((string)($arrSummary['summary_json_path'] ?? ''));
$arrSummary['summary_html_path'] = $this->storageRelativePath((string)($arrSummary['summary_html_path'] ?? ''));
$arrSummary['prompt_markdown_path'] = $this->storageRelativePath((string)($arrSummary['prompt_markdown_path'] ?? ''));
return $this->success([
'status' => 'success',
'summary' => $arrSummary,
]);
} catch (\Throwable $Throwable) {
return $this->error('9999', '生成视频缺失字段工作台失败:' . $Throwable->getMessage());
}
}
protected function metadataMissingWorkbenchRoot(): string
{
return rtrim((string)root_path(), '/') . '/storage/video-metadata-missing/workbench';
}
protected function metadataMissingWorkbenchRunRoot(): string
{
return $this->metadataMissingWorkbenchRoot() . '/runs';
}
protected function metadataMissingTaskPoolRoot(): string
{
return rtrim((string)root_path(), '/') . '/storage/video-metadata-missing/task-pool';
}
protected function storageRelativePath(string $strPath): string
{
$strStorageRoot = rtrim(str_replace('\\', '/', rtrim((string)root_path(), '/') . '/storage/'), '/');
$strPath = str_replace('\\', '/', $strPath);
if ($strPath !== '' && str_starts_with($strPath, $strStorageRoot . '/')) {
return substr($strPath, strlen($strStorageRoot . '/'));
}
return $strPath;
}
}

View File

@@ -16,7 +16,8 @@ class AdminCors extends AllowCrossDomain
protected $header = [
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => 3600,
'Access-Control-Allow-Methods' => '*',
'Access-Control-Allow-Headers' => '*',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => 'Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-CSRF-TOKEN, X-Requested-With, admin-token',
'Vary' => 'Origin',
];
}

View File

@@ -35,17 +35,17 @@ class SeoCollector extends BaseController
protected function summaryRoot(): string
{
return $this->publicRoot() . '/_admin_templates/domain-seo-external/latest';
return $this->storageRoot() . '/external-seo/latest';
}
protected function snapshotRunRoot(): string
{
return $this->publicRoot() . '/_admin_templates/domain-seo-external/snapshot-runs';
return $this->storageRoot() . '/external-seo/snapshot-runs';
}
protected function snapshotValidateRunRoot(): string
{
return $this->publicRoot() . '/_admin_templates/domain-seo-external/snapshot-validate-runs';
return $this->storageRoot() . '/external-seo/snapshot-validate-runs';
}
protected function spiderCrawlLatestRoot(): string
@@ -138,15 +138,15 @@ class SeoCollector extends BaseController
$result = DomainExternalSeoSnapshotSchemaHelper::validateSnapshots((array)$snapshots);
$runRoot = DomainExternalSeoSnapshotValidateRunHelper::createRunRoot($this->snapshotValidateRunRoot(), 'snapshot_validate');
$result = DomainExternalSeoSnapshotValidateRunHelper::writeRunArtifacts(
$this->publicRoot(),
$this->storageRoot(),
$runRoot,
$result,
(array)$snapshots
);
$result['run_root'] = $this->publicRelativePath($runRoot);
$result['summary_json_path'] = $this->publicRelativePath((string)($result['summary_json_path'] ?? ''));
$result['summary_html_path'] = $this->publicRelativePath((string)($result['summary_html_path'] ?? ''));
$result['payload_json_path'] = $this->publicRelativePath((string)($result['payload_json_path'] ?? ''));
$result['run_root'] = $this->storageRelativePath($runRoot);
$result['summary_json_path'] = $this->storageRelativePath((string)($result['summary_json_path'] ?? ''));
$result['summary_html_path'] = $this->storageRelativePath((string)($result['summary_html_path'] ?? ''));
$result['payload_json_path'] = $this->storageRelativePath((string)($result['payload_json_path'] ?? ''));
return $this->success($result);
} catch (\Throwable $throwable) {
@@ -166,37 +166,37 @@ class SeoCollector extends BaseController
$result = DomainExternalSeoSnapshotHelper::ingestSnapshots((array)$snapshots, $source);
$runRoot = DomainExternalSeoSnapshotRunHelper::createRunRoot($this->snapshotRunRoot(), 'snapshot_push');
$result = DomainExternalSeoSnapshotRunHelper::writeRunArtifacts(
$this->publicRoot(),
$this->storageRoot(),
$runRoot,
$result,
(array)$snapshots
);
$result['run_root'] = $this->publicRelativePath($runRoot);
$result['summary_json_path'] = $this->publicRelativePath((string)($result['summary_json_path'] ?? ''));
$result['summary_html_path'] = $this->publicRelativePath((string)($result['summary_html_path'] ?? ''));
$result['payload_json_path'] = $this->publicRelativePath((string)($result['payload_json_path'] ?? ''));
$result['run_root'] = $this->storageRelativePath($runRoot);
$result['summary_json_path'] = $this->storageRelativePath((string)($result['summary_json_path'] ?? ''));
$result['summary_html_path'] = $this->storageRelativePath((string)($result['summary_html_path'] ?? ''));
$result['payload_json_path'] = $this->storageRelativePath((string)($result['payload_json_path'] ?? ''));
$overview = DomainExternalSeoCnOverviewHelper::buildOverview(14, 10);
$overview = DomainExternalSeoCnOverviewHelper::writeArtifacts($this->summaryRoot(), $overview);
$overview['summary_json_path'] = $this->publicRelativePath((string)($overview['summary_json_path'] ?? ''));
$overview['summary_html_path'] = $this->publicRelativePath((string)($overview['summary_html_path'] ?? ''));
$overview['summary_json_path'] = $this->storageRelativePath((string)($overview['summary_json_path'] ?? ''));
$overview['summary_html_path'] = $this->storageRelativePath((string)($overview['summary_html_path'] ?? ''));
$snapshotTrend = DomainExternalSeoSnapshotTrendHelper::buildSummary($this->snapshotRunRoot(), 10);
$snapshotTrend = DomainExternalSeoSnapshotTrendHelper::writeArtifacts($this->summaryRoot(), $snapshotTrend);
$snapshotTrend['summary_json_path'] = $this->publicRelativePath((string)($snapshotTrend['summary_json_path'] ?? ''));
$snapshotTrend['summary_html_path'] = $this->publicRelativePath((string)($snapshotTrend['summary_html_path'] ?? ''));
$snapshotTrend['summary_json_path'] = $this->storageRelativePath((string)($snapshotTrend['summary_json_path'] ?? ''));
$snapshotTrend['summary_html_path'] = $this->storageRelativePath((string)($snapshotTrend['summary_html_path'] ?? ''));
$result['post_ingest_overview'] = $overview;
$result['post_ingest_snapshot_trend'] = $snapshotTrend;
$result = DomainExternalSeoSnapshotRunHelper::writeRunArtifacts(
$this->publicRoot(),
$this->storageRoot(),
$runRoot,
$result,
(array)$snapshots
);
$result['summary_json_path'] = $this->publicRelativePath((string)($result['summary_json_path'] ?? ''));
$result['summary_html_path'] = $this->publicRelativePath((string)($result['summary_html_path'] ?? ''));
$result['payload_json_path'] = $this->publicRelativePath((string)($result['payload_json_path'] ?? ''));
$result['summary_json_path'] = $this->storageRelativePath((string)($result['summary_json_path'] ?? ''));
$result['summary_html_path'] = $this->storageRelativePath((string)($result['summary_html_path'] ?? ''));
$result['payload_json_path'] = $this->storageRelativePath((string)($result['payload_json_path'] ?? ''));
return $this->success($result);
} catch (\Throwable $throwable) {

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\model\PlanTaskModel;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class DomainSpiderMdPlanTaskSeedCommand extends Command
{
protected function configure()
{
$this->setName('plan:seed:domain-spider-md')
->setDescription('补齐蜘蛛池MD自动生成计划任务 GENERATE_DOMAIN_SPIDER_MD');
}
protected function execute(Input $input, Output $output)
{
$strCode = 'GENERATE_DOMAIN_SPIDER_MD';
$arrData = [
'pt_name' => '蜘蛛池MD自动生成',
'pt_code' => $strCode,
'pt_enable' => 0,
'pt_limit' => 24 * 3600,
'pt_last_exec' => 0,
];
$PlanTaskModel = PlanTaskModel::where('pt_code', $strCode)->find();
if ($PlanTaskModel instanceof PlanTaskModel) {
$output->writeln('计划任务已存在,无需重复创建:' . $strCode);
$output->writeln('pt_id' . (int)$PlanTaskModel->pt_id);
$output->writeln('pt_name' . (string)$PlanTaskModel->pt_name);
$output->writeln('pt_enable' . (int)$PlanTaskModel->pt_enable);
$output->writeln('pt_limit' . (int)$PlanTaskModel->pt_limit);
return 0;
}
$intId = (int)PlanTaskModel::insertGetId($arrData);
$output->writeln('计划任务已创建:' . $strCode);
$output->writeln('pt_id' . $intId);
$output->writeln('pt_name' . $arrData['pt_name']);
$output->writeln('pt_enable' . $arrData['pt_enable']);
$output->writeln('pt_limit' . $arrData['pt_limit']);
return 0;
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\model\VideoModel;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
class VideoMetadataAuditCommand extends Command
{
protected function configure()
{
$this->setName('video:metadata:audit')
->addOption('sample', null, Option::VALUE_OPTIONAL, '抽样输出条数,默认 20', 20)
->setDescription('审计视频库缺失字段情况,并生成历史回填分析文件');
}
protected function execute(Input $input, Output $output)
{
$intSample = max(1, (int)$input->getOption('sample'));
$arrSummary = VideoModel::getInstance()->buildMissingMetadataAuditSummary($intSample);
$strRoot = rtrim((string)root_path(), '/');
$strOutputRoot = $strRoot . '/storage/video-metadata-audit';
if (!is_dir($strOutputRoot)) {
@mkdir($strOutputRoot, 0777, true);
}
$strJsonPath = $strOutputRoot . '/latest.json';
$strMarkdownPath = $strOutputRoot . '/latest.md';
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL);
file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary));
$output->writeln('视频元数据缺失审计已生成');
$output->writeln('总视频数:' . (int)($arrSummary['total_videos'] ?? 0));
$output->writeln('存在任一缺失字段的视频数:' . (int)($arrSummary['videos_with_any_missing_metadata'] ?? 0));
$output->writeln('JSON' . $strJsonPath);
$output->writeln('Markdown' . $strMarkdownPath);
foreach ((array)($arrSummary['field_stats'] ?? []) as $arrField) {
$output->writeln(sprintf(
'%s => 缺失 %d 条,占比 %.2f%%',
(string)($arrField['field'] ?? ''),
(int)($arrField['missing_count'] ?? 0),
((float)($arrField['missing_ratio'] ?? 0)) * 100
));
}
return 0;
}
protected function buildMarkdown(array $arrSummary): string
{
$arrLines = [];
$arrLines[] = '# 视频元数据缺失审计';
$arrLines[] = '';
$arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? '');
$arrLines[] = '- 总视频数:' . (int)($arrSummary['total_videos'] ?? 0);
$arrLines[] = '- 任一缺失字段视频数:' . (int)($arrSummary['videos_with_any_missing_metadata'] ?? 0);
$arrLines[] = '';
$arrLines[] = '## 字段缺失统计';
$arrLines[] = '';
$arrLines[] = '| 字段 | 缺失数 | 缺失占比 | 建议动作 |';
$arrLines[] = '| --- | ---: | ---: | --- |';
foreach ((array)($arrSummary['field_stats'] ?? []) as $arrField) {
$strField = (string)($arrField['field'] ?? '');
$intMissingCount = (int)($arrField['missing_count'] ?? 0);
$floatRatio = ((float)($arrField['missing_ratio'] ?? 0)) * 100;
$arrLines[] = sprintf(
'| %s | %d | %.2f%% | %s |',
$strField,
$intMissingCount,
$floatRatio,
$this->buildSuggestedAction($strField)
);
}
$arrLines[] = '';
$arrLines[] = '## 抽样样本';
$arrLines[] = '';
$arrLines[] = '| v_id | 片名 | 分类 | 年份 | 备注 | 缺失字段 | 更新时间 |';
$arrLines[] = '| ---: | --- | --- | --- | --- | --- | --- |';
foreach ((array)($arrSummary['samples'] ?? []) as $arrSample) {
$arrLines[] = sprintf(
'| %d | %s | %s | %s | %s | %s | %s |',
(int)($arrSample['v_id'] ?? 0),
$this->escapeMarkdown((string)($arrSample['v_name'] ?? '')),
$this->escapeMarkdown((string)($arrSample['v_category'] ?? '')),
$this->escapeMarkdown((string)($arrSample['v_year'] ?? '')),
$this->escapeMarkdown((string)($arrSample['v_remarks'] ?? '')),
$this->escapeMarkdown(implode(', ', (array)($arrSample['missing_fields'] ?? []))),
$this->escapeMarkdown((string)($arrSample['updated_at'] ?? ''))
);
}
$arrLines[] = '';
$arrLines[] = '## 判读原则';
$arrLines[] = '';
$arrLines[] = '1. 演员、导演、年份、地区、语言这类结构化字段,优先靠重采、补源、人工校正,不能让 AI 猜。';
$arrLines[] = '2. 简介、备注这类文案型字段,如果事实基础足够,可以走 AI 生成并写回视频库,但必须只补空字段。';
$arrLines[] = '3. 后续采集器已接入“只补空字段、不覆盖已有值”的写库逻辑,新采到的有效字段会逐步沉淀进库。';
$arrLines[] = '';
return implode(PHP_EOL, $arrLines) . PHP_EOL;
}
protected function buildSuggestedAction(string $strField): string
{
return match ($strField) {
'v_actor', 'v_director', 'v_year', 'v_lang', 'v_lang_en', 'v_area', 'v_area_en', 'v_publish_date'
=> '优先重采或补源,不建议 AI 猜测',
'v_description', 'v_remarks'
=> '可在事实边界内走 AI 补全并写库',
default => '先审计,再决定重采或补写',
};
}
protected function escapeMarkdown(string $strValue): string
{
$strValue = trim($strValue);
if ($strValue === '') {
return '';
}
return str_replace('|', '\\|', $strValue);
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\helper\VideoMetadataCopyFillHelper;
use app\model\VideoModel;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
class VideoMetadataCopyFillCommand extends Command
{
protected function configure()
{
$this->setName('video:metadata:fill-copy')
->addOption('limit', null, Option::VALUE_OPTIONAL, '本次最多处理多少条,默认 500', 500)
->addOption('dry-run', null, Option::VALUE_NONE, '只预览,不写库')
->setDescription('批量补写视频库里为空的简介和备注,只补空字段');
}
protected function execute(Input $input, Output $output)
{
$intLimit = max(1, min((int)$input->getOption('limit'), 5000));
$boolDryRun = (bool)$input->getOption('dry-run');
$VideoModel = VideoModel::getInstance();
$Cursor = $VideoModel->getCol()->find(
[
'$or' => [
['v_description' => ['$exists' => false]],
['v_description' => ''],
['v_description' => null],
['v_remarks' => ['$exists' => false]],
['v_remarks' => ''],
['v_remarks' => null],
],
],
[
'limit' => $intLimit,
'sort' => ['updated_at' => -1, 'v_id' => -1],
'projection' => [
'_id' => 0,
'v_id' => 1,
'v_name' => 1,
'v_category' => 1,
'v_parent_category' => 1,
'v_year' => 1,
'v_area' => 1,
'v_lang' => 1,
'v_director' => 1,
'v_actor' => 1,
'v_remarks' => 1,
'v_description' => 1,
'v_isend' => 1,
'v_publish_date' => 1,
],
'typeMap' => VideoModel::$arrOptions['typeMap'],
]
);
$intScanned = 0;
$intUpdated = 0;
$arrSamples = [];
foreach ($Cursor as $arrVideo) {
$intScanned++;
$arrUpdate = VideoMetadataCopyFillHelper::buildFillPayload((array)$arrVideo, 'local_copy_fill_command');
if (empty($arrUpdate)) {
continue;
}
$arrSamples[] = [
'v_id' => (int)($arrVideo['v_id'] ?? 0),
'v_name' => (string)($arrVideo['v_name'] ?? ''),
'filled_fields' => (array)($arrUpdate['v_metadata_fill']['filled_fields'] ?? []),
'generated_description' => (string)($arrUpdate['v_description'] ?? ''),
'generated_remarks' => (string)($arrUpdate['v_remarks'] ?? ''),
];
if (!$boolDryRun) {
$VideoModel->updateOne(
['v_id' => (int)($arrVideo['v_id'] ?? 0)],
['$set' => $arrUpdate]
);
}
$intUpdated++;
}
$output->writeln('视频文案型元数据补写完成');
$output->writeln('模式:' . ($boolDryRun ? 'dry-run' : 'write'));
$output->writeln('扫描条数:' . $intScanned);
$output->writeln('命中可补写条数:' . $intUpdated);
foreach (array_slice($arrSamples, 0, 5) as $arrSample) {
$output->writeln(sprintf(
'#%d %s => %s',
(int)($arrSample['v_id'] ?? 0),
(string)($arrSample['v_name'] ?? ''),
implode(', ', (array)($arrSample['filled_fields'] ?? []))
));
}
return 0;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\model\PlanTaskModel;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class VideoMetadataMissingPlanTaskSeedCommand extends Command
{
protected function configure()
{
$this->setName('plan:seed:video-metadata-missing')
->setDescription('补齐视频缺字段任务池计划任务 REFRESH_VIDEO_METADATA_TASK_POOL');
}
protected function execute(Input $input, Output $output)
{
$strCode = 'REFRESH_VIDEO_METADATA_TASK_POOL';
$arrData = [
'pt_name' => '视频缺字段任务池刷新',
'pt_code' => $strCode,
'pt_enable' => 0,
'pt_limit' => 24 * 3600,
'pt_last_exec' => 0,
];
$PlanTaskModel = PlanTaskModel::where('pt_code', $strCode)->find();
if ($PlanTaskModel instanceof PlanTaskModel) {
$output->writeln('计划任务已存在,无需重复创建:' . $strCode);
$output->writeln('pt_id' . (int)$PlanTaskModel->pt_id);
$output->writeln('pt_name' . (string)$PlanTaskModel->pt_name);
$output->writeln('pt_enable' . (int)$PlanTaskModel->pt_enable);
$output->writeln('pt_limit' . (int)$PlanTaskModel->pt_limit);
return 0;
}
$intId = (int)PlanTaskModel::insertGetId($arrData);
$output->writeln('计划任务已创建:' . $strCode);
$output->writeln('pt_id' . $intId);
$output->writeln('pt_name' . $arrData['pt_name']);
$output->writeln('pt_enable' . $arrData['pt_enable']);
$output->writeln('pt_limit' . $arrData['pt_limit']);
return 0;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\helper\VideoMetadataMissingRefreshHelper;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
class VideoMetadataMissingTaskPoolCommand extends Command
{
protected function configure()
{
$this->setName('video:metadata:task-pool')
->addOption('sample', null, Option::VALUE_OPTIONAL, '缺失字段样本数,默认 20', 20)
->addOption('queue-limit', null, Option::VALUE_OPTIONAL, '重采优先队列条数,默认 100', 100)
->addOption('prompt-limit', null, Option::VALUE_OPTIONAL, 'Codex 派单样本数,默认 20', 20)
->addOption('batch-size', null, Option::VALUE_OPTIONAL, '每批数量,默认 20', 20)
->addOption('batch-limit', null, Option::VALUE_OPTIONAL, '最大批次数,默认 10', 10)
->setDescription('生成视频缺字段任务池产物供后台展示、Codex 接手和计划任务挂接');
}
protected function execute(Input $input, Output $output)
{
$intSample = max(1, min(100, (int)$input->getOption('sample')));
$intQueueLimit = max(1, min(500, (int)$input->getOption('queue-limit')));
$intPromptLimit = max(1, min(100, (int)$input->getOption('prompt-limit')));
$intBatchSize = max(5, min(100, (int)$input->getOption('batch-size')));
$intBatchLimit = max(1, min(50, (int)$input->getOption('batch-limit')));
$arrResult = VideoMetadataMissingRefreshHelper::refresh([
'sample' => $intSample,
'queue_limit' => $intQueueLimit,
'prompt_limit' => $intPromptLimit,
'batch_size' => $intBatchSize,
'batch_limit' => $intBatchLimit,
]);
$arrWorkbenchSummary = (array)($arrResult['workbench'] ?? []);
$arrTaskPoolSummary = (array)($arrResult['task_pool'] ?? []);
$output->writeln('视频缺字段任务池已生成');
$output->writeln('工作台缺字段视频数:' . (int)(($arrWorkbenchSummary['audit'] ?? [])['videos_with_any_missing_metadata'] ?? 0));
$output->writeln('工作台重采优先队列:' . (int)(($arrWorkbenchSummary['queue'] ?? [])['queue_size'] ?? 0));
$output->writeln('任务池批次数:' . count((array)($arrTaskPoolSummary['batches'] ?? [])));
$output->writeln('工作台 JSON' . (string)($arrWorkbenchSummary['summary_json_path'] ?? ''));
$output->writeln('工作台 HTML' . (string)($arrWorkbenchSummary['summary_html_path'] ?? ''));
$output->writeln('任务池 JSON' . (string)($arrTaskPoolSummary['summary_json_path'] ?? ''));
$output->writeln('任务池 HTML' . (string)($arrTaskPoolSummary['summary_html_path'] ?? ''));
return 0;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\helper\VideoMetadataMissingWorkbenchHelper;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
class VideoMetadataMissingWorkbenchCommand extends Command
{
protected function configure()
{
$this->setName('video:metadata:workbench')
->addOption('sample', null, Option::VALUE_OPTIONAL, '缺失字段样本数,默认 20', 20)
->addOption('queue-limit', null, Option::VALUE_OPTIONAL, '重采优先队列条数,默认 100', 100)
->addOption('prompt-limit', null, Option::VALUE_OPTIONAL, 'Codex 派单样本数,默认 20', 20)
->setDescription('生成视频缺失字段工作台产物,供后台和 Codex 派单使用');
}
protected function execute(Input $input, Output $output)
{
$intSample = max(1, min(100, (int)$input->getOption('sample')));
$intQueueLimit = max(1, min(500, (int)$input->getOption('queue-limit')));
$intPromptLimit = max(1, min(100, (int)$input->getOption('prompt-limit')));
$strOutputRoot = rtrim((string)root_path(), '/') . '/storage/video-metadata-missing/workbench';
$arrSummary = VideoMetadataMissingWorkbenchHelper::buildSummary($intSample, $intQueueLimit, $intPromptLimit);
$arrSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($strOutputRoot, $arrSummary);
$output->writeln('视频缺失字段工作台已生成');
$output->writeln('存在缺字段视频数:' . (int)(($arrSummary['audit'] ?? [])['videos_with_any_missing_metadata'] ?? 0));
$output->writeln('重采优先队列:' . (int)(($arrSummary['queue'] ?? [])['queue_size'] ?? 0));
$output->writeln('JSON' . (string)($arrSummary['summary_json_path'] ?? ''));
$output->writeln('HTML' . (string)($arrSummary['summary_html_path'] ?? ''));
$output->writeln('Prompt' . (string)($arrSummary['prompt_markdown_path'] ?? ''));
return 0;
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\model\VideoModel;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
class VideoMetadataRecrawlQueueCommand extends Command
{
protected function configure()
{
$this->setName('video:metadata:recrawl-queue')
->addOption('limit', null, Option::VALUE_OPTIONAL, '输出多少条重采优先项,默认 200', 200)
->setDescription('生成演员/导演缺失的视频重采优先队列');
}
protected function execute(Input $input, Output $output)
{
$intLimit = max(1, min((int)$input->getOption('limit'), 5000));
$arrSummary = VideoModel::getInstance()->buildRecrawlPriorityQueueSummary($intLimit);
$strRoot = rtrim((string)root_path(), '/');
$strOutputRoot = $strRoot . '/storage/video-metadata-recrawl-queue';
if (!is_dir($strOutputRoot)) {
@mkdir($strOutputRoot, 0777, true);
}
$strJsonPath = $strOutputRoot . '/latest.json';
$strMarkdownPath = $strOutputRoot . '/latest.md';
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL);
file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary));
$output->writeln('视频元数据重采优先队列已生成');
$output->writeln('队列条数:' . (int)($arrSummary['queue_size'] ?? 0));
$output->writeln('JSON' . $strJsonPath);
$output->writeln('Markdown' . $strMarkdownPath);
foreach (array_slice((array)($arrSummary['items'] ?? []), 0, 10) as $arrItem) {
$output->writeln(sprintf(
'#%d %s => score:%d | %s',
(int)($arrItem['v_id'] ?? 0),
(string)($arrItem['v_name'] ?? ''),
(int)($arrItem['priority_score'] ?? 0),
(string)($arrItem['priority_reason'] ?? '')
));
}
return 0;
}
protected function buildMarkdown(array $arrSummary): string
{
$arrLines = [];
$arrLines[] = '# 视频元数据重采优先队列';
$arrLines[] = '';
$arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? '');
$arrLines[] = '- 队列条数:' . (int)($arrSummary['queue_size'] ?? 0);
$arrLines[] = '- 当前规则:优先处理演员 / 导演缺失且已有访问信号、仍在更新、多线路可交叉重采的视频';
$arrLines[] = '';
$arrLines[] = '| 优先分 | v_id | 片名 | 分类 | 缺失字段 | 周点击 | 总点击 | 播放源数 | 更新时间 | 优先原因 |';
$arrLines[] = '| ---: | ---: | --- | --- | --- | ---: | ---: | ---: | --- | --- |';
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
$arrLines[] = sprintf(
'| %d | %d | %s | %s | %s | %d | %d | %d | %s | %s |',
(int)($arrItem['priority_score'] ?? 0),
(int)($arrItem['v_id'] ?? 0),
$this->escapeMarkdown((string)($arrItem['v_name'] ?? '')),
$this->escapeMarkdown((string)($arrItem['v_category'] ?? '')),
$this->escapeMarkdown(implode(', ', (array)($arrItem['missing_fields'] ?? []))),
(int)(($arrItem['click_stats'] ?? [])['weekly'] ?? 0),
(int)(($arrItem['click_stats'] ?? [])['total'] ?? 0),
(int)($arrItem['play_source_count'] ?? 0),
$this->escapeMarkdown((string)($arrItem['updated_at'] ?? '')),
$this->escapeMarkdown((string)($arrItem['priority_reason'] ?? ''))
);
}
$arrLines[] = '';
$arrLines[] = '## 执行建议';
$arrLines[] = '';
$arrLines[] = '1. 先处理前 50 条高分项,优先尝试从已有多播放源重新抓取演员/导演。';
$arrLines[] = '2. 如果多源都拿不到,再转人工补源或定向接第三方资料源。';
$arrLines[] = '3. 不允许用 AI 猜演员/导演;结构化事实字段只能靠真实来源补齐。';
$arrLines[] = '4. 每次重采后,复跑 `php think video:metadata:audit` 看缺口是否下降。';
$arrLines[] = '';
return implode(PHP_EOL, $arrLines) . PHP_EOL;
}
protected function escapeMarkdown(string $strValue): string
{
$strValue = trim($strValue);
if ($strValue === '') {
return '';
}
return str_replace('|', '\\|', $strValue);
}
}

View File

@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\model\VideoModel;
use app\task\crawler\douban\page\Site as DoubanSite;
use app\task\crawler\youzhi\page\Site as YouzhiSite;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
class VideoMetadataRecrawlRunCommand extends Command
{
protected function configure()
{
$this->setName('video:metadata:recrawl-run')
->addOption('limit', null, Option::VALUE_OPTIONAL, '默认按重采队列取多少条,默认 10', 10)
->addOption('v-ids', null, Option::VALUE_OPTIONAL, '指定 v_id多个逗号分隔指定后优先按 v_id 执行', '')
->addOption('sources', null, Option::VALUE_OPTIONAL, '强制指定源站,多个逗号分隔,如 douban,youzhi', '')
->addOption('dry-run', null, Option::VALUE_NONE, '只搜索和匹配,不真正回写')
->setDescription('按视频元数据重采队列执行演员/导演定向重采,并生成执行结果报告');
}
protected function execute(Input $input, Output $output)
{
$intLimit = max(1, min((int)$input->getOption('limit'), 100));
$arrForcedVIds = $this->parseIntList((string)$input->getOption('v-ids'));
$arrForcedSources = $this->parseSourceList((string)$input->getOption('sources'));
$boolDryRun = (bool)$input->getOption('dry-run');
$arrCandidates = $this->resolveCandidates($arrForcedVIds, $intLimit);
if (empty($arrCandidates)) {
$output->writeln('没有可执行的候选视频');
return 0;
}
$arrSummary = [
'generated_at' => date(DATE_ATOM),
'mode' => !empty($arrForcedVIds) ? 'explicit_v_ids' : 'queue',
'dry_run' => $boolDryRun,
'limit' => $intLimit,
'requested_v_ids' => $arrForcedVIds,
'forced_sources' => $arrForcedSources,
'attempted_videos' => count($arrCandidates),
'success_actor_count' => 0,
'success_director_count' => 0,
'success_both_count' => 0,
'failed_both_count' => 0,
'items' => [],
];
foreach ($arrCandidates as $arrCandidate) {
$arrItemSummary = $this->processCandidate($arrCandidate, $arrForcedSources, $boolDryRun);
$arrSummary['items'][] = $arrItemSummary;
if (!empty($arrItemSummary['actor_filled'])) {
$arrSummary['success_actor_count']++;
}
if (!empty($arrItemSummary['director_filled'])) {
$arrSummary['success_director_count']++;
}
if (!empty($arrItemSummary['actor_filled']) && !empty($arrItemSummary['director_filled'])) {
$arrSummary['success_both_count']++;
}
if (empty($arrItemSummary['actor_filled']) && empty($arrItemSummary['director_filled'])) {
$arrSummary['failed_both_count']++;
}
$output->writeln(sprintf(
'#%d %s | actor:%s | director:%s | sources:%s',
(int)$arrItemSummary['v_id'],
(string)$arrItemSummary['v_name'],
!empty($arrItemSummary['actor_filled']) ? 'filled' : 'no',
!empty($arrItemSummary['director_filled']) ? 'filled' : 'no',
implode(',', array_column((array)$arrItemSummary['source_runs'], 'source'))
));
}
$strRoot = rtrim((string)root_path(), '/');
$strOutputRoot = $strRoot . '/storage/video-metadata-recrawl-run';
if (!is_dir($strOutputRoot)) {
@mkdir($strOutputRoot, 0777, true);
}
$strJsonPath = $strOutputRoot . '/latest.json';
$strMarkdownPath = $strOutputRoot . '/latest.md';
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL);
file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary));
$output->writeln('视频元数据定向重采报告已生成');
$output->writeln('JSON' . $strJsonPath);
$output->writeln('Markdown' . $strMarkdownPath);
return 0;
}
protected function resolveCandidates(array $arrForcedVIds, int $intLimit): array
{
if (!empty($arrForcedVIds)) {
$arrCandidates = [];
foreach ($arrForcedVIds as $intVId) {
$arrVideo = VideoModel::getInstance()->getVideoByVId($intVId);
if (empty($arrVideo)) {
continue;
}
$arrPlaySources = array_values(array_filter(array_map('trim', array_keys((array)($arrVideo['v_play_url'] ?? [])))));
$arrCandidates[] = [
'v_id' => (int)($arrVideo['v_id'] ?? 0),
'v_name' => (string)($arrVideo['v_name'] ?? ''),
'v_name_en' => (string)($arrVideo['v_name_en'] ?? ''),
'play_sources' => $arrPlaySources,
];
}
return $arrCandidates;
}
return (array)(VideoModel::getInstance()->buildRecrawlPriorityQueueSummary($intLimit)['items'] ?? []);
}
protected function processCandidate(array $arrCandidate, array $arrForcedSources, bool $boolDryRun): array
{
$intVId = (int)($arrCandidate['v_id'] ?? 0);
$strVName = trim((string)($arrCandidate['v_name'] ?? ''));
$strVNameEn = trim((string)($arrCandidate['v_name_en'] ?? ''));
$arrBefore = VideoModel::getInstance()->getVideoByVId($intVId) ?? [];
$arrSourceRuns = [];
$arrSources = !empty($arrForcedSources)
? $arrForcedSources
: $this->parseSourceList(implode(',', (array)($arrCandidate['play_sources'] ?? [])));
foreach ($arrSources as $strSource) {
$arrSearchResult = $this->searchSourceVideo($strSource, $strVName, $strVNameEn);
$arrSourceRun = [
'source' => $strSource,
'matched' => !empty($arrSearchResult),
'matched_vod_id' => (int)($arrSearchResult['vod_id'] ?? 0),
'matched_vod_name' => (string)($arrSearchResult['vod_name'] ?? ''),
'status' => 'no_match',
];
if (empty($arrSearchResult)) {
$arrSourceRuns[] = $arrSourceRun;
continue;
}
if ($boolDryRun) {
$arrSourceRun['status'] = 'matched_dry_run';
$arrSourceRuns[] = $arrSourceRun;
continue;
}
$boolSaved = $this->fetchAndSaveSourceVideo($strSource, (int)$arrSearchResult['vod_id']);
$arrSourceRun['status'] = $boolSaved ? 'saved' : 'fetch_failed';
$arrSourceRuns[] = $arrSourceRun;
}
$arrAfter = VideoModel::getInstance()->getVideoByVId($intVId) ?? [];
$VideoModel = VideoModel::getInstance();
$boolActorBefore = $VideoModel->isFieldMetadataMissing('v_actor', $arrBefore['v_actor'] ?? null);
$boolDirectorBefore = $VideoModel->isFieldMetadataMissing('v_director', $arrBefore['v_director'] ?? null);
$boolActorAfter = $VideoModel->isFieldMetadataMissing('v_actor', $arrAfter['v_actor'] ?? null);
$boolDirectorAfter = $VideoModel->isFieldMetadataMissing('v_director', $arrAfter['v_director'] ?? null);
return [
'v_id' => $intVId,
'v_name' => $strVName,
'sources_requested' => $arrSources,
'actor_filled' => $boolActorBefore && !$boolActorAfter,
'director_filled' => $boolDirectorBefore && !$boolDirectorAfter,
'before' => [
'actor_empty' => $boolActorBefore,
'director_empty' => $boolDirectorBefore,
],
'after' => [
'actor_empty' => $boolActorAfter,
'director_empty' => $boolDirectorAfter,
],
'source_runs' => $arrSourceRuns,
];
}
protected function searchSourceVideo(string $strSource, string $strVName, string $strVNameEn = ''): array
{
$Site = $this->makeSite($strSource);
$strUri = $this->buildSearchUri($strSource, $strVName);
$Response = $Site->getClient()->get($strUri);
$strContent = (string)$Response->getBody()->getContents();
$arrContent = json_decode($strContent, true);
$arrList = (array)($arrContent['list'] ?? []);
if (empty($arrList)) {
return [];
}
$strTargetName = $this->normalizeTitle($strVName);
$strTargetNameEn = strtolower(trim($strVNameEn));
foreach ($arrList as $arrItem) {
$strVodName = $this->normalizeTitle((string)($arrItem['vod_name'] ?? ''));
$strVodNameEn = strtolower(trim((string)($arrItem['vod_en'] ?? '')));
if ($strVodName !== '' && $strVodName === $strTargetName) {
return (array)$arrItem;
}
if ($strTargetNameEn !== '' && $strVodNameEn !== '' && $strVodNameEn === $strTargetNameEn) {
return (array)$arrItem;
}
}
return (array)$arrList[0];
}
protected function fetchAndSaveSourceVideo(string $strSource, int $intVodId): bool
{
if ($intVodId <= 0) {
return false;
}
$Site = $this->makeSite($strSource);
$arrPages = $Site->getVideoInfoPageList([
['v_source_id' => $intVodId],
]);
if (empty($arrPages)) {
return false;
}
$boolSaved = false;
foreach ($arrPages as $VideoInfoPage) {
$boolSaved = $VideoInfoPage->saveVideo() || $boolSaved;
}
return $boolSaved;
}
protected function makeSite(string $strSource)
{
return match ($strSource) {
'douban' => new DoubanSite(),
'youzhi' => new YouzhiSite(),
default => throw new \InvalidArgumentException('不支持的源站:' . $strSource),
};
}
protected function buildSearchUri(string $strSource, string $strKeyword): string
{
$strKeyword = urlencode($strKeyword);
return match ($strSource) {
'douban' => '/api.php/provide/vod/at/josn?ac=list&wd=' . $strKeyword,
'youzhi' => '/inc/api_mac10.php?ac=list&wd=' . $strKeyword,
default => throw new \InvalidArgumentException('不支持的源站:' . $strSource),
};
}
protected function parseIntList(string $strValue): array
{
return array_values(array_filter(array_map(static function (string $strItem): int {
return (int)trim($strItem);
}, explode(',', $strValue)), static function (int $intValue): bool {
return $intValue > 0;
}));
}
protected function parseSourceList(string $strValue): array
{
$arrAllowed = ['douban', 'youzhi'];
return array_values(array_filter(array_unique(array_map(static function (string $strItem): string {
return trim($strItem);
}, explode(',', $strValue))), static function (string $strItem) use ($arrAllowed): bool {
return in_array($strItem, $arrAllowed, true);
}));
}
protected function normalizeTitle(string $strValue): string
{
$strValue = trim(mb_strtolower($strValue));
if ($strValue === '') {
return '';
}
return preg_replace('/[\s\p{P}\p{S}]+/u', '', $strValue) ?? $strValue;
}
protected function buildMarkdown(array $arrSummary): string
{
$arrLines = [];
$arrLines[] = '# 视频元数据定向重采执行结果';
$arrLines[] = '';
$arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? '');
$arrLines[] = '- 模式:' . (string)($arrSummary['mode'] ?? '');
$arrLines[] = '- Dry Run' . (!empty($arrSummary['dry_run']) ? 'yes' : 'no');
$arrLines[] = '- 处理条数:' . (int)($arrSummary['attempted_videos'] ?? 0);
$arrLines[] = '- 演员补齐条数:' . (int)($arrSummary['success_actor_count'] ?? 0);
$arrLines[] = '- 导演补齐条数:' . (int)($arrSummary['success_director_count'] ?? 0);
$arrLines[] = '- 双字段都补齐:' . (int)($arrSummary['success_both_count'] ?? 0);
$arrLines[] = '- 双字段都未补齐:' . (int)($arrSummary['failed_both_count'] ?? 0);
$arrLines[] = '';
$arrLines[] = '| v_id | 片名 | actor补齐 | director补齐 | 源站执行摘要 |';
$arrLines[] = '| ---: | --- | --- | --- | --- |';
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
$arrSourceSummary = array_map(static function (array $arrRun): string {
return sprintf(
'%s:%s%s',
(string)($arrRun['source'] ?? ''),
(string)($arrRun['status'] ?? ''),
!empty($arrRun['matched_vod_id']) ? '#' . (int)$arrRun['matched_vod_id'] : ''
);
}, (array)($arrItem['source_runs'] ?? []));
$arrLines[] = sprintf(
'| %d | %s | %s | %s | %s |',
(int)($arrItem['v_id'] ?? 0),
$this->escapeMarkdown((string)($arrItem['v_name'] ?? '')),
!empty($arrItem['actor_filled']) ? 'yes' : 'no',
!empty($arrItem['director_filled']) ? 'yes' : 'no',
$this->escapeMarkdown(implode(' / ', $arrSourceSummary))
);
}
$arrLines[] = '';
return implode(PHP_EOL, $arrLines) . PHP_EOL;
}
protected function escapeMarkdown(string $strValue): string
{
return str_replace('|', '\\|', trim($strValue));
}
}

View File

@@ -776,18 +776,24 @@ namespace {
return $strZh;
}
$strPinYinToolsPath = root_path() . '/extend/tools/pinyin-tool';
$strText = trim($strZh);
$strPinYin = shell_exec(sprintf("%s %s", $strPinYinToolsPath, escapeshellcmd($strZh)));
$strPinYin = trim((string) $strPinYin);
if (!is_string($strPinYin) || trim($strPinYin) === '') {
return 'k_' . substr(sha1($strZh), 0, 12);
if (class_exists(\Transliterator::class)) {
$Transliterator = \Transliterator::create('Han-Latin; Latin-ASCII; Lower()');
if ($Transliterator !== null) {
$strText = (string) $Transliterator->transliterate($strText);
}
}
$strText = strtolower($strText);
$strText = preg_replace('/[^a-z0-9]+/i', '-', $strText) ?? '';
$strText = trim($strText, '-');
return $strPinYin;
if ($strText === '') {
return 'k-' . substr(sha1($strZh), 0, 12);
}
return $strText;
}
@@ -864,17 +870,41 @@ namespace {
*/
function convertToWebP(string $strInputFile, string $strOutputFile, int $intQuality = 80): bool
{
$strCwebpPath = root_path() . '/extend/tools/cwebp';
$strCMD = escapeshellcmd("$strCwebpPath -q $intQuality " . escapeshellarg($strInputFile) . " -o " . escapeshellarg($strOutputFile));
exec($strCMD . ' 2>&1', $strOutput, $intReturnCode);
if ($intReturnCode === 0) {
return true;
} else {
if (!is_file($strInputFile) || !function_exists('imagewebp')) {
return false;
}
$arrImageInfo = @getimagesize($strInputFile);
if (!is_array($arrImageInfo)) {
return false;
}
$Image = match ($arrImageInfo[2] ?? null) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($strInputFile),
IMAGETYPE_PNG => @imagecreatefrompng($strInputFile),
IMAGETYPE_GIF => @imagecreatefromgif($strInputFile),
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($strInputFile) : false,
default => false,
};
if ($Image === false) {
return false;
}
imagepalettetotruecolor($Image);
imagealphablending($Image, true);
imagesavealpha($Image, true);
$strOutputDir = dirname($strOutputFile);
if (!is_dir($strOutputDir) && !@mkdir($strOutputDir, 0777, true) && !is_dir($strOutputDir)) {
imagedestroy($Image);
return false;
}
$boolResult = @imagewebp($Image, $strOutputFile, max(0, min(100, $intQuality)));
imagedestroy($Image);
return $boolResult && is_file($strOutputFile);
}
/**

View File

@@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
use app\model\VideoModel;
use app\model\VideoCategoryModel;
use think\App;
class DomainAutoSampleHelper
{
protected static bool $boolInitialized = false;
protected static function ensureAppInitialized(): void
{
if (self::$boolInitialized) {
return;
}
(new App())->initialize();
self::$boolInitialized = true;
}
public static function discover(string $strHost, array $arrOptions = []): array
{
self::ensureAppInitialized();
$strHost = DomainModel::normalizeHost($strHost);
if ($strHost === '') {
return [
'host' => '',
'status' => 'invalid_host',
'message' => 'Host empty.',
'sample_source' => 'video_auto_discovery',
'checked_candidates' => 0,
'sample' => null,
];
}
$intLimit = max(5, min(200, (int)($arrOptions['limit'] ?? 80)));
$intPreferredVideoId = max(0, (int)($arrOptions['preferred_video_id'] ?? 0));
$DomainRow = self::findDomainRow($strHost);
$arrCandidates = self::fetchCandidateVideos($intLimit, $intPreferredVideoId);
$intChecked = 0;
foreach ($arrCandidates as $arrVideo) {
$intChecked++;
$arrSample = self::buildSampleFromVideo($strHost, $DomainRow, $arrVideo);
if ($arrSample === null) {
continue;
}
return [
'host' => $strHost,
'status' => 'passed',
'message' => 'Auto sample discovered from video library.',
'sample_source' => 'video_auto_discovery',
'checked_candidates' => $intChecked,
'sample' => $arrSample,
];
}
return [
'host' => $strHost,
'status' => 'failed_no_candidate',
'message' => 'No valid default sample could be discovered from video library.',
'sample_source' => 'video_auto_discovery',
'checked_candidates' => $intChecked,
'sample' => null,
];
}
protected static function findDomainRow(string $strHost): ?DomainModel
{
$strExactDomain = DomainModel::normalizeStoredDomain($strHost, DomainModel::MATCH_TYPE_EXACT);
$DomainRow = app(DomainModel::class)->where('d_domain', $strExactDomain)->find();
return $DomainRow instanceof DomainModel ? $DomainRow : null;
}
protected static function fetchCandidateVideos(int $intLimit, int $intPreferredVideoId = 0): array
{
$VideoCollection = VideoModel::getInstance()->getCol();
$arrProjection = [
'_id' => 0,
'v_id' => 1,
'v_name' => 1,
'v_name_en' => 1,
'v_seo_words' => 1,
'v_play_url' => 1,
'v_parent_category' => 1,
'v_parent_category_en' => 1,
'v_category' => 1,
'v_category_en' => 1,
];
$arrCandidates = [];
if ($intPreferredVideoId > 0) {
$arrPreferred = $VideoCollection->findOne(['v_id' => $intPreferredVideoId], [
'typeMap' => [
'root' => 'array',
'document' => 'array',
'array' => 'array',
],
'projection' => $arrProjection,
]);
if (is_array($arrPreferred) && !empty($arrPreferred)) {
$arrCandidates[] = $arrPreferred;
}
}
$Cursor = $VideoCollection->find([], [
'typeMap' => [
'root' => 'array',
'document' => 'array',
'array' => 'array',
],
'projection' => $arrProjection,
'sort' => ['v_id' => -1],
'limit' => $intLimit,
]);
foreach (iterator_to_array($Cursor) as $arrVideo) {
if (!is_array($arrVideo)) {
continue;
}
if ($intPreferredVideoId > 0 && (int)($arrVideo['v_id'] ?? 0) === $intPreferredVideoId) {
continue;
}
$arrCandidates[] = $arrVideo;
}
return $arrCandidates;
}
protected static function buildSampleFromVideo(string $strHost, ?DomainModel $DomainRow, array $arrVideo): ?array
{
$intVideoId = (int)($arrVideo['v_id'] ?? 0);
$strVideoName = trim((string)($arrVideo['v_name'] ?? ''));
$strVideoSlug = trim((string)($arrVideo['v_name_en'] ?? ''));
if ($intVideoId <= 0 || $strVideoName === '' || $strVideoSlug === '') {
return null;
}
$arrPlay = self::resolvePlaySample($arrVideo);
if ($arrPlay === null) {
return null;
}
$arrCategory = self::resolveCategorySample($arrVideo);
if ($arrCategory === null) {
return null;
}
$strKeyword = self::resolveSearchKeyword($DomainRow, $arrVideo);
if ($strKeyword === '') {
return null;
}
$arrResult = [
'host' => $strHost,
'video' => [
'v_id' => $intVideoId,
'v_name' => $strVideoName,
'v_name_en' => $strVideoSlug,
],
'detail' => [
'detail_id' => $intVideoId,
'detail_slug' => $strVideoSlug,
],
'search' => [
'search_keyword' => $strKeyword,
],
'category' => $arrCategory,
'play' => $arrPlay,
'runtime_acceptance' => [
'samples' => [
[
'video_id' => $intVideoId,
'forge_id' => 1,
],
],
],
];
$arrUrls = self::buildUrls($strHost, $DomainRow, $arrResult);
if (!empty($arrUrls)) {
$arrResult['urls'] = $arrUrls;
}
return $arrResult;
}
protected static function resolvePlaySample(array $arrVideo): ?array
{
$arrPlayGroups = is_array($arrVideo['v_play_url'] ?? null) ? (array)($arrVideo['v_play_url'] ?? []) : [];
foreach ($arrPlayGroups as $strPlayType => $arrEpisodes) {
$strPlayType = trim((string)$strPlayType);
if ($strPlayType === '' || !is_array($arrEpisodes) || empty($arrEpisodes)) {
continue;
}
$arrEpisode = (array)($arrEpisodes[0] ?? []);
$strEpisodeName = trim((string)($arrEpisode['name'] ?? ''));
if ($strEpisodeName === '') {
$strEpisodeName = '第1集';
}
return [
'play_type' => $strPlayType,
'play_index' => 1,
'episode_name' => $strEpisodeName,
];
}
return null;
}
protected static function resolveCategorySample(array $arrVideo): ?array
{
$strParentSlug = trim((string)($arrVideo['v_parent_category_en'] ?? ''));
$strCategorySlug = trim((string)($arrVideo['v_category_en'] ?? ''));
$strParentName = trim((string)($arrVideo['v_parent_category'] ?? ''));
$strCategoryName = trim((string)($arrVideo['v_category'] ?? ''));
if ($strParentSlug === '' && $strParentName !== '') {
$strParentSlug = self::findCategorySlugByName($strParentName);
}
if ($strCategorySlug === '' && $strCategoryName !== '') {
$strCategorySlug = self::findCategorySlugByName($strCategoryName);
}
if ($strParentSlug === '' || $strCategorySlug === '') {
return null;
}
return [
'category_parent' => $strParentSlug,
'category_child' => $strCategorySlug,
];
}
protected static function resolveSearchKeyword(?DomainModel $DomainRow, array $arrVideo): string
{
$arrKeywordCandidates = [];
$strDomainKeyword = trim((string)($DomainRow?->d_name ?? ''));
if ($strDomainKeyword !== '') {
$arrKeywordCandidates[] = $strDomainKeyword;
}
$arrSeoWordValues = self::normalizeKeywordSource($arrVideo['v_seo_words'] ?? '');
foreach ($arrSeoWordValues as $strSeoWords) {
foreach (preg_split('/[,\x{3001}\x{ff0c}\s]+/u', $strSeoWords) as $strWord) {
$strWord = trim((string)$strWord);
if ($strWord !== '') {
$arrKeywordCandidates[] = $strWord;
}
}
}
$strVideoName = trim((string)($arrVideo['v_name'] ?? ''));
if ($strVideoName !== '') {
$arrKeywordCandidates[] = $strVideoName;
}
foreach ($arrKeywordCandidates as $strKeyword) {
$strKeyword = trim($strKeyword);
if ($strKeyword !== '') {
return mb_substr($strKeyword, 0, 20);
}
}
return '';
}
protected static function normalizeKeywordSource(mixed $value): array
{
if (is_string($value) || is_numeric($value)) {
$strValue = trim((string)$value);
return $strValue === '' ? [] : [$strValue];
}
if (!is_array($value)) {
return [];
}
$arrValues = [];
array_walk_recursive($value, static function ($item) use (&$arrValues): void {
if (is_string($item) || is_numeric($item)) {
$strItem = trim((string)$item);
if ($strItem !== '') {
$arrValues[] = $strItem;
}
}
});
return $arrValues;
}
protected static function buildUrls(string $strHost, ?DomainModel $DomainRow, array $arrSample): array
{
if (!$DomainRow instanceof DomainModel) {
return [];
}
$TpStyle = SiteStyle::getConfig($DomainRow, $strHost);
$UrlBuilder = new UrlBuilder($TpStyle);
$strSlug = (string)($arrSample['detail']['detail_slug'] ?? '');
$intVideoId = (int)($arrSample['detail']['detail_id'] ?? 0);
$strSearchKeyword = (string)($arrSample['search']['search_keyword'] ?? '');
$strCategoryParent = (string)($arrSample['category']['category_parent'] ?? '');
$strCategoryChild = (string)($arrSample['category']['category_child'] ?? '');
$strPlayType = (string)($arrSample['play']['play_type'] ?? '');
$intPlayIndex = max(1, (int)($arrSample['play']['play_index'] ?? 1));
$arrUrls = [
'home' => $UrlBuilder->home(),
];
if ($strSearchKeyword !== '') {
$arrUrls['search'] = $UrlBuilder->searchResult($strSearchKeyword);
}
if ($strCategoryParent !== '' && $strCategoryChild !== '') {
$arrUrls['category'] = $UrlBuilder->categoryChild($strCategoryParent, $strCategoryChild, 1);
}
if ($strSlug !== '' && $intVideoId > 0) {
$arrUrls['detail'] = $UrlBuilder->detail($strSlug, $intVideoId);
}
if ($strSlug !== '' && $intVideoId > 0 && $strPlayType !== '') {
$arrUrls['play'] = $UrlBuilder->play($strSlug, $intVideoId, $strPlayType, $intPlayIndex);
}
return $arrUrls;
}
protected static function findCategorySlugByName(string $strName): string
{
$strName = trim($strName);
if ($strName === '') {
return '';
}
$arrCategory = app(VideoCategoryModel::class)
->where('vc_name', $strName)
->field(['vc_name_en'])
->find();
return trim((string)($arrCategory['vc_name_en'] ?? ''));
}
}

View File

@@ -10,6 +10,91 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class DomainBatchImportTemplateHelper
{
public static function buildNoteDisplayLabel(string $field): string
{
$label = self::getHeaderLabels()[$field] ?? $field;
return $label . '' . $field . '';
}
public static function getHeaderLabels(): array
{
return [
'd_domain' => '域名',
'd_name' => '站点名称',
't_id' => '模板ID',
'tkd_provider' => 'TKD来源',
'tkd_mode' => 'TKD模式',
'd_index_title' => '首页标题',
'd_index_keywords' => '首页关键词',
'd_index_description' => '首页描述',
'strategy_profile' => 'SEO策略',
'd_keywords' => '站点关键词',
'd_description' => '站点描述',
'd_baidu_token' => '百度推送Token',
'd_match_type' => '匹配类型',
'd_parent_domain' => '父域名',
'd_seed_scope' => '种子范围',
'd_logo_type' => 'LOGO类型',
'd_text_logo' => '文字LOGO',
'd_img_logo' => '图片LOGO',
'd_statis' => '统计代码',
'd_content_encode' => '内容编码',
'info_id' => '信息ID',
'd_seo_cfg' => '高级SEO配置JSON',
];
}
public static function getHeaderAliases(): array
{
$labels = self::getHeaderLabels();
$aliases = [];
foreach ($labels as $field => $label) {
$aliases[$field] = $field;
$aliases[mb_strtolower($field)] = $field;
$aliases[$label] = $field;
$aliases[mb_strtolower($label)] = $field;
}
foreach ([
'站点域名' => 'd_domain',
'域名host' => 'd_domain',
'首页SEO标题' => 'd_index_title',
'首页SEO关键词' => 'd_index_keywords',
'首页SEO描述' => 'd_index_description',
'策略' => 'strategy_profile',
'高级配置JSON' => 'd_seo_cfg',
'内容编码类型' => 'd_content_encode',
] as $alias => $field) {
$aliases[$alias] = $field;
$aliases[mb_strtolower($alias)] = $field;
}
return $aliases;
}
public static function normalizeHeader(string $header): string
{
$normalized = trim($header);
if ($normalized === '') {
return '';
}
$aliases = self::getHeaderAliases();
return $aliases[$normalized] ?? $aliases[mb_strtolower($normalized)] ?? '';
}
public static function getDisplayHeaders(): array
{
$labels = self::getHeaderLabels();
return array_map(
static fn(string $field): string => $labels[$field] ?? $field,
self::getHeaders()
);
}
public static function writeWorkbook(
string $filePath,
string $sheetTitle,
@@ -42,11 +127,13 @@ class DomainBatchImportTemplateHelper
$notesSheet = $spreadsheet->createSheet();
$notesSheet->setTitle('notes');
$notesSheet->setCellValue('A1', 'column');
$notesSheet->setCellValue('B1', 'note');
$notesSheet->setCellValue('A1', '字段');
$notesSheet->setCellValue('B1', '说明');
$notesSheet->getStyle('A1:B1')->getFont()->setBold(true);
$notesSheet->getColumnDimension('A')->setWidth(28);
$notesSheet->getColumnDimension('B')->setWidth(80);
$notesSheet->getColumnDimension('A')->setWidth(42);
$notesSheet->getColumnDimension('B')->setWidth(120);
$notesSheet->getStyle('A:B')->getAlignment()->setWrapText(true);
$notesSheet->getStyle('A:B')->getAlignment()->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_TOP);
$rowNo = 2;
foreach ($columnNotes as $column => $note) {
@@ -65,55 +152,55 @@ class DomainBatchImportTemplateHelper
{
return [
'd_domain',
't_id',
'd_name',
'd_keywords',
't_id',
'tkd_provider',
'tkd_mode',
'd_index_title',
'd_index_keywords',
'd_index_description',
'strategy_profile',
'd_keywords',
'd_description',
'd_baidu_token',
'd_match_type',
'd_parent_domain',
'd_seed_scope',
'd_statis',
'd_logo_type',
'd_text_logo',
'd_img_logo',
'd_content_encode',
'info_id',
'd_match_type',
'd_parent_domain',
'd_seed_scope',
'd_baidu_token',
'd_seo_cfg',
'strategy_profile',
'tkd_mode',
'tkd_provider',
];
}
public static function getColumnNotes(): array
{
return [
'd_domain' => '必填站点域名wildcard 模式可填 *.example.com 或 example.com,由后端归一化。',
't_id' => '必填,模板 ID。',
'd_name' => '必填,站点名称。',
'd_keywords' => '可选,站点关键词。',
'd_index_title' => '可选,首页标题。',
'd_index_keywords' => '可选,首页关键词。',
'd_index_description' => '可选,首页描述。',
'd_description' => '可选,站点描述。',
'd_statis' => '可选,统计代码。',
'd_logo_type' => '必填0 或 1。',
'd_text_logo' => '可选,文字 LOGO。',
'd_img_logo' => '可选,图片 LOGO。',
'd_content_encode' => '必填,通常填 0。',
'info_id' => '可选,通常填 0。',
'd_match_type' => '可选exact 或 wildcard。',
'd_parent_domain' => 'wildcard 模式建议填写根域名。',
'd_seed_scope' => '可选domain 或 host。',
'd_baidu_token' => '可选,百度推送 token。',
'd_seo_cfg' => '可选JSON会在 strategy_profile 默认值基础上再做覆盖。',
'strategy_profile' => '可选standard / traffic / expand / closed。',
'tkd_mode' => '可选1/auto_generate=全自动生成2/ai_optimize=导入 TKD 后本地/AI 优化3/force_import=强制使用导入值。',
'tkd_provider' => '可选,local 或 openai;当前默认 local本地优化已生效,后续可接 OpenAI API。',
'd_domain' => '必填。填写站点正式域名,不要带 https:// 。示例jingxifa.com。只有做泛域名时才填 *.example.com。',
'd_name' => '必填。填写站点前台展示名称,会用于页头、页脚和基础 SEO 文案。示例:鲸溪发影视。',
't_id' => "通常直接填 1。\n可选值说明:\n- 1当前默认模板。绝大多数 GPT 模板站都填这个。\n- 其他数字:代表后台另外配置过的模板 ID只有你明确知道该站要切到别的模板时才改。\n不会判断时就保持 1不要猜。",
'd_index_title' => '建议填写。首页给搜索引擎看的标题,尽量自然,不要堆词。建议 28 到 40 字。',
'd_index_keywords' => '建议填写。首页核心关键词,多个词用逗号分隔。示例:短剧推荐,热播短剧,高清短剧。',
'd_index_description' => '建议填写。首页一句话简介,建议 40 到 80 字,写自然一点。',
'strategy_profile' => "SEO 策略档位,默认 standard。\n可选值说明:\n- standard标准站最常用默认值。普通内容站、普通影视站、先求稳定收录时选它。\n- traffic流量型。更强调关键词覆盖、流量页铺量适合明确要做流量词扩展的站。\n- expand扩展型。更适合泛域名、子站扩展、批量铺设场景。\n- closed收缩/关闭型。通常用于不再继续扩张或只保留最小发布动作的站。\n不确定就填 standard。",
'd_keywords' => '可选。不填也能跑;如要补充站点级关键词,可填在这里。',
'd_description' => '可选。不填时系统会按站点名称自动兜底一版简介。',
'd_baidu_token' => '可选。只有需要百度主动推送时才填写。',
'd_match_type' => "域名匹配方式,默认 exact。\n可选值说明:\n- exact精确匹配。普通单域名站使用这个例如 jingxifa.com。\n- wildcard泛匹配。只有泛域名场景才用例如 *.example.com。\n如果你填的是普通单域名,就保持 exact。",
'd_parent_domain' => "父域名只在 wildcard 泛域名场景下填写。\n填写规则:\n- 如果 d_domain 是 *.example.com这里填 example.com。\n- 如果 d_domain 是普通单域名,例如 jingxifa.com这里留空。\n不要把 https:// 或路径带进来。",
'd_seed_scope' => "种子范围,默认 domain。\n可选值说明:\n- domain按整域名范围处理普通站点默认用这个。\n- host按具体 host 处理,常见于泛域名、子站单独铺设的场景。\n不确定就用 domain。",
'd_statis' => '可选。统计代码区,运营通常不用填。',
'd_logo_type' => "LOGO 类型,通常填 0。\n可选值说明:\n- 0文字 LOGO 优先。最常用,普通站点直接用这个。\n- 1图片 LOGO 优先。只有明确要展示图片 LOGO且 d_img_logo 已准备好时才用。\n不确定就填 0。",
'd_text_logo' => '可选。填写前台显示的文字 LOGO不填时可按站点名称兜底。',
'd_img_logo' => '可选。只有要使用图片 LOGO 时才填写。',
'd_content_encode' => "内容编码,通常填 0。\n可选值说明:\n- 0默认编码方式。普通站直接用这个。\n- 其他值:特殊历史兼容场景才会用,运营不要自行改。\n不会判断就填 0。",
'info_id' => "信息 ID通常填 0。\n可选值说明:\n- 0默认即可。\n- 其他数字:通常是历史数据联动或特殊资源绑定才会用。\n普通新站不要改。",
'd_seo_cfg' => "高级 SEO 配置 JSON。普通运营通常留空。\n适用场景:\n- 要覆盖默认 SEO 行为\n- 要补结构化数据\n- 要定制 forge / route_group 等技术项\n注意:必须填写合法 JSON例如 {\"forge\":{\"route_group\":\"film\"}}。\n如果看不懂,就留空。",
'tkd_mode' => "TKD 模式。一般可留空,由系统自动判断。\n常见可选值说明:\n- 留空:推荐。系统会按上下文自动选择。\n - 如果 TKD来源=openai则留空会自动按 2 / ai_optimize 处理。\n - 如果首页标题、关键词、描述已经手工填写,则更偏向导入现成内容。\n - 如果都没填且来源不是 openai则会走默认自动生成策略。\n- 1偏向使用导入内容/现成内容。\n- 2偏向使用系统生成或 AI 优化策略。\n- 3强制按导入内容处理。\n不确定时local 可留空openai 也可以直接留空。",
'tkd_provider' => "TKD 来源。一般保持 local 或按需填 openai\n可选值说明:\n- local本地默认来源,最稳,常规就用它。\n- openai走 AI 生成或 AI 优化链路。若你填 openai 且 TKD模式留空系统会自动按 2 / ai_optimize 处理。\n- 其他值:属于技术兼容项,不建议运营手填。\n想走 OpenAI 全自动时推荐TKD来源=openaiTKD模式留空或直接填 2。",
];
}
@@ -121,52 +208,52 @@ class DomainBatchImportTemplateHelper
{
return [
[
'demo-example-com',
1,
'Demo Example',
'Demo Example,影视推荐',
'Demo Example 影视内容推荐',
'Demo Example,影视推荐',
'Demo Example 内容整理与推荐。',
'Demo Example 内容整理与推荐',
'',
0,
'Demo Example',
'',
0,
0,
'exact',
'',
'domain',
'',
'',
'standard',
'1',
'local',
'd_domain' => 'demo-example-com',
'd_name' => 'Demo Example',
't_id' => 1,
'd_index_title' => 'Demo Example 影视内容推荐',
'd_index_keywords' => 'Demo Example,影视推荐',
'd_index_description' => 'Demo Example 内容整理与推荐',
'strategy_profile' => 'standard',
'd_keywords' => 'Demo Example,影视推荐',
'd_description' => 'Demo Example 内容整理与推荐。',
'd_baidu_token' => '',
'd_match_type' => 'exact',
'd_parent_domain' => '',
'd_seed_scope' => 'domain',
'd_statis' => '',
'd_logo_type' => 0,
'd_text_logo' => 'Demo Example',
'd_img_logo' => '',
'd_content_encode' => 0,
'info_id' => 0,
'd_seo_cfg' => '',
'tkd_mode' => '1',
'tkd_provider' => 'local',
],
[
'*.traffic-example.com',
1,
'Traffic Example',
'Traffic Example,热门推荐',
'Traffic Example 热门内容推荐',
'Traffic Example,热门推荐',
'Traffic Example 热门内容整理与推荐。',
'Traffic Example 热门内容整理与推荐',
'',
0,
'Traffic Example',
'',
0,
0,
'wildcard',
'traffic-example.com',
'host',
'',
'{"forge":{"route_group":"film"},"structured_data":{"organization_name":"Traffic Example Studio"}}',
'traffic',
'2',
'local',
'd_domain' => '*.traffic-example.com',
'd_name' => 'Traffic Example',
't_id' => 1,
'd_index_title' => 'Traffic Example 热门内容推荐',
'd_index_keywords' => 'Traffic Example,热门推荐',
'd_index_description' => 'Traffic Example 热门内容整理与推荐',
'strategy_profile' => 'traffic',
'd_keywords' => 'Traffic Example,热门推荐',
'd_description' => 'Traffic Example 热门内容整理与推荐。',
'd_baidu_token' => '',
'd_match_type' => 'wildcard',
'd_parent_domain' => 'traffic-example.com',
'd_seed_scope' => 'host',
'd_statis' => '',
'd_logo_type' => 0,
'd_text_logo' => 'Traffic Example',
'd_img_logo' => '',
'd_content_encode' => 0,
'info_id' => 0,
'd_seo_cfg' => '{"forge":{"route_group":"film"},"structured_data":{"organization_name":"Traffic Example Studio"}}',
'tkd_mode' => '2',
'tkd_provider' => 'local',
],
];
}
@@ -175,8 +262,45 @@ class DomainBatchImportTemplateHelper
{
return [
'headers' => self::getHeaders(),
'display_headers' => self::getDisplayHeaders(),
'header_labels' => self::getHeaderLabels(),
'column_notes' => self::getColumnNotes(),
'example_rows' => self::getExampleRows(),
'template_type' => 'site_domain_import',
'filling_guidance' => [
'main_fields' => [
'd_domain',
'd_name',
't_id',
'd_index_title',
'd_index_keywords',
'd_index_description',
'strategy_profile',
],
'optional_fields' => [
'd_keywords',
'd_description',
'd_baidu_token',
'd_match_type',
'd_parent_domain',
'd_seed_scope',
'd_statis',
'd_logo_type',
'd_text_logo',
'd_img_logo',
'd_content_encode',
'info_id',
'd_seo_cfg',
'tkd_mode',
'tkd_provider',
],
],
'operator_notice' => [
'基础导入模板只负责站点基础信息,不负责 GPT 启动样本字段。',
'运营通常只需要先填写域名、站点名称、模板ID、首页标题、首页关键词、首页描述、SEO策略。',
'其余字段大多数都有默认值或属于技术配置,可留空。',
'如果要做 GPT 供料与启动包,请改用 domain-supply-batch-template.xlsx。',
],
'supported_strategy_profiles' => ['standard', 'traffic', 'expand', 'closed'],
'supported_match_types' => ['exact', 'wildcard'],
'supported_seed_scopes' => ['domain', 'host'],
@@ -189,19 +313,28 @@ class DomainBatchImportTemplateHelper
$fileName = 'site-domain-import-template.xlsx';
$filePath = $outputRoot . '/' . $fileName;
$summary = self::buildSummary();
$headerLabels = self::getHeaderLabels();
$rows = array_map(
static function (array $row): array {
return array_combine(self::getHeaders(), $row) ?: [];
static function (array $row) use ($headerLabels): array {
$displayRow = [];
foreach (self::getHeaders() as $field) {
$displayRow[$headerLabels[$field] ?? $field] = (string)($row[$field] ?? '');
}
return $displayRow;
},
self::getExampleRows()
);
$displayNotes = [];
foreach (self::getColumnNotes() as $field => $note) {
$displayNotes[self::buildNoteDisplayLabel($field)] = $note;
}
self::writeWorkbook(
$filePath,
'site-domain-import',
self::getHeaders(),
self::getDisplayHeaders(),
$rows,
self::getColumnNotes()
$displayNotes
);
return [

View File

@@ -125,6 +125,8 @@ class DomainBootstrapApplyHelper
$DomainModel->d_seo_cfg = $arrNormalizedPayload['d_seo_cfg'] ?? $DomainModel->d_seo_cfg;
$DomainModel->save();
DomainModel::flushAllDomianOnCache();
SeoResourcePoolHelper::initSitePositioning((string)$DomainModel->d_domain, 1, true);
DomainSitemapGenerationHelper::queueForDomains([(string)$DomainModel->d_domain]);
$arrSummary['status'] = 'applied';
return $arrSummary;

View File

@@ -61,16 +61,34 @@ class DomainBootstrapRegisterHelper
$arrSeoCfgInput = [];
}
$strTkdProviderInput = trim((string)($arrPayload['tkd_provider'] ?? $arrPayload['seo_tkd_provider'] ?? ($arrSeoCfgInput['tkd']['provider'] ?? DomainModel::TKD_PROVIDER_LOCAL)));
$strTkdProviderNormalized = DomainModel::normalizeTkdProvider($strTkdProviderInput);
$strTkdModeInput = trim((string)($arrPayload['tkd_mode'] ?? $arrPayload['seo_tkd_mode'] ?? ($arrSeoCfgInput['tkd']['mode'] ?? '')));
if ($strTkdModeInput === '') {
$strTkdModeInput = $boolHasImportedHomeTkd ? DomainModel::TKD_MODE_FORCE_IMPORT : DomainModel::TKD_MODE_AUTO_GENERATE;
$strTkdModeInput = $strTkdProviderNormalized === DomainModel::TKD_PROVIDER_OPENAI
? DomainModel::TKD_MODE_AI_OPTIMIZE
: ($boolHasImportedHomeTkd ? DomainModel::TKD_MODE_FORCE_IMPORT : DomainModel::TKD_MODE_AUTO_GENERATE);
}
if (DomainSeoNamingHelper::shouldOptimizeForOpenAi($strTkdProviderNormalized, $strTkdModeInput)) {
$arrSeoDefaults = DomainSeoNamingHelper::buildSeoDefaults(
$strSiteName,
$strDomain,
$strRawIndexTitle,
$strRawIndexKeywords,
$strRawIndexDescription
);
$strSiteName = $arrSeoDefaults['site_name'];
$strSiteKeywords = trim((string)($arrPayload['d_keywords'] ?? $strSiteName));
$strSiteDescription = trim((string)($arrPayload['d_description'] ?? ($strSiteName . '内容整理与推荐。')));
$strIndexTitle = $arrSeoDefaults['index_title'];
$strIndexKeywords = $arrSeoDefaults['index_keywords'];
$strIndexDescription = $arrSeoDefaults['index_description'];
}
$strTkdProviderInput = trim((string)($arrPayload['tkd_provider'] ?? $arrPayload['seo_tkd_provider'] ?? ($arrSeoCfgInput['tkd']['provider'] ?? DomainModel::TKD_PROVIDER_LOCAL)));
$arrSeoCfgInput['tkd'] = array_merge(
(array)($arrSeoCfgInput['tkd'] ?? []),
[
'mode' => DomainModel::normalizeTkdMode($strTkdModeInput),
'provider' => DomainModel::normalizeTkdProvider($strTkdProviderInput),
'provider' => $strTkdProviderNormalized,
]
);
@@ -175,6 +193,7 @@ class DomainBootstrapRegisterHelper
DomainModel::flushAllDomianOnCache();
SeoCopyGenerationHelper::initializeForDomain($DomainModel);
SeoResourcePoolHelper::initSitePositioning((string)$DomainModel->d_domain, 1, true);
DomainSitemapGenerationHelper::queueForDomains([(string)$DomainModel->d_domain]);
$arrSummary['status'] = 'created';
$arrSummary['found'] = true;

View File

@@ -10,7 +10,7 @@ class DomainExternalSeoManualImportHelper
{
public static function defaultRoot(string $strCodeRoot): string
{
return rtrim($strCodeRoot, '/') . '/public/_admin_templates/domain-seo-external/manual-import';
return rtrim($strCodeRoot, '/') . '/storage/external-seo/manual-import';
}
public static function getHeaders(): array

View File

@@ -6,12 +6,12 @@ namespace app\common\helper;
class DomainExternalSeoManualImportRunIndexHelper
{
protected static function publicRelativePath(string $strPath): string
protected static function storageRelativePath(string $strPath): string
{
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$strStorageRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/storage', '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strPublicRoot . '/')) {
return substr($strPath, strlen($strPublicRoot . '/'));
if (str_starts_with($strPath, $strStorageRoot . '/')) {
return substr($strPath, strlen($strStorageRoot . '/'));
}
return $strPath;
@@ -47,16 +47,16 @@ class DomainExternalSeoManualImportRunIndexHelper
$arrItems[] = [
'run_id' => basename($strRunRoot),
'run_root_public' => self::publicRelativePath($strRunRoot),
'run_root_public' => self::storageRelativePath($strRunRoot),
'status' => (string)($arrSummary['status'] ?? 'empty'),
'provider_key' => (string)($arrSummary['provider_key'] ?? 'manual_csv_import'),
'rows_total' => self::countCsvRows($strCsvPath),
'hosts_count' => self::countCsvHosts($strCsvPath),
'metrics' => (array)($arrSummary['metrics'] ?? []),
'uploaded_report_path' => $strUploadedPath !== '' ? self::publicRelativePath($strUploadedPath) : '',
'csv_path' => is_file($strCsvPath) ? self::publicRelativePath($strCsvPath) : '',
'summary_json_path' => self::publicRelativePath($strSummaryPath),
'summary_html_path' => is_file($strRunRoot . '/seo-external-real.summary.html') ? self::publicRelativePath($strRunRoot . '/seo-external-real.summary.html') : '',
'uploaded_report_path' => $strUploadedPath !== '' ? self::storageRelativePath($strUploadedPath) : '',
'csv_path' => is_file($strCsvPath) ? self::storageRelativePath($strCsvPath) : '',
'summary_json_path' => self::storageRelativePath($strSummaryPath),
'summary_html_path' => is_file($strRunRoot . '/seo-external-real.summary.html') ? self::storageRelativePath($strRunRoot . '/seo-external-real.summary.html') : '',
'updated_at' => date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time())),
];
}
@@ -69,16 +69,16 @@ class DomainExternalSeoManualImportRunIndexHelper
$arrSummary = is_array($arrDecoded) ? $arrDecoded : [];
array_unshift($arrItems, [
'run_id' => 'latest_manual_import',
'run_root_public' => self::publicRelativePath($strLatestDir),
'run_root_public' => self::storageRelativePath($strLatestDir),
'status' => (string)($arrSummary['status'] ?? 'empty'),
'provider_key' => (string)($arrSummary['provider_key'] ?? 'manual_csv_import'),
'rows_total' => self::countCsvRows($strLatestDir . '/manual-seo-import.csv'),
'hosts_count' => self::countCsvHosts($strLatestDir . '/manual-seo-import.csv'),
'metrics' => (array)($arrSummary['metrics'] ?? []),
'uploaded_report_path' => '',
'csv_path' => self::publicRelativePath($strLatestDir . '/manual-seo-import.csv'),
'summary_json_path' => self::publicRelativePath($strLatestSummaryJsonPath),
'summary_html_path' => self::publicRelativePath(dirname($strRoot) . '/latest/seo-external-real.summary.html'),
'csv_path' => self::storageRelativePath($strLatestDir . '/manual-seo-import.csv'),
'summary_json_path' => self::storageRelativePath($strLatestSummaryJsonPath),
'summary_html_path' => self::storageRelativePath(dirname($strRoot) . '/latest/seo-external-real.summary.html'),
'updated_at' => date(DATE_ATOM, (int)(filemtime($strLatestSummaryJsonPath) ?: time())),
]);
}

View File

@@ -16,7 +16,7 @@ class DomainExternalSeoSnapshotAnalysisHelper
$arrProviders = [];
$arrHosts = [];
$arrLatestIndexByHost = [];
$arrLatestSignalsByHost = [];
$intKeywordSnapshots = (int)SeoExternalSnapshotModel::where('metric_date', '>=', $strStartDate)
->where('scope', 'keyword')
->count();
@@ -28,9 +28,10 @@ class DomainExternalSeoSnapshotAnalysisHelper
self::eachSnapshotRow(
SeoExternalSnapshotModel::where('metric_date', '>=', $strStartDate)
->where('scope', 'host'),
['id', 'provider', 'host', 'scope', 'metric_date', 'status', 'indexed_status', 'baidu_pc_ip_range', 'baidu_mobile_ip_range', 'pc_keyword_count', 'mobile_keyword_count', 'queried_at'],
static function (array $arrItem) use (&$arrProviders, &$arrHosts, &$arrLatestIndexByHost, &$strLatestMetricDate): void {
['id', 'provider', 'snapshot_type', 'host', 'scope', 'metric_date', 'status', 'indexed_status', 'result_count_text', 'baidu_pc_ip_range', 'baidu_mobile_ip_range', 'pc_keyword_count', 'mobile_keyword_count', 'queried_at'],
static function (array $arrItem) use (&$arrProviders, &$arrHosts, &$arrLatestSignalsByHost, &$strLatestMetricDate): void {
$strProvider = trim((string)($arrItem['provider'] ?? ''));
$strSnapshotType = trim((string)($arrItem['snapshot_type'] ?? ''));
$strHost = trim((string)($arrItem['host'] ?? ''));
$strMetricDate = trim((string)($arrItem['metric_date'] ?? ''));
$strStatus = trim((string)($arrItem['status'] ?? ''));
@@ -48,34 +49,33 @@ class DomainExternalSeoSnapshotAnalysisHelper
}
if ($strHost !== '') {
$strHostKey = $strHost;
$arrCurrent = $arrLatestIndexByHost[$strHostKey] ?? null;
$boolShouldReplace = false;
if (!$arrCurrent) {
$boolShouldReplace = true;
} else {
$strCurrentDate = (string)($arrCurrent['metric_date'] ?? '');
$intCurrentQueriedAt = (int)($arrCurrent['queried_at'] ?? 0);
if (strcmp($strCurrentDate, $strMetricDate) < 0) {
$boolShouldReplace = true;
} elseif ($strCurrentDate === $strMetricDate && $intCurrentQueriedAt < $intQueriedAt) {
$boolShouldReplace = true;
}
$arrCandidate = [
'host' => $strHost,
'provider' => $strProvider,
'snapshot_type' => $strSnapshotType,
'metric_date' => $strMetricDate,
'queried_at' => $intQueriedAt,
'status' => $strStatus,
'indexed_status' => $strIndexedStatus,
'result_count_text' => trim((string)($arrItem['result_count_text'] ?? '')),
'baidu_pc_ip_range' => trim((string)($arrItem['baidu_pc_ip_range'] ?? '')),
'baidu_mobile_ip_range' => trim((string)($arrItem['baidu_mobile_ip_range'] ?? '')),
'pc_keyword_count' => (int)($arrItem['pc_keyword_count'] ?? 0),
'mobile_keyword_count' => (int)($arrItem['mobile_keyword_count'] ?? 0),
];
if (self::shouldReplaceSnapshot($arrLatestSignalsByHost[$strHost]['display'] ?? null, $strMetricDate, $intQueriedAt)) {
$arrLatestSignalsByHost[$strHost]['display'] = $arrCandidate;
}
if ($boolShouldReplace) {
$arrLatestIndexByHost[$strHostKey] = [
'host' => $strHost,
'provider' => $strProvider,
'metric_date' => $strMetricDate,
'queried_at' => $intQueriedAt,
'status' => $strStatus,
'indexed_status' => $strIndexedStatus,
'baidu_pc_ip_range' => trim((string)($arrItem['baidu_pc_ip_range'] ?? '')),
'baidu_mobile_ip_range' => trim((string)($arrItem['baidu_mobile_ip_range'] ?? '')),
'pc_keyword_count' => (int)($arrItem['pc_keyword_count'] ?? 0),
'mobile_keyword_count' => (int)($arrItem['mobile_keyword_count'] ?? 0),
];
if ($strSnapshotType === 'site_query'
&& self::shouldReplaceSnapshot($arrLatestSignalsByHost[$strHost]['index'] ?? null, $strMetricDate, $intQueriedAt)) {
$arrLatestSignalsByHost[$strHost]['index'] = $arrCandidate;
}
if ($strSnapshotType === 'aizhan_summary'
&& self::shouldReplaceSnapshot($arrLatestSignalsByHost[$strHost]['keyword'] ?? null, $strMetricDate, $intQueriedAt)) {
$arrLatestSignalsByHost[$strHost]['keyword'] = $arrCandidate;
}
}
},
@@ -85,13 +85,44 @@ class DomainExternalSeoSnapshotAnalysisHelper
$intIndexedLikeCount = 0;
$intKeywordReadyCount = 0;
$intTrafficReadyCount = 0;
$arrHostRows = array_values($arrLatestIndexByHost);
$arrHostRows = [];
foreach ($arrLatestSignalsByHost as $strHost => $arrSignals) {
$arrDisplay = (array)($arrSignals['display'] ?? []);
$arrIndex = (array)($arrSignals['index'] ?? []);
$arrKeyword = (array)($arrSignals['keyword'] ?? []);
$arrProvidersForHost = [];
foreach ([$arrIndex, $arrKeyword, $arrDisplay] as $arrSignalRow) {
$strSignalProvider = trim((string)($arrSignalRow['provider'] ?? ''));
if ($strSignalProvider !== '') {
$arrProvidersForHost[$strSignalProvider] = true;
}
}
$arrHostRows[] = [
'host' => $strHost,
'provider' => implode(',', array_keys($arrProvidersForHost)),
'index_provider' => (string)($arrIndex['provider'] ?? ''),
'keyword_provider' => (string)($arrKeyword['provider'] ?? ''),
'metric_date' => max((string)($arrDisplay['metric_date'] ?? ''), (string)($arrIndex['metric_date'] ?? ''), (string)($arrKeyword['metric_date'] ?? '')),
'queried_at' => max((int)($arrDisplay['queried_at'] ?? 0), (int)($arrIndex['queried_at'] ?? 0), (int)($arrKeyword['queried_at'] ?? 0)),
'status' => (string)($arrDisplay['status'] ?? ''),
'indexed_status' => (string)($arrIndex['indexed_status'] ?? ''),
'result_count_text' => (string)($arrIndex['result_count_text'] ?? ''),
'baidu_pc_ip_range' => (string)($arrKeyword['baidu_pc_ip_range'] ?? ''),
'baidu_mobile_ip_range' => (string)($arrKeyword['baidu_mobile_ip_range'] ?? ''),
'pc_keyword_count' => (int)($arrKeyword['pc_keyword_count'] ?? 0),
'mobile_keyword_count' => (int)($arrKeyword['mobile_keyword_count'] ?? 0),
];
}
foreach ($arrHostRows as &$arrRow) {
$boolIndexedLike = (string)($arrRow['indexed_status'] ?? '') === 'indexed_like';
$boolIndexedLike = self::normalizeIndexState($arrRow) === 'indexed_like';
$boolKeywordReady = (int)($arrRow['pc_keyword_count'] ?? 0) > 0 || (int)($arrRow['mobile_keyword_count'] ?? 0) > 0;
$boolTrafficReady = !self::isZeroRange((string)($arrRow['baidu_pc_ip_range'] ?? ''))
|| !self::isZeroRange((string)($arrRow['baidu_mobile_ip_range'] ?? ''));
$arrRow['indexed_like'] = $boolIndexedLike;
$arrRow['keyword_ready'] = $boolKeywordReady;
$arrRow['traffic_ready'] = $boolTrafficReady;
@@ -113,6 +144,11 @@ class DomainExternalSeoSnapshotAnalysisHelper
if ($intLeftKeyword !== $intRightKeyword) {
return $intRightKeyword <=> $intLeftKeyword;
}
$intLeftIndexed = !empty($arrLeft['indexed_like']) ? 1 : 0;
$intRightIndexed = !empty($arrRight['indexed_like']) ? 1 : 0;
if ($intLeftIndexed !== $intRightIndexed) {
return $intRightIndexed <=> $intLeftIndexed;
}
return strcmp((string)($arrLeft['host'] ?? ''), (string)($arrRight['host'] ?? ''));
});
@@ -402,6 +438,19 @@ class DomainExternalSeoSnapshotAnalysisHelper
return $strRange === '' || $strRange === '0 ~ 0' || $strRange === '0~0';
}
protected static function shouldReplaceSnapshot(?array $arrCurrent, string $strMetricDate, int $intQueriedAt): bool
{
if (!$arrCurrent) {
return true;
}
$strCurrentDate = (string)($arrCurrent['metric_date'] ?? '');
$intCurrentQueriedAt = (int)($arrCurrent['queried_at'] ?? 0);
return strcmp($strCurrentDate, $strMetricDate) < 0
|| ($strCurrentDate === $strMetricDate && $intCurrentQueriedAt < $intQueriedAt);
}
protected static function normalizeIndexState(array $arrRow): string
{
$strIndexedStatus = trim((string)($arrRow['indexed_status'] ?? ''));

View File

@@ -6,12 +6,12 @@ namespace app\common\helper;
class DomainExternalSeoSnapshotRunIndexHelper
{
protected static function publicRelativePath(string $strPath): string
protected static function storageRelativePath(string $strPath): string
{
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$strStorageRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/storage', '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strPublicRoot . '/')) {
return substr($strPath, strlen($strPublicRoot . '/'));
if (str_starts_with($strPath, $strStorageRoot . '/')) {
return substr($strPath, strlen($strStorageRoot . '/'));
}
return $strPath;
@@ -60,7 +60,7 @@ class DomainExternalSeoSnapshotRunIndexHelper
$arrItems[] = [
'run_id' => basename($strRunRoot),
'run_root_public' => self::publicRelativePath($strRunRoot),
'run_root_public' => self::storageRelativePath($strRunRoot),
'source' => (string)($arrSummary['source'] ?? ''),
'received_count' => $intReceivedCount,
'inserted_count' => (int)($arrSummary['inserted_count'] ?? 0),
@@ -82,9 +82,9 @@ class DomainExternalSeoSnapshotRunIndexHelper
'post_ingest_snapshot_trend' => (array)($arrSummary['post_ingest_snapshot_trend'] ?? []),
'post_ingest_health_label' => (string)(($arrSummary['post_ingest_overview']['health_label'] ?? '')),
'post_ingest_trend_label' => (string)(($arrSummary['post_ingest_snapshot_trend']['success_trend']['label'] ?? '')),
'summary_json_path' => self::publicRelativePath($strSummaryPath),
'summary_html_path' => self::publicRelativePath($strRunRoot . '/snapshot-ingest.summary.html'),
'payload_json_path' => self::publicRelativePath($strRunRoot . '/snapshots.payload.json'),
'summary_json_path' => self::storageRelativePath($strSummaryPath),
'summary_html_path' => self::storageRelativePath($strRunRoot . '/snapshot-ingest.summary.html'),
'payload_json_path' => self::storageRelativePath($strRunRoot . '/snapshots.payload.json'),
'updated_at' => date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time())),
];
}

View File

@@ -6,12 +6,12 @@ namespace app\common\helper;
class DomainExternalSeoSnapshotValidateRunIndexHelper
{
protected static function publicRelativePath(string $strPath): string
protected static function storageRelativePath(string $strPath): string
{
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$strStorageRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/storage', '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strPublicRoot . '/')) {
return substr($strPath, strlen($strPublicRoot . '/'));
if (str_starts_with($strPath, $strStorageRoot . '/')) {
return substr($strPath, strlen($strStorageRoot . '/'));
}
return $strPath;
@@ -50,7 +50,7 @@ class DomainExternalSeoSnapshotValidateRunIndexHelper
$arrItems[] = [
'run_id' => basename($strRunRoot),
'run_root_public' => self::publicRelativePath($strRunRoot),
'run_root_public' => self::storageRelativePath($strRunRoot),
'received_count' => $intReceivedCount,
'valid_count' => (int)($arrSummary['valid_count'] ?? 0),
'error_count' => $intErrorCount,
@@ -63,9 +63,9 @@ class DomainExternalSeoSnapshotValidateRunIndexHelper
'warnings' => (array)($arrSummary['warnings'] ?? []),
'error_buckets' => (array)($arrSummary['error_buckets'] ?? []),
'warning_buckets' => (array)($arrSummary['warning_buckets'] ?? []),
'summary_json_path' => self::publicRelativePath($strSummaryPath),
'summary_html_path' => self::publicRelativePath($strRunRoot . '/snapshot-validate.summary.html'),
'payload_json_path' => self::publicRelativePath($strRunRoot . '/snapshots.payload.json'),
'summary_json_path' => self::storageRelativePath($strSummaryPath),
'summary_html_path' => self::storageRelativePath($strRunRoot . '/snapshot-validate.summary.html'),
'payload_json_path' => self::storageRelativePath($strRunRoot . '/snapshots.payload.json'),
'updated_at' => date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time())),
];
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportAsyncJobHelper
{
public const STATUS_QUEUED = 'queued';
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
public static function createJob(string $type, array $payload = [], array $meta = []): array
{
$baseRoot = self::baseRoot();
self::ensureDir($baseRoot);
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$jobId = date('His') . '_' . trim($type, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$jobRoot = $dateDir . '/' . $jobId;
self::ensureDir($jobRoot);
$job = [
'job_id' => $jobId,
'type' => $type,
'label' => self::labelForType($type),
'status' => self::STATUS_QUEUED,
'progress_percent' => 0,
'progress_current' => 0,
'progress_total' => 0,
'current_step' => 'queued',
'message' => '任务已入队,等待执行',
'payload' => $payload,
'meta' => $meta,
'summary' => [],
'created_at' => date(DATE_ATOM),
'updated_at' => date(DATE_ATOM),
'started_at' => '',
'finished_at' => '',
'job_root' => $jobRoot,
'state_path' => self::statePath($jobRoot),
'log_path' => self::logPath($jobRoot),
];
file_put_contents($job['state_path'], json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($job['log_path'], '[' . date('Y-m-d H:i:s') . "] 已入队 {$job['label']}" . PHP_EOL);
return $job;
}
public static function readJob(string $jobId): array
{
$jobRoot = self::findJobRoot($jobId);
if ($jobRoot === '') {
return [];
}
$statePath = self::statePath($jobRoot);
if (!is_file($statePath)) {
return [];
}
$job = json_decode((string)file_get_contents($statePath), true);
return is_array($job) ? $job : [];
}
public static function updateJob(string $jobId, array $patch): array
{
$job = self::readJob($jobId);
if (empty($job)) {
throw new \RuntimeException('Job not found: ' . $jobId);
}
$job = array_merge($job, $patch);
$job['updated_at'] = date(DATE_ATOM);
if (($patch['status'] ?? '') === self::STATUS_RUNNING && empty($job['started_at'])) {
$job['started_at'] = date(DATE_ATOM);
}
if (in_array((string)($patch['status'] ?? ''), [self::STATUS_SUCCESS, self::STATUS_FAILED], true)) {
$job['finished_at'] = date(DATE_ATOM);
$job['progress_percent'] = 100;
}
file_put_contents((string)$job['state_path'], json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $job;
}
public static function appendLog(string $jobId, string $message): void
{
$job = self::readJob($jobId);
if (empty($job)) {
return;
}
$line = '[' . date('Y-m-d H:i:s') . '] ' . trim($message) . PHP_EOL;
file_put_contents((string)$job['log_path'], $line, FILE_APPEND);
self::updateJob($jobId, ['last_log' => trim($message)]);
}
public static function updateProgress(string $jobId, int $current, int $total, string $step = '', string $message = ''): array
{
$percent = $total > 0 ? (int)floor(($current / $total) * 100) : 0;
return self::updateJob($jobId, [
'progress_current' => $current,
'progress_total' => $total,
'progress_percent' => max(0, min(100, $percent)),
'current_step' => $step !== '' ? $step : 'running',
'message' => $message !== '' ? $message : (($step !== '' ? $step : 'running') . " {$current}/{$total}"),
]);
}
public static function readLogTail(string $jobId, int $lines = 120): array
{
$job = self::readJob($jobId);
if (empty($job) || empty($job['log_path']) || !is_file((string)$job['log_path'])) {
return [];
}
$content = trim((string)file_get_contents((string)$job['log_path']));
if ($content === '') {
return [];
}
$allLines = preg_split('/\r\n|\r|\n/', $content);
return array_values(array_slice(is_array($allLines) ? $allLines : [], -1 * max(1, $lines)));
}
public static function labelForType(string $type): string
{
$map = [
'failed_queue_rerun' => '失败队列重跑',
'failure_remediation' => '失败补料候选',
'self_healing' => '自动修复',
'health_workbench_refresh' => '健康台落盘',
'spider_md_generate' => '蜘蛛池MD生成',
'seo_copy_ai_generate' => 'AI文案生成',
'seo_copy_ai_optimize' => 'AI文案重优化',
'seo_copy_ai_rollback' => 'AI文案回滚',
];
return (string)($map[$type] ?? $type);
}
public static function baseRoot(): string
{
return dirname(__DIR__, 3) . '/storage/domain_import_async_jobs';
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
protected static function findJobRoot(string $jobId): string
{
$jobId = trim($jobId);
if ($jobId === '') {
return '';
}
$matches = glob(self::baseRoot() . '/*/' . $jobId);
if (!is_array($matches) || empty($matches)) {
return '';
}
return (string)$matches[0];
}
protected static function statePath(string $jobRoot): string
{
return rtrim($jobRoot, '/') . '/job.state.json';
}
protected static function logPath(string $jobRoot): string
{
return rtrim($jobRoot, '/') . '/job.log';
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportFailureRemediationHelper
{
public static function buildPlan(array $arrQueueItem): array
{
$strHost = trim((string)($arrQueueItem['host'] ?? ''));
$strFailedStage = trim((string)($arrQueueItem['failed_stage'] ?? ''));
$arrFailedStages = array_values(array_filter((array)($arrQueueItem['failed_stages'] ?? []), 'is_string'));
$strPrimaryStage = $strFailedStage !== '' ? $strFailedStage : (string)($arrFailedStages[0] ?? '');
$arrPlan = [
'status' => 'planned',
'kind' => 'reprobe',
'title' => '重新探测',
'summary' => '当前失败先按自动样本发现 + 页面弹道重探处理。',
'recommended_command' => self::buildProbeCommand($strHost),
'sample_command' => self::buildSampleCommand($strHost),
'probe_command' => self::buildProbeCommand($strHost),
'pipeline_command' => self::buildPipelineCommand($strHost, $strPrimaryStage),
'post_action' => '执行补料候选后,再重新跑页面弹道自检。',
'priority' => 'medium',
];
if ($strHost === '') {
$arrPlan['status'] = 'invalid';
$arrPlan['summary'] = 'host 缺失,暂时无法生成补料候选链。';
$arrPlan['recommended_command'] = '';
return $arrPlan;
}
if (in_array($strPrimaryStage, ['sample', 'domain', 'host'], true)) {
$arrPlan['kind'] = 'sample_rebuild';
$arrPlan['title'] = '重建样本并重探';
$arrPlan['summary'] = '当前优先重建自动样本,再重新跑首页/搜索/详情/播放弹道。';
$arrPlan['recommended_command'] = self::buildSampleCommand($strHost);
$arrPlan['post_action'] = '样本重建后继续执行页面弹道自检。';
$arrPlan['priority'] = 'high';
return $arrPlan;
}
if (in_array($strPrimaryStage, ['search', 'detail', 'play', 'home', 'category'], true)) {
$arrPlan['kind'] = 'pipeline_candidate';
$arrPlan['title'] = '补料候选链';
$arrPlan['summary'] = '当前页面链已能定位到失败阶段,优先走 bootstrap/pipeline dry-run 候选链,再回到弹道自检。';
$arrPlan['recommended_command'] = self::buildPipelineCommand($strHost, $strPrimaryStage);
$arrPlan['priority'] = in_array($strPrimaryStage, ['detail', 'play'], true) ? 'high' : 'medium';
return $arrPlan;
}
return $arrPlan;
}
protected static function buildSampleCommand(string $strHost): string
{
return 'php scripts/domain_auto_sample_discover.php ' . escapeshellarg($strHost) . ' --format=text';
}
protected static function buildProbeCommand(string $strHost): string
{
return 'php scripts/domain_trajectory_probe.php ' . escapeshellarg($strHost) . ' --format=text';
}
protected static function buildPipelineCommand(string $strHost, string $strFailedStage): string
{
$arrArgs = [
'php scripts/domain_bootstrap_pipeline_run.php',
'--host=' . escapeshellarg($strHost),
'--dry-run=1',
'--step-limit=3',
'--allow-prepare=1',
'--format=text',
];
if (in_array($strFailedStage, ['detail', 'play'], true)) {
$arrArgs[] = '--allow-release-dry-run=1';
}
return implode(' ', $arrArgs);
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportHealthWorkbenchIndexHelper
{
protected static function publicRelativePath(string $path): string
{
$publicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$path = str_replace('\\', '/', $path);
if (str_starts_with($path, $publicRoot . '/')) {
return substr($path, strlen($publicRoot . '/'));
}
return $path;
}
public static function buildSummary(string $runRoot, int $limit = 20): array
{
$runRoot = rtrim($runRoot, '/');
if ($runRoot === '' || !is_dir($runRoot)) {
return [
'items' => [],
'total' => 0,
'latest_run' => [],
];
}
$summaryFiles = array_merge(
(array)glob($runRoot . '/*/import-health-workbench.summary.json'),
(array)glob($runRoot . '/*/*/import-health-workbench.summary.json')
);
$summaryFiles = array_values(array_filter(array_unique($summaryFiles), 'is_file'));
usort($summaryFiles, static function (string $left, string $right): int {
return ((int)(filemtime($right) ?: 0)) <=> ((int)(filemtime($left) ?: 0));
});
$items = [];
foreach (array_slice($summaryFiles, 0, max(1, $limit)) as $summaryPath) {
$data = json_decode((string)file_get_contents($summaryPath), true);
if (!is_array($data)) {
continue;
}
$runDir = dirname($summaryPath);
$healthLabel = (string)($data['health_label'] ?? '');
$workbenchStage = (string)($data['workbench_stage'] ?? '');
$activeCount = (int)($data['active_count'] ?? 0);
$resolvedCount = (int)($data['resolved_count'] ?? 0);
[$alertLevel, $alertReason] = self::resolveAlert($healthLabel, $workbenchStage, $activeCount);
$items[] = [
'run_id' => basename($runDir),
'run_root_public' => self::publicRelativePath($runDir),
'health_label' => $healthLabel,
'workbench_stage' => $workbenchStage,
'hero_summary' => (string)($data['hero_summary'] ?? ''),
'active_count' => $activeCount,
'queue_count' => (int)($data['queue_count'] ?? 0),
'resolved_count' => $resolvedCount,
'alert_level' => $alertLevel,
'alert_reason' => $alertReason,
'summary_json_path' => self::publicRelativePath($summaryPath),
'summary_html_path' => self::publicRelativePath($runDir . '/import-health-workbench.summary.html'),
'updated_at' => date(DATE_ATOM, (int)(filemtime($summaryPath) ?: time())),
];
}
$alertBuckets = [
'high' => 0,
'medium' => 0,
'low' => 0,
'none' => 0,
];
foreach ($items as $item) {
$level = (string)($item['alert_level'] ?? 'none');
if (!isset($alertBuckets[$level])) {
$alertBuckets[$level] = 0;
}
$alertBuckets[$level]++;
}
$topAttentionRuns = array_values(array_filter($items, static function (array $item): bool {
return in_array((string)($item['alert_level'] ?? 'none'), ['high', 'medium'], true);
}));
usort($topAttentionRuns, static function (array $left, array $right): int {
$priority = ['high' => 3, 'medium' => 2, 'low' => 1, 'none' => 0];
return ($priority[(string)($right['alert_level'] ?? 'none')] ?? 0) <=> ($priority[(string)($left['alert_level'] ?? 'none')] ?? 0);
});
return [
'items' => $items,
'total' => count($items),
'latest_run' => $items[0] ?? [],
'alert_buckets' => $alertBuckets,
'top_attention_runs' => array_slice($topAttentionRuns, 0, 5),
];
}
protected static function resolveAlert(string $healthLabel, string $workbenchStage, int $activeCount): array
{
if ($activeCount > 0 && $healthLabel === 'worsening') {
return ['high', '当前失败仍存在且趋势上升'];
}
if ($activeCount > 0 && in_array($workbenchStage, ['needs_attention', 'needs_self_healing'], true)) {
return ['medium', '当前失败仍需要自动修复或重点关注'];
}
if ($activeCount === 0 && $workbenchStage === 'observe_recovery') {
return ['low', '当前失败已清空,继续观察恢复稳定性'];
}
return ['none', '当前导入主线平稳'];
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportHealthWorkbenchRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'health_workbench'): string
{
$baseRoot = rtrim($baseRoot, '/');
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$runId = date('His') . '_' . trim($prefix, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
self::ensureDir($runRoot);
return $runRoot;
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
public static function persist(array $summary, string $runRoot, array $meta = []): array
{
self::ensureDir($runRoot);
$summary['meta'] = array_merge((array)($summary['meta'] ?? []), $meta);
$summary['generated_at'] = (string)($summary['generated_at'] ?? date(DATE_ATOM));
$summaryJsonPath = $runRoot . '/import-health-workbench.summary.json';
$summaryHtmlPath = $runRoot . '/import-health-workbench.summary.html';
file_put_contents($summaryJsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($summaryHtmlPath, self::renderHtml($summary));
$summary['summary_json_path'] = $summaryJsonPath;
$summary['summary_html_path'] = $summaryHtmlPath;
file_put_contents($summaryJsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $summary;
}
protected static function renderHtml(array $summary): string
{
$priorityActions = (array)($summary['priority_actions'] ?? []);
$topFailedStages = (array)($summary['top_failed_stages'] ?? []);
$recoveryMix = (array)($summary['recovery_mix'] ?? []);
$renderList = static function (array $items, callable $formatter): string {
if (!$items) {
return '<li>-</li>';
}
$html = '';
foreach ($items as $item) {
$html .= '<li>' . $formatter($item) . '</li>';
}
return $html;
};
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>导入健康台摘要</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}h1{margin-bottom:8px;}ul{margin-top:8px;}code{background:#f3f3f3;padding:2px 4px;}table{border-collapse:collapse;width:100%;margin-top:16px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;}th{background:#f6f6f6;}</style>'
. '</head><body>'
. '<h1>导入健康台摘要</h1>'
. '<p>生成时间:<code>' . htmlspecialchars(DomainImportReportViewHelper::formatDateTime((string)($summary['generated_at'] ?? '')), ENT_QUOTES, 'UTF-8') . '</code></p>'
. '<p>健康状态:<strong>' . htmlspecialchars(DomainImportReportViewHelper::translateHealthLabel((string)($summary['health_label'] ?? '')), ENT_QUOTES, 'UTF-8') . '</strong> / 当前阶段:<strong>' . htmlspecialchars(DomainImportReportViewHelper::translateStage((string)($summary['workbench_stage'] ?? '')), ENT_QUOTES, 'UTF-8') . '</strong></p>'
. '<p>摘要:' . htmlspecialchars((string)($summary['hero_summary'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
. '<h3>优先动作</h3><ul>'
. $renderList($priorityActions, static function (array $item): string {
return htmlspecialchars((string)($item['label'] ?? '-'), ENT_QUOTES, 'UTF-8') . ' / '
. htmlspecialchars((string)($item['summary'] ?? '-'), ENT_QUOTES, 'UTF-8');
})
. '</ul>'
. '<h3>主要失败阶段</h3><ul>'
. $renderList($topFailedStages, static function (array $item): string {
return htmlspecialchars(DomainImportReportViewHelper::translateStage((string)($item['stage'] ?? '-')), ENT_QUOTES, 'UTF-8') . '' . (int)($item['count'] ?? 0);
})
. '</ul>'
. '<h3>恢复路径</h3>'
. '<p>主路径:<strong>' . htmlspecialchars(DomainImportReportViewHelper::translatePrimaryPath((string)($recoveryMix['primary_path'] ?? '')), ENT_QUOTES, 'UTF-8') . '</strong></p>'
. '<p>已恢复总数:<strong>' . (int)($recoveryMix['resolved_total'] ?? 0) . '</strong> / 重跑恢复:<strong>' . (int)($recoveryMix['resolved_by_rerun_count'] ?? 0) . '</strong> / 补料恢复:<strong>' . (int)($recoveryMix['resolved_by_remediation_count'] ?? 0) . '</strong></p>'
. '</body></html>';
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportHealthWorkbenchTrendHelper
{
public static function buildSummary(string $runRoot, int $limit = 10): array
{
$indexSummary = DomainImportHealthWorkbenchIndexHelper::buildSummary($runRoot, $limit);
$items = (array)($indexSummary['items'] ?? []);
$latestRun = (array)($indexSummary['latest_run'] ?? []);
$previousRun = (array)($items[1] ?? []);
$latestActiveCount = (int)($latestRun['active_count'] ?? 0);
$previousActiveCount = (int)($previousRun['active_count'] ?? $latestActiveCount);
$direction = $latestActiveCount <=> $previousActiveCount;
$label = $latestActiveCount === $previousActiveCount
? 'flat'
: ($latestActiveCount < $previousActiveCount ? 'improving' : 'worsening');
$stageBuckets = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$stage = trim((string)($item['workbench_stage'] ?? ''));
if ($stage === '') {
$stage = 'unknown';
}
if (!isset($stageBuckets[$stage])) {
$stageBuckets[$stage] = 0;
}
$stageBuckets[$stage]++;
}
arsort($stageBuckets);
$bucketItems = [];
foreach (array_slice($stageBuckets, 0, 5, true) as $stage => $count) {
$bucketItems[] = [
'stage' => $stage,
'count' => (int)$count,
];
}
[$alertLevel, $alertReason] = self::resolveAlert(
$label,
(string)($latestRun['workbench_stage'] ?? ''),
$latestActiveCount,
$previousActiveCount
);
return [
'generated_at' => date(DATE_ATOM),
'runs_count' => (int)($indexSummary['total'] ?? 0),
'latest_run' => $latestRun,
'previous_run' => $previousRun,
'latest_active_count' => $latestActiveCount,
'previous_active_count' => $previousActiveCount,
'latest_health_label' => (string)($latestRun['health_label'] ?? ''),
'previous_health_label' => (string)($previousRun['health_label'] ?? ''),
'latest_stage' => (string)($latestRun['workbench_stage'] ?? ''),
'previous_stage' => (string)($previousRun['workbench_stage'] ?? ''),
'direction' => $direction,
'label' => $label,
'alert_level' => $alertLevel,
'alert_reason' => $alertReason,
'stage_buckets' => $bucketItems,
];
}
protected static function resolveAlert(string $label, string $latestStage, int $latestActiveCount, int $previousActiveCount): array
{
if ($label === 'worsening' && $latestActiveCount > 0) {
return ['high', '健康台趋势上升且当前仍有失败 host'];
}
if (in_array($latestStage, ['needs_attention', 'needs_self_healing'], true) && $latestActiveCount > 0) {
return ['medium', '当前趋势仍需重点观察自动修复是否接管'];
}
if ($label === 'flat' && $latestActiveCount === 0 && $previousActiveCount === 0) {
return ['low', '当前趋势持平且失败队列已清空,继续观察恢复稳定性'];
}
return ['none', '当前健康台趋势平稳'];
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportManualAttentionHandleRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'manual_attention'): string
{
$baseRoot = rtrim($baseRoot, '/');
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$runId = date('His') . '_' . trim($prefix, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
self::ensureDir($runRoot);
return $runRoot;
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
public static function persist(array $summary, string $runRoot, array $meta = []): array
{
self::ensureDir($runRoot);
$summary['meta'] = array_merge((array)($summary['meta'] ?? []), $meta);
$summary['generated_at'] = (string)($summary['generated_at'] ?? date(DATE_ATOM));
$summaryJsonPath = $runRoot . '/import-manual-attention.summary.json';
$summaryHtmlPath = $runRoot . '/import-manual-attention.summary.html';
file_put_contents($summaryJsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($summaryHtmlPath, self::renderHtml($summary));
$summary['summary_json_path'] = $summaryJsonPath;
$summary['summary_html_path'] = $summaryHtmlPath;
file_put_contents($summaryJsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $summary;
}
protected static function renderHtml(array $summary): string
{
$rows = [];
foreach ((array)($summary['items'] ?? []) as $item) {
$rows[] = '<tr>'
. '<td>' . htmlspecialchars((string)($item['host'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($item['action'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($item['status'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($item['reprobe_status'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($item['message'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Domain Import Manual Attention Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;margin-top:16px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{background:#f3f3f3;padding:2px 4px;}</style>'
. '</head><body>'
. '<h1>Domain Import Manual Attention Summary</h1>'
. '<p>generated_at: <code>' . htmlspecialchars((string)($summary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</code></p>'
. '<p>processed: <strong>' . (int)($summary['processed_count'] ?? 0) . '</strong> / resolved: <strong>' . (int)($summary['resolved_count'] ?? 0) . '</strong> / deferred: <strong>' . (int)($summary['deferred_count'] ?? 0) . '</strong> / ignored: <strong>' . (int)($summary['ignored_count'] ?? 0) . '</strong> / failed: <strong>' . (int)($summary['failed_count'] ?? 0) . '</strong></p>'
. '<table><thead><tr><th>host</th><th>action</th><th>status</th><th>reprobe_status</th><th>message</th></tr></thead><tbody>'
. implode('', $rows)
. '</tbody></table></body></html>';
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportRemediationRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'remediation'): string
{
$baseRoot = rtrim($baseRoot, '/');
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$runId = date('His') . '_' . trim($prefix, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
self::ensureDir($runRoot);
return $runRoot;
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
public static function persist(array $arrSummary, string $strRunRoot, array $arrMeta = []): array
{
self::ensureDir($strRunRoot);
$arrSummary['meta'] = array_merge((array)($arrSummary['meta'] ?? []), $arrMeta);
$arrSummary['generated_at'] = (string)($arrSummary['generated_at'] ?? date(DATE_ATOM));
$strSummaryJsonPath = $strRunRoot . '/import-remediation.summary.json';
$strSummaryHtmlPath = $strRunRoot . '/import-remediation.summary.html';
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strSummaryHtmlPath, self::renderHtml($arrSummary));
$arrSummary['summary_json_path'] = $strSummaryJsonPath;
$arrSummary['summary_html_path'] = $strSummaryHtmlPath;
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . htmlspecialchars((string)($arrItem['host'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['status'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['remediation_kind'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['remediation_status'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['failed_stage'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['message'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Domain Import Remediation Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;margin-top:16px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{background:#f3f3f3;padding:2px 4px;}</style>'
. '</head><body>'
. '<h1>Domain Import Remediation Summary</h1>'
. '<p>generated_at: <code>' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</code></p>'
. '<p>processed: <strong>' . (int)($arrSummary['processed_count'] ?? 0) . '</strong> / passed: <strong>' . (int)($arrSummary['passed_count'] ?? 0) . '</strong> / failed: <strong>' . (int)($arrSummary['failed_count'] ?? 0) . '</strong> / recovered: <strong>' . (int)($arrSummary['recovered_count'] ?? 0) . '</strong></p>'
. '<table><thead><tr><th>host</th><th>status</th><th>remediation_kind</th><th>remediation_status</th><th>failed_stage</th><th>message</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table></body></html>';
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportReportViewHelper
{
public static function formatDateTime(string $value): string
{
$value = trim($value);
if ($value === '') {
return '-';
}
$timestamp = strtotime($value);
if ($timestamp === false) {
return $value;
}
return date('Y-m-d H:i:s', $timestamp);
}
public static function translateStatus(string $status): string
{
$status = trim($status);
if ($status === '') {
return '-';
}
$map = [
'queued' => '已排队',
'running' => '执行中',
'success' => '成功',
'failed' => '失败',
'passed' => '通过',
'partial' => '部分通过',
'sample_discovery_failed' => '样本发现失败',
'pending' => '待处理',
'observing' => '观察中',
'done' => '已完成',
'ignored' => '已忽略',
'imported' => '已导入',
];
return $map[$status] ?? $status;
}
public static function translateStage(string $stage): string
{
$stage = trim($stage);
if ($stage === '') {
return '-';
}
$map = [
'home' => '首页',
'search' => '搜索页',
'detail' => '详情页',
'play' => '播放页',
'sample' => '样本发现',
'idle' => '未开始',
'steady' => '稳定',
'observe_recovery' => '恢复观察',
'needs_attention' => '需要关注',
'needs_self_healing' => '等待自动修复',
'needs_rerun' => '等待重跑',
'starting' => '开始执行',
'prepare' => '准备中',
'sample_discovery' => '样本发现',
'probing' => '页面探测',
'probe_failed' => '探测失败',
'health_workbench' => '健康台落盘',
'completed' => '已完成',
];
return $map[$stage] ?? $stage;
}
public static function translateMessage(string $message): string
{
$message = trim($message);
if ($message === '') {
return '-';
}
$map = [
'Trajectory probe found failed stages.' => '页面链路探测发现失败阶段。',
'Trajectory probe passed.' => '页面链路探测通过。',
'No probe checks were produced.' => '未生成有效的探测检查结果。',
];
return $map[$message] ?? $message;
}
public static function translateCheckDetail(string $detail): string
{
$detail = trim($detail);
if ($detail === '') {
return '-';
}
if (preg_match('/^http_(\d+)$/', $detail, $matches)) {
return 'HTTP 响应异常 ' . $matches[1];
}
if ($detail === 'ok') {
return '正常';
}
if ($detail === 'passed') {
return '通过';
}
return $detail;
}
public static function translateCheckStatus(int $status): string
{
return $status === 1 ? '通过' : '失败';
}
public static function translateHealthLabel(string $label): string
{
$label = trim($label);
if ($label === '') {
return '-';
}
$map = [
'steady' => '平稳',
'attention' => '需要关注',
'worsening' => '风险上升',
'waiting_self_healing' => '等待自动修复',
'growing' => '持续改善',
];
return $map[$label] ?? $label;
}
public static function translateTrendLabel(string $label): string
{
$label = trim($label);
if ($label === '') {
return '-';
}
$map = [
'flat' => '持平',
'improving' => '改善中',
'worsening' => '上升中',
];
return $map[$label] ?? $label;
}
public static function translatePrimaryPath(string $path): string
{
$path = trim($path);
if ($path === '') {
return '-';
}
$map = [
'none' => '暂无明显主路径',
'rerun' => '主要靠重跑恢复',
'remediation' => '主要靠补料恢复',
'balanced' => '重跑与补料共同恢复',
];
return $map[$path] ?? $path;
}
public static function translateClosureStage(string $stage): string
{
$stage = trim($stage);
if ($stage === '') {
return '-';
}
$map = [
'import_not_started' => '导入未开始',
'import_recovery_needed' => '导入恢复中',
'manual_intervention_needed' => '需要人工介入',
'external_waiting_data' => '等待站外数据',
'waiting_indexing' => '等待收录',
'indexing_without_keywords' => '已有收录待起词',
'seo_attention' => '站外效果需关注',
'seo_growing' => '站外效果增长中',
'closed_loop_running' => '闭环运行中',
];
return $map[$stage] ?? $stage;
}
public static function translateResultLabel(string $label): string
{
$label = trim($label);
if ($label === '') {
return '-';
}
$map = [
'pending' => '待观察',
'improved' => '有改善',
'no_change' => '无明显变化',
'regression' => '出现回退',
];
return $map[$label] ?? $label;
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportRerunRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'rerun'): string
{
$baseRoot = rtrim($baseRoot, '/');
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$runId = date('His') . '_' . trim($prefix, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
self::ensureDir($runRoot);
return $runRoot;
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
public static function persist(array $arrSummary, string $strRunRoot, array $arrMeta = []): array
{
self::ensureDir($strRunRoot);
$arrSummary['meta'] = array_merge((array)($arrSummary['meta'] ?? []), $arrMeta);
$arrSummary['generated_at'] = (string)($arrSummary['generated_at'] ?? date(DATE_ATOM));
$strSummaryJsonPath = $strRunRoot . '/import-rerun.summary.json';
$strSummaryHtmlPath = $strRunRoot . '/import-rerun.summary.html';
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strSummaryHtmlPath, self::renderHtml($arrSummary));
$arrSummary['summary_json_path'] = $strSummaryJsonPath;
$arrSummary['summary_html_path'] = $strSummaryHtmlPath;
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
$strHost = htmlspecialchars((string)($arrItem['host'] ?? ''), ENT_QUOTES, 'UTF-8');
$strStatus = htmlspecialchars(DomainImportReportViewHelper::translateStatus((string)($arrItem['status'] ?? '')), ENT_QUOTES, 'UTF-8');
$strMessage = htmlspecialchars(DomainImportReportViewHelper::translateMessage((string)($arrItem['message'] ?? '')), ENT_QUOTES, 'UTF-8');
$strFailedStage = htmlspecialchars(DomainImportReportViewHelper::translateStage((string)($arrItem['failed_stage'] ?? '')), ENT_QUOTES, 'UTF-8');
$arrRows[] = '<tr>'
. '<td>' . $strHost . '</td>'
. '<td>' . $strStatus . '</td>'
. '<td>' . $strFailedStage . '</td>'
. '<td>' . $strMessage . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>站点导入重跑摘要</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;margin-top:16px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{background:#f3f3f3;padding:2px 4px;}</style>'
. '</head><body>'
. '<h1>站点导入重跑摘要</h1>'
. '<p>生成时间:<code>' . htmlspecialchars(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? '')), ENT_QUOTES, 'UTF-8') . '</code></p>'
. '<p>处理总数:<strong>' . (int)($arrSummary['processed_count'] ?? 0) . '</strong> / 通过:<strong>' . (int)($arrSummary['passed_count'] ?? 0) . '</strong> / 失败:<strong>' . (int)($arrSummary['failed_count'] ?? 0) . '</strong></p>'
. '<table><thead><tr><th>域名</th><th>状态</th><th>失败阶段</th><th>说明</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table></body></html>';
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportSelfHealingPolicyHelper
{
public static function buildPlan(
string $importRunRoot,
string $rerunRoot,
string $remediationRoot,
string $selfHealingRoot,
int $limit = 10,
int $cooldownSeconds = 14400,
int $failureThreshold = 3
): array {
$limit = max(1, min(100, $limit));
$cooldownSeconds = max(0, $cooldownSeconds);
$failureThreshold = max(1, min(20, $failureThreshold));
$queueSummary = DomainImportFailedQueueHelper::buildSummary($importRunRoot, 200);
$queueItems = array_values((array)($queueSummary['items'] ?? []));
$latestAttemptMap = self::buildLatestAttemptMap($rerunRoot, $remediationRoot);
$recentFailureCountMap = self::buildRecentFailureCountMap($rerunRoot, $remediationRoot, 20);
$eligibleItems = [];
$skippedItems = [];
$downgradedItems = [];
foreach ($queueItems as $item) {
if (!is_array($item)) {
continue;
}
$host = trim((string)($item['host'] ?? ''));
if ($host === '') {
continue;
}
$attempt = (array)($latestAttemptMap[$host] ?? []);
$recentFailureCount = (int)($recentFailureCountMap[$host] ?? 0);
$lastAttemptAt = (string)($attempt['updated_at'] ?? '');
$lastAttemptTimestamp = $lastAttemptAt !== '' ? (int)(strtotime($lastAttemptAt) ?: 0) : 0;
$cooldownRemaining = 0;
if ($cooldownSeconds > 0 && $lastAttemptTimestamp > 0) {
$cooldownRemaining = max(0, ($lastAttemptTimestamp + $cooldownSeconds) - time());
}
$policyItem = [
'host' => $host,
'failed_stage' => (string)($item['failed_stage'] ?? ''),
'updated_at' => (string)($item['updated_at'] ?? ''),
'recent_failure_count' => $recentFailureCount,
'last_attempt_kind' => (string)($attempt['kind'] ?? ''),
'last_attempt_status' => (string)($attempt['status'] ?? ''),
'last_attempt_updated_at' => $lastAttemptAt,
'cooldown_remaining_seconds' => $cooldownRemaining,
'policy_state' => 'eligible',
'policy_reason' => '可进入自动修复',
];
if ($recentFailureCount >= $failureThreshold) {
$policyItem['policy_state'] = 'downgraded';
$policyItem['policy_reason'] = sprintf('最近已连续失败 %d 次,建议转人工关注', $recentFailureCount);
$downgradedItems[] = $policyItem;
continue;
}
if ($cooldownRemaining > 0) {
$policyItem['policy_state'] = 'skipped';
$policyItem['policy_reason'] = sprintf('距离最近一次自动修复尝试过近,剩余冷却 %d 秒', $cooldownRemaining);
$skippedItems[] = $policyItem;
continue;
}
$eligibleItems[] = $policyItem;
}
usort($eligibleItems, static function (array $left, array $right): int {
return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? ''));
});
usort($skippedItems, static function (array $left, array $right): int {
return ((int)($right['cooldown_remaining_seconds'] ?? 0)) <=> ((int)($left['cooldown_remaining_seconds'] ?? 0));
});
usort($downgradedItems, static function (array $left, array $right): int {
return ((int)($right['recent_failure_count'] ?? 0)) <=> ((int)($left['recent_failure_count'] ?? 0));
});
$selectedHosts = array_map(
static fn (array $item): string => (string)($item['host'] ?? ''),
array_slice($eligibleItems, 0, $limit)
);
return [
'generated_at' => date(DATE_ATOM),
'queue_count' => count($queueItems),
'limit' => $limit,
'cooldown_seconds' => $cooldownSeconds,
'failure_threshold' => $failureThreshold,
'eligible_count' => count($eligibleItems),
'selected_count' => count($selectedHosts),
'skipped_count' => count($skippedItems),
'downgraded_count' => count($downgradedItems),
'selected_hosts' => array_values(array_filter($selectedHosts)),
'eligible_items' => $eligibleItems,
'skipped_items' => $skippedItems,
'downgraded_items' => $downgradedItems,
];
}
protected static function buildLatestAttemptMap(string $rerunRoot, string $remediationRoot): array
{
$runs = array_merge(
self::readAttemptRuns($rerunRoot, 'import-rerun.summary.json', 'rerun', 20),
self::readAttemptRuns($remediationRoot, 'import-remediation.summary.json', 'remediation', 20)
);
usort($runs, static function (array $left, array $right): int {
return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? ''));
});
$map = [];
foreach ($runs as $run) {
$host = trim((string)($run['host'] ?? ''));
if ($host === '' || isset($map[$host])) {
continue;
}
$map[$host] = $run;
}
return $map;
}
protected static function buildRecentFailureCountMap(string $rerunRoot, string $remediationRoot, int $maxFiles): array
{
$runs = array_merge(
self::readAttemptRuns($rerunRoot, 'import-rerun.summary.json', 'rerun', $maxFiles),
self::readAttemptRuns($remediationRoot, 'import-remediation.summary.json', 'remediation', $maxFiles)
);
$counts = [];
foreach ($runs as $run) {
$host = trim((string)($run['host'] ?? ''));
$status = trim((string)($run['status'] ?? ''));
if ($host === '' || $status === '' || $status === 'passed') {
continue;
}
if (!isset($counts[$host])) {
$counts[$host] = 0;
}
$counts[$host]++;
}
return $counts;
}
protected static function readAttemptRuns(string $baseRoot, string $summaryFileName, string $kind, int $maxFiles): array
{
$baseRoot = rtrim($baseRoot, '/');
if ($baseRoot === '' || !is_dir($baseRoot)) {
return [];
}
$summaryFiles = array_merge(
(array)glob($baseRoot . '/*/' . $summaryFileName),
(array)glob($baseRoot . '/*/*/' . $summaryFileName)
);
$summaryFiles = array_values(array_filter(array_unique($summaryFiles), 'is_file'));
usort($summaryFiles, static function (string $left, string $right): int {
return ((int)(filemtime($right) ?: 0)) <=> ((int)(filemtime($left) ?: 0));
});
$items = [];
foreach (array_slice($summaryFiles, 0, max(1, $maxFiles)) as $summaryPath) {
$data = json_decode((string)file_get_contents($summaryPath), true);
if (!is_array($data)) {
continue;
}
$updatedAt = date(DATE_ATOM, (int)(filemtime($summaryPath) ?: time()));
foreach ((array)($data['items'] ?? []) as $item) {
if (!is_array($item)) {
continue;
}
$host = trim((string)($item['host'] ?? ''));
if ($host === '') {
continue;
}
$items[] = [
'host' => $host,
'kind' => $kind,
'status' => (string)($item['status'] ?? ''),
'updated_at' => $updatedAt,
'run_id' => basename(dirname($summaryPath)),
];
}
}
return $items;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportSelfHealingRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'self_healing'): string
{
$baseRoot = rtrim($baseRoot, '/');
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$runId = date('His') . '_' . trim($prefix, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
self::ensureDir($runRoot);
return $runRoot;
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
public static function persist(array $arrSummary, string $strRunRoot, array $arrMeta = []): array
{
self::ensureDir($strRunRoot);
$arrSummary['meta'] = array_merge((array)($arrSummary['meta'] ?? []), $arrMeta);
$arrSummary['generated_at'] = (string)($arrSummary['generated_at'] ?? date(DATE_ATOM));
$strSummaryJsonPath = $strRunRoot . '/import-self-healing.summary.json';
$strSummaryHtmlPath = $strRunRoot . '/import-self-healing.summary.html';
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strSummaryHtmlPath, self::renderHtml($arrSummary));
$arrSummary['summary_json_path'] = $strSummaryJsonPath;
$arrSummary['summary_html_path'] = $strSummaryHtmlPath;
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrTrendSummary = (array)($arrSummary['trend_summary'] ?? []);
$arrFailureTrend = (array)($arrTrendSummary['failure_trend'] ?? []);
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>导入自动修复摘要</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;margin-top:16px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{background:#f3f3f3;padding:2px 4px;}</style>'
. '</head><body>'
. '<h1>导入自动修复摘要</h1>'
. '<p>生成时间:<code>' . htmlspecialchars(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? '')), ENT_QUOTES, 'UTF-8') . '</code></p>'
. '<p>状态:<strong>' . htmlspecialchars(DomainImportReportViewHelper::translateStatus((string)($arrSummary['status'] ?? '')), ENT_QUOTES, 'UTF-8') . '</strong></p>'
. '<p>当前失败数:<strong>' . (int)($arrTrendSummary['queue_count'] ?? 0) . '</strong> / 重跑记录数:<strong>' . (int)($arrTrendSummary['rerun_runs_count'] ?? 0) . '</strong> / 补料记录数:<strong>' . (int)($arrTrendSummary['remediation_runs_count'] ?? 0) . '</strong></p>'
. '<p>补料恢复数:<strong>' . (int)($arrTrendSummary['resolved_by_remediation_count'] ?? 0) . '</strong> / 失败趋势:<strong>' . htmlspecialchars(DomainImportReportViewHelper::translateTrendLabel((string)($arrFailureTrend['label'] ?? '')), ENT_QUOTES, 'UTF-8') . '</strong></p>'
. '</body></html>';
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
class DomainSeoNamingHelper
{
public static function shouldOptimizeForOpenAi(?string $strTkdProvider, ?string $strTkdMode): bool
{
$strProvider = DomainModel::normalizeTkdProvider((string)$strTkdProvider);
$strMode = DomainModel::normalizeTkdMode((string)$strTkdMode);
return $strProvider === DomainModel::TKD_PROVIDER_OPENAI
|| $strMode === DomainModel::TKD_MODE_AI_OPTIMIZE;
}
public static function normalizeSiteName(string $strSiteName, string $strDomain = ''): string
{
$strSiteName = trim($strSiteName);
$strSiteName = preg_replace('/\s+/u', '', $strSiteName) ?: $strSiteName;
$strSiteName = trim($strSiteName, "-_\t\n\r\0\x0B .|");
if ($strSiteName === '') {
$strSiteName = self::fallbackNameFromDomain($strDomain);
}
foreach (['影视网', '电影网', '剧情网', '短剧网', '影视', '影院', '视频', '短剧', '剧情'] as $suffix) {
$quoted = preg_quote($suffix, '/');
$strSiteName = preg_replace('/(' . $quoted . '){2,}$/u', $suffix, $strSiteName) ?: $strSiteName;
}
$strSiteName = preg_replace('/^(.{2,12}?)\1$/u', '$1', $strSiteName) ?: $strSiteName;
$strSiteName = preg_replace('/官网网$/u', '官网', $strSiteName) ?: $strSiteName;
return trim($strSiteName) !== '' ? trim($strSiteName) : self::fallbackNameFromDomain($strDomain);
}
public static function buildSeoDefaults(
string $strSiteName,
string $strDomain = '',
string $strIndexTitle = '',
string $strIndexKeywords = '',
string $strIndexDescription = ''
): array {
$strSiteName = self::normalizeSiteName($strSiteName, $strDomain);
if (trim($strIndexTitle) === '') {
$strIndexTitle = $strSiteName . '-高清影视内容精选-每日更新';
}
if (trim($strIndexKeywords) === '') {
$strIndexKeywords = implode(',', array_values(array_unique(array_filter([
$strSiteName,
'高清影视',
'热播短剧',
'剧情介绍',
'热门推荐',
]))));
}
if (trim($strIndexDescription) === '') {
$strIndexDescription = $strSiteName . '专注于高清影视、热播短剧与剧情内容整理,支持分类浏览、搜索查找和热门推荐,内容持续更新。';
}
return [
'site_name' => $strSiteName,
'index_title' => trim($strIndexTitle),
'index_keywords' => trim($strIndexKeywords),
'index_description' => trim($strIndexDescription),
];
}
protected static function fallbackNameFromDomain(string $strDomain): string
{
$strDomain = strtolower(trim($strDomain));
$strDomain = preg_replace('/^https?:\/\//', '', $strDomain) ?: $strDomain;
$strDomain = preg_replace('/^www\./', '', $strDomain) ?: $strDomain;
$strDomain = explode('/', $strDomain)[0] ?? $strDomain;
$strDomain = explode('.', $strDomain)[0] ?? $strDomain;
$strDomain = str_replace(['-', '_'], '', $strDomain);
return $strDomain !== '' ? $strDomain : '影视站';
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
use app\task\logic\VideoSiteMapLogic;
use microserver\QueueManage;
class DomainSitemapGenerationHelper
{
public static function normalizeDomains(array $arrDomains): array
{
return array_values(array_unique(array_filter(array_map(
static fn($mValue): string => DomainModel::normalizeStoredDomain((string)$mValue),
$arrDomains
))));
}
public static function queueForDomains(array $arrDomains): void
{
$arrDomains = self::normalizeDomains($arrDomains);
if (empty($arrDomains)) {
return;
}
$arrTask = [
'callback' => [VideoSiteMapLogic::class, 'generateSiteMap'],
'data' => [
'domains' => $arrDomains,
],
];
$QueueManage = new QueueManage(2);
$QueueManage->set($arrTask);
}
public static function generateNow(array $arrDomains): void
{
$arrDomains = self::normalizeDomains($arrDomains);
if (empty($arrDomains)) {
return;
}
$VideoSiteMapLogic = new VideoSiteMapLogic();
$VideoSiteMapLogic->generateSiteMap([
'domains' => $arrDomains,
]);
}
}

View File

@@ -15,6 +15,9 @@ class DomainSpiderCrawlLogHelper
$arrItem['host'] = strtolower(trim((string)($arrItem['host'] ?? '')));
$arrItem['path'] = trim((string)($arrItem['path'] ?? ''));
$arrItem['page_type'] = DomainSpiderCrawlLogSchemaHelper::normalizePageType((string)($arrItem['page_type'] ?? 'other'));
if ($arrItem['page_type'] === 'other') {
$arrItem['page_type'] = DomainSpiderCrawlLogSchemaHelper::inferPageTypeFromPath((string)$arrItem['path'], 'other');
}
$arrItem['bot_name'] = DomainSpiderCrawlLogSchemaHelper::normalizeBotName((string)($arrItem['bot_name'] ?? 'other_bot'));
$arrItem['status'] = (int)($arrItem['status'] ?? 0);
$arrItem['count'] = max(0, (int)($arrItem['count'] ?? 0));
@@ -26,6 +29,9 @@ class DomainSpiderCrawlLogHelper
$arrItem['host'] = strtolower(trim((string)($arrItem['host'] ?? '')));
$arrItem['url'] = trim((string)($arrItem['url'] ?? ''));
$arrItem['page_type'] = DomainSpiderCrawlLogSchemaHelper::normalizePageType((string)($arrItem['page_type'] ?? 'other'));
if ($arrItem['page_type'] === 'other') {
$arrItem['page_type'] = DomainSpiderCrawlLogSchemaHelper::inferPageTypeFromPath((string)$arrItem['url'], 'other');
}
$arrItem['bot_name'] = DomainSpiderCrawlLogSchemaHelper::normalizeBotName((string)($arrItem['bot_name'] ?? 'other_bot'));
$arrItem['status'] = (int)($arrItem['status'] ?? 0);
$arrItem['cache_status'] = self::normalizeCacheStatus((string)($arrItem['cache_status'] ?? ''));

View File

@@ -202,6 +202,60 @@ class DomainSpiderCrawlLogSchemaHelper
return in_array($strPageType, self::buildSchema()['page_type_enum'], true) ? $strPageType : 'other';
}
public static function inferPageTypeFromPath(string $strPath, string $strFallback = 'other'): string
{
$strPath = strtolower(trim($strPath));
if ($strPath === '') {
return self::normalizePageType($strFallback);
}
$strPath = parse_url($strPath, PHP_URL_PATH) ?: $strPath;
$strPath = '/' . ltrim($strPath, '/');
$strNormalized = trim($strPath, '/');
if ($strNormalized === '' || $strNormalized === 'index.php') {
return 'home';
}
if ($strNormalized === 'robots.txt') {
return 'robots';
}
if (str_contains($strNormalized, 'sitemap')) {
return 'sitemap';
}
if (str_starts_with($strNormalized, 'search') || str_starts_with($strNormalized, 'get-index')) {
return 'search';
}
if (
str_contains($strNormalized, 'videotype')
|| str_contains($strNormalized, 'vodtype')
|| str_contains($strNormalized, 'fenlei')
) {
return 'category';
}
if (
str_starts_with($strNormalized, 'voddetail/')
|| str_starts_with($strNormalized, 'video-info/')
|| str_starts_with($strNormalized, 'neirong-')
) {
return 'detail';
}
if (
str_starts_with($strNormalized, 'vodplay/')
|| str_starts_with($strNormalized, 'video-play/')
|| str_starts_with($strNormalized, 'bf-')
) {
return 'play';
}
return self::normalizePageType($strFallback);
}
public static function normalizeBotName(string $strBotName): string
{
$strValue = strtolower(trim($strBotName));

View File

@@ -6,6 +6,8 @@ namespace app\common\helper;
class DomainSpiderCrawlWorkbenchHelper
{
protected const MAX_AGGREGATE_RUNS = 12;
protected static function codeRootFromStorageRoot(string $storageRoot): string
{
return rtrim(dirname(rtrim($storageRoot, '/')), '/');
@@ -308,7 +310,7 @@ class DomainSpiderCrawlWorkbenchHelper
continue;
}
$items[] = [
'summary' => $summary,
'summary_path' => (string)$summaryPath,
'normalized' => self::normalizeRunSummary($publicRoot, $summary, (string)$summaryPath),
'generated_at_ts' => $generatedTimestamp,
];
@@ -336,7 +338,14 @@ class DomainSpiderCrawlWorkbenchHelper
$anomalyRecords = [];
$latestGeneratedAt = '';
foreach ($runEntries as $entry) {
$summary = is_array($entry['summary'] ?? null) ? (array)$entry['summary'] : [];
$summaryPath = trim((string)($entry['summary_path'] ?? ''));
if ($summaryPath === '') {
continue;
}
$summary = self::readJsonFile($summaryPath);
if (empty($summary)) {
continue;
}
$generatedAt = trim((string)($summary['generated_at'] ?? ''));
if ($generatedAt !== '' && ($latestGeneratedAt === '' || strcmp($generatedAt, $latestGeneratedAt) > 0)) {
$latestGeneratedAt = $generatedAt;
@@ -363,6 +372,15 @@ class DomainSpiderCrawlWorkbenchHelper
return $aggregated;
}
protected static function limitAggregateRunEntries(array $runEntries): array
{
if (count($runEntries) <= self::MAX_AGGREGATE_RUNS) {
return $runEntries;
}
return array_slice($runEntries, 0, self::MAX_AGGREGATE_RUNS);
}
protected static function buildRecentRuns(array $runEntries, int $limit): array
{
$items = [];
@@ -993,7 +1011,7 @@ class DomainSpiderCrawlWorkbenchHelper
$rows = [];
foreach ($windows as $hours) {
$runEntries = self::collectRunEntries($publicRoot, $runsRoot, $hours);
$summary = self::aggregateRunSummaries($runEntries);
$summary = self::aggregateRunSummaries(self::limitAggregateRunEntries($runEntries));
$summary = self::filterSummaryByBotScope($summary, $botScope);
$summary = self::filterSummaryBySelectedBots($summary, $selectedBots);
$metrics = self::summarizeWindow($summary, $host);
@@ -1241,7 +1259,7 @@ class DomainSpiderCrawlWorkbenchHelper
$recentRuns = self::buildRecentRuns($runEntries, $runLimit);
if ($windowHours > 0 && !empty($runEntries)) {
$latestSummary = self::aggregateRunSummaries($runEntries);
$latestSummary = self::aggregateRunSummaries(self::limitAggregateRunEntries($runEntries));
}
if (empty($latestSummary) && !empty($recentRuns)) {

View File

@@ -17,20 +17,33 @@ class DomainSpiderMdRunHelper
return dirname(__DIR__, 3) . '/storage/spider_md_runs';
}
public static function activeRunRoot(): string
{
return self::baseRoot() . '/active';
}
public static function buildSummary(int $limit = 20): array
{
$limit = max(1, min(100, $limit));
$runs = [];
SpiderMdConfigHelper::ensureSystemConfigDefaults();
$gitee = self::resolveGiteeConfig();
$github = self::resolveGithubConfig();
unset($gitee['token']);
$matches = glob(self::baseRoot() . '/*/*/summary.json');
if (is_array($matches)) {
rsort($matches);
foreach (array_slice($matches, 0, $limit) as $summaryPath) {
$payload = self::readJsonFile((string)$summaryPath);
if (!empty($payload)) {
$runs[] = $payload;
unset($github['token']);
$activeSummaryPath = self::activeRunRoot() . '/summary.json';
$activePayload = self::readJsonFile($activeSummaryPath);
if (!empty($activePayload)) {
$runs[] = $activePayload;
} else {
$matches = glob(self::baseRoot() . '/*/*/summary.json');
if (is_array($matches)) {
rsort($matches);
foreach (array_slice($matches, 0, $limit) as $summaryPath) {
$payload = self::readJsonFile((string)$summaryPath);
if (!empty($payload)) {
$runs[] = $payload;
}
}
}
}
@@ -39,6 +52,7 @@ class DomainSpiderMdRunHelper
'generated_at' => date(DATE_ATOM),
'run_root' => self::baseRoot(),
'gitee' => $gitee,
'github' => $github,
'runtime_config' => self::resolveRuntimeConfig(),
'config_summary' => SpiderMdConfigHelper::buildConfigSummary(),
'latest_run' => $runs[0] ?? null,
@@ -50,51 +64,38 @@ class DomainSpiderMdRunHelper
{
$config = self::resolveRuntimeConfig($options);
$giteeConfig = self::resolveGiteeConfig();
$githubConfig = self::resolveGithubConfig();
self::ensureDir(self::baseRoot());
self::ensureDir(self::activeRunRoot());
$forceRegenerate = !empty($options['force_regenerate']);
$previous = self::findLatestSuccessfulRunSummary();
$today = date('Ymd');
$giteeTargetChanged = !empty($previous) ? self::giteeTargetChanged($previous, $giteeConfig) : false;
$remoteTargetChanged = !empty($previous)
? (self::giteeTargetChanged($previous, $giteeConfig) || self::githubTargetChanged($previous, $githubConfig))
: false;
$previousGiteePushStatus = trim((string)($previous['gitee_push_status'] ?? ''));
$allowRetryToday = in_array($previousGiteePushStatus, ['failed', 'partial_failed'], true);
$previousGithubPushStatus = trim((string)($previous['github_push_status'] ?? ''));
$allowRetryPush = in_array($previousGiteePushStatus, ['failed', 'partial_failed'], true)
|| in_array($previousGithubPushStatus, ['failed', 'partial_failed'], true);
$forceFullRemoteSync = $forceRegenerate || empty($previous) || $remoteTargetChanged || $allowRetryPush;
if (
!$forceRegenerate
&& !empty($previous)
&& substr((string)($previous['generated_at'] ?? ''), 0, 10) === date('Y-m-d')
&& !$giteeTargetChanged
&& !$allowRetryToday
) {
self::log($logger, '今日已生成过蜘蛛池 MD跳过本轮如需全新生成请使用强制重生成');
$previous['status'] = 'skipped';
$previous['skipped_reason'] = 'already_generated_today';
$previous['runtime_config'] = $config;
return $previous;
}
if ($forceRegenerate || empty($previous) || $giteeTargetChanged) {
if ($forceRegenerate || empty($previous)) {
if ($forceRegenerate) {
self::clearAllRuns();
} elseif ($giteeTargetChanged) {
self::log($logger, '检测到 Gitee 推送目标已变更,本轮按全新生成处理');
}
$dateDir = self::baseRoot() . '/' . $today;
self::ensureDir($dateDir);
$runId = date('His') . '_spider_md_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
$runRoot = self::activeRunRoot();
self::ensureDir($runRoot);
self::log($logger, $forceRegenerate ? '开始强制重生成蜘蛛池 MD' : '开始首次生成蜘蛛池 MD');
} else {
$runId = (string)($previous['run_id'] ?? '');
$runRoot = (string)($previous['run_root'] ?? '');
if ($runId === '' || $runRoot === '') {
throw new \RuntimeException('历史蜘蛛池MD运行目录无效无法续写');
}
$runId = date('His') . '_spider_md_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = self::activeRunRoot();
self::ensureDir($runRoot);
if ($allowRetryToday) {
self::log($logger, '检测到上一轮 Gitee 推送失败,本轮继续沿用当前目录并重试推送');
} else {
self::log($logger, '开始增量续写蜘蛛池 MD');
self::log($logger, '开始增量续写蜘蛛池 MD');
if ($remoteTargetChanged) {
self::log($logger, '检测到远端推送目标已变更,本轮保留现有分片,仅执行全量远端同步');
}
if ($allowRetryPush) {
self::log($logger, '检测到上一轮远端推送失败,本轮保留现有分片并重试远端同步');
}
}
@@ -105,6 +106,7 @@ class DomainSpiderMdRunHelper
$existingState = self::loadExistingRunState($runRoot, (int)$config['max_links_per_file']);
$aggregateLinks = [];
$writtenFiles = (array)($existingState['files'] ?? []);
$changedFiles = [];
$chunkState = [
'records' => (array)($existingState['pending_records'] ?? []),
'max_links_per_file' => (int)$config['max_links_per_file'],
@@ -112,6 +114,7 @@ class DomainSpiderMdRunHelper
'generated_at' => date('Y-m-d H:i:s'),
'run_root' => $runRoot,
'files' => &$writtenFiles,
'changed_files' => &$changedFiles,
'known_urls' => (array)($existingState['known_urls'] ?? []),
'reused_last_file' => (string)($existingState['reused_last_file'] ?? ''),
];
@@ -147,10 +150,14 @@ class DomainSpiderMdRunHelper
/** @var DomainModel $domain */
$domain = $domainRow['domain'];
$forgePolicy = $domain->getForgeSeoCfg();
$existingDetailCount = (int)(($existingState['detail_count_by_host'] ?? [])[$host] ?? 0);
$existingForgeCount = (int)(($existingState['forge_count_by_host'] ?? [])[$host] ?? 0);
$detailLimit = (int)$config['detail_limit_per_host'];
$forgeLimit = (int)$config['forge_limit_per_host'];
$domainProgress[$host] = [
'row' => $domainRow,
'detail_remaining' => (int)$config['detail_limit_per_host'] > 0 ? (int)$config['detail_limit_per_host'] : null,
'forge_remaining' => (int)$config['forge_limit_per_host'] > 0 ? (int)$config['forge_limit_per_host'] : null,
'detail_remaining' => $detailLimit > 0 ? max(0, $detailLimit - $existingDetailCount) : null,
'forge_remaining' => $forgeLimit > 0 ? max(0, $forgeLimit - $existingForgeCount) : null,
'forge_per_video' => max(0, (int)($forgePolicy['sitemap_count'] ?? 0)),
];
}
@@ -208,6 +215,7 @@ class DomainSpiderMdRunHelper
'max_links_per_file' => (int)$config['max_links_per_file'],
'config' => $config,
'mode' => $forceRegenerate || empty($previous) ? 'rebuild' : 'append',
'remote_mode' => 'fixed-root-incremental',
'new_links' => [
'aggregate_count' => $newAggregateCount,
'detail_count' => $newDetailCount,
@@ -215,19 +223,23 @@ class DomainSpiderMdRunHelper
'total_links' => $newAggregateCount + $newDetailCount + $newForgeCount,
],
'files' => $writtenFiles,
'changed_files' => array_values(array_unique($changedFiles)),
'full_remote_sync' => $forceFullRemoteSync ? 1 : 0,
'gitee' => array_diff_key($giteeConfig, ['token' => true]),
'github' => array_diff_key($githubConfig, ['token' => true]),
'gitee_files' => [],
'gitee_pushed' => 0,
'gitee_push_attempted' => 0,
'gitee_push_status' => 'pending',
'gitee_push_error' => '',
'github_files' => [],
'github_pushed' => 0,
'github_push_attempted' => 0,
'github_push_status' => 'pending',
'github_push_error' => '',
];
file_put_contents(
$runRoot . '/summary.json',
json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
file_put_contents($runRoot . '/summary.html', self::renderSummaryHtml($summary));
self::writeSummaryArtifacts($runRoot, $summary);
$giteePush = self::pushRunToGitee($summary, $logger);
$summary['gitee_files'] = (array)($giteePush['items'] ?? []);
@@ -235,22 +247,20 @@ class DomainSpiderMdRunHelper
$summary['gitee_push_attempted'] = (int)($giteePush['attempted'] ?? 0);
$summary['gitee_push_status'] = (string)($giteePush['status'] ?? 'skipped');
$summary['gitee_push_error'] = (string)($giteePush['error'] ?? '');
$githubPush = self::pushRunToGithub($summary, $logger);
$summary['github_files'] = (array)($githubPush['items'] ?? []);
$summary['github_pushed'] = !empty($summary['github_files']) ? 1 : 0;
$summary['github_push_attempted'] = (int)($githubPush['attempted'] ?? 0);
$summary['github_push_status'] = (string)($githubPush['status'] ?? 'skipped');
$summary['github_push_error'] = (string)($githubPush['error'] ?? '');
file_put_contents(
$runRoot . '/gitee-links.json',
json_encode([
'run_id' => $runId,
'generated_at' => date(DATE_ATOM),
'status' => $summary['gitee_push_status'],
'error' => $summary['gitee_push_error'],
'items' => $summary['gitee_files'],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
self::writeLinkArtifacts($runRoot, $summary);
file_put_contents(
$runRoot . '/summary.json',
json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
file_put_contents($runRoot . '/summary.html', self::renderSummaryHtml($summary));
self::syncMetadataToRemotes($summary, $logger);
self::log($logger, '蜘蛛池 MD 生成完成,共 ' . count($writtenFiles) . ' 个文件');
@@ -451,12 +461,13 @@ class DomainSpiderMdRunHelper
$forgePerVideo = max(0, (int)($forgePolicy['sitemap_count'] ?? 0));
foreach ($detailRows as $row) {
$strPinyin = (string)($row['v_name_en'] ?? '');
$detail[] = self::makeRecord(
$host,
'detail',
'详情页',
(string)$row['v_name'],
self::absoluteUrl($host, $urlBuilder->detail((string)$row['v_name_en'], (int)$row['v_id'])),
self::absoluteUrl($host, $urlBuilder->detail($strPinyin, (int)$row['v_id'])),
'detail'
);
@@ -488,7 +499,7 @@ class DomainSpiderMdRunHelper
'forge',
'详情泛链接',
$forgeKeyword,
self::absoluteUrl($host, $urlBuilder->detailForge((string)$row['v_name_en'], (int)$row['v_id'], $forgeId)),
self::absoluteUrl($host, $urlBuilder->detailForge($strPinyin, (int)$row['v_id'], $forgeId)),
'detail_forge'
);
}
@@ -578,6 +589,7 @@ class DomainSpiderMdRunHelper
return (string)($item['section_label'] ?? '');
}, $state['records']))),
];
$state['changed_files'][] = $fileName;
$state['records'] = [];
$state['reused_last_file'] = '';
@@ -585,6 +597,11 @@ class DomainSpiderMdRunHelper
protected static function findLatestSuccessfulRunSummary(): array
{
$activeSummary = self::readJsonFile(self::activeRunRoot() . '/summary.json');
if (!empty($activeSummary) && (string)($activeSummary['status'] ?? '') === 'success') {
return $activeSummary;
}
$matches = glob(self::baseRoot() . '/*/*/summary.json');
if (!is_array($matches) || empty($matches)) {
return [];
@@ -643,9 +660,45 @@ class DomainSpiderMdRunHelper
'aggregate_count' => (int)($summary['aggregate_count'] ?? 0),
'detail_count' => (int)($summary['detail_count'] ?? 0),
'forge_count' => (int)($summary['forge_count'] ?? 0),
'detail_count_by_host' => self::countExistingSectionByHost($files, $pendingRecords, 'detail'),
'forge_count_by_host' => self::countExistingSectionByHost($files, $pendingRecords, 'forge'),
];
}
protected static function countExistingSectionByHost(array $files, array $pendingRecords, string $section): array
{
$counts = [];
foreach ($files as $fileMeta) {
$filePath = (string)($fileMeta['file_path'] ?? '');
if ($filePath === '' || !is_file($filePath)) {
continue;
}
foreach (self::parseMarkdownChunkRecords($filePath) as $record) {
if ((string)($record['section'] ?? '') !== $section) {
continue;
}
$host = trim((string)($record['host'] ?? ''));
if ($host === '') {
continue;
}
$counts[$host] = (int)($counts[$host] ?? 0) + 1;
}
}
foreach ($pendingRecords as $record) {
if ((string)($record['section'] ?? '') !== $section) {
continue;
}
$host = trim((string)($record['host'] ?? ''));
if ($host === '') {
continue;
}
$counts[$host] = (int)($counts[$host] ?? 0) + 1;
}
return $counts;
}
protected static function parseMarkdownChunkRecords(string $filePath): array
{
$lines = @file($filePath, FILE_IGNORE_NEW_LINES);
@@ -791,6 +844,8 @@ class DomainSpiderMdRunHelper
foreach (glob(self::baseRoot() . '/*') ?: [] as $dateDir) {
self::deletePath((string)$dateDir);
}
self::deletePath(self::activeRunRoot());
self::ensureDir(self::activeRunRoot());
}
protected static function deletePath(string $path): void
@@ -924,23 +979,8 @@ class DomainSpiderMdRunHelper
}
$items = [];
$dateDir = basename(dirname((string)($summary['run_root'] ?? '')));
if (!preg_match('/^\d{8}$/', $dateDir)) {
$dateDir = date('Ymd', strtotime((string)($summary['generated_at'] ?? 'now')));
}
$baseRemoteDir = trim((string)$config['root'], '/');
$baseRemoteDir = trim($baseRemoteDir . '/' . $dateDir . '/' . (string)($summary['run_id'] ?? ''), '/');
$localFiles = array_merge(
array_map(static function (array $file): string {
return (string)($file['file_path'] ?? '');
}, (array)($summary['files'] ?? [])),
[
(string)($summary['run_root'] ?? '') . '/summary.json',
(string)($summary['run_root'] ?? '') . '/summary.html',
(string)($summary['run_root'] ?? '') . '/gitee-links.json',
]
);
$localFiles = self::resolveIncrementalLocalFiles($summary);
try {
foreach ($localFiles as $filePath) {
@@ -976,7 +1016,7 @@ class DomainSpiderMdRunHelper
'status' => 'success',
'attempted' => 1,
'error' => '',
'items' => $items,
'items' => self::buildRemoteItems($summary, $config, 'gitee'),
];
}
@@ -1093,6 +1133,378 @@ class DomainSpiderMdRunHelper
return SpiderMdConfigHelper::resolveGiteeConfig();
}
protected static function resolveGithubConfig(): array
{
return SpiderMdConfigHelper::resolveGithubConfig();
}
protected static function githubTargetChanged(array $previous, array $currentConfig): bool
{
$previousGithub = (array)($previous['github'] ?? []);
foreach (['enabled', 'owner', 'repo', 'branch', 'root'] as $key) {
if ((string)($previousGithub[$key] ?? '') !== (string)($currentConfig[$key] ?? '')) {
return true;
}
}
return false;
}
protected static function pushRunToGithub(array $summary, ?callable $logger = null): array
{
$config = self::resolveGithubConfig();
if (empty($config['enabled']) || empty($config['configured'])) {
self::log($logger, 'GitHub 未启用或未配置,本轮跳过 GitHub 推送');
return [
'status' => 'skipped',
'attempted' => 0,
'error' => '',
'items' => [],
];
}
$items = [];
$baseRemoteDir = trim((string)$config['root'], '/');
$localFiles = self::resolveIncrementalLocalFiles($summary);
try {
foreach ($localFiles as $filePath) {
if (!is_file($filePath)) {
continue;
}
$fileName = basename($filePath);
$remotePath = $baseRemoteDir . '/' . $fileName;
$response = self::upsertGithubFile($config, $remotePath, (string)file_get_contents($filePath), 'spider md update ' . $fileName);
$items[] = [
'file_name' => $fileName,
'remote_path' => $remotePath,
'html_url' => (string)($response['html_url'] ?? self::buildGithubHtmlUrl($config, $remotePath)),
'download_url' => (string)($response['download_url'] ?? self::buildGithubRawUrl($config, $remotePath)),
'local_path' => $filePath,
];
self::log($logger, '已推送 GitHub' . $fileName);
}
} catch (\Throwable $throwable) {
$message = trim($throwable->getMessage());
self::log($logger, 'GitHub 推送失败:' . $message);
return [
'status' => !empty($items) ? 'partial_failed' : 'failed',
'attempted' => 1,
'error' => $message,
'items' => $items,
];
}
return [
'status' => 'success',
'attempted' => 1,
'error' => '',
'items' => self::buildRemoteItems($summary, $config, 'github'),
];
}
protected static function resolveIncrementalLocalFiles(array $summary): array
{
$changedFileNames = array_values(array_unique(array_filter(array_map(static function ($item): string {
return trim((string)$item);
}, (array)($summary['changed_files'] ?? [])))));
$allFiles = array_values(array_filter(array_map(static function (array $file): string {
return (string)($file['file_path'] ?? '');
}, (array)($summary['files'] ?? []))));
if (!empty($summary['full_remote_sync'])) {
return $allFiles;
}
if (empty($changedFileNames)) {
return [];
}
$selected = [];
foreach ((array)($summary['files'] ?? []) as $file) {
$fileName = trim((string)($file['file_name'] ?? ''));
$filePath = (string)($file['file_path'] ?? '');
if ($fileName === '' || $filePath === '') {
continue;
}
if (in_array($fileName, $changedFileNames, true)) {
$selected[] = $filePath;
}
}
return array_values(array_unique($selected));
}
protected static function buildRemoteItems(array $summary, array $config, string $provider): array
{
$baseRemoteDir = trim((string)($config['root'] ?? ''), '/');
$items = [];
$fileNames = [];
foreach ((array)($summary['files'] ?? []) as $file) {
$fileName = trim((string)($file['file_name'] ?? ''));
$filePath = (string)($file['file_path'] ?? '');
if ($fileName === '') {
continue;
}
$fileNames[] = [
'file_name' => $fileName,
'local_path' => $filePath,
];
}
$fileNames[] = ['file_name' => 'summary.json', 'local_path' => (string)($summary['run_root'] ?? '') . '/summary.json'];
$fileNames[] = ['file_name' => 'summary.html', 'local_path' => (string)($summary['run_root'] ?? '') . '/summary.html'];
$fileNames[] = ['file_name' => $provider . '-links.json', 'local_path' => (string)($summary['run_root'] ?? '') . '/' . $provider . '-links.json'];
foreach ($fileNames as $fileMeta) {
$fileName = (string)($fileMeta['file_name'] ?? '');
if ($fileName === '') {
continue;
}
$remotePath = trim($baseRemoteDir . '/' . $fileName, '/');
$items[] = [
'file_name' => $fileName,
'remote_path' => $remotePath,
'html_url' => $provider === 'github'
? self::buildGithubHtmlUrl($config, $remotePath)
: self::buildGiteeHtmlUrl($config, $remotePath),
'download_url' => $provider === 'github'
? self::buildGithubRawUrl($config, $remotePath)
: self::buildGiteeRawUrl($config, $remotePath),
'local_path' => (string)($fileMeta['local_path'] ?? ''),
];
}
return $items;
}
protected static function writeSummaryArtifacts(string $runRoot, array $summary): void
{
file_put_contents(
$runRoot . '/summary.json',
json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
file_put_contents($runRoot . '/summary.html', self::renderSummaryHtml($summary));
}
protected static function writeLinkArtifacts(string $runRoot, array $summary): void
{
file_put_contents(
$runRoot . '/gitee-links.json',
json_encode([
'run_id' => (string)($summary['run_id'] ?? ''),
'generated_at' => date(DATE_ATOM),
'status' => (string)($summary['gitee_push_status'] ?? 'pending'),
'error' => (string)($summary['gitee_push_error'] ?? ''),
'items' => (array)($summary['gitee_files'] ?? []),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
file_put_contents(
$runRoot . '/github-links.json',
json_encode([
'run_id' => (string)($summary['run_id'] ?? ''),
'generated_at' => date(DATE_ATOM),
'status' => (string)($summary['github_push_status'] ?? 'pending'),
'error' => (string)($summary['github_push_error'] ?? ''),
'items' => (array)($summary['github_files'] ?? []),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
}
protected static function syncMetadataToRemotes(array $summary, ?callable $logger = null): void
{
self::syncMetadataToGitee($summary, $logger);
self::syncMetadataToGithub($summary, $logger);
}
protected static function syncMetadataToGitee(array $summary, ?callable $logger = null): void
{
$config = self::resolveGiteeConfig();
if (empty($config['enabled']) || empty($config['configured'])) {
return;
}
$baseRemoteDir = trim((string)$config['root'], '/');
foreach (['summary.json', 'summary.html', 'gitee-links.json'] as $fileName) {
$filePath = (string)($summary['run_root'] ?? '') . '/' . $fileName;
if (!is_file($filePath)) {
continue;
}
$remotePath = trim($baseRemoteDir . '/' . $fileName, '/');
self::upsertGiteeFile($config, $remotePath, (string)file_get_contents($filePath), 'spider md metadata update ' . $fileName);
self::log($logger, '已更新 Gitee 元数据:' . $fileName);
}
}
protected static function syncMetadataToGithub(array $summary, ?callable $logger = null): void
{
$config = self::resolveGithubConfig();
if (empty($config['enabled']) || empty($config['configured'])) {
return;
}
$baseRemoteDir = trim((string)$config['root'], '/');
foreach (['summary.json', 'summary.html', 'github-links.json'] as $fileName) {
$filePath = (string)($summary['run_root'] ?? '') . '/' . $fileName;
if (!is_file($filePath)) {
continue;
}
$remotePath = trim($baseRemoteDir . '/' . $fileName, '/');
self::upsertGithubFile($config, $remotePath, (string)file_get_contents($filePath), 'spider md metadata update ' . $fileName);
self::log($logger, '已更新 GitHub 元数据:' . $fileName);
}
}
protected static function upsertGithubFile(array $config, string $remotePath, string $content, string $message): array
{
$existing = self::githubRequest(
'GET',
sprintf(
'https://api.github.com/repos/%s/%s/contents/%s?ref=%s',
rawurlencode((string)$config['owner']),
rawurlencode((string)$config['repo']),
str_replace('%2F', '/', rawurlencode($remotePath)),
rawurlencode((string)$config['branch'])
),
$config,
[],
true
);
$payload = [
'message' => $message,
'content' => base64_encode($content),
'branch' => (string)$config['branch'],
];
if (!empty($existing['sha'])) {
$payload['sha'] = (string)$existing['sha'];
}
return self::githubRequest(
'PUT',
sprintf(
'https://api.github.com/repos/%s/%s/contents/%s',
rawurlencode((string)$config['owner']),
rawurlencode((string)$config['repo']),
str_replace('%2F', '/', rawurlencode($remotePath))
),
$config,
$payload
);
}
protected static function githubRequest(string $method, string $url, array $config, array $payload = [], bool $allowNotFound = false): array
{
if (!function_exists('curl_init')) {
throw new \RuntimeException('当前 PHP 环境缺少 curl 扩展,无法推送 GitHub');
}
$headers = [
'Accept: application/vnd.github+json',
'Authorization: Bearer ' . (string)$config['token'],
'X-GitHub-Api-Version: 2022-11-28',
'User-Agent: SEONexus-SpiderMd',
];
$maxAttempts = 3;
$lastError = '';
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$curl = curl_init();
if ($curl === false) {
throw new \RuntimeException('初始化 GitHub 请求失败');
}
curl_setopt_array($curl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
]);
if (!empty($payload)) {
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$response = curl_exec($curl);
$errno = curl_errno($curl);
$error = curl_error($curl);
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($errno !== 0) {
$lastError = 'GitHub 请求失败:' . $error;
if ($attempt < $maxAttempts) {
usleep(300000 * $attempt);
continue;
}
throw new \RuntimeException($lastError);
}
$decoded = json_decode((string)$response, true);
if ($allowNotFound && $status === 404) {
return [];
}
if ($status >= 400) {
$message = '';
if (is_array($decoded)) {
$message = trim((string)($decoded['message'] ?? ''));
if ($message === '') {
$message = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
}
} else {
$message = trim((string)$response);
}
if ($message === '') {
$message = 'HTTP ' . $status . ',响应体为空';
} else {
$message = 'HTTP ' . $status . '' . $message;
}
$lastError = 'GitHub 请求返回异常:' . $message;
if (in_array($status, [429, 500, 502, 503, 504], true) && $attempt < $maxAttempts) {
usleep(500000 * $attempt);
continue;
}
throw new \RuntimeException($lastError);
}
if (is_array($decoded) && isset($decoded['content']) && is_array($decoded['content'])) {
$decoded['html_url'] = (string)($decoded['content']['html_url'] ?? '');
$decoded['download_url'] = (string)($decoded['content']['download_url'] ?? '');
$decoded['sha'] = (string)($decoded['content']['sha'] ?? ($decoded['sha'] ?? ''));
}
return is_array($decoded) ? $decoded : [];
}
throw new \RuntimeException($lastError !== '' ? $lastError : 'GitHub 请求失败:未知异常');
}
protected static function buildGithubHtmlUrl(array $config, string $remotePath): string
{
return sprintf(
'https://github.com/%s/%s/blob/%s/%s',
rawurlencode((string)$config['owner']),
rawurlencode((string)$config['repo']),
rawurlencode((string)$config['branch']),
str_replace('%2F', '/', rawurlencode($remotePath))
);
}
protected static function buildGithubRawUrl(array $config, string $remotePath): string
{
return sprintf(
'https://raw.githubusercontent.com/%s/%s/%s/%s',
rawurlencode((string)$config['owner']),
rawurlencode((string)$config['repo']),
rawurlencode((string)$config['branch']),
str_replace('%2F', '/', rawurlencode($remotePath))
);
}
protected static function readJsonFile(string $path): array
{
if (!is_file($path)) {

View File

@@ -8,6 +8,92 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
class DomainSupplyBatchHelper
{
protected static function isSampleField(string $field): bool
{
return in_array($field, [
'detail_id',
'detail_slug',
'search_keyword',
'category_parent',
'category_child',
], true);
}
protected static function fillAutoDiscoveredSample(array $row): array
{
$host = self::hostLabelFromRow($row);
if ($host === '') {
return [
'row' => $row,
'status' => 'skipped',
'message' => 'host_empty',
'filled_fields' => [],
];
}
$needsSample = false;
foreach ([
'detail_id',
'detail_slug',
'search_keyword',
'category_parent',
'category_child',
] as $field) {
if (trim((string)($row[$field] ?? '')) === '') {
$needsSample = true;
break;
}
}
if (!$needsSample) {
return [
'row' => $row,
'status' => 'not_needed',
'message' => 'sample_fields_already_present',
'filled_fields' => [],
];
}
$sampleResult = DomainAutoSampleHelper::discover($host, ['limit' => 80]);
if (($sampleResult['status'] ?? '') !== 'passed' || empty($sampleResult['sample']) || !is_array($sampleResult['sample'])) {
return [
'row' => $row,
'status' => 'failed',
'message' => (string)($sampleResult['message'] ?? 'auto_sample_discovery_failed'),
'filled_fields' => [],
];
}
$sample = (array)$sampleResult['sample'];
$filledFields = [];
$mapping = [
'detail_id' => (string)($sample['detail']['detail_id'] ?? ''),
'detail_slug' => (string)($sample['detail']['detail_slug'] ?? ''),
'search_keyword' => (string)($sample['search']['search_keyword'] ?? ''),
'category_parent' => (string)($sample['category']['category_parent'] ?? ''),
'category_child' => (string)($sample['category']['category_child'] ?? ''),
'play_type' => (string)($sample['play']['play_type'] ?? ''),
'play_index' => (string)($sample['play']['play_index'] ?? ''),
];
foreach ($mapping as $field => $value) {
if ($value === '') {
continue;
}
if (trim((string)($row[$field] ?? '')) !== '') {
continue;
}
$row[$field] = $value;
$filledFields[] = $field;
}
return [
'row' => $row,
'status' => empty($filledFields) ? 'failed' : 'filled',
'message' => empty($filledFields) ? 'auto_sample_discovery_returned_empty_fields' : 'auto_sample_discovered',
'filled_fields' => $filledFields,
];
}
protected static function publicRelativeToAbsolutePath(string $path): string
{
$path = str_replace('\\', '/', trim($path));
@@ -122,12 +208,28 @@ class DomainSupplyBatchHelper
throw new \RuntimeException('Replay template rows are empty: ' . $summaryPath);
}
$headerLabels = DomainSupplyBatchTemplateHelper::getHeaderLabels();
$displayRows = array_map(
static function (array $row) use ($headerLabels): array {
$displayRow = [];
foreach (DomainSupplyBatchTemplateHelper::getHeaders() as $field) {
$displayRow[$headerLabels[$field] ?? $field] = (string)($row[$field] ?? '');
}
return $displayRow;
},
$rows
);
$displayNotes = [];
foreach (DomainSupplyBatchTemplateHelper::getColumnNotes() as $field => $note) {
$displayNotes[$headerLabels[$field] ?? $field] = $note;
}
DomainBatchImportTemplateHelper::writeWorkbook(
$outputPath,
'domain-supply-batch',
DomainSupplyBatchTemplateHelper::getHeaders(),
$rows,
DomainSupplyBatchTemplateHelper::getColumnNotes()
DomainSupplyBatchTemplateHelper::getDisplayHeaders(),
$displayRows,
$displayNotes
);
return $outputPath;
@@ -164,7 +266,9 @@ class DomainSupplyBatchHelper
return [];
}
$headers = array_map(static fn ($value): string => trim((string)$value), (array)array_shift($rows));
$headers = array_map(static function ($value): string {
return DomainSupplyBatchTemplateHelper::normalizeHeader(trim((string)$value));
}, (array)array_shift($rows));
$normalized = [];
foreach ($rows as $row) {
$assoc = [];
@@ -362,9 +466,19 @@ class DomainSupplyBatchHelper
continue;
}
$autoSample = self::fillAutoDiscoveredSample($row);
$row = (array)($autoSample['row'] ?? $row);
$missing = self::validateRow($row);
$status = empty($missing) ? 'ready' : 'failed';
$message = empty($missing) ? 'prepared' : ('missing:' . implode(',', $missing));
if (($autoSample['status'] ?? '') === 'filled') {
$message = 'auto_sample_filled:' . implode(',', (array)($autoSample['filled_fields'] ?? []));
} elseif (($autoSample['status'] ?? '') === 'failed') {
$sampleMissing = array_values(array_filter($missing, static fn(string $field): bool => self::isSampleField($field)));
if (!empty($sampleMissing)) {
$message .= ';auto_sample_failed:' . (string)($autoSample['message'] ?? '');
}
}
$siteRow = self::buildSiteImportRow($row);
$bootstrapRow = self::buildBootstrapBatchRow($row);
@@ -385,6 +499,9 @@ class DomainSupplyBatchHelper
'site_domain' => (string)($siteRow['d_domain'] ?? ''),
'bundle_host' => (string)($bootstrapRow['host'] ?? ''),
'missing_fields' => $missing,
'auto_sample_status' => (string)($autoSample['status'] ?? ''),
'auto_sample_message' => (string)($autoSample['message'] ?? ''),
'auto_sample_filled_fields' => array_values((array)($autoSample['filled_fields'] ?? [])),
];
}

View File

@@ -12,61 +12,145 @@ class DomainSupplyBatchTemplateHelper
{
return [
'host',
'template_id',
'strategy_profile',
'match_type',
'parent_domain',
'seed_scope',
'site_name',
'site_keywords',
'site_description',
'template_id',
'site_index_title',
'site_index_keywords',
'site_index_description',
'baidu_token',
'seo_cfg_json',
'strategy_profile',
'detail_id',
'detail_slug',
'search_keyword',
'category_parent',
'category_child',
'site_keywords',
'site_description',
'baidu_token',
'match_type',
'parent_domain',
'seed_scope',
'play_type',
'play_index',
'forge_index',
'rank_slug',
'base_url',
'bundle_name',
'seo_cfg_json',
];
}
public static function getHeaderLabels(): array
{
return [
'host' => '域名',
'site_name' => '站点名称',
'template_id' => '模板ID',
'site_index_title' => '首页标题',
'site_index_keywords' => '首页关键词',
'site_index_description' => '首页描述',
'strategy_profile' => 'SEO策略',
'detail_id' => '详情样本ID',
'detail_slug' => '详情样本Slug',
'search_keyword' => '搜索样本关键词',
'category_parent' => '父分类Slug',
'category_child' => '子分类Slug',
'site_keywords' => '站点关键词',
'site_description' => '站点描述',
'baidu_token' => '百度推送Token',
'match_type' => '匹配类型',
'parent_domain' => '父域名',
'seed_scope' => '种子范围',
'play_type' => '播放类型',
'play_index' => '播放序号',
'forge_index' => '锻造序号',
'rank_slug' => '榜单Slug',
'base_url' => '站点基础地址',
'bundle_name' => '自定义Bundle名称',
'seo_cfg_json' => '高级SEO配置JSON',
];
}
public static function getHeaderAliases(): array
{
$labels = self::getHeaderLabels();
$aliases = [];
foreach ($labels as $field => $label) {
$aliases[$field] = $field;
$aliases[mb_strtolower($field)] = $field;
$aliases[$label] = $field;
$aliases[mb_strtolower($label)] = $field;
}
foreach ([
'站点域名' => 'host',
'域名host' => 'host',
'策略' => 'strategy_profile',
'首页SEO标题' => 'site_index_title',
'首页SEO关键词' => 'site_index_keywords',
'首页SEO描述' => 'site_index_description',
'搜索关键词' => 'search_keyword',
'父分类' => 'category_parent',
'子分类' => 'category_child',
'基础地址' => 'base_url',
'高级配置JSON' => 'seo_cfg_json',
] as $alias => $field) {
$aliases[$alias] = $field;
$aliases[mb_strtolower($alias)] = $field;
}
return $aliases;
}
public static function normalizeHeader(string $header): string
{
$normalized = trim($header);
if ($normalized === '') {
return '';
}
$aliases = self::getHeaderAliases();
return $aliases[$normalized] ?? $aliases[mb_strtolower($normalized)] ?? '';
}
public static function getDisplayHeaders(): array
{
$labels = self::getHeaderLabels();
return array_map(
static fn(string $field): string => $labels[$field] ?? $field,
self::getHeaders()
);
}
public static function getColumnNotes(): array
{
return [
'host' => '必填,域名或 wildcard 域名;会同时用于站点导入和 bundle 生成。',
'template_id' => '必填,站点模板 ID默认 1。',
'strategy_profile' => '可选standard / traffic / expand / closed默认 standard。',
'match_type' => '可选exact 或 wildcard默认 exact。',
'parent_domain' => 'wildcard 模式建议填写根域名。',
'seed_scope' => '可选domain 或 host默认 domain。',
'site_name' => '可选,站点名称。',
'site_keywords' => '可选,站点关键词。',
'site_description' => '可选,站点描述。',
'site_index_title' => '可选,首页标题;不填时导入链按站点名称推导。',
'site_index_keywords' => '可选,首页关键词;不填时导入链按站点关键词推导。',
'site_index_description' => '可选,首页描述;不填时导入链按站点描述推导。',
'baidu_token' => '可选,百度推送 token。',
'seo_cfg_json' => '可选JSON会在 strategy_profile 默认值基础上继续覆盖。',
'detail_id' => '必填,启动包详情样本视频 ID。',
'detail_slug' => '必填,启动包详情样本 slug。',
'search_keyword' => '必填,启动包搜索页样本关键词。',
'category_parent' => '必填,父分类 slug。',
'category_child' => '必填,子分类 slug。',
'play_type' => '可选,默认 douban。',
'play_index' => '可选,默认 1。',
'forge_index' => '可选,默认 1。',
'rank_slug' => '可选默认 daily。',
'base_url' => '可选,默认 https://<host>。',
'bundle_name' => '可选,自定义 bundle 目录名。',
'host' => '必填。填写站点正式域名,不要带 https:// 。示例jingxifa.com。只有做泛域名时才填 *.example.com。',
'site_name' => '必填。填写站点前台展示名称,会用于页头、页脚和基础 SEO 文案。示例:鲸溪发影视。',
'template_id' => "通常直接填 1。\n可选值说明:\n- 1当前默认 GPT 模板。绝大多数站都用这个。\n- 其他数字:代表后台另有模板配置,只有技术明确指定时才改。\n不确定就填 1。",
'site_index_title' => '建议填写。首页给搜索引擎看的标题,尽量自然,不要堆词。建议 28 到 40 字。',
'site_index_keywords' => '建议填写。首页核心关键词,多个词用逗号分隔。示例:短剧推荐,热播短剧,高清短剧。',
'site_index_description' => '建议填写。首页一句话简介,建议 40 到 80 字,写自然一点。',
'strategy_profile' => "SEO 策略档位,默认 standard。\n可选值说明:\n- standard标准站最常用默认值先求稳定收录和正常运营时选它。\n- traffic流量型更偏向做关键词覆盖和流量页扩展。\n- expand扩展型更适合泛域名、子站批量扩展。\n- closed收缩/关闭型,通常用于只保留最小更新动作的站。\n不确定就填 standard。",
'detail_id' => '通常不用手填。站点部署成功后,系统会优先通过自动样本发现,从当前站点可用内容里自动提取。只有域名还没部署成功、当前只是试跑库、或自动发现失败时,才人工补填真实详情视频 ID。',
'detail_slug' => '通常不用手填。会和详情样本 ID 一起自动获取。若人工填写,必须和详情样本 ID 对应,不能乱配。',
'search_keyword' => '通常不用手填。系统会优先从站点名称、视频 SEO 词、视频标题里自动挑一个可搜索关键词。只有自动发现失败时,再人工填写一个能搜出结果的词。',
'category_parent' => '通常不用手填。系统会从自动样本视频所属分类里提取一级分类 slug。只有自动发现失败时再人工补填。示例dian-ying。',
'category_child' => '通常不用手填。系统会从自动样本视频所属分类里提取二级分类 slug。只有自动发现失败时再人工补填。示例ju-qing-pian。',
'site_keywords' => '可选。不填也能跑;如要补充站点级关键词,可填在这里。',
'site_description' => '可选。不填时系统会按站点名称自动兜底一版简介。',
'baidu_token' => '可选。只有需要百度主动推送时才填写。',
'match_type' => "域名匹配方式,默认 exact。\n可选值说明:\n- exact精确匹配。普通单域名站使用这个例如 jingxifa.com。\n- wildcard泛匹配。只有泛域名场景才用例如 *.example.com。\n不确定就保持 exact。",
'parent_domain' => "父域名只在 wildcard 泛域名场景下填写。\n填写规则:\n- host 是 *.example.com 时,这里填 example.com。\n- host 是普通单域名时留空。\n不要填协议、路径或 www。",
'seed_scope' => "种子范围,默认 domain。\n可选值说明:\n- domain按整域名范围处理普通站点默认用这个。\n- host按具体 host 处理,常见于泛域名、子站拆分运营场景。\n不确定就填 domain。",
'play_type' => "播放类型。通常可留空,自动样本成功时系统会自动带出。\n常见可选值说明:\n- douban常见默认线路绝大多数站留空后最终也会落到它。\n- youzhi另一种播放线路标识只有样本明确属于这条线路时才填。\n- 其他值:必须与站内真实播放线路一致,不能凭感觉乱写。\n不确定就留空,优先让系统自动发现。",
'play_index' => "播放序号,默认 1。\n可选值说明:\n- 1第一条播放线路下的第一集/第一入口,最常用。\n- 2、3、4...:只有你明确知道该样本要绑定到第几集或第几个入口时才改。\n不确定就填 1 或留空。",
'forge_index' => "锻造序号,默认 1。\n可选值说明:\n- 1默认主锻造位绝大多数站都用这个。\n- 其他数字:只有技术明确安排了多锻造位时才改。\n运营通常保持 1 即可。",
'rank_slug' => "榜单 slug默认 daily。\n可选值说明:\n- daily日榜最常用默认值。\n- weekly周榜只有明确要挂周榜时才用。\n- monthly月榜只有明确要挂月榜时才用。\n- 其他 slug必须后台已有对应榜单配置否则不要乱填。\n不确定就填 daily。",
'base_url' => '可选默认自动按 https://域名 生成。只有特殊环境才手填。',
'bundle_name' => '可选。一般留空,系统会按域名自动生成 bundle 目录名。',
'seo_cfg_json' => '可选。高级配置区,通常给技术同学使用。运营不需要填写;只有明确要覆盖默认 SEO 策略时再填 JSON。',
];
}
@@ -134,8 +218,50 @@ class DomainSupplyBatchTemplateHelper
{
return [
'headers' => self::getHeaders(),
'display_headers' => self::getDisplayHeaders(),
'header_labels' => self::getHeaderLabels(),
'column_notes' => self::getColumnNotes(),
'example_rows' => self::getExampleRows(),
'template_type' => 'gpt_supply_batch',
'filling_guidance' => [
'main_fields' => [
'host',
'site_name',
'template_id',
'site_index_title',
'site_index_keywords',
'site_index_description',
'strategy_profile',
],
'auto_discovery_fields' => [
'detail_id',
'detail_slug',
'search_keyword',
'category_parent',
'category_child',
],
'optional_fields' => [
'site_keywords',
'site_description',
'baidu_token',
'match_type',
'parent_domain',
'seed_scope',
'play_type',
'play_index',
'forge_index',
'rank_slug',
'base_url',
'bundle_name',
'seo_cfg_json',
],
],
'operator_notice' => [
'运营通常只需要先填写主填写区。',
'详情样本ID、详情样本Slug、搜索样本关键词、父分类Slug、子分类Slug 通常可以留空。',
'这些样本字段会在域名部署成功或试跑库有可用内容时,优先自动发现并补全。',
'只有自动发现失败时,才需要人工补填样本字段。',
],
'supported_strategy_profiles' => [
DomainModel::SEO_STRATEGY_PROFILE_STANDARD,
DomainModel::SEO_STRATEGY_PROFILE_TRAFFIC,
@@ -165,12 +291,29 @@ class DomainSupplyBatchTemplateHelper
$fileName = 'domain-supply-batch-template.xlsx';
$filePath = $outputRoot . '/' . $fileName;
$headerLabels = self::getHeaderLabels();
$displayHeaders = self::getDisplayHeaders();
$displayRows = array_map(
static function (array $row) use ($headerLabels): array {
$displayRow = [];
foreach (self::getHeaders() as $field) {
$displayRow[$headerLabels[$field] ?? $field] = (string)($row[$field] ?? '');
}
return $displayRow;
},
self::getExampleRows()
);
$displayNotes = [];
foreach (self::getColumnNotes() as $field => $note) {
$displayNotes[DomainBatchImportTemplateHelper::buildNoteDisplayLabel($field)] = $note;
}
DomainBatchImportTemplateHelper::writeWorkbook(
$filePath,
'domain-supply-batch',
self::getHeaders(),
self::getExampleRows(),
self::getColumnNotes()
$displayHeaders,
$displayRows,
$displayNotes
);
return [

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
use think\App;
class DomainTrajectoryProbeHelper
{
protected static bool $boolInitialized = false;
protected static function ensureAppInitialized(): void
{
if (self::$boolInitialized) {
return;
}
(new App())->initialize();
self::$boolInitialized = true;
}
public static function probe(string $strHost, array $arrSample, array $arrOptions = []): array
{
self::ensureAppInitialized();
$strHost = DomainModel::normalizeHost($strHost);
if ($strHost === '') {
return [
'host' => '',
'status' => 'invalid_host',
'message' => 'Host empty.',
'checks' => [],
'failed_stage' => 'host',
'all_passed' => false,
];
}
$DomainRow = self::findDomainRow($strHost);
if (!$DomainRow instanceof DomainModel) {
return [
'host' => $strHost,
'status' => 'domain_not_found',
'message' => 'Domain not found in site table.',
'checks' => [],
'failed_stage' => 'domain',
'all_passed' => false,
];
}
$TpStyle = SiteStyle::getConfig($DomainRow, $strHost);
$UrlBuilder = new UrlBuilder($TpStyle);
$strBaseUrl = rtrim((string)($arrOptions['base_url'] ?? ('https://' . $strHost)), '/');
$arrUrls = self::resolveUrls($UrlBuilder, $arrSample);
if (!empty($arrSample['urls']) && is_array($arrSample['urls'])) {
$arrUrls = array_merge($arrUrls, (array)$arrSample['urls']);
}
$arrChecks = [];
foreach ([
'home' => '首页',
'search' => '搜索',
'detail' => '详情',
'play' => '播放',
] as $strStage => $strLabel) {
$strPath = trim((string)($arrUrls[$strStage] ?? ''));
if ($strPath === '') {
$arrChecks[$strStage] = [
'label' => $strLabel,
'passed' => false,
'url' => '',
'status' => 0,
'detail' => 'url_missing',
];
continue;
}
$arrResponse = self::fetchPage($strBaseUrl . $strPath, $strHost);
$strBody = (string)($arrResponse['body'] ?? '');
$intStatus = (int)($arrResponse['status'] ?? 0);
$boolHasHtml = str_contains(strtolower($strBody), '<html');
$boolLooksLikeError = self::looksLikeErrorPage($strBody);
$boolPassed = $intStatus === 200 && $boolHasHtml && !$boolLooksLikeError;
$arrChecks[$strStage] = [
'label' => $strLabel,
'passed' => $boolPassed,
'url' => $strBaseUrl . $strPath,
'path' => $strPath,
'status' => $intStatus,
'detail' => $boolPassed ? 'ok' : self::resolveFailureDetail($intStatus, $boolHasHtml, $boolLooksLikeError),
];
}
$arrFailedStages = array_keys(array_filter($arrChecks, static fn(array $arrCheck): bool => empty($arrCheck['passed'])));
$boolAllPassed = empty($arrFailedStages);
return [
'host' => $strHost,
'status' => $boolAllPassed ? 'passed' : 'failed',
'message' => $boolAllPassed ? 'Trajectory probe passed.' : 'Trajectory probe found failed stages.',
'sample' => $arrSample,
'checks' => $arrChecks,
'failed_stage' => $arrFailedStages[0] ?? '',
'failed_stages' => $arrFailedStages,
'all_passed' => $boolAllPassed,
'probed_at' => date(DATE_ATOM),
];
}
protected static function findDomainRow(string $strHost): ?DomainModel
{
$strExactDomain = DomainModel::normalizeStoredDomain($strHost, DomainModel::MATCH_TYPE_EXACT);
$DomainRow = app(DomainModel::class)->where('d_domain', $strExactDomain)->find();
return $DomainRow instanceof DomainModel ? $DomainRow : null;
}
protected static function resolveUrls(UrlBuilder $UrlBuilder, array $arrSample): array
{
$strSlug = (string)($arrSample['detail']['detail_slug'] ?? '');
$intVideoId = (int)($arrSample['detail']['detail_id'] ?? 0);
$strSearchKeyword = (string)($arrSample['search']['search_keyword'] ?? '');
$strCategoryParent = (string)($arrSample['category']['category_parent'] ?? '');
$strCategoryChild = (string)($arrSample['category']['category_child'] ?? '');
$strPlayType = (string)($arrSample['play']['play_type'] ?? '');
$intPlayIndex = max(1, (int)($arrSample['play']['play_index'] ?? 1));
$arrUrls = [
'home' => $UrlBuilder->home(),
];
if ($strSearchKeyword !== '') {
$arrUrls['search'] = $UrlBuilder->searchResult($strSearchKeyword);
}
if ($strSlug !== '' && $intVideoId > 0) {
$arrUrls['detail'] = $UrlBuilder->detail($strSlug, $intVideoId);
}
if ($strSlug !== '' && $intVideoId > 0 && $strPlayType !== '') {
$arrUrls['play'] = $UrlBuilder->play($strSlug, $intVideoId, $strPlayType, $intPlayIndex);
}
if ($strCategoryParent !== '' && $strCategoryChild !== '') {
$arrUrls['category'] = $UrlBuilder->categoryChild($strCategoryParent, $strCategoryChild, 1);
}
return $arrUrls;
}
protected static function fetchPage(string $strUrl, string $strHost): array
{
$Context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => implode("\r\n", [
'Host: ' . $strHost,
'Connection: close',
]),
'ignore_errors' => true,
'timeout' => 20,
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
]);
$strBody = @file_get_contents($strUrl, false, $Context);
$arrHeaders = $http_response_header ?? [];
$intStatus = 0;
foreach ($arrHeaders as $strHeaderLine) {
if (preg_match('/^HTTP\/\S+\s+(\d{3})/i', $strHeaderLine, $arrMatches)) {
$intStatus = (int)($arrMatches[1] ?? 0);
}
}
return [
'status' => $intStatus,
'body' => $strBody === false ? '' : $strBody,
];
}
protected static function looksLikeErrorPage(string $strBody): bool
{
$strNormalized = strtolower($strBody);
foreach ([
'<title>thinkphp',
'think\\exception',
'undefined array key',
'uncaught exception',
'fatal error',
'parse error',
'call to undefined',
'stack trace',
'whoops',
'not found</title>',
'404 not found',
'500 internal server error',
] as $strNeedle) {
if (str_contains($strNormalized, $strNeedle)) {
return true;
}
}
return false;
}
protected static function resolveFailureDetail(int $intStatus, bool $boolHasHtml, bool $boolLooksLikeError): string
{
if ($intStatus !== 200) {
return 'http_' . $intStatus;
}
if (!$boolHasHtml) {
return 'html_missing';
}
if ($boolLooksLikeError) {
return 'error_page_detected';
}
return 'unknown_failure';
}
}

View File

@@ -27,14 +27,6 @@ class JsBuilder
@mkdir($targetDir, 0755, true);
}
// 输出文件名(域名专属缓存)
$targetFile = $targetDir . "{$staticHash}.js";
// 如果已经生成过,直接返回
if (file_exists($targetFile)) {
return "/static/js/compiled/{$staticHash}.js";
}
// 你可以像 CSS 一样在这里挂一个“全站基础 JS”
// 注意:如果没有这个文件,就别写入,否则会被 skip
$listJs = [
@@ -46,9 +38,46 @@ class JsBuilder
$finalJsFiles = array_merge($listJs, $jsFiles);
$finalJsFiles = array_values(array_unique(array_filter($finalJsFiles)));
$signatureParts = [
'static_hash' => $staticHash,
'dom_prefix' => $domPrefix,
'builder' => is_file(__FILE__) ? sha1_file(__FILE__) : (string)@filemtime(__FILE__),
'files' => [],
];
// 如果 compiled 已存在且比所有源文件都新,直接返回;否则自动重建。
$intLatestSourceTime = @filemtime(__FILE__) ?: 0;
foreach ($finalJsFiles as $file) {
$file = ltrim((string)$file, '/');
$path = $baseDir . $file;
if (is_file($path)) {
$intLatestSourceTime = max($intLatestSourceTime, (int)@filemtime($path));
$signatureParts['files'][] = [
'file' => $file,
'mtime' => (int)@filemtime($path),
'size' => (int)@filesize($path),
'sha1' => sha1_file($path),
];
} else {
$signatureParts['files'][] = [
'file' => $file,
'missing' => true,
];
}
}
// 前端代理的静态缓存按 path 命中query version 无法兜底;文件名必须跟随源码变化。
$sourceHash = substr(sha1((string)json_encode($signatureParts, JSON_UNESCAPED_SLASHES)), 0, 10);
$targetName = "{$staticHash}-{$sourceHash}.js";
$targetFile = $targetDir . $targetName;
if (file_exists($targetFile) && (int)@filemtime($targetFile) >= $intLatestSourceTime) {
return "/static/js/compiled/{$targetName}";
}
// 合并 JS 文件内容
$allJs = "";
$allJs .= "/*! compiled: {$staticHash}.js */\n";
$allJs .= "/*! compiled: {$targetName} */\n";
$allJs .= "(function(){\n'use strict';\n";
foreach ($finalJsFiles as $file) {
@@ -82,6 +111,6 @@ class JsBuilder
file_put_contents($targetFile, $allJs);
// 返回前端可访问路径
return "/static/js/compiled/{$staticHash}.js";
return "/static/js/compiled/{$targetName}";
}
}

View File

@@ -8,6 +8,11 @@ use app\model\DomainModel;
class SeoCopyAiProviderHelper
{
public static function buildSharedAiRuleBlock(): string
{
return SeoCopyAiRuleConfigHelper::buildSharedRuleBlock();
}
public static function resolveProviderConfig(?string $strPreferredProvider = null): array
{
$strDefaultProvider = self::readEnv('SEO_COPY_AI_PROVIDER', DomainModel::TKD_PROVIDER_LOCAL);
@@ -699,7 +704,12 @@ class SeoCopyAiProviderHelper
'content' => [
[
'type' => 'input_text',
'text' => '你是资深中文 SEO 编辑,目标是在不虚构事实的前提下,输出适合影视站首页、分类页、搜索页、详情页、播放页的高相关、高可读、强点击意图文案。输出必须是合法 JSON。'
'text' => implode("\n", [
'你是资深中文 SEO 编辑,目标是在不虚构事实的前提下,输出适合影视站首页、分类页、搜索页、详情页、播放页的高相关、高可读、强点击意图文案。',
'输出必须是合法 JSON。',
'',
self::buildSharedAiRuleBlock(),
]),
],
],
],
@@ -791,6 +801,10 @@ class SeoCopyAiProviderHelper
'2. detail 描述不要只改写公共简介,要更像资料页摘要,先告诉用户这页能解决什么,再补事实线索。',
'3. play 描述不要只写在线播放,要带回详情路径、线路判断和必要的事实线索。',
'4. 不要虚构具体剧情细节;如果缺少事实,就用更保守的资料页表达。',
'5. detail.description_template、play.description_template 优先按“影视详情页摘要规则”执行。',
'6. detail.detail_body_lead/detail_body_tail、play.play_body_lead/play_body_next 优先按“影视详情页正文补全规则”执行。',
'',
self::buildSharedAiRuleBlock(),
]);
}
@@ -1332,24 +1346,12 @@ class SeoCopyAiProviderHelper
protected static function pickThemeKeywords(array $arrResourceContext, string $strFallback, int $intLimit = 4): array
{
$arrOut = [];
foreach (array_merge(
return SeoCopyContextHelper::normalizeKeywordCandidates(array_merge(
[$strFallback],
(array)($arrResourceContext['priority_keywords'] ?? []),
(array)($arrResourceContext['feedback_keywords'] ?? []),
(array)($arrResourceContext['topics'] ?? [])
) as $strItem) {
$strItem = trim((string)$strItem);
if ($strItem === '' || in_array($strItem, $arrOut, true)) {
continue;
}
$arrOut[] = $strItem;
if (count($arrOut) >= $intLimit) {
break;
}
}
return $arrOut;
), [], $intLimit);
}
protected static function pickString(array $arrPool, int $intSeed, string $strSalt): string

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\SystemConfigModel;
class SeoCopyAiRuleConfigHelper
{
protected const DEFINITIONS = [
'SEO_COPY_AI_RULE_HIGHEST' => [
'default' => "最高指令:\n1. 所有 AI 生成文案必须以 SEO 效果、可读性、真实感和可持续收录为共同目标。\n2. 所有生成只能基于已提供字段与事实,不允许虚构剧情、角色关系、上映信息、评价内容、播放权益或不存在的资料。\n3. 如果当前规则开始明显限制 SEO 增长、限制文案质量上限、限制更高质量的智能发挥,不允许静默硬套,必须明确指出冲突点、风险点和可替代优化方案,再继续讨论升级规则。\n4. 不允许因为“遵守规则”而产出明显模板化、机械化、低区分度、低点击意图的文案。\n5. 不允许输出营销承诺词,例如“免费观看”“高清完整版”“极速播放”“本站提供”“全网最全”等。\n6. 不允许堆砌关键词,不允许片名大面积重复,通常不要超过 2 次。\n7. 语言必须自然、克制、像人工编辑,不要满篇泛称、代词和模板痕迹。",
'description' => 'SEO文案AI最高指令控制真实性、SEO效果、去模板化如果规则本身开始压制SEO增长或高质量发挥必须先明确提出冲突点再讨论升级不能静默硬套。',
],
'SEO_COPY_AI_RULE_DETAIL_SUMMARY' => [
'default' => "影视详情页摘要规则:\n1. 适用于详情页 description、详情摘要、播放页短摘要等短文案场景。\n2. 长度控制在 60 到 110 个中文字符之间。\n3. 如果原简介很短,可以结合导演、主演、题材、地区、年份、更新状态补足信息。\n4. 如果信息不足,输出尽量客观、克制的资料型摘要,不要硬编剧情。\n5. 尽量避免“这部作品 / 这部剧 / 这部片子 / 这部故事 / 它 / 这部新作 / 这套剧集 / 这个故事”等泛称或代词。\n6. 电影:更像电影简介,强调人物处境、冲突推进、情绪和命运。\n7. 剧集:更像连续剧情介绍,强调人物关系线和推进节奏。\n8. 综艺:更像节目简介,强调现场互动、气氛和临场变化。\n9. 动漫:更像动画简介,强调世界观、人物立场和设定线索。\n10. 短剧:更像短剧简介,强调人物处境、冲突推进、情绪和命运。",
'description' => 'SEO文案AI详情摘要规则控制 detail/play 短摘要的长度、真实性、场景风格和禁用泛称代词,适合搜索摘要与详情简述。',
],
'SEO_COPY_AI_RULE_DETAIL_BODY' => [
'default' => "影视详情页正文补全规则:\n1. 适用于详情页正文、播放页正文、详情补充介绍等中长文案场景。\n2. 字数控制在 120 到 220 个中文字符之间。\n3. 如果原简介存在且可用,应优先保留原简介核心信息,再补充结构化资料。\n4. 尽量包含作品类型、地区、语言、年份、导演、主演、更新状态等关键信息。\n5. 如果信息不足,写成简洁客观的资料型介绍,不要硬编剧情。\n6. 不要完全照搬字段顺序,要自然组织表达。\n7. 同样避免“这部作品 / 这部剧 / 这部片子 / 它 / 这部新作 / 这套剧集”等泛称或代词大面积出现。\n8. 电影:更像电影简介,强调人物处境、冲突推进、情绪和命运。\n9. 剧集:更像连续剧情介绍,强调人物关系线和推进节奏。\n10. 综艺:更像节目简介,强调现场互动、气氛和临场变化。\n11. 动漫:更像动画简介,强调世界观、人物立场和设定线索。\n12. 短剧:更像短剧简介,强调人物处境、冲突推进、情绪和命运。",
'description' => 'SEO文案AI详情正文规则控制 detail/play 中长正文的真实性、信息密度、字数范围和分类写法,避免模板化和硬编剧情。',
],
'SEO_COPY_AI_RULE_MISSING_FIELDS' => [
'default' => "缺失字段处理规则:\n1. 当演员、导演、年份、地区、语言、简介、备注等字段为空时,只能承认缺失,不能脑补、补写、猜测、拼接不存在的信息。\n2. 缺少剧情简介时,优先退回“资料型写法”:用已知的片名、类型、地区、语言、年份、更新状态、清晰度、集数、备注等字段,组织成客观简介。\n3. 缺少演员或导演时,不要为了句子完整强行写“由某某领衔”之类表达,可以直接改写为题材导向、设定导向或更新状态导向。\n4. 缺少年份时,不要伪造上映时间、播出时间、年代背景;可以直接省略年份,改用类型、地区、语言、更新状态补足。\n5. 缺少地区、语言时,不要写“未知地区”“未知语言”这种伤点击的话术,直接跳过该字段,用其它已有信息维持句子完整。\n6. 缺少简介且字段很少时,允许输出更克制的资料页文案,但仍要尽量保证可读性,不能只剩机械字段堆叠。\n7. 如果只有片名和极少数字段,优先写成“作品资料说明型”短文案,不要硬写剧情,不要假装很懂内容。\n8. 任意缺失字段都不能触发模板化兜底句式,例如“讲述了一段故事”“围绕主人公展开”“精彩剧情值得关注”这类空泛废话。\n9. 当信息不足以支撑长正文时,允许适当缩短,但必须保证自然、克制、像真人编辑整理,而不是错误扩写。\n10. SEO 目标不是把空字段补满,而是在真实边界内,把现有信息组织成最可信、最自然、最不模板化的可收录文案。",
'description' => 'SEO文案AI缺失字段处理规则专门约束演员、导演、年份、简介等字段为空时的写法要求只在真实信息边界内做资料型组织禁止脑补和空泛扩写。',
],
'SEO_COPY_AI_RULE_MOVIE_STYLE' => [
'default' => "电影细分规则:\n1. 电影文案更像电影简介,不要写成剧集分集导语。\n2. 优先强调人物处境、核心冲突、情绪变化、命运推进。\n3. 如果事实不足,可优先组织题材、地区、年份、导演、主演、上映阶段或备注状态,不要硬编剧情反转。\n4. 句式可以更集中、更凝练,保留电影应有的整体感和完成度。\n5. 不要泛泛罗列字段,尽量让信息组织像真实电影站资料页摘要。",
'description' => 'SEO文案AI电影细分规则用于电影类详情/播放文案,强调人物处境、冲突推进、情绪与命运,不要写成剧集或综艺口吻。',
],
'SEO_COPY_AI_RULE_SERIES_STYLE' => [
'default' => "剧集细分规则:\n1. 剧集文案更像连续剧情介绍,不要写成单部电影梗概。\n2. 优先强调人物关系线、剧情推进节奏、阶段性发展与追更感。\n3. 可以适度体现“随着剧情推进”“围绕某条关系线展开”等结构,但不要模板化重复。\n4. 如果信息不足,可回到演员、导演、题材、地区、年份、更新状态这些已知事实,保持追剧导向。\n5. 要让用户和蜘蛛感知到这是持续推进型内容,而不是一次性完整闭环。",
'description' => 'SEO文案AI剧集细分规则用于电视剧/连续剧类详情文案,强调人物关系线、推进节奏和持续更新属性。',
],
'SEO_COPY_AI_RULE_VARIETY_STYLE' => [
'default' => "综艺细分规则:\n1. 综艺文案更像节目简介,不要写成剧情片简介。\n2. 优先强调节目形式、现场互动、嘉宾关系、气氛变化和临场感。\n3. 适合突出舞台、话题、竞演、观察、陪伴、互动等节目特征,但不能编造具体桥段。\n4. 如果事实不足,可结合地区、年份、语言、更新状态和已知嘉宾/主持信息,保持节目导向。\n5. 语气可以更灵动,但仍要克制、真实、像人工编辑。",
'description' => 'SEO文案AI综艺细分规则用于综艺/真人秀类文案,强调互动、气氛、节目形式和临场变化,避免剧情片化。',
],
'SEO_COPY_AI_RULE_ANIME_STYLE' => [
'default' => "动漫细分规则:\n1. 动漫文案更像动画简介,不要硬套真人影视口吻。\n2. 优先强调世界观、人物立场、设定线索、阵营关系和冒险方向。\n3. 可以突出成长、战斗、幻想、校园、异世界、科幻等题材感,但不能编造不存在的设定。\n4. 如果信息不足,可先稳住在类型、地区、年份、语言、导演、主演/配音、更新状态等已知资料。\n5. 表达要保留动画内容常见的设定感和想象空间,但不要写成二创文案。",
'description' => 'SEO文案AI动漫细分规则用于动漫/动画类文案,强调世界观、设定线索、人物立场与题材氛围。',
],
'SEO_COPY_AI_RULE_SHORT_DRAMA_STYLE' => [
'default' => "短剧细分规则:\n1. 短剧文案更强调人物处境、冲突推进、情绪起伏和高密度反差感。\n2. 句式可以更紧凑,但不要低俗、不要标题党、不要营销化。\n3. 如果事实有限,可围绕人物关系、身份落差、阶段性冲突、更新状态来组织,不要编造狗血桥段。\n4. 要让文案读起来有短剧节奏感和推动力,但仍保持资料页风格,不要写成宣发广告。\n5. 适合更突出“发展快、情绪强、关系推进明显”的内容质感。",
'description' => 'SEO文案AI短剧细分规则用于短剧类文案强调人物处境、情绪张力、冲突推进和紧凑节奏。',
],
'SEO_COPY_AI_RULE_HIGHEST_NOTE' => [
'default' => "实验备注:\n- 修改目的:\n- 预期影响:\n- 观察指标:收录 / 蜘蛛抓取 / 页面点击 / 摘要自然度\n- 回看日期:",
'description' => 'SEO文案AI规则实验备注记录最高指令的修改目的、预期影响和回看日期便于后续SEO复盘。',
],
'SEO_COPY_AI_RULE_DETAIL_SUMMARY_NOTE' => [
'default' => "实验备注:\n- 修改目的:\n- 预期影响:\n- 观察指标:搜索摘要可读性 / 点击意图 / 收录摘要匹配度\n- 回看日期:",
'description' => 'SEO文案AI规则实验备注记录详情摘要规则调整的目的、预期影响和回看日期。',
],
'SEO_COPY_AI_RULE_DETAIL_BODY_NOTE' => [
'default' => "实验备注:\n- 修改目的:\n- 预期影响:\n- 观察指标:正文自然度 / 页面停留 / 蜘蛛深抓 / 详情页收录质量\n- 回看日期:",
'description' => 'SEO文案AI规则实验备注记录详情正文规则调整的目的、预期影响和回看日期。',
],
'SEO_COPY_AI_RULE_MISSING_FIELDS_NOTE' => [
'default' => "实验备注:\n- 修改目的:减少缺字段资源的硬编、废话和模板腔\n- 预期影响:空字段资源也能保持可信、自然、可收录\n- 观察指标:缺字段详情页自然度 / 收录质量 / 低质页占比\n- 回看日期:",
'description' => 'SEO文案AI规则实验备注记录缺失字段处理规则调整的目的、预期影响和回看日期。',
],
'SEO_COPY_AI_RULE_MOVIE_STYLE_NOTE' => [
'default' => "实验备注:\n- 修改目的:\n- 预期影响:电影页更像真人编辑简介\n- 观察指标:电影详情页摘要自然度 / 收录描述质量 / 点击率\n- 回看日期:",
'description' => 'SEO文案AI规则实验备注记录电影细分规则调整的实验目的和复盘节点。',
],
'SEO_COPY_AI_RULE_SERIES_STYLE_NOTE' => [
'default' => "实验备注:\n- 修改目的:\n- 预期影响:剧集页更突出关系线与追更节奏\n- 观察指标:剧集详情页自然度 / 收录稳定性 / 点击率\n- 回看日期:",
'description' => 'SEO文案AI规则实验备注记录剧集细分规则调整的实验目的和复盘节点。',
],
'SEO_COPY_AI_RULE_VARIETY_STYLE_NOTE' => [
'default' => "实验备注:\n- 修改目的:\n- 预期影响:综艺页更有节目感和互动感\n- 观察指标:摘要差异度 / 收录摘要匹配 / 页面点击率\n- 回看日期:",
'description' => 'SEO文案AI规则实验备注记录综艺细分规则调整的实验目的和复盘节点。',
],
'SEO_COPY_AI_RULE_ANIME_STYLE_NOTE' => [
'default' => "实验备注:\n- 修改目的:\n- 预期影响:动漫页更有设定感和世界观线索\n- 观察指标:摘要自然度 / 题材匹配度 / 收录质量\n- 回看日期:",
'description' => 'SEO文案AI规则实验备注记录动漫细分规则调整的实验目的和复盘节点。',
],
'SEO_COPY_AI_RULE_SHORT_DRAMA_STYLE_NOTE' => [
'default' => "实验备注:\n- 修改目的:\n- 预期影响:短剧页更有节奏感和情绪推动力\n- 观察指标:点击意图 / 摘要张力 / 收录质量\n- 回看日期:",
'description' => 'SEO文案AI规则实验备注记录短剧细分规则调整的实验目的和复盘节点。',
],
];
public static function ensureSystemConfigDefaults(): void
{
$changed = false;
foreach (self::DEFINITIONS as $code => $definition) {
$exists = SystemConfigModel::where('sc_code', $code)->find();
if ($exists !== null) {
$expectedDescription = (string)($definition['description'] ?? $code);
if ((string)($exists->sc_description ?? '') !== $expectedDescription) {
$exists->sc_description = $expectedDescription;
$exists->save();
$changed = true;
}
continue;
}
$model = new SystemConfigModel();
$model->save([
'sc_code' => $code,
'sc_val' => (string)($definition['default'] ?? ''),
'sc_description' => (string)($definition['description'] ?? $code),
'site_id' => 0,
]);
$changed = true;
}
if ($changed) {
SystemConfigModel::flushCache();
}
}
public static function buildSharedRuleBlock(): string
{
self::ensureSystemConfigDefaults();
$parts = [];
foreach (self::displayRuleCodes() as $code) {
$value = trim((string)SystemConfigModel::getValByCode($code));
if ($value === '') {
$value = trim((string)(self::DEFINITIONS[$code]['default'] ?? ''));
}
if ($value !== '') {
$parts[] = $value;
}
}
return implode("\n\n", $parts);
}
public static function editableCodes(): array
{
return array_keys(self::DEFINITIONS);
}
public static function noteCodeForRule(string $code): string
{
return str_ends_with($code, '_NOTE') ? $code : ($code . '_NOTE');
}
public static function isRuleNoteCode(string $code): bool
{
return str_ends_with($code, '_NOTE');
}
public static function displayRuleCodes(): array
{
return array_values(array_filter(array_keys(self::DEFINITIONS), static function (string $code): bool {
return !self::isRuleNoteCode($code);
}));
}
public static function defaultValue(string $code): string
{
return (string)(self::DEFINITIONS[$code]['default'] ?? '');
}
public static function resetToDefault(?string $code = null): array
{
self::ensureSystemConfigDefaults();
$codes = $code !== null && $code !== ''
? [$code]
: self::editableCodes();
$changed = [];
foreach ($codes as $itemCode) {
if (!array_key_exists($itemCode, self::DEFINITIONS)) {
continue;
}
$model = SystemConfigModel::where('sc_code', $itemCode)->find();
if ($model === null) {
continue;
}
$model->sc_val = self::defaultValue($itemCode);
$model->sc_description = (string)(self::DEFINITIONS[$itemCode]['description'] ?? $itemCode);
$model->save();
$changed[] = $itemCode;
}
if (!empty($changed)) {
SystemConfigModel::flushCache();
}
return [
'codes' => array_values($changed),
'count' => count($changed),
];
}
}

View File

@@ -101,6 +101,9 @@ class SeoCopyBatchPrepareHelper
'- 文风要自然、克制、站内导航型,避免“全网最全”“免费观看”等夸张承诺。',
'- 输出必须能直接写入目标 JSON 文件。',
'',
'## Highest Rules',
SeoCopyAiProviderHelper::buildSharedAiRuleBlock(),
'',
'## Scene Rules',
(string)$strRulesJson,
'',

View File

@@ -4,6 +4,52 @@ namespace app\common\helper;
class SeoCopyContextHelper
{
public static function normalizeKeywordCandidates(array $arrItems, array $arrExclude = [], int $intLimit = 10): array
{
$arrExclude = array_values(array_filter(array_map(static function ($item): string {
return trim((string)$item);
}, $arrExclude), static function (string $item): bool {
return $item !== '';
}));
$arrOut = [];
foreach ($arrItems as $item) {
$strItem = trim((string)$item);
if ($strItem === '') {
continue;
}
$strItem = preg_replace('/\s+/u', '', $strItem) ?: $strItem;
$strItem = trim($strItem, ",,。;;:|/-_");
if ($strItem === '') {
continue;
}
if (mb_strlen($strItem) < 2 || mb_strlen($strItem) > 14) {
continue;
}
if (in_array($strItem, $arrExclude, true) || in_array($strItem, $arrOut, true)) {
continue;
}
if (preg_match('/[“”"\'\(\)\[\]{}]/u', $strItem) === 1) {
continue;
}
if (preg_match('/(首页|分类页|详情页|搜索页|播放页|首屏|收录|起词|带动|围绕|承接|路径|导购|整理型|流量型|扩展型|继续|建议|适合|当前|优先)/u', $strItem) === 1) {
continue;
}
$arrOut[] = $strItem;
if (count($arrOut) >= $intLimit) {
break;
}
}
return $arrOut;
}
public static function buildResourceContext(string $strHost): array
{
$strHost = \app\model\DomainModel::normalizeHost($strHost);
@@ -24,11 +70,7 @@ class SeoCopyContextHelper
$arrPositioning = SeoResourcePoolHelper::readJsonFile($strRoot . '/site_positioning/by_host/' . $strHostKey . '.json');
$arrFeedback = SeoResourcePoolHelper::readJsonFile($strRoot . '/keyword_feedback/by_host/' . $strHostKey . '.json');
$arrPriorityKeywords = array_values(array_filter(array_map(static function ($value): string {
return trim((string)$value);
}, (array)($arrPositioning['priority_keywords'] ?? [])), static function (string $value): bool {
return $value !== '';
}));
$arrPriorityKeywords = self::normalizeKeywordCandidates((array)($arrPositioning['priority_keywords'] ?? []));
$arrTopics = array_values(array_filter(array_map(static function ($value): string {
return trim((string)$value);
}, array_merge((array)($arrPositioning['primary_topics'] ?? []), (array)($arrPositioning['secondary_topics'] ?? []))), static function (string $value): bool {
@@ -108,11 +150,12 @@ class SeoCopyContextHelper
$strRawDescription,
]
), 8, $arrExclude);
$arrThemePhrases = self::filterHomeMetaPhrases($arrPhrases);
$strPrimary = $arrPhrases[0] ?? '影视内容推荐';
$strSecondary = $arrPhrases[1] ?? '高清内容整理';
$strTertiary = $arrPhrases[2] ?? '热门片单更新';
$strQuaternary = $arrPhrases[3] ?? '剧情与演员资料';
$strPrimary = $arrThemePhrases[0] ?? $arrPhrases[0] ?? '影视内容推荐';
$strSecondary = $arrThemePhrases[1] ?? $arrPhrases[1] ?? '高清内容整理';
$strTertiary = $arrThemePhrases[2] ?? $arrPhrases[2] ?? '热门片单更新';
$strQuaternary = $arrThemePhrases[3] ?? $arrPhrases[3] ?? '剧情与演员资料';
$strSiteSuffix = $strSiteName !== '' ? ('-' . $strSiteName) : '';
if ($strTkdMode === \app\model\DomainModel::TKD_MODE_FORCE_IMPORT) {
@@ -135,6 +178,7 @@ class SeoCopyContextHelper
if (
$strTkdMode === \app\model\DomainModel::TKD_MODE_AI_OPTIMIZE
&& $strRawTitle !== ''
&& !self::isBoilerplateHomeTitle($strRawTitle)
&& !str_contains($strRawTitle, '{')
&& !str_contains($strRawTitle, '}')
&& mb_strlen($strRawTitle) <= 42
@@ -143,6 +187,7 @@ class SeoCopyContextHelper
$strTitle = $strRawTitle;
} elseif (
$strRawTitle !== ''
&& !self::isBoilerplateHomeTitle($strRawTitle)
&& !str_contains($strRawTitle, '{')
&& !str_contains($strRawTitle, '}')
&& mb_strlen($strRawTitle) <= 42
@@ -153,24 +198,34 @@ class SeoCopyContextHelper
$arrTitleParts = array_slice(array_values(array_filter([
$strPrimary,
$strSecondary,
$strTertiary,
])), 0, 3);
$strTitle = implode('、', $arrTitleParts) . $strSiteSuffix;
])), 0, 2);
if ($strSiteName !== '') {
$strTitle = $strSiteName;
if (!empty($arrTitleParts)) {
$strTitle .= '-' . implode('·', $arrTitleParts);
} else {
$strTitle .= '-热门内容推荐';
}
} else {
$strTitle = implode('·', $arrTitleParts) . $strSiteSuffix;
}
}
$arrKeywords = [];
foreach (array_merge([$strSiteName], (array)($arrResourceContext['priority_keywords'] ?? []), (array)($arrResourceContext['feedback_keywords'] ?? []), array_slice($arrPhrases, 0, 6)) as $strKeyword) {
$strKeyword = trim((string)$strKeyword);
if ($strKeyword === '' || in_array($strKeyword, $arrKeywords, true)) {
continue;
}
$arrKeywords[] = $strKeyword;
}
$arrKeywords = self::buildHomeKeywordBundle(
$strSiteName,
$strHost,
$arrResourceContext,
$arrThemePhrases
);
if (empty($arrKeywords)) {
$arrKeywords = array_values(array_filter([$strSiteName, '影视内容推荐', '剧情介绍', '演员资料']));
}
$strPhraseSummary = implode('、', array_slice($arrPhrases, 0, 4));
$arrDescriptionKeywords = array_values(array_filter(array_slice($arrKeywords, 1, 4), static function ($item): bool {
return trim((string)$item) !== '';
}));
$strPhraseSummary = implode('、', $arrDescriptionKeywords);
if ($strPhraseSummary === '') {
$strPhraseSummary = '影视内容推荐、剧情介绍、演员资料、播放线索';
}
@@ -195,13 +250,13 @@ class SeoCopyContextHelper
$strDescription = $strSiteName !== ''
? sprintf(
'%s围绕%s等主题整理首页内容持续补充热门片单、剧情介绍、演员资料与播放线索。%s',
'%s首页重点承接%s等内容入口适合先看首屏推荐、题材分区和站内搜索再决定继续进入详情页或播放页。%s',
$strSiteName,
$strPhraseSummary,
$strDescriptionTail
)
: sprintf(
'当前首页围绕%s等主题整理内容持续补充热门片单、剧情介绍、演员资料与播放线索。%s',
'当前首页重点承接%s等内容入口适合先看首屏推荐、题材分区和站内搜索再决定继续进入详情页或播放页。%s',
$strPhraseSummary,
$strDescriptionTail
);
@@ -220,6 +275,68 @@ class SeoCopyContextHelper
];
}
protected static function buildHomeKeywordBundle(
string $strSiteName,
string $strHost,
array $arrResourceContext,
array $arrThemePhrases
): array {
$arrBasePool = array_merge(
[$strSiteName],
(array)($arrResourceContext['priority_keywords'] ?? []),
(array)($arrResourceContext['topics'] ?? []),
array_slice($arrThemePhrases, 0, 6),
['电视剧', '电影', '短剧', '剧情介绍']
);
$arrKeywords = self::normalizeKeywordCandidates(
$arrBasePool,
[$strHost, preg_replace('/^www\./i', '', $strHost)],
8
);
$arrKeywords = array_values(array_filter($arrKeywords, static function (string $item): bool {
if ($item === '') {
return false;
}
if (preg_match('/(高清影视内容精选|每日更新|高清影视|内容推荐|内容整理|热门片单更新|播放线索|演员资料)/u', $item) === 1) {
return false;
}
return true;
}));
return array_values(array_slice(array_unique($arrKeywords), 0, 6));
}
protected static function isBoilerplateHomeTitle(string $strTitle): bool
{
$strTitle = trim($strTitle);
if ($strTitle === '') {
return false;
}
return preg_match('/(高清影视内容精选|每日更新|影视内容推荐)/u', $strTitle) === 1;
}
protected static function filterHomeMetaPhrases(array $arrPhrases): array
{
return array_values(array_filter(array_map(static function ($item): string {
return trim((string)$item);
}, $arrPhrases), static function (string $item): bool {
if ($item === '') {
return false;
}
if (preg_match('/(高清影视内容精选|每日更新|高清影视|影视内容推荐|内容整理与推荐|热门片单更新|剧情与演员资料)/u', $item) === 1) {
return false;
}
return true;
}));
}
public static function extractPhrases(array $arrTexts, int $intLimit = 6, array $arrExclude = []): array
{
$arrStopWords = [
@@ -256,6 +373,9 @@ class SeoCopyContextHelper
if (preg_match('/(是一个|为您提供|欢迎|尽在|在线观看|免费看|免费高清|好网站|视觉体验|拥有全网|影视剧资源|各种|平台|专区|网站)/u', $strPart) === 1) {
continue;
}
if (preg_match('/(收录|起词|带动|导购|整理型|流量型|扩展型|首屏|详情页|分类页|搜索页|播放页|继续浏览|浏览路线|站外反馈|活跃词)/u', $strPart) === 1) {
continue;
}
if (preg_match('/(搜索结果|分类频道|内容列表|搜索落地页)$/u', $strPart) === 1) {
continue;
}

View File

@@ -26,8 +26,10 @@ class SeoCopyFallbackBuilder
{
$strSiteName = self::siteName($arrFacts);
$strScopes = self::joinItems((array)($arrFacts['content_scopes'] ?? []), 3, '影视内容');
$strLanding = $strSiteName . '首页当前覆盖' . $strScopes . '等常见内容入口,适合先看首屏推荐,再按分类、榜单或搜索继续缩小范围。';
return [
'search_landing' => $strLanding,
'intro_text' => $strSiteName . '首页当前覆盖' . $strScopes . '等常见内容入口,建议先看首屏内容卡片,再继续按分类、榜单或搜索缩小范围。',
'intro_meta' => '首页更适合先发现值得点开的内容,说明性提示只做辅助,不建议压住首屏主体内容。',
'guide_cards' => [
@@ -94,8 +96,10 @@ class SeoCopyFallbackBuilder
private static function buildRankIndex(array $arrFacts): array
{
$strPeriods = self::joinItems((array)($arrFacts['rank_periods'] ?? []), 3, '常见榜单周期');
$strLanding = '当前榜单首页汇总了' . $strPeriods . '等常见周期入口,适合先确定周期,再继续进入具体榜单列表页。';
return [
'search_landing' => $strLanding,
'intro_text' => '当前榜单首页汇总了' . $strPeriods . '等常见周期入口,适合先确定要看的榜单范围再继续下钻。',
'intro_meta' => '如果你更关心具体周期,可以直接进入对应榜单列表页,再继续看详情和播放路径。',
'guide_cards' => [
@@ -111,8 +115,10 @@ class SeoCopyFallbackBuilder
$strPeriod = self::fallback((string)($arrFacts['rank_period_name'] ?? ''), '当前周期');
$strScope = self::fallback((string)($arrFacts['rank_scope_name'] ?? ''), '当前范围');
$intVisible = (int)($arrFacts['page_stats']['visible'] ?? 0);
$strLanding = '当前榜单列表页展示的是' . $strScope . '下的' . $strPeriod . '结果,当前页可继续浏览的条目约为' . max(1, $intVisible) . '条。';
return [
'search_landing' => $strLanding,
'intro_text' => '当前榜单列表页展示的是' . $strScope . '下的' . $strPeriod . '结果,当前页可继续浏览的条目约为' . max(1, $intVisible) . '条。',
'intro_meta' => '如果你想横向对比结果,可以切到其他周期榜单,或者直接进入详情页继续确认内容。',
'guide_cards' => [

View File

@@ -87,11 +87,14 @@ class SeoCopyGenerationHelper
json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
);
$seededPublishedCopy = self::ensurePublishedStarterCopy($DomainModel, $overrides);
return [
'state' => $state,
'manifest' => $manifest,
'state_path' => self::resolveStatePath($host),
'manifest_path' => self::resolveManifestPath($host),
'seeded_published_copy' => $seededPublishedCopy,
];
}
@@ -114,13 +117,17 @@ class SeoCopyGenerationHelper
$hasLocalCopy = self::hasLocalCopyPayload($host);
$tkdMode = DomainModel::normalizeTkdMode((string)($tkdCfg['mode'] ?? DomainModel::TKD_MODE_AUTO_GENERATE));
$tkdProvider = DomainModel::normalizeTkdProvider((string)($tkdCfg['provider'] ?? DomainModel::TKD_PROVIDER_LOCAL));
$boolOpenAiCopyPreferred = $tkdProvider === DomainModel::TKD_PROVIDER_OPENAI
|| $tkdMode === DomainModel::TKD_MODE_AI_OPTIMIZE;
$waitForAi = !empty($copyCfg['wait_for_ai_before_publish']) || ($tkdProvider === DomainModel::TKD_PROVIDER_OPENAI && $tkdMode !== DomainModel::TKD_MODE_FORCE_IMPORT);
$status = self::STATUS_LOCAL_READY;
if ($waitForAi) {
$status = $hasLocalCopy ? self::STATUS_LOCAL_READY : self::STATUS_AI_PENDING;
}
if (!empty($rawCopyCfg['status']) && in_array((string)$rawCopyCfg['status'], [self::STATUS_DRAFT, self::STATUS_LOCAL_READY, self::STATUS_AI_PENDING, self::STATUS_AI_READY], true)) {
$strRawStatus = (string)($rawCopyCfg['status'] ?? '');
$boolLegacyDraftForOpenAi = $boolOpenAiCopyPreferred && $strRawStatus === self::STATUS_DRAFT;
if (!$boolLegacyDraftForOpenAi && $strRawStatus !== '' && in_array($strRawStatus, [self::STATUS_DRAFT, self::STATUS_LOCAL_READY, self::STATUS_AI_PENDING, self::STATUS_AI_READY], true)) {
$status = (string)$rawCopyCfg['status'];
}
if (!empty($overrides['status'])) {
@@ -193,12 +200,83 @@ class SeoCopyGenerationHelper
return is_dir($root);
}
protected static function hasPublishedCopyPayload(string $host): bool
{
$host = self::normalizeHost($host);
if ($host === '') {
return false;
}
$root = SeoCopyStore::publishedRoot() . '/' . self::buildHostKey($host);
if (!is_dir($root)) {
return false;
}
$arrFiles = glob($root . '/*/*.json') ?: [];
return !empty($arrFiles);
}
protected static function ensurePublishedStarterCopy(DomainModel $DomainModel, array $overrides = []): array
{
$host = self::normalizeHost((string)($DomainModel->d_domain ?? ''));
if ($host === '') {
return [
'attempted' => false,
'reason' => 'empty_host',
];
}
if (array_key_exists('seed_published_copy', $overrides) && empty($overrides['seed_published_copy'])) {
return [
'attempted' => false,
'reason' => 'disabled_by_override',
'host' => $host,
];
}
if (self::hasPublishedCopyPayload($host)) {
return [
'attempted' => false,
'reason' => 'already_exists',
'host' => $host,
];
}
try {
$arrResult = SeoCopyAiProviderHelper::generatePublishedCopy($DomainModel, [
// 首次初始化始终只落一本地增强版 starter避免误调远程 AI。
'provider' => DomainModel::TKD_PROVIDER_LOCAL,
]);
return [
'attempted' => true,
'reason' => 'seeded_local_starter',
'host' => $host,
'provider_effective' => (string)($arrResult['provider_effective'] ?? DomainModel::TKD_PROVIDER_LOCAL),
'written_pages' => (array)($arrResult['written_pages'] ?? []),
'message' => (string)($arrResult['message'] ?? ''),
];
} catch (\Throwable $throwable) {
self::appendHistory($host, [
'type' => 'published_copy_seed_failed',
'message' => $throwable->getMessage(),
]);
return [
'attempted' => true,
'reason' => 'seed_failed',
'host' => $host,
'error' => $throwable->getMessage(),
];
}
}
protected static function buildStateMessage(string $status, bool $waitForAi, bool $hasLocalCopy): string
{
return match ($status) {
self::STATUS_AI_PENDING => $waitForAi
? '当前站点已导入,等待 AI 生成首页/分类/搜索等核心文案后再进入发布阶段。'
: '当前站点已进入 AI 生成队列。',
? '当前站点已进入 AI 待处理状态。默认由 Codex/人工优先接管优化;只有后台显式点击 AI 优化时,才会调用远程 AI 生成。'
: '当前站点已进入 AI 待处理状态,可由 Codex/人工继续优化,或在后台手动触发 AI 任务。',
self::STATUS_AI_READY => 'AI 文案已就绪,可继续发布或切换到 AI 版本。',
self::STATUS_LOCAL_READY => $hasLocalCopy
? '本地规则文案与本地文案稿均可用,可直接上线或继续等待 AI 增强。'

View File

@@ -24,7 +24,93 @@ class SeoCopyStore
return trim($strTemplate);
}
return trim(strtr($strTemplate, $arrReplace));
foreach ($arrTokens as $strKey => $mValue) {
$strKey = trim((string)$strKey);
if ($strKey === '' || trim((string)$mValue) !== '') {
continue;
}
$strTemplate = self::stripEmptyTokenArtifacts($strTemplate, $strKey);
}
return self::normalizeInterpolatedText(strtr($strTemplate, $arrReplace));
}
private static function stripEmptyTokenArtifacts(string $strTemplate, string $strKey): string
{
$strToken = preg_quote('{' . $strKey . '}', '/');
$arrLabelMap = [
'video_alias' => ['别名'],
'year' => ['年份'],
'area_name' => ['地区'],
'lang_name' => ['语言'],
'actor_names' => ['演员'],
'director_names' => ['导演'],
'remarks' => ['备注'],
'play_line' => ['播放线路', '线路'],
'episode_name' => ['集数', '期数', '剧集'],
];
foreach ((array)($arrLabelMap[$strKey] ?? []) as $strLabel) {
$strLabel = preg_quote($strLabel, '/');
$arrLabelPatterns = [
'/' . $strLabel . '\s*' . $strToken . '\s*[、,,]/u',
'/[、,,]\s*' . $strLabel . '\s*' . $strToken . '/u',
'/' . $strLabel . '\s*' . $strToken . '/u',
];
foreach ($arrLabelPatterns as $strPattern) {
$strTemplate = preg_replace($strPattern, '', $strTemplate) ?? $strTemplate;
}
}
$arrPatterns = [
// 删掉前置连词 + 空 token例如 “和{remarks}”
'/\s*(?:和|及|与|并|或)\s*' . $strToken . '/u',
// 删掉空 token + 后置连词,例如 “{remarks}和”
'/' . $strToken . '\s*(?:和|及|与|并|或)\s*/u',
// 删掉与空 token 紧邻的顿号/逗号
'/[、,,]\s*' . $strToken . '/u',
'/' . $strToken . '\s*[、,,]/u',
// 默认兜底:直接删掉空 token
'/' . $strToken . '/u',
];
foreach ($arrPatterns as $strPattern) {
$strTemplate = preg_replace($strPattern, '', $strTemplate) ?? $strTemplate;
}
return $strTemplate;
}
private static function normalizeInterpolatedText(string $strValue): string
{
$strValue = preg_replace('/\{[a-z0-9_]+\}/iu', '', $strValue) ?? $strValue;
$strValue = str_replace(['高清高清播放', '高清高清'], ['高清播放', '高清'], $strValue);
$strValue = preg_replace('/\s{2,}/u', ' ', $strValue) ?? $strValue;
$strValue = preg_replace('/\s*[_|]+\s*/u', ' - ', $strValue) ?? $strValue;
$strValue = preg_replace('/\s*-\s*-\s*/u', ' - ', $strValue) ?? $strValue;
$strValue = preg_replace('/(会把|把)([^,。;!?、\\s]{1,24})和([^,。;!?、\\s]{1,24})(等资料|这类线索)/u', '$1$2、$3$4', $strValue) ?? $strValue;
$strValue = preg_replace('/(年份|地区|语言|演员|导演|备注|别名|线路|集数)(?=[A-Za-z0-9])/u', '$1 ', $strValue) ?? $strValue;
$strValue = preg_replace('/(地区|语言|演员|导演|备注|别名)([\p{Han}A-Za-z])/u', '$1 $2', $strValue) ?? $strValue;
$strValue = preg_replace('/地区\s*([^,。;!?、]+?)和语言\s*([^,。;!?、]+)/u', '地区 $1语言 $2', $strValue) ?? $strValue;
$strValue = preg_replace('/(会把)([^,。;!?、\\s]{1,24})、([^,。;!?、\\s]{1,24})(这类线索)/u', '$1$2和$3$4', $strValue) ?? $strValue;
$strValue = preg_replace('/(会把)([^,。;!?、\\s]{1,24})、([^,。;!?、\\s]{1,24})(等资料)/u', '$1$2、$3$4', $strValue) ?? $strValue;
$strValue = preg_replace('/补看(\d{4}[^、,。;!?]*?)、([\p{Han}A-Za-z]{1,20})、([\p{Han}A-Za-z]{1,20})与([\p{Han}A-Za-z·、,]{1,40})/u', '补看 $1、$2、$3以及$4', $strValue) ?? $strValue;
$strValue = preg_replace('/(别名|年份|地区|语言|演员|导演|备注)\s+与/u', '$1与', $strValue) ?? $strValue;
$strValue = preg_replace('/本页,建议/u', '本页建议', $strValue) ?? $strValue;
$strValue = preg_replace('/本页,适合/u', '本页适合', $strValue) ?? $strValue;
$strValue = preg_replace('/本页,方便/u', '本页方便', $strValue) ?? $strValue;
$strValue = preg_replace('/([、,,])(?:\s*\\1)+/u', '$1', $strValue) ?? $strValue;
$strValue = preg_replace('/([(【\[])\s*[、,,\-]+\s*/u', '$1', $strValue) ?? $strValue;
$strValue = preg_replace('/\s*[、,,\-]+\s*([)】\]])/u', '$1', $strValue) ?? $strValue;
$strValue = preg_replace('/(会把|把|将|用|看)、/u', '$1', $strValue) ?? $strValue;
$strValue = preg_replace('/(\d{4})-\d{2}-\d{2}(?:\s+\d{2}:\d{2}:\d{2})?/u', '$1', $strValue) ?? $strValue;
$strValue = preg_replace('/\s*([,。;!?、])/u', '$1', $strValue) ?? $strValue;
$strValue = preg_replace('/([(【\[])\s+/u', '$1', $strValue) ?? $strValue;
$strValue = preg_replace('/\s+([)】\]])/u', '$1', $strValue) ?? $strValue;
return trim($strValue);
}
public static function buildPageKey(array|string $mValue, string $strFallback = 'index'): string

View File

@@ -4,6 +4,31 @@ namespace app\common\helper;
class SeoWordsHelper
{
/**
* Resolve a stable forge keyword from video seo words.
*/
public static function resolveForgeWord(array $video = [], int $intForgeId = 1): string
{
$intForgeId = max(1, $intForgeId);
$arrWords = array_values(array_filter(array_map(
static fn($mVal): string => trim((string)$mVal),
(array)($video['v_seo_words'] ?? [])
), static fn(string $strVal): bool => $strVal !== ''));
if (!empty($arrWords)) {
$intIndex = ($intForgeId - 1) % count($arrWords);
return $arrWords[$intIndex];
}
$strName = trim((string)($video['v_name'] ?? ''));
if ($strName !== '') {
return $strName;
}
return trim((string)($video['v_name_en'] ?? ''));
}
/**
* 根据 $intRewrite 处理 SeoWords
*/
@@ -22,32 +47,39 @@ class SeoWordsHelper
// 0原序全部
case 0:
$arrSeoWords = $words;
break;
// 1前 6
// 1前 6
case 1:
$arrSeoWords = array_slice($words, 0, 6);
break;
// 2随机 8
// 2随机 8
case 2:
shuffle($words);
$arrSeoWords = array_slice($words, 0, 8);
break;
// 3插年份
// 3插年份
case 3:
$arrSeoWords = self::appendYear($words, $video);
break;
// 4插“在线观看 / 免费 / 高清”
// 4插“在线观看 / 免费 / 高清”
case 4:
$arrSeoWords = self::appendSuffix($words);
break;
// 5重排 + 去重
// 5重排 + 去重
case 5:
shuffle($words);
$arrSeoWords = array_values(array_unique($words));
break;
// 兜底
// 兜底
default:
$arrSeoWords = array_slice($words, 0, 6);
break;
}
$arrNewsSeoWords = [];

View File

@@ -1710,7 +1710,13 @@ class SiteStyle
{
if (!$domainRow) return null;
$raw = is_array($domainRow) ? ($domainRow['t_cfg'] ?? null) : ($domainRow->t_cfg ?? null);
return $raw ? json_decode($raw, true) : null;
if (is_array($raw)) {
return $raw;
}
if (!is_string($raw) || trim($raw) === '') {
return null;
}
return json_decode($raw, true);
}
private static function writeDbConfig($domainRow, array $cfg): void
@@ -1871,7 +1877,6 @@ class SiteStyle
$intId = (int)($arrVideo['v_id'] ?? 0);
if (!empty($strSlug) && $intId > 0) {
// 你当前详情 URL 示例:/voddetail/qian-long-zai-tian-247979
return '/voddetail/' . $strSlug . '-' . $intId;
}
if ($intId > 0) {

View File

@@ -36,32 +36,62 @@ class SpiderMdConfigHelper
],
'SPIDER_MD_GITEE_ENABLED' => [
'default' => '0',
'description' => '蜘蛛池MD是否启用 Gitee 推送',
'description' => '蜘蛛池MD是否启用 Gitee 推送0=关闭1=开启;开启后会把生成的 markdown 与 links.json 同步到 Gitee',
'secret' => false,
],
'SPIDER_MD_GITEE_OWNER' => [
'default' => '',
'description' => '蜘蛛池MD Gitee 仓库 owner',
'description' => '蜘蛛池MD Gitee 仓库 owner(仓库所属用户名或组织名,例如 yourname',
'secret' => false,
],
'SPIDER_MD_GITEE_REPO' => [
'default' => '',
'description' => '蜘蛛池MD Gitee 仓库 repo',
'description' => '蜘蛛池MD Gitee 仓库 repo(仓库名,不带 owner例如 seo-spider-md',
'secret' => false,
],
'SPIDER_MD_GITEE_BRANCH' => [
'default' => 'master',
'description' => '蜘蛛池MD Gitee 分支',
'description' => '蜘蛛池MD Gitee 分支(默认 master推送文件会写入这个分支',
'secret' => false,
],
'SPIDER_MD_GITEE_ROOT' => [
'default' => 'seo-spider-md',
'description' => '蜘蛛池MD Gitee 远程根目录',
'description' => '蜘蛛池MD Gitee 远程根目录(仓库内子目录,例如 seo-spider-md留空表示直接写仓库根目录',
'secret' => false,
],
'SPIDER_MD_GITEE_TOKEN' => [
'default' => '',
'description' => '蜘蛛池MD Gitee Token',
'description' => '蜘蛛池MD Gitee Token(需要仓库 contents / push 权限;后台保存后会参与远程推送)',
'secret' => true,
],
'SPIDER_MD_GITHUB_ENABLED' => [
'default' => '0',
'description' => '蜘蛛池MD是否启用 GitHub 推送0=关闭1=开启;开启后会把生成的 markdown 与 links.json 同步到 GitHub',
'secret' => false,
],
'SPIDER_MD_GITHUB_OWNER' => [
'default' => '',
'description' => '蜘蛛池MD GitHub 仓库 owner仓库所属用户名或组织名例如 yourname',
'secret' => false,
],
'SPIDER_MD_GITHUB_REPO' => [
'default' => '',
'description' => '蜘蛛池MD GitHub 仓库 repo仓库名不带 owner例如 seo-spider-md',
'secret' => false,
],
'SPIDER_MD_GITHUB_BRANCH' => [
'default' => 'main',
'description' => '蜘蛛池MD GitHub 分支(默认 main推送文件会写入这个分支',
'secret' => false,
],
'SPIDER_MD_GITHUB_ROOT' => [
'default' => 'seo-spider-md',
'description' => '蜘蛛池MD GitHub 远程根目录(仓库内子目录,例如 seo-spider-md留空表示直接写仓库根目录',
'secret' => false,
],
'SPIDER_MD_GITHUB_TOKEN' => [
'default' => '',
'description' => '蜘蛛池MD GitHub Token建议使用细粒度 Token并授予当前仓库 Contents: Read and write 权限)',
'secret' => true,
],
'SPIDER_MD_RETENTION_DAYS' => [
@@ -197,6 +227,43 @@ class SpiderMdConfigHelper
];
}
public static function resolveGithubConfig(): array
{
self::ensureSystemConfigDefaults();
$enabledMeta = self::readValueMeta('SPIDER_MD_GITHUB_ENABLED');
$ownerMeta = self::readValueMeta('SPIDER_MD_GITHUB_OWNER');
$repoMeta = self::readValueMeta('SPIDER_MD_GITHUB_REPO');
$branchMeta = self::readValueMeta('SPIDER_MD_GITHUB_BRANCH');
$rootMeta = self::readValueMeta('SPIDER_MD_GITHUB_ROOT');
$tokenMeta = self::readValueMeta('SPIDER_MD_GITHUB_TOKEN');
$enabled = (int)($enabledMeta['value'] ?? '0') === 1;
$owner = trim((string)($ownerMeta['value'] ?? ''));
$repo = trim((string)($repoMeta['value'] ?? ''));
$branch = trim((string)($branchMeta['value'] ?? 'main'));
$root = trim((string)($rootMeta['value'] ?? 'seo-spider-md'));
$token = trim((string)($tokenMeta['value'] ?? ''));
return [
'enabled' => $enabled ? 1 : 0,
'configured' => ($enabled && $owner !== '' && $repo !== '' && $token !== '') ? 1 : 0,
'owner' => $owner,
'repo' => $repo,
'branch' => $branch !== '' ? $branch : 'main',
'root' => $root,
'token' => $token,
'sources' => [
'enabled' => (string)($enabledMeta['source'] ?? 'env'),
'owner' => (string)($ownerMeta['source'] ?? 'env'),
'repo' => (string)($repoMeta['source'] ?? 'env'),
'branch' => (string)($branchMeta['source'] ?? 'env'),
'root' => (string)($rootMeta['source'] ?? 'env'),
'token' => (string)($tokenMeta['source'] ?? 'env'),
],
];
}
protected static function readOperationalValue(string $code): string
{
return (string)(self::readValueMeta($code)['value'] ?? '');

View File

@@ -83,7 +83,7 @@ class UrlBuilder
return $this->replacePattern($pattern, [
'strParentCategory' => $strParentCategory,
'strCategory' => $strCategory,
'intPage' => max(1, (int)$intPage),
'intPage' => $this->normalizePageValue($intPage),
]);
}
@@ -226,4 +226,13 @@ class UrlBuilder
}
return $this->trimSlash($pattern);
}
protected function normalizePageValue(int|string $page): string
{
if (is_string($page) && strpos($page, '{page}') !== false) {
return '{page}';
}
return (string)max(1, (int)$page);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class VideoMetadataCopyFillHelper
{
public static function buildFillPayload(array $arrVideo, string $strSource = 'local_copy_fill'): array
{
$strDescription = trim((string)($arrVideo['v_description'] ?? ''));
$strRemarks = trim((string)($arrVideo['v_remarks'] ?? ''));
$arrUpdate = [];
$arrFilledFields = [];
if ($strDescription === '') {
$strGeneratedDescription = self::buildDescription($arrVideo);
if ($strGeneratedDescription !== '') {
$arrUpdate['v_description'] = $strGeneratedDescription;
$arrFilledFields[] = 'v_description';
}
}
if ($strRemarks === '') {
$strGeneratedRemarks = self::buildRemarks($arrVideo);
if ($strGeneratedRemarks !== '') {
$arrUpdate['v_remarks'] = $strGeneratedRemarks;
$arrFilledFields[] = 'v_remarks';
}
}
if (empty($arrFilledFields)) {
return [];
}
$arrUpdate['updated_at'] = new \MongoDB\BSON\UTCDateTime();
$arrUpdate['v_metadata_fill'] = [
'last_source' => $strSource,
'updated_at' => new \MongoDB\BSON\UTCDateTime(),
'filled_fields' => $arrFilledFields,
];
return $arrUpdate;
}
public static function buildDescription(array $arrVideo): string
{
$strVideoName = trim((string)($arrVideo['v_name'] ?? ''));
if ($strVideoName === '') {
return '';
}
$strCategory = trim((string)($arrVideo['v_category'] ?? ''));
$strParentCategory = trim((string)($arrVideo['v_parent_category'] ?? ''));
$strCategoryLabel = $strCategory !== '' ? $strCategory : $strParentCategory;
$strYear = trim((string)($arrVideo['v_year'] ?? ''));
$strArea = self::joinList($arrVideo['v_area'] ?? []);
$strLang = self::joinList($arrVideo['v_lang'] ?? []);
$strDirector = self::joinList($arrVideo['v_director'] ?? []);
$strActor = self::joinList($arrVideo['v_actor'] ?? [], 4);
$strRemarks = trim((string)($arrVideo['v_remarks'] ?? ''));
$boolEnded = (int)($arrVideo['v_isend'] ?? 0) === 1;
$arrSentences = [];
$strHead = $strVideoName;
if ($strCategoryLabel !== '') {
$strHead .= '属于' . $strCategoryLabel;
} else {
$strHead .= '为影视内容资料页';
}
if ($strYear !== '') {
$strHead .= ',记录年份为' . $strYear;
}
if ($strArea !== '') {
$strHead .= ',地区信息为' . $strArea;
}
if ($strLang !== '') {
$strHead .= ',语言信息为' . $strLang;
}
$arrSentences[] = $strHead . '。';
$arrPeople = [];
if ($strDirector !== '') {
$arrPeople[] = '导演信息为' . $strDirector;
}
if ($strActor !== '') {
$arrPeople[] = '演员阵容包括' . $strActor;
}
if (!empty($arrPeople)) {
$arrSentences[] = implode('', $arrPeople) . '。';
}
$arrStatus = [];
if ($strRemarks !== '') {
$arrStatus[] = '当前更新备注为' . $strRemarks;
}
$arrStatus[] = $boolEnded ? '内容状态显示已完结' : '内容状态以当前更新进度为准';
$arrSentences[] = implode('', $arrStatus) . '。';
$strDescription = preg_replace('/\s+/u', '', implode('', $arrSentences));
$strDescription = trim((string)$strDescription);
if ($strDescription === '') {
return '';
}
if (mb_strlen($strDescription) > 180) {
$strDescription = mb_substr($strDescription, 0, 178) . '。';
}
return $strDescription;
}
public static function buildRemarks(array $arrVideo): string
{
$strRemarks = trim((string)($arrVideo['v_remarks'] ?? ''));
if ($strRemarks !== '') {
return $strRemarks;
}
$boolEnded = (int)($arrVideo['v_isend'] ?? 0) === 1;
$strPublishDate = trim((string)($arrVideo['v_publish_date'] ?? ''));
$strYear = trim((string)($arrVideo['v_year'] ?? ''));
if ($boolEnded) {
return '已完结';
}
if ($strPublishDate !== '') {
return '更新至' . mb_substr($strPublishDate, 0, 10);
}
if ($strYear !== '') {
return $strYear . '年内容';
}
return '';
}
protected static function joinList($mixedValue, int $intLimit = 3): string
{
$arrValues = is_array($mixedValue) ? $mixedValue : [$mixedValue];
$arrValues = array_values(array_filter(array_map(static function ($mixedItem): string {
return trim((string)$mixedItem);
}, $arrValues), static function (string $strItem): bool {
if ($strItem === '') {
return false;
}
$arrInvalid = ['未知', '内详', '暂无', '不详', '待补充', '未知导演', '未知演员'];
return !in_array($strItem, $arrInvalid, true);
}));
if (empty($arrValues)) {
return '';
}
return implode('、', array_slice($arrValues, 0, $intLimit));
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class VideoMetadataMissingRefreshHelper
{
public static function refresh(array $arrOptions = []): array
{
$intSample = max(1, min(100, (int)($arrOptions['sample'] ?? 20)));
$intQueueLimit = max(1, min(500, (int)($arrOptions['queue_limit'] ?? 100)));
$intPromptLimit = max(1, min(100, (int)($arrOptions['prompt_limit'] ?? 20)));
$intBatchSize = max(5, min(100, (int)($arrOptions['batch_size'] ?? 20)));
$intBatchLimit = max(1, min(50, (int)($arrOptions['batch_limit'] ?? 10)));
$strStorageRoot = rtrim((string)root_path(), '/') . '/storage/video-metadata-missing';
$strWorkbenchRoot = $strStorageRoot . '/workbench';
$strTaskPoolRoot = $strStorageRoot . '/task-pool';
$arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::buildSummary(
$intSample,
$intQueueLimit,
$intPromptLimit
);
$arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts(
$strWorkbenchRoot,
$arrWorkbenchSummary
);
$arrTaskPoolSummary = VideoMetadataMissingTaskPoolHelper::buildSummary(
$arrWorkbenchSummary,
$intBatchSize,
$intBatchLimit
);
$arrTaskPoolSummary = VideoMetadataMissingTaskPoolHelper::writeArtifacts(
$strTaskPoolRoot,
$arrTaskPoolSummary
);
$arrTaskPoolStatusSummary = VideoMetadataMissingTaskPoolStatusHelper::buildSummary(
$strWorkbenchRoot,
$strTaskPoolRoot
);
$arrTaskPoolStatusSummary = VideoMetadataMissingTaskPoolStatusHelper::writeArtifacts(
$strTaskPoolRoot,
$arrTaskPoolStatusSummary
);
return [
'options' => [
'sample' => $intSample,
'queue_limit' => $intQueueLimit,
'prompt_limit' => $intPromptLimit,
'batch_size' => $intBatchSize,
'batch_limit' => $intBatchLimit,
],
'workbench' => $arrWorkbenchSummary,
'task_pool' => $arrTaskPoolSummary,
'task_pool_status' => $arrTaskPoolStatusSummary,
];
}
}

View File

@@ -0,0 +1,450 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class VideoMetadataMissingTaskPoolHelper
{
public static function buildSummary(array $arrWorkbenchSummary, int $intBatchSize = 20, int $intBatchLimit = 10): array
{
$intBatchSize = max(5, min($intBatchSize, 100));
$intBatchLimit = max(1, min($intBatchLimit, 50));
$arrAudit = (array)($arrWorkbenchSummary['audit'] ?? []);
$arrQueue = (array)($arrWorkbenchSummary['queue'] ?? []);
$arrItems = (array)($arrQueue['items'] ?? []);
$arrFieldStats = (array)($arrAudit['field_stats'] ?? []);
usort($arrFieldStats, static function (array $arrA, array $arrB): int {
return (int)($arrB['missing_count'] ?? 0) <=> (int)($arrA['missing_count'] ?? 0);
});
$arrBatches = self::buildBatches($arrItems, $arrFieldStats, $intBatchSize, $intBatchLimit);
$arrSummary = [
'generated_at' => date('c'),
'source_generated_at' => (string)($arrWorkbenchSummary['generated_at'] ?? ''),
'batch_size' => $intBatchSize,
'batch_limit' => $intBatchLimit,
'total_videos' => (int)($arrAudit['total_videos'] ?? 0),
'videos_with_any_missing_metadata' => (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0),
'queue_size' => (int)($arrQueue['queue_size'] ?? 0),
'top_missing_fields' => array_values(array_slice($arrFieldStats, 0, 5)),
'batches' => $arrBatches,
'todo_summary' => self::buildTodoSummary($arrBatches),
'operator_hint' => '这是一套给技术 / Codex 使用的缺字段待处理池。当前先用产物型批次池,不直接入库,不自动补事实字段。',
];
return VideoMetadataMissingTaskPoolStateHelper::applyStateMap($arrSummary, [], '');
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim(str_replace('\\', '/', $strOutputRoot), '/');
$arrSummary = VideoMetadataMissingTaskPoolStateHelper::applyStateMap(
$arrSummary,
VideoMetadataMissingTaskPoolStateHelper::readStateMap($strOutputRoot),
$strOutputRoot
);
$arrSummary['run_id'] = self::buildRunId();
$arrSummary['summary_json_path'] = $strOutputRoot . '/index.json';
$arrSummary['summary_html_path'] = $strOutputRoot . '/index.html';
$arrSummary = self::writeLatestBatchArtifacts($strOutputRoot, $arrSummary);
self::writeFile(
$arrSummary['summary_json_path'],
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
self::writeFile($arrSummary['summary_html_path'], self::renderHtml($arrSummary));
self::writeRunArtifacts($strOutputRoot, $arrSummary);
return $arrSummary;
}
public static function readLatestSummary(string $strOutputRoot): array
{
$strPath = rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/index.json';
if (!is_file($strPath)) {
return [];
}
$arrSummary = json_decode((string)file_get_contents($strPath), true);
if (!is_array($arrSummary)) {
return [];
}
return VideoMetadataMissingTaskPoolStateHelper::applyStateMap(
$arrSummary,
VideoMetadataMissingTaskPoolStateHelper::readStateMap($strOutputRoot),
$strOutputRoot
);
}
public static function findBatchById(array $arrSummary, string $strBatchId): array
{
$strBatchId = trim($strBatchId);
if ($strBatchId === '') {
return [];
}
foreach ((array)($arrSummary['batches'] ?? []) as $arrBatch) {
if (!is_array($arrBatch)) {
continue;
}
if ((string)($arrBatch['batch_id'] ?? '') === $strBatchId) {
return $arrBatch;
}
}
return [];
}
protected static function buildBatches(array $arrItems, array $arrFieldStats, int $intBatchSize, int $intBatchLimit): array
{
$arrFieldOrder = [];
foreach ($arrFieldStats as $arrField) {
$strField = trim((string)($arrField['field'] ?? ''));
if ($strField !== '') {
$arrFieldOrder[] = $strField;
}
}
if (empty($arrFieldOrder)) {
$arrFieldOrder = ['v_director', 'v_actor'];
}
$arrBuckets = [];
foreach ($arrItems as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$arrMissingFields = array_values(array_filter((array)($arrItem['missing_fields'] ?? []), static fn ($strField): bool => trim((string)$strField) !== ''));
$strPrimaryField = '';
foreach ($arrFieldOrder as $strField) {
if (in_array($strField, $arrMissingFields, true)) {
$strPrimaryField = $strField;
break;
}
}
if ($strPrimaryField === '') {
$strPrimaryField = (string)($arrMissingFields[0] ?? 'unknown');
}
$arrBuckets[$strPrimaryField][] = $arrItem;
}
$arrBatches = [];
$intBatchNo = 1;
foreach ($arrFieldOrder as $strField) {
$arrBucket = (array)($arrBuckets[$strField] ?? []);
if (empty($arrBucket)) {
continue;
}
$arrChunks = array_chunk($arrBucket, $intBatchSize);
foreach ($arrChunks as $intChunkIndex => $arrChunk) {
if (count($arrBatches) >= $intBatchLimit) {
break 2;
}
$strBatchId = sprintf('video-metadata-batch-%02d', $intBatchNo);
$arrBatches[] = [
'batch_id' => $strBatchId,
'batch_no' => $intBatchNo,
'focus_field' => $strField,
'batch_label' => self::buildBatchLabel($strField, $intChunkIndex + 1),
'items_count' => count($arrChunk),
'priority_level' => self::resolvePriorityLevel($strField),
'items' => array_values($arrChunk),
'codex_prompt' => self::buildBatchPrompt($strField, $strBatchId, $arrChunk),
];
$intBatchNo++;
}
}
return $arrBatches;
}
protected static function buildTodoSummary(array $arrBatches): array
{
$arrTodo = [];
foreach ($arrBatches as $arrBatch) {
$arrTodo[] = [
'batch_id' => (string)($arrBatch['batch_id'] ?? ''),
'label' => (string)($arrBatch['batch_label'] ?? ''),
'focus_field' => (string)($arrBatch['focus_field'] ?? ''),
'items_count' => (int)($arrBatch['items_count'] ?? 0),
'priority_level' => (string)($arrBatch['priority_level'] ?? ''),
];
}
return $arrTodo;
}
protected static function buildBatchLabel(string $strField, int $intChunkNo): string
{
$arrMap = [
'v_director' => '导演缺失批次',
'v_actor' => '演员缺失批次',
'v_lang' => '语言缺失批次',
'v_lang_en' => '英文语言缺失批次',
'v_area' => '地区缺失批次',
'v_area_en' => '英文地区缺失批次',
'v_year' => '年份缺失批次',
'v_description' => '简介缺失批次',
'v_remarks' => '备注缺失批次',
'v_publish_date' => '上映时间缺失批次',
];
return ($arrMap[$strField] ?? ($strField . ' 缺失批次')) . ' #' . $intChunkNo;
}
protected static function resolvePriorityLevel(string $strField): string
{
return match ($strField) {
'v_director', 'v_actor' => 'P1',
'v_lang', 'v_lang_en', 'v_area', 'v_area_en' => 'P2',
default => 'P3',
};
}
protected static function buildBatchPrompt(string $strField, string $strBatchId, array $arrItems): string
{
$arrLines = [];
$arrLines[] = '# 视频缺字段批次接手';
$arrLines[] = '';
$arrLines[] = '- 批次ID' . $strBatchId;
$arrLines[] = '- 重点字段:' . $strField;
$arrLines[] = '- 批次数量:' . count($arrItems);
$arrLines[] = '';
$arrLines[] = '要求:';
$arrLines[] = '1. 结构化字段不能编造,优先建议重采、交叉源核验、人工确认。';
$arrLines[] = '2. 先按优先级给出处理顺序,再给出每条或每组的建议动作。';
$arrLines[] = '3. 如果某些条目只适合补文案,不适合补事实字段,要明确标记。';
$arrLines[] = '';
$arrLines[] = '待处理视频:';
foreach ($arrItems as $arrItem) {
$arrLines[] = sprintf(
'- v_id=%d | %s | 分类=%s | 缺失=%s | 原因=%s',
(int)($arrItem['v_id'] ?? 0),
trim((string)($arrItem['v_name'] ?? '')),
trim((string)($arrItem['v_category'] ?? '')),
implode(',', (array)($arrItem['missing_fields'] ?? [])),
trim((string)($arrItem['priority_reason'] ?? ''))
);
}
$arrLines[] = '';
$arrLines[] = '请输出:';
$arrLines[] = '1. 本批次的处理优先顺序';
$arrLines[] = '2. 每条适合走重采 / 人工核验 / 仅补文案 哪一种';
$arrLines[] = '3. 如果要继续写脚本或命令,请直接给执行建议';
$arrLines[] = '';
return implode(PHP_EOL, $arrLines) . PHP_EOL;
}
protected static function renderHtml(array $arrSummary): string
{
$arrBatches = (array)($arrSummary['batches'] ?? []);
$strRows = '';
foreach ($arrBatches as $arrBatch) {
$strPromptPath = htmlspecialchars((string)($arrBatch['prompt_markdown_path'] ?? ''), ENT_QUOTES, 'UTF-8');
$strDetailPath = htmlspecialchars((string)($arrBatch['detail_json_path'] ?? ''), ENT_QUOTES, 'UTF-8');
$strHistoryPath = htmlspecialchars((string)($arrBatch['history_json_path'] ?? ''), ENT_QUOTES, 'UTF-8');
$strRows .= '<tr>'
. '<td>' . htmlspecialchars((string)($arrBatch['batch_id'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrBatch['batch_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrBatch['focus_field'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrBatch['priority_level'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrBatch['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrBatch['owner'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . (int)($arrBatch['history_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrBatch['items_count'] ?? 0) . '</td>'
. '<td>'
. '<button type="button" class="btn" data-copy-prompt="' . htmlspecialchars((string)($arrBatch['codex_prompt'] ?? ''), ENT_QUOTES, 'UTF-8') . '">复制提示词</button> '
. ($strDetailPath !== '' ? '<a class="btn link" href="' . $strDetailPath . '" target="_blank">详情JSON</a> ' : '')
. ($strPromptPath !== '' ? '<a class="btn link" href="' . $strPromptPath . '" target="_blank">提示词MD</a> ' : '')
. ($strHistoryPath !== '' ? '<a class="btn link" href="' . $strHistoryPath . '" target="_blank">历史JSON</a>' : '')
. '</td>'
. '</tr>';
}
$strCards = '';
foreach ($arrBatches as $arrBatch) {
$strPromptPath = htmlspecialchars((string)($arrBatch['prompt_markdown_path'] ?? ''), ENT_QUOTES, 'UTF-8');
$strDetailPath = htmlspecialchars((string)($arrBatch['detail_json_path'] ?? ''), ENT_QUOTES, 'UTF-8');
$strHistoryPath = htmlspecialchars((string)($arrBatch['history_json_path'] ?? ''), ENT_QUOTES, 'UTF-8');
$strCards .= '<div class="batch-card">'
. '<h3>' . htmlspecialchars((string)($arrBatch['batch_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</h3>'
. '<p><strong>批次ID</strong>' . htmlspecialchars((string)($arrBatch['batch_id'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
. '<p><strong>重点字段:</strong>' . htmlspecialchars((string)($arrBatch['focus_field'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
. '<p><strong>优先级:</strong>' . htmlspecialchars((string)($arrBatch['priority_level'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
. '<p><strong>状态:</strong>' . htmlspecialchars((string)($arrBatch['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
. '<p><strong>负责人:</strong>' . htmlspecialchars((string)($arrBatch['owner'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
. '<p><strong>历史次数:</strong>' . (int)($arrBatch['history_count'] ?? 0) . '</p>'
. '<p><strong>数量:</strong>' . (int)($arrBatch['items_count'] ?? 0) . '</p>'
. '<p class="actions">'
. '<button type="button" class="btn" data-copy-prompt="' . htmlspecialchars((string)($arrBatch['codex_prompt'] ?? ''), ENT_QUOTES, 'UTF-8') . '">复制提示词</button> '
. ($strDetailPath !== '' ? '<a class="btn link" href="' . $strDetailPath . '" target="_blank">详情JSON</a> ' : '')
. ($strPromptPath !== '' ? '<a class="btn link" href="' . $strPromptPath . '" target="_blank">提示词MD</a> ' : '')
. ($strHistoryPath !== '' ? '<a class="btn link" href="' . $strHistoryPath . '" target="_blank">历史JSON</a>' : '')
. '</p>'
. '</div>';
}
return '<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>视频缺字段待处理池</title>
<style>
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f8fb;color:#1f2937;margin:0;padding:24px;}
.wrap{max-width:1200px;margin:0 auto;}
.card{background:#fff;border-radius:16px;box-shadow:0 8px 24px rgba(15,23,42,.06);padding:20px;margin-bottom:20px;}
.cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}
.batch-card{border:1px solid #e5e7eb;border-radius:12px;padding:16px;background:#f8fafc;}
.batch-card h3{margin:0 0 10px;}
.batch-card p{margin:6px 0;font-size:14px;}
.actions{margin-top:12px;}
.btn{display:inline-block;border:1px solid #cbd5e1;background:#fff;color:#0f172a;border-radius:8px;padding:6px 10px;font-size:13px;text-decoration:none;cursor:pointer;}
.btn.link{cursor:pointer;}
.toolbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:12px;}
.toolbar .msg{font-size:13px;color:#475569;}
table{width:100%;border-collapse:collapse;}
th,td{border-bottom:1px solid #e5e7eb;padding:10px;text-align:left;vertical-align:top;font-size:14px;}
th{background:#f8fafc;}
@media (max-width: 900px){.cards{grid-template-columns:1fr;}}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>视频缺字段待处理池</h1>
<p>生成时间:' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>
<p>说明:当前先做产物型任务池,不自动补事实字段,不直接入库。</p>
<div class="toolbar"><span class="msg" id="copy-status">点击“复制提示词”后,可直接粘贴到 Codex 窗口。</span></div>
</div>
<div class="card">
<h2>批次卡片</h2>
<div class="cards">' . $strCards . '</div>
</div>
<div class="card">
<table>
<thead><tr><th>批次ID</th><th>批次名称</th><th>重点字段</th><th>优先级</th><th>状态</th><th>负责人</th><th>历史</th><th>数量</th><th>动作</th></tr></thead>
<tbody>' . $strRows . '</tbody>
</table>
</div>
</div>
<script>
document.querySelectorAll("[data-copy-prompt]").forEach(function (btn) {
btn.addEventListener("click", async function () {
var text = btn.getAttribute("data-copy-prompt") || "";
var status = document.getElementById("copy-status");
try {
await navigator.clipboard.writeText(text);
if (status) status.textContent = "提示词已复制,可以直接粘贴给 Codex。";
} catch (err) {
if (status) status.textContent = "当前浏览器未允许剪贴板复制,请改为打开提示词 MD 手动复制。";
}
});
});
</script>
</body>
</html>';
}
protected static function writeRunArtifacts(string $strOutputRoot, array $arrSummary): void
{
$strRunId = trim((string)($arrSummary['run_id'] ?? ''));
if ($strRunId === '') {
return;
}
$strDay = substr((string)($arrSummary['generated_at'] ?? date('c')), 0, 10);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $strDay)) {
$strDay = date('Y-m-d');
}
$strRunRoot = $strOutputRoot . '/runs/' . $strDay . '/' . $strRunId;
self::writeFile(
$strRunRoot . '/video-metadata-missing-task-pool.summary.json',
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
self::writeFile(
$strRunRoot . '/video-metadata-missing-task-pool.summary.html',
self::renderHtml($arrSummary)
);
foreach ((array)($arrSummary['batches'] ?? []) as $arrBatch) {
$strBatchId = trim((string)($arrBatch['batch_id'] ?? ''));
if ($strBatchId === '') {
continue;
}
self::writeFile($strRunRoot . '/batches/' . $strBatchId . '.md', (string)($arrBatch['codex_prompt'] ?? ''));
self::writeFile(
$strRunRoot . '/batches/' . $strBatchId . '.json',
json_encode($arrBatch, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
}
}
protected static function writeLatestBatchArtifacts(string $strOutputRoot, array $arrSummary): array
{
foreach ((array)($arrSummary['batches'] ?? []) as $intIndex => $arrBatch) {
if (!is_array($arrBatch)) {
continue;
}
$strBatchId = trim((string)($arrBatch['batch_id'] ?? ''));
if ($strBatchId === '') {
continue;
}
$strDetailPath = $strOutputRoot . '/batches/' . $strBatchId . '.json';
$strPromptPath = $strOutputRoot . '/batches/' . $strBatchId . '.md';
$strHistoryPath = $strOutputRoot . '/state/history/' . $strBatchId . '.json';
self::writeFile(
$strDetailPath,
json_encode($arrBatch, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
self::writeFile($strPromptPath, (string)($arrBatch['codex_prompt'] ?? ''));
$arrSummary['batches'][$intIndex]['detail_json_path'] = self::storageRelativePath($strDetailPath);
$arrSummary['batches'][$intIndex]['prompt_markdown_path'] = self::storageRelativePath($strPromptPath);
$arrSummary['batches'][$intIndex]['history_json_path'] = is_file($strHistoryPath)
? self::storageRelativePath($strHistoryPath)
: self::storageRelativePath($strHistoryPath);
}
return $arrSummary;
}
protected static function writeFile(string $strPath, string $strContent): void
{
$strDir = dirname($strPath);
if (!is_dir($strDir)) {
@mkdir($strDir, 0777, true);
}
file_put_contents($strPath, $strContent);
}
protected static function buildRunId(): string
{
return 'video-metadata-missing-task-pool-' . date('Ymd-His');
}
protected static function storageRelativePath(string $strPath): string
{
$strStorageRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/storage', '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strStorageRoot . '/')) {
return substr($strPath, strlen($strStorageRoot . '/'));
}
return $strPath;
}
}

View File

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class VideoMetadataMissingTaskPoolStateHelper
{
public const STATUS_PENDING = 'pending';
public const STATUS_DISPATCHED = 'dispatched';
public const STATUS_PROCESSING = 'processing';
public const STATUS_DONE = 'done';
public const STATUS_SKIPPED = 'skipped';
public static function readStateMap(string $strOutputRoot): array
{
$strPath = self::stateFilePath($strOutputRoot);
if (!is_file($strPath)) {
return [];
}
$arrData = json_decode((string)file_get_contents($strPath), true);
if (!is_array($arrData)) {
return [];
}
return (array)($arrData['batches'] ?? []);
}
public static function saveBatchState(
string $strOutputRoot,
string $strBatchId,
string $strStatus,
string $strOwner = '',
string $strNote = ''
): array {
$strBatchId = trim($strBatchId);
if ($strBatchId === '') {
return [];
}
$strStatus = self::normalizeStatus($strStatus);
$arrMap = self::readStateMap($strOutputRoot);
$arrMap[$strBatchId] = [
'batch_id' => $strBatchId,
'status' => $strStatus,
'owner' => trim($strOwner),
'note' => trim($strNote),
'updated_at' => self::nowIso8601WithMicroseconds(),
];
self::writeStateMap($strOutputRoot, $arrMap);
self::appendBatchHistory($strOutputRoot, $strBatchId, $arrMap[$strBatchId]);
return $arrMap[$strBatchId];
}
public static function readBatchHistory(string $strOutputRoot, string $strBatchId, int $intLimit = 50): array
{
$strBatchId = trim($strBatchId);
if ($strBatchId === '') {
return [];
}
$strPath = self::historyFilePath($strOutputRoot, $strBatchId);
if (!is_file($strPath)) {
return [];
}
$arrData = json_decode((string)file_get_contents($strPath), true);
if (!is_array($arrData)) {
return [];
}
$arrItems = array_values((array)($arrData['items'] ?? []));
usort($arrItems, static function (array $arrA, array $arrB): int {
return strcmp((string)($arrB['updated_at'] ?? ''), (string)($arrA['updated_at'] ?? ''));
});
return array_slice($arrItems, 0, max(1, min($intLimit, 200)));
}
public static function applyStateMap(array $arrSummary, array $arrStateMap, string $strOutputRoot = ''): array
{
$arrBatches = (array)($arrSummary['batches'] ?? []);
$arrStatusBuckets = [];
$strOutputRoot = rtrim(str_replace('\\', '/', $strOutputRoot), '/');
foreach ($arrBatches as &$arrBatch) {
$strBatchId = trim((string)($arrBatch['batch_id'] ?? ''));
$arrState = (array)($arrStateMap[$strBatchId] ?? []);
$strStatus = self::normalizeStatus((string)($arrState['status'] ?? self::STATUS_PENDING));
$arrBatch['status'] = $strStatus;
$arrBatch['status_label'] = self::statusLabel($strStatus);
$arrBatch['owner'] = trim((string)($arrState['owner'] ?? ''));
$arrBatch['note'] = trim((string)($arrState['note'] ?? ''));
$arrBatch['state_updated_at'] = trim((string)($arrState['updated_at'] ?? ''));
$arrBatch['history_count'] = $strOutputRoot !== ''
? count(self::readBatchHistory($strOutputRoot, $strBatchId, 200))
: 0;
$arrStatusBuckets[$strStatus] = (int)($arrStatusBuckets[$strStatus] ?? 0) + 1;
}
unset($arrBatch);
$arrSummary['batches'] = $arrBatches;
$arrSummary['status_buckets'] = $arrStatusBuckets;
$arrTodoSummary = [];
foreach ($arrBatches as $arrBatch) {
$arrTodoSummary[] = [
'batch_id' => (string)($arrBatch['batch_id'] ?? ''),
'label' => (string)($arrBatch['batch_label'] ?? ''),
'focus_field' => (string)($arrBatch['focus_field'] ?? ''),
'items_count' => (int)($arrBatch['items_count'] ?? 0),
'priority_level' => (string)($arrBatch['priority_level'] ?? ''),
'status' => (string)($arrBatch['status'] ?? self::STATUS_PENDING),
'status_label' => (string)($arrBatch['status_label'] ?? self::statusLabel(self::STATUS_PENDING)),
'owner' => (string)($arrBatch['owner'] ?? ''),
];
}
$arrSummary['todo_summary'] = $arrTodoSummary;
return $arrSummary;
}
public static function statusLabel(string $strStatus): string
{
return match (self::normalizeStatus($strStatus)) {
self::STATUS_PENDING => '待处理',
self::STATUS_DISPATCHED => '已派单',
self::STATUS_PROCESSING => '处理中',
self::STATUS_DONE => '已完成',
self::STATUS_SKIPPED => '已跳过',
default => '待处理',
};
}
protected static function normalizeStatus(string $strStatus): string
{
$strStatus = strtolower(trim($strStatus));
$arrAllowed = [
self::STATUS_PENDING,
self::STATUS_DISPATCHED,
self::STATUS_PROCESSING,
self::STATUS_DONE,
self::STATUS_SKIPPED,
];
return in_array($strStatus, $arrAllowed, true) ? $strStatus : self::STATUS_PENDING;
}
protected static function stateFilePath(string $strOutputRoot): string
{
return rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/state/batch-status.json';
}
protected static function writeStateMap(string $strOutputRoot, array $arrMap): void
{
$strPath = self::stateFilePath($strOutputRoot);
$strDir = dirname($strPath);
if (!is_dir($strDir)) {
@mkdir($strDir, 0777, true);
}
file_put_contents($strPath, json_encode([
'updated_at' => self::nowIso8601WithMicroseconds(),
'batches' => $arrMap,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
protected static function appendBatchHistory(string $strOutputRoot, string $strBatchId, array $arrState): void
{
$strPath = self::historyFilePath($strOutputRoot, $strBatchId);
$strDir = dirname($strPath);
if (!is_dir($strDir)) {
@mkdir($strDir, 0777, true);
}
$arrData = [];
if (is_file($strPath)) {
$arrDecoded = json_decode((string)file_get_contents($strPath), true);
if (is_array($arrDecoded)) {
$arrData = $arrDecoded;
}
}
$arrItems = array_values((array)($arrData['items'] ?? []));
$arrItems[] = [
'batch_id' => $strBatchId,
'status' => (string)($arrState['status'] ?? self::STATUS_PENDING),
'status_label' => self::statusLabel((string)($arrState['status'] ?? self::STATUS_PENDING)),
'owner' => (string)($arrState['owner'] ?? ''),
'note' => (string)($arrState['note'] ?? ''),
'updated_at' => (string)($arrState['updated_at'] ?? self::nowIso8601WithMicroseconds()),
];
file_put_contents($strPath, json_encode([
'updated_at' => self::nowIso8601WithMicroseconds(),
'batch_id' => $strBatchId,
'items' => $arrItems,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
protected static function historyFilePath(string $strOutputRoot, string $strBatchId): string
{
return rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/state/history/' . $strBatchId . '.json';
}
protected static function nowIso8601WithMicroseconds(): string
{
$floatNow = microtime(true);
$intNow = (int)$floatNow;
$intMicro = (int)(($floatNow - $intNow) * 1000000);
return sprintf('%s.%06d%s', date('Y-m-d\\TH:i:s', $intNow), $intMicro, date('P', $intNow));
}
}

View File

@@ -0,0 +1,398 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\PlanTaskModel;
class VideoMetadataMissingTaskPoolStatusHelper
{
public static function buildSummary(string $strWorkbenchRoot, string $strTaskPoolRoot): array
{
$arrWorkbench = VideoMetadataMissingWorkbenchHelper::readLatestSummary($strWorkbenchRoot);
$arrTaskPool = VideoMetadataMissingTaskPoolHelper::readLatestSummary($strTaskPoolRoot);
$arrBatchSummary = self::buildBatchSummary((array)($arrTaskPool['batches'] ?? []));
$arrPlanTask = self::buildPlanTaskStatus();
return [
'generated_at' => date(DATE_ATOM),
'workbench' => [
'exists' => !empty($arrWorkbench),
'generated_at' => (string)($arrWorkbench['generated_at'] ?? ''),
'videos_with_any_missing_metadata' => (int)(($arrWorkbench['audit'] ?? [])['videos_with_any_missing_metadata'] ?? 0),
'queue_size' => (int)(($arrWorkbench['queue'] ?? [])['queue_size'] ?? 0),
'summary_html_path' => (string)($arrWorkbench['summary_html_path'] ?? ''),
'summary_json_path' => (string)($arrWorkbench['summary_json_path'] ?? ''),
],
'task_pool' => [
'exists' => !empty($arrTaskPool),
'generated_at' => (string)($arrTaskPool['generated_at'] ?? ''),
'batch_count' => count((array)($arrTaskPool['batches'] ?? [])),
'todo_summary_count' => count((array)($arrTaskPool['todo_summary'] ?? [])),
'summary_html_path' => (string)($arrTaskPool['summary_html_path'] ?? ''),
'summary_json_path' => (string)($arrTaskPool['summary_json_path'] ?? ''),
'batch_status_summary' => $arrBatchSummary,
],
'plan_task' => $arrPlanTask,
'operator_hint' => self::buildOperatorHint($arrWorkbench, $arrTaskPool, $arrPlanTask),
];
}
public static function writeArtifacts(string $strTaskPoolRoot, array $arrSummary): array
{
$strTaskPoolRoot = rtrim(str_replace('\\', '/', $strTaskPoolRoot), '/');
$strStatusRoot = $strTaskPoolRoot . '/status';
$strOpsRoot = $strTaskPoolRoot . '/ops';
$arrSummary['summary_json_path'] = $strStatusRoot . '/index.json';
$arrSummary['summary_html_path'] = $strStatusRoot . '/index.html';
$arrSummary['ops_json_path'] = $strOpsRoot . '/index.json';
$arrSummary['ops_html_path'] = $strOpsRoot . '/index.html';
self::writeFile(
$arrSummary['summary_json_path'],
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
self::writeFile($arrSummary['summary_html_path'], self::renderHtml($arrSummary));
self::writeFile(
$arrSummary['ops_json_path'],
json_encode(self::buildOpsSummary($arrSummary), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
self::writeFile($arrSummary['ops_html_path'], self::renderOpsHtml($arrSummary));
return $arrSummary;
}
protected static function buildBatchSummary(array $arrBatches): array
{
$arrCounts = [
'pending' => 0,
'dispatched' => 0,
'processing' => 0,
'done' => 0,
'skipped' => 0,
];
foreach ($arrBatches as $arrBatch) {
$strStatus = (string)($arrBatch['status'] ?? VideoMetadataMissingTaskPoolStateHelper::STATUS_PENDING);
if (!array_key_exists($strStatus, $arrCounts)) {
$arrCounts[$strStatus] = 0;
}
$arrCounts[$strStatus]++;
}
return $arrCounts;
}
protected static function buildPlanTaskStatus(): array
{
$strTaskCode = 'REFRESH_VIDEO_METADATA_TASK_POOL';
$strDbError = '';
try {
$PlanTaskModel = PlanTaskModel::where('pt_code', $strTaskCode)->find();
} catch (\Throwable $Throwable) {
$PlanTaskModel = null;
$strDbError = $Throwable->getMessage();
}
return [
'task_code' => $strTaskCode,
'db_ready' => $strDbError === '',
'db_error' => $strDbError,
'task_exists' => $PlanTaskModel instanceof PlanTaskModel,
'pt_id' => (int)($PlanTaskModel->pt_id ?? 0),
'pt_name' => (string)($PlanTaskModel->pt_name ?? '视频缺字段任务池刷新'),
'pt_enable' => (int)($PlanTaskModel->pt_enable ?? 0),
'pt_limit' => (int)($PlanTaskModel->pt_limit ?? 0),
'pt_last_exec' => !empty($PlanTaskModel->pt_last_exec) ? date(DATE_ATOM, (int)$PlanTaskModel->pt_last_exec) : '',
'status_label' => $strDbError !== ''
? 'db_blocked'
: (($PlanTaskModel instanceof PlanTaskModel)
? ((int)($PlanTaskModel->pt_enable ?? 0) === 1 ? 'enabled' : 'disabled')
: 'missing'),
];
}
protected static function buildOperatorHint(array $arrWorkbench, array $arrTaskPool, array $arrPlanTask): array
{
$arrHint = [];
if (empty($arrWorkbench)) {
$arrHint[] = '当前还没有工作台产物,建议先手动执行 video:metadata:task-pool 生成一轮。';
}
if (!empty($arrWorkbench) && empty($arrTaskPool)) {
$arrHint[] = '当前已有工作台,但还没有任务池产物,建议再执行一次 task-pool 刷新命令。';
}
if (($arrPlanTask['status_label'] ?? '') === 'missing') {
$arrHint[] = '如果要接入数据库计划任务,请新增 pt_code=REFRESH_VIDEO_METADATA_TASK_POOL。';
}
if (($arrPlanTask['status_label'] ?? '') === 'disabled') {
$arrHint[] = '当前数据库计划任务已存在但未启用,启用前建议先保持较大执行间隔。';
}
if (($arrPlanTask['status_label'] ?? '') === 'enabled') {
$arrHint[] = '当前数据库计划任务已启用;这条任务只刷新产物,不自动补事实字段。';
}
if (($arrPlanTask['status_label'] ?? '') === 'db_blocked') {
$arrHint[] = '当前脚本环境拿不到数据库任务表配置,因此这里只能返回任务池现状;如果在正式后台环境调用,一般会恢复数据库计划任务状态识别。';
}
if (empty($arrHint)) {
$arrHint[] = '当前工作台、任务池、计划任务状态都已具备,下一步可以做后台入口卡片或操作面板。';
}
return $arrHint;
}
protected static function renderHtml(array $arrSummary): string
{
$arrWorkbench = (array)($arrSummary['workbench'] ?? []);
$arrTaskPool = (array)($arrSummary['task_pool'] ?? []);
$arrBatchStatus = (array)($arrTaskPool['batch_status_summary'] ?? []);
$arrPlanTask = (array)($arrSummary['plan_task'] ?? []);
$arrHints = (array)($arrSummary['operator_hint'] ?? []);
$strHintRows = '';
foreach ($arrHints as $strHint) {
$strHintRows .= '<li>' . htmlspecialchars((string)$strHint, ENT_QUOTES, 'UTF-8') . '</li>';
}
$strBatchRows = '';
foreach ($arrBatchStatus as $strStatus => $intCount) {
$strBatchRows .= '<tr>'
. '<td>' . htmlspecialchars((string)$strStatus, ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . (int)$intCount . '</td>'
. '</tr>';
}
return '<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>视频缺字段任务池状态总览</title>
<style>
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f8fb;color:#1f2937;margin:0;padding:24px;}
.wrap{max-width:1180px;margin:0 auto;}
.card{background:#fff;border-radius:16px;box-shadow:0 8px 24px rgba(15,23,42,.06);padding:20px;margin-bottom:20px;}
.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;}
.stat{background:#f8fafc;border:1px solid #e5e7eb;border-radius:12px;padding:16px;}
.stat .num{font-size:28px;font-weight:700;margin-top:6px;}
table{width:100%;border-collapse:collapse;}
th,td{border-bottom:1px solid #e5e7eb;padding:10px;text-align:left;vertical-align:top;font-size:14px;}
th{background:#f8fafc;}
ul{margin:0;padding-left:20px;}
.path{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:13px;color:#475569;word-break:break-all;}
@media (max-width: 960px){.grid{grid-template-columns:1fr 1fr;}}
@media (max-width: 640px){.grid{grid-template-columns:1fr;} body{padding:14px;}}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>视频缺字段任务池状态总览</h1>
<p>生成时间:' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>
<div class="grid">
<div class="stat"><div>缺字段视频数</div><div class="num">' . (int)($arrWorkbench['videos_with_any_missing_metadata'] ?? 0) . '</div></div>
<div class="stat"><div>重采优先队列</div><div class="num">' . (int)($arrWorkbench['queue_size'] ?? 0) . '</div></div>
<div class="stat"><div>任务池批次数</div><div class="num">' . (int)($arrTaskPool['batch_count'] ?? 0) . '</div></div>
<div class="stat"><div>计划任务状态</div><div class="num">' . htmlspecialchars((string)($arrPlanTask['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</div></div>
</div>
</div>
<div class="card">
<h2>工作台 / 任务池产物</h2>
<table>
<thead><tr><th>模块</th><th>生成时间</th><th>HTML</th><th>JSON</th></tr></thead>
<tbody>
<tr>
<td>工作台</td>
<td>' . htmlspecialchars((string)($arrWorkbench['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
<td class="path">' . htmlspecialchars((string)($arrWorkbench['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
<td class="path">' . htmlspecialchars((string)($arrWorkbench['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
</tr>
<tr>
<td>任务池</td>
<td>' . htmlspecialchars((string)($arrTaskPool['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
<td class="path">' . htmlspecialchars((string)($arrTaskPool['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
<td class="path">' . htmlspecialchars((string)($arrTaskPool['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>批次状态统计</h2>
<table>
<thead><tr><th>状态</th><th>数量</th></tr></thead>
<tbody>' . $strBatchRows . '</tbody>
</table>
</div>
<div class="card">
<h2>计划任务状态</h2>
<table>
<thead><tr><th>字段</th><th>值</th></tr></thead>
<tbody>
<tr><td>task_code</td><td>' . htmlspecialchars((string)($arrPlanTask['task_code'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td></tr>
<tr><td>status_label</td><td>' . htmlspecialchars((string)($arrPlanTask['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td></tr>
<tr><td>pt_enable</td><td>' . (int)($arrPlanTask['pt_enable'] ?? 0) . '</td></tr>
<tr><td>pt_limit</td><td>' . (int)($arrPlanTask['pt_limit'] ?? 0) . '</td></tr>
<tr><td>pt_last_exec</td><td>' . htmlspecialchars((string)($arrPlanTask['pt_last_exec'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td></tr>
<tr><td>db_error</td><td>' . htmlspecialchars((string)($arrPlanTask['db_error'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h2>操作提示</h2>
<ul>' . $strHintRows . '</ul>
</div>
</div>
</body>
</html>';
}
public static function buildOpsSummary(array $arrSummary): array
{
$arrPlanTask = (array)($arrSummary['plan_task'] ?? []);
$arrWorkbench = (array)($arrSummary['workbench'] ?? []);
$arrTaskPool = (array)($arrSummary['task_pool'] ?? []);
return [
'generated_at' => (string)($arrSummary['generated_at'] ?? ''),
'commands' => [
[
'label' => '刷新工作台 + 任务池',
'command' => 'php SEONexus/code/think video:metadata:task-pool --sample=8 --queue-limit=12 --prompt-limit=6 --batch-size=10 --batch-limit=4',
],
[
'label' => '补齐计划任务种子',
'command' => 'php SEONexus/code/think plan:seed:video-metadata-missing',
],
[
'label' => '查看任务池状态接口',
'command' => 'GET /admin/video/metadata/missing/task-pool/status',
],
[
'label' => '查看任务池运维接口',
'command' => 'GET /admin/video/metadata/missing/task-pool/ops',
],
[
'label' => '保存任务池计划任务状态',
'command' => 'POST /admin/video/metadata/missing/task-pool/plan-task/status/save',
],
],
'plan_task' => $arrPlanTask,
'workbench' => $arrWorkbench,
'task_pool' => $arrTaskPool,
'recommended_action' => (($arrPlanTask['status_label'] ?? '') === 'disabled')
? '先在后台把 REFRESH_VIDEO_METADATA_TASK_POOL 打开,建议 pt_limit 保持 86400。'
: '当前计划任务已具备基础状态,继续保持刷新即可。',
];
}
protected static function renderOpsHtml(array $arrSummary): string
{
$arrOps = self::buildOpsSummary($arrSummary);
$arrCommands = (array)($arrOps['commands'] ?? []);
$arrPlanTask = (array)($arrOps['plan_task'] ?? []);
$arrWorkbench = (array)($arrOps['workbench'] ?? []);
$arrTaskPool = (array)($arrOps['task_pool'] ?? []);
$strCommandRows = '';
foreach ($arrCommands as $arrCommand) {
$strCommandRows .= '<tr><td>'
. htmlspecialchars((string)($arrCommand['label'] ?? ''), ENT_QUOTES, 'UTF-8')
. '</td><td><code>'
. htmlspecialchars((string)($arrCommand['command'] ?? ''), ENT_QUOTES, 'UTF-8')
. '</code></td></tr>';
}
return '<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>视频缺字段任务池运维页</title>
<style>
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f8fb;color:#1f2937;margin:0;padding:24px;}
.wrap{max-width:1180px;margin:0 auto;}
.card{background:#fff;border-radius:16px;box-shadow:0 8px 24px rgba(15,23,42,.06);padding:20px;margin-bottom:20px;}
.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;}
.stat{background:#f8fafc;border:1px solid #e5e7eb;border-radius:12px;padding:16px;}
.stat .num{font-size:28px;font-weight:700;margin-top:6px;}
table{width:100%;border-collapse:collapse;}
th,td{border-bottom:1px solid #e5e7eb;padding:10px;text-align:left;vertical-align:top;font-size:14px;}
th{background:#f8fafc;}
code{white-space:pre-wrap;word-break:break-all;}
.hint{background:#f8fafc;border-left:4px solid #2563eb;padding:12px 14px;border-radius:8px;}
.path{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:13px;color:#475569;word-break:break-all;}
@media (max-width: 960px){.grid{grid-template-columns:1fr 1fr;}}
@media (max-width: 640px){.grid{grid-template-columns:1fr;} body{padding:14px;}}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>视频缺字段任务池运维页</h1>
<p>生成时间:' . htmlspecialchars((string)($arrOps['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>
<div class="grid">
<div class="stat"><div>缺字段视频数</div><div class="num">' . (int)($arrWorkbench['videos_with_any_missing_metadata'] ?? 0) . '</div></div>
<div class="stat"><div>任务池批次数</div><div class="num">' . (int)($arrTaskPool['batch_count'] ?? 0) . '</div></div>
<div class="stat"><div>计划任务状态</div><div class="num">' . htmlspecialchars((string)($arrPlanTask['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</div></div>
<div class="stat"><div>建议间隔</div><div class="num">' . (int)($arrPlanTask['pt_limit'] ?? 0) . '</div></div>
</div>
</div>
<div class="card">
<h2>推荐动作</h2>
<div class="hint">' . htmlspecialchars((string)($arrOps['recommended_action'] ?? ''), ENT_QUOTES, 'UTF-8') . '</div>
</div>
<div class="card">
<h2>常用命令</h2>
<table>
<thead><tr><th>用途</th><th>命令</th></tr></thead>
<tbody>' . $strCommandRows . '</tbody>
</table>
</div>
<div class="card">
<h2>当前产物</h2>
<table>
<thead><tr><th>模块</th><th>HTML</th><th>JSON</th></tr></thead>
<tbody>
<tr>
<td>工作台</td>
<td class="path">' . htmlspecialchars((string)($arrWorkbench['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
<td class="path">' . htmlspecialchars((string)($arrWorkbench['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
</tr>
<tr>
<td>任务池</td>
<td class="path">' . htmlspecialchars((string)($arrTaskPool['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
<td class="path">' . htmlspecialchars((string)($arrTaskPool['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>';
}
protected static function writeFile(string $strPath, string $strContent): void
{
$strDir = dirname($strPath);
if (!is_dir($strDir)) {
mkdir($strDir, 0777, true);
}
file_put_contents($strPath, $strContent);
}
}

View File

@@ -0,0 +1,329 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\VideoModel;
class VideoMetadataMissingWorkbenchHelper
{
public static function buildSummary(
int $intSampleLimit = 20,
int $intQueueLimit = 100,
int $intPromptLimit = 20
): array {
$VideoModel = VideoModel::getInstance();
$arrAudit = $VideoModel->buildMissingMetadataAuditSummary($intSampleLimit);
$intQueueScanLimit = max(500, min(5000, $intQueueLimit * 20));
$arrQueue = $VideoModel->buildRecrawlPriorityQueueSummary($intQueueLimit, $intQueueScanLimit);
$arrFieldStats = (array)($arrAudit['field_stats'] ?? []);
usort($arrFieldStats, static function (array $arrA, array $arrB): int {
return (int)($arrB['missing_count'] ?? 0) <=> (int)($arrA['missing_count'] ?? 0);
});
$arrTopFields = array_values(array_slice($arrFieldStats, 0, 5));
$arrPromptItems = array_values(array_slice((array)($arrQueue['items'] ?? []), 0, max(1, min($intPromptLimit, 100))));
$arrSummary = [
'generated_at' => date('c'),
'sample_limit' => $intSampleLimit,
'queue_limit' => $intQueueLimit,
'queue_scan_limit' => $intQueueScanLimit,
'prompt_limit' => $intPromptLimit,
'audit' => $arrAudit,
'queue' => $arrQueue,
'top_missing_fields' => $arrTopFields,
'next_actions' => self::buildNextActions($arrAudit, $arrQueue, $arrTopFields),
'codex_dispatch_prompt' => self::buildCodexDispatchPrompt($arrPromptItems, $arrTopFields),
'operator_notes' => self::buildOperatorNotes(),
];
return $arrSummary;
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim(str_replace('\\', '/', $strOutputRoot), '/');
$arrSummary['summary_json_path'] = $strOutputRoot . '/index.json';
$arrSummary['summary_html_path'] = $strOutputRoot . '/index.html';
$arrSummary['prompt_markdown_path'] = $strOutputRoot . '/prompts/latest.md';
$arrSummary['run_id'] = self::buildRunId();
self::writeFile(
$strOutputRoot . '/index.json',
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
self::writeFile($strOutputRoot . '/index.html', self::renderHtml($arrSummary));
self::writeFile($strOutputRoot . '/prompts/latest.md', (string)($arrSummary['codex_dispatch_prompt'] ?? ''));
self::writeRunArtifacts($strOutputRoot, $arrSummary);
return $arrSummary;
}
public static function readLatestSummary(string $strOutputRoot): array
{
$strPath = rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/index.json';
if (!is_file($strPath)) {
return [];
}
$arrSummary = json_decode((string)file_get_contents($strPath), true);
return is_array($arrSummary) ? $arrSummary : [];
}
protected static function buildNextActions(array $arrAudit, array $arrQueue, array $arrTopFields): array
{
$intAnyMissing = (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0);
$intQueueSize = (int)($arrQueue['queue_size'] ?? 0);
$strTopField = (string)(($arrTopFields[0] ?? [])['field'] ?? '');
$arrActions = [];
$arrActions[] = [
'key' => 'workbench_refresh',
'label' => '先刷新缺失字段工作台',
'summary' => '把当前视频库缺失字段、优先重采队列、Codex 派单提示词一起固化成后台可读产物。',
];
if ($intQueueSize > 0) {
$arrActions[] = [
'key' => 'recrawl_priority',
'label' => '优先处理演员 / 导演缺失高优先视频',
'summary' => '当前重采优先队列 ' . $intQueueSize . ' 条,优先解决结构化字段空值,避免详情页长期资料残缺。',
];
}
if ($intAnyMissing > 0) {
$arrActions[] = [
'key' => 'codex_dispatch',
'label' => '把缺失池派给 Codex 做人工接手优化',
'summary' => '当前仍有 ' . $intAnyMissing . ' 条视频存在缺字段,建议技术从后台复制提示词,分批派给 Codex 跟进。',
];
}
if ($strTopField !== '') {
$arrActions[] = [
'key' => 'focus_field',
'label' => '本轮重点字段:' . $strTopField,
'summary' => '当前缺失量最大的字段先处理,收敛速度最快,也最容易改善详情页完整度。',
];
}
return $arrActions;
}
protected static function buildOperatorNotes(): array
{
return [
[
'title' => '这套工作台是干什么的',
'content' => '它负责把“视频库哪些字段是空的、哪些视频优先补、给 Codex 的派单提示词”集中整理出来,方便技术和运营协同处理。',
],
[
'title' => '当前不会自动做什么',
'content' => '不会因为检测到空字段,就自动调用 AI API 去猜演员、导演、年份、地区等结构化资料;这类字段仍然优先重采或人工确认。',
],
[
'title' => '适合 Codex 接手的场景',
'content' => '当后台已经把缺失视频列表和重点字段列出来后,技术可以复制提示词给 Codex让 Codex按批次分析、整理、制定补料策略避免运营直接面对复杂技术字段。',
],
[
'title' => '适合后台 AI 的场景',
'content' => '仅适合在事实基础足够时补简介、备注、摘要类文案字段,不适合直接猜演员、导演、上映时间、地区、语言等结构化事实字段。',
],
];
}
protected static function buildCodexDispatchPrompt(array $arrPromptItems, array $arrTopFields): string
{
$arrLines = [];
$arrLines[] = '# 视频缺失字段 Codex 接手提示词';
$arrLines[] = '';
$arrLines[] = '你现在接手的是“视频元数据缺失字段处理”任务。';
$arrLines[] = '目标:优先提升详情页资料完整度,减少演员、导演、年份、地区、语言、发布时间、简介、备注为空的情况。';
$arrLines[] = '';
$arrLines[] = '执行原则:';
$arrLines[] = '1. 结构化字段如演员、导演、年份、地区、语言、发布时间,不允许编造。';
$arrLines[] = '2. 简介、备注等文案字段,只能在现有事实边界内补全,不允许虚构剧情和角色关系。';
$arrLines[] = '3. 优先处理高优先级、点击高、播放源多、近期更新的视频。';
$arrLines[] = '4. 输出时请先给出分批处理建议,再给出每批次的风险说明。';
$arrLines[] = '';
$arrLines[] = '当前缺失最严重字段:';
foreach ($arrTopFields as $arrField) {
$arrLines[] = sprintf(
'- %s缺失 %d 条,占比 %.2f%%',
(string)($arrField['field'] ?? ''),
(int)($arrField['missing_count'] ?? 0),
((float)($arrField['missing_ratio'] ?? 0)) * 100
);
}
$arrLines[] = '';
$arrLines[] = '本轮优先样本:';
foreach ($arrPromptItems as $arrItem) {
$arrLines[] = sprintf(
'- v_id=%d | %s | 分类=%s | 缺失=%s | 原因=%s',
(int)($arrItem['v_id'] ?? 0),
trim((string)($arrItem['v_name'] ?? '')),
trim((string)($arrItem['v_category'] ?? '')),
implode(',', (array)($arrItem['missing_fields'] ?? [])),
trim((string)($arrItem['priority_reason'] ?? ''))
);
}
$arrLines[] = '';
$arrLines[] = '请输出:';
$arrLines[] = '1. 本轮处理优先级和分批方案';
$arrLines[] = '2. 哪些字段适合重采,哪些字段适合人工补充,哪些字段适合仅补文案';
$arrLines[] = '3. 如果需要后续命令或脚本,请直接给出可执行建议';
$arrLines[] = '';
return implode(PHP_EOL, $arrLines) . PHP_EOL;
}
protected static function renderHtml(array $arrSummary): string
{
$arrAudit = (array)($arrSummary['audit'] ?? []);
$arrQueue = (array)($arrSummary['queue'] ?? []);
$arrTopFields = (array)($arrSummary['top_missing_fields'] ?? []);
$arrActions = (array)($arrSummary['next_actions'] ?? []);
$arrItems = array_values(array_slice((array)($arrQueue['items'] ?? []), 0, 20));
$strPrompt = htmlspecialchars((string)($arrSummary['codex_dispatch_prompt'] ?? ''), ENT_QUOTES, 'UTF-8');
$strTopRows = '';
foreach ($arrTopFields as $arrField) {
$strTopRows .= '<tr>'
. '<td>' . htmlspecialchars((string)($arrField['field'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . (int)($arrField['missing_count'] ?? 0) . '</td>'
. '<td>' . number_format(((float)($arrField['missing_ratio'] ?? 0)) * 100, 2) . '%</td>'
. '</tr>';
}
$strActionRows = '';
foreach ($arrActions as $arrAction) {
$strActionRows .= '<li><strong>'
. htmlspecialchars((string)($arrAction['label'] ?? ''), ENT_QUOTES, 'UTF-8')
. '</strong>'
. htmlspecialchars((string)($arrAction['summary'] ?? ''), ENT_QUOTES, 'UTF-8')
. '</li>';
}
$strItemRows = '';
foreach ($arrItems as $arrItem) {
$strItemRows .= '<tr>'
. '<td>' . (int)($arrItem['v_id'] ?? 0) . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['v_name'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['v_category'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars(implode(', ', (array)($arrItem['missing_fields'] ?? [])), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . (int)($arrItem['priority_score'] ?? 0) . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['priority_reason'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '</tr>';
}
return '<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>视频缺失字段工作台</title>
<style>
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f8fb;color:#1f2937;margin:0;padding:24px;}
.wrap{max-width:1280px;margin:0 auto;}
.card{background:#fff;border-radius:16px;box-shadow:0 8px 24px rgba(15,23,42,.06);padding:20px;margin-bottom:20px;}
h1,h2{margin:0 0 12px;}
.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;}
.stat{background:#f8fafc;border:1px solid #e5e7eb;border-radius:12px;padding:16px;}
.stat .num{font-size:28px;font-weight:700;margin-top:6px;}
table{width:100%;border-collapse:collapse;}
th,td{border-bottom:1px solid #e5e7eb;padding:10px;text-align:left;vertical-align:top;font-size:14px;}
th{background:#f8fafc;}
pre{white-space:pre-wrap;word-break:break-word;background:#0f172a;color:#e2e8f0;padding:16px;border-radius:12px;overflow:auto;}
ul{margin:0;padding-left:20px;}
@media (max-width: 960px){.grid{grid-template-columns:1fr 1fr;}}
@media (max-width: 640px){.grid{grid-template-columns:1fr;} body{padding:14px;}}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>视频缺失字段工作台</h1>
<p>生成时间:' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>
<div class="grid">
<div class="stat"><div>总视频数</div><div class="num">' . (int)($arrAudit['total_videos'] ?? 0) . '</div></div>
<div class="stat"><div>存在缺字段视频</div><div class="num">' . (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0) . '</div></div>
<div class="stat"><div>重采优先队列</div><div class="num">' . (int)($arrQueue['queue_size'] ?? 0) . '</div></div>
<div class="stat"><div>Codex 派单样本</div><div class="num">' . (int)($arrSummary['prompt_limit'] ?? 0) . '</div></div>
</div>
</div>
<div class="card">
<h2>下一步动作</h2>
<ul>' . $strActionRows . '</ul>
</div>
<div class="card">
<h2>缺失最多字段 Top</h2>
<table>
<thead><tr><th>字段</th><th>缺失数</th><th>缺失占比</th></tr></thead>
<tbody>' . $strTopRows . '</tbody>
</table>
</div>
<div class="card">
<h2>高优先级视频样本</h2>
<table>
<thead><tr><th>v_id</th><th>片名</th><th>分类</th><th>缺失字段</th><th>优先分</th><th>优先原因</th></tr></thead>
<tbody>' . $strItemRows . '</tbody>
</table>
</div>
<div class="card">
<h2>Codex 派单提示词</h2>
<pre>' . $strPrompt . '</pre>
</div>
</div>
</body>
</html>';
}
protected static function writeFile(string $strPath, string $strContent): void
{
$strDir = dirname($strPath);
if (!is_dir($strDir)) {
@mkdir($strDir, 0777, true);
}
file_put_contents($strPath, $strContent);
}
protected static function writeRunArtifacts(string $strOutputRoot, array $arrSummary): void
{
$strRunId = trim((string)($arrSummary['run_id'] ?? ''));
if ($strRunId === '') {
return;
}
$strDay = substr((string)($arrSummary['generated_at'] ?? date('c')), 0, 10);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $strDay)) {
$strDay = date('Y-m-d');
}
$strRunRoot = $strOutputRoot . '/runs/' . $strDay . '/' . $strRunId;
self::writeFile(
$strRunRoot . '/video-metadata-missing-workbench.summary.json',
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
self::writeFile(
$strRunRoot . '/video-metadata-missing-workbench.summary.html',
self::renderHtml($arrSummary)
);
self::writeFile(
$strRunRoot . '/prompt.md',
(string)($arrSummary['codex_dispatch_prompt'] ?? '')
);
}
protected static function buildRunId(): string
{
return 'video-metadata-missing-workbench-' . date('Ymd-His');
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class VideoMetadataMissingWorkbenchIndexHelper
{
public static function buildSummary(string $strRunRoot, int $intLimit = 20): array
{
$strRunRoot = rtrim(str_replace('\\', '/', $strRunRoot), '/');
if ($strRunRoot === '' || !is_dir($strRunRoot)) {
return [
'items' => [],
'total' => 0,
'latest_run' => [],
'health_buckets' => [],
];
}
$arrFiles = array_merge(
(array)glob($strRunRoot . '/*/*/video-metadata-missing-workbench.summary.json')
);
$arrFiles = array_values(array_filter(array_unique($arrFiles), 'is_file'));
usort($arrFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
$arrItems = [];
foreach (array_slice($arrFiles, 0, max(1, $intLimit)) as $strSummaryPath) {
$arrData = json_decode((string)file_get_contents($strSummaryPath), true);
if (!is_array($arrData)) {
continue;
}
$arrAudit = (array)($arrData['audit'] ?? []);
$arrQueue = (array)($arrData['queue'] ?? []);
$arrTopFields = (array)($arrData['top_missing_fields'] ?? []);
$intAnyMissing = (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0);
$intTotal = (int)($arrAudit['total_videos'] ?? 0);
$floatRatio = $intTotal > 0 ? round($intAnyMissing / $intTotal, 4) : 0.0;
[$strHealthLabel, $strHealthReason] = self::resolveHealth($floatRatio, $arrTopFields);
$strRunDir = dirname($strSummaryPath);
$arrItems[] = [
'run_id' => basename($strRunDir),
'generated_at' => (string)($arrData['generated_at'] ?? date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time()))),
'videos_with_any_missing_metadata' => $intAnyMissing,
'total_videos' => $intTotal,
'missing_ratio' => $floatRatio,
'queue_size' => (int)($arrQueue['queue_size'] ?? 0),
'top_field' => (string)(($arrTopFields[0] ?? [])['field'] ?? ''),
'top_field_missing_count' => (int)(($arrTopFields[0] ?? [])['missing_count'] ?? 0),
'health_label' => $strHealthLabel,
'health_reason' => $strHealthReason,
'summary_json_path' => self::storageRelativePath($strSummaryPath),
'summary_html_path' => self::storageRelativePath($strRunDir . '/video-metadata-missing-workbench.summary.html'),
'prompt_markdown_path' => self::storageRelativePath($strRunDir . '/prompt.md'),
];
}
$arrHealthBuckets = [];
foreach ($arrItems as $arrItem) {
$strLabel = (string)($arrItem['health_label'] ?? 'unknown');
$arrHealthBuckets[$strLabel] = (int)($arrHealthBuckets[$strLabel] ?? 0) + 1;
}
return [
'items' => $arrItems,
'total' => count($arrItems),
'latest_run' => $arrItems[0] ?? [],
'health_buckets' => $arrHealthBuckets,
];
}
protected static function resolveHealth(float $floatRatio, array $arrTopFields): array
{
$strTopField = (string)(($arrTopFields[0] ?? [])['field'] ?? '');
if ($floatRatio >= 0.30) {
return ['high_attention', '当前整体缺字段占比仍高,且结构化字段缺失量较大。重点先收敛演员、导演。'];
}
if ($strTopField === 'v_actor' || $strTopField === 'v_director') {
return ['structure_focus', '当前主要仍是演员 / 导演缺失,适合优先走重采和人工核验链。'];
}
return ['steady', '当前缺字段问题相对可控,适合按优先队列持续收敛。'];
}
protected static function storageRelativePath(string $strPath): string
{
$strStorageRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/storage', '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strStorageRoot . '/')) {
return substr($strPath, strlen($strStorageRoot . '/'));
}
return $strPath;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\home;
use app\common\helper\DomainSeoNamingHelper;
use app\model\ConverterMovel;
use app\model\GanRaoMaModel;
use app\model\CategoryModel;
@@ -75,8 +76,16 @@ abstract class BaseController
*/
private function initSiteTKD()
{
$strSiteName = DomainSeoNamingHelper::normalizeSiteName(
(string)($this->request->DomainModel->d_name ?? ''),
(string)($this->request->DomainModel->d_domain ?? '')
);
if ($strSiteName !== '' && isset($this->request->DomainModel)) {
$this->request->DomainModel->d_name = $strSiteName;
}
ConverterMovel::setVal([
'strSiteName' => $this->request->DomainModel->d_name,
'strSiteName' => $strSiteName,
'strSiteDomain' => $this->request->DomainModel->d_domain,
'strSiteKeywords' => $this->request->DomainModel->d_keywords,
'strSiteDescription' => $this->request->DomainModel->d_description,

View File

@@ -22,35 +22,60 @@ $strTmpCode = $siteContext->getTemplate();
if ($strTmpCode == 'videoGpt1') {
// if (env('IS_GPT_TMP', false)) {
$routeGetHead = static function (string $route, $handler) {
return Route::rule($route, $handler, 'GET|HEAD');
};
// ---------- Common public routes (keep compatible) ----------
Route::get('/robots', function (\think\Request $Request, SiteContext $SiteContext) {
$routeGetHead('/robots', function (\think\Request $Request, SiteContext $SiteContext) {
return view('sitemap/robots.txt');
})->ext('txt');
Route::get('/sitemap', function (\think\Request $Request, SiteContext $SiteContext) {
$routeGetHead('/sitemap', function (\think\Request $Request, SiteContext $SiteContext) {
return view('video/getMap.html');
})->ext('html');
Route::get('rss/baidu', function () {
$routeGetHead('/sitemap.xml', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('INDEX');
});
$routeGetHead('rss/baidu', function () {
return view('rss/baidu.xml')->contentType('text/xml');
})->ext('xml');
Route::get('rss/so', function () {
return view('rss/so.xml')->contentType('text/xml');
$routeGetHead('rss/so', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSoSitemapResponse();
})->ext('xml');
Route::get('/sitemap_index', function (\think\Request $Request, SiteContext $SiteContext) {
$routeGetHead('/sitemap_index', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('INDEX');
})->ext('xml');
Route::get('/sitemap-main', function (\think\Request $Request, SiteContext $SiteContext) {
$routeGetHead('/sitemap_index.xml', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('INDEX');
});
$routeGetHead('/sitemap-main', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('MAIN');
})->ext('xml');
Route::get('/sitemap-videos-:page', function (\think\Request $Request, SiteContext $SiteContext) {
$routeGetHead('/sitemap-main.xml', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('MAIN');
});
$routeGetHead('/sitemap-videos-:page', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('VIDEO');
})->ext('xml');
$routeGetHead('/sitemap-videos-:page.xml', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('VIDEO');
});
// 榜单首页历史深链优先硬绑定,避免被通用 rank_list 模式误吞到列表页模板。
$routeGetHead('/phb-index', function () {
return view('video/getRankIndex.html');
});
// ========== 1⃣ 取当前域名冻结的 family ==========
$tpStyle = SiteStyle::getConfig();
$family = $tpStyle['template_cfg']['url_family'];
@@ -62,19 +87,34 @@ if ($strTmpCode == 'videoGpt1') {
string $route,
string $view,
array $pattern = []
) use (&$registered) {
) use (&$registered, $routeGetHead) {
if (isset($registered[$route])) {
return;
}
$r = Route::get($route, fn() => view($view));
$r = $routeGetHead($route, fn() => view($view));
if ($pattern) {
$r->pattern($pattern);
}
$registered[$route] = true;
// 兼容带 .html 的冻结路径。
// ThinkPHP 在旧路由里普遍使用 "/path" + ->ext('html')
// 而当前 family 里有一部分站点会直接冻结成 "/get.html"、"/record.html"。
// 这里同时补一个无后缀别名,避免这类路径在 GPT 新路由分支下落不到视图。
if (str_ends_with($route, '.html')) {
$routeWithoutExt = substr($route, 0, -5);
if ($routeWithoutExt !== '' && !isset($registered[$routeWithoutExt])) {
$alias = $routeGetHead($routeWithoutExt, fn() => view($view))->ext('html');
if ($pattern) {
$alias->pattern($pattern);
}
$registered[$routeWithoutExt] = true;
}
}
};
// ========== 2.5️⃣ 冻结前按 sort 排序 ==========
@@ -126,7 +166,7 @@ if ($strTmpCode == 'videoGpt1') {
break;
case 'category_home':
$register($route, 'video/getCategory.html');
$register($route, 'video/getMap.html');
break;
case 'category_parent':
$register($route, 'video/getCategoryType.html',[
@@ -143,7 +183,7 @@ if ($strTmpCode == 'videoGpt1') {
case 'detail':
$register($route, 'video/getVideoInfo.html', [
'intVId' => '\d+',
'intVId' => '\d*',
'strPinyin' => '[\w-]+'
]);
break;
@@ -167,7 +207,7 @@ if ($strTmpCode == 'videoGpt1') {
$register($route, 'video/getRankIndex.html');
break;
case 'rank_list':
$register($route, 'video/getRankIndex.html',[
$register($route, 'video/getRankList.html',[
'strParentCategory' => '[a-z\-]*', // 允许空值
'strCategory' => '[a-z\-]*', // 允许空值
'strSortType' => '[a-z\-]*', // 允许空值
@@ -185,6 +225,156 @@ if ($strTmpCode == 'videoGpt1') {
}
}
}
// ========== 4⃣ 历史深页兼容路由 ==========
// videoGpt1 主输出仍然只走当前 family
// 这里只补“旧 detail / play 深链入口”,避免历史外链、旧 sitemap、
// 百度已抓取的旧 family URL 在路由层直接 404。
$legacyCompatRoutes = [
'rank_index' => [
'/paihang/index',
'/rank/index',
'/top/index',
'/phb/index',
'/phb-index',
// 历史榜单聚合入口兼容。
// 旧站点上曾出现过这类“label”别名入口当前 GPT 新路由
// 没有注册,访问会直接落到 404。这里统一回榜单首页模板。
'/label/new.html',
'/label/new',
],
'detail_forge' => [
'/vodinfo/:strPinyin-:intVId-:intVForgeId',
'/vod/:strPinyin-:intVId-:intVForgeId',
'/video-info/:strPinyin-:intVId-:intVForgeId',
'/video-detail/:strPinyin-:intVId-:intVForgeId',
'/video/:strPinyin-:intVId-:intVForgeId',
'/shipin/:strPinyin-:intVId-:intVForgeId',
'/shipin-xiangqing/:strPinyin-:intVId-:intVForgeId',
'/shipin-neiron/:strPinyin-:intVId-:intVForgeId',
'/voddetail/-:intVId-:intVForgeId',
'/vodinfo/-:intVId-:intVForgeId',
'/vod/-:intVId-:intVForgeId',
'/video-info/-:intVId-:intVForgeId',
'/video-detail/-:intVId-:intVForgeId',
'/video/-:intVId-:intVForgeId',
'/shipin/-:intVId-:intVForgeId',
'/shipin-xiangqing/-:intVId-:intVForgeId',
'/shipin-neiron/-:intVId-:intVForgeId',
'/voddetail/:intVId-:intVForgeId',
'/vodinfo/:intVId-:intVForgeId',
'/vod/:intVId-:intVForgeId',
'/video-info/:intVId-:intVForgeId',
'/video-detail/:intVId-:intVForgeId',
'/video/:intVId-:intVForgeId',
'/shipin/:intVId-:intVForgeId',
'/shipin-xiangqing/:intVId-:intVForgeId',
'/shipin-neiron/:intVId-:intVForgeId',
],
'detail' => [
'/detail/:strPinyin',
'/detail/pinyin-:strPinyin',
'/detail/:strPinyin/:intVForgeId',
'/voddetail/:strPinyin-:intVId',
'/vodinfo/:strPinyin-:intVId',
'/vod/:strPinyin-:intVId',
'/video-info/:strPinyin-:intVId',
'/video-detail/:strPinyin-:intVId',
'/video/:strPinyin-:intVId',
'/shipin/:strPinyin-:intVId',
'/shipin-xiangqing/:strPinyin-:intVId',
'/shipin-neiron/:strPinyin-:intVId',
'/voddetail/-:intVId',
'/vodinfo/-:intVId',
'/vod/-:intVId',
'/video-info/-:intVId',
'/video-detail/-:intVId',
'/video/-:intVId',
'/shipin/-:intVId',
'/shipin-xiangqing/-:intVId',
'/shipin-neiron/-:intVId',
'/voddetail/:intVId',
'/vodinfo/:intVId',
'/vod/:intVId',
'/video-info/:intVId',
'/video-detail/:intVId',
'/video/:intVId',
'/shipin/:intVId',
'/shipin-xiangqing/:intVId',
'/shipin-neiron/:intVId',
],
'play' => [
'/vodplay/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/vodbf/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/vodseed/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/video-play/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/video-bofang/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/video-show/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/shipin-play/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/shipin-bofang/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/shipin-kan/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/vodplay/-:intVId-:strPlayType-:intPlayIndex',
'/vodbf/-:intVId-:strPlayType-:intPlayIndex',
'/vodseed/-:intVId-:strPlayType-:intPlayIndex',
'/video-play/-:intVId-:strPlayType-:intPlayIndex',
'/video-bofang/-:intVId-:strPlayType-:intPlayIndex',
'/video-show/-:intVId-:strPlayType-:intPlayIndex',
'/shipin-play/-:intVId-:strPlayType-:intPlayIndex',
'/shipin-bofang/-:intVId-:strPlayType-:intPlayIndex',
'/shipin-kan/-:intVId-:strPlayType-:intPlayIndex',
'/vodplay/:intVId-:strPlayType-:intPlayIndex',
'/vodbf/:intVId-:strPlayType-:intPlayIndex',
'/vodseed/:intVId-:strPlayType-:intPlayIndex',
'/video-play/:intVId-:strPlayType-:intPlayIndex',
'/video-bofang/:intVId-:strPlayType-:intPlayIndex',
'/video-show/:intVId-:strPlayType-:intPlayIndex',
'/shipin-play/:intVId-:strPlayType-:intPlayIndex',
'/shipin-bofang/:intVId-:strPlayType-:intPlayIndex',
'/shipin-kan/:intVId-:strPlayType-:intPlayIndex',
],
];
foreach ($legacyCompatRoutes as $page => $routes) {
foreach ($routes as $route) {
switch ($page) {
case 'rank_index':
$register($route, 'video/getRankIndex.html');
break;
case 'detail':
$register($route, 'video/getVideoInfo.html', [
'intVId' => '\d*',
'strPinyin' => '[\w-]+',
'intVForgeId' => '\d*',
]);
break;
case 'detail_forge':
$register($route, 'video/getVideoInfo.html', [
'intVId' => '\d+',
'strPinyin' => '[\w-]+',
'intVForgeId' => '\d+',
]);
break;
case 'play':
$register($route, 'video/getVideoPlayUrl.html', [
'intVId' => '\d+',
'strPinyin' => '[\w-]+',
'strPlayType' => '[\w-]+',
'intPlayIndex' => '\d+',
]);
break;
}
}
}
return;
} else {
@@ -208,6 +398,10 @@ if ($strTmpCode == 'videoGpt1') {
return view('video/getMap.html');
})->ext('html');
Route::get('/sitemap.xml', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('INDEX');
});
/**
* /rss/baidu
*/
@@ -219,9 +413,8 @@ if ($strTmpCode == 'videoGpt1') {
/**
* /rss/so
*/
Route::get('rss/so', function () {
return view('rss/so.xml')
->contentType('text/xml');
Route::get('rss/so', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSoSitemapResponse();
})->ext('xml');
/**
@@ -232,6 +425,10 @@ if ($strTmpCode == 'videoGpt1') {
// return view('sitemap/sitemap_index.xml');
})->ext('xml');
Route::get('/sitemap_index.xml', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('INDEX');
});
/**
* 首页、分类列表、排行榜、男生/女生频道。
*/
@@ -240,6 +437,10 @@ if ($strTmpCode == 'videoGpt1') {
// return view('sitemap/sitemap-main.xml');
})->ext('xml');
Route::get('/sitemap-main.xml', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('MAIN');
});
/**
* 小说目录页面。
*/
@@ -279,6 +480,10 @@ if ($strTmpCode == 'videoGpt1') {
// return view('sitemap/sitemap-books.xml');
})->ext('xml');
Route::get('/sitemap-videos-:page.xml', function (\think\Request $Request, SiteContext $SiteContext) {
return $SiteContext->getSiteMapByCode('VIDEO');
});
/**
* 搜索首页
@@ -722,7 +927,7 @@ if ($strTmpCode == 'videoGpt1') {
],
//排行榜
'video_rank_index' => ['/paihang/index', '/rank/index', '/paihang-quan', '/top/index', '/paihang-bang', '/ranks/index', '/sort/index', '/order/index', '/phb/index'],
'video_rank_index' => ['/paihang/index', '/rank/index', '/paihang-quan', '/top/index', '/paihang-bang', '/ranks/index', '/sort/index', '/order/index', '/phb/index', '/phb-index'],
//排行榜
'video_rank_list' => [

View File

@@ -16,6 +16,7 @@ export_name="arrVideoPiaofang" /}
{block name="description"}{site:replace code="VIDEO@INDEX@INDEX@DESCRIPTION"}{/block}
{block name="head"}
<link rel="canonical" href="https://{$DomainModel->d_domain}" />
<!-- 社交媒体标签 -->
<meta property="og:title" content='{site:replace code="VIDEO@INDEX@INDEX@TITLE"}' />
<meta property="og:description" content='{site:replace code="VIDEO@INDEX@INDEX@DESCRIPTION"}' />
@@ -26,6 +27,7 @@ export_name="arrVideoPiaofang" /}
<script type="application/ld+json">
[
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "{$DomainModel->d_name}",
"url": "https://{$DomainModel->d_domain}",
@@ -95,10 +97,10 @@ export_name="arrVideoPiaofang" /}
{volist name="arrVideoPiaofang" id="Video" key="key"}
{if $key < 5 }
{
"@type": "ListItem{$Video.v_id}",
"@type": "ListItem",
"position": {$key},
"item": {
"@type": "Movies",
"@type": "Movie",
"name": "{$Video.v_name}",
"url": 'https://{$DomainModel->d_domain}{site:vurl v_id="$Video.v_id" v_py="$Video.v_name_en"/}',
"description": "{$Video.v_description}",
@@ -208,4 +210,4 @@ export_name="arrVideoPiaofang" /}
{block name="customize"}
{/block}
{/block}

View File

@@ -1,35 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.{$DomainModel->d_domain}/</loc>
<loc>https://{$DomainModel->d_domain}/</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://www.{$DomainModel->d_domain}{$strRankUrlTemp}</loc>
<loc>https://{$DomainModel->d_domain}{$strRankUrlTemp}</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<!-- 分类 -->
{novel:category d_key="strCategoryPinyin" d_val="strCategoryName"}
<!-- 排序 -->
{novel:sort d_key="sort_key" d_val="strSortName"}
<!-- 状态 -->
{novel:status d_key="status_key" d_val="strStatusName"}
<url>
<loc>https://www.{$DomainModel->d_domain}{site:nflurl category="$strCategoryPinyin" order="$sort_key" status="$status_key" /}</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
{/novel:status}
{/novel:sort}
{/novel:category}
<!-- 注意 所有链接不要分页,有分页的取第一页即可 -->
</urlset>
</urlset>

View File

@@ -1,39 +1,7 @@
{novel:pagerexp page="1" limit="40000"
sort_type="news"
d_key="d_key"
d_val="Novel"
cache_life="3600" func="generateCategoryPager" export_name="resData" /}
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-main.xml</loc>
<loc>https://{$DomainModel->d_domain}/sitemap-main.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
<!--小说总数量/50000 得出 sitemap-books 一共有多少个 -->
{for start="0" end="$resData.p_data.pages"}
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-books-{$i+1}.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
{/for}
<!-- 小说总数量/50000 得出 sitemap-books-catalog一共有多少个,每个小说只取一个目录链接,分页不需要加入去 -->
{for start="0" end="$resData.p_data.pages"}
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-books-catalog-{$i+1}.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
{/for}
<!-- 小说章节取最新章节 -->
{for start="0" end="$resData.p_data.pages"}
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-chapters-{$i+1}.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
{/for}
</sitemapindex>
</sitemapindex>

View File

@@ -13,10 +13,12 @@
{block name="description"}{site:replace code="VIDEO@GETCATEGORYINDEX@DESCRIPTION"}{/block}
{block name="head"}
<meta name="robots" content="index,follow">
<link rel="canonical" href='https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}'>
<!-- 社交媒体标签 -->
<meta property="og:title" content='{site:replace code="VIDEO@GETCATEGORYINDEX@TITLE"}' />
<meta property="og:description" content='{site:replace code="VIDEO@GETCATEGORYINDEX@DESCRIPTION"}' />
<meta property="og:url" content="https://{$DomainModel->d_domain}" />
<meta property="og:url" content='https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}' />
{switch $Request.route.strParentCategory }
{case 'dian-ying' }
<meta property="og:type" content="video.movie" />
@@ -45,32 +47,13 @@
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": "{$DomainModel->d_name} - 最新{site:getval code="strVideoParentCategoryName" /}推荐",
"url": "https://{$DomainModel->d_domain}",
"url": "https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}",
"description": "{site:replace code="VIDEO@GETCATEGORYINDEX@DESCRIPTION"}",
"potentialAction": {
"@type": "SearchAction",
"target": 'https://{$DomainModel->d_domain}{site:vsurl key="search_term_string" p="1"/}",
"query-input": "required name=search_term_string"
"isPartOf": {
"@type": "WebSite",
"name": "{$DomainModel->d_name}",
"url": "https://{$DomainModel->d_domain}"
}
"hasPart": [
{video:ranklist count="5"
v_parent_category_en="$Request.route.strParentCategory"
sort_type="weekly"
d_key="key" d_val="Video" cache_life="3600"}
{if $key < 5 }
{
"@type": "VideoObject",
"name": "{$Video.v_name}",
"description": "{$Video.v_description}",
"thumbnailUrl": '{$Video.v_pic}',
"uploadDate": "{:date('Y-m-d')}",
"url": 'https://{$DomainModel->d_domain}{site:vurl v_id="$Video.v_id" v_py="$Video.v_name_en"/}',
},
{/if}
{/video:ranklist}
]
},
{
"@context": "https://schema.org",
@@ -89,7 +72,7 @@
"item": "https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}"
}
]
},
}
]
</script>
@@ -192,4 +175,4 @@
{/block}
{block name="customize"}
{/block}
{/block}

View File

@@ -210,7 +210,7 @@ href='https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$ar
</div>
<p class="data">
<span class="text-muted">分类:</span>
<a href='{site:vsurl key="$arrVideo.v_parent_category" p="1"/}'>{$arrVideo.v_parent_category}</a>
<a href='{site:vciurl parent_category="$arrVideo.v_parent_category_en" /}'>{$arrVideo.v_parent_category}</a>
<span class="split-line"></span>
<span class="text-muted hidden-xs">地区:</span>
{volist name='$arrVideo.v_area' id='vo' key='index'}
@@ -481,4 +481,4 @@ href='https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$ar
</script>
{/block}
{block name="customize"} {/block}
{block name="customize"} {/block}

View File

@@ -44,7 +44,7 @@
<meta property="og:type" content="video.movie" />
{/switch}
<meta property="og:url"
content='https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en"/}'>
content='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>
<meta property="og:image" content="{$arrVideo.v_pic}">
<meta property="og:site_name" content="{$DomainModel->d_name}">
<meta property="og:video:type" content="video/m3u8">
@@ -69,7 +69,7 @@ href='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$a
"description": "{$arrVideo.v_description}",
"thumbnailUrl": "{$arrVideo.v_pic}",
"uploadDate": "{:date('Y-m-d')}",
"url": 'https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en"/}',
"url": 'https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}',
"inLanguage": [
{volist name='$arrVideo.v_lang' id='strVideoLang' key='index'}
@@ -521,4 +521,4 @@ href='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$a
defer></script> -->
{/block}
{/block}

View File

@@ -8,6 +8,7 @@
{block name="keywords"}{site:replace code="VIDEO@INDEX@INDEX@KEYWORDS"}{/block}
{block name="description"}{site:replace code="VIDEO@INDEX@INDEX@DESCRIPTION"}{/block}
{block name="head"}
<link rel="canonical" href="https://{$DomainModel->d_domain}" />
{// 社交媒体标签 }
<meta property="og:title" content='{site:replace code="VIDEO@INDEX@INDEX@TITLE"}' />
<meta property="og:description" content='{site:replace code="VIDEO@INDEX@INDEX@DESCRIPTION"}' />
@@ -17,6 +18,7 @@
<script type="application/ld+json">
[
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "{$DomainModel->d_name}",
"url": "https://{$DomainModel->d_domain}",
@@ -84,10 +86,10 @@
{volist name="arrVideoPiaofang" id="Video" key="key"}
{if $key < 5 }
{
"@type": "ListItem{$Video.v_id}",
"@type": "ListItem",
"position": {$key},
"item": {
"@type": "Movies",
"@type": "Movie",
"name": "{$Video.v_name}",
"url": 'https://{$DomainModel->d_domain}{site:vurl v_id="$Video.v_id" v_py="$Video.v_name_en"/}',
"description": "{$Video.v_description}",
@@ -185,4 +187,4 @@
</div>
{/block}
{block name="customize"}
{/block}
{/block}

View File

@@ -1,35 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.{$DomainModel->d_domain}/</loc>
<loc>https://{$DomainModel->d_domain}/</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://www.{$DomainModel->d_domain}{$strRankUrlTemp}</loc>
<loc>https://{$DomainModel->d_domain}{$strRankUrlTemp}</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<!-- 分类 -->
{novel:category d_key="strCategoryPinyin" d_val="strCategoryName"}
<!-- 排序 -->
{novel:sort d_key="sort_key" d_val="strSortName"}
<!-- 状态 -->
{novel:status d_key="status_key" d_val="strStatusName"}
<url>
<loc>https://www.{$DomainModel->d_domain}{site:nflurl category="$strCategoryPinyin" order="$sort_key" status="$status_key" /}</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
{/novel:status}
{/novel:sort}
{/novel:category}
<!-- 注意 所有链接不要分页,有分页的取第一页即可 -->
</urlset>
</urlset>

View File

@@ -1,39 +1,7 @@
{novel:pagerexp page="1" limit="40000"
sort_type="news"
d_key="d_key"
d_val="Novel"
cache_life="3600" func="generateCategoryPager" export_name="resData" /}
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-main.xml</loc>
<loc>https://{$DomainModel->d_domain}/sitemap-main.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
<!--小说总数量/50000 得出 sitemap-books 一共有多少个 -->
{for start="0" end="$resData.p_data.pages"}
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-books-{$i+1}.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
{/for}
<!-- 小说总数量/50000 得出 sitemap-books-catalog一共有多少个,每个小说只取一个目录链接,分页不需要加入去 -->
{for start="0" end="$resData.p_data.pages"}
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-books-catalog-{$i+1}.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
{/for}
<!-- 小说章节取最新章节 -->
{for start="0" end="$resData.p_data.pages"}
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-chapters-{$i+1}.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
{/for}
</sitemapindex>
</sitemapindex>

View File

@@ -13,10 +13,12 @@
{block name="description"}{site:replace code="VIDEO@GETCATEGORYINDEX@DESCRIPTION"}{/block}
{block name="head"}
<meta name="robots" content="index,follow">
<link rel="canonical" href='https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}'>
<!-- 社交媒体标签 -->
<meta property="og:title" content='{site:replace code="VIDEO@GETCATEGORYINDEX@TITLE"}' />
<meta property="og:description" content='{site:replace code="VIDEO@GETCATEGORYINDEX@DESCRIPTION"}' />
<meta property="og:url" content="https://{$DomainModel->d_domain}" />
<meta property="og:url" content='https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}' />
{switch $Request.route.strParentCategory }
{case 'dian-ying' }
<meta property="og:type" content="video.movie" />
@@ -45,32 +47,13 @@
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": "{$DomainModel->d_name} - 最新{site:getval code="strVideoParentCategoryName" /}推荐",
"url": "https://{$DomainModel->d_domain}",
"url": "https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}",
"description": "{site:replace code="VIDEO@GETCATEGORYINDEX@DESCRIPTION"}",
"potentialAction": {
"@type": "SearchAction",
"target": 'https://{$DomainModel->d_domain}{site:vsurl key="search_term_string" p="1"/}",
"query-input": "required name=search_term_string"
"isPartOf": {
"@type": "WebSite",
"name": "{$DomainModel->d_name}",
"url": "https://{$DomainModel->d_domain}"
}
"hasPart": [
{video:ranklist count="5"
v_parent_category_en="$Request.route.strParentCategory"
sort_type="weekly"
d_key="key" d_val="Video" cache_life="3600"}
{if $key < 5 }
{
"@type": "VideoObject",
"name": "{$Video.v_name}",
"description": "{$Video.v_description}",
"thumbnailUrl": '{$Video.v_pic}',
"uploadDate": "{:date('Y-m-d')}",
"url": 'https://{$DomainModel->d_domain}{site:vurl v_id="$Video.v_id" v_py="$Video.v_name_en"/}',
},
{/if}
{/video:ranklist}
]
},
{
"@context": "https://schema.org",
@@ -89,7 +72,7 @@
"item": "https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}"
}
]
},
}
]
</script>
@@ -218,4 +201,4 @@
{/block}
{block name="customize"}
{/block}
{/block}

View File

@@ -181,7 +181,7 @@ href='https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$ar
</h2>
<p class="data">
<span class="text-muted">类型:</span>
<a href='{site:vsurl key="$arrVideo.v_category" p="1"/}'>{$arrVideo.v_category}</a>
<a href='{site:vclurl parent_category="$arrVideo.v_parent_category_en" category="$arrVideo.v_category_en" area="all" lang="all" year="all" order="all" page="1"/}'>{$arrVideo.v_category}</a>
&nbsp;
<span class="split-line"></span>
<span class="text-muted hidden-xs">地区:</span>
@@ -416,4 +416,4 @@ href='https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$ar
</script>
{/block}
{block name="customize"} {/block}
{block name="customize"} {/block}

View File

@@ -43,7 +43,7 @@
{default /}
<meta property="og:type" content="video.movie" />
{/switch}
<meta property="og:url" content='https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en"/}'>
<meta property="og:url" content='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>
<meta property="og:image" content="{$arrVideo.v_pic}">
<meta property="og:site_name" content="{$DomainModel->d_name}">
<meta property="og:video:type" content="video/m3u8">
@@ -68,7 +68,7 @@ href='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$a
"description": "{$arrVideo.v_description}",
"thumbnailUrl": "{$arrVideo.v_pic}",
"uploadDate": "{:date('Y-m-d')}",
"url": 'https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en"/}',
"url": 'https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}',
"inLanguage": [
{volist name='$arrVideo.v_lang' id='strVideoLang' key='index'}
@@ -205,7 +205,7 @@ href='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$a
</h1>
<p class="data margin-0">
<span class="text-muted">类型:</span>
<a href='{site:vsurl key="$arrVideo.v_category" p="1"/}'>{$arrVideo.v_category}</a>
<a href='{site:vclurl parent_category="$arrVideo.v_parent_category_en" category="$arrVideo.v_category_en" area="all" lang="all" year="all" order="all" page="1"/}'>{$arrVideo.v_category}</a>
&nbsp;
<span class="split-line"></span>
<span class="text-muted">地区:</span>
@@ -422,4 +422,4 @@ href='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$a
<script type="text/javascript" src="/public/video/DPlayer.min.js" defer></script>
{/block}
{/block}

View File

@@ -10,6 +10,7 @@ export_name="arrVideoPiaofang" /}
{block name="keywords"}{site:replace code="VIDEO@INDEX@INDEX@KEYWORDS"}{/block}
{block name="description"}{site:replace code="VIDEO@INDEX@INDEX@DESCRIPTION"}{/block}
{block name="head"}
<link rel="canonical" href="https://{$DomainModel->d_domain}" />
<!-- 社交媒体标签 -->
<meta property="og:title" content='{site:replace code="VIDEO@INDEX@INDEX@TITLE"}' />
<meta property="og:description" content='{site:replace code="VIDEO@INDEX@INDEX@DESCRIPTION"}' />
@@ -19,6 +20,7 @@ export_name="arrVideoPiaofang" /}
<script type="application/ld+json">
[
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "{$DomainModel->d_name}",
"url": "https://{$DomainModel->d_domain}",
@@ -85,10 +87,10 @@ export_name="arrVideoPiaofang" /}
{volist name="arrVideoPiaofang" id="Video" key="key"}
{if $key < 5 }
{
"@type": "ListItem{$Video.v_id}",
"@type": "ListItem",
"position": {$key},
"item": {
"@type": "Movies",
"@type": "Movie",
"name": "{$Video.v_name}",
"url": 'https://{$DomainModel->d_domain}{site:vurl v_id="$Video.v_id" v_py="$Video.v_name_en"/}',
"description": "{$Video.v_description}",
@@ -208,4 +210,4 @@ d_key="key" d_val="Video" cache_life="3600"}
</div>
{/block}
{block name="customize"}
{/block}
{/block}

View File

@@ -1,35 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.{$DomainModel->d_domain}/</loc>
<loc>https://{$DomainModel->d_domain}/</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://www.{$DomainModel->d_domain}{$strRankUrlTemp}</loc>
<loc>https://{$DomainModel->d_domain}{$strRankUrlTemp}</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<!-- 分类 -->
{novel:category d_key="strCategoryPinyin" d_val="strCategoryName"}
<!-- 排序 -->
{novel:sort d_key="sort_key" d_val="strSortName"}
<!-- 状态 -->
{novel:status d_key="status_key" d_val="strStatusName"}
<url>
<loc>https://www.{$DomainModel->d_domain}{site:nflurl category="$strCategoryPinyin" order="$sort_key" status="$status_key" /}</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
{/novel:status}
{/novel:sort}
{/novel:category}
<!-- 注意 所有链接不要分页,有分页的取第一页即可 -->
</urlset>
</urlset>

View File

@@ -1,39 +1,7 @@
{novel:pagerexp page="1" limit="40000"
sort_type="news"
d_key="d_key"
d_val="Novel"
cache_life="3600" func="generateCategoryPager" export_name="resData" /}
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-main.xml</loc>
<loc>https://{$DomainModel->d_domain}/sitemap-main.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
<!--小说总数量/50000 得出 sitemap-books 一共有多少个 -->
{for start="0" end="$resData.p_data.pages"}
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-books-{$i+1}.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
{/for}
<!-- 小说总数量/50000 得出 sitemap-books-catalog一共有多少个,每个小说只取一个目录链接,分页不需要加入去 -->
{for start="0" end="$resData.p_data.pages"}
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-books-catalog-{$i+1}.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
{/for}
<!-- 小说章节取最新章节 -->
{for start="0" end="$resData.p_data.pages"}
<sitemap>
<loc>https://www.{$DomainModel->d_domain}/sitemap-chapters-{$i+1}.xml</loc>
<lastmod>{:date('Y-m-d')}</lastmod>
</sitemap>
{/for}
</sitemapindex>
</sitemapindex>

View File

@@ -13,10 +13,12 @@
{block name="description"}{site:replace code="VIDEO@GETCATEGORYINDEX@DESCRIPTION"}{/block}
{block name="head"}
<meta name="robots" content="index,follow">
<link rel="canonical" href='https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}'>
<!-- 社交媒体标签 -->
<meta property="og:title" content='{site:replace code="VIDEO@GETCATEGORYINDEX@TITLE"}' />
<meta property="og:description" content='{site:replace code="VIDEO@GETCATEGORYINDEX@DESCRIPTION"}' />
<meta property="og:url" content="https://{$DomainModel->d_domain}" />
<meta property="og:url" content='https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}' />
{switch $Request.route.strParentCategory }
{case 'dian-ying' }
<meta property="og:type" content="video.movie" />
@@ -45,32 +47,13 @@
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": "{$DomainModel->d_name} - 最新{site:getval code="strVideoParentCategoryName" /}推荐",
"url": "https://{$DomainModel->d_domain}",
"url": "https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}",
"description": "{site:replace code="VIDEO@GETCATEGORYINDEX@DESCRIPTION"}",
"potentialAction": {
"@type": "SearchAction",
"target": 'https://{$DomainModel->d_domain}{site:vsurl key="search_term_string" p="1"/}",
"query-input": "required name=search_term_string"
"isPartOf": {
"@type": "WebSite",
"name": "{$DomainModel->d_name}",
"url": "https://{$DomainModel->d_domain}"
}
"hasPart": [
{video:ranklist count="5"
v_parent_category_en="$Request.route.strParentCategory"
sort_type="weekly"
d_key="key" d_val="Video" cache_life="3600"}
{if $key < 5 }
{
"@type": "VideoObject",
"name": "{$Video.v_name}",
"description": "{$Video.v_description}",
"thumbnailUrl": '{$Video.v_pic}',
"uploadDate": "{:date('Y-m-d')}",
"url": 'https://{$DomainModel->d_domain}{site:vurl v_id="$Video.v_id" v_py="$Video.v_name_en"/}',
},
{/if}
{/video:ranklist}
]
},
{
"@context": "https://schema.org",
@@ -89,7 +72,7 @@
"item": "https://{$DomainModel->d_domain}{site:vciurl parent_category="$Request.route.strParentCategory" /}"
}
]
},
}
]
</script>
@@ -191,4 +174,4 @@
{/block}
{block name="customize"}
{/block}
{/block}

View File

@@ -216,11 +216,11 @@ href='https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$ar
<p class="bb52e4 data">
<span class="ed25a2 text-muted">分类:</span>
<a
href='{site:vsurl key="$arrVideo.v_parent_category" p="1"/}'>{$arrVideo.v_parent_category}</a>
href='{site:vciurl parent_category="$arrVideo.v_parent_category_en" /}'>{$arrVideo.v_parent_category}</a>
</p>
<p class="bb52e4 data">
<span class="ed25a2 text-muted">类型:</span>
<a href='{site:vsurl key="$arrVideo.v_category" p="1"/}'>{$arrVideo.v_category}</a>
<a href='{site:vclurl parent_category="$arrVideo.v_parent_category_en" category="$arrVideo.v_category_en" area="all" lang="all" year="all" order="all" page="1"/}'>{$arrVideo.v_category}</a>
</p>
<p class="d994f6 data">
<span class="d8c8c2 text-muted">地区:</span>
@@ -420,4 +420,4 @@ href='https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$ar
</script>
{/block}
{block name="customize"} {/block}
{block name="customize"} {/block}

View File

@@ -44,7 +44,7 @@
<meta property="og:type" content="video.movie" />
{/switch}
<meta property="og:url"
content='https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en"/}'>
content='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>
<meta property="og:image" content="{$arrVideo.v_pic}">
<meta property="og:site_name" content="{$DomainModel->d_name}">
<meta property="og:video:type" content="video/m3u8">
@@ -69,7 +69,7 @@ href='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$a
"description": "{$arrVideo.v_description}",
"thumbnailUrl": "{$arrVideo.v_pic}",
"uploadDate": "{:date('Y-m-d')}",
"url": 'https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en"/}',
"url": 'https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}',
"inLanguage": [
{volist name='$arrVideo.v_lang' id='strVideoLang' key='index'}
@@ -448,4 +448,4 @@ href='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$a
defer></script> -->
{/block}
{/block}

View File

@@ -1,12 +1,98 @@
{extend name="base" /}
{block name="get-data"}
{video:seocopy scene="home" export_name="seoCopy" /}
{php}
$strHomeIntroTitle = trim((string)($DomainModel->d_name ?? ''));
if ($strHomeIntroTitle === '') {
$strHomeIntroTitle = '影视内容推荐';
}
$strHomeIntroText = trim((string)($seoCopy['intro_text'] ?? $seoCopy['category_intro'] ?? ''));
if ($strHomeIntroText === '') {
$strHomeIntroText = trim((string)($seoCopy['description'] ?? ''));
}
if ($strHomeIntroText === '') {
$strHomeIntroText = $strHomeIntroTitle . '首页整理了电影、剧集、综艺、动漫等常见入口,适合先看首屏推荐,再继续按分类、榜单和搜索缩小范围。';
}
$strHomeIntroMeta = trim((string)($seoCopy['intro_meta'] ?? $seoCopy['faq_content'] ?? ''));
if ($strHomeIntroMeta === '') {
$strHomeIntroMeta = '建议先看首屏推荐,再根据题材、热度和搜索词继续深入浏览。';
}
$arrHomeCategoryNames = array_values(array_filter(array_map(static function ($arrCategory): string {
return trim((string)($arrCategory['name'] ?? ''));
}, (array)($TpStyle['template_cfg']['pages']['home']['categories'] ?? [])), static function (string $strName): bool {
return $strName !== '';
}));
$strHomeCategorySummary = implode('、', array_slice($arrHomeCategoryNames, 0, 3));
if ($strHomeCategorySummary === '') {
$strHomeCategorySummary = '电影、剧集、综艺';
}
$arrHomeSlotKeys = array_values(array_filter(array_map(static function ($arrSlot): string {
return trim((string)($arrSlot['layout_key'] ?? ''));
}, (array)($TpStyle['template_cfg']['pages']['home']['slots'] ?? [])), static function (string $strKey): bool {
return $strKey !== '';
}));
$strHomePrimarySlot = $arrHomeSlotKeys[0] ?? 'news';
$arrHomePrimarySlotLabelMap = [
'news' => '更新区',
'rank' => '榜单区',
'tuijian' => '推荐区',
'update' => '更新区',
'piaofang' => '热度区',
'lunli' => '内容区',
];
$strHomePrimarySlotLabel = $arrHomePrimarySlotLabelMap[$strHomePrimarySlot] ?? '推荐区';
$intHomeTopSlotCount = count((array)($TpStyle['template_cfg']['pages']['home']['slots'] ?? []));
$intHomeCategoryCount = count((array)($TpStyle['template_cfg']['pages']['home']['categories'] ?? []));
$arrHomeQuickSignals = [
['label' => '首屏主区', 'text' => '先看' . $strHomePrimarySlotLabel],
['label' => '频道方向', 'text' => $strHomeCategorySummary],
['label' => '内容覆盖', 'text' => '当前约有' . (1 + $intHomeTopSlotCount + $intHomeCategoryCount) . '组入口区块'],
['label' => '浏览节奏', 'text' => '首屏吸引 -> 详情判断 -> 播放选择'],
];
$arrHomeGuideCards = array_values(array_filter((array)($seoCopy['guide_cards'] ?? []), static function ($arrCard): bool {
return is_array($arrCard)
&& (
trim((string)($arrCard['title'] ?? '')) !== ''
|| trim((string)($arrCard['text'] ?? '')) !== ''
);
}));
if (empty($arrHomeGuideCards)) {
$arrHomeGuideCards = [
[
'title' => '首屏入口',
'text' => '首页第一屏更适合先看' . $strHomePrimarySlotLabel . '和刚刚更新,快速判断当前最值得点开的内容。',
],
[
'title' => '内容范围',
'text' => '当前首页已经把' . $strHomeCategorySummary . '等入口串起来,适合横向切换不同内容类型。',
],
[
'title' => '搜索建议',
'text' => '如果你已经知道片名、演员或题材词,直接用搜索通常会比翻列表更快。',
],
[
'title' => '后续路径',
'text' => '找到感兴趣的内容后,建议先看详情页确认题材和线路,再决定是否直接进入播放页。',
],
];
}
{/php}
{/block}
{block name="title"}{site:replace code="VIDEO@INDEX@INDEX@TITLE"}{/block}
{block name="keywords"}{site:replace code="VIDEO@INDEX@INDEX@KEYWORDS"}{/block}
{block name="description"}{site:replace code="VIDEO@INDEX@INDEX@DESCRIPTION"}{/block}
{block name="title"}{site:seotkd code="title" page="home" /}{/block}
{block name="keywords"}{site:seotkd code="keywords" page="home" /}{/block}
{block name="description"}{site:seotkd code="description" page="home" /}{/block}
{block name="head"}
<meta name="robots" content="index,follow">
@@ -14,8 +100,8 @@
{// 社交媒体标签 }
<meta property="og:title" content='{site:replace code="VIDEO@INDEX@INDEX@TITLE"}' />
<meta property="og:description" content='{site:replace code="VIDEO@INDEX@INDEX@DESCRIPTION"}' />
<meta property="og:title" content='{site:seotkd code="title" page="home" /}' />
<meta property="og:description" content='{site:seotkd code="description" page="home" /}' />
<meta property="og:url" content="https://{$DomainModel->d_domain}" />
<meta property="og:type" content="website" />
{// 结构化数据 }
@@ -26,7 +112,7 @@
"name": "{$DomainModel->d_name}",
"url": "https://{$DomainModel->d_domain}",
"alternateName": "{$DomainModel->d_domain}",
"description": "{site:replace code="VIDEO@INDEX@INDEX@DESCRIPTION"}",
"description": "{site:seotkd code='description' page='home' /}",
"potentialAction": {
"@type": "SearchAction",
"target": 'https://{$DomainModel->d_domain}{site:vsurl key="search_term_string" p="1"/}',
@@ -42,6 +128,25 @@
{block name="main"}
<main class="page-home" style="max-width:{$TpStyle.template_cfg.global.page_max_width_pc}px;margin:0 auto;">
<section class="{$TpStyle.dom_prefix}-home-intro" style="margin:0 0 18px;padding:20px 22px;border:1px solid var(--border-color);border-radius:24px;background:linear-gradient(135deg,rgba(255,255,255,.98),var(--bg-soft-color));box-shadow:0 12px 28px rgba(0,0,0,.04);">
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:10px;">
<span style="display:inline-flex;width:28px;height:3px;border-radius:999px;background:linear-gradient(90deg,var(--color-primary),var(--color-secondary));"></span>
<span style="display:inline-flex;align-items:center;padding:4px 10px;border-radius:999px;background:rgba(0,0,0,.04);color:var(--text-muted-color);font-size:12px;line-height:1.5;">首页导览</span>
</div>
<h1 style="margin:0 0 10px;color:var(--text-color);font-size:24px;line-height:1.35;">{$strHomeIntroTitle}</h1>
<p style="margin:0 0 10px;color:var(--text-color);font-size:15px;line-height:1.9;">{$strHomeIntroText}</p>
<p style="margin:0;color:var(--text-muted-color);font-size:13px;line-height:1.8;">{$strHomeIntroMeta}</p>
</section>
<section class="{$TpStyle.dom_prefix}-home-quick-signals" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin:0 0 18px;">
{foreach $arrHomeQuickSignals as $arrSignal}
<article style="padding:12px 14px;border-radius:16px;background:rgba(255,255,255,.92);border:1px solid rgba(0,0,0,.06);box-shadow:0 8px 18px rgba(0,0,0,.03);">
<strong style="display:block;margin:0 0 6px;color:var(--text-color);font-size:12px;line-height:1.5;">{$arrSignal.label}</strong>
<span style="display:block;color:var(--text-muted-color);font-size:13px;line-height:1.7;">{$arrSignal.text}</span>
</article>
{/foreach}
</section>
{// lunli }
{assign name="Slot" value="$TpStyle.template_cfg.pages.home.lunliSlots[0]"}
{assign name="title" value="$Slot.title_text"}
@@ -74,6 +179,8 @@
{/foreach}
{include file="module/seo_copy/collection" /}
{// 分类}
{foreach $TpStyle.template_cfg.pages.home.categories as $HomeCategory}
@@ -105,4 +212,3 @@
</main>
{/block}

View File

@@ -1,4 +1,10 @@
<article class="{$TpStyle.dom_prefix}-d1-wrap">
{php}
$strDetailPlaySlug = trim((string)($arrVideo['v_name_en'] ?? ''));
if ($strDetailPlaySlug === '') {
$strDetailPlaySlug = (string)($arrVideo['v_id'] ?? '');
}
{/php}
{// 顶部基本信息 }
<header class="{$TpStyle.dom_prefix}-d1-hd">
@@ -42,14 +48,17 @@
<?php $keyLine = 0; ?>
{volist name='$arrPlayUrlType' id='arrPlayUrl'}
<?php $keyLine++ ;?>
{php}
$strEpisodePlayUrl = app(\app\services\VideoService::class)->getVideoPlayUrl(
(int)($arrVideo['v_id'] ?? 0),
$strDetailPlaySlug,
(string)$strPlayGroupName,
(int)$keyLine
);
{/php}
<li>
<a class="{if $keyLine == $Request.route.intPlayIndex}active{/if}"
href='{site:vpurl
v_id="$arrVideo.v_id"
v_py="$arrVideo.v_name_en"
play_type="$strPlayGroupName"
play_index="$keyLine"
/}'
href="{$strEpisodePlayUrl}"
m3u8="{$arrPlayUrl.url}">
{$arrPlayUrl.name}
</a>

View File

@@ -1,4 +1,10 @@
<main class="{$TpStyle.dom_prefix}-d2-page">
{php}
$strDetailPlaySlug = trim((string)($arrVideo['v_name_en'] ?? ''));
if ($strDetailPlaySlug === '') {
$strDetailPlaySlug = (string)($arrVideo['v_id'] ?? '');
}
{/php}
{// 顶部大横图 }
<section class="{$TpStyle.dom_prefix}-d2-hero">
@@ -45,8 +51,16 @@
<?php $keyLine = 0; ?>
{volist name='$arrPlayUrlType' id='arrPlayUrl'}
<?php $keyLine++ ;?>
{php}
$strEpisodePlayUrl = app(\app\services\VideoService::class)->getVideoPlayUrl(
(int)($arrVideo['v_id'] ?? 0),
$strDetailPlaySlug,
(string)$strPlayGroupName,
(int)$keyLine
);
{/php}
<li>
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="$strPlayGroupName" play_index="$keyLine" /}'
<a href="{$strEpisodePlayUrl}"
class="{if $keyLine == $Request.route.intPlayIndex}active{/if}"
m3u8="{$arrPlayUrl.url}">
{$arrPlayUrl.name}

View File

@@ -1,4 +1,10 @@
<article class="{$TpStyle.dom_prefix}-d3-page">
{php}
$strDetailPlaySlug = trim((string)($arrVideo['v_name_en'] ?? ''));
if ($strDetailPlaySlug === '') {
$strDetailPlaySlug = (string)($arrVideo['v_id'] ?? '');
}
{/php}
<section class="{$TpStyle.dom_prefix}-d3-top">
<div class="{$TpStyle.dom_prefix}-d3-picbox">
@@ -32,12 +38,20 @@
<?php $keyLineGroup = 0; ?>
{foreach $arrVideo.v_play_url as $strPlayGroupName=>$arrPlayUrlType }
<?php $keyLineGroup++ ;?>
<div class="{$TpStyle.dom_prefix}-d3-epbox {eq name='$keyLineGroup' value='1'}active{/eq}"
<div class="{$TpStyle.dom_prefix}-d3-epbox {eq name='$keyLineGroup' value='1'}active{/eq}"
id="d3_box_{$strPlayGroupName}">
<?php $keyLine = 0; ?>
{volist name='$arrPlayUrlType' id='arrPlayUrl'}
<?php $keyLine++ ;?>
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="$strPlayGroupName" play_index="$keyLine" /}'
{php}
$strEpisodePlayUrl = app(\app\services\VideoService::class)->getVideoPlayUrl(
(int)($arrVideo['v_id'] ?? 0),
$strDetailPlaySlug,
(string)$strPlayGroupName,
(int)$keyLine
);
{/php}
<a href="{$strEpisodePlayUrl}"
class="{$TpStyle.dom_prefix}-d3-epitem {if $keyLine == $Request.route.intPlayIndex}active{/if}"
m3u8="{$arrPlayUrl.url}">
{$arrPlayUrl.name}

View File

@@ -1,4 +1,10 @@
<section class="{$TpStyle.dom_prefix}-d4-page">
{php}
$strDetailPlaySlug = trim((string)($arrVideo['v_name_en'] ?? ''));
if ($strDetailPlaySlug === '') {
$strDetailPlaySlug = (string)($arrVideo['v_id'] ?? '');
}
{/php}
<header class="{$TpStyle.dom_prefix}-d4-head">
<h1>{$arrVideo.v_name}</h1>
@@ -42,8 +48,16 @@
<?php $keyLine = 0; ?>
{volist name='$arrPlayUrlType' id='arrPlayUrl'}
<?php $keyLine++ ;?>
{php}
$strEpisodePlayUrl = app(\app\services\VideoService::class)->getVideoPlayUrl(
(int)($arrVideo['v_id'] ?? 0),
$strDetailPlaySlug,
(string)$strPlayGroupName,
(int)$keyLine
);
{/php}
<li>
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="$strPlayGroupName" play_index="$keyLine" /}'
<a href="{$strEpisodePlayUrl}"
class="{if $keyLine == $Request.route.intPlayIndex}active{/if}"
m3u8="{$arrPlayUrl.url}">
{$arrPlayUrl.name}

View File

@@ -1,4 +1,10 @@
<article class="{$TpStyle.dom_prefix}-d5-page">
{php}
$strDetailPlaySlug = trim((string)($arrVideo['v_name_en'] ?? ''));
if ($strDetailPlaySlug === '') {
$strDetailPlaySlug = (string)($arrVideo['v_id'] ?? '');
}
{/php}
<header class="{$TpStyle.dom_prefix}-d5-head">
<h1>{$arrVideo.v_name}</h1>
@@ -35,7 +41,15 @@
<?php $keyLine = 0; ?>
{volist name='$arrPlayUrlType' id='arrPlayUrl'}
<?php $keyLine++ ;?>
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="$strPlayGroupName" play_index="$keyLine" /}'
{php}
$strEpisodePlayUrl = app(\app\services\VideoService::class)->getVideoPlayUrl(
(int)($arrVideo['v_id'] ?? 0),
$strDetailPlaySlug,
(string)$strPlayGroupName,
(int)$keyLine
);
{/php}
<a href="{$strEpisodePlayUrl}"
class="{$TpStyle.dom_prefix}-d5-epbtn {if $keyLine == $Request.route.intPlayIndex}active{/if}"
m3u8="{$arrPlayUrl.url}">
{$arrPlayUrl.name}

View File

@@ -1,111 +1,123 @@
{// ===================== Action Variants ===================== }
{php}
$strDetailActionSlug = trim((string)($arrVideo['v_name_en'] ?? request()->route('strPinyin') ?? ''));
if ($strDetailActionSlug === '') {
$strDetailActionSlug = (string)($arrVideo['v_id'] ?? '');
}
$strDetailDefaultPlayUrl = app(\app\services\VideoService::class)->getVideoPlayUrl(
(int)($arrVideo['v_id'] ?? 0),
$strDetailActionSlug,
'default',
1
);
{/php}
{if $variant == 0}
<div class="{$TpStyle.dom_prefix}-dm-action v0">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>立即播放</a>
<a href="{$strDetailDefaultPlayUrl}">立即播放</a>
</div>
{elseif $variant == 1}
<div class="{$TpStyle.dom_prefix}-dm-action v1">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>在线播放</a>
<a href="{$strDetailDefaultPlayUrl}">在线播放</a>
<span>无需安装</span>
</div>
{elseif $variant == 2}
<nav class="{$TpStyle.dom_prefix}-dm-action v2">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>▶ 播放</a>
<a href="{$strDetailDefaultPlayUrl}">▶ 播放</a>
<a href="#desc">剧情</a>
</nav>
{elseif $variant == 3}
<ul class="{$TpStyle.dom_prefix}-dm-action v3">
<li><a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>高清</a></li>
<li><a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>免费</a></li>
<li><a href="{$strDetailDefaultPlayUrl}">高清</a></li>
<li><a href="{$strDetailDefaultPlayUrl}">免费</a></li>
</ul>
{elseif $variant == 4}
<div class="{$TpStyle.dom_prefix}-dm-action v4">
<button onclick="location.href='{$playUrl}'">立即观看</button>
<button onclick="location.href='{$strDetailDefaultPlayUrl}'">立即观看</button>
</div>
{elseif $variant == 5}
<div class="{$TpStyle.dom_prefix}-dm-action v5">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>{$arrVideo.v_name} 在线观看</a>
<a href="{$strDetailDefaultPlayUrl}">{$arrVideo.v_name} 在线观看</a>
</div>
{elseif $variant == 6}
<footer class="{$TpStyle.dom_prefix}-dm-action v6">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>开始播放</a>
<a href="{$strDetailDefaultPlayUrl}">开始播放</a>
</footer>
{elseif $variant == 7}
<section class="{$TpStyle.dom_prefix}-dm-action v7">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>免费观看</a>
<a href="{$strDetailDefaultPlayUrl}">免费观看</a>
<small>高清资源</small>
</section>
{elseif $variant == 8}
<div class="{$TpStyle.dom_prefix}-dm-action v8">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>播放正片</a>
<a href="{$strDetailDefaultPlayUrl}">播放正片</a>
</div>
{elseif $variant == 9}
<div class="{$TpStyle.dom_prefix}-dm-action v9">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>立即观看 {$arrVideo.v_name}</a>
<a href="{$strDetailDefaultPlayUrl}">立即观看 {$arrVideo.v_name}</a>
</div>
{elseif $variant == 10}
<div class="{$TpStyle.dom_prefix}-dm-action v10">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>在线播放 · 高清</a>
<a href="{$strDetailDefaultPlayUrl}">在线播放 · 高清</a>
</div>
{elseif $variant == 11}
<div class="{$TpStyle.dom_prefix}-dm-action v11">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>点击播放</a>
<a href="{$strDetailDefaultPlayUrl}">点击播放</a>
<span>支持多线路</span>
</div>
{elseif $variant == 12}
<nav class="{$TpStyle.dom_prefix}-dm-action v12">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>播放</a>
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>下载</a>
<a href="{$strDetailDefaultPlayUrl}">播放</a>
<a href="{$strDetailDefaultPlayUrl}">下载</a>
</nav>
{elseif $variant == 13}
<div class="{$TpStyle.dom_prefix}-dm-action v13">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'></a>
<a href="{$strDetailDefaultPlayUrl}"></a>
</div>
{elseif $variant == 14}
<div class="{$TpStyle.dom_prefix}-dm-action v14">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>{$arrVideo.v_name} 免费看</a>
<a href="{$strDetailDefaultPlayUrl}">{$arrVideo.v_name} 免费看</a>
</div>
{elseif $variant == 15}
<aside class="{$TpStyle.dom_prefix}-dm-action v15">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>立即播放</a>
<a href="{$strDetailDefaultPlayUrl}">立即播放</a>
</aside>
{elseif $variant == 16}
<div class="{$TpStyle.dom_prefix}-dm-action v16"
data-action="play">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>播放影片</a>
<a href="{$strDetailDefaultPlayUrl}">播放影片</a>
</div>
{elseif $variant == 17}
<div class="{$TpStyle.dom_prefix}-dm-action v17">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>高清播放</a>
<a href="{$strDetailDefaultPlayUrl}">高清播放</a>
<em>无需登录</em>
</div>
{elseif $variant == 18}
<section class="{$TpStyle.dom_prefix}-dm-action v18">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>在线观看</a>
<a href="{$strDetailDefaultPlayUrl}">在线观看</a>
</section>
{elseif $variant == 19}
<footer class="{$TpStyle.dom_prefix}-dm-action v19">
<a href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>马上播放</a>
<a href="{$strDetailDefaultPlayUrl}">马上播放</a>
</footer>
{/if}

View File

@@ -1,5 +1,17 @@
{// ===================== Cover Variants ===================== }
{video:imgalt video="$arrVideo" slot="detail_cover" item_type="poster" export_name="strAlt" /}
{php}
$strDetailCoverSlug = trim((string)($arrVideo['v_name_en'] ?? request()->route('strPinyin') ?? ''));
if ($strDetailCoverSlug === '') {
$strDetailCoverSlug = (string)($arrVideo['v_id'] ?? '');
}
$strDetailCoverPlayUrl = app(\app\services\VideoService::class)->getVideoPlayUrl(
(int)($arrVideo['v_id'] ?? 0),
$strDetailCoverSlug,
'default',
1
);
{/php}
{if $variant == 0}
<div class="{$TpStyle.dom_prefix}-dm-cover v0">
@@ -20,7 +32,7 @@
{elseif $variant == 2}
<a class="{$TpStyle.dom_prefix}-dm-cover v2"
href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>
href="{$strDetailCoverPlayUrl}">
<img class="{$TpStyle.dom_prefix}-dm-cover-img"
src="{site:cfg code='PUBLIC_COVER_DOMAIN' encode='false'/}{$arrVideo.v_pic}"
alt="{$strAlt}">
@@ -89,7 +101,7 @@
{elseif $variant == 11}
<a class="{$TpStyle.dom_prefix}-dm-cover v11"
href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>
href="{$strDetailCoverPlayUrl}">
<img class="{$TpStyle.dom_prefix}-dm-cover-bg" alt="{$strAlt}"
src="{site:cfg code='PUBLIC_COVER_DOMAIN' encode='false'/}{$arrVideo.v_pic}">
</a>

View File

@@ -1,80 +1,207 @@
{// ===================== Desc Variants ===================== }
{php}
$intCurrentForgeId = (int)(request()->route('intVForgeId') ?? 0);
$strSeoCopyScene = $intCurrentForgeId > 0 ? 'forge' : 'detail';
{/php}
{video:seocopy scene="$strSeoCopyScene" video="$arrVideo" export_name="seoCopy" /}
{php}
$strDetailMainDescription = trim(preg_replace('/\s+/u', ' ', strip_tags((string)($arrVideo['v_description'] ?? ''))));
if ($strDetailMainDescription === '') {
$strDetailMainDescription = '当前内容暂无完整剧情简介,建议结合详情信息与播放入口继续判断。';
}
$arrPlayGroups = is_array($arrVideo['v_play_url'] ?? null) ? $arrVideo['v_play_url'] : [];
$strPrimaryPlayType = '';
$intPrimaryPlayIndex = 1;
if (!empty($arrPlayGroups)) {
$strPrimaryPlayType = (string)array_key_first($arrPlayGroups);
}
$strDetailSlug = trim((string)($arrVideo['v_name_en'] ?? request()->route('strPinyin') ?? ''));
if ($strDetailSlug === '') {
$strDetailSlug = (string)($arrVideo['v_id'] ?? '');
}
$strPreferredPlayUrl = '';
if ((int)($arrVideo['v_id'] ?? 0) > 0 && $strPrimaryPlayType !== '') {
$strPreferredPlayUrl = app(\app\services\VideoService::class)->getVideoPlayUrl(
(int)$arrVideo['v_id'],
$strDetailSlug,
$strPrimaryPlayType,
$intPrimaryPlayIndex
);
}
$boolIsForgeDetail = $intCurrentForgeId > 0;
$strOriginVideoName = trim((string)($arrVideo['v_name'] ?? ''));
$strMainVideoInfoUrl = '';
if ((int)($arrVideo['v_id'] ?? 0) > 0) {
$strMainVideoInfoUrl = app(\app\services\VideoService::class)->getVideoInfoUrl(
(int)$arrVideo['v_id'],
$strDetailSlug
);
}
$strDetailBody = trim((string)($seoCopy['detail_faq'] ?? ''));
$strDetailBodyLead = trim((string)($seoCopy['detail_body_lead'] ?? ''));
$strDetailPlayLinkLead = trim((string)($seoCopy['detail_play_link_lead'] ?? ''));
$strDetailBodyTail = trim((string)($seoCopy['detail_body_tail'] ?? ''));
$strForgeBodyLead = trim((string)($seoCopy['forge_body_lead'] ?? ''));
$strForgeReturnLead = trim((string)($seoCopy['forge_return_lead'] ?? ''));
if ($strForgeReturnLead === '') {
$strForgeReturnLead = '如果想回到标准详情,可返回';
}
$strForgePlayLead = trim((string)($seoCopy['forge_play_lead'] ?? ''));
$strForgeDetailNote = trim((string)($seoCopy['forge_detail_note'] ?? ''));
$arrGuideCards = array_values(array_filter((array)($seoCopy['guide_cards'] ?? []), static function ($arrCard): bool {
return is_array($arrCard)
&& (
trim((string)($arrCard['title'] ?? '')) !== ''
|| trim((string)($arrCard['text'] ?? '')) !== ''
);
}));
$arrForgeEntryLinks = [];
foreach ($arrGuideCards as $arrGuideCard) {
$strGuideTitle = trim((string)($arrGuideCard['title'] ?? ''));
$strGuideText = trim((string)($arrGuideCard['text'] ?? ''));
$strGuideLine = trim($strGuideTitle . ($strGuideText !== '' ? '' . $strGuideText : ''));
if ($strGuideLine !== '') {
$arrForgeEntryLinks[] = [
'url' => $strMainVideoInfoUrl !== '' ? $strMainVideoInfoUrl : '/',
'text' => $strGuideLine,
];
}
}
{/php}
<style>
.{$TpStyle.dom_prefix}-dm-desc,
.{$TpStyle.dom_prefix}-dm-desc-body,
.{$TpStyle.dom_prefix}-dm-desc-guide,
.{$TpStyle.dom_prefix}-dm-forge-note {
min-width: 0;
word-break: break-word;
}
.{$TpStyle.dom_prefix}-dm-desc p,
.{$TpStyle.dom_prefix}-dm-desc li,
.{$TpStyle.dom_prefix}-dm-desc blockquote,
.{$TpStyle.dom_prefix}-dm-desc summary,
.{$TpStyle.dom_prefix}-dm-desc-body p,
.{$TpStyle.dom_prefix}-dm-desc-guide p,
.{$TpStyle.dom_prefix}-dm-forge-note p {
font-size: 14px;
line-height: 1.78;
}
.{$TpStyle.dom_prefix}-dm-desc header,
.{$TpStyle.dom_prefix}-dm-desc summary {
font-size: 16px;
line-height: 1.45;
}
@media (max-width: 768px) {
.{$TpStyle.dom_prefix}-dm-desc p,
.{$TpStyle.dom_prefix}-dm-desc li,
.{$TpStyle.dom_prefix}-dm-desc blockquote,
.{$TpStyle.dom_prefix}-dm-desc summary,
.{$TpStyle.dom_prefix}-dm-desc-body p,
.{$TpStyle.dom_prefix}-dm-desc-guide p,
.{$TpStyle.dom_prefix}-dm-forge-note p {
font-size: 13px !important;
line-height: 1.74 !important;
}
.{$TpStyle.dom_prefix}-dm-desc header,
.{$TpStyle.dom_prefix}-dm-desc summary {
font-size: 15px !important;
}
.{$TpStyle.dom_prefix}-dm-desc-body,
.{$TpStyle.dom_prefix}-dm-desc-guide,
.{$TpStyle.dom_prefix}-dm-forge-note {
margin-top: 8px !important;
padding: 10px 10px !important;
border-radius: 12px !important;
}
}
</style>
{if $variant == 0}
<p class="{$TpStyle.dom_prefix}-dm-desc v0">
{$arrVideo.v_description}
{$strDetailMainDescription}
</p>
{elseif $variant == 1}
<div class="{$TpStyle.dom_prefix}-dm-desc v1">
<p>{$arrVideo.v_description|mb_substr=0,120}</p>
<p>{$strDetailMainDescription|mb_substr=0,120}</p>
</div>
{elseif $variant == 2}
<section class="{$TpStyle.dom_prefix}-dm-desc v2">
<p>{$arrVideo.v_description|mb_substr=0,200}</p>
<p>{$strDetailMainDescription|mb_substr=0,200}</p>
</section>
{elseif $variant == 3}
<article class="{$TpStyle.dom_prefix}-dm-desc v3">
<p>{$arrVideo.v_description}</p>
<p>{$strDetailMainDescription}</p>
</article>
{elseif $variant == 4}
<div class="{$TpStyle.dom_prefix}-dm-desc v4">
<p>
剧情简介:{$arrVideo.v_description|mb_substr=0,150}
剧情简介:{$strDetailMainDescription|mb_substr=0,150}
</p>
</div>
{elseif $variant == 5}
<details class="{$TpStyle.dom_prefix}-dm-desc v5">
<summary>剧情介绍</summary>
<p>{$arrVideo.v_description}</p>
<p>{$strDetailMainDescription}</p>
</details>
{elseif $variant == 6}
<div class="{$TpStyle.dom_prefix}-dm-desc v6">
<p>{$arrVideo.v_description|mb_substr=0,100}...</p>
<p>{$strDetailMainDescription|mb_substr=0,100}...</p>
<a href="#desc">查看详情</a>
</div>
{elseif $variant == 7}
<section class="{$TpStyle.dom_prefix}-dm-desc v7">
<header>影片简介</header>
<p>{$arrVideo.v_description|mb_substr=0,180}</p>
<p>{$strDetailMainDescription|mb_substr=0,180}</p>
</section>
{elseif $variant == 8}
<div class="{$TpStyle.dom_prefix}-dm-desc v8">
<blockquote>
{$arrVideo.v_description|mb_substr=0,160}
{$strDetailMainDescription|mb_substr=0,160}
</blockquote>
</div>
{elseif $variant == 9}
<p class="{$TpStyle.dom_prefix}-dm-desc v9">
{$arrVideo.v_name}{$arrVideo.v_year})剧情:
{$arrVideo.v_description|mb_substr=0,140}
{$strDetailMainDescription|mb_substr=0,140}
</p>
{elseif $variant == 10}
<div class="{$TpStyle.dom_prefix}-dm-desc v10">
<p itemprop="description">
{$arrVideo.v_description|mb_substr=0,160}
{$strDetailMainDescription|mb_substr=0,160}
</p>
</div>
{elseif $variant == 11}
<div class="{$TpStyle.dom_prefix}-dm-desc v11"
data-desc="{$arrVideo.v_description|mb_substr=0,200}">
<p>{$arrVideo.v_description|mb_substr=0,120}</p>
data-desc="{$strDetailMainDescription|mb_substr=0,200}">
<p>{$strDetailMainDescription|mb_substr=0,120}</p>
</div>
{elseif $variant == 12}
<section class="{$TpStyle.dom_prefix}-dm-desc v12">
<p>
{$arrVideo.v_description|mb_substr=0,100}
{$strDetailMainDescription|mb_substr=0,100}
<span class="{$TpStyle.dom_prefix}-dm-desc-more">
{$arrVideo.v_name} 在线观看
</span>
@@ -84,7 +211,7 @@
{elseif $variant == 13}
<div class="{$TpStyle.dom_prefix}-dm-desc v13">
<p>
{$arrVideo.v_description|mb_substr=0,130}
{$strDetailMainDescription|mb_substr=0,130}
高清完整版内容介绍。
</p>
</div>
@@ -92,7 +219,7 @@
{elseif $variant == 14}
<div class="{$TpStyle.dom_prefix}-dm-desc v14">
<p>
{$arrVideo.v_description|mb_substr=0,150}
{$strDetailMainDescription|mb_substr=0,150}
</p>
<p class="{$TpStyle.dom_prefix}-dm-desc-note">
本片支持在线播放
@@ -101,7 +228,7 @@
{elseif $variant == 15}
<p class="{$TpStyle.dom_prefix}-dm-desc v15">
{$arrVideo.v_description|mb_substr=0,120}
{$strDetailMainDescription|mb_substr=0,120}
免费观看{$arrVideo.v_name}
</p>
@@ -109,30 +236,59 @@
<div class="{$TpStyle.dom_prefix}-dm-desc v16">
<p>
<strong>{$arrVideo.v_name}</strong>
{$arrVideo.v_description|mb_substr=0,140}
{$strDetailMainDescription|mb_substr=0,140}
</p>
</div>
{elseif $variant == 17}
<section class="{$TpStyle.dom_prefix}-dm-desc v17">
<p>{$arrVideo.v_description|mb_substr=0,180}</p>
<p>{$strDetailMainDescription|mb_substr=0,180}</p>
</section>
{elseif $variant == 18}
<div class="{$TpStyle.dom_prefix}-dm-desc v18">
<p class="{$TpStyle.dom_prefix}-dm-desc-hidden">
{$arrVideo.v_description}
{$strDetailMainDescription}
</p>
</div>
{elseif $variant == 19}
<article class="{$TpStyle.dom_prefix}-dm-desc v19">
<header>剧情简介</header>
<p>{$arrVideo.v_description|mb_substr=0,160}</p>
<p>{$strDetailMainDescription|mb_substr=0,160}</p>
</article>
{/if}
{notempty name="$strDetailBody"}
<div class="{$TpStyle.dom_prefix}-dm-desc-body" style="margin-top:10px;padding:12px 12px;border:1px solid var(--border-color);border-radius:14px;background:var(--bg-soft-color);">
<p style="margin:0;color:var(--text-color);line-height:1.8;">{$strDetailBody}</p>
</div>
{/notempty}
{if empty($boolIsForgeDetail)}
{include file="module/seo_copy/detail" /}
{/if}
{if !empty($boolIsForgeDetail)}
<div class="{$TpStyle.dom_prefix}-dm-desc-guide" style="margin-top:10px;padding:12px 12px;border:1px solid var(--border-color);border-radius:14px;background:var(--bg-soft-color);">
<p style="margin:0;color:var(--text-color);line-height:1.8;">{$strForgeBodyLead|default='当前页围绕延伸词补充主片相关信息,适合先确认内容归属。'}</p>
<p style="margin:8px 0 0;color:var(--text-muted-color);line-height:1.7;">
{notempty name="$strMainVideoInfoUrl"}
{$strForgeReturnLead|default='如果想回到标准详情,可返回'} <a href="{$strMainVideoInfoUrl}">《{$strOriginVideoName}》详情页</a>
{/notempty}
{notempty name="$strPreferredPlayUrl"}
{$strForgePlayLead|default='如果准备继续观看,也可以直接进入'} <a href="{$strPreferredPlayUrl}">播放页</a>
{/notempty}
</p>
</div>
<div class="{$TpStyle.dom_prefix}-dm-forge-note" style="margin-top:10px;padding:12px 12px;border:1px solid var(--border-color);border-radius:14px;background:var(--bg-soft-color);">
<p style="margin:0;color:var(--text-color);line-height:1.8;">{$strForgeDetailNote|default='当前页提供的是延伸词视角,适合先确认内容归属,再决定是否回到标准详情或播放页。'}</p>
{notempty name="$strMainVideoInfoUrl"}
<p style="margin:8px 0 0;color:var(--text-muted-color);line-height:1.7;">如果你想继续查看主内容页,可以回到 <a href="{$strMainVideoInfoUrl}">《{$strOriginVideoName}》详情页</a></p>
{/notempty}
</div>
{/if}
{video:seoaddon video="$arrVideo" export_name="arrAddon" /}
{include file="module/detail/_seo_addon" /}

View File

@@ -1,117 +1,133 @@
{// ===================== Title Variants ===================== }
{php}
$strDetailTitleName = trim((string)($arrVideo['v_name'] ?? ''));
if ($strDetailTitleName === '') {
$strDetailTitleName = '视频详情';
}
$strDetailTitleYear = trim((string)($arrVideo['v_year'] ?? ''));
$boolShowDetailYear = $strDetailTitleYear !== ''
&& preg_match('/^(19|20)\d{2}$/', $strDetailTitleYear)
&& $strDetailTitleYear !== '0';
$strDetailTitleScore = trim((string)($arrVideo['v_score'] ?? ''));
$boolShowDetailScore = $strDetailTitleScore !== ''
&& is_numeric($strDetailTitleScore)
&& (float)$strDetailTitleScore > 0;
{/php}
{if $variant == 0}
<h1 class="{$TpStyle.dom_prefix}-dm-title v0">
{$arrVideo.v_name}
{$strDetailTitleName}
</h1>
{elseif $variant == 1}
<h1 class="{$TpStyle.dom_prefix}-dm-title v1">
{$arrVideo.v_name}
<small>{$arrVideo.v_year}</small>
{$strDetailTitleName}
{if $boolShowDetailYear}<small>{$strDetailTitleYear}</small>{/if}
</h1>
{elseif $variant == 2}
<h2 class="{$TpStyle.dom_prefix}-dm-title v2">
《{$arrVideo.v_name}》
</h2>
<h1 class="{$TpStyle.dom_prefix}-dm-title v2">
《{$strDetailTitleName}》
</h1>
{elseif $variant == 3}
<header class="{$TpStyle.dom_prefix}-dm-title-wrap v3">
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$arrVideo.v_name}</h1>
<span class="{$TpStyle.dom_prefix}-dm-sub">{$arrVideo.v_year}</span>
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$strDetailTitleName}</h1>
{if $boolShowDetailYear}<span class="{$TpStyle.dom_prefix}-dm-sub">{$strDetailTitleYear}</span>{/if}
</header>
{elseif $variant == 4}
<div class="{$TpStyle.dom_prefix}-dm-title-box v4">
<strong class="{$TpStyle.dom_prefix}-dm-title">{$arrVideo.v_name}</strong>
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$strDetailTitleName}</h1>
</div>
{elseif $variant == 5}
<h1 class="{$TpStyle.dom_prefix}-dm-title v5">
{$arrVideo.v_name}
{$strDetailTitleName}
<sup>HD</sup>
</h1>
{elseif $variant == 6}
<section class="{$TpStyle.dom_prefix}-dm-title-wrap v6">
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$arrVideo.v_name}</h1>
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$strDetailTitleName}</h1>
</section>
{elseif $variant == 7}
<article class="{$TpStyle.dom_prefix}-dm-title-wrap v7">
<h2 class="{$TpStyle.dom_prefix}-dm-title">{$arrVideo.v_name}</h2>
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$strDetailTitleName}</h1>
</article>
{elseif $variant == 8}
<figure class="{$TpStyle.dom_prefix}-dm-title-wrap v8">
<figcaption class="{$TpStyle.dom_prefix}-dm-title">
{$arrVideo.v_name}
</figcaption>
<h1 class="{$TpStyle.dom_prefix}-dm-title">
{$strDetailTitleName}
</h1>
</figure>
{elseif $variant == 9}
<h1 class="{$TpStyle.dom_prefix}-dm-title v9">
{$arrVideo.v_name}
<span class="{$TpStyle.dom_prefix}-dm-tag">在线观看</span>
{$strDetailTitleName}
</h1>
{elseif $variant == 10}
<h1 class="{$TpStyle.dom_prefix}-dm-title v10" data-title="{$arrVideo.v_name}">
{$arrVideo.v_name}
<h1 class="{$TpStyle.dom_prefix}-dm-title v10" data-title="{$strDetailTitleName}">
{$strDetailTitleName}
</h1>
{elseif $variant == 11}
<h2 class="{$TpStyle.dom_prefix}-dm-title v11">
<strong>{$arrVideo.v_name}</strong>
</h2>
<h1 class="{$TpStyle.dom_prefix}-dm-title v11">
<strong>{$strDetailTitleName}</strong>
</h1>
{elseif $variant == 12}
<div class="{$TpStyle.dom_prefix}-dm-title-box v12">
<h1 class="{$TpStyle.dom_prefix}-dm-title">
{$arrVideo.v_name}
<em>{$arrVideo.v_year}</em>
{$strDetailTitleName}
{if $boolShowDetailYear}<em>{$strDetailTitleYear}</em>{/if}
</h1>
</div>
{elseif $variant == 13}
<h1 class="{$TpStyle.dom_prefix}-dm-title v13">
{$arrVideo.v_name}
<span class="{$TpStyle.dom_prefix}-dm-score">{$arrVideo.v_score}</span>
{$strDetailTitleName}
{if $boolShowDetailScore}<span class="{$TpStyle.dom_prefix}-dm-score">{$strDetailTitleScore}</span>{/if}
</h1>
{elseif $variant == 14}
<header class="{$TpStyle.dom_prefix}-dm-title-wrap v14">
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$arrVideo.v_name}</h1>
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$strDetailTitleName}</h1>
<i class="{$TpStyle.dom_prefix}-dm-icon"></i>
</header>
{elseif $variant == 15}
<h1 class="{$TpStyle.dom_prefix}-dm-title v15">
{$arrVideo.v_name} 免费高清完整版
{$strDetailTitleName} 免费高清完整版
</h1>
{elseif $variant == 16}
<h1 class="{$TpStyle.dom_prefix}-dm-title v16" itemprop="name">
{$arrVideo.v_name}
{$strDetailTitleName}
</h1>
{elseif $variant == 17}
<div class="{$TpStyle.dom_prefix}-dm-title-box v17">
<h2 class="{$TpStyle.dom_prefix}-dm-title">{$arrVideo.v_name}</h2>
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$strDetailTitleName}</h1>
</div>
{elseif $variant == 18}
<h1 class="{$TpStyle.dom_prefix}-dm-title v18">
{$arrVideo.v_name}
{$strDetailTitleName}
<span class="{$TpStyle.dom_prefix}-dm-hidden">
{$arrVideo.v_year} 在线观看
{if $boolShowDetailYear}{$strDetailTitleYear}{/if}
</span>
</h1>
{elseif $variant == 19}
<section class="{$TpStyle.dom_prefix}-dm-title-wrap v19">
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$arrVideo.v_name}</h1>
<h1 class="{$TpStyle.dom_prefix}-dm-title">{$strDetailTitleName}</h1>
</section>
{/if}

View File

@@ -18,6 +18,4 @@ $title = $Slot['title_text'] ?? null;
{// ===== Shell + Item ===== }
{include file="module/list/shell/_shell_router" /}
</section>
</section>
</section>

View File

@@ -38,7 +38,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_url ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_url ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</nav>
{/case}
@@ -118,7 +118,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_url ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_url ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</div>
{/case}
@@ -198,7 +198,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_url ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_url ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</footer>
{/case}
@@ -278,7 +278,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_url ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_url ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</section>
{/case}
@@ -358,7 +358,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_url ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_url ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</aside>
{/case}
@@ -438,7 +438,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_url ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_url ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</nav>
{/case}
@@ -518,7 +518,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_url ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_url ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</div>
{/case}
@@ -598,7 +598,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_url ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_url ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</footer>
{/case}
@@ -678,7 +678,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_url ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_url ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</section>
{/case}
@@ -758,7 +758,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_url ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_url ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</aside>
{/case}
@@ -834,4 +834,4 @@
</ol>
</footer>
{/default}
{/switch}
{/switch}

View File

@@ -327,7 +327,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_urll ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_urll ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</div>
@@ -745,7 +745,7 @@
<label>跳到</label>
<input class="{$TpStyle.dom_prefix}-pg-inp" type="number" min="1" max="{$pages}" value="{$page}">
<button class="{$TpStyle.dom_prefix}-pg-btn" type="button"
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);p=Math.max(1,Math.min(max,p));var u='{:$first_urll ?? '#'}';if(!u){return;}u=u.replace(/page\d+$/i,'page'+p);window.location.href=u;})(this)">确定</button>
onclick="(function(btn){var wrap=btn.parentNode;var inp=wrap.querySelector('input');var max=parseInt(wrap.getAttribute('data-pages')||1);var p=parseInt(inp.value||1);var u='{:$first_urll ?? '#'}';p=Math.max(1,Math.min(max,p));if(!u||u==='#'){return;}if(/[?&]page=\\d+/i.test(u)){u=u.replace(/([?&]page=)\\d+/i,'$1'+p);}else if(/[?&]page=\\{page\\}/i.test(u)){u=u.replace(/([?&]page=)\\{page\\}/i,'$1'+p);}else if(/-\\{page\\}(?=$|[?#])/i.test(u)){u=u.replace(/-\\{page\\}(?=$|[?#])/i,'-'+p);}else{u=u.replace(/-(\\d+)(?=$|[?#])/,'-'+p);}window.location.href=u;})(this)">确定</button>
</div>
</div>
@@ -875,4 +875,4 @@
</footer>
{/default}
{/switch}
{/switch}

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