restore bootstrap and seo helper chain after rebase drop
This commit is contained in:
@@ -1,91 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\admin\controller\AdminUser;
|
||||
use app\admin\controller\Domain;
|
||||
use app\admin\controller\SeoSupply;
|
||||
use app\admin\controller\Novel;
|
||||
use app\admin\controller\Site;
|
||||
use app\admin\controller\SystemConfig;
|
||||
use app\admin\controller\Video;
|
||||
use app\admin\middleware\AdminAuth;
|
||||
use think\facade\Route;
|
||||
|
||||
Route::group("/guest", function () {
|
||||
Route::post("/login", "Guest/Login")->name("Guest@Login");
|
||||
Route::post("/logout", "Guest/Logout")->name("Guest@Logout");
|
||||
});
|
||||
|
||||
|
||||
# 系统配置
|
||||
Route::group("/system", function () {
|
||||
Route::get("/user/info", [AdminUser::class, "info"])->name("AdminUser@info");
|
||||
Route::get("/user/list", [AdminUser::class, "getAdminUserList"])->name("AdminUser@getAdminUserList");
|
||||
Route::post("/user/save", [AdminUser::class, "saveAdminUser"])->name("AdminUser@saveAdminUser");
|
||||
Route::post("/user/del", [AdminUser::class, "delAdminUser"])->name("AdminUser@delAdminUser");
|
||||
|
||||
Route::get("/config/list", [SystemConfig::class, "getSystemConfigList"])->name("SystemConfig@getSystemConfigList");
|
||||
Route::post("/config/save", [SystemConfig::class, "saveSystemConfig"])->name("SystemConfig@saveSystemConfig");
|
||||
|
||||
Route::get("/cjpz/list", [SystemConfig::class, "getCaiJiPeiZhiList"])->name("SystemConfig@getCaiJiPeiZhiList");
|
||||
Route::post("/cjpz/save", [SystemConfig::class, "saveCaiJiPeiZhi"])->name("SystemConfig@saveCaiJiPeiZhi");
|
||||
|
||||
Route::get("/plan/list", [SystemConfig::class, "getPlanTaskList"])->name("SystemConfig@getPlanTaskList");
|
||||
Route::post("/plan/save", [SystemConfig::class, "savePlanTask"])->name("SystemConfig@savePlanTask");
|
||||
Route::post("/cache/flush", [SystemConfig::class, "flushCache"])->name("SystemConfig@flushCache");
|
||||
})->middleware(AdminAuth::class);
|
||||
|
||||
# 小说
|
||||
Route::group("/novel", function () {
|
||||
Route::get("/list", [Novel::class, "getNovelList"])->name("Domain@getNovelList");
|
||||
Route::post("/level/set", [Novel::class, "updateNovelLevel"])->name("Novel@updateNovelLevel");
|
||||
Route::get("/category/list", [Novel::class, "getCategory"])->name("Novel@getCategory");
|
||||
})->middleware(AdminAuth::class);
|
||||
|
||||
# 视频
|
||||
Route::group("/video", function () {
|
||||
Route::get("/list", [Video::class, "getVideoList"])->name("Domain@getVideoList");
|
||||
Route::post("/level/set", [Video::class, "updateVideoLevel"])->name("Video@updateVideoLevel");
|
||||
Route::post("/boxoffice/set", [Video::class, "updateBoxOffice"])->name("Video@updateBoxOffice");
|
||||
Route::get("/category/list", [Video::class, "getCategory"])->name("Video@getCategory");
|
||||
})->middleware(AdminAuth::class);
|
||||
|
||||
# 广告
|
||||
Route::group("/guanggao", function () {
|
||||
# 主
|
||||
Route::get("/main/group/list", "GuangGao/getGuangGaoFenZuList")->name("GuangGao@getGuangGaoFenZuList");
|
||||
Route::post("/main/group/save", "GuangGao/saveGuangGaoFenZu")->name("GuangGao@saveGuangGaoFenZu");
|
||||
Route::post("/main/group/del", "GuangGao/delGuangGaoFenZu")->name("GuangGao@delGuangGaoFenZu");
|
||||
Route::get("/main/list", "GuangGao/getGuangGaoList")->name("GuangGao@getGuangGaoList");
|
||||
Route::post("/main/save", "GuangGao/saveGuangGao")->name("GuangGao@saveGuangGao");
|
||||
Route::post("/main/del", "GuangGao/delGuangGao")->name("GuangGao@delGuangGao");
|
||||
|
||||
# 广告配置
|
||||
Route::get("/ggpz/list", "GuangGao/getGuangGaoPeiZhiList")->name("GuangGao@getGuangGaoPeiZhiList");
|
||||
Route::post("/ggpz/save", "GuangGao/saveGuangGaoPeiZhi")->name("GuangGao@saveGuangGaoPeiZhi");
|
||||
|
||||
})->middleware(AdminAuth::class);
|
||||
|
||||
# 站点
|
||||
use app\admin\middleware\AdminAuth;
|
||||
use think\facade\Route;
|
||||
|
||||
Route::group("/guest", function () {
|
||||
Route::post("/login", "Guest/Login")->name("Guest@Login");
|
||||
Route::post("/logout", "Guest/Logout")->name("Guest@Logout");
|
||||
});
|
||||
|
||||
|
||||
# 系统配置
|
||||
Route::group("/system", function () {
|
||||
Route::get("/user/info", [AdminUser::class, "info"])->name("AdminUser@info");
|
||||
Route::get("/user/list", [AdminUser::class, "getAdminUserList"])->name("AdminUser@getAdminUserList");
|
||||
Route::post("/user/save", [AdminUser::class, "saveAdminUser"])->name("AdminUser@saveAdminUser");
|
||||
Route::post("/user/del", [AdminUser::class, "delAdminUser"])->name("AdminUser@delAdminUser");
|
||||
|
||||
Route::get("/config/list", [SystemConfig::class, "getSystemConfigList"])->name("SystemConfig@getSystemConfigList");
|
||||
Route::post("/config/save", [SystemConfig::class, "saveSystemConfig"])->name("SystemConfig@saveSystemConfig");
|
||||
|
||||
Route::get("/cjpz/list", [SystemConfig::class, "getCaiJiPeiZhiList"])->name("SystemConfig@getCaiJiPeiZhiList");
|
||||
Route::post("/cjpz/save", [SystemConfig::class, "saveCaiJiPeiZhi"])->name("SystemConfig@saveCaiJiPeiZhi");
|
||||
|
||||
Route::get("/plan/list", [SystemConfig::class, "getPlanTaskList"])->name("SystemConfig@getPlanTaskList");
|
||||
Route::post("/plan/save", [SystemConfig::class, "savePlanTask"])->name("SystemConfig@savePlanTask");
|
||||
Route::post("/cache/flush", [SystemConfig::class, "flushCache"])->name("SystemConfig@flushCache");
|
||||
})->middleware(AdminAuth::class);
|
||||
|
||||
# 小说
|
||||
Route::group("/novel", function () {
|
||||
Route::get("/list", [Novel::class, "getNovelList"])->name("Domain@getNovelList");
|
||||
Route::post("/level/set", [Novel::class, "updateNovelLevel"])->name("Novel@updateNovelLevel");
|
||||
Route::get("/category/list", [Novel::class, "getCategory"])->name("Novel@getCategory");
|
||||
})->middleware(AdminAuth::class);
|
||||
|
||||
# 视频
|
||||
Route::group("/video", function () {
|
||||
Route::get("/list", [Video::class, "getVideoList"])->name("Domain@getVideoList");
|
||||
Route::post("/level/set", [Video::class, "updateVideoLevel"])->name("Video@updateVideoLevel");
|
||||
Route::post("/boxoffice/set", [Video::class, "updateBoxOffice"])->name("Video@updateBoxOffice");
|
||||
Route::get("/category/list", [Video::class, "getCategory"])->name("Video@getCategory");
|
||||
})->middleware(AdminAuth::class);
|
||||
|
||||
# 广告
|
||||
Route::group("/guanggao", function () {
|
||||
# 主
|
||||
Route::get("/main/group/list", "GuangGao/getGuangGaoFenZuList")->name("GuangGao@getGuangGaoFenZuList");
|
||||
Route::post("/main/group/save", "GuangGao/saveGuangGaoFenZu")->name("GuangGao@saveGuangGaoFenZu");
|
||||
Route::post("/main/group/del", "GuangGao/delGuangGaoFenZu")->name("GuangGao@delGuangGaoFenZu");
|
||||
Route::get("/main/list", "GuangGao/getGuangGaoList")->name("GuangGao@getGuangGaoList");
|
||||
Route::post("/main/save", "GuangGao/saveGuangGao")->name("GuangGao@saveGuangGao");
|
||||
Route::post("/main/del", "GuangGao/delGuangGao")->name("GuangGao@delGuangGao");
|
||||
|
||||
# 广告配置
|
||||
Route::get("/ggpz/list", "GuangGao/getGuangGaoPeiZhiList")->name("GuangGao@getGuangGaoPeiZhiList");
|
||||
Route::post("/ggpz/save", "GuangGao/saveGuangGaoPeiZhi")->name("GuangGao@saveGuangGaoPeiZhi");
|
||||
|
||||
})->middleware(AdminAuth::class);
|
||||
|
||||
# 站点
|
||||
Route::group("/site", function () {
|
||||
|
||||
|
||||
# 域名管理
|
||||
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/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");
|
||||
|
||||
# TKD 参数模板
|
||||
Route::get("/tkdarg/list", [Site::class, "getTKDArgList"])->name("Site@getTKDArgList");
|
||||
|
||||
# 视图模板
|
||||
Route::get("/template/list", [Site::class, "getTemplateList"])->name("Site@getTKDArgList");
|
||||
|
||||
# 替换模板管理
|
||||
Route::get("/subject/fomart/group/list", [Site::class, "getSubjectFomartGroupList"])->name("Site@getSubjectFomartGroupList");
|
||||
Route::post("/subject/fomart/group/save", [Site::class, "saveSubjectFomartGroup"])->name("Site@saveSubjectFomartGroup");
|
||||
Route::post("/subject/fomart/group/del", [Site::class, "delSubjectFomartGroup"])->name("Site@delSubjectFomartGroup");
|
||||
|
||||
Route::get("/subject/fomart/list", [Site::class, "getSubjectFomartList"])->name("Site@getSubjectFomartList");
|
||||
Route::post("/subject/fomart/save", [Site::class, "saveSubjectFomart"])->name("Site@saveSubjectFomart");
|
||||
Route::get("/tkdarg/list", [Site::class, "getTKDArgList"])->name("Site@getTKDArgList");
|
||||
|
||||
# 视图模板
|
||||
Route::get("/template/list", [Site::class, "getTemplateList"])->name("Site@getTKDArgList");
|
||||
|
||||
# 替换模板管理
|
||||
Route::get("/subject/fomart/group/list", [Site::class, "getSubjectFomartGroupList"])->name("Site@getSubjectFomartGroupList");
|
||||
Route::post("/subject/fomart/group/save", [Site::class, "saveSubjectFomartGroup"])->name("Site@saveSubjectFomartGroup");
|
||||
Route::post("/subject/fomart/group/del", [Site::class, "delSubjectFomartGroup"])->name("Site@delSubjectFomartGroup");
|
||||
|
||||
Route::get("/subject/fomart/list", [Site::class, "getSubjectFomartList"])->name("Site@getSubjectFomartList");
|
||||
Route::post("/subject/fomart/save", [Site::class, "saveSubjectFomart"])->name("Site@saveSubjectFomart");
|
||||
Route::post("/subject/fomart/del", [Site::class, "delSubjectFomart"])->name("Site@delSubjectFomart");
|
||||
})->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);
|
||||
|
||||
390
code/app/admin/controller/SeoSupply.php
Normal file
390
code/app/admin/controller/SeoSupply.php
Normal 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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,39 +3,53 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\api\controller\Novel;
|
||||
use app\api\controller\SeoCollector;
|
||||
use app\api\controller\Statis;
|
||||
use app\api\middleware\SeoCollectorTokenAuth;
|
||||
use think\facade\Route;
|
||||
|
||||
|
||||
Route::post("/publish", [Statis::class, 'publish'])
|
||||
// ->middleware(\think\middleware\Throttle::class, [
|
||||
// 'visit_rate' => '1/m',
|
||||
// 'key' => '__CONTROLLER__/__ACTION__/__IP__',
|
||||
// ])
|
||||
->name('Statis@publish');
|
||||
|
||||
|
||||
// Route::get("/search", [Novel::class, 'search'])
|
||||
// ->middleware(\think\middleware\Throttle::class, [
|
||||
// 'visit_rate' => '1/m',
|
||||
// 'key' => '__CONTROLLER__/__ACTION__/__IP__',
|
||||
// ])
|
||||
// ->name('Novel@search');
|
||||
|
||||
|
||||
|
||||
|
||||
Route::post("/publishv", [Statis::class, 'publishV'])
|
||||
// ->middleware(\think\middleware\Throttle::class, [
|
||||
// 'visit_rate' => '1/m',
|
||||
// 'key' => '__CONTROLLER__/__ACTION__/__IP__',
|
||||
// ])
|
||||
->name('Statis@publishV');
|
||||
|
||||
|
||||
|
||||
|
||||
Route::post("/publish", [Statis::class, 'publish'])
|
||||
// ->middleware(\think\middleware\Throttle::class, [
|
||||
// 'visit_rate' => '1/m',
|
||||
// 'key' => '__CONTROLLER__/__ACTION__/__IP__',
|
||||
// ])
|
||||
->name('Statis@publish');
|
||||
|
||||
|
||||
// Route::get("/search", [Novel::class, 'search'])
|
||||
// ->middleware(\think\middleware\Throttle::class, [
|
||||
// 'visit_rate' => '1/m',
|
||||
// 'key' => '__CONTROLLER__/__ACTION__/__IP__',
|
||||
// ])
|
||||
// ->name('Novel@search');
|
||||
|
||||
|
||||
|
||||
|
||||
Route::post("/publishv", [Statis::class, 'publishV'])
|
||||
// ->middleware(\think\middleware\Throttle::class, [
|
||||
// 'visit_rate' => '1/m',
|
||||
// 'key' => '__CONTROLLER__/__ACTION__/__IP__',
|
||||
// ])
|
||||
->name('Statis@publishV');
|
||||
|
||||
|
||||
Route::get("/test0909", [Statis::class, 'test0909'])
|
||||
// ->middleware(\think\middleware\Throttle::class, [
|
||||
// 'visit_rate' => '1/m',
|
||||
// 'key' => '__CONTROLLER__/__ACTION__/__IP__',
|
||||
// ])
|
||||
->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);
|
||||
|
||||
304
code/app/api/controller/SeoCollector.php
Normal file
304
code/app/api/controller/SeoCollector.php
Normal 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->publicRoot() . '/_admin_templates/domain-seo-external/latest';
|
||||
}
|
||||
|
||||
protected function snapshotRunRoot(): string
|
||||
{
|
||||
return $this->publicRoot() . '/_admin_templates/domain-seo-external/snapshot-runs';
|
||||
}
|
||||
|
||||
protected function snapshotValidateRunRoot(): string
|
||||
{
|
||||
return $this->publicRoot() . '/_admin_templates/domain-seo-external/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->publicRoot(),
|
||||
$runRoot,
|
||||
$result,
|
||||
(array)$snapshots
|
||||
);
|
||||
$result['run_root'] = $this->publicRelativePath($runRoot);
|
||||
$result['summary_json_path'] = $this->publicRelativePath((string)($result['summary_json_path'] ?? ''));
|
||||
$result['summary_html_path'] = $this->publicRelativePath((string)($result['summary_html_path'] ?? ''));
|
||||
$result['payload_json_path'] = $this->publicRelativePath((string)($result['payload_json_path'] ?? ''));
|
||||
|
||||
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->publicRoot(),
|
||||
$runRoot,
|
||||
$result,
|
||||
(array)$snapshots
|
||||
);
|
||||
$result['run_root'] = $this->publicRelativePath($runRoot);
|
||||
$result['summary_json_path'] = $this->publicRelativePath((string)($result['summary_json_path'] ?? ''));
|
||||
$result['summary_html_path'] = $this->publicRelativePath((string)($result['summary_html_path'] ?? ''));
|
||||
$result['payload_json_path'] = $this->publicRelativePath((string)($result['payload_json_path'] ?? ''));
|
||||
|
||||
$overview = DomainExternalSeoCnOverviewHelper::buildOverview(14, 10);
|
||||
$overview = DomainExternalSeoCnOverviewHelper::writeArtifacts($this->summaryRoot(), $overview);
|
||||
$overview['summary_json_path'] = $this->publicRelativePath((string)($overview['summary_json_path'] ?? ''));
|
||||
$overview['summary_html_path'] = $this->publicRelativePath((string)($overview['summary_html_path'] ?? ''));
|
||||
|
||||
$snapshotTrend = DomainExternalSeoSnapshotTrendHelper::buildSummary($this->snapshotRunRoot(), 10);
|
||||
$snapshotTrend = DomainExternalSeoSnapshotTrendHelper::writeArtifacts($this->summaryRoot(), $snapshotTrend);
|
||||
$snapshotTrend['summary_json_path'] = $this->publicRelativePath((string)($snapshotTrend['summary_json_path'] ?? ''));
|
||||
$snapshotTrend['summary_html_path'] = $this->publicRelativePath((string)($snapshotTrend['summary_html_path'] ?? ''));
|
||||
|
||||
$result['post_ingest_overview'] = $overview;
|
||||
$result['post_ingest_snapshot_trend'] = $snapshotTrend;
|
||||
$result = DomainExternalSeoSnapshotRunHelper::writeRunArtifacts(
|
||||
$this->publicRoot(),
|
||||
$runRoot,
|
||||
$result,
|
||||
(array)$snapshots
|
||||
);
|
||||
$result['summary_json_path'] = $this->publicRelativePath((string)($result['summary_json_path'] ?? ''));
|
||||
$result['summary_html_path'] = $this->publicRelativePath((string)($result['summary_html_path'] ?? ''));
|
||||
$result['payload_json_path'] = $this->publicRelativePath((string)($result['payload_json_path'] ?? ''));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
213
code/app/common/helper/DomainBatchImportTemplateHelper.php
Normal file
213
code/app/common/helper/DomainBatchImportTemplateHelper.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?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 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 [
|
||||
'd_domain',
|
||||
't_id',
|
||||
'd_name',
|
||||
'd_keywords',
|
||||
'd_index_title',
|
||||
'd_index_keywords',
|
||||
'd_index_description',
|
||||
'd_description',
|
||||
'd_statis',
|
||||
'd_logo_type',
|
||||
'd_text_logo',
|
||||
'd_img_logo',
|
||||
'd_content_encode',
|
||||
'info_id',
|
||||
'd_match_type',
|
||||
'd_parent_domain',
|
||||
'd_seed_scope',
|
||||
'd_baidu_token',
|
||||
'd_seo_cfg',
|
||||
'strategy_profile',
|
||||
'tkd_mode',
|
||||
'tkd_provider',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getColumnNotes(): array
|
||||
{
|
||||
return [
|
||||
'd_domain' => '必填,站点域名;wildcard 模式可填 *.example.com 或 example.com,由后端归一化。',
|
||||
't_id' => '必填,模板 ID。',
|
||||
'd_name' => '必填,站点名称。',
|
||||
'd_keywords' => '可选,站点关键词。',
|
||||
'd_index_title' => '可选,首页标题。',
|
||||
'd_index_keywords' => '可选,首页关键词。',
|
||||
'd_index_description' => '可选,首页描述。',
|
||||
'd_description' => '可选,站点描述。',
|
||||
'd_statis' => '可选,统计代码。',
|
||||
'd_logo_type' => '必填,0 或 1。',
|
||||
'd_text_logo' => '可选,文字 LOGO。',
|
||||
'd_img_logo' => '可选,图片 LOGO。',
|
||||
'd_content_encode' => '必填,通常填 0。',
|
||||
'info_id' => '可选,通常填 0。',
|
||||
'd_match_type' => '可选,exact 或 wildcard。',
|
||||
'd_parent_domain' => 'wildcard 模式建议填写根域名。',
|
||||
'd_seed_scope' => '可选,domain 或 host。',
|
||||
'd_baidu_token' => '可选,百度推送 token。',
|
||||
'd_seo_cfg' => '可选,JSON;会在 strategy_profile 默认值基础上再做覆盖。',
|
||||
'strategy_profile' => '可选,standard / traffic / expand / closed。',
|
||||
'tkd_mode' => '可选,1/auto_generate=全自动生成;2/ai_optimize=导入 TKD 后本地/AI 优化;3/force_import=强制使用导入值。',
|
||||
'tkd_provider' => '可选,local 或 openai;当前默认 local,本地优化已生效,后续可接 OpenAI API。',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getExampleRows(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'demo-example-com',
|
||||
1,
|
||||
'Demo Example',
|
||||
'Demo Example,影视推荐',
|
||||
'Demo Example 影视内容推荐',
|
||||
'Demo Example,影视推荐',
|
||||
'Demo Example 内容整理与推荐。',
|
||||
'Demo Example 内容整理与推荐。',
|
||||
'',
|
||||
0,
|
||||
'Demo Example',
|
||||
'',
|
||||
0,
|
||||
0,
|
||||
'exact',
|
||||
'',
|
||||
'domain',
|
||||
'',
|
||||
'',
|
||||
'standard',
|
||||
'1',
|
||||
'local',
|
||||
],
|
||||
[
|
||||
'*.traffic-example.com',
|
||||
1,
|
||||
'Traffic Example',
|
||||
'Traffic Example,热门推荐',
|
||||
'Traffic Example 热门内容推荐',
|
||||
'Traffic Example,热门推荐',
|
||||
'Traffic Example 热门内容整理与推荐。',
|
||||
'Traffic Example 热门内容整理与推荐。',
|
||||
'',
|
||||
0,
|
||||
'Traffic Example',
|
||||
'',
|
||||
0,
|
||||
0,
|
||||
'wildcard',
|
||||
'traffic-example.com',
|
||||
'host',
|
||||
'',
|
||||
'{"forge":{"route_group":"film"},"structured_data":{"organization_name":"Traffic Example Studio"}}',
|
||||
'traffic',
|
||||
'2',
|
||||
'local',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildSummary(): array
|
||||
{
|
||||
return [
|
||||
'headers' => self::getHeaders(),
|
||||
'column_notes' => self::getColumnNotes(),
|
||||
'example_rows' => self::getExampleRows(),
|
||||
'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();
|
||||
$rows = array_map(
|
||||
static function (array $row): array {
|
||||
return array_combine(self::getHeaders(), $row) ?: [];
|
||||
},
|
||||
self::getExampleRows()
|
||||
);
|
||||
|
||||
self::writeWorkbook(
|
||||
$filePath,
|
||||
'site-domain-import',
|
||||
self::getHeaders(),
|
||||
$rows,
|
||||
self::getColumnNotes()
|
||||
);
|
||||
|
||||
return [
|
||||
'file_name' => $fileName,
|
||||
'file_path' => $filePath,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
}
|
||||
132
code/app/common/helper/DomainBootstrapApplyHelper.php
Normal file
132
code/app/common/helper/DomainBootstrapApplyHelper.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?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();
|
||||
|
||||
$arrSummary['status'] = 'applied';
|
||||
return $arrSummary;
|
||||
}
|
||||
}
|
||||
216
code/app/common/helper/DomainBootstrapBatchTemplateHelper.php
Normal file
216
code/app/common/helper/DomainBootstrapBatchTemplateHelper.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
1102
code/app/common/helper/DomainBootstrapBundleIndexHelper.php
Normal file
1102
code/app/common/helper/DomainBootstrapBundleIndexHelper.php
Normal file
File diff suppressed because it is too large
Load Diff
169
code/app/common/helper/DomainBootstrapCompletedHelper.php
Normal file
169
code/app/common/helper/DomainBootstrapCompletedHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
225
code/app/common/helper/DomainBootstrapDutyHelper.php
Normal file
225
code/app/common/helper/DomainBootstrapDutyHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
821
code/app/common/helper/DomainBootstrapEnvTemplateHelper.php
Normal file
821
code/app/common/helper/DomainBootstrapEnvTemplateHelper.php
Normal file
@@ -0,0 +1,821 @@
|
||||
<?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) {
|
||||
$arrAssignments = (array)(self::parseTemplateAssignments((string)($arrTemplate['path'] ?? ''))['assignments'] ?? []);
|
||||
$arrCompare = self::compareTemplateWithCurrent($arrCurrentMap, $arrAssignments, $arrAllowedKeys);
|
||||
$arrTemplates[] = [
|
||||
'key' => $strKey,
|
||||
'name' => (string)($arrTemplate['name'] ?? $strKey),
|
||||
'path' => (string)($arrTemplate['path'] ?? ''),
|
||||
'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' => 'drift',
|
||||
'state_label' => '存在偏移',
|
||||
];
|
||||
}
|
||||
|
||||
usort($arrTemplates, static function (array $arrLeft, array $arrRight): int {
|
||||
$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) {
|
||||
$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;
|
||||
$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,
|
||||
];
|
||||
}
|
||||
}
|
||||
235
code/app/common/helper/DomainBootstrapExecutionHelper.php
Normal file
235
code/app/common/helper/DomainBootstrapExecutionHelper.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
189
code/app/common/helper/DomainBootstrapHandoffHelper.php
Normal file
189
code/app/common/helper/DomainBootstrapHandoffHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
83
code/app/common/helper/DomainBootstrapImportHelper.php
Normal file
83
code/app/common/helper/DomainBootstrapImportHelper.php
Normal 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 结果目录是否正确。",
|
||||
};
|
||||
}
|
||||
}
|
||||
190
code/app/common/helper/DomainBootstrapOncallHelper.php
Normal file
190
code/app/common/helper/DomainBootstrapOncallHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
488
code/app/common/helper/DomainBootstrapOncallService.php
Normal file
488
code/app/common/helper/DomainBootstrapOncallService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
137
code/app/common/helper/DomainBootstrapPipelineHelper.php
Normal file
137
code/app/common/helper/DomainBootstrapPipelineHelper.php
Normal 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, '-');
|
||||
}
|
||||
}
|
||||
72
code/app/common/helper/DomainBootstrapPublishHelper.php
Normal file
72
code/app/common/helper/DomainBootstrapPublishHelper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
185
code/app/common/helper/DomainBootstrapRegisterHelper.php
Normal file
185
code/app/common/helper/DomainBootstrapRegisterHelper.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?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 = [];
|
||||
}
|
||||
|
||||
$strTkdModeInput = trim((string)($arrPayload['tkd_mode'] ?? $arrPayload['seo_tkd_mode'] ?? ($arrSeoCfgInput['tkd']['mode'] ?? '')));
|
||||
if ($strTkdModeInput === '') {
|
||||
$strTkdModeInput = $boolHasImportedHomeTkd ? DomainModel::TKD_MODE_FORCE_IMPORT : DomainModel::TKD_MODE_AUTO_GENERATE;
|
||||
}
|
||||
$strTkdProviderInput = trim((string)($arrPayload['tkd_provider'] ?? $arrPayload['seo_tkd_provider'] ?? ($arrSeoCfgInput['tkd']['provider'] ?? DomainModel::TKD_PROVIDER_LOCAL)));
|
||||
$arrSeoCfgInput['tkd'] = array_merge(
|
||||
(array)($arrSeoCfgInput['tkd'] ?? []),
|
||||
[
|
||||
'mode' => DomainModel::normalizeTkdMode($strTkdModeInput),
|
||||
'provider' => DomainModel::normalizeTkdProvider($strTkdProviderInput),
|
||||
]
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
$arrSummary['status'] = 'created';
|
||||
$arrSummary['found'] = true;
|
||||
$arrSummary['domain_id'] = (int)$DomainModel->d_id;
|
||||
|
||||
return $arrSummary;
|
||||
}
|
||||
}
|
||||
150
code/app/common/helper/DomainBootstrapRunHelper.php
Normal file
150
code/app/common/helper/DomainBootstrapRunHelper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
445
code/app/common/helper/DomainBootstrapRunIndexHelper.php
Normal file
445
code/app/common/helper/DomainBootstrapRunIndexHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
183
code/app/common/helper/DomainBootstrapRunService.php
Normal file
183
code/app/common/helper/DomainBootstrapRunService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,不新造任务配置链。',
|
||||
];
|
||||
}
|
||||
}
|
||||
174
code/app/common/helper/DomainExternalSeoBaiduPushTrendHelper.php
Normal file
174
code/app/common/helper/DomainExternalSeoBaiduPushTrendHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
153
code/app/common/helper/DomainExternalSeoCnOverviewHelper.php
Normal file
153
code/app/common/helper/DomainExternalSeoCnOverviewHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 校验和正式快照接收放到同一视角,方便先判断问题卡在字段校验还是正式回推阶段。',
|
||||
];
|
||||
}
|
||||
}
|
||||
377
code/app/common/helper/DomainExternalSeoManualImportHelper.php
Normal file
377
code/app/common/helper/DomainExternalSeoManualImportHelper.php
Normal 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, '/') . '/public/_admin_templates/domain-seo-external/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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class DomainExternalSeoManualImportRunIndexHelper
|
||||
{
|
||||
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 $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::publicRelativePath($strRunRoot),
|
||||
'status' => (string)($arrSummary['status'] ?? 'empty'),
|
||||
'provider_key' => (string)($arrSummary['provider_key'] ?? 'manual_csv_import'),
|
||||
'rows_total' => self::countCsvRows($strCsvPath),
|
||||
'hosts_count' => self::countCsvHosts($strCsvPath),
|
||||
'metrics' => (array)($arrSummary['metrics'] ?? []),
|
||||
'uploaded_report_path' => $strUploadedPath !== '' ? self::publicRelativePath($strUploadedPath) : '',
|
||||
'csv_path' => is_file($strCsvPath) ? self::publicRelativePath($strCsvPath) : '',
|
||||
'summary_json_path' => self::publicRelativePath($strSummaryPath),
|
||||
'summary_html_path' => is_file($strRunRoot . '/seo-external-real.summary.html') ? self::publicRelativePath($strRunRoot . '/seo-external-real.summary.html') : '',
|
||||
'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::publicRelativePath($strLatestDir),
|
||||
'status' => (string)($arrSummary['status'] ?? 'empty'),
|
||||
'provider_key' => (string)($arrSummary['provider_key'] ?? 'manual_csv_import'),
|
||||
'rows_total' => self::countCsvRows($strLatestDir . '/manual-seo-import.csv'),
|
||||
'hosts_count' => self::countCsvHosts($strLatestDir . '/manual-seo-import.csv'),
|
||||
'metrics' => (array)($arrSummary['metrics'] ?? []),
|
||||
'uploaded_report_path' => '',
|
||||
'csv_path' => self::publicRelativePath($strLatestDir . '/manual-seo-import.csv'),
|
||||
'summary_json_path' => self::publicRelativePath($strLatestSummaryJsonPath),
|
||||
'summary_html_path' => self::publicRelativePath(dirname($strRoot) . '/latest/seo-external-real.summary.html'),
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@@ -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: 收录变化、爱站关键词升降和历史快照摘要自动生成,用来给批量域名场景提供第一版操作建议。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 不再作为当前默认主线。',
|
||||
];
|
||||
}
|
||||
}
|
||||
327
code/app/common/helper/DomainExternalSeoRealSummaryHelper.php
Normal file
327
code/app/common/helper/DomainExternalSeoRealSummaryHelper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
283
code/app/common/helper/DomainExternalSeoSiteQueryProbeHelper.php
Normal file
283
code/app/common/helper/DomainExternalSeoSiteQueryProbeHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
<?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 = [];
|
||||
$arrLatestIndexByHost = [];
|
||||
$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', 'host', 'scope', 'metric_date', 'status', 'indexed_status', 'baidu_pc_ip_range', 'baidu_mobile_ip_range', 'pc_keyword_count', 'mobile_keyword_count', 'queried_at'],
|
||||
static function (array $arrItem) use (&$arrProviders, &$arrHosts, &$arrLatestIndexByHost, &$strLatestMetricDate): void {
|
||||
$strProvider = trim((string)($arrItem['provider'] ?? ''));
|
||||
$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 !== '') {
|
||||
$strHostKey = $strHost;
|
||||
$arrCurrent = $arrLatestIndexByHost[$strHostKey] ?? null;
|
||||
$boolShouldReplace = false;
|
||||
if (!$arrCurrent) {
|
||||
$boolShouldReplace = true;
|
||||
} else {
|
||||
$strCurrentDate = (string)($arrCurrent['metric_date'] ?? '');
|
||||
$intCurrentQueriedAt = (int)($arrCurrent['queried_at'] ?? 0);
|
||||
if (strcmp($strCurrentDate, $strMetricDate) < 0) {
|
||||
$boolShouldReplace = true;
|
||||
} elseif ($strCurrentDate === $strMetricDate && $intCurrentQueriedAt < $intQueriedAt) {
|
||||
$boolShouldReplace = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($boolShouldReplace) {
|
||||
$arrLatestIndexByHost[$strHostKey] = [
|
||||
'host' => $strHost,
|
||||
'provider' => $strProvider,
|
||||
'metric_date' => $strMetricDate,
|
||||
'queried_at' => $intQueriedAt,
|
||||
'status' => $strStatus,
|
||||
'indexed_status' => $strIndexedStatus,
|
||||
'baidu_pc_ip_range' => trim((string)($arrItem['baidu_pc_ip_range'] ?? '')),
|
||||
'baidu_mobile_ip_range' => trim((string)($arrItem['baidu_mobile_ip_range'] ?? '')),
|
||||
'pc_keyword_count' => (int)($arrItem['pc_keyword_count'] ?? 0),
|
||||
'mobile_keyword_count' => (int)($arrItem['mobile_keyword_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
},
|
||||
1000
|
||||
);
|
||||
|
||||
$intIndexedLikeCount = 0;
|
||||
$intKeywordReadyCount = 0;
|
||||
$intTrafficReadyCount = 0;
|
||||
$arrHostRows = array_values($arrLatestIndexByHost);
|
||||
|
||||
foreach ($arrHostRows as &$arrRow) {
|
||||
$boolIndexedLike = (string)($arrRow['indexed_status'] ?? '') === '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['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;
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
268
code/app/common/helper/DomainExternalSeoSnapshotHelper.php
Normal file
268
code/app/common/helper/DomainExternalSeoSnapshotHelper.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
113
code/app/common/helper/DomainExternalSeoSnapshotRunHelper.php
Normal file
113
code/app/common/helper/DomainExternalSeoSnapshotRunHelper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class DomainExternalSeoSnapshotRunIndexHelper
|
||||
{
|
||||
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 $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::publicRelativePath($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::publicRelativePath($strSummaryPath),
|
||||
'summary_html_path' => self::publicRelativePath($strRunRoot . '/snapshot-ingest.summary.html'),
|
||||
'payload_json_path' => self::publicRelativePath($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'];
|
||||
}
|
||||
}
|
||||
191
code/app/common/helper/DomainExternalSeoSnapshotSchemaHelper.php
Normal file
191
code/app/common/helper/DomainExternalSeoSnapshotSchemaHelper.php
Normal 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));
|
||||
}
|
||||
}
|
||||
127
code/app/common/helper/DomainExternalSeoSnapshotTrendHelper.php
Normal file
127
code/app/common/helper/DomainExternalSeoSnapshotTrendHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class DomainExternalSeoSnapshotValidateRunIndexHelper
|
||||
{
|
||||
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 $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::publicRelativePath($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::publicRelativePath($strSummaryPath),
|
||||
'summary_html_path' => self::publicRelativePath($strRunRoot . '/snapshot-validate.summary.html'),
|
||||
'payload_json_path' => self::publicRelativePath($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'];
|
||||
}
|
||||
}
|
||||
303
code/app/common/helper/DomainExternalSeoSummaryHelper.php
Normal file
303
code/app/common/helper/DomainExternalSeoSummaryHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
140
code/app/common/helper/DomainExternalSeoTrendHelper.php
Normal file
140
code/app/common/helper/DomainExternalSeoTrendHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
162
code/app/common/helper/DomainImportExternalSeoClosureHelper.php
Normal file
162
code/app/common/helper/DomainImportExternalSeoClosureHelper.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
267
code/app/common/helper/DomainImportFailedQueueHelper.php
Normal file
267
code/app/common/helper/DomainImportFailedQueueHelper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
96
code/app/common/helper/DomainImportHealthTrendHelper.php
Normal file
96
code/app/common/helper/DomainImportHealthTrendHelper.php
Normal 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')),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
355
code/app/common/helper/DomainImportHealthWorkbenchHelper.php
Normal file
355
code/app/common/helper/DomainImportHealthWorkbenchHelper.php
Normal 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(
|
||||
'最新人工处理 %s,Host %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 '导入主线整体平稳,可以先看最新导入与自动修复结果。';
|
||||
}
|
||||
}
|
||||
@@ -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] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? []);
|
||||
}
|
||||
}
|
||||
133
code/app/common/helper/DomainImportProbeRunHelper.php
Normal file
133
code/app/common/helper/DomainImportProbeRunHelper.php
Normal 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>';
|
||||
}
|
||||
}
|
||||
@@ -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] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
69
code/app/common/helper/DomainImportRerunIndexHelper.php
Normal file
69
code/app/common/helper/DomainImportRerunIndexHelper.php
Normal 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] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
86
code/app/common/helper/DomainImportRunIndexHelper.php
Normal file
86
code/app/common/helper/DomainImportRunIndexHelper.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
158
code/app/common/helper/DomainSpiderCrawlLogHelper.php
Normal file
158
code/app/common/helper/DomainSpiderCrawlLogHelper.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class DomainSpiderCrawlLogHelper
|
||||
{
|
||||
public static function ingestPayload(array $arrPayload, string $strSource = 'frontend_nginx_push'): array
|
||||
{
|
||||
$arrSummaryRecords = array_values(array_filter((array)($arrPayload['summary_records'] ?? []), 'is_array'));
|
||||
$arrAnomalyRecords = array_values(array_filter((array)($arrPayload['anomaly_records'] ?? []), 'is_array'));
|
||||
|
||||
$arrSummaryRecords = array_map(static function (array $arrItem): array {
|
||||
$arrItem['host'] = strtolower(trim((string)($arrItem['host'] ?? '')));
|
||||
$arrItem['path'] = trim((string)($arrItem['path'] ?? ''));
|
||||
$arrItem['page_type'] = DomainSpiderCrawlLogSchemaHelper::normalizePageType((string)($arrItem['page_type'] ?? 'other'));
|
||||
$arrItem['bot_name'] = DomainSpiderCrawlLogSchemaHelper::normalizeBotName((string)($arrItem['bot_name'] ?? 'other_bot'));
|
||||
$arrItem['status'] = (int)($arrItem['status'] ?? 0);
|
||||
$arrItem['count'] = max(0, (int)($arrItem['count'] ?? 0));
|
||||
$arrItem['cache_status'] = self::normalizeCacheStatus((string)($arrItem['cache_status'] ?? ''));
|
||||
return $arrItem;
|
||||
}, $arrSummaryRecords);
|
||||
|
||||
$arrAnomalyRecords = array_map(static function (array $arrItem): array {
|
||||
$arrItem['host'] = strtolower(trim((string)($arrItem['host'] ?? '')));
|
||||
$arrItem['url'] = trim((string)($arrItem['url'] ?? ''));
|
||||
$arrItem['page_type'] = DomainSpiderCrawlLogSchemaHelper::normalizePageType((string)($arrItem['page_type'] ?? 'other'));
|
||||
$arrItem['bot_name'] = DomainSpiderCrawlLogSchemaHelper::normalizeBotName((string)($arrItem['bot_name'] ?? 'other_bot'));
|
||||
$arrItem['status'] = (int)($arrItem['status'] ?? 0);
|
||||
$arrItem['cache_status'] = self::normalizeCacheStatus((string)($arrItem['cache_status'] ?? ''));
|
||||
return $arrItem;
|
||||
}, $arrAnomalyRecords);
|
||||
|
||||
$arrHosts = [];
|
||||
$arrPageBuckets = [];
|
||||
$arrBotBuckets = [];
|
||||
$arrSummaryStatusBuckets = [];
|
||||
$arrAnomalyStatusBuckets = [];
|
||||
$intSummaryRequests = 0;
|
||||
|
||||
foreach ($arrSummaryRecords as $arrItem) {
|
||||
$strHost = (string)($arrItem['host'] ?? '');
|
||||
if ($strHost !== '') {
|
||||
$arrHosts[$strHost] = true;
|
||||
}
|
||||
|
||||
$strPageType = (string)($arrItem['page_type'] ?? 'other');
|
||||
$strBotName = (string)($arrItem['bot_name'] ?? 'other_bot');
|
||||
$strStatus = (string)((int)($arrItem['status'] ?? 0));
|
||||
$intCount = max(0, (int)($arrItem['count'] ?? 0));
|
||||
|
||||
$arrPageBuckets[$strPageType] = (int)($arrPageBuckets[$strPageType] ?? 0) + $intCount;
|
||||
$arrBotBuckets[$strBotName] = (int)($arrBotBuckets[$strBotName] ?? 0) + $intCount;
|
||||
$arrSummaryStatusBuckets[$strStatus] = (int)($arrSummaryStatusBuckets[$strStatus] ?? 0) + $intCount;
|
||||
$intSummaryRequests += $intCount;
|
||||
}
|
||||
|
||||
foreach ($arrAnomalyRecords as $arrItem) {
|
||||
$strHost = (string)($arrItem['host'] ?? '');
|
||||
if ($strHost !== '') {
|
||||
$arrHosts[$strHost] = true;
|
||||
}
|
||||
$strStatus = (string)((int)($arrItem['status'] ?? 0));
|
||||
$arrAnomalyStatusBuckets[$strStatus] = (int)($arrAnomalyStatusBuckets[$strStatus] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$arrTopHosts = [];
|
||||
foreach ($arrSummaryRecords as $arrItem) {
|
||||
$strHost = (string)($arrItem['host'] ?? '');
|
||||
if ($strHost === '') {
|
||||
continue;
|
||||
}
|
||||
$arrTopHosts[$strHost] = (int)($arrTopHosts[$strHost] ?? 0) + max(0, (int)($arrItem['count'] ?? 0));
|
||||
}
|
||||
arsort($arrTopHosts);
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'mode' => 'domain_spider_crawl_log_ingest',
|
||||
'source' => trim($strSource) !== '' ? trim($strSource) : 'frontend_nginx_push',
|
||||
'summary_record_count' => count($arrSummaryRecords),
|
||||
'anomaly_record_count' => count($arrAnomalyRecords),
|
||||
'summary_total_requests' => $intSummaryRequests,
|
||||
'hosts_count' => count($arrHosts),
|
||||
'bots_count' => count($arrBotBuckets),
|
||||
'page_type_buckets' => self::toBucketRows($arrPageBuckets, 'page_type'),
|
||||
'bot_buckets' => self::toBucketRows($arrBotBuckets, 'bot_name'),
|
||||
'summary_status_buckets' => self::toBucketRows($arrSummaryStatusBuckets, 'status'),
|
||||
'anomaly_status_buckets' => self::toBucketRows($arrAnomalyStatusBuckets, 'status'),
|
||||
'top_hosts' => array_slice(self::toBucketRows($arrTopHosts, 'host'), 0, 20),
|
||||
'summary_records' => $arrSummaryRecords,
|
||||
'anomaly_records' => $arrAnomalyRecords,
|
||||
'note' => '这份摘要用于分析蜘蛛是否来抓、抓了哪些页、状态如何,以及缓存层是否影响深抓。',
|
||||
];
|
||||
}
|
||||
|
||||
public static function writeLatestArtifacts(string $strRoot, array $arrSummary): array
|
||||
{
|
||||
$strRoot = rtrim($strRoot, '/');
|
||||
if (!is_dir($strRoot) && !mkdir($strRoot, 0777, true) && !is_dir($strRoot)) {
|
||||
throw new \RuntimeException('create crawl latest root failed: ' . $strRoot);
|
||||
}
|
||||
|
||||
$strJsonPath = $strRoot . '/spider-crawl.summary.json';
|
||||
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$strHtmlPath = $strRoot . '/spider-crawl.summary.html';
|
||||
$strHtml = '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>蜘蛛抓取概览</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;vertical-align:top}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>蜘蛛抓取概览</h1><div class="meta">'
|
||||
. '<div class="card">source: ' . htmlspecialchars((string)($arrSummary['source'] ?? '-')) . '</div>'
|
||||
. '<div class="card">summary_records: ' . (int)($arrSummary['summary_record_count'] ?? 0) . '</div>'
|
||||
. '<div class="card">anomaly_records: ' . (int)($arrSummary['anomaly_record_count'] ?? 0) . '</div>'
|
||||
. '<div class="card">summary_total_requests: ' . (int)($arrSummary['summary_total_requests'] ?? 0) . '</div>'
|
||||
. '<div class="card">hosts: ' . (int)($arrSummary['hosts_count'] ?? 0) . '</div>'
|
||||
. '</div>';
|
||||
|
||||
$strHtml .= '<h2>Top Hosts</h2><table><thead><tr><th>host</th><th>count</th></tr></thead><tbody>';
|
||||
foreach ((array)($arrSummary['top_hosts'] ?? []) as $arrItem) {
|
||||
$strHtml .= '<tr><td>' . htmlspecialchars((string)($arrItem['host'] ?? '')) . '</td><td>' . (int)($arrItem['count'] ?? 0) . '</td></tr>';
|
||||
}
|
||||
$strHtml .= '</tbody></table>';
|
||||
|
||||
$strHtml .= '<h2>页面类型分布</h2><table><thead><tr><th>page_type</th><th>count</th></tr></thead><tbody>';
|
||||
foreach ((array)($arrSummary['page_type_buckets'] ?? []) as $arrItem) {
|
||||
$strHtml .= '<tr><td>' . htmlspecialchars((string)($arrItem['page_type'] ?? '')) . '</td><td>' . (int)($arrItem['count'] ?? 0) . '</td></tr>';
|
||||
}
|
||||
$strHtml .= '</tbody></table>';
|
||||
|
||||
file_put_contents($strHtmlPath, $strHtml . '</body></html>');
|
||||
|
||||
$arrSummary['summary_json_path'] = $strJsonPath;
|
||||
$arrSummary['summary_html_path'] = $strHtmlPath;
|
||||
return $arrSummary;
|
||||
}
|
||||
|
||||
protected static function normalizeCacheStatus(string $strValue): string
|
||||
{
|
||||
$strValue = strtoupper(trim($strValue));
|
||||
if ($strValue === '' || $strValue === '-') {
|
||||
return 'NONE';
|
||||
}
|
||||
$arrAllowed = DomainSpiderCrawlLogSchemaHelper::buildSchema()['cache_status_enum'];
|
||||
return in_array($strValue, $arrAllowed, true) ? $strValue : 'UNKNOWN';
|
||||
}
|
||||
|
||||
protected static function toBucketRows(array $arrBuckets, string $strField): array
|
||||
{
|
||||
arsort($arrBuckets);
|
||||
return array_map(static function (string $strKey, int $intCount) use ($strField): array {
|
||||
return [
|
||||
$strField => $strKey,
|
||||
'count' => $intCount,
|
||||
];
|
||||
}, array_keys($arrBuckets), array_values($arrBuckets));
|
||||
}
|
||||
}
|
||||
87
code/app/common/helper/DomainSpiderCrawlLogRunHelper.php
Normal file
87
code/app/common/helper/DomainSpiderCrawlLogRunHelper.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class DomainSpiderCrawlLogRunHelper
|
||||
{
|
||||
protected static function rootRelativePath(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 = 'crawl_log_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 crawl log run root failed: ' . $strRunRoot);
|
||||
}
|
||||
|
||||
return $strRunRoot;
|
||||
}
|
||||
|
||||
public static function writeRunArtifacts(string $strRoot, string $strRunRoot, array $arrSummary, array $arrPayload): array
|
||||
{
|
||||
$strRunRoot = rtrim($strRunRoot, '/');
|
||||
if (!is_dir($strRunRoot) && !mkdir($strRunRoot, 0777, true) && !is_dir($strRunRoot)) {
|
||||
throw new \RuntimeException('crawl log run root missing: ' . $strRunRoot);
|
||||
}
|
||||
|
||||
$strPayloadPath = $strRunRoot . '/crawl-logs.payload.json';
|
||||
file_put_contents($strPayloadPath, json_encode($arrPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$strJsonPath = $strRunRoot . '/crawl-logs.summary.json';
|
||||
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$strHtmlPath = $strRunRoot . '/crawl-logs.summary.html';
|
||||
$strHtml = '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>蜘蛛抓取日志摘要</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;vertical-align:top}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>蜘蛛抓取日志摘要</h1><div class="meta">'
|
||||
. '<div class="card">source: ' . htmlspecialchars((string)($arrSummary['source'] ?? '-')) . '</div>'
|
||||
. '<div class="card">summary_records: ' . (int)($arrSummary['summary_record_count'] ?? 0) . '</div>'
|
||||
. '<div class="card">anomaly_records: ' . (int)($arrSummary['anomaly_record_count'] ?? 0) . '</div>'
|
||||
. '<div class="card">hosts: ' . (int)($arrSummary['hosts_count'] ?? 0) . '</div>'
|
||||
. '<div class="card">bots: ' . (int)($arrSummary['bots_count'] ?? 0) . '</div>'
|
||||
. '</div>';
|
||||
|
||||
$strHtml .= '<h2>页面类型分布</h2><table><thead><tr><th>page_type</th><th>count</th></tr></thead><tbody>';
|
||||
foreach ((array)($arrSummary['page_type_buckets'] ?? []) as $arrItem) {
|
||||
$strHtml .= '<tr><td>' . htmlspecialchars((string)($arrItem['page_type'] ?? '')) . '</td><td>' . (int)($arrItem['count'] ?? 0) . '</td></tr>';
|
||||
}
|
||||
$strHtml .= '</tbody></table>';
|
||||
|
||||
$strHtml .= '<h2>蜘蛛分布</h2><table><thead><tr><th>bot_name</th><th>count</th></tr></thead><tbody>';
|
||||
foreach ((array)($arrSummary['bot_buckets'] ?? []) as $arrItem) {
|
||||
$strHtml .= '<tr><td>' . htmlspecialchars((string)($arrItem['bot_name'] ?? '')) . '</td><td>' . (int)($arrItem['count'] ?? 0) . '</td></tr>';
|
||||
}
|
||||
$strHtml .= '</tbody></table>';
|
||||
|
||||
if (!empty($arrSummary['anomaly_status_buckets'])) {
|
||||
$strHtml .= '<h2>异常状态</h2><table><thead><tr><th>status</th><th>count</th></tr></thead><tbody>';
|
||||
foreach ((array)$arrSummary['anomaly_status_buckets'] as $arrItem) {
|
||||
$strHtml .= '<tr><td>' . htmlspecialchars((string)($arrItem['status'] ?? '')) . '</td><td>' . (int)($arrItem['count'] ?? 0) . '</td></tr>';
|
||||
}
|
||||
$strHtml .= '</tbody></table>';
|
||||
}
|
||||
|
||||
file_put_contents($strHtmlPath, $strHtml . '</body></html>');
|
||||
|
||||
$arrSummary['payload_json_path'] = self::rootRelativePath($strRoot, $strPayloadPath);
|
||||
$arrSummary['summary_json_path'] = self::rootRelativePath($strRoot, $strJsonPath);
|
||||
$arrSummary['summary_html_path'] = self::rootRelativePath($strRoot, $strHtmlPath);
|
||||
$arrSummary['run_root_public'] = self::rootRelativePath($strRoot, $strRunRoot);
|
||||
|
||||
return $arrSummary;
|
||||
}
|
||||
}
|
||||
237
code/app/common/helper/DomainSpiderCrawlLogSchemaHelper.php
Normal file
237
code/app/common/helper/DomainSpiderCrawlLogSchemaHelper.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class DomainSpiderCrawlLogSchemaHelper
|
||||
{
|
||||
public static function buildSchema(): array
|
||||
{
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'mode' => 'domain_spider_crawl_log_schema',
|
||||
'summary_record' => [
|
||||
'required' => ['server_time', 'host', 'path', 'page_type', 'status', 'count', 'bot_name'],
|
||||
'recommended' => ['cache_status', 'request_time_avg', 'upstream_time_avg', 'source_server'],
|
||||
],
|
||||
'anomaly_record' => [
|
||||
'required' => ['time', 'host', 'url', 'status', 'bot_name', 'user_agent'],
|
||||
'recommended' => ['page_type', 'cache_status', 'request_time', 'upstream_time', 'referer', 'remote_ip_hash', 'source_server'],
|
||||
],
|
||||
'page_type_enum' => ['home', 'category', 'search', 'detail', 'play', 'sitemap', 'robots', 'other'],
|
||||
'cache_status_enum' => ['HIT', 'MISS', 'BYPASS', 'EXPIRED', 'STALE', 'UPDATING', 'REVALIDATED', 'NONE', 'UNKNOWN'],
|
||||
'bot_name_enum' => ['baiduspider', 'googlebot', 'bytespider', 'sogou', 'bingbot', 'other_bot'],
|
||||
'note' => '前端站群建议先聚合 Nginx 蜘蛛请求,再调 validate 做 dry-run,最后调 crawl-logs/save 正式入库分析。',
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildChecklist(): array
|
||||
{
|
||||
$arrSchema = self::buildSchema();
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'mode' => 'domain_spider_crawl_log_checklist',
|
||||
'summary_record' => (array)($arrSchema['summary_record'] ?? []),
|
||||
'anomaly_record' => (array)($arrSchema['anomaly_record'] ?? []),
|
||||
'page_type_enum' => (array)($arrSchema['page_type_enum'] ?? []),
|
||||
'cache_status_enum' => (array)($arrSchema['cache_status_enum'] ?? []),
|
||||
'bot_name_enum' => (array)($arrSchema['bot_name_enum'] ?? []),
|
||||
'note' => 'summary_records 适合定时推聚合摘要;anomaly_records 只推异常/深抓失败等关键明细。',
|
||||
];
|
||||
}
|
||||
|
||||
public static function validatePayload(array $arrPayload): array
|
||||
{
|
||||
$arrSchema = self::buildSchema();
|
||||
$arrSummaryRecords = array_values(array_filter((array)($arrPayload['summary_records'] ?? []), 'is_array'));
|
||||
$arrAnomalyRecords = array_values(array_filter((array)($arrPayload['anomaly_records'] ?? []), 'is_array'));
|
||||
|
||||
$arrErrors = [];
|
||||
$arrWarnings = [];
|
||||
$arrValidSummary = [];
|
||||
$arrValidAnomalies = [];
|
||||
|
||||
foreach ($arrSummaryRecords as $intIndex => $arrItem) {
|
||||
$arrResult = self::validateRecord(
|
||||
$arrItem,
|
||||
(array)($arrSchema['summary_record']['required'] ?? []),
|
||||
(array)($arrSchema['summary_record']['recommended'] ?? []),
|
||||
'summary_records',
|
||||
$intIndex
|
||||
);
|
||||
|
||||
$arrErrors = array_merge($arrErrors, (array)($arrResult['errors'] ?? []));
|
||||
$arrWarnings = array_merge($arrWarnings, (array)($arrResult['warnings'] ?? []));
|
||||
if (!empty($arrResult['valid'])) {
|
||||
$arrValidSummary[] = [
|
||||
'index' => $intIndex,
|
||||
'host' => trim((string)($arrItem['host'] ?? '')),
|
||||
'path' => trim((string)($arrItem['path'] ?? '')),
|
||||
'page_type' => self::normalizePageType((string)($arrItem['page_type'] ?? 'other')),
|
||||
'status' => (int)($arrItem['status'] ?? 0),
|
||||
'count' => max(0, (int)($arrItem['count'] ?? 0)),
|
||||
'bot_name' => self::normalizeBotName((string)($arrItem['bot_name'] ?? 'other_bot')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($arrAnomalyRecords as $intIndex => $arrItem) {
|
||||
$arrResult = self::validateRecord(
|
||||
$arrItem,
|
||||
(array)($arrSchema['anomaly_record']['required'] ?? []),
|
||||
(array)($arrSchema['anomaly_record']['recommended'] ?? []),
|
||||
'anomaly_records',
|
||||
$intIndex
|
||||
);
|
||||
|
||||
$arrErrors = array_merge($arrErrors, (array)($arrResult['errors'] ?? []));
|
||||
$arrWarnings = array_merge($arrWarnings, (array)($arrResult['warnings'] ?? []));
|
||||
if (!empty($arrResult['valid'])) {
|
||||
$arrValidAnomalies[] = [
|
||||
'index' => $intIndex,
|
||||
'host' => trim((string)($arrItem['host'] ?? '')),
|
||||
'url' => trim((string)($arrItem['url'] ?? '')),
|
||||
'page_type' => self::normalizePageType((string)($arrItem['page_type'] ?? 'other')),
|
||||
'status' => (int)($arrItem['status'] ?? 0),
|
||||
'bot_name' => self::normalizeBotName((string)($arrItem['bot_name'] ?? 'other_bot')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'mode' => 'domain_spider_crawl_log_validate',
|
||||
'source' => trim((string)($arrPayload['source'] ?? 'push_api')),
|
||||
'summary_record_count' => count($arrSummaryRecords),
|
||||
'anomaly_record_count' => count($arrAnomalyRecords),
|
||||
'valid_summary_count' => count($arrValidSummary),
|
||||
'valid_anomaly_count' => count($arrValidAnomalies),
|
||||
'error_count' => count($arrErrors),
|
||||
'warning_count' => count($arrWarnings),
|
||||
'passed' => empty($arrErrors),
|
||||
'valid_summary_items' => $arrValidSummary,
|
||||
'valid_anomaly_items' => $arrValidAnomalies,
|
||||
'errors' => $arrErrors,
|
||||
'warnings' => $arrWarnings,
|
||||
'error_buckets' => self::buildBuckets($arrErrors),
|
||||
'warning_buckets' => self::buildBuckets($arrWarnings),
|
||||
'note' => 'validate 只做字段级 dry-run,不会写入蜘蛛抓取分析运行目录。',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function validateRecord(array $arrItem, array $arrRequired, array $arrRecommended, string $strCollection, int $intIndex): array
|
||||
{
|
||||
$arrErrors = [];
|
||||
$arrWarnings = [];
|
||||
|
||||
$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[] = [
|
||||
'collection' => $strCollection,
|
||||
'index' => $intIndex,
|
||||
'message' => 'missing required fields: ' . implode(', ', $arrMissing),
|
||||
];
|
||||
}
|
||||
|
||||
$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[] = [
|
||||
'collection' => $strCollection,
|
||||
'index' => $intIndex,
|
||||
'message' => 'missing recommended fields: ' . implode(', ', $arrMissingRecommended),
|
||||
];
|
||||
}
|
||||
|
||||
$strPageType = trim((string)($arrItem['page_type'] ?? ''));
|
||||
if ($strPageType !== '' && !in_array(self::normalizePageType($strPageType), self::buildSchema()['page_type_enum'], true)) {
|
||||
$arrErrors[] = [
|
||||
'collection' => $strCollection,
|
||||
'index' => $intIndex,
|
||||
'message' => 'page_type invalid: ' . $strPageType,
|
||||
];
|
||||
}
|
||||
|
||||
$strBotName = trim((string)($arrItem['bot_name'] ?? ''));
|
||||
if ($strBotName !== '' && !in_array(self::normalizeBotName($strBotName), self::buildSchema()['bot_name_enum'], true)) {
|
||||
$arrWarnings[] = [
|
||||
'collection' => $strCollection,
|
||||
'index' => $intIndex,
|
||||
'message' => 'bot_name normalized to other_bot: ' . $strBotName,
|
||||
];
|
||||
}
|
||||
|
||||
$intStatus = (int)($arrItem['status'] ?? 0);
|
||||
if ($intStatus <= 0 || $intStatus > 599) {
|
||||
$arrErrors[] = [
|
||||
'collection' => $strCollection,
|
||||
'index' => $intIndex,
|
||||
'message' => 'status must be between 1 and 599',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($arrErrors),
|
||||
'errors' => $arrErrors,
|
||||
'warnings' => $arrWarnings,
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalizePageType(string $strPageType): string
|
||||
{
|
||||
$strPageType = strtolower(trim($strPageType));
|
||||
return in_array($strPageType, self::buildSchema()['page_type_enum'], true) ? $strPageType : 'other';
|
||||
}
|
||||
|
||||
public static function normalizeBotName(string $strBotName): string
|
||||
{
|
||||
$strValue = strtolower(trim($strBotName));
|
||||
return match (true) {
|
||||
str_contains($strValue, 'baidu') => 'baiduspider',
|
||||
str_contains($strValue, 'google') => 'googlebot',
|
||||
str_contains($strValue, 'bytespider') || str_contains($strValue, 'bytedance') => 'bytespider',
|
||||
str_contains($strValue, 'sogou') => 'sogou',
|
||||
str_contains($strValue, 'bing') => 'bingbot',
|
||||
default => 'other_bot',
|
||||
};
|
||||
}
|
||||
|
||||
protected static function buildBuckets(array $arrItems): array
|
||||
{
|
||||
$arrBuckets = [];
|
||||
foreach ($arrItems as $arrItem) {
|
||||
$strMessage = trim((string)($arrItem['message'] ?? 'unknown'));
|
||||
if ($strMessage === '') {
|
||||
$strMessage = 'unknown';
|
||||
}
|
||||
$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));
|
||||
}
|
||||
}
|
||||
1306
code/app/common/helper/DomainSpiderCrawlWorkbenchHelper.php
Normal file
1306
code/app/common/helper/DomainSpiderCrawlWorkbenchHelper.php
Normal file
File diff suppressed because it is too large
Load Diff
1119
code/app/common/helper/DomainSpiderMdRunHelper.php
Normal file
1119
code/app/common/helper/DomainSpiderMdRunHelper.php
Normal file
File diff suppressed because it is too large
Load Diff
505
code/app/common/helper/DomainSupplyBatchHelper.php
Normal file
505
code/app/common/helper/DomainSupplyBatchHelper.php
Normal file
@@ -0,0 +1,505 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class DomainSupplyBatchHelper
|
||||
{
|
||||
protected static function publicRelativeToAbsolutePath(string $path): string
|
||||
{
|
||||
$path = str_replace('\\', '/', trim($path));
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
if (str_starts_with($path, '/')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return dirname(__DIR__, 2) . '/public/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
public static function findLatestSummaryPath(string $runRoot): string
|
||||
{
|
||||
$runRoot = rtrim($runRoot, '/');
|
||||
if ($runRoot === '' || !is_dir($runRoot)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$matches = array_merge(
|
||||
(array)glob($runRoot . '/*/supply.summary.json'),
|
||||
(array)glob($runRoot . '/*/*/supply.summary.json')
|
||||
);
|
||||
$matches = array_values(array_filter(array_unique($matches), 'is_file'));
|
||||
usort($matches, static function (string $left, string $right): int {
|
||||
$leftTime = (int)(filemtime($left) ?: 0);
|
||||
$rightTime = (int)(filemtime($right) ?: 0);
|
||||
if ($leftTime !== $rightTime) {
|
||||
return $rightTime <=> $leftTime;
|
||||
}
|
||||
|
||||
return strcmp($right, $left);
|
||||
});
|
||||
|
||||
return $matches[0] ?? '';
|
||||
}
|
||||
|
||||
public static function findLatestTemplatePath(string $runRoot): string
|
||||
{
|
||||
$runRoot = rtrim($runRoot, '/');
|
||||
if ($runRoot === '' || !is_dir($runRoot)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$matches = array_merge(
|
||||
(array)glob($runRoot . '/*/domain-supply-batch-upload.*'),
|
||||
(array)glob($runRoot . '/*/*/domain-supply-batch-upload.*')
|
||||
);
|
||||
$matches = array_values(array_filter(array_unique($matches), 'is_file'));
|
||||
usort($matches, static function (string $left, string $right): int {
|
||||
$leftTime = (int)(filemtime($left) ?: 0);
|
||||
$rightTime = (int)(filemtime($right) ?: 0);
|
||||
if ($leftTime !== $rightTime) {
|
||||
return $rightTime <=> $leftTime;
|
||||
}
|
||||
|
||||
return strcmp($right, $left);
|
||||
});
|
||||
|
||||
return $matches[0] ?? '';
|
||||
}
|
||||
|
||||
public static function buildReplayTemplateFromSummary(string $summaryPath, string $outputPath): string
|
||||
{
|
||||
if (!is_file($summaryPath)) {
|
||||
throw new \RuntimeException('Supply summary not found: ' . $summaryPath);
|
||||
}
|
||||
|
||||
$summary = json_decode((string)file_get_contents($summaryPath), true);
|
||||
if (!is_array($summary)) {
|
||||
throw new \RuntimeException('Invalid supply summary json: ' . $summaryPath);
|
||||
}
|
||||
|
||||
$bootstrapBatchPath = self::publicRelativeToAbsolutePath((string)($summary['bootstrap_batch_path'] ?? ''));
|
||||
if (!is_file($bootstrapBatchPath)) {
|
||||
throw new \RuntimeException('Bootstrap batch workbook not found for replay: ' . $bootstrapBatchPath);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach (self::readRows($bootstrapBatchPath) as $bootstrapRow) {
|
||||
$rows[] = [
|
||||
'host' => trim((string)($bootstrapRow['host'] ?? '')),
|
||||
'template_id' => trim((string)($bootstrapRow['template_id'] ?? '1')),
|
||||
'strategy_profile' => trim((string)($bootstrapRow['strategy_profile'] ?? '')),
|
||||
'match_type' => trim((string)($bootstrapRow['match_type'] ?? '')),
|
||||
'parent_domain' => trim((string)($bootstrapRow['parent_domain'] ?? '')),
|
||||
'seed_scope' => trim((string)($bootstrapRow['seed_scope'] ?? '')),
|
||||
'site_name' => trim((string)($bootstrapRow['site_name'] ?? '')),
|
||||
'site_keywords' => trim((string)($bootstrapRow['site_keywords'] ?? '')),
|
||||
'site_description' => trim((string)($bootstrapRow['site_description'] ?? '')),
|
||||
'site_index_title' => '',
|
||||
'site_index_keywords' => '',
|
||||
'site_index_description' => '',
|
||||
'baidu_token' => '',
|
||||
'seo_cfg_json' => '',
|
||||
'detail_id' => trim((string)($bootstrapRow['detail_id'] ?? '')),
|
||||
'detail_slug' => trim((string)($bootstrapRow['detail_slug'] ?? '')),
|
||||
'search_keyword' => trim((string)($bootstrapRow['search_keyword'] ?? '')),
|
||||
'category_parent' => trim((string)($bootstrapRow['category_parent'] ?? '')),
|
||||
'category_child' => trim((string)($bootstrapRow['category_child'] ?? '')),
|
||||
'play_type' => trim((string)($bootstrapRow['play_type'] ?? '')),
|
||||
'play_index' => trim((string)($bootstrapRow['play_index'] ?? '')),
|
||||
'forge_index' => trim((string)($bootstrapRow['forge_index'] ?? '')),
|
||||
'rank_slug' => trim((string)($bootstrapRow['rank_slug'] ?? '')),
|
||||
'base_url' => trim((string)($bootstrapRow['base_url'] ?? '')),
|
||||
'bundle_name' => trim((string)($bootstrapRow['bundle_name'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($rows)) {
|
||||
throw new \RuntimeException('Replay template rows are empty: ' . $summaryPath);
|
||||
}
|
||||
|
||||
DomainBatchImportTemplateHelper::writeWorkbook(
|
||||
$outputPath,
|
||||
'domain-supply-batch',
|
||||
DomainSupplyBatchTemplateHelper::getHeaders(),
|
||||
$rows,
|
||||
DomainSupplyBatchTemplateHelper::getColumnNotes()
|
||||
);
|
||||
|
||||
return $outputPath;
|
||||
}
|
||||
|
||||
public static function createRunRoot(string $runRoot, string $prefix = 'task'): string
|
||||
{
|
||||
$runRoot = rtrim($runRoot, '/');
|
||||
$dateDir = $runRoot . '/' . date('Ymd');
|
||||
self::ensureDir($dateDir);
|
||||
$token = substr(md5(uniqid('', true)), 0, 6);
|
||||
$dir = $dateDir . '/' . date('His') . '_' . trim($prefix, '_') . '_' . $token;
|
||||
self::ensureDir($dir);
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
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 readRows(string $path): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($path);
|
||||
$sheet = $spreadsheet->getSheet(0);
|
||||
$rows = $sheet->toArray(null, true, true, false);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
unset($spreadsheet);
|
||||
|
||||
if (empty($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$headers = array_map(static fn ($value): string => trim((string)$value), (array)array_shift($rows));
|
||||
$normalized = [];
|
||||
foreach ($rows as $row) {
|
||||
$assoc = [];
|
||||
$hasData = false;
|
||||
foreach ($headers as $index => $header) {
|
||||
if ($header === '') {
|
||||
continue;
|
||||
}
|
||||
$value = trim((string)($row[$index] ?? ''));
|
||||
$assoc[$header] = $value;
|
||||
if ($value !== '') {
|
||||
$hasData = true;
|
||||
}
|
||||
}
|
||||
if ($hasData) {
|
||||
$normalized[] = $assoc;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public static function hostLabelFromRow(array $row): string
|
||||
{
|
||||
return trim((string)($row['host'] ?? ''));
|
||||
}
|
||||
|
||||
public static function buildSiteImportRow(array $row): array
|
||||
{
|
||||
$headers = DomainBatchImportTemplateHelper::getHeaders();
|
||||
$siteName = trim((string)($row['site_name'] ?? ''));
|
||||
$siteKeywords = trim((string)($row['site_keywords'] ?? ''));
|
||||
$siteDescription = trim((string)($row['site_description'] ?? ''));
|
||||
|
||||
$mapped = [
|
||||
'd_domain' => self::hostLabelFromRow($row),
|
||||
't_id' => trim((string)($row['template_id'] ?? '1')),
|
||||
'd_name' => $siteName,
|
||||
'd_keywords' => $siteKeywords,
|
||||
'd_index_title' => trim((string)($row['site_index_title'] ?? '')),
|
||||
'd_index_keywords' => trim((string)($row['site_index_keywords'] ?? '')),
|
||||
'd_index_description' => trim((string)($row['site_index_description'] ?? '')),
|
||||
'd_description' => $siteDescription,
|
||||
'd_statis' => '',
|
||||
'd_logo_type' => '0',
|
||||
'd_text_logo' => $siteName,
|
||||
'd_img_logo' => '',
|
||||
'd_content_encode' => '0',
|
||||
'info_id' => '0',
|
||||
'd_match_type' => trim((string)($row['match_type'] ?? '')),
|
||||
'd_parent_domain' => trim((string)($row['parent_domain'] ?? '')),
|
||||
'd_seed_scope' => trim((string)($row['seed_scope'] ?? '')),
|
||||
'd_baidu_token' => trim((string)($row['baidu_token'] ?? '')),
|
||||
'd_seo_cfg' => trim((string)($row['seo_cfg_json'] ?? '')),
|
||||
'strategy_profile' => trim((string)($row['strategy_profile'] ?? '')),
|
||||
];
|
||||
|
||||
$output = [];
|
||||
foreach ($headers as $header) {
|
||||
$output[$header] = (string)($mapped[$header] ?? '');
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
public static function buildBootstrapBatchRow(array $row): array
|
||||
{
|
||||
$headers = DomainBootstrapBatchTemplateHelper::getHeaders();
|
||||
$mapped = [
|
||||
'host' => self::hostLabelFromRow($row),
|
||||
'detail_id' => trim((string)($row['detail_id'] ?? '')),
|
||||
'detail_slug' => trim((string)($row['detail_slug'] ?? '')),
|
||||
'search_keyword' => trim((string)($row['search_keyword'] ?? '')),
|
||||
'category_parent' => trim((string)($row['category_parent'] ?? '')),
|
||||
'category_child' => trim((string)($row['category_child'] ?? '')),
|
||||
'play_type' => trim((string)($row['play_type'] ?? '')),
|
||||
'play_index' => trim((string)($row['play_index'] ?? '')),
|
||||
'forge_index' => trim((string)($row['forge_index'] ?? '')),
|
||||
'rank_slug' => trim((string)($row['rank_slug'] ?? '')),
|
||||
'match_type' => trim((string)($row['match_type'] ?? '')),
|
||||
'parent_domain' => trim((string)($row['parent_domain'] ?? '')),
|
||||
'seed_scope' => trim((string)($row['seed_scope'] ?? '')),
|
||||
'strategy_profile' => trim((string)($row['strategy_profile'] ?? '')),
|
||||
'template_id' => trim((string)($row['template_id'] ?? '1')),
|
||||
'site_name' => trim((string)($row['site_name'] ?? '')),
|
||||
'site_keywords' => trim((string)($row['site_keywords'] ?? '')),
|
||||
'site_description' => trim((string)($row['site_description'] ?? '')),
|
||||
'base_url' => trim((string)($row['base_url'] ?? '')),
|
||||
'bundle_name' => trim((string)($row['bundle_name'] ?? '')),
|
||||
];
|
||||
|
||||
$output = [];
|
||||
foreach ($headers as $header) {
|
||||
$output[$header] = (string)($mapped[$header] ?? '');
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
public static function validateRow(array $row): array
|
||||
{
|
||||
$required = [
|
||||
'host',
|
||||
'template_id',
|
||||
'strategy_profile',
|
||||
'detail_id',
|
||||
'detail_slug',
|
||||
'search_keyword',
|
||||
'category_parent',
|
||||
'category_child',
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
foreach ($required as $field) {
|
||||
if (trim((string)($row[$field] ?? '')) === '') {
|
||||
$missing[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
public static function renderSummaryHtml(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['strategy_profile'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($item['status'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($item['message'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($item['site_domain'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($item['bundle_host'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return implode(PHP_EOL, [
|
||||
'<!doctype html>',
|
||||
'<html lang="zh-CN">',
|
||||
'<head>',
|
||||
'<meta charset="utf-8">',
|
||||
'<title>Domain Supply Batch Summary</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;}</style>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
'<h1>Domain Supply Batch Summary</h1>',
|
||||
'<p>template: <code>' . htmlspecialchars((string)($summary['template_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</code></p>',
|
||||
'<p>output_root: <code>' . htmlspecialchars((string)($summary['output_root'] ?? ''), ENT_QUOTES, 'UTF-8') . '</code></p>',
|
||||
'<p>processed: <strong>' . (int)($summary['processed_count'] ?? 0) . '</strong> / success: <strong>' . (int)($summary['success_count'] ?? 0) . '</strong> / failed: <strong>' . (int)($summary['failed_count'] ?? 0) . '</strong></p>',
|
||||
'<p>site_import_rows: <strong>' . (int)($summary['site_import_rows_count'] ?? 0) . '</strong> / bootstrap_rows: <strong>' . (int)($summary['bootstrap_rows_count'] ?? 0) . '</strong></p>',
|
||||
'<table><thead><tr><th>Host</th><th>Strategy</th><th>Status</th><th>Message</th><th>Site Import</th><th>Bootstrap</th></tr></thead><tbody>',
|
||||
...$rows,
|
||||
'</tbody></table>',
|
||||
'</body>',
|
||||
'</html>',
|
||||
]) . PHP_EOL;
|
||||
}
|
||||
|
||||
public static function process(string $templatePath, array $options = []): array
|
||||
{
|
||||
if (!is_file($templatePath)) {
|
||||
throw new \RuntimeException('Template file not found: ' . $templatePath);
|
||||
}
|
||||
|
||||
$outputRoot = rtrim((string)($options['output_root'] ?? dirname(__DIR__, 2) . '/storage/domain_supply_batch'), '/');
|
||||
$runBootstrap = (bool)($options['run_bootstrap'] ?? false);
|
||||
$batchRoot = rtrim((string)($options['batch_root'] ?? ''), '/');
|
||||
$indexRoot = rtrim((string)($options['index_root'] ?? ''), '/');
|
||||
$portalRoot = rtrim((string)($options['portal_root'] ?? (dirname(__DIR__, 2) . '/public/_seo_copy_release')), '/');
|
||||
$bootstrapRun = (string)($options['bootstrap_run'] ?? 'off');
|
||||
$runRoot = rtrim((string)($options['run_root'] ?? ''), '/');
|
||||
$force = (bool)($options['force'] ?? false);
|
||||
|
||||
self::ensureDir($outputRoot);
|
||||
$generatedRoot = $outputRoot . '/generated';
|
||||
self::ensureDir($generatedRoot);
|
||||
|
||||
if ($batchRoot === '') {
|
||||
$batchRoot = $outputRoot . '/bundles';
|
||||
}
|
||||
if ($indexRoot === '') {
|
||||
$indexRoot = $batchRoot;
|
||||
}
|
||||
|
||||
$rows = self::readRows($templatePath);
|
||||
$items = [];
|
||||
$siteRows = [];
|
||||
$bootstrapRows = [];
|
||||
$successCount = 0;
|
||||
$failedCount = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$host = self::hostLabelFromRow($row);
|
||||
if ($host === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing = self::validateRow($row);
|
||||
$status = empty($missing) ? 'ready' : 'failed';
|
||||
$message = empty($missing) ? 'prepared' : ('missing:' . implode(',', $missing));
|
||||
|
||||
$siteRow = self::buildSiteImportRow($row);
|
||||
$bootstrapRow = self::buildBootstrapBatchRow($row);
|
||||
|
||||
if (empty($missing)) {
|
||||
$siteRows[] = $siteRow;
|
||||
$bootstrapRows[] = $bootstrapRow;
|
||||
$successCount++;
|
||||
} else {
|
||||
$failedCount++;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'host' => $host,
|
||||
'strategy_profile' => (string)($row['strategy_profile'] ?? ''),
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'site_domain' => (string)($siteRow['d_domain'] ?? ''),
|
||||
'bundle_host' => (string)($bootstrapRow['host'] ?? ''),
|
||||
'missing_fields' => $missing,
|
||||
];
|
||||
}
|
||||
|
||||
$siteImportPath = $generatedRoot . '/site-domain-import.generated.xlsx';
|
||||
$bootstrapBatchPath = $generatedRoot . '/domain-bootstrap-batch.generated.xlsx';
|
||||
|
||||
DomainBatchImportTemplateHelper::writeWorkbook(
|
||||
$siteImportPath,
|
||||
'site-domain-import',
|
||||
DomainBatchImportTemplateHelper::getHeaders(),
|
||||
$siteRows,
|
||||
DomainBatchImportTemplateHelper::getColumnNotes()
|
||||
);
|
||||
|
||||
DomainBootstrapBatchTemplateHelper::writeWorkbook(
|
||||
$bootstrapBatchPath,
|
||||
'domain-bootstrap-batch',
|
||||
DomainBootstrapBatchTemplateHelper::getHeaders(),
|
||||
$bootstrapRows,
|
||||
DomainBootstrapBatchTemplateHelper::getColumnNotes()
|
||||
);
|
||||
|
||||
$bootstrapBatchSummary = [];
|
||||
if ($runBootstrap && !empty($bootstrapRows)) {
|
||||
$command = [
|
||||
'php',
|
||||
escapeshellarg(dirname(__DIR__, 3) . '/scripts/seo_copy_domain_bootstrap_batch.php'),
|
||||
escapeshellarg($bootstrapBatchPath),
|
||||
'--batch-root=' . escapeshellarg($batchRoot),
|
||||
'--index-root=' . escapeshellarg($indexRoot),
|
||||
'--portal-root=' . escapeshellarg($portalRoot !== '' ? $portalRoot : 'off'),
|
||||
'--bootstrap-run=' . escapeshellarg($bootstrapRun),
|
||||
];
|
||||
if ($runRoot !== '') {
|
||||
$command[] = '--run-root=' . escapeshellarg($runRoot);
|
||||
}
|
||||
if ($force) {
|
||||
$command[] = '--force';
|
||||
}
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec(implode(' ', $command) . ' 2>&1', $output, $exitCode);
|
||||
$joined = trim(implode(PHP_EOL, $output));
|
||||
$decoded = json_decode($joined, true);
|
||||
$bootstrapBatchSummary = [
|
||||
'status' => $exitCode === 0 ? 'success' : 'failed',
|
||||
'command' => implode(' ', $command),
|
||||
'exit_code' => $exitCode,
|
||||
'output' => $output,
|
||||
'summary' => is_array($decoded) ? $decoded : [],
|
||||
];
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'template_path' => $templatePath,
|
||||
'output_root' => $outputRoot,
|
||||
'processed_count' => count($items),
|
||||
'success_count' => $successCount,
|
||||
'failed_count' => $failedCount,
|
||||
'site_import_rows_count' => count($siteRows),
|
||||
'bootstrap_rows_count' => count($bootstrapRows),
|
||||
'site_import_path' => $siteImportPath,
|
||||
'bootstrap_batch_path' => $bootstrapBatchPath,
|
||||
'run_bootstrap' => $runBootstrap,
|
||||
'batch_root' => $batchRoot,
|
||||
'index_root' => $indexRoot,
|
||||
'portal_root' => $portalRoot,
|
||||
'bootstrap_run' => $bootstrapRun,
|
||||
'items' => $items,
|
||||
'bootstrap_batch' => $bootstrapBatchSummary,
|
||||
];
|
||||
|
||||
$summaryJsonPath = $outputRoot . '/supply.summary.json';
|
||||
$summaryHtmlPath = $outputRoot . '/supply.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::renderSummaryHtml($summary));
|
||||
|
||||
$summary['summary_json'] = $summaryJsonPath;
|
||||
$summary['summary_html'] = $summaryHtmlPath;
|
||||
file_put_contents($summaryJsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public static function runLatest(string $runRoot, array $options = []): array
|
||||
{
|
||||
$templatePath = trim((string)($options['template_path'] ?? ''));
|
||||
$outputRoot = trim((string)($options['output_root'] ?? ''));
|
||||
if ($outputRoot === '') {
|
||||
$outputRoot = self::createRunRoot($runRoot, (string)($options['run_prefix'] ?? 'task'));
|
||||
}
|
||||
$templateSource = 'explicit';
|
||||
|
||||
if ($templatePath === '') {
|
||||
$templateSource = 'latest';
|
||||
$templatePath = self::findLatestTemplatePath($runRoot);
|
||||
}
|
||||
if ($templatePath === '') {
|
||||
$summaryPath = self::findLatestSummaryPath($runRoot);
|
||||
if ($summaryPath !== '') {
|
||||
$templatePath = self::buildReplayTemplateFromSummary($summaryPath, $outputRoot . '/domain-supply-batch-replay.xlsx');
|
||||
$templateSource = 'latest_replay';
|
||||
}
|
||||
}
|
||||
if ($templatePath === '') {
|
||||
throw new \RuntimeException('Latest domain supply template not found under: ' . $runRoot);
|
||||
}
|
||||
|
||||
$summary = self::process($templatePath, array_merge($options, [
|
||||
'output_root' => $outputRoot,
|
||||
]));
|
||||
$summary['template_source'] = $templateSource;
|
||||
$summary['resolved_template_path'] = $templatePath;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
182
code/app/common/helper/DomainSupplyBatchTemplateHelper.php
Normal file
182
code/app/common/helper/DomainSupplyBatchTemplateHelper.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\DomainModel;
|
||||
|
||||
class DomainSupplyBatchTemplateHelper
|
||||
{
|
||||
public static function getHeaders(): array
|
||||
{
|
||||
return [
|
||||
'host',
|
||||
'template_id',
|
||||
'strategy_profile',
|
||||
'match_type',
|
||||
'parent_domain',
|
||||
'seed_scope',
|
||||
'site_name',
|
||||
'site_keywords',
|
||||
'site_description',
|
||||
'site_index_title',
|
||||
'site_index_keywords',
|
||||
'site_index_description',
|
||||
'baidu_token',
|
||||
'seo_cfg_json',
|
||||
'detail_id',
|
||||
'detail_slug',
|
||||
'search_keyword',
|
||||
'category_parent',
|
||||
'category_child',
|
||||
'play_type',
|
||||
'play_index',
|
||||
'forge_index',
|
||||
'rank_slug',
|
||||
'base_url',
|
||||
'bundle_name',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getColumnNotes(): array
|
||||
{
|
||||
return [
|
||||
'host' => '必填,域名或 wildcard 域名;会同时用于站点导入和 bundle 生成。',
|
||||
'template_id' => '必填,站点模板 ID,默认 1。',
|
||||
'strategy_profile' => '可选,standard / traffic / expand / closed,默认 standard。',
|
||||
'match_type' => '可选,exact 或 wildcard,默认 exact。',
|
||||
'parent_domain' => 'wildcard 模式建议填写根域名。',
|
||||
'seed_scope' => '可选,domain 或 host,默认 domain。',
|
||||
'site_name' => '可选,站点名称。',
|
||||
'site_keywords' => '可选,站点关键词。',
|
||||
'site_description' => '可选,站点描述。',
|
||||
'site_index_title' => '可选,首页标题;不填时导入链按站点名称推导。',
|
||||
'site_index_keywords' => '可选,首页关键词;不填时导入链按站点关键词推导。',
|
||||
'site_index_description' => '可选,首页描述;不填时导入链按站点描述推导。',
|
||||
'baidu_token' => '可选,百度推送 token。',
|
||||
'seo_cfg_json' => '可选,JSON;会在 strategy_profile 默认值基础上继续覆盖。',
|
||||
'detail_id' => '必填,启动包详情样本视频 ID。',
|
||||
'detail_slug' => '必填,启动包详情样本 slug。',
|
||||
'search_keyword' => '必填,启动包搜索页样本关键词。',
|
||||
'category_parent' => '必填,父分类 slug。',
|
||||
'category_child' => '必填,子分类 slug。',
|
||||
'play_type' => '可选,默认 douban。',
|
||||
'play_index' => '可选,默认 1。',
|
||||
'forge_index' => '可选,默认 1。',
|
||||
'rank_slug' => '可选,默认 daily。',
|
||||
'base_url' => '可选,默认 https://<host>。',
|
||||
'bundle_name' => '可选,自定义 bundle 目录名。',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getExampleRows(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'host' => 'traffic-supply.example.com',
|
||||
'template_id' => '1',
|
||||
'strategy_profile' => DomainModel::SEO_STRATEGY_PROFILE_TRAFFIC,
|
||||
'match_type' => DomainModel::MATCH_TYPE_EXACT,
|
||||
'parent_domain' => '',
|
||||
'seed_scope' => DomainModel::SEED_SCOPE_DOMAIN,
|
||||
'site_name' => 'Traffic Supply',
|
||||
'site_keywords' => 'Traffic Supply,影视推荐',
|
||||
'site_description' => 'Traffic Supply 内容整理与推荐。',
|
||||
'site_index_title' => 'Traffic Supply 热门影视推荐',
|
||||
'site_index_keywords' => 'Traffic Supply,热门影视推荐',
|
||||
'site_index_description' => 'Traffic Supply 热门内容整理与推荐。',
|
||||
'baidu_token' => '',
|
||||
'seo_cfg_json' => '{"forge":{"route_group":"film"}}',
|
||||
'detail_id' => '154124',
|
||||
'detail_slug' => 'sheng-dan-bu-du-xing-meng-fei-si-lian-qu',
|
||||
'search_keyword' => '圣诞',
|
||||
'category_parent' => 'dian-ying',
|
||||
'category_child' => 'ju-qing-pian',
|
||||
'play_type' => 'youzhi',
|
||||
'play_index' => '1',
|
||||
'forge_index' => '1',
|
||||
'rank_slug' => 'daily',
|
||||
'base_url' => '',
|
||||
'bundle_name' => '',
|
||||
],
|
||||
[
|
||||
'host' => '*.expand-supply.example.com',
|
||||
'template_id' => '1',
|
||||
'strategy_profile' => DomainModel::SEO_STRATEGY_PROFILE_EXPAND,
|
||||
'match_type' => DomainModel::MATCH_TYPE_WILDCARD,
|
||||
'parent_domain' => 'expand-supply.example.com',
|
||||
'seed_scope' => DomainModel::SEED_SCOPE_HOST,
|
||||
'site_name' => 'Expand Supply',
|
||||
'site_keywords' => 'Expand Supply,影视推荐',
|
||||
'site_description' => 'Expand Supply 内容整理与推荐。',
|
||||
'site_index_title' => 'Expand Supply 聚合页推荐',
|
||||
'site_index_keywords' => 'Expand Supply,聚合推荐',
|
||||
'site_index_description' => 'Expand Supply 聚合内容整理与推荐。',
|
||||
'baidu_token' => '',
|
||||
'seo_cfg_json' => '{"structured_data":{"organization_name":"Expand Supply Studio"}}',
|
||||
'detail_id' => '153511',
|
||||
'detail_slug' => 'yi-gua-ding-qian-kun',
|
||||
'search_keyword' => '一卦定乾坤',
|
||||
'category_parent' => 'zong-yi',
|
||||
'category_child' => 'da-lu-zong-yi',
|
||||
'play_type' => 'douban',
|
||||
'play_index' => '1',
|
||||
'forge_index' => '1',
|
||||
'rank_slug' => 'daily',
|
||||
'base_url' => '',
|
||||
'bundle_name' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
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,
|
||||
],
|
||||
'artifacts' => [
|
||||
'site_import' => 'site-domain-import.generated.xlsx',
|
||||
'bootstrap_batch' => 'domain-bootstrap-batch.generated.xlsx',
|
||||
'summary_json' => 'supply.summary.json',
|
||||
'summary_html' => 'supply.summary.html',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function writeTemplate(string $outputRoot): array
|
||||
{
|
||||
$outputRoot = rtrim($outputRoot, '/');
|
||||
$fileName = 'domain-supply-batch-template.xlsx';
|
||||
$filePath = $outputRoot . '/' . $fileName;
|
||||
|
||||
DomainBatchImportTemplateHelper::writeWorkbook(
|
||||
$filePath,
|
||||
'domain-supply-batch',
|
||||
self::getHeaders(),
|
||||
self::getExampleRows(),
|
||||
self::getColumnNotes()
|
||||
);
|
||||
|
||||
return [
|
||||
'file_name' => $fileName,
|
||||
'file_path' => $filePath,
|
||||
'summary' => self::buildSummary(),
|
||||
];
|
||||
}
|
||||
}
|
||||
2817
code/app/common/helper/DomainSupplyRunIndexHelper.php
Normal file
2817
code/app/common/helper/DomainSupplyRunIndexHelper.php
Normal file
File diff suppressed because it is too large
Load Diff
1404
code/app/common/helper/SeoCopyAiProviderHelper.php
Normal file
1404
code/app/common/helper/SeoCopyAiProviderHelper.php
Normal file
File diff suppressed because it is too large
Load Diff
322
code/app/common/helper/SeoCopyApprovedManifestHelper.php
Normal file
322
code/app/common/helper/SeoCopyApprovedManifestHelper.php
Normal file
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
use SplFileInfo;
|
||||
|
||||
class SeoCopyApprovedManifestHelper
|
||||
{
|
||||
public static function buildSummary(string $strApprovedDir, bool $boolAllowMissing = false): array
|
||||
{
|
||||
if (!is_dir($strApprovedDir)) {
|
||||
if (!$boolAllowMissing) {
|
||||
throw new RuntimeException('Approved manifest directory not found: ' . $strApprovedDir);
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'manifests_count' => 0,
|
||||
'manifests' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$arrManifests = [];
|
||||
$Iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($strApprovedDir, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($Iterator as $FileInfo) {
|
||||
/** @var SplFileInfo $FileInfo */
|
||||
if (!$FileInfo->isFile() || strtolower($FileInfo->getExtension()) !== 'json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strPath = str_replace('\\', '/', $FileInfo->getPathname());
|
||||
$strRelativePath = ltrim(str_replace(rtrim(str_replace('\\', '/', $strApprovedDir), '/'), '', $strPath), '/');
|
||||
if (str_starts_with($strRelativePath, 'details/')) {
|
||||
continue;
|
||||
}
|
||||
$strFilename = basename($strPath);
|
||||
if ($strFilename === 'approved-index.json') {
|
||||
continue;
|
||||
}
|
||||
$arrData = json_decode((string)file_get_contents($strPath), true);
|
||||
if (!is_array($arrData)) {
|
||||
continue;
|
||||
}
|
||||
if (self::shouldSkipManifestFile($strFilename, $arrData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrItems = $arrData;
|
||||
if (isset($arrData['items']) && is_array($arrData['items'])) {
|
||||
$arrItems = $arrData['items'];
|
||||
}
|
||||
if (!is_array($arrItems)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrHosts = [];
|
||||
$arrScenes = [];
|
||||
$arrSceneTotals = [];
|
||||
foreach ($arrItems as $arrItem) {
|
||||
if (!is_array($arrItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strHost = trim((string)($arrItem['host'] ?? ''));
|
||||
$strScene = trim((string)($arrItem['scene'] ?? ''));
|
||||
if ($strHost !== '') {
|
||||
$arrHosts[$strHost] = true;
|
||||
}
|
||||
if ($strScene !== '') {
|
||||
$arrScenes[$strScene] = true;
|
||||
$arrSceneTotals[$strScene] = ($arrSceneTotals[$strScene] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($arrSceneTotals, SORT_STRING);
|
||||
$strDetailSlug = self::buildDetailSlug($strFilename);
|
||||
$arrManifests[] = [
|
||||
'filename' => $strFilename,
|
||||
'relative_path' => $strRelativePath,
|
||||
'detail_slug' => $strDetailSlug,
|
||||
'release_tag' => self::buildReleaseTagFromFilename($strFilename),
|
||||
'items_count' => count($arrItems),
|
||||
'hosts' => array_values(array_keys($arrHosts)),
|
||||
'scenes' => array_values(array_keys($arrScenes)),
|
||||
'scene_totals' => $arrSceneTotals,
|
||||
'items' => array_values($arrItems),
|
||||
'updated_at' => date('c', (int)$FileInfo->getMTime()),
|
||||
];
|
||||
}
|
||||
|
||||
usort($arrManifests, static function (array $arrA, array $arrB): int {
|
||||
return strcmp((string)($arrA['filename'] ?? ''), (string)($arrB['filename'] ?? ''));
|
||||
});
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'manifests_count' => count($arrManifests),
|
||||
'manifests' => $arrManifests,
|
||||
];
|
||||
}
|
||||
|
||||
public static function writeSummary(string $strPath, array $arrSummary): void
|
||||
{
|
||||
self::writeFile(
|
||||
$strPath,
|
||||
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
}
|
||||
|
||||
public static function writeArtifacts(string $strOutputRoot, array $arrSummary, bool $boolPortalMode = false): void
|
||||
{
|
||||
self::writeSummary(rtrim($strOutputRoot, '/') . '/approved-index.json', $arrSummary);
|
||||
self::writeFile(rtrim($strOutputRoot, '/') . '/approved-index.html', self::renderHtmlSummary($arrSummary));
|
||||
|
||||
foreach ((array)($arrSummary['manifests'] ?? []) as $arrManifest) {
|
||||
if (!is_array($arrManifest)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strDetailSlug = trim((string)($arrManifest['detail_slug'] ?? ''));
|
||||
if ($strDetailSlug === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::writeFile(
|
||||
rtrim($strOutputRoot, '/') . '/details/' . $strDetailSlug . '.json',
|
||||
json_encode($arrManifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile(
|
||||
rtrim($strOutputRoot, '/') . '/details/' . $strDetailSlug . '.html',
|
||||
self::renderManifestDetailHtml($arrManifest, $boolPortalMode)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function renderTextSummary(array $arrSummary): string
|
||||
{
|
||||
$arrLines = [
|
||||
'generated_at: ' . (string)($arrSummary['generated_at'] ?? ''),
|
||||
'manifests: ' . (int)($arrSummary['manifests_count'] ?? 0),
|
||||
];
|
||||
|
||||
foreach ((array)($arrSummary['manifests'] ?? []) as $arrManifest) {
|
||||
if (!is_array($arrManifest)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrLines[] = '- ' . (string)($arrManifest['filename'] ?? '')
|
||||
. ' | release_tag=' . (string)($arrManifest['release_tag'] ?? '')
|
||||
. ' | items=' . (int)($arrManifest['items_count'] ?? 0)
|
||||
. ' | scenes=' . implode(',', (array)($arrManifest['scenes'] ?? []))
|
||||
. ' | hosts=' . implode(',', (array)($arrManifest['hosts'] ?? []));
|
||||
}
|
||||
|
||||
return implode(PHP_EOL, $arrLines) . PHP_EOL;
|
||||
}
|
||||
|
||||
public static function renderHtmlSummary(array $arrSummary): string
|
||||
{
|
||||
$arrRows = [];
|
||||
foreach ((array)($arrSummary['manifests'] ?? []) as $arrManifest) {
|
||||
if (!is_array($arrManifest)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strFilename = self::escape((string)($arrManifest['filename'] ?? ''));
|
||||
$strReleaseTag = self::escape((string)($arrManifest['release_tag'] ?? ''));
|
||||
$strHosts = self::escape(implode(', ', (array)($arrManifest['hosts'] ?? [])));
|
||||
$strScenes = self::escape(implode(', ', (array)($arrManifest['scenes'] ?? [])));
|
||||
$strUpdatedAt = self::escape((string)($arrManifest['updated_at'] ?? ''));
|
||||
$strDetailSlug = self::escape((string)($arrManifest['detail_slug'] ?? ''));
|
||||
|
||||
$arrRows[] = '<tr>'
|
||||
. '<td><a href="./details/' . $strDetailSlug . '.html">' . $strFilename . '</a></td>'
|
||||
. '<td>' . $strReleaseTag . '</td>'
|
||||
. '<td>' . (int)($arrManifest['items_count'] ?? 0) . '</td>'
|
||||
. '<td>' . $strHosts . '</td>'
|
||||
. '<td>' . $strScenes . '</td>'
|
||||
. '<td>' . $strUpdatedAt . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'Approved Manifest Index',
|
||||
'<p class="lead">内部只读清单页,展示已批准 manifest 的覆盖范围与更新时间。</p>'
|
||||
. '<div class="meta-grid">'
|
||||
. '<div><strong>generated_at</strong><span>' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>manifests</strong><span>' . (int)($arrSummary['manifests_count'] ?? 0) . '</span></div>'
|
||||
. '</div>'
|
||||
. '<table><thead><tr><th>filename</th><th>release_tag</th><th>items</th><th>hosts</th><th>scenes</th><th>updated_at</th></tr></thead><tbody>'
|
||||
. implode('', $arrRows)
|
||||
. '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
public static function buildReleaseTagFromFilename(string $strFilename): string
|
||||
{
|
||||
if (str_ends_with($strFilename, '.manifest.json')) {
|
||||
return substr($strFilename, 0, -strlen('.manifest.json'));
|
||||
}
|
||||
|
||||
if (str_ends_with($strFilename, '.json')) {
|
||||
return substr($strFilename, 0, -strlen('.json'));
|
||||
}
|
||||
|
||||
return $strFilename;
|
||||
}
|
||||
|
||||
protected static function shouldSkipManifestFile(string $strFilename, array $arrData): bool
|
||||
{
|
||||
if ((bool)preg_match('/^\d{8}_\d{6}\.manifest\.json$/', $strFilename)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($arrData['source_manifest']) && isset($arrData['manifest_digest'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static function renderManifestDetailHtml(array $arrManifest, bool $boolHasPortalParent = false): string
|
||||
{
|
||||
$arrCards = [
|
||||
'<div><strong>filename</strong><span>' . self::escape((string)($arrManifest['filename'] ?? '')) . '</span></div>',
|
||||
'<div><strong>release_tag</strong><span>' . self::escape((string)($arrManifest['release_tag'] ?? '')) . '</span></div>',
|
||||
'<div><strong>items</strong><span>' . (int)($arrManifest['items_count'] ?? 0) . '</span></div>',
|
||||
'<div><strong>hosts</strong><span>' . self::escape(implode(', ', (array)($arrManifest['hosts'] ?? []))) . '</span></div>',
|
||||
'<div><strong>scenes</strong><span>' . self::escape(implode(', ', (array)($arrManifest['scenes'] ?? []))) . '</span></div>',
|
||||
'<div><strong>updated_at</strong><span>' . self::escape((string)($arrManifest['updated_at'] ?? '')) . '</span></div>',
|
||||
];
|
||||
|
||||
$arrItemRows = [];
|
||||
foreach ((array)($arrManifest['items'] ?? []) as $arrItem) {
|
||||
if (!is_array($arrItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrItemRows[] = '<tr>'
|
||||
. '<td>' . self::escape((string)($arrItem['host'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrItem['scene'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape(json_encode(array_values((array)($arrItem['page_parts'] ?? [])), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
$arrSceneTotalsRows = [];
|
||||
foreach ((array)($arrManifest['scene_totals'] ?? []) as $strScene => $intCount) {
|
||||
$arrSceneTotalsRows[] = '<tr>'
|
||||
. '<td>' . self::escape((string)$strScene) . '</td>'
|
||||
. '<td>' . (int)$intCount . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
$arrNavLinks = ['<a href="../approved-index.html">返回 approved 清单总览</a>'];
|
||||
if ($boolHasPortalParent) {
|
||||
$arrNavLinks[] = '<a href="../../index.html">返回门户首页</a>';
|
||||
$arrNavLinks[] = '<a href="../../runs/index.html">查看门禁运行记录</a>';
|
||||
$arrNavLinks[] = '<a href="../../ops/index.html">查看发布 SOP</a>';
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'Approved Manifest Detail',
|
||||
'<p class="lead">内部只读 manifest 详情页,展示当前 approved 清单覆盖的 host、scene 与 page_parts。</p>'
|
||||
. '<p>' . implode(' | ', $arrNavLinks) . '</p>'
|
||||
. '<div class="meta-grid">' . implode('', $arrCards) . '</div>'
|
||||
. '<h2>Scene Totals</h2><table><thead><tr><th>scene</th><th>count</th></tr></thead><tbody>' . implode('', $arrSceneTotalsRows) . '</tbody></table>'
|
||||
. '<h2>Items</h2><table><thead><tr><th>host</th><th>scene</th><th>page_parts</th></tr></thead><tbody>' . implode('', $arrItemRows) . '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
protected static function buildDetailSlug(string $strFilename): string
|
||||
{
|
||||
$strSlug = self::buildReleaseTagFromFilename($strFilename);
|
||||
$strSlug = preg_replace('/[^a-zA-Z0-9\\-_]+/', '-', strtolower(trim($strSlug)));
|
||||
$strSlug = trim((string)$strSlug, '-_');
|
||||
|
||||
return $strSlug !== '' ? $strSlug : sha1($strFilename);
|
||||
}
|
||||
|
||||
protected static function writeFile(string $strPath, string $strContent): 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, $strContent);
|
||||
}
|
||||
|
||||
protected static function escape(string $strValue): string
|
||||
{
|
||||
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
protected static function wrapHtml(string $strTitle, string $strBody): string
|
||||
{
|
||||
$strEscapedTitle = self::escape($strTitle);
|
||||
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
. '<meta name="robots" content="noindex,nofollow,noarchive">'
|
||||
. '<title>' . $strEscapedTitle . '</title>'
|
||||
. '<style>'
|
||||
. 'body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:24px;background:#f5f7fb;color:#1f2937;}'
|
||||
. 'h1{margin:0 0 16px;}'
|
||||
. 'p{line-height:1.7;}'
|
||||
. '.lead{margin:0 0 16px;color:#475569;}'
|
||||
. 'table{width:100%;border-collapse:collapse;background:#fff;margin:16px 0 24px;border-radius:12px;overflow:hidden;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. 'th,td{padding:12px 14px;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top;font-size:14px;}'
|
||||
. 'th{background:#eef2ff;color:#334155;font-weight:700;}'
|
||||
. '.meta-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px;}'
|
||||
. '.meta-grid div{background:#fff;padding:14px 16px;border-radius:12px;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. '.meta-grid strong{display:block;font-size:12px;color:#64748b;margin:0 0 6px;text-transform:uppercase;letter-spacing:.04em;}'
|
||||
. '.meta-grid span{font-size:14px;color:#111827;}'
|
||||
. '</style></head><body><h1>' . $strEscapedTitle . '</h1>' . $strBody . '</body></html>';
|
||||
}
|
||||
}
|
||||
15
code/app/common/helper/SeoCopyBatchImportHelper.php
Normal file
15
code/app/common/helper/SeoCopyBatchImportHelper.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/scripts/seo_copy_batch_import.php';
|
||||
|
||||
class SeoCopyBatchImportHelper
|
||||
{
|
||||
public static function importFromSource(string $strSourceDir, string $strTargetRoot): array
|
||||
{
|
||||
return seoCopyBatchImportExecute($strSourceDir, $strTargetRoot);
|
||||
}
|
||||
}
|
||||
307
code/app/common/helper/SeoCopyBatchPrepareHelper.php
Normal file
307
code/app/common/helper/SeoCopyBatchPrepareHelper.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
|
||||
class SeoCopyBatchPrepareHelper
|
||||
{
|
||||
public static function codeRoot(): string
|
||||
{
|
||||
return dirname(__DIR__, 3);
|
||||
}
|
||||
|
||||
public static function defaultApprovedDir(): string
|
||||
{
|
||||
return self::codeRoot() . '/data/seo_copy_jobs/approved';
|
||||
}
|
||||
|
||||
public static function defaultBatchRoot(): string
|
||||
{
|
||||
return self::codeRoot() . '/storage/seo_copy_batch';
|
||||
}
|
||||
|
||||
public static function normalizeHostDir(string $strHost): string
|
||||
{
|
||||
return SeoCopyStore::buildPageKey($strHost, 'host');
|
||||
}
|
||||
|
||||
public static function buildReleaseTagFromManifestPath(string $strManifestPath): string
|
||||
{
|
||||
$strFilename = basename($strManifestPath);
|
||||
if (str_ends_with($strFilename, '.manifest.json')) {
|
||||
$strFilename = substr($strFilename, 0, -strlen('.manifest.json'));
|
||||
} elseif (str_ends_with($strFilename, '.json')) {
|
||||
$strFilename = substr($strFilename, 0, -strlen('.json'));
|
||||
}
|
||||
|
||||
$strFilename = preg_replace('/[^a-zA-Z0-9\\-_]+/', '-', strtolower(trim($strFilename)));
|
||||
$strFilename = trim((string)$strFilename, '-_');
|
||||
|
||||
return $strFilename !== '' ? $strFilename : date('Ymd_His');
|
||||
}
|
||||
|
||||
public static function resolveManifestPath(string $strInput, string $strApprovedDir): string
|
||||
{
|
||||
$strInput = trim($strInput);
|
||||
if ($strInput !== '' && is_file($strInput)) {
|
||||
return $strInput;
|
||||
}
|
||||
|
||||
if ($strInput === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$arrCandidates = [
|
||||
rtrim($strApprovedDir, '/') . '/' . $strInput,
|
||||
rtrim($strApprovedDir, '/') . '/' . $strInput . '.json',
|
||||
rtrim($strApprovedDir, '/') . '/' . $strInput . '.manifest.json',
|
||||
];
|
||||
|
||||
foreach ($arrCandidates as $strCandidate) {
|
||||
if (is_file($strCandidate)) {
|
||||
return $strCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $strInput;
|
||||
}
|
||||
|
||||
public static function buildPromptMarkdown(string $strHost, string $strScene, array $arrPageParts, array $arrFacts): string
|
||||
{
|
||||
$strPageKey = SeoCopySchema::buildScenePageKey($strScene, $arrPageParts);
|
||||
$arrEnvelope = SeoCopySchema::buildGenerationEnvelope($strScene);
|
||||
$strTargetPath = self::codeRoot() . '/data/seo_copy/' . self::normalizeHostDir($strHost) . '/' . $strScene . '/' . $strPageKey . '.json';
|
||||
|
||||
$strRulesJson = json_encode($arrEnvelope['rules'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$strFactHintsJson = json_encode($arrEnvelope['fact_hints'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$strFactsJson = json_encode(!empty($arrFacts) ? $arrFacts : (object)[], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$strTemplateJson = json_encode($arrEnvelope['template'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return implode(PHP_EOL, [
|
||||
'# SEO Copy Prompt',
|
||||
'',
|
||||
'## Target',
|
||||
'- host: ' . $strHost,
|
||||
'- scene: ' . $strScene,
|
||||
'- page_key: ' . $strPageKey,
|
||||
'- target_path: ' . $strTargetPath,
|
||||
'',
|
||||
'## Instruction',
|
||||
'你是站群 SEO 内容补料助手。请基于给定 facts 生成一个 JSON 对象,只输出 JSON,不要输出解释、Markdown、代码块。',
|
||||
'要求:',
|
||||
'- 必须严格遵守 scene rules 和 template 字段结构。',
|
||||
'- 只能改写和组织已知事实,不要编造演员、年份、剧情、线路、榜单数据。',
|
||||
'- 文案目标是提升页面可读性、解释力和 SEO 覆盖,但不要堆砌关键词。',
|
||||
'- 文风要自然、克制、站内导航型,避免“全网最全”“免费观看”等夸张承诺。',
|
||||
'- 输出必须能直接写入目标 JSON 文件。',
|
||||
'',
|
||||
'## Scene Rules',
|
||||
(string)$strRulesJson,
|
||||
'',
|
||||
'## Fact Hints',
|
||||
(string)$strFactHintsJson,
|
||||
'',
|
||||
'## Facts',
|
||||
(string)$strFactsJson,
|
||||
'',
|
||||
'## Return JSON Template',
|
||||
(string)$strTemplateJson,
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function prepareManifest(string $strManifestPath, array $arrOptions = []): array
|
||||
{
|
||||
$strApprovedDir = trim((string)($arrOptions['approved_dir'] ?? self::defaultApprovedDir()));
|
||||
$strManifestPath = self::resolveManifestPath($strManifestPath, $strApprovedDir);
|
||||
if ($strManifestPath === '' || !is_file($strManifestPath)) {
|
||||
throw new RuntimeException('Manifest file not found: ' . $strManifestPath);
|
||||
}
|
||||
|
||||
$strOutDir = trim((string)($arrOptions['out_dir'] ?? ''));
|
||||
if ($strOutDir === '') {
|
||||
$strOutDir = self::defaultBatchRoot() . '/' . date('Ymd_His') . '_' . self::buildReleaseTagFromManifestPath($strManifestPath);
|
||||
}
|
||||
|
||||
$boolCleanOutDir = (bool)($arrOptions['clean_out_dir'] ?? false);
|
||||
if ($boolCleanOutDir && is_dir($strOutDir)) {
|
||||
self::removeDirectoryContents($strOutDir);
|
||||
}
|
||||
|
||||
self::ensureDirectory($strOutDir);
|
||||
|
||||
$arrManifest = json_decode((string)file_get_contents($strManifestPath), true);
|
||||
if (!is_array($arrManifest)) {
|
||||
throw new RuntimeException('Manifest must be a JSON array.');
|
||||
}
|
||||
|
||||
$arrSummary = [];
|
||||
foreach ($arrManifest as $intIndex => $arrItem) {
|
||||
if (!is_array($arrItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strHost = trim((string)($arrItem['host'] ?? ''));
|
||||
$strScene = trim((string)($arrItem['scene'] ?? ''));
|
||||
$arrPageParts = array_values((array)($arrItem['page_parts'] ?? []));
|
||||
|
||||
if ($strHost === '' || $strScene === '' || !in_array($strScene, SeoCopySchema::getSupportedScenes(), true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strPageKey = SeoCopySchema::buildScenePageKey($strScene, $arrPageParts);
|
||||
$arrFacts = SeoCopyFactsBuilder::build($strHost, $strScene, $arrPageParts);
|
||||
$strPromptMarkdown = self::buildPromptMarkdown($strHost, $strScene, $arrPageParts, $arrFacts);
|
||||
|
||||
$strItemDir = rtrim($strOutDir, '/') . '/' . self::normalizeHostDir($strHost) . '/' . $strScene;
|
||||
self::ensureDirectory($strItemDir);
|
||||
|
||||
$strFactsPath = $strItemDir . '/' . $strPageKey . '.facts.json';
|
||||
$strPromptPath = $strItemDir . '/' . $strPageKey . '.prompt.md';
|
||||
|
||||
file_put_contents($strFactsPath, json_encode($arrFacts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
file_put_contents($strPromptPath, $strPromptMarkdown);
|
||||
|
||||
$arrSummary[] = [
|
||||
'index' => $intIndex,
|
||||
'host' => $strHost,
|
||||
'scene' => $strScene,
|
||||
'page_key' => $strPageKey,
|
||||
'facts_path' => $strFactsPath,
|
||||
'prompt_path' => $strPromptPath,
|
||||
];
|
||||
}
|
||||
|
||||
$arrOutput = [
|
||||
'manifest' => realpath($strManifestPath) ?: $strManifestPath,
|
||||
'release_tag' => self::buildReleaseTagFromManifestPath($strManifestPath),
|
||||
'approved_dir' => realpath($strApprovedDir) ?: $strApprovedDir,
|
||||
'out_dir' => $strOutDir,
|
||||
'count' => count($arrSummary),
|
||||
'items' => $arrSummary,
|
||||
];
|
||||
|
||||
file_put_contents(
|
||||
rtrim($strOutDir, '/') . '/prepare-summary.json',
|
||||
json_encode($arrOutput, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
file_put_contents(
|
||||
rtrim($strOutDir, '/') . '/manifest.copy.json',
|
||||
json_encode($arrManifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
|
||||
return $arrOutput;
|
||||
}
|
||||
|
||||
public static function prepareApprovedManifests(array $arrApprovedSummary, string $strBatchRoot, array $arrOptions = []): array
|
||||
{
|
||||
$strApprovedDir = trim((string)($arrOptions['approved_dir'] ?? self::defaultApprovedDir()));
|
||||
$boolForce = (bool)($arrOptions['force'] ?? false);
|
||||
$arrResults = [];
|
||||
|
||||
foreach ((array)($arrApprovedSummary['manifests'] ?? []) as $arrManifest) {
|
||||
if (!is_array($arrManifest)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strReleaseTag = trim((string)($arrManifest['release_tag'] ?? ''));
|
||||
$strRelativePath = trim((string)($arrManifest['relative_path'] ?? ''));
|
||||
$strFilename = trim((string)($arrManifest['filename'] ?? ''));
|
||||
if ($strReleaseTag === '' || ($strRelativePath === '' && $strFilename === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strManifestPath = rtrim($strApprovedDir, '/') . '/' . ($strRelativePath !== '' ? $strRelativePath : $strFilename);
|
||||
$strOutDir = rtrim($strBatchRoot, '/') . '/approved/' . $strReleaseTag;
|
||||
$strSummaryPath = $strOutDir . '/prepare-summary.json';
|
||||
|
||||
$boolShouldSkip = false;
|
||||
if (!$boolForce && is_file($strManifestPath) && is_file($strSummaryPath)) {
|
||||
$boolShouldSkip = self::isSummaryReadable($strSummaryPath) && filemtime($strSummaryPath) >= filemtime($strManifestPath);
|
||||
}
|
||||
|
||||
if ($boolShouldSkip) {
|
||||
$arrResults[] = [
|
||||
'release_tag' => $strReleaseTag,
|
||||
'manifest' => $strManifestPath,
|
||||
'out_dir' => $strOutDir,
|
||||
'status' => 'skipped',
|
||||
'reason' => 'up_to_date',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrPrepared = self::prepareManifest($strManifestPath, [
|
||||
'approved_dir' => $strApprovedDir,
|
||||
'out_dir' => $strOutDir,
|
||||
'clean_out_dir' => true,
|
||||
]);
|
||||
$arrPrepared['status'] = 'prepared';
|
||||
$arrResults[] = $arrPrepared;
|
||||
}
|
||||
|
||||
$intPreparedCount = 0;
|
||||
$intSkippedCount = 0;
|
||||
foreach ($arrResults as $arrItem) {
|
||||
if (($arrItem['status'] ?? '') === 'prepared') {
|
||||
$intPreparedCount++;
|
||||
continue;
|
||||
}
|
||||
if (($arrItem['status'] ?? '') === 'skipped') {
|
||||
$intSkippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'approved_dir' => realpath($strApprovedDir) ?: $strApprovedDir,
|
||||
'batch_root' => $strBatchRoot,
|
||||
'manifests_total' => count((array)($arrApprovedSummary['manifests'] ?? [])),
|
||||
'prepared_count' => $intPreparedCount,
|
||||
'skipped_count' => $intSkippedCount,
|
||||
'items' => $arrResults,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function ensureDirectory(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 removeDirectoryContents(string $strDir): void
|
||||
{
|
||||
if (!is_dir($strDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$Iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($strDir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($Iterator as $FileInfo) {
|
||||
$strPath = $FileInfo->getPathname();
|
||||
if ($FileInfo->isDir()) {
|
||||
@rmdir($strPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
@unlink($strPath);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function isSummaryReadable(string $strSummaryPath): bool
|
||||
{
|
||||
$arrData = json_decode((string)file_get_contents($strSummaryPath), true);
|
||||
|
||||
return is_array($arrData) && isset($arrData['count']) && isset($arrData['items']) && is_array($arrData['items']);
|
||||
}
|
||||
}
|
||||
300
code/app/common/helper/SeoCopyContextHelper.php
Normal file
300
code/app/common/helper/SeoCopyContextHelper.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class SeoCopyContextHelper
|
||||
{
|
||||
public static function buildResourceContext(string $strHost): array
|
||||
{
|
||||
$strHost = \app\model\DomainModel::normalizeHost($strHost);
|
||||
if ($strHost === '') {
|
||||
return [
|
||||
'positioning' => [],
|
||||
'feedback' => [],
|
||||
'phrase_texts' => [],
|
||||
'priority_keywords' => [],
|
||||
'feedback_keywords' => [],
|
||||
'topics' => [],
|
||||
'hot_keyword' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$strRoot = SeoResourcePoolHelper::resourceRoot();
|
||||
$strHostKey = SeoResourcePoolHelper::hostFileKey($strHost);
|
||||
$arrPositioning = SeoResourcePoolHelper::readJsonFile($strRoot . '/site_positioning/by_host/' . $strHostKey . '.json');
|
||||
$arrFeedback = SeoResourcePoolHelper::readJsonFile($strRoot . '/keyword_feedback/by_host/' . $strHostKey . '.json');
|
||||
|
||||
$arrPriorityKeywords = array_values(array_filter(array_map(static function ($value): string {
|
||||
return trim((string)$value);
|
||||
}, (array)($arrPositioning['priority_keywords'] ?? [])), static function (string $value): bool {
|
||||
return $value !== '';
|
||||
}));
|
||||
$arrTopics = array_values(array_filter(array_map(static function ($value): string {
|
||||
return trim((string)$value);
|
||||
}, array_merge((array)($arrPositioning['primary_topics'] ?? []), (array)($arrPositioning['secondary_topics'] ?? []))), static function (string $value): bool {
|
||||
return $value !== '';
|
||||
}));
|
||||
$arrFeedbackKeywords = [];
|
||||
foreach ((array)($arrFeedback['keywords'] ?? []) as $arrItem) {
|
||||
if (!is_array($arrItem)) {
|
||||
continue;
|
||||
}
|
||||
$strKeyword = trim((string)($arrItem['keyword'] ?? ''));
|
||||
if ($strKeyword !== '' && !in_array($strKeyword, $arrFeedbackKeywords, true)) {
|
||||
$arrFeedbackKeywords[] = $strKeyword;
|
||||
}
|
||||
if (count($arrFeedbackKeywords) >= 8) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$arrPhraseTexts = [];
|
||||
$arrBlocks = [
|
||||
implode(',', $arrPriorityKeywords),
|
||||
implode(',', $arrTopics),
|
||||
implode(',', $arrFeedbackKeywords),
|
||||
trim((string)($arrPositioning['homepage_goal'] ?? '')),
|
||||
trim((string)($arrPositioning['tone'] ?? '')),
|
||||
trim((string)($arrFeedback['domain_keyword'] ?? '')),
|
||||
];
|
||||
foreach ($arrBlocks as $strText) {
|
||||
$strText = trim((string)$strText);
|
||||
if ($strText !== '') {
|
||||
$arrPhraseTexts[] = $strText;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'positioning' => $arrPositioning,
|
||||
'feedback' => $arrFeedback,
|
||||
'phrase_texts' => $arrPhraseTexts,
|
||||
'priority_keywords' => $arrPriorityKeywords,
|
||||
'feedback_keywords' => $arrFeedbackKeywords,
|
||||
'topics' => $arrTopics,
|
||||
'hot_keyword' => $arrFeedbackKeywords[0] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildHomeMetaBundle(
|
||||
string $strSiteName,
|
||||
string $strHost,
|
||||
string $strRawTitle,
|
||||
string $strRawKeywords,
|
||||
string $strRawDescription,
|
||||
array $arrOptions = []
|
||||
): array {
|
||||
$strSiteName = trim($strSiteName);
|
||||
$strHost = trim($strHost);
|
||||
$intSeed = (int)($arrOptions['seed'] ?? crc32($strHost !== '' ? $strHost : ($strSiteName !== '' ? $strSiteName : 'home')));
|
||||
$strPrimarySlotLabel = trim((string)($arrOptions['primary_slot_label'] ?? '推荐区'));
|
||||
$strCategorySummary = trim((string)($arrOptions['category_summary'] ?? '电影、剧集、综艺'));
|
||||
$strTkdMode = \app\model\DomainModel::normalizeTkdMode((string)($arrOptions['tkd_mode'] ?? \app\model\DomainModel::TKD_MODE_AUTO_GENERATE));
|
||||
$strTkdProvider = \app\model\DomainModel::normalizeTkdProvider((string)($arrOptions['tkd_provider'] ?? \app\model\DomainModel::TKD_PROVIDER_LOCAL));
|
||||
$arrExclude = array_values(array_filter([
|
||||
$strSiteName,
|
||||
$strHost,
|
||||
preg_replace('/^www\./i', '', $strHost),
|
||||
$strSiteName !== '' ? preg_replace('/影视网?$/u', '', $strSiteName) : '',
|
||||
], static function ($strItem): bool {
|
||||
return trim((string)$strItem) !== '';
|
||||
}));
|
||||
|
||||
$arrResourceContext = self::buildResourceContext($strHost);
|
||||
$arrPhrases = self::extractPhrases(array_merge(
|
||||
(array)($arrResourceContext['phrase_texts'] ?? []),
|
||||
[
|
||||
$strRawTitle,
|
||||
$strRawKeywords,
|
||||
$strRawDescription,
|
||||
]
|
||||
), 8, $arrExclude);
|
||||
|
||||
$strPrimary = $arrPhrases[0] ?? '影视内容推荐';
|
||||
$strSecondary = $arrPhrases[1] ?? '高清内容整理';
|
||||
$strTertiary = $arrPhrases[2] ?? '热门片单更新';
|
||||
$strQuaternary = $arrPhrases[3] ?? '剧情与演员资料';
|
||||
$strSiteSuffix = $strSiteName !== '' ? ('-' . $strSiteName) : '';
|
||||
|
||||
if ($strTkdMode === \app\model\DomainModel::TKD_MODE_FORCE_IMPORT) {
|
||||
return [
|
||||
'title' => trim($strRawTitle),
|
||||
'keywords' => trim($strRawKeywords),
|
||||
'description' => trim($strRawDescription),
|
||||
'phrases' => $arrPhrases,
|
||||
'primary_phrase' => $strPrimary,
|
||||
'secondary_phrase' => $strSecondary,
|
||||
'tertiary_phrase' => $strTertiary,
|
||||
'quaternary_phrase' => $strQuaternary,
|
||||
'mode' => $strTkdMode,
|
||||
'provider' => $strTkdProvider,
|
||||
];
|
||||
}
|
||||
|
||||
$intTitleSeparators = substr_count($strRawTitle, ',') + substr_count($strRawTitle, ',');
|
||||
$strRawTitle = trim($strRawTitle);
|
||||
if (
|
||||
$strTkdMode === \app\model\DomainModel::TKD_MODE_AI_OPTIMIZE
|
||||
&& $strRawTitle !== ''
|
||||
&& !str_contains($strRawTitle, '{')
|
||||
&& !str_contains($strRawTitle, '}')
|
||||
&& mb_strlen($strRawTitle) <= 42
|
||||
&& $intTitleSeparators <= 2
|
||||
) {
|
||||
$strTitle = $strRawTitle;
|
||||
} elseif (
|
||||
$strRawTitle !== ''
|
||||
&& !str_contains($strRawTitle, '{')
|
||||
&& !str_contains($strRawTitle, '}')
|
||||
&& mb_strlen($strRawTitle) <= 42
|
||||
&& $intTitleSeparators <= 2
|
||||
) {
|
||||
$strTitle = $strRawTitle;
|
||||
} else {
|
||||
$arrTitleParts = array_slice(array_values(array_filter([
|
||||
$strPrimary,
|
||||
$strSecondary,
|
||||
$strTertiary,
|
||||
])), 0, 3);
|
||||
$strTitle = implode('、', $arrTitleParts) . $strSiteSuffix;
|
||||
}
|
||||
|
||||
$arrKeywords = [];
|
||||
foreach (array_merge([$strSiteName], (array)($arrResourceContext['priority_keywords'] ?? []), (array)($arrResourceContext['feedback_keywords'] ?? []), array_slice($arrPhrases, 0, 6)) as $strKeyword) {
|
||||
$strKeyword = trim((string)$strKeyword);
|
||||
if ($strKeyword === '' || in_array($strKeyword, $arrKeywords, true)) {
|
||||
continue;
|
||||
}
|
||||
$arrKeywords[] = $strKeyword;
|
||||
}
|
||||
if (empty($arrKeywords)) {
|
||||
$arrKeywords = array_values(array_filter([$strSiteName, '影视内容推荐', '剧情介绍', '演员资料']));
|
||||
}
|
||||
|
||||
$strPhraseSummary = implode('、', array_slice($arrPhrases, 0, 4));
|
||||
if ($strPhraseSummary === '') {
|
||||
$strPhraseSummary = '影视内容推荐、剧情介绍、演员资料、播放线索';
|
||||
}
|
||||
|
||||
$strHotKeyword = trim((string)($arrResourceContext['hot_keyword'] ?? ''));
|
||||
$arrDescriptionTailPool = [
|
||||
'首屏优先承接%s和%s等入口,适合先看重点内容,再按分类或榜单继续筛选。',
|
||||
'首页会持续围绕%s补充内容线索,方便先快速浏览,再继续细看相关题材。',
|
||||
'当前首页更适合先从%s切入,再结合%s这些方向继续做内容筛选。',
|
||||
'如果先看首页主推内容,再顺着%s深入浏览,整体找片效率会更高。',
|
||||
'页面会把%s放在更靠前的位置,便于先判断内容方向,再决定继续深看哪一类。',
|
||||
'首页浏览节奏更偏向先看%s,再围绕%s逐步缩小范围。',
|
||||
$strHotKeyword !== '' ? '站外回推里最近更贴近“' . $strHotKeyword . '”这类词,首页会优先围绕%s与%s重新组织首屏信息。' : '',
|
||||
];
|
||||
$arrDescriptionTailPool = array_values(array_filter($arrDescriptionTailPool, static fn(string $item): bool => trim($item) !== ''));
|
||||
$strDescriptionTailTemplate = $arrDescriptionTailPool[abs($intSeed) % count($arrDescriptionTailPool)];
|
||||
$strDescriptionTail = sprintf(
|
||||
$strDescriptionTailTemplate,
|
||||
$strPrimarySlotLabel,
|
||||
$strCategorySummary
|
||||
);
|
||||
|
||||
$strDescription = $strSiteName !== ''
|
||||
? sprintf(
|
||||
'%s围绕%s等主题整理首页内容,持续补充热门片单、剧情介绍、演员资料与播放线索。%s',
|
||||
$strSiteName,
|
||||
$strPhraseSummary,
|
||||
$strDescriptionTail
|
||||
)
|
||||
: sprintf(
|
||||
'当前首页围绕%s等主题整理内容,持续补充热门片单、剧情介绍、演员资料与播放线索。%s',
|
||||
$strPhraseSummary,
|
||||
$strDescriptionTail
|
||||
);
|
||||
|
||||
return [
|
||||
'title' => trim($strTitle),
|
||||
'keywords' => implode(',', $arrKeywords),
|
||||
'description' => trim($strDescription),
|
||||
'phrases' => $arrPhrases,
|
||||
'primary_phrase' => $strPrimary,
|
||||
'secondary_phrase' => $strSecondary,
|
||||
'tertiary_phrase' => $strTertiary,
|
||||
'quaternary_phrase' => $strQuaternary,
|
||||
'mode' => $strTkdMode,
|
||||
'provider' => $strTkdProvider,
|
||||
];
|
||||
}
|
||||
|
||||
public static function extractPhrases(array $arrTexts, int $intLimit = 6, array $arrExclude = []): array
|
||||
{
|
||||
$arrStopWords = [
|
||||
'首页', '推荐', '内容', '影视', '影视网', '视频', '站内', '当前', '结果',
|
||||
'搜索', '列表', '分类', '频道', '剧情', '介绍', '演员', '资料', '播放',
|
||||
'线索', '大全', '在线观看', '全集', '高清', '最新', '热门',
|
||||
];
|
||||
$arrExclude = array_values(array_filter(array_map(static function ($strItem): string {
|
||||
return trim((string)$strItem);
|
||||
}, $arrExclude), static function (string $strItem): bool {
|
||||
return $strItem !== '';
|
||||
}));
|
||||
|
||||
$arrOut = [];
|
||||
|
||||
foreach ($arrTexts as $strText) {
|
||||
$strText = trim((string)$strText);
|
||||
if ($strText === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrParts = preg_split('/[\\s,,。;;::\\|\\/\\-_]+/u', $strText) ?: [];
|
||||
foreach ($arrParts as $strPart) {
|
||||
$strPart = trim((string)$strPart);
|
||||
if ($strPart === '' || mb_strlen($strPart) < 2 || mb_strlen($strPart) > 18) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($strPart, $arrStopWords, true)) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($strPart, $arrExclude, true)) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/(是一个|为您提供|欢迎|尽在|在线观看|免费看|免费高清|好网站|视觉体验|拥有全网|影视剧资源|各种|平台|专区|网站)/u', $strPart) === 1) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/(搜索结果|分类频道|内容列表|搜索落地页)$/u', $strPart) === 1) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($strPart, $arrOut, true)) {
|
||||
$arrOut[] = $strPart;
|
||||
}
|
||||
if (count($arrOut) >= $intLimit) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $arrOut;
|
||||
}
|
||||
|
||||
public static function pickPrimaryPhrase(array $arrTexts, string $strFallback, array $arrExclude = []): string
|
||||
{
|
||||
$arrPhrases = self::extractPhrases($arrTexts, 1, $arrExclude);
|
||||
return $arrPhrases[0] ?? trim($strFallback);
|
||||
}
|
||||
|
||||
public static function joinTopPhrases(array $arrTexts, string $strFallback, int $intLimit = 3, array $arrExclude = []): string
|
||||
{
|
||||
$arrPhrases = self::extractPhrases($arrTexts, $intLimit, $arrExclude);
|
||||
if (empty($arrPhrases)) {
|
||||
return trim($strFallback);
|
||||
}
|
||||
|
||||
return implode('、', array_slice($arrPhrases, 0, $intLimit));
|
||||
}
|
||||
|
||||
public static function mergeTextsWithResourceContext(array $arrTexts, string $strHost): array
|
||||
{
|
||||
$arrResource = self::buildResourceContext($strHost);
|
||||
return array_values(array_filter(array_merge(
|
||||
(array)($arrResource['phrase_texts'] ?? []),
|
||||
$arrTexts
|
||||
), static function ($value): bool {
|
||||
return trim((string)$value) !== '';
|
||||
}));
|
||||
}
|
||||
}
|
||||
385
code/app/common/helper/SeoCopyFactsBuilder.php
Normal file
385
code/app/common/helper/SeoCopyFactsBuilder.php
Normal file
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\DomainModel;
|
||||
use app\model\VideoCategoryModel;
|
||||
use app\model\VideoClicksModel;
|
||||
use app\model\VideoModel;
|
||||
use app\services\StaticConfig;
|
||||
|
||||
class SeoCopyFactsBuilder
|
||||
{
|
||||
public static function build(string $strHost, string $strScene, array $arrPageParts): array
|
||||
{
|
||||
$strHost = trim($strHost);
|
||||
$arrPageParts = array_values(array_filter(array_map(static fn($mVal) => trim((string)$mVal), $arrPageParts), static fn($strVal) => $strVal !== ''));
|
||||
|
||||
/** @var DomainModel|null $DomainRow */
|
||||
$DomainRow = app(DomainModel::class)->where('d_domain', $strHost)->find();
|
||||
$arrTpStyle = $DomainRow ? SiteStyle::getConfig($DomainRow, $strHost) : [];
|
||||
|
||||
return match ($strScene) {
|
||||
'home' => self::buildHomeFacts($strHost, $DomainRow, $arrTpStyle),
|
||||
'category_index' => self::buildCategoryIndexFacts($strHost, $DomainRow, $arrPageParts),
|
||||
'category_list' => self::buildCategoryListFacts($strHost, $DomainRow, $arrTpStyle, $arrPageParts),
|
||||
'search' => self::buildSearchFacts($strHost, $DomainRow, $arrTpStyle, $arrPageParts),
|
||||
'rank_index' => self::buildRankIndexFacts($strHost, $DomainRow),
|
||||
'rank_list' => self::buildRankListFacts($strHost, $DomainRow, $arrTpStyle, $arrPageParts),
|
||||
'detail' => self::buildDetailFacts($strHost, $DomainRow, $arrPageParts),
|
||||
'forge' => self::buildForgeFacts($strHost, $DomainRow, $arrPageParts),
|
||||
'play' => self::buildPlayFacts($strHost, $DomainRow, $arrPageParts),
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
protected static function buildHomeFacts(string $strHost, ?DomainModel $DomainRow, array $arrTpStyle): array
|
||||
{
|
||||
$arrHomePage = (array)($arrTpStyle['template_cfg']['pages']['home'] ?? []);
|
||||
$arrHomeCategories = array_values(array_filter(array_map(
|
||||
static fn(array $arrItem) => trim((string)($arrItem['name'] ?? '')),
|
||||
(array)($arrHomePage['categories'] ?? [])
|
||||
)));
|
||||
|
||||
$arrScopes = [];
|
||||
foreach ((array)VideoCategoryModel::getCustomCategory() as $arrParentCategory) {
|
||||
$strName = trim((string)($arrParentCategory['v_category'] ?? ''));
|
||||
if ($strName !== '') {
|
||||
$arrScopes[] = $strName;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'site_name' => self::getSiteName($DomainRow, $strHost),
|
||||
'content_scopes' => !empty($arrScopes) ? $arrScopes : ['电影', '电视剧', '综艺', '动漫', '短剧'],
|
||||
'home_category_blocks' => $arrHomeCategories,
|
||||
'navigation_paths' => [
|
||||
'首页 -> 分类频道 -> 详情页',
|
||||
'首页 -> 搜索页 -> 详情页 -> 播放页',
|
||||
'首页 -> 榜单页 -> 详情页 -> 播放页',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildCategoryIndexFacts(string $strHost, ?DomainModel $DomainRow, array $arrPageParts): array
|
||||
{
|
||||
$strParentSlug = (string)($arrPageParts[0] ?? '');
|
||||
$arrChildren = self::getChildCategories($strParentSlug);
|
||||
|
||||
return [
|
||||
'site_name' => self::getSiteName($DomainRow, $strHost),
|
||||
'parent_category_slug' => $strParentSlug,
|
||||
'parent_category_name' => self::getParentCategoryName($strParentSlug),
|
||||
'sub_category_examples' => array_slice(array_values(array_filter(array_map(
|
||||
static fn(array $arrItem) => trim((string)($arrItem['v_category'] ?? '')),
|
||||
$arrChildren
|
||||
))), 0, 5),
|
||||
'sub_category_total' => count($arrChildren),
|
||||
'search_path' => '如果题材入口还不够精确,可继续使用站内搜索按片名、演员或题材词缩小范围。',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildCategoryListFacts(string $strHost, ?DomainModel $DomainRow, array $arrTpStyle, array $arrPageParts): array
|
||||
{
|
||||
$strParentSlug = (string)($arrPageParts[0] ?? '');
|
||||
$strCategorySlug = (string)($arrPageParts[1] ?? '');
|
||||
$intPage = max(1, (int)($arrPageParts[2] ?? 1));
|
||||
$intLimit = self::getLayoutLimit($arrTpStyle, 'category_list', 36);
|
||||
|
||||
$arrFilter = [
|
||||
'$and' => [
|
||||
['v_parent_category_en' => $strParentSlug],
|
||||
['v_category_en' => $strCategorySlug],
|
||||
],
|
||||
];
|
||||
|
||||
$arrStats = self::countPageStats($arrFilter, $intPage, $intLimit);
|
||||
|
||||
return [
|
||||
'site_name' => self::getSiteName($DomainRow, $strHost),
|
||||
'parent_category_slug' => $strParentSlug,
|
||||
'parent_category_name' => self::getParentCategoryName($strParentSlug),
|
||||
'category_slug' => $strCategorySlug,
|
||||
'category_name' => VideoCategoryModel::getNameBySlug($strCategorySlug),
|
||||
'page_stats' => $arrStats,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildSearchFacts(string $strHost, ?DomainModel $DomainRow, array $arrTpStyle, array $arrPageParts): array
|
||||
{
|
||||
$strKeyword = (string)($arrPageParts[0] ?? '');
|
||||
$intPage = max(1, (int)($arrPageParts[1] ?? 1));
|
||||
$intLimit = self::getLayoutLimit($arrTpStyle, 'search_list', 32);
|
||||
|
||||
$strKeyword = mb_substr($strKeyword, 0, 30);
|
||||
$arrFilter = [
|
||||
'$and' => [[
|
||||
'$or' => [
|
||||
['v_name' => ['$regex' => $strKeyword, '$options' => 'i']],
|
||||
['v_author' => ['$regex' => $strKeyword, '$options' => 'i']],
|
||||
['v_category' => ['$regex' => $strKeyword, '$options' => 'i']],
|
||||
['v_description' => ['$regex' => $strKeyword, '$options' => 'i']],
|
||||
],
|
||||
]],
|
||||
];
|
||||
|
||||
$arrStats = self::countPageStats($arrFilter, $intPage, $intLimit);
|
||||
|
||||
return [
|
||||
'site_name' => self::getSiteName($DomainRow, $strHost),
|
||||
'keyword' => $strKeyword,
|
||||
'result_stats' => $arrStats,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildRankIndexFacts(string $strHost, ?DomainModel $DomainRow): array
|
||||
{
|
||||
return [
|
||||
'site_name' => self::getSiteName($DomainRow, $strHost),
|
||||
'rank_periods' => ['日榜', '周榜', '月榜', '总榜'],
|
||||
'rank_path' => '榜单首页 -> 榜单列表 -> 详情页 -> 延伸词页 -> 播放页',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildRankListFacts(string $strHost, ?DomainModel $DomainRow, array $arrTpStyle, array $arrPageParts): array
|
||||
{
|
||||
[$strParentSlug, $strCategorySlug, $strSortType, $intPage] = self::resolveRankParts($arrPageParts);
|
||||
$intLimit = self::getLayoutLimit($arrTpStyle, 'rank_list_index', 12);
|
||||
|
||||
$strVCategoryEn = ($strCategorySlug !== '' && $strCategorySlug !== 'all') ? $strCategorySlug : null;
|
||||
$strVParentCategoryEn = ($strParentSlug !== '' && $strParentSlug !== 'all') ? $strParentSlug : null;
|
||||
|
||||
$arrVideo = VideoModel::getInstance()->getRankVideoOnCache('', $intLimit, $strSortType, null, $strVCategoryEn, $strVParentCategoryEn);
|
||||
if (empty($arrVideo)) {
|
||||
$arrFallbackFilter = [];
|
||||
if ($strVCategoryEn !== null) {
|
||||
$arrFallbackFilter['v_category_en'] = $strVCategoryEn;
|
||||
}
|
||||
if ($strVParentCategoryEn !== null) {
|
||||
$arrFallbackFilter['v_parent_category_en'] = $strVParentCategoryEn;
|
||||
}
|
||||
|
||||
$arrVideo = VideoModel::getInstance()->findManySortedWithCache(
|
||||
$arrFallbackFilter,
|
||||
$intLimit,
|
||||
[
|
||||
'v_publish_date' => -1,
|
||||
'updated_at' => -1,
|
||||
'v_id' => -1,
|
||||
],
|
||||
'',
|
||||
0,
|
||||
);
|
||||
}
|
||||
$intVisible = count($arrVideo);
|
||||
|
||||
$strScopeName = self::buildRankScopeName($strParentSlug, $strCategorySlug);
|
||||
|
||||
return [
|
||||
'site_name' => self::getSiteName($DomainRow, $strHost),
|
||||
'rank_period_name' => self::getRankPeriodName($strSortType),
|
||||
'rank_scope_name' => $strScopeName,
|
||||
'page_stats' => [
|
||||
'page' => $intPage,
|
||||
'visible' => $intVisible,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildDetailFacts(string $strHost, ?DomainModel $DomainRow, array $arrPageParts): array
|
||||
{
|
||||
$intVideoId = (int)($arrPageParts[0] ?? 0);
|
||||
$arrVideo = VideoModel::getInstance()->findOne(['v_id' => $intVideoId]) ?? [];
|
||||
|
||||
return self::buildVideoBaseFacts($strHost, $DomainRow, $arrVideo, [
|
||||
'scene' => 'detail',
|
||||
'video_id' => $intVideoId,
|
||||
]);
|
||||
}
|
||||
|
||||
protected static function buildForgeFacts(string $strHost, ?DomainModel $DomainRow, array $arrPageParts): array
|
||||
{
|
||||
$intVideoId = (int)($arrPageParts[0] ?? 0);
|
||||
$intForgeId = max(1, (int)($arrPageParts[1] ?? 1));
|
||||
$arrVideo = VideoModel::getInstance()->findOne(['v_id' => $intVideoId]) ?? [];
|
||||
$strForgeKeyword = SeoWordsHelper::resolveForgeWord($arrVideo, $intForgeId);
|
||||
|
||||
$arrFacts = self::buildVideoBaseFacts($strHost, $DomainRow, $arrVideo, [
|
||||
'scene' => 'forge',
|
||||
'video_id' => $intVideoId,
|
||||
'forge_id' => $intForgeId,
|
||||
]);
|
||||
$arrFacts['forge_keyword'] = $strForgeKeyword;
|
||||
$arrFacts['mapping_relation'] = $strForgeKeyword !== '' && !empty($arrVideo['v_name'])
|
||||
? '延伸词“' . $strForgeKeyword . '”当前映射到主片《' . (string)$arrVideo['v_name'] . '》。'
|
||||
: '';
|
||||
|
||||
return $arrFacts;
|
||||
}
|
||||
|
||||
protected static function buildPlayFacts(string $strHost, ?DomainModel $DomainRow, array $arrPageParts): array
|
||||
{
|
||||
$intVideoId = (int)($arrPageParts[0] ?? 0);
|
||||
$strPlayType = (string)($arrPageParts[1] ?? '');
|
||||
$intPlayIndex = max(1, (int)($arrPageParts[2] ?? 1));
|
||||
$arrVideo = VideoModel::getInstance()->findOne(['v_id' => $intVideoId]) ?? [];
|
||||
|
||||
$arrFacts = self::buildVideoBaseFacts($strHost, $DomainRow, $arrVideo, [
|
||||
'scene' => 'play',
|
||||
'video_id' => $intVideoId,
|
||||
]);
|
||||
$arrFacts['play_type'] = $strPlayType;
|
||||
$arrFacts['episode_label_or_index'] = self::resolveEpisodeLabel($arrVideo, $strPlayType, $intPlayIndex);
|
||||
$arrFacts['play_index'] = $intPlayIndex;
|
||||
|
||||
return $arrFacts;
|
||||
}
|
||||
|
||||
protected static function buildVideoBaseFacts(string $strHost, ?DomainModel $DomainRow, array $arrVideo, array $arrExtra = []): array
|
||||
{
|
||||
$intVideoId = (int)($arrVideo['v_id'] ?? 0);
|
||||
$arrPlayUrl = (array)($arrVideo['v_play_url'] ?? []);
|
||||
$arrPlayGroups = array_values(array_filter(array_map(static fn($strKey) => trim((string)$strKey), array_keys($arrPlayUrl))));
|
||||
$intEpisodeCount = 0;
|
||||
foreach ($arrPlayUrl as $arrEpisodes) {
|
||||
if (is_array($arrEpisodes)) {
|
||||
$intEpisodeCount += count($arrEpisodes);
|
||||
}
|
||||
}
|
||||
|
||||
$strCategoryName = trim((string)($arrVideo['v_category'] ?? ''));
|
||||
if ($strCategoryName === '') {
|
||||
$strCategoryName = trim((string)($arrVideo['v_parent_category'] ?? ''));
|
||||
}
|
||||
|
||||
$strDescription = trim((string)($arrVideo['v_description'] ?? ''));
|
||||
if ($strDescription === '' || $strDescription === '暂无简介') {
|
||||
$strDescription = trim($strCategoryName . '内容;当前记录暂无详细简介。');
|
||||
} else {
|
||||
$strDescription = mb_substr($strDescription, 0, 90);
|
||||
}
|
||||
|
||||
return array_merge([
|
||||
'site_name' => self::getSiteName($DomainRow, $strHost),
|
||||
'video_id' => $intVideoId,
|
||||
'video_name' => (string)($arrVideo['v_name'] ?? ''),
|
||||
'video_slug' => (string)($arrVideo['v_name_en'] ?? ''),
|
||||
'parent_category_name' => (string)($arrVideo['v_parent_category'] ?? ''),
|
||||
'category_name' => $strCategoryName,
|
||||
'year' => (string)($arrVideo['v_year'] ?? ''),
|
||||
'publish_date' => (string)($arrVideo['v_publish_date'] ?? ''),
|
||||
'is_end' => (int)($arrVideo['v_isend'] ?? 0),
|
||||
'play_group_names' => $arrPlayGroups,
|
||||
'episode_count' => $intEpisodeCount,
|
||||
'click_stats' => $intVideoId > 0 ? VideoClicksModel::getInstance()->getStats($intVideoId) : [],
|
||||
'core_plot_or_positioning' => $strDescription,
|
||||
], $arrExtra);
|
||||
}
|
||||
|
||||
protected static function countPageStats(array $arrFilter, int $intPage, int $intLimit): array
|
||||
{
|
||||
$intTotal = (int)VideoModel::getInstance()->getCol()->countDocuments($arrFilter);
|
||||
$intPages = max(1, (int)ceil($intTotal / max(1, $intLimit)));
|
||||
$intSkip = max(0, ($intPage - 1) * $intLimit);
|
||||
$intVisible = max(0, min($intLimit, $intTotal - $intSkip));
|
||||
|
||||
return [
|
||||
'page' => $intPage,
|
||||
'pages' => $intPages,
|
||||
'total' => $intTotal,
|
||||
'visible' => $intVisible,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function getLayoutLimit(array $arrTpStyle, string $strLayoutKey, int $intDefault): int
|
||||
{
|
||||
return (int)($arrTpStyle['template_cfg']['list_layout'][$strLayoutKey]['layout']['max_items'] ?? $intDefault);
|
||||
}
|
||||
|
||||
protected static function getSiteName(?DomainModel $DomainRow, string $strHost): string
|
||||
{
|
||||
$strName = trim((string)($DomainRow->d_name ?? ''));
|
||||
if ($strName !== '') {
|
||||
return $strName;
|
||||
}
|
||||
|
||||
$strHost = preg_replace('/^www\./', '', strtolower(trim($strHost)));
|
||||
$strHost = preg_replace('/\..*$/', '', (string)$strHost);
|
||||
|
||||
return $strHost !== '' ? $strHost : '站点';
|
||||
}
|
||||
|
||||
protected static function getParentCategoryName(string $strParentSlug): string
|
||||
{
|
||||
return (string)(StaticConfig::$arrVideoClass[$strParentSlug] ?? VideoCategoryModel::getNameBySlug($strParentSlug));
|
||||
}
|
||||
|
||||
protected static function getChildCategories(string $strParentSlug): array
|
||||
{
|
||||
foreach ((array)VideoCategoryModel::getCustomCategory() as $arrParentCategory) {
|
||||
if ((string)($arrParentCategory['v_category_en'] ?? '') === $strParentSlug) {
|
||||
return (array)($arrParentCategory['children'] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
protected static function getRankPeriodName(string $strSortType): string
|
||||
{
|
||||
return match ($strSortType) {
|
||||
'daily' => '日榜',
|
||||
'weekly' => '周榜',
|
||||
'monthly' => '月榜',
|
||||
'total' => '总榜',
|
||||
default => '热榜',
|
||||
};
|
||||
}
|
||||
|
||||
protected static function buildRankScopeName(string $strParentSlug, string $strCategorySlug): string
|
||||
{
|
||||
$arrParts = [];
|
||||
if ($strParentSlug !== '' && $strParentSlug !== 'all') {
|
||||
$arrParts[] = self::getParentCategoryName($strParentSlug);
|
||||
}
|
||||
if ($strCategorySlug !== '' && $strCategorySlug !== 'all') {
|
||||
$arrParts[] = VideoCategoryModel::getNameBySlug($strCategorySlug);
|
||||
}
|
||||
|
||||
$strScopeName = trim(implode('', array_filter($arrParts)));
|
||||
|
||||
return $strScopeName !== '' ? $strScopeName : '热门视频';
|
||||
}
|
||||
|
||||
protected static function resolveEpisodeLabel(array $arrVideo, string $strPlayType, int $intPlayIndex): string|int
|
||||
{
|
||||
$arrPlayUrl = (array)($arrVideo['v_play_url'] ?? []);
|
||||
$arrCurrentPlay = (array)($arrPlayUrl[$strPlayType] ?? []);
|
||||
$arrEpisode = (array)($arrCurrentPlay[$intPlayIndex - 1] ?? []);
|
||||
$strLabel = trim((string)($arrEpisode['name'] ?? ''));
|
||||
|
||||
return $strLabel !== '' ? $strLabel : $intPlayIndex;
|
||||
}
|
||||
|
||||
protected static function resolveRankParts(array $arrPageParts): array
|
||||
{
|
||||
$intPage = 1;
|
||||
$strParentSlug = 'all';
|
||||
$strCategorySlug = 'all';
|
||||
$strSortType = 'daily';
|
||||
|
||||
if (count($arrPageParts) === 1) {
|
||||
$strSortType = (string)$arrPageParts[0];
|
||||
} elseif (count($arrPageParts) === 2) {
|
||||
$strSortType = (string)$arrPageParts[0];
|
||||
$intPage = max(1, (int)$arrPageParts[1]);
|
||||
} elseif (count($arrPageParts) >= 3) {
|
||||
$strParentSlug = (string)$arrPageParts[0];
|
||||
$strCategorySlug = (string)$arrPageParts[1];
|
||||
$strSortType = (string)$arrPageParts[2];
|
||||
$intPage = max(1, (int)($arrPageParts[3] ?? 1));
|
||||
}
|
||||
|
||||
return [$strParentSlug, $strCategorySlug, $strSortType, $intPage];
|
||||
}
|
||||
}
|
||||
216
code/app/common/helper/SeoCopyFallbackBuilder.php
Normal file
216
code/app/common/helper/SeoCopyFallbackBuilder.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class SeoCopyFallbackBuilder
|
||||
{
|
||||
public static function build(string $strScene, array $arrFacts): array
|
||||
{
|
||||
return match ($strScene) {
|
||||
'home' => self::buildHome($arrFacts),
|
||||
'category_index' => self::buildCategoryIndex($arrFacts),
|
||||
'category_list' => self::buildCategoryList($arrFacts),
|
||||
'search' => self::buildSearch($arrFacts),
|
||||
'rank_index' => self::buildRankIndex($arrFacts),
|
||||
'rank_list' => self::buildRankList($arrFacts),
|
||||
'detail' => self::buildDetail($arrFacts),
|
||||
'forge' => self::buildForge($arrFacts),
|
||||
'play' => self::buildPlay($arrFacts),
|
||||
default => SeoCopySchema::getSceneTemplate($strScene),
|
||||
};
|
||||
}
|
||||
|
||||
private static function buildHome(array $arrFacts): array
|
||||
{
|
||||
$strSiteName = self::siteName($arrFacts);
|
||||
$strScopes = self::joinItems((array)($arrFacts['content_scopes'] ?? []), 3, '影视内容');
|
||||
|
||||
return [
|
||||
'intro_text' => $strSiteName . '首页当前覆盖' . $strScopes . '等常见内容入口,建议先看首屏内容卡片,再继续按分类、榜单或搜索缩小范围。',
|
||||
'intro_meta' => '首页更适合先发现值得点开的内容,说明性提示只做辅助,不建议压住首屏主体内容。',
|
||||
'guide_cards' => [
|
||||
['title' => '首屏入口', 'text' => '首页首屏更适合先看推荐内容和更新区,快速判断当前最值得点开的内容。'],
|
||||
['title' => '频道覆盖', 'text' => '当前首页已经串起' . $strScopes . '等常见入口,适合先在不同内容类型之间横向切换。'],
|
||||
['title' => '搜索补充', 'text' => '如果目标已经明确,直接用站内搜索按片名、人物或题材词定位会更快。'],
|
||||
['title' => '继续路径', 'text' => '从首页进入分类、榜单或搜索后,都可以继续进入详情页和播放页完成后续浏览。'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildCategoryIndex(array $arrFacts): array
|
||||
{
|
||||
$strParent = self::fallback((string)($arrFacts['parent_category_name'] ?? ''), '当前频道');
|
||||
$strExamples = self::joinItems((array)($arrFacts['sub_category_examples'] ?? []), 3, '细分题材');
|
||||
|
||||
return [
|
||||
'intro_text' => '当前频道页围绕' . $strParent . '整理了' . $strExamples . '等细分入口,适合先确定题材范围再继续下钻。',
|
||||
'category_intro' => '当前频道页围绕' . $strParent . '整理了' . $strExamples . '等细分入口,适合先确定题材范围再继续下钻。',
|
||||
'intro_meta' => '如果当前频道入口还不够精确,可以继续进入细分类页或改用站内搜索补充筛选。',
|
||||
'guide_cards' => [
|
||||
['title' => '当前频道', 'text' => '本页先帮助确认当前大类和可继续进入的细分题材入口。'],
|
||||
['title' => '细分范围', 'text' => '建议先从最接近目标内容的细分类入口继续进入列表页。'],
|
||||
['title' => '后续动作', 'text' => '进入列表页后可以继续看详情页、延伸词页和播放页。'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildCategoryList(array $arrFacts): array
|
||||
{
|
||||
$strCategory = self::fallback((string)($arrFacts['category_name'] ?? ''), '当前题材');
|
||||
$intVisible = (int)($arrFacts['page_stats']['visible'] ?? 0);
|
||||
$intPage = max(1, (int)($arrFacts['page_stats']['page'] ?? 1));
|
||||
|
||||
return [
|
||||
'intro_text' => '当前列表页围绕' . $strCategory . '整理了第' . $intPage . '页可浏览条目,当前可见数量约为' . max(1, $intVisible) . '条。',
|
||||
'category_intro' => '当前列表页围绕' . $strCategory . '整理了第' . $intPage . '页可浏览条目,当前可见数量约为' . max(1, $intVisible) . '条。',
|
||||
'intro_meta' => '如果当前页还没找到合适内容,可以继续翻页,或回到上级频道重新调整筛选范围。',
|
||||
'guide_cards' => [
|
||||
['title' => '当前范围', 'text' => '本页已经把当前题材范围内的可见条目整理到同一列表中。'],
|
||||
['title' => '翻页建议', 'text' => '如果这一页结果不够合适,可以继续翻页查看后续条目。'],
|
||||
['title' => '后续路径', 'text' => '点击条目后可以继续进入详情页、延伸词页和播放页。'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildSearch(array $arrFacts): array
|
||||
{
|
||||
$strKeyword = self::fallback((string)($arrFacts['keyword'] ?? ''), '当前关键词');
|
||||
$intVisible = (int)($arrFacts['result_stats']['visible'] ?? 0);
|
||||
|
||||
return [
|
||||
'search_landing' => '当前搜索结果围绕“' . $strKeyword . '”整理,当前页可继续浏览的结果约为' . max(1, $intVisible) . '条。',
|
||||
'intro_text' => '当前搜索结果围绕“' . $strKeyword . '”整理,当前页可继续浏览的结果约为' . max(1, $intVisible) . '条。',
|
||||
'intro_meta' => '如果结果范围仍然偏宽,可以继续补充关键词,或切到分类和榜单页换一个入口继续找。',
|
||||
'guide_cards' => [
|
||||
['title' => '当前关键词', 'text' => '本页用于先确认当前关键词下有哪些可继续浏览的结果。'],
|
||||
['title' => '缩小范围', 'text' => '如果结果太多,建议继续补充人物、年份或题材词缩小范围。'],
|
||||
['title' => '后续路径', 'text' => '进入结果详情后,还可以继续看延伸词入口和播放页。'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildRankIndex(array $arrFacts): array
|
||||
{
|
||||
$strPeriods = self::joinItems((array)($arrFacts['rank_periods'] ?? []), 3, '常见榜单周期');
|
||||
|
||||
return [
|
||||
'intro_text' => '当前榜单首页汇总了' . $strPeriods . '等常见周期入口,适合先确定要看的榜单范围再继续下钻。',
|
||||
'intro_meta' => '如果你更关心具体周期,可以直接进入对应榜单列表页,再继续看详情和播放路径。',
|
||||
'guide_cards' => [
|
||||
['title' => '榜单首页', 'text' => '本页先帮助确认有哪些常用榜单周期和可继续进入的榜单入口。'],
|
||||
['title' => '周期区别', 'text' => '不同周期适合看短时热度变化或更稳定的长期趋势。'],
|
||||
['title' => '后续路径', 'text' => '进入榜单列表后,可以继续看详情页、延伸词页和播放页。'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildRankList(array $arrFacts): array
|
||||
{
|
||||
$strPeriod = self::fallback((string)($arrFacts['rank_period_name'] ?? ''), '当前周期');
|
||||
$strScope = self::fallback((string)($arrFacts['rank_scope_name'] ?? ''), '当前范围');
|
||||
$intVisible = (int)($arrFacts['page_stats']['visible'] ?? 0);
|
||||
|
||||
return [
|
||||
'intro_text' => '当前榜单列表页展示的是' . $strScope . '下的' . $strPeriod . '结果,当前页可继续浏览的条目约为' . max(1, $intVisible) . '条。',
|
||||
'intro_meta' => '如果你想横向对比结果,可以切到其他周期榜单,或者直接进入详情页继续确认内容。',
|
||||
'guide_cards' => [
|
||||
['title' => '当前周期', 'text' => '本页当前聚焦的是对应周期内的榜单结果。'],
|
||||
['title' => '看榜建议', 'text' => '建议先看当前榜单范围,再决定是否切到其他周期做对照。'],
|
||||
['title' => '继续浏览', 'text' => '点击榜单条目后可以继续进入详情页、延伸词页和播放页。'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildDetail(array $arrFacts): array
|
||||
{
|
||||
$strVideo = self::fallback((string)($arrFacts['video_name'] ?? ''), '当前内容');
|
||||
$strCategory = self::fallback((string)($arrFacts['category_name'] ?? ''), '当前题材');
|
||||
$strYear = self::fallback((string)($arrFacts['year'] ?? ''), '年份待补充');
|
||||
$strSummary = self::summary($arrFacts);
|
||||
|
||||
return [
|
||||
'detail_body' => '本页围绕《' . $strVideo . '》整理剧情简介、题材信息与播放线索,方便先确认内容方向。',
|
||||
'detail_body_lead' => '本页围绕《' . $strVideo . '》整理剧情简介、题材信息与播放线索,方便先确认内容方向。',
|
||||
'detail_play_link_lead' => '如果准备继续观看,可直接进入',
|
||||
'detail_body_tail' => '如果想换个切口继续了解这部内容,可以继续浏览延伸词入口或返回分类页。',
|
||||
'detail_faq' => '常见问题:如果你已经确认要看这部内容,可以先看剧情简介,再进入播放页;如果还想补充检索角度,也可以先回到分类页继续筛选。',
|
||||
'main_description' => '《' . $strVideo . '》当前归类到' . $strCategory . ',参考年份为' . $strYear . '。' . $strSummary,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildForge(array $arrFacts): array
|
||||
{
|
||||
$strVideo = self::fallback((string)($arrFacts['video_name'] ?? ''), '当前内容');
|
||||
$strKeyword = self::fallback((string)($arrFacts['forge_keyword'] ?? ''), $strVideo);
|
||||
$strSummary = self::summary($arrFacts);
|
||||
|
||||
return [
|
||||
'forge_detail_note' => '当前页围绕“' . $strKeyword . '”这条延伸词补充主内容《' . $strVideo . '》的对应关系。',
|
||||
'forge_body_lead' => '这条延伸词页面主要帮助确认“' . $strKeyword . '”与《' . $strVideo . '》之间的映射,再决定是否继续看主详情。',
|
||||
'forge_return_lead' => '如果想回到标准详情,可返回',
|
||||
'forge_play_lead' => '如果准备继续观看,也可以直接进入',
|
||||
'main_description' => '当前延伸词条最终仍指向《' . $strVideo . '》。' . $strSummary,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildPlay(array $arrFacts): array
|
||||
{
|
||||
$strVideo = self::fallback((string)($arrFacts['video_name'] ?? ''), '当前内容');
|
||||
$strType = self::fallback((string)($arrFacts['play_type'] ?? ''), '默认线路');
|
||||
$strEpisode = (string)($arrFacts['episode_label_or_index'] ?? ($arrFacts['play_index'] ?? 1));
|
||||
$strSummary = self::summary($arrFacts);
|
||||
|
||||
return [
|
||||
'play_intro' => '当前播放页提供《' . $strVideo . '》的' . $strType . '线路入口,当前可继续浏览的集数位置为' . $strEpisode . '。',
|
||||
'play_meta_note' => '如果当前线路不合适,可以先回详情页确认内容归属,再切换其他播放入口继续浏览。',
|
||||
'play_body_lead' => '播放器下方正文主要帮助补充《' . $strVideo . '》的剧情、题材和返回路径信息。',
|
||||
'play_body_next' => '如果想继续确认内容信息,可以回详情页;如果想换范围继续看,也可以返回题材页。',
|
||||
];
|
||||
}
|
||||
|
||||
private static function siteName(array $arrFacts): string
|
||||
{
|
||||
$strSiteName = trim((string)($arrFacts['site_name'] ?? ''));
|
||||
if ($strSiteName === '' || $strSiteName === '*' || $strSiteName === '[]') {
|
||||
return '当前站点';
|
||||
}
|
||||
|
||||
return $strSiteName;
|
||||
}
|
||||
|
||||
private static function summary(array $arrFacts): string
|
||||
{
|
||||
$strSummary = trim((string)($arrFacts['core_plot_or_positioning'] ?? ''));
|
||||
if ($strSummary === '') {
|
||||
return '当前页以内容归属、题材定位和后续浏览路径整理为主。';
|
||||
}
|
||||
|
||||
$strSummary = preg_replace('/\s+/u', ' ', $strSummary);
|
||||
|
||||
return mb_substr((string)$strSummary, 0, 90);
|
||||
}
|
||||
|
||||
private static function joinItems(array $arrItems, int $intLimit, string $strFallback): string
|
||||
{
|
||||
$arrItems = array_values(array_filter(array_map(static function ($mVal): string {
|
||||
return trim((string)$mVal);
|
||||
}, $arrItems), static function (string $strVal): bool {
|
||||
return $strVal !== '';
|
||||
}));
|
||||
|
||||
if (empty($arrItems)) {
|
||||
return $strFallback;
|
||||
}
|
||||
|
||||
return implode('、', array_slice($arrItems, 0, $intLimit));
|
||||
}
|
||||
|
||||
private static function fallback(string $strValue, string $strFallback): string
|
||||
{
|
||||
$strValue = trim($strValue);
|
||||
|
||||
return $strValue !== '' ? $strValue : $strFallback;
|
||||
}
|
||||
}
|
||||
256
code/app/common/helper/SeoCopyGenerationHelper.php
Normal file
256
code/app/common/helper/SeoCopyGenerationHelper.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\DomainModel;
|
||||
|
||||
class SeoCopyGenerationHelper
|
||||
{
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_LOCAL_READY = 'local_ready';
|
||||
public const STATUS_AI_PENDING = 'ai_pending';
|
||||
public const STATUS_AI_READY = 'ai_ready';
|
||||
|
||||
public static function stateRoot(): string
|
||||
{
|
||||
return dirname(__DIR__, 3) . '/storage/seo_copy_generation_state';
|
||||
}
|
||||
|
||||
public static function historyRoot(): string
|
||||
{
|
||||
return dirname(__DIR__, 3) . '/storage/seo_copy_generation_history';
|
||||
}
|
||||
|
||||
public static function normalizeHost(string $host): string
|
||||
{
|
||||
$host = strtolower(trim($host));
|
||||
$host = preg_replace('/:\d+$/', '', $host);
|
||||
return trim((string)$host);
|
||||
}
|
||||
|
||||
public static function buildHostKey(string $host): string
|
||||
{
|
||||
return SeoCopyStore::buildPageKey($host, 'host');
|
||||
}
|
||||
|
||||
public static function resolveStatePath(string $host): string
|
||||
{
|
||||
return rtrim(self::stateRoot(), '/') . '/' . self::buildHostKey($host) . '.state.json';
|
||||
}
|
||||
|
||||
public static function resolveManifestPath(string $host): string
|
||||
{
|
||||
return rtrim(self::stateRoot(), '/') . '/' . self::buildHostKey($host) . '.manifest.json';
|
||||
}
|
||||
|
||||
public static function readState(string $host): array
|
||||
{
|
||||
$path = self::resolveStatePath($host);
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode((string)file_get_contents($path), true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
public static function readHistory(string $host): array
|
||||
{
|
||||
$strPath = self::resolveHistoryPath($host);
|
||||
if (!is_file($strPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrData = json_decode((string)file_get_contents($strPath), true);
|
||||
return is_array($arrData) ? $arrData : [];
|
||||
}
|
||||
|
||||
public static function initializeForDomain(DomainModel $DomainModel, array $overrides = []): array
|
||||
{
|
||||
$host = self::normalizeHost((string)($DomainModel->d_domain ?? ''));
|
||||
if ($host === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$state = self::buildState($DomainModel, $overrides);
|
||||
self::ensureRoot();
|
||||
file_put_contents(
|
||||
self::resolveStatePath($host),
|
||||
json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
);
|
||||
|
||||
$manifest = self::buildManifest($DomainModel, $state);
|
||||
file_put_contents(
|
||||
self::resolveManifestPath($host),
|
||||
json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
);
|
||||
|
||||
return [
|
||||
'state' => $state,
|
||||
'manifest' => $manifest,
|
||||
'state_path' => self::resolveStatePath($host),
|
||||
'manifest_path' => self::resolveManifestPath($host),
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildState(DomainModel $DomainModel, array $overrides = []): array
|
||||
{
|
||||
$host = self::normalizeHost((string)($DomainModel->d_domain ?? ''));
|
||||
$seoCfg = method_exists($DomainModel, 'getSeoCfg') ? (array)$DomainModel->getSeoCfg() : [];
|
||||
$rawSeoCfg = $DomainModel->d_seo_cfg ?? [];
|
||||
if (is_string($rawSeoCfg)) {
|
||||
$rawDecodedSeoCfg = json_decode($rawSeoCfg, true);
|
||||
$rawSeoCfg = is_array($rawDecodedSeoCfg) ? $rawDecodedSeoCfg : [];
|
||||
}
|
||||
if (!is_array($rawSeoCfg)) {
|
||||
$rawSeoCfg = [];
|
||||
}
|
||||
$tkdCfg = (array)($seoCfg['tkd'] ?? []);
|
||||
$copyCfg = (array)($seoCfg['copy_generation'] ?? []);
|
||||
$rawCopyCfg = is_array($rawSeoCfg['copy_generation'] ?? null) ? (array)$rawSeoCfg['copy_generation'] : [];
|
||||
|
||||
$hasLocalCopy = self::hasLocalCopyPayload($host);
|
||||
$tkdMode = DomainModel::normalizeTkdMode((string)($tkdCfg['mode'] ?? DomainModel::TKD_MODE_AUTO_GENERATE));
|
||||
$tkdProvider = DomainModel::normalizeTkdProvider((string)($tkdCfg['provider'] ?? DomainModel::TKD_PROVIDER_LOCAL));
|
||||
$waitForAi = !empty($copyCfg['wait_for_ai_before_publish']) || ($tkdProvider === DomainModel::TKD_PROVIDER_OPENAI && $tkdMode !== DomainModel::TKD_MODE_FORCE_IMPORT);
|
||||
|
||||
$status = self::STATUS_LOCAL_READY;
|
||||
if ($waitForAi) {
|
||||
$status = $hasLocalCopy ? self::STATUS_LOCAL_READY : self::STATUS_AI_PENDING;
|
||||
}
|
||||
if (!empty($rawCopyCfg['status']) && in_array((string)$rawCopyCfg['status'], [self::STATUS_DRAFT, self::STATUS_LOCAL_READY, self::STATUS_AI_PENDING, self::STATUS_AI_READY], true)) {
|
||||
$status = (string)$rawCopyCfg['status'];
|
||||
}
|
||||
if (!empty($overrides['status'])) {
|
||||
$status = (string)$overrides['status'];
|
||||
}
|
||||
|
||||
$releaseGate = (string)($copyCfg['release_gate'] ?? ($waitForAi ? 'wait_ai' : 'open'));
|
||||
if (!empty($overrides['release_gate'])) {
|
||||
$releaseGate = (string)$overrides['release_gate'];
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'domain_id' => (int)($DomainModel->d_id ?? 0),
|
||||
'status' => $status,
|
||||
'release_gate' => $releaseGate,
|
||||
'wait_for_ai_before_publish' => $waitForAi,
|
||||
'tkd_mode' => $tkdMode,
|
||||
'tkd_provider' => $tkdProvider,
|
||||
'provider_preference' => (string)($copyCfg['provider_preference'] ?? 'local_first'),
|
||||
'ai_provider' => (string)($copyCfg['ai_provider'] ?? DomainModel::TKD_PROVIDER_OPENAI),
|
||||
'has_local_copy_payload' => $hasLocalCopy,
|
||||
'updated_at' => date('c'),
|
||||
'message' => self::buildStateMessage($status, $waitForAi, $hasLocalCopy),
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildManifest(DomainModel $DomainModel, array $state = []): array
|
||||
{
|
||||
$host = self::normalizeHost((string)($DomainModel->d_domain ?? ''));
|
||||
if (empty($state)) {
|
||||
$state = self::buildState($DomainModel);
|
||||
}
|
||||
|
||||
$targets = [
|
||||
['scene' => 'home', 'page_key' => 'index', 'fields' => ['title', 'keywords', 'description', 'intro_text', 'guide_cards']],
|
||||
['scene' => 'category_index', 'page_key' => 'index', 'fields' => ['title', 'keywords', 'description', 'intro_text', 'guide_cards']],
|
||||
['scene' => 'category_list', 'page_key' => 'default', 'fields' => ['title', 'keywords', 'description', 'category_intro', 'guide_cards']],
|
||||
['scene' => 'search', 'page_key' => 'landing', 'fields' => ['title', 'keywords', 'description', 'intro_text', 'guide_cards']],
|
||||
['scene' => 'detail', 'page_key' => 'default', 'fields' => ['title', 'keywords', 'description', 'detail_body', 'guide_cards']],
|
||||
['scene' => 'play', 'page_key' => 'default', 'fields' => ['title', 'keywords', 'description', 'play_intro', 'guide_cards']],
|
||||
];
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'domain_id' => (int)($DomainModel->d_id ?? 0),
|
||||
'status' => (string)($state['status'] ?? self::STATUS_DRAFT),
|
||||
'release_gate' => (string)($state['release_gate'] ?? 'open'),
|
||||
'tkd_mode' => (string)($state['tkd_mode'] ?? DomainModel::TKD_MODE_AUTO_GENERATE),
|
||||
'tkd_provider' => (string)($state['tkd_provider'] ?? DomainModel::TKD_PROVIDER_LOCAL),
|
||||
'targets' => $targets,
|
||||
'facts_sources' => [
|
||||
'domain_record' => true,
|
||||
'site_style_cfg' => true,
|
||||
'video_facts_scan' => true,
|
||||
'seo_copy_store' => true,
|
||||
],
|
||||
'generated_at' => date('c'),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function hasLocalCopyPayload(string $host): bool
|
||||
{
|
||||
$host = self::normalizeHost($host);
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$root = dirname(__DIR__, 3) . '/data/seo_copy/' . self::buildHostKey($host);
|
||||
return is_dir($root);
|
||||
}
|
||||
|
||||
protected static function buildStateMessage(string $status, bool $waitForAi, bool $hasLocalCopy): string
|
||||
{
|
||||
return match ($status) {
|
||||
self::STATUS_AI_PENDING => $waitForAi
|
||||
? '当前站点已导入,等待 AI 生成首页/分类/搜索等核心文案后再进入发布阶段。'
|
||||
: '当前站点已进入 AI 生成队列。',
|
||||
self::STATUS_AI_READY => 'AI 文案已就绪,可继续发布或切换到 AI 版本。',
|
||||
self::STATUS_LOCAL_READY => $hasLocalCopy
|
||||
? '本地规则文案与本地文案稿均可用,可直接上线或继续等待 AI 增强。'
|
||||
: '本地规则文案已可用,可直接上线,也可继续等待 AI 增强。',
|
||||
default => '当前站点文案仍处于草稿阶段,建议先生成本地文案或提交 AI 任务。',
|
||||
};
|
||||
}
|
||||
|
||||
protected static function ensureRoot(): void
|
||||
{
|
||||
$root = self::stateRoot();
|
||||
if (is_dir($root)) {
|
||||
self::ensureHistoryRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
@mkdir($root, 0777, true);
|
||||
self::ensureHistoryRoot();
|
||||
}
|
||||
|
||||
public static function appendHistory(string $host, array $entry): array
|
||||
{
|
||||
$host = self::normalizeHost($host);
|
||||
if ($host === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
self::ensureRoot();
|
||||
$strPath = self::resolveHistoryPath($host);
|
||||
$arrHistory = self::readHistory($host);
|
||||
$arrHistory[] = array_merge([
|
||||
'host' => $host,
|
||||
'recorded_at' => date(DATE_ATOM),
|
||||
], $entry);
|
||||
|
||||
$arrHistory = array_values(array_slice($arrHistory, -60));
|
||||
file_put_contents($strPath, json_encode($arrHistory, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL);
|
||||
return $arrHistory;
|
||||
}
|
||||
|
||||
public static function resolveHistoryPath(string $host): string
|
||||
{
|
||||
return rtrim(self::historyRoot(), '/') . '/' . self::buildHostKey($host) . '.history.json';
|
||||
}
|
||||
|
||||
protected static function ensureHistoryRoot(): void
|
||||
{
|
||||
$root = self::historyRoot();
|
||||
if (is_dir($root)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@mkdir($root, 0777, true);
|
||||
}
|
||||
}
|
||||
1226
code/app/common/helper/SeoCopyPortalHomeHelper.php
Normal file
1226
code/app/common/helper/SeoCopyPortalHomeHelper.php
Normal file
File diff suppressed because it is too large
Load Diff
161
code/app/common/helper/SeoCopyReleaseIndexHelper.php
Normal file
161
code/app/common/helper/SeoCopyReleaseIndexHelper.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
use SplFileInfo;
|
||||
|
||||
class SeoCopyReleaseIndexHelper
|
||||
{
|
||||
public static function buildSummary(string $strLogDir, bool $boolAllowMissing = false): array
|
||||
{
|
||||
if (!is_dir($strLogDir)) {
|
||||
if (!$boolAllowMissing) {
|
||||
throw new RuntimeException('Log directory not found: ' . $strLogDir);
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'log_dir' => $strLogDir,
|
||||
'publish_logs' => 0,
|
||||
'rollback_logs' => 0,
|
||||
'releases' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$arrReleases = [];
|
||||
$intPublishLogs = 0;
|
||||
$intRollbackLogs = 0;
|
||||
|
||||
$Iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($strLogDir, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($Iterator as $FileInfo) {
|
||||
/** @var SplFileInfo $FileInfo */
|
||||
if (!$FileInfo->isFile() || strtolower($FileInfo->getExtension()) !== 'json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strPath = str_replace('\\', '/', $FileInfo->getPathname());
|
||||
if (str_contains($strPath, '/backups/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrData = json_decode((string)file_get_contents($strPath), true);
|
||||
if (!is_array($arrData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strLogType = trim((string)($arrData['log_type'] ?? ''));
|
||||
$strReleaseTag = trim((string)($arrData['release_tag'] ?? ''));
|
||||
if ($strReleaseTag === '' || !in_array($strLogType, ['publish', 'rollback'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($arrReleases[$strReleaseTag])) {
|
||||
$arrReleases[$strReleaseTag] = [
|
||||
'release_tag' => $strReleaseTag,
|
||||
'status' => 'published',
|
||||
'published_count' => 0,
|
||||
'skipped_count' => 0,
|
||||
'target_root' => '',
|
||||
'manifest' => '',
|
||||
'manifest_digest' => '',
|
||||
'publish_log_path' => '',
|
||||
'rollback_log_path' => '',
|
||||
'last_event_type' => '',
|
||||
'last_event_at' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$strCreatedAt = trim((string)($arrData['created_at'] ?? ''));
|
||||
$arrRelease = &$arrReleases[$strReleaseTag];
|
||||
|
||||
if ($strLogType === 'publish') {
|
||||
$intPublishLogs++;
|
||||
$boolDryRun = (bool)($arrData['dry_run'] ?? false);
|
||||
$arrRelease['published_count'] = (int)($arrData['published_count'] ?? 0);
|
||||
$arrRelease['skipped_count'] = (int)($arrData['skipped_count'] ?? 0);
|
||||
$arrRelease['target_root'] = (string)($arrData['target_root'] ?? '');
|
||||
$arrRelease['manifest'] = (string)($arrData['manifest'] ?? '');
|
||||
$arrRelease['manifest_digest'] = (string)($arrData['manifest_digest'] ?? '');
|
||||
$arrRelease['publish_log_path'] = $strPath;
|
||||
if ($arrRelease['last_event_at'] === '' || $strCreatedAt >= $arrRelease['last_event_at']) {
|
||||
$arrRelease['last_event_type'] = $boolDryRun ? 'publish_dry_run' : 'publish';
|
||||
$arrRelease['last_event_at'] = $strCreatedAt;
|
||||
if ($arrRelease['status'] !== 'rollbacked') {
|
||||
$arrRelease['status'] = $boolDryRun ? 'publish_dry_run' : 'published';
|
||||
}
|
||||
}
|
||||
unset($arrRelease);
|
||||
continue;
|
||||
}
|
||||
|
||||
$intRollbackLogs++;
|
||||
$arrRelease['rollback_log_path'] = $strPath;
|
||||
$arrRelease['status'] = ((bool)($arrData['dry_run'] ?? false)) ? 'rollback_dry_run' : 'rollbacked';
|
||||
if ($arrRelease['last_event_at'] === '' || $strCreatedAt >= $arrRelease['last_event_at']) {
|
||||
$arrRelease['last_event_type'] = 'rollback';
|
||||
$arrRelease['last_event_at'] = $strCreatedAt;
|
||||
}
|
||||
unset($arrRelease);
|
||||
}
|
||||
|
||||
$arrReleaseList = array_values($arrReleases);
|
||||
usort($arrReleaseList, static function (array $arrA, array $arrB): int {
|
||||
$strTimeA = (string)($arrA['last_event_at'] ?? '');
|
||||
$strTimeB = (string)($arrB['last_event_at'] ?? '');
|
||||
if ($strTimeA !== $strTimeB) {
|
||||
return strcmp($strTimeB, $strTimeA);
|
||||
}
|
||||
|
||||
return strcmp((string)($arrB['release_tag'] ?? ''), (string)($arrA['release_tag'] ?? ''));
|
||||
});
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'log_dir' => realpath($strLogDir) ?: $strLogDir,
|
||||
'publish_logs' => $intPublishLogs,
|
||||
'rollback_logs' => $intRollbackLogs,
|
||||
'releases_count' => count($arrReleaseList),
|
||||
'releases' => $arrReleaseList,
|
||||
];
|
||||
}
|
||||
|
||||
public static function writeSummary(string $strPath, array $arrSummary): 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($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
}
|
||||
|
||||
public static function renderTextSummary(array $arrSummary): string
|
||||
{
|
||||
$arrLines = [
|
||||
'log_dir: ' . $arrSummary['log_dir'],
|
||||
'publish_logs: ' . $arrSummary['publish_logs'],
|
||||
'rollback_logs: ' . $arrSummary['rollback_logs'],
|
||||
'releases: ' . count($arrSummary['releases']),
|
||||
];
|
||||
|
||||
if (!empty($arrSummary['releases'])) {
|
||||
$arrLines[] = 'release_index:';
|
||||
foreach ($arrSummary['releases'] as $arrRelease) {
|
||||
$arrLines[] = '- ' . $arrRelease['release_tag']
|
||||
. ' | status=' . $arrRelease['status']
|
||||
. ' | published=' . $arrRelease['published_count']
|
||||
. ' | skipped=' . $arrRelease['skipped_count']
|
||||
. ' | last_event=' . $arrRelease['last_event_type']
|
||||
. ' @ ' . $arrRelease['last_event_at'];
|
||||
}
|
||||
}
|
||||
|
||||
return implode(PHP_EOL, $arrLines) . PHP_EOL;
|
||||
}
|
||||
}
|
||||
285
code/app/common/helper/SeoCopyReleasePortalHelper.php
Normal file
285
code/app/common/helper/SeoCopyReleasePortalHelper.php
Normal file
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SeoCopyReleasePortalHelper
|
||||
{
|
||||
public static function buildPortalSummary(array $arrSummary): array
|
||||
{
|
||||
$arrReleases = [];
|
||||
foreach ((array)($arrSummary['releases'] ?? []) as $arrRelease) {
|
||||
if (!is_array($arrRelease)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrReleases[] = [
|
||||
'release_tag' => (string)($arrRelease['release_tag'] ?? ''),
|
||||
'status' => (string)($arrRelease['status'] ?? ''),
|
||||
'published_count' => (int)($arrRelease['published_count'] ?? 0),
|
||||
'skipped_count' => (int)($arrRelease['skipped_count'] ?? 0),
|
||||
'last_event_type' => (string)($arrRelease['last_event_type'] ?? ''),
|
||||
'last_event_at' => (string)($arrRelease['last_event_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => (string)($arrSummary['generated_at'] ?? date('c')),
|
||||
'publish_logs' => (int)($arrSummary['publish_logs'] ?? 0),
|
||||
'rollback_logs' => (int)($arrSummary['rollback_logs'] ?? 0),
|
||||
'releases_count' => count($arrReleases),
|
||||
'releases' => $arrReleases,
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildPortalReleaseDetail(array $arrRelease): array
|
||||
{
|
||||
$arrPublishLog = self::readJsonFile((string)($arrRelease['publish_log_path'] ?? ''));
|
||||
$arrRollbackLog = self::readJsonFile((string)($arrRelease['rollback_log_path'] ?? ''));
|
||||
|
||||
$arrSceneTotals = [];
|
||||
$arrPublishItems = [];
|
||||
foreach ((array)($arrPublishLog['published'] ?? []) as $arrItem) {
|
||||
if (!is_array($arrItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strScene = trim((string)($arrItem['scene'] ?? ''));
|
||||
$strPageKey = trim((string)($arrItem['page_key'] ?? ''));
|
||||
$strAction = trim((string)($arrItem['action'] ?? ''));
|
||||
|
||||
if ($strScene !== '') {
|
||||
$arrSceneTotals[$strScene] = ($arrSceneTotals[$strScene] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$arrPublishItems[] = [
|
||||
'scene' => $strScene,
|
||||
'page_key' => $strPageKey,
|
||||
'action' => $strAction,
|
||||
];
|
||||
}
|
||||
|
||||
ksort($arrSceneTotals, SORT_STRING);
|
||||
|
||||
$arrRollbackModes = [];
|
||||
foreach ((array)($arrRollbackLog['restored'] ?? []) as $arrItem) {
|
||||
if (!is_array($arrItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strMode = trim((string)($arrItem['mode'] ?? ''));
|
||||
if ($strMode === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrRollbackModes[$strMode] = ($arrRollbackModes[$strMode] ?? 0) + 1;
|
||||
}
|
||||
|
||||
ksort($arrRollbackModes, SORT_STRING);
|
||||
|
||||
return [
|
||||
'release_tag' => (string)($arrRelease['release_tag'] ?? ''),
|
||||
'status' => (string)($arrRelease['status'] ?? ''),
|
||||
'last_event_type' => (string)($arrRelease['last_event_type'] ?? ''),
|
||||
'last_event_at' => (string)($arrRelease['last_event_at'] ?? ''),
|
||||
'published_count' => (int)($arrRelease['published_count'] ?? 0),
|
||||
'skipped_count' => (int)($arrRelease['skipped_count'] ?? 0),
|
||||
'scene_totals' => $arrSceneTotals,
|
||||
'publish_items' => $arrPublishItems,
|
||||
'rollback' => [
|
||||
'exists' => !empty($arrRollbackLog),
|
||||
'dry_run' => (bool)($arrRollbackLog['dry_run'] ?? false),
|
||||
'restored_count' => count((array)($arrRollbackLog['restored'] ?? [])),
|
||||
'skipped_count' => count((array)($arrRollbackLog['skipped'] ?? [])),
|
||||
'modes' => $arrRollbackModes,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function writeArtifacts(string $strPortalRoot, array $arrSummary): void
|
||||
{
|
||||
$arrPortalSummary = self::buildPortalSummary($arrSummary);
|
||||
|
||||
self::writeFile(
|
||||
rtrim($strPortalRoot, '/') . '/latest-index.json',
|
||||
json_encode($arrPortalSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile(
|
||||
rtrim($strPortalRoot, '/') . '/latest-index.html',
|
||||
self::renderLatestIndexHtml($arrPortalSummary)
|
||||
);
|
||||
|
||||
$strReleaseDir = rtrim($strPortalRoot, '/') . '/releases';
|
||||
foreach ((array)($arrSummary['releases'] ?? []) as $arrRelease) {
|
||||
if (!is_array($arrRelease)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strReleaseTag = trim((string)($arrRelease['release_tag'] ?? ''));
|
||||
if ($strReleaseTag === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrDetail = self::buildPortalReleaseDetail($arrRelease);
|
||||
self::writeFile(
|
||||
$strReleaseDir . '/' . $strReleaseTag . '.json',
|
||||
json_encode($arrDetail, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile(
|
||||
$strReleaseDir . '/' . $strReleaseTag . '.html',
|
||||
self::renderReleaseDetailHtml($arrDetail)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function renderLatestIndexHtml(array $arrSummary): string
|
||||
{
|
||||
$arrRows = [];
|
||||
foreach ((array)($arrSummary['releases'] ?? []) as $arrRelease) {
|
||||
if (!is_array($arrRelease)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strTag = self::escape((string)($arrRelease['release_tag'] ?? ''));
|
||||
$arrRows[] = '<tr>'
|
||||
. '<td><a href="./releases/' . rawurlencode((string)($arrRelease['release_tag'] ?? '')) . '.html">' . $strTag . '</a></td>'
|
||||
. '<td>' . self::escape((string)($arrRelease['status'] ?? '')) . '</td>'
|
||||
. '<td>' . (int)($arrRelease['published_count'] ?? 0) . '</td>'
|
||||
. '<td>' . (int)($arrRelease['skipped_count'] ?? 0) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRelease['last_event_type'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRelease['last_event_at'] ?? '')) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'SEO Copy Release Portal',
|
||||
'<p class="lead">内部只读预览页,仅展示发布批次状态与场景清单,不输出绝对路径。'
|
||||
. '</p>'
|
||||
. '<div class="meta-grid">'
|
||||
. '<div><strong>generated_at</strong><span>' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>publish_logs</strong><span>' . (int)($arrSummary['publish_logs'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>rollback_logs</strong><span>' . (int)($arrSummary['rollback_logs'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>releases</strong><span>' . (int)($arrSummary['releases_count'] ?? 0) . '</span></div>'
|
||||
. '</div>'
|
||||
. '<table><thead><tr><th>release_tag</th><th>status</th><th>published</th><th>skipped</th><th>last_event</th><th>last_event_at</th></tr></thead><tbody>'
|
||||
. implode('', $arrRows)
|
||||
. '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
public static function renderReleaseDetailHtml(array $arrDetail): string
|
||||
{
|
||||
$arrSceneRows = [];
|
||||
foreach ((array)($arrDetail['scene_totals'] ?? []) as $strScene => $intCount) {
|
||||
$arrSceneRows[] = '<tr><td>' . self::escape((string)$strScene) . '</td><td>' . (int)$intCount . '</td></tr>';
|
||||
}
|
||||
|
||||
$arrPublishRows = [];
|
||||
foreach ((array)($arrDetail['publish_items'] ?? []) as $arrItem) {
|
||||
if (!is_array($arrItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrPublishRows[] = '<tr>'
|
||||
. '<td>' . self::escape((string)($arrItem['scene'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrItem['page_key'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrItem['action'] ?? '')) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
$arrRollbackRows = [];
|
||||
foreach ((array)(($arrDetail['rollback'] ?? [])['modes'] ?? []) as $strMode => $intCount) {
|
||||
$arrRollbackRows[] = '<tr><td>' . self::escape((string)$strMode) . '</td><td>' . (int)$intCount . '</td></tr>';
|
||||
}
|
||||
|
||||
$strRollbackSection = '';
|
||||
if (!empty($arrRollbackRows) || !empty(($arrDetail['rollback'] ?? [])['exists'])) {
|
||||
$strRollbackSection = '<h2>Rollback</h2>'
|
||||
. '<div class="meta-grid">'
|
||||
. '<div><strong>exists</strong><span>' . (!empty(($arrDetail['rollback'] ?? [])['exists']) ? 'yes' : 'no') . '</span></div>'
|
||||
. '<div><strong>dry_run</strong><span>' . (!empty(($arrDetail['rollback'] ?? [])['dry_run']) ? 'yes' : 'no') . '</span></div>'
|
||||
. '<div><strong>restored_count</strong><span>' . (int)(($arrDetail['rollback'] ?? [])['restored_count'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>skipped_count</strong><span>' . (int)(($arrDetail['rollback'] ?? [])['skipped_count'] ?? 0) . '</span></div>'
|
||||
. '</div>';
|
||||
|
||||
if (!empty($arrRollbackRows)) {
|
||||
$strRollbackSection .= '<table><thead><tr><th>mode</th><th>count</th></tr></thead><tbody>'
|
||||
. implode('', $arrRollbackRows)
|
||||
. '</tbody></table>';
|
||||
}
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'Release Detail: ' . self::escape((string)($arrDetail['release_tag'] ?? '')),
|
||||
'<p><a href="../latest-index.html">返回批次总览</a></p>'
|
||||
. '<div class="meta-grid">'
|
||||
. '<div><strong>release_tag</strong><span>' . self::escape((string)($arrDetail['release_tag'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>status</strong><span>' . self::escape((string)($arrDetail['status'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>last_event</strong><span>' . self::escape((string)($arrDetail['last_event_type'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>last_event_at</strong><span>' . self::escape((string)($arrDetail['last_event_at'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>published_count</strong><span>' . (int)($arrDetail['published_count'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>skipped_count</strong><span>' . (int)($arrDetail['skipped_count'] ?? 0) . '</span></div>'
|
||||
. '</div>'
|
||||
. '<h2>Scene Totals</h2><table><thead><tr><th>scene</th><th>count</th></tr></thead><tbody>'
|
||||
. implode('', $arrSceneRows)
|
||||
. '</tbody></table>'
|
||||
. '<h2>Published Items</h2><table><thead><tr><th>scene</th><th>page_key</th><th>action</th></tr></thead><tbody>'
|
||||
. implode('', $arrPublishRows)
|
||||
. '</tbody></table>'
|
||||
. $strRollbackSection
|
||||
);
|
||||
}
|
||||
|
||||
protected static function readJsonFile(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 writeFile(string $strPath, string $strContent): 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, $strContent);
|
||||
}
|
||||
|
||||
protected static function escape(string $strValue): string
|
||||
{
|
||||
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
protected static function wrapHtml(string $strTitle, string $strBody): string
|
||||
{
|
||||
$strEscapedTitle = self::escape($strTitle);
|
||||
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
. '<meta name="robots" content="noindex,nofollow,noarchive">'
|
||||
. '<title>' . $strEscapedTitle . '</title>'
|
||||
. '<style>'
|
||||
. 'body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:24px;background:#f5f7fb;color:#1f2937;}'
|
||||
. 'h1,h2{margin:0 0 16px;}'
|
||||
. 'p{line-height:1.7;}'
|
||||
. '.lead{margin:0 0 16px;color:#475569;}'
|
||||
. 'table{width:100%;border-collapse:collapse;background:#fff;margin:16px 0 24px;border-radius:12px;overflow:hidden;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. 'th,td{padding:12px 14px;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top;font-size:14px;}'
|
||||
. 'th{background:#eef2ff;color:#334155;font-weight:700;}'
|
||||
. '.meta-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px;}'
|
||||
. '.meta-grid div{background:#fff;padding:14px 16px;border-radius:12px;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. '.meta-grid strong{display:block;font-size:12px;color:#64748b;margin:0 0 6px;text-transform:uppercase;letter-spacing:.04em;}'
|
||||
. '.meta-grid span{font-size:14px;color:#111827;}'
|
||||
. 'a{color:#2563eb;text-decoration:none;}'
|
||||
. 'a:hover{text-decoration:underline;}'
|
||||
. '</style></head><body><h1>' . $strEscapedTitle . '</h1>' . $strBody . '</body></html>';
|
||||
}
|
||||
}
|
||||
24
code/app/common/helper/SeoCopyReleaseRunHelper.php
Normal file
24
code/app/common/helper/SeoCopyReleaseRunHelper.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class SeoCopyReleaseRunHelper
|
||||
{
|
||||
public static function run(array $arrOptions): array
|
||||
{
|
||||
require_once dirname(__DIR__, 3) . '/scripts/seo_copy_release_run.php';
|
||||
|
||||
if (!function_exists('seoCopyReleaseRunExecute')) {
|
||||
throw new \RuntimeException('seoCopyReleaseRunExecute is unavailable.');
|
||||
}
|
||||
|
||||
$arrResult = \seoCopyReleaseRunExecute($arrOptions);
|
||||
|
||||
return [
|
||||
'exit_code' => (int)($arrResult['exit_code'] ?? 0),
|
||||
'summary' => (array)($arrResult['summary'] ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
260
code/app/common/helper/SeoCopyReleaseRunIndexHelper.php
Normal file
260
code/app/common/helper/SeoCopyReleaseRunIndexHelper.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
use SplFileInfo;
|
||||
|
||||
class SeoCopyReleaseRunIndexHelper
|
||||
{
|
||||
public static function buildSummary(string $strRunsRoot, bool $boolAllowMissing = false): array
|
||||
{
|
||||
if (!is_dir($strRunsRoot)) {
|
||||
if (!$boolAllowMissing) {
|
||||
throw new RuntimeException('Run summary root not found: ' . $strRunsRoot);
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'runs_root' => $strRunsRoot,
|
||||
'runs_count' => 0,
|
||||
'status_totals' => [],
|
||||
'latest_run' => [],
|
||||
'runs' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$arrRuns = [];
|
||||
$arrStatusTotals = [];
|
||||
|
||||
$Iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($strRunsRoot, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($Iterator as $FileInfo) {
|
||||
/** @var SplFileInfo $FileInfo */
|
||||
if (!$FileInfo->isFile() || $FileInfo->getFilename() !== 'run-summary.json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strPath = str_replace('\\', '/', $FileInfo->getPathname());
|
||||
$arrData = json_decode((string)file_get_contents($strPath), true);
|
||||
if (!is_array($arrData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strStatus = trim((string)($arrData['status'] ?? ''));
|
||||
$strReleaseTag = trim((string)($arrData['release_tag'] ?? ''));
|
||||
$strManifest = trim((string)($arrData['manifest'] ?? ''));
|
||||
$strBaseUrl = trim((string)($arrData['base_url'] ?? ''));
|
||||
$strRunDir = str_replace('\\', '/', dirname($strPath));
|
||||
$strRunName = basename($strRunDir);
|
||||
$strUpdatedAt = date('c', (int)$FileInfo->getMTime());
|
||||
$strDetailSlug = self::buildDetailSlug($strRunName);
|
||||
$arrVerify = (array)($arrData['verify'] ?? []);
|
||||
$arrFrontVerify = (array)($arrData['front_verify'] ?? []);
|
||||
$arrPublish = (array)($arrData['publish'] ?? []);
|
||||
|
||||
if ($strStatus !== '') {
|
||||
$arrStatusTotals[$strStatus] = ($arrStatusTotals[$strStatus] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$arrRuns[] = [
|
||||
'run_name' => $strRunName,
|
||||
'detail_slug' => $strDetailSlug,
|
||||
'release_tag' => $strReleaseTag,
|
||||
'status' => $strStatus,
|
||||
'manifest_name' => basename($strManifest),
|
||||
'base_url' => $strBaseUrl,
|
||||
'verify_checked' => (int)($arrVerify['checked_files'] ?? 0),
|
||||
'verify_readable' => (int)($arrVerify['readable_files'] ?? 0),
|
||||
'verify_issues' => count((array)($arrVerify['issues'] ?? [])),
|
||||
'front_total' => (int)($arrFrontVerify['total'] ?? 0),
|
||||
'front_passed' => (int)($arrFrontVerify['passed'] ?? 0),
|
||||
'front_failed' => (int)($arrFrontVerify['failed'] ?? 0),
|
||||
'published_count' => (int)($arrPublish['published_count'] ?? 0),
|
||||
'skipped_count' => (int)($arrPublish['skipped_count'] ?? 0),
|
||||
'error' => trim((string)($arrData['error'] ?? '')),
|
||||
'updated_at' => $strUpdatedAt,
|
||||
];
|
||||
}
|
||||
|
||||
usort($arrRuns, static function (array $arrA, array $arrB): int {
|
||||
return strcmp((string)($arrB['updated_at'] ?? ''), (string)($arrA['updated_at'] ?? ''));
|
||||
});
|
||||
|
||||
ksort($arrStatusTotals, SORT_STRING);
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'runs_root' => realpath($strRunsRoot) ?: $strRunsRoot,
|
||||
'runs_count' => count($arrRuns),
|
||||
'status_totals' => $arrStatusTotals,
|
||||
'latest_run' => $arrRuns[0] ?? [],
|
||||
'runs' => $arrRuns,
|
||||
];
|
||||
}
|
||||
|
||||
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): void
|
||||
{
|
||||
self::writeFile(
|
||||
rtrim($strOutputRoot, '/') . '/index.json',
|
||||
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile(
|
||||
rtrim($strOutputRoot, '/') . '/index.html',
|
||||
self::renderHtml($arrSummary)
|
||||
);
|
||||
|
||||
foreach ((array)($arrSummary['runs'] ?? []) as $arrRun) {
|
||||
if (!is_array($arrRun)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strDetailSlug = trim((string)($arrRun['detail_slug'] ?? ''));
|
||||
if ($strDetailSlug === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::writeFile(
|
||||
rtrim($strOutputRoot, '/') . '/details/' . $strDetailSlug . '.json',
|
||||
json_encode($arrRun, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile(
|
||||
rtrim($strOutputRoot, '/') . '/details/' . $strDetailSlug . '.html',
|
||||
self::renderDetailHtml($arrRun)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function renderHtml(array $arrSummary): string
|
||||
{
|
||||
$arrRows = [];
|
||||
foreach ((array)($arrSummary['runs'] ?? []) as $arrRun) {
|
||||
if (!is_array($arrRun)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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['release_tag'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['status'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['manifest_name'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['base_url'] ?? '')) . '</td>'
|
||||
. '<td>' . (int)($arrRun['verify_issues'] ?? 0) . '</td>'
|
||||
. '<td>' . (int)($arrRun['front_failed'] ?? 0) . '</td>'
|
||||
. '<td>' . (int)($arrRun['published_count'] ?? 0) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['updated_at'] ?? '')) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'SEO Copy Release Runs',
|
||||
'<p class="lead">内部只读运行记录页,展示最近的门禁执行结果与核心统计。</p>'
|
||||
. '<div class="meta-grid">'
|
||||
. '<div><strong>generated_at</strong><span>' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>runs</strong><span>' . (int)($arrSummary['runs_count'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>statuses</strong><span>' . self::escape(implode(', ', array_keys((array)($arrSummary['status_totals'] ?? [])))) . '</span></div>'
|
||||
. '</div>'
|
||||
. '<table><thead><tr><th>run_name</th><th>release_tag</th><th>status</th><th>manifest</th><th>base_url</th><th>verify_issues</th><th>front_failed</th><th>published</th><th>updated_at</th></tr></thead><tbody>'
|
||||
. implode('', $arrRows)
|
||||
. '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
protected static function writeFile(string $strPath, string $strContent): 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, $strContent);
|
||||
}
|
||||
|
||||
protected static function escape(string $strValue): string
|
||||
{
|
||||
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
protected static function wrapHtml(string $strTitle, string $strBody): string
|
||||
{
|
||||
$strEscapedTitle = self::escape($strTitle);
|
||||
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
. '<meta name="robots" content="noindex,nofollow,noarchive">'
|
||||
. '<title>' . $strEscapedTitle . '</title>'
|
||||
. '<style>'
|
||||
. 'body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:24px;background:#f5f7fb;color:#1f2937;}'
|
||||
. 'h1{margin:0 0 16px;}'
|
||||
. 'p{line-height:1.7;}'
|
||||
. '.lead{margin:0 0 16px;color:#475569;}'
|
||||
. 'table{width:100%;border-collapse:collapse;background:#fff;margin:16px 0 24px;border-radius:12px;overflow:hidden;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. 'th,td{padding:12px 14px;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top;font-size:14px;}'
|
||||
. 'th{background:#eef2ff;color:#334155;font-weight:700;}'
|
||||
. '.meta-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px;}'
|
||||
. '.meta-grid div{background:#fff;padding:14px 16px;border-radius:12px;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. '.meta-grid strong{display:block;font-size:12px;color:#64748b;margin:0 0 6px;text-transform:uppercase;letter-spacing:.04em;}'
|
||||
. '.meta-grid span{font-size:14px;color:#111827;}'
|
||||
. '</style></head><body><h1>' . $strEscapedTitle . '</h1>' . $strBody . '</body></html>';
|
||||
}
|
||||
|
||||
protected static function renderDetailHtml(array $arrRun): string
|
||||
{
|
||||
$arrCards = [
|
||||
'<div><strong>run_name</strong><span>' . self::escape((string)($arrRun['run_name'] ?? '')) . '</span></div>',
|
||||
'<div><strong>release_tag</strong><span>' . self::escape((string)($arrRun['release_tag'] ?? '')) . '</span></div>',
|
||||
'<div><strong>status</strong><span>' . self::escape((string)($arrRun['status'] ?? '')) . '</span></div>',
|
||||
'<div><strong>manifest</strong><span>' . self::escape((string)($arrRun['manifest_name'] ?? '')) . '</span></div>',
|
||||
'<div><strong>verify</strong><span>' . (int)($arrRun['verify_checked'] ?? 0) . '/' . (int)($arrRun['verify_readable'] ?? 0) . ' readable</span></div>',
|
||||
'<div><strong>front_verify</strong><span>' . (int)($arrRun['front_passed'] ?? 0) . '/' . (int)($arrRun['front_total'] ?? 0) . ' passed</span></div>',
|
||||
'<div><strong>publish</strong><span>' . (int)($arrRun['published_count'] ?? 0) . ' published / ' . (int)($arrRun['skipped_count'] ?? 0) . ' skipped</span></div>',
|
||||
'<div><strong>updated_at</strong><span>' . self::escape((string)($arrRun['updated_at'] ?? '')) . '</span></div>',
|
||||
];
|
||||
|
||||
$strError = trim((string)($arrRun['error'] ?? ''));
|
||||
$strErrorBlock = $strError !== ''
|
||||
? '<p><strong>Error:</strong> ' . self::escape($strError) . '</p>'
|
||||
: '';
|
||||
$strReleaseTag = trim((string)($arrRun['release_tag'] ?? ''));
|
||||
$strRelatedRelease = $strReleaseTag !== ''
|
||||
? '<a href="../../releases/' . rawurlencode($strReleaseTag) . '.html">查看对应 release 详情</a>'
|
||||
: '';
|
||||
$arrNavLinks = [
|
||||
'<a href="../../index.html">返回门户首页</a>',
|
||||
'<a href="../index.html">返回 runs 总览</a>',
|
||||
];
|
||||
if ($strRelatedRelease !== '') {
|
||||
$arrNavLinks[] = $strRelatedRelease;
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'SEO Copy Run Detail',
|
||||
'<p class="lead">内部只读运行详情页,展示单次门禁执行的摘要结果。</p>'
|
||||
. '<p>' . implode(' | ', $arrNavLinks) . '</p>'
|
||||
. '<div class="meta-grid">' . implode('', $arrCards) . '</div>'
|
||||
. $strErrorBlock
|
||||
. '<table><thead><tr><th>metric</th><th>value</th></tr></thead><tbody>'
|
||||
. '<tr><td>verify_checked</td><td>' . (int)($arrRun['verify_checked'] ?? 0) . '</td></tr>'
|
||||
. '<tr><td>verify_readable</td><td>' . (int)($arrRun['verify_readable'] ?? 0) . '</td></tr>'
|
||||
. '<tr><td>verify_issues</td><td>' . (int)($arrRun['verify_issues'] ?? 0) . '</td></tr>'
|
||||
. '<tr><td>front_total</td><td>' . (int)($arrRun['front_total'] ?? 0) . '</td></tr>'
|
||||
. '<tr><td>front_passed</td><td>' . (int)($arrRun['front_passed'] ?? 0) . '</td></tr>'
|
||||
. '<tr><td>front_failed</td><td>' . (int)($arrRun['front_failed'] ?? 0) . '</td></tr>'
|
||||
. '<tr><td>published_count</td><td>' . (int)($arrRun['published_count'] ?? 0) . '</td></tr>'
|
||||
. '<tr><td>skipped_count</td><td>' . (int)($arrRun['skipped_count'] ?? 0) . '</td></tr>'
|
||||
. '<tr><td>base_url</td><td>' . self::escape((string)($arrRun['base_url'] ?? '')) . '</td></tr>'
|
||||
. '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
protected static function buildDetailSlug(string $strRunName): string
|
||||
{
|
||||
$strRunName = preg_replace('/[^a-zA-Z0-9\\-_]+/', '-', strtolower(trim($strRunName)));
|
||||
$strRunName = trim((string)$strRunName, '-_');
|
||||
|
||||
return $strRunName !== '' ? $strRunName : sha1((string)microtime(true));
|
||||
}
|
||||
}
|
||||
202
code/app/common/helper/SeoCopyReleaseViewHelper.php
Normal file
202
code/app/common/helper/SeoCopyReleaseViewHelper.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SeoCopyReleaseViewHelper
|
||||
{
|
||||
public static function buildReleaseDetail(array $arrRelease): array
|
||||
{
|
||||
$arrPublishLog = self::readJsonFile((string)($arrRelease['publish_log_path'] ?? ''));
|
||||
$arrRollbackLog = self::readJsonFile((string)($arrRelease['rollback_log_path'] ?? ''));
|
||||
|
||||
return [
|
||||
'release_tag' => (string)($arrRelease['release_tag'] ?? ''),
|
||||
'status' => (string)($arrRelease['status'] ?? ''),
|
||||
'last_event_type' => (string)($arrRelease['last_event_type'] ?? ''),
|
||||
'last_event_at' => (string)($arrRelease['last_event_at'] ?? ''),
|
||||
'target_root' => (string)($arrRelease['target_root'] ?? ''),
|
||||
'manifest' => (string)($arrRelease['manifest'] ?? ''),
|
||||
'manifest_digest' => (string)($arrRelease['manifest_digest'] ?? ''),
|
||||
'publish_log_path' => (string)($arrRelease['publish_log_path'] ?? ''),
|
||||
'rollback_log_path' => (string)($arrRelease['rollback_log_path'] ?? ''),
|
||||
'published_count' => (int)($arrRelease['published_count'] ?? 0),
|
||||
'skipped_count' => (int)($arrRelease['skipped_count'] ?? 0),
|
||||
'publish_items' => (array)($arrPublishLog['published'] ?? []),
|
||||
'publish_skipped' => (array)($arrPublishLog['skipped'] ?? []),
|
||||
'rollback_restored' => (array)($arrRollbackLog['restored'] ?? []),
|
||||
'rollback_skipped' => (array)($arrRollbackLog['skipped'] ?? []),
|
||||
'rollback_dry_run' => (bool)($arrRollbackLog['dry_run'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
public static function writeArtifacts(string $strLogDir, array $arrSummary): void
|
||||
{
|
||||
self::writeFile(rtrim($strLogDir, '/') . '/latest-index.html', self::renderLatestIndexHtml($arrSummary));
|
||||
|
||||
$strReleaseDir = rtrim($strLogDir, '/') . '/releases';
|
||||
foreach ((array)($arrSummary['releases'] ?? []) as $arrRelease) {
|
||||
if (!is_array($arrRelease)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strReleaseTag = trim((string)($arrRelease['release_tag'] ?? ''));
|
||||
if ($strReleaseTag === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrDetail = self::buildReleaseDetail($arrRelease);
|
||||
self::writeFile(
|
||||
$strReleaseDir . '/' . $strReleaseTag . '.json',
|
||||
json_encode($arrDetail, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile(
|
||||
$strReleaseDir . '/' . $strReleaseTag . '.html',
|
||||
self::renderReleaseDetailHtml($arrDetail)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function renderLatestIndexHtml(array $arrSummary): string
|
||||
{
|
||||
$arrRows = [];
|
||||
foreach ((array)($arrSummary['releases'] ?? []) as $arrRelease) {
|
||||
if (!is_array($arrRelease)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strTag = htmlspecialchars((string)($arrRelease['release_tag'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$strStatus = htmlspecialchars((string)($arrRelease['status'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$strLastEvent = htmlspecialchars((string)($arrRelease['last_event_type'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$strLastAt = htmlspecialchars((string)($arrRelease['last_event_at'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$intPublished = (int)($arrRelease['published_count'] ?? 0);
|
||||
$intSkipped = (int)($arrRelease['skipped_count'] ?? 0);
|
||||
$arrRows[] = '<tr>'
|
||||
. '<td><a href="./releases/' . $strTag . '.html">' . $strTag . '</a></td>'
|
||||
. '<td>' . $strStatus . '</td>'
|
||||
. '<td>' . $intPublished . '</td>'
|
||||
. '<td>' . $intSkipped . '</td>'
|
||||
. '<td>' . $strLastEvent . '</td>'
|
||||
. '<td>' . $strLastAt . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'SEO Copy Release Index',
|
||||
'<div class="meta-grid">'
|
||||
. '<div><strong>generated_at</strong><span>' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span></div>'
|
||||
. '<div><strong>publish_logs</strong><span>' . (int)($arrSummary['publish_logs'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>rollback_logs</strong><span>' . (int)($arrSummary['rollback_logs'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>releases</strong><span>' . count((array)($arrSummary['releases'] ?? [])) . '</span></div>'
|
||||
. '</div>'
|
||||
. '<table><thead><tr><th>release_tag</th><th>status</th><th>published</th><th>skipped</th><th>last_event</th><th>last_event_at</th></tr></thead><tbody>'
|
||||
. implode('', $arrRows)
|
||||
. '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
public static function renderReleaseDetailHtml(array $arrDetail): string
|
||||
{
|
||||
$strTag = htmlspecialchars((string)($arrDetail['release_tag'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$strStatus = htmlspecialchars((string)($arrDetail['status'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$strEvent = htmlspecialchars((string)($arrDetail['last_event_type'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$strAt = htmlspecialchars((string)($arrDetail['last_event_at'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
$arrPublishedRows = [];
|
||||
foreach ((array)($arrDetail['publish_items'] ?? []) as $arrItem) {
|
||||
if (!is_array($arrItem)) {
|
||||
continue;
|
||||
}
|
||||
$arrPublishedRows[] = '<tr>'
|
||||
. '<td>' . htmlspecialchars((string)($arrItem['scene'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($arrItem['page_key'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($arrItem['action'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</td>'
|
||||
. '<td><code>' . htmlspecialchars((string)($arrItem['target_path'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
$arrRollbackRows = [];
|
||||
foreach ((array)($arrDetail['rollback_restored'] ?? []) as $arrItem) {
|
||||
if (!is_array($arrItem)) {
|
||||
continue;
|
||||
}
|
||||
$arrRollbackRows[] = '<tr>'
|
||||
. '<td>' . htmlspecialchars((string)($arrItem['mode'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</td>'
|
||||
. '<td><code>' . htmlspecialchars((string)($arrItem['target_path'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></td>'
|
||||
. '<td><code>' . htmlspecialchars((string)($arrItem['backup_path'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
$strRollbackSection = '';
|
||||
if (!empty($arrRollbackRows)) {
|
||||
$strRollbackSection = '<h2>Rollback</h2><table><thead><tr><th>mode</th><th>target_path</th><th>backup_path</th></tr></thead><tbody>'
|
||||
. implode('', $arrRollbackRows)
|
||||
. '</tbody></table>';
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'Release Detail: ' . $strTag,
|
||||
'<p><a href="../latest-index.html">Back to latest index</a></p>'
|
||||
. '<div class="meta-grid">'
|
||||
. '<div><strong>release_tag</strong><span>' . $strTag . '</span></div>'
|
||||
. '<div><strong>status</strong><span>' . $strStatus . '</span></div>'
|
||||
. '<div><strong>last_event</strong><span>' . $strEvent . '</span></div>'
|
||||
. '<div><strong>last_event_at</strong><span>' . $strAt . '</span></div>'
|
||||
. '</div>'
|
||||
. '<p><strong>target_root</strong><br><code>' . htmlspecialchars((string)($arrDetail['target_root'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></p>'
|
||||
. '<p><strong>manifest</strong><br><code>' . htmlspecialchars((string)($arrDetail['manifest'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></p>'
|
||||
. '<p><strong>manifest_digest</strong><br><code>' . htmlspecialchars((string)($arrDetail['manifest_digest'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></p>'
|
||||
. '<h2>Published Items</h2><table><thead><tr><th>scene</th><th>page_key</th><th>action</th><th>target_path</th></tr></thead><tbody>'
|
||||
. implode('', $arrPublishedRows)
|
||||
. '</tbody></table>'
|
||||
. $strRollbackSection
|
||||
);
|
||||
}
|
||||
|
||||
protected static function readJsonFile(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 writeFile(string $strPath, string $strContent): 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, $strContent);
|
||||
}
|
||||
|
||||
protected static function wrapHtml(string $strTitle, string $strBody): string
|
||||
{
|
||||
$strEscapedTitle = htmlspecialchars($strTitle, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
. '<title>' . $strEscapedTitle . '</title>'
|
||||
. '<style>'
|
||||
. 'body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:24px;background:#f5f7fb;color:#1f2937;}'
|
||||
. 'h1,h2{margin:0 0 16px;}'
|
||||
. 'p{line-height:1.7;}'
|
||||
. 'table{width:100%;border-collapse:collapse;background:#fff;margin:16px 0 24px;border-radius:12px;overflow:hidden;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. 'th,td{padding:12px 14px;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top;font-size:14px;}'
|
||||
. 'th{background:#eef2ff;color:#334155;font-weight:700;}'
|
||||
. 'code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;word-break:break-all;}'
|
||||
. '.meta-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px;}'
|
||||
. '.meta-grid div{background:#fff;padding:14px 16px;border-radius:12px;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. '.meta-grid strong{display:block;font-size:12px;color:#64748b;margin:0 0 6px;text-transform:uppercase;letter-spacing:.04em;}'
|
||||
. '.meta-grid span{font-size:14px;color:#111827;}'
|
||||
. 'a{color:#2563eb;text-decoration:none;}'
|
||||
. 'a:hover{text-decoration:underline;}'
|
||||
. '</style></head><body><h1>' . $strEscapedTitle . '</h1>' . $strBody . '</body></html>';
|
||||
}
|
||||
}
|
||||
889
code/app/common/helper/SeoCopyRuntimeAcceptanceHelper.php
Normal file
889
code/app/common/helper/SeoCopyRuntimeAcceptanceHelper.php
Normal file
@@ -0,0 +1,889 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\DomainModel;
|
||||
use app\model\VideoModel;
|
||||
use app\task\logic\VideoSiteMapLogic;
|
||||
use ReflectionMethod;
|
||||
use RuntimeException;
|
||||
use think\App;
|
||||
|
||||
class SeoCopyRuntimeAcceptanceHelper
|
||||
{
|
||||
public static function getConfiguredSamplesForHost(string $strHost): array
|
||||
{
|
||||
self::ensureAppInitialized();
|
||||
|
||||
$strHost = DomainModel::normalizeHost($strHost);
|
||||
if ($strHost === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var DomainModel|null $DomainRow */
|
||||
$DomainRow = app(DomainModel::class)->where('d_domain', DomainModel::normalizeStoredDomain($strHost, DomainModel::MATCH_TYPE_EXACT))->find();
|
||||
if (!$DomainRow instanceof DomainModel || !$DomainRow->isRuntimeAcceptanceEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $DomainRow->getRuntimeAcceptanceSamples();
|
||||
}
|
||||
|
||||
public static function verify(string $strHost, int $intVideoId, array $arrOptions = []): array
|
||||
{
|
||||
self::ensureAppInitialized();
|
||||
|
||||
$intForgeId = max(1, (int)($arrOptions['forge_id'] ?? 1));
|
||||
$strBaseUrl = rtrim((string)($arrOptions['base_url'] ?? ('https://' . $strHost)), '/');
|
||||
$intExpandedSitemapCount = max(0, (int)($arrOptions['expand_sitemap_count'] ?? 2));
|
||||
$intExpandedEntryCount = max(0, (int)($arrOptions['expand_entry_count'] ?? 4));
|
||||
$strTargetRoot = (string)($arrOptions['target_root'] ?? dirname(__DIR__, 3) . '/data/seo_copy');
|
||||
|
||||
$arrDelegatedSummary = self::runAcceptanceVerifyScript($strHost, $intVideoId, $intForgeId, $strBaseUrl, $strTargetRoot);
|
||||
if (!empty($arrDelegatedSummary)) {
|
||||
return $arrDelegatedSummary;
|
||||
}
|
||||
|
||||
/** @var DomainModel|null $DomainRow */
|
||||
$DomainRow = app(DomainModel::class)->where('d_domain', $strHost)->find();
|
||||
if (empty($DomainRow)) {
|
||||
throw new RuntimeException('Domain not found: ' . $strHost);
|
||||
}
|
||||
|
||||
$arrVideo = VideoModel::getInstance()->findOne(['v_id' => $intVideoId], [
|
||||
'typeMap' => [
|
||||
'root' => 'array',
|
||||
'document' => 'array',
|
||||
'array' => 'array',
|
||||
],
|
||||
'projection' => [
|
||||
'_id' => 0,
|
||||
'v_id' => 1,
|
||||
'v_name' => 1,
|
||||
'v_name_en' => 1,
|
||||
'v_play_url' => 1,
|
||||
'v_seo_words' => 1,
|
||||
],
|
||||
]) ?? [];
|
||||
|
||||
$strSlug = trim((string)($arrVideo['v_name_en'] ?? ''));
|
||||
if ($strSlug === '') {
|
||||
throw new RuntimeException('Video slug empty: ' . $intVideoId);
|
||||
}
|
||||
|
||||
$arrSuites = [];
|
||||
|
||||
$arrForgeRoute = self::verifyForgeRouteGroup($DomainRow, $arrVideo, $strHost, $intVideoId, $intForgeId, $strBaseUrl, $strTargetRoot);
|
||||
$arrSuites['forge_route_group'] = self::buildSuiteSummary($arrForgeRoute, static function (array $arrJson): string {
|
||||
return 'group=' . (string)($arrJson['forge_group'] ?? '-')
|
||||
. ', detail=' . (string)($arrJson['detail_path'] ?? '-')
|
||||
. ', forge=' . (string)($arrJson['forge_path'] ?? '-');
|
||||
});
|
||||
|
||||
$arrForgeOutput = self::verifyForgeOutputRuntime($DomainRow, $arrVideo, $strHost, $intVideoId, $strBaseUrl, $intExpandedSitemapCount, $intExpandedEntryCount);
|
||||
$arrSuites['forge_output_runtime'] = self::buildSuiteSummary($arrForgeOutput, static function (array $arrJson): string {
|
||||
return 'entry=' . (int)($arrJson['entry_links_detected'] ?? 0)
|
||||
. ', current=' . implode(',', (array)($arrJson['current_output_kinds'] ?? []))
|
||||
. ', expanded=' . implode(',', (array)($arrJson['expanded_output_kinds'] ?? []));
|
||||
});
|
||||
|
||||
$arrPlayRuntime = self::verifyPlayRuntime($DomainRow, $arrVideo, $strHost, $intVideoId, $strBaseUrl);
|
||||
$arrSuites['play_runtime'] = self::buildSuiteSummary($arrPlayRuntime, static function (array $arrJson): string {
|
||||
return 'play=' . (string)($arrJson['play_path'] ?? '-')
|
||||
. ', type=' . (string)($arrJson['play_type'] ?? '-')
|
||||
. ', index=' . (int)($arrJson['play_index'] ?? 0);
|
||||
});
|
||||
|
||||
$arrForgeFiles = self::verifyForgeOutputFiles($DomainRow, $arrVideo, $strHost, $intVideoId, $intExpandedSitemapCount);
|
||||
$arrSuites['forge_output_files'] = self::buildSuiteSummary($arrForgeFiles, static function (array $arrJson): string {
|
||||
return 'xml=' . (int)($arrJson['expanded_xml_entries'] ?? 0)
|
||||
. ', json=' . (int)($arrJson['expanded_json_count'] ?? 0);
|
||||
});
|
||||
|
||||
$arrChecks = [];
|
||||
foreach ($arrSuites as $strSuiteKey => $arrSuite) {
|
||||
$arrChecks[] = [
|
||||
'label' => $strSuiteKey . '_all_passed',
|
||||
'passed' => !empty($arrSuite['passed']),
|
||||
'detail' => 'failed_checks=' . (int)($arrSuite['failed_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $strHost,
|
||||
'video_id' => $intVideoId,
|
||||
'forge_id' => $intForgeId,
|
||||
'suites' => $arrSuites,
|
||||
'checks' => $arrChecks,
|
||||
'all_passed' => !in_array(false, array_column($arrChecks, 'passed'), true),
|
||||
'verified_at' => date(DATE_ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function runAcceptanceVerifyScript(string $strHost, int $intVideoId, int $intForgeId, string $strBaseUrl, string $strTargetRoot = ''): array
|
||||
{
|
||||
if (!function_exists('proc_open')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$strScript = dirname(__DIR__, 3) . '/scripts/seo_copy_runtime_acceptance_verify.php';
|
||||
if (!is_file($strScript)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrCommand = [
|
||||
PHP_BINARY,
|
||||
$strScript,
|
||||
$strHost,
|
||||
(string)$intVideoId,
|
||||
'--forge-id=' . $intForgeId,
|
||||
'--base-url=' . $strBaseUrl,
|
||||
];
|
||||
|
||||
if (trim($strTargetRoot) !== '') {
|
||||
$arrCommand[] = '--target-root=' . $strTargetRoot;
|
||||
}
|
||||
|
||||
$arrCommand[] = '--format=json';
|
||||
$arrDescriptor = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$Process = proc_open($arrCommand, $arrDescriptor, $arrPipes, dirname(__DIR__, 3));
|
||||
if (!is_resource($Process)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
fclose($arrPipes[0]);
|
||||
$strStdout = stream_get_contents($arrPipes[1]);
|
||||
$strStderr = stream_get_contents($arrPipes[2]);
|
||||
fclose($arrPipes[1]);
|
||||
fclose($arrPipes[2]);
|
||||
$intExitCode = proc_close($Process);
|
||||
|
||||
$arrJson = json_decode((string)$strStdout, true);
|
||||
if (!is_array($arrJson)) {
|
||||
$intJsonStart = strpos((string)$strStdout, '{');
|
||||
if ($intJsonStart !== false) {
|
||||
$arrJson = json_decode(substr((string)$strStdout, $intJsonStart), true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($arrJson) || empty($arrJson['suites']) || empty($arrJson['checks'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!isset($arrJson['all_passed'])) {
|
||||
$arrJson['all_passed'] = $intExitCode === 0;
|
||||
}
|
||||
|
||||
if (!isset($arrJson['verified_at'])) {
|
||||
$arrJson['verified_at'] = date(DATE_ATOM);
|
||||
}
|
||||
|
||||
if ($intExitCode !== 0 && empty($arrJson['stderr']) && trim($strStderr) !== '') {
|
||||
$arrJson['stderr'] = trim($strStderr);
|
||||
}
|
||||
|
||||
return $arrJson;
|
||||
}
|
||||
|
||||
public static function run(string $strHost, int $intVideoId, array $arrOptions = []): array
|
||||
{
|
||||
$intForgeId = max(1, (int)($arrOptions['forge_id'] ?? 1));
|
||||
$strBaseUrl = rtrim((string)($arrOptions['base_url'] ?? ('https://' . $strHost)), '/');
|
||||
$strRunRoot = (string)($arrOptions['run_root'] ?? dirname(__DIR__, 3) . '/storage/seo_copy_runtime_acceptance_runs');
|
||||
$strPortalRoot = (string)($arrOptions['portal_root'] ?? dirname(__DIR__, 3) . '/public/_seo_copy_release');
|
||||
|
||||
$arrSummary = self::verify($strHost, $intVideoId, [
|
||||
'forge_id' => $intForgeId,
|
||||
'base_url' => $strBaseUrl,
|
||||
'expand_sitemap_count' => $arrOptions['expand_sitemap_count'] ?? 2,
|
||||
'expand_entry_count' => $arrOptions['expand_entry_count'] ?? 4,
|
||||
'target_root' => $arrOptions['target_root'] ?? dirname(__DIR__, 3) . '/data/seo_copy',
|
||||
]);
|
||||
|
||||
$strDayDir = rtrim($strRunRoot, '/') . '/' . date('Ymd');
|
||||
if (!is_dir($strDayDir) && !mkdir($strDayDir, 0777, true) && !is_dir($strDayDir)) {
|
||||
throw new RuntimeException('Failed to create runtime acceptance run root: ' . $strDayDir);
|
||||
}
|
||||
|
||||
$strRunName = date('His') . '_' . preg_replace('/[^a-zA-Z0-9\\-]+/', '-', $strHost) . '_v' . $intVideoId . '_f' . $intForgeId;
|
||||
$strRunPath = $strDayDir . '/' . $strRunName . '.json';
|
||||
$arrSummary['run_name'] = $strRunName;
|
||||
$arrSummary['stored_at'] = date(DATE_ATOM);
|
||||
$arrSummary['run_path'] = $strRunPath;
|
||||
file_put_contents($strRunPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
|
||||
$arrIndexSummary = SeoCopyRuntimeAcceptanceIndexHelper::buildSummary($strRunRoot, true);
|
||||
$strOutputRoot = '';
|
||||
if ($strPortalRoot !== '' && strtolower($strPortalRoot) !== 'off') {
|
||||
$strOutputRoot = rtrim($strPortalRoot, '/') . '/runtime-acceptance';
|
||||
SeoCopyRuntimeAcceptanceIndexHelper::writeArtifacts($strOutputRoot, $arrIndexSummary);
|
||||
SeoCopyPortalHomeHelper::writeArtifacts($strPortalRoot, SeoCopyPortalHomeHelper::buildSummary($strPortalRoot));
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'host' => $strHost,
|
||||
'video_id' => $intVideoId,
|
||||
'forge_id' => $intForgeId,
|
||||
'run_root' => realpath($strRunRoot) ?: $strRunRoot,
|
||||
'run_path' => $strRunPath,
|
||||
'output_root' => $strOutputRoot,
|
||||
'status' => !empty($arrSummary['all_passed']) ? 'passed' : 'failed',
|
||||
'all_passed' => !empty($arrSummary['all_passed']),
|
||||
'latest_run' => $arrIndexSummary['latest_run'] ?? [],
|
||||
'runs_count' => (int)($arrIndexSummary['runs_count'] ?? 0),
|
||||
'status_totals' => (array)($arrIndexSummary['status_totals'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
public static function runHostConfiguredSamples(string $strHost, array $arrOptions = []): array
|
||||
{
|
||||
self::ensureAppInitialized();
|
||||
|
||||
$strHost = DomainModel::normalizeHost($strHost);
|
||||
if ($strHost === '') {
|
||||
throw new RuntimeException('Host empty');
|
||||
}
|
||||
|
||||
$arrSamples = self::getConfiguredSamplesForHost($strHost);
|
||||
if (empty($arrSamples)) {
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'host' => $strHost,
|
||||
'status' => 'skipped_missing_samples',
|
||||
'configured_samples' => 0,
|
||||
'processed_count' => 0,
|
||||
'passed_count' => 0,
|
||||
'failed_count' => 0,
|
||||
'targets' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$strRunRoot = (string)($arrOptions['run_root'] ?? dirname(__DIR__, 3) . '/storage/seo_copy_runtime_acceptance_runs');
|
||||
$strPortalRoot = (string)($arrOptions['portal_root'] ?? dirname(__DIR__, 3) . '/public/_seo_copy_release');
|
||||
$strBaseUrl = rtrim((string)($arrOptions['base_url'] ?? ('https://' . $strHost)), '/');
|
||||
$arrRuns = [];
|
||||
|
||||
foreach ($arrSamples as $arrSample) {
|
||||
$intVideoId = (int)($arrSample['video_id'] ?? 0);
|
||||
if ($intVideoId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$arrRuns[] = self::run($strHost, $intVideoId, [
|
||||
'forge_id' => max(1, (int)($arrSample['forge_id'] ?? 1)),
|
||||
'base_url' => $strBaseUrl,
|
||||
'run_root' => $strRunRoot,
|
||||
'portal_root' => $strPortalRoot,
|
||||
'expand_sitemap_count' => $arrOptions['expand_sitemap_count'] ?? 2,
|
||||
'expand_entry_count' => $arrOptions['expand_entry_count'] ?? 4,
|
||||
'target_root' => $arrOptions['target_root'] ?? dirname(__DIR__, 3) . '/data/seo_copy',
|
||||
]);
|
||||
}
|
||||
|
||||
$intPassedCount = count(array_filter($arrRuns, static fn(array $arrRun): bool => !empty($arrRun['all_passed'])));
|
||||
$intFailedCount = max(0, count($arrRuns) - $intPassedCount);
|
||||
$arrLatestRun = $arrRuns[count($arrRuns) - 1] ?? [];
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'host' => $strHost,
|
||||
'status' => $intFailedCount === 0 ? 'passed' : ($intPassedCount > 0 ? 'partial' : 'failed'),
|
||||
'configured_samples' => count($arrSamples),
|
||||
'processed_count' => count($arrRuns),
|
||||
'passed_count' => $intPassedCount,
|
||||
'failed_count' => $intFailedCount,
|
||||
'latest_run' => $arrLatestRun,
|
||||
'targets' => array_values(array_map(static function (array $arrRun): array {
|
||||
return [
|
||||
'host' => (string)($arrRun['host'] ?? ''),
|
||||
'video_id' => (int)($arrRun['video_id'] ?? 0),
|
||||
'forge_id' => (int)($arrRun['forge_id'] ?? 0),
|
||||
'status' => (string)($arrRun['status'] ?? ''),
|
||||
'all_passed' => !empty($arrRun['all_passed']),
|
||||
'run_path' => (string)($arrRun['run_path'] ?? ''),
|
||||
];
|
||||
}, $arrRuns)),
|
||||
];
|
||||
}
|
||||
|
||||
public static function runForgeConfiguredSample(string $strHost, array $arrOptions = []): array
|
||||
{
|
||||
self::ensureAppInitialized();
|
||||
|
||||
$strHost = DomainModel::normalizeHost($strHost);
|
||||
if ($strHost === '') {
|
||||
throw new RuntimeException('Host empty');
|
||||
}
|
||||
|
||||
$arrSamples = self::getConfiguredSamplesForHost($strHost);
|
||||
if (empty($arrSamples)) {
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'host' => $strHost,
|
||||
'status' => 'skipped_missing_samples',
|
||||
'configured_samples' => 0,
|
||||
'selected_sample' => [],
|
||||
'run' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$arrSample = (array)($arrOptions['sample'] ?? $arrSamples[0]);
|
||||
$intVideoId = max(1, (int)($arrSample['video_id'] ?? 0));
|
||||
$intForgeId = max(1, (int)($arrSample['forge_id'] ?? 1));
|
||||
if ($intVideoId <= 0) {
|
||||
throw new RuntimeException('Sample video id empty for host: ' . $strHost);
|
||||
}
|
||||
|
||||
$arrRun = self::run($strHost, $intVideoId, [
|
||||
'forge_id' => $intForgeId,
|
||||
'base_url' => (string)($arrOptions['base_url'] ?? ('https://' . $strHost)),
|
||||
'run_root' => (string)($arrOptions['run_root'] ?? dirname(__DIR__, 3) . '/storage/seo_copy_runtime_acceptance_runs'),
|
||||
'portal_root' => (string)($arrOptions['portal_root'] ?? dirname(__DIR__, 3) . '/public/_seo_copy_release'),
|
||||
'target_root' => (string)($arrOptions['target_root'] ?? dirname(__DIR__, 3) . '/data/seo_copy'),
|
||||
]);
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'host' => $strHost,
|
||||
'status' => (string)($arrRun['status'] ?? 'failed'),
|
||||
'configured_samples' => count($arrSamples),
|
||||
'selected_sample' => [
|
||||
'video_id' => $intVideoId,
|
||||
'forge_id' => $intForgeId,
|
||||
],
|
||||
'run' => $arrRun,
|
||||
];
|
||||
}
|
||||
|
||||
public static function runFailedHosts(array $arrOptions = []): array
|
||||
{
|
||||
self::ensureAppInitialized();
|
||||
|
||||
$strRunRoot = (string)($arrOptions['run_root'] ?? dirname(__DIR__, 3) . '/storage/seo_copy_runtime_acceptance_runs');
|
||||
$strPortalRoot = (string)($arrOptions['portal_root'] ?? dirname(__DIR__, 3) . '/public/_seo_copy_release');
|
||||
$strHost = DomainModel::normalizeHost((string)($arrOptions['host'] ?? ''));
|
||||
$strCategory = trim((string)($arrOptions['category'] ?? ''));
|
||||
$intLimit = max(1, (int)($arrOptions['limit'] ?? 10));
|
||||
|
||||
$arrSummary = SeoCopyRuntimeAcceptanceIndexHelper::buildSummary($strRunRoot, true);
|
||||
$arrFailedHosts = array_values(array_filter((array)($arrSummary['failed_hosts'] ?? []), 'is_array'));
|
||||
if ($strHost !== '') {
|
||||
$arrFailedHosts = array_values(array_filter($arrFailedHosts, static function (array $arrHostSummary) use ($strHost): bool {
|
||||
return DomainModel::normalizeHost((string)($arrHostSummary['host'] ?? '')) === $strHost;
|
||||
}));
|
||||
}
|
||||
if ($strCategory !== '') {
|
||||
$arrFailedHosts = array_values(array_filter($arrFailedHosts, static function (array $arrHostSummary) use ($strCategory): bool {
|
||||
foreach ((array)($arrHostSummary['top_failure_categories'] ?? []) as $arrCategory) {
|
||||
if (!is_array($arrCategory)) {
|
||||
continue;
|
||||
}
|
||||
if ((string)($arrCategory['suite_key'] ?? '') === $strCategory) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
$arrSelectedHosts = array_slice($arrFailedHosts, 0, $intLimit);
|
||||
if (empty($arrSelectedHosts)) {
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'status' => 'skipped_no_failed_host',
|
||||
'host' => $strHost,
|
||||
'category' => $strCategory,
|
||||
'limit' => $intLimit,
|
||||
'failed_hosts_count' => count($arrFailedHosts),
|
||||
'processed_count' => 0,
|
||||
'passed_count' => 0,
|
||||
'failed_count' => 0,
|
||||
'hosts' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$arrRuns = [];
|
||||
foreach ($arrSelectedHosts as $arrHostSummary) {
|
||||
$strTargetHost = DomainModel::normalizeHost((string)($arrHostSummary['host'] ?? ''));
|
||||
if ($strTargetHost === '') {
|
||||
continue;
|
||||
}
|
||||
$arrRuns[] = self::runHostConfiguredSamples($strTargetHost, [
|
||||
'run_root' => $strRunRoot,
|
||||
'portal_root' => $strPortalRoot,
|
||||
'base_url' => 'https://' . $strTargetHost,
|
||||
]);
|
||||
}
|
||||
|
||||
$intPassedCount = count(array_filter($arrRuns, static fn(array $arrRun): bool => (string)($arrRun['status'] ?? '') === 'passed'));
|
||||
$intFailedCount = count(array_filter($arrRuns, static fn(array $arrRun): bool => in_array((string)($arrRun['status'] ?? ''), ['failed', 'partial'], true)));
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'status' => $intFailedCount === 0 ? 'passed' : ($intPassedCount > 0 ? 'partial' : 'failed'),
|
||||
'host' => $strHost,
|
||||
'category' => $strCategory,
|
||||
'limit' => $intLimit,
|
||||
'failed_hosts_count' => count($arrFailedHosts),
|
||||
'processed_count' => count($arrRuns),
|
||||
'passed_count' => $intPassedCount,
|
||||
'failed_count' => $intFailedCount,
|
||||
'hosts' => array_values(array_map(static function (array $arrRun): array {
|
||||
return [
|
||||
'host' => (string)($arrRun['host'] ?? ''),
|
||||
'status' => (string)($arrRun['status'] ?? ''),
|
||||
'configured_samples' => (int)($arrRun['configured_samples'] ?? 0),
|
||||
'processed_count' => (int)($arrRun['processed_count'] ?? 0),
|
||||
'passed_count' => (int)($arrRun['passed_count'] ?? 0),
|
||||
'failed_count' => (int)($arrRun['failed_count'] ?? 0),
|
||||
];
|
||||
}, $arrRuns)),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildSuiteSummary(array $arrJson, callable $callable): array
|
||||
{
|
||||
$arrChildChecks = is_array($arrJson['checks'] ?? null) ? (array)$arrJson['checks'] : [];
|
||||
$intCheckCount = count($arrChildChecks);
|
||||
$intPassedCount = count(array_filter($arrChildChecks, static fn(array $arrCheck): bool => !empty($arrCheck['passed'])));
|
||||
$intFailedCount = max(0, $intCheckCount - $intPassedCount);
|
||||
|
||||
return [
|
||||
'passed' => !empty($arrJson['all_passed']),
|
||||
'exit_code' => !empty($arrJson['all_passed']) ? 0 : 1,
|
||||
'check_count' => $intCheckCount,
|
||||
'passed_count' => $intPassedCount,
|
||||
'failed_count' => $intFailedCount,
|
||||
'summary' => $callable($arrJson),
|
||||
'stderr' => '',
|
||||
'json' => $arrJson,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function verifyForgeRouteGroup(DomainModel $DomainRow, array $arrVideo, string $strHost, int $intVideoId, int $intForgeId, string $strBaseUrl, string $strTargetRoot): array
|
||||
{
|
||||
$arrForgeSeoCfg = $DomainRow->getForgeSeoCfg();
|
||||
$strActualGroup = DomainModel::normalizeForgeRouteGroup((string)($arrForgeSeoCfg['route_group'] ?? DomainModel::FORGE_ROUTE_GROUP_DEFAULT));
|
||||
$strExpectedGroup = $strActualGroup;
|
||||
|
||||
$TpStyle = SiteStyle::getConfig($DomainRow, $strHost);
|
||||
$UrlBuilder = new UrlBuilder($TpStyle);
|
||||
$strSlug = trim((string)($arrVideo['v_name_en'] ?? ''));
|
||||
$strDetailPath = $UrlBuilder->detail($strSlug, $intVideoId);
|
||||
$strForgePath = $UrlBuilder->detailForge($strSlug, $intVideoId, $intForgeId);
|
||||
$arrDetailParsed = $UrlBuilder->parseDetailPath($strDetailPath);
|
||||
$arrForgeParsed = $UrlBuilder->parseDetailForgePath($strForgePath);
|
||||
$arrDetailResolved = $UrlBuilder->resolvePagePath($strDetailPath, ['detail_forge', 'detail', 'play']);
|
||||
$arrForgeResolved = $UrlBuilder->resolvePagePath($strForgePath, ['detail_forge', 'detail', 'play']);
|
||||
$strDetailRoundTrip = !empty($arrDetailParsed)
|
||||
? $UrlBuilder->detail((string)($arrDetailParsed['strPinyin'] ?? ''), (int)($arrDetailParsed['intVId'] ?? 0))
|
||||
: '';
|
||||
$strForgeRoundTrip = !empty($arrForgeParsed)
|
||||
? $UrlBuilder->detailForge(
|
||||
(string)($arrForgeParsed['strPinyin'] ?? ''),
|
||||
(int)($arrForgeParsed['intVId'] ?? 0),
|
||||
(int)($arrForgeParsed['intVForgeId'] ?? 0)
|
||||
)
|
||||
: '';
|
||||
$arrDetailForgeFamily = (array)($TpStyle['template_cfg']['url_family']['detail_forge'] ?? []);
|
||||
$strFamilyPattern = (string)($arrDetailForgeFamily['pattern'] ?? '');
|
||||
$strFamilyRoute = (string)($arrDetailForgeFamily['routes'][0] ?? '');
|
||||
$strFamilyPrefix = self::extractLeadingRouteToken($strFamilyPattern);
|
||||
$strExpectedPrefix = DomainModel::resolveForgeRouteGroupPrefix($strExpectedGroup);
|
||||
|
||||
$arrDetailResponse = self::fetchPage($strBaseUrl . $strDetailPath, $strHost);
|
||||
$arrForgeResponse = self::fetchPage($strBaseUrl . $strForgePath, $strHost);
|
||||
$strRawDetailBody = (string)($arrDetailResponse['body'] ?? '');
|
||||
$strRawForgeBody = (string)($arrForgeResponse['body'] ?? '');
|
||||
$strNormalizedForgeBody = self::normalizeForMatch((string)($arrForgeResponse['body'] ?? ''));
|
||||
|
||||
$arrForgeSeoCopy = SeoCopyStore::getPageDataFromRoot($strTargetRoot, $strHost, 'forge', $intVideoId . '-forge-' . $intForgeId);
|
||||
|
||||
$arrChecks = [];
|
||||
$arrChecks[] = ['label' => 'group_matches_expected', 'passed' => $strActualGroup === $strExpectedGroup, 'detail' => $strActualGroup . ' / expected=' . $strExpectedGroup];
|
||||
$arrChecks[] = ['label' => 'family_prefix_matches_group', 'passed' => $strExpectedPrefix === '' ? $strFamilyPrefix !== '' : $strFamilyPrefix === $strExpectedPrefix, 'detail' => $strFamilyPrefix . ' / expected_prefix=' . ($strExpectedPrefix !== '' ? $strExpectedPrefix : '[keep-family]')];
|
||||
$arrChecks[] = ['label' => 'detail_reverse_parse', 'passed' => !empty($arrDetailParsed) && (int)($arrDetailParsed['intVId'] ?? 0) === $intVideoId && (string)($arrDetailParsed['strPinyin'] ?? '') === $strSlug, 'detail' => json_encode($arrDetailParsed, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
$arrChecks[] = ['label' => 'forge_reverse_parse', 'passed' => !empty($arrForgeParsed) && (int)($arrForgeParsed['intVId'] ?? 0) === $intVideoId && (int)($arrForgeParsed['intVForgeId'] ?? 0) === $intForgeId && (string)($arrForgeParsed['strPinyin'] ?? '') === $strSlug, 'detail' => json_encode($arrForgeParsed, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
$arrChecks[] = ['label' => 'detail_resolve_page', 'passed' => (string)($arrDetailResolved['page_key'] ?? '') === 'detail', 'detail' => (string)($arrDetailResolved['page_key'] ?? '[miss]')];
|
||||
$arrChecks[] = ['label' => 'forge_resolve_page', 'passed' => (string)($arrForgeResolved['page_key'] ?? '') === 'detail_forge', 'detail' => (string)($arrForgeResolved['page_key'] ?? '[miss]')];
|
||||
$arrChecks[] = ['label' => 'detail_round_trip', 'passed' => $strDetailRoundTrip === $strDetailPath, 'detail' => $strDetailRoundTrip . ' / expected=' . $strDetailPath];
|
||||
$arrChecks[] = ['label' => 'forge_round_trip', 'passed' => $strForgeRoundTrip === $strForgePath, 'detail' => $strForgeRoundTrip . ' / expected=' . $strForgePath];
|
||||
$arrChecks[] = ['label' => 'detail_status_200', 'passed' => (int)($arrDetailResponse['status'] ?? 0) === 200, 'detail' => 'status=' . (int)($arrDetailResponse['status'] ?? 0)];
|
||||
$arrChecks[] = ['label' => 'forge_status_200', 'passed' => (int)($arrForgeResponse['status'] ?? 0) === 200, 'detail' => 'status=' . (int)($arrForgeResponse['status'] ?? 0)];
|
||||
$arrChecks[] = ['label' => 'detail_contains_forge_link', 'passed' => str_contains($strRawDetailBody, $strForgePath), 'detail' => $strForgePath];
|
||||
$arrChecks[] = ['label' => 'forge_contains_self_path', 'passed' => str_contains($strRawForgeBody, $strForgePath), 'detail' => $strForgePath];
|
||||
|
||||
if (!empty($arrForgeSeoCopy)) {
|
||||
foreach (['forge_detail_note', 'forge_body_lead'] as $strField) {
|
||||
$strNeedle = trim((string)($arrForgeSeoCopy[$strField] ?? ''));
|
||||
if ($strNeedle === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrChecks[] = [
|
||||
'label' => 'forge_copy_' . $strField,
|
||||
'passed' => str_contains($strNormalizedForgeBody, self::normalizeForMatch($strNeedle)),
|
||||
'detail' => mb_substr($strNeedle, 0, 80),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $strHost,
|
||||
'video_id' => $intVideoId,
|
||||
'forge_id' => $intForgeId,
|
||||
'slug' => $strSlug,
|
||||
'forge_group' => $strActualGroup,
|
||||
'expected_group' => $strExpectedGroup,
|
||||
'detail_path' => $strDetailPath,
|
||||
'forge_path' => $strForgePath,
|
||||
'family_pattern' => $strFamilyPattern,
|
||||
'family_route' => $strFamilyRoute,
|
||||
'detail_resolved_page' => (string)($arrDetailResolved['page_key'] ?? ''),
|
||||
'forge_resolved_page' => (string)($arrForgeResolved['page_key'] ?? ''),
|
||||
'detail_reverse_parse' => $arrDetailParsed,
|
||||
'forge_reverse_parse' => $arrForgeParsed,
|
||||
'detail_status' => (int)($arrDetailResponse['status'] ?? 0),
|
||||
'forge_status' => (int)($arrForgeResponse['status'] ?? 0),
|
||||
'checks' => $arrChecks,
|
||||
'all_passed' => !in_array(false, array_column($arrChecks, 'passed'), true),
|
||||
'verified_at' => date(DATE_ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function verifyForgeOutputRuntime(DomainModel $DomainRow, array $arrVideo, string $strHost, int $intVideoId, string $strBaseUrl, int $intExpandedSitemapCount, int $intExpandedEntryCount): array
|
||||
{
|
||||
$strSlug = trim((string)($arrVideo['v_name_en'] ?? ''));
|
||||
$Logic = app(VideoSiteMapLogic::class);
|
||||
$arrUrlFamily = $Logic->getGptArrUrlTmp($DomainRow);
|
||||
$TpStyle = SiteStyle::getConfig($DomainRow, $strHost);
|
||||
$UrlBuilder = new UrlBuilder($TpStyle);
|
||||
|
||||
$ReflectionMethod = new ReflectionMethod(VideoSiteMapLogic::class, 'buildVideoOutputRows');
|
||||
$ReflectionMethod->setAccessible(true);
|
||||
|
||||
$arrCurrentRows = $ReflectionMethod->invoke($Logic, $DomainRow, $arrUrlFamily, $arrVideo);
|
||||
$DomainProbe = $DomainRow->clone();
|
||||
$arrProbeSeoCfg = $DomainProbe->getSeoCfg();
|
||||
$arrProbeSeoCfg['forge']['enable'] = true;
|
||||
$arrProbeSeoCfg['forge']['entry_count'] = $intExpandedEntryCount;
|
||||
$arrProbeSeoCfg['forge']['sitemap_count'] = $intExpandedSitemapCount;
|
||||
$DomainProbe->setAttr('d_seo_cfg', $arrProbeSeoCfg);
|
||||
$arrExpandedRows = $ReflectionMethod->invoke($Logic, $DomainProbe, $arrUrlFamily, $arrVideo);
|
||||
|
||||
$strDetailPath = $UrlBuilder->detail($strSlug, $intVideoId);
|
||||
$arrDetailResponse = self::fetchPage($strBaseUrl . $strDetailPath, $strHost);
|
||||
preg_match_all('#href="' . preg_quote($strDetailPath, '#') . '/(\d+)"#u', (string)$arrDetailResponse['body'], $arrLinkMatches);
|
||||
$intEntryLinksDetected = count(array_unique($arrLinkMatches[1] ?? []));
|
||||
|
||||
$arrCurrentKinds = array_values(array_map(static fn(array $arrRow) => (string)($arrRow['kind'] ?? ''), $arrCurrentRows));
|
||||
$arrExpandedKinds = array_values(array_map(static fn(array $arrRow) => (string)($arrRow['kind'] ?? ''), $arrExpandedRows));
|
||||
|
||||
$arrChecks = [];
|
||||
$arrChecks[] = ['label' => 'detail_entry_count_matches_config', 'passed' => $intEntryLinksDetected === $DomainRow->getForgeEntryDisplayCount(), 'detail' => $intEntryLinksDetected . ' / expected=' . $DomainRow->getForgeEntryDisplayCount()];
|
||||
$arrChecks[] = ['label' => 'current_output_default_detail_only', 'passed' => $DomainRow->getForgeSitemapCount() === 0 ? count($arrCurrentRows) === 1 && (($arrCurrentRows[0]['kind'] ?? '') === 'detail') : count($arrCurrentRows) >= 1, 'detail' => json_encode($arrCurrentKinds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
$arrChecks[] = ['label' => 'expanded_output_contains_expected_rows', 'passed' => count($arrExpandedRows) >= (1 + $intExpandedSitemapCount), 'detail' => 'count=' . count($arrExpandedRows) . ' / expected_at_least=' . (1 + $intExpandedSitemapCount)];
|
||||
|
||||
foreach ($arrExpandedRows as $intIndex => $arrRow) {
|
||||
$strHref = (string)($arrRow['href'] ?? '');
|
||||
$strPath = parse_url($strHref, PHP_URL_PATH) ?: '';
|
||||
$arrResolved = $UrlBuilder->resolvePagePath($strPath, ['detail_forge', 'detail']);
|
||||
$strExpectedPage = (($arrRow['kind'] ?? 'detail') === 'forge') ? 'detail_forge' : 'detail';
|
||||
$arrChecks[] = [
|
||||
'label' => 'expanded_row_' . $intIndex . '_resolve_page',
|
||||
'passed' => (string)($arrResolved['page_key'] ?? '') === $strExpectedPage,
|
||||
'detail' => $strPath . ' => ' . (string)($arrResolved['page_key'] ?? '[miss]'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $strHost,
|
||||
'video_id' => $intVideoId,
|
||||
'slug' => $strSlug,
|
||||
'entry_count_current' => $DomainRow->getForgeEntryDisplayCount(),
|
||||
'entry_links_detected' => $intEntryLinksDetected,
|
||||
'sitemap_count_current' => $DomainRow->getForgeSitemapCount(),
|
||||
'current_output_kinds' => $arrCurrentKinds,
|
||||
'expanded_entry_count' => $intExpandedEntryCount,
|
||||
'expanded_sitemap_count' => $intExpandedSitemapCount,
|
||||
'expanded_output_kinds' => $arrExpandedKinds,
|
||||
'current_rows' => $arrCurrentRows,
|
||||
'expanded_rows' => $arrExpandedRows,
|
||||
'checks' => $arrChecks,
|
||||
'all_passed' => !in_array(false, array_column($arrChecks, 'passed'), true),
|
||||
'verified_at' => date(DATE_ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function verifyPlayRuntime(DomainModel $DomainRow, array $arrVideo, string $strHost, int $intVideoId, string $strBaseUrl): array
|
||||
{
|
||||
$strSlug = trim((string)($arrVideo['v_name_en'] ?? ''));
|
||||
$arrPlayGroups = is_array($arrVideo['v_play_url'] ?? null) ? (array)$arrVideo['v_play_url'] : [];
|
||||
if (empty($arrPlayGroups)) {
|
||||
throw new RuntimeException('Video play groups missing: ' . $intVideoId);
|
||||
}
|
||||
|
||||
$strPlayType = (string)array_key_first($arrPlayGroups);
|
||||
$arrCurrentPlayGroup = (array)($arrPlayGroups[$strPlayType] ?? []);
|
||||
$intPlayIndex = 1;
|
||||
$arrCurrentEpisode = (array)($arrCurrentPlayGroup[$intPlayIndex - 1] ?? []);
|
||||
$strEpisodeName = trim((string)($arrCurrentEpisode['name'] ?? ''));
|
||||
if ($strEpisodeName === '') {
|
||||
$strEpisodeName = '第' . $intPlayIndex . '集';
|
||||
}
|
||||
|
||||
$TpStyle = SiteStyle::getConfig($DomainRow, $strHost);
|
||||
$UrlBuilder = new UrlBuilder($TpStyle);
|
||||
|
||||
$strDetailPath = $UrlBuilder->detail($strSlug, $intVideoId);
|
||||
$strPlayPath = $UrlBuilder->play($strSlug, $intVideoId, $strPlayType, $intPlayIndex);
|
||||
$arrPlayParsed = $UrlBuilder->parsePlayPath($strPlayPath);
|
||||
$arrPlayResolved = $UrlBuilder->resolvePagePath($strPlayPath, ['play', 'detail_forge', 'detail']);
|
||||
$strPlayRoundTrip = !empty($arrPlayParsed)
|
||||
? $UrlBuilder->play(
|
||||
(string)($arrPlayParsed['strPinyin'] ?? ''),
|
||||
(int)($arrPlayParsed['intVId'] ?? 0),
|
||||
(string)($arrPlayParsed['strPlayType'] ?? ''),
|
||||
(int)($arrPlayParsed['intPlayIndex'] ?? 0)
|
||||
)
|
||||
: '';
|
||||
|
||||
$arrDetailResponse = self::fetchPage($strBaseUrl . $strDetailPath, $strHost);
|
||||
$arrPlayResponse = self::fetchPage($strBaseUrl . $strPlayPath, $strHost);
|
||||
$strRawDetailBody = (string)($arrDetailResponse['body'] ?? '');
|
||||
$strRawPlayBody = (string)($arrPlayResponse['body'] ?? '');
|
||||
$strNormalizedPlayBody = self::normalizeForMatch($strRawPlayBody);
|
||||
|
||||
$arrChecks = [];
|
||||
$arrChecks[] = ['label' => 'play_reverse_parse', 'passed' => !empty($arrPlayParsed) && (int)($arrPlayParsed['intVId'] ?? 0) === $intVideoId && (string)($arrPlayParsed['strPinyin'] ?? '') === $strSlug && (string)($arrPlayParsed['strPlayType'] ?? '') === $strPlayType && (int)($arrPlayParsed['intPlayIndex'] ?? 0) === $intPlayIndex, 'detail' => json_encode($arrPlayParsed, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
$arrChecks[] = ['label' => 'play_resolve_page', 'passed' => (string)($arrPlayResolved['page_key'] ?? '') === 'play', 'detail' => (string)($arrPlayResolved['page_key'] ?? '[miss]')];
|
||||
$arrChecks[] = ['label' => 'play_round_trip', 'passed' => $strPlayRoundTrip === $strPlayPath, 'detail' => $strPlayRoundTrip . ' / expected=' . $strPlayPath];
|
||||
$arrChecks[] = ['label' => 'detail_contains_play_link', 'passed' => str_contains($strRawDetailBody, $strPlayPath), 'detail' => $strPlayPath];
|
||||
$arrChecks[] = ['label' => 'detail_status_200', 'passed' => (int)($arrDetailResponse['status'] ?? 0) === 200, 'detail' => 'status=' . (int)($arrDetailResponse['status'] ?? 0)];
|
||||
$arrChecks[] = ['label' => 'play_status_200', 'passed' => (int)($arrPlayResponse['status'] ?? 0) === 200, 'detail' => 'status=' . (int)($arrPlayResponse['status'] ?? 0)];
|
||||
$arrChecks[] = ['label' => 'play_contains_self_path', 'passed' => str_contains($strRawPlayBody, $strPlayPath), 'detail' => $strPlayPath];
|
||||
$arrChecks[] = ['label' => 'play_contains_episode_name', 'passed' => str_contains($strNormalizedPlayBody, self::normalizeForMatch($strEpisodeName)), 'detail' => $strEpisodeName];
|
||||
|
||||
return [
|
||||
'host' => $strHost,
|
||||
'video_id' => $intVideoId,
|
||||
'slug' => $strSlug,
|
||||
'play_type' => $strPlayType,
|
||||
'play_index' => $intPlayIndex,
|
||||
'episode_name' => $strEpisodeName,
|
||||
'detail_path' => $strDetailPath,
|
||||
'play_path' => $strPlayPath,
|
||||
'play_resolved_page' => (string)($arrPlayResolved['page_key'] ?? ''),
|
||||
'play_reverse_parse' => $arrPlayParsed,
|
||||
'detail_status' => (int)($arrDetailResponse['status'] ?? 0),
|
||||
'play_status' => (int)($arrPlayResponse['status'] ?? 0),
|
||||
'checks' => $arrChecks,
|
||||
'all_passed' => !in_array(false, array_column($arrChecks, 'passed'), true),
|
||||
'verified_at' => date(DATE_ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function verifyForgeOutputFiles(DomainModel $DomainRow, array $arrVideo, string $strHost, int $intVideoId, int $intExpandedSitemapCount): array
|
||||
{
|
||||
$strSlug = trim((string)($arrVideo['v_name_en'] ?? ''));
|
||||
$TpStyle = SiteStyle::getConfig($DomainRow, $strHost);
|
||||
$UrlBuilder = new UrlBuilder($TpStyle);
|
||||
$arrUrlFamily = (array)($TpStyle['template_cfg']['url_family'] ?? []);
|
||||
$strDetailPath = $UrlBuilder->detail($strSlug, $intVideoId);
|
||||
$arrExpectedDefaultUrls = ['https://www.' . $strHost . $strDetailPath];
|
||||
$arrExpectedExpandedUrls = $arrExpectedDefaultUrls;
|
||||
for ($intCurrentForgeId = 1; $intCurrentForgeId <= $intExpandedSitemapCount; $intCurrentForgeId++) {
|
||||
$arrExpectedExpandedUrls[] = 'https://www.' . $strHost . $UrlBuilder->detailForge($strSlug, $intVideoId, $intCurrentForgeId);
|
||||
}
|
||||
|
||||
$strTmpRoot = rtrim(sys_get_temp_dir(), '/') . '/forge-output-file-verify-' . date('YmdHis') . '-' . substr(md5($strHost . '|' . $intVideoId), 0, 6);
|
||||
$strDefaultRoot = $strTmpRoot . '/default';
|
||||
$strExpandedRoot = $strTmpRoot . '/expanded';
|
||||
@mkdir($strDefaultRoot, 0755, true);
|
||||
@mkdir($strExpandedRoot, 0755, true);
|
||||
|
||||
$DefaultLogic = self::buildProbeLogic($DomainRow, $arrVideo, $strDefaultRoot, $arrUrlFamily);
|
||||
$ExpandedDomain = $DomainRow->clone();
|
||||
$arrExpandedSeoCfg = $ExpandedDomain->getSeoCfg();
|
||||
$arrExpandedSeoCfg['forge']['enable'] = true;
|
||||
$arrExpandedSeoCfg['forge']['sitemap_count'] = $intExpandedSitemapCount;
|
||||
$ExpandedDomain->setAttr('d_seo_cfg', $arrExpandedSeoCfg);
|
||||
$ExpandedLogic = self::buildProbeLogic($ExpandedDomain, $arrVideo, $strExpandedRoot, $arrUrlFamily);
|
||||
|
||||
$GenerateMapInfo = new ReflectionMethod(VideoSiteMapLogic::class, 'generateMapInfo');
|
||||
$GenerateMapInfo->setAccessible(true);
|
||||
$GenerateMapInfo->invoke($DefaultLogic, 1);
|
||||
$DefaultLogic->generateVideoJsonByPage(1);
|
||||
$GenerateMapInfo->invoke($ExpandedLogic, 1);
|
||||
$ExpandedLogic->generateVideoJsonByPage(1);
|
||||
|
||||
$arrDefaultFiles = self::collectFileSummary($strDefaultRoot, $strHost);
|
||||
$arrExpandedFiles = self::collectFileSummary($strExpandedRoot, $strHost);
|
||||
$arrDefaultJsonHrefs = (array)($arrDefaultFiles['json_hrefs'] ?? []);
|
||||
$arrExpandedJsonHrefs = (array)($arrExpandedFiles['json_hrefs'] ?? []);
|
||||
|
||||
$arrChecks = [];
|
||||
$arrChecks[] = ['label' => 'default_xml_only_detail', 'passed' => $arrDefaultFiles['xml_locs'] === $arrExpectedDefaultUrls, 'detail' => json_encode($arrDefaultFiles['xml_locs'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
$arrChecks[] = ['label' => 'default_txt_only_detail', 'passed' => $arrDefaultFiles['txt_locs'] === $arrExpectedDefaultUrls, 'detail' => json_encode($arrDefaultFiles['txt_locs'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
$arrChecks[] = ['label' => 'default_json_only_detail', 'passed' => $arrDefaultJsonHrefs === $arrExpectedDefaultUrls, 'detail' => json_encode($arrDefaultJsonHrefs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
$arrChecks[] = ['label' => 'expanded_xml_matches_expected', 'passed' => $arrExpandedFiles['xml_locs'] === $arrExpectedExpandedUrls, 'detail' => json_encode($arrExpandedFiles['xml_locs'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
$arrChecks[] = ['label' => 'expanded_txt_matches_expected', 'passed' => $arrExpandedFiles['txt_locs'] === $arrExpectedExpandedUrls, 'detail' => json_encode($arrExpandedFiles['txt_locs'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
$arrChecks[] = ['label' => 'expanded_json_matches_expected', 'passed' => $arrExpandedJsonHrefs === $arrExpectedExpandedUrls, 'detail' => json_encode($arrExpandedJsonHrefs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
|
||||
|
||||
return [
|
||||
'host' => $strHost,
|
||||
'video_id' => $intVideoId,
|
||||
'slug' => $strSlug,
|
||||
'default_root' => $strDefaultRoot,
|
||||
'expanded_root' => $strExpandedRoot,
|
||||
'default_json_count' => count($arrDefaultJsonHrefs),
|
||||
'expanded_json_count' => count($arrExpandedJsonHrefs),
|
||||
'default_xml_entries' => count($arrDefaultFiles['xml_locs']),
|
||||
'expanded_xml_entries' => count($arrExpandedFiles['xml_locs']),
|
||||
'checks' => $arrChecks,
|
||||
'all_passed' => !in_array(false, array_column($arrChecks, 'passed'), true),
|
||||
'verified_at' => date(DATE_ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function ensureAppInitialized(): void
|
||||
{
|
||||
static $boolInitialized = false;
|
||||
if ($boolInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
(new App())->initialize();
|
||||
$boolInitialized = true;
|
||||
}
|
||||
|
||||
protected static function fetchPage(string $strUrl, string $strHost): array
|
||||
{
|
||||
$Context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => implode("\r\n", [
|
||||
'Host: ' . $strHost,
|
||||
'Connection: close',
|
||||
]),
|
||||
'ignore_errors' => true,
|
||||
'timeout' => 20,
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$strBody = @file_get_contents($strUrl, false, $Context);
|
||||
$arrHeaders = $http_response_header ?? [];
|
||||
$intStatus = 0;
|
||||
foreach ($arrHeaders as $strHeaderLine) {
|
||||
if (preg_match('/^HTTP\/\S+\s+(\d{3})/i', $strHeaderLine, $arrMatches)) {
|
||||
$intStatus = (int)($arrMatches[1] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $intStatus,
|
||||
'body' => $strBody === false ? '' : $strBody,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function normalizeForMatch(string $strValue): string
|
||||
{
|
||||
$strValue = html_entity_decode($strValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$strValue = strip_tags($strValue);
|
||||
$strValue = str_replace(["\r", "\n", "\t", '“', '”', '‘', '’', ' '], [' ', ' ', ' ', '"', '"', "'", "'", ' '], $strValue);
|
||||
$strValue = preg_replace('/\s+/u', ' ', $strValue);
|
||||
|
||||
return trim((string)$strValue);
|
||||
}
|
||||
|
||||
protected static function extractLeadingRouteToken(string $strPattern): string
|
||||
{
|
||||
$strPattern = ltrim(trim($strPattern), '/');
|
||||
if ($strPattern === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!preg_match('/^([^\/\{-]+)/', $strPattern, $arrMatches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string)($arrMatches[1] ?? ''));
|
||||
}
|
||||
|
||||
protected static function buildProbeLogic(DomainModel $DomainModel, array $arrVideo, string $strSiteMapPath, array $arrUrlFamily): VideoSiteMapLogic
|
||||
{
|
||||
return new class($DomainModel, $arrVideo, $strSiteMapPath, $arrUrlFamily) extends VideoSiteMapLogic {
|
||||
protected DomainModel $ProbeDomainModel;
|
||||
protected array $ProbeVideo;
|
||||
protected array $ProbeUrlFamily;
|
||||
|
||||
public function __construct(DomainModel $DomainModel, array $arrVideo, string $strSiteMapPath, array $arrUrlFamily)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->ProbeDomainModel = $DomainModel;
|
||||
$this->ProbeVideo = $arrVideo;
|
||||
$this->ProbeUrlFamily = $arrUrlFamily;
|
||||
$this->arrDomainModel = [$DomainModel];
|
||||
$this->strSiteMapPath = rtrim($strSiteMapPath, '/') . '/';
|
||||
$this->intPageSize = 1;
|
||||
$this->intBatchSize = 1;
|
||||
}
|
||||
|
||||
public function fetchVideosByBatch(int $intStart, int $intCount): \Generator
|
||||
{
|
||||
yield $this->ProbeVideo;
|
||||
}
|
||||
|
||||
public function getDomain(): array
|
||||
{
|
||||
return [$this->ProbeDomainModel];
|
||||
}
|
||||
|
||||
public function getGptArrUrlTmp($DomainModel)
|
||||
{
|
||||
return $this->ProbeUrlFamily;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected static function collectFileSummary(string $strRootDir, string $strHost): array
|
||||
{
|
||||
$strDomainDir = rtrim($strRootDir, '/') . '/' . $strHost;
|
||||
$strXmlFile = $strDomainDir . '/sitemap-videos-1.xml';
|
||||
$strTxtFile = $strDomainDir . '/sitemap-videos-1.txt';
|
||||
$strJsonFile = $strDomainDir . '/video-list-1.json';
|
||||
|
||||
$strXml = is_file($strXmlFile) ? (string)file_get_contents($strXmlFile) : '';
|
||||
$strTxt = is_file($strTxtFile) ? (string)file_get_contents($strTxtFile) : '';
|
||||
$strJson = is_file($strJsonFile) ? (string)file_get_contents($strJsonFile) : '';
|
||||
|
||||
preg_match_all('#<loc>([^<]+)</loc>#u', $strXml, $arrXmlMatches);
|
||||
$arrXmlLocs = array_values(array_filter(array_map('trim', $arrXmlMatches[1] ?? [])));
|
||||
$arrTxtLocs = array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $strTxt) ?: [])));
|
||||
preg_match_all('#"href"\s*:\s*"([^"]+)"#u', $strJson, $arrJsonMatches);
|
||||
$arrJsonHrefs = array_values(array_filter(array_map(static function ($strHref) {
|
||||
return stripcslashes(trim((string)$strHref));
|
||||
}, $arrJsonMatches[1] ?? [])));
|
||||
|
||||
return [
|
||||
'xml_file' => $strXmlFile,
|
||||
'txt_file' => $strTxtFile,
|
||||
'json_file' => $strJsonFile,
|
||||
'xml_locs' => $arrXmlLocs,
|
||||
'txt_locs' => $arrTxtLocs,
|
||||
'json_hrefs' => $arrJsonHrefs,
|
||||
];
|
||||
}
|
||||
}
|
||||
543
code/app/common/helper/SeoCopyRuntimeAcceptanceIndexHelper.php
Normal file
543
code/app/common/helper/SeoCopyRuntimeAcceptanceIndexHelper.php
Normal file
@@ -0,0 +1,543 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\DomainModel;
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
use SplFileInfo;
|
||||
|
||||
class SeoCopyRuntimeAcceptanceIndexHelper
|
||||
{
|
||||
public static function buildSummary(string $strRunsRoot, bool $boolAllowMissing = false): array
|
||||
{
|
||||
if (!is_dir($strRunsRoot)) {
|
||||
if (!$boolAllowMissing) {
|
||||
throw new RuntimeException('Runtime acceptance runs root not found: ' . $strRunsRoot);
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'runs_root' => $strRunsRoot,
|
||||
'runs_count' => 0,
|
||||
'hosts_count' => 0,
|
||||
'status_totals' => [],
|
||||
'host_totals' => [],
|
||||
'latest_hosts' => [],
|
||||
'failed_hosts_count' => 0,
|
||||
'failed_hosts' => [],
|
||||
'suite_failure_totals' => [],
|
||||
'failure_category_totals' => [],
|
||||
'rerun_failed_hosts_command' => 'php scripts/seo_copy_runtime_acceptance_failed_hosts_run.php --format=text',
|
||||
'latest_run' => [],
|
||||
'runs' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$arrRuns = [];
|
||||
$arrStatusTotals = [];
|
||||
$arrHostTotals = [];
|
||||
$arrLatestByHost = [];
|
||||
$arrSuiteFailureTotals = [];
|
||||
$arrFailureCategoryTotals = [];
|
||||
|
||||
$Iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($strRunsRoot, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($Iterator as $FileInfo) {
|
||||
/** @var SplFileInfo $FileInfo */
|
||||
if (!$FileInfo->isFile() || strtolower($FileInfo->getExtension()) !== 'json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strPath = str_replace('\\', '/', $FileInfo->getPathname());
|
||||
$arrData = json_decode((string)file_get_contents($strPath), true);
|
||||
if (!is_array($arrData) || !isset($arrData['suites'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strRunName = basename($strPath, '.json');
|
||||
$strStatus = !empty($arrData['all_passed']) ? 'passed' : 'failed';
|
||||
$strUpdatedAt = trim((string)($arrData['stored_at'] ?? $arrData['verified_at'] ?? ''));
|
||||
if ($strUpdatedAt === '') {
|
||||
$strUpdatedAt = date('c', (int)$FileInfo->getMTime());
|
||||
}
|
||||
$arrSuites = is_array($arrData['suites'] ?? null) ? (array)$arrData['suites'] : [];
|
||||
$intSuiteCount = count($arrSuites);
|
||||
$intPassedSuites = count(array_filter($arrSuites, static fn(array $arrSuite): bool => !empty($arrSuite['passed'])));
|
||||
|
||||
$arrStatusTotals[$strStatus] = ($arrStatusTotals[$strStatus] ?? 0) + 1;
|
||||
$strHost = trim((string)($arrData['host'] ?? ''));
|
||||
if ($strHost !== '') {
|
||||
if (!isset($arrHostTotals[$strHost])) {
|
||||
$arrHostTotals[$strHost] = [
|
||||
'host' => $strHost,
|
||||
'runs' => 0,
|
||||
'passed' => 0,
|
||||
'failed' => 0,
|
||||
'failed_suites' => 0,
|
||||
'suite_failure_totals' => [],
|
||||
];
|
||||
}
|
||||
$arrHostTotals[$strHost]['runs']++;
|
||||
$arrHostTotals[$strHost][$strStatus] = (int)($arrHostTotals[$strHost][$strStatus] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$arrRun = [
|
||||
'run_name' => $strRunName,
|
||||
'detail_slug' => self::buildDetailSlug($strRunName),
|
||||
'host' => $strHost,
|
||||
'video_id' => (int)($arrData['video_id'] ?? 0),
|
||||
'forge_id' => (int)($arrData['forge_id'] ?? 0),
|
||||
'status' => $strStatus,
|
||||
'suite_count' => $intSuiteCount,
|
||||
'passed_suites' => $intPassedSuites,
|
||||
'failed_suites' => max(0, $intSuiteCount - $intPassedSuites),
|
||||
'suite_keys' => array_values(array_keys($arrSuites)),
|
||||
'summary' => self::buildRunSummary($arrSuites),
|
||||
'updated_at' => $strUpdatedAt,
|
||||
'json_path' => $strPath,
|
||||
'suite_statuses' => self::buildSuiteStatuses($arrSuites),
|
||||
];
|
||||
$arrRuns[] = $arrRun;
|
||||
|
||||
foreach ((array)($arrRun['suite_statuses'] ?? []) as $arrSuiteStatus) {
|
||||
if (!is_array($arrSuiteStatus) || !empty($arrSuiteStatus['passed'])) {
|
||||
continue;
|
||||
}
|
||||
$strSuiteKey = trim((string)($arrSuiteStatus['suite_key'] ?? ''));
|
||||
if ($strSuiteKey === '') {
|
||||
continue;
|
||||
}
|
||||
$arrSuiteFailureTotals[$strSuiteKey] = ($arrSuiteFailureTotals[$strSuiteKey] ?? 0) + 1;
|
||||
if ($strHost !== '') {
|
||||
$arrHostTotals[$strHost]['failed_suites'] = (int)($arrHostTotals[$strHost]['failed_suites'] ?? 0) + 1;
|
||||
$arrHostTotals[$strHost]['suite_failure_totals'][$strSuiteKey] = (int)(($arrHostTotals[$strHost]['suite_failure_totals'][$strSuiteKey] ?? 0)) + 1;
|
||||
}
|
||||
$strFailureCategory = self::mapSuiteKeyToFailureCategory($strSuiteKey);
|
||||
$arrFailureCategoryTotals[$strFailureCategory] = ($arrFailureCategoryTotals[$strFailureCategory] ?? 0) + 1;
|
||||
if ($strHost !== '') {
|
||||
$arrHostTotals[$strHost]['failure_category_totals'][$strFailureCategory] = (int)(($arrHostTotals[$strHost]['failure_category_totals'][$strFailureCategory] ?? 0)) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($strHost !== '' && (!isset($arrLatestByHost[$strHost]) || strcmp($strUpdatedAt, (string)($arrLatestByHost[$strHost]['updated_at'] ?? '')) >= 0)) {
|
||||
$arrLatestByHost[$strHost] = $arrRun;
|
||||
}
|
||||
}
|
||||
|
||||
usort($arrRuns, static function (array $arrA, array $arrB): int {
|
||||
return strcmp((string)($arrB['updated_at'] ?? ''), (string)($arrA['updated_at'] ?? ''));
|
||||
});
|
||||
ksort($arrStatusTotals, SORT_STRING);
|
||||
ksort($arrHostTotals, SORT_STRING);
|
||||
arsort($arrSuiteFailureTotals, SORT_NUMERIC);
|
||||
foreach ($arrHostTotals as $strHost => &$arrHostSummary) {
|
||||
$arrHostSummary['latest_run'] = (array)($arrLatestByHost[$strHost] ?? []);
|
||||
arsort($arrHostSummary['suite_failure_totals'], SORT_NUMERIC);
|
||||
$arrHostSummary['detail_slug'] = self::buildHostDetailSlug($strHost);
|
||||
$arrHostSummary['top_failed_suites'] = self::normalizeSuiteTotals($arrHostSummary['suite_failure_totals']);
|
||||
$arrHostSummary['top_failure_categories'] = self::normalizeSuiteTotals((array)($arrHostSummary['failure_category_totals'] ?? []));
|
||||
$arrHostSummary['configured_samples'] = self::getConfiguredSamplesForHost($strHost);
|
||||
$arrHostSummary['configured_samples_count'] = count((array)($arrHostSummary['configured_samples'] ?? []));
|
||||
$arrHostSummary['rerun_command'] = self::buildHostRerunCommand($strHost);
|
||||
$arrHostSummary['likely_issue'] = self::buildLikelyIssueSummary((array)($arrHostSummary['top_failure_categories'] ?? []));
|
||||
$arrHostSummary['top_failure_category'] = (string)(((array)($arrHostSummary['top_failure_categories'][0] ?? []))['suite_key'] ?? '');
|
||||
$arrHostSummary['remediation_action'] = self::buildRemediationAction((string)($arrHostSummary['top_failure_category'] ?? ''), $strHost);
|
||||
}
|
||||
unset($arrHostSummary);
|
||||
|
||||
$arrFailedHosts = array_values(array_filter($arrHostTotals, static function (array $arrHostSummary): bool {
|
||||
return (int)($arrHostSummary['failed'] ?? 0) > 0;
|
||||
}));
|
||||
usort($arrFailedHosts, static function (array $arrA, array $arrB): int {
|
||||
$intFailedCompare = (int)($arrB['failed'] ?? 0) <=> (int)($arrA['failed'] ?? 0);
|
||||
if ($intFailedCompare !== 0) {
|
||||
return $intFailedCompare;
|
||||
}
|
||||
return strcmp((string)($arrA['host'] ?? ''), (string)($arrB['host'] ?? ''));
|
||||
});
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'runs_root' => realpath($strRunsRoot) ?: $strRunsRoot,
|
||||
'runs_count' => count($arrRuns),
|
||||
'hosts_count' => count($arrHostTotals),
|
||||
'status_totals' => $arrStatusTotals,
|
||||
'host_totals' => array_values($arrHostTotals),
|
||||
'latest_hosts' => array_values($arrLatestByHost),
|
||||
'failed_hosts_count' => count($arrFailedHosts),
|
||||
'failed_hosts' => array_slice($arrFailedHosts, 0, 20),
|
||||
'suite_failure_totals' => self::normalizeSuiteTotals($arrSuiteFailureTotals),
|
||||
'failure_category_totals' => self::normalizeSuiteTotals($arrFailureCategoryTotals),
|
||||
'rerun_failed_hosts_command' => 'php scripts/seo_copy_runtime_acceptance_failed_hosts_run.php --format=text',
|
||||
'latest_run' => $arrRuns[0] ?? [],
|
||||
'runs' => $arrRuns,
|
||||
];
|
||||
}
|
||||
|
||||
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): void
|
||||
{
|
||||
self::writeFile(rtrim($strOutputRoot, '/') . '/index.json', json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
self::writeFile(rtrim($strOutputRoot, '/') . '/index.html', self::renderHtml($arrSummary));
|
||||
|
||||
foreach ((array)($arrSummary['runs'] ?? []) as $arrRun) {
|
||||
if (!is_array($arrRun)) {
|
||||
continue;
|
||||
}
|
||||
$strDetailSlug = trim((string)($arrRun['detail_slug'] ?? ''));
|
||||
if ($strDetailSlug === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::writeFile(rtrim($strOutputRoot, '/') . '/details/' . $strDetailSlug . '.json', json_encode($arrRun, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
self::writeFile(rtrim($strOutputRoot, '/') . '/details/' . $strDetailSlug . '.html', self::renderDetailHtml($arrRun));
|
||||
}
|
||||
|
||||
foreach ((array)($arrSummary['host_totals'] ?? []) as $arrHostSummary) {
|
||||
if (!is_array($arrHostSummary)) {
|
||||
continue;
|
||||
}
|
||||
$strDetailSlug = trim((string)($arrHostSummary['detail_slug'] ?? ''));
|
||||
if ($strDetailSlug === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::writeFile(rtrim($strOutputRoot, '/') . '/hosts/' . $strDetailSlug . '.json', json_encode($arrHostSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
self::writeFile(rtrim($strOutputRoot, '/') . '/hosts/' . $strDetailSlug . '.html', self::renderHostDetailHtml($arrHostSummary, (array)($arrSummary['runs'] ?? [])));
|
||||
}
|
||||
}
|
||||
|
||||
protected static function buildRunSummary(array $arrSuites): string
|
||||
{
|
||||
$arrParts = [];
|
||||
foreach ($arrSuites as $strSuiteKey => $arrSuite) {
|
||||
if (!is_array($arrSuite)) {
|
||||
continue;
|
||||
}
|
||||
$arrParts[] = $strSuiteKey . ':' . (!empty($arrSuite['passed']) ? 'ok' : 'fail');
|
||||
}
|
||||
|
||||
return implode(', ', $arrParts);
|
||||
}
|
||||
|
||||
protected static function renderHtml(array $arrSummary): string
|
||||
{
|
||||
$arrRows = [];
|
||||
foreach ((array)($arrSummary['runs'] ?? []) as $arrRun) {
|
||||
if (!is_array($arrRun)) {
|
||||
continue;
|
||||
}
|
||||
$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>' . (int)($arrRun['video_id'] ?? 0) . '</td>'
|
||||
. '<td>' . (int)($arrRun['forge_id'] ?? 0) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['status'] ?? '')) . '</td>'
|
||||
. '<td>' . (int)($arrRun['passed_suites'] ?? 0) . '/' . (int)($arrRun['suite_count'] ?? 0) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['summary'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['updated_at'] ?? '')) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'SEO Copy Runtime Acceptance',
|
||||
'<p class="lead">运行时验收总览页,统一展示 forge/play/output/file-output 总验收结果。</p>'
|
||||
. '<p><a href="../index.html">返回门户首页</a></p>'
|
||||
. '<div class="meta-grid">'
|
||||
. '<div><strong>generated_at</strong><span>' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>runs</strong><span>' . (int)($arrSummary['runs_count'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>hosts</strong><span>' . (int)($arrSummary['hosts_count'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>failed_hosts</strong><span>' . (int)($arrSummary['failed_hosts_count'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>statuses</strong><span>' . self::escape(implode(', ', array_keys((array)($arrSummary['status_totals'] ?? [])))) . '</span></div>'
|
||||
. '</div>'
|
||||
. '<p><strong>Failed Hosts Rerun:</strong> <code>' . self::escape((string)($arrSummary['rerun_failed_hosts_command'] ?? '')) . '</code></p>'
|
||||
. self::renderHostSummaryTable((array)($arrSummary['host_totals'] ?? []))
|
||||
. self::renderSuiteFailureTable((array)($arrSummary['failure_category_totals'] ?? []), 'Failure Categories', 'category')
|
||||
. self::renderSuiteFailureTable((array)($arrSummary['suite_failure_totals'] ?? []))
|
||||
. '<table><thead><tr><th>run_name</th><th>host</th><th>video_id</th><th>forge_id</th><th>status</th><th>suites</th><th>summary</th><th>updated_at</th></tr></thead><tbody>'
|
||||
. implode('', $arrRows)
|
||||
. '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
protected static function renderHostSummaryTable(array $arrHostTotals): string
|
||||
{
|
||||
if (empty($arrHostTotals)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$arrRows = [];
|
||||
foreach ($arrHostTotals as $arrHostSummary) {
|
||||
if (!is_array($arrHostSummary)) {
|
||||
continue;
|
||||
}
|
||||
$arrLatestRun = (array)($arrHostSummary['latest_run'] ?? []);
|
||||
$arrRows[] = '<tr>'
|
||||
. '<td><a href="./hosts/' . rawurlencode((string)($arrHostSummary['detail_slug'] ?? '')) . '.html">' . self::escape((string)($arrHostSummary['host'] ?? '')) . '</a></td>'
|
||||
. '<td>' . (int)($arrHostSummary['runs'] ?? 0) . '</td>'
|
||||
. '<td>' . (int)($arrHostSummary['passed'] ?? 0) . '</td>'
|
||||
. '<td>' . (int)($arrHostSummary['failed'] ?? 0) . '</td>'
|
||||
. '<td>' . (int)($arrHostSummary['failed_suites'] ?? 0) . '</td>'
|
||||
. '<td>' . self::escape(implode(', ', array_map(static function (array $arrSuite): string {
|
||||
return (string)($arrSuite['suite_key'] ?? '') . ':' . (int)($arrSuite['count'] ?? 0);
|
||||
}, array_slice((array)($arrHostSummary['top_failed_suites'] ?? []), 0, 3)))) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrHostSummary['top_failure_category'] ?? '')) . '</td>'
|
||||
. '<td><code>' . self::escape((string)($arrHostSummary['remediation_action'] ?? '')) . '</code></td>'
|
||||
. '<td>' . self::escape((string)($arrLatestRun['run_name'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrLatestRun['updated_at'] ?? '')) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return '<h2>Host Coverage</h2><table><thead><tr><th>host</th><th>runs</th><th>passed</th><th>failed</th><th>failed_suites</th><th>top_failed_suites</th><th>top_failure_category</th><th>remediation_action</th><th>latest_run</th><th>latest_at</th></tr></thead><tbody>'
|
||||
. implode('', $arrRows)
|
||||
. '</tbody></table>';
|
||||
}
|
||||
|
||||
protected static function renderSuiteFailureTable(array $arrSuiteTotals, string $strTitle = 'Failed Suite Hotspots', string $strLabel = 'suite_key'): string
|
||||
{
|
||||
if (empty($arrSuiteTotals)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$arrRows = [];
|
||||
foreach ($arrSuiteTotals as $arrSuiteTotal) {
|
||||
if (!is_array($arrSuiteTotal)) {
|
||||
continue;
|
||||
}
|
||||
$arrRows[] = '<tr>'
|
||||
. '<td>' . self::escape((string)($arrSuiteTotal['suite_key'] ?? '')) . '</td>'
|
||||
. '<td>' . (int)($arrSuiteTotal['count'] ?? 0) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return '<h2>' . self::escape($strTitle) . '</h2><table><thead><tr><th>' . self::escape($strLabel) . '</th><th>fail_count</th></tr></thead><tbody>'
|
||||
. implode('', $arrRows)
|
||||
. '</tbody></table>';
|
||||
}
|
||||
|
||||
protected static function renderDetailHtml(array $arrRun): string
|
||||
{
|
||||
$arrCards = [
|
||||
'<div><strong>run_name</strong><span>' . self::escape((string)($arrRun['run_name'] ?? '')) . '</span></div>',
|
||||
'<div><strong>host</strong><span>' . self::escape((string)($arrRun['host'] ?? '')) . '</span></div>',
|
||||
'<div><strong>video_id</strong><span>' . (int)($arrRun['video_id'] ?? 0) . '</span></div>',
|
||||
'<div><strong>forge_id</strong><span>' . (int)($arrRun['forge_id'] ?? 0) . '</span></div>',
|
||||
'<div><strong>status</strong><span>' . self::escape((string)($arrRun['status'] ?? '')) . '</span></div>',
|
||||
'<div><strong>suites</strong><span>' . (int)($arrRun['passed_suites'] ?? 0) . '/' . (int)($arrRun['suite_count'] ?? 0) . ' passed</span></div>',
|
||||
'<div><strong>updated_at</strong><span>' . self::escape((string)($arrRun['updated_at'] ?? '')) . '</span></div>',
|
||||
];
|
||||
|
||||
return self::wrapHtml(
|
||||
'SEO Copy Runtime Acceptance Detail',
|
||||
'<p class="lead">运行时验收单次结果页,展示单个 host/video 样本的总验收摘要。</p>'
|
||||
. '<p><a href="../../index.html">返回门户首页</a> | <a href="../index.html">返回 runtime acceptance 总览</a></p>'
|
||||
. '<div class="meta-grid">' . implode('', $arrCards) . '</div>'
|
||||
. '<table><thead><tr><th>metric</th><th>value</th></tr></thead><tbody>'
|
||||
. '<tr><td>suite_keys</td><td>' . self::escape(implode(', ', (array)($arrRun['suite_keys'] ?? []))) . '</td></tr>'
|
||||
. '<tr><td>suite_statuses</td><td>' . self::escape(implode(', ', array_map(static function (array $arrSuiteStatus): string {
|
||||
return (string)($arrSuiteStatus['suite_key'] ?? '') . ':' . (!empty($arrSuiteStatus['passed']) ? 'ok' : 'fail');
|
||||
}, (array)($arrRun['suite_statuses'] ?? [])))) . '</td></tr>'
|
||||
. '<tr><td>summary</td><td>' . self::escape((string)($arrRun['summary'] ?? '')) . '</td></tr>'
|
||||
. '<tr><td>json_path</td><td>' . self::escape((string)($arrRun['json_path'] ?? '')) . '</td></tr>'
|
||||
. '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
protected static function renderHostDetailHtml(array $arrHostSummary, array $arrRuns): string
|
||||
{
|
||||
$strHost = trim((string)($arrHostSummary['host'] ?? ''));
|
||||
$arrFilteredRuns = array_values(array_filter($arrRuns, static function (array $arrRun) use ($strHost): bool {
|
||||
return trim((string)($arrRun['host'] ?? '')) === $strHost;
|
||||
}));
|
||||
$arrConfiguredSamples = (array)($arrHostSummary['configured_samples'] ?? self::getConfiguredSamplesForHost($strHost));
|
||||
$strPortalRoot = dirname(__DIR__, 3) . '/public/_seo_copy_release';
|
||||
$strRunRoot = dirname(__DIR__, 3) . '/storage/seo_copy_runtime_acceptance_runs';
|
||||
$strRerunCommand = 'cd ' . dirname(__DIR__, 3) . ' && ' . self::buildHostRerunCommand($strHost);
|
||||
$arrTopFailureCategories = (array)($arrHostSummary['top_failure_categories'] ?? self::normalizeSuiteTotals((array)($arrHostSummary['failure_category_totals'] ?? [])));
|
||||
$strLikelyIssue = (string)($arrHostSummary['likely_issue'] ?? self::buildLikelyIssueSummary($arrTopFailureCategories));
|
||||
|
||||
$arrRows = [];
|
||||
foreach ($arrFilteredRuns as $arrRun) {
|
||||
$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['status'] ?? '')) . '</td>'
|
||||
. '<td>' . (int)($arrRun['video_id'] ?? 0) . '</td>'
|
||||
. '<td>' . (int)($arrRun['forge_id'] ?? 0) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['summary'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['updated_at'] ?? '')) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'SEO Copy Runtime Acceptance Host Detail',
|
||||
'<p class="lead">运行时验收 host 详情页,聚焦单个域名的通过/失败历史与失败热点。</p>'
|
||||
. '<p><a href="../../index.html">返回门户首页</a> | <a href="../index.html">返回 runtime acceptance 总览</a></p>'
|
||||
. '<div class="meta-grid">'
|
||||
. '<div><strong>host</strong><span>' . self::escape($strHost) . '</span></div>'
|
||||
. '<div><strong>runs</strong><span>' . (int)($arrHostSummary['runs'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>passed</strong><span>' . (int)($arrHostSummary['passed'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>failed</strong><span>' . (int)($arrHostSummary['failed'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>failed_suites</strong><span>' . (int)($arrHostSummary['failed_suites'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>configured_samples</strong><span>' . count($arrConfiguredSamples) . '</span></div>'
|
||||
. '</div>'
|
||||
. '<table><thead><tr><th>metric</th><th>value</th></tr></thead><tbody>'
|
||||
. '<tr><td>likely_issue</td><td>' . self::escape($strLikelyIssue) . '</td></tr>'
|
||||
. '<tr><td>rerun_command</td><td><code>' . self::escape($strRerunCommand) . '</code></td></tr>'
|
||||
. '<tr><td>portal_root</td><td><code>' . self::escape($strPortalRoot) . '</code></td></tr>'
|
||||
. '<tr><td>run_root</td><td><code>' . self::escape($strRunRoot) . '</code></td></tr>'
|
||||
. '<tr><td>configured_samples</td><td>' . self::escape(implode(', ', array_map(static function (array $arrSample): string {
|
||||
return (string)($arrSample['video_id'] ?? 0) . ':' . (string)($arrSample['forge_id'] ?? 1);
|
||||
}, $arrConfiguredSamples))) . '</td></tr>'
|
||||
. '<tr><td>top_failure_category</td><td>' . self::escape((string)($arrHostSummary['top_failure_category'] ?? '')) . '</td></tr>'
|
||||
. '<tr><td>remediation_action</td><td><code>' . self::escape((string)($arrHostSummary['remediation_action'] ?? '')) . '</code></td></tr>'
|
||||
. '</tbody></table>'
|
||||
. self::renderSuiteFailureTable($arrTopFailureCategories, 'Failure Categories', 'category')
|
||||
. self::renderSuiteFailureTable((array)($arrHostSummary['top_failed_suites'] ?? []))
|
||||
. '<table><thead><tr><th>run_name</th><th>status</th><th>video_id</th><th>forge_id</th><th>summary</th><th>updated_at</th></tr></thead><tbody>'
|
||||
. implode('', $arrRows)
|
||||
. '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
protected static function buildSuiteStatuses(array $arrSuites): array
|
||||
{
|
||||
$arrStatuses = [];
|
||||
foreach ($arrSuites as $strSuiteKey => $arrSuite) {
|
||||
if (!is_array($arrSuite)) {
|
||||
continue;
|
||||
}
|
||||
$arrStatuses[] = [
|
||||
'suite_key' => (string)$strSuiteKey,
|
||||
'passed' => !empty($arrSuite['passed']),
|
||||
];
|
||||
}
|
||||
return $arrStatuses;
|
||||
}
|
||||
|
||||
protected static function normalizeSuiteTotals(array $arrSuiteTotals): array
|
||||
{
|
||||
$arrResult = [];
|
||||
foreach ($arrSuiteTotals as $strSuiteKey => $intCount) {
|
||||
$arrResult[] = [
|
||||
'suite_key' => (string)$strSuiteKey,
|
||||
'count' => (int)$intCount,
|
||||
];
|
||||
}
|
||||
return $arrResult;
|
||||
}
|
||||
|
||||
protected static function mapSuiteKeyToFailureCategory(string $strSuiteKey): string
|
||||
{
|
||||
return match ($strSuiteKey) {
|
||||
'forge_route_group', 'forge_output_runtime' => 'route_runtime',
|
||||
'play_runtime' => 'play_runtime',
|
||||
'forge_output_files' => 'file_output',
|
||||
default => 'other',
|
||||
};
|
||||
}
|
||||
|
||||
protected static function buildLikelyIssueSummary(array $arrFailureCategories): string
|
||||
{
|
||||
if (empty($arrFailureCategories)) {
|
||||
return '当前没有失败分类';
|
||||
}
|
||||
|
||||
$arrLabels = array_map(static function (array $arrCategory): string {
|
||||
$strKey = (string)($arrCategory['suite_key'] ?? '');
|
||||
$strLabel = match ($strKey) {
|
||||
'route_runtime' => '路由/运行时路径',
|
||||
'play_runtime' => '播放页路径',
|
||||
'file_output' => '文件输出链',
|
||||
default => '其他',
|
||||
};
|
||||
|
||||
return $strLabel . ':' . (int)($arrCategory['count'] ?? 0);
|
||||
}, array_slice($arrFailureCategories, 0, 3));
|
||||
|
||||
return implode(' / ', $arrLabels);
|
||||
}
|
||||
|
||||
protected static function getConfiguredSamplesForHost(string $strHost): array
|
||||
{
|
||||
if ($strHost === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var DomainModel|null $DomainRow */
|
||||
$DomainRow = app(DomainModel::class)->where('d_domain', $strHost)->find();
|
||||
if (!$DomainRow instanceof DomainModel) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $DomainRow->getRuntimeAcceptanceSamples();
|
||||
}
|
||||
|
||||
protected static function buildHostRerunCommand(string $strHost): string
|
||||
{
|
||||
return 'php scripts/seo_copy_runtime_acceptance_forge_run.php ' . $strHost . ' --format=text';
|
||||
}
|
||||
|
||||
protected static function buildRemediationAction(string $strCategory, string $strHost): string
|
||||
{
|
||||
return match ($strCategory) {
|
||||
'route_runtime' => 'php scripts/seo_copy_runtime_acceptance_failed_hosts_run.php --category=route_runtime --host=' . $strHost . ' --format=text',
|
||||
'play_runtime' => 'php scripts/seo_copy_runtime_acceptance_failed_hosts_run.php --category=play_runtime --host=' . $strHost . ' --format=text',
|
||||
'file_output' => 'php scripts/seo_copy_runtime_acceptance_failed_hosts_run.php --category=file_output --host=' . $strHost . ' --format=text',
|
||||
default => 'php scripts/seo_copy_runtime_acceptance_host_run.php ' . $strHost . ' --format=text',
|
||||
};
|
||||
}
|
||||
|
||||
protected static function buildHostDetailSlug(string $strHost): string
|
||||
{
|
||||
$strHost = preg_replace('/[^a-zA-Z0-9\\-_]+/', '-', strtolower(trim($strHost)));
|
||||
$strHost = trim((string)$strHost, '-_');
|
||||
return $strHost !== '' ? $strHost : sha1((string)microtime(true));
|
||||
}
|
||||
|
||||
protected static function writeFile(string $strPath, string $strContent): 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, $strContent);
|
||||
}
|
||||
|
||||
protected static function buildDetailSlug(string $strRunName): string
|
||||
{
|
||||
$strRunName = preg_replace('/[^a-zA-Z0-9\\-_]+/', '-', strtolower(trim($strRunName)));
|
||||
$strRunName = trim((string)$strRunName, '-_');
|
||||
return $strRunName !== '' ? $strRunName : sha1((string)microtime(true));
|
||||
}
|
||||
|
||||
protected static function escape(string $strValue): string
|
||||
{
|
||||
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
protected static function wrapHtml(string $strTitle, string $strBody): string
|
||||
{
|
||||
$strEscapedTitle = self::escape($strTitle);
|
||||
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
. '<meta name="robots" content="noindex,nofollow,noarchive">'
|
||||
. '<title>' . $strEscapedTitle . '</title>'
|
||||
. '<style>'
|
||||
. 'body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:24px;background:#f5f7fb;color:#1f2937;}'
|
||||
. 'h1{margin:0 0 16px;}'
|
||||
. 'p{line-height:1.7;}'
|
||||
. '.lead{margin:0 0 16px;color:#475569;}'
|
||||
. 'table{width:100%;border-collapse:collapse;background:#fff;margin:16px 0 24px;border-radius:12px;overflow:hidden;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. 'th,td{padding:12px 14px;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top;font-size:14px;}'
|
||||
. 'th{background:#eef2ff;color:#334155;font-weight:700;}'
|
||||
. '.meta-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px;}'
|
||||
. '.meta-grid div{background:#fff;padding:14px 16px;border-radius:12px;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. '.meta-grid strong{display:block;font-size:12px;color:#64748b;margin:0 0 6px;text-transform:uppercase;letter-spacing:.04em;}'
|
||||
. '.meta-grid span{font-size:14px;color:#111827;}'
|
||||
. '</style></head><body><h1>' . $strEscapedTitle . '</h1>' . $strBody . '</body></html>';
|
||||
}
|
||||
}
|
||||
323
code/app/common/helper/SeoCopySchema.php
Normal file
323
code/app/common/helper/SeoCopySchema.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class SeoCopySchema
|
||||
{
|
||||
public static function buildScenePageKey(string $strScene, array|string $mValue): string
|
||||
{
|
||||
$arrValues = is_array($mValue) ? array_values($mValue) : [$mValue];
|
||||
$arrValues = array_values(array_filter(array_map(static fn($mVal) => trim((string)$mVal), $arrValues), static fn($strVal) => $strVal !== ''));
|
||||
|
||||
return match ($strScene) {
|
||||
'home', 'rank_index' => SeoCopyStore::buildPageKey('index', 'index'),
|
||||
'category_index' => SeoCopyStore::buildPageKey([$arrValues[0] ?? ''], 'category-index'),
|
||||
'category_list' => SeoCopyStore::buildPageKey([$arrValues[0] ?? '', $arrValues[1] ?? ''], 'category-list'),
|
||||
'search' => SeoCopyStore::buildPageKey([$arrValues[0] ?? ''], 'search'),
|
||||
'rank_list' => self::buildRankListPageKey($arrValues),
|
||||
'detail' => (string)($arrValues[0] ?? '0'),
|
||||
'forge' => (string)($arrValues[0] ?? '0') . '-forge-' . (string)($arrValues[1] ?? '1'),
|
||||
'play' => (string)($arrValues[0] ?? '0') . '-play-' . (string)($arrValues[1] ?? '') . '-' . (string)($arrValues[2] ?? '1'),
|
||||
default => SeoCopyStore::buildPageKey($arrValues, 'index'),
|
||||
};
|
||||
}
|
||||
|
||||
public static function getSceneRules(string $strScene): array
|
||||
{
|
||||
return match ($strScene) {
|
||||
'home' => [
|
||||
'goal' => '强化首页入口解释、浏览路径提示和分类筛选引导。',
|
||||
'tone' => '克制、清晰、站内导航型,不夸张,不堆词。',
|
||||
'field_rules' => [
|
||||
'intro_text' => '1 句,55-90 字,说明首页整理了什么、适合怎么用。',
|
||||
'intro_meta' => '1 句,35-70 字,给出继续筛选建议。',
|
||||
'guide_cards' => '3 张卡片,每张 title 2-8 字,text 35-75 字。',
|
||||
],
|
||||
'required_facts' => ['site_name', 'content_scopes', 'navigation_paths'],
|
||||
'forbidden' => [
|
||||
'不要编造具体片名、演员、年份。',
|
||||
'不要写“全网最全”“免费在线观看”等夸张承诺。',
|
||||
'不要出现搜索引擎、采集、站群等内部字样。',
|
||||
],
|
||||
],
|
||||
'category_index' => [
|
||||
'goal' => '解释该大类频道的题材入口价值和进入下一步的方式。',
|
||||
'tone' => '题材导航型,强调缩小范围。',
|
||||
'field_rules' => [
|
||||
'intro_text' => '1 句,55-90 字,说明当前大类和细分题材入口。',
|
||||
'intro_meta' => '1 句,35-70 字,给出进入题材页或搜索的建议。',
|
||||
'guide_cards' => '3 张卡片,围绕当前频道、题材分区、进入下一步。',
|
||||
],
|
||||
'required_facts' => ['parent_category_name', 'sub_category_examples', 'search_path'],
|
||||
'forbidden' => [
|
||||
'不要虚构不存在的细分类数量。',
|
||||
'不要直接复制 SEO title 作为正文。',
|
||||
],
|
||||
],
|
||||
'category_list' => [
|
||||
'goal' => '解释当前子分类列表的范围、翻页价值和点击后的浏览路径。',
|
||||
'tone' => '筛选型,强调同题材连续浏览。',
|
||||
'field_rules' => [
|
||||
'intro_text' => '1 句,60-95 字,交代当前题材与列表用途。',
|
||||
'intro_meta' => '1 句,35-70 字,给出翻页或回上级分类建议。',
|
||||
'guide_cards' => '3 张卡片,围绕当前范围、翻页建议、点击后路径。',
|
||||
],
|
||||
'required_facts' => ['parent_category_name', 'category_name', 'page_stats'],
|
||||
'forbidden' => [
|
||||
'不要虚构本页条目总数。',
|
||||
'不要写成剧情简介页面。',
|
||||
],
|
||||
],
|
||||
'search' => [
|
||||
'goal' => '说明当前关键词结果如何使用,以及如何继续缩小范围。',
|
||||
'tone' => '检索辅助型,强调关键词收敛。',
|
||||
'field_rules' => [
|
||||
'search_landing' => '1 句,50-85 字,说明当前搜索结果页的使用价值。',
|
||||
'intro_text' => '1 句,50-85 字,说明当前关键词结果的用途。',
|
||||
'intro_meta' => '1 句,30-65 字,给出补充关键词建议。',
|
||||
'guide_cards' => '3 张卡片,围绕当前关键词、缩小范围、进入详情前。',
|
||||
],
|
||||
'required_facts' => ['keyword', 'result_stats'],
|
||||
'forbidden' => [
|
||||
'不要评价搜索结果“最准确”“全网最全”。',
|
||||
'不要虚构命中条目名称。',
|
||||
],
|
||||
],
|
||||
'rank_index' => [
|
||||
'goal' => '解释榜单首页的不同周期价值和进入具体榜单的路径。',
|
||||
'tone' => '热度观察型,强调周期差异。',
|
||||
'field_rules' => [
|
||||
'intro_text' => '1 句,55-90 字,说明榜单首页汇总了哪些周期。',
|
||||
'intro_meta' => '1 句,35-70 字,说明先看哪个周期更合适。',
|
||||
'guide_cards' => '3 张卡片,围绕榜单首页、周期区别、后续路径。',
|
||||
],
|
||||
'required_facts' => ['rank_periods', 'rank_path'],
|
||||
'forbidden' => [
|
||||
'不要杜撰具体榜单名次变化。',
|
||||
'不要写成内容推荐文案。',
|
||||
],
|
||||
],
|
||||
'rank_list' => [
|
||||
'goal' => '解释当前榜单周期的意义,以及看榜后如何进入详情页。',
|
||||
'tone' => '热度对比型,强调周期和点击路径。',
|
||||
'field_rules' => [
|
||||
'intro_text' => '1 句,55-90 字,说明当前周期和用途。',
|
||||
'intro_meta' => '1 句,35-70 字,说明如何与其他周期对照。',
|
||||
'guide_cards' => '3 张卡片,围绕当前周期、看榜建议、进入详情前。',
|
||||
],
|
||||
'required_facts' => ['rank_period_name', 'rank_scope_name', 'page_stats'],
|
||||
'forbidden' => [
|
||||
'不要虚构榜单条目热度数据。',
|
||||
'不要把榜单页写成详情页。',
|
||||
],
|
||||
],
|
||||
'detail' => [
|
||||
'goal' => '补足主详情页的主线剧情、浏览价值、常见问题和进入播放页的提示。',
|
||||
'tone' => '信息整理型,强调剧情、人物、观看入口。',
|
||||
'field_rules' => [
|
||||
'detail_body' => '1 句,55-95 字,说明本页重点整理什么(可与主正文联动)。',
|
||||
'detail_body_lead' => '1 句,55-95 字,说明本页重点整理什么。',
|
||||
'detail_play_link_lead' => '1 句短引导,10-28 字,衔接播放入口。',
|
||||
'detail_body_tail' => '1 句,25-55 字,提示继续浏览路径。',
|
||||
'detail_faq' => '1 段,45-110 字,回答本页常见问题或继续浏览建议。',
|
||||
'main_description' => '1 段,70-130 字,用于正文主描述。',
|
||||
],
|
||||
'required_facts' => ['video_name', 'category_name', 'core_plot_or_positioning'],
|
||||
'forbidden' => [
|
||||
'不要编造演员关系和结局。',
|
||||
'不要使用“全集免费在线观看”之类高风险承诺。',
|
||||
],
|
||||
],
|
||||
'forge' => [
|
||||
'goal' => '说明延伸词页与主片的映射关系,并引导回主详情或进入播放页。',
|
||||
'tone' => '延伸词解释型,强调词条归属。',
|
||||
'field_rules' => [
|
||||
'forge_detail_note' => '1 句,35-75 字,说明当前延伸词切口。',
|
||||
'forge_body_lead' => '1 句,55-95 字,说明本页如何辅助确认主片归属。',
|
||||
'forge_return_lead' => '1 句短引导,10-28 字,衔接回主详情。',
|
||||
'forge_play_lead' => '1 句短引导,10-28 字,衔接播放入口。',
|
||||
'main_description' => '1 段,75-135 字,强调延伸词与主内容映射。',
|
||||
],
|
||||
'required_facts' => ['video_name', 'forge_keyword', 'mapping_relation'],
|
||||
'forbidden' => [
|
||||
'不要把 forge 页写成独立新片。',
|
||||
'不要虚构不存在的年份含义。',
|
||||
],
|
||||
],
|
||||
'play' => [
|
||||
'goal' => '说明当前播放页提供的入口、线路信息和回详情路径。',
|
||||
'tone' => '观看辅助型,强调当前集数、线路和回流路径。',
|
||||
'field_rules' => [
|
||||
'play_intro' => '1 句,55-95 字,说明本页提供什么观看入口。',
|
||||
'play_meta_note' => '1 句,35-75 字,说明线路或补看建议。',
|
||||
'play_body_lead' => '1 句,55-95 字,说明播放器下正文用途。',
|
||||
'play_body_next' => '1 句,25-55 字,提示回详情页或回题材页。',
|
||||
],
|
||||
'required_facts' => ['video_name', 'play_type', 'episode_label_or_index'],
|
||||
'forbidden' => [
|
||||
'不要承诺播放一定可用。',
|
||||
'不要编造线路来源和画质。',
|
||||
],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public static function getSceneFactHints(string $strScene): array
|
||||
{
|
||||
return match ($strScene) {
|
||||
'home' => [
|
||||
'site_name' => '站点名称,如“叙学影视网”。',
|
||||
'content_scopes' => ['电影', '电视剧', '综艺', '动漫', '短剧'],
|
||||
'navigation_paths' => ['首页 -> 分类频道 -> 详情页', '首页 -> 搜索页 -> 详情页 -> 播放页'],
|
||||
],
|
||||
'category_index' => [
|
||||
'parent_category_name' => '大类名称,如“电影”。',
|
||||
'sub_category_examples' => ['动作片', '喜剧片', '爱情片'],
|
||||
'search_path' => '如果题材仍然不够精确,可继续走站内搜索。',
|
||||
],
|
||||
'category_list' => [
|
||||
'parent_category_name' => '大类名称,如“电影”。',
|
||||
'category_name' => '子类名称,如“少时电影”。',
|
||||
'page_stats' => [
|
||||
'page' => 1,
|
||||
'pages' => 12,
|
||||
'total' => 348,
|
||||
'visible' => 30,
|
||||
],
|
||||
],
|
||||
'search' => [
|
||||
'keyword' => '当前搜索词。',
|
||||
'result_stats' => [
|
||||
'total' => 315,
|
||||
'page' => 1,
|
||||
'pages' => 10,
|
||||
'visible' => 32,
|
||||
],
|
||||
],
|
||||
'rank_index' => [
|
||||
'rank_periods' => ['日榜', '周榜', '月榜', '总榜'],
|
||||
'rank_path' => '榜单首页 -> 榜单列表 -> 详情页 -> 播放页',
|
||||
],
|
||||
'rank_list' => [
|
||||
'rank_period_name' => '如“日榜”。',
|
||||
'rank_scope_name' => '如“热门视频”或“电影”。',
|
||||
'page_stats' => [
|
||||
'page' => 1,
|
||||
'visible' => 30,
|
||||
],
|
||||
],
|
||||
'detail' => [
|
||||
'video_name' => '作品名称。',
|
||||
'category_name' => '题材或分类名称。',
|
||||
'core_plot_or_positioning' => '主线剧情、人物关系或内容定位的简要事实。',
|
||||
],
|
||||
'forge' => [
|
||||
'video_name' => '主片名称。',
|
||||
'forge_keyword' => '当前延伸词,如“相逢不识旧时人2026”。',
|
||||
'mapping_relation' => '说明该延伸词和主片的关系。',
|
||||
],
|
||||
'play' => [
|
||||
'video_name' => '作品名称。',
|
||||
'play_type' => '当前线路标识,如“douban”。',
|
||||
'episode_label_or_index' => '当前集数,如“第01集”或 1。',
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public static function getSceneTemplate(string $strScene): array
|
||||
{
|
||||
return match ($strScene) {
|
||||
'home',
|
||||
'search',
|
||||
'rank_index',
|
||||
'rank_list' => [
|
||||
'search_landing' => '',
|
||||
'intro_text' => '',
|
||||
'intro_meta' => '',
|
||||
'guide_cards' => self::buildGuideCardTemplate(),
|
||||
],
|
||||
'category_index',
|
||||
'category_list' => [
|
||||
'intro_text' => '',
|
||||
'category_intro' => '',
|
||||
'intro_meta' => '',
|
||||
'guide_cards' => self::buildGuideCardTemplate(),
|
||||
],
|
||||
'detail' => [
|
||||
'detail_body' => '',
|
||||
'detail_body_lead' => '',
|
||||
'detail_play_link_lead' => '',
|
||||
'detail_body_tail' => '',
|
||||
'detail_faq' => '',
|
||||
'main_description' => '',
|
||||
],
|
||||
'forge' => [
|
||||
'forge_detail_note' => '',
|
||||
'forge_body_lead' => '',
|
||||
'forge_return_lead' => '',
|
||||
'forge_play_lead' => '',
|
||||
'main_description' => '',
|
||||
],
|
||||
'play' => [
|
||||
'play_intro' => '',
|
||||
'play_meta_note' => '',
|
||||
'play_body_lead' => '',
|
||||
'play_body_next' => '',
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public static function getSupportedScenes(): array
|
||||
{
|
||||
return [
|
||||
'home',
|
||||
'category_index',
|
||||
'category_list',
|
||||
'search',
|
||||
'rank_index',
|
||||
'rank_list',
|
||||
'detail',
|
||||
'forge',
|
||||
'play',
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildGenerationEnvelope(string $strScene): array
|
||||
{
|
||||
return [
|
||||
'scene' => $strScene,
|
||||
'rules' => self::getSceneRules($strScene),
|
||||
'fact_hints' => self::getSceneFactHints($strScene),
|
||||
'template' => self::getSceneTemplate($strScene),
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildGuideCardTemplate(): array
|
||||
{
|
||||
return [
|
||||
['title' => '', 'text' => ''],
|
||||
['title' => '', 'text' => ''],
|
||||
['title' => '', 'text' => ''],
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildRankListPageKey(array $arrValues): string
|
||||
{
|
||||
if (count($arrValues) <= 1) {
|
||||
return SeoCopyStore::buildPageKey([$arrValues[0] ?? 'daily'], 'rank-list');
|
||||
}
|
||||
|
||||
if (count($arrValues) === 2) {
|
||||
return SeoCopyStore::buildPageKey([$arrValues[0] ?? 'daily'], 'rank-list');
|
||||
}
|
||||
|
||||
return SeoCopyStore::buildPageKey([
|
||||
$arrValues[0] ?? '',
|
||||
$arrValues[1] ?? '',
|
||||
$arrValues[2] ?? '',
|
||||
], 'rank-list');
|
||||
}
|
||||
}
|
||||
247
code/app/common/helper/SeoCopyStore.php
Normal file
247
code/app/common/helper/SeoCopyStore.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class SeoCopyStore
|
||||
{
|
||||
public static function interpolateTemplate(string $strTemplate, array $arrTokens = []): string
|
||||
{
|
||||
$strTemplate = (string)$strTemplate;
|
||||
if ($strTemplate === '' || empty($arrTokens)) {
|
||||
return trim($strTemplate);
|
||||
}
|
||||
|
||||
$arrReplace = [];
|
||||
foreach ($arrTokens as $strKey => $mValue) {
|
||||
$strKey = trim((string)$strKey);
|
||||
if ($strKey === '') {
|
||||
continue;
|
||||
}
|
||||
$arrReplace['{' . $strKey . '}'] = trim((string)$mValue);
|
||||
}
|
||||
|
||||
if (empty($arrReplace)) {
|
||||
return trim($strTemplate);
|
||||
}
|
||||
|
||||
return trim(strtr($strTemplate, $arrReplace));
|
||||
}
|
||||
|
||||
public static function buildPageKey(array|string $mValue, string $strFallback = 'index'): string
|
||||
{
|
||||
$arrValues = is_array($mValue) ? $mValue : [$mValue];
|
||||
$arrParts = [];
|
||||
|
||||
foreach ($arrValues as $strValue) {
|
||||
$strValue = trim((string)$strValue);
|
||||
if ($strValue === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strNormalized = self::normalize($strValue);
|
||||
if (self::shouldFallbackToHash($strValue, $strNormalized)) {
|
||||
$strNormalized = 'h-' . substr(md5($strValue), 0, 16);
|
||||
}
|
||||
|
||||
$arrParts[] = $strNormalized;
|
||||
}
|
||||
|
||||
return !empty($arrParts) ? implode('--', $arrParts) : self::normalize($strFallback);
|
||||
}
|
||||
|
||||
public static function getTextByPage(string $host, string $scene, string $pageKey, string $field, string $fallback = ''): string
|
||||
{
|
||||
return self::getTextByPageFromRoot(self::defaultRoot(), $host, $scene, $pageKey, $field, $fallback);
|
||||
}
|
||||
|
||||
public static function getTextByPageFromRoot(string $strRoot, string $host, string $scene, string $pageKey, string $field, string $fallback = ''): string
|
||||
{
|
||||
$arrData = self::getPageDataFromRoot($strRoot, $host, $scene, $pageKey);
|
||||
$strVal = trim((string)($arrData[$field] ?? ''));
|
||||
|
||||
return $strVal !== '' ? $strVal : $fallback;
|
||||
}
|
||||
|
||||
public static function getCardsByPage(string $host, string $scene, string $pageKey, string $field, array $arrFallback = []): array
|
||||
{
|
||||
return self::getCardsByPageFromRoot(self::defaultRoot(), $host, $scene, $pageKey, $field, $arrFallback);
|
||||
}
|
||||
|
||||
public static function getCardsByPageFromRoot(string $strRoot, string $host, string $scene, string $pageKey, string $field, array $arrFallback = []): array
|
||||
{
|
||||
$arrData = self::getPageDataFromRoot($strRoot, $host, $scene, $pageKey);
|
||||
$arrCards = $arrData[$field] ?? null;
|
||||
|
||||
if (!is_array($arrCards)) {
|
||||
return $arrFallback;
|
||||
}
|
||||
|
||||
$arrOut = [];
|
||||
|
||||
foreach ($arrCards as $arrCard) {
|
||||
if (!is_array($arrCard)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strTitle = trim((string)($arrCard['title'] ?? ''));
|
||||
$strText = trim((string)($arrCard['text'] ?? ''));
|
||||
$strHref = trim((string)($arrCard['href'] ?? ''));
|
||||
|
||||
if ($strTitle === '' && $strText === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrItem = [
|
||||
'title' => $strTitle,
|
||||
'text' => $strText,
|
||||
];
|
||||
|
||||
if ($strHref !== '') {
|
||||
$arrItem['href'] = $strHref;
|
||||
}
|
||||
|
||||
$arrOut[] = $arrItem;
|
||||
}
|
||||
|
||||
return !empty($arrOut) ? $arrOut : $arrFallback;
|
||||
}
|
||||
|
||||
public static function getPageData(string $host, string $scene, string $pageKey): array
|
||||
{
|
||||
return self::getPageDataFromRoot(self::defaultRoot(), $host, $scene, $pageKey);
|
||||
}
|
||||
|
||||
public static function getPreferredPageData(string $host, string $scene, array|string $pageKeys, array $roots = []): array
|
||||
{
|
||||
$arrPageKeys = is_array($pageKeys) ? $pageKeys : [$pageKeys];
|
||||
$arrPageKeys = array_values(array_filter(array_map(static function ($strPageKey): string {
|
||||
return trim((string)$strPageKey);
|
||||
}, $arrPageKeys), static function (string $strPageKey): bool {
|
||||
return $strPageKey !== '';
|
||||
}));
|
||||
if (empty($arrPageKeys)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrRoots = !empty($roots) ? $roots : self::preferredRoots();
|
||||
foreach ($arrRoots as $strRoot) {
|
||||
$strRoot = rtrim(trim((string)$strRoot), '/');
|
||||
if ($strRoot === '' || !is_dir($strRoot)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($arrPageKeys as $strPageKey) {
|
||||
$arrData = self::getPageDataFromRoot($strRoot, $host, $scene, $strPageKey);
|
||||
if (!empty($arrData)) {
|
||||
return $arrData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPageDataFromRoot(string $strRoot, string $host, string $scene, string $pageKey): array
|
||||
{
|
||||
$strPath = self::resolvePagePathFromRoot($strRoot, $host, $scene, $pageKey);
|
||||
if ($strPath === '' || !is_file($strPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$strJson = @file_get_contents($strPath);
|
||||
if ($strJson === false || trim($strJson) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrData = json_decode($strJson, true);
|
||||
|
||||
return is_array($arrData) ? $arrData : [];
|
||||
}
|
||||
|
||||
public static function writePageDataToRoot(string $strRoot, string $host, string $scene, string $pageKey, array $arrData): string
|
||||
{
|
||||
$strPath = self::resolvePagePathFromRoot($strRoot, $host, $scene, $pageKey);
|
||||
if ($strPath === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$strDir = dirname($strPath);
|
||||
if (!is_dir($strDir)) {
|
||||
@mkdir($strDir, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($strPath, json_encode($arrData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
return $strPath;
|
||||
}
|
||||
|
||||
public static function resolvePagePathFromRoot(string $strRoot, string $host, string $scene, string $pageKey): string
|
||||
{
|
||||
$strHostDir = self::normalize($host);
|
||||
$strSceneDir = self::normalize($scene);
|
||||
$strPageFile = self::normalize($pageKey);
|
||||
$strRoot = rtrim(trim($strRoot), '/');
|
||||
|
||||
if ($strRoot === '' || $strHostDir === '' || $strSceneDir === '' || $strPageFile === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $strRoot . '/' . $strHostDir . '/' . $strSceneDir . '/' . $strPageFile . '.json';
|
||||
}
|
||||
|
||||
private static function normalize(string $strValue): string
|
||||
{
|
||||
$strValue = strtolower(trim($strValue));
|
||||
$strValue = preg_replace('/[^a-z0-9\\-_]+/', '-', $strValue);
|
||||
$strValue = trim((string)$strValue, '-_');
|
||||
|
||||
return (string)$strValue;
|
||||
}
|
||||
|
||||
private static function shouldFallbackToHash(string $strOriginal, string $strNormalized): bool
|
||||
{
|
||||
if ($strNormalized === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d+$/', $strNormalized) === 1 && preg_match('/[^\x00-\x7F]/', $strOriginal) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function basePath(): string
|
||||
{
|
||||
return dirname(__DIR__, 3);
|
||||
}
|
||||
|
||||
private static function defaultRoot(): string
|
||||
{
|
||||
foreach (['SEO_COPY_ROOT_OVERRIDE', 'SEO_COPY_ROOT'] as $strEnvKey) {
|
||||
$strOverride = trim((string)($_SERVER[$strEnvKey] ?? $_ENV[$strEnvKey] ?? getenv($strEnvKey) ?: ''));
|
||||
if ($strOverride !== '' && is_dir($strOverride)) {
|
||||
return rtrim($strOverride, '/');
|
||||
}
|
||||
}
|
||||
|
||||
return self::basePath() . '/data/seo_copy';
|
||||
}
|
||||
|
||||
public static function publishedRoot(): string
|
||||
{
|
||||
$strOverride = trim((string)($_SERVER['SEO_COPY_PUBLISHED_ROOT'] ?? $_ENV['SEO_COPY_PUBLISHED_ROOT'] ?? getenv('SEO_COPY_PUBLISHED_ROOT') ?: ''));
|
||||
if ($strOverride !== '') {
|
||||
return rtrim($strOverride, '/');
|
||||
}
|
||||
|
||||
return self::basePath() . '/data/seo_copy_published';
|
||||
}
|
||||
|
||||
public static function preferredRoots(): array
|
||||
{
|
||||
return [
|
||||
self::publishedRoot(),
|
||||
self::defaultRoot(),
|
||||
];
|
||||
}
|
||||
}
|
||||
461
code/app/common/helper/SeoCopyTaskRunPortalHelper.php
Normal file
461
code/app/common/helper/SeoCopyTaskRunPortalHelper.php
Normal file
@@ -0,0 +1,461 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
use SplFileInfo;
|
||||
|
||||
class SeoCopyTaskRunPortalHelper
|
||||
{
|
||||
public static function buildSummary(string $strTaskRunsRoot, bool $boolAllowMissing = false, string $strStateRoot = ''): array
|
||||
{
|
||||
if (!is_dir($strTaskRunsRoot)) {
|
||||
if (!$boolAllowMissing) {
|
||||
throw new RuntimeException('Task run summary root not found: ' . $strTaskRunsRoot);
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'task_runs_root' => $strTaskRunsRoot,
|
||||
'task_state_root' => $strStateRoot,
|
||||
'runs_count' => 0,
|
||||
'tasks_count' => 0,
|
||||
'latest_tasks' => [],
|
||||
'runs' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$arrRuns = [];
|
||||
$arrLatestByTask = [];
|
||||
|
||||
$Iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($strTaskRunsRoot, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($Iterator as $FileInfo) {
|
||||
/** @var SplFileInfo $FileInfo */
|
||||
if (!$FileInfo->isFile() || strtolower($FileInfo->getExtension()) !== 'json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strPath = str_replace('\\', '/', $FileInfo->getPathname());
|
||||
$arrParsed = self::parseRunPath($strPath, $strTaskRunsRoot);
|
||||
if (empty($arrParsed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrData = json_decode((string)file_get_contents($strPath), true);
|
||||
if (!is_array($arrData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strTaskSlug = (string)$arrParsed['task_slug'];
|
||||
$strGeneratedAt = trim((string)($arrData['generated_at'] ?? ''));
|
||||
$strUpdatedAt = $strGeneratedAt !== '' ? $strGeneratedAt : date('c', (int)$FileInfo->getMTime());
|
||||
$strStatus = trim((string)($arrData['status'] ?? 'success'));
|
||||
|
||||
$arrRun = [
|
||||
'run_name' => (string)$arrParsed['run_name'],
|
||||
'detail_slug' => self::buildDetailSlug((string)$arrParsed['run_name']),
|
||||
'task_slug' => $strTaskSlug,
|
||||
'status' => $strStatus,
|
||||
'summary' => self::buildRunSummary($strTaskSlug, $arrData),
|
||||
'updated_at' => $strUpdatedAt,
|
||||
];
|
||||
|
||||
$arrRuns[] = $arrRun;
|
||||
|
||||
if (!isset($arrLatestByTask[$strTaskSlug]) || strcmp($strUpdatedAt, (string)$arrLatestByTask[$strTaskSlug]['updated_at']) >= 0) {
|
||||
$arrLatestByTask[$strTaskSlug] = $arrRun;
|
||||
}
|
||||
}
|
||||
|
||||
usort($arrRuns, static function (array $arrA, array $arrB): int {
|
||||
return strcmp((string)($arrB['updated_at'] ?? ''), (string)($arrA['updated_at'] ?? ''));
|
||||
});
|
||||
ksort($arrLatestByTask, SORT_STRING);
|
||||
|
||||
$arrStates = SeoCopyTaskStateHelper::readStates($strStateRoot);
|
||||
foreach ($arrLatestByTask as $strTaskSlug => &$arrRun) {
|
||||
$arrState = (array)($arrStates[$strTaskSlug] ?? []);
|
||||
$arrRun['state'] = $arrState;
|
||||
$arrRun['age_seconds'] = self::buildAgeSeconds((string)($arrRun['updated_at'] ?? ''));
|
||||
$arrRun['health_status'] = self::buildHealthStatus($arrRun, $arrState);
|
||||
}
|
||||
unset($arrRun);
|
||||
|
||||
foreach ($arrRuns as &$arrRun) {
|
||||
$arrState = (array)($arrStates[(string)($arrRun['task_slug'] ?? '')] ?? []);
|
||||
$arrRun['age_seconds'] = self::buildAgeSeconds((string)($arrRun['updated_at'] ?? ''));
|
||||
$arrRun['health_status'] = self::buildHealthStatus($arrRun, $arrState);
|
||||
}
|
||||
unset($arrRun);
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'task_runs_root' => realpath($strTaskRunsRoot) ?: $strTaskRunsRoot,
|
||||
'task_state_root' => ($strStateRoot !== '' && is_dir($strStateRoot)) ? (realpath($strStateRoot) ?: $strStateRoot) : $strStateRoot,
|
||||
'runs_count' => count($arrRuns),
|
||||
'tasks_count' => count($arrLatestByTask),
|
||||
'latest_tasks' => array_values($arrLatestByTask),
|
||||
'runs' => $arrRuns,
|
||||
];
|
||||
}
|
||||
|
||||
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): void
|
||||
{
|
||||
self::writeFile(
|
||||
rtrim($strOutputRoot, '/') . '/index.json',
|
||||
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile(
|
||||
rtrim($strOutputRoot, '/') . '/index.html',
|
||||
self::renderHtml($arrSummary)
|
||||
);
|
||||
|
||||
foreach ((array)($arrSummary['runs'] ?? []) as $arrRun) {
|
||||
if (!is_array($arrRun)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strDetailSlug = trim((string)($arrRun['detail_slug'] ?? ''));
|
||||
if ($strDetailSlug === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::writeFile(
|
||||
rtrim($strOutputRoot, '/') . '/details/' . $strDetailSlug . '.json',
|
||||
json_encode($arrRun, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile(
|
||||
rtrim($strOutputRoot, '/') . '/details/' . $strDetailSlug . '.html',
|
||||
self::renderDetailHtml($arrRun)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function buildRunSummary(string $strTaskSlug, array $arrData): string
|
||||
{
|
||||
return match ($strTaskSlug) {
|
||||
'seo-copy-prepare-approved' => sprintf(
|
||||
'prepared=%d, skipped=%d, manifests=%d',
|
||||
(int)($arrData['prepared_count'] ?? 0),
|
||||
(int)($arrData['skipped_count'] ?? 0),
|
||||
(int)($arrData['manifests_total'] ?? 0)
|
||||
),
|
||||
'seo-copy-refresh-portal' => sprintf(
|
||||
'approved=%d, releases=%d, runs=%d, bootstrap_oncall=%d, completed=%d',
|
||||
(int)($arrData['approved_manifests'] ?? 0),
|
||||
(int)($arrData['releases'] ?? 0),
|
||||
(int)($arrData['runs'] ?? 0),
|
||||
(int)($arrData['bootstrap_oncall_open'] ?? 0),
|
||||
(int)($arrData['bootstrap_completed_count'] ?? 0)
|
||||
),
|
||||
'seo-copy-run-bootstrap-bundles' => sprintf(
|
||||
'executed=%d, skipped=%d, pending=%d, oncall=%d, completed=%d, latest=%s:%s',
|
||||
(int)($arrData['executed_count'] ?? 0),
|
||||
(int)($arrData['skipped_count'] ?? 0),
|
||||
(int)($arrData['pending_count'] ?? 0),
|
||||
(int)($arrData['oncall_open'] ?? 0),
|
||||
(int)($arrData['completed_count'] ?? 0),
|
||||
trim((string)($arrData['oncall_latest_host'] ?? '')),
|
||||
trim((string)($arrData['oncall_latest_todo'] ?? ''))
|
||||
),
|
||||
'seo-copy-run-bootstrap-oncall' => sprintf(
|
||||
'selected=%s, owner=%s, todo=%s, execution=%s, processed=%d, oncall=%d, completed=%d',
|
||||
trim((string)($arrData['selected_host'] ?? '')),
|
||||
trim((string)($arrData['selected_owner'] ?? '')),
|
||||
trim((string)($arrData['selected_todo'] ?? '')),
|
||||
trim((string)($arrData['execution_status'] ?? ($arrData['status'] ?? 'success'))),
|
||||
(int)($arrData['processed_count'] ?? 0),
|
||||
(int)($arrData['oncall_open'] ?? 0),
|
||||
(int)($arrData['completed_count'] ?? 0)
|
||||
),
|
||||
'seo-copy-run-bootstrap-pipeline' => sprintf(
|
||||
'processed=%d, open=%d->%d, stop=%s, import=%s, latest=%s:%s, completed=%s:%s',
|
||||
(int)($arrData['processed_count'] ?? 0),
|
||||
(int)($arrData['open_count_before'] ?? 0),
|
||||
(int)($arrData['open_count_after'] ?? 0),
|
||||
trim((string)($arrData['stop_reason'] ?? '')),
|
||||
self::buildPipelineImportSummary($arrData),
|
||||
trim((string)($arrData['latest_host_after'] ?? '')),
|
||||
trim((string)($arrData['latest_todo_after'] ?? '')),
|
||||
trim((string)($arrData['latest_completed_host'] ?? '')),
|
||||
trim((string)($arrData['latest_completed_stage'] ?? ''))
|
||||
),
|
||||
'seo-copy-run-runtime-acceptance' => sprintf(
|
||||
'source=%s, processed=%d/%d, skipped=%d, domains=%d/%d, passed=%d, failed=%d, host=%s, video=%d, forge=%d, all_passed=%s, runs=%d, latest=%s',
|
||||
trim((string)($arrData['target_source'] ?? 'explicit')),
|
||||
(int)($arrData['processed_count'] ?? 1),
|
||||
(int)($arrData['selected_targets'] ?? (int)($arrData['processed_count'] ?? 1)),
|
||||
(int)($arrData['skipped_targets'] ?? max(0, (int)($arrData['total_targets'] ?? 0) - (int)($arrData['selected_targets'] ?? (int)($arrData['processed_count'] ?? 1)))),
|
||||
(int)($arrData['selected_domains'] ?? 0),
|
||||
(int)($arrData['enabled_domains'] ?? 0),
|
||||
(int)($arrData['passed_count'] ?? (!empty($arrData['all_passed']) ? 1 : 0)),
|
||||
(int)($arrData['failed_count'] ?? (!empty($arrData['all_passed']) ? 0 : 1)),
|
||||
trim((string)($arrData['host'] ?? '')),
|
||||
(int)($arrData['video_id'] ?? 0),
|
||||
(int)($arrData['forge_id'] ?? 0),
|
||||
!empty($arrData['all_passed']) ? 'yes' : 'no',
|
||||
(int)($arrData['runtime_runs_count'] ?? 0),
|
||||
trim((string)(((array)($arrData['latest_runtime_run'] ?? []))['run_name'] ?? ''))
|
||||
),
|
||||
'seo-copy-run-runtime-acceptance-failed-hosts' => sprintf(
|
||||
'host=%s, category=%s, processed=%d/%d, passed=%d, failed=%d, latest=%s',
|
||||
trim((string)($arrData['host'] ?? '')),
|
||||
trim((string)($arrData['category'] ?? '')),
|
||||
(int)($arrData['processed_count'] ?? 0),
|
||||
(int)($arrData['failed_hosts_count'] ?? 0),
|
||||
(int)($arrData['passed_count'] ?? 0),
|
||||
(int)($arrData['failed_count'] ?? 0),
|
||||
trim((string)(((array)($arrData['hosts'] ?? [])[0] ?? [])['host'] ?? ''))
|
||||
),
|
||||
'seo-copy-run-domain-supply-batch' => sprintf(
|
||||
'source=%s, processed=%d, success=%d, failed=%d, bootstrap=%s, latest=%s, failed_host=%s',
|
||||
trim((string)($arrData['template_source'] ?? 'latest')),
|
||||
(int)($arrData['processed_count'] ?? 0),
|
||||
(int)($arrData['success_count'] ?? 0),
|
||||
(int)($arrData['failed_count'] ?? 0),
|
||||
!empty($arrData['run_bootstrap']) ? trim((string)($arrData['bootstrap_run'] ?? 'on')) : 'off',
|
||||
trim((string)(((array)($arrData['latest_supply_run'] ?? []))['run_id'] ?? '')),
|
||||
trim((string)(((array)($arrData['latest_failed_host'] ?? []))['host'] ?? ''))
|
||||
),
|
||||
'seo-copy-run-domain-supply-orchestration' => sprintf(
|
||||
'program=%s, ready=%s, focus=%s, tower=%s, mainline_ctl=%s, source=%s, exec=%s, review=%s, blockers=%d, current=%s, next=%s, lead=%s, queue=%d, phase=%s, mainline=%s, dispatch=%s, handoff=%s, latest=%s, hosts=%d, failed=%d',
|
||||
trim((string)(((array)($arrData['mainline_program'] ?? []))['label'] ?? (((array)($arrData['mainline_workbench'] ?? []))['label'] ?? ''))),
|
||||
trim((string)(((array)($arrData['mainline_program'] ?? []))['ready_label'] ?? '')),
|
||||
trim((string)(((array)($arrData['mainline_program'] ?? []))['focus_label'] ?? (((array)($arrData['mainline_workbench'] ?? []))['focus_label'] ?? ''))),
|
||||
trim((string)(((array)($arrData['business_control_tower'] ?? []))['label'] ?? '')),
|
||||
trim((string)(((array)($arrData['mainline_control_tower'] ?? []))['label'] ?? '')),
|
||||
trim((string)(((array)($arrData['mainline_program'] ?? []))['source_label'] ?? (((array)($arrData['mainline_control_tower'] ?? []))['source_label'] ?? ''))),
|
||||
trim((string)(((array)($arrData['business_control_tower'] ?? []))['execution_label'] ?? (((array)($arrData['business_execution_policy'] ?? []))['label'] ?? ''))),
|
||||
trim((string)(((array)($arrData['business_control_tower'] ?? []))['review_label'] ?? (((array)($arrData['business_review_policy'] ?? []))['label'] ?? ''))),
|
||||
(int)(((array)($arrData['mainline_program'] ?? []))['blockers_count'] ?? (((array)($arrData['business_control_tower'] ?? []))['blockers_count'] ?? ($arrData['business_entry_blockers_count'] ?? count((array)($arrData['business_entry_blockers'] ?? []))))),
|
||||
trim((string)(((array)($arrData['mainline_program'] ?? []))['current_action_label'] ?? (((array)($arrData['mainline_control_tower'] ?? []))['current_action_label'] ?? (((array)($arrData['business_control_tower'] ?? []))['current_action_label'] ?? (((array)($arrData['business_action_current'] ?? []))['label'] ?? ''))))),
|
||||
trim((string)(((array)($arrData['mainline_program'] ?? []))['next_action_label'] ?? (((array)($arrData['mainline_control_tower'] ?? []))['next_action_label'] ?? (((array)($arrData['business_control_tower'] ?? []))['next_action_label'] ?? (((array)($arrData['business_action_next'] ?? []))['label'] ?? ''))))),
|
||||
trim((string)(((array)($arrData['mainline_program'] ?? []))['lead_host'] ?? (((array)($arrData['mainline_control_tower'] ?? []))['lead_host'] ?? (((array)($arrData['business_control_tower'] ?? []))['lead_host'] ?? (((array)($arrData['lead_business_host'] ?? []))['host'] ?? ''))))),
|
||||
(int)(((array)($arrData['mainline_program'] ?? []))['queue_count'] ?? (((array)($arrData['mainline_control_tower'] ?? []))['queue_count'] ?? (((array)($arrData['business_control_tower'] ?? []))['queue_count'] ?? ($arrData['business_takeover_queue_count'] ?? count((array)($arrData['business_takeover_queue'] ?? [])))))),
|
||||
trim((string)(((array)($arrData['mainline_program'] ?? []))['phase_label'] ?? (((array)($arrData['mainline_control_tower'] ?? []))['phase_label'] ?? (((array)($arrData['phase'] ?? []))['label'] ?? '')))),
|
||||
trim((string)(((array)($arrData['mainline_program'] ?? []))['mainline_label'] ?? (((array)($arrData['mainline_control_tower'] ?? []))['mainline_label'] ?? (((array)($arrData['mainline_plan'] ?? []))['current_stage_label'] ?? (((array)($arrData['business_control_tower'] ?? []))['mainline_label'] ?? ''))))),
|
||||
trim((string)(((array)($arrData['mainline_program'] ?? []))['dispatch_label'] ?? (((array)($arrData['mainline_control_tower'] ?? []))['dispatch_label'] ?? (((array)($arrData['dispatch_plan'] ?? []))['current_lane_label'] ?? (((array)($arrData['dispatch_stage'] ?? []))['lane_label'] ?? ''))))),
|
||||
trim((string)(((array)($arrData['business_control_tower'] ?? []))['current_focus_label'] ?? (((array)($arrData['handoff_plan'] ?? []))['current_focus_label'] ?? ''))),
|
||||
trim((string)(((array)($arrData['latest_run'] ?? []))['run_id'] ?? '')),
|
||||
(int)($arrData['hosts_count'] ?? 0),
|
||||
(int)($arrData['failed_hosts_count'] ?? 0)
|
||||
),
|
||||
default => sprintf(
|
||||
'status=%s',
|
||||
trim((string)($arrData['status'] ?? 'success'))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
protected static function buildPipelineImportSummary(array $arrData): string
|
||||
{
|
||||
$boolRequested = (bool)($arrData['import_requested'] ?? ($arrData['allow_import'] ?? false));
|
||||
$boolArmed = (bool)($arrData['import_armed'] ?? ($arrData['allow_import'] ?? false));
|
||||
if (!(bool)($arrData['allow_import'] ?? false)) {
|
||||
return $boolRequested && !$boolArmed ? 'requested-not-armed' : 'off';
|
||||
}
|
||||
|
||||
$strDir = trim((string)($arrData['ai_result_dir'] ?? ''));
|
||||
if ($strDir !== '') {
|
||||
return 'dir';
|
||||
}
|
||||
|
||||
$strRoot = trim((string)($arrData['ai_result_root'] ?? ''));
|
||||
if ($strRoot !== '') {
|
||||
return 'root';
|
||||
}
|
||||
|
||||
return 'on';
|
||||
}
|
||||
|
||||
protected static function renderHtml(array $arrSummary): string
|
||||
{
|
||||
$arrLatestRows = [];
|
||||
foreach ((array)($arrSummary['latest_tasks'] ?? []) as $arrRun) {
|
||||
if (!is_array($arrRun)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrLatestRows[] = '<tr>'
|
||||
. '<td><a href="./details/' . rawurlencode((string)($arrRun['detail_slug'] ?? '')) . '.html">' . self::escape((string)($arrRun['task_slug'] ?? '')) . '</a></td>'
|
||||
. '<td>' . self::escape((string)($arrRun['status'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['health_status'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['summary'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['updated_at'] ?? '')) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
$arrRunRows = [];
|
||||
foreach ((array)($arrSummary['runs'] ?? []) as $arrRun) {
|
||||
if (!is_array($arrRun)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrRunRows[] = '<tr>'
|
||||
. '<td><a href="./details/' . rawurlencode((string)($arrRun['detail_slug'] ?? '')) . '.html">' . self::escape((string)($arrRun['run_name'] ?? '')) . '</a></td>'
|
||||
. '<td>' . self::escape((string)($arrRun['task_slug'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['status'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['health_status'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['summary'] ?? '')) . '</td>'
|
||||
. '<td>' . self::escape((string)($arrRun['updated_at'] ?? '')) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return self::wrapHtml(
|
||||
'SEO Copy Scheduled Tasks',
|
||||
'<p class="lead">内部只读任务健康页,展示最近的 SEO 补料定时任务执行摘要。</p>'
|
||||
. '<p><a href="../index.html">返回门户首页</a></p>'
|
||||
. '<div class="meta-grid">'
|
||||
. '<div><strong>generated_at</strong><span>' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>tasks</strong><span>' . (int)($arrSummary['tasks_count'] ?? 0) . '</span></div>'
|
||||
. '<div><strong>runs</strong><span>' . (int)($arrSummary['runs_count'] ?? 0) . '</span></div>'
|
||||
. '</div>'
|
||||
. '<h2>Latest By Task</h2><table><thead><tr><th>task_slug</th><th>status</th><th>health</th><th>summary</th><th>updated_at</th></tr></thead><tbody>'
|
||||
. implode('', $arrLatestRows)
|
||||
. '</tbody></table>'
|
||||
. '<h2>Recent Task Runs</h2><table><thead><tr><th>run_name</th><th>task_slug</th><th>status</th><th>health</th><th>summary</th><th>updated_at</th></tr></thead><tbody>'
|
||||
. implode('', $arrRunRows)
|
||||
. '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
protected static function renderDetailHtml(array $arrRun): string
|
||||
{
|
||||
return self::wrapHtml(
|
||||
'SEO Copy Task Run Detail',
|
||||
'<p class="lead">内部只读任务详情页,展示单次定时任务摘要。</p>'
|
||||
. '<p><a href="../../index.html">返回门户首页</a> | <a href="../index.html">返回任务健康页</a></p>'
|
||||
. '<div class="meta-grid">'
|
||||
. '<div><strong>run_name</strong><span>' . self::escape((string)($arrRun['run_name'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>task_slug</strong><span>' . self::escape((string)($arrRun['task_slug'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>status</strong><span>' . self::escape((string)($arrRun['status'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>health</strong><span>' . self::escape((string)($arrRun['health_status'] ?? '')) . '</span></div>'
|
||||
. '<div><strong>updated_at</strong><span>' . self::escape((string)($arrRun['updated_at'] ?? '')) . '</span></div>'
|
||||
. '</div>'
|
||||
. '<table><thead><tr><th>field</th><th>value</th></tr></thead><tbody>'
|
||||
. '<tr><td>summary</td><td>' . self::escape((string)($arrRun['summary'] ?? '')) . '</td></tr>'
|
||||
. '<tr><td>age_seconds</td><td>' . (int)($arrRun['age_seconds'] ?? 0) . '</td></tr>'
|
||||
. '</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
protected static function buildHealthStatus(array $arrRun, array $arrState): string
|
||||
{
|
||||
$strStateStatus = trim((string)($arrState['status'] ?? ''));
|
||||
if ($strStateStatus === 'running') {
|
||||
return 'running';
|
||||
}
|
||||
if ($strStateStatus === 'error' || (string)($arrRun['status'] ?? '') === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
if ($strStateStatus === 'skipped_locked' || (string)($arrRun['status'] ?? '') === 'skipped_locked') {
|
||||
return 'locked';
|
||||
}
|
||||
|
||||
$intAge = (int)($arrRun['age_seconds'] ?? 0);
|
||||
$intExpected = self::getExpectedIntervalSeconds((string)($arrRun['task_slug'] ?? ''));
|
||||
if ($intExpected > 0 && $intAge > ($intExpected * 2)) {
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
protected static function buildAgeSeconds(string $strUpdatedAt): int
|
||||
{
|
||||
if ($strUpdatedAt === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$intUpdatedAt = strtotime($strUpdatedAt);
|
||||
if ($intUpdatedAt === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, time() - $intUpdatedAt);
|
||||
}
|
||||
|
||||
protected static function getExpectedIntervalSeconds(string $strTaskSlug): int
|
||||
{
|
||||
return match ($strTaskSlug) {
|
||||
'seo-copy-prepare-approved' => 21600,
|
||||
'seo-copy-refresh-portal' => 900,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
protected static function parseRunPath(string $strPath, string $strTaskRunsRoot): array
|
||||
{
|
||||
$strRelative = ltrim(str_replace(rtrim(str_replace('\\', '/', $strTaskRunsRoot), '/'), '', $strPath), '/');
|
||||
$arrParts = explode('/', $strRelative);
|
||||
if (count($arrParts) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$strDateDir = trim((string)($arrParts[0] ?? ''));
|
||||
$strFilename = basename($strPath);
|
||||
if (!preg_match('/^(\\d{6})_(.+)\\.json$/', $strFilename, $arrMatch)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$strTimePart = (string)($arrMatch[1] ?? '');
|
||||
$strTaskSlug = (string)($arrMatch[2] ?? '');
|
||||
|
||||
return [
|
||||
'task_slug' => $strTaskSlug,
|
||||
'run_name' => $strDateDir . '-' . $strTimePart . '-' . $strTaskSlug,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildDetailSlug(string $strRunName): string
|
||||
{
|
||||
$strRunName = preg_replace('/[^a-zA-Z0-9\\-_]+/', '-', strtolower(trim($strRunName)));
|
||||
$strRunName = trim((string)$strRunName, '-_');
|
||||
|
||||
return $strRunName !== '' ? $strRunName : sha1((string)microtime(true));
|
||||
}
|
||||
|
||||
protected static function writeFile(string $strPath, string $strContent): 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, $strContent);
|
||||
}
|
||||
|
||||
protected static function escape(string $strValue): string
|
||||
{
|
||||
return htmlspecialchars($strValue, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
protected static function wrapHtml(string $strTitle, string $strBody): string
|
||||
{
|
||||
$strEscapedTitle = self::escape($strTitle);
|
||||
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
. '<meta name="robots" content="noindex,nofollow,noarchive">'
|
||||
. '<title>' . $strEscapedTitle . '</title>'
|
||||
. '<style>'
|
||||
. 'body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:24px;background:#f5f7fb;color:#1f2937;}'
|
||||
. 'h1,h2{margin:0 0 16px;}'
|
||||
. 'p{line-height:1.7;}'
|
||||
. '.lead{margin:0 0 16px;color:#475569;}'
|
||||
. 'table{width:100%;border-collapse:collapse;background:#fff;margin:16px 0 24px;border-radius:12px;overflow:hidden;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. 'th,td{padding:12px 14px;border-bottom:1px solid #e5e7eb;text-align:left;vertical-align:top;font-size:14px;}'
|
||||
. 'th{background:#eef2ff;color:#334155;font-weight:700;}'
|
||||
. '.meta-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px;}'
|
||||
. '.meta-grid div{background:#fff;padding:14px 16px;border-radius:12px;box-shadow:0 6px 22px rgba(15,23,42,.06);}'
|
||||
. '.meta-grid strong{display:block;font-size:12px;color:#64748b;margin:0 0 6px;text-transform:uppercase;letter-spacing:.04em;}'
|
||||
. '.meta-grid span{font-size:14px;color:#111827;}'
|
||||
. 'a{color:#2563eb;text-decoration:none;}'
|
||||
. 'a:hover{text-decoration:underline;}'
|
||||
. '</style></head><body><h1>' . $strEscapedTitle . '</h1>' . $strBody . '</body></html>';
|
||||
}
|
||||
}
|
||||
61
code/app/common/helper/SeoCopyTaskStateHelper.php
Normal file
61
code/app/common/helper/SeoCopyTaskStateHelper.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SeoCopyTaskStateHelper
|
||||
{
|
||||
public static function writeState(string $strStateRoot, string $strTaskSlug, array $arrState): string
|
||||
{
|
||||
$strStateRoot = rtrim($strStateRoot, '/');
|
||||
if ($strStateRoot === '') {
|
||||
throw new RuntimeException('Task state root can not be empty.');
|
||||
}
|
||||
|
||||
if (!is_dir($strStateRoot) && !mkdir($strStateRoot, 0777, true) && !is_dir($strStateRoot)) {
|
||||
throw new RuntimeException('Failed to create seo_copy task state directory: ' . $strStateRoot);
|
||||
}
|
||||
|
||||
$strPath = $strStateRoot . '/' . $strTaskSlug . '.json';
|
||||
$arrPayload = array_merge([
|
||||
'task_slug' => $strTaskSlug,
|
||||
'updated_at' => date('c'),
|
||||
'pid' => getmypid(),
|
||||
], $arrState);
|
||||
|
||||
file_put_contents($strPath, json_encode($arrPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
|
||||
return $strPath;
|
||||
}
|
||||
|
||||
public static function readStates(string $strStateRoot): array
|
||||
{
|
||||
$strStateRoot = rtrim($strStateRoot, '/');
|
||||
if ($strStateRoot === '' || !is_dir($strStateRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrStates = [];
|
||||
foreach ((array)glob($strStateRoot . '/*.json') as $strPath) {
|
||||
$arrState = json_decode((string)file_get_contents($strPath), true);
|
||||
if (!is_array($arrState)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strTaskSlug = trim((string)($arrState['task_slug'] ?? ''));
|
||||
if ($strTaskSlug === '') {
|
||||
$strTaskSlug = basename($strPath, '.json');
|
||||
$arrState['task_slug'] = $strTaskSlug;
|
||||
}
|
||||
|
||||
$arrStates[$strTaskSlug] = $arrState;
|
||||
}
|
||||
|
||||
ksort($arrStates, SORT_STRING);
|
||||
|
||||
return $arrStates;
|
||||
}
|
||||
}
|
||||
637
code/app/common/helper/SeoResourcePoolHelper.php
Normal file
637
code/app/common/helper/SeoResourcePoolHelper.php
Normal file
@@ -0,0 +1,637 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\DomainModel;
|
||||
use app\model\SeoExternalSnapshotModel;
|
||||
|
||||
class SeoResourcePoolHelper
|
||||
{
|
||||
public static function resourceRoot(): string
|
||||
{
|
||||
return dirname(__DIR__, 3) . '/data/seo_resource';
|
||||
}
|
||||
|
||||
public static function readJsonFile(string $path): array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($path), true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
public static function writeJsonFile(string $path, array $payload): void
|
||||
{
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
}
|
||||
|
||||
public static function hostFileKey(string $host): string
|
||||
{
|
||||
$host = DomainModel::normalizeHost($host);
|
||||
$host = str_replace('.', '-', $host);
|
||||
$host = preg_replace('/[^a-z0-9\-]+/i', '-', $host) ?: 'unknown-host';
|
||||
return trim((string)$host, '-');
|
||||
}
|
||||
|
||||
public static function normalizeText(string $value): string
|
||||
{
|
||||
$value = str_replace(["\r", "\n", "\t"], ' ', $value);
|
||||
$value = preg_replace('/\s+/u', ' ', $value) ?: $value;
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
public static function initSitePositioning(?string $hostFilter = null, int $limit = 100, bool $force = false): array
|
||||
{
|
||||
$hostFilter = DomainModel::normalizeHost((string)$hostFilter);
|
||||
$root = self::resourceRoot();
|
||||
$defaultBundle = self::readJsonFile($root . '/common/site_positioning.default.json');
|
||||
$template = self::readJsonFile($root . '/site_positioning/templates/default.json');
|
||||
$defaults = is_array($defaultBundle['defaults'] ?? null) ? $defaultBundle['defaults'] : [];
|
||||
|
||||
$query = app(DomainModel::class)->order('d_id', 'asc');
|
||||
if ($hostFilter !== '') {
|
||||
$query = $query->where('d_domain', $hostFilter);
|
||||
}
|
||||
$rows = $query->limit(max(1, min(5000, $limit)))->select()->toArray();
|
||||
|
||||
$written = [];
|
||||
$skipped = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$currentHost = DomainModel::normalizeHost((string)($row['d_domain'] ?? ''));
|
||||
if ($currentHost === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $root . '/site_positioning/by_host/' . self::hostFileKey($currentHost) . '.json';
|
||||
if (!$force && is_file($path)) {
|
||||
$skipped[] = ['host' => $currentHost, 'path' => $path, 'reason' => 'exists'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = self::buildSitePositioning($row, $defaults, $template);
|
||||
self::writeJsonFile($path, $payload);
|
||||
$written[] = [
|
||||
'host' => $currentHost,
|
||||
'path' => $path,
|
||||
'primary_topics' => $payload['primary_topics'],
|
||||
'priority_keywords' => $payload['priority_keywords'],
|
||||
'ai_priority' => $payload['ai_priority'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'mode' => 'seo_resource_site_positioning_init',
|
||||
'selected_host' => $hostFilter,
|
||||
'force' => $force,
|
||||
'processed_count' => count($rows),
|
||||
'written_count' => count($written),
|
||||
'skipped_count' => count($skipped),
|
||||
'written' => $written,
|
||||
'skipped' => $skipped,
|
||||
];
|
||||
}
|
||||
|
||||
public static function syncKeywordFeedback(?string $hostFilter = null, int $days = 30, int $limit = 50): array
|
||||
{
|
||||
$hostFilter = DomainModel::normalizeHost((string)$hostFilter);
|
||||
$days = max(1, min(180, $days));
|
||||
$limit = max(1, min(200, $limit));
|
||||
|
||||
$domainMap = self::readDomainMap();
|
||||
$startDate = date('Y-m-d', strtotime('-' . max(0, $days - 1) . ' days'));
|
||||
$query = app(SeoExternalSnapshotModel::class)
|
||||
->where('metric_date', '>=', $startDate)
|
||||
->where(function ($subQuery) {
|
||||
$subQuery->where(function ($q) {
|
||||
$q->where('provider', 'aizhan')->where('snapshot_type', 'aizhan_keyword')->where('scope', 'keyword');
|
||||
})->whereOr(function ($q) {
|
||||
$q->where('scope', 'host')->whereIn('snapshot_type', ['aizhan_summary', 'site_query']);
|
||||
});
|
||||
})
|
||||
->order('metric_date', 'desc')
|
||||
->order('id', 'desc');
|
||||
|
||||
if ($hostFilter !== '') {
|
||||
$query = $query->where('host', $hostFilter);
|
||||
}
|
||||
|
||||
$rows = $query->select()->toArray();
|
||||
$hostPayloads = [];
|
||||
$globalKeywords = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$host = DomainModel::normalizeHost((string)($row['host'] ?? ''));
|
||||
if ($host === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($hostPayloads[$host])) {
|
||||
$domainRow = $domainMap[$host] ?? [];
|
||||
$hostPayloads[$host] = [
|
||||
'version' => 1,
|
||||
'host' => $host,
|
||||
'updated_at' => date(DATE_ATOM),
|
||||
'generated_by' => 'seo_resource_keyword_feedback_sync',
|
||||
'source' => 'seo_external_snapshot',
|
||||
'site_name' => self::normalizeText((string)($domainRow['d_name'] ?? '')),
|
||||
'domain_keyword' => self::normalizeText((string)($domainRow['d_keywords'] ?? '')),
|
||||
'summary' => [
|
||||
'providers' => [],
|
||||
'snapshot_count' => 0,
|
||||
'indexed_like' => false,
|
||||
'keyword_ready' => false,
|
||||
'traffic_ready' => false,
|
||||
'pc_keyword_count' => 0,
|
||||
'mobile_keyword_count' => 0,
|
||||
'latest_metric_date' => '',
|
||||
],
|
||||
'keywords' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = &$hostPayloads[$host];
|
||||
$payload['summary']['snapshot_count']++;
|
||||
$provider = trim((string)($row['provider'] ?? ''));
|
||||
if ($provider !== '' && !in_array($provider, $payload['summary']['providers'], true)) {
|
||||
$payload['summary']['providers'][] = $provider;
|
||||
}
|
||||
|
||||
$metricDate = trim((string)($row['metric_date'] ?? ''));
|
||||
if ($metricDate !== '' && strcmp((string)$payload['summary']['latest_metric_date'], $metricDate) < 0) {
|
||||
$payload['summary']['latest_metric_date'] = $metricDate;
|
||||
}
|
||||
|
||||
if ((string)($row['scope'] ?? '') === 'host') {
|
||||
$payload['summary']['pc_keyword_count'] = max((int)$payload['summary']['pc_keyword_count'], (int)($row['pc_keyword_count'] ?? 0));
|
||||
$payload['summary']['mobile_keyword_count'] = max((int)$payload['summary']['mobile_keyword_count'], (int)($row['mobile_keyword_count'] ?? 0));
|
||||
$payload['summary']['keyword_ready'] = $payload['summary']['keyword_ready']
|
||||
|| ((int)($row['pc_keyword_count'] ?? 0) > 0 || (int)($row['mobile_keyword_count'] ?? 0) > 0);
|
||||
$payload['summary']['indexed_like'] = $payload['summary']['indexed_like']
|
||||
|| ((string)($row['indexed_status'] ?? '') === 'indexed_like');
|
||||
$payload['summary']['traffic_ready'] = $payload['summary']['traffic_ready']
|
||||
|| trim((string)($row['baidu_pc_ip_range'] ?? '')) !== ''
|
||||
|| trim((string)($row['baidu_mobile_ip_range'] ?? '')) !== '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$keyword = self::normalizeText((string)($row['keyword'] ?? ''));
|
||||
if ($keyword === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keywordItem = [
|
||||
'keyword' => $keyword,
|
||||
'source' => trim((string)($row['snapshot_type'] ?? '')) ?: $provider,
|
||||
'provider' => $provider,
|
||||
'metric_date' => $metricDate,
|
||||
'rank_value' => (int)($row['rank_value'] ?? 0),
|
||||
'search_volume' => (int)($row['search_volume'] ?? 0),
|
||||
'weight' => self::keywordWeight($row),
|
||||
'intent' => self::inferIntent($keyword),
|
||||
];
|
||||
|
||||
$payload['keywords'][] = $keywordItem;
|
||||
$globalKeywords[] = $keywordItem + ['host' => $host];
|
||||
}
|
||||
|
||||
foreach ($hostPayloads as $host => &$payload) {
|
||||
usort($payload['keywords'], static function (array $left, array $right): int {
|
||||
$leftWeight = (float)($left['weight'] ?? 0);
|
||||
$rightWeight = (float)($right['weight'] ?? 0);
|
||||
if ($leftWeight !== $rightWeight) {
|
||||
return $rightWeight <=> $leftWeight;
|
||||
}
|
||||
return ((int)($right['search_volume'] ?? 0)) <=> ((int)($left['search_volume'] ?? 0));
|
||||
});
|
||||
|
||||
$payload['keywords'] = self::uniqueKeywords(array_map(static function (array $item) use ($host): array {
|
||||
$item['host'] = $host;
|
||||
return $item;
|
||||
}, $payload['keywords']), $limit);
|
||||
$payload['keywords'] = array_map(static function (array $item): array {
|
||||
unset($item['host']);
|
||||
return $item;
|
||||
}, $payload['keywords']);
|
||||
|
||||
if (empty($payload['keywords']) && ($payload['summary']['keyword_ready'] || $payload['summary']['indexed_like'])) {
|
||||
$fallbackKeyword = self::normalizeText((string)($payload['domain_keyword'] ?? ''));
|
||||
if ($fallbackKeyword !== '') {
|
||||
$payload['keywords'][] = [
|
||||
'keyword' => $fallbackKeyword,
|
||||
'source' => 'domain_keyword_fallback',
|
||||
'provider' => 'local',
|
||||
'metric_date' => (string)($payload['summary']['latest_metric_date'] ?? ''),
|
||||
'rank_value' => 0,
|
||||
'search_volume' => 0,
|
||||
'weight' => self::hostSummaryWeight($payload['summary']),
|
||||
'intent' => 'home',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$path = self::resourceRoot() . '/keyword_feedback/by_host/' . self::hostFileKey($host) . '.json';
|
||||
self::writeJsonFile($path, $payload);
|
||||
$payload['path'] = $path;
|
||||
}
|
||||
unset($payload);
|
||||
|
||||
usort($globalKeywords, static function (array $left, array $right): int {
|
||||
$leftWeight = (float)($left['weight'] ?? 0);
|
||||
$rightWeight = (float)($right['weight'] ?? 0);
|
||||
if ($leftWeight !== $rightWeight) {
|
||||
return $rightWeight <=> $leftWeight;
|
||||
}
|
||||
return ((int)($right['search_volume'] ?? 0)) <=> ((int)($left['search_volume'] ?? 0));
|
||||
});
|
||||
|
||||
$latest = [
|
||||
'version' => 1,
|
||||
'updated_at' => date(DATE_ATOM),
|
||||
'generated_by' => 'seo_resource_keyword_feedback_sync',
|
||||
'source' => 'seo_external_snapshot',
|
||||
'days' => $days,
|
||||
'selected_host' => $hostFilter,
|
||||
'host_count' => count($hostPayloads),
|
||||
'keyword_count' => count($globalKeywords),
|
||||
'keywords' => self::uniqueKeywords($globalKeywords, $limit),
|
||||
'hosts' => array_values(array_map(static function (array $payload): array {
|
||||
return [
|
||||
'host' => (string)($payload['host'] ?? ''),
|
||||
'site_name' => (string)($payload['site_name'] ?? ''),
|
||||
'keyword_count' => count((array)($payload['keywords'] ?? [])),
|
||||
'indexed_like' => !empty($payload['summary']['indexed_like']),
|
||||
'keyword_ready' => !empty($payload['summary']['keyword_ready']),
|
||||
'traffic_ready' => !empty($payload['summary']['traffic_ready']),
|
||||
'latest_metric_date' => (string)($payload['summary']['latest_metric_date'] ?? ''),
|
||||
'path' => (string)($payload['path'] ?? ''),
|
||||
];
|
||||
}, array_values($hostPayloads))),
|
||||
];
|
||||
|
||||
$latestPath = self::resourceRoot() . '/keyword_feedback/hot_keywords.latest.json';
|
||||
self::writeJsonFile($latestPath, $latest);
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'mode' => 'seo_resource_keyword_feedback_sync',
|
||||
'days' => $days,
|
||||
'selected_host' => $hostFilter,
|
||||
'host_count' => count($hostPayloads),
|
||||
'keyword_count' => count($globalKeywords),
|
||||
'latest_path' => $latestPath,
|
||||
'hosts' => $latest['hosts'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildAiTriggerScore(?string $hostFilter = null, int $limit = 100): array
|
||||
{
|
||||
$hostFilter = DomainModel::normalizeHost((string)$hostFilter);
|
||||
$limit = max(1, min(5000, $limit));
|
||||
$rules = self::readJsonFile(self::resourceRoot() . '/common/ai_trigger_rules.json');
|
||||
$enabledRules = [];
|
||||
foreach ((array)($rules['rules'] ?? []) as $rule) {
|
||||
if (!empty($rule['enabled']) && !empty($rule['key'])) {
|
||||
$enabledRules[(string)$rule['key']] = (string)($rule['description'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$query = app(DomainModel::class)->order('d_id', 'asc');
|
||||
if ($hostFilter !== '') {
|
||||
$query = $query->where('d_domain', $hostFilter);
|
||||
}
|
||||
$rows = $query->limit($limit)->select()->toArray();
|
||||
$items = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$host = DomainModel::normalizeHost((string)($row['d_domain'] ?? ''));
|
||||
if ($host === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$state = SeoCopyGenerationHelper::readState($host);
|
||||
$positioning = self::readJsonFile(self::resourceRoot() . '/site_positioning/by_host/' . self::hostFileKey($host) . '.json');
|
||||
$feedback = self::readJsonFile(self::resourceRoot() . '/keyword_feedback/by_host/' . self::hostFileKey($host) . '.json');
|
||||
$history = SeoCopyGenerationHelper::readHistory($host);
|
||||
$latestHistory = !empty($history) ? end($history) : [];
|
||||
if (!is_array($latestHistory)) {
|
||||
$latestHistory = [];
|
||||
}
|
||||
|
||||
$reasons = [];
|
||||
$score = 0;
|
||||
$waitForAi = !empty($state['wait_for_ai_before_publish']) || !empty($positioning['wait_for_ai_before_publish']);
|
||||
if ($waitForAi && isset($enabledRules['wait_before_publish'])) {
|
||||
$score += 40;
|
||||
$reasons[] = $enabledRules['wait_before_publish'];
|
||||
}
|
||||
|
||||
$priorityKeywords = (array)($positioning['priority_keywords'] ?? []);
|
||||
if (count($priorityKeywords) < 2 && isset($enabledRules['low_quality_local_copy'])) {
|
||||
$score += 20;
|
||||
$reasons[] = $enabledRules['low_quality_local_copy'];
|
||||
}
|
||||
|
||||
$feedbackKeywords = (array)($feedback['keywords'] ?? []);
|
||||
$keywordReady = !empty($feedback['summary']['keyword_ready']);
|
||||
$indexedLike = !empty($feedback['summary']['indexed_like']);
|
||||
if ($indexedLike && !$keywordReady && isset($enabledRules['indexed_without_keywords'])) {
|
||||
$score += 25;
|
||||
$reasons[] = $enabledRules['indexed_without_keywords'];
|
||||
}
|
||||
|
||||
if (count($feedbackKeywords) >= 3 && count($priorityKeywords) <= 2 && isset($enabledRules['keyword_feedback_drift'])) {
|
||||
$score += 20;
|
||||
$reasons[] = $enabledRules['keyword_feedback_drift'];
|
||||
}
|
||||
|
||||
$status = (string)($state['status'] ?? 'draft');
|
||||
$recommend = $score >= 40 || in_array($status, [SeoCopyGenerationHelper::STATUS_AI_PENDING], true);
|
||||
$items[] = [
|
||||
'host' => $host,
|
||||
'score' => $score,
|
||||
'recommend_ai' => $recommend,
|
||||
'status' => $status,
|
||||
'provider_preference' => (string)($state['provider_preference'] ?? ''),
|
||||
'latest_provider' => (string)($latestHistory['provider_effective'] ?? ''),
|
||||
'latest_recorded_at' => (string)($latestHistory['recorded_at'] ?? ''),
|
||||
'feedback_keyword_count' => count($feedbackKeywords),
|
||||
'priority_keyword_count' => count($priorityKeywords),
|
||||
'reasons' => array_values(array_unique($reasons)),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static function (array $left, array $right): int {
|
||||
if ((int)$left['score'] !== (int)$right['score']) {
|
||||
return ((int)$right['score']) <=> ((int)$left['score']);
|
||||
}
|
||||
return strcmp((string)$left['host'], (string)$right['host']);
|
||||
});
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'mode' => 'seo_resource_ai_trigger_score',
|
||||
'selected_host' => $hostFilter,
|
||||
'items' => $items,
|
||||
'recommend_count' => count(array_filter($items, static fn(array $item): bool => !empty($item['recommend_ai']))),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildSitePositioning(array $domainRow, array $defaults, array $template): array
|
||||
{
|
||||
$host = DomainModel::normalizeHost((string)($domainRow['d_domain'] ?? ''));
|
||||
$seoCfg = is_array($domainRow['d_seo_cfg'] ?? null) ? $domainRow['d_seo_cfg'] : [];
|
||||
$copyCfg = is_array($seoCfg['copy_generation'] ?? null) ? $seoCfg['copy_generation'] : [];
|
||||
|
||||
return array_merge($template, [
|
||||
'version' => 1,
|
||||
'host' => $host,
|
||||
'site_name' => self::normalizeText((string)($domainRow['d_name'] ?? $host)),
|
||||
'primary_topics' => self::inferPrimaryTopics($domainRow, $defaults),
|
||||
'secondary_topics' => self::inferSecondaryTopics($domainRow, $defaults),
|
||||
'priority_keywords' => self::inferPriorityKeywords($domainRow, $defaults),
|
||||
'tone' => self::inferTone($domainRow, $defaults),
|
||||
'homepage_goal' => self::inferHomepageGoal($domainRow, $defaults),
|
||||
'ai_priority' => self::inferAiPriority($domainRow, $defaults),
|
||||
'wait_for_ai_before_publish' => !empty($copyCfg['wait_for_ai_before_publish']),
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'generated_by' => 'seo_resource_site_positioning_init',
|
||||
'source' => [
|
||||
'domain_id' => (int)($domainRow['d_id'] ?? 0),
|
||||
'domain_keyword' => self::normalizeText((string)($domainRow['d_keywords'] ?? '')),
|
||||
'domain_description_excerpt' => mb_substr(self::normalizeText((string)($domainRow['d_description'] ?? '')), 0, 120),
|
||||
'copy_generation_status' => trim((string)($copyCfg['status'] ?? 'draft')) ?: 'draft',
|
||||
'tkd_mode' => trim((string)($seoCfg['tkd']['mode'] ?? 'auto_generate')) ?: 'auto_generate',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected static function readDomainMap(): array
|
||||
{
|
||||
$map = [];
|
||||
$rows = app(DomainModel::class)->field(['d_id', 'd_domain', 'd_name', 'd_keywords', 'd_seo_cfg'])->select()->toArray();
|
||||
foreach ($rows as $row) {
|
||||
$host = DomainModel::normalizeHost((string)($row['d_domain'] ?? ''));
|
||||
if ($host !== '') {
|
||||
$map[$host] = $row;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
protected static function splitKeywordCandidates(string ...$texts): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($texts as $text) {
|
||||
$text = self::normalizeText($text);
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
$parts = preg_split('/[,,、|\/\s]+/u', $text) ?: [];
|
||||
foreach ($parts as $part) {
|
||||
$part = self::normalizeText((string)$part);
|
||||
if ($part === '' || mb_strlen($part) < 2 || mb_strlen($part) > 40) {
|
||||
continue;
|
||||
}
|
||||
$items[] = $part;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($items));
|
||||
}
|
||||
|
||||
protected static function extractCategoryTopics(array $domainRow): array
|
||||
{
|
||||
$topics = [];
|
||||
$cfg = is_array($domainRow['t_cfg'] ?? null) ? $domainRow['t_cfg'] : [];
|
||||
foreach ((array)($cfg['pages']['home']['categories'] ?? []) as $item) {
|
||||
$name = self::normalizeText((string)($item['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$topics[] = $name;
|
||||
}
|
||||
}
|
||||
foreach ((array)($cfg['pages']['category']['cat1_map'] ?? []) as $item) {
|
||||
$name = self::normalizeText((string)($item['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$topics[] = $name;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($topics));
|
||||
}
|
||||
|
||||
protected static function filterNoise(array $items, array $blocked): array
|
||||
{
|
||||
$blocked = array_values(array_filter(array_map(static fn(string $item): string => self::normalizeText($item), $blocked)));
|
||||
return array_values(array_filter($items, static function (string $item) use ($blocked): bool {
|
||||
foreach ($blocked as $block) {
|
||||
if ($block !== '' && mb_stripos($item, $block) !== false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
protected static function inferPriorityKeywords(array $domainRow, array $defaults): array
|
||||
{
|
||||
$candidates = self::splitKeywordCandidates(
|
||||
(string)($domainRow['d_keywords'] ?? ''),
|
||||
(string)($domainRow['d_index_keywords'] ?? ''),
|
||||
(string)($domainRow['d_index_title'] ?? '')
|
||||
);
|
||||
$blocked = [
|
||||
(string)($domainRow['d_name'] ?? ''),
|
||||
(string)($domainRow['d_domain'] ?? ''),
|
||||
'在线观看', '免费观看', '高清', '影视', '影院', '大全',
|
||||
];
|
||||
$candidates = self::filterNoise($candidates, $blocked);
|
||||
if (count($candidates) < 3) {
|
||||
$candidates = array_values(array_unique(array_merge($candidates, (array)($defaults['priority_keywords'] ?? []))));
|
||||
}
|
||||
return array_slice($candidates, 0, 6);
|
||||
}
|
||||
|
||||
protected static function inferPrimaryTopics(array $domainRow, array $defaults): array
|
||||
{
|
||||
$topics = self::extractCategoryTopics($domainRow);
|
||||
if (count($topics) < 2) {
|
||||
$topics = array_values(array_unique(array_merge($topics, self::splitKeywordCandidates((string)($domainRow['d_keywords'] ?? '')))));
|
||||
}
|
||||
$allow = ['电影', '电视剧', '综艺', '动漫', '短剧', '韩剧', '日剧', '欧美剧', '国产剧', '港台剧', '泰剧', '纪录片'];
|
||||
$selected = [];
|
||||
foreach ($topics as $topic) {
|
||||
foreach ($allow as $candidate) {
|
||||
if ($topic === $candidate || mb_stripos($topic, $candidate) !== false) {
|
||||
$selected[] = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
$selected = array_values(array_unique($selected));
|
||||
if (empty($selected)) {
|
||||
$selected = (array)($defaults['primary_topics'] ?? ['电视剧', '电影', '短剧']);
|
||||
}
|
||||
return array_slice($selected, 0, 4);
|
||||
}
|
||||
|
||||
protected static function inferSecondaryTopics(array $domainRow, array $defaults): array
|
||||
{
|
||||
$items = [];
|
||||
$description = self::normalizeText((string)($domainRow['d_description'] ?? ''));
|
||||
$mapping = [
|
||||
'剧情' => '剧情介绍',
|
||||
'演员' => '演员资料',
|
||||
'更新' => '更新进度',
|
||||
'推荐' => '热门推荐',
|
||||
'排行' => '热门排行',
|
||||
'专题' => '内容专题',
|
||||
'播放' => '播放线索',
|
||||
];
|
||||
foreach ($mapping as $needle => $label) {
|
||||
if ($description !== '' && mb_stripos($description, $needle) !== false) {
|
||||
$items[] = $label;
|
||||
}
|
||||
}
|
||||
$items = array_values(array_unique(array_merge($items, (array)($defaults['secondary_topics'] ?? []))));
|
||||
return array_slice($items, 0, 6);
|
||||
}
|
||||
|
||||
protected static function inferTone(array $domainRow, array $defaults): string
|
||||
{
|
||||
$description = self::normalizeText((string)($domainRow['d_description'] ?? ''));
|
||||
if ($description !== '') {
|
||||
if (mb_stripos($description, '整理') !== false || mb_stripos($description, '参考') !== false) {
|
||||
return '内容整理型';
|
||||
}
|
||||
if (mb_stripos($description, '更新') !== false || mb_stripos($description, '推荐') !== false) {
|
||||
return '内容导购型';
|
||||
}
|
||||
}
|
||||
return (string)($defaults['tone'] ?? '内容导购型');
|
||||
}
|
||||
|
||||
protected static function inferHomepageGoal(array $domainRow, array $defaults): string
|
||||
{
|
||||
$keywords = self::normalizeText((string)($domainRow['d_keywords'] ?? ''));
|
||||
if ($keywords !== '') {
|
||||
return '先围绕“' . mb_substr($keywords, 0, 18) . '”建立首页和分类页主题,再带动详情页与搜索页起词。';
|
||||
}
|
||||
return (string)($defaults['homepage_goal'] ?? '先收录首页与分类页,再带动详情页起词');
|
||||
}
|
||||
|
||||
protected static function inferAiPriority(array $domainRow, array $defaults): string
|
||||
{
|
||||
$seoCfg = is_array($domainRow['d_seo_cfg'] ?? null) ? $domainRow['d_seo_cfg'] : [];
|
||||
$copyCfg = is_array($seoCfg['copy_generation'] ?? null) ? $seoCfg['copy_generation'] : [];
|
||||
if (!empty($copyCfg['wait_for_ai_before_publish']) || (string)($copyCfg['provider_preference'] ?? '') === 'ai_first') {
|
||||
return 'high';
|
||||
}
|
||||
return (string)($defaults['ai_priority'] ?? 'normal');
|
||||
}
|
||||
|
||||
protected static function inferIntent(string $keyword): string
|
||||
{
|
||||
$keyword = self::normalizeText($keyword);
|
||||
if ($keyword === '') {
|
||||
return 'home';
|
||||
}
|
||||
if (preg_match('/(电影|电视剧|综艺|动漫|短剧|韩剧|日剧|欧美剧|国产剧|纪录片)/u', $keyword)) {
|
||||
return 'category';
|
||||
}
|
||||
if (preg_match('/(演员|剧情|第\d+集|全集|完整版|在线观看)/u', $keyword)) {
|
||||
return 'detail';
|
||||
}
|
||||
return 'search';
|
||||
}
|
||||
|
||||
protected static function keywordWeight(array $row): float
|
||||
{
|
||||
$rank = max(1, (int)($row['rank_value'] ?? 0));
|
||||
$volume = max(0, (int)($row['search_volume'] ?? 0));
|
||||
$base = $volume > 0 ? min(1, $volume / 1000) : 0.12;
|
||||
$rankBoost = max(0.08, min(1, (100 - min(100, $rank)) / 100));
|
||||
return round(($base * 0.65) + ($rankBoost * 0.35), 4);
|
||||
}
|
||||
|
||||
protected static function hostSummaryWeight(array $row): float
|
||||
{
|
||||
$pc = max(0, (int)($row['pc_keyword_count'] ?? 0));
|
||||
$mobile = max(0, (int)($row['mobile_keyword_count'] ?? 0));
|
||||
$sum = $pc + $mobile;
|
||||
if ($sum <= 0) {
|
||||
return 0.15;
|
||||
}
|
||||
return round(min(1, ($sum / 50)), 4);
|
||||
}
|
||||
|
||||
protected static function uniqueKeywords(array $items, int $limit): array
|
||||
{
|
||||
$seen = [];
|
||||
$result = [];
|
||||
foreach ($items as $item) {
|
||||
$host = self::normalizeText((string)($item['host'] ?? ''));
|
||||
$keyword = self::normalizeText((string)($item['keyword'] ?? ''));
|
||||
$key = $host . '|' . $keyword;
|
||||
if ($keyword === '' || isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$result[] = $item;
|
||||
if (count($result) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
251
code/app/common/helper/SpiderMdConfigHelper.php
Normal file
251
code/app/common/helper/SpiderMdConfigHelper.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\SystemConfigModel;
|
||||
|
||||
class SpiderMdConfigHelper
|
||||
{
|
||||
protected const DEFINITIONS = [
|
||||
'SPIDER_MD_MAX_LINKS_PER_FILE' => [
|
||||
'default' => '700',
|
||||
'description' => '蜘蛛池MD单文件最大链接数',
|
||||
'secret' => false,
|
||||
],
|
||||
'SPIDER_MD_CATEGORY_LINK_LIMIT_PER_HOST' => [
|
||||
'default' => '0',
|
||||
'description' => '蜘蛛池MD每域名分类首页链接上限(0 为不限制,仅生成分类首页/分类入口)',
|
||||
'secret' => false,
|
||||
],
|
||||
'SPIDER_MD_SEARCH_LINK_LIMIT_PER_HOST' => [
|
||||
'default' => '10',
|
||||
'description' => '蜘蛛池MD每域名搜索页链接上限(默认 10,0 为不生成)',
|
||||
'secret' => false,
|
||||
],
|
||||
'SPIDER_MD_DETAIL_LINK_LIMIT_PER_HOST' => [
|
||||
'default' => '1000',
|
||||
'description' => '蜘蛛池MD每域名详情页链接上限(默认 1000,0 为不限制)',
|
||||
'secret' => false,
|
||||
],
|
||||
'SPIDER_MD_FORGE_LINK_LIMIT_PER_HOST' => [
|
||||
'default' => '1000',
|
||||
'description' => '蜘蛛池MD每域名详情泛链接上限(默认 1000,0 为不限制)',
|
||||
'secret' => false,
|
||||
],
|
||||
'SPIDER_MD_GITEE_ENABLED' => [
|
||||
'default' => '0',
|
||||
'description' => '蜘蛛池MD是否启用 Gitee 推送',
|
||||
'secret' => false,
|
||||
],
|
||||
'SPIDER_MD_GITEE_OWNER' => [
|
||||
'default' => '',
|
||||
'description' => '蜘蛛池MD Gitee 仓库 owner',
|
||||
'secret' => false,
|
||||
],
|
||||
'SPIDER_MD_GITEE_REPO' => [
|
||||
'default' => '',
|
||||
'description' => '蜘蛛池MD Gitee 仓库 repo',
|
||||
'secret' => false,
|
||||
],
|
||||
'SPIDER_MD_GITEE_BRANCH' => [
|
||||
'default' => 'master',
|
||||
'description' => '蜘蛛池MD Gitee 分支',
|
||||
'secret' => false,
|
||||
],
|
||||
'SPIDER_MD_GITEE_ROOT' => [
|
||||
'default' => 'seo-spider-md',
|
||||
'description' => '蜘蛛池MD Gitee 远程根目录',
|
||||
'secret' => false,
|
||||
],
|
||||
'SPIDER_MD_GITEE_TOKEN' => [
|
||||
'default' => '',
|
||||
'description' => '蜘蛛池MD Gitee Token',
|
||||
'secret' => true,
|
||||
],
|
||||
'SPIDER_MD_RETENTION_DAYS' => [
|
||||
'default' => '10',
|
||||
'description' => '蜘蛛池MD生成记录保留天数(默认 10 天)',
|
||||
'secret' => false,
|
||||
],
|
||||
];
|
||||
|
||||
public static function ensureSystemConfigDefaults(): void
|
||||
{
|
||||
$changed = false;
|
||||
|
||||
foreach (self::DEFINITIONS as $code => $definition) {
|
||||
$exists = SystemConfigModel::where('sc_code', $code)->find();
|
||||
if ($exists !== null) {
|
||||
$expectedDescription = (string)($definition['description'] ?? $code);
|
||||
if ((string)($exists->sc_description ?? '') !== $expectedDescription) {
|
||||
$exists->sc_description = $expectedDescription;
|
||||
$exists->save();
|
||||
$changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$model = new SystemConfigModel();
|
||||
$model->save([
|
||||
'sc_code' => $code,
|
||||
'sc_val' => (string)($definition['default'] ?? ''),
|
||||
'sc_description' => (string)($definition['description'] ?? $code),
|
||||
'site_id' => 0,
|
||||
]);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
SystemConfigModel::flushCache();
|
||||
}
|
||||
}
|
||||
|
||||
public static function resolveRuntimeConfig(array $overrides = []): array
|
||||
{
|
||||
self::ensureSystemConfigDefaults();
|
||||
|
||||
return [
|
||||
'max_links_per_file' => max(100, min(5000, (int)self::firstNonEmptyValue(
|
||||
$overrides['max_links_per_file'] ?? null,
|
||||
self::readOperationalValue('SPIDER_MD_MAX_LINKS_PER_FILE')
|
||||
))),
|
||||
'category_link_limit_per_host' => max(0, min(500, (int)self::firstNonEmptyValue(
|
||||
$overrides['category_link_limit_per_host'] ?? null,
|
||||
self::readOperationalValue('SPIDER_MD_CATEGORY_LINK_LIMIT_PER_HOST')
|
||||
))),
|
||||
'search_link_limit_per_host' => max(0, min(100, (int)self::firstNonEmptyValue(
|
||||
$overrides['search_link_limit_per_host'] ?? null,
|
||||
self::readOperationalValue('SPIDER_MD_SEARCH_LINK_LIMIT_PER_HOST')
|
||||
))),
|
||||
'detail_limit_per_host' => max(0, min(200000, (int)self::firstNonEmptyValue(
|
||||
$overrides['detail_limit_per_host'] ?? null,
|
||||
self::readOperationalValue('SPIDER_MD_DETAIL_LINK_LIMIT_PER_HOST')
|
||||
))),
|
||||
'forge_limit_per_host' => max(0, min(200000, (int)self::firstNonEmptyValue(
|
||||
$overrides['forge_limit_per_host'] ?? null,
|
||||
self::readOperationalValue('SPIDER_MD_FORGE_LINK_LIMIT_PER_HOST')
|
||||
))),
|
||||
'retention_days' => max(1, min(90, (int)self::firstNonEmptyValue(
|
||||
$overrides['retention_days'] ?? null,
|
||||
self::readOperationalValue('SPIDER_MD_RETENTION_DAYS')
|
||||
))),
|
||||
];
|
||||
}
|
||||
|
||||
public static function resolveGiteeConfig(): array
|
||||
{
|
||||
self::ensureSystemConfigDefaults();
|
||||
|
||||
$enabledMeta = self::readValueMeta('SPIDER_MD_GITEE_ENABLED');
|
||||
$ownerMeta = self::readValueMeta('SPIDER_MD_GITEE_OWNER');
|
||||
$repoMeta = self::readValueMeta('SPIDER_MD_GITEE_REPO');
|
||||
$branchMeta = self::readValueMeta('SPIDER_MD_GITEE_BRANCH');
|
||||
$rootMeta = self::readValueMeta('SPIDER_MD_GITEE_ROOT');
|
||||
$tokenMeta = self::readValueMeta('SPIDER_MD_GITEE_TOKEN');
|
||||
|
||||
$enabled = (int)($enabledMeta['value'] ?? '0') === 1;
|
||||
$owner = trim((string)($ownerMeta['value'] ?? ''));
|
||||
$repo = trim((string)($repoMeta['value'] ?? ''));
|
||||
$branch = trim((string)($branchMeta['value'] ?? 'master'));
|
||||
$root = trim((string)($rootMeta['value'] ?? 'seo-spider-md'));
|
||||
$token = trim((string)($tokenMeta['value'] ?? ''));
|
||||
|
||||
return [
|
||||
'enabled' => $enabled ? 1 : 0,
|
||||
'configured' => ($enabled && $owner !== '' && $repo !== '' && $token !== '') ? 1 : 0,
|
||||
'owner' => $owner,
|
||||
'repo' => $repo,
|
||||
'branch' => $branch !== '' ? $branch : 'master',
|
||||
'root' => $root,
|
||||
'token' => $token,
|
||||
'sources' => [
|
||||
'enabled' => (string)($enabledMeta['source'] ?? 'env'),
|
||||
'owner' => (string)($ownerMeta['source'] ?? 'env'),
|
||||
'repo' => (string)($repoMeta['source'] ?? 'env'),
|
||||
'branch' => (string)($branchMeta['source'] ?? 'env'),
|
||||
'root' => (string)($rootMeta['source'] ?? 'env'),
|
||||
'token' => (string)($tokenMeta['source'] ?? 'env'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildConfigSummary(): array
|
||||
{
|
||||
self::ensureSystemConfigDefaults();
|
||||
|
||||
$items = [];
|
||||
foreach (self::DEFINITIONS as $code => $definition) {
|
||||
$meta = self::readValueMeta($code);
|
||||
$items[] = [
|
||||
'code' => $code,
|
||||
'description' => (string)($definition['description'] ?? $code),
|
||||
'value' => !empty($definition['secret'])
|
||||
? ((string)($meta['value'] ?? '') !== '' ? '***' : '')
|
||||
: (string)($meta['value'] ?? ''),
|
||||
'source' => (string)($meta['source'] ?? 'env'),
|
||||
'secret' => !empty($definition['secret']) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'editable_in_backend_codes' => array_values(array_keys(array_filter(self::DEFINITIONS, static function (array $definition): bool {
|
||||
return empty($definition['secret']);
|
||||
}))),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function readOperationalValue(string $code): string
|
||||
{
|
||||
return (string)(self::readValueMeta($code)['value'] ?? '');
|
||||
}
|
||||
|
||||
protected static function readValueMeta(string $code): array
|
||||
{
|
||||
$definition = self::DEFINITIONS[$code] ?? ['default' => '', 'secret' => false];
|
||||
$default = (string)($definition['default'] ?? '');
|
||||
$scValue = SystemConfigModel::getValByCode($code);
|
||||
if ($scValue !== null && $scValue !== '') {
|
||||
return ['value' => (string)$scValue, 'source' => 'system_config'];
|
||||
}
|
||||
|
||||
$envValue = self::readEnv($code, '');
|
||||
if ($envValue !== '') {
|
||||
return ['value' => $envValue, 'source' => 'env'];
|
||||
}
|
||||
|
||||
return ['value' => $default, 'source' => 'default'];
|
||||
}
|
||||
|
||||
protected static function firstNonEmptyValue($first, string $fallback): string
|
||||
{
|
||||
if ($first !== null && $first !== '') {
|
||||
return (string)$first;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
protected static function readEnv(string $key, string $default = ''): string
|
||||
{
|
||||
try {
|
||||
if (function_exists('env')) {
|
||||
$value = env($key, null);
|
||||
if ($value !== null && $value !== '') {
|
||||
return is_scalar($value) ? (string)$value : $default;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
foreach ([$_SERVER[$key] ?? null, $_ENV[$key] ?? null, getenv($key)] as $value) {
|
||||
if ($value !== false && $value !== null && $value !== '') {
|
||||
return (string)$value;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user