From 0794c903d76e54ab143530a6e0c7d9f36e11be10 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Apr 2026 14:00:19 +0800 Subject: [PATCH] restore bootstrap and seo helper chain after rebase drop --- code/app/admin/config/router.php | 254 +- code/app/admin/controller/SeoSupply.php | 390 ++ code/app/admin/controller/Site.php | 4245 +++++++++++++++-- code/app/api/config/router.php | 70 +- code/app/api/controller/SeoCollector.php | 304 ++ .../DomainBatchImportTemplateHelper.php | 213 + .../helper/DomainBootstrapApplyHelper.php | 132 + .../DomainBootstrapBatchTemplateHelper.php | 216 + .../DomainBootstrapBundleIndexHelper.php | 1102 +++++ .../helper/DomainBootstrapCompletedHelper.php | 169 + .../helper/DomainBootstrapDutyHelper.php | 225 + .../DomainBootstrapEnvTemplateHelper.php | 821 ++++ .../helper/DomainBootstrapExecutionHelper.php | 235 + .../helper/DomainBootstrapHandoffHelper.php | 189 + .../helper/DomainBootstrapImportHelper.php | 83 + .../helper/DomainBootstrapOncallHelper.php | 190 + .../helper/DomainBootstrapOncallService.php | 488 ++ .../helper/DomainBootstrapPipelineHelper.php | 137 + .../helper/DomainBootstrapPublishHelper.php | 72 + .../helper/DomainBootstrapRegisterHelper.php | 185 + .../helper/DomainBootstrapRunHelper.php | 150 + .../helper/DomainBootstrapRunIndexHelper.php | 445 ++ .../helper/DomainBootstrapRunService.php | 183 + ...alSeoAizhanKeywordAttentionQueueHelper.php | 203 + ...ainExternalSeoAizhanKeywordProbeHelper.php | 298 ++ ...ainExternalSeoAizhanKeywordTrendHelper.php | 177 + ...nExternalSeoBaiduFeedbackSummaryHelper.php | 240 + ...mainExternalSeoBaiduProviderPlanHelper.php | 109 + ...ternalSeoBaiduPushAttentionQueueHelper.php | 217 + ...omainExternalSeoBaiduPushSummaryHelper.php | 187 + ...inExternalSeoBaiduPushTaskStatusHelper.php | 44 + .../DomainExternalSeoBaiduPushTrendHelper.php | 174 + .../DomainExternalSeoCnOverviewHelper.php | 153 + ...DomainExternalSeoCollectorStatusHelper.php | 105 + .../DomainExternalSeoFailedQueueHelper.php | 59 + ...inExternalSeoIntegrationOverviewHelper.php | 45 + .../DomainExternalSeoManualImportHelper.php | 377 ++ ...nExternalSeoManualImportRunIndexHelper.php | 167 + ...xternalSeoOptimizationSuggestionHelper.php | 161 + ...omainExternalSeoProviderRegistryHelper.php | 79 + .../DomainExternalSeoRealSummaryHelper.php | 327 ++ ...inExternalSeoSearchConsoleConfigHelper.php | 123 + ...ainExternalSeoSearchConsoleFetchHelper.php | 200 + ...rnalSeoSearchConsoleProviderPlanHelper.php | 152 + .../DomainExternalSeoSiteQueryProbeHelper.php | 283 ++ ...omainExternalSeoSnapshotAnalysisHelper.php | 474 ++ .../DomainExternalSeoSnapshotHelper.php | 268 ++ .../DomainExternalSeoSnapshotRunHelper.php | 113 + ...omainExternalSeoSnapshotRunIndexHelper.php | 132 + .../DomainExternalSeoSnapshotSchemaHelper.php | 191 + .../DomainExternalSeoSnapshotTrendHelper.php | 127 + ...inExternalSeoSnapshotValidateRunHelper.php | 89 + ...ernalSeoSnapshotValidateRunIndexHelper.php | 116 + .../helper/DomainExternalSeoSummaryHelper.php | 303 ++ .../helper/DomainExternalSeoTrendHelper.php | 140 + ...inImportExternalSeoClosureActionHelper.php | 211 + ...ortExternalSeoClosureActionIndexHelper.php | 201 + .../DomainImportExternalSeoClosureHelper.php | 162 + ...ortExternalSeoClosureHostHistoryHelper.php | 60 + ...ainImportExternalSeoClosureTodayHelper.php | 72 + .../helper/DomainImportFailedQueueHelper.php | 267 ++ .../helper/DomainImportHealthTrendHelper.php | 96 + .../DomainImportHealthWorkbenchHelper.php | 355 ++ ...ImportManualAttentionHandleIndexHelper.php | 64 + ...DomainImportManualAttentionQueueHelper.php | 216 + .../helper/DomainImportProbeRunHelper.php | 133 + .../DomainImportRemediationIndexHelper.php | 70 + .../helper/DomainImportRerunIndexHelper.php | 69 + .../helper/DomainImportRunIndexHelper.php | 86 + .../DomainImportSelfHealingIndexHelper.php | 73 + .../helper/DomainSpiderCrawlLogHelper.php | 158 + .../helper/DomainSpiderCrawlLogRunHelper.php | 87 + .../DomainSpiderCrawlLogSchemaHelper.php | 237 + .../DomainSpiderCrawlWorkbenchHelper.php | 1306 +++++ .../common/helper/DomainSpiderMdRunHelper.php | 1119 +++++ .../common/helper/DomainSupplyBatchHelper.php | 505 ++ .../DomainSupplyBatchTemplateHelper.php | 182 + .../helper/DomainSupplyRunIndexHelper.php | 2817 +++++++++++ .../common/helper/SeoCopyAiProviderHelper.php | 1404 ++++++ .../helper/SeoCopyApprovedManifestHelper.php | 322 ++ .../helper/SeoCopyBatchImportHelper.php | 15 + .../helper/SeoCopyBatchPrepareHelper.php | 307 ++ .../common/helper/SeoCopyContextHelper.php | 300 ++ .../app/common/helper/SeoCopyFactsBuilder.php | 385 ++ .../common/helper/SeoCopyFallbackBuilder.php | 216 + .../common/helper/SeoCopyGenerationHelper.php | 256 + .../common/helper/SeoCopyPortalHomeHelper.php | 1226 +++++ .../helper/SeoCopyReleaseIndexHelper.php | 161 + .../helper/SeoCopyReleasePortalHelper.php | 285 ++ .../common/helper/SeoCopyReleaseRunHelper.php | 24 + .../helper/SeoCopyReleaseRunIndexHelper.php | 260 + .../helper/SeoCopyReleaseViewHelper.php | 202 + .../helper/SeoCopyRuntimeAcceptanceHelper.php | 889 ++++ .../SeoCopyRuntimeAcceptanceIndexHelper.php | 543 +++ code/app/common/helper/SeoCopySchema.php | 323 ++ code/app/common/helper/SeoCopyStore.php | 247 + .../helper/SeoCopyTaskRunPortalHelper.php | 461 ++ .../common/helper/SeoCopyTaskStateHelper.php | 61 + .../common/helper/SeoResourcePoolHelper.php | 637 +++ .../common/helper/SpiderMdConfigHelper.php | 251 + code/app/model/DomainModel.php | 1229 ++++- code/app/task/logic/SeoCopyTaskLogic.php | 1078 +++++ code/scripts/_bootstrap_cli_app.php | 12 + .../domain_bootstrap_batch_template.php | 38 + .../scripts/domain_bootstrap_bundle_index.php | 79 + code/scripts/domain_bootstrap_env_apply.php | 94 + code/scripts/domain_bootstrap_import_ai.php | 99 + code/scripts/domain_bootstrap_oncall_run.php | 196 + .../scripts/domain_bootstrap_pipeline_run.php | 190 + code/scripts/domain_bootstrap_publish.php | 90 + code/scripts/domain_bootstrap_register.php | 116 + code/scripts/domain_bootstrap_run.php | 262 + code/scripts/domain_bootstrap_run_index.php | 79 + code/scripts/domain_seo_bootstrap_apply.php | 124 + code/scripts/seo_copy_approved_index.php | 77 + code/scripts/seo_copy_batch_front_verify.php | 339 ++ code/scripts/seo_copy_batch_import.php | 252 + code/scripts/seo_copy_batch_prepare.php | 77 + code/scripts/seo_copy_batch_publish.php | 404 ++ code/scripts/seo_copy_batch_verify.php | 195 + code/scripts/seo_copy_domain_bootstrap.php | 769 +++ .../seo_copy_domain_bootstrap_batch.php | 306 ++ code/scripts/seo_copy_domain_supply_batch.php | 99 + ...o_copy_domain_supply_orchestration_run.php | 85 + code/scripts/seo_copy_facts.php | 49 + .../seo_copy_forge_output_file_verify.php | 288 ++ code/scripts/seo_copy_forge_output_verify.php | 290 ++ .../seo_copy_forge_route_group_verify.php | 365 ++ code/scripts/seo_copy_front_verify.php | 280 ++ code/scripts/seo_copy_generation_prepare.php | 26 + code/scripts/seo_copy_generation_status.php | 23 + code/scripts/seo_copy_play_route_verify.php | 304 ++ code/scripts/seo_copy_portal_home.php | 51 + code/scripts/seo_copy_prompt.php | 154 + code/scripts/seo_copy_readback.php | 158 + code/scripts/seo_copy_release_index.php | 65 + code/scripts/seo_copy_release_portal.php | 62 + code/scripts/seo_copy_release_rollback.php | 226 + code/scripts/seo_copy_release_run.php | 1172 +++++ code/scripts/seo_copy_release_run_index.php | 61 + ...py_runtime_acceptance_failed_hosts_run.php | 85 + .../seo_copy_runtime_acceptance_forge_run.php | 124 + .../seo_copy_runtime_acceptance_host_run.php | 85 + .../seo_copy_runtime_acceptance_run.php | 81 + .../seo_copy_runtime_acceptance_verify.php | 217 + .../seo_copy_runtime_governance_run.php | 416 ++ code/scripts/seo_copy_scaffold.php | 100 + 147 files changed, 43977 insertions(+), 646 deletions(-) create mode 100644 code/app/admin/controller/SeoSupply.php create mode 100644 code/app/api/controller/SeoCollector.php create mode 100644 code/app/common/helper/DomainBatchImportTemplateHelper.php create mode 100644 code/app/common/helper/DomainBootstrapApplyHelper.php create mode 100644 code/app/common/helper/DomainBootstrapBatchTemplateHelper.php create mode 100644 code/app/common/helper/DomainBootstrapBundleIndexHelper.php create mode 100644 code/app/common/helper/DomainBootstrapCompletedHelper.php create mode 100644 code/app/common/helper/DomainBootstrapDutyHelper.php create mode 100644 code/app/common/helper/DomainBootstrapEnvTemplateHelper.php create mode 100644 code/app/common/helper/DomainBootstrapExecutionHelper.php create mode 100644 code/app/common/helper/DomainBootstrapHandoffHelper.php create mode 100644 code/app/common/helper/DomainBootstrapImportHelper.php create mode 100644 code/app/common/helper/DomainBootstrapOncallHelper.php create mode 100644 code/app/common/helper/DomainBootstrapOncallService.php create mode 100644 code/app/common/helper/DomainBootstrapPipelineHelper.php create mode 100644 code/app/common/helper/DomainBootstrapPublishHelper.php create mode 100644 code/app/common/helper/DomainBootstrapRegisterHelper.php create mode 100644 code/app/common/helper/DomainBootstrapRunHelper.php create mode 100644 code/app/common/helper/DomainBootstrapRunIndexHelper.php create mode 100644 code/app/common/helper/DomainBootstrapRunService.php create mode 100644 code/app/common/helper/DomainExternalSeoAizhanKeywordAttentionQueueHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoAizhanKeywordProbeHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoAizhanKeywordTrendHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoBaiduFeedbackSummaryHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoBaiduProviderPlanHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoBaiduPushAttentionQueueHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoBaiduPushSummaryHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoBaiduPushTaskStatusHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoBaiduPushTrendHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoCnOverviewHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoCollectorStatusHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoFailedQueueHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoIntegrationOverviewHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoManualImportHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoManualImportRunIndexHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoOptimizationSuggestionHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoProviderRegistryHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoRealSummaryHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSearchConsoleConfigHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSearchConsoleFetchHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSearchConsoleProviderPlanHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSiteQueryProbeHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSnapshotAnalysisHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSnapshotHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSnapshotRunHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSnapshotRunIndexHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSnapshotSchemaHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSnapshotTrendHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSnapshotValidateRunHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSnapshotValidateRunIndexHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoSummaryHelper.php create mode 100644 code/app/common/helper/DomainExternalSeoTrendHelper.php create mode 100644 code/app/common/helper/DomainImportExternalSeoClosureActionHelper.php create mode 100644 code/app/common/helper/DomainImportExternalSeoClosureActionIndexHelper.php create mode 100644 code/app/common/helper/DomainImportExternalSeoClosureHelper.php create mode 100644 code/app/common/helper/DomainImportExternalSeoClosureHostHistoryHelper.php create mode 100644 code/app/common/helper/DomainImportExternalSeoClosureTodayHelper.php create mode 100644 code/app/common/helper/DomainImportFailedQueueHelper.php create mode 100644 code/app/common/helper/DomainImportHealthTrendHelper.php create mode 100644 code/app/common/helper/DomainImportHealthWorkbenchHelper.php create mode 100644 code/app/common/helper/DomainImportManualAttentionHandleIndexHelper.php create mode 100644 code/app/common/helper/DomainImportManualAttentionQueueHelper.php create mode 100644 code/app/common/helper/DomainImportProbeRunHelper.php create mode 100644 code/app/common/helper/DomainImportRemediationIndexHelper.php create mode 100644 code/app/common/helper/DomainImportRerunIndexHelper.php create mode 100644 code/app/common/helper/DomainImportRunIndexHelper.php create mode 100644 code/app/common/helper/DomainImportSelfHealingIndexHelper.php create mode 100644 code/app/common/helper/DomainSpiderCrawlLogHelper.php create mode 100644 code/app/common/helper/DomainSpiderCrawlLogRunHelper.php create mode 100644 code/app/common/helper/DomainSpiderCrawlLogSchemaHelper.php create mode 100644 code/app/common/helper/DomainSpiderCrawlWorkbenchHelper.php create mode 100644 code/app/common/helper/DomainSpiderMdRunHelper.php create mode 100644 code/app/common/helper/DomainSupplyBatchHelper.php create mode 100644 code/app/common/helper/DomainSupplyBatchTemplateHelper.php create mode 100644 code/app/common/helper/DomainSupplyRunIndexHelper.php create mode 100644 code/app/common/helper/SeoCopyAiProviderHelper.php create mode 100644 code/app/common/helper/SeoCopyApprovedManifestHelper.php create mode 100644 code/app/common/helper/SeoCopyBatchImportHelper.php create mode 100644 code/app/common/helper/SeoCopyBatchPrepareHelper.php create mode 100644 code/app/common/helper/SeoCopyContextHelper.php create mode 100644 code/app/common/helper/SeoCopyFactsBuilder.php create mode 100644 code/app/common/helper/SeoCopyFallbackBuilder.php create mode 100644 code/app/common/helper/SeoCopyGenerationHelper.php create mode 100644 code/app/common/helper/SeoCopyPortalHomeHelper.php create mode 100644 code/app/common/helper/SeoCopyReleaseIndexHelper.php create mode 100644 code/app/common/helper/SeoCopyReleasePortalHelper.php create mode 100644 code/app/common/helper/SeoCopyReleaseRunHelper.php create mode 100644 code/app/common/helper/SeoCopyReleaseRunIndexHelper.php create mode 100644 code/app/common/helper/SeoCopyReleaseViewHelper.php create mode 100644 code/app/common/helper/SeoCopyRuntimeAcceptanceHelper.php create mode 100644 code/app/common/helper/SeoCopyRuntimeAcceptanceIndexHelper.php create mode 100644 code/app/common/helper/SeoCopySchema.php create mode 100644 code/app/common/helper/SeoCopyStore.php create mode 100644 code/app/common/helper/SeoCopyTaskRunPortalHelper.php create mode 100644 code/app/common/helper/SeoCopyTaskStateHelper.php create mode 100644 code/app/common/helper/SeoResourcePoolHelper.php create mode 100644 code/app/common/helper/SpiderMdConfigHelper.php create mode 100644 code/app/task/logic/SeoCopyTaskLogic.php create mode 100644 code/scripts/_bootstrap_cli_app.php create mode 100644 code/scripts/domain_bootstrap_batch_template.php create mode 100644 code/scripts/domain_bootstrap_bundle_index.php create mode 100644 code/scripts/domain_bootstrap_env_apply.php create mode 100644 code/scripts/domain_bootstrap_import_ai.php create mode 100644 code/scripts/domain_bootstrap_oncall_run.php create mode 100644 code/scripts/domain_bootstrap_pipeline_run.php create mode 100644 code/scripts/domain_bootstrap_publish.php create mode 100644 code/scripts/domain_bootstrap_register.php create mode 100644 code/scripts/domain_bootstrap_run.php create mode 100644 code/scripts/domain_bootstrap_run_index.php create mode 100644 code/scripts/domain_seo_bootstrap_apply.php create mode 100644 code/scripts/seo_copy_approved_index.php create mode 100644 code/scripts/seo_copy_batch_front_verify.php create mode 100644 code/scripts/seo_copy_batch_import.php create mode 100644 code/scripts/seo_copy_batch_prepare.php create mode 100644 code/scripts/seo_copy_batch_publish.php create mode 100644 code/scripts/seo_copy_batch_verify.php create mode 100644 code/scripts/seo_copy_domain_bootstrap.php create mode 100644 code/scripts/seo_copy_domain_bootstrap_batch.php create mode 100644 code/scripts/seo_copy_domain_supply_batch.php create mode 100644 code/scripts/seo_copy_domain_supply_orchestration_run.php create mode 100644 code/scripts/seo_copy_facts.php create mode 100644 code/scripts/seo_copy_forge_output_file_verify.php create mode 100644 code/scripts/seo_copy_forge_output_verify.php create mode 100644 code/scripts/seo_copy_forge_route_group_verify.php create mode 100644 code/scripts/seo_copy_front_verify.php create mode 100644 code/scripts/seo_copy_generation_prepare.php create mode 100644 code/scripts/seo_copy_generation_status.php create mode 100644 code/scripts/seo_copy_play_route_verify.php create mode 100644 code/scripts/seo_copy_portal_home.php create mode 100644 code/scripts/seo_copy_prompt.php create mode 100644 code/scripts/seo_copy_readback.php create mode 100644 code/scripts/seo_copy_release_index.php create mode 100644 code/scripts/seo_copy_release_portal.php create mode 100644 code/scripts/seo_copy_release_rollback.php create mode 100644 code/scripts/seo_copy_release_run.php create mode 100644 code/scripts/seo_copy_release_run_index.php create mode 100644 code/scripts/seo_copy_runtime_acceptance_failed_hosts_run.php create mode 100644 code/scripts/seo_copy_runtime_acceptance_forge_run.php create mode 100644 code/scripts/seo_copy_runtime_acceptance_host_run.php create mode 100644 code/scripts/seo_copy_runtime_acceptance_run.php create mode 100644 code/scripts/seo_copy_runtime_acceptance_verify.php create mode 100644 code/scripts/seo_copy_runtime_governance_run.php create mode 100644 code/scripts/seo_copy_scaffold.php diff --git a/code/app/admin/config/router.php b/code/app/admin/config/router.php index 510f375..7fd1a7e 100644 --- a/code/app/admin/config/router.php +++ b/code/app/admin/config/router.php @@ -1,91 +1,193 @@ -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); diff --git a/code/app/admin/controller/SeoSupply.php b/code/app/admin/controller/SeoSupply.php new file mode 100644 index 0000000..3d71ad7 --- /dev/null +++ b/code/app/admin/controller/SeoSupply.php @@ -0,0 +1,390 @@ +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; + } +} diff --git a/code/app/admin/controller/Site.php b/code/app/admin/controller/Site.php index 3f00ab6..72caca9 100644 --- a/code/app/admin/controller/Site.php +++ b/code/app/admin/controller/Site.php @@ -1,172 +1,3167 @@ - $arrStep) { + if (!is_array($arrStep)) { + continue; + } + $strLabel = trim((string)($arrStep['label'] ?? '')); + if ($strLabel === '') { + continue; + } + $arrExecutionSequence[] = [ + 'step_key' => (string)($arrStep['step_key'] ?? ('execution_sequence_' . ($index + 1))), + 'label' => $strLabel, + 'is_current' => !empty($arrStep['is_current']), + 'is_next' => !empty($arrStep['is_next']), + ]; + } - /** - * upload site domain info file - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ - public function getTKDArgList(Request $Request, ?AdminUserModel $AdminUserModel) - { - return $this->success(ConverterMovel::$arrDescription); + $arrHandoffChain = []; + foreach ((array)($arrRunbook['steps'] ?? $arrExecutionChain['steps'] ?? []) as $index => $arrStep) { + if (!is_array($arrStep)) { + continue; + } + $strLabel = trim((string)($arrStep['label'] ?? '')); + if ($strLabel === '') { + continue; + } + $arrHandoffChain[] = [ + 'step_key' => (string)($arrStep['step_key'] ?? ('handoff_chain_' . ($index + 1))), + 'label' => $strLabel, + 'is_current' => !empty($arrStep['is_current']), + 'is_next' => !empty($arrStep['is_next']), + ]; + } + + $strCurrentStageLabel = ''; + $strNextStageLabel = ''; + foreach ($arrExecutionSequence as $arrStep) { + if ($strCurrentStageLabel === '' && !empty($arrStep['is_current'])) { + $strCurrentStageLabel = (string)($arrStep['label'] ?? ''); + } + if ($strNextStageLabel === '' && !empty($arrStep['is_next'])) { + $strNextStageLabel = (string)($arrStep['label'] ?? ''); + } + } + if ($strCurrentStageLabel === '') { + $strCurrentStageLabel = (string)($arrScheduler['label'] ?? $arrDispatch['label'] ?? ''); + } + if ($strNextStageLabel === '') { + $strNextStageLabel = (string)($arrScheduler['next_action_label'] ?? $arrDispatch['next_action_label'] ?? $arrRunbook['next_action_label'] ?? ''); + } + $strFinalStageLabel = ''; + if (!empty($arrHandoffChain)) { + $arrLastStep = $arrHandoffChain[count($arrHandoffChain) - 1]; + $strFinalStageLabel = (string)($arrLastStep['label'] ?? ''); + } elseif (!empty($arrExecutionSequence)) { + $arrLastStep = $arrExecutionSequence[count($arrExecutionSequence) - 1]; + $strFinalStageLabel = (string)($arrLastStep['label'] ?? ''); + } + + $strPrimaryAction = (string)($arrScheduler['primary_action'] ?? $arrDispatch['primary_action'] ?? $arrRunbook['primary_action'] ?? ''); + $strPrimaryActionLabel = (string)($arrScheduler['primary_action_label'] ?? $arrDispatch['primary_action_label'] ?? $arrRunbook['primary_action_label'] ?? '执行当前控制动作'); + $strSecondaryAction = (string)($arrScheduler['secondary_action'] ?? $arrDispatch['secondary_action'] ?? $arrRunbook['secondary_action'] ?? ''); + $strSecondaryActionLabel = (string)($arrScheduler['secondary_action_label'] ?? $arrDispatch['secondary_action_label'] ?? $arrRunbook['secondary_action_label'] ?? '执行下一控制动作'); + $strLeadHost = (string)($arrScheduler['lead_host'] ?? $arrDispatch['lead_host'] ?? $arrRunbook['lead_host'] ?? $arrExecutionChain['lead_host'] ?? ''); + $strEntrySummary = trim(implode(',', array_filter([ + $strCurrentStageLabel !== '' ? '当前阶段 ' . $strCurrentStageLabel : '', + $strNextStageLabel !== '' ? '下一阶段 ' . $strNextStageLabel : '', + $strLeadHost !== '' ? 'Lead ' . $strLeadHost : '', + ]))); + $arrActionOrder = []; + if ($strPrimaryAction !== '') { + $arrActionOrder[] = [ + 'action_key' => 'primary', + 'label' => $strPrimaryActionLabel, + 'action' => $strPrimaryAction, + 'host' => $strLeadHost, + 'is_current' => true, + 'is_next' => false, + ]; + } + if ($strSecondaryAction !== '') { + $arrActionOrder[] = [ + 'action_key' => 'secondary', + 'label' => $strSecondaryActionLabel, + 'action' => $strSecondaryAction, + 'host' => $strLeadHost, + 'is_current' => false, + 'is_next' => true, + ]; + } + if ($strLeadHost !== '') { + $arrActionOrder[] = [ + 'action_key' => 'host', + 'label' => '进入 Lead Host', + 'action' => 'latest-supply-host', + 'host' => $strLeadHost, + 'is_current' => empty($arrActionOrder), + 'is_next' => false, + ]; + } + $arrPriorityOrder = []; + if ($strPrimaryAction !== '') { + $arrPriorityOrder[] = [ + 'priority_key' => 'current_stage', + 'label' => $strCurrentStageLabel !== '' ? $strCurrentStageLabel : '当前阶段', + 'summary' => $strPrimaryActionLabel, + 'action' => $strPrimaryAction, + 'host' => $strLeadHost, + 'is_current' => true, + 'is_next' => false, + ]; + } + if ($strSecondaryAction !== '') { + $arrPriorityOrder[] = [ + 'priority_key' => 'next_stage', + 'label' => $strNextStageLabel !== '' ? $strNextStageLabel : '下一阶段', + 'summary' => $strSecondaryActionLabel, + 'action' => $strSecondaryAction, + 'host' => $strLeadHost, + 'is_current' => false, + 'is_next' => true, + ]; + } + if ($strLeadHost !== '') { + $arrPriorityOrder[] = [ + 'priority_key' => 'final_stage', + 'label' => $strFinalStageLabel !== '' ? $strFinalStageLabel : '最终阶段', + 'summary' => '进入 Lead Host', + 'action' => 'latest-supply-host', + 'host' => $strLeadHost, + 'is_current' => empty($arrPriorityOrder), + 'is_next' => false, + ]; + } + $arrExecutionRank = []; + foreach ($arrPriorityOrder as $index => $arrPriorityStep) { + if (!is_array($arrPriorityStep)) { + continue; + } + $arrExecutionRank[] = [ + 'rank_key' => (string)($arrPriorityStep['priority_key'] ?? ('priority_' . ($index + 1))), + 'lane' => 'priority', + 'label' => (string)($arrPriorityStep['label'] ?? ''), + 'summary' => (string)($arrPriorityStep['summary'] ?? ''), + 'action' => (string)($arrPriorityStep['action'] ?? ''), + 'host' => (string)($arrPriorityStep['host'] ?? ''), + 'is_current' => !empty($arrPriorityStep['is_current']), + 'is_next' => !empty($arrPriorityStep['is_next']), + ]; + } + foreach ($arrActionOrder as $index => $arrActionStep) { + if (!is_array($arrActionStep)) { + continue; + } + $arrExecutionRank[] = [ + 'rank_key' => (string)($arrActionStep['action_key'] ?? ('action_' . ($index + 1))), + 'lane' => 'action', + 'label' => (string)($arrActionStep['label'] ?? ''), + 'summary' => (string)($arrActionStep['label'] ?? ''), + 'action' => (string)($arrActionStep['action'] ?? ''), + 'host' => (string)($arrActionStep['host'] ?? ''), + 'is_current' => !empty($arrActionStep['is_current']) && empty($arrPriorityOrder), + 'is_next' => !empty($arrActionStep['is_next']) && empty($arrPriorityOrder), + ]; + } + $arrExecutionRoute = []; + foreach ($arrExecutionRank as $index => $arrRankStep) { + if (!is_array($arrRankStep)) { + continue; + } + $strLabel = trim((string)($arrRankStep['label'] ?? '')); + if ($strLabel === '') { + continue; + } + $arrExecutionRoute[] = [ + 'route_key' => (string)($arrRankStep['rank_key'] ?? ('route_' . ($index + 1))), + 'label' => $strLabel, + 'summary' => (string)($arrRankStep['summary'] ?? ''), + 'lane' => (string)($arrRankStep['lane'] ?? ''), + 'action' => (string)($arrRankStep['action'] ?? ''), + 'host' => (string)($arrRankStep['host'] ?? ''), + 'is_current' => !empty($arrRankStep['is_current']), + 'is_next' => !empty($arrRankStep['is_next']), + ]; + } + $arrExecutionPath = []; + foreach ($arrExecutionRoute as $index => $arrRouteStep) { + if (!is_array($arrRouteStep)) { + continue; + } + $strLabel = trim((string)($arrRouteStep['label'] ?? '')); + if ($strLabel === '') { + continue; + } + $arrExecutionPath[] = [ + 'path_key' => (string)($arrRouteStep['route_key'] ?? ('path_' . ($index + 1))), + 'label' => $strLabel, + 'summary' => (string)($arrRouteStep['summary'] ?? ''), + 'lane' => (string)($arrRouteStep['lane'] ?? ''), + 'action' => (string)($arrRouteStep['action'] ?? ''), + 'host' => (string)($arrRouteStep['host'] ?? ''), + 'is_current' => !empty($arrRouteStep['is_current']), + 'is_next' => !empty($arrRouteStep['is_next']), + ]; + } + $arrCurrentExecutionPath = []; + $arrNextExecutionPath = []; + foreach ($arrExecutionPath as $arrPathStep) { + if (empty($arrCurrentExecutionPath) && !empty($arrPathStep['is_current'])) { + $arrCurrentExecutionPath = $arrPathStep; + } + if (empty($arrNextExecutionPath) && !empty($arrPathStep['is_next'])) { + $arrNextExecutionPath = $arrPathStep; + } + } + if (empty($arrCurrentExecutionPath) && !empty($arrExecutionPath)) { + $arrCurrentExecutionPath = $arrExecutionPath[0]; + } + if (empty($arrNextExecutionPath) && count($arrExecutionPath) > 1) { + $arrNextExecutionPath = $arrExecutionPath[1]; + } + $strExecutionPathSummary = trim(implode(',', array_filter([ + !empty($arrCurrentExecutionPath['label']) ? '当前路径 ' . (string)$arrCurrentExecutionPath['label'] : '', + !empty($arrNextExecutionPath['label']) ? '下一路径 ' . (string)$arrNextExecutionPath['label'] : '', + $strLeadHost !== '' ? 'Lead ' . $strLeadHost : '', + ]))); + $strResolvedEntryAction = (string)($arrCurrentExecutionPath['action'] ?? $strPrimaryAction); + $strResolvedEntryActionLabel = (string)($arrCurrentExecutionPath['label'] ?? $strPrimaryActionLabel); + $strResolvedEntryHost = (string)($arrCurrentExecutionPath['host'] ?? $strLeadHost); + $strResolvedFollowupAction = (string)($arrNextExecutionPath['action'] ?? $strSecondaryAction); + $strResolvedFollowupActionLabel = (string)($arrNextExecutionPath['label'] ?? $strSecondaryActionLabel); + $strResolvedFollowupHost = (string)($arrNextExecutionPath['host'] ?? $strLeadHost); + $strResolvedEntrySummary = trim(implode(',', array_filter([ + $strResolvedEntryActionLabel !== '' ? '当前入口 ' . $strResolvedEntryActionLabel : '', + $strResolvedFollowupActionLabel !== '' ? '后续入口 ' . $strResolvedFollowupActionLabel : '', + $strResolvedEntryHost !== '' ? 'Lead ' . $strResolvedEntryHost : '', + ]))); + + return [ + 'label' => (string)($arrScheduler['label'] ?? $arrDispatch['label'] ?? $arrRunbook['label'] ?? $arrExecutionChain['label'] ?? $arrExecutionPlan['label'] ?? '当前供给 / 繁殖主链控制对象'), + 'title' => '当前供给 / 繁殖主链控制对象', + 'summary' => (string)($arrScheduler['summary'] ?? $arrDispatch['summary'] ?? $arrRunbook['summary'] ?? $arrExecutionChain['summary'] ?? $arrExecutionPlan['summary'] ?? '当前还没有形成明确的供给 / 繁殖主链控制结论。'), + 'tag_type' => (string)($arrScheduler['tag_type'] ?? $arrDispatch['tag_type'] ?? $arrRunbook['tag_type'] ?? $arrExecutionChain['tag_type'] ?? $arrExecutionPlan['tag_type'] ?? 'warning'), + 'focus_label' => (string)($arrScheduler['focus_label'] ?? $arrDispatch['focus_label'] ?? $arrRunbook['focus_label'] ?? ''), + 'source_label' => '高层主链控制', + 'program_label' => (string)($arrDispatch['label'] ?? $arrRunbook['label'] ?? ''), + 'ready_label' => (string)($arrScheduler['label'] ?? $arrDispatch['label'] ?? ''), + 'stage_kind' => (string)($arrScheduler['dispatch_kind'] ?? $arrDispatch['dispatch_kind'] ?? $arrRunbook['stage_kind'] ?? ''), + 'current_stage_label' => $strCurrentStageLabel, + 'next_stage_label' => $strNextStageLabel, + 'final_stage_label' => $strFinalStageLabel, + 'current_action_label' => (string)($arrScheduler['current_action_label'] ?? $arrDispatch['current_action_label'] ?? $arrRunbook['current_action_label'] ?? $arrExecutionChain['current_action_label'] ?? ''), + 'next_action_label' => (string)($arrScheduler['next_action_label'] ?? $arrDispatch['next_action_label'] ?? $arrRunbook['next_action_label'] ?? $arrExecutionChain['next_action_label'] ?? ''), + 'lead_host' => $strLeadHost, + 'queue_count' => (int)($arrScheduler['queue_count'] ?? $arrDispatch['queue_count'] ?? $arrRunbook['queue_count'] ?? $arrExecutionPlan['queue_count'] ?? 0), + 'blockers_count' => (int)($arrScheduler['blockers_count'] ?? $arrDispatch['blockers_count'] ?? $arrRunbook['blockers_count'] ?? $arrExecutionPlan['blockers_count'] ?? 0), + 'primary_action' => $strPrimaryAction, + 'primary_action_label' => $strPrimaryActionLabel, + 'secondary_action' => $strSecondaryAction, + 'secondary_action_label' => $strSecondaryActionLabel, + 'entry_title' => '统一执行入口', + 'entry_summary' => $strEntrySummary !== '' ? $strEntrySummary : '当前还没有形成明确的执行入口。', + 'entry_action' => $strPrimaryAction, + 'entry_action_label' => $strPrimaryActionLabel, + 'entry_host' => $strLeadHost, + 'followup_action' => $strSecondaryAction, + 'followup_action_label' => $strSecondaryActionLabel, + 'resolved_entry_summary' => $strResolvedEntrySummary !== '' ? $strResolvedEntrySummary : ($strExecutionPathSummary !== '' ? $strExecutionPathSummary : $strEntrySummary), + 'resolved_entry_action' => $strResolvedEntryAction, + 'resolved_entry_action_label' => $strResolvedEntryActionLabel, + 'resolved_entry_host' => $strResolvedEntryHost, + 'resolved_followup_action' => $strResolvedFollowupAction, + 'resolved_followup_action_label' => $strResolvedFollowupActionLabel, + 'resolved_followup_host' => $strResolvedFollowupHost, + 'resolved_current_action_label' => $strResolvedEntryActionLabel, + 'resolved_next_action_label' => $strResolvedFollowupActionLabel, + 'action_order_title' => '统一动作顺序', + 'action_order' => $arrActionOrder, + 'action_order_count' => count($arrActionOrder), + 'priority_order_title' => '统一入口优先级', + 'priority_order' => $arrPriorityOrder, + 'priority_order_count' => count($arrPriorityOrder), + 'execution_rank_title' => '高层执行顺位', + 'execution_rank' => $arrExecutionRank, + 'execution_rank_count' => count($arrExecutionRank), + 'execution_route_title' => '高层执行链路', + 'execution_route' => $arrExecutionRoute, + 'execution_route_count' => count($arrExecutionRoute), + 'execution_path_title' => '高层执行路径', + 'execution_path' => $arrExecutionPath, + 'execution_path_count' => count($arrExecutionPath), + 'execution_path_summary' => $strExecutionPathSummary !== '' ? $strExecutionPathSummary : '当前还没有形成明确的高层执行路径。', + 'execution_path_current_label' => (string)($arrCurrentExecutionPath['label'] ?? ''), + 'execution_path_current_action' => (string)($arrCurrentExecutionPath['action'] ?? ''), + 'execution_path_current_host' => (string)($arrCurrentExecutionPath['host'] ?? ''), + 'execution_path_next_label' => (string)($arrNextExecutionPath['label'] ?? ''), + 'execution_path_next_action' => (string)($arrNextExecutionPath['action'] ?? ''), + 'execution_path_next_host' => (string)($arrNextExecutionPath['host'] ?? ''), + 'steps' => $arrExecutionSequence, + 'steps_count' => count($arrExecutionSequence), + 'execution_sequence_title' => '统一执行顺序', + 'execution_sequence' => $arrExecutionSequence, + 'execution_sequence_count' => count($arrExecutionSequence), + 'handoff_chain_title' => '统一承接链', + 'handoff_chain' => $arrHandoffChain, + 'handoff_chain_count' => count($arrHandoffChain), + 'meta' => array_values(array_unique(array_filter(array_map('strval', array_merge( + (array)($arrScheduler['meta'] ?? []), + ['Controller 高层主链', 'Execution Sequence ' . count($arrExecutionSequence), 'Handoff Chain ' . count($arrHandoffChain)] + ))))), + ]; } + protected function buildBreedingMainlineScheduler( + array $arrDispatch, + array $arrRunbook, + array $arrExecutionChain, + array $arrExecutionPlan + ): array { + $strSchedulerKind = 'direct_scheduler'; + $strSchedulerLabel = '直接按主链调度推进'; + $strNextLaneLabel = (string)($arrDispatch['next_action_label'] ?? $arrRunbook['next_action_label'] ?? $arrExecutionChain['next_action_label'] ?? ''); + if (str_contains((string)($arrDispatch['dispatch_kind'] ?? ''), 'runtime')) { + $strSchedulerKind = 'runtime_scheduler'; + $strSchedulerLabel = '先过运行时调度器'; + } elseif (str_contains((string)($arrDispatch['dispatch_kind'] ?? ''), 'oncall')) { + $strSchedulerKind = 'oncall_scheduler'; + $strSchedulerLabel = '先走 Oncall 调度器'; + } elseif (str_contains((string)($arrDispatch['dispatch_kind'] ?? ''), 'ops')) { + $strSchedulerKind = 'ops_scheduler'; + $strSchedulerLabel = '直接落到 Ops 调度器'; + } elseif (str_contains((string)($arrDispatch['dispatch_kind'] ?? ''), 'host')) { + $strSchedulerKind = 'host_scheduler'; + $strSchedulerLabel = '先接 Host 再落 Ops'; + } + + $arrSteps = []; + foreach ((array)($arrDispatch['steps'] ?? $arrRunbook['steps'] ?? $arrExecutionChain['steps'] ?? []) as $index => $arrStep) { + if (!is_array($arrStep)) { + continue; + } + $strLabel = trim((string)($arrStep['label'] ?? '')); + if ($strLabel === '') { + continue; + } + $arrSteps[] = [ + 'step_key' => (string)($arrStep['step_key'] ?? ('scheduler_step_' . ($index + 1))), + 'label' => $strLabel, + 'is_current' => !empty($arrStep['is_current']), + 'is_next' => !empty($arrStep['is_next']), + ]; + } + + return [ + 'label' => $strSchedulerLabel, + 'title' => '当前供给 / 繁殖主链调度器', + 'summary' => (string)($arrDispatch['summary'] ?? $arrRunbook['summary'] ?? $arrExecutionChain['summary'] ?? $arrExecutionPlan['summary'] ?? '当前还没有形成明确的供给 / 繁殖主链调度器结论。'), + 'tag_type' => (string)($arrDispatch['tag_type'] ?? $arrRunbook['tag_type'] ?? $arrExecutionChain['tag_type'] ?? $arrExecutionPlan['tag_type'] ?? 'warning'), + 'dispatch_kind' => $strSchedulerKind, + 'focus_label' => (string)($arrDispatch['focus_label'] ?? $arrRunbook['focus_label'] ?? ''), + 'stage_kind' => (string)($arrDispatch['stage_kind'] ?? $arrRunbook['stage_kind'] ?? $arrExecutionChain['stage_kind'] ?? ''), + 'current_action_label' => (string)($arrDispatch['current_action_label'] ?? $arrRunbook['current_action_label'] ?? $arrExecutionChain['current_action_label'] ?? ''), + 'next_action_label' => $strNextLaneLabel, + 'lead_host' => (string)($arrDispatch['lead_host'] ?? $arrRunbook['lead_host'] ?? $arrExecutionChain['lead_host'] ?? ''), + 'queue_count' => (int)($arrDispatch['queue_count'] ?? $arrRunbook['queue_count'] ?? $arrExecutionPlan['queue_count'] ?? 0), + 'blockers_count' => (int)($arrDispatch['blockers_count'] ?? $arrRunbook['blockers_count'] ?? $arrExecutionPlan['blockers_count'] ?? 0), + 'steps' => $arrSteps, + 'steps_count' => count($arrSteps), + 'primary_action' => (string)($arrDispatch['primary_action'] ?? $arrRunbook['primary_action'] ?? ''), + 'primary_action_label' => (string)($arrDispatch['primary_action_label'] ?? $arrRunbook['primary_action_label'] ?? '执行当前调度器动作'), + 'secondary_action' => (string)($arrDispatch['secondary_action'] ?? $arrRunbook['secondary_action'] ?? ''), + 'secondary_action_label' => (string)($arrDispatch['secondary_action_label'] ?? $arrRunbook['secondary_action_label'] ?? '执行下一调度器动作'), + 'meta' => array_values(array_unique(array_filter(array_map('strval', array_merge( + (array)($arrDispatch['meta'] ?? []), + ['Scheduler ' . $strSchedulerLabel] + ))))), + ]; + } + + protected function buildBreedingMainlineDispatch( + array $arrRunbook, + array $arrExecutionChain, + array $arrExecutionPlan, + array $arrOrchestration, + array $arrProgram + ): array { + $strFocusLabel = (string)($arrRunbook['focus_label'] ?? $arrProgram['focus_label'] ?? ''); + $strCurrentActionLabel = (string)($arrRunbook['current_action_label'] ?? $arrExecutionChain['current_action_label'] ?? $arrExecutionPlan['current_action_label'] ?? $arrOrchestration['current_action_label'] ?? ''); + $strNextActionLabel = (string)($arrRunbook['next_action_label'] ?? $arrExecutionChain['next_action_label'] ?? $arrExecutionPlan['next_action_label'] ?? $arrOrchestration['next_action_label'] ?? ''); + $strLeadHost = (string)($arrRunbook['lead_host'] ?? $arrExecutionChain['lead_host'] ?? $arrExecutionPlan['lead_host'] ?? $arrOrchestration['lead_host'] ?? $arrProgram['lead_host'] ?? ''); + $strDispatchKind = 'runbook_takeover'; + $strDispatchLabel = '按 Runbook 接手'; + + if (str_contains($strCurrentActionLabel, '运行时') || str_contains($strFocusLabel, '运行时')) { + $strDispatchKind = 'runtime_guard'; + $strDispatchLabel = '先过运行时守门'; + } elseif (str_contains($strCurrentActionLabel, 'Oncall') || str_contains($strFocusLabel, 'Oncall')) { + $strDispatchKind = 'oncall_push'; + $strDispatchLabel = '先推进 Oncall'; + } elseif (str_contains($strCurrentActionLabel, 'Ops') || str_contains($strNextActionLabel, 'Ops')) { + $strDispatchKind = 'ops_push'; + $strDispatchLabel = '接手后推进 Ops'; + } elseif (str_contains($strCurrentActionLabel, 'Host')) { + $strDispatchKind = 'host_takeover'; + $strDispatchLabel = '直接接手 Host'; + } + + $arrRoute = []; + foreach ((array)($arrRunbook['steps'] ?? $arrExecutionChain['steps'] ?? []) as $index => $arrStep) { + if (!is_array($arrStep)) { + continue; + } + $strLabel = trim((string)($arrStep['label'] ?? '')); + if ($strLabel === '') { + continue; + } + $arrRoute[] = [ + 'step_key' => (string)($arrStep['step_key'] ?? ('dispatch_step_' . ($index + 1))), + 'label' => $strLabel, + 'is_current' => !empty($arrStep['is_current']), + 'is_next' => !empty($arrStep['is_next']), + ]; + } + + return [ + 'label' => $strDispatchLabel, + 'title' => '当前供给 / 繁殖主链调度对象', + 'summary' => (string)($arrRunbook['summary'] ?? $arrExecutionChain['summary'] ?? $arrExecutionPlan['summary'] ?? '当前还没有形成明确的供给 / 繁殖主链调度结论。'), + 'tag_type' => (string)($arrRunbook['tag_type'] ?? $arrExecutionChain['tag_type'] ?? $arrExecutionPlan['tag_type'] ?? $arrOrchestration['tag_type'] ?? 'warning'), + 'dispatch_kind' => $strDispatchKind, + 'focus_label' => $strFocusLabel, + 'stage_kind' => (string)($arrRunbook['stage_kind'] ?? $arrExecutionChain['stage_kind'] ?? $arrExecutionPlan['stage_kind'] ?? $arrOrchestration['stage_kind'] ?? ''), + 'current_action_label' => $strCurrentActionLabel, + 'next_action_label' => $strNextActionLabel, + 'lead_host' => $strLeadHost, + 'queue_count' => (int)($arrRunbook['queue_count'] ?? $arrExecutionPlan['queue_count'] ?? $arrProgram['queue_count'] ?? 0), + 'blockers_count' => (int)($arrRunbook['blockers_count'] ?? $arrExecutionPlan['blockers_count'] ?? $arrProgram['blockers_count'] ?? 0), + 'steps' => $arrRoute, + 'steps_count' => count($arrRoute), + 'primary_action' => (string)($arrRunbook['primary_action'] ?? $arrExecutionChain['primary_action'] ?? $arrExecutionPlan['primary_action'] ?? $arrProgram['primary_action'] ?? ''), + 'primary_action_label' => (string)($arrRunbook['primary_action_label'] ?? $arrExecutionChain['primary_action_label'] ?? $arrExecutionPlan['primary_action_label'] ?? $arrProgram['primary_action_label'] ?? '执行当前调度动作'), + 'secondary_action' => (string)($arrRunbook['secondary_action'] ?? $arrExecutionChain['secondary_action'] ?? $arrExecutionPlan['secondary_action'] ?? $arrProgram['secondary_action'] ?? ''), + 'secondary_action_label' => (string)($arrRunbook['secondary_action_label'] ?? $arrExecutionChain['secondary_action_label'] ?? $arrExecutionPlan['secondary_action_label'] ?? $arrProgram['secondary_action_label'] ?? '执行下一调度动作'), + 'meta' => array_values(array_unique(array_filter(array_map('strval', array_merge( + (array)($arrRunbook['meta'] ?? []), + ['Dispatch ' . $strDispatchLabel] + ))))), + ]; + } + + protected function buildBreedingMainlineRunbook( + array $arrExecutionChain, + array $arrExecutionPlan, + array $arrOrchestration, + array $arrProgram, + array $arrWorkbench, + array $arrMainline + ): array { + $arrSteps = []; + foreach ((array)($arrExecutionChain['steps'] ?? []) as $arrStep) { + if (!is_array($arrStep)) { + continue; + } + $strLabel = trim((string)($arrStep['label'] ?? '')); + if ($strLabel === '') { + continue; + } + $arrSteps[] = [ + 'step_key' => (string)($arrStep['step_key'] ?? ('step_' . (count($arrSteps) + 1))), + 'label' => $strLabel, + 'is_current' => !empty($arrStep['is_current']), + 'is_next' => !empty($arrStep['is_next']), + ]; + } + if (empty($arrSteps)) { + foreach ((array)($arrExecutionPlan['steps'] ?? []) as $index => $strStepLabel) { + $strStepLabel = trim((string)$strStepLabel); + if ($strStepLabel === '') { + continue; + } + $arrSteps[] = [ + 'step_key' => 'step_' . ($index + 1), + 'label' => $strStepLabel, + 'is_current' => $index === 0, + 'is_next' => $index === 1, + ]; + } + } + + $strFocusLabel = (string)($arrProgram['focus_label'] ?? $arrWorkbench['focus_label'] ?? $arrMainline['focus_label'] ?? ''); + $strStageKind = (string)($arrExecutionChain['stage_kind'] ?? $arrExecutionPlan['stage_kind'] ?? $arrOrchestration['stage_kind'] ?? ''); + + return [ + 'label' => (string)($arrExecutionChain['label'] ?? $arrExecutionPlan['label'] ?? $arrOrchestration['label'] ?? $arrProgram['label'] ?? $arrWorkbench['label'] ?? $arrMainline['label'] ?? '当前供给 / 繁殖主链 Runbook'), + 'title' => '当前供给 / 繁殖主链 Runbook', + 'summary' => (string)($arrExecutionChain['summary'] ?? $arrExecutionPlan['summary'] ?? $arrOrchestration['summary'] ?? $arrProgram['summary'] ?? $arrWorkbench['summary'] ?? $arrMainline['summary'] ?? '当前还没有形成明确的供给 / 繁殖主链接手说明。'), + 'tag_type' => (string)($arrExecutionChain['tag_type'] ?? $arrExecutionPlan['tag_type'] ?? $arrOrchestration['tag_type'] ?? $arrProgram['tag_type'] ?? $arrWorkbench['tag_type'] ?? $arrMainline['tag_type'] ?? 'warning'), + 'focus_label' => $strFocusLabel, + 'source_label' => (string)($arrMainline['source_label'] ?? $arrWorkbench['source_label'] ?? ''), + 'program_label' => (string)($arrProgram['label'] ?? ''), + 'ready_label' => (string)($arrProgram['ready_label'] ?? $arrExecutionPlan['ready_label'] ?? ''), + 'stage_kind' => $strStageKind, + 'current_action_label' => (string)($arrExecutionChain['current_action_label'] ?? $arrExecutionPlan['current_action_label'] ?? $arrOrchestration['current_action_label'] ?? $arrProgram['current_action_label'] ?? $arrWorkbench['current_action_label'] ?? $arrMainline['current_action_label'] ?? ''), + 'next_action_label' => (string)($arrExecutionChain['next_action_label'] ?? $arrExecutionPlan['next_action_label'] ?? $arrOrchestration['next_action_label'] ?? $arrProgram['next_action_label'] ?? $arrWorkbench['next_action_label'] ?? $arrMainline['next_action_label'] ?? ''), + 'lead_host' => (string)($arrExecutionChain['lead_host'] ?? $arrExecutionPlan['lead_host'] ?? $arrOrchestration['lead_host'] ?? $arrProgram['lead_host'] ?? $arrWorkbench['lead_host'] ?? $arrMainline['lead_host'] ?? ''), + 'queue_count' => (int)($arrExecutionPlan['queue_count'] ?? $arrProgram['queue_count'] ?? $arrWorkbench['queue_count'] ?? $arrMainline['queue_count'] ?? 0), + 'blockers_count' => (int)($arrExecutionPlan['blockers_count'] ?? $arrProgram['blockers_count'] ?? $arrWorkbench['blockers_count'] ?? $arrMainline['blockers_count'] ?? 0), + 'steps' => $arrSteps, + 'steps_count' => count($arrSteps), + 'primary_action' => (string)($arrExecutionChain['primary_action'] ?? $arrExecutionPlan['primary_action'] ?? $arrOrchestration['primary_action'] ?? $arrProgram['primary_action'] ?? $arrWorkbench['primary_action'] ?? $arrMainline['primary_action'] ?? ''), + 'primary_action_label' => (string)($arrExecutionChain['primary_action_label'] ?? $arrExecutionPlan['primary_action_label'] ?? $arrOrchestration['primary_action_label'] ?? $arrProgram['primary_action_label'] ?? $arrWorkbench['primary_action_label'] ?? $arrMainline['primary_action_label'] ?? '执行主链动作'), + 'secondary_action' => (string)($arrExecutionChain['secondary_action'] ?? $arrExecutionPlan['secondary_action'] ?? $arrOrchestration['secondary_action'] ?? $arrProgram['secondary_action'] ?? $arrWorkbench['secondary_action'] ?? $arrMainline['secondary_action'] ?? ''), + 'secondary_action_label' => (string)($arrExecutionChain['secondary_action_label'] ?? $arrExecutionPlan['secondary_action_label'] ?? $arrOrchestration['secondary_action_label'] ?? $arrProgram['secondary_action_label'] ?? $arrWorkbench['secondary_action_label'] ?? $arrMainline['secondary_action_label'] ?? '执行下一动作'), + 'meta' => array_values(array_unique(array_filter(array_map('strval', array_merge( + (array)($arrExecutionChain['meta'] ?? []), + (array)($arrExecutionPlan['meta'] ?? []), + (array)($arrProgram['meta'] ?? []), + $strFocusLabel !== '' ? ['Focus ' . $strFocusLabel] : [], + $strStageKind !== '' ? ['Runbook ' . $strStageKind] : [] + ))))), + ]; + } + + protected function buildBreedingMainlineExecutionChain(array $arrExecutionPlan, array $arrOrchestration, array $arrProgram): array + { + $arrSteps = []; + foreach ((array)($arrExecutionPlan['steps'] ?? []) as $index => $label) { + $strLabel = trim((string)$label); + if ($strLabel === '') { + continue; + } + $arrSteps[] = [ + 'step_key' => 'step_' . ($index + 1), + 'label' => $strLabel, + 'is_current' => $index === 0, + 'is_next' => $index === 1, + ]; + } + + return [ + 'label' => (string)($arrExecutionPlan['label'] ?? $arrOrchestration['label'] ?? $arrProgram['label'] ?? '当前供给 / 繁殖主链执行链'), + 'title' => '当前供给 / 繁殖主链执行链', + 'summary' => (string)($arrExecutionPlan['summary'] ?? $arrOrchestration['summary'] ?? $arrProgram['summary'] ?? '当前还没有形成明确的高层主链执行链。'), + 'tag_type' => (string)($arrExecutionPlan['tag_type'] ?? $arrOrchestration['tag_type'] ?? $arrProgram['tag_type'] ?? 'warning'), + 'stage_kind' => (string)($arrExecutionPlan['stage_kind'] ?? $arrOrchestration['stage_kind'] ?? ''), + 'current_action_label' => (string)($arrExecutionPlan['current_action_label'] ?? $arrOrchestration['current_action_label'] ?? $arrProgram['current_action_label'] ?? ''), + 'next_action_label' => (string)($arrExecutionPlan['next_action_label'] ?? $arrOrchestration['next_action_label'] ?? $arrProgram['next_action_label'] ?? ''), + 'lead_host' => (string)($arrExecutionPlan['lead_host'] ?? $arrOrchestration['lead_host'] ?? $arrProgram['lead_host'] ?? ''), + 'steps' => $arrSteps, + 'steps_count' => count($arrSteps), + 'primary_action' => (string)($arrExecutionPlan['primary_action'] ?? ''), + 'primary_action_label' => (string)($arrExecutionPlan['primary_action_label'] ?? '执行当前主线动作'), + 'secondary_action' => (string)($arrExecutionPlan['secondary_action'] ?? ''), + 'secondary_action_label' => (string)($arrExecutionPlan['secondary_action_label'] ?? '执行下一动作'), + 'meta' => array_values(array_unique(array_filter(array_map('strval', array_merge( + (array)($arrExecutionPlan['meta'] ?? []), + ['Chain ' . count($arrSteps)] + ))))), + ]; + } + + protected function buildBreedingMainlineExecutionPlan( + array $arrBreedingMainline, + array $arrBreedingMainlineWorkbench, + array $arrBreedingMainlineProgram, + array $arrBreedingMainlineOrchestration + ): array { + $strStageKind = (string)($arrBreedingMainlineOrchestration['stage_kind'] ?? 'supply_mainline'); + $strLeadHost = (string)($arrBreedingMainlineOrchestration['lead_host'] ?? $arrBreedingMainlineProgram['lead_host'] ?? $arrBreedingMainlineWorkbench['lead_host'] ?? $arrBreedingMainline['lead_host'] ?? ''); + $strCurrentAction = (string)($arrBreedingMainlineOrchestration['current_action_label'] ?? $arrBreedingMainlineProgram['current_action_label'] ?? ''); + $strNextAction = (string)($arrBreedingMainlineOrchestration['next_action_label'] ?? $arrBreedingMainlineProgram['next_action_label'] ?? ''); + + $arrSteps = []; + if ($strCurrentAction !== '') { + $arrSteps[] = $strCurrentAction; + } + if ($strNextAction !== '' && $strNextAction !== $strCurrentAction) { + $arrSteps[] = $strNextAction; + } + + if ($strStageKind === 'runtime_guard' && !in_array('回到供给主链', $arrSteps, true)) { + $arrSteps[] = '回到供给主链'; + } elseif ($strStageKind === 'business_takeover' && !in_array('进入 Ops 主线', $arrSteps, true)) { + $arrSteps[] = '进入 Ops 主线'; + } elseif ($strStageKind === 'direct_ops' && !in_array('继续推进 Ops', $arrSteps, true)) { + $arrSteps[] = '继续推进 Ops'; + } + + return [ + 'label' => (string)($arrBreedingMainlineOrchestration['label'] ?? $arrBreedingMainlineProgram['label'] ?? '当前供给 / 繁殖主链执行计划'), + 'title' => '当前供给 / 繁殖主链执行计划', + 'summary' => (string)($arrBreedingMainlineOrchestration['summary'] ?? $arrBreedingMainlineProgram['summary'] ?? $arrBreedingMainlineWorkbench['summary'] ?? $arrBreedingMainline['summary'] ?? '当前还没有形成明确的高层主链执行计划。'), + 'tag_type' => (string)($arrBreedingMainlineOrchestration['tag_type'] ?? $arrBreedingMainlineProgram['tag_type'] ?? $arrBreedingMainlineWorkbench['tag_type'] ?? $arrBreedingMainline['tag_type'] ?? 'warning'), + 'stage_kind' => $strStageKind, + 'lead_host' => $strLeadHost, + 'current_action_label' => $strCurrentAction, + 'next_action_label' => $strNextAction, + 'queue_count' => (int)($arrBreedingMainlineOrchestration['queue_count'] ?? $arrBreedingMainlineProgram['queue_count'] ?? 0), + 'blockers_count' => (int)($arrBreedingMainlineOrchestration['blockers_count'] ?? $arrBreedingMainlineProgram['blockers_count'] ?? 0), + 'steps' => $arrSteps, + 'primary_action' => (string)($arrBreedingMainlineOrchestration['primary_action'] ?? $arrBreedingMainlineProgram['primary_action'] ?? ''), + 'primary_action_label' => (string)($arrBreedingMainlineOrchestration['primary_action_label'] ?? $arrBreedingMainlineProgram['primary_action_label'] ?? '执行当前主线动作'), + 'secondary_action' => (string)($arrBreedingMainlineOrchestration['secondary_action'] ?? $arrBreedingMainlineProgram['secondary_action'] ?? ''), + 'secondary_action_label' => (string)($arrBreedingMainlineOrchestration['secondary_action_label'] ?? $arrBreedingMainlineProgram['secondary_action_label'] ?? '执行下一动作'), + 'meta' => array_values(array_unique(array_filter(array_map('strval', array_merge( + (array)($arrBreedingMainlineOrchestration['meta'] ?? []), + (array)($arrBreedingMainlineProgram['meta'] ?? []), + ['Stage ' . $strStageKind] + ))))), + ]; + } + + protected function buildBreedingMainlineOrchestration(array $arrBreedingMainline, array $arrBreedingMainlineWorkbench, array $arrBreedingMainlineProgram, array $arrOpsSummary): array + { + $strMode = (string)($arrBreedingMainline['mode'] ?? 'supply_mainline'); + $strCurrentAction = (string)($arrBreedingMainlineProgram['current_action_label'] ?? $arrBreedingMainlineWorkbench['current_action_label'] ?? $arrBreedingMainline['current_action_label'] ?? ''); + $strNextAction = (string)($arrBreedingMainlineProgram['next_action_label'] ?? $arrBreedingMainlineWorkbench['next_action_label'] ?? $arrBreedingMainline['next_action_label'] ?? ''); + $strLeadHost = (string)($arrBreedingMainlineProgram['lead_host'] ?? $arrBreedingMainlineWorkbench['lead_host'] ?? $arrBreedingMainline['lead_host'] ?? ''); + + $strStageKind = 'supply_mainline'; + $strStageLabel = '回到供给主链'; + $strStageSummary = (string)($arrBreedingMainlineProgram['summary'] ?? $arrBreedingMainlineWorkbench['summary'] ?? $arrBreedingMainline['summary'] ?? '当前还没有形成明确的高层主链编排态。'); + + if ($strMode === 'runtime_guard') { + $strStageKind = 'runtime_guard'; + $strStageLabel = '运行时守门阶段'; + } elseif ($strMode === 'oncall_push') { + $strStageKind = 'oncall_push'; + $strStageLabel = 'Oncall 推进阶段'; + } elseif ($strMode === 'bootstrap_host') { + $strStageKind = 'bootstrap_host'; + $strStageLabel = 'Bootstrap Host 承接阶段'; + } elseif ((string)($arrBreedingMainlineProgram['program_kind'] ?? '') === 'direct_ops') { + $strStageKind = 'direct_ops'; + $strStageLabel = '直接推进 Ops 阶段'; + } elseif ((string)($arrBreedingMainlineProgram['program_kind'] ?? '') === 'business_takeover') { + $strStageKind = 'business_takeover'; + $strStageLabel = '业务接手阶段'; + } + + return [ + 'stage_kind' => $strStageKind, + 'label' => $strStageLabel, + 'title' => '当前供给 / 繁殖主链编排', + 'summary' => $strStageSummary, + 'tag_type' => (string)($arrBreedingMainlineProgram['tag_type'] ?? $arrBreedingMainlineWorkbench['tag_type'] ?? $arrBreedingMainline['tag_type'] ?? 'warning'), + 'focus_label' => (string)($arrBreedingMainlineProgram['focus_label'] ?? $arrBreedingMainlineWorkbench['focus_label'] ?? $arrBreedingMainline['focus_label'] ?? ''), + 'program_label' => (string)($arrBreedingMainlineProgram['label'] ?? ''), + 'ready_label' => (string)($arrBreedingMainlineProgram['ready_label'] ?? ''), + 'current_action_label' => $strCurrentAction, + 'next_action_label' => $strNextAction, + 'lead_host' => $strLeadHost, + 'queue_count' => (int)($arrBreedingMainlineProgram['queue_count'] ?? $arrBreedingMainlineWorkbench['queue_count'] ?? $arrBreedingMainline['queue_count'] ?? 0), + 'blockers_count' => (int)($arrBreedingMainlineProgram['blockers_count'] ?? $arrBreedingMainlineWorkbench['blockers_count'] ?? $arrBreedingMainline['blockers_count'] ?? 0), + 'primary_action' => (string)($arrBreedingMainlineProgram['primary_action'] ?? $arrBreedingMainlineWorkbench['primary_action'] ?? $arrBreedingMainline['primary_action'] ?? ''), + 'primary_action_label' => (string)($arrBreedingMainlineProgram['primary_action_label'] ?? $arrBreedingMainlineWorkbench['primary_action_label'] ?? $arrBreedingMainline['primary_action_label'] ?? '执行当前主线动作'), + 'secondary_action' => (string)($arrBreedingMainlineProgram['secondary_action'] ?? $arrBreedingMainlineWorkbench['secondary_action'] ?? $arrBreedingMainline['secondary_action'] ?? ''), + 'secondary_action_label' => (string)($arrBreedingMainlineProgram['secondary_action_label'] ?? $arrBreedingMainlineWorkbench['secondary_action_label'] ?? $arrBreedingMainline['secondary_action_label'] ?? '执行下一动作'), + 'meta' => array_values(array_unique(array_filter(array_map('strval', array_merge( + (array)($arrBreedingMainlineProgram['meta'] ?? []), + (array)($arrBreedingMainlineWorkbench['meta'] ?? []), + (array)($arrBreedingMainline['meta'] ?? []), + ['Stage ' . $strStageLabel] + ))))), + ]; + } + + protected function buildBreedingMainlineProgram(array $arrBreedingMainline, array $arrBreedingMainlineWorkbench, array $arrOpsSummary, array $arrDomainSupplySummary): array + { + $arrProgram = (array)($arrDomainSupplySummary['mainline_program'] ?? []); + $arrRuntimePortal = (array)($arrOpsSummary['runtime_acceptance_portal'] ?? []); + $strMode = (string)($arrBreedingMainline['mode'] ?? 'supply_mainline'); + + $strLabel = (string)($arrBreedingMainlineWorkbench['label'] ?? $arrBreedingMainline['label'] ?? $arrProgram['label'] ?? '待判断'); + $strReadyLabel = (string)($arrBreedingMainline['ready_label'] ?? $arrBreedingMainlineWorkbench['ready_label'] ?? $arrProgram['ready_label'] ?? ''); + $strFocusLabel = (string)($arrBreedingMainlineWorkbench['focus_label'] ?? $arrBreedingMainline['focus_label'] ?? ''); + $strCurrentActionLabel = (string)($arrBreedingMainlineWorkbench['current_action_label'] ?? $arrBreedingMainline['current_action_label'] ?? ''); + $strNextActionLabel = (string)($arrBreedingMainlineWorkbench['next_action_label'] ?? $arrBreedingMainline['next_action_label'] ?? ''); + $strLeadHost = (string)($arrBreedingMainlineWorkbench['lead_host'] ?? $arrBreedingMainline['lead_host'] ?? ''); + $arrMeta = array_values(array_filter(array_map('strval', (array)($arrBreedingMainlineWorkbench['meta'] ?? $arrBreedingMainline['meta'] ?? [])))); + + $strProgramKind = 'supply_mainline'; + $strProgramLabel = '回到供给主链'; + if ($strMode === 'runtime_guard') { + $strProgramKind = 'runtime_guard'; + $strProgramLabel = '先过运行时守门'; + $strReadyLabel = '守门后回主链'; + $strFocusLabel = $strFocusLabel !== '' ? $strFocusLabel : '运行时失败 Host'; + $strNextActionLabel = $strNextActionLabel !== '' ? $strNextActionLabel : '回到供给主链'; + $arrMeta[] = 'Runtime blockers ' . (int)($arrRuntimePortal['failed_hosts_count'] ?? 0); + } elseif ($strMode === 'oncall_push') { + $strProgramKind = 'oncall_push'; + $strProgramLabel = '先推进 Oncall'; + } elseif ($strMode === 'bootstrap_host') { + $strProgramKind = 'bootstrap_host'; + $strProgramLabel = '先接 Bootstrap Host'; + } elseif (str_contains($strCurrentActionLabel, 'Ops') || str_contains($strNextActionLabel, 'Ops')) { + $strProgramKind = 'direct_ops'; + $strProgramLabel = '直接接手并推进 Ops'; + if ($strReadyLabel === '') { + $strReadyLabel = '接手后推进 Ops'; + } + } elseif ($strCurrentActionLabel !== '' || $strLeadHost !== '') { + $strProgramKind = 'business_takeover'; + $strProgramLabel = '直接进入业务接手'; + } + + return [ + 'program_kind' => $strProgramKind, + 'label' => $strProgramLabel, + 'title' => '当前供给 / 繁殖主链程序', + 'summary' => (string)($arrBreedingMainlineWorkbench['summary'] ?? $arrBreedingMainline['summary'] ?? '当前还没有形成明确的高层主链程序态。'), + 'tag_type' => (string)($arrBreedingMainlineWorkbench['tag_type'] ?? $arrBreedingMainline['tag_type'] ?? 'warning'), + 'ready_label' => $strReadyLabel, + 'focus_label' => $strFocusLabel, + 'current_action_label' => $strCurrentActionLabel, + 'next_action_label' => $strNextActionLabel, + 'lead_host' => $strLeadHost, + 'queue_count' => (int)($arrBreedingMainlineWorkbench['queue_count'] ?? $arrBreedingMainline['queue_count'] ?? 0), + 'blockers_count' => (int)($arrBreedingMainlineWorkbench['blockers_count'] ?? $arrBreedingMainline['blockers_count'] ?? 0), + 'primary_action' => (string)($arrBreedingMainlineWorkbench['primary_action'] ?? $arrBreedingMainline['primary_action'] ?? ''), + 'primary_action_label' => (string)($arrBreedingMainlineWorkbench['primary_action_label'] ?? $arrBreedingMainline['primary_action_label'] ?? '执行主链动作'), + 'secondary_action' => (string)($arrBreedingMainlineWorkbench['secondary_action'] ?? $arrBreedingMainline['secondary_action'] ?? ''), + 'secondary_action_label' => (string)($arrBreedingMainlineWorkbench['secondary_action_label'] ?? $arrBreedingMainline['secondary_action_label'] ?? '执行下一动作'), + 'meta' => array_values(array_unique($arrMeta)), + ]; + } + + protected function buildBreedingMainlineWorkbench(array $arrBreedingMainline, array $arrOpsSummary, array $arrDomainSupplySummary): array + { + $arrProgram = (array)($arrDomainSupplySummary['mainline_program'] ?? []); + $arrWorkbench = (array)($arrDomainSupplySummary['mainline_workbench'] ?? []); + $arrRuntimePortal = (array)($arrOpsSummary['runtime_acceptance_portal'] ?? []); + $arrLatestRuntimeTask = (array)($arrOpsSummary['latest_runtime_acceptance_task'] ?? []); + $arrLatestSupplyTask = (array)($arrOpsSummary['latest_domain_supply_task'] ?? []); + $arrLatestSupplyOrchestrationTask = (array)($arrOpsSummary['latest_domain_supply_orchestration_task'] ?? []); + + $strMode = (string)($arrBreedingMainline['mode'] ?? 'supply_mainline'); + $strLeadHost = (string)($arrBreedingMainline['lead_host'] ?? ''); + $arrMeta = []; + + if ($strMode === 'runtime_guard') { + $arrMeta[] = 'Runtime task ' . (string)($arrLatestRuntimeTask['status'] ?? '-'); + $arrMeta[] = 'Failed hosts ' . (int)($arrRuntimePortal['failed_hosts_count'] ?? 0); + $arrMeta[] = '先处理 runtime guard 再回主链'; + } elseif ($strMode === 'oncall_push') { + $arrMeta[] = 'Supply task ' . (string)($arrLatestSupplyTask['status'] ?? '-'); + $arrMeta[] = 'Oncall 优先推进'; + } else { + $arrMeta[] = 'Supply orchestration ' . (string)($arrLatestSupplyOrchestrationTask['status'] ?? '-'); + $arrMeta[] = 'Supply task ' . (string)($arrLatestSupplyTask['status'] ?? '-'); + } + if ($strLeadHost !== '') { + $arrMeta[] = 'Lead ' . $strLeadHost; + } + + return [ + 'mode' => $strMode, + 'label' => (string)($arrBreedingMainline['label'] ?? $arrProgram['label'] ?? '待判断'), + 'title' => '当前供给 / 繁殖主链工作台', + 'summary' => (string)($arrBreedingMainline['summary'] ?? $arrWorkbench['summary'] ?? $arrProgram['summary'] ?? '当前还没有形成明确的高层主线推进建议。'), + 'tag_type' => (string)($arrBreedingMainline['tag_type'] ?? $arrProgram['tag_type'] ?? 'warning'), + 'focus_label' => (string)($arrBreedingMainline['focus_label'] ?? $arrProgram['focus_label'] ?? ''), + 'source_label' => (string)($arrBreedingMainline['source_label'] ?? $arrProgram['source_label'] ?? ''), + 'program_label' => (string)($arrBreedingMainline['program_label'] ?? $arrProgram['label'] ?? ''), + 'ready_label' => (string)($arrBreedingMainline['ready_label'] ?? $arrProgram['ready_label'] ?? ''), + 'phase_label' => (string)($arrWorkbench['phase_label'] ?? ''), + 'dispatch_label' => (string)($arrWorkbench['dispatch_label'] ?? ''), + 'mainline_label' => (string)($arrWorkbench['mainline_label'] ?? ''), + 'current_action_label' => (string)($arrBreedingMainline['current_action_label'] ?? ''), + 'next_action_label' => (string)($arrBreedingMainline['next_action_label'] ?? ''), + 'lead_host' => $strLeadHost, + 'failed_host' => (string)($arrBreedingMainline['failed_host'] ?? ''), + 'queue_count' => (int)($arrBreedingMainline['queue_count'] ?? $arrProgram['queue_count'] ?? 0), + 'blockers_count' => (int)($arrBreedingMainline['blockers_count'] ?? $arrProgram['blockers_count'] ?? 0), + 'primary_action' => (string)($arrBreedingMainline['primary_action'] ?? ''), + 'primary_action_label' => (string)($arrBreedingMainline['primary_action_label'] ?? '执行主链动作'), + 'secondary_action' => (string)($arrBreedingMainline['secondary_action'] ?? ''), + 'secondary_action_label' => (string)($arrBreedingMainline['secondary_action_label'] ?? '执行下一动作'), + 'meta' => $arrMeta, + ]; + } + + protected function buildBreedingMainlineSummary(array $arrOpsSummary, array $arrDomainSupplySummary): array + { + $arrProgram = (array)($arrDomainSupplySummary['mainline_program'] ?? []); + $arrLatestRuntimeTask = (array)($arrOpsSummary['latest_runtime_acceptance_task'] ?? []); + $arrLatestRuntimeRun = (array)($arrOpsSummary['latest_runtime_acceptance'] ?? []); + $arrLatestSupplyTask = (array)($arrOpsSummary['latest_domain_supply_task'] ?? []); + $arrLatestSupplyOrchestrationTask = (array)($arrOpsSummary['latest_domain_supply_orchestration_task'] ?? []); + $arrOncallLatest = (array)(($arrOpsSummary['bootstrap_oncall_portal'] ?? [])['latest_entry'] ?? []); + $arrBundleLatest = (array)(($arrOpsSummary['bootstrap_portal'] ?? [])['latest_bundle'] ?? []); + $arrCompletedLatest = (array)(($arrOpsSummary['bootstrap_completed_portal'] ?? [])['latest_entry'] ?? []); + $arrRuntimePortal = (array)($arrOpsSummary['runtime_acceptance_portal'] ?? []); + + $arrSummary = [ + 'mode' => 'supply_mainline', + 'label' => (string)($arrProgram['label'] ?? '进入供给主链'), + 'title' => '当前供给 / 繁殖主链总入口', + 'summary' => (string)($arrProgram['summary'] ?? '当前还没有形成明确的供给 / 繁殖主链入口。'), + 'tag_type' => (string)($arrProgram['tag_type'] ?? 'warning'), + 'focus_label' => (string)($arrProgram['focus_label'] ?? ''), + 'source_label' => (string)($arrProgram['source_label'] ?? ''), + 'program_label' => (string)($arrProgram['label'] ?? ''), + 'ready_label' => (string)($arrProgram['ready_label'] ?? ''), + 'current_action_label' => (string)($arrProgram['current_action_label'] ?? ''), + 'next_action_label' => (string)($arrProgram['next_action_label'] ?? ''), + 'lead_host' => (string)($arrProgram['lead_host'] ?? ''), + 'failed_host' => (string)($arrProgram['failed_host'] ?? ''), + 'queue_count' => (int)($arrProgram['queue_count'] ?? 0), + 'blockers_count' => (int)($arrProgram['blockers_count'] ?? 0), + 'primary_action' => 'latest-supply-mainline', + 'primary_action_label' => '进入供给主链', + 'secondary_action' => 'latest-host', + 'secondary_action_label' => '进入最新 Host 主线', + 'meta' => [], + ]; + + if ((int)($arrRuntimePortal['failed_hosts_count'] ?? 0) > 0) { + $arrSummary = [ + 'mode' => 'runtime_guard', + 'label' => '先过运行时守门', + 'title' => '当前供给 / 繁殖主链总入口', + 'summary' => '运行时验收当前存在失败 Host,建议先处理运行时守门,再继续推进供给 / 繁殖主链。', + 'tag_type' => 'danger', + 'focus_label' => '运行时失败 Host', + 'source_label' => 'runtime_acceptance', + 'program_label' => (string)($arrProgram['label'] ?? ''), + 'ready_label' => '需先过守门', + 'current_action_label' => (string)($arrLatestRuntimeRun['summary'] ?? '接手失败 Host'), + 'next_action_label' => '回到供给主链', + 'lead_host' => (string)(($arrRuntimePortal['failed_hosts'][0]['host'] ?? '') ?: ($arrLatestRuntimeRun['host'] ?? '')), + 'failed_host' => (string)(($arrRuntimePortal['failed_hosts'][0]['host'] ?? '') ?: ($arrLatestRuntimeRun['host'] ?? '')), + 'queue_count' => (int)($arrRuntimePortal['failed_hosts_count'] ?? 0), + 'blockers_count' => (int)($arrRuntimePortal['failed_hosts_count'] ?? 0), + 'primary_action' => 'latest-runtime-failed-host', + 'primary_action_label' => '接手运行时失败 Host', + 'secondary_action' => 'latest-runtime-run', + 'secondary_action_label' => '查看运行时验收', + 'meta' => [ + 'Runtime task ' . (string)($arrLatestRuntimeTask['status'] ?? '-'), + 'Failed hosts ' . (int)($arrRuntimePortal['failed_hosts_count'] ?? 0), + ], + ]; + } elseif ((string)($arrProgram['program_kind'] ?? '') === 'orchestration_task' || (string)($arrProgram['program_kind'] ?? '') === 'supply_task' || (string)($arrProgram['program_kind'] ?? '') === 'supply_run') { + $arrSummary['meta'] = [ + 'Supply task ' . (string)($arrLatestSupplyTask['status'] ?? '-'), + 'Orchestration ' . (string)($arrLatestSupplyOrchestrationTask['status'] ?? '-'), + ]; + } elseif ((int)(($arrOpsSummary['bootstrap_oncall_portal'] ?? [])['open_count'] ?? 0) > 0) { + $arrSummary = [ + 'mode' => 'oncall_push', + 'label' => '进入值班推进', + 'title' => '当前供给 / 繁殖主链总入口', + 'summary' => '当前已有 Host 进入值班待办,建议先推进 oncall 主线,再继续回到供给 / 繁殖主链。', + 'tag_type' => 'warning', + 'focus_label' => '值班推进', + 'source_label' => 'bootstrap_oncall', + 'program_label' => (string)($arrProgram['label'] ?? ''), + 'ready_label' => '先推 oncall', + 'current_action_label' => (string)($arrOncallLatest['todo_title'] ?? '进入值班待办'), + 'next_action_label' => '回到供给主链', + 'lead_host' => (string)($arrOncallLatest['host'] ?? ''), + 'failed_host' => '', + 'queue_count' => (int)(($arrOpsSummary['bootstrap_oncall_portal'] ?? [])['open_count'] ?? 0), + 'blockers_count' => 0, + 'primary_action' => 'latest-oncall', + 'primary_action_label' => '进入值班待办', + 'secondary_action' => 'latest-supply-mainline', + 'secondary_action_label' => '回到供给主链', + 'meta' => [ + 'Priority ' . (string)($arrOncallLatest['priority_label'] ?? '-'), + 'Host ' . (string)($arrOncallLatest['host'] ?? '-'), + ], + ]; + } elseif ((string)($arrProgram['lead_host'] ?? '') !== '') { + $arrSummary['meta'] = [ + 'Lead ' . (string)($arrProgram['lead_host'] ?? '-'), + 'Queue ' . (int)($arrProgram['queue_count'] ?? 0), + 'Ready ' . (string)($arrProgram['ready_label'] ?? '-'), + ]; + } elseif ((string)($arrBundleLatest['host'] ?? '') !== '') { + $arrSummary = [ + 'mode' => 'bootstrap_host', + 'label' => '进入 Bootstrap Host 主线', + 'title' => '当前供给 / 繁殖主链总入口', + 'summary' => '当前没有明确的 supply lead host,建议先接手最新 Bootstrap Host 主线,再回到供给 / 繁殖总入口。', + 'tag_type' => 'info', + 'focus_label' => 'Bootstrap Host', + 'source_label' => 'bootstrap_host', + 'program_label' => (string)($arrProgram['label'] ?? ''), + 'ready_label' => '进入 Host 主线', + 'current_action_label' => (string)($arrBundleLatest['handoff_next_step'] ?? '进入 Host 主线'), + 'next_action_label' => '回到供给主链', + 'lead_host' => (string)($arrBundleLatest['host'] ?? ''), + 'failed_host' => '', + 'queue_count' => 1, + 'blockers_count' => 0, + 'primary_action' => 'latest-host', + 'primary_action_label' => '进入最新 Host 主线', + 'secondary_action' => 'latest-supply-mainline', + 'secondary_action_label' => '回到供给主链', + 'meta' => [ + 'Stage ' . (string)($arrBundleLatest['handoff_stage_label'] ?? '-'), + 'Host ' . (string)($arrBundleLatest['host'] ?? '-'), + ], + ]; + } elseif ((string)($arrCompletedLatest['host'] ?? '') !== '') { + $arrSummary['meta'] = [ + 'Completed ' . (string)($arrCompletedLatest['host'] ?? '-'), + ]; + } + + return $arrSummary; + } + + protected function bootstrapCodeRoot(): string + { + return dirname(__DIR__, 3); + } + + protected function bootstrapEnvFile(): string + { + return $this->bootstrapCodeRoot() . '/.env'; + } + + protected function bootstrapPortalRoot(): string + { + $strEnvRoot = trim((string)getenv('SEO_COPY_RELEASE_PORTAL_ROOT')); + if ($strEnvRoot !== '') { + return $strEnvRoot; + } + + return $this->bootstrapCodeRoot() . '/public/_seo_copy_release'; + } + + protected function domainImportTemplateRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-import'; + } + + protected function domainImportRunRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-import-runs'; + } + + protected function domainImportRerunRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-import-reruns'; + } + + protected function domainImportRemediationRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-import-remediations'; + } + + protected function domainImportSelfHealingRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-import-self-healing'; + } + + protected function domainExternalSeoSummaryRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-seo-external/latest'; + } + + protected function domainExternalSeoManualImportRoot(): string + { + return DomainExternalSeoManualImportHelper::defaultRoot($this->bootstrapCodeRoot()); + } + + protected function domainExternalSeoSnapshotRunRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-seo-external/snapshot-runs'; + } + + protected function domainExternalSeoSnapshotValidateRunRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-seo-external/snapshot-validate-runs'; + } + + protected function domainExternalSeoStorageRoot(): string + { + return $this->bootstrapCodeRoot() . '/storage/external-seo'; + } + + protected function domainExternalSeoProviderCredentialPath(string $strProviderKey): string + { + $strRoot = $this->domainExternalSeoStorageRoot(); + return match ($strProviderKey) { + 'google_search_console' => $strRoot . '/google-search-console.credentials.json', + 'baidu_webmaster' => $strRoot . '/baidu-webmaster.credentials.json', + default => '', + }; + } + + protected function domainSupplyTemplateRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-supply-batch'; + } + + protected function domainSupplyRunRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-supply-runs'; + } + + protected function domainSpiderCrawlLatestRoot(): string + { + return $this->bootstrapCodeRoot() . '/storage/domain-spider-crawl/latest'; + } + + protected function domainSpiderCrawlRunRoot(): string + { + return $this->bootstrapCodeRoot() . '/storage/domain-spider-crawl/runs'; + } + + protected function publicRelativePath(string $strPath): string + { + $strPublicRoot = rtrim($this->bootstrapCodeRoot() . '/public/', '/'); + $strPath = str_replace('\\', '/', $strPath); + $strPublicRoot = str_replace('\\', '/', $strPublicRoot); + if (str_starts_with($strPath, $strPublicRoot . '/')) { + return substr($strPath, strlen($strPublicRoot . '/')); + } + + return $strPath; + } + + protected function bootstrapTemplateKey(?string $strTemplate): string + { + $strTemplate = strtolower(trim((string)$strTemplate)); + if ($strTemplate === '') { + return 'baseline'; + } + + $arrAllowed = ['baseline', 'testing-safe', 'production-safe']; + if (!in_array($strTemplate, $arrAllowed, true)) { + return 'baseline'; + } + + return $strTemplate; + } + + protected function normalizeDomain(?string $strDomain): string + { + return DomainModel::normalizeHost((string)$strDomain); + } + + protected function normalizeMatchType(?string $strMatchType): string + { + return DomainModel::normalizeMatchType((string)$strMatchType); + } + + protected function normalizeSeedScope(?string $strSeedScope): string + { + return DomainModel::normalizeSeedScope((string)$strSeedScope); + } + + protected function normalizeStoredDomain(?string $strDomain, ?string $strMatchType): string + { + return DomainModel::normalizeStoredDomain((string)$strDomain, (string)$strMatchType); + } + + protected function normalizeParentDomain(?string $strParentDomain): string + { + return DomainModel::normalizeParentDomain((string)$strParentDomain); + } + + protected function normalizeSeoCfg(mixed $mSeoCfg, ?DomainModel $DomainModel = null): array + { + return DomainModel::normalizeSeoCfg($mSeoCfg, $DomainModel); + } + + protected function normalizeSeoCfgForDomainData(mixed $mSeoCfg, array $arrDomainData): array + { + $TempDomainModel = new DomainModel(); + $TempDomainModel->d_match_type = $this->normalizeMatchType($arrDomainData['d_match_type'] ?? null); + $TempDomainModel->d_seed_scope = $this->normalizeSeedScope($arrDomainData['d_seed_scope'] ?? null); + $TempDomainModel->d_parent_domain = $this->normalizeParentDomain($arrDomainData['d_parent_domain'] ?? null); + $TempDomainModel->d_domain = $this->normalizeStoredDomain($arrDomainData['d_domain'] ?? '', $TempDomainModel->d_match_type); + + return $this->normalizeSeoCfg($mSeoCfg, $TempDomainModel); + } + + protected function applySeoStrategyProfileForDomainData(mixed $mSeoCfg, array $arrDomainData, ?string $strStrategyProfile): array + { + $TempDomainModel = new DomainModel(); + $TempDomainModel->d_match_type = $this->normalizeMatchType($arrDomainData['d_match_type'] ?? null); + $TempDomainModel->d_seed_scope = $this->normalizeSeedScope($arrDomainData['d_seed_scope'] ?? null); + $TempDomainModel->d_parent_domain = $this->normalizeParentDomain($arrDomainData['d_parent_domain'] ?? null); + $TempDomainModel->d_domain = $this->normalizeStoredDomain($arrDomainData['d_domain'] ?? '', $TempDomainModel->d_match_type); + + return DomainModel::applySeoStrategyProfile($mSeoCfg, $TempDomainModel, $strStrategyProfile); + } + + protected function normalizeStrategyProfile(?string $strStrategyProfile): string + { + return DomainModel::normalizeSeoStrategyProfile((string)$strStrategyProfile); + } /** * upload site domain info file - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ + public function getTKDArgList(Request $Request, ?AdminUserModel $AdminUserModel) + { + return $this->success(ConverterMovel::$arrDescription); + } + + + /** + * upload site domain info file + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ public function uploadDomain(Request $Request, ?AdminUserModel $AdminUserModel) { $File = $Request->file('excel_file'); - if (!$File) { - return $this->error("9999", '请上传 Excel 文件'); - } - - try { - $arrAllowedExt = ['xls', 'xlsx']; - $strExt = $File->getOriginalExtension(); - if (!in_array($strExt, $arrAllowedExt)) { - return $this->error("9999", '仅支持 xls 或 xlsx 文件'); - } - - $FilePath = $File->getPathname(); - + if (!$File) { + return $this->error("9999", '请上传 Excel 文件'); + } + + try { + $arrAllowedExt = ['xls', 'xlsx']; + $strExt = $File->getOriginalExtension(); + if (!in_array($strExt, $arrAllowedExt)) { + return $this->error("9999", '仅支持 xls 或 xlsx 文件'); + } + + $FilePath = $File->getPathname(); + $Spreadsheet = IOFactory::load($FilePath); $Sheet = $Spreadsheet->getActiveSheet(); $arrData = $Sheet->toArray(); array_shift($arrData); + $strRunRoot = DomainImportProbeRunHelper::createRunRoot($this->domainImportRunRoot(), 'excel_import'); + $strUploadedExcelPath = $strRunRoot . '/uploaded.' . $strExt; + copy($FilePath, $strUploadedExcelPath); + + $intRowsTotal = 0; + $intInsertedCount = 0; + $intSkippedExistingCount = 0; + $intSkippedEmptyDomainCount = 0; + $intSkippedTemplateMissingCount = 0; + $intSkippedDuplicateInFileCount = 0; + $arrStrategyProfileCounts = []; + $arrInsertedDomains = []; + $arrSkippedDomains = []; + $arrSeenDomains = []; + $arrInsertedHosts = []; + foreach ($arrData as $arrRows) { + $intRowsTotal++; + $strStrategyProfile = $this->normalizeStrategyProfile($arrRows[19] ?? DomainModel::SEO_STRATEGY_PROFILE_STANDARD); + $strTkdMode = DomainModel::normalizeTkdMode((string)($arrRows[20] ?? '')); + $strTkdProvider = DomainModel::normalizeTkdProvider((string)($arrRows[21] ?? DomainModel::TKD_PROVIDER_LOCAL)); $arrDomain = [ - 'd_domain' => $arrRows[0], + 'd_domain' => $this->normalizeStoredDomain($arrRows[0] ?? '', $arrRows[14] ?? DomainModel::MATCH_TYPE_EXACT), 't_id' => $arrRows[1], 'd_name' => $arrRows[2], 'd_keywords' => $arrRows[3], - 'd_index_title' => $arrRows[4], - 'd_index_keywords' => $arrRows[5], - 'd_index_description' => $arrRows[6], - 'd_description' => $arrRows[7], - 'd_statis' => $arrRows[8], - 'd_logo_type' => (int)$arrRows[9], - 'd_text_logo' => $arrRows[10], - 'd_img_logo' => $arrRows[11], + 'd_index_title' => $arrRows[4], + 'd_index_keywords' => $arrRows[5], + 'd_index_description' => $arrRows[6], + 'd_description' => $arrRows[7], + 'd_statis' => $arrRows[8], + 'd_logo_type' => (int)$arrRows[9], + 'd_text_logo' => $arrRows[10], + 'd_img_logo' => $arrRows[11], 'd_content_encode' => $arrRows[12], 'info_id' => $arrRows[13], - //'d_baidu_token' => $arrRows[14], + 'd_match_type' => $this->normalizeMatchType($arrRows[14] ?? DomainModel::MATCH_TYPE_EXACT), + 'd_parent_domain' => $this->normalizeParentDomain($arrRows[15] ?? ''), + 'd_seed_scope' => $this->normalizeSeedScope($arrRows[16] ?? DomainModel::SEED_SCOPE_DOMAIN), + 'd_baidu_token' => $arrRows[17] ?? null, ]; + $arrDomain['d_seo_cfg'] = $this->applySeoStrategyProfileForDomainData($arrRows[18] ?? null, $arrDomain, $strStrategyProfile); + $arrDomain['d_seo_cfg']['tkd'] = array_merge( + (array)($arrDomain['d_seo_cfg']['tkd'] ?? []), + [ + 'mode' => $strTkdMode !== '' ? $strTkdMode : ((trim((string)($arrRows[4] ?? '')) !== '' || trim((string)($arrRows[5] ?? '')) !== '' || trim((string)($arrRows[6] ?? '')) !== '') ? DomainModel::TKD_MODE_FORCE_IMPORT : DomainModel::TKD_MODE_AUTO_GENERATE), + 'provider' => $strTkdProvider, + ] + ); - $TCfgTemplate = TemplatesModel::where('t_id', $arrDomain['t_id'])->find()->t_cfg_template; + if ($arrDomain['d_domain'] === '') { + $intSkippedEmptyDomainCount++; + continue; + } + + if (isset($arrSeenDomains[$arrDomain['d_domain']])) { + $intSkippedDuplicateInFileCount++; + $arrSkippedDomains[] = [ + 'd_domain' => $arrDomain['d_domain'], + 'reason' => 'duplicate_in_file', + ]; + continue; + } + $arrSeenDomains[$arrDomain['d_domain']] = true; + + $TemplateModel = TemplatesModel::where('t_id', $arrDomain['t_id'])->find(); + if (empty($TemplateModel)) { + $intSkippedTemplateMissingCount++; + $arrSkippedDomains[] = [ + 'd_domain' => $arrDomain['d_domain'], + 'reason' => 'template_missing', + 't_id' => (int)$arrDomain['t_id'], + ]; + continue; + } + + $TCfgTemplate = $TemplateModel->t_cfg_template; $arrDomain['t_cfg'] = json_encode($TCfgTemplate, JSON_UNESCAPED_UNICODE); - - /** @var DomainModel **/ - // $DomainModel = DomainModel::where('d_domain', $arrDomain['d_domain'])->findOrEmpty(); + + /** @var DomainModel **/ + // $DomainModel = DomainModel::where('d_domain', $arrDomain['d_domain'])->findOrEmpty(); $DomainModel = DomainModel::where('d_domain', $arrDomain['d_domain'])->find(); if (!empty($DomainModel)) { + $intSkippedExistingCount++; + $arrSkippedDomains[] = [ + 'd_domain' => $arrDomain['d_domain'], + 'reason' => 'domain_exists', + ]; continue; } $DomainModel = new DomainModel(); $DomainModel->fill($arrDomain); $DomainModel->save(); + SeoCopyGenerationHelper::initializeForDomain($DomainModel); + SeoResourcePoolHelper::initSitePositioning((string)$DomainModel->d_domain, 1, true); + $intInsertedCount++; + $arrInsertedDomains[] = [ + 'd_id' => (int)$DomainModel->d_id, + 'd_domain' => (string)$DomainModel->d_domain, + 'strategy_profile' => $strStrategyProfile, + ]; + $arrInsertedHosts[] = (string)$DomainModel->d_domain; + $arrStrategyProfileCounts[$strStrategyProfile] = (int)($arrStrategyProfileCounts[$strStrategyProfile] ?? 0) + 1; DomainModel::flushAllDomianOnCache(); } + $arrImportProbeSummary = []; + if (!empty($arrInsertedHosts)) { + $arrImportProbeSummary = DomainImportProbeRunHelper::run($arrInsertedHosts, $strRunRoot, [ + 'rows_total' => $intRowsTotal, + 'inserted_count' => $intInsertedCount, + 'skipped_existing_count' => $intSkippedExistingCount, + 'skipped_empty_domain_count' => $intSkippedEmptyDomainCount, + 'skipped_template_missing_count' => $intSkippedTemplateMissingCount, + 'skipped_duplicate_in_file_count' => $intSkippedDuplicateInFileCount, + 'strategy_profile_counts' => $arrStrategyProfileCounts, + ]); + } - return $this->success(); + return $this->success([ + 'rows_total' => $intRowsTotal, + 'inserted_count' => $intInsertedCount, + 'skipped_existing_count' => $intSkippedExistingCount, + 'skipped_empty_domain_count' => $intSkippedEmptyDomainCount, + 'skipped_template_missing_count' => $intSkippedTemplateMissingCount, + 'skipped_duplicate_in_file_count' => $intSkippedDuplicateInFileCount, + 'strategy_profile_counts' => $arrStrategyProfileCounts, + 'inserted_domains' => array_slice($arrInsertedDomains, 0, 20), + 'skipped_domains' => array_slice($arrSkippedDomains, 0, 20), + 'run_root' => $this->publicRelativePath($strRunRoot), + 'uploaded_excel_path' => $this->publicRelativePath($strUploadedExcelPath), + 'probe_status' => (string)($arrImportProbeSummary['status'] ?? 'skipped'), + 'probe_processed_count' => (int)($arrImportProbeSummary['processed_count'] ?? 0), + 'probe_passed_count' => (int)($arrImportProbeSummary['passed_count'] ?? 0), + 'probe_failed_count' => (int)($arrImportProbeSummary['failed_count'] ?? 0), + 'summary_json_path' => $this->publicRelativePath((string)($arrImportProbeSummary['summary_json_path'] ?? '')), + 'summary_html_path' => $this->publicRelativePath((string)($arrImportProbeSummary['summary_html_path'] ?? '')), + ]); } catch (\Exception $e) { return $this->error("9999", '导入失败: ' . $e->getMessage()); } } - /** - * 列表 - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ - public function getDomainList(Request $Request, ?AdminUserModel $AdminUserModel) + public function getDomainImportTemplate(Request $Request, ?AdminUserModel $AdminUserModel) { - $arrData = [ - "page" => $Request->get('page', 1), - "limit" => $Request->get('limit', 10), - "key" => $Request->get('key'), - ]; + try { + $arrTemplate = DomainBatchImportTemplateHelper::writeTemplate($this->domainImportTemplateRoot()); + $strRelativePath = str_replace($this->bootstrapCodeRoot() . '/public/', '', $arrTemplate['file_path']); - $arrRule = [ - 'page' => 'require|number', - 'limit' => 'require|number', - ]; + return $this->success([ + 'excel_template_path' => $strRelativePath, + 'template_file_name' => $arrTemplate['file_name'], + 'template_summary' => $arrTemplate['summary'], + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', $Throwable->getMessage()); + } + } - $this->validate($arrData, $arrRule); + public function getDomainSupplyTemplate(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrTemplate = DomainSupplyBatchTemplateHelper::writeTemplate($this->domainSupplyTemplateRoot()); + $strRelativePath = $this->publicRelativePath((string)$arrTemplate['file_path']); - $DomainModel = DomainModel::alias('d'); + return $this->success([ + 'excel_template_path' => $strRelativePath, + 'template_file_name' => $arrTemplate['file_name'], + 'template_summary' => $arrTemplate['summary'], + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '生成一体化供给模板失败:' . $Throwable->getMessage()); + } + } - if (!empty($arrData['key'])) { - $strKey = $arrData['key']; - $DomainModel->where(function ($DomainModel) use ($strKey) { - $DomainModel->whereOr([ - ['d.d_domain', 'like', "%" . $strKey . "%"], - ['d.d_id', 'like', "%" . $strKey . "%"], - ]); - }); + public function runDomainSupplyBatch(Request $Request, ?AdminUserModel $AdminUserModel) + { + $File = $Request->file('excel_file'); + if (!$File) { + return $this->error('9999', '请上传一体化供给 Excel 文件'); } + try { + $strExt = strtolower((string)$File->getOriginalExtension()); + if (!in_array($strExt, ['xls', 'xlsx'], true)) { + return $this->error('9999', '仅支持 xls 或 xlsx 文件'); + } + + $strDate = date('Ymd'); + $strRunId = date('His') . '_' . substr(md5(uniqid('', true)), 0, 6); + $strRunRoot = rtrim($this->domainSupplyRunRoot(), '/') . '/' . $strDate . '/' . $strRunId; + DomainSupplyBatchHelper::ensureDir($strRunRoot); + + $strTemplatePath = $strRunRoot . '/domain-supply-batch-upload.' . $strExt; + copy($File->getPathname(), $strTemplatePath); + + $strBootstrapRun = strtolower(trim((string)$Request->post('bootstrap_run', 'off'))); + if (!in_array($strBootstrapRun, ['off', 'dry-run', 'run'], true)) { + $strBootstrapRun = 'off'; + } + + $arrSummary = DomainSupplyBatchHelper::process($strTemplatePath, [ + 'output_root' => $strRunRoot, + 'run_bootstrap' => (bool)$Request->post('run_bootstrap', false), + 'bootstrap_run' => $strBootstrapRun, + 'portal_root' => 'off', + 'force' => (bool)$Request->post('force', false), + ]); + + return $this->success([ + 'run_root' => $this->publicRelativePath($strRunRoot), + 'uploaded_template_path' => $this->publicRelativePath($strTemplatePath), + 'processed_count' => (int)($arrSummary['processed_count'] ?? 0), + 'success_count' => (int)($arrSummary['success_count'] ?? 0), + 'failed_count' => (int)($arrSummary['failed_count'] ?? 0), + 'site_import_rows_count' => (int)($arrSummary['site_import_rows_count'] ?? 0), + 'bootstrap_rows_count' => (int)($arrSummary['bootstrap_rows_count'] ?? 0), + 'run_bootstrap' => (bool)($arrSummary['run_bootstrap'] ?? false), + 'bootstrap_run' => (string)($arrSummary['bootstrap_run'] ?? $strBootstrapRun), + 'site_import_path' => $this->publicRelativePath((string)($arrSummary['site_import_path'] ?? '')), + 'bootstrap_batch_path' => $this->publicRelativePath((string)($arrSummary['bootstrap_batch_path'] ?? '')), + 'summary_json_path' => $this->publicRelativePath((string)($arrSummary['summary_json'] ?? '')), + 'summary_html_path' => $this->publicRelativePath((string)($arrSummary['summary_html'] ?? '')), + 'items' => array_slice((array)($arrSummary['items'] ?? []), 0, 100), + 'bootstrap_batch' => $arrSummary['bootstrap_batch'] ?? [], + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '执行一体化供给失败:' . $Throwable->getMessage()); + } + } + + public function getDomainSupplyRunSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + $arrSummary = DomainSupplyRunIndexHelper::buildSummary($this->domainSupplyRunRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取一体化供给运行记录失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportRunSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + $arrSummary = DomainImportRunIndexHelper::buildSummary($this->domainImportRunRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入运行记录失败:' . $Throwable->getMessage()); + } + } + + public function getDomainSpiderMdSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 10))); + return $this->success(DomainSpiderMdRunHelper::buildSummary($intLimit)); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取蜘蛛池MD记录失败:' . $Throwable->getMessage()); + } + } + + public function getDomainSpiderCrawlWorkbenchSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intRunLimit = max(1, min(50, (int)$Request->get('run_limit', 10))); + $intAnomalyLimit = max(1, min(500, (int)$Request->get('anomaly_limit', 50))); + $strHost = DomainModel::normalizeHost((string)$Request->get('host', '')); + $intWindowHours = max(0, min(720, (int)$Request->get('window_hours', 24))); + $strBotScope = trim((string)$Request->get('bot_scope', 'domestic')); + $mixSelectedBots = $Request->get('selected_bots', []); + return $this->success(DomainSpiderCrawlWorkbenchHelper::buildSummary( + $this->bootstrapCodeRoot() . '/storage', + $this->domainSpiderCrawlLatestRoot(), + $this->domainSpiderCrawlRunRoot(), + $intRunLimit, + $intAnomalyLimit, + $strHost, + $intWindowHours, + $strBotScope, + $mixSelectedBots + )); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取蜘蛛抓取概览失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportFailedQueue(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 50))); + $arrSummary = DomainImportFailedQueueHelper::buildSummary($this->domainImportRunRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入失败队列失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportRerunSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + $arrSummary = DomainImportRerunIndexHelper::buildSummary($this->domainImportRerunRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入重跑记录失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportRemediationSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + $arrSummary = DomainImportRemediationIndexHelper::buildSummary($this->domainImportRemediationRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入补料记录失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportSelfHealingSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + $arrSummary = DomainImportSelfHealingIndexHelper::buildSummary($this->domainImportSelfHealingRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入自动修复记录失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportHealthWorkbench(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(20, (int)$Request->get('limit', 10))); + $arrSummary = DomainImportHealthWorkbenchHelper::buildSummary( + $this->domainImportRunRoot(), + $this->domainImportRerunRoot(), + $this->domainImportSelfHealingRoot(), + $intLimit + ); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入健康台失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportManualAttentionQueue(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + $arrSummary = DomainImportManualAttentionQueueHelper::buildSummary( + $this->domainImportRunRoot(), + $this->domainImportRerunRoot(), + $this->domainImportSelfHealingRoot(), + $intLimit + ); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入人工关注队列失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportManualAttentionHandleSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + $arrSummary = DomainImportManualAttentionHandleIndexHelper::buildSummary($this->domainImportManualAttentionHandleRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入人工处理记录失败:' . $Throwable->getMessage()); + } + } + + public function runDomainImportManualAttentionHandle(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strHost = \app\model\DomainModel::normalizeHost((string)$Request->post('host', '')); + $strAction = strtolower(trim((string)$Request->post('action', 'resolved'))); + $arrCommand = [ + 'php', + escapeshellarg($this->bootstrapCodeRoot() . '/scripts/domain_import_manual_attention_handle_run.php'), + '--import-run-root=' . escapeshellarg($this->domainImportRunRoot()), + '--rerun-root=' . escapeshellarg($this->domainImportRerunRoot()), + '--self-healing-root=' . escapeshellarg($this->domainImportSelfHealingRoot()), + '--handle-root=' . escapeshellarg($this->domainImportManualAttentionHandleRoot()), + '--host=' . escapeshellarg($strHost), + '--action=' . escapeshellarg($strAction), + '--format=json', + ]; + + $arrExecResult = $this->runShellCommand($arrCommand); + $arrOutput = $arrExecResult['output']; + $intExitCode = $arrExecResult['exit_code']; + $arrSummary = json_decode(trim(implode(PHP_EOL, $arrOutput)), true); + if (!is_array($arrSummary)) { + $arrSummary = [ + 'status' => $intExitCode === 0 ? 'success' : 'failed', + 'output' => $arrOutput, + ]; + } + + return $intExitCode === 0 + ? $this->success(['status' => 'success', 'summary' => $arrSummary]) + : $this->error('9999', '执行站点导入人工处理失败', ['summary' => $arrSummary]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '执行站点导入人工处理失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportHealthWorkbenchSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + $arrSummary = DomainImportHealthWorkbenchIndexHelper::buildSummary($this->domainImportHealthWorkbenchRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入健康台记录失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportHealthWorkbenchTrend(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(20, (int)$Request->get('limit', 10))); + $arrSummary = DomainImportHealthWorkbenchTrendHelper::buildSummary($this->domainImportHealthWorkbenchRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入健康台趋势失败:' . $Throwable->getMessage()); + } + } + + public function runDomainImportHealthWorkbenchSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(20, (int)$Request->post('limit', 10))); + $job = $this->enqueueDomainImportAsyncJob('health_workbench_refresh', [ + 'limit' => $intLimit, + ]); + + return $this->success([ + 'status' => 'queued', + 'job' => $job, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '执行站点导入健康台落盘失败:' . $Throwable->getMessage()); + } + } + + public function runDomainSpiderMdGenerate(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + SpiderMdConfigHelper::ensureSystemConfigDefaults(); + + $payload = []; + foreach ([ + 'max_links_per_file' => [100, 5000], + 'category_link_limit_per_host' => [0, 500], + 'search_link_limit_per_host' => [0, 100], + 'detail_limit_per_host' => [0, 200000], + 'forge_limit_per_host' => [0, 200000], + 'retention_days' => [1, 90], + ] as $key => [$min, $max]) { + $rawValue = $Request->post($key, null); + if ($rawValue === null || $rawValue === '') { + continue; + } + $payload[$key] = max($min, min($max, (int)$rawValue)); + } + + $payload['force_regenerate'] = (int)$Request->post('force_regenerate', 0) === 1 ? 1 : 0; + + $job = $this->enqueueDomainImportAsyncJob('spider_md_generate', $payload); + + return $this->success([ + 'status' => 'queued', + 'job' => $job, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '执行蜘蛛池MD生成失败:' . $Throwable->getMessage()); + } + } + + public function runDomainImportFailedQueue(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strHost = $this->normalizeDomain((string)$Request->post('host', '')); + $intLimit = max(1, min(200, (int)$Request->post('limit', 50))); + $job = $this->enqueueDomainImportAsyncJob('failed_queue_rerun', [ + 'host' => $strHost, + 'limit' => $intLimit, + ]); + + return $this->success([ + 'status' => 'queued', + 'job' => $job, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '执行站点导入失败队列重跑失败:' . $Throwable->getMessage()); + } + } + + public function runDomainImportFailureRemediation(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strHost = $this->normalizeDomain((string)$Request->post('host', '')); + $intLimit = max(1, min(200, (int)$Request->post('limit', 50))); + $job = $this->enqueueDomainImportAsyncJob('failure_remediation', [ + 'host' => $strHost, + 'limit' => $intLimit, + ]); + + return $this->success([ + 'status' => 'queued', + 'job' => $job, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '执行站点导入失败补料候选失败:' . $Throwable->getMessage()); + } + } + + public function runDomainImportSelfHealing(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $job = $this->enqueueDomainImportAsyncJob('self_healing', []); + + return $this->success([ + 'status' => 'queued', + 'job' => $job, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '执行站点导入自动修复失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportAsyncJobStatus(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $jobId = trim((string)$Request->get('job_id', '')); + if ($jobId === '') { + return $this->error('9999', 'job_id 不能为空'); + } + + $job = \app\common\helper\DomainImportAsyncJobHelper::readJob($jobId); + if (empty($job)) { + return $this->error('9999', '任务不存在'); + } + + $job['log_tail'] = \app\common\helper\DomainImportAsyncJobHelper::readLogTail($jobId, max(20, min(500, (int)$Request->get('lines', 160)))); + return $this->success($job); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取导入任务状态失败:' . $Throwable->getMessage()); + } + } + + public function getDomainSeoCopyAiProviderStatus(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrProvider = SeoCopyAiProviderHelper::resolveProviderConfig(); + $intTotal = (int)DomainModel::count(); + $intLocalReady = 0; + $intAiPending = 0; + $intAiReady = 0; + + foreach (DomainModel::field(['d_domain'])->select() as $DomainRow) { + if (!$DomainRow instanceof DomainModel) { + continue; + } + $arrState = SeoCopyGenerationHelper::readState((string)($DomainRow->d_domain ?? '')); + $strStatus = (string)($arrState['status'] ?? ''); + if ($strStatus === SeoCopyGenerationHelper::STATUS_AI_PENDING) { + $intAiPending++; + } elseif ($strStatus === SeoCopyGenerationHelper::STATUS_AI_READY) { + $intAiReady++; + } elseif ($strStatus === SeoCopyGenerationHelper::STATUS_LOCAL_READY) { + $intLocalReady++; + } + } + + return $this->success([ + 'provider' => (string)($arrProvider['provider'] ?? 'local'), + 'provider_label' => (string)($arrProvider['label'] ?? '本地增强'), + 'configured' => !empty($arrProvider['configured']) ? 1 : 0, + 'model' => (string)($arrProvider['model'] ?? ''), + 'base_url' => (string)($arrProvider['base_url'] ?? ''), + 'timeout_ms' => (int)($arrProvider['timeout_ms'] ?? 0), + 'api_key_configured' => trim((string)($arrProvider['api_key'] ?? '')) !== '' ? 1 : 0, + 'fallback_provider' => (string)($arrProvider['fallback_provider'] ?? 'local'), + 'protocol' => (string)($arrProvider['protocol'] ?? ''), + 'openai_compatible' => !empty($arrProvider['openai_compatible']) ? 1 : 0, + 'supported_providers' => (array)($arrProvider['supported_providers'] ?? []), + 'env_keys' => (array)($arrProvider['env_keys'] ?? []), + 'missing_fields' => array_values((array)($arrProvider['missing_fields'] ?? [])), + 'config_note' => (string)($arrProvider['config_note'] ?? ''), + 'published_root' => \app\common\helper\SeoCopyStore::publishedRoot(), + 'state_root' => SeoCopyGenerationHelper::stateRoot(), + 'history_root' => SeoCopyGenerationHelper::historyRoot(), + 'domain_counts' => [ + 'total' => $intTotal, + 'local_ready' => $intLocalReady, + 'ai_pending' => $intAiPending, + 'ai_ready' => $intAiReady, + ], + 'resource_pool_summary' => $this->buildSeoResourcePoolSummary(), + 'ai_trigger_summary' => $this->buildSeoCopyAiTriggerSummary(), + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取 AI 文案 provider 状态失败:' . $Throwable->getMessage()); + } + } + + public function getDomainSeoCopyGenerationHistory(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strHost = DomainModel::normalizeHost((string)$Request->get('host', '')); + $intLimit = max(1, min(200, (int)$Request->get('limit', 50))); + $arrItems = $this->buildDomainSeoCopyGenerationHistoryItems($strHost, $intLimit); + + return $this->success([ + 'host' => $strHost, + 'items' => $arrItems, + 'total' => count($arrItems), + 'latest_item' => $arrItems[0] ?? null, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取 AI 文案优化历史失败:' . $Throwable->getMessage()); + } + } + + public function getDomainSeoCopyGenerationHistoryDetail(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strHost = DomainModel::normalizeHost((string)$Request->get('host', '')); + $strJobId = trim((string)$Request->get('job_id', '')); + if ($strHost === '' || $strJobId === '') { + return $this->error('9999', 'host 与 job_id 不能为空'); + } + + $arrHistory = SeoCopyGenerationHelper::readHistory($strHost); + foreach (array_reverse($arrHistory) as $arrEntry) { + if (!is_array($arrEntry)) { + continue; + } + if ((string)($arrEntry['job_id'] ?? '') !== $strJobId) { + continue; + } + + return $this->success([ + 'host' => $strHost, + 'job_id' => $strJobId, + 'item' => [ + 'host' => DomainModel::normalizeHost((string)($arrEntry['host'] ?? '')), + 'recorded_at' => (string)($arrEntry['recorded_at'] ?? ''), + 'action' => (string)($arrEntry['action'] ?? ''), + 'job_id' => (string)($arrEntry['job_id'] ?? ''), + 'provider_requested' => (string)($arrEntry['provider_requested'] ?? ''), + 'provider_effective' => (string)($arrEntry['provider_effective'] ?? ''), + 'configured' => !empty($arrEntry['configured']) ? 1 : 0, + 'message' => (string)($arrEntry['message'] ?? ''), + 'published_pages' => count((array)($arrEntry['written_pages'] ?? [])), + 'written_pages' => (array)($arrEntry['written_pages'] ?? []), + 'page_diffs' => (array)($arrEntry['page_diffs'] ?? []), + 'state' => (array)($arrEntry['state'] ?? []), + ], + ]); + } + + return $this->error('9999', '未找到对应的 AI 优化历史'); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取 AI 文案优化详情失败:' . $Throwable->getMessage()); + } + } + + public function getDomainSeoCopyPublishedPreview(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strHost = DomainModel::normalizeHost((string)$Request->get('host', '')); + if ($strHost === '') { + return $this->error('9999', 'host 不能为空'); + } + + $arrTargets = [ + ['scene' => 'home', 'page_key' => 'index'], + ['scene' => 'category_index', 'page_key' => 'index'], + ['scene' => 'category_list', 'page_key' => 'default'], + ['scene' => 'search', 'page_key' => 'landing'], + ['scene' => 'detail', 'page_key' => 'default'], + ['scene' => 'play', 'page_key' => 'default'], + ]; + $arrItems = []; + foreach ($arrTargets as $arrTarget) { + $strScene = (string)$arrTarget['scene']; + $strPageKey = (string)$arrTarget['page_key']; + $arrData = SeoCopyStore::getPreferredPageData($strHost, $strScene, [$strPageKey, 'default', 'index'], [SeoCopyStore::publishedRoot()]); + $arrItems[] = [ + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'data' => is_array($arrData) ? $arrData : [], + ]; + } + + return $this->success([ + 'host' => $strHost, + 'items' => $arrItems, + 'published_root' => SeoCopyStore::publishedRoot(), + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取当前已发布文案失败:' . $Throwable->getMessage()); + } + } + + public function rollbackDomainSeoCopyHistory(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strHost = DomainModel::normalizeHost((string)$Request->post('host', '')); + $strJobId = trim((string)$Request->post('job_id', '')); + if ($strHost === '' || $strJobId === '') { + return $this->error('9999', 'host 与 job_id 不能为空'); + } + $arrHistory = SeoCopyGenerationHelper::readHistory($strHost); + $arrMatched = []; + foreach (array_reverse($arrHistory) as $arrEntry) { + if (!is_array($arrEntry)) { + continue; + } + if ((string)($arrEntry['job_id'] ?? '') !== $strJobId) { + continue; + } + $arrMatched = $arrEntry; + break; + } + if (empty($arrMatched)) { + return $this->error('9999', '未找到可回滚的历史记录'); + } + if (empty((array)($arrMatched['page_diffs'] ?? []))) { + return $this->error('9999', '当前历史没有可回滚的页面数据'); + } + + $job = \app\common\helper\DomainImportAsyncJobHelper::createJob('seo_copy_ai_rollback', [ + 'host' => $strHost, + 'target_job_id' => $strJobId, + ]); + return $this->success([ + 'host' => $strHost, + 'rollback_from_job_id' => $strJobId, + 'message' => '已提交 AI 文案回滚任务。', + 'job' => $job, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '回滚 AI 文案历史失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportHealthTrend(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(20, (int)$Request->get('limit', 10))); + $arrSummary = DomainImportHealthTrendHelper::buildSummary( + $this->domainImportRunRoot(), + $this->domainImportRerunRoot(), + $intLimit + ); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站点导入健康趋势失败:' . $Throwable->getMessage()); + } + } + + protected function buildDomainSeoCopyGenerationHistoryItems(string $strHost = '', int $intLimit = 50): array + { + $arrItems = []; + $strHistoryRoot = SeoCopyGenerationHelper::historyRoot(); + if (!is_dir($strHistoryRoot)) { + return []; + } + + $arrPaths = []; + if ($strHost !== '') { + $strPath = SeoCopyGenerationHelper::resolveHistoryPath($strHost); + if (is_file($strPath)) { + $arrPaths[] = $strPath; + } + } else { + $arrPaths = glob(rtrim($strHistoryRoot, '/') . '/*.history.json') ?: []; + } + + foreach ($arrPaths as $strPath) { + $arrHistory = json_decode((string)file_get_contents($strPath), true); + if (!is_array($arrHistory)) { + continue; + } + + foreach ($arrHistory as $arrEntry) { + if (!is_array($arrEntry)) { + continue; + } + + $arrItems[] = [ + 'host' => DomainModel::normalizeHost((string)($arrEntry['host'] ?? '')), + 'recorded_at' => (string)($arrEntry['recorded_at'] ?? ''), + 'action' => (string)($arrEntry['action'] ?? ''), + 'job_id' => (string)($arrEntry['job_id'] ?? ''), + 'provider_requested' => (string)($arrEntry['provider_requested'] ?? ''), + 'provider_effective' => (string)($arrEntry['provider_effective'] ?? ''), + 'configured' => !empty($arrEntry['configured']) ? 1 : 0, + 'message' => (string)($arrEntry['message'] ?? ''), + 'published_pages' => count((array)($arrEntry['written_pages'] ?? [])), + 'written_pages' => (array)($arrEntry['written_pages'] ?? []), + 'page_diffs' => (array)($arrEntry['page_diffs'] ?? []), + 'state' => (array)($arrEntry['state'] ?? []), + ]; + } + } + + usort($arrItems, static function (array $arrLeft, array $arrRight): int { + return strcmp((string)($arrRight['recorded_at'] ?? ''), (string)($arrLeft['recorded_at'] ?? '')); + }); + + return array_values(array_slice($arrItems, 0, $intLimit)); + } + + protected function domainImportHealthWorkbenchRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-import-health-workbench'; + } + + protected function domainImportManualAttentionHandleRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-import-manual-attention-handles'; + } + + protected function domainImportExternalSeoClosureActionRoot(): string + { + return $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-import-external-seo-closure-actions'; + } + + protected function findClosureActionRunRoot(string $strRunId): string + { + $strRunId = trim($strRunId); + if ($strRunId === '') { + return ''; + } + + $strRoot = rtrim($this->domainImportExternalSeoClosureActionRoot(), '/'); + $arrCandidates = [ + $strRoot . '/*/' . $strRunId, + $strRoot . '/*/*/' . $strRunId, + ]; + foreach ($arrCandidates as $strPattern) { + foreach ((array)glob($strPattern) as $strCandidate) { + if (is_dir($strCandidate)) { + return $strCandidate; + } + } + } + + return ''; + } + + public function getDomainExternalSeoSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + $arrSummary = DomainExternalSeoSummaryHelper::buildSummary( + $this->domainImportRunRoot(), + $this->domainImportRerunRoot(), + $intLimit + ); + $arrSummary = DomainExternalSeoSummaryHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外 SEO 摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoFailedQueue(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 50))); + $arrSummary = DomainExternalSeoFailedQueueHelper::buildSummary( + $this->domainImportRunRoot(), + $this->domainImportRerunRoot(), + $intLimit + ); + + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外 SEO 异常队列失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoTrendSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(20, (int)$Request->get('limit', 10))); + $arrSummary = DomainExternalSeoTrendHelper::buildSummary( + $this->domainImportRunRoot(), + $this->domainImportRerunRoot(), + $intLimit + ); + $arrSummary = DomainExternalSeoTrendHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外 SEO 趋势摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoProviderStatus(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrSummary = DomainExternalSeoProviderRegistryHelper::buildSummary($this->bootstrapCodeRoot()); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外数据接入状态失败:' . $Throwable->getMessage()); + } + } + + public function uploadDomainExternalSeoProviderCredential(Request $Request, ?AdminUserModel $AdminUserModel) + { + $strProviderKey = trim((string)$Request->post('provider_key', '')); + $File = $Request->file('credential_file'); + + if ($strProviderKey === '') { + return $this->error('9999', '请先选择 provider_key'); + } + if (!$File) { + return $this->error('9999', '请上传凭证文件'); + } + + try { + $strTargetPath = $this->domainExternalSeoProviderCredentialPath($strProviderKey); + if ($strTargetPath === '') { + return $this->error('9999', '当前 provider 不支持凭证上传:' . $strProviderKey); + } + + $strExt = strtolower((string)$File->getOriginalExtension()); + if ($strExt !== 'json') { + return $this->error('9999', '凭证文件仅支持 json'); + } + + $strContent = (string)file_get_contents($File->getPathname()); + $arrDecoded = json_decode($strContent, true); + if (!is_array($arrDecoded)) { + return $this->error('9999', '凭证文件不是有效 JSON'); + } + + $strRoot = $this->domainExternalSeoStorageRoot(); + if (!is_dir($strRoot) && !mkdir($strRoot, 0777, true) && !is_dir($strRoot)) { + throw new \RuntimeException('创建站外凭证目录失败:' . $strRoot); + } + + file_put_contents( + $strTargetPath, + json_encode($arrDecoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + + $arrSummary = DomainExternalSeoProviderRegistryHelper::buildSummary($this->bootstrapCodeRoot()); + + return $this->success([ + 'provider_key' => $strProviderKey, + 'credential_path' => $strTargetPath, + 'credential_file_size' => (int)(filesize($strTargetPath) ?: 0), + 'credential_updated_at' => date(DATE_ATOM, (int)(filemtime($strTargetPath) ?: time())), + 'provider_summary' => $arrSummary, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '上传站外 provider 凭证失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoSearchConsolePropertyMapTemplate(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strOutputDir = $this->bootstrapCodeRoot() . '/public/_admin_templates/domain-seo-external/search-console'; + $arrTemplate = DomainExternalSeoSearchConsoleConfigHelper::writeTemplate($strOutputDir); + return $this->success([ + 'file_name' => (string)($arrTemplate['file_name'] ?? ''), + 'file_path' => $this->publicRelativePath((string)($arrTemplate['file_path'] ?? '')), + 'summary' => (array)($arrTemplate['summary'] ?? []), + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '生成 Search Console property map 模板失败:' . $Throwable->getMessage()); + } + } + + public function uploadDomainExternalSeoSearchConsolePropertyMap(Request $Request, ?AdminUserModel $AdminUserModel) + { + $File = $Request->file('property_map_file'); + if (!$File) { + return $this->error('9999', '请上传 Search Console property map 文件'); + } + + try { + $strExt = strtolower((string)$File->getOriginalExtension()); + if ($strExt !== 'json') { + return $this->error('9999', 'property map 仅支持 json 文件'); + } + + $strStorageRoot = $this->bootstrapCodeRoot() . '/storage/external-seo'; + if (!is_dir($strStorageRoot) && !mkdir($strStorageRoot, 0777, true) && !is_dir($strStorageRoot)) { + throw new \RuntimeException('创建 Search Console 配置目录失败:' . $strStorageRoot); + } + + $strUploadedPath = $strStorageRoot . '/google-search-console.property-map.uploaded.json'; + copy($File->getPathname(), $strUploadedPath); + + $arrResult = DomainExternalSeoSearchConsoleConfigHelper::saveConfig($this->bootstrapCodeRoot(), $strUploadedPath); + $arrProviderSummary = DomainExternalSeoProviderRegistryHelper::buildSummary($this->bootstrapCodeRoot()); + $arrRealSummary = DomainExternalSeoRealSummaryHelper::buildSummary($this->bootstrapCodeRoot(), 7); + $arrRealSummary = DomainExternalSeoRealSummaryHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrRealSummary); + + return $this->success([ + 'uploaded_property_map_path' => $strUploadedPath, + 'config_path' => (string)($arrResult['config_path'] ?? ''), + 'mapped_hosts_count' => (int)($arrResult['mapped_hosts_count'] ?? 0), + 'provider_summary' => $arrProviderSummary, + 'real_summary_status' => (string)($arrRealSummary['status'] ?? ''), + 'summary_json_path' => $this->publicRelativePath((string)($arrRealSummary['summary_json_path'] ?? '')), + 'summary_html_path' => $this->publicRelativePath((string)($arrRealSummary['summary_html_path'] ?? '')), + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '上传 Search Console property map 失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoSearchConsoleProviderPlan(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intDays = max(1, min(30, (int)$Request->get('days', 7))); + $arrSummary = DomainExternalSeoSearchConsoleProviderPlanHelper::buildSummary($this->bootstrapCodeRoot(), $intDays); + $arrSummary = DomainExternalSeoSearchConsoleProviderPlanHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取 Search Console provider 计划失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoSearchConsoleFetchSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intDays = max(1, min(30, (int)$Request->get('days', 7))); + $arrSummary = DomainExternalSeoSearchConsoleFetchHelper::buildSummary($this->bootstrapCodeRoot(), $intDays); + $arrSummary = DomainExternalSeoSearchConsoleFetchHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取 Search Console fetch 摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoBaiduProviderPlan(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrSummary = DomainExternalSeoBaiduProviderPlanHelper::buildSummary($this->bootstrapCodeRoot()); + $arrSummary = DomainExternalSeoBaiduProviderPlanHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取百度 provider 计划失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoBaiduPushSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(200, (int)$Request->get('limit', 50))); + $arrSummary = DomainExternalSeoBaiduPushSummaryHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit); + $arrSummary = DomainExternalSeoBaiduPushSummaryHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取百度推送观察摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoBaiduPushTaskStatus(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + return $this->success(DomainExternalSeoBaiduPushTaskStatusHelper::buildSummary()); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取百度推送任务状态失败:' . $Throwable->getMessage()); + } + } + + public function saveDomainExternalSeoBaiduPushTaskStatus(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrData = [ + 'pt_enable' => (int)$Request->post('pt_enable', 0), + 'pt_limit' => (int)$Request->post('pt_limit', 0), + ]; + $this->validate($arrData, [ + 'pt_enable' => 'require|number|in:0,1', + 'pt_limit' => 'require|number|egt:0', + ]); + + $PlanTaskModel = \app\model\PlanTaskModel::where('pt_code', 'PUSH_BAIDU_VIDEO_URL')->find(); + if (!$PlanTaskModel) { + return $this->error('9999', '未找到 PUSH_BAIDU_VIDEO_URL 任务配置'); + } + + $PlanTaskModel->pt_enable = $arrData['pt_enable']; + $PlanTaskModel->pt_limit = $arrData['pt_limit']; + $PlanTaskModel->save(); + + return $this->success(DomainExternalSeoBaiduPushTaskStatusHelper::buildSummary()); + } catch (\Throwable $Throwable) { + return $this->error('9999', '保存百度推送任务状态失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoBaiduPushAttentionQueue(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(300, (int)$Request->get('limit', 100))); + $arrQueue = DomainExternalSeoBaiduPushAttentionQueueHelper::buildQueue($this->bootstrapCodeRoot(), $intLimit); + $arrQueue = DomainExternalSeoBaiduPushAttentionQueueHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrQueue); + $arrQueue['summary_json_path'] = $this->publicRelativePath((string)($arrQueue['summary_json_path'] ?? '')); + $arrQueue['summary_html_path'] = $this->publicRelativePath((string)($arrQueue['summary_html_path'] ?? '')); + return $this->success($arrQueue); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取百度推送异常队列失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoBaiduPushTrendSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 20))); + $arrSummary = DomainExternalSeoBaiduPushTrendHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit); + $arrSummary = DomainExternalSeoBaiduPushTrendHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取百度推送趋势摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoBaiduFeedbackSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 20))); + $arrSummary = DomainExternalSeoBaiduFeedbackSummaryHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit); + $arrSummary = DomainExternalSeoBaiduFeedbackSummaryHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取百度反馈摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoSiteQueryProbeSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 20))); + $arrSummary = DomainExternalSeoSiteQueryProbeHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit); + $arrSummary = DomainExternalSeoSiteQueryProbeHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取百度 site: 收录探测失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoAizhanKeywordProbeSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 20))); + $arrSummary = DomainExternalSeoAizhanKeywordProbeHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit); + $arrSummary = DomainExternalSeoAizhanKeywordProbeHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取爱站关键词摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoAizhanKeywordAttentionQueue(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(300, (int)$Request->get('limit', 100))); + $arrQueue = DomainExternalSeoAizhanKeywordAttentionQueueHelper::buildQueue($this->bootstrapCodeRoot(), $intLimit); + $arrQueue = DomainExternalSeoAizhanKeywordAttentionQueueHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrQueue); + $arrQueue['summary_json_path'] = $this->publicRelativePath((string)($arrQueue['summary_json_path'] ?? '')); + $arrQueue['summary_html_path'] = $this->publicRelativePath((string)($arrQueue['summary_html_path'] ?? '')); + return $this->success($arrQueue); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取爱站关键词异常队列失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoAizhanKeywordTrendSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 20))); + $arrSummary = DomainExternalSeoAizhanKeywordTrendHelper::buildSummary($this->bootstrapCodeRoot(), $intLimit); + $arrSummary = DomainExternalSeoAizhanKeywordTrendHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取爱站关键词趋势摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoCollectHosts(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(5000, (int)$Request->get('limit', 1000))); + $boolIncludeWildcard = (int)$Request->get('include_wildcard', 0) === 1; + return $this->success(DomainExternalSeoSnapshotHelper::buildCollectHosts($intLimit, $boolIncludeWildcard)); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外采集域名列表失败:' . $Throwable->getMessage()); + } + } + + public function saveDomainExternalSeoSnapshots(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrSnapshots = $Request->post('snapshots/a', []); + if (empty($arrSnapshots)) { + $strSnapshotsJson = trim((string)$Request->post('snapshots_json', '')); + if ($strSnapshotsJson !== '') { + $arrDecoded = json_decode($strSnapshotsJson, true); + $arrSnapshots = is_array($arrDecoded) ? $arrDecoded : []; + } + } + if (empty($arrSnapshots) && $Request->getContent()) { + $arrDecoded = json_decode((string)$Request->getContent(), true); + if (is_array($arrDecoded)) { + $arrSnapshots = (array)($arrDecoded['snapshots'] ?? $arrDecoded); + } + } + + if (empty($arrSnapshots)) { + return $this->error('9999', '请提交 snapshots'); + } + + $strSource = trim((string)$Request->post('source', 'push_api')); + $arrResult = DomainExternalSeoSnapshotHelper::ingestSnapshots((array)$arrSnapshots, $strSource); + $strRunRoot = DomainExternalSeoSnapshotRunHelper::createRunRoot($this->domainExternalSeoSnapshotRunRoot(), 'snapshot_push'); + $arrResult = DomainExternalSeoSnapshotRunHelper::writeRunArtifacts( + $this->bootstrapCodeRoot() . '/public', + $strRunRoot, + $arrResult, + (array)$arrSnapshots + ); + $arrResult['run_root'] = $this->publicRelativePath($strRunRoot); + $arrResult['summary_json_path'] = $this->publicRelativePath((string)($arrResult['summary_json_path'] ?? '')); + $arrResult['summary_html_path'] = $this->publicRelativePath((string)($arrResult['summary_html_path'] ?? '')); + $arrResult['payload_json_path'] = $this->publicRelativePath((string)($arrResult['payload_json_path'] ?? '')); + $arrOverview = DomainExternalSeoCnOverviewHelper::buildOverview(14, 10); + $arrOverview = DomainExternalSeoCnOverviewHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrOverview); + $arrOverview['summary_json_path'] = $this->publicRelativePath((string)($arrOverview['summary_json_path'] ?? '')); + $arrOverview['summary_html_path'] = $this->publicRelativePath((string)($arrOverview['summary_html_path'] ?? '')); + + $arrSnapshotTrend = DomainExternalSeoSnapshotTrendHelper::buildSummary($this->domainExternalSeoSnapshotRunRoot(), 10); + $arrSnapshotTrend = DomainExternalSeoSnapshotTrendHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSnapshotTrend); + $arrSnapshotTrend['summary_json_path'] = $this->publicRelativePath((string)($arrSnapshotTrend['summary_json_path'] ?? '')); + $arrSnapshotTrend['summary_html_path'] = $this->publicRelativePath((string)($arrSnapshotTrend['summary_html_path'] ?? '')); + + $arrResult['post_ingest_overview'] = $arrOverview; + $arrResult['post_ingest_snapshot_trend'] = $arrSnapshotTrend; + $arrResult = DomainExternalSeoSnapshotRunHelper::writeRunArtifacts( + $this->bootstrapCodeRoot() . '/public', + $strRunRoot, + $arrResult, + (array)$arrSnapshots + ); + $arrResult['summary_json_path'] = $this->publicRelativePath((string)($arrResult['summary_json_path'] ?? '')); + $arrResult['summary_html_path'] = $this->publicRelativePath((string)($arrResult['summary_html_path'] ?? '')); + $arrResult['payload_json_path'] = $this->publicRelativePath((string)($arrResult['payload_json_path'] ?? '')); + $arrSnapshotTrend = DomainExternalSeoSnapshotTrendHelper::buildSummary($this->domainExternalSeoSnapshotRunRoot(), 10); + $arrSnapshotTrend = DomainExternalSeoSnapshotTrendHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSnapshotTrend); + $arrSnapshotTrend['summary_json_path'] = $this->publicRelativePath((string)($arrSnapshotTrend['summary_json_path'] ?? '')); + $arrSnapshotTrend['summary_html_path'] = $this->publicRelativePath((string)($arrSnapshotTrend['summary_html_path'] ?? '')); + $arrResult['post_ingest_snapshot_trend'] = $arrSnapshotTrend; + return $this->success($arrResult); + } catch (\Throwable $Throwable) { + return $this->error('9999', '保存站外 SEO 快照失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoSnapshotSchema(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + return $this->success(DomainExternalSeoSnapshotSchemaHelper::buildSchema()); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外 SEO 快照字段规则失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoSnapshotChecklist(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + return $this->success(DomainExternalSeoSnapshotSchemaHelper::buildChecklist()); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外 SEO 快照字段校验清单失败:' . $Throwable->getMessage()); + } + } + + public function validateDomainExternalSeoSnapshots(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrSnapshots = $Request->post('snapshots/a', []); + if (empty($arrSnapshots)) { + $strSnapshotsJson = trim((string)$Request->post('snapshots_json', '')); + if ($strSnapshotsJson !== '') { + $arrDecoded = json_decode($strSnapshotsJson, true); + $arrSnapshots = is_array($arrDecoded) ? $arrDecoded : []; + } + } + if (empty($arrSnapshots) && $Request->getContent()) { + $arrDecoded = json_decode((string)$Request->getContent(), true); + if (is_array($arrDecoded)) { + $arrSnapshots = (array)($arrDecoded['snapshots'] ?? $arrDecoded); + } + } + if (empty($arrSnapshots)) { + return $this->error('9999', '请提交 snapshots'); + } + + $arrResult = DomainExternalSeoSnapshotSchemaHelper::validateSnapshots((array)$arrSnapshots); + $strRunRoot = DomainExternalSeoSnapshotValidateRunHelper::createRunRoot($this->domainExternalSeoSnapshotValidateRunRoot(), 'snapshot_validate'); + $arrResult = DomainExternalSeoSnapshotValidateRunHelper::writeRunArtifacts( + $this->bootstrapCodeRoot() . '/public', + $strRunRoot, + $arrResult, + (array)$arrSnapshots + ); + $arrResult['run_root'] = $this->publicRelativePath($strRunRoot); + $arrResult['summary_json_path'] = $this->publicRelativePath((string)($arrResult['summary_json_path'] ?? '')); + $arrResult['summary_html_path'] = $this->publicRelativePath((string)($arrResult['summary_html_path'] ?? '')); + $arrResult['payload_json_path'] = $this->publicRelativePath((string)($arrResult['payload_json_path'] ?? '')); + + return $this->success($arrResult); + } catch (\Throwable $Throwable) { + return $this->error('9999', '校验站外 SEO 快照失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoSnapshotValidateRuns(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 20))); + return $this->success(DomainExternalSeoSnapshotValidateRunIndexHelper::buildSummary($this->domainExternalSeoSnapshotValidateRunRoot(), $intLimit)); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外 SEO 快照校验记录失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoIntegrationOverview(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 10))); + return $this->success(DomainExternalSeoIntegrationOverviewHelper::buildSummary( + $this->domainExternalSeoSnapshotValidateRunRoot(), + $this->domainExternalSeoSnapshotRunRoot(), + $intLimit + )); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外 SEO 联调总览失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoCollectorStatus(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 10))); + return $this->success(DomainExternalSeoCollectorStatusHelper::buildSummary( + $this->domainExternalSeoSnapshotValidateRunRoot(), + $this->domainExternalSeoSnapshotRunRoot(), + $intLimit + )); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取 seo-collector 正式链路状态失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoSnapshotRuns(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(100, (int)$Request->get('limit', 20))); + $arrSummary = DomainExternalSeoSnapshotRunIndexHelper::buildSummary($this->domainExternalSeoSnapshotRunRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外 SEO 快照接收记录失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoSnapshotTrend(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 10))); + $arrSummary = DomainExternalSeoSnapshotTrendHelper::buildSummary($this->domainExternalSeoSnapshotRunRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外 SEO 快照接收趋势失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoSnapshotSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intDays = max(1, min(90, (int)$Request->get('days', 7))); + $intLimit = max(1, min(200, (int)$Request->get('limit', 20))); + return $this->success(DomainExternalSeoSnapshotAnalysisHelper::buildSummary($intDays, $intLimit)); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外快照摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoKeywordMovementSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intDays = max(2, min(90, (int)$Request->get('days', 14))); + $intLimit = max(1, min(200, (int)$Request->get('limit', 50))); + return $this->success(DomainExternalSeoSnapshotAnalysisHelper::buildKeywordMovementSummary($intDays, $intLimit)); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取关键词升降摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoIndexMovementSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intDays = max(2, min(90, (int)$Request->get('days', 14))); + $intLimit = max(1, min(200, (int)$Request->get('limit', 50))); + return $this->success(DomainExternalSeoSnapshotAnalysisHelper::buildIndexMovementSummary($intDays, $intLimit)); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取收录变化摘要失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoOptimizationSuggestions(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intDays = max(2, min(90, (int)$Request->get('days', 14))); + $intLimit = max(1, min(100, (int)$Request->get('limit', 20))); + return $this->success(DomainExternalSeoOptimizationSuggestionHelper::buildSuggestions($intDays, $intLimit)); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外 SEO 优化建议失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoCnOverview(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intDays = max(2, min(90, (int)$Request->get('days', 14))); + $intLimit = max(1, min(100, (int)$Request->get('limit', 20))); + return $this->success(DomainExternalSeoCnOverviewHelper::buildOverview($intDays, $intLimit)); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取大陆 SEO 总览失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportExternalSeoClosure(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intDays = max(2, min(90, (int)$Request->get('days', 14))); + $intLimit = max(1, min(50, (int)$Request->get('limit', 10))); + return $this->success(DomainImportExternalSeoClosureHelper::buildSummary( + $this->domainImportRunRoot(), + $this->domainImportRerunRoot(), + $this->domainImportSelfHealingRoot(), + $intDays, + $intLimit + )); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取导入与站外 SEO 闭环总览失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportExternalSeoClosureActionSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + return $this->success(DomainImportExternalSeoClosureActionIndexHelper::buildSummary($this->domainImportExternalSeoClosureActionRoot(), $intLimit)); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取闭环动作记录失败:' . $Throwable->getMessage()); + } + } + + public function runDomainImportExternalSeoClosureAction(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strActionKey = trim((string)$Request->post('action_key', '')); + $strActionLabel = trim((string)$Request->post('action_label', '')); + $strActionSummary = trim((string)$Request->post('action_summary', '')); + $strClosureStage = trim((string)$Request->post('closure_stage', '')); + $strHealthLabel = trim((string)$Request->post('health_label', '')); + $strStatus = trim((string)$Request->post('status', 'pending')); + $strNote = trim((string)$Request->post('status_note', '')); + $strTargetType = trim((string)$Request->post('target_type', '')); + $strTargetKey = trim((string)$Request->post('target_key', '')); + $strTargetHost = trim((string)$Request->post('target_host', '')); + + $arrClosure = DomainImportExternalSeoClosureHelper::buildSummary( + $this->domainImportRunRoot(), + $this->domainImportRerunRoot(), + $this->domainImportSelfHealingRoot(), + 14, + 10 + ); + + $runRoot = DomainImportExternalSeoClosureActionHelper::createRunRoot($this->domainImportExternalSeoClosureActionRoot(), $strActionKey !== '' ? $strActionKey : 'closure_action'); + $summary = DomainImportExternalSeoClosureActionHelper::persist([ + 'action_key' => $strActionKey, + 'action_label' => $strActionLabel, + 'action_summary' => $strActionSummary, + 'closure_stage' => $strClosureStage !== '' ? $strClosureStage : (string)($arrClosure['closure_stage'] ?? ''), + 'health_label' => $strHealthLabel !== '' ? $strHealthLabel : (string)($arrClosure['health_label'] ?? ''), + 'target_type' => $strTargetType, + 'target_key' => $strTargetKey, + 'target_host' => $strTargetHost, + 'status' => $strStatus !== '' ? $strStatus : 'pending', + 'status_note' => $strNote, + 'baseline' => DomainImportExternalSeoClosureActionHelper::baselineFromClosure($arrClosure), + ], $runRoot); + + $summary['run_root_public'] = $this->publicRelativePath($runRoot); + $summary['summary_json_path'] = $this->publicRelativePath((string)($summary['summary_json_path'] ?? '')); + $summary['summary_html_path'] = $this->publicRelativePath((string)($summary['summary_html_path'] ?? '')); + + return $this->success($summary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '写入闭环动作记录失败:' . $Throwable->getMessage()); + } + } + + public function updateDomainImportExternalSeoClosureActionStatus(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strRunId = trim((string)$Request->post('run_id', '')); + $strStatus = trim((string)$Request->post('status', 'pending')); + $strNote = trim((string)$Request->post('status_note', '')); + $strRunRoot = $this->findClosureActionRunRoot($strRunId); + if ($strRunRoot === '') { + return $this->error('9999', '闭环动作记录不存在'); + } + + $summary = DomainImportExternalSeoClosureActionHelper::updateStatus($strRunRoot, $strStatus, $strNote); + $summary['run_root_public'] = $this->publicRelativePath($strRunRoot); + $summary['summary_json_path'] = $this->publicRelativePath((string)($summary['summary_json_path'] ?? '')); + $summary['summary_html_path'] = $this->publicRelativePath((string)($summary['summary_html_path'] ?? '')); + + return $this->success($summary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '更新闭环动作状态失败:' . $Throwable->getMessage()); + } + } + + public function refreshDomainImportExternalSeoClosureActionResult(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strRunId = trim((string)$Request->post('run_id', '')); + $strRunRoot = $this->findClosureActionRunRoot($strRunId); + if ($strRunRoot === '') { + return $this->error('9999', '闭环动作记录不存在'); + } + + $arrClosure = DomainImportExternalSeoClosureHelper::buildSummary( + $this->domainImportRunRoot(), + $this->domainImportRerunRoot(), + $this->domainImportSelfHealingRoot(), + 14, + 10 + ); + $summary = DomainImportExternalSeoClosureActionHelper::refreshResult($strRunRoot, $arrClosure); + $summary['run_root_public'] = $this->publicRelativePath($strRunRoot); + $summary['summary_json_path'] = $this->publicRelativePath((string)($summary['summary_json_path'] ?? '')); + $summary['summary_html_path'] = $this->publicRelativePath((string)($summary['summary_html_path'] ?? '')); + + return $this->success($summary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '刷新闭环动作结果失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportExternalSeoClosureToday(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intDays = max(2, min(90, (int)$Request->get('days', 14))); + $intLimit = max(1, min(20, (int)$Request->get('limit', 10))); + return $this->success(DomainImportExternalSeoClosureTodayHelper::buildSummary( + $this->domainImportRunRoot(), + $this->domainImportRerunRoot(), + $this->domainImportSelfHealingRoot(), + $this->domainImportExternalSeoClosureActionRoot(), + $intDays, + $intLimit + )); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取闭环今日值班面失败:' . $Throwable->getMessage()); + } + } + + public function getDomainImportExternalSeoClosureHostHistory(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strHost = trim((string)$Request->get('host', '')); + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + return $this->success(DomainImportExternalSeoClosureHostHistoryHelper::buildSummary( + $this->domainImportExternalSeoClosureActionRoot(), + $strHost, + $intLimit + )); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取闭环 Host 历史失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoRealSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intDays = max(1, min(30, (int)$Request->get('days', 7))); + $arrSummary = DomainExternalSeoRealSummaryHelper::buildSummary($this->bootstrapCodeRoot(), $intDays); + $arrSummary = DomainExternalSeoRealSummaryHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取真实站外 summary 骨架失败:' . $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoManualImportTemplate(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrTemplate = DomainExternalSeoManualImportHelper::writeTemplate($this->domainExternalSeoManualImportRoot()); + return $this->success([ + 'file_name' => (string)($arrTemplate['file_name'] ?? ''), + 'file_path' => $this->publicRelativePath((string)($arrTemplate['file_path'] ?? '')), + 'summary' => $arrTemplate['summary'] ?? [], + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '生成站外手工导入模板失败:' . $Throwable->getMessage()); + } + } + + public function uploadDomainExternalSeoManualImport(Request $Request, ?AdminUserModel $AdminUserModel) + { + $File = $Request->file('excel_file'); + if (!$File) { + return $this->error('9999', '请上传站外手工导入 Excel 文件'); + } + + try { + $strExt = strtolower((string)$File->getOriginalExtension()); + if (!in_array($strExt, ['xls', 'xlsx', 'csv'], true)) { + return $this->error('9999', '仅支持 xls、xlsx 或 csv 文件'); + } + + $strRoot = $this->domainExternalSeoManualImportRoot(); + if (!is_dir($strRoot) && !mkdir($strRoot, 0777, true) && !is_dir($strRoot)) { + throw new \RuntimeException('创建站外手工导入目录失败:' . $strRoot); + } + + if ($strExt === 'csv') { + $strUploadedPath = $strRoot . '/uploaded.' . $strExt; + copy($File->getPathname(), $strUploadedPath); + $arrResult = DomainExternalSeoManualImportHelper::importCsv($strUploadedPath, $this->bootstrapCodeRoot()); + } else { + $strUploadedPath = $strRoot . '/uploaded.' . $strExt; + copy($File->getPathname(), $strUploadedPath); + $arrResult = DomainExternalSeoManualImportHelper::importWorkbook($strUploadedPath, $this->bootstrapCodeRoot()); + } + + $strRunRoot = (string)($arrResult['run_root'] ?? ''); + if ($strRunRoot !== '') { + $strRunUploadedPath = $strRunRoot . '/uploaded.' . $strExt; + copy($File->getPathname(), $strRunUploadedPath); + } else { + $strRunUploadedPath = ''; + } + + $arrRealSummary = DomainExternalSeoRealSummaryHelper::buildSummary($this->bootstrapCodeRoot(), 7); + $arrRealSummary = DomainExternalSeoRealSummaryHelper::writeArtifacts($this->domainExternalSeoSummaryRoot(), $arrRealSummary); + if ($strRunRoot !== '') { + DomainExternalSeoRealSummaryHelper::writeArtifacts($strRunRoot, $arrRealSummary); + } + + return $this->success([ + 'uploaded_excel_path' => $this->publicRelativePath($strUploadedPath), + 'uploaded_report_path' => $this->publicRelativePath($strUploadedPath), + 'csv_path' => $this->publicRelativePath((string)($arrResult['csv_path'] ?? '')), + 'run_root' => $this->publicRelativePath($strRunRoot), + 'run_csv_path' => $this->publicRelativePath((string)($arrResult['run_csv_path'] ?? '')), + 'run_uploaded_report_path' => $this->publicRelativePath($strRunUploadedPath), + 'rows_total' => (int)($arrResult['rows_total'] ?? 0), + 'imported_count' => (int)($arrResult['imported_count'] ?? 0), + 'real_summary_status' => (string)($arrRealSummary['status'] ?? ''), + 'real_metrics_available' => !empty($arrRealSummary['real_metrics_available']), + 'real_summary_provider' => (string)($arrRealSummary['provider_key'] ?? ''), + 'metrics' => (array)($arrRealSummary['metrics'] ?? []), + 'summary_json_path' => $this->publicRelativePath((string)($arrRealSummary['summary_json_path'] ?? '')), + 'summary_html_path' => $this->publicRelativePath((string)($arrRealSummary['summary_html_path'] ?? '')), + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '导入站外手工报表失败:' . $Throwable->getMessage()); + } + } + + /** + * 列表 + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ + public function getDomainList(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrData = [ + "page" => $Request->get('page', 1), + "limit" => $Request->get('limit', 10), + "key" => $Request->get('key'), + ]; + + $arrRule = [ + 'page' => 'require|number', + 'limit' => 'require|number', + ]; + + $this->validate($arrData, $arrRule); + + $DomainModel = DomainModel::alias('d'); + + if (!empty($arrData['key'])) { + $strKey = $arrData['key']; + $DomainModel->where(function ($DomainModel) use ($strKey) { + $DomainModel->whereOr([ + ['d.d_domain', 'like', "%" . $strKey . "%"], + ['d.d_id', 'like', "%" . $strKey . "%"], + ]); + }); + } + $Paginate = $DomainModel->paginate([ 'list_rows' => $arrData['limit'], 'page' => $arrData['page'], ]); + $arrItems = []; + foreach ($Paginate->items() as $DomainRow) { + if ($DomainRow instanceof DomainModel) { + $arrState = SeoCopyGenerationHelper::readState((string)($DomainRow->d_domain ?? '')); + if (empty($arrState)) { + $arrState = SeoCopyGenerationHelper::buildState($DomainRow); + } + + $DomainRow->copy_generation_status = (string)($arrState['status'] ?? ''); + $DomainRow->copy_generation_release_gate = (string)($arrState['release_gate'] ?? ''); + $DomainRow->copy_generation_message = (string)($arrState['message'] ?? ''); + $DomainRow->copy_generation_wait_for_ai = !empty($arrState['wait_for_ai_before_publish']) ? 1 : 0; + $DomainRow->copy_generation_tkd_mode = (string)($arrState['tkd_mode'] ?? ''); + $DomainRow->copy_generation_tkd_provider = (string)($arrState['tkd_provider'] ?? ''); + $DomainRow->copy_generation_updated_at = (string)($arrState['updated_at'] ?? ''); + $arrHistory = SeoCopyGenerationHelper::readHistory((string)($DomainRow->d_domain ?? '')); + $arrLatestHistory = []; + if (!empty($arrHistory)) { + $arrLatestHistory = end($arrHistory); + if (!is_array($arrLatestHistory)) { + $arrLatestHistory = []; + } + } + $DomainRow->copy_generation_latest_action = (string)($arrLatestHistory['action'] ?? ''); + $DomainRow->copy_generation_latest_provider = (string)($arrLatestHistory['provider_effective'] ?? ''); + $DomainRow->copy_generation_latest_message = (string)($arrLatestHistory['message'] ?? ''); + $DomainRow->copy_generation_latest_recorded_at = (string)($arrLatestHistory['recorded_at'] ?? ''); + $arrAiTriggerItem = $this->buildSeoCopyAiTriggerItem((string)($DomainRow->d_domain ?? '')); + $DomainRow->copy_generation_ai_score = (int)($arrAiTriggerItem['score'] ?? 0); + $DomainRow->copy_generation_ai_recommend = !empty($arrAiTriggerItem['recommend_ai']) ? 1 : 0; + $DomainRow->copy_generation_feedback_keyword_count = (int)($arrAiTriggerItem['feedback_keyword_count'] ?? 0); + $DomainRow->copy_generation_ai_reasons = (array)($arrAiTriggerItem['reasons'] ?? []); + } + + $arrItems[] = $DomainRow; + } + $arrResult = [ - 'items' => $Paginate->items(), + 'items' => $arrItems, 'total' => $Paginate->total(), 'current_page' => $Paginate->currentPage(), 'total_pages' => $Paginate->lastPage(), ]; - + return $this->success($arrResult); } + protected function buildSeoResourcePoolSummary(): array + { + $strRoot = SeoResourcePoolHelper::resourceRoot(); + $arrLatestKeywords = SeoResourcePoolHelper::readJsonFile($strRoot . '/keyword_feedback/hot_keywords.latest.json'); + $arrHosts = (array)($arrLatestKeywords['hosts'] ?? []); + $arrKeywords = (array)($arrLatestKeywords['keywords'] ?? []); + $arrPositioningFiles = glob($strRoot . '/site_positioning/by_host/*.json') ?: []; + $arrFeedbackFiles = glob($strRoot . '/keyword_feedback/by_host/*.json') ?: []; - /** - * 添加|修改 域名 - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ + return [ + 'resource_root' => $strRoot, + 'site_positioning_count' => count($arrPositioningFiles), + 'keyword_feedback_host_count' => count($arrFeedbackFiles), + 'hot_keyword_count' => count($arrKeywords), + 'hot_keyword_updated_at' => (string)($arrLatestKeywords['updated_at'] ?? ''), + 'latest_days' => (int)($arrLatestKeywords['days'] ?? 0), + 'reflected_host_count' => count($arrHosts), + 'indexed_like_host_count' => count(array_filter($arrHosts, static fn(array $item): bool => !empty($item['indexed_like']))), + 'keyword_ready_host_count' => count(array_filter($arrHosts, static fn(array $item): bool => !empty($item['keyword_ready']))), + 'traffic_ready_host_count' => count(array_filter($arrHosts, static fn(array $item): bool => !empty($item['traffic_ready']))), + 'node_feedback_ready' => !empty($arrLatestKeywords), + 'top_keywords' => array_slice(array_map(static function (array $item): array { + return [ + 'keyword' => (string)($item['keyword'] ?? ''), + 'host' => (string)($item['host'] ?? ''), + 'source' => (string)($item['source'] ?? ''), + 'weight' => (float)($item['weight'] ?? 0), + ]; + }, $arrKeywords), 0, 12), + ]; + } + + protected function buildSeoCopyAiTriggerSummary(): array + { + $arrScoreSummary = SeoResourcePoolHelper::buildAiTriggerScore('', 500); + $arrItems = array_values((array)($arrScoreSummary['items'] ?? [])); + + return [ + 'generated_at' => (string)($arrScoreSummary['generated_at'] ?? ''), + 'recommend_count' => (int)($arrScoreSummary['recommend_count'] ?? 0), + 'top_items' => array_slice($arrItems, 0, 20), + ]; + } + + protected function buildSeoCopyAiTriggerItem(string $strHost): array + { + $strHost = DomainModel::normalizeHost($strHost); + if ($strHost === '') { + return []; + } + + $arrRules = SeoResourcePoolHelper::readJsonFile(SeoResourcePoolHelper::resourceRoot() . '/common/ai_trigger_rules.json'); + $arrEnabledRules = []; + foreach ((array)($arrRules['rules'] ?? []) as $arrRule) { + if (!empty($arrRule['enabled']) && !empty($arrRule['key'])) { + $arrEnabledRules[(string)$arrRule['key']] = (string)($arrRule['description'] ?? ''); + } + } + + $arrState = SeoCopyGenerationHelper::readState($strHost); + $arrPositioning = SeoResourcePoolHelper::readJsonFile( + SeoResourcePoolHelper::resourceRoot() . '/site_positioning/by_host/' . SeoResourcePoolHelper::hostFileKey($strHost) . '.json' + ); + $arrFeedback = SeoResourcePoolHelper::readJsonFile( + SeoResourcePoolHelper::resourceRoot() . '/keyword_feedback/by_host/' . SeoResourcePoolHelper::hostFileKey($strHost) . '.json' + ); + $arrHistory = SeoCopyGenerationHelper::readHistory($strHost); + $arrLatestHistory = !empty($arrHistory) ? end($arrHistory) : []; + if (!is_array($arrLatestHistory)) { + $arrLatestHistory = []; + } + + $arrReasons = []; + $intScore = 0; + $boolWaitForAi = !empty($arrState['wait_for_ai_before_publish']) || !empty($arrPositioning['wait_for_ai_before_publish']); + if ($boolWaitForAi && isset($arrEnabledRules['wait_before_publish'])) { + $intScore += 40; + $arrReasons[] = $arrEnabledRules['wait_before_publish']; + } + + $arrPriorityKeywords = (array)($arrPositioning['priority_keywords'] ?? []); + if (count($arrPriorityKeywords) < 2 && isset($arrEnabledRules['low_quality_local_copy'])) { + $intScore += 20; + $arrReasons[] = $arrEnabledRules['low_quality_local_copy']; + } + + $arrFeedbackKeywords = (array)($arrFeedback['keywords'] ?? []); + $boolKeywordReady = !empty($arrFeedback['summary']['keyword_ready']); + $boolIndexedLike = !empty($arrFeedback['summary']['indexed_like']); + if ($boolIndexedLike && !$boolKeywordReady && isset($arrEnabledRules['indexed_without_keywords'])) { + $intScore += 25; + $arrReasons[] = $arrEnabledRules['indexed_without_keywords']; + } + + if (count($arrFeedbackKeywords) >= 3 && count($arrPriorityKeywords) <= 2 && isset($arrEnabledRules['keyword_feedback_drift'])) { + $intScore += 20; + $arrReasons[] = $arrEnabledRules['keyword_feedback_drift']; + } + + $strStatus = (string)($arrState['status'] ?? 'draft'); + return [ + 'host' => $strHost, + 'score' => $intScore, + 'recommend_ai' => $intScore >= 40 || $strStatus === SeoCopyGenerationHelper::STATUS_AI_PENDING, + 'status' => $strStatus, + 'provider_preference' => (string)($arrState['provider_preference'] ?? ''), + 'latest_provider' => (string)($arrLatestHistory['provider_effective'] ?? ''), + 'latest_recorded_at' => (string)($arrLatestHistory['recorded_at'] ?? ''), + 'feedback_keyword_count' => count($arrFeedbackKeywords), + 'priority_keyword_count' => count($arrPriorityKeywords), + 'reasons' => array_values(array_unique($arrReasons)), + ]; + } + + + /** + * 添加|修改 域名 + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ public function saveDomain(Request $Request, ?AdminUserModel $AdminUserModel) { $arrData = [ "d_id" => $Request->post('d_id'), - "d_domain" => $Request->post('d_domain'), + "d_domain" => '', "t_id" => $Request->post('t_id'), "d_name" => $Request->post('d_name'), "d_keywords" => $Request->post('d_keywords'), @@ -175,316 +3170,830 @@ class Site extends BaseController "d_index_keywords" => $Request->post('d_index_keywords'), "d_index_description" => $Request->post('d_index_description'), "d_statis" => $Request->post('d_statis'), - "d_logo_type" => $Request->post('d_logo_type'), + "d_logo_type" => $Request->post('d_logo_type') ?? 0, "d_text_logo" => $Request->post('d_text_logo'), - "d_img_log" => $Request->post('d_img_log'), - "d_content_encode" => $Request->post('d_content_encode'), + "d_img_logo" => $Request->post('d_img_logo'), + "d_content_encode" => $Request->post('d_content_encode') ?? 0, "t_cfg" => $Request->post('t_cfg'), "info_id" => $Request->post('info_id'), "d_baidu_token" => $Request->post('d_baidu_token'), + "d_match_type" => $this->normalizeMatchType($Request->post('d_match_type')), + "d_parent_domain" => $this->normalizeParentDomain($Request->post('d_parent_domain')), + "d_seed_scope" => $this->normalizeSeedScope($Request->post('d_seed_scope')), + "d_seo_cfg" => [], ]; - + $arrData['d_domain'] = $this->normalizeStoredDomain($Request->post('d_domain'), $arrData['d_match_type']); + $arrRule = [ // 'd_id' => 'require|number', 'd_domain' => 'require', + 't_id' => 'require|number', + 'd_name' => 'require', 'd_logo_type' => 'require|number', 'd_content_encode' => 'require|number', 't_cfg' => 'require', + 'd_match_type' => 'require', + 'd_seed_scope' => 'require', ]; $this->validate($arrData, $arrRule); + $DomainExists = DomainModel::where('d_domain', $arrData['d_domain'])->find(); + if (!empty($DomainExists) && (empty($arrData['d_id']) || (int)$DomainExists->d_id !== (int)$arrData['d_id'])) { + return $this->error("9999", '站点域名已存在'); + } + if ($arrData['d_id']) { - /** @var DomainModel **/ - $DomainModel = DomainModel::where('d_id', $arrData['d_id'])->findOrEmpty(); + $DomainModel = DomainModel::where('d_id', $arrData['d_id'])->find(); + if (empty($DomainModel)) { + return $this->error('9993'); + } } else { $DomainModel = new DomainModel; } + $mSeoCfgInput = $Request->post('d_seo_cfg'); + if ($mSeoCfgInput === null || $mSeoCfgInput === '') { + $mSeoCfgInput = $DomainModel->d_seo_cfg ?? []; + } + $arrData['d_seo_cfg'] = $this->normalizeSeoCfgForDomainData($mSeoCfgInput, $arrData); + $DomainModel->fill($arrData); $DomainModel->save(); DomainModel::flushAllDomianOnCache(); + SeoCopyGenerationHelper::initializeForDomain($DomainModel); return $this->success(); } + public function submitDomainSeoCopyAiGenerate(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrIds = $Request->post('d_ids/a', []); + if (!is_array($arrIds)) { + $arrIds = []; + } + $arrIds = array_values(array_unique(array_filter(array_map('intval', $arrIds), static fn (int $intId): bool => $intId > 0))); + $boolWaitForAi = (int)$Request->post('wait_for_ai_before_publish', 1) === 1; + + if (empty($arrIds)) { + return $this->error('9999', '请先选择至少一个站点'); + } + + $arrDomains = DomainModel::whereIn('d_id', $arrIds)->select()->all(); + if (empty($arrDomains)) { + return $this->error('9999', '未找到可提交的站点'); + } + + $arrProviderCfg = SeoCopyAiProviderHelper::resolveProviderConfig(); + $strRequestedProvider = (string)($arrProviderCfg['provider'] ?? DomainModel::TKD_PROVIDER_LOCAL); + + foreach ($arrDomains as $DomainModel) { + if (!$DomainModel instanceof DomainModel) { + continue; + } + + $arrSeoCfg = $DomainModel->getSeoCfg(); + $arrSeoCfg['copy_generation'] = array_merge( + (array)($arrSeoCfg['copy_generation'] ?? []), + [ + 'status' => SeoCopyGenerationHelper::STATUS_AI_PENDING, + 'release_gate' => $boolWaitForAi ? 'wait_ai' : 'open', + 'wait_for_ai_before_publish' => $boolWaitForAi, + 'provider_preference' => 'ai_first', + 'ai_provider' => $strRequestedProvider, + ] + ); + $DomainModel->d_seo_cfg = $arrSeoCfg; + $DomainModel->save(); + SeoCopyGenerationHelper::initializeForDomain($DomainModel, [ + 'status' => SeoCopyGenerationHelper::STATUS_AI_PENDING, + 'release_gate' => $boolWaitForAi ? 'wait_ai' : 'open', + ]); + } + + DomainModel::flushAllDomianOnCache(); + + $job = $this->enqueueDomainImportAsyncJob('seo_copy_ai_generate', [ + 'd_ids' => $arrIds, + 'wait_for_ai_before_publish' => $boolWaitForAi ? 1 : 0, + 'provider' => $strRequestedProvider, + ]); + + return $this->success([ + 'queued_count' => count($arrIds), + 'wait_for_ai_before_publish' => $boolWaitForAi ? 1 : 0, + 'job' => $job, + ]); + } + + public function optimizeDomainSeoCopyAiGenerate(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrIds = $Request->post('d_ids/a', []); + if (!is_array($arrIds)) { + $arrIds = []; + } + $arrIds = array_values(array_unique(array_filter(array_map('intval', $arrIds), static fn (int $intId): bool => $intId > 0))); + if (empty($arrIds)) { + return $this->error('9999', '请先选择至少一个站点'); + } + + $arrDomains = DomainModel::whereIn('d_id', $arrIds)->select()->all(); + if (empty($arrDomains)) { + return $this->error('9999', '未找到可优化的站点'); + } + + $arrProviderCfg = SeoCopyAiProviderHelper::resolveProviderConfig(); + $strRequestedProvider = (string)($arrProviderCfg['provider'] ?? DomainModel::TKD_PROVIDER_LOCAL); + + foreach ($arrDomains as $DomainModel) { + if (!$DomainModel instanceof DomainModel) { + continue; + } + + $arrSeoCfg = $DomainModel->getSeoCfg(); + $arrSeoCfg['copy_generation'] = array_merge( + (array)($arrSeoCfg['copy_generation'] ?? []), + [ + 'status' => SeoCopyGenerationHelper::STATUS_AI_PENDING, + 'release_gate' => 'wait_ai', + 'wait_for_ai_before_publish' => true, + 'provider_preference' => 'ai_first', + 'ai_provider' => $strRequestedProvider, + ] + ); + $DomainModel->d_seo_cfg = $arrSeoCfg; + $DomainModel->save(); + SeoCopyGenerationHelper::initializeForDomain($DomainModel, [ + 'status' => SeoCopyGenerationHelper::STATUS_AI_PENDING, + 'release_gate' => 'wait_ai', + ]); + } + + DomainModel::flushAllDomianOnCache(); + + $job = $this->enqueueDomainImportAsyncJob('seo_copy_ai_optimize', [ + 'd_ids' => $arrIds, + 'wait_for_ai_before_publish' => 1, + 'provider' => $strRequestedProvider, + ]); + + return $this->success([ + 'queued_count' => count($arrIds), + 'wait_for_ai_before_publish' => 1, + 'job' => $job, + ]); + } + + public function applyDomainStrategyProfileBatch(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrIds = $Request->post('d_ids/a', []); + if (!is_array($arrIds)) { + $arrIds = []; + } + $arrIds = array_values(array_unique(array_filter(array_map('intval', $arrIds), static fn (int $intId): bool => $intId > 0))); + $strStrategyProfile = DomainModel::normalizeSeoStrategyProfile((string)$Request->post('strategy_profile', DomainModel::SEO_STRATEGY_PROFILE_STANDARD)); + + if (empty($arrIds)) { + return $this->error('9999', '请先选择至少一个站点'); + } + + $arrDomains = DomainModel::whereIn('d_id', $arrIds)->select()->all(); + if (empty($arrDomains)) { + return $this->error('9999', '未找到可更新的站点'); + } + + $intUpdatedCount = 0; + $arrUpdatedDomains = []; + + foreach ($arrDomains as $DomainModel) { + if (!$DomainModel instanceof DomainModel) { + continue; + } + + $arrDomainData = [ + 'd_domain' => (string)$DomainModel->d_domain, + 'd_match_type' => (string)($DomainModel->d_match_type ?? DomainModel::MATCH_TYPE_EXACT), + 'd_parent_domain' => (string)($DomainModel->d_parent_domain ?? ''), + 'd_seed_scope' => (string)($DomainModel->d_seed_scope ?? DomainModel::SEED_SCOPE_DOMAIN), + ]; + $DomainModel->d_seo_cfg = $this->applySeoStrategyProfileForDomainData($DomainModel->d_seo_cfg ?? [], $arrDomainData, $strStrategyProfile); + $DomainModel->save(); + $intUpdatedCount++; + $arrUpdatedDomains[] = [ + 'd_id' => (int)$DomainModel->d_id, + 'd_domain' => (string)$DomainModel->d_domain, + 'strategy_profile' => (string)($DomainModel->d_seo_cfg['strategy_profile'] ?? $strStrategyProfile), + ]; + } + + DomainModel::flushAllDomianOnCache(); + + return $this->success([ + 'strategy_profile' => $strStrategyProfile, + 'updated_count' => $intUpdatedCount, + 'updated_domains' => $arrUpdatedDomains, + ]); + } + + public function getBootstrapEnvTemplateList(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrSummary = DomainBootstrapEnvTemplateHelper::inspect([ + 'code_root' => $this->bootstrapCodeRoot(), + 'env_file' => $this->bootstrapEnvFile(), + ]); + + return $this->success($arrSummary); + } + + public function getBootstrapOpsSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrSummary = SeoCopyPortalHomeHelper::buildSummary($this->bootstrapPortalRoot()); + $arrSummary['domain_supply'] = DomainSupplyRunIndexHelper::buildSummary($this->domainSupplyRunRoot(), 10, $arrSummary); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', $Throwable->getMessage()); + } + } + + public function getBootstrapWorkbenchSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrOpsSummary = SeoCopyPortalHomeHelper::buildSummary($this->bootstrapPortalRoot()); + $arrEnvSummary = DomainBootstrapEnvTemplateHelper::inspect([ + 'code_root' => $this->bootstrapCodeRoot(), + 'env_file' => $this->bootstrapEnvFile(), + ]); + + $arrDomainSupplySummary = DomainSupplyRunIndexHelper::buildSummary($this->domainSupplyRunRoot(), 10, $arrOpsSummary); + $arrBreedingMainline = $this->buildBreedingMainlineSummary($arrOpsSummary, $arrDomainSupplySummary); + + $arrBreedingMainlineWorkbench = $this->buildBreedingMainlineWorkbench($arrBreedingMainline, $arrOpsSummary, $arrDomainSupplySummary); + + $arrBreedingMainlineProgram = $this->buildBreedingMainlineProgram($arrBreedingMainline, $arrBreedingMainlineWorkbench, $arrOpsSummary, $arrDomainSupplySummary); + + $arrBreedingMainlineOrchestration = $this->buildBreedingMainlineOrchestration($arrBreedingMainline, $arrBreedingMainlineWorkbench, $arrBreedingMainlineProgram, $arrOpsSummary); + + $arrBreedingMainlineExecutionPlan = $this->buildBreedingMainlineExecutionPlan($arrBreedingMainline, $arrBreedingMainlineWorkbench, $arrBreedingMainlineProgram, $arrBreedingMainlineOrchestration); + $arrBreedingMainlineExecutionChain = $this->buildBreedingMainlineExecutionChain($arrBreedingMainlineExecutionPlan, $arrBreedingMainlineOrchestration, $arrBreedingMainlineProgram); + $arrBreedingMainlineRunbook = $this->buildBreedingMainlineRunbook( + $arrBreedingMainlineExecutionChain, + $arrBreedingMainlineExecutionPlan, + $arrBreedingMainlineOrchestration, + $arrBreedingMainlineProgram, + $arrBreedingMainlineWorkbench, + $arrBreedingMainline + ); + $arrBreedingMainlineDispatch = $this->buildBreedingMainlineDispatch( + $arrBreedingMainlineRunbook, + $arrBreedingMainlineExecutionChain, + $arrBreedingMainlineExecutionPlan, + $arrBreedingMainlineOrchestration, + $arrBreedingMainlineProgram + ); + $arrBreedingMainlineScheduler = $this->buildBreedingMainlineScheduler( + $arrBreedingMainlineDispatch, + $arrBreedingMainlineRunbook, + $arrBreedingMainlineExecutionChain, + $arrBreedingMainlineExecutionPlan + ); + $arrBreedingMainlineController = $this->buildBreedingMainlineController( + $arrBreedingMainlineScheduler, + $arrBreedingMainlineDispatch, + $arrBreedingMainlineRunbook, + $arrBreedingMainlineExecutionChain, + $arrBreedingMainlineExecutionPlan + ); + + return $this->success([ + 'generated_at' => date('c'), + 'portal_root' => $this->bootstrapPortalRoot(), + 'bootstrap_ops' => $arrOpsSummary, + 'bootstrap_env' => $arrEnvSummary, + 'domain_supply' => $arrDomainSupplySummary, + 'breeding_mainline' => $arrBreedingMainline, + 'breeding_mainline_workbench' => $arrBreedingMainlineWorkbench, + 'breeding_mainline_program' => $arrBreedingMainlineProgram, + 'breeding_mainline_orchestration' => $arrBreedingMainlineOrchestration, + 'breeding_mainline_execution_plan' => $arrBreedingMainlineExecutionPlan, + 'breeding_mainline_execution_chain' => $arrBreedingMainlineExecutionChain, + 'breeding_mainline_runbook' => $arrBreedingMainlineRunbook, + 'breeding_mainline_dispatch' => $arrBreedingMainlineDispatch, + 'breeding_mainline_scheduler' => $arrBreedingMainlineScheduler, + 'breeding_mainline_controller' => $arrBreedingMainlineController, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', $Throwable->getMessage()); + } + } + + public function previewBootstrapEnvTemplate(Request $Request, ?AdminUserModel $AdminUserModel) + { + $strTemplate = $this->bootstrapTemplateKey($Request->post('template')); + + try { + $arrSummary = DomainBootstrapEnvTemplateHelper::apply([ + 'code_root' => $this->bootstrapCodeRoot(), + 'env_file' => $this->bootstrapEnvFile(), + 'template' => $strTemplate, + 'dry_run' => true, + 'source' => 'admin-preview', + 'record_latest' => true, + ]); + + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', $Throwable->getMessage()); + } + } + + public function applyBootstrapEnvTemplate(Request $Request, ?AdminUserModel $AdminUserModel) + { + $strTemplate = $this->bootstrapTemplateKey($Request->post('template')); + + try { + $arrSummary = DomainBootstrapEnvTemplateHelper::apply([ + 'code_root' => $this->bootstrapCodeRoot(), + 'env_file' => $this->bootstrapEnvFile(), + 'template' => $strTemplate, + 'dry_run' => false, + 'source' => 'admin-apply', + 'record_latest' => true, + ]); + + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', $Throwable->getMessage()); + } + } + + public function previewBootstrapEnvHistoryRestore(Request $Request, ?AdminUserModel $AdminUserModel) + { + $strActionId = trim((string)$Request->post('action_id')); + $strSnapshotSide = strtolower(trim((string)$Request->post('snapshot_side', 'after'))); + + try { + $arrSummary = DomainBootstrapEnvTemplateHelper::restoreFromAction([ + 'code_root' => $this->bootstrapCodeRoot(), + 'env_file' => $this->bootstrapEnvFile(), + 'action_id' => $strActionId, + 'snapshot_side' => $strSnapshotSide, + 'dry_run' => true, + 'source' => 'admin-restore-preview', + 'record_latest' => true, + ]); + + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', $Throwable->getMessage()); + } + } + + public function applyBootstrapEnvHistoryRestore(Request $Request, ?AdminUserModel $AdminUserModel) + { + $strActionId = trim((string)$Request->post('action_id')); + $strSnapshotSide = strtolower(trim((string)$Request->post('snapshot_side', 'after'))); + + try { + $arrSummary = DomainBootstrapEnvTemplateHelper::restoreFromAction([ + 'code_root' => $this->bootstrapCodeRoot(), + 'env_file' => $this->bootstrapEnvFile(), + 'action_id' => $strActionId, + 'snapshot_side' => $strSnapshotSide, + 'dry_run' => false, + 'source' => 'admin-restore-apply', + 'record_latest' => true, + ]); + + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', $Throwable->getMessage()); + } + } + + public function getDomainExternalSeoManualImportRuns(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrSummary = DomainExternalSeoManualImportRunIndexHelper::buildSummary($this->domainExternalSeoManualImportRoot()); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取站外手工报表记录失败:' . $Throwable->getMessage()); + } + } + /** * 列表 - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ - public function getTemplateList(Request $Request, ?AdminUserModel $AdminUserModel) - { - $arrData = [ - "page" => $Request->get('page', 1), - "limit" => $Request->get('limit', 10), - "key" => $Request->get('key'), - ]; - - $arrRule = [ - 'page' => 'require|number', - 'limit' => 'require|number', - ]; - - $this->validate($arrData, $arrRule); - - $TemplatesModel = TemplatesModel::alias('t'); - - if (!empty($arrData['key'])) { - $strKey = $arrData['key']; - $TemplatesModel->where(function ($TemplatesModel) use ($strKey) { - $TemplatesModel->whereOr([ - ['t.t_code', 'like', "%" . $strKey . "%"], - ]); - }); - } - - $Paginate = $TemplatesModel->paginate([ - 'list_rows' => $arrData['limit'], - 'page' => $arrData['page'], - ]); - - $arrResult = [ - 'items' => $Paginate->items(), - 'total' => $Paginate->total(), - 'current_page' => $Paginate->currentPage(), - 'total_pages' => $Paginate->lastPage(), - ]; - - return $this->success($arrResult); - } - - /** - * 替换模板分组列表 - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ - public function getSubjectFomartGroupList(Request $Request, ?AdminUserModel $AdminUserModel) - { - $arrData = [ - "page" => $Request->get('page', 1), - "limit" => $Request->get('limit', 10), - "key" => $Request->get('key'), - ]; - - $arrRule = [ - 'page' => 'require|number', - 'limit' => 'require|number', - ]; - - $this->validate($arrData, $arrRule); - - $SubjectFomartGroupModel = SubjectFomartGroupModel::alias('sfg'); - - if (!empty($arrData['key'])) { - $strKey = $arrData['key']; - $SubjectFomartGroupModel->where(function ($SubjectFomartGroupModel) use ($strKey) { - $SubjectFomartGroupModel->whereOr([ - ['sfg.sfg_name', 'like', "%" . $strKey . "%"], - ['sfg.sfg_description', 'like', "%" . $strKey . "%"], - ]); - }); - } - - $Paginate = $SubjectFomartGroupModel->paginate([ - 'list_rows' => $arrData['limit'], - 'page' => $arrData['page'], - ]); - - $arrResult = [ - 'items' => $Paginate->items(), - 'total' => $Paginate->total(), - 'current_page' => $Paginate->currentPage(), - 'total_pages' => $Paginate->lastPage(), - ]; - - return $this->success($arrResult); - } - - /** - * 添加|修改 替换模板分组 - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ - public function saveSubjectFomartGroup(Request $Request, ?AdminUserModel $AdminUserModel) - { - $arrData = [ - "sfg_id" => $Request->post('sfg_id'), - "sfg_name" => $Request->post('sfg_name'), - "sfg_description" => $Request->post('sfg_description'), - - ]; - - $arrRule = [ - // 'sfg_id' => 'require|number', - 'sfg_name' => 'require', - 'sfg_description' => 'require', - ]; - - $this->validate($arrData, $arrRule); - - if ($arrData['sfg_id']) { - $SubjectFomartGroupModel = SubjectFomartGroupModel::where('sfg_id', $arrData['sfg_id'])->findOrEmpty(); - } else { - $SubjectFomartGroupModel = new SubjectFomartGroupModel; - } - - $SubjectFomartGroupModel->fill($arrData); - - $SubjectFomartGroupModel->save(); - return $this->success(); - } - - /** - * 添加|修改 替换模板分组 - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ - public function delSubjectFomartGroup(Request $Request, ?AdminUserModel $AdminUserModel) - { - $arrData = [ - "sfg_id" => $Request->post('sfg_id'), - - ]; - - $arrRule = [ - 'sfg_id' => 'require', - ]; - - $this->validate($arrData, $arrRule); - - $arrSfgId = explode(',', $arrData['sfg_id']); - - SubjectFomartGroupModel::whereIn('sfg_id', $arrSfgId)->delete(); - - SubjectFomartModel::whereIn('sfg_id', $arrSfgId)->delete(); - - return $this->success(); - } - - - - - /** - * 替换模板分组列表 - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ - public function getSubjectFomartList(Request $Request, ?AdminUserModel $AdminUserModel) - { - $arrData = [ - "page" => $Request->get('page', 1), - "limit" => $Request->get('limit', 10), - "key" => $Request->get('key'), - "sfg_id" => $Request->get('sfg_id'), - ]; - - $arrRule = [ - 'page' => 'require|number', - 'limit' => 'require|number', - ]; - - $this->validate($arrData, $arrRule); - - $SubjectFomartModel = SubjectFomartModel::alias('sf'); - - if (!empty($arrData['key'])) { - $strKey = $arrData['key']; - $SubjectFomartModel->where(function ($SubjectFomartModel) use ($strKey) { - $SubjectFomartModel->whereOr([ - ['sf.sf_val', 'like', "%" . $strKey . "%"], - ]); - }); - } - if (!empty($arrData['sfg_id'])) { - $SubjectFomartModel->where('sf.sfg_id', $arrData['sfg_id']); - } - - $Paginate = $SubjectFomartModel->paginate([ - 'list_rows' => $arrData['limit'], - 'page' => $arrData['page'], - ]); - - $arrResult = [ - 'items' => $Paginate->items(), - 'total' => $Paginate->total(), - 'current_page' => $Paginate->currentPage(), - 'total_pages' => $Paginate->lastPage(), - ]; - - return $this->success($arrResult); - } - - /** - * 添加|修改 替换模板分组 - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ - public function saveSubjectFomart(Request $Request, ?AdminUserModel $AdminUserModel) - { - $arrData = [ - "sf_id" => $Request->post('sf_id'), - "sfg_id" => $Request->post('sfg_id'), - "sf_val" => $Request->post('sf_val'), - - ]; - - $arrRule = [ - // 'sf_id' => 'require|number', - 'sfg_id' => 'require|number', - 'sf_val' => 'require', - ]; - - $this->validate($arrData, $arrRule); - - if ($arrData['sf_id']) { - $SubjectFomartModel = SubjectFomartModel::where('sf_id', $arrData['sf_id'])->findOrEmpty(); - } else { - $SubjectFomartModel = new SubjectFomartModel; - } - - $SubjectFomartModel->fill($arrData); - - $SubjectFomartModel->save(); - return $this->success(); - } - - /** - * 添加|修改 替换模板分组 - * - * @param Request $Request - * @param AdminUserModel|null $AdminUserModel - * @return Response - */ + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ + public function getTemplateList(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrData = [ + "page" => $Request->get('page', 1), + "limit" => $Request->get('limit', 10), + "key" => $Request->get('key'), + ]; + + $arrRule = [ + 'page' => 'require|number', + 'limit' => 'require|number', + ]; + + $this->validate($arrData, $arrRule); + + $TemplatesModel = TemplatesModel::alias('t'); + + if (!empty($arrData['key'])) { + $strKey = $arrData['key']; + $TemplatesModel->where(function ($TemplatesModel) use ($strKey) { + $TemplatesModel->whereOr([ + ['t.t_code', 'like', "%" . $strKey . "%"], + ]); + }); + } + + $Paginate = $TemplatesModel->paginate([ + 'list_rows' => $arrData['limit'], + 'page' => $arrData['page'], + ]); + + $arrResult = [ + 'items' => $Paginate->items(), + 'total' => $Paginate->total(), + 'current_page' => $Paginate->currentPage(), + 'total_pages' => $Paginate->lastPage(), + ]; + + return $this->success($arrResult); + } + + /** + * 替换模板分组列表 + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ + public function getSubjectFomartGroupList(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrData = [ + "page" => $Request->get('page', 1), + "limit" => $Request->get('limit', 10), + "key" => $Request->get('key'), + ]; + + $arrRule = [ + 'page' => 'require|number', + 'limit' => 'require|number', + ]; + + $this->validate($arrData, $arrRule); + + $SubjectFomartGroupModel = SubjectFomartGroupModel::alias('sfg'); + + if (!empty($arrData['key'])) { + $strKey = $arrData['key']; + $SubjectFomartGroupModel->where(function ($SubjectFomartGroupModel) use ($strKey) { + $SubjectFomartGroupModel->whereOr([ + ['sfg.sfg_name', 'like', "%" . $strKey . "%"], + ['sfg.sfg_description', 'like', "%" . $strKey . "%"], + ]); + }); + } + + $Paginate = $SubjectFomartGroupModel->paginate([ + 'list_rows' => $arrData['limit'], + 'page' => $arrData['page'], + ]); + + $arrResult = [ + 'items' => $Paginate->items(), + 'total' => $Paginate->total(), + 'current_page' => $Paginate->currentPage(), + 'total_pages' => $Paginate->lastPage(), + ]; + + return $this->success($arrResult); + } + + /** + * 添加|修改 替换模板分组 + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ + public function saveSubjectFomartGroup(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrData = [ + "sfg_id" => $Request->post('sfg_id'), + "sfg_name" => $Request->post('sfg_name'), + "sfg_description" => $Request->post('sfg_description'), + + ]; + + $arrRule = [ + // 'sfg_id' => 'require|number', + 'sfg_name' => 'require', + 'sfg_description' => 'require', + ]; + + $this->validate($arrData, $arrRule); + + if ($arrData['sfg_id']) { + $SubjectFomartGroupModel = SubjectFomartGroupModel::where('sfg_id', $arrData['sfg_id'])->findOrEmpty(); + } else { + $SubjectFomartGroupModel = new SubjectFomartGroupModel; + } + + $SubjectFomartGroupModel->fill($arrData); + + $SubjectFomartGroupModel->save(); + return $this->success(); + } + + /** + * 添加|修改 替换模板分组 + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ + public function delSubjectFomartGroup(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrData = [ + "sfg_id" => $Request->post('sfg_id'), + + ]; + + $arrRule = [ + 'sfg_id' => 'require', + ]; + + $this->validate($arrData, $arrRule); + + $arrSfgId = explode(',', $arrData['sfg_id']); + + SubjectFomartGroupModel::whereIn('sfg_id', $arrSfgId)->delete(); + + SubjectFomartModel::whereIn('sfg_id', $arrSfgId)->delete(); + + return $this->success(); + } + + + + + /** + * 替换模板分组列表 + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ + public function getSubjectFomartList(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrData = [ + "page" => $Request->get('page', 1), + "limit" => $Request->get('limit', 10), + "key" => $Request->get('key'), + "sfg_id" => $Request->get('sfg_id'), + ]; + + $arrRule = [ + 'page' => 'require|number', + 'limit' => 'require|number', + ]; + + $this->validate($arrData, $arrRule); + + $SubjectFomartModel = SubjectFomartModel::alias('sf'); + + if (!empty($arrData['key'])) { + $strKey = $arrData['key']; + $SubjectFomartModel->where(function ($SubjectFomartModel) use ($strKey) { + $SubjectFomartModel->whereOr([ + ['sf.sf_val', 'like', "%" . $strKey . "%"], + ]); + }); + } + if (!empty($arrData['sfg_id'])) { + $SubjectFomartModel->where('sf.sfg_id', $arrData['sfg_id']); + } + + $Paginate = $SubjectFomartModel->paginate([ + 'list_rows' => $arrData['limit'], + 'page' => $arrData['page'], + ]); + + $arrResult = [ + 'items' => $Paginate->items(), + 'total' => $Paginate->total(), + 'current_page' => $Paginate->currentPage(), + 'total_pages' => $Paginate->lastPage(), + ]; + + return $this->success($arrResult); + } + + /** + * 添加|修改 替换模板分组 + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ + public function saveSubjectFomart(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrData = [ + "sf_id" => $Request->post('sf_id'), + "sfg_id" => $Request->post('sfg_id'), + "sf_val" => $Request->post('sf_val'), + + ]; + + $arrRule = [ + // 'sf_id' => 'require|number', + 'sfg_id' => 'require|number', + 'sf_val' => 'require', + ]; + + $this->validate($arrData, $arrRule); + + if ($arrData['sf_id']) { + $SubjectFomartModel = SubjectFomartModel::where('sf_id', $arrData['sf_id'])->findOrEmpty(); + } else { + $SubjectFomartModel = new SubjectFomartModel; + } + + $SubjectFomartModel->fill($arrData); + + $SubjectFomartModel->save(); + return $this->success(); + } + + /** + * 添加|修改 替换模板分组 + * + * @param Request $Request + * @param AdminUserModel|null $AdminUserModel + * @return Response + */ public function delSubjectFomart(Request $Request, ?AdminUserModel $AdminUserModel) { $arrData = [ "sf_id" => $Request->post('sf_id'), - - ]; - - $arrRule = [ - 'sf_id' => 'require', - ]; - - $this->validate($arrData, $arrRule); - - $arrSfId = explode(',', $arrData['sf_id']); - SubjectFomartModel::whereIn('sf_id', $arrSfId)->delete(); + + ]; + + $arrRule = [ + 'sf_id' => 'require', + ]; + + $this->validate($arrData, $arrRule); + + $arrSfId = explode(',', $arrData['sf_id']); + SubjectFomartModel::whereIn('sf_id', $arrSfId)->delete(); return $this->success(); } + + protected function executeDomainImportFailedQueueRun(string $strHost = '', int $intLimit = 50): array + { + $strRunRoot = $this->domainImportRunRoot(); + $strRerunRoot = $this->domainImportRerunRoot(); + + $arrQueueSummary = \app\common\helper\DomainImportFailedQueueHelper::buildSummary($strRunRoot, $intLimit); + $arrQueueItems = (array)($arrQueueSummary['items'] ?? []); + if ($strHost !== '') { + $arrQueueItems = array_values(array_filter($arrQueueItems, static function (array $arrItem) use ($strHost): bool { + return \app\model\DomainModel::normalizeHost((string)($arrItem['host'] ?? '')) === $strHost; + })); + } + + $arrItems = []; + $intPassedCount = 0; + $intFailedCount = 0; + + foreach ($arrQueueItems as $arrQueueItem) { + $strCurrentHost = \app\model\DomainModel::normalizeHost((string)($arrQueueItem['host'] ?? '')); + if ($strCurrentHost === '') { + continue; + } + + $arrSampleResult = \app\common\helper\DomainAutoSampleHelper::discover($strCurrentHost, ['limit' => 80]); + if (($arrSampleResult['status'] ?? '') !== 'passed' || empty($arrSampleResult['sample'])) { + $arrItems[] = [ + 'host' => $strCurrentHost, + 'status' => 'sample_discovery_failed', + 'failed_stage' => 'sample', + 'message' => (string)($arrSampleResult['message'] ?? ''), + ]; + $intFailedCount++; + continue; + } + + $arrProbeResult = \app\common\helper\DomainTrajectoryProbeHelper::probe($strCurrentHost, (array)$arrSampleResult['sample'], [ + 'base_url' => 'https://' . $strCurrentHost, + ]); + if (!empty($arrProbeResult['all_passed'])) { + $intPassedCount++; + } else { + $intFailedCount++; + } + $arrItems[] = $arrProbeResult; + } + + $arrSummary = [ + 'processed_count' => count($arrItems), + 'passed_count' => $intPassedCount, + 'failed_count' => $intFailedCount, + 'items' => $arrItems, + 'queue_count' => (int)($arrQueueSummary['queue_count'] ?? 0), + 'generated_at' => date(DATE_ATOM), + ]; + + $strPersistRunRoot = \app\common\helper\DomainImportRerunRunHelper::createRunRoot($strRerunRoot, 'failed_queue'); + file_put_contents($strPersistRunRoot . '/hosts.txt', implode(PHP_EOL, array_values(array_filter(array_map(static function (array $arrItem): string { + return \app\model\DomainModel::normalizeHost((string)($arrItem['host'] ?? '')); + }, $arrItems)))) . PHP_EOL); + + return \app\common\helper\DomainImportRerunRunHelper::persist($arrSummary, $strPersistRunRoot, [ + 'queue_count' => (int)($arrQueueSummary['queue_count'] ?? 0), + 'selected_host' => $strHost, + 'run_root' => $strRunRoot, + ]); + } + + protected function enqueueDomainImportAsyncJob(string $type, array $payload = []): array + { + $job = \app\common\helper\DomainImportAsyncJobHelper::createJob($type, $payload, [ + 'run_root' => $this->domainImportRunRoot(), + 'rerun_root' => $this->domainImportRerunRoot(), + 'remediation_root' => $this->domainImportRemediationRoot(), + 'self_healing_root' => $this->domainImportSelfHealingRoot(), + 'health_workbench_root' => $this->domainImportHealthWorkbenchRoot(), + ]); + + $queue = \microserver\QueueManage::getInstance(); + $queue->set([ + 'callback' => [\app\task\logic\DomainImportAsyncTaskLogic::class, 'handle'], + 'data' => [ + 'job_id' => (string)($job['job_id'] ?? ''), + ], + ]); + + return $job; + } + + protected function runShellCommand(array $arrCommand): array + { + $strCommand = implode(' ', $arrCommand); + $arrOutput = []; + $intExitCode = 0; + + if (\function_exists('proc_open')) { + $arrDescriptors = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $Process = \proc_open($strCommand, $arrDescriptors, $arrPipes); + if (\is_resource($Process)) { + $strStdout = isset($arrPipes[1]) && \is_resource($arrPipes[1]) ? stream_get_contents($arrPipes[1]) : ''; + $strStderr = isset($arrPipes[2]) && \is_resource($arrPipes[2]) ? stream_get_contents($arrPipes[2]) : ''; + + if (isset($arrPipes[1]) && \is_resource($arrPipes[1])) { + fclose($arrPipes[1]); + } + if (isset($arrPipes[2]) && \is_resource($arrPipes[2])) { + fclose($arrPipes[2]); + } + + $intExitCode = \proc_close($Process); + $strCombined = trim($strStdout . ($strStderr !== '' ? PHP_EOL . $strStderr : '')); + $arrOutput = $strCombined === '' ? [] : preg_split('/\r\n|\r|\n/', $strCombined); + + return [ + 'command' => $strCommand, + 'output' => is_array($arrOutput) ? $arrOutput : [], + 'exit_code' => (int)$intExitCode, + ]; + } + } + + if (\function_exists('exec')) { + \exec($strCommand . ' 2>&1', $arrOutput, $intExitCode); + return [ + 'command' => $strCommand, + 'output' => $arrOutput, + 'exit_code' => (int)$intExitCode, + ]; + } + + throw new \RuntimeException('当前 PHP 环境禁用了命令执行能力,请开启 proc_open 或 exec。'); + } } diff --git a/code/app/api/config/router.php b/code/app/api/config/router.php index 7c763a8..c2e100d 100644 --- a/code/app/api/config/router.php +++ b/code/app/api/config/router.php @@ -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); diff --git a/code/app/api/controller/SeoCollector.php b/code/app/api/controller/SeoCollector.php new file mode 100644 index 0000000..7d3c875 --- /dev/null +++ b/code/app/api/controller/SeoCollector.php @@ -0,0 +1,304 @@ +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; + } +} diff --git a/code/app/common/helper/DomainBatchImportTemplateHelper.php b/code/app/common/helper/DomainBatchImportTemplateHelper.php new file mode 100644 index 0000000..2f9cb4d --- /dev/null +++ b/code/app/common/helper/DomainBatchImportTemplateHelper.php @@ -0,0 +1,213 @@ +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, + ]; + } +} diff --git a/code/app/common/helper/DomainBootstrapApplyHelper.php b/code/app/common/helper/DomainBootstrapApplyHelper.php new file mode 100644 index 0000000..bf14be6 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapApplyHelper.php @@ -0,0 +1,132 @@ + $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; + } +} diff --git a/code/app/common/helper/DomainBootstrapBatchTemplateHelper.php b/code/app/common/helper/DomainBootstrapBatchTemplateHelper.php new file mode 100644 index 0000000..84e1201 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapBatchTemplateHelper.php @@ -0,0 +1,216 @@ +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://。', + '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(), + ]; + } +} diff --git a/code/app/common/helper/DomainBootstrapBundleIndexHelper.php b/code/app/common/helper/DomainBootstrapBundleIndexHelper.php new file mode 100644 index 0000000..8b2d144 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapBundleIndexHelper.php @@ -0,0 +1,1102 @@ + 'README.md', + 'site_register' => 'site-register.sample.json', + 'site_register_preview' => 'site-register.preview.json', + 'site_register_result' => 'site-register.result.json', + 'site_bootstrap' => 'site-bootstrap.sample.json', + 'site_apply_preview' => 'site-apply.preview.json', + 'site_apply_result' => 'site-apply.result.json', + 'import_result' => 'batch-import.result.json', + 'publish_result' => 'release-publish.result.json', + ]; + + public 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); + } + } + + 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), + 'bundles' => [], + 'bundles_count' => 0, + ]; + } + + throw new \RuntimeException('Scan root not found: ' . $strScanRoot); + } + + $arrPipelineImportPolicy = self::buildPipelineImportPolicy(); + $arrBundles = []; + foreach ((array)glob($strScanRoot . '/*/bootstrap-summary.json') as $strSummaryPath) { + $arrSummary = json_decode((string)file_get_contents($strSummaryPath), true); + if (!is_array($arrSummary)) { + continue; + } + + $strBundleRoot = dirname($strSummaryPath); + $arrBundleFiles = (array)($arrSummary['bundle_files'] ?? []); + $strManifestPath = self::resolveManifestPath($strBundleRoot, (string)($arrSummary['manifest'] ?? '')); + $strManifestName = preg_replace('/\.manifest\.json$/', '', basename($strManifestPath)); + $strManifestName = preg_replace('/\.json$/', '', (string)$strManifestName); + $strDetailSlug = self::buildDetailSlug((string)($arrSummary['host'] ?? ''), $strBundleRoot); + $arrBundle = [ + 'host' => (string)($arrSummary['host'] ?? ''), + 'detail_slug' => $strDetailSlug, + 'bundle_root' => $strBundleRoot, + 'summary_path' => $strSummaryPath, + 'runbook_path' => self::resolveBundleFilePath($strBundleRoot, 'runbook', (string)($arrBundleFiles['runbook'] ?? '')), + 'site_register_path' => self::resolveBundleFilePath($strBundleRoot, 'site_register', (string)($arrBundleFiles['site_register'] ?? '')), + 'site_register_preview_path' => self::resolveBundleFilePath($strBundleRoot, 'site_register_preview', (string)($arrBundleFiles['site_register_preview'] ?? '')), + 'site_register_result_path' => self::resolveBundleFilePath($strBundleRoot, 'site_register_result', (string)($arrBundleFiles['site_register_result'] ?? '')), + 'site_bootstrap_path' => self::resolveBundleFilePath($strBundleRoot, 'site_bootstrap', (string)($arrBundleFiles['site_bootstrap'] ?? '')), + 'site_apply_preview_path' => self::resolveBundleFilePath($strBundleRoot, 'site_apply_preview', (string)($arrBundleFiles['site_apply_preview'] ?? '')), + 'site_apply_result_path' => self::resolveBundleFilePath($strBundleRoot, 'site_apply_result', (string)($arrBundleFiles['site_apply_result'] ?? '')), + 'import_result_path' => self::resolveBundleFilePath($strBundleRoot, 'import_result', (string)($arrBundleFiles['import_result'] ?? '')), + 'publish_result_path' => self::resolveBundleFilePath($strBundleRoot, 'publish_result', (string)($arrBundleFiles['publish_result'] ?? '')), + 'manifest_path' => $strManifestPath, + 'manifest_name' => $strManifestName, + 'target_root' => rtrim($strBundleRoot, '/') . '/data/seo_copy', + 'approved_dir' => rtrim($strBundleRoot, '/') . '/data/seo_copy_jobs/approved', + 'base_url' => 'https://' . (string)($arrSummary['host'] ?? ''), + 'items_count' => (int)($arrSummary['items_count'] ?? 0), + 'strategy_profile' => (string)($arrSummary['site_strategy']['d_seo_cfg']['strategy_profile'] ?? ''), + 'structured_policy' => (string)($arrSummary['site_strategy']['d_seo_cfg']['structured_data']['policy'] ?? ''), + 'reference_mode' => (string)($arrSummary['site_strategy']['d_seo_cfg']['structured_data']['reference_mode'] ?? ''), + 'forge_exposure_profile' => (string)($arrSummary['site_strategy']['d_seo_cfg']['forge']['exposure_profile'] ?? ''), + 'canonical_mode' => (string)($arrSummary['site_strategy']['d_seo_cfg']['canonical_mode'] ?? ''), + 'play_index_mode' => (string)($arrSummary['site_strategy']['d_seo_cfg']['play_index_mode'] ?? ''), + 'robots_policy' => (string)($arrSummary['site_strategy']['d_seo_cfg']['robots_policy'] ?? ''), + 'register_status' => (string)($arrSummary['site_register_preview']['status'] ?? ''), + 'register_create_fields' => (int)($arrSummary['site_register_preview']['create_fields'] ?? 0), + 'template_id' => (int)($arrSummary['site_register_preview']['template_id'] ?? 0), + 'template_code' => (string)($arrSummary['site_register_preview']['template_code'] ?? ''), + 'register_found' => (bool)($arrSummary['site_register_preview']['found'] ?? false), + 'register_result_status' => (string)($arrSummary['site_register_result']['status'] ?? ''), + 'register_result_at' => (string)($arrSummary['site_register_result']['executed_at'] ?? ''), + 'apply_status' => (string)($arrSummary['site_apply_preview']['status'] ?? ''), + 'apply_changed_fields' => (int)($arrSummary['site_apply_preview']['changed_fields'] ?? 0), + 'apply_found' => (bool)($arrSummary['site_apply_preview']['found'] ?? false), + 'apply_result_status' => (string)($arrSummary['site_apply_result']['status'] ?? ''), + 'apply_result_at' => (string)($arrSummary['site_apply_result']['executed_at'] ?? ''), + 'bundle_run_status' => (string)($arrSummary['bundle_run']['status'] ?? ''), + 'bundle_run_name' => (string)($arrSummary['bundle_run']['run_name'] ?? ''), + 'bundle_run_detail_slug' => (string)($arrSummary['bundle_run']['run_name'] ?? ''), + 'bundle_run_json' => (string)($arrSummary['bundle_run']['run_json'] ?? ''), + 'bundle_run_dry_run' => (bool)($arrSummary['bundle_run']['dry_run'] ?? false), + 'bundle_run_executed_at' => (string)($arrSummary['bundle_run']['executed_at'] ?? ''), + 'prepare_result_status' => (string)($arrSummary['prepare_result']['status'] ?? ''), + 'prepare_result_at' => (string)($arrSummary['prepare_result']['executed_at'] ?? ''), + 'prepare_out_dir' => (string)($arrSummary['prepare_result']['out_dir'] ?? ''), + 'prepare_summary_path' => (string)($arrSummary['prepare_result']['summary_path'] ?? ''), + 'import_result_status' => (string)($arrSummary['import_result']['status'] ?? ''), + 'import_result_at' => (string)($arrSummary['import_result']['executed_at'] ?? ''), + 'import_source_dir' => (string)($arrSummary['import_result']['source_dir'] ?? ''), + 'import_imported_count' => (int)($arrSummary['import_result']['imported_count'] ?? 0), + 'import_skipped_count' => (int)($arrSummary['import_result']['skipped_count'] ?? 0), + 'publish_result_status' => (string)($arrSummary['publish_result']['status'] ?? ''), + 'publish_result_at' => (string)($arrSummary['publish_result']['executed_at'] ?? ''), + 'publish_result_run_dir' => (string)($arrSummary['publish_result']['run_dir'] ?? ''), + 'publish_result_run_summary_path' => (string)($arrSummary['publish_result']['run_summary_path'] ?? ''), + 'publish_result_target_root' => (string)($arrSummary['publish_result']['target_root'] ?? ''), + 'publish_result_published_count' => (int)($arrSummary['publish_result']['published_count'] ?? 0), + 'release_dry_run_status' => (string)($arrSummary['release_dry_run']['status'] ?? ''), + 'release_dry_run_at' => (string)($arrSummary['release_dry_run']['executed_at'] ?? ''), + 'release_dry_run_run_dir' => (string)($arrSummary['release_dry_run']['run_dir'] ?? ''), + 'release_dry_run_summary_path' => (string)($arrSummary['release_dry_run']['run_summary_path'] ?? ''), + 'preview_status' => (string)($arrSummary['site_apply_preview']['status'] ?? ''), + 'preview_changed_fields' => (int)($arrSummary['site_apply_preview']['changed_fields'] ?? 0), + 'found' => (bool)($arrSummary['site_apply_preview']['found'] ?? false), + 'generated_at' => date(DATE_ATOM, (int)filemtime($strSummaryPath)), + 'pipeline_import_requested' => (bool)($arrPipelineImportPolicy['requested'] ?? false), + 'pipeline_import_armed' => (bool)($arrPipelineImportPolicy['armed'] ?? false), + 'pipeline_import_enabled' => (bool)($arrPipelineImportPolicy['enabled'] ?? false), + 'pipeline_import_mode' => (string)($arrPipelineImportPolicy['mode'] ?? 'off'), + 'pipeline_import_source_kind' => (string)($arrPipelineImportPolicy['source_kind'] ?? ''), + 'pipeline_import_source_path' => (string)($arrPipelineImportPolicy['source_path'] ?? ''), + 'pipeline_import_summary' => (string)($arrPipelineImportPolicy['summary'] ?? ''), + 'next_commands' => self::buildNextCommands( + $strBundleRoot, + self::resolveBundleFilePath($strBundleRoot, 'site_register', (string)($arrBundleFiles['site_register'] ?? '')), + self::resolveBundleFilePath($strBundleRoot, 'site_bootstrap', (string)($arrBundleFiles['site_bootstrap'] ?? '')), + rtrim($strBundleRoot, '/') . '/data/seo_copy_jobs/approved', + rtrim($strBundleRoot, '/') . '/data/seo_copy', + 'https://' . (string)($arrSummary['host'] ?? ''), + $strManifestName + ), + ]; + + $arrBundles[] = array_merge($arrBundle, self::buildHandoffState($arrBundle)); + } + + usort($arrBundles, static function (array $a, array $b): int { + return strcmp((string)($b['generated_at'] ?? ''), (string)($a['generated_at'] ?? '')); + }); + + $intRegisterReadyCount = 0; + $intRegisterExistingCount = 0; + $intRegisterResultCount = 0; + $intApplyReadyCount = 0; + $intApplyPendingCount = 0; + $intApplyResultCount = 0; + $intImportResultCount = 0; + $intPublishResultCount = 0; + $intBundleRunCount = 0; + $intHandoffRegisterCount = 0; + $intHandoffApplyCount = 0; + $intHandoffPrepareCount = 0; + $intHandoffImportCount = 0; + $intHandoffReleaseDryRunCount = 0; + $intHandoffPublishCount = 0; + $intHandoffAttentionCount = 0; + + foreach ($arrBundles as $arrBundle) { + $strRegisterStatus = (string)($arrBundle['register_status'] ?? ''); + $strRegisterResultStatus = (string)($arrBundle['register_result_status'] ?? ''); + $strApplyStatus = (string)($arrBundle['apply_status'] ?? ''); + $strApplyResultStatus = (string)($arrBundle['apply_result_status'] ?? ''); + $strImportResultStatus = (string)($arrBundle['import_result_status'] ?? ''); + $strPublishResultStatus = (string)($arrBundle['publish_result_status'] ?? ''); + + if ($strRegisterStatus === 'ready_to_create') { + $intRegisterReadyCount++; + } + if ($strRegisterStatus === 'domain_exists') { + $intRegisterExistingCount++; + } + if (in_array($strRegisterResultStatus, ['created', 'domain_exists', 'ready_to_create'], true)) { + $intRegisterResultCount++; + } + if (in_array($strApplyStatus, ['dry_run', 'no_change', 'applied'], true)) { + $intApplyReadyCount++; + } + if (in_array($strApplyStatus, ['domain_not_found', 'preview_unavailable'], true)) { + $intApplyPendingCount++; + } + if (in_array($strApplyResultStatus, ['applied', 'noop', 'domain_not_found', 'dry_run'], true)) { + $intApplyResultCount++; + } + if ($strImportResultStatus !== '') { + $intImportResultCount++; + } + if ($strPublishResultStatus === 'published') { + $intPublishResultCount++; + } + if ((string)($arrBundle['bundle_run_status'] ?? '') !== '') { + $intBundleRunCount++; + } + $strHandoffCode = (string)($arrBundle['handoff_stage_code'] ?? ''); + if ($strHandoffCode === 'ready_to_register') { + $intHandoffRegisterCount++; + } elseif ($strHandoffCode === 'ready_to_apply') { + $intHandoffApplyCount++; + } elseif ($strHandoffCode === 'ready_for_prepare') { + $intHandoffPrepareCount++; + } elseif ($strHandoffCode === 'ready_to_import') { + $intHandoffImportCount++; + } elseif ($strHandoffCode === 'ready_to_release_dry_run') { + $intHandoffReleaseDryRunCount++; + } elseif ($strHandoffCode === 'ready_to_publish') { + $intHandoffPublishCount++; + } elseif ($strHandoffCode === 'needs_attention') { + $intHandoffAttentionCount++; + } + } + + return [ + 'scan_root' => $strScanRoot, + 'generated_at' => date(DATE_ATOM), + 'bundles' => $arrBundles, + 'bundles_count' => count($arrBundles), + 'register_ready_count' => $intRegisterReadyCount, + 'register_existing_count' => $intRegisterExistingCount, + 'register_result_count' => $intRegisterResultCount, + 'apply_ready_count' => $intApplyReadyCount, + 'apply_pending_count' => $intApplyPendingCount, + 'apply_result_count' => $intApplyResultCount, + 'import_result_count' => $intImportResultCount, + 'publish_result_count' => $intPublishResultCount, + 'bundle_run_count' => $intBundleRunCount, + 'handoff_ready_to_register_count' => $intHandoffRegisterCount, + 'handoff_ready_to_apply_count' => $intHandoffApplyCount, + 'handoff_ready_for_prepare_count' => $intHandoffPrepareCount, + 'handoff_ready_to_import_count' => $intHandoffImportCount, + 'handoff_ready_to_release_dry_run_count' => $intHandoffReleaseDryRunCount, + 'handoff_ready_to_publish_count' => $intHandoffPublishCount, + 'handoff_attention_count' => $intHandoffAttentionCount, + 'latest_bundle' => $arrBundles[0] ?? null, + 'pipeline_import_policy' => $arrPipelineImportPolicy, + ]; + } + + public static function renderHtml(array $arrSummary): string + { + $arrRows = []; + foreach ((array)($arrSummary['bundles'] ?? []) as $arrBundle) { + $strRegisterStatus = htmlspecialchars((string)($arrBundle['register_status'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strApplyStatus = htmlspecialchars((string)($arrBundle['apply_status'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strHost = htmlspecialchars((string)($arrBundle['host'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strStrategyProfile = htmlspecialchars((string)($arrBundle['strategy_profile'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strStructuredPolicy = htmlspecialchars((string)($arrBundle['structured_policy'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strReferenceMode = htmlspecialchars((string)($arrBundle['reference_mode'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strForgeProfile = htmlspecialchars((string)($arrBundle['forge_exposure_profile'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strGeneratedAt = htmlspecialchars((string)($arrBundle['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8'); + $intCreateFields = (int)($arrBundle['register_create_fields'] ?? 0); + $intChangedFields = (int)($arrBundle['apply_changed_fields'] ?? 0); + $intItemsCount = (int)($arrBundle['items_count'] ?? 0); + $strImportPolicy = htmlspecialchars((string)($arrBundle['pipeline_import_mode'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strRunbookPath = htmlspecialchars((string)($arrBundle['runbook_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strRegisterPreviewPath = htmlspecialchars((string)($arrBundle['site_register_preview_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strApplyPreviewPath = htmlspecialchars((string)($arrBundle['site_apply_preview_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strRegisterResultStatus = htmlspecialchars((string)($arrBundle['register_result_status'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strApplyResultStatus = htmlspecialchars((string)($arrBundle['apply_result_status'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strRegisterResultPath = htmlspecialchars((string)($arrBundle['site_register_result_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strApplyResultPath = htmlspecialchars((string)($arrBundle['site_apply_result_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strBundleRunStatus = htmlspecialchars((string)($arrBundle['bundle_run_status'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strBundleRunName = htmlspecialchars((string)($arrBundle['bundle_run_name'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strBundleRunJson = htmlspecialchars((string)($arrBundle['bundle_run_json'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strBundleRunSlug = trim((string)($arrBundle['bundle_run_detail_slug'] ?? '')); + $strHandoffStage = htmlspecialchars((string)($arrBundle['handoff_stage_label'] ?? ''), ENT_QUOTES, 'UTF-8'); + + $strRunArtifactCell = '' . $strBundleRunName . '
' . $strBundleRunJson . ''; + if ($strBundleRunSlug !== '') { + $strRunArtifactCell = '' . $strBundleRunName . '
' . $strBundleRunJson . ''; + } + + $arrRows[] = '' + . '' . $strHost . '' + . '' . $strStrategyProfile . '' + . '' . $strStructuredPolicy . '' + . '' . $strReferenceMode . '' + . '' . $strForgeProfile . '' + . '' . $strImportPolicy . '' + . '' . $strRegisterStatus . '' + . '' . $intCreateFields . '' + . '' . $strRegisterResultStatus . '' + . '' . $strApplyStatus . '' + . '' . $intChangedFields . '' + . '' . $strApplyResultStatus . '' + . '' . $strHandoffStage . '' + . '' . $strBundleRunStatus . '' + . '' . $intItemsCount . '' + . '' . $strGeneratedAt . '' + . '' . $strRunbookPath . '' + . '' . $strRegisterPreviewPath . '' + . '' . $strRegisterResultPath . '' + . '' . $strApplyPreviewPath . '' + . '' . $strApplyResultPath . '' + . '' . $strRunArtifactCell . '' + . ''; + } + + return implode(PHP_EOL, [ + '', + '', + '', + '', + 'Domain Bootstrap Bundles', + '', + '', + '', + '

查看值班入口 | 查看启动包接手队列 | 查看值班待办 | 查看已完成启动包 | 查看启动包运行记录 | 返回统一门户首页 | 查看新域名上线 SOP

', + '

Domain Bootstrap Bundles

', + '

scan_root: ' . htmlspecialchars((string)($arrSummary['scan_root'] ?? ''), ENT_QUOTES, 'UTF-8') . '

', + '

bundles_count: ' . (int)($arrSummary['bundles_count'] ?? 0) . '

', + '

register_ready: ' . (int)($arrSummary['register_ready_count'] ?? 0) . ' / register_existing: ' . (int)($arrSummary['register_existing_count'] ?? 0) . ' / register_done: ' . (int)($arrSummary['register_result_count'] ?? 0) . '

', + '

apply_ready: ' . (int)($arrSummary['apply_ready_count'] ?? 0) . ' / apply_pending: ' . (int)($arrSummary['apply_pending_count'] ?? 0) . ' / apply_done: ' . (int)($arrSummary['apply_result_count'] ?? 0) . ' / import_done: ' . (int)($arrSummary['import_result_count'] ?? 0) . ' / publish_done: ' . (int)($arrSummary['publish_result_count'] ?? 0) . '

', + '

bundle_runs: ' . (int)($arrSummary['bundle_run_count'] ?? 0) . '

', + '

handoff: 待入库 ' . (int)($arrSummary['handoff_ready_to_register_count'] ?? 0) . ' / 待应用 ' . (int)($arrSummary['handoff_ready_to_apply_count'] ?? 0) . ' / 可进 prepare ' . (int)($arrSummary['handoff_ready_for_prepare_count'] ?? 0) . ' / 待导入 ' . (int)($arrSummary['handoff_ready_to_import_count'] ?? 0) . ' / 待跑 dry-run ' . (int)($arrSummary['handoff_ready_to_release_dry_run_count'] ?? 0) . ' / 待发布 ' . (int)($arrSummary['handoff_ready_to_publish_count'] ?? 0) . ' / 需关注 ' . (int)($arrSummary['handoff_attention_count'] ?? 0) . '

', + '

pipeline_import_policy: ' . htmlspecialchars((string)(($arrSummary['pipeline_import_policy'] ?? [])['mode'] ?? 'off'), ENT_QUOTES, 'UTF-8') . ' / requested=' . (((bool)(($arrSummary['pipeline_import_policy'] ?? [])['requested'] ?? false)) ? 'yes' : 'no') . ' / armed=' . (((bool)(($arrSummary['pipeline_import_policy'] ?? [])['armed'] ?? false)) ? 'yes' : 'no') . ' / effective=' . (((bool)(($arrSummary['pipeline_import_policy'] ?? [])['enabled'] ?? false)) ? 'yes' : 'no') . ' / ' . htmlspecialchars((string)(($arrSummary['pipeline_import_policy'] ?? [])['source_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '

', + '', + '', + '', + ...$arrRows, + '', + '
HostStrategyPolicyReferenceForge ProfileImport PolicyRegisterCreate FieldsRegister ResultApplyChangedApply ResultHandoffRun StatusItemsGeneratedRunbookRegister PreviewRegister Result FileApply PreviewApply Result FileRun Artifact
', + '', + '', + ]) . PHP_EOL; + } + + 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'; + $strScanRoot = (string)($arrSummary['scan_root'] ?? ''); + $strPortalRoot = basename(rtrim($strOutputRoot, '/')) === 'bootstrap' + ? dirname(rtrim($strOutputRoot, '/')) + : ''; + + 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['bundles'] ?? []) as $arrBundle) { + if (!is_array($arrBundle)) { + continue; + } + + self::synchronizeBundleLocalFiles($arrBundle, $strScanRoot, $strPortalRoot); + + $strDetailSlug = trim((string)($arrBundle['detail_slug'] ?? '')); + if ($strDetailSlug === '') { + continue; + } + + file_put_contents( + $strDetailsRoot . '/' . $strDetailSlug . '.json', + json_encode($arrBundle, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + file_put_contents( + $strDetailsRoot . '/' . $strDetailSlug . '.html', + self::renderDetailHtml($arrBundle) + ); + } + + return [ + 'json' => $strJsonPath, + 'html' => $strHtmlPath, + ]; + } + + protected static function renderDetailHtml(array $arrBundle): string + { + $arrInfoLines = [ + 'host' => (string)($arrBundle['host'] ?? ''), + 'bundle_root' => (string)($arrBundle['bundle_root'] ?? ''), + 'strategy_profile' => (string)($arrBundle['strategy_profile'] ?? ''), + 'canonical_mode' => (string)($arrBundle['canonical_mode'] ?? ''), + 'play_index_mode' => (string)($arrBundle['play_index_mode'] ?? ''), + 'robots_policy' => (string)($arrBundle['robots_policy'] ?? ''), + 'forge_exposure_profile' => (string)($arrBundle['forge_exposure_profile'] ?? ''), + 'structured_policy' => (string)($arrBundle['structured_policy'] ?? ''), + 'reference_mode' => (string)($arrBundle['reference_mode'] ?? ''), + 'pipeline_import_mode' => (string)($arrBundle['pipeline_import_mode'] ?? ''), + 'pipeline_import_requested' => ((bool)($arrBundle['pipeline_import_requested'] ?? false)) ? 'yes' : 'no', + 'pipeline_import_armed' => ((bool)($arrBundle['pipeline_import_armed'] ?? false)) ? 'yes' : 'no', + 'pipeline_import_enabled' => ((bool)($arrBundle['pipeline_import_enabled'] ?? false)) ? 'yes' : 'no', + 'handoff_stage' => (string)($arrBundle['handoff_stage_label'] ?? ''), + 'register_status' => (string)($arrBundle['register_status'] ?? ''), + 'register_result_status' => (string)($arrBundle['register_result_status'] ?? ''), + 'apply_status' => (string)($arrBundle['apply_status'] ?? ''), + 'apply_result_status' => (string)($arrBundle['apply_result_status'] ?? ''), + 'bundle_run_status' => (string)($arrBundle['bundle_run_status'] ?? ''), + 'prepare_result_status' => (string)($arrBundle['prepare_result_status'] ?? ''), + 'import_result_status' => (string)($arrBundle['import_result_status'] ?? ''), + 'release_dry_run_status' => (string)($arrBundle['release_dry_run_status'] ?? ''), + 'publish_result_status' => (string)($arrBundle['publish_result_status'] ?? ''), + 'items_count' => (string)($arrBundle['items_count'] ?? 0), + 'generated_at' => (string)($arrBundle['generated_at'] ?? ''), + ]; + + $arrPathLines = [ + 'manifest_path' => (string)($arrBundle['manifest_path'] ?? ''), + 'runbook_path' => (string)($arrBundle['runbook_path'] ?? ''), + 'site_register_path' => (string)($arrBundle['site_register_path'] ?? ''), + 'site_register_preview_path' => (string)($arrBundle['site_register_preview_path'] ?? ''), + 'site_register_result_path' => (string)($arrBundle['site_register_result_path'] ?? ''), + 'site_bootstrap_path' => (string)($arrBundle['site_bootstrap_path'] ?? ''), + 'site_apply_preview_path' => (string)($arrBundle['site_apply_preview_path'] ?? ''), + 'site_apply_result_path' => (string)($arrBundle['site_apply_result_path'] ?? ''), + 'import_result_path' => (string)($arrBundle['import_result_path'] ?? ''), + 'publish_result_path' => (string)($arrBundle['publish_result_path'] ?? ''), + 'bundle_run_json' => (string)($arrBundle['bundle_run_json'] ?? ''), + 'prepare_summary_path' => (string)($arrBundle['prepare_summary_path'] ?? ''), + 'prepare_out_dir' => (string)($arrBundle['prepare_out_dir'] ?? ''), + 'import_source_dir' => (string)($arrBundle['import_source_dir'] ?? ''), + 'pipeline_import_source_path' => (string)($arrBundle['pipeline_import_source_path'] ?? ''), + 'release_dry_run_summary_path' => (string)($arrBundle['release_dry_run_summary_path'] ?? ''), + 'release_dry_run_run_dir' => (string)($arrBundle['release_dry_run_run_dir'] ?? ''), + 'publish_result_run_summary_path' => (string)($arrBundle['publish_result_run_summary_path'] ?? ''), + 'publish_result_run_dir' => (string)($arrBundle['publish_result_run_dir'] ?? ''), + 'publish_result_target_root' => (string)($arrBundle['publish_result_target_root'] ?? ''), + ]; + $arrHandoffLines = [ + 'stage' => (string)($arrBundle['handoff_stage_label'] ?? ''), + 'summary' => (string)($arrBundle['handoff_summary'] ?? ''), + 'next_step' => (string)($arrBundle['handoff_next_step'] ?? ''), + 'recommended_command' => (string)($arrBundle['handoff_next_command'] ?? ''), + 'pipeline_import_policy_summary' => (string)($arrBundle['pipeline_import_summary'] ?? ''), + ]; + + $arrCommandRows = []; + foreach ((array)($arrBundle['next_commands'] ?? []) as $strKey => $strCommand) { + $arrCommandRows[] = '' . self::escape((string)$strKey) . '' . self::escape((string)$strCommand) . ''; + } + + $arrInfoRows = []; + foreach ($arrInfoLines as $strKey => $strValue) { + $arrInfoRows[] = '' . self::escape($strKey) . '' . self::escape($strValue) . ''; + } + + $arrPathRows = []; + foreach ($arrPathLines as $strKey => $strValue) { + $arrPathRows[] = '' . self::escape($strKey) . '' . self::escape($strValue) . ''; + } + $arrHandoffRows = []; + foreach ($arrHandoffLines as $strKey => $strValue) { + $strCell = $strKey === 'recommended_command' + ? '' . self::escape($strValue) . '' + : self::escape($strValue); + $arrHandoffRows[] = '' . self::escape($strKey) . '' . $strCell . ''; + } + + return implode(PHP_EOL, [ + '', + '', + '', + '', + 'Bootstrap Bundle Detail', + '', + '', + '', + '

返回启动包总览 | 查看值班入口 | 查看启动包接手队列 | 查看值班待办 | 查看已完成启动包 | 查看启动包运行记录 | 返回统一门户首页 | 查看新域名上线 SOP

', + '

' . self::escape((string)($arrBundle['host'] ?? '')) . '

', + '

状态摘要

', + '' . implode('', $arrInfoRows) . '
', + '

接手判断

', + '' . implode('', $arrHandoffRows) . '
', + '

文件路径

', + '' . implode('', $arrPathRows) . '
', + '

建议命令

', + '' . implode('', $arrCommandRows) . '
stepcommand
', + '', + '', + ]) . PHP_EOL; + } + + protected static function resolveBundleFilePath(string $strBundleRoot, string $strKey, string $strFallback): string + { + $strDefaultFile = self::BUNDLE_FILE_MAP[$strKey] ?? ''; + if ($strDefaultFile !== '') { + $strPreferred = rtrim($strBundleRoot, '/') . '/' . $strDefaultFile; + if (is_file($strPreferred)) { + return $strPreferred; + } + } + + return $strFallback; + } + + 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 buildDetailSlug(string $strHost, string $strBundleRoot): string + { + $strHost = trim(strtolower($strHost)); + $strHost = preg_replace('/[^a-z0-9]+/', '-', $strHost) ?: ''; + $strHost = trim($strHost, '-'); + if ($strHost !== '') { + return $strHost; + } + + $strBase = basename($strBundleRoot); + $strBase = preg_replace('/[^a-zA-Z0-9\-]+/', '-', $strBase) ?: 'bundle'; + return strtolower(trim($strBase, '-')); + } + + protected static function buildNextCommands( + string $strBundleRoot, + string $strRegisterPath, + string $strBootstrapPath, + string $strApprovedDir, + string $strTargetRoot, + string $strBaseUrl, + string $strManifestName + ): array { + $strBundleRoot = rtrim($strBundleRoot, '/'); + $strManifestName = trim($strManifestName); + $strHost = $strBaseUrl !== '' ? (preg_replace('#^https?://#', '', $strBaseUrl) ?: '') : ''; + + return [ + 'pipeline_dry_run' => 'php scripts/domain_bootstrap_pipeline_run.php --host=' . escapeshellarg($strHost) . ' --step-limit=6 --dry-run=1 --allow-prepare=1 --format=text', + 'pipeline' => 'php scripts/domain_bootstrap_pipeline_run.php --host=' . escapeshellarg($strHost) . ' --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 --host=' . escapeshellarg($strHost) . ' --step-limit=3 --dry-run=0 --allow-import=1 --ai-result-dir= --allow-release-dry-run=1 --format=text', + 'pipeline_import_root' => 'php scripts/domain_bootstrap_pipeline_run.php --host=' . escapeshellarg($strHost) . ' --step-limit=3 --dry-run=0 --allow-import=1 --ai-result-root= --allow-release-dry-run=1 --format=text', + 'oncall_dry_run' => 'php scripts/domain_bootstrap_oncall_run.php --host=' . escapeshellarg($strHost) . ' --dry-run=1 --format=text', + 'oncall' => 'php scripts/domain_bootstrap_oncall_run.php --host=' . escapeshellarg($strHost) . ' --dry-run=0 --format=text', + 'run_dry_run' => 'php scripts/domain_bootstrap_run.php ' . escapeshellarg($strBundleRoot) . ' --dry-run=1 --format=text', + 'run' => 'php scripts/domain_bootstrap_run.php ' . escapeshellarg($strBundleRoot) . ' --dry-run=0 --format=text', + 'register_dry_run' => 'php scripts/domain_bootstrap_register.php ' . escapeshellarg($strRegisterPath) . ' --dry-run=1 --format=text', + 'register' => 'php scripts/domain_bootstrap_register.php ' . escapeshellarg($strRegisterPath) . ' --format=text', + 'apply_dry_run' => 'php scripts/domain_seo_bootstrap_apply.php ' . escapeshellarg($strBootstrapPath) . ' --dry-run=1 --format=text', + 'apply' => 'php scripts/domain_seo_bootstrap_apply.php ' . escapeshellarg($strBootstrapPath) . ' --format=text', + 'prepare' => 'php scripts/seo_copy_batch_prepare.php ' . escapeshellarg($strManifestName) . ' --approved-dir=' . escapeshellarg($strApprovedDir), + 'import' => 'php scripts/domain_bootstrap_import_ai.php ' . escapeshellarg($strBundleRoot) . ' --format=text', + 'import_low_level' => 'php scripts/seo_copy_batch_import.php --target-root=' . escapeshellarg($strTargetRoot), + 'dry_run' => 'php scripts/seo_copy_release_run.php ' . escapeshellarg($strManifestName) . ' --approved-dir=' . escapeshellarg($strApprovedDir) . ' --source-root=' . escapeshellarg($strTargetRoot) . ' --base-url=' . escapeshellarg($strBaseUrl) . ' --dry-run=1 --format=text', + 'publish' => 'php scripts/domain_bootstrap_publish.php ' . escapeshellarg($strBundleRoot) . ' --format=text', + 'publish_low_level' => 'php scripts/seo_copy_release_run.php ' . escapeshellarg($strManifestName) . ' --approved-dir=' . escapeshellarg($strApprovedDir) . ' --source-root=' . escapeshellarg($strTargetRoot) . ' --base-url=' . escapeshellarg($strBaseUrl) . ' --format=text', + ]; + } + + protected static function buildHandoffState(array $arrBundle): array + { + $strRegisterStatus = (string)($arrBundle['register_status'] ?? ''); + $strRegisterResult = (string)($arrBundle['register_result_status'] ?? ''); + $strApplyStatus = (string)($arrBundle['apply_status'] ?? ''); + $strApplyResult = (string)($arrBundle['apply_result_status'] ?? ''); + $strRunStatus = (string)($arrBundle['bundle_run_status'] ?? ''); + $strPrepareResultStatus = (string)($arrBundle['prepare_result_status'] ?? ''); + $strPrepareResultAt = (string)($arrBundle['prepare_result_at'] ?? ''); + $strImportResultStatus = (string)($arrBundle['import_result_status'] ?? ''); + $strImportResultAt = (string)($arrBundle['import_result_at'] ?? ''); + $strPublishResultStatus = (string)($arrBundle['publish_result_status'] ?? ''); + $strPublishResultAt = (string)($arrBundle['publish_result_at'] ?? ''); + $strReleaseDryRunStatus = (string)($arrBundle['release_dry_run_status'] ?? ''); + $strReleaseDryRunAt = (string)($arrBundle['release_dry_run_at'] ?? ''); + $arrCommands = (array)($arrBundle['next_commands'] ?? []); + + if ($strPublishResultStatus === 'published') { + return self::formatHandoffState( + 'published', + '已正式发布', + '当前 bundle 已完成正式发布,后续如需继续处理请转入新的 bundle 或新的发布批次。', + '查看发布记录', + (string)($arrCommands['publish_low_level'] ?? '') + ); + } + + $boolImportIsLatestAfterPrepare = $strImportResultAt !== '' + && ($strPrepareResultAt === '' || strcmp($strImportResultAt, $strPrepareResultAt) >= 0); + $strLatestPrereqAt = $boolImportIsLatestAfterPrepare ? $strImportResultAt : $strPrepareResultAt; + $boolReleaseMatchesLatestPrereq = $strReleaseDryRunAt !== '' + && ($strLatestPrereqAt === '' || strcmp($strReleaseDryRunAt, $strLatestPrereqAt) >= 0); + + if ( + $boolReleaseMatchesLatestPrereq + && + in_array($strReleaseDryRunStatus, ['verify_failed', 'front_verify_failed', 'error'], true) + ) { + return self::formatHandoffState( + 'needs_attention', + '需人工关注', + 'release dry-run 门禁未通过,建议先查看 release run 摘要并修正补料内容,再重跑 dry-run。', + '先重跑 release dry-run', + (string)($arrCommands['dry_run'] ?? '') + ); + } + + if ($boolReleaseMatchesLatestPrereq && $strReleaseDryRunStatus === 'dry_run_passed') { + return self::formatHandoffState( + 'ready_to_publish', + '待正式发布', + 'prepare 与 release dry-run 已通过,下一步可以正式执行 publish。', + '正式执行发布', + (string)($arrCommands['publish'] ?? '') + ); + } + + if ($boolImportIsLatestAfterPrepare && in_array($strImportResultStatus, ['imported', 'partially_imported'], true)) { + return self::formatHandoffState( + 'ready_to_release_dry_run', + '待执行 release dry-run', + 'AI 结果已导入 bundle 目标目录,下一步应执行 release dry-run 验证文件层与前台命中,再决定是否正式发布。', + '先跑 release dry-run', + (string)($arrCommands['dry_run'] ?? '') + ); + } + + if ($boolImportIsLatestAfterPrepare && in_array($strImportResultStatus, ['import_failed', 'no_files_imported'], true)) { + return self::formatHandoffState( + 'needs_attention', + '需人工关注', + 'AI 结果导入未成功,当前应先检查导入结果和源目录结构,再决定是否重跑 import。', + '先重跑高层 pipeline import', + (string)($arrCommands['pipeline_import'] ?? ($arrCommands['import'] ?? '')) + ); + } + + if ($strPrepareResultStatus === 'prepared') { + $arrImportGuidance = self::buildReadyToImportGuidance($arrBundle, $arrCommands); + return self::formatHandoffState( + 'ready_to_import', + '待导入 AI 结果', + $arrImportGuidance['summary'], + $arrImportGuidance['next_step'], + $arrImportGuidance['command'] + ); + } + + if ( + in_array($strRunStatus, ['needs_attention', 'dry_run_needs_attention', 'error'], true) + || $strRegisterResult === 'error' + || $strApplyResult === 'error' + ) { + return self::formatHandoffState( + 'needs_attention', + '需人工关注', + '当前 bootstrap 链路里至少有一段需要人工核对,建议先查看 bundle 详情页里的 preview / result 差异,再决定是否重跑。', + '先重跑高层 dry-run 并核对差异', + (string)($arrCommands['run_dry_run'] ?? '') + ); + } + + if (in_array($strApplyResult, ['applied', 'noop'], true)) { + return self::formatHandoffState( + 'ready_for_prepare', + '可进入 Prepare', + '域名记录与 SEO 策略已经正式落地,下一步可以直接进入 prepare 和 release dry-run 门禁链。', + '继续执行 prepare', + (string)($arrCommands['prepare'] ?? '') + ); + } + + if ( + in_array($strRegisterResult, ['domain_exists', 'created'], true) + && in_array($strApplyResult, ['dry_run', ''], true) + ) { + return self::formatHandoffState( + 'ready_to_apply', + '待正式应用', + '域名已存在或已完成入库,当前 apply dry-run 已可用,下一步应正式执行高层 run 或 apply。', + '正式执行高层 run', + (string)($arrCommands['run'] ?? ($arrCommands['apply'] ?? '')) + ); + } + + if ( + $strRegisterResult === 'ready_to_create' + || $strRegisterStatus === 'ready_to_create' + ) { + return self::formatHandoffState( + 'ready_to_register', + '待正式入库', + '当前域名尚未正式入库,但 register 样板已经完整,下一步应创建 domain 记录并继续 apply。', + '正式执行高层 run 或 register', + (string)($arrCommands['run'] ?? ($arrCommands['register'] ?? '')) + ); + } + + if ( + $strApplyStatus === 'domain_not_found' + || $strApplyResult === 'domain_not_found' + ) { + return self::formatHandoffState( + 'needs_attention', + '需先入库', + 'apply 仍返回 domain_not_found,说明当前目标域名还未正式落库,需要先完成 register。', + '先执行高层 run 或 register', + (string)($arrCommands['run'] ?? ($arrCommands['register'] ?? '')) + ); + } + + if ($strRunStatus === 'dry_run_completed') { + return self::formatHandoffState( + 'ready_to_execute', + '待正式执行', + '高层 bootstrap dry-run 已完整通过,下一步可按当前推荐命令正式执行 register + apply。', + '正式执行高层 run', + (string)($arrCommands['run'] ?? '') + ); + } + + return self::formatHandoffState( + 'pending_review', + '待复核', + '当前 bundle 已经有样板和预览,但还没有形成明确的正式动作结果,建议先查看详情页并重跑 dry-run。', + '先执行高层 dry-run', + (string)($arrCommands['run_dry_run'] ?? '') + ); + } + + 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 buildReadyToImportGuidance(array $arrBundle, array $arrCommands): array + { + $strMode = (string)($arrBundle['pipeline_import_mode'] ?? 'off'); + $strSourcePath = trim((string)($arrBundle['pipeline_import_source_path'] ?? '')); + $strSourceHint = $strSourcePath !== '' ? ' 当前来源路径是 <' . $strSourcePath . '>。' : ''; + + return match ($strMode) { + 'requested-not-armed' => [ + 'summary' => 'prepare 已生成 facts/prompt 包,但当前 task 自动导入策略还是 requested-not-armed;这表示已经声明可导入,但还没正式 armed。请先确认 AI 结果目录并显式放行后,再以非 dry-run 方式执行 pipeline import。' . $strSourceHint, + 'next_step' => '先放行 import 策略,再执行高层 pipeline import', + 'command' => (string)($arrCommands['pipeline_import_root'] ?? ($arrCommands['pipeline_import'] ?? ($arrCommands['import'] ?? ''))), + ], + 'dir' => [ + 'summary' => 'prepare 已生成 facts/prompt 包,当前 task 自动导入策略已 armed,并固定读取 AI 结果目录;下一步可以直接以非 dry-run 方式执行高层 pipeline import,导入后再继续 release dry-run。' . $strSourceHint, + 'next_step' => '先执行高层 pipeline import', + 'command' => (string)($arrCommands['pipeline_import'] ?? ($arrCommands['import'] ?? '')), + ], + 'root' => [ + 'summary' => 'prepare 已生成 facts/prompt 包,当前 task 自动导入策略已 armed,并按 ai-result-root 自动找结果目录;下一步可以直接以非 dry-run 方式执行高层 pipeline import。' . $strSourceHint, + 'next_step' => '先执行高层 pipeline import(root)', + 'command' => (string)($arrCommands['pipeline_import_root'] ?? ($arrCommands['pipeline_import'] ?? ($arrCommands['import'] ?? ''))), + ], + 'on' => [ + 'summary' => 'prepare 已生成 facts/prompt 包,但当前虽然允许 import,仍未给出 AI 结果目录;请先补齐 ai-result-dir 或 ai-result-root,再执行高层 pipeline import。', + 'next_step' => '先补齐 AI 结果目录,再执行 pipeline import', + 'command' => (string)($arrCommands['pipeline_import_root'] ?? ($arrCommands['pipeline_import'] ?? ($arrCommands['import'] ?? ''))), + ], + default => [ + 'summary' => 'prepare 已生成 facts/prompt 包,但当前 task 自动导入默认仍关闭;下一步应先确认 AI 结果目录,再以非 dry-run 方式执行高层 pipeline import。', + 'next_step' => '先确认 AI 结果目录,再执行高层 pipeline import', + 'command' => (string)($arrCommands['pipeline_import'] ?? ($arrCommands['import'] ?? '')), + ], + }; + } + + protected static function buildPipelineImportPolicy(): array + { + $boolRequested = self::getBoolEnv('SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_IMPORT', false); + $boolArmed = self::getBoolEnv('SEO_COPY_BOOTSTRAP_PIPELINE_IMPORT_ARMED', false); + $boolEnabled = $boolRequested && $boolArmed; + $strDir = self::getStringEnv('SEO_COPY_BOOTSTRAP_PIPELINE_AI_RESULT_DIR', ''); + $strRoot = self::getStringEnv('SEO_COPY_BOOTSTRAP_PIPELINE_AI_RESULT_ROOT', ''); + + $strMode = 'off'; + $strSourceKind = ''; + $strSourcePath = ''; + if (!$boolEnabled) { + $strMode = $boolRequested && !$boolArmed ? 'requested-not-armed' : 'off'; + } elseif ($strDir !== '') { + $strMode = 'dir'; + $strSourceKind = 'dir'; + $strSourcePath = $strDir; + } elseif ($strRoot !== '') { + $strMode = 'root'; + $strSourceKind = 'root'; + $strSourcePath = $strRoot; + } else { + $strMode = 'on'; + } + + $strSummary = match ($strMode) { + 'requested-not-armed' => '已请求导入但未 armed,task 主链不会自动写入 AI 结果。', + 'dir' => '已 armed,自动从固定 ai_result_dir 读取 AI 结果。', + 'root' => '已 armed,自动从 ai_result_root 按 host 寻找 AI 结果目录。', + 'on' => '已 armed,但尚未配置 AI 结果目录来源。', + default => '默认关闭,需显式放行后才会自动导入 AI 结果。', + }; + + return [ + 'requested' => $boolRequested, + 'armed' => $boolArmed, + 'enabled' => $boolEnabled, + 'mode' => $strMode, + 'source_kind' => $strSourceKind, + 'source_path' => $strSourcePath, + 'summary' => $strSummary, + ]; + } + + protected static function getBoolEnv(string $strKey, bool $boolDefault): bool + { + $strValue = self::getStringEnv($strKey, ''); + if ($strValue === '') { + return $boolDefault; + } + + return in_array(strtolower($strValue), ['1', 'true', 'yes', 'on'], true); + } + + protected static function getStringEnv(string $strKey, string $strDefault): string + { + $strValue = getenv($strKey); + if ($strValue !== false) { + $strValue = trim((string)$strValue); + if ($strValue !== '') { + return $strValue; + } + } + + if (function_exists('env')) { + $mixedEnv = env($strKey); + if ($mixedEnv !== null) { + $strEnv = trim((string)$mixedEnv); + if ($strEnv !== '') { + return $strEnv; + } + } + } + + return $strDefault; + } + + protected static function escape(string $strValue): string + { + return htmlspecialchars($strValue, ENT_QUOTES, 'UTF-8'); + } + + protected static function synchronizeBundleLocalFiles(array $arrBundle, string $strScanRoot, string $strPortalRoot): void + { + $strSummaryPath = trim((string)($arrBundle['summary_path'] ?? '')); + if ($strSummaryPath === '' || !is_file($strSummaryPath)) { + return; + } + + $arrSummary = json_decode((string)file_get_contents($strSummaryPath), true); + if (!is_array($arrSummary)) { + return; + } + + $strBundleRoot = rtrim((string)($arrBundle['bundle_root'] ?? ''), '/'); + if ($strBundleRoot === '') { + return; + } + + $arrSummary['manifest'] = (string)($arrBundle['manifest_path'] ?? ''); + $arrSummary['target_root'] = (string)($arrBundle['target_root'] ?? ''); + $arrSummary['approved_dir'] = (string)($arrBundle['approved_dir'] ?? ''); + $arrSummary['bundle_root'] = $strBundleRoot; + $arrSummary['base_url'] = (string)($arrBundle['base_url'] ?? ''); + $arrSummary['site_register_path'] = (string)($arrBundle['site_register_path'] ?? ''); + $arrSummary['site_bootstrap_path'] = (string)($arrBundle['site_bootstrap_path'] ?? ''); + $arrSummary['next_commands'] = (array)($arrBundle['next_commands'] ?? []); + $arrSummary['handoff'] = [ + 'stage_code' => (string)($arrBundle['handoff_stage_code'] ?? ''), + 'stage_label' => (string)($arrBundle['handoff_stage_label'] ?? ''), + 'summary' => (string)($arrBundle['handoff_summary'] ?? ''), + 'next_step' => (string)($arrBundle['handoff_next_step'] ?? ''), + 'next_command' => (string)($arrBundle['handoff_next_command'] ?? ''), + ]; + + $arrSummary['bundle_files'] = array_merge((array)($arrSummary['bundle_files'] ?? []), [ + 'summary' => $strSummaryPath, + 'runbook' => (string)($arrBundle['runbook_path'] ?? ($strBundleRoot . '/README.md')), + 'commands' => $strBundleRoot . '/commands.sh', + 'site_register' => (string)($arrBundle['site_register_path'] ?? ''), + 'site_register_preview' => (string)($arrBundle['site_register_preview_path'] ?? ''), + 'site_register_result' => (string)($arrBundle['site_register_result_path'] ?? ''), + 'site_bootstrap' => (string)($arrBundle['site_bootstrap_path'] ?? ''), + 'site_apply_preview' => (string)($arrBundle['site_apply_preview_path'] ?? ''), + 'site_apply_result' => (string)($arrBundle['site_apply_result_path'] ?? ''), + 'import_result' => (string)($arrBundle['import_result_path'] ?? ''), + 'publish_result' => (string)($arrBundle['publish_result_path'] ?? ''), + 'prepare_summary' => (string)($arrBundle['prepare_summary_path'] ?? ''), + 'release_dry_run_summary' => (string)($arrBundle['release_dry_run_summary_path'] ?? ''), + ]); + + if ($strScanRoot !== '') { + $arrSummary['bundle_index']['scan_root'] = $strScanRoot; + } + if ($strPortalRoot !== '') { + $arrSummary['bundle_portal']['root'] = $strPortalRoot; + } + + $strTargetRoot = (string)($arrBundle['target_root'] ?? ''); + $strHostDir = (string)($arrSummary['host_dir'] ?? ''); + foreach ((array)($arrSummary['items'] ?? []) as $intIndex => $arrItem) { + if (!is_array($arrItem)) { + continue; + } + + $strScene = trim((string)($arrItem['scene'] ?? '')); + $strPageKey = trim((string)($arrItem['page_key'] ?? '')); + if ($strTargetRoot === '' || $strHostDir === '' || $strScene === '' || $strPageKey === '') { + continue; + } + + $arrSummary['items'][$intIndex]['target_path'] = rtrim($strTargetRoot, '/') . '/' . $strHostDir . '/' . $strScene . '/' . $strPageKey . '.json'; + } + + file_put_contents( + $strSummaryPath, + json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + + $strRunbookPath = (string)($arrSummary['bundle_files']['runbook'] ?? ($strBundleRoot . '/README.md')); + $strCommandsPath = (string)($arrSummary['bundle_files']['commands'] ?? ($strBundleRoot . '/commands.sh')); + + file_put_contents($strCommandsPath, self::renderBundleCommandsScript($arrBundle)); + @chmod($strCommandsPath, 0755); + file_put_contents($strRunbookPath, self::renderBundleRunbookMarkdown($arrBundle, $strCommandsPath)); + } + + protected static function renderBundleCommandsScript(array $arrBundle): string + { + $arrCommands = (array)($arrBundle['next_commands'] ?? []); + $strImportMode = (string)($arrBundle['pipeline_import_mode'] ?? 'off'); + $strImportSummary = (string)($arrBundle['pipeline_import_summary'] ?? ''); + $strImportSource = (string)($arrBundle['pipeline_import_source_path'] ?? ''); + + return implode(PHP_EOL, [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + '', + 'cd ' . escapeshellarg(dirname(__DIR__, 3)), + '', + '# 1. 推荐先走更高层 pipeline 编排入口', + (string)($arrCommands['pipeline_dry_run'] ?? ''), + '# ' . (string)($arrCommands['pipeline'] ?? ''), + '', + '# 2. 如需更细粒度推进,再走 oncall / run 入口', + (string)($arrCommands['oncall_dry_run'] ?? ''), + '# ' . (string)($arrCommands['oncall'] ?? ''), + '', + (string)($arrCommands['run_dry_run'] ?? ''), + '# ' . (string)($arrCommands['run'] ?? ''), + '', + '# 3. 如需拆步排查,再分别执行 register/apply', + (string)($arrCommands['register_dry_run'] ?? ''), + '# ' . (string)($arrCommands['register'] ?? ''), + '', + (string)($arrCommands['apply_dry_run'] ?? ''), + '# ' . (string)($arrCommands['apply'] ?? ''), + '', + '# 4. 生成 facts + prompt', + (string)($arrCommands['prepare'] ?? ''), + '', + '# 5. 导入 AI 结果', + '# policy_mode=' . $strImportMode, + '# policy_summary=' . $strImportSummary, + '# policy_source=' . $strImportSource, + '# ' . (string)($arrCommands['pipeline_import'] ?? ''), + '# ' . (string)($arrCommands['pipeline_import_root'] ?? ''), + '# ' . (string)($arrCommands['import'] ?? ''), + '', + '# 6. 跑 dry-run 门禁', + (string)($arrCommands['dry_run'] ?? ''), + '', + '# 7. dry-run 通过后正式发布', + '# ' . (string)($arrCommands['publish'] ?? ''), + '# ' . (string)($arrCommands['publish_low_level'] ?? ''), + '', + ]); + } + + protected static function renderBundleRunbookMarkdown(array $arrBundle, string $strCommandsPath): string + { + $arrCommands = (array)($arrBundle['next_commands'] ?? []); + $strImportMode = (string)($arrBundle['pipeline_import_mode'] ?? 'off'); + $strImportSummary = (string)($arrBundle['pipeline_import_summary'] ?? ''); + $strImportSource = (string)($arrBundle['pipeline_import_source_path'] ?? ''); + + return implode(PHP_EOL, [ + '# 新域名离线补料启动包', + '', + '## 基本信息', + '', + '- host: `' . (string)($arrBundle['host'] ?? '') . '`', + '- bundle_root: `' . (string)($arrBundle['bundle_root'] ?? '') . '`', + '- manifest: `' . (string)($arrBundle['manifest_path'] ?? '') . '`', + '- target_root: `' . (string)($arrBundle['target_root'] ?? '') . '`', + '- approved_dir: `' . (string)($arrBundle['approved_dir'] ?? '') . '`', + '- base_url: `' . (string)($arrBundle['base_url'] ?? '') . '`', + '- items_count: `' . (string)($arrBundle['items_count'] ?? 0) . '`', + '- strategy_profile: `' . (string)($arrBundle['strategy_profile'] ?? '') . '`', + '- canonical_mode: `' . (string)($arrBundle['canonical_mode'] ?? '') . '`', + '- play_index_mode: `' . (string)($arrBundle['play_index_mode'] ?? '') . '`', + '- robots_policy: `' . (string)($arrBundle['robots_policy'] ?? '') . '`', + '- forge_exposure_profile: `' . (string)($arrBundle['forge_exposure_profile'] ?? '') . '`', + '- structured_policy: `' . (string)($arrBundle['structured_policy'] ?? '') . '`', + '- reference_mode: `' . (string)($arrBundle['reference_mode'] ?? '') . '`', + '- pipeline_import_mode: `' . $strImportMode . '`', + '- pipeline_import_requested: `' . (((bool)($arrBundle['pipeline_import_requested'] ?? false)) ? 'yes' : 'no') . '`', + '- pipeline_import_armed: `' . (((bool)($arrBundle['pipeline_import_armed'] ?? false)) ? 'yes' : 'no') . '`', + '- pipeline_import_enabled: `' . (((bool)($arrBundle['pipeline_import_enabled'] ?? false)) ? 'yes' : 'no') . '`', + '- handoff_stage: `' . (string)($arrBundle['handoff_stage_label'] ?? '') . '`', + '- register_status: `' . (string)($arrBundle['register_status'] ?? '') . '`', + '- register_result_status: `' . (string)($arrBundle['register_result_status'] ?? '') . '`', + '- apply_status: `' . (string)($arrBundle['apply_status'] ?? '') . '`', + '- apply_result_status: `' . (string)($arrBundle['apply_result_status'] ?? '') . '`', + '- bundle_run_status: `' . (string)($arrBundle['bundle_run_status'] ?? '') . '`', + '- prepare_result_status: `' . (string)($arrBundle['prepare_result_status'] ?? '') . '`', + '- import_result_status: `' . (string)($arrBundle['import_result_status'] ?? '') . '`', + '- release_dry_run_status: `' . (string)($arrBundle['release_dry_run_status'] ?? '') . '`', + '- publish_result_status: `' . (string)($arrBundle['publish_result_status'] ?? '') . '`', + '', + '## 当前关键文件', + '', + '- `site-register.sample.json`: `' . (string)($arrBundle['site_register_path'] ?? '') . '`', + '- `site-bootstrap.sample.json`: `' . (string)($arrBundle['site_bootstrap_path'] ?? '') . '`', + '- `site-register.result.json`: `' . (string)($arrBundle['site_register_result_path'] ?? '') . '`', + '- `site-apply.result.json`: `' . (string)($arrBundle['site_apply_result_path'] ?? '') . '`', + '- `batch-import.result.json`: `' . (string)($arrBundle['import_result_path'] ?? '') . '`', + '- `release-publish.result.json`: `' . (string)($arrBundle['publish_result_path'] ?? '') . '`', + '- `bundle_run_json`: `' . (string)($arrBundle['bundle_run_json'] ?? '') . '`', + '- `prepare_summary`: `' . (string)($arrBundle['prepare_summary_path'] ?? '') . '`', + '- `release_dry_run_summary`: `' . (string)($arrBundle['release_dry_run_summary_path'] ?? '') . '`', + '- `publish_result_summary`: `' . (string)($arrBundle['publish_result_run_summary_path'] ?? '') . '`', + '', + '## 接手判断', + '', + '- stage: `' . (string)($arrBundle['handoff_stage_label'] ?? '') . '`', + '- summary: ' . (string)($arrBundle['handoff_summary'] ?? ''), + '- next_step: ' . (string)($arrBundle['handoff_next_step'] ?? ''), + '- next_command: `' . (string)($arrBundle['handoff_next_command'] ?? '') . '`', + '- import_policy_summary: ' . $strImportSummary, + '- import_policy_source: `' . $strImportSource . '`', + '', + '## 下一步', + '', + '1. 优先执行高层 `domain_bootstrap_pipeline_run.php` dry-run', + '2. 如 pipeline dry-run 正常,再决定是否正式执行可落地动作', + '3. 如需更细粒度推进,再退回 `domain_bootstrap_oncall_run.php` 或 `domain_bootstrap_run.php`', + '4. 确认站点策略无误后,再继续 `prepare -> import -> dry_run -> publish`', + '5. 如果当前是 `ready_to_import`,优先看 `pipeline_import_mode`:`off` 先补目录,`requested-not-armed` 先放行,再执行非 dry-run import', + '', + '## Import Policy', + '', + '- mode: `' . $strImportMode . '`', + '- summary: ' . $strImportSummary, + '- source_path: `' . $strImportSource . '`', + '', + '## 推荐命令', + '', + '- 命令脚本:`' . $strCommandsPath . '`', + '', + '```bash', + (string)($arrCommands['pipeline_dry_run'] ?? ''), + '# ' . (string)($arrCommands['pipeline'] ?? ''), + (string)($arrCommands['oncall_dry_run'] ?? ''), + '# ' . (string)($arrCommands['oncall'] ?? ''), + (string)($arrCommands['run_dry_run'] ?? ''), + '# ' . (string)($arrCommands['run'] ?? ''), + (string)($arrCommands['register_dry_run'] ?? ''), + '# ' . (string)($arrCommands['register'] ?? ''), + (string)($arrCommands['apply_dry_run'] ?? ''), + '# ' . (string)($arrCommands['apply'] ?? ''), + (string)($arrCommands['prepare'] ?? ''), + '# ' . (string)($arrCommands['pipeline_import'] ?? ''), + '# ' . (string)($arrCommands['pipeline_import_root'] ?? ''), + '# ' . (string)($arrCommands['import'] ?? ''), + '# ' . (string)($arrCommands['import_low_level'] ?? ''), + (string)($arrCommands['dry_run'] ?? ''), + '# ' . (string)($arrCommands['publish'] ?? ''), + '# ' . (string)($arrCommands['publish_low_level'] ?? ''), + '```', + '', + ]); + } +} diff --git a/code/app/common/helper/DomainBootstrapCompletedHelper.php b/code/app/common/helper/DomainBootstrapCompletedHelper.php new file mode 100644 index 0000000..1a4f112 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapCompletedHelper.php @@ -0,0 +1,169 @@ + (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[] = 'bundle'; + } + if ((string)($arrEntry['run_detail_slug'] ?? '') !== '') { + $arrLinks[] = 'run'; + } + + $arrRows[] = '' + . '' . self::escape((string)($arrEntry['host'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['structured_policy'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['reference_mode'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['publish_result_status'] ?? '')) . '' + . '' . (int)($arrEntry['publish_result_published_count'] ?? 0) . '' + . '' . self::escape((string)($arrEntry['summary'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '' + . '' . implode(' / ', $arrLinks) . '' + . ''; + } + + $arrLatest = (array)($arrSummary['latest_entry'] ?? []); + $strLatest = trim((string)($arrLatest['host'] ?? '')) !== '' + ? '

Latest Completed Bundle: ' + . self::escape((string)($arrLatest['host'] ?? '')) + . ' / ' + . self::escape((string)($arrLatest['handoff_stage_label'] ?? '')) + . ' / ' + . self::escape((string)($arrLatest['updated_at'] ?? '')) + . '

' + : ''; + + return implode(PHP_EOL, [ + '', + '', + '', + '', + 'Completed Bootstrap Bundles', + '', + '', + '', + '

返回启动包总览 | 查看值班入口 | 查看值班待办 | 查看启动包接手队列 | 查看启动包运行记录 | 返回统一门户首页

', + '

Completed Bootstrap Bundles

', + '

completed_count: ' . (int)($arrSummary['completed_count'] ?? 0) . ' / published_count: ' . (int)($arrSummary['published_count'] ?? 0) . '

', + $strLatest, + '', + '', + '', + ...$arrRows, + '', + '
hoststagepolicyreferencepublish_statuspublishedsummaryupdated_atlinks
', + '', + '', + ]) . 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'); + } +} diff --git a/code/app/common/helper/DomainBootstrapDutyHelper.php b/code/app/common/helper/DomainBootstrapDutyHelper.php new file mode 100644 index 0000000..55ab0ae --- /dev/null +++ b/code/app/common/helper/DomainBootstrapDutyHelper.php @@ -0,0 +1,225 @@ + (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[] = 'bundle'; + } + if ((string)($arrEntry['run_detail_slug'] ?? '') !== '') { + $arrLinks[] = 'run'; + } + + $arrRows[] = '' + . '' . self::escape((string)($arrEntry['host'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['priority_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['owner_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['source_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['todo_title'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['summary'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['next_command'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '' + . '' . implode(' / ', $arrLinks) . '' + . ''; + } + + return implode(PHP_EOL, [ + '', + '', + '', + '', + 'Bootstrap Duty Queue', + '', + '', + '', + '

返回启动包总览 | 查看值班入口 | 查看启动包接手队列 | 查看已完成启动包 | 查看启动包运行记录 | 返回统一门户首页 | 查看新域名上线 SOP

', + '

Bootstrap Duty Queue

', + '

open_count: ' . (int)($arrSummary['open_count'] ?? 0) . '

', + '

priority: P0 ' . (int)($arrSummary['priority_p0_count'] ?? 0) . ' / P1 ' . (int)($arrSummary['priority_p1_count'] ?? 0) . ' / P2 ' . (int)($arrSummary['priority_p2_count'] ?? 0) . ' / P3 ' . (int)($arrSummary['priority_p3_count'] ?? 0) . '

', + '

owner: 站点入库 ' . (int)($arrSummary['owner_register_count'] ?? 0) . ' / 站点策略 ' . (int)($arrSummary['owner_apply_count'] ?? 0) . ' / 补料发布 ' . (int)($arrSummary['owner_prepare_count'] ?? 0) . ' / 人工复核/排障 ' . (int)($arrSummary['owner_manual_count'] ?? 0) . '

', + '', + '', + '', + ...$arrRows, + '', + '
hostpriorityownerhandoffsourcetodosummarynext_commandupdated_atlinks
', + '', + '', + ]) . 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'); + } +} diff --git a/code/app/common/helper/DomainBootstrapEnvTemplateHelper.php b/code/app/common/helper/DomainBootstrapEnvTemplateHelper.php new file mode 100644 index 0000000..9c0a798 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapEnvTemplateHelper.php @@ -0,0 +1,821 @@ + $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, + ]; + } +} diff --git a/code/app/common/helper/DomainBootstrapExecutionHelper.php b/code/app/common/helper/DomainBootstrapExecutionHelper.php new file mode 100644 index 0000000..6b37578 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapExecutionHelper.php @@ -0,0 +1,235 @@ + $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, + ]; + } +} diff --git a/code/app/common/helper/DomainBootstrapHandoffHelper.php b/code/app/common/helper/DomainBootstrapHandoffHelper.php new file mode 100644 index 0000000..a2dbc77 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapHandoffHelper.php @@ -0,0 +1,189 @@ + 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[] = 'bundle'; + } + if ((string)($arrEntry['run_detail_slug'] ?? '') !== '') { + $arrLinks[] = 'run'; + } + + $arrRows[] = '' + . '' . self::escape((string)($arrEntry['host'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['source_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['summary'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['next_step'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['next_command'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '' + . '' . implode(' / ', $arrLinks) . '' + . ''; + } + + return implode(PHP_EOL, [ + '', + '', + '', + '', + 'Bootstrap Handoff Queue', + '', + '', + '', + '

返回启动包总览 | 查看已完成启动包 | 查看启动包运行记录 | 返回统一门户首页 | 查看新域名上线 SOP

', + '

Bootstrap Handoff Queue

', + '

hosts_count: ' . (int)($arrSummary['hosts_count'] ?? 0) . '

', + '

handoff: 待入库 ' . (int)($arrSummary['handoff_ready_to_register_count'] ?? 0) . ' / 待应用 ' . (int)($arrSummary['handoff_ready_to_apply_count'] ?? 0) . ' / 可进 prepare ' . (int)($arrSummary['handoff_ready_for_prepare_count'] ?? 0) . ' / 待导入 ' . (int)($arrSummary['handoff_ready_to_import_count'] ?? 0) . ' / 待跑 dry-run ' . (int)($arrSummary['handoff_ready_to_release_dry_run_count'] ?? 0) . ' / 待发布 ' . (int)($arrSummary['handoff_ready_to_publish_count'] ?? 0) . ' / 需关注 ' . (int)($arrSummary['handoff_attention_count'] ?? 0) . '

', + '', + '', + '', + ...$arrRows, + '', + '
hosthandoffsourcesummarynext_stepnext_commandupdated_atlinks
', + '', + '', + ]) . 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'); + } +} diff --git a/code/app/common/helper/DomainBootstrapImportHelper.php b/code/app/common/helper/DomainBootstrapImportHelper.php new file mode 100644 index 0000000..ab139f1 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapImportHelper.php @@ -0,0 +1,83 @@ + 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 结果目录是否正确。", + }; + } +} diff --git a/code/app/common/helper/DomainBootstrapOncallHelper.php b/code/app/common/helper/DomainBootstrapOncallHelper.php new file mode 100644 index 0000000..2192aa4 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapOncallHelper.php @@ -0,0 +1,190 @@ + $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= --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[] = 'bundle'; + } + if ((string)($arrFocus['run_detail_slug'] ?? '') !== '') { + $arrLinks[] = 'run'; + } + + $arrFocusCards[] = '
' + . '' . self::escape((string)($arrFocus['owner_label'] ?? '')) . '' + . '

host: ' . self::escape((string)($arrFocus['host'] ?? '')) . '

' + . '

priority: ' . self::escape((string)($arrFocus['priority_label'] ?? '')) . '

' + . '

todo: ' . self::escape((string)($arrFocus['todo_title'] ?? '')) . '

' + . '

' . self::escape((string)($arrFocus['summary'] ?? '')) . '

' + . '

' . self::escape((string)($arrFocus['next_command'] ?? '')) . '

' + . '

' . implode(' / ', $arrLinks) . '

' + . '
'; + } + + $arrRows = []; + foreach ((array)($arrSummary['top_entries'] ?? []) as $arrEntry) { + if (!is_array($arrEntry)) { + continue; + } + + $arrRows[] = '' + . '' . self::escape((string)($arrEntry['host'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['priority_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['owner_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['todo_title'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['next_command'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '' + . ''; + } + + $arrLatest = (array)($arrSummary['latest_entry'] ?? []); + $strLatest = trim((string)($arrLatest['host'] ?? '')) !== '' + ? '

当前优先待办: ' + . self::escape((string)($arrLatest['host'] ?? '')) + . ' / ' + . self::escape((string)($arrLatest['priority_label'] ?? '')) + . ' / ' + . self::escape((string)($arrLatest['todo_title'] ?? '')) + . ' / ' . self::escape((string)($arrLatest['next_command'] ?? '')) . '

' + : ''; + + $arrImportPolicy = (array)($arrSummary['pipeline_import_policy'] ?? []); + $strImportPolicy = '

Import Policy: ' + . 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') + . ' / ' . self::escape((string)($arrImportPolicy['source_path'] ?? '')) . '' + . ' / ' + . self::escape((string)($arrImportPolicy['summary'] ?? '')) + . '

'; + + $arrGlobalCommands = []; + foreach ((array)($arrSummary['recommended_commands'] ?? []) as $strKey => $strCommand) { + $arrGlobalCommands[] = '' . self::escape((string)$strKey) . '' . self::escape((string)$strCommand) . ''; + } + + return implode(PHP_EOL, [ + '', + '', + '', + '', + 'Bootstrap Oncall Desk', + '', + '', + '', + '

返回启动包总览 | 查看值班待办 | 查看启动包接手队列 | 查看已完成启动包 | 查看启动包运行记录 | 返回统一门户首页 | 查看新域名上线 SOP

', + '

Bootstrap Oncall Desk

', + '

open_count: ' . (int)($arrSummary['open_count'] ?? 0) . '

', + '

priority: P0 ' . (int)($arrSummary['priority_p0_count'] ?? 0) . ' / P1 ' . (int)($arrSummary['priority_p1_count'] ?? 0) . ' / P2 ' . (int)($arrSummary['priority_p2_count'] ?? 0) . ' / P3 ' . (int)($arrSummary['priority_p3_count'] ?? 0) . '

', + $strLatest, + $strImportPolicy, + '

Global Commands

', + '', + '', + '', + ...$arrGlobalCommands, + '', + '
stepcommand
', + '

Owner Focus

', + '
' . implode('', $arrFocusCards) . '
', + '

Top Todo

', + '', + '', + '', + ...$arrRows, + '', + '
hostpriorityownerhandofftodonext_commandupdated_at
', + '', + '', + ]) . 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'); + } +} diff --git a/code/app/common/helper/DomainBootstrapOncallService.php b/code/app/common/helper/DomainBootstrapOncallService.php new file mode 100644 index 0000000..54b43d0 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapOncallService.php @@ -0,0 +1,488 @@ + $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; + } +} diff --git a/code/app/common/helper/DomainBootstrapPipelineHelper.php b/code/app/common/helper/DomainBootstrapPipelineHelper.php new file mode 100644 index 0000000..ff50863 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapPipelineHelper.php @@ -0,0 +1,137 @@ + $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, '-'); + } +} diff --git a/code/app/common/helper/DomainBootstrapPublishHelper.php b/code/app/common/helper/DomainBootstrapPublishHelper.php new file mode 100644 index 0000000..92d9542 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapPublishHelper.php @@ -0,0 +1,72 @@ + (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; + } +} diff --git a/code/app/common/helper/DomainBootstrapRegisterHelper.php b/code/app/common/helper/DomainBootstrapRegisterHelper.php new file mode 100644 index 0000000..1bb2ce1 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapRegisterHelper.php @@ -0,0 +1,185 @@ +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; + } +} diff --git a/code/app/common/helper/DomainBootstrapRunHelper.php b/code/app/common/helper/DomainBootstrapRunHelper.php new file mode 100644 index 0000000..2cefecd --- /dev/null +++ b/code/app/common/helper/DomainBootstrapRunHelper.php @@ -0,0 +1,150 @@ + $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; + } +} diff --git a/code/app/common/helper/DomainBootstrapRunIndexHelper.php b/code/app/common/helper/DomainBootstrapRunIndexHelper.php new file mode 100644 index 0000000..5a95ab8 --- /dev/null +++ b/code/app/common/helper/DomainBootstrapRunIndexHelper.php @@ -0,0 +1,445 @@ + $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'] ?? '')) . '
' . self::escape((string)($arrRun['bundle_root'] ?? '')) . ''; + + $arrRows[] = '' + . '' . self::escape((string)($arrRun['run_name'] ?? '')) . '' + . '' . self::escape((string)($arrRun['host'] ?? '')) . '' + . '' . self::escape((string)($arrRun['overall_status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['register_status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['apply_status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['handoff_stage_label'] ?? '')) . '' + . '' . ($arrRun['dry_run'] ? 'yes' : 'no') . '' + . '' . (int)($arrRun['errors_count'] ?? 0) . '' + . '' . self::escape((string)($arrRun['summary'] ?? '')) . '' + . '' . self::escape((string)($arrRun['updated_at'] ?? '')) . '' + . '' . $strBundleCell . '' + . ''; + } + + $arrStatusBits = []; + foreach ((array)($arrSummary['status_totals'] ?? []) as $strStatus => $intCount) { + $arrStatusBits[] = self::escape((string)$strStatus) . ': ' . (int)$intCount . ''; + } + + return implode(PHP_EOL, [ + '', + '', + '', + '', + 'Domain Bootstrap Runs', + '', + '', + '', + '

返回启动包总览 | 查看值班入口 | 查看启动包接手队列 | 查看值班待办 | 返回统一门户首页 | 查看新域名上线 SOP

', + '

Domain Bootstrap Runs

', + '

scan_root: ' . self::escape((string)($arrSummary['scan_root'] ?? '')) . '

', + '

runs_count: ' . (int)($arrSummary['runs_count'] ?? 0) . '

', + '

status_totals: ' . implode(' / ', $arrStatusBits) . '

', + '

handoff: 待入库 ' . (int)($arrSummary['handoff_ready_to_register_count'] ?? 0) . ' / 待应用 ' . (int)($arrSummary['handoff_ready_to_apply_count'] ?? 0) . ' / 可进 prepare ' . (int)($arrSummary['handoff_ready_for_prepare_count'] ?? 0) . ' / 需关注 ' . (int)($arrSummary['handoff_attention_count'] ?? 0) . '

', + '', + '', + '', + ...$arrRows, + '', + '
run_namehoststatusregisterapplyhandoffdry_runerrorssummaryupdated_atbundle
', + '', + '', + ]) . 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[] = '' . self::escape($strKey) . '' . self::escape($strValue) . ''; + } + + $arrPathHtml = []; + foreach ($arrPathRows as $strKey => $strValue) { + $arrPathHtml[] = '' . self::escape($strKey) . '' . self::escape($strValue) . ''; + } + + $arrStatusHtml = []; + foreach ($arrStatusRows as $strKey => $strValue) { + $arrStatusHtml[] = '' . self::escape($strKey) . '' . self::escape($strValue) . ''; + } + + $strBundleLink = ''; + if ((string)($arrRun['bundle_detail_slug'] ?? '') !== '') { + $strBundleLink = '

返回对应 bundle 详情

'; + } + + return implode(PHP_EOL, [ + '', + '', + '', + '', + 'Bootstrap Run Detail', + '', + '', + '', + '

返回启动包运行记录 | 查看值班入口 | 查看启动包接手队列 | 查看值班待办 | 返回统一门户首页 | 查看新域名上线 SOP

', + $strBundleLink, + '

' . self::escape((string)($arrRun['run_name'] ?? '')) . '

', + '

状态摘要

', + '' . implode('', $arrInfoHtml) . '
', + '

状态说明

', + '' . implode('', $arrStatusHtml) . '
', + '

文件路径

', + '' . implode('', $arrPathHtml) . '
', + '', + '', + ]) . 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'); + } +} diff --git a/code/app/common/helper/DomainBootstrapRunService.php b/code/app/common/helper/DomainBootstrapRunService.php new file mode 100644 index 0000000..b6a03ec --- /dev/null +++ b/code/app/common/helper/DomainBootstrapRunService.php @@ -0,0 +1,183 @@ +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'; + } +} diff --git a/code/app/common/helper/DomainExternalSeoAizhanKeywordAttentionQueueHelper.php b/code/app/common/helper/DomainExternalSeoAizhanKeywordAttentionQueueHelper.php new file mode 100644 index 0000000..1abb282 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoAizhanKeywordAttentionQueueHelper.php @@ -0,0 +1,203 @@ + (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[] = '' + . '' . self::escape((string)($arrItem['host'] ?? '')) . '' + . '' . self::escape((string)($arrItem['attention_level'] ?? '')) . '' + . '' . self::escape(implode(', ', (array)($arrItem['reason_codes'] ?? []))) . '' + . '' . self::escape((string)($arrItem['baidu_pc_ip_range'] ?? '')) . '' + . '' . self::escape((string)($arrItem['baidu_mobile_ip_range'] ?? '')) . '' + . '' . (int)($arrItem['pc_keyword_count'] ?? 0) . '' + . '' . (int)($arrItem['mobile_keyword_count'] ?? 0) . '' + . '' . self::escape((string)($arrItem['recommended_action'] ?? '')) . '' + . ''; + } + + return 'Aizhan Keyword Attention Queue' + . '' + . '' + . '

Aizhan Keyword Attention Queue

' + . '

' . self::escape((string)($arrQueue['note'] ?? '')) . '

' + . '
' + . '
host_count
' . (int)($arrQueue['host_count'] ?? 0) . '
' + . '
queue_count
' . (int)($arrQueue['queue_count'] ?? 0) . '
' + . '
ready_count
' . (int)($arrQueue['ready_count'] ?? 0) . '
' + . '
captcha_count
' . (int)($arrQueue['captcha_count'] ?? 0) . '
' + . '
blocked_count
' . (int)($arrQueue['blocked_count'] ?? 0) . '
' + . '
' + . '' + . implode('', $arrRows) + . '
hostlevelreasonsbaidu_pc_ip_rangebaidu_mobile_ip_rangepc_keyword_countmobile_keyword_countrecommended_action
' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoAizhanKeywordProbeHelper.php b/code/app/common/helper/DomainExternalSeoAizhanKeywordProbeHelper.php new file mode 100644 index 0000000..cb35b83 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoAizhanKeywordProbeHelper.php @@ -0,0 +1,298 @@ +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>/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 = '/]+href="' . preg_quote($strUrl, '/') . '[^"]*"[^>]*class="gjc_more"[^>]*>.*?(\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[] = '' + . '' . self::escape((string)($arrItem['host'] ?? '')) . '' + . '' . self::escape((string)($arrItem['status'] ?? '')) . '' + . '' . self::escape((string)($arrItem['site_title'] ?? '')) . '' + . '' . self::escape((string)($arrItem['baidu_pc_ip_range'] ?? '')) . '' + . '' . self::escape((string)($arrItem['baidu_mobile_ip_range'] ?? '')) . '' + . '' . (int)($arrItem['pc_keyword_count'] ?? 0) . '' + . '' . (int)($arrItem['mobile_keyword_count'] ?? 0) . '' + . '' . self::escape((string)($arrItem['page_title'] ?? '')) . '' + . '' . self::escape((string)($arrItem['probed_at'] ?? '')) . '' + . ''; + } + + return 'Aizhan Keyword Probe Summary' + . '' + . '' + . '

爱站关键词摘要

' + . '

generated_at: ' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '

' + . '

' . self::escape((string)($arrSummary['note'] ?? '')) . '

' + . '
' + . '
host_count
' . (int)($arrSummary['host_count'] ?? 0) . '
' + . '
ready
' . (int)((($arrSummary['status_buckets'] ?? [])['ready']) ?? 0) . '
' + . '
captcha
' . (int)((($arrSummary['status_buckets'] ?? [])['captcha']) ?? 0) . '
' + . '
blocked
' . (int)((($arrSummary['status_buckets'] ?? [])['blocked']) ?? 0) . '
' + . '
' + . '' + . implode('', $arrRows) + . '
hoststatussite_titlebaidu_pc_ip_rangebaidu_mobile_ip_rangepc_keyword_countmobile_keyword_countpage_titleprobed_at
'; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoAizhanKeywordTrendHelper.php b/code/app/common/helper/DomainExternalSeoAizhanKeywordTrendHelper.php new file mode 100644 index 0000000..0947b2f --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoAizhanKeywordTrendHelper.php @@ -0,0 +1,177 @@ + 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[] = '' + . '' . self::escape((string)($arrItem['host'] ?? '')) . '' + . '' . self::escape((string)($arrItem['baidu_pc_ip_range'] ?? '')) . '' + . '' . self::escape((string)($arrItem['baidu_mobile_ip_range'] ?? '')) . '' + . '' . (int)($arrItem['pc_keyword_count'] ?? 0) . '' + . '' . (int)($arrItem['mobile_keyword_count'] ?? 0) . '' + . '' . self::escape((string)($arrItem['probed_at'] ?? '')) . '' + . ''; + } + + return 'Aizhan Keyword Trend Summary' + . '' + . '' + . '

Aizhan Keyword Trend Summary

' + . '

' . self::escape((string)($arrSummary['note'] ?? '')) . '

' + . '
' + . '
health_label
' . self::escape((string)($arrSummary['health_label'] ?? '')) . '
' + . '
ready_count
' . (int)($arrSummary['ready_count'] ?? 0) . '
' + . '
queue_count
' . (int)($arrSummary['queue_count'] ?? 0) . '
' + . '
keyword_ready_count
' . (int)($arrSummary['keyword_ready_count'] ?? 0) . '
' + . '
traffic_ready_count
' . (int)($arrSummary['traffic_ready_count'] ?? 0) . '
' + . '
zero_keyword_count
' . (int)($arrSummary['zero_keyword_count'] ?? 0) . '
' + . '
' + . '' + . implode('', $arrRows) + . '
hostbaidu_pc_ip_rangebaidu_mobile_ip_rangepc_keyword_countmobile_keyword_countprobed_at
' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoBaiduFeedbackSummaryHelper.php b/code/app/common/helper/DomainExternalSeoBaiduFeedbackSummaryHelper.php new file mode 100644 index 0000000..b979d06 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoBaiduFeedbackSummaryHelper.php @@ -0,0 +1,240 @@ + 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[] = '' + . '' . self::escape((string)($arrHost['host'] ?? '')) . '' + . '' . self::escape((string)($arrHost['provider'] ?? '')) . '' + . '' . (int)($arrHost['rows_count'] ?? 0) . '' + . '' . (int)($arrHost['impressions'] ?? 0) . '' + . '' . (int)($arrHost['clicks'] ?? 0) . '' + . '' . self::escape((string)($arrHost['ctr'] ?? '0')) . '' + . '' . self::escape((string)($arrHost['avg_position'] ?? '-')) . '' + . ''; + } + + return 'Baidu Feedback Summary' + . '' + . '' + . '

Baidu Feedback Summary

' + . '

' . self::escape((string)($arrSummary['note'] ?? '')) . '

' + . '
' + . '
status
' . self::escape((string)($arrSummary['status'] ?? '')) . '
' + . '
hosts_count
' . (int)($arrSummary['hosts_count'] ?? 0) . '
' + . '
impressions
' . self::escape((string)(($arrSummary['metrics'] ?? [])['impressions'] ?? 0)) . '
' + . '
clicks
' . self::escape((string)(($arrSummary['metrics'] ?? [])['clicks'] ?? 0)) . '
' + . '
ctr
' . self::escape((string)(($arrSummary['metrics'] ?? [])['ctr'] ?? 0)) . '
' + . '
avg_position
' . self::escape((string)(($arrSummary['metrics'] ?? [])['avg_position'] ?? '-')) . '
' + . '
rows_count
' . (int)($arrSummary['rows_count'] ?? 0) . '
' + . '
providers
' . self::escape(implode(', ', (array)($arrSummary['providers'] ?? []))) . '
' + . '
' + . '' + . implode('', $arrHostRows) + . '
hostproviderrowsimpressionsclicksctravg_position
' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoBaiduProviderPlanHelper.php b/code/app/common/helper/DomainExternalSeoBaiduProviderPlanHelper.php new file mode 100644 index 0000000..38b9526 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoBaiduProviderPlanHelper.php @@ -0,0 +1,109 @@ + 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[] = '
  • ' . self::escape((string)$strStep) . '
  • '; + } + + return 'Baidu Provider Plan' + . '' + . '' + . '

    Baidu Provider Plan

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '
    ' + . '
    status
    ' . self::escape((string)($arrSummary['status'] ?? '')) . '
    ' + . '
    credential_ready
    ' . (!empty($arrSummary['credential_ready']) ? 'yes' : 'no') . '
    ' + . '
    credential_size
    ' . (int)($arrSummary['credential_file_size'] ?? 0) . '
    ' + . '
    updated_at
    ' . self::escape((string)($arrSummary['credential_updated_at'] ?? '-')) . '
    ' + . '
    ' + . '

    credential_path: ' . self::escape((string)($arrSummary['credential_path'] ?? '')) . '

    ' + . '

    submission_scope: ' . self::escape(implode(', ', (array)(($arrSummary['submission_scope'] ?? [])['capabilities'] ?? []))) . '

    ' + . '

    Next Steps

      ' . implode('', $arrStepRows) . '
    ' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoBaiduPushAttentionQueueHelper.php b/code/app/common/helper/DomainExternalSeoBaiduPushAttentionQueueHelper.php new file mode 100644 index 0000000..bb2f797 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoBaiduPushAttentionQueueHelper.php @@ -0,0 +1,217 @@ + '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[] = '' + . '' . self::escape((string)($arrItem['attention_level'] ?? '')) . '' + . '' . self::escape(implode(', ', (array)($arrItem['reason_codes'] ?? []))) . '' + . '' . self::escape((string)($arrItem['reason_summary'] ?? '')) . '' + . '' . self::escape((string)($arrItem['recommended_action'] ?? '')) . '' + . '' . self::escape((string)($arrItem['db_error'] ?? '')) . '' + . ''; + } + + $arrRows = []; + foreach ((array)($arrQueue['items'] ?? []) as $arrItem) { + $arrRows[] = '' + . '' . self::escape((string)($arrItem['domain'] ?? '')) . '' + . '' . self::escape((string)($arrItem['attention_level'] ?? '')) . '' + . '' . self::escape(implode(', ', (array)($arrItem['reason_codes'] ?? []))) . '' + . '' . (!empty($arrItem['token_ready']) ? self::escape((string)($arrItem['token_masked'] ?? 'yes')) : 'no') . '' + . '' . (!empty($arrItem['state_ready']) ? 'yes' : 'no') . '' + . '' . (int)($arrItem['txt_file_count'] ?? 0) . '' + . '' . self::escape((string)($arrItem['latest_txt_file'] ?? '')) . '' + . '' . self::escape((string)($arrItem['recommended_action'] ?? '')) . '' + . ''; + } + + return 'Baidu Push Attention Queue' + . '' + . '' + . '

    Baidu Push Attention Queue

    ' + . '

    ' . self::escape((string)($arrQueue['note'] ?? '')) . '

    ' + . '
    ' + . '
    queue_count
    ' . (int)($arrQueue['queue_count'] ?? 0) . '
    ' + . '
    system_attention_count
    ' . (int)($arrQueue['system_attention_count'] ?? 0) . '
    ' + . '
    task_enabled
    ' . (!empty($arrQueue['task_enabled']) ? 'yes' : 'no') . '
    ' + . '
    db_ready
    ' . (!empty($arrQueue['db_ready']) ? 'yes' : 'no') . '
    ' + . '
    ' + . '

    task_last_exec: ' . self::escape((string)($arrQueue['task_last_exec'] ?? '')) . '

    ' + . '

    db_error: ' . self::escape((string)($arrQueue['db_error'] ?? '')) . '

    ' + . '

    System Attention

    ' + . '' + . implode('', $arrSystemRows) + . '
    levelreasonssummaryrecommended_actiondb_error
    ' + . '

    Domain Queue

    ' + . '' + . implode('', $arrRows) + . '
    domainlevelreasonstokenstatetxt_fileslatest_txt_filerecommended_action
    ' + . ''; + } + + 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/ 下是否已经产出待推送 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoBaiduPushSummaryHelper.php b/code/app/common/helper/DomainExternalSeoBaiduPushSummaryHelper.php new file mode 100644 index 0000000..4b1fc3a --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoBaiduPushSummaryHelper.php @@ -0,0 +1,187 @@ +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[] = '' + . '' . self::escape((string)($arrItem['domain'] ?? '')) . '' + . '' . (!empty($arrItem['token_ready']) ? 'yes' : 'no') . '' + . '' . self::escape((string)($arrItem['token_masked'] ?? '')) . '' + . '' . (!empty($arrItem['state_ready']) ? 'yes' : 'no') . '' + . '' . (int)($arrItem['txt_file_count'] ?? 0) . '' + . '' . self::escape((string)($arrItem['latest_txt_file'] ?? '')) . '' + . '' . (int)($arrItem['file_index'] ?? 0) . '' + . '' . (int)($arrItem['line_index'] ?? 0) . '' + . '' . self::escape((string)($arrItem['updated_at'] ?? '')) . '' + . ''; + } + + return 'Baidu Push Summary' + . '' + . '' + . '

    Baidu Push Summary

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '
    ' + . '
    task_enabled
    ' . (!empty($arrSummary['task_enabled']) ? 'yes' : 'no') . '
    ' + . '
    domain_count
    ' . (int)($arrSummary['domain_count'] ?? 0) . '
    ' + . '
    token_ready_count
    ' . (int)($arrSummary['token_ready_count'] ?? 0) . '
    ' + . '
    state_ready_count
    ' . (int)($arrSummary['state_ready_count'] ?? 0) . '
    ' + . '
    ' + . '

    task_last_exec: ' . self::escape((string)($arrSummary['task_last_exec'] ?? '')) . '

    ' + . '

    db_ready: ' . (!empty($arrSummary['db_ready']) ? 'yes' : 'no') . '

    ' + . '

    db_error: ' . self::escape((string)($arrSummary['db_error'] ?? '')) . '

    ' + . '' + . implode('', $arrRows) + . '
    domaintoken_readytokenstate_readytxt_fileslatest_txt_filefile_indexline_indexupdated_at
    ' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoBaiduPushTaskStatusHelper.php b/code/app/common/helper/DomainExternalSeoBaiduPushTaskStatusHelper.php new file mode 100644 index 0000000..cdb5323 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoBaiduPushTaskStatusHelper.php @@ -0,0 +1,44 @@ +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,不新造任务配置链。', + ]; + } +} diff --git a/code/app/common/helper/DomainExternalSeoBaiduPushTrendHelper.php b/code/app/common/helper/DomainExternalSeoBaiduPushTrendHelper.php new file mode 100644 index 0000000..e7c06e1 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoBaiduPushTrendHelper.php @@ -0,0 +1,174 @@ + 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[] = '' + . '' . self::escape((string)($arrItem['domain'] ?? '')) . '' + . '' . self::escape((string)($arrItem['updated_at'] ?? '')) . '' + . '' . (int)($arrItem['age_hours'] ?? 0) . '' + . '' . (!empty($arrItem['token_ready']) ? 'yes' : 'no') . '' + . '' . (!empty($arrItem['state_ready']) ? 'yes' : 'no') . '' + . '' . (int)($arrItem['txt_file_count'] ?? 0) . '' + . ''; + } + + return 'Baidu Push Trend Summary' + . '' + . '' + . '

    Baidu Push Trend Summary

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '
    ' + . '
    health_label
    ' . self::escape((string)($arrSummary['health_label'] ?? '')) . '
    ' + . '
    queue_count
    ' . (int)($arrSummary['queue_count'] ?? 0) . '
    ' + . '
    recent_state_count
    ' . (int)($arrSummary['recent_state_count'] ?? 0) . '
    ' + . '
    stale_state_count
    ' . (int)($arrSummary['stale_state_count'] ?? 0) . '
    ' + . '
    never_started_count
    ' . (int)($arrSummary['never_started_count'] ?? 0) . '
    ' + . '
    ' + . '

    task_last_exec: ' . self::escape((string)($arrSummary['task_last_exec'] ?? '')) . '

    ' + . '

    db_error: ' . self::escape((string)($arrSummary['db_error'] ?? '')) . '

    ' + . '' + . implode('', $arrRows) + . '
    domainupdated_atage_hourstokenstatetxt_files
    ' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoCnOverviewHelper.php b/code/app/common/helper/DomainExternalSeoCnOverviewHelper.php new file mode 100644 index 0000000..6d99e9a --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoCnOverviewHelper.php @@ -0,0 +1,153 @@ + 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[] = '' + . '' . self::escape((string)($arrItem['host'] ?? '')) . '' + . '' . self::escape((string)($arrItem['provider'] ?? '')) . '' + . '' . self::escape((string)($arrItem['indexed_status'] ?? '')) . '' + . '' . (int)($arrItem['pc_keyword_count'] ?? 0) . '' + . '' . (int)($arrItem['mobile_keyword_count'] ?? 0) . '' + . ''; + } + + $arrSuggestionRows = []; + foreach ((array)($arrSummary['top_suggestions'] ?? []) as $arrItem) { + $arrSuggestionRows[] = '' + . '' . self::escape((string)($arrItem['priority'] ?? '')) . '' + . '' . self::escape((string)($arrItem['title'] ?? '')) . '' + . '' . self::escape((string)($arrItem['action'] ?? '')) . '' + . ''; + } + + return 'SEO External CN Overview' + . '' + . '' + . '

    SEO External CN Overview

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '

    generated_at: ' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '

    ' + . '

    health_label: ' . self::escape((string)($arrSummary['health_label'] ?? '')) . '

    ' + . '
    ' + . '
    host_count
    ' . (int)(($arrSummary['snapshot_summary'] ?? [])['host_count'] ?? 0) . '
    ' + . '
    indexed_like
    ' . (int)(($arrSummary['snapshot_summary'] ?? [])['indexed_like_host_count'] ?? 0) . '
    ' + . '
    newly_indexed
    ' . (int)(($arrSummary['index_movement'] ?? [])['newly_indexed_count'] ?? 0) . '
    ' + . '
    keyword_up
    ' . (int)(($arrSummary['keyword_movement'] ?? [])['up_count'] ?? 0) . '
    ' + . '
    ' + . '

    Top Hosts

    ' + . '' . implode('', $arrHostRows) . '
    hostproviderindexed_statuspc_keyword_countmobile_keyword_count
    ' + . '

    Top Suggestions

    ' + . '' . implode('', $arrSuggestionRows) . '
    prioritytitleaction
    ' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoCollectorStatusHelper.php b/code/app/common/helper/DomainExternalSeoCollectorStatusHelper.php new file mode 100644 index 0000000..47c520d --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoCollectorStatusHelper.php @@ -0,0 +1,105 @@ + 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 ', + ], + '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; + } +} diff --git a/code/app/common/helper/DomainExternalSeoFailedQueueHelper.php b/code/app/common/helper/DomainExternalSeoFailedQueueHelper.php new file mode 100644 index 0000000..e56ec9b --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoFailedQueueHelper.php @@ -0,0 +1,59 @@ + $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'] ?? []), + ]; + } +} diff --git a/code/app/common/helper/DomainExternalSeoIntegrationOverviewHelper.php b/code/app/common/helper/DomainExternalSeoIntegrationOverviewHelper.php new file mode 100644 index 0000000..1c0f7df --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoIntegrationOverviewHelper.php @@ -0,0 +1,45 @@ + 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 校验和正式快照接收放到同一视角,方便先判断问题卡在字段校验还是正式回推阶段。', + ]; + } +} diff --git a/code/app/common/helper/DomainExternalSeoManualImportHelper.php b/code/app/common/helper/DomainExternalSeoManualImportHelper.php new file mode 100644 index 0000000..efe15e7 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoManualImportHelper.php @@ -0,0 +1,377 @@ + '必填,数据日期,例如 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), + ]; + } +} diff --git a/code/app/common/helper/DomainExternalSeoManualImportRunIndexHelper.php b/code/app/common/helper/DomainExternalSeoManualImportRunIndexHelper.php new file mode 100644 index 0000000..f7051a6 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoManualImportRunIndexHelper.php @@ -0,0 +1,167 @@ + [], + '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); + } +} diff --git a/code/app/common/helper/DomainExternalSeoOptimizationSuggestionHelper.php b/code/app/common/helper/DomainExternalSeoOptimizationSuggestionHelper.php new file mode 100644 index 0000000..ba8ad31 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoOptimizationSuggestionHelper.php @@ -0,0 +1,161 @@ + 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: 收录变化、爱站关键词升降和历史快照摘要自动生成,用来给批量域名场景提供第一版操作建议。', + ]; + } +} diff --git a/code/app/common/helper/DomainExternalSeoProviderRegistryHelper.php b/code/app/common/helper/DomainExternalSeoProviderRegistryHelper.php new file mode 100644 index 0000000..43a3111 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoProviderRegistryHelper.php @@ -0,0 +1,79 @@ + '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 不再作为当前默认主线。', + ]; + } +} diff --git a/code/app/common/helper/DomainExternalSeoRealSummaryHelper.php b/code/app/common/helper/DomainExternalSeoRealSummaryHelper.php new file mode 100644 index 0000000..b9d084e --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoRealSummaryHelper.php @@ -0,0 +1,327 @@ + 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[] = '
    ' + . self::escape((string)($arrMetric['label'] ?? '-')) + . '
    ' + . self::escape(self::formatMetricValue($arrMetric)) + . '
    ' . self::escape((string)($arrMetric['note'] ?? '')) . '
    ' + . '
    '; + } + + $arrQueryRows = []; + foreach ((array)($arrSummary['top_queries'] ?? []) as $arrQuery) { + $arrQueryRows[] = '' + . '' . self::escape((string)($arrQuery['query'] ?? '')) . '' + . '' . (int)($arrQuery['impressions'] ?? 0) . '' + . ''; + } + + $arrPageRows = []; + foreach ((array)($arrSummary['top_pages'] ?? []) as $arrPage) { + $arrPageRows[] = '' + . '' . self::escape((string)($arrPage['page'] ?? '')) . '' + . '' . (int)($arrPage['impressions'] ?? 0) . '' + . ''; + } + + $arrStepRows = []; + foreach ((array)($arrSummary['next_steps'] ?? []) as $strStep) { + $arrStepRows[] = '
  • ' . self::escape((string)$strStep) . '
  • '; + } + + return '站外真实结果摘要' + . '' + . '' + . '

    站外真实结果摘要

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '

    生成时间:' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? ''))) . '

    ' + . '
    ' + . '
    数据来源
    ' . self::escape((string)($arrSummary['provider_label'] ?? '')) . '
    ' + . '
    当前状态
    ' . self::escape(DomainImportReportViewHelper::translateStatus((string)($arrSummary['status'] ?? ''))) . '
    ' + . implode('', $arrHeadlineRows) + . '
    真实数据
    ' . (!empty($arrSummary['real_metrics_available']) ? '已接入' : '未接入') . '
    ' + . '
    来源状态
    ' . self::escape((string)($arrSummary['provider_status'] ?? '-')) . '
    ' + . '
    ' + . '

    重点搜索词

    ' + . '' . implode('', $arrQueryRows) . '
    搜索词展现
    ' + . '

    重点页面

    ' + . '' . implode('', $arrPageRows) . '
    页面展现
    ' + . '

    下一步建议

      ' . implode('', $arrStepRows) . '
    ' + . ''; + } + + 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; + } +} diff --git a/code/app/common/helper/DomainExternalSeoSearchConsoleConfigHelper.php b/code/app/common/helper/DomainExternalSeoSearchConsoleConfigHelper.php new file mode 100644 index 0000000..9c07519 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSearchConsoleConfigHelper.php @@ -0,0 +1,123 @@ + '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); + } + } +} diff --git a/code/app/common/helper/DomainExternalSeoSearchConsoleFetchHelper.php b/code/app/common/helper/DomainExternalSeoSearchConsoleFetchHelper.php new file mode 100644 index 0000000..db7ab41 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSearchConsoleFetchHelper.php @@ -0,0 +1,200 @@ + $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[] = '' + . '' . self::escape((string)($arrItem['host'] ?? '')) . '' + . '' . self::escape((string)($arrItem['site_url'] ?? '')) . '' + . '' . self::escape((string)($arrItem['property_scope'] ?? '')) . '' + . '' . self::escape(implode(', ', (array)($arrItem['request_dimensions'] ?? []))) . '' + . '' . self::escape(implode(', ', (array)($arrItem['request_metrics'] ?? []))) . '' + . '' . self::escape((string)(($arrItem['date_range'] ?? [])['start_date'] ?? '')) . ' ~ ' . self::escape((string)(($arrItem['date_range'] ?? [])['end_date'] ?? '')) . '' + . '' . self::escape((string)($arrItem['request_status'] ?? '')) . '' + . ''; + } + + $arrStepRows = []; + foreach ((array)($arrSummary['next_steps'] ?? []) as $strStep) { + $arrStepRows[] = '
  • ' . self::escape((string)$strStep) . '
  • '; + } + + return 'Search Console Fetch Summary' + . '' + . '' + . '

    Search Console Fetch Summary

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '
    ' + . '
    status
    ' . self::escape((string)($arrSummary['status'] ?? '')) . '
    ' + . '
    planned_request_count
    ' . (int)($arrSummary['planned_request_count'] ?? 0) . '
    ' + . '
    credential_ready
    ' . (!empty($arrSummary['credential_ready']) ? 'yes' : 'no') . '
    ' + . '
    property_map_ready
    ' . (!empty($arrSummary['property_map_ready']) ? 'yes' : 'no') . '
    ' + . '
    client_ready
    ' . (!empty($arrSummary['client_dependency_ready']) ? 'yes' : 'no') . '
    ' + . '
    blocked_reason
    ' . self::escape((string)($arrSummary['blocked_reason'] ?? '-')) . '
    ' + . '
    ' + . '

    Request Plan

    ' + . '' . implode('', $arrRows) . '
    hostsite_urlscopedimensionsmetricsdate_rangerequest_status
    ' + . '

    Next Steps

      ' . implode('', $arrStepRows) . '
    ' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoSearchConsoleProviderPlanHelper.php b/code/app/common/helper/DomainExternalSeoSearchConsoleProviderPlanHelper.php new file mode 100644 index 0000000..8cd2b58 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSearchConsoleProviderPlanHelper.php @@ -0,0 +1,152 @@ + 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[] = '' + . '' . self::escape((string)($arrItem['host'] ?? '')) . '' + . '' . self::escape((string)($arrItem['site_url'] ?? '')) . '' + . '' . self::escape((string)($arrItem['property_scope'] ?? '')) . '' + . ''; + } + + $arrStepRows = []; + foreach ((array)($arrSummary['next_steps'] ?? []) as $strStep) { + $arrStepRows[] = '
  • ' . self::escape((string)$strStep) . '
  • '; + } + + return 'Search Console Provider Plan' + . '' + . '' + . '

    Search Console Provider Plan

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '
    ' + . '
    status
    ' . self::escape((string)($arrSummary['status'] ?? '')) . '
    ' + . '
    credential_ready
    ' . (!empty($arrSummary['credential_ready']) ? 'yes' : 'no') . '
    ' + . '
    property_map_ready
    ' . (!empty($arrSummary['property_map_ready']) ? 'yes' : 'no') . '
    ' + . '
    client_ready
    ' . (!empty($arrSummary['client_dependency_ready']) ? 'yes' : 'no') . '
    ' + . '
    mapped_hosts
    ' . (int)($arrSummary['mapped_hosts_count'] ?? 0) . '
    ' + . '
    days
    ' . (int)($arrSummary['days'] ?? 0) . '
    ' + . '
    ' + . '

    credential_path: ' . self::escape((string)($arrSummary['credential_path'] ?? '')) . '

    ' + . '

    property_map_path: ' . self::escape((string)($arrSummary['property_map_path'] ?? '')) . '

    ' + . '

    Properties Preview

    ' + . '' . implode('', $arrPropertyRows) . '
    hostsite_urlproperty_scope
    ' + . '

    Next Steps

      ' . implode('', $arrStepRows) . '
    ' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoSiteQueryProbeHelper.php b/code/app/common/helper/DomainExternalSeoSiteQueryProbeHelper.php new file mode 100644 index 0000000..6ae286b --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSiteQueryProbeHelper.php @@ -0,0 +1,283 @@ +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>/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[] = '' + . '' . self::escape((string)($arrItem['host'] ?? '')) . '' + . '' . self::escape((string)($arrItem['query_status'] ?? '')) . '' + . '' . self::escape((string)($arrItem['engine_variant'] ?? '')) . '' + . '' . (int)($arrItem['http_status'] ?? 0) . '' + . '' . self::escape((string)($arrItem['page_title'] ?? '')) . '' + . '' . self::escape((string)($arrItem['result_count_text'] ?? '')) . '' + . '' . (!empty($arrItem['captcha_detected']) ? 'yes' : 'no') . '' + . '' . self::escape((string)($arrItem['error_message'] ?? '')) . '' + . ''; + } + + return 'Site Query Probe Summary' + . '' + . '' + . '

    Site Query Probe Summary

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '
    ' + . '
    host_count
    ' . (int)($arrSummary['host_count'] ?? 0) . '
    ' + . '
    indexed_like
    ' . (int)((($arrSummary['status_buckets'] ?? [])['indexed_like']) ?? 0) . '
    ' + . '
    captcha
    ' . (int)((($arrSummary['status_buckets'] ?? [])['captcha']) ?? 0) . '
    ' + . '
    no_result
    ' . (int)((($arrSummary['status_buckets'] ?? [])['no_result']) ?? 0) . '
    ' + . '
    blocked
    ' . (int)((($arrSummary['status_buckets'] ?? [])['blocked']) ?? 0) . '
    ' + . '
    ' + . '' + . implode('', $arrRows) + . '
    hoststatusenginehttptitleresult_count_textcaptchaerror
    ' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoSnapshotAnalysisHelper.php b/code/app/common/helper/DomainExternalSeoSnapshotAnalysisHelper.php new file mode 100644 index 0000000..901f0ba --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSnapshotAnalysisHelper.php @@ -0,0 +1,474 @@ +=', $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; + } +} diff --git a/code/app/common/helper/DomainExternalSeoSnapshotHelper.php b/code/app/common/helper/DomainExternalSeoSnapshotHelper.php new file mode 100644 index 0000000..8cf4dee --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSnapshotHelper.php @@ -0,0 +1,268 @@ +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, + ]; + } +} diff --git a/code/app/common/helper/DomainExternalSeoSnapshotRunHelper.php b/code/app/common/helper/DomainExternalSeoSnapshotRunHelper.php new file mode 100644 index 0000000..d7bfcde --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSnapshotRunHelper.php @@ -0,0 +1,113 @@ + 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 = 'SEO 外部快照接收摘要' + . '' + . '' + . '

    SEO 外部快照接收摘要

    ' + . '
    ' + . '
    source: ' . htmlspecialchars((string)($arrSummary['source'] ?? '-')) . '
    ' + . '
    received: ' . (int)($arrSummary['received_count'] ?? 0) . '
    ' + . '
    inserted: ' . (int)($arrSummary['inserted_count'] ?? 0) . '
    ' + . '
    updated: ' . (int)($arrSummary['updated_count'] ?? 0) . '
    ' + . '
    failed: ' . (int)($arrSummary['failed_count'] ?? 0) . '
    ' + . '
    '; + + $arrOverview = (array)($arrSummary['post_ingest_overview'] ?? []); + $arrTrend = (array)($arrSummary['post_ingest_snapshot_trend'] ?? []); + if (!empty($arrOverview) || !empty($arrTrend)) { + $strHtml .= '

    Post Ingest

    '; + if (!empty($arrOverview)) { + $strHtml .= '
    health: ' . htmlspecialchars((string)($arrOverview['health_label'] ?? '-')) . '
    ' + . '
    hosts: ' . (int)($arrOverview['snapshot_summary']['host_count'] ?? 0) . '
    ' + . '
    indexed_like: ' . (int)($arrOverview['snapshot_summary']['indexed_like_host_count'] ?? 0) . '
    '; + } + if (!empty($arrTrend)) { + $strHtml .= '
    trend: ' . htmlspecialchars((string)($arrTrend['success_trend']['label'] ?? '-')) . '
    ' + . '
    runs: ' . (int)($arrTrend['runs_count'] ?? 0) . '
    ' + . '
    latest success rate: ' . htmlspecialchars((string)($arrTrend['success_trend']['latest_success_rate'] ?? '-')) . '
    '; + } + $strHtml .= '
    '; + } + + $arrItems = array_merge((array)($arrSummary['inserted_items'] ?? []), (array)($arrSummary['updated_items'] ?? [])); + if (!empty($arrItems)) { + $strHtml .= ''; + foreach ($arrItems as $arrItem) { + $strHtml .= '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + $strHtml .= '
    HostProviderSnapshot TypeScopeMode
    ' . htmlspecialchars((string)($arrItem['host'] ?? '')) . '' . htmlspecialchars((string)($arrItem['provider'] ?? '')) . '' . htmlspecialchars((string)($arrItem['snapshot_type'] ?? '')) . '' . htmlspecialchars((string)($arrItem['scope'] ?? '')) . '' . htmlspecialchars((string)($arrItem['mode'] ?? '')) . '
    '; + } + + if (!empty($arrSummary['errors'])) { + $strHtml .= '

    Errors

    '; + foreach ((array)$arrSummary['errors'] as $arrError) { + $strHtml .= ''; + } + $strHtml .= '
    IndexMessage
    ' . (int)($arrError['index'] ?? 0) . '' . htmlspecialchars((string)($arrError['message'] ?? '')) . '
    '; + } + + $strHtml .= ''; + 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; + } +} diff --git a/code/app/common/helper/DomainExternalSeoSnapshotRunIndexHelper.php b/code/app/common/helper/DomainExternalSeoSnapshotRunIndexHelper.php new file mode 100644 index 0000000..67ed8c7 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSnapshotRunIndexHelper.php @@ -0,0 +1,132 @@ + [], + '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']; + } +} diff --git a/code/app/common/helper/DomainExternalSeoSnapshotSchemaHelper.php b/code/app/common/helper/DomainExternalSeoSnapshotSchemaHelper.php new file mode 100644 index 0000000..0a66e05 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSnapshotSchemaHelper.php @@ -0,0 +1,191 @@ + 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)); + } +} diff --git a/code/app/common/helper/DomainExternalSeoSnapshotTrendHelper.php b/code/app/common/helper/DomainExternalSeoSnapshotTrendHelper.php new file mode 100644 index 0000000..3c61dbb --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSnapshotTrendHelper.php @@ -0,0 +1,127 @@ + 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[] = '' + . '' . self::escape((string)($arrItem['run_id'] ?? '')) . '' + . '' . self::escape((string)($arrItem['source'] ?? '')) . '' + . '' . (int)($arrItem['received_count'] ?? 0) . '' + . '' . (int)($arrItem['inserted_count'] ?? 0) . '' + . '' . (int)($arrItem['updated_count'] ?? 0) . '' + . '' . (int)($arrItem['failed_count'] ?? 0) . '' + . '' . self::escape((string)($arrItem['success_rate'] ?? '0')) . '' + . '' . self::escape((string)($arrItem['updated_at'] ?? '')) . '' + . ''; + } + + return 'SEO External Snapshot Trend' + . '' + . '' + . '

    SEO External Snapshot Trend

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '

    generated_at: ' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '

    ' + . '
    ' + . '
    runs_count
    ' . (int)($arrSummary['runs_count'] ?? 0) . '
    ' + . '
    latest_success_rate
    ' . self::escape((string)(($arrSummary['success_trend'] ?? [])['latest_success_rate'] ?? '0')) . '
    ' + . '
    previous_success_rate
    ' . self::escape((string)(($arrSummary['success_trend'] ?? [])['previous_success_rate'] ?? '0')) . '
    ' + . '
    trend
    ' . self::escape((string)(($arrSummary['success_trend'] ?? [])['label'] ?? 'flat')) . '
    ' + . '
    ' + . '' . implode('', $arrRows) . '
    run_idsourcereceivedinsertedupdatedfailedsuccess_rateupdated_at
    ' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoSnapshotValidateRunHelper.php b/code/app/common/helper/DomainExternalSeoSnapshotValidateRunHelper.php new file mode 100644 index 0000000..1ebad31 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSnapshotValidateRunHelper.php @@ -0,0 +1,89 @@ + 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 = 'SEO 外部快照字段校验摘要' + . '' + . '' + . '

    SEO 外部快照字段校验摘要

    ' + . '
    ' + . '
    received: ' . (int)($arrSummary['received_count'] ?? 0) . '
    ' + . '
    valid: ' . (int)($arrSummary['valid_count'] ?? 0) . '
    ' + . '
    errors: ' . (int)($arrSummary['error_count'] ?? 0) . '
    ' + . '
    warnings: ' . (int)($arrSummary['warning_count'] ?? 0) . '
    ' + . '
    passed: ' . (!empty($arrSummary['passed']) ? 'yes' : 'no') . '
    ' + . '
    '; + + if (!empty($arrSummary['error_buckets'])) { + $strHtml .= '

    Error Buckets

    '; + foreach ((array)$arrSummary['error_buckets'] as $arrBucket) { + $strHtml .= ''; + } + $strHtml .= '
    MessageCount
    ' . htmlspecialchars((string)($arrBucket['message'] ?? '')) . '' . (int)($arrBucket['count'] ?? 0) . '
    '; + } + + if (!empty($arrSummary['warning_buckets'])) { + $strHtml .= '

    Warning Buckets

    '; + foreach ((array)$arrSummary['warning_buckets'] as $arrBucket) { + $strHtml .= ''; + } + $strHtml .= '
    MessageCount
    ' . htmlspecialchars((string)($arrBucket['message'] ?? '')) . '' . (int)($arrBucket['count'] ?? 0) . '
    '; + } + + $strHtml .= ''; + 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; + } +} diff --git a/code/app/common/helper/DomainExternalSeoSnapshotValidateRunIndexHelper.php b/code/app/common/helper/DomainExternalSeoSnapshotValidateRunIndexHelper.php new file mode 100644 index 0000000..628ff16 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSnapshotValidateRunIndexHelper.php @@ -0,0 +1,116 @@ + [], + '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']; + } +} diff --git a/code/app/common/helper/DomainExternalSeoSummaryHelper.php b/code/app/common/helper/DomainExternalSeoSummaryHelper.php new file mode 100644 index 0000000..ba736cc --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoSummaryHelper.php @@ -0,0 +1,303 @@ + $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[] = '' + . '' . self::escape((string)($arrHost['host'] ?? '')) . '' + . '' . self::escape(self::translateReadiness((string)($arrHost['external_readiness'] ?? ''))) . '' + . '' . self::escape(DomainImportReportViewHelper::translateStatus((string)($arrHost['probe_status'] ?? ''))) . '' + . '' . self::escape(DomainImportReportViewHelper::translateStage((string)($arrHost['failed_stage'] ?? ''))) . '' + . '' . self::escape((string)($arrHost['search_keyword'] ?? '')) . '' + . '' . self::escape(self::translateSampleSource((string)($arrHost['sample_source'] ?? ''))) . '' + . '' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrHost['probed_at'] ?? ''))) . '' + . ''; + } + + $arrKeywordRows = []; + foreach ((array)($arrSummary['top_keywords'] ?? []) as $arrKeyword) { + $arrKeywordRows[] = '' + . '' . self::escape((string)($arrKeyword['keyword'] ?? '')) . '' + . '' . (int)($arrKeyword['host_count'] ?? 0) . '' + . ''; + } + + return '站外效果摘要' + . '' + . '' + . '

    站外效果摘要

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '

    生成时间:' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? ''))) . '

    ' + . '

    摘要说明:' . self::escape((string)($arrSummary['hero_summary'] ?? '')) . '

    ' + . '
    ' + . '
    域名数
    ' . (int)($arrSummary['host_count'] ?? 0) . '
    ' + . '
    已恢复
    ' . (int)($arrSummary['ready_count'] ?? 0) . '
    ' + . '
    待关注
    ' . (int)($arrSummary['attention_count'] ?? 0) . '
    ' + . '
    关键词样本
    ' . (int)($arrSummary['tracked_keyword_count'] ?? 0) . '
    ' + . '
    ' + . '

    趋势:' . self::escape(DomainImportReportViewHelper::translateTrendLabel((string)(($arrSummary['failure_trend'] ?? [])['label'] ?? ''))) . '

    ' + . '

    重点关键词样本

    ' + . '' . implode('', $arrKeywordRows) . '
    关键词关联域名数
    ' + . '

    域名明细

    ' + . '' + . implode('', $arrRows) + . '
    域名当前状态探测结果失败阶段搜索词样本样本来源最近探测时间
    ' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainExternalSeoTrendHelper.php b/code/app/common/helper/DomainExternalSeoTrendHelper.php new file mode 100644 index 0000000..6e2b1d7 --- /dev/null +++ b/code/app/common/helper/DomainExternalSeoTrendHelper.php @@ -0,0 +1,140 @@ + (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[] = '' + . '' . self::escape((string)($arrItem['run_id'] ?? '')) . '' + . '' . (int)($arrItem['passed_count'] ?? 0) . '' + . '' . (int)($arrItem['failed_count'] ?? 0) . '' + . '' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrItem['updated_at'] ?? ''))) . '' + . ''; + } + + $arrImportRows = []; + foreach ((array)($arrSummary['recent_import_runs'] ?? []) as $arrItem) { + $arrImportRows[] = '' + . '' . self::escape((string)($arrItem['run_id'] ?? '')) . '' + . '' . (int)($arrItem['processed_count'] ?? 0) . '' + . '' . (int)($arrItem['passed_count'] ?? 0) . '' + . '' . (int)($arrItem['failed_count'] ?? 0) . '' + . '' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrItem['updated_at'] ?? ''))) . '' + . ''; + } + + return '站外效果趋势' + . '' + . '' + . '

    站外效果趋势

    ' + . '

    ' . self::escape((string)($arrSummary['note'] ?? '')) . '

    ' + . '

    生成时间:' . self::escape(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? ''))) . '

    ' + . '

    趋势说明:' . self::escape((string)($arrSummary['hero_summary'] ?? '')) . '

    ' + . '
    ' + . '
    域名数
    ' . (int)($arrSummary['host_count'] ?? 0) . '
    ' + . '
    已恢复
    ' . (int)($arrSummary['ready_count'] ?? 0) . '
    ' + . '
    待关注
    ' . (int)($arrSummary['attention_count'] ?? 0) . '
    ' + . '
    当前异常队列
    ' . (int)($arrSummary['external_queue_count'] ?? 0) . '
    ' + . '
    趋势
    ' . self::escape(DomainImportReportViewHelper::translateTrendLabel((string)(($arrSummary['proxy_signal_trend'] ?? [])['label'] ?? ''))) . '
    ' + . '
    ' + . '

    最近重跑

    ' + . '' . implode('', $arrRerunRows) . '
    运行批次通过失败更新时间
    ' + . '

    最近导入

    ' + . '' . implode('', $arrImportRows) . '
    运行批次探测数通过失败更新时间
    ' + . ''; + } + + 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'); + } +} diff --git a/code/app/common/helper/DomainImportExternalSeoClosureActionHelper.php b/code/app/common/helper/DomainImportExternalSeoClosureActionHelper.php new file mode 100644 index 0000000..d5aa5c9 --- /dev/null +++ b/code/app/common/helper/DomainImportExternalSeoClosureActionHelper.php @@ -0,0 +1,211 @@ + (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 '闭环动作摘要' + . '' + . '' + . '

    闭环动作摘要

    ' + . '

    生成时间:' . $escape(DomainImportReportViewHelper::formatDateTime((string)($summary['generated_at'] ?? ''))) . '

    ' + . '

    状态:' . $escape(DomainImportReportViewHelper::translateStatus((string)($summary['status'] ?? 'pending'))) . '

    ' + . '

    动作:' . $escape((string)($summary['action_label'] ?? '')) . '

    ' + . '

    动作说明:' . $escape((string)($summary['action_summary'] ?? '')) . '

    ' + . '

    闭环阶段:' . $escape(DomainImportReportViewHelper::translateClosureStage((string)($summary['closure_stage'] ?? ''))) . ' / 健康状态:' . $escape(DomainImportReportViewHelper::translateHealthLabel((string)($summary['health_label'] ?? ''))) . '

    ' + . '

    状态备注:' . $escape((string)($summary['status_note'] ?? '')) . '

    ' + . '

    目标类型:' . $escape((string)($summary['target_type'] ?? '')) . ' / 目标键:' . $escape((string)($summary['target_key'] ?? '')) . ' / 目标域名:' . $escape((string)($summary['target_host'] ?? '')) . '

    ' + . '

    结果:' . $escape(DomainImportReportViewHelper::translateResultLabel((string)($summary['result_label'] ?? 'pending'))) . '

    ' + . '

    结果摘要:' . $escape((string)($summary['result_summary'] ?? '')) . '

    ' + . '

    观察建议:' . $escape((string)($summary['result_observation'] ?? '')) . '

    ' + . ''; + } +} diff --git a/code/app/common/helper/DomainImportExternalSeoClosureActionIndexHelper.php b/code/app/common/helper/DomainImportExternalSeoClosureActionIndexHelper.php new file mode 100644 index 0000000..3fd12bd --- /dev/null +++ b/code/app/common/helper/DomainImportExternalSeoClosureActionIndexHelper.php @@ -0,0 +1,201 @@ + [], + '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); + } +} diff --git a/code/app/common/helper/DomainImportExternalSeoClosureHelper.php b/code/app/common/helper/DomainImportExternalSeoClosureHelper.php new file mode 100644 index 0000000..95eb9f3 --- /dev/null +++ b/code/app/common/helper/DomainImportExternalSeoClosureHelper.php @@ -0,0 +1,162 @@ + 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); + } +} diff --git a/code/app/common/helper/DomainImportExternalSeoClosureHostHistoryHelper.php b/code/app/common/helper/DomainImportExternalSeoClosureHostHistoryHelper.php new file mode 100644 index 0000000..613a352 --- /dev/null +++ b/code/app/common/helper/DomainImportExternalSeoClosureHostHistoryHelper.php @@ -0,0 +1,60 @@ + 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), + ]; + } +} diff --git a/code/app/common/helper/DomainImportExternalSeoClosureTodayHelper.php b/code/app/common/helper/DomainImportExternalSeoClosureTodayHelper.php new file mode 100644 index 0000000..ad93cb8 --- /dev/null +++ b/code/app/common/helper/DomainImportExternalSeoClosureTodayHelper.php @@ -0,0 +1,72 @@ + 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'] ?? []), + ]; + } +} diff --git a/code/app/common/helper/DomainImportFailedQueueHelper.php b/code/app/common/helper/DomainImportFailedQueueHelper.php new file mode 100644 index 0000000..074843e --- /dev/null +++ b/code/app/common/helper/DomainImportFailedQueueHelper.php @@ -0,0 +1,267 @@ + 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; + } +} diff --git a/code/app/common/helper/DomainImportHealthTrendHelper.php b/code/app/common/helper/DomainImportHealthTrendHelper.php new file mode 100644 index 0000000..a84c4cc --- /dev/null +++ b/code/app/common/helper/DomainImportHealthTrendHelper.php @@ -0,0 +1,96 @@ + (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')), + ], + ]; + } +} diff --git a/code/app/common/helper/DomainImportHealthWorkbenchHelper.php b/code/app/common/helper/DomainImportHealthWorkbenchHelper.php new file mode 100644 index 0000000..7a1e910 --- /dev/null +++ b/code/app/common/helper/DomainImportHealthWorkbenchHelper.php @@ -0,0 +1,355 @@ + 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 '导入主线整体平稳,可以先看最新导入与自动修复结果。'; + } +} diff --git a/code/app/common/helper/DomainImportManualAttentionHandleIndexHelper.php b/code/app/common/helper/DomainImportManualAttentionHandleIndexHelper.php new file mode 100644 index 0000000..7ddddc1 --- /dev/null +++ b/code/app/common/helper/DomainImportManualAttentionHandleIndexHelper.php @@ -0,0 +1,64 @@ + [], '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] ?? [], + ]; + } +} diff --git a/code/app/common/helper/DomainImportManualAttentionQueueHelper.php b/code/app/common/helper/DomainImportManualAttentionQueueHelper.php new file mode 100644 index 0000000..18caf26 --- /dev/null +++ b/code/app/common/helper/DomainImportManualAttentionQueueHelper.php @@ -0,0 +1,216 @@ += 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'] ?? []); + } +} diff --git a/code/app/common/helper/DomainImportProbeRunHelper.php b/code/app/common/helper/DomainImportProbeRunHelper.php new file mode 100644 index 0000000..c8e0fda --- /dev/null +++ b/code/app/common/helper/DomainImportProbeRunHelper.php @@ -0,0 +1,133 @@ + 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('
    ', $arrChecks); + $arrRows[] = '' + . '' . $strHost . '' + . '' . $strStatus . '' + . '' . $strFailedStage . '' + . '' . $strMessage . '' + . '' . $strChecks . '' + . ''; + } + + return '站点导入探测摘要' + . '' + . '' + . '

    站点导入探测摘要

    ' + . '

    生成时间:' . htmlspecialchars(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? '')), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    状态:' . htmlspecialchars(DomainImportReportViewHelper::translateStatus((string)($arrSummary['status'] ?? '')), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    处理总数:' . (int)($arrSummary['processed_count'] ?? 0) . ' / 通过:' . (int)($arrSummary['passed_count'] ?? 0) . ' / 失败:' . (int)($arrSummary['failed_count'] ?? 0) . '

    ' + . '' + . implode('', $arrRows) + . '
    域名状态失败阶段说明检查结果
    '; + } +} diff --git a/code/app/common/helper/DomainImportRemediationIndexHelper.php b/code/app/common/helper/DomainImportRemediationIndexHelper.php new file mode 100644 index 0000000..87f1a83 --- /dev/null +++ b/code/app/common/helper/DomainImportRemediationIndexHelper.php @@ -0,0 +1,70 @@ + [], + '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] ?? [], + ]; + } +} diff --git a/code/app/common/helper/DomainImportRerunIndexHelper.php b/code/app/common/helper/DomainImportRerunIndexHelper.php new file mode 100644 index 0000000..18d9d9f --- /dev/null +++ b/code/app/common/helper/DomainImportRerunIndexHelper.php @@ -0,0 +1,69 @@ + [], + '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] ?? [], + ]; + } +} diff --git a/code/app/common/helper/DomainImportRunIndexHelper.php b/code/app/common/helper/DomainImportRunIndexHelper.php new file mode 100644 index 0000000..6acf91b --- /dev/null +++ b/code/app/common/helper/DomainImportRunIndexHelper.php @@ -0,0 +1,86 @@ + [], + '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]); + } +} diff --git a/code/app/common/helper/DomainImportSelfHealingIndexHelper.php b/code/app/common/helper/DomainImportSelfHealingIndexHelper.php new file mode 100644 index 0000000..8ed8511 --- /dev/null +++ b/code/app/common/helper/DomainImportSelfHealingIndexHelper.php @@ -0,0 +1,73 @@ + [], + '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] ?? [], + ]; + } +} diff --git a/code/app/common/helper/DomainSpiderCrawlLogHelper.php b/code/app/common/helper/DomainSpiderCrawlLogHelper.php new file mode 100644 index 0000000..4beee24 --- /dev/null +++ b/code/app/common/helper/DomainSpiderCrawlLogHelper.php @@ -0,0 +1,158 @@ + 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 = '蜘蛛抓取概览' + . '' + . '

    蜘蛛抓取概览

    ' + . '
    source: ' . htmlspecialchars((string)($arrSummary['source'] ?? '-')) . '
    ' + . '
    summary_records: ' . (int)($arrSummary['summary_record_count'] ?? 0) . '
    ' + . '
    anomaly_records: ' . (int)($arrSummary['anomaly_record_count'] ?? 0) . '
    ' + . '
    summary_total_requests: ' . (int)($arrSummary['summary_total_requests'] ?? 0) . '
    ' + . '
    hosts: ' . (int)($arrSummary['hosts_count'] ?? 0) . '
    ' + . '
    '; + + $strHtml .= '

    Top Hosts

    '; + foreach ((array)($arrSummary['top_hosts'] ?? []) as $arrItem) { + $strHtml .= ''; + } + $strHtml .= '
    hostcount
    ' . htmlspecialchars((string)($arrItem['host'] ?? '')) . '' . (int)($arrItem['count'] ?? 0) . '
    '; + + $strHtml .= '

    页面类型分布

    '; + foreach ((array)($arrSummary['page_type_buckets'] ?? []) as $arrItem) { + $strHtml .= ''; + } + $strHtml .= '
    page_typecount
    ' . htmlspecialchars((string)($arrItem['page_type'] ?? '')) . '' . (int)($arrItem['count'] ?? 0) . '
    '; + + file_put_contents($strHtmlPath, $strHtml . ''); + + $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)); + } +} diff --git a/code/app/common/helper/DomainSpiderCrawlLogRunHelper.php b/code/app/common/helper/DomainSpiderCrawlLogRunHelper.php new file mode 100644 index 0000000..a6dbcea --- /dev/null +++ b/code/app/common/helper/DomainSpiderCrawlLogRunHelper.php @@ -0,0 +1,87 @@ +蜘蛛抓取日志摘要' + . '' + . '

    蜘蛛抓取日志摘要

    ' + . '
    source: ' . htmlspecialchars((string)($arrSummary['source'] ?? '-')) . '
    ' + . '
    summary_records: ' . (int)($arrSummary['summary_record_count'] ?? 0) . '
    ' + . '
    anomaly_records: ' . (int)($arrSummary['anomaly_record_count'] ?? 0) . '
    ' + . '
    hosts: ' . (int)($arrSummary['hosts_count'] ?? 0) . '
    ' + . '
    bots: ' . (int)($arrSummary['bots_count'] ?? 0) . '
    ' + . '
    '; + + $strHtml .= '

    页面类型分布

    '; + foreach ((array)($arrSummary['page_type_buckets'] ?? []) as $arrItem) { + $strHtml .= ''; + } + $strHtml .= '
    page_typecount
    ' . htmlspecialchars((string)($arrItem['page_type'] ?? '')) . '' . (int)($arrItem['count'] ?? 0) . '
    '; + + $strHtml .= '

    蜘蛛分布

    '; + foreach ((array)($arrSummary['bot_buckets'] ?? []) as $arrItem) { + $strHtml .= ''; + } + $strHtml .= '
    bot_namecount
    ' . htmlspecialchars((string)($arrItem['bot_name'] ?? '')) . '' . (int)($arrItem['count'] ?? 0) . '
    '; + + if (!empty($arrSummary['anomaly_status_buckets'])) { + $strHtml .= '

    异常状态

    '; + foreach ((array)$arrSummary['anomaly_status_buckets'] as $arrItem) { + $strHtml .= ''; + } + $strHtml .= '
    statuscount
    ' . htmlspecialchars((string)($arrItem['status'] ?? '')) . '' . (int)($arrItem['count'] ?? 0) . '
    '; + } + + file_put_contents($strHtmlPath, $strHtml . ''); + + $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; + } +} diff --git a/code/app/common/helper/DomainSpiderCrawlLogSchemaHelper.php b/code/app/common/helper/DomainSpiderCrawlLogSchemaHelper.php new file mode 100644 index 0000000..f5ed068 --- /dev/null +++ b/code/app/common/helper/DomainSpiderCrawlLogSchemaHelper.php @@ -0,0 +1,237 @@ + 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)); + } +} diff --git a/code/app/common/helper/DomainSpiderCrawlWorkbenchHelper.php b/code/app/common/helper/DomainSpiderCrawlWorkbenchHelper.php new file mode 100644 index 0000000..fc353cd --- /dev/null +++ b/code/app/common/helper/DomainSpiderCrawlWorkbenchHelper.php @@ -0,0 +1,1306 @@ + $summaryRecords, + 'anomaly_records' => $anomalyRecords, + ], (string)($summary['source'] ?? 'frontend_nginx_push_filtered')); + $filtered['generated_at'] = trim((string)($summary['generated_at'] ?? date(DATE_ATOM))); + $filtered['mode'] = (string)($summary['mode'] ?? 'domain_spider_crawl_log_ingest'); + $filtered['bot_scope'] = $botScope; + $filtered['summary_json_path'] = (string)($summary['summary_json_path'] ?? ''); + $filtered['summary_html_path'] = (string)($summary['summary_html_path'] ?? ''); + + return $filtered; + } + + protected static function filterSummaryBySelectedBots(array $summary, array $selectedBots): array + { + $selectedBots = self::normalizeSelectedBots($selectedBots); + if (empty($summary)) { + return $summary; + } + + if (empty($selectedBots)) { + $filtered = DomainSpiderCrawlLogHelper::ingestPayload([ + 'summary_records' => [], + 'anomaly_records' => [], + ], (string)($summary['source'] ?? 'frontend_nginx_push_filtered')); + $filtered['generated_at'] = trim((string)($summary['generated_at'] ?? date(DATE_ATOM))); + $filtered['mode'] = (string)($summary['mode'] ?? 'domain_spider_crawl_log_ingest'); + $filtered['summary_json_path'] = (string)($summary['summary_json_path'] ?? ''); + $filtered['summary_html_path'] = (string)($summary['summary_html_path'] ?? ''); + $filtered['selected_bots'] = []; + + return $filtered; + } + + $allowed = array_flip($selectedBots); + $summaryRecords = array_values(array_filter((array)($summary['summary_records'] ?? []), static function ($row) use ($allowed): bool { + if (!is_array($row)) { + return false; + } + $botName = strtolower(trim((string)($row['bot_name'] ?? 'other_bot'))); + return isset($allowed[$botName]); + })); + $anomalyRecords = array_values(array_filter((array)($summary['anomaly_records'] ?? []), static function ($row) use ($allowed): bool { + if (!is_array($row)) { + return false; + } + $botName = strtolower(trim((string)($row['bot_name'] ?? 'other_bot'))); + return isset($allowed[$botName]); + })); + + $filtered = DomainSpiderCrawlLogHelper::ingestPayload([ + 'summary_records' => $summaryRecords, + 'anomaly_records' => $anomalyRecords, + ], (string)($summary['source'] ?? 'frontend_nginx_push_filtered')); + $filtered['generated_at'] = trim((string)($summary['generated_at'] ?? date(DATE_ATOM))); + $filtered['mode'] = (string)($summary['mode'] ?? 'domain_spider_crawl_log_ingest'); + $filtered['summary_json_path'] = (string)($summary['summary_json_path'] ?? ''); + $filtered['summary_html_path'] = (string)($summary['summary_html_path'] ?? ''); + $filtered['selected_bots'] = $selectedBots; + + return $filtered; + } + + protected static function buildAnomalyPriority(array $row): array + { + $pageType = trim((string)($row['page_type'] ?? 'other')); + $status = (int)($row['status'] ?? 0); + $botName = strtolower(trim((string)($row['bot_name'] ?? 'other_bot'))); + $url = strtolower(trim((string)($row['url'] ?? ''))); + + $score = 20; + $reason = '常规异常,建议继续观察。'; + + if (($pageType === 'robots' || str_contains($url, '/robots.txt')) && in_array($status, [403, 404, 444, 500, 502, 503], true)) { + $score = 95; + $reason = 'robots 抓取异常,容易直接影响蜘蛛入口判断。'; + } elseif (($pageType === 'sitemap' || str_contains($url, 'sitemap')) && in_array($status, [403, 404, 444, 500, 502, 503], true)) { + $score = 90; + $reason = 'sitemap 抓取异常,容易影响深层 URL 发现效率。'; + } elseif (in_array($pageType, ['detail', 'play'], true) && in_array($status, [403, 404, 444, 500, 502, 503], true)) { + $score = str_contains($botName, 'baidu') ? 88 : 82; + $reason = str_contains($botName, 'baidu') + ? '百度蜘蛛命中深页异常,优先排查详情/播放页可访问性。' + : '深页抓取异常,建议优先排查详情/播放页返回状态。'; + } elseif ($pageType === 'category' && in_array($status, [403, 404, 444, 500, 502, 503], true)) { + $score = 72; + $reason = '分类页抓取异常,可能影响蜘蛛继续向深页扩展。'; + } elseif ($pageType === 'home' && in_array($status, [403, 404, 444, 500, 502, 503], true)) { + $score = 70; + $reason = '首页抓取异常,建议优先确认入口页可访问性。'; + } elseif (in_array($status, [500, 502, 503], true)) { + $score = 76; + $reason = '服务端错误较重,建议优先排查回源和应用状态。'; + } elseif (in_array($status, [403, 404, 444], true)) { + $score = 58; + $reason = '存在抓取拦截或未命中,建议结合 URL 类型继续判断。'; + } elseif (in_array($status, [301, 302], true) && in_array($pageType, ['detail', 'play', 'robots', 'sitemap'], true)) { + $score = 52; + $reason = '关键入口或深页存在跳转,建议确认跳转链是否稳定。'; + } + + $label = '低'; + $tagType = 'info'; + if ($score >= 85) { + $label = '高'; + $tagType = 'danger'; + } elseif ($score >= 60) { + $label = '中'; + $tagType = 'warning'; + } + + return [ + 'priority_score' => $score, + 'priority_label' => $label, + 'priority_tag_type' => $tagType, + 'priority_reason' => $reason, + ]; + } + + protected static function parseTimeValue(?string $value): int + { + $value = trim((string)$value); + if ($value === '') { + return 0; + } + $timestamp = strtotime($value); + return $timestamp === false ? 0 : $timestamp; + } + + protected static function publicRelativePath(string $publicRoot, string $path): string + { + $publicRoot = str_replace('\\', '/', rtrim($publicRoot, '/')); + $path = str_replace('\\', '/', trim($path)); + if ($path !== '' && str_starts_with($path, $publicRoot . '/')) { + return substr($path, strlen($publicRoot . '/')); + } + + return $path; + } + + protected 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 : []; + } + + protected static function formatPercent(int $part, int $total): string + { + if ($total <= 0) { + return '0.0%'; + } + + return number_format(($part / $total) * 100, 1) . '%'; + } + + protected static function toCountMap(array $rows, string $keyField): array + { + $map = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $key = trim((string)($row[$keyField] ?? '')); + if ($key === '') { + continue; + } + $map[$key] = (int)($row['count'] ?? 0); + } + + return $map; + } + + protected static function normalizeRunSummary(string $publicRoot, array $summary, string $summaryPath): array + { + $runRoot = trim((string)($summary['run_root'] ?? '')); + if ($runRoot === '') { + $runRoot = dirname($summaryPath); + } + + return [ + 'run_id' => trim((string)($summary['run_id'] ?? basename($runRoot))), + 'generated_at' => trim((string)($summary['generated_at'] ?? '')), + 'source' => trim((string)($summary['source'] ?? '')), + 'summary_record_count' => (int)($summary['summary_record_count'] ?? 0), + 'anomaly_record_count' => (int)($summary['anomaly_record_count'] ?? 0), + 'summary_total_requests' => (int)($summary['summary_total_requests'] ?? 0), + 'hosts_count' => (int)($summary['hosts_count'] ?? 0), + 'bots_count' => (int)($summary['bots_count'] ?? 0), + 'run_root' => self::publicRelativePath($publicRoot, $runRoot), + 'summary_json_path' => self::publicRelativePath($publicRoot, trim((string)($summary['summary_json_path'] ?? $summaryPath))), + 'summary_html_path' => self::publicRelativePath($publicRoot, trim((string)($summary['summary_html_path'] ?? ($runRoot . '/crawl-logs.summary.html')))), + 'payload_json_path' => self::publicRelativePath($publicRoot, trim((string)($summary['payload_json_path'] ?? ($runRoot . '/crawl-logs.payload.json')))), + 'top_page_types' => array_slice((array)($summary['page_type_buckets'] ?? []), 0, 5), + 'top_bots' => array_slice((array)($summary['bot_buckets'] ?? []), 0, 5), + 'top_hosts' => array_slice((array)($summary['top_hosts'] ?? []), 0, 5), + ]; + } + + protected static function collectRunEntries(string $publicRoot, string $runsRoot, int $windowHours = 0): array + { + $items = []; + $matches = glob(rtrim($runsRoot, '/') . '/*/*/crawl-logs.summary.json'); + if (!is_array($matches)) { + return []; + } + + $cutoffTimestamp = $windowHours > 0 ? (time() - ($windowHours * 3600)) : 0; + foreach ($matches as $summaryPath) { + $summary = self::readJsonFile((string)$summaryPath); + if (empty($summary)) { + continue; + } + $generatedAt = trim((string)($summary['generated_at'] ?? '')); + $generatedTimestamp = self::parseTimeValue($generatedAt); + if ($cutoffTimestamp > 0 && $generatedTimestamp > 0 && $generatedTimestamp < $cutoffTimestamp) { + continue; + } + $items[] = [ + 'summary' => $summary, + 'normalized' => self::normalizeRunSummary($publicRoot, $summary, (string)$summaryPath), + 'generated_at_ts' => $generatedTimestamp, + ]; + } + + usort($items, static function (array $left, array $right): int { + $leftTs = (int)($left['generated_at_ts'] ?? 0); + $rightTs = (int)($right['generated_at_ts'] ?? 0); + if ($leftTs !== $rightTs) { + return $rightTs <=> $leftTs; + } + return strcmp((string)($right['normalized']['run_id'] ?? ''), (string)($left['normalized']['run_id'] ?? '')); + }); + + return $items; + } + + protected static function aggregateRunSummaries(array $runEntries): array + { + if (empty($runEntries)) { + return []; + } + + $summaryRecords = []; + $anomalyRecords = []; + $latestGeneratedAt = ''; + foreach ($runEntries as $entry) { + $summary = is_array($entry['summary'] ?? null) ? (array)$entry['summary'] : []; + $generatedAt = trim((string)($summary['generated_at'] ?? '')); + if ($generatedAt !== '' && ($latestGeneratedAt === '' || strcmp($generatedAt, $latestGeneratedAt) > 0)) { + $latestGeneratedAt = $generatedAt; + } + foreach ((array)($summary['summary_records'] ?? []) as $row) { + if (is_array($row)) { + $summaryRecords[] = $row; + } + } + foreach ((array)($summary['anomaly_records'] ?? []) as $row) { + if (is_array($row)) { + $anomalyRecords[] = $row; + } + } + } + + $aggregated = DomainSpiderCrawlLogHelper::ingestPayload([ + 'summary_records' => $summaryRecords, + 'anomaly_records' => $anomalyRecords, + ], 'frontend_nginx_push_aggregated'); + $aggregated['generated_at'] = $latestGeneratedAt !== '' ? $latestGeneratedAt : date(DATE_ATOM); + $aggregated['mode'] = 'domain_spider_crawl_log_aggregated'; + + return $aggregated; + } + + protected static function buildRecentRuns(array $runEntries, int $limit): array + { + $items = []; + foreach (array_slice($runEntries, 0, $limit) as $entry) { + $items[] = (array)($entry['normalized'] ?? []); + } + + return $items; + } + + protected static function buildRecentAnomalies(array $latestSummary, int $limit): array + { + $items = []; + foreach ((array)($latestSummary['anomaly_records'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $item = [ + 'time' => trim((string)($row['time'] ?? '')), + 'host' => trim((string)($row['host'] ?? '')), + 'url' => trim((string)($row['url'] ?? '')), + 'page_type' => trim((string)($row['page_type'] ?? 'other')), + 'status' => (int)($row['status'] ?? 0), + 'bot_name' => trim((string)($row['bot_name'] ?? 'other_bot')), + 'cache_status' => trim((string)($row['cache_status'] ?? 'NONE')), + 'request_time' => (float)($row['request_time'] ?? 0), + 'upstream_time' => (float)($row['upstream_time'] ?? 0), + 'source_server' => trim((string)($row['source_server'] ?? '')), + 'referer' => trim((string)($row['referer'] ?? '')), + 'user_agent' => trim((string)($row['user_agent'] ?? '')), + ]; + $items[] = array_merge($item, self::buildAnomalyPriority($item)); + } + + usort($items, static function (array $left, array $right): int { + $priorityCompare = ((int)($right['priority_score'] ?? 0)) <=> ((int)($left['priority_score'] ?? 0)); + if ($priorityCompare !== 0) { + return $priorityCompare; + } + return strcmp((string)($right['time'] ?? ''), (string)($left['time'] ?? '')); + }); + + return array_slice($items, 0, $limit); + } + + protected static function buildDeepCoverage(array $latestSummary): array + { + $pageTypeMap = self::toCountMap((array)($latestSummary['page_type_buckets'] ?? []), 'page_type'); + $totalRequests = (int)($latestSummary['summary_total_requests'] ?? 0); + $detailRequests = (int)($pageTypeMap['detail'] ?? 0); + $playRequests = (int)($pageTypeMap['play'] ?? 0); + $searchRequests = (int)($pageTypeMap['search'] ?? 0); + $categoryRequests = (int)($pageTypeMap['category'] ?? 0); + $homeRequests = (int)($pageTypeMap['home'] ?? 0); + $deepRequests = $detailRequests + $playRequests; + + $detailAnomalies = 0; + $playAnomalies = 0; + $hostBuckets = []; + $botBuckets = []; + foreach ((array)($latestSummary['anomaly_records'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $pageType = trim((string)($row['page_type'] ?? 'other')); + $host = trim((string)($row['host'] ?? '')); + $botName = trim((string)($row['bot_name'] ?? 'other_bot')); + if ($pageType === 'detail') { + $detailAnomalies++; + } + if ($pageType === 'play') { + $playAnomalies++; + } + if ($pageType !== 'detail' && $pageType !== 'play') { + continue; + } + if ($host !== '') { + $hostBuckets[$host] = (int)($hostBuckets[$host] ?? 0) + 1; + } + if ($botName !== '') { + $botBuckets[$botName] = (int)($botBuckets[$botName] ?? 0) + 1; + } + } + + arsort($hostBuckets); + arsort($botBuckets); + + $hostRows = []; + foreach (array_slice($hostBuckets, 0, 10, true) as $host => $count) { + $hostRows[] = [ + 'host' => $host, + 'count' => (int)$count, + ]; + } + + $botRows = []; + foreach ($botBuckets as $bot => $anomalyCount) { + $botRows[] = [ + 'bot_name' => $bot, + 'deep_anomaly_count' => (int)$anomalyCount, + ]; + } + + return [ + 'total_requests' => $totalRequests, + 'home_requests' => $homeRequests, + 'category_requests' => $categoryRequests, + 'search_requests' => $searchRequests, + 'detail_requests' => $detailRequests, + 'play_requests' => $playRequests, + 'deep_requests' => $deepRequests, + 'deep_ratio' => self::formatPercent($deepRequests, $totalRequests), + 'detail_anomaly_count' => $detailAnomalies, + 'play_anomaly_count' => $playAnomalies, + 'top_hosts' => $hostRows, + 'bot_rows' => $botRows, + 'status_note' => $deepRequests > 0 + ? '当前已经能看到 detail/play 抓取信号,可以继续观察深页异常是否集中在 4xx/5xx/444。' + : '当前还没有形成明显的 detail/play 抓取信号,先重点看 robots、首页和分类页是否稳定被抓。', + ]; + } + + protected static function buildHostHealth(array $latestSummary, string $storageRoot = ''): array + { + $hosts = []; + + foreach ((array)($latestSummary['summary_records'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $host = trim((string)($row['host'] ?? '')); + if ($host === '') { + continue; + } + $count = max(0, (int)($row['count'] ?? 0)); + $pageType = trim((string)($row['page_type'] ?? 'other')); + $status = (int)($row['status'] ?? 0); + $botName = trim((string)($row['bot_name'] ?? 'other_bot')); + + if (!isset($hosts[$host])) { + $hosts[$host] = [ + 'host' => $host, + 'total_requests' => 0, + 'detail_requests' => 0, + 'play_requests' => 0, + 'robots_requests' => 0, + 'sitemap_requests' => 0, + 'status_200' => 0, + 'status_301' => 0, + 'status_302' => 0, + 'anomaly_count' => 0, + 'detail_anomaly_count' => 0, + 'play_anomaly_count' => 0, + 'top_bot_name' => $botName !== '' ? $botName : 'other_bot', + '_bot_buckets' => [], + ]; + } + + $hosts[$host]['total_requests'] += $count; + if ($pageType === 'detail') { + $hosts[$host]['detail_requests'] += $count; + } elseif ($pageType === 'play') { + $hosts[$host]['play_requests'] += $count; + } elseif ($pageType === 'robots') { + $hosts[$host]['robots_requests'] += $count; + } elseif ($pageType === 'sitemap') { + $hosts[$host]['sitemap_requests'] += $count; + } + + if ($status === 200) { + $hosts[$host]['status_200'] += $count; + } elseif ($status === 301) { + $hosts[$host]['status_301'] += $count; + } elseif ($status === 302) { + $hosts[$host]['status_302'] += $count; + } + + $hosts[$host]['_bot_buckets'][$botName] = (int)($hosts[$host]['_bot_buckets'][$botName] ?? 0) + $count; + } + + foreach ((array)($latestSummary['anomaly_records'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $host = trim((string)($row['host'] ?? '')); + if ($host === '') { + continue; + } + if (!isset($hosts[$host])) { + $hosts[$host] = [ + 'host' => $host, + 'total_requests' => 0, + 'detail_requests' => 0, + 'play_requests' => 0, + 'robots_requests' => 0, + 'sitemap_requests' => 0, + 'status_200' => 0, + 'status_301' => 0, + 'status_302' => 0, + 'anomaly_count' => 0, + 'detail_anomaly_count' => 0, + 'play_anomaly_count' => 0, + 'top_bot_name' => trim((string)($row['bot_name'] ?? 'other_bot')), + '_bot_buckets' => [], + ]; + } + + $pageType = trim((string)($row['page_type'] ?? 'other')); + $hosts[$host]['anomaly_count']++; + if ($pageType === 'detail') { + $hosts[$host]['detail_anomaly_count']++; + } elseif ($pageType === 'play') { + $hosts[$host]['play_anomaly_count']++; + } + } + + $rows = []; + foreach ($hosts as $host => $item) { + arsort($item['_bot_buckets']); + $topBotName = $item['top_bot_name']; + if (!empty($item['_bot_buckets'])) { + $topBotName = (string)array_key_first($item['_bot_buckets']); + } + $deepRequests = (int)$item['detail_requests'] + (int)$item['play_requests']; + $healthScore = ($item['status_200'] * 2) + $deepRequests - ($item['anomaly_count'] * 3); + $rows[] = [ + 'host' => $host, + 'total_requests' => (int)$item['total_requests'], + 'detail_requests' => (int)$item['detail_requests'], + 'play_requests' => (int)$item['play_requests'], + 'deep_requests' => $deepRequests, + 'robots_requests' => (int)$item['robots_requests'], + 'sitemap_requests' => (int)$item['sitemap_requests'], + 'status_200' => (int)$item['status_200'], + 'status_301' => (int)$item['status_301'], + 'status_302' => (int)$item['status_302'], + 'anomaly_count' => (int)$item['anomaly_count'], + 'detail_anomaly_count' => (int)$item['detail_anomaly_count'], + 'play_anomaly_count' => (int)$item['play_anomaly_count'], + 'top_bot_name' => $topBotName, + 'health_score' => $healthScore, + 'indexed_like' => !empty(self::readKeywordFeedbackHostSummary($storageRoot, $host)['indexed_like']), + 'keyword_ready' => !empty(self::readKeywordFeedbackHostSummary($storageRoot, $host)['keyword_ready']), + 'traffic_ready' => !empty(self::readKeywordFeedbackHostSummary($storageRoot, $host)['traffic_ready']), + 'status_label' => $deepRequests > 0 + ? ((int)$item['anomaly_count'] > 0 ? '已深抓,需看异常' : '已深抓,状态较稳') + : (((int)$item['robots_requests'] > 0 || (int)$item['sitemap_requests'] > 0) ? '入口已命中,待深抓' : '抓取信号偏弱'), + ]; + } + + usort($rows, static function (array $left, array $right): int { + $scoreCompare = ((int)$right['health_score']) <=> ((int)$left['health_score']); + if ($scoreCompare !== 0) { + return $scoreCompare; + } + + return strcmp((string)($left['host'] ?? ''), (string)($right['host'] ?? '')); + }); + + return array_slice($rows, 0, 30); + } + + protected static function buildIndexSignalSummary(string $storageRoot, array $hostHealthRows): array + { + $items = []; + foreach ($hostHealthRows as $row) { + if (!is_array($row)) { + continue; + } + $host = trim((string)($row['host'] ?? '')); + if ($host === '') { + continue; + } + $feedback = self::readKeywordFeedbackHostSummary($storageRoot, $host); + $items[] = [ + 'host' => $host, + 'indexed_like' => !empty($feedback['indexed_like']), + 'keyword_ready' => !empty($feedback['keyword_ready']), + 'traffic_ready' => !empty($feedback['traffic_ready']), + 'keyword_count' => (int)($feedback['keyword_count'] ?? 0), + 'latest_metric_date' => trim((string)($feedback['latest_metric_date'] ?? '')), + ]; + } + + $indexedLikeCount = 0; + $keywordReadyCount = 0; + $trafficReadyCount = 0; + foreach ($items as $item) { + if (!empty($item['indexed_like'])) { + $indexedLikeCount++; + } + if (!empty($item['keyword_ready'])) { + $keywordReadyCount++; + } + if (!empty($item['traffic_ready'])) { + $trafficReadyCount++; + } + } + + usort($items, static function (array $left, array $right): int { + $leftScore = ((int)!empty($left['indexed_like']) * 100) + ((int)!empty($left['keyword_ready']) * 10) + (int)($left['keyword_count'] ?? 0); + $rightScore = ((int)!empty($right['indexed_like']) * 100) + ((int)!empty($right['keyword_ready']) * 10) + (int)($right['keyword_count'] ?? 0); + if ($leftScore !== $rightScore) { + return $rightScore <=> $leftScore; + } + return strcmp((string)($left['host'] ?? ''), (string)($right['host'] ?? '')); + }); + + $statusNote = $indexedLikeCount > 0 + ? '当前已经有域名开始出现 indexed_like 信号,可以结合蜘蛛深抓继续看哪些站点开始转化成真实收录。' + : '当前抓取侧已有信号,但收录侧还没有明显抬头,先继续观察百度深抓和入口页稳定性。'; + + return [ + 'host_count' => count($items), + 'indexed_like_count' => $indexedLikeCount, + 'keyword_ready_count' => $keywordReadyCount, + 'traffic_ready_count' => $trafficReadyCount, + 'rows' => array_slice($items, 0, 20), + 'status_note' => $statusNote, + ]; + } + + protected static function buildHostDetail(array $latestSummary, string $targetHost, int $anomalyLimit): array + { + $targetHost = trim($targetHost); + if ($targetHost === '') { + return []; + } + + $summaryRows = []; + $pageTypeBuckets = []; + $statusBuckets = []; + $botBuckets = []; + $entryRequests = 0; + $deepRequests = 0; + $recentAnomalies = []; + + foreach ((array)($latestSummary['summary_records'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $host = trim((string)($row['host'] ?? '')); + if ($host !== $targetHost) { + continue; + } + $count = max(0, (int)($row['count'] ?? 0)); + $pageType = trim((string)($row['page_type'] ?? 'other')); + $status = (int)($row['status'] ?? 0); + $botName = trim((string)($row['bot_name'] ?? 'other_bot')); + $summaryRows[] = [ + 'path' => trim((string)($row['path'] ?? '')), + 'page_type' => $pageType, + 'status' => $status, + 'bot_name' => $botName, + 'count' => $count, + ]; + $pageTypeBuckets[$pageType] = (int)($pageTypeBuckets[$pageType] ?? 0) + $count; + $statusBuckets[(string)$status] = (int)($statusBuckets[(string)$status] ?? 0) + $count; + $botBuckets[$botName] = (int)($botBuckets[$botName] ?? 0) + $count; + if ($pageType === 'robots' || $pageType === 'sitemap' || $pageType === 'home' || $pageType === 'category' || $pageType === 'search') { + $entryRequests += $count; + } + if ($pageType === 'detail' || $pageType === 'play') { + $deepRequests += $count; + } + } + + foreach ((array)($latestSummary['anomaly_records'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $host = trim((string)($row['host'] ?? '')); + if ($host !== $targetHost) { + continue; + } + $item = [ + 'time' => trim((string)($row['time'] ?? '')), + 'host' => $host, + 'url' => trim((string)($row['url'] ?? '')), + 'page_type' => trim((string)($row['page_type'] ?? 'other')), + 'status' => (int)($row['status'] ?? 0), + 'bot_name' => trim((string)($row['bot_name'] ?? 'other_bot')), + 'cache_status' => trim((string)($row['cache_status'] ?? 'NONE')), + 'request_time' => (float)($row['request_time'] ?? 0), + 'upstream_time' => (float)($row['upstream_time'] ?? 0), + 'source_server' => trim((string)($row['source_server'] ?? '')), + 'referer' => trim((string)($row['referer'] ?? '')), + 'user_agent' => trim((string)($row['user_agent'] ?? '')), + ]; + $recentAnomalies[] = array_merge($item, self::buildAnomalyPriority($item)); + } + + if (empty($summaryRows) && empty($recentAnomalies)) { + return []; + } + + arsort($pageTypeBuckets); + arsort($statusBuckets); + arsort($botBuckets); + usort($recentAnomalies, static function (array $left, array $right): int { + $priorityCompare = ((int)($right['priority_score'] ?? 0)) <=> ((int)($left['priority_score'] ?? 0)); + if ($priorityCompare !== 0) { + return $priorityCompare; + } + return strcmp((string)($right['time'] ?? ''), (string)($left['time'] ?? '')); + }); + + $totalRequests = array_sum($pageTypeBuckets); + $anomalyCount = count($recentAnomalies); + $topBotName = empty($botBuckets) ? '' : (string)array_key_first($botBuckets); + + return [ + 'host' => $targetHost, + 'has_data' => 1, + 'total_requests' => $totalRequests, + 'entry_requests' => $entryRequests, + 'deep_requests' => $deepRequests, + 'anomaly_count' => $anomalyCount, + 'deep_ratio' => self::formatPercent($deepRequests, max(1, $totalRequests)), + 'top_bot_name' => $topBotName, + 'page_type_buckets' => array_map(static function (string $pageType, int $count): array { + return [ + 'page_type' => $pageType, + 'count' => $count, + ]; + }, array_keys($pageTypeBuckets), array_values($pageTypeBuckets)), + 'status_buckets' => array_map(static function (string $status, int $count): array { + return [ + 'status' => $status, + 'count' => $count, + ]; + }, array_keys($statusBuckets), array_values($statusBuckets)), + 'bot_buckets' => array_map(static function (string $botName, int $count): array { + return [ + 'bot_name' => $botName, + 'count' => $count, + ]; + }, array_keys($botBuckets), array_values($botBuckets)), + 'recent_anomalies' => array_slice($recentAnomalies, 0, $anomalyLimit), + 'status_note' => $deepRequests > 0 + ? ($anomalyCount > 0 ? '当前域名已经进入深页抓取,但异常仍需继续压低。' : '当前域名已经有稳定深页抓取信号,可以继续观察百度是否开始持续回访。') + : ($entryRequests > 0 ? '当前域名还停留在入口抓取阶段,先继续盯 robots、首页、分类入口。' : '当前域名抓取信号仍偏弱,先确认前端日志、入口页和 robots 是否已稳定暴露。'), + ]; + } + + protected static function buildOverview(array $latestSummary): array + { + $pageTypeMap = self::toCountMap((array)($latestSummary['page_type_buckets'] ?? []), 'page_type'); + $summaryStatusMap = self::toCountMap((array)($latestSummary['summary_status_buckets'] ?? []), 'status'); + $anomalyStatusMap = self::toCountMap((array)($latestSummary['anomaly_status_buckets'] ?? []), 'status'); + $priorityHealth = [ + 'high' => 0, + 'medium' => 0, + 'low' => 0, + ]; + foreach ((array)($latestSummary['anomaly_records'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $priority = self::buildAnomalyPriority($row); + $label = (string)($priority['priority_label'] ?? '低'); + if ($label === '高') { + $priorityHealth['high']++; + } elseif ($label === '中') { + $priorityHealth['medium']++; + } else { + $priorityHealth['low']++; + } + } + + return [ + 'generated_at' => trim((string)($latestSummary['generated_at'] ?? '')), + 'source' => trim((string)($latestSummary['source'] ?? '')), + 'summary_record_count' => (int)($latestSummary['summary_record_count'] ?? 0), + 'anomaly_record_count' => (int)($latestSummary['anomaly_record_count'] ?? 0), + 'summary_total_requests' => (int)($latestSummary['summary_total_requests'] ?? 0), + 'hosts_count' => (int)($latestSummary['hosts_count'] ?? 0), + 'bots_count' => (int)($latestSummary['bots_count'] ?? 0), + 'home_requests' => (int)($pageTypeMap['home'] ?? 0), + 'category_requests' => (int)($pageTypeMap['category'] ?? 0), + 'search_requests' => (int)($pageTypeMap['search'] ?? 0), + 'detail_requests' => (int)($pageTypeMap['detail'] ?? 0), + 'play_requests' => (int)($pageTypeMap['play'] ?? 0), + 'robots_requests' => (int)($pageTypeMap['robots'] ?? 0), + 'sitemap_requests' => (int)($pageTypeMap['sitemap'] ?? 0), + 'top_bots' => array_slice((array)($latestSummary['bot_buckets'] ?? []), 0, 8), + 'top_hosts' => array_slice((array)($latestSummary['top_hosts'] ?? []), 0, 10), + 'summary_status_buckets' => array_slice((array)($latestSummary['summary_status_buckets'] ?? []), 0, 10), + 'anomaly_status_buckets' => array_slice((array)($latestSummary['anomaly_status_buckets'] ?? []), 0, 10), + 'status_health' => [ + 'status_200' => (int)($summaryStatusMap['200'] ?? 0), + 'status_301' => (int)($summaryStatusMap['301'] ?? 0), + 'status_302' => (int)($summaryStatusMap['302'] ?? 0), + 'status_403' => (int)($anomalyStatusMap['403'] ?? 0), + 'status_404' => (int)($anomalyStatusMap['404'] ?? 0), + 'status_444' => (int)($anomalyStatusMap['444'] ?? 0), + 'status_500' => (int)($anomalyStatusMap['500'] ?? 0), + ], + 'priority_health' => $priorityHealth, + 'status_note' => ((int)($anomalyStatusMap['444'] ?? 0) > 0 || (int)($anomalyStatusMap['404'] ?? 0) > 0) + ? '当前存在蜘蛛抓取异常状态,优先看 444/404 是否集中在 robots、分类页、详情页或播放页。' + : '当前摘要里还没有明显异常状态堆积,可以继续观察深页覆盖是否抬头。', + ]; + } + + protected static function buildRecommendations(array $latestSummary, array $overview, array $deepCoverage): array + { + $items = []; + $pageTypeMap = self::toCountMap((array)($latestSummary['page_type_buckets'] ?? []), 'page_type'); + $anomalyStatusMap = self::toCountMap((array)($latestSummary['anomaly_status_buckets'] ?? []), 'status'); + $botMap = self::toCountMap((array)($latestSummary['bot_buckets'] ?? []), 'bot_name'); + + $robotsRequests = (int)($pageTypeMap['robots'] ?? 0); + $sitemapRequests = (int)($pageTypeMap['sitemap'] ?? 0); + $detailRequests = (int)($pageTypeMap['detail'] ?? 0); + $playRequests = (int)($pageTypeMap['play'] ?? 0); + $deepRequests = (int)($deepCoverage['deep_requests'] ?? 0); + $status444 = (int)($anomalyStatusMap['444'] ?? 0); + $status403 = (int)($anomalyStatusMap['403'] ?? 0); + $status404 = (int)($anomalyStatusMap['404'] ?? 0); + $baiduRequests = (int)($botMap['baiduspider'] ?? 0); + + if ($status444 > 0 || $status403 > 0 || $status404 > 0) { + $items[] = [ + 'key' => 'entry_status_fix', + 'title' => '先压入口异常状态', + 'priority' => 'high', + 'priority_label' => '高优先', + 'tag_type' => 'danger', + 'summary' => '当前存在 403/404/444,优先检查 robots、sitemap、分类入口、详情/播放页是否被缓存层或防护规则误伤。', + 'action' => '先按蜘蛛筛选只看百度或当前目标蜘蛛,再钻取异常域名,优先处理入口页和深页返回异常。', + ]; + } + + if ($baiduRequests > 0 && $deepRequests === 0) { + $items[] = [ + 'key' => 'baidu_deep_crawl', + 'title' => '继续引导百度往深页抓', + 'priority' => 'high', + 'priority_label' => '高优先', + 'tag_type' => 'warning', + 'summary' => '当前百度已进站,但 detail/play 深抓信号还不明显,下一步重点看 robots、sitemap、分类入口是否持续命中。', + 'action' => '保持百度受管蜘蛛放行,同时继续观察分类页、详情页、播放页是否开始出现 200 命中。', + ]; + } + + if (($robotsRequests + $sitemapRequests) === 0) { + $items[] = [ + 'key' => 'entry_exposure', + 'title' => '补强 robots 与 sitemap 曝光', + 'priority' => 'medium', + 'priority_label' => '中优先', + 'tag_type' => 'warning', + 'summary' => '当前入口文件命中偏弱,蜘蛛可能还没有稳定建立入口发现链路。', + 'action' => '确认 robots.txt 与 sitemap*.xml 对受管蜘蛛稳定返回 200,并持续观察入口命中是否抬头。', + ]; + } + + if (($detailRequests + $playRequests) > 0 && $deepRequests > 0) { + $items[] = [ + 'key' => 'deep_crawl_keep', + 'title' => '继续放大已出现的深抓信号', + 'priority' => 'medium', + 'priority_label' => '中优先', + 'tag_type' => 'success', + 'summary' => '当前已经能看到 detail/play 抓取信号,下一步重点是压低深页异常,保持持续回访。', + 'action' => '优先处理深页 4xx/5xx/444,再观察健康分高的域名是否继续扩大深抓覆盖。', + ]; + } + + if (empty($items)) { + $items[] = [ + 'key' => 'observe_window', + 'title' => '当前先继续观察窗口变化', + 'priority' => 'low', + 'priority_label' => '低优先', + 'tag_type' => 'info', + 'summary' => '这轮日志没有出现明显的高优先异常,先继续看入口命中和深页覆盖是否逐步抬头。', + 'action' => '建议继续保持定时推送,重点观察最近 6 小时和 24 小时窗口的变化。', + ]; + } + + return $items; + } + + protected static function summarizeWindow(array $summary, string $host = ''): array + { + if (empty($summary)) { + return [ + 'summary_total_requests' => 0, + 'anomaly_record_count' => 0, + 'deep_requests' => 0, + 'detail_requests' => 0, + 'play_requests' => 0, + 'hosts_count' => 0, + 'bots_count' => 0, + ]; + } + + if (trim($host) !== '') { + $hostDetail = self::buildHostDetail($summary, trim($host), 20); + return [ + 'summary_total_requests' => (int)($hostDetail['total_requests'] ?? 0), + 'anomaly_record_count' => (int)($hostDetail['anomaly_count'] ?? 0), + 'deep_requests' => (int)($hostDetail['deep_requests'] ?? 0), + 'detail_requests' => (int)(self::toCountMap((array)($hostDetail['page_type_buckets'] ?? []), 'page_type')['detail'] ?? 0), + 'play_requests' => (int)(self::toCountMap((array)($hostDetail['page_type_buckets'] ?? []), 'page_type')['play'] ?? 0), + 'hosts_count' => $hostDetail ? 1 : 0, + 'bots_count' => count((array)($hostDetail['bot_buckets'] ?? [])), + ]; + } + + $pageTypeMap = self::toCountMap((array)($summary['page_type_buckets'] ?? []), 'page_type'); + return [ + 'summary_total_requests' => (int)($summary['summary_total_requests'] ?? 0), + 'anomaly_record_count' => (int)($summary['anomaly_record_count'] ?? 0), + 'deep_requests' => (int)($pageTypeMap['detail'] ?? 0) + (int)($pageTypeMap['play'] ?? 0), + 'detail_requests' => (int)($pageTypeMap['detail'] ?? 0), + 'play_requests' => (int)($pageTypeMap['play'] ?? 0), + 'hosts_count' => (int)($summary['hosts_count'] ?? 0), + 'bots_count' => (int)($summary['bots_count'] ?? 0), + ]; + } + + protected static function buildWindowComparison(string $publicRoot, string $runsRoot, string $botScope, array $selectedBots, string $host = ''): array + { + $windows = [1, 6, 24, 72]; + $rows = []; + foreach ($windows as $hours) { + $runEntries = self::collectRunEntries($publicRoot, $runsRoot, $hours); + $summary = self::aggregateRunSummaries($runEntries); + $summary = self::filterSummaryByBotScope($summary, $botScope); + $summary = self::filterSummaryBySelectedBots($summary, $selectedBots); + $metrics = self::summarizeWindow($summary, $host); + $rows[] = [ + 'window_hours' => $hours, + 'label' => '最近 ' . $hours . ' 小时', + 'summary_total_requests' => (int)($metrics['summary_total_requests'] ?? 0), + 'anomaly_record_count' => (int)($metrics['anomaly_record_count'] ?? 0), + 'deep_requests' => (int)($metrics['deep_requests'] ?? 0), + 'detail_requests' => (int)($metrics['detail_requests'] ?? 0), + 'play_requests' => (int)($metrics['play_requests'] ?? 0), + 'hosts_count' => (int)($metrics['hosts_count'] ?? 0), + 'bots_count' => (int)($metrics['bots_count'] ?? 0), + ]; + } + + $latest6 = $rows[1] ?? []; + $latest24 = $rows[2] ?? []; + $trendNote = '当前窗口对比数据还不够丰富,先继续观察。'; + if (((int)($latest24['summary_total_requests'] ?? 0)) > 0) { + $deepDelta = (int)($latest6['deep_requests'] ?? 0) - (int)($latest24['deep_requests'] ?? 0); + $anomalyDelta = (int)($latest6['anomaly_record_count'] ?? 0) - (int)($latest24['anomaly_record_count'] ?? 0); + if ($deepDelta > 0 && $anomalyDelta <= 0) { + $trendNote = '最近 6 小时深页抓取比 24 小时窗口更活跃,且异常没有同步抬升,方向是好的。'; + } elseif ($anomalyDelta > 0) { + $trendNote = '最近 6 小时异常抬升更明显,建议优先处理当前高优先异常后再观察下一轮。'; + } elseif ((int)($latest6['summary_total_requests'] ?? 0) === 0) { + $trendNote = '最近 6 小时抓取偏弱,建议继续观察定时推送是否稳定、入口页是否持续被命中。'; + } + } + + return [ + 'rows' => $rows, + 'status_note' => $trendNote, + ]; + } + + protected static function buildHostRecommendations(array $hostDetail): array + { + if (empty($hostDetail)) { + return []; + } + + $items = []; + $entryRequests = (int)($hostDetail['entry_requests'] ?? 0); + $deepRequests = (int)($hostDetail['deep_requests'] ?? 0); + $anomalyCount = (int)($hostDetail['anomaly_count'] ?? 0); + + if ($deepRequests > 0 && $anomalyCount > 0) { + $items[] = [ + 'key' => 'host_deep_fix', + 'title' => '这台域名已深抓,先压深页异常', + 'priority' => 'high', + 'priority_label' => '高优先', + 'tag_type' => 'danger', + 'summary' => '当前域名已经进入 detail/play 抓取,但异常仍偏多,最适合作为样板站优先修复。', + 'action' => '先看最近异常里的 URL 和状态码,优先处理 play/detail 命中的 403/404/444/5xx。', + ]; + } elseif ($entryRequests > 0 && $deepRequests === 0) { + $items[] = [ + 'key' => 'host_wait_deep', + 'title' => '这台域名入口已命中,继续推深抓', + 'priority' => 'medium', + 'priority_label' => '中优先', + 'tag_type' => 'warning', + 'summary' => '当前域名已经被蜘蛛发现,但还停留在入口层,下一步重点是让分类、详情、播放页开始被打到。', + 'action' => '继续看 robots、首页、分类页是否稳定 200,并观察 detail/play 是否开始进入页面类型分布。', + ]; + } else { + $items[] = [ + 'key' => 'host_signal_weak', + 'title' => '这台域名抓取信号还偏弱', + 'priority' => 'low', + 'priority_label' => '低优先', + 'tag_type' => 'info', + 'summary' => '当前域名还没有形成明显的入口或深页抓取信号,先继续观察是否有首页、分类、robots 命中。', + 'action' => '先保持日志推送和入口放行,再看最近 24 小时窗口是否开始出现首页/分类抓取。', + ]; + } + + return $items; + } + + protected static function buildHostFocusGroups(array $hostHealthRows): array + { + $groups = [ + 'needs_fix' => [ + 'key' => 'needs_fix', + 'label' => '已深抓,需看异常', + 'tag_type' => 'danger', + 'summary' => '这些域名已经进入深页抓取,最适合作为样板站优先压异常。', + 'items' => [], + ], + 'ready_for_deep' => [ + 'key' => 'ready_for_deep', + 'label' => '入口已命中,待深抓', + 'tag_type' => 'warning', + 'summary' => '这些域名已经被蜘蛛发现,下一步重点推进分类、详情、播放页深抓。', + 'items' => [], + ], + 'weak_signal' => [ + 'key' => 'weak_signal', + 'label' => '抓取信号偏弱', + 'tag_type' => 'info', + 'summary' => '这些域名当前抓取信号还弱,先继续观察入口命中和首页/分类曝光。', + 'items' => [], + ], + ]; + + foreach ($hostHealthRows as $row) { + if (!is_array($row)) { + continue; + } + $item = [ + 'host' => trim((string)($row['host'] ?? '')), + 'top_bot_name' => trim((string)($row['top_bot_name'] ?? '')), + 'total_requests' => (int)($row['total_requests'] ?? 0), + 'deep_requests' => (int)($row['deep_requests'] ?? 0), + 'anomaly_count' => (int)($row['anomaly_count'] ?? 0), + 'health_score' => (int)($row['health_score'] ?? 0), + 'status_label' => trim((string)($row['status_label'] ?? '')), + ]; + $statusLabel = $item['status_label']; + if ($statusLabel === '已深抓,需看异常') { + $groups['needs_fix']['items'][] = $item; + } elseif ($statusLabel === '入口已命中,待深抓') { + $groups['ready_for_deep']['items'][] = $item; + } elseif ($statusLabel === '抓取信号偏弱') { + $groups['weak_signal']['items'][] = $item; + } + } + + foreach ($groups as &$group) { + usort($group['items'], static function (array $left, array $right): int { + $leftAnomaly = (int)($left['anomaly_count'] ?? 0); + $rightAnomaly = (int)($right['anomaly_count'] ?? 0); + if ($leftAnomaly !== $rightAnomaly) { + return $rightAnomaly <=> $leftAnomaly; + } + return ((int)($right['total_requests'] ?? 0)) <=> ((int)($left['total_requests'] ?? 0)); + }); + $group['count'] = count($group['items']); + $group['items'] = array_slice($group['items'], 0, 8); + } + unset($group); + + return array_values($groups); + } + + protected static function buildBaiduDeepCrawlChecklist(array $hostHealthRows): array + { + $groups = [ + 'boost_deep' => [ + 'key' => 'boost_deep', + 'label' => '已深抓可放大', + 'tag_type' => 'success', + 'summary' => '这些域名已经出现百度深抓,优先压异常并放大 detail/play 回访。', + 'action' => '先处理深页异常,再继续观察 detail/play 的 200 命中是否持续抬头。', + 'items' => [], + ], + 'wait_deep' => [ + 'key' => 'wait_deep', + 'label' => '入口已命中待深抓', + 'tag_type' => 'warning', + 'summary' => '这些域名百度已经发现入口,下一步重点推动分类、详情、播放页进入抓取分布。', + 'action' => '继续观察 robots、首页、分类入口稳定 200,并看 detail/play 是否开始出现。', + 'items' => [], + ], + 'signal_up' => [ + 'key' => 'signal_up', + 'label' => '收录信号已抬头优先扶一把', + 'tag_type' => 'primary', + 'summary' => '这些域名已经出现 indexed_like 或关键词就绪信号,优先维持抓取稳定并放大深抓。', + 'action' => '维持入口和深页稳定命中,优先压低异常,观察收录信号是否继续增强。', + 'items' => [], + ], + ]; + + foreach ($hostHealthRows as $row) { + if (!is_array($row)) { + continue; + } + $item = [ + 'host' => trim((string)($row['host'] ?? '')), + 'top_bot_name' => trim((string)($row['top_bot_name'] ?? '')), + 'total_requests' => (int)($row['total_requests'] ?? 0), + 'deep_requests' => (int)($row['deep_requests'] ?? 0), + 'detail_requests' => (int)($row['detail_requests'] ?? 0), + 'play_requests' => (int)($row['play_requests'] ?? 0), + 'robots_requests' => (int)($row['robots_requests'] ?? 0), + 'sitemap_requests' => (int)($row['sitemap_requests'] ?? 0), + 'anomaly_count' => (int)($row['anomaly_count'] ?? 0), + 'health_score' => (int)($row['health_score'] ?? 0), + 'indexed_like' => !empty($row['indexed_like']), + 'keyword_ready' => !empty($row['keyword_ready']), + 'status_label' => trim((string)($row['status_label'] ?? '')), + ]; + + if ($item['deep_requests'] > 0) { + $groups['boost_deep']['items'][] = $item; + } + if ($item['deep_requests'] === 0 && ($item['robots_requests'] > 0 || $item['sitemap_requests'] > 0)) { + $groups['wait_deep']['items'][] = $item; + } + if ($item['indexed_like'] || $item['keyword_ready']) { + $groups['signal_up']['items'][] = $item; + } + } + + foreach ($groups as &$group) { + usort($group['items'], static function (array $left, array $right): int { + $leftScore = ((int)!empty($left['indexed_like']) * 1000) + + ((int)!empty($left['keyword_ready']) * 500) + + ((int)($left['deep_requests'] ?? 0) * 20) + + ((int)($left['health_score'] ?? 0)); + $rightScore = ((int)!empty($right['indexed_like']) * 1000) + + ((int)!empty($right['keyword_ready']) * 500) + + ((int)($right['deep_requests'] ?? 0) * 20) + + ((int)($right['health_score'] ?? 0)); + if ($leftScore !== $rightScore) { + return $rightScore <=> $leftScore; + } + return strcmp((string)($left['host'] ?? ''), (string)($right['host'] ?? '')); + }); + $group['count'] = count($group['items']); + $group['items'] = array_slice($group['items'], 0, 8); + } + unset($group); + + return array_values(array_filter($groups, static function (array $group): bool { + return !empty($group['items']); + })); + } + + public static function buildSummary(string $publicRoot, string $latestRoot, string $runsRoot, int $runLimit = 10, int $anomalyLimit = 50, string $host = '', int $windowHours = 24, string $botScope = 'domestic', $selectedBots = []): array + { + $runLimit = max(1, min(50, $runLimit)); + $anomalyLimit = max(1, min(500, $anomalyLimit)); + $windowHours = max(0, min(720, $windowHours)); + $botScope = self::normalizeBotScope($botScope); + $selectedBots = self::normalizeSelectedBots($selectedBots); + $latestSummaryPath = rtrim($latestRoot, '/') . '/spider-crawl.summary.json'; + $latestSummary = self::readJsonFile($latestSummaryPath); + $runEntries = self::collectRunEntries($publicRoot, $runsRoot, $windowHours); + $recentRuns = self::buildRecentRuns($runEntries, $runLimit); + + if ($windowHours > 0 && !empty($runEntries)) { + $latestSummary = self::aggregateRunSummaries($runEntries); + } + + if (empty($latestSummary) && !empty($recentRuns)) { + $latestRunJsonPath = trim((string)($recentRuns[0]['summary_json_path'] ?? '')); + if ($latestRunJsonPath !== '') { + $latestSummary = self::readJsonFile(rtrim($publicRoot, '/') . '/' . ltrim($latestRunJsonPath, '/')); + } + } + + $latestSummary = self::filterSummaryByBotScope($latestSummary, $botScope); + $latestSummary = self::filterSummaryBySelectedBots($latestSummary, $selectedBots); + + $hasData = !empty($latestSummary); + $latestMeta = []; + $overview = []; + $deepCoverage = []; + $recentAnomalies = []; + + if ($hasData) { + $latestMeta = [ + 'generated_at' => trim((string)($latestSummary['generated_at'] ?? '')), + 'source' => trim((string)($latestSummary['source'] ?? '')), + 'summary_json_path' => self::publicRelativePath($publicRoot, trim((string)($latestSummary['summary_json_path'] ?? $latestSummaryPath))), + 'summary_html_path' => self::publicRelativePath($publicRoot, trim((string)($latestSummary['summary_html_path'] ?? (rtrim($latestRoot, '/') . '/spider-crawl.summary.html')))), + ]; + $overview = self::buildOverview($latestSummary); + $deepCoverage = self::buildDeepCoverage($latestSummary); + $recentAnomalies = self::buildRecentAnomalies($latestSummary, $anomalyLimit); + } + + $hostDetail = ($hasData && trim($host) !== '') ? self::buildHostDetail($latestSummary, $host, $anomalyLimit) : []; + + $hostHealthRows = $hasData ? self::buildHostHealth($latestSummary, $publicRoot) : []; + + return [ + 'generated_at' => date(DATE_ATOM), + 'has_data' => $hasData ? 1 : 0, + 'latest_root' => $latestRoot, + 'runs_root' => $runsRoot, + 'selected_host' => trim($host), + 'window_hours' => $windowHours, + 'bot_scope' => $botScope, + 'selected_bots' => $selectedBots, + 'latest' => $latestMeta, + 'overview' => $overview, + 'recommendations' => $hasData ? self::buildRecommendations($latestSummary, $overview, $deepCoverage) : [], + 'window_comparison' => $hasData ? self::buildWindowComparison($publicRoot, $runsRoot, $botScope, $selectedBots, trim($host)) : [], + 'deep_page_coverage' => $deepCoverage, + 'host_health_rows' => $hostHealthRows, + 'index_signal_summary' => $hasData ? self::buildIndexSignalSummary($publicRoot, $hostHealthRows) : [], + 'host_focus_groups' => $hasData ? self::buildHostFocusGroups($hostHealthRows) : [], + 'baidu_deep_crawl_checklist' => $hasData ? self::buildBaiduDeepCrawlChecklist($hostHealthRows) : [], + 'host_detail' => $hostDetail, + 'host_recommendations' => !empty($hostDetail) ? self::buildHostRecommendations($hostDetail) : [], + 'recent_anomalies' => $recentAnomalies, + 'recent_runs' => $recentRuns, + 'status_note' => $hasData + ? '抓取日志已接入,可以直接从蜘蛛概览、最近异常和深页覆盖判断当前 SEO 抓取健康。' + : '当前还没有收到前端站群蜘蛛日志,请先部署 spiderlog-agent 并推送 crawl-logs/save。', + ]; + } +} diff --git a/code/app/common/helper/DomainSpiderMdRunHelper.php b/code/app/common/helper/DomainSpiderMdRunHelper.php new file mode 100644 index 0000000..91e60fa --- /dev/null +++ b/code/app/common/helper/DomainSpiderMdRunHelper.php @@ -0,0 +1,1119 @@ + date(DATE_ATOM), + 'run_root' => self::baseRoot(), + 'gitee' => $gitee, + 'runtime_config' => self::resolveRuntimeConfig(), + 'config_summary' => SpiderMdConfigHelper::buildConfigSummary(), + 'latest_run' => $runs[0] ?? null, + 'runs' => $runs, + ]; + } + + public static function run(array $options = [], ?callable $logger = null): array + { + $config = self::resolveRuntimeConfig($options); + $giteeConfig = self::resolveGiteeConfig(); + self::ensureDir(self::baseRoot()); + $forceRegenerate = !empty($options['force_regenerate']); + $previous = self::findLatestSuccessfulRunSummary(); + $today = date('Ymd'); + $giteeTargetChanged = !empty($previous) ? self::giteeTargetChanged($previous, $giteeConfig) : false; + $previousGiteePushStatus = trim((string)($previous['gitee_push_status'] ?? '')); + $allowRetryToday = in_array($previousGiteePushStatus, ['failed', 'partial_failed'], true); + + if ( + !$forceRegenerate + && !empty($previous) + && substr((string)($previous['generated_at'] ?? ''), 0, 10) === date('Y-m-d') + && !$giteeTargetChanged + && !$allowRetryToday + ) { + self::log($logger, '今日已生成过蜘蛛池 MD,跳过本轮;如需全新生成请使用强制重生成'); + $previous['status'] = 'skipped'; + $previous['skipped_reason'] = 'already_generated_today'; + $previous['runtime_config'] = $config; + return $previous; + } + + if ($forceRegenerate || empty($previous) || $giteeTargetChanged) { + if ($forceRegenerate) { + self::clearAllRuns(); + } elseif ($giteeTargetChanged) { + self::log($logger, '检测到 Gitee 推送目标已变更,本轮按全新生成处理'); + } + $dateDir = self::baseRoot() . '/' . $today; + self::ensureDir($dateDir); + $runId = date('His') . '_spider_md_' . substr(md5(uniqid('', true)), 0, 6); + $runRoot = $dateDir . '/' . $runId; + self::ensureDir($runRoot); + self::log($logger, $forceRegenerate ? '开始强制重生成蜘蛛池 MD' : '开始首次生成蜘蛛池 MD'); + } else { + $runId = (string)($previous['run_id'] ?? ''); + $runRoot = (string)($previous['run_root'] ?? ''); + if ($runId === '' || $runRoot === '') { + throw new \RuntimeException('历史蜘蛛池MD运行目录无效,无法续写'); + } + self::ensureDir($runRoot); + if ($allowRetryToday) { + self::log($logger, '检测到上一轮 Gitee 推送失败,本轮继续沿用当前目录并重试推送'); + } else { + self::log($logger, '开始增量续写蜘蛛池 MD'); + } + } + + $domains = self::resolveDomains(); + self::log($logger, '已载入 ' . count($domains) . ' 个域名'); + self::log($logger, '已准备按流式方式生成详情与泛链接'); + + $existingState = self::loadExistingRunState($runRoot, (int)$config['max_links_per_file']); + $aggregateLinks = []; + $writtenFiles = (array)($existingState['files'] ?? []); + $chunkState = [ + 'records' => (array)($existingState['pending_records'] ?? []), + 'max_links_per_file' => (int)$config['max_links_per_file'], + 'run_id' => $runId, + 'generated_at' => date('Y-m-d H:i:s'), + 'run_root' => $runRoot, + 'files' => &$writtenFiles, + 'known_urls' => (array)($existingState['known_urls'] ?? []), + 'reused_last_file' => (string)($existingState['reused_last_file'] ?? ''), + ]; + $aggregateCount = (int)($existingState['aggregate_count'] ?? 0); + $detailCount = (int)($existingState['detail_count'] ?? 0); + $forgeCount = (int)($existingState['forge_count'] ?? 0); + $newAggregateCount = 0; + $newDetailCount = 0; + $newForgeCount = 0; + + foreach ($domains as $index => $domainRow) { + $host = DomainModel::normalizeHost((string)($domainRow['host'] ?? '')); + if ($host === '') { + continue; + } + + self::log($logger, sprintf('[%d/%d] 生成域名 %s', $index + 1, count($domains), $host)); + + $domainAggregate = self::buildAggregateLinksForDomain($domainRow, $config); + $aggregateLinks = array_merge($aggregateLinks, $domainAggregate); + } + + $aggregateLinks = self::deduplicateByUrl($aggregateLinks); + $newAggregateCount = self::appendChunkRecords($chunkState, $aggregateLinks); + $aggregateCount += $newAggregateCount; + + $domainProgress = []; + foreach ($domains as $domainRow) { + $host = DomainModel::normalizeHost((string)($domainRow['host'] ?? '')); + if ($host === '') { + continue; + } + /** @var DomainModel $domain */ + $domain = $domainRow['domain']; + $forgePolicy = $domain->getForgeSeoCfg(); + $domainProgress[$host] = [ + 'row' => $domainRow, + 'detail_remaining' => (int)$config['detail_limit_per_host'] > 0 ? (int)$config['detail_limit_per_host'] : null, + 'forge_remaining' => (int)$config['forge_limit_per_host'] > 0 ? (int)$config['forge_limit_per_host'] : null, + 'forge_per_video' => max(0, (int)($forgePolicy['sitemap_count'] ?? 0)), + ]; + } + + foreach (self::iterateVideoRows() as $videoRow) { + foreach ($domainProgress as $host => &$progress) { + $detailRemaining = $progress['detail_remaining']; + $forgeRemaining = $progress['forge_remaining']; + $forgePerVideo = (int)($progress['forge_per_video'] ?? 0); + + if ($detailRemaining === 0 && ($forgePerVideo <= 0 || $forgeRemaining === 0)) { + continue; + } + + $bundle = self::buildDetailLinksForDomain($progress['row'], [$videoRow], [ + 'detail_limit_per_host' => $detailRemaining === null ? 1 : min(1, (int)$detailRemaining), + 'forge_limit_per_host' => $forgeRemaining === null ? 0 : (int)$forgeRemaining, + ]); + + if (!empty($bundle['detail'])) { + $appended = self::appendChunkRecords($chunkState, $bundle['detail']); + $detailCount += $appended; + $newDetailCount += $appended; + if ($detailRemaining !== null && $detailRemaining > 0) { + $progress['detail_remaining'] = max(0, $detailRemaining - $appended); + } + } + + if (!empty($bundle['forge'])) { + $appended = self::appendChunkRecords($chunkState, $bundle['forge']); + $forgeCount += $appended; + $newForgeCount += $appended; + if ($forgeRemaining !== null && $forgeRemaining > 0) { + $progress['forge_remaining'] = max(0, $forgeRemaining - $appended); + } + } + } + unset($progress); + } + + self::flushChunkRecords($chunkState); + self::cleanupExpiredRuns((int)($config['retention_days'] ?? 10), $runRoot); + + $summary = [ + 'run_id' => $runId, + 'generated_at' => date(DATE_ATOM), + 'status' => 'success', + 'run_root' => $runRoot, + 'domain_count' => count($domains), + 'aggregate_count' => $aggregateCount, + 'detail_count' => $detailCount, + 'forge_count' => $forgeCount, + 'total_links' => $aggregateCount + $detailCount + $forgeCount, + 'file_count' => count($writtenFiles), + 'max_links_per_file' => (int)$config['max_links_per_file'], + 'config' => $config, + 'mode' => $forceRegenerate || empty($previous) ? 'rebuild' : 'append', + 'new_links' => [ + 'aggregate_count' => $newAggregateCount, + 'detail_count' => $newDetailCount, + 'forge_count' => $newForgeCount, + 'total_links' => $newAggregateCount + $newDetailCount + $newForgeCount, + ], + 'files' => $writtenFiles, + 'gitee' => array_diff_key($giteeConfig, ['token' => true]), + 'gitee_files' => [], + 'gitee_pushed' => 0, + 'gitee_push_attempted' => 0, + 'gitee_push_status' => 'pending', + 'gitee_push_error' => '', + ]; + + file_put_contents( + $runRoot . '/summary.json', + json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + file_put_contents($runRoot . '/summary.html', self::renderSummaryHtml($summary)); + + $giteePush = self::pushRunToGitee($summary, $logger); + $summary['gitee_files'] = (array)($giteePush['items'] ?? []); + $summary['gitee_pushed'] = !empty($summary['gitee_files']) ? 1 : 0; + $summary['gitee_push_attempted'] = (int)($giteePush['attempted'] ?? 0); + $summary['gitee_push_status'] = (string)($giteePush['status'] ?? 'skipped'); + $summary['gitee_push_error'] = (string)($giteePush['error'] ?? ''); + + file_put_contents( + $runRoot . '/gitee-links.json', + json_encode([ + 'run_id' => $runId, + 'generated_at' => date(DATE_ATOM), + 'status' => $summary['gitee_push_status'], + 'error' => $summary['gitee_push_error'], + 'items' => $summary['gitee_files'], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + file_put_contents( + $runRoot . '/summary.json', + json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + file_put_contents($runRoot . '/summary.html', self::renderSummaryHtml($summary)); + + self::log($logger, '蜘蛛池 MD 生成完成,共 ' . count($writtenFiles) . ' 个文件'); + + return $summary; + } + + protected static function resolveRuntimeConfig(array $options = []): array + { + return SpiderMdConfigHelper::resolveRuntimeConfig($options); + } + + protected static function resolveDomains(): array + { + $items = []; + foreach ((array)DomainModel::getAllDomainOnCache() as $domainModel) { + if (!$domainModel instanceof DomainModel) { + continue; + } + + $host = DomainModel::normalizeHost((string)($domainModel->d_domain ?? '')); + if ($host === '') { + continue; + } + + $items[] = [ + 'host' => $host, + 'domain' => $domainModel, + 'template' => TemplatesModel::getTemplateOnCacheByTId((int)($domainModel->t_id ?? 0)), + 'tp_style' => SiteStyle::getConfig($domainModel, $host), + ]; + } + + usort($items, static function (array $left, array $right): int { + return strcmp((string)$left['host'], (string)$right['host']); + }); + + return $items; + } + + protected static function resolveVideoRows(int $limit): array + { + $VideoModel = VideoModel::getInstance(); + VideoModel::$arrOptions['projection'] = [ + '_id' => 0, + 'v_id' => 1, + 'v_name' => 1, + 'v_name_en' => 1, + 'v_seo_words' => 1, + ]; + + if ($limit <= 0) { + $cursor = $VideoModel->getCol()->find([], [ + 'projection' => [ + '_id' => 0, + 'v_id' => 1, + 'v_name' => 1, + 'v_name_en' => 1, + 'v_seo_words' => 1, + ], + 'sort' => ['v_level' => -1], + 'batchSize' => 500, + ]); + $items = iterator_to_array($cursor, false); + } else { + $result = $VideoModel->findPaginatedWithCache([], 1, $limit); + $items = (array)($result['data'] ?? $result['items'] ?? []); + } + return array_values(array_filter(array_map(static function ($item): array { + $row = is_array($item) ? $item : (array)$item; + return [ + 'v_id' => (int)($row['v_id'] ?? 0), + 'v_name' => trim((string)($row['v_name'] ?? '')), + 'v_name_en' => trim((string)($row['v_name_en'] ?? '')), + 'v_seo_words' => is_array($row['v_seo_words'] ?? null) ? (array)$row['v_seo_words'] : [], + ]; + }, $items), static function (array $item): bool { + return (int)($item['v_id'] ?? 0) > 0 && trim((string)($item['v_name_en'] ?? '')) !== ''; + })); + } + + protected static function buildAggregateLinksForDomain(array $domainRow, array $config): array + { + $host = (string)$domainRow['host']; + $domain = $domainRow['domain']; + $urlBuilder = new UrlBuilder((array)$domainRow['tp_style']); + + $items = []; + $items[] = self::makeRecord($host, 'aggregate', '首页/分类/搜索页', $domain->d_name ?: $host, self::absoluteUrl($host, $urlBuilder->home()), 'home'); + $items[] = self::makeRecord($host, 'aggregate', '首页/分类/搜索页', '分类首页', self::absoluteUrl($host, $urlBuilder->categoryHome()), 'category_home'); + + $categories = (array)VideoCategoryModel::getCustomCategory('ONE'); + $categoryLimit = (int)$config['category_link_limit_per_host']; + $categoryCount = 0; + + foreach ($categories as $parentCategory) { + if ($categoryLimit > 0 && $categoryCount >= $categoryLimit) { + break; + } + + $parentSlug = trim((string)($parentCategory['v_category_en'] ?? '')); + $parentName = trim((string)($parentCategory['v_category'] ?? $parentSlug)); + if ($parentSlug === '') { + continue; + } + + $items[] = self::makeRecord( + $host, + 'aggregate', + '首页/分类/搜索页', + $parentName . '分类入口', + self::absoluteUrl($host, $urlBuilder->categoryParent($parentSlug)), + 'category_parent' + ); + $categoryCount++; + } + + if ((int)$config['search_link_limit_per_host'] > 0) { + foreach (self::buildSearchKeywordsForDomain($domainRow, (int)$config['search_link_limit_per_host']) as $keyword) { + $items[] = self::makeRecord( + $host, + 'aggregate', + '首页/分类/搜索页', + $keyword . '搜索结果', + self::absoluteUrl($host, $urlBuilder->searchResult($keyword)), + 'search' + ); + } + } + + return self::deduplicateByUrl($items); + } + + protected static function buildSearchKeywordsForDomain(array $domainRow, int $limit): array + { + $host = (string)$domainRow['host']; + /** @var DomainModel $domain */ + $domain = $domainRow['domain']; + $keywords = []; + + $feedback = SeoResourcePoolHelper::readJsonFile( + SeoResourcePoolHelper::resourceRoot() . '/keyword_feedback/by_host/' . SeoResourcePoolHelper::hostFileKey($host) . '.json' + ); + foreach ((array)($feedback['keywords'] ?? []) as $item) { + $keyword = SeoResourcePoolHelper::normalizeText((string)($item['keyword'] ?? '')); + if ($keyword !== '') { + $keywords[] = $keyword; + } + } + + foreach (preg_split('/[\r\n,,|\\/]+/u', (string)($domain->d_keywords ?? '')) ?: [] as $keyword) { + $keyword = SeoResourcePoolHelper::normalizeText((string)$keyword); + if ($keyword !== '') { + $keywords[] = $keyword; + } + } + + $sitePositioning = SeoResourcePoolHelper::readJsonFile( + SeoResourcePoolHelper::resourceRoot() . '/site_positioning/by_host/' . SeoResourcePoolHelper::hostFileKey($host) . '.json' + ); + foreach ((array)($sitePositioning['priority_keywords'] ?? []) as $keyword) { + $keyword = SeoResourcePoolHelper::normalizeText((string)$keyword); + if ($keyword !== '') { + $keywords[] = $keyword; + } + } + + $globalHot = SeoResourcePoolHelper::readJsonFile( + SeoResourcePoolHelper::resourceRoot() . '/keyword_feedback/hot_keywords.latest.json' + ); + foreach ((array)($globalHot['keywords'] ?? []) as $item) { + $keyword = SeoResourcePoolHelper::normalizeText((string)($item['keyword'] ?? '')); + if ($keyword !== '') { + $keywords[] = $keyword; + } + } + + $keywords = array_values(array_unique(array_filter($keywords, static function (string $keyword): bool { + return mb_strlen($keyword) >= 2; + }))); + + return array_slice($keywords, 0, $limit); + } + + protected static function buildDetailLinksForDomain(array $domainRow, array $videoRows, array $config): array + { + $host = (string)$domainRow['host']; + /** @var DomainModel $domain */ + $domain = $domainRow['domain']; + $tpStyle = (array)$domainRow['tp_style']; + $urlBuilder = new UrlBuilder($tpStyle); + $forgePolicy = $domain->getForgeSeoCfg(); + + $detail = []; + $forge = []; + $detailLimit = (int)$config['detail_limit_per_host']; + $detailRows = $detailLimit > 0 ? array_slice($videoRows, 0, $detailLimit) : $videoRows; + $forgeLimit = (int)$config['forge_limit_per_host']; + $forgePerVideo = max(0, (int)($forgePolicy['sitemap_count'] ?? 0)); + + foreach ($detailRows as $row) { + $detail[] = self::makeRecord( + $host, + 'detail', + '详情页', + (string)$row['v_name'], + self::absoluteUrl($host, $urlBuilder->detail((string)$row['v_name_en'], (int)$row['v_id'])), + 'detail' + ); + + if ($forgePerVideo <= 0) { + continue; + } + + if ($forgeLimit > 0 && count($forge) >= $forgeLimit) { + continue; + } + + for ($forgeId = 1; $forgeId <= $forgePerVideo; $forgeId++) { + if ($forgeLimit > 0 && count($forge) >= $forgeLimit) { + break; + } + + $forgeKeyword = SeoWordsHelper::resolveForgeWord([ + 'v_name' => (string)$row['v_name'], + 'v_name_en' => (string)$row['v_name_en'], + 'v_seo_words' => (array)$row['v_seo_words'], + ], $forgeId); + $forgeKeyword = SeoResourcePoolHelper::normalizeText($forgeKeyword); + if ($forgeKeyword === '') { + continue; + } + + $forge[] = self::makeRecord( + $host, + 'forge', + '详情泛链接', + $forgeKeyword, + self::absoluteUrl($host, $urlBuilder->detailForge((string)$row['v_name_en'], (int)$row['v_id'], $forgeId)), + 'detail_forge' + ); + } + } + + return [ + 'detail' => self::deduplicateByUrl($detail), + 'forge' => self::deduplicateByUrl($forge), + ]; + } + + protected static function iterateVideoRows(): \Generator + { + $VideoModel = VideoModel::getInstance(); + $cursor = $VideoModel->getCol()->find([], [ + 'projection' => [ + '_id' => 0, + 'v_id' => 1, + 'v_name' => 1, + 'v_name_en' => 1, + 'v_seo_words' => 1, + ], + 'sort' => ['v_level' => -1], + 'batchSize' => 500, + ]); + + foreach ($cursor as $item) { + $row = is_array($item) ? $item : (array)$item; + $normalized = [ + 'v_id' => (int)($row['v_id'] ?? 0), + 'v_name' => trim((string)($row['v_name'] ?? '')), + 'v_name_en' => trim((string)($row['v_name_en'] ?? '')), + 'v_seo_words' => is_array($row['v_seo_words'] ?? null) ? (array)$row['v_seo_words'] : [], + ]; + + if ($normalized['v_id'] <= 0 || $normalized['v_name_en'] === '') { + continue; + } + + yield $normalized; + } + } + + protected static function appendChunkRecords(array &$state, array $records): int + { + $appended = 0; + foreach ($records as $record) { + $url = trim((string)($record['url'] ?? '')); + if ($url === '' || isset($state['known_urls'][$url])) { + continue; + } + + $state['records'][] = $record; + $state['known_urls'][$url] = true; + $appended++; + if (count($state['records']) >= (int)$state['max_links_per_file']) { + self::flushChunkRecords($state); + } + } + + return $appended; + } + + protected static function flushChunkRecords(array &$state): void + { + if (empty($state['records'])) { + return; + } + + $reusedFile = trim((string)($state['reused_last_file'] ?? '')); + $chunkIndex = count($state['files']) + 1; + $fileName = $reusedFile !== '' ? $reusedFile : sprintf('part-%03d.md', $chunkIndex); + $filePath = (string)$state['run_root'] . '/' . $fileName; + + file_put_contents($filePath, self::renderMarkdownChunk($state['records'], [ + 'run_id' => (string)$state['run_id'], + 'chunk_index' => self::extractChunkIndexFromFileName($fileName) ?: $chunkIndex, + 'chunk_total' => '?', + 'generated_at' => (string)$state['generated_at'], + ])); + + $state['files'][] = [ + 'file_name' => $fileName, + 'file_path' => $filePath, + 'link_count' => count($state['records']), + 'sections' => array_values(array_unique(array_map(static function (array $item): string { + return (string)($item['section_label'] ?? ''); + }, $state['records']))), + ]; + + $state['records'] = []; + $state['reused_last_file'] = ''; + } + + protected static function findLatestSuccessfulRunSummary(): array + { + $matches = glob(self::baseRoot() . '/*/*/summary.json'); + if (!is_array($matches) || empty($matches)) { + return []; + } + + rsort($matches); + foreach ($matches as $summaryPath) { + $payload = self::readJsonFile((string)$summaryPath); + if (!empty($payload) && (string)($payload['status'] ?? '') === 'success') { + return $payload; + } + } + + return []; + } + + protected static function loadExistingRunState(string $runRoot, int $maxLinksPerFile): array + { + $summary = self::readJsonFile($runRoot . '/summary.json'); + $files = array_values(array_filter((array)($summary['files'] ?? []), static function ($item): bool { + return is_array($item) && !empty($item['file_name']); + })); + + $knownUrls = []; + foreach ($files as $fileMeta) { + $filePath = (string)($fileMeta['file_path'] ?? ''); + if ($filePath === '' || !is_file($filePath)) { + continue; + } + foreach (self::parseMarkdownChunkRecords($filePath) as $record) { + $url = trim((string)($record['url'] ?? '')); + if ($url !== '') { + $knownUrls[$url] = true; + } + } + } + + $pendingRecords = []; + $reusedLastFile = ''; + if (!empty($files)) { + $lastFile = $files[count($files) - 1]; + $lastCount = (int)($lastFile['link_count'] ?? 0); + if ($lastCount > 0 && $lastCount < $maxLinksPerFile) { + $reusedLastFile = (string)($lastFile['file_name'] ?? ''); + $lastFilePath = (string)($lastFile['file_path'] ?? ''); + $pendingRecords = $lastFilePath !== '' ? self::parseMarkdownChunkRecords($lastFilePath) : []; + array_pop($files); + } + } + + return [ + 'files' => $files, + 'known_urls' => $knownUrls, + 'pending_records' => $pendingRecords, + 'reused_last_file' => $reusedLastFile, + 'aggregate_count' => (int)($summary['aggregate_count'] ?? 0), + 'detail_count' => (int)($summary['detail_count'] ?? 0), + 'forge_count' => (int)($summary['forge_count'] ?? 0), + ]; + } + + protected static function parseMarkdownChunkRecords(string $filePath): array + { + $lines = @file($filePath, FILE_IGNORE_NEW_LINES); + if (!is_array($lines) || empty($lines)) { + return []; + } + + $records = []; + $sectionLabel = ''; + $current = []; + foreach ($lines as $line) { + $trimmed = trim((string)$line); + if ($trimmed === '') { + continue; + } + if (str_starts_with($trimmed, '## ')) { + $sectionLabel = trim(substr($trimmed, 3)); + continue; + } + if (str_starts_with($trimmed, '- 标题: ')) { + if (!empty($current['url'])) { + $current['section_label'] = $sectionLabel; + $records[] = $current; + } + $current = [ + 'title' => trim(substr($trimmed, strlen('- 标题: '))), + ]; + continue; + } + if (str_starts_with($trimmed, '- 链接: ')) { + $current['url'] = trim(substr($trimmed, strlen('- 链接: '))); + continue; + } + if (str_starts_with($trimmed, '- host: `')) { + $current['host'] = rtrim(trim(substr($trimmed, strlen('- host: `'))), '`'); + } + } + + if (!empty($current['url'])) { + $current['section_label'] = $sectionLabel; + $records[] = $current; + } + + return array_values(array_filter(array_map(static function (array $item): array { + $sectionLabel = (string)($item['section_label'] ?? '链接'); + return [ + 'host' => (string)($item['host'] ?? ''), + 'section' => self::sectionFromLabel($sectionLabel), + 'section_label' => $sectionLabel, + 'title' => (string)($item['title'] ?? ''), + 'url' => (string)($item['url'] ?? ''), + 'kind' => self::kindFromLabel($sectionLabel), + ]; + }, $records), static function (array $item): bool { + return trim((string)($item['url'] ?? '')) !== ''; + })); + } + + protected static function sectionFromLabel(string $label): string + { + return match ($label) { + '详情页' => 'detail', + '详情泛链接' => 'forge', + default => 'aggregate', + }; + } + + protected static function kindFromLabel(string $label): string + { + return match ($label) { + '详情页' => 'detail', + '详情泛链接' => 'detail_forge', + default => 'aggregate', + }; + } + + protected static function extractChunkIndexFromFileName(string $fileName): int + { + if (preg_match('/part-(\d+)\.md$/', $fileName, $matches)) { + return (int)($matches[1] ?? 0); + } + + return 0; + } + + protected static function cleanupExpiredRuns(int $retentionDays, string $activeRunRoot): void + { + $retentionDays = max(1, $retentionDays); + $threshold = strtotime('-' . $retentionDays . ' days'); + $dateDirs = glob(self::baseRoot() . '/*'); + if (!is_array($dateDirs)) { + return; + } + + foreach ($dateDirs as $dateDir) { + if (!is_dir($dateDir)) { + continue; + } + + $dateName = basename($dateDir); + if (!preg_match('/^\d{8}$/', $dateName)) { + continue; + } + + $dateTime = \DateTime::createFromFormat('Ymd', $dateName); + if (!$dateTime instanceof \DateTime || $dateTime->getTimestamp() >= $threshold) { + continue; + } + + $runDirs = glob($dateDir . '/*'); + if (!is_array($runDirs)) { + continue; + } + + foreach ($runDirs as $runDir) { + if (realpath($runDir) === realpath($activeRunRoot)) { + continue; + } + self::deletePath($runDir); + } + + $remaining = glob($dateDir . '/*'); + if (empty($remaining)) { + @rmdir($dateDir); + } + } + } + + protected static function giteeTargetChanged(array $previous, array $currentConfig): bool + { + $previousGitee = (array)($previous['gitee'] ?? []); + foreach (['enabled', 'owner', 'repo', 'branch', 'root'] as $key) { + if ((string)($previousGitee[$key] ?? '') !== (string)($currentConfig[$key] ?? '')) { + return true; + } + } + + return false; + } + + protected static function clearAllRuns(): void + { + foreach (glob(self::baseRoot() . '/*') ?: [] as $dateDir) { + self::deletePath((string)$dateDir); + } + } + + protected static function deletePath(string $path): void + { + if (is_file($path) || is_link($path)) { + @unlink($path); + return; + } + + if (!is_dir($path)) { + return; + } + + foreach (scandir($path) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + self::deletePath($path . '/' . $item); + } + + @rmdir($path); + } + + protected static function deduplicateByUrl(array $items): array + { + $map = []; + foreach ($items as $item) { + $url = trim((string)($item['url'] ?? '')); + if ($url === '' || isset($map[$url])) { + continue; + } + $map[$url] = $item; + } + + return array_values($map); + } + + protected static function makeRecord(string $host, string $section, string $sectionLabel, string $title, string $url, string $kind): array + { + return [ + 'host' => $host, + 'section' => $section, + 'section_label' => $sectionLabel, + 'title' => SeoResourcePoolHelper::normalizeText($title), + 'url' => $url, + 'kind' => $kind, + ]; + } + + protected static function absoluteUrl(string $host, string $uri): string + { + return 'https://' . $host . '/' . ltrim($uri, '/'); + } + + protected static function chunkRecords(array $records, int $max): array + { + if (empty($records)) { + return []; + } + + return array_chunk($records, $max); + } + + protected static function renderMarkdownChunk(array $records, array $meta): string + { + $lines = []; + $lines[] = '# 影视资料与视频资源索引'; + $lines[] = ''; + $lines[] = '- 批次编号: ' . (string)($meta['run_id'] ?? '-'); + $lines[] = '- 生成时间: ' . (string)($meta['generated_at'] ?? '-'); + $lines[] = '- 文件分片: ' . (string)($meta['chunk_index'] ?? 1) . '/' . (string)($meta['chunk_total'] ?? 1); + $lines[] = '- 链接数: ' . count($records); + $lines[] = '- 说明: 本文件用于整理首页、分类、搜索、详情与相关资料入口,方便后续做资料分发与外部提交。'; + $lines[] = ''; + + $grouped = []; + foreach ($records as $item) { + $grouped[(string)($item['section_label'] ?? '链接')][] = $item; + } + + foreach ($grouped as $sectionLabel => $items) { + $lines[] = '## ' . $sectionLabel; + $lines[] = ''; + foreach ($items as $item) { + $title = str_replace(["\r", "\n"], ' ', (string)($item['title'] ?? '链接')); + $url = (string)($item['url'] ?? ''); + $host = (string)($item['host'] ?? ''); + $lines[] = '- 标题: ' . $title; + $lines[] = ' - 链接: ' . $url; + $lines[] = ' - host: `' . $host . '`'; + } + $lines[] = ''; + } + + return implode(PHP_EOL, $lines) . PHP_EOL; + } + + protected static function renderSummaryHtml(array $summary): string + { + $rows = ''; + foreach ((array)($summary['files'] ?? []) as $item) { + $rows .= '' + . '' . htmlspecialchars((string)($item['file_name'] ?? '-'), ENT_QUOTES, 'UTF-8') . '' + . '' . (int)($item['link_count'] ?? 0) . '' + . '' . htmlspecialchars(implode(' / ', (array)($item['sections'] ?? [])), ENT_QUOTES, 'UTF-8') . '' + . ''; + } + + return '蜘蛛池 MD 生成摘要' + . '' + . '' + . '

    蜘蛛池 MD 生成摘要

    ' + . '

    Run ID:' . htmlspecialchars((string)($summary['run_id'] ?? '-'), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    生成时间:' . htmlspecialchars((string)($summary['generated_at'] ?? '-'), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    域名数:' . (int)($summary['domain_count'] ?? 0) . ',总链接数:' . (int)($summary['total_links'] ?? 0) . ',文件数:' . (int)($summary['file_count'] ?? 0) . '

    ' + . '' . $rows . '
    文件链接数分组
    ' + . ''; + } + + protected static function pushRunToGitee(array $summary, ?callable $logger = null): array + { + $config = self::resolveGiteeConfig(); + if (empty($config['enabled']) || empty($config['configured'])) { + self::log($logger, 'Gitee 未启用或未配置,本轮仅保留本地文件'); + return [ + 'status' => 'skipped', + 'attempted' => 0, + 'error' => '', + 'items' => [], + ]; + } + + $items = []; + $dateDir = basename(dirname((string)($summary['run_root'] ?? ''))); + if (!preg_match('/^\d{8}$/', $dateDir)) { + $dateDir = date('Ymd', strtotime((string)($summary['generated_at'] ?? 'now'))); + } + $baseRemoteDir = trim((string)$config['root'], '/'); + $baseRemoteDir = trim($baseRemoteDir . '/' . $dateDir . '/' . (string)($summary['run_id'] ?? ''), '/'); + + $localFiles = array_merge( + array_map(static function (array $file): string { + return (string)($file['file_path'] ?? ''); + }, (array)($summary['files'] ?? [])), + [ + (string)($summary['run_root'] ?? '') . '/summary.json', + (string)($summary['run_root'] ?? '') . '/summary.html', + (string)($summary['run_root'] ?? '') . '/gitee-links.json', + ] + ); + + try { + foreach ($localFiles as $filePath) { + if (!is_file($filePath)) { + continue; + } + + $fileName = basename($filePath); + $remotePath = $baseRemoteDir . '/' . $fileName; + $response = self::upsertGiteeFile($config, $remotePath, (string)file_get_contents($filePath), 'spider md update ' . $fileName); + + $items[] = [ + 'file_name' => $fileName, + 'remote_path' => $remotePath, + 'html_url' => (string)($response['html_url'] ?? self::buildGiteeHtmlUrl($config, $remotePath)), + 'download_url' => (string)($response['download_url'] ?? self::buildGiteeRawUrl($config, $remotePath)), + 'local_path' => $filePath, + ]; + self::log($logger, '已推送 Gitee:' . $fileName); + } + } catch (\Throwable $throwable) { + $message = trim($throwable->getMessage()); + self::log($logger, 'Gitee 推送失败:' . $message); + return [ + 'status' => !empty($items) ? 'partial_failed' : 'failed', + 'attempted' => 1, + 'error' => $message, + 'items' => $items, + ]; + } + + return [ + 'status' => 'success', + 'attempted' => 1, + 'error' => '', + 'items' => $items, + ]; + } + + protected static function upsertGiteeFile(array $config, string $remotePath, string $content, string $message): array + { + $existing = self::giteeRequest( + 'GET', + sprintf( + 'https://gitee.com/api/v5/repos/%s/%s/contents/%s?access_token=%s&ref=%s', + rawurlencode((string)$config['owner']), + rawurlencode((string)$config['repo']), + str_replace('%2F', '/', rawurlencode($remotePath)), + rawurlencode((string)$config['token']), + rawurlencode((string)$config['branch']) + ), + [], + true + ); + + $payload = [ + 'access_token' => (string)$config['token'], + 'content' => base64_encode($content), + 'message' => $message, + 'branch' => (string)$config['branch'], + ]; + + $method = 'POST'; + $url = sprintf( + 'https://gitee.com/api/v5/repos/%s/%s/contents/%s', + rawurlencode((string)$config['owner']), + rawurlencode((string)$config['repo']), + str_replace('%2F', '/', rawurlencode($remotePath)) + ); + + if (!empty($existing['sha'])) { + $method = 'PUT'; + $payload['sha'] = (string)$existing['sha']; + } + + return self::giteeRequest($method, $url, $payload); + } + + protected static function giteeRequest(string $method, string $url, array $payload = [], bool $allowNotFound = false): array + { + if (!function_exists('curl_init')) { + throw new \RuntimeException('当前 PHP 环境缺少 curl 扩展,无法推送 Gitee'); + } + + $curl = curl_init(); + if ($curl === false) { + throw new \RuntimeException('初始化 Gitee 请求失败'); + } + + $headers = ['Accept: application/json']; + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 45, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + ]); + + if (!empty($payload)) { + curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($payload)); + } + + $response = curl_exec($curl); + $errno = curl_errno($curl); + $error = curl_error($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if ($errno !== 0) { + throw new \RuntimeException('Gitee 请求失败:' . $error); + } + + $decoded = json_decode((string)$response, true); + if ($allowNotFound && $status === 404) { + return []; + } + + if ($status >= 400) { + $message = is_array($decoded) ? (string)($decoded['message'] ?? json_encode($decoded, JSON_UNESCAPED_UNICODE)) : (string)$response; + throw new \RuntimeException('Gitee 请求返回异常:' . $message); + } + + return is_array($decoded) ? $decoded : []; + } + + protected static function buildGiteeHtmlUrl(array $config, string $remotePath): string + { + return sprintf( + 'https://gitee.com/%s/%s/blob/%s/%s', + rawurlencode((string)$config['owner']), + rawurlencode((string)$config['repo']), + rawurlencode((string)$config['branch']), + str_replace('%2F', '/', rawurlencode($remotePath)) + ); + } + + protected static function buildGiteeRawUrl(array $config, string $remotePath): string + { + return sprintf( + 'https://gitee.com/%s/%s/raw/%s/%s', + rawurlencode((string)$config['owner']), + rawurlencode((string)$config['repo']), + rawurlencode((string)$config['branch']), + str_replace('%2F', '/', rawurlencode($remotePath)) + ); + } + + protected static function resolveGiteeConfig(): array + { + return SpiderMdConfigHelper::resolveGiteeConfig(); + } + + protected 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 : []; + } + + protected static function ensureDir(string $dir): void + { + if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { + throw new \RuntimeException('创建目录失败:' . $dir); + } + } + + protected static function log(?callable $logger, string $message): void + { + if ($logger !== null) { + $logger($message); + } + } +} diff --git a/code/app/common/helper/DomainSupplyBatchHelper.php b/code/app/common/helper/DomainSupplyBatchHelper.php new file mode 100644 index 0000000..af4e571 --- /dev/null +++ b/code/app/common/helper/DomainSupplyBatchHelper.php @@ -0,0 +1,505 @@ + $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[] = '' + . '' . htmlspecialchars((string)($item['host'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['strategy_profile'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['status'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['message'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['site_domain'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['bundle_host'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . ''; + } + + return implode(PHP_EOL, [ + '', + '', + '', + '', + 'Domain Supply Batch Summary', + '', + '', + '', + '

    Domain Supply Batch Summary

    ', + '

    template: ' . htmlspecialchars((string)($summary['template_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '

    ', + '

    output_root: ' . htmlspecialchars((string)($summary['output_root'] ?? ''), ENT_QUOTES, 'UTF-8') . '

    ', + '

    processed: ' . (int)($summary['processed_count'] ?? 0) . ' / success: ' . (int)($summary['success_count'] ?? 0) . ' / failed: ' . (int)($summary['failed_count'] ?? 0) . '

    ', + '

    site_import_rows: ' . (int)($summary['site_import_rows_count'] ?? 0) . ' / bootstrap_rows: ' . (int)($summary['bootstrap_rows_count'] ?? 0) . '

    ', + '', + ...$rows, + '
    HostStrategyStatusMessageSite ImportBootstrap
    ', + '', + '', + ]) . 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; + } +} diff --git a/code/app/common/helper/DomainSupplyBatchTemplateHelper.php b/code/app/common/helper/DomainSupplyBatchTemplateHelper.php new file mode 100644 index 0000000..323b7be --- /dev/null +++ b/code/app/common/helper/DomainSupplyBatchTemplateHelper.php @@ -0,0 +1,182 @@ + '必填,域名或 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://。', + '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(), + ]; + } +} diff --git a/code/app/common/helper/DomainSupplyRunIndexHelper.php b/code/app/common/helper/DomainSupplyRunIndexHelper.php new file mode 100644 index 0000000..a32db5b --- /dev/null +++ b/code/app/common/helper/DomainSupplyRunIndexHelper.php @@ -0,0 +1,2817 @@ + false, + 'runs_count' => 0, + 'latest_run' => [], + 'items' => [], + 'host_totals' => [], + 'latest_failed_host' => [], + 'mainline_program' => [], + 'mainline_entry' => [], + 'mainline_workbench' => [], + 'mainline_control_tower' => [], + 'orchestration_summary' => self::emptyOrchestrationSummary(), + ]; + } + + $items = []; + $hostTotals = []; + $summaryPaths = array_merge( + (array)glob($runRoot . '/*/supply.summary.json'), + (array)glob($runRoot . '/*/*/supply.summary.json') + ); + $summaryPaths = array_values(array_unique($summaryPaths)); + + foreach ($summaryPaths as $summaryPath) { + $summary = json_decode((string)file_get_contents($summaryPath), true); + if (!is_array($summary)) { + continue; + } + + $runDir = dirname($summaryPath); + $parentDir = basename(dirname($runDir)); + $runBase = basename($runDir); + $runId = ctype_digit($parentDir) ? ($parentDir . '/' . $runBase) : $runBase; + $items[] = [ + 'run_id' => $runId, + 'run_root' => $runDir, + 'run_root_public' => self::publicRelativePath($runDir), + 'summary_json_path' => self::publicRelativePath($summaryPath), + 'summary_html_path' => self::publicRelativePath($runDir . '/supply.summary.html'), + 'uploaded_template_path' => self::publicRelativePath(self::firstMatched($runDir . '/domain-supply-batch-upload.*')), + 'processed_count' => (int)($summary['processed_count'] ?? 0), + 'success_count' => (int)($summary['success_count'] ?? 0), + 'failed_count' => (int)($summary['failed_count'] ?? 0), + 'site_import_rows_count' => (int)($summary['site_import_rows_count'] ?? 0), + 'bootstrap_rows_count' => (int)($summary['bootstrap_rows_count'] ?? 0), + 'run_bootstrap' => (bool)($summary['run_bootstrap'] ?? false), + 'bootstrap_run' => (string)($summary['bootstrap_run'] ?? 'off'), + 'site_import_path' => self::publicRelativePath((string)($summary['site_import_path'] ?? '')), + 'bootstrap_batch_path' => self::publicRelativePath((string)($summary['bootstrap_batch_path'] ?? '')), + 'status' => ((int)($summary['failed_count'] ?? 0) > 0) ? 'partial' : 'success', + 'hosts' => array_values(array_filter(array_map(static fn (array $item): string => (string)($item['host'] ?? ''), (array)($summary['items'] ?? [])))), + 'items_preview' => array_slice((array)($summary['items'] ?? []), 0, 10), + 'bootstrap_batch' => $summary['bootstrap_batch'] ?? [], + 'updated_at' => date('c', filemtime($summaryPath) ?: time()), + ]; + + foreach ((array)($summary['items'] ?? []) as $runItem) { + if (!is_array($runItem)) { + continue; + } + $host = (string)($runItem['bundle_host'] ?? $runItem['site_domain'] ?? $runItem['host'] ?? ''); + if ($host === '') { + continue; + } + if (!isset($hostTotals[$host])) { + $hostTotals[$host] = [ + 'host' => $host, + 'runs' => 0, + 'processed_count' => 0, + 'success_count' => 0, + 'failed_count' => 0, + 'latest_run_id' => '', + 'latest_status' => '', + 'latest_message' => '', + 'latest_strategy_profile' => '', + 'strategy_profiles' => [], + 'latest_run_root_public' => '', + 'latest_summary_json_path' => '', + 'latest_summary_html_path' => '', + 'ops_panel' => 'queue', + 'host_console_panel' => 'bundle', + 'inspector_kind' => 'bundle', + 'updated_at' => '', + ]; + } + + $itemStatus = strtolower((string)($runItem['status'] ?? '')); + $isSuccess = in_array($itemStatus, ['ready', 'success'], true); + $hostTotals[$host]['runs'] += 1; + $hostTotals[$host]['processed_count'] += 1; + if ($isSuccess) { + $hostTotals[$host]['success_count'] += 1; + } else { + $hostTotals[$host]['failed_count'] += 1; + } + $strategyProfile = (string)($runItem['strategy_profile'] ?? ''); + if ($strategyProfile !== '' && !in_array($strategyProfile, $hostTotals[$host]['strategy_profiles'], true)) { + $hostTotals[$host]['strategy_profiles'][] = $strategyProfile; + } + + $currentRunId = (string)$runId; + if ($hostTotals[$host]['latest_run_id'] === '' || strcmp($currentRunId, (string)$hostTotals[$host]['latest_run_id']) >= 0) { + $hostTotals[$host]['latest_run_id'] = $currentRunId; + $hostTotals[$host]['latest_status'] = $itemStatus ?: (($isSuccess) ? 'success' : 'failed'); + $hostTotals[$host]['latest_message'] = (string)($runItem['message'] ?? ''); + $hostTotals[$host]['latest_strategy_profile'] = $strategyProfile; + $hostTotals[$host]['latest_run_root_public'] = self::publicRelativePath($runDir); + $hostTotals[$host]['latest_summary_json_path'] = self::publicRelativePath($summaryPath); + $hostTotals[$host]['latest_summary_html_path'] = self::publicRelativePath($runDir . '/supply.summary.html'); + $hostTotals[$host]['updated_at'] = date('c', filemtime($summaryPath) ?: time()); + if ($isSuccess) { + $hostTotals[$host]['ops_panel'] = 'queue'; + $hostTotals[$host]['host_console_panel'] = 'bundle'; + $hostTotals[$host]['inspector_kind'] = 'bundle'; + } else { + $hostTotals[$host]['ops_panel'] = 'attention'; + $hostTotals[$host]['host_console_panel'] = 'runs'; + $hostTotals[$host]['inspector_kind'] = 'attention_bundle'; + } + } + } + } + + usort($items, static function (array $left, array $right): int { + return strcmp((string)($right['run_id'] ?? ''), (string)($left['run_id'] ?? '')); + }); + + $hostTotals = array_values($hostTotals); + foreach ($hostTotals as &$hostSummary) { + $resolvedFocus = self::resolveOpsFocusForHost((string)($hostSummary['host'] ?? ''), $opsSummary, $hostSummary); + $hostSummary['resolved_ops_panel'] = $resolvedFocus['panel']; + $hostSummary['resolved_host_console_panel'] = $resolvedFocus['host_console_panel']; + $hostSummary['resolved_inspector_kind'] = $resolvedFocus['inspector_kind']; + $hostSummary['orchestration_bucket'] = $resolvedFocus['bucket']; + } + unset($hostSummary); + usort($hostTotals, static function (array $left, array $right): int { + $leftFailed = (int)($left['failed_count'] ?? 0); + $rightFailed = (int)($right['failed_count'] ?? 0); + if ($leftFailed !== $rightFailed) { + return $rightFailed <=> $leftFailed; + } + return strcmp((string)($right['latest_run_id'] ?? ''), (string)($left['latest_run_id'] ?? '')); + }); + + $latestFailedHost = []; + foreach ($hostTotals as $hostSummary) { + if ((int)($hostSummary['failed_count'] ?? 0) > 0) { + $latestFailedHost = $hostSummary; + break; + } + } + + $orchestrationSummary = self::buildOrchestrationSummary( + $hostTotals, + $items[0] ?? [], + $opsSummary['latest_domain_supply_task'] ?? [], + $opsSummary['latest_domain_supply_orchestration_task'] ?? [] + ); + + $mainlineControlTower = self::buildMainlineControlTower( + $items[0] ?? [], + $latestFailedHost, + $opsSummary['latest_domain_supply_task'] ?? [], + $opsSummary['latest_domain_supply_orchestration_task'] ?? [], + $orchestrationSummary + ); + + $mainlineWorkbench = self::buildMainlineWorkbench( + $items[0] ?? [], + $latestFailedHost, + $opsSummary['latest_domain_supply_task'] ?? [], + $opsSummary['latest_domain_supply_orchestration_task'] ?? [], + $mainlineControlTower, + $orchestrationSummary + ); + + return [ + 'exists' => true, + 'runs_count' => count($items), + 'latest_run' => $items[0] ?? [], + 'items' => array_slice($items, 0, max(1, $limit)), + 'host_totals' => $hostTotals, + 'latest_failed_host' => $latestFailedHost, + 'mainline_program' => self::buildMainlineProgram( + $items[0] ?? [], + $latestFailedHost, + $opsSummary['latest_domain_supply_task'] ?? [], + $opsSummary['latest_domain_supply_orchestration_task'] ?? [], + self::buildMainlineEntry( + $items[0] ?? [], + $latestFailedHost, + $opsSummary['latest_domain_supply_task'] ?? [], + $opsSummary['latest_domain_supply_orchestration_task'] ?? [], + $mainlineControlTower, + $mainlineWorkbench + ), + $mainlineWorkbench, + $mainlineControlTower, + $orchestrationSummary + ), + 'mainline_entry' => self::buildMainlineEntry( + $items[0] ?? [], + $latestFailedHost, + $opsSummary['latest_domain_supply_task'] ?? [], + $opsSummary['latest_domain_supply_orchestration_task'] ?? [], + $mainlineControlTower, + $mainlineWorkbench + ), + 'mainline_workbench' => $mainlineWorkbench, + 'mainline_control_tower' => $mainlineControlTower, + 'orchestration_summary' => $orchestrationSummary, + ]; + } + + protected static function buildMainlineProgram( + array $latestRun, + array $latestFailedHost, + array $latestTask, + array $latestOrchestrationTask, + array $mainlineEntry, + array $mainlineWorkbench, + array $mainlineControlTower, + array $orchestrationSummary + ): array { + $programKind = 'business_takeover'; + $label = (string)($mainlineWorkbench['label'] ?? $mainlineControlTower['label'] ?? '待判断'); + $title = '当前供给 / 繁殖主链'; + $summary = (string)($mainlineWorkbench['summary'] ?? $mainlineControlTower['summary'] ?? '当前还没有形成明确的供给 / 繁殖主链判断。'); + $tagType = (string)($mainlineWorkbench['tag_type'] ?? $mainlineControlTower['tag_type'] ?? 'warning'); + $focusLabel = (string)($mainlineWorkbench['focus_label'] ?? $mainlineEntry['focus_label'] ?? '业务接手'); + $focusSummary = (string)($mainlineWorkbench['focus_summary'] ?? $mainlineEntry['focus_summary'] ?? ''); + $sourceLabel = (string)($mainlineControlTower['source_label'] ?? $mainlineEntry['source_label'] ?? $focusLabel); + $phaseLabel = (string)($mainlineWorkbench['phase_label'] ?? $mainlineControlTower['phase_label'] ?? ''); + $mainlineLabel = (string)($mainlineWorkbench['mainline_label'] ?? $mainlineControlTower['mainline_label'] ?? ''); + $dispatchLabel = (string)($mainlineWorkbench['dispatch_label'] ?? $mainlineControlTower['dispatch_label'] ?? ''); + $reviewLabel = (string)($mainlineWorkbench['review_label'] ?? ''); + $executionLabel = (string)($mainlineWorkbench['execution_label'] ?? ''); + $currentActionLabel = (string)($mainlineWorkbench['current_action_label'] ?? $mainlineControlTower['current_action_label'] ?? ''); + $nextActionLabel = (string)($mainlineWorkbench['next_action_label'] ?? $mainlineControlTower['next_action_label'] ?? ''); + $leadHost = (string)($mainlineWorkbench['lead_host'] ?? $mainlineControlTower['lead_host'] ?? ''); + $queueCount = (int)($mainlineWorkbench['queue_count'] ?? $mainlineControlTower['queue_count'] ?? 0); + $blockersCount = (int)($mainlineWorkbench['blockers_count'] ?? $mainlineControlTower['blockers_count'] ?? 0); + $hostsCount = (int)($mainlineWorkbench['hosts_count'] ?? ($orchestrationSummary['hosts_count'] ?? 0)); + $failedHostsCount = (int)($mainlineWorkbench['failed_hosts_count'] ?? (!empty($latestFailedHost) ? 1 : 0)); + $reviewRequired = (bool)($mainlineWorkbench['review_required'] ?? $mainlineControlTower['review_required'] ?? false); + $canSkipReview = (bool)($mainlineWorkbench['can_skip_review'] ?? $mainlineControlTower['can_skip_review'] ?? false); + $primaryAction = (string)($mainlineWorkbench['primary_action'] ?? $mainlineControlTower['primary_action'] ?? ''); + $primaryActionLabel = (string)($mainlineWorkbench['primary_action_label'] ?? $mainlineControlTower['primary_action_label'] ?? '执行当前主链动作'); + $secondaryAction = (string)($mainlineWorkbench['secondary_action'] ?? $mainlineControlTower['secondary_action'] ?? ''); + $secondaryActionLabel = (string)($mainlineWorkbench['secondary_action_label'] ?? $mainlineControlTower['secondary_action_label'] ?? '执行下一主链动作'); + $tertiaryAction = (string)($mainlineWorkbench['tertiary_action'] ?? ''); + $tertiaryActionLabel = (string)($mainlineWorkbench['tertiary_action_label'] ?? ''); + + if ((string)($mainlineEntry['entry_kind'] ?? '') !== '' && (string)($mainlineEntry['entry_kind'] ?? '') !== 'business_takeover') { + $programKind = (string)$mainlineEntry['entry_kind']; + $label = (string)($mainlineEntry['label'] ?? $label); + $summary = (string)($mainlineEntry['summary'] ?? $summary); + $tagType = (string)($mainlineEntry['tag_type'] ?? $tagType); + $focusLabel = (string)($mainlineEntry['focus_label'] ?? $focusLabel); + $focusSummary = (string)($mainlineEntry['focus_summary'] ?? $focusSummary); + $sourceLabel = (string)($mainlineEntry['source_label'] ?? $sourceLabel); + $phaseLabel = (string)($mainlineEntry['phase_label'] ?? $phaseLabel); + $mainlineLabel = (string)($mainlineEntry['mainline_label'] ?? $mainlineLabel); + $dispatchLabel = (string)($mainlineEntry['dispatch_label'] ?? $dispatchLabel); + $currentActionLabel = (string)($mainlineEntry['current_action_label'] ?? $currentActionLabel); + $nextActionLabel = (string)($mainlineEntry['next_action_label'] ?? $nextActionLabel); + $leadHost = (string)($mainlineEntry['lead_host'] ?? $leadHost); + $queueCount = (int)($mainlineEntry['queue_count'] ?? $queueCount); + $blockersCount = (int)($mainlineEntry['blockers_count'] ?? $blockersCount); + $reviewRequired = (bool)($mainlineEntry['review_required'] ?? $reviewRequired); + $canSkipReview = (bool)($mainlineEntry['can_skip_review'] ?? $canSkipReview); + $primaryAction = (string)($mainlineEntry['primary_action'] ?? $primaryAction); + $primaryActionLabel = (string)($mainlineEntry['primary_action_label'] ?? $primaryActionLabel); + $secondaryAction = (string)($mainlineEntry['secondary_action'] ?? $secondaryAction); + $secondaryActionLabel = (string)($mainlineEntry['secondary_action_label'] ?? $secondaryActionLabel); + if ($focusSummary === '') { + $focusSummary = '当前先回看主链入口,再决定是否继续进入供给接手和 Ops 主线。'; + } + } + + $readyKind = 'takeover_ready'; + $readyLabel = '可直接接手'; + if ($programKind === 'orchestration_task' || $programKind === 'supply_task' || $programKind === 'supply_run') { + $readyKind = 'review_first'; + $readyLabel = '先回看入口'; + } elseif ($blockersCount > 0 || $reviewRequired) { + $readyKind = 'review_gate'; + $readyLabel = '需先过 Review'; + } elseif ($nextActionLabel !== '' && str_contains($nextActionLabel, 'Ops')) { + $readyKind = 'ops_ready'; + $readyLabel = '接手后推进 Ops'; + } + + return [ + 'program_kind' => $programKind, + 'label' => $label, + 'title' => $title, + 'summary' => $summary, + 'tag_type' => $tagType, + 'ready_kind' => $readyKind, + 'ready_label' => $readyLabel, + 'focus_label' => $focusLabel, + 'focus_summary' => $focusSummary, + 'source_label' => $sourceLabel, + 'phase_label' => $phaseLabel, + 'mainline_label' => $mainlineLabel, + 'dispatch_label' => $dispatchLabel, + 'review_label' => $reviewLabel, + 'execution_label' => $executionLabel, + 'current_action_label' => $currentActionLabel, + 'next_action_label' => $nextActionLabel, + 'latest_run_id' => (string)($latestRun['run_id'] ?? ''), + 'lead_host' => $leadHost, + 'failed_host' => (string)($latestFailedHost['host'] ?? ''), + 'queue_count' => $queueCount, + 'blockers_count' => $blockersCount, + 'hosts_count' => $hostsCount, + 'failed_hosts_count' => $failedHostsCount, + 'review_required' => $reviewRequired, + 'can_skip_review' => $canSkipReview, + 'orchestration_task_status' => (string)($latestOrchestrationTask['status'] ?? ''), + 'supply_task_status' => (string)($latestTask['status'] ?? ''), + 'run_status' => (string)($latestRun['status'] ?? ''), + 'primary_action' => $primaryAction, + 'primary_action_label' => $primaryActionLabel, + 'secondary_action' => $secondaryAction, + 'secondary_action_label' => $secondaryActionLabel, + 'tertiary_action' => $tertiaryAction, + 'tertiary_action_label' => $tertiaryActionLabel, + ]; + } + + protected static function emptyOrchestrationSummary(): array + { + return [ + 'buckets' => [], + 'plan' => [], + 'sequence' => [], + 'phase' => [], + 'next_action' => [], + 'action_order' => [], + 'handoff_plan' => [], + 'mainline_plan' => [], + 'mainline_chain' => [], + 'mainline_route' => [], + 'projected_mainline_route' => [], + 'next_mainline_stage' => [], + 'projected_next_mainline_stage' => [], + 'next_business_action' => [], + 'mainline_sequence_plan' => [], + 'business_sequence' => [], + 'business_takeover_queue' => [], + 'business_entry_rule' => [], + 'business_entry_gate_stack' => [], + 'business_entry_current_gate' => [], + 'business_entry_blockers' => [], + 'business_entry_strategy' => [], + 'business_action_queue' => [], + 'business_action_current' => [], + 'business_action_next' => [], + 'business_execution_policy' => [], + 'business_review_policy' => [], + 'business_control_tower' => [], + 'dispatch_plan' => [], + 'dispatch_stage' => [], + 'next_dispatch_stage' => [], + 'dispatch_chain' => [], + 'dispatch_order' => [], + 'hosts_count' => 0, + ]; + } + + protected static function buildOrchestrationSummary(array $hostTotals, array $latestRun, array $latestTask, array $latestOrchestrationTask = []): array + { + $bucketOrder = ['attention', 'oncall', 'bundle', 'supply', 'completed']; + $bucketLabels = [ + 'attention' => '先排异常', + 'oncall' => '进入 Oncall', + 'bundle' => '推进 Bundle 主线', + 'supply' => '先回看 Supply', + 'completed' => '已完成 Host', + ]; + $bucketTagTypes = [ + 'attention' => 'danger', + 'oncall' => 'warning', + 'bundle' => 'warning', + 'supply' => 'info', + 'completed' => 'success', + ]; + + $grouped = []; + foreach ($bucketOrder as $bucketKey) { + $grouped[$bucketKey] = []; + } + foreach ($hostTotals as $hostSummary) { + $bucketKey = (string)($hostSummary['orchestration_bucket'] ?? 'bundle'); + if (!isset($grouped[$bucketKey])) { + $grouped[$bucketKey] = []; + } + $grouped[$bucketKey][] = $hostSummary; + } + + $buckets = []; + foreach ($bucketOrder as $bucketKey) { + $hosts = $grouped[$bucketKey] ?? []; + $buckets[] = [ + 'key' => $bucketKey, + 'label' => $bucketLabels[$bucketKey], + 'count' => count($hosts), + 'lead_host' => $hosts[0]['host'] ?? '', + 'resolved_ops_panel' => $hosts[0]['resolved_ops_panel'] ?? '', + 'resolved_host_console_panel' => $hosts[0]['resolved_host_console_panel'] ?? '', + 'hosts' => array_values(array_filter(array_map(static fn (array $item): string => (string)($item['host'] ?? ''), $hosts))), + 'tag_type' => $bucketTagTypes[$bucketKey], + ]; + } + + $latestTaskStatus = strtolower((string)($latestTask['status'] ?? '')); + $latestOrchestrationStatus = strtolower((string)($latestOrchestrationTask['status'] ?? '')); + $latestRunStatus = strtolower((string)($latestRun['status'] ?? '')); + + $sequence = []; + + if (($latestTask['task_slug'] ?? '') === '' && ($latestOrchestrationTask['task_slug'] ?? '') === '' && ($latestRun['run_id'] ?? '') === '') { + $sequence[] = [ + 'key' => 'bootstrap_supply_missing', + 'label' => '待发起', + 'title' => '先发起一体化供给', + 'summary' => '当前还没有供给任务和供给运行记录,建议先打开供给工作台生成或上传模板,再进入批量编排。', + 'tag_type' => 'warning', + 'action' => 'open-supply-dialog', + 'action_label' => '打开供给工作台', + 'bucket_key' => 'all', + 'lead_host' => '', + 'resolved_ops_panel' => '', + 'resolved_host_console_panel' => '', + 'resolved_inspector_kind' => '', + 'meta' => ['Task missing', 'Run missing', '当前没有可接手的供给批次'], + 'priority' => 10, + ]; + } + + if (($latestOrchestrationTask['task_slug'] ?? '') !== '' && $latestOrchestrationStatus !== '' && $latestOrchestrationStatus !== 'success') { + $sequence[] = [ + 'key' => 'latest_supply_orchestration_task', + 'label' => '先回看编排', + 'title' => '最近供给编排任务未成功', + 'summary' => (string)($latestOrchestrationTask['summary'] ?? '最近供给编排任务存在异常或未完成,建议先确认编排结论。'), + 'tag_type' => 'danger', + 'action' => 'latest-supply-orchestration-task', + 'action_label' => '查看供给编排任务', + 'bucket_key' => 'all', + 'lead_host' => '', + 'resolved_ops_panel' => '', + 'resolved_host_console_panel' => '', + 'resolved_inspector_kind' => '', + 'meta' => [ + 'Orchestration ' . ($latestOrchestrationTask['task_slug'] ?? '-'), + 'Status ' . $latestOrchestrationStatus, + 'Run ' . ($latestRun['run_id'] ?? '-'), + ], + 'priority' => 20, + ]; + } + + if ($latestTaskStatus !== '' && $latestTaskStatus !== 'success') { + $sequence[] = [ + 'key' => 'latest_supply_task', + 'label' => '先回看任务', + 'title' => '最近供给调度任务未成功', + 'summary' => (string)($latestTask['summary'] ?? '最近供给调度任务存在异常或未完成,建议先确认任务健康,再决定是否继续看 run 或接 Host。'), + 'tag_type' => 'danger', + 'action' => 'latest-supply-task', + 'action_label' => '查看供给调度任务', + 'bucket_key' => 'all', + 'lead_host' => '', + 'resolved_ops_panel' => '', + 'resolved_host_console_panel' => '', + 'resolved_inspector_kind' => '', + 'meta' => [ + 'Task ' . ($latestTask['task_slug'] ?? '-'), + 'Status ' . $latestTaskStatus, + 'Run ' . ($latestRun['run_id'] ?? '-'), + ], + 'priority' => 30, + ]; + } + + if ($latestRunStatus !== '' && $latestRunStatus !== 'success') { + $sequence[] = [ + 'key' => 'latest_supply_run', + 'label' => '先回看运行', + 'title' => '最近供给运行存在失败或未收口项', + 'summary' => '最近供给运行 ' . ($latestRun['run_id'] ?? '-') . ' 当前状态 ' . $latestRunStatus . ',建议先确认本次生成结果,再进入具体 Host 主线。', + 'tag_type' => 'warning', + 'action' => 'latest-supply-run', + 'action_label' => '查看供给运行', + 'bucket_key' => 'all', + 'lead_host' => '', + 'resolved_ops_panel' => '', + 'resolved_host_console_panel' => '', + 'resolved_inspector_kind' => '', + 'meta' => [ + 'Run ' . ($latestRun['run_id'] ?? '-'), + '处理 ' . (int)($latestRun['processed_count'] ?? 0), + '失败 ' . (int)($latestRun['failed_count'] ?? 0), + ], + 'priority' => 40, + ]; + } + + foreach ($bucketOrder as $bucketKey) { + $hosts = $grouped[$bucketKey] ?? []; + if (count($hosts) < 1) { + continue; + } + $leadHost = (string)($hosts[0]['host'] ?? ''); + $leadOpsPanel = (string)($hosts[0]['resolved_ops_panel'] ?? ''); + $leadConsolePanel = (string)($hosts[0]['resolved_host_console_panel'] ?? ''); + $leadInspectorKind = (string)($hosts[0]['resolved_inspector_kind'] ?? ''); + if ($bucketKey === 'attention') { + $sequence[] = [ + 'key' => 'bucket_attention', + 'label' => '先排异常', + 'title' => '这批供给结果先从异常 Host 开始接手', + 'summary' => '当前异常桶有 ' . count($hosts) . ' 个 Host,建议先从 ' . $leadHost . ' 开始,优先处理 attention / failed run 主线。', + 'tag_type' => 'danger', + 'action' => 'bucket-attention', + 'action_label' => '接手异常桶首个 Host', + 'bucket_key' => 'attention', + 'lead_host' => $leadHost, + 'resolved_ops_panel' => $leadOpsPanel, + 'resolved_host_console_panel' => $leadConsolePanel, + 'resolved_inspector_kind' => $leadInspectorKind, + 'meta' => ['异常 Host ' . count($hosts), '代表 ' . $leadHost, '主线 ' . $leadOpsPanel . '/' . $leadConsolePanel], + 'priority' => 50, + ]; + continue; + } + if ($bucketKey === 'oncall') { + $sequence[] = [ + 'key' => 'bucket_oncall', + 'label' => '进入 Oncall', + 'title' => '这批供给结果先推进 Oncall 主线', + 'summary' => '当前已有 ' . count($hosts) . ' 个 Host 进入 Oncall,建议先从 ' . $leadHost . ' 开始值班推进。', + 'tag_type' => 'warning', + 'action' => 'bucket-oncall', + 'action_label' => '接手 Oncall 首个 Host', + 'bucket_key' => 'oncall', + 'lead_host' => $leadHost, + 'resolved_ops_panel' => $leadOpsPanel, + 'resolved_host_console_panel' => $leadConsolePanel, + 'resolved_inspector_kind' => $leadInspectorKind, + 'meta' => ['Oncall Host ' . count($hosts), '代表 ' . $leadHost, '主线 ' . $leadOpsPanel . '/' . $leadConsolePanel], + 'priority' => 60, + ]; + continue; + } + if ($bucketKey === 'bundle') { + $sequence[] = [ + 'key' => 'bucket_bundle', + 'label' => '推进 Bundle', + 'title' => '这批供给结果先推进 Bundle 主线', + 'summary' => '当前有 ' . count($hosts) . ' 个 Host 已进入 bundle 队列,建议先从 ' . $leadHost . ' 开始推进。', + 'tag_type' => 'warning', + 'action' => 'bucket-bundle', + 'action_label' => '接手 Bundle 首个 Host', + 'bucket_key' => 'bundle', + 'lead_host' => $leadHost, + 'resolved_ops_panel' => $leadOpsPanel, + 'resolved_host_console_panel' => $leadConsolePanel, + 'resolved_inspector_kind' => $leadInspectorKind, + 'meta' => ['Bundle Host ' . count($hosts), '代表 ' . $leadHost, '主线 ' . $leadOpsPanel . '/' . $leadConsolePanel], + 'priority' => 70, + ]; + continue; + } + if ($bucketKey === 'supply') { + $sequence[] = [ + 'key' => 'bucket_supply', + 'label' => '回看 Supply', + 'title' => '当前先回看 Supply-only Host', + 'summary' => '当前还有 ' . count($hosts) . ' 个 Host 仅停留在 supply 主线,建议先从 ' . $leadHost . ' 开始确认供给结果。', + 'tag_type' => 'info', + 'action' => 'bucket-supply', + 'action_label' => '接手 Supply 首个 Host', + 'bucket_key' => 'supply', + 'lead_host' => $leadHost, + 'resolved_ops_panel' => $leadOpsPanel, + 'resolved_host_console_panel' => $leadConsolePanel, + 'resolved_inspector_kind' => $leadInspectorKind, + 'meta' => ['Supply-only ' . count($hosts), '代表 ' . $leadHost, '主线 ' . $leadOpsPanel . '/' . $leadConsolePanel], + 'priority' => 80, + ]; + continue; + } + if ($bucketKey === 'completed') { + $sequence[] = [ + 'key' => 'bucket_completed', + 'label' => '已完成', + 'title' => '当前批次主要处于已完成态', + 'summary' => '当前已完成 Host ' . count($hosts) . ' 个,代表 Host ' . $leadHost . '。这批供给结果可以先回看已完成主线或继续发起下一批。', + 'tag_type' => 'success', + 'action' => 'bucket-completed', + 'action_label' => '查看已完成 Host', + 'bucket_key' => 'completed', + 'lead_host' => $leadHost, + 'resolved_ops_panel' => $leadOpsPanel, + 'resolved_host_console_panel' => $leadConsolePanel, + 'resolved_inspector_kind' => $leadInspectorKind, + 'meta' => ['Completed ' . count($hosts), '代表 ' . $leadHost, '主线 ' . $leadOpsPanel . '/' . $leadConsolePanel], + 'priority' => 90, + ]; + continue; + } + } + + usort($sequence, static function (array $left, array $right): int { + return ((int)($left['priority'] ?? 999)) <=> ((int)($right['priority'] ?? 999)); + }); + + $plan = [ + 'label' => '回看供给', + 'title' => '当前批次没有明确接手桶,先回看供给运行', + 'summary' => '当前没有命中的异常、oncall、bundle 或 completed 分桶,建议先回看最近一次供给运行或重新打开供给工作台。', + 'tag_type' => 'warning', + 'primary_action' => 'latest-supply-run', + 'primary_action_label' => '查看供给运行', + 'secondary_action' => 'open-supply-dialog', + 'secondary_action_label' => '打开供给工作台', + 'tertiary_action' => '', + 'tertiary_action_label' => '', + 'bucket_key' => 'all', + 'lead_host' => '', + 'meta' => [ + 'Task ' . ($latestTaskStatus !== '' ? $latestTaskStatus : '-'), + 'Run ' . ($latestRun['run_id'] ?? '-'), + 'Hosts ' . count($hostTotals), + ], + ]; + + if (!empty($sequence)) { + $firstStep = $sequence[0]; + $plan = [ + 'label' => (string)($firstStep['label'] ?? ''), + 'title' => (string)($firstStep['title'] ?? ''), + 'summary' => (string)($firstStep['summary'] ?? ''), + 'tag_type' => (string)($firstStep['tag_type'] ?? 'warning'), + 'primary_action' => (string)($firstStep['action'] ?? ''), + 'primary_action_label' => (string)($firstStep['action_label'] ?? ''), + 'secondary_action' => (string)(($sequence[1]['action'] ?? '') ?: ''), + 'secondary_action_label' => (string)(($sequence[1]['action_label'] ?? '') ?: ''), + 'tertiary_action' => (string)(($sequence[2]['action'] ?? '') ?: ''), + 'tertiary_action_label' => (string)(($sequence[2]['action_label'] ?? '') ?: ''), + 'bucket_key' => (string)($firstStep['bucket_key'] ?? 'all'), + 'lead_host' => (string)($firstStep['lead_host'] ?? ''), + 'meta' => (array)($firstStep['meta'] ?? []), + ]; + } + + $actionOrder = array_map(static function (array $step, int $index): array { + return [ + 'index' => $index + 1, + 'key' => (string)($step['key'] ?? ''), + 'label' => (string)($step['label'] ?? ''), + 'title' => (string)($step['title'] ?? ''), + 'action' => (string)($step['action'] ?? ''), + 'action_label' => (string)($step['action_label'] ?? ''), + 'bucket_key' => (string)($step['bucket_key'] ?? 'all'), + 'lead_host' => (string)($step['lead_host'] ?? ''), + 'tag_type' => (string)($step['tag_type'] ?? 'warning'), + 'resolved_ops_panel' => (string)($step['resolved_ops_panel'] ?? ''), + 'resolved_host_console_panel' => (string)($step['resolved_host_console_panel'] ?? ''), + 'resolved_inspector_kind' => (string)($step['resolved_inspector_kind'] ?? ''), + 'summary' => (string)($step['summary'] ?? ''), + ]; + }, $sequence, array_keys($sequence)); + + $phase = self::buildOrchestrationPhase($sequence, $latestRun, $latestTask, $latestOrchestrationTask); + $nextAction = self::buildNextAction($sequence); + $dispatchChain = self::buildDispatchChain($sequence, $phase, $nextAction); + $dispatchOrder = self::buildDispatchOrder($dispatchChain, $phase); + $dispatchStage = (array)($dispatchChain[0] ?? []); + $nextDispatchStage = (array)($dispatchOrder[1] ?? []); + $dispatchPlan = self::buildDispatchPlan($dispatchOrder, $dispatchStage, $nextDispatchStage, $plan); + $handoffPlan = self::buildHandoffPlan($dispatchStage, $nextDispatchStage, $dispatchPlan, $plan, $phase); + $mainlineChain = self::buildMainlineChain($sequence, $dispatchOrder, $handoffPlan, $phase); + $mainlineRoute = self::buildMainlineRoute($mainlineChain); + $projectedMainlineRoute = self::buildProjectedMainlineRoute($mainlineChain, $mainlineRoute, $buckets, $phase); + $nextMainlineStage = (array)($mainlineRoute[1] ?? []); + $projectedNextMainlineStage = (array)($projectedMainlineRoute[1] ?? []); + $mainlinePlan = self::buildMainlinePlan($mainlineChain, $mainlineRoute, $projectedMainlineRoute, $handoffPlan, $phase); + $nextBusinessAction = self::buildNextBusinessAction($projectedMainlineRoute, $mainlinePlan, $handoffPlan, $phase); + $mainlineSequencePlan = self::buildMainlineSequencePlan($projectedMainlineRoute, $mainlinePlan, $nextBusinessAction, $phase); + $businessSequence = self::buildBusinessSequence($projectedMainlineRoute); + $businessTakeoverQueue = self::buildBusinessTakeoverQueue($hostTotals, $projectedMainlineRoute); + $businessEntryRule = self::buildBusinessEntryRule($sequence, $phase, $businessTakeoverQueue); + $businessEntryGateStack = self::buildBusinessEntryGateStack( + $latestOrchestrationTask, + $latestTask, + $latestRun, + $businessEntryRule, + $businessTakeoverQueue, + $phase + ); + $businessEntryCurrentGate = (array)($businessEntryGateStack[0] ?? []); + $businessEntryBlockers = array_values(array_filter($businessEntryGateStack, static function (array $gate): bool { + return (bool)($gate['blocking'] ?? false); + })); + $businessEntryStrategy = self::buildBusinessEntryStrategy( + $businessEntryRule, + $businessEntryGateStack, + $businessEntryCurrentGate, + $businessEntryBlockers + ); + $businessActionQueue = self::buildBusinessActionQueue( + $businessEntryStrategy, + $businessEntryGateStack, + $businessTakeoverQueue, + $projectedMainlineRoute + ); + $businessActionCurrent = (array)($businessActionQueue[0] ?? []); + $businessActionNext = (array)($businessActionQueue[1] ?? []); + $businessExecutionPolicy = self::buildBusinessExecutionPolicy( + $businessEntryStrategy, + $businessActionCurrent, + $businessActionNext + ); + $businessReviewPolicy = self::buildBusinessReviewPolicy( + $businessEntryStrategy, + $businessEntryGateStack, + $businessEntryBlockers, + $businessExecutionPolicy + ); + $businessControlTower = self::buildBusinessControlTower( + $phase, + $mainlinePlan, + $handoffPlan, + $businessEntryRule, + $businessEntryStrategy, + $businessExecutionPolicy, + $businessReviewPolicy, + $businessActionQueue, + $businessActionCurrent, + $businessActionNext, + $businessEntryCurrentGate, + $businessEntryBlockers, + $businessTakeoverQueue + ); + + return [ + 'hosts_count' => count($hostTotals), + 'buckets' => $buckets, + 'plan' => $plan, + 'sequence' => array_map(static function (array $step): array { + unset($step['priority']); + return $step; + }, $sequence), + 'phase' => $phase, + 'next_action' => $nextAction, + 'action_order' => $actionOrder, + 'handoff_plan' => $handoffPlan, + 'mainline_plan' => $mainlinePlan, + 'mainline_chain' => $mainlineChain, + 'mainline_route' => $mainlineRoute, + 'projected_mainline_route' => $projectedMainlineRoute, + 'next_mainline_stage' => $nextMainlineStage, + 'projected_next_mainline_stage' => $projectedNextMainlineStage, + 'next_business_action' => $nextBusinessAction, + 'mainline_sequence_plan' => $mainlineSequencePlan, + 'business_sequence' => $businessSequence, + 'business_takeover_queue' => $businessTakeoverQueue, + 'business_entry_rule' => $businessEntryRule, + 'business_entry_gate_stack' => $businessEntryGateStack, + 'business_entry_current_gate' => $businessEntryCurrentGate, + 'business_entry_blockers' => $businessEntryBlockers, + 'business_entry_strategy' => $businessEntryStrategy, + 'business_action_queue' => $businessActionQueue, + 'business_action_current' => $businessActionCurrent, + 'business_action_next' => $businessActionNext, + 'business_execution_policy' => $businessExecutionPolicy, + 'business_review_policy' => $businessReviewPolicy, + 'business_control_tower' => $businessControlTower, + 'dispatch_plan' => $dispatchPlan, + 'dispatch_stage' => $dispatchStage, + 'next_dispatch_stage' => $nextDispatchStage, + 'dispatch_chain' => $dispatchChain, + 'dispatch_order' => $dispatchOrder, + ]; + } + + protected static function buildOrchestrationPhase(array $sequence, array $latestRun, array $latestTask, array $latestOrchestrationTask): array + { + $firstStep = (array)($sequence[0] ?? []); + $firstKey = (string)($firstStep['key'] ?? ''); + $firstLabel = (string)($firstStep['label'] ?? ''); + $phase = [ + 'key' => 'idle', + 'label' => '待编排', + 'summary' => '当前供给主线还没有形成明确的编排阶段。', + 'tag_type' => 'warning', + ]; + + if ($firstKey === 'bootstrap_supply_missing') { + return [ + 'key' => 'bootstrap', + 'label' => '待发起供给', + 'summary' => '当前还没有供给 task 和供给 run,建议先发起一体化供给。', + 'tag_type' => 'warning', + ]; + } + + if (in_array($firstKey, ['latest_supply_orchestration_task', 'latest_supply_task', 'latest_supply_run'], true)) { + return [ + 'key' => 'review', + 'label' => '先回看编排链', + 'summary' => (string)($firstStep['summary'] ?? '最近供给任务、编排任务或供给运行未收口,建议先回看记录再继续接 Host。'), + 'tag_type' => (string)($firstStep['tag_type'] ?? 'danger'), + ]; + } + + if ($firstKey === 'bucket_attention') { + return [ + 'key' => 'attention', + 'label' => '异常接手阶段', + 'summary' => '当前这批供给结果优先进入异常接手,先处理失败 host 或异常主线。', + 'tag_type' => 'danger', + ]; + } + + if ($firstKey === 'bucket_oncall') { + return [ + 'key' => 'oncall', + 'label' => 'Oncall 推进阶段', + 'summary' => '当前这批供给结果已经进入 Oncall 主线,建议先从值班待办开始推进。', + 'tag_type' => 'warning', + ]; + } + + if ($firstKey === 'bucket_bundle') { + return [ + 'key' => 'bundle', + 'label' => 'Bundle 推进阶段', + 'summary' => '当前这批供给结果主要停留在 bundle 队列,建议先推进 Host 主线。', + 'tag_type' => 'warning', + ]; + } + + if ($firstKey === 'bucket_supply') { + return [ + 'key' => 'supply', + 'label' => 'Supply 回看阶段', + 'summary' => '当前这批 host 仍主要停留在 supply 主线,建议先确认供给结果和生成产物。', + 'tag_type' => 'info', + ]; + } + + if ($firstKey === 'bucket_completed') { + return [ + 'key' => 'completed', + 'label' => '批次完成阶段', + 'summary' => '当前这批供给结果主要已经进入 completed 主线,可以回看完成态或继续下一批。', + 'tag_type' => 'success', + ]; + } + + if (($latestOrchestrationTask['task_slug'] ?? '') !== '' || ($latestTask['task_slug'] ?? '') !== '' || ($latestRun['run_id'] ?? '') !== '') { + $phase['summary'] = '当前供给主线已有运行记录,但还没有明确阶段,建议先回看最近 task 或 run。'; + } + + if ($firstLabel !== '') { + $phase['summary'] = (string)($firstStep['summary'] ?? $phase['summary']); + } + + return $phase; + } + + protected static function buildNextAction(array $sequence): array + { + $firstStep = (array)($sequence[0] ?? []); + if (empty($firstStep)) { + return []; + } + + return [ + 'key' => (string)($firstStep['key'] ?? ''), + 'label' => (string)($firstStep['label'] ?? ''), + 'action' => (string)($firstStep['action'] ?? ''), + 'action_label' => (string)($firstStep['action_label'] ?? ''), + 'bucket_key' => (string)($firstStep['bucket_key'] ?? 'all'), + 'lead_host' => (string)($firstStep['lead_host'] ?? ''), + 'tag_type' => (string)($firstStep['tag_type'] ?? 'warning'), + 'resolved_ops_panel' => (string)($firstStep['resolved_ops_panel'] ?? ''), + 'resolved_host_console_panel' => (string)($firstStep['resolved_host_console_panel'] ?? ''), + 'resolved_inspector_kind' => (string)($firstStep['resolved_inspector_kind'] ?? ''), + 'summary' => (string)($firstStep['summary'] ?? ''), + ]; + } + + protected static function buildDispatchChain(array $sequence, array $phase, array $nextAction): array + { + $chain = []; + foreach ($sequence as $index => $step) { + $step = (array)$step; + $action = (string)($step['action'] ?? ''); + $lane = 'takeover'; + $laneLabel = 'Host 接手'; + + if ($action === 'open-supply-dialog') { + $lane = 'bootstrap'; + $laneLabel = '发起供给'; + } elseif (in_array($action, ['latest-supply-orchestration-task', 'latest-supply-task', 'latest-supply-run'], true)) { + $lane = 'review'; + $laneLabel = '回看任务链'; + } elseif (($step['bucket_key'] ?? '') === 'completed') { + $lane = 'completed'; + $laneLabel = '完成回看'; + } + + $chain[] = [ + 'index' => $index + 1, + 'lane' => $lane, + 'lane_label' => $laneLabel, + 'key' => (string)($step['key'] ?? ''), + 'label' => (string)($step['label'] ?? ''), + 'title' => (string)($step['title'] ?? ''), + 'summary' => (string)($step['summary'] ?? ''), + 'action' => $action, + 'action_label' => (string)($step['action_label'] ?? ''), + 'lead_host' => (string)($step['lead_host'] ?? ''), + 'bucket_key' => (string)($step['bucket_key'] ?? ''), + 'tag_type' => (string)($step['tag_type'] ?? 'warning'), + 'resolved_ops_panel' => (string)($step['resolved_ops_panel'] ?? ''), + 'resolved_host_console_panel' => (string)($step['resolved_host_console_panel'] ?? ''), + 'resolved_inspector_kind' => (string)($step['resolved_inspector_kind'] ?? ''), + 'meta' => (array)($step['meta'] ?? []), + ]; + } + + if (empty($chain) && !empty($phase)) { + $chain[] = [ + 'index' => 1, + 'lane' => 'idle', + 'lane_label' => '待编排', + 'key' => (string)($phase['key'] ?? ''), + 'label' => (string)($phase['label'] ?? ''), + 'title' => (string)($phase['label'] ?? ''), + 'summary' => (string)($phase['summary'] ?? ''), + 'action' => (string)($nextAction['action'] ?? ''), + 'action_label' => (string)($nextAction['action_label'] ?? ''), + 'lead_host' => (string)($nextAction['lead_host'] ?? ''), + 'bucket_key' => (string)($nextAction['bucket_key'] ?? 'all'), + 'tag_type' => (string)($phase['tag_type'] ?? 'warning'), + 'resolved_ops_panel' => (string)($nextAction['resolved_ops_panel'] ?? ''), + 'resolved_host_console_panel' => (string)($nextAction['resolved_host_console_panel'] ?? ''), + 'resolved_inspector_kind' => (string)($nextAction['resolved_inspector_kind'] ?? ''), + 'meta' => [], + ]; + } + + return $chain; + } + + protected static function buildDispatchOrder(array $dispatchChain, array $phase): array + { + if (empty($dispatchChain)) { + if (empty($phase)) { + return []; + } + + return [[ + 'index' => 1, + 'lane' => 'idle', + 'lane_label' => '待编排', + 'title' => (string)($phase['label'] ?? '待编排'), + 'summary' => (string)($phase['summary'] ?? ''), + 'count' => 0, + 'lead_host' => '', + 'primary_action' => '', + 'primary_action_label' => '', + 'tag_type' => (string)($phase['tag_type'] ?? 'warning'), + 'resolved_ops_panel' => '', + 'resolved_host_console_panel' => '', + 'resolved_inspector_kind' => '', + 'keys' => [], + ]]; + } + + $grouped = []; + $lanePriorityMap = [ + 'bootstrap' => 10, + 'review' => 20, + 'takeover' => 30, + 'completed' => 40, + 'idle' => 50, + ]; + foreach ($dispatchChain as $step) { + $step = (array)$step; + $lane = (string)($step['lane'] ?? 'takeover'); + if (!isset($grouped[$lane])) { + $grouped[$lane] = [ + 'lane' => $lane, + 'lane_label' => (string)($step['lane_label'] ?? 'Host 接手'), + 'title' => (string)($step['title'] ?? $step['label'] ?? '调度阶段'), + 'summary' => (string)($step['summary'] ?? ''), + 'count' => 0, + 'lead_host' => (string)($step['lead_host'] ?? ''), + 'primary_action' => (string)($step['action'] ?? ''), + 'primary_action_label' => (string)($step['action_label'] ?? ''), + 'tag_type' => (string)($step['tag_type'] ?? 'warning'), + 'resolved_ops_panel' => (string)($step['resolved_ops_panel'] ?? ''), + 'resolved_host_console_panel' => (string)($step['resolved_host_console_panel'] ?? ''), + 'resolved_inspector_kind' => (string)($step['resolved_inspector_kind'] ?? ''), + 'keys' => [], + 'priority' => (int)($lanePriorityMap[$lane] ?? 999), + ]; + } + + $grouped[$lane]['count'] += 1; + $grouped[$lane]['keys'][] = (string)($step['key'] ?? ''); + if ($grouped[$lane]['summary'] === '' && (string)($step['summary'] ?? '') !== '') { + $grouped[$lane]['summary'] = (string)$step['summary']; + } + if ($grouped[$lane]['lead_host'] === '' && (string)($step['lead_host'] ?? '') !== '') { + $grouped[$lane]['lead_host'] = (string)$step['lead_host']; + } + if ($grouped[$lane]['primary_action'] === '' && (string)($step['action'] ?? '') !== '') { + $grouped[$lane]['primary_action'] = (string)$step['action']; + $grouped[$lane]['primary_action_label'] = (string)($step['action_label'] ?? ''); + } + } + + uasort($grouped, static function (array $left, array $right): int { + $leftPriority = (int)($left['priority'] ?? 999); + $rightPriority = (int)($right['priority'] ?? 999); + if ($leftPriority !== $rightPriority) { + return $leftPriority <=> $rightPriority; + } + + return strcmp((string)($left['lane'] ?? ''), (string)($right['lane'] ?? '')); + }); + + $order = []; + $index = 1; + foreach ($grouped as $stage) { + $stage['index'] = $index; + unset($stage['priority']); + $order[] = $stage; + $index += 1; + } + + return $order; + } + + protected static function buildDispatchPlan(array $dispatchOrder, array $dispatchStage, array $nextDispatchStage, array $plan): array + { + $currentLaneLabel = trim((string)($dispatchStage['lane_label'] ?? '')); + $nextLaneLabel = trim((string)($nextDispatchStage['lane_label'] ?? '')); + $currentAction = trim((string)($dispatchStage['action'] ?? ($dispatchStage['primary_action'] ?? ''))); + $currentActionLabel = trim((string)($dispatchStage['action_label'] ?? ($dispatchStage['primary_action_label'] ?? ''))); + $nextAction = trim((string)($nextDispatchStage['primary_action'] ?? '')); + $nextActionLabel = trim((string)($nextDispatchStage['primary_action_label'] ?? '')); + $leadHost = trim((string)($dispatchStage['lead_host'] ?? '')); + $finalStage = (array)(!empty($dispatchOrder) ? $dispatchOrder[count($dispatchOrder) - 1] : []); + + if (empty($dispatchOrder)) { + return [ + 'label' => '待调度', + 'title' => '当前还没有形成批量调度计划', + 'summary' => trim((string)($plan['summary'] ?? '当前供给主线还没有形成明确的调度顺序。')), + 'tag_type' => trim((string)($plan['tag_type'] ?? 'warning')), + 'current_lane' => '', + 'next_lane' => '', + 'total_stages' => 0, + 'remaining_stages' => 0, + 'current_action' => '', + 'current_action_label' => '', + 'next_action' => '', + 'next_action_label' => '', + 'lead_host' => trim((string)($plan['lead_host'] ?? '')), + 'final_lane' => '', + 'final_lane_label' => '', + ]; + } + + $summary = '当前阶段 ' . ($currentLaneLabel !== '' ? $currentLaneLabel : '待调度'); + if ($leadHost !== '') { + $summary .= ',先从 ' . $leadHost . ' 开始。'; + } else { + $summary .= '。'; + } + if ($nextLaneLabel !== '') { + $summary .= ' 下一阶段将进入 ' . $nextLaneLabel . '。'; + } + + return [ + 'label' => $currentLaneLabel !== '' ? $currentLaneLabel : '批量调度', + 'title' => '当前批次调度总计划', + 'summary' => $summary, + 'tag_type' => trim((string)($dispatchStage['tag_type'] ?? $plan['tag_type'] ?? 'warning')), + 'current_lane' => trim((string)($dispatchStage['lane'] ?? '')), + 'current_lane_label' => $currentLaneLabel, + 'next_lane' => trim((string)($nextDispatchStage['lane'] ?? '')), + 'next_lane_label' => $nextLaneLabel, + 'total_stages' => count($dispatchOrder), + 'remaining_stages' => max(0, count($dispatchOrder) - 1), + 'current_action' => $currentAction, + 'current_action_label' => $currentActionLabel, + 'next_action' => $nextAction, + 'next_action_label' => $nextActionLabel, + 'lead_host' => $leadHost, + 'final_lane' => trim((string)($finalStage['lane'] ?? '')), + 'final_lane_label' => trim((string)($finalStage['lane_label'] ?? '')), + ]; + } + + protected static function buildHandoffPlan(array $dispatchStage, array $nextDispatchStage, array $dispatchPlan, array $plan, array $phase): array + { + $currentAction = trim((string)($dispatchPlan['current_action'] ?? $dispatchStage['action'] ?? '')); + $leadHost = trim((string)($dispatchPlan['lead_host'] ?? $dispatchStage['lead_host'] ?? $plan['lead_host'] ?? '')); + $currentOpsPanel = trim((string)($dispatchStage['resolved_ops_panel'] ?? '')); + $currentConsolePanel = trim((string)($dispatchStage['resolved_host_console_panel'] ?? '')); + $currentInspectorKind = trim((string)($dispatchStage['resolved_inspector_kind'] ?? '')); + $nextAction = trim((string)($dispatchPlan['next_action'] ?? $nextDispatchStage['primary_action'] ?? '')); + + $currentFocus = self::buildHandoffFocus( + $currentAction, + $leadHost, + $currentOpsPanel, + $currentConsolePanel, + $currentInspectorKind + ); + $followupFocus = self::buildHandoffFocus( + $nextAction, + trim((string)($nextDispatchStage['lead_host'] ?? $leadHost)), + trim((string)($nextDispatchStage['resolved_ops_panel'] ?? '')), + trim((string)($nextDispatchStage['resolved_host_console_panel'] ?? '')), + trim((string)($nextDispatchStage['resolved_inspector_kind'] ?? '')) + ); + + $summary = '当前先' . ($currentFocus['label'] !== '' ? $currentFocus['label'] : '回看供给主线'); + if ($currentFocus['target_summary'] !== '') { + $summary .= ',目标 ' . $currentFocus['target_summary']; + } + if ($followupFocus['label'] !== '') { + $summary .= ';随后 ' . $followupFocus['label']; + if ($followupFocus['target_summary'] !== '') { + $summary .= ',目标 ' . $followupFocus['target_summary']; + } + } elseif (!empty($dispatchPlan['final_lane_label'])) { + $summary .= ';最终进入 ' . trim((string)$dispatchPlan['final_lane_label']) . '。'; + } else { + $summary .= '。'; + } + + return [ + 'label' => $currentFocus['label'] !== '' ? $currentFocus['label'] : '业务主线承接', + 'title' => '当前批次业务主线承接', + 'summary' => $summary, + 'tag_type' => trim((string)($dispatchStage['tag_type'] ?? $plan['tag_type'] ?? $phase['tag_type'] ?? 'warning')), + 'current_focus_type' => $currentFocus['type'], + 'current_focus_label' => $currentFocus['label'], + 'current_action' => $currentAction, + 'current_action_label' => trim((string)($dispatchPlan['current_action_label'] ?? $dispatchStage['action_label'] ?? '')), + 'current_target_type' => $currentFocus['target_type'], + 'current_target_label' => $currentFocus['target_label'], + 'current_target_summary' => $currentFocus['target_summary'], + 'lead_host' => $leadHost, + 'resolved_ops_panel' => $currentOpsPanel, + 'resolved_host_console_panel' => $currentConsolePanel, + 'resolved_inspector_kind' => $currentInspectorKind, + 'followup_focus_type' => $followupFocus['type'], + 'followup_focus_label' => $followupFocus['label'], + 'followup_action' => $nextAction, + 'followup_action_label' => trim((string)($dispatchPlan['next_action_label'] ?? $nextDispatchStage['primary_action_label'] ?? '')), + 'followup_target_type' => $followupFocus['target_type'], + 'followup_target_label' => $followupFocus['target_label'], + 'followup_target_summary' => $followupFocus['target_summary'], + 'final_lane_label' => trim((string)($dispatchPlan['final_lane_label'] ?? '')), + ]; + } + + protected static function buildHandoffFocus(string $action, string $leadHost, string $opsPanel, string $consolePanel, string $inspectorKind): array + { + $focus = [ + 'type' => '', + 'label' => '', + 'target_type' => '', + 'target_label' => '', + 'target_summary' => '', + ]; + + if ($action === '') { + return $focus; + } + + return match ($action) { + 'open-supply-dialog' => [ + 'type' => 'bootstrap_start', + 'label' => '发起供给', + 'target_type' => 'dialog', + 'target_label' => '一体化供给工作台', + 'target_summary' => '打开供给工作台', + ], + 'latest-supply-orchestration-task' => [ + 'type' => 'orchestration_task_review', + 'label' => '回看供给编排任务', + 'target_type' => 'task', + 'target_label' => '供给编排任务', + 'target_summary' => '最近供给编排任务', + ], + 'latest-supply-task' => [ + 'type' => 'task_review', + 'label' => '回看供给调度任务', + 'target_type' => 'task', + 'target_label' => '供给调度任务', + 'target_summary' => '最近供给调度任务', + ], + 'latest-supply-run' => [ + 'type' => 'run_review', + 'label' => '回看供给运行', + 'target_type' => 'run', + 'target_label' => '供给运行', + 'target_summary' => '最近供给运行', + ], + 'bucket-attention' => [ + 'type' => 'ops_attention', + 'label' => '接手异常主线', + 'target_type' => 'host', + 'target_label' => $leadHost, + 'target_summary' => self::buildHostTargetSummary($leadHost, $opsPanel, $consolePanel, $inspectorKind), + ], + 'bucket-oncall' => [ + 'type' => 'ops_oncall', + 'label' => '接手 Oncall 主线', + 'target_type' => 'host', + 'target_label' => $leadHost, + 'target_summary' => self::buildHostTargetSummary($leadHost, $opsPanel, $consolePanel, $inspectorKind), + ], + 'bucket-bundle' => [ + 'type' => 'ops_bundle', + 'label' => '推进 Bundle 主线', + 'target_type' => 'host', + 'target_label' => $leadHost, + 'target_summary' => self::buildHostTargetSummary($leadHost, $opsPanel, $consolePanel, $inspectorKind), + ], + 'bucket-supply' => [ + 'type' => 'supply_takeover', + 'label' => '接手 Supply Host', + 'target_type' => 'host', + 'target_label' => $leadHost, + 'target_summary' => self::buildHostTargetSummary($leadHost, $opsPanel, $consolePanel, $inspectorKind), + ], + 'bucket-completed' => [ + 'type' => 'ops_completed', + 'label' => '回看已完成主线', + 'target_type' => 'host', + 'target_label' => $leadHost, + 'target_summary' => self::buildHostTargetSummary($leadHost, $opsPanel, $consolePanel, $inspectorKind), + ], + default => [ + 'type' => 'custom', + 'label' => $action, + 'target_type' => 'custom', + 'target_label' => $leadHost, + 'target_summary' => self::buildHostTargetSummary($leadHost, $opsPanel, $consolePanel, $inspectorKind), + ], + }; + } + + protected static function buildHostTargetSummary(string $leadHost, string $opsPanel, string $consolePanel, string $inspectorKind): string + { + $parts = []; + if ($leadHost !== '') { + $parts[] = $leadHost; + } + if ($opsPanel !== '' || $consolePanel !== '') { + $parts[] = trim($opsPanel . '/' . $consolePanel, '/'); + } + if ($inspectorKind !== '') { + $parts[] = 'inspector=' . $inspectorKind; + } + + return implode(' ', $parts); + } + + protected static function buildMainlineChain(array $sequence, array $dispatchOrder, array $handoffPlan, array $phase): array + { + $stages = []; + $stagePriorityMap = [ + 'bootstrap' => 10, + 'review' => 20, + 'host_takeover' => 30, + 'ops_execution' => 40, + 'completed' => 50, + 'idle' => 60, + ]; + + foreach ($sequence as $step) { + $step = (array)$step; + $stage = self::mapStepToMainlineStage($step); + $stageKey = (string)($stage['stage'] ?? 'idle'); + if (!isset($stages[$stageKey])) { + $stages[$stageKey] = [ + 'stage' => $stageKey, + 'stage_label' => (string)($stage['stage_label'] ?? '待编排'), + 'summary' => (string)($stage['summary'] ?? ''), + 'count' => 0, + 'lead_host' => (string)($step['lead_host'] ?? ''), + 'primary_action' => (string)($step['action'] ?? ''), + 'primary_action_label' => (string)($step['action_label'] ?? ''), + 'tag_type' => (string)($step['tag_type'] ?? 'warning'), + 'resolved_ops_panel' => (string)($step['resolved_ops_panel'] ?? ''), + 'resolved_host_console_panel' => (string)($step['resolved_host_console_panel'] ?? ''), + 'resolved_inspector_kind' => (string)($step['resolved_inspector_kind'] ?? ''), + 'priority' => (int)($stagePriorityMap[$stageKey] ?? 999), + ]; + } + $stages[$stageKey]['count'] += 1; + if ($stages[$stageKey]['summary'] === '' && (string)($stage['summary'] ?? '') !== '') { + $stages[$stageKey]['summary'] = (string)$stage['summary']; + } + if ($stages[$stageKey]['lead_host'] === '' && (string)($step['lead_host'] ?? '') !== '') { + $stages[$stageKey]['lead_host'] = (string)$step['lead_host']; + } + } + + if (empty($stages)) { + $stages['idle'] = [ + 'stage' => 'idle', + 'stage_label' => '待编排', + 'summary' => (string)($phase['summary'] ?? '当前供给主线还没有形成明确的业务主线。'), + 'count' => 0, + 'lead_host' => '', + 'primary_action' => '', + 'primary_action_label' => '', + 'tag_type' => (string)($phase['tag_type'] ?? 'warning'), + 'resolved_ops_panel' => '', + 'resolved_host_console_panel' => '', + 'resolved_inspector_kind' => '', + 'priority' => (int)($stagePriorityMap['idle'] ?? 999), + ]; + } + + uasort($stages, static function (array $left, array $right): int { + $lp = (int)($left['priority'] ?? 999); + $rp = (int)($right['priority'] ?? 999); + if ($lp !== $rp) { + return $lp <=> $rp; + } + return strcmp((string)($left['stage'] ?? ''), (string)($right['stage'] ?? '')); + }); + + $chain = []; + $index = 1; + foreach ($stages as $stage) { + unset($stage['priority']); + $stage['index'] = $index; + $chain[] = $stage; + $index += 1; + } + + if (!empty($dispatchOrder) && !empty($handoffPlan['lead_host'])) { + foreach ($chain as &$stage) { + if ((string)($stage['lead_host'] ?? '') === '') { + $stage['lead_host'] = (string)$handoffPlan['lead_host']; + } + } + unset($stage); + } + + return $chain; + } + + protected static function buildMainlineRoute(array $mainlineChain): array + { + $route = []; + foreach ($mainlineChain as $stage) { + $stage = (array)$stage; + $route[] = [ + 'index' => count($route) + 1, + 'stage' => (string)($stage['stage'] ?? ''), + 'stage_label' => (string)($stage['stage_label'] ?? ''), + 'summary' => (string)($stage['summary'] ?? ''), + 'lead_host' => (string)($stage['lead_host'] ?? ''), + 'primary_action' => (string)($stage['primary_action'] ?? ''), + 'primary_action_label' => (string)($stage['primary_action_label'] ?? ''), + 'tag_type' => (string)($stage['tag_type'] ?? 'warning'), + ]; + } + + return $route; + } + + protected static function buildProjectedMainlineRoute(array $mainlineChain, array $mainlineRoute, array $buckets, array $phase): array + { + $currentStageKey = (string)(((array)($mainlineChain[0] ?? []))['stage'] ?? 'idle'); + $canonicalOrder = ['bootstrap', 'review', 'host_takeover', 'ops_execution', 'completed']; + $bucketByKey = []; + foreach ($buckets as $bucket) { + $bucket = (array)$bucket; + $bucketByKey[(string)($bucket['key'] ?? '')] = $bucket; + } + $stageDefaults = [ + 'bootstrap' => [ + 'stage_label' => '发起供给', + 'summary' => '先发起或回放当前批次的一体化供给。', + 'primary_action' => 'open-supply-dialog', + 'primary_action_label' => '打开供给工作台', + 'tag_type' => 'warning', + ], + 'review' => [ + 'stage_label' => '回看任务链', + 'summary' => '先回看供给任务、编排任务或最近一次供给运行,再决定是否进入 Host 主线。', + 'primary_action' => 'latest-supply-orchestration-task', + 'primary_action_label' => '查看供给编排任务', + 'tag_type' => 'warning', + ], + 'host_takeover' => [ + 'stage_label' => 'Host 接手', + 'summary' => '从当前批次的 lead host 开始接手 Supply Host 主线。', + 'primary_action' => 'bucket-supply', + 'primary_action_label' => '接手 Supply Host', + 'tag_type' => 'info', + ], + 'ops_execution' => [ + 'stage_label' => '进入 Ops 主线', + 'summary' => '将 Host 推进到 Bundle / Oncall / Attention 等 Ops 主线。', + 'primary_action' => self::resolveProjectedOpsAction($bucketByKey), + 'primary_action_label' => self::resolveProjectedOpsActionLabel($bucketByKey), + 'tag_type' => 'warning', + ], + 'completed' => [ + 'stage_label' => '已完成回看', + 'summary' => '当前批次进入已完成态,回看完成结果或继续下一批。', + 'primary_action' => !empty($bucketByKey['completed']['count']) ? 'bucket-completed' : 'latest-completed', + 'primary_action_label' => !empty($bucketByKey['completed']['count']) ? '查看已完成 Host' : '进入已完成主线', + 'tag_type' => 'success', + ], + ]; + + $currentIndex = array_search($currentStageKey, $canonicalOrder, true); + if ($currentIndex === false) { + $currentIndex = ($currentStageKey === 'idle') ? 1 : 0; + } + + $projectedKeys = array_slice($canonicalOrder, $currentIndex); + if (empty($projectedKeys)) { + $projectedKeys = ['review', 'host_takeover', 'ops_execution', 'completed']; + } + + $routeByStage = []; + foreach ($mainlineRoute as $routeItem) { + $routeItem = (array)$routeItem; + $stageKey = (string)($routeItem['stage'] ?? ''); + if ($stageKey !== '') { + $routeByStage[$stageKey] = $routeItem; + } + } + + $leadHost = (string)(((array)($mainlineChain[0] ?? []))['lead_host'] ?? ''); + $projectedRoute = []; + foreach ($projectedKeys as $index => $stageKey) { + $existing = (array)($routeByStage[$stageKey] ?? []); + $defaults = (array)($stageDefaults[$stageKey] ?? []); + $projectedRoute[] = [ + 'index' => $index + 1, + 'stage' => $stageKey, + 'stage_label' => (string)($existing['stage_label'] ?? $defaults['stage_label'] ?? $stageKey), + 'summary' => (string)($existing['summary'] ?? $defaults['summary'] ?? ($phase['summary'] ?? '')), + 'lead_host' => (string)($existing['lead_host'] ?? $leadHost), + 'primary_action' => (string)($existing['primary_action'] ?? $defaults['primary_action'] ?? ''), + 'primary_action_label' => (string)($existing['primary_action_label'] ?? $defaults['primary_action_label'] ?? ''), + 'tag_type' => (string)($existing['tag_type'] ?? $defaults['tag_type'] ?? ($phase['tag_type'] ?? 'warning')), + ]; + } + + return $projectedRoute; + } + + protected static function resolveProjectedOpsAction(array $bucketByKey): string + { + if (!empty($bucketByKey['attention']['count'])) { + return 'bucket-attention'; + } + if (!empty($bucketByKey['oncall']['count'])) { + return 'bucket-oncall'; + } + if (!empty($bucketByKey['bundle']['count'])) { + return 'bucket-bundle'; + } + + return 'latest-host'; + } + + protected static function resolveProjectedOpsActionLabel(array $bucketByKey): string + { + if (!empty($bucketByKey['attention']['count'])) { + return '接手异常 Host'; + } + if (!empty($bucketByKey['oncall']['count'])) { + return '接手 Oncall Host'; + } + if (!empty($bucketByKey['bundle']['count'])) { + return '接手 Bundle Host'; + } + + return '进入 Host 主线'; + } + + protected static function buildMainlinePlan(array $mainlineChain, array $mainlineRoute, array $projectedMainlineRoute, array $handoffPlan, array $phase): array + { + $current = (array)($mainlineChain[0] ?? []); + $next = (array)($mainlineRoute[1] ?? $projectedMainlineRoute[1] ?? []); + $final = (array)(!empty($projectedMainlineRoute) ? $projectedMainlineRoute[count($projectedMainlineRoute) - 1] : (!empty($mainlineChain) ? $mainlineChain[count($mainlineChain) - 1] : [])); + + if (empty($current)) { + return [ + 'label' => '待编排', + 'title' => '当前还没有形成业务主线计划', + 'summary' => (string)($phase['summary'] ?? '当前供给主线还没有形成明确的业务主线计划。'), + 'tag_type' => (string)($phase['tag_type'] ?? 'warning'), + 'current_stage' => '', + 'current_stage_label' => '', + 'next_stage' => '', + 'next_stage_label' => '', + 'final_stage' => '', + 'final_stage_label' => '', + 'current_action' => '', + 'current_action_label' => '', + 'next_action' => '', + 'next_action_label' => '', + 'lead_host' => '', + 'total_stages' => 0, + 'remaining_stages' => 0, + 'route_count' => 0, + 'projected_route_count' => 0, + ]; + } + + $summary = '当前先进入' . (string)($current['stage_label'] ?? '业务主线'); + if (!empty($current['lead_host'])) { + $summary .= ',代表 Host ' . (string)$current['lead_host']; + } + if (!empty($next['stage_label'])) { + $summary .= ';随后进入' . (string)$next['stage_label']; + } elseif (!empty($final['stage_label']) && (string)($final['stage'] ?? '') !== (string)($current['stage'] ?? '')) { + $summary .= ';最终进入' . (string)$final['stage_label']; + } elseif (!empty($handoffPlan['summary'])) { + $summary .= '。' . (string)$handoffPlan['summary']; + } else { + $summary .= '。'; + } + + return [ + 'label' => (string)($current['stage_label'] ?? '业务主线'), + 'title' => '当前批次业务主线计划', + 'summary' => $summary, + 'tag_type' => (string)($current['tag_type'] ?? $phase['tag_type'] ?? 'warning'), + 'current_stage' => (string)($current['stage'] ?? ''), + 'current_stage_label' => (string)($current['stage_label'] ?? ''), + 'next_stage' => (string)($next['stage'] ?? ''), + 'next_stage_label' => (string)($next['stage_label'] ?? ''), + 'final_stage' => (string)($final['stage'] ?? ''), + 'final_stage_label' => (string)($final['stage_label'] ?? ''), + 'current_action' => (string)($current['primary_action'] ?? ''), + 'current_action_label' => (string)($current['primary_action_label'] ?? ''), + 'next_action' => (string)($next['primary_action'] ?? ''), + 'next_action_label' => (string)($next['primary_action_label'] ?? ''), + 'lead_host' => (string)($current['lead_host'] ?? $handoffPlan['lead_host'] ?? ''), + 'total_stages' => count($projectedMainlineRoute) > 0 ? count($projectedMainlineRoute) : count($mainlineChain), + 'remaining_stages' => max(0, (count($projectedMainlineRoute) > 0 ? count($projectedMainlineRoute) : count($mainlineChain)) - 1), + 'route_count' => count($mainlineRoute), + 'projected_route_count' => count($projectedMainlineRoute), + ]; + } + + protected static function buildNextBusinessAction(array $projectedMainlineRoute, array $mainlinePlan, array $handoffPlan, array $phase): array + { + $current = (array)($projectedMainlineRoute[0] ?? []); + $next = (array)($projectedMainlineRoute[1] ?? []); + $final = (array)(!empty($projectedMainlineRoute) ? $projectedMainlineRoute[count($projectedMainlineRoute) - 1] : []); + + if (empty($current)) { + return [ + 'label' => '待承接', + 'summary' => (string)($phase['summary'] ?? '当前还没有形成明确的业务顺序承接。'), + 'tag_type' => (string)($phase['tag_type'] ?? 'warning'), + 'current_stage' => '', + 'current_stage_label' => '', + 'next_stage' => '', + 'next_stage_label' => '', + 'final_stage' => '', + 'final_stage_label' => '', + 'action' => '', + 'action_label' => '', + 'lead_host' => '', + ]; + } + + $summary = '当前先完成' . (string)($current['stage_label'] ?? '业务主线'); + if (!empty($current['lead_host'])) { + $summary .= ',代表 Host ' . (string)$current['lead_host']; + } + if (!empty($next['stage_label'])) { + $summary .= ';随后承接到' . (string)$next['stage_label']; + } + if (!empty($final['stage_label']) && (string)($final['stage'] ?? '') !== (string)($current['stage'] ?? '')) { + $summary .= ';最终收口到' . (string)$final['stage_label']; + } else { + $summary .= '。'; + } + + return [ + 'label' => !empty($next['stage_label']) ? ('后续进入' . (string)$next['stage_label']) : ('当前保持' . (string)($current['stage_label'] ?? '业务主线')), + 'summary' => $summary, + 'tag_type' => (string)($next['tag_type'] ?? $current['tag_type'] ?? $phase['tag_type'] ?? 'warning'), + 'current_stage' => (string)($current['stage'] ?? ''), + 'current_stage_label' => (string)($current['stage_label'] ?? ''), + 'next_stage' => (string)($next['stage'] ?? ''), + 'next_stage_label' => (string)($next['stage_label'] ?? ''), + 'final_stage' => (string)($final['stage'] ?? ''), + 'final_stage_label' => (string)($final['stage_label'] ?? ''), + 'action' => (string)($next['primary_action'] ?? ''), + 'action_label' => (string)($next['primary_action_label'] ?? ''), + 'lead_host' => (string)($current['lead_host'] ?? $mainlinePlan['lead_host'] ?? $handoffPlan['lead_host'] ?? ''), + ]; + } + + protected static function buildMainlineSequencePlan(array $projectedMainlineRoute, array $mainlinePlan, array $nextBusinessAction, array $phase): array + { + if (empty($projectedMainlineRoute)) { + return [ + 'title' => '当前还没有形成业务顺序承接', + 'summary' => (string)($phase['summary'] ?? '当前供给主线还没有形成明确的业务顺序承接。'), + 'tag_type' => (string)($phase['tag_type'] ?? 'warning'), + 'ordered_labels' => [], + 'current_stage_label' => '', + 'next_stage_label' => '', + 'final_stage_label' => '', + 'current_action' => '', + 'current_action_label' => '', + 'next_action' => '', + 'next_action_label' => '', + 'lead_host' => '', + 'remaining_stages' => 0, + 'projected_route_count' => 0, + ]; + } + + $orderedLabels = array_values(array_filter(array_map(static function (array $stage): string { + return trim((string)($stage['stage_label'] ?? '')); + }, $projectedMainlineRoute))); + + return [ + 'title' => '当前批次业务顺序承接', + 'summary' => (string)($nextBusinessAction['summary'] ?? $mainlinePlan['summary'] ?? ''), + 'tag_type' => (string)($nextBusinessAction['tag_type'] ?? $mainlinePlan['tag_type'] ?? $phase['tag_type'] ?? 'warning'), + 'ordered_labels' => $orderedLabels, + 'current_stage_label' => (string)($mainlinePlan['current_stage_label'] ?? ''), + 'next_stage_label' => (string)($nextBusinessAction['next_stage_label'] ?? $mainlinePlan['next_stage_label'] ?? ''), + 'final_stage_label' => (string)($nextBusinessAction['final_stage_label'] ?? $mainlinePlan['final_stage_label'] ?? ''), + 'current_action' => (string)($mainlinePlan['current_action'] ?? ''), + 'current_action_label' => (string)($mainlinePlan['current_action_label'] ?? ''), + 'next_action' => (string)($nextBusinessAction['action'] ?? ''), + 'next_action_label' => (string)($nextBusinessAction['action_label'] ?? ''), + 'lead_host' => (string)($mainlinePlan['lead_host'] ?? ''), + 'remaining_stages' => max(0, count($projectedMainlineRoute) - 1), + 'projected_route_count' => count($projectedMainlineRoute), + ]; + } + + protected static function buildBusinessSequence(array $projectedMainlineRoute): array + { + $sequence = []; + foreach ($projectedMainlineRoute as $index => $stage) { + $stage = (array)$stage; + $sequence[] = [ + 'index' => $index + 1, + 'stage' => (string)($stage['stage'] ?? ''), + 'stage_label' => (string)($stage['stage_label'] ?? ''), + 'summary' => (string)($stage['summary'] ?? ''), + 'lead_host' => (string)($stage['lead_host'] ?? ''), + 'primary_action' => (string)($stage['primary_action'] ?? ''), + 'primary_action_label' => (string)($stage['primary_action_label'] ?? ''), + 'tag_type' => (string)($stage['tag_type'] ?? 'warning'), + 'status' => $index === 0 ? 'current' : 'upcoming', + 'status_label' => $index === 0 ? '当前' : '后续', + ]; + } + + return $sequence; + } + + protected static function buildBusinessTakeoverQueue(array $hostTotals, array $projectedMainlineRoute): array + { + if (empty($hostTotals)) { + return []; + } + + $bucketPriorityMap = [ + 'attention' => 10, + 'oncall' => 20, + 'bundle' => 30, + 'supply' => 40, + 'completed' => 50, + ]; + $bucketLabelMap = [ + 'attention' => '异常优先', + 'oncall' => 'Oncall 优先', + 'bundle' => 'Bundle 推进', + 'supply' => 'Supply 接手', + 'completed' => '完成回看', + ]; + $tagTypeMap = [ + 'attention' => 'danger', + 'oncall' => 'warning', + 'bundle' => 'warning', + 'supply' => 'info', + 'completed' => 'success', + ]; + + $stageIndexMap = []; + $routeByStage = []; + foreach ($projectedMainlineRoute as $index => $stage) { + $stage = (array)$stage; + $stageKey = (string)($stage['stage'] ?? ''); + if ($stageKey === '') { + continue; + } + $stageIndexMap[$stageKey] = $index; + $routeByStage[$stageKey] = $stage; + } + + $queueHosts = array_values(array_filter($hostTotals, 'is_array')); + usort($queueHosts, static function (array $left, array $right) use ($bucketPriorityMap): int { + $leftBucket = (string)($left['orchestration_bucket'] ?? 'supply'); + $rightBucket = (string)($right['orchestration_bucket'] ?? 'supply'); + $leftPriority = (int)($bucketPriorityMap[$leftBucket] ?? 999); + $rightPriority = (int)($bucketPriorityMap[$rightBucket] ?? 999); + if ($leftPriority !== $rightPriority) { + return $leftPriority <=> $rightPriority; + } + + $leftFailed = (int)($left['failed_count'] ?? 0); + $rightFailed = (int)($right['failed_count'] ?? 0); + if ($leftFailed !== $rightFailed) { + return $rightFailed <=> $leftFailed; + } + + return strcmp((string)($right['latest_run_id'] ?? ''), (string)($left['latest_run_id'] ?? '')); + }); + + $queue = []; + foreach ($queueHosts as $index => $hostSummary) { + $hostSummary = (array)$hostSummary; + $bucketKey = (string)($hostSummary['orchestration_bucket'] ?? 'supply'); + $stageKey = self::resolveCurrentBusinessStageForHost($hostSummary); + $currentStage = (array)($routeByStage[$stageKey] ?? []); + $nextStage = []; + if (isset($stageIndexMap[$stageKey])) { + $nextStage = (array)($projectedMainlineRoute[((int)$stageIndexMap[$stageKey]) + 1] ?? []); + } + + $recommendedActionLabel = match ($bucketKey) { + 'attention' => '进入异常主线', + 'oncall' => '进入 Oncall 主线', + 'bundle' => '进入 Bundle 主线', + 'completed' => '查看已完成主线', + default => '接手 Supply Host', + }; + + $queue[] = [ + 'order' => $index + 1, + 'host' => (string)($hostSummary['host'] ?? ''), + 'bucket_key' => $bucketKey, + 'bucket_label' => (string)($bucketLabelMap[$bucketKey] ?? '业务接手'), + 'current_stage' => $stageKey, + 'current_stage_label' => (string)($currentStage['stage_label'] ?? ''), + 'next_stage' => (string)($nextStage['stage'] ?? ''), + 'next_stage_label' => (string)($nextStage['stage_label'] ?? ''), + 'resolved_ops_panel' => (string)($hostSummary['resolved_ops_panel'] ?? ''), + 'resolved_host_console_panel' => (string)($hostSummary['resolved_host_console_panel'] ?? ''), + 'resolved_inspector_kind' => (string)($hostSummary['resolved_inspector_kind'] ?? ''), + 'failed_count' => (int)($hostSummary['failed_count'] ?? 0), + 'success_count' => (int)($hostSummary['success_count'] ?? 0), + 'latest_status' => (string)($hostSummary['latest_status'] ?? ''), + 'latest_message' => (string)($hostSummary['latest_message'] ?? ''), + 'strategy_profiles' => array_values((array)($hostSummary['strategy_profiles'] ?? [])), + 'recommended_action_label' => $recommendedActionLabel, + 'tag_type' => (string)($tagTypeMap[$bucketKey] ?? 'info'), + ]; + } + + return $queue; + } + + protected static function buildBusinessEntryRule(array $sequence, array $phase, array $businessTakeoverQueue): array + { + $firstStep = (array)($sequence[0] ?? []); + $firstQueue = (array)($businessTakeoverQueue[0] ?? []); + $firstKey = (string)($firstStep['key'] ?? ''); + $firstAction = (string)($firstStep['action'] ?? ''); + + if ($firstKey === 'bootstrap_supply_missing') { + return [ + 'mode' => 'bootstrap', + 'label' => '先发起供给', + 'summary' => (string)($firstStep['summary'] ?? '当前还没有供给 task/run,需先发起供给。'), + 'tag_type' => 'warning', + 'primary_action' => (string)($firstStep['action'] ?? 'open-supply-dialog'), + 'primary_action_label' => (string)($firstStep['action_label'] ?? '打开供给工作台'), + 'lead_host' => '', + 'queue_ready' => false, + 'queue_blocked' => true, + 'queue_count' => count($businessTakeoverQueue), + 'blocking_reason' => (string)($firstStep['title'] ?? '缺少供给运行记录'), + 'next_stage_label' => '', + ]; + } + + if (in_array($firstAction, ['latest-supply-orchestration-task', 'latest-supply-task', 'latest-supply-run'], true)) { + return [ + 'mode' => 'review_gate', + 'label' => '先回看任务链', + 'summary' => (string)($firstStep['summary'] ?? '当前应先回看供给任务链,再进入 Host 接手。'), + 'tag_type' => (string)($firstStep['tag_type'] ?? 'danger'), + 'primary_action' => $firstAction, + 'primary_action_label' => (string)($firstStep['action_label'] ?? '先回看任务链'), + 'lead_host' => (string)($firstQueue['host'] ?? ''), + 'queue_ready' => !empty($businessTakeoverQueue), + 'queue_blocked' => true, + 'queue_count' => count($businessTakeoverQueue), + 'blocking_reason' => (string)($firstStep['title'] ?? '最近 task/run 未收口'), + 'next_stage_label' => (string)($firstQueue['current_stage_label'] ?? ''), + ]; + } + + return [ + 'mode' => 'host_takeover', + 'label' => '直接 Host 接手', + 'summary' => !empty($firstQueue) + ? ('当前可直接进入 Host 接手,建议先从 ' . (string)($firstQueue['host'] ?? '-') . ' 开始。') + : (string)($phase['summary'] ?? '当前可直接进入 Host 接手。'), + 'tag_type' => (string)($firstStep['tag_type'] ?? $phase['tag_type'] ?? 'info'), + 'primary_action' => (string)($firstStep['action'] ?? ''), + 'primary_action_label' => (string)($firstStep['action_label'] ?? '接手当前 Host'), + 'lead_host' => (string)($firstQueue['host'] ?? $firstStep['lead_host'] ?? ''), + 'queue_ready' => !empty($businessTakeoverQueue), + 'queue_blocked' => false, + 'queue_count' => count($businessTakeoverQueue), + 'blocking_reason' => '', + 'next_stage_label' => (string)($firstQueue['next_stage_label'] ?? ''), + ]; + } + + protected static function buildBusinessEntryGateStack( + array $latestOrchestrationTask, + array $latestTask, + array $latestRun, + array $businessEntryRule, + array $businessTakeoverQueue, + array $phase + ): array { + $mode = (string)($businessEntryRule['mode'] ?? ''); + $queueCount = count($businessTakeoverQueue); + $leadHost = (string)($businessEntryRule['lead_host'] ?? ''); + + if ($mode === 'bootstrap') { + return [[ + 'index' => 1, + 'key' => 'bootstrap_supply', + 'label' => '发起供给', + 'status' => 'current', + 'status_label' => '待执行', + 'blocking' => true, + 'tag_type' => 'warning', + 'summary' => (string)($businessEntryRule['summary'] ?? '当前还没有供给 task/run,需先发起供给。'), + 'action' => (string)($businessEntryRule['primary_action'] ?? 'open-supply-dialog'), + 'action_label' => (string)($businessEntryRule['primary_action_label'] ?? '打开供给工作台'), + 'lead_host' => '', + 'queue_count' => $queueCount, + ]]; + } + + $rawGates = []; + $orchestrationStatus = strtolower((string)($latestOrchestrationTask['status'] ?? '')); + $taskStatus = strtolower((string)($latestTask['status'] ?? '')); + $runStatus = strtolower((string)($latestRun['status'] ?? '')); + + if (($latestOrchestrationTask['task_slug'] ?? '') !== '') { + $rawGates[] = [ + 'key' => 'orchestration_task', + 'label' => '编排任务', + 'successful' => $orchestrationStatus === 'success', + 'blocking' => $orchestrationStatus !== '' && $orchestrationStatus !== 'success', + 'tag_type' => $orchestrationStatus === 'success' ? 'success' : 'danger', + 'summary' => $orchestrationStatus === 'success' + ? '最近供给编排任务已通过。' + : ((string)($latestOrchestrationTask['summary'] ?? '最近供给编排任务未收口,需先回看。')), + 'action' => 'latest-supply-orchestration-task', + 'action_label' => '查看供给编排任务', + 'lead_host' => '', + 'queue_count' => $queueCount, + ]; + } + + if (($latestTask['task_slug'] ?? '') !== '') { + $rawGates[] = [ + 'key' => 'supply_task', + 'label' => '供给任务', + 'successful' => $taskStatus === 'success', + 'blocking' => $taskStatus !== '' && $taskStatus !== 'success', + 'tag_type' => $taskStatus === 'success' ? 'success' : 'danger', + 'summary' => $taskStatus === 'success' + ? '最近供给任务已通过。' + : ((string)($latestTask['summary'] ?? '最近供给任务未收口,需先回看。')), + 'action' => 'latest-supply-task', + 'action_label' => '查看供给调度任务', + 'lead_host' => '', + 'queue_count' => $queueCount, + ]; + } + + if (($latestRun['run_id'] ?? '') !== '') { + $rawGates[] = [ + 'key' => 'supply_run', + 'label' => '供给运行', + 'successful' => $runStatus === 'success', + 'blocking' => $runStatus !== '' && $runStatus !== 'success', + 'tag_type' => $runStatus === 'success' ? 'success' : 'warning', + 'summary' => $runStatus === 'success' + ? ('最近供给运行 ' . (string)($latestRun['run_id'] ?? '-') . ' 已通过。') + : ('最近供给运行 ' . (string)($latestRun['run_id'] ?? '-') . ' 仍需回看。'), + 'action' => 'latest-supply-run', + 'action_label' => '查看供给运行', + 'lead_host' => '', + 'queue_count' => $queueCount, + ]; + } + + $rawGates[] = [ + 'key' => 'host_takeover', + 'label' => 'Host 接手', + 'successful' => false, + 'blocking' => false, + 'tag_type' => ($mode === 'host_takeover') ? 'warning' : 'info', + 'summary' => ($mode === 'host_takeover') + ? ('当前已通过前置关卡,可直接从 ' . ($leadHost !== '' ? $leadHost : '-') . ' 开始接手。') + : ((string)($phase['summary'] ?? '当前下一步会进入 Host 接手。')), + 'action' => (string)($businessEntryRule['primary_action'] ?? ''), + 'action_label' => (string)($businessEntryRule['primary_action_label'] ?? '接手当前 Host'), + 'lead_host' => $leadHost, + 'queue_count' => $queueCount, + ]; + + $gates = []; + $currentAssigned = false; + foreach ($rawGates as $gate) { + $gate = (array)$gate; + $successful = (bool)($gate['successful'] ?? false); + $blocking = (bool)($gate['blocking'] ?? false); + + if ($successful) { + $status = 'passed'; + $statusLabel = '已通过'; + } elseif (!$currentAssigned) { + $status = 'current'; + $statusLabel = $blocking ? '当前阻塞' : '当前入口'; + $currentAssigned = true; + } else { + $status = 'upcoming'; + $statusLabel = '后续入口'; + } + + $gates[] = [ + 'index' => count($gates) + 1, + 'key' => (string)($gate['key'] ?? ''), + 'label' => (string)($gate['label'] ?? ''), + 'status' => $status, + 'status_label' => $statusLabel, + 'blocking' => $blocking, + 'tag_type' => (string)($gate['tag_type'] ?? 'info'), + 'summary' => (string)($gate['summary'] ?? ''), + 'action' => (string)($gate['action'] ?? ''), + 'action_label' => (string)($gate['action_label'] ?? ''), + 'lead_host' => (string)($gate['lead_host'] ?? ''), + 'queue_count' => (int)($gate['queue_count'] ?? $queueCount), + ]; + } + + return $gates; + } + + protected static function buildBusinessEntryStrategy( + array $businessEntryRule, + array $businessEntryGateStack, + array $businessEntryCurrentGate, + array $businessEntryBlockers + ): array { + $mode = (string)($businessEntryRule['mode'] ?? ''); + $passedCount = count(array_filter($businessEntryGateStack, static function (array $gate): bool { + return (string)($gate['status'] ?? '') === 'passed'; + })); + $totalCount = count($businessEntryGateStack); + $blockersCount = count($businessEntryBlockers); + $currentGateLabel = (string)($businessEntryCurrentGate['label'] ?? ''); + $nextStageLabel = (string)($businessEntryRule['next_stage_label'] ?? ''); + + if ($mode === 'bootstrap') { + return [ + 'mode' => 'bootstrap', + 'label' => '先发起供给', + 'summary' => '当前还没有形成可接手批次,需先发起供给,再进入后续 review 和 Host 接手。', + 'tag_type' => 'warning', + 'primary_action' => (string)($businessEntryRule['primary_action'] ?? 'open-supply-dialog'), + 'primary_action_label' => (string)($businessEntryRule['primary_action_label'] ?? '打开供给工作台'), + 'lead_host' => '', + 'current_gate_label' => $currentGateLabel, + 'passed_gates' => $passedCount, + 'total_gates' => $totalCount, + 'blockers_count' => $blockersCount, + 'next_stage_label' => '', + ]; + } + + if ($mode === 'review_gate') { + return [ + 'mode' => 'review_gate', + 'label' => '先通过 Review Gate', + 'summary' => '当前仍有 review blocker,需先通过编排任务、供给任务或供给运行关卡,再进入 Host 接手。', + 'tag_type' => 'danger', + 'primary_action' => (string)($businessEntryRule['primary_action'] ?? ''), + 'primary_action_label' => (string)($businessEntryRule['primary_action_label'] ?? '先回看任务链'), + 'lead_host' => (string)($businessEntryRule['lead_host'] ?? ''), + 'current_gate_label' => $currentGateLabel, + 'passed_gates' => $passedCount, + 'total_gates' => $totalCount, + 'blockers_count' => $blockersCount, + 'next_stage_label' => $nextStageLabel, + ]; + } + + return [ + 'mode' => 'host_takeover', + 'label' => '直接进入 Host 接手', + 'summary' => '当前前置关卡已通过,可直接进入 Host 接手,并继续推进到 Ops 主线。', + 'tag_type' => 'success', + 'primary_action' => (string)($businessEntryRule['primary_action'] ?? ''), + 'primary_action_label' => (string)($businessEntryRule['primary_action_label'] ?? '接手当前 Host'), + 'lead_host' => (string)($businessEntryRule['lead_host'] ?? ''), + 'current_gate_label' => $currentGateLabel, + 'passed_gates' => $passedCount, + 'total_gates' => $totalCount, + 'blockers_count' => $blockersCount, + 'next_stage_label' => $nextStageLabel, + ]; + } + + protected static function buildBusinessActionQueue( + array $businessEntryStrategy, + array $businessEntryGateStack, + array $businessTakeoverQueue, + array $projectedMainlineRoute + ): array { + $queue = []; + $strategyMode = (string)($businessEntryStrategy['mode'] ?? ''); + $leadHost = (string)($businessEntryStrategy['lead_host'] ?? ''); + + if ($strategyMode === 'bootstrap') { + $queue[] = [ + 'index' => 1, + 'key' => 'bootstrap_supply', + 'label' => '发起供给', + 'status' => 'current', + 'status_label' => '当前动作', + 'summary' => (string)($businessEntryStrategy['summary'] ?? '当前需先发起供给。'), + 'action' => (string)($businessEntryStrategy['primary_action'] ?? 'open-supply-dialog'), + 'action_label' => (string)($businessEntryStrategy['primary_action_label'] ?? '打开供给工作台'), + 'lead_host' => '', + 'tag_type' => 'warning', + ]; + + return $queue; + } + + foreach ($businessEntryGateStack as $gate) { + $gate = (array)$gate; + if (!(bool)($gate['blocking'] ?? false)) { + continue; + } + $queue[] = [ + 'index' => count($queue) + 1, + 'key' => 'review_' . (string)($gate['key'] ?? ''), + 'label' => '回看 ' . (string)($gate['label'] ?? '关卡'), + 'status' => empty($queue) ? 'current' : 'upcoming', + 'status_label' => empty($queue) ? '当前动作' : '后续动作', + 'summary' => (string)($gate['summary'] ?? '当前需先回看入口关卡。'), + 'action' => (string)($gate['action'] ?? ''), + 'action_label' => (string)($gate['action_label'] ?? '回看当前关卡'), + 'lead_host' => (string)($gate['lead_host'] ?? ''), + 'tag_type' => (string)($gate['tag_type'] ?? 'danger'), + ]; + } + + if (!empty($businessTakeoverQueue)) { + $leadQueue = (array)$businessTakeoverQueue[0]; + $queue[] = [ + 'index' => count($queue) + 1, + 'key' => 'host_takeover', + 'label' => '接手 Host', + 'status' => empty($queue) ? 'current' : 'upcoming', + 'status_label' => empty($queue) ? '当前动作' : '后续动作', + 'summary' => '当前批次已可进入 Host 接手,建议先从 ' . ((string)($leadQueue['host'] ?? $leadHost ?: '-')) . ' 开始。', + 'action' => 'bucket-supply', + 'action_label' => '接手首个 Host', + 'lead_host' => (string)($leadQueue['host'] ?? $leadHost), + 'tag_type' => ($strategyMode === 'host_takeover') ? 'warning' : 'info', + ]; + } + + foreach ($projectedMainlineRoute as $stage) { + $stage = (array)$stage; + $stageKey = (string)($stage['stage'] ?? ''); + if (!in_array($stageKey, ['ops_execution', 'completed'], true)) { + continue; + } + $queue[] = [ + 'index' => count($queue) + 1, + 'key' => 'projected_' . $stageKey, + 'label' => (string)($stage['stage_label'] ?? $stageKey), + 'status' => empty($queue) ? 'current' : 'upcoming', + 'status_label' => empty($queue) ? '当前动作' : '后续动作', + 'summary' => (string)($stage['summary'] ?? ''), + 'action' => (string)($stage['primary_action'] ?? ''), + 'action_label' => (string)($stage['primary_action_label'] ?? '执行当前阶段'), + 'lead_host' => (string)($stage['lead_host'] ?? ''), + 'tag_type' => (string)($stage['tag_type'] ?? 'info'), + ]; + } + + return array_values(array_map(static function (array $item, int $index): array { + $item['index'] = $index + 1; + if (($item['status'] ?? '') !== 'current' && $index === 0) { + $item['status'] = 'current'; + $item['status_label'] = '当前动作'; + } + return $item; + }, $queue, array_keys($queue))); + } + + protected static function buildBusinessExecutionPolicy( + array $businessEntryStrategy, + array $businessActionCurrent, + array $businessActionNext + ): array { + $strategyMode = (string)($businessEntryStrategy['mode'] ?? ''); + $currentLabel = (string)($businessActionCurrent['label'] ?? ''); + $nextLabel = (string)($businessActionNext['label'] ?? ''); + + if ($strategyMode === 'bootstrap') { + return [ + 'mode' => 'bootstrap_first', + 'label' => '先发起再编排', + 'summary' => '当前还没有形成可接手批次,需先发起供给,再进入 review / Host 接手。', + 'tag_type' => 'warning', + 'review_required' => false, + 'takeover_ready' => false, + 'ops_ready' => false, + 'primary_action' => (string)($businessEntryStrategy['primary_action'] ?? ''), + 'primary_action_label' => (string)($businessEntryStrategy['primary_action_label'] ?? '打开供给工作台'), + 'lead_host' => '', + 'current_action_label' => $currentLabel, + 'next_action_label' => $nextLabel, + ]; + } + + if ($strategyMode === 'review_gate') { + return [ + 'mode' => 'review_then_takeover', + 'label' => '先 Review 再接手', + 'summary' => '当前仍有 review blocker,需先回看任务/运行,再进入 Host 接手和 Ops 主线。', + 'tag_type' => 'danger', + 'review_required' => true, + 'takeover_ready' => false, + 'ops_ready' => false, + 'primary_action' => (string)($businessEntryStrategy['primary_action'] ?? ''), + 'primary_action_label' => (string)($businessEntryStrategy['primary_action_label'] ?? '先回看任务链'), + 'lead_host' => (string)($businessEntryStrategy['lead_host'] ?? ''), + 'current_action_label' => $currentLabel, + 'next_action_label' => $nextLabel, + ]; + } + + return [ + 'mode' => 'direct_takeover', + 'label' => '直接接手并推进 Ops', + 'summary' => '当前前置关卡已通过,可直接进入 Host 接手,并继续推进到 Ops 主线。', + 'tag_type' => 'success', + 'review_required' => false, + 'takeover_ready' => true, + 'ops_ready' => $nextLabel === '进入 Ops 主线', + 'primary_action' => (string)($businessEntryStrategy['primary_action'] ?? ''), + 'primary_action_label' => (string)($businessEntryStrategy['primary_action_label'] ?? '接手当前 Host'), + 'lead_host' => (string)($businessEntryStrategy['lead_host'] ?? ''), + 'current_action_label' => $currentLabel, + 'next_action_label' => $nextLabel, + ]; + } + + protected static function buildBusinessReviewPolicy( + array $businessEntryStrategy, + array $businessEntryGateStack, + array $businessEntryBlockers, + array $businessExecutionPolicy + ): array { + $strategyMode = (string)($businessEntryStrategy['mode'] ?? ''); + $blockingGateLabels = array_values(array_filter(array_map(static function (array $gate): string { + return (string)($gate['label'] ?? ''); + }, $businessEntryBlockers))); + $currentGate = (array)($businessEntryGateStack[0] ?? []); + + if ($strategyMode === 'bootstrap') { + return [ + 'mode' => 'bootstrap_pending', + 'label' => '供给未发起', + 'summary' => '当前还没有供给批次,暂时谈不上是否跳过 review,需要先发起供给。', + 'tag_type' => 'warning', + 'can_skip_review' => false, + 'review_required' => false, + 'blockers_count' => count($businessEntryBlockers), + 'blocking_gate_labels' => $blockingGateLabels, + 'primary_action' => (string)($businessEntryStrategy['primary_action'] ?? 'open-supply-dialog'), + 'primary_action_label' => (string)($businessEntryStrategy['primary_action_label'] ?? '打开供给工作台'), + 'next_after_review' => '', + 'lead_host' => '', + ]; + } + + if ($strategyMode === 'review_gate') { + return [ + 'mode' => 'review_required', + 'label' => '必须先 Review', + 'summary' => '当前仍有 review blocker,不能跳过 review,需先回看任务/运行关卡。', + 'tag_type' => 'danger', + 'can_skip_review' => false, + 'review_required' => true, + 'blockers_count' => count($businessEntryBlockers), + 'blocking_gate_labels' => $blockingGateLabels, + 'primary_action' => (string)($businessExecutionPolicy['primary_action'] ?? $businessEntryStrategy['primary_action'] ?? ''), + 'primary_action_label' => (string)($businessExecutionPolicy['primary_action_label'] ?? $businessEntryStrategy['primary_action_label'] ?? '先回看任务链'), + 'next_after_review' => (string)($businessExecutionPolicy['next_action_label'] ?? ''), + 'lead_host' => (string)($businessExecutionPolicy['lead_host'] ?? $businessEntryStrategy['lead_host'] ?? ''), + ]; + } + + return [ + 'mode' => 'skip_review', + 'label' => '允许跳过 Review', + 'summary' => '当前前置 review gate 已通过,可以直接进入 Host 接手,并继续推进到 Ops 主线。', + 'tag_type' => 'success', + 'can_skip_review' => true, + 'review_required' => false, + 'blockers_count' => count($businessEntryBlockers), + 'blocking_gate_labels' => $blockingGateLabels, + 'primary_action' => (string)($businessExecutionPolicy['primary_action'] ?? ''), + 'primary_action_label' => (string)($businessExecutionPolicy['primary_action_label'] ?? '接手当前 Host'), + 'next_after_review' => (string)($businessExecutionPolicy['next_action_label'] ?? ''), + 'lead_host' => (string)($businessExecutionPolicy['lead_host'] ?? ($currentGate['lead_host'] ?? '')), + ]; + } + + protected static function buildBusinessControlTower( + array $phase, + array $mainlinePlan, + array $handoffPlan, + array $businessEntryRule, + array $businessEntryStrategy, + array $businessExecutionPolicy, + array $businessReviewPolicy, + array $businessActionQueue, + array $businessActionCurrent, + array $businessActionNext, + array $businessEntryCurrentGate, + array $businessEntryBlockers, + array $businessTakeoverQueue + ): array { + $leadHost = (string)($businessExecutionPolicy['lead_host'] ?? $businessReviewPolicy['lead_host'] ?? $businessEntryRule['lead_host'] ?? ''); + $blockingGateLabels = array_values(array_filter(array_map(static function (array $gate): string { + return (string)($gate['label'] ?? ''); + }, $businessEntryBlockers))); + + $primaryAction = (string)( + (($businessReviewPolicy['review_required'] ?? false) + ? ($businessReviewPolicy['primary_action'] ?? '') + : ($businessExecutionPolicy['primary_action'] ?? '')) + ?: ($businessEntryStrategy['primary_action'] ?? '') + ); + $primaryActionLabel = (string)( + (($businessReviewPolicy['review_required'] ?? false) + ? ($businessReviewPolicy['primary_action_label'] ?? '') + : ($businessExecutionPolicy['primary_action_label'] ?? '')) + ?: ($businessEntryStrategy['primary_action_label'] ?? '') + ?: '执行当前主建议' + ); + + $secondaryAction = ''; + $secondaryActionLabel = ''; + if ((string)($businessActionCurrent['action'] ?? '') !== '' && (string)($businessActionCurrent['action'] ?? '') !== $primaryAction) { + $secondaryAction = (string)($businessActionCurrent['action'] ?? ''); + $secondaryActionLabel = (string)($businessActionCurrent['action_label'] ?? '执行当前动作'); + } elseif ((string)($businessActionNext['action'] ?? '') !== '' && (string)($businessActionNext['action'] ?? '') !== $primaryAction) { + $secondaryAction = (string)($businessActionNext['action'] ?? ''); + $secondaryActionLabel = (string)($businessActionNext['action_label'] ?? '执行下一动作'); + } + + $tertiaryAction = ''; + $tertiaryActionLabel = ''; + if ((string)($businessActionNext['action'] ?? '') !== '' && (string)($businessActionNext['action'] ?? '') !== $primaryAction && (string)($businessActionNext['action'] ?? '') !== $secondaryAction) { + $tertiaryAction = (string)($businessActionNext['action'] ?? ''); + $tertiaryActionLabel = (string)($businessActionNext['action_label'] ?? '执行下一动作'); + } + + $label = (string)($businessExecutionPolicy['label'] ?? $businessEntryStrategy['label'] ?? $businessEntryRule['label'] ?? '待判断'); + $summary = (string)($businessExecutionPolicy['summary'] ?? $businessReviewPolicy['summary'] ?? $businessEntryStrategy['summary'] ?? $businessEntryRule['summary'] ?? '当前还没有形成明确的业务承接结论。'); + + return [ + 'key' => (string)($businessExecutionPolicy['mode'] ?? $businessEntryStrategy['mode'] ?? $businessEntryRule['mode'] ?? 'pending'), + 'label' => $label, + 'summary' => $summary, + 'tag_type' => (string)($businessExecutionPolicy['tag_type'] ?? $businessReviewPolicy['tag_type'] ?? $businessEntryStrategy['tag_type'] ?? $businessEntryRule['tag_type'] ?? 'warning'), + 'phase_label' => (string)($phase['label'] ?? ''), + 'mainline_label' => (string)($mainlinePlan['current_stage_label'] ?? ''), + 'current_focus_label' => (string)($handoffPlan['current_focus_label'] ?? ''), + 'strategy_label' => (string)($businessEntryStrategy['label'] ?? ''), + 'execution_label' => (string)($businessExecutionPolicy['label'] ?? ''), + 'review_label' => (string)($businessReviewPolicy['label'] ?? ''), + 'review_required' => (bool)($businessReviewPolicy['review_required'] ?? $businessExecutionPolicy['review_required'] ?? false), + 'can_skip_review' => (bool)($businessReviewPolicy['can_skip_review'] ?? false), + 'blockers_count' => count($businessEntryBlockers), + 'blocking_gate_labels' => $blockingGateLabels, + 'current_gate_label' => (string)($businessEntryCurrentGate['label'] ?? ''), + 'queue_count' => (int)($businessEntryRule['queue_count'] ?? count($businessTakeoverQueue)), + 'current_action_label' => (string)($businessActionCurrent['label'] ?? $businessExecutionPolicy['current_action_label'] ?? ''), + 'next_action_label' => (string)($businessActionNext['label'] ?? $businessExecutionPolicy['next_action_label'] ?? ''), + 'lead_host' => $leadHost, + 'primary_action' => $primaryAction, + 'primary_action_label' => $primaryActionLabel, + 'secondary_action' => $secondaryAction, + 'secondary_action_label' => $secondaryActionLabel, + 'tertiary_action' => $tertiaryAction, + 'tertiary_action_label' => $tertiaryActionLabel, + 'actions_count' => count($businessActionQueue), + ]; + } + + protected static function buildMainlineControlTower( + array $latestRun, + array $latestFailedHost, + array $latestTask, + array $latestOrchestrationTask, + array $orchestrationSummary + ): array { + $businessControlTower = (array)($orchestrationSummary['business_control_tower'] ?? []); + $mainlinePlan = (array)($orchestrationSummary['mainline_plan'] ?? []); + $dispatchPlan = (array)($orchestrationSummary['dispatch_plan'] ?? []); + $phase = (array)($orchestrationSummary['phase'] ?? []); + + $latestOrchestrationStatus = strtolower((string)($latestOrchestrationTask['status'] ?? '')); + $latestTaskStatus = strtolower((string)($latestTask['status'] ?? '')); + $latestRunStatus = strtolower((string)($latestRun['status'] ?? '')); + + $source = 'business_takeover'; + $label = (string)($businessControlTower['label'] ?? '待判断'); + $summary = (string)($businessControlTower['summary'] ?? '当前还没有形成明确的供给主线判断。'); + $tagType = (string)($businessControlTower['tag_type'] ?? 'warning'); + $primaryAction = (string)($businessControlTower['primary_action'] ?? ''); + $primaryActionLabel = (string)($businessControlTower['primary_action_label'] ?? '执行当前主建议'); + $secondaryAction = (string)($businessControlTower['secondary_action'] ?? ''); + $secondaryActionLabel = (string)($businessControlTower['secondary_action_label'] ?? '执行下一动作'); + $leadHost = (string)($businessControlTower['lead_host'] ?? ''); + $sourceLabel = '业务接手'; + $sourceSummary = '当前这批供给结果已经进入业务接手阶段,可直接按主建议推进。'; + + if (($latestOrchestrationTask['task_slug'] ?? '') !== '' && $latestOrchestrationStatus !== '' && $latestOrchestrationStatus !== 'success') { + $source = 'orchestration_task'; + $sourceLabel = '编排任务'; + $label = '先回看供给编排任务'; + $summary = (string)($latestOrchestrationTask['summary'] ?? '最近供给编排任务未收口,建议先回看任务结果。'); + $tagType = 'danger'; + $primaryAction = 'latest-supply-orchestration-task'; + $primaryActionLabel = '查看供给编排任务'; + $secondaryAction = ''; + $secondaryActionLabel = ''; + $sourceSummary = '最近编排任务未收口,当前应先回看 orchestration task。'; + } elseif (($latestTask['task_slug'] ?? '') !== '' && $latestTaskStatus !== '' && $latestTaskStatus !== 'success') { + $source = 'supply_task'; + $sourceLabel = '供给调度任务'; + $label = '先回看供给调度任务'; + $summary = (string)($latestTask['summary'] ?? '最近供给调度任务未收口,建议先回看任务结果。'); + $tagType = 'danger'; + $primaryAction = 'latest-supply-task'; + $primaryActionLabel = '查看供给调度任务'; + $secondaryAction = ''; + $secondaryActionLabel = ''; + $sourceSummary = '最近供给调度任务未收口,当前应先回看 supply task。'; + } elseif (($latestRun['run_id'] ?? '') !== '' && !in_array($latestRunStatus, ['', 'success'], true)) { + $source = 'supply_run'; + $sourceLabel = '供给运行'; + $label = '先回看供给运行'; + $summary = '最近供给运行 ' . (string)($latestRun['run_id'] ?? '-') . ' 仍需回看,再决定是否进入 Host 接手。'; + $tagType = ((int)($latestRun['failed_count'] ?? 0) > 0) ? 'danger' : 'warning'; + $primaryAction = 'latest-supply-run'; + $primaryActionLabel = '查看供给运行'; + $secondaryAction = ''; + $secondaryActionLabel = ''; + $sourceSummary = '最近 supply run 还未完全收口,当前应先回看运行记录。'; + } + + return [ + 'source' => $source, + 'source_label' => $sourceLabel, + 'source_summary' => $sourceSummary, + 'label' => $label, + 'summary' => $summary, + 'tag_type' => $tagType, + 'phase_label' => (string)($phase['label'] ?? ''), + 'mainline_label' => (string)($mainlinePlan['current_stage_label'] ?? ''), + 'dispatch_label' => (string)($dispatchPlan['current_lane_label'] ?? ''), + 'latest_run_id' => (string)($latestRun['run_id'] ?? ''), + 'lead_host' => $leadHost, + 'failed_host' => (string)($latestFailedHost['host'] ?? ''), + 'review_required' => (bool)($businessControlTower['review_required'] ?? false), + 'can_skip_review' => (bool)($businessControlTower['can_skip_review'] ?? false), + 'blockers_count' => (int)($businessControlTower['blockers_count'] ?? 0), + 'queue_count' => (int)($businessControlTower['queue_count'] ?? 0), + 'current_action_label' => (string)($businessControlTower['current_action_label'] ?? ''), + 'next_action_label' => (string)($businessControlTower['next_action_label'] ?? ''), + 'primary_action' => $primaryAction, + 'primary_action_label' => $primaryActionLabel, + 'secondary_action' => $secondaryAction, + 'secondary_action_label' => $secondaryActionLabel, + ]; + } + + protected static function buildMainlineWorkbench( + array $latestRun, + array $latestFailedHost, + array $latestTask, + array $latestOrchestrationTask, + array $mainlineControlTower, + array $orchestrationSummary + ): array { + $businessControlTower = (array)($orchestrationSummary['business_control_tower'] ?? []); + $phase = (array)($orchestrationSummary['phase'] ?? []); + $mainlinePlan = (array)($orchestrationSummary['mainline_plan'] ?? []); + $dispatchPlan = (array)($orchestrationSummary['dispatch_plan'] ?? []); + + $focusKind = 'business_takeover'; + $focusLabel = '业务接手'; + $label = (string)($mainlineControlTower['label'] ?? '待判断'); + $title = '当前供给主线工作台'; + $summary = (string)($mainlineControlTower['summary'] ?? '当前还没有形成明确的供给主线判断。'); + $tagType = (string)($mainlineControlTower['tag_type'] ?? 'warning'); + $primaryAction = (string)($mainlineControlTower['primary_action'] ?? ''); + $primaryActionLabel = (string)($mainlineControlTower['primary_action_label'] ?? '执行当前主线动作'); + $secondaryAction = (string)($mainlineControlTower['secondary_action'] ?? ''); + $secondaryActionLabel = (string)($mainlineControlTower['secondary_action_label'] ?? '执行下一主线动作'); + $tertiaryAction = ''; + $tertiaryActionLabel = ''; + $focusSummary = (string)($mainlineControlTower['source_summary'] ?? ''); + + $latestOrchestrationStatus = strtolower((string)($latestOrchestrationTask['status'] ?? '')); + $latestTaskStatus = strtolower((string)($latestTask['status'] ?? '')); + $latestRunStatus = strtolower((string)($latestRun['status'] ?? '')); + + if (($latestOrchestrationTask['task_slug'] ?? '') !== '' && $latestOrchestrationStatus !== '' && $latestOrchestrationStatus !== 'success') { + $focusKind = 'orchestration_task'; + $focusLabel = '供给编排任务'; + $label = '先回看供给编排任务'; + $summary = (string)($latestOrchestrationTask['summary'] ?? '最近供给编排任务未收口,建议先确认编排结论。'); + $tagType = 'danger'; + $primaryAction = 'latest-supply-orchestration-task'; + $primaryActionLabel = '查看供给编排任务'; + $secondaryAction = ($latestRun['run_id'] ?? '') !== '' ? 'latest-supply-run' : ''; + $secondaryActionLabel = $secondaryAction !== '' ? '查看最近供给运行' : ''; + $tertiaryAction = ($latestTask['task_slug'] ?? '') !== '' ? 'latest-supply-task' : ''; + $tertiaryActionLabel = $tertiaryAction !== '' ? '查看供给调度任务' : ''; + $focusSummary = '当前最高优先级是先回看 orchestration task,再决定是否继续进入 run 或 host 接手。'; + } elseif (($latestTask['task_slug'] ?? '') !== '' && $latestTaskStatus !== '' && $latestTaskStatus !== 'success') { + $focusKind = 'supply_task'; + $focusLabel = '供给调度任务'; + $label = '先回看供给调度任务'; + $summary = (string)($latestTask['summary'] ?? '最近供给调度任务未收口,建议先确认任务执行结果。'); + $tagType = 'danger'; + $primaryAction = 'latest-supply-task'; + $primaryActionLabel = '查看供给调度任务'; + $secondaryAction = ($latestRun['run_id'] ?? '') !== '' ? 'latest-supply-run' : ''; + $secondaryActionLabel = $secondaryAction !== '' ? '查看最近供给运行' : ''; + $tertiaryAction = ($latestOrchestrationTask['task_slug'] ?? '') !== '' ? 'latest-supply-orchestration-task' : ''; + $tertiaryActionLabel = $tertiaryAction !== '' ? '查看供给编排任务' : ''; + $focusSummary = '当前最高优先级是先回看 supply task,确认这一批供给是否已经稳定落盘。'; + } elseif (($latestRun['run_id'] ?? '') !== '' && !in_array($latestRunStatus, ['', 'success'], true)) { + $focusKind = 'supply_run'; + $focusLabel = '供给运行'; + $label = '先回看供给运行'; + $summary = '最近供给运行 ' . (string)($latestRun['run_id'] ?? '-') . ' 仍需回看,再决定是否进入 Host 接手。'; + $tagType = ((int)($latestRun['failed_count'] ?? 0) > 0) ? 'danger' : 'warning'; + $primaryAction = 'latest-supply-run'; + $primaryActionLabel = '查看供给运行'; + $secondaryAction = ($mainlineControlTower['primary_action'] ?? '') !== '' ? (string)$mainlineControlTower['primary_action'] : ''; + $secondaryActionLabel = $secondaryAction !== '' ? (string)($mainlineControlTower['primary_action_label'] ?? '执行当前主线动作') : ''; + $focusSummary = '当前最高优先级是先回看 supply run,确认运行结果后再进入 Host 接手。'; + } + + return [ + 'focus_kind' => $focusKind, + 'focus_label' => $focusLabel, + 'focus_summary' => $focusSummary, + 'label' => $label, + 'title' => $title, + 'summary' => $summary, + 'tag_type' => $tagType, + 'source_label' => (string)($mainlineControlTower['source_label'] ?? $focusLabel), + 'phase_label' => (string)($mainlineControlTower['phase_label'] ?? ($phase['label'] ?? '')), + 'mainline_label' => (string)($mainlineControlTower['mainline_label'] ?? ($mainlinePlan['current_stage_label'] ?? '')), + 'dispatch_label' => (string)($mainlineControlTower['dispatch_label'] ?? ($dispatchPlan['current_lane_label'] ?? '')), + 'review_label' => (string)($businessControlTower['review_label'] ?? ''), + 'execution_label' => (string)($businessControlTower['execution_label'] ?? ''), + 'current_action_label' => (string)($mainlineControlTower['current_action_label'] ?? ''), + 'next_action_label' => (string)($mainlineControlTower['next_action_label'] ?? ''), + 'latest_run_id' => (string)($latestRun['run_id'] ?? ''), + 'lead_host' => (string)($mainlineControlTower['lead_host'] ?? ''), + 'failed_host' => (string)($latestFailedHost['host'] ?? ''), + 'queue_count' => (int)($mainlineControlTower['queue_count'] ?? 0), + 'blockers_count' => (int)($mainlineControlTower['blockers_count'] ?? 0), + 'hosts_count' => (int)($orchestrationSummary['hosts_count'] ?? 0), + 'failed_hosts_count' => !empty($latestFailedHost) ? 1 : 0, + 'review_required' => (bool)($mainlineControlTower['review_required'] ?? false), + 'can_skip_review' => (bool)($mainlineControlTower['can_skip_review'] ?? false), + 'primary_action' => $primaryAction, + 'primary_action_label' => $primaryActionLabel, + 'secondary_action' => $secondaryAction, + 'secondary_action_label' => $secondaryActionLabel, + 'tertiary_action' => $tertiaryAction, + 'tertiary_action_label' => $tertiaryActionLabel, + ]; + } + + protected static function buildMainlineEntry( + array $latestRun, + array $latestFailedHost, + array $latestTask, + array $latestOrchestrationTask, + array $mainlineControlTower, + array $mainlineWorkbench + ): array { + $latestOrchestrationStatus = strtolower((string)($latestOrchestrationTask['status'] ?? '')); + $latestTaskStatus = strtolower((string)($latestTask['status'] ?? '')); + $latestRunStatus = strtolower((string)($latestRun['status'] ?? '')); + + $entryKind = 'business_takeover'; + $entryLabel = '进入供给主线'; + $title = '当前供给 / 繁殖主线入口'; + $summary = (string)($mainlineWorkbench['summary'] ?? $mainlineControlTower['summary'] ?? '当前还没有形成明确的供给主线入口。'); + $tagType = (string)($mainlineWorkbench['tag_type'] ?? $mainlineControlTower['tag_type'] ?? 'warning'); + $focusLabel = (string)($mainlineWorkbench['focus_label'] ?? '业务接手'); + $focusSummary = (string)($mainlineWorkbench['focus_summary'] ?? ''); + $primaryAction = (string)($mainlineWorkbench['primary_action'] ?? ''); + $primaryActionLabel = (string)($mainlineWorkbench['primary_action_label'] ?? '执行当前入口动作'); + $secondaryAction = (string)($mainlineWorkbench['secondary_action'] ?? ''); + $secondaryActionLabel = (string)($mainlineWorkbench['secondary_action_label'] ?? '执行下一入口动作'); + $leadHost = (string)($mainlineWorkbench['lead_host'] ?? $mainlineControlTower['lead_host'] ?? ''); + + if (($latestOrchestrationTask['task_slug'] ?? '') !== '' && $latestOrchestrationStatus !== '' && $latestOrchestrationStatus !== 'success') { + $entryKind = 'orchestration_task'; + $entryLabel = '先回看供给编排任务'; + $summary = (string)($latestOrchestrationTask['summary'] ?? '最近供给编排任务未收口,当前主线入口应先停在 orchestration task。'); + $tagType = 'danger'; + $focusLabel = '供给编排任务'; + $focusSummary = '当前不建议直接继续接 Host,先把最新 orchestration task 看完再继续。'; + $primaryAction = 'latest-supply-orchestration-task'; + $primaryActionLabel = '查看供给编排任务'; + $secondaryAction = ($latestRun['run_id'] ?? '') !== '' ? 'latest-supply-run' : ''; + $secondaryActionLabel = $secondaryAction !== '' ? '再看最近供给运行' : ''; + } elseif (($latestTask['task_slug'] ?? '') !== '' && $latestTaskStatus !== '' && $latestTaskStatus !== 'success') { + $entryKind = 'supply_task'; + $entryLabel = '先回看供给调度任务'; + $summary = (string)($latestTask['summary'] ?? '最近供给调度任务未收口,当前主线入口应先停在 supply task。'); + $tagType = 'danger'; + $focusLabel = '供给调度任务'; + $focusSummary = '当前不建议直接继续接 Host,先把最新 supply task 看完再继续。'; + $primaryAction = 'latest-supply-task'; + $primaryActionLabel = '查看供给调度任务'; + $secondaryAction = ($latestRun['run_id'] ?? '') !== '' ? 'latest-supply-run' : ''; + $secondaryActionLabel = $secondaryAction !== '' ? '再看最近供给运行' : ''; + } elseif (($latestRun['run_id'] ?? '') !== '' && !in_array($latestRunStatus, ['', 'success'], true)) { + $entryKind = 'supply_run'; + $entryLabel = '先回看供给运行'; + $summary = '最近供给运行 ' . (string)($latestRun['run_id'] ?? '-') . ' 还需要回看,当前主线入口建议先确认运行结果。'; + $tagType = ((int)($latestRun['failed_count'] ?? 0) > 0) ? 'danger' : 'warning'; + $focusLabel = '供给运行'; + $focusSummary = '当前先回看 run,再决定是否继续进入 Host 接手。'; + $primaryAction = 'latest-supply-run'; + $primaryActionLabel = '查看供给运行'; + $secondaryAction = ($mainlineWorkbench['primary_action'] ?? '') !== '' ? (string)$mainlineWorkbench['primary_action'] : ''; + $secondaryActionLabel = $secondaryAction !== '' ? (string)($mainlineWorkbench['primary_action_label'] ?? '执行主线动作') : ''; + } + + return [ + 'entry_kind' => $entryKind, + 'label' => $entryLabel, + 'title' => $title, + 'summary' => $summary, + 'tag_type' => $tagType, + 'focus_label' => $focusLabel, + 'focus_summary' => $focusSummary, + 'source_label' => (string)($mainlineWorkbench['source_label'] ?? $mainlineControlTower['source_label'] ?? $focusLabel), + 'phase_label' => (string)($mainlineWorkbench['phase_label'] ?? ''), + 'mainline_label' => (string)($mainlineWorkbench['mainline_label'] ?? ''), + 'dispatch_label' => (string)($mainlineWorkbench['dispatch_label'] ?? ''), + 'current_action_label' => (string)($mainlineWorkbench['current_action_label'] ?? ''), + 'next_action_label' => (string)($mainlineWorkbench['next_action_label'] ?? ''), + 'latest_run_id' => (string)($latestRun['run_id'] ?? ''), + 'lead_host' => $leadHost, + 'failed_host' => (string)($latestFailedHost['host'] ?? ''), + 'queue_count' => (int)($mainlineWorkbench['queue_count'] ?? 0), + 'blockers_count' => (int)($mainlineWorkbench['blockers_count'] ?? 0), + 'review_required' => (bool)($mainlineWorkbench['review_required'] ?? false), + 'can_skip_review' => (bool)($mainlineWorkbench['can_skip_review'] ?? false), + 'orchestration_task_status' => (string)($latestOrchestrationTask['status'] ?? ''), + 'supply_task_status' => (string)($latestTask['status'] ?? ''), + 'run_status' => (string)($latestRun['status'] ?? ''), + 'primary_action' => $primaryAction, + 'primary_action_label' => $primaryActionLabel, + 'secondary_action' => $secondaryAction, + 'secondary_action_label' => $secondaryActionLabel, + ]; + } + + protected static function resolveCurrentBusinessStageForHost(array $hostSummary): string + { + $bucketKey = (string)($hostSummary['orchestration_bucket'] ?? 'supply'); + return match ($bucketKey) { + 'attention', 'oncall', 'bundle' => 'ops_execution', + 'completed' => 'completed', + default => 'host_takeover', + }; + } + + protected static function mapStepToMainlineStage(array $step): array + { + $action = (string)($step['action'] ?? ''); + $bucket = (string)($step['bucket_key'] ?? ''); + + if ($action === 'open-supply-dialog') { + return [ + 'stage' => 'bootstrap', + 'stage_label' => '发起供给', + 'summary' => (string)($step['summary'] ?? '当前先发起供给。'), + ]; + } + + if (in_array($action, ['latest-supply-orchestration-task', 'latest-supply-task', 'latest-supply-run'], true)) { + return [ + 'stage' => 'review', + 'stage_label' => '回看任务链', + 'summary' => (string)($step['summary'] ?? '当前先回看 supply 的 task / run。'), + ]; + } + + if ($bucket === 'completed') { + return [ + 'stage' => 'completed', + 'stage_label' => '已完成回看', + 'summary' => (string)($step['summary'] ?? '当前这批结果已进入已完成主线。'), + ]; + } + + if (in_array($bucket, ['attention', 'oncall', 'bundle'], true)) { + return [ + 'stage' => 'ops_execution', + 'stage_label' => '进入 Ops 主线', + 'summary' => (string)($step['summary'] ?? '当前开始进入 Ops 主线推进。'), + ]; + } + + return [ + 'stage' => 'host_takeover', + 'stage_label' => 'Host 接手', + 'summary' => (string)($step['summary'] ?? '当前先接手 Host 主线。'), + ]; + } + + protected static function resolveOpsFocusForHost(string $host, array $opsSummary, array $hostSummary): array + { + if ($host === '') { + return [ + 'panel' => 'queue', + 'host_console_panel' => 'bundle', + 'inspector_kind' => 'bundle', + 'bucket' => 'bundle', + ]; + } + + $completedEntries = (array)($opsSummary['bootstrap_completed_portal']['entries'] + ?? $opsSummary['bootstrap_completed_portal']['top_entries'] + ?? []); + foreach ($completedEntries as $entry) { + if ((string)($entry['host'] ?? '') === $host) { + return [ + 'panel' => 'completed', + 'host_console_panel' => 'completed', + 'inspector_kind' => 'completed', + 'bucket' => 'completed', + ]; + } + } + + $oncallEntries = (array)($opsSummary['bootstrap_oncall_portal']['top_entries'] ?? []); + foreach ($oncallEntries as $entry) { + if ((string)($entry['host'] ?? '') === $host) { + return [ + 'panel' => 'oncall', + 'host_console_panel' => 'oncall', + 'inspector_kind' => 'oncall', + 'bucket' => 'oncall', + ]; + } + } + + $runEntries = (array)($opsSummary['bootstrap_runs_portal']['runs'] ?? []); + foreach ($runEntries as $entry) { + if ((string)($entry['host'] ?? '') === $host && (string)($entry['overall_status'] ?? '') === 'failed') { + return [ + 'panel' => 'attention', + 'host_console_panel' => 'runs', + 'inspector_kind' => 'failed_run', + 'bucket' => 'attention', + ]; + } + } + + $bundleEntries = (array)($opsSummary['bootstrap_portal']['bundles'] ?? []); + foreach ($bundleEntries as $entry) { + if ((string)($entry['host'] ?? '') === $host && (string)($entry['handoff_stage_code'] ?? '') === 'attention') { + return [ + 'panel' => 'attention', + 'host_console_panel' => 'runs', + 'inspector_kind' => 'attention_bundle', + 'bucket' => 'attention', + ]; + } + } + foreach ($bundleEntries as $entry) { + if ((string)($entry['host'] ?? '') === $host) { + return [ + 'panel' => 'queue', + 'host_console_panel' => 'bundle', + 'inspector_kind' => 'bundle', + 'bucket' => 'bundle', + ]; + } + } + + $failed = (int)($hostSummary['failed_count'] ?? 0) > 0; + if ($failed) { + return [ + 'panel' => 'attention', + 'host_console_panel' => 'supply', + 'inspector_kind' => 'supply', + 'bucket' => 'attention', + ]; + } + + return [ + 'panel' => 'queue', + 'host_console_panel' => 'supply', + 'inspector_kind' => 'supply', + 'bucket' => 'supply', + ]; + } + + protected static function firstMatched(string $pattern): string + { + $matches = (array)glob($pattern); + return $matches[0] ?? ''; + } + + protected static function publicRelativePath(string $path): string + { + $path = str_replace('\\', '/', trim($path)); + if ($path === '') { + return ''; + } + + $publicRoot = str_replace('\\', '/', dirname(__DIR__, 2) . '/public/'); + if (str_starts_with($path, $publicRoot)) { + return substr($path, strlen($publicRoot)); + } + + return $path; + } +} diff --git a/code/app/common/helper/SeoCopyAiProviderHelper.php b/code/app/common/helper/SeoCopyAiProviderHelper.php new file mode 100644 index 0000000..2d08b9f --- /dev/null +++ b/code/app/common/helper/SeoCopyAiProviderHelper.php @@ -0,0 +1,1404 @@ + $strProvider, + 'configured' => $boolConfigured, + 'label' => $strResolvedLabel, + 'model' => $strModel, + 'base_url' => $strBaseUrl, + 'api_key' => $strApiKey, + 'timeout_ms' => $intTimeoutMs, + 'fallback_provider' => DomainModel::TKD_PROVIDER_LOCAL, + 'protocol' => $boolOpenAiCompatible ? 'openai_responses' : 'local_rules', + 'openai_compatible' => $boolOpenAiCompatible ? 1 : 0, + 'supported_providers' => [ + [ + 'key' => DomainModel::TKD_PROVIDER_LOCAL, + 'label' => '本地增强', + 'protocol' => 'local_rules', + ], + [ + 'key' => DomainModel::TKD_PROVIDER_OPENAI, + 'label' => 'OpenAI / OpenAI兼容接口', + 'protocol' => 'openai_responses', + ], + ], + 'env_keys' => [ + 'provider' => ['SEO_COPY_AI_PROVIDER'], + 'model' => ['SEO_COPY_AI_MODEL', 'OPENAI_MODEL'], + 'base_url' => ['SEO_COPY_AI_BASE_URL', 'OPENAI_BASE_URL'], + 'api_key' => ['SEO_COPY_AI_API_KEY', 'OPENAI_API_KEY'], + 'timeout_ms' => ['SEO_COPY_AI_TIMEOUT_MS'], + ], + 'missing_fields' => $arrMissing, + 'config_note' => $boolOpenAiCompatible + ? '当前 provider 走 OpenAI Responses 协议,也兼容多数 OpenAI 风格接口厂商。' + : '当前 provider 走本地增强规则,适合未配置远程 AI 时稳定兜底。', + ]; + } + + public static function providerLabel(string $strProvider): string + { + return match (DomainModel::normalizeTkdProvider($strProvider)) { + DomainModel::TKD_PROVIDER_OPENAI => 'OpenAI', + default => '本地增强', + }; + } + + public static function generatePublishedCopy(DomainModel $DomainModel, array $arrOptions = []): array + { + $strHost = DomainModel::normalizeHost((string)($DomainModel->d_domain ?? '')); + if ($strHost === '') { + throw new \RuntimeException('缺少有效域名,无法生成文案'); + } + + $arrProviderCfg = self::resolveProviderConfig((string)($arrOptions['provider'] ?? (($DomainModel->getCopyGenerationSeoCfg()['ai_provider'] ?? DomainModel::TKD_PROVIDER_LOCAL)))); + $strEffectiveProvider = !empty($arrProviderCfg['configured']) + ? (string)$arrProviderCfg['provider'] + : (string)$arrProviderCfg['fallback_provider']; + $intSeed = abs((int)crc32($strHost)); + $boolOptimize = !empty($arrOptions['optimize']); + $arrAiBundle = []; + if ($strEffectiveProvider === DomainModel::TKD_PROVIDER_OPENAI && !empty($arrProviderCfg['configured'])) { + try { + $arrAiBundle = self::requestOpenAiCopyBundle($DomainModel, $arrProviderCfg, $boolOptimize); + } catch (\Throwable $throwable) { + $arrAiBundle = [ + '_error' => $throwable->getMessage(), + ]; + } + } + + $arrHome = self::mergeAiPayload( + self::buildHomePayload($DomainModel, $intSeed, $strEffectiveProvider), + (array)($arrAiBundle['home'] ?? []) + ); + $arrCategoryIndex = self::mergeAiPayload( + self::buildCategoryIndexPayload($DomainModel, $intSeed, $strEffectiveProvider), + (array)($arrAiBundle['category_index'] ?? []) + ); + $arrCategoryIndex = self::finalizeCollectionPayload('category_index', $arrCategoryIndex, $intSeed); + $arrCategoryList = self::mergeAiPayload( + self::buildCategoryListPayload($DomainModel, $intSeed, $strEffectiveProvider), + (array)($arrAiBundle['category_list'] ?? []) + ); + $arrCategoryList = self::finalizeCollectionPayload('category_list', $arrCategoryList, $intSeed); + $arrSearch = self::mergeAiPayload( + self::buildSearchPayload($DomainModel, $intSeed, $strEffectiveProvider), + (array)($arrAiBundle['search'] ?? []) + ); + $arrSearch = self::finalizeCollectionPayload('search', $arrSearch, $intSeed); + $arrDetail = self::mergeAiPayload( + self::buildDetailDefaultPayload($DomainModel, $intSeed, $strEffectiveProvider), + (array)($arrAiBundle['detail'] ?? []) + ); + $arrPlay = self::mergeAiPayload( + self::buildPlayDefaultPayload($DomainModel, $intSeed, $strEffectiveProvider), + (array)($arrAiBundle['play'] ?? []) + ); + $arrDetail = self::finalizeDeepPayload('detail', $arrDetail, $intSeed); + $arrPlay = self::finalizeDeepPayload('play', $arrPlay, $intSeed); + + $strPublishedRoot = SeoCopyStore::publishedRoot(); + DomainImportAsyncJobHelper::ensureDir($strPublishedRoot); + + $arrWritten = []; + $arrPageDiffs = []; + $arrPages = [ + ['scene' => 'home', 'page_key' => 'index', 'payload' => $arrHome], + ['scene' => 'category_index', 'page_key' => 'index', 'payload' => $arrCategoryIndex], + ['scene' => 'category_list', 'page_key' => 'default', 'payload' => $arrCategoryList], + ['scene' => 'search', 'page_key' => 'landing', 'payload' => $arrSearch], + ['scene' => 'detail', 'page_key' => 'default', 'payload' => $arrDetail], + ['scene' => 'play', 'page_key' => 'default', 'payload' => $arrPlay], + ]; + + foreach ($arrPages as $arrPage) { + $strScene = (string)$arrPage['scene']; + $strPageKey = (string)$arrPage['page_key']; + $arrBeforePayload = SeoCopyStore::getPageDataFromRoot($strPublishedRoot, $strHost, $strScene, $strPageKey); + $strPath = SeoCopyStore::resolvePagePathFromRoot($strPublishedRoot, $strHost, (string)$arrPage['scene'], (string)$arrPage['page_key']); + if ($strPath === '') { + continue; + } + + DomainImportAsyncJobHelper::ensureDir(dirname($strPath)); + file_put_contents($strPath, json_encode($arrPage['payload'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + $arrAfterPayload = (array)$arrPage['payload']; + + $arrWritten[] = [ + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'path' => $strPath, + ]; + $arrPageDiffs[] = [ + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'before' => self::summarizePayload($arrBeforePayload), + 'after' => self::summarizePayload($arrAfterPayload), + 'changed_fields' => self::detectChangedFields($arrBeforePayload, $arrAfterPayload), + ]; + } + + return [ + 'host' => $strHost, + 'provider_requested' => (string)$arrProviderCfg['provider'], + 'provider_effective' => $strEffectiveProvider, + 'provider_label' => self::providerLabel($strEffectiveProvider), + 'configured' => !empty($arrProviderCfg['configured']), + 'model' => (string)($arrProviderCfg['model'] ?? ''), + 'published_root' => $strPublishedRoot, + 'written_pages' => $arrWritten, + 'page_diffs' => $arrPageDiffs, + 'ai_error' => (string)($arrAiBundle['_error'] ?? ''), + 'message' => !empty($arrProviderCfg['configured']) + ? ((string)($arrAiBundle['_error'] ?? '') !== '' + ? 'AI 请求失败,已按本地增强策略生成并发布文案。' + : '已生成并发布首页/分类/搜索/详情/播放页文案。') + : '当前 AI provider 未完成配置,已按本地增强策略生成并发布文案。', + ]; + } + + protected static function mergeAiPayload(array $arrBasePayload, array $arrAiPayload): array + { + foreach ($arrAiPayload as $strKey => $mValue) { + if (is_string($mValue)) { + $strValue = trim($mValue); + if ($strValue !== '') { + $arrBasePayload[$strKey] = $strValue; + } + continue; + } + + if (is_array($mValue) && !empty($mValue)) { + $arrBasePayload[$strKey] = $mValue; + } + } + + return $arrBasePayload; + } + + protected static function summarizePayload(array $arrPayload): array + { + $arrSummary = []; + $arrFields = [ + 'title', + 'keywords', + 'description', + 'intro_text', + 'intro_meta', + 'category_intro', + 'search_landing', + 'faq_content', + 'title_template', + 'keywords_template', + 'description_template', + 'detail_body_lead', + 'detail_play_link_lead', + 'detail_body_tail', + 'detail_faq', + 'play_intro', + 'play_meta_note', + 'play_body_lead', + 'play_body_next', + ]; + + foreach ($arrFields as $strField) { + $strValue = trim((string)($arrPayload[$strField] ?? '')); + if ($strValue !== '') { + $arrSummary[$strField] = $strValue; + } + } + + if (isset($arrPayload['guide_cards']) && is_array($arrPayload['guide_cards'])) { + $arrSummary['guide_cards'] = array_values(array_map(static function ($arrCard): array { + if (!is_array($arrCard)) { + return []; + } + return [ + 'title' => trim((string)($arrCard['title'] ?? '')), + 'text' => trim((string)($arrCard['text'] ?? '')), + ]; + }, $arrPayload['guide_cards'])); + } + + return $arrSummary; + } + + protected static function detectChangedFields(array $arrBeforePayload, array $arrAfterPayload): array + { + $arrBeforeSummary = self::summarizePayload($arrBeforePayload); + $arrAfterSummary = self::summarizePayload($arrAfterPayload); + $arrKeys = array_values(array_unique(array_merge(array_keys($arrBeforeSummary), array_keys($arrAfterSummary)))); + $arrChanged = []; + foreach ($arrKeys as $strKey) { + if (json_encode($arrBeforeSummary[$strKey] ?? null, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) === json_encode($arrAfterSummary[$strKey] ?? null, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) { + continue; + } + $arrChanged[] = $strKey; + } + + return $arrChanged; + } + + protected static function buildHomePayload(DomainModel $DomainModel, int $intSeed, string $strProvider): array + { + $strSiteName = trim((string)($DomainModel->d_name ?? '')); + $strHost = trim((string)($DomainModel->d_domain ?? '')); + $arrResourceContext = SeoCopyContextHelper::buildResourceContext($strHost); + $strHotKeyword = trim((string)($arrResourceContext['hot_keyword'] ?? '')); + $arrMeta = SeoCopyContextHelper::buildHomeMetaBundle( + $strSiteName, + $strHost, + (string)($DomainModel->d_index_title ?? ''), + (string)($DomainModel->d_index_keywords ?? ''), + (string)($DomainModel->d_index_description ?? ''), + [ + 'seed' => $intSeed, + 'tkd_mode' => DomainModel::TKD_MODE_AI_OPTIMIZE, + 'tkd_provider' => $strProvider, + 'primary_slot_label' => self::pickString(['更新区', '首页首屏', '推荐区'], $intSeed, 'home-slot'), + 'category_summary' => self::pickString(['电影、剧集、动漫', '综艺、短剧、电影', '电影、短剧、综艺'], $intSeed, 'home-category'), + ] + ); + + $strPrimaryPhrase = (string)($arrMeta['primary_phrase'] ?? '影视内容'); + $strSecondaryPhrase = (string)($arrMeta['secondary_phrase'] ?? '热门更新'); + + return [ + 'title' => (string)($arrMeta['title'] ?? ''), + 'keywords' => (string)($arrMeta['keywords'] ?? ''), + 'description' => (string)($arrMeta['description'] ?? ''), + 'intro_text' => self::pickString([ + "{$strSiteName}当前优先整理{$strPrimaryPhrase}、{$strSecondaryPhrase}这些方向的内容入口,适合先看首屏主推,再继续转入分类、榜单与搜索细筛。", + "首页会优先承接{$strPrimaryPhrase}相关内容,再逐步展开{$strSecondaryPhrase}等路径,适合先建立兴趣再继续深看。", + "{$strSiteName}会把更容易吸引点击的内容放在首屏前部,方便先判断值得继续看的方向,再转去榜单或搜索补充。", + ], $intSeed, 'home-intro'), + 'intro_meta' => self::pickString([ + '当前为 AI 优化后的首页引导稿,优先服务首屏点击和主题相关性。', + '这版首页文案会优先兼顾主题聚焦、可读性和首屏留存。', + '当前首页稿已按域名主题和首屏结构重新优化。', + $strHotKeyword !== '' ? '当前首页稿已把站外回推里更活跃的“' . $strHotKeyword . '”相关语义并入首屏承接。' : '', + ], $intSeed, 'home-meta'), + 'guide_cards' => [ + [ + 'title' => self::pickString(['首页先看', '首屏入口', '先看内容'], $intSeed, 'home-card-1-title'), + 'text' => self::pickString([ + '建议先看首屏推荐和刚刚更新,再决定是否继续切到榜单或分类页。', + '首页第一屏更适合先抓住有兴趣的内容卡片,再回头看说明和分类。', + '如果首屏已经出现感兴趣的内容,建议先点详情页确认,再决定是否继续翻页。', + ], $intSeed, 'home-card-1-text'), + ], + [ + 'title' => self::pickString(['查找路径', '快速定位', '搜索建议'], $intSeed, 'home-card-2-title'), + 'text' => self::pickString([ + '如果已经知道片名、演员或题材词,直接用搜索通常会比翻列表更快。', + '目标比较明确时,优先搜索片名、人物名和题材词会更高效。', + '不想来回翻页时,可以先搜关键词,再回看相关推荐继续扩展。', + ], $intSeed, 'home-card-2-text'), + ], + [ + 'title' => self::pickString(['继续深入', '榜单路径', '后续动作'], $intSeed, 'home-card-3-title'), + 'text' => self::pickString([ + '看完首页后,第二步更适合去榜单和分类页继续扩大范围。', + '如果更关注热度变化,建议先去榜单,再决定是否进入详情页。', + '首页主要负责抓兴趣,后续更适合用榜单和分类做第二轮判断。', + ], $intSeed, 'home-card-3-text'), + ], + ], + ]; + } + + protected static function buildCategoryIndexPayload(DomainModel $DomainModel, int $intSeed, string $strProvider): array + { + $strSiteName = trim((string)($DomainModel->d_name ?? '')); + $strHost = trim((string)($DomainModel->d_domain ?? '')); + $arrResourceContext = SeoCopyContextHelper::buildResourceContext($strHost); + $strHotKeyword = trim((string)($arrResourceContext['hot_keyword'] ?? '')); + $strKeyword = self::pickPrimaryKeyword($DomainModel, '电影'); + $arrThemeKeywords = self::pickThemeKeywords($arrResourceContext, $strKeyword, 4); + $strThemeKeyword = $arrThemeKeywords[1] ?? ($strHotKeyword !== '' ? $strHotKeyword : $strKeyword); + $strTitle = self::pickString([ + "{$strKeyword}分类频道-{$strSiteName}", + "{$strSiteName}{$strKeyword}分类入口", + "{$strKeyword}频道导航-{$strSiteName}", + ], $intSeed, 'category-index-title'); + $strKeywords = implode(',', array_values(array_unique(array_filter(array_merge([ + $strKeyword, + $strKeyword . '分类', + $strKeyword . '推荐', + $strSiteName, + ], $arrThemeKeywords))))); + $strDescription = self::pickString([ + "{$strSiteName}汇总{$strKeyword}相关分类入口和{$strThemeKeyword}等细分方向,适合先确定题材范围,再继续进入更细的列表页。", + "当前频道会围绕{$strKeyword}相关内容做分区整理,并补充{$strThemeKeyword}这类更贴近近期反馈的方向,方便先确认大方向。", + "{$strSiteName}把{$strKeyword}相关的常看方向集中到当前频道页,适合先看分类路径,再决定后续浏览路线。", + $strHotKeyword !== '' ? "{$strSiteName}当前会优先把更接近{$strHotKeyword}这类站外活跃词的分类方向往前承接,便于更快进入相关题材。" : '', + ], $intSeed, 'category-index-description'); + + return [ + 'title' => $strTitle, + 'keywords' => $strKeywords, + 'description' => $strDescription, + 'intro_text' => self::pickString([ + "当前频道更适合先看{$strKeyword}相关入口,再决定继续深入哪一类内容。", + "这一页主要负责把{$strKeyword}方向先分清楚,再转到更具体的列表页做筛选。", + "如果你是从首页进来的,这里更适合做第二轮题材判断,尤其继续分出{$strThemeKeyword}这类更细方向。", + ], $intSeed, 'category-index-intro'), + 'category_intro' => self::pickString([ + "当前频道已经把{$strKeyword}相关入口做了集中整理,更适合先判断方向,再继续进入细分列表。", + "{$strSiteName}会先把{$strKeyword}相关题材路径放在这一层,便于先确定浏览路线。", + "这一层更像{$strKeyword}的一级入口页,适合先做大范围判断,再转到{$strThemeKeyword}这类更细方向。", + ], $intSeed, 'category-index-category-intro'), + 'intro_meta' => self::pickString([ + '当前为 AI 优化后的分类入口文案,更强调题材分流与进入路径。', + '这版分类页文案会优先承接题材入口和继续筛选逻辑。', + '当前分类页稿已按分流效率和 SEO 主题聚焦重新优化。', + ], $intSeed, 'category-index-meta'), + 'guide_cards' => [ + [ + 'title' => '先看方向', + 'text' => '建议先看一级分类入口,确认题材方向后,再进入更细的列表页。' + ], + [ + 'title' => '继续细分', + 'text' => "如果当前方向还不够准,可以继续切到更细的题材,尤其留意{$strThemeKeyword}这类更具体的入口。" + ], + ], + ]; + } + + protected static function buildCategoryListPayload(DomainModel $DomainModel, int $intSeed, string $strProvider): array + { + $strSiteName = trim((string)($DomainModel->d_name ?? '')); + $strHost = trim((string)($DomainModel->d_domain ?? '')); + $arrResourceContext = SeoCopyContextHelper::buildResourceContext($strHost); + $strHotKeyword = trim((string)($arrResourceContext['hot_keyword'] ?? '')); + $strKeyword = self::pickPrimaryKeyword($DomainModel, '热门影视'); + $arrThemeKeywords = self::pickThemeKeywords($arrResourceContext, $strKeyword, 4); + $strThemeKeyword = $arrThemeKeywords[1] ?? ($strHotKeyword !== '' ? $strHotKeyword : $strKeyword); + $strTitle = self::pickString([ + "{$strKeyword}内容列表-{$strSiteName}", + "{$strSiteName}{$strKeyword}推荐列表", + "{$strKeyword}分类内容页-{$strSiteName}", + ], $intSeed, 'category-list-title'); + $strKeywords = implode(',', array_values(array_unique(array_filter(array_merge([ + $strKeyword, + $strKeyword . '内容列表', + $strKeyword . '推荐', + $strKeyword . '分类', + $strSiteName, + ], $arrThemeKeywords))))); + $strDescription = self::pickString([ + "{$strSiteName}持续整理{$strKeyword}相关内容,并优先承接{$strThemeKeyword}这类更贴近站外反馈的结果,适合先看同类内容密度,再决定是否继续翻页或进入详情页。", + "当前列表页围绕{$strKeyword}集中露出同类内容,便于先判断命中方向,再继续缩小范围,也适合留意{$strThemeKeyword}这类更具体的切口。", + "{$strSiteName}把{$strKeyword}相关内容集中到当前列表页,适合先看当前页质量,再决定是否继续翻页。", + $strHotKeyword !== '' ? "{$strSiteName}当前会优先承接更接近{$strHotKeyword}这类站外活跃词的列表语义,帮助更快命中当前浏览意图。" : '', + ], $intSeed, 'category-list-description'); + + return [ + 'title' => $strTitle, + 'keywords' => $strKeywords, + 'description' => $strDescription, + 'intro_text' => self::pickString([ + "当前列表更适合先看{$strKeyword}相关内容密度,再决定是否继续翻页或切回上一级分类。", + "如果你正在筛同类内容,这一页更适合先看首屏命中,再决定是否继续深入。", + "当前页主要负责把{$strKeyword}相关条目集中露出,方便先做第二轮筛选,也适合顺着{$strThemeKeyword}继续缩小范围。", + ], $intSeed, 'category-list-intro'), + 'category_intro' => self::pickString([ + "{$strSiteName}会先把{$strKeyword}相关条目集中露出,适合先看当前页质量,再判断是否继续翻页。", + "这一页更像{$strKeyword}的集中浏览入口,适合先看同类内容分布,再继续点进详情页,也可以顺着{$strThemeKeyword}扩展。", + "如果你已经确定方向,当前列表页最适合先看同类条目密度,再决定是否继续缩小范围。", + ], $intSeed, 'category-list-category-intro'), + 'intro_meta' => self::pickString([ + '当前为 AI 优化后的分类列表页文案,更强调同类内容密度、翻页节奏与详情页转化。', + '这版分类列表页会优先兼顾列表留存、继续翻页和详情页点击。', + '当前分类列表稿已按 SEO 主题聚焦和站内继续浏览路径重新优化。', + ], $intSeed, 'category-list-meta'), + 'guide_cards' => [ + [ + 'title' => '先看密度', + 'text' => '建议先判断首屏同类内容质量,再决定是否继续翻页。' + ], + [ + 'title' => '继续缩小', + 'text' => "如果范围还偏宽,可以回上一级分类或直接叠加{$strThemeKeyword}这类更细关键词。" + ], + [ + 'title' => '下一步动作', + 'text' => '找到接近目标的条目后,建议先进详情页再决定是否继续看。' + ], + ], + 'provider' => $strProvider, + ]; + } + + protected static function buildSearchPayload(DomainModel $DomainModel, int $intSeed, string $strProvider): array + { + $strSiteName = trim((string)($DomainModel->d_name ?? '')); + $strHost = trim((string)($DomainModel->d_domain ?? '')); + $arrResourceContext = SeoCopyContextHelper::buildResourceContext($strHost); + $strHotKeyword = trim((string)($arrResourceContext['hot_keyword'] ?? '')); + $strKeyword = self::pickPrimaryKeyword($DomainModel, '影视'); + $arrThemeKeywords = self::pickThemeKeywords($arrResourceContext, $strKeyword, 4); + $strThemeKeyword = $arrThemeKeywords[1] ?? ($strHotKeyword !== '' ? $strHotKeyword : $strKeyword); + $strTitle = self::pickString([ + "{$strKeyword}搜索结果-{$strSiteName}", + "{$strSiteName}{$strKeyword}相关内容检索", + "{$strKeyword}站内搜索-{$strSiteName}", + ], $intSeed, 'search-title'); + $strKeywords = implode(',', array_values(array_unique(array_filter(array_merge([ + $strKeyword, + $strKeyword . '搜索', + $strKeyword . '相关视频', + $strSiteName, + ], $arrThemeKeywords))))); + $strDescription = self::pickString([ + "{$strSiteName}为你整理与{$strKeyword}相关的搜索结果,并优先补充{$strThemeKeyword}这类更贴近近期反馈的结果语义,方便快速查看剧情简介、演员资料和播放线索。", + "当前搜索页围绕{$strKeyword}相关内容做集中收敛,适合先看命中质量,再决定是否继续细筛,也可以补充{$strThemeKeyword}这类更具体的词。", + "{$strSiteName}会把{$strKeyword}相关结果优先露出,方便先确认方向,再进一步缩小范围。", + $strHotKeyword !== '' ? "{$strSiteName}会优先围绕站外反馈更活跃的{$strHotKeyword}相关语义重组搜索承接文案,帮助更快命中当前检索意图。" : '', + ], $intSeed, 'search-description'); + + return [ + 'title' => $strTitle, + 'keywords' => $strKeywords, + 'description' => $strDescription, + 'intro_text' => self::pickString([ + "当前搜索页会优先围绕{$strKeyword}给出结果,适合先判断命中质量,再决定是否翻页。", + "如果目标已经比较明确,这页更适合先看首屏命中,再继续补充人物、年份或题材词缩小范围,也可以尝试{$strThemeKeyword}这类更贴近反馈的词。", + "搜索结果页的第一步是先确认方向,第二步再靠组合词把范围压小。", + ], $intSeed, 'search-intro'), + 'search_landing' => self::pickString([ + "当前关键词“{$strKeyword}”的结果页更适合先看命中条目,再继续进入详情页。", + "搜索页会先集中露出和“{$strKeyword}”最接近的内容,便于快速判断方向。", + "建议先用“{$strKeyword}”判断结果面,再继续叠加年份、演员和题材词,必要时直接尝试{$strThemeKeyword}。 ", + ], $intSeed, 'search-landing'), + 'intro_meta' => self::pickString([ + '当前为 AI 优化后的搜索页文案,更强调收敛结果和继续筛选。', + '这版搜索页会优先兼顾命中质量、继续筛选和详情页转化。', + '当前搜索页稿已按搜索意图和结果页留存重新优化。', + ], $intSeed, 'search-meta'), + 'faq_content' => self::pickString([ + "如果当前“{$strKeyword}”结果仍然偏宽,建议继续补充演员名、年份或题材词,一般更容易缩小到目标内容。", + "当结果不够精确时,把“{$strKeyword}”和人物、年份、剧情词组合搜索,通常会更稳。", + "搜索页更适合先用宽词找方向,再用更细的组合词锁定目标条目。", + ], $intSeed, 'search-faq'), + 'guide_cards' => [ + [ + 'title' => '先看命中', + 'text' => '建议先判断首屏命中质量,再决定是否继续翻页或改词搜索。' + ], + [ + 'title' => '缩小范围', + 'text' => "如果结果太宽,可以叠加人物、年份、题材词,或直接试试{$strThemeKeyword}这类更具体的词。" + ], + ], + ]; + } + + protected static function buildDetailDefaultPayload(DomainModel $DomainModel, int $intSeed, string $strProvider): array + { + $strSiteName = trim((string)($DomainModel->d_name ?? '')); + $strKeyword = self::pickPrimaryKeyword($DomainModel, '剧情介绍'); + $strHost = trim((string)($DomainModel->d_domain ?? '')); + $arrResourceContext = SeoCopyContextHelper::buildResourceContext($strHost); + $arrThemeKeywords = self::pickThemeKeywords($arrResourceContext, $strKeyword, 3); + $strThemeKeyword = $arrThemeKeywords[0] ?? $strKeyword; + $strAssistKeyword = $arrThemeKeywords[1] ?? ($arrThemeKeywords[0] ?? $strKeyword); + + return [ + 'title_template' => self::pickString([ + '{video_name}' . $strThemeKeyword . '、演员表、播放入口-' . $strSiteName, + '{video_name}详情介绍、' . $strAssistKeyword . '、在线播放-' . $strSiteName, + '{video_name}' . $strThemeKeyword . '与播放线索-' . $strSiteName, + '{video_name}{year}' . $strThemeKeyword . '、{area_name}内容线索-' . $strSiteName, + '{video_name}{video_alias}{year}' . $strThemeKeyword . '资料页-' . $strSiteName, + ], $intSeed, 'detail-title'), + 'keywords_template' => implode(',', array_filter([ + '{video_name}', + '{video_name}剧情介绍', + '{video_name}演员表', + '{video_name}在线播放', + '{video_alias}', + '{actor_names}', + '{director_names}', + '{area_name}', + '{lang_name}', + '{remarks}', + $strThemeKeyword, + $strAssistKeyword, + $strSiteName, + ])), + 'description_template' => self::pickString([ + $strSiteName . '整理《{video_name}》的' . $strThemeKeyword . '、演员资料、题材信息与播放线索,方便先看重点内容再决定是否继续观看。', + '当前页围绕《{video_name}》汇总' . $strAssistKeyword . '、演员资料和播放入口,适合先确认内容方向,再继续进入播放页。', + $strSiteName . '提供《{video_name}》相关的' . $strThemeKeyword . '、人物资料和播放线索,帮助更快判断内容是否值得继续看。', + $strSiteName . '会先整理《{video_name}》的{year}、{area_name}、{actor_names}和播放线索,再帮助判断是否继续观看。', + $strSiteName . '会把《{video_name}》的{video_alias}、{remarks}、{lang_name}和{director_names}等资料线索整理在详情页里,方便先判断内容再继续观看。', + ], $intSeed, 'detail-description'), + 'detail_body_lead' => self::pickString([ + '本页会围绕《{video_name}》优先整理' . $strThemeKeyword . '、人物信息与播放线索,方便快速判断内容方向。', + '当前详情页先把《{video_name}》的核心' . $strAssistKeyword . '、演员资料和观看路径整理到一起,适合先看重点再继续浏览。', + '如果你是从搜索或首页进入,这一页更适合先确认《{video_name}》的' . $strThemeKeyword . '和人物,再决定是否继续看。', + '这条详情页会补充《{video_name}》的{year}、{area_name}、{director_names}和{actor_names}等事实线索,方便快速判断内容。', + ], $intSeed, 'detail-body-lead-ai'), + 'detail_play_link_lead' => self::pickString([ + '如果已经确认方向,可以直接进入播放页继续观看。', + '如果准备继续看片,下一步更适合切到播放页。', + '确认剧情和人物信息后,可以直接进入播放页继续浏览。', + ], $intSeed, 'detail-play-link-ai'), + 'detail_body_tail' => self::pickString([ + '如果还想扩大范围,可以回到分类页或继续搜索相关词条。', + '如果还没确定,也可以先看同类题材或返回搜索页继续筛选。', + '如果想换个切口继续判断,可以回分类页或站内搜索继续看。', + ], $intSeed, 'detail-body-tail-ai'), + 'detail_faq' => self::pickString([ + '建议先看剧情简介和演员资料,再决定是否直接进入播放页。', + '如果目标已经比较明确,确认完剧情和人物信息后再进播放页通常更高效。', + '详情页更适合先建立内容判断,再决定是否继续观看。', + '如果更在意演员和地区线索,建议先看{actor_names}、{area_name}这些事实信息,再决定是否继续进入播放页。', + ], $intSeed, 'detail-faq-ai'), + 'guide_cards' => [ + ['title' => '先看重点', 'text' => '先用' . $strThemeKeyword . '快速判断内容方向,再决定是否继续看。'], + ['title' => '确认人物', 'text' => '演员资料和人物信息适合帮助快速确认内容归属与' . $strAssistKeyword . '。'], + ['title' => '继续动作', 'text' => '确认完重点信息后,可进入播放页或回分类页继续筛选。'], + ], + 'topic_hint' => $strKeyword, + 'provider' => $strProvider, + ]; + } + + protected static function buildPlayDefaultPayload(DomainModel $DomainModel, int $intSeed, string $strProvider): array + { + $strSiteName = trim((string)($DomainModel->d_name ?? '')); + $strHost = trim((string)($DomainModel->d_domain ?? '')); + $arrResourceContext = SeoCopyContextHelper::buildResourceContext($strHost); + $arrThemeKeywords = self::pickThemeKeywords($arrResourceContext, '在线播放', 3); + $strThemeKeyword = $arrThemeKeywords[0] ?? '在线播放'; + $strAssistKeyword = $arrThemeKeywords[1] ?? ($arrThemeKeywords[0] ?? '播放入口'); + + return [ + 'title_template' => self::pickString([ + '{video_name}{episode_name}' . $strThemeKeyword . '-' . $strSiteName, + '{video_name}{episode_name}免费视频观看-' . $strSiteName, + '{video_name}{episode_name}' . $strAssistKeyword . '-' . $strSiteName, + '{video_name}{episode_name}{year}' . $strThemeKeyword . '-' . $strSiteName, + '{video_name}{episode_name}{area_name}{lang_name}' . $strThemeKeyword . '-' . $strSiteName, + ], $intSeed, 'play-title'), + 'keywords_template' => implode(',', array_filter([ + '{video_name}', + '{video_name}{episode_name}', + '{video_name}在线播放', + '{video_name}播放', + '{video_alias}', + '{actor_names}', + '{director_names}', + '{area_name}', + '{lang_name}', + '{remarks}', + $strThemeKeyword, + $strAssistKeyword, + $strSiteName, + ])), + 'description_template' => self::pickString([ + $strSiteName . '提供《{video_name}》{episode_name}的' . $strThemeKeyword . ',并同步整理剧情线索、回详情路径与当前线路信息。', + '当前播放页围绕《{video_name}》{episode_name}提供' . $strAssistKeyword . ',同时保留剧情与详情回看路径。', + $strSiteName . '正在整理《{video_name}》{episode_name}的播放入口、线路说明和回详情页路径,方便继续观看与切线。', + $strSiteName . '会先整理《{video_name}》{episode_name}的{year}、{area_name}、{actor_names}和线路信息,再承接继续观看动作。', + $strSiteName . '会把《{video_name}》{episode_name}的{video_alias}、{lang_name}、{remarks}和{director_names}等线索放到播放页,方便继续看前先确认内容事实。', + ], $intSeed, 'play-description'), + 'play_intro' => self::pickString([ + '当前页会围绕《{video_name}》{episode_name}提供' . $strThemeKeyword . ',并补充回详情页和分类页的路径。', + '如果你已经确认要继续看,这里更适合直接进入《{video_name}》{episode_name}的播放入口与线路切换。', + '本页会优先承接《{video_name}》{episode_name}的播放动作,并保留回详情页继续补看资料的路径。', + '当前播放页也会补充《{video_name}》的{year}、{area_name}和{actor_names}等事实线索,方便继续判断内容。', + '当前播放页会把《{video_name}》{episode_name}的{lang_name}、{remarks}、{director_names}和{video_alias}这些线索补在播放动作前,方便先确认内容。', + ], $intSeed, 'play-intro-ai'), + 'play_meta_note' => self::pickString([ + '当前播放为{episode_name},如加载不稳可切换线路或回详情页继续浏览。', + '建议先确认当前集数和线路,再根据加载情况决定是否切换。', + '如果当前线路不稳,可切线或先回详情页确认剧情与人物资料。', + '如果还想先确认事实信息,可回详情页补看{year}、{area_name}和{director_names}等资料。', + '如果想先补看事实线索,也可以回详情页确认{video_alias}、{lang_name}和{remarks}这些资料。', + ], $intSeed, 'play-meta-ai'), + 'play_body_lead' => self::pickString([ + '本页重点提供《{video_name}》{episode_name}的播放入口,并同步保留回详情页补看资料的路径。', + '播放页主要负责承接继续观看动作,同时补充当前剧集和线路信息。', + '如果已经确定继续看,本页可以直接进入播放,同时也保留回详情页的路径。', + '当前播放页还会把《{video_name}》的{year}、{area_name}和{actor_names}这类事实线索补在播放动作前,方便先确认内容。', + '当前播放页会继续补充《{video_name}》的{video_alias}、{lang_name}、{remarks}和{director_names}等资料,避免只剩单一播放入口。', + ], $intSeed, 'play-body-lead-ai'), + 'play_body_next' => self::pickString([ + '如果想先补看剧情,可以返回详情页继续浏览。', + '如果还想先确认剧情和人物资料,建议先回详情页。', + '如果还没完全确定内容方向,可以先回详情页补看重点信息。', + ], $intSeed, 'play-body-next-ai'), + 'guide_cards' => [ + ['title' => '当前剧集', 'text' => '当前播放页更适合先确认集数和线路,再继续观看与切线。'], + ['title' => '线路建议', 'text' => '如果当前播放不稳,可优先切换线路或改看' . $strAssistKeyword . '。'], + ['title' => '补看资料', 'text' => '需要补看剧情或人物资料时,可直接回详情页继续浏览。'], + ], + 'provider' => $strProvider, + ]; + } + + protected static function requestOpenAiCopyBundle(DomainModel $DomainModel, array $arrProviderCfg, bool $boolOptimize = false): array + { + $strApiKey = trim((string)($arrProviderCfg['api_key'] ?? '')); + if ($strApiKey === '') { + throw new \RuntimeException('OpenAI API Key 未配置'); + } + + $strBaseUrl = rtrim((string)($arrProviderCfg['base_url'] ?? 'https://api.openai.com/v1'), '/'); + $strModel = trim((string)($arrProviderCfg['model'] ?? 'gpt-5.4-mini')); + $intTimeoutMs = max(3000, (int)($arrProviderCfg['timeout_ms'] ?? 45000)); + + $arrPayload = [ + 'model' => $strModel, + 'input' => [ + [ + 'role' => 'system', + 'content' => [ + [ + 'type' => 'input_text', + 'text' => '你是资深中文 SEO 编辑,目标是在不虚构事实的前提下,输出适合影视站首页、分类页、搜索页、详情页、播放页的高相关、高可读、强点击意图文案。输出必须是合法 JSON。' + ], + ], + ], + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'input_text', + 'text' => self::buildOpenAiPrompt($DomainModel, $boolOptimize), + ], + ], + ], + ], + 'text' => [ + 'format' => [ + 'type' => 'json_schema', + 'name' => 'seo_copy_bundle', + 'schema' => self::buildOpenAiSchema(), + 'strict' => true, + ], + ], + ]; + + $arrResponse = self::postJson( + $strBaseUrl . '/responses', + $arrPayload, + [ + 'Authorization: Bearer ' . $strApiKey, + 'Content-Type: application/json', + ], + $intTimeoutMs + ); + + $strOutput = self::extractResponseJsonText($arrResponse); + if ($strOutput === '') { + throw new \RuntimeException('OpenAI 未返回可解析的结构化内容'); + } + + $arrDecoded = json_decode($strOutput, true); + if (!is_array($arrDecoded)) { + throw new \RuntimeException('OpenAI 返回内容不是合法 JSON'); + } + + return $arrDecoded; + } + + protected static function buildOpenAiPrompt(DomainModel $DomainModel, bool $boolOptimize): string + { + $strSiteName = trim((string)($DomainModel->d_name ?? '')); + $strHost = trim((string)($DomainModel->d_domain ?? '')); + $arrResourceContext = SeoCopyContextHelper::buildResourceContext($strHost); + $strHotKeyword = trim((string)($arrResourceContext['hot_keyword'] ?? '')); + $arrPriorityKeywords = array_slice((array)($arrResourceContext['priority_keywords'] ?? []), 0, 6); + $arrFeedbackKeywords = array_slice((array)($arrResourceContext['feedback_keywords'] ?? []), 0, 6); + $arrTopics = array_slice((array)($arrResourceContext['topics'] ?? []), 0, 6); + + return implode("\n", [ + '任务模式:' . ($boolOptimize ? '重优化' : '首次生成'), + '站点名称:' . $strSiteName, + '域名:' . $strHost, + '站点关键词:' . trim((string)($DomainModel->d_keywords ?? '')), + '站点描述:' . trim((string)($DomainModel->d_description ?? '')), + '首页标题原始值:' . trim((string)($DomainModel->d_index_title ?? '')), + '首页关键词原始值:' . trim((string)($DomainModel->d_index_keywords ?? '')), + '首页描述原始值:' . trim((string)($DomainModel->d_index_description ?? '')), + '站点定位优先词:' . implode(' / ', array_values(array_filter($arrPriorityKeywords, static fn($item): bool => trim((string)$item) !== ''))), + '站外回推热词:' . implode(' / ', array_values(array_filter($arrFeedbackKeywords, static fn($item): bool => trim((string)$item) !== ''))), + '站点内容主题:' . implode(' / ', array_values(array_filter($arrTopics, static fn($item): bool => trim((string)$item) !== ''))), + '当前最热反馈词:' . ($strHotKeyword !== '' ? $strHotKeyword : '无'), + '', + '目标:', + '1. 以 SEO 排名和尽快收录为优先。', + '2. 文案要可读,不要纯堆词。', + '3. 首页、分类页、搜索页要像真实影视站,不要像站群模板说明书。', + '4. 详情页、播放页请输出带占位符模板,允许使用这些占位符:{site_name} {video_name} {episode_name} {category_name} {year} {play_line} {video_alias} {actor_names} {director_names} {area_name} {lang_name} {remarks}。', + '5. 不要编造具体影片事实,不要写不存在的演员和剧情。', + '6. guide_cards 每页输出 2-3 条,短句、可读、动作明确。', + '', + '聚合页强约束:', + '1. category_index 是分类入口页,不是普通栏目名;title 必须明显包含“分类 / 频道 / 导航 / 入口”中的至少一个词。', + '2. category_list 是流量承接列表页;title 必须明显包含“在线观看 / 最新资源 / 推荐 / 内容列表”中的至少一个强意图词,优先写成“{category_name}在线观看_{site_name}”这类结构。', + '3. search 是关键词搜索结果页;title 必须明显包含“{keyword}”以及“搜索结果 / 站内搜索 / 相关结果”中的至少一个词。', + '4. category_index / category_list / search 的 description 都要强调:先浏览、再筛选、再进入详情页或播放页,帮助蜘蛛和用户继续往深页走。', + '5. 不要只写“内容推荐、影视内容、快速浏览”这种泛词,尽量把分类词、搜索词、站点定位词和站外反馈词融合进去。', + '6. intro_text 和 category_intro/search_landing 要更像承接搜索意图的导语,而不是站内说明书。', + '', + '深页强约束:', + '1. detail/play 要优先组织“剧情主线 + 观看路径 + 事实线索”,事实线索优先使用年份、地区、演员、导演、别名、备注这些占位符。', + '2. detail 描述不要只改写公共简介,要更像资料页摘要,先告诉用户这页能解决什么,再补事实线索。', + '3. play 描述不要只写在线播放,要带回详情路径、线路判断和必要的事实线索。', + '4. 不要虚构具体剧情细节;如果缺少事实,就用更保守的资料页表达。', + ]); + } + + protected static function buildOpenAiSchema(): array + { + $arrCardSchema = [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'title' => ['type' => 'string'], + 'text' => ['type' => 'string'], + ], + 'required' => ['title', 'text'], + ]; + + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'home' => self::sceneSchema(['title', 'keywords', 'description', 'intro_text', 'intro_meta'], $arrCardSchema), + 'category_index' => self::sceneSchema(['title', 'keywords', 'description', 'intro_text', 'category_intro', 'intro_meta'], $arrCardSchema), + 'category_list' => self::sceneSchema(['title', 'keywords', 'description', 'intro_text', 'category_intro', 'intro_meta'], $arrCardSchema), + 'search' => self::sceneSchema(['title', 'keywords', 'description', 'intro_text', 'search_landing', 'intro_meta', 'faq_content'], $arrCardSchema), + 'detail' => self::sceneSchema(['title_template', 'keywords_template', 'description_template', 'detail_body_lead', 'detail_play_link_lead', 'detail_body_tail', 'detail_faq'], $arrCardSchema), + 'play' => self::sceneSchema(['title_template', 'keywords_template', 'description_template', 'play_intro', 'play_meta_note', 'play_body_lead', 'play_body_next'], $arrCardSchema), + ], + 'required' => ['home', 'category_index', 'category_list', 'search', 'detail', 'play'], + ]; + } + + protected static function sceneSchema(array $arrTextFields, array $arrCardSchema): array + { + $arrProperties = []; + foreach ($arrTextFields as $strField) { + $arrProperties[$strField] = ['type' => 'string']; + } + $arrProperties['guide_cards'] = [ + 'type' => 'array', + 'items' => $arrCardSchema, + 'minItems' => 2, + 'maxItems' => 3, + ]; + + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => $arrProperties, + 'required' => array_merge($arrTextFields, ['guide_cards']), + ]; + } + + protected static function finalizeCollectionPayload(string $strScene, array $arrPayload, int $intSeed): array + { + $arrPayload = is_array($arrPayload) ? $arrPayload : []; + + if ($strScene === 'category_index') { + $arrPayload['title'] = self::normalizeCollectionText( + (string)($arrPayload['title'] ?? ''), + [ + '{parent_category_name}分类频道_{site_name}', + '{parent_category_name}分类导航与热门入口_{site_name}', + '{parent_category_name}热门分类入口_{site_name}', + ], + $intSeed, + 'category-index-final-title' + ); + if (!self::containsAny((string)$arrPayload['title'], ['分类', '频道', '导航'])) { + $arrPayload['title'] = self::pickString([ + '{parent_category_name}分类频道_{site_name}', + '{parent_category_name}分类导航_{site_name}', + '{parent_category_name}热门分类入口_{site_name}', + ], $intSeed, 'category-index-title-force'); + } + $arrPayload['title'] = self::normalizeSeoTitle((string)($arrPayload['title'] ?? '')); + $arrPayload['keywords'] = self::normalizeCollectionText( + (string)($arrPayload['keywords'] ?? ''), + [ + '{parent_category_name}', + '{parent_category_name}分类', + '{parent_category_name}推荐', + '{site_name}', + ], + $intSeed, + 'category-index-final-keywords', + true + ); + $arrPayload['description'] = self::normalizeCollectionText( + (string)($arrPayload['description'] ?? ''), + [ + '{site_name}汇总{parent_category_name}分类下的热门内容与细分方向,方便先按类型浏览,再继续进入更细的列表页或详情页。', + '{site_name}会优先整理{parent_category_name}常看分类入口,便于快速判断方向并继续筛选内容,再进入详情页查看播放线索。', + '{site_name}把{parent_category_name}常看分类入口集中到当前页面,适合先判断题材方向,再继续深入列表页和详情页。', + ], + $intSeed, + 'category-index-final-description' + ); + $arrPayload['intro_text'] = self::normalizeCollectionText( + (string)($arrPayload['intro_text'] ?? ''), + [ + '当前页会先把{parent_category_name}方向的常看分类入口整理出来,便于先定方向,再继续筛选列表与详情页。', + '如果你准备按题材找内容,这一页更适合先看{parent_category_name}分类入口,再决定下一步进入哪类列表。', + ], + $intSeed, + 'category-index-final-intro' + ); + $arrPayload['category_intro'] = self::normalizeCollectionText( + (string)($arrPayload['category_intro'] ?? ''), + [ + '当前频道会优先承接{parent_category_name}相关分类路径,方便先看入口,再逐步进入更细列表。', + '{site_name}把{parent_category_name}的一级分类和细分方向集中到这里,适合先判断题材,再继续点进详情页。', + ], + $intSeed, + 'category-index-final-category-intro' + ); + } elseif ($strScene === 'category_list') { + $arrPayload['title'] = self::normalizeCollectionText( + (string)($arrPayload['title'] ?? ''), + [ + '{category_name}免费在线观看_{site_name}', + '{category_name}最新资源与在线观看_{site_name}', + '{category_name}推荐内容列表_{site_name}', + ], + $intSeed, + 'category-list-final-title' + ); + if (!self::containsAny((string)$arrPayload['title'], ['在线观看', '免费', '资源', '内容列表'])) { + $arrPayload['title'] = self::pickString([ + '{category_name}免费在线观看_{site_name}', + '{category_name}最新资源与在线观看_{site_name}', + '{category_name}推荐内容列表_{site_name}', + ], $intSeed, 'category-list-title-force'); + } + $arrPayload['title'] = self::normalizeSeoTitle((string)($arrPayload['title'] ?? '')); + $arrPayload['keywords'] = self::normalizeCollectionText( + (string)($arrPayload['keywords'] ?? ''), + [ + '{category_name}', + '{category_name}免费在线观看', + '{category_name}最新资源', + '{category_name}推荐', + '{site_name}', + ], + $intSeed, + 'category-list-final-keywords', + true + ); + $arrPayload['description'] = self::normalizeCollectionText( + (string)($arrPayload['description'] ?? ''), + [ + '{site_name}持续整理{category_name}相关影视资源,适合先看同类内容,再决定是否进入详情页、播放页或继续翻页。', + '{site_name}提供{category_name}最新资源与在线播放入口,方便快速筛选并查看可播放内容,再继续点进详情页确认。', + '当前页会优先集中露出{category_name}相关条目,帮助先判断同类内容密度,再继续翻页或进入详情页。', + ], + $intSeed, + 'category-list-final-description' + ); + $arrPayload['intro_text'] = self::normalizeCollectionText( + (string)($arrPayload['intro_text'] ?? ''), + [ + '当前页会优先露出{category_name}相关条目,适合先看首屏命中,再继续翻页或进入详情页。', + '如果你正在筛{category_name}同类资源,这里更适合先判断首屏质量,再决定是否继续看更多条目。', + ], + $intSeed, + 'category-list-final-intro' + ); + $arrPayload['category_intro'] = self::normalizeCollectionText( + (string)($arrPayload['category_intro'] ?? ''), + [ + '{site_name}把{category_name}相关条目集中到这一页,方便先看同类内容,再继续缩小范围。', + '这一页更像{category_name}的流量承接列表,适合先判断内容密度,再进入详情页查看具体信息。', + ], + $intSeed, + 'category-list-final-category-intro' + ); + $arrPayload['intro_meta'] = self::normalizeCollectionText( + (string)($arrPayload['intro_meta'] ?? ''), + [ + '{category_name}在线观看列表页,支持先浏览再筛选并进入详情页或播放页。', + '围绕{category_name}相关条目做集中露出,适合先看列表,再继续筛选目标内容。', + ], + $intSeed, + 'category-list-final-meta' + ); + $arrPayload['guide_cards'] = [ + [ + 'title' => '先看列表', + 'text' => '优先浏览{category_name}相关条目,先判断首屏命中是否符合需求。' + ], + [ + 'title' => '继续筛选', + 'text' => '结合更新进度、热度和类型继续缩小范围。' + ], + [ + 'title' => '进入详情页', + 'text' => '确认目标后进入详情页或播放页继续查看。' + ], + ]; + } elseif ($strScene === 'search') { + $arrPayload['title'] = self::normalizeCollectionText( + (string)($arrPayload['title'] ?? ''), + [ + '{keyword}搜索结果_{site_name}', + '{keyword}站内搜索结果_{site_name}', + '{keyword}相关影视结果_{site_name}', + ], + $intSeed, + 'search-final-title' + ); + if (!self::containsAny((string)$arrPayload['title'], ['搜索', '结果'])) { + $arrPayload['title'] = self::pickString([ + '{keyword}搜索结果_{site_name}', + '{keyword}站内搜索_{site_name}', + '{keyword}相关影视资源_{site_name}', + ], $intSeed, 'search-title-force'); + } + $arrPayload['title'] = self::normalizeSeoTitle((string)($arrPayload['title'] ?? '')); + $arrPayload['keywords'] = self::normalizeCollectionText( + (string)($arrPayload['keywords'] ?? ''), + [ + '{keyword}', + '{keyword}搜索', + '{keyword}相关视频', + '{keyword}在线观看', + '{site_name}', + ], + $intSeed, + 'search-final-keywords', + true + ); + $arrPayload['description'] = self::normalizeCollectionText( + (string)($arrPayload['description'] ?? ''), + [ + '{site_name}为你整理与{keyword}相关的搜索结果,方便快速查看剧情简介、演员资料与播放线索,再继续进入详情页或播放页。', + '当前搜索页围绕{keyword}相关内容集中露出结果,适合先看命中质量,再继续缩小范围并点进详情页。', + '如果你正在找“{keyword}”相关内容,这一页更适合先看首屏命中,再补充人物、年份或题材词继续筛选。', + ], + $intSeed, + 'search-final-description' + ); + $arrPayload['search_landing'] = self::normalizeCollectionText( + (string)($arrPayload['search_landing'] ?? ''), + [ + '这里会优先集中露出与“{keyword}”最接近的内容,便于快速判断方向并进入详情页。', + '当前搜索页更适合先看“{keyword}”的首屏命中,再决定是否补充人物、年份或题材词。', + '建议先用“{keyword}”确认结果方向,再逐步叠加更细的搜索词把范围压小。', + ], + $intSeed, + 'search-final-landing' + ); + $arrPayload['intro_text'] = self::normalizeCollectionText( + (string)($arrPayload['intro_text'] ?? ''), + [ + '输入片名、题材或人物词后,这一页会优先露出和“{keyword}”最接近的结果,方便快速判断是否继续点击。', + '搜索页的第一步是先确认“{keyword}”相关结果,第二步再叠加更细词继续缩小范围。', + ], + $intSeed, + 'search-final-intro' + ); + $arrPayload['faq_content'] = self::normalizeCollectionText( + (string)($arrPayload['faq_content'] ?? ''), + [ + '如果“{keyword}”结果不够准,建议继续补充演员名、年份、地区或题材词;如果结果太少,可以换简称、别名或近义词再搜。', + '搜索不到目标内容时,可以把“{keyword}”和人物、年份、剧情词组合搜索,通常更容易缩小到目标条目。', + ], + $intSeed, + 'search-final-faq' + ); + } + + return $arrPayload; + } + + protected static function finalizeDeepPayload(string $strScene, array $arrPayload, int $intSeed): array + { + $arrPayload = is_array($arrPayload) ? $arrPayload : []; + $arrFactTokens = $strScene === 'detail' + ? ['{year}', '{area_name}', '{actor_names}', '{director_names}', '{video_alias}', '{lang_name}', '{remarks}'] + : ['{year}', '{area_name}', '{actor_names}', '{director_names}', '{video_alias}', '{lang_name}', '{remarks}', '{play_line}', '{episode_name}']; + + if (self::countFactTokenHits($arrPayload, $arrFactTokens) < 3) { + if ($strScene === 'detail') { + $arrPayload['description_template'] = self::ensureFactAwareText( + (string)($arrPayload['description_template'] ?? ''), + self::pickString([ + '页面会继续补充{year}、{area_name}、{actor_names}、{director_names}与{video_alias}等资料线索,方便先判断内容归属再进入播放页。', + '这条详情页会把{lang_name}、{remarks}、{actor_names}和{director_names}整理在剧情资料前,方便先看资料再决定是否继续播放。', + ], $intSeed, 'detail-fact-fallback') + ); + $arrPayload['detail_body_lead'] = self::ensureFactAwareText( + (string)($arrPayload['detail_body_lead'] ?? ''), + self::pickString([ + '本页会重点补充{year}、{area_name}、{actor_names}和{director_names}这些事实线索,方便快速判断内容。', + '当前详情页会把{video_alias}、{lang_name}和{remarks}等资料并入主描述,减少只看公共简介的同质感。', + ], $intSeed, 'detail-body-fact-fallback') + ); + $arrPayload['detail_faq'] = self::ensureFactAwareText( + (string)($arrPayload['detail_faq'] ?? ''), + '如果想进一步确认,可优先查看{year}、{area_name}、{lang_name}、{actor_names}和{remarks}这些事实字段。' + ); + } else { + $arrPayload['description_template'] = self::ensureFactAwareText( + (string)($arrPayload['description_template'] ?? ''), + self::pickString([ + '建议先核对{year}、{area_name}、{actor_names}、{director_names}与{remarks},再根据{play_line}选择合适播放方式。', + '当前播放页也会补充{video_alias}、{lang_name}和{remarks}这些资料,避免只剩单一在线播放入口。', + ], $intSeed, 'play-fact-fallback') + ); + $arrPayload['play_intro'] = self::ensureFactAwareText( + (string)($arrPayload['play_intro'] ?? ''), + '如需先确认资料,可回详情页补看{year}、{area_name}、{lang_name}与{actor_names}。' + ); + $arrPayload['play_body_lead'] = self::ensureFactAwareText( + (string)($arrPayload['play_body_lead'] ?? ''), + '当前页会把{video_alias}、{director_names}和{remarks}这类线索补在播放动作前,方便继续判断内容。' + ); + } + } + + if (isset($arrPayload['guide_cards']) && is_array($arrPayload['guide_cards'])) { + $arrPayload['guide_cards'] = array_values(array_slice(array_map(static function ($arrCard): array { + if (!is_array($arrCard)) { + return ['title' => '', 'text' => '']; + } + + return [ + 'title' => trim((string)($arrCard['title'] ?? '')), + 'text' => trim((string)($arrCard['text'] ?? '')), + ]; + }, $arrPayload['guide_cards']), 0, 3)); + } + + return $arrPayload; + } + + protected static function countFactTokenHits(array $arrPayload, array $arrFactTokens): int + { + $strJoined = implode("\n", array_map(static function ($value): string { + if (is_array($value)) { + return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''; + } + + return (string)$value; + }, $arrPayload)); + + $intHits = 0; + foreach ($arrFactTokens as $strToken) { + if ($strToken !== '' && mb_strpos($strJoined, $strToken) !== false) { + $intHits++; + } + } + + return $intHits; + } + + protected static function ensureFactAwareText(string $text, string $suffix): string + { + $text = trim($text); + $suffix = trim($suffix); + if ($suffix === '') { + return $text; + } + if ($text === '') { + return $suffix; + } + if (mb_strpos($text, $suffix) !== false) { + return $text; + } + + return rtrim($text, " \t\n\r\0\x0B。;,") . '。' . $suffix; + } + + protected static function normalizeCollectionText(string $strValue, array $arrFallbacks, int $intSeed, string $strKey, bool $boolKeywords = false): string + { + $strValue = trim($strValue); + if ($boolKeywords) { + $strValue = trim($strValue, ', '); + } + + if ($strValue !== '') { + if ($boolKeywords) { + $arrParts = array_values(array_filter(array_map(static function ($item): string { + return trim((string)$item); + }, preg_split('/[,,]+/u', $strValue) ?: []), static function (string $item): bool { + return $item !== ''; + })); + $arrParts = array_values(array_unique($arrParts)); + if (count($arrParts) >= 4 && preg_match('/\{(keyword|category_name|parent_category_name|site_name)\}/', $strValue) === 1) { + return implode(',', $arrParts); + } + } elseif (preg_match('/\{(keyword|category_name|parent_category_name|site_name)\}/', $strValue) === 1 && mb_strlen($strValue) >= 10) { + return $strValue; + } + } + + if ($boolKeywords) { + return implode(',', array_values(array_filter(array_unique($arrFallbacks), static function ($item): bool { + return trim((string)$item) !== ''; + }))); + } + + return self::pickString($arrFallbacks, $intSeed, $strKey); + } + + protected static function normalizeSeoTitle(string $strValue): string + { + $strValue = trim($strValue); + if ($strValue === '') { + return ''; + } + + $arrTokens = []; + $strValue = preg_replace_callback('/\{[a-z0-9_]+\}/i', static function (array $arrMatch) use (&$arrTokens): string { + $strKey = '%%TK' . count($arrTokens) . '%%'; + $arrTokens[$strKey] = $arrMatch[0]; + return $strKey; + }, $strValue) ?: $strValue; + + $strValue = preg_replace('/\s*[_||]+\s*/u', ' - ', $strValue) ?: $strValue; + $strValue = preg_replace('/\s*-\s*-\s*/u', ' - ', $strValue) ?: $strValue; + $strValue = preg_replace('/\s{2,}/u', ' ', $strValue) ?: $strValue; + if (!empty($arrTokens)) { + $strValue = strtr($strValue, $arrTokens); + } + + return trim($strValue); + } + + protected static function containsAny(string $strValue, array $arrNeedles): bool + { + $strValue = trim($strValue); + if ($strValue === '') { + return false; + } + + foreach ($arrNeedles as $strNeedle) { + $strNeedle = trim((string)$strNeedle); + if ($strNeedle !== '' && mb_strpos($strValue, $strNeedle) !== false) { + return true; + } + } + + return false; + } + + protected static function postJson(string $strUrl, array $arrPayload, array $arrHeaders, int $intTimeoutMs): array + { + if (function_exists('curl_init')) { + $mCurl = curl_init($strUrl); + if ($mCurl === false) { + throw new \RuntimeException('初始化 curl 失败'); + } + + curl_setopt_array($mCurl, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $arrHeaders, + CURLOPT_POSTFIELDS => json_encode($arrPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + CURLOPT_TIMEOUT_MS => $intTimeoutMs, + ]); + + $strBody = (string)curl_exec($mCurl); + $intHttpCode = (int)curl_getinfo($mCurl, CURLINFO_HTTP_CODE); + $strError = curl_error($mCurl); + curl_close($mCurl); + + if ($strBody === '' && $strError !== '') { + throw new \RuntimeException('OpenAI 请求失败:' . $strError); + } + + $arrDecoded = json_decode($strBody, true); + if ($intHttpCode >= 400) { + $strMessage = is_array($arrDecoded) + ? (string)($arrDecoded['error']['message'] ?? $arrDecoded['message'] ?? $strBody) + : $strBody; + throw new \RuntimeException('OpenAI 返回错误:' . trim($strMessage)); + } + + return is_array($arrDecoded) ? $arrDecoded : []; + } + + $arrContext = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $arrHeaders), + 'content' => json_encode($arrPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'timeout' => max(3, (int)ceil($intTimeoutMs / 1000)), + 'ignore_errors' => true, + ], + ]); + + $strBody = @file_get_contents($strUrl, false, $arrContext); + if ($strBody === false) { + throw new \RuntimeException('OpenAI 请求失败:无法读取响应'); + } + + $arrDecoded = json_decode($strBody, true); + return is_array($arrDecoded) ? $arrDecoded : []; + } + + protected static function extractResponseJsonText(array $arrResponse): string + { + $strOutputText = trim((string)($arrResponse['output_text'] ?? '')); + if ($strOutputText !== '') { + return $strOutputText; + } + + foreach ((array)($arrResponse['output'] ?? []) as $arrOutputItem) { + if (!is_array($arrOutputItem)) { + continue; + } + foreach ((array)($arrOutputItem['content'] ?? []) as $arrContentItem) { + if (!is_array($arrContentItem)) { + continue; + } + $strCandidate = trim((string)($arrContentItem['text'] ?? $arrContentItem['output_text'] ?? '')); + if ($strCandidate !== '') { + return $strCandidate; + } + } + } + + return ''; + } + + protected static function pickPrimaryKeyword(DomainModel $DomainModel, string $strFallback): string + { + $arrKeywords = SeoCopyContextHelper::extractPhrases(SeoCopyContextHelper::mergeTextsWithResourceContext([ + (string)($DomainModel->d_index_title ?? ''), + (string)($DomainModel->d_index_keywords ?? ''), + (string)($DomainModel->d_keywords ?? ''), + (string)($DomainModel->d_description ?? ''), + ], (string)($DomainModel->d_domain ?? '')), 1, [ + (string)($DomainModel->d_name ?? ''), + (string)($DomainModel->d_domain ?? ''), + ]); + + return trim((string)($arrKeywords[0] ?? $strFallback)); + } + + protected static function pickThemeKeywords(array $arrResourceContext, string $strFallback, int $intLimit = 4): array + { + $arrOut = []; + foreach (array_merge( + [$strFallback], + (array)($arrResourceContext['priority_keywords'] ?? []), + (array)($arrResourceContext['feedback_keywords'] ?? []), + (array)($arrResourceContext['topics'] ?? []) + ) as $strItem) { + $strItem = trim((string)$strItem); + if ($strItem === '' || in_array($strItem, $arrOut, true)) { + continue; + } + $arrOut[] = $strItem; + if (count($arrOut) >= $intLimit) { + break; + } + } + + return $arrOut; + } + + protected static function pickString(array $arrPool, int $intSeed, string $strSalt): string + { + $arrPool = array_values(array_filter(array_map(static function ($strValue): string { + return trim((string)$strValue); + }, $arrPool), static function (string $strValue): bool { + return $strValue !== ''; + })); + + if (empty($arrPool)) { + return ''; + } + + $intIndex = abs((int)crc32($strSalt . ':' . $intSeed)) % count($arrPool); + return (string)$arrPool[$intIndex]; + } + + protected static function readEnv(string $strKey, string $strDefault = ''): string + { + $mEnvValue = null; + if (function_exists('env')) { + $mEnvValue = env($strKey); + } + if ($mEnvValue !== null && $mEnvValue !== false) { + $strValue = trim((string)$mEnvValue); + if ($strValue !== '') { + return $strValue; + } + } + + foreach ([$_SERVER, $_ENV] as $arrSource) { + if (!is_array($arrSource) || !array_key_exists($strKey, $arrSource)) { + continue; + } + $strValue = trim((string)$arrSource[$strKey]); + if ($strValue !== '') { + return $strValue; + } + } + + $mGetEnvValue = getenv($strKey); + if ($mGetEnvValue !== false) { + $strValue = trim((string)$mGetEnvValue); + if ($strValue !== '') { + return $strValue; + } + } + + return $strDefault; + } +} diff --git a/code/app/common/helper/SeoCopyApprovedManifestHelper.php b/code/app/common/helper/SeoCopyApprovedManifestHelper.php new file mode 100644 index 0000000..5b387cb --- /dev/null +++ b/code/app/common/helper/SeoCopyApprovedManifestHelper.php @@ -0,0 +1,322 @@ + 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[] = '' + . '' . $strFilename . '' + . '' . $strReleaseTag . '' + . '' . (int)($arrManifest['items_count'] ?? 0) . '' + . '' . $strHosts . '' + . '' . $strScenes . '' + . '' . $strUpdatedAt . '' + . ''; + } + + return self::wrapHtml( + 'Approved Manifest Index', + '

    内部只读清单页,展示已批准 manifest 的覆盖范围与更新时间。

    ' + . '
    ' + . '
    generated_at' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '
    ' + . '
    manifests' . (int)($arrSummary['manifests_count'] ?? 0) . '
    ' + . '
    ' + . '' + . implode('', $arrRows) + . '
    filenamerelease_tagitemshostsscenesupdated_at
    ' + ); + } + + 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 = [ + '
    filename' . self::escape((string)($arrManifest['filename'] ?? '')) . '
    ', + '
    release_tag' . self::escape((string)($arrManifest['release_tag'] ?? '')) . '
    ', + '
    items' . (int)($arrManifest['items_count'] ?? 0) . '
    ', + '
    hosts' . self::escape(implode(', ', (array)($arrManifest['hosts'] ?? []))) . '
    ', + '
    scenes' . self::escape(implode(', ', (array)($arrManifest['scenes'] ?? []))) . '
    ', + '
    updated_at' . self::escape((string)($arrManifest['updated_at'] ?? '')) . '
    ', + ]; + + $arrItemRows = []; + foreach ((array)($arrManifest['items'] ?? []) as $arrItem) { + if (!is_array($arrItem)) { + continue; + } + + $arrItemRows[] = '' + . '' . self::escape((string)($arrItem['host'] ?? '')) . '' + . '' . self::escape((string)($arrItem['scene'] ?? '')) . '' + . '' . self::escape(json_encode(array_values((array)($arrItem['page_parts'] ?? [])), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '' + . ''; + } + + $arrSceneTotalsRows = []; + foreach ((array)($arrManifest['scene_totals'] ?? []) as $strScene => $intCount) { + $arrSceneTotalsRows[] = '' + . '' . self::escape((string)$strScene) . '' + . '' . (int)$intCount . '' + . ''; + } + + $arrNavLinks = ['返回 approved 清单总览']; + if ($boolHasPortalParent) { + $arrNavLinks[] = '返回门户首页'; + $arrNavLinks[] = '查看门禁运行记录'; + $arrNavLinks[] = '查看发布 SOP'; + } + + return self::wrapHtml( + 'Approved Manifest Detail', + '

    内部只读 manifest 详情页,展示当前 approved 清单覆盖的 host、scene 与 page_parts。

    ' + . '

    ' . implode(' | ', $arrNavLinks) . '

    ' + . '
    ' . implode('', $arrCards) . '
    ' + . '

    Scene Totals

    ' . implode('', $arrSceneTotalsRows) . '
    scenecount
    ' + . '

    Items

    ' . implode('', $arrItemRows) . '
    hostscenepage_parts
    ' + ); + } + + 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 '' + . '' + . '' . $strEscapedTitle . '' + . '

    ' . $strEscapedTitle . '

    ' . $strBody . ''; + } +} diff --git a/code/app/common/helper/SeoCopyBatchImportHelper.php b/code/app/common/helper/SeoCopyBatchImportHelper.php new file mode 100644 index 0000000..fb51264 --- /dev/null +++ b/code/app/common/helper/SeoCopyBatchImportHelper.php @@ -0,0 +1,15 @@ + $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']); + } +} diff --git a/code/app/common/helper/SeoCopyContextHelper.php b/code/app/common/helper/SeoCopyContextHelper.php new file mode 100644 index 0000000..7d89b60 --- /dev/null +++ b/code/app/common/helper/SeoCopyContextHelper.php @@ -0,0 +1,300 @@ + [], + '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) !== ''; + })); + } +} diff --git a/code/app/common/helper/SeoCopyFactsBuilder.php b/code/app/common/helper/SeoCopyFactsBuilder.php new file mode 100644 index 0000000..d5c9618 --- /dev/null +++ b/code/app/common/helper/SeoCopyFactsBuilder.php @@ -0,0 +1,385 @@ + 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]; + } +} diff --git a/code/app/common/helper/SeoCopyFallbackBuilder.php b/code/app/common/helper/SeoCopyFallbackBuilder.php new file mode 100644 index 0000000..2ee94ad --- /dev/null +++ b/code/app/common/helper/SeoCopyFallbackBuilder.php @@ -0,0 +1,216 @@ + 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; + } +} diff --git a/code/app/common/helper/SeoCopyGenerationHelper.php b/code/app/common/helper/SeoCopyGenerationHelper.php new file mode 100644 index 0000000..ed8d6f3 --- /dev/null +++ b/code/app/common/helper/SeoCopyGenerationHelper.php @@ -0,0 +1,256 @@ +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); + } +} diff --git a/code/app/common/helper/SeoCopyPortalHomeHelper.php b/code/app/common/helper/SeoCopyPortalHomeHelper.php new file mode 100644 index 0000000..33e4f1f --- /dev/null +++ b/code/app/common/helper/SeoCopyPortalHomeHelper.php @@ -0,0 +1,1226 @@ + date('c'), + 'portal_root' => $strPortalRoot, + 'runtime_acceptance' => !empty($arrRuntimeAcceptanceIndex) ? 'ready' : 'missing', + 'runtime_runs' => (int)($arrRuntimeAcceptanceIndex['runs_count'] ?? count($arrRuntimeAcceptanceRuns)), + 'runtime_hosts' => (int)($arrRuntimeAcceptanceIndex['hosts_count'] ?? 0), + 'runtime_failed_hosts' => (int)($arrRuntimeAcceptanceIndex['failed_hosts_count'] ?? 0), + 'latest_runtime_acceptance' => self::pickLatestRun($arrRuntimeAcceptanceRuns), + 'latest_runtime_acceptance_task' => $arrLatestRuntimeTask, + 'latest_domain_supply_task' => $arrLatestDomainSupplyTask, + 'latest_domain_supply_orchestration_task' => $arrLatestDomainSupplyOrchestrationTask, + 'release_portal' => [ + 'exists' => !empty($arrReleaseIndex), + 'publish_logs' => (int)($arrReleaseIndex['publish_logs'] ?? 0), + 'rollback_logs' => (int)($arrReleaseIndex['rollback_logs'] ?? 0), + 'releases_count' => (int)($arrReleaseIndex['releases_count'] ?? count($arrReleases)), + 'status_totals' => $arrReleaseStatusTotals, + 'latest_release' => self::pickLatestRelease($arrReleases), + 'releases' => $arrReleases, + ], + 'approved_portal' => [ + 'exists' => !empty($arrApprovedIndex), + 'manifests_count' => (int)($arrApprovedIndex['manifests_count'] ?? count($arrManifests)), + 'latest_manifest' => self::pickLatestManifest($arrManifests), + 'manifests' => $arrManifests, + ], + 'runs_portal' => [ + 'exists' => !empty($arrRunsIndex), + 'runs_count' => (int)($arrRunsIndex['runs_count'] ?? count($arrRuns)), + 'status_totals' => (array)($arrRunsIndex['status_totals'] ?? []), + 'latest_run' => self::pickLatestRun($arrRuns), + 'runs' => $arrRuns, + ], + 'tasks_portal' => [ + 'exists' => !empty($arrTaskIndex), + 'runs_count' => (int)($arrTaskIndex['runs_count'] ?? 0), + 'tasks_count' => (int)($arrTaskIndex['tasks_count'] ?? count($arrTasks)), + 'latest_tasks' => $arrTasks, + 'latest_task' => self::pickLatestRun($arrTasks), + ], + 'runtime_acceptance_portal' => [ + 'exists' => !empty($arrRuntimeAcceptanceIndex), + 'runs_count' => (int)($arrRuntimeAcceptanceIndex['runs_count'] ?? count($arrRuntimeAcceptanceRuns)), + 'hosts_count' => (int)($arrRuntimeAcceptanceIndex['hosts_count'] ?? 0), + 'failed_hosts_count' => (int)($arrRuntimeAcceptanceIndex['failed_hosts_count'] ?? 0), + 'status_totals' => (array)($arrRuntimeAcceptanceIndex['status_totals'] ?? []), + 'host_totals' => (array)($arrRuntimeAcceptanceIndex['host_totals'] ?? []), + 'failed_hosts' => (array)($arrRuntimeAcceptanceIndex['failed_hosts'] ?? []), + 'suite_failure_totals' => (array)($arrRuntimeAcceptanceIndex['suite_failure_totals'] ?? []), + 'failure_category_totals' => (array)($arrRuntimeAcceptanceIndex['failure_category_totals'] ?? []), + 'rerun_failed_hosts_command' => (string)($arrRuntimeAcceptanceIndex['rerun_failed_hosts_command'] ?? ''), + 'latest_run' => self::pickLatestRun($arrRuntimeAcceptanceRuns), + 'runs' => $arrRuntimeAcceptanceRuns, + ], + 'bootstrap_portal' => [ + 'exists' => !empty($arrBootstrapIndex), + 'bundles_count' => (int)($arrBootstrapIndex['bundles_count'] ?? count($arrBundles)), + 'pipeline_import_policy' => (array)($arrBootstrapIndex['pipeline_import_policy'] ?? []), + 'register_ready_count' => self::countBootstrapBundlesByStatus($arrBundles, ['ready_to_create'], 'register_status'), + 'register_existing_count' => self::countBootstrapBundlesByStatus($arrBundles, ['domain_exists'], 'register_status'), + 'register_result_count' => self::countBootstrapBundlesByStatus($arrBundles, ['created', 'domain_exists', 'ready_to_create'], 'register_result_status'), + 'apply_ready_count' => self::countBootstrapBundlesByStatus($arrBundles, ['dry_run', 'no_change', 'applied'], 'apply_status'), + 'apply_pending_count' => self::countBootstrapBundlesByStatus($arrBundles, ['domain_not_found', 'preview_unavailable'], 'apply_status'), + 'apply_result_count' => self::countBootstrapBundlesByStatus($arrBundles, ['applied', 'noop', 'domain_not_found', 'dry_run'], 'apply_result_status'), + 'import_result_count' => self::countBootstrapBundlesByStatus($arrBundles, ['imported', 'partially_imported', 'import_failed', 'no_files_imported'], 'import_result_status'), + 'publish_result_count' => self::countBootstrapBundlesByStatus($arrBundles, ['published'], 'publish_result_status'), + 'bundle_run_count' => self::countBootstrapBundlesByStatus($arrBundles, ['dry_run_completed', 'dry_run_needs_attention', 'completed', 'needs_attention'], 'bundle_run_status'), + 'handoff_ready_to_register_count' => self::countBootstrapBundlesByStatus($arrBundles, ['ready_to_register'], 'handoff_stage_code'), + 'handoff_ready_to_apply_count' => self::countBootstrapBundlesByStatus($arrBundles, ['ready_to_apply'], 'handoff_stage_code'), + 'handoff_ready_for_prepare_count' => self::countBootstrapBundlesByStatus($arrBundles, ['ready_for_prepare'], 'handoff_stage_code'), + 'handoff_ready_to_import_count' => self::countBootstrapBundlesByStatus($arrBundles, ['ready_to_import'], 'handoff_stage_code'), + 'handoff_ready_to_release_dry_run_count' => self::countBootstrapBundlesByStatus($arrBundles, ['ready_to_release_dry_run'], 'handoff_stage_code'), + 'handoff_ready_to_publish_count' => self::countBootstrapBundlesByStatus($arrBundles, ['ready_to_publish'], 'handoff_stage_code'), + 'handoff_attention_count' => self::countBootstrapBundlesByStatus($arrBundles, ['needs_attention'], 'handoff_stage_code'), + 'latest_bundle' => self::pickLatestBootstrapBundle($arrBundles), + 'bundles' => $arrBundles, + ], + 'bootstrap_runs_portal' => [ + 'exists' => !empty($arrBootstrapRunsIndex), + 'runs_count' => (int)($arrBootstrapRunsIndex['runs_count'] ?? count($arrBootstrapRuns)), + 'status_totals' => (array)($arrBootstrapRunsIndex['status_totals'] ?? []), + 'handoff_ready_to_register_count' => self::countBootstrapBundlesByStatus($arrBootstrapRuns, ['ready_to_register'], 'handoff_stage_code'), + 'handoff_ready_to_apply_count' => self::countBootstrapBundlesByStatus($arrBootstrapRuns, ['ready_to_apply'], 'handoff_stage_code'), + 'handoff_ready_for_prepare_count' => self::countBootstrapBundlesByStatus($arrBootstrapRuns, ['ready_for_prepare'], 'handoff_stage_code'), + 'handoff_attention_count' => self::countBootstrapBundlesByStatus($arrBootstrapRuns, ['needs_attention'], 'handoff_stage_code'), + 'latest_run' => self::pickLatestRun($arrBootstrapRuns), + 'runs' => $arrBootstrapRuns, + ], + 'bootstrap_handoff_portal' => $arrBootstrapHandoff, + 'bootstrap_duty_portal' => $arrBootstrapDuty, + 'bootstrap_oncall_portal' => $arrBootstrapOncall, + 'bootstrap_completed_portal' => $arrBootstrapCompleted, + ]; + } + + public static function writeArtifacts(string $strPortalRoot, array $arrSummary): void + { + DomainBootstrapHandoffHelper::writeArtifacts( + rtrim($strPortalRoot, '/') . '/bootstrap/handoff', + (array)($arrSummary['bootstrap_handoff_portal'] ?? []) + ); + DomainBootstrapDutyHelper::writeArtifacts( + rtrim($strPortalRoot, '/') . '/bootstrap/duty', + (array)($arrSummary['bootstrap_duty_portal'] ?? []) + ); + DomainBootstrapOncallHelper::writeArtifacts( + rtrim($strPortalRoot, '/') . '/bootstrap/oncall', + (array)($arrSummary['bootstrap_oncall_portal'] ?? []) + ); + DomainBootstrapCompletedHelper::writeArtifacts( + rtrim($strPortalRoot, '/') . '/bootstrap/completed', + (array)($arrSummary['bootstrap_completed_portal'] ?? []) + ); + + self::writeFile( + rtrim($strPortalRoot, '/') . '/index.json', + json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + self::writeFile( + rtrim($strPortalRoot, '/') . '/index.html', + self::renderHtml($arrSummary) + ); + + $arrOpsSummary = self::buildOpsSummary($strPortalRoot); + self::writeFile( + rtrim($strPortalRoot, '/') . '/ops/index.json', + json_encode($arrOpsSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + self::writeFile( + rtrim($strPortalRoot, '/') . '/ops/index.html', + self::renderOpsHtml($arrOpsSummary) + ); + } + + public static function renderHtml(array $arrSummary): string + { + $arrReleaseRows = []; + foreach ((array)(($arrSummary['release_portal'] ?? [])['releases'] ?? []) as $arrRelease) { + if (!is_array($arrRelease)) { + continue; + } + + $arrReleaseRows[] = '' + . '' . self::escape((string)($arrRelease['release_tag'] ?? '')) . '' + . '' . self::escape((string)($arrRelease['status'] ?? '')) . '' + . '' . (int)($arrRelease['published_count'] ?? 0) . '' + . '' . self::escape((string)($arrRelease['last_event_at'] ?? '')) . '' + . ''; + } + + $arrManifestRows = []; + foreach ((array)(($arrSummary['approved_portal'] ?? [])['manifests'] ?? []) as $arrManifest) { + if (!is_array($arrManifest)) { + continue; + } + + $arrManifestRows[] = '' + . '' . self::escape((string)($arrManifest['filename'] ?? '')) . '' + . '' . self::escape((string)($arrManifest['release_tag'] ?? '')) . '' + . '' . (int)($arrManifest['items_count'] ?? 0) . '' + . '' . self::escape(implode(', ', (array)($arrManifest['scenes'] ?? []))) . '' + . ''; + } + + $arrRunRows = []; + foreach ((array)(($arrSummary['runs_portal'] ?? [])['runs'] ?? []) as $arrRun) { + if (!is_array($arrRun)) { + continue; + } + + $arrRunRows[] = '' + . '' . self::escape((string)($arrRun['run_name'] ?? '')) . '' + . '' . self::escape((string)($arrRun['release_tag'] ?? '')) . '' + . '' . self::escape((string)($arrRun['status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['manifest_name'] ?? '')) . '' + . '' . (int)($arrRun['verify_issues'] ?? 0) . '' + . '' . (int)($arrRun['front_failed'] ?? 0) . '' + . '' . self::escape((string)($arrRun['updated_at'] ?? '')) . '' + . ''; + } + + $arrCards = [ + '
    release_portal' . (!empty(($arrSummary['release_portal'] ?? [])['exists']) ? 'ready' : 'missing') . '
    ', + '
    approved_portal' . (!empty(($arrSummary['approved_portal'] ?? [])['exists']) ? 'ready' : 'missing') . '
    ', + '
    runs_portal' . (!empty(($arrSummary['runs_portal'] ?? [])['exists']) ? 'ready' : 'missing') . '
    ', + '
    tasks_portal' . (!empty(($arrSummary['tasks_portal'] ?? [])['exists']) ? 'ready' : 'missing') . '
    ', + '
    runtime_acceptance' . (!empty(($arrSummary['runtime_acceptance_portal'] ?? [])['exists']) ? 'ready' : 'missing') . '
    ', + '
    bootstrap_portal' . (!empty(($arrSummary['bootstrap_portal'] ?? [])['exists']) ? 'ready' : 'missing') . '
    ', + '
    releases' . (int)(($arrSummary['release_portal'] ?? [])['releases_count'] ?? 0) . '
    ', + '
    approved_manifests' . (int)(($arrSummary['approved_portal'] ?? [])['manifests_count'] ?? 0) . '
    ', + '
    recent_runs' . (int)(($arrSummary['runs_portal'] ?? [])['runs_count'] ?? 0) . '
    ', + '
    task_runs' . (int)(($arrSummary['tasks_portal'] ?? [])['runs_count'] ?? 0) . '
    ', + '
    runtime_runs' . (int)(($arrSummary['runtime_acceptance_portal'] ?? [])['runs_count'] ?? 0) . '
    ', + '
    runtime_hosts' . (int)(($arrSummary['runtime_acceptance_portal'] ?? [])['hosts_count'] ?? 0) . '
    ', + '
    runtime_failed_hosts' . (int)(($arrSummary['runtime_acceptance_portal'] ?? [])['failed_hosts_count'] ?? 0) . '
    ', + '
    register_ready' . (int)(($arrSummary['bootstrap_portal'] ?? [])['register_ready_count'] ?? 0) . '
    ', + '
    register_existing' . (int)(($arrSummary['bootstrap_portal'] ?? [])['register_existing_count'] ?? 0) . '
    ', + '
    register_done' . (int)(($arrSummary['bootstrap_portal'] ?? [])['register_result_count'] ?? 0) . '
    ', + '
    apply_ready' . (int)(($arrSummary['bootstrap_portal'] ?? [])['apply_ready_count'] ?? 0) . '
    ', + '
    apply_pending' . (int)(($arrSummary['bootstrap_portal'] ?? [])['apply_pending_count'] ?? 0) . '
    ', + '
    apply_done' . (int)(($arrSummary['bootstrap_portal'] ?? [])['apply_result_count'] ?? 0) . '
    ', + '
    import_done' . (int)(($arrSummary['bootstrap_portal'] ?? [])['import_result_count'] ?? 0) . '
    ', + '
    bootstrap_runs' . (int)(($arrSummary['bootstrap_portal'] ?? [])['bundle_run_count'] ?? 0) . '
    ', + '
    bootstrap_run_records' . (int)(($arrSummary['bootstrap_runs_portal'] ?? [])['runs_count'] ?? 0) . '
    ', + '
    bootstrap_run_prepare' . (int)(($arrSummary['bootstrap_runs_portal'] ?? [])['handoff_ready_for_prepare_count'] ?? 0) . '
    ', + '
    bootstrap_run_attention' . (int)(($arrSummary['bootstrap_runs_portal'] ?? [])['handoff_attention_count'] ?? 0) . '
    ', + '
    bootstrap_handoff_hosts' . (int)(($arrSummary['bootstrap_handoff_portal'] ?? [])['hosts_count'] ?? 0) . '
    ', + '
    bootstrap_duty_open' . (int)(($arrSummary['bootstrap_duty_portal'] ?? [])['open_count'] ?? 0) . '
    ', + '
    bootstrap_oncall_open' . (int)(($arrSummary['bootstrap_oncall_portal'] ?? [])['open_count'] ?? 0) . '
    ', + '
    bootstrap_completed' . (int)(($arrSummary['bootstrap_completed_portal'] ?? [])['completed_count'] ?? 0) . '
    ', + '
    bootstrap_published' . (int)(($arrSummary['bootstrap_completed_portal'] ?? [])['published_count'] ?? 0) . '
    ', + '
    bootstrap_import_policy' . self::escape((string)((($arrSummary['bootstrap_portal'] ?? [])['pipeline_import_policy'] ?? [])['mode'] ?? 'off')) . '
    ', + '
    待入库' . (int)(($arrSummary['bootstrap_portal'] ?? [])['handoff_ready_to_register_count'] ?? 0) . '
    ', + '
    待应用' . (int)(($arrSummary['bootstrap_portal'] ?? [])['handoff_ready_to_apply_count'] ?? 0) . '
    ', + '
    可进prepare' . (int)(($arrSummary['bootstrap_portal'] ?? [])['handoff_ready_for_prepare_count'] ?? 0) . '
    ', + '
    待导入' . (int)(($arrSummary['bootstrap_portal'] ?? [])['handoff_ready_to_import_count'] ?? 0) . '
    ', + '
    待跑dry-run' . (int)(($arrSummary['bootstrap_portal'] ?? [])['handoff_ready_to_release_dry_run_count'] ?? 0) . '
    ', + '
    需关注' . (int)(($arrSummary['bootstrap_portal'] ?? [])['handoff_attention_count'] ?? 0) . '
    ', + ]; + + $strLatestRelease = ''; + if (!empty(($arrSummary['release_portal'] ?? [])['latest_release'])) { + $arrLatestRelease = (array)($arrSummary['release_portal']['latest_release'] ?? []); + $strLatestRelease = '

    Latest Release: ' + . self::escape((string)($arrLatestRelease['release_tag'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestRelease['status'] ?? '')) + . '

    '; + } + + $strLatestManifest = ''; + if (!empty(($arrSummary['approved_portal'] ?? [])['latest_manifest'])) { + $arrLatestManifest = (array)($arrSummary['approved_portal']['latest_manifest'] ?? []); + $strLatestManifest = '

    Latest Approved Manifest: ' + . '' + . self::escape((string)($arrLatestManifest['filename'] ?? '')) + . '' + . ' / ' + . (int)($arrLatestManifest['items_count'] ?? 0) + . ' items

    '; + } + + $strLatestRun = ''; + if (!empty(($arrSummary['runs_portal'] ?? [])['latest_run'])) { + $arrLatestRun = (array)($arrSummary['runs_portal']['latest_run'] ?? []); + $strLatestRun = '

    Latest Run: ' + . '' + . self::escape((string)($arrLatestRun['run_name'] ?? '')) + . '' + . ' / ' + . self::escape((string)($arrLatestRun['status'] ?? '')) + . '

    '; + } + + $strLatestTask = ''; + if (!empty(($arrSummary['tasks_portal'] ?? [])['latest_task'])) { + $arrLatestTask = (array)($arrSummary['tasks_portal']['latest_task'] ?? []); + $strLatestTask = '

    Latest Task: ' + . '' + . self::escape((string)($arrLatestTask['task_slug'] ?? '')) + . '' + . ' / ' + . self::escape((string)($arrLatestTask['status'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestTask['health_status'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestTask['summary'] ?? '')) + . '

    '; + } + + $strLatestRuntimeAcceptance = ''; + if (!empty(($arrSummary['runtime_acceptance_portal'] ?? [])['latest_run'])) { + $arrLatestRuntimeAcceptance = (array)($arrSummary['runtime_acceptance_portal']['latest_run'] ?? []); + $strLatestRuntimeAcceptance = '

    Latest Runtime Acceptance: ' + . '' + . self::escape((string)($arrLatestRuntimeAcceptance['run_name'] ?? '')) + . '' + . ' / ' + . self::escape((string)($arrLatestRuntimeAcceptance['status'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestRuntimeAcceptance['summary'] ?? '')) + . '

    '; + } + $strRuntimeRerunCommand = ''; + if (!empty(($arrSummary['runtime_acceptance_portal'] ?? [])['rerun_failed_hosts_command'])) { + $strRuntimeRerunCommand = '

    Runtime Failed Hosts Rerun: ' + . self::escape((string)(($arrSummary['runtime_acceptance_portal'] ?? [])['rerun_failed_hosts_command'] ?? '')) + . '

    '; + } + + $strLatestRuntimeTask = ''; + if (!empty($arrSummary['latest_runtime_acceptance_task'])) { + $arrLatestRuntimeTask = (array)($arrSummary['latest_runtime_acceptance_task'] ?? []); + $strLatestRuntimeTask = '

    Latest Runtime Acceptance Task: ' + . '' + . self::escape((string)($arrLatestRuntimeTask['run_name'] ?? ($arrLatestRuntimeTask['task_slug'] ?? ''))) + . '' + . ' / ' + . self::escape((string)($arrLatestRuntimeTask['status'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestRuntimeTask['summary'] ?? '')) + . '

    '; + } + + $strRuntimeFailureCoverage = ''; + if (!empty(($arrSummary['runtime_acceptance_portal'] ?? [])['failed_hosts'])) { + $arrFailedHosts = array_map(static function (array $arrHost): string { + $strDetailSlug = trim((string)($arrHost['detail_slug'] ?? '')); + $strHost = (string)($arrHost['host'] ?? ''); + $strFailedSuites = implode(', ', array_map(static function (array $arrSuite): string { + return (string)($arrSuite['suite_key'] ?? '') . ':' . (int)($arrSuite['count'] ?? 0); + }, array_slice((array)($arrHost['top_failed_suites'] ?? []), 0, 3))); + $strRerunCommand = (string)($arrHost['rerun_command'] ?? ('php scripts/seo_copy_runtime_acceptance_host_run.php ' . $strHost . ' --format=text')); + $strLikelyIssue = trim((string)($arrHost['likely_issue'] ?? '')); + $strLink = $strDetailSlug !== '' + ? '' . self::escape($strHost) . '' + : self::escape($strHost); + return $strLink . ' (' . (int)($arrHost['failed'] ?? 0) . ' failed, ' . self::escape($strFailedSuites) + . ($strLikelyIssue !== '' ? ', issue=' . self::escape($strLikelyIssue) : '') + . ', rerun=' . self::escape($strRerunCommand) . ')'; + }, array_values(array_filter((array)(($arrSummary['runtime_acceptance_portal'] ?? [])['failed_hosts'] ?? []), 'is_array'))); + $strRuntimeFailureCoverage = '

    Runtime Failed Hosts: ' + . implode(' / ', $arrFailedHosts) + . '

    '; + } + + $strLatestBootstrap = ''; + if (!empty(($arrSummary['bootstrap_portal'] ?? [])['latest_bundle'])) { + $arrLatestBootstrap = (array)($arrSummary['bootstrap_portal']['latest_bundle'] ?? []); + $strLatestBootstrap = '

    Latest Bootstrap Bundle: ' + . '' + . self::escape((string)($arrLatestBootstrap['host'] ?? '')) + . '' + . ' / ' + . self::escape((string)($arrLatestBootstrap['structured_policy'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrap['register_status'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrap['apply_status'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrap['register_result_status'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrap['apply_result_status'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrap['bundle_run_status'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrap['handoff_stage_label'] ?? '')) + . ' / 查看启动包总览

    '; + } + + $arrBootstrapImportPolicy = (array)(($arrSummary['bootstrap_portal'] ?? [])['pipeline_import_policy'] ?? []); + $strBootstrapImportPolicy = '

    Bootstrap Import Policy: ' + . self::escape((string)($arrBootstrapImportPolicy['mode'] ?? 'off')) + . ' / requested=' . (((bool)($arrBootstrapImportPolicy['requested'] ?? false)) ? 'yes' : 'no') + . ' / armed=' . (((bool)($arrBootstrapImportPolicy['armed'] ?? false)) ? 'yes' : 'no') + . ' / effective=' . (((bool)($arrBootstrapImportPolicy['enabled'] ?? false)) ? 'yes' : 'no') + . ' / ' . self::escape((string)($arrBootstrapImportPolicy['source_path'] ?? '')) . '' + . ' / ' + . self::escape((string)($arrBootstrapImportPolicy['summary'] ?? '')) + . '

    '; + + $arrReleaseStatusTotals = (array)(($arrSummary['release_portal'] ?? [])['status_totals'] ?? []); + $strReleaseStatusSummary = '
    ' + . '
    published' . (int)($arrReleaseStatusTotals['published'] ?? 0) . '
    ' + . '
    publish_dry_run' . (int)($arrReleaseStatusTotals['publish_dry_run'] ?? 0) . '
    ' + . '
    rollbacked' . (int)($arrReleaseStatusTotals['rollbacked'] ?? 0) . '
    ' + . '
    '; + + $arrObservationLinks = []; + $arrLatestRelease = (array)(($arrSummary['release_portal'] ?? [])['latest_release'] ?? []); + $arrLatestRun = (array)(($arrSummary['runs_portal'] ?? [])['latest_run'] ?? []); + $arrLatestRuntimeAcceptance = (array)(($arrSummary['runtime_acceptance_portal'] ?? [])['latest_run'] ?? []); + foreach ([ + '首页总览' => './index.html', + '最新发布总览' => './latest-index.html', + '最新发布详情' => !empty($arrLatestRelease['release_tag']) ? './releases/' . rawurlencode((string)$arrLatestRelease['release_tag']) . '.html' : './latest-index.html', + '最新运行记录' => !empty($arrLatestRun['detail_slug']) ? './runs/details/' . rawurlencode((string)$arrLatestRun['detail_slug']) . '.html' : './runs/index.html', + '最新运行时验收' => !empty($arrLatestRuntimeAcceptance['detail_slug']) ? './runtime-acceptance/details/' . rawurlencode((string)$arrLatestRuntimeAcceptance['detail_slug']) . '.html' : './runtime-acceptance/index.html', + 'approved 总览' => './approved/approved-index.html', + 'runs 总览' => './runs/index.html', + 'runtime acceptance 总览' => './runtime-acceptance/index.html', + '启动包总览' => './bootstrap/index.html', + '接手队列' => './bootstrap/handoff/index.html', + '值班待办' => './bootstrap/duty/index.html', + '值班入口' => './bootstrap/oncall/index.html', + '已完成启动包' => './bootstrap/completed/index.html', + '启动包运行记录' => './bootstrap/runs/index.html', + '发布 SOP' => './ops/index.html', + ] as $strLabel => $strHref) { + $arrObservationLinks[] = '' . self::escape($strLabel) . ''; + } + + $strLatestBootstrapRun = ''; + if (!empty(($arrSummary['bootstrap_runs_portal'] ?? [])['latest_run'])) { + $arrLatestBootstrapRun = (array)($arrSummary['bootstrap_runs_portal']['latest_run'] ?? []); + $strLatestBootstrapRun = '

    Latest Bootstrap Run: ' + . '' + . self::escape((string)($arrLatestBootstrapRun['run_name'] ?? '')) + . '' + . ' / ' + . self::escape((string)($arrLatestBootstrapRun['overall_status'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrapRun['handoff_stage_label'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrapRun['summary'] ?? '')) + . '

    '; + } + + $strLatestBootstrapHandoff = ''; + if (!empty(($arrSummary['bootstrap_handoff_portal'] ?? [])['latest_entry'])) { + $arrLatestBootstrapHandoff = (array)($arrSummary['bootstrap_handoff_portal']['latest_entry'] ?? []); + $strLatestBootstrapHandoff = '

    Latest Bootstrap Handoff: ' + . self::escape((string)($arrLatestBootstrapHandoff['host'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrapHandoff['handoff_stage_label'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrapHandoff['next_step'] ?? '')) + . ' / 查看接手队列

    '; + } + + $strLatestBootstrapDuty = ''; + if (!empty(($arrSummary['bootstrap_duty_portal'] ?? [])['latest_entry'])) { + $arrLatestBootstrapDuty = (array)($arrSummary['bootstrap_duty_portal']['latest_entry'] ?? []); + $strLatestBootstrapDuty = '

    Latest Bootstrap Duty: ' + . self::escape((string)($arrLatestBootstrapDuty['host'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrapDuty['priority_label'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrapDuty['todo_title'] ?? '')) + . ' / 查看值班待办

    '; + } + + $strLatestBootstrapOncall = ''; + if (!empty(($arrSummary['bootstrap_oncall_portal'] ?? [])['latest_entry'])) { + $arrLatestBootstrapOncall = (array)($arrSummary['bootstrap_oncall_portal']['latest_entry'] ?? []); + $strLatestBootstrapOncall = '

    Latest Bootstrap Oncall: ' + . self::escape((string)($arrLatestBootstrapOncall['host'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrapOncall['priority_label'] ?? '')) + . ' / ' + . self::escape((string)($arrLatestBootstrapOncall['todo_title'] ?? '')) + . ' / 查看值班入口

    '; + } + + $strLatestBootstrapCompleted = ''; + if (!empty(($arrSummary['bootstrap_completed_portal'] ?? [])['latest_entry'])) { + $arrLatestBootstrapCompleted = (array)($arrSummary['bootstrap_completed_portal']['latest_entry'] ?? []); + $strLatestBootstrapCompleted = '

    Latest Completed Bootstrap: ' + . '' + . self::escape((string)($arrLatestBootstrapCompleted['host'] ?? '')) + . '' + . ' / ' + . self::escape((string)($arrLatestBootstrapCompleted['handoff_stage_label'] ?? '')) + . ' / 查看已完成启动包

    '; + } + + $arrTaskRows = []; + foreach ((array)(($arrSummary['tasks_portal'] ?? [])['latest_tasks'] ?? []) as $arrTask) { + if (!is_array($arrTask)) { + continue; + } + + $arrTaskRows[] = '' + . '' . self::escape((string)($arrTask['task_slug'] ?? '')) . '' + . '' . self::escape((string)($arrTask['status'] ?? '')) . '' + . '' . self::escape((string)($arrTask['health_status'] ?? '')) . '' + . '' . self::escape((string)($arrTask['summary'] ?? '')) . '' + . '' . self::escape((string)($arrTask['updated_at'] ?? '')) . '' + . ''; + } + + $arrRuntimeAcceptanceRows = []; + foreach ((array)(($arrSummary['runtime_acceptance_portal'] ?? [])['runs'] ?? []) as $arrRun) { + if (!is_array($arrRun)) { + continue; + } + + $arrRuntimeAcceptanceRows[] = '' + . '' . self::escape((string)($arrRun['run_name'] ?? '')) . '' + . '' . self::escape((string)($arrRun['host'] ?? '')) . '' + . '' . (int)($arrRun['video_id'] ?? 0) . '' + . '' . (int)($arrRun['forge_id'] ?? 0) . '' + . '' . self::escape((string)($arrRun['status'] ?? '')) . '' + . '' . (int)($arrRun['passed_suites'] ?? 0) . '/' . (int)($arrRun['suite_count'] ?? 0) . '' + . '' . self::escape((string)($arrRun['updated_at'] ?? '')) . '' + . ''; + } + + $arrBootstrapRows = []; + foreach ((array)(($arrSummary['bootstrap_portal'] ?? [])['bundles'] ?? []) as $arrBundle) { + if (!is_array($arrBundle)) { + continue; + } + + $arrBootstrapRows[] = '' + . '' . self::escape((string)($arrBundle['host'] ?? '')) . '' + . '' . self::escape((string)($arrBundle['structured_policy'] ?? '')) . '' + . '' . self::escape((string)($arrBundle['reference_mode'] ?? '')) . '' + . '' . self::escape((string)($arrBundle['pipeline_import_mode'] ?? '')) . '' + . '' . self::escape((string)($arrBundle['register_status'] ?? '')) . '' + . '' . (int)($arrBundle['register_create_fields'] ?? 0) . '' + . '' . self::escape((string)($arrBundle['register_result_status'] ?? '')) . '' + . '' . self::escape((string)($arrBundle['apply_status'] ?? '')) . '' + . '' . (int)($arrBundle['apply_changed_fields'] ?? 0) . '' + . '' . self::escape((string)($arrBundle['apply_result_status'] ?? '')) . '' + . '' . self::escape((string)($arrBundle['handoff_stage_label'] ?? '')) . '' + . '' . self::escape((string)($arrBundle['bundle_run_status'] ?? '')) . '' + . '' . (int)($arrBundle['items_count'] ?? 0) . '' + . '' . self::escape((string)($arrBundle['generated_at'] ?? '')) . '' + . ''; + } + + $arrBootstrapRunRows = []; + foreach ((array)(($arrSummary['bootstrap_runs_portal'] ?? [])['runs'] ?? []) as $arrRun) { + if (!is_array($arrRun)) { + continue; + } + + $arrBootstrapRunRows[] = '' + . '' . self::escape((string)($arrRun['run_name'] ?? '')) . '' + . '' . self::escape((string)($arrRun['host'] ?? '')) . '' + . '' . self::escape((string)($arrRun['overall_status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['register_status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['apply_status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['handoff_stage_label'] ?? '')) . '' + . '' . ($arrRun['dry_run'] ? 'yes' : 'no') . '' + . '' . (int)($arrRun['errors_count'] ?? 0) . '' + . '' . self::escape((string)($arrRun['updated_at'] ?? '')) . '' + . ''; + } + + $arrBootstrapHandoffRows = []; + foreach ((array)(($arrSummary['bootstrap_handoff_portal'] ?? [])['entries'] ?? []) as $arrEntry) { + if (!is_array($arrEntry)) { + continue; + } + + $arrBootstrapHandoffRows[] = '' + . '' . self::escape((string)($arrEntry['host'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['source_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['next_step'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['next_command'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '' + . ''; + } + + $arrBootstrapDutyRows = []; + foreach ((array)(($arrSummary['bootstrap_duty_portal'] ?? [])['entries'] ?? []) as $arrEntry) { + if (!is_array($arrEntry)) { + continue; + } + + $arrBootstrapDutyRows[] = '' + . '' . self::escape((string)($arrEntry['host'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['priority_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['owner_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['todo_title'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['next_command'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '' + . ''; + } + + $arrBootstrapOncallRows = []; + foreach ((array)(($arrSummary['bootstrap_oncall_portal'] ?? [])['top_entries'] ?? []) as $arrEntry) { + if (!is_array($arrEntry)) { + continue; + } + + $arrBootstrapOncallRows[] = '' + . '' . self::escape((string)($arrEntry['host'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['priority_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['owner_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['todo_title'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['next_command'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '' + . ''; + } + + $arrBootstrapCompletedRows = []; + foreach ((array)(($arrSummary['bootstrap_completed_portal'] ?? [])['top_entries'] ?? []) as $arrEntry) { + if (!is_array($arrEntry)) { + continue; + } + + $arrBootstrapCompletedRows[] = '' + . '' . self::escape((string)($arrEntry['host'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['handoff_stage_label'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['structured_policy'] ?? '')) . '' + . '' . self::escape((string)($arrEntry['publish_result_status'] ?? '')) . '' + . '' . (int)($arrEntry['publish_result_published_count'] ?? 0) . '' + . '' . self::escape((string)($arrEntry['updated_at'] ?? '')) . '' + . ''; + } + + return self::wrapHtml( + 'SEO Copy Portal Home', + '

    内部只读总览页,统一展示已发批次与已批准清单两层入口。

    ' + . '
    ' . implode('', $arrCards) . '
    ' + . '

    Release Status Summary

    ' + . $strReleaseStatusSummary + . '

    Observation Shortcuts

    ' + . '

    ' . implode(' | ', $arrObservationLinks) . '

    ' + . $strLatestRelease + . $strLatestManifest + . $strLatestRun + . $strLatestTask + . $strLatestRuntimeAcceptance + . $strLatestRuntimeTask + . $strRuntimeRerunCommand + . $strRuntimeFailureCoverage + . $strLatestBootstrap + . $strBootstrapImportPolicy + . $strLatestBootstrapRun + . $strLatestBootstrapHandoff + . $strLatestBootstrapDuty + . $strLatestBootstrapOncall + . $strLatestBootstrapCompleted + . '

    查看已发批次总览 | 查看 approved 清单总览 | 查看门禁运行记录 | 查看定时任务健康页 | 查看运行时总验收 | 查看新域名启动包总览 | 查看启动包接手队列 | 查看值班待办 | 查看值班入口 | 查看已完成启动包 | 查看启动包运行记录 | 查看发布 SOP

    ' + . '

    Recent Releases

    ' + . implode('', $arrReleaseRows) + . '
    release_tagstatuspublishedlast_event_at
    ' + . '

    Approved Manifests

    ' + . implode('', $arrManifestRows) + . '
    filenamerelease_tagitemsscenes
    ' + . '

    Recent Runs

    ' + . implode('', $arrRunRows) + . '
    run_namerelease_tagstatusmanifestverify_issuesfront_failedupdated_at
    ' + . '

    Scheduled Task Health

    ' + . implode('', $arrTaskRows) + . '
    task_slugstatushealthsummaryupdated_at
    ' + . '

    Runtime Acceptance

    ' + . implode('', $arrRuntimeAcceptanceRows) + . '
    run_namehostvideo_idforge_idstatussuitesupdated_at
    ' + . '

    Bootstrap Bundles

    ' + . implode('', $arrBootstrapRows) + . '
    hostpolicyreferenceimport_policyregistercreate_fieldsregister_resultapplychangedapply_resulthandoffrun_statusitemsgenerated_at
    ' + . '

    Bootstrap Runs

    ' + . implode('', $arrBootstrapRunRows) + . '
    run_namehoststatusregisterapplyhandoffdry_runerrorsupdated_at
    ' + . '

    Bootstrap Handoff Queue

    ' + . implode('', $arrBootstrapHandoffRows) + . '
    hosthandoffsourcenext_stepnext_commandupdated_at
    ' + . '

    Bootstrap Duty Queue

    ' + . implode('', $arrBootstrapDutyRows) + . '
    hostpriorityownerhandofftodonext_commandupdated_at
    ' + . '

    Bootstrap Oncall Desk

    ' + . implode('', $arrBootstrapOncallRows) + . '
    hostpriorityownertodonext_commandupdated_at
    ' + . '

    Completed Bootstrap Bundles

    ' + . implode('', $arrBootstrapCompletedRows) + . '
    hoststagepolicypublish_statuspublishedupdated_at
    ' + ); + } + + protected static function buildOpsSummary(string $strPortalRoot): array + { + $arrPortalSummary = self::buildSummary($strPortalRoot); + $arrLatestBootstrap = (array)($arrPortalSummary['bootstrap_portal']['latest_bundle'] ?? []); + $arrBootstrapImportPolicy = (array)($arrPortalSummary['bootstrap_portal']['pipeline_import_policy'] ?? []); + $arrBootstrapEnvTemplates = [ + [ + 'name' => 'Baseline Safe Defaults', + 'scope' => '通用最低安全基线', + 'task_policy' => '只放行 portal 刷新;bootstrap 相关变量全部保持 dry-run/off。', + 'path' => '.example.env', + 'note' => '空的 PLAN_TASK_CODE_ALLOWLIST = 放行所有已启用任务,不能留空。', + 'preview_command' => 'php scripts/domain_bootstrap_env_apply.php --template=baseline --env-file=.env --dry-run=1 --format=text', + 'apply_command' => 'php scripts/domain_bootstrap_env_apply.php --template=baseline --env-file=.env --dry-run=0 --format=text', + ], + [ + 'name' => 'Bootstrap Testing Safe', + 'scope' => '测试服/预发布环境', + 'task_policy' => '放行 prepare/refresh/bundles/oncall/pipeline;默认 dry-run=1,import/release/publish 全关。', + 'path' => '.env.bootstrap.testing.safe.example', + 'note' => '适合验证 bundle/oncall/pipeline 主链,不默认写入 AI 导入和正式发布。', + 'preview_command' => 'php scripts/domain_bootstrap_env_apply.php --template=testing-safe --env-file=.env --dry-run=1 --format=text', + 'apply_command' => 'php scripts/domain_bootstrap_env_apply.php --template=testing-safe --env-file=.env --dry-run=0 --format=text', + ], + [ + 'name' => 'Bootstrap Production Safe', + 'scope' => '正式服只读观察基线', + 'task_policy' => '放行 refresh/bundles/oncall/pipeline;prepare/import/release/publish 默认全关。', + 'path' => '.env.bootstrap.production.safe.example', + 'note' => '适合先观察 portal/oncall/task,不让正式服自动前推内容写入。', + 'preview_command' => 'php scripts/domain_bootstrap_env_apply.php --template=production-safe --env-file=.env --dry-run=1 --format=text', + 'apply_command' => 'php scripts/domain_bootstrap_env_apply.php --template=production-safe --env-file=.env --dry-run=0 --format=text', + ], + ]; + $strLatestBootstrapHost = (string)($arrLatestBootstrap['host'] ?? ''); + $strLatestBootstrapDetail = trim((string)($arrLatestBootstrap['detail_slug'] ?? '')) !== '' + ? '../bootstrap/details/' . rawurlencode((string)($arrLatestBootstrap['detail_slug'] ?? '')) . '.html' + : '../bootstrap/index.html'; + + $arrSteps = [ + [ + 'step' => '0. 准备新域名启动包', + 'goal' => '生成 batch1 样板、域名 SEO 策略样板和 apply 预览摘要。', + 'command' => 'php scripts/seo_copy_domain_bootstrap.php --detail-id= --detail-slug= --search-keyword= --category-parent= --category-child= --bundle-root=storage/domain_bootstrap/ --index-root=storage/domain_bootstrap --portal-root=public/_seo_copy_release', + 'portal_entry' => '../bootstrap/index.html', + ], + [ + 'step' => '1. 检查 approved 清单', + 'goal' => '确认 release_tag、host、scene、page_parts 已批准。', + 'command' => 'php scripts/seo_copy_approved_index.php --approved-dir=data/seo_copy_jobs/approved --output-root=public/_seo_copy_release/approved --format=text', + 'portal_entry' => '../approved/approved-index.html', + ], + [ + 'step' => '2. 先跑 dry-run 门禁', + 'goal' => '先过 verify/front_verify,再看是否允许发布。', + 'command' => 'php scripts/seo_copy_release_run.php --approved-dir=data/seo_copy_jobs/approved --source-root=data/seo_copy --base-url=https:// --portal-root=public/_seo_copy_release --dry-run=1 --format=text', + 'portal_entry' => '../runs/index.html', + ], + [ + 'step' => '3. 正式发布', + 'goal' => '在门禁通过后正式写入目标目录并刷新 release 索引。', + 'command' => 'php scripts/seo_copy_release_run.php --approved-dir=data/seo_copy_jobs/approved --source-root=data/seo_copy --base-url=https:// --portal-root=public/_seo_copy_release --format=text', + 'portal_entry' => '../latest-index.html', + ], + [ + 'step' => '4. 必要时按批次回滚', + 'goal' => '根据 release_tag 回滚本次发布带来的覆盖或新增。', + 'command' => 'php scripts/seo_copy_release_rollback.php --release-tag= --log-dir=storage/seo_copy_publish_logs --portal-root=public/_seo_copy_release --format=text', + 'portal_entry' => '../latest-index.html', + ], + [ + 'step' => '5. 只读核对门户状态', + 'goal' => '确认首页、批次、approved 与 runs 三层入口都已刷新。', + 'command' => 'php scripts/seo_copy_portal_home.php --portal-root=public/_seo_copy_release --format=text', + 'portal_entry' => '../index.html', + ], + ]; + $arrBootstrapSteps = [ + [ + 'step' => 'B0. 生成启动包', + 'goal' => '先产出 bundle、site-register.sample.json、site-bootstrap.sample.json 和首轮 preview。', + 'command' => 'php scripts/seo_copy_domain_bootstrap.php --detail-id= --detail-slug= --search-keyword= --category-parent= --category-child= --bundle-root=storage/domain_bootstrap_bundles/ --index-root=storage/domain_bootstrap_bundles --portal-root=public/_seo_copy_release', + 'portal_entry' => '../bootstrap/index.html', + ], + [ + 'step' => 'B1. 先看 bundle 详情', + 'goal' => '确认 host、structured_policy、register/apply preview 和建议命令都正确。', + 'command' => '打开 portal 的 bootstrap details 页面,优先看最新 bundle 的详情页。', + 'portal_entry' => $strLatestBootstrapDetail, + ], + [ + 'step' => 'B2. 跑高层 dry-run', + 'goal' => '优先用一条命令串行验证 register + apply,避免先拆成多条脚本。', + 'command' => 'php scripts/domain_bootstrap_run.php --dry-run=1 --portal-root=public/_seo_copy_release --format=text', + 'portal_entry' => '../bootstrap/index.html', + ], + [ + 'step' => 'B3. 再做正式 register/apply', + 'goal' => 'dry-run 正常后,再真正创建 domain 记录并写入域名 SEO 策略。', + 'command' => 'php scripts/domain_bootstrap_run.php --dry-run=0 --portal-root=public/_seo_copy_release --format=text', + 'portal_entry' => '../bootstrap/index.html', + ], + [ + 'step' => 'B4. 接 prepare 与 release dry-run', + 'goal' => '域名入库和策略落地后,再进入补料 prepare 与门禁链。', + 'command' => 'php scripts/seo_copy_release_run.php --approved-dir= --source-root= --base-url=https:// --portal-root=public/_seo_copy_release --dry-run=1 --format=text', + 'portal_entry' => '../runs/index.html', + ], + ]; + $arrBootstrapImportSteps = [ + [ + 'step' => 'I0. 保持默认关闭', + 'goal' => '日常默认保持 off,避免 task 主链自动写入 AI 结果。', + 'command' => 'SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_IMPORT=0 && SEO_COPY_BOOTSTRAP_PIPELINE_IMPORT_ARMED=0', + 'portal_entry' => '../bootstrap/oncall/index.html', + ], + [ + 'step' => 'I1. 只请求不武装', + 'goal' => '需要先暴露接手提示但暂不允许写入时,使用 requested-not-armed。', + 'command' => 'SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_IMPORT=1 && SEO_COPY_BOOTSTRAP_PIPELINE_IMPORT_ARMED=0', + 'portal_entry' => '../tasks/index.html', + ], + [ + 'step' => 'I2. 按固定目录放行', + 'goal' => '确认 AI 结果已经集中落到单个目录后,显式 armed 并指向 ai_result_dir。', + 'command' => 'SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_IMPORT=1 SEO_COPY_BOOTSTRAP_PIPELINE_IMPORT_ARMED=1 SEO_COPY_BOOTSTRAP_PIPELINE_AI_RESULT_DIR= php scripts/domain_bootstrap_pipeline_run.php --host= --step-limit=3 --dry-run=0 --allow-import=1 --ai-result-dir= --allow-release-dry-run=1 --format=text', + 'portal_entry' => '../bootstrap/details/' . (trim((string)($arrLatestBootstrap['detail_slug'] ?? '')) !== '' ? rawurlencode((string)($arrLatestBootstrap['detail_slug'] ?? '')) . '.html' : ''), + ], + [ + 'step' => 'I3. 按根目录放行', + 'goal' => '需要按 host 自动找 AI 结果目录时,显式 armed 并指向 ai_result_root。', + 'command' => 'SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_IMPORT=1 SEO_COPY_BOOTSTRAP_PIPELINE_IMPORT_ARMED=1 SEO_COPY_BOOTSTRAP_PIPELINE_AI_RESULT_ROOT= php scripts/domain_bootstrap_pipeline_run.php --host= --step-limit=3 --dry-run=0 --allow-import=1 --ai-result-root= --allow-release-dry-run=1 --format=text', + 'portal_entry' => '../bootstrap/details/' . (trim((string)($arrLatestBootstrap['detail_slug'] ?? '')) !== '' ? rawurlencode((string)($arrLatestBootstrap['detail_slug'] ?? '')) . '.html' : ''), + ], + [ + 'step' => 'I4. task 自动调度前复核', + 'goal' => '如果准备让 task/swoole 自动吃结果目录,先确认 tasks 页显示的 import 模式与当前计划一致。', + 'command' => '先查看 tasks/index.html 中 seo-copy-run-bootstrap-pipeline 的 import=off|requested-not-armed|dir|root,再决定是否把 armed 配置写入 .env', + 'portal_entry' => '../tasks/index.html', + ], + ]; + $arrCommonFailures = [ + [ + 'status' => 'verify_failed', + 'meaning' => '补料目录存在缺字段、空字段或不可读 JSON。', + 'suggestion' => '先跑 seo_copy_batch_verify / seo_copy_release_run --dry-run,修正 source_root 里的 JSON 后再重试。', + ], + [ + 'status' => 'front_verify_failed', + 'meaning' => '前台页面没有命中补料,或 base-url/host/route 不匹配。', + 'suggestion' => '核对 approved manifest 的 host、scene、page_parts,以及当前站点真实路由和 base-url。', + ], + [ + 'status' => 'publish_dry_run', + 'meaning' => '本次只是演练,没有正式写入目标目录。', + 'suggestion' => '确认 dry-run 输出无误后,去掉 --dry-run=1 再跑正式发布。', + ], + [ + 'status' => 'rollbacked', + 'meaning' => '该 release_tag 已执行回滚。', + 'suggestion' => '回到 latest-index 和 runs 详情页确认是误发、字段错还是命中错,再决定是否重发。', + ], + [ + 'status' => 'error', + 'meaning' => '脚本运行期抛错,中途退出。', + 'suggestion' => '优先查看 runs 详情页里的 error 摘要,再检查 CLI 参数、目录权限和 base-url。', + ], + [ + 'status' => 'domain_not_found', + 'meaning' => '启动包预览时未在站点表找到目标域名。', + 'suggestion' => '先创建目标域名记录,再重新执行 bootstrap preview/apply。', + ], + [ + 'status' => 'ready_to_create', + 'meaning' => '新域名还没入库,但 register 样板已足够创建记录。', + 'suggestion' => '优先跑 domain_bootstrap_run.php --dry-run=0,或拆步执行 register 正式创建。', + ], + [ + 'status' => 'dry_run_needs_attention', + 'meaning' => '高层 bundle run 已跑完 dry-run,但 register/apply 至少有一步需要人工关注。', + 'suggestion' => '先打开 bootstrap detail 页确认 preview 差异,再决定是否正式执行 register/apply。', + ], + [ + 'status' => 'blocked_import', + 'meaning' => '当前已到待导入阶段,但 import 目录未提供、未放行,或策略仍是 off。', + 'suggestion' => '先确认 import policy 是 off、requested-not-armed、dir 还是 root;只有 armed 且目录正确时,才执行非 dry-run import。', + ], + [ + 'status' => 'blocked_import_dry_run', + 'meaning' => '当前命令仍处于 dry-run,系统不会执行真实 AI 导入写入。', + 'suggestion' => '核对目录和策略无误后,改用 --dry-run=0 执行高层 pipeline import。', + ], + ]; + + return [ + 'generated_at' => date('c'), + 'portal_root' => $strPortalRoot, + 'title' => 'SEO Copy Release SOP', + 'latest_bootstrap_host' => $strLatestBootstrapHost, + 'latest_bootstrap_detail' => $strLatestBootstrapDetail, + 'bootstrap_import_policy' => $arrBootstrapImportPolicy, + 'bootstrap_env_templates' => $arrBootstrapEnvTemplates, + 'bootstrap_env_warning' => '不要把 PLAN_TASK_CODE_ALLOWLIST 留空;在当前 PlanTask 逻辑里,空值等于放行所有已启用 pt_code。新增的 env 模板脚本只会更新 PLAN_TASK_CODE_* 与 SEO_COPY_BOOTSTRAP_*,不会覆盖数据库、Redis、代理等业务变量。', + 'steps' => $arrSteps, + 'bootstrap_steps' => $arrBootstrapSteps, + 'bootstrap_import_steps' => $arrBootstrapImportSteps, + 'common_failures' => $arrCommonFailures, + 'quick_links' => [ + ['label' => '门户首页', 'href' => '../index.html'], + ['label' => '已发批次', 'href' => '../latest-index.html'], + ['label' => 'approved 清单', 'href' => '../approved/approved-index.html'], + ['label' => '门禁运行记录', 'href' => '../runs/index.html'], + ['label' => '定时任务健康页', 'href' => '../tasks/index.html'], + ['label' => '新域名启动包', 'href' => '../bootstrap/index.html'], + ['label' => '启动包接手队列', 'href' => '../bootstrap/handoff/index.html'], + ['label' => '启动包值班待办', 'href' => '../bootstrap/duty/index.html'], + ['label' => '启动包值班入口', 'href' => '../bootstrap/oncall/index.html'], + ['label' => '启动包运行记录', 'href' => '../bootstrap/runs/index.html'], + ], + ]; + } + + protected static function renderOpsHtml(array $arrSummary): string + { + $arrQuickLinks = []; + foreach ((array)($arrSummary['quick_links'] ?? []) as $arrLink) { + if (!is_array($arrLink)) { + continue; + } + + $strLabel = trim((string)($arrLink['label'] ?? '')); + $strHref = trim((string)($arrLink['href'] ?? '')); + if ($strLabel === '' || $strHref === '') { + continue; + } + + $arrQuickLinks[] = '' . self::escape($strLabel) . ''; + } + + $arrRows = []; + foreach ((array)($arrSummary['steps'] ?? []) as $arrStep) { + if (!is_array($arrStep)) { + continue; + } + + $arrRows[] = '' + . '' . self::escape((string)($arrStep['step'] ?? '')) . '' + . '' . self::escape((string)($arrStep['goal'] ?? '')) . '' + . '' . self::escape((string)($arrStep['command'] ?? '')) . '' + . '' . self::escape((string)($arrStep['portal_entry'] ?? './index.html')) . '' + . ''; + } + + $arrFailureRows = []; + foreach ((array)($arrSummary['common_failures'] ?? []) as $arrFailure) { + if (!is_array($arrFailure)) { + continue; + } + + $arrFailureRows[] = '' + . '' . self::escape((string)($arrFailure['status'] ?? '')) . '' + . '' . self::escape((string)($arrFailure['meaning'] ?? '')) . '' + . '' . self::escape((string)($arrFailure['suggestion'] ?? '')) . '' + . ''; + } + + $arrBootstrapRows = []; + foreach ((array)($arrSummary['bootstrap_steps'] ?? []) as $arrStep) { + if (!is_array($arrStep)) { + continue; + } + + $arrBootstrapRows[] = '' + . '' . self::escape((string)($arrStep['step'] ?? '')) . '' + . '' . self::escape((string)($arrStep['goal'] ?? '')) . '' + . '' . self::escape((string)($arrStep['command'] ?? '')) . '' + . '' . self::escape((string)($arrStep['portal_entry'] ?? '../bootstrap/index.html')) . '' + . ''; + } + + $arrBootstrapImportRows = []; + foreach ((array)($arrSummary['bootstrap_import_steps'] ?? []) as $arrStep) { + if (!is_array($arrStep)) { + continue; + } + + $arrBootstrapImportRows[] = '' + . '' . self::escape((string)($arrStep['step'] ?? '')) . '' + . '' . self::escape((string)($arrStep['goal'] ?? '')) . '' + . '' . self::escape((string)($arrStep['command'] ?? '')) . '' + . '' . self::escape((string)($arrStep['portal_entry'] ?? '../tasks/index.html')) . '' + . ''; + } + + $arrBootstrapEnvRows = []; + foreach ((array)($arrSummary['bootstrap_env_templates'] ?? []) as $arrTemplate) { + if (!is_array($arrTemplate)) { + continue; + } + + $arrBootstrapEnvRows[] = '' + . '' . self::escape((string)($arrTemplate['name'] ?? '')) . '' + . '' . self::escape((string)($arrTemplate['scope'] ?? '')) . '' + . '' . self::escape((string)($arrTemplate['task_policy'] ?? '')) . '' + . '' . self::escape((string)($arrTemplate['path'] ?? '')) . '' + . '' . self::escape((string)($arrTemplate['preview_command'] ?? '')) . '' + . '' . self::escape((string)($arrTemplate['apply_command'] ?? '')) . '' + . '' . self::escape((string)($arrTemplate['note'] ?? '')) . '' + . ''; + } + + $strLatestBootstrap = trim((string)($arrSummary['latest_bootstrap_host'] ?? '')) !== '' + ? '

    当前最新 bundle: ' + . '' + . self::escape((string)($arrSummary['latest_bootstrap_host'] ?? '')) + . '

    ' + : ''; + $arrImportPolicy = (array)($arrSummary['bootstrap_import_policy'] ?? []); + $strBootstrapImportPolicy = '

    当前 Import Policy: ' + . 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') + . ' / ' . self::escape((string)($arrImportPolicy['source_path'] ?? '')) . '' + . ' / ' + . self::escape((string)($arrImportPolicy['summary'] ?? '')) + . '

    '; + $strBootstrapEnvWarning = '

    环境模板提醒: ' + . self::escape((string)($arrSummary['bootstrap_env_warning'] ?? '')) + . '

    '; + + return self::wrapHtml( + 'SEO Copy Release SOP', + '

    统一门户内的只读操作手册页,固定展示 approved -> run -> publish -> rollback -> portal 的推荐顺序。

    ' + . '

    ' . implode(' | ', $arrQuickLinks) . '

    ' + . '' + . implode('', $arrRows) + . '
    stepgoalcommandportal entry
    ' + . '

    Bootstrap Onboarding SOP

    ' + . '

    新域名上线时,建议先走 bundle 详情页和高层 run 入口,再进入补料门禁链。

    ' + . $strLatestBootstrap + . $strBootstrapImportPolicy + . '' + . implode('', $arrBootstrapRows) + . '
    stepgoalcommandportal entry
    ' + . '

    Bootstrap Import Arming SOP

    ' + . '

    `AI import` 默认保持关闭。只有在确认 AI 结果目录可读且确实准备执行非 dry-run 导入时,才把策略切到 armed。

    ' + . '' + . implode('', $arrBootstrapImportRows) + . '
    stepgoalcommandportal entry
    ' + . '

    Bootstrap Environment Templates

    ' + . '

    测试服和正式服都优先从安全模板起步,不要直接把旧 .env 或空 allowlist 拷过去。

    ' + . $strBootstrapEnvWarning + . '' + . implode('', $arrBootstrapEnvRows) + . '
    templatescopetask_policypathpreview_commandapply_commandnote
    ' + . '

    Common Failures

    ' + . implode('', $arrFailureRows) + . '
    statusmeaningsuggestion
    ' + ); + } + + protected static function pickLatestRelease(array $arrReleases): array + { + $arrLatest = []; + foreach ($arrReleases as $arrRelease) { + if (!is_array($arrRelease)) { + continue; + } + + if (empty($arrLatest) || strcmp((string)($arrRelease['last_event_at'] ?? ''), (string)($arrLatest['last_event_at'] ?? '')) >= 0) { + $arrLatest = $arrRelease; + } + } + + return $arrLatest; + } + + protected static function pickLatestManifest(array $arrManifests): array + { + $arrLatest = []; + foreach ($arrManifests as $arrManifest) { + if (!is_array($arrManifest)) { + continue; + } + + if (empty($arrLatest) || strcmp((string)($arrManifest['updated_at'] ?? ''), (string)($arrLatest['updated_at'] ?? '')) >= 0) { + $arrLatest = $arrManifest; + } + } + + return $arrLatest; + } + + protected static function pickLatestRun(array $arrRuns): array + { + $arrLatest = []; + foreach ($arrRuns as $arrRun) { + if (!is_array($arrRun)) { + continue; + } + + if (empty($arrLatest) || strcmp((string)($arrRun['updated_at'] ?? ''), (string)($arrLatest['updated_at'] ?? '')) >= 0) { + $arrLatest = $arrRun; + } + } + + return $arrLatest; + } + + protected static function pickTaskBySlug(array $arrTasks, string $strTaskSlug): array + { + foreach ($arrTasks as $arrTask) { + if (!is_array($arrTask)) { + continue; + } + + if ((string)($arrTask['task_slug'] ?? '') !== $strTaskSlug) { + continue; + } + + return $arrTask; + } + + return []; + } + + protected static function pickLatestBootstrapBundle(array $arrBundles): array + { + $arrLatest = []; + foreach ($arrBundles as $arrBundle) { + if (!is_array($arrBundle)) { + continue; + } + + if (empty($arrLatest) || strcmp((string)($arrBundle['generated_at'] ?? ''), (string)($arrLatest['generated_at'] ?? '')) >= 0) { + $arrLatest = $arrBundle; + } + } + + return $arrLatest; + } + + protected static function countBootstrapBundlesByStatus(array $arrBundles, array $arrStatuses, string $strField = 'preview_status'): int + { + $intCount = 0; + foreach ($arrBundles as $arrBundle) { + if (!is_array($arrBundle)) { + continue; + } + + if (in_array((string)($arrBundle[$strField] ?? ''), $arrStatuses, true)) { + $intCount++; + } + } + + return $intCount; + } + + protected static function countByFieldValues(array $arrItems, string $strField, array $arrTargets): array + { + $arrTotals = []; + foreach ($arrTargets as $strTarget) { + $arrTotals[$strTarget] = 0; + } + + foreach ($arrItems as $arrItem) { + if (!is_array($arrItem)) { + continue; + } + + $strValue = (string)($arrItem[$strField] ?? ''); + if (!array_key_exists($strValue, $arrTotals)) { + continue; + } + + $arrTotals[$strValue]++; + } + + return $arrTotals; + } + + 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 '' + . '' + . '' . $strEscapedTitle . '' + . '

    ' . $strEscapedTitle . '

    ' . $strBody . ''; + } +} diff --git a/code/app/common/helper/SeoCopyReleaseIndexHelper.php b/code/app/common/helper/SeoCopyReleaseIndexHelper.php new file mode 100644 index 0000000..385cbf6 --- /dev/null +++ b/code/app/common/helper/SeoCopyReleaseIndexHelper.php @@ -0,0 +1,161 @@ + 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; + } +} diff --git a/code/app/common/helper/SeoCopyReleasePortalHelper.php b/code/app/common/helper/SeoCopyReleasePortalHelper.php new file mode 100644 index 0000000..084d642 --- /dev/null +++ b/code/app/common/helper/SeoCopyReleasePortalHelper.php @@ -0,0 +1,285 @@ + (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[] = '' + . '' . $strTag . '' + . '' . self::escape((string)($arrRelease['status'] ?? '')) . '' + . '' . (int)($arrRelease['published_count'] ?? 0) . '' + . '' . (int)($arrRelease['skipped_count'] ?? 0) . '' + . '' . self::escape((string)($arrRelease['last_event_type'] ?? '')) . '' + . '' . self::escape((string)($arrRelease['last_event_at'] ?? '')) . '' + . ''; + } + + return self::wrapHtml( + 'SEO Copy Release Portal', + '

    内部只读预览页,仅展示发布批次状态与场景清单,不输出绝对路径。' + . '

    ' + . '
    ' + . '
    generated_at' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '
    ' + . '
    publish_logs' . (int)($arrSummary['publish_logs'] ?? 0) . '
    ' + . '
    rollback_logs' . (int)($arrSummary['rollback_logs'] ?? 0) . '
    ' + . '
    releases' . (int)($arrSummary['releases_count'] ?? 0) . '
    ' + . '
    ' + . '' + . implode('', $arrRows) + . '
    release_tagstatuspublishedskippedlast_eventlast_event_at
    ' + ); + } + + public static function renderReleaseDetailHtml(array $arrDetail): string + { + $arrSceneRows = []; + foreach ((array)($arrDetail['scene_totals'] ?? []) as $strScene => $intCount) { + $arrSceneRows[] = '' . self::escape((string)$strScene) . '' . (int)$intCount . ''; + } + + $arrPublishRows = []; + foreach ((array)($arrDetail['publish_items'] ?? []) as $arrItem) { + if (!is_array($arrItem)) { + continue; + } + + $arrPublishRows[] = '' + . '' . self::escape((string)($arrItem['scene'] ?? '')) . '' + . '' . self::escape((string)($arrItem['page_key'] ?? '')) . '' + . '' . self::escape((string)($arrItem['action'] ?? '')) . '' + . ''; + } + + $arrRollbackRows = []; + foreach ((array)(($arrDetail['rollback'] ?? [])['modes'] ?? []) as $strMode => $intCount) { + $arrRollbackRows[] = '' . self::escape((string)$strMode) . '' . (int)$intCount . ''; + } + + $strRollbackSection = ''; + if (!empty($arrRollbackRows) || !empty(($arrDetail['rollback'] ?? [])['exists'])) { + $strRollbackSection = '

    Rollback

    ' + . '
    ' + . '
    exists' . (!empty(($arrDetail['rollback'] ?? [])['exists']) ? 'yes' : 'no') . '
    ' + . '
    dry_run' . (!empty(($arrDetail['rollback'] ?? [])['dry_run']) ? 'yes' : 'no') . '
    ' + . '
    restored_count' . (int)(($arrDetail['rollback'] ?? [])['restored_count'] ?? 0) . '
    ' + . '
    skipped_count' . (int)(($arrDetail['rollback'] ?? [])['skipped_count'] ?? 0) . '
    ' + . '
    '; + + if (!empty($arrRollbackRows)) { + $strRollbackSection .= '' + . implode('', $arrRollbackRows) + . '
    modecount
    '; + } + } + + return self::wrapHtml( + 'Release Detail: ' . self::escape((string)($arrDetail['release_tag'] ?? '')), + '

    返回批次总览

    ' + . '
    ' + . '
    release_tag' . self::escape((string)($arrDetail['release_tag'] ?? '')) . '
    ' + . '
    status' . self::escape((string)($arrDetail['status'] ?? '')) . '
    ' + . '
    last_event' . self::escape((string)($arrDetail['last_event_type'] ?? '')) . '
    ' + . '
    last_event_at' . self::escape((string)($arrDetail['last_event_at'] ?? '')) . '
    ' + . '
    published_count' . (int)($arrDetail['published_count'] ?? 0) . '
    ' + . '
    skipped_count' . (int)($arrDetail['skipped_count'] ?? 0) . '
    ' + . '
    ' + . '

    Scene Totals

    ' + . implode('', $arrSceneRows) + . '
    scenecount
    ' + . '

    Published Items

    ' + . implode('', $arrPublishRows) + . '
    scenepage_keyaction
    ' + . $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 '' + . '' + . '' . $strEscapedTitle . '' + . '

    ' . $strEscapedTitle . '

    ' . $strBody . ''; + } +} diff --git a/code/app/common/helper/SeoCopyReleaseRunHelper.php b/code/app/common/helper/SeoCopyReleaseRunHelper.php new file mode 100644 index 0000000..1805679 --- /dev/null +++ b/code/app/common/helper/SeoCopyReleaseRunHelper.php @@ -0,0 +1,24 @@ + (int)($arrResult['exit_code'] ?? 0), + 'summary' => (array)($arrResult['summary'] ?? []), + ]; + } +} diff --git a/code/app/common/helper/SeoCopyReleaseRunIndexHelper.php b/code/app/common/helper/SeoCopyReleaseRunIndexHelper.php new file mode 100644 index 0000000..69d2b8a --- /dev/null +++ b/code/app/common/helper/SeoCopyReleaseRunIndexHelper.php @@ -0,0 +1,260 @@ + 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[] = '' + . '' . self::escape((string)($arrRun['run_name'] ?? '')) . '' + . '' . self::escape((string)($arrRun['release_tag'] ?? '')) . '' + . '' . self::escape((string)($arrRun['status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['manifest_name'] ?? '')) . '' + . '' . self::escape((string)($arrRun['base_url'] ?? '')) . '' + . '' . (int)($arrRun['verify_issues'] ?? 0) . '' + . '' . (int)($arrRun['front_failed'] ?? 0) . '' + . '' . (int)($arrRun['published_count'] ?? 0) . '' + . '' . self::escape((string)($arrRun['updated_at'] ?? '')) . '' + . ''; + } + + return self::wrapHtml( + 'SEO Copy Release Runs', + '

    内部只读运行记录页,展示最近的门禁执行结果与核心统计。

    ' + . '
    ' + . '
    generated_at' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '
    ' + . '
    runs' . (int)($arrSummary['runs_count'] ?? 0) . '
    ' + . '
    statuses' . self::escape(implode(', ', array_keys((array)($arrSummary['status_totals'] ?? [])))) . '
    ' + . '
    ' + . '' + . implode('', $arrRows) + . '
    run_namerelease_tagstatusmanifestbase_urlverify_issuesfront_failedpublishedupdated_at
    ' + ); + } + + 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 '' + . '' + . '' . $strEscapedTitle . '' + . '

    ' . $strEscapedTitle . '

    ' . $strBody . ''; + } + + protected static function renderDetailHtml(array $arrRun): string + { + $arrCards = [ + '
    run_name' . self::escape((string)($arrRun['run_name'] ?? '')) . '
    ', + '
    release_tag' . self::escape((string)($arrRun['release_tag'] ?? '')) . '
    ', + '
    status' . self::escape((string)($arrRun['status'] ?? '')) . '
    ', + '
    manifest' . self::escape((string)($arrRun['manifest_name'] ?? '')) . '
    ', + '
    verify' . (int)($arrRun['verify_checked'] ?? 0) . '/' . (int)($arrRun['verify_readable'] ?? 0) . ' readable
    ', + '
    front_verify' . (int)($arrRun['front_passed'] ?? 0) . '/' . (int)($arrRun['front_total'] ?? 0) . ' passed
    ', + '
    publish' . (int)($arrRun['published_count'] ?? 0) . ' published / ' . (int)($arrRun['skipped_count'] ?? 0) . ' skipped
    ', + '
    updated_at' . self::escape((string)($arrRun['updated_at'] ?? '')) . '
    ', + ]; + + $strError = trim((string)($arrRun['error'] ?? '')); + $strErrorBlock = $strError !== '' + ? '

    Error: ' . self::escape($strError) . '

    ' + : ''; + $strReleaseTag = trim((string)($arrRun['release_tag'] ?? '')); + $strRelatedRelease = $strReleaseTag !== '' + ? '查看对应 release 详情' + : ''; + $arrNavLinks = [ + '返回门户首页', + '返回 runs 总览', + ]; + if ($strRelatedRelease !== '') { + $arrNavLinks[] = $strRelatedRelease; + } + + return self::wrapHtml( + 'SEO Copy Run Detail', + '

    内部只读运行详情页,展示单次门禁执行的摘要结果。

    ' + . '

    ' . implode(' | ', $arrNavLinks) . '

    ' + . '
    ' . implode('', $arrCards) . '
    ' + . $strErrorBlock + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '
    metricvalue
    verify_checked' . (int)($arrRun['verify_checked'] ?? 0) . '
    verify_readable' . (int)($arrRun['verify_readable'] ?? 0) . '
    verify_issues' . (int)($arrRun['verify_issues'] ?? 0) . '
    front_total' . (int)($arrRun['front_total'] ?? 0) . '
    front_passed' . (int)($arrRun['front_passed'] ?? 0) . '
    front_failed' . (int)($arrRun['front_failed'] ?? 0) . '
    published_count' . (int)($arrRun['published_count'] ?? 0) . '
    skipped_count' . (int)($arrRun['skipped_count'] ?? 0) . '
    base_url' . self::escape((string)($arrRun['base_url'] ?? '')) . '
    ' + ); + } + + 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)); + } +} diff --git a/code/app/common/helper/SeoCopyReleaseViewHelper.php b/code/app/common/helper/SeoCopyReleaseViewHelper.php new file mode 100644 index 0000000..35a2bb7 --- /dev/null +++ b/code/app/common/helper/SeoCopyReleaseViewHelper.php @@ -0,0 +1,202 @@ + (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[] = '' + . '' . $strTag . '' + . '' . $strStatus . '' + . '' . $intPublished . '' + . '' . $intSkipped . '' + . '' . $strLastEvent . '' + . '' . $strLastAt . '' + . ''; + } + + return self::wrapHtml( + 'SEO Copy Release Index', + '
    ' + . '
    generated_at' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
    ' + . '
    publish_logs' . (int)($arrSummary['publish_logs'] ?? 0) . '
    ' + . '
    rollback_logs' . (int)($arrSummary['rollback_logs'] ?? 0) . '
    ' + . '
    releases' . count((array)($arrSummary['releases'] ?? [])) . '
    ' + . '
    ' + . '' + . implode('', $arrRows) + . '
    release_tagstatuspublishedskippedlast_eventlast_event_at
    ' + ); + } + + 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[] = '' + . '' . htmlspecialchars((string)($arrItem['scene'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['page_key'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['action'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['target_path'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . ''; + } + + $arrRollbackRows = []; + foreach ((array)($arrDetail['rollback_restored'] ?? []) as $arrItem) { + if (!is_array($arrItem)) { + continue; + } + $arrRollbackRows[] = '' + . '' . htmlspecialchars((string)($arrItem['mode'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['target_path'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['backup_path'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . ''; + } + + $strRollbackSection = ''; + if (!empty($arrRollbackRows)) { + $strRollbackSection = '

    Rollback

    ' + . implode('', $arrRollbackRows) + . '
    modetarget_pathbackup_path
    '; + } + + return self::wrapHtml( + 'Release Detail: ' . $strTag, + '

    Back to latest index

    ' + . '
    ' + . '
    release_tag' . $strTag . '
    ' + . '
    status' . $strStatus . '
    ' + . '
    last_event' . $strEvent . '
    ' + . '
    last_event_at' . $strAt . '
    ' + . '
    ' + . '

    target_root
    ' . htmlspecialchars((string)($arrDetail['target_root'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

    ' + . '

    manifest
    ' . htmlspecialchars((string)($arrDetail['manifest'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

    ' + . '

    manifest_digest
    ' . htmlspecialchars((string)($arrDetail['manifest_digest'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

    ' + . '

    Published Items

    ' + . implode('', $arrPublishedRows) + . '
    scenepage_keyactiontarget_path
    ' + . $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 '' + . '' . $strEscapedTitle . '' + . '

    ' . $strEscapedTitle . '

    ' . $strBody . ''; + } +} diff --git a/code/app/common/helper/SeoCopyRuntimeAcceptanceHelper.php b/code/app/common/helper/SeoCopyRuntimeAcceptanceHelper.php new file mode 100644 index 0000000..10763cd --- /dev/null +++ b/code/app/common/helper/SeoCopyRuntimeAcceptanceHelper.php @@ -0,0 +1,889 @@ +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('#([^<]+)#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, + ]; + } +} diff --git a/code/app/common/helper/SeoCopyRuntimeAcceptanceIndexHelper.php b/code/app/common/helper/SeoCopyRuntimeAcceptanceIndexHelper.php new file mode 100644 index 0000000..027009d --- /dev/null +++ b/code/app/common/helper/SeoCopyRuntimeAcceptanceIndexHelper.php @@ -0,0 +1,543 @@ + 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[] = '' + . '' . self::escape((string)($arrRun['run_name'] ?? '')) . '' + . '' . self::escape((string)($arrRun['host'] ?? '')) . '' + . '' . (int)($arrRun['video_id'] ?? 0) . '' + . '' . (int)($arrRun['forge_id'] ?? 0) . '' + . '' . self::escape((string)($arrRun['status'] ?? '')) . '' + . '' . (int)($arrRun['passed_suites'] ?? 0) . '/' . (int)($arrRun['suite_count'] ?? 0) . '' + . '' . self::escape((string)($arrRun['summary'] ?? '')) . '' + . '' . self::escape((string)($arrRun['updated_at'] ?? '')) . '' + . ''; + } + + return self::wrapHtml( + 'SEO Copy Runtime Acceptance', + '

    运行时验收总览页,统一展示 forge/play/output/file-output 总验收结果。

    ' + . '

    返回门户首页

    ' + . '
    ' + . '
    generated_at' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '
    ' + . '
    runs' . (int)($arrSummary['runs_count'] ?? 0) . '
    ' + . '
    hosts' . (int)($arrSummary['hosts_count'] ?? 0) . '
    ' + . '
    failed_hosts' . (int)($arrSummary['failed_hosts_count'] ?? 0) . '
    ' + . '
    statuses' . self::escape(implode(', ', array_keys((array)($arrSummary['status_totals'] ?? [])))) . '
    ' + . '
    ' + . '

    Failed Hosts Rerun: ' . self::escape((string)($arrSummary['rerun_failed_hosts_command'] ?? '')) . '

    ' + . self::renderHostSummaryTable((array)($arrSummary['host_totals'] ?? [])) + . self::renderSuiteFailureTable((array)($arrSummary['failure_category_totals'] ?? []), 'Failure Categories', 'category') + . self::renderSuiteFailureTable((array)($arrSummary['suite_failure_totals'] ?? [])) + . '' + . implode('', $arrRows) + . '
    run_namehostvideo_idforge_idstatussuitessummaryupdated_at
    ' + ); + } + + 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[] = '' + . '' . self::escape((string)($arrHostSummary['host'] ?? '')) . '' + . '' . (int)($arrHostSummary['runs'] ?? 0) . '' + . '' . (int)($arrHostSummary['passed'] ?? 0) . '' + . '' . (int)($arrHostSummary['failed'] ?? 0) . '' + . '' . (int)($arrHostSummary['failed_suites'] ?? 0) . '' + . '' . 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)))) . '' + . '' . self::escape((string)($arrHostSummary['top_failure_category'] ?? '')) . '' + . '' . self::escape((string)($arrHostSummary['remediation_action'] ?? '')) . '' + . '' . self::escape((string)($arrLatestRun['run_name'] ?? '')) . '' + . '' . self::escape((string)($arrLatestRun['updated_at'] ?? '')) . '' + . ''; + } + + return '

    Host Coverage

    ' + . implode('', $arrRows) + . '
    hostrunspassedfailedfailed_suitestop_failed_suitestop_failure_categoryremediation_actionlatest_runlatest_at
    '; + } + + 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[] = '' + . '' . self::escape((string)($arrSuiteTotal['suite_key'] ?? '')) . '' + . '' . (int)($arrSuiteTotal['count'] ?? 0) . '' + . ''; + } + + return '

    ' . self::escape($strTitle) . '

    ' + . implode('', $arrRows) + . '
    ' . self::escape($strLabel) . 'fail_count
    '; + } + + protected static function renderDetailHtml(array $arrRun): string + { + $arrCards = [ + '
    run_name' . self::escape((string)($arrRun['run_name'] ?? '')) . '
    ', + '
    host' . self::escape((string)($arrRun['host'] ?? '')) . '
    ', + '
    video_id' . (int)($arrRun['video_id'] ?? 0) . '
    ', + '
    forge_id' . (int)($arrRun['forge_id'] ?? 0) . '
    ', + '
    status' . self::escape((string)($arrRun['status'] ?? '')) . '
    ', + '
    suites' . (int)($arrRun['passed_suites'] ?? 0) . '/' . (int)($arrRun['suite_count'] ?? 0) . ' passed
    ', + '
    updated_at' . self::escape((string)($arrRun['updated_at'] ?? '')) . '
    ', + ]; + + return self::wrapHtml( + 'SEO Copy Runtime Acceptance Detail', + '

    运行时验收单次结果页,展示单个 host/video 样本的总验收摘要。

    ' + . '

    返回门户首页 | 返回 runtime acceptance 总览

    ' + . '
    ' . implode('', $arrCards) . '
    ' + . '' + . '' + . '' + . '' + . '' + . '
    metricvalue
    suite_keys' . self::escape(implode(', ', (array)($arrRun['suite_keys'] ?? []))) . '
    suite_statuses' . self::escape(implode(', ', array_map(static function (array $arrSuiteStatus): string { + return (string)($arrSuiteStatus['suite_key'] ?? '') . ':' . (!empty($arrSuiteStatus['passed']) ? 'ok' : 'fail'); + }, (array)($arrRun['suite_statuses'] ?? [])))) . '
    summary' . self::escape((string)($arrRun['summary'] ?? '')) . '
    json_path' . self::escape((string)($arrRun['json_path'] ?? '')) . '
    ' + ); + } + + 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[] = '' + . '' . self::escape((string)($arrRun['run_name'] ?? '')) . '' + . '' . self::escape((string)($arrRun['status'] ?? '')) . '' + . '' . (int)($arrRun['video_id'] ?? 0) . '' + . '' . (int)($arrRun['forge_id'] ?? 0) . '' + . '' . self::escape((string)($arrRun['summary'] ?? '')) . '' + . '' . self::escape((string)($arrRun['updated_at'] ?? '')) . '' + . ''; + } + + return self::wrapHtml( + 'SEO Copy Runtime Acceptance Host Detail', + '

    运行时验收 host 详情页,聚焦单个域名的通过/失败历史与失败热点。

    ' + . '

    返回门户首页 | 返回 runtime acceptance 总览

    ' + . '
    ' + . '
    host' . self::escape($strHost) . '
    ' + . '
    runs' . (int)($arrHostSummary['runs'] ?? 0) . '
    ' + . '
    passed' . (int)($arrHostSummary['passed'] ?? 0) . '
    ' + . '
    failed' . (int)($arrHostSummary['failed'] ?? 0) . '
    ' + . '
    failed_suites' . (int)($arrHostSummary['failed_suites'] ?? 0) . '
    ' + . '
    configured_samples' . count($arrConfiguredSamples) . '
    ' + . '
    ' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '
    metricvalue
    likely_issue' . self::escape($strLikelyIssue) . '
    rerun_command' . self::escape($strRerunCommand) . '
    portal_root' . self::escape($strPortalRoot) . '
    run_root' . self::escape($strRunRoot) . '
    configured_samples' . self::escape(implode(', ', array_map(static function (array $arrSample): string { + return (string)($arrSample['video_id'] ?? 0) . ':' . (string)($arrSample['forge_id'] ?? 1); + }, $arrConfiguredSamples))) . '
    top_failure_category' . self::escape((string)($arrHostSummary['top_failure_category'] ?? '')) . '
    remediation_action' . self::escape((string)($arrHostSummary['remediation_action'] ?? '')) . '
    ' + . self::renderSuiteFailureTable($arrTopFailureCategories, 'Failure Categories', 'category') + . self::renderSuiteFailureTable((array)($arrHostSummary['top_failed_suites'] ?? [])) + . '' + . implode('', $arrRows) + . '
    run_namestatusvideo_idforge_idsummaryupdated_at
    ' + ); + } + + 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 '' + . '' + . '' . $strEscapedTitle . '' + . '

    ' . $strEscapedTitle . '

    ' . $strBody . ''; + } +} diff --git a/code/app/common/helper/SeoCopySchema.php b/code/app/common/helper/SeoCopySchema.php new file mode 100644 index 0000000..f4686a8 --- /dev/null +++ b/code/app/common/helper/SeoCopySchema.php @@ -0,0 +1,323 @@ + 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'); + } +} diff --git a/code/app/common/helper/SeoCopyStore.php b/code/app/common/helper/SeoCopyStore.php new file mode 100644 index 0000000..7011e1a --- /dev/null +++ b/code/app/common/helper/SeoCopyStore.php @@ -0,0 +1,247 @@ + $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(), + ]; + } +} diff --git a/code/app/common/helper/SeoCopyTaskRunPortalHelper.php b/code/app/common/helper/SeoCopyTaskRunPortalHelper.php new file mode 100644 index 0000000..8398694 --- /dev/null +++ b/code/app/common/helper/SeoCopyTaskRunPortalHelper.php @@ -0,0 +1,461 @@ + 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[] = '' + . '' . self::escape((string)($arrRun['task_slug'] ?? '')) . '' + . '' . self::escape((string)($arrRun['status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['health_status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['summary'] ?? '')) . '' + . '' . self::escape((string)($arrRun['updated_at'] ?? '')) . '' + . ''; + } + + $arrRunRows = []; + foreach ((array)($arrSummary['runs'] ?? []) as $arrRun) { + if (!is_array($arrRun)) { + continue; + } + + $arrRunRows[] = '' + . '' . self::escape((string)($arrRun['run_name'] ?? '')) . '' + . '' . self::escape((string)($arrRun['task_slug'] ?? '')) . '' + . '' . self::escape((string)($arrRun['status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['health_status'] ?? '')) . '' + . '' . self::escape((string)($arrRun['summary'] ?? '')) . '' + . '' . self::escape((string)($arrRun['updated_at'] ?? '')) . '' + . ''; + } + + return self::wrapHtml( + 'SEO Copy Scheduled Tasks', + '

    内部只读任务健康页,展示最近的 SEO 补料定时任务执行摘要。

    ' + . '

    返回门户首页

    ' + . '
    ' + . '
    generated_at' . self::escape((string)($arrSummary['generated_at'] ?? '')) . '
    ' + . '
    tasks' . (int)($arrSummary['tasks_count'] ?? 0) . '
    ' + . '
    runs' . (int)($arrSummary['runs_count'] ?? 0) . '
    ' + . '
    ' + . '

    Latest By Task

    ' + . implode('', $arrLatestRows) + . '
    task_slugstatushealthsummaryupdated_at
    ' + . '

    Recent Task Runs

    ' + . implode('', $arrRunRows) + . '
    run_nametask_slugstatushealthsummaryupdated_at
    ' + ); + } + + protected static function renderDetailHtml(array $arrRun): string + { + return self::wrapHtml( + 'SEO Copy Task Run Detail', + '

    内部只读任务详情页,展示单次定时任务摘要。

    ' + . '

    返回门户首页 | 返回任务健康页

    ' + . '
    ' + . '
    run_name' . self::escape((string)($arrRun['run_name'] ?? '')) . '
    ' + . '
    task_slug' . self::escape((string)($arrRun['task_slug'] ?? '')) . '
    ' + . '
    status' . self::escape((string)($arrRun['status'] ?? '')) . '
    ' + . '
    health' . self::escape((string)($arrRun['health_status'] ?? '')) . '
    ' + . '
    updated_at' . self::escape((string)($arrRun['updated_at'] ?? '')) . '
    ' + . '
    ' + . '' + . '' + . '' + . '
    fieldvalue
    summary' . self::escape((string)($arrRun['summary'] ?? '')) . '
    age_seconds' . (int)($arrRun['age_seconds'] ?? 0) . '
    ' + ); + } + + 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 '' + . '' + . '' . $strEscapedTitle . '' + . '

    ' . $strEscapedTitle . '

    ' . $strBody . ''; + } +} diff --git a/code/app/common/helper/SeoCopyTaskStateHelper.php b/code/app/common/helper/SeoCopyTaskStateHelper.php new file mode 100644 index 0000000..9e8744f --- /dev/null +++ b/code/app/common/helper/SeoCopyTaskStateHelper.php @@ -0,0 +1,61 @@ + $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; + } +} diff --git a/code/app/common/helper/SeoResourcePoolHelper.php b/code/app/common/helper/SeoResourcePoolHelper.php new file mode 100644 index 0000000..fe8f3f6 --- /dev/null +++ b/code/app/common/helper/SeoResourcePoolHelper.php @@ -0,0 +1,637 @@ +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; + } +} diff --git a/code/app/common/helper/SpiderMdConfigHelper.php b/code/app/common/helper/SpiderMdConfigHelper.php new file mode 100644 index 0000000..f3b0365 --- /dev/null +++ b/code/app/common/helper/SpiderMdConfigHelper.php @@ -0,0 +1,251 @@ + [ + '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; + } +} diff --git a/code/app/model/DomainModel.php b/code/app/model/DomainModel.php index 6740657..f5a5604 100644 --- a/code/app/model/DomainModel.php +++ b/code/app/model/DomainModel.php @@ -1,67 +1,809 @@ - self::SEO_STRATEGY_PROFILE_STANDARD, + 'canonical_mode' => self::CANONICAL_MODE_STRICT, + 'play_index_mode' => self::PLAY_INDEX_MODE_NOINDEX_FOLLOW, + 'forge' => [ + 'exposure_profile' => self::FORGE_EXPOSURE_PROFILE_SAFE, + 'enable' => true, + 'count' => 12, + 'entry_count' => 3, + 'sitemap_count' => 0, + 'route_group' => self::FORGE_ROUTE_GROUP_DEFAULT, + 'title_source' => self::FORGE_TITLE_SOURCE_SEO_WORDS, + ], + 'intro_block' => [ + 'home' => true, + 'category' => true, + 'detail' => true, + 'search' => true, + ], + 'structured_data' => [ + 'policy' => self::STRUCTURED_POLICY_FULL, + 'reference_mode' => self::STRUCTURED_REFERENCE_MODE_INLINE, + 'website_node' => true, + 'page_node' => true, + 'breadcrumb_node' => true, + 'video_object_node' => true, + 'organization_node' => true, + 'organization_name' => '', + 'same_as' => [], + ], + 'runtime_acceptance' => [ + 'enable' => false, + 'samples' => [], + ], + 'tkd' => [ + 'mode' => self::TKD_MODE_AUTO_GENERATE, + 'provider' => self::TKD_PROVIDER_LOCAL, + ], + 'copy_generation' => [ + 'status' => 'draft', + 'release_gate' => 'open', + 'wait_for_ai_before_publish' => false, + 'provider_preference' => 'local_first', + 'ai_provider' => self::TKD_PROVIDER_OPENAI, + ], + 'robots_policy' => self::ROBOTS_POLICY_DEFAULT, + ]; } - /** - * Undocumented function - * - * @return void - */ + protected static function mergeSeoCfg(array $arrDefault, array $arrOverride): array + { + foreach ($arrOverride as $strKey => $mVal) { + if (is_array($mVal) && isset($arrDefault[$strKey]) && is_array($arrDefault[$strKey])) { + $arrDefault[$strKey] = self::mergeSeoCfg($arrDefault[$strKey], $mVal); + continue; + } + + $arrDefault[$strKey] = $mVal; + } + + return $arrDefault; + } + + public static function normalizeSeoCfg(array|string|null $mSeoCfg, ?self $DomainModel = null): array + { + if (is_string($mSeoCfg)) { + $arrDecoded = json_decode($mSeoCfg, true); + $mSeoCfg = is_array($arrDecoded) ? $arrDecoded : []; + } + + if (!is_array($mSeoCfg)) { + $mSeoCfg = []; + } + + $arrRawStructuredData = is_array($mSeoCfg['structured_data'] ?? null) ? $mSeoCfg['structured_data'] : []; + $strStrategyProfile = self::normalizeSeoStrategyProfile((string)($mSeoCfg['strategy_profile'] ?? self::SEO_STRATEGY_PROFILE_STANDARD)); + $arrSeoCfg = self::mergeSeoCfg( + self::defaultSeoCfg(), + self::getSeoStrategyProfileDefaults($strStrategyProfile) + ); + $arrSeoCfg = self::mergeSeoCfg($arrSeoCfg, $mSeoCfg); + $arrSeoCfg['strategy_profile'] = $strStrategyProfile; + + $arrSeoCfg['canonical_mode'] = in_array( + (string)($arrSeoCfg['canonical_mode'] ?? ''), + [self::CANONICAL_MODE_STRICT, self::CANONICAL_MODE_RELAXED], + true + ) ? (string)$arrSeoCfg['canonical_mode'] : self::CANONICAL_MODE_STRICT; + + $arrSeoCfg['play_index_mode'] = in_array( + (string)($arrSeoCfg['play_index_mode'] ?? ''), + [self::PLAY_INDEX_MODE_NOINDEX_FOLLOW, self::PLAY_INDEX_MODE_INDEX_FOLLOW], + true + ) ? (string)$arrSeoCfg['play_index_mode'] : self::PLAY_INDEX_MODE_NOINDEX_FOLLOW; + + $arrRawForgeSeoCfg = is_array($mSeoCfg['forge'] ?? null) ? $mSeoCfg['forge'] : []; + $strExposureProfile = self::normalizeForgeExposureProfile( + (string)($arrRawForgeSeoCfg['exposure_profile'] ?? $arrSeoCfg['forge']['exposure_profile'] ?? self::FORGE_EXPOSURE_PROFILE_SAFE) + ); + $arrForgeSeoCfg = array_merge( + self::defaultSeoCfg()['forge'], + self::getForgeExposureProfileDefaults($strExposureProfile), + $arrRawForgeSeoCfg + ); + + $arrForgeSeoCfg['exposure_profile'] = $strExposureProfile; + $arrForgeSeoCfg['enable'] = (bool)($arrForgeSeoCfg['enable'] ?? true); + $arrForgeSeoCfg['count'] = max(0, (int)($arrForgeSeoCfg['count'] ?? 12)); + $arrForgeSeoCfg['entry_count'] = max( + 0, + min( + (int)$arrForgeSeoCfg['count'], + (int)($arrForgeSeoCfg['entry_count'] ?? self::defaultSeoCfg()['forge']['entry_count']) + ) + ); + $arrForgeSeoCfg['sitemap_count'] = max( + 0, + min( + (int)$arrForgeSeoCfg['count'], + (int)($arrForgeSeoCfg['sitemap_count'] ?? self::defaultSeoCfg()['forge']['sitemap_count']) + ) + ); + $arrForgeSeoCfg['route_group'] = self::normalizeForgeRouteGroup( + (string)($arrForgeSeoCfg['route_group'] ?? self::FORGE_ROUTE_GROUP_DEFAULT) + ); + + $arrForgeSeoCfg['title_source'] = trim((string)($arrForgeSeoCfg['title_source'] ?? self::FORGE_TITLE_SOURCE_SEO_WORDS)); + if ($arrForgeSeoCfg['title_source'] === '') { + $arrForgeSeoCfg['title_source'] = self::FORGE_TITLE_SOURCE_SEO_WORDS; + } + $arrSeoCfg['forge'] = $arrForgeSeoCfg; + + foreach (['home', 'category', 'detail', 'search'] as $strIntroKey) { + $arrSeoCfg['intro_block'][$strIntroKey] = (bool)($arrSeoCfg['intro_block'][$strIntroKey] ?? true); + } + + $strStructuredPolicy = self::normalizeStructuredDataPolicy((string)($arrSeoCfg['structured_data']['policy'] ?? self::STRUCTURED_POLICY_FULL)); + $arrStructuredData = array_merge( + self::defaultSeoCfg()['structured_data'], + self::getStructuredDataPolicyDefaults($strStructuredPolicy), + $arrRawStructuredData + ); + $arrStructuredData['policy'] = $strStructuredPolicy; + + $arrStructuredData['reference_mode'] = in_array( + (string)($arrStructuredData['reference_mode'] ?? self::STRUCTURED_REFERENCE_MODE_INLINE), + [self::STRUCTURED_REFERENCE_MODE_INLINE, self::STRUCTURED_REFERENCE_MODE_ID_ONLY], + true + ) ? (string)$arrStructuredData['reference_mode'] : self::STRUCTURED_REFERENCE_MODE_INLINE; + $arrStructuredData['website_node'] = (bool)($arrStructuredData['website_node'] ?? true); + $arrStructuredData['page_node'] = (bool)($arrStructuredData['page_node'] ?? true); + $arrStructuredData['breadcrumb_node'] = (bool)($arrStructuredData['breadcrumb_node'] ?? true); + $arrStructuredData['video_object_node'] = (bool)($arrStructuredData['video_object_node'] ?? true); + $arrStructuredData['organization_node'] = (bool)($arrStructuredData['organization_node'] ?? true); + $arrStructuredData['organization_name'] = trim((string)($arrStructuredData['organization_name'] ?? '')); + + $arrSameAs = $arrStructuredData['same_as'] ?? []; + if (is_string($arrSameAs)) { + $arrSameAs = preg_split('/[\r\n,]+/', $arrSameAs) ?: []; + } + if (!is_array($arrSameAs)) { + $arrSameAs = []; + } + $arrSameAs = array_values(array_unique(array_filter(array_map(function ($strUrl) { + $strUrl = trim((string)$strUrl); + if ($strUrl === '' || !preg_match('#^https?://#i', $strUrl)) { + return ''; + } + + return $strUrl; + }, $arrSameAs)))); + $arrStructuredData['same_as'] = array_slice($arrSameAs, 0, 20); + $arrSeoCfg['structured_data'] = $arrStructuredData; + + $arrTkdSeoCfg = is_array($arrSeoCfg['tkd'] ?? null) ? $arrSeoCfg['tkd'] : []; + $arrTkdSeoCfg = array_merge(self::defaultSeoCfg()['tkd'], $arrTkdSeoCfg); + $arrTkdSeoCfg['mode'] = self::normalizeTkdMode((string)($arrTkdSeoCfg['mode'] ?? self::TKD_MODE_AUTO_GENERATE)); + $arrTkdSeoCfg['provider'] = self::normalizeTkdProvider((string)($arrTkdSeoCfg['provider'] ?? self::TKD_PROVIDER_LOCAL)); + $arrSeoCfg['tkd'] = $arrTkdSeoCfg; + + $arrCopyGenerationSeoCfg = is_array($arrSeoCfg['copy_generation'] ?? null) ? $arrSeoCfg['copy_generation'] : []; + $arrCopyGenerationSeoCfg = array_merge(self::defaultSeoCfg()['copy_generation'], $arrCopyGenerationSeoCfg); + $arrCopyGenerationSeoCfg['status'] = trim((string)($arrCopyGenerationSeoCfg['status'] ?? 'draft')) ?: 'draft'; + $arrCopyGenerationSeoCfg['release_gate'] = trim((string)($arrCopyGenerationSeoCfg['release_gate'] ?? 'open')) ?: 'open'; + $arrCopyGenerationSeoCfg['wait_for_ai_before_publish'] = (bool)($arrCopyGenerationSeoCfg['wait_for_ai_before_publish'] ?? false); + $arrCopyGenerationSeoCfg['provider_preference'] = trim((string)($arrCopyGenerationSeoCfg['provider_preference'] ?? 'local_first')) ?: 'local_first'; + $arrCopyGenerationSeoCfg['ai_provider'] = self::normalizeTkdProvider((string)($arrCopyGenerationSeoCfg['ai_provider'] ?? self::TKD_PROVIDER_OPENAI)); + $arrSeoCfg['copy_generation'] = $arrCopyGenerationSeoCfg; + + $arrRuntimeAcceptance = is_array($arrSeoCfg['runtime_acceptance'] ?? null) + ? $arrSeoCfg['runtime_acceptance'] + : self::defaultSeoCfg()['runtime_acceptance']; + $arrRuntimeAcceptance['enable'] = (bool)($arrRuntimeAcceptance['enable'] ?? false); + $arrRuntimeAcceptance['samples'] = self::normalizeRuntimeAcceptanceSamples($arrRuntimeAcceptance['samples'] ?? []); + $arrSeoCfg['runtime_acceptance'] = $arrRuntimeAcceptance; + + $arrSeoCfg['robots_policy'] = in_array( + trim((string)($arrSeoCfg['robots_policy'] ?? self::ROBOTS_POLICY_DEFAULT)), + [ + self::ROBOTS_POLICY_DEFAULT, + self::ROBOTS_POLICY_INDEX_FOLLOW, + self::ROBOTS_POLICY_NOINDEX_FOLLOW, + self::ROBOTS_POLICY_NOINDEX_NOFOLLOW, + ], + true + ) ? trim((string)$arrSeoCfg['robots_policy']) : self::ROBOTS_POLICY_DEFAULT; + + $arrSeoCfg['match_type'] = self::normalizeMatchType((string)($DomainModel->d_match_type ?? $arrSeoCfg['match_type'] ?? self::MATCH_TYPE_EXACT)); + $arrSeoCfg['seed_scope'] = self::normalizeSeedScope((string)($DomainModel->d_seed_scope ?? $arrSeoCfg['seed_scope'] ?? self::SEED_SCOPE_DOMAIN)); + $arrSeoCfg['wildcard_enable'] = $arrSeoCfg['match_type'] === self::MATCH_TYPE_WILDCARD; + + return $arrSeoCfg; + } + + public function getSeoCfg(): array + { + return self::normalizeSeoCfg($this->d_seo_cfg ?? [], $this); + } + + public function getForgeSeoCfg(): array + { + return (array)($this->getSeoCfg()['forge'] ?? self::defaultSeoCfg()['forge']); + } + + public function getRuntimeAcceptanceCfg(): array + { + return (array)($this->getSeoCfg()['runtime_acceptance'] ?? self::defaultSeoCfg()['runtime_acceptance']); + } + + public function getTkdSeoCfg(): array + { + return (array)($this->getSeoCfg()['tkd'] ?? self::defaultSeoCfg()['tkd']); + } + + public function getCopyGenerationSeoCfg(): array + { + return (array)($this->getSeoCfg()['copy_generation'] ?? self::defaultSeoCfg()['copy_generation']); + } + + public function isRuntimeAcceptanceEnabled(): bool + { + $arrRuntimeCfg = $this->getRuntimeAcceptanceCfg(); + return !empty($arrRuntimeCfg['enable']) && !empty($arrRuntimeCfg['samples']); + } + + public function getRuntimeAcceptanceSamples(): array + { + $arrRuntimeCfg = $this->getRuntimeAcceptanceCfg(); + return self::normalizeRuntimeAcceptanceSamples($arrRuntimeCfg['samples'] ?? []); + } + + public static function getForgeRouteGroupMap(): array + { + return [ + self::FORGE_ROUTE_GROUP_DEFAULT => '', + self::FORGE_ROUTE_GROUP_VIDEO => self::FORGE_ROUTE_GROUP_VIDEO, + self::FORGE_ROUTE_GROUP_DETAIL => self::FORGE_ROUTE_GROUP_DETAIL, + self::FORGE_ROUTE_GROUP_INFO => self::FORGE_ROUTE_GROUP_INFO, + self::FORGE_ROUTE_GROUP_VOD => self::FORGE_ROUTE_GROUP_VOD, + self::FORGE_ROUTE_GROUP_MOVIE => self::FORGE_ROUTE_GROUP_MOVIE, + self::FORGE_ROUTE_GROUP_FILM => self::FORGE_ROUTE_GROUP_FILM, + self::FORGE_ROUTE_GROUP_XIANGQING => self::FORGE_ROUTE_GROUP_XIANGQING, + self::FORGE_ROUTE_GROUP_SHIPIN => self::FORGE_ROUTE_GROUP_SHIPIN, + self::FORGE_ROUTE_GROUP_NEIRONG => self::FORGE_ROUTE_GROUP_NEIRONG, + self::FORGE_ROUTE_GROUP_DIANYING => self::FORGE_ROUTE_GROUP_DIANYING, + ]; + } + + public static function getSeoStrategyProfileMap(): array + { + return [ + self::SEO_STRATEGY_PROFILE_STANDARD => [ + 'canonical_mode' => self::CANONICAL_MODE_STRICT, + 'play_index_mode' => self::PLAY_INDEX_MODE_NOINDEX_FOLLOW, + 'robots_policy' => self::ROBOTS_POLICY_DEFAULT, + 'forge' => [ + 'exposure_profile' => self::FORGE_EXPOSURE_PROFILE_SAFE, + ], + 'structured_data' => [ + 'policy' => self::STRUCTURED_POLICY_FULL, + ], + ], + self::SEO_STRATEGY_PROFILE_TRAFFIC => [ + 'canonical_mode' => self::CANONICAL_MODE_STRICT, + 'play_index_mode' => self::PLAY_INDEX_MODE_INDEX_FOLLOW, + 'robots_policy' => self::ROBOTS_POLICY_INDEX_FOLLOW, + 'forge' => [ + 'exposure_profile' => self::FORGE_EXPOSURE_PROFILE_BALANCED, + ], + 'structured_data' => [ + 'policy' => self::STRUCTURED_POLICY_COMPACT, + ], + ], + self::SEO_STRATEGY_PROFILE_EXPAND => [ + 'canonical_mode' => self::CANONICAL_MODE_RELAXED, + 'play_index_mode' => self::PLAY_INDEX_MODE_INDEX_FOLLOW, + 'robots_policy' => self::ROBOTS_POLICY_INDEX_FOLLOW, + 'forge' => [ + 'exposure_profile' => self::FORGE_EXPOSURE_PROFILE_EXPANDED, + ], + 'structured_data' => [ + 'policy' => self::STRUCTURED_POLICY_COMPACT, + ], + ], + self::SEO_STRATEGY_PROFILE_CLOSED => [ + 'canonical_mode' => self::CANONICAL_MODE_STRICT, + 'play_index_mode' => self::PLAY_INDEX_MODE_NOINDEX_FOLLOW, + 'robots_policy' => self::ROBOTS_POLICY_NOINDEX_NOFOLLOW, + 'forge' => [ + 'exposure_profile' => self::FORGE_EXPOSURE_PROFILE_OFF, + ], + 'structured_data' => [ + 'policy' => self::STRUCTURED_POLICY_MINIMAL, + ], + ], + ]; + } + + public static function normalizeTkdMode(string $strMode): string + { + $strMode = strtolower(trim($strMode)); + $arrMap = [ + '1' => self::TKD_MODE_AUTO_GENERATE, + 'auto' => self::TKD_MODE_AUTO_GENERATE, + 'auto_generate' => self::TKD_MODE_AUTO_GENERATE, + 'auto-generate' => self::TKD_MODE_AUTO_GENERATE, + '2' => self::TKD_MODE_AI_OPTIMIZE, + 'ai' => self::TKD_MODE_AI_OPTIMIZE, + 'ai_optimize' => self::TKD_MODE_AI_OPTIMIZE, + 'ai-optimize' => self::TKD_MODE_AI_OPTIMIZE, + 'optimize' => self::TKD_MODE_AI_OPTIMIZE, + '3' => self::TKD_MODE_FORCE_IMPORT, + 'force' => self::TKD_MODE_FORCE_IMPORT, + 'force_import' => self::TKD_MODE_FORCE_IMPORT, + 'force-import' => self::TKD_MODE_FORCE_IMPORT, + 'import' => self::TKD_MODE_FORCE_IMPORT, + ]; + + return $arrMap[$strMode] ?? self::TKD_MODE_AUTO_GENERATE; + } + + public static function normalizeTkdProvider(string $strProvider): string + { + $strProvider = strtolower(trim($strProvider)); + if (in_array($strProvider, [self::TKD_PROVIDER_OPENAI, 'gpt', 'chatgpt', 'openai_compatible', 'openai-compatible', 'compatible'], true)) { + return self::TKD_PROVIDER_OPENAI; + } + + return self::TKD_PROVIDER_LOCAL; + } + + public static function normalizeSeoStrategyProfile(?string $strProfile): string + { + $strProfile = strtolower(trim((string)$strProfile)); + return array_key_exists($strProfile, self::getSeoStrategyProfileMap()) + ? $strProfile + : self::SEO_STRATEGY_PROFILE_STANDARD; + } + + public static function getSeoStrategyProfileDefaults(?string $strProfile): array + { + $strProfile = self::normalizeSeoStrategyProfile($strProfile); + return (array)(self::getSeoStrategyProfileMap()[$strProfile] ?? self::getSeoStrategyProfileMap()[self::SEO_STRATEGY_PROFILE_STANDARD]); + } + + public static function applySeoStrategyProfile(array|string|null $mSeoCfg, ?self $DomainModel = null, ?string $strProfile = null): array + { + $arrCurrentSeoCfg = self::normalizeSeoCfg($mSeoCfg, $DomainModel); + $strNormalizedProfile = self::normalizeSeoStrategyProfile($strProfile ?? (string)($arrCurrentSeoCfg['strategy_profile'] ?? self::SEO_STRATEGY_PROFILE_STANDARD)); + $arrProfileDefaults = self::getSeoStrategyProfileDefaults($strNormalizedProfile); + + $arrCurrentSeoCfg['strategy_profile'] = $strNormalizedProfile; + $arrCurrentSeoCfg['canonical_mode'] = (string)($arrProfileDefaults['canonical_mode'] ?? self::CANONICAL_MODE_STRICT); + $arrCurrentSeoCfg['play_index_mode'] = (string)($arrProfileDefaults['play_index_mode'] ?? self::PLAY_INDEX_MODE_NOINDEX_FOLLOW); + $arrCurrentSeoCfg['robots_policy'] = (string)($arrProfileDefaults['robots_policy'] ?? self::ROBOTS_POLICY_DEFAULT); + + $strForgeExposureProfile = self::normalizeForgeExposureProfile((string)($arrProfileDefaults['forge']['exposure_profile'] ?? self::FORGE_EXPOSURE_PROFILE_SAFE)); + $arrForgeProfileDefaults = self::getForgeExposureProfileDefaults($strForgeExposureProfile); + $arrCurrentForgeSeoCfg = is_array($arrCurrentSeoCfg['forge'] ?? null) ? $arrCurrentSeoCfg['forge'] : []; + $arrCurrentSeoCfg['forge'] = array_merge( + $arrCurrentForgeSeoCfg, + $arrForgeProfileDefaults, + [ + 'exposure_profile' => $strForgeExposureProfile, + 'route_group' => self::normalizeForgeRouteGroup((string)($arrCurrentForgeSeoCfg['route_group'] ?? self::FORGE_ROUTE_GROUP_DEFAULT)), + 'title_source' => trim((string)($arrCurrentForgeSeoCfg['title_source'] ?? self::FORGE_TITLE_SOURCE_SEO_WORDS)) ?: self::FORGE_TITLE_SOURCE_SEO_WORDS, + ] + ); + + $strStructuredPolicy = self::normalizeStructuredDataPolicy((string)($arrProfileDefaults['structured_data']['policy'] ?? self::STRUCTURED_POLICY_FULL)); + $arrStructuredPolicyDefaults = self::getStructuredDataPolicyDefaults($strStructuredPolicy); + $arrCurrentStructuredData = is_array($arrCurrentSeoCfg['structured_data'] ?? null) ? $arrCurrentSeoCfg['structured_data'] : []; + $arrCurrentSeoCfg['structured_data'] = array_merge( + $arrCurrentStructuredData, + $arrStructuredPolicyDefaults, + [ + 'policy' => $strStructuredPolicy, + 'organization_name' => trim((string)($arrCurrentStructuredData['organization_name'] ?? '')), + 'same_as' => $arrCurrentStructuredData['same_as'] ?? [], + ] + ); + + return self::normalizeSeoCfg($arrCurrentSeoCfg, $DomainModel); + } + + public static function getForgeExposureProfileMap(): array + { + return [ + self::FORGE_EXPOSURE_PROFILE_SAFE => [ + 'enable' => true, + 'count' => 12, + 'entry_count' => 3, + 'sitemap_count' => 0, + ], + self::FORGE_EXPOSURE_PROFILE_BALANCED => [ + 'enable' => true, + 'count' => 12, + 'entry_count' => 3, + 'sitemap_count' => 2, + ], + self::FORGE_EXPOSURE_PROFILE_EXPANDED => [ + 'enable' => true, + 'count' => 16, + 'entry_count' => 5, + 'sitemap_count' => 4, + ], + self::FORGE_EXPOSURE_PROFILE_OFF => [ + 'enable' => false, + 'count' => 0, + 'entry_count' => 0, + 'sitemap_count' => 0, + ], + ]; + } + + public static function normalizeForgeExposureProfile(?string $strProfile): string + { + $strProfile = strtolower(trim((string)$strProfile)); + return array_key_exists($strProfile, self::getForgeExposureProfileMap()) + ? $strProfile + : self::FORGE_EXPOSURE_PROFILE_SAFE; + } + + public static function getForgeExposureProfileDefaults(?string $strProfile): array + { + $strProfile = self::normalizeForgeExposureProfile($strProfile); + return (array)(self::getForgeExposureProfileMap()[$strProfile] ?? self::getForgeExposureProfileMap()[self::FORGE_EXPOSURE_PROFILE_SAFE]); + } + + public static function normalizeForgeRouteGroup(?string $strRouteGroup): string + { + $strRouteGroup = strtolower(trim((string)$strRouteGroup)); + return array_key_exists($strRouteGroup, self::getForgeRouteGroupMap()) + ? $strRouteGroup + : self::FORGE_ROUTE_GROUP_DEFAULT; + } + + public static function resolveForgeRouteGroupPrefix(?string $strRouteGroup): string + { + $strRouteGroup = self::normalizeForgeRouteGroup($strRouteGroup); + return (string)(self::getForgeRouteGroupMap()[$strRouteGroup] ?? ''); + } + + public function isForgeEnabled(): bool + { + $arrForgeSeoCfg = $this->getForgeSeoCfg(); + return (bool)($arrForgeSeoCfg['enable'] ?? true) && (int)($arrForgeSeoCfg['count'] ?? 0) > 0; + } + + public function getForgeCountLimit(): int + { + $arrForgeSeoCfg = $this->getForgeSeoCfg(); + return max(0, (int)($arrForgeSeoCfg['count'] ?? 0)); + } + + public function getForgeEntryDisplayCount(): int + { + $arrForgeSeoCfg = $this->getForgeSeoCfg(); + return max(0, min($this->getForgeCountLimit(), (int)($arrForgeSeoCfg['entry_count'] ?? self::defaultSeoCfg()['forge']['entry_count']))); + } + + public function getForgeSitemapCount(): int + { + $arrForgeSeoCfg = $this->getForgeSeoCfg(); + return max(0, min($this->getForgeCountLimit(), (int)($arrForgeSeoCfg['sitemap_count'] ?? self::defaultSeoCfg()['forge']['sitemap_count']))); + } + + public static function normalizeHost(string $strHost): string + { + $strHost = strtolower(trim($strHost)); + $strHost = preg_replace('#^https?://#i', '', $strHost); + $strHost = preg_replace('/:\d+$/', '', (string)$strHost); + $strHost = trim((string)$strHost, '.'); + $strHost = preg_replace('/^www\./', '', (string)$strHost); + + return (string)$strHost; + } + + public static function normalizeMatchType(?string $strMatchType): string + { + $strMatchType = strtolower(trim((string)$strMatchType)); + return in_array($strMatchType, [self::MATCH_TYPE_EXACT, self::MATCH_TYPE_WILDCARD], true) + ? $strMatchType + : self::MATCH_TYPE_EXACT; + } + + public static function normalizeSeedScope(?string $strSeedScope): string + { + $strSeedScope = strtolower(trim((string)$strSeedScope)); + return in_array($strSeedScope, [self::SEED_SCOPE_DOMAIN, self::SEED_SCOPE_HOST], true) + ? $strSeedScope + : self::SEED_SCOPE_DOMAIN; + } + + public static function normalizeStructuredDataPolicy(?string $strPolicy): string + { + $strPolicy = strtolower(trim((string)$strPolicy)); + return in_array( + $strPolicy, + [self::STRUCTURED_POLICY_FULL, self::STRUCTURED_POLICY_COMPACT, self::STRUCTURED_POLICY_MINIMAL], + true + ) ? $strPolicy : self::STRUCTURED_POLICY_FULL; + } + + public static function getStructuredDataPolicyDefaults(?string $strPolicy): array + { + $strPolicy = self::normalizeStructuredDataPolicy($strPolicy); + + return match ($strPolicy) { + self::STRUCTURED_POLICY_COMPACT => [ + 'reference_mode' => self::STRUCTURED_REFERENCE_MODE_ID_ONLY, + 'website_node' => true, + 'page_node' => true, + 'breadcrumb_node' => true, + 'video_object_node' => true, + 'organization_node' => true, + ], + self::STRUCTURED_POLICY_MINIMAL => [ + 'reference_mode' => self::STRUCTURED_REFERENCE_MODE_ID_ONLY, + 'website_node' => true, + 'page_node' => true, + 'breadcrumb_node' => false, + 'video_object_node' => true, + 'organization_node' => true, + ], + default => [ + 'reference_mode' => self::STRUCTURED_REFERENCE_MODE_INLINE, + 'website_node' => true, + 'page_node' => true, + 'breadcrumb_node' => true, + 'video_object_node' => true, + 'organization_node' => true, + ], + }; + } + + public static function normalizeStoredDomain(string $strDomain, ?string $strMatchType = null): string + { + $strMatchType = self::normalizeMatchType($strMatchType); + $strDomain = self::normalizeHost($strDomain); + $strDomain = preg_replace('/^\*\./', '', (string)$strDomain); + + if ($strMatchType === self::MATCH_TYPE_WILDCARD && $strDomain !== '') { + return '*.' . $strDomain; + } + + return (string)$strDomain; + } + + public static function normalizeParentDomain(?string $strParentDomain): string + { + $strParentDomain = self::normalizeHost((string)$strParentDomain); + $strParentDomain = preg_replace('/^\*\./', '', (string)$strParentDomain); + return (string)$strParentDomain; + } + + public static function normalizeRuntimeAcceptanceSamples(mixed $mSamples): array + { + if (is_string($mSamples)) { + $arrParsed = preg_split('/[\r\n,]+/', $mSamples) ?: []; + $mSamples = array_values(array_filter(array_map(static function ($strLine) { + $strLine = trim((string)$strLine); + if ($strLine === '') { + return null; + } + + if (preg_match('/^(\d+)(?::(\d+))?$/', $strLine, $arrMatches)) { + return [ + 'video_id' => (int)($arrMatches[1] ?? 0), + 'forge_id' => max(1, (int)($arrMatches[2] ?? 1)), + ]; + } + + return null; + }, $arrParsed))); + } + + if (!is_array($mSamples)) { + $mSamples = []; + } + + $arrSamples = []; + foreach ($mSamples as $mSample) { + if (is_string($mSample)) { + $mSample = self::normalizeRuntimeAcceptanceSamples($mSample); + foreach ($mSample as $arrNestedSample) { + $arrSamples[] = $arrNestedSample; + } + continue; + } + + if (!is_array($mSample)) { + continue; + } + + $intVideoId = max(0, (int)($mSample['video_id'] ?? $mSample['v_id'] ?? 0)); + $intForgeId = max(1, (int)($mSample['forge_id'] ?? 1)); + if ($intVideoId <= 0) { + continue; + } + + $strKey = $intVideoId . ':' . $intForgeId; + $arrSamples[$strKey] = [ + 'video_id' => $intVideoId, + 'forge_id' => $intForgeId, + ]; + } + + return array_slice(array_values($arrSamples), 0, 20); + } + + public static function extractWildcardBase(string $strDomain, ?string $strParentDomain = null): string + { + $strParentDomain = self::normalizeParentDomain($strParentDomain); + if ($strParentDomain !== '') { + return $strParentDomain; + } + + $strDomain = strtolower(trim($strDomain)); + $strDomain = preg_replace('/^\*\./', '', (string)$strDomain); + return self::normalizeHost((string)$strDomain); + } + + public static function rootDomainFromHost(string $strHost): string + { + $strHost = self::normalizeHost($strHost); + if ($strHost === '') { + return ''; + } + + if (filter_var($strHost, FILTER_VALIDATE_IP) || $strHost === 'localhost') { + return $strHost; + } + + $arrParts = explode('.', $strHost); + $intCount = count($arrParts); + if ($intCount <= 2) { + return $strHost; + } + + $arrSecondLevelTlds = [ + 'com.cn', + 'net.cn', + 'org.cn', + 'gov.cn', + 'co.uk', + 'org.uk', + 'ac.uk', + 'com.hk', + 'com.tw', + ]; + + $strLastTwo = $arrParts[$intCount - 2] . '.' . $arrParts[$intCount - 1]; + if (in_array($strLastTwo, $arrSecondLevelTlds, true) && $intCount >= 3) { + return $arrParts[$intCount - 3] . '.' . $strLastTwo; + } + + return $strLastTwo; + } + + /** + * Undocumented function + * + * @return array + */ + static public function getAllDomainOnCache(): array + { + if (empty(self::$arrAllDomain)) { + $strKey = 'Domain:All'; + self::$arrAllDomain = Cache::get($strKey); + if (empty(self::$arrAllDomain)) { + self::flushAllDomianOnCache(); + } + } + return self::$arrAllDomain; + } + + /** + * Undocumented function + * + * @return void + */ static public function flushAllDomianOnCache() { self::$arrAllDomain = []; + self::$arrResolvedDomain = []; $strKey = 'Domain:All'; $DomainModelAll = DomainModel::select(); foreach ($DomainModelAll as $DomainModel) { - self::$arrAllDomain[$DomainModel->d_domain] = $DomainModel; + $strKeyName = self::normalizeStoredDomain( + (string)$DomainModel->d_domain, + (string)($DomainModel->d_match_type ?? self::MATCH_TYPE_EXACT) + ); + self::$arrAllDomain[$strKeyName] = $DomainModel; } return Cache::set($strKey, self::$arrAllDomain); } - - /** - * Undocumented function - * - * @param string $strDomain - * @return DomainModel|NULL - */ + + /** + * Undocumented function + * + * @param string $strDomain + * @return DomainModel|NULL + */ static public function getDomainOnCacheByDomain($strDomain): DomainModel|NULL { + $strDomain = self::normalizeStoredDomain((string)$strDomain, self::MATCH_TYPE_EXACT); if (key_exists($strDomain, self::getAllDomainOnCache())) { return self::getAllDomainOnCache()[$strDomain]; } @@ -69,150 +811,289 @@ class DomainModel extends BaseModel return NULL; } - /** - * Undocumented function - * - * @param string $strWords - * @return string - */ - static public function wordsEncode($strWords): string + protected static function buildResolvedDomainContext(DomainModel $DomainModel, string $strRequestedHost, string $strMatchReason): array { - $Request = request(); - $intEncode = $Request->DomainModel->d_content_encode; + $strSourceDomain = self::normalizeStoredDomain( + (string)$DomainModel->d_domain, + (string)($DomainModel->d_match_type ?? self::MATCH_TYPE_EXACT) + ); + $strMatchType = self::normalizeMatchType((string)($DomainModel->d_match_type ?? self::MATCH_TYPE_EXACT)); + $strSeedScope = self::normalizeSeedScope((string)($DomainModel->d_seed_scope ?? self::SEED_SCOPE_DOMAIN)); + $strParentDomain = self::extractWildcardBase($strSourceDomain, (string)($DomainModel->d_parent_domain ?? '')); - if ($intEncode == 1) { - $strWords = customEntities($strWords); + $RuntimeDomainModel = $DomainModel->clone(); + if ($strMatchType === self::MATCH_TYPE_WILDCARD) { + $RuntimeDomainModel->d_parent_domain = $strParentDomain; + $RuntimeDomainModel->d_domain = $strRequestedHost; } - return $strWords; + $RuntimeDomainModel->d_match_type = $strMatchType; + $RuntimeDomainModel->d_seed_scope = $strSeedScope; + $RuntimeDomainModel->runtime_host = $strRequestedHost; + $RuntimeDomainModel->source_domain = $strSourceDomain; + $RuntimeDomainModel->match_reason = $strMatchReason; + $RuntimeDomainModel->resolved_from_cache = false; + $RuntimeDomainModel->resolved_lookup_state = $strMatchReason . '_hit'; + + return [ + 'runtime' => $RuntimeDomainModel, + 'source' => $DomainModel, + 'requested_host' => $strRequestedHost, + 'source_domain' => $strSourceDomain, + 'parent_domain' => $strParentDomain, + 'match_type' => $strMatchType, + 'match_reason' => $strMatchReason, + 'resolved_from_cache' => false, + 'resolved_lookup_state' => $strMatchReason . '_hit', + 'seed_scope' => $strSeedScope, + 'seed_host' => $strSeedScope === self::SEED_SCOPE_HOST ? $strRequestedHost : ($strMatchType === self::MATCH_TYPE_WILDCARD ? $strParentDomain : preg_replace('/^\*\./', '', $strSourceDomain)), + ]; } + public static function resolveDomainContextByHost(string $strHost): array + { + $strRequestedHost = self::normalizeHost($strHost); + if ($strRequestedHost === '') { + return [ + 'runtime' => null, + 'source' => null, + 'requested_host' => '', + 'source_domain' => '', + 'parent_domain' => '', + 'match_type' => self::MATCH_TYPE_EXACT, + 'match_reason' => 'none', + 'seed_scope' => self::SEED_SCOPE_DOMAIN, + 'seed_host' => '', + ]; + } - /** - * GetFomartSubjectEx - * - * @param string $strKey - * @param string $strDomain - * @param string $strController - * @param string $strAction - * @param string $strDiff - * @param boolean $boolJoin - * @return string - */ + if (isset(self::$arrResolvedDomain[$strRequestedHost])) { + $arrCached = self::$arrResolvedDomain[$strRequestedHost]; + $arrCached['resolved_from_cache'] = true; + $arrCached['resolved_lookup_state'] = 'cache_hit'; + + return self::$arrResolvedDomain[$strRequestedHost] = $arrCached; + } + + $arrAllDomain = self::getAllDomainOnCache(); + + $strExactKey = self::normalizeStoredDomain($strRequestedHost, self::MATCH_TYPE_EXACT); + if (isset($arrAllDomain[$strExactKey])) { + $arrResolved = self::buildResolvedDomainContext($arrAllDomain[$strExactKey], $strRequestedHost, 'exact'); + $arrResolved['resolved_from_cache'] = false; + $arrResolved['resolved_lookup_state'] = 'exact_hit'; + + return self::$arrResolvedDomain[$strRequestedHost] = $arrResolved; + } + + $DomainWildcardModel = null; + $strWildcardBase = ''; + foreach ($arrAllDomain as $strStoredDomain => $DomainModel) { + $strMatchType = self::normalizeMatchType((string)($DomainModel->d_match_type ?? self::MATCH_TYPE_EXACT)); + if ($strMatchType !== self::MATCH_TYPE_WILDCARD) { + continue; + } + + $strBaseDomain = self::extractWildcardBase($strStoredDomain, (string)($DomainModel->d_parent_domain ?? '')); + if ($strBaseDomain === '' || $strRequestedHost === $strBaseDomain) { + continue; + } + + if (!str_ends_with($strRequestedHost, '.' . $strBaseDomain)) { + continue; + } + + if ($DomainWildcardModel === null || strlen($strBaseDomain) > strlen($strWildcardBase)) { + $DomainWildcardModel = $DomainModel; + $strWildcardBase = $strBaseDomain; + } + } + + if ($DomainWildcardModel !== null) { + $arrResolved = self::buildResolvedDomainContext($DomainWildcardModel, $strRequestedHost, 'wildcard'); + $arrResolved['resolved_from_cache'] = false; + $arrResolved['resolved_lookup_state'] = 'wildcard_hit'; + + return self::$arrResolvedDomain[$strRequestedHost] = $arrResolved; + } + + $strRootDomain = self::rootDomainFromHost($strRequestedHost); + $strRootKey = self::normalizeStoredDomain($strRootDomain, self::MATCH_TYPE_EXACT); + if ($strRootDomain !== '' && $strRootDomain !== $strRequestedHost && isset($arrAllDomain[$strRootKey])) { + $arrResolved = self::buildResolvedDomainContext($arrAllDomain[$strRootKey], $strRequestedHost, 'root_fallback'); + $arrResolved['resolved_from_cache'] = false; + $arrResolved['resolved_lookup_state'] = 'root_fallback_hit'; + + return self::$arrResolvedDomain[$strRequestedHost] = $arrResolved; + } + + return self::$arrResolvedDomain[$strRequestedHost] = [ + 'runtime' => null, + 'source' => null, + 'requested_host' => $strRequestedHost, + 'source_domain' => '', + 'parent_domain' => '', + 'match_type' => self::MATCH_TYPE_EXACT, + 'match_reason' => 'miss', + 'resolved_from_cache' => false, + 'resolved_lookup_state' => 'miss', + 'seed_scope' => self::SEED_SCOPE_DOMAIN, + 'seed_host' => '', + ]; + } + + /** + * Undocumented function + * + * @param string $strWords + * @return string + */ + static public function wordsEncode($strWords): string + { + $Request = request(); + $intEncode = $Request->DomainModel->d_content_encode; + + if ($intEncode == 1) { + $strWords = customEntities($strWords); + } + + return $strWords; + } + + + /** + * GetFomartSubjectEx + * + * @param string $strKey + * @param string $strDomain + * @param string $strController + * @param string $strAction + * @param string $strDiff + * @param boolean $boolJoin + * @return string + */ public function getFomartSubjectEx(string $strKey, string $strDomain, string $strController, string $strAction, $strDiff = '', bool $boolJoin = true): string { - - if ( !array_key_exists($strKey, $this->t_cfg)) { + if (!is_array($this->t_cfg)) { + try { + $arrRuntimeStyle = \app\common\helper\SiteStyle::getConfig($this, (string)($this->d_domain ?? '')); + $this->t_cfg = is_array($arrRuntimeStyle['template_cfg'] ?? null) ? $arrRuntimeStyle['template_cfg'] : []; + } catch (\Throwable $Throwable) { + $this->t_cfg = []; + } + } + + if (!is_array($this->t_cfg) || !array_key_exists($strKey, $this->t_cfg)) { return ''; $strError = sprintf("t_cfg [%s] not found! %s ", $strKey , $strDomain . '-' . $strController . '-' . $strAction. '-' . $strDiff. '-' . $boolJoin.'-'.request()->url); throw new \Exception($strError); } - - $intSfgId = $this->t_cfg[$strKey]['sfg_id']; - $SubjectFomartModel = NULL; - - $arrAllSubjectFomartModel = SubjectFomartModel::getAllSubjectFomartOnCacheBySfgId($intSfgId); - if (empty($arrAllSubjectFomartModel)) { - $strError = sprintf("subject fomart [%s] not found !", $strKey); - throw new \Exception($strError); - } - - if ($boolJoin) { - $strUUId = sprintf('%s-%s-%s-%s-%s', $strDomain, $strController, $strAction, $strKey, $strDiff); - } else { - $strUUId = sprintf('%s-%s-%s', $strDomain, $strKey, $strDiff); - } - - $arrConfigSubjectFomart = ConfigSubjectFomartModel::getInstance()->getConfigSubjectFomartByCsfKey($strUUId); - - if (empty($arrConfigSubjectFomart)) { - - $intKey = array_rand($arrAllSubjectFomartModel); - - $SubjectFomartModel = $arrAllSubjectFomartModel[$intKey]; - - ConfigSubjectFomartModel::getInstance()->insert([ - 'csf_key' => $strUUId, - 'sf_id' => $SubjectFomartModel->sf_id, - ]); - } else { - $intSfId = $arrConfigSubjectFomart['sf_id']; - - foreach ($arrAllSubjectFomartModel as $objTemp) { - if ($objTemp->sf_id == $intSfId) { - $SubjectFomartModel = $objTemp; - break; - } - } - - # FIX 补丁,用于修复站点配置发生修改,但MONGO记录的数据无法找到MYSQL配置数据发生的异常 - if ($SubjectFomartModel == NULL && count($arrAllSubjectFomartModel) > 0) { - ConfigSubjectFomartModel::getInstance()->delConfigSubjectFomartByCsfKey($strUUId); - return $this->getFomartSubjectEx($strKey, $strDomain, $strController, $strAction, $strDiff, $boolJoin); - } - } - - if (empty($SubjectFomartModel)) { - $strError = sprintf("subject fomart [%s] not found !", $strKey); - throw new \Exception($strError); - } - - return $SubjectFomartModel->sf_val; - } - - /** - * Undocumented function - * - * @param string $strKey - * @return string - */ - public function getFomartSubject($strKey, $strDiff = '', bool $boolJoin = true) - { - $strDomain = request()->DomainModel->d_domain; - $strController = request()->controller(); - $strAction = request()->action(); - - return $this->getFomartSubjectEx($strKey, $strDomain, $strController, $strAction, $strDiff, $boolJoin); - } - - /** - * GetFomartUrl - * - * @param string $strKey - * @param array $arrArgs - * @return string - */ - public function getFomartUrl(string $strKey, array $arrArgs): string - { - $strSfVal = $this->getFomartSubject($strKey, '', false); - $arrKeys = []; - $arrVals = []; - foreach ($arrArgs as $strKey => $strVal) { - $arrKeys[] = '{' . $strKey . '}'; - $arrVals[] = $strVal; - } - return str_replace($arrKeys, $arrVals, $strSfVal); - } - - /** - * GetFomartUrl - * - * @param string $strKey - * @param array $arrArgs - * @return string - */ - public function getFomartUrlEx(string $strKey, string $strDomain, string $strController, string $strAction, array $arrArgs): string - { - $strSfVal = $this->getFomartSubjectEx($strKey, $strDomain, $strController, $strAction, '', false); - $arrKeys = []; - $arrVals = []; - foreach ($arrArgs as $strKey => $strVal) { - $arrKeys[] = '{' . $strKey . '}'; - $arrVals[] = $strVal; - } - - $strUri = str_replace($arrKeys, $arrVals, $strSfVal); - $strUri = str_replace('//', '/', $strUri); - return $strUri; - } -} + + $intSfgId = $this->t_cfg[$strKey]['sfg_id']; + $SubjectFomartModel = NULL; + + $arrAllSubjectFomartModel = SubjectFomartModel::getAllSubjectFomartOnCacheBySfgId($intSfgId); + if (empty($arrAllSubjectFomartModel)) { + $strError = sprintf("subject fomart [%s] not found !", $strKey); + throw new \Exception($strError); + } + + if ($boolJoin) { + $strUUId = sprintf('%s-%s-%s-%s-%s', $strDomain, $strController, $strAction, $strKey, $strDiff); + } else { + $strUUId = sprintf('%s-%s-%s', $strDomain, $strKey, $strDiff); + } + + $arrConfigSubjectFomart = ConfigSubjectFomartModel::getInstance()->getConfigSubjectFomartByCsfKey($strUUId); + + if (empty($arrConfigSubjectFomart)) { + + $intKey = array_rand($arrAllSubjectFomartModel); + + $SubjectFomartModel = $arrAllSubjectFomartModel[$intKey]; + + ConfigSubjectFomartModel::getInstance()->insert([ + 'csf_key' => $strUUId, + 'sf_id' => $SubjectFomartModel->sf_id, + ]); + } else { + $intSfId = $arrConfigSubjectFomart['sf_id']; + + foreach ($arrAllSubjectFomartModel as $objTemp) { + if ($objTemp->sf_id == $intSfId) { + $SubjectFomartModel = $objTemp; + break; + } + } + + # FIX 补丁,用于修复站点配置发生修改,但MONGO记录的数据无法找到MYSQL配置数据发生的异常 + if ($SubjectFomartModel == NULL && count($arrAllSubjectFomartModel) > 0) { + ConfigSubjectFomartModel::getInstance()->delConfigSubjectFomartByCsfKey($strUUId); + return $this->getFomartSubjectEx($strKey, $strDomain, $strController, $strAction, $strDiff, $boolJoin); + } + } + + if (empty($SubjectFomartModel)) { + $strError = sprintf("subject fomart [%s] not found !", $strKey); + throw new \Exception($strError); + } + + return $SubjectFomartModel->sf_val; + } + + /** + * Undocumented function + * + * @param string $strKey + * @return string + */ + public function getFomartSubject($strKey, $strDiff = '', bool $boolJoin = true) + { + $strDomain = request()->DomainModel->d_domain; + $strController = request()->controller(); + $strAction = request()->action(); + + return $this->getFomartSubjectEx($strKey, $strDomain, $strController, $strAction, $strDiff, $boolJoin); + } + + /** + * GetFomartUrl + * + * @param string $strKey + * @param array $arrArgs + * @return string + */ + public function getFomartUrl(string $strKey, array $arrArgs): string + { + $strSfVal = $this->getFomartSubject($strKey, '', false); + $arrKeys = []; + $arrVals = []; + foreach ($arrArgs as $strKey => $strVal) { + $arrKeys[] = '{' . $strKey . '}'; + $arrVals[] = $strVal; + } + return str_replace($arrKeys, $arrVals, $strSfVal); + } + + /** + * GetFomartUrl + * + * @param string $strKey + * @param array $arrArgs + * @return string + */ + public function getFomartUrlEx(string $strKey, string $strDomain, string $strController, string $strAction, array $arrArgs): string + { + $strSfVal = $this->getFomartSubjectEx($strKey, $strDomain, $strController, $strAction, '', false); + $arrKeys = []; + $arrVals = []; + foreach ($arrArgs as $strKey => $strVal) { + $arrKeys[] = '{' . $strKey . '}'; + $arrVals[] = $strVal; + } + + $strUri = str_replace($arrKeys, $arrVals, $strSfVal); + $strUri = str_replace('//', '/', $strUri); + return $strUri; + } +} diff --git a/code/app/task/logic/SeoCopyTaskLogic.php b/code/app/task/logic/SeoCopyTaskLogic.php new file mode 100644 index 0000000..95a128f --- /dev/null +++ b/code/app/task/logic/SeoCopyTaskLogic.php @@ -0,0 +1,1078 @@ +runLockedTask('seo-copy-prepare-approved', function (): array { + $strApprovedDir = $this->getApprovedDir(); + $strBatchRoot = $this->getBatchRoot(); + $arrApprovedSummary = SeoCopyApprovedManifestHelper::buildSummary($strApprovedDir, true); + + return SeoCopyBatchPrepareHelper::prepareApprovedManifests($arrApprovedSummary, $strBatchRoot, [ + 'approved_dir' => $strApprovedDir, + 'force' => $this->getBoolEnv('SEO_COPY_TASK_PREPARE_FORCE', false), + ]); + }); + } + + public function refreshPortal(): array + { + return $this->runLockedTask('seo-copy-refresh-portal', function (): array { + $strApprovedDir = $this->getApprovedDir(); + $strPortalRoot = $this->getPortalRoot(); + $strLogDir = $this->getPublishLogDir(); + $strRunsRoot = $this->getRunsRoot(); + $strTaskRunsRoot = $this->getTaskRunsRoot(); + $strBootstrapRunRoot = $this->getBootstrapRunRoot(); + $strRuntimeAcceptanceRunRoot = $this->getPathEnv('SEO_COPY_RUNTIME_ACCEPTANCE_RUN_ROOT', dirname(__DIR__, 3) . '/storage/seo_copy_runtime_acceptance_runs'); + + $arrApprovedSummary = SeoCopyApprovedManifestHelper::buildSummary($strApprovedDir, true); + SeoCopyApprovedManifestHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/approved', $arrApprovedSummary, true); + + $arrReleaseSummary = SeoCopyReleaseIndexHelper::buildSummary($strLogDir, true); + SeoCopyReleasePortalHelper::writeArtifacts($strPortalRoot, $arrReleaseSummary); + + $arrRunsSummary = SeoCopyReleaseRunIndexHelper::buildSummary($strRunsRoot, true); + SeoCopyReleaseRunIndexHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/runs', $arrRunsSummary); + + $arrRuntimeAcceptanceSummary = SeoCopyRuntimeAcceptanceIndexHelper::buildSummary($strRuntimeAcceptanceRunRoot, true); + SeoCopyRuntimeAcceptanceIndexHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/runtime-acceptance', $arrRuntimeAcceptanceSummary); + + $arrTaskSummary = SeoCopyTaskRunPortalHelper::buildSummary($strTaskRunsRoot, true, $this->getTaskStateRoot()); + SeoCopyTaskRunPortalHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/tasks', $arrTaskSummary); + + $arrBootstrapIndex = DomainBootstrapBundleIndexHelper::buildSummary($this->getBootstrapBundleRoot(), true); + $arrBootstrapRunSummary = DomainBootstrapRunIndexHelper::buildSummary($strBootstrapRunRoot, true); + $arrBootstrapHandoff = DomainBootstrapHandoffHelper::buildSummary($arrBootstrapIndex, $arrBootstrapRunSummary); + $arrBootstrapDuty = DomainBootstrapDutyHelper::buildSummary($arrBootstrapHandoff); + $arrBootstrapOncall = DomainBootstrapOncallHelper::buildSummary($arrBootstrapDuty); + $arrBootstrapCompleted = DomainBootstrapCompletedHelper::buildSummary($arrBootstrapIndex); + DomainBootstrapRunIndexHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/bootstrap/runs', $arrBootstrapRunSummary); + + $arrPortalSummary = SeoCopyPortalHomeHelper::buildSummary($strPortalRoot); + SeoCopyPortalHomeHelper::writeArtifacts($strPortalRoot, $arrPortalSummary); + + return [ + 'generated_at' => date('c'), + 'approved_dir' => $strApprovedDir, + 'portal_root' => $strPortalRoot, + 'publish_log_dir' => $strLogDir, + 'runs_root' => $strRunsRoot, + 'runtime_acceptance_run_root' => $strRuntimeAcceptanceRunRoot, + 'task_runs_root' => $strTaskRunsRoot, + 'approved_manifests' => (int)($arrApprovedSummary['manifests_count'] ?? 0), + 'releases' => count((array)($arrReleaseSummary['releases'] ?? [])), + 'runs' => (int)($arrRunsSummary['runs_count'] ?? 0), + 'runtime_acceptance_runs' => (int)($arrRuntimeAcceptanceSummary['runs_count'] ?? 0), + 'runtime_acceptance_hosts' => (int)($arrRuntimeAcceptanceSummary['hosts_count'] ?? 0), + 'task_runs' => (int)($arrTaskSummary['runs_count'] ?? 0), + 'tasks' => (int)($arrTaskSummary['tasks_count'] ?? 0), + 'bootstrap_bundles' => (int)($arrBootstrapIndex['bundles_count'] ?? 0), + 'bootstrap_runs' => (int)($arrBootstrapRunSummary['runs_count'] ?? 0), + 'bootstrap_oncall_open' => (int)($arrBootstrapOncall['open_count'] ?? 0), + 'bootstrap_oncall_latest_host' => (string)(($arrBootstrapOncall['latest_entry'] ?? [])['host'] ?? ''), + 'bootstrap_oncall_latest_priority' => (string)(($arrBootstrapOncall['latest_entry'] ?? [])['priority_label'] ?? ''), + 'bootstrap_oncall_latest_todo' => (string)(($arrBootstrapOncall['latest_entry'] ?? [])['todo_title'] ?? ''), + 'bootstrap_completed_count' => (int)($arrBootstrapCompleted['completed_count'] ?? 0), + 'bootstrap_latest_completed_host' => (string)(($arrBootstrapCompleted['latest_entry'] ?? [])['host'] ?? ''), + ]; + }); + } + + public function runBootstrapBundles(): array + { + return $this->runLockedTask('seo-copy-run-bootstrap-bundles', function (): array { + $strBundleRoot = $this->getBootstrapBundleRoot(); + $strRunRoot = $this->getBootstrapRunRoot(); + $strPortalRoot = $this->getPortalRoot(); + $intLimit = max(1, $this->getIntEnv('SEO_COPY_BOOTSTRAP_TASK_LIMIT', 20)); + $boolForce = $this->getBoolEnv('SEO_COPY_BOOTSTRAP_TASK_FORCE', false); + $boolDryRun = $this->getBoolEnv('SEO_COPY_BOOTSTRAP_TASK_DRY_RUN', true); + + $arrIndexSummary = DomainBootstrapBundleIndexHelper::buildSummary($strBundleRoot, true); + $arrAllBundles = array_values(array_filter((array)($arrIndexSummary['bundles'] ?? []), 'is_array')); + $arrPendingBundles = array_values(array_filter($arrAllBundles, static function (array $arrBundle) use ($boolForce): bool { + if ($boolForce) { + return true; + } + + return (string)($arrBundle['register_result_status'] ?? '') === '' + || (string)($arrBundle['apply_result_status'] ?? '') === '' + || (string)($arrBundle['bundle_run_status'] ?? '') === ''; + })); + + $arrExecuted = []; + foreach (array_slice($arrPendingBundles, 0, $intLimit) as $arrBundle) { + $strBundlePath = trim((string)($arrBundle['bundle_root'] ?? '')); + if ($strBundlePath === '') { + continue; + } + + $arrRunSummary = DomainBootstrapRunService::runBundle($strBundlePath, [ + 'dry_run' => $boolDryRun, + 'run_root' => $strRunRoot, + 'index_root' => $strBundleRoot, + 'portal_root' => $strPortalRoot, + ]); + + $arrExecuted[] = [ + 'host' => (string)($arrRunSummary['host'] ?? ''), + 'bundle_root' => $strBundlePath, + 'overall_status' => (string)($arrRunSummary['overall_status'] ?? ''), + 'run_json' => (string)(($arrRunSummary['run_artifacts'] ?? [])['json'] ?? ''), + ]; + } + + $arrSkipped = []; + foreach ($arrAllBundles as $arrBundle) { + $strBundlePath = trim((string)($arrBundle['bundle_root'] ?? '')); + if ($strBundlePath === '') { + continue; + } + + $boolWasExecuted = false; + foreach ($arrExecuted as $arrDone) { + if (($arrDone['bundle_root'] ?? '') === $strBundlePath) { + $boolWasExecuted = true; + break; + } + } + + if ($boolWasExecuted) { + continue; + } + + $arrSkipped[] = [ + 'host' => (string)($arrBundle['host'] ?? ''), + 'bundle_root' => $strBundlePath, + 'reason' => $boolForce ? 'limit_reached' : 'already_has_results', + ]; + } + + $arrIndexSummary = DomainBootstrapBundleIndexHelper::buildSummary($strBundleRoot, true); + $arrRunSummary = DomainBootstrapRunIndexHelper::buildSummary($strRunRoot, true); + $arrHandoffSummary = DomainBootstrapHandoffHelper::buildSummary($arrIndexSummary, $arrRunSummary); + $arrDutySummary = DomainBootstrapDutyHelper::buildSummary($arrHandoffSummary); + $arrOncallSummary = DomainBootstrapOncallHelper::buildSummary($arrDutySummary); + $arrCompletedSummary = DomainBootstrapCompletedHelper::buildSummary($arrIndexSummary); + + return [ + 'generated_at' => date('c'), + 'status' => 'success', + 'bundle_root' => $strBundleRoot, + 'run_root' => $strRunRoot, + 'dry_run' => $boolDryRun, + 'force' => $boolForce, + 'limit' => $intLimit, + 'bundle_count' => count($arrAllBundles), + 'pending_count' => count($arrPendingBundles), + 'executed_count' => count($arrExecuted), + 'skipped_count' => count($arrSkipped), + 'oncall_open' => (int)($arrOncallSummary['open_count'] ?? 0), + 'oncall_latest_host' => (string)(($arrOncallSummary['latest_entry'] ?? [])['host'] ?? ''), + 'oncall_latest_priority' => (string)(($arrOncallSummary['latest_entry'] ?? [])['priority_label'] ?? ''), + 'oncall_latest_todo' => (string)(($arrOncallSummary['latest_entry'] ?? [])['todo_title'] ?? ''), + 'completed_count' => (int)($arrCompletedSummary['completed_count'] ?? 0), + 'latest_completed_host' => (string)(($arrCompletedSummary['latest_entry'] ?? [])['host'] ?? ''), + 'executed' => $arrExecuted, + 'skipped' => array_slice($arrSkipped, 0, 20), + ]; + }); + } + + public function runBootstrapOncall(): array + { + return $this->runLockedTask('seo-copy-run-bootstrap-oncall', function (): array { + $arrOptions = [ + 'bundle_root' => $this->getBootstrapBundleRoot(), + 'run_root' => $this->getBootstrapRunRoot(), + 'portal_root' => $this->getPortalRoot(), + 'host' => $this->getStringEnv('SEO_COPY_BOOTSTRAP_ONCALL_HOST', ''), + 'owner' => $this->getStringEnv('SEO_COPY_BOOTSTRAP_ONCALL_OWNER', ''), + 'priority' => $this->getStringEnv('SEO_COPY_BOOTSTRAP_ONCALL_PRIORITY', ''), + 'limit' => $this->getIntEnv('SEO_COPY_BOOTSTRAP_ONCALL_LIMIT', 1), + 'dry_run' => $this->getBoolEnv('SEO_COPY_BOOTSTRAP_ONCALL_DRY_RUN', true), + 'allow_attention' => $this->getBoolEnv('SEO_COPY_BOOTSTRAP_ONCALL_ALLOW_ATTENTION', false), + 'allow_prepare' => $this->getBoolEnv('SEO_COPY_BOOTSTRAP_ONCALL_ALLOW_PREPARE', false), + 'allow_release_dry_run' => $this->getBoolEnv('SEO_COPY_BOOTSTRAP_ONCALL_ALLOW_RELEASE_DRY_RUN', false), + 'allow_publish' => $this->getBoolEnv('SEO_COPY_BOOTSTRAP_ONCALL_ALLOW_PUBLISH', false), + ]; + $arrContext = DomainBootstrapOncallService::buildContext($arrOptions); + $arrEntry = DomainBootstrapOncallService::selectEntry($arrContext, $arrOptions); + + if (empty($arrEntry)) { + return [ + 'generated_at' => date('c'), + 'status' => 'skipped_no_entry', + 'bundle_root' => $this->getBootstrapBundleRoot(), + 'run_root' => $this->getBootstrapRunRoot(), + 'dispatch_limit' => (int)($arrOptions['limit'] ?? 1), + 'oncall_open' => (int)(($arrContext['oncall_summary'] ?? [])['open_count'] ?? 0), + ]; + } + + $arrDispatch = DomainBootstrapOncallService::dispatch($arrOptions); + $arrCompletedSummary = DomainBootstrapCompletedHelper::buildSummary( + DomainBootstrapBundleIndexHelper::buildSummary($this->getBootstrapBundleRoot(), true) + ); + $arrExecutions = array_values(array_filter((array)($arrDispatch['executions'] ?? []), 'is_array')); + $arrFirstExecution = (array)($arrExecutions[0] ?? []); + $strExecutionStatus = (string)($arrFirstExecution['execution_status'] ?? ($arrDispatch['execution_status'] ?? '')); + $boolHasSuccess = count(array_filter($arrExecutions, static function (array $arrExecution): bool { + return in_array((string)($arrExecution['execution_status'] ?? ''), ['bootstrap_run_executed', 'attention_dry_run_executed', 'prepare_executed', 'release_dry_run_executed', 'prepare_and_release_dry_run_executed', 'publish_executed'], true); + })) > 0; + $strTaskStatus = !$boolHasSuccess && in_array($strExecutionStatus, ['blocked_attention', 'blocked_prepare', 'blocked_publish', 'unsupported_stage'], true) + ? 'needs_attention' + : 'success'; + + return [ + 'generated_at' => date('c'), + 'status' => $strTaskStatus, + 'bundle_root' => $this->getBootstrapBundleRoot(), + 'run_root' => $this->getBootstrapRunRoot(), + 'dispatch_limit' => (int)($arrOptions['limit'] ?? 1), + 'processed_count' => (int)($arrDispatch['processed_count'] ?? 0), + 'oncall_open' => (int)($arrDispatch['open_count_after'] ?? (int)(($arrContext['oncall_summary'] ?? [])['open_count'] ?? 0)), + 'selected_host' => (string)($arrDispatch['selected_host'] ?? (string)($arrEntry['host'] ?? '')), + 'selected_priority' => (string)($arrDispatch['selected_priority'] ?? (string)($arrEntry['priority_label'] ?? '')), + 'selected_owner' => (string)($arrDispatch['selected_owner'] ?? (string)($arrEntry['owner_label'] ?? '')), + 'selected_todo' => (string)($arrDispatch['selected_todo'] ?? (string)($arrEntry['todo_title'] ?? '')), + 'execution_status' => $strExecutionStatus, + 'run_json' => (string)($arrFirstExecution['run_json'] ?? ''), + 'prepare_out_dir' => (string)($arrFirstExecution['prepare_out_dir'] ?? ''), + 'message' => (string)($arrFirstExecution['message'] ?? ''), + 'recommended_command' => (string)($arrFirstExecution['recommended_command'] ?? ''), + 'latest_host_after' => (string)($arrDispatch['latest_host_after'] ?? ''), + 'latest_priority_after' => (string)($arrDispatch['latest_priority_after'] ?? ''), + 'latest_todo_after' => (string)($arrDispatch['latest_todo_after'] ?? ''), + 'completed_count' => (int)($arrCompletedSummary['completed_count'] ?? 0), + 'latest_completed_host' => (string)(($arrCompletedSummary['latest_entry'] ?? [])['host'] ?? ''), + 'executions' => $arrExecutions, + ]; + }); + } + + public function runBootstrapPipeline(): array + { + return $this->runLockedTask('seo-copy-run-bootstrap-pipeline', function (): array { + $boolImportRequested = $this->getBoolEnv('SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_IMPORT', false); + $boolImportArmed = $this->getBoolEnv('SEO_COPY_BOOTSTRAP_PIPELINE_IMPORT_ARMED', false); + $arrOptions = [ + 'bundle_root' => $this->getBootstrapBundleRoot(), + 'run_root' => $this->getBootstrapRunRoot(), + 'portal_root' => $this->getPortalRoot(), + 'pipeline_run_root' => $this->getPathEnv('SEO_COPY_BOOTSTRAP_PIPELINE_RUN_ROOT', dirname(__DIR__, 3) . '/storage/domain_bootstrap_pipeline_runs'), + 'host' => $this->getStringEnv('SEO_COPY_BOOTSTRAP_PIPELINE_HOST', ''), + 'owner' => $this->getStringEnv('SEO_COPY_BOOTSTRAP_PIPELINE_OWNER', ''), + 'priority' => $this->getStringEnv('SEO_COPY_BOOTSTRAP_PIPELINE_PRIORITY', ''), + 'step_limit' => $this->getIntEnv('SEO_COPY_BOOTSTRAP_PIPELINE_STEP_LIMIT', 6), + 'dry_run' => $this->getBoolEnv('SEO_COPY_BOOTSTRAP_PIPELINE_DRY_RUN', true), + 'allow_attention' => $this->getBoolEnv('SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_ATTENTION', false), + 'allow_prepare' => $this->getBoolEnv('SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_PREPARE', true), + 'allow_import' => $boolImportRequested && $boolImportArmed, + 'ai_result_dir' => $this->getStringEnv('SEO_COPY_BOOTSTRAP_PIPELINE_AI_RESULT_DIR', ''), + 'ai_result_root' => $this->getStringEnv('SEO_COPY_BOOTSTRAP_PIPELINE_AI_RESULT_ROOT', ''), + 'allow_release_dry_run' => $this->getBoolEnv('SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_RELEASE_DRY_RUN', false), + 'allow_publish' => $this->getBoolEnv('SEO_COPY_BOOTSTRAP_PIPELINE_ALLOW_PUBLISH', false), + ]; + + $arrSummary = DomainBootstrapPipelineHelper::run($arrOptions); + + return [ + 'generated_at' => (string)($arrSummary['generated_at'] ?? date('c')), + 'status' => (string)($arrSummary['status'] ?? 'success'), + 'bundle_root' => $this->getBootstrapBundleRoot(), + 'run_root' => $this->getBootstrapRunRoot(), + 'pipeline_run_root' => (string)($arrOptions['pipeline_run_root'] ?? ''), + 'step_limit' => (int)($arrSummary['step_limit'] ?? 0), + 'dry_run' => (bool)($arrSummary['dry_run'] ?? true), + 'import_requested' => $boolImportRequested, + 'import_armed' => $boolImportArmed, + 'allow_import' => (bool)($arrSummary['allow_import'] ?? false), + 'ai_result_dir' => (string)($arrSummary['ai_result_dir'] ?? ''), + 'ai_result_root' => (string)($arrSummary['ai_result_root'] ?? ''), + 'processed_count' => (int)($arrSummary['processed_count'] ?? 0), + 'open_count_before' => (int)($arrSummary['open_count_before'] ?? 0), + 'open_count_after' => (int)($arrSummary['open_count_after'] ?? 0), + 'stop_reason' => (string)($arrSummary['stop_reason'] ?? ''), + 'latest_host_after' => (string)($arrSummary['latest_host_after'] ?? ''), + 'latest_priority_after' => (string)($arrSummary['latest_priority_after'] ?? ''), + 'latest_todo_after' => (string)($arrSummary['latest_todo_after'] ?? ''), + 'latest_completed_host' => (string)($arrSummary['latest_completed_host'] ?? ''), + 'latest_completed_stage' => (string)($arrSummary['latest_completed_stage'] ?? ''), + 'pipeline_json' => (string)(($arrSummary['artifacts'] ?? [])['json'] ?? ''), + 'steps' => array_slice((array)($arrSummary['steps'] ?? []), 0, 20), + ]; + }); + } + + public function runRuntimeAcceptance(): array + { + return $this->runLockedTask('seo-copy-run-runtime-acceptance', function (): array { + $strRawHostEnv = trim((string)getenv('SEO_COPY_RUNTIME_ACCEPTANCE_HOST')); + $strRawVideoIdEnv = trim((string)getenv('SEO_COPY_RUNTIME_ACCEPTANCE_VIDEO_ID')); + $strRawForgeIdEnv = trim((string)getenv('SEO_COPY_RUNTIME_ACCEPTANCE_FORGE_ID')); + $strRawBaseUrlEnv = trim((string)getenv('SEO_COPY_RUNTIME_ACCEPTANCE_BASE_URL')); + $strRawLimitEnv = trim((string)getenv('SEO_COPY_RUNTIME_ACCEPTANCE_LIMIT')); + $boolForceAllDomains = in_array(strtolower($strRawHostEnv), ['*', 'all', 'all_domains'], true); + $strHost = $this->getStringEnvPreferEnvFile('SEO_COPY_RUNTIME_ACCEPTANCE_HOST', ''); + $intVideoId = $this->getIntEnvPreferEnvFile('SEO_COPY_RUNTIME_ACCEPTANCE_VIDEO_ID', 0); + $intForgeId = max(1, $this->getIntEnvPreferEnvFile('SEO_COPY_RUNTIME_ACCEPTANCE_FORGE_ID', 1)); + $strBaseUrl = $this->getStringEnvPreferEnvFile('SEO_COPY_RUNTIME_ACCEPTANCE_BASE_URL', ''); + $strRunRoot = $this->getPathEnv('SEO_COPY_RUNTIME_ACCEPTANCE_RUN_ROOT', dirname(__DIR__, 3) . '/storage/seo_copy_runtime_acceptance_runs'); + $strPortalRoot = $this->getPortalRoot(); + $intLimit = max(1, $this->getIntEnvPreferEnvFile('SEO_COPY_RUNTIME_ACCEPTANCE_LIMIT', 1)); + + if ($boolForceAllDomains) { + $strHost = ''; + $intVideoId = 0; + if ($strRawLimitEnv !== '' && is_numeric($strRawLimitEnv)) { + $intLimit = max(1, (int)$strRawLimitEnv); + } + } + + if ($strHost === '' && $strRawHostEnv === '') { + $strHost = $this->getEnvFileValue('SEO_COPY_RUNTIME_ACCEPTANCE_HOST', ''); + } + if ($intVideoId <= 0 && $strRawVideoIdEnv === '') { + $strVideoId = $this->getEnvFileValue('SEO_COPY_RUNTIME_ACCEPTANCE_VIDEO_ID', ''); + $intVideoId = is_numeric($strVideoId) ? (int)$strVideoId : 0; + } + if ($intForgeId <= 1 && $strRawForgeIdEnv === '') { + $strForgeId = $this->getEnvFileValue('SEO_COPY_RUNTIME_ACCEPTANCE_FORGE_ID', ''); + if (is_numeric($strForgeId)) { + $intForgeId = max(1, (int)$strForgeId); + } + } + if ($strBaseUrl === '' && $strRawBaseUrlEnv === '') { + $strBaseUrl = $this->getEnvFileValue('SEO_COPY_RUNTIME_ACCEPTANCE_BASE_URL', ''); + } + + $arrTargetContext = $this->resolveRuntimeAcceptanceTargets($strHost, $intVideoId, $intForgeId, $intLimit); + $arrTargets = (array)($arrTargetContext['targets'] ?? []); + if (empty($arrTargets)) { + return [ + 'generated_at' => date('c'), + 'status' => 'skipped_missing_target', + 'host' => $boolForceAllDomains ? '*' : $strHost, + 'video_id' => $intVideoId, + 'forge_id' => $intForgeId, + 'limit' => $intLimit, + 'target_source' => $boolForceAllDomains ? 'domain_cfg_all' : (string)($arrTargetContext['source'] ?? ''), + 'configured_samples' => (int)($arrTargetContext['configured_samples'] ?? 0), + 'enabled_domains' => (int)($arrTargetContext['enabled_domains'] ?? 0), + 'selected_domains' => (int)($arrTargetContext['selected_domains'] ?? 0), + 'total_targets' => (int)($arrTargetContext['total_targets'] ?? 0), + 'selected_targets' => (int)($arrTargetContext['selected_targets'] ?? 0), + 'skipped_targets' => (int)($arrTargetContext['skipped_targets'] ?? 0), + 'domain_summaries' => array_slice((array)($arrTargetContext['domain_summaries'] ?? []), 0, 20), + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, + ]; + } + + $arrRuns = []; + foreach ($arrTargets as $arrTarget) { + $strTargetHost = trim((string)($arrTarget['host'] ?? '')); + $intTargetVideoId = (int)($arrTarget['video_id'] ?? 0); + $intTargetForgeId = max(1, (int)($arrTarget['forge_id'] ?? 1)); + if ($strTargetHost === '' || $intTargetVideoId <= 0) { + continue; + } + + $arrRuns[] = SeoCopyRuntimeAcceptanceHelper::run($strTargetHost, $intTargetVideoId, [ + 'forge_id' => $intTargetForgeId, + 'base_url' => $strBaseUrl !== '' ? $strBaseUrl : ('https://' . $strTargetHost), + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, + ]); + } + + if (empty($arrRuns)) { + return [ + 'generated_at' => date('c'), + 'status' => 'skipped_missing_target', + 'host' => $strHost, + 'video_id' => $intVideoId, + 'forge_id' => $intForgeId, + 'limit' => $intLimit, + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, + ]; + } + + $arrRunSummary = $arrRuns[0]; + $intPassedCount = count(array_filter($arrRuns, static fn(array $arrRun): bool => !empty($arrRun['all_passed']))); + $intFailedCount = max(0, count($arrRuns) - $intPassedCount); + + return [ + 'generated_at' => (string)($arrRunSummary['generated_at'] ?? date('c')), + 'status' => $intFailedCount === 0 ? 'success' : ($intPassedCount > 0 ? 'partial' : 'failed'), + 'host' => $boolForceAllDomains ? '*' : (string)($arrRunSummary['host'] ?? $strHost), + 'video_id' => (int)($arrRunSummary['video_id'] ?? $intVideoId), + 'forge_id' => (int)($arrRunSummary['forge_id'] ?? $intForgeId), + 'base_url' => $strBaseUrl !== '' ? $strBaseUrl : ('https://' . ((string)($arrRunSummary['host'] ?? $strHost))), + 'limit' => $intLimit, + 'target_source' => $boolForceAllDomains ? 'domain_cfg_all' : (string)($arrTargetContext['source'] ?? ''), + 'configured_samples' => (int)($arrTargetContext['configured_samples'] ?? count($arrTargets)), + 'enabled_domains' => (int)($arrTargetContext['enabled_domains'] ?? 0), + 'selected_domains' => (int)($arrTargetContext['selected_domains'] ?? 0), + 'total_targets' => (int)($arrTargetContext['total_targets'] ?? count($arrTargets)), + 'selected_targets' => (int)($arrTargetContext['selected_targets'] ?? count($arrTargets)), + 'skipped_targets' => (int)($arrTargetContext['skipped_targets'] ?? 0), + 'processed_count' => count($arrRuns), + 'passed_count' => $intPassedCount, + 'failed_count' => $intFailedCount, + 'domain_summaries' => array_slice((array)($arrTargetContext['domain_summaries'] ?? []), 0, 20), + 'targets' => array_values(array_map(static fn(array $arrRun): array => [ + '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)), + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, + 'exit_code' => $intFailedCount === 0 ? 0 : 1, + 'all_passed' => $intFailedCount === 0, + 'runtime_run_path' => (string)($arrRunSummary['run_path'] ?? ''), + 'runtime_output_root' => (string)($arrRunSummary['output_root'] ?? ''), + 'runtime_runs_count' => (int)($arrRunSummary['runs_count'] ?? 0), + 'runtime_status_totals' => (array)($arrRunSummary['status_totals'] ?? []), + 'latest_runtime_run' => (array)($arrRunSummary['latest_run'] ?? []), + ]; + }); + } + + public function runRuntimeAcceptanceFailedHosts(): array + { + return $this->runLockedTask('seo-copy-run-runtime-acceptance-failed-hosts', function (): array { + $strHost = $this->getStringEnvPreferEnvFile('SEO_COPY_RUNTIME_ACCEPTANCE_FAILED_HOSTS_HOST', ''); + $strCategory = $this->getStringEnvPreferEnvFile('SEO_COPY_RUNTIME_ACCEPTANCE_FAILED_HOSTS_CATEGORY', ''); + $intLimit = max(1, $this->getIntEnvPreferEnvFile('SEO_COPY_RUNTIME_ACCEPTANCE_FAILED_HOSTS_LIMIT', 5)); + $strRunRoot = $this->getPathEnv('SEO_COPY_RUNTIME_ACCEPTANCE_RUN_ROOT', dirname(__DIR__, 3) . '/storage/seo_copy_runtime_acceptance_runs'); + $strPortalRoot = $this->getPortalRoot(); + + $arrResult = SeoCopyRuntimeAcceptanceHelper::runFailedHosts([ + 'host' => $strHost, + 'category' => $strCategory, + 'limit' => $intLimit, + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, + ]); + + return [ + 'generated_at' => (string)($arrResult['generated_at'] ?? date('c')), + 'status' => (string)($arrResult['status'] ?? 'success'), + 'host' => (string)($arrResult['host'] ?? ''), + 'category' => (string)($arrResult['category'] ?? ''), + 'limit' => $intLimit, + 'failed_hosts_count' => (int)($arrResult['failed_hosts_count'] ?? 0), + 'processed_count' => (int)($arrResult['processed_count'] ?? 0), + 'passed_count' => (int)($arrResult['passed_count'] ?? 0), + 'failed_count' => (int)($arrResult['failed_count'] ?? 0), + 'hosts' => array_values((array)($arrResult['hosts'] ?? [])), + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, + ]; + }); + } + + public function runDomainSupplyBatch(): array + { + return $this->runLockedTask('seo-copy-run-domain-supply-batch', function (): array { + $strSupplyRunRoot = $this->getPathEnv('SEO_COPY_DOMAIN_SUPPLY_RUN_ROOT', dirname(__DIR__, 3) . '/public/_admin_templates/domain-supply-runs'); + $strTemplatePath = $this->getStringEnvPreferEnvFile('SEO_COPY_DOMAIN_SUPPLY_TEMPLATE_PATH', ''); + $boolRunBootstrap = $this->getBoolEnv('SEO_COPY_DOMAIN_SUPPLY_RUN_BOOTSTRAP', false); + $strBootstrapRun = strtolower($this->getStringEnvPreferEnvFile('SEO_COPY_DOMAIN_SUPPLY_BOOTSTRAP_RUN', 'off')); + if (!in_array($strBootstrapRun, ['off', 'dry-run', 'run'], true)) { + $strBootstrapRun = 'off'; + } + $boolForce = $this->getBoolEnv('SEO_COPY_DOMAIN_SUPPLY_FORCE', false); + + $arrResult = DomainSupplyBatchHelper::runLatest($strSupplyRunRoot, [ + 'template_path' => $strTemplatePath, + 'run_bootstrap' => $boolRunBootstrap, + 'bootstrap_run' => $strBootstrapRun, + 'portal_root' => 'off', + 'force' => $boolForce, + 'run_prefix' => 'task', + ]); + $arrRunSummary = DomainSupplyRunIndexHelper::buildSummary($strSupplyRunRoot, 10); + $arrLatestRun = (array)($arrRunSummary['latest_run'] ?? []); + + return [ + 'generated_at' => date('c'), + 'status' => (int)($arrResult['failed_count'] ?? 0) > 0 ? 'partial' : 'success', + 'template_source' => (string)($arrResult['template_source'] ?? 'latest'), + 'template_path' => (string)($arrResult['resolved_template_path'] ?? ''), + 'run_root' => (string)($arrResult['output_root'] ?? ''), + 'run_root_public' => $this->toPublicRelativePath((string)($arrResult['output_root'] ?? '')), + 'processed_count' => (int)($arrResult['processed_count'] ?? 0), + 'success_count' => (int)($arrResult['success_count'] ?? 0), + 'failed_count' => (int)($arrResult['failed_count'] ?? 0), + 'site_import_rows_count' => (int)($arrResult['site_import_rows_count'] ?? 0), + 'bootstrap_rows_count' => (int)($arrResult['bootstrap_rows_count'] ?? 0), + 'run_bootstrap' => (bool)($arrResult['run_bootstrap'] ?? false), + 'bootstrap_run' => (string)($arrResult['bootstrap_run'] ?? $strBootstrapRun), + 'force' => $boolForce, + 'summary_json_path' => $this->toPublicRelativePath((string)($arrResult['summary_json'] ?? '')), + 'summary_html_path' => $this->toPublicRelativePath((string)($arrResult['summary_html'] ?? '')), + 'items' => array_slice((array)($arrResult['items'] ?? []), 0, 50), + 'supply_runs_count' => (int)($arrRunSummary['runs_count'] ?? 0), + 'latest_supply_run' => $arrLatestRun, + 'latest_failed_host' => (array)($arrRunSummary['latest_failed_host'] ?? []), + ]; + }); + } + + public function runDomainSupplyOrchestration(): array + { + return $this->runLockedTask('seo-copy-run-domain-supply-orchestration', function (): array { + $strSupplyRunRoot = $this->getPathEnv('SEO_COPY_DOMAIN_SUPPLY_RUN_ROOT', dirname(__DIR__, 3) . '/public/_admin_templates/domain-supply-runs'); + $strPortalRoot = $this->getPortalRoot(); + $arrOpsSummary = SeoCopyPortalHomeHelper::buildSummary($strPortalRoot); + $arrSupplySummary = DomainSupplyRunIndexHelper::buildSummary($strSupplyRunRoot, 20, $arrOpsSummary); + $arrOrchestration = (array)($arrSupplySummary['orchestration_summary'] ?? []); + $arrPlan = (array)($arrOrchestration['plan'] ?? []); + $arrBuckets = array_values(array_filter((array)($arrOrchestration['buckets'] ?? []), 'is_array')); + $arrSequence = array_values(array_filter((array)($arrOrchestration['sequence'] ?? []), 'is_array')); + $arrPhase = (array)($arrOrchestration['phase'] ?? []); + $arrNextAction = (array)($arrOrchestration['next_action'] ?? []); + $arrActionOrder = array_values(array_filter((array)($arrOrchestration['action_order'] ?? []), 'is_array')); + $arrHandoffPlan = (array)($arrOrchestration['handoff_plan'] ?? []); + $arrMainlinePlan = (array)($arrOrchestration['mainline_plan'] ?? []); + $arrMainlineChain = array_values(array_filter((array)($arrOrchestration['mainline_chain'] ?? []), 'is_array')); + $arrMainlineRoute = array_values(array_filter((array)($arrOrchestration['mainline_route'] ?? []), 'is_array')); + $arrProjectedMainlineRoute = array_values(array_filter((array)($arrOrchestration['projected_mainline_route'] ?? []), 'is_array')); + $arrNextMainlineStage = (array)($arrOrchestration['next_mainline_stage'] ?? []); + $arrProjectedNextMainlineStage = (array)($arrOrchestration['projected_next_mainline_stage'] ?? []); + $arrNextBusinessAction = (array)($arrOrchestration['next_business_action'] ?? []); + $arrMainlineSequencePlan = (array)($arrOrchestration['mainline_sequence_plan'] ?? []); + $arrBusinessSequence = array_values(array_filter((array)($arrOrchestration['business_sequence'] ?? []), 'is_array')); + $arrBusinessTakeoverQueue = array_values(array_filter((array)($arrOrchestration['business_takeover_queue'] ?? []), 'is_array')); + $arrBusinessEntryRule = (array)($arrOrchestration['business_entry_rule'] ?? []); + $arrBusinessEntryGateStack = array_values(array_filter((array)($arrOrchestration['business_entry_gate_stack'] ?? []), 'is_array')); + $arrBusinessEntryCurrentGate = (array)($arrOrchestration['business_entry_current_gate'] ?? []); + $arrBusinessEntryBlockers = array_values(array_filter((array)($arrOrchestration['business_entry_blockers'] ?? []), 'is_array')); + $arrBusinessEntryStrategy = (array)($arrOrchestration['business_entry_strategy'] ?? []); + $arrBusinessActionQueue = array_values(array_filter((array)($arrOrchestration['business_action_queue'] ?? []), 'is_array')); + $arrBusinessActionCurrent = (array)($arrOrchestration['business_action_current'] ?? []); + $arrBusinessActionNext = (array)($arrOrchestration['business_action_next'] ?? []); + $arrBusinessExecutionPolicy = (array)($arrOrchestration['business_execution_policy'] ?? []); + $arrBusinessReviewPolicy = (array)($arrOrchestration['business_review_policy'] ?? []); + $arrBusinessControlTower = (array)($arrOrchestration['business_control_tower'] ?? []); + $arrMainlineProgram = (array)($arrSupplySummary['mainline_program'] ?? []); + $arrMainlineEntry = (array)($arrSupplySummary['mainline_entry'] ?? []); + $arrMainlineWorkbench = (array)($arrSupplySummary['mainline_workbench'] ?? []); + $arrMainlineControlTower = (array)($arrSupplySummary['mainline_control_tower'] ?? []); + $arrDispatchPlan = (array)($arrOrchestration['dispatch_plan'] ?? []); + $arrDispatchStage = (array)($arrOrchestration['dispatch_stage'] ?? []); + $arrNextDispatchStage = (array)($arrOrchestration['next_dispatch_stage'] ?? []); + $arrDispatchChain = array_values(array_filter((array)($arrOrchestration['dispatch_chain'] ?? []), 'is_array')); + $arrDispatchOrder = array_values(array_filter((array)($arrOrchestration['dispatch_order'] ?? []), 'is_array')); + $arrFirstStep = (array)($arrSequence[0] ?? []); + $arrSecondStep = (array)($arrSequence[1] ?? []); + $arrThirdStep = (array)($arrSequence[2] ?? []); + $arrLeadBusinessHost = (array)($arrBusinessTakeoverQueue[0] ?? []); + + $strLabel = trim((string)($arrPlan['label'] ?? '')); + $strStatus = in_array($strLabel, ['待发起', '先回看编排', '先回看任务', '先回看运行', '先排异常'], true) + ? 'needs_attention' + : 'success'; + + return [ + 'generated_at' => date('c'), + 'status' => $strStatus, + 'supply_runs_count' => (int)($arrSupplySummary['runs_count'] ?? 0), + 'hosts_count' => (int)($arrOrchestration['hosts_count'] ?? 0), + 'failed_hosts_count' => count(array_filter((array)($arrSupplySummary['host_totals'] ?? []), static function (array $arrHost): bool { + return (int)($arrHost['failed_count'] ?? 0) > 0; + })), + 'latest_run' => (array)($arrSupplySummary['latest_run'] ?? []), + 'latest_failed_host' => (array)($arrSupplySummary['latest_failed_host'] ?? []), + 'plan' => $arrPlan, + 'buckets' => $arrBuckets, + 'sequence' => $arrSequence, + 'sequence_count' => count($arrSequence), + 'phase' => $arrPhase, + 'next_action' => $arrNextAction, + 'action_order' => $arrActionOrder, + 'handoff_plan' => $arrHandoffPlan, + 'mainline_plan' => $arrMainlinePlan, + 'mainline_chain' => $arrMainlineChain, + 'mainline_route' => $arrMainlineRoute, + 'projected_mainline_route' => $arrProjectedMainlineRoute, + 'next_mainline_stage' => $arrNextMainlineStage, + 'projected_next_mainline_stage' => $arrProjectedNextMainlineStage, + 'next_business_action' => $arrNextBusinessAction, + 'mainline_sequence_plan' => $arrMainlineSequencePlan, + 'business_sequence' => $arrBusinessSequence, + 'business_takeover_queue' => $arrBusinessTakeoverQueue, + 'business_takeover_queue_count' => count($arrBusinessTakeoverQueue), + 'business_entry_rule' => $arrBusinessEntryRule, + 'business_entry_gate_stack' => $arrBusinessEntryGateStack, + 'business_entry_gate_stack_count' => count($arrBusinessEntryGateStack), + 'business_entry_current_gate' => $arrBusinessEntryCurrentGate, + 'business_entry_blockers' => $arrBusinessEntryBlockers, + 'business_entry_blockers_count' => count($arrBusinessEntryBlockers), + 'business_entry_strategy' => $arrBusinessEntryStrategy, + 'business_action_queue' => $arrBusinessActionQueue, + 'business_action_queue_count' => count($arrBusinessActionQueue), + 'business_action_current' => $arrBusinessActionCurrent, + 'business_action_next' => $arrBusinessActionNext, + 'business_execution_policy' => $arrBusinessExecutionPolicy, + 'business_review_policy' => $arrBusinessReviewPolicy, + 'business_control_tower' => $arrBusinessControlTower, + 'mainline_program' => $arrMainlineProgram, + 'mainline_entry' => $arrMainlineEntry, + 'mainline_workbench' => $arrMainlineWorkbench, + 'mainline_control_tower' => $arrMainlineControlTower, + 'dispatch_plan' => $arrDispatchPlan, + 'dispatch_stage' => $arrDispatchStage, + 'next_dispatch_stage' => $arrNextDispatchStage, + 'dispatch_chain' => $arrDispatchChain, + 'dispatch_chain_count' => count($arrDispatchChain), + 'dispatch_order' => $arrDispatchOrder, + 'dispatch_order_count' => count($arrDispatchOrder), + 'mainline_chain_count' => count($arrMainlineChain), + 'mainline_route_count' => count($arrMainlineRoute), + 'projected_mainline_route_count' => count($arrProjectedMainlineRoute), + 'first_step' => $arrFirstStep, + 'next_actions' => array_values(array_filter([ + trim((string)($arrFirstStep['action'] ?? '')), + trim((string)($arrSecondStep['action'] ?? '')), + trim((string)($arrThirdStep['action'] ?? '')), + ])), + 'lead_hosts' => array_values(array_filter([ + trim((string)($arrFirstStep['lead_host'] ?? '')), + trim((string)($arrSecondStep['lead_host'] ?? '')), + trim((string)($arrThirdStep['lead_host'] ?? '')), + ])), + 'lead_business_host' => $arrLeadBusinessHost, + 'bucket_counts' => array_reduce($arrBuckets, static function (array $arrCarry, array $arrBucket): array { + $arrCarry[(string)($arrBucket['key'] ?? '')] = (int)($arrBucket['count'] ?? 0); + return $arrCarry; + }, []), + 'portal_root' => $strPortalRoot, + 'supply_run_root' => $strSupplyRunRoot, + ]; + }); + } + + protected function resolveRuntimeAcceptanceTargets(string $strHost, int $intVideoId, int $intForgeId, int $intLimit): array + { + $arrTargets = []; + $strSource = ''; + $intConfiguredSamples = 0; + $intEnabledDomains = 0; + $arrDomainSummaries = []; + + if ($strHost !== '' && $intVideoId > 0) { + return [ + 'source' => 'explicit', + 'configured_samples' => 1, + 'enabled_domains' => 1, + 'selected_domains' => 1, + 'total_targets' => 1, + 'selected_targets' => 1, + 'skipped_targets' => 0, + 'domain_summaries' => [[ + 'host' => DomainModel::normalizeHost($strHost), + 'configured_samples' => 1, + 'selected_samples' => 1, + 'skipped_samples' => 0, + ]], + 'targets' => [[ + 'host' => DomainModel::normalizeHost($strHost), + 'video_id' => $intVideoId, + 'forge_id' => max(1, $intForgeId), + ]], + ]; + } + + $Query = DomainModel::order('d_id asc'); + if ($strHost !== '') { + $Query->where('d_domain', DomainModel::normalizeStoredDomain($strHost, DomainModel::MATCH_TYPE_EXACT)); + } + + /** @var array $arrDomains */ + $arrDomains = $Query->select()->all(); + foreach ($arrDomains as $DomainRow) { + if (!$DomainRow instanceof DomainModel || !$DomainRow->isRuntimeAcceptanceEnabled()) { + continue; + } + + $intEnabledDomains++; + $arrSamples = $DomainRow->getRuntimeAcceptanceSamples(); + $intSampleCount = count($arrSamples); + $intConfiguredSamples += $intSampleCount; + $arrDomainSummaries[] = [ + 'host' => (string)($DomainRow->d_domain ?? ''), + 'configured_samples' => $intSampleCount, + 'selected_samples' => 0, + 'skipped_samples' => 0, + ]; + foreach ($arrSamples as $arrSample) { + $arrTargets[] = [ + 'host' => (string)($DomainRow->d_domain ?? ''), + 'video_id' => (int)($arrSample['video_id'] ?? 0), + 'forge_id' => max(1, (int)($arrSample['forge_id'] ?? 1)), + ]; + } + } + + if (!empty($arrTargets)) { + $strSource = $strHost !== '' ? 'domain_cfg_host' : 'domain_cfg_all'; + } + + $arrFilteredTargets = array_values(array_filter($arrTargets, static function (array $arrTarget): bool { + return trim((string)($arrTarget['host'] ?? '')) !== '' && (int)($arrTarget['video_id'] ?? 0) > 0; + })); + $arrSelectedTargets = array_slice($arrFilteredTargets, 0, $intLimit); + $arrSelectedByHost = []; + foreach ($arrSelectedTargets as $arrTarget) { + $strTargetHost = trim((string)($arrTarget['host'] ?? '')); + if ($strTargetHost === '') { + continue; + } + $arrSelectedByHost[$strTargetHost] = ($arrSelectedByHost[$strTargetHost] ?? 0) + 1; + } + foreach ($arrDomainSummaries as &$arrDomainSummary) { + $strTargetHost = trim((string)($arrDomainSummary['host'] ?? '')); + $intSelectedCount = (int)($arrSelectedByHost[$strTargetHost] ?? 0); + $arrDomainSummary['selected_samples'] = $intSelectedCount; + $arrDomainSummary['skipped_samples'] = max(0, (int)($arrDomainSummary['configured_samples'] ?? 0) - $intSelectedCount); + } + unset($arrDomainSummary); + $intSelectedDomains = count(array_filter($arrDomainSummaries, static function (array $arrDomainSummary): bool { + return (int)($arrDomainSummary['selected_samples'] ?? 0) > 0; + })); + + return [ + 'source' => $strSource, + 'configured_samples' => $intConfiguredSamples, + 'enabled_domains' => $intEnabledDomains, + 'selected_domains' => $intSelectedDomains, + 'total_targets' => count($arrFilteredTargets), + 'selected_targets' => count($arrSelectedTargets), + 'skipped_targets' => max(0, count($arrFilteredTargets) - count($arrSelectedTargets)), + 'domain_summaries' => $arrDomainSummaries, + 'targets' => $arrSelectedTargets, + ]; + } + + protected function getApprovedDir(): string + { + return $this->getPathEnv('SEO_COPY_APPROVED_DIR', dirname(__DIR__, 3) . '/data/seo_copy_jobs/approved'); + } + + protected function getBatchRoot(): string + { + return $this->getPathEnv('SEO_COPY_BATCH_STORAGE_ROOT', dirname(__DIR__, 3) . '/storage/seo_copy_batch'); + } + + protected function getPortalRoot(): string + { + return $this->getPathEnv('SEO_COPY_RELEASE_PORTAL_ROOT', dirname(__DIR__, 3) . '/public/_seo_copy_release'); + } + + protected function toPublicRelativePath(string $path): string + { + $path = str_replace('\\', '/', trim($path)); + if ($path === '') { + return ''; + } + + $publicRoot = str_replace('\\', '/', dirname(__DIR__, 3) . '/public/'); + if (str_starts_with($path, $publicRoot)) { + return substr($path, strlen($publicRoot)); + } + + return $path; + } + + protected function getPublishLogDir(): string + { + return $this->getPathEnv('SEO_COPY_PUBLISH_LOG_DIR', dirname(__DIR__, 3) . '/storage/seo_copy_publish_logs'); + } + + protected function getRunsRoot(): string + { + return $this->getPathEnv('SEO_COPY_RELEASE_RUNS_ROOT', dirname(__DIR__, 3) . '/storage/seo_copy_release_runs'); + } + + protected function getTaskRunsRoot(): string + { + return $this->getPathEnv('SEO_COPY_TASK_RUNS_ROOT', dirname(__DIR__, 3) . '/storage/seo_copy_task_runs'); + } + + protected function getTaskStateRoot(): string + { + return $this->getPathEnv('SEO_COPY_TASK_STATE_ROOT', dirname(__DIR__, 3) . '/storage/seo_copy_task_state'); + } + + protected function getBootstrapBundleRoot(): string + { + return $this->getPathEnv('SEO_COPY_BOOTSTRAP_BUNDLE_ROOT', dirname(__DIR__, 3) . '/storage/domain_bootstrap_bundles'); + } + + protected function getBootstrapRunRoot(): string + { + return $this->getPathEnv('SEO_COPY_BOOTSTRAP_RUN_ROOT', dirname(__DIR__, 3) . '/storage/domain_bootstrap_runs'); + } + + protected function writeTaskSummary(string $strTaskSlug, array $arrSummary): string + { + $strDir = $this->getTaskRunsRoot() . '/' . date('Ymd'); + if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) { + throw new \RuntimeException('Failed to create seo_copy task summary directory: ' . $strDir); + } + + $strPath = $strDir . '/' . date('His') . '_' . $strTaskSlug . '.json'; + file_put_contents($strPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL); + + return $strPath; + } + + protected function getPathEnv(string $strKey, string $strDefault): string + { + $strValue = $this->getStringEnv($strKey, ''); + + return $strValue !== '' ? $strValue : $strDefault; + } + + protected function getBoolEnv(string $strKey, bool $boolDefault): bool + { + $strValue = $this->getStringEnv($strKey, ''); + if ($strValue === '') { + return $boolDefault; + } + + return in_array(strtolower($strValue), ['1', 'true', 'yes', 'on'], true); + } + + protected function getIntEnv(string $strKey, int $intDefault): int + { + $strValue = $this->getStringEnv($strKey, ''); + if ($strValue === '' || !is_numeric($strValue)) { + return $intDefault; + } + + return (int)$strValue; + } + + protected function getStringEnv(string $strKey, string $strDefault = ''): string + { + $strValue = trim((string)getenv($strKey)); + if ($strValue !== '') { + return $strValue; + } + + $mixEnvValue = env($strKey, null); + if ($mixEnvValue === null) { + return $strDefault; + } + + return trim((string)$mixEnvValue); + } + + protected function getStringEnvPreferAppEnv(string $strKey, string $strDefault = ''): string + { + $mixEnvValue = env($strKey, null); + if ($mixEnvValue !== null && trim((string)$mixEnvValue) !== '') { + return trim((string)$mixEnvValue); + } + + $strValue = trim((string)getenv($strKey)); + if ($strValue !== '') { + return $strValue; + } + + return $strDefault; + } + + protected function getIntEnvPreferAppEnv(string $strKey, int $intDefault): int + { + $strValue = $this->getStringEnvPreferAppEnv($strKey, ''); + if ($strValue === '' || !is_numeric($strValue)) { + return $intDefault; + } + + return (int)$strValue; + } + + protected function getStringEnvPreferEnvFile(string $strKey, string $strDefault = ''): string + { + $strValue = $this->getEnvFileValue($strKey, ''); + if ($strValue !== '') { + return $strValue; + } + + return $this->getStringEnvPreferAppEnv($strKey, $strDefault); + } + + protected function getIntEnvPreferEnvFile(string $strKey, int $intDefault): int + { + $strValue = $this->getStringEnvPreferEnvFile($strKey, ''); + if ($strValue === '' || !is_numeric($strValue)) { + return $intDefault; + } + + return (int)$strValue; + } + + protected function getEnvFileValue(string $strKey, string $strDefault = ''): string + { + static $arrEnvCache = null; + if (!is_array($arrEnvCache)) { + $arrEnvCache = []; + $strEnvFile = dirname(__DIR__, 3) . '/.env'; + if (is_file($strEnvFile)) { + foreach ((array)(file($strEnvFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []) as $strLine) { + $strLine = trim((string)$strLine); + if ($strLine === '' || str_starts_with($strLine, '#') || !str_contains($strLine, '=')) { + continue; + } + + [$strEnvKey, $strEnvValue] = array_map('trim', explode('=', $strLine, 2)); + if ($strEnvKey === '') { + continue; + } + + $arrEnvCache[$strEnvKey] = trim($strEnvValue, " \t\n\r\0\x0B\"'"); + } + } + } + + return trim((string)($arrEnvCache[$strKey] ?? $strDefault)); + } + + protected function runLockedTask(string $strTaskSlug, callable $callable): array + { + $strLockDir = dirname(__DIR__, 3) . '/runtime/seo_copy_task_locks'; + if (!is_dir($strLockDir) && !mkdir($strLockDir, 0777, true) && !is_dir($strLockDir)) { + throw new \RuntimeException('Failed to create seo_copy task lock directory: ' . $strLockDir); + } + + $strLockPath = $strLockDir . '/' . $strTaskSlug . '.lock'; + $strStateRoot = $this->getTaskStateRoot(); + $Handle = fopen($strLockPath, 'c+'); + if ($Handle === false) { + throw new \RuntimeException('Failed to open seo_copy task lock file: ' . $strLockPath); + } + + if (!flock($Handle, LOCK_EX | LOCK_NB)) { + fclose($Handle); + $arrSummary = [ + 'generated_at' => date('c'), + 'status' => 'skipped_locked', + 'task_slug' => $strTaskSlug, + 'lock_path' => $strLockPath, + ]; + $arrSummary['summary_path'] = $this->writeTaskSummary($strTaskSlug, $arrSummary); + $arrSummary['state_path'] = SeoCopyTaskStateHelper::writeState($strStateRoot, $strTaskSlug, [ + 'status' => 'skipped_locked', + 'lock_path' => $strLockPath, + 'summary_path' => $arrSummary['summary_path'], + ]); + $this->refreshTaskHealthPortalArtifacts(); + echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + + return $arrSummary; + } + + try { + SeoCopyTaskStateHelper::writeState($strStateRoot, $strTaskSlug, [ + 'status' => 'running', + 'started_at' => date('c'), + 'lock_path' => $strLockPath, + ]); + $arrSummary = (array)$callable(); + $arrSummary['generated_at'] = (string)($arrSummary['generated_at'] ?? date('c')); + $arrSummary['status'] = (string)($arrSummary['status'] ?? 'success'); + $arrSummary['task_slug'] = $strTaskSlug; + $arrSummary['summary_path'] = $this->writeTaskSummary($strTaskSlug, $arrSummary); + $arrSummary['state_path'] = SeoCopyTaskStateHelper::writeState($strStateRoot, $strTaskSlug, [ + 'status' => $arrSummary['status'], + 'finished_at' => date('c'), + 'summary_path' => $arrSummary['summary_path'], + 'lock_path' => $strLockPath, + ]); + $this->refreshTaskHealthPortalArtifacts(); + echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + + return $arrSummary; + } catch (\Throwable $e) { + $arrSummary = [ + 'generated_at' => date('c'), + 'status' => 'error', + 'task_slug' => $strTaskSlug, + 'error' => $e->getMessage(), + ]; + $arrSummary['summary_path'] = $this->writeTaskSummary($strTaskSlug, $arrSummary); + $arrSummary['state_path'] = SeoCopyTaskStateHelper::writeState($strStateRoot, $strTaskSlug, [ + 'status' => 'error', + 'finished_at' => date('c'), + 'summary_path' => $arrSummary['summary_path'], + 'lock_path' => $strLockPath, + 'error' => $e->getMessage(), + ]); + $this->refreshTaskHealthPortalArtifacts(); + echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + + throw $e; + } finally { + flock($Handle, LOCK_UN); + fclose($Handle); + } + } + + protected function refreshTaskHealthPortalArtifacts(): void + { + try { + $strPortalRoot = $this->getPortalRoot(); + $strTaskRunsRoot = $this->getTaskRunsRoot(); + $strTaskStateRoot = $this->getTaskStateRoot(); + + $arrTaskSummary = SeoCopyTaskRunPortalHelper::buildSummary($strTaskRunsRoot, true, $strTaskStateRoot); + SeoCopyTaskRunPortalHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/tasks', $arrTaskSummary); + + if (is_dir($strPortalRoot)) { + $arrPortalSummary = SeoCopyPortalHomeHelper::buildSummary($strPortalRoot); + SeoCopyPortalHomeHelper::writeArtifacts($strPortalRoot, $arrPortalSummary); + } + } catch (\Throwable $e) { + // Keep task execution resilient even if health portal refresh fails. + } + } +} diff --git a/code/scripts/_bootstrap_cli_app.php b/code/scripts/_bootstrap_cli_app.php new file mode 100644 index 0000000..e0abcec --- /dev/null +++ b/code/scripts/_bootstrap_cli_app.php @@ -0,0 +1,12 @@ +initialize(); + +return $app; diff --git a/code/scripts/domain_bootstrap_batch_template.php b/code/scripts/domain_bootstrap_batch_template.php new file mode 100644 index 0000000..9ae789a --- /dev/null +++ b/code/scripts/domain_bootstrap_batch_template.php @@ -0,0 +1,38 @@ +getMessage() . PHP_EOL); + printUsage(); + exit(1); +} + +echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/domain_bootstrap_bundle_index.php b/code/scripts/domain_bootstrap_bundle_index.php new file mode 100644 index 0000000..0697a74 --- /dev/null +++ b/code/scripts/domain_bootstrap_bundle_index.php @@ -0,0 +1,79 @@ +getMessage() . PHP_EOL); + printUsage(); + exit(1); +} + +echo json_encode([ + 'scan_root' => $scanRoot, + 'output_root' => $outputRoot, + 'bundles_count' => (int)($summary['bundles_count'] ?? 0), + 'json' => $artifacts['json'] ?? '', + 'html' => $artifacts['html'] ?? '', + 'portal_root' => $portalRoot, + 'portal_json' => $portalArtifacts['json'] ?? '', + 'portal_html' => $portalArtifacts['html'] ?? '', +], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/domain_bootstrap_env_apply.php b/code/scripts/domain_bootstrap_env_apply.php new file mode 100644 index 0000000..c51a694 --- /dev/null +++ b/code/scripts/domain_bootstrap_env_apply.php @@ -0,0 +1,94 @@ + 'baseline', + 'env_file' => dirname(__DIR__) . '/.env', + 'dry_run' => true, + 'format' => 'json', + 'code_root' => dirname(__DIR__), + 'source' => 'cli', + 'record_latest' => true, +]; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--template=')) { + $arrOptions['template'] = trim(substr($strArg, strlen('--template='))); + continue; + } + if (str_starts_with($strArg, '--env-file=')) { + $arrOptions['env_file'] = trim(substr($strArg, strlen('--env-file='))); + continue; + } + if (str_starts_with($strArg, '--dry-run=')) { + $arrOptions['dry_run'] = domainBootstrapEnvApplyParseBool(substr($strArg, strlen('--dry-run='))); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $arrOptions['format'] = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } +} + +try { + $arrSummary = DomainBootstrapEnvTemplateHelper::apply($arrOptions); + + if (($arrOptions['format'] ?? 'json') === 'text') { + echo 'status=' . (string)($arrSummary['status'] ?? '') . PHP_EOL; + echo 'template=' . (string)($arrSummary['template'] ?? '') . PHP_EOL; + echo 'template_path=' . (string)($arrSummary['template_path'] ?? '') . PHP_EOL; + echo 'env_file=' . (string)($arrSummary['env_file'] ?? '') . PHP_EOL; + echo 'parsed_assignment_count=' . (int)($arrSummary['parsed_assignment_count'] ?? 0) . PHP_EOL; + echo 'ignored_assignment_count=' . (int)($arrSummary['ignored_assignment_count'] ?? 0) . PHP_EOL; + echo 'changed_count=' . (int)($arrSummary['changed_count'] ?? 0) . PHP_EOL; + echo 'added_count=' . (int)($arrSummary['added_count'] ?? 0) . PHP_EOL; + echo 'updated_count=' . (int)($arrSummary['updated_count'] ?? 0) . PHP_EOL; + + foreach ((array)($arrSummary['changes'] ?? []) as $intIndex => $arrChange) { + $strPrefix = 'change' . ($intIndex + 1); + echo $strPrefix . '_key=' . (string)($arrChange['key'] ?? '') . PHP_EOL; + echo $strPrefix . '_type=' . (string)($arrChange['type'] ?? '') . PHP_EOL; + echo $strPrefix . '_old=' . (string)($arrChange['old'] ?? '') . PHP_EOL; + echo $strPrefix . '_new=' . (string)($arrChange['new'] ?? '') . PHP_EOL; + } + + exit(0); + } + + echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} catch (Throwable $throwable) { + if (($arrOptions['format'] ?? 'json') === 'text') { + fwrite(STDERR, 'error=' . $throwable->getMessage() . PHP_EOL); + domainBootstrapEnvApplyUsage(); + exit(1); + } + + echo json_encode([ + 'status' => 'error', + 'message' => $throwable->getMessage(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + exit(1); +} diff --git a/code/scripts/domain_bootstrap_import_ai.php b/code/scripts/domain_bootstrap_import_ai.php new file mode 100644 index 0000000..9e83859 --- /dev/null +++ b/code/scripts/domain_bootstrap_import_ai.php @@ -0,0 +1,99 @@ + [--format=json|text] [--index-root=/abs/path] [--portal-root=/abs/path|off]\n\n"; + echo "Example:\n"; + echo " php scripts/domain_bootstrap_import_ai.php /tmp/domain-bootstrap/chuanjiafeng-compact /tmp/ai-results --format=text\n"; +} + +function domainBootstrapImportAiRenderText(array $arrSummary): string +{ + $arrLines = [ + 'status=' . (string)($arrSummary['status'] ?? ''), + 'bundle_root=' . (string)($arrSummary['bundle_root'] ?? ''), + 'source_dir=' . (string)($arrSummary['source_dir'] ?? ''), + 'target_root=' . (string)($arrSummary['target_root'] ?? ''), + 'imported_count=' . (int)($arrSummary['imported_count'] ?? 0), + 'skipped_count=' . (int)($arrSummary['skipped_count'] ?? 0), + 'message=' . (string)($arrSummary['message'] ?? ''), + ]; + + foreach ((array)($arrSummary['bundle_artifacts'] ?? []) as $strKey => $mValue) { + if (is_array($mValue)) { + continue; + } + + $arrLines[] = 'artifact_' . $strKey . '=' . (string)$mValue; + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (count($arrArgs) < 2) { + domainBootstrapImportAiPrintUsage(); + exit(1); +} + +$strInput = ''; +$strSourceDir = ''; +$strFormat = 'json'; +$strIndexRoot = ''; +$strPortalRoot = ''; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + if (str_starts_with($strArg, '--index-root=')) { + $strIndexRoot = trim(substr($strArg, strlen('--index-root='))); + continue; + } + if (str_starts_with($strArg, '--portal-root=')) { + $strPortalRoot = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + + if ($strInput === '') { + $strInput = trim($strArg); + continue; + } + + if ($strSourceDir === '') { + $strSourceDir = trim($strArg); + } +} + +if ($strInput === '' || $strSourceDir === '') { + domainBootstrapImportAiPrintUsage(); + exit(1); +} + +try { + $arrSummary = DomainBootstrapImportHelper::runBundleImport($strInput, $strSourceDir, [ + 'index_root' => $strIndexRoot, + 'portal_root' => $strPortalRoot, + ]); +} catch (Throwable $throwable) { + fwrite(STDERR, $throwable->getMessage() . PHP_EOL); + exit(1); +} + +if ($strFormat === 'text') { + echo domainBootstrapImportAiRenderText($arrSummary); + exit(0); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/domain_bootstrap_oncall_run.php b/code/scripts/domain_bootstrap_oncall_run.php new file mode 100644 index 0000000..703c612 --- /dev/null +++ b/code/scripts/domain_bootstrap_oncall_run.php @@ -0,0 +1,196 @@ + '', + 'owner' => '', + 'priority' => '', + 'limit' => 1, + 'dry_run' => true, + 'allow_attention' => false, + 'allow_prepare' => false, + 'allow_release_dry_run' => false, + 'allow_publish' => false, + 'bundle_root' => dirname(__DIR__) . '/storage/domain_bootstrap_bundles', + 'run_root' => dirname(__DIR__) . '/storage/domain_bootstrap_runs', + 'portal_root' => dirname(__DIR__) . '/public/_seo_copy_release', + 'format' => 'json', +]; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--host=')) { + $arrOptions['host'] = trim(substr($strArg, strlen('--host='))); + continue; + } + if (str_starts_with($strArg, '--owner=')) { + $arrOptions['owner'] = trim(substr($strArg, strlen('--owner='))); + continue; + } + if (str_starts_with($strArg, '--priority=')) { + $arrOptions['priority'] = strtoupper(trim(substr($strArg, strlen('--priority=')))); + continue; + } + if (str_starts_with($strArg, '--limit=')) { + $arrOptions['limit'] = max(1, (int)trim(substr($strArg, strlen('--limit=')))); + continue; + } + if (str_starts_with($strArg, '--dry-run=')) { + $arrOptions['dry_run'] = domainBootstrapOncallParseBool(substr($strArg, strlen('--dry-run='))); + continue; + } + if (str_starts_with($strArg, '--allow-attention=')) { + $arrOptions['allow_attention'] = domainBootstrapOncallParseBool(substr($strArg, strlen('--allow-attention='))); + continue; + } + if (str_starts_with($strArg, '--allow-prepare=')) { + $arrOptions['allow_prepare'] = domainBootstrapOncallParseBool(substr($strArg, strlen('--allow-prepare='))); + continue; + } + if (str_starts_with($strArg, '--allow-release-dry-run=')) { + $arrOptions['allow_release_dry_run'] = domainBootstrapOncallParseBool(substr($strArg, strlen('--allow-release-dry-run='))); + continue; + } + if (str_starts_with($strArg, '--allow-publish=')) { + $arrOptions['allow_publish'] = domainBootstrapOncallParseBool(substr($strArg, strlen('--allow-publish='))); + continue; + } + if (str_starts_with($strArg, '--bundle-root=')) { + $arrOptions['bundle_root'] = rtrim(trim(substr($strArg, strlen('--bundle-root='))), '/'); + continue; + } + if (str_starts_with($strArg, '--run-root=')) { + $arrOptions['run_root'] = rtrim(trim(substr($strArg, strlen('--run-root='))), '/'); + continue; + } + if (str_starts_with($strArg, '--portal-root=')) { + $arrOptions['portal_root'] = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $arrOptions['format'] = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } +} + +try { + (new App())->initialize(); + + $arrDispatch = DomainBootstrapOncallService::dispatch($arrOptions); + if ((int)($arrDispatch['processed_count'] ?? 0) <= 0) { + throw new RuntimeException('No matching oncall entry found.'); + } + $arrExecutions = array_values(array_filter((array)($arrDispatch['executions'] ?? []), 'is_array')); + + if (($arrOptions['format'] ?? 'json') === 'text') { + echo 'processed_count=' . (int)($arrDispatch['processed_count'] ?? 0) . PHP_EOL; + echo 'open_count_before=' . (int)($arrDispatch['open_count_before'] ?? 0) . PHP_EOL; + echo 'open_count_after=' . (int)($arrDispatch['open_count_after'] ?? 0) . PHP_EOL; + echo 'latest_host_after=' . (string)($arrDispatch['latest_host_after'] ?? '') . PHP_EOL; + echo 'latest_todo_after=' . (string)($arrDispatch['latest_todo_after'] ?? '') . PHP_EOL; + + foreach ($arrExecutions as $intIndex => $arrExecution) { + $strPrefix = 'step' . ($intIndex + 1); + echo $strPrefix . '_host=' . (string)($arrExecution['host'] ?? '') . PHP_EOL; + echo $strPrefix . '_priority=' . (string)($arrExecution['priority_label'] ?? '') . PHP_EOL; + echo $strPrefix . '_owner=' . (string)($arrExecution['owner_label'] ?? '') . PHP_EOL; + echo $strPrefix . '_todo=' . (string)($arrExecution['todo_title'] ?? '') . PHP_EOL; + echo $strPrefix . '_status=' . (string)($arrExecution['execution_status'] ?? 'unknown') . PHP_EOL; + if (($arrExecution['run_json'] ?? '') !== '') { + echo $strPrefix . '_run_json=' . (string)($arrExecution['run_json'] ?? '') . PHP_EOL; + } + if (($arrExecution['prepare_out_dir'] ?? '') !== '') { + echo $strPrefix . '_prepare_out_dir=' . (string)($arrExecution['prepare_out_dir'] ?? '') . PHP_EOL; + } + if (($arrExecution['release_run_status'] ?? '') !== '') { + echo $strPrefix . '_release_run_status=' . (string)($arrExecution['release_run_status'] ?? '') . PHP_EOL; + } + if (($arrExecution['release_run_dir'] ?? '') !== '') { + echo $strPrefix . '_release_run_dir=' . (string)($arrExecution['release_run_dir'] ?? '') . PHP_EOL; + } + if (($arrExecution['release_run_summary_path'] ?? '') !== '') { + echo $strPrefix . '_release_run_summary_path=' . (string)($arrExecution['release_run_summary_path'] ?? '') . PHP_EOL; + } + if (($arrExecution['publish_run_status'] ?? '') !== '') { + echo $strPrefix . '_publish_run_status=' . (string)($arrExecution['publish_run_status'] ?? '') . PHP_EOL; + } + if (($arrExecution['publish_run_dir'] ?? '') !== '') { + echo $strPrefix . '_publish_run_dir=' . (string)($arrExecution['publish_run_dir'] ?? '') . PHP_EOL; + } + if (($arrExecution['publish_run_summary_path'] ?? '') !== '') { + echo $strPrefix . '_publish_run_summary_path=' . (string)($arrExecution['publish_run_summary_path'] ?? '') . PHP_EOL; + } + if (($arrExecution['message'] ?? '') !== '') { + echo $strPrefix . '_message=' . (string)($arrExecution['message'] ?? '') . PHP_EOL; + } + if (($arrExecution['recommended_command'] ?? '') !== '') { + echo $strPrefix . '_recommended_command=' . (string)($arrExecution['recommended_command'] ?? '') . PHP_EOL; + } + } + $boolBlockedOnly = !empty($arrExecutions) && count(array_filter($arrExecutions, static function (array $arrExecution): bool { + return in_array((string)($arrExecution['execution_status'] ?? ''), ['blocked_attention', 'blocked_prepare', 'blocked_publish', 'unsupported_stage'], true); + })) === count($arrExecutions); + + exit($boolBlockedOnly ? 2 : 0); + } + + echo json_encode([ + 'selected_host' => (string)($arrDispatch['selected_host'] ?? ''), + 'selected_priority' => (string)($arrDispatch['selected_priority'] ?? ''), + 'selected_owner' => (string)($arrDispatch['selected_owner'] ?? ''), + 'selected_todo' => (string)($arrDispatch['selected_todo'] ?? ''), + 'result' => $arrDispatch, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} catch (Throwable $throwable) { + if (($arrOptions['format'] ?? 'json') === 'text') { + fwrite(STDERR, 'error=' . $throwable->getMessage() . PHP_EOL); + domainBootstrapOncallPrintUsage(); + exit(1); + } + + echo json_encode([ + 'status' => 'error', + 'message' => $throwable->getMessage(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + exit(1); +} diff --git a/code/scripts/domain_bootstrap_pipeline_run.php b/code/scripts/domain_bootstrap_pipeline_run.php new file mode 100644 index 0000000..7317243 --- /dev/null +++ b/code/scripts/domain_bootstrap_pipeline_run.php @@ -0,0 +1,190 @@ + '', + 'owner' => '', + 'priority' => '', + 'step_limit' => 6, + 'dry_run' => true, + 'allow_attention' => false, + 'allow_prepare' => true, + 'allow_import' => false, + 'ai_result_dir' => '', + 'ai_result_root' => '', + 'allow_release_dry_run' => false, + 'allow_publish' => false, + 'bundle_root' => dirname(__DIR__) . '/storage/domain_bootstrap_bundles', + 'run_root' => dirname(__DIR__) . '/storage/domain_bootstrap_runs', + 'portal_root' => dirname(__DIR__) . '/public/_seo_copy_release', + 'pipeline_run_root' => dirname(__DIR__) . '/storage/domain_bootstrap_pipeline_runs', + 'format' => 'json', +]; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--host=')) { + $arrOptions['host'] = trim(substr($strArg, strlen('--host='))); + continue; + } + if (str_starts_with($strArg, '--owner=')) { + $arrOptions['owner'] = trim(substr($strArg, strlen('--owner='))); + continue; + } + if (str_starts_with($strArg, '--priority=')) { + $arrOptions['priority'] = strtoupper(trim(substr($strArg, strlen('--priority=')))); + continue; + } + if (str_starts_with($strArg, '--step-limit=')) { + $arrOptions['step_limit'] = max(1, (int)trim(substr($strArg, strlen('--step-limit=')))); + continue; + } + if (str_starts_with($strArg, '--dry-run=')) { + $arrOptions['dry_run'] = domainBootstrapPipelineParseBool(substr($strArg, strlen('--dry-run='))); + continue; + } + if (str_starts_with($strArg, '--allow-attention=')) { + $arrOptions['allow_attention'] = domainBootstrapPipelineParseBool(substr($strArg, strlen('--allow-attention='))); + continue; + } + if (str_starts_with($strArg, '--allow-prepare=')) { + $arrOptions['allow_prepare'] = domainBootstrapPipelineParseBool(substr($strArg, strlen('--allow-prepare='))); + continue; + } + if (str_starts_with($strArg, '--allow-import=')) { + $arrOptions['allow_import'] = domainBootstrapPipelineParseBool(substr($strArg, strlen('--allow-import='))); + continue; + } + if (str_starts_with($strArg, '--ai-result-dir=')) { + $arrOptions['ai_result_dir'] = rtrim(trim(substr($strArg, strlen('--ai-result-dir='))), '/'); + continue; + } + if (str_starts_with($strArg, '--ai-result-root=')) { + $arrOptions['ai_result_root'] = rtrim(trim(substr($strArg, strlen('--ai-result-root='))), '/'); + continue; + } + if (str_starts_with($strArg, '--allow-release-dry-run=')) { + $arrOptions['allow_release_dry_run'] = domainBootstrapPipelineParseBool(substr($strArg, strlen('--allow-release-dry-run='))); + continue; + } + if (str_starts_with($strArg, '--allow-publish=')) { + $arrOptions['allow_publish'] = domainBootstrapPipelineParseBool(substr($strArg, strlen('--allow-publish='))); + continue; + } + if (str_starts_with($strArg, '--bundle-root=')) { + $arrOptions['bundle_root'] = rtrim(trim(substr($strArg, strlen('--bundle-root='))), '/'); + continue; + } + if (str_starts_with($strArg, '--run-root=')) { + $arrOptions['run_root'] = rtrim(trim(substr($strArg, strlen('--run-root='))), '/'); + continue; + } + if (str_starts_with($strArg, '--portal-root=')) { + $arrOptions['portal_root'] = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + if (str_starts_with($strArg, '--pipeline-run-root=')) { + $arrOptions['pipeline_run_root'] = rtrim(trim(substr($strArg, strlen('--pipeline-run-root='))), '/'); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $arrOptions['format'] = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } +} + +try { + (new App())->initialize(); + $arrSummary = DomainBootstrapPipelineHelper::run($arrOptions); + + if (($arrOptions['format'] ?? 'json') === 'text') { + echo 'status=' . (string)($arrSummary['status'] ?? '') . PHP_EOL; + echo 'stop_reason=' . (string)($arrSummary['stop_reason'] ?? '') . PHP_EOL; + echo 'processed_count=' . (int)($arrSummary['processed_count'] ?? 0) . PHP_EOL; + echo 'open_count_before=' . (int)($arrSummary['open_count_before'] ?? 0) . PHP_EOL; + echo 'open_count_after=' . (int)($arrSummary['open_count_after'] ?? 0) . PHP_EOL; + echo 'latest_host_after=' . (string)($arrSummary['latest_host_after'] ?? '') . PHP_EOL; + echo 'latest_todo_after=' . (string)($arrSummary['latest_todo_after'] ?? '') . PHP_EOL; + echo 'latest_completed_host=' . (string)($arrSummary['latest_completed_host'] ?? '') . PHP_EOL; + echo 'pipeline_json=' . (string)(($arrSummary['artifacts'] ?? [])['json'] ?? '') . PHP_EOL; + + foreach ((array)($arrSummary['steps'] ?? []) as $intIndex => $arrStep) { + $strPrefix = 'step' . ($intIndex + 1); + echo $strPrefix . '_host=' . (string)($arrStep['host'] ?? '') . PHP_EOL; + echo $strPrefix . '_stage=' . (string)($arrStep['handoff_stage_label'] ?? '') . PHP_EOL; + echo $strPrefix . '_todo=' . (string)($arrStep['todo_title'] ?? '') . PHP_EOL; + echo $strPrefix . '_status=' . (string)($arrStep['execution_status'] ?? '') . PHP_EOL; + if (($arrStep['import_status'] ?? '') !== '') { + echo $strPrefix . '_import_status=' . (string)($arrStep['import_status'] ?? '') . PHP_EOL; + } + if (($arrStep['import_source_dir'] ?? '') !== '') { + echo $strPrefix . '_import_source_dir=' . (string)($arrStep['import_source_dir'] ?? '') . PHP_EOL; + } + if (($arrStep['message'] ?? '') !== '') { + echo $strPrefix . '_message=' . (string)($arrStep['message'] ?? '') . PHP_EOL; + } + if (($arrStep['recommended_command'] ?? '') !== '') { + echo $strPrefix . '_recommended_command=' . (string)($arrStep['recommended_command'] ?? '') . PHP_EOL; + } + } + exit(((int)($arrSummary['processed_count'] ?? 0)) > 0 ? 0 : 2); + } + + echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} catch (Throwable $throwable) { + if (($arrOptions['format'] ?? 'json') === 'text') { + fwrite(STDERR, 'error=' . $throwable->getMessage() . PHP_EOL); + domainBootstrapPipelinePrintUsage(); + exit(1); + } + + echo json_encode([ + 'status' => 'error', + 'message' => $throwable->getMessage(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + exit(1); +} diff --git a/code/scripts/domain_bootstrap_publish.php b/code/scripts/domain_bootstrap_publish.php new file mode 100644 index 0000000..aae1256 --- /dev/null +++ b/code/scripts/domain_bootstrap_publish.php @@ -0,0 +1,90 @@ + [--target-root=/abs/path] [--index-root=/abs/path] [--portal-root=/abs/path|off] [--format=json|text]\n\n"; + echo "Example:\n"; + echo " php scripts/domain_bootstrap_publish.php /tmp/domain-bootstrap/chuanjiafeng-compact --format=text\n"; +} + +function domainBootstrapPublishRenderText(array $arrSummary): string +{ + $arrLines = [ + 'status=' . (string)($arrSummary['status'] ?? ''), + 'bundle_root=' . (string)($arrSummary['bundle_root'] ?? ''), + 'published_count=' . (int)($arrSummary['published_count'] ?? 0), + 'run_dir=' . (string)($arrSummary['run_dir'] ?? ''), + 'run_summary_path=' . (string)($arrSummary['run_summary_path'] ?? ''), + 'target_root=' . (string)($arrSummary['target_root'] ?? ''), + 'message=' . (string)($arrSummary['message'] ?? ''), + ]; + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (empty($arrArgs)) { + domainBootstrapPublishPrintUsage(); + exit(1); +} + +$strInput = ''; +$strFormat = 'json'; +$strTargetRoot = ''; +$strIndexRoot = ''; +$strPortalRoot = ''; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + if (str_starts_with($strArg, '--target-root=')) { + $strTargetRoot = trim(substr($strArg, strlen('--target-root='))); + continue; + } + if (str_starts_with($strArg, '--index-root=')) { + $strIndexRoot = trim(substr($strArg, strlen('--index-root='))); + continue; + } + if (str_starts_with($strArg, '--portal-root=')) { + $strPortalRoot = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + if ($strInput === '') { + $strInput = trim($strArg); + } +} + +if ($strInput === '') { + domainBootstrapPublishPrintUsage(); + exit(1); +} + +try { + $arrSummary = DomainBootstrapPublishHelper::runBundlePublish($strInput, [ + 'target_root' => $strTargetRoot, + 'index_root' => $strIndexRoot, + 'portal_root' => $strPortalRoot, + ]); +} catch (Throwable $throwable) { + fwrite(STDERR, $throwable->getMessage() . PHP_EOL); + exit(1); +} + +if ($strFormat === 'text') { + echo domainBootstrapPublishRenderText($arrSummary); + exit(0); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/domain_bootstrap_register.php b/code/scripts/domain_bootstrap_register.php new file mode 100644 index 0000000..a882caa --- /dev/null +++ b/code/scripts/domain_bootstrap_register.php @@ -0,0 +1,116 @@ + [--dry-run=1] [--format=json|text] [--index-root=/abs/path] [--portal-root=/abs/path|off] [--write-result=1]\n\n"; + echo "Example:\n"; + echo " php scripts/domain_bootstrap_register.php /tmp/seo-domain-bootstrap/site-register.sample.json --dry-run=1\n"; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +$strRegisterPath = ''; +$boolDryRun = false; +$strFormat = 'json'; +$strIndexRoot = ''; +$strPortalRoot = ''; +$boolWriteResult = true; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--dry-run=')) { + $boolDryRun = in_array(strtolower(trim(substr($strArg, strlen('--dry-run=')))), ['1', 'true', 'yes', 'on'], true); + continue; + } + + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + if (str_starts_with($strArg, '--index-root=')) { + $strIndexRoot = trim(substr($strArg, strlen('--index-root='))); + continue; + } + if (str_starts_with($strArg, '--portal-root=')) { + $strPortalRoot = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + if (str_starts_with($strArg, '--write-result=')) { + $boolWriteResult = in_array(strtolower(trim(substr($strArg, strlen('--write-result=')))), ['1', 'true', 'yes', 'on'], true); + continue; + } + + if ($strRegisterPath === '') { + $strRegisterPath = trim($strArg); + } +} + +if ($strRegisterPath === '' || !is_file($strRegisterPath)) { + fwrite(STDERR, "Register file not found: {$strRegisterPath}\n"); + printUsage(); + exit(1); +} + +try { + (new App())->initialize(); + $arrPayload = DomainBootstrapRegisterHelper::loadRegisterPayload($strRegisterPath); + $arrSummary = $boolDryRun + ? DomainBootstrapRegisterHelper::inspectPayload($arrPayload) + : DomainBootstrapRegisterHelper::createDomain($arrPayload); +} catch (Throwable $throwable) { + fwrite(STDERR, $throwable->getMessage() . PHP_EOL); + exit(1); +} + +$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'] ?? [], +]; + +if ($boolWriteResult) { + $strBundleRoot = DomainBootstrapExecutionHelper::inferBundleRoot($strRegisterPath); + if ($strBundleRoot !== '') { + $arrResult = $arrOutput + ['executed_at' => date(DATE_ATOM)]; + $arrOutput['bundle_artifacts'] = DomainBootstrapExecutionHelper::persistResult( + $strBundleRoot, + 'register', + $arrResult, + $strIndexRoot !== '' ? $strIndexRoot : null, + $strPortalRoot !== '' ? $strPortalRoot : null + ); + } +} + +if ($strFormat === 'text') { + foreach ($arrOutput as $strKey => $mValue) { + if (is_array($mValue)) { + echo $strKey . ': ' . json_encode($mValue, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + continue; + } + + echo $strKey . ': ' . (is_bool($mValue) ? ($mValue ? 'true' : 'false') : $mValue) . PHP_EOL; + } + exit(0); +} + +echo json_encode($arrOutput, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/domain_bootstrap_run.php b/code/scripts/domain_bootstrap_run.php new file mode 100644 index 0000000..45a1760 --- /dev/null +++ b/code/scripts/domain_bootstrap_run.php @@ -0,0 +1,262 @@ + [--dry-run=1] [--register=1] [--apply=1] [--run-root=/abs/path] [--index-root=/abs/path] [--portal-root=/abs/path|off] [--format=json|text]\n\n"; + echo "Example:\n"; + echo " php scripts/domain_bootstrap_run.php /tmp/domain-bootstrap-bundles/chuanjiafeng-compact --dry-run=1 --portal-root=public/_seo_copy_release --format=text\n"; +} + +function parseBool(string $strValue): bool +{ + return in_array(strtolower(trim($strValue)), ['1', 'true', 'yes', 'on'], true); +} + +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; +} + +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; +} + +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'; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +$strInput = ''; +$boolDryRun = true; +$boolRunRegister = true; +$boolRunApply = true; +$strRunRoot = dirname(__DIR__) . '/storage/domain_bootstrap_runs'; +$strIndexRoot = ''; +$strPortalRoot = ''; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--dry-run=')) { + $boolDryRun = parseBool(substr($strArg, strlen('--dry-run='))); + continue; + } + if (str_starts_with($strArg, '--register=')) { + $boolRunRegister = parseBool(substr($strArg, strlen('--register='))); + continue; + } + if (str_starts_with($strArg, '--apply=')) { + $boolRunApply = parseBool(substr($strArg, strlen('--apply='))); + continue; + } + if (str_starts_with($strArg, '--run-root=')) { + $strRunRoot = rtrim(trim(substr($strArg, strlen('--run-root='))), '/'); + continue; + } + if (str_starts_with($strArg, '--index-root=')) { + $strIndexRoot = rtrim(trim(substr($strArg, strlen('--index-root='))), '/'); + continue; + } + if (str_starts_with($strArg, '--portal-root=')) { + $strPortalRoot = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ($strInput === '') { + $strInput = trim($strArg); + } +} + +if ($strInput === '' || (!$boolRunRegister && !$boolRunApply)) { + printUsage(); + exit(1); +} + +$strBundleRoot = DomainBootstrapRunHelper::resolveBundleRoot($strInput); +if ($strBundleRoot === '') { + fwrite(STDERR, "Bundle root not found from input: {$strInput}\n"); + printUsage(); + exit(1); +} + +$arrBundleSummary = DomainBootstrapRunHelper::loadBundleSummary($strBundleRoot); +if (empty($arrBundleSummary)) { + fwrite(STDERR, "bootstrap-summary.json not found in bundle: {$strBundleRoot}\n"); + exit(1); +} + +$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 { + (new App())->initialize(); + + if ($boolRunRegister) { + $arrRegisterResult = runRegisterStep( + $arrPaths['register_sample'], + $boolDryRun, + $strIndexRoot !== '' ? $strIndexRoot : null, + $strPortalRoot !== '' ? $strPortalRoot : null + ); + } + + if ($boolRunApply) { + $arrApplyResult = 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' + : inferOverallStatus($arrRegisterResult, $arrApplyResult, $boolDryRun); + +$arrRunArtifacts = DomainBootstrapRunHelper::writeRunSummary($strRunRoot, $strHost, $arrRunSummary); +$arrRunSummary['run_artifacts'] = $arrRunArtifacts; +DomainBootstrapRunHelper::persistBundleRunMeta($strBundleRoot, $arrRunArtifacts, $arrRunSummary); + +if ($strFormat === 'text') { + echo 'overall_status=' . $arrRunSummary['overall_status'] . PHP_EOL; + echo 'host=' . $strHost . PHP_EOL; + echo 'bundle_root=' . $strBundleRoot . PHP_EOL; + echo 'dry_run=' . ($boolDryRun ? 'yes' : 'no') . PHP_EOL; + echo 'register_status=' . (string)($arrRegisterResult['status'] ?? '') . PHP_EOL; + echo 'apply_status=' . (string)($arrApplyResult['status'] ?? '') . PHP_EOL; + echo 'run_json=' . (string)($arrRunArtifacts['json'] ?? '') . PHP_EOL; + if (!empty($arrErrors)) { + foreach ($arrErrors as $strError) { + echo 'error=' . $strError . PHP_EOL; + } + } + exit(!empty($arrErrors) ? 1 : 0); +} + +echo json_encode($arrRunSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/domain_bootstrap_run_index.php b/code/scripts/domain_bootstrap_run_index.php new file mode 100644 index 0000000..7c237b4 --- /dev/null +++ b/code/scripts/domain_bootstrap_run_index.php @@ -0,0 +1,79 @@ +getMessage() . PHP_EOL); + printUsage(); + exit(1); +} + +echo json_encode([ + 'scan_root' => $strScanRoot, + 'output_root' => $strOutputRoot, + 'runs_count' => (int)($arrSummary['runs_count'] ?? 0), + 'json' => $arrArtifacts['json'] ?? '', + 'html' => $arrArtifacts['html'] ?? '', + 'portal_root' => $strPortalRoot, + 'portal_json' => $arrPortalArtifacts['json'] ?? '', + 'portal_html' => $arrPortalArtifacts['html'] ?? '', +], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/domain_seo_bootstrap_apply.php b/code/scripts/domain_seo_bootstrap_apply.php new file mode 100644 index 0000000..030e322 --- /dev/null +++ b/code/scripts/domain_seo_bootstrap_apply.php @@ -0,0 +1,124 @@ + [--dry-run=1] [--format=json|text] [--index-root=/abs/path] [--portal-root=/abs/path|off] [--write-result=1]\n\n"; + echo "Example:\n"; + echo " php scripts/domain_seo_bootstrap_apply.php /tmp/seo-domain-bootstrap/site-bootstrap.sample.json --dry-run=1\n"; +} + +function toJson(array $data): string +{ + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} + +function renderText(array $summary): string +{ + $lines = [ + 'status=' . ($summary['status'] ?? ''), + 'domain=' . ($summary['payload']['d_domain'] ?? ''), + 'found=' . (($summary['found'] ?? false) ? 'yes' : 'no'), + 'dry_run=' . (($summary['dry_run'] ?? false) ? 'yes' : 'no'), + 'changed_fields=' . count((array)($summary['diff'] ?? [])), + ]; + + foreach ((array)($summary['diff'] ?? []) as $field => $values) { + $lines[] = '- ' . $field; + } + + if (!empty($summary['message'])) { + $lines[] = 'message=' . $summary['message']; + } + + return implode(PHP_EOL, $lines) . PHP_EOL; +} + +$args = $argv; +array_shift($args); + +if (empty($args)) { + printUsage(); + exit(1); +} + +$bootstrapPath = ''; +$dryRun = false; +$format = 'json'; +$indexRoot = ''; +$portalRoot = ''; +$writeResult = true; + +foreach ($args as $arg) { + if (str_starts_with($arg, '--dry-run=')) { + $dryRun = in_array(strtolower(trim(substr($arg, strlen('--dry-run=')))), ['1', 'true', 'yes', 'on'], true); + continue; + } + if (str_starts_with($arg, '--format=')) { + $format = strtolower(trim(substr($arg, strlen('--format=')))); + continue; + } + if (str_starts_with($arg, '--index-root=')) { + $indexRoot = trim(substr($arg, strlen('--index-root='))); + continue; + } + if (str_starts_with($arg, '--portal-root=')) { + $portalRoot = trim(substr($arg, strlen('--portal-root='))); + continue; + } + if (str_starts_with($arg, '--write-result=')) { + $writeResult = in_array(strtolower(trim(substr($arg, strlen('--write-result=')))), ['1', 'true', 'yes', 'on'], true); + continue; + } + if ($bootstrapPath === '') { + $bootstrapPath = trim($arg); + } +} + +if ($bootstrapPath === '' || !is_file($bootstrapPath)) { + fwrite(STDERR, "Bootstrap file not found: {$bootstrapPath}\n"); + printUsage(); + exit(1); +} + +try { + $payload = DomainBootstrapApplyHelper::loadBootstrapPayload($bootstrapPath); +} catch (\Throwable $Throwable) { + fwrite(STDERR, $Throwable->getMessage() . PHP_EOL); + exit(1); +} + +(new App())->initialize(); + +$summary = [ + 'bootstrap_path' => $bootstrapPath, + 'dry_run' => $dryRun, +]; +$summary = array_merge( + $summary, + $dryRun ? DomainBootstrapApplyHelper::inspectPayload($payload) : DomainBootstrapApplyHelper::applyPayload($payload) +); + +if ($writeResult) { + $bundleRoot = DomainBootstrapExecutionHelper::inferBundleRoot($bootstrapPath); + if ($bundleRoot !== '') { + $summary['bundle_artifacts'] = DomainBootstrapExecutionHelper::persistResult( + $bundleRoot, + 'apply', + $summary + ['executed_at' => date(DATE_ATOM)], + $indexRoot !== '' ? $indexRoot : null, + $portalRoot !== '' ? $portalRoot : null + ); + } +} + +echo $format === 'text' ? renderText($summary) : toJson($summary); diff --git a/code/scripts/seo_copy_approved_index.php b/code/scripts/seo_copy_approved_index.php new file mode 100644 index 0000000..f1c35aa --- /dev/null +++ b/code/scripts/seo_copy_approved_index.php @@ -0,0 +1,77 @@ +getMessage() . PHP_EOL); + printUsage(); + exit(1); +} + +if ($strFormat === 'text') { + echo SeoCopyApprovedManifestHelper::renderTextSummary($arrSummary); + exit(0); +} + +echo json_encode([ + 'approved_dir' => realpath($strApprovedDir) ?: $strApprovedDir, + 'output_root' => realpath($strOutputRoot) ?: $strOutputRoot, + 'approved_index_json' => rtrim($strOutputRoot, '/') . '/approved-index.json', + 'approved_index_html' => rtrim($strOutputRoot, '/') . '/approved-index.html', + 'manifests' => (int)($arrSummary['manifests_count'] ?? 0), +], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_batch_front_verify.php b/code/scripts/seo_copy_batch_front_verify.php new file mode 100644 index 0000000..c19a9d6 --- /dev/null +++ b/code/scripts/seo_copy_batch_front_verify.php @@ -0,0 +1,339 @@ + [--target-root=/abs/path] [--base-url=http://127.0.0.1:8910] [--format=json|text]\n\n"; + echo "Example:\n"; + echo " php scripts/seo_copy_batch_front_verify.php data/seo_copy_jobs/examples/chuanjiafeng-batch.json --base-url=http://127.0.0.1:8910 --format=text\n"; +} + +function ensureAppInitialized(): void +{ + static $boolInitialized = false; + + if ($boolInitialized) { + return; + } + + (new App())->initialize(); + $boolInitialized = true; +} + +function buildScenePath(string $strHost, string $strScene, array $arrPageParts): string +{ + ensureAppInitialized(); + + /** @var DomainModel|null $DomainRow */ + $DomainRow = app(DomainModel::class)->where('d_domain', $strHost)->find(); + if (empty($DomainRow)) { + return ''; + } + + $TpStyle = SiteStyle::getConfig($DomainRow, $strHost); + $UrlBuilder = new UrlBuilder($TpStyle); + + if (in_array($strScene, ['detail', 'forge', 'play'], true)) { + $intVideoId = (int)($arrPageParts[0] ?? 0); + $arrVideo = VideoModel::getInstance()->findOne(['v_id' => $intVideoId], [ + 'typeMap' => [ + 'root' => 'array', + 'document' => 'array', + 'array' => 'array', + ], + 'projection' => [ + '_id' => 0, + 'v_name_en' => 1, + ], + ]) ?? []; + $strSlug = trim((string)($arrVideo['v_name_en'] ?? '')); + + return match ($strScene) { + 'detail' => $UrlBuilder->detail($strSlug, $intVideoId), + 'forge' => $UrlBuilder->detailForge($strSlug, $intVideoId, max(1, (int)($arrPageParts[1] ?? 1))), + 'play' => $UrlBuilder->play($strSlug, $intVideoId, (string)($arrPageParts[1] ?? ''), max(1, (int)($arrPageParts[2] ?? 1))), + default => '', + }; + } + + return match ($strScene) { + 'home' => $UrlBuilder->home(), + 'category_index' => $UrlBuilder->categoryParent((string)($arrPageParts[0] ?? '')), + 'category_list' => $UrlBuilder->categoryChild((string)($arrPageParts[0] ?? ''), (string)($arrPageParts[1] ?? ''), max(1, (int)($arrPageParts[2] ?? 1))), + 'search' => $UrlBuilder->searchResult((string)($arrPageParts[0] ?? '')), + 'rank_index' => $UrlBuilder->rankIndex(), + 'rank_list' => $UrlBuilder->rankList((string)($arrPageParts[0] ?? 'daily')), + default => '', + }; +} + +function buildNeedles(string $strScene, array $arrData): array +{ + return match ($strScene) { + 'home', + 'category_index', + 'category_list', + 'search', + 'rank_index', + 'rank_list' => array_values(array_filter([ + trim((string)($arrData['intro_text'] ?? '')), + trim((string)($arrData['intro_meta'] ?? '')), + trim((string)($arrData['guide_cards'][0]['title'] ?? '')), + ])), + 'detail' => array_values(array_filter([ + trim((string)($arrData['detail_body_lead'] ?? '')), + trim((string)($arrData['detail_play_link_lead'] ?? '')), + ])), + 'forge' => array_values(array_filter([ + trim((string)($arrData['forge_detail_note'] ?? '')), + trim((string)($arrData['forge_body_lead'] ?? '')), + ])), + 'play' => array_values(array_filter([ + trim((string)($arrData['play_intro'] ?? '')), + trim((string)($arrData['play_body_lead'] ?? '')), + ])), + default => [], + }; +} + +function fetchPage(string $strUrl, string $strHost): array +{ + $arrHeaders = [ + 'Host: ' . $strHost, + 'Connection: close', + ]; + $Context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => implode("\r\n", $arrHeaders), + 'ignore_errors' => true, + 'timeout' => 15, + ], + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ]); + + $strBody = @file_get_contents($strUrl, false, $Context); + $arrResponseHeaders = $http_response_header ?? []; + $intStatus = 0; + + foreach ($arrResponseHeaders 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, + ]; +} + +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); +} + +function verifyItem(string $strHost, string $strScene, array $arrPageParts, string $strTargetRoot, string $strBaseUrl): array +{ + $strPageKey = SeoCopySchema::buildScenePageKey($strScene, $arrPageParts); + $arrData = SeoCopyStore::getPageDataFromRoot($strTargetRoot, $strHost, $strScene, $strPageKey); + $strPath = buildScenePath($strHost, $strScene, $arrPageParts); + + if (empty($arrData)) { + return [ + 'host' => $strHost, + 'scene' => $strScene, + 'page_parts' => $arrPageParts, + 'page_key' => $strPageKey, + 'path' => $strPath, + 'http_status' => 0, + 'all_matched' => false, + 'reason' => 'missing_seo_copy_data', + 'checks' => [], + ]; + } + + if ($strPath === '') { + return [ + 'host' => $strHost, + 'scene' => $strScene, + 'page_parts' => $arrPageParts, + 'page_key' => $strPageKey, + 'path' => '', + 'http_status' => 0, + 'all_matched' => false, + 'reason' => 'unresolved_path', + 'checks' => [], + ]; + } + + $strUrl = rtrim($strBaseUrl, '/') . $strPath; + $arrResponse = fetchPage($strUrl, $strHost); + $strNormalizedBody = normalizeForMatch((string)($arrResponse['body'] ?? '')); + $arrNeedles = buildNeedles($strScene, $arrData); + + $arrChecks = []; + foreach ($arrNeedles as $intIndex => $strNeedle) { + $arrChecks[] = [ + 'label' => 'needle_' . ($intIndex + 1), + 'matched' => $strNeedle !== '' && str_contains($strNormalizedBody, normalizeForMatch($strNeedle)), + 'preview' => mb_substr($strNeedle, 0, 120), + ]; + } + + return [ + 'host' => $strHost, + 'scene' => $strScene, + 'page_parts' => $arrPageParts, + 'page_key' => $strPageKey, + 'path' => $strPath, + 'http_status' => (int)($arrResponse['status'] ?? 0), + 'all_matched' => (int)($arrResponse['status'] ?? 0) === 200 && !in_array(false, array_column($arrChecks, 'matched'), true), + 'reason' => '', + 'checks' => $arrChecks, + ]; +} + +function renderTextSummary(array $arrSummary): string +{ + $arrLines = [ + 'manifest: ' . $arrSummary['manifest'], + 'target_root: ' . $arrSummary['target_root'], + 'base_url: ' . $arrSummary['base_url'], + 'total: ' . $arrSummary['total'], + 'passed: ' . $arrSummary['passed'], + 'failed: ' . $arrSummary['failed'], + ]; + + if (!empty($arrSummary['items'])) { + $arrLines[] = 'items:'; + foreach ($arrSummary['items'] as $arrItem) { + $arrLines[] = '- ' . ($arrItem['all_matched'] ? '[ok] ' : '[fail] ') + . $arrItem['host'] . ' / ' . $arrItem['scene'] . ' / ' . $arrItem['page_key'] + . ' / status=' . $arrItem['http_status'] + . ($arrItem['reason'] !== '' ? ' / reason=' . $arrItem['reason'] : ''); + } + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (empty($arrArgs)) { + printUsage(); + exit(1); +} + +$strManifestPath = ''; +$strTargetRoot = dirname(__DIR__) . '/data/seo_copy'; +$strBaseUrl = 'http://127.0.0.1:8910'; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--target-root=')) { + $strTargetRoot = substr($strArg, strlen('--target-root=')); + continue; + } + + if (str_starts_with($strArg, '--base-url=')) { + $strBaseUrl = rtrim(substr($strArg, strlen('--base-url=')), '/'); + continue; + } + + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(substr($strArg, strlen('--format='))); + continue; + } + + if ($strManifestPath === '') { + $strManifestPath = $strArg; + } +} + +if ($strManifestPath === '' || !is_file($strManifestPath)) { + fwrite(STDERR, "Manifest file not found: {$strManifestPath}\n"); + printUsage(); + exit(1); +} + +$strManifestJson = (string)file_get_contents($strManifestPath); +$arrManifest = json_decode($strManifestJson, true); +if (!is_array($arrManifest)) { + fwrite(STDERR, "Manifest must be a JSON array.\n"); + exit(1); +} + +$arrItems = []; +$intPassed = 0; +$intFailed = 0; + +foreach ($arrManifest as $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; + } + + $arrResult = verifyItem($strHost, $strScene, $arrPageParts, $strTargetRoot, $strBaseUrl); + $arrItems[] = $arrResult; + + if ($arrResult['all_matched']) { + $intPassed++; + } else { + $intFailed++; + } +} + +$arrSummary = [ + 'manifest' => realpath($strManifestPath) ?: $strManifestPath, + 'target_root' => $strTargetRoot, + 'base_url' => $strBaseUrl, + 'total' => count($arrItems), + 'passed' => $intPassed, + 'failed' => $intFailed, + 'items' => $arrItems, +]; + +if ($strFormat === 'text') { + echo renderTextSummary($arrSummary); + exit(0); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_batch_import.php b/code/scripts/seo_copy_batch_import.php new file mode 100644 index 0000000..3c64a2d --- /dev/null +++ b/code/scripts/seo_copy_batch_import.php @@ -0,0 +1,252 @@ + [--target-root=/abs/path]\n\n"; + echo "Expected source structure:\n"; + echo " ///.json\n\n"; + echo "Example:\n"; + echo " php scripts/seo_copy_batch_import.php /tmp/ai-results\n"; +} + +function seoCopyBatchImportNormalizeGuideCards(mixed $mValue): array +{ + if (!is_array($mValue)) { + return []; + } + + $arrOut = []; + foreach ($mValue 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 $arrOut; +} + +function seoCopyBatchImportNormalizePayload(string $strScene, array $arrPayload): array +{ + $arrTemplate = SeoCopySchema::getSceneTemplate($strScene); + $arrOut = []; + + foreach ($arrTemplate as $strKey => $mTemplateVal) { + if ($strKey === 'guide_cards') { + $arrOut[$strKey] = seoCopyBatchImportNormalizeGuideCards($arrPayload[$strKey] ?? []); + continue; + } + + $arrOut[$strKey] = trim((string)($arrPayload[$strKey] ?? '')); + } + + return $arrOut; +} + +function seoCopyBatchImportValidatePayload(string $strScene, array $arrPayload): array +{ + $arrErrors = []; + $arrTemplate = SeoCopySchema::getSceneTemplate($strScene); + + foreach ($arrTemplate as $strKey => $mTemplateVal) { + if (!array_key_exists($strKey, $arrPayload)) { + $arrErrors[] = "Missing field: {$strKey}"; + continue; + } + + if ($strKey === 'guide_cards') { + if (!is_array($arrPayload[$strKey]) || count($arrPayload[$strKey]) === 0) { + $arrErrors[] = 'guide_cards must be a non-empty array'; + } + continue; + } + + if (!is_string($arrPayload[$strKey])) { + $arrErrors[] = "{$strKey} must be a string"; + } + } + + return $arrErrors; +} + +function seoCopyBatchImportExecute(string $strSourceDir, string $strTargetRoot): array +{ + $strSourceDir = rtrim($strSourceDir, '/'); + $strTargetRoot = rtrim($strTargetRoot, '/'); + + if ($strSourceDir === '' || !is_dir($strSourceDir)) { + throw new RuntimeException('Source directory not found: ' . $strSourceDir); + } + + $arrSummary = [ + 'source_dir' => realpath($strSourceDir) ?: $strSourceDir, + 'target_root' => $strTargetRoot, + 'imported' => [], + 'skipped' => [], + ]; + + $Iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($strSourceDir, FilesystemIterator::SKIP_DOTS)); + foreach ($Iterator as $FileInfo) { + /** @var SplFileInfo $FileInfo */ + if (!$FileInfo->isFile() || strtolower($FileInfo->getExtension()) !== 'json') { + continue; + } + + if (preg_match('/\.facts\.json$/i', $FileInfo->getFilename()) === 1) { + continue; + } + + $strRelative = str_replace('\\', '/', substr($FileInfo->getPathname(), strlen($strSourceDir) + 1)); + $arrSegments = explode('/', $strRelative); + + if (count($arrSegments) < 3) { + $arrSummary['skipped'][] = [ + 'file' => $FileInfo->getPathname(), + 'reason' => 'invalid_path_structure', + ]; + continue; + } + + $strHostDir = $arrSegments[0]; + $strScene = $arrSegments[1]; + $strFilename = $arrSegments[count($arrSegments) - 1]; + $strPageKey = preg_replace('/\.json$/i', '', $strFilename); + + if (!in_array($strScene, SeoCopySchema::getSupportedScenes(), true)) { + $arrSummary['skipped'][] = [ + 'file' => $FileInfo->getPathname(), + 'reason' => 'unsupported_scene', + ]; + continue; + } + + $strJson = (string)file_get_contents($FileInfo->getPathname()); + $arrPayload = json_decode($strJson, true); + if (!is_array($arrPayload)) { + $arrSummary['skipped'][] = [ + 'file' => $FileInfo->getPathname(), + 'reason' => 'invalid_json', + ]; + continue; + } + + $arrNormalized = seoCopyBatchImportNormalizePayload($strScene, $arrPayload); + $arrErrors = seoCopyBatchImportValidatePayload($strScene, $arrNormalized); + if (!empty($arrErrors)) { + $arrSummary['skipped'][] = [ + 'file' => $FileInfo->getPathname(), + 'reason' => 'validation_failed', + 'errors' => $arrErrors, + ]; + continue; + } + + $strTargetDir = $strTargetRoot . '/' . $strHostDir . '/' . $strScene; + if (!is_dir($strTargetDir) && !mkdir($strTargetDir, 0777, true) && !is_dir($strTargetDir)) { + $arrSummary['skipped'][] = [ + 'file' => $FileInfo->getPathname(), + 'reason' => 'mkdir_failed', + ]; + continue; + } + + $strTargetPath = $strTargetDir . '/' . $strPageKey . '.json'; + file_put_contents($strTargetPath, json_encode($arrNormalized, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL); + + $arrSummary['imported'][] = [ + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'target_path' => $strTargetPath, + ]; + } + + $arrSummary['imported_count'] = count((array)$arrSummary['imported']); + $arrSummary['skipped_count'] = count((array)$arrSummary['skipped']); + + return $arrSummary; +} + +function seoCopyBatchImportParseCliArgs(array $arrArgs): array +{ + $strSourceDir = ''; + $strTargetRoot = dirname(__DIR__) . '/data/seo_copy'; + + foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--target-root=')) { + $strTargetRoot = trim(substr($strArg, strlen('--target-root='))); + continue; + } + + if ($strSourceDir === '') { + $strSourceDir = trim($strArg); + } + } + + return [ + 'source_dir' => $strSourceDir, + 'target_root' => $strTargetRoot, + ]; +} + +function seoCopyBatchImportCliMain(array $argv): int +{ + $arrArgs = $argv; + array_shift($arrArgs); + + if (empty($arrArgs)) { + seoCopyBatchImportPrintUsage(); + return 1; + } + + $arrOptions = seoCopyBatchImportParseCliArgs($arrArgs); + $strSourceDir = (string)($arrOptions['source_dir'] ?? ''); + if ($strSourceDir === '' || !is_dir($strSourceDir)) { + fwrite(STDERR, "Source directory not found: {$strSourceDir}\n"); + seoCopyBatchImportPrintUsage(); + return 1; + } + + try { + $arrSummary = seoCopyBatchImportExecute( + $strSourceDir, + (string)($arrOptions['target_root'] ?? '') + ); + } catch (Throwable $throwable) { + fwrite(STDERR, $throwable->getMessage() . PHP_EOL); + return 1; + } + + echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + return 0; +} + +if (PHP_SAPI === 'cli' && realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + exit(seoCopyBatchImportCliMain($argv)); +} diff --git a/code/scripts/seo_copy_batch_prepare.php b/code/scripts/seo_copy_batch_prepare.php new file mode 100644 index 0000000..0375c7a --- /dev/null +++ b/code/scripts/seo_copy_batch_prepare.php @@ -0,0 +1,77 @@ + [--approved-dir=/abs/path] [--out-dir=/abs/path]\n\n"; + echo "Example:\n"; + echo " php scripts/seo_copy_batch_prepare.php data/seo_copy_jobs/examples/chuanjiafeng-batch.json\n"; + echo " php scripts/seo_copy_batch_prepare.php chuanjiafeng-core-batch1 --approved-dir=data/seo_copy_jobs/approved\n"; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (empty($arrArgs)) { + printUsage(); + exit(1); +} + +$strManifestPath = ''; +$strApprovedDir = dirname(__DIR__) . '/data/seo_copy_jobs/approved'; +$strOutDir = ''; +$boolOutDirCustom = false; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--approved-dir=')) { + $strApprovedDir = substr($strArg, strlen('--approved-dir=')); + continue; + } + + if (str_starts_with($strArg, '--out-dir=')) { + $strOutDir = substr($strArg, strlen('--out-dir=')); + $boolOutDirCustom = true; + continue; + } + + if ($strManifestPath === '') { + $strManifestPath = $strArg; + } +} + +if ($strManifestPath === '' || !is_file($strManifestPath)) { + $strManifestPath = SeoCopyBatchPrepareHelper::resolveManifestPath($strManifestPath, $strApprovedDir); +} + +if ($strManifestPath === '' || !is_file($strManifestPath)) { + fwrite(STDERR, "Manifest file not found: {$strManifestPath}\n"); + printUsage(); + exit(1); +} + +(new App())->initialize(); + +if (!$boolOutDirCustom) { + $strOutDir = dirname(__DIR__) . '/storage/seo_copy_batch/' . date('Ymd_His') . '_' . SeoCopyBatchPrepareHelper::buildReleaseTagFromManifestPath($strManifestPath); +} + +$arrOutput = SeoCopyBatchPrepareHelper::prepareManifest($strManifestPath, [ + 'approved_dir' => $strApprovedDir, + 'out_dir' => $strOutDir, +]); + +echo json_encode($arrOutput, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_batch_publish.php b/code/scripts/seo_copy_batch_publish.php new file mode 100644 index 0000000..cff7faf --- /dev/null +++ b/code/scripts/seo_copy_batch_publish.php @@ -0,0 +1,404 @@ + --source-root=/abs/path [--target-root=/abs/path] [--dry-run=1] [--skip-existing=1] [--release-tag=tag] [--log-dir=/abs/path] [--approved-dir=/abs/path]\n\n"; + echo "Examples:\n"; + echo " php scripts/seo_copy_batch_publish.php /tmp/seo-front-manifest.json --source-root=/tmp/seo-front-dst\n"; + echo " php scripts/seo_copy_batch_publish.php data/seo_copy_jobs/examples/chuanjiafeng-batch.json --source-root=/tmp/seo-approved --dry-run=1\n"; +} + +function normalizeGuideCards(mixed $mValue): array +{ + if (!is_array($mValue)) { + return []; + } + + $arrOut = []; + foreach ($mValue 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 $arrOut; +} + +function normalizePayload(string $strScene, array $arrPayload): array +{ + $arrTemplate = SeoCopySchema::getSceneTemplate($strScene); + $arrOut = []; + + foreach ($arrTemplate as $strKey => $mTemplateVal) { + if ($strKey === 'guide_cards') { + $arrOut[$strKey] = normalizeGuideCards($arrPayload[$strKey] ?? []); + continue; + } + + $arrOut[$strKey] = trim((string)($arrPayload[$strKey] ?? '')); + } + + return $arrOut; +} + +function validatePayload(string $strScene, array $arrPayload): array +{ + $arrErrors = []; + $arrTemplate = SeoCopySchema::getSceneTemplate($strScene); + + foreach ($arrTemplate as $strKey => $mTemplateVal) { + if (!array_key_exists($strKey, $arrPayload)) { + $arrErrors[] = "Missing field: {$strKey}"; + continue; + } + + if ($strKey === 'guide_cards') { + if (!is_array($arrPayload[$strKey]) || count($arrPayload[$strKey]) === 0) { + $arrErrors[] = 'guide_cards must be a non-empty array'; + } + continue; + } + + if (!is_string($arrPayload[$strKey])) { + $arrErrors[] = "{$strKey} must be a string"; + continue; + } + + if (trim($arrPayload[$strKey]) === '') { + $arrErrors[] = "{$strKey} must be a non-empty string"; + } + } + + return $arrErrors; +} + +function buildReleaseTag(string $strReleaseTag): string +{ + $strReleaseTag = strtolower(trim($strReleaseTag)); + $strReleaseTag = preg_replace('/[^a-z0-9\\-_]+/', '-', $strReleaseTag); + $strReleaseTag = trim((string)$strReleaseTag, '-_'); + + return $strReleaseTag !== '' ? $strReleaseTag : date('Ymd_His'); +} + +function buildManifestDigest(array $arrManifest): string +{ + return sha1(json_encode($arrManifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); +} + +function writeJsonFile(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); +} + +function writeRawFile(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); +} + +function resolvePortalRoot(): string +{ + $strPortalRoot = trim((string)getenv('SEO_COPY_RELEASE_PORTAL_ROOT')); + if (in_array(strtolower($strPortalRoot), ['0', 'false', 'off', 'disable', 'disabled'], true)) { + return ''; + } + + return $strPortalRoot !== '' ? $strPortalRoot : dirname(__DIR__) . '/public/_seo_copy_release'; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (empty($arrArgs)) { + printUsage(); + exit(1); +} + +$strManifestPath = ''; +$strSourceRoot = ''; +$strTargetRoot = dirname(__DIR__) . '/data/seo_copy'; +$boolDryRun = false; +$boolSkipExisting = false; +$strReleaseTag = ''; +$strLogDir = dirname(__DIR__) . '/storage/seo_copy_publish_logs'; +$strApprovedDir = dirname(__DIR__) . '/data/seo_copy_jobs/approved'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--source-root=')) { + $strSourceRoot = substr($strArg, strlen('--source-root=')); + continue; + } + + if (str_starts_with($strArg, '--target-root=')) { + $strTargetRoot = substr($strArg, strlen('--target-root=')); + continue; + } + + if (str_starts_with($strArg, '--dry-run=')) { + $boolDryRun = in_array(strtolower(substr($strArg, strlen('--dry-run='))), ['1', 'true', 'yes'], true); + continue; + } + + if (str_starts_with($strArg, '--skip-existing=')) { + $boolSkipExisting = in_array(strtolower(substr($strArg, strlen('--skip-existing='))), ['1', 'true', 'yes'], true); + continue; + } + + if (str_starts_with($strArg, '--release-tag=')) { + $strReleaseTag = substr($strArg, strlen('--release-tag=')); + continue; + } + + if (str_starts_with($strArg, '--log-dir=')) { + $strLogDir = substr($strArg, strlen('--log-dir=')); + continue; + } + + if (str_starts_with($strArg, '--approved-dir=')) { + $strApprovedDir = substr($strArg, strlen('--approved-dir=')); + continue; + } + + if ($strManifestPath === '') { + $strManifestPath = $strArg; + } +} + +if ($strManifestPath === '' || !is_file($strManifestPath)) { + fwrite(STDERR, "Manifest file not found: {$strManifestPath}\n"); + printUsage(); + exit(1); +} + +if ($strSourceRoot === '' || !is_dir($strSourceRoot)) { + fwrite(STDERR, "Source root not found: {$strSourceRoot}\n"); + printUsage(); + exit(1); +} + +$strManifestJson = (string)file_get_contents($strManifestPath); +$arrManifest = json_decode($strManifestJson, true); +if (!is_array($arrManifest)) { + fwrite(STDERR, "Manifest must be a JSON array.\n"); + exit(1); +} + +$strReleaseTag = buildReleaseTag($strReleaseTag); +$strManifestDigest = buildManifestDigest($arrManifest); +$strManifestArchivePath = rtrim($strApprovedDir, '/') . '/' . $strReleaseTag . '.manifest.json'; +$strPublishLogPath = rtrim($strLogDir, '/') . '/' . date('Ymd') . '/' . date('His') . '_' . $strReleaseTag . '.json'; +$strBackupRoot = rtrim($strLogDir, '/') . '/' . date('Ymd') . '/backups/' . $strReleaseTag; +$strLatestIndexPath = rtrim($strLogDir, '/') . '/latest-index.json'; +$strPortalRoot = resolvePortalRoot(); + +$arrSummary = [ + 'log_type' => 'publish', + 'created_at' => date('c'), + 'manifest' => realpath($strManifestPath) ?: $strManifestPath, + 'manifest_digest' => $strManifestDigest, + 'source_root' => realpath($strSourceRoot) ?: $strSourceRoot, + 'target_root' => $strTargetRoot, + 'dry_run' => $boolDryRun, + 'skip_existing' => $boolSkipExisting, + 'release_tag' => $strReleaseTag, + 'manifest_archive_path' => $strManifestArchivePath, + 'publish_log_path' => $strPublishLogPath, + 'backup_root' => $strBackupRoot, + 'latest_index_path' => $strLatestIndexPath, + 'portal_root' => $strPortalRoot, + 'portal_index_path' => $strPortalRoot !== '' ? rtrim($strPortalRoot, '/') . '/latest-index.html' : '', + 'published_count' => 0, + 'skipped_count' => 0, + 'published' => [], + 'skipped' => [], +]; + +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)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'reason' => 'invalid_manifest_item', + ]; + continue; + } + + $strPageKey = SeoCopySchema::buildScenePageKey($strScene, $arrPageParts); + $strSourcePath = SeoCopyStore::resolvePagePathFromRoot($strSourceRoot, $strHost, $strScene, $strPageKey); + $strTargetPath = SeoCopyStore::resolvePagePathFromRoot($strTargetRoot, $strHost, $strScene, $strPageKey); + + if ($strSourcePath === '' || !is_file($strSourcePath)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'reason' => 'source_missing', + ]; + continue; + } + + if ($boolSkipExisting && $strTargetPath !== '' && is_file($strTargetPath)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'reason' => 'target_exists', + ]; + continue; + } + + $arrPayload = SeoCopyStore::getPageDataFromRoot($strSourceRoot, $strHost, $strScene, $strPageKey); + if (empty($arrPayload)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'reason' => 'source_unreadable', + ]; + continue; + } + + $arrNormalized = normalizePayload($strScene, $arrPayload); + $arrErrors = validatePayload($strScene, $arrNormalized); + if (!empty($arrErrors)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'reason' => 'validation_failed', + 'errors' => $arrErrors, + ]; + continue; + } + + if (!$boolDryRun) { + $strTargetDir = dirname($strTargetPath); + if (!is_dir($strTargetDir) && !mkdir($strTargetDir, 0777, true) && !is_dir($strTargetDir)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'reason' => 'mkdir_failed', + ]; + continue; + } + + $strAction = is_file($strTargetPath) ? 'update' : 'create'; + $strBackupPath = ''; + if ($strAction === 'update') { + $strRelativeBackup = ltrim(str_replace(rtrim($strTargetRoot, '/'), '', $strTargetPath), '/'); + $strBackupPath = rtrim($strBackupRoot, '/') . '/' . $strRelativeBackup; + writeRawFile($strBackupPath, (string)file_get_contents($strTargetPath)); + } + + file_put_contents($strTargetPath, json_encode($arrNormalized, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } else { + $strAction = is_file($strTargetPath) ? 'update' : 'create'; + $strBackupPath = $strAction === 'update' + ? rtrim($strBackupRoot, '/') . '/' . ltrim(str_replace(rtrim($strTargetRoot, '/'), '', $strTargetPath), '/') + : ''; + } + + $arrSummary['published'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'source_path' => $strSourcePath, + 'target_path' => $strTargetPath, + 'action' => $strAction, + 'backup_path' => $strBackupPath, + 'sha1' => sha1(json_encode($arrNormalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)), + ]; +} + +$arrSummary['published_count'] = count($arrSummary['published']); +$arrSummary['skipped_count'] = count($arrSummary['skipped']); + +if (!$boolDryRun) { + writeJsonFile($strManifestArchivePath, [ + 'release_tag' => $strReleaseTag, + 'created_at' => date('c'), + 'source_manifest' => realpath($strManifestPath) ?: $strManifestPath, + 'manifest_digest' => $strManifestDigest, + 'items' => $arrManifest, + ]); + + $arrApprovedSummary = SeoCopyApprovedManifestHelper::buildSummary($strApprovedDir, true); + SeoCopyApprovedManifestHelper::writeArtifacts($strApprovedDir, $arrApprovedSummary); + if ($strPortalRoot !== '') { + SeoCopyApprovedManifestHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/approved', $arrApprovedSummary); + } +} + +writeJsonFile($strPublishLogPath, $arrSummary); +$arrLatestSummary = SeoCopyReleaseIndexHelper::buildSummary($strLogDir, true); +SeoCopyReleaseIndexHelper::writeSummary($strLatestIndexPath, $arrLatestSummary); +SeoCopyReleaseViewHelper::writeArtifacts($strLogDir, $arrLatestSummary); +if ($strPortalRoot !== '') { + SeoCopyReleasePortalHelper::writeArtifacts($strPortalRoot, $arrLatestSummary); + SeoCopyPortalHomeHelper::writeArtifacts($strPortalRoot, SeoCopyPortalHomeHelper::buildSummary($strPortalRoot)); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_batch_verify.php b/code/scripts/seo_copy_batch_verify.php new file mode 100644 index 0000000..47b902a --- /dev/null +++ b/code/scripts/seo_copy_batch_verify.php @@ -0,0 +1,195 @@ + [--host=host-dir] [--scene=scene] [--format=json|text]\n\n"; + echo "Examples:\n"; + echo " php scripts/seo_copy_batch_verify.php /tmp/seo-import-dst\n"; + echo " php scripts/seo_copy_batch_verify.php /tmp/seo-import-dst --host=chuanjiafeng-net --scene=play --format=text\n"; +} + +function summarizeFields(string $strScene, array $arrData): array +{ + $arrTemplate = SeoCopySchema::getSceneTemplate($strScene); + $arrMissing = []; + $arrEmpty = []; + + foreach ($arrTemplate as $strField => $mTemplateVal) { + if (!array_key_exists($strField, $arrData)) { + $arrMissing[] = $strField; + continue; + } + + if ($strField === 'guide_cards') { + if (!is_array($arrData[$strField]) || count($arrData[$strField]) === 0) { + $arrEmpty[] = $strField; + } + continue; + } + + if (trim((string)$arrData[$strField]) === '') { + $arrEmpty[] = $strField; + } + } + + return [ + 'missing_fields' => $arrMissing, + 'empty_fields' => $arrEmpty, + ]; +} + +function renderTextSummary(array $arrSummary): string +{ + $arrLines = [ + 'target_root: ' . $arrSummary['target_root'], + 'total_files: ' . $arrSummary['total_files'], + 'checked_files: ' . $arrSummary['checked_files'], + 'readable_files: ' . $arrSummary['readable_files'], + ]; + + if (!empty($arrSummary['by_scene'])) { + $arrLines[] = 'by_scene:'; + foreach ($arrSummary['by_scene'] as $strScene => $arrSceneSummary) { + $arrLines[] = '- ' . $strScene . ': total=' . $arrSceneSummary['total'] . ', readable=' . $arrSceneSummary['readable']; + } + } + + if (!empty($arrSummary['issues'])) { + $arrLines[] = 'issues:'; + foreach ($arrSummary['issues'] as $arrIssue) { + $arrLines[] = '- ' . $arrIssue['host'] . '/' . $arrIssue['scene'] . '/' . $arrIssue['page_key']; + if (!empty($arrIssue['missing_fields'])) { + $arrLines[] = ' missing: ' . implode(', ', $arrIssue['missing_fields']); + } + if (!empty($arrIssue['empty_fields'])) { + $arrLines[] = ' empty: ' . implode(', ', $arrIssue['empty_fields']); + } + } + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (empty($arrArgs)) { + printUsage(); + exit(1); +} + +$strTargetRoot = ''; +$strHostFilter = ''; +$strSceneFilter = ''; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--host=')) { + $strHostFilter = trim(substr($strArg, strlen('--host='))); + continue; + } + + if (str_starts_with($strArg, '--scene=')) { + $strSceneFilter = trim(substr($strArg, strlen('--scene='))); + continue; + } + + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(substr($strArg, strlen('--format='))); + continue; + } + + if ($strTargetRoot === '') { + $strTargetRoot = $strArg; + } +} + +if ($strTargetRoot === '' || !is_dir($strTargetRoot)) { + fwrite(STDERR, "Target root not found: {$strTargetRoot}\n"); + printUsage(); + exit(1); +} + +$arrSummary = [ + 'target_root' => realpath($strTargetRoot) ?: $strTargetRoot, + 'total_files' => 0, + 'checked_files' => 0, + 'readable_files' => 0, + 'by_scene' => [], + 'issues' => [], +]; + +$Iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($strTargetRoot, FilesystemIterator::SKIP_DOTS)); +foreach ($Iterator as $FileInfo) { + /** @var SplFileInfo $FileInfo */ + if (!$FileInfo->isFile() || strtolower($FileInfo->getExtension()) !== 'json') { + continue; + } + + $arrSummary['total_files']++; + $strRelative = str_replace('\\', '/', substr($FileInfo->getPathname(), strlen(rtrim($strTargetRoot, '/')) + 1)); + $arrSegments = explode('/', $strRelative); + + if (count($arrSegments) < 3) { + continue; + } + + $strHostDir = $arrSegments[0]; + $strScene = $arrSegments[1]; + $strPageKey = preg_replace('/\.json$/i', '', $arrSegments[count($arrSegments) - 1]); + + if ($strHostFilter !== '' && $strHostFilter !== $strHostDir) { + continue; + } + + if ($strSceneFilter !== '' && $strSceneFilter !== $strScene) { + continue; + } + + if (!in_array($strScene, SeoCopySchema::getSupportedScenes(), true)) { + continue; + } + + $arrSummary['checked_files']++; + $arrSummary['by_scene'][$strScene] = $arrSummary['by_scene'][$strScene] ?? [ + 'total' => 0, + 'readable' => 0, + ]; + $arrSummary['by_scene'][$strScene]['total']++; + + $arrData = SeoCopyStore::getPageDataFromRoot($strTargetRoot, $strHostDir, $strScene, $strPageKey); + $arrFieldSummary = summarizeFields($strScene, $arrData); + $boolReadable = !empty($arrData) && empty($arrFieldSummary['missing_fields']); + + if ($boolReadable) { + $arrSummary['readable_files']++; + $arrSummary['by_scene'][$strScene]['readable']++; + } + + if (!$boolReadable || !empty($arrFieldSummary['empty_fields'])) { + $arrSummary['issues'][] = [ + 'host' => $strHostDir, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'missing_fields' => $arrFieldSummary['missing_fields'], + 'empty_fields' => $arrFieldSummary['empty_fields'], + ]; + } +} + +if ($strFormat === 'text') { + echo renderTextSummary($arrSummary); + exit(0); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_domain_bootstrap.php b/code/scripts/seo_copy_domain_bootstrap.php new file mode 100644 index 0000000..1a22aa3 --- /dev/null +++ b/code/scripts/seo_copy_domain_bootstrap.php @@ -0,0 +1,769 @@ + \\\n"; + echo " --detail-id= --detail-slug= --search-keyword= \\\n"; + echo " --category-parent= --category-child= [--play-type=] \\\n"; + echo " [--play-index=1] [--forge-index=1] [--rank-slug=daily] [--manifest-name=] \\\n"; + echo " [--match-type=exact|wildcard] [--parent-domain=] [--seed-scope=domain|host] \\\n"; + echo " [--strategy-profile=standard|traffic|expand|closed] \\\n"; + echo " [--canonical-mode=strict|relaxed] [--play-index-mode=noindex_follow|index_follow] \\\n"; + echo " [--robots-policy=default|index_follow|noindex_follow|noindex_nofollow] \\\n"; + echo " [--structured-policy=full|compact|minimal] \\\n"; + echo " [--template-id=] [--site-name=] [--site-keywords=] [--site-description=] \\\n"; + echo " [--index-root=/abs/path] \\\n"; + echo " [--portal-root=/abs/path|off] \\\n"; + echo " [--bootstrap-run=off|dry_run|apply] [--run-root=/abs/path] \\\n"; + echo " [--target-root=/abs/path] [--approved-dir=/abs/path] [--bundle-root=/abs/path] \\\n"; + echo " [--base-url=https://host] [--force]\n\n"; + echo "Example:\n"; + echo " php scripts/seo_copy_domain_bootstrap.php demo.example.com \\\n"; + echo " --detail-id=154229 --detail-slug=xiang-feng-bu-shi-jiu-shi-ren \\\n"; + echo " --search-keyword=相逢 --category-parent=dian-ying --category-child=shao-shi-dian-ying \\\n"; + echo " --play-type=douban --rank-slug=daily\n"; +} + +function ensureDir(string $dir): void +{ + if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { + throw new RuntimeException('Failed to create directory: ' . $dir); + } +} + +function writeJsonIfNeeded(string $path, array $data, bool $force): string +{ + if (is_file($path) && !$force) { + return 'skipped'; + } + + ensureDir(dirname($path)); + file_put_contents( + $path, + json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + + return is_file($path) ? 'written' : 'failed'; +} + +function writeTextFile(string $path, string $content): void +{ + ensureDir(dirname($path)); + file_put_contents($path, $content); +} + +function exportJson(array $data): string +{ + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} + +function sh(string $value): string +{ + return escapeshellarg($value); +} + +function buildDomainBootstrapPayload( + string $host, + string $matchType, + string $parentDomain, + string $seedScope, + string $strategyProfile, + ?string $canonicalMode, + ?string $playIndexMode, + ?string $robotsPolicy, + ?string $structuredPolicy +): array { + $normalizedMatchType = DomainModel::normalizeMatchType($matchType); + $normalizedParentDomain = DomainModel::normalizeParentDomain($parentDomain); + $normalizedSeedScope = DomainModel::normalizeSeedScope($seedScope); + $normalizedDomain = DomainModel::normalizeStoredDomain($host, $normalizedMatchType); + $arrSeoCfgPayload = [ + 'strategy_profile' => $strategyProfile, + ]; + if ($canonicalMode !== null && $canonicalMode !== '') { + $arrSeoCfgPayload['canonical_mode'] = $canonicalMode; + } + if ($playIndexMode !== null && $playIndexMode !== '') { + $arrSeoCfgPayload['play_index_mode'] = $playIndexMode; + } + if ($robotsPolicy !== null && $robotsPolicy !== '') { + $arrSeoCfgPayload['robots_policy'] = $robotsPolicy; + } + if ($structuredPolicy !== null && $structuredPolicy !== '') { + $arrSeoCfgPayload['structured_data'] = [ + 'policy' => $structuredPolicy, + ]; + } + + $normalizedSeoCfg = DomainModel::normalizeSeoCfg($arrSeoCfgPayload); + + return [ + 'd_domain' => $normalizedDomain, + 'd_match_type' => $normalizedMatchType, + 'd_parent_domain' => $normalizedParentDomain, + 'd_seed_scope' => $normalizedSeedScope, + 'd_seo_cfg' => $normalizedSeoCfg, + '_notes' => [ + '导入前请补齐 t_id、d_name、关键词、描述等站点基础字段。', + 'wildcard 模式建议同步确认父域名和 seed_scope,避免多子域共用错误 seed。', + '本文件只负责域名 SEO 策略与匹配参数,不会替代离线补料 JSON 与 approved manifest。', + ], + ]; +} + +function buildSiteApplyPreview(array $arrPayload): array +{ + try { + (new App())->initialize(); + return DomainBootstrapApplyHelper::inspectPayload($arrPayload); + } catch (\Throwable $Throwable) { + return [ + 'status' => 'preview_unavailable', + 'message' => $Throwable->getMessage(), + 'found' => false, + 'diff' => [], + 'changed_fields' => 0, + 'payload' => $arrPayload, + ]; + } +} + +function buildSiteRegisterPayload( + string $host, + array $arrSiteStrategy, + int $intTemplateId, + string $strSiteName, + string $strSiteKeywords, + string $strSiteDescription +): array { + $strDefaultSiteName = trim($strSiteName); + if ($strDefaultSiteName === '') { + $strDefaultSiteName = DomainModel::normalizeHost($host); + } + + $strDefaultKeywords = trim($strSiteKeywords); + if ($strDefaultKeywords === '') { + $strDefaultKeywords = $strDefaultSiteName; + } + + $strDefaultDescription = trim($strSiteDescription); + if ($strDefaultDescription === '') { + $strDefaultDescription = $strDefaultSiteName . '内容整理与推荐。'; + } + + return [ + 'd_domain' => (string)($arrSiteStrategy['d_domain'] ?? $host), + 'd_match_type' => (string)($arrSiteStrategy['d_match_type'] ?? DomainModel::MATCH_TYPE_EXACT), + 'd_parent_domain' => (string)($arrSiteStrategy['d_parent_domain'] ?? ''), + 'd_seed_scope' => (string)($arrSiteStrategy['d_seed_scope'] ?? DomainModel::SEED_SCOPE_DOMAIN), + 'd_seo_cfg' => (array)($arrSiteStrategy['d_seo_cfg'] ?? []), + 't_id' => $intTemplateId, + 'd_name' => $strDefaultSiteName, + 'd_keywords' => $strDefaultKeywords, + 'd_description' => $strDefaultDescription, + 'd_index_title' => $strDefaultSiteName . '影视内容推荐', + 'd_index_keywords' => $strDefaultKeywords, + 'd_index_description' => $strDefaultDescription, + 'd_statis' => '', + 'd_logo_type' => 0, + 'd_text_logo' => $strDefaultSiteName, + 'd_img_logo' => '', + 'd_content_encode' => 0, + 'info_id' => 0, + 'd_baidu_token' => '', + '_notes' => [ + '本文件用于创建 domain 记录,模板配置 t_cfg 会在创建时自动从模板表读取。', + '如目标域名已存在,register dry-run 会返回 domain_exists,后续直接执行 SEO 策略 apply 即可。', + '建议在正式 apply 前先检查 site_name、template_id 和首页三要素文案。', + ], + ]; +} + +function buildSiteRegisterPreview(array $arrPayload): array +{ + try { + (new App())->initialize(); + return DomainBootstrapRegisterHelper::inspectPayload($arrPayload); + } catch (\Throwable $Throwable) { + return [ + 'status' => 'preview_unavailable', + 'message' => $Throwable->getMessage(), + 'found' => false, + 'domain_id' => 0, + 'template_id' => (int)($arrPayload['t_id'] ?? 0), + 'template_found' => false, + 'create_fields' => 0, + 'payload' => $arrPayload, + ]; + } +} + +function renderCommandsScript(array $summary, string $codeRoot, string $manifestArg): string +{ + $runDryRun = "php scripts/domain_bootstrap_run.php " + . sh((string)($summary['bundle_root'] ?? '.')) + . " --dry-run=1 --format=text"; + + $runApply = "php scripts/domain_bootstrap_run.php " + . sh((string)($summary['bundle_root'] ?? '.')) + . " --dry-run=0 --format=text"; + + $applyDryRun = "php scripts/domain_seo_bootstrap_apply.php " + . sh((string)($summary['site_bootstrap_path'] ?? ($summary['bundle_files']['site_bootstrap'] ?? 'site-bootstrap.sample.json'))) + . " --dry-run=1 --format=text"; + + $registerDryRun = "php scripts/domain_bootstrap_register.php " + . sh((string)($summary['site_register_path'] ?? ($summary['bundle_files']['site_register'] ?? 'site-register.sample.json'))) + . " --dry-run=1 --format=text"; + + $register = "php scripts/domain_bootstrap_register.php " + . sh((string)($summary['site_register_path'] ?? ($summary['bundle_files']['site_register'] ?? 'site-register.sample.json'))) + . " --format=text"; + + $apply = "php scripts/domain_seo_bootstrap_apply.php " + . sh((string)($summary['site_bootstrap_path'] ?? ($summary['bundle_files']['site_bootstrap'] ?? 'site-bootstrap.sample.json'))) + . " --format=text"; + + $prepare = "php scripts/seo_copy_batch_prepare.php " + . sh($manifestArg) + . " --approved-dir=" . sh((string)$summary['approved_dir']); + + $dryRun = "php scripts/seo_copy_release_run.php " + . sh($manifestArg) + . " --approved-dir=" . sh((string)$summary['approved_dir']) + . " --source-root=" . sh((string)$summary['target_root']) + . " --base-url=" . sh((string)$summary['base_url']) + . " --dry-run=1 --format=text"; + + $publish = "php scripts/seo_copy_release_run.php " + . sh($manifestArg) + . " --approved-dir=" . sh((string)$summary['approved_dir']) + . " --source-root=" . sh((string)$summary['target_root']) + . " --base-url=" . sh((string)$summary['base_url']) + . " --format=text"; + + return implode(PHP_EOL, [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + '', + 'cd ' . sh($codeRoot), + '', + '# 1. 推荐先走高层 run 入口', + $runDryRun, + '# ' . $runApply, + '', + '# 2. 如需拆步排查,再分别执行 register/apply', + $registerDryRun, + '# ' . $register, + '', + $applyDryRun, + '# ' . $apply, + '', + '# 3. 生成 facts + prompt', + $prepare, + '', + '# 4. 跑 dry-run 门禁', + $dryRun, + '', + '# 5. dry-run 通过后正式发布', + '# ' . $publish, + '', + ]); +} + +function renderRunbookMarkdown(array $summary, string $manifestArg, string $commandsPath): string +{ + $items = array_map(static function (array $item): string { + return '- `' . ($item['scene'] ?? '') . '` -> `' . ($item['page_key'] ?? '') . '`'; + }, (array)($summary['items'] ?? [])); + + return implode(PHP_EOL, [ + '# 新域名离线补料启动包', + '', + '## 基本信息', + '', + '- host: `' . $summary['host'] . '`', + '- host_dir: `' . $summary['host_dir'] . '`', + '- manifest: `' . $summary['manifest'] . '`', + '- target_root: `' . $summary['target_root'] . '`', + '- approved_dir: `' . $summary['approved_dir'] . '`', + '- base_url: `' . $summary['base_url'] . '`', + '- items_count: `' . $summary['items_count'] . '`', + '- site_register: `' . ($summary['bundle_files']['site_register'] ?? '') . '`', + '- site_register_preview: `' . ($summary['bundle_files']['site_register_preview'] ?? '') . '`', + '- site_bootstrap: `' . ($summary['bundle_files']['site_bootstrap'] ?? ($summary['site_bootstrap_path'] ?? '')) . '`', + '- site_apply_preview: `' . ($summary['bundle_files']['site_apply_preview'] ?? '') . '`', + '- structured_policy: `' . ($summary['site_strategy']['d_seo_cfg']['structured_data']['policy'] ?? '') . '`', + '- reference_mode: `' . ($summary['site_strategy']['d_seo_cfg']['structured_data']['reference_mode'] ?? '') . '`', + '- strategy_profile: `' . ($summary['site_strategy']['d_seo_cfg']['strategy_profile'] ?? '') . '`', + '- forge_profile: `' . ($summary['site_strategy']['d_seo_cfg']['forge']['exposure_profile'] ?? '') . '`', + '- register_status: `' . (($summary['site_register_preview']['status'] ?? 'unknown')) . '`', + '- register_template_id: `' . (($summary['site_register_preview']['template_id'] ?? 0)) . '`', + '- preview_status: `' . (($summary['site_apply_preview']['status'] ?? 'unknown')) . '`', + '- preview_changed_fields: `' . (($summary['site_apply_preview']['changed_fields'] ?? 0)) . '`', + '', + '## 本次生成的 scene', + '', + ...$items, + '', + '## 建议先应用的域名策略', + '', + '- `match_type`: `' . ($summary['site_strategy']['d_match_type'] ?? '') . '`', + '- `seed_scope`: `' . ($summary['site_strategy']['d_seed_scope'] ?? '') . '`', + '- `strategy_profile`: `' . ($summary['site_strategy']['d_seo_cfg']['strategy_profile'] ?? '') . '`', + '- `canonical_mode`: `' . ($summary['site_strategy']['d_seo_cfg']['canonical_mode'] ?? '') . '`', + '- `play_index_mode`: `' . ($summary['site_strategy']['d_seo_cfg']['play_index_mode'] ?? '') . '`', + '- `robots_policy`: `' . ($summary['site_strategy']['d_seo_cfg']['robots_policy'] ?? '') . '`', + '- `forge.exposure_profile`: `' . ($summary['site_strategy']['d_seo_cfg']['forge']['exposure_profile'] ?? '') . '`', + '', + '## 下一步', + '', + '1. 优先用 `domain_bootstrap_run.php` 串行执行 `register + apply` dry-run', + '2. dry-run 无误后,再用同一个 run 入口执行正式 `register/apply`', + '3. 如遇边界问题,再退回 `domain_bootstrap_register.php / domain_seo_bootstrap_apply.php` 分步排查', + '4. 补全或改写生成出的 JSON', + '5. 跑 `prepare`', + '6. 跑 `dry-run release_run`', + '7. 通过后再正式 `publish`', + '', + '## 推荐命令', + '', + '- manifest 名称:`' . $manifestArg . '`', + '- 命令脚本:`' . $commandsPath . '`', + '', + '也可以直接执行:', + '', + '```bash', + $summary['next_commands']['run_dry_run'] ?? '', + '# ' . ($summary['next_commands']['run'] ?? ''), + $summary['next_commands']['register_dry_run'] ?? '', + '# ' . ($summary['next_commands']['register'] ?? ''), + $summary['next_commands']['apply_dry_run'] ?? '', + '# ' . ($summary['next_commands']['apply'] ?? ''), + $summary['next_commands']['prepare'] ?? '', + $summary['next_commands']['dry_run'] ?? '', + '# ' . ($summary['next_commands']['publish'] ?? ''), + '```', + '', + ]); +} + +$args = $argv; +array_shift($args); + +if (empty($args)) { + bootstrapUsage(); + exit(1); +} + +$host = ''; +$detailId = ''; +$detailSlug = ''; +$searchKeyword = ''; +$categoryParent = ''; +$categoryChild = ''; +$playType = 'douban'; +$playIndex = '1'; +$forgeIndex = '1'; +$rankSlug = 'daily'; +$manifestName = ''; +$targetRoot = dirname(__DIR__) . '/data/seo_copy'; +$approvedDir = dirname(__DIR__) . '/data/seo_copy_jobs/approved'; +$bundleRoot = ''; +$baseUrl = ''; +$matchType = DomainModel::MATCH_TYPE_EXACT; +$parentDomain = ''; +$seedScope = DomainModel::SEED_SCOPE_DOMAIN; +$strategyProfile = DomainModel::SEO_STRATEGY_PROFILE_STANDARD; +$canonicalMode = null; +$playIndexMode = null; +$robotsPolicy = null; +$structuredPolicy = null; +$templateId = 0; +$siteName = ''; +$siteKeywords = ''; +$siteDescription = ''; +$indexRoot = ''; +$portalRoot = ''; +$bootstrapRunMode = 'off'; +$runRoot = ''; +$force = false; +$customTargetRoot = false; +$customApprovedDir = false; + +foreach ($args as $arg) { + if ($arg === '--force') { + $force = true; + continue; + } + + if (str_starts_with($arg, '--detail-id=')) { + $detailId = trim(substr($arg, strlen('--detail-id='))); + continue; + } + if (str_starts_with($arg, '--detail-slug=')) { + $detailSlug = trim(substr($arg, strlen('--detail-slug='))); + continue; + } + if (str_starts_with($arg, '--search-keyword=')) { + $searchKeyword = trim(substr($arg, strlen('--search-keyword='))); + continue; + } + if (str_starts_with($arg, '--category-parent=')) { + $categoryParent = trim(substr($arg, strlen('--category-parent='))); + continue; + } + if (str_starts_with($arg, '--category-child=')) { + $categoryChild = trim(substr($arg, strlen('--category-child='))); + continue; + } + if (str_starts_with($arg, '--play-type=')) { + $playType = trim(substr($arg, strlen('--play-type='))); + continue; + } + if (str_starts_with($arg, '--play-index=')) { + $playIndex = trim(substr($arg, strlen('--play-index='))); + continue; + } + if (str_starts_with($arg, '--forge-index=')) { + $forgeIndex = trim(substr($arg, strlen('--forge-index='))); + continue; + } + if (str_starts_with($arg, '--rank-slug=')) { + $rankSlug = trim(substr($arg, strlen('--rank-slug='))); + continue; + } + if (str_starts_with($arg, '--manifest-name=')) { + $manifestName = trim(substr($arg, strlen('--manifest-name='))); + continue; + } + if (str_starts_with($arg, '--match-type=')) { + $matchType = trim(substr($arg, strlen('--match-type='))); + continue; + } + if (str_starts_with($arg, '--parent-domain=')) { + $parentDomain = trim(substr($arg, strlen('--parent-domain='))); + continue; + } + if (str_starts_with($arg, '--seed-scope=')) { + $seedScope = trim(substr($arg, strlen('--seed-scope='))); + continue; + } + if (str_starts_with($arg, '--strategy-profile=')) { + $strategyProfile = trim(substr($arg, strlen('--strategy-profile='))); + continue; + } + if (str_starts_with($arg, '--canonical-mode=')) { + $canonicalMode = trim(substr($arg, strlen('--canonical-mode='))); + continue; + } + if (str_starts_with($arg, '--play-index-mode=')) { + $playIndexMode = trim(substr($arg, strlen('--play-index-mode='))); + continue; + } + if (str_starts_with($arg, '--robots-policy=')) { + $robotsPolicy = trim(substr($arg, strlen('--robots-policy='))); + continue; + } + if (str_starts_with($arg, '--structured-policy=')) { + $structuredPolicy = trim(substr($arg, strlen('--structured-policy='))); + continue; + } + if (str_starts_with($arg, '--template-id=')) { + $templateId = (int)trim(substr($arg, strlen('--template-id='))); + continue; + } + if (str_starts_with($arg, '--site-name=')) { + $siteName = trim(substr($arg, strlen('--site-name='))); + continue; + } + if (str_starts_with($arg, '--site-keywords=')) { + $siteKeywords = trim(substr($arg, strlen('--site-keywords='))); + continue; + } + if (str_starts_with($arg, '--site-description=')) { + $siteDescription = trim(substr($arg, strlen('--site-description='))); + continue; + } + if (str_starts_with($arg, '--index-root=')) { + $indexRoot = rtrim(trim(substr($arg, strlen('--index-root='))), '/'); + continue; + } + if (str_starts_with($arg, '--portal-root=')) { + $portalRoot = rtrim(trim(substr($arg, strlen('--portal-root='))), '/'); + continue; + } + if (str_starts_with($arg, '--bootstrap-run=')) { + $bootstrapRunMode = trim(substr($arg, strlen('--bootstrap-run='))); + continue; + } + if (str_starts_with($arg, '--run-root=')) { + $runRoot = rtrim(trim(substr($arg, strlen('--run-root='))), '/'); + continue; + } + if (str_starts_with($arg, '--target-root=')) { + $targetRoot = rtrim(trim(substr($arg, strlen('--target-root='))), '/'); + $customTargetRoot = true; + continue; + } + if (str_starts_with($arg, '--approved-dir=')) { + $approvedDir = rtrim(trim(substr($arg, strlen('--approved-dir='))), '/'); + $customApprovedDir = true; + continue; + } + if (str_starts_with($arg, '--bundle-root=')) { + $bundleRoot = rtrim(trim(substr($arg, strlen('--bundle-root='))), '/'); + continue; + } + if (str_starts_with($arg, '--base-url=')) { + $baseUrl = trim(substr($arg, strlen('--base-url='))); + continue; + } + + if ($host === '') { + $host = trim($arg); + } +} + +if ( + $host === '' || + $detailId === '' || + $detailSlug === '' || + $searchKeyword === '' || + $categoryParent === '' || + $categoryChild === '' +) { + bootstrapUsage(); + exit(1); +} + +$bootstrapRunMode = trim(strtolower($bootstrapRunMode)); +if (!in_array($bootstrapRunMode, ['off', 'dry_run', 'apply'], true)) { + fwrite(STDERR, "Invalid --bootstrap-run value: {$bootstrapRunMode}\n"); + bootstrapUsage(); + exit(1); +} + +if ($bundleRoot !== '') { + if (!$customTargetRoot) { + $targetRoot = $bundleRoot . '/data/seo_copy'; + } + if (!$customApprovedDir) { + $approvedDir = $bundleRoot . '/data/seo_copy_jobs/approved'; + } +} + +if ($bootstrapRunMode !== 'off' && $bundleRoot === '') { + fwrite(STDERR, "--bootstrap-run requires --bundle-root so the script can persist run artifacts.\n"); + exit(1); +} + +$hostDir = SeoCopyBatchPrepareHelper::normalizeHostDir($host); +$manifestTag = $manifestName !== '' + ? preg_replace('/[^a-zA-Z0-9\-_]+/', '-', trim($manifestName)) + : $hostDir . '-core-batch1'; +$manifestFilename = str_ends_with($manifestTag, '.manifest.json') ? $manifestTag : $manifestTag . '.manifest.json'; +$manifestPath = rtrim($approvedDir, '/') . '/' . $manifestFilename; +$baseUrl = $baseUrl !== '' ? $baseUrl : 'https://' . $host; +$codeRoot = dirname(__DIR__); +$manifestArg = preg_replace('/\.manifest\.json$/', '', $manifestFilename); +$manifestArg = preg_replace('/\.json$/', '', (string)$manifestArg); +$siteStrategy = buildDomainBootstrapPayload( + $host, + $matchType, + $parentDomain, + $seedScope, + $strategyProfile, + $canonicalMode, + $playIndexMode, + $robotsPolicy, + $structuredPolicy +); +$siteRegister = buildSiteRegisterPayload( + $host, + $siteStrategy, + $templateId, + $siteName, + $siteKeywords, + $siteDescription +); +$siteRegisterPreview = buildSiteRegisterPreview($siteRegister); +$siteApplyPreview = buildSiteApplyPreview($siteStrategy); + +$items = [ + ['scene' => 'home', 'page_parts' => ['index']], + ['scene' => 'category_index', 'page_parts' => [$categoryParent]], + ['scene' => 'category_list', 'page_parts' => [$categoryParent, $categoryChild]], + ['scene' => 'search', 'page_parts' => [$searchKeyword]], + ['scene' => 'rank_index', 'page_parts' => ['index']], + ['scene' => 'rank_list', 'page_parts' => [$rankSlug]], + ['scene' => 'detail', 'page_parts' => [$detailId]], + ['scene' => 'forge', 'page_parts' => [$detailId, $forgeIndex]], + ['scene' => 'play', 'page_parts' => [$detailId, $playType, $playIndex]], +]; + +$manifest = []; +$fileResults = []; + +foreach ($items as $item) { + $scene = $item['scene']; + $pageParts = $item['page_parts']; + $pageKey = SeoCopySchema::buildScenePageKey($scene, $pageParts); + $facts = SeoCopyFactsBuilder::build($host, $scene, $pageParts); + $template = SeoCopyFallbackBuilder::build($scene, $facts); + $targetPath = rtrim($targetRoot, '/') . '/' . $hostDir . '/' . $scene . '/' . $pageKey . '.json'; + $status = writeJsonIfNeeded($targetPath, $template, $force); + + $manifest[] = [ + 'host' => $host, + 'scene' => $scene, + 'page_parts' => $pageParts, + ]; + + $fileResults[] = [ + 'scene' => $scene, + 'page_key' => $pageKey, + 'target_path' => $targetPath, + 'status' => $status, + ]; +} + +$manifestStatus = writeJsonIfNeeded($manifestPath, $manifest, $force); + +$summary = [ + 'host' => $host, + 'host_dir' => $hostDir, + 'manifest' => $manifestPath, + 'manifest_status' => $manifestStatus, + 'target_root' => $targetRoot, + 'approved_dir' => $approvedDir, + 'bundle_root' => $bundleRoot, + 'base_url' => $baseUrl, + 'items_count' => count($manifest), + 'items' => $fileResults, + 'site_register' => $siteRegister, + 'site_register_preview' => $siteRegisterPreview, + 'site_strategy' => $siteStrategy, + 'site_apply_preview' => $siteApplyPreview, + 'next_commands' => [ + 'run_dry_run' => 'php scripts/domain_bootstrap_run.php ' . ($bundleRoot !== '' ? $bundleRoot : '.') . ' --dry-run=1 --format=text', + 'run' => 'php scripts/domain_bootstrap_run.php ' . ($bundleRoot !== '' ? $bundleRoot : '.') . ' --dry-run=0 --format=text', + 'register_dry_run' => 'php scripts/domain_bootstrap_register.php ' . ($bundleRoot !== '' ? $bundleRoot . '/site-register.sample.json' : './site-register.sample.json') . ' --dry-run=1 --format=text', + 'register' => 'php scripts/domain_bootstrap_register.php ' . ($bundleRoot !== '' ? $bundleRoot . '/site-register.sample.json' : './site-register.sample.json') . ' --format=text', + 'apply_dry_run' => 'php scripts/domain_seo_bootstrap_apply.php ' . ($bundleRoot !== '' ? $bundleRoot . '/site-bootstrap.sample.json' : './site-bootstrap.sample.json') . ' --dry-run=1 --format=text', + 'apply' => 'php scripts/domain_seo_bootstrap_apply.php ' . ($bundleRoot !== '' ? $bundleRoot . '/site-bootstrap.sample.json' : './site-bootstrap.sample.json') . ' --format=text', + 'prepare' => 'php scripts/seo_copy_batch_prepare.php ' . $manifestArg . ' --approved-dir=' . $approvedDir, + 'dry_run' => 'php scripts/seo_copy_release_run.php ' . $manifestArg . ' --approved-dir=' . $approvedDir . ' --source-root=' . $targetRoot . ' --base-url=' . $baseUrl . ' --dry-run=1 --format=text', + 'publish' => 'php scripts/seo_copy_release_run.php ' . $manifestArg . ' --approved-dir=' . $approvedDir . ' --source-root=' . $targetRoot . ' --base-url=' . $baseUrl . ' --format=text', + ], +]; + +if ($bundleRoot !== '') { + ensureDir($bundleRoot); + $summaryPath = $bundleRoot . '/bootstrap-summary.json'; + $commandsPath = $bundleRoot . '/commands.sh'; + $runbookPath = $bundleRoot . '/README.md'; + $manifestCopyPath = $bundleRoot . '/manifest.copy.json'; + $siteRegisterPath = $bundleRoot . '/site-register.sample.json'; + $siteRegisterPreviewPath = $bundleRoot . '/site-register.preview.json'; + $siteBootstrapPath = $bundleRoot . '/site-bootstrap.sample.json'; + $siteApplyPreviewPath = $bundleRoot . '/site-apply.preview.json'; + + $summary['site_register_path'] = $siteRegisterPath; + $summary['site_bootstrap_path'] = $siteBootstrapPath; + $summary['bundle_files'] = [ + 'summary' => $summaryPath, + 'manifest_copy' => $manifestCopyPath, + 'site_register' => $siteRegisterPath, + 'site_register_preview' => $siteRegisterPreviewPath, + 'site_bootstrap' => $siteBootstrapPath, + 'site_apply_preview' => $siteApplyPreviewPath, + 'commands' => $commandsPath, + 'runbook' => $runbookPath, + ]; + + writeTextFile($manifestCopyPath, exportJson($manifest)); + writeTextFile($siteRegisterPath, exportJson($siteRegister)); + writeTextFile($siteRegisterPreviewPath, exportJson($siteRegisterPreview)); + writeTextFile($siteBootstrapPath, exportJson($siteStrategy)); + writeTextFile($siteApplyPreviewPath, exportJson($siteApplyPreview)); + writeTextFile($commandsPath, renderCommandsScript($summary, $codeRoot, $manifestArg)); + @chmod($commandsPath, 0755); + writeTextFile($runbookPath, renderRunbookMarkdown($summary, $manifestArg, $commandsPath)); + writeTextFile($summaryPath, exportJson($summary)); + + if ($indexRoot !== '') { + $arrBundleIndexSummary = DomainBootstrapBundleIndexHelper::buildSummary($indexRoot, true); + $arrBundleIndexArtifacts = DomainBootstrapBundleIndexHelper::writeArtifacts(rtrim($indexRoot, '/') . '/_index', $arrBundleIndexSummary); + $summary['bundle_index'] = [ + 'scan_root' => $indexRoot, + 'bundles_count' => (int)($arrBundleIndexSummary['bundles_count'] ?? 0), + 'json' => $arrBundleIndexArtifacts['json'] ?? '', + 'html' => $arrBundleIndexArtifacts['html'] ?? '', + ]; + if ($portalRoot !== '' && strtolower($portalRoot) !== 'off') { + $arrPortalArtifacts = DomainBootstrapBundleIndexHelper::writeArtifacts(rtrim($portalRoot, '/') . '/bootstrap', $arrBundleIndexSummary); + SeoCopyPortalHomeHelper::writeArtifacts($portalRoot, SeoCopyPortalHomeHelper::buildSummary($portalRoot)); + $summary['bundle_portal'] = [ + 'root' => $portalRoot, + 'json' => $arrPortalArtifacts['json'] ?? '', + 'html' => $arrPortalArtifacts['html'] ?? '', + ]; + } + writeTextFile($summaryPath, exportJson($summary)); + } + + if ($bootstrapRunMode !== '' && $bootstrapRunMode !== 'off') { + $boolDryRun = $bootstrapRunMode !== 'apply'; + $arrRunSummary = DomainBootstrapRunService::runBundle($bundleRoot, [ + 'dry_run' => $boolDryRun, + 'run_root' => $runRoot !== '' ? $runRoot : ($codeRoot . '/storage/domain_bootstrap_runs'), + 'index_root' => $indexRoot !== '' ? $indexRoot : null, + 'portal_root' => $portalRoot !== '' ? $portalRoot : null, + 'run_register' => true, + 'run_apply' => true, + ]); + + $summary['bootstrap_run_mode'] = $bootstrapRunMode; + $summary['bootstrap_run'] = $arrRunSummary; + + $arrLatestBundleSummary = json_decode((string)file_get_contents($summaryPath), true); + if (is_array($arrLatestBundleSummary)) { + $summary = array_merge($summary, $arrLatestBundleSummary); + $summary['bootstrap_run_mode'] = $bootstrapRunMode; + $summary['bootstrap_run'] = $arrRunSummary; + } + + writeTextFile($summaryPath, exportJson($summary)); + } +} + +echo exportJson($summary); diff --git a/code/scripts/seo_copy_domain_bootstrap_batch.php b/code/scripts/seo_copy_domain_bootstrap_batch.php new file mode 100644 index 0000000..cc4485d --- /dev/null +++ b/code/scripts/seo_copy_domain_bootstrap_batch.php @@ -0,0 +1,306 @@ + [--batch-root=/abs/path] [--index-root=/abs/path] [--portal-root=/abs/path|off] [--bootstrap-run=off|dry_run|apply] [--run-root=/abs/path] [--force]\n\n"; + echo "Example:\n"; + echo " php scripts/seo_copy_domain_bootstrap_batch.php /tmp/domain-bootstrap-batch-template/domain-bootstrap-batch-template.xlsx --batch-root=/tmp/domain-bootstrap-batch --index-root=/tmp/domain-bootstrap-bundles --portal-root=public/_seo_copy_release\n"; +} + +function ensureDirBatch(string $dir): void +{ + if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { + throw new RuntimeException('Failed to create directory: ' . $dir); + } +} + +function hostToDir(string $host): string +{ + return SeoCopyBatchPrepareHelper::normalizeHostDir($host); +} + +function normalizeSheetRows(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 ($v): string => trim((string)$v), (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; +} + +function buildSingleCommand(array $row, string $bundleRoot, string $indexRoot, string $portalRoot, string $bootstrapRun, string $runRoot, bool $force): string +{ + $codeRoot = dirname(__DIR__); + $parts = [ + 'php', + escapeshellarg($codeRoot . '/scripts/seo_copy_domain_bootstrap.php'), + escapeshellarg((string)$row['host']), + '--detail-id=' . escapeshellarg((string)$row['detail_id']), + '--detail-slug=' . escapeshellarg((string)$row['detail_slug']), + '--search-keyword=' . escapeshellarg((string)$row['search_keyword']), + '--category-parent=' . escapeshellarg((string)$row['category_parent']), + '--category-child=' . escapeshellarg((string)$row['category_child']), + '--bundle-root=' . escapeshellarg($bundleRoot), + '--index-root=' . escapeshellarg($indexRoot), + '--portal-root=' . escapeshellarg($portalRoot), + '--bootstrap-run=' . escapeshellarg($bootstrapRun), + ]; + + $optionalMap = [ + 'play_type' => '--play-type=', + 'play_index' => '--play-index=', + 'forge_index' => '--forge-index=', + 'rank_slug' => '--rank-slug=', + 'match_type' => '--match-type=', + 'parent_domain' => '--parent-domain=', + 'seed_scope' => '--seed-scope=', + 'strategy_profile' => '--strategy-profile=', + 'template_id' => '--template-id=', + 'site_name' => '--site-name=', + 'site_keywords' => '--site-keywords=', + 'site_description' => '--site-description=', + 'base_url' => '--base-url=', + ]; + + foreach ($optionalMap as $key => $prefix) { + $value = trim((string)($row[$key] ?? '')); + if ($value !== '') { + $parts[] = $prefix . escapeshellarg($value); + } + } + + if ($runRoot !== '') { + $parts[] = '--run-root=' . escapeshellarg($runRoot); + } + if ($force) { + $parts[] = '--force'; + } + + return implode(' ', $parts); +} + +function renderBatchSummaryHtml(array $summary): string +{ + $rows = []; + foreach ((array)($summary['items'] ?? []) as $item) { + $rows[] = '' + . '' . htmlspecialchars((string)($item['host'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['bundle_root'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['strategy_profile'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['status'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['message'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['command'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . ''; + } + + return implode(PHP_EOL, [ + '', + '', + '', + '', + 'Domain Bootstrap Batch Summary', + '', + '', + '', + '

    Domain Bootstrap Batch Summary

    ', + '

    template: ' . htmlspecialchars((string)($summary['template_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '

    ', + '

    batch_root: ' . htmlspecialchars((string)($summary['batch_root'] ?? ''), ENT_QUOTES, 'UTF-8') . '

    ', + '

    processed: ' . (int)($summary['processed_count'] ?? 0) . ' / success: ' . (int)($summary['success_count'] ?? 0) . ' / failed: ' . (int)($summary['failed_count'] ?? 0) . '

    ', + '', + ...$rows, + '
    HostBundle RootStrategyStatusMessageCommand
    ', + '', + '', + ]) . PHP_EOL; +} + +$args = $argv; +array_shift($args); + +$templatePath = ''; +$batchRoot = dirname(__DIR__) . '/storage/domain_bootstrap_batch'; +$indexRoot = dirname(__DIR__) . '/storage/domain_bootstrap_bundles'; +$portalRoot = dirname(__DIR__) . '/public/_seo_copy_release'; +$bootstrapRun = 'off'; +$runRoot = ''; +$force = false; + +foreach ($args as $arg) { + if ($arg === '--force') { + $force = true; + continue; + } + if (str_starts_with($arg, '--batch-root=')) { + $batchRoot = rtrim(trim(substr($arg, strlen('--batch-root='))), '/'); + continue; + } + if (str_starts_with($arg, '--index-root=')) { + $indexRoot = rtrim(trim(substr($arg, strlen('--index-root='))), '/'); + continue; + } + if (str_starts_with($arg, '--portal-root=')) { + $portalRoot = rtrim(trim(substr($arg, strlen('--portal-root='))), '/'); + continue; + } + if (str_starts_with($arg, '--bootstrap-run=')) { + $bootstrapRun = trim(substr($arg, strlen('--bootstrap-run='))); + continue; + } + if (str_starts_with($arg, '--run-root=')) { + $runRoot = rtrim(trim(substr($arg, strlen('--run-root='))), '/'); + continue; + } + if ($templatePath === '') { + $templatePath = trim($arg); + } +} + +if ($templatePath === '') { + printBatchUsage(); + exit(1); +} + +$bootstrapRun = strtolower(trim($bootstrapRun)); +if (!in_array($bootstrapRun, ['off', 'dry_run', 'apply'], true)) { + fwrite(STDERR, "Invalid --bootstrap-run value: {$bootstrapRun}\n"); + printBatchUsage(); + exit(1); +} + +if (!is_file($templatePath)) { + fwrite(STDERR, "Template file not found: {$templatePath}\n"); + exit(1); +} + +ensureDirBatch($batchRoot); +ensureDirBatch($indexRoot); +if ($portalRoot !== '' && strtolower($portalRoot) !== 'off') { + ensureDirBatch($portalRoot); +} + +$rows = normalizeSheetRows($templatePath); +$summaryItems = []; +$successCount = 0; +$failedCount = 0; + +foreach ($rows as $row) { + $host = trim((string)($row['host'] ?? '')); + if ($host === '') { + continue; + } + + $hostDir = hostToDir($host); + $bundleName = trim((string)($row['bundle_name'] ?? '')); + $bundleRoot = $batchRoot . '/' . ($bundleName !== '' ? $bundleName : $hostDir); + $command = buildSingleCommand($row, $bundleRoot, $indexRoot, $portalRoot !== '' ? $portalRoot : 'off', $bootstrapRun, $runRoot, $force); + + $output = []; + $exitCode = 0; + exec($command . ' 2>&1', $output, $exitCode); + $joined = trim(implode(PHP_EOL, $output)); + $decoded = json_decode($joined, true); + + $status = $exitCode === 0 ? 'success' : 'failed'; + $message = $exitCode === 0 ? 'bundle_generated' : ($joined !== '' ? $joined : 'command_failed'); + if (is_array($decoded)) { + $message = (string)($decoded['manifest_status'] ?? $message); + } + + if ($exitCode === 0) { + $successCount++; + } else { + $failedCount++; + } + + $summaryItems[] = [ + 'host' => $host, + 'bundle_root' => $bundleRoot, + 'strategy_profile' => (string)($row['strategy_profile'] ?? ''), + 'status' => $status, + 'message' => $message, + 'command' => $command, + 'exit_code' => $exitCode, + 'output' => $output, + ]; +} + +$bundleIndexSummary = DomainBootstrapBundleIndexHelper::buildSummary($indexRoot, true); +$bundleIndexArtifacts = DomainBootstrapBundleIndexHelper::writeArtifacts(rtrim($indexRoot, '/') . '/_index', $bundleIndexSummary); +$portalArtifacts = []; +if ($portalRoot !== '' && strtolower($portalRoot) !== 'off') { + $portalArtifacts = DomainBootstrapBundleIndexHelper::writeArtifacts(rtrim($portalRoot, '/') . '/bootstrap', $bundleIndexSummary); + SeoCopyPortalHomeHelper::writeArtifacts($portalRoot, SeoCopyPortalHomeHelper::buildSummary($portalRoot)); +} + +$summary = [ + 'template_path' => $templatePath, + 'batch_root' => $batchRoot, + 'index_root' => $indexRoot, + 'portal_root' => $portalRoot, + 'bootstrap_run' => $bootstrapRun, + 'processed_count' => count($summaryItems), + 'success_count' => $successCount, + 'failed_count' => $failedCount, + 'items' => $summaryItems, + 'bundle_index' => [ + 'bundles_count' => (int)($bundleIndexSummary['bundles_count'] ?? 0), + 'json' => $bundleIndexArtifacts['json'] ?? '', + 'html' => $bundleIndexArtifacts['html'] ?? '', + ], + 'bundle_portal' => [ + 'json' => $portalArtifacts['json'] ?? '', + 'html' => $portalArtifacts['html'] ?? '', + ], +]; + +$summaryJsonPath = rtrim($batchRoot, '/') . '/batch.summary.json'; +$summaryHtmlPath = rtrim($batchRoot, '/') . '/batch.summary.html'; +file_put_contents($summaryJsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL); +file_put_contents($summaryHtmlPath, renderBatchSummaryHtml($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); + +echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_domain_supply_batch.php b/code/scripts/seo_copy_domain_supply_batch.php new file mode 100644 index 0000000..d809470 --- /dev/null +++ b/code/scripts/seo_copy_domain_supply_batch.php @@ -0,0 +1,99 @@ + [--output-root=/abs/path] [--run-bootstrap=0|1] [--batch-root=/abs/path] [--index-root=/abs/path] [--portal-root=/abs/path|off] [--bootstrap-run=off|dry_run|apply] [--run-root=/abs/path] [--force]\n\n"; + echo "Example:\n"; + echo " php scripts/seo_copy_domain_supply_batch.php /tmp/domain-supply-batch-template/domain-supply-batch-template.xlsx --output-root=/tmp/domain-supply-batch --run-bootstrap=1 --portal-root=off\n"; +} + +$args = $argv; +array_shift($args); + +$templatePath = ''; +$outputRoot = dirname(__DIR__) . '/storage/domain_supply_batch'; +$runBootstrap = false; +$batchRoot = ''; +$indexRoot = dirname(__DIR__) . '/storage/domain_bootstrap_bundles'; +$indexRootProvided = false; +$portalRoot = dirname(__DIR__) . '/public/_seo_copy_release'; +$bootstrapRun = 'off'; +$runRoot = ''; +$force = false; + +foreach ($args as $arg) { + if ($arg === '--force') { + $force = true; + continue; + } + if (str_starts_with($arg, '--output-root=')) { + $outputRoot = rtrim(trim(substr($arg, strlen('--output-root='))), '/'); + continue; + } + if (str_starts_with($arg, '--run-bootstrap=')) { + $value = strtolower(trim(substr($arg, strlen('--run-bootstrap=')))); + $runBootstrap = in_array($value, ['1', 'true', 'yes', 'on'], true); + continue; + } + if (str_starts_with($arg, '--batch-root=')) { + $batchRoot = rtrim(trim(substr($arg, strlen('--batch-root='))), '/'); + continue; + } + if (str_starts_with($arg, '--index-root=')) { + $indexRoot = rtrim(trim(substr($arg, strlen('--index-root='))), '/'); + $indexRootProvided = true; + continue; + } + if (str_starts_with($arg, '--portal-root=')) { + $portalRoot = rtrim(trim(substr($arg, strlen('--portal-root='))), '/'); + continue; + } + if (str_starts_with($arg, '--bootstrap-run=')) { + $bootstrapRun = trim(substr($arg, strlen('--bootstrap-run='))); + continue; + } + if (str_starts_with($arg, '--run-root=')) { + $runRoot = rtrim(trim(substr($arg, strlen('--run-root='))), '/'); + continue; + } + if (in_array($arg, ['-h', '--help'], true)) { + printSupplyBatchUsage(); + exit(0); + } + if ($templatePath === '') { + $templatePath = trim($arg); + } +} + +if ($templatePath === '') { + printSupplyBatchUsage(); + exit(1); +} + +if (!is_file($templatePath)) { + fwrite(STDERR, "Template file not found: {$templatePath}\n"); + exit(1); +} +$summary = DomainSupplyBatchHelper::process($templatePath, [ + 'output_root' => $outputRoot, + 'run_bootstrap' => $runBootstrap, + 'batch_root' => $batchRoot, + 'index_root' => $indexRootProvided ? $indexRoot : '', + 'portal_root' => $portalRoot, + 'bootstrap_run' => $bootstrapRun, + 'run_root' => $runRoot, + 'force' => $force, +]); + +echo json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_domain_supply_orchestration_run.php b/code/scripts/seo_copy_domain_supply_orchestration_run.php new file mode 100644 index 0000000..463462e --- /dev/null +++ b/code/scripts/seo_copy_domain_supply_orchestration_run.php @@ -0,0 +1,85 @@ + date('c'), + 'status' => in_array((string)($arrPlan['label'] ?? ''), ['先回看任务', '先回看运行', '先排异常'], true) ? 'needs_attention' : 'success', + 'hosts_count' => (int)($arrOrchestration['hosts_count'] ?? 0), + 'failed_hosts_count' => count(array_filter((array)($arrSupplySummary['host_totals'] ?? []), static function (array $arrHost): bool { + return (int)($arrHost['failed_count'] ?? 0) > 0; + })), + 'latest_run' => (array)($arrSupplySummary['latest_run'] ?? []), + 'latest_failed_host' => (array)($arrSupplySummary['latest_failed_host'] ?? []), + 'plan' => $arrPlan, + 'buckets' => array_values(array_filter((array)($arrOrchestration['buckets'] ?? []), 'is_array')), +]; + +if ($strFormat === 'text') { + echo 'status: ' . (string)($arrResult['status'] ?? '') . PHP_EOL; + echo 'hosts_count: ' . (int)($arrResult['hosts_count'] ?? 0) . PHP_EOL; + echo 'failed_hosts_count: ' . (int)($arrResult['failed_hosts_count'] ?? 0) . PHP_EOL; + echo 'latest_run: ' . (string)(((array)($arrResult['latest_run'] ?? []))['run_id'] ?? '') . PHP_EOL; + echo 'plan_label: ' . (string)(((array)($arrResult['plan'] ?? []))['label'] ?? '') . PHP_EOL; + echo 'plan_title: ' . (string)(((array)($arrResult['plan'] ?? []))['title'] ?? '') . PHP_EOL; + echo 'plan_lead_host: ' . (string)(((array)($arrResult['plan'] ?? []))['lead_host'] ?? '') . PHP_EOL; + echo 'plan_bucket: ' . (string)(((array)($arrResult['plan'] ?? []))['bucket_key'] ?? '') . PHP_EOL; + foreach ((array)($arrResult['buckets'] ?? []) as $arrBucket) { + if (!is_array($arrBucket)) { + continue; + } + echo '- ' . (string)($arrBucket['key'] ?? '') + . ' count=' . (int)($arrBucket['count'] ?? 0) + . ' lead=' . (string)($arrBucket['lead_host'] ?? '') + . ' ops=' . (string)($arrBucket['resolved_ops_panel'] ?? '') . '/' . (string)($arrBucket['resolved_host_console_panel'] ?? '') + . PHP_EOL; + } +} else { + echo json_encode($arrResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} + +exit(((string)($arrResult['status'] ?? '')) === 'success' ? 0 : 1); diff --git a/code/scripts/seo_copy_facts.php b/code/scripts/seo_copy_facts.php new file mode 100644 index 0000000..14ef7e2 --- /dev/null +++ b/code/scripts/seo_copy_facts.php @@ -0,0 +1,49 @@ + [page-part ...]\n\n"; + echo "Examples:\n"; + echo " php scripts/seo_copy_facts.php chuanjiafeng.net home index\n"; + echo " php scripts/seo_copy_facts.php chuanjiafeng.net category_list dian-ying shao-shi-dian-ying 1\n"; + echo " php scripts/seo_copy_facts.php chuanjiafeng.net search 相逢 1\n"; + echo " php scripts/seo_copy_facts.php chuanjiafeng.net detail 154229\n"; + echo " php scripts/seo_copy_facts.php chuanjiafeng.net play 154229 douban 1\n\n"; + echo "Supported scenes:\n"; + echo " {$strScenes}\n"; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (count($arrArgs) < 3) { + printUsage(); + exit(1); +} + +$strHost = (string)array_shift($arrArgs); +$strScene = (string)array_shift($arrArgs); + +if (!in_array($strScene, SeoCopySchema::getSupportedScenes(), true)) { + fwrite(STDERR, "Unsupported scene: {$strScene}\n"); + printUsage(); + exit(1); +} + +(new App())->initialize(); + +$arrFacts = SeoCopyFactsBuilder::build($strHost, $strScene, $arrArgs); +echo json_encode($arrFacts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_forge_output_file_verify.php b/code/scripts/seo_copy_forge_output_file_verify.php new file mode 100644 index 0000000..288bb51 --- /dev/null +++ b/code/scripts/seo_copy_forge_output_file_verify.php @@ -0,0 +1,288 @@ + [--expand-sitemap-count=2] [--format=json|text]\n"; +} + +function ensureAppInitialized(): void +{ + static $boolInitialized = false; + if ($boolInitialized) { + return; + } + + (new App())->initialize(); + $boolInitialized = true; +} + +function renderText(array $arrSummary): string +{ + $arrLines = [ + 'host: ' . $arrSummary['host'], + 'video_id: ' . $arrSummary['video_id'], + 'slug: ' . $arrSummary['slug'], + 'default_json_count: ' . $arrSummary['default_json_count'], + 'expanded_json_count: ' . $arrSummary['expanded_json_count'], + 'default_xml_entries: ' . $arrSummary['default_xml_entries'], + 'expanded_xml_entries: ' . $arrSummary['expanded_xml_entries'], + 'all_passed: ' . ($arrSummary['all_passed'] ? 'yes' : 'no'), + 'checks:', + ]; + + foreach ($arrSummary['checks'] as $arrCheck) { + $arrLines[] = '- ' . ($arrCheck['passed'] ? '[ok] ' : '[fail] ') . $arrCheck['label'] . ': ' . $arrCheck['detail']; + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +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; + } + }; +} + +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'; + + $mXmlRaw = is_file($strXmlFile) ? file_get_contents($strXmlFile) : ''; + $mTxtRaw = is_file($strTxtFile) ? file_get_contents($strTxtFile) : ''; + $mJsonRaw = is_file($strJsonFile) ? file_get_contents($strJsonFile) : ''; + $strXml = is_string($mXmlRaw) ? $mXmlRaw : ''; + $strTxt = is_string($mTxtRaw) ? $mTxtRaw : ''; + $strJson = is_string($mJsonRaw) ? $mJsonRaw : ''; + + preg_match_all('#([^<]+)#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, + ]; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (count($arrArgs) < 2) { + printUsage(); + exit(1); +} + +$strHost = ''; +$intVideoId = 0; +$intExpandedSitemapCount = 2; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--expand-sitemap-count=')) { + $intExpandedSitemapCount = max(0, (int)substr($strArg, strlen('--expand-sitemap-count='))); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + continue; + } + if ($intVideoId === 0) { + $intVideoId = (int)$strArg; + continue; + } +} + +if ($strHost === '' || $intVideoId <= 0) { + printUsage(); + exit(1); +} + +ensureAppInitialized(); + +/** @var DomainModel|null $DomainRow */ +$DomainRow = app(DomainModel::class)->where('d_domain', $strHost)->find(); +if (empty($DomainRow)) { + fwrite(STDERR, "Domain not found: {$strHost}\n"); + exit(1); +} + +$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_seo_words' => 1, + ], +]) ?? []; + +$strSlug = trim((string)($arrVideo['v_name_en'] ?? '')); +if ($strSlug === '') { + fwrite(STDERR, "Video slug empty: {$intVideoId}\n"); + exit(1); +} + +$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 ($intForgeId = 1; $intForgeId <= $intExpandedSitemapCount; $intForgeId++) { + $arrExpectedExpandedUrls[] = 'https://www.' . $strHost . $UrlBuilder->detailForge($strSlug, $intVideoId, $intForgeId); +} + +$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); + +$DefaultDomain = $DomainRow->clone(); +$arrDefaultSeoCfg = $DefaultDomain->getSeoCfg(); +$arrDefaultSeoCfg['forge']['enable'] = false; +$arrDefaultSeoCfg['forge']['count'] = 0; +$arrDefaultSeoCfg['forge']['entry_count'] = 0; +$arrDefaultSeoCfg['forge']['sitemap_count'] = 0; +$DefaultDomain->setAttr('d_seo_cfg', $arrDefaultSeoCfg); +$DefaultLogic = buildProbeLogic($DefaultDomain, $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 = 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 = collectFileSummary($strDefaultRoot, $strHost); +$arrExpandedFiles = 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), +]; + +$boolAllPassed = !in_array(false, array_column($arrChecks, 'passed'), true); + +$arrSummary = [ + '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' => $boolAllPassed, + 'verified_at' => date(DATE_ATOM), +]; + +if ($strFormat === 'text') { + echo renderText($arrSummary); + exit($boolAllPassed ? 0 : 1); +} + +echo json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL; +exit($boolAllPassed ? 0 : 1); diff --git a/code/scripts/seo_copy_forge_output_verify.php b/code/scripts/seo_copy_forge_output_verify.php new file mode 100644 index 0000000..c61e183 --- /dev/null +++ b/code/scripts/seo_copy_forge_output_verify.php @@ -0,0 +1,290 @@ + [--expand-sitemap-count=2] [--expand-entry-count=4] [--base-url=https://host] [--format=json|text]\n"; +} + +function ensureAppInitialized(): void +{ + static $boolInitialized = false; + if ($boolInitialized) { + return; + } + + (new App())->initialize(); + $boolInitialized = true; +} + +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, + ]; +} + +function extractForgeEntryIds(string $strDetailPath, string $strBody): int +{ + $arrMatches = []; + $strEscapedDetailPath = preg_quote($strDetailPath, '#'); + + // 兼容常见 detail_forge 链接样式: + // 1) /detail/slug/153511-1 + // 2) /detail/slug/153511/1 + // 3) /detail/slug-153511-1 或其他常见变体 + $strPattern = '#href="[^"]*' . $strEscapedDetailPath + . '(?:-(?P\d+)|/(?P\d+)|\.(?P\d+))?"#u'; + + if (!preg_match_all($strPattern, $strBody, $arrMatches)) { + return 0; + } + + $arrEntryIds = []; + foreach ($arrMatches['dashId'] ?? [] as $strDashId) { + $strDashId = (string)$strDashId; + if ($strDashId !== '' && ctype_digit($strDashId)) { + $arrEntryIds[(int)$strDashId] = true; + } + } + foreach ($arrMatches['slashId'] ?? [] as $strSlashId) { + $strSlashId = (string)$strSlashId; + if ($strSlashId !== '' && ctype_digit($strSlashId)) { + $arrEntryIds[(int)$strSlashId] = true; + } + } + foreach ($arrMatches['extId'] ?? [] as $strExtId) { + $strExtId = (string)$strExtId; + if ($strExtId !== '' && ctype_digit($strExtId)) { + $arrEntryIds[(int)$strExtId] = true; + } + } + + return count($arrEntryIds); +} + +function renderText(array $arrSummary): string +{ + $arrLines = [ + 'host: ' . $arrSummary['host'], + 'video_id: ' . $arrSummary['video_id'], + 'slug: ' . $arrSummary['slug'], + 'entry_count_current: ' . $arrSummary['entry_count_current'], + 'entry_links_detected: ' . $arrSummary['entry_links_detected'], + 'sitemap_count_current: ' . $arrSummary['sitemap_count_current'], + 'current_output_kinds: ' . implode(',', $arrSummary['current_output_kinds']), + 'expanded_sitemap_count: ' . $arrSummary['expanded_sitemap_count'], + 'expanded_output_kinds: ' . implode(',', $arrSummary['expanded_output_kinds']), + 'all_passed: ' . ($arrSummary['all_passed'] ? 'yes' : 'no'), + 'checks:', + ]; + + foreach ($arrSummary['checks'] as $arrCheck) { + $arrLines[] = '- ' . ($arrCheck['passed'] ? '[ok] ' : '[fail] ') . $arrCheck['label'] . ': ' . $arrCheck['detail']; + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (count($arrArgs) < 2) { + printUsage(); + exit(1); +} + +$strHost = ''; +$intVideoId = 0; +$intExpandedSitemapCount = 2; +$intExpandedEntryCount = 4; +$strBaseUrl = ''; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--expand-sitemap-count=')) { + $intExpandedSitemapCount = max(0, (int)substr($strArg, strlen('--expand-sitemap-count='))); + continue; + } + if (str_starts_with($strArg, '--expand-entry-count=')) { + $intExpandedEntryCount = max(0, (int)substr($strArg, strlen('--expand-entry-count='))); + continue; + } + if (str_starts_with($strArg, '--base-url=')) { + $strBaseUrl = rtrim(substr($strArg, strlen('--base-url=')), '/'); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + continue; + } + if ($intVideoId === 0) { + $intVideoId = (int)$strArg; + continue; + } +} + +if ($strHost === '' || $intVideoId <= 0) { + printUsage(); + exit(1); +} + +ensureAppInitialized(); + +/** @var DomainModel|null $DomainRow */ +$DomainRow = app(DomainModel::class)->where('d_domain', $strHost)->find(); +if (empty($DomainRow)) { + fwrite(STDERR, "Domain not found: {$strHost}\n"); + exit(1); +} + +$strBaseUrl = $strBaseUrl !== '' ? $strBaseUrl : ('https://' . $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_seo_words' => 1, + ], +]) ?? []; + +$strSlug = trim((string)($arrVideo['v_name_en'] ?? '')); +if ($strSlug === '') { + fwrite(STDERR, "Video slug empty: {$intVideoId}\n"); + exit(1); +} + +$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 = fetchPage($strBaseUrl . $strDetailPath, $strHost); +$intEntryLinksDetected = extractForgeEntryIds($strDetailPath, (string)$arrDetailResponse['body']); + +$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]'), + ]; +} + +$boolAllPassed = !in_array(false, array_column($arrChecks, 'passed'), true); + +$arrSummary = [ + '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' => $boolAllPassed, + 'verified_at' => date(DATE_ATOM), +]; + +if ($strFormat === 'text') { + echo renderText($arrSummary); + exit($boolAllPassed ? 0 : 1); +} + +echo json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL; +exit($boolAllPassed ? 0 : 1); diff --git a/code/scripts/seo_copy_forge_route_group_verify.php b/code/scripts/seo_copy_forge_route_group_verify.php new file mode 100644 index 0000000..d19c780 --- /dev/null +++ b/code/scripts/seo_copy_forge_route_group_verify.php @@ -0,0 +1,365 @@ + [--base-url=https://host] [--expected-group=video|film|detail_forge_v2] [--target-root=/abs/path] [--format=json|text]\n\n"; + echo "Example:\n"; + echo " php scripts/seo_copy_forge_route_group_verify.php chuanjiafeng.net 154124 1 --format=text\n"; +} + +function ensureAppInitialized(): void +{ + static $boolInitialized = false; + if ($boolInitialized) { + return; + } + + (new App())->initialize(); + $boolInitialized = true; +} + +function fetchPage(string $strUrl, string $strHost): array +{ + $arrHeaders = [ + 'Host: ' . $strHost, + 'Connection: close', + ]; + + $Context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => implode("\r\n", $arrHeaders), + 'ignore_errors' => true, + 'timeout' => 20, + ], + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ]); + + $strBody = @file_get_contents($strUrl, false, $Context); + $arrResponseHeaders = $http_response_header ?? []; + $intStatus = 0; + + foreach ($arrResponseHeaders 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, + 'headers' => $arrResponseHeaders, + ]; +} + +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); +} + +function extractLeadingRouteToken(string $strPattern): string +{ + $strPattern = ltrim(trim($strPattern), '/'); + if ($strPattern === '') { + return ''; + } + + if (!preg_match('/^([^\/\{-]+)/', $strPattern, $arrMatches)) { + return ''; + } + + return trim((string)($arrMatches[1] ?? '')); +} + +function renderText(array $arrSummary): string +{ + $arrLines = [ + 'host: ' . $arrSummary['host'], + 'video_id: ' . $arrSummary['video_id'], + 'forge_id: ' . $arrSummary['forge_id'], + 'slug: ' . $arrSummary['slug'], + 'forge_group: ' . $arrSummary['forge_group'], + 'expected_group: ' . $arrSummary['expected_group'], + 'detail_path: ' . $arrSummary['detail_path'], + 'forge_path: ' . $arrSummary['forge_path'], + 'family_pattern: ' . $arrSummary['family_pattern'], + 'family_route: ' . $arrSummary['family_route'], + 'detail_resolved_page: ' . ($arrSummary['detail_resolved_page'] ?: '-'), + 'forge_resolved_page: ' . ($arrSummary['forge_resolved_page'] ?: '-'), + 'detail_status: ' . $arrSummary['detail_status'], + 'forge_status: ' . $arrSummary['forge_status'], + 'all_passed: ' . ($arrSummary['all_passed'] ? 'yes' : 'no'), + 'checks:', + ]; + + foreach ($arrSummary['checks'] as $arrCheck) { + $arrLines[] = '- ' . ($arrCheck['passed'] ? '[ok] ' : '[fail] ') . $arrCheck['label'] . ': ' . $arrCheck['detail']; + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (count($arrArgs) < 3) { + printUsage(); + exit(1); +} + +$strHost = ''; +$intVideoId = 0; +$intForgeId = 0; +$strBaseUrl = ''; +$strExpectedGroup = ''; +$strTargetRoot = dirname(__DIR__) . '/data/seo_copy'; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--base-url=')) { + $strBaseUrl = rtrim(substr($strArg, strlen('--base-url=')), '/'); + continue; + } + if (str_starts_with($strArg, '--expected-group=')) { + $strExpectedGroup = trim(substr($strArg, strlen('--expected-group='))); + continue; + } + if (str_starts_with($strArg, '--target-root=')) { + $strTargetRoot = trim(substr($strArg, strlen('--target-root='))); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + continue; + } + if ($intVideoId === 0) { + $intVideoId = (int)$strArg; + continue; + } + if ($intForgeId === 0) { + $intForgeId = max(1, (int)$strArg); + continue; + } +} + +if ($strHost === '' || $intVideoId <= 0 || $intForgeId <= 0) { + printUsage(); + exit(1); +} + +ensureAppInitialized(); + +/** @var DomainModel|null $DomainRow */ +$DomainRow = app(DomainModel::class)->where('d_domain', $strHost)->find(); +if (empty($DomainRow)) { + fwrite(STDERR, "Domain not found: {$strHost}\n"); + exit(1); +} + +$strBaseUrl = $strBaseUrl !== '' ? $strBaseUrl : ('https://' . $strHost); +$arrForgeSeoCfg = $DomainRow->getForgeSeoCfg(); +$strActualGroup = DomainModel::normalizeForgeRouteGroup((string)($arrForgeSeoCfg['route_group'] ?? DomainModel::FORGE_ROUTE_GROUP_DEFAULT)); +$strExpectedGroup = $strExpectedGroup !== '' + ? DomainModel::normalizeForgeRouteGroup($strExpectedGroup) + : $strActualGroup; + +$TpStyle = SiteStyle::getConfig($DomainRow, $strHost); +$UrlBuilder = new UrlBuilder($TpStyle); +$arrVideo = VideoModel::getInstance()->findOne(['v_id' => $intVideoId], [ + 'typeMap' => [ + 'root' => 'array', + 'document' => 'array', + 'array' => 'array', + ], + 'projection' => [ + '_id' => 0, + 'v_name_en' => 1, + ], +]) ?? []; + +$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 = extractLeadingRouteToken($strFamilyPattern); +$strExpectedPrefix = DomainModel::resolveForgeRouteGroupPrefix($strExpectedGroup); + +$arrDetailResponse = fetchPage($strBaseUrl . $strDetailPath, $strHost); +$arrForgeResponse = fetchPage($strBaseUrl . $strForgePath, $strHost); +$strRawDetailBody = (string)($arrDetailResponse['body'] ?? ''); +$strRawForgeBody = (string)($arrForgeResponse['body'] ?? ''); +$strNormalizedDetailBody = normalizeForMatch((string)($arrDetailResponse['body'] ?? '')); +$strNormalizedForgeBody = 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, normalizeForMatch($strNeedle)), + 'detail' => mb_substr($strNeedle, 0, 80), + ]; + } +} + +$boolAllPassed = !in_array(false, array_column($arrChecks, 'passed'), true); + +$arrSummary = [ + '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' => $boolAllPassed, + 'verified_at' => date(DATE_ATOM), +]; + +if ($strFormat === 'text') { + echo renderText($arrSummary); + exit($boolAllPassed ? 0 : 1); +} + +echo json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL; +exit($boolAllPassed ? 0 : 1); diff --git a/code/scripts/seo_copy_front_verify.php b/code/scripts/seo_copy_front_verify.php new file mode 100644 index 0000000..de77b36 --- /dev/null +++ b/code/scripts/seo_copy_front_verify.php @@ -0,0 +1,280 @@ + [page-part ...] [--target-root=/abs/path] [--path=/target/url] [--base-url=http://127.0.0.1:8910] [--format=json|text]\n\n"; + echo "Examples:\n"; + echo " php scripts/seo_copy_front_verify.php chuanjiafeng.net search 相逢 --target-root=/tmp/seo-import-dst --base-url=http://127.0.0.1:8910 --format=text\n"; + echo " php scripts/seo_copy_front_verify.php chuanjiafeng.net play 154229 douban 1 --path=/m3u8/154229-douban-1 --base-url=http://127.0.0.1:8910 --format=text\n"; +} + +function buildScenePath(string $strHost, string $strScene, array $arrPageParts): string +{ + static $boolInitialized = false; + + if (!$boolInitialized) { + (new App())->initialize(); + $boolInitialized = true; + } + + /** @var DomainModel|null $DomainRow */ + $DomainRow = app(DomainModel::class)->where('d_domain', $strHost)->find(); + if (empty($DomainRow)) { + return ''; + } + + $TpStyle = SiteStyle::getConfig($DomainRow, $strHost); + $UrlBuilder = new UrlBuilder($TpStyle); + + if (in_array($strScene, ['detail', 'forge', 'play'], true)) { + $intVideoId = (int)($arrPageParts[0] ?? 0); + $arrVideo = VideoModel::getInstance()->findOne(['v_id' => $intVideoId], [ + 'typeMap' => [ + 'root' => 'array', + 'document' => 'array', + 'array' => 'array', + ], + 'projection' => [ + '_id' => 0, + 'v_name_en' => 1, + ], + ]) ?? []; + $strSlug = trim((string)($arrVideo['v_name_en'] ?? '')); + + return match ($strScene) { + 'detail' => $UrlBuilder->detail($strSlug, $intVideoId), + 'forge' => $UrlBuilder->detailForge($strSlug, $intVideoId, max(1, (int)($arrPageParts[1] ?? 1))), + 'play' => $UrlBuilder->play($strSlug, $intVideoId, (string)($arrPageParts[1] ?? ''), max(1, (int)($arrPageParts[2] ?? 1))), + default => '', + }; + } + + return match ($strScene) { + 'home' => $UrlBuilder->home(), + 'category_index' => $UrlBuilder->categoryParent((string)($arrPageParts[0] ?? '')), + 'category_list' => $UrlBuilder->categoryChild((string)($arrPageParts[0] ?? ''), (string)($arrPageParts[1] ?? ''), max(1, (int)($arrPageParts[2] ?? 1))), + 'search' => $UrlBuilder->searchResult((string)($arrPageParts[0] ?? '')), + 'rank_index' => $UrlBuilder->rankIndex(), + 'rank_list' => $UrlBuilder->rankList((string)($arrPageParts[0] ?? 'daily')), + default => '', + }; +} + +function buildNeedles(string $strScene, array $arrData): array +{ + return match ($strScene) { + 'home', + 'category_index', + 'category_list', + 'search', + 'rank_index', + 'rank_list' => array_values(array_filter([ + trim((string)($arrData['intro_text'] ?? '')), + trim((string)($arrData['intro_meta'] ?? '')), + trim((string)($arrData['faq_content'] ?? '')), + trim((string)($arrData['guide_cards'][0]['title'] ?? '')), + ])), + 'detail' => array_values(array_filter([ + trim((string)($arrData['detail_body_lead'] ?? '')), + trim((string)($arrData['detail_play_link_lead'] ?? '')), + ])), + 'forge' => array_values(array_filter([ + trim((string)($arrData['forge_detail_note'] ?? '')), + trim((string)($arrData['forge_body_lead'] ?? '')), + ])), + 'play' => array_values(array_filter([ + trim((string)($arrData['play_intro'] ?? '')), + trim((string)($arrData['play_body_lead'] ?? '')), + ])), + default => [], + }; +} + +function fetchPage(string $strUrl, string $strHost): array +{ + $arrHeaders = [ + 'Host: ' . $strHost, + 'Connection: close', + ]; + $Context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => implode("\r\n", $arrHeaders), + 'ignore_errors' => true, + 'timeout' => 15, + ], + ]); + + $strBody = @file_get_contents($strUrl, false, $Context); + $arrResponseHeaders = $http_response_header ?? []; + $intStatus = 0; + + foreach ($arrResponseHeaders 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, + 'headers' => $arrResponseHeaders, + ]; +} + +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); +} + +function renderTextSummary(array $arrSummary): string +{ + $arrLines = [ + 'host: ' . $arrSummary['host'], + 'scene: ' . $arrSummary['scene'], + 'page_key: ' . $arrSummary['page_key'], + 'path: ' . $arrSummary['path'], + 'status: ' . $arrSummary['http_status'], + 'all_matched: ' . ($arrSummary['all_matched'] ? 'yes' : 'no'), + ]; + + $arrLines[] = 'needles:'; + foreach ($arrSummary['checks'] as $arrCheck) { + $arrLines[] = '- ' . ($arrCheck['matched'] ? '[ok] ' : '[miss] ') . $arrCheck['label'] . ': ' . $arrCheck['preview']; + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (empty($arrArgs)) { + printUsage(); + exit(1); +} + +$strHost = ''; +$strScene = ''; +$arrPageParts = []; +$strTargetRoot = dirname(__DIR__) . '/data/seo_copy'; +$strPath = ''; +$strBaseUrl = 'http://127.0.0.1:8910'; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--target-root=')) { + $strTargetRoot = substr($strArg, strlen('--target-root=')); + continue; + } + + if (str_starts_with($strArg, '--path=')) { + $strPath = substr($strArg, strlen('--path=')); + continue; + } + + if (str_starts_with($strArg, '--base-url=')) { + $strBaseUrl = rtrim(substr($strArg, strlen('--base-url=')), '/'); + continue; + } + + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(substr($strArg, strlen('--format='))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + continue; + } + + if ($strScene === '') { + $strScene = trim($strArg); + continue; + } + + $arrPageParts[] = $strArg; +} + +if ($strHost === '' || $strScene === '' || !in_array($strScene, SeoCopySchema::getSupportedScenes(), true)) { + printUsage(); + exit(1); +} + +$strPageKey = SeoCopySchema::buildScenePageKey($strScene, $arrPageParts); +$arrData = SeoCopyStore::getPageDataFromRoot($strTargetRoot, $strHost, $strScene, $strPageKey); +if (empty($arrData)) { + fwrite(STDERR, "No readable seo_copy data found for {$strScene}/{$strPageKey} under {$strTargetRoot}\n"); + exit(1); +} + +if ($strPath === '') { + $strPath = buildScenePath($strHost, $strScene, $arrPageParts); +} + +if ($strPath === '') { + fwrite(STDERR, "Unable to resolve path automatically for scene {$strScene}; please pass --path=/your/url\n"); + exit(1); +} + +$arrNeedles = buildNeedles($strScene, $arrData); +$strUrl = $strBaseUrl . $strPath; +$arrResponse = fetchPage($strUrl, $strHost); +$strBody = (string)($arrResponse['body'] ?? ''); +$intStatus = (int)($arrResponse['status'] ?? 0); +$strNormalizedBody = normalizeForMatch($strBody); + +$arrChecks = []; +foreach ($arrNeedles as $intIndex => $strNeedle) { + $boolMatched = $strNeedle !== '' && str_contains($strNormalizedBody, normalizeForMatch($strNeedle)); + $arrChecks[] = [ + 'label' => 'needle_' . ($intIndex + 1), + 'matched' => $boolMatched, + 'preview' => mb_substr($strNeedle, 0, 120), + ]; +} + +$arrSummary = [ + 'host' => $strHost, + 'scene' => $strScene, + 'page_parts' => $arrPageParts, + 'page_key' => $strPageKey, + 'target_root' => $strTargetRoot, + 'path' => $strPath, + 'url' => $strUrl, + 'http_status' => $intStatus, + 'all_matched' => $intStatus === 200 && !in_array(false, array_column($arrChecks, 'matched'), true), + 'checks' => $arrChecks, +]; + +if ($strFormat === 'text') { + echo renderTextSummary($arrSummary); +} else { + echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} diff --git a/code/scripts/seo_copy_generation_prepare.php b/code/scripts/seo_copy_generation_prepare.php new file mode 100644 index 0000000..d61a261 --- /dev/null +++ b/code/scripts/seo_copy_generation_prepare.php @@ -0,0 +1,26 @@ +initialize(); + +$strHost = trim((string)($argv[1] ?? '')); +if ($strHost === '') { + fwrite(STDERR, "Usage: php scripts/seo_copy_generation_prepare.php \n"); + exit(1); +} + +DomainModel::flushAllDomianOnCache(); +$DomainModel = DomainModel::getDomainOnCacheByDomain($strHost); +if (!$DomainModel instanceof DomainModel) { + fwrite(STDERR, "Domain not found: {$strHost}\n"); + exit(1); +} + +$arrResult = SeoCopyGenerationHelper::initializeForDomain($DomainModel); +echo json_encode($arrResult, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL; diff --git a/code/scripts/seo_copy_generation_status.php b/code/scripts/seo_copy_generation_status.php new file mode 100644 index 0000000..61f8bdd --- /dev/null +++ b/code/scripts/seo_copy_generation_status.php @@ -0,0 +1,23 @@ +initialize(); + +$strHost = trim((string)($argv[1] ?? '')); +if ($strHost === '') { + fwrite(STDERR, "Usage: php scripts/seo_copy_generation_status.php \n"); + exit(1); +} + +$arrState = SeoCopyGenerationHelper::readState($strHost); +if (empty($arrState)) { + fwrite(STDERR, "No state found for {$strHost}\n"); + exit(1); +} + +echo json_encode($arrState, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL; diff --git a/code/scripts/seo_copy_play_route_verify.php b/code/scripts/seo_copy_play_route_verify.php new file mode 100644 index 0000000..a0cd09a --- /dev/null +++ b/code/scripts/seo_copy_play_route_verify.php @@ -0,0 +1,304 @@ + [--play-type=default] [--play-index=1] [--base-url=https://host] [--format=json|text]\n"; +} + +function ensureAppInitialized(): void +{ + static $boolInitialized = false; + if ($boolInitialized) { + return; + } + + (new App())->initialize(); + $boolInitialized = true; +} + +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, + ]; +} + +function normalizeForMatch(string $strValue): string +{ + $strValue = html_entity_decode($strValue, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $strValue = strip_tags($strValue); + $strValue = preg_replace('/\s+/u', ' ', str_replace(["\r", "\n", "\t"], ' ', $strValue)); + return trim((string)$strValue); +} + +function hasPlayParamMatch( + array $arrParsed, + string $strSlug, + int $intVideoId, + string $strPlayType, + int $intPlayIndex +): bool { + if ((int)($arrParsed['intVId'] ?? 0) !== $intVideoId + || (string)($arrParsed['strPlayType'] ?? '') !== $strPlayType + || (int)($arrParsed['intPlayIndex'] ?? 0) !== $intPlayIndex + ) { + return false; + } + + if (array_key_exists('strPinyin', $arrParsed)) { + return (string)($arrParsed['strPinyin'] ?? '') === $strSlug; + } + + return true; +} + +function renderText(array $arrSummary): string +{ + $arrLines = [ + 'host: ' . $arrSummary['host'], + 'video_id: ' . $arrSummary['video_id'], + 'slug: ' . $arrSummary['slug'], + 'play_type: ' . $arrSummary['play_type'], + 'play_index: ' . $arrSummary['play_index'], + 'detail_path: ' . $arrSummary['detail_path'], + 'play_path: ' . $arrSummary['play_path'], + 'play_resolved_page: ' . ($arrSummary['play_resolved_page'] ?: '-'), + 'detail_status: ' . $arrSummary['detail_status'], + 'play_status: ' . $arrSummary['play_status'], + 'all_passed: ' . ($arrSummary['all_passed'] ? 'yes' : 'no'), + 'checks:', + ]; + + foreach ($arrSummary['checks'] as $arrCheck) { + $arrLines[] = '- ' . ($arrCheck['passed'] ? '[ok] ' : '[fail] ') . $arrCheck['label'] . ': ' . $arrCheck['detail']; + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (count($arrArgs) < 2) { + printUsage(); + exit(1); +} + +$strHost = ''; +$intVideoId = 0; +$strRequestedPlayType = ''; +$intRequestedPlayIndex = 1; +$strBaseUrl = ''; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--play-type=')) { + $strRequestedPlayType = trim(substr($strArg, strlen('--play-type='))); + continue; + } + if (str_starts_with($strArg, '--play-index=')) { + $intRequestedPlayIndex = max(1, (int)substr($strArg, strlen('--play-index='))); + continue; + } + if (str_starts_with($strArg, '--base-url=')) { + $strBaseUrl = rtrim(substr($strArg, strlen('--base-url=')), '/'); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + continue; + } + if ($intVideoId === 0) { + $intVideoId = (int)$strArg; + continue; + } +} + +if ($strHost === '' || $intVideoId <= 0) { + printUsage(); + exit(1); +} + +ensureAppInitialized(); + +/** @var DomainModel|null $DomainRow */ +$DomainRow = app(DomainModel::class)->where('d_domain', $strHost)->find(); +if (empty($DomainRow)) { + fwrite(STDERR, "Domain not found: {$strHost}\n"); + exit(1); +} + +$strBaseUrl = $strBaseUrl !== '' ? $strBaseUrl : ('https://' . $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, + ], +]) ?? []; + +$strSlug = trim((string)($arrVideo['v_name_en'] ?? '')); +$arrPlayGroups = is_array($arrVideo['v_play_url'] ?? null) ? (array)$arrVideo['v_play_url'] : []; +if ($strSlug === '' || empty($arrPlayGroups)) { + fwrite(STDERR, "Video slug/play groups missing: {$intVideoId}\n"); + exit(1); +} + +$strPlayType = $strRequestedPlayType !== '' ? $strRequestedPlayType : (string)array_key_first($arrPlayGroups); +$arrCurrentPlayGroup = (array)($arrPlayGroups[$strPlayType] ?? []); +if (empty($arrCurrentPlayGroup)) { + $strPlayType = (string)array_key_first($arrPlayGroups); + $arrCurrentPlayGroup = (array)($arrPlayGroups[$strPlayType] ?? []); +} + +$intPlayIndex = max(1, min($intRequestedPlayIndex, count($arrCurrentPlayGroup))); +$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 = fetchPage($strBaseUrl . $strDetailPath, $strHost); +$arrPlayResponse = fetchPage($strBaseUrl . $strPlayPath, $strHost); +$strRawDetailBody = (string)($arrDetailResponse['body'] ?? ''); +$strRawPlayBody = (string)($arrPlayResponse['body'] ?? ''); +$strNormalizedPlayBody = normalizeForMatch($strRawPlayBody); + +$arrChecks = []; +$arrChecks[] = [ + 'label' => 'play_reverse_parse', + 'passed' => !empty($arrPlayParsed) + && hasPlayParamMatch($arrPlayParsed, $strSlug, $intVideoId, $strPlayType, $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, normalizeForMatch($strEpisodeName)), + 'detail' => $strEpisodeName, +]; + +$boolAllPassed = !in_array(false, array_column($arrChecks, 'passed'), true); + +$arrSummary = [ + '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' => $boolAllPassed, + 'verified_at' => date(DATE_ATOM), +]; + +if ($strFormat === 'text') { + echo renderText($arrSummary); + exit($boolAllPassed ? 0 : 1); +} + +echo json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL; +exit($boolAllPassed ? 0 : 1); diff --git a/code/scripts/seo_copy_portal_home.php b/code/scripts/seo_copy_portal_home.php new file mode 100644 index 0000000..5e38d39 --- /dev/null +++ b/code/scripts/seo_copy_portal_home.php @@ -0,0 +1,51 @@ +getMessage() . PHP_EOL); + printUsage(); + exit(1); +} + +echo json_encode([ + 'portal_root' => realpath($strPortalRoot) ?: $strPortalRoot, + 'index_html' => rtrim($strPortalRoot, '/') . '/index.html', + 'index_json' => rtrim($strPortalRoot, '/') . '/index.json', + 'releases' => (int)(($arrSummary['release_portal'] ?? [])['releases_count'] ?? 0), + 'approved_manifests' => (int)(($arrSummary['approved_portal'] ?? [])['manifests_count'] ?? 0), +], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_prompt.php b/code/scripts/seo_copy_prompt.php new file mode 100644 index 0000000..5731755 --- /dev/null +++ b/code/scripts/seo_copy_prompt.php @@ -0,0 +1,154 @@ + [page-part ...] [--facts-file=/abs/path.json] [--facts-json='{}'] [--format=markdown|json]\n\n"; + echo "Examples:\n"; + echo " php scripts/seo_copy_prompt.php chuanjiafeng.net home index\n"; + echo " php scripts/seo_copy_prompt.php chuanjiafeng.net search 相逢 --facts-json='{\"keyword\":\"相逢\",\"result_total\":315}'\n"; + echo " php scripts/seo_copy_prompt.php chuanjiafeng.net detail 154229 --facts-file=/tmp/detail-facts.json\n\n"; + echo "Supported scenes:\n"; + echo " {$strScenes}\n"; +} + +function parseFacts(array $arrOptions): array +{ + if (($arrOptions['auto-facts'] ?? '0') === '1') { + return ['__AUTO_FACTS__' => true]; + } + + if (!empty($arrOptions['facts-json'])) { + $arrData = json_decode((string)$arrOptions['facts-json'], true); + if (!is_array($arrData)) { + fwrite(STDERR, "Invalid --facts-json, must be valid JSON object.\n"); + exit(1); + } + return $arrData; + } + + if (!empty($arrOptions['facts-file'])) { + $strPath = (string)$arrOptions['facts-file']; + if (!is_file($strPath)) { + fwrite(STDERR, "Facts file not found: {$strPath}\n"); + exit(1); + } + + $strJson = (string)file_get_contents($strPath); + $arrData = json_decode($strJson, true); + if (!is_array($arrData)) { + fwrite(STDERR, "Facts file must contain valid JSON object.\n"); + exit(1); + } + return $arrData; + } + + return []; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +$arrOptions = [ + 'format' => 'markdown', +]; +$arrParts = []; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--')) { + $arrTmp = explode('=', substr($strArg, 2), 2); + $strKey = $arrTmp[0] ?? ''; + $strVal = $arrTmp[1] ?? '1'; + $arrOptions[$strKey] = $strVal; + continue; + } + + $arrParts[] = $strArg; +} + +if (count($arrParts) < 3) { + printUsage(); + exit(1); +} + +$strHost = (string)array_shift($arrParts); +$strScene = (string)array_shift($arrParts); + +if (!in_array($strScene, SeoCopySchema::getSupportedScenes(), true)) { + fwrite(STDERR, "Unsupported scene: {$strScene}\n"); + printUsage(); + exit(1); +} + +$strPageKey = SeoCopySchema::buildScenePageKey($strScene, $arrParts); +$arrEnvelope = SeoCopySchema::buildGenerationEnvelope($strScene); +$arrFacts = parseFacts($arrOptions); + +if (($arrFacts['__AUTO_FACTS__'] ?? false) === true) { + (new App())->initialize(); + $arrFacts = SeoCopyFactsBuilder::build($strHost, $strScene, $arrParts); +} + +$arrPromptData = [ + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'page_parts' => $arrParts, + 'target_path' => dirname(__DIR__) . '/data/seo_copy/' . SeoCopyStore::buildPageKey($strHost, 'host') . '/' . $strScene . '/' . $strPageKey . '.json', + 'rules' => $arrEnvelope['rules'], + 'fact_hints' => $arrEnvelope['fact_hints'], + 'template' => $arrEnvelope['template'], + 'facts' => $arrFacts, +]; + +if (($arrOptions['format'] ?? 'markdown') === 'json') { + echo json_encode($arrPromptData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + exit(0); +} + +$strTemplateJson = json_encode($arrEnvelope['template'], 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); +$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); + +echo "# SEO Copy Prompt" . PHP_EOL . PHP_EOL; +echo "## Target" . PHP_EOL; +echo "- host: {$strHost}" . PHP_EOL; +echo "- scene: {$strScene}" . PHP_EOL; +echo "- page_key: {$strPageKey}" . PHP_EOL; +echo "- target_path: {$arrPromptData['target_path']}" . PHP_EOL . PHP_EOL; + +echo "## Instruction" . PHP_EOL; +echo "你是站群 SEO 内容补料助手。请基于给定 facts 生成一个 JSON 对象,只输出 JSON,不要输出解释、Markdown、代码块。" . PHP_EOL; +echo "要求:" . PHP_EOL; +echo "- 必须严格遵守 scene rules 和 template 字段结构。" . PHP_EOL; +echo "- 只能改写和组织已知事实,不要编造演员、年份、剧情、线路、榜单数据。" . PHP_EOL; +echo "- 文案目标是提升页面可读性、解释力和 SEO 覆盖,但不要堆砌关键词。" . PHP_EOL; +echo "- 文风要自然、克制、站内导航型,避免“全网最全”“免费观看”等夸张承诺。" . PHP_EOL; +echo "- 输出必须能直接写入目标 JSON 文件。" . PHP_EOL . PHP_EOL; + +echo "## Scene Rules" . PHP_EOL; +echo $strRulesJson . PHP_EOL . PHP_EOL; + +echo "## Fact Hints" . PHP_EOL; +echo ($strFactHintsJson !== false ? $strFactHintsJson : '{}') . PHP_EOL . PHP_EOL; + +echo "## Facts" . PHP_EOL; +echo ($strFactsJson !== false ? $strFactsJson : '{}') . PHP_EOL . PHP_EOL; + +echo "## Return JSON Template" . PHP_EOL; +echo ($strTemplateJson !== false ? $strTemplateJson : '{}') . PHP_EOL; diff --git a/code/scripts/seo_copy_readback.php b/code/scripts/seo_copy_readback.php new file mode 100644 index 0000000..9685c45 --- /dev/null +++ b/code/scripts/seo_copy_readback.php @@ -0,0 +1,158 @@ + [page-part ...] [--target-root=/abs/path] [--format=json|text]\n\n"; + echo "Examples:\n"; + echo " php scripts/seo_copy_readback.php chuanjiafeng.net play 154229 douban 1\n"; + echo " php scripts/seo_copy_readback.php chuanjiafeng.net search 相逢 --format=text\n"; +} + +function buildFieldSummary(array $arrTemplate, array $arrData): array +{ + $arrSummary = []; + + foreach ($arrTemplate as $strField => $mTemplateVal) { + $mValue = $arrData[$strField] ?? null; + $arrItem = [ + 'field' => $strField, + 'present' => array_key_exists($strField, $arrData), + 'type' => gettype($mValue), + ]; + + if ($strField === 'guide_cards') { + $arrCards = is_array($mValue) ? $mValue : []; + $arrItem['count'] = count($arrCards); + $arrItem['preview'] = array_slice(array_map(static function (array $arrCard): array { + return [ + 'title' => trim((string)($arrCard['title'] ?? '')), + 'text' => trim((string)($arrCard['text'] ?? '')), + 'href' => trim((string)($arrCard['href'] ?? '')), + ]; + }, array_filter($arrCards, 'is_array')), 0, 2); + } else { + $strValue = trim((string)$mValue); + $arrItem['length'] = mb_strlen($strValue); + $arrItem['preview'] = mb_substr($strValue, 0, 120); + } + + $arrSummary[] = $arrItem; + } + + return $arrSummary; +} + +function renderTextSummary(array $arrSummary): string +{ + $arrLines = [ + 'host: ' . $arrSummary['host'], + 'scene: ' . $arrSummary['scene'], + 'page_key: ' . $arrSummary['page_key'], + 'target_root: ' . $arrSummary['target_root'], + 'resolved_path: ' . $arrSummary['resolved_path'], + 'exists: ' . ($arrSummary['exists'] ? 'yes' : 'no'), + ]; + + if (!empty($arrSummary['missing_fields'])) { + $arrLines[] = 'missing_fields: ' . implode(', ', $arrSummary['missing_fields']); + } + + $arrLines[] = 'fields:'; + foreach ($arrSummary['fields'] as $arrField) { + if ($arrField['field'] === 'guide_cards') { + $arrLines[] = '- ' . $arrField['field'] . ': count=' . ($arrField['count'] ?? 0); + foreach ((array)($arrField['preview'] ?? []) as $arrCard) { + $arrLines[] = ' • ' . ($arrCard['title'] ?: '(no title)') . ' | ' . ($arrCard['text'] ?: '(no text)'); + } + continue; + } + + $arrLines[] = '- ' . $arrField['field'] . ': len=' . ($arrField['length'] ?? 0) . ' | ' . ($arrField['preview'] ?? ''); + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (empty($arrArgs)) { + printUsage(); + exit(1); +} + +$strHost = ''; +$strScene = ''; +$arrPageParts = []; +$strTargetRoot = dirname(__DIR__) . '/data/seo_copy'; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--target-root=')) { + $strTargetRoot = substr($strArg, strlen('--target-root=')); + continue; + } + + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(substr($strArg, strlen('--format='))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + continue; + } + + if ($strScene === '') { + $strScene = trim($strArg); + continue; + } + + $arrPageParts[] = $strArg; +} + +if ($strHost === '' || $strScene === '' || !in_array($strScene, SeoCopySchema::getSupportedScenes(), true)) { + printUsage(); + exit(1); +} + +$strPageKey = SeoCopySchema::buildScenePageKey($strScene, $arrPageParts); +$strResolvedPath = SeoCopyStore::resolvePagePathFromRoot($strTargetRoot, $strHost, $strScene, $strPageKey); +$arrTemplate = SeoCopySchema::getSceneTemplate($strScene); +$arrData = SeoCopyStore::getPageDataFromRoot($strTargetRoot, $strHost, $strScene, $strPageKey); +$arrMissingFields = []; + +foreach (array_keys($arrTemplate) as $strField) { + if (!array_key_exists($strField, $arrData)) { + $arrMissingFields[] = $strField; + } +} + +$arrSummary = [ + 'host' => $strHost, + 'scene' => $strScene, + 'page_parts' => $arrPageParts, + 'page_key' => $strPageKey, + 'target_root' => $strTargetRoot, + 'resolved_path' => $strResolvedPath, + 'exists' => $strResolvedPath !== '' && is_file($strResolvedPath), + 'missing_fields' => $arrMissingFields, + 'fields' => buildFieldSummary($arrTemplate, $arrData), +]; + +if ($strFormat === 'text') { + echo renderTextSummary($arrSummary); + exit(0); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_release_index.php b/code/scripts/seo_copy_release_index.php new file mode 100644 index 0000000..5f2e52d --- /dev/null +++ b/code/scripts/seo_copy_release_index.php @@ -0,0 +1,65 @@ +getMessage() . PHP_EOL); + printUsage(); + exit(1); +} + +if ($strOutPath !== '') { + SeoCopyReleaseIndexHelper::writeSummary($strOutPath, $arrSummary); +} + +if ($strFormat === 'text') { + echo SeoCopyReleaseIndexHelper::renderTextSummary($arrSummary); + exit(0); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_release_portal.php b/code/scripts/seo_copy_release_portal.php new file mode 100644 index 0000000..60f25c7 --- /dev/null +++ b/code/scripts/seo_copy_release_portal.php @@ -0,0 +1,62 @@ +getMessage() . PHP_EOL); + printUsage(); + exit(1); +} + +echo json_encode([ + 'log_dir' => realpath($strLogDir) ?: $strLogDir, + 'output_root' => realpath($strOutputRoot) ?: $strOutputRoot, + 'latest_index_html' => rtrim($strOutputRoot, '/') . '/latest-index.html', + 'latest_index_json' => rtrim($strOutputRoot, '/') . '/latest-index.json', + 'releases' => count((array)($arrSummary['releases'] ?? [])), +], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_release_rollback.php b/code/scripts/seo_copy_release_rollback.php new file mode 100644 index 0000000..3073983 --- /dev/null +++ b/code/scripts/seo_copy_release_rollback.php @@ -0,0 +1,226 @@ + [--dry-run=1]\n"; + echo " php scripts/seo_copy_release_rollback.php --release-tag=tag [--log-dir=/abs/path] [--dry-run=1]\n\n"; + echo "Examples:\n"; + echo " php scripts/seo_copy_release_rollback.php storage/seo_copy_publish_logs/20260404/172023_nightly.json\n"; +} + +function writeRawFile(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); +} + +function writeJsonFile(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); +} + +function resolvePortalRoot(): string +{ + $strPortalRoot = trim((string)getenv('SEO_COPY_RELEASE_PORTAL_ROOT')); + if (in_array(strtolower($strPortalRoot), ['0', 'false', 'off', 'disable', 'disabled'], true)) { + return ''; + } + + return $strPortalRoot !== '' ? $strPortalRoot : dirname(__DIR__) . '/public/_seo_copy_release'; +} + +function findPublishLogByReleaseTag(string $strLogDir, string $strReleaseTag): string +{ + if ($strLogDir === '' || !is_dir($strLogDir) || $strReleaseTag === '') { + return ''; + } + + $arrMatches = []; + $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; + } + + if (str_ends_with($strPath, '_' . $strReleaseTag . '.json')) { + $arrMatches[] = $strPath; + } + } + + sort($arrMatches, SORT_STRING); + + return !empty($arrMatches) ? (string)end($arrMatches) : ''; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (empty($arrArgs)) { + printUsage(); + exit(1); +} + +$strLogPath = ''; +$boolDryRun = false; +$strReleaseTag = ''; +$strLogDir = dirname(__DIR__) . '/storage/seo_copy_publish_logs'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--dry-run=')) { + $boolDryRun = in_array(strtolower(substr($strArg, strlen('--dry-run='))), ['1', 'true', 'yes'], true); + continue; + } + + if (str_starts_with($strArg, '--release-tag=')) { + $strReleaseTag = trim(substr($strArg, strlen('--release-tag='))); + continue; + } + + if (str_starts_with($strArg, '--log-dir=')) { + $strLogDir = trim(substr($strArg, strlen('--log-dir='))); + continue; + } + + if ($strLogPath === '') { + $strLogPath = $strArg; + } +} + +if ($strLogPath === '' && $strReleaseTag !== '') { + $strLogPath = findPublishLogByReleaseTag($strLogDir, $strReleaseTag); +} + +if ($strLogPath === '' || !is_file($strLogPath)) { + fwrite(STDERR, "Publish log not found: {$strLogPath}\n"); + printUsage(); + exit(1); +} + +$strLogJson = (string)file_get_contents($strLogPath); +$arrLog = json_decode($strLogJson, true); +if (!is_array($arrLog) || !is_array($arrLog['published'] ?? null)) { + fwrite(STDERR, "Invalid publish log format.\n"); + exit(1); +} + +$arrSummary = [ + 'log_type' => 'rollback', + 'created_at' => date('c'), + 'publish_log' => realpath($strLogPath) ?: $strLogPath, + 'release_tag' => (string)($arrLog['release_tag'] ?? ''), + 'dry_run' => $boolDryRun, + 'restored' => [], + 'skipped' => [], +]; + +for ($intIndex = count($arrLog['published']) - 1; $intIndex >= 0; $intIndex--) { + $arrItem = $arrLog['published'][$intIndex]; + if (!is_array($arrItem)) { + continue; + } + + $strTargetPath = (string)($arrItem['target_path'] ?? ''); + $strAction = (string)($arrItem['action'] ?? ''); + $strBackupPath = (string)($arrItem['backup_path'] ?? ''); + + if ($strTargetPath === '' || $strAction === '') { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'reason' => 'invalid_log_item', + ]; + continue; + } + + if ($strAction === 'update') { + if ($strBackupPath === '' || !is_file($strBackupPath)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'target_path' => $strTargetPath, + 'reason' => 'missing_backup', + ]; + continue; + } + + if (!$boolDryRun) { + writeRawFile($strTargetPath, (string)file_get_contents($strBackupPath)); + } + + $arrSummary['restored'][] = [ + 'index' => $intIndex, + 'target_path' => $strTargetPath, + 'mode' => 'restore_backup', + 'backup_path' => $strBackupPath, + ]; + continue; + } + + if ($strAction === 'create') { + if (!$boolDryRun && is_file($strTargetPath)) { + unlink($strTargetPath); + } + + $arrSummary['restored'][] = [ + 'index' => $intIndex, + 'target_path' => $strTargetPath, + 'mode' => 'delete_created_file', + 'backup_path' => '', + ]; + continue; + } + + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'target_path' => $strTargetPath, + 'reason' => 'unsupported_action', + ]; +} + +$arrSummary['restored_count'] = count($arrSummary['restored']); +$arrSummary['skipped_count'] = count($arrSummary['skipped']); + +$strRollbackLogPath = rtrim($strLogDir, '/') . '/' . date('Ymd') . '/rollback_' . date('His') . '_' . ($arrSummary['release_tag'] ?: 'unknown') . '.json'; +$strPortalRoot = resolvePortalRoot(); +$arrSummary['rollback_log_path'] = $strRollbackLogPath; +$arrSummary['latest_index_path'] = rtrim($strLogDir, '/') . '/latest-index.json'; +$arrSummary['portal_root'] = $strPortalRoot; +$arrSummary['portal_index_path'] = $strPortalRoot !== '' ? rtrim($strPortalRoot, '/') . '/latest-index.html' : ''; + +writeJsonFile($strRollbackLogPath, $arrSummary); +$arrLatestSummary = SeoCopyReleaseIndexHelper::buildSummary($strLogDir, true); +SeoCopyReleaseIndexHelper::writeSummary($arrSummary['latest_index_path'], $arrLatestSummary); +SeoCopyReleaseViewHelper::writeArtifacts($strLogDir, $arrLatestSummary); +if ($strPortalRoot !== '') { + SeoCopyReleasePortalHelper::writeArtifacts($strPortalRoot, $arrLatestSummary); + SeoCopyPortalHomeHelper::writeArtifacts($strPortalRoot, SeoCopyPortalHomeHelper::buildSummary($strPortalRoot)); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_release_run.php b/code/scripts/seo_copy_release_run.php new file mode 100644 index 0000000..83dcd5e --- /dev/null +++ b/code/scripts/seo_copy_release_run.php @@ -0,0 +1,1172 @@ + --source-root=/abs/path [--target-root=/abs/path] [--release-tag=tag] [--log-dir=/abs/path] [--approved-dir=/abs/path] [--portal-root=/abs/path|off] [--base-url=https://host] [--temp-port=8920] [--php-bin=/usr/bin/php] [--dry-run=1] [--format=json|text]\n\n"; + echo "Behavior:\n"; + echo " 1. verify source-root payloads\n"; + echo " 2. front verify manifest pages\n"; + echo " 3. publish only after both gates pass\n\n"; + echo "Notes:\n"; + echo " - If proc_open is unavailable in current PHP CLI, you must pass --base-url.\n"; + echo " - When proc_open is available and --base-url is omitted, the script can start a temp PHP server.\n"; +} + +function parseBoolArg(string $strValue): bool +{ + return in_array(strtolower(trim($strValue)), ['1', 'true', 'yes', 'on'], true); +} + +function canSpawnProcess(): bool +{ + return function_exists('proc_open'); +} + +function buildCommand(array $arrParts): string +{ + return implode(' ', array_map(static fn($strPart) => escapeshellarg((string)$strPart), $arrParts)); +} + +function writeTextFile(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); +} + +function writeJsonFile(string $strPath, array $arrData): void +{ + writeTextFile( + $strPath, + json_encode($arrData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); +} + +function firstHostFromManifest(array $arrManifest): string +{ + foreach ($arrManifest as $arrItem) { + if (!is_array($arrItem)) { + continue; + } + + $strHost = trim((string)($arrItem['host'] ?? '')); + if ($strHost !== '') { + return $strHost; + } + } + + return 'localhost'; +} + +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; +} + +function fetchStatus(string $strUrl, string $strHost): int +{ + $Context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "Host: {$strHost}\r\nConnection: close", + 'ignore_errors' => true, + 'timeout' => 3, + ], + ]); + + @file_get_contents($strUrl, false, $Context); + $arrHeaders = $http_response_header ?? []; + foreach ($arrHeaders as $strHeaderLine) { + if (preg_match('/^HTTP\/\S+\s+(\d{3})/i', $strHeaderLine, $arrMatches)) { + return (int)($arrMatches[1] ?? 0); + } + } + + return 0; +} + +function startTempServer(string $strCodeRoot, string $strSourceRoot, int $intPort, string $strPhpBin, string $strHost, string $strLogPath): array +{ + if (!canSpawnProcess()) { + throw new RuntimeException('proc_open is unavailable; temp PHP server cannot be started.'); + } + + writeTextFile($strLogPath, ''); + $arrDescriptors = [ + 0 => ['pipe', 'r'], + 1 => ['file', $strLogPath, 'a'], + 2 => ['file', $strLogPath, 'a'], + ]; + + $strCommand = buildCommand([ + $strPhpBin, + '-S', + '127.0.0.1:' . $intPort, + '-t', + 'public', + 'public/router.php', + ]); + + $Process = proc_open($strCommand, $arrDescriptors, $arrPipes, $strCodeRoot, array_merge($_ENV, [ + 'SEO_COPY_ROOT_OVERRIDE' => $strSourceRoot, + ])); + + if (!is_resource($Process)) { + throw new RuntimeException('Failed to start temp PHP server.'); + } + + fclose($arrPipes[0]); + + $strBaseUrl = 'http://127.0.0.1:' . $intPort; + $intWaitMs = 0; + while ($intWaitMs < 15000) { + $arrStatus = proc_get_status($Process); + if (!$arrStatus['running']) { + throw new RuntimeException('Temp PHP server exited early. Log: ' . $strLogPath); + } + + $intStatus = fetchStatus($strBaseUrl . '/', $strHost); + if ($intStatus > 0) { + return [ + 'process' => $Process, + 'base_url' => $strBaseUrl, + 'log_path' => $strLogPath, + 'port' => $intPort, + ]; + } + + usleep(250000); + $intWaitMs += 250; + } + + stopTempServer($Process); + throw new RuntimeException('Timed out waiting for temp PHP server. Log: ' . $strLogPath); +} + +function stopTempServer($Process): void +{ + if (!is_resource($Process)) { + return; + } + + $arrStatus = proc_get_status($Process); + if (!empty($arrStatus['running'])) { + @proc_terminate($Process); + usleep(300000); + $arrStatus = proc_get_status($Process); + if (!empty($arrStatus['running']) && function_exists('posix_kill')) { + @posix_kill((int)$arrStatus['pid'], SIGKILL); + } + } + + @proc_close($Process); +} + +function normalizeGuideCards(mixed $mValue): array +{ + if (!is_array($mValue)) { + return []; + } + + $arrOut = []; + foreach ($mValue 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 $arrOut; +} + +function normalizePayload(string $strScene, array $arrPayload): array +{ + $arrTemplate = SeoCopySchema::getSceneTemplate($strScene); + $arrOut = []; + + foreach ($arrTemplate as $strKey => $mTemplateVal) { + if ($strKey === 'guide_cards') { + $arrOut[$strKey] = normalizeGuideCards($arrPayload[$strKey] ?? []); + continue; + } + + $arrOut[$strKey] = trim((string)($arrPayload[$strKey] ?? '')); + } + + return $arrOut; +} + +function validatePayload(string $strScene, array $arrPayload): array +{ + $arrErrors = []; + $arrTemplate = SeoCopySchema::getSceneTemplate($strScene); + + foreach ($arrTemplate as $strKey => $mTemplateVal) { + if (!array_key_exists($strKey, $arrPayload)) { + $arrErrors[] = "Missing field: {$strKey}"; + continue; + } + + if ($strKey === 'guide_cards') { + if (!is_array($arrPayload[$strKey]) || count($arrPayload[$strKey]) === 0) { + $arrErrors[] = 'guide_cards must be a non-empty array'; + } + continue; + } + + if (!is_string($arrPayload[$strKey])) { + $arrErrors[] = "{$strKey} must be a string"; + continue; + } + + if (trim($arrPayload[$strKey]) === '') { + $arrErrors[] = "{$strKey} must be a non-empty string"; + } + } + + return $arrErrors; +} + +function summarizeFields(string $strScene, array $arrData): array +{ + $arrTemplate = SeoCopySchema::getSceneTemplate($strScene); + $arrMissing = []; + $arrEmpty = []; + + foreach ($arrTemplate as $strField => $mTemplateVal) { + if (!array_key_exists($strField, $arrData)) { + $arrMissing[] = $strField; + continue; + } + + if ($strField === 'guide_cards') { + if (!is_array($arrData[$strField]) || count($arrData[$strField]) === 0) { + $arrEmpty[] = $strField; + } + continue; + } + + if (trim((string)$arrData[$strField]) === '') { + $arrEmpty[] = $strField; + } + } + + return [ + 'missing_fields' => $arrMissing, + 'empty_fields' => $arrEmpty, + ]; +} + +function verifySourceRoot(string $strTargetRoot): array +{ + $arrSummary = [ + 'target_root' => realpath($strTargetRoot) ?: $strTargetRoot, + 'total_files' => 0, + 'checked_files' => 0, + 'readable_files' => 0, + 'by_scene' => [], + 'issues' => [], + ]; + + $Iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($strTargetRoot, FilesystemIterator::SKIP_DOTS)); + foreach ($Iterator as $FileInfo) { + /** @var SplFileInfo $FileInfo */ + if (!$FileInfo->isFile() || strtolower($FileInfo->getExtension()) !== 'json') { + continue; + } + + if (preg_match('/\.facts\.json$/i', $FileInfo->getFilename()) === 1) { + continue; + } + + $arrSummary['total_files']++; + $strRelative = str_replace('\\', '/', substr($FileInfo->getPathname(), strlen(rtrim($strTargetRoot, '/')) + 1)); + $arrSegments = explode('/', $strRelative); + + if (count($arrSegments) < 3) { + continue; + } + + $strHostDir = $arrSegments[0]; + $strScene = $arrSegments[1]; + $strPageKey = preg_replace('/\.json$/i', '', $arrSegments[count($arrSegments) - 1]); + + if (!in_array($strScene, SeoCopySchema::getSupportedScenes(), true)) { + continue; + } + + $arrSummary['checked_files']++; + $arrSummary['by_scene'][$strScene] = $arrSummary['by_scene'][$strScene] ?? [ + 'total' => 0, + 'readable' => 0, + ]; + $arrSummary['by_scene'][$strScene]['total']++; + + $arrData = SeoCopyStore::getPageDataFromRoot($strTargetRoot, $strHostDir, $strScene, $strPageKey); + $arrFieldSummary = summarizeFields($strScene, $arrData); + $boolReadable = !empty($arrData) && empty($arrFieldSummary['missing_fields']); + + if ($boolReadable) { + $arrSummary['readable_files']++; + $arrSummary['by_scene'][$strScene]['readable']++; + } + + if (!$boolReadable || !empty($arrFieldSummary['empty_fields'])) { + $arrSummary['issues'][] = [ + 'host' => $strHostDir, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'missing_fields' => $arrFieldSummary['missing_fields'], + 'empty_fields' => $arrFieldSummary['empty_fields'], + ]; + } + } + + return $arrSummary; +} + +function ensureAppInitialized(): void +{ + static $boolInitialized = false; + + if ($boolInitialized) { + return; + } + + (new App())->initialize(); + $boolInitialized = true; +} + +function buildScenePath(string $strHost, string $strScene, array $arrPageParts): string +{ + ensureAppInitialized(); + + /** @var DomainModel|null $DomainRow */ + $DomainRow = app(DomainModel::class)->where('d_domain', $strHost)->find(); + if (empty($DomainRow)) { + return ''; + } + + $TpStyle = SiteStyle::getConfig($DomainRow, $strHost); + $UrlBuilder = new UrlBuilder($TpStyle); + + if (in_array($strScene, ['detail', 'forge', 'play'], true)) { + $intVideoId = (int)($arrPageParts[0] ?? 0); + $arrVideo = VideoModel::getInstance()->findOne(['v_id' => $intVideoId], [ + 'typeMap' => [ + 'root' => 'array', + 'document' => 'array', + 'array' => 'array', + ], + 'projection' => [ + '_id' => 0, + 'v_name_en' => 1, + ], + ]) ?? []; + $strSlug = trim((string)($arrVideo['v_name_en'] ?? '')); + + return match ($strScene) { + 'detail' => $UrlBuilder->detail($strSlug, $intVideoId), + 'forge' => $UrlBuilder->detailForge($strSlug, $intVideoId, max(1, (int)($arrPageParts[1] ?? 1))), + 'play' => $UrlBuilder->play($strSlug, $intVideoId, (string)($arrPageParts[1] ?? ''), max(1, (int)($arrPageParts[2] ?? 1))), + default => '', + }; + } + + return match ($strScene) { + 'home' => $UrlBuilder->home(), + 'category_index' => $UrlBuilder->categoryParent((string)($arrPageParts[0] ?? '')), + 'category_list' => $UrlBuilder->categoryChild((string)($arrPageParts[0] ?? ''), (string)($arrPageParts[1] ?? ''), max(1, (int)($arrPageParts[2] ?? 1))), + 'search' => $UrlBuilder->searchResult((string)($arrPageParts[0] ?? '')), + 'rank_index' => $UrlBuilder->rankIndex(), + 'rank_list' => $UrlBuilder->rankList((string)($arrPageParts[0] ?? 'daily')), + default => '', + }; +} + +function buildNeedles(string $strScene, array $arrData): array +{ + return match ($strScene) { + 'home', + 'category_index', + 'category_list', + 'search', + 'rank_index', + 'rank_list' => array_values(array_filter([ + trim((string)($arrData['intro_text'] ?? '')), + trim((string)($arrData['intro_meta'] ?? '')), + trim((string)($arrData['guide_cards'][0]['title'] ?? '')), + ])), + 'detail' => array_values(array_filter([ + trim((string)($arrData['detail_body_lead'] ?? '')), + trim((string)($arrData['detail_play_link_lead'] ?? '')), + ])), + 'forge' => array_values(array_filter([ + trim((string)($arrData['forge_detail_note'] ?? '')), + trim((string)($arrData['forge_body_lead'] ?? '')), + ])), + 'play' => array_values(array_filter([ + trim((string)($arrData['play_intro'] ?? '')), + trim((string)($arrData['play_body_lead'] ?? '')), + ])), + default => [], + }; +} + +function fetchPage(string $strUrl, string $strHost): array +{ + $Context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "Host: {$strHost}\r\nConnection: close", + 'ignore_errors' => true, + 'timeout' => 15, + ], + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ]); + + $strBody = @file_get_contents($strUrl, false, $Context); + $arrResponseHeaders = $http_response_header ?? []; + $intStatus = 0; + + foreach ($arrResponseHeaders 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, + ]; +} + +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); +} + +function verifyFrontItem(string $strHost, string $strScene, array $arrPageParts, string $strTargetRoot, string $strBaseUrl): array +{ + $strPageKey = SeoCopySchema::buildScenePageKey($strScene, $arrPageParts); + $arrData = SeoCopyStore::getPageDataFromRoot($strTargetRoot, $strHost, $strScene, $strPageKey); + $strPath = buildScenePath($strHost, $strScene, $arrPageParts); + + if (empty($arrData)) { + return [ + 'host' => $strHost, + 'scene' => $strScene, + 'page_parts' => $arrPageParts, + 'page_key' => $strPageKey, + 'path' => $strPath, + 'http_status' => 0, + 'all_matched' => false, + 'reason' => 'missing_seo_copy_data', + 'checks' => [], + ]; + } + + if ($strPath === '') { + return [ + 'host' => $strHost, + 'scene' => $strScene, + 'page_parts' => $arrPageParts, + 'page_key' => $strPageKey, + 'path' => '', + 'http_status' => 0, + 'all_matched' => false, + 'reason' => 'unresolved_path', + 'checks' => [], + ]; + } + + $strUrl = rtrim($strBaseUrl, '/') . $strPath; + $arrResponse = fetchPage($strUrl, $strHost); + $strNormalizedBody = normalizeForMatch((string)($arrResponse['body'] ?? '')); + $arrNeedles = buildNeedles($strScene, $arrData); + + $arrChecks = []; + foreach ($arrNeedles as $intIndex => $strNeedle) { + $arrChecks[] = [ + 'label' => 'needle_' . ($intIndex + 1), + 'matched' => $strNeedle !== '' && str_contains($strNormalizedBody, normalizeForMatch($strNeedle)), + 'preview' => mb_substr($strNeedle, 0, 120), + ]; + } + + return [ + 'host' => $strHost, + 'scene' => $strScene, + 'page_parts' => $arrPageParts, + 'page_key' => $strPageKey, + 'path' => $strPath, + 'http_status' => (int)($arrResponse['status'] ?? 0), + 'all_matched' => (int)($arrResponse['status'] ?? 0) === 200 && !in_array(false, array_column($arrChecks, 'matched'), true), + 'reason' => '', + 'checks' => $arrChecks, + ]; +} + +function verifyFrontManifest(array $arrManifest, string $strTargetRoot, string $strBaseUrl, string $strManifestPath): array +{ + $arrItems = []; + $intPassed = 0; + $intFailed = 0; + + foreach ($arrManifest as $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; + } + + $arrResult = verifyFrontItem($strHost, $strScene, $arrPageParts, $strTargetRoot, $strBaseUrl); + $arrItems[] = $arrResult; + + if ($arrResult['all_matched']) { + $intPassed++; + } else { + $intFailed++; + } + } + + return [ + 'manifest' => realpath($strManifestPath) ?: $strManifestPath, + 'target_root' => $strTargetRoot, + 'base_url' => $strBaseUrl, + 'total' => count($arrItems), + 'passed' => $intPassed, + 'failed' => $intFailed, + 'items' => $arrItems, + ]; +} + +function buildReleaseTag(string $strReleaseTag): string +{ + $strReleaseTag = strtolower(trim($strReleaseTag)); + $strReleaseTag = preg_replace('/[^a-z0-9\\-_]+/', '-', $strReleaseTag); + $strReleaseTag = trim((string)$strReleaseTag, '-_'); + + return $strReleaseTag !== '' ? $strReleaseTag : date('Ymd_His'); +} + +function buildManifestDigest(array $arrManifest): string +{ + return sha1(json_encode($arrManifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); +} + +function resolvePortalRoot(string $strPortalRootArg): string +{ + $strPortalRootArg = trim($strPortalRootArg); + if (in_array(strtolower($strPortalRootArg), ['0', 'false', 'off', 'disable', 'disabled'], true)) { + return ''; + } + + if ($strPortalRootArg !== '') { + return $strPortalRootArg; + } + + $strEnvValue = trim((string)getenv('SEO_COPY_RELEASE_PORTAL_ROOT')); + if (in_array(strtolower($strEnvValue), ['0', 'false', 'off', 'disable', 'disabled'], true)) { + return ''; + } + + return $strEnvValue !== '' ? $strEnvValue : dirname(__DIR__) . '/public/_seo_copy_release'; +} + +function refreshPortalRunArtifacts(string $strPortalRootArg, string $strRunsRoot): void +{ + $strPortalRoot = resolvePortalRoot($strPortalRootArg); + if ($strPortalRoot === '') { + return; + } + + $arrRunSummary = SeoCopyReleaseRunIndexHelper::buildSummary($strRunsRoot, true); + SeoCopyReleaseRunIndexHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/runs', $arrRunSummary); + SeoCopyPortalHomeHelper::writeArtifacts($strPortalRoot, SeoCopyPortalHomeHelper::buildSummary($strPortalRoot)); +} + +function publishManifest(array $arrManifest, string $strManifestPath, string $strSourceRoot, string $strTargetRoot, string $strReleaseTag, string $strLogDir, string $strApprovedDir, bool $boolDryRun, string $strPortalRootArg): array +{ + $strReleaseTag = buildReleaseTag($strReleaseTag); + $strManifestDigest = buildManifestDigest($arrManifest); + $strManifestArchivePath = rtrim($strApprovedDir, '/') . '/' . $strReleaseTag . '.manifest.json'; + $strPublishLogPath = rtrim($strLogDir, '/') . '/' . date('Ymd') . '/' . date('His') . '_' . $strReleaseTag . '.json'; + $strBackupRoot = rtrim($strLogDir, '/') . '/' . date('Ymd') . '/backups/' . $strReleaseTag; + $strLatestIndexPath = rtrim($strLogDir, '/') . '/latest-index.json'; + $strPortalRoot = resolvePortalRoot($strPortalRootArg); + + $arrSummary = [ + 'log_type' => 'publish', + 'created_at' => date('c'), + 'manifest' => realpath($strManifestPath) ?: $strManifestPath, + 'manifest_digest' => $strManifestDigest, + 'source_root' => realpath($strSourceRoot) ?: $strSourceRoot, + 'target_root' => $strTargetRoot, + 'dry_run' => $boolDryRun, + 'skip_existing' => false, + 'release_tag' => $strReleaseTag, + 'manifest_archive_path' => $strManifestArchivePath, + 'publish_log_path' => $strPublishLogPath, + 'backup_root' => $strBackupRoot, + 'latest_index_path' => $strLatestIndexPath, + 'portal_root' => $strPortalRoot, + 'portal_index_path' => $strPortalRoot !== '' ? rtrim($strPortalRoot, '/') . '/latest-index.html' : '', + 'published_count' => 0, + 'skipped_count' => 0, + 'published' => [], + 'skipped' => [], + ]; + + 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)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'reason' => 'invalid_manifest_item', + ]; + continue; + } + + $strPageKey = SeoCopySchema::buildScenePageKey($strScene, $arrPageParts); + $strSourcePath = SeoCopyStore::resolvePagePathFromRoot($strSourceRoot, $strHost, $strScene, $strPageKey); + $strTargetPath = SeoCopyStore::resolvePagePathFromRoot($strTargetRoot, $strHost, $strScene, $strPageKey); + + if ($strSourcePath === '' || !is_file($strSourcePath)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'reason' => 'source_missing', + ]; + continue; + } + + $arrPayload = SeoCopyStore::getPageDataFromRoot($strSourceRoot, $strHost, $strScene, $strPageKey); + if (empty($arrPayload)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'reason' => 'source_unreadable', + ]; + continue; + } + + $arrNormalized = normalizePayload($strScene, $arrPayload); + $arrErrors = validatePayload($strScene, $arrNormalized); + if (!empty($arrErrors)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'reason' => 'validation_failed', + 'errors' => $arrErrors, + ]; + continue; + } + + if (!$boolDryRun) { + $strTargetDir = dirname($strTargetPath); + if (!is_dir($strTargetDir) && !mkdir($strTargetDir, 0777, true) && !is_dir($strTargetDir)) { + $arrSummary['skipped'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'reason' => 'mkdir_failed', + ]; + continue; + } + + $strAction = is_file($strTargetPath) ? 'update' : 'create'; + $strBackupPath = ''; + if ($strAction === 'update') { + $strRelativeBackup = ltrim(str_replace(rtrim($strTargetRoot, '/'), '', $strTargetPath), '/'); + $strBackupPath = rtrim($strBackupRoot, '/') . '/' . $strRelativeBackup; + writeTextFile($strBackupPath, (string)file_get_contents($strTargetPath)); + } + + writeTextFile( + $strTargetPath, + json_encode($arrNormalized, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + } else { + $strAction = is_file($strTargetPath) ? 'update' : 'create'; + $strBackupPath = $strAction === 'update' + ? rtrim($strBackupRoot, '/') . '/' . ltrim(str_replace(rtrim($strTargetRoot, '/'), '', $strTargetPath), '/') + : ''; + } + + $arrSummary['published'][] = [ + 'index' => $intIndex, + 'host' => $strHost, + 'scene' => $strScene, + 'page_key' => $strPageKey, + 'source_path' => $strSourcePath, + 'target_path' => $strTargetPath, + 'action' => $strAction, + 'backup_path' => $strBackupPath, + 'sha1' => sha1(json_encode($arrNormalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)), + ]; + } + + $arrSummary['published_count'] = count($arrSummary['published']); + $arrSummary['skipped_count'] = count($arrSummary['skipped']); + + if (!$boolDryRun) { + writeJsonFile($strManifestArchivePath, [ + 'release_tag' => $strReleaseTag, + 'created_at' => date('c'), + 'source_manifest' => realpath($strManifestPath) ?: $strManifestPath, + 'manifest_digest' => $strManifestDigest, + 'items' => $arrManifest, + ]); + + $arrApprovedSummary = SeoCopyApprovedManifestHelper::buildSummary($strApprovedDir, true); + SeoCopyApprovedManifestHelper::writeArtifacts($strApprovedDir, $arrApprovedSummary); + if ($strPortalRoot !== '') { + SeoCopyApprovedManifestHelper::writeArtifacts(rtrim($strPortalRoot, '/') . '/approved', $arrApprovedSummary, true); + } + } + + writeJsonFile($strPublishLogPath, $arrSummary); + $arrLatestSummary = SeoCopyReleaseIndexHelper::buildSummary($strLogDir, true); + SeoCopyReleaseIndexHelper::writeSummary($strLatestIndexPath, $arrLatestSummary); + SeoCopyReleaseViewHelper::writeArtifacts($strLogDir, $arrLatestSummary); + if ($strPortalRoot !== '') { + SeoCopyReleasePortalHelper::writeArtifacts($strPortalRoot, $arrLatestSummary); + SeoCopyPortalHomeHelper::writeArtifacts($strPortalRoot, SeoCopyPortalHomeHelper::buildSummary($strPortalRoot)); + } + + return $arrSummary; +} + +function renderTextSummary(array $arrSummary): string +{ + $arrLines = [ + 'status: ' . (string)($arrSummary['status'] ?? ''), + 'manifest: ' . (string)($arrSummary['manifest'] ?? ''), + 'source_root: ' . (string)($arrSummary['source_root'] ?? ''), + 'target_root: ' . (string)($arrSummary['target_root'] ?? ''), + 'release_tag: ' . (string)($arrSummary['release_tag'] ?? ''), + 'run_dir: ' . (string)($arrSummary['run_dir'] ?? ''), + 'base_url: ' . (string)($arrSummary['base_url'] ?? ''), + ]; + + if (!empty($arrSummary['verify'])) { + $arrLines[] = 'verify: checked=' . (int)($arrSummary['verify']['checked_files'] ?? 0) + . ', readable=' . (int)($arrSummary['verify']['readable_files'] ?? 0) + . ', issues=' . count((array)($arrSummary['verify']['issues'] ?? [])); + } + + if (!empty($arrSummary['front_verify'])) { + $arrLines[] = 'front_verify: total=' . (int)($arrSummary['front_verify']['total'] ?? 0) + . ', passed=' . (int)($arrSummary['front_verify']['passed'] ?? 0) + . ', failed=' . (int)($arrSummary['front_verify']['failed'] ?? 0); + } + + if (!empty($arrSummary['publish'])) { + $arrLines[] = 'publish: published=' . (int)($arrSummary['publish']['published_count'] ?? 0) + . ', skipped=' . (int)($arrSummary['publish']['skipped_count'] ?? 0) + . ', latest_index=' . (string)($arrSummary['publish']['latest_index_path'] ?? '') + . ', portal_index=' . (string)($arrSummary['publish']['portal_index_path'] ?? ''); + } + + if (!empty($arrSummary['error'])) { + $arrLines[] = 'error: ' . (string)$arrSummary['error']; + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +function seoCopyReleaseRunDefaultOptions(): array +{ + return [ + 'manifest_path' => '', + 'source_root' => '', + 'target_root' => dirname(__DIR__) . '/data/seo_copy', + 'release_tag' => date('Ymd_His'), + 'log_dir' => dirname(__DIR__) . '/storage/seo_copy_publish_logs', + 'approved_dir' => dirname(__DIR__) . '/data/seo_copy_jobs/approved', + 'portal_root_arg' => '', + 'base_url' => '', + 'temp_port' => 8920, + 'php_bin' => PHP_BINARY ?: 'php', + 'dry_run' => false, + 'format' => 'json', + ]; +} + +function seoCopyReleaseRunExecute(array $arrOptions): array +{ + $arrOptions = array_merge(seoCopyReleaseRunDefaultOptions(), $arrOptions); + + $strManifestPath = trim((string)($arrOptions['manifest_path'] ?? '')); + $strSourceRoot = trim((string)($arrOptions['source_root'] ?? '')); + $strTargetRoot = trim((string)($arrOptions['target_root'] ?? '')); + $strReleaseTag = trim((string)($arrOptions['release_tag'] ?? '')); + $strLogDir = trim((string)($arrOptions['log_dir'] ?? '')); + $strApprovedDir = trim((string)($arrOptions['approved_dir'] ?? '')); + $strPortalRootArg = trim((string)($arrOptions['portal_root_arg'] ?? '')); + $strBaseUrl = rtrim(trim((string)($arrOptions['base_url'] ?? '')), '/'); + $intTempPort = max(1024, (int)($arrOptions['temp_port'] ?? 8920)); + $strPhpBin = trim((string)($arrOptions['php_bin'] ?? 'php')); + $boolDryRun = (bool)($arrOptions['dry_run'] ?? false); + + if ($strManifestPath === '' || !is_file($strManifestPath)) { + $strManifestPath = resolveManifestPath($strManifestPath, $strApprovedDir); + } + + if ($strManifestPath === '' || !is_file($strManifestPath)) { + throw new RuntimeException('Manifest file not found: ' . $strManifestPath); + } + + if ($strSourceRoot === '' || !is_dir($strSourceRoot)) { + throw new RuntimeException('Source root not found: ' . $strSourceRoot); + } + + $arrManifest = json_decode((string)file_get_contents($strManifestPath), true); + if (!is_array($arrManifest)) { + throw new RuntimeException('Manifest must be a JSON array.'); + } + + $strRunDir = dirname(__DIR__) . '/storage/seo_copy_release_runs/' . date('Ymd') . '/' . date('His') . '_' . preg_replace('/[^a-zA-Z0-9\\-_]+/', '-', $strReleaseTag); + if (!is_dir($strRunDir) && !mkdir($strRunDir, 0777, true) && !is_dir($strRunDir)) { + throw new RuntimeException('Failed to create run dir: ' . $strRunDir); + } + + $strCodeRoot = dirname(__DIR__); + $strRunsRoot = dirname(__DIR__) . '/storage/seo_copy_release_runs'; + $strHost = firstHostFromManifest($arrManifest); + $arrSummary = [ + 'status' => 'starting', + 'manifest' => realpath($strManifestPath) ?: $strManifestPath, + 'source_root' => realpath($strSourceRoot) ?: $strSourceRoot, + 'target_root' => $strTargetRoot, + 'release_tag' => buildReleaseTag($strReleaseTag), + 'run_dir' => $strRunDir, + 'base_url' => $strBaseUrl, + 'verify' => [], + 'front_verify' => [], + 'publish' => [], + 'artifacts' => [], + ]; + + $Server = null; + $intExitCode = 0; + + try { + if ($strBaseUrl === '') { + if (!canSpawnProcess()) { + throw new RuntimeException('Current PHP CLI disabled proc_open; please pass --base-url for front verify.'); + } + + $Server = startTempServer( + $strCodeRoot, + $strSourceRoot, + $intTempPort, + $strPhpBin, + $strHost, + $strRunDir . '/temp-server.log' + ); + $strBaseUrl = (string)$Server['base_url']; + $arrSummary['base_url'] = $strBaseUrl; + $arrSummary['artifacts']['temp_server_log'] = (string)$Server['log_path']; + } + + $arrVerifySummary = verifySourceRoot($strSourceRoot); + $arrSummary['verify'] = $arrVerifySummary; + writeJsonFile($strRunDir . '/batch_verify.summary.json', $arrVerifySummary); + + $boolVerifyPassed = (int)($arrVerifySummary['checked_files'] ?? 0) > 0 + && (int)($arrVerifySummary['checked_files'] ?? 0) === (int)($arrVerifySummary['readable_files'] ?? 0) + && empty($arrVerifySummary['issues']); + + if (!$boolVerifyPassed) { + $arrSummary['status'] = 'verify_failed'; + $intExitCode = 2; + writeJsonFile($strRunDir . '/run-summary.json', $arrSummary); + refreshPortalRunArtifacts($strPortalRootArg, $strRunsRoot); + + return [ + 'exit_code' => $intExitCode, + 'summary' => $arrSummary, + ]; + } + + $arrFrontVerifySummary = verifyFrontManifest($arrManifest, $strSourceRoot, $strBaseUrl, $strManifestPath); + $arrSummary['front_verify'] = $arrFrontVerifySummary; + writeJsonFile($strRunDir . '/batch_front_verify.summary.json', $arrFrontVerifySummary); + + $boolFrontVerifyPassed = (int)($arrFrontVerifySummary['total'] ?? 0) > 0 + && (int)($arrFrontVerifySummary['failed'] ?? 0) === 0 + && (int)($arrFrontVerifySummary['passed'] ?? 0) === (int)($arrFrontVerifySummary['total'] ?? 0); + + if (!$boolFrontVerifyPassed) { + $arrSummary['status'] = 'front_verify_failed'; + $intExitCode = 3; + writeJsonFile($strRunDir . '/run-summary.json', $arrSummary); + refreshPortalRunArtifacts($strPortalRootArg, $strRunsRoot); + + return [ + 'exit_code' => $intExitCode, + 'summary' => $arrSummary, + ]; + } + + $arrPublishSummary = publishManifest( + $arrManifest, + $strManifestPath, + $strSourceRoot, + $strTargetRoot, + $strReleaseTag, + $strLogDir, + $strApprovedDir, + $boolDryRun, + $strPortalRootArg + ); + $arrSummary['publish'] = $arrPublishSummary; + $arrSummary['status'] = $boolDryRun ? 'dry_run_passed' : 'published'; + $arrSummary['artifacts']['run_summary'] = $strRunDir . '/run-summary.json'; + + writeJsonFile($strRunDir . '/batch_publish.summary.json', $arrPublishSummary); + writeJsonFile($strRunDir . '/run-summary.json', $arrSummary); + refreshPortalRunArtifacts($strPortalRootArg, $strRunsRoot); + + return [ + 'exit_code' => 0, + 'summary' => $arrSummary, + ]; + } catch (Throwable $e) { + $arrSummary['status'] = 'error'; + $arrSummary['error'] = $e->getMessage(); + $intExitCode = 1; + writeJsonFile($strRunDir . '/run-summary.json', $arrSummary); + refreshPortalRunArtifacts($strPortalRootArg, $strRunsRoot); + + return [ + 'exit_code' => $intExitCode, + 'summary' => $arrSummary, + ]; + } finally { + if (is_array($Server) && isset($Server['process'])) { + stopTempServer($Server['process']); + } + } +} + +function seoCopyReleaseRunParseCliArgs(array $arrArgs): array +{ + $arrOptions = seoCopyReleaseRunDefaultOptions(); + + foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--source-root=')) { + $arrOptions['source_root'] = trim(substr($strArg, strlen('--source-root='))); + continue; + } + + if (str_starts_with($strArg, '--target-root=')) { + $arrOptions['target_root'] = trim(substr($strArg, strlen('--target-root='))); + continue; + } + + if (str_starts_with($strArg, '--release-tag=')) { + $arrOptions['release_tag'] = trim(substr($strArg, strlen('--release-tag='))); + continue; + } + + if (str_starts_with($strArg, '--log-dir=')) { + $arrOptions['log_dir'] = trim(substr($strArg, strlen('--log-dir='))); + continue; + } + + if (str_starts_with($strArg, '--approved-dir=')) { + $arrOptions['approved_dir'] = trim(substr($strArg, strlen('--approved-dir='))); + continue; + } + + if (str_starts_with($strArg, '--portal-root=')) { + $arrOptions['portal_root_arg'] = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + + if (str_starts_with($strArg, '--base-url=')) { + $arrOptions['base_url'] = rtrim(trim(substr($strArg, strlen('--base-url='))), '/'); + continue; + } + + if (str_starts_with($strArg, '--temp-port=')) { + $arrOptions['temp_port'] = max(1024, (int)trim(substr($strArg, strlen('--temp-port=')))); + continue; + } + + if (str_starts_with($strArg, '--php-bin=')) { + $arrOptions['php_bin'] = trim(substr($strArg, strlen('--php-bin='))); + continue; + } + + if (str_starts_with($strArg, '--dry-run=')) { + $arrOptions['dry_run'] = parseBoolArg(substr($strArg, strlen('--dry-run='))); + continue; + } + + if (str_starts_with($strArg, '--format=')) { + $arrOptions['format'] = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ((string)$arrOptions['manifest_path'] === '') { + $arrOptions['manifest_path'] = $strArg; + } + } + + return $arrOptions; +} + +function seoCopyReleaseRunCliMain(array $argv): int +{ + $arrArgs = $argv; + array_shift($arrArgs); + + if (empty($arrArgs)) { + printUsage(); + return 1; + } + + $arrOptions = seoCopyReleaseRunParseCliArgs($arrArgs); + $strFormat = strtolower(trim((string)($arrOptions['format'] ?? 'json'))); + + try { + $arrResult = seoCopyReleaseRunExecute($arrOptions); + $arrSummary = (array)($arrResult['summary'] ?? []); + $intExitCode = (int)($arrResult['exit_code'] ?? 0); + + if ($strFormat === 'text') { + echo renderTextSummary($arrSummary); + if (!empty($arrSummary['error'])) { + fwrite(STDERR, (string)$arrSummary['error'] . PHP_EOL); + } + } else { + echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + } + + return $intExitCode; + } catch (Throwable $e) { + if ($strFormat === 'text') { + fwrite(STDERR, $e->getMessage() . PHP_EOL); + printUsage(); + } else { + echo json_encode([ + 'status' => 'error', + 'message' => $e->getMessage(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + } + + return 1; + } +} + +if (PHP_SAPI === 'cli' && realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + exit(seoCopyReleaseRunCliMain($argv)); +} diff --git a/code/scripts/seo_copy_release_run_index.php b/code/scripts/seo_copy_release_run_index.php new file mode 100644 index 0000000..86cc853 --- /dev/null +++ b/code/scripts/seo_copy_release_run_index.php @@ -0,0 +1,61 @@ +getMessage() . PHP_EOL); + printUsage(); + exit(1); +} + +if ($strFormat === 'text') { + echo json_encode([ + 'runs_root' => realpath($strRunsRoot) ?: $strRunsRoot, + 'output_root' => realpath($strOutputRoot) ?: $strOutputRoot, + 'runs' => (int)($arrSummary['runs_count'] ?? 0), + 'latest_run' => (string)(($arrSummary['latest_run'] ?? [])['run_name'] ?? ''), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; + exit(0); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_runtime_acceptance_failed_hosts_run.php b/code/scripts/seo_copy_runtime_acceptance_failed_hosts_run.php new file mode 100644 index 0000000..0f24fd5 --- /dev/null +++ b/code/scripts/seo_copy_runtime_acceptance_failed_hosts_run.php @@ -0,0 +1,85 @@ + $strHost, + 'category' => $strCategory, + 'limit' => $intLimit, + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, +]); + +if ($strFormat === 'text') { + echo 'status: ' . (string)($arrResult['status'] ?? '') . PHP_EOL; + echo 'host_filter: ' . (string)($arrResult['host'] ?? '') . PHP_EOL; + echo 'category_filter: ' . (string)($arrResult['category'] ?? '') . PHP_EOL; + echo 'failed_hosts_count: ' . (int)($arrResult['failed_hosts_count'] ?? 0) . PHP_EOL; + echo 'processed_count: ' . (int)($arrResult['processed_count'] ?? 0) . PHP_EOL; + echo 'passed_count: ' . (int)($arrResult['passed_count'] ?? 0) . PHP_EOL; + echo 'failed_count: ' . (int)($arrResult['failed_count'] ?? 0) . PHP_EOL; + foreach ((array)($arrResult['hosts'] ?? []) as $arrHost) { + if (!is_array($arrHost)) { + continue; + } + echo '- ' . (string)($arrHost['host'] ?? '') + . ' status=' . (string)($arrHost['status'] ?? '') + . ' samples=' . (int)($arrHost['configured_samples'] ?? 0) + . ' processed=' . (int)($arrHost['processed_count'] ?? 0) + . ' passed=' . (int)($arrHost['passed_count'] ?? 0) + . ' failed=' . (int)($arrHost['failed_count'] ?? 0) + . PHP_EOL; + } +} else { + echo json_encode($arrResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} + +exit(((int)($arrResult['failed_count'] ?? 0)) === 0 ? 0 : 1); diff --git a/code/scripts/seo_copy_runtime_acceptance_forge_run.php b/code/scripts/seo_copy_runtime_acceptance_forge_run.php new file mode 100644 index 0000000..fbda90e --- /dev/null +++ b/code/scripts/seo_copy_runtime_acceptance_forge_run.php @@ -0,0 +1,124 @@ + [] [--forge-id=1] [--base-url=https://host] [--run-root=/abs/path] [--portal-root=/abs/path|off] [--target-root=/abs/path] [--format=json|text]\n"; +} + +function renderText(array $arrSummary): string +{ + $arrLines = [ + 'host: ' . (string)($arrSummary['host'] ?? ''), + 'status: ' . (string)($arrSummary['status'] ?? ''), + 'configured_samples: ' . (int)($arrSummary['configured_samples'] ?? 0), + ]; + + if (!empty($arrSummary['selected_sample'])) { + $arrLines[] = 'selected_sample: video=' . (int)($arrSummary['selected_sample']['video_id'] ?? 0) + . ', forge=' . (int)($arrSummary['selected_sample']['forge_id'] ?? 0); + } + + if (!empty($arrSummary['run']) && is_array($arrSummary['run'])) { + $arrLines[] = 'run_name: ' . (string)($arrSummary['run']['run_name'] ?? ''); + $arrLines[] = 'all_passed: ' . (!empty($arrSummary['run']['all_passed']) ? 'yes' : 'no'); + foreach ((array)($arrSummary['run']['checks'] ?? []) as $arrCheck) { + if (!is_array($arrCheck)) { + continue; + } + $arrLines[] = '- ' . (!empty($arrCheck['passed']) ? '[ok] ' : '[fail] ') + . (string)($arrCheck['label'] ?? '') + . ': ' . (string)($arrCheck['detail'] ?? ''); + } + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (count($arrArgs) < 1) { + printUsage(); + exit(1); +} + +$strHost = ''; +$intVideoId = 0; +$intForgeId = 1; +$strBaseUrl = ''; +$strRunRoot = dirname(__DIR__) . '/storage/seo_copy_runtime_acceptance_runs'; +$strPortalRoot = dirname(__DIR__) . '/public/_seo_copy_release'; +$strTargetRoot = dirname(__DIR__) . '/data/seo_copy'; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--forge-id=')) { + $intForgeId = max(1, (int)substr($strArg, strlen('--forge-id='))); + continue; + } + if (str_starts_with($strArg, '--base-url=')) { + $strBaseUrl = rtrim(substr($strArg, strlen('--base-url=')), '/'); + continue; + } + if (str_starts_with($strArg, '--run-root=')) { + $strRunRoot = trim(substr($strArg, strlen('--run-root='))); + continue; + } + if (str_starts_with($strArg, '--portal-root=')) { + $strPortalRoot = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + if (str_starts_with($strArg, '--target-root=')) { + $strTargetRoot = trim(substr($strArg, strlen('--target-root='))); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + continue; + } + if ($intVideoId === 0) { + $intVideoId = max(0, (int)$strArg); + continue; + } +} + +if ($strHost === '') { + printUsage(); + exit(1); +} + +$arrResult = $intVideoId > 0 + ? SeoCopyRuntimeAcceptanceHelper::run($strHost, $intVideoId, [ + 'forge_id' => $intForgeId, + 'base_url' => $strBaseUrl !== '' ? $strBaseUrl : ('https://' . $strHost), + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, + 'target_root' => $strTargetRoot, + ]) + : SeoCopyRuntimeAcceptanceHelper::runForgeConfiguredSample($strHost, [ + 'base_url' => $strBaseUrl !== '' ? $strBaseUrl : ('https://' . $strHost), + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, + 'target_root' => $strTargetRoot, + ]); + +if ($strFormat === 'text') { + echo renderText($arrResult); +} else { + echo json_encode($arrResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} + +exit(!empty($arrResult['run']['all_passed'] ?? $arrResult['all_passed'] ?? false) ? 0 : 1); diff --git a/code/scripts/seo_copy_runtime_acceptance_host_run.php b/code/scripts/seo_copy_runtime_acceptance_host_run.php new file mode 100644 index 0000000..0c29248 --- /dev/null +++ b/code/scripts/seo_copy_runtime_acceptance_host_run.php @@ -0,0 +1,85 @@ + [--base-url=https://host] [--run-root=/abs/path] [--portal-root=/abs/path|off] [--format=json|text]\n"; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (count($arrArgs) < 1) { + printUsage(); + exit(1); +} + +$strHost = ''; +$strBaseUrl = ''; +$strRunRoot = dirname(__DIR__) . '/storage/seo_copy_runtime_acceptance_runs'; +$strPortalRoot = dirname(__DIR__) . '/public/_seo_copy_release'; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--base-url=')) { + $strBaseUrl = rtrim(substr($strArg, strlen('--base-url=')), '/'); + continue; + } + if (str_starts_with($strArg, '--run-root=')) { + $strRunRoot = trim(substr($strArg, strlen('--run-root='))); + continue; + } + if (str_starts_with($strArg, '--portal-root=')) { + $strPortalRoot = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + } +} + +if ($strHost === '') { + printUsage(); + exit(1); +} + +$arrResult = SeoCopyRuntimeAcceptanceHelper::runHostConfiguredSamples($strHost, [ + 'base_url' => $strBaseUrl !== '' ? $strBaseUrl : ('https://' . $strHost), + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, +]); + +if ($strFormat === 'text') { + echo 'host: ' . (string)($arrResult['host'] ?? '') . PHP_EOL; + echo 'status: ' . (string)($arrResult['status'] ?? '') . PHP_EOL; + echo 'configured_samples: ' . (int)($arrResult['configured_samples'] ?? 0) . PHP_EOL; + echo 'processed_count: ' . (int)($arrResult['processed_count'] ?? 0) . PHP_EOL; + echo 'passed_count: ' . (int)($arrResult['passed_count'] ?? 0) . PHP_EOL; + echo 'failed_count: ' . (int)($arrResult['failed_count'] ?? 0) . PHP_EOL; + foreach ((array)($arrResult['targets'] ?? []) as $arrTarget) { + if (!is_array($arrTarget)) { + continue; + } + echo '- ' . (string)($arrTarget['host'] ?? '') + . ' video=' . (int)($arrTarget['video_id'] ?? 0) + . ' forge=' . (int)($arrTarget['forge_id'] ?? 0) + . ' status=' . (string)($arrTarget['status'] ?? '') + . PHP_EOL; + } +} else { + echo json_encode($arrResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} + +exit(((int)($arrResult['failed_count'] ?? 0)) === 0 ? 0 : 1); diff --git a/code/scripts/seo_copy_runtime_acceptance_run.php b/code/scripts/seo_copy_runtime_acceptance_run.php new file mode 100644 index 0000000..197fe7a --- /dev/null +++ b/code/scripts/seo_copy_runtime_acceptance_run.php @@ -0,0 +1,81 @@ + [--forge-id=1] [--base-url=https://host] [--run-root=/abs/path] [--portal-root=/abs/path|off] [--format=json|text]\n"; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (count($arrArgs) < 2) { + printUsage(); + exit(1); +} + +$strHost = ''; +$intVideoId = 0; +$intForgeId = 1; +$strBaseUrl = ''; +$strRunRoot = dirname(__DIR__) . '/storage/seo_copy_runtime_acceptance_runs'; +$strPortalRoot = dirname(__DIR__) . '/public/_seo_copy_release'; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--forge-id=')) { + $intForgeId = max(1, (int)substr($strArg, strlen('--forge-id='))); + continue; + } + if (str_starts_with($strArg, '--base-url=')) { + $strBaseUrl = rtrim(substr($strArg, strlen('--base-url=')), '/'); + continue; + } + if (str_starts_with($strArg, '--run-root=')) { + $strRunRoot = trim(substr($strArg, strlen('--run-root='))); + continue; + } + if (str_starts_with($strArg, '--portal-root=')) { + $strPortalRoot = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + continue; + } + if ($intVideoId === 0) { + $intVideoId = (int)$strArg; + continue; + } +} + +if ($strHost === '' || $intVideoId <= 0) { + printUsage(); + exit(1); +} + +$arrResult = SeoCopyRuntimeAcceptanceHelper::run($strHost, $intVideoId, [ + 'forge_id' => $intForgeId, + 'base_url' => $strBaseUrl !== '' ? $strBaseUrl : ('https://' . $strHost), + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, +]); + +echo json_encode($arrResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +exit(!empty($arrResult['all_passed']) ? 0 : 1); diff --git a/code/scripts/seo_copy_runtime_acceptance_verify.php b/code/scripts/seo_copy_runtime_acceptance_verify.php new file mode 100644 index 0000000..3bcfdff --- /dev/null +++ b/code/scripts/seo_copy_runtime_acceptance_verify.php @@ -0,0 +1,217 @@ + [--forge-id=1] [--base-url=https://host] [--format=json|text]\n"; +} + +function runChildScript(string $strScript, array $arrArgs = []): array +{ + $arrCommand = array_merge([PHP_BINARY, $strScript], $arrArgs); + $arrDescriptor = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $Process = proc_open($arrCommand, $arrDescriptor, $arrPipes, dirname(__DIR__)); + if (!is_resource($Process)) { + return [ + 'ok' => false, + 'exit_code' => 1, + 'stdout' => '', + 'stderr' => 'proc_open failed', + 'json' => null, + ]; + } + + 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($strStdout, true); + if (!is_array($arrJson)) { + $intJsonStart = strpos($strStdout, '{'); + if ($intJsonStart !== false) { + $strJsonPayload = substr($strStdout, $intJsonStart); + $arrJson = json_decode($strJsonPayload, true); + } + } + + return [ + 'ok' => $intExitCode === 0, + 'exit_code' => $intExitCode, + 'stdout' => is_string($strStdout) ? $strStdout : '', + 'stderr' => is_string($strStderr) ? $strStderr : '', + 'json' => is_array($arrJson) ? $arrJson : null, + ]; +} + +function renderText(array $arrSummary): string +{ + $arrLines = [ + 'host: ' . $arrSummary['host'], + 'video_id: ' . $arrSummary['video_id'], + 'forge_id: ' . $arrSummary['forge_id'], + 'all_passed: ' . ($arrSummary['all_passed'] ? 'yes' : 'no'), + 'suites:', + ]; + + foreach ($arrSummary['suites'] as $strSuiteKey => $arrSuite) { + $arrLines[] = '- ' . (($arrSuite['passed'] ?? false) ? '[ok] ' : '[fail] ') . $strSuiteKey + . ': exit=' . (int)($arrSuite['exit_code'] ?? 1) + . ', checks=' . (int)($arrSuite['check_count'] ?? 0) + . ', passed=' . (int)($arrSuite['passed_count'] ?? 0) + . ', failed=' . (int)($arrSuite['failed_count'] ?? 0); + if (!empty($arrSuite['summary'])) { + $arrLines[] = ' ' . $arrSuite['summary']; + } + } + + $arrLines[] = 'checks:'; + foreach ($arrSummary['checks'] as $arrCheck) { + $arrLines[] = '- ' . ($arrCheck['passed'] ? '[ok] ' : '[fail] ') . $arrCheck['label'] . ': ' . $arrCheck['detail']; + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (count($arrArgs) < 2) { + printUsage(); + exit(1); +} + +$strHost = ''; +$intVideoId = 0; +$intForgeId = 1; +$strBaseUrl = ''; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--forge-id=')) { + $intForgeId = max(1, (int)substr($strArg, strlen('--forge-id='))); + continue; + } + if (str_starts_with($strArg, '--base-url=')) { + $strBaseUrl = rtrim(substr($strArg, strlen('--base-url=')), '/'); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + continue; + } + if ($intVideoId === 0) { + $intVideoId = (int)$strArg; + continue; + } +} + +if ($strHost === '' || $intVideoId <= 0) { + printUsage(); + exit(1); +} + +$strBaseUrl = $strBaseUrl !== '' ? $strBaseUrl : ('https://' . $strHost); +$strScriptsDir = __DIR__; + +$arrSuiteDefs = [ + 'forge_route_group' => [ + 'script' => $strScriptsDir . '/seo_copy_forge_route_group_verify.php', + 'args' => [$strHost, (string)$intVideoId, (string)$intForgeId, '--base-url=' . $strBaseUrl, '--format=json'], + 'summary' => static function (array $arrJson): string { + return 'group=' . (string)($arrJson['forge_group'] ?? '-') + . ', detail=' . (string)($arrJson['detail_path'] ?? '-') + . ', forge=' . (string)($arrJson['forge_path'] ?? '-'); + }, + ], + 'forge_output_runtime' => [ + 'script' => $strScriptsDir . '/seo_copy_forge_output_verify.php', + 'args' => [$strHost, (string)$intVideoId, '--base-url=' . $strBaseUrl, '--format=json'], + 'summary' => 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'] ?? [])); + }, + ], + 'play_runtime' => [ + 'script' => $strScriptsDir . '/seo_copy_play_route_verify.php', + 'args' => [$strHost, (string)$intVideoId, '--base-url=' . $strBaseUrl, '--format=json'], + 'summary' => static function (array $arrJson): string { + return 'play=' . (string)($arrJson['play_path'] ?? '-') + . ', type=' . (string)($arrJson['play_type'] ?? '-') + . ', index=' . (int)($arrJson['play_index'] ?? 0); + }, + ], + 'forge_output_files' => [ + 'script' => $strScriptsDir . '/seo_copy_forge_output_file_verify.php', + 'args' => [$strHost, (string)$intVideoId, '--format=json'], + 'summary' => static function (array $arrJson): string { + return 'xml=' . (int)($arrJson['expanded_xml_entries'] ?? 0) + . ', json=' . (int)($arrJson['expanded_json_count'] ?? 0); + }, + ], +]; + +$arrSuites = []; +$arrChecks = []; + +foreach ($arrSuiteDefs as $strSuiteKey => $arrSuiteDef) { + $arrChild = runChildScript((string)$arrSuiteDef['script'], (array)$arrSuiteDef['args']); + $arrJson = is_array($arrChild['json']) ? $arrChild['json'] : []; + $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); + $boolPassed = $arrChild['ok'] && !empty($arrJson) && !empty($arrJson['all_passed']); + + $arrSuites[$strSuiteKey] = [ + 'passed' => $boolPassed, + 'exit_code' => (int)($arrChild['exit_code'] ?? 1), + 'check_count' => $intCheckCount, + 'passed_count' => $intPassedCount, + 'failed_count' => $intFailedCount, + 'summary' => !empty($arrJson) && is_callable($arrSuiteDef['summary']) ? $arrSuiteDef['summary']($arrJson) : trim((string)($arrChild['stderr'] ?? '')), + 'stderr' => (string)($arrChild['stderr'] ?? ''), + 'json' => $arrJson, + ]; + + $arrChecks[] = [ + 'label' => $strSuiteKey . '_all_passed', + 'passed' => $boolPassed, + 'detail' => 'exit=' . (int)($arrChild['exit_code'] ?? 1) . ', failed_checks=' . $intFailedCount, + ]; +} + +$boolAllPassed = !in_array(false, array_column($arrChecks, 'passed'), true); + +$arrSummary = [ + 'host' => $strHost, + 'video_id' => $intVideoId, + 'forge_id' => $intForgeId, + 'suites' => $arrSuites, + 'checks' => $arrChecks, + 'all_passed' => $boolAllPassed, + 'verified_at' => date(DATE_ATOM), +]; + +if ($strFormat === 'text') { + echo renderText($arrSummary); + exit($boolAllPassed ? 0 : 1); +} + +echo json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL; +exit($boolAllPassed ? 0 : 1); diff --git a/code/scripts/seo_copy_runtime_governance_run.php b/code/scripts/seo_copy_runtime_governance_run.php new file mode 100644 index 0000000..0d7fd79 --- /dev/null +++ b/code/scripts/seo_copy_runtime_governance_run.php @@ -0,0 +1,416 @@ + [] [--forge-id=1] [--manifest=/abs/path] [--base-url=http://127.0.0.1:8910] [--target-root=/abs/path] [--run-root=/abs/path] [--portal-root=/abs/path|off] [--format=json|text]\n"; +} + +function ensureAppInitialized(): void +{ + static $boolInitialized = false; + if ($boolInitialized) { + return; + } + + (new App())->initialize(); + $boolInitialized = true; +} + +function runChildScript(string $strScript, array $arrArgs = []): array +{ + $arrCommand = array_merge([PHP_BINARY, $strScript], $arrArgs); + $arrDescriptor = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $Process = proc_open($arrCommand, $arrDescriptor, $arrPipes, dirname(__DIR__)); + if (!is_resource($Process)) { + return [ + 'ok' => false, + 'exit_code' => 1, + 'stdout' => '', + 'stderr' => 'proc_open failed', + 'json' => null, + ]; + } + + 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($strStdout, true); + if (!is_array($arrJson)) { + $intJsonStart = strpos($strStdout, '{'); + if ($intJsonStart !== false) { + $arrJson = json_decode(substr($strStdout, $intJsonStart), true); + } + } + + return [ + 'ok' => $intExitCode === 0, + 'exit_code' => $intExitCode, + 'stdout' => (string)$strStdout, + 'stderr' => (string)$strStderr, + 'json' => is_array($arrJson) ? $arrJson : null, + ]; +} + +function normalizeHost(string $strHost): string +{ + return DomainModel::normalizeHost($strHost); +} + +function resolvePath(string $strPath, string $strBaseDir): string +{ + $strPath = trim($strPath); + if ($strPath === '') { + return ''; + } + + if (str_starts_with($strPath, '/') || preg_match('/^[A-Za-z]:[\\\\\\/]/', $strPath) === 1) { + return $strPath; + } + + $strResolved = realpath($strBaseDir . '/' . ltrim($strPath, '/\\')); + if ($strResolved !== false) { + return $strResolved; + } + + return rtrim($strBaseDir, '/') . '/' . ltrim($strPath, '/\\'); +} + +function buildGovernanceMatrix(string $strHostFilter = '', string $strMatchTypeFilter = ''): array +{ + ensureAppInitialized(); + + $arrRows = (new DomainModel())->select()->toArray(); + $arrItems = []; + $arrTotals = [ + 'total' => 0, + 'exact' => 0, + 'wildcard' => 0, + 'seed_domain' => 0, + 'seed_host' => 0, + 'runtime_ready' => 0, + 'runtime_missing' => 0, + ]; + + foreach ($arrRows as $arrRow) { + if (!is_array($arrRow)) { + continue; + } + + $strDomain = normalizeHost((string)($arrRow['d_domain'] ?? '')); + if ($strHostFilter !== '' && $strHostFilter !== $strDomain) { + continue; + } + + $strMatchType = DomainModel::normalizeMatchType((string)($arrRow['d_match_type'] ?? DomainModel::MATCH_TYPE_EXACT)); + if ($strMatchTypeFilter !== '' && $strMatchTypeFilter !== $strMatchType) { + continue; + } + + $strParentDomain = DomainModel::normalizeParentDomain((string)($arrRow['d_parent_domain'] ?? '')); + $strSeedScope = DomainModel::normalizeSeedScope((string)($arrRow['d_seed_scope'] ?? DomainModel::SEED_SCOPE_DOMAIN)); + $arrSeoCfg = DomainModel::normalizeSeoCfg((array)($arrRow['d_seo_cfg'] ?? []), null); + $arrRuntimeSamples = (array)($arrSeoCfg['runtime_acceptance']['samples'] ?? []); + $boolRuntimeReady = !empty($arrSeoCfg['runtime_acceptance']['enable']) && !empty($arrRuntimeSamples); + $strDiagnosticHost = $strMatchType === DomainModel::MATCH_TYPE_WILDCARD && $strParentDomain !== '' + ? 'governance.' . $strParentDomain + : $strDomain; + $arrResolved = DomainModel::resolveDomainContextByHost($strDiagnosticHost); + + $arrItems[] = [ + 'host' => $strDomain, + 'stored_domain' => DomainModel::normalizeStoredDomain($strDomain, $strMatchType), + 'match_type' => $strMatchType, + 'parent_domain' => $strParentDomain, + 'seed_scope' => $strSeedScope, + 'runtime_acceptance_enabled' => !empty($arrSeoCfg['runtime_acceptance']['enable']), + 'runtime_samples_count' => count($arrRuntimeSamples), + 'runtime_samples' => array_values(array_map(static function ($arrSample): array { + return [ + 'video_id' => (int)($arrSample['video_id'] ?? 0), + 'forge_id' => (int)($arrSample['forge_id'] ?? 0), + ]; + }, $arrRuntimeSamples)), + 'diagnostic_host' => $strDiagnosticHost, + 'resolved_match_type' => (string)($arrResolved['match_type'] ?? ''), + 'resolved_match_reason' => (string)($arrResolved['match_reason'] ?? ''), + 'resolved_seed_scope' => (string)($arrResolved['seed_scope'] ?? ''), + 'resolved_from_cache' => !empty($arrResolved['resolved_from_cache']), + 'resolved_lookup_state' => (string)($arrResolved['resolved_lookup_state'] ?? ''), + ]; + + $arrTotals['total']++; + $arrTotals[$strMatchType] = (int)($arrTotals[$strMatchType] ?? 0) + 1; + $arrTotals[$strSeedScope === DomainModel::SEED_SCOPE_HOST ? 'seed_host' : 'seed_domain']++; + $arrTotals[$boolRuntimeReady ? 'runtime_ready' : 'runtime_missing']++; + } + + return [ + 'generated_at' => date(DATE_ATOM), + 'host_filter' => $strHostFilter, + 'match_type_filter' => $strMatchTypeFilter, + 'totals' => $arrTotals, + 'items' => $arrItems, + 'sample_hosts' => array_values(array_filter(array_map(static fn(array $arrItem) => (string)($arrItem['host'] ?? ''), $arrItems))), + ]; +} + +function renderMatrixText(array $arrMatrix): string +{ + $arrLines = [ + 'matrix_total: ' . (int)($arrMatrix['totals']['total'] ?? 0), + 'exact_count: ' . (int)($arrMatrix['totals']['exact'] ?? 0), + 'wildcard_count: ' . (int)($arrMatrix['totals']['wildcard'] ?? 0), + 'runtime_ready_count: ' . (int)($arrMatrix['totals']['runtime_ready'] ?? 0), + 'runtime_missing_count: ' . (int)($arrMatrix['totals']['runtime_missing'] ?? 0), + 'items:', + ]; + + foreach (array_slice((array)($arrMatrix['items'] ?? []), 0, 20) as $arrItem) { + $arrLines[] = '- ' . ($arrItem['match_type'] ?? '') + . ' | ' . ($arrItem['host'] ?? '') + . ' | parent=' . ($arrItem['parent_domain'] ?? '-') + . ' | seed=' . ($arrItem['seed_scope'] ?? '-') + . ' | runtime=' . (!empty($arrItem['runtime_acceptance_enabled']) ? 'ready' : 'missing') + . ' | diag=' . ($arrItem['resolved_match_reason'] ?? '-') + . ' | cache=' . (!empty($arrItem['resolved_from_cache']) ? 'hit' : 'miss'); + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +function renderSummaryText(array $arrSummary): string +{ + $arrLines = [ + 'host: ' . (string)($arrSummary['host'] ?? ''), + 'video_id: ' . (int)($arrSummary['video_id'] ?? 0), + 'forge_id: ' . (int)($arrSummary['forge_id'] ?? 0), + 'manifest: ' . (string)($arrSummary['manifest'] ?? ''), + 'base_url: ' . (string)($arrSummary['base_url'] ?? ''), + 'all_passed: ' . (!empty($arrSummary['all_passed']) ? 'yes' : 'no'), + 'matrix:', + trim((string)($arrSummary['matrix_text'] ?? '')), + 'page_inspection:', + trim((string)($arrSummary['page_text'] ?? '')), + 'runtime_acceptance:', + trim((string)($arrSummary['runtime_text'] ?? '')), + 'diagnostics:', + trim((string)($arrSummary['diagnostics_text'] ?? '')), + ]; + + return implode(PHP_EOL, array_filter($arrLines, static fn(string $strLine): bool => $strLine !== '')) . PHP_EOL; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +if (empty($arrArgs)) { + printUsage(); + exit(1); +} + +$strHost = ''; +$intVideoId = 0; +$intForgeId = 1; +$strBaseDir = dirname(__DIR__); +$strWorkingDir = getcwd() ?: $strBaseDir; +$strManifest = $strBaseDir . '/data/seo_copy_jobs/examples/chuanjiafeng-batch.json'; +$strTargetRoot = $strBaseDir . '/data/seo_copy'; +$strBaseUrl = 'http://127.0.0.1:8910'; +$strRunRoot = $strBaseDir . '/storage/seo_copy_runtime_acceptance_runs'; +$strPortalRoot = $strBaseDir . '/public/_seo_copy_release'; +$strFormat = 'json'; + +foreach ($arrArgs as $strArg) { + if (str_starts_with($strArg, '--forge-id=')) { + $intForgeId = max(1, (int)substr($strArg, strlen('--forge-id='))); + continue; + } + if (str_starts_with($strArg, '--manifest=')) { + $strManifest = trim(substr($strArg, strlen('--manifest='))); + continue; + } + if (str_starts_with($strArg, '--target-root=')) { + $strTargetRoot = trim(substr($strArg, strlen('--target-root='))); + continue; + } + if (str_starts_with($strArg, '--base-url=')) { + $strBaseUrl = rtrim(substr($strArg, strlen('--base-url=')), '/'); + continue; + } + if (str_starts_with($strArg, '--run-root=')) { + $strRunRoot = trim(substr($strArg, strlen('--run-root='))); + continue; + } + if (str_starts_with($strArg, '--portal-root=')) { + $strPortalRoot = trim(substr($strArg, strlen('--portal-root='))); + continue; + } + if (str_starts_with($strArg, '--format=')) { + $strFormat = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if ($strHost === '') { + $strHost = trim($strArg); + continue; + } + if ($intVideoId === 0) { + $intVideoId = (int)$strArg; + continue; + } +} + +if ($strHost === '') { + printUsage(); + exit(1); +} + +$strManifest = resolvePath($strManifest, $strWorkingDir); +$strTargetRoot = resolvePath($strTargetRoot, $strWorkingDir); +$strRunRoot = resolvePath($strRunRoot, $strWorkingDir); +$strPortalRoot = $strPortalRoot === 'off' ? 'off' : resolvePath($strPortalRoot, $strWorkingDir); + +ensureAppInitialized(); + +$arrMatrix = buildGovernanceMatrix($strHost, ''); +$arrDiagnostics = DomainModel::resolveDomainContextByHost($strHost); + +$arrRuntimeSummary = $intVideoId > 0 + ? SeoCopyRuntimeAcceptanceHelper::verify($strHost, $intVideoId, [ + 'forge_id' => $intForgeId, + 'base_url' => $strBaseUrl, + 'target_root' => $strTargetRoot, + ]) + : SeoCopyRuntimeAcceptanceHelper::runHostConfiguredSamples($strHost, [ + 'base_url' => $strBaseUrl, + 'run_root' => $strRunRoot, + 'portal_root' => $strPortalRoot, + 'target_root' => $strTargetRoot, + ]); + +$arrPageResult = ['ok' => false, 'exit_code' => 1, 'stdout' => '', 'stderr' => '', 'json' => null]; +if ($strManifest !== '' && is_file($strManifest)) { + $arrPageResult = runChildScript( + dirname(__DIR__) . '/scripts/seo_copy_batch_front_verify.php', + [$strManifest, '--target-root=' . $strTargetRoot, '--base-url=' . $strBaseUrl, '--format=json'] + ); +} + +$arrPageJson = is_array($arrPageResult['json'] ?? null) ? (array)$arrPageResult['json'] : []; +$arrPageItems = array_values(array_filter((array)($arrPageJson['items'] ?? []), 'is_array')); +$intPagePassed = count(array_filter($arrPageItems, static fn(array $arrItem): bool => !empty($arrItem['all_matched']))); +$intPageFailed = max(0, count($arrPageItems) - $intPagePassed); + +$arrChecks = []; +$arrChecks[] = [ + 'label' => 'matrix_has_rows', + 'passed' => (int)($arrMatrix['totals']['total'] ?? 0) > 0, + 'detail' => 'rows=' . (int)($arrMatrix['totals']['total'] ?? 0), +]; +$arrChecks[] = [ + 'label' => 'matrix_has_runtime_ready', + 'passed' => (int)($arrMatrix['totals']['runtime_ready'] ?? 0) > 0, + 'detail' => 'runtime_ready=' . (int)($arrMatrix['totals']['runtime_ready'] ?? 0), +]; +$arrChecks[] = [ + 'label' => 'page_inspection_all_matched', + 'passed' => !empty($arrPageJson) && !empty($arrPageJson['total']) && $intPageFailed === 0, + 'detail' => 'passed=' . $intPagePassed . ', failed=' . $intPageFailed, +]; +$arrChecks[] = [ + 'label' => 'runtime_acceptance_passed', + 'passed' => !empty($arrRuntimeSummary['all_passed']), + 'detail' => !empty($arrRuntimeSummary['all_passed']) ? 'passed' : 'failed', +]; +$arrChecks[] = [ + 'label' => 'diagnostics_resolved', + 'passed' => !empty($arrDiagnostics) && (string)($arrDiagnostics['match_reason'] ?? '') !== 'miss', + 'detail' => json_encode([ + 'match_type' => $arrDiagnostics['match_type'] ?? '', + 'match_reason' => $arrDiagnostics['match_reason'] ?? '', + 'seed_scope' => $arrDiagnostics['seed_scope'] ?? '', + 'resolved_from_cache' => $arrDiagnostics['resolved_from_cache'] ?? false, + 'resolved_lookup_state' => $arrDiagnostics['resolved_lookup_state'] ?? '', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), +]; + +$boolAllPassed = !in_array(false, array_column($arrChecks, 'passed'), true); + +$arrSummary = [ + 'host' => $strHost, + 'video_id' => $intVideoId, + 'forge_id' => $intForgeId, + 'manifest' => $strManifest, + 'base_url' => $strBaseUrl, + 'target_root' => $strTargetRoot, + 'matrix' => $arrMatrix, + 'diagnostics' => $arrDiagnostics, + 'page_inspection' => $arrPageJson, + 'runtime_acceptance' => $arrRuntimeSummary, + 'checks' => $arrChecks, + 'all_passed' => $boolAllPassed, + 'verified_at' => date(DATE_ATOM), +]; + +$arrSummary['matrix_text'] = renderMatrixText($arrMatrix); +$arrSummary['page_text'] = trim((string)($arrPageResult['stdout'] ?? '')); +$arrRuntimeTextLines = []; +if (!empty($arrRuntimeSummary['suites']) && is_array($arrRuntimeSummary['suites'])) { + $arrRuntimeTextLines[] = 'status: ' . (!empty($arrRuntimeSummary['all_passed']) ? 'passed' : 'failed'); + foreach ((array)$arrRuntimeSummary['suites'] as $strSuiteKey => $arrSuiteSummary) { + if (!is_array($arrSuiteSummary)) { + continue; + } + $arrRuntimeTextLines[] = $strSuiteKey . ': ' . (!empty($arrSuiteSummary['passed']) ? 'passed' : 'failed') + . ' | checks=' . (int)($arrSuiteSummary['check_count'] ?? 0) + . ' | passed=' . (int)($arrSuiteSummary['passed_count'] ?? 0) + . ' | failed=' . (int)($arrSuiteSummary['failed_count'] ?? 0); + } +} else { + $arrRuntimeTextLines = [ + 'status: ' . (string)($arrRuntimeSummary['status'] ?? ''), + 'configured_samples: ' . (int)($arrRuntimeSummary['configured_samples'] ?? 0), + 'processed_count: ' . (int)($arrRuntimeSummary['processed_count'] ?? ($arrRuntimeSummary['runs_count'] ?? 0)), + 'passed_count: ' . (int)($arrRuntimeSummary['passed_count'] ?? 0), + 'failed_count: ' . (int)($arrRuntimeSummary['failed_count'] ?? 0), + ]; +} +$arrSummary['runtime_text'] = implode(PHP_EOL, $arrRuntimeTextLines); +$arrSummary['diagnostics_text'] = implode(PHP_EOL, [ + 'requested_host: ' . (string)($arrDiagnostics['requested_host'] ?? ''), + 'source_domain: ' . (string)($arrDiagnostics['source_domain'] ?? ''), + 'parent_domain: ' . (string)($arrDiagnostics['parent_domain'] ?? ''), + 'match_type: ' . (string)($arrDiagnostics['match_type'] ?? ''), + 'match_reason: ' . (string)($arrDiagnostics['match_reason'] ?? ''), + 'seed_scope: ' . (string)($arrDiagnostics['seed_scope'] ?? ''), + 'seed_host: ' . (string)($arrDiagnostics['seed_host'] ?? ''), + 'resolved_from_cache: ' . (!empty($arrDiagnostics['resolved_from_cache']) ? 'yes' : 'no'), + 'resolved_lookup_state: ' . (string)($arrDiagnostics['resolved_lookup_state'] ?? ''), +]); + +if ($strFormat === 'text') { + echo renderSummaryText($arrSummary); + exit($boolAllPassed ? 0 : 1); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +exit($boolAllPassed ? 0 : 1); diff --git a/code/scripts/seo_copy_scaffold.php b/code/scripts/seo_copy_scaffold.php new file mode 100644 index 0000000..760d77d --- /dev/null +++ b/code/scripts/seo_copy_scaffold.php @@ -0,0 +1,100 @@ + [page-part ...] [--force]\n\n"; + echo "Examples:\n"; + echo " php scripts/seo_copy_scaffold.php chuanjiafeng.net home index\n"; + echo " php scripts/seo_copy_scaffold.php chuanjiafeng.net search 相逢\n"; + echo " php scripts/seo_copy_scaffold.php chuanjiafeng.net category_list dian-ying shao-shi-dian-ying\n\n"; + echo "Supported scenes:\n"; + echo " {$scenes}\n"; +} + +$arrArgs = $argv; +array_shift($arrArgs); + +$boolForce = false; +$arrParts = []; + +foreach ($arrArgs as $strArg) { + if ($strArg === '--force') { + $boolForce = true; + continue; + } + + $arrParts[] = $strArg; +} + +if (count($arrParts) < 3) { + printUsage(); + exit(1); +} + +$strHost = (string)array_shift($arrParts); +$strScene = (string)array_shift($arrParts); + +if (!in_array($strScene, SeoCopySchema::getSupportedScenes(), true)) { + fwrite(STDERR, "Unsupported scene: {$strScene}\n"); + printUsage(); + exit(1); +} + +$strPageKey = SeoCopySchema::buildScenePageKey($strScene, $arrParts); +$arrTemplate = SeoCopySchema::getSceneTemplate($strScene); + +if (empty($arrTemplate)) { + fwrite(STDERR, "No template defined for scene: {$strScene}\n"); + exit(1); +} + +$strHostDir = strtolower(trim($strHost)); +$strHostDir = preg_replace('/[^a-z0-9\\-_]+/', '-', $strHostDir); +$strHostDir = trim((string)$strHostDir, '-_'); + +$strSceneDir = strtolower(trim($strScene)); +$strSceneDir = preg_replace('/[^a-z0-9\\-_]+/', '-', $strSceneDir); +$strSceneDir = trim((string)$strSceneDir, '-_'); + +if ($strHostDir === '' || $strSceneDir === '' || $strPageKey === '') { + fwrite(STDERR, "Invalid host / scene / page key.\n"); + exit(1); +} + +$strDir = dirname(__DIR__) . '/data/seo_copy/' . $strHostDir . '/' . $strSceneDir; +$strPath = $strDir . '/' . $strPageKey . '.json'; + +if (is_file($strPath) && !$boolForce) { + echo "Skip existing file: {$strPath}\n"; + exit(0); +} + +if (!is_dir($strDir) && !mkdir($strDir, 0777, true) && !is_dir($strDir)) { + fwrite(STDERR, "Failed to create directory: {$strDir}\n"); + exit(1); +} + +$strJson = json_encode($arrTemplate, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +if ($strJson === false) { + fwrite(STDERR, "Failed to encode template.\n"); + exit(1); +} + +$strJson .= PHP_EOL; + +if (file_put_contents($strPath, $strJson) === false) { + fwrite(STDERR, "Failed to write file: {$strPath}\n"); + exit(1); +} + +echo "Generated: {$strPath}\n";