Compare commits

..

94 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
root
7ece23688f guard missing bootstrap env templates on legacy installs 2026-04-15 14:06:24 +08:00
root
0794c903d7 restore bootstrap and seo helper chain after rebase drop 2026-04-15 14:00:19 +08:00
Your Name
887e95286f debug 2026-04-14 23:16:47 +08:00
make
27d193be28 ad adtop 2026-04-11 16:33:53 +08:00
make
6bb0953ab2 debug 2026-02-22 19:42:47 +08:00
make
f4ee8dd16b dedbug 2026-01-25 00:54:38 +08:00
make
aa426d83e7 debug 2026-01-20 13:00:42 +08:00
make
83a92479a1 debug 2026-01-20 12:03:31 +08:00
make
14ca7c4cdc debug 2026-01-19 23:20:19 +08:00
make
934ead3e11 baidu tuison 2026-01-19 21:58:00 +08:00
make
0323018c8c debug 2026-01-17 20:18:29 +08:00
make
7ac1e11e46 data 2026-01-12 15:27:24 +08:00
878 changed files with 114569 additions and 2301 deletions

5
code/.gitignore vendored
View File

@@ -17,4 +17,9 @@ Thumbs.db
public/static/css/compiled/* public/static/css/compiled/*
public/static/js/compiled/* public/static/js/compiled/*
public/static/favicon/generated/* 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/* /storage/*

View File

@@ -7,6 +7,7 @@ use think\exception\Handle;
use think\exception\HttpException; use think\exception\HttpException;
use think\exception\HttpResponseException; use think\exception\HttpResponseException;
use think\exception\ValidateException; use think\exception\ValidateException;
use think\facade\Log;
use think\Response; use think\Response;
use Throwable; use Throwable;
@@ -36,6 +37,8 @@ class ExceptionHandle extends Handle
*/ */
public function report(Throwable $exception): void public function report(Throwable $exception): void
{ {
$this->reportFrontendDetailException($exception);
// 使用内置的方式记录异常日志 // 使用内置的方式记录异常日志
parent::report($exception); parent::report($exception);
} }
@@ -55,4 +58,63 @@ class ExceptionHandle extends Handle
// 其他错误交给系统处理 // 其他错误交给系统处理
return parent::render($request, $e); 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,19 @@
declare(strict_types=1); declare(strict_types=1);
use app\admin\controller\AdminUser; use app\admin\controller\AdminUser;
use app\admin\controller\Index;
use app\admin\controller\Domain; use app\admin\controller\Domain;
use app\admin\controller\SeoSupply;
use app\admin\controller\Novel; use app\admin\controller\Novel;
use app\admin\controller\ServerNode;
use app\admin\controller\Site; use app\admin\controller\Site;
use app\admin\controller\SystemConfig; use app\admin\controller\SystemConfig;
use app\admin\controller\Video; use app\admin\controller\Video;
use app\admin\middleware\AdminAuth; 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::group("/guest", function () {
Route::post("/login", "Guest/Login")->name("Guest@Login"); Route::post("/login", "Guest/Login")->name("Guest@Login");
Route::post("/logout", "Guest/Logout")->name("Guest@Logout"); Route::post("/logout", "Guest/Logout")->name("Guest@Logout");
@@ -26,6 +31,11 @@ Route::group("/system", function () {
Route::get("/config/list", [SystemConfig::class, "getSystemConfigList"])->name("SystemConfig@getSystemConfigList"); Route::get("/config/list", [SystemConfig::class, "getSystemConfigList"])->name("SystemConfig@getSystemConfigList");
Route::post("/config/save", [SystemConfig::class, "saveSystemConfig"])->name("SystemConfig@saveSystemConfig"); 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::get("/cjpz/list", [SystemConfig::class, "getCaiJiPeiZhiList"])->name("SystemConfig@getCaiJiPeiZhiList");
Route::post("/cjpz/save", [SystemConfig::class, "saveCaiJiPeiZhi"])->name("SystemConfig@saveCaiJiPeiZhi"); Route::post("/cjpz/save", [SystemConfig::class, "saveCaiJiPeiZhi"])->name("SystemConfig@saveCaiJiPeiZhi");
@@ -48,6 +58,20 @@ Route::group("/video", function () {
Route::post("/level/set", [Video::class, "updateVideoLevel"])->name("Video@updateVideoLevel"); Route::post("/level/set", [Video::class, "updateVideoLevel"])->name("Video@updateVideoLevel");
Route::post("/boxoffice/set", [Video::class, "updateBoxOffice"])->name("Video@updateBoxOffice"); Route::post("/boxoffice/set", [Video::class, "updateBoxOffice"])->name("Video@updateBoxOffice");
Route::get("/category/list", [Video::class, "getCategory"])->name("Video@getCategory"); 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); })->middleware(AdminAuth::class);
# 广告 # 广告
@@ -71,8 +95,93 @@ Route::group("/site", function () {
# 域名管理 # 域名管理
Route::get("/domain/list", [Site::class, "getDomainList"])->name("Site@getDomainList"); Route::get("/domain/list", [Site::class, "getDomainList"])->name("Site@getDomainList");
Route::get("/domain/import/template", [Site::class, "getDomainImportTemplate"])->name("Site@getDomainImportTemplate");
Route::get("/domain/supply/template", [Site::class, "getDomainSupplyTemplate"])->name("Site@getDomainSupplyTemplate");
Route::get("/domain/supply/run/summary", [Site::class, "getDomainSupplyRunSummary"])->name("Site@getDomainSupplyRunSummary");
Route::post("/domain/supply/run", [Site::class, "runDomainSupplyBatch"])->name("Site@runDomainSupplyBatch");
Route::get("/domain/spider-md/summary", [Site::class, "getDomainSpiderMdSummary"])->name("Site@getDomainSpiderMdSummary");
Route::post("/domain/spider-md/run", [Site::class, "runDomainSpiderMdGenerate"])->name("Site@runDomainSpiderMdGenerate");
Route::get("/domain/spider-crawl/workbench/summary", [Site::class, "getDomainSpiderCrawlWorkbenchSummary"])->name("Site@getDomainSpiderCrawlWorkbenchSummary");
Route::post("/domain/upload", [Site::class, "uploadDomain"])->name("Site@uploadDomain"); Route::post("/domain/upload", [Site::class, "uploadDomain"])->name("Site@uploadDomain");
Route::post("/domain/save", [Site::class, "saveDomain"])->name("Site@saveDomain"); Route::post("/domain/save", [Site::class, "saveDomain"])->name("Site@saveDomain");
Route::post("/domain/seo-copy/ai/submit", [Site::class, "submitDomainSeoCopyAiGenerate"])->name("Site@submitDomainSeoCopyAiGenerate");
Route::post("/domain/seo-copy/ai/optimize", [Site::class, "optimizeDomainSeoCopyAiGenerate"])->name("Site@optimizeDomainSeoCopyAiGenerate");
Route::get("/domain/seo-copy/provider/status", [Site::class, "getDomainSeoCopyAiProviderStatus"])->name("Site@getDomainSeoCopyAiProviderStatus");
Route::get("/domain/seo-copy/history", [Site::class, "getDomainSeoCopyGenerationHistory"])->name("Site@getDomainSeoCopyGenerationHistory");
Route::get("/domain/seo-copy/history/detail", [Site::class, "getDomainSeoCopyGenerationHistoryDetail"])->name("Site@getDomainSeoCopyGenerationHistoryDetail");
Route::get("/domain/seo-copy/published/preview", [Site::class, "getDomainSeoCopyPublishedPreview"])->name("Site@getDomainSeoCopyPublishedPreview");
Route::post("/domain/seo-copy/history/rollback", [Site::class, "rollbackDomainSeoCopyHistory"])->name("Site@rollbackDomainSeoCopyHistory");
Route::post("/domain/strategy/apply", [Site::class, "applyDomainStrategyProfileBatch"])->name("Site@applyDomainStrategyProfileBatch");
Route::get("/domain/import/run/summary", [Site::class, "getDomainImportRunSummary"])->name("Site@getDomainImportRunSummary");
Route::get("/domain/import/failed/queue", [Site::class, "getDomainImportFailedQueue"])->name("Site@getDomainImportFailedQueue");
Route::get("/domain/import/task/status", [Site::class, "getDomainImportAsyncJobStatus"])->name("Site@getDomainImportAsyncJobStatus");
Route::post("/domain/import/failed/queue/run", [Site::class, "runDomainImportFailedQueue"])->name("Site@runDomainImportFailedQueue");
Route::get("/domain/import/rerun/summary", [Site::class, "getDomainImportRerunSummary"])->name("Site@getDomainImportRerunSummary");
Route::get("/domain/import/remediation/summary", [Site::class, "getDomainImportRemediationSummary"])->name("Site@getDomainImportRemediationSummary");
Route::post("/domain/import/remediation/run", [Site::class, "runDomainImportFailureRemediation"])->name("Site@runDomainImportFailureRemediation");
Route::get("/domain/import/self-healing/summary", [Site::class, "getDomainImportSelfHealingSummary"])->name("Site@getDomainImportSelfHealingSummary");
Route::post("/domain/import/self-healing/run", [Site::class, "runDomainImportSelfHealing"])->name("Site@runDomainImportSelfHealing");
Route::get("/domain/import/health/workbench", [Site::class, "getDomainImportHealthWorkbench"])->name("Site@getDomainImportHealthWorkbench");
Route::get("/domain/import/manual-attention/queue", [Site::class, "getDomainImportManualAttentionQueue"])->name("Site@getDomainImportManualAttentionQueue");
Route::get("/domain/import/manual-attention/handle/summary", [Site::class, "getDomainImportManualAttentionHandleSummary"])->name("Site@getDomainImportManualAttentionHandleSummary");
Route::post("/domain/import/manual-attention/handle/run", [Site::class, "runDomainImportManualAttentionHandle"])->name("Site@runDomainImportManualAttentionHandle");
Route::get("/domain/import/health/workbench/summary", [Site::class, "getDomainImportHealthWorkbenchSummary"])->name("Site@getDomainImportHealthWorkbenchSummary");
Route::get("/domain/import/health/workbench/trend", [Site::class, "getDomainImportHealthWorkbenchTrend"])->name("Site@getDomainImportHealthWorkbenchTrend");
Route::post("/domain/import/health/workbench/run", [Site::class, "runDomainImportHealthWorkbenchSummary"])->name("Site@runDomainImportHealthWorkbenchSummary");
Route::get("/domain/import/health/trend", [Site::class, "getDomainImportHealthTrend"])->name("Site@getDomainImportHealthTrend");
Route::get("/domain/external/seo/collect/hosts", [Site::class, "getDomainExternalSeoCollectHosts"])->name("Site@getDomainExternalSeoCollectHosts");
Route::get("/domain/external/seo/summary", [Site::class, "getDomainExternalSeoSummary"])->name("Site@getDomainExternalSeoSummary");
Route::get("/domain/external/seo/failed/queue", [Site::class, "getDomainExternalSeoFailedQueue"])->name("Site@getDomainExternalSeoFailedQueue");
Route::get("/domain/external/seo/trend/summary", [Site::class, "getDomainExternalSeoTrendSummary"])->name("Site@getDomainExternalSeoTrendSummary");
Route::get("/domain/external/seo/provider/status", [Site::class, "getDomainExternalSeoProviderStatus"])->name("Site@getDomainExternalSeoProviderStatus");
Route::get("/domain/external/seo/collector/status", [Site::class, "getDomainExternalSeoCollectorStatus"])->name("Site@getDomainExternalSeoCollectorStatus");
Route::post("/domain/external/seo/provider/credential/upload", [Site::class, "uploadDomainExternalSeoProviderCredential"])->name("Site@uploadDomainExternalSeoProviderCredential");
Route::get("/domain/external/seo/search-console/property-map/template", [Site::class, "getDomainExternalSeoSearchConsolePropertyMapTemplate"])->name("Site@getDomainExternalSeoSearchConsolePropertyMapTemplate");
Route::post("/domain/external/seo/search-console/property-map/upload", [Site::class, "uploadDomainExternalSeoSearchConsolePropertyMap"])->name("Site@uploadDomainExternalSeoSearchConsolePropertyMap");
Route::get("/domain/external/seo/search-console/provider/plan", [Site::class, "getDomainExternalSeoSearchConsoleProviderPlan"])->name("Site@getDomainExternalSeoSearchConsoleProviderPlan");
Route::get("/domain/external/seo/search-console/fetch/summary", [Site::class, "getDomainExternalSeoSearchConsoleFetchSummary"])->name("Site@getDomainExternalSeoSearchConsoleFetchSummary");
Route::get("/domain/external/seo/baidu/provider/plan", [Site::class, "getDomainExternalSeoBaiduProviderPlan"])->name("Site@getDomainExternalSeoBaiduProviderPlan");
Route::get("/domain/external/seo/baidu/push/summary", [Site::class, "getDomainExternalSeoBaiduPushSummary"])->name("Site@getDomainExternalSeoBaiduPushSummary");
Route::get("/domain/external/seo/baidu/push/attention", [Site::class, "getDomainExternalSeoBaiduPushAttentionQueue"])->name("Site@getDomainExternalSeoBaiduPushAttentionQueue");
Route::get("/domain/external/seo/baidu/push/trend", [Site::class, "getDomainExternalSeoBaiduPushTrendSummary"])->name("Site@getDomainExternalSeoBaiduPushTrendSummary");
Route::get("/domain/external/seo/baidu/push/task/status", [Site::class, "getDomainExternalSeoBaiduPushTaskStatus"])->name("Site@getDomainExternalSeoBaiduPushTaskStatus");
Route::post("/domain/external/seo/baidu/push/task/status/save", [Site::class, "saveDomainExternalSeoBaiduPushTaskStatus"])->name("Site@saveDomainExternalSeoBaiduPushTaskStatus");
Route::get("/domain/external/seo/baidu/feedback/summary", [Site::class, "getDomainExternalSeoBaiduFeedbackSummary"])->name("Site@getDomainExternalSeoBaiduFeedbackSummary");
Route::get("/domain/external/seo/site-query/probe/summary", [Site::class, "getDomainExternalSeoSiteQueryProbeSummary"])->name("Site@getDomainExternalSeoSiteQueryProbeSummary");
Route::get("/domain/external/seo/aizhan/keyword/probe/summary", [Site::class, "getDomainExternalSeoAizhanKeywordProbeSummary"])->name("Site@getDomainExternalSeoAizhanKeywordProbeSummary");
Route::get("/domain/external/seo/aizhan/keyword/attention", [Site::class, "getDomainExternalSeoAizhanKeywordAttentionQueue"])->name("Site@getDomainExternalSeoAizhanKeywordAttentionQueue");
Route::get("/domain/external/seo/aizhan/keyword/trend", [Site::class, "getDomainExternalSeoAizhanKeywordTrendSummary"])->name("Site@getDomainExternalSeoAizhanKeywordTrendSummary");
Route::get("/domain/external/seo/snapshots/schema", [Site::class, "getDomainExternalSeoSnapshotSchema"])->name("Site@getDomainExternalSeoSnapshotSchema");
Route::get("/domain/external/seo/snapshots/checklist", [Site::class, "getDomainExternalSeoSnapshotChecklist"])->name("Site@getDomainExternalSeoSnapshotChecklist");
Route::post("/domain/external/seo/snapshots/validate", [Site::class, "validateDomainExternalSeoSnapshots"])->name("Site@validateDomainExternalSeoSnapshots");
Route::post("/domain/external/seo/snapshots/save", [Site::class, "saveDomainExternalSeoSnapshots"])->name("Site@saveDomainExternalSeoSnapshots");
Route::get("/domain/external/seo/snapshots/validate/runs", [Site::class, "getDomainExternalSeoSnapshotValidateRuns"])->name("Site@getDomainExternalSeoSnapshotValidateRuns");
Route::get("/domain/external/seo/integration/overview", [Site::class, "getDomainExternalSeoIntegrationOverview"])->name("Site@getDomainExternalSeoIntegrationOverview");
Route::get("/domain/external/seo/snapshots/runs", [Site::class, "getDomainExternalSeoSnapshotRuns"])->name("Site@getDomainExternalSeoSnapshotRuns");
Route::get("/domain/external/seo/snapshots/trend", [Site::class, "getDomainExternalSeoSnapshotTrend"])->name("Site@getDomainExternalSeoSnapshotTrend");
Route::get("/domain/external/seo/snapshots/summary", [Site::class, "getDomainExternalSeoSnapshotSummary"])->name("Site@getDomainExternalSeoSnapshotSummary");
Route::get("/domain/external/seo/keyword/movement", [Site::class, "getDomainExternalSeoKeywordMovementSummary"])->name("Site@getDomainExternalSeoKeywordMovementSummary");
Route::get("/domain/external/seo/index/movement", [Site::class, "getDomainExternalSeoIndexMovementSummary"])->name("Site@getDomainExternalSeoIndexMovementSummary");
Route::get("/domain/external/seo/optimization/suggestions", [Site::class, "getDomainExternalSeoOptimizationSuggestions"])->name("Site@getDomainExternalSeoOptimizationSuggestions");
Route::get("/domain/external/seo/cn/overview", [Site::class, "getDomainExternalSeoCnOverview"])->name("Site@getDomainExternalSeoCnOverview");
Route::get("/domain/import/external-seo/closure", [Site::class, "getDomainImportExternalSeoClosure"])->name("Site@getDomainImportExternalSeoClosure");
Route::get("/domain/import/external-seo/closure/action/summary", [Site::class, "getDomainImportExternalSeoClosureActionSummary"])->name("Site@getDomainImportExternalSeoClosureActionSummary");
Route::post("/domain/import/external-seo/closure/action/run", [Site::class, "runDomainImportExternalSeoClosureAction"])->name("Site@runDomainImportExternalSeoClosureAction");
Route::post("/domain/import/external-seo/closure/action/status", [Site::class, "updateDomainImportExternalSeoClosureActionStatus"])->name("Site@updateDomainImportExternalSeoClosureActionStatus");
Route::post("/domain/import/external-seo/closure/action/refresh", [Site::class, "refreshDomainImportExternalSeoClosureActionResult"])->name("Site@refreshDomainImportExternalSeoClosureActionResult");
Route::get("/domain/import/external-seo/closure/today", [Site::class, "getDomainImportExternalSeoClosureToday"])->name("Site@getDomainImportExternalSeoClosureToday");
Route::get("/domain/import/external-seo/closure/host/history", [Site::class, "getDomainImportExternalSeoClosureHostHistory"])->name("Site@getDomainImportExternalSeoClosureHostHistory");
Route::get("/domain/external/seo/real/summary", [Site::class, "getDomainExternalSeoRealSummary"])->name("Site@getDomainExternalSeoRealSummary");
Route::get("/domain/external/seo/manual/template", [Site::class, "getDomainExternalSeoManualImportTemplate"])->name("Site@getDomainExternalSeoManualImportTemplate");
Route::post("/domain/external/seo/manual/upload", [Site::class, "uploadDomainExternalSeoManualImport"])->name("Site@uploadDomainExternalSeoManualImport");
Route::get("/domain/external/seo/manual/runs", [Site::class, "getDomainExternalSeoManualImportRuns"])->name("Site@getDomainExternalSeoManualImportRuns");
Route::get("/bootstrap/env/template/list", [Site::class, "getBootstrapEnvTemplateList"])->name("Site@getBootstrapEnvTemplateList");
Route::get("/bootstrap/ops/summary", [Site::class, "getBootstrapOpsSummary"])->name("Site@getBootstrapOpsSummary");
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 参数模板 # TKD 参数模板
Route::get("/tkdarg/list", [Site::class, "getTKDArgList"])->name("Site@getTKDArgList"); Route::get("/tkdarg/list", [Site::class, "getTKDArgList"])->name("Site@getTKDArgList");
@@ -89,3 +198,21 @@ Route::group("/site", function () {
Route::post("/subject/fomart/save", [Site::class, "saveSubjectFomart"])->name("Site@saveSubjectFomart"); Route::post("/subject/fomart/save", [Site::class, "saveSubjectFomart"])->name("Site@saveSubjectFomart");
Route::post("/subject/fomart/del", [Site::class, "delSubjectFomart"])->name("Site@delSubjectFomart"); Route::post("/subject/fomart/del", [Site::class, "delSubjectFomart"])->name("Site@delSubjectFomart");
})->middleware(AdminAuth::class); })->middleware(AdminAuth::class);
# SEO 内容供给层
Route::group("/seo", function () {
Route::get("/overview", [SeoSupply::class, "getOverview"])->name("SeoSupply@getOverview");
Route::get("/keyword-queue/list", [SeoSupply::class, "getKeywordQueueList"])->name("SeoSupply@getKeywordQueueList");
Route::post("/keyword-queue/save", [SeoSupply::class, "saveKeywordQueue"])->name("SeoSupply@saveKeywordQueue");
Route::post("/keyword-queue/clean", [SeoSupply::class, "cleanKeywordQueue"])->name("SeoSupply@cleanKeywordQueue");
Route::get("/content-task/list", [SeoSupply::class, "getContentTaskList"])->name("SeoSupply@getContentTaskList");
Route::post("/content-task/save", [SeoSupply::class, "saveContentTask"])->name("SeoSupply@saveContentTask");
Route::post("/content-task/run", [SeoSupply::class, "runContentTasks"])->name("SeoSupply@runContentTasks");
Route::post("/content-feed/refresh", [SeoSupply::class, "refreshContentFeedArtifacts"])->name("SeoSupply@refreshContentFeedArtifacts");
Route::get("/material/list", [SeoSupply::class, "getMaterialLibraryList"])->name("SeoSupply@getMaterialLibraryList");
Route::post("/material/save", [SeoSupply::class, "saveMaterialLibrary"])->name("SeoSupply@saveMaterialLibrary");
Route::post("/seed/keywords", [SeoSupply::class, "seedKeywordQueueFromDomains"])->name("SeoSupply@seedKeywordQueueFromDomains");
Route::post("/seed/tasks", [SeoSupply::class, "seedContentTasksFromKeywordQueue"])->name("SeoSupply@seedContentTasksFromKeywordQueue");
Route::post("/seed/materials", [SeoSupply::class, "seedMaterialLibrary"])->name("SeoSupply@seedMaterialLibrary");
Route::post("/sync", [SeoSupply::class, "syncSupplyLayer"])->name("SeoSupply@syncSupplyLayer");
})->middleware(AdminAuth::class);

View File

@@ -5,8 +5,8 @@ namespace app\admin\controller;
class Index 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,390 @@
<?php
declare(strict_types=1);
namespace app\admin\controller;
use app\admin\BaseController;
use app\model\AdminUserModel;
use app\model\SeoContentTaskModel;
use app\model\SeoKeywordQueueModel;
use app\model\SeoMaterialLibraryModel;
use app\task\logic\SeoV3ContentSupplyLogic;
use app\Request;
use think\Response;
class SeoSupply extends BaseController
{
public function getOverview(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$Logic = new SeoV3ContentSupplyLogic();
return $this->success($Logic->buildSummary([
'domain_id' => (int)$Request->get('domain_id', 0),
'host' => (string)$Request->get('host', ''),
'status' => (string)$Request->get('status', ''),
'key' => (string)$Request->get('key', ''),
]));
}
public function getKeywordQueueList(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$arrData = [
'page' => (int)$Request->get('page', 1),
'limit' => (int)$Request->get('limit', 20),
'key' => (string)$Request->get('key', ''),
'domain_id' => (int)$Request->get('domain_id', 0),
'host' => (string)$Request->get('host', ''),
'status' => (string)$Request->get('status', ''),
];
$this->validate($arrData, [
'page' => 'require|number',
'limit' => 'require|number',
]);
$Query = SeoKeywordQueueModel::alias('q')->order('q.id', 'desc');
$this->applyListFilters($Query, $arrData, 'q.keyword');
$Paginate = $Query->paginate([
'list_rows' => $arrData['limit'],
'page' => $arrData['page'],
]);
return $this->success([
'items' => $Paginate->items(),
'total' => $Paginate->total(),
'current_page' => $Paginate->currentPage(),
'total_pages' => $Paginate->lastPage(),
]);
}
public function saveKeywordQueue(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$arrData = [
'id' => (int)$Request->post('id', 0),
'domain_id' => (int)$Request->post('domain_id', 0),
'host' => trim((string)$Request->post('host', '')),
'keyword' => trim((string)$Request->post('keyword', '')),
'keyword_signature' => trim((string)$Request->post('keyword_signature', '')),
'source' => trim((string)$Request->post('source', '')),
'status' => trim((string)$Request->post('status', SeoKeywordQueueModel::STATUS_PENDING)),
'weight' => (int)$Request->post('weight', 0),
'cleaned_keyword' => trim((string)$Request->post('cleaned_keyword', '')),
'payload' => $this->normalizeJsonPayload($Request->post('payload', '')),
'retry_count' => (int)$Request->post('retry_count', 0),
'last_error' => trim((string)$Request->post('last_error', '')),
'ready_at' => (int)$Request->post('ready_at', 0),
'done_at' => (int)$Request->post('done_at', 0),
];
$this->validate($arrData, [
'domain_id' => 'require|number',
'keyword' => 'require',
'source' => 'require',
'status' => 'require',
]);
$Model = $arrData['id'] > 0 ? SeoKeywordQueueModel::where('id', $arrData['id'])->find() : new SeoKeywordQueueModel();
if (empty($Model)) {
return $this->error('9993');
}
if ($arrData['keyword_signature'] === '') {
$arrData['keyword_signature'] = sha1($arrData['domain_id'] . '|' . $arrData['host'] . '|' . $arrData['keyword'] . '|' . $arrData['source']);
}
$arrData['created_at'] = (int)($Model->created_at ?? time());
$arrData['updated_at'] = time();
$Model->fill($arrData);
$Model->save();
return $this->success(['id' => (int)$Model->id]);
}
public function getContentTaskList(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$arrData = [
'page' => (int)$Request->get('page', 1),
'limit' => (int)$Request->get('limit', 20),
'key' => (string)$Request->get('key', ''),
'domain_id' => (int)$Request->get('domain_id', 0),
'host' => (string)$Request->get('host', ''),
'status' => (string)$Request->get('status', ''),
];
$this->validate($arrData, [
'page' => 'require|number',
'limit' => 'require|number',
]);
$Query = SeoContentTaskModel::alias('t')->order('t.id', 'desc');
$this->applyListFilters($Query, $arrData, 't.title');
$Paginate = $Query->paginate([
'list_rows' => $arrData['limit'],
'page' => $arrData['page'],
]);
return $this->success([
'items' => $Paginate->items(),
'total' => $Paginate->total(),
'current_page' => $Paginate->currentPage(),
'total_pages' => $Paginate->lastPage(),
]);
}
public function saveContentTask(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$arrData = [
'id' => (int)$Request->post('id', 0),
'domain_id' => (int)$Request->post('domain_id', 0),
'keyword_queue_id' => (int)$Request->post('keyword_queue_id', 0),
'host' => trim((string)$Request->post('host', '')),
'task_type' => trim((string)$Request->post('task_type', 'scene_content')),
'target_scene' => trim((string)$Request->post('target_scene', '')),
'title' => trim((string)$Request->post('title', '')),
'status' => trim((string)$Request->post('status', SeoContentTaskModel::STATUS_PENDING)),
'source' => trim((string)$Request->post('source', '')),
'payload' => $this->normalizeJsonPayload($Request->post('payload', '')),
'result_payload' => $this->normalizeJsonPayload($Request->post('result_payload', '')),
'task_signature' => trim((string)$Request->post('task_signature', '')),
'retry_count' => (int)$Request->post('retry_count', 0),
'last_error' => trim((string)$Request->post('last_error', '')),
'finished_at' => (int)$Request->post('finished_at', 0),
];
$this->validate($arrData, [
'domain_id' => 'require|number',
'keyword_queue_id' => 'require|number',
'task_type' => 'require',
'target_scene' => 'require',
'title' => 'require',
'status' => 'require',
]);
$Model = $arrData['id'] > 0 ? SeoContentTaskModel::where('id', $arrData['id'])->find() : new SeoContentTaskModel();
if (empty($Model)) {
return $this->error('9993');
}
if ($arrData['task_signature'] === '') {
$arrData['task_signature'] = sha1($arrData['keyword_queue_id'] . '|' . $arrData['task_type'] . '|' . $arrData['target_scene'] . '|' . $arrData['title']);
}
$arrData['created_at'] = (int)($Model->created_at ?? time());
$arrData['updated_at'] = time();
$Model->fill($arrData);
$Model->save();
return $this->success(['id' => (int)$Model->id]);
}
public function getMaterialLibraryList(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$arrData = [
'page' => (int)$Request->get('page', 1),
'limit' => (int)$Request->get('limit', 20),
'key' => (string)$Request->get('key', ''),
'domain_id' => (int)$Request->get('domain_id', 0),
'host' => (string)$Request->get('host', ''),
'status' => (string)$Request->get('status', ''),
];
$this->validate($arrData, [
'page' => 'require|number',
'limit' => 'require|number',
]);
$Query = SeoMaterialLibraryModel::alias('m')->order('m.sort', 'desc')->order('m.id', 'desc');
$this->applyListFilters($Query, $arrData, 'm.title');
$Paginate = $Query->paginate([
'list_rows' => $arrData['limit'],
'page' => $arrData['page'],
]);
return $this->success([
'items' => $Paginate->items(),
'total' => $Paginate->total(),
'current_page' => $Paginate->currentPage(),
'total_pages' => $Paginate->lastPage(),
]);
}
public function saveMaterialLibrary(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$arrData = [
'id' => (int)$Request->post('id', 0),
'domain_id' => (int)$Request->post('domain_id', 0),
'source_task_id' => (int)$Request->post('source_task_id', 0),
'host' => trim((string)$Request->post('host', '')),
'material_type' => trim((string)$Request->post('material_type', '')),
'title' => trim((string)$Request->post('title', '')),
'content' => trim((string)$Request->post('content', '')),
'status' => trim((string)$Request->post('status', SeoMaterialLibraryModel::STATUS_ACTIVE)),
'sort' => (int)$Request->post('sort', 0),
'material_signature' => trim((string)$Request->post('material_signature', '')),
'payload' => $this->normalizeJsonPayload($Request->post('payload', '')),
];
$this->validate($arrData, [
'material_type' => 'require',
'title' => 'require',
'content' => 'require',
'status' => 'require',
]);
$Model = $arrData['id'] > 0 ? SeoMaterialLibraryModel::where('id', $arrData['id'])->find() : new SeoMaterialLibraryModel();
if (empty($Model)) {
return $this->error('9993');
}
if ($arrData['material_signature'] === '') {
$arrData['material_signature'] = sha1($arrData['domain_id'] . '|' . $arrData['material_type'] . '|' . $arrData['title']);
}
$arrData['created_at'] = (int)($Model->created_at ?? time());
$arrData['updated_at'] = time();
$Model->fill($arrData);
$Model->save();
return $this->success(['id' => (int)$Model->id]);
}
public function seedKeywordQueueFromDomains(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$Logic = new SeoV3ContentSupplyLogic();
return $this->success($Logic->seedKeywordQueueFromDomains([
'domain_id' => (int)$Request->post('domain_id', 0),
'domain_ids' => $Request->post('domain_ids/a', []),
'host' => (string)$Request->post('host', ''),
'hosts' => $Request->post('hosts/a', []),
'limit_per_domain' => (int)$Request->post('limit_per_domain', 20),
]));
}
public function cleanKeywordQueue(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$Logic = new SeoV3ContentSupplyLogic();
return $this->success($Logic->cleanKeywordQueue([
'domain_id' => (int)$Request->post('domain_id', 0),
'domain_ids' => $Request->post('domain_ids/a', []),
'host' => (string)$Request->post('host', ''),
'hosts' => $Request->post('hosts/a', []),
'status' => (string)$Request->post('status', ''),
'limit' => (int)$Request->post('limit', 100),
]));
}
public function seedContentTasksFromKeywordQueue(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$Logic = new SeoV3ContentSupplyLogic();
return $this->success($Logic->seedContentTasksFromKeywordQueue([
'domain_id' => (int)$Request->post('domain_id', 0),
'domain_ids' => $Request->post('domain_ids/a', []),
'host' => (string)$Request->post('host', ''),
'hosts' => $Request->post('hosts/a', []),
'scenes' => $Request->post('scenes/a', []),
]));
}
public function seedMaterialLibrary(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$Logic = new SeoV3ContentSupplyLogic();
return $this->success($Logic->seedMaterialLibrary([
'domain_id' => (int)$Request->post('domain_id', 0),
'domain_ids' => $Request->post('domain_ids/a', []),
'host' => (string)$Request->post('host', ''),
'hosts' => $Request->post('hosts/a', []),
]));
}
public function syncSupplyLayer(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$Logic = new SeoV3ContentSupplyLogic();
return $this->success($Logic->syncSupplyLayer([
'domain_id' => (int)$Request->post('domain_id', 0),
'domain_ids' => $Request->post('domain_ids/a', []),
'host' => (string)$Request->post('host', ''),
'hosts' => $Request->post('hosts/a', []),
'limit_per_domain' => (int)$Request->post('limit_per_domain', 20),
'scenes' => $Request->post('scenes/a', []),
]));
}
public function runContentTasks(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$Logic = new SeoV3ContentSupplyLogic();
return $this->success($Logic->runContentTasks([
'domain_id' => (int)$Request->post('domain_id', 0),
'domain_ids' => $Request->post('domain_ids/a', []),
'host' => (string)$Request->post('host', ''),
'hosts' => $Request->post('hosts/a', []),
'status' => (string)$Request->post('status', ''),
'limit' => (int)$Request->post('limit', 50),
]));
}
public function refreshContentFeedArtifacts(Request $Request, ?AdminUserModel $AdminUserModel): Response
{
$Logic = new SeoV3ContentSupplyLogic();
return $this->success($Logic->refreshContentFeedArtifacts([
'domain_id' => (int)$Request->post('domain_id', 0),
'domain_ids' => $Request->post('domain_ids/a', []),
'host' => (string)$Request->post('host', ''),
'hosts' => $Request->post('hosts/a', []),
]));
}
protected function applyListFilters($Query, array $arrData, string $strKeyField): void
{
$intDomainId = (int)($arrData['domain_id'] ?? 0);
if ($intDomainId > 0) {
$Query->where('domain_id', $intDomainId);
}
$strHost = trim((string)($arrData['host'] ?? ''));
if ($strHost !== '') {
$Query->where('host', $strHost);
}
$strStatus = trim((string)($arrData['status'] ?? ''));
if ($strStatus !== '') {
$Query->where('status', $strStatus);
}
$strKey = trim((string)($arrData['key'] ?? ''));
if ($strKey !== '') {
$Query->where(function ($Query) use ($strKey, $strKeyField) {
$Query->whereOr([
[$strKeyField, 'like', '%' . $strKey . '%'],
['host', 'like', '%' . $strKey . '%'],
['status', 'like', '%' . $strKey . '%'],
['source', 'like', '%' . $strKey . '%'],
]);
});
}
}
protected function normalizeJsonPayload(mixed $mPayload): string
{
if (is_array($mPayload) || is_object($mPayload)) {
return json_encode($mPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
}
$strPayload = trim((string)$mPayload);
if ($strPayload === '') {
return '';
}
$arrDecoded = json_decode($strPayload, true);
if (is_array($arrDecoded)) {
return json_encode($arrDecoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
}
return $strPayload;
}
}

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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace app\admin\controller; namespace app\admin\controller;
use app\admin\BaseController; use app\admin\BaseController;
use app\common\helper\SeoCopyAiRuleConfigHelper;
use app\common\helper\SpiderMdConfigHelper;
use app\model\AdminUserModel; use app\model\AdminUserModel;
use app\model\BaoYangRuZhuModel; use app\model\BaoYangRuZhuModel;
use app\model\CaiJiPeiZhiModel; use app\model\CaiJiPeiZhiModel;
@@ -23,6 +25,44 @@ use think\Response;
class SystemConfig extends BaseController 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) public function getSystemConfigList(Request $Request, ?AdminUserModel $AdminUserModel)
{ {
SeoCopyAiRuleConfigHelper::ensureSystemConfigDefaults();
SpiderMdConfigHelper::ensureSystemConfigDefaults();
$arrData = [ $arrData = [
"page" => $Request->get('page', 1), "page" => $Request->get('page', 1),
"limit" => $Request->get('limit', 10), "limit" => $Request->get('limit', 10),
@@ -196,6 +239,25 @@ class SystemConfig extends BaseController
return $this->success(); 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; namespace app\admin\controller;
use app\admin\BaseController; 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\AdminUserModel;
use app\model\PlanTaskModel;
use app\model\VideoModel; use app\model\VideoModel;
use app\model\VideoCategoryModel; use app\model\VideoCategoryModel;
@@ -131,4 +137,363 @@ class Video extends BaseController
$arrResult = VideoCategoryModel::$arrCategory; $arrResult = VideoCategoryModel::$arrCategory;
return $this->success($arrResult); 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 = [ protected $header = [
'Access-Control-Allow-Credentials' => 'true', 'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => 3600, 'Access-Control-Max-Age' => 3600,
'Access-Control-Allow-Methods' => '*', 'Access-Control-Allow-Methods' => 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => '*', '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

@@ -3,7 +3,9 @@
declare(strict_types=1); declare(strict_types=1);
use app\api\controller\Novel; use app\api\controller\Novel;
use app\api\controller\SeoCollector;
use app\api\controller\Statis; use app\api\controller\Statis;
use app\api\middleware\SeoCollectorTokenAuth;
use think\facade\Route; use think\facade\Route;
@@ -39,3 +41,15 @@ Route::get("/test0909", [Statis::class, 'test0909'])
// 'key' => '__CONTROLLER__/__ACTION__/__IP__', // 'key' => '__CONTROLLER__/__ACTION__/__IP__',
// ]) // ])
->name('Statis@test0909'); ->name('Statis@test0909');
Route::group('/seo-collector', function () {
Route::get('/collect/hosts', [SeoCollector::class, 'collectHosts'])->name('SeoCollector@collectHosts');
Route::get('/snapshots/schema', [SeoCollector::class, 'snapshotSchema'])->name('SeoCollector@snapshotSchema');
Route::get('/snapshots/checklist', [SeoCollector::class, 'snapshotChecklist'])->name('SeoCollector@snapshotChecklist');
Route::post('/snapshots/validate', [SeoCollector::class, 'validateSnapshots'])->name('SeoCollector@validateSnapshots');
Route::post('/snapshots/save', [SeoCollector::class, 'saveSnapshots'])->name('SeoCollector@saveSnapshots');
Route::get('/crawl-logs/schema', [SeoCollector::class, 'crawlLogSchema'])->name('SeoCollector@crawlLogSchema');
Route::get('/crawl-logs/checklist', [SeoCollector::class, 'crawlLogChecklist'])->name('SeoCollector@crawlLogChecklist');
Route::post('/crawl-logs/validate', [SeoCollector::class, 'validateCrawlLogs'])->name('SeoCollector@validateCrawlLogs');
Route::post('/crawl-logs/save', [SeoCollector::class, 'saveCrawlLogs'])->name('SeoCollector@saveCrawlLogs');
})->middleware(SeoCollectorTokenAuth::class);

View File

@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\api\BaseController;
use app\common\helper\DomainExternalSeoSnapshotHelper;
use app\common\helper\DomainExternalSeoSnapshotSchemaHelper;
use app\common\helper\DomainExternalSeoSnapshotRunHelper;
use app\common\helper\DomainExternalSeoSnapshotTrendHelper;
use app\common\helper\DomainExternalSeoCnOverviewHelper;
use app\common\helper\DomainExternalSeoSnapshotValidateRunHelper;
use app\common\helper\DomainSpiderCrawlLogHelper;
use app\common\helper\DomainSpiderCrawlLogRunHelper;
use app\common\helper\DomainSpiderCrawlLogSchemaHelper;
use think\Request;
class SeoCollector extends BaseController
{
protected function codeRoot(): string
{
return dirname(__DIR__, 3);
}
protected function publicRoot(): string
{
return $this->codeRoot() . '/public';
}
protected function storageRoot(): string
{
return $this->codeRoot() . '/storage';
}
protected function summaryRoot(): string
{
return $this->storageRoot() . '/external-seo/latest';
}
protected function snapshotRunRoot(): string
{
return $this->storageRoot() . '/external-seo/snapshot-runs';
}
protected function snapshotValidateRunRoot(): string
{
return $this->storageRoot() . '/external-seo/snapshot-validate-runs';
}
protected function spiderCrawlLatestRoot(): string
{
return $this->storageRoot() . '/domain-spider-crawl/latest';
}
protected function spiderCrawlRunRoot(): string
{
return $this->storageRoot() . '/domain-spider-crawl/runs';
}
protected function publicRelativePath(string $path): string
{
$publicRoot = str_replace('\\', '/', rtrim($this->publicRoot(), '/'));
$path = str_replace('\\', '/', $path);
if (str_starts_with($path, $publicRoot . '/')) {
return substr($path, strlen($publicRoot . '/'));
}
return $path;
}
protected function storageRelativePath(string $path): string
{
$storageRoot = str_replace('\\', '/', rtrim($this->storageRoot(), '/'));
$path = str_replace('\\', '/', $path);
if (str_starts_with($path, $storageRoot . '/')) {
return substr($path, strlen($storageRoot . '/'));
}
return $path;
}
protected function success(array $data = [])
{
return json([
'code' => '1000',
'msg' => '成功',
'data' => $data,
], 200, [], [JSON_UNESCAPED_UNICODE]);
}
protected function error(string $code, string $msg)
{
return json([
'code' => $code,
'msg' => $msg,
'data' => [],
], 200, [], [JSON_UNESCAPED_UNICODE]);
}
public function collectHosts(Request $request)
{
try {
$limit = max(1, min(5000, (int)$request->get('limit', 1000)));
$includeWildcard = (int)$request->get('include_wildcard', 0) === 1;
return $this->success(DomainExternalSeoSnapshotHelper::buildCollectHosts($limit, $includeWildcard));
} catch (\Throwable $throwable) {
return $this->error('9999', '读取站外采集域名列表失败:' . $throwable->getMessage());
}
}
public function snapshotSchema()
{
try {
return $this->success(DomainExternalSeoSnapshotSchemaHelper::buildSchema());
} catch (\Throwable $throwable) {
return $this->error('9999', '读取站外 SEO 快照字段规则失败:' . $throwable->getMessage());
}
}
public function snapshotChecklist()
{
try {
return $this->success(DomainExternalSeoSnapshotSchemaHelper::buildChecklist());
} catch (\Throwable $throwable) {
return $this->error('9999', '读取站外 SEO 快照字段校验清单失败:' . $throwable->getMessage());
}
}
public function validateSnapshots(Request $request)
{
try {
$snapshots = $this->resolveSnapshots($request);
if (empty($snapshots)) {
return $this->error('9999', '请提交 snapshots');
}
$result = DomainExternalSeoSnapshotSchemaHelper::validateSnapshots((array)$snapshots);
$runRoot = DomainExternalSeoSnapshotValidateRunHelper::createRunRoot($this->snapshotValidateRunRoot(), 'snapshot_validate');
$result = DomainExternalSeoSnapshotValidateRunHelper::writeRunArtifacts(
$this->storageRoot(),
$runRoot,
$result,
(array)$snapshots
);
$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) {
return $this->error('9999', '校验站外 SEO 快照失败:' . $throwable->getMessage());
}
}
public function saveSnapshots(Request $request)
{
try {
$snapshots = $this->resolveSnapshots($request);
if (empty($snapshots)) {
return $this->error('9999', '请提交 snapshots');
}
$source = trim((string)$request->post('source', 'push_api'));
$result = DomainExternalSeoSnapshotHelper::ingestSnapshots((array)$snapshots, $source);
$runRoot = DomainExternalSeoSnapshotRunHelper::createRunRoot($this->snapshotRunRoot(), 'snapshot_push');
$result = DomainExternalSeoSnapshotRunHelper::writeRunArtifacts(
$this->storageRoot(),
$runRoot,
$result,
(array)$snapshots
);
$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->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->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->storageRoot(),
$runRoot,
$result,
(array)$snapshots
);
$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) {
return $this->error('9999', '保存站外 SEO 快照失败:' . $throwable->getMessage());
}
}
public function crawlLogSchema()
{
try {
return $this->success(DomainSpiderCrawlLogSchemaHelper::buildSchema());
} catch (\Throwable $throwable) {
return $this->error('9999', '读取蜘蛛抓取日志字段规则失败:' . $throwable->getMessage());
}
}
public function crawlLogChecklist()
{
try {
return $this->success(DomainSpiderCrawlLogSchemaHelper::buildChecklist());
} catch (\Throwable $throwable) {
return $this->error('9999', '读取蜘蛛抓取日志校验清单失败:' . $throwable->getMessage());
}
}
public function validateCrawlLogs(Request $request)
{
try {
$payload = $this->resolveCrawlLogPayload($request);
$result = DomainSpiderCrawlLogSchemaHelper::validatePayload($payload);
$runRoot = DomainSpiderCrawlLogRunHelper::createRunRoot($this->spiderCrawlRunRoot(), 'crawl_log_validate');
$result = DomainSpiderCrawlLogRunHelper::writeRunArtifacts($this->storageRoot(), $runRoot, $result, $payload);
$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) {
return $this->error('9999', '校验蜘蛛抓取日志失败:' . $throwable->getMessage());
}
}
public function saveCrawlLogs(Request $request)
{
try {
$payload = $this->resolveCrawlLogPayload($request);
$source = trim((string)($payload['source'] ?? $request->post('source', 'frontend_nginx_push')));
$result = DomainSpiderCrawlLogHelper::ingestPayload($payload, $source);
$runRoot = DomainSpiderCrawlLogRunHelper::createRunRoot($this->spiderCrawlRunRoot(), 'crawl_log_push');
$result = DomainSpiderCrawlLogRunHelper::writeRunArtifacts($this->storageRoot(), $runRoot, $result, $payload);
$latest = DomainSpiderCrawlLogHelper::writeLatestArtifacts($this->spiderCrawlLatestRoot(), $result);
$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'] ?? ''));
$result['latest_summary_json_path'] = $this->storageRelativePath((string)($latest['summary_json_path'] ?? ''));
$result['latest_summary_html_path'] = $this->storageRelativePath((string)($latest['summary_html_path'] ?? ''));
return $this->success($result);
} catch (\Throwable $throwable) {
return $this->error('9999', '保存蜘蛛抓取日志失败:' . $throwable->getMessage());
}
}
protected function resolveSnapshots(Request $request): array
{
$snapshots = $request->post('snapshots/a', []);
if (empty($snapshots)) {
$snapshotsJson = trim((string)$request->post('snapshots_json', ''));
if ($snapshotsJson !== '') {
$decoded = json_decode($snapshotsJson, true);
$snapshots = is_array($decoded) ? $decoded : [];
}
}
if (empty($snapshots) && $request->getContent()) {
$decoded = json_decode((string)$request->getContent(), true);
if (is_array($decoded)) {
$snapshots = (array)($decoded['snapshots'] ?? $decoded);
}
}
return (array)$snapshots;
}
protected function resolveCrawlLogPayload(Request $request): array
{
$arrPayload = [
'source' => trim((string)$request->post('source', 'frontend_nginx_push')),
'summary_records' => $request->post('summary_records/a', []),
'anomaly_records' => $request->post('anomaly_records/a', []),
];
if ((!empty($arrPayload['summary_records']) || !empty($arrPayload['anomaly_records'])) === false && $request->getContent()) {
$arrDecoded = json_decode((string)$request->getContent(), true);
if (is_array($arrDecoded)) {
$arrPayload['source'] = trim((string)($arrDecoded['source'] ?? $arrPayload['source']));
$arrPayload['summary_records'] = array_values((array)($arrDecoded['summary_records'] ?? []));
$arrPayload['anomaly_records'] = array_values((array)($arrDecoded['anomaly_records'] ?? []));
}
}
return $arrPayload;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use app\Request;
class SeoCollectorTokenAuth
{
public function handle(Request $request, \Closure $next)
{
$expectedToken = $this->resolveExpectedToken();
if ($expectedToken === '') {
return $this->jsonError('9999', 'SEO_COLLECTOR_TOKEN 未配置');
}
$providedToken = $this->resolveProvidedToken($request);
if ($providedToken === '' || !hash_equals($expectedToken, $providedToken)) {
return $this->jsonError('9995', '采集 token 无效');
}
return $next($request);
}
protected function resolveExpectedToken(): string
{
$candidates = [
trim((string)getenv('SEO_COLLECTOR_TOKEN')),
trim((string)env('SEO_COLLECTOR_TOKEN', '')),
trim((string)($_SERVER['SEO_COLLECTOR_TOKEN'] ?? '')),
trim((string)($_ENV['SEO_COLLECTOR_TOKEN'] ?? '')),
];
foreach ($candidates as $value) {
if ($value !== '') {
return $value;
}
}
return '';
}
protected function resolveProvidedToken(Request $request): string
{
$headerToken = trim((string)$request->header('x-seo-collector-token', ''));
if ($headerToken !== '') {
return $headerToken;
}
$bearer = trim((string)$request->header('authorization', ''));
if (preg_match('/^Bearer\s+(.+)$/i', $bearer, $matches)) {
return trim((string)($matches[1] ?? ''));
}
$queryToken = trim((string)$request->get('token', ''));
if ($queryToken !== '') {
return $queryToken;
}
return trim((string)$request->post('token', ''));
}
protected function jsonError(string $code, string $message)
{
return json([
'code' => $code,
'msg' => $message,
'data' => [],
], 200, [], [JSON_UNESCAPED_UNICODE]);
}
}

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; return $strZh;
} }
$strPinYinToolsPath = root_path() . '/extend/tools/pinyin-tool'; $strText = trim($strZh);
$strPinYin = shell_exec(sprintf("%s %s", $strPinYinToolsPath, escapeshellcmd($strZh))); if (class_exists(\Transliterator::class)) {
$Transliterator = \Transliterator::create('Han-Latin; Latin-ASCII; Lower()');
$strPinYin = trim((string) $strPinYin); if ($Transliterator !== null) {
$strText = (string) $Transliterator->transliterate($strText);
if (!is_string($strPinYin) || trim($strPinYin) === '') { }
return 'k_' . substr(sha1($strZh), 0, 12);
} }
$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 function convertToWebP(string $strInputFile, string $strOutputFile, int $intQuality = 80): bool
{ {
$strCwebpPath = root_path() . '/extend/tools/cwebp'; if (!is_file($strInputFile) || !function_exists('imagewebp')) {
$strCMD = escapeshellcmd("$strCwebpPath -q $intQuality " . escapeshellarg($strInputFile) . " -o " . escapeshellarg($strOutputFile));
exec($strCMD . ' 2>&1', $strOutput, $intReturnCode);
if ($intReturnCode === 0) {
return true;
} else {
return false; 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

@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
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,
array $headers,
array $rows,
array $columnNotes
): void {
$outputRoot = dirname($filePath);
if (!is_dir($outputRoot) && !mkdir($outputRoot, 0777, true) && !is_dir($outputRoot)) {
throw new \RuntimeException('Failed to create output root: ' . $outputRoot);
}
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle($sheetTitle);
foreach ($headers as $index => $header) {
$column = Coordinate::stringFromColumnIndex($index + 1);
$sheet->setCellValue($column . '1', $header);
$sheet->getStyle($column . '1')->getFont()->setBold(true);
$sheet->getColumnDimension($column)->setWidth(26);
}
foreach ($rows as $rowIndex => $row) {
foreach ($headers as $columnIndex => $header) {
$column = Coordinate::stringFromColumnIndex($columnIndex + 1);
$sheet->setCellValue($column . (string)($rowIndex + 2), (string)($row[$header] ?? ''));
}
}
$notesSheet = $spreadsheet->createSheet();
$notesSheet->setTitle('notes');
$notesSheet->setCellValue('A1', '字段');
$notesSheet->setCellValue('B1', '说明');
$notesSheet->getStyle('A1:B1')->getFont()->setBold(true);
$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) {
$notesSheet->setCellValue('A' . $rowNo, $column);
$notesSheet->setCellValue('B' . $rowNo, $note);
$rowNo++;
}
$writer = new Xlsx($spreadsheet);
$writer->save($filePath);
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
}
public static function getHeaders(): array
{
return [
'd_domain',
'd_name',
'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_seo_cfg',
];
}
public static function getColumnNotes(): array
{
return [
'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。",
];
}
public static function getExampleRows(): array
{
return [
[
'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',
],
[
'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',
],
];
}
public static function buildSummary(): array
{
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'],
];
}
public static function writeTemplate(string $outputRoot): array
{
$outputRoot = rtrim($outputRoot, '/');
$fileName = 'site-domain-import-template.xlsx';
$filePath = $outputRoot . '/' . $fileName;
$summary = self::buildSummary();
$headerLabels = self::getHeaderLabels();
$rows = 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[self::buildNoteDisplayLabel($field)] = $note;
}
self::writeWorkbook(
$filePath,
'site-domain-import',
self::getDisplayHeaders(),
$rows,
$displayNotes
);
return [
'file_name' => $fileName,
'file_path' => $filePath,
'summary' => $summary,
];
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
class DomainBootstrapApplyHelper
{
public static function loadBootstrapPayload(string $strPath): array
{
if ($strPath === '' || !is_file($strPath)) {
throw new \RuntimeException('Bootstrap file not found: ' . $strPath);
}
$arrPayload = json_decode((string)file_get_contents($strPath), true);
if (!is_array($arrPayload)) {
throw new \RuntimeException('Invalid bootstrap JSON: ' . $strPath);
}
return $arrPayload;
}
public static function normalizePayload(array $arrPayload): array
{
$strMatchType = DomainModel::normalizeMatchType((string)($arrPayload['d_match_type'] ?? DomainModel::MATCH_TYPE_EXACT));
$strDomain = DomainModel::normalizeStoredDomain((string)($arrPayload['d_domain'] ?? ''), $strMatchType);
$strParentDomain = DomainModel::normalizeParentDomain((string)($arrPayload['d_parent_domain'] ?? ''));
$strSeedScope = DomainModel::normalizeSeedScope((string)($arrPayload['d_seed_scope'] ?? DomainModel::SEED_SCOPE_DOMAIN));
$arrSeoCfg = DomainModel::normalizeSeoCfg($arrPayload['d_seo_cfg'] ?? []);
return [
'd_domain' => $strDomain,
'd_match_type' => $strMatchType,
'd_parent_domain' => $strParentDomain,
'd_seed_scope' => $strSeedScope,
'd_seo_cfg' => $arrSeoCfg,
];
}
public static function buildDiff(array $arrBefore, array $arrAfter): array
{
$arrDiff = [];
foreach ($arrAfter as $strKey => $mAfterValue) {
$mBeforeValue = $arrBefore[$strKey] ?? null;
if (json_encode($mBeforeValue, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) === json_encode($mAfterValue, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) {
continue;
}
$arrDiff[$strKey] = [
'before' => $mBeforeValue,
'after' => $mAfterValue,
];
}
return $arrDiff;
}
public static function inspectPayload(array $arrPayload): array
{
$arrNormalizedPayload = self::normalizePayload($arrPayload);
$strTargetDomain = (string)($arrNormalizedPayload['d_domain'] ?? '');
$DomainModel = DomainModel::where('d_domain', $strTargetDomain)->find();
$arrSummary = [
'payload' => $arrNormalizedPayload,
'found' => !empty($DomainModel),
'status' => 'pending',
'message' => '',
'diff' => [],
'changed_fields' => 0,
];
if (!$DomainModel) {
$arrSummary['status'] = 'domain_not_found';
$arrSummary['message'] = '请先在站点表中创建目标域名,再应用 bootstrap 策略样板。';
return $arrSummary;
}
$arrBefore = [
'd_domain' => (string)$DomainModel->d_domain,
'd_match_type' => (string)$DomainModel->d_match_type,
'd_parent_domain' => (string)$DomainModel->d_parent_domain,
'd_seed_scope' => (string)$DomainModel->d_seed_scope,
'd_seo_cfg' => DomainModel::normalizeSeoCfg($DomainModel->d_seo_cfg ?? [], $DomainModel),
];
$arrSummary['domain_id'] = (int)$DomainModel->d_id;
$arrSummary['diff'] = self::buildDiff($arrBefore, $arrNormalizedPayload);
$arrSummary['changed_fields'] = count($arrSummary['diff']);
$arrSummary['status'] = empty($arrSummary['diff']) ? 'noop' : 'dry_run';
if ($arrSummary['status'] === 'noop') {
$arrSummary['message'] = '目标域名当前策略已经与 bootstrap 样板一致。';
}
return $arrSummary;
}
public static function applyPayload(array $arrPayload): array
{
$arrSummary = self::inspectPayload($arrPayload);
if (($arrSummary['status'] ?? '') === 'domain_not_found') {
return $arrSummary;
}
if (($arrSummary['status'] ?? '') === 'noop') {
return $arrSummary;
}
$arrNormalizedPayload = (array)($arrSummary['payload'] ?? []);
$DomainModel = DomainModel::where('d_id', (int)($arrSummary['domain_id'] ?? 0))->find();
if (!$DomainModel) {
$arrSummary['status'] = 'domain_not_found';
$arrSummary['message'] = '目标域名在应用前已不存在。';
return $arrSummary;
}
$DomainModel->d_domain = $arrNormalizedPayload['d_domain'] ?? $DomainModel->d_domain;
$DomainModel->d_match_type = $arrNormalizedPayload['d_match_type'] ?? $DomainModel->d_match_type;
$DomainModel->d_parent_domain = $arrNormalizedPayload['d_parent_domain'] ?? $DomainModel->d_parent_domain;
$DomainModel->d_seed_scope = $arrNormalizedPayload['d_seed_scope'] ?? $DomainModel->d_seed_scope;
$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

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use app\model\DomainModel;
class DomainBootstrapBatchTemplateHelper
{
public static function writeWorkbook(
string $filePath,
string $sheetTitle,
array $headers,
array $rows,
array $columnNotes
): void {
$outputRoot = dirname($filePath);
if (!is_dir($outputRoot) && !mkdir($outputRoot, 0777, true) && !is_dir($outputRoot)) {
throw new \RuntimeException('Failed to create output root: ' . $outputRoot);
}
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle($sheetTitle);
foreach ($headers as $index => $header) {
$column = Coordinate::stringFromColumnIndex($index + 1);
$sheet->setCellValue($column . '1', $header);
$sheet->getStyle($column . '1')->getFont()->setBold(true);
$sheet->getColumnDimension($column)->setWidth(26);
}
foreach ($rows as $rowIndex => $row) {
foreach ($headers as $columnIndex => $header) {
$column = Coordinate::stringFromColumnIndex($columnIndex + 1);
$sheet->setCellValue($column . (string)($rowIndex + 2), (string)($row[$header] ?? ''));
}
}
$notesSheet = $spreadsheet->createSheet();
$notesSheet->setTitle('notes');
$notesSheet->setCellValue('A1', 'column');
$notesSheet->setCellValue('B1', 'note');
$notesSheet->getStyle('A1:B1')->getFont()->setBold(true);
$notesSheet->getColumnDimension('A')->setWidth(28);
$notesSheet->getColumnDimension('B')->setWidth(80);
$rowNo = 2;
foreach ($columnNotes as $column => $note) {
$notesSheet->setCellValue('A' . $rowNo, $column);
$notesSheet->setCellValue('B' . $rowNo, $note);
$rowNo++;
}
$writer = new Xlsx($spreadsheet);
$writer->save($filePath);
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
}
public static function getHeaders(): array
{
return [
'host',
'detail_id',
'detail_slug',
'search_keyword',
'category_parent',
'category_child',
'play_type',
'play_index',
'forge_index',
'rank_slug',
'match_type',
'parent_domain',
'seed_scope',
'strategy_profile',
'template_id',
'site_name',
'site_keywords',
'site_description',
'base_url',
'bundle_name',
];
}
public static function getColumnNotes(): array
{
return [
'host' => '必填,新域名 host。',
'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。',
'match_type' => '可选exact 或 wildcard默认 exact。',
'parent_domain' => 'wildcard 模式建议填写根域名。',
'seed_scope' => '可选domain 或 host默认 domain。',
'strategy_profile' => '可选standard / traffic / expand / closed默认 standard。',
'template_id' => '可选,默认 1。',
'site_name' => '可选,站点名称。',
'site_keywords' => '可选,站点关键词。',
'site_description' => '可选,站点描述。',
'base_url' => '可选,默认 https://<host>。',
'bundle_name' => '可选,单条 bundle 目录名;不填则按 host_dir 自动生成。',
];
}
public static function getExampleRows(): array
{
return [
[
'traffic-demo.example.com',
'154124',
'sheng-dan-bu-du-xing-meng-fei-si-lian-qu',
'圣诞',
'dian-ying',
'ju-qing-pian',
'youzhi',
'1',
'1',
'daily',
DomainModel::MATCH_TYPE_EXACT,
'',
DomainModel::SEED_SCOPE_DOMAIN,
DomainModel::SEO_STRATEGY_PROFILE_TRAFFIC,
'1',
'Traffic Demo',
'Traffic Demo,影视推荐',
'Traffic Demo 内容整理与推荐。',
'',
'',
],
[
'expand-demo.example.com',
'153511',
'yi-gua-ding-qian-kun',
'一卦定乾坤',
'zong-yi',
'da-lu-zong-yi',
'douban',
'1',
'1',
'daily',
DomainModel::MATCH_TYPE_EXACT,
'',
DomainModel::SEED_SCOPE_DOMAIN,
DomainModel::SEO_STRATEGY_PROFILE_EXPAND,
'1',
'Expand Demo',
'Expand Demo,影视推荐',
'Expand Demo 内容整理与推荐。',
'',
'',
],
];
}
public static function buildSummary(): array
{
return [
'headers' => self::getHeaders(),
'column_notes' => self::getColumnNotes(),
'example_rows' => self::getExampleRows(),
'supported_strategy_profiles' => [
DomainModel::SEO_STRATEGY_PROFILE_STANDARD,
DomainModel::SEO_STRATEGY_PROFILE_TRAFFIC,
DomainModel::SEO_STRATEGY_PROFILE_EXPAND,
DomainModel::SEO_STRATEGY_PROFILE_CLOSED,
],
'supported_match_types' => [
DomainModel::MATCH_TYPE_EXACT,
DomainModel::MATCH_TYPE_WILDCARD,
],
'supported_seed_scopes' => [
DomainModel::SEED_SCOPE_DOMAIN,
DomainModel::SEED_SCOPE_HOST,
],
];
}
public static function writeTemplate(string $outputRoot): array
{
$outputRoot = rtrim($outputRoot, '/');
$fileName = 'domain-bootstrap-batch-template.xlsx';
$filePath = $outputRoot . '/' . $fileName;
$rows = array_map(
static function (array $row): array {
return array_combine(self::getHeaders(), $row) ?: [];
},
self::getExampleRows()
);
self::writeWorkbook(
$filePath,
'domain-bootstrap-batch',
self::getHeaders(),
$rows,
self::getColumnNotes()
);
return [
'file_name' => $fileName,
'file_path' => $filePath,
'summary' => self::buildSummary(),
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapCompletedHelper
{
public static function buildSummary(array $arrBundleIndex): array
{
$arrBundles = array_values(array_filter((array)($arrBundleIndex['bundles'] ?? []), 'is_array'));
$arrEntries = [];
foreach ($arrBundles as $arrBundle) {
$strStageCode = (string)($arrBundle['handoff_stage_code'] ?? '');
if (!in_array($strStageCode, ['published', 'completed'], true)) {
continue;
}
$arrEntries[] = [
'host' => (string)($arrBundle['host'] ?? ''),
'detail_slug' => (string)($arrBundle['detail_slug'] ?? ''),
'run_detail_slug' => (string)($arrBundle['bundle_run_detail_slug'] ?? ''),
'handoff_stage_code' => $strStageCode,
'handoff_stage_label' => (string)($arrBundle['handoff_stage_label'] ?? ''),
'summary' => (string)($arrBundle['handoff_summary'] ?? ''),
'structured_policy' => (string)($arrBundle['structured_policy'] ?? ''),
'reference_mode' => (string)($arrBundle['reference_mode'] ?? ''),
'publish_result_status' => (string)($arrBundle['publish_result_status'] ?? ''),
'publish_result_published_count' => (int)($arrBundle['publish_result_published_count'] ?? 0),
'publish_result_run_dir' => (string)($arrBundle['publish_result_run_dir'] ?? ''),
'publish_result_run_summary_path' => (string)($arrBundle['publish_result_run_summary_path'] ?? ''),
'publish_result_target_root' => (string)($arrBundle['publish_result_target_root'] ?? ''),
'updated_at' => self::pickUpdatedAt($arrBundle),
];
}
usort($arrEntries, static function (array $arrA, array $arrB): int {
return strcmp((string)($arrB['updated_at'] ?? ''), (string)($arrA['updated_at'] ?? ''));
});
return [
'generated_at' => date(DATE_ATOM),
'completed_count' => count($arrEntries),
'published_count' => self::countByStatus($arrEntries, 'published'),
'latest_entry' => $arrEntries[0] ?? null,
'entries' => $arrEntries,
'top_entries' => array_slice($arrEntries, 0, 10),
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
self::ensureDir($strOutputRoot);
$strJsonPath = rtrim($strOutputRoot, '/') . '/index.json';
$strHtmlPath = rtrim($strOutputRoot, '/') . '/index.html';
file_put_contents(
$strJsonPath,
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return [
'json' => $strJsonPath,
'html' => $strHtmlPath,
];
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['entries'] ?? []) as $arrEntry) {
if (!is_array($arrEntry)) {
continue;
}
$arrLinks = [];
if ((string)($arrEntry['detail_slug'] ?? '') !== '') {
$arrLinks[] = '<a href="../details/' . rawurlencode((string)($arrEntry['detail_slug'] ?? '')) . '.html">bundle</a>';
}
if ((string)($arrEntry['run_detail_slug'] ?? '') !== '') {
$arrLinks[] = '<a href="../runs/details/' . rawurlencode((string)($arrEntry['run_detail_slug'] ?? '')) . '.html">run</a>';
}
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrEntry['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['structured_policy'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['reference_mode'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['publish_result_status'] ?? '')) . '</td>'
. '<td>' . (int)($arrEntry['publish_result_published_count'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrEntry['summary'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '</td>'
. '<td>' . implode(' / ', $arrLinks) . '</td>'
. '</tr>';
}
$arrLatest = (array)($arrSummary['latest_entry'] ?? []);
$strLatest = trim((string)($arrLatest['host'] ?? '')) !== ''
? '<p><strong>Latest Completed Bundle:</strong> '
. self::escape((string)($arrLatest['host'] ?? ''))
. ' / '
. self::escape((string)($arrLatest['handoff_stage_label'] ?? ''))
. ' / '
. self::escape((string)($arrLatest['updated_at'] ?? ''))
. '</p>'
: '';
return implode(PHP_EOL, [
'<!doctype html>',
'<html lang="zh-CN">',
'<head>',
'<meta charset="utf-8">',
'<title>Completed Bootstrap Bundles</title>',
'<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}a{color:#0b57d0;text-decoration:none;}code{word-break:break-all;white-space:pre-wrap;}</style>',
'</head>',
'<body>',
'<p><a href="../index.html">返回启动包总览</a> | <a href="../oncall/index.html">查看值班入口</a> | <a href="../duty/index.html">查看值班待办</a> | <a href="../handoff/index.html">查看启动包接手队列</a> | <a href="../runs/index.html">查看启动包运行记录</a> | <a href="../../index.html">返回统一门户首页</a></p>',
'<h1>Completed Bootstrap Bundles</h1>',
'<p>completed_count: <strong>' . (int)($arrSummary['completed_count'] ?? 0) . '</strong> / published_count: <strong>' . (int)($arrSummary['published_count'] ?? 0) . '</strong></p>',
$strLatest,
'<table>',
'<thead><tr><th>host</th><th>stage</th><th>policy</th><th>reference</th><th>publish_status</th><th>published</th><th>summary</th><th>updated_at</th><th>links</th></tr></thead>',
'<tbody>',
...$arrRows,
'</tbody>',
'</table>',
'</body>',
'</html>',
]) . PHP_EOL;
}
protected static function pickUpdatedAt(array $arrBundle): string
{
foreach (['publish_result_at', 'generated_at', 'release_dry_run_at', 'bundle_run_executed_at'] as $strField) {
$strValue = trim((string)($arrBundle[$strField] ?? ''));
if ($strValue !== '') {
return $strValue;
}
}
return '';
}
protected static function countByStatus(array $arrEntries, string $strStatus): int
{
$intCount = 0;
foreach ($arrEntries as $arrEntry) {
if ((string)($arrEntry['publish_result_status'] ?? '') === $strStatus) {
$intCount++;
}
}
return $intCount;
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapDutyHelper
{
public static function buildSummary(array $arrHandoffSummary): array
{
$arrEntries = array_values(array_filter((array)($arrHandoffSummary['entries'] ?? []), 'is_array'));
$arrDutyEntries = [];
foreach ($arrEntries as $arrEntry) {
if (in_array((string)($arrEntry['handoff_stage_code'] ?? ''), ['published', 'completed'], true)) {
continue;
}
$arrDutyEntries[] = array_merge($arrEntry, self::buildDutyMeta($arrEntry));
}
usort($arrDutyEntries, static function (array $arrA, array $arrB): int {
$intPriorityCompare = (int)($arrB['priority_score'] ?? 0) <=> (int)($arrA['priority_score'] ?? 0);
if ($intPriorityCompare !== 0) {
return $intPriorityCompare;
}
$intTimeCompare = strcmp((string)($arrB['updated_at'] ?? ''), (string)($arrA['updated_at'] ?? ''));
if ($intTimeCompare !== 0) {
return $intTimeCompare;
}
return strcmp((string)($arrA['host'] ?? ''), (string)($arrB['host'] ?? ''));
});
return [
'generated_at' => date(DATE_ATOM),
'entries' => $arrDutyEntries,
'open_count' => count($arrDutyEntries),
'pipeline_import_policy' => (array)($arrHandoffSummary['pipeline_import_policy'] ?? []),
'priority_p0_count' => self::countByPriority($arrDutyEntries, 'P0'),
'priority_p1_count' => self::countByPriority($arrDutyEntries, 'P1'),
'priority_p2_count' => self::countByPriority($arrDutyEntries, 'P2'),
'priority_p3_count' => self::countByPriority($arrDutyEntries, 'P3'),
'owner_register_count' => self::countByOwner($arrDutyEntries, '站点入库'),
'owner_apply_count' => self::countByOwner($arrDutyEntries, '站点策略'),
'owner_prepare_count' => self::countByOwner($arrDutyEntries, '补料发布'),
'owner_manual_count' => self::countByOwner($arrDutyEntries, '人工复核/排障'),
'latest_entry' => $arrDutyEntries[0] ?? null,
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
self::ensureDir($strOutputRoot);
$strJsonPath = rtrim($strOutputRoot, '/') . '/index.json';
$strHtmlPath = rtrim($strOutputRoot, '/') . '/index.html';
file_put_contents(
$strJsonPath,
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return [
'json' => $strJsonPath,
'html' => $strHtmlPath,
];
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['entries'] ?? []) as $arrEntry) {
if (!is_array($arrEntry)) {
continue;
}
$arrLinks = [];
if ((string)($arrEntry['bundle_detail_slug'] ?? '') !== '') {
$arrLinks[] = '<a href="../details/' . rawurlencode((string)($arrEntry['bundle_detail_slug'] ?? '')) . '.html">bundle</a>';
}
if ((string)($arrEntry['run_detail_slug'] ?? '') !== '') {
$arrLinks[] = '<a href="../runs/details/' . rawurlencode((string)($arrEntry['run_detail_slug'] ?? '')) . '.html">run</a>';
}
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrEntry['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['priority_label'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['owner_label'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['source_label'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['todo_title'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['summary'] ?? '')) . '</td>'
. '<td><code>' . self::escape((string)($arrEntry['next_command'] ?? '')) . '</code></td>'
. '<td>' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '</td>'
. '<td>' . implode(' / ', $arrLinks) . '</td>'
. '</tr>';
}
return implode(PHP_EOL, [
'<!doctype html>',
'<html lang="zh-CN">',
'<head>',
'<meta charset="utf-8">',
'<title>Bootstrap Duty Queue</title>',
'<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{word-break:break-all;white-space:pre-wrap;}a{color:#0b57d0;text-decoration:none;}</style>',
'</head>',
'<body>',
'<p><a href="../index.html">返回启动包总览</a> | <a href="../oncall/index.html">查看值班入口</a> | <a href="../handoff/index.html">查看启动包接手队列</a> | <a href="../completed/index.html">查看已完成启动包</a> | <a href="../runs/index.html">查看启动包运行记录</a> | <a href="../../index.html">返回统一门户首页</a> | <a href="../../ops/index.html#bootstrap-sop">查看新域名上线 SOP</a></p>',
'<h1>Bootstrap Duty Queue</h1>',
'<p>open_count: <strong>' . (int)($arrSummary['open_count'] ?? 0) . '</strong></p>',
'<p>priority: <strong>P0 ' . (int)($arrSummary['priority_p0_count'] ?? 0) . '</strong> / <strong>P1 ' . (int)($arrSummary['priority_p1_count'] ?? 0) . '</strong> / <strong>P2 ' . (int)($arrSummary['priority_p2_count'] ?? 0) . '</strong> / <strong>P3 ' . (int)($arrSummary['priority_p3_count'] ?? 0) . '</strong></p>',
'<p>owner: <strong>站点入库 ' . (int)($arrSummary['owner_register_count'] ?? 0) . '</strong> / <strong>站点策略 ' . (int)($arrSummary['owner_apply_count'] ?? 0) . '</strong> / <strong>补料发布 ' . (int)($arrSummary['owner_prepare_count'] ?? 0) . '</strong> / <strong>人工复核/排障 ' . (int)($arrSummary['owner_manual_count'] ?? 0) . '</strong></p>',
'<table>',
'<thead><tr><th>host</th><th>priority</th><th>owner</th><th>handoff</th><th>source</th><th>todo</th><th>summary</th><th>next_command</th><th>updated_at</th><th>links</th></tr></thead>',
'<tbody>',
...$arrRows,
'</tbody>',
'</table>',
'</body>',
'</html>',
]) . PHP_EOL;
}
protected static function buildDutyMeta(array $arrEntry): array
{
$strCode = (string)($arrEntry['handoff_stage_code'] ?? '');
return match ($strCode) {
'needs_attention' => [
'priority_score' => 100,
'priority_label' => 'P0',
'owner_label' => '人工复核/排障',
'todo_title' => '先处理异常',
],
'ready_to_register' => [
'priority_score' => 90,
'priority_label' => 'P1',
'owner_label' => '站点入库',
'todo_title' => '先正式入库',
],
'ready_to_apply', 'ready_to_execute' => [
'priority_score' => 80,
'priority_label' => 'P1',
'owner_label' => '站点策略',
'todo_title' => '先正式应用',
],
'ready_for_prepare' => [
'priority_score' => 70,
'priority_label' => 'P2',
'owner_label' => '补料发布',
'todo_title' => '先进入 Prepare',
],
'ready_to_import' => [
'priority_score' => 69,
'priority_label' => 'P2',
'owner_label' => '补料发布',
'todo_title' => '先导入 AI 结果',
],
'ready_to_release_dry_run' => [
'priority_score' => 68,
'priority_label' => 'P2',
'owner_label' => '补料发布',
'todo_title' => '先跑 release dry-run',
],
'ready_to_publish' => [
'priority_score' => 67,
'priority_label' => 'P2',
'owner_label' => '补料发布',
'todo_title' => '先正式发布',
],
default => [
'priority_score' => 40,
'priority_label' => 'P3',
'owner_label' => '人工复核/排障',
'todo_title' => '先人工复核',
],
};
}
protected static function countByPriority(array $arrEntries, string $strPriority): int
{
$intCount = 0;
foreach ($arrEntries as $arrEntry) {
if (!is_array($arrEntry)) {
continue;
}
if ((string)($arrEntry['priority_label'] ?? '') === $strPriority) {
$intCount++;
}
}
return $intCount;
}
protected static function countByOwner(array $arrEntries, string $strOwner): int
{
$intCount = 0;
foreach ($arrEntries as $arrEntry) {
if (!is_array($arrEntry)) {
continue;
}
if ((string)($arrEntry['owner_label'] ?? '') === $strOwner) {
$intCount++;
}
}
return $intCount;
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}

View File

@@ -0,0 +1,842 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use RuntimeException;
class DomainBootstrapEnvTemplateHelper
{
public static function inspect(array $arrOptions = []): array
{
$strCodeRoot = rtrim((string)($arrOptions['code_root'] ?? dirname(__DIR__, 3)), '/');
$strEnvFile = self::resolvePath((string)($arrOptions['env_file'] ?? ($strCodeRoot . '/.env')), $strCodeRoot);
$arrCurrentMap = self::parseEnvFileMap($strEnvFile);
$arrCatalog = self::getTemplateCatalog($strCodeRoot);
$arrAllowedKeys = self::allowedEnvKeys();
$arrTemplates = [];
foreach ($arrCatalog as $strKey => $arrTemplate) {
$strTemplatePath = (string)($arrTemplate['path'] ?? '');
$boolTemplateExists = $strTemplatePath !== '' && is_file($strTemplatePath);
$arrTemplateParse = self::parseTemplateAssignments($strTemplatePath);
$arrAssignments = (array)($arrTemplateParse['assignments'] ?? []);
$arrCompare = self::compareTemplateWithCurrent($arrCurrentMap, $arrAssignments, $arrAllowedKeys);
$arrTemplates[] = [
'key' => $strKey,
'name' => (string)($arrTemplate['name'] ?? $strKey),
'path' => $strTemplatePath,
'exists' => $boolTemplateExists,
'preview_command' => 'php scripts/domain_bootstrap_env_apply.php --template=' . $strKey . ' --env-file=.env --dry-run=1 --format=text',
'apply_command' => 'php scripts/domain_bootstrap_env_apply.php --template=' . $strKey . ' --env-file=.env --dry-run=0 --format=text',
'compared_key_count' => (int)($arrCompare['compared_key_count'] ?? 0),
'matched_key_count' => (int)($arrCompare['matched_key_count'] ?? 0),
'drift_count' => (int)($arrCompare['drift_count'] ?? 0),
'drift_items' => array_values((array)($arrCompare['drift_items'] ?? [])),
'state' => $boolTemplateExists ? 'drift' : 'missing',
'state_label' => $boolTemplateExists ? '存在偏移' : '模板缺失',
];
}
usort($arrTemplates, static function (array $arrLeft, array $arrRight): int {
$boolLeftExists = (bool)($arrLeft['exists'] ?? false);
$boolRightExists = (bool)($arrRight['exists'] ?? false);
if ($boolLeftExists !== $boolRightExists) {
return $boolLeftExists ? -1 : 1;
}
$intDriftCompare = ((int)($arrLeft['drift_count'] ?? 0)) <=> ((int)($arrRight['drift_count'] ?? 0));
if ($intDriftCompare !== 0) {
return $intDriftCompare;
}
return ((int)($arrRight['matched_key_count'] ?? 0)) <=> ((int)($arrLeft['matched_key_count'] ?? 0));
});
$arrCurrentProfile = [
'closest_template_key' => '',
'closest_template_name' => '',
'exact_match' => false,
'drift_count' => count($arrAllowedKeys),
'matched_key_count' => 0,
'drift_items' => [],
'label' => '未识别',
];
foreach ($arrTemplates as $intIndex => $arrTemplate) {
if (!(bool)($arrTemplate['exists'] ?? false)) {
continue;
}
$boolExactMatch = ((int)($arrTemplate['drift_count'] ?? 0)) === 0;
$boolClosest = $intIndex === 0;
if ($boolExactMatch) {
$arrTemplates[$intIndex]['state'] = 'current';
$arrTemplates[$intIndex]['state_label'] = '当前匹配';
} elseif ($boolClosest) {
$arrTemplates[$intIndex]['state'] = 'closest';
$arrTemplates[$intIndex]['state_label'] = '最接近';
}
if ($boolClosest) {
$arrCurrentProfile = [
'closest_template_key' => (string)($arrTemplate['key'] ?? ''),
'closest_template_name' => (string)($arrTemplate['name'] ?? ''),
'exact_match' => $boolExactMatch,
'drift_count' => (int)($arrTemplate['drift_count'] ?? 0),
'matched_key_count' => (int)($arrTemplate['matched_key_count'] ?? 0),
'drift_items' => array_values((array)($arrTemplate['drift_items'] ?? [])),
'label' => $boolExactMatch ? '当前已匹配模板' : '当前最接近模板',
];
}
}
$arrCurrentValues = [];
foreach ($arrAllowedKeys as $strKey) {
$arrCurrentValues[] = [
'key' => $strKey,
'value' => (string)($arrCurrentMap[$strKey] ?? ''),
];
}
$strAllowList = (string)($arrCurrentMap['PLAN_TASK_CODE_ALLOWLIST'] ?? '');
$arrActionHistory = self::readActionHistory($strCodeRoot);
$arrActionHistory = array_map(
static fn(array $arrItem): array => self::enrichActionWithRestorePreview($arrItem, $arrCurrentMap, $arrAllowedKeys),
$arrActionHistory
);
$arrLatestAction = self::readLatestAction($strCodeRoot);
if (!empty($arrLatestAction)) {
$arrLatestAction = self::enrichActionWithRestorePreview($arrLatestAction, $arrCurrentMap, $arrAllowedKeys);
}
return [
'env_file' => $strEnvFile,
'warning' => '不要把 PLAN_TASK_CODE_ALLOWLIST 留空;在当前 PlanTask 逻辑里,空值等于放行所有已启用 pt_code。',
'allow_all_risk' => trim($strAllowList) === '',
'current_values' => $arrCurrentValues,
'current_profile' => $arrCurrentProfile,
'latest_action' => $arrLatestAction,
'action_history' => $arrActionHistory,
'action_history_summary' => self::summarizeActionHistory($arrActionHistory),
'templates' => $arrTemplates,
];
}
public static function apply(array $arrOptions): array
{
$strCodeRoot = rtrim((string)($arrOptions['code_root'] ?? dirname(__DIR__, 3)), '/');
$strEnvFile = self::resolvePath((string)($arrOptions['env_file'] ?? ($strCodeRoot . '/.env')), $strCodeRoot);
$strTemplate = trim((string)($arrOptions['template'] ?? ''));
$strTemplatePath = self::resolveTemplatePath($strTemplate, $strCodeRoot);
$boolDryRun = (bool)($arrOptions['dry_run'] ?? true);
$boolRecordLatest = !array_key_exists('record_latest', $arrOptions) || (bool)$arrOptions['record_latest'];
$strSource = trim((string)($arrOptions['source'] ?? 'cli'));
if (!is_file($strTemplatePath)) {
throw new RuntimeException('Template file not found: ' . $strTemplatePath);
}
$arrTemplateParse = self::parseTemplateAssignments($strTemplatePath);
$arrAssignments = (array)($arrTemplateParse['assignments'] ?? []);
if (empty($arrAssignments)) {
throw new RuntimeException('No bootstrap env assignments found in template: ' . $strTemplatePath);
}
$arrAllowedKeys = self::allowedEnvKeys();
$arrCurrentMap = self::parseEnvFileMap($strEnvFile);
$arrCurrentLines = is_file($strEnvFile)
? preg_split("/\r\n|\n|\r/", (string)file_get_contents($strEnvFile))
: [];
$arrCurrentLines = is_array($arrCurrentLines) ? $arrCurrentLines : [];
$arrMerge = self::mergeEnvLines($arrCurrentLines, $arrAssignments);
$arrNewLines = (array)($arrMerge['lines'] ?? []);
$arrChanges = (array)($arrMerge['changes'] ?? []);
$arrNewMap = self::parseEnvLinesMap($arrNewLines);
if (!$boolDryRun) {
$strDir = dirname($strEnvFile);
if ($strDir !== '' && !is_dir($strDir)) {
@mkdir($strDir, 0777, true);
}
file_put_contents($strEnvFile, implode(PHP_EOL, $arrNewLines) . PHP_EOL);
}
$arrSummary = [
'action_id' => date('YmdHis') . '_' . substr(md5($strSource . '|' . $strTemplatePath . '|' . microtime(true)), 0, 8),
'status' => $boolDryRun ? 'dry_run' : 'applied',
'template' => $strTemplate !== '' ? $strTemplate : basename($strTemplatePath),
'template_path' => $strTemplatePath,
'env_file' => $strEnvFile,
'allowed_prefixes' => ['PLAN_TASK_CODE_*', 'SEO_COPY_BOOTSTRAP_*'],
'parsed_assignment_count' => count($arrAssignments),
'ignored_assignment_count' => (int)($arrTemplateParse['ignored_count'] ?? 0),
'changed_count' => count($arrChanges),
'added_count' => count(array_filter($arrChanges, static fn(array $arrChange): bool => (string)($arrChange['type'] ?? '') === 'added')),
'updated_count' => count(array_filter($arrChanges, static fn(array $arrChange): bool => (string)($arrChange['type'] ?? '') === 'updated')),
'changes' => array_values($arrChanges),
'impact_groups' => self::collectImpactGroups($arrChanges),
'source' => $strSource,
'action_at' => date('c'),
'action_kind' => $boolDryRun ? 'template_preview' : 'template_apply',
'restore_supported' => true,
'restore_available_sides' => ['before', 'after'],
'snapshot_before' => self::selectAllowedSnapshot($arrCurrentMap, $arrAllowedKeys),
'snapshot_after' => self::selectAllowedSnapshot($arrNewMap, $arrAllowedKeys),
];
if ($boolRecordLatest) {
self::writeLatestAction($strCodeRoot, $arrSummary);
self::appendActionHistory($strCodeRoot, $arrSummary);
}
return $arrSummary;
}
public static function restoreFromAction(array $arrOptions): array
{
$strCodeRoot = rtrim((string)($arrOptions['code_root'] ?? dirname(__DIR__, 3)), '/');
$strEnvFile = self::resolvePath((string)($arrOptions['env_file'] ?? ($strCodeRoot . '/.env')), $strCodeRoot);
$strActionId = trim((string)($arrOptions['action_id'] ?? ''));
$strSnapshotSide = strtolower(trim((string)($arrOptions['snapshot_side'] ?? 'after')));
$boolDryRun = (bool)($arrOptions['dry_run'] ?? true);
$boolRecordLatest = !array_key_exists('record_latest', $arrOptions) || (bool)$arrOptions['record_latest'];
$strSource = trim((string)($arrOptions['source'] ?? 'cli-restore'));
if ($strActionId === '') {
throw new RuntimeException('action_id is required');
}
if (!in_array($strSnapshotSide, ['before', 'after'], true)) {
$strSnapshotSide = 'after';
}
$arrHistory = self::readActionHistory($strCodeRoot);
$arrAction = [];
foreach ($arrHistory as $arrItem) {
if ((string)($arrItem['action_id'] ?? '') === $strActionId) {
$arrAction = $arrItem;
break;
}
}
if (empty($arrAction)) {
throw new RuntimeException('History action not found: ' . $strActionId);
}
$arrSnapshot = (array)($arrAction['snapshot_' . $strSnapshotSide] ?? []);
if (empty($arrSnapshot)) {
throw new RuntimeException('Snapshot not available for action: ' . $strActionId);
}
$arrAllowedKeys = self::allowedEnvKeys();
$arrAssignments = [];
foreach ($arrAllowedKeys as $strKey) {
$arrAssignments[$strKey] = (string)($arrSnapshot[$strKey] ?? '');
}
$arrCurrentMap = self::parseEnvFileMap($strEnvFile);
$arrCurrentLines = is_file($strEnvFile)
? preg_split("/\r\n|\n|\r/", (string)file_get_contents($strEnvFile))
: [];
$arrCurrentLines = is_array($arrCurrentLines) ? $arrCurrentLines : [];
$arrMerge = self::mergeEnvLines($arrCurrentLines, $arrAssignments);
$arrNewLines = (array)($arrMerge['lines'] ?? []);
$arrChanges = (array)($arrMerge['changes'] ?? []);
$arrNewMap = self::parseEnvLinesMap($arrNewLines);
if (!$boolDryRun) {
$strDir = dirname($strEnvFile);
if ($strDir !== '' && !is_dir($strDir)) {
@mkdir($strDir, 0777, true);
}
file_put_contents($strEnvFile, implode(PHP_EOL, $arrNewLines) . PHP_EOL);
}
$arrSummary = [
'action_id' => date('YmdHis') . '_' . substr(md5($strSource . '|' . $strActionId . '|' . microtime(true)), 0, 8),
'status' => $boolDryRun ? 'dry_run' : 'applied',
'template' => (string)($arrAction['template'] ?? 'history-restore'),
'template_path' => (string)($arrAction['template_path'] ?? ''),
'env_file' => $strEnvFile,
'parsed_assignment_count' => count($arrAssignments),
'ignored_assignment_count' => 0,
'changed_count' => count($arrChanges),
'added_count' => count(array_filter($arrChanges, static fn(array $arrChange): bool => (string)($arrChange['type'] ?? '') === 'added')),
'updated_count' => count(array_filter($arrChanges, static fn(array $arrChange): bool => (string)($arrChange['type'] ?? '') === 'updated')),
'changes' => array_values($arrChanges),
'impact_groups' => self::collectImpactGroups($arrChanges),
'source' => $strSource,
'action_at' => date('c'),
'action_kind' => $boolDryRun ? 'history_restore_preview' : 'history_restore_apply',
'restore_supported' => true,
'restore_available_sides' => ['before', 'after'],
'restore_from_action_id' => $strActionId,
'restore_snapshot_side' => $strSnapshotSide,
'restore_from_template' => (string)($arrAction['template'] ?? ''),
'restore_from_status' => (string)($arrAction['status'] ?? ''),
'snapshot_before' => self::selectAllowedSnapshot($arrCurrentMap, $arrAllowedKeys),
'snapshot_after' => self::selectAllowedSnapshot($arrNewMap, $arrAllowedKeys),
];
if ($boolRecordLatest) {
self::writeLatestAction($strCodeRoot, $arrSummary);
self::appendActionHistory($strCodeRoot, $arrSummary);
}
return $arrSummary;
}
public static function resolveTemplatePath(string $strTemplate, string $strCodeRoot): string
{
$arrCatalog = self::getTemplateCatalog($strCodeRoot);
$strLookup = strtolower(trim($strTemplate));
if ($strLookup === '' || $strLookup === 'baseline' || $strLookup === 'baseline-safe' || $strLookup === '.example.env') {
return $arrCatalog['baseline']['path'];
}
if (isset($arrCatalog[$strLookup])) {
return $arrCatalog[$strLookup]['path'];
}
$arrAliases = [
'testing' => 'testing-safe',
'testing-safe' => 'testing-safe',
'.env.bootstrap.testing.safe.example' => 'testing-safe',
'production' => 'production-safe',
'prod' => 'production-safe',
'production-safe' => 'production-safe',
'.env.bootstrap.production.safe.example' => 'production-safe',
];
if (isset($arrAliases[$strLookup]) && isset($arrCatalog[$arrAliases[$strLookup]])) {
return $arrCatalog[$arrAliases[$strLookup]]['path'];
}
return self::resolvePath($strTemplate, $strCodeRoot);
}
public static function getTemplateCatalog(string $strCodeRoot): array
{
$strCodeRoot = rtrim($strCodeRoot, '/');
return [
'baseline' => [
'name' => 'Baseline Safe Defaults',
'path' => $strCodeRoot . '/.example.env',
],
'testing-safe' => [
'name' => 'Bootstrap Testing Safe',
'path' => $strCodeRoot . '/.env.bootstrap.testing.safe.example',
],
'production-safe' => [
'name' => 'Bootstrap Production Safe',
'path' => $strCodeRoot . '/.env.bootstrap.production.safe.example',
],
];
}
protected static function parseTemplateAssignments(string $strTemplatePath): array
{
$arrAssignments = [];
$intIgnored = 0;
if ($strTemplatePath === '' || !is_file($strTemplatePath)) {
return [
'assignments' => [],
'ignored_count' => 0,
];
}
$arrLines = preg_split("/\r\n|\n|\r/", (string)file_get_contents($strTemplatePath));
foreach ((array)$arrLines as $strLine) {
if (!preg_match('/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/', (string)$strLine, $arrMatches)) {
continue;
}
$strKey = strtoupper(trim((string)($arrMatches[1] ?? '')));
$strValue = rtrim((string)($arrMatches[2] ?? ''));
if (!self::isAllowedKey($strKey)) {
$intIgnored++;
continue;
}
$arrAssignments[$strKey] = $strValue;
}
return [
'assignments' => $arrAssignments,
'ignored_count' => $intIgnored,
];
}
protected static function parseEnvFileMap(string $strEnvFile): array
{
if (!is_file($strEnvFile)) {
return [];
}
$arrMap = [];
$arrLines = preg_split("/\r\n|\n|\r/", (string)file_get_contents($strEnvFile));
foreach ((array)$arrLines as $strLine) {
if (!preg_match('/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/', (string)$strLine, $arrMatches)) {
continue;
}
$strKey = strtoupper(trim((string)($arrMatches[1] ?? '')));
if (!self::isAllowedKey($strKey)) {
continue;
}
$arrMap[$strKey] = rtrim((string)($arrMatches[2] ?? ''));
}
return $arrMap;
}
protected static function parseEnvLinesMap(array $arrLines): array
{
$arrMap = [];
foreach ($arrLines as $strLine) {
if (!preg_match('/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/', (string)$strLine, $arrMatches)) {
continue;
}
$strKey = strtoupper(trim((string)($arrMatches[1] ?? '')));
if (!self::isAllowedKey($strKey)) {
continue;
}
$arrMap[$strKey] = rtrim((string)($arrMatches[2] ?? ''));
}
return $arrMap;
}
protected static function allowedEnvKeys(): array
{
return [
'PLAN_TASK_CODE_ALLOWLIST',
'PLAN_TASK_CODE_BLOCKLIST',
'SEO_COPY_BOOTSTRAP_ONCALL_LIMIT',
'SEO_COPY_BOOTSTRAP_ONCALL_DRY_RUN',
'SEO_COPY_BOOTSTRAP_ONCALL_ALLOW_PREPARE',
'SEO_COPY_BOOTSTRAP_ONCALL_ALLOW_RELEASE_DRY_RUN',
'SEO_COPY_BOOTSTRAP_ONCALL_ALLOW_PUBLISH',
'SEO_COPY_BOOTSTRAP_PIPELINE_STEP_LIMIT',
'SEO_COPY_BOOTSTRAP_PIPELINE_DRY_RUN',
'SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_PREPARE',
'SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_IMPORT',
'SEO_COPY_BOOTSTRAP_PIPELINE_IMPORT_ARMED',
'SEO_COPY_BOOTSTRAP_PIPELINE_AI_RESULT_DIR',
'SEO_COPY_BOOTSTRAP_PIPELINE_AI_RESULT_ROOT',
'SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_RELEASE_DRY_RUN',
'SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_PUBLISH',
];
}
protected static function mergeEnvLines(array $arrCurrentLines, array $arrAssignments): array
{
$arrLines = $arrCurrentLines;
$arrRemaining = $arrAssignments;
$arrChanges = [];
foreach ($arrLines as $intIndex => $strLine) {
if (!preg_match('/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/', (string)$strLine, $arrMatches)) {
continue;
}
$strKey = strtoupper(trim((string)($arrMatches[1] ?? '')));
if (!array_key_exists($strKey, $arrRemaining)) {
continue;
}
$strOldValue = rtrim((string)($arrMatches[2] ?? ''));
$strNewValue = (string)$arrRemaining[$strKey];
if ($strOldValue !== $strNewValue) {
$arrLines[$intIndex] = $strKey . ' = ' . $strNewValue;
$arrChanges[] = [
'key' => $strKey,
'type' => 'updated',
'old' => $strOldValue,
'new' => $strNewValue,
];
}
unset($arrRemaining[$strKey]);
}
if (!empty($arrRemaining)) {
$boolNeedGap = !empty($arrLines) && trim((string)end($arrLines)) !== '';
if ($boolNeedGap) {
$arrLines[] = '';
}
foreach ($arrRemaining as $strKey => $strValue) {
$arrLines[] = $strKey . ' = ' . $strValue;
$arrChanges[] = [
'key' => (string)$strKey,
'type' => 'added',
'old' => '',
'new' => (string)$strValue,
];
}
}
return [
'lines' => $arrLines,
'changes' => $arrChanges,
];
}
protected static function isAllowedKey(string $strKey): bool
{
if (in_array($strKey, ['PLAN_TASK_CODE_ALLOWLIST', 'PLAN_TASK_CODE_BLOCKLIST'], true)) {
return true;
}
return str_starts_with($strKey, 'SEO_COPY_BOOTSTRAP_');
}
protected static function resolvePath(string $strPath, string $strCodeRoot): string
{
$strPath = trim($strPath);
if ($strPath === '') {
return $strCodeRoot;
}
if ($strPath[0] === '/') {
return $strPath;
}
return rtrim($strCodeRoot, '/') . '/' . ltrim($strPath, '/');
}
protected static function compareTemplateWithCurrent(array $arrCurrentMap, array $arrAssignments, array $arrAllowedKeys): array
{
$intCompared = 0;
$intMatched = 0;
$arrDriftItems = [];
foreach ($arrAllowedKeys as $strKey) {
$intCompared++;
$strCurrentValue = (string)($arrCurrentMap[$strKey] ?? '');
$strTemplateValue = (string)($arrAssignments[$strKey] ?? '');
if ($strCurrentValue === $strTemplateValue) {
$intMatched++;
continue;
}
$arrDriftItems[] = [
'key' => $strKey,
'current_value' => $strCurrentValue,
'template_value' => $strTemplateValue,
];
}
return [
'compared_key_count' => $intCompared,
'matched_key_count' => $intMatched,
'drift_count' => max(0, $intCompared - $intMatched),
'drift_items' => $arrDriftItems,
];
}
protected static function collectImpactGroups(array $arrChanges): array
{
$arrGroups = [];
foreach ($arrChanges as $arrChange) {
$arrGroup = self::classifyKeyGroup((string)($arrChange['key'] ?? ''));
$strGroup = (string)($arrGroup['group'] ?? 'other');
if (!isset($arrGroups[$strGroup])) {
$arrGroups[$strGroup] = [
'group' => $strGroup,
'label' => (string)($arrGroup['label'] ?? '其他'),
'count' => 0,
];
}
$arrGroups[$strGroup]['count']++;
}
return array_values($arrGroups);
}
protected static function selectAllowedSnapshot(array $arrMap, array $arrAllowedKeys): array
{
$arrSnapshot = [];
foreach ($arrAllowedKeys as $strKey) {
$arrSnapshot[$strKey] = (string)($arrMap[$strKey] ?? '');
}
return $arrSnapshot;
}
protected static function classifyKeyGroup(string $strKey): array
{
if (str_starts_with($strKey, 'PLAN_TASK_CODE_')) {
return ['group' => 'task', 'label' => '任务放行'];
}
if (str_starts_with($strKey, 'SEO_COPY_BOOTSTRAP_ONCALL_')) {
if (str_contains($strKey, 'ALLOW_RELEASE_DRY_RUN') || str_contains($strKey, 'ALLOW_PUBLISH')) {
return ['group' => 'release', 'label' => '发布门禁'];
}
return ['group' => 'oncall', 'label' => 'Oncall'];
}
if (str_starts_with($strKey, 'SEO_COPY_BOOTSTRAP_PIPELINE_')) {
if (str_contains($strKey, 'ALLOW_IMPORT') || str_contains($strKey, 'IMPORT_ARMED') || str_contains($strKey, 'AI_RESULT_')) {
return ['group' => 'import', 'label' => 'AI导入'];
}
if (str_contains($strKey, 'ALLOW_RELEASE_DRY_RUN') || str_contains($strKey, 'ALLOW_PUBLISH')) {
return ['group' => 'release', 'label' => '发布门禁'];
}
return ['group' => 'pipeline', 'label' => 'Pipeline核心'];
}
return ['group' => 'other', 'label' => '其他'];
}
protected static function buildSnapshotDelta(array $arrCurrentMap, array $arrSnapshot, array $arrAllowedKeys): array
{
$arrChanges = [];
foreach ($arrAllowedKeys as $strKey) {
$strCurrentValue = (string)($arrCurrentMap[$strKey] ?? '');
$strSnapshotValue = (string)($arrSnapshot[$strKey] ?? '');
if ($strCurrentValue === $strSnapshotValue) {
continue;
}
$arrChanges[] = [
'key' => $strKey,
'type' => $strCurrentValue === '' && $strSnapshotValue !== '' ? 'added' : 'updated',
'old' => $strCurrentValue,
'new' => $strSnapshotValue,
];
}
return [
'changed_count' => count($arrChanges),
'added_count' => count(array_filter($arrChanges, static fn(array $arrChange): bool => (string)($arrChange['type'] ?? '') === 'added')),
'updated_count' => count(array_filter($arrChanges, static fn(array $arrChange): bool => (string)($arrChange['type'] ?? '') === 'updated')),
'changes' => array_values($arrChanges),
'impact_groups' => self::collectImpactGroups($arrChanges),
'is_same_as_current' => count($arrChanges) === 0,
];
}
protected static function enrichActionWithRestorePreview(array $arrAction, array $arrCurrentMap, array $arrAllowedKeys): array
{
if (empty($arrAction) || !(bool)($arrAction['restore_supported'] ?? false)) {
return $arrAction;
}
$arrPreview = [];
foreach (['before', 'after'] as $strSnapshotSide) {
$arrSnapshot = (array)($arrAction['snapshot_' . $strSnapshotSide] ?? []);
if (empty($arrSnapshot)) {
continue;
}
$arrDelta = self::buildSnapshotDelta($arrCurrentMap, $arrSnapshot, $arrAllowedKeys);
$arrPreview[$strSnapshotSide] = array_merge(
[
'snapshot_side' => $strSnapshotSide,
'title' => $strSnapshotSide === 'before' ? '恢复前态' : '恢复后态',
'description' => ((int)($arrDelta['changed_count'] ?? 0)) > 0
? '恢复后会改动 ' . (int)($arrDelta['changed_count'] ?? 0) . ' 项 Bootstrap 键'
: '当前 .env 已与该快照一致',
],
$arrDelta
);
}
$arrAction['restore_preview'] = $arrPreview;
return $arrAction;
}
protected static function latestActionPath(string $strCodeRoot): string
{
return rtrim($strCodeRoot, '/') . '/storage/domain_bootstrap_env/latest-action.json';
}
protected static function actionHistoryPath(string $strCodeRoot): string
{
return rtrim($strCodeRoot, '/') . '/storage/domain_bootstrap_env/action-history.json';
}
protected static function writeLatestAction(string $strCodeRoot, array $arrSummary): void
{
$strPath = self::latestActionPath($strCodeRoot);
$strDir = dirname($strPath);
if (!is_dir($strDir)) {
@mkdir($strDir, 0777, true);
}
file_put_contents($strPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
protected static function readLatestAction(string $strCodeRoot): array
{
$strPath = self::latestActionPath($strCodeRoot);
if (!is_file($strPath)) {
return [];
}
$arrData = json_decode((string)file_get_contents($strPath), true);
return is_array($arrData) ? $arrData : [];
}
protected static function appendActionHistory(string $strCodeRoot, array $arrSummary): void
{
$strPath = self::actionHistoryPath($strCodeRoot);
$strDir = dirname($strPath);
if (!is_dir($strDir)) {
@mkdir($strDir, 0777, true);
}
$arrHistory = self::readActionHistory($strCodeRoot);
array_unshift($arrHistory, $arrSummary);
$arrDeduped = [];
$arrSeen = [];
foreach ($arrHistory as $arrItem) {
$strActionId = (string)($arrItem['action_id'] ?? '');
if ($strActionId !== '' && isset($arrSeen[$strActionId])) {
continue;
}
if ($strActionId !== '') {
$arrSeen[$strActionId] = true;
}
$arrDeduped[] = $arrItem;
if (count($arrDeduped) >= 20) {
break;
}
}
file_put_contents($strPath, json_encode($arrDeduped, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
protected static function readActionHistory(string $strCodeRoot): array
{
$strPath = self::actionHistoryPath($strCodeRoot);
if (!is_file($strPath)) {
return [];
}
$arrData = json_decode((string)file_get_contents($strPath), true);
if (!is_array($arrData)) {
return [];
}
return array_values(array_filter($arrData, static fn($arrItem): bool => is_array($arrItem)));
}
protected static function summarizeActionHistory(array $arrHistory): array
{
$arrStatusBuckets = [];
$arrGroupBuckets = [];
$arrSourceBuckets = [];
$arrTemplateBuckets = [];
$arrActionKindBuckets = [];
foreach ($arrHistory as $arrItem) {
$strStatus = (string)($arrItem['status'] ?? 'unknown');
$strSource = (string)($arrItem['source'] ?? 'unknown');
$strTemplate = (string)($arrItem['template'] ?? 'unknown');
$strActionKind = (string)($arrItem['action_kind'] ?? ($strStatus === 'applied' ? 'template_apply' : 'template_preview'));
if (!isset($arrStatusBuckets[$strStatus])) {
$arrStatusBuckets[$strStatus] = ['key' => $strStatus, 'label' => $strStatus, 'count' => 0];
}
$arrStatusBuckets[$strStatus]['count']++;
if (!isset($arrSourceBuckets[$strSource])) {
$arrSourceBuckets[$strSource] = ['key' => $strSource, 'label' => $strSource, 'count' => 0];
}
$arrSourceBuckets[$strSource]['count']++;
if (!isset($arrTemplateBuckets[$strTemplate])) {
$arrTemplateBuckets[$strTemplate] = ['key' => $strTemplate, 'label' => $strTemplate, 'count' => 0];
}
$arrTemplateBuckets[$strTemplate]['count']++;
if (!isset($arrActionKindBuckets[$strActionKind])) {
$arrActionKindBuckets[$strActionKind] = [
'key' => $strActionKind,
'label' => $strActionKind,
'count' => 0
];
}
$arrActionKindBuckets[$strActionKind]['count']++;
foreach ((array)($arrItem['impact_groups'] ?? []) as $arrGroup) {
$strGroup = (string)($arrGroup['group'] ?? 'other');
$strLabel = (string)($arrGroup['label'] ?? '其他');
$intCount = (int)($arrGroup['count'] ?? 0);
if (!isset($arrGroupBuckets[$strGroup])) {
$arrGroupBuckets[$strGroup] = ['key' => $strGroup, 'label' => $strLabel, 'count' => 0];
}
$arrGroupBuckets[$strGroup]['count'] += $intCount;
}
}
$arrStatusBuckets = array_values($arrStatusBuckets);
$arrGroupBuckets = array_values($arrGroupBuckets);
$arrSourceBuckets = array_values($arrSourceBuckets);
$arrTemplateBuckets = array_values($arrTemplateBuckets);
$arrActionKindBuckets = array_values($arrActionKindBuckets);
$fnSort = static function (array &$arrBuckets): void {
usort($arrBuckets, static function (array $arrLeft, array $arrRight): int {
$intCountCompare = ((int)($arrRight['count'] ?? 0)) <=> ((int)($arrLeft['count'] ?? 0));
if ($intCountCompare !== 0) {
return $intCountCompare;
}
return strcmp((string)($arrLeft['label'] ?? ''), (string)($arrRight['label'] ?? ''));
});
};
$fnSort($arrStatusBuckets);
$fnSort($arrGroupBuckets);
$fnSort($arrSourceBuckets);
$fnSort($arrTemplateBuckets);
$fnSort($arrActionKindBuckets);
return [
'total_count' => count($arrHistory),
'applied_count' => count(array_filter($arrHistory, static fn(array $arrItem): bool => (string)($arrItem['status'] ?? '') === 'applied')),
'dry_run_count' => count(array_filter($arrHistory, static fn(array $arrItem): bool => (string)($arrItem['status'] ?? '') === 'dry_run')),
'restore_supported_count' => count(array_filter($arrHistory, static fn(array $arrItem): bool => (bool)($arrItem['restore_supported'] ?? false))),
'template_preview_count' => count(array_filter($arrHistory, static fn(array $arrItem): bool => (string)($arrItem['action_kind'] ?? '') === 'template_preview')),
'template_apply_count' => count(array_filter($arrHistory, static fn(array $arrItem): bool => (string)($arrItem['action_kind'] ?? '') === 'template_apply')),
'restore_preview_count' => count(array_filter($arrHistory, static fn(array $arrItem): bool => (string)($arrItem['action_kind'] ?? '') === 'history_restore_preview')),
'restore_apply_count' => count(array_filter($arrHistory, static fn(array $arrItem): bool => (string)($arrItem['action_kind'] ?? '') === 'history_restore_apply')),
'latest_action_at' => (string)($arrHistory[0]['action_at'] ?? ''),
'status_buckets' => $arrStatusBuckets,
'group_buckets' => $arrGroupBuckets,
'source_buckets' => $arrSourceBuckets,
'template_buckets' => $arrTemplateBuckets,
'action_kind_buckets' => $arrActionKindBuckets,
];
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapExecutionHelper
{
public static function inferBundleRoot(string $strSamplePath): string
{
$strBasename = basename($strSamplePath);
if (!in_array($strBasename, ['site-register.sample.json', 'site-bootstrap.sample.json'], true)) {
return '';
}
$strBundleRoot = dirname($strSamplePath);
return is_file(rtrim($strBundleRoot, '/') . '/bootstrap-summary.json') ? $strBundleRoot : '';
}
public static function loadBundleSummary(string $strBundleRoot): array
{
$strSummaryPath = rtrim($strBundleRoot, '/') . '/bootstrap-summary.json';
if (!is_file($strSummaryPath)) {
return [];
}
$arrSummary = json_decode((string)file_get_contents($strSummaryPath), true);
return is_array($arrSummary) ? $arrSummary : [];
}
public static function persistResult(
string $strBundleRoot,
string $strKind,
array $arrResult,
?string $strIndexRoot = null,
?string $strPortalRoot = null
): array {
$strResultKey = $strKind === 'register' ? 'site_register_result' : 'site_apply_result';
$strFileKey = $strKind === 'register' ? 'site_register_result' : 'site_apply_result';
$strResultPath = $strBundleRoot . '/' . ($strKind === 'register' ? 'site-register.result.json' : 'site-apply.result.json');
return self::persistMeta(
$strBundleRoot,
$strResultKey,
$strFileKey,
$strResultPath,
$arrResult,
$strIndexRoot,
$strPortalRoot
);
}
public static function persistReleaseDryRunResult(
string $strBundleRoot,
array $arrResult,
?string $strIndexRoot = null,
?string $strPortalRoot = null
): array {
return self::persistMeta(
$strBundleRoot,
'release_dry_run',
'release_dry_run_summary',
(string)($arrResult['run_summary_path'] ?? ''),
$arrResult,
$strIndexRoot,
$strPortalRoot
);
}
public static function persistPrepareResult(
string $strBundleRoot,
array $arrResult,
?string $strIndexRoot = null,
?string $strPortalRoot = null
): array {
return self::persistMeta(
$strBundleRoot,
'prepare_result',
'prepare_summary',
(string)($arrResult['summary_path'] ?? ''),
$arrResult,
$strIndexRoot,
$strPortalRoot
);
}
public static function persistImportResult(
string $strBundleRoot,
array $arrResult,
?string $strIndexRoot = null,
?string $strPortalRoot = null
): array {
return self::persistMeta(
$strBundleRoot,
'import_result',
'import_result',
$strBundleRoot . '/batch-import.result.json',
$arrResult,
$strIndexRoot,
$strPortalRoot
);
}
public static function persistPublishResult(
string $strBundleRoot,
array $arrResult,
?string $strIndexRoot = null,
?string $strPortalRoot = null
): array {
return self::persistMeta(
$strBundleRoot,
'publish_result',
'publish_result',
$strBundleRoot . '/release-publish.result.json',
$arrResult,
$strIndexRoot,
$strPortalRoot
);
}
protected static function persistMeta(
string $strBundleRoot,
string $strSummaryKey,
string $strFileKey,
string $strResultPath,
array $arrResult,
?string $strIndexRoot = null,
?string $strPortalRoot = null
): array {
$strBundleRoot = rtrim($strBundleRoot, '/');
if ($strBundleRoot === '' || !is_dir($strBundleRoot)) {
return [];
}
$arrSummary = self::loadBundleSummary($strBundleRoot);
if (empty($arrSummary)) {
return [];
}
$strResolvedScanRoot = trim((string)$strIndexRoot);
if ($strResolvedScanRoot === '') {
$strResolvedScanRoot = trim((string)($arrSummary['bundle_index']['scan_root'] ?? ''));
}
if ($strResolvedScanRoot === '') {
$strResolvedScanRoot = dirname($strBundleRoot);
}
$strResolvedPortalRoot = trim((string)$strPortalRoot);
if ($strResolvedPortalRoot === '') {
$strResolvedPortalRoot = trim((string)($arrSummary['bundle_portal']['root'] ?? ''));
}
$arrSummary[$strSummaryKey] = $arrResult;
if ($strResultPath !== '') {
$arrSummary['bundle_files'][$strFileKey] = $strResultPath;
}
$arrSummary['bundle_index']['scan_root'] = $strResolvedScanRoot;
$arrSummary['bundle_portal']['root'] = $strResolvedPortalRoot;
if ($strResultPath !== '') {
self::writeJson($strResultPath, $arrResult);
}
self::writeJson($strBundleRoot . '/bootstrap-summary.json', $arrSummary);
$arrArtifacts = [
'result_path' => $strResultPath,
'summary_path' => $strBundleRoot . '/bootstrap-summary.json',
];
if ($strResolvedScanRoot !== '') {
$arrBundleIndexSummary = DomainBootstrapBundleIndexHelper::buildSummary($strResolvedScanRoot, true);
$arrArtifacts['bundle_index'] = DomainBootstrapBundleIndexHelper::writeArtifacts(rtrim($strResolvedScanRoot, '/') . '/_index', $arrBundleIndexSummary);
$arrRunIndexSummary = self::loadCachedBootstrapRunSummary($strResolvedScanRoot, $strResolvedPortalRoot);
$arrHandoffSummary = DomainBootstrapHandoffHelper::buildSummary($arrBundleIndexSummary, $arrRunIndexSummary);
$arrDutySummary = DomainBootstrapDutyHelper::buildSummary($arrHandoffSummary);
$arrOncallSummary = DomainBootstrapOncallHelper::buildSummary($arrDutySummary);
$arrArtifacts['bundle_handoff'] = DomainBootstrapHandoffHelper::writeArtifacts(rtrim($strResolvedScanRoot, '/') . '/_handoff', $arrHandoffSummary);
$arrArtifacts['bundle_duty'] = DomainBootstrapDutyHelper::writeArtifacts(rtrim($strResolvedScanRoot, '/') . '/_duty', $arrDutySummary);
$arrArtifacts['bundle_oncall'] = DomainBootstrapOncallHelper::writeArtifacts(rtrim($strResolvedScanRoot, '/') . '/_oncall', $arrOncallSummary);
if ($strResolvedPortalRoot !== '' && strtolower($strResolvedPortalRoot) !== 'off') {
$arrArtifacts['bundle_portal'] = DomainBootstrapBundleIndexHelper::writeArtifacts(rtrim($strResolvedPortalRoot, '/') . '/bootstrap', $arrBundleIndexSummary);
$arrArtifacts['bundle_handoff_portal'] = DomainBootstrapHandoffHelper::writeArtifacts(rtrim($strResolvedPortalRoot, '/') . '/bootstrap/handoff', $arrHandoffSummary);
$arrArtifacts['bundle_duty_portal'] = DomainBootstrapDutyHelper::writeArtifacts(rtrim($strResolvedPortalRoot, '/') . '/bootstrap/duty', $arrDutySummary);
$arrArtifacts['bundle_oncall_portal'] = DomainBootstrapOncallHelper::writeArtifacts(rtrim($strResolvedPortalRoot, '/') . '/bootstrap/oncall', $arrOncallSummary);
SeoCopyPortalHomeHelper::writeArtifacts($strResolvedPortalRoot, SeoCopyPortalHomeHelper::buildSummary($strResolvedPortalRoot));
}
} elseif ($strResolvedPortalRoot !== '' && strtolower($strResolvedPortalRoot) !== 'off') {
SeoCopyPortalHomeHelper::writeArtifacts($strResolvedPortalRoot, SeoCopyPortalHomeHelper::buildSummary($strResolvedPortalRoot));
}
return $arrArtifacts;
}
protected static function writeJson(string $strPath, array $arrData): void
{
$strDir = dirname($strPath);
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
file_put_contents(
$strPath,
json_encode($arrData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
}
protected static function loadCachedBootstrapRunSummary(string $strScanRoot, string $strPortalRoot): array
{
$arrCandidates = [];
if ($strScanRoot !== '') {
$arrCandidates[] = rtrim($strScanRoot, '/') . '/_runs/index.json';
}
if ($strPortalRoot !== '' && strtolower($strPortalRoot) !== 'off') {
$arrCandidates[] = rtrim($strPortalRoot, '/') . '/bootstrap/runs/index.json';
}
foreach ($arrCandidates as $strPath) {
if (!is_file($strPath)) {
continue;
}
$arrSummary = json_decode((string)file_get_contents($strPath), true);
if (is_array($arrSummary)) {
return $arrSummary;
}
}
return [
'runs' => [],
'runs_count' => 0,
];
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapHandoffHelper
{
public static function buildSummary(array $arrBundleIndex, array $arrRunIndex): array
{
$arrBundles = array_values(array_filter((array)($arrBundleIndex['bundles'] ?? []), 'is_array'));
$arrRuns = array_values(array_filter((array)($arrRunIndex['runs'] ?? []), 'is_array'));
$arrLatestBundles = self::pickLatestByHost($arrBundles, 'generated_at');
$arrLatestRuns = self::pickLatestByHost($arrRuns, 'updated_at');
$arrHosts = array_unique(array_filter(array_merge(array_keys($arrLatestBundles), array_keys($arrLatestRuns))));
sort($arrHosts);
$arrEntries = [];
foreach ($arrHosts as $strHost) {
$arrEntries[] = self::buildEntry(
$strHost,
(array)($arrLatestBundles[$strHost] ?? []),
(array)($arrLatestRuns[$strHost] ?? [])
);
}
usort($arrEntries, static function (array $arrA, array $arrB): int {
return strcmp((string)($arrB['updated_at'] ?? ''), (string)($arrA['updated_at'] ?? ''));
});
return [
'generated_at' => date(DATE_ATOM),
'entries' => $arrEntries,
'hosts_count' => count($arrEntries),
'pipeline_import_policy' => (array)($arrBundleIndex['pipeline_import_policy'] ?? []),
'handoff_ready_to_register_count' => self::countByCode($arrEntries, 'ready_to_register'),
'handoff_ready_to_apply_count' => self::countByCode($arrEntries, 'ready_to_apply'),
'handoff_ready_for_prepare_count' => self::countByCode($arrEntries, 'ready_for_prepare'),
'handoff_ready_to_import_count' => self::countByCode($arrEntries, 'ready_to_import'),
'handoff_ready_to_release_dry_run_count' => self::countByCode($arrEntries, 'ready_to_release_dry_run'),
'handoff_ready_to_publish_count' => self::countByCode($arrEntries, 'ready_to_publish'),
'handoff_attention_count' => self::countByCode($arrEntries, 'needs_attention'),
'latest_entry' => $arrEntries[0] ?? null,
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
self::ensureDir($strOutputRoot);
$strJsonPath = rtrim($strOutputRoot, '/') . '/index.json';
$strHtmlPath = rtrim($strOutputRoot, '/') . '/index.html';
file_put_contents(
$strJsonPath,
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return [
'json' => $strJsonPath,
'html' => $strHtmlPath,
];
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['entries'] ?? []) as $arrEntry) {
if (!is_array($arrEntry)) {
continue;
}
$arrLinks = [];
if ((string)($arrEntry['bundle_detail_slug'] ?? '') !== '') {
$arrLinks[] = '<a href="../details/' . rawurlencode((string)($arrEntry['bundle_detail_slug'] ?? '')) . '.html">bundle</a>';
}
if ((string)($arrEntry['run_detail_slug'] ?? '') !== '') {
$arrLinks[] = '<a href="../runs/details/' . rawurlencode((string)($arrEntry['run_detail_slug'] ?? '')) . '.html">run</a>';
}
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrEntry['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['source_label'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['summary'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['next_step'] ?? '')) . '</td>'
. '<td><code>' . self::escape((string)($arrEntry['next_command'] ?? '')) . '</code></td>'
. '<td>' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '</td>'
. '<td>' . implode(' / ', $arrLinks) . '</td>'
. '</tr>';
}
return implode(PHP_EOL, [
'<!doctype html>',
'<html lang="zh-CN">',
'<head>',
'<meta charset="utf-8">',
'<title>Bootstrap Handoff Queue</title>',
'<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{word-break:break-all;white-space:pre-wrap;}a{color:#0b57d0;text-decoration:none;}</style>',
'</head>',
'<body>',
'<p><a href="../index.html">返回启动包总览</a> | <a href="../completed/index.html">查看已完成启动包</a> | <a href="../runs/index.html">查看启动包运行记录</a> | <a href="../../index.html">返回统一门户首页</a> | <a href="../../ops/index.html#bootstrap-sop">查看新域名上线 SOP</a></p>',
'<h1>Bootstrap Handoff Queue</h1>',
'<p>hosts_count: <strong>' . (int)($arrSummary['hosts_count'] ?? 0) . '</strong></p>',
'<p>handoff: <strong>待入库 ' . (int)($arrSummary['handoff_ready_to_register_count'] ?? 0) . '</strong> / <strong>待应用 ' . (int)($arrSummary['handoff_ready_to_apply_count'] ?? 0) . '</strong> / <strong>可进 prepare ' . (int)($arrSummary['handoff_ready_for_prepare_count'] ?? 0) . '</strong> / <strong>待导入 ' . (int)($arrSummary['handoff_ready_to_import_count'] ?? 0) . '</strong> / <strong>待跑 dry-run ' . (int)($arrSummary['handoff_ready_to_release_dry_run_count'] ?? 0) . '</strong> / <strong>待发布 ' . (int)($arrSummary['handoff_ready_to_publish_count'] ?? 0) . '</strong> / <strong>需关注 ' . (int)($arrSummary['handoff_attention_count'] ?? 0) . '</strong></p>',
'<table>',
'<thead><tr><th>host</th><th>handoff</th><th>source</th><th>summary</th><th>next_step</th><th>next_command</th><th>updated_at</th><th>links</th></tr></thead>',
'<tbody>',
...$arrRows,
'</tbody>',
'</table>',
'</body>',
'</html>',
]) . PHP_EOL;
}
protected static function buildEntry(string $strHost, array $arrBundle, array $arrRun): array
{
$strBundleAt = (string)($arrBundle['generated_at'] ?? '');
$strRunAt = (string)($arrRun['updated_at'] ?? '');
$boolUseRun = $strRunAt !== '' && ($strBundleAt === '' || strcmp($strRunAt, $strBundleAt) >= 0);
$arrSource = $boolUseRun ? $arrRun : $arrBundle;
return [
'host' => $strHost,
'source' => $boolUseRun ? 'run' : 'bundle',
'source_label' => $boolUseRun ? '最新 run' : '最新 bundle',
'handoff_stage_code' => (string)($arrSource['handoff_stage_code'] ?? ''),
'handoff_stage_label' => (string)($arrSource['handoff_stage_label'] ?? ''),
'summary' => (string)(($arrSource['handoff_summary'] ?? '') ?: ($arrSource['summary'] ?? '')),
'next_step' => (string)($arrSource['handoff_next_step'] ?? ''),
'next_command' => (string)($arrSource['handoff_next_command'] ?? ''),
'updated_at' => $boolUseRun ? $strRunAt : $strBundleAt,
'bundle_detail_slug' => (string)($arrBundle['detail_slug'] ?? ''),
'run_detail_slug' => (string)($arrRun['detail_slug'] ?? ''),
];
}
protected static function pickLatestByHost(array $arrItems, string $strTimeField): array
{
$arrMapped = [];
foreach ($arrItems as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$strHost = trim((string)($arrItem['host'] ?? ''));
if ($strHost === '') {
continue;
}
if (!isset($arrMapped[$strHost]) || strcmp((string)($arrItem[$strTimeField] ?? ''), (string)($arrMapped[$strHost][$strTimeField] ?? '')) >= 0) {
$arrMapped[$strHost] = $arrItem;
}
}
return $arrMapped;
}
protected static function countByCode(array $arrEntries, string $strCode): int
{
$intCount = 0;
foreach ($arrEntries as $arrEntry) {
if (!is_array($arrEntry)) {
continue;
}
if ((string)($arrEntry['handoff_stage_code'] ?? '') === $strCode) {
$intCount++;
}
}
return $intCount;
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapImportHelper
{
public static function runBundleImport(string $strInput, string $strSourceDir, array $arrOptions = []): array
{
$strBundleRoot = DomainBootstrapRunHelper::resolveBundleRoot($strInput);
if ($strBundleRoot === '') {
throw new \RuntimeException('Bundle root not found from input: ' . $strInput);
}
$arrBundleSummary = DomainBootstrapRunHelper::loadBundleSummary($strBundleRoot);
if (empty($arrBundleSummary)) {
throw new \RuntimeException('bootstrap-summary.json not found in bundle: ' . $strBundleRoot);
}
$strTargetRoot = trim((string)($arrBundleSummary['target_root'] ?? ($strBundleRoot . '/data/seo_copy')));
if ($strTargetRoot === '') {
throw new \RuntimeException('target_root not found in bundle summary.');
}
$strIndexRoot = trim((string)($arrOptions['index_root'] ?? ''));
if ($strIndexRoot === '') {
$strIndexRoot = trim((string)($arrBundleSummary['bundle_index']['scan_root'] ?? ''));
}
$strPortalRoot = trim((string)($arrOptions['portal_root'] ?? ''));
if ($strPortalRoot === '') {
$strPortalRoot = trim((string)($arrBundleSummary['bundle_portal']['root'] ?? ''));
}
$arrImportSummary = SeoCopyBatchImportHelper::importFromSource($strSourceDir, $strTargetRoot);
$intImportedCount = (int)($arrImportSummary['imported_count'] ?? count((array)($arrImportSummary['imported'] ?? [])));
$intSkippedCount = (int)($arrImportSummary['skipped_count'] ?? count((array)($arrImportSummary['skipped'] ?? [])));
$strStatus = 'no_files_imported';
if ($intImportedCount > 0 && $intSkippedCount === 0) {
$strStatus = 'imported';
} elseif ($intImportedCount > 0) {
$strStatus = 'partially_imported';
} elseif ($intSkippedCount > 0) {
$strStatus = 'import_failed';
}
$arrResult = [
'status' => $strStatus,
'executed_at' => date(DATE_ATOM),
'source_dir' => (string)($arrImportSummary['source_dir'] ?? $strSourceDir),
'target_root' => $strTargetRoot,
'imported_count' => $intImportedCount,
'skipped_count' => $intSkippedCount,
'imported' => (array)($arrImportSummary['imported'] ?? []),
'skipped' => (array)($arrImportSummary['skipped'] ?? []),
'message' => self::buildMessage($strStatus, $intImportedCount, $intSkippedCount),
];
$arrArtifacts = DomainBootstrapExecutionHelper::persistImportResult(
$strBundleRoot,
$arrResult,
$strIndexRoot !== '' ? $strIndexRoot : null,
$strPortalRoot !== '' ? $strPortalRoot : null
);
$arrResult['bundle_root'] = $strBundleRoot;
$arrResult['bundle_artifacts'] = $arrArtifacts;
return $arrResult;
}
protected static function buildMessage(string $strStatus, int $intImportedCount, int $intSkippedCount): string
{
return match ($strStatus) {
'imported' => 'AI 结果已完整导入,下一步可以执行 release dry-run。',
'partially_imported' => 'AI 结果已部分导入,建议先查看 skipped 明细,再决定是否执行 release dry-run。',
'import_failed' => "没有文件成功导入,当前 skipped={$intSkippedCount},建议先修正 AI 结果目录结构或字段。",
default => "没有检测到可导入文件,当前 imported={$intImportedCount},建议先确认 AI 结果目录是否正确。",
};
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapOncallHelper
{
public static function buildSummary(array $arrDutySummary): array
{
$arrEntries = array_values(array_filter((array)($arrDutySummary['entries'] ?? []), 'is_array'));
$arrOwnerFocus = [];
foreach ($arrEntries as $arrEntry) {
$strOwner = (string)($arrEntry['owner_label'] ?? '未分配');
if (!isset($arrOwnerFocus[$strOwner])) {
$arrOwnerFocus[$strOwner] = [
'owner_label' => $strOwner,
'host' => (string)($arrEntry['host'] ?? ''),
'priority_label' => (string)($arrEntry['priority_label'] ?? ''),
'todo_title' => (string)($arrEntry['todo_title'] ?? ''),
'summary' => (string)($arrEntry['summary'] ?? ''),
'next_command' => (string)($arrEntry['next_command'] ?? ''),
'bundle_detail_slug' => (string)($arrEntry['bundle_detail_slug'] ?? ''),
'run_detail_slug' => (string)($arrEntry['run_detail_slug'] ?? ''),
'updated_at' => (string)($arrEntry['updated_at'] ?? ''),
];
}
}
return [
'generated_at' => date(DATE_ATOM),
'open_count' => (int)($arrDutySummary['open_count'] ?? count($arrEntries)),
'pipeline_import_policy' => (array)($arrDutySummary['pipeline_import_policy'] ?? []),
'priority_p0_count' => (int)($arrDutySummary['priority_p0_count'] ?? 0),
'priority_p1_count' => (int)($arrDutySummary['priority_p1_count'] ?? 0),
'priority_p2_count' => (int)($arrDutySummary['priority_p2_count'] ?? 0),
'priority_p3_count' => (int)($arrDutySummary['priority_p3_count'] ?? 0),
'latest_entry' => (array)($arrDutySummary['latest_entry'] ?? []),
'top_entries' => array_slice($arrEntries, 0, 5),
'owner_focus' => array_values($arrOwnerFocus),
'recommended_commands' => [
'next_dry_run' => 'php scripts/domain_bootstrap_oncall_run.php --dry-run=1 --format=text',
'next_run' => 'php scripts/domain_bootstrap_oncall_run.php --dry-run=0 --format=text',
'dispatch_dry_run' => 'php scripts/domain_bootstrap_oncall_run.php --limit=2 --dry-run=1 --format=text',
'dispatch_run' => 'php scripts/domain_bootstrap_oncall_run.php --limit=2 --dry-run=0 --format=text',
'pipeline_dry_run' => 'php scripts/domain_bootstrap_pipeline_run.php --step-limit=6 --dry-run=1 --allow-prepare=1 --format=text',
'pipeline_run' => 'php scripts/domain_bootstrap_pipeline_run.php --step-limit=6 --dry-run=0 --allow-prepare=1 --allow-release-dry-run=1 --format=text',
'pipeline_import' => 'php scripts/domain_bootstrap_pipeline_run.php --step-limit=3 --dry-run=0 --allow-import=1 --ai-result-dir=<ai-result-dir> --allow-release-dry-run=1 --format=text',
],
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
self::ensureDir($strOutputRoot);
$strJsonPath = rtrim($strOutputRoot, '/') . '/index.json';
$strHtmlPath = rtrim($strOutputRoot, '/') . '/index.html';
file_put_contents(
$strJsonPath,
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return [
'json' => $strJsonPath,
'html' => $strHtmlPath,
];
}
protected static function renderHtml(array $arrSummary): string
{
$arrFocusCards = [];
foreach ((array)($arrSummary['owner_focus'] ?? []) as $arrFocus) {
if (!is_array($arrFocus)) {
continue;
}
$arrLinks = [];
if ((string)($arrFocus['bundle_detail_slug'] ?? '') !== '') {
$arrLinks[] = '<a href="../details/' . rawurlencode((string)($arrFocus['bundle_detail_slug'] ?? '')) . '.html">bundle</a>';
}
if ((string)($arrFocus['run_detail_slug'] ?? '') !== '') {
$arrLinks[] = '<a href="../runs/details/' . rawurlencode((string)($arrFocus['run_detail_slug'] ?? '')) . '.html">run</a>';
}
$arrFocusCards[] = '<div class="focus-card">'
. '<strong>' . self::escape((string)($arrFocus['owner_label'] ?? '')) . '</strong>'
. '<p>host: ' . self::escape((string)($arrFocus['host'] ?? '')) . '</p>'
. '<p>priority: ' . self::escape((string)($arrFocus['priority_label'] ?? '')) . '</p>'
. '<p>todo: ' . self::escape((string)($arrFocus['todo_title'] ?? '')) . '</p>'
. '<p>' . self::escape((string)($arrFocus['summary'] ?? '')) . '</p>'
. '<p><code>' . self::escape((string)($arrFocus['next_command'] ?? '')) . '</code></p>'
. '<p>' . implode(' / ', $arrLinks) . '</p>'
. '</div>';
}
$arrRows = [];
foreach ((array)($arrSummary['top_entries'] ?? []) as $arrEntry) {
if (!is_array($arrEntry)) {
continue;
}
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrEntry['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['priority_label'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['owner_label'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrEntry['todo_title'] ?? '')) . '</td>'
. '<td><code>' . self::escape((string)($arrEntry['next_command'] ?? '')) . '</code></td>'
. '<td>' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '</td>'
. '</tr>';
}
$arrLatest = (array)($arrSummary['latest_entry'] ?? []);
$strLatest = trim((string)($arrLatest['host'] ?? '')) !== ''
? '<p><strong>当前优先待办:</strong> '
. self::escape((string)($arrLatest['host'] ?? ''))
. ' / '
. self::escape((string)($arrLatest['priority_label'] ?? ''))
. ' / '
. self::escape((string)($arrLatest['todo_title'] ?? ''))
. ' / <code>' . self::escape((string)($arrLatest['next_command'] ?? '')) . '</code></p>'
: '';
$arrImportPolicy = (array)($arrSummary['pipeline_import_policy'] ?? []);
$strImportPolicy = '<p><strong>Import Policy:</strong> '
. self::escape((string)($arrImportPolicy['mode'] ?? 'off'))
. ' / requested=' . (((bool)($arrImportPolicy['requested'] ?? false)) ? 'yes' : 'no')
. ' / armed=' . (((bool)($arrImportPolicy['armed'] ?? false)) ? 'yes' : 'no')
. ' / effective=' . (((bool)($arrImportPolicy['enabled'] ?? false)) ? 'yes' : 'no')
. ' / <code>' . self::escape((string)($arrImportPolicy['source_path'] ?? '')) . '</code>'
. ' / '
. self::escape((string)($arrImportPolicy['summary'] ?? ''))
. '</p>';
$arrGlobalCommands = [];
foreach ((array)($arrSummary['recommended_commands'] ?? []) as $strKey => $strCommand) {
$arrGlobalCommands[] = '<tr><td>' . self::escape((string)$strKey) . '</td><td><code>' . self::escape((string)$strCommand) . '</code></td></tr>';
}
return implode(PHP_EOL, [
'<!doctype html>',
'<html lang="zh-CN">',
'<head>',
'<meta charset="utf-8">',
'<title>Bootstrap Oncall Desk</title>',
'<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{word-break:break-all;white-space:pre-wrap;}a{color:#0b57d0;text-decoration:none;}.focus-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px;margin:16px 0 24px;}.focus-card{border:1px solid #ddd;padding:12px;background:#fff;}.focus-card strong{display:block;margin-bottom:8px;}</style>',
'</head>',
'<body>',
'<p><a href="../index.html">返回启动包总览</a> | <a href="../duty/index.html">查看值班待办</a> | <a href="../handoff/index.html">查看启动包接手队列</a> | <a href="../completed/index.html">查看已完成启动包</a> | <a href="../runs/index.html">查看启动包运行记录</a> | <a href="../../index.html">返回统一门户首页</a> | <a href="../../ops/index.html#bootstrap-sop">查看新域名上线 SOP</a></p>',
'<h1>Bootstrap Oncall Desk</h1>',
'<p>open_count: <strong>' . (int)($arrSummary['open_count'] ?? 0) . '</strong></p>',
'<p>priority: <strong>P0 ' . (int)($arrSummary['priority_p0_count'] ?? 0) . '</strong> / <strong>P1 ' . (int)($arrSummary['priority_p1_count'] ?? 0) . '</strong> / <strong>P2 ' . (int)($arrSummary['priority_p2_count'] ?? 0) . '</strong> / <strong>P3 ' . (int)($arrSummary['priority_p3_count'] ?? 0) . '</strong></p>',
$strLatest,
$strImportPolicy,
'<h2>Global Commands</h2>',
'<table>',
'<thead><tr><th>step</th><th>command</th></tr></thead>',
'<tbody>',
...$arrGlobalCommands,
'</tbody>',
'</table>',
'<h2>Owner Focus</h2>',
'<div class="focus-grid">' . implode('', $arrFocusCards) . '</div>',
'<h2>Top Todo</h2>',
'<table>',
'<thead><tr><th>host</th><th>priority</th><th>owner</th><th>handoff</th><th>todo</th><th>next_command</th><th>updated_at</th></tr></thead>',
'<tbody>',
...$arrRows,
'</tbody>',
'</table>',
'</body>',
'</html>',
]) . PHP_EOL;
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}

View File

@@ -0,0 +1,488 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use think\App;
class DomainBootstrapOncallService
{
public static function dispatch(array $arrOptions = []): array
{
$intLimit = max(1, (int)($arrOptions['limit'] ?? 1));
$arrSeen = [];
$arrExecutions = [];
$arrInitialContext = self::buildContext($arrOptions);
$arrInitialOncall = (array)($arrInitialContext['oncall_summary'] ?? []);
for ($intIndex = 0; $intIndex < $intLimit; $intIndex++) {
$arrContext = self::buildContext($arrOptions);
$arrEntry = self::selectEntry($arrContext, $arrOptions, $arrSeen);
if (empty($arrEntry)) {
break;
}
$strSignature = self::buildEntrySignature($arrEntry);
$arrSeen[$strSignature] = true;
$arrResult = self::execute($arrEntry, $arrContext, $arrOptions);
$arrExecutions[] = [
'selected_entry' => $arrEntry,
'result' => $arrResult,
];
}
$arrFinalContext = self::buildContext($arrOptions);
$arrFinalOncall = (array)($arrFinalContext['oncall_summary'] ?? []);
$arrLatestEntry = (array)($arrFinalOncall['latest_entry'] ?? []);
$arrFirstExecution = (array)($arrExecutions[0] ?? []);
$arrFirstEntry = (array)($arrFirstExecution['selected_entry'] ?? []);
$arrFirstResult = (array)($arrFirstExecution['result'] ?? []);
return [
'generated_at' => date(DATE_ATOM),
'limit' => $intLimit,
'processed_count' => count($arrExecutions),
'open_count_before' => (int)($arrInitialOncall['open_count'] ?? 0),
'open_count_after' => (int)($arrFinalOncall['open_count'] ?? 0),
'selected_host' => (string)($arrFirstEntry['host'] ?? ''),
'selected_priority' => (string)($arrFirstEntry['priority_label'] ?? ''),
'selected_owner' => (string)($arrFirstEntry['owner_label'] ?? ''),
'selected_todo' => (string)($arrFirstEntry['todo_title'] ?? ''),
'execution_status' => (string)($arrFirstResult['status'] ?? 'no_entry'),
'latest_host_after' => (string)($arrLatestEntry['host'] ?? ''),
'latest_priority_after' => (string)($arrLatestEntry['priority_label'] ?? ''),
'latest_todo_after' => (string)($arrLatestEntry['todo_title'] ?? ''),
'executions' => array_map(static function (array $arrExecution): array {
$arrEntry = (array)($arrExecution['selected_entry'] ?? []);
$arrResult = (array)($arrExecution['result'] ?? []);
return [
'host' => (string)($arrEntry['host'] ?? ''),
'priority_label' => (string)($arrEntry['priority_label'] ?? ''),
'owner_label' => (string)($arrEntry['owner_label'] ?? ''),
'todo_title' => (string)($arrEntry['todo_title'] ?? ''),
'handoff_stage_code' => (string)($arrEntry['handoff_stage_code'] ?? ''),
'handoff_stage_label' => (string)($arrEntry['handoff_stage_label'] ?? ''),
'execution_status' => (string)($arrResult['status'] ?? ''),
'run_json' => (string)($arrResult['run_json'] ?? ''),
'prepare_out_dir' => (string)($arrResult['prepare_out_dir'] ?? ''),
'import_status' => (string)($arrResult['import_status'] ?? ''),
'import_source_dir' => (string)($arrResult['import_source_dir'] ?? ''),
'release_run_status' => (string)($arrResult['release_run_status'] ?? ''),
'release_run_dir' => (string)($arrResult['release_run_dir'] ?? ''),
'release_run_summary_path' => (string)($arrResult['release_run_summary_path'] ?? ''),
'message' => (string)($arrResult['message'] ?? ''),
'recommended_command' => (string)($arrResult['recommended_command'] ?? ''),
];
}, $arrExecutions),
];
}
public static function buildContext(array $arrOptions = []): array
{
$strBundleRoot = rtrim((string)($arrOptions['bundle_root'] ?? dirname(__DIR__, 3) . '/storage/domain_bootstrap_bundles'), '/');
$strRunRoot = rtrim((string)($arrOptions['run_root'] ?? dirname(__DIR__, 3) . '/storage/domain_bootstrap_runs'), '/');
$strPortalRoot = trim((string)($arrOptions['portal_root'] ?? dirname(__DIR__, 3) . '/public/_seo_copy_release'));
$arrBundleSummary = DomainBootstrapBundleIndexHelper::buildSummary($strBundleRoot, true);
$arrRunSummary = DomainBootstrapRunIndexHelper::buildSummary($strRunRoot, true);
$arrHandoffSummary = DomainBootstrapHandoffHelper::buildSummary($arrBundleSummary, $arrRunSummary);
$arrDutySummary = DomainBootstrapDutyHelper::buildSummary($arrHandoffSummary);
$arrOncallSummary = DomainBootstrapOncallHelper::buildSummary($arrDutySummary);
return [
'bundle_root' => $strBundleRoot,
'run_root' => $strRunRoot,
'portal_root' => $strPortalRoot,
'bundle_summary' => $arrBundleSummary,
'run_summary' => $arrRunSummary,
'handoff_summary' => $arrHandoffSummary,
'duty_summary' => $arrDutySummary,
'oncall_summary' => $arrOncallSummary,
];
}
public static function selectEntry(array $arrContext, array $arrFilters = [], array $arrSkipSignatures = []): array
{
$arrEntries = array_values(array_filter((array)(($arrContext['duty_summary'] ?? [])['entries'] ?? []), 'is_array'));
$strHost = trim((string)($arrFilters['host'] ?? ''));
$strOwner = trim((string)($arrFilters['owner'] ?? ''));
$strPriority = strtoupper(trim((string)($arrFilters['priority'] ?? '')));
foreach ($arrEntries as $arrEntry) {
if ($strHost !== '' && (string)($arrEntry['host'] ?? '') !== $strHost) {
continue;
}
if ($strOwner !== '' && (string)($arrEntry['owner_label'] ?? '') !== $strOwner) {
continue;
}
if ($strPriority !== '' && strtoupper((string)($arrEntry['priority_label'] ?? '')) !== $strPriority) {
continue;
}
if (isset($arrSkipSignatures[self::buildEntrySignature($arrEntry)])) {
continue;
}
if (!self::isEntryRunnable($arrEntry, $arrFilters)) {
continue;
}
return $arrEntry;
}
return [];
}
public static function execute(array $arrEntry, array $arrContext, array $arrOptions = []): array
{
if (empty($arrEntry)) {
throw new \RuntimeException('No oncall entry selected.');
}
$boolDryRun = (bool)($arrOptions['dry_run'] ?? true);
$boolAllowAttention = (bool)($arrOptions['allow_attention'] ?? false);
$boolAllowPrepare = (bool)($arrOptions['allow_prepare'] ?? false);
$boolAllowImport = (bool)($arrOptions['allow_import'] ?? false);
$boolAllowReleaseDryRun = (bool)($arrOptions['allow_release_dry_run'] ?? false);
$boolAllowPublish = (bool)($arrOptions['allow_publish'] ?? false);
$strHost = (string)($arrEntry['host'] ?? '');
$strStageCode = (string)($arrEntry['handoff_stage_code'] ?? '');
$arrBundle = self::findBundleByHost((array)($arrContext['bundle_summary'] ?? []), $strHost);
if (empty($arrBundle)) {
throw new \RuntimeException('Bundle not found for host: ' . $strHost);
}
$strBundleRoot = trim((string)($arrBundle['bundle_root'] ?? ''));
$strRunRoot = trim((string)($arrContext['run_root'] ?? ''));
$strIndexRoot = trim((string)($arrContext['bundle_root'] ?? ''));
$strPortalRoot = trim((string)($arrContext['portal_root'] ?? ''));
$arrResult = [
'generated_at' => date(DATE_ATOM),
'host' => $strHost,
'handoff_stage_code' => $strStageCode,
'handoff_stage_label' => (string)($arrEntry['handoff_stage_label'] ?? ''),
'priority_label' => (string)($arrEntry['priority_label'] ?? ''),
'owner_label' => (string)($arrEntry['owner_label'] ?? ''),
'todo_title' => (string)($arrEntry['todo_title'] ?? ''),
'dry_run' => $boolDryRun,
'bundle_root' => $strBundleRoot,
'status' => 'pending',
];
if (
$strStageCode === 'ready_to_release_dry_run'
&& $boolAllowReleaseDryRun
) {
return self::executeReleaseDryRun($arrResult, $arrBundle, $strBundleRoot, $strIndexRoot, $strPortalRoot);
}
if (
in_array($strStageCode, ['needs_attention', 'pending_review'], true)
&& $boolAllowReleaseDryRun
&& in_array((string)($arrBundle['release_dry_run_status'] ?? ''), ['verify_failed', 'front_verify_failed', 'error'], true)
&& in_array((string)($arrBundle['apply_result_status'] ?? ''), ['applied', 'noop'], true)
) {
return self::executeReleaseDryRun($arrResult, $arrBundle, $strBundleRoot, $strIndexRoot, $strPortalRoot);
}
if (in_array($strStageCode, ['ready_to_register', 'ready_to_apply', 'ready_to_execute'], true)) {
$arrRunSummary = DomainBootstrapRunService::runBundle($strBundleRoot, [
'dry_run' => $boolDryRun,
'run_root' => $strRunRoot,
'index_root' => $strIndexRoot,
'portal_root' => $strPortalRoot,
]);
$arrResult['status'] = 'bootstrap_run_executed';
$arrResult['run_summary'] = $arrRunSummary;
$arrResult['run_json'] = (string)(($arrRunSummary['run_artifacts'] ?? [])['json'] ?? '');
return $arrResult;
}
if ($strStageCode === 'needs_attention' || $strStageCode === 'pending_review') {
if (!$boolAllowAttention) {
$arrResult['status'] = 'blocked_attention';
$arrResult['message'] = 'Current host requires manual review before execution.';
$arrResult['recommended_command'] = (string)($arrEntry['next_command'] ?? '');
return $arrResult;
}
$arrRunSummary = DomainBootstrapRunService::runBundle($strBundleRoot, [
'dry_run' => true,
'run_root' => $strRunRoot,
'index_root' => $strIndexRoot,
'portal_root' => $strPortalRoot,
]);
$arrResult['status'] = 'attention_dry_run_executed';
$arrResult['run_summary'] = $arrRunSummary;
$arrResult['run_json'] = (string)(($arrRunSummary['run_artifacts'] ?? [])['json'] ?? '');
return $arrResult;
}
if ($strStageCode === 'ready_for_prepare') {
if (!$boolAllowPrepare) {
$arrResult['status'] = 'blocked_prepare';
$arrResult['message'] = 'Current host is ready for prepare, but prepare execution is disabled.';
$arrResult['recommended_command'] = (string)($arrEntry['next_command'] ?? '');
return $arrResult;
}
(new App())->initialize();
$strManifestPath = trim((string)($arrBundle['manifest_path'] ?? ''));
$strApprovedDir = trim((string)($arrBundle['approved_dir'] ?? ''));
$arrPrepareSummary = SeoCopyBatchPrepareHelper::prepareManifest($strManifestPath, [
'approved_dir' => $strApprovedDir,
]);
$arrPrepareMeta = [
'status' => 'prepared',
'executed_at' => date(DATE_ATOM),
'manifest' => $strManifestPath,
'approved_dir' => $strApprovedDir,
'out_dir' => (string)($arrPrepareSummary['out_dir'] ?? ''),
'summary_path' => rtrim((string)($arrPrepareSummary['out_dir'] ?? ''), '/') . '/prepare-summary.json',
'message' => 'Prepare 已执行,下一步应导入 AI 结果后再跑 release dry-run。',
'recommended_command' => (string)(($arrBundle['next_commands'] ?? [])['import'] ?? ''),
];
$arrPrepareArtifacts = DomainBootstrapExecutionHelper::persistPrepareResult(
$strBundleRoot,
$arrPrepareMeta,
$strIndexRoot,
$strPortalRoot
);
$arrResult['status'] = 'prepare_executed';
$arrResult['prepare_summary'] = $arrPrepareSummary;
$arrResult['prepare_out_dir'] = (string)($arrPrepareSummary['out_dir'] ?? '');
$arrResult['prepare_artifacts'] = $arrPrepareArtifacts;
$arrResult['message'] = (string)$arrPrepareMeta['message'];
$arrResult['recommended_command'] = (string)$arrPrepareMeta['recommended_command'];
return $arrResult;
}
if ($strStageCode === 'ready_to_import') {
if (!$boolAllowImport) {
$arrResult['status'] = 'blocked_import';
$arrResult['message'] = 'Current host is ready to import AI results, but import execution is disabled.';
$arrResult['recommended_command'] = (string)($arrEntry['next_command'] ?? '');
return $arrResult;
}
if ($boolDryRun) {
$arrResult['status'] = 'blocked_import_dry_run';
$arrResult['message'] = 'AI import is a write step. Re-run with --dry-run=0 after confirming ai-result-dir/root.';
$arrResult['recommended_command'] = (string)($arrEntry['next_command'] ?? '');
return $arrResult;
}
$strAiResultDir = self::resolveAiResultDir($arrBundle, $arrOptions);
if ($strAiResultDir === '') {
$arrResult['status'] = 'blocked_import';
$arrResult['message'] = 'Current host is ready to import AI results, but ai-result-dir/root is not configured or no matching directory was found.';
$arrResult['recommended_command'] = (string)($arrEntry['next_command'] ?? '');
return $arrResult;
}
$arrImportSummary = DomainBootstrapImportHelper::runBundleImport($strBundleRoot, $strAiResultDir, [
'index_root' => $strIndexRoot,
'portal_root' => $strPortalRoot,
]);
$arrResult['status'] = in_array((string)($arrImportSummary['status'] ?? ''), ['imported', 'partially_imported'], true)
? 'import_executed'
: 'import_failed';
$arrResult['import_summary'] = $arrImportSummary;
$arrResult['import_status'] = (string)($arrImportSummary['status'] ?? '');
$arrResult['import_source_dir'] = (string)($arrImportSummary['source_dir'] ?? '');
$arrResult['import_artifacts'] = (array)($arrImportSummary['bundle_artifacts'] ?? []);
$arrResult['message'] = (string)($arrImportSummary['message'] ?? '');
$arrResult['recommended_command'] = in_array((string)($arrImportSummary['status'] ?? ''), ['imported', 'partially_imported'], true)
? (string)(($arrBundle['next_commands'] ?? [])['dry_run'] ?? '')
: (string)(($arrBundle['next_commands'] ?? [])['import'] ?? '');
return $arrResult;
}
if ($strStageCode === 'ready_to_publish') {
if (!$boolAllowPublish) {
$arrResult['status'] = 'blocked_publish';
$arrResult['message'] = 'Current host is ready to publish, but publish execution is disabled.';
$arrResult['recommended_command'] = (string)($arrEntry['next_command'] ?? '');
return $arrResult;
}
return self::executePublish($arrResult, $arrBundle, $strBundleRoot, $strIndexRoot, $strPortalRoot);
}
$arrResult['status'] = 'unsupported_stage';
$arrResult['message'] = 'Current handoff stage is not supported by oncall runner.';
$arrResult['recommended_command'] = (string)($arrEntry['next_command'] ?? '');
return $arrResult;
}
protected static function findBundleByHost(array $arrBundleSummary, string $strHost): array
{
foreach ((array)($arrBundleSummary['bundles'] ?? []) as $arrBundle) {
if (!is_array($arrBundle)) {
continue;
}
if ((string)($arrBundle['host'] ?? '') === $strHost) {
return $arrBundle;
}
}
return [];
}
protected static function buildEntrySignature(array $arrEntry): string
{
return implode('|', [
(string)($arrEntry['host'] ?? ''),
(string)($arrEntry['handoff_stage_code'] ?? ''),
(string)($arrEntry['todo_title'] ?? ''),
]);
}
protected static function isEntryRunnable(array $arrEntry, array $arrFilters): bool
{
$strStageCode = (string)($arrEntry['handoff_stage_code'] ?? '');
return match ($strStageCode) {
'needs_attention', 'pending_review' => (bool)($arrFilters['allow_attention'] ?? false),
'ready_for_prepare' => (bool)($arrFilters['allow_prepare'] ?? false),
'ready_to_import' => (bool)($arrFilters['allow_import'] ?? false),
'ready_to_release_dry_run' => (bool)($arrFilters['allow_release_dry_run'] ?? false),
'ready_to_publish' => (bool)($arrFilters['allow_publish'] ?? false),
default => true,
};
}
protected static function resolveAiResultDir(array $arrBundle, array $arrOptions): string
{
$strAiResultDir = rtrim(trim((string)($arrOptions['ai_result_dir'] ?? '')), '/');
if ($strAiResultDir !== '' && is_dir($strAiResultDir)) {
return $strAiResultDir;
}
$strAiResultRoot = rtrim(trim((string)($arrOptions['ai_result_root'] ?? '')), '/');
if ($strAiResultRoot === '' || !is_dir($strAiResultRoot)) {
return '';
}
$strHost = trim((string)($arrBundle['host'] ?? ''));
$strBundleRoot = trim((string)($arrBundle['bundle_root'] ?? ''));
$arrCandidates = array_filter([
$strBundleRoot !== '' ? $strAiResultRoot . '/' . basename($strBundleRoot) : '',
$strHost !== '' ? $strAiResultRoot . '/' . $strHost : '',
$strHost !== '' ? $strAiResultRoot . '/' . str_replace('.', '-', $strHost) : '',
$strHost !== '' ? $strAiResultRoot . '/' . str_replace('.', '_', $strHost) : '',
], static fn($strPath) => $strPath !== '');
foreach ($arrCandidates as $strCandidate) {
if (is_dir($strCandidate)) {
return $strCandidate;
}
}
return '';
}
protected static function executeReleaseDryRun(
array $arrResult,
array $arrBundle,
string $strBundleRoot,
string $strIndexRoot,
string $strPortalRoot
): array {
$arrReleaseDryRun = SeoCopyReleaseRunHelper::run([
'manifest_path' => (string)($arrBundle['manifest_path'] ?? ''),
'approved_dir' => (string)($arrBundle['approved_dir'] ?? ''),
'source_root' => (string)($arrBundle['target_root'] ?? ''),
'target_root' => (string)($arrBundle['target_root'] ?? ''),
'base_url' => (string)($arrBundle['base_url'] ?? ''),
'portal_root_arg' => $strPortalRoot,
'dry_run' => true,
]);
$arrReleaseSummary = (array)($arrReleaseDryRun['summary'] ?? []);
$strReleaseStatus = (string)($arrReleaseSummary['status'] ?? '');
$arrReleaseMeta = [
'status' => $strReleaseStatus,
'exit_code' => (int)($arrReleaseDryRun['exit_code'] ?? 0),
'dry_run' => true,
'executed_at' => date(DATE_ATOM),
'run_dir' => (string)($arrReleaseSummary['run_dir'] ?? ''),
'run_summary_path' => (string)(($arrReleaseSummary['artifacts'] ?? [])['run_summary'] ?? ''),
'manifest' => (string)($arrReleaseSummary['manifest'] ?? ''),
'source_root' => (string)($arrReleaseSummary['source_root'] ?? ''),
'message' => $strReleaseStatus === 'dry_run_passed'
? 'Prepare 与 release dry-run 已执行完成,下一步建议正式发布。'
: 'release dry-run 未通过,请先查看门禁摘要并修正后重试。',
'recommended_command' => $strReleaseStatus === 'dry_run_passed'
? (string)(($arrBundle['next_commands'] ?? [])['publish'] ?? '')
: (string)(($arrBundle['next_commands'] ?? [])['dry_run'] ?? ''),
];
$arrReleaseArtifacts = DomainBootstrapExecutionHelper::persistReleaseDryRunResult(
$strBundleRoot,
$arrReleaseMeta,
$strIndexRoot,
$strPortalRoot
);
$arrResult['status'] = $strReleaseStatus === 'dry_run_passed'
? 'release_dry_run_executed'
: 'release_dry_run_failed';
$arrResult['release_run_summary'] = $arrReleaseSummary;
$arrResult['release_run_status'] = $strReleaseStatus;
$arrResult['release_run_dir'] = (string)($arrReleaseMeta['run_dir'] ?? '');
$arrResult['release_run_summary_path'] = (string)($arrReleaseMeta['run_summary_path'] ?? '');
$arrResult['release_dry_run_artifacts'] = $arrReleaseArtifacts;
$arrResult['message'] = (string)$arrReleaseMeta['message'];
$arrResult['recommended_command'] = (string)$arrReleaseMeta['recommended_command'];
return $arrResult;
}
protected static function executePublish(
array $arrResult,
array $arrBundle,
string $strBundleRoot,
string $strIndexRoot,
string $strPortalRoot
): array {
$arrPublish = DomainBootstrapPublishHelper::runBundlePublish($strBundleRoot, [
'index_root' => $strIndexRoot,
'portal_root' => $strPortalRoot,
]);
$strPublishStatus = (string)($arrPublish['status'] ?? '');
$arrResult['status'] = $strPublishStatus === 'published'
? 'publish_executed'
: 'publish_failed';
$arrResult['publish_summary'] = $arrPublish;
$arrResult['publish_run_status'] = $strPublishStatus;
$arrResult['publish_run_dir'] = (string)($arrPublish['run_dir'] ?? '');
$arrResult['publish_run_summary_path'] = (string)($arrPublish['run_summary_path'] ?? '');
$arrResult['message'] = (string)($arrPublish['message'] ?? '');
$arrResult['recommended_command'] = $strPublishStatus === 'published'
? ''
: (string)(($arrBundle['next_commands'] ?? [])['publish'] ?? '');
return $arrResult;
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapPipelineHelper
{
public static function run(array $arrOptions = []): array
{
$intStepLimit = max(1, (int)($arrOptions['step_limit'] ?? 6));
$strRunRoot = rtrim((string)($arrOptions['pipeline_run_root'] ?? dirname(__DIR__, 3) . '/storage/domain_bootstrap_pipeline_runs'), '/');
$arrSeen = [];
$arrSteps = [];
$strStopReason = 'step_limit_reached';
$arrInitialContext = DomainBootstrapOncallService::buildContext($arrOptions);
$arrInitialOncall = (array)($arrInitialContext['oncall_summary'] ?? []);
for ($intIndex = 0; $intIndex < $intStepLimit; $intIndex++) {
$arrContext = DomainBootstrapOncallService::buildContext($arrOptions);
$arrEntry = DomainBootstrapOncallService::selectEntry($arrContext, $arrOptions, $arrSeen);
if (empty($arrEntry)) {
$strStopReason = 'no_runnable_entry';
break;
}
$strSignature = self::buildEntrySignature($arrEntry);
$arrSeen[$strSignature] = true;
$arrResult = DomainBootstrapOncallService::execute($arrEntry, $arrContext, $arrOptions);
$arrSteps[] = [
'step' => $intIndex + 1,
'host' => (string)($arrEntry['host'] ?? ''),
'priority_label' => (string)($arrEntry['priority_label'] ?? ''),
'owner_label' => (string)($arrEntry['owner_label'] ?? ''),
'handoff_stage_code' => (string)($arrEntry['handoff_stage_code'] ?? ''),
'handoff_stage_label' => (string)($arrEntry['handoff_stage_label'] ?? ''),
'todo_title' => (string)($arrEntry['todo_title'] ?? ''),
'execution_status' => (string)($arrResult['status'] ?? ''),
'message' => (string)($arrResult['message'] ?? ''),
'recommended_command' => (string)($arrResult['recommended_command'] ?? ''),
'run_json' => (string)($arrResult['run_json'] ?? ''),
'prepare_out_dir' => (string)($arrResult['prepare_out_dir'] ?? ''),
'import_status' => (string)($arrResult['import_status'] ?? ''),
'import_source_dir' => (string)($arrResult['import_source_dir'] ?? ''),
'release_run_status' => (string)($arrResult['release_run_status'] ?? ''),
'release_run_dir' => (string)($arrResult['release_run_dir'] ?? ''),
'release_run_summary_path' => (string)($arrResult['release_run_summary_path'] ?? ''),
'publish_run_status' => (string)($arrResult['publish_run_status'] ?? ''),
'publish_run_dir' => (string)($arrResult['publish_run_dir'] ?? ''),
'publish_run_summary_path' => (string)($arrResult['publish_run_summary_path'] ?? ''),
'executed_at' => date(DATE_ATOM),
];
}
$arrFinalContext = DomainBootstrapOncallService::buildContext($arrOptions);
$arrFinalOncall = (array)($arrFinalContext['oncall_summary'] ?? []);
$arrLatestEntry = (array)($arrFinalOncall['latest_entry'] ?? []);
$arrLatestCompleted = (array)(DomainBootstrapCompletedHelper::buildSummary((array)($arrFinalContext['bundle_summary'] ?? []))['latest_entry'] ?? []);
$arrSummary = [
'generated_at' => date(DATE_ATOM),
'status' => empty($arrSteps) ? 'idle' : 'completed',
'stop_reason' => $strStopReason,
'step_limit' => $intStepLimit,
'processed_count' => count($arrSteps),
'open_count_before' => (int)($arrInitialOncall['open_count'] ?? 0),
'open_count_after' => (int)($arrFinalOncall['open_count'] ?? 0),
'latest_host_after' => (string)($arrLatestEntry['host'] ?? ''),
'latest_priority_after' => (string)($arrLatestEntry['priority_label'] ?? ''),
'latest_todo_after' => (string)($arrLatestEntry['todo_title'] ?? ''),
'latest_completed_host' => (string)($arrLatestCompleted['host'] ?? ''),
'latest_completed_stage' => (string)($arrLatestCompleted['handoff_stage_label'] ?? ''),
'host_filter' => trim((string)($arrOptions['host'] ?? '')),
'dry_run' => (bool)($arrOptions['dry_run'] ?? true),
'allow_attention' => (bool)($arrOptions['allow_attention'] ?? false),
'allow_prepare' => (bool)($arrOptions['allow_prepare'] ?? false),
'allow_import' => (bool)($arrOptions['allow_import'] ?? false),
'allow_release_dry_run' => (bool)($arrOptions['allow_release_dry_run'] ?? false),
'allow_publish' => (bool)($arrOptions['allow_publish'] ?? false),
'ai_result_dir' => trim((string)($arrOptions['ai_result_dir'] ?? '')),
'ai_result_root' => trim((string)($arrOptions['ai_result_root'] ?? '')),
'steps' => $arrSteps,
];
$arrSummary['artifacts'] = self::writeRunSummary($strRunRoot, $arrSummary);
return $arrSummary;
}
protected static function writeRunSummary(string $strRunRoot, array $arrSummary): array
{
if ($strRunRoot === '') {
throw new \RuntimeException('Pipeline run root is required.');
}
$strDateDir = $strRunRoot . '/' . date('Ymd');
if (!is_dir($strDateDir) && !mkdir($strDateDir, 0777, true) && !is_dir($strDateDir)) {
throw new \RuntimeException('Failed to create pipeline run directory: ' . $strDateDir);
}
$strHostPart = trim((string)($arrSummary['host_filter'] ?? ''));
$strHostPart = $strHostPart !== '' ? '-' . self::slugify($strHostPart) : '';
$strRunName = date('His') . '_bootstrap-pipeline' . $strHostPart;
$strJsonPath = $strDateDir . '/' . $strRunName . '.json';
file_put_contents(
$strJsonPath,
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
return [
'run_root' => $strRunRoot,
'run_date' => date('Ymd'),
'run_name' => $strRunName,
'json' => $strJsonPath,
];
}
protected static function buildEntrySignature(array $arrEntry): string
{
return implode('|', [
(string)($arrEntry['host'] ?? ''),
(string)($arrEntry['handoff_stage_code'] ?? ''),
(string)($arrEntry['todo_title'] ?? ''),
]);
}
protected static function slugify(string $strValue): string
{
$strValue = strtolower(trim($strValue));
$strValue = preg_replace('/[^a-z0-9]+/', '-', $strValue) ?: 'pipeline';
return trim($strValue, '-');
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapPublishHelper
{
public static function runBundlePublish(string $strInput, array $arrOptions = []): array
{
$strBundleRoot = DomainBootstrapRunHelper::resolveBundleRoot($strInput);
if ($strBundleRoot === '') {
throw new \RuntimeException('Bundle root not found from input: ' . $strInput);
}
$arrBundleSummary = DomainBootstrapRunHelper::loadBundleSummary($strBundleRoot);
if (empty($arrBundleSummary)) {
throw new \RuntimeException('bootstrap-summary.json not found in bundle: ' . $strBundleRoot);
}
$strIndexRoot = trim((string)($arrOptions['index_root'] ?? ''));
if ($strIndexRoot === '') {
$strIndexRoot = trim((string)($arrBundleSummary['bundle_index']['scan_root'] ?? ''));
}
$strPortalRoot = trim((string)($arrOptions['portal_root'] ?? ''));
if ($strPortalRoot === '') {
$strPortalRoot = trim((string)($arrBundleSummary['bundle_portal']['root'] ?? ''));
}
$strTargetRoot = trim((string)($arrOptions['target_root'] ?? ''));
$arrReleaseRun = SeoCopyReleaseRunHelper::run([
'manifest_path' => (string)($arrBundleSummary['manifest'] ?? ''),
'approved_dir' => (string)($arrBundleSummary['approved_dir'] ?? ($strBundleRoot . '/data/seo_copy_jobs/approved')),
'source_root' => (string)($arrBundleSummary['target_root'] ?? ($strBundleRoot . '/data/seo_copy')),
'target_root' => $strTargetRoot !== '' ? $strTargetRoot : dirname(__DIR__, 3) . '/data/seo_copy',
'base_url' => (string)($arrBundleSummary['base_url'] ?? ''),
'portal_root_arg' => $strPortalRoot,
'dry_run' => false,
]);
$arrReleaseSummary = (array)($arrReleaseRun['summary'] ?? []);
$strStatus = (string)($arrReleaseSummary['status'] ?? '');
$arrResult = [
'status' => $strStatus,
'exit_code' => (int)($arrReleaseRun['exit_code'] ?? 0),
'dry_run' => false,
'executed_at' => date(DATE_ATOM),
'run_dir' => (string)($arrReleaseSummary['run_dir'] ?? ''),
'run_summary_path' => (string)(($arrReleaseSummary['artifacts'] ?? [])['run_summary'] ?? ''),
'manifest' => (string)($arrReleaseSummary['manifest'] ?? ''),
'source_root' => (string)($arrReleaseSummary['source_root'] ?? ''),
'target_root' => (string)($arrReleaseSummary['target_root'] ?? ''),
'published_count' => (int)($arrReleaseSummary['published_count'] ?? 0),
'message' => $strStatus === 'published'
? '正式发布已完成,当前 bundle 可以退出待办主线。'
: '正式发布未完成,请先查看 publish 摘要或回退到 dry-run 排障。',
];
$arrArtifacts = DomainBootstrapExecutionHelper::persistPublishResult(
$strBundleRoot,
$arrResult,
$strIndexRoot !== '' ? $strIndexRoot : null,
$strPortalRoot !== '' ? $strPortalRoot : null
);
$arrResult['bundle_root'] = $strBundleRoot;
$arrResult['bundle_artifacts'] = $arrArtifacts;
return $arrResult;
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
use app\model\TemplatesModel;
class DomainBootstrapRegisterHelper
{
public static function loadRegisterPayload(string $strPath): array
{
if ($strPath === '' || !is_file($strPath)) {
throw new \RuntimeException('Register file not found: ' . $strPath);
}
$arrPayload = json_decode((string)file_get_contents($strPath), true);
if (!is_array($arrPayload)) {
throw new \RuntimeException('Invalid register JSON: ' . $strPath);
}
return $arrPayload;
}
public static function normalizePayload(array $arrPayload): array
{
$strMatchType = DomainModel::normalizeMatchType((string)($arrPayload['d_match_type'] ?? DomainModel::MATCH_TYPE_EXACT));
$strDomain = DomainModel::normalizeStoredDomain((string)($arrPayload['d_domain'] ?? ''), $strMatchType);
$strParentDomain = DomainModel::normalizeParentDomain((string)($arrPayload['d_parent_domain'] ?? ''));
$strSeedScope = DomainModel::normalizeSeedScope((string)($arrPayload['d_seed_scope'] ?? DomainModel::SEED_SCOPE_DOMAIN));
$TempDomainModel = new DomainModel();
$TempDomainModel->d_match_type = $strMatchType;
$TempDomainModel->d_seed_scope = $strSeedScope;
$TempDomainModel->d_parent_domain = $strParentDomain;
$TempDomainModel->d_domain = $strDomain;
$strSiteName = trim((string)($arrPayload['d_name'] ?? ''));
if ($strSiteName === '') {
$strSiteName = $strDomain;
}
$strRawIndexTitle = trim((string)($arrPayload['d_index_title'] ?? ''));
$strRawIndexKeywords = trim((string)($arrPayload['d_index_keywords'] ?? ''));
$strRawIndexDescription = trim((string)($arrPayload['d_index_description'] ?? ''));
$boolHasImportedHomeTkd = $strRawIndexTitle !== '' || $strRawIndexKeywords !== '' || $strRawIndexDescription !== '';
$strSiteKeywords = trim((string)($arrPayload['d_keywords'] ?? $strSiteName));
$strSiteDescription = trim((string)($arrPayload['d_description'] ?? ($strSiteName . '内容整理与推荐。')));
$strIndexTitle = trim((string)($arrPayload['d_index_title'] ?? ($strSiteName . '影视内容推荐')));
$strIndexKeywords = trim((string)($arrPayload['d_index_keywords'] ?? $strSiteKeywords));
$strIndexDescription = trim((string)($arrPayload['d_index_description'] ?? $strSiteDescription));
$arrSeoCfgInput = $arrPayload['d_seo_cfg'] ?? [];
if (is_string($arrSeoCfgInput)) {
$arrDecodedSeoCfg = json_decode($arrSeoCfgInput, true);
$arrSeoCfgInput = is_array($arrDecodedSeoCfg) ? $arrDecodedSeoCfg : [];
}
if (!is_array($arrSeoCfgInput)) {
$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 = $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'];
}
$arrSeoCfgInput['tkd'] = array_merge(
(array)($arrSeoCfgInput['tkd'] ?? []),
[
'mode' => DomainModel::normalizeTkdMode($strTkdModeInput),
'provider' => $strTkdProviderNormalized,
]
);
return [
'd_domain' => $strDomain,
'd_match_type' => $strMatchType,
'd_parent_domain' => $strParentDomain,
'd_seed_scope' => $strSeedScope,
'd_seo_cfg' => DomainModel::normalizeSeoCfg($arrSeoCfgInput, $TempDomainModel),
't_id' => max(0, (int)($arrPayload['t_id'] ?? 0)),
'd_name' => $strSiteName,
'd_keywords' => $strSiteKeywords,
'd_description' => $strSiteDescription,
'd_index_title' => $strIndexTitle,
'd_index_keywords' => $strIndexKeywords,
'd_index_description' => $strIndexDescription,
'd_statis' => (string)($arrPayload['d_statis'] ?? ''),
'd_logo_type' => (int)($arrPayload['d_logo_type'] ?? 0),
'd_text_logo' => trim((string)($arrPayload['d_text_logo'] ?? $strSiteName)),
'd_img_logo' => trim((string)($arrPayload['d_img_logo'] ?? '')),
'd_content_encode' => max(0, (int)($arrPayload['d_content_encode'] ?? 0)),
'info_id' => max(0, (int)($arrPayload['info_id'] ?? 0)),
'd_baidu_token' => trim((string)($arrPayload['d_baidu_token'] ?? '')),
];
}
public static function inspectPayload(array $arrPayload): array
{
$arrNormalizedPayload = self::normalizePayload($arrPayload);
$strTargetDomain = (string)($arrNormalizedPayload['d_domain'] ?? '');
$intTemplateId = (int)($arrNormalizedPayload['t_id'] ?? 0);
$arrSummary = [
'payload' => $arrNormalizedPayload,
'found' => false,
'status' => 'pending',
'message' => '',
'create_fields' => 0,
'domain_id' => 0,
'template_found' => false,
'template_id' => $intTemplateId,
];
if ($strTargetDomain === '') {
$arrSummary['status'] = 'invalid_payload';
$arrSummary['message'] = 'd_domain 为空,无法创建站点记录。';
return $arrSummary;
}
$DomainModel = DomainModel::where('d_domain', $strTargetDomain)->find();
if ($DomainModel) {
$arrSummary['found'] = true;
$arrSummary['domain_id'] = (int)$DomainModel->d_id;
$arrSummary['status'] = 'domain_exists';
$arrSummary['message'] = '目标域名已存在,可继续执行 SEO 策略 apply。';
return $arrSummary;
}
if ($intTemplateId <= 0) {
$arrSummary['status'] = 'template_id_required';
$arrSummary['message'] = '缺少 t_id无法创建站点记录。';
return $arrSummary;
}
$TemplateModel = TemplatesModel::where('t_id', $intTemplateId)->find();
if (!$TemplateModel) {
$arrSummary['status'] = 'template_not_found';
$arrSummary['message'] = '模板不存在,无法创建站点记录。';
return $arrSummary;
}
$arrSummary['template_found'] = true;
$arrSummary['template_code'] = (string)($TemplateModel->t_code ?? '');
$arrSummary['status'] = 'ready_to_create';
$arrSummary['message'] = '目标域名尚未创建,当前样板已具备建站记录所需最小字段。';
$arrSummary['create_fields'] = count($arrNormalizedPayload);
return $arrSummary;
}
public static function createDomain(array $arrPayload): array
{
$arrSummary = self::inspectPayload($arrPayload);
$strStatus = (string)($arrSummary['status'] ?? '');
if ($strStatus !== 'ready_to_create') {
return $arrSummary;
}
$arrNormalizedPayload = (array)($arrSummary['payload'] ?? []);
$TemplateModel = TemplatesModel::where('t_id', (int)($arrNormalizedPayload['t_id'] ?? 0))->find();
if (!$TemplateModel) {
$arrSummary['status'] = 'template_not_found';
$arrSummary['message'] = '模板在创建前已不存在。';
return $arrSummary;
}
$DomainModel = new DomainModel();
$DomainModel->fill($arrNormalizedPayload);
$DomainModel->t_cfg = $TemplateModel->t_cfg_template ?? [];
$DomainModel->save();
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;
$arrSummary['domain_id'] = (int)$DomainModel->d_id;
return $arrSummary;
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapRunHelper
{
public static function resolveBundleRoot(string $strInput): string
{
$strInput = trim($strInput);
if ($strInput === '') {
return '';
}
if (is_dir($strInput) && is_file(rtrim($strInput, '/') . '/bootstrap-summary.json')) {
return rtrim($strInput, '/');
}
if (is_file($strInput)) {
return DomainBootstrapExecutionHelper::inferBundleRoot($strInput);
}
return '';
}
public static function loadBundleSummary(string $strBundleRoot): array
{
return DomainBootstrapExecutionHelper::loadBundleSummary($strBundleRoot);
}
public static function extractBundlePaths(array $arrSummary, string $strBundleRoot): array
{
$arrFiles = (array)($arrSummary['bundle_files'] ?? []);
$strBundleRoot = rtrim($strBundleRoot, '/');
$strRegisterSample = self::preferBundleFile(
$strBundleRoot,
'site-register.sample.json',
(string)($arrFiles['site_register'] ?? '')
);
$strApplySample = self::preferBundleFile(
$strBundleRoot,
'site-bootstrap.sample.json',
(string)($arrFiles['site_bootstrap'] ?? '')
);
$strSummaryPath = $strBundleRoot . '/bootstrap-summary.json';
return [
'register_sample' => $strRegisterSample,
'apply_sample' => $strApplySample,
'summary_path' => $strSummaryPath,
];
}
public static function writeRunSummary(string $strRunRoot, string $strHost, array $arrSummary): array
{
$strRunRoot = rtrim($strRunRoot, '/');
if ($strRunRoot === '') {
throw new \RuntimeException('Run root is required.');
}
$strDateDir = $strRunRoot . '/' . date('Ymd');
if (!is_dir($strDateDir) && !mkdir($strDateDir, 0777, true) && !is_dir($strDateDir)) {
throw new \RuntimeException('Failed to create run directory: ' . $strDateDir);
}
$strHostSlug = preg_replace('/[^a-zA-Z0-9\-]+/', '-', strtolower(trim($strHost)));
$strHostSlug = trim((string)$strHostSlug, '-');
if ($strHostSlug === '') {
$strHostSlug = 'domain-bootstrap';
}
$strRunName = date('His') . '_' . $strHostSlug;
$strJsonPath = $strDateDir . '/' . $strRunName . '.json';
file_put_contents(
$strJsonPath,
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
return [
'run_root' => $strRunRoot,
'run_date' => date('Ymd'),
'run_name' => $strRunName,
'json' => $strJsonPath,
];
}
public static function persistBundleRunMeta(string $strBundleRoot, array $arrRunArtifacts, array $arrRunSummary): void
{
$arrSummary = self::loadBundleSummary($strBundleRoot);
if (empty($arrSummary)) {
return;
}
$arrSummary['bundle_run'] = [
'run_name' => (string)($arrRunArtifacts['run_name'] ?? ''),
'run_json' => (string)($arrRunArtifacts['json'] ?? ''),
'status' => (string)($arrRunSummary['overall_status'] ?? ''),
'dry_run' => (bool)($arrRunSummary['dry_run'] ?? false),
'executed_at' => (string)($arrRunSummary['executed_at'] ?? ''),
];
$strSummaryPath = rtrim($strBundleRoot, '/') . '/bootstrap-summary.json';
file_put_contents(
$strSummaryPath,
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
$strScanRoot = trim((string)($arrSummary['bundle_index']['scan_root'] ?? ''));
$strPortalRoot = trim((string)($arrSummary['bundle_portal']['root'] ?? ''));
$strRunRoot = trim((string)($arrRunArtifacts['run_root'] ?? ''));
if ($strScanRoot !== '') {
$arrBundleIndexSummary = DomainBootstrapBundleIndexHelper::buildSummary($strScanRoot, true);
DomainBootstrapBundleIndexHelper::writeArtifacts(rtrim($strScanRoot, '/') . '/_index', $arrBundleIndexSummary);
if ($strRunRoot !== '') {
$arrRunIndexSummary = DomainBootstrapRunIndexHelper::buildSummary($strRunRoot, true);
DomainBootstrapRunIndexHelper::writeArtifacts(rtrim($strScanRoot, '/') . '/_runs', $arrRunIndexSummary);
}
if ($strPortalRoot !== '' && strtolower($strPortalRoot) !== 'off') {
DomainBootstrapBundleIndexHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/bootstrap', $arrBundleIndexSummary);
if ($strRunRoot !== '') {
$arrRunIndexSummary = $arrRunIndexSummary ?? DomainBootstrapRunIndexHelper::buildSummary($strRunRoot, true);
DomainBootstrapRunIndexHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/bootstrap/runs', $arrRunIndexSummary);
}
SeoCopyPortalHomeHelper::writeArtifacts($strPortalRoot, SeoCopyPortalHomeHelper::buildSummary($strPortalRoot));
}
} elseif ($strPortalRoot !== '' && strtolower($strPortalRoot) !== 'off') {
if ($strRunRoot !== '') {
$arrRunIndexSummary = DomainBootstrapRunIndexHelper::buildSummary($strRunRoot, true);
DomainBootstrapRunIndexHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/bootstrap/runs', $arrRunIndexSummary);
}
SeoCopyPortalHomeHelper::writeArtifacts($strPortalRoot, SeoCopyPortalHomeHelper::buildSummary($strPortalRoot));
}
}
protected static function preferBundleFile(string $strBundleRoot, string $strFilename, string $strFallback): string
{
$strPreferred = rtrim($strBundleRoot, '/') . '/' . ltrim($strFilename, '/');
if (is_file($strPreferred)) {
return $strPreferred;
}
return $strFallback !== '' ? $strFallback : $strPreferred;
}
}

View File

@@ -0,0 +1,445 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapRunIndexHelper
{
public static function buildSummary(string $strScanRoot, bool $boolAllowMissing = false): array
{
$strScanRoot = rtrim($strScanRoot, '/');
if ($strScanRoot === '' || !is_dir($strScanRoot)) {
if ($boolAllowMissing) {
return [
'scan_root' => $strScanRoot,
'generated_at' => date(DATE_ATOM),
'runs' => [],
'runs_count' => 0,
'status_totals' => [],
];
}
throw new \RuntimeException('Scan root not found: ' . $strScanRoot);
}
$arrRuns = [];
foreach ((array)glob($strScanRoot . '/*/*.json') as $strJsonPath) {
if (str_contains($strJsonPath, '/_index/')) {
continue;
}
$arrRun = json_decode((string)file_get_contents($strJsonPath), true);
if (!is_array($arrRun)) {
continue;
}
$strRunName = preg_replace('/\.json$/', '', basename($strJsonPath));
$strHost = trim((string)($arrRun['host'] ?? ''));
$strBundleRoot = trim((string)($arrRun['bundle_root'] ?? ''));
$strExecutedAt = trim((string)($arrRun['executed_at'] ?? ''));
$strStatus = trim((string)($arrRun['overall_status'] ?? ''));
$arrBundleSummary = self::loadBundleSummary($strBundleRoot);
$strManifestPath = self::resolveManifestPath($strBundleRoot, (string)($arrBundleSummary['manifest'] ?? ''));
$strManifestName = preg_replace('/\.manifest\.json$/', '', basename($strManifestPath));
$strManifestName = preg_replace('/\.json$/', '', (string)$strManifestName);
$arrRunItem = [
'run_name' => $strRunName,
'detail_slug' => $strRunName,
'run_json' => $strJsonPath,
'host' => $strHost,
'bundle_root' => $strBundleRoot,
'bundle_detail_slug' => self::buildBundleDetailSlug($strHost, $strBundleRoot),
'manifest_path' => $strManifestPath,
'manifest_name' => $strManifestName,
'approved_dir' => trim((string)($arrBundleSummary['approved_dir'] ?? '')),
'target_root' => trim((string)($arrBundleSummary['target_root'] ?? '')),
'base_url' => trim((string)($arrBundleSummary['base_url'] ?? '')),
'dry_run' => (bool)($arrRun['dry_run'] ?? false),
'run_register' => (bool)($arrRun['run_register'] ?? false),
'run_apply' => (bool)($arrRun['run_apply'] ?? false),
'overall_status' => $strStatus,
'register_status' => (string)(($arrRun['register_result'] ?? [])['status'] ?? ''),
'register_message' => (string)(($arrRun['register_result'] ?? [])['message'] ?? ''),
'apply_status' => (string)(($arrRun['apply_result'] ?? [])['status'] ?? ''),
'apply_message' => (string)(($arrRun['apply_result'] ?? [])['message'] ?? ''),
'errors_count' => count((array)($arrRun['errors'] ?? [])),
'errors' => array_values((array)($arrRun['errors'] ?? [])),
'executed_at' => $strExecutedAt,
'updated_at' => $strExecutedAt !== '' ? $strExecutedAt : date(DATE_ATOM, (int)filemtime($strJsonPath)),
'summary' => self::buildRunSummaryText($arrRun),
];
$arrRuns[] = array_merge($arrRunItem, self::buildHandoffState($arrRunItem));
}
usort($arrRuns, static function (array $arrA, array $arrB): int {
return strcmp((string)($arrB['updated_at'] ?? ''), (string)($arrA['updated_at'] ?? ''));
});
$arrStatusTotals = [];
$intHandoffRegisterCount = 0;
$intHandoffApplyCount = 0;
$intHandoffPrepareCount = 0;
$intHandoffAttentionCount = 0;
foreach ($arrRuns as $arrRun) {
$strStatus = (string)($arrRun['overall_status'] ?? '');
if ($strStatus === '') {
// keep counting handoff below even if status is empty
} else {
$arrStatusTotals[$strStatus] = (int)($arrStatusTotals[$strStatus] ?? 0) + 1;
}
$strHandoffCode = (string)($arrRun['handoff_stage_code'] ?? '');
if ($strHandoffCode === 'ready_to_register') {
$intHandoffRegisterCount++;
} elseif ($strHandoffCode === 'ready_to_apply') {
$intHandoffApplyCount++;
} elseif ($strHandoffCode === 'ready_for_prepare') {
$intHandoffPrepareCount++;
} elseif ($strHandoffCode === 'needs_attention') {
$intHandoffAttentionCount++;
}
}
ksort($arrStatusTotals);
return [
'scan_root' => $strScanRoot,
'generated_at' => date(DATE_ATOM),
'runs' => $arrRuns,
'runs_count' => count($arrRuns),
'status_totals' => $arrStatusTotals,
'handoff_ready_to_register_count' => $intHandoffRegisterCount,
'handoff_ready_to_apply_count' => $intHandoffApplyCount,
'handoff_ready_for_prepare_count' => $intHandoffPrepareCount,
'handoff_attention_count' => $intHandoffAttentionCount,
'latest_run' => $arrRuns[0] ?? null,
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
self::ensureDir($strOutputRoot);
$strJsonPath = rtrim($strOutputRoot, '/') . '/index.json';
$strHtmlPath = rtrim($strOutputRoot, '/') . '/index.html';
$strDetailsRoot = rtrim($strOutputRoot, '/') . '/details';
file_put_contents(
$strJsonPath,
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
self::ensureDir($strDetailsRoot);
foreach ((array)($arrSummary['runs'] ?? []) as $arrRun) {
if (!is_array($arrRun)) {
continue;
}
$strDetailSlug = trim((string)($arrRun['detail_slug'] ?? ''));
if ($strDetailSlug === '') {
continue;
}
file_put_contents(
$strDetailsRoot . '/' . $strDetailSlug . '.json',
json_encode($arrRun, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
file_put_contents(
$strDetailsRoot . '/' . $strDetailSlug . '.html',
self::renderDetailHtml($arrRun)
);
}
return [
'json' => $strJsonPath,
'html' => $strHtmlPath,
];
}
public static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['runs'] ?? []) as $arrRun) {
if (!is_array($arrRun)) {
continue;
}
$strBundleCell = self::escape((string)($arrRun['host'] ?? '')) . '<br><code>' . self::escape((string)($arrRun['bundle_root'] ?? '')) . '</code>';
$arrRows[] = '<tr>'
. '<td><a href="./details/' . rawurlencode((string)($arrRun['detail_slug'] ?? '')) . '.html">' . self::escape((string)($arrRun['run_name'] ?? '')) . '</a></td>'
. '<td>' . self::escape((string)($arrRun['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrRun['overall_status'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrRun['register_status'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrRun['apply_status'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrRun['handoff_stage_label'] ?? '')) . '</td>'
. '<td>' . ($arrRun['dry_run'] ? 'yes' : 'no') . '</td>'
. '<td>' . (int)($arrRun['errors_count'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrRun['summary'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrRun['updated_at'] ?? '')) . '</td>'
. '<td>' . $strBundleCell . '</td>'
. '</tr>';
}
$arrStatusBits = [];
foreach ((array)($arrSummary['status_totals'] ?? []) as $strStatus => $intCount) {
$arrStatusBits[] = self::escape((string)$strStatus) . ': <strong>' . (int)$intCount . '</strong>';
}
return implode(PHP_EOL, [
'<!doctype html>',
'<html lang="zh-CN">',
'<head>',
'<meta charset="utf-8">',
'<title>Domain Bootstrap Runs</title>',
'<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{word-break:break-all;white-space:pre-wrap;}a{color:#0b57d0;text-decoration:none;}</style>',
'</head>',
'<body>',
'<p><a href="../index.html">返回启动包总览</a> | <a href="../oncall/index.html">查看值班入口</a> | <a href="../handoff/index.html">查看启动包接手队列</a> | <a href="../duty/index.html">查看值班待办</a> | <a href="../../index.html">返回统一门户首页</a> | <a href="../../ops/index.html#bootstrap-sop">查看新域名上线 SOP</a></p>',
'<h1>Domain Bootstrap Runs</h1>',
'<p>scan_root: <code>' . self::escape((string)($arrSummary['scan_root'] ?? '')) . '</code></p>',
'<p>runs_count: <strong>' . (int)($arrSummary['runs_count'] ?? 0) . '</strong></p>',
'<p>status_totals: ' . implode(' / ', $arrStatusBits) . '</p>',
'<p>handoff: <strong>待入库 ' . (int)($arrSummary['handoff_ready_to_register_count'] ?? 0) . '</strong> / <strong>待应用 ' . (int)($arrSummary['handoff_ready_to_apply_count'] ?? 0) . '</strong> / <strong>可进 prepare ' . (int)($arrSummary['handoff_ready_for_prepare_count'] ?? 0) . '</strong> / <strong>需关注 ' . (int)($arrSummary['handoff_attention_count'] ?? 0) . '</strong></p>',
'<table>',
'<thead><tr><th>run_name</th><th>host</th><th>status</th><th>register</th><th>apply</th><th>handoff</th><th>dry_run</th><th>errors</th><th>summary</th><th>updated_at</th><th>bundle</th></tr></thead>',
'<tbody>',
...$arrRows,
'</tbody>',
'</table>',
'</body>',
'</html>',
]) . PHP_EOL;
}
protected static function renderDetailHtml(array $arrRun): string
{
$arrInfoRows = [
'run_name' => (string)($arrRun['run_name'] ?? ''),
'host' => (string)($arrRun['host'] ?? ''),
'overall_status' => (string)($arrRun['overall_status'] ?? ''),
'register_status' => (string)($arrRun['register_status'] ?? ''),
'apply_status' => (string)($arrRun['apply_status'] ?? ''),
'dry_run' => (bool)($arrRun['dry_run'] ?? false) ? 'yes' : 'no',
'run_register' => (bool)($arrRun['run_register'] ?? false) ? 'yes' : 'no',
'run_apply' => (bool)($arrRun['run_apply'] ?? false) ? 'yes' : 'no',
'errors_count' => (string)($arrRun['errors_count'] ?? 0),
'executed_at' => (string)($arrRun['executed_at'] ?? ''),
'handoff_stage' => (string)($arrRun['handoff_stage_label'] ?? ''),
];
$arrPathRows = [
'run_json' => (string)($arrRun['run_json'] ?? ''),
'bundle_root' => (string)($arrRun['bundle_root'] ?? ''),
];
$arrStatusRows = [
'summary' => (string)($arrRun['summary'] ?? ''),
'handoff_summary' => (string)($arrRun['handoff_summary'] ?? ''),
'handoff_next_step' => (string)($arrRun['handoff_next_step'] ?? ''),
'handoff_next_command' => (string)($arrRun['handoff_next_command'] ?? ''),
'register_message' => (string)($arrRun['register_message'] ?? ''),
'apply_message' => (string)($arrRun['apply_message'] ?? ''),
'errors' => implode(PHP_EOL, (array)($arrRun['errors'] ?? [])),
];
$arrInfoHtml = [];
foreach ($arrInfoRows as $strKey => $strValue) {
$arrInfoHtml[] = '<tr><td>' . self::escape($strKey) . '</td><td>' . self::escape($strValue) . '</td></tr>';
}
$arrPathHtml = [];
foreach ($arrPathRows as $strKey => $strValue) {
$arrPathHtml[] = '<tr><td>' . self::escape($strKey) . '</td><td><code>' . self::escape($strValue) . '</code></td></tr>';
}
$arrStatusHtml = [];
foreach ($arrStatusRows as $strKey => $strValue) {
$arrStatusHtml[] = '<tr><td>' . self::escape($strKey) . '</td><td><code>' . self::escape($strValue) . '</code></td></tr>';
}
$strBundleLink = '';
if ((string)($arrRun['bundle_detail_slug'] ?? '') !== '') {
$strBundleLink = '<p><a href="../../details/' . rawurlencode((string)($arrRun['bundle_detail_slug'] ?? '')) . '.html">返回对应 bundle 详情</a></p>';
}
return implode(PHP_EOL, [
'<!doctype html>',
'<html lang="zh-CN">',
'<head>',
'<meta charset="utf-8">',
'<title>Bootstrap Run Detail</title>',
'<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;margin-bottom:20px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{word-break:break-all;white-space:pre-wrap;}a{color:#0b57d0;text-decoration:none;}</style>',
'</head>',
'<body>',
'<p><a href="../index.html">返回启动包运行记录</a> | <a href="../../oncall/index.html">查看值班入口</a> | <a href="../../handoff/index.html">查看启动包接手队列</a> | <a href="../../duty/index.html">查看值班待办</a> | <a href="../../index.html">返回统一门户首页</a> | <a href="../../ops/index.html#bootstrap-sop">查看新域名上线 SOP</a></p>',
$strBundleLink,
'<h1>' . self::escape((string)($arrRun['run_name'] ?? '')) . '</h1>',
'<h2>状态摘要</h2>',
'<table><tbody>' . implode('', $arrInfoHtml) . '</tbody></table>',
'<h2>状态说明</h2>',
'<table><tbody>' . implode('', $arrStatusHtml) . '</tbody></table>',
'<h2>文件路径</h2>',
'<table><tbody>' . implode('', $arrPathHtml) . '</tbody></table>',
'</body>',
'</html>',
]) . PHP_EOL;
}
protected static function buildBundleDetailSlug(string $strHost, string $strBundleRoot): string
{
$strHost = trim(strtolower($strHost));
$strHost = preg_replace('/[^a-z0-9]+/', '-', $strHost) ?: '';
$strHost = trim((string)$strHost, '-');
if ($strHost !== '') {
return $strHost;
}
$strBase = basename($strBundleRoot);
$strBase = preg_replace('/[^a-zA-Z0-9\-]+/', '-', $strBase) ?: 'bundle';
return strtolower(trim((string)$strBase, '-'));
}
protected static function buildRunSummaryText(array $arrRun): string
{
$strRegister = (string)(($arrRun['register_result'] ?? [])['status'] ?? '');
$strApply = (string)(($arrRun['apply_result'] ?? [])['status'] ?? '');
$strStatus = (string)($arrRun['overall_status'] ?? '');
return trim('status=' . $strStatus . ', register=' . $strRegister . ', apply=' . $strApply, ' ,');
}
protected static function buildHandoffState(array $arrRun): array
{
$strOverallStatus = (string)($arrRun['overall_status'] ?? '');
$strRegisterStatus = (string)($arrRun['register_status'] ?? '');
$strApplyStatus = (string)($arrRun['apply_status'] ?? '');
$strBundleRoot = (string)($arrRun['bundle_root'] ?? '');
$strRunDryCommand = self::buildRunCommand($strBundleRoot, true);
$strRunCommand = self::buildRunCommand($strBundleRoot, false);
$strPrepareCommand = self::buildPrepareCommand($arrRun);
if ((int)($arrRun['errors_count'] ?? 0) > 0 || in_array($strOverallStatus, ['failed', 'needs_attention', 'dry_run_needs_attention'], true)) {
return self::formatHandoffState(
'needs_attention',
'需关注',
'当前 bootstrap run 已出现错误或异常状态,建议先重跑 dry-run 并核对 register/apply 明细。',
'先重跑高层 dry-run',
$strRunDryCommand
);
}
if (in_array($strOverallStatus, ['completed'], true) && in_array($strApplyStatus, ['applied', 'noop'], true)) {
return self::formatHandoffState(
'ready_for_prepare',
'可进入 Prepare',
'本次 bootstrap run 已完成正式入库与策略应用,下一步可以进入 prepare 和 release dry-run。',
'继续执行 prepare',
$strPrepareCommand
);
}
if (in_array($strOverallStatus, ['dry_run_completed'], true) && in_array($strRegisterStatus, ['domain_exists', 'created'], true) && in_array($strApplyStatus, ['dry_run', 'noop'], true)) {
return self::formatHandoffState(
'ready_to_apply',
'待正式应用',
'高层 dry-run 已确认域名可继续 apply下一步应正式执行 bootstrap run。',
'正式执行高层 run',
$strRunCommand
);
}
if (in_array($strOverallStatus, ['dry_run_completed'], true) && $strRegisterStatus === 'ready_to_create') {
return self::formatHandoffState(
'ready_to_register',
'待正式入库',
'高层 dry-run 已确认 register 样板完整,下一步应正式执行 bootstrap run 完成入库。',
'正式执行高层 run',
$strRunCommand
);
}
return self::formatHandoffState(
'pending_review',
'待复核',
'当前 run 已存在,但还没有形成稳定的正式推进信号,建议先回 bundle 详情或重跑 dry-run。',
'先重跑高层 dry-run',
$strRunDryCommand
);
}
protected static function formatHandoffState(
string $strCode,
string $strLabel,
string $strSummary,
string $strNextStep,
string $strCommand
): array {
return [
'handoff_stage_code' => $strCode,
'handoff_stage_label' => $strLabel,
'handoff_summary' => $strSummary,
'handoff_next_step' => $strNextStep,
'handoff_next_command' => $strCommand,
];
}
protected static function buildRunCommand(string $strBundleRoot, bool $boolDryRun): string
{
if ($strBundleRoot === '') {
return '';
}
return 'php scripts/domain_bootstrap_run.php ' . escapeshellarg($strBundleRoot) . ' --dry-run=' . ($boolDryRun ? '1' : '0') . ' --format=text';
}
protected static function buildPrepareCommand(array $arrRun): string
{
$strManifestName = trim((string)($arrRun['manifest_name'] ?? ''));
$strApprovedDir = trim((string)($arrRun['approved_dir'] ?? ''));
if ($strManifestName === '' || $strApprovedDir === '') {
return '';
}
return 'php scripts/seo_copy_batch_prepare.php ' . escapeshellarg($strManifestName) . ' --approved-dir=' . escapeshellarg($strApprovedDir);
}
protected static function loadBundleSummary(string $strBundleRoot): array
{
$strSummaryPath = rtrim($strBundleRoot, '/') . '/bootstrap-summary.json';
if ($strBundleRoot === '' || !is_file($strSummaryPath)) {
return [];
}
$arrSummary = json_decode((string)file_get_contents($strSummaryPath), true);
return is_array($arrSummary) ? $arrSummary : [];
}
protected static function resolveManifestPath(string $strBundleRoot, string $strFallback): string
{
foreach ((array)glob(rtrim($strBundleRoot, '/') . '/data/seo_copy_jobs/approved/*.manifest.json') as $strPath) {
if (is_file($strPath)) {
return $strPath;
}
}
return $strFallback;
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainBootstrapRunService
{
public static function runBundle(string $strInput, array $arrOptions = []): array
{
$boolDryRun = (bool)($arrOptions['dry_run'] ?? true);
$boolRunRegister = (bool)($arrOptions['run_register'] ?? true);
$boolRunApply = (bool)($arrOptions['run_apply'] ?? true);
$strRunRoot = rtrim((string)($arrOptions['run_root'] ?? dirname(__DIR__, 3) . '/storage/domain_bootstrap_runs'), '/');
$strIndexRoot = trim((string)($arrOptions['index_root'] ?? ''));
$strPortalRoot = trim((string)($arrOptions['portal_root'] ?? ''));
if (!$boolRunRegister && !$boolRunApply) {
throw new \RuntimeException('At least one of run_register / run_apply must be enabled.');
}
$strBundleRoot = DomainBootstrapRunHelper::resolveBundleRoot($strInput);
if ($strBundleRoot === '') {
throw new \RuntimeException('Bundle root not found from input: ' . $strInput);
}
$arrBundleSummary = DomainBootstrapRunHelper::loadBundleSummary($strBundleRoot);
if (empty($arrBundleSummary)) {
throw new \RuntimeException('bootstrap-summary.json not found in bundle: ' . $strBundleRoot);
}
$arrPaths = DomainBootstrapRunHelper::extractBundlePaths($arrBundleSummary, $strBundleRoot);
$strHost = (string)($arrBundleSummary['host'] ?? '');
$strIndexRoot = $strIndexRoot !== '' ? $strIndexRoot : (string)($arrBundleSummary['bundle_index']['scan_root'] ?? '');
$strPortalRoot = $strPortalRoot !== '' ? $strPortalRoot : (string)($arrBundleSummary['bundle_portal']['root'] ?? '');
$arrRegisterResult = [];
$arrApplyResult = [];
$arrErrors = [];
try {
if ($boolRunRegister) {
$arrRegisterResult = self::runRegisterStep(
$arrPaths['register_sample'],
$boolDryRun,
$strIndexRoot !== '' ? $strIndexRoot : null,
$strPortalRoot !== '' ? $strPortalRoot : null
);
}
if ($boolRunApply) {
$arrApplyResult = self::runApplyStep(
$arrPaths['apply_sample'],
$boolDryRun,
$strIndexRoot !== '' ? $strIndexRoot : null,
$strPortalRoot !== '' ? $strPortalRoot : null
);
}
} catch (\Throwable $throwable) {
$arrErrors[] = $throwable->getMessage();
}
$arrRunSummary = [
'input' => $strInput,
'bundle_root' => $strBundleRoot,
'summary_path' => $arrPaths['summary_path'],
'host' => $strHost,
'dry_run' => $boolDryRun,
'run_register' => $boolRunRegister,
'run_apply' => $boolRunApply,
'register_result' => $arrRegisterResult,
'apply_result' => $arrApplyResult,
'errors' => $arrErrors,
'executed_at' => date(DATE_ATOM),
];
$arrRunSummary['overall_status'] = !empty($arrErrors)
? 'failed'
: self::inferOverallStatus($arrRegisterResult, $arrApplyResult, $boolDryRun);
$arrRunArtifacts = DomainBootstrapRunHelper::writeRunSummary($strRunRoot, $strHost, $arrRunSummary);
$arrRunSummary['run_artifacts'] = $arrRunArtifacts;
DomainBootstrapRunHelper::persistBundleRunMeta($strBundleRoot, $arrRunArtifacts, $arrRunSummary);
return $arrRunSummary;
}
protected static function runRegisterStep(string $strRegisterPath, bool $boolDryRun, ?string $strIndexRoot, ?string $strPortalRoot): array
{
$arrPayload = DomainBootstrapRegisterHelper::loadRegisterPayload($strRegisterPath);
$arrSummary = $boolDryRun
? DomainBootstrapRegisterHelper::inspectPayload($arrPayload)
: DomainBootstrapRegisterHelper::createDomain($arrPayload);
$arrOutput = [
'register_path' => $strRegisterPath,
'dry_run' => $boolDryRun,
'status' => $arrSummary['status'] ?? 'unknown',
'message' => $arrSummary['message'] ?? '',
'domain_id' => (int)($arrSummary['domain_id'] ?? 0),
'found' => (bool)($arrSummary['found'] ?? false),
'template_id' => (int)($arrSummary['template_id'] ?? 0),
'template_found' => (bool)($arrSummary['template_found'] ?? false),
'template_code' => (string)($arrSummary['template_code'] ?? ''),
'create_fields' => (int)($arrSummary['create_fields'] ?? 0),
'payload' => $arrSummary['payload'] ?? [],
'executed_at' => date(DATE_ATOM),
];
$strBundleRoot = DomainBootstrapExecutionHelper::inferBundleRoot($strRegisterPath);
if ($strBundleRoot !== '') {
$arrOutput['bundle_artifacts'] = DomainBootstrapExecutionHelper::persistResult(
$strBundleRoot,
'register',
$arrOutput,
$strIndexRoot,
$strPortalRoot
);
}
return $arrOutput;
}
protected static function runApplyStep(string $strApplyPath, bool $boolDryRun, ?string $strIndexRoot, ?string $strPortalRoot): array
{
$arrPayload = DomainBootstrapApplyHelper::loadBootstrapPayload($strApplyPath);
$arrSummary = $boolDryRun
? DomainBootstrapApplyHelper::inspectPayload($arrPayload)
: DomainBootstrapApplyHelper::applyPayload($arrPayload);
$arrOutput = [
'bootstrap_path' => $strApplyPath,
'dry_run' => $boolDryRun,
'status' => $arrSummary['status'] ?? 'unknown',
'message' => $arrSummary['message'] ?? '',
'domain_id' => (int)($arrSummary['domain_id'] ?? 0),
'found' => (bool)($arrSummary['found'] ?? false),
'changed_fields' => (int)($arrSummary['changed_fields'] ?? 0),
'diff' => $arrSummary['diff'] ?? [],
'payload' => $arrSummary['payload'] ?? [],
'executed_at' => date(DATE_ATOM),
];
$strBundleRoot = DomainBootstrapExecutionHelper::inferBundleRoot($strApplyPath);
if ($strBundleRoot !== '') {
$arrOutput['bundle_artifacts'] = DomainBootstrapExecutionHelper::persistResult(
$strBundleRoot,
'apply',
$arrOutput,
$strIndexRoot,
$strPortalRoot
);
}
return $arrOutput;
}
protected static function inferOverallStatus(array $arrRegisterResult, array $arrApplyResult, bool $boolDryRun): string
{
$strRegisterStatus = (string)($arrRegisterResult['status'] ?? '');
$strApplyStatus = (string)($arrApplyResult['status'] ?? '');
if ($boolDryRun) {
if (
in_array($strRegisterStatus, ['', 'domain_exists', 'ready_to_create'], true)
&& in_array($strApplyStatus, ['', 'dry_run', 'noop', 'domain_not_found'], true)
) {
return 'dry_run_completed';
}
return 'dry_run_needs_attention';
}
if (
in_array($strRegisterStatus, ['', 'created', 'domain_exists'], true)
&& in_array($strApplyStatus, ['', 'applied', 'noop'], true)
) {
return 'completed';
}
return 'needs_attention';
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoAizhanKeywordAttentionQueueHelper
{
public static function buildQueue(string $strCodeRoot, int $intLimit = 100): array
{
$intLimit = max(1, min(300, $intLimit));
$arrSummary = DomainExternalSeoAizhanKeywordProbeHelper::buildSummary($strCodeRoot, $intLimit);
$arrItems = [];
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$arrReasons = [];
$strStatus = (string)($arrItem['status'] ?? 'unknown');
$intPcKeywordCount = (int)($arrItem['pc_keyword_count'] ?? 0);
$intMobileKeywordCount = (int)($arrItem['mobile_keyword_count'] ?? 0);
$strPcIpRange = trim((string)($arrItem['baidu_pc_ip_range'] ?? ''));
$strMobileIpRange = trim((string)($arrItem['baidu_mobile_ip_range'] ?? ''));
if ($strStatus === 'captcha') {
$arrReasons[] = 'captcha';
} elseif ($strStatus === 'blocked') {
$arrReasons[] = 'blocked';
} elseif ($strStatus === 'unknown') {
$arrReasons[] = 'unknown';
}
if ($intPcKeywordCount <= 0 && $intMobileKeywordCount <= 0) {
$arrReasons[] = 'keyword_zero';
}
if (self::isZeroRange($strPcIpRange) && self::isZeroRange($strMobileIpRange)) {
$arrReasons[] = 'traffic_zero';
}
$arrReasons = array_values(array_unique($arrReasons));
if (empty($arrReasons)) {
continue;
}
$arrItems[] = [
'host' => (string)($arrItem['host'] ?? ''),
'attention_level' => self::buildAttentionLevel($arrReasons),
'reason_codes' => $arrReasons,
'reason_summary' => self::buildReasonSummary($arrReasons),
'recommended_action' => self::buildRecommendedAction($arrReasons),
'status' => $strStatus,
'site_title' => (string)($arrItem['site_title'] ?? ''),
'baidu_pc_ip_range' => $strPcIpRange,
'baidu_mobile_ip_range' => $strMobileIpRange,
'pc_keyword_count' => $intPcKeywordCount,
'mobile_keyword_count' => $intMobileKeywordCount,
'pc_rank_url' => (string)($arrItem['pc_rank_url'] ?? ''),
'mobile_rank_url' => (string)($arrItem['mobile_rank_url'] ?? ''),
'probed_at' => (string)($arrItem['probed_at'] ?? ''),
];
}
usort($arrItems, static function (array $arrLeft, array $arrRight): int {
$arrLevelWeight = ['critical' => 0, 'warning' => 1, 'info' => 2];
$intLeftWeight = $arrLevelWeight[$arrLeft['attention_level'] ?? 'info'] ?? 9;
$intRightWeight = $arrLevelWeight[$arrRight['attention_level'] ?? 'info'] ?? 9;
if ($intLeftWeight !== $intRightWeight) {
return $intLeftWeight <=> $intRightWeight;
}
return strcmp((string)($arrLeft['host'] ?? ''), (string)($arrRight['host'] ?? ''));
});
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'aizhan_keyword_attention',
'host_count' => (int)($arrSummary['host_count'] ?? 0),
'queue_count' => count($arrItems),
'ready_count' => (int)((($arrSummary['status_buckets'] ?? [])['ready']) ?? 0),
'captcha_count' => (int)((($arrSummary['status_buckets'] ?? [])['captcha']) ?? 0),
'blocked_count' => (int)((($arrSummary['status_buckets'] ?? [])['blocked']) ?? 0),
'items' => array_slice($arrItems, 0, $intLimit),
'note' => '当前队列优先收爱站关键词结果面里需要关注的域名验证码、抓取阻塞、0词、0来路。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrQueue): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/aizhan-keyword-attention.summary.json';
$strHtmlPath = $strOutputRoot . '/aizhan-keyword-attention.summary.html';
$arrQueue['summary_json_path'] = $strJsonPath;
$arrQueue['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrQueue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrQueue));
return $arrQueue;
}
protected static function isZeroRange(string $strRange): bool
{
$strRange = trim($strRange);
return $strRange === '' || $strRange === '0 ~ 0' || $strRange === '0~0';
}
protected static function buildAttentionLevel(array $arrReasons): string
{
if (in_array('captcha', $arrReasons, true) || in_array('blocked', $arrReasons, true) || in_array('unknown', $arrReasons, true)) {
return 'critical';
}
if (in_array('keyword_zero', $arrReasons, true) && in_array('traffic_zero', $arrReasons, true)) {
return 'warning';
}
return 'info';
}
protected static function buildReasonSummary(array $arrReasons): string
{
$arrLabels = [
'captcha' => '爱站查询命中验证',
'blocked' => '爱站查询被阻塞',
'unknown' => '爱站结果状态未知',
'keyword_zero' => 'PC/移动词数均为 0',
'traffic_zero' => '百度来路区间仍为 0 ~ 0',
];
$arrSummary = [];
foreach ($arrReasons as $strReason) {
$arrSummary[] = $arrLabels[$strReason] ?? $strReason;
}
return implode('', $arrSummary);
}
protected static function buildRecommendedAction(array $arrReasons): string
{
if (in_array('captcha', $arrReasons, true) || in_array('blocked', $arrReasons, true) || in_array('unknown', $arrReasons, true)) {
return '先确认爱站公开查询页是否能稳定打开,再继续看词数和来路指标。';
}
if (in_array('keyword_zero', $arrReasons, true) && in_array('traffic_zero', $arrReasons, true)) {
return '当前更像冷启动阶段,建议继续观察收录探测和页面供给,再看爱站词数是否开始抬升。';
}
if (in_array('keyword_zero', $arrReasons, true)) {
return '建议重点看页面标题、内容供给和长尾覆盖,先把词数做起来。';
}
if (in_array('traffic_zero', $arrReasons, true)) {
return '建议继续观察收录和关键词分布,来路区间通常会晚于词数变化。';
}
return '建议继续观察爱站结果面是否出现新词和来路抬升。';
}
protected static function renderHtml(array $arrQueue): string
{
$arrRows = [];
foreach ((array)($arrQueue['items'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['attention_level'] ?? '')) . '</td>'
. '<td>' . self::escape(implode(', ', (array)($arrItem['reason_codes'] ?? []))) . '</td>'
. '<td>' . self::escape((string)($arrItem['baidu_pc_ip_range'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['baidu_mobile_ip_range'] ?? '')) . '</td>'
. '<td>' . (int)($arrItem['pc_keyword_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['mobile_keyword_count'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrItem['recommended_action'] ?? '')) . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Aizhan Keyword Attention Queue</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(5,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>Aizhan Keyword Attention Queue</h1>'
. '<p class="muted">' . self::escape((string)($arrQueue['note'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>host_count</strong><br>' . (int)($arrQueue['host_count'] ?? 0) . '</div>'
. '<div class="card"><strong>queue_count</strong><br>' . (int)($arrQueue['queue_count'] ?? 0) . '</div>'
. '<div class="card"><strong>ready_count</strong><br>' . (int)($arrQueue['ready_count'] ?? 0) . '</div>'
. '<div class="card"><strong>captcha_count</strong><br>' . (int)($arrQueue['captcha_count'] ?? 0) . '</div>'
. '<div class="card"><strong>blocked_count</strong><br>' . (int)($arrQueue['blocked_count'] ?? 0) . '</div>'
. '</div>'
. '<table><thead><tr><th>host</th><th>level</th><th>reasons</th><th>baidu_pc_ip_range</th><th>baidu_mobile_ip_range</th><th>pc_keyword_count</th><th>mobile_keyword_count</th><th>recommended_action</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
class DomainExternalSeoAizhanKeywordProbeHelper
{
public static function buildSummary(string $strCodeRoot, int $intLimit = 20): array
{
$intLimit = max(1, min(100, $intLimit));
$arrDomains = array_values(array_filter(DomainModel::getAllDomainOnCache(), static function ($mItem): bool {
return $mItem instanceof DomainModel;
}));
$arrHosts = [];
foreach ($arrDomains as $DomainModel) {
$strHost = trim((string)$DomainModel->d_domain);
if ($strHost === '') {
continue;
}
if (str_contains($strHost, '*')) {
continue;
}
$arrHosts[] = $strHost;
}
sort($arrHosts);
$arrItems = [];
$arrStatusBuckets = [
'ready' => 0,
'captcha' => 0,
'blocked' => 0,
'unknown' => 0,
];
foreach (array_slice($arrHosts, 0, $intLimit) as $strHost) {
$arrProbe = self::probeHost($strHost);
$arrItems[] = $arrProbe;
$strStatus = (string)($arrProbe['status'] ?? 'unknown');
if (!isset($arrStatusBuckets[$strStatus])) {
$arrStatusBuckets[$strStatus] = 0;
}
$arrStatusBuckets[$strStatus]++;
}
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'aizhan_keyword_probe',
'engine' => 'aizhan_cha',
'host_count' => count($arrItems),
'status_buckets' => $arrStatusBuckets,
'items' => $arrItems,
'note' => '当前第一版优先抓爱站综合查询页里的可见基础指标百度来路区间、PC/移动关键词数、对应排名页入口。如果命中风控或结构异常,也会明确沉淀状态。',
];
}
public static function probeHost(string $strHost): array
{
$strHost = trim($strHost);
if ($strHost === '') {
return self::emptyItem('', 'unknown', 'empty_host');
}
$strUrl = 'https://www.aizhan.com/cha/' . rawurlencode($strHost) . '/';
$arrResponse = self::httpGet(
$strUrl,
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36'
);
$strHtml = (string)($arrResponse['body'] ?? '');
$strFinalUrl = (string)($arrResponse['final_url'] ?? '');
$intStatus = (int)($arrResponse['http_status'] ?? 0);
$strTitle = self::extractTitle($strHtml);
$boolCaptcha = self::detectCaptcha($strHtml, $strFinalUrl, $strTitle);
if (!empty($arrResponse['error_message'])) {
return self::emptyItem($strHost, 'blocked', (string)$arrResponse['error_message'], [
'http_status' => $intStatus,
'final_url' => $strFinalUrl,
'page_title' => $strTitle,
'query_page_url' => $strUrl,
]);
}
if ($boolCaptcha) {
return self::emptyItem($strHost, 'captcha', '', [
'http_status' => $intStatus,
'final_url' => $strFinalUrl,
'page_title' => $strTitle,
'query_page_url' => $strUrl,
]);
}
$strBaiduPcIp = self::extractById($strHtml, 'baidurank_ip');
$strBaiduMobileIp = self::extractById($strHtml, 'baidurank_m_ip');
$intPcKeywordCount = self::extractKeywordCount($strHtml, 'https://baidurank.aizhan.com/baidu/' . $strHost);
$intMobileKeywordCount = self::extractKeywordCount($strHtml, 'https://baidurank.aizhan.com/mobile/' . $strHost);
$strStatus = 'unknown';
if (
$strTitle !== ''
&& str_contains($strTitle, '的综合查询')
&& ($strBaiduPcIp !== '' || $strBaiduMobileIp !== '' || $intPcKeywordCount >= 0 || $intMobileKeywordCount >= 0)
) {
$strStatus = 'ready';
}
return [
'host' => $strHost,
'status' => $strStatus,
'http_status' => $intStatus,
'final_url' => $strFinalUrl,
'page_title' => $strTitle,
'site_title' => self::extractSiteTitle($strTitle),
'query_page_url' => $strUrl,
'baidu_pc_ip_range' => $strBaiduPcIp,
'baidu_mobile_ip_range' => $strBaiduMobileIp,
'pc_keyword_count' => $intPcKeywordCount,
'mobile_keyword_count' => $intMobileKeywordCount,
'pc_rank_url' => 'https://baidurank.aizhan.com/baidu/' . $strHost,
'mobile_rank_url' => 'https://baidurank.aizhan.com/mobile/' . $strHost,
'error_message' => '',
'probed_at' => date(DATE_ATOM),
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/aizhan-keyword-probe.summary.json';
$strHtmlPath = $strOutputRoot . '/aizhan-keyword-probe.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function emptyItem(string $strHost, string $strStatus, string $strError = '', array $arrAppend = []): array
{
return array_merge([
'host' => $strHost,
'status' => $strStatus,
'http_status' => 0,
'final_url' => '',
'page_title' => '',
'site_title' => '',
'query_page_url' => '',
'baidu_pc_ip_range' => '',
'baidu_mobile_ip_range' => '',
'pc_keyword_count' => 0,
'mobile_keyword_count' => 0,
'pc_rank_url' => '',
'mobile_rank_url' => '',
'error_message' => $strError,
'probed_at' => date(DATE_ATOM),
], $arrAppend);
}
protected static function httpGet(string $strUrl, string $strUserAgent): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $strUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HTTPHEADER => [
'Accept-Language: zh-CN,zh;q=0.9',
],
CURLOPT_USERAGENT => $strUserAgent,
CURLOPT_HEADER => false,
]);
$strBody = (string)curl_exec($ch);
$strError = curl_error($ch);
$intStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$strFinalUrl = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
curl_close($ch);
return [
'http_status' => $intStatus,
'final_url' => $strFinalUrl,
'body' => $strBody,
'error_message' => $strError,
];
}
protected static function detectCaptcha(string $strHtml, string $strFinalUrl, string $strTitle): bool
{
$strCombined = strtolower($strHtml . "\n" . $strFinalUrl . "\n" . $strTitle);
return str_contains($strCombined, 'aliyuncaptcha')
&& (str_contains($strCombined, '请完成验证') || str_contains($strCombined, '验证码') || str_contains($strCombined, '安全验证'));
}
protected static function extractTitle(string $strHtml): string
{
if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $strHtml, $arrMatches)) {
return trim(html_entity_decode(strip_tags((string)$arrMatches[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
return '';
}
protected static function extractSiteTitle(string $strTitle): string
{
if (preg_match('/^[^_]+的综合查询_([^_]+)_爱站网/u', $strTitle, $arrMatches)) {
return trim((string)$arrMatches[1]);
}
return '';
}
protected static function extractById(string $strHtml, string $strId): string
{
$strPattern = '/id="' . preg_quote($strId, '/') . '"[^>]*>(.*?)<\/[^>]+>/is';
if (preg_match($strPattern, $strHtml, $arrMatches)) {
return trim(html_entity_decode(strip_tags((string)$arrMatches[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
return '';
}
protected static function extractKeywordCount(string $strHtml, string $strUrl): int
{
$strPattern = '/<a[^>]+href="' . preg_quote($strUrl, '/') . '[^"]*"[^>]*class="gjc_more"[^>]*>.*?<b>(\d+)<\/b>/is';
if (preg_match($strPattern, $strHtml, $arrMatches)) {
return (int)$arrMatches[1];
}
return 0;
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['status'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['site_title'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['baidu_pc_ip_range'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['baidu_mobile_ip_range'] ?? '')) . '</td>'
. '<td>' . (int)($arrItem['pc_keyword_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['mobile_keyword_count'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrItem['page_title'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['probed_at'] ?? '')) . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Aizhan Keyword Probe Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;color:#222}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px;text-align:left}th{background:#f5f7fa}.metrics{display:flex;gap:12px;flex-wrap:wrap;margin:16px 0}.card{border:1px solid #ddd;border-radius:8px;padding:12px;min-width:120px;background:#fafafa}.label{font-size:12px;color:#666}.value{font-size:22px;font-weight:600;margin-top:6px}</style>'
. '</head><body>'
. '<h1>爱站关键词摘要</h1>'
. '<p>generated_at: ' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '</p>'
. '<p>' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<div class="metrics">'
. '<div class="card"><div class="label">host_count</div><div class="value">' . (int)($arrSummary['host_count'] ?? 0) . '</div></div>'
. '<div class="card"><div class="label">ready</div><div class="value">' . (int)((($arrSummary['status_buckets'] ?? [])['ready']) ?? 0) . '</div></div>'
. '<div class="card"><div class="label">captcha</div><div class="value">' . (int)((($arrSummary['status_buckets'] ?? [])['captcha']) ?? 0) . '</div></div>'
. '<div class="card"><div class="label">blocked</div><div class="value">' . (int)((($arrSummary['status_buckets'] ?? [])['blocked']) ?? 0) . '</div></div>'
. '</div>'
. '<table><thead><tr><th>host</th><th>status</th><th>site_title</th><th>baidu_pc_ip_range</th><th>baidu_mobile_ip_range</th><th>pc_keyword_count</th><th>mobile_keyword_count</th><th>page_title</th><th>probed_at</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table></body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (is_dir($strDir)) {
return;
}
if (!mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('创建目录失败:' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoAizhanKeywordTrendHelper
{
public static function buildSummary(string $strCodeRoot, int $intLimit = 20): array
{
$intProbeLimit = max(1, min(300, $intLimit * 3));
$arrSummary = DomainExternalSeoAizhanKeywordProbeHelper::buildSummary($strCodeRoot, $intProbeLimit);
$arrAttention = DomainExternalSeoAizhanKeywordAttentionQueueHelper::buildQueue($strCodeRoot, $intProbeLimit);
$intKeywordReadyCount = 0;
$intTrafficReadyCount = 0;
$intZeroKeywordCount = 0;
$intZeroTrafficCount = 0;
$arrReadyItems = [];
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$intPcKeywordCount = (int)($arrItem['pc_keyword_count'] ?? 0);
$intMobileKeywordCount = (int)($arrItem['mobile_keyword_count'] ?? 0);
$strPcIpRange = trim((string)($arrItem['baidu_pc_ip_range'] ?? ''));
$strMobileIpRange = trim((string)($arrItem['baidu_mobile_ip_range'] ?? ''));
$boolKeywordReady = $intPcKeywordCount > 0 || $intMobileKeywordCount > 0;
$boolTrafficReady = !(self::isZeroRange($strPcIpRange) && self::isZeroRange($strMobileIpRange));
if ($boolKeywordReady) {
$intKeywordReadyCount++;
} else {
$intZeroKeywordCount++;
}
if ($boolTrafficReady) {
$intTrafficReadyCount++;
} else {
$intZeroTrafficCount++;
}
if ((string)($arrItem['status'] ?? '') === 'ready') {
$arrReadyItems[] = [
'host' => (string)($arrItem['host'] ?? ''),
'baidu_pc_ip_range' => $strPcIpRange,
'baidu_mobile_ip_range' => $strMobileIpRange,
'pc_keyword_count' => $intPcKeywordCount,
'mobile_keyword_count' => $intMobileKeywordCount,
'probed_at' => (string)($arrItem['probed_at'] ?? ''),
];
}
}
usort($arrReadyItems, static function (array $arrLeft, array $arrRight): int {
$intLeftKeywords = (int)($arrLeft['pc_keyword_count'] ?? 0) + (int)($arrLeft['mobile_keyword_count'] ?? 0);
$intRightKeywords = (int)($arrRight['pc_keyword_count'] ?? 0) + (int)($arrRight['mobile_keyword_count'] ?? 0);
if ($intLeftKeywords !== $intRightKeywords) {
return $intRightKeywords <=> $intLeftKeywords;
}
return strcmp((string)($arrLeft['host'] ?? ''), (string)($arrRight['host'] ?? ''));
});
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'aizhan_keyword_proxy_trend',
'host_count' => (int)($arrSummary['host_count'] ?? 0),
'ready_count' => (int)((($arrSummary['status_buckets'] ?? [])['ready']) ?? 0),
'captcha_count' => (int)((($arrSummary['status_buckets'] ?? [])['captcha']) ?? 0),
'blocked_count' => (int)((($arrSummary['status_buckets'] ?? [])['blocked']) ?? 0),
'queue_count' => (int)($arrAttention['queue_count'] ?? 0),
'keyword_ready_count' => $intKeywordReadyCount,
'traffic_ready_count' => $intTrafficReadyCount,
'zero_keyword_count' => $intZeroKeywordCount,
'zero_traffic_count' => $intZeroTrafficCount,
'health_label' => self::buildHealthLabel(
(int)((($arrSummary['status_buckets'] ?? [])['blocked']) ?? 0),
(int)((($arrSummary['status_buckets'] ?? [])['captcha']) ?? 0),
$intKeywordReadyCount,
$intTrafficReadyCount,
(int)($arrSummary['host_count'] ?? 0)
),
'ready_items' => array_slice($arrReadyItems, 0, $intLimit),
'note' => '当前趋势是爱站关键词 proxy trend复用摘要和异常队列优先判断这批域名当前处于 active、warmup、cold 还是 blocked。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/aizhan-keyword-trend.summary.json';
$strHtmlPath = $strOutputRoot . '/aizhan-keyword-trend.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function buildHealthLabel(
int $intBlockedCount,
int $intCaptchaCount,
int $intKeywordReadyCount,
int $intTrafficReadyCount,
int $intHostCount
): string {
if ($intBlockedCount > 0 || $intCaptchaCount >= max(1, (int)floor($intHostCount / 2))) {
return 'blocked';
}
if ($intTrafficReadyCount > 0) {
return 'active';
}
if ($intKeywordReadyCount > 0) {
return 'warmup';
}
return 'cold';
}
protected static function isZeroRange(string $strRange): bool
{
$strRange = trim($strRange);
return $strRange === '' || $strRange === '0 ~ 0' || $strRange === '0~0';
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['ready_items'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['baidu_pc_ip_range'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['baidu_mobile_ip_range'] ?? '')) . '</td>'
. '<td>' . (int)($arrItem['pc_keyword_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['mobile_keyword_count'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrItem['probed_at'] ?? '')) . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Aizhan Keyword Trend Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>Aizhan Keyword Trend Summary</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>health_label</strong><br>' . self::escape((string)($arrSummary['health_label'] ?? '')) . '</div>'
. '<div class="card"><strong>ready_count</strong><br>' . (int)($arrSummary['ready_count'] ?? 0) . '</div>'
. '<div class="card"><strong>queue_count</strong><br>' . (int)($arrSummary['queue_count'] ?? 0) . '</div>'
. '<div class="card"><strong>keyword_ready_count</strong><br>' . (int)($arrSummary['keyword_ready_count'] ?? 0) . '</div>'
. '<div class="card"><strong>traffic_ready_count</strong><br>' . (int)($arrSummary['traffic_ready_count'] ?? 0) . '</div>'
. '<div class="card"><strong>zero_keyword_count</strong><br>' . (int)($arrSummary['zero_keyword_count'] ?? 0) . '</div>'
. '</div>'
. '<table><thead><tr><th>host</th><th>baidu_pc_ip_range</th><th>baidu_mobile_ip_range</th><th>pc_keyword_count</th><th>mobile_keyword_count</th><th>probed_at</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoBaiduFeedbackSummaryHelper
{
public static function buildSummary(string $strCodeRoot, int $intLimit = 20): array
{
$intLimit = max(1, min(100, $intLimit));
$arrRows = DomainExternalSeoManualImportHelper::loadLatestRows($strCodeRoot);
if (empty($arrRows)) {
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'baidu_feedback_manual_summary',
'status' => 'empty',
'rows_count' => 0,
'hosts_count' => 0,
'providers' => [],
'metrics' => [
'impressions' => 0,
'clicks' => 0,
'ctr' => 0,
'avg_position' => null,
],
'host_rows' => [],
'top_queries' => [],
'top_pages' => [],
'note' => '当前还没有可用的站外手工报表,因此百度反馈摘要为空。',
];
}
$arrBaiduRows = array_values(array_filter($arrRows, static function (array $arrRow): bool {
$strProvider = strtolower(trim((string)($arrRow['provider'] ?? '')));
return $strProvider !== '' && str_contains($strProvider, 'baidu');
}));
if (empty($arrBaiduRows)) {
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'baidu_feedback_manual_summary',
'status' => 'no_baidu_rows',
'rows_count' => 0,
'hosts_count' => 0,
'providers' => [],
'metrics' => [
'impressions' => 0,
'clicks' => 0,
'ctr' => 0,
'avg_position' => null,
],
'host_rows' => [],
'top_queries' => [],
'top_pages' => [],
'note' => '当前手工报表已存在,但还没有 provider 包含 baidu 的数据行。后续导入百度资源平台导出的报表时,请把 provider 写成 baidu_webmaster 或带 baidu 标识的值。',
];
}
$intImpressions = 0;
$intClicks = 0;
$floatWeightedPosition = 0.0;
$intPositionWeight = 0;
$arrProviders = [];
$arrHosts = [];
$arrQueries = [];
$arrPages = [];
foreach ($arrBaiduRows as $arrRow) {
$strProvider = trim((string)($arrRow['provider'] ?? ''));
$strHost = trim((string)($arrRow['host'] ?? ''));
$strQuery = trim((string)($arrRow['query'] ?? ''));
$strPage = trim((string)($arrRow['page'] ?? ''));
$intRowImpressions = (int)round((float)($arrRow['impressions'] ?? 0));
$intRowClicks = (int)round((float)($arrRow['clicks'] ?? 0));
$floatPosition = is_numeric((string)($arrRow['avg_position'] ?? '')) ? (float)$arrRow['avg_position'] : null;
$intImpressions += $intRowImpressions;
$intClicks += $intRowClicks;
if ($strProvider !== '') {
$arrProviders[$strProvider] = true;
}
if ($floatPosition !== null && $intRowImpressions > 0) {
$floatWeightedPosition += $floatPosition * $intRowImpressions;
$intPositionWeight += $intRowImpressions;
}
if ($strHost !== '') {
if (!isset($arrHosts[$strHost])) {
$arrHosts[$strHost] = [
'host' => $strHost,
'provider' => $strProvider,
'rows_count' => 0,
'impressions' => 0,
'clicks' => 0,
'weighted_position' => 0.0,
'position_weight' => 0,
];
}
$arrHosts[$strHost]['rows_count']++;
$arrHosts[$strHost]['impressions'] += $intRowImpressions;
$arrHosts[$strHost]['clicks'] += $intRowClicks;
if ($floatPosition !== null && $intRowImpressions > 0) {
$arrHosts[$strHost]['weighted_position'] += $floatPosition * $intRowImpressions;
$arrHosts[$strHost]['position_weight'] += $intRowImpressions;
}
}
if ($strQuery !== '') {
$arrQueries[$strQuery] = ($arrQueries[$strQuery] ?? 0) + $intRowImpressions;
}
if ($strPage !== '') {
$arrPages[$strPage] = ($arrPages[$strPage] ?? 0) + $intRowImpressions;
}
}
arsort($arrQueries);
arsort($arrPages);
$arrHostRows = array_map(static function (array $arrHost): array {
$intImpressions = (int)($arrHost['impressions'] ?? 0);
$intClicks = (int)($arrHost['clicks'] ?? 0);
$intWeight = (int)($arrHost['position_weight'] ?? 0);
return [
'host' => (string)($arrHost['host'] ?? ''),
'provider' => (string)($arrHost['provider'] ?? ''),
'rows_count' => (int)($arrHost['rows_count'] ?? 0),
'impressions' => $intImpressions,
'clicks' => $intClicks,
'ctr' => $intImpressions > 0 ? round($intClicks / $intImpressions, 4) : 0,
'avg_position' => $intWeight > 0 ? round(((float)($arrHost['weighted_position'] ?? 0)) / $intWeight, 2) : null,
];
}, array_values($arrHosts));
usort($arrHostRows, static function (array $arrLeft, array $arrRight): int {
$intCmp = (int)($arrRight['impressions'] ?? 0) <=> (int)($arrLeft['impressions'] ?? 0);
if ($intCmp !== 0) {
return $intCmp;
}
return strcmp((string)($arrLeft['host'] ?? ''), (string)($arrRight['host'] ?? ''));
});
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'baidu_feedback_manual_summary',
'status' => 'ready',
'rows_count' => count($arrBaiduRows),
'hosts_count' => count($arrHostRows),
'providers' => array_values(array_keys($arrProviders)),
'metrics' => [
'impressions' => $intImpressions,
'clicks' => $intClicks,
'ctr' => $intImpressions > 0 ? round($intClicks / $intImpressions, 4) : 0,
'avg_position' => $intPositionWeight > 0 ? round($floatWeightedPosition / $intPositionWeight, 2) : null,
],
'host_rows' => array_slice($arrHostRows, 0, $intLimit),
'top_queries' => array_map(static function (string $strQuery, int $intImpressions): array {
return [
'query' => $strQuery,
'impressions' => $intImpressions,
];
}, array_keys(array_slice($arrQueries, 0, 10, true)), array_values(array_slice($arrQueries, 0, 10, true))),
'top_pages' => array_map(static function (string $strPage, int $intImpressions): array {
return [
'page' => $strPage,
'impressions' => $intImpressions,
];
}, array_keys(array_slice($arrPages, 0, 10, true)), array_values(array_slice($arrPages, 0, 10, true))),
'note' => '当前百度反馈摘要复用站外手工报表导入链,只聚合 provider 包含 baidu 的数据行。它和百度推送链解耦,更适合先看大陆 SEO 结果面。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/baidu-feedback.summary.json';
$strHtmlPath = $strOutputRoot . '/baidu-feedback.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrHostRows = [];
foreach ((array)($arrSummary['host_rows'] ?? []) as $arrHost) {
$arrHostRows[] = '<tr>'
. '<td>' . self::escape((string)($arrHost['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrHost['provider'] ?? '')) . '</td>'
. '<td>' . (int)($arrHost['rows_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrHost['impressions'] ?? 0) . '</td>'
. '<td>' . (int)($arrHost['clicks'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrHost['ctr'] ?? '0')) . '</td>'
. '<td>' . self::escape((string)($arrHost['avg_position'] ?? '-')) . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Baidu Feedback Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>Baidu Feedback Summary</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>status</strong><br>' . self::escape((string)($arrSummary['status'] ?? '')) . '</div>'
. '<div class="card"><strong>hosts_count</strong><br>' . (int)($arrSummary['hosts_count'] ?? 0) . '</div>'
. '<div class="card"><strong>impressions</strong><br>' . self::escape((string)(($arrSummary['metrics'] ?? [])['impressions'] ?? 0)) . '</div>'
. '<div class="card"><strong>clicks</strong><br>' . self::escape((string)(($arrSummary['metrics'] ?? [])['clicks'] ?? 0)) . '</div>'
. '<div class="card"><strong>ctr</strong><br>' . self::escape((string)(($arrSummary['metrics'] ?? [])['ctr'] ?? 0)) . '</div>'
. '<div class="card"><strong>avg_position</strong><br>' . self::escape((string)(($arrSummary['metrics'] ?? [])['avg_position'] ?? '-')) . '</div>'
. '<div class="card"><strong>rows_count</strong><br>' . (int)($arrSummary['rows_count'] ?? 0) . '</div>'
. '<div class="card"><strong>providers</strong><br>' . self::escape(implode(', ', (array)($arrSummary['providers'] ?? []))) . '</div>'
. '</div>'
. '<table><thead><tr><th>host</th><th>provider</th><th>rows</th><th>impressions</th><th>clicks</th><th>ctr</th><th>avg_position</th></tr></thead><tbody>'
. implode('', $arrHostRows)
. '</tbody></table>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoBaiduProviderPlanHelper
{
public static function buildSummary(string $strCodeRoot): array
{
$arrProviderSummary = DomainExternalSeoProviderRegistryHelper::buildSummary($strCodeRoot);
$arrBaiduProvider = [];
foreach ((array)($arrProviderSummary['providers'] ?? []) as $arrProvider) {
if ((string)($arrProvider['provider_key'] ?? '') === 'baidu_webmaster') {
$arrBaiduProvider = (array)$arrProvider;
break;
}
}
$boolCredentialReady = !empty($arrBaiduProvider['credential_path']);
$strStatus = 'planned';
$arrNextSteps = [
'当前先复用现有 PlanTask 的百度推送执行链',
'再补百度资源平台凭证文件',
'最后再补提交状态 / 收录反馈观察层',
];
if ($boolCredentialReady) {
$strStatus = 'credential_ready_for_baidu_submission_chain';
$arrNextSteps = [
'当前百度资源平台凭证已就绪',
'继续复用现有 PlanTask 推送链',
'下一步补资源状态 / 收录反馈观察层,不把它误当成点击 / 展现效果报表',
];
}
return [
'generated_at' => date(DATE_ATOM),
'provider_key' => 'baidu_webmaster',
'provider_label' => '百度搜索资源平台',
'status' => $strStatus,
'credential_ready' => $boolCredentialReady,
'credential_path' => (string)($arrBaiduProvider['credential_path'] ?? ''),
'credential_updated_at' => (string)($arrBaiduProvider['credential_updated_at'] ?? ''),
'credential_file_size' => (int)($arrBaiduProvider['credential_file_size'] ?? 0),
'submission_scope' => [
'capabilities' => ['push', 'submission_status', 'resource_feedback'],
'note' => '当前百度主线优先复用既有推送任务,再补提交状态 / 收录反馈;不把它当成点击 / 展现主报表。',
],
'next_steps' => $arrNextSteps,
'note' => '当前是百度 provider 计划层:它聚焦大陆 SEO 主线下的百度推送 / 提交 / 收录反馈准备态。百度推送执行层已在 PlanTask 中存在,这里不重复造执行链,只补准备态和观察层。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/baidu-provider-plan.summary.json';
$strHtmlPath = $strOutputRoot . '/baidu-provider-plan.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrStepRows = [];
foreach ((array)($arrSummary['next_steps'] ?? []) as $strStep) {
$arrStepRows[] = '<li>' . self::escape((string)$strStep) . '</li>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Baidu Provider Plan</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}code{background:#f3f3f3;padding:2px 4px;} .grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>Baidu Provider Plan</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>status</strong><br>' . self::escape((string)($arrSummary['status'] ?? '')) . '</div>'
. '<div class="card"><strong>credential_ready</strong><br>' . (!empty($arrSummary['credential_ready']) ? 'yes' : 'no') . '</div>'
. '<div class="card"><strong>credential_size</strong><br>' . (int)($arrSummary['credential_file_size'] ?? 0) . '</div>'
. '<div class="card"><strong>updated_at</strong><br>' . self::escape((string)($arrSummary['credential_updated_at'] ?? '-')) . '</div>'
. '</div>'
. '<p>credential_path: <code>' . self::escape((string)($arrSummary['credential_path'] ?? '')) . '</code></p>'
. '<p>submission_scope: <code>' . self::escape(implode(', ', (array)(($arrSummary['submission_scope'] ?? [])['capabilities'] ?? []))) . '</code></p>'
. '<h2>Next Steps</h2><ul>' . implode('', $arrStepRows) . '</ul>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoBaiduPushAttentionQueueHelper
{
public static function buildQueue(string $strCodeRoot, int $intLimit = 100): array
{
$intLimit = max(1, min(300, $intLimit));
$arrSummary = DomainExternalSeoBaiduPushSummaryHelper::buildSummary($strCodeRoot, 500);
$arrSystemItems = [];
if (empty($arrSummary['db_ready'])) {
$arrSystemItems[] = [
'scope' => 'system',
'attention_level' => 'critical',
'reason_codes' => ['db_blocked'],
'reason_summary' => '当前脚本环境无法读取数据库配置,百度推送观察层只能返回骨架。',
'recommended_action' => '优先确认 CLI / 任务环境的数据库配置是否完整,再继续看域名级异常。',
'db_error' => (string)($arrSummary['db_error'] ?? ''),
];
}
if (empty($arrSummary['task_enabled'])) {
$arrSystemItems[] = [
'scope' => 'system',
'attention_level' => 'warning',
'reason_codes' => ['task_disabled'],
'reason_summary' => 'PUSH_BAIDU_VIDEO_URL 任务当前未启用。',
'recommended_action' => '先确认 PlanTask 里的百度推送任务是否应恢复为启用态。',
'db_error' => '',
];
}
$arrItems = [];
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$arrReasons = [];
$strLevel = 'warning';
if (empty($arrItem['token_ready'])) {
$arrReasons[] = 'token_missing';
$strLevel = 'critical';
}
if ((int)($arrItem['txt_file_count'] ?? 0) <= 0) {
$arrReasons[] = 'txt_missing';
}
if (!empty($arrItem['token_ready']) && empty($arrItem['state_ready'])) {
$arrReasons[] = 'state_missing';
}
if (!empty($arrItem['state_ready']) && (string)($arrItem['updated_at'] ?? '') === '') {
$arrReasons[] = 'state_timestamp_missing';
}
if (empty($arrReasons)) {
continue;
}
$arrItems[] = [
'scope' => 'domain',
'domain' => (string)($arrItem['domain'] ?? ''),
'attention_level' => $strLevel,
'reason_codes' => $arrReasons,
'reason_summary' => self::buildReasonSummary($arrReasons),
'recommended_action' => self::buildRecommendedAction($arrReasons),
'token_ready' => !empty($arrItem['token_ready']),
'token_masked' => (string)($arrItem['token_masked'] ?? ''),
'state_ready' => !empty($arrItem['state_ready']),
'state_path' => (string)($arrItem['state_path'] ?? ''),
'txt_file_count' => (int)($arrItem['txt_file_count'] ?? 0),
'latest_txt_file' => (string)($arrItem['latest_txt_file'] ?? ''),
'file_index' => (int)($arrItem['file_index'] ?? 0),
'line_index' => (int)($arrItem['line_index'] ?? 0),
'updated_at' => (string)($arrItem['updated_at'] ?? ''),
];
}
usort($arrItems, static function (array $arrLeft, array $arrRight): int {
$arrLevelWeight = ['critical' => 0, 'warning' => 1, 'info' => 2];
$intLeftWeight = $arrLevelWeight[$arrLeft['attention_level'] ?? 'info'] ?? 9;
$intRightWeight = $arrLevelWeight[$arrRight['attention_level'] ?? 'info'] ?? 9;
if ($intLeftWeight !== $intRightWeight) {
return $intLeftWeight <=> $intRightWeight;
}
return strcmp((string)($arrLeft['domain'] ?? ''), (string)($arrRight['domain'] ?? ''));
});
return [
'generated_at' => date(DATE_ATOM),
'task_code' => (string)($arrSummary['task_code'] ?? 'PUSH_BAIDU_VIDEO_URL'),
'task_enabled' => !empty($arrSummary['task_enabled']),
'task_last_exec' => (string)($arrSummary['task_last_exec'] ?? ''),
'db_ready' => !empty($arrSummary['db_ready']),
'db_error' => (string)($arrSummary['db_error'] ?? ''),
'queue_count' => count($arrItems),
'system_attention_count' => count($arrSystemItems),
'items' => array_slice($arrItems, 0, $intLimit),
'system_items' => $arrSystemItems,
'note' => '当前队列复用已存在的百度推送执行链和观察摘要,不重做推送逻辑,只把需要人工关注的系统级和域名级问题收出来。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrQueue): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/baidu-push-attention.summary.json';
$strHtmlPath = $strOutputRoot . '/baidu-push-attention.summary.html';
$arrQueue['summary_json_path'] = $strJsonPath;
$arrQueue['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrQueue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrQueue));
return $arrQueue;
}
protected static function renderHtml(array $arrQueue): string
{
$arrSystemRows = [];
foreach ((array)($arrQueue['system_items'] ?? []) as $arrItem) {
$arrSystemRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['attention_level'] ?? '')) . '</td>'
. '<td>' . self::escape(implode(', ', (array)($arrItem['reason_codes'] ?? []))) . '</td>'
. '<td>' . self::escape((string)($arrItem['reason_summary'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['recommended_action'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['db_error'] ?? '')) . '</td>'
. '</tr>';
}
$arrRows = [];
foreach ((array)($arrQueue['items'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['domain'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['attention_level'] ?? '')) . '</td>'
. '<td>' . self::escape(implode(', ', (array)($arrItem['reason_codes'] ?? []))) . '</td>'
. '<td>' . (!empty($arrItem['token_ready']) ? self::escape((string)($arrItem['token_masked'] ?? 'yes')) : 'no') . '</td>'
. '<td>' . (!empty($arrItem['state_ready']) ? 'yes' : 'no') . '</td>'
. '<td>' . (int)($arrItem['txt_file_count'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrItem['latest_txt_file'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['recommended_action'] ?? '')) . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Baidu Push Attention Queue</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>Baidu Push Attention Queue</h1>'
. '<p class="muted">' . self::escape((string)($arrQueue['note'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>queue_count</strong><br>' . (int)($arrQueue['queue_count'] ?? 0) . '</div>'
. '<div class="card"><strong>system_attention_count</strong><br>' . (int)($arrQueue['system_attention_count'] ?? 0) . '</div>'
. '<div class="card"><strong>task_enabled</strong><br>' . (!empty($arrQueue['task_enabled']) ? 'yes' : 'no') . '</div>'
. '<div class="card"><strong>db_ready</strong><br>' . (!empty($arrQueue['db_ready']) ? 'yes' : 'no') . '</div>'
. '</div>'
. '<p>task_last_exec: <code>' . self::escape((string)($arrQueue['task_last_exec'] ?? '')) . '</code></p>'
. '<p>db_error: <code>' . self::escape((string)($arrQueue['db_error'] ?? '')) . '</code></p>'
. '<h2>System Attention</h2>'
. '<table><thead><tr><th>level</th><th>reasons</th><th>summary</th><th>recommended_action</th><th>db_error</th></tr></thead><tbody>'
. implode('', $arrSystemRows)
. '</tbody></table>'
. '<h2>Domain Queue</h2>'
. '<table><thead><tr><th>domain</th><th>level</th><th>reasons</th><th>token</th><th>state</th><th>txt_files</th><th>latest_txt_file</th><th>recommended_action</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table>'
. '</body></html>';
}
protected static function buildReasonSummary(array $arrReasons): string
{
$arrLabels = [
'token_missing' => '缺少百度推送 token',
'txt_missing' => '缺少可推送的 txt URL 文件',
'state_missing' => '还没有百度推送 state 进度文件',
'state_timestamp_missing' => 'state 文件缺少进度更新时间',
];
$arrSummary = [];
foreach ($arrReasons as $strReason) {
$arrSummary[] = $arrLabels[$strReason] ?? $strReason;
}
return implode('', $arrSummary);
}
protected static function buildRecommendedAction(array $arrReasons): string
{
if (in_array('token_missing', $arrReasons, true)) {
return '先补站点 d_baidu_token再看推送链是否继续产出 state。';
}
if (in_array('txt_missing', $arrReasons, true)) {
return '先确认 SiteMap/<domain> 下是否已经产出待推送 txt 文件。';
}
if (in_array('state_missing', $arrReasons, true)) {
return '先确认 PlanTask 百度推送任务是否启用,并观察首次执行后是否生成 baidu_state.json。';
}
return '建议先看最近一次推送任务执行和该域名 SiteMap 目录是否有新变化。';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
use app\model\PlanTaskModel;
class DomainExternalSeoBaiduPushSummaryHelper
{
public static function buildSummary(string $strCodeRoot, int $intLimit = 50): array
{
$intLimit = max(1, min(200, $intLimit));
$strSiteMapRoot = rtrim($strCodeRoot, '/') . '/storage/SiteMap';
$strDbError = '';
try {
$PlanTaskModel = PlanTaskModel::where('pt_code', 'PUSH_BAIDU_VIDEO_URL')->find();
} catch (\Throwable $Throwable) {
$PlanTaskModel = null;
$strDbError = $Throwable->getMessage();
}
try {
$arrDomains = array_values(array_filter(DomainModel::getAllDomainOnCache(), static function ($mItem): bool {
return $mItem instanceof DomainModel;
}));
} catch (\Throwable $Throwable) {
$arrDomains = [];
if ($strDbError === '') {
$strDbError = $Throwable->getMessage();
}
}
$arrItems = [];
$intTokenReadyCount = 0;
$intStateReadyCount = 0;
foreach ($arrDomains as $DomainModel) {
$strDomain = trim((string)$DomainModel->d_domain);
if ($strDomain === '') {
continue;
}
$strToken = trim((string)($DomainModel->d_baidu_token ?? ''));
$boolTokenReady = $strToken !== '';
if ($boolTokenReady) {
$intTokenReadyCount++;
}
$strDomainDir = $strSiteMapRoot . '/' . $strDomain;
$strStatePath = $strDomainDir . '/baidu_state.json';
$arrState = self::readJsonFile($strStatePath);
$boolStateReady = is_file($strStatePath) && !empty($arrState);
if ($boolStateReady) {
$intStateReadyCount++;
}
$arrTxtFiles = is_dir($strDomainDir)
? array_values(array_filter((array)glob($strDomainDir . '/*.txt'), 'is_file'))
: [];
sort($arrTxtFiles);
$arrItems[] = [
'domain' => $strDomain,
'token_ready' => $boolTokenReady,
'token_masked' => self::maskToken($strToken),
'state_ready' => $boolStateReady,
'state_path' => is_file($strStatePath) ? $strStatePath : '',
'file_index' => (int)($arrState['file_index'] ?? 0),
'line_index' => (int)($arrState['line_index'] ?? 0),
'updated_at' => (string)($arrState['updated_at'] ?? ''),
'txt_file_count' => count($arrTxtFiles),
'latest_txt_file' => !empty($arrTxtFiles) ? basename($arrTxtFiles[count($arrTxtFiles) - 1]) : '',
];
}
usort($arrItems, static function (array $arrLeft, array $arrRight): int {
return strcmp((string)($arrLeft['domain'] ?? ''), (string)($arrRight['domain'] ?? ''));
});
return [
'generated_at' => date(DATE_ATOM),
'task_code' => 'PUSH_BAIDU_VIDEO_URL',
'task_enabled' => (bool)($PlanTaskModel->pt_enable ?? false),
'task_last_exec' => !empty($PlanTaskModel->pt_last_exec) ? date(DATE_ATOM, (int)$PlanTaskModel->pt_last_exec) : '',
'domain_count' => count($arrItems),
'token_ready_count' => $intTokenReadyCount,
'state_ready_count' => $intStateReadyCount,
'db_ready' => $strDbError === '',
'db_error' => $strDbError,
'items' => array_slice($arrItems, 0, $intLimit),
'note' => $strDbError === ''
? '当前摘要复用现有 PlanTask 百度推送执行链,只做观察层:看任务是否启用、哪些域名配置了 token、哪些域名已经产生日志/进度状态。'
: '当前摘要复用现有 PlanTask 百度推送执行链,但当前脚本环境没有可用数据库配置,因此只能返回观察骨架和阻塞原因。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/baidu-push.summary.json';
$strHtmlPath = $strOutputRoot . '/baidu-push.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['domain'] ?? '')) . '</td>'
. '<td>' . (!empty($arrItem['token_ready']) ? 'yes' : 'no') . '</td>'
. '<td>' . self::escape((string)($arrItem['token_masked'] ?? '')) . '</td>'
. '<td>' . (!empty($arrItem['state_ready']) ? 'yes' : 'no') . '</td>'
. '<td>' . (int)($arrItem['txt_file_count'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrItem['latest_txt_file'] ?? '')) . '</td>'
. '<td>' . (int)($arrItem['file_index'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['line_index'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrItem['updated_at'] ?? '')) . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Baidu Push Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>Baidu Push Summary</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>task_enabled</strong><br>' . (!empty($arrSummary['task_enabled']) ? 'yes' : 'no') . '</div>'
. '<div class="card"><strong>domain_count</strong><br>' . (int)($arrSummary['domain_count'] ?? 0) . '</div>'
. '<div class="card"><strong>token_ready_count</strong><br>' . (int)($arrSummary['token_ready_count'] ?? 0) . '</div>'
. '<div class="card"><strong>state_ready_count</strong><br>' . (int)($arrSummary['state_ready_count'] ?? 0) . '</div>'
. '</div>'
. '<p>task_last_exec: <code>' . self::escape((string)($arrSummary['task_last_exec'] ?? '')) . '</code></p>'
. '<p>db_ready: <code>' . (!empty($arrSummary['db_ready']) ? 'yes' : 'no') . '</code></p>'
. '<p>db_error: <code>' . self::escape((string)($arrSummary['db_error'] ?? '')) . '</code></p>'
. '<table><thead><tr><th>domain</th><th>token_ready</th><th>token</th><th>state_ready</th><th>txt_files</th><th>latest_txt_file</th><th>file_index</th><th>line_index</th><th>updated_at</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table>'
. '</body></html>';
}
protected static function readJsonFile(string $strPath): array
{
if (!is_file($strPath)) {
return [];
}
$arrDecoded = json_decode((string)file_get_contents($strPath), true);
return is_array($arrDecoded) ? $arrDecoded : [];
}
protected static function maskToken(string $strToken): string
{
$strToken = trim($strToken);
if ($strToken === '') {
return '';
}
if (strlen($strToken) <= 6) {
return str_repeat('*', max(1, strlen($strToken)));
}
return substr($strToken, 0, 3) . str_repeat('*', max(1, strlen($strToken) - 6)) . substr($strToken, -3);
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\PlanTaskModel;
class DomainExternalSeoBaiduPushTaskStatusHelper
{
public static function buildSummary(): array
{
$strTaskCode = 'PUSH_BAIDU_VIDEO_URL';
$strDbError = '';
try {
$PlanTaskModel = PlanTaskModel::where('pt_code', $strTaskCode)->find();
} catch (\Throwable $Throwable) {
$PlanTaskModel = null;
$strDbError = $Throwable->getMessage();
}
return [
'generated_at' => date(DATE_ATOM),
'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'),
'note' => $strDbError !== ''
? '当前脚本环境没有可用数据库配置,因此这里只能返回任务骨架和阻塞原因。'
: '当前快捷管理直接复用 PlanTask 里的 PUSH_BAIDU_VIDEO_URL不新造任务配置链。',
];
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoBaiduPushTrendHelper
{
public static function buildSummary(string $strCodeRoot, int $intLimit = 20): array
{
$arrPushSummary = DomainExternalSeoBaiduPushSummaryHelper::buildSummary($strCodeRoot, 500);
$arrAttention = DomainExternalSeoBaiduPushAttentionQueueHelper::buildQueue($strCodeRoot, 300);
$intRecentStateCount = 0;
$intStaleStateCount = 0;
$intNeverStartedCount = 0;
$intTxtMissingCount = 0;
$arrRecentItems = [];
$intNow = time();
foreach ((array)($arrPushSummary['items'] ?? []) as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$boolTokenReady = !empty($arrItem['token_ready']);
$boolStateReady = !empty($arrItem['state_ready']);
$strUpdatedAt = trim((string)($arrItem['updated_at'] ?? ''));
$intTxtFileCount = (int)($arrItem['txt_file_count'] ?? 0);
$intUpdatedTs = $strUpdatedAt !== '' ? (int)strtotime($strUpdatedAt) : 0;
if ($boolTokenReady && !$boolStateReady) {
$intNeverStartedCount++;
}
if ($intTxtFileCount <= 0) {
$intTxtMissingCount++;
}
if ($boolStateReady && $intUpdatedTs > 0) {
$intAgeSeconds = max(0, $intNow - $intUpdatedTs);
if ($intAgeSeconds <= 86400) {
$intRecentStateCount++;
}
if ($intAgeSeconds > 86400 * 7) {
$intStaleStateCount++;
}
$arrRecentItems[] = [
'domain' => (string)($arrItem['domain'] ?? ''),
'updated_at' => date(DATE_ATOM, $intUpdatedTs),
'age_hours' => (int)floor($intAgeSeconds / 3600),
'token_ready' => $boolTokenReady,
'state_ready' => $boolStateReady,
'txt_file_count' => $intTxtFileCount,
];
}
}
usort($arrRecentItems, static function (array $arrLeft, array $arrRight): int {
return strcmp((string)($arrRight['updated_at'] ?? ''), (string)($arrLeft['updated_at'] ?? ''));
});
$strHealthLabel = self::buildHealthLabel(
!empty($arrPushSummary['db_ready']),
!empty($arrPushSummary['task_enabled']),
$intRecentStateCount,
$intStaleStateCount,
$intNeverStartedCount
);
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'baidu_push_proxy_trend',
'task_code' => (string)($arrPushSummary['task_code'] ?? 'PUSH_BAIDU_VIDEO_URL'),
'task_enabled' => !empty($arrPushSummary['task_enabled']),
'task_last_exec' => (string)($arrPushSummary['task_last_exec'] ?? ''),
'db_ready' => !empty($arrPushSummary['db_ready']),
'db_error' => (string)($arrPushSummary['db_error'] ?? ''),
'domain_count' => (int)($arrPushSummary['domain_count'] ?? 0),
'token_ready_count' => (int)($arrPushSummary['token_ready_count'] ?? 0),
'state_ready_count' => (int)($arrPushSummary['state_ready_count'] ?? 0),
'queue_count' => (int)($arrAttention['queue_count'] ?? 0),
'system_attention_count' => (int)($arrAttention['system_attention_count'] ?? 0),
'recent_state_count' => $intRecentStateCount,
'stale_state_count' => $intStaleStateCount,
'never_started_count' => $intNeverStartedCount,
'txt_missing_count' => $intTxtMissingCount,
'health_label' => $strHealthLabel,
'recent_state_items' => array_slice($arrRecentItems, 0, $intLimit),
'note' => '当前趋势是百度推送 proxy trend复用 task_last_exec、baidu_state 更新时间和异常队列,先判断这条链是 blocked、active、stagnating 还是 idle。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/baidu-push-trend.summary.json';
$strHtmlPath = $strOutputRoot . '/baidu-push-trend.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function buildHealthLabel(
bool $boolDbReady,
bool $boolTaskEnabled,
int $intRecentStateCount,
int $intStaleStateCount,
int $intNeverStartedCount
): string {
if (!$boolDbReady || !$boolTaskEnabled) {
return 'blocked';
}
if ($intRecentStateCount > 0) {
return 'active';
}
if ($intStaleStateCount > 0 || $intNeverStartedCount > 0) {
return 'stagnating';
}
return 'idle';
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['recent_state_items'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['domain'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['updated_at'] ?? '')) . '</td>'
. '<td>' . (int)($arrItem['age_hours'] ?? 0) . '</td>'
. '<td>' . (!empty($arrItem['token_ready']) ? 'yes' : 'no') . '</td>'
. '<td>' . (!empty($arrItem['state_ready']) ? 'yes' : 'no') . '</td>'
. '<td>' . (int)($arrItem['txt_file_count'] ?? 0) . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Baidu Push Trend Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(5,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>Baidu Push Trend Summary</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>health_label</strong><br>' . self::escape((string)($arrSummary['health_label'] ?? '')) . '</div>'
. '<div class="card"><strong>queue_count</strong><br>' . (int)($arrSummary['queue_count'] ?? 0) . '</div>'
. '<div class="card"><strong>recent_state_count</strong><br>' . (int)($arrSummary['recent_state_count'] ?? 0) . '</div>'
. '<div class="card"><strong>stale_state_count</strong><br>' . (int)($arrSummary['stale_state_count'] ?? 0) . '</div>'
. '<div class="card"><strong>never_started_count</strong><br>' . (int)($arrSummary['never_started_count'] ?? 0) . '</div>'
. '</div>'
. '<p>task_last_exec: <code>' . self::escape((string)($arrSummary['task_last_exec'] ?? '')) . '</code></p>'
. '<p>db_error: <code>' . self::escape((string)($arrSummary['db_error'] ?? '')) . '</code></p>'
. '<table><thead><tr><th>domain</th><th>updated_at</th><th>age_hours</th><th>token</th><th>state</th><th>txt_files</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoCnOverviewHelper
{
public static function buildOverview(int $intDays = 14, int $intLimit = 20): array
{
$intDays = max(2, min(90, $intDays));
$intLimit = max(1, min(100, $intLimit));
$arrSnapshotSummary = DomainExternalSeoSnapshotAnalysisHelper::buildSummary(min($intDays, 30), $intLimit);
$arrIndexMovement = DomainExternalSeoSnapshotAnalysisHelper::buildIndexMovementSummary($intDays, $intLimit);
$arrKeywordMovement = DomainExternalSeoSnapshotAnalysisHelper::buildKeywordMovementSummary($intDays, $intLimit);
$arrOptimization = DomainExternalSeoOptimizationSuggestionHelper::buildSuggestions($intDays, $intLimit);
$arrSuggestions = (array)($arrOptimization['suggestions'] ?? []);
$strHealthLabel = 'cold';
if ((int)($arrSnapshotSummary['indexed_like_host_count'] ?? 0) > 0) {
$strHealthLabel = 'warming';
}
if ((int)($arrKeywordMovement['up_count'] ?? 0) > (int)($arrKeywordMovement['down_count'] ?? 0)
&& (int)($arrSnapshotSummary['keyword_ready_host_count'] ?? 0) > 0) {
$strHealthLabel = 'growing';
}
if ((int)($arrIndexMovement['lost_indexed_count'] ?? 0) > 0 || (int)($arrKeywordMovement['dropped_count'] ?? 0) > 0) {
$strHealthLabel = 'attention';
}
if ((int)($arrIndexMovement['captcha_count'] ?? 0) > 0) {
$strHealthLabel = 'captcha';
}
$arrPriorityBuckets = [
'high' => 0,
'medium' => 0,
'low' => 0,
];
foreach ($arrSuggestions as $arrSuggestion) {
$strPriority = trim((string)($arrSuggestion['priority'] ?? 'low'));
if (!isset($arrPriorityBuckets[$strPriority])) {
$strPriority = 'low';
}
$arrPriorityBuckets[$strPriority]++;
}
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_external_cn_overview',
'days' => $intDays,
'health_label' => $strHealthLabel,
'priority_buckets' => $arrPriorityBuckets,
'snapshot_summary' => [
'latest_metric_date' => (string)($arrSnapshotSummary['latest_metric_date'] ?? ''),
'snapshot_count' => (int)($arrSnapshotSummary['snapshot_count'] ?? 0),
'host_count' => (int)($arrSnapshotSummary['host_count'] ?? 0),
'indexed_like_host_count' => (int)($arrSnapshotSummary['indexed_like_host_count'] ?? 0),
'keyword_ready_host_count' => (int)($arrSnapshotSummary['keyword_ready_host_count'] ?? 0),
'traffic_ready_host_count' => (int)($arrSnapshotSummary['traffic_ready_host_count'] ?? 0),
],
'index_movement' => [
'latest_metric_date' => (string)($arrIndexMovement['latest_metric_date'] ?? ''),
'newly_indexed_count' => (int)($arrIndexMovement['newly_indexed_count'] ?? 0),
'lost_indexed_count' => (int)($arrIndexMovement['lost_indexed_count'] ?? 0),
'captcha_count' => (int)($arrIndexMovement['captcha_count'] ?? 0),
'blocked_count' => (int)($arrIndexMovement['blocked_count'] ?? 0),
'no_result_count' => (int)($arrIndexMovement['no_result_count'] ?? 0),
],
'keyword_movement' => [
'latest_metric_date' => (string)($arrKeywordMovement['latest_metric_date'] ?? ''),
'new_count' => (int)($arrKeywordMovement['new_count'] ?? 0),
'up_count' => (int)($arrKeywordMovement['up_count'] ?? 0),
'down_count' => (int)($arrKeywordMovement['down_count'] ?? 0),
'dropped_count' => (int)($arrKeywordMovement['dropped_count'] ?? 0),
],
'top_hosts' => array_slice((array)($arrSnapshotSummary['hosts'] ?? []), 0, min(10, $intLimit)),
'top_suggestions' => array_slice($arrSuggestions, 0, min(5, $intLimit)),
'note' => '大陆 SEO 总览面板会把快照摘要、收录变化、关键词升降和自动优化建议合并到一个视角里,适合批量域名值班时先看大盘。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/seo-external-cn-overview.summary.json';
$strHtmlPath = $strOutputRoot . '/seo-external-cn-overview.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrHostRows = [];
foreach ((array)($arrSummary['top_hosts'] ?? []) as $arrItem) {
$arrHostRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['provider'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['indexed_status'] ?? '')) . '</td>'
. '<td>' . (int)($arrItem['pc_keyword_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['mobile_keyword_count'] ?? 0) . '</td>'
. '</tr>';
}
$arrSuggestionRows = [];
foreach ((array)($arrSummary['top_suggestions'] ?? []) as $arrItem) {
$arrSuggestionRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['priority'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['title'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['action'] ?? '')) . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>SEO External CN Overview</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>SEO External CN Overview</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<p>generated_at: <code>' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '</code></p>'
. '<p>health_label: <code>' . self::escape((string)($arrSummary['health_label'] ?? '')) . '</code></p>'
. '<div class="grid">'
. '<div class="card"><strong>host_count</strong><br>' . (int)(($arrSummary['snapshot_summary'] ?? [])['host_count'] ?? 0) . '</div>'
. '<div class="card"><strong>indexed_like</strong><br>' . (int)(($arrSummary['snapshot_summary'] ?? [])['indexed_like_host_count'] ?? 0) . '</div>'
. '<div class="card"><strong>newly_indexed</strong><br>' . (int)(($arrSummary['index_movement'] ?? [])['newly_indexed_count'] ?? 0) . '</div>'
. '<div class="card"><strong>keyword_up</strong><br>' . (int)(($arrSummary['keyword_movement'] ?? [])['up_count'] ?? 0) . '</div>'
. '</div>'
. '<h2>Top Hosts</h2>'
. '<table><thead><tr><th>host</th><th>provider</th><th>indexed_status</th><th>pc_keyword_count</th><th>mobile_keyword_count</th></tr></thead><tbody>' . implode('', $arrHostRows) . '</tbody></table>'
. '<h2>Top Suggestions</h2>'
. '<table><thead><tr><th>priority</th><th>title</th><th>action</th></tr></thead><tbody>' . implode('', $arrSuggestionRows) . '</tbody></table>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoCollectorStatusHelper
{
public static function buildSummary(string $strValidateRunRoot, string $strSnapshotRunRoot, int $intLimit = 10): array
{
$intLimit = max(1, min(50, $intLimit));
$arrValidate = DomainExternalSeoSnapshotValidateRunIndexHelper::buildSummary($strValidateRunRoot, $intLimit);
$arrSnapshot = DomainExternalSeoSnapshotRunIndexHelper::buildSummary($strSnapshotRunRoot, $intLimit);
$arrLatestValidate = (array)($arrValidate['latest_run'] ?? []);
$arrLatestSnapshot = (array)($arrSnapshot['latest_run'] ?? []);
$blTokenConfigured = self::hasCollectorTokenConfigured();
$strStatusLabel = 'not_configured';
$strDebugSummary = 'SEO_COLLECTOR_TOKEN 未配置,采集端还不能走正式 seo-collector 链路。';
if ($blTokenConfigured) {
$strStatusLabel = 'ready';
$strDebugSummary = 'seo-collector 已启用 env token 鉴权,等待采集端继续推真实批次。';
if (!empty($arrLatestValidate) && (($arrLatestValidate['alert_level'] ?? 'none') === 'high')) {
$strStatusLabel = 'attention';
$strDebugSummary = '最近一次 validate 存在高优先级异常,建议先修字段或快照结构。';
}
if (!empty($arrLatestSnapshot) && (($arrLatestSnapshot['alert_level'] ?? 'none') === 'high')) {
$strStatusLabel = 'attention';
$strDebugSummary = '最近一次 save 存在高优先级异常,建议先看快照接收记录。';
}
if (!empty($arrLatestValidate) && !empty($arrLatestValidate['passed'])) {
$strStatusLabel = 'validated';
$strDebugSummary = '最近一次 validate 已通过,采集端可以继续正式 save。';
}
if (!empty($arrLatestSnapshot) && (int)($arrLatestSnapshot['received_count'] ?? 0) > 0 && (int)($arrLatestSnapshot['failed_count'] ?? 0) === 0) {
$strStatusLabel = 'connected';
$strDebugSummary = 'seo-collector 正式链路已接通collect/validate/save 都已有成功记录。';
}
}
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_collector_status',
'status_label' => $strStatusLabel,
'token_configured' => $blTokenConfigured,
'auth_mode' => 'env_token',
'api_path_prefix' => '/seo-collector',
'supported_headers' => [
'x-seo-collector-token',
'Authorization: Bearer <token>',
],
'formal_endpoints' => [
'GET /seo-collector/collect/hosts',
'GET /seo-collector/snapshots/checklist',
'GET /seo-collector/snapshots/schema',
'POST /seo-collector/snapshots/validate',
'POST /seo-collector/snapshots/save',
],
'latest_validate_run' => [
'run_id' => (string)($arrLatestValidate['run_id'] ?? ''),
'passed' => !empty($arrLatestValidate['passed']),
'success_rate' => (float)($arrLatestValidate['success_rate'] ?? 0),
'alert_level' => (string)($arrLatestValidate['alert_level'] ?? 'none'),
'alert_reason' => (string)($arrLatestValidate['alert_reason'] ?? ''),
'updated_at' => (string)($arrLatestValidate['updated_at'] ?? ''),
],
'latest_snapshot_run' => [
'run_id' => (string)($arrLatestSnapshot['run_id'] ?? ''),
'received_count' => (int)($arrLatestSnapshot['received_count'] ?? 0),
'inserted_count' => (int)($arrLatestSnapshot['inserted_count'] ?? 0),
'updated_count' => (int)($arrLatestSnapshot['updated_count'] ?? 0),
'failed_count' => (int)($arrLatestSnapshot['failed_count'] ?? 0),
'success_rate' => (float)($arrLatestSnapshot['success_rate'] ?? 0),
'alert_level' => (string)($arrLatestSnapshot['alert_level'] ?? 'none'),
'alert_reason' => (string)($arrLatestSnapshot['alert_reason'] ?? ''),
'updated_at' => (string)($arrLatestSnapshot['updated_at'] ?? ''),
],
'debug_summary' => $strDebugSummary,
'note' => 'Node 采集正式链路现在应统一走 /seo-collector/*,不再依赖后台 admin 登录态。',
];
}
protected static function hasCollectorTokenConfigured(): bool
{
$arrCandidates = [
trim((string)getenv('SEO_COLLECTOR_TOKEN')),
trim((string)env('SEO_COLLECTOR_TOKEN', '')),
trim((string)($_SERVER['SEO_COLLECTOR_TOKEN'] ?? '')),
trim((string)($_ENV['SEO_COLLECTOR_TOKEN'] ?? '')),
];
foreach ($arrCandidates as $strToken) {
if ($strToken !== '') {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoFailedQueueHelper
{
public static function buildSummary(string $strImportRunRoot, string $strRerunRoot, int $intLimit = 50): array
{
$arrExternalSummary = DomainExternalSeoSummaryHelper::buildSummary($strImportRunRoot, $strRerunRoot, max($intLimit, 20));
$arrHosts = array_values(array_filter((array)($arrExternalSummary['hosts'] ?? []), 'is_array'));
$arrItems = [];
foreach ($arrHosts as $arrHost) {
$strReadiness = (string)($arrHost['external_readiness'] ?? '');
if ($strReadiness === 'ready') {
continue;
}
$strHost = trim((string)($arrHost['host'] ?? ''));
if ($strHost === '') {
continue;
}
$arrItems[] = [
'host' => $strHost,
'external_readiness' => $strReadiness,
'probe_status' => (string)($arrHost['probe_status'] ?? ''),
'failed_stage' => (string)($arrHost['failed_stage'] ?? ''),
'search_keyword' => (string)($arrHost['search_keyword'] ?? ''),
'detail_id' => (int)($arrHost['detail_id'] ?? 0),
'detail_slug' => (string)($arrHost['detail_slug'] ?? ''),
'sample_source' => (string)($arrHost['sample_source'] ?? ''),
'probed_at' => (string)($arrHost['probed_at'] ?? ''),
'summary_json_path' => (string)($arrHost['summary_json_path'] ?? ''),
'summary_html_path' => (string)($arrHost['summary_html_path'] ?? ''),
'sample_command' => 'php scripts/domain_auto_sample_discover.php ' . escapeshellarg($strHost) . ' --format=text',
'probe_command' => 'php scripts/domain_trajectory_probe.php ' . escapeshellarg($strHost) . ' --format=text',
];
}
usort($arrItems, static function (array $arrLeft, array $arrRight): int {
return strcmp((string)($arrRight['probed_at'] ?? ''), (string)($arrLeft['probed_at'] ?? ''));
});
$arrItems = array_slice($arrItems, 0, max(1, $intLimit));
return [
'queue_count' => count($arrItems),
'items' => $arrItems,
'latest_item' => $arrItems[0] ?? [],
'generated_at' => date(DATE_ATOM),
'mode' => (string)($arrExternalSummary['mode'] ?? 'proxy_readiness'),
'note' => '当前站外异常队列基于 proxy_readiness 摘要生成,属于站外效果前置异常清单,不代表真实搜索引擎异常队列。',
'failure_trend' => (array)($arrExternalSummary['failure_trend'] ?? []),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoIntegrationOverviewHelper
{
public static function buildSummary(string $strValidateRunRoot, string $strSnapshotRunRoot, int $intLimit = 10): array
{
$arrValidate = DomainExternalSeoSnapshotValidateRunIndexHelper::buildSummary($strValidateRunRoot, $intLimit);
$arrSnapshot = DomainExternalSeoSnapshotRunIndexHelper::buildSummary($strSnapshotRunRoot, $intLimit);
$arrLatestValidate = (array)($arrValidate['latest_run'] ?? []);
$arrLatestSnapshot = (array)($arrSnapshot['latest_run'] ?? []);
$strHealthLabel = 'idle';
if (!empty($arrLatestValidate) && (($arrLatestValidate['alert_level'] ?? 'none') === 'high')) {
$strHealthLabel = 'validate_blocked';
} elseif (!empty($arrLatestSnapshot) && (($arrLatestSnapshot['alert_level'] ?? 'none') === 'high')) {
$strHealthLabel = 'push_blocked';
} elseif (!empty($arrLatestValidate) || !empty($arrLatestSnapshot)) {
$strHealthLabel = 'running';
}
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_external_integration_overview',
'health_label' => $strHealthLabel,
'validate' => [
'total' => (int)($arrValidate['total'] ?? 0),
'alert_buckets' => (array)($arrValidate['alert_buckets'] ?? []),
'latest_run' => $arrLatestValidate,
'top_attention_runs' => array_values(array_slice((array)($arrValidate['top_attention_runs'] ?? []), 0, 3)),
],
'snapshot' => [
'total' => (int)($arrSnapshot['total'] ?? 0),
'alert_buckets' => (array)($arrSnapshot['alert_buckets'] ?? []),
'latest_run' => $arrLatestSnapshot,
'top_attention_runs' => array_values(array_slice((array)($arrSnapshot['top_attention_runs'] ?? []), 0, 3)),
],
'note' => '联调总览会把 dry-run 校验和正式快照接收放到同一视角,方便先判断问题卡在字段校验还是正式回推阶段。',
];
}
}

View File

@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use PhpOffice\PhpSpreadsheet\IOFactory;
class DomainExternalSeoManualImportHelper
{
public static function defaultRoot(string $strCodeRoot): string
{
return rtrim($strCodeRoot, '/') . '/storage/external-seo/manual-import';
}
public static function getHeaders(): array
{
return [
'date',
'host',
'provider',
'query',
'page',
'impressions',
'clicks',
'ctr',
'avg_position',
];
}
public static function getColumnNotes(): array
{
return [
'date' => '必填,数据日期,例如 2026-04-10。',
'host' => '必填,域名,例如 chuanjiafeng.net。',
'provider' => '必填,数据来源,例如 google_search_console / baidu_webmaster / manual_csv_import。',
'query' => '可选,关键词。',
'page' => '可选,页面 URL 或 path。',
'impressions' => '必填,展现数。',
'clicks' => '必填,点击数。',
'ctr' => '可选,点击率;不填时可由 clicks/impressions 推导。',
'avg_position' => '可选,平均排名。',
];
}
public static function getExampleRows(): array
{
return [
[
'date' => '2026-04-10',
'host' => 'chuanjiafeng.net',
'provider' => 'manual_csv_import',
'query' => '相逢不识旧时人',
'page' => '/film/154229-xiang-feng-bu-shi-jiu-shi-ren',
'impressions' => '120',
'clicks' => '8',
'ctr' => '0.0667',
'avg_position' => '11.3',
],
[
'date' => '2026-04-10',
'host' => 'chuanjiafeng.net',
'provider' => 'manual_csv_import',
'query' => '相逢不识旧时人',
'page' => '/get.html?keyword=%E7%9B%B8%E9%80%A2%E4%B8%8D%E8%AF%86%E6%97%A7%E6%97%B6%E4%BA%BA',
'impressions' => '30',
'clicks' => '1',
'ctr' => '0.0333',
'avg_position' => '18.2',
],
];
}
public static function writeTemplate(string $strOutputRoot): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
$strFileName = 'domain-external-seo-manual-import-template.xlsx';
$strFilePath = $strOutputRoot . '/' . $strFileName;
DomainBatchImportTemplateHelper::writeWorkbook(
$strFilePath,
'domain-external-seo-manual',
self::getHeaders(),
self::getExampleRows(),
self::getColumnNotes()
);
return [
'file_name' => $strFileName,
'file_path' => $strFilePath,
'summary' => [
'headers' => self::getHeaders(),
'column_notes' => self::getColumnNotes(),
'example_rows' => self::getExampleRows(),
],
];
}
public static function importWorkbook(string $strWorkbookPath, string $strCodeRoot): array
{
$spreadsheet = IOFactory::load($strWorkbookPath);
$sheet = $spreadsheet->getSheet(0);
$rows = $sheet->toArray(null, true, true, false);
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
if (empty($rows)) {
return [
'rows_total' => 0,
'imported_count' => 0,
'csv_path' => '',
'summary' => self::buildSummary($strCodeRoot),
];
}
$headers = array_map(static fn($strHeader): string => trim((string)$strHeader), (array)array_shift($rows));
$arrNormalized = [];
foreach ($rows as $arrRow) {
$arrAssoc = [];
$boolHasData = false;
foreach ($headers as $intIndex => $strHeader) {
if ($strHeader === '') {
continue;
}
$strValue = trim((string)($arrRow[$intIndex] ?? ''));
$arrAssoc[$strHeader] = $strValue;
if ($strValue !== '') {
$boolHasData = true;
}
}
if ($boolHasData) {
$arrNormalized[] = self::normalizeRow($arrAssoc);
}
}
return self::persistNormalizedRows($arrNormalized, count($rows), $strCodeRoot);
}
public static function importCsv(string $strCsvInputPath, string $strCodeRoot): array
{
$arrRows = self::loadCsvRows($strCsvInputPath);
$arrNormalized = [];
foreach ($arrRows as $arrRow) {
$boolHasData = false;
foreach ((array)$arrRow as $mValue) {
if (trim((string)$mValue) !== '') {
$boolHasData = true;
break;
}
}
if (!$boolHasData) {
continue;
}
$arrNormalized[] = self::normalizeRow($arrRow);
}
return self::persistNormalizedRows($arrNormalized, count($arrRows), $strCodeRoot);
}
public static function createRunRoot(string $strCodeRoot): string
{
$strBaseRoot = self::defaultRoot($strCodeRoot) . '/runs';
$strDate = date('Ymd');
$strRunId = date('His') . '_manual_' . substr(md5((string)microtime(true)), 0, 6);
$strRunRoot = $strBaseRoot . '/' . $strDate . '/' . $strRunId;
self::ensureDir($strRunRoot);
return $strRunRoot;
}
public static function loadLatestRows(string $strCodeRoot): array
{
$strRoot = self::defaultRoot($strCodeRoot);
$arrCandidates = [
$strRoot . '/manual-seo-import.csv',
$strRoot . '/latest/manual-seo-import.csv',
];
foreach ($arrCandidates as $strPath) {
if (is_file($strPath)) {
return self::loadCsvRows($strPath);
}
}
return [];
}
public static function buildSummary(string $strCodeRoot): array
{
$arrRows = self::loadLatestRows($strCodeRoot);
if (empty($arrRows)) {
return [
'status' => 'empty',
'rows_count' => 0,
'hosts_count' => 0,
'metrics' => [
'impressions' => 0,
'clicks' => 0,
'ctr' => 0,
'avg_position' => null,
],
'top_queries' => [],
'top_pages' => [],
'providers' => [],
];
}
$intImpressions = 0;
$intClicks = 0;
$floatWeightedPosition = 0.0;
$intPositionWeight = 0;
$arrQueryAgg = [];
$arrPageAgg = [];
$arrHosts = [];
$arrProviders = [];
foreach ($arrRows as $arrRow) {
$strHost = trim((string)($arrRow['host'] ?? ''));
$strProvider = trim((string)($arrRow['provider'] ?? ''));
$strQuery = trim((string)($arrRow['query'] ?? ''));
$strPage = trim((string)($arrRow['page'] ?? ''));
$intRowImpressions = (int)round((float)($arrRow['impressions'] ?? 0));
$intRowClicks = (int)round((float)($arrRow['clicks'] ?? 0));
$floatPosition = is_numeric((string)($arrRow['avg_position'] ?? '')) ? (float)$arrRow['avg_position'] : null;
$intImpressions += $intRowImpressions;
$intClicks += $intRowClicks;
if ($floatPosition !== null && $intRowImpressions > 0) {
$floatWeightedPosition += $floatPosition * $intRowImpressions;
$intPositionWeight += $intRowImpressions;
}
if ($strHost !== '') {
$arrHosts[$strHost] = true;
}
if ($strProvider !== '') {
$arrProviders[$strProvider] = true;
}
if ($strQuery !== '') {
$arrQueryAgg[$strQuery] = ($arrQueryAgg[$strQuery] ?? 0) + $intRowImpressions;
}
if ($strPage !== '') {
$arrPageAgg[$strPage] = ($arrPageAgg[$strPage] ?? 0) + $intRowImpressions;
}
}
arsort($arrQueryAgg);
arsort($arrPageAgg);
return [
'status' => 'ready',
'rows_count' => count($arrRows),
'hosts_count' => count($arrHosts),
'metrics' => [
'impressions' => $intImpressions,
'clicks' => $intClicks,
'ctr' => $intImpressions > 0 ? round($intClicks / $intImpressions, 4) : 0,
'avg_position' => $intPositionWeight > 0 ? round($floatWeightedPosition / $intPositionWeight, 2) : null,
],
'top_queries' => array_map(static function (string $strQuery, int $intImpressions): array {
return [
'query' => $strQuery,
'impressions' => $intImpressions,
];
}, array_keys(array_slice($arrQueryAgg, 0, 10, true)), array_values(array_slice($arrQueryAgg, 0, 10, true))),
'top_pages' => array_map(static function (string $strPage, int $intImpressions): array {
return [
'page' => $strPage,
'impressions' => $intImpressions,
];
}, array_keys(array_slice($arrPageAgg, 0, 10, true)), array_values(array_slice($arrPageAgg, 0, 10, true))),
'providers' => array_values(array_keys($arrProviders)),
];
}
protected static function loadCsvRows(string $strPath): array
{
$Handle = fopen($strPath, 'rb');
if ($Handle === false) {
return [];
}
$arrRows = [];
$arrHeaders = [];
$intIndex = 0;
while (($arrCols = fgetcsv($Handle)) !== false) {
if ($intIndex === 0) {
$arrHeaders = array_map(static function ($strHeader): string {
return trim((string)$strHeader);
}, $arrCols);
$intIndex++;
continue;
}
if (empty(array_filter($arrCols, static fn($mValue): bool => trim((string)$mValue) !== ''))) {
$intIndex++;
continue;
}
$arrRows[] = array_combine($arrHeaders, array_map(static function ($mValue): string {
return trim((string)$mValue);
}, $arrCols)) ?: [];
$intIndex++;
}
fclose($Handle);
return $arrRows;
}
protected static function normalizeRow(array $arrRow): array
{
$arrOutput = [];
foreach (self::getHeaders() as $strHeader) {
$arrOutput[$strHeader] = trim((string)($arrRow[$strHeader] ?? ''));
}
if ($arrOutput['ctr'] === '' && $arrOutput['impressions'] !== '') {
$intImpressions = (float)$arrOutput['impressions'];
$intClicks = (float)$arrOutput['clicks'];
$arrOutput['ctr'] = $intImpressions > 0 ? (string)round($intClicks / $intImpressions, 4) : '0';
}
return $arrOutput;
}
protected static function writeCsv(string $strPath, array $arrRows): void
{
self::ensureDir(dirname($strPath));
$Handle = fopen($strPath, 'wb');
if ($Handle === false) {
throw new \RuntimeException('Failed to open csv file for writing: ' . $strPath);
}
fputcsv($Handle, self::getHeaders());
foreach ($arrRows as $arrRow) {
$arrValues = [];
foreach (self::getHeaders() as $strHeader) {
$arrValues[] = (string)($arrRow[$strHeader] ?? '');
}
fputcsv($Handle, $arrValues);
}
fclose($Handle);
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function persistNormalizedRows(array $arrNormalized, int $intRowsTotal, string $strCodeRoot): array
{
$strRoot = self::defaultRoot($strCodeRoot);
$strLatestRoot = $strRoot . '/latest';
$strRunRoot = self::createRunRoot($strCodeRoot);
self::ensureDir($strRoot);
self::ensureDir($strLatestRoot);
self::ensureDir($strRunRoot);
$strLatestCsvPath = $strLatestRoot . '/manual-seo-import.csv';
$strRunCsvPath = $strRunRoot . '/manual-seo-import.csv';
self::writeCsv($strLatestCsvPath, $arrNormalized);
self::writeCsv($strRunCsvPath, $arrNormalized);
return [
'rows_total' => $intRowsTotal,
'imported_count' => count($arrNormalized),
'csv_path' => $strLatestCsvPath,
'run_csv_path' => $strRunCsvPath,
'run_root' => $strRunRoot,
'summary' => self::buildSummary($strCodeRoot),
];
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoManualImportRunIndexHelper
{
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;
}
public static function buildSummary(string $strRoot): array
{
$strRoot = rtrim($strRoot, '/');
if ($strRoot === '' || !is_dir($strRoot)) {
return [
'items' => [],
'total' => 0,
'latest_run' => [],
];
}
$arrItems = [];
$arrRunSummaryFiles = array_merge(
(array)glob($strRoot . '/runs/*/*/seo-external-real.summary.json'),
(array)glob($strRoot . '/runs/*/seo-external-real.summary.json')
);
$arrRunSummaryFiles = array_values(array_filter(array_unique($arrRunSummaryFiles), 'is_file'));
usort($arrRunSummaryFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
foreach ($arrRunSummaryFiles as $strSummaryPath) {
$strRunRoot = dirname($strSummaryPath);
$arrDecoded = json_decode((string)file_get_contents($strSummaryPath), true);
$arrSummary = is_array($arrDecoded) ? $arrDecoded : [];
$strCsvPath = $strRunRoot . '/manual-seo-import.csv';
$strUploadedPath = self::resolveUploadedReportPath($strRunRoot);
$arrItems[] = [
'run_id' => basename($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::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())),
];
}
$strLatestDir = $strRoot . '/latest';
if (is_dir($strLatestDir)) {
$strLatestSummaryJsonPath = dirname($strRoot) . '/latest/seo-external-real.summary.json';
if (is_file($strLatestSummaryJsonPath)) {
$arrDecoded = json_decode((string)file_get_contents($strLatestSummaryJsonPath), true);
$arrSummary = is_array($arrDecoded) ? $arrDecoded : [];
array_unshift($arrItems, [
'run_id' => 'latest_manual_import',
'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::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())),
]);
}
}
return [
'items' => $arrItems,
'total' => count($arrItems),
'latest_run' => $arrItems[0] ?? [],
];
}
protected static function resolveUploadedReportPath(string $strRoot): string
{
$arrMatches = array_values(array_filter((array)glob($strRoot . '/uploaded.*'), 'is_file'));
if (empty($arrMatches)) {
return '';
}
usort($arrMatches, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
return $arrMatches[0];
}
protected static function countCsvRows(string $strCsvPath): int
{
if (!is_file($strCsvPath)) {
return 0;
}
$Handle = fopen($strCsvPath, 'rb');
if ($Handle === false) {
return 0;
}
$intCount = 0;
$intIndex = 0;
while (($arrCols = fgetcsv($Handle)) !== false) {
if ($intIndex === 0) {
$intIndex++;
continue;
}
if (!empty(array_filter($arrCols, static fn($mValue): bool => trim((string)$mValue) !== ''))) {
$intCount++;
}
$intIndex++;
}
fclose($Handle);
return $intCount;
}
protected static function countCsvHosts(string $strCsvPath): int
{
if (!is_file($strCsvPath)) {
return 0;
}
$Handle = fopen($strCsvPath, 'rb');
if ($Handle === false) {
return 0;
}
$arrHeaders = [];
$arrHosts = [];
$intIndex = 0;
while (($arrCols = fgetcsv($Handle)) !== false) {
if ($intIndex === 0) {
$arrHeaders = array_map(static fn($mValue): string => trim((string)$mValue), $arrCols);
$intIndex++;
continue;
}
$arrAssoc = array_combine($arrHeaders, array_map(static fn($mValue): string => trim((string)$mValue), $arrCols)) ?: [];
$strHost = trim((string)($arrAssoc['host'] ?? ''));
if ($strHost !== '') {
$arrHosts[$strHost] = true;
}
$intIndex++;
}
fclose($Handle);
return count($arrHosts);
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoOptimizationSuggestionHelper
{
public static function buildSuggestions(int $intDays = 14, int $intLimit = 20): array
{
$intDays = max(2, min(90, $intDays));
$intLimit = max(1, min(100, $intLimit));
$arrIndexMovement = DomainExternalSeoSnapshotAnalysisHelper::buildIndexMovementSummary($intDays, $intLimit);
$arrKeywordMovement = DomainExternalSeoSnapshotAnalysisHelper::buildKeywordMovementSummary($intDays, $intLimit);
$arrSummary = DomainExternalSeoSnapshotAnalysisHelper::buildSummary(min($intDays, 30), $intLimit);
$arrSuggestions = [];
if ((int)($arrIndexMovement['captcha_count'] ?? 0) > 0) {
$arrSuggestions[] = [
'priority' => 'high',
'type' => 'index_probe_captcha',
'title' => '收录探测被验证码拦截',
'summary' => '当前有一批域名在 site: 收录探测时进入验证码状态,收录结果暂时不稳定。',
'action' => '降低探测频率,增加采集间隔,并把 captcha host 单独排队重试。',
'evidence' => [
'captcha_count' => (int)($arrIndexMovement['captcha_count'] ?? 0),
],
];
}
if ((int)($arrIndexMovement['lost_indexed_count'] ?? 0) > 0) {
$arrSuggestions[] = [
'priority' => 'high',
'type' => 'index_loss',
'title' => '发现丢失收录域名',
'summary' => '有域名从 indexed_like 变成了非收录态,应优先看站点可访问性和页面质量。',
'action' => '优先检查首页、列表、详情、播放的可访问性,其次检查 robots、canonical、模板正文是否被冲掉。',
'evidence' => [
'lost_indexed_count' => (int)($arrIndexMovement['lost_indexed_count'] ?? 0),
'sample_hosts' => array_values(array_slice(array_map(static fn(array $arrItem): string => (string)($arrItem['host'] ?? ''), (array)($arrIndexMovement['lost_indexed_items'] ?? [])), 0, 5)),
],
];
}
if ((int)($arrIndexMovement['newly_indexed_count'] ?? 0) > 0) {
$arrSuggestions[] = [
'priority' => 'medium',
'type' => 'index_growth',
'title' => '有新增收录域名',
'summary' => '部分域名已经进入新增收录阶段,可以把这些域名作为当前模板链和内容策略的正样本。',
'action' => '优先对新增收录域名继续补充列表页、详情页和播放页内容供给,观察是否能进一步带动关键词增长。',
'evidence' => [
'newly_indexed_count' => (int)($arrIndexMovement['newly_indexed_count'] ?? 0),
],
];
}
if ((int)($arrKeywordMovement['dropped_count'] ?? 0) > 0) {
$arrSuggestions[] = [
'priority' => 'high',
'type' => 'keyword_drop',
'title' => '发现掉词',
'summary' => '部分关键词在最新周期里已经掉出监控结果,应优先回看对应域名和页面模板。',
'action' => '优先补详情正文和搜索结果页正文供给,并排查对应 host 是否同时存在收录下滑。',
'evidence' => [
'dropped_count' => (int)($arrKeywordMovement['dropped_count'] ?? 0),
'sample_keywords' => array_values(array_slice(array_map(static fn(array $arrItem): string => (string)($arrItem['keyword'] ?? ''), (array)($arrKeywordMovement['dropped_items'] ?? [])), 0, 5)),
],
];
}
if ((int)($arrKeywordMovement['down_count'] ?? 0) > (int)($arrKeywordMovement['up_count'] ?? 0)) {
$arrSuggestions[] = [
'priority' => 'medium',
'type' => 'keyword_downward',
'title' => '关键词整体偏下行',
'summary' => '当前下降词数量大于上升词,说明模板链虽然在跑,但关键词侧的竞争力还不够稳。',
'action' => '优先在已有收录的域名上加厚栏目页和详情页可见文本,先稳住已有词,再做新增词扩张。',
'evidence' => [
'up_count' => (int)($arrKeywordMovement['up_count'] ?? 0),
'down_count' => (int)($arrKeywordMovement['down_count'] ?? 0),
],
];
}
if ((int)($arrKeywordMovement['new_count'] ?? 0) > 0 && (int)($arrKeywordMovement['up_count'] ?? 0) > 0) {
$arrSuggestions[] = [
'priority' => 'medium',
'type' => 'keyword_growth',
'title' => '关键词出现新增和上升',
'summary' => '当前已经有新增词和上升词,说明爱站关键词链开始出现增长信号。',
'action' => '优先围绕这些增长词所在的 host 继续做同类词扩写,保留当前模板和供给策略,不要大改壳。',
'evidence' => [
'new_count' => (int)($arrKeywordMovement['new_count'] ?? 0),
'up_count' => (int)($arrKeywordMovement['up_count'] ?? 0),
],
];
}
if ((int)($arrSummary['indexed_like_host_count'] ?? 0) === 0) {
$arrSuggestions[] = [
'priority' => 'high',
'type' => 'no_indexed_hosts',
'title' => '当前没有 indexed_like 域名',
'summary' => '目前历史快照里还没有稳定进入 indexed_like 的域名,说明站外结果面还在冷启动阶段。',
'action' => '先保证批量域名前台可访问、正文可见、路由稳定,再继续观察收录变化,不要过早做大规模关键词判断。',
'evidence' => [
'indexed_like_host_count' => (int)($arrSummary['indexed_like_host_count'] ?? 0),
],
];
}
if ((int)($arrSummary['keyword_ready_host_count'] ?? 0) === 0) {
$arrSuggestions[] = [
'priority' => 'medium',
'type' => 'no_keyword_ready_hosts',
'title' => '当前还没有关键词 ready 域名',
'summary' => '历史快照里还没有域名形成稳定的关键词侧结果,爱站趋势仍处于冷启动态。',
'action' => '优先盯住已收录 host等第一批词出现后再做大规模排名升降判断。',
'evidence' => [
'keyword_ready_host_count' => (int)($arrSummary['keyword_ready_host_count'] ?? 0),
],
];
}
usort($arrSuggestions, static function (array $arrLeft, array $arrRight): int {
$arrPriorityRank = ['high' => 3, 'medium' => 2, 'low' => 1];
$intLeft = $arrPriorityRank[(string)($arrLeft['priority'] ?? 'low')] ?? 0;
$intRight = $arrPriorityRank[(string)($arrRight['priority'] ?? 'low')] ?? 0;
return $intRight <=> $intLeft;
});
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_external_optimization_suggestions',
'days' => $intDays,
'index_movement' => [
'latest_metric_date' => (string)($arrIndexMovement['latest_metric_date'] ?? ''),
'newly_indexed_count' => (int)($arrIndexMovement['newly_indexed_count'] ?? 0),
'lost_indexed_count' => (int)($arrIndexMovement['lost_indexed_count'] ?? 0),
'captcha_count' => (int)($arrIndexMovement['captcha_count'] ?? 0),
],
'keyword_movement' => [
'latest_metric_date' => (string)($arrKeywordMovement['latest_metric_date'] ?? ''),
'new_count' => (int)($arrKeywordMovement['new_count'] ?? 0),
'up_count' => (int)($arrKeywordMovement['up_count'] ?? 0),
'down_count' => (int)($arrKeywordMovement['down_count'] ?? 0),
'dropped_count' => (int)($arrKeywordMovement['dropped_count'] ?? 0),
],
'summary' => [
'indexed_like_host_count' => (int)($arrSummary['indexed_like_host_count'] ?? 0),
'keyword_ready_host_count' => (int)($arrSummary['keyword_ready_host_count'] ?? 0),
'traffic_ready_host_count' => (int)($arrSummary['traffic_ready_host_count'] ?? 0),
],
'suggestions' => array_slice($arrSuggestions, 0, $intLimit),
'note' => '当前建议层基于 site: 收录变化、爱站关键词升降和历史快照摘要自动生成,用来给批量域名场景提供第一版操作建议。',
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoProviderRegistryHelper
{
public static function buildSummary(string $strCodeRoot): array
{
$strCodeRoot = rtrim($strCodeRoot, '/');
$strSearchConsoleCredPath = $strCodeRoot . '/storage/external-seo/google-search-console.credentials.json';
$strBaiduWebmasterCredPath = $strCodeRoot . '/storage/external-seo/baidu-webmaster.credentials.json';
$arrManualSummary = DomainExternalSeoManualImportHelper::buildSummary($strCodeRoot);
$arrSearchConsoleMapStatus = DomainExternalSeoSearchConsoleConfigHelper::buildStatus($strCodeRoot);
$arrProviders = [
[
'provider_key' => 'google_search_console',
'label' => 'Google Search Console',
'status' => is_file($strSearchConsoleCredPath) ? 'credentials_ready' : 'planned',
'mode' => 'service_account_or_oauth',
'credential_path' => is_file($strSearchConsoleCredPath) ? $strSearchConsoleCredPath : '',
'credential_updated_at' => is_file($strSearchConsoleCredPath) ? date(DATE_ATOM, (int)(filemtime($strSearchConsoleCredPath) ?: time())) : '',
'credential_file_size' => is_file($strSearchConsoleCredPath) ? (int)(filesize($strSearchConsoleCredPath) ?: 0) : 0,
'property_map_path' => (string)($arrSearchConsoleMapStatus['config_path'] ?? ''),
'property_map_ready' => !empty($arrSearchConsoleMapStatus['property_map_ready']),
'mapped_hosts_count' => (int)($arrSearchConsoleMapStatus['mapped_hosts_count'] ?? 0),
'required_fields' => ['site_url', 'property_scope', 'query/date metrics'],
'metrics' => ['impressions', 'clicks', 'ctr', 'avg_position', 'query', 'page'],
'note' => is_file($strSearchConsoleCredPath)
? (!empty($arrSearchConsoleMapStatus['property_map_ready'])
? '已存在凭证文件和 property map可以继续往真实 API 拉数链路推进。'
: '已存在凭证文件,但还缺 property map下一步先上传 host -> property 映射。')
: '当前仓库还没有 Google Search Console 凭证文件,后续接入时建议优先从这里开始。',
],
[
'provider_key' => 'baidu_webmaster',
'label' => '百度搜索资源平台',
'status' => is_file($strBaiduWebmasterCredPath) ? 'credentials_ready' : 'partial',
'mode' => 'token_plus_platform_credentials',
'credential_path' => is_file($strBaiduWebmasterCredPath) ? $strBaiduWebmasterCredPath : '',
'credential_updated_at' => is_file($strBaiduWebmasterCredPath) ? date(DATE_ATOM, (int)(filemtime($strBaiduWebmasterCredPath) ?: time())) : '',
'credential_file_size' => is_file($strBaiduWebmasterCredPath) ? (int)(filesize($strBaiduWebmasterCredPath) ?: 0) : 0,
'required_fields' => ['domain token', 'site verification', 'push / resource API'],
'metrics' => ['index coverage', 'push token', 'resource status'],
'note' => '当前大陆 SEO 主线下,百度更适合作为推送 / 提交 / 收录反馈平台;`d_baidu_token` 只代表推送能力,不代表点击 / 展现效果拉数。',
],
[
'provider_key' => 'manual_csv_import',
'label' => '人工 CSV / 报表导入',
'status' => ($arrManualSummary['status'] ?? '') === 'ready' ? 'data_ready' : 'planned',
'mode' => 'manual_batch_import',
'credential_path' => '',
'credential_updated_at' => '',
'credential_file_size' => 0,
'required_fields' => ['date', 'host', 'impressions', 'clicks', 'ctr', 'position'],
'metrics' => ['impressions', 'clicks', 'ctr', 'avg_position'],
'note' => ($arrManualSummary['status'] ?? '') === 'ready'
? '当前已经检测到手工导入数据,可以直接产出第一版真实 summary。'
: '如果外部平台 API 暂时不方便接,这条可以作为过渡方案。',
],
];
$intReadyCount = count(array_filter($arrProviders, static function (array $arrProvider): bool {
return (string)($arrProvider['status'] ?? '') === 'credentials_ready';
}));
return [
'generated_at' => date(DATE_ATOM),
'integration_stage' => $intReadyCount > 0 ? 'provider_prepared_partial' : 'provider_planning',
'providers_count' => count($arrProviders),
'credentials_ready_count' => $intReadyCount,
'providers' => $arrProviders,
'next_recommended_provider' => $arrProviders[1] ?? $arrProviders[0] ?? [],
'note' => '当前按大陆 SEO 主线优先推荐百度资源平台做推送 / 提交 / 收录反馈准备Google / Search Console 不再作为当前默认主线。',
];
}
}

View File

@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoRealSummaryHelper
{
public static function buildSummary(string $strCodeRoot, int $intDays = 7): array
{
$intDays = max(1, min(30, $intDays));
$arrAutoSummary = self::buildAutoSnapshotSummary($intDays);
if (($arrAutoSummary['status'] ?? '') === 'ready') {
return $arrAutoSummary;
}
$arrManualSummary = DomainExternalSeoManualImportHelper::buildSummary($strCodeRoot);
if (($arrManualSummary['status'] ?? '') === 'ready') {
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'manual_csv_real_summary',
'status' => 'ready',
'days' => $intDays,
'source_mode' => 'manual_csv',
'provider_key' => 'manual_csv_import',
'provider_label' => '人工 CSV / 报表导入',
'provider_status' => 'data_ready',
'credential_path' => '',
'client_dependency_ready' => true,
'real_metrics_available' => true,
'headline_metrics' => [
['key' => 'impressions', 'label' => '展现', 'value' => ($arrManualSummary['metrics']['impressions'] ?? null), 'note' => '先看有没有持续增长'],
['key' => 'clicks', 'label' => '点击', 'value' => ($arrManualSummary['metrics']['clicks'] ?? null), 'note' => '展现起来后,点击才会跟着动'],
['key' => 'ctr', 'label' => '点击率', 'value' => ($arrManualSummary['metrics']['ctr'] ?? null), 'format' => 'percent', 'note' => '标题和摘要是否能吸引点击'],
['key' => 'avg_position', 'label' => '平均排名', 'value' => ($arrManualSummary['metrics']['avg_position'] ?? null), 'note' => '数值越小越好'],
],
'metrics' => (array)($arrManualSummary['metrics'] ?? []),
'top_queries' => array_values((array)($arrManualSummary['top_queries'] ?? [])),
'top_pages' => array_values((array)($arrManualSummary['top_pages'] ?? [])),
'next_steps' => [
'当前展示的是人工报表导入结果,适合先看展现、点击和排名。',
'如果已经接通 Node 自动快照,后面建议让自动数据成为主来源。',
'人工 CSV 更适合作为临时补录或历史对账,不适合长期主维护。',
],
'note' => '当前结果来自人工 CSV / 报表导入。这类数据适合临时对账,但长期建议切回自动快照或平台 API减少人力维护。',
];
}
$arrProviderSummary = DomainExternalSeoProviderRegistryHelper::buildSummary($strCodeRoot);
$arrProviders = array_values(array_filter((array)($arrProviderSummary['providers'] ?? []), 'is_array'));
$arrGoogleProvider = [];
foreach ($arrProviders as $arrProvider) {
if ((string)($arrProvider['provider_key'] ?? '') === 'google_search_console') {
$arrGoogleProvider = $arrProvider;
break;
}
}
$boolHasGoogleCreds = !empty($arrGoogleProvider['credential_path']);
$boolHasGoogleClient = class_exists('\\Google\\Service\\SearchConsole') || class_exists('\\Google_Service_Webmasters');
$boolHasPropertyMap = !empty($arrGoogleProvider['property_map_ready']);
$intMappedHostsCount = (int)($arrGoogleProvider['mapped_hosts_count'] ?? 0);
$strStatus = 'not_configured';
$arrNextSteps = [
'先准备 Search Console 凭证文件',
'确认域名 property 映射方式',
'补第一版真实 query/page summary 拉数实现',
];
if ($boolHasGoogleCreds && !$boolHasPropertyMap) {
$strStatus = 'provider_ready_but_property_map_missing';
$arrNextSteps = [
'当前已存在 Search Console 凭证文件',
'但还没有 host -> property 的映射配置',
'下一步先上传 property map再继续接真实拉数实现',
];
} elseif ($boolHasGoogleCreds && $boolHasPropertyMap && !$boolHasGoogleClient) {
$strStatus = 'provider_ready_but_client_missing';
$arrNextSteps = [
'当前已存在 Search Console 凭证文件和 property map',
'但仓库还没有 Google API 客户端依赖',
'下一步应先补 client 或改成 HTTP 方式接入',
];
} elseif ($boolHasGoogleCreds && $boolHasPropertyMap && $boolHasGoogleClient) {
$strStatus = 'ready_for_api_implementation';
$arrNextSteps = [
'补 Search Console query/page 拉数逻辑',
'输出真实 summary.json/html',
'再沉淀真实异常队列',
];
}
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'real_external_provider_skeleton',
'status' => $strStatus,
'days' => $intDays,
'source_mode' => 'provider_skeleton',
'provider_key' => 'google_search_console',
'provider_label' => (string)($arrGoogleProvider['label'] ?? 'Google Search Console'),
'provider_status' => (string)($arrGoogleProvider['status'] ?? 'planned'),
'credential_path' => (string)($arrGoogleProvider['credential_path'] ?? ''),
'property_map_path' => (string)($arrGoogleProvider['property_map_path'] ?? ''),
'property_map_ready' => $boolHasPropertyMap,
'mapped_hosts_count' => $intMappedHostsCount,
'client_dependency_ready' => $boolHasGoogleClient,
'real_metrics_available' => false,
'headline_metrics' => [
['key' => 'provider', 'label' => '数据来源', 'value' => (string)($arrGoogleProvider['label'] ?? 'Google Search Console'), 'note' => '当前还是接入骨架'],
['key' => 'mapped_hosts', 'label' => '映射域名', 'value' => $intMappedHostsCount, 'note' => '确认 property map 是否齐备'],
['key' => 'property_map', 'label' => '属性映射', 'value' => $boolHasPropertyMap ? '已就绪' : '缺失', 'note' => '域名要先映射到平台属性'],
['key' => 'client_dependency', 'label' => '客户端依赖', 'value' => $boolHasGoogleClient ? '已就绪' : '缺失', 'note' => '平台 SDK 或 HTTP 客户端是否齐备'],
],
'metrics' => [
'impressions' => null,
'clicks' => null,
'ctr' => null,
'avg_position' => null,
],
'top_queries' => [],
'top_pages' => [],
'next_steps' => $arrNextSteps,
'note' => '当前是真实站外数据接入骨架层:优先以 Search Console 作为第一 provider除了凭证还需要 property map。只有映射和客户端依赖都齐备后才适合继续接真实点击 / 展现数据。',
];
}
protected static function buildAutoSnapshotSummary(int $intDays): array
{
$arrSnapshotSummary = DomainExternalSeoSnapshotAnalysisHelper::buildSummary($intDays, 20);
$intSnapshotCount = (int)($arrSnapshotSummary['snapshot_count'] ?? 0);
$intHostCount = (int)($arrSnapshotSummary['host_count'] ?? 0);
if ($intSnapshotCount <= 0 || $intHostCount <= 0) {
return [
'status' => 'empty',
];
}
$arrIndexMovement = DomainExternalSeoSnapshotAnalysisHelper::buildIndexMovementSummary(max(2, $intDays), 10);
$arrKeywordMovement = DomainExternalSeoSnapshotAnalysisHelper::buildKeywordMovementSummary(max(2, $intDays), 10);
$arrTopQueries = [];
foreach ((array)($arrKeywordMovement['up_items'] ?? []) as $arrItem) {
$strKeyword = trim((string)($arrItem['keyword'] ?? ''));
if ($strKeyword === '') {
continue;
}
$arrTopQueries[] = [
'query' => $strKeyword,
'impressions' => (int)($arrItem['search_volume'] ?? 0),
];
if (count($arrTopQueries) >= 10) {
break;
}
}
if (empty($arrTopQueries)) {
foreach ((array)($arrKeywordMovement['new_items'] ?? []) as $arrItem) {
$strKeyword = trim((string)($arrItem['keyword'] ?? ''));
if ($strKeyword === '') {
continue;
}
$arrTopQueries[] = [
'query' => $strKeyword,
'impressions' => (int)($arrItem['search_volume'] ?? 0),
];
if (count($arrTopQueries) >= 10) {
break;
}
}
}
$arrTopPages = [];
foreach ((array)($arrSnapshotSummary['hosts'] ?? []) as $arrHost) {
$arrTopPages[] = [
'page' => (string)($arrHost['host'] ?? ''),
'impressions' => (int)(((int)($arrHost['pc_keyword_count'] ?? 0)) + ((int)($arrHost['mobile_keyword_count'] ?? 0))),
];
}
$strLatestMetricDate = trim((string)($arrSnapshotSummary['latest_metric_date'] ?? ''));
$boolDataFresh = $strLatestMetricDate !== '' && strtotime($strLatestMetricDate) !== false
? (time() - strtotime($strLatestMetricDate) <= (86400 * 7))
: false;
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'automation_snapshot_real_summary',
'status' => 'ready',
'days' => $intDays,
'source_mode' => 'automation_snapshot',
'provider_key' => 'node_snapshot_analysis',
'provider_label' => 'Node 自动快照 / 爱站 / 百度 site',
'provider_status' => $boolDataFresh ? 'data_ready' : 'data_stale',
'credential_path' => '',
'client_dependency_ready' => true,
'real_metrics_available' => true,
'headline_metrics' => [
['key' => 'host_count', 'label' => '采集域名', 'value' => $intHostCount, 'note' => '最近周期内有自动快照的域名数'],
['key' => 'indexed_like_host_count', 'label' => '疑似已收录', 'value' => (int)($arrSnapshotSummary['indexed_like_host_count'] ?? 0), 'note' => '百度 site 侧看起来已经有结果'],
['key' => 'keyword_ready_host_count', 'label' => '已起词域名', 'value' => (int)($arrSnapshotSummary['keyword_ready_host_count'] ?? 0), 'note' => '爱站关键词侧已有数据'],
['key' => 'traffic_ready_host_count', 'label' => '流量就绪', 'value' => (int)($arrSnapshotSummary['traffic_ready_host_count'] ?? 0), 'note' => '已有可用流量区间信号'],
],
'metrics' => [
'impressions' => null,
'clicks' => null,
'ctr' => null,
'avg_position' => null,
],
'top_queries' => array_values($arrTopQueries),
'top_pages' => array_values($arrTopPages),
'snapshot_metrics' => [
'snapshot_count' => $intSnapshotCount,
'host_count' => $intHostCount,
'indexed_like_host_count' => (int)($arrSnapshotSummary['indexed_like_host_count'] ?? 0),
'keyword_ready_host_count' => (int)($arrSnapshotSummary['keyword_ready_host_count'] ?? 0),
'traffic_ready_host_count' => (int)($arrSnapshotSummary['traffic_ready_host_count'] ?? 0),
'newly_indexed_count' => (int)($arrIndexMovement['newly_indexed_count'] ?? 0),
'lost_indexed_count' => (int)($arrIndexMovement['lost_indexed_count'] ?? 0),
'keyword_up_count' => (int)($arrKeywordMovement['up_count'] ?? 0),
'keyword_new_count' => (int)($arrKeywordMovement['new_count'] ?? 0),
'latest_metric_date' => $strLatestMetricDate,
],
'next_steps' => [
'当前展示的是 Node 自动回推后的快照结果,更适合日常自动化值班。',
'优先盯疑似已收录、已起词和流量就绪这 3 类域名,判断这批域名是否开始自然起量。',
'如果后面再接入平台级点击/展现接口,再把这块升级成更完整的真实流量结果面。',
],
'note' => '当前结果优先消费 Node 自动快照。它反映的是收录、起词和流量就绪等自动化信号,不是 Search Console 那类展现/点击报表。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/seo-external-real.summary.json';
$strHtmlPath = $strOutputRoot . '/seo-external-real.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrHeadlineRows = [];
foreach ((array)($arrSummary['headline_metrics'] ?? []) as $arrMetric) {
$arrHeadlineRows[] = '<div class="card"><strong>'
. self::escape((string)($arrMetric['label'] ?? '-'))
. '</strong><br>'
. self::escape(self::formatMetricValue($arrMetric))
. '<div class="muted" style="margin-top:6px;font-size:12px;">' . self::escape((string)($arrMetric['note'] ?? '')) . '</div>'
. '</div>';
}
$arrQueryRows = [];
foreach ((array)($arrSummary['top_queries'] ?? []) as $arrQuery) {
$arrQueryRows[] = '<tr>'
. '<td>' . self::escape((string)($arrQuery['query'] ?? '')) . '</td>'
. '<td>' . (int)($arrQuery['impressions'] ?? 0) . '</td>'
. '</tr>';
}
$arrPageRows = [];
foreach ((array)($arrSummary['top_pages'] ?? []) as $arrPage) {
$arrPageRows[] = '<tr>'
. '<td>' . self::escape((string)($arrPage['page'] ?? '')) . '</td>'
. '<td>' . (int)($arrPage['impressions'] ?? 0) . '</td>'
. '</tr>';
}
$arrStepRows = [];
foreach ((array)($arrSummary['next_steps'] ?? []) as $strStep) {
$arrStepRows[] = '<li>' . self::escape((string)$strStep) . '</li>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>站外真实结果摘要</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>站外真实结果摘要</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<p>生成时间:<code>' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? ''))) . '</code></p>'
. '<div class="grid">'
. '<div class="card"><strong>数据来源</strong><br>' . self::escape((string)($arrSummary['provider_label'] ?? '')) . '</div>'
. '<div class="card"><strong>当前状态</strong><br>' . self::escape(DomainImportReportViewHelper::translateStatus((string)($arrSummary['status'] ?? ''))) . '</div>'
. implode('', $arrHeadlineRows)
. '<div class="card"><strong>真实数据</strong><br>' . (!empty($arrSummary['real_metrics_available']) ? '已接入' : '未接入') . '</div>'
. '<div class="card"><strong>来源状态</strong><br>' . self::escape((string)($arrSummary['provider_status'] ?? '-')) . '</div>'
. '</div>'
. '<h2>重点搜索词</h2>'
. '<table><thead><tr><th>搜索词</th><th>展现</th></tr></thead><tbody>' . implode('', $arrQueryRows) . '</tbody></table>'
. '<h2>重点页面</h2>'
. '<table><thead><tr><th>页面</th><th>展现</th></tr></thead><tbody>' . implode('', $arrPageRows) . '</tbody></table>'
. '<h2>下一步建议</h2><ul>' . implode('', $arrStepRows) . '</ul>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
protected static function formatMetricValue(array $arrMetric): string
{
$value = $arrMetric['value'] ?? null;
if ($value === null || $value === '') {
return '-';
}
if (($arrMetric['format'] ?? '') === 'percent' && is_numeric((string)$value)) {
return round(((float)$value) * 100, 2) . '%';
}
return (string)$value;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoSearchConsoleConfigHelper
{
public static function defaultConfigPath(string $strCodeRoot): string
{
return rtrim($strCodeRoot, '/') . '/storage/external-seo/google-search-console.property-map.json';
}
public static function buildTemplate(): array
{
return [
'file_name' => 'google-search-console.property-map.sample.json',
'content' => [
'updated_at' => date(DATE_ATOM),
'properties' => [
[
'host' => 'chuanjiafeng.net',
'site_url' => 'sc-domain:chuanjiafeng.net',
'property_scope' => 'domain',
'provider' => 'google_search_console',
'note' => '域属性示例;也可以改成 URL 前缀属性。',
],
[
'host' => 'www.example.com',
'site_url' => 'https://www.example.com/',
'property_scope' => 'url_prefix',
'provider' => 'google_search_console',
'note' => 'URL 前缀属性示例。',
],
],
],
];
}
public static function writeTemplate(string $strOutputDir): array
{
$strOutputDir = rtrim($strOutputDir, '/');
self::ensureDir($strOutputDir);
$arrTemplate = self::buildTemplate();
$strPath = $strOutputDir . '/' . (string)$arrTemplate['file_name'];
file_put_contents($strPath, json_encode($arrTemplate['content'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return [
'file_name' => (string)$arrTemplate['file_name'],
'file_path' => $strPath,
'summary' => (array)$arrTemplate['content'],
];
}
public static function saveConfig(string $strCodeRoot, string $strSourcePath): array
{
$strContent = (string)file_get_contents($strSourcePath);
$arrDecoded = json_decode($strContent, true);
if (!is_array($arrDecoded)) {
throw new \RuntimeException('Search Console property map 不是有效 JSON');
}
$arrProperties = array_values(array_filter((array)($arrDecoded['properties'] ?? []), static function ($mItem): bool {
return is_array($mItem) && trim((string)($mItem['host'] ?? '')) !== '' && trim((string)($mItem['site_url'] ?? '')) !== '';
}));
$arrPayload = [
'updated_at' => date(DATE_ATOM),
'properties' => array_map(static function (array $arrItem): array {
return [
'host' => trim((string)($arrItem['host'] ?? '')),
'site_url' => trim((string)($arrItem['site_url'] ?? '')),
'property_scope' => trim((string)($arrItem['property_scope'] ?? 'domain')),
'provider' => trim((string)($arrItem['provider'] ?? 'google_search_console')),
'note' => trim((string)($arrItem['note'] ?? '')),
];
}, $arrProperties),
];
$strTargetPath = self::defaultConfigPath($strCodeRoot);
self::ensureDir(dirname($strTargetPath));
file_put_contents($strTargetPath, json_encode($arrPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return [
'config_path' => $strTargetPath,
'mapped_hosts_count' => count($arrPayload['properties']),
'summary' => self::buildStatus($strCodeRoot),
];
}
public static function loadConfig(string $strCodeRoot): array
{
$strPath = self::defaultConfigPath($strCodeRoot);
if (!is_file($strPath)) {
return [];
}
$arrDecoded = json_decode((string)file_get_contents($strPath), true);
return is_array($arrDecoded) ? $arrDecoded : [];
}
public static function buildStatus(string $strCodeRoot): array
{
$strPath = self::defaultConfigPath($strCodeRoot);
$arrConfig = self::loadConfig($strCodeRoot);
$arrProperties = array_values(array_filter((array)($arrConfig['properties'] ?? []), 'is_array'));
return [
'config_path' => is_file($strPath) ? $strPath : '',
'property_map_ready' => is_file($strPath) && !empty($arrProperties),
'mapped_hosts_count' => count($arrProperties),
'updated_at' => is_file($strPath) ? date(DATE_ATOM, (int)(filemtime($strPath) ?: time())) : '',
'properties' => $arrProperties,
];
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoSearchConsoleFetchHelper
{
public static function buildSummary(string $strCodeRoot, int $intDays = 7): array
{
$intDays = max(1, min(30, $intDays));
$arrPlan = DomainExternalSeoSearchConsoleProviderPlanHelper::buildSummary($strCodeRoot, $intDays);
$arrProperties = array_values(array_filter((array)($arrPlan['properties_preview'] ?? []), 'is_array'));
$arrRequestPlan = [];
foreach ($arrProperties as $arrProperty) {
$strHost = trim((string)($arrProperty['host'] ?? ''));
$strSiteUrl = trim((string)($arrProperty['site_url'] ?? ''));
$strPropertyScope = trim((string)($arrProperty['property_scope'] ?? ''));
if ($strHost === '' || $strSiteUrl === '') {
continue;
}
$arrRequestPlan[] = [
'host' => $strHost,
'site_url' => $strSiteUrl,
'property_scope' => $strPropertyScope,
'request_dimensions' => ['query', 'page'],
'request_metrics' => ['impressions', 'clicks', 'ctr', 'avg_position'],
'date_range' => [
'days' => $intDays,
'start_date' => date('Y-m-d', strtotime('-' . max(0, $intDays - 1) . ' days')),
'end_date' => date('Y-m-d'),
],
'request_status' => self::resolveRequestStatus($arrPlan),
];
}
return [
'generated_at' => date(DATE_ATOM),
'provider_key' => 'google_search_console',
'mode' => 'search_console_fetch_skeleton',
'status' => self::resolveFetchStatus($arrPlan, $arrRequestPlan),
'days' => $intDays,
'credential_ready' => !empty($arrPlan['credential_ready']),
'property_map_ready' => !empty($arrPlan['property_map_ready']),
'client_dependency_ready' => !empty($arrPlan['client_dependency_ready']),
'planned_request_count' => count($arrRequestPlan),
'request_plan' => $arrRequestPlan,
'blocked_reason' => self::resolveBlockedReason($arrPlan, $arrRequestPlan),
'next_steps' => self::resolveNextSteps($arrPlan, $arrRequestPlan),
'note' => '当前是 Search Console fetch helper 骨架:它不会真实发请求,但会把真正准备请求的 property、维度、时间范围和当前阻塞原因列出来方便后续无缝接入真实 API。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/search-console-fetch.summary.json';
$strHtmlPath = $strOutputRoot . '/search-console-fetch.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function resolveRequestStatus(array $arrPlan): string
{
if (empty($arrPlan['credential_ready'])) {
return 'credential_missing';
}
if (empty($arrPlan['property_map_ready'])) {
return 'property_map_missing';
}
if (empty($arrPlan['client_dependency_ready'])) {
return 'client_missing';
}
return 'ready_to_fetch';
}
protected static function resolveFetchStatus(array $arrPlan, array $arrRequestPlan): string
{
if (empty($arrPlan['credential_ready'])) {
return 'blocked_by_credential';
}
if (empty($arrPlan['property_map_ready'])) {
return 'blocked_by_property_map';
}
if (empty($arrRequestPlan)) {
return 'blocked_by_empty_request_plan';
}
if (empty($arrPlan['client_dependency_ready'])) {
return 'blocked_by_client';
}
return 'ready_for_fetch_implementation';
}
protected static function resolveBlockedReason(array $arrPlan, array $arrRequestPlan): string
{
if (empty($arrPlan['credential_ready'])) {
return 'Search Console 凭证缺失';
}
if (empty($arrPlan['property_map_ready'])) {
return 'Search Console property map 缺失';
}
if (empty($arrRequestPlan)) {
return '当前没有可用的 property 请求计划';
}
if (empty($arrPlan['client_dependency_ready'])) {
return 'Google Search Console client 依赖缺失';
}
return '';
}
protected static function resolveNextSteps(array $arrPlan, array $arrRequestPlan): array
{
if (empty($arrPlan['credential_ready'])) {
return ['先上传 Search Console 凭证 JSON'];
}
if (empty($arrPlan['property_map_ready'])) {
return ['先上传 Search Console property map'];
}
if (empty($arrRequestPlan)) {
return ['先补可用的 host -> property 映射,确保 request_plan 非空'];
}
if (empty($arrPlan['client_dependency_ready'])) {
return [
'补 Google Search Console client 依赖或 HTTP 调用层',
'接真实 query/page 请求',
'把真实结果写入 real summary / trend / failed queue',
];
}
return [
'实现真实 Search Console query/page 拉数',
'沉淀真实 summary',
'再接真实异常队列与趋势层',
];
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['request_plan'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['site_url'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['property_scope'] ?? '')) . '</td>'
. '<td>' . self::escape(implode(', ', (array)($arrItem['request_dimensions'] ?? []))) . '</td>'
. '<td>' . self::escape(implode(', ', (array)($arrItem['request_metrics'] ?? []))) . '</td>'
. '<td>' . self::escape((string)(($arrItem['date_range'] ?? [])['start_date'] ?? '')) . ' ~ ' . self::escape((string)(($arrItem['date_range'] ?? [])['end_date'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['request_status'] ?? '')) . '</td>'
. '</tr>';
}
$arrStepRows = [];
foreach ((array)($arrSummary['next_steps'] ?? []) as $strStep) {
$arrStepRows[] = '<li>' . self::escape((string)$strStep) . '</li>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Search Console Fetch Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>Search Console Fetch Summary</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>status</strong><br>' . self::escape((string)($arrSummary['status'] ?? '')) . '</div>'
. '<div class="card"><strong>planned_request_count</strong><br>' . (int)($arrSummary['planned_request_count'] ?? 0) . '</div>'
. '<div class="card"><strong>credential_ready</strong><br>' . (!empty($arrSummary['credential_ready']) ? 'yes' : 'no') . '</div>'
. '<div class="card"><strong>property_map_ready</strong><br>' . (!empty($arrSummary['property_map_ready']) ? 'yes' : 'no') . '</div>'
. '<div class="card"><strong>client_ready</strong><br>' . (!empty($arrSummary['client_dependency_ready']) ? 'yes' : 'no') . '</div>'
. '<div class="card"><strong>blocked_reason</strong><br>' . self::escape((string)($arrSummary['blocked_reason'] ?? '-')) . '</div>'
. '</div>'
. '<h2>Request Plan</h2>'
. '<table><thead><tr><th>host</th><th>site_url</th><th>scope</th><th>dimensions</th><th>metrics</th><th>date_range</th><th>request_status</th></tr></thead><tbody>' . implode('', $arrRows) . '</tbody></table>'
. '<h2>Next Steps</h2><ul>' . implode('', $arrStepRows) . '</ul>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoSearchConsoleProviderPlanHelper
{
public static function buildSummary(string $strCodeRoot, int $intDays = 7): array
{
$intDays = max(1, min(30, $intDays));
$arrProviderSummary = DomainExternalSeoProviderRegistryHelper::buildSummary($strCodeRoot);
$arrSearchConsoleStatus = DomainExternalSeoSearchConsoleConfigHelper::buildStatus($strCodeRoot);
$arrGoogleProvider = [];
foreach ((array)($arrProviderSummary['providers'] ?? []) as $arrProvider) {
if ((string)($arrProvider['provider_key'] ?? '') === 'google_search_console') {
$arrGoogleProvider = (array)$arrProvider;
break;
}
}
$boolCredentialReady = !empty($arrGoogleProvider['credential_path']);
$boolPropertyMapReady = !empty($arrSearchConsoleStatus['property_map_ready']);
$boolClientReady = class_exists('\\Google\\Service\\SearchConsole') || class_exists('\\Google_Service_Webmasters');
$arrProperties = array_values(array_filter((array)($arrSearchConsoleStatus['properties'] ?? []), 'is_array'));
$strStatus = 'planned';
$arrNextSteps = [
'先上传 Search Console 凭证文件',
'再上传 host -> property 映射',
'最后补 Google client 或 HTTP 拉数实现',
];
if ($boolCredentialReady && !$boolPropertyMapReady) {
$strStatus = 'credential_ready_property_map_missing';
$arrNextSteps = [
'当前凭证已就绪',
'下一步上传 Search Console property map',
'映射完成后再进入真实 query/page 拉数',
];
} elseif ($boolCredentialReady && $boolPropertyMapReady && !$boolClientReady) {
$strStatus = 'ready_for_client_implementation';
$arrNextSteps = [
'凭证和 property map 都已齐备',
'下一步补 Google Search Console client 依赖或 HTTP 调用层',
'然后实现最近 7 天 query/page summary 拉数',
];
} elseif ($boolCredentialReady && $boolPropertyMapReady && $boolClientReady) {
$strStatus = 'ready_for_api_fetch';
$arrNextSteps = [
'当前已具备真实 API 拉数的前置条件',
'下一步实现 Search Console query/page 聚合请求',
'随后接入真实 summary / failed queue / trend',
];
}
return [
'generated_at' => date(DATE_ATOM),
'provider_key' => 'google_search_console',
'provider_label' => 'Google Search Console',
'days' => $intDays,
'status' => $strStatus,
'credential_ready' => $boolCredentialReady,
'property_map_ready' => $boolPropertyMapReady,
'client_dependency_ready' => $boolClientReady,
'credential_path' => (string)($arrGoogleProvider['credential_path'] ?? ''),
'property_map_path' => (string)($arrSearchConsoleStatus['config_path'] ?? ''),
'mapped_hosts_count' => count($arrProperties),
'properties_preview' => array_slice(array_map(static function (array $arrItem): array {
return [
'host' => (string)($arrItem['host'] ?? ''),
'site_url' => (string)($arrItem['site_url'] ?? ''),
'property_scope' => (string)($arrItem['property_scope'] ?? ''),
];
}, $arrProperties), 0, 20),
'fetch_scope' => [
'days' => $intDays,
'dimensions' => ['query', 'page'],
'aggregations' => ['impressions', 'clicks', 'ctr', 'avg_position'],
],
'next_steps' => $arrNextSteps,
'note' => '当前是 Search Console provider 计划层它明确凭证、映射、client 和待拉数范围是否齐备,用来判断什么时候可以真正进入 Search Console 拉数实现。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/search-console-provider-plan.summary.json';
$strHtmlPath = $strOutputRoot . '/search-console-provider-plan.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrPropertyRows = [];
foreach ((array)($arrSummary['properties_preview'] ?? []) as $arrItem) {
$arrPropertyRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['site_url'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['property_scope'] ?? '')) . '</td>'
. '</tr>';
}
$arrStepRows = [];
foreach ((array)($arrSummary['next_steps'] ?? []) as $strStep) {
$arrStepRows[] = '<li>' . self::escape((string)$strStep) . '</li>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Search Console Provider Plan</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>Search Console Provider Plan</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>status</strong><br>' . self::escape((string)($arrSummary['status'] ?? '')) . '</div>'
. '<div class="card"><strong>credential_ready</strong><br>' . (!empty($arrSummary['credential_ready']) ? 'yes' : 'no') . '</div>'
. '<div class="card"><strong>property_map_ready</strong><br>' . (!empty($arrSummary['property_map_ready']) ? 'yes' : 'no') . '</div>'
. '<div class="card"><strong>client_ready</strong><br>' . (!empty($arrSummary['client_dependency_ready']) ? 'yes' : 'no') . '</div>'
. '<div class="card"><strong>mapped_hosts</strong><br>' . (int)($arrSummary['mapped_hosts_count'] ?? 0) . '</div>'
. '<div class="card"><strong>days</strong><br>' . (int)($arrSummary['days'] ?? 0) . '</div>'
. '</div>'
. '<p>credential_path: <code>' . self::escape((string)($arrSummary['credential_path'] ?? '')) . '</code></p>'
. '<p>property_map_path: <code>' . self::escape((string)($arrSummary['property_map_path'] ?? '')) . '</code></p>'
. '<h2>Properties Preview</h2>'
. '<table><thead><tr><th>host</th><th>site_url</th><th>property_scope</th></tr></thead><tbody>' . implode('', $arrPropertyRows) . '</tbody></table>'
. '<h2>Next Steps</h2><ul>' . implode('', $arrStepRows) . '</ul>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
class DomainExternalSeoSiteQueryProbeHelper
{
public static function buildSummary(string $strCodeRoot, int $intLimit = 20): array
{
$intLimit = max(1, min(100, $intLimit));
$arrDomains = array_values(array_filter(DomainModel::getAllDomainOnCache(), static function ($mItem): bool {
return $mItem instanceof DomainModel;
}));
$arrHosts = [];
foreach ($arrDomains as $DomainModel) {
$strHost = trim((string)$DomainModel->d_domain);
if ($strHost === '') {
continue;
}
$arrHosts[] = $strHost;
}
sort($arrHosts);
$arrItems = [];
$arrStatusCount = [
'indexed_like' => 0,
'captcha' => 0,
'no_result' => 0,
'blocked' => 0,
'unknown' => 0,
];
foreach (array_slice($arrHosts, 0, $intLimit) as $strHost) {
$arrProbe = self::probeHost($strHost);
$arrItems[] = $arrProbe;
$strStatus = (string)($arrProbe['query_status'] ?? 'unknown');
if (!isset($arrStatusCount[$strStatus])) {
$arrStatusCount[$strStatus] = 0;
}
$arrStatusCount[$strStatus]++;
}
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'site_query_probe',
'engine' => 'baidu_site_query',
'host_count' => count($arrItems),
'status_buckets' => $arrStatusCount,
'items' => $arrItems,
'note' => '当前第一版只做 site: 收录探测采集层,优先区分 indexed_like / captcha / no_result / blocked / unknown。是否命中验证码页也会明确沉淀后续再增强结果页解析。',
];
}
public static function probeHost(string $strHost): array
{
$strHost = trim($strHost);
if ($strHost === '') {
return [
'host' => '',
'query_status' => 'unknown',
'query' => '',
'engine_variant' => '',
'http_status' => 0,
'final_url' => '',
'page_title' => '',
'result_count_text' => '',
'captcha_detected' => false,
'has_host_mention' => false,
'error_message' => 'empty_host',
'probed_at' => date(DATE_ATOM),
];
}
$arrVariants = [
[
'engine_variant' => 'baidu_mobile',
'url' => 'https://m.baidu.com/s?word=' . rawurlencode('site:' . $strHost),
'user_agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
],
[
'engine_variant' => 'baidu_web',
'url' => 'https://www.baidu.com/s?wd=' . rawurlencode('site:' . $strHost),
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36',
],
];
$arrLastProbe = [];
foreach ($arrVariants as $arrVariant) {
$arrProbe = self::fetchVariant($strHost, $arrVariant);
$arrLastProbe = $arrProbe;
if (($arrProbe['query_status'] ?? '') === 'indexed_like') {
return $arrProbe;
}
if (($arrProbe['query_status'] ?? '') === 'no_result') {
return $arrProbe;
}
if (($arrProbe['query_status'] ?? '') === 'captcha') {
return $arrProbe;
}
}
return $arrLastProbe;
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/site-query-probe.summary.json';
$strHtmlPath = $strOutputRoot . '/site-query-probe.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function fetchVariant(string $strHost, array $arrVariant): array
{
$strUrl = (string)($arrVariant['url'] ?? '');
$strUserAgent = (string)($arrVariant['user_agent'] ?? 'Mozilla/5.0');
$strVariant = (string)($arrVariant['engine_variant'] ?? 'unknown');
$arrResponse = self::httpGet($strUrl, $strUserAgent);
$strHtml = (string)($arrResponse['body'] ?? '');
$strFinalUrl = (string)($arrResponse['final_url'] ?? '');
$intStatus = (int)($arrResponse['http_status'] ?? 0);
$strTitle = self::extractTitle($strHtml);
$boolCaptcha = self::detectCaptcha($strHtml, $strFinalUrl, $strTitle);
$boolHasHostMention = stripos($strHtml, $strHost) !== false;
$strResultCountText = self::extractResultCountText($strHtml);
$strStatus = 'unknown';
if (!empty($arrResponse['error_message'])) {
$strStatus = 'blocked';
} elseif ($boolCaptcha) {
$strStatus = 'captcha';
} elseif ($strTitle !== '' && stripos($strTitle, 'site:' . $strHost) !== false && !$boolCaptcha) {
$strStatus = $boolHasHostMention ? 'indexed_like' : 'no_result';
} elseif ($boolHasHostMention && !$boolCaptcha) {
$strStatus = 'indexed_like';
}
return [
'host' => $strHost,
'query_status' => $strStatus,
'query' => 'site:' . $strHost,
'engine_variant' => $strVariant,
'http_status' => $intStatus,
'final_url' => $strFinalUrl,
'page_title' => $strTitle,
'result_count_text' => $strResultCountText,
'captcha_detected' => $boolCaptcha,
'has_host_mention' => $boolHasHostMention,
'error_message' => (string)($arrResponse['error_message'] ?? ''),
'probed_at' => date(DATE_ATOM),
];
}
protected static function httpGet(string $strUrl, string $strUserAgent): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $strUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HTTPHEADER => [
'Accept-Language: zh-CN,zh;q=0.9',
],
CURLOPT_USERAGENT => $strUserAgent,
CURLOPT_HEADER => false,
]);
$strBody = (string)curl_exec($ch);
$strError = curl_error($ch);
$intStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$strFinalUrl = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
curl_close($ch);
return [
'http_status' => $intStatus,
'final_url' => $strFinalUrl,
'body' => $strBody,
'error_message' => $strError,
];
}
protected static function detectCaptcha(string $strHtml, string $strFinalUrl, string $strTitle): bool
{
$strCombined = strtolower($strHtml . "\n" . $strFinalUrl . "\n" . $strTitle);
return str_contains($strCombined, 'wappass.baidu.com/static/captcha')
|| str_contains($strCombined, '百度安全验证')
|| str_contains($strCombined, '安全验证')
|| str_contains($strCombined, 'captcha');
}
protected static function extractTitle(string $strHtml): string
{
if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $strHtml, $arrMatches)) {
return trim(html_entity_decode(strip_tags((string)$arrMatches[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
return '';
}
protected static function extractResultCountText(string $strHtml): string
{
$arrPatterns = [
'/百度为您找到相关结果约([^<]+)/u',
'/相关结果约([^<]+)/u',
'/找到相关结果约([^<]+)/u',
];
foreach ($arrPatterns as $strPattern) {
if (preg_match($strPattern, $strHtml, $arrMatches)) {
return trim((string)$arrMatches[0]);
}
}
return '';
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['host'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['query_status'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['engine_variant'] ?? '')) . '</td>'
. '<td>' . (int)($arrItem['http_status'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrItem['page_title'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['result_count_text'] ?? '')) . '</td>'
. '<td>' . (!empty($arrItem['captcha_detected']) ? 'yes' : 'no') . '</td>'
. '<td>' . self::escape((string)($arrItem['error_message'] ?? '')) . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Site Query Probe Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(5,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>Site Query Probe Summary</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>host_count</strong><br>' . (int)($arrSummary['host_count'] ?? 0) . '</div>'
. '<div class="card"><strong>indexed_like</strong><br>' . (int)((($arrSummary['status_buckets'] ?? [])['indexed_like']) ?? 0) . '</div>'
. '<div class="card"><strong>captcha</strong><br>' . (int)((($arrSummary['status_buckets'] ?? [])['captcha']) ?? 0) . '</div>'
. '<div class="card"><strong>no_result</strong><br>' . (int)((($arrSummary['status_buckets'] ?? [])['no_result']) ?? 0) . '</div>'
. '<div class="card"><strong>blocked</strong><br>' . (int)((($arrSummary['status_buckets'] ?? [])['blocked']) ?? 0) . '</div>'
. '</div>'
. '<table><thead><tr><th>host</th><th>status</th><th>engine</th><th>http</th><th>title</th><th>result_count_text</th><th>captcha</th><th>error</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,523 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\SeoExternalSnapshotModel;
class DomainExternalSeoSnapshotAnalysisHelper
{
public static function buildSummary(int $intDays = 7, int $intLimit = 20): array
{
$intDays = max(1, min(90, $intDays));
$intLimit = max(1, min(200, $intLimit));
$strStartDate = date('Y-m-d', strtotime('-' . ($intDays - 1) . ' days'));
$arrProviders = [];
$arrHosts = [];
$arrLatestSignalsByHost = [];
$intKeywordSnapshots = (int)SeoExternalSnapshotModel::where('metric_date', '>=', $strStartDate)
->where('scope', 'keyword')
->count();
$intHostSnapshots = (int)SeoExternalSnapshotModel::where('metric_date', '>=', $strStartDate)
->where('scope', 'host')
->count();
$strLatestMetricDate = '';
self::eachSnapshotRow(
SeoExternalSnapshotModel::where('metric_date', '>=', $strStartDate)
->where('scope', 'host'),
['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'] ?? ''));
$strIndexedStatus = trim((string)($arrItem['indexed_status'] ?? ''));
$intQueriedAt = (int)($arrItem['queried_at'] ?? 0);
if ($strProvider !== '') {
$arrProviders[$strProvider] = true;
}
if ($strHost !== '') {
$arrHosts[$strHost] = true;
}
if ($strMetricDate !== '' && $strMetricDate > $strLatestMetricDate) {
$strLatestMetricDate = $strMetricDate;
}
if ($strHost !== '') {
$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 ($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;
}
}
},
1000
);
$intIndexedLikeCount = 0;
$intKeywordReadyCount = 0;
$intTrafficReadyCount = 0;
$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 = 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;
if ($boolIndexedLike) {
$intIndexedLikeCount++;
}
if ($boolKeywordReady) {
$intKeywordReadyCount++;
}
if ($boolTrafficReady) {
$intTrafficReadyCount++;
}
}
unset($arrRow);
usort($arrHostRows, static function (array $arrLeft, array $arrRight): int {
$intLeftKeyword = ((int)($arrLeft['pc_keyword_count'] ?? 0) + (int)($arrLeft['mobile_keyword_count'] ?? 0));
$intRightKeyword = ((int)($arrRight['pc_keyword_count'] ?? 0) + (int)($arrRight['mobile_keyword_count'] ?? 0));
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'] ?? ''));
});
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_external_snapshot_summary',
'days' => $intDays,
'latest_metric_date' => $strLatestMetricDate,
'snapshot_count' => $intHostSnapshots + $intKeywordSnapshots,
'host_snapshot_count' => $intHostSnapshots,
'keyword_snapshot_count' => $intKeywordSnapshots,
'host_count' => count($arrHosts),
'providers' => array_values(array_keys($arrProviders)),
'indexed_like_host_count' => $intIndexedLikeCount,
'keyword_ready_host_count' => $intKeywordReadyCount,
'traffic_ready_host_count' => $intTrafficReadyCount,
'hosts' => array_slice($arrHostRows, 0, $intLimit),
'note' => '当前摘要基于 seo_external_snapshot 历史快照表,后面无论是本项目采集还是外部 Node 回推,都统一从这张表做分析。',
];
}
public static function buildKeywordMovementSummary(int $intDays = 14, int $intLimit = 50): array
{
$intDays = max(2, min(90, $intDays));
$intLimit = max(1, min(200, $intLimit));
$strStartDate = date('Y-m-d', strtotime('-' . ($intDays - 1) . ' days'));
$arrDates = self::fetchDistinctMetricDates(
SeoExternalSnapshotModel::where('metric_date', '>=', $strStartDate)
->where('scope', 'keyword')
->where('provider', 'aizhan')
->where('snapshot_type', 'aizhan_keyword'),
2
);
$strLatestDate = $arrDates[0] ?? '';
$strPreviousDate = $arrDates[1] ?? '';
$arrLatestMap = [];
$arrPreviousMap = [];
if ($strLatestDate !== '' || $strPreviousDate !== '') {
$arrMetricDates = array_values(array_filter([$strLatestDate, $strPreviousDate], static fn ($strDate) => $strDate !== ''));
self::eachSnapshotRow(
SeoExternalSnapshotModel::where('scope', 'keyword')
->where('provider', 'aizhan')
->where('snapshot_type', 'aizhan_keyword')
->where('metric_date', 'in', $arrMetricDates),
['id', 'metric_date', 'host', 'keyword', 'device', 'rank_value', 'search_volume'],
static function (array $arrRow) use ($strLatestDate, $strPreviousDate, &$arrLatestMap, &$arrPreviousMap): void {
$strMetricDate = trim((string)($arrRow['metric_date'] ?? ''));
$strHost = trim((string)($arrRow['host'] ?? ''));
$strKeyword = trim((string)($arrRow['keyword'] ?? ''));
$strDevice = trim((string)($arrRow['device'] ?? ''));
if ($strHost === '' || $strKeyword === '' || $strMetricDate === '') {
return;
}
$strKey = implode('|', [$strHost, $strKeyword, $strDevice]);
$arrPayload = [
'host' => $strHost,
'keyword' => $strKeyword,
'device' => $strDevice,
'rank_value' => (int)($arrRow['rank_value'] ?? 0),
'search_volume' => (int)($arrRow['search_volume'] ?? 0),
'metric_date' => $strMetricDate,
];
if ($strMetricDate === $strLatestDate && !isset($arrLatestMap[$strKey])) {
$arrLatestMap[$strKey] = $arrPayload;
}
if ($strMetricDate === $strPreviousDate && !isset($arrPreviousMap[$strKey])) {
$arrPreviousMap[$strKey] = $arrPayload;
}
},
2000
);
}
$arrNew = [];
$arrUp = [];
$arrDown = [];
$arrDropped = [];
foreach ($arrLatestMap as $strKey => $arrLatest) {
$arrPrevious = $arrPreviousMap[$strKey] ?? null;
if (!$arrPrevious) {
$arrNew[] = $arrLatest + ['change_type' => 'new', 'rank_change' => null];
continue;
}
$intLatestRank = (int)($arrLatest['rank_value'] ?? 0);
$intPreviousRank = (int)($arrPrevious['rank_value'] ?? 0);
$intDiff = $intPreviousRank - $intLatestRank;
if ($intLatestRank > 0 && $intPreviousRank > 0) {
if ($intDiff > 0) {
$arrUp[] = $arrLatest + ['change_type' => 'up', 'previous_rank' => $intPreviousRank, 'rank_change' => $intDiff];
} elseif ($intDiff < 0) {
$arrDown[] = $arrLatest + ['change_type' => 'down', 'previous_rank' => $intPreviousRank, 'rank_change' => $intDiff];
}
}
}
foreach ($arrPreviousMap as $strKey => $arrPrevious) {
if (!isset($arrLatestMap[$strKey])) {
$arrDropped[] = $arrPrevious + ['change_type' => 'dropped', 'rank_change' => null];
}
}
usort($arrUp, static function (array $a, array $b): int {
return ((int)($b['rank_change'] ?? 0)) <=> ((int)($a['rank_change'] ?? 0));
});
usort($arrDown, static function (array $a, array $b): int {
return ((int)($a['rank_change'] ?? 0)) <=> ((int)($b['rank_change'] ?? 0));
});
usort($arrNew, static function (array $a, array $b): int {
return ((int)($b['search_volume'] ?? 0)) <=> ((int)($a['search_volume'] ?? 0));
});
usort($arrDropped, static function (array $a, array $b): int {
return ((int)($b['search_volume'] ?? 0)) <=> ((int)($a['search_volume'] ?? 0));
});
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_external_keyword_movement',
'days' => $intDays,
'latest_metric_date' => $strLatestDate,
'previous_metric_date' => $strPreviousDate,
'latest_keyword_count' => count($arrLatestMap),
'previous_keyword_count' => count($arrPreviousMap),
'new_count' => count($arrNew),
'up_count' => count($arrUp),
'down_count' => count($arrDown),
'dropped_count' => count($arrDropped),
'new_items' => array_slice($arrNew, 0, $intLimit),
'up_items' => array_slice($arrUp, 0, $intLimit),
'down_items' => array_slice($arrDown, 0, $intLimit),
'dropped_items' => array_slice($arrDropped, 0, $intLimit),
'note' => '当前升降分析只消费 seo_external_snapshot 表里 provider=aizhan、snapshot_type=aizhan_keyword、scope=keyword 的历史快照。',
];
}
public static function buildIndexMovementSummary(int $intDays = 14, int $intLimit = 50): array
{
$intDays = max(2, min(90, $intDays));
$intLimit = max(1, min(200, $intLimit));
$strStartDate = date('Y-m-d', strtotime('-' . ($intDays - 1) . ' days'));
$arrDates = self::fetchDistinctMetricDates(
SeoExternalSnapshotModel::where('metric_date', '>=', $strStartDate)
->where('scope', 'host')
->where('snapshot_type', 'site_query'),
2
);
$strLatestDate = $arrDates[0] ?? '';
$strPreviousDate = $arrDates[1] ?? '';
$arrLatestMap = [];
$arrPreviousMap = [];
if ($strLatestDate !== '' || $strPreviousDate !== '') {
$arrMetricDates = array_values(array_filter([$strLatestDate, $strPreviousDate], static fn ($strDate) => $strDate !== ''));
self::eachSnapshotRow(
SeoExternalSnapshotModel::where('scope', 'host')
->where('snapshot_type', 'site_query')
->where('metric_date', 'in', $arrMetricDates),
['id', 'metric_date', 'host', 'provider', 'status', 'indexed_status', 'result_count_text', 'queried_at'],
static function (array $arrRow) use ($strLatestDate, $strPreviousDate, &$arrLatestMap, &$arrPreviousMap): void {
$strMetricDate = trim((string)($arrRow['metric_date'] ?? ''));
$strHost = trim((string)($arrRow['host'] ?? ''));
if ($strHost === '' || $strMetricDate === '') {
return;
}
$arrPayload = [
'host' => $strHost,
'provider' => trim((string)($arrRow['provider'] ?? '')),
'metric_date' => $strMetricDate,
'status' => trim((string)($arrRow['status'] ?? '')),
'indexed_status' => trim((string)($arrRow['indexed_status'] ?? '')),
'result_count_text' => trim((string)($arrRow['result_count_text'] ?? '')),
'queried_at' => trim((string)($arrRow['queried_at'] ?? '')),
'normalized_state' => self::normalizeIndexState($arrRow),
];
if ($strMetricDate === $strLatestDate && !isset($arrLatestMap[$strHost])) {
$arrLatestMap[$strHost] = $arrPayload;
}
if ($strMetricDate === $strPreviousDate && !isset($arrPreviousMap[$strHost])) {
$arrPreviousMap[$strHost] = $arrPayload;
}
},
2000
);
}
$arrNewlyIndexed = [];
$arrLostIndexed = [];
$arrCaptcha = [];
$arrNoResult = [];
$arrBlocked = [];
$arrChanged = [];
foreach ($arrLatestMap as $strHost => $arrLatest) {
$arrPrevious = $arrPreviousMap[$strHost] ?? null;
$strLatestState = (string)($arrLatest['normalized_state'] ?? '');
$strPreviousState = (string)($arrPrevious['normalized_state'] ?? '');
if ($strLatestState === 'indexed_like' && $strPreviousState !== 'indexed_like') {
$arrNewlyIndexed[] = $arrLatest + [
'change_type' => 'newly_indexed',
'previous_state' => $strPreviousState,
'previous_metric_date' => (string)($arrPrevious['metric_date'] ?? ''),
];
}
if ($strPreviousState === 'indexed_like' && $strLatestState !== 'indexed_like') {
$arrLostIndexed[] = $arrLatest + [
'change_type' => 'lost_indexed',
'previous_state' => $strPreviousState,
'previous_metric_date' => (string)($arrPrevious['metric_date'] ?? ''),
];
}
if ($strLatestState === 'captcha') {
$arrCaptcha[] = $arrLatest + ['change_type' => 'captcha'];
}
if ($strLatestState === 'no_result') {
$arrNoResult[] = $arrLatest + ['change_type' => 'no_result'];
}
if ($strLatestState === 'blocked') {
$arrBlocked[] = $arrLatest + ['change_type' => 'blocked'];
}
if ($arrPrevious && $strLatestState !== $strPreviousState) {
$arrChanged[] = $arrLatest + [
'change_type' => 'changed',
'previous_state' => $strPreviousState,
'previous_metric_date' => (string)($arrPrevious['metric_date'] ?? ''),
];
}
}
usort($arrNewlyIndexed, static function (array $a, array $b): int {
return strcmp((string)($a['host'] ?? ''), (string)($b['host'] ?? ''));
});
usort($arrLostIndexed, static function (array $a, array $b): int {
return strcmp((string)($a['host'] ?? ''), (string)($b['host'] ?? ''));
});
usort($arrCaptcha, static function (array $a, array $b): int {
return strcmp((string)($a['host'] ?? ''), (string)($b['host'] ?? ''));
});
usort($arrNoResult, static function (array $a, array $b): int {
return strcmp((string)($a['host'] ?? ''), (string)($b['host'] ?? ''));
});
usort($arrBlocked, static function (array $a, array $b): int {
return strcmp((string)($a['host'] ?? ''), (string)($b['host'] ?? ''));
});
usort($arrChanged, static function (array $a, array $b): int {
return strcmp((string)($a['host'] ?? ''), (string)($b['host'] ?? ''));
});
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_external_index_movement',
'days' => $intDays,
'latest_metric_date' => $strLatestDate,
'previous_metric_date' => $strPreviousDate,
'latest_host_count' => count($arrLatestMap),
'previous_host_count' => count($arrPreviousMap),
'newly_indexed_count' => count($arrNewlyIndexed),
'lost_indexed_count' => count($arrLostIndexed),
'captcha_count' => count($arrCaptcha),
'no_result_count' => count($arrNoResult),
'blocked_count' => count($arrBlocked),
'changed_count' => count($arrChanged),
'newly_indexed_items' => array_slice($arrNewlyIndexed, 0, $intLimit),
'lost_indexed_items' => array_slice($arrLostIndexed, 0, $intLimit),
'captcha_items' => array_slice($arrCaptcha, 0, $intLimit),
'no_result_items' => array_slice($arrNoResult, 0, $intLimit),
'blocked_items' => array_slice($arrBlocked, 0, $intLimit),
'changed_items' => array_slice($arrChanged, 0, $intLimit),
'note' => '当前收录变化分析只消费 seo_external_snapshot 表里 snapshot_type=site_query、scope=host 的历史快照,适合和 site: 收录探测配套使用。',
];
}
protected static function isZeroRange(string $strRange): bool
{
$strRange = trim($strRange);
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'] ?? ''));
$strStatus = trim((string)($arrRow['status'] ?? ''));
foreach ([$strIndexedStatus, $strStatus] as $strState) {
if (in_array($strState, ['indexed_like', 'captcha', 'no_result', 'blocked', 'unknown'], true)) {
return $strState;
}
}
$strResultCountText = trim((string)($arrRow['result_count_text'] ?? ''));
if ($strResultCountText !== '') {
return 'indexed_like';
}
return 'unknown';
}
protected static function eachSnapshotRow($Query, array $arrFields, callable $callableHandler, int $intChunkSize = 1000): void
{
$intChunkSize = max(100, min(5000, $intChunkSize));
$intLastId = 0;
while (true) {
$QueryChunk = clone $Query;
if ($intLastId > 0) {
$QueryChunk->where('id', '<', $intLastId);
}
$arrRows = $QueryChunk
->field($arrFields)
->order('id', 'desc')
->limit($intChunkSize)
->select()
->toArray();
if (!$arrRows) {
break;
}
foreach ($arrRows as $arrRow) {
$callableHandler($arrRow);
}
$arrLastRow = end($arrRows);
$intLastId = (int)($arrLastRow['id'] ?? 0);
if (count($arrRows) < $intChunkSize || $intLastId <= 0) {
break;
}
}
}
protected static function fetchDistinctMetricDates($Query, int $intLimit = 2): array
{
$intLimit = max(1, min(10, $intLimit));
$arrDates = $Query
->distinct(true)
->field(['metric_date'])
->order('metric_date', 'desc')
->limit($intLimit)
->column('metric_date');
$arrDates = array_values(array_filter(array_map(static fn ($strDate) => trim((string)$strDate), (array)$arrDates)));
rsort($arrDates);
return $arrDates;
}
}

View File

@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
use app\model\SeoExternalSnapshotModel;
class DomainExternalSeoSnapshotHelper
{
public static function buildCollectHosts(int $intLimit = 1000, bool $boolIncludeWildcard = false): array
{
$intLimit = max(1, min(5000, $intLimit));
$arrHosts = [];
$arrFallbackHosts = [];
$arrDomainRows = array_values((array)DomainModel::getAllDomainOnCache());
$boolCacheRefreshed = false;
if (empty($arrDomainRows)) {
DomainModel::flushAllDomianOnCache();
$arrDomainRows = array_values((array)DomainModel::getAllDomainOnCache());
$boolCacheRefreshed = true;
}
$intWildcardFilteredCount = 0;
$intWildcardRawCount = 0;
foreach ($arrDomainRows as $DomainModel) {
if (!$DomainModel instanceof DomainModel) {
continue;
}
$strHost = trim((string)$DomainModel->d_domain);
if ($strHost === '') {
continue;
}
$boolWildcard = str_contains($strHost, '*') || (string)($DomainModel->d_match_type ?? '') === DomainModel::MATCH_TYPE_WILDCARD;
if ($boolWildcard) {
$intWildcardRawCount++;
}
if (!$boolIncludeWildcard && $boolWildcard) {
$intWildcardFilteredCount++;
$strFallbackHost = self::resolveFallbackHost($DomainModel);
if ($strFallbackHost !== '' && !isset($arrFallbackHosts[$strFallbackHost])) {
$arrFallbackHosts[$strFallbackHost] = [
'domain_id' => (int)$DomainModel->d_id,
'host' => $strFallbackHost,
'match_type' => (string)($DomainModel->d_match_type ?? ''),
'template_id' => (int)($DomainModel->t_id ?? 0),
'site_name' => (string)($DomainModel->d_name ?? ''),
'baidu_token_ready' => trim((string)($DomainModel->d_baidu_token ?? '')) !== '',
'source_domain' => $strHost,
'source_mode' => 'wildcard_root_fallback',
];
}
continue;
}
$arrHosts[$strHost] = [
'domain_id' => (int)$DomainModel->d_id,
'host' => $strHost,
'match_type' => (string)($DomainModel->d_match_type ?? ''),
'template_id' => (int)($DomainModel->t_id ?? 0),
'site_name' => (string)($DomainModel->d_name ?? ''),
'baidu_token_ready' => trim((string)($DomainModel->d_baidu_token ?? '')) !== '',
'source_domain' => $strHost,
'source_mode' => $boolWildcard ? 'wildcard' : 'exact',
];
}
$strSelectionMode = 'exact';
if (empty($arrHosts) && !$boolIncludeWildcard && !empty($arrFallbackHosts)) {
$arrHosts = $arrFallbackHosts;
$strSelectionMode = 'wildcard_root_fallback';
} elseif (empty($arrHosts)) {
$strSelectionMode = 'empty';
}
$intExactHostCount = count($arrHosts);
usort($arrHosts, static function (array $a, array $b): int {
return strcmp((string)$a['host'], (string)$b['host']);
});
$arrHosts = array_slice($arrHosts, 0, $intLimit);
return [
'generated_at' => date(DATE_ATOM),
'host_count' => count($arrHosts),
'exact_host_count' => $intExactHostCount,
'include_wildcard' => $boolIncludeWildcard,
'cache_refreshed' => $boolCacheRefreshed,
'selection_mode' => $strSelectionMode,
'wildcard_filtered_count' => $intWildcardFilteredCount,
'wildcard_raw_count' => $intWildcardRawCount,
'fallback_host_count' => count($arrFallbackHosts),
'items' => $arrHosts,
'note' => '给外部采集端使用的域名列表。默认跳过 wildcard host如果 exact host 为空,会自动回退到 wildcard root host。',
];
}
protected static function resolveFallbackHost(DomainModel $DomainModel): string
{
$strHost = trim((string)$DomainModel->d_domain);
$strParent = trim((string)($DomainModel->d_parent_domain ?? ''));
if ($strParent !== '') {
return self::normalizeHost($strParent);
}
$strHost = preg_replace('/^\*\./', '', $strHost);
return self::normalizeHost((string)$strHost);
}
protected static function normalizeHost(string $host): string
{
$host = trim(strtolower($host));
$host = preg_replace('#^https?://#', '', $host);
$host = preg_replace('#/.*$#', '', $host);
return trim((string)$host, '.');
}
public static function ingestSnapshots(array $arrSnapshots, string $strSource = 'push_api'): array
{
$arrInserted = [];
$arrUpdated = [];
$arrErrors = [];
foreach ($arrSnapshots as $intIndex => $arrItem) {
try {
if (!is_array($arrItem)) {
throw new \RuntimeException('snapshot item must be array');
}
$arrPayload = self::normalizeSnapshot($arrItem, $strSource);
$SeoExternalSnapshotModel = SeoExternalSnapshotModel::where('snapshot_signature', $arrPayload['snapshot_signature'])->find();
if ($SeoExternalSnapshotModel) {
$SeoExternalSnapshotModel->fill($arrPayload);
$SeoExternalSnapshotModel->updated_at = time();
$SeoExternalSnapshotModel->save();
$arrUpdated[] = [
'id' => (int)$SeoExternalSnapshotModel->id,
'host' => $arrPayload['host'],
'provider' => $arrPayload['provider'],
'snapshot_type' => $arrPayload['snapshot_type'],
'scope' => $arrPayload['scope'],
'mode' => 'updated',
];
continue;
}
$SeoExternalSnapshotModel = new SeoExternalSnapshotModel();
$SeoExternalSnapshotModel->fill($arrPayload);
$SeoExternalSnapshotModel->created_at = time();
$SeoExternalSnapshotModel->updated_at = time();
$SeoExternalSnapshotModel->save();
$arrInserted[] = [
'id' => (int)$SeoExternalSnapshotModel->id,
'host' => $arrPayload['host'],
'provider' => $arrPayload['provider'],
'snapshot_type' => $arrPayload['snapshot_type'],
'scope' => $arrPayload['scope'],
'mode' => 'inserted',
];
} catch (\Throwable $Throwable) {
$arrErrors[] = [
'index' => $intIndex,
'message' => $Throwable->getMessage(),
];
}
}
if (!empty($arrInserted) || !empty($arrUpdated)) {
SeoResourcePoolHelper::syncKeywordFeedback('', 30, 100);
}
return [
'generated_at' => date(DATE_ATOM),
'source' => $strSource,
'received_count' => count($arrSnapshots),
'inserted_count' => count($arrInserted),
'updated_count' => count($arrUpdated),
'failed_count' => count($arrErrors),
'inserted_items' => $arrInserted,
'updated_items' => $arrUpdated,
'errors' => $arrErrors,
];
}
protected static function normalizeSnapshot(array $arrItem, string $strSource): array
{
$strHost = trim((string)($arrItem['host'] ?? ''));
if ($strHost === '') {
throw new \RuntimeException('host is required');
}
$strProvider = trim((string)($arrItem['provider'] ?? ''));
if ($strProvider === '') {
throw new \RuntimeException('provider is required');
}
$strScope = trim((string)($arrItem['scope'] ?? 'host'));
if (!in_array($strScope, ['host', 'keyword'], true)) {
$strScope = 'host';
}
$strSnapshotType = trim((string)($arrItem['snapshot_type'] ?? ''));
if ($strSnapshotType === '') {
$strSnapshotType = $strProvider;
}
$strKeyword = trim((string)($arrItem['keyword'] ?? ''));
$strDevice = trim((string)($arrItem['device'] ?? ''));
$strMetricDate = trim((string)($arrItem['metric_date'] ?? date('Y-m-d')));
$intQueriedAt = (int)($arrItem['queried_at'] ?? time());
if ($intQueriedAt <= 0) {
$intQueriedAt = time();
}
$DomainModel = DomainModel::where('d_domain', $strHost)->find();
$intDomainId = $DomainModel ? (int)$DomainModel->d_id : 0;
$mPayload = $arrItem['payload'] ?? [];
if (is_string($mPayload)) {
$arrDecoded = json_decode($mPayload, true);
$mPayload = is_array($arrDecoded) ? $arrDecoded : ['raw' => $mPayload];
}
if (!is_array($mPayload)) {
$mPayload = [];
}
$strSignature = sha1(implode('|', [
$strProvider,
$strSnapshotType,
$strScope,
$strHost,
$strKeyword,
$strDevice,
$strMetricDate,
]));
return [
'domain_id' => $intDomainId,
'host' => $strHost,
'provider' => $strProvider,
'snapshot_type' => $strSnapshotType,
'scope' => $strScope,
'keyword' => $strKeyword,
'device' => $strDevice,
'metric_date' => $strMetricDate,
'queried_at' => $intQueriedAt,
'source' => trim((string)($arrItem['source'] ?? $strSource)),
'status' => trim((string)($arrItem['status'] ?? '')),
'indexed_status' => trim((string)($arrItem['indexed_status'] ?? '')),
'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),
'rank_value' => (int)($arrItem['rank_value'] ?? 0),
'search_volume' => (int)($arrItem['search_volume'] ?? 0),
'payload' => json_encode($mPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'snapshot_signature' => $strSignature,
];
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoSnapshotRunHelper
{
protected static function publicRelativePath(string $strRoot, string $strPath): string
{
$strRoot = str_replace('\\', '/', rtrim($strRoot, '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strRoot . '/')) {
return substr($strPath, strlen($strRoot . '/'));
}
return $strPath;
}
public static function createRunRoot(string $strRoot, string $strPrefix = 'snapshot_push'): string
{
$strRoot = rtrim($strRoot, '/');
$strDate = date('Ymd');
$strRunId = date('His') . '_' . $strPrefix . '_' . substr(sha1(uniqid('', true)), 0, 6);
$strRunRoot = $strRoot . '/' . $strDate . '/' . $strRunId;
if (!is_dir($strRunRoot) && !mkdir($strRunRoot, 0777, true) && !is_dir($strRunRoot)) {
throw new \RuntimeException('create snapshot run root failed: ' . $strRunRoot);
}
return $strRunRoot;
}
public static function writeRunArtifacts(string $strPublicRoot, string $strRunRoot, array $arrSummary, array $arrSnapshots): array
{
$strRunRoot = rtrim($strRunRoot, '/');
if (!is_dir($strRunRoot) && !mkdir($strRunRoot, 0777, true) && !is_dir($strRunRoot)) {
throw new \RuntimeException('snapshot run root missing: ' . $strRunRoot);
}
$strPayloadPath = $strRunRoot . '/snapshots.payload.json';
file_put_contents($strPayloadPath, json_encode([
'generated_at' => date(DATE_ATOM),
'snapshots' => array_values($arrSnapshots),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$strJsonPath = $strRunRoot . '/snapshot-ingest.summary.json';
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$strHtmlPath = $strRunRoot . '/snapshot-ingest.summary.html';
$strHtml = '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>SEO 外部快照接收摘要</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;color:#303133}table{border-collapse:collapse;width:100%;margin-top:16px}th,td{border:1px solid #ebeef5;padding:8px;text-align:left}th{background:#f5f7fa}.meta{display:flex;gap:16px;flex-wrap:wrap;margin:12px 0}.card{border:1px solid #ebeef5;border-radius:8px;padding:12px;background:#fafafa}</style>'
. '</head><body>'
. '<h1>SEO 外部快照接收摘要</h1>'
. '<div class="meta">'
. '<div class="card">source: ' . htmlspecialchars((string)($arrSummary['source'] ?? '-')) . '</div>'
. '<div class="card">received: ' . (int)($arrSummary['received_count'] ?? 0) . '</div>'
. '<div class="card">inserted: ' . (int)($arrSummary['inserted_count'] ?? 0) . '</div>'
. '<div class="card">updated: ' . (int)($arrSummary['updated_count'] ?? 0) . '</div>'
. '<div class="card">failed: ' . (int)($arrSummary['failed_count'] ?? 0) . '</div>'
. '</div>';
$arrOverview = (array)($arrSummary['post_ingest_overview'] ?? []);
$arrTrend = (array)($arrSummary['post_ingest_snapshot_trend'] ?? []);
if (!empty($arrOverview) || !empty($arrTrend)) {
$strHtml .= '<h2>Post Ingest</h2><div class="meta">';
if (!empty($arrOverview)) {
$strHtml .= '<div class="card">health: ' . htmlspecialchars((string)($arrOverview['health_label'] ?? '-')) . '</div>'
. '<div class="card">hosts: ' . (int)($arrOverview['snapshot_summary']['host_count'] ?? 0) . '</div>'
. '<div class="card">indexed_like: ' . (int)($arrOverview['snapshot_summary']['indexed_like_host_count'] ?? 0) . '</div>';
}
if (!empty($arrTrend)) {
$strHtml .= '<div class="card">trend: ' . htmlspecialchars((string)($arrTrend['success_trend']['label'] ?? '-')) . '</div>'
. '<div class="card">runs: ' . (int)($arrTrend['runs_count'] ?? 0) . '</div>'
. '<div class="card">latest success rate: ' . htmlspecialchars((string)($arrTrend['success_trend']['latest_success_rate'] ?? '-')) . '</div>';
}
$strHtml .= '</div>';
}
$arrItems = array_merge((array)($arrSummary['inserted_items'] ?? []), (array)($arrSummary['updated_items'] ?? []));
if (!empty($arrItems)) {
$strHtml .= '<table><thead><tr><th>Host</th><th>Provider</th><th>Snapshot Type</th><th>Scope</th><th>Mode</th></tr></thead><tbody>';
foreach ($arrItems as $arrItem) {
$strHtml .= '<tr>'
. '<td>' . htmlspecialchars((string)($arrItem['host'] ?? '')) . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['provider'] ?? '')) . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['snapshot_type'] ?? '')) . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['scope'] ?? '')) . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['mode'] ?? '')) . '</td>'
. '</tr>';
}
$strHtml .= '</tbody></table>';
}
if (!empty($arrSummary['errors'])) {
$strHtml .= '<h2>Errors</h2><table><thead><tr><th>Index</th><th>Message</th></tr></thead><tbody>';
foreach ((array)$arrSummary['errors'] as $arrError) {
$strHtml .= '<tr><td>' . (int)($arrError['index'] ?? 0) . '</td><td>' . htmlspecialchars((string)($arrError['message'] ?? '')) . '</td></tr>';
}
$strHtml .= '</tbody></table>';
}
$strHtml .= '</body></html>';
file_put_contents($strHtmlPath, $strHtml);
$arrSummary['payload_json_path'] = self::publicRelativePath($strPublicRoot, $strPayloadPath);
$arrSummary['summary_json_path'] = self::publicRelativePath($strPublicRoot, $strJsonPath);
$arrSummary['summary_html_path'] = self::publicRelativePath($strPublicRoot, $strHtmlPath);
$arrSummary['run_root_public'] = self::publicRelativePath($strPublicRoot, $strRunRoot);
return $arrSummary;
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoSnapshotRunIndexHelper
{
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;
}
public static function buildSummary(string $strRoot, int $intLimit = 20): array
{
$strRoot = rtrim($strRoot, '/');
$intLimit = max(1, min(100, $intLimit));
if ($strRoot === '' || !is_dir($strRoot)) {
return [
'items' => [],
'total' => 0,
'latest_run' => [],
];
}
$arrSummaryFiles = array_merge(
(array)glob($strRoot . '/*/snapshot-ingest.summary.json'),
(array)glob($strRoot . '/*/*/snapshot-ingest.summary.json')
);
$arrSummaryFiles = array_values(array_filter(array_unique($arrSummaryFiles), 'is_file'));
usort($arrSummaryFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
$arrItems = [];
foreach (array_slice($arrSummaryFiles, 0, $intLimit) as $strSummaryPath) {
$arrDecoded = json_decode((string)file_get_contents($strSummaryPath), true);
$arrSummary = is_array($arrDecoded) ? $arrDecoded : [];
$strRunRoot = dirname($strSummaryPath);
$arrErrors = (array)($arrSummary['errors'] ?? []);
$arrErrorBuckets = [];
foreach ($arrErrors as $arrError) {
$strMessage = trim((string)($arrError['message'] ?? 'unknown_error'));
if ($strMessage === '') {
$strMessage = 'unknown_error';
}
$arrErrorBuckets[$strMessage] = (int)($arrErrorBuckets[$strMessage] ?? 0) + 1;
}
arsort($arrErrorBuckets);
$intReceivedCount = (int)($arrSummary['received_count'] ?? 0);
$intFailedCount = (int)($arrSummary['failed_count'] ?? 0);
$fltSuccessRate = $intReceivedCount > 0 ? round((($intReceivedCount - $intFailedCount) / $intReceivedCount) * 100, 2) : 0.0;
[$strAlertLevel, $strAlertReason] = self::buildAlertMeta($intReceivedCount, $intFailedCount, $arrErrorBuckets);
$arrItems[] = [
'run_id' => basename($strRunRoot),
'run_root_public' => self::storageRelativePath($strRunRoot),
'source' => (string)($arrSummary['source'] ?? ''),
'received_count' => $intReceivedCount,
'inserted_count' => (int)($arrSummary['inserted_count'] ?? 0),
'updated_count' => (int)($arrSummary['updated_count'] ?? 0),
'failed_count' => $intFailedCount,
'success_rate' => $fltSuccessRate,
'alert_level' => $strAlertLevel,
'alert_reason' => $strAlertReason,
'inserted_items' => (array)($arrSummary['inserted_items'] ?? []),
'updated_items' => (array)($arrSummary['updated_items'] ?? []),
'errors' => $arrErrors,
'error_buckets' => array_map(static function (string $strMessage, int $intCount): array {
return [
'message' => $strMessage,
'count' => $intCount,
];
}, array_keys($arrErrorBuckets), array_values($arrErrorBuckets)),
'post_ingest_overview' => (array)($arrSummary['post_ingest_overview'] ?? []),
'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::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())),
];
}
$arrAlertBuckets = [
'high' => 0,
'medium' => 0,
'low' => 0,
'none' => 0,
];
foreach ($arrItems as $arrItem) {
$strLevel = (string)($arrItem['alert_level'] ?? 'none');
if (!array_key_exists($strLevel, $arrAlertBuckets)) {
$strLevel = 'none';
}
$arrAlertBuckets[$strLevel]++;
}
return [
'items' => $arrItems,
'total' => count($arrItems),
'latest_run' => $arrItems[0] ?? [],
'alert_buckets' => $arrAlertBuckets,
'top_attention_runs' => array_values(array_filter($arrItems, static function (array $arrItem): bool {
return in_array((string)($arrItem['alert_level'] ?? 'none'), ['high', 'medium'], true);
})),
];
}
protected static function buildAlertMeta(int $intReceivedCount, int $intFailedCount, array $arrErrorBuckets): array
{
if ($intReceivedCount <= 0) {
return ['medium', '未接收到任何快照'];
}
if ($intFailedCount >= $intReceivedCount) {
return ['high', '本批回推全部失败'];
}
if ($intFailedCount > 0) {
$strTopError = (string)(array_key_first($arrErrorBuckets) ?? '存在失败记录');
return ['medium', $strTopError];
}
return ['none', 'success'];
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoSnapshotSchemaHelper
{
public static function buildSchema(): array
{
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_external_snapshot_schema',
'common_required' => ['host', 'provider', 'snapshot_type', 'scope', 'metric_date'],
'scope_enum' => ['host', 'keyword'],
'providers' => [
'baidu_site' => [
'snapshot_types' => [
'site_query' => [
'scope' => 'host',
'required' => ['host', 'provider', 'snapshot_type', 'scope', 'metric_date'],
'recommended' => ['queried_at', 'status', 'indexed_status', 'result_count_text', 'payload'],
],
],
],
'aizhan' => [
'snapshot_types' => [
'aizhan_summary' => [
'scope' => 'host',
'required' => ['host', 'provider', 'snapshot_type', 'scope', 'metric_date'],
'recommended' => ['queried_at', 'status', 'baidu_pc_ip_range', 'baidu_mobile_ip_range', 'pc_keyword_count', 'mobile_keyword_count', 'payload'],
],
'aizhan_keyword' => [
'scope' => 'keyword',
'required' => ['host', 'provider', 'snapshot_type', 'scope', 'keyword', 'metric_date'],
'recommended' => ['queried_at', 'device', 'rank_value', 'search_volume', 'payload'],
],
],
],
],
'note' => '建议采集端先调 validate 做 dry-run再调 snapshots/save 正式入库。',
];
}
public static function buildChecklist(): array
{
$arrSchema = self::buildSchema();
$arrRows = [];
foreach ((array)($arrSchema['providers'] ?? []) as $strProvider => $arrProviderMeta) {
foreach ((array)($arrProviderMeta['snapshot_types'] ?? []) as $strSnapshotType => $arrTypeMeta) {
$arrRows[] = [
'provider' => $strProvider,
'snapshot_type' => $strSnapshotType,
'scope' => (string)($arrTypeMeta['scope'] ?? 'host'),
'required' => array_values((array)($arrTypeMeta['required'] ?? [])),
'recommended' => array_values((array)($arrTypeMeta['recommended'] ?? [])),
];
}
}
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_external_snapshot_checklist',
'common_required' => (array)($arrSchema['common_required'] ?? []),
'rows' => $arrRows,
'note' => '这是一张给采集端直接照着实现的字段校验清单。推荐先看 checklist再配合 validate 做 dry-run。',
];
}
public static function validateSnapshots(array $arrSnapshots): array
{
$arrSchema = self::buildSchema();
$arrProviders = (array)($arrSchema['providers'] ?? []);
$arrErrors = [];
$arrWarnings = [];
$arrValidItems = [];
foreach ($arrSnapshots as $intIndex => $arrItem) {
if (!is_array($arrItem)) {
$arrErrors[] = ['index' => $intIndex, 'message' => 'snapshot item must be array'];
continue;
}
$strProvider = trim((string)($arrItem['provider'] ?? ''));
$strSnapshotType = trim((string)($arrItem['snapshot_type'] ?? ''));
$strScope = trim((string)($arrItem['scope'] ?? ''));
$arrRequired = (array)($arrSchema['common_required'] ?? []);
$arrRecommended = [];
if ($strProvider !== '' && $strSnapshotType !== '') {
$arrTypeMeta = (array)(((array)(($arrProviders[$strProvider] ?? [])['snapshot_types'] ?? []))[$strSnapshotType] ?? []);
if (!empty($arrTypeMeta['required'])) {
$arrRequired = array_values(array_unique(array_merge($arrRequired, (array)$arrTypeMeta['required'])));
}
$arrRecommended = (array)($arrTypeMeta['recommended'] ?? []);
if (!empty($arrTypeMeta['scope']) && $strScope !== '' && $strScope !== (string)$arrTypeMeta['scope']) {
$arrErrors[] = ['index' => $intIndex, 'message' => 'scope mismatch, expected ' . (string)$arrTypeMeta['scope']];
continue;
}
}
$arrMissing = [];
foreach ($arrRequired as $strField) {
$mValue = $arrItem[$strField] ?? null;
$boolEmpty = $mValue === null
|| (is_string($mValue) && trim($mValue) === '')
|| (is_array($mValue) && empty($mValue));
if ($boolEmpty) {
$arrMissing[] = $strField;
}
}
if (!empty($arrMissing)) {
$arrErrors[] = ['index' => $intIndex, 'message' => 'missing required fields: ' . implode(', ', $arrMissing)];
continue;
}
if (!in_array($strScope, ['host', 'keyword'], true)) {
$arrErrors[] = ['index' => $intIndex, 'message' => 'scope must be host or keyword'];
continue;
}
$strMetricDate = trim((string)($arrItem['metric_date'] ?? ''));
if ($strMetricDate !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $strMetricDate)) {
$arrErrors[] = ['index' => $intIndex, 'message' => 'metric_date must be YYYY-MM-DD'];
continue;
}
$arrMissingRecommended = [];
foreach ($arrRecommended as $strField) {
$mValue = $arrItem[$strField] ?? null;
$boolEmpty = $mValue === null
|| (is_string($mValue) && trim($mValue) === '')
|| (is_array($mValue) && empty($mValue));
if ($boolEmpty) {
$arrMissingRecommended[] = $strField;
}
}
if (!empty($arrMissingRecommended)) {
$arrWarnings[] = ['index' => $intIndex, 'message' => 'missing recommended fields: ' . implode(', ', $arrMissingRecommended)];
}
$arrValidItems[] = [
'index' => $intIndex,
'host' => (string)($arrItem['host'] ?? ''),
'provider' => $strProvider,
'snapshot_type' => $strSnapshotType,
'scope' => $strScope,
];
}
$arrErrorBuckets = self::buildBuckets($arrErrors);
$arrWarningBuckets = self::buildBuckets($arrWarnings);
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_external_snapshot_validate',
'received_count' => count($arrSnapshots),
'valid_count' => count($arrValidItems),
'error_count' => count($arrErrors),
'warning_count' => count($arrWarnings),
'passed' => empty($arrErrors),
'valid_items' => $arrValidItems,
'errors' => $arrErrors,
'warnings' => $arrWarnings,
'error_buckets' => $arrErrorBuckets,
'warning_buckets' => $arrWarningBuckets,
'note' => 'validate 只做字段级 dry-run不写入快照表。',
];
}
protected static function buildBuckets(array $arrItems): array
{
$arrBuckets = [];
foreach ($arrItems as $arrItem) {
$strMessage = trim((string)($arrItem['message'] ?? 'unknown_error'));
if ($strMessage === '') {
$strMessage = 'unknown_error';
}
$arrBuckets[$strMessage] = (int)($arrBuckets[$strMessage] ?? 0) + 1;
}
arsort($arrBuckets);
return array_map(static function (string $strMessage, int $intCount): array {
return [
'message' => $strMessage,
'count' => $intCount,
];
}, array_keys($arrBuckets), array_values($arrBuckets));
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoSnapshotTrendHelper
{
public static function buildSummary(string $strRunRoot, int $intLimit = 10): array
{
$intLimit = max(1, min(50, $intLimit));
$arrRunSummary = DomainExternalSeoSnapshotRunIndexHelper::buildSummary($strRunRoot, $intLimit);
$arrItems = (array)($arrRunSummary['items'] ?? []);
$arrTrend = array_map(static function (array $arrItem): array {
$intReceived = (int)($arrItem['received_count'] ?? 0);
$intFailed = (int)($arrItem['failed_count'] ?? 0);
$fltSuccessRate = $intReceived > 0 ? round((($intReceived - $intFailed) / $intReceived) * 100, 2) : 0.0;
return [
'run_id' => (string)($arrItem['run_id'] ?? ''),
'source' => (string)($arrItem['source'] ?? ''),
'received_count' => $intReceived,
'inserted_count' => (int)($arrItem['inserted_count'] ?? 0),
'updated_count' => (int)($arrItem['updated_count'] ?? 0),
'failed_count' => $intFailed,
'success_rate' => $fltSuccessRate,
'updated_at' => (string)($arrItem['updated_at'] ?? ''),
];
}, array_slice($arrItems, 0, $intLimit));
$arrLatest = (array)($arrRunSummary['latest_run'] ?? []);
$arrPrevious = (array)($arrItems[1] ?? $arrLatest);
$intLatestFailed = (int)($arrLatest['failed_count'] ?? 0);
$intPreviousFailed = (int)($arrPrevious['failed_count'] ?? $intLatestFailed);
$intLatestReceived = (int)($arrLatest['received_count'] ?? 0);
$intPreviousReceived = (int)($arrPrevious['received_count'] ?? $intLatestReceived);
$fltLatestSuccessRate = $intLatestReceived > 0 ? round((($intLatestReceived - $intLatestFailed) / $intLatestReceived) * 100, 2) : 0.0;
$fltPreviousSuccessRate = $intPreviousReceived > 0 ? round((($intPreviousReceived - $intPreviousFailed) / $intPreviousReceived) * 100, 2) : $fltLatestSuccessRate;
$strLabel = 'flat';
if ($fltLatestSuccessRate > $fltPreviousSuccessRate) {
$strLabel = 'improving';
} elseif ($fltLatestSuccessRate < $fltPreviousSuccessRate) {
$strLabel = 'worsening';
}
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'seo_external_snapshot_trend',
'runs_count' => (int)($arrRunSummary['total'] ?? 0),
'latest_run' => $arrLatest,
'recent_runs' => $arrTrend,
'success_trend' => [
'latest_success_rate' => $fltLatestSuccessRate,
'previous_success_rate' => $fltPreviousSuccessRate,
'latest_failed_count' => $intLatestFailed,
'previous_failed_count' => $intPreviousFailed,
'label' => $strLabel,
],
'note' => '站外快照接收趋势会按最近几次 snapshots/save 的接收结果汇总成功率变化,适合看 Node 回推是否逐步稳定。',
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/seo-external-snapshot-trend.summary.json';
$strHtmlPath = $strOutputRoot . '/seo-external-snapshot-trend.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['recent_runs'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['run_id'] ?? '')) . '</td>'
. '<td>' . self::escape((string)($arrItem['source'] ?? '')) . '</td>'
. '<td>' . (int)($arrItem['received_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['inserted_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['updated_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['failed_count'] ?? 0) . '</td>'
. '<td>' . self::escape((string)($arrItem['success_rate'] ?? '0')) . '</td>'
. '<td>' . self::escape((string)($arrItem['updated_at'] ?? '')) . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>SEO External Snapshot Trend</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>SEO External Snapshot Trend</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<p>generated_at: <code>' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '</code></p>'
. '<div class="grid">'
. '<div class="card"><strong>runs_count</strong><br>' . (int)($arrSummary['runs_count'] ?? 0) . '</div>'
. '<div class="card"><strong>latest_success_rate</strong><br>' . self::escape((string)(($arrSummary['success_trend'] ?? [])['latest_success_rate'] ?? '0')) . '</div>'
. '<div class="card"><strong>previous_success_rate</strong><br>' . self::escape((string)(($arrSummary['success_trend'] ?? [])['previous_success_rate'] ?? '0')) . '</div>'
. '<div class="card"><strong>trend</strong><br>' . self::escape((string)(($arrSummary['success_trend'] ?? [])['label'] ?? 'flat')) . '</div>'
. '</div>'
. '<table><thead><tr><th>run_id</th><th>source</th><th>received</th><th>inserted</th><th>updated</th><th>failed</th><th>success_rate</th><th>updated_at</th></tr></thead><tbody>' . implode('', $arrRows) . '</tbody></table>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoSnapshotValidateRunHelper
{
protected static function publicRelativePath(string $strRoot, string $strPath): string
{
$strRoot = str_replace('\\', '/', rtrim($strRoot, '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strRoot . '/')) {
return substr($strPath, strlen($strRoot . '/'));
}
return $strPath;
}
public static function createRunRoot(string $strRoot, string $strPrefix = 'snapshot_validate'): string
{
$strRoot = rtrim($strRoot, '/');
$strDate = date('Ymd');
$strRunId = date('His') . '_' . $strPrefix . '_' . substr(sha1(uniqid('', true)), 0, 6);
$strRunRoot = $strRoot . '/' . $strDate . '/' . $strRunId;
if (!is_dir($strRunRoot) && !mkdir($strRunRoot, 0777, true) && !is_dir($strRunRoot)) {
throw new \RuntimeException('create validate run root failed: ' . $strRunRoot);
}
return $strRunRoot;
}
public static function writeRunArtifacts(string $strPublicRoot, string $strRunRoot, array $arrSummary, array $arrSnapshots): array
{
$strRunRoot = rtrim($strRunRoot, '/');
if (!is_dir($strRunRoot) && !mkdir($strRunRoot, 0777, true) && !is_dir($strRunRoot)) {
throw new \RuntimeException('validate run root missing: ' . $strRunRoot);
}
$strPayloadPath = $strRunRoot . '/snapshots.payload.json';
file_put_contents($strPayloadPath, json_encode([
'generated_at' => date(DATE_ATOM),
'snapshots' => array_values($arrSnapshots),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$strJsonPath = $strRunRoot . '/snapshot-validate.summary.json';
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$strHtmlPath = $strRunRoot . '/snapshot-validate.summary.html';
$strHtml = '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>SEO 外部快照字段校验摘要</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;color:#303133}table{border-collapse:collapse;width:100%;margin-top:16px}th,td{border:1px solid #ebeef5;padding:8px;text-align:left}th{background:#f5f7fa}.meta{display:flex;gap:16px;flex-wrap:wrap;margin:12px 0}.card{border:1px solid #ebeef5;border-radius:8px;padding:12px;background:#fafafa}</style>'
. '</head><body>'
. '<h1>SEO 外部快照字段校验摘要</h1>'
. '<div class="meta">'
. '<div class="card">received: ' . (int)($arrSummary['received_count'] ?? 0) . '</div>'
. '<div class="card">valid: ' . (int)($arrSummary['valid_count'] ?? 0) . '</div>'
. '<div class="card">errors: ' . (int)($arrSummary['error_count'] ?? 0) . '</div>'
. '<div class="card">warnings: ' . (int)($arrSummary['warning_count'] ?? 0) . '</div>'
. '<div class="card">passed: ' . (!empty($arrSummary['passed']) ? 'yes' : 'no') . '</div>'
. '</div>';
if (!empty($arrSummary['error_buckets'])) {
$strHtml .= '<h2>Error Buckets</h2><table><thead><tr><th>Message</th><th>Count</th></tr></thead><tbody>';
foreach ((array)$arrSummary['error_buckets'] as $arrBucket) {
$strHtml .= '<tr><td>' . htmlspecialchars((string)($arrBucket['message'] ?? '')) . '</td><td>' . (int)($arrBucket['count'] ?? 0) . '</td></tr>';
}
$strHtml .= '</tbody></table>';
}
if (!empty($arrSummary['warning_buckets'])) {
$strHtml .= '<h2>Warning Buckets</h2><table><thead><tr><th>Message</th><th>Count</th></tr></thead><tbody>';
foreach ((array)$arrSummary['warning_buckets'] as $arrBucket) {
$strHtml .= '<tr><td>' . htmlspecialchars((string)($arrBucket['message'] ?? '')) . '</td><td>' . (int)($arrBucket['count'] ?? 0) . '</td></tr>';
}
$strHtml .= '</tbody></table>';
}
$strHtml .= '</body></html>';
file_put_contents($strHtmlPath, $strHtml);
$arrSummary['payload_json_path'] = self::publicRelativePath($strPublicRoot, $strPayloadPath);
$arrSummary['summary_json_path'] = self::publicRelativePath($strPublicRoot, $strJsonPath);
$arrSummary['summary_html_path'] = self::publicRelativePath($strPublicRoot, $strHtmlPath);
$arrSummary['run_root_public'] = self::publicRelativePath($strPublicRoot, $strRunRoot);
return $arrSummary;
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoSnapshotValidateRunIndexHelper
{
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;
}
public static function buildSummary(string $strRoot, int $intLimit = 20): array
{
$strRoot = rtrim($strRoot, '/');
$intLimit = max(1, min(100, $intLimit));
if ($strRoot === '' || !is_dir($strRoot)) {
return [
'items' => [],
'total' => 0,
'latest_run' => [],
];
}
$arrSummaryFiles = array_merge(
(array)glob($strRoot . '/*/snapshot-validate.summary.json'),
(array)glob($strRoot . '/*/*/snapshot-validate.summary.json')
);
$arrSummaryFiles = array_values(array_filter(array_unique($arrSummaryFiles), 'is_file'));
usort($arrSummaryFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
$arrItems = [];
foreach (array_slice($arrSummaryFiles, 0, $intLimit) as $strSummaryPath) {
$arrDecoded = json_decode((string)file_get_contents($strSummaryPath), true);
$arrSummary = is_array($arrDecoded) ? $arrDecoded : [];
$strRunRoot = dirname($strSummaryPath);
$intReceivedCount = (int)($arrSummary['received_count'] ?? 0);
$intErrorCount = (int)($arrSummary['error_count'] ?? 0);
$intWarningCount = (int)($arrSummary['warning_count'] ?? 0);
[$strAlertLevel, $strAlertReason] = self::buildAlertMeta($intReceivedCount, $intErrorCount, $intWarningCount, (array)($arrSummary['error_buckets'] ?? []));
$arrItems[] = [
'run_id' => basename($strRunRoot),
'run_root_public' => self::storageRelativePath($strRunRoot),
'received_count' => $intReceivedCount,
'valid_count' => (int)($arrSummary['valid_count'] ?? 0),
'error_count' => $intErrorCount,
'warning_count' => $intWarningCount,
'passed' => !empty($arrSummary['passed']),
'success_rate' => $intReceivedCount > 0 ? round((($intReceivedCount - $intErrorCount) / $intReceivedCount) * 100, 2) : 0.0,
'alert_level' => $strAlertLevel,
'alert_reason' => $strAlertReason,
'errors' => (array)($arrSummary['errors'] ?? []),
'warnings' => (array)($arrSummary['warnings'] ?? []),
'error_buckets' => (array)($arrSummary['error_buckets'] ?? []),
'warning_buckets' => (array)($arrSummary['warning_buckets'] ?? []),
'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())),
];
}
$arrAlertBuckets = [
'high' => 0,
'medium' => 0,
'low' => 0,
'none' => 0,
];
foreach ($arrItems as $arrItem) {
$strLevel = (string)($arrItem['alert_level'] ?? 'none');
if (!array_key_exists($strLevel, $arrAlertBuckets)) {
$strLevel = 'none';
}
$arrAlertBuckets[$strLevel]++;
}
return [
'items' => $arrItems,
'total' => count($arrItems),
'latest_run' => $arrItems[0] ?? [],
'alert_buckets' => $arrAlertBuckets,
'top_attention_runs' => array_values(array_filter($arrItems, static function (array $arrItem): bool {
return in_array((string)($arrItem['alert_level'] ?? 'none'), ['high', 'medium'], true);
})),
];
}
protected static function buildAlertMeta(int $intReceivedCount, int $intErrorCount, int $intWarningCount, array $arrErrorBuckets): array
{
if ($intReceivedCount <= 0) {
return ['medium', '未接收到任何待校验快照'];
}
if ($intErrorCount >= $intReceivedCount) {
return ['high', '本批 dry-run 全部未通过'];
}
if ($intErrorCount > 0) {
$strTopError = (string)(($arrErrorBuckets[0]['message'] ?? '') ?: '存在字段错误');
return ['medium', $strTopError];
}
if ($intWarningCount > 0) {
return ['low', '存在推荐字段缺失'];
}
return ['none', 'success'];
}
}

View File

@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoSummaryHelper
{
public static function buildSummary(string $strImportRunRoot, string $strRerunRoot, int $intLimit = 20): array
{
$arrImportIndex = DomainImportRunIndexHelper::buildSummary($strImportRunRoot, $intLimit);
$arrQueueSummary = DomainImportFailedQueueHelper::buildSummary($strImportRunRoot, 100);
$arrRerunIndex = DomainImportRerunIndexHelper::buildSummary($strRerunRoot, $intLimit);
$arrHealthTrend = DomainImportHealthTrendHelper::buildSummary($strImportRunRoot, $strRerunRoot, min(10, $intLimit));
$arrLatestImport = (array)($arrImportIndex['latest_run'] ?? []);
$arrLatestImportData = self::readSummaryJson(self::resolvePublicPath((string)($arrLatestImport['summary_json_path'] ?? '')));
$arrLatestItems = array_values(array_filter((array)($arrLatestImportData['items'] ?? []), 'is_array'));
$arrLatestRerunItems = self::collectLatestRerunItems($strRerunRoot);
$arrQueueItems = array_values(array_filter((array)($arrQueueSummary['items'] ?? []), 'is_array'));
$arrQueueByHost = [];
foreach ($arrQueueItems as $arrQueueItem) {
$strHost = trim((string)($arrQueueItem['host'] ?? ''));
if ($strHost !== '') {
$arrQueueByHost[$strHost] = $arrQueueItem;
}
}
$arrHostRows = [];
$arrKeywordBuckets = [];
$intReadyCount = 0;
$intAttentionCount = 0;
$intProbeFailedCount = 0;
$arrHostBase = [];
foreach ($arrLatestItems as $arrItem) {
$strHost = trim((string)($arrItem['host'] ?? ''));
if ($strHost !== '') {
$arrHostBase[$strHost] = $arrItem;
}
}
foreach ($arrLatestRerunItems as $strHost => $arrItem) {
if (!isset($arrHostBase[$strHost])) {
$arrHostBase[$strHost] = $arrItem;
}
}
foreach ($arrHostBase as $strHost => $arrItem) {
$arrEffectiveItem = (array)($arrLatestRerunItems[$strHost] ?? $arrItem);
$arrQueueItem = (array)($arrQueueByHost[$strHost] ?? []);
$strSearchKeyword = trim((string)(($arrEffectiveItem['sample']['search']['search_keyword'] ?? '') ?: ($arrEffectiveItem['sample_result']['sample']['search']['search_keyword'] ?? '')));
$strSampleSource = trim((string)(($arrEffectiveItem['sample_result']['sample_source'] ?? '') ?: (!empty($arrLatestRerunItems[$strHost]) ? 'rerun_recovered' : 'import_probe')));
$boolAllPassed = !empty($arrEffectiveItem['all_passed']);
$strReadiness = 'probe_failed';
if (!empty($arrQueueItem)) {
$strReadiness = 'attention';
$intAttentionCount++;
} elseif ($boolAllPassed) {
$strReadiness = 'ready';
$intReadyCount++;
} else {
$intProbeFailedCount++;
}
if ($strSearchKeyword !== '') {
$arrKeywordBuckets[$strSearchKeyword] = ($arrKeywordBuckets[$strSearchKeyword] ?? 0) + 1;
}
$arrHostRows[] = [
'host' => $strHost,
'probe_status' => (string)($arrEffectiveItem['status'] ?? ''),
'external_readiness' => $strReadiness,
'all_passed' => $boolAllPassed,
'failed_stage' => (string)(!empty($arrQueueItem['failed_stage']) ? $arrQueueItem['failed_stage'] : ($arrEffectiveItem['failed_stage'] ?? '')),
'search_keyword' => $strSearchKeyword,
'detail_id' => (int)(($arrEffectiveItem['sample']['detail']['detail_id'] ?? 0) ?: ($arrEffectiveItem['sample_result']['sample']['detail']['detail_id'] ?? 0)),
'detail_slug' => (string)(($arrEffectiveItem['sample']['detail']['detail_slug'] ?? '') ?: ($arrEffectiveItem['sample_result']['sample']['detail']['detail_slug'] ?? '')),
'sample_source' => $strSampleSource,
'probed_at' => (string)($arrEffectiveItem['probed_at'] ?? ''),
'summary_html_path' => (string)($arrLatestImport['summary_html_path'] ?? ''),
'summary_json_path' => (string)($arrLatestImport['summary_json_path'] ?? ''),
'effective_run_type' => !empty($arrLatestRerunItems[$strHost]) ? 'rerun' : 'import_probe',
];
}
arsort($arrKeywordBuckets);
$arrTopKeywords = [];
foreach (array_slice($arrKeywordBuckets, 0, 10, true) as $strKeyword => $intCount) {
$arrTopKeywords[] = [
'keyword' => $strKeyword,
'host_count' => $intCount,
];
}
$intHostCount = count($arrHostRows);
$intTrackedKeywordCount = count($arrKeywordBuckets);
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'proxy_readiness',
'note' => '当前摘要优先展示最新的导入探测与重跑结果,用来判断这批域名是否已经具备站外 SEO 起量前的可访问基础。这里不是搜索引擎最终收录/点击结果,而是上线前准备态。',
'real_external_metrics' => false,
'host_count' => $intHostCount,
'ready_count' => $intReadyCount,
'attention_count' => $intAttentionCount,
'probe_failed_count' => $intProbeFailedCount,
'tracked_keyword_count' => $intTrackedKeywordCount,
'latest_import_run' => $arrLatestImport,
'latest_rerun_run' => $arrRerunIndex['latest_run'] ?? [],
'queue_count' => (int)($arrQueueSummary['queue_count'] ?? 0),
'rerun_runs_count' => (int)($arrRerunIndex['total'] ?? 0),
'failure_trend' => (array)($arrHealthTrend['failure_trend'] ?? []),
'hero_summary' => self::buildHeroSummary($intHostCount, $intReadyCount, $intAttentionCount, $intProbeFailedCount, (int)($arrQueueSummary['queue_count'] ?? 0)),
'top_keywords' => $arrTopKeywords,
'hosts' => $arrHostRows,
'source_summary' => [
'import_runs_count' => (int)($arrImportIndex['total'] ?? 0),
'rerun_runs_count' => (int)($arrRerunIndex['total'] ?? 0),
'queue_count' => (int)($arrQueueSummary['queue_count'] ?? 0),
],
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/seo-external.summary.json';
$strHtmlPath = $strOutputRoot . '/seo-external.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['hosts'] ?? []) as $arrHost) {
$arrRows[] = '<tr>'
. '<td>' . self::escape((string)($arrHost['host'] ?? '')) . '</td>'
. '<td>' . self::escape(self::translateReadiness((string)($arrHost['external_readiness'] ?? ''))) . '</td>'
. '<td>' . self::escape(DomainImportReportViewHelper::translateStatus((string)($arrHost['probe_status'] ?? ''))) . '</td>'
. '<td>' . self::escape(DomainImportReportViewHelper::translateStage((string)($arrHost['failed_stage'] ?? ''))) . '</td>'
. '<td>' . self::escape((string)($arrHost['search_keyword'] ?? '')) . '</td>'
. '<td>' . self::escape(self::translateSampleSource((string)($arrHost['sample_source'] ?? ''))) . '</td>'
. '<td>' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrHost['probed_at'] ?? ''))) . '</td>'
. '</tr>';
}
$arrKeywordRows = [];
foreach ((array)($arrSummary['top_keywords'] ?? []) as $arrKeyword) {
$arrKeywordRows[] = '<tr>'
. '<td>' . self::escape((string)($arrKeyword['keyword'] ?? '')) . '</td>'
. '<td>' . (int)($arrKeyword['host_count'] ?? 0) . '</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;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>站外效果摘要</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<p>生成时间:<code>' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? ''))) . '</code></p>'
. '<p>摘要说明:' . self::escape((string)($arrSummary['hero_summary'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>域名数</strong><br>' . (int)($arrSummary['host_count'] ?? 0) . '</div>'
. '<div class="card"><strong>已恢复</strong><br>' . (int)($arrSummary['ready_count'] ?? 0) . '</div>'
. '<div class="card"><strong>待关注</strong><br>' . (int)($arrSummary['attention_count'] ?? 0) . '</div>'
. '<div class="card"><strong>关键词样本</strong><br>' . (int)($arrSummary['tracked_keyword_count'] ?? 0) . '</div>'
. '</div>'
. '<p>趋势:<code>' . self::escape(DomainImportReportViewHelper::translateTrendLabel((string)(($arrSummary['failure_trend'] ?? [])['label'] ?? ''))) . '</code></p>'
. '<h2>重点关键词样本</h2>'
. '<table><thead><tr><th>关键词</th><th>关联域名数</th></tr></thead><tbody>' . implode('', $arrKeywordRows) . '</tbody></table>'
. '<h2>域名明细</h2>'
. '<table><thead><tr><th>域名</th><th>当前状态</th><th>探测结果</th><th>失败阶段</th><th>搜索词样本</th><th>样本来源</th><th>最近探测时间</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table>'
. '</body></html>';
}
protected static function collectLatestRerunItems(string $strRerunRoot): array
{
$strRerunRoot = rtrim($strRerunRoot, '/');
if ($strRerunRoot === '' || !is_dir($strRerunRoot)) {
return [];
}
$arrSummaryFiles = array_merge(
(array)glob($strRerunRoot . '/*/import-rerun.summary.json'),
(array)glob($strRerunRoot . '/*/*/import-rerun.summary.json')
);
$arrSummaryFiles = array_values(array_filter(array_unique($arrSummaryFiles), 'is_file'));
usort($arrSummaryFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
$arrLatestByHost = [];
foreach ($arrSummaryFiles as $strSummaryPath) {
$arrData = self::readSummaryJson($strSummaryPath);
foreach ((array)($arrData['items'] ?? []) as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$strHost = trim((string)($arrItem['host'] ?? ''));
if ($strHost === '' || isset($arrLatestByHost[$strHost])) {
continue;
}
$arrLatestByHost[$strHost] = $arrItem;
}
}
return $arrLatestByHost;
}
protected static function buildHeroSummary(int $intHostCount, int $intReadyCount, int $intAttentionCount, int $intProbeFailedCount, int $intQueueCount): string
{
if ($intHostCount <= 0) {
return '当前还没有站外效果摘要数据。';
}
if ($intQueueCount <= 0 && $intProbeFailedCount <= 0) {
return '当前这批域名已全部通过探测,可继续观察真实收录与关键词结果。';
}
if ($intReadyCount > 0 && $intAttentionCount > 0) {
return '当前已有 ' . $intReadyCount . ' 个域名恢复正常,还剩 ' . $intAttentionCount . ' 个待继续关注。';
}
if ($intReadyCount > 0) {
return '当前已有 ' . $intReadyCount . ' 个域名恢复正常,剩余失败项主要集中在尾部问题。';
}
return '当前这批域名仍以探测恢复为主,建议继续先清失败队列,再看真实 SEO 结果。';
}
protected static function translateReadiness(string $strReadiness): string
{
$arrMap = [
'ready' => '已恢复',
'attention' => '待关注',
'probe_failed' => '探测失败',
];
return $arrMap[$strReadiness] ?? $strReadiness;
}
protected static function translateSampleSource(string $strSampleSource): string
{
$arrMap = [
'rerun_recovered' => '重跑结果',
'import_probe' => '首次导入探测',
'runtime_acceptance' => '运行时准入样本',
'detail_probe' => '详情页样本',
];
return $arrMap[$strSampleSource] ?? ($strSampleSource === '' ? '-' : $strSampleSource);
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function readSummaryJson(string $strPath): array
{
if ($strPath === '' || !is_file($strPath)) {
return [];
}
$arrData = json_decode((string)file_get_contents($strPath), true);
return is_array($arrData) ? $arrData : [];
}
protected static function resolvePublicPath(string $strPath): string
{
$strPath = trim($strPath);
if ($strPath === '') {
return '';
}
if (is_file($strPath)) {
return $strPath;
}
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$strPath = str_replace('\\', '/', ltrim($strPath, '/'));
$strCandidate = $strPublicRoot . '/' . $strPath;
return is_file($strCandidate) ? $strCandidate : '';
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainExternalSeoTrendHelper
{
public static function buildSummary(string $strImportRunRoot, string $strRerunRoot, int $intLimit = 10): array
{
$arrExternalSummary = DomainExternalSeoSummaryHelper::buildSummary($strImportRunRoot, $strRerunRoot, max(20, $intLimit));
$arrFailedQueue = DomainExternalSeoFailedQueueHelper::buildSummary($strImportRunRoot, $strRerunRoot, max(20, $intLimit));
$arrHealthTrend = DomainImportHealthTrendHelper::buildSummary($strImportRunRoot, $strRerunRoot, $intLimit);
$arrRerunSeries = [];
foreach ((array)($arrHealthTrend['recent_reruns'] ?? []) as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$arrRerunSeries[] = [
'run_id' => (string)($arrItem['run_id'] ?? ''),
'passed_count' => (int)($arrItem['passed_count'] ?? 0),
'failed_count' => (int)($arrItem['failed_count'] ?? 0),
'updated_at' => (string)($arrItem['updated_at'] ?? ''),
];
}
$arrImportSeries = [];
foreach ((array)($arrHealthTrend['recent_import_runs'] ?? []) as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$arrImportSeries[] = [
'run_id' => (string)($arrItem['run_id'] ?? ''),
'processed_count' => (int)($arrItem['processed_count'] ?? 0),
'passed_count' => (int)($arrItem['passed_count'] ?? 0),
'failed_count' => (int)($arrItem['failed_count'] ?? 0),
'updated_at' => (string)($arrItem['updated_at'] ?? ''),
];
}
return [
'generated_at' => date(DATE_ATOM),
'mode' => 'proxy_readiness_trend',
'note' => '当前趋势优先展示这批域名的可访问恢复情况,用来判断站外 SEO 起量前的准备态是否稳定。它不是最终展现/点击趋势,而是上线前恢复趋势。',
'real_external_metrics' => false,
'host_count' => (int)($arrExternalSummary['host_count'] ?? 0),
'ready_count' => (int)($arrExternalSummary['ready_count'] ?? 0),
'attention_count' => (int)($arrExternalSummary['attention_count'] ?? 0),
'probe_failed_count' => (int)($arrExternalSummary['probe_failed_count'] ?? 0),
'external_queue_count' => (int)($arrFailedQueue['queue_count'] ?? 0),
'tracked_keyword_count' => (int)($arrExternalSummary['tracked_keyword_count'] ?? 0),
'hero_summary' => (string)($arrExternalSummary['hero_summary'] ?? ''),
'proxy_signal_trend' => [
'label' => (string)(($arrHealthTrend['failure_trend'] ?? [])['label'] ?? 'flat'),
'direction' => (int)(($arrHealthTrend['failure_trend'] ?? [])['direction'] ?? 0),
'latest_failed_count' => (int)(($arrHealthTrend['failure_trend'] ?? [])['latest_failed_count'] ?? 0),
'previous_failed_count' => (int)(($arrHealthTrend['failure_trend'] ?? [])['previous_failed_count'] ?? 0),
],
'recent_reruns' => $arrRerunSeries,
'recent_import_runs' => $arrImportSeries,
'latest_import_run' => (array)($arrExternalSummary['latest_import_run'] ?? []),
'latest_rerun_run' => (array)($arrExternalSummary['latest_rerun_run'] ?? []),
'top_keywords' => array_slice((array)($arrExternalSummary['top_keywords'] ?? []), 0, 10),
];
}
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
{
$strOutputRoot = rtrim($strOutputRoot, '/');
self::ensureDir($strOutputRoot);
$strJsonPath = $strOutputRoot . '/seo-external-trend.summary.json';
$strHtmlPath = $strOutputRoot . '/seo-external-trend.summary.html';
$arrSummary['summary_json_path'] = $strJsonPath;
$arrSummary['summary_html_path'] = $strHtmlPath;
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strHtmlPath, self::renderHtml($arrSummary));
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrRerunRows = [];
foreach ((array)($arrSummary['recent_reruns'] ?? []) as $arrItem) {
$arrRerunRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['run_id'] ?? '')) . '</td>'
. '<td>' . (int)($arrItem['passed_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['failed_count'] ?? 0) . '</td>'
. '<td>' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrItem['updated_at'] ?? ''))) . '</td>'
. '</tr>';
}
$arrImportRows = [];
foreach ((array)($arrSummary['recent_import_runs'] ?? []) as $arrItem) {
$arrImportRows[] = '<tr>'
. '<td>' . self::escape((string)($arrItem['run_id'] ?? '')) . '</td>'
. '<td>' . (int)($arrItem['processed_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['passed_count'] ?? 0) . '</td>'
. '<td>' . (int)($arrItem['failed_count'] ?? 0) . '</td>'
. '<td>' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrItem['updated_at'] ?? ''))) . '</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;line-height:1.6;}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;} .grid{display:grid;grid-template-columns:repeat(5,minmax(120px,1fr));gap:12px;margin:16px 0;} .card{border:1px solid #eee;border-radius:8px;padding:12px;background:#fafafa;} .muted{color:#666;}</style>'
. '</head><body>'
. '<h1>站外效果趋势</h1>'
. '<p class="muted">' . self::escape((string)($arrSummary['note'] ?? '')) . '</p>'
. '<p>生成时间:<code>' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? ''))) . '</code></p>'
. '<p>趋势说明:' . self::escape((string)($arrSummary['hero_summary'] ?? '')) . '</p>'
. '<div class="grid">'
. '<div class="card"><strong>域名数</strong><br>' . (int)($arrSummary['host_count'] ?? 0) . '</div>'
. '<div class="card"><strong>已恢复</strong><br>' . (int)($arrSummary['ready_count'] ?? 0) . '</div>'
. '<div class="card"><strong>待关注</strong><br>' . (int)($arrSummary['attention_count'] ?? 0) . '</div>'
. '<div class="card"><strong>当前异常队列</strong><br>' . (int)($arrSummary['external_queue_count'] ?? 0) . '</div>'
. '<div class="card"><strong>趋势</strong><br>' . self::escape(DomainImportReportViewHelper::translateTrendLabel((string)(($arrSummary['proxy_signal_trend'] ?? [])['label'] ?? ''))) . '</div>'
. '</div>'
. '<h2>最近重跑</h2>'
. '<table><thead><tr><th>运行批次</th><th>通过</th><th>失败</th><th>更新时间</th></tr></thead><tbody>' . implode('', $arrRerunRows) . '</tbody></table>'
. '<h2>最近导入</h2>'
. '<table><thead><tr><th>运行批次</th><th>探测数</th><th>通过</th><th>失败</th><th>更新时间</th></tr></thead><tbody>' . implode('', $arrImportRows) . '</tbody></table>'
. '</body></html>';
}
protected static function ensureDir(string $strDir): void
{
if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) {
throw new \RuntimeException('Failed to create directory: ' . $strDir);
}
}
protected static function escape(string $strValue): string
{
return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8');
}
}

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,211 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportExternalSeoClosureActionHelper
{
public static function baselineFromClosure(array $closure): array
{
return [
'closure_stage' => (string)($closure['closure_stage'] ?? ''),
'health_label' => (string)($closure['health_label'] ?? ''),
'import_active_count' => (int)((array)($closure['import_summary'] ?? [])['active_count'] ?? 0),
'manual_attention_count' => (int)((array)($closure['import_summary'] ?? [])['manual_attention_count'] ?? 0),
'indexed_like_host_count' => (int)((array)($closure['external_summary'] ?? [])['indexed_like_host_count'] ?? 0),
'keyword_ready_host_count' => (int)((array)($closure['external_summary'] ?? [])['keyword_ready_host_count'] ?? 0),
'newly_indexed_count' => (int)((array)($closure['external_summary'] ?? [])['newly_indexed_count'] ?? 0),
'keyword_up_count' => (int)((array)($closure['external_summary'] ?? [])['keyword_up_count'] ?? 0),
];
}
public static function createRunRoot(string $baseRoot, string $prefix = 'closure_action'): 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 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));
$summary['updated_at'] = date(DATE_ATOM);
$jsonPath = $runRoot . '/import-external-seo-closure-action.summary.json';
$htmlPath = $runRoot . '/import-external-seo-closure-action.summary.html';
$summary['summary_json_path'] = $jsonPath;
$summary['summary_html_path'] = $htmlPath;
file_put_contents($jsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($htmlPath, self::renderHtml($summary));
return $summary;
}
public static function updateStatus(string $runRoot, string $status, string $note = ''): array
{
$runRoot = rtrim($runRoot, '/');
$jsonPath = $runRoot . '/import-external-seo-closure-action.summary.json';
if (!is_file($jsonPath)) {
throw new \RuntimeException('Closure action summary not found: ' . $jsonPath);
}
$summary = json_decode((string)file_get_contents($jsonPath), true);
if (!is_array($summary)) {
$summary = [];
}
$allowed = ['pending', 'running', 'observing', 'done', 'ignored'];
if (!in_array($status, $allowed, true)) {
$status = 'pending';
}
$summary['status'] = $status;
$summary['status_note'] = $note;
$summary['updated_at'] = date(DATE_ATOM);
return self::persist($summary, $runRoot);
}
public static function refreshResult(string $runRoot, array $latestClosure): array
{
$runRoot = rtrim($runRoot, '/');
$jsonPath = $runRoot . '/import-external-seo-closure-action.summary.json';
if (!is_file($jsonPath)) {
throw new \RuntimeException('Closure action summary not found: ' . $jsonPath);
}
$summary = json_decode((string)file_get_contents($jsonPath), true);
if (!is_array($summary)) {
$summary = [];
}
$baseline = (array)($summary['baseline'] ?? []);
$latest = self::baselineFromClosure($latestClosure);
[$label, $score, $observation, $reason] = self::evaluateResult($baseline, $latest);
$summary['latest'] = $latest;
$summary['result_label'] = $label;
$summary['result_score'] = $score;
$summary['result_observation'] = $observation;
$summary['result_summary'] = $reason;
$summary['updated_at'] = date(DATE_ATOM);
if (($summary['status'] ?? 'pending') === 'done' && in_array($label, ['no_change', 'regression'], true)) {
$summary['status'] = 'observing';
}
if (($summary['status'] ?? 'pending') === 'pending' && $label === 'improved') {
$summary['status'] = 'observing';
}
return self::persist($summary, $runRoot);
}
protected static function evaluateResult(array $baseline, array $latest): array
{
$score = 0;
$importActiveDelta = (int)($baseline['import_active_count'] ?? 0) - (int)($latest['import_active_count'] ?? 0);
if ($importActiveDelta > 0) {
$score += 2;
} elseif ($importActiveDelta < 0) {
$score -= 2;
}
$manualDelta = (int)($baseline['manual_attention_count'] ?? 0) - (int)($latest['manual_attention_count'] ?? 0);
if ($manualDelta > 0) {
$score += 1;
} elseif ($manualDelta < 0) {
$score -= 1;
}
$indexedDelta = (int)($latest['indexed_like_host_count'] ?? 0) - (int)($baseline['indexed_like_host_count'] ?? 0);
if ($indexedDelta > 0) {
$score += 2;
} elseif ($indexedDelta < 0) {
$score -= 2;
}
$keywordReadyDelta = (int)($latest['keyword_ready_host_count'] ?? 0) - (int)($baseline['keyword_ready_host_count'] ?? 0);
if ($keywordReadyDelta > 0) {
$score += 2;
} elseif ($keywordReadyDelta < 0) {
$score -= 2;
}
$keywordUpDelta = (int)($latest['keyword_up_count'] ?? 0) - (int)($baseline['keyword_up_count'] ?? 0);
if ($keywordUpDelta > 0) {
$score += 1;
}
$newIndexedDelta = (int)($latest['newly_indexed_count'] ?? 0) - (int)($baseline['newly_indexed_count'] ?? 0);
if ($newIndexedDelta > 0) {
$score += 1;
}
$label = 'no_change';
if ($score >= 2) {
$label = 'improved';
} elseif ($score <= -2) {
$label = 'regression';
}
$observation = '观察当前导入恢复和站外效果是否继续保持';
if ($label === 'improved') {
$observation = '动作后出现改善,建议继续观察并准备收口';
} elseif ($label === 'regression') {
$observation = '动作后没有改善甚至变差,建议重新评估动作路径';
}
$reason = sprintf(
'导入失败 %d -> %d人工接管 %d -> %d收录 Host %d -> %d关键词 Ready %d -> %d。',
(int)($baseline['import_active_count'] ?? 0),
(int)($latest['import_active_count'] ?? 0),
(int)($baseline['manual_attention_count'] ?? 0),
(int)($latest['manual_attention_count'] ?? 0),
(int)($baseline['indexed_like_host_count'] ?? 0),
(int)($latest['indexed_like_host_count'] ?? 0),
(int)($baseline['keyword_ready_host_count'] ?? 0),
(int)($latest['keyword_ready_host_count'] ?? 0)
);
return [$label, $score, $observation, $reason];
}
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 renderHtml(array $summary): string
{
$escape = static fn(string $v): string => htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>闭环动作摘要</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}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>' . $escape(DomainImportReportViewHelper::formatDateTime((string)($summary['generated_at'] ?? ''))) . '</code></p>'
. '<p>状态:<strong>' . $escape(DomainImportReportViewHelper::translateStatus((string)($summary['status'] ?? 'pending'))) . '</strong></p>'
. '<p>动作:<strong>' . $escape((string)($summary['action_label'] ?? '')) . '</strong></p>'
. '<p>动作说明:' . $escape((string)($summary['action_summary'] ?? '')) . '</p>'
. '<p>闭环阶段:<code>' . $escape(DomainImportReportViewHelper::translateClosureStage((string)($summary['closure_stage'] ?? ''))) . '</code> / 健康状态:<code>' . $escape(DomainImportReportViewHelper::translateHealthLabel((string)($summary['health_label'] ?? ''))) . '</code></p>'
. '<p>状态备注:' . $escape((string)($summary['status_note'] ?? '')) . '</p>'
. '<p>目标类型:<code>' . $escape((string)($summary['target_type'] ?? '')) . '</code> / 目标键:<code>' . $escape((string)($summary['target_key'] ?? '')) . '</code> / 目标域名:<code>' . $escape((string)($summary['target_host'] ?? '')) . '</code></p>'
. '<p>结果:<strong>' . $escape(DomainImportReportViewHelper::translateResultLabel((string)($summary['result_label'] ?? 'pending'))) . '</strong></p>'
. '<p>结果摘要:' . $escape((string)($summary['result_summary'] ?? '')) . '</p>'
. '<p>观察建议:' . $escape((string)($summary['result_observation'] ?? '')) . '</p>'
. '</body></html>';
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportExternalSeoClosureActionIndexHelper
{
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' => [],
'status_buckets' => ['pending' => 0, 'running' => 0, 'observing' => 0, 'done' => 0, 'ignored' => 0],
'result_buckets' => ['improved' => 0, 'no_change' => 0, 'regression' => 0, 'pending' => 0],
'action_buckets' => [],
'target_type_buckets' => [],
'stage_buckets' => [],
'alert_buckets' => ['high' => 0, 'medium' => 0, 'low' => 0, 'none' => 0],
'top_attention_runs' => [],
'effectiveness' => [],
];
}
$summaryFiles = array_merge(
(array)glob($runRoot . '/*/import-external-seo-closure-action.summary.json'),
(array)glob($runRoot . '/*/*/import-external-seo-closure-action.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);
$items[] = [
'run_id' => basename($runDir),
'run_root_public' => self::publicRelativePath($runDir),
'action_key' => (string)($data['action_key'] ?? ''),
'action_label' => (string)($data['action_label'] ?? ''),
'action_summary' => (string)($data['action_summary'] ?? ''),
'closure_stage' => (string)($data['closure_stage'] ?? ''),
'health_label' => (string)($data['health_label'] ?? ''),
'target_type' => (string)($data['target_type'] ?? ''),
'target_key' => (string)($data['target_key'] ?? ''),
'target_host' => (string)($data['target_host'] ?? ''),
'status' => (string)($data['status'] ?? 'pending'),
'status_note' => (string)($data['status_note'] ?? ''),
'result_label' => (string)($data['result_label'] ?? 'pending'),
'result_score' => (int)($data['result_score'] ?? 0),
'result_summary' => (string)($data['result_summary'] ?? ''),
'result_observation' => (string)($data['result_observation'] ?? ''),
'summary_json_path' => self::publicRelativePath($summaryPath),
'summary_html_path' => self::publicRelativePath($runDir . '/import-external-seo-closure-action.summary.html'),
'updated_at' => (string)($data['updated_at'] ?? date(DATE_ATOM, (int)(filemtime($summaryPath) ?: time()))),
];
}
$statusBuckets = ['pending' => 0, 'running' => 0, 'observing' => 0, 'done' => 0, 'ignored' => 0];
$resultBuckets = ['improved' => 0, 'no_change' => 0, 'regression' => 0, 'pending' => 0];
$actionBuckets = [];
$targetTypeBuckets = [];
$stageBuckets = [];
$alertBuckets = ['high' => 0, 'medium' => 0, 'low' => 0, 'none' => 0];
foreach ($items as $index => $item) {
$status = (string)($item['status'] ?? 'pending');
if (!isset($statusBuckets[$status])) {
$statusBuckets[$status] = 0;
}
$statusBuckets[$status]++;
$result = (string)($item['result_label'] ?? 'pending');
if (!isset($resultBuckets[$result])) {
$resultBuckets[$result] = 0;
}
$resultBuckets[$result]++;
$actionKey = (string)($item['action_key'] ?? 'unknown');
if (!isset($actionBuckets[$actionKey])) {
$actionBuckets[$actionKey] = 0;
}
$actionBuckets[$actionKey]++;
$targetType = (string)($item['target_type'] ?? 'none');
if (!isset($targetTypeBuckets[$targetType])) {
$targetTypeBuckets[$targetType] = 0;
}
$targetTypeBuckets[$targetType]++;
$stage = (string)($item['closure_stage'] ?? 'unknown');
if (!isset($stageBuckets[$stage])) {
$stageBuckets[$stage] = 0;
}
$stageBuckets[$stage]++;
[$alertLevel, $alertReason] = self::resolveAlert($item);
$items[$index]['alert_level'] = $alertLevel;
$items[$index]['alert_reason'] = $alertReason;
$alertBuckets[$alertLevel] = (int)($alertBuckets[$alertLevel] ?? 0) + 1;
}
$effectiveness = self::buildEffectiveness($items);
$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] ?? [],
'status_buckets' => $statusBuckets,
'result_buckets' => $resultBuckets,
'action_buckets' => $actionBuckets,
'target_type_buckets' => $targetTypeBuckets,
'stage_buckets' => $stageBuckets,
'alert_buckets' => $alertBuckets,
'top_attention_runs' => array_slice($topAttentionRuns, 0, 5),
'effectiveness' => $effectiveness,
];
}
protected static function resolveAlert(array $item): array
{
$status = (string)($item['status'] ?? 'pending');
$result = (string)($item['result_label'] ?? 'pending');
$stage = (string)($item['closure_stage'] ?? '');
if ($result === 'regression') {
return ['high', '动作执行后结果变差,建议优先复盘'];
}
if ($status === 'pending') {
return ['medium', '动作仍未开始,建议尽快处理'];
}
if ($status === 'observing' && in_array($result, ['pending', 'no_change'], true)) {
return ['medium', '动作进入观察态但还没有明显改善'];
}
if ($result === 'improved') {
return ['low', '动作后已有改善,继续观察是否稳定'];
}
if ($status === 'running' || $stage === 'manual_intervention_needed') {
return ['low', '动作仍在处理中,继续跟进'];
}
return ['none', '当前动作状态平稳'];
}
protected static function buildEffectiveness(array $items): array
{
$stats = [];
foreach ($items as $item) {
$key = (string)($item['action_key'] ?? 'unknown');
if (!isset($stats[$key])) {
$stats[$key] = [
'action_key' => $key,
'action_label' => (string)($item['action_label'] ?? $key),
'total' => 0,
'improved' => 0,
'no_change' => 0,
'regression' => 0,
'pending' => 0,
];
}
$stats[$key]['total']++;
$result = (string)($item['result_label'] ?? 'pending');
if (!isset($stats[$key][$result])) {
$stats[$key][$result] = 0;
}
$stats[$key][$result]++;
}
usort($stats, static function (array $left, array $right): int {
return ((int)($right['improved'] ?? 0)) <=> ((int)($left['improved'] ?? 0));
});
return array_slice(array_values($stats), 0, 10);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportExternalSeoClosureHelper
{
public static function buildSummary(string $strImportRunRoot, string $strRerunRoot, string $strSelfHealingRoot, int $intDays = 14, int $intLimit = 10): array
{
$intDays = max(2, min(90, $intDays));
$intLimit = max(1, min(50, $intLimit));
$arrImport = DomainImportHealthWorkbenchHelper::buildSummary($strImportRunRoot, $strRerunRoot, $strSelfHealingRoot, $intLimit);
$arrExternal = DomainExternalSeoCnOverviewHelper::buildOverview($intDays, $intLimit);
$intImportRuns = (int)($arrImport['import_runs_count'] ?? 0);
$intActiveCount = (int)($arrImport['active_count'] ?? 0);
$intManualAttentionCount = (int)($arrImport['manual_attention_count'] ?? 0);
$intHostCount = (int)(($arrExternal['snapshot_summary'] ?? [])['host_count'] ?? 0);
$intIndexedLike = (int)(($arrExternal['snapshot_summary'] ?? [])['indexed_like_host_count'] ?? 0);
$intKeywordReady = (int)(($arrExternal['snapshot_summary'] ?? [])['keyword_ready_host_count'] ?? 0);
$intKeywordUp = (int)(($arrExternal['keyword_movement'] ?? [])['up_count'] ?? 0);
$intNewlyIndexed = (int)(($arrExternal['index_movement'] ?? [])['newly_indexed_count'] ?? 0);
$strStage = 'closed_loop_running';
if ($intImportRuns === 0) {
$strStage = 'import_not_started';
} elseif ($intActiveCount > 0) {
$strStage = $intManualAttentionCount > 0 ? 'manual_intervention_needed' : 'import_recovery_needed';
} elseif ($intHostCount === 0) {
$strStage = 'external_waiting_data';
} elseif ($intIndexedLike <= 0 && $intKeywordReady <= 0) {
$strStage = 'waiting_indexing';
} elseif ($intIndexedLike > 0 && $intKeywordReady <= 0) {
$strStage = 'indexing_without_keywords';
} elseif ($intKeywordUp > 0 || $intNewlyIndexed > 0) {
$strStage = 'seo_growing';
} elseif (in_array((string)($arrExternal['health_label'] ?? ''), ['attention', 'captcha'], true)) {
$strStage = 'seo_attention';
}
$strHealth = 'steady';
if (in_array($strStage, ['import_not_started', 'external_waiting_data', 'waiting_indexing'], true)) {
$strHealth = 'warming';
}
if (in_array($strStage, ['import_recovery_needed', 'manual_intervention_needed', 'seo_attention'], true)) {
$strHealth = 'attention';
}
if ($strStage === 'seo_growing') {
$strHealth = 'growing';
}
return [
'generated_at' => date(DATE_ATOM),
'days' => $intDays,
'health_label' => $strHealth,
'closure_stage' => $strStage,
'hero_summary' => self::buildHeroSummary($strStage, $arrImport, $arrExternal),
'import_summary' => [
'health_label' => (string)($arrImport['health_label'] ?? ''),
'workbench_stage' => (string)($arrImport['workbench_stage'] ?? ''),
'import_runs_count' => $intImportRuns,
'active_count' => $intActiveCount,
'manual_attention_count' => $intManualAttentionCount,
'latest_self_healing_run' => (array)($arrImport['latest_self_healing_run'] ?? []),
],
'external_summary' => [
'health_label' => (string)($arrExternal['health_label'] ?? ''),
'host_count' => $intHostCount,
'indexed_like_host_count' => $intIndexedLike,
'keyword_ready_host_count' => $intKeywordReady,
'newly_indexed_count' => (int)(($arrExternal['index_movement'] ?? [])['newly_indexed_count'] ?? 0),
'lost_indexed_count' => (int)(($arrExternal['index_movement'] ?? [])['lost_indexed_count'] ?? 0),
'keyword_up_count' => $intKeywordUp,
'keyword_down_count' => (int)(($arrExternal['keyword_movement'] ?? [])['down_count'] ?? 0),
'priority_high_count' => (int)(($arrExternal['priority_buckets'] ?? [])['high'] ?? 0),
],
'priority_actions' => self::buildPriorityActions($strStage, $arrImport, $arrExternal),
'top_import_actions' => array_slice((array)($arrImport['priority_actions'] ?? []), 0, 3),
'top_seo_suggestions' => array_slice((array)($arrExternal['top_suggestions'] ?? []), 0, 3),
];
}
protected static function buildHeroSummary(string $strStage, array $arrImport, array $arrExternal): string
{
$intActiveCount = (int)($arrImport['active_count'] ?? 0);
$intManualAttentionCount = (int)($arrImport['manual_attention_count'] ?? 0);
$intIndexedLike = (int)(($arrExternal['snapshot_summary'] ?? [])['indexed_like_host_count'] ?? 0);
$intKeywordReady = (int)(($arrExternal['snapshot_summary'] ?? [])['keyword_ready_host_count'] ?? 0);
if ($strStage === 'import_not_started') {
return '导入主线还没起量,先完成导入与自动探测,再看站外效果。';
}
if ($strStage === 'import_recovery_needed') {
return sprintf('当前仍有 %d 个失败 host先把导入恢复链跑稳再继续盯站外效果。', $intActiveCount);
}
if ($strStage === 'manual_intervention_needed') {
return sprintf('当前已有 %d 个 host 进入人工接管,建议先消化人工关注队列,再观察外部效果。', $intManualAttentionCount);
}
if ($strStage === 'external_waiting_data') {
return '导入主线已稳定,但站外采集数据还没进来,先等 Node/快照回推。';
}
if ($strStage === 'waiting_indexing') {
return '导入主线已稳定,站外快照已开始进入,但收录和关键词还没明显起来,先观察收录起量。';
}
if ($strStage === 'indexing_without_keywords') {
return sprintf('已经有 %d 个 host 出现收录信号,但关键词还没明显起来,下一步重点看词面。', $intIndexedLike);
}
if ($strStage === 'seo_attention') {
return '站外大盘已经有异常信号,建议直接看收录变化、关键词升降和自动建议。';
}
if ($strStage === 'seo_growing') {
return sprintf('闭环已经开始转正:收录 host %d关键词 ready host %d可以进入效果优化阶段。', $intIndexedLike, $intKeywordReady);
}
return '导入恢复和站外效果都已接入,当前可以按闭环总览继续值班。';
}
protected static function buildPriorityActions(string $strStage, array $arrImport, array $arrExternal): array
{
$arrActions = [];
if (in_array($strStage, ['import_recovery_needed', 'manual_intervention_needed'], true)) {
$arrActions[] = [
'key' => 'open_import_health',
'label' => '先看导入健康台',
'summary' => '导入恢复主线还没完全稳定,先确认失败队列、自愈记录和人工接管状态。',
];
$arrActions[] = [
'key' => 'open_manual_attention',
'label' => '处理人工关注队列',
'summary' => '优先消化被自动修复降级的 host避免站外采集结果被站内异常污染。',
];
} else {
$arrActions[] = [
'key' => 'open_cn_overview',
'label' => '先看大陆 SEO 总览',
'summary' => '导入主线已基本稳定,先判断收录和关键词有没有开始接上。',
];
}
if (in_array($strStage, ['waiting_indexing', 'indexing_without_keywords', 'seo_attention', 'seo_growing', 'closed_loop_running'], true)) {
$arrActions[] = [
'key' => 'open_index_movement',
'label' => '看收录变化',
'summary' => '优先判断新增收录、丢失收录和验证码状态。',
];
$arrActions[] = [
'key' => 'open_keyword_movement',
'label' => '看关键词升降',
'summary' => '判断词面是开始增长、停滞,还是已经出现掉词。',
];
$arrActions[] = [
'key' => 'open_optimization',
'label' => '看自动优化建议',
'summary' => '让系统先给出当前最值得执行的外部 SEO 动作。',
];
}
return array_slice($arrActions, 0, 5);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportExternalSeoClosureHostHistoryHelper
{
public static function buildSummary(string $runRoot, string $host = '', int $limit = 20): array
{
$host = trim($host);
$limit = max(1, min(50, $limit));
$actionSummary = DomainImportExternalSeoClosureActionIndexHelper::buildSummary($runRoot, 200);
$items = array_values(array_filter((array)($actionSummary['items'] ?? []), static function ($item): bool {
return is_array($item);
}));
$hostBuckets = [];
foreach ($items as $item) {
$targetHost = trim((string)($item['target_host'] ?? ''));
if ($targetHost === '') {
continue;
}
$hostBuckets[$targetHost] = (int)($hostBuckets[$targetHost] ?? 0) + 1;
}
arsort($hostBuckets);
$filteredItems = [];
if ($host !== '') {
$filteredItems = array_values(array_filter($items, static function (array $item) use ($host): bool {
return trim((string)($item['target_host'] ?? '')) === $host;
}));
}
$statusBuckets = [];
$resultBuckets = [];
$alertBuckets = [];
foreach ($filteredItems as $item) {
$status = (string)($item['status'] ?? 'pending');
$result = (string)($item['result_label'] ?? 'pending');
$alert = (string)($item['alert_level'] ?? 'none');
$statusBuckets[$status] = (int)($statusBuckets[$status] ?? 0) + 1;
$resultBuckets[$result] = (int)($resultBuckets[$result] ?? 0) + 1;
$alertBuckets[$alert] = (int)($alertBuckets[$alert] ?? 0) + 1;
}
return [
'generated_at' => date(DATE_ATOM),
'host' => $host,
'host_selected' => $host !== '',
'total_actions' => count($filteredItems),
'latest_run' => $filteredItems[0] ?? [],
'status_buckets' => $statusBuckets,
'result_buckets' => $resultBuckets,
'alert_buckets' => $alertBuckets,
'items' => array_slice($filteredItems, 0, $limit),
'host_buckets' => array_slice($hostBuckets, 0, 10, true),
];
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportExternalSeoClosureTodayHelper
{
public static function buildSummary(
string $strImportRunRoot,
string $strRerunRoot,
string $strSelfHealingRoot,
string $strClosureActionRoot,
int $intDays = 14,
int $intLimit = 10
): array {
$arrClosure = DomainImportExternalSeoClosureHelper::buildSummary($strImportRunRoot, $strRerunRoot, $strSelfHealingRoot, $intDays, $intLimit);
$arrActionSummary = DomainImportExternalSeoClosureActionIndexHelper::buildSummary($strClosureActionRoot, 50);
$arrTodayItems = [];
$strToday = date('Y-m-d');
foreach ((array)($arrActionSummary['items'] ?? []) as $item) {
if (!is_array($item)) {
continue;
}
$updatedAt = (string)($item['updated_at'] ?? '');
if ($updatedAt !== '' && substr($updatedAt, 0, 10) === $strToday) {
$arrTodayItems[] = $item;
}
}
$pending = 0;
$attention = 0;
$improved = 0;
foreach ($arrTodayItems as $item) {
$status = (string)($item['status'] ?? 'pending');
$result = (string)($item['result_label'] ?? 'pending');
if (in_array($status, ['pending', 'running', 'observing'], true)) {
$pending++;
}
if ($result === 'improved') {
$improved++;
}
if (in_array($result, ['regression', 'pending'], true)) {
$attention++;
}
}
return [
'generated_at' => date(DATE_ATOM),
'today' => $strToday,
'health_label' => (string)($arrClosure['health_label'] ?? 'steady'),
'closure_stage' => (string)($arrClosure['closure_stage'] ?? 'closed_loop_running'),
'hero_summary' => (string)($arrClosure['hero_summary'] ?? ''),
'pending_action_count' => $pending,
'attention_action_count' => $attention,
'improved_action_count' => $improved,
'manual_attention_count' => (int)((array)($arrClosure['import_summary'] ?? [])['manual_attention_count'] ?? 0),
'import_active_count' => (int)((array)($arrClosure['import_summary'] ?? [])['active_count'] ?? 0),
'indexed_like_host_count' => (int)((array)($arrClosure['external_summary'] ?? [])['indexed_like_host_count'] ?? 0),
'keyword_ready_host_count' => (int)((array)($arrClosure['external_summary'] ?? [])['keyword_ready_host_count'] ?? 0),
'today_actions' => array_slice($arrTodayItems, 0, 10),
'top_effectiveness' => array_slice((array)($arrActionSummary['effectiveness'] ?? []), 0, 5),
'status_buckets' => (array)($arrActionSummary['status_buckets'] ?? []),
'result_buckets' => (array)($arrActionSummary['result_buckets'] ?? []),
'target_type_buckets' => (array)($arrActionSummary['target_type_buckets'] ?? []),
'stage_buckets' => (array)($arrActionSummary['stage_buckets'] ?? []),
'alert_buckets' => (array)($arrActionSummary['alert_buckets'] ?? []),
'top_attention_runs' => array_slice((array)($arrActionSummary['top_attention_runs'] ?? []), 0, 5),
'priority_actions' => (array)($arrClosure['priority_actions'] ?? []),
];
}
}

View File

@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportFailedQueueHelper
{
public static function buildSummary(string $strRunRoot, int $intLimit = 50): array
{
$strRunRoot = rtrim($strRunRoot, '/');
if ($strRunRoot === '' || !is_dir($strRunRoot)) {
return [
'queue_count' => 0,
'active_count' => 0,
'resolved_count' => 0,
'resolved_by_rerun_count' => 0,
'resolved_by_remediation_count' => 0,
'items' => [],
'resolved_items' => [],
'resolved_hosts_preview' => [],
'latest_item' => [],
'rerun_all_command' => 'php scripts/domain_import_failed_queue_run.php --format=text',
'generated_at' => date(DATE_ATOM),
];
}
$arrSummaryFiles = array_merge(
(array)glob($strRunRoot . '/*/import-probe.summary.json'),
(array)glob($strRunRoot . '/*/*/import-probe.summary.json')
);
$arrSummaryFiles = array_values(array_filter(array_unique($arrSummaryFiles), 'is_file'));
usort($arrSummaryFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
$arrItems = [];
foreach ($arrSummaryFiles as $strSummaryPath) {
$arrSummary = json_decode((string)file_get_contents($strSummaryPath), true);
if (!is_array($arrSummary)) {
continue;
}
$strRunDir = dirname($strSummaryPath);
$strRunId = basename($strRunDir);
$strUpdatedAt = date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time()));
foreach ((array)($arrSummary['items'] ?? []) as $arrProbeItem) {
if (!is_array($arrProbeItem) || !empty($arrProbeItem['all_passed'])) {
continue;
}
$strHost = trim((string)($arrProbeItem['host'] ?? ''));
if ($strHost === '') {
continue;
}
$arrFailedStages = array_values(array_filter((array)($arrProbeItem['failed_stages'] ?? []), 'is_string'));
$arrDetailSample = is_array($arrProbeItem['sample']['detail'] ?? null) ? $arrProbeItem['sample']['detail'] : [];
$arrSearchSample = is_array($arrProbeItem['sample']['search'] ?? null) ? $arrProbeItem['sample']['search'] : [];
$arrRemediationPlan = DomainImportFailureRemediationHelper::buildPlan([
'host' => $strHost,
'failed_stage' => (string)($arrProbeItem['failed_stage'] ?? ''),
'failed_stages' => $arrFailedStages,
]);
$arrItems[$strHost] = [
'host' => $strHost,
'run_id' => $strRunId,
'status' => (string)($arrProbeItem['status'] ?? 'failed'),
'message' => (string)($arrProbeItem['message'] ?? ''),
'failed_stage' => (string)($arrProbeItem['failed_stage'] ?? ''),
'failed_stages' => $arrFailedStages,
'failed_checks' => self::buildFailedChecks((array)($arrProbeItem['checks'] ?? [])),
'detail_id' => (int)($arrDetailSample['detail_id'] ?? 0),
'detail_slug' => trim((string)($arrDetailSample['detail_slug'] ?? '')),
'search_keyword' => trim((string)($arrSearchSample['search_keyword'] ?? '')),
'summary_json_path' => self::publicRelativePath($strSummaryPath),
'summary_html_path' => self::publicRelativePath($strRunDir . '/import-probe.summary.html'),
'run_root_public' => self::publicRelativePath($strRunDir),
'sample_command' => self::buildSampleCommand($strHost),
'rerun_command' => self::buildRerunCommand($strHost),
'remediation_plan' => $arrRemediationPlan,
'remediation_command' => (string)($arrRemediationPlan['recommended_command'] ?? ''),
'updated_at' => $strUpdatedAt,
];
}
}
$arrItems = array_values($arrItems);
$arrLatestRerunByHost = self::buildLatestRerunMap($strRunRoot);
$arrLatestRemediationByHost = self::buildLatestRemediationMap($strRunRoot);
$arrActiveItems = [];
$arrResolvedItems = [];
$intResolvedByRerun = 0;
$intResolvedByRemediation = 0;
foreach ($arrItems as $arrItem) {
$strHost = trim((string)($arrItem['host'] ?? ''));
$strFailedUpdatedAt = (string)($arrItem['updated_at'] ?? '');
$arrRerunItem = $arrLatestRerunByHost[$strHost] ?? [];
$arrRemediationItem = $arrLatestRemediationByHost[$strHost] ?? [];
$strRerunUpdatedAt = (string)($arrRerunItem['updated_at'] ?? '');
$strRerunStatus = trim((string)($arrRerunItem['status'] ?? ''));
$strRemediationUpdatedAt = (string)($arrRemediationItem['updated_at'] ?? '');
$strRemediationStatus = trim((string)($arrRemediationItem['status'] ?? ''));
$boolResolvedByRerun = $strRerunUpdatedAt !== ''
&& $strFailedUpdatedAt !== ''
&& strcmp($strRerunUpdatedAt, $strFailedUpdatedAt) >= 0
&& $strRerunStatus === 'passed';
$boolResolvedByRemediation = $strRemediationUpdatedAt !== ''
&& $strFailedUpdatedAt !== ''
&& strcmp($strRemediationUpdatedAt, $strFailedUpdatedAt) >= 0
&& $strRemediationStatus === 'passed';
$boolResolved = $boolResolvedByRerun || $boolResolvedByRemediation;
$arrItem['queue_state'] = $boolResolved ? 'resolved' : 'active';
$arrItem['resolved_by_rerun'] = $boolResolvedByRerun;
$arrItem['resolved_by_remediation'] = $boolResolvedByRemediation;
$arrItem['latest_rerun_status'] = $strRerunStatus;
$arrItem['latest_rerun_updated_at'] = $strRerunUpdatedAt;
$arrItem['latest_remediation_status'] = $strRemediationStatus;
$arrItem['latest_remediation_updated_at'] = $strRemediationUpdatedAt;
if ($boolResolved) {
if ($boolResolvedByRerun) {
$intResolvedByRerun++;
} elseif ($boolResolvedByRemediation) {
$intResolvedByRemediation++;
}
$arrResolvedItems[] = $arrItem;
} else {
$arrActiveItems[] = $arrItem;
}
}
usort($arrActiveItems, static function (array $arrLeft, array $arrRight): int {
return strcmp((string)($arrRight['updated_at'] ?? ''), (string)($arrLeft['updated_at'] ?? ''));
});
usort($arrResolvedItems, static function (array $arrLeft, array $arrRight): int {
return strcmp((string)($arrRight['updated_at'] ?? ''), (string)($arrLeft['updated_at'] ?? ''));
});
$arrActiveItems = array_slice($arrActiveItems, 0, max(1, $intLimit));
$arrResolvedItems = array_slice($arrResolvedItems, 0, max(1, $intLimit));
return [
'queue_count' => count($arrActiveItems),
'active_count' => count($arrActiveItems),
'resolved_count' => count($arrResolvedItems),
'resolved_by_rerun_count' => $intResolvedByRerun,
'resolved_by_remediation_count' => $intResolvedByRemediation,
'items' => $arrActiveItems,
'resolved_items' => $arrResolvedItems,
'resolved_hosts_preview' => array_values(array_filter(array_map(static fn (array $arrItem): string => (string)($arrItem['host'] ?? ''), array_slice($arrResolvedItems, 0, 5)))),
'latest_item' => $arrActiveItems[0] ?? [],
'rerun_all_command' => self::buildRerunAllCommand($strRunRoot),
'generated_at' => date(DATE_ATOM),
];
}
protected static function buildFailedChecks(array $arrChecks): array
{
$arrFailedChecks = [];
foreach ($arrChecks as $strKey => $arrCheck) {
if (!is_array($arrCheck) || !empty($arrCheck['passed'])) {
continue;
}
$arrFailedChecks[] = [
'stage' => (string)$strKey,
'status' => (int)($arrCheck['status'] ?? 0),
'detail' => (string)($arrCheck['detail'] ?? ''),
'url' => (string)($arrCheck['url'] ?? ''),
];
}
return $arrFailedChecks;
}
protected static function publicRelativePath(string $strPath): string
{
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strPublicRoot . '/')) {
return substr($strPath, strlen($strPublicRoot . '/'));
}
return $strPath;
}
protected static function buildSampleCommand(string $strHost): string
{
return 'php scripts/domain_auto_sample_discover.php ' . escapeshellarg($strHost) . ' --format=text';
}
protected static function buildRerunCommand(string $strHost): string
{
return 'php scripts/domain_trajectory_probe.php ' . escapeshellarg($strHost) . ' --format=text';
}
protected static function buildRerunAllCommand(string $strRunRoot): string
{
return 'php scripts/domain_import_failed_queue_run.php'
. ' --run-root=' . escapeshellarg($strRunRoot)
. ' --format=text';
}
protected static function buildLatestRerunMap(string $strImportRunRoot): array
{
$strRerunRoot = preg_replace('#/domain-import-runs/?$#', '/domain-import-reruns', $strImportRunRoot);
$strRerunRoot = is_string($strRerunRoot) ? $strRerunRoot : '';
return self::buildLatestRunMap($strRerunRoot, 'import-rerun.summary.json');
}
protected static function buildLatestRemediationMap(string $strImportRunRoot): array
{
$strRemediationRoot = preg_replace('#/domain-import-runs/?$#', '/domain-import-remediations', $strImportRunRoot);
$strRemediationRoot = is_string($strRemediationRoot) ? $strRemediationRoot : '';
return self::buildLatestRunMap($strRemediationRoot, 'import-remediation.summary.json');
}
protected static function buildLatestRunMap(string $strRunRoot, string $strSummaryFileName): array
{
$strRunRoot = rtrim($strRunRoot, '/');
if ($strRunRoot === '' || !is_dir($strRunRoot)) {
return [];
}
$arrSummaryFiles = array_merge(
(array)glob($strRunRoot . '/*/' . $strSummaryFileName),
(array)glob($strRunRoot . '/*/*/' . $strSummaryFileName)
);
$arrSummaryFiles = array_values(array_filter(array_unique($arrSummaryFiles), 'is_file'));
usort($arrSummaryFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
$arrHostMap = [];
foreach ($arrSummaryFiles as $strSummaryPath) {
$arrSummary = json_decode((string)file_get_contents($strSummaryPath), true);
if (!is_array($arrSummary)) {
continue;
}
$strUpdatedAt = date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time()));
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$strHost = trim((string)($arrItem['host'] ?? ''));
if ($strHost === '' || isset($arrHostMap[$strHost])) {
continue;
}
$arrHostMap[$strHost] = [
'status' => trim((string)($arrItem['status'] ?? '')),
'updated_at' => $strUpdatedAt,
'run_id' => basename(dirname($strSummaryPath)),
];
}
}
return $arrHostMap;
}
}

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,96 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportHealthTrendHelper
{
public static function buildSummary(string $strImportRunRoot, string $strRerunRoot, int $intLimit = 10): array
{
$arrQueueSummary = DomainImportFailedQueueHelper::buildSummary($strImportRunRoot, 100);
$arrImportSummary = DomainImportRunIndexHelper::buildSummary($strImportRunRoot, $intLimit);
$arrRerunSummary = DomainImportRerunIndexHelper::buildSummary($strRerunRoot, $intLimit);
$strRemediationRoot = preg_replace('#/domain-import-reruns/?$#', '/domain-import-remediations', $strRerunRoot);
$strRemediationRoot = is_string($strRemediationRoot) ? $strRemediationRoot : '';
$arrRemediationSummary = DomainImportRemediationIndexHelper::buildSummary($strRemediationRoot, $intLimit);
$arrRerunItems = (array)($arrRerunSummary['items'] ?? []);
$arrRemediationItems = (array)($arrRemediationSummary['items'] ?? []);
$arrTrend = array_map(static function (array $arrItem): array {
return [
'run_id' => (string)($arrItem['run_id'] ?? ''),
'processed_count' => (int)($arrItem['processed_count'] ?? 0),
'passed_count' => (int)($arrItem['passed_count'] ?? 0),
'failed_count' => (int)($arrItem['failed_count'] ?? 0),
'updated_at' => (string)($arrItem['updated_at'] ?? ''),
];
}, array_slice($arrRerunItems, 0, $intLimit));
$arrRemediationTrend = array_map(static function (array $arrItem): array {
return [
'run_id' => (string)($arrItem['run_id'] ?? ''),
'processed_count' => (int)($arrItem['processed_count'] ?? 0),
'passed_count' => (int)($arrItem['passed_count'] ?? 0),
'failed_count' => (int)($arrItem['failed_count'] ?? 0),
'recovered_count' => (int)($arrItem['recovered_count'] ?? 0),
'updated_at' => (string)($arrItem['updated_at'] ?? ''),
];
}, array_slice($arrRemediationItems, 0, $intLimit));
$arrLatestRerun = (array)($arrRerunSummary['latest_run'] ?? []);
$arrPreviousRerun = (array)($arrRerunItems[1] ?? []);
$arrLatestImport = (array)($arrImportSummary['latest_run'] ?? []);
$intActiveCount = (int)($arrQueueSummary['active_count'] ?? 0);
$intResolvedCount = (int)($arrQueueSummary['resolved_count'] ?? 0);
$intLatestFailed = $intActiveCount;
$intPreviousFailed = 0;
if ((int)($arrPreviousRerun['processed_count'] ?? 0) > 0) {
$intPreviousFailed = (int)($arrPreviousRerun['failed_count'] ?? 0);
} elseif ((int)($arrLatestImport['failed_count'] ?? 0) > 0) {
$intPreviousFailed = (int)($arrLatestImport['failed_count'] ?? 0);
} elseif ($intResolvedCount > 0) {
$intPreviousFailed = $intActiveCount + $intResolvedCount;
} else {
$intPreviousFailed = (int)($arrLatestRerun['failed_count'] ?? $intLatestFailed);
}
if ($intPreviousFailed <= 0) {
$intPreviousFailed = $intLatestFailed;
}
$strTrendLabel = $intLatestFailed === $intPreviousFailed
? 'flat'
: ($intLatestFailed < $intPreviousFailed ? 'improving' : 'worsening');
return [
'generated_at' => date(DATE_ATOM),
'queue_count' => (int)($arrQueueSummary['queue_count'] ?? 0),
'active_count' => (int)($arrQueueSummary['active_count'] ?? 0),
'resolved_count' => (int)($arrQueueSummary['resolved_count'] ?? 0),
'resolved_by_rerun_count' => (int)($arrQueueSummary['resolved_by_rerun_count'] ?? 0),
'resolved_by_remediation_count' => (int)($arrQueueSummary['resolved_by_remediation_count'] ?? 0),
'resolved_hosts_preview' => array_values((array)($arrQueueSummary['resolved_hosts_preview'] ?? [])),
'latest_import_run' => $arrImportSummary['latest_run'] ?? [],
'latest_rerun' => $arrRerunSummary['latest_run'] ?? [],
'latest_remediation' => $arrRemediationSummary['latest_run'] ?? [],
'recent_reruns' => $arrTrend,
'recent_remediations' => $arrRemediationTrend,
'recent_import_runs' => array_slice((array)($arrImportSummary['items'] ?? []), 0, $intLimit),
'rerun_runs_count' => (int)($arrRerunSummary['total'] ?? 0),
'remediation_runs_count' => (int)($arrRemediationSummary['total'] ?? 0),
'import_runs_count' => (int)($arrImportSummary['total'] ?? 0),
'failure_trend' => [
'latest_failed_count' => $intLatestFailed,
'previous_failed_count' => $intPreviousFailed,
'direction' => $intLatestFailed <=> $intPreviousFailed,
'label' => $strTrendLabel,
'basis' => (int)($arrPreviousRerun['processed_count'] ?? 0) > 0
? 'previous_rerun'
: (((int)($arrLatestImport['failed_count'] ?? 0) > 0)
? 'latest_import'
: ($intResolvedCount > 0 ? 'resolved_plus_active' : 'latest_rerun')),
],
];
}
}

View File

@@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportHealthWorkbenchHelper
{
public static function buildSummary(string $strImportRunRoot, string $strRerunRoot, string $strSelfHealingRoot, int $intLimit = 10): array
{
$arrImportSummary = DomainImportRunIndexHelper::buildSummary($strImportRunRoot, $intLimit);
$arrQueueSummary = DomainImportFailedQueueHelper::buildSummary($strImportRunRoot, 100);
$arrTrendSummary = DomainImportHealthTrendHelper::buildSummary($strImportRunRoot, $strRerunRoot, $intLimit);
$arrRerunSummary = DomainImportRerunIndexHelper::buildSummary($strRerunRoot, $intLimit);
$strRemediationRoot = preg_replace('#/domain-import-reruns/?$#', '/domain-import-remediations', $strRerunRoot);
$strRemediationRoot = is_string($strRemediationRoot) ? $strRemediationRoot : '';
$arrRemediationSummary = DomainImportRemediationIndexHelper::buildSummary($strRemediationRoot, $intLimit);
$arrSelfHealingSummary = DomainImportSelfHealingIndexHelper::buildSummary($strSelfHealingRoot, $intLimit);
$arrManualAttentionSummary = DomainImportManualAttentionQueueHelper::buildSummary($strImportRunRoot, $strRerunRoot, $strSelfHealingRoot, $intLimit);
$strManualHandleRoot = preg_replace('#/domain-import-self-healing/?$#', '/domain-import-manual-attention-handles', $strSelfHealingRoot);
$strManualHandleRoot = is_string($strManualHandleRoot) ? $strManualHandleRoot : '';
$arrManualHandleSummary = DomainImportManualAttentionHandleIndexHelper::buildSummary($strManualHandleRoot, $intLimit);
$arrFailureTrend = (array)($arrTrendSummary['failure_trend'] ?? []);
$strFailureTrendLabel = (string)($arrFailureTrend['label'] ?? 'flat');
$strHealthLabel = 'steady';
if ((int)($arrQueueSummary['active_count'] ?? 0) > 0) {
$strHealthLabel = 'attention';
}
if ($strFailureTrendLabel === 'worsening') {
$strHealthLabel = 'worsening';
}
if ((int)($arrSelfHealingSummary['total'] ?? 0) === 0 && (int)($arrQueueSummary['active_count'] ?? 0) > 0) {
$strHealthLabel = 'waiting_self_healing';
}
$strWorkbenchStage = self::resolveWorkbenchStage(
$strHealthLabel,
(int)($arrImportSummary['total'] ?? 0),
(int)($arrQueueSummary['active_count'] ?? 0),
(int)($arrSelfHealingSummary['total'] ?? 0)
);
$arrPriorityActions = self::buildPriorityActions(
$strWorkbenchStage,
$strHealthLabel,
$arrQueueSummary,
$arrSelfHealingSummary,
$arrImportSummary,
$arrTrendSummary,
$arrManualHandleSummary
);
$arrTopFailedStages = self::buildTopFailedStages((array)($arrQueueSummary['items'] ?? []));
$arrRecoveryMix = self::buildRecoveryMix(
(int)($arrQueueSummary['resolved_by_rerun_count'] ?? 0),
(int)($arrQueueSummary['resolved_by_remediation_count'] ?? 0),
(int)($arrSelfHealingSummary['total'] ?? 0)
);
return [
'generated_at' => date(DATE_ATOM),
'health_label' => $strHealthLabel,
'workbench_stage' => $strWorkbenchStage,
'import_runs_count' => (int)($arrImportSummary['total'] ?? 0),
'queue_count' => (int)($arrQueueSummary['queue_count'] ?? 0),
'active_count' => (int)($arrQueueSummary['active_count'] ?? 0),
'resolved_count' => (int)($arrQueueSummary['resolved_count'] ?? 0),
'resolved_by_rerun_count' => (int)($arrQueueSummary['resolved_by_rerun_count'] ?? 0),
'resolved_by_remediation_count' => (int)($arrQueueSummary['resolved_by_remediation_count'] ?? 0),
'rerun_runs_count' => (int)($arrTrendSummary['rerun_runs_count'] ?? 0),
'remediation_runs_count' => (int)($arrTrendSummary['remediation_runs_count'] ?? 0),
'self_healing_runs_count' => (int)($arrSelfHealingSummary['total'] ?? 0),
'manual_attention_count' => (int)($arrManualAttentionSummary['queue_count'] ?? 0),
'manual_handle_runs_count' => (int)($arrManualHandleSummary['total'] ?? 0),
'latest_import_run' => $arrImportSummary['latest_run'] ?? [],
'latest_failed_item' => $arrQueueSummary['latest_item'] ?? [],
'latest_rerun_run' => $arrRerunSummary['latest_run'] ?? [],
'latest_remediation_run' => $arrRemediationSummary['latest_run'] ?? [],
'latest_self_healing_run' => $arrSelfHealingSummary['latest_run'] ?? [],
'latest_manual_attention_item' => $arrManualAttentionSummary['latest_item'] ?? [],
'latest_manual_handle_run' => $arrManualHandleSummary['latest_run'] ?? [],
'failure_trend' => $arrTrendSummary['failure_trend'] ?? [],
'resolved_hosts_preview' => array_values((array)($arrQueueSummary['resolved_hosts_preview'] ?? [])),
'recent_import_runs' => array_slice((array)($arrImportSummary['items'] ?? []), 0, $intLimit),
'recent_rerun_runs' => array_slice((array)($arrRerunSummary['items'] ?? []), 0, $intLimit),
'recent_remediation_runs' => array_slice((array)($arrRemediationSummary['items'] ?? []), 0, $intLimit),
'recent_self_healing_runs' => array_slice((array)($arrSelfHealingSummary['items'] ?? []), 0, $intLimit),
'recent_manual_attention_items' => array_slice((array)($arrManualAttentionSummary['items'] ?? []), 0, $intLimit),
'recent_manual_handle_runs' => array_slice((array)($arrManualHandleSummary['items'] ?? []), 0, $intLimit),
'manual_attention_buckets' => (array)($arrManualAttentionSummary['attention_buckets'] ?? []),
'top_failed_stages' => $arrTopFailedStages,
'recovery_mix' => $arrRecoveryMix,
'priority_actions' => $arrPriorityActions,
'hero_summary' => self::buildHeroSummary($strWorkbenchStage, $strHealthLabel, $arrQueueSummary, $arrSelfHealingSummary, $arrTrendSummary),
];
}
protected static function buildTopFailedStages(array $arrItems): array
{
$arrBuckets = [];
foreach ($arrItems as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$strStage = trim((string)($arrItem['failed_stage'] ?? ''));
if ($strStage === '') {
$strStage = 'unknown';
}
if (!isset($arrBuckets[$strStage])) {
$arrBuckets[$strStage] = 0;
}
$arrBuckets[$strStage]++;
}
arsort($arrBuckets);
$arrOutput = [];
foreach (array_slice($arrBuckets, 0, 5, true) as $strStage => $intCount) {
$arrOutput[] = [
'stage' => $strStage,
'count' => (int)$intCount,
];
}
return $arrOutput;
}
protected static function buildRecoveryMix(int $intResolvedByRerun, int $intResolvedByRemediation, int $intSelfHealingRuns): array
{
$intTotalResolved = max(0, $intResolvedByRerun) + max(0, $intResolvedByRemediation);
$strPrimary = 'none';
if ($intResolvedByRerun > $intResolvedByRemediation) {
$strPrimary = 'rerun';
} elseif ($intResolvedByRemediation > $intResolvedByRerun) {
$strPrimary = 'remediation';
} elseif ($intTotalResolved > 0) {
$strPrimary = 'balanced';
}
return [
'resolved_total' => $intTotalResolved,
'resolved_by_rerun_count' => $intResolvedByRerun,
'resolved_by_remediation_count' => $intResolvedByRemediation,
'self_healing_runs_count' => $intSelfHealingRuns,
'primary_path' => $strPrimary,
];
}
protected static function resolveWorkbenchStage(string $strHealthLabel, int $intImportRunsCount, int $intActiveCount, int $intSelfHealingRunsCount): string
{
if ($intImportRunsCount === 0) {
return 'idle';
}
if ($intActiveCount === 0) {
return $intSelfHealingRunsCount > 0 ? 'observe_recovery' : 'steady';
}
if ($strHealthLabel === 'worsening') {
return 'needs_attention';
}
if ($strHealthLabel === 'waiting_self_healing' && $intActiveCount > 0) {
return 'needs_self_healing';
}
if ($intActiveCount > 0) {
return $intSelfHealingRunsCount > 0 ? 'observe_recovery' : 'needs_rerun';
}
return 'steady';
}
protected static function buildPriorityActions(
string $strWorkbenchStage,
string $strHealthLabel,
array $arrQueueSummary,
array $arrSelfHealingSummary,
array $arrImportSummary,
array $arrTrendSummary,
array $arrManualHandleSummary
): array {
$arrActions = [];
$intActiveCount = (int)($arrQueueSummary['active_count'] ?? 0);
$arrLatestFailedItem = (array)($arrQueueSummary['latest_item'] ?? []);
$arrLatestSelfHealingRun = (array)($arrSelfHealingSummary['latest_run'] ?? []);
$arrLatestImportRun = (array)($arrImportSummary['latest_run'] ?? []);
$arrLatestManualHandleRun = (array)($arrManualHandleSummary['latest_run'] ?? []);
$arrFailureTrend = (array)($arrTrendSummary['failure_trend'] ?? []);
$strTrendLabel = (string)($arrFailureTrend['label'] ?? 'flat');
if ($strWorkbenchStage === 'idle') {
$arrActions[] = [
'key' => 'import_runs',
'label' => '先导入一批站点',
'summary' => '当前还没有导入记录,先从批量导入开始建立主线数据。',
'action_key' => 'open_import_runs',
'action_label' => '看最近导入记录',
'tag_type' => 'info',
];
}
if ($intActiveCount > 0) {
$arrActions[] = [
'key' => 'failed_queue',
'label' => '先看失败队列',
'summary' => sprintf('当前仍有 %d 个失败 host优先确认失败阶段和失败明细。', $intActiveCount),
'action_key' => 'open_failed_queue',
'action_label' => '打开失败队列',
'tag_type' => $strHealthLabel === 'worsening' ? 'danger' : 'warning',
];
}
if ((int)($arrQueueSummary['active_count'] ?? 0) > 0 && (int)($arrSelfHealingSummary['latest_run']['downgraded_count'] ?? 0) > 0) {
$arrActions[] = [
'key' => 'manual_attention',
'label' => '人工接管降级 Host',
'summary' => sprintf('最近自动修复已降级 %d 个 host建议进入人工关注队列继续处理。', (int)($arrSelfHealingSummary['latest_run']['downgraded_count'] ?? 0)),
'action_key' => 'open_manual_attention',
'action_label' => '打开人工关注队列',
'tag_type' => 'danger',
];
}
if ((int)($arrTrendSummary['rerun_runs_count'] ?? 0) > 0) {
$arrActions[] = [
'key' => 'rerun_runs',
'label' => '回看最近重跑',
'summary' => sprintf('最近已有 %d 笔重跑记录,可以先判断恢复主要靠重跑还是继续卡住。', (int)($arrTrendSummary['rerun_runs_count'] ?? 0)),
'action_key' => 'open_rerun_runs',
'action_label' => '看重跑记录',
'tag_type' => 'info',
];
}
if ((int)($arrTrendSummary['remediation_runs_count'] ?? 0) > 0) {
$arrActions[] = [
'key' => 'remediation_runs',
'label' => '回看最近补料',
'summary' => sprintf('最近已有 %d 笔补料记录,适合判断失败 host 是否开始从补料链恢复。', (int)($arrTrendSummary['remediation_runs_count'] ?? 0)),
'action_key' => 'open_remediation_runs',
'action_label' => '看补料记录',
'tag_type' => 'info',
];
}
if (!empty($arrLatestManualHandleRun)) {
$arrActions[] = [
'key' => 'manual_handle_runs',
'label' => '回看最近人工处理',
'summary' => sprintf(
'最新人工处理 %sHost %s动作 %s状态 %s。',
(string)($arrLatestManualHandleRun['run_id'] ?? '-'),
(string)($arrLatestManualHandleRun['host'] ?? '-'),
(string)($arrLatestManualHandleRun['action'] ?? '-'),
(string)($arrLatestManualHandleRun['status'] ?? '-')
),
'action_key' => 'open_manual_handle_runs',
'action_label' => '看人工处理记录',
'tag_type' => 'primary',
];
}
if ($intActiveCount > 0 && ($strWorkbenchStage === 'needs_self_healing' || $strHealthLabel === 'worsening')) {
$arrActions[] = [
'key' => 'run_self_healing',
'label' => '建议执行自动修复',
'summary' => '先让系统按“重跑 -> 补料 -> 自动重探”尝试恢复失败 host。',
'action_key' => 'run_self_healing',
'action_label' => '执行自动修复',
'tag_type' => 'danger',
];
}
if (!empty($arrLatestSelfHealingRun)) {
$arrActions[] = [
'key' => 'self_healing_runs',
'label' => '回看最近自动修复',
'summary' => sprintf(
'最新自动修复 %s状态 %s。',
(string)($arrLatestSelfHealingRun['run_id'] ?? '-'),
(string)($arrLatestSelfHealingRun['status'] ?? '-')
),
'action_key' => 'open_self_healing_runs',
'action_label' => '看自动修复记录',
'tag_type' => 'success',
];
}
if (!empty($arrLatestFailedItem)) {
$arrActions[] = [
'key' => 'latest_failed_host',
'label' => '盯住最新失败 Host',
'summary' => sprintf(
'%s 当前失败在 %s 阶段,适合从失败队列继续下钻。',
(string)($arrLatestFailedItem['host'] ?? '-'),
(string)($arrLatestFailedItem['failed_stage'] ?? '-')
),
'action_key' => 'open_failed_queue',
'action_label' => '查看失败 Host',
'tag_type' => 'warning',
];
}
if ($strTrendLabel === 'improving' && !empty($arrLatestImportRun)) {
$arrActions[] = [
'key' => 'observe_import_runs',
'label' => '先观察恢复趋势',
'summary' => sprintf(
'失败趋势在改善,最近导入批次 %s 可继续观察而不必立刻人工干预。',
(string)($arrLatestImportRun['run_id'] ?? '-')
),
'action_key' => 'open_import_runs',
'action_label' => '看导入记录',
'tag_type' => 'info',
];
}
return array_slice($arrActions, 0, 5);
}
protected static function buildHeroSummary(string $strWorkbenchStage, string $strHealthLabel, array $arrQueueSummary, array $arrSelfHealingSummary, array $arrTrendSummary): string
{
$intActiveCount = (int)($arrQueueSummary['active_count'] ?? 0);
$intResolvedCount = (int)($arrQueueSummary['resolved_count'] ?? 0);
$intResolvedByRemediation = (int)($arrQueueSummary['resolved_by_remediation_count'] ?? 0);
$arrLatestSelfHealingRun = (array)($arrSelfHealingSummary['latest_run'] ?? []);
$strTrendLabel = (string)((array)($arrTrendSummary['failure_trend'] ?? []))['label'] ?? 'flat';
if ($strWorkbenchStage === 'idle') {
return '当前还没有导入记录,先从批量导入建立健康主线。';
}
if ($strWorkbenchStage === 'needs_self_healing') {
return sprintf('当前有 %d 个失败 host且还缺自动修复进展适合先让系统跑一轮自愈。', $intActiveCount);
}
if ($intActiveCount === 0 && !empty($arrLatestSelfHealingRun)) {
return sprintf(
'当前失败队列已清空,最近自动修复为 %s建议先观察恢复结果是否继续稳定。',
(string)($arrLatestSelfHealingRun['run_id'] ?? '-')
);
}
if ($intActiveCount === 0) {
return '当前失败队列为空,导入主线暂时平稳,可以继续观察新导入批次。';
}
if ($intResolvedCount > 0 && in_array($strTrendLabel, ['improving', 'flat'], true)) {
return sprintf('本轮已自动消退 %d 个失败 host当前还剩 %d 个,建议优先盯住剩余尾部问题。', $intResolvedCount, $intActiveCount);
}
if ($strHealthLabel === 'worsening') {
return sprintf('失败趋势上升,当前失败 host %d 个,建议优先看失败队列并触发自动修复。', $intActiveCount);
}
if ($strWorkbenchStage === 'observe_recovery') {
return sprintf(
'系统已经开始自动修复,最新 run 为 %s补料恢复累计 %d 个。',
(string)($arrLatestSelfHealingRun['run_id'] ?? '-'),
$intResolvedByRemediation
);
}
return '导入主线整体平稳,可以先看最新导入与自动修复结果。';
}
}

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,64 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportManualAttentionHandleIndexHelper
{
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-manual-attention.summary.json'),
(array)glob($runRoot . '/*/*/import-manual-attention.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);
$firstItem = (array)((array)($data['items'] ?? [])[0] ?? []);
$items[] = [
'run_id' => basename($runDir),
'run_root_public' => self::publicRelativePath($runDir),
'host' => (string)($firstItem['host'] ?? ''),
'action' => (string)($firstItem['action'] ?? ''),
'status' => (string)($firstItem['status'] ?? ''),
'reprobe_status' => (string)($firstItem['reprobe_status'] ?? ''),
'summary_json_path' => self::publicRelativePath($summaryPath),
'summary_html_path' => self::publicRelativePath($runDir . '/import-manual-attention.summary.html'),
'updated_at' => date(DATE_ATOM, (int)(filemtime($summaryPath) ?: time())),
];
}
return [
'items' => $items,
'total' => count($items),
'latest_run' => $items[0] ?? [],
];
}
}

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,216 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportManualAttentionQueueHelper
{
public static function buildSummary(string $importRunRoot, string $rerunRoot, string $selfHealingRoot, int $limit = 20): array
{
$limit = max(1, min(100, $limit));
$queueSummary = DomainImportFailedQueueHelper::buildSummary($importRunRoot, 200);
$selfHealingSummary = DomainImportSelfHealingIndexHelper::buildSummary($selfHealingRoot, 10);
$latestPolicy = self::readLatestPolicy($selfHealingSummary);
$handleRoot = preg_replace('#/domain-import-self-healing/?$#', '/domain-import-manual-attention-handles', $selfHealingRoot);
$handleRoot = is_string($handleRoot) ? $handleRoot : '';
$latestHandleMap = self::buildLatestHandleMap($handleRoot);
$downgradedMap = [];
foreach ((array)($latestPolicy['downgraded_items'] ?? []) as $item) {
if (!is_array($item)) {
continue;
}
$host = trim((string)($item['host'] ?? ''));
if ($host === '') {
continue;
}
$downgradedMap[$host] = $item;
}
$skippedMap = [];
foreach ((array)($latestPolicy['skipped_items'] ?? []) as $item) {
if (!is_array($item)) {
continue;
}
$host = trim((string)($item['host'] ?? ''));
if ($host === '') {
continue;
}
$skippedMap[$host] = $item;
}
$items = [];
foreach ((array)($queueSummary['items'] ?? []) as $queueItem) {
if (!is_array($queueItem)) {
continue;
}
$host = trim((string)($queueItem['host'] ?? ''));
if ($host === '') {
continue;
}
$latestHandle = (array)($latestHandleMap[$host] ?? []);
$latestHandleStatus = trim((string)($latestHandle['status'] ?? ''));
$latestHandleUpdatedAt = trim((string)($latestHandle['updated_at'] ?? ''));
$queueUpdatedAt = trim((string)($queueItem['updated_at'] ?? ''));
if (in_array($latestHandleStatus, ['resolved', 'ignored'], true)
&& $latestHandleUpdatedAt !== ''
&& $queueUpdatedAt !== ''
&& strcmp($latestHandleUpdatedAt, $queueUpdatedAt) >= 0
) {
continue;
}
[$level, $reason, $source] = self::resolveAttention($queueItem, $downgradedMap[$host] ?? [], $skippedMap[$host] ?? []);
if ($level === 'none') {
continue;
}
$items[] = [
'host' => $host,
'failed_stage' => (string)($queueItem['failed_stage'] ?? ''),
'updated_at' => (string)($queueItem['updated_at'] ?? ''),
'attention_level' => $level,
'attention_reason' => $reason,
'attention_source' => $source,
'handled_status' => $latestHandleStatus,
'handled_updated_at' => $latestHandleUpdatedAt,
'handled_reprobe_status' => (string)($latestHandle['reprobe_status'] ?? ''),
'latest_rerun_status' => (string)($queueItem['latest_rerun_status'] ?? ''),
'latest_rerun_updated_at' => (string)($queueItem['latest_rerun_updated_at'] ?? ''),
'latest_remediation_status' => (string)($queueItem['latest_remediation_status'] ?? ''),
'latest_remediation_updated_at' => (string)($queueItem['latest_remediation_updated_at'] ?? ''),
'summary_json_path' => (string)($queueItem['summary_json_path'] ?? ''),
'summary_html_path' => (string)($queueItem['summary_html_path'] ?? ''),
'run_root_public' => (string)($queueItem['run_root_public'] ?? ''),
'remediation_command' => (string)($queueItem['remediation_command'] ?? ''),
'rerun_command' => (string)($queueItem['rerun_command'] ?? ''),
];
}
usort($items, static function (array $left, array $right): int {
$priority = ['high' => 3, 'medium' => 2, 'low' => 1, 'none' => 0];
$levelCompare = ($priority[(string)($right['attention_level'] ?? 'none')] ?? 0) <=> ($priority[(string)($left['attention_level'] ?? 'none')] ?? 0);
if ($levelCompare !== 0) {
return $levelCompare;
}
return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? ''));
});
$bucket = ['high' => 0, 'medium' => 0, 'low' => 0];
foreach ($items as $item) {
$level = (string)($item['attention_level'] ?? '');
if (isset($bucket[$level])) {
$bucket[$level]++;
}
}
$items = array_slice($items, 0, $limit);
return [
'generated_at' => date(DATE_ATOM),
'queue_count' => count($items),
'attention_buckets' => $bucket,
'items' => $items,
'latest_item' => $items[0] ?? [],
'self_healing_latest_run' => (array)($selfHealingSummary['latest_run'] ?? []),
];
}
protected static function buildLatestHandleMap(string $handleRoot): array
{
$handleRoot = rtrim($handleRoot, '/');
if ($handleRoot === '' || !is_dir($handleRoot)) {
return [];
}
$summaryFiles = array_merge(
(array)glob($handleRoot . '/*/import-manual-attention.summary.json'),
(array)glob($handleRoot . '/*/*/import-manual-attention.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));
});
$map = [];
foreach ($summaryFiles as $summaryPath) {
$data = json_decode((string)file_get_contents($summaryPath), true);
if (!is_array($data)) {
continue;
}
$item = (array)((array)($data['items'] ?? [])[0] ?? []);
$host = trim((string)($item['host'] ?? ''));
if ($host === '' || isset($map[$host])) {
continue;
}
$map[$host] = [
'status' => (string)($item['status'] ?? ''),
'reprobe_status' => (string)($item['reprobe_status'] ?? ''),
'updated_at' => date(DATE_ATOM, (int)(filemtime($summaryPath) ?: time())),
];
}
return $map;
}
protected static function resolveAttention(array $queueItem, array $downgradedItem, array $skippedItem): array
{
if (!empty($downgradedItem)) {
return [
'high',
(string)($downgradedItem['policy_reason'] ?? '最近连续失败次数过多,建议转人工处理'),
'downgraded',
];
}
if (!empty($skippedItem)) {
return [
'medium',
(string)($skippedItem['policy_reason'] ?? '当前处于自动修复冷却期,建议人工先介入判断'),
'cooldown_skipped',
];
}
$failedStage = trim((string)($queueItem['failed_stage'] ?? ''));
$latestRerunStatus = trim((string)($queueItem['latest_rerun_status'] ?? ''));
$latestRemediationStatus = trim((string)($queueItem['latest_remediation_status'] ?? ''));
if ($latestRerunStatus === 'failed' && $latestRemediationStatus === 'failed') {
return ['medium', '最近重跑和补料都未恢复,建议人工继续接管', 'both_failed'];
}
if (in_array($failedStage, ['detail', 'play'], true)) {
return ['low', '当前卡在详情或播放链,适合人工优先核对模板和样本', 'deep_stage'];
}
return ['none', '', ''];
}
protected static function readLatestPolicy(array $selfHealingSummary): array
{
$latestRun = (array)($selfHealingSummary['latest_run'] ?? []);
$summaryJsonPath = trim((string)($latestRun['summary_json_path'] ?? ''));
if ($summaryJsonPath === '') {
return [];
}
$publicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$absolutePath = $publicRoot . '/' . ltrim($summaryJsonPath, '/');
if (!is_file($absolutePath)) {
return [];
}
$data = json_decode((string)file_get_contents($absolutePath), true);
if (!is_array($data)) {
return [];
}
return (array)($data['policy'] ?? []);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
class DomainImportProbeRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'import'): 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 run(array $arrHosts, string $strRunRoot, array $arrMeta = []): array
{
$arrHosts = array_values(array_unique(array_filter(array_map(static function (string $strHost): string {
return DomainModel::normalizeHost($strHost);
}, $arrHosts))));
self::ensureDir($strRunRoot);
file_put_contents($strRunRoot . '/hosts.txt', implode(PHP_EOL, $arrHosts) . PHP_EOL);
$arrItems = [];
$intPassedCount = 0;
$intFailedCount = 0;
foreach ($arrHosts as $strHost) {
$arrSampleResult = DomainAutoSampleHelper::discover($strHost, ['limit' => 80]);
if (($arrSampleResult['status'] ?? '') !== 'passed' || empty($arrSampleResult['sample'])) {
$arrItems[] = [
'host' => $strHost,
'status' => 'sample_discovery_failed',
'message' => (string)($arrSampleResult['message'] ?? ''),
'failed_stage' => 'sample',
'all_passed' => false,
'sample_result' => $arrSampleResult,
];
$intFailedCount++;
continue;
}
$arrProbeResult = DomainTrajectoryProbeHelper::probe($strHost, (array)$arrSampleResult['sample'], [
'base_url' => 'https://' . $strHost,
]);
$arrProbeResult['sample_result'] = $arrSampleResult;
$arrItems[] = $arrProbeResult;
if (!empty($arrProbeResult['all_passed'])) {
$intPassedCount++;
} else {
$intFailedCount++;
}
}
$arrSummary = [
'status' => $intFailedCount === 0 ? 'passed' : ($intPassedCount > 0 ? 'partial' : 'failed'),
'processed_count' => count($arrHosts),
'passed_count' => $intPassedCount,
'failed_count' => $intFailedCount,
'items' => $arrItems,
'meta' => $arrMeta,
'generated_at' => date(DATE_ATOM),
];
$strSummaryJsonPath = $strRunRoot . '/import-probe.summary.json';
$strSummaryHtmlPath = $strRunRoot . '/import-probe.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');
$arrChecks = [];
foreach ((array)($arrItem['checks'] ?? []) as $strKey => $arrCheck) {
$strCheckStage = DomainImportReportViewHelper::translateStage((string)$strKey);
$strCheckDetail = DomainImportReportViewHelper::translateCheckDetail((string)($arrCheck['detail'] ?? ''));
$strCheckStatus = DomainImportReportViewHelper::translateCheckStatus((int)($arrCheck['status'] ?? 0));
$arrChecks[] = htmlspecialchars(
$strCheckStage . '' . $strCheckDetail . '' . $strCheckStatus . '',
ENT_QUOTES,
'UTF-8'
);
}
$strChecks = implode('<br>', $arrChecks);
$arrRows[] = '<tr>'
. '<td>' . $strHost . '</td>'
. '<td>' . $strStatus . '</td>'
. '<td>' . $strFailedStage . '</td>'
. '<td>' . $strMessage . '</td>'
. '<td>' . $strChecks . '</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>状态:<code>' . htmlspecialchars(DomainImportReportViewHelper::translateStatus((string)($arrSummary['status'] ?? '')), 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><th>检查结果</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table></body></html>';
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportRemediationIndexHelper
{
protected static function publicRelativePath(string $strPath): string
{
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strPublicRoot . '/')) {
return substr($strPath, strlen($strPublicRoot . '/'));
}
return $strPath;
}
public static function buildSummary(string $strRunRoot, int $intLimit = 20): array
{
$strRunRoot = rtrim($strRunRoot, '/');
if ($strRunRoot === '' || !is_dir($strRunRoot)) {
return [
'items' => [],
'total' => 0,
'latest_run' => [],
];
}
$arrSummaryFiles = array_merge(
(array)glob($strRunRoot . '/*/import-remediation.summary.json'),
(array)glob($strRunRoot . '/*/*/import-remediation.summary.json')
);
$arrSummaryFiles = array_values(array_filter(array_unique($arrSummaryFiles), 'is_file'));
usort($arrSummaryFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
$arrItems = [];
foreach (array_slice($arrSummaryFiles, 0, max(1, $intLimit)) as $strSummaryPath) {
$arrData = json_decode((string)file_get_contents($strSummaryPath), true);
if (!is_array($arrData)) {
continue;
}
$strRunDir = dirname($strSummaryPath);
$arrItems[] = [
'run_id' => basename($strRunDir),
'run_root_public' => self::publicRelativePath($strRunDir),
'processed_count' => (int)($arrData['processed_count'] ?? 0),
'passed_count' => (int)($arrData['passed_count'] ?? 0),
'failed_count' => (int)($arrData['failed_count'] ?? 0),
'recovered_count' => (int)($arrData['recovered_count'] ?? 0),
'hosts' => array_values(array_filter(array_map(static function (array $arrItem): string {
return trim((string)($arrItem['host'] ?? ''));
}, (array)($arrData['items'] ?? [])))),
'summary_json_path' => self::publicRelativePath($strSummaryPath),
'summary_html_path' => self::publicRelativePath($strRunDir . '/import-remediation.summary.html'),
'updated_at' => date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time())),
];
}
return [
'items' => $arrItems,
'total' => count($arrItems),
'latest_run' => $arrItems[0] ?? [],
];
}
}

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,69 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportRerunIndexHelper
{
protected static function publicRelativePath(string $strPath): string
{
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strPublicRoot . '/')) {
return substr($strPath, strlen($strPublicRoot . '/'));
}
return $strPath;
}
public static function buildSummary(string $strRunRoot, int $intLimit = 20): array
{
$strRunRoot = rtrim($strRunRoot, '/');
if ($strRunRoot === '' || !is_dir($strRunRoot)) {
return [
'items' => [],
'total' => 0,
'latest_run' => [],
];
}
$arrSummaryFiles = array_merge(
(array)glob($strRunRoot . '/*/import-rerun.summary.json'),
(array)glob($strRunRoot . '/*/*/import-rerun.summary.json')
);
$arrSummaryFiles = array_values(array_filter(array_unique($arrSummaryFiles), 'is_file'));
usort($arrSummaryFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
$arrItems = [];
foreach (array_slice($arrSummaryFiles, 0, max(1, $intLimit)) as $strSummaryPath) {
$arrData = json_decode((string)file_get_contents($strSummaryPath), true);
if (!is_array($arrData)) {
continue;
}
$strRunDir = dirname($strSummaryPath);
$arrItems[] = [
'run_id' => basename($strRunDir),
'run_root_public' => self::publicRelativePath($strRunDir),
'processed_count' => (int)($arrData['processed_count'] ?? 0),
'passed_count' => (int)($arrData['passed_count'] ?? 0),
'failed_count' => (int)($arrData['failed_count'] ?? 0),
'hosts' => array_values(array_filter(array_map(static function (array $arrItem): string {
return trim((string)($arrItem['host'] ?? ''));
}, (array)($arrData['items'] ?? [])))),
'summary_json_path' => self::publicRelativePath($strSummaryPath),
'summary_html_path' => self::publicRelativePath($strRunDir . '/import-rerun.summary.html'),
'updated_at' => date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time())),
];
}
return [
'items' => $arrItems,
'total' => count($arrItems),
'latest_run' => $arrItems[0] ?? [],
];
}
}

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,86 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportRunIndexHelper
{
protected static function publicRelativePath(string $strPath): string
{
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strPublicRoot . '/')) {
return substr($strPath, strlen($strPublicRoot . '/'));
}
return $strPath;
}
public static function buildSummary(string $strRunRoot, int $intLimit = 20): array
{
$strRunRoot = rtrim($strRunRoot, '/');
if ($strRunRoot === '' || !is_dir($strRunRoot)) {
return [
'items' => [],
'total' => 0,
'latest_run' => [],
];
}
$arrSummaryFiles = array_merge(
(array)glob($strRunRoot . '/*/import-probe.summary.json'),
(array)glob($strRunRoot . '/*/*/import-probe.summary.json')
);
$arrSummaryFiles = array_values(array_filter(array_unique($arrSummaryFiles), 'is_file'));
usort($arrSummaryFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
$arrItems = [];
foreach (array_slice($arrSummaryFiles, 0, max(1, $intLimit)) as $strSummaryPath) {
$arrData = json_decode((string)file_get_contents($strSummaryPath), true);
if (!is_array($arrData)) {
continue;
}
$strRunDir = dirname($strSummaryPath);
$strRelativeRunRoot = self::publicRelativePath($strRunDir);
$arrItems[] = [
'run_id' => basename($strRunDir),
'run_root_public' => $strRelativeRunRoot,
'status' => 'imported',
'probe_status' => (string)($arrData['status'] ?? ''),
'processed_count' => (int)($arrData['processed_count'] ?? 0),
'passed_count' => (int)($arrData['passed_count'] ?? 0),
'failed_count' => (int)($arrData['failed_count'] ?? 0),
'hosts' => array_values(array_filter(array_map(static function (array $arrItem): string {
return trim((string)($arrItem['host'] ?? ''));
}, (array)($arrData['items'] ?? [])))),
'failed_hosts' => array_values(array_filter(array_map(static function (array $arrItem): string {
return empty($arrItem['all_passed']) ? trim((string)($arrItem['host'] ?? '')) : '';
}, (array)($arrData['items'] ?? [])))),
'summary_json_path' => self::publicRelativePath($strSummaryPath),
'summary_html_path' => self::publicRelativePath($strRunDir . '/import-probe.summary.html'),
'uploaded_excel_path' => self::resolveUploadedExcelPath($strRunDir),
'updated_at' => date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time())),
];
}
return [
'items' => $arrItems,
'total' => count($arrItems),
'latest_run' => $arrItems[0] ?? [],
];
}
protected static function resolveUploadedExcelPath(string $strRunDir): string
{
$arrMatches = array_values(array_filter((array)glob(rtrim($strRunDir, '/') . '/uploaded.*'), 'is_file'));
if (empty($arrMatches)) {
return '';
}
return self::publicRelativePath($arrMatches[0]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportSelfHealingIndexHelper
{
protected static function publicRelativePath(string $strPath): string
{
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$strPath = str_replace('\\', '/', $strPath);
if (str_starts_with($strPath, $strPublicRoot . '/')) {
return substr($strPath, strlen($strPublicRoot . '/'));
}
return $strPath;
}
public static function buildSummary(string $strRunRoot, int $intLimit = 20): array
{
$strRunRoot = rtrim($strRunRoot, '/');
if ($strRunRoot === '' || !is_dir($strRunRoot)) {
return [
'items' => [],
'total' => 0,
'latest_run' => [],
];
}
$arrSummaryFiles = array_merge(
(array)glob($strRunRoot . '/*/import-self-healing.summary.json'),
(array)glob($strRunRoot . '/*/*/import-self-healing.summary.json')
);
$arrSummaryFiles = array_values(array_filter(array_unique($arrSummaryFiles), 'is_file'));
usort($arrSummaryFiles, static function (string $strLeft, string $strRight): int {
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
});
$arrItems = [];
foreach (array_slice($arrSummaryFiles, 0, max(1, $intLimit)) as $strSummaryPath) {
$arrData = json_decode((string)file_get_contents($strSummaryPath), true);
if (!is_array($arrData)) {
continue;
}
$strRunDir = dirname($strSummaryPath);
$arrTrendSummary = (array)($arrData['trend_summary'] ?? []);
$arrPolicy = (array)($arrData['policy'] ?? []);
$arrItems[] = [
'run_id' => basename($strRunDir),
'run_root_public' => self::publicRelativePath($strRunDir),
'status' => (string)($arrData['status'] ?? ''),
'queue_count' => (int)($arrTrendSummary['queue_count'] ?? 0),
'selected_count' => (int)($arrPolicy['selected_count'] ?? 0),
'skipped_count' => (int)($arrPolicy['skipped_count'] ?? 0),
'downgraded_count' => (int)($arrPolicy['downgraded_count'] ?? 0),
'rerun_runs_count' => (int)($arrTrendSummary['rerun_runs_count'] ?? 0),
'remediation_runs_count' => (int)($arrTrendSummary['remediation_runs_count'] ?? 0),
'resolved_by_remediation_count' => (int)($arrTrendSummary['resolved_by_remediation_count'] ?? 0),
'summary_json_path' => self::publicRelativePath($strSummaryPath),
'summary_html_path' => self::publicRelativePath($strRunDir . '/import-self-healing.summary.html'),
'updated_at' => date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time())),
];
}
return [
'items' => $arrItems,
'total' => count($arrItems),
'latest_run' => $arrItems[0] ?? [],
];
}
}

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 : '影视站';
}
}

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