From cec1fdbc6939c6e4db77c7185f46cefbdc2711e8 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Apr 2026 20:23:30 +0800 Subject: [PATCH] feat: add metadata workbench and shared seo rules --- code/app/admin/config/router.php | 36 +- code/app/admin/controller/Index.php | 6 +- code/app/admin/controller/Site.php | 292 +++++++++- code/app/admin/controller/SystemConfig.php | 22 + code/app/admin/controller/Video.php | 365 +++++++++++++ .../app/command/VideoMetadataAuditCommand.php | 134 +++++ .../command/VideoMetadataCopyFillCommand.php | 109 ++++ ...ideoMetadataMissingPlanTaskSeedCommand.php | 50 ++ .../VideoMetadataMissingTaskPoolCommand.php | 55 ++ .../VideoMetadataMissingWorkbenchCommand.php | 43 ++ .../VideoMetadataRecrawlQueueCommand.php | 106 ++++ .../VideoMetadataRecrawlRunCommand.php | 344 ++++++++++++ .../helper/DomainBootstrapApplyHelper.php | 2 + .../helper/DomainBootstrapRegisterHelper.php | 1 + .../helper/DomainSitemapGenerationHelper.php | 51 ++ .../common/helper/SeoCopyAiProviderHelper.php | 32 +- .../helper/SeoCopyAiRuleConfigHelper.php | 196 +++++++ .../helper/SeoCopyBatchPrepareHelper.php | 3 + .../common/helper/SeoCopyContextHelper.php | 166 +++++- .../common/helper/SeoCopyGenerationHelper.php | 84 ++- .../helper/VideoMetadataCopyFillHelper.php | 162 ++++++ .../VideoMetadataMissingRefreshHelper.php | 62 +++ .../VideoMetadataMissingTaskPoolHelper.php | 450 +++++++++++++++ ...ideoMetadataMissingTaskPoolStateHelper.php | 216 ++++++++ ...deoMetadataMissingTaskPoolStatusHelper.php | 398 ++++++++++++++ .../VideoMetadataMissingWorkbenchHelper.php | 329 +++++++++++ ...deoMetadataMissingWorkbenchIndexHelper.php | 99 ++++ code/app/model/DomainModel.php | 33 ++ code/app/model/VideoModel.php | 513 ++++++++++++++++++ code/app/services/SiteContext.php | 126 ++++- .../crawler/douban/page/VideoInfoPage.php | 10 + .../crawler/fengchao/page/VideoInfoPage.php | 10 + .../crawler/maotai/page/VideoInfoPage.php | 10 + .../crawler/wuxian/page/VideoInfoPage.php | 10 + .../crawler/youzhi/page/VideoCategory.php | 5 + .../crawler/youzhi/page/VideoInfoPage.php | 27 +- code/app/task/logic/VideoSiteMapLogic.php | 22 +- code/app/task/module/PlanTask.php | 26 + code/config/console.php | 7 + code/database/seeders/PlanTaskSeeder.php | 6 + ...26-04-18-AI文案与视频元数据公共规则主线.md | 222 ++++++++ docs/2026-04-18-PHP与Vue收口交付总结.md | 223 ++++++++ docs/2026-04-18-视频缺失字段工作台V1说明.md | 379 +++++++++++++ docs/Codex启动提示词模板.md | 114 +++- 44 files changed, 5488 insertions(+), 68 deletions(-) create mode 100644 code/app/command/VideoMetadataAuditCommand.php create mode 100644 code/app/command/VideoMetadataCopyFillCommand.php create mode 100644 code/app/command/VideoMetadataMissingPlanTaskSeedCommand.php create mode 100644 code/app/command/VideoMetadataMissingTaskPoolCommand.php create mode 100644 code/app/command/VideoMetadataMissingWorkbenchCommand.php create mode 100644 code/app/command/VideoMetadataRecrawlQueueCommand.php create mode 100644 code/app/command/VideoMetadataRecrawlRunCommand.php create mode 100644 code/app/common/helper/DomainSitemapGenerationHelper.php create mode 100644 code/app/common/helper/SeoCopyAiRuleConfigHelper.php create mode 100644 code/app/common/helper/VideoMetadataCopyFillHelper.php create mode 100644 code/app/common/helper/VideoMetadataMissingRefreshHelper.php create mode 100644 code/app/common/helper/VideoMetadataMissingTaskPoolHelper.php create mode 100644 code/app/common/helper/VideoMetadataMissingTaskPoolStateHelper.php create mode 100644 code/app/common/helper/VideoMetadataMissingTaskPoolStatusHelper.php create mode 100644 code/app/common/helper/VideoMetadataMissingWorkbenchHelper.php create mode 100644 code/app/common/helper/VideoMetadataMissingWorkbenchIndexHelper.php create mode 100644 docs/2026-04-18-AI文案与视频元数据公共规则主线.md create mode 100644 docs/2026-04-18-PHP与Vue收口交付总结.md create mode 100644 docs/2026-04-18-视频缺失字段工作台V1说明.md diff --git a/code/app/admin/config/router.php b/code/app/admin/config/router.php index 1af87a7..edfd2ea 100644 --- a/code/app/admin/config/router.php +++ b/code/app/admin/config/router.php @@ -3,6 +3,7 @@ declare(strict_types=1); use app\admin\controller\AdminUser; +use app\admin\controller\Index; use app\admin\controller\Domain; use app\admin\controller\SeoSupply; use app\admin\controller\Novel; @@ -10,7 +11,9 @@ use app\admin\controller\Site; use app\admin\controller\SystemConfig; use app\admin\controller\Video; use app\admin\middleware\AdminAuth; -use think\facade\Route; +use think\facade\Route; + +Route::get("/", [Index::class, "index"])->name("Index@index")->middleware(AdminAuth::class); Route::group("/guest", function () { Route::post("/login", "Guest/Login")->name("Guest@Login"); @@ -25,8 +28,9 @@ Route::group("/system", function () { 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("/config/list", [SystemConfig::class, "getSystemConfigList"])->name("SystemConfig@getSystemConfigList"); + Route::post("/config/save", [SystemConfig::class, "saveSystemConfig"])->name("SystemConfig@saveSystemConfig"); + Route::post("/config/seo-ai-rules/reset", [SystemConfig::class, "resetSeoAiRules"])->name("SystemConfig@resetSeoAiRules"); Route::get("/cjpz/list", [SystemConfig::class, "getCaiJiPeiZhiList"])->name("SystemConfig@getCaiJiPeiZhiList"); Route::post("/cjpz/save", [SystemConfig::class, "saveCaiJiPeiZhi"])->name("SystemConfig@saveCaiJiPeiZhi"); @@ -44,12 +48,26 @@ Route::group("/novel", function () { })->middleware(AdminAuth::class); # 视频 -Route::group("/video", function () { - Route::get("/list", [Video::class, "getVideoList"])->name("Domain@getVideoList"); - Route::post("/level/set", [Video::class, "updateVideoLevel"])->name("Video@updateVideoLevel"); - Route::post("/boxoffice/set", [Video::class, "updateBoxOffice"])->name("Video@updateBoxOffice"); - Route::get("/category/list", [Video::class, "getCategory"])->name("Video@getCategory"); -})->middleware(AdminAuth::class); +Route::group("/video", function () { + Route::get("/list", [Video::class, "getVideoList"])->name("Domain@getVideoList"); + Route::post("/level/set", [Video::class, "updateVideoLevel"])->name("Video@updateVideoLevel"); + Route::post("/boxoffice/set", [Video::class, "updateBoxOffice"])->name("Video@updateBoxOffice"); + Route::get("/category/list", [Video::class, "getCategory"])->name("Video@getCategory"); + Route::get("/metadata/missing/workbench", [Video::class, "getMetadataMissingWorkbenchSummary"])->name("Video@getMetadataMissingWorkbenchSummary"); + Route::get("/metadata/missing/workbench/runs", [Video::class, "getMetadataMissingWorkbenchRuns"])->name("Video@getMetadataMissingWorkbenchRuns"); + Route::get("/metadata/missing/prompt", [Video::class, "getMetadataMissingCodexPrompt"])->name("Video@getMetadataMissingCodexPrompt"); + Route::post("/metadata/missing/workbench/run", [Video::class, "runMetadataMissingWorkbench"])->name("Video@runMetadataMissingWorkbench"); + Route::get("/metadata/missing/task-pool", [Video::class, "getMetadataMissingTaskPool"])->name("Video@getMetadataMissingTaskPool"); + Route::get("/metadata/missing/task-pool/status", [Video::class, "getMetadataMissingTaskPoolStatus"])->name("Video@getMetadataMissingTaskPoolStatus"); + Route::get("/metadata/missing/task-pool/ops", [Video::class, "getMetadataMissingTaskPoolOps"])->name("Video@getMetadataMissingTaskPoolOps"); + Route::get("/metadata/missing/task-pool/plan-task/status", [Video::class, "getMetadataMissingTaskPoolPlanTaskStatus"])->name("Video@getMetadataMissingTaskPoolPlanTaskStatus"); + Route::get("/metadata/missing/task-pool/batch/detail", [Video::class, "getMetadataMissingTaskPoolBatchDetail"])->name("Video@getMetadataMissingTaskPoolBatchDetail"); + Route::get("/metadata/missing/task-pool/batch/prompt", [Video::class, "getMetadataMissingTaskPoolBatchPrompt"])->name("Video@getMetadataMissingTaskPoolBatchPrompt"); + Route::get("/metadata/missing/task-pool/batch/history", [Video::class, "getMetadataMissingTaskPoolBatchHistory"])->name("Video@getMetadataMissingTaskPoolBatchHistory"); + Route::post("/metadata/missing/task-pool/run", [Video::class, "runMetadataMissingTaskPool"])->name("Video@runMetadataMissingTaskPool"); + Route::post("/metadata/missing/task-pool/batch/state/save", [Video::class, "saveMetadataMissingTaskPoolBatchState"])->name("Video@saveMetadataMissingTaskPoolBatchState"); + Route::post("/metadata/missing/task-pool/plan-task/status/save", [Video::class, "saveMetadataMissingTaskPoolPlanTaskStatus"])->name("Video@saveMetadataMissingTaskPoolPlanTaskStatus"); +})->middleware(AdminAuth::class); # 广告 Route::group("/guanggao", function () { diff --git a/code/app/admin/controller/Index.php b/code/app/admin/controller/Index.php index 677cfdd..0d012c9 100644 --- a/code/app/admin/controller/Index.php +++ b/code/app/admin/controller/Index.php @@ -1,12 +1,12 @@ save(); SeoCopyGenerationHelper::initializeForDomain($DomainModel); SeoResourcePoolHelper::initSitePositioning((string)$DomainModel->d_domain, 1, true); + DomainSitemapGenerationHelper::queueForDomains([(string)$DomainModel->d_domain]); $intInsertedCount++; $arrInsertedDomains[] = [ 'd_id' => (int)$DomainModel->d_id, 'd_domain' => (string)$DomainModel->d_domain, + 'd_name' => (string)$DomainModel->d_name, + 't_id' => (int)$DomainModel->t_id, 'strategy_profile' => $strStrategyProfile, + 'tkd_provider' => $strTkdProvider, + 'tkd_mode' => (string)($arrDomain['d_seo_cfg']['tkd']['mode'] ?? ''), ]; $arrInsertedHosts[] = (string)$DomainModel->d_domain; $arrStrategyProfileCounts[$strStrategyProfile] = (int)($arrStrategyProfileCounts[$strStrategyProfile] ?? 0) + 1; @@ -1402,6 +1408,8 @@ class Site extends BaseController ]); } + $arrPostImportWorkflow = $this->buildPostImportWorkflow($arrInsertedDomains, $arrImportProbeSummary); + return $this->success([ 'rows_total' => $intRowsTotal, 'inserted_count' => $intInsertedCount, @@ -1420,6 +1428,9 @@ class Site extends BaseController 'probe_failed_count' => (int)($arrImportProbeSummary['failed_count'] ?? 0), 'summary_json_path' => $this->publicRelativePath((string)($arrImportProbeSummary['summary_json_path'] ?? '')), 'summary_html_path' => $this->publicRelativePath((string)($arrImportProbeSummary['summary_html_path'] ?? '')), + 'post_import_workflow' => $arrPostImportWorkflow, + 'next_step_notice' => array_values((array)($arrPostImportWorkflow['notices'] ?? [])), + 'codex_continue_prompt' => (string)($arrPostImportWorkflow['codex']['prompt'] ?? ''), ]); } catch (\Exception $e) { return $this->error("9999", '导入失败: ' . $e->getMessage()); @@ -3217,10 +3228,282 @@ class Site extends BaseController 'reasons' => array_values(array_unique($arrReasons)), ]; } - - - /** - * 添加|修改 域名 + + protected function buildPostImportWorkflow(array $arrInsertedDomains, array $arrImportProbeSummary = []): array + { + $arrInsertedDomains = array_values(array_filter( + $arrInsertedDomains, + static fn ($arrItem): bool => is_array($arrItem) && !empty($arrItem['d_domain']) + )); + if (empty($arrInsertedDomains)) { + return [ + 'enabled' => false, + 'notices' => [], + ]; + } + + $arrHosts = array_values(array_unique(array_filter(array_map( + static fn (array $arrItem): string => DomainModel::normalizeHost((string)($arrItem['d_domain'] ?? '')), + $arrInsertedDomains + )))); + $arrIds = array_values(array_unique(array_filter(array_map( + static fn (array $arrItem): int => (int)($arrItem['d_id'] ?? 0), + $arrInsertedDomains + )))); + $arrOpenAiDomains = array_values(array_filter($arrInsertedDomains, static function (array $arrItem): bool { + return DomainSeoNamingHelper::shouldOptimizeForOpenAi( + (string)($arrItem['tkd_provider'] ?? ''), + (string)($arrItem['tkd_mode'] ?? '') + ); + })); + + $boolContainsOpenAiDomains = !empty($arrOpenAiDomains); + $strProbeStatus = (string)($arrImportProbeSummary['status'] ?? 'skipped'); + $strProbeSummaryHtmlPath = $this->publicRelativePath((string)($arrImportProbeSummary['summary_html_path'] ?? '')); + $strProbeSummaryJsonPath = $this->publicRelativePath((string)($arrImportProbeSummary['summary_json_path'] ?? '')); + $arrArtifactAudit = $this->buildPostImportArtifactAudit($arrInsertedDomains); + + $arrNotices = [ + '这批域名已经导入成功,但系统不会直接自动开始 AI 生成,请先确认页面探测结果。', + '如果由运营继续处理,请先看探测摘要,再手动点击后台 AI 生成功能。', + '如果由技术继续处理,请复制 Codex 提示词,把这批域名交给 Codex 接管。', + ]; + if ($strProbeStatus !== '') { + $arrNotices[] = '本批次导入探测状态:' . $strProbeStatus . '。建议先确认首页、分类页、详情页、播放页链路正常,再进入下一步。'; + } + if ($boolContainsOpenAiDomains) { + $arrNotices[] = '这批域名里包含 OpenAI 模式站点,默认建议运营走后台 AI 按钮,技术走 Codex 接管。'; + } + + return [ + 'enabled' => true, + 'auto_ai_after_import' => false, + 'summary' => '导入已完成。请先看探测结果,再明确选择“运营继续”或“技术继续”,系统不会自动偷偷调用 OpenAI。', + 'inserted_domain_count' => count($arrInsertedDomains), + 'inserted_domain_hosts' => $arrHosts, + 'contains_openai_domains' => $boolContainsOpenAiDomains, + 'artifact_audit' => $arrArtifactAudit, + 'recommended_for_operator' => 'openai_continue', + 'recommended_for_technical' => 'codex_continue', + 'notices' => $arrNotices, + 'choices' => [ + [ + 'key' => 'openai_continue', + 'label' => 'OpenAI继续', + 'audience' => '运营', + 'description' => '适合运营同学。先确认探测正常,再把这批新站提交到后台 AI 生成队列。', + ], + [ + 'key' => 'codex_continue', + 'label' => 'Codex继续', + 'audience' => '技术', + 'description' => '适合技术同学。复制提示词给 Codex,由 Codex 接手 SEO 优化、复测、记录和收口。', + ], + ], + 'openai' => [ + 'domain_ids' => $arrIds, + 'domain_id_csv' => implode(',', $arrIds), + 'hosts' => $arrHosts, + 'probe_status' => $strProbeStatus, + 'probe_summary_html_path' => $strProbeSummaryHtmlPath, + 'probe_summary_json_path' => $strProbeSummaryJsonPath, + 'operator_steps' => [ + '先打开本次导入探测摘要,确认首页、分类页、详情页、播放页没有明显报错。', + '确认页面链路正常后,回到后台站点列表,筛选或勾选本批域名。', + '如果这批站点准备走运营 AI 链路,再点击 AI 生成或 AI 重优化按钮,显式触发 OpenAI 队列。', + ], + ], + 'codex' => [ + 'workspace_path' => $this->bootstrapCodeRoot(), + 'open_in_vscode_hint' => '请先在 VS Code 打开当前项目工作区,再把下面的提示词完整发给 Codex。Codex 接手时不会自动消耗 OpenAI API。', + 'prompt' => $this->buildPostImportCodexPrompt($arrInsertedDomains, $arrImportProbeSummary), + ], + 'ui_suggestion' => [ + 'message' => '建议在导入成功弹窗里直接展示两个按钮:Codex继续、OpenAI继续,并支持一键复制 Codex 提示词。', + 'button_labels' => ['Codex继续', 'OpenAI继续', '复制Codex提示词'], + ], + ]; + } + + protected function buildPostImportArtifactAudit(array $arrInsertedDomains): array + { + $arrInsertedDomains = array_values(array_filter( + $arrInsertedDomains, + static fn ($arrItem): bool => is_array($arrItem) && !empty($arrItem['d_domain']) + )); + if (empty($arrInsertedDomains)) { + return [ + 'summary' => [ + 'total' => 0, + 'state_ready_count' => 0, + 'manifest_ready_count' => 0, + 'positioning_ready_count' => 0, + 'published_copy_ready_count' => 0, + 'sitemap_generated_count' => 0, + 'ai_pending_count' => 0, + ], + 'items' => [], + ]; + } + + $arrItems = []; + $intStateReadyCount = 0; + $intManifestReadyCount = 0; + $intPositioningReadyCount = 0; + $intPublishedCopyReadyCount = 0; + $intSitemapGeneratedCount = 0; + $intAiPendingCount = 0; + + foreach ($arrInsertedDomains as $arrItem) { + $strHost = DomainModel::normalizeHost((string)($arrItem['d_domain'] ?? '')); + if ($strHost === '') { + continue; + } + + $strHostKey = SeoCopyStore::buildPageKey($strHost, 'host'); + $strStatePath = SeoCopyGenerationHelper::resolveStatePath($strHost); + $strManifestPath = SeoCopyGenerationHelper::resolveManifestPath($strHost); + $strPositioningPath = SeoResourcePoolHelper::resourceRoot() . '/site_positioning/by_host/' . SeoResourcePoolHelper::hostFileKey($strHost) . '.json'; + $strPublishedRoot = rtrim(SeoCopyStore::publishedRoot(), '/') . '/' . $strHostKey; + $strSitemapRoot = rtrim((string)root_path('storage/SiteMap'), '/'); + $strSitemapDir = $strSitemapRoot . '/' . $strHost; + + $boolStateExists = is_file($strStatePath); + $boolManifestExists = is_file($strManifestPath); + $boolPositioningExists = is_file($strPositioningPath); + $boolPublishedCopyExists = !empty(glob($strPublishedRoot . '/*/*.json') ?: []); + + $arrState = $boolStateExists ? SeoCopyGenerationHelper::readState($strHost) : []; + $strAiStatus = (string)($arrState['status'] ?? ''); + $boolAiPending = $strAiStatus === SeoCopyGenerationHelper::STATUS_AI_PENDING; + $boolWaitForAi = !empty($arrState['wait_for_ai_before_publish']); + + $arrSitemapCandidates = [ + $strSitemapDir . '/sitemap_index.xml', + $strSitemapDir . '/sitemap-main.xml', + $strSitemapDir . '/sitemap-videos-1.xml', + ]; + $arrExistingSitemaps = array_values(array_filter($arrSitemapCandidates, static fn (string $strPath): bool => is_file($strPath))); + $boolSitemapGenerated = !empty($arrExistingSitemaps); + + if ($boolStateExists) { + $intStateReadyCount++; + } + if ($boolManifestExists) { + $intManifestReadyCount++; + } + if ($boolPositioningExists) { + $intPositioningReadyCount++; + } + if ($boolPublishedCopyExists) { + $intPublishedCopyReadyCount++; + } + if ($boolSitemapGenerated) { + $intSitemapGeneratedCount++; + } + if ($boolAiPending) { + $intAiPendingCount++; + } + + $arrItems[] = [ + 'host' => $strHost, + 'site_name' => (string)($arrItem['d_name'] ?? ''), + 'state_exists' => $boolStateExists, + 'manifest_exists' => $boolManifestExists, + 'site_positioning_exists' => $boolPositioningExists, + 'published_copy_exists' => $boolPublishedCopyExists, + 'ai_status' => $strAiStatus !== '' ? $strAiStatus : 'unknown', + 'wait_for_ai_before_publish' => $boolWaitForAi, + 'sitemap_status' => $boolSitemapGenerated ? 'generated' : 'queued_pending', + 'existing_sitemaps' => array_map(static fn (string $strPath): string => basename($strPath), $arrExistingSitemaps), + 'notes' => [ + $boolStateExists ? 'state.json 已生成' : 'state.json 缺失', + $boolManifestExists ? 'manifest.json 已生成' : 'manifest.json 缺失', + $boolPositioningExists ? 'site_positioning 已生成' : 'site_positioning 缺失', + $boolPublishedCopyExists ? 'starter copy 已发布' : 'starter copy 未落盘', + $boolSitemapGenerated ? '已检测到 sitemap 文件' : '已请求 sitemap 生成,当前尚未看到落盘文件', + ], + ]; + } + + return [ + 'summary' => [ + 'total' => count($arrItems), + 'state_ready_count' => $intStateReadyCount, + 'manifest_ready_count' => $intManifestReadyCount, + 'positioning_ready_count' => $intPositioningReadyCount, + 'published_copy_ready_count' => $intPublishedCopyReadyCount, + 'sitemap_generated_count' => $intSitemapGeneratedCount, + 'ai_pending_count' => $intAiPendingCount, + ], + 'items' => $arrItems, + ]; + } + + protected function buildPostImportCodexPrompt(array $arrInsertedDomains, array $arrImportProbeSummary = []): string + { + $arrLines = [ + '请接手这批刚导入的新站,并继续完成导入后的 SEO 收口工作。', + '', + '本批域名:', + ]; + + foreach ($arrInsertedDomains as $arrItem) { + if (!is_array($arrItem)) { + continue; + } + + $strDomain = DomainModel::normalizeHost((string)($arrItem['d_domain'] ?? '')); + if ($strDomain === '') { + continue; + } + + $arrLines[] = sprintf( + '- %s | 站点名=%s | 模板ID=%d | TKD来源=%s | TKD模式=%s | 策略=%s', + $strDomain, + trim((string)($arrItem['d_name'] ?? '')) !== '' ? (string)$arrItem['d_name'] : '未命名', + (int)($arrItem['t_id'] ?? 0), + (string)($arrItem['tkd_provider'] ?? DomainModel::TKD_PROVIDER_LOCAL), + (string)($arrItem['tkd_mode'] ?? DomainModel::TKD_MODE_AUTO_GENERATE), + (string)($arrItem['strategy_profile'] ?? DomainModel::SEO_STRATEGY_PROFILE_STANDARD) + ); + } + + $strProbeStatus = (string)($arrImportProbeSummary['status'] ?? 'skipped'); + $strProbeSummaryHtmlPath = $this->publicRelativePath((string)($arrImportProbeSummary['summary_html_path'] ?? '')); + $strProbeSummaryJsonPath = $this->publicRelativePath((string)($arrImportProbeSummary['summary_json_path'] ?? '')); + + $arrLines[] = ''; + $arrLines[] = '已知背景:'; + $arrLines[] = '- 导入成功后,不要默认静默调用 OpenAI API。'; + $arrLines[] = '- 如果后台明确点击 AI 优化,再走 OpenAI 队列;否则由 Codex 直接接管人工优化。'; + $arrLines[] = '- 请优先以 SEO 效果、可收录、可复测、可持续跟踪为目标。'; + $arrLines[] = ''; + $arrLines[] = '请执行:'; + $arrLines[] = '1. 复查首页、搜索页、分类页、详情页、播放页是否可访问,确认没有 404、500 或模板错位。'; + $arrLines[] = '2. 检查导入后的自动产物是否齐全,包括 SEO copy、探测报告、sitemap 队列、站点定位资源。'; + $arrLines[] = '3. 如果这批站点配置为 OpenAI 模式,但当前是 Codex 接管,请直接做人审优化,不要自动消耗 API。'; + $arrLines[] = '4. 输出这批站点的 Day0 基线,包含标题、关键词、描述、收录入口、主要风险。'; + $arrLines[] = '5. 把处理记录写到 GPT 模板专属文档目录,并在公共主线文档里做简短汇总。'; + + if ($strProbeStatus !== '' || $strProbeSummaryHtmlPath !== '' || $strProbeSummaryJsonPath !== '') { + $arrLines[] = ''; + $arrLines[] = '导入探测信息:'; + $arrLines[] = '- probe_status: ' . ($strProbeStatus !== '' ? $strProbeStatus : 'skipped'); + if ($strProbeSummaryHtmlPath !== '') { + $arrLines[] = '- probe_summary_html_path: ' . $strProbeSummaryHtmlPath; + } + if ($strProbeSummaryJsonPath !== '') { + $arrLines[] = '- probe_summary_json_path: ' . $strProbeSummaryJsonPath; + } + } + + return implode(PHP_EOL, $arrLines); + } + + + /** + * 添加|修改 域名 * * @param Request $Request * @param AdminUserModel|null $AdminUserModel @@ -3292,6 +3575,7 @@ class Site extends BaseController $DomainModel->save(); DomainModel::flushAllDomianOnCache(); SeoCopyGenerationHelper::initializeForDomain($DomainModel); + DomainSitemapGenerationHelper::queueForDomains([(string)$DomainModel->d_domain]); return $this->success(); } diff --git a/code/app/admin/controller/SystemConfig.php b/code/app/admin/controller/SystemConfig.php index 01e9234..d92714d 100644 --- a/code/app/admin/controller/SystemConfig.php +++ b/code/app/admin/controller/SystemConfig.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace app\admin\controller; use app\admin\BaseController; +use app\common\helper\SeoCopyAiRuleConfigHelper; use app\model\AdminUserModel; use app\model\BaoYangRuZhuModel; use app\model\CaiJiPeiZhiModel; @@ -120,6 +121,8 @@ class SystemConfig extends BaseController */ public function getSystemConfigList(Request $Request, ?AdminUserModel $AdminUserModel) { + SeoCopyAiRuleConfigHelper::ensureSystemConfigDefaults(); + $arrData = [ "page" => $Request->get('page', 1), "limit" => $Request->get('limit', 10), @@ -196,6 +199,25 @@ class SystemConfig extends BaseController return $this->success(); } + public function resetSeoAiRules(Request $Request, ?AdminUserModel $AdminUserModel) + { + $arrData = [ + 'sc_code' => trim((string)$Request->post('sc_code', '')), + ]; + + if ($arrData['sc_code'] !== '' && !in_array($arrData['sc_code'], SeoCopyAiRuleConfigHelper::editableCodes(), true)) { + return $this->error('9993', '无效的 SEO AI 规则编码'); + } + + $arrResult = SeoCopyAiRuleConfigHelper::resetToDefault($arrData['sc_code'] !== '' ? $arrData['sc_code'] : null); + + return $this->success([ + 'codes' => $arrResult['codes'] ?? [], + 'count' => (int)($arrResult['count'] ?? 0), + 'scope' => $arrData['sc_code'] !== '' ? 'single' : 'all', + ]); + } + /** * 资源端点列表 diff --git a/code/app/admin/controller/Video.php b/code/app/admin/controller/Video.php index 5b2833f..9b71d15 100644 --- a/code/app/admin/controller/Video.php +++ b/code/app/admin/controller/Video.php @@ -5,7 +5,13 @@ declare(strict_types=1); namespace app\admin\controller; use app\admin\BaseController; +use app\common\helper\VideoMetadataMissingWorkbenchHelper; +use app\common\helper\VideoMetadataMissingWorkbenchIndexHelper; +use app\common\helper\VideoMetadataMissingTaskPoolHelper; +use app\common\helper\VideoMetadataMissingTaskPoolStateHelper; +use app\common\helper\VideoMetadataMissingTaskPoolStatusHelper; use app\model\AdminUserModel; +use app\model\PlanTaskModel; use app\model\VideoModel; use app\model\VideoCategoryModel; @@ -131,4 +137,363 @@ class Video extends BaseController $arrResult = VideoCategoryModel::$arrCategory; return $this->success($arrResult); } + + public function getMetadataMissingWorkbenchSummary(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $boolRefresh = (int)$Request->get('refresh', 0) === 1; + $intSample = max(1, min(100, (int)$Request->get('sample', 20))); + $intQueueLimit = max(1, min(500, (int)$Request->get('queue_limit', 100))); + $intPromptLimit = max(1, min(100, (int)$Request->get('prompt_limit', 20))); + + $arrSummary = $boolRefresh ? [] : VideoMetadataMissingWorkbenchHelper::readLatestSummary($this->metadataMissingWorkbenchRoot()); + if (empty($arrSummary)) { + $arrSummary = VideoMetadataMissingWorkbenchHelper::buildSummary($intSample, $intQueueLimit, $intPromptLimit); + $arrSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($this->metadataMissingWorkbenchRoot(), $arrSummary); + } + + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + $arrSummary['prompt_markdown_path'] = $this->publicRelativePath((string)($arrSummary['prompt_markdown_path'] ?? '')); + + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取视频缺失字段工作台失败:' . $Throwable->getMessage()); + } + } + + public function getMetadataMissingCodexPrompt(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrSummary = VideoMetadataMissingWorkbenchHelper::readLatestSummary($this->metadataMissingWorkbenchRoot()); + if (empty($arrSummary)) { + $intSample = max(1, min(100, (int)$Request->get('sample', 20))); + $intQueueLimit = max(1, min(500, (int)$Request->get('queue_limit', 100))); + $intPromptLimit = max(1, min(100, (int)$Request->get('prompt_limit', 20))); + $arrSummary = VideoMetadataMissingWorkbenchHelper::buildSummary($intSample, $intQueueLimit, $intPromptLimit); + $arrSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($this->metadataMissingWorkbenchRoot(), $arrSummary); + } + + return $this->success([ + 'prompt' => (string)($arrSummary['codex_dispatch_prompt'] ?? ''), + 'prompt_markdown_path' => $this->publicRelativePath((string)($arrSummary['prompt_markdown_path'] ?? '')), + 'generated_at' => (string)($arrSummary['generated_at'] ?? ''), + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取视频缺失字段提示词失败:' . $Throwable->getMessage()); + } + } + + public function getMetadataMissingWorkbenchRuns(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intLimit = max(1, min(50, (int)$Request->get('limit', 20))); + $arrSummary = VideoMetadataMissingWorkbenchIndexHelper::buildSummary($this->metadataMissingWorkbenchRunRoot(), $intLimit); + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取视频缺失字段工作台运行记录失败:' . $Throwable->getMessage()); + } + } + + public function getMetadataMissingTaskPool(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intBatchSize = max(5, min(100, (int)$Request->get('batch_size', 20))); + $intBatchLimit = max(1, min(50, (int)$Request->get('batch_limit', 10))); + $boolRefresh = (int)$Request->get('refresh', 0) === 1; + + $arrSummary = $boolRefresh ? [] : VideoMetadataMissingTaskPoolHelper::readLatestSummary($this->metadataMissingTaskPoolRoot()); + if (empty($arrSummary)) { + $arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::readLatestSummary($this->metadataMissingWorkbenchRoot()); + if (empty($arrWorkbenchSummary)) { + $arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::buildSummary(20, 100, 20); + $arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($this->metadataMissingWorkbenchRoot(), $arrWorkbenchSummary); + } + + $arrSummary = VideoMetadataMissingTaskPoolHelper::buildSummary($arrWorkbenchSummary, $intBatchSize, $intBatchLimit); + $arrSummary = VideoMetadataMissingTaskPoolHelper::writeArtifacts($this->metadataMissingTaskPoolRoot(), $arrSummary); + } + + $arrSummary['summary_json_path'] = $this->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 getMetadataMissingTaskPoolStatus(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrSummary = VideoMetadataMissingTaskPoolStatusHelper::buildSummary( + $this->metadataMissingWorkbenchRoot(), + $this->metadataMissingTaskPoolRoot() + ); + + $arrSummary['workbench']['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['workbench']['summary_json_path'] ?? '')); + $arrSummary['workbench']['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['workbench']['summary_html_path'] ?? '')); + $arrSummary['task_pool']['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['task_pool']['summary_json_path'] ?? '')); + $arrSummary['task_pool']['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['task_pool']['summary_html_path'] ?? '')); + + return $this->success($arrSummary); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取视频缺字段任务池状态失败:' . $Throwable->getMessage()); + } + } + + public function getMetadataMissingTaskPoolOps(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrSummary = VideoMetadataMissingTaskPoolStatusHelper::buildSummary( + $this->metadataMissingWorkbenchRoot(), + $this->metadataMissingTaskPoolRoot() + ); + + $arrSummary['workbench']['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['workbench']['summary_json_path'] ?? '')); + $arrSummary['workbench']['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['workbench']['summary_html_path'] ?? '')); + $arrSummary['task_pool']['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['task_pool']['summary_json_path'] ?? '')); + $arrSummary['task_pool']['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['task_pool']['summary_html_path'] ?? '')); + + return $this->success([ + 'summary' => $arrSummary, + 'ops' => VideoMetadataMissingTaskPoolStatusHelper::buildOpsSummary($arrSummary), + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取视频缺字段任务池运维页失败:' . $Throwable->getMessage()); + } + } + + public function getMetadataMissingTaskPoolPlanTaskStatus(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + return $this->success(VideoMetadataMissingTaskPoolStatusHelper::buildSummary( + $this->metadataMissingWorkbenchRoot(), + $this->metadataMissingTaskPoolRoot() + )); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取视频缺字段计划任务状态失败:' . $Throwable->getMessage()); + } + } + + public function saveMetadataMissingTaskPoolPlanTaskStatus(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $arrData = [ + 'pt_enable' => (int)$Request->post('pt_enable', 0), + 'pt_limit' => (int)$Request->post('pt_limit', 86400), + ]; + $this->validate($arrData, [ + 'pt_enable' => 'require|number|in:0,1', + 'pt_limit' => 'require|number|egt:0', + ]); + + $PlanTaskModel = PlanTaskModel::where('pt_code', 'REFRESH_VIDEO_METADATA_TASK_POOL')->find(); + if (!$PlanTaskModel) { + return $this->error('9999', '未找到 REFRESH_VIDEO_METADATA_TASK_POOL 任务配置,请先执行 plan:seed:video-metadata-missing'); + } + + $PlanTaskModel->pt_enable = $arrData['pt_enable']; + $PlanTaskModel->pt_limit = $arrData['pt_limit']; + $PlanTaskModel->save(); + + return $this->success(VideoMetadataMissingTaskPoolStatusHelper::buildSummary( + $this->metadataMissingWorkbenchRoot(), + $this->metadataMissingTaskPoolRoot() + )); + } catch (\Throwable $Throwable) { + return $this->error('9999', '保存视频缺字段计划任务状态失败:' . $Throwable->getMessage()); + } + } + + public function getMetadataMissingTaskPoolBatchDetail(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strBatchId = trim((string)$Request->get('batch_id', '')); + if ($strBatchId === '') { + return $this->error('9999', 'batch_id 不能为空'); + } + + $arrSummary = VideoMetadataMissingTaskPoolHelper::readLatestSummary($this->metadataMissingTaskPoolRoot()); + if (empty($arrSummary)) { + return $this->error('9999', '当前还没有可用的缺字段待处理池'); + } + + $arrBatch = VideoMetadataMissingTaskPoolHelper::findBatchById($arrSummary, $strBatchId); + if (empty($arrBatch)) { + return $this->error('9999', '没有找到对应批次'); + } + + return $this->success([ + 'batch' => $arrBatch, + 'history' => VideoMetadataMissingTaskPoolStateHelper::readBatchHistory( + $this->metadataMissingTaskPoolRoot(), + $strBatchId, + 50 + ), + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取视频缺字段批次详情失败:' . $Throwable->getMessage()); + } + } + + public function getMetadataMissingTaskPoolBatchPrompt(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strBatchId = trim((string)$Request->get('batch_id', '')); + if ($strBatchId === '') { + return $this->error('9999', 'batch_id 不能为空'); + } + + $arrSummary = VideoMetadataMissingTaskPoolHelper::readLatestSummary($this->metadataMissingTaskPoolRoot()); + if (empty($arrSummary)) { + return $this->error('9999', '当前还没有可用的缺字段待处理池'); + } + + $arrBatch = VideoMetadataMissingTaskPoolHelper::findBatchById($arrSummary, $strBatchId); + if (empty($arrBatch)) { + return $this->error('9999', '没有找到对应批次'); + } + + return $this->success([ + 'batch_id' => $strBatchId, + 'batch_label' => (string)($arrBatch['batch_label'] ?? ''), + 'focus_field' => (string)($arrBatch['focus_field'] ?? ''), + 'prompt' => (string)($arrBatch['codex_prompt'] ?? ''), + 'status' => (string)($arrBatch['status'] ?? ''), + 'status_label' => (string)($arrBatch['status_label'] ?? ''), + 'owner' => (string)($arrBatch['owner'] ?? ''), + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取视频缺字段批次提示词失败:' . $Throwable->getMessage()); + } + } + + public function runMetadataMissingTaskPool(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intBatchSize = max(5, min(100, (int)$Request->post('batch_size', 20))); + $intBatchLimit = max(1, min(50, (int)$Request->post('batch_limit', 10))); + + $arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::readLatestSummary($this->metadataMissingWorkbenchRoot()); + if (empty($arrWorkbenchSummary)) { + $arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::buildSummary(20, 100, 20); + $arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($this->metadataMissingWorkbenchRoot(), $arrWorkbenchSummary); + } + + $arrSummary = VideoMetadataMissingTaskPoolHelper::buildSummary($arrWorkbenchSummary, $intBatchSize, $intBatchLimit); + $arrSummary = VideoMetadataMissingTaskPoolHelper::writeArtifacts($this->metadataMissingTaskPoolRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + + return $this->success([ + 'status' => 'success', + 'summary' => $arrSummary, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '生成视频缺字段待处理池失败:' . $Throwable->getMessage()); + } + } + + public function saveMetadataMissingTaskPoolBatchState(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strBatchId = trim((string)$Request->post('batch_id', '')); + $strStatus = trim((string)$Request->post('status', VideoMetadataMissingTaskPoolStateHelper::STATUS_PENDING)); + $strOwner = trim((string)$Request->post('owner', '')); + $strNote = trim((string)$Request->post('note', '')); + + if ($strBatchId === '') { + return $this->error('9999', 'batch_id 不能为空'); + } + + $arrState = VideoMetadataMissingTaskPoolStateHelper::saveBatchState( + $this->metadataMissingTaskPoolRoot(), + $strBatchId, + $strStatus, + $strOwner, + $strNote + ); + + $arrSummary = VideoMetadataMissingTaskPoolHelper::readLatestSummary($this->metadataMissingTaskPoolRoot()); + + return $this->success([ + 'state' => $arrState, + 'status_label' => VideoMetadataMissingTaskPoolStateHelper::statusLabel((string)($arrState['status'] ?? 'pending')), + 'summary' => $arrSummary, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '保存视频缺字段批次状态失败:' . $Throwable->getMessage()); + } + } + + public function getMetadataMissingTaskPoolBatchHistory(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $strBatchId = trim((string)$Request->get('batch_id', '')); + $intLimit = max(1, min(200, (int)$Request->get('limit', 50))); + if ($strBatchId === '') { + return $this->error('9999', 'batch_id 不能为空'); + } + + return $this->success([ + 'batch_id' => $strBatchId, + 'items' => VideoMetadataMissingTaskPoolStateHelper::readBatchHistory( + $this->metadataMissingTaskPoolRoot(), + $strBatchId, + $intLimit + ), + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '读取视频缺字段批次历史失败:' . $Throwable->getMessage()); + } + } + + public function runMetadataMissingWorkbench(Request $Request, ?AdminUserModel $AdminUserModel) + { + try { + $intSample = max(1, min(100, (int)$Request->post('sample', 20))); + $intQueueLimit = max(1, min(500, (int)$Request->post('queue_limit', 100))); + $intPromptLimit = max(1, min(100, (int)$Request->post('prompt_limit', 20))); + + $arrSummary = VideoMetadataMissingWorkbenchHelper::buildSummary($intSample, $intQueueLimit, $intPromptLimit); + $arrSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($this->metadataMissingWorkbenchRoot(), $arrSummary); + $arrSummary['summary_json_path'] = $this->publicRelativePath((string)($arrSummary['summary_json_path'] ?? '')); + $arrSummary['summary_html_path'] = $this->publicRelativePath((string)($arrSummary['summary_html_path'] ?? '')); + $arrSummary['prompt_markdown_path'] = $this->publicRelativePath((string)($arrSummary['prompt_markdown_path'] ?? '')); + + return $this->success([ + 'status' => 'success', + 'summary' => $arrSummary, + ]); + } catch (\Throwable $Throwable) { + return $this->error('9999', '生成视频缺失字段工作台失败:' . $Throwable->getMessage()); + } + } + + protected function metadataMissingWorkbenchRoot(): string + { + return rtrim((string)root_path(), '/') . '/app/public/_admin_templates/video-metadata-missing-workbench'; + } + + protected function metadataMissingWorkbenchRunRoot(): string + { + return $this->metadataMissingWorkbenchRoot() . '/runs'; + } + + protected function metadataMissingTaskPoolRoot(): string + { + return rtrim((string)root_path(), '/') . '/app/public/_admin_templates/video-metadata-missing-task-pool'; + } + + protected function publicRelativePath(string $strPath): string + { + $strPublicRoot = rtrim(str_replace('\\', '/', rtrim((string)root_path(), '/') . '/app/public/'), '/'); + $strPath = str_replace('\\', '/', $strPath); + if ($strPath !== '' && str_starts_with($strPath, $strPublicRoot . '/')) { + return substr($strPath, strlen($strPublicRoot . '/')); + } + + return $strPath; + } } diff --git a/code/app/command/VideoMetadataAuditCommand.php b/code/app/command/VideoMetadataAuditCommand.php new file mode 100644 index 0000000..a8804ae --- /dev/null +++ b/code/app/command/VideoMetadataAuditCommand.php @@ -0,0 +1,134 @@ +setName('video:metadata:audit') + ->addOption('sample', null, Option::VALUE_OPTIONAL, '抽样输出条数,默认 20', 20) + ->setDescription('审计视频库缺失字段情况,并生成历史回填分析文件'); + } + + protected function execute(Input $input, Output $output) + { + $intSample = max(1, (int)$input->getOption('sample')); + $arrSummary = VideoModel::getInstance()->buildMissingMetadataAuditSummary($intSample); + + $strRoot = rtrim((string)root_path(), '/'); + $strOutputRoot = $strRoot . '/storage/video-metadata-audit'; + if (!is_dir($strOutputRoot)) { + @mkdir($strOutputRoot, 0777, true); + } + + $strJsonPath = $strOutputRoot . '/latest.json'; + $strMarkdownPath = $strOutputRoot . '/latest.md'; + + file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL); + file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary)); + + $output->writeln('视频元数据缺失审计已生成'); + $output->writeln('总视频数:' . (int)($arrSummary['total_videos'] ?? 0)); + $output->writeln('存在任一缺失字段的视频数:' . (int)($arrSummary['videos_with_any_missing_metadata'] ?? 0)); + $output->writeln('JSON:' . $strJsonPath); + $output->writeln('Markdown:' . $strMarkdownPath); + + foreach ((array)($arrSummary['field_stats'] ?? []) as $arrField) { + $output->writeln(sprintf( + '%s => 缺失 %d 条,占比 %.2f%%', + (string)($arrField['field'] ?? ''), + (int)($arrField['missing_count'] ?? 0), + ((float)($arrField['missing_ratio'] ?? 0)) * 100 + )); + } + + return 0; + } + + protected function buildMarkdown(array $arrSummary): string + { + $arrLines = []; + $arrLines[] = '# 视频元数据缺失审计'; + $arrLines[] = ''; + $arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? ''); + $arrLines[] = '- 总视频数:' . (int)($arrSummary['total_videos'] ?? 0); + $arrLines[] = '- 任一缺失字段视频数:' . (int)($arrSummary['videos_with_any_missing_metadata'] ?? 0); + $arrLines[] = ''; + $arrLines[] = '## 字段缺失统计'; + $arrLines[] = ''; + $arrLines[] = '| 字段 | 缺失数 | 缺失占比 | 建议动作 |'; + $arrLines[] = '| --- | ---: | ---: | --- |'; + + foreach ((array)($arrSummary['field_stats'] ?? []) as $arrField) { + $strField = (string)($arrField['field'] ?? ''); + $intMissingCount = (int)($arrField['missing_count'] ?? 0); + $floatRatio = ((float)($arrField['missing_ratio'] ?? 0)) * 100; + $arrLines[] = sprintf( + '| %s | %d | %.2f%% | %s |', + $strField, + $intMissingCount, + $floatRatio, + $this->buildSuggestedAction($strField) + ); + } + + $arrLines[] = ''; + $arrLines[] = '## 抽样样本'; + $arrLines[] = ''; + $arrLines[] = '| v_id | 片名 | 分类 | 年份 | 备注 | 缺失字段 | 更新时间 |'; + $arrLines[] = '| ---: | --- | --- | --- | --- | --- | --- |'; + + foreach ((array)($arrSummary['samples'] ?? []) as $arrSample) { + $arrLines[] = sprintf( + '| %d | %s | %s | %s | %s | %s | %s |', + (int)($arrSample['v_id'] ?? 0), + $this->escapeMarkdown((string)($arrSample['v_name'] ?? '')), + $this->escapeMarkdown((string)($arrSample['v_category'] ?? '')), + $this->escapeMarkdown((string)($arrSample['v_year'] ?? '')), + $this->escapeMarkdown((string)($arrSample['v_remarks'] ?? '')), + $this->escapeMarkdown(implode(', ', (array)($arrSample['missing_fields'] ?? []))), + $this->escapeMarkdown((string)($arrSample['updated_at'] ?? '')) + ); + } + + $arrLines[] = ''; + $arrLines[] = '## 判读原则'; + $arrLines[] = ''; + $arrLines[] = '1. 演员、导演、年份、地区、语言这类结构化字段,优先靠重采、补源、人工校正,不能让 AI 猜。'; + $arrLines[] = '2. 简介、备注这类文案型字段,如果事实基础足够,可以走 AI 生成并写回视频库,但必须只补空字段。'; + $arrLines[] = '3. 后续采集器已接入“只补空字段、不覆盖已有值”的写库逻辑,新采到的有效字段会逐步沉淀进库。'; + $arrLines[] = ''; + + return implode(PHP_EOL, $arrLines) . PHP_EOL; + } + + protected function buildSuggestedAction(string $strField): string + { + return match ($strField) { + 'v_actor', 'v_director', 'v_year', 'v_lang', 'v_lang_en', 'v_area', 'v_area_en', 'v_publish_date' + => '优先重采或补源,不建议 AI 猜测', + 'v_description', 'v_remarks' + => '可在事实边界内走 AI 补全并写库', + default => '先审计,再决定重采或补写', + }; + } + + protected function escapeMarkdown(string $strValue): string + { + $strValue = trim($strValue); + if ($strValue === '') { + return ''; + } + + return str_replace('|', '\\|', $strValue); + } +} diff --git a/code/app/command/VideoMetadataCopyFillCommand.php b/code/app/command/VideoMetadataCopyFillCommand.php new file mode 100644 index 0000000..b893d5e --- /dev/null +++ b/code/app/command/VideoMetadataCopyFillCommand.php @@ -0,0 +1,109 @@ +setName('video:metadata:fill-copy') + ->addOption('limit', null, Option::VALUE_OPTIONAL, '本次最多处理多少条,默认 500', 500) + ->addOption('dry-run', null, Option::VALUE_NONE, '只预览,不写库') + ->setDescription('批量补写视频库里为空的简介和备注,只补空字段'); + } + + protected function execute(Input $input, Output $output) + { + $intLimit = max(1, min((int)$input->getOption('limit'), 5000)); + $boolDryRun = (bool)$input->getOption('dry-run'); + $VideoModel = VideoModel::getInstance(); + + $Cursor = $VideoModel->getCol()->find( + [ + '$or' => [ + ['v_description' => ['$exists' => false]], + ['v_description' => ''], + ['v_description' => null], + ['v_remarks' => ['$exists' => false]], + ['v_remarks' => ''], + ['v_remarks' => null], + ], + ], + [ + 'limit' => $intLimit, + 'sort' => ['updated_at' => -1, 'v_id' => -1], + 'projection' => [ + '_id' => 0, + 'v_id' => 1, + 'v_name' => 1, + 'v_category' => 1, + 'v_parent_category' => 1, + 'v_year' => 1, + 'v_area' => 1, + 'v_lang' => 1, + 'v_director' => 1, + 'v_actor' => 1, + 'v_remarks' => 1, + 'v_description' => 1, + 'v_isend' => 1, + 'v_publish_date' => 1, + ], + 'typeMap' => VideoModel::$arrOptions['typeMap'], + ] + ); + + $intScanned = 0; + $intUpdated = 0; + $arrSamples = []; + + foreach ($Cursor as $arrVideo) { + $intScanned++; + $arrUpdate = VideoMetadataCopyFillHelper::buildFillPayload((array)$arrVideo, 'local_copy_fill_command'); + if (empty($arrUpdate)) { + continue; + } + + $arrSamples[] = [ + 'v_id' => (int)($arrVideo['v_id'] ?? 0), + 'v_name' => (string)($arrVideo['v_name'] ?? ''), + 'filled_fields' => (array)($arrUpdate['v_metadata_fill']['filled_fields'] ?? []), + 'generated_description' => (string)($arrUpdate['v_description'] ?? ''), + 'generated_remarks' => (string)($arrUpdate['v_remarks'] ?? ''), + ]; + + if (!$boolDryRun) { + $VideoModel->updateOne( + ['v_id' => (int)($arrVideo['v_id'] ?? 0)], + ['$set' => $arrUpdate] + ); + } + + $intUpdated++; + } + + $output->writeln('视频文案型元数据补写完成'); + $output->writeln('模式:' . ($boolDryRun ? 'dry-run' : 'write')); + $output->writeln('扫描条数:' . $intScanned); + $output->writeln('命中可补写条数:' . $intUpdated); + + foreach (array_slice($arrSamples, 0, 5) as $arrSample) { + $output->writeln(sprintf( + '#%d %s => %s', + (int)($arrSample['v_id'] ?? 0), + (string)($arrSample['v_name'] ?? ''), + implode(', ', (array)($arrSample['filled_fields'] ?? [])) + )); + } + + return 0; + } +} diff --git a/code/app/command/VideoMetadataMissingPlanTaskSeedCommand.php b/code/app/command/VideoMetadataMissingPlanTaskSeedCommand.php new file mode 100644 index 0000000..c28f7ed --- /dev/null +++ b/code/app/command/VideoMetadataMissingPlanTaskSeedCommand.php @@ -0,0 +1,50 @@ +setName('plan:seed:video-metadata-missing') + ->setDescription('补齐视频缺字段任务池计划任务 REFRESH_VIDEO_METADATA_TASK_POOL'); + } + + protected function execute(Input $input, Output $output) + { + $strCode = 'REFRESH_VIDEO_METADATA_TASK_POOL'; + $arrData = [ + 'pt_name' => '视频缺字段任务池刷新', + 'pt_code' => $strCode, + 'pt_enable' => 0, + 'pt_limit' => 24 * 3600, + 'pt_last_exec' => 0, + ]; + + $PlanTaskModel = PlanTaskModel::where('pt_code', $strCode)->find(); + if ($PlanTaskModel instanceof PlanTaskModel) { + $output->writeln('计划任务已存在,无需重复创建:' . $strCode); + $output->writeln('pt_id:' . (int)$PlanTaskModel->pt_id); + $output->writeln('pt_name:' . (string)$PlanTaskModel->pt_name); + $output->writeln('pt_enable:' . (int)$PlanTaskModel->pt_enable); + $output->writeln('pt_limit:' . (int)$PlanTaskModel->pt_limit); + return 0; + } + + $intId = (int)PlanTaskModel::insertGetId($arrData); + $output->writeln('计划任务已创建:' . $strCode); + $output->writeln('pt_id:' . $intId); + $output->writeln('pt_name:' . $arrData['pt_name']); + $output->writeln('pt_enable:' . $arrData['pt_enable']); + $output->writeln('pt_limit:' . $arrData['pt_limit']); + + return 0; + } +} diff --git a/code/app/command/VideoMetadataMissingTaskPoolCommand.php b/code/app/command/VideoMetadataMissingTaskPoolCommand.php new file mode 100644 index 0000000..ad5c8d0 --- /dev/null +++ b/code/app/command/VideoMetadataMissingTaskPoolCommand.php @@ -0,0 +1,55 @@ +setName('video:metadata:task-pool') + ->addOption('sample', null, Option::VALUE_OPTIONAL, '缺失字段样本数,默认 20', 20) + ->addOption('queue-limit', null, Option::VALUE_OPTIONAL, '重采优先队列条数,默认 100', 100) + ->addOption('prompt-limit', null, Option::VALUE_OPTIONAL, 'Codex 派单样本数,默认 20', 20) + ->addOption('batch-size', null, Option::VALUE_OPTIONAL, '每批数量,默认 20', 20) + ->addOption('batch-limit', null, Option::VALUE_OPTIONAL, '最大批次数,默认 10', 10) + ->setDescription('生成视频缺字段任务池产物,供后台展示、Codex 接手和计划任务挂接'); + } + + protected function execute(Input $input, Output $output) + { + $intSample = max(1, min(100, (int)$input->getOption('sample'))); + $intQueueLimit = max(1, min(500, (int)$input->getOption('queue-limit'))); + $intPromptLimit = max(1, min(100, (int)$input->getOption('prompt-limit'))); + $intBatchSize = max(5, min(100, (int)$input->getOption('batch-size'))); + $intBatchLimit = max(1, min(50, (int)$input->getOption('batch-limit'))); + + $arrResult = VideoMetadataMissingRefreshHelper::refresh([ + 'sample' => $intSample, + 'queue_limit' => $intQueueLimit, + 'prompt_limit' => $intPromptLimit, + 'batch_size' => $intBatchSize, + 'batch_limit' => $intBatchLimit, + ]); + $arrWorkbenchSummary = (array)($arrResult['workbench'] ?? []); + $arrTaskPoolSummary = (array)($arrResult['task_pool'] ?? []); + + $output->writeln('视频缺字段任务池已生成'); + $output->writeln('工作台缺字段视频数:' . (int)(($arrWorkbenchSummary['audit'] ?? [])['videos_with_any_missing_metadata'] ?? 0)); + $output->writeln('工作台重采优先队列:' . (int)(($arrWorkbenchSummary['queue'] ?? [])['queue_size'] ?? 0)); + $output->writeln('任务池批次数:' . count((array)($arrTaskPoolSummary['batches'] ?? []))); + $output->writeln('工作台 JSON:' . (string)($arrWorkbenchSummary['summary_json_path'] ?? '')); + $output->writeln('工作台 HTML:' . (string)($arrWorkbenchSummary['summary_html_path'] ?? '')); + $output->writeln('任务池 JSON:' . (string)($arrTaskPoolSummary['summary_json_path'] ?? '')); + $output->writeln('任务池 HTML:' . (string)($arrTaskPoolSummary['summary_html_path'] ?? '')); + + return 0; + } +} diff --git a/code/app/command/VideoMetadataMissingWorkbenchCommand.php b/code/app/command/VideoMetadataMissingWorkbenchCommand.php new file mode 100644 index 0000000..9f5222c --- /dev/null +++ b/code/app/command/VideoMetadataMissingWorkbenchCommand.php @@ -0,0 +1,43 @@ +setName('video:metadata:workbench') + ->addOption('sample', null, Option::VALUE_OPTIONAL, '缺失字段样本数,默认 20', 20) + ->addOption('queue-limit', null, Option::VALUE_OPTIONAL, '重采优先队列条数,默认 100', 100) + ->addOption('prompt-limit', null, Option::VALUE_OPTIONAL, 'Codex 派单样本数,默认 20', 20) + ->setDescription('生成视频缺失字段工作台产物,供后台和 Codex 派单使用'); + } + + protected function execute(Input $input, Output $output) + { + $intSample = max(1, min(100, (int)$input->getOption('sample'))); + $intQueueLimit = max(1, min(500, (int)$input->getOption('queue-limit'))); + $intPromptLimit = max(1, min(100, (int)$input->getOption('prompt-limit'))); + $strOutputRoot = rtrim((string)root_path(), '/') . '/app/public/_admin_templates/video-metadata-missing-workbench'; + + $arrSummary = VideoMetadataMissingWorkbenchHelper::buildSummary($intSample, $intQueueLimit, $intPromptLimit); + $arrSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($strOutputRoot, $arrSummary); + + $output->writeln('视频缺失字段工作台已生成'); + $output->writeln('存在缺字段视频数:' . (int)(($arrSummary['audit'] ?? [])['videos_with_any_missing_metadata'] ?? 0)); + $output->writeln('重采优先队列:' . (int)(($arrSummary['queue'] ?? [])['queue_size'] ?? 0)); + $output->writeln('JSON:' . (string)($arrSummary['summary_json_path'] ?? '')); + $output->writeln('HTML:' . (string)($arrSummary['summary_html_path'] ?? '')); + $output->writeln('Prompt:' . (string)($arrSummary['prompt_markdown_path'] ?? '')); + + return 0; + } +} diff --git a/code/app/command/VideoMetadataRecrawlQueueCommand.php b/code/app/command/VideoMetadataRecrawlQueueCommand.php new file mode 100644 index 0000000..10fe89e --- /dev/null +++ b/code/app/command/VideoMetadataRecrawlQueueCommand.php @@ -0,0 +1,106 @@ +setName('video:metadata:recrawl-queue') + ->addOption('limit', null, Option::VALUE_OPTIONAL, '输出多少条重采优先项,默认 200', 200) + ->setDescription('生成演员/导演缺失的视频重采优先队列'); + } + + protected function execute(Input $input, Output $output) + { + $intLimit = max(1, min((int)$input->getOption('limit'), 5000)); + $arrSummary = VideoModel::getInstance()->buildRecrawlPriorityQueueSummary($intLimit); + + $strRoot = rtrim((string)root_path(), '/'); + $strOutputRoot = $strRoot . '/storage/video-metadata-recrawl-queue'; + if (!is_dir($strOutputRoot)) { + @mkdir($strOutputRoot, 0777, true); + } + + $strJsonPath = $strOutputRoot . '/latest.json'; + $strMarkdownPath = $strOutputRoot . '/latest.md'; + + file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL); + file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary)); + + $output->writeln('视频元数据重采优先队列已生成'); + $output->writeln('队列条数:' . (int)($arrSummary['queue_size'] ?? 0)); + $output->writeln('JSON:' . $strJsonPath); + $output->writeln('Markdown:' . $strMarkdownPath); + + foreach (array_slice((array)($arrSummary['items'] ?? []), 0, 10) as $arrItem) { + $output->writeln(sprintf( + '#%d %s => score:%d | %s', + (int)($arrItem['v_id'] ?? 0), + (string)($arrItem['v_name'] ?? ''), + (int)($arrItem['priority_score'] ?? 0), + (string)($arrItem['priority_reason'] ?? '') + )); + } + + return 0; + } + + protected function buildMarkdown(array $arrSummary): string + { + $arrLines = []; + $arrLines[] = '# 视频元数据重采优先队列'; + $arrLines[] = ''; + $arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? ''); + $arrLines[] = '- 队列条数:' . (int)($arrSummary['queue_size'] ?? 0); + $arrLines[] = '- 当前规则:优先处理演员 / 导演缺失且已有访问信号、仍在更新、多线路可交叉重采的视频'; + $arrLines[] = ''; + $arrLines[] = '| 优先分 | v_id | 片名 | 分类 | 缺失字段 | 周点击 | 总点击 | 播放源数 | 更新时间 | 优先原因 |'; + $arrLines[] = '| ---: | ---: | --- | --- | --- | ---: | ---: | ---: | --- | --- |'; + + foreach ((array)($arrSummary['items'] ?? []) as $arrItem) { + $arrLines[] = sprintf( + '| %d | %d | %s | %s | %s | %d | %d | %d | %s | %s |', + (int)($arrItem['priority_score'] ?? 0), + (int)($arrItem['v_id'] ?? 0), + $this->escapeMarkdown((string)($arrItem['v_name'] ?? '')), + $this->escapeMarkdown((string)($arrItem['v_category'] ?? '')), + $this->escapeMarkdown(implode(', ', (array)($arrItem['missing_fields'] ?? []))), + (int)(($arrItem['click_stats'] ?? [])['weekly'] ?? 0), + (int)(($arrItem['click_stats'] ?? [])['total'] ?? 0), + (int)($arrItem['play_source_count'] ?? 0), + $this->escapeMarkdown((string)($arrItem['updated_at'] ?? '')), + $this->escapeMarkdown((string)($arrItem['priority_reason'] ?? '')) + ); + } + + $arrLines[] = ''; + $arrLines[] = '## 执行建议'; + $arrLines[] = ''; + $arrLines[] = '1. 先处理前 50 条高分项,优先尝试从已有多播放源重新抓取演员/导演。'; + $arrLines[] = '2. 如果多源都拿不到,再转人工补源或定向接第三方资料源。'; + $arrLines[] = '3. 不允许用 AI 猜演员/导演;结构化事实字段只能靠真实来源补齐。'; + $arrLines[] = '4. 每次重采后,复跑 `php think video:metadata:audit` 看缺口是否下降。'; + $arrLines[] = ''; + + return implode(PHP_EOL, $arrLines) . PHP_EOL; + } + + protected function escapeMarkdown(string $strValue): string + { + $strValue = trim($strValue); + if ($strValue === '') { + return ''; + } + + return str_replace('|', '\\|', $strValue); + } +} diff --git a/code/app/command/VideoMetadataRecrawlRunCommand.php b/code/app/command/VideoMetadataRecrawlRunCommand.php new file mode 100644 index 0000000..4196715 --- /dev/null +++ b/code/app/command/VideoMetadataRecrawlRunCommand.php @@ -0,0 +1,344 @@ +setName('video:metadata:recrawl-run') + ->addOption('limit', null, Option::VALUE_OPTIONAL, '默认按重采队列取多少条,默认 10', 10) + ->addOption('v-ids', null, Option::VALUE_OPTIONAL, '指定 v_id,多个逗号分隔;指定后优先按 v_id 执行', '') + ->addOption('sources', null, Option::VALUE_OPTIONAL, '强制指定源站,多个逗号分隔,如 douban,youzhi', '') + ->addOption('dry-run', null, Option::VALUE_NONE, '只搜索和匹配,不真正回写') + ->setDescription('按视频元数据重采队列执行演员/导演定向重采,并生成执行结果报告'); + } + + protected function execute(Input $input, Output $output) + { + $intLimit = max(1, min((int)$input->getOption('limit'), 100)); + $arrForcedVIds = $this->parseIntList((string)$input->getOption('v-ids')); + $arrForcedSources = $this->parseSourceList((string)$input->getOption('sources')); + $boolDryRun = (bool)$input->getOption('dry-run'); + + $arrCandidates = $this->resolveCandidates($arrForcedVIds, $intLimit); + + if (empty($arrCandidates)) { + $output->writeln('没有可执行的候选视频'); + return 0; + } + + $arrSummary = [ + 'generated_at' => date(DATE_ATOM), + 'mode' => !empty($arrForcedVIds) ? 'explicit_v_ids' : 'queue', + 'dry_run' => $boolDryRun, + 'limit' => $intLimit, + 'requested_v_ids' => $arrForcedVIds, + 'forced_sources' => $arrForcedSources, + 'attempted_videos' => count($arrCandidates), + 'success_actor_count' => 0, + 'success_director_count' => 0, + 'success_both_count' => 0, + 'failed_both_count' => 0, + 'items' => [], + ]; + + foreach ($arrCandidates as $arrCandidate) { + $arrItemSummary = $this->processCandidate($arrCandidate, $arrForcedSources, $boolDryRun); + $arrSummary['items'][] = $arrItemSummary; + + if (!empty($arrItemSummary['actor_filled'])) { + $arrSummary['success_actor_count']++; + } + if (!empty($arrItemSummary['director_filled'])) { + $arrSummary['success_director_count']++; + } + if (!empty($arrItemSummary['actor_filled']) && !empty($arrItemSummary['director_filled'])) { + $arrSummary['success_both_count']++; + } + if (empty($arrItemSummary['actor_filled']) && empty($arrItemSummary['director_filled'])) { + $arrSummary['failed_both_count']++; + } + + $output->writeln(sprintf( + '#%d %s | actor:%s | director:%s | sources:%s', + (int)$arrItemSummary['v_id'], + (string)$arrItemSummary['v_name'], + !empty($arrItemSummary['actor_filled']) ? 'filled' : 'no', + !empty($arrItemSummary['director_filled']) ? 'filled' : 'no', + implode(',', array_column((array)$arrItemSummary['source_runs'], 'source')) + )); + } + + $strRoot = rtrim((string)root_path(), '/'); + $strOutputRoot = $strRoot . '/storage/video-metadata-recrawl-run'; + if (!is_dir($strOutputRoot)) { + @mkdir($strOutputRoot, 0777, true); + } + + $strJsonPath = $strOutputRoot . '/latest.json'; + $strMarkdownPath = $strOutputRoot . '/latest.md'; + + file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL); + file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary)); + + $output->writeln('视频元数据定向重采报告已生成'); + $output->writeln('JSON:' . $strJsonPath); + $output->writeln('Markdown:' . $strMarkdownPath); + + return 0; + } + + protected function resolveCandidates(array $arrForcedVIds, int $intLimit): array + { + if (!empty($arrForcedVIds)) { + $arrCandidates = []; + foreach ($arrForcedVIds as $intVId) { + $arrVideo = VideoModel::getInstance()->getVideoByVId($intVId); + if (empty($arrVideo)) { + continue; + } + + $arrPlaySources = array_values(array_filter(array_map('trim', array_keys((array)($arrVideo['v_play_url'] ?? []))))); + $arrCandidates[] = [ + 'v_id' => (int)($arrVideo['v_id'] ?? 0), + 'v_name' => (string)($arrVideo['v_name'] ?? ''), + 'v_name_en' => (string)($arrVideo['v_name_en'] ?? ''), + 'play_sources' => $arrPlaySources, + ]; + } + + return $arrCandidates; + } + + return (array)(VideoModel::getInstance()->buildRecrawlPriorityQueueSummary($intLimit)['items'] ?? []); + } + + protected function processCandidate(array $arrCandidate, array $arrForcedSources, bool $boolDryRun): array + { + $intVId = (int)($arrCandidate['v_id'] ?? 0); + $strVName = trim((string)($arrCandidate['v_name'] ?? '')); + $strVNameEn = trim((string)($arrCandidate['v_name_en'] ?? '')); + + $arrBefore = VideoModel::getInstance()->getVideoByVId($intVId) ?? []; + $arrSourceRuns = []; + + $arrSources = !empty($arrForcedSources) + ? $arrForcedSources + : $this->parseSourceList(implode(',', (array)($arrCandidate['play_sources'] ?? []))); + + foreach ($arrSources as $strSource) { + $arrSearchResult = $this->searchSourceVideo($strSource, $strVName, $strVNameEn); + + $arrSourceRun = [ + 'source' => $strSource, + 'matched' => !empty($arrSearchResult), + 'matched_vod_id' => (int)($arrSearchResult['vod_id'] ?? 0), + 'matched_vod_name' => (string)($arrSearchResult['vod_name'] ?? ''), + 'status' => 'no_match', + ]; + + if (empty($arrSearchResult)) { + $arrSourceRuns[] = $arrSourceRun; + continue; + } + + if ($boolDryRun) { + $arrSourceRun['status'] = 'matched_dry_run'; + $arrSourceRuns[] = $arrSourceRun; + continue; + } + + $boolSaved = $this->fetchAndSaveSourceVideo($strSource, (int)$arrSearchResult['vod_id']); + $arrSourceRun['status'] = $boolSaved ? 'saved' : 'fetch_failed'; + $arrSourceRuns[] = $arrSourceRun; + } + + $arrAfter = VideoModel::getInstance()->getVideoByVId($intVId) ?? []; + + $VideoModel = VideoModel::getInstance(); + $boolActorBefore = $VideoModel->isFieldMetadataMissing('v_actor', $arrBefore['v_actor'] ?? null); + $boolDirectorBefore = $VideoModel->isFieldMetadataMissing('v_director', $arrBefore['v_director'] ?? null); + $boolActorAfter = $VideoModel->isFieldMetadataMissing('v_actor', $arrAfter['v_actor'] ?? null); + $boolDirectorAfter = $VideoModel->isFieldMetadataMissing('v_director', $arrAfter['v_director'] ?? null); + + return [ + 'v_id' => $intVId, + 'v_name' => $strVName, + 'sources_requested' => $arrSources, + 'actor_filled' => $boolActorBefore && !$boolActorAfter, + 'director_filled' => $boolDirectorBefore && !$boolDirectorAfter, + 'before' => [ + 'actor_empty' => $boolActorBefore, + 'director_empty' => $boolDirectorBefore, + ], + 'after' => [ + 'actor_empty' => $boolActorAfter, + 'director_empty' => $boolDirectorAfter, + ], + 'source_runs' => $arrSourceRuns, + ]; + } + + protected function searchSourceVideo(string $strSource, string $strVName, string $strVNameEn = ''): array + { + $Site = $this->makeSite($strSource); + $strUri = $this->buildSearchUri($strSource, $strVName); + + $Response = $Site->getClient()->get($strUri); + $strContent = (string)$Response->getBody()->getContents(); + $arrContent = json_decode($strContent, true); + $arrList = (array)($arrContent['list'] ?? []); + + if (empty($arrList)) { + return []; + } + + $strTargetName = $this->normalizeTitle($strVName); + $strTargetNameEn = strtolower(trim($strVNameEn)); + + foreach ($arrList as $arrItem) { + $strVodName = $this->normalizeTitle((string)($arrItem['vod_name'] ?? '')); + $strVodNameEn = strtolower(trim((string)($arrItem['vod_en'] ?? ''))); + + if ($strVodName !== '' && $strVodName === $strTargetName) { + return (array)$arrItem; + } + + if ($strTargetNameEn !== '' && $strVodNameEn !== '' && $strVodNameEn === $strTargetNameEn) { + return (array)$arrItem; + } + } + + return (array)$arrList[0]; + } + + protected function fetchAndSaveSourceVideo(string $strSource, int $intVodId): bool + { + if ($intVodId <= 0) { + return false; + } + + $Site = $this->makeSite($strSource); + $arrPages = $Site->getVideoInfoPageList([ + ['v_source_id' => $intVodId], + ]); + + if (empty($arrPages)) { + return false; + } + + $boolSaved = false; + foreach ($arrPages as $VideoInfoPage) { + $boolSaved = $VideoInfoPage->saveVideo() || $boolSaved; + } + + return $boolSaved; + } + + protected function makeSite(string $strSource) + { + return match ($strSource) { + 'douban' => new DoubanSite(), + 'youzhi' => new YouzhiSite(), + default => throw new \InvalidArgumentException('不支持的源站:' . $strSource), + }; + } + + protected function buildSearchUri(string $strSource, string $strKeyword): string + { + $strKeyword = urlencode($strKeyword); + + return match ($strSource) { + 'douban' => '/api.php/provide/vod/at/josn?ac=list&wd=' . $strKeyword, + 'youzhi' => '/inc/api_mac10.php?ac=list&wd=' . $strKeyword, + default => throw new \InvalidArgumentException('不支持的源站:' . $strSource), + }; + } + + protected function parseIntList(string $strValue): array + { + return array_values(array_filter(array_map(static function (string $strItem): int { + return (int)trim($strItem); + }, explode(',', $strValue)), static function (int $intValue): bool { + return $intValue > 0; + })); + } + + protected function parseSourceList(string $strValue): array + { + $arrAllowed = ['douban', 'youzhi']; + + return array_values(array_filter(array_unique(array_map(static function (string $strItem): string { + return trim($strItem); + }, explode(',', $strValue))), static function (string $strItem) use ($arrAllowed): bool { + return in_array($strItem, $arrAllowed, true); + })); + } + + protected function normalizeTitle(string $strValue): string + { + $strValue = trim(mb_strtolower($strValue)); + if ($strValue === '') { + return ''; + } + + return preg_replace('/[\s\p{P}\p{S}]+/u', '', $strValue) ?? $strValue; + } + + protected function buildMarkdown(array $arrSummary): string + { + $arrLines = []; + $arrLines[] = '# 视频元数据定向重采执行结果'; + $arrLines[] = ''; + $arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? ''); + $arrLines[] = '- 模式:' . (string)($arrSummary['mode'] ?? ''); + $arrLines[] = '- Dry Run:' . (!empty($arrSummary['dry_run']) ? 'yes' : 'no'); + $arrLines[] = '- 处理条数:' . (int)($arrSummary['attempted_videos'] ?? 0); + $arrLines[] = '- 演员补齐条数:' . (int)($arrSummary['success_actor_count'] ?? 0); + $arrLines[] = '- 导演补齐条数:' . (int)($arrSummary['success_director_count'] ?? 0); + $arrLines[] = '- 双字段都补齐:' . (int)($arrSummary['success_both_count'] ?? 0); + $arrLines[] = '- 双字段都未补齐:' . (int)($arrSummary['failed_both_count'] ?? 0); + $arrLines[] = ''; + $arrLines[] = '| v_id | 片名 | actor补齐 | director补齐 | 源站执行摘要 |'; + $arrLines[] = '| ---: | --- | --- | --- | --- |'; + + foreach ((array)($arrSummary['items'] ?? []) as $arrItem) { + $arrSourceSummary = array_map(static function (array $arrRun): string { + return sprintf( + '%s:%s%s', + (string)($arrRun['source'] ?? ''), + (string)($arrRun['status'] ?? ''), + !empty($arrRun['matched_vod_id']) ? '#' . (int)$arrRun['matched_vod_id'] : '' + ); + }, (array)($arrItem['source_runs'] ?? [])); + + $arrLines[] = sprintf( + '| %d | %s | %s | %s | %s |', + (int)($arrItem['v_id'] ?? 0), + $this->escapeMarkdown((string)($arrItem['v_name'] ?? '')), + !empty($arrItem['actor_filled']) ? 'yes' : 'no', + !empty($arrItem['director_filled']) ? 'yes' : 'no', + $this->escapeMarkdown(implode(' / ', $arrSourceSummary)) + ); + } + + $arrLines[] = ''; + return implode(PHP_EOL, $arrLines) . PHP_EOL; + } + + protected function escapeMarkdown(string $strValue): string + { + return str_replace('|', '\\|', trim($strValue)); + } +} diff --git a/code/app/common/helper/DomainBootstrapApplyHelper.php b/code/app/common/helper/DomainBootstrapApplyHelper.php index bf14be6..1b5ac2a 100644 --- a/code/app/common/helper/DomainBootstrapApplyHelper.php +++ b/code/app/common/helper/DomainBootstrapApplyHelper.php @@ -125,6 +125,8 @@ class DomainBootstrapApplyHelper $DomainModel->d_seo_cfg = $arrNormalizedPayload['d_seo_cfg'] ?? $DomainModel->d_seo_cfg; $DomainModel->save(); DomainModel::flushAllDomianOnCache(); + SeoResourcePoolHelper::initSitePositioning((string)$DomainModel->d_domain, 1, true); + DomainSitemapGenerationHelper::queueForDomains([(string)$DomainModel->d_domain]); $arrSummary['status'] = 'applied'; return $arrSummary; diff --git a/code/app/common/helper/DomainBootstrapRegisterHelper.php b/code/app/common/helper/DomainBootstrapRegisterHelper.php index 11b0d1c..0c86555 100644 --- a/code/app/common/helper/DomainBootstrapRegisterHelper.php +++ b/code/app/common/helper/DomainBootstrapRegisterHelper.php @@ -193,6 +193,7 @@ class DomainBootstrapRegisterHelper DomainModel::flushAllDomianOnCache(); SeoCopyGenerationHelper::initializeForDomain($DomainModel); SeoResourcePoolHelper::initSitePositioning((string)$DomainModel->d_domain, 1, true); + DomainSitemapGenerationHelper::queueForDomains([(string)$DomainModel->d_domain]); $arrSummary['status'] = 'created'; $arrSummary['found'] = true; diff --git a/code/app/common/helper/DomainSitemapGenerationHelper.php b/code/app/common/helper/DomainSitemapGenerationHelper.php new file mode 100644 index 0000000..f91acbf --- /dev/null +++ b/code/app/common/helper/DomainSitemapGenerationHelper.php @@ -0,0 +1,51 @@ + DomainModel::normalizeStoredDomain((string)$mValue), + $arrDomains + )))); + } + + public static function queueForDomains(array $arrDomains): void + { + $arrDomains = self::normalizeDomains($arrDomains); + if (empty($arrDomains)) { + return; + } + + $arrTask = [ + 'callback' => [VideoSiteMapLogic::class, 'generateSiteMap'], + 'data' => [ + 'domains' => $arrDomains, + ], + ]; + + $QueueManage = new QueueManage(2); + $QueueManage->set($arrTask); + } + + public static function generateNow(array $arrDomains): void + { + $arrDomains = self::normalizeDomains($arrDomains); + if (empty($arrDomains)) { + return; + } + + $VideoSiteMapLogic = new VideoSiteMapLogic(); + $VideoSiteMapLogic->generateSiteMap([ + 'domains' => $arrDomains, + ]); + } +} diff --git a/code/app/common/helper/SeoCopyAiProviderHelper.php b/code/app/common/helper/SeoCopyAiProviderHelper.php index 2d08b9f..29a016e 100644 --- a/code/app/common/helper/SeoCopyAiProviderHelper.php +++ b/code/app/common/helper/SeoCopyAiProviderHelper.php @@ -8,6 +8,11 @@ use app\model\DomainModel; class SeoCopyAiProviderHelper { + public static function buildSharedAiRuleBlock(): string + { + return SeoCopyAiRuleConfigHelper::buildSharedRuleBlock(); + } + public static function resolveProviderConfig(?string $strPreferredProvider = null): array { $strDefaultProvider = self::readEnv('SEO_COPY_AI_PROVIDER', DomainModel::TKD_PROVIDER_LOCAL); @@ -699,7 +704,12 @@ class SeoCopyAiProviderHelper 'content' => [ [ 'type' => 'input_text', - 'text' => '你是资深中文 SEO 编辑,目标是在不虚构事实的前提下,输出适合影视站首页、分类页、搜索页、详情页、播放页的高相关、高可读、强点击意图文案。输出必须是合法 JSON。' + 'text' => implode("\n", [ + '你是资深中文 SEO 编辑,目标是在不虚构事实的前提下,输出适合影视站首页、分类页、搜索页、详情页、播放页的高相关、高可读、强点击意图文案。', + '输出必须是合法 JSON。', + '', + self::buildSharedAiRuleBlock(), + ]), ], ], ], @@ -791,6 +801,10 @@ class SeoCopyAiProviderHelper '2. detail 描述不要只改写公共简介,要更像资料页摘要,先告诉用户这页能解决什么,再补事实线索。', '3. play 描述不要只写在线播放,要带回详情路径、线路判断和必要的事实线索。', '4. 不要虚构具体剧情细节;如果缺少事实,就用更保守的资料页表达。', + '5. detail.description_template、play.description_template 优先按“影视详情页摘要规则”执行。', + '6. detail.detail_body_lead/detail_body_tail、play.play_body_lead/play_body_next 优先按“影视详情页正文补全规则”执行。', + '', + self::buildSharedAiRuleBlock(), ]); } @@ -1332,24 +1346,12 @@ class SeoCopyAiProviderHelper protected static function pickThemeKeywords(array $arrResourceContext, string $strFallback, int $intLimit = 4): array { - $arrOut = []; - foreach (array_merge( + return SeoCopyContextHelper::normalizeKeywordCandidates(array_merge( [$strFallback], (array)($arrResourceContext['priority_keywords'] ?? []), (array)($arrResourceContext['feedback_keywords'] ?? []), (array)($arrResourceContext['topics'] ?? []) - ) as $strItem) { - $strItem = trim((string)$strItem); - if ($strItem === '' || in_array($strItem, $arrOut, true)) { - continue; - } - $arrOut[] = $strItem; - if (count($arrOut) >= $intLimit) { - break; - } - } - - return $arrOut; + ), [], $intLimit); } protected static function pickString(array $arrPool, int $intSeed, string $strSalt): string diff --git a/code/app/common/helper/SeoCopyAiRuleConfigHelper.php b/code/app/common/helper/SeoCopyAiRuleConfigHelper.php new file mode 100644 index 0000000..9454e1b --- /dev/null +++ b/code/app/common/helper/SeoCopyAiRuleConfigHelper.php @@ -0,0 +1,196 @@ + [ + 'default' => "最高指令:\n1. 所有 AI 生成文案必须以 SEO 效果、可读性、真实感和可持续收录为共同目标。\n2. 所有生成只能基于已提供字段与事实,不允许虚构剧情、角色关系、上映信息、评价内容、播放权益或不存在的资料。\n3. 如果当前规则开始明显限制 SEO 增长、限制文案质量上限、限制更高质量的智能发挥,不允许静默硬套,必须明确指出冲突点、风险点和可替代优化方案,再继续讨论升级规则。\n4. 不允许因为“遵守规则”而产出明显模板化、机械化、低区分度、低点击意图的文案。\n5. 不允许输出营销承诺词,例如“免费观看”“高清完整版”“极速播放”“本站提供”“全网最全”等。\n6. 不允许堆砌关键词,不允许片名大面积重复,通常不要超过 2 次。\n7. 语言必须自然、克制、像人工编辑,不要满篇泛称、代词和模板痕迹。", + 'description' => 'SEO文案AI最高指令:控制真实性、SEO效果、去模板化;如果规则本身开始压制SEO增长或高质量发挥,必须先明确提出冲突点再讨论升级,不能静默硬套。', + ], + 'SEO_COPY_AI_RULE_DETAIL_SUMMARY' => [ + 'default' => "影视详情页摘要规则:\n1. 适用于详情页 description、详情摘要、播放页短摘要等短文案场景。\n2. 长度控制在 60 到 110 个中文字符之间。\n3. 如果原简介很短,可以结合导演、主演、题材、地区、年份、更新状态补足信息。\n4. 如果信息不足,输出尽量客观、克制的资料型摘要,不要硬编剧情。\n5. 尽量避免“这部作品 / 这部剧 / 这部片子 / 这部故事 / 它 / 这部新作 / 这套剧集 / 这个故事”等泛称或代词。\n6. 电影:更像电影简介,强调人物处境、冲突推进、情绪和命运。\n7. 剧集:更像连续剧情介绍,强调人物关系线和推进节奏。\n8. 综艺:更像节目简介,强调现场互动、气氛和临场变化。\n9. 动漫:更像动画简介,强调世界观、人物立场和设定线索。\n10. 短剧:更像短剧简介,强调人物处境、冲突推进、情绪和命运。", + 'description' => 'SEO文案AI详情摘要规则:控制 detail/play 短摘要的长度、真实性、场景风格和禁用泛称代词,适合搜索摘要与详情简述。', + ], + 'SEO_COPY_AI_RULE_DETAIL_BODY' => [ + 'default' => "影视详情页正文补全规则:\n1. 适用于详情页正文、播放页正文、详情补充介绍等中长文案场景。\n2. 字数控制在 120 到 220 个中文字符之间。\n3. 如果原简介存在且可用,应优先保留原简介核心信息,再补充结构化资料。\n4. 尽量包含作品类型、地区、语言、年份、导演、主演、更新状态等关键信息。\n5. 如果信息不足,写成简洁客观的资料型介绍,不要硬编剧情。\n6. 不要完全照搬字段顺序,要自然组织表达。\n7. 同样避免“这部作品 / 这部剧 / 这部片子 / 它 / 这部新作 / 这套剧集”等泛称或代词大面积出现。\n8. 电影:更像电影简介,强调人物处境、冲突推进、情绪和命运。\n9. 剧集:更像连续剧情介绍,强调人物关系线和推进节奏。\n10. 综艺:更像节目简介,强调现场互动、气氛和临场变化。\n11. 动漫:更像动画简介,强调世界观、人物立场和设定线索。\n12. 短剧:更像短剧简介,强调人物处境、冲突推进、情绪和命运。", + 'description' => 'SEO文案AI详情正文规则:控制 detail/play 中长正文的真实性、信息密度、字数范围和分类写法,避免模板化和硬编剧情。', + ], + 'SEO_COPY_AI_RULE_MISSING_FIELDS' => [ + 'default' => "缺失字段处理规则:\n1. 当演员、导演、年份、地区、语言、简介、备注等字段为空时,只能承认缺失,不能脑补、补写、猜测、拼接不存在的信息。\n2. 缺少剧情简介时,优先退回“资料型写法”:用已知的片名、类型、地区、语言、年份、更新状态、清晰度、集数、备注等字段,组织成客观简介。\n3. 缺少演员或导演时,不要为了句子完整强行写“由某某领衔”之类表达,可以直接改写为题材导向、设定导向或更新状态导向。\n4. 缺少年份时,不要伪造上映时间、播出时间、年代背景;可以直接省略年份,改用类型、地区、语言、更新状态补足。\n5. 缺少地区、语言时,不要写“未知地区”“未知语言”这种伤点击的话术,直接跳过该字段,用其它已有信息维持句子完整。\n6. 缺少简介且字段很少时,允许输出更克制的资料页文案,但仍要尽量保证可读性,不能只剩机械字段堆叠。\n7. 如果只有片名和极少数字段,优先写成“作品资料说明型”短文案,不要硬写剧情,不要假装很懂内容。\n8. 任意缺失字段都不能触发模板化兜底句式,例如“讲述了一段故事”“围绕主人公展开”“精彩剧情值得关注”这类空泛废话。\n9. 当信息不足以支撑长正文时,允许适当缩短,但必须保证自然、克制、像真人编辑整理,而不是错误扩写。\n10. SEO 目标不是把空字段补满,而是在真实边界内,把现有信息组织成最可信、最自然、最不模板化的可收录文案。", + 'description' => 'SEO文案AI缺失字段处理规则:专门约束演员、导演、年份、简介等字段为空时的写法,要求只在真实信息边界内做资料型组织,禁止脑补和空泛扩写。', + ], + 'SEO_COPY_AI_RULE_MOVIE_STYLE' => [ + 'default' => "电影细分规则:\n1. 电影文案更像电影简介,不要写成剧集分集导语。\n2. 优先强调人物处境、核心冲突、情绪变化、命运推进。\n3. 如果事实不足,可优先组织题材、地区、年份、导演、主演、上映阶段或备注状态,不要硬编剧情反转。\n4. 句式可以更集中、更凝练,保留电影应有的整体感和完成度。\n5. 不要泛泛罗列字段,尽量让信息组织像真实电影站资料页摘要。", + 'description' => 'SEO文案AI电影细分规则:用于电影类详情/播放文案,强调人物处境、冲突推进、情绪与命运,不要写成剧集或综艺口吻。', + ], + 'SEO_COPY_AI_RULE_SERIES_STYLE' => [ + 'default' => "剧集细分规则:\n1. 剧集文案更像连续剧情介绍,不要写成单部电影梗概。\n2. 优先强调人物关系线、剧情推进节奏、阶段性发展与追更感。\n3. 可以适度体现“随着剧情推进”“围绕某条关系线展开”等结构,但不要模板化重复。\n4. 如果信息不足,可回到演员、导演、题材、地区、年份、更新状态这些已知事实,保持追剧导向。\n5. 要让用户和蜘蛛感知到这是持续推进型内容,而不是一次性完整闭环。", + 'description' => 'SEO文案AI剧集细分规则:用于电视剧/连续剧类详情文案,强调人物关系线、推进节奏和持续更新属性。', + ], + 'SEO_COPY_AI_RULE_VARIETY_STYLE' => [ + 'default' => "综艺细分规则:\n1. 综艺文案更像节目简介,不要写成剧情片简介。\n2. 优先强调节目形式、现场互动、嘉宾关系、气氛变化和临场感。\n3. 适合突出舞台、话题、竞演、观察、陪伴、互动等节目特征,但不能编造具体桥段。\n4. 如果事实不足,可结合地区、年份、语言、更新状态和已知嘉宾/主持信息,保持节目导向。\n5. 语气可以更灵动,但仍要克制、真实、像人工编辑。", + 'description' => 'SEO文案AI综艺细分规则:用于综艺/真人秀类文案,强调互动、气氛、节目形式和临场变化,避免剧情片化。', + ], + 'SEO_COPY_AI_RULE_ANIME_STYLE' => [ + 'default' => "动漫细分规则:\n1. 动漫文案更像动画简介,不要硬套真人影视口吻。\n2. 优先强调世界观、人物立场、设定线索、阵营关系和冒险方向。\n3. 可以突出成长、战斗、幻想、校园、异世界、科幻等题材感,但不能编造不存在的设定。\n4. 如果信息不足,可先稳住在类型、地区、年份、语言、导演、主演/配音、更新状态等已知资料。\n5. 表达要保留动画内容常见的设定感和想象空间,但不要写成二创文案。", + 'description' => 'SEO文案AI动漫细分规则:用于动漫/动画类文案,强调世界观、设定线索、人物立场与题材氛围。', + ], + 'SEO_COPY_AI_RULE_SHORT_DRAMA_STYLE' => [ + 'default' => "短剧细分规则:\n1. 短剧文案更强调人物处境、冲突推进、情绪起伏和高密度反差感。\n2. 句式可以更紧凑,但不要低俗、不要标题党、不要营销化。\n3. 如果事实有限,可围绕人物关系、身份落差、阶段性冲突、更新状态来组织,不要编造狗血桥段。\n4. 要让文案读起来有短剧节奏感和推动力,但仍保持资料页风格,不要写成宣发广告。\n5. 适合更突出“发展快、情绪强、关系推进明显”的内容质感。", + 'description' => 'SEO文案AI短剧细分规则:用于短剧类文案,强调人物处境、情绪张力、冲突推进和紧凑节奏。', + ], + 'SEO_COPY_AI_RULE_HIGHEST_NOTE' => [ + 'default' => "实验备注:\n- 修改目的:\n- 预期影响:\n- 观察指标:收录 / 蜘蛛抓取 / 页面点击 / 摘要自然度\n- 回看日期:", + 'description' => 'SEO文案AI规则实验备注:记录最高指令的修改目的、预期影响和回看日期,便于后续SEO复盘。', + ], + 'SEO_COPY_AI_RULE_DETAIL_SUMMARY_NOTE' => [ + 'default' => "实验备注:\n- 修改目的:\n- 预期影响:\n- 观察指标:搜索摘要可读性 / 点击意图 / 收录摘要匹配度\n- 回看日期:", + 'description' => 'SEO文案AI规则实验备注:记录详情摘要规则调整的目的、预期影响和回看日期。', + ], + 'SEO_COPY_AI_RULE_DETAIL_BODY_NOTE' => [ + 'default' => "实验备注:\n- 修改目的:\n- 预期影响:\n- 观察指标:正文自然度 / 页面停留 / 蜘蛛深抓 / 详情页收录质量\n- 回看日期:", + 'description' => 'SEO文案AI规则实验备注:记录详情正文规则调整的目的、预期影响和回看日期。', + ], + 'SEO_COPY_AI_RULE_MISSING_FIELDS_NOTE' => [ + 'default' => "实验备注:\n- 修改目的:减少缺字段资源的硬编、废话和模板腔\n- 预期影响:空字段资源也能保持可信、自然、可收录\n- 观察指标:缺字段详情页自然度 / 收录质量 / 低质页占比\n- 回看日期:", + 'description' => 'SEO文案AI规则实验备注:记录缺失字段处理规则调整的目的、预期影响和回看日期。', + ], + 'SEO_COPY_AI_RULE_MOVIE_STYLE_NOTE' => [ + 'default' => "实验备注:\n- 修改目的:\n- 预期影响:电影页更像真人编辑简介\n- 观察指标:电影详情页摘要自然度 / 收录描述质量 / 点击率\n- 回看日期:", + 'description' => 'SEO文案AI规则实验备注:记录电影细分规则调整的实验目的和复盘节点。', + ], + 'SEO_COPY_AI_RULE_SERIES_STYLE_NOTE' => [ + 'default' => "实验备注:\n- 修改目的:\n- 预期影响:剧集页更突出关系线与追更节奏\n- 观察指标:剧集详情页自然度 / 收录稳定性 / 点击率\n- 回看日期:", + 'description' => 'SEO文案AI规则实验备注:记录剧集细分规则调整的实验目的和复盘节点。', + ], + 'SEO_COPY_AI_RULE_VARIETY_STYLE_NOTE' => [ + 'default' => "实验备注:\n- 修改目的:\n- 预期影响:综艺页更有节目感和互动感\n- 观察指标:摘要差异度 / 收录摘要匹配 / 页面点击率\n- 回看日期:", + 'description' => 'SEO文案AI规则实验备注:记录综艺细分规则调整的实验目的和复盘节点。', + ], + 'SEO_COPY_AI_RULE_ANIME_STYLE_NOTE' => [ + 'default' => "实验备注:\n- 修改目的:\n- 预期影响:动漫页更有设定感和世界观线索\n- 观察指标:摘要自然度 / 题材匹配度 / 收录质量\n- 回看日期:", + 'description' => 'SEO文案AI规则实验备注:记录动漫细分规则调整的实验目的和复盘节点。', + ], + 'SEO_COPY_AI_RULE_SHORT_DRAMA_STYLE_NOTE' => [ + 'default' => "实验备注:\n- 修改目的:\n- 预期影响:短剧页更有节奏感和情绪推动力\n- 观察指标:点击意图 / 摘要张力 / 收录质量\n- 回看日期:", + 'description' => 'SEO文案AI规则实验备注:记录短剧细分规则调整的实验目的和复盘节点。', + ], + ]; + + public static function ensureSystemConfigDefaults(): void + { + $changed = false; + + foreach (self::DEFINITIONS as $code => $definition) { + $exists = SystemConfigModel::where('sc_code', $code)->find(); + if ($exists !== null) { + $expectedDescription = (string)($definition['description'] ?? $code); + if ((string)($exists->sc_description ?? '') !== $expectedDescription) { + $exists->sc_description = $expectedDescription; + $exists->save(); + $changed = true; + } + continue; + } + + $model = new SystemConfigModel(); + $model->save([ + 'sc_code' => $code, + 'sc_val' => (string)($definition['default'] ?? ''), + 'sc_description' => (string)($definition['description'] ?? $code), + 'site_id' => 0, + ]); + $changed = true; + } + + if ($changed) { + SystemConfigModel::flushCache(); + } + } + + public static function buildSharedRuleBlock(): string + { + self::ensureSystemConfigDefaults(); + + $parts = []; + foreach (self::displayRuleCodes() as $code) { + $value = trim((string)SystemConfigModel::getValByCode($code)); + if ($value === '') { + $value = trim((string)(self::DEFINITIONS[$code]['default'] ?? '')); + } + if ($value !== '') { + $parts[] = $value; + } + } + + return implode("\n\n", $parts); + } + + public static function editableCodes(): array + { + return array_keys(self::DEFINITIONS); + } + + public static function noteCodeForRule(string $code): string + { + return str_ends_with($code, '_NOTE') ? $code : ($code . '_NOTE'); + } + + public static function isRuleNoteCode(string $code): bool + { + return str_ends_with($code, '_NOTE'); + } + + public static function displayRuleCodes(): array + { + return array_values(array_filter(array_keys(self::DEFINITIONS), static function (string $code): bool { + return !self::isRuleNoteCode($code); + })); + } + + public static function defaultValue(string $code): string + { + return (string)(self::DEFINITIONS[$code]['default'] ?? ''); + } + + public static function resetToDefault(?string $code = null): array + { + self::ensureSystemConfigDefaults(); + + $codes = $code !== null && $code !== '' + ? [$code] + : self::editableCodes(); + + $changed = []; + foreach ($codes as $itemCode) { + if (!array_key_exists($itemCode, self::DEFINITIONS)) { + continue; + } + + $model = SystemConfigModel::where('sc_code', $itemCode)->find(); + if ($model === null) { + continue; + } + + $model->sc_val = self::defaultValue($itemCode); + $model->sc_description = (string)(self::DEFINITIONS[$itemCode]['description'] ?? $itemCode); + $model->save(); + $changed[] = $itemCode; + } + + if (!empty($changed)) { + SystemConfigModel::flushCache(); + } + + return [ + 'codes' => array_values($changed), + 'count' => count($changed), + ]; + } +} diff --git a/code/app/common/helper/SeoCopyBatchPrepareHelper.php b/code/app/common/helper/SeoCopyBatchPrepareHelper.php index 65b0ee3..652c5aa 100644 --- a/code/app/common/helper/SeoCopyBatchPrepareHelper.php +++ b/code/app/common/helper/SeoCopyBatchPrepareHelper.php @@ -101,6 +101,9 @@ class SeoCopyBatchPrepareHelper '- 文风要自然、克制、站内导航型,避免“全网最全”“免费观看”等夸张承诺。', '- 输出必须能直接写入目标 JSON 文件。', '', + '## Highest Rules', + SeoCopyAiProviderHelper::buildSharedAiRuleBlock(), + '', '## Scene Rules', (string)$strRulesJson, '', diff --git a/code/app/common/helper/SeoCopyContextHelper.php b/code/app/common/helper/SeoCopyContextHelper.php index 7d89b60..91be16d 100644 --- a/code/app/common/helper/SeoCopyContextHelper.php +++ b/code/app/common/helper/SeoCopyContextHelper.php @@ -4,6 +4,52 @@ namespace app\common\helper; class SeoCopyContextHelper { + public static function normalizeKeywordCandidates(array $arrItems, array $arrExclude = [], int $intLimit = 10): array + { + $arrExclude = array_values(array_filter(array_map(static function ($item): string { + return trim((string)$item); + }, $arrExclude), static function (string $item): bool { + return $item !== ''; + })); + + $arrOut = []; + foreach ($arrItems as $item) { + $strItem = trim((string)$item); + if ($strItem === '') { + continue; + } + + $strItem = preg_replace('/\s+/u', '', $strItem) ?: $strItem; + $strItem = trim($strItem, ",,。;;::|/-_"); + if ($strItem === '') { + continue; + } + + if (mb_strlen($strItem) < 2 || mb_strlen($strItem) > 14) { + continue; + } + + if (in_array($strItem, $arrExclude, true) || in_array($strItem, $arrOut, true)) { + continue; + } + + if (preg_match('/[“”"\'\(\)\[\]{}]/u', $strItem) === 1) { + continue; + } + + if (preg_match('/(首页|分类页|详情页|搜索页|播放页|首屏|收录|起词|带动|围绕|承接|路径|导购|整理型|流量型|扩展型|继续|建议|适合|当前|优先)/u', $strItem) === 1) { + continue; + } + + $arrOut[] = $strItem; + if (count($arrOut) >= $intLimit) { + break; + } + } + + return $arrOut; + } + public static function buildResourceContext(string $strHost): array { $strHost = \app\model\DomainModel::normalizeHost($strHost); @@ -24,11 +70,7 @@ class SeoCopyContextHelper $arrPositioning = SeoResourcePoolHelper::readJsonFile($strRoot . '/site_positioning/by_host/' . $strHostKey . '.json'); $arrFeedback = SeoResourcePoolHelper::readJsonFile($strRoot . '/keyword_feedback/by_host/' . $strHostKey . '.json'); - $arrPriorityKeywords = array_values(array_filter(array_map(static function ($value): string { - return trim((string)$value); - }, (array)($arrPositioning['priority_keywords'] ?? [])), static function (string $value): bool { - return $value !== ''; - })); + $arrPriorityKeywords = self::normalizeKeywordCandidates((array)($arrPositioning['priority_keywords'] ?? [])); $arrTopics = array_values(array_filter(array_map(static function ($value): string { return trim((string)$value); }, array_merge((array)($arrPositioning['primary_topics'] ?? []), (array)($arrPositioning['secondary_topics'] ?? []))), static function (string $value): bool { @@ -108,11 +150,12 @@ class SeoCopyContextHelper $strRawDescription, ] ), 8, $arrExclude); + $arrThemePhrases = self::filterHomeMetaPhrases($arrPhrases); - $strPrimary = $arrPhrases[0] ?? '影视内容推荐'; - $strSecondary = $arrPhrases[1] ?? '高清内容整理'; - $strTertiary = $arrPhrases[2] ?? '热门片单更新'; - $strQuaternary = $arrPhrases[3] ?? '剧情与演员资料'; + $strPrimary = $arrThemePhrases[0] ?? $arrPhrases[0] ?? '影视内容推荐'; + $strSecondary = $arrThemePhrases[1] ?? $arrPhrases[1] ?? '高清内容整理'; + $strTertiary = $arrThemePhrases[2] ?? $arrPhrases[2] ?? '热门片单更新'; + $strQuaternary = $arrThemePhrases[3] ?? $arrPhrases[3] ?? '剧情与演员资料'; $strSiteSuffix = $strSiteName !== '' ? ('-' . $strSiteName) : ''; if ($strTkdMode === \app\model\DomainModel::TKD_MODE_FORCE_IMPORT) { @@ -135,6 +178,7 @@ class SeoCopyContextHelper if ( $strTkdMode === \app\model\DomainModel::TKD_MODE_AI_OPTIMIZE && $strRawTitle !== '' + && !self::isBoilerplateHomeTitle($strRawTitle) && !str_contains($strRawTitle, '{') && !str_contains($strRawTitle, '}') && mb_strlen($strRawTitle) <= 42 @@ -143,6 +187,7 @@ class SeoCopyContextHelper $strTitle = $strRawTitle; } elseif ( $strRawTitle !== '' + && !self::isBoilerplateHomeTitle($strRawTitle) && !str_contains($strRawTitle, '{') && !str_contains($strRawTitle, '}') && mb_strlen($strRawTitle) <= 42 @@ -153,24 +198,34 @@ class SeoCopyContextHelper $arrTitleParts = array_slice(array_values(array_filter([ $strPrimary, $strSecondary, - $strTertiary, - ])), 0, 3); - $strTitle = implode('、', $arrTitleParts) . $strSiteSuffix; + ])), 0, 2); + if ($strSiteName !== '') { + $strTitle = $strSiteName; + if (!empty($arrTitleParts)) { + $strTitle .= '-' . implode('·', $arrTitleParts); + } else { + $strTitle .= '-热门内容推荐'; + } + } else { + $strTitle = implode('·', $arrTitleParts) . $strSiteSuffix; + } } - $arrKeywords = []; - foreach (array_merge([$strSiteName], (array)($arrResourceContext['priority_keywords'] ?? []), (array)($arrResourceContext['feedback_keywords'] ?? []), array_slice($arrPhrases, 0, 6)) as $strKeyword) { - $strKeyword = trim((string)$strKeyword); - if ($strKeyword === '' || in_array($strKeyword, $arrKeywords, true)) { - continue; - } - $arrKeywords[] = $strKeyword; - } + $arrKeywords = self::buildHomeKeywordBundle( + $strSiteName, + $strHost, + $arrResourceContext, + $arrThemePhrases + ); + if (empty($arrKeywords)) { $arrKeywords = array_values(array_filter([$strSiteName, '影视内容推荐', '剧情介绍', '演员资料'])); } - $strPhraseSummary = implode('、', array_slice($arrPhrases, 0, 4)); + $arrDescriptionKeywords = array_values(array_filter(array_slice($arrKeywords, 1, 4), static function ($item): bool { + return trim((string)$item) !== ''; + })); + $strPhraseSummary = implode('、', $arrDescriptionKeywords); if ($strPhraseSummary === '') { $strPhraseSummary = '影视内容推荐、剧情介绍、演员资料、播放线索'; } @@ -195,13 +250,13 @@ class SeoCopyContextHelper $strDescription = $strSiteName !== '' ? sprintf( - '%s围绕%s等主题整理首页内容,持续补充热门片单、剧情介绍、演员资料与播放线索。%s', + '%s首页重点承接%s等内容入口,适合先看首屏推荐、题材分区和站内搜索,再决定继续进入详情页或播放页。%s', $strSiteName, $strPhraseSummary, $strDescriptionTail ) : sprintf( - '当前首页围绕%s等主题整理内容,持续补充热门片单、剧情介绍、演员资料与播放线索。%s', + '当前首页重点承接%s等内容入口,适合先看首屏推荐、题材分区和站内搜索,再决定继续进入详情页或播放页。%s', $strPhraseSummary, $strDescriptionTail ); @@ -220,6 +275,68 @@ class SeoCopyContextHelper ]; } + protected static function buildHomeKeywordBundle( + string $strSiteName, + string $strHost, + array $arrResourceContext, + array $arrThemePhrases + ): array { + $arrBasePool = array_merge( + [$strSiteName], + (array)($arrResourceContext['priority_keywords'] ?? []), + (array)($arrResourceContext['topics'] ?? []), + array_slice($arrThemePhrases, 0, 6), + ['电视剧', '电影', '短剧', '剧情介绍'] + ); + + $arrKeywords = self::normalizeKeywordCandidates( + $arrBasePool, + [$strHost, preg_replace('/^www\./i', '', $strHost)], + 8 + ); + + $arrKeywords = array_values(array_filter($arrKeywords, static function (string $item): bool { + if ($item === '') { + return false; + } + + if (preg_match('/(高清影视内容精选|每日更新|高清影视|内容推荐|内容整理|热门片单更新|播放线索|演员资料)/u', $item) === 1) { + return false; + } + + return true; + })); + + return array_values(array_slice(array_unique($arrKeywords), 0, 6)); + } + + protected static function isBoilerplateHomeTitle(string $strTitle): bool + { + $strTitle = trim($strTitle); + if ($strTitle === '') { + return false; + } + + return preg_match('/(高清影视内容精选|每日更新|影视内容推荐)/u', $strTitle) === 1; + } + + protected static function filterHomeMetaPhrases(array $arrPhrases): array + { + return array_values(array_filter(array_map(static function ($item): string { + return trim((string)$item); + }, $arrPhrases), static function (string $item): bool { + if ($item === '') { + return false; + } + + if (preg_match('/(高清影视内容精选|每日更新|高清影视|影视内容推荐|内容整理与推荐|热门片单更新|剧情与演员资料)/u', $item) === 1) { + return false; + } + + return true; + })); + } + public static function extractPhrases(array $arrTexts, int $intLimit = 6, array $arrExclude = []): array { $arrStopWords = [ @@ -256,6 +373,9 @@ class SeoCopyContextHelper if (preg_match('/(是一个|为您提供|欢迎|尽在|在线观看|免费看|免费高清|好网站|视觉体验|拥有全网|影视剧资源|各种|平台|专区|网站)/u', $strPart) === 1) { continue; } + if (preg_match('/(收录|起词|带动|导购|整理型|流量型|扩展型|首屏|详情页|分类页|搜索页|播放页|继续浏览|浏览路线|站外反馈|活跃词)/u', $strPart) === 1) { + continue; + } if (preg_match('/(搜索结果|分类频道|内容列表|搜索落地页)$/u', $strPart) === 1) { continue; } diff --git a/code/app/common/helper/SeoCopyGenerationHelper.php b/code/app/common/helper/SeoCopyGenerationHelper.php index ed8d6f3..ce11d5f 100644 --- a/code/app/common/helper/SeoCopyGenerationHelper.php +++ b/code/app/common/helper/SeoCopyGenerationHelper.php @@ -87,11 +87,14 @@ class SeoCopyGenerationHelper json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) ); + $seededPublishedCopy = self::ensurePublishedStarterCopy($DomainModel, $overrides); + return [ 'state' => $state, 'manifest' => $manifest, 'state_path' => self::resolveStatePath($host), 'manifest_path' => self::resolveManifestPath($host), + 'seeded_published_copy' => $seededPublishedCopy, ]; } @@ -114,13 +117,17 @@ class SeoCopyGenerationHelper $hasLocalCopy = self::hasLocalCopyPayload($host); $tkdMode = DomainModel::normalizeTkdMode((string)($tkdCfg['mode'] ?? DomainModel::TKD_MODE_AUTO_GENERATE)); $tkdProvider = DomainModel::normalizeTkdProvider((string)($tkdCfg['provider'] ?? DomainModel::TKD_PROVIDER_LOCAL)); + $boolOpenAiCopyPreferred = $tkdProvider === DomainModel::TKD_PROVIDER_OPENAI + || $tkdMode === DomainModel::TKD_MODE_AI_OPTIMIZE; $waitForAi = !empty($copyCfg['wait_for_ai_before_publish']) || ($tkdProvider === DomainModel::TKD_PROVIDER_OPENAI && $tkdMode !== DomainModel::TKD_MODE_FORCE_IMPORT); $status = self::STATUS_LOCAL_READY; if ($waitForAi) { $status = $hasLocalCopy ? self::STATUS_LOCAL_READY : self::STATUS_AI_PENDING; } - if (!empty($rawCopyCfg['status']) && in_array((string)$rawCopyCfg['status'], [self::STATUS_DRAFT, self::STATUS_LOCAL_READY, self::STATUS_AI_PENDING, self::STATUS_AI_READY], true)) { + $strRawStatus = (string)($rawCopyCfg['status'] ?? ''); + $boolLegacyDraftForOpenAi = $boolOpenAiCopyPreferred && $strRawStatus === self::STATUS_DRAFT; + if (!$boolLegacyDraftForOpenAi && $strRawStatus !== '' && in_array($strRawStatus, [self::STATUS_DRAFT, self::STATUS_LOCAL_READY, self::STATUS_AI_PENDING, self::STATUS_AI_READY], true)) { $status = (string)$rawCopyCfg['status']; } if (!empty($overrides['status'])) { @@ -193,12 +200,83 @@ class SeoCopyGenerationHelper return is_dir($root); } + protected static function hasPublishedCopyPayload(string $host): bool + { + $host = self::normalizeHost($host); + if ($host === '') { + return false; + } + + $root = SeoCopyStore::publishedRoot() . '/' . self::buildHostKey($host); + if (!is_dir($root)) { + return false; + } + + $arrFiles = glob($root . '/*/*.json') ?: []; + return !empty($arrFiles); + } + + protected static function ensurePublishedStarterCopy(DomainModel $DomainModel, array $overrides = []): array + { + $host = self::normalizeHost((string)($DomainModel->d_domain ?? '')); + if ($host === '') { + return [ + 'attempted' => false, + 'reason' => 'empty_host', + ]; + } + + if (array_key_exists('seed_published_copy', $overrides) && empty($overrides['seed_published_copy'])) { + return [ + 'attempted' => false, + 'reason' => 'disabled_by_override', + 'host' => $host, + ]; + } + + if (self::hasPublishedCopyPayload($host)) { + return [ + 'attempted' => false, + 'reason' => 'already_exists', + 'host' => $host, + ]; + } + + try { + $arrResult = SeoCopyAiProviderHelper::generatePublishedCopy($DomainModel, [ + // 首次初始化始终只落一本地增强版 starter,避免误调远程 AI。 + 'provider' => DomainModel::TKD_PROVIDER_LOCAL, + ]); + + return [ + 'attempted' => true, + 'reason' => 'seeded_local_starter', + 'host' => $host, + 'provider_effective' => (string)($arrResult['provider_effective'] ?? DomainModel::TKD_PROVIDER_LOCAL), + 'written_pages' => (array)($arrResult['written_pages'] ?? []), + 'message' => (string)($arrResult['message'] ?? ''), + ]; + } catch (\Throwable $throwable) { + self::appendHistory($host, [ + 'type' => 'published_copy_seed_failed', + 'message' => $throwable->getMessage(), + ]); + + return [ + 'attempted' => true, + 'reason' => 'seed_failed', + 'host' => $host, + 'error' => $throwable->getMessage(), + ]; + } + } + protected static function buildStateMessage(string $status, bool $waitForAi, bool $hasLocalCopy): string { return match ($status) { self::STATUS_AI_PENDING => $waitForAi - ? '当前站点已导入,等待 AI 生成首页/分类/搜索等核心文案后再进入发布阶段。' - : '当前站点已进入 AI 生成队列。', + ? '当前站点已进入 AI 待处理状态。默认由 Codex/人工优先接管优化;只有后台显式点击 AI 优化时,才会调用远程 AI 生成。' + : '当前站点已进入 AI 待处理状态,可由 Codex/人工继续优化,或在后台手动触发 AI 任务。', self::STATUS_AI_READY => 'AI 文案已就绪,可继续发布或切换到 AI 版本。', self::STATUS_LOCAL_READY => $hasLocalCopy ? '本地规则文案与本地文案稿均可用,可直接上线或继续等待 AI 增强。' diff --git a/code/app/common/helper/VideoMetadataCopyFillHelper.php b/code/app/common/helper/VideoMetadataCopyFillHelper.php new file mode 100644 index 0000000..f572aad --- /dev/null +++ b/code/app/common/helper/VideoMetadataCopyFillHelper.php @@ -0,0 +1,162 @@ + $strSource, + 'updated_at' => new \MongoDB\BSON\UTCDateTime(), + 'filled_fields' => $arrFilledFields, + ]; + + return $arrUpdate; + } + + public static function buildDescription(array $arrVideo): string + { + $strVideoName = trim((string)($arrVideo['v_name'] ?? '')); + if ($strVideoName === '') { + return ''; + } + + $strCategory = trim((string)($arrVideo['v_category'] ?? '')); + $strParentCategory = trim((string)($arrVideo['v_parent_category'] ?? '')); + $strCategoryLabel = $strCategory !== '' ? $strCategory : $strParentCategory; + $strYear = trim((string)($arrVideo['v_year'] ?? '')); + $strArea = self::joinList($arrVideo['v_area'] ?? []); + $strLang = self::joinList($arrVideo['v_lang'] ?? []); + $strDirector = self::joinList($arrVideo['v_director'] ?? []); + $strActor = self::joinList($arrVideo['v_actor'] ?? [], 4); + $strRemarks = trim((string)($arrVideo['v_remarks'] ?? '')); + $boolEnded = (int)($arrVideo['v_isend'] ?? 0) === 1; + + $arrSentences = []; + + $strHead = $strVideoName; + if ($strCategoryLabel !== '') { + $strHead .= '属于' . $strCategoryLabel; + } else { + $strHead .= '为影视内容资料页'; + } + if ($strYear !== '') { + $strHead .= ',记录年份为' . $strYear; + } + if ($strArea !== '') { + $strHead .= ',地区信息为' . $strArea; + } + if ($strLang !== '') { + $strHead .= ',语言信息为' . $strLang; + } + $arrSentences[] = $strHead . '。'; + + $arrPeople = []; + if ($strDirector !== '') { + $arrPeople[] = '导演信息为' . $strDirector; + } + if ($strActor !== '') { + $arrPeople[] = '演员阵容包括' . $strActor; + } + if (!empty($arrPeople)) { + $arrSentences[] = implode(',', $arrPeople) . '。'; + } + + $arrStatus = []; + if ($strRemarks !== '') { + $arrStatus[] = '当前更新备注为' . $strRemarks; + } + $arrStatus[] = $boolEnded ? '内容状态显示已完结' : '内容状态以当前更新进度为准'; + $arrSentences[] = implode(',', $arrStatus) . '。'; + + $strDescription = preg_replace('/\s+/u', '', implode('', $arrSentences)); + $strDescription = trim((string)$strDescription); + + if ($strDescription === '') { + return ''; + } + + if (mb_strlen($strDescription) > 180) { + $strDescription = mb_substr($strDescription, 0, 178) . '。'; + } + + return $strDescription; + } + + public static function buildRemarks(array $arrVideo): string + { + $strRemarks = trim((string)($arrVideo['v_remarks'] ?? '')); + if ($strRemarks !== '') { + return $strRemarks; + } + + $boolEnded = (int)($arrVideo['v_isend'] ?? 0) === 1; + $strPublishDate = trim((string)($arrVideo['v_publish_date'] ?? '')); + $strYear = trim((string)($arrVideo['v_year'] ?? '')); + + if ($boolEnded) { + return '已完结'; + } + + if ($strPublishDate !== '') { + return '更新至' . mb_substr($strPublishDate, 0, 10); + } + + if ($strYear !== '') { + return $strYear . '年内容'; + } + + return ''; + } + + protected static function joinList($mixedValue, int $intLimit = 3): string + { + $arrValues = is_array($mixedValue) ? $mixedValue : [$mixedValue]; + $arrValues = array_values(array_filter(array_map(static function ($mixedItem): string { + return trim((string)$mixedItem); + }, $arrValues), static function (string $strItem): bool { + if ($strItem === '') { + return false; + } + + $arrInvalid = ['未知', '内详', '暂无', '不详', '待补充', '未知导演', '未知演员']; + return !in_array($strItem, $arrInvalid, true); + })); + + if (empty($arrValues)) { + return ''; + } + + return implode('、', array_slice($arrValues, 0, $intLimit)); + } +} diff --git a/code/app/common/helper/VideoMetadataMissingRefreshHelper.php b/code/app/common/helper/VideoMetadataMissingRefreshHelper.php new file mode 100644 index 0000000..8f2734a --- /dev/null +++ b/code/app/common/helper/VideoMetadataMissingRefreshHelper.php @@ -0,0 +1,62 @@ + [ + 'sample' => $intSample, + 'queue_limit' => $intQueueLimit, + 'prompt_limit' => $intPromptLimit, + 'batch_size' => $intBatchSize, + 'batch_limit' => $intBatchLimit, + ], + 'workbench' => $arrWorkbenchSummary, + 'task_pool' => $arrTaskPoolSummary, + 'task_pool_status' => $arrTaskPoolStatusSummary, + ]; + } +} diff --git a/code/app/common/helper/VideoMetadataMissingTaskPoolHelper.php b/code/app/common/helper/VideoMetadataMissingTaskPoolHelper.php new file mode 100644 index 0000000..dd0a297 --- /dev/null +++ b/code/app/common/helper/VideoMetadataMissingTaskPoolHelper.php @@ -0,0 +1,450 @@ + (int)($arrA['missing_count'] ?? 0); + }); + + $arrBatches = self::buildBatches($arrItems, $arrFieldStats, $intBatchSize, $intBatchLimit); + + $arrSummary = [ + 'generated_at' => date('c'), + 'source_generated_at' => (string)($arrWorkbenchSummary['generated_at'] ?? ''), + 'batch_size' => $intBatchSize, + 'batch_limit' => $intBatchLimit, + 'total_videos' => (int)($arrAudit['total_videos'] ?? 0), + 'videos_with_any_missing_metadata' => (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0), + 'queue_size' => (int)($arrQueue['queue_size'] ?? 0), + 'top_missing_fields' => array_values(array_slice($arrFieldStats, 0, 5)), + 'batches' => $arrBatches, + 'todo_summary' => self::buildTodoSummary($arrBatches), + 'operator_hint' => '这是一套给技术 / Codex 使用的缺字段待处理池。当前先用产物型批次池,不直接入库,不自动补事实字段。', + ]; + + return VideoMetadataMissingTaskPoolStateHelper::applyStateMap($arrSummary, [], ''); + } + + public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array + { + $strOutputRoot = rtrim(str_replace('\\', '/', $strOutputRoot), '/'); + $arrSummary = VideoMetadataMissingTaskPoolStateHelper::applyStateMap( + $arrSummary, + VideoMetadataMissingTaskPoolStateHelper::readStateMap($strOutputRoot), + $strOutputRoot + ); + $arrSummary['run_id'] = self::buildRunId(); + $arrSummary['summary_json_path'] = $strOutputRoot . '/index.json'; + $arrSummary['summary_html_path'] = $strOutputRoot . '/index.html'; + $arrSummary = self::writeLatestBatchArtifacts($strOutputRoot, $arrSummary); + + self::writeFile( + $arrSummary['summary_json_path'], + json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + self::writeFile($arrSummary['summary_html_path'], self::renderHtml($arrSummary)); + self::writeRunArtifacts($strOutputRoot, $arrSummary); + + return $arrSummary; + } + + public static function readLatestSummary(string $strOutputRoot): array + { + $strPath = rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/index.json'; + if (!is_file($strPath)) { + return []; + } + + $arrSummary = json_decode((string)file_get_contents($strPath), true); + if (!is_array($arrSummary)) { + return []; + } + + return VideoMetadataMissingTaskPoolStateHelper::applyStateMap( + $arrSummary, + VideoMetadataMissingTaskPoolStateHelper::readStateMap($strOutputRoot), + $strOutputRoot + ); + } + + public static function findBatchById(array $arrSummary, string $strBatchId): array + { + $strBatchId = trim($strBatchId); + if ($strBatchId === '') { + return []; + } + + foreach ((array)($arrSummary['batches'] ?? []) as $arrBatch) { + if (!is_array($arrBatch)) { + continue; + } + + if ((string)($arrBatch['batch_id'] ?? '') === $strBatchId) { + return $arrBatch; + } + } + + return []; + } + + protected static function buildBatches(array $arrItems, array $arrFieldStats, int $intBatchSize, int $intBatchLimit): array + { + $arrFieldOrder = []; + foreach ($arrFieldStats as $arrField) { + $strField = trim((string)($arrField['field'] ?? '')); + if ($strField !== '') { + $arrFieldOrder[] = $strField; + } + } + if (empty($arrFieldOrder)) { + $arrFieldOrder = ['v_director', 'v_actor']; + } + + $arrBuckets = []; + foreach ($arrItems as $arrItem) { + if (!is_array($arrItem)) { + continue; + } + + $arrMissingFields = array_values(array_filter((array)($arrItem['missing_fields'] ?? []), static fn ($strField): bool => trim((string)$strField) !== '')); + $strPrimaryField = ''; + foreach ($arrFieldOrder as $strField) { + if (in_array($strField, $arrMissingFields, true)) { + $strPrimaryField = $strField; + break; + } + } + if ($strPrimaryField === '') { + $strPrimaryField = (string)($arrMissingFields[0] ?? 'unknown'); + } + $arrBuckets[$strPrimaryField][] = $arrItem; + } + + $arrBatches = []; + $intBatchNo = 1; + foreach ($arrFieldOrder as $strField) { + $arrBucket = (array)($arrBuckets[$strField] ?? []); + if (empty($arrBucket)) { + continue; + } + + $arrChunks = array_chunk($arrBucket, $intBatchSize); + foreach ($arrChunks as $intChunkIndex => $arrChunk) { + if (count($arrBatches) >= $intBatchLimit) { + break 2; + } + + $strBatchId = sprintf('video-metadata-batch-%02d', $intBatchNo); + $arrBatches[] = [ + 'batch_id' => $strBatchId, + 'batch_no' => $intBatchNo, + 'focus_field' => $strField, + 'batch_label' => self::buildBatchLabel($strField, $intChunkIndex + 1), + 'items_count' => count($arrChunk), + 'priority_level' => self::resolvePriorityLevel($strField), + 'items' => array_values($arrChunk), + 'codex_prompt' => self::buildBatchPrompt($strField, $strBatchId, $arrChunk), + ]; + $intBatchNo++; + } + } + + return $arrBatches; + } + + protected static function buildTodoSummary(array $arrBatches): array + { + $arrTodo = []; + foreach ($arrBatches as $arrBatch) { + $arrTodo[] = [ + 'batch_id' => (string)($arrBatch['batch_id'] ?? ''), + 'label' => (string)($arrBatch['batch_label'] ?? ''), + 'focus_field' => (string)($arrBatch['focus_field'] ?? ''), + 'items_count' => (int)($arrBatch['items_count'] ?? 0), + 'priority_level' => (string)($arrBatch['priority_level'] ?? ''), + ]; + } + + return $arrTodo; + } + + protected static function buildBatchLabel(string $strField, int $intChunkNo): string + { + $arrMap = [ + 'v_director' => '导演缺失批次', + 'v_actor' => '演员缺失批次', + 'v_lang' => '语言缺失批次', + 'v_lang_en' => '英文语言缺失批次', + 'v_area' => '地区缺失批次', + 'v_area_en' => '英文地区缺失批次', + 'v_year' => '年份缺失批次', + 'v_description' => '简介缺失批次', + 'v_remarks' => '备注缺失批次', + 'v_publish_date' => '上映时间缺失批次', + ]; + + return ($arrMap[$strField] ?? ($strField . ' 缺失批次')) . ' #' . $intChunkNo; + } + + protected static function resolvePriorityLevel(string $strField): string + { + return match ($strField) { + 'v_director', 'v_actor' => 'P1', + 'v_lang', 'v_lang_en', 'v_area', 'v_area_en' => 'P2', + default => 'P3', + }; + } + + protected static function buildBatchPrompt(string $strField, string $strBatchId, array $arrItems): string + { + $arrLines = []; + $arrLines[] = '# 视频缺字段批次接手'; + $arrLines[] = ''; + $arrLines[] = '- 批次ID:' . $strBatchId; + $arrLines[] = '- 重点字段:' . $strField; + $arrLines[] = '- 批次数量:' . count($arrItems); + $arrLines[] = ''; + $arrLines[] = '要求:'; + $arrLines[] = '1. 结构化字段不能编造,优先建议重采、交叉源核验、人工确认。'; + $arrLines[] = '2. 先按优先级给出处理顺序,再给出每条或每组的建议动作。'; + $arrLines[] = '3. 如果某些条目只适合补文案,不适合补事实字段,要明确标记。'; + $arrLines[] = ''; + $arrLines[] = '待处理视频:'; + + foreach ($arrItems as $arrItem) { + $arrLines[] = sprintf( + '- v_id=%d | %s | 分类=%s | 缺失=%s | 原因=%s', + (int)($arrItem['v_id'] ?? 0), + trim((string)($arrItem['v_name'] ?? '')), + trim((string)($arrItem['v_category'] ?? '')), + implode(',', (array)($arrItem['missing_fields'] ?? [])), + trim((string)($arrItem['priority_reason'] ?? '')) + ); + } + + $arrLines[] = ''; + $arrLines[] = '请输出:'; + $arrLines[] = '1. 本批次的处理优先顺序'; + $arrLines[] = '2. 每条适合走重采 / 人工核验 / 仅补文案 哪一种'; + $arrLines[] = '3. 如果要继续写脚本或命令,请直接给执行建议'; + $arrLines[] = ''; + + return implode(PHP_EOL, $arrLines) . PHP_EOL; + } + + protected static function renderHtml(array $arrSummary): string + { + $arrBatches = (array)($arrSummary['batches'] ?? []); + $strRows = ''; + foreach ($arrBatches as $arrBatch) { + $strPromptPath = htmlspecialchars((string)($arrBatch['prompt_markdown_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strDetailPath = htmlspecialchars((string)($arrBatch['detail_json_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strHistoryPath = htmlspecialchars((string)($arrBatch['history_json_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strRows .= '' + . '' . htmlspecialchars((string)($arrBatch['batch_id'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrBatch['batch_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrBatch['focus_field'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrBatch['priority_level'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrBatch['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrBatch['owner'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . (int)($arrBatch['history_count'] ?? 0) . '' + . '' . (int)($arrBatch['items_count'] ?? 0) . '' + . '' + . ' ' + . ($strDetailPath !== '' ? '详情JSON ' : '') + . ($strPromptPath !== '' ? '提示词MD ' : '') + . ($strHistoryPath !== '' ? '历史JSON' : '') + . '' + . ''; + } + + $strCards = ''; + foreach ($arrBatches as $arrBatch) { + $strPromptPath = htmlspecialchars((string)($arrBatch['prompt_markdown_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strDetailPath = htmlspecialchars((string)($arrBatch['detail_json_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strHistoryPath = htmlspecialchars((string)($arrBatch['history_json_path'] ?? ''), ENT_QUOTES, 'UTF-8'); + $strCards .= '
' + . '

' . htmlspecialchars((string)($arrBatch['batch_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '

' + . '

批次ID:' . htmlspecialchars((string)($arrBatch['batch_id'] ?? ''), ENT_QUOTES, 'UTF-8') . '

' + . '

重点字段:' . htmlspecialchars((string)($arrBatch['focus_field'] ?? ''), ENT_QUOTES, 'UTF-8') . '

' + . '

优先级:' . htmlspecialchars((string)($arrBatch['priority_level'] ?? ''), ENT_QUOTES, 'UTF-8') . '

' + . '

状态:' . htmlspecialchars((string)($arrBatch['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '

' + . '

负责人:' . htmlspecialchars((string)($arrBatch['owner'] ?? ''), ENT_QUOTES, 'UTF-8') . '

' + . '

历史次数:' . (int)($arrBatch['history_count'] ?? 0) . '

' + . '

数量:' . (int)($arrBatch['items_count'] ?? 0) . '

' + . '

' + . ' ' + . ($strDetailPath !== '' ? '详情JSON ' : '') + . ($strPromptPath !== '' ? '提示词MD ' : '') + . ($strHistoryPath !== '' ? '历史JSON' : '') + . '

' + . '
'; + } + + return ' + + + + + 视频缺字段待处理池 + + + +
+
+

视频缺字段待处理池

+

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

+

说明:当前先做产物型任务池,不自动补事实字段,不直接入库。

+
点击“复制提示词”后,可直接粘贴到 Codex 窗口。
+
+
+

批次卡片

+
' . $strCards . '
+
+
+ + + ' . $strRows . ' +
批次ID批次名称重点字段优先级状态负责人历史数量动作
+
+
+ + +'; + } + + protected static function writeRunArtifacts(string $strOutputRoot, array $arrSummary): void + { + $strRunId = trim((string)($arrSummary['run_id'] ?? '')); + if ($strRunId === '') { + return; + } + + $strDay = substr((string)($arrSummary['generated_at'] ?? date('c')), 0, 10); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $strDay)) { + $strDay = date('Y-m-d'); + } + + $strRunRoot = $strOutputRoot . '/runs/' . $strDay . '/' . $strRunId; + self::writeFile( + $strRunRoot . '/video-metadata-missing-task-pool.summary.json', + json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + self::writeFile( + $strRunRoot . '/video-metadata-missing-task-pool.summary.html', + self::renderHtml($arrSummary) + ); + + foreach ((array)($arrSummary['batches'] ?? []) as $arrBatch) { + $strBatchId = trim((string)($arrBatch['batch_id'] ?? '')); + if ($strBatchId === '') { + continue; + } + self::writeFile($strRunRoot . '/batches/' . $strBatchId . '.md', (string)($arrBatch['codex_prompt'] ?? '')); + self::writeFile( + $strRunRoot . '/batches/' . $strBatchId . '.json', + json_encode($arrBatch, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + } + } + + protected static function writeLatestBatchArtifacts(string $strOutputRoot, array $arrSummary): array + { + foreach ((array)($arrSummary['batches'] ?? []) as $intIndex => $arrBatch) { + if (!is_array($arrBatch)) { + continue; + } + + $strBatchId = trim((string)($arrBatch['batch_id'] ?? '')); + if ($strBatchId === '') { + continue; + } + + $strDetailPath = $strOutputRoot . '/batches/' . $strBatchId . '.json'; + $strPromptPath = $strOutputRoot . '/batches/' . $strBatchId . '.md'; + $strHistoryPath = $strOutputRoot . '/state/history/' . $strBatchId . '.json'; + + self::writeFile( + $strDetailPath, + json_encode($arrBatch, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + self::writeFile($strPromptPath, (string)($arrBatch['codex_prompt'] ?? '')); + + $arrSummary['batches'][$intIndex]['detail_json_path'] = self::publicRelativePath($strDetailPath); + $arrSummary['batches'][$intIndex]['prompt_markdown_path'] = self::publicRelativePath($strPromptPath); + $arrSummary['batches'][$intIndex]['history_json_path'] = is_file($strHistoryPath) + ? self::publicRelativePath($strHistoryPath) + : self::publicRelativePath($strHistoryPath); + } + + return $arrSummary; + } + + protected static function writeFile(string $strPath, string $strContent): void + { + $strDir = dirname($strPath); + if (!is_dir($strDir)) { + @mkdir($strDir, 0777, true); + } + file_put_contents($strPath, $strContent); + } + + protected static function buildRunId(): string + { + return 'video-metadata-missing-task-pool-' . date('Ymd-His'); + } + + protected static function publicRelativePath(string $strPath): string + { + $strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 2) . '/public', '/')); + $strPath = str_replace('\\', '/', $strPath); + if (str_starts_with($strPath, $strPublicRoot . '/')) { + return substr($strPath, strlen($strPublicRoot . '/')); + } + + return $strPath; + } +} diff --git a/code/app/common/helper/VideoMetadataMissingTaskPoolStateHelper.php b/code/app/common/helper/VideoMetadataMissingTaskPoolStateHelper.php new file mode 100644 index 0000000..6d0e6fe --- /dev/null +++ b/code/app/common/helper/VideoMetadataMissingTaskPoolStateHelper.php @@ -0,0 +1,216 @@ + $strBatchId, + 'status' => $strStatus, + 'owner' => trim($strOwner), + 'note' => trim($strNote), + 'updated_at' => self::nowIso8601WithMicroseconds(), + ]; + + self::writeStateMap($strOutputRoot, $arrMap); + self::appendBatchHistory($strOutputRoot, $strBatchId, $arrMap[$strBatchId]); + return $arrMap[$strBatchId]; + } + + public static function readBatchHistory(string $strOutputRoot, string $strBatchId, int $intLimit = 50): array + { + $strBatchId = trim($strBatchId); + if ($strBatchId === '') { + return []; + } + + $strPath = self::historyFilePath($strOutputRoot, $strBatchId); + if (!is_file($strPath)) { + return []; + } + + $arrData = json_decode((string)file_get_contents($strPath), true); + if (!is_array($arrData)) { + return []; + } + + $arrItems = array_values((array)($arrData['items'] ?? [])); + usort($arrItems, static function (array $arrA, array $arrB): int { + return strcmp((string)($arrB['updated_at'] ?? ''), (string)($arrA['updated_at'] ?? '')); + }); + + return array_slice($arrItems, 0, max(1, min($intLimit, 200))); + } + + public static function applyStateMap(array $arrSummary, array $arrStateMap, string $strOutputRoot = ''): array + { + $arrBatches = (array)($arrSummary['batches'] ?? []); + $arrStatusBuckets = []; + $strOutputRoot = rtrim(str_replace('\\', '/', $strOutputRoot), '/'); + + foreach ($arrBatches as &$arrBatch) { + $strBatchId = trim((string)($arrBatch['batch_id'] ?? '')); + $arrState = (array)($arrStateMap[$strBatchId] ?? []); + $strStatus = self::normalizeStatus((string)($arrState['status'] ?? self::STATUS_PENDING)); + $arrBatch['status'] = $strStatus; + $arrBatch['status_label'] = self::statusLabel($strStatus); + $arrBatch['owner'] = trim((string)($arrState['owner'] ?? '')); + $arrBatch['note'] = trim((string)($arrState['note'] ?? '')); + $arrBatch['state_updated_at'] = trim((string)($arrState['updated_at'] ?? '')); + $arrBatch['history_count'] = $strOutputRoot !== '' + ? count(self::readBatchHistory($strOutputRoot, $strBatchId, 200)) + : 0; + $arrStatusBuckets[$strStatus] = (int)($arrStatusBuckets[$strStatus] ?? 0) + 1; + } + unset($arrBatch); + + $arrSummary['batches'] = $arrBatches; + $arrSummary['status_buckets'] = $arrStatusBuckets; + + $arrTodoSummary = []; + foreach ($arrBatches as $arrBatch) { + $arrTodoSummary[] = [ + 'batch_id' => (string)($arrBatch['batch_id'] ?? ''), + 'label' => (string)($arrBatch['batch_label'] ?? ''), + 'focus_field' => (string)($arrBatch['focus_field'] ?? ''), + 'items_count' => (int)($arrBatch['items_count'] ?? 0), + 'priority_level' => (string)($arrBatch['priority_level'] ?? ''), + 'status' => (string)($arrBatch['status'] ?? self::STATUS_PENDING), + 'status_label' => (string)($arrBatch['status_label'] ?? self::statusLabel(self::STATUS_PENDING)), + 'owner' => (string)($arrBatch['owner'] ?? ''), + ]; + } + $arrSummary['todo_summary'] = $arrTodoSummary; + + return $arrSummary; + } + + public static function statusLabel(string $strStatus): string + { + return match (self::normalizeStatus($strStatus)) { + self::STATUS_PENDING => '待处理', + self::STATUS_DISPATCHED => '已派单', + self::STATUS_PROCESSING => '处理中', + self::STATUS_DONE => '已完成', + self::STATUS_SKIPPED => '已跳过', + default => '待处理', + }; + } + + protected static function normalizeStatus(string $strStatus): string + { + $strStatus = strtolower(trim($strStatus)); + $arrAllowed = [ + self::STATUS_PENDING, + self::STATUS_DISPATCHED, + self::STATUS_PROCESSING, + self::STATUS_DONE, + self::STATUS_SKIPPED, + ]; + + return in_array($strStatus, $arrAllowed, true) ? $strStatus : self::STATUS_PENDING; + } + + protected static function stateFilePath(string $strOutputRoot): string + { + return rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/state/batch-status.json'; + } + + protected static function writeStateMap(string $strOutputRoot, array $arrMap): void + { + $strPath = self::stateFilePath($strOutputRoot); + $strDir = dirname($strPath); + if (!is_dir($strDir)) { + @mkdir($strDir, 0777, true); + } + + file_put_contents($strPath, json_encode([ + 'updated_at' => self::nowIso8601WithMicroseconds(), + 'batches' => $arrMap, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + protected static function appendBatchHistory(string $strOutputRoot, string $strBatchId, array $arrState): void + { + $strPath = self::historyFilePath($strOutputRoot, $strBatchId); + $strDir = dirname($strPath); + if (!is_dir($strDir)) { + @mkdir($strDir, 0777, true); + } + + $arrData = []; + if (is_file($strPath)) { + $arrDecoded = json_decode((string)file_get_contents($strPath), true); + if (is_array($arrDecoded)) { + $arrData = $arrDecoded; + } + } + + $arrItems = array_values((array)($arrData['items'] ?? [])); + $arrItems[] = [ + 'batch_id' => $strBatchId, + 'status' => (string)($arrState['status'] ?? self::STATUS_PENDING), + 'status_label' => self::statusLabel((string)($arrState['status'] ?? self::STATUS_PENDING)), + 'owner' => (string)($arrState['owner'] ?? ''), + 'note' => (string)($arrState['note'] ?? ''), + 'updated_at' => (string)($arrState['updated_at'] ?? self::nowIso8601WithMicroseconds()), + ]; + + file_put_contents($strPath, json_encode([ + 'updated_at' => self::nowIso8601WithMicroseconds(), + 'batch_id' => $strBatchId, + 'items' => $arrItems, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + protected static function historyFilePath(string $strOutputRoot, string $strBatchId): string + { + return rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/state/history/' . $strBatchId . '.json'; + } + + protected static function nowIso8601WithMicroseconds(): string + { + $floatNow = microtime(true); + $intNow = (int)$floatNow; + $intMicro = (int)(($floatNow - $intNow) * 1000000); + + return sprintf('%s.%06d%s', date('Y-m-d\\TH:i:s', $intNow), $intMicro, date('P', $intNow)); + } +} diff --git a/code/app/common/helper/VideoMetadataMissingTaskPoolStatusHelper.php b/code/app/common/helper/VideoMetadataMissingTaskPoolStatusHelper.php new file mode 100644 index 0000000..152769a --- /dev/null +++ b/code/app/common/helper/VideoMetadataMissingTaskPoolStatusHelper.php @@ -0,0 +1,398 @@ + date(DATE_ATOM), + 'workbench' => [ + 'exists' => !empty($arrWorkbench), + 'generated_at' => (string)($arrWorkbench['generated_at'] ?? ''), + 'videos_with_any_missing_metadata' => (int)(($arrWorkbench['audit'] ?? [])['videos_with_any_missing_metadata'] ?? 0), + 'queue_size' => (int)(($arrWorkbench['queue'] ?? [])['queue_size'] ?? 0), + 'summary_html_path' => (string)($arrWorkbench['summary_html_path'] ?? ''), + 'summary_json_path' => (string)($arrWorkbench['summary_json_path'] ?? ''), + ], + 'task_pool' => [ + 'exists' => !empty($arrTaskPool), + 'generated_at' => (string)($arrTaskPool['generated_at'] ?? ''), + 'batch_count' => count((array)($arrTaskPool['batches'] ?? [])), + 'todo_summary_count' => count((array)($arrTaskPool['todo_summary'] ?? [])), + 'summary_html_path' => (string)($arrTaskPool['summary_html_path'] ?? ''), + 'summary_json_path' => (string)($arrTaskPool['summary_json_path'] ?? ''), + 'batch_status_summary' => $arrBatchSummary, + ], + 'plan_task' => $arrPlanTask, + 'operator_hint' => self::buildOperatorHint($arrWorkbench, $arrTaskPool, $arrPlanTask), + ]; + } + + public static function writeArtifacts(string $strTaskPoolRoot, array $arrSummary): array + { + $strTaskPoolRoot = rtrim(str_replace('\\', '/', $strTaskPoolRoot), '/'); + $strStatusRoot = $strTaskPoolRoot . '/status'; + $strOpsRoot = $strTaskPoolRoot . '/ops'; + $arrSummary['summary_json_path'] = $strStatusRoot . '/index.json'; + $arrSummary['summary_html_path'] = $strStatusRoot . '/index.html'; + $arrSummary['ops_json_path'] = $strOpsRoot . '/index.json'; + $arrSummary['ops_html_path'] = $strOpsRoot . '/index.html'; + + self::writeFile( + $arrSummary['summary_json_path'], + json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + self::writeFile($arrSummary['summary_html_path'], self::renderHtml($arrSummary)); + self::writeFile( + $arrSummary['ops_json_path'], + json_encode(self::buildOpsSummary($arrSummary), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + self::writeFile($arrSummary['ops_html_path'], self::renderOpsHtml($arrSummary)); + + return $arrSummary; + } + + protected static function buildBatchSummary(array $arrBatches): array + { + $arrCounts = [ + 'pending' => 0, + 'dispatched' => 0, + 'processing' => 0, + 'done' => 0, + 'skipped' => 0, + ]; + + foreach ($arrBatches as $arrBatch) { + $strStatus = (string)($arrBatch['status'] ?? VideoMetadataMissingTaskPoolStateHelper::STATUS_PENDING); + if (!array_key_exists($strStatus, $arrCounts)) { + $arrCounts[$strStatus] = 0; + } + $arrCounts[$strStatus]++; + } + + return $arrCounts; + } + + protected static function buildPlanTaskStatus(): array + { + $strTaskCode = 'REFRESH_VIDEO_METADATA_TASK_POOL'; + $strDbError = ''; + + try { + $PlanTaskModel = PlanTaskModel::where('pt_code', $strTaskCode)->find(); + } catch (\Throwable $Throwable) { + $PlanTaskModel = null; + $strDbError = $Throwable->getMessage(); + } + + return [ + 'task_code' => $strTaskCode, + 'db_ready' => $strDbError === '', + 'db_error' => $strDbError, + 'task_exists' => $PlanTaskModel instanceof PlanTaskModel, + 'pt_id' => (int)($PlanTaskModel->pt_id ?? 0), + 'pt_name' => (string)($PlanTaskModel->pt_name ?? '视频缺字段任务池刷新'), + 'pt_enable' => (int)($PlanTaskModel->pt_enable ?? 0), + 'pt_limit' => (int)($PlanTaskModel->pt_limit ?? 0), + 'pt_last_exec' => !empty($PlanTaskModel->pt_last_exec) ? date(DATE_ATOM, (int)$PlanTaskModel->pt_last_exec) : '', + 'status_label' => $strDbError !== '' + ? 'db_blocked' + : (($PlanTaskModel instanceof PlanTaskModel) + ? ((int)($PlanTaskModel->pt_enable ?? 0) === 1 ? 'enabled' : 'disabled') + : 'missing'), + ]; + } + + protected static function buildOperatorHint(array $arrWorkbench, array $arrTaskPool, array $arrPlanTask): array + { + $arrHint = []; + + if (empty($arrWorkbench)) { + $arrHint[] = '当前还没有工作台产物,建议先手动执行 video:metadata:task-pool 生成一轮。'; + } + + if (!empty($arrWorkbench) && empty($arrTaskPool)) { + $arrHint[] = '当前已有工作台,但还没有任务池产物,建议再执行一次 task-pool 刷新命令。'; + } + + if (($arrPlanTask['status_label'] ?? '') === 'missing') { + $arrHint[] = '如果要接入数据库计划任务,请新增 pt_code=REFRESH_VIDEO_METADATA_TASK_POOL。'; + } + + if (($arrPlanTask['status_label'] ?? '') === 'disabled') { + $arrHint[] = '当前数据库计划任务已存在但未启用,启用前建议先保持较大执行间隔。'; + } + + if (($arrPlanTask['status_label'] ?? '') === 'enabled') { + $arrHint[] = '当前数据库计划任务已启用;这条任务只刷新产物,不自动补事实字段。'; + } + + if (($arrPlanTask['status_label'] ?? '') === 'db_blocked') { + $arrHint[] = '当前脚本环境拿不到数据库任务表配置,因此这里只能返回任务池现状;如果在正式后台环境调用,一般会恢复数据库计划任务状态识别。'; + } + + if (empty($arrHint)) { + $arrHint[] = '当前工作台、任务池、计划任务状态都已具备,下一步可以做后台入口卡片或操作面板。'; + } + + return $arrHint; + } + + protected static function renderHtml(array $arrSummary): string + { + $arrWorkbench = (array)($arrSummary['workbench'] ?? []); + $arrTaskPool = (array)($arrSummary['task_pool'] ?? []); + $arrBatchStatus = (array)($arrTaskPool['batch_status_summary'] ?? []); + $arrPlanTask = (array)($arrSummary['plan_task'] ?? []); + $arrHints = (array)($arrSummary['operator_hint'] ?? []); + + $strHintRows = ''; + foreach ($arrHints as $strHint) { + $strHintRows .= '
  • ' . htmlspecialchars((string)$strHint, ENT_QUOTES, 'UTF-8') . '
  • '; + } + + $strBatchRows = ''; + foreach ($arrBatchStatus as $strStatus => $intCount) { + $strBatchRows .= '' + . '' . htmlspecialchars((string)$strStatus, ENT_QUOTES, 'UTF-8') . '' + . '' . (int)$intCount . '' + . ''; + } + + return ' + + + + + 视频缺字段任务池状态总览 + + + +
    +
    +

    视频缺字段任务池状态总览

    +

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

    +
    +
    缺字段视频数
    ' . (int)($arrWorkbench['videos_with_any_missing_metadata'] ?? 0) . '
    +
    重采优先队列
    ' . (int)($arrWorkbench['queue_size'] ?? 0) . '
    +
    任务池批次数
    ' . (int)($arrTaskPool['batch_count'] ?? 0) . '
    +
    计划任务状态
    ' . htmlspecialchars((string)($arrPlanTask['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    +
    +
    + +
    +

    工作台 / 任务池产物

    + + + + + + + + + + + + + + + + +
    模块生成时间HTMLJSON
    工作台' . htmlspecialchars((string)($arrWorkbench['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '' . htmlspecialchars((string)($arrWorkbench['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '' . htmlspecialchars((string)($arrWorkbench['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    任务池' . htmlspecialchars((string)($arrTaskPool['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '' . htmlspecialchars((string)($arrTaskPool['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '' . htmlspecialchars((string)($arrTaskPool['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    +
    + +
    +

    批次状态统计

    + + + ' . $strBatchRows . ' +
    状态数量
    +
    + +
    +

    计划任务状态

    + + + + + + + + + + +
    字段
    task_code' . htmlspecialchars((string)($arrPlanTask['task_code'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    status_label' . htmlspecialchars((string)($arrPlanTask['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    pt_enable' . (int)($arrPlanTask['pt_enable'] ?? 0) . '
    pt_limit' . (int)($arrPlanTask['pt_limit'] ?? 0) . '
    pt_last_exec' . htmlspecialchars((string)($arrPlanTask['pt_last_exec'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    db_error' . htmlspecialchars((string)($arrPlanTask['db_error'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    +
    + +
    +

    操作提示

    +
      ' . $strHintRows . '
    +
    +
    + +'; + } + + public static function buildOpsSummary(array $arrSummary): array + { + $arrPlanTask = (array)($arrSummary['plan_task'] ?? []); + $arrWorkbench = (array)($arrSummary['workbench'] ?? []); + $arrTaskPool = (array)($arrSummary['task_pool'] ?? []); + + return [ + 'generated_at' => (string)($arrSummary['generated_at'] ?? ''), + 'commands' => [ + [ + 'label' => '刷新工作台 + 任务池', + 'command' => 'php SEONexus/code/think video:metadata:task-pool --sample=8 --queue-limit=12 --prompt-limit=6 --batch-size=10 --batch-limit=4', + ], + [ + 'label' => '补齐计划任务种子', + 'command' => 'php SEONexus/code/think plan:seed:video-metadata-missing', + ], + [ + 'label' => '查看任务池状态接口', + 'command' => 'GET /admin/video/metadata/missing/task-pool/status', + ], + [ + 'label' => '查看任务池运维接口', + 'command' => 'GET /admin/video/metadata/missing/task-pool/ops', + ], + [ + 'label' => '保存任务池计划任务状态', + 'command' => 'POST /admin/video/metadata/missing/task-pool/plan-task/status/save', + ], + ], + 'plan_task' => $arrPlanTask, + 'workbench' => $arrWorkbench, + 'task_pool' => $arrTaskPool, + 'recommended_action' => (($arrPlanTask['status_label'] ?? '') === 'disabled') + ? '先在后台把 REFRESH_VIDEO_METADATA_TASK_POOL 打开,建议 pt_limit 保持 86400。' + : '当前计划任务已具备基础状态,继续保持刷新即可。', + ]; + } + + protected static function renderOpsHtml(array $arrSummary): string + { + $arrOps = self::buildOpsSummary($arrSummary); + $arrCommands = (array)($arrOps['commands'] ?? []); + $arrPlanTask = (array)($arrOps['plan_task'] ?? []); + $arrWorkbench = (array)($arrOps['workbench'] ?? []); + $arrTaskPool = (array)($arrOps['task_pool'] ?? []); + + $strCommandRows = ''; + foreach ($arrCommands as $arrCommand) { + $strCommandRows .= '' + . htmlspecialchars((string)($arrCommand['label'] ?? ''), ENT_QUOTES, 'UTF-8') + . '' + . htmlspecialchars((string)($arrCommand['command'] ?? ''), ENT_QUOTES, 'UTF-8') + . ''; + } + + return ' + + + + + 视频缺字段任务池运维页 + + + +
    +
    +

    视频缺字段任务池运维页

    +

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

    +
    +
    缺字段视频数
    ' . (int)($arrWorkbench['videos_with_any_missing_metadata'] ?? 0) . '
    +
    任务池批次数
    ' . (int)($arrTaskPool['batch_count'] ?? 0) . '
    +
    计划任务状态
    ' . htmlspecialchars((string)($arrPlanTask['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    +
    建议间隔
    ' . (int)($arrPlanTask['pt_limit'] ?? 0) . '
    +
    +
    + +
    +

    推荐动作

    +
    ' . htmlspecialchars((string)($arrOps['recommended_action'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    +
    + +
    +

    常用命令

    + + + ' . $strCommandRows . ' +
    用途命令
    +
    + +
    +

    当前产物

    + + + + + + + + + + + + + + +
    模块HTMLJSON
    工作台' . htmlspecialchars((string)($arrWorkbench['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '' . htmlspecialchars((string)($arrWorkbench['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    任务池' . htmlspecialchars((string)($arrTaskPool['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '' . htmlspecialchars((string)($arrTaskPool['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '
    +
    +
    + +'; + } + + protected static function writeFile(string $strPath, string $strContent): void + { + $strDir = dirname($strPath); + if (!is_dir($strDir)) { + mkdir($strDir, 0777, true); + } + + file_put_contents($strPath, $strContent); + } +} diff --git a/code/app/common/helper/VideoMetadataMissingWorkbenchHelper.php b/code/app/common/helper/VideoMetadataMissingWorkbenchHelper.php new file mode 100644 index 0000000..30e1302 --- /dev/null +++ b/code/app/common/helper/VideoMetadataMissingWorkbenchHelper.php @@ -0,0 +1,329 @@ +buildMissingMetadataAuditSummary($intSampleLimit); + $intQueueScanLimit = max(500, min(5000, $intQueueLimit * 20)); + $arrQueue = $VideoModel->buildRecrawlPriorityQueueSummary($intQueueLimit, $intQueueScanLimit); + + $arrFieldStats = (array)($arrAudit['field_stats'] ?? []); + usort($arrFieldStats, static function (array $arrA, array $arrB): int { + return (int)($arrB['missing_count'] ?? 0) <=> (int)($arrA['missing_count'] ?? 0); + }); + + $arrTopFields = array_values(array_slice($arrFieldStats, 0, 5)); + $arrPromptItems = array_values(array_slice((array)($arrQueue['items'] ?? []), 0, max(1, min($intPromptLimit, 100)))); + + $arrSummary = [ + 'generated_at' => date('c'), + 'sample_limit' => $intSampleLimit, + 'queue_limit' => $intQueueLimit, + 'queue_scan_limit' => $intQueueScanLimit, + 'prompt_limit' => $intPromptLimit, + 'audit' => $arrAudit, + 'queue' => $arrQueue, + 'top_missing_fields' => $arrTopFields, + 'next_actions' => self::buildNextActions($arrAudit, $arrQueue, $arrTopFields), + 'codex_dispatch_prompt' => self::buildCodexDispatchPrompt($arrPromptItems, $arrTopFields), + 'operator_notes' => self::buildOperatorNotes(), + ]; + + return $arrSummary; + } + + public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array + { + $strOutputRoot = rtrim(str_replace('\\', '/', $strOutputRoot), '/'); + $arrSummary['summary_json_path'] = $strOutputRoot . '/index.json'; + $arrSummary['summary_html_path'] = $strOutputRoot . '/index.html'; + $arrSummary['prompt_markdown_path'] = $strOutputRoot . '/prompts/latest.md'; + $arrSummary['run_id'] = self::buildRunId(); + + self::writeFile( + $strOutputRoot . '/index.json', + json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + self::writeFile($strOutputRoot . '/index.html', self::renderHtml($arrSummary)); + self::writeFile($strOutputRoot . '/prompts/latest.md', (string)($arrSummary['codex_dispatch_prompt'] ?? '')); + self::writeRunArtifacts($strOutputRoot, $arrSummary); + + return $arrSummary; + } + + public static function readLatestSummary(string $strOutputRoot): array + { + $strPath = rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/index.json'; + if (!is_file($strPath)) { + return []; + } + + $arrSummary = json_decode((string)file_get_contents($strPath), true); + return is_array($arrSummary) ? $arrSummary : []; + } + + protected static function buildNextActions(array $arrAudit, array $arrQueue, array $arrTopFields): array + { + $intAnyMissing = (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0); + $intQueueSize = (int)($arrQueue['queue_size'] ?? 0); + $strTopField = (string)(($arrTopFields[0] ?? [])['field'] ?? ''); + + $arrActions = []; + $arrActions[] = [ + 'key' => 'workbench_refresh', + 'label' => '先刷新缺失字段工作台', + 'summary' => '把当前视频库缺失字段、优先重采队列、Codex 派单提示词一起固化成后台可读产物。', + ]; + + if ($intQueueSize > 0) { + $arrActions[] = [ + 'key' => 'recrawl_priority', + 'label' => '优先处理演员 / 导演缺失高优先视频', + 'summary' => '当前重采优先队列 ' . $intQueueSize . ' 条,优先解决结构化字段空值,避免详情页长期资料残缺。', + ]; + } + + if ($intAnyMissing > 0) { + $arrActions[] = [ + 'key' => 'codex_dispatch', + 'label' => '把缺失池派给 Codex 做人工接手优化', + 'summary' => '当前仍有 ' . $intAnyMissing . ' 条视频存在缺字段,建议技术从后台复制提示词,分批派给 Codex 跟进。', + ]; + } + + if ($strTopField !== '') { + $arrActions[] = [ + 'key' => 'focus_field', + 'label' => '本轮重点字段:' . $strTopField, + 'summary' => '当前缺失量最大的字段先处理,收敛速度最快,也最容易改善详情页完整度。', + ]; + } + + return $arrActions; + } + + protected static function buildOperatorNotes(): array + { + return [ + [ + 'title' => '这套工作台是干什么的', + 'content' => '它负责把“视频库哪些字段是空的、哪些视频优先补、给 Codex 的派单提示词”集中整理出来,方便技术和运营协同处理。', + ], + [ + 'title' => '当前不会自动做什么', + 'content' => '不会因为检测到空字段,就自动调用 AI API 去猜演员、导演、年份、地区等结构化资料;这类字段仍然优先重采或人工确认。', + ], + [ + 'title' => '适合 Codex 接手的场景', + 'content' => '当后台已经把缺失视频列表和重点字段列出来后,技术可以复制提示词给 Codex,让 Codex按批次分析、整理、制定补料策略,避免运营直接面对复杂技术字段。', + ], + [ + 'title' => '适合后台 AI 的场景', + 'content' => '仅适合在事实基础足够时补简介、备注、摘要类文案字段,不适合直接猜演员、导演、上映时间、地区、语言等结构化事实字段。', + ], + ]; + } + + protected static function buildCodexDispatchPrompt(array $arrPromptItems, array $arrTopFields): string + { + $arrLines = []; + $arrLines[] = '# 视频缺失字段 Codex 接手提示词'; + $arrLines[] = ''; + $arrLines[] = '你现在接手的是“视频元数据缺失字段处理”任务。'; + $arrLines[] = '目标:优先提升详情页资料完整度,减少演员、导演、年份、地区、语言、发布时间、简介、备注为空的情况。'; + $arrLines[] = ''; + $arrLines[] = '执行原则:'; + $arrLines[] = '1. 结构化字段如演员、导演、年份、地区、语言、发布时间,不允许编造。'; + $arrLines[] = '2. 简介、备注等文案字段,只能在现有事实边界内补全,不允许虚构剧情和角色关系。'; + $arrLines[] = '3. 优先处理高优先级、点击高、播放源多、近期更新的视频。'; + $arrLines[] = '4. 输出时请先给出分批处理建议,再给出每批次的风险说明。'; + $arrLines[] = ''; + $arrLines[] = '当前缺失最严重字段:'; + foreach ($arrTopFields as $arrField) { + $arrLines[] = sprintf( + '- %s:缺失 %d 条,占比 %.2f%%', + (string)($arrField['field'] ?? ''), + (int)($arrField['missing_count'] ?? 0), + ((float)($arrField['missing_ratio'] ?? 0)) * 100 + ); + } + $arrLines[] = ''; + $arrLines[] = '本轮优先样本:'; + foreach ($arrPromptItems as $arrItem) { + $arrLines[] = sprintf( + '- v_id=%d | %s | 分类=%s | 缺失=%s | 原因=%s', + (int)($arrItem['v_id'] ?? 0), + trim((string)($arrItem['v_name'] ?? '')), + trim((string)($arrItem['v_category'] ?? '')), + implode(',', (array)($arrItem['missing_fields'] ?? [])), + trim((string)($arrItem['priority_reason'] ?? '')) + ); + } + $arrLines[] = ''; + $arrLines[] = '请输出:'; + $arrLines[] = '1. 本轮处理优先级和分批方案'; + $arrLines[] = '2. 哪些字段适合重采,哪些字段适合人工补充,哪些字段适合仅补文案'; + $arrLines[] = '3. 如果需要后续命令或脚本,请直接给出可执行建议'; + $arrLines[] = ''; + + return implode(PHP_EOL, $arrLines) . PHP_EOL; + } + + protected static function renderHtml(array $arrSummary): string + { + $arrAudit = (array)($arrSummary['audit'] ?? []); + $arrQueue = (array)($arrSummary['queue'] ?? []); + $arrTopFields = (array)($arrSummary['top_missing_fields'] ?? []); + $arrActions = (array)($arrSummary['next_actions'] ?? []); + $arrItems = array_values(array_slice((array)($arrQueue['items'] ?? []), 0, 20)); + + $strPrompt = htmlspecialchars((string)($arrSummary['codex_dispatch_prompt'] ?? ''), ENT_QUOTES, 'UTF-8'); + + $strTopRows = ''; + foreach ($arrTopFields as $arrField) { + $strTopRows .= '' + . '' . htmlspecialchars((string)($arrField['field'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . (int)($arrField['missing_count'] ?? 0) . '' + . '' . number_format(((float)($arrField['missing_ratio'] ?? 0)) * 100, 2) . '%' + . ''; + } + + $strActionRows = ''; + foreach ($arrActions as $arrAction) { + $strActionRows .= '
  • ' + . htmlspecialchars((string)($arrAction['label'] ?? ''), ENT_QUOTES, 'UTF-8') + . ':' + . htmlspecialchars((string)($arrAction['summary'] ?? ''), ENT_QUOTES, 'UTF-8') + . '
  • '; + } + + $strItemRows = ''; + foreach ($arrItems as $arrItem) { + $strItemRows .= '' + . '' . (int)($arrItem['v_id'] ?? 0) . '' + . '' . htmlspecialchars((string)($arrItem['v_name'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['v_category'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars(implode(', ', (array)($arrItem['missing_fields'] ?? [])), ENT_QUOTES, 'UTF-8') . '' + . '' . (int)($arrItem['priority_score'] ?? 0) . '' + . '' . htmlspecialchars((string)($arrItem['priority_reason'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . ''; + } + + return ' + + + + + 视频缺失字段工作台 + + + +
    +
    +

    视频缺失字段工作台

    +

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

    +
    +
    总视频数
    ' . (int)($arrAudit['total_videos'] ?? 0) . '
    +
    存在缺字段视频
    ' . (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0) . '
    +
    重采优先队列
    ' . (int)($arrQueue['queue_size'] ?? 0) . '
    +
    Codex 派单样本
    ' . (int)($arrSummary['prompt_limit'] ?? 0) . '
    +
    +
    + +
    +

    下一步动作

    +
      ' . $strActionRows . '
    +
    + +
    +

    缺失最多字段 Top

    + + + ' . $strTopRows . ' +
    字段缺失数缺失占比
    +
    + +
    +

    高优先级视频样本

    + + + ' . $strItemRows . ' +
    v_id片名分类缺失字段优先分优先原因
    +
    + +
    +

    Codex 派单提示词

    +
    ' . $strPrompt . '
    +
    +
    + +'; + } + + protected static function writeFile(string $strPath, string $strContent): void + { + $strDir = dirname($strPath); + if (!is_dir($strDir)) { + @mkdir($strDir, 0777, true); + } + file_put_contents($strPath, $strContent); + } + + protected static function writeRunArtifacts(string $strOutputRoot, array $arrSummary): void + { + $strRunId = trim((string)($arrSummary['run_id'] ?? '')); + if ($strRunId === '') { + return; + } + + $strDay = substr((string)($arrSummary['generated_at'] ?? date('c')), 0, 10); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $strDay)) { + $strDay = date('Y-m-d'); + } + + $strRunRoot = $strOutputRoot . '/runs/' . $strDay . '/' . $strRunId; + self::writeFile( + $strRunRoot . '/video-metadata-missing-workbench.summary.json', + json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL + ); + self::writeFile( + $strRunRoot . '/video-metadata-missing-workbench.summary.html', + self::renderHtml($arrSummary) + ); + self::writeFile( + $strRunRoot . '/prompt.md', + (string)($arrSummary['codex_dispatch_prompt'] ?? '') + ); + } + + protected static function buildRunId(): string + { + return 'video-metadata-missing-workbench-' . date('Ymd-His'); + } +} diff --git a/code/app/common/helper/VideoMetadataMissingWorkbenchIndexHelper.php b/code/app/common/helper/VideoMetadataMissingWorkbenchIndexHelper.php new file mode 100644 index 0000000..1c1c913 --- /dev/null +++ b/code/app/common/helper/VideoMetadataMissingWorkbenchIndexHelper.php @@ -0,0 +1,99 @@ + [], + 'total' => 0, + 'latest_run' => [], + 'health_buckets' => [], + ]; + } + + $arrFiles = array_merge( + (array)glob($strRunRoot . '/*/*/video-metadata-missing-workbench.summary.json') + ); + $arrFiles = array_values(array_filter(array_unique($arrFiles), 'is_file')); + usort($arrFiles, static function (string $strLeft, string $strRight): int { + return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0)); + }); + + $arrItems = []; + foreach (array_slice($arrFiles, 0, max(1, $intLimit)) as $strSummaryPath) { + $arrData = json_decode((string)file_get_contents($strSummaryPath), true); + if (!is_array($arrData)) { + continue; + } + + $arrAudit = (array)($arrData['audit'] ?? []); + $arrQueue = (array)($arrData['queue'] ?? []); + $arrTopFields = (array)($arrData['top_missing_fields'] ?? []); + $intAnyMissing = (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0); + $intTotal = (int)($arrAudit['total_videos'] ?? 0); + $floatRatio = $intTotal > 0 ? round($intAnyMissing / $intTotal, 4) : 0.0; + [$strHealthLabel, $strHealthReason] = self::resolveHealth($floatRatio, $arrTopFields); + + $strRunDir = dirname($strSummaryPath); + $arrItems[] = [ + 'run_id' => basename($strRunDir), + 'generated_at' => (string)($arrData['generated_at'] ?? date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time()))), + 'videos_with_any_missing_metadata' => $intAnyMissing, + 'total_videos' => $intTotal, + 'missing_ratio' => $floatRatio, + 'queue_size' => (int)($arrQueue['queue_size'] ?? 0), + 'top_field' => (string)(($arrTopFields[0] ?? [])['field'] ?? ''), + 'top_field_missing_count' => (int)(($arrTopFields[0] ?? [])['missing_count'] ?? 0), + 'health_label' => $strHealthLabel, + 'health_reason' => $strHealthReason, + 'summary_json_path' => self::publicRelativePath($strSummaryPath), + 'summary_html_path' => self::publicRelativePath($strRunDir . '/video-metadata-missing-workbench.summary.html'), + 'prompt_markdown_path' => self::publicRelativePath($strRunDir . '/prompt.md'), + ]; + } + + $arrHealthBuckets = []; + foreach ($arrItems as $arrItem) { + $strLabel = (string)($arrItem['health_label'] ?? 'unknown'); + $arrHealthBuckets[$strLabel] = (int)($arrHealthBuckets[$strLabel] ?? 0) + 1; + } + + return [ + 'items' => $arrItems, + 'total' => count($arrItems), + 'latest_run' => $arrItems[0] ?? [], + 'health_buckets' => $arrHealthBuckets, + ]; + } + + protected static function resolveHealth(float $floatRatio, array $arrTopFields): array + { + $strTopField = (string)(($arrTopFields[0] ?? [])['field'] ?? ''); + if ($floatRatio >= 0.30) { + return ['high_attention', '当前整体缺字段占比仍高,且结构化字段缺失量较大。重点先收敛演员、导演。']; + } + if ($strTopField === 'v_actor' || $strTopField === 'v_director') { + return ['structure_focus', '当前主要仍是演员 / 导演缺失,适合优先走重采和人工核验链。']; + } + + return ['steady', '当前缺字段问题相对可控,适合按优先队列持续收敛。']; + } + + protected static function publicRelativePath(string $strPath): string + { + $strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 2) . '/public', '/')); + $strPath = str_replace('\\', '/', $strPath); + if (str_starts_with($strPath, $strPublicRoot . '/')) { + return substr($strPath, strlen($strPublicRoot . '/')); + } + + return $strPath; + } +} diff --git a/code/app/model/DomainModel.php b/code/app/model/DomainModel.php index f5a5604..a15a28a 100644 --- a/code/app/model/DomainModel.php +++ b/code/app/model/DomainModel.php @@ -255,6 +255,7 @@ class DomainModel extends BaseModel $arrTkdSeoCfg['provider'] = self::normalizeTkdProvider((string)($arrTkdSeoCfg['provider'] ?? self::TKD_PROVIDER_LOCAL)); $arrSeoCfg['tkd'] = $arrTkdSeoCfg; + $arrRawCopyGenerationSeoCfg = is_array($mSeoCfg['copy_generation'] ?? null) ? $mSeoCfg['copy_generation'] : []; $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'; @@ -262,6 +263,38 @@ class DomainModel extends BaseModel $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)); + + $boolOpenAiCopyPreferred = $arrTkdSeoCfg['provider'] === self::TKD_PROVIDER_OPENAI + || $arrTkdSeoCfg['mode'] === self::TKD_MODE_AI_OPTIMIZE; + if ($boolOpenAiCopyPreferred) { + $boolLegacyWaitForAiDefault = array_key_exists('wait_for_ai_before_publish', $arrRawCopyGenerationSeoCfg) + && empty($arrRawCopyGenerationSeoCfg['wait_for_ai_before_publish']); + $boolLegacyProviderPreferenceDefault = array_key_exists('provider_preference', $arrRawCopyGenerationSeoCfg) + && trim((string)($arrRawCopyGenerationSeoCfg['provider_preference'] ?? '')) === 'local_first'; + $boolLegacyStatusDefault = array_key_exists('status', $arrRawCopyGenerationSeoCfg) + && trim((string)($arrRawCopyGenerationSeoCfg['status'] ?? '')) === 'draft'; + + if (!array_key_exists('wait_for_ai_before_publish', $arrRawCopyGenerationSeoCfg) || $boolLegacyWaitForAiDefault) { + $arrCopyGenerationSeoCfg['wait_for_ai_before_publish'] = true; + } + + if (!array_key_exists('provider_preference', $arrRawCopyGenerationSeoCfg) + || trim((string)($arrRawCopyGenerationSeoCfg['provider_preference'] ?? '')) === '' + || $boolLegacyProviderPreferenceDefault) { + $arrCopyGenerationSeoCfg['provider_preference'] = 'ai_first'; + } + + if (!array_key_exists('status', $arrRawCopyGenerationSeoCfg) + || trim((string)($arrRawCopyGenerationSeoCfg['status'] ?? '')) === '' + || $boolLegacyStatusDefault) { + $arrCopyGenerationSeoCfg['status'] = 'ai_pending'; + } + + if (!array_key_exists('ai_provider', $arrRawCopyGenerationSeoCfg) || trim((string)($arrRawCopyGenerationSeoCfg['ai_provider'] ?? '')) === '') { + $arrCopyGenerationSeoCfg['ai_provider'] = self::TKD_PROVIDER_OPENAI; + } + } + $arrSeoCfg['copy_generation'] = $arrCopyGenerationSeoCfg; $arrRuntimeAcceptance = is_array($arrSeoCfg['runtime_acceptance'] ?? null) diff --git a/code/app/model/VideoModel.php b/code/app/model/VideoModel.php index e1f79b7..1b09338 100644 --- a/code/app/model/VideoModel.php +++ b/code/app/model/VideoModel.php @@ -13,6 +13,19 @@ use Exception; */ class VideoModel extends MongoModel { + protected array $fillableMetadataFields = [ + 'v_actor', + 'v_director', + 'v_lang', + 'v_lang_en', + 'v_area', + 'v_area_en', + 'v_year', + 'v_remarks', + 'v_description', + 'v_publish_date', + ]; + /** * Undocumented variable * @@ -203,6 +216,506 @@ class VideoModel extends MongoModel return $this->findOne(['v_name_en' => $strNameEn]); } + public function buildEmptyMetadataBackfillSet(array $arrExistingVideo, array $arrIncomingVideo, string $strSource = 'crawler_backfill'): array + { + $arrUpdate = []; + $arrFilledFields = []; + + foreach ($this->fillableMetadataFields as $strField) { + $mixedExisting = $arrExistingVideo[$strField] ?? null; + $mixedIncoming = $arrIncomingVideo[$strField] ?? null; + + if (!$this->isFieldMetadataMissing($strField, $mixedExisting)) { + continue; + } + + if ($this->isFieldMetadataMissing($strField, $mixedIncoming)) { + continue; + } + + $mixedNormalized = $this->normalizeMetadataValueByField($strField, $mixedIncoming); + if ($this->isFieldMetadataMissing($strField, $mixedNormalized)) { + continue; + } + + $arrUpdate[$strField] = $mixedNormalized; + $arrFilledFields[] = $strField; + } + + if (empty($arrFilledFields)) { + return []; + } + + $arrUpdate['updated_at'] = new \MongoDB\BSON\UTCDateTime(); + $arrUpdate['v_metadata_fill'] = [ + 'last_source' => $strSource, + 'updated_at' => new \MongoDB\BSON\UTCDateTime(), + 'filled_fields' => $arrFilledFields, + ]; + + return $arrUpdate; + } + + public function buildMissingMetadataAuditSummary(int $intSampleLimit = 20): array + { + $intSampleLimit = max(1, min($intSampleLimit, 100)); + $intTotal = (int)$this->getCol()->countDocuments(); + $arrFields = []; + $arrAnyMissingFilters = []; + + foreach ($this->fillableMetadataFields as $strField) { + $arrFilter = $this->buildMissingMetadataFilter($strField); + $intMissingCount = (int)$this->getCol()->countDocuments($arrFilter); + $arrFields[] = [ + 'field' => $strField, + 'missing_count' => $intMissingCount, + 'missing_ratio' => $intTotal > 0 ? round($intMissingCount / $intTotal, 4) : 0, + ]; + $arrAnyMissingFilters[] = $arrFilter; + } + + $arrAnyMissingFilter = empty($arrAnyMissingFilters) ? [] : ['$or' => $arrAnyMissingFilters]; + $intAnyMissingCount = empty($arrAnyMissingFilter) ? 0 : (int)$this->getCol()->countDocuments($arrAnyMissingFilter); + + $Cursor = $this->getCol()->find($arrAnyMissingFilter, [ + 'limit' => $intSampleLimit, + 'sort' => ['updated_at' => -1, 'v_id' => -1], + 'projection' => [ + '_id' => 0, + 'v_id' => 1, + 'v_name' => 1, + 'v_category' => 1, + 'v_year' => 1, + 'v_remarks' => 1, + 'updated_at' => 1, + 'v_actor' => 1, + 'v_director' => 1, + 'v_lang' => 1, + 'v_lang_en' => 1, + 'v_area' => 1, + 'v_area_en' => 1, + 'v_description' => 1, + 'v_publish_date' => 1, + ], + 'typeMap' => self::$arrOptions['typeMap'], + ]); + + $arrSamples = []; + foreach (iterator_to_array($Cursor) as $arrVideo) { + $arrMissingFields = []; + foreach ($this->fillableMetadataFields as $strField) { + if ($this->isFieldMetadataMissing($strField, $arrVideo[$strField] ?? null)) { + $arrMissingFields[] = $strField; + } + } + + applyToKeys($arrVideo, ['updated_at'], 'formatMongoDate'); + + $arrSamples[] = [ + 'v_id' => (int)($arrVideo['v_id'] ?? 0), + 'v_name' => (string)($arrVideo['v_name'] ?? ''), + 'v_category' => (string)($arrVideo['v_category'] ?? ''), + 'v_year' => is_array($arrVideo['v_year'] ?? null) ? '' : (string)($arrVideo['v_year'] ?? ''), + 'v_remarks' => is_array($arrVideo['v_remarks'] ?? null) ? '' : (string)($arrVideo['v_remarks'] ?? ''), + 'updated_at' => (string)($arrVideo['updated_at'] ?? ''), + 'missing_fields' => $arrMissingFields, + ]; + } + + return [ + 'generated_at' => date(DATE_ATOM), + 'total_videos' => $intTotal, + 'videos_with_any_missing_metadata' => $intAnyMissingCount, + 'fillable_fields' => $this->fillableMetadataFields, + 'field_stats' => $arrFields, + 'sample_limit' => $intSampleLimit, + 'samples' => $arrSamples, + ]; + } + + public function buildRecrawlPriorityQueueSummary(int $intLimit = 200, int $intScanLimit = 3000): array + { + $intLimit = max(1, min($intLimit, 5000)); + $intScanLimit = max($intLimit, min($intScanLimit, 20000)); + $arrCandidates = []; + + $Cursor = $this->getCol()->find( + [ + '$or' => [ + $this->buildMissingMetadataFilter('v_actor'), + $this->buildMissingMetadataFilter('v_director'), + ], + ], + [ + 'projection' => [ + '_id' => 0, + 'v_id' => 1, + 'v_name' => 1, + 'v_category' => 1, + 'v_parent_category' => 1, + 'v_year' => 1, + 'v_actor' => 1, + 'v_director' => 1, + 'v_remarks' => 1, + 'v_isend' => 1, + 'v_publish_date' => 1, + 'v_play_url' => 1, + 'updated_at' => 1, + ], + 'limit' => $intScanLimit, + 'sort' => ['updated_at' => -1, 'v_id' => -1], + 'typeMap' => self::$arrOptions['typeMap'], + ] + ); + + foreach ($Cursor as $arrVideo) { + $intVId = (int)($arrVideo['v_id'] ?? 0); + if ($intVId <= 0) { + continue; + } + + $arrStats = VideoClicksModel::getInstance()->getStats($intVId); + $arrPlayUrl = (array)($arrVideo['v_play_url'] ?? []); + $arrSources = array_values(array_filter(array_map(static function ($mixedKey): string { + return trim((string)$mixedKey); + }, array_keys($arrPlayUrl)), static function (string $strKey): bool { + return $strKey !== ''; + })); + + $boolMissingActor = $this->isFieldMetadataMissing('v_actor', $arrVideo['v_actor'] ?? null); + $boolMissingDirector = $this->isFieldMetadataMissing('v_director', $arrVideo['v_director'] ?? null); + $strCategory = trim((string)($arrVideo['v_category'] ?? '')); + $strParentCategory = trim((string)($arrVideo['v_parent_category'] ?? '')); + $intPriorityScore = $this->calculateRecrawlPriorityScore( + $arrVideo, + $arrStats, + $boolMissingActor, + $boolMissingDirector, + count($arrSources), + $strCategory, + $strParentCategory + ); + + $arrCandidates[] = [ + 'v_id' => $intVId, + 'v_name' => (string)($arrVideo['v_name'] ?? ''), + 'v_category' => $strCategory, + 'v_parent_category' => $strParentCategory, + 'v_year' => (string)($arrVideo['v_year'] ?? ''), + 'v_remarks' => (string)($arrVideo['v_remarks'] ?? ''), + 'v_isend' => (int)($arrVideo['v_isend'] ?? 0), + 'v_publish_date' => (string)($arrVideo['v_publish_date'] ?? ''), + 'missing_actor' => $boolMissingActor, + 'missing_director' => $boolMissingDirector, + 'missing_fields' => array_values(array_filter([ + $boolMissingActor ? 'v_actor' : '', + $boolMissingDirector ? 'v_director' : '', + ])), + 'play_sources' => $arrSources, + 'play_source_count' => count($arrSources), + 'click_stats' => $arrStats, + 'priority_score' => $intPriorityScore, + 'updated_at' => ($arrVideo['updated_at'] ?? null) instanceof \MongoDB\BSON\UTCDateTime + ? formatMongoDate($arrVideo['updated_at']) + : '', + 'priority_reason' => $this->buildRecrawlPriorityReason( + $arrVideo, + $boolMissingActor, + $boolMissingDirector, + $arrStats, + (int)($arrVideo['v_isend'] ?? 0), + count($arrSources), + $strCategory, + $strParentCategory + ), + ]; + } + + usort($arrCandidates, static function (array $arrA, array $arrB): int { + $intScoreCompare = (int)($arrB['priority_score'] ?? 0) <=> (int)($arrA['priority_score'] ?? 0); + if ($intScoreCompare !== 0) { + return $intScoreCompare; + } + + $intWeeklyCompare = (int)(($arrB['click_stats'] ?? [])['weekly'] ?? 0) <=> (int)(($arrA['click_stats'] ?? [])['weekly'] ?? 0); + if ($intWeeklyCompare !== 0) { + return $intWeeklyCompare; + } + + $strPublishA = (string)($arrA['v_publish_date'] ?? ''); + $strPublishB = (string)($arrB['v_publish_date'] ?? ''); + $intPublishCompare = strcmp($strPublishB, $strPublishA); + if ($intPublishCompare !== 0) { + return $intPublishCompare; + } + + return (int)($arrB['v_id'] ?? 0) <=> (int)($arrA['v_id'] ?? 0); + }); + + $arrCandidates = array_values(array_slice($arrCandidates, 0, $intLimit)); + + return [ + 'generated_at' => date(DATE_ATOM), + 'limit' => $intLimit, + 'scan_limit' => $intScanLimit, + 'queue_size' => count($arrCandidates), + 'items' => $arrCandidates, + ]; + } + + protected function isEmptyMetadataValue($mixedValue): bool + { + if ($mixedValue === null) { + return true; + } + + if (is_string($mixedValue)) { + return trim($mixedValue) === ''; + } + + if (is_array($mixedValue)) { + return count(array_filter($mixedValue, function ($mixedItem) { + if (is_string($mixedItem)) { + return trim($mixedItem) !== ''; + } + + return !empty($mixedItem); + })) === 0; + } + + return empty($mixedValue); + } + + public function isFieldMetadataMissing(string $strField, $mixedValue): bool + { + $mixedNormalized = $this->normalizeMetadataValueByField($strField, $mixedValue); + return $this->isEmptyMetadataValue($mixedNormalized); + } + + protected function buildMissingMetadataFilter(string $strField): array + { + $arrInvalidValues = $this->getInvalidMetadataValuesByField($strField); + + $arrOr = [ + [$strField => ['$exists' => false]], + [$strField => null], + [$strField => ''], + [$strField => []], + ]; + + foreach ($arrInvalidValues as $strInvalid) { + $arrOr[] = [$strField => $strInvalid]; + } + + return [ + '$or' => $arrOr, + ]; + } + + protected function buildRecrawlPriorityReason( + array $arrVideo, + bool $boolMissingActor, + bool $boolMissingDirector, + array $arrStats, + int $intIsEnd, + int $intSourceCount, + string $strCategory, + string $strParentCategory + ): string { + $arrReasons = []; + + if ($boolMissingActor) { + $arrReasons[] = '演员缺失'; + } + if ($boolMissingDirector) { + $arrReasons[] = '导演缺失'; + } + if ((int)($arrStats['weekly'] ?? 0) > 0 || (int)($arrStats['total'] ?? 0) > 0) { + $arrReasons[] = '已有访问信号'; + } + $intFreshDays = $this->resolveFreshDays((string)($arrVideo['v_publish_date'] ?? '')); + if ($intFreshDays >= 0 && $intFreshDays <= 14) { + $arrReasons[] = '近14天新内容'; + } + if ($intIsEnd !== 1) { + $arrReasons[] = '仍可能持续更新'; + } + if ($intSourceCount >= 2) { + $arrReasons[] = '已有多播放源可交叉重采'; + } + $strCategoryKey = $strCategory . ' ' . $strParentCategory; + if (preg_match('/综艺|动漫|连续剧|国产剧|韩剧|日剧/u', $strCategoryKey)) { + $arrReasons[] = '高频更新题材'; + } + + return implode(' / ', $arrReasons); + } + + protected function calculateRecrawlPriorityScore( + array $arrVideo, + array $arrStats, + bool $boolMissingActor, + bool $boolMissingDirector, + int $intSourceCount, + string $strCategory, + string $strParentCategory + ): int { + $intScore = 0; + + if ($boolMissingActor) { + $intScore += 30; + } + if ($boolMissingDirector) { + $intScore += 35; + } + + $intFreshDays = $this->resolveFreshDays((string)($arrVideo['v_publish_date'] ?? '')); + if ($intFreshDays >= 0 && $intFreshDays <= 3) { + $intScore += 28; + } elseif ($intFreshDays <= 7) { + $intScore += 22; + } elseif ($intFreshDays <= 14) { + $intScore += 16; + } elseif ($intFreshDays <= 30) { + $intScore += 10; + } + + if ((int)($arrVideo['v_isend'] ?? 0) !== 1) { + $intScore += 15; + } + + if ($intSourceCount >= 2) { + $intScore += 10; + } elseif ($intSourceCount === 1) { + $intScore += 4; + } + + $strCategoryKey = $strCategory . ' ' . $strParentCategory; + if (preg_match('/综艺/u', $strCategoryKey)) { + $intScore += 10; + } elseif (preg_match('/动漫/u', $strCategoryKey)) { + $intScore += 9; + } elseif (preg_match('/连续剧|国产剧|韩剧|日剧/u', $strCategoryKey)) { + $intScore += 8; + } elseif (preg_match('/短剧|现代都市|古装仙侠/u', $strCategoryKey)) { + $intScore += 6; + } + + if (trim((string)($arrVideo['v_remarks'] ?? '')) !== '') { + $intScore += 4; + } + + if (!empty($arrStats['total'])) { + $intScore += min(18, (int)floor(log(max(1, (int)$arrStats['total']), 2))); + } + if (!empty($arrStats['weekly'])) { + $intScore += min(12, (int)$arrStats['weekly']); + } + + return $intScore; + } + + protected function resolveFreshDays(string $strPublishDate): int + { + $strPublishDate = trim($strPublishDate); + if ($strPublishDate === '') { + return -1; + } + + $intTs = strtotime($strPublishDate); + if ($intTs === false) { + return -1; + } + + $intDiff = time() - $intTs; + if ($intDiff < 0) { + return 0; + } + + return (int)floor($intDiff / 86400); + } + + protected function normalizeMetadataValue($mixedValue) + { + if (is_array($mixedValue)) { + $arrValue = array_values(array_filter($mixedValue, function ($mixedItem) { + if (is_string($mixedItem)) { + return trim($mixedItem) !== ''; + } + + return !empty($mixedItem); + })); + + return array_map(function ($mixedItem) { + return is_string($mixedItem) ? trim($mixedItem) : $mixedItem; + }, $arrValue); + } + + if (is_string($mixedValue)) { + return trim($mixedValue); + } + + return $mixedValue; + } + + protected function normalizeMetadataValueByField(string $strField, $mixedValue) + { + $mixedNormalized = $this->normalizeMetadataValue($mixedValue); + $arrInvalidValues = $this->getInvalidMetadataValuesByField($strField); + + if (is_array($mixedNormalized)) { + $arrValue = array_values(array_filter($mixedNormalized, function ($mixedItem) use ($arrInvalidValues) { + if (!is_string($mixedItem)) { + return !empty($mixedItem); + } + + $strValue = trim($mixedItem); + if ($strValue === '') { + return false; + } + + return !in_array($strValue, $arrInvalidValues, true); + })); + + return $arrValue; + } + + if (is_string($mixedNormalized)) { + $strValue = trim($mixedNormalized); + if (in_array($strValue, $arrInvalidValues, true)) { + return ''; + } + + return $strValue; + } + + return $mixedNormalized; + } + + protected function getInvalidMetadataValuesByField(string $strField): array + { + $arrCommonInvalid = [ + '内详', + '未知', + '不详', + '待补充', + '暂无', + '暂无信息', + ]; + + return match ($strField) { + 'v_actor' => array_values(array_unique(array_merge($arrCommonInvalid, [ + '未知演员', + '演员未知', + ]))), + 'v_director' => array_values(array_unique(array_merge($arrCommonInvalid, [ + '未知导演', + '导演未知', + ]))), + default => $arrCommonInvalid, + }; + } + /** * 查询符合条件的随机多条小说 diff --git a/code/app/services/SiteContext.php b/code/app/services/SiteContext.php index 4c5aba2..09a8175 100644 --- a/code/app/services/SiteContext.php +++ b/code/app/services/SiteContext.php @@ -767,8 +767,8 @@ class SiteContext $arrCandidates = []; - if ($strPage === 'home') { - // 首页优先吃 domain 表,避免已导入/AI 生成的首页 TKD 被旧标题池覆盖。 + if (in_array($strPage, ['home', 'search', 'category_index', 'category_list', 'rank_index', 'rank_list'], true)) { + // 首页、搜索页、分类页、榜单页优先吃 domain/published 文案,避免旧标题池抢占。 $arrCandidates[] = $this->renderSeoFromDomainRecord($strCode, $strPage); $arrCandidates[] = $this->renderSeoFromPool($strCode, $strPage); $arrCandidates[] = $this->renderSeoFromLegacyKey($strCode, $strPage); @@ -832,6 +832,10 @@ class SiteContext $strSiteName = trim((string)($this->DomainModel->d_name ?? '')); $strHost = trim((string)($this->DomainModel->d_domain ?? '')); + $strSeoCopyMeta = $this->renderSeoFromPublishedCopy($strCode, $strPage, $strHost, $strSiteName); + if ($strSeoCopyMeta !== '') { + return $strSeoCopyMeta; + } if ($strPage === 'home') { return match ($strCode) { @@ -897,6 +901,115 @@ class SiteContext return trim((string)($arrGeneric[$strCode] ?? '')); } + protected function renderSeoFromPublishedCopy(string $strCode, string $strPage, string $strHost, string $strSiteName): string + { + $strHost = trim($strHost); + if ($strHost === '') { + return ''; + } + + $arrPageConfig = $this->resolveSeoCopySceneConfig($strPage); + if (empty($arrPageConfig['scene'])) { + return ''; + } + + $strScene = (string)$arrPageConfig['scene']; + $arrPageKeys = (array)($arrPageConfig['page_keys'] ?? []); + $arrPayload = \app\common\helper\SeoCopyStore::getPreferredPageData( + $strHost, + $strScene, + $arrPageKeys, + [\app\common\helper\SeoCopyStore::publishedRoot()] + ); + if (empty($arrPayload)) { + return ''; + } + + $arrTokens = [ + 'site_name' => $strSiteName, + 'keyword' => trim((string)$this->Request->get('keyword', '')), + 'parent_category_name' => trim((string)(ConverterMovel::getVal('strVideoParentCategoryName') ?? '')), + 'category_name' => trim((string)(ConverterMovel::getVal('strVideoCategoryName') ?? '')), + ]; + + $strField = match ($strCode) { + 'title' => 'title', + 'keywords' => 'keywords', + 'description' => 'description', + default => '', + }; + if ($strField === '') { + return ''; + } + + $strValue = trim((string)($arrPayload[$strField] ?? '')); + if ($strValue === '') { + return ''; + } + + return \app\common\helper\SeoCopyStore::interpolateTemplate($strValue, $arrTokens); + } + + protected function resolveSeoCopySceneConfig(string $strPage): array + { + return match ($strPage) { + 'home' => [ + 'scene' => 'home', + 'page_keys' => ['index'], + ], + 'search' => [ + 'scene' => 'search', + 'page_keys' => array_values(array_filter([ + \app\common\helper\SeoCopySchema::buildScenePageKey('search', [ + trim((string)$this->Request->get('keyword', '')), + ]), + 'landing', + ])), + ], + 'category_index' => [ + 'scene' => 'category_index', + 'page_keys' => array_values(array_filter([ + \app\common\helper\SeoCopySchema::buildScenePageKey('category_index', [ + trim((string)$this->Request->route('strParentCategory')), + ]), + 'index', + ])), + ], + 'category_list' => [ + 'scene' => 'category_list', + 'page_keys' => array_values(array_filter([ + \app\common\helper\SeoCopySchema::buildScenePageKey('category_list', [ + trim((string)$this->Request->route('strParentCategory')), + trim((string)$this->Request->route('strCategory')), + ]), + 'default', + ])), + ], + 'rank_index' => [ + 'scene' => 'rank_index', + 'page_keys' => ['index'], + ], + 'rank_list' => [ + 'scene' => 'rank_list', + 'page_keys' => array_values(array_filter([ + \app\common\helper\SeoCopySchema::buildScenePageKey('rank_list', [ + trim((string)$this->Request->route('strSortType')), + trim((string)$this->Request->route('strParentCategory')), + trim((string)$this->Request->route('strCategory')), + ]), + \app\common\helper\SeoCopySchema::buildScenePageKey('rank_list', [ + trim((string)$this->Request->route('strSortType')), + ]), + 'default', + ])), + ], + default => [ + 'scene' => '', + 'page_keys' => [], + ], + }; + } + protected function normalizeSeoText(mixed $mValue): string { $strValue = trim((string)$mValue); @@ -983,6 +1096,13 @@ class SiteContext '高清免费', '免费播放', ]; + $arrCoreTopicWords = [ + '电影', + '电视剧', + '短剧', + '综艺', + '动漫', + ]; $arrParts = []; foreach ($arrRawParts as $strPart) { @@ -1008,7 +1128,7 @@ class SiteContext continue; } - if (mb_strlen($strPart) <= 2 && !preg_match('/^\d+$/u', $strPart)) { + if (mb_strlen($strPart) <= 2 && !preg_match('/^\d+$/u', $strPart) && !in_array($strPart, $arrCoreTopicWords, true)) { continue; } diff --git a/code/app/task/crawler/douban/page/VideoInfoPage.php b/code/app/task/crawler/douban/page/VideoInfoPage.php index f1ad36a..cb1fe7f 100644 --- a/code/app/task/crawler/douban/page/VideoInfoPage.php +++ b/code/app/task/crawler/douban/page/VideoInfoPage.php @@ -219,6 +219,16 @@ class VideoInfoPage extends BasePage 'v_play_url' => $sortedPlayUrl, ]; + $arrMetadataBackfill = $this->getVideoModel()->buildEmptyMetadataBackfillSet( + $arrExistsVideo, + $arrPageVideo, + 'douban_crawler' + ); + + if (!empty($arrMetadataBackfill)) { + $arrUpdate = array_merge($arrUpdate, $arrMetadataBackfill); + } + $this->getVideoModel()->updateOne( ['v_name' => $arrPageVideo['v_name']], ['$set' => $arrUpdate] diff --git a/code/app/task/crawler/fengchao/page/VideoInfoPage.php b/code/app/task/crawler/fengchao/page/VideoInfoPage.php index 35994ab..a0b9a7c 100644 --- a/code/app/task/crawler/fengchao/page/VideoInfoPage.php +++ b/code/app/task/crawler/fengchao/page/VideoInfoPage.php @@ -246,6 +246,16 @@ class VideoInfoPage extends BasePage 'v_play_url' => $sortedPlayUrl, ]; + $arrMetadataBackfill = $this->getVideoModel()->buildEmptyMetadataBackfillSet( + $arrExistsVideo, + $arrPageVideo, + 'fengchao_crawler' + ); + + if (!empty($arrMetadataBackfill)) { + $arrUpdate = array_merge($arrUpdate, $arrMetadataBackfill); + } + $this->getVideoModel()->updateOne( ['v_name' => $arrPageVideo['v_name']], ['$set' => $arrUpdate] diff --git a/code/app/task/crawler/maotai/page/VideoInfoPage.php b/code/app/task/crawler/maotai/page/VideoInfoPage.php index 325ae42..7e00cc5 100644 --- a/code/app/task/crawler/maotai/page/VideoInfoPage.php +++ b/code/app/task/crawler/maotai/page/VideoInfoPage.php @@ -233,6 +233,16 @@ class VideoInfoPage extends BasePage 'v_name_en' => zhToPinYin($arrPageVideo['v_name']), ]; + $arrMetadataBackfill = $this->getVideoModel()->buildEmptyMetadataBackfillSet( + $arrExistsVideo, + $arrPageVideo, + 'maotai_crawler' + ); + + if (!empty($arrMetadataBackfill)) { + $arrUpdate = array_merge($arrUpdate, $arrMetadataBackfill); + } + $this->getVideoModel()->updateOne( ['v_name' => $arrPageVideo['v_name']], ['$set' => $arrUpdate] diff --git a/code/app/task/crawler/wuxian/page/VideoInfoPage.php b/code/app/task/crawler/wuxian/page/VideoInfoPage.php index 0f11924..3da0ca0 100644 --- a/code/app/task/crawler/wuxian/page/VideoInfoPage.php +++ b/code/app/task/crawler/wuxian/page/VideoInfoPage.php @@ -252,6 +252,16 @@ class VideoInfoPage extends BasePage 'v_play_url' => $sortedPlayUrl, ]; + $arrMetadataBackfill = $this->getVideoModel()->buildEmptyMetadataBackfillSet( + $arrExistsVideo, + $arrPageVideo, + 'wuxian_crawler' + ); + + if (!empty($arrMetadataBackfill)) { + $arrUpdate = array_merge($arrUpdate, $arrMetadataBackfill); + } + $this->getVideoModel()->updateOne( ['v_name' => $arrPageVideo['v_name']], ['$set' => $arrUpdate] diff --git a/code/app/task/crawler/youzhi/page/VideoCategory.php b/code/app/task/crawler/youzhi/page/VideoCategory.php index 3e3b31f..7e0df26 100644 --- a/code/app/task/crawler/youzhi/page/VideoCategory.php +++ b/code/app/task/crawler/youzhi/page/VideoCategory.php @@ -169,6 +169,10 @@ class VideoCategory 'name' => '体育', 'pinyin' => 'ti-yu', ], + 95 => [ + 'name' => '短剧大全', + 'pinyin' => 'duan-ju-da-quan', + ], ]; static public $arrParent = [ @@ -212,5 +216,6 @@ class VideoCategory 19 => 19, 83 => 83, 94 => 94, + 95 => 83, ]; } diff --git a/code/app/task/crawler/youzhi/page/VideoInfoPage.php b/code/app/task/crawler/youzhi/page/VideoInfoPage.php index 69d11f1..b84cc36 100644 --- a/code/app/task/crawler/youzhi/page/VideoInfoPage.php +++ b/code/app/task/crawler/youzhi/page/VideoInfoPage.php @@ -127,7 +127,8 @@ class VideoInfoPage extends BasePage } $arrPlayUrl = $cleanPlayUrl; - $arrVideo['type_id_1'] = VideoCategory::$arrParent[$arrVideo['type_id']]; + $arrVideo['type_id'] = (int)($arrVideo['type_id'] ?? 0); + $arrVideo['type_id_1'] = VideoCategory::$arrParent[$arrVideo['type_id']] ?? $arrVideo['type_id']; $arrVideo['vod_actor'] = trim($arrVideo['vod_actor']); $arrVideo['vod_director'] = trim($arrVideo['vod_director']); @@ -159,10 +160,16 @@ class VideoInfoPage extends BasePage $arrVideo['vod_content'] = mb_rtrim($arrVideo['vod_content'], '\ '); $arrVideo['vod_content'] = ensureUtf8($arrVideo['vod_content']); - $arrVideo['v_category'] = VideoCategory::$arrCategory[$arrVideo['type_id']]['name']; - $arrVideo['v_category_en'] = VideoCategory::$arrCategory[$arrVideo['type_id']]['pinyin']; - $arrVideo['v_parent_category'] = VideoCategory::$arrCategory[$arrVideo['type_id_1']]['name']; - $arrVideo['v_parent_category_en'] = VideoCategory::$arrCategory[$arrVideo['type_id_1']]['pinyin']; + $arrCurrentCategory = VideoCategory::$arrCategory[$arrVideo['type_id']] ?? VideoCategory::$arrCategory[$arrVideo['type_id_1']] ?? [ + 'name' => '短剧大全', + 'pinyin' => 'duan-ju-da-quan', + ]; + $arrParentCategory = VideoCategory::$arrCategory[$arrVideo['type_id_1']] ?? $arrCurrentCategory; + + $arrVideo['v_category'] = $arrCurrentCategory['name']; + $arrVideo['v_category_en'] = $arrCurrentCategory['pinyin']; + $arrVideo['v_parent_category'] = $arrParentCategory['name']; + $arrVideo['v_parent_category_en'] = $arrParentCategory['pinyin']; foreach ($arrVideo as $strKey => $mixedVal) { $strKey = str_replace('vod', 'v', $strKey); @@ -249,6 +256,16 @@ class VideoInfoPage extends BasePage 'v_play_url' => $sortedPlayUrl, ]; + $arrMetadataBackfill = $this->getVideoModel()->buildEmptyMetadataBackfillSet( + $arrExistsVideo, + $arrPageVideo, + 'youzhi_crawler' + ); + + if (!empty($arrMetadataBackfill)) { + $arrUpdate = array_merge($arrUpdate, $arrMetadataBackfill); + } + $this->getVideoModel()->updateOne( ['v_name' => $arrPageVideo['v_name']], ['$set' => $arrUpdate] diff --git a/code/app/task/logic/VideoSiteMapLogic.php b/code/app/task/logic/VideoSiteMapLogic.php index c44815f..70f70e3 100644 --- a/code/app/task/logic/VideoSiteMapLogic.php +++ b/code/app/task/logic/VideoSiteMapLogic.php @@ -88,7 +88,7 @@ class VideoSiteMapLogic */ public function getDomain(): array { - if (empty($this->arrDomain)) { + if (empty($this->arrDomainModel)) { $this->arrDomainModel = DomainModel::getAllDomainOnCache(); } return $this->arrDomainModel; @@ -100,8 +100,26 @@ class VideoSiteMapLogic * * @return void */ - public function generateSiteMap() + public function generateSiteMap(array $arrTaskData = []) { + $arrDomains = array_values(array_unique(array_filter(array_map( + static fn($mValue): string => DomainModel::normalizeStoredDomain((string)$mValue), + (array)($arrTaskData['domains'] ?? []) + )))); + + if (!empty($arrDomains)) { + $arrAllDomainModel = DomainModel::getAllDomainOnCache(); + $this->arrDomainModel = array_values(array_filter( + $arrAllDomainModel, + static fn($DomainModel): bool => in_array((string)$DomainModel->d_domain, $arrDomains, true) + )); + } + + if (empty($this->getDomain())) { + echo 'No domains matched for sitemap generation.' . PHP_EOL; + return; + } + // 1. 只查一次总数(非常重要) $arrResult = $this->VideoModel->findPaginatedWithCache([], 1, 1); $this->intTotal = $arrResult['total']; diff --git a/code/app/task/module/PlanTask.php b/code/app/task/module/PlanTask.php index 86aea0e..c6e9aa0 100644 --- a/code/app/task/module/PlanTask.php +++ b/code/app/task/module/PlanTask.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace app\task\module; +use app\common\helper\VideoMetadataMissingRefreshHelper; use app\model\PlanTaskModel; use app\task\logic\SiteMapLogic; use app\task\logic\VideoSiteMapLogic; @@ -94,6 +95,9 @@ class PlanTask case 'PUSH_BAIDU_VIDEO_URL': self::pushBaiduVideoUrl(); break; + case 'REFRESH_VIDEO_METADATA_TASK_POOL': + self::refreshVideoMetadataTaskPool(); + break; } @@ -115,6 +119,28 @@ class PlanTask $QueueManage->set($arrTask); } + /** + * 安全刷新视频缺字段工作台与任务池。 + * + * 说明: + * 1. 只生成扫描产物和派单产物 + * 2. 不自动补结构化事实字段 + * 3. 不自动调用 AI API + * 4. 适合作为计划任务安全入口 + * + * @return array + */ + public static function refreshVideoMetadataTaskPool(): array + { + return VideoMetadataMissingRefreshHelper::refresh([ + 'sample' => 8, + 'queue_limit' => 50, + 'prompt_limit' => 10, + 'batch_size' => 10, + 'batch_limit' => 10, + ]); + } + /** * Undocumented function * diff --git a/code/config/console.php b/code/config/console.php index c46fea4..cf384e5 100644 --- a/code/config/console.php +++ b/code/config/console.php @@ -11,5 +11,12 @@ return [ 'mongo:manage' => 'db\MongoMigrate', 'novel:manage' => 'db\NovelManage', 'bbc:manage' => 'bbc\Init', + 'video:metadata:audit' => 'app\\command\\VideoMetadataAuditCommand', + 'video:metadata:fill-copy' => 'app\\command\\VideoMetadataCopyFillCommand', + 'video:metadata:recrawl-queue' => 'app\\command\\VideoMetadataRecrawlQueueCommand', + 'video:metadata:recrawl-run' => 'app\\command\\VideoMetadataRecrawlRunCommand', + 'video:metadata:workbench' => 'app\\command\\VideoMetadataMissingWorkbenchCommand', + 'video:metadata:task-pool' => 'app\\command\\VideoMetadataMissingTaskPoolCommand', + 'plan:seed:video-metadata-missing' => 'app\\command\\VideoMetadataMissingPlanTaskSeedCommand', ], ]; diff --git a/code/database/seeders/PlanTaskSeeder.php b/code/database/seeders/PlanTaskSeeder.php index 0f5a6a0..07515cb 100644 --- a/code/database/seeders/PlanTaskSeeder.php +++ b/code/database/seeders/PlanTaskSeeder.php @@ -123,6 +123,12 @@ class PlanTaskSeeder 'pt_enable' => 0, 'pt_limit' => 1 * 24 * 3600, ], + [ + 'pt_name' => '视频缺字段任务池刷新', + 'pt_code' => 'REFRESH_VIDEO_METADATA_TASK_POOL', + 'pt_enable' => 0, + 'pt_limit' => 1 * 24 * 3600, + ], diff --git a/docs/2026-04-18-AI文案与视频元数据公共规则主线.md b/docs/2026-04-18-AI文案与视频元数据公共规则主线.md new file mode 100644 index 0000000..8a31fdf --- /dev/null +++ b/docs/2026-04-18-AI文案与视频元数据公共规则主线.md @@ -0,0 +1,222 @@ +# 2026-04-18 AI文案与视频元数据公共规则主线 + +这份文档不是 GPT 模板专属文档,而是当前整个项目的公共规则主线。 + +适用范围: + +1. GPT 模板 +2. 老模板 +3. 后续其它模板 +4. 后台人工触发 AI +5. Codex 接手优化 +6. 视频采集、视频回填、SEO 文案生成、详情页正文补全 + +一句话: + +> 从 2026-04-18 开始,`AI 文案规则` 和 `视频元数据缺失处理规则` 统一提升为全项目公共规则,所有接手 Codex 都必须先读。 + +## 为什么要升级成公共规则 + +前面这些规则虽然最早是在 GPT 模板线里沉淀出来的,但它们约束的不是某一个模板的视觉或 SEO 结构,而是整个项目共同面对的两类基础问题: + +1. AI 文案怎么在真实边界内写得更像人工、又不伤 SEO +2. 演员、导演、年份、地区、语言、简介等字段为空时,系统应该怎么处理,才能既不乱猜又能持续沉淀进视频库 + +这两件事天然就是全项目公共能力,不应该只埋在某条模板支线下面。 + +## 公共规则 1:AI 文案最高指令 + +所有 AI 文案,不管是后台 OpenAI、批处理提示词,还是 Codex 参考输出,都必须遵守: + +1. 共同目标是:`SEO 效果 + 可读性 + 真实感 + 可持续收录` +2. 只能基于已提供字段和事实写,不允许虚构剧情、角色关系、上映信息、评价内容、播放权益或不存在的资料 +3. 如果现有规则开始明显压制 SEO 增长、压制点击意图、压制更高质量发挥,不能静默硬套,必须明确提出冲突点,再讨论升级 +4. 不允许为了“守规则”写出机械化、模板化、低区分度文案 +5. 不允许写营销承诺词,例如: + - `免费观看` + - `高清完整版` + - `极速播放` + - `本站提供` + - `全网最全` +6. 不允许堆砌关键词,不允许片名大面积重复,通常不要超过 `2` 次 +7. 语言必须自然、克制、像人工整理,不要满篇泛称、代词和模板痕迹 + +## 公共规则 2:缺失字段绝不能靠 AI 猜 + +下面这些字段都属于结构化事实字段: + +1. 演员 +2. 导演 +3. 年份 +4. 地区 +5. 语言 +6. 发布日期 + +统一规则: + +1. 这些字段优先靠 `重采 / 补源 / 人工校正` +2. 不允许 AI 猜测补齐 +3. `内详 / 未知 / 不详 / 待补充 / 暂无 / 未知演员 / 未知导演` 一律按缺失处理 +4. 搜到了详情,不等于补齐成功 +5. 只有拿到真实有效字段值,才算补齐 + +## 公共规则 3:文案型空字段可以补,但只能在真实性边界内补 + +下面这些字段属于文案型字段: + +1. 简介 +2. 备注 +3. 详情页摘要 +4. 详情页正文补充 + +统一规则: + +1. 可以在真实性边界内由 AI 补全 +2. 只能使用已有字段组织文案,不能脑补事实 +3. 优先写成自然、克制、资料型、可收录的内容 +4. 只补空字段,不覆盖已有非空字段 +5. 如果事实基础太弱,允许写短,不能硬凑长文 + +## 公共规则 4:缺失字段必须优先写回视频库 + +这是当前整个项目都要遵守的公共回写原则: + +1. 缺失字段一旦补到,优先写回视频数据库 +2. 默认原则:`只补空字段,不覆盖已有非空字段` +3. 不允许把空值、脏值、占位值写成“已补齐” +4. 写库目标不是把库塞满,而是让视频记录后续越来越完整,减少反复空转 + +## 公共规则 5:当前全项目的字段优化主矛盾 + +到目前这一步,文案型空字段已经不是主矛盾,真正的主矛盾是: + +1. `v_actor` +2. `v_director` + +所以全项目视频 SEO 元数据优化主线已经统一切成两条: + +1. 结构化字段缺失: + - 走重采、补源、人工校正 +2. 文案型字段缺失: + - 在真实性边界内走 AI 补全并写回空字段 + +## 当前全项目统一执行口径 + +### A. 做 AI 文案时 + +1. 先看事实字段是否足够 +2. 足够才写 +3. 不足就克制写成资料型 +4. 不够就宁可短,不要乱编 + +### B. 做字段补齐时 + +1. 先区分结构化字段和文案型字段 +2. 结构化字段缺失优先排到重采队列 +3. 文案型字段缺失优先排到 AI 补全队列 +4. 所有结果最终都要沉淀回视频库 + +### B.1 做缺字段扫描和任务派发时 + +当前全项目建议统一使用下面这条命令做安全触发: + +```bash +php SEONexus/code/think video:metadata:task-pool --sample=8 --queue-limit=50 --prompt-limit=10 --batch-size=10 --batch-limit=10 +``` + +这条命令的统一含义是: + +1. 扫描视频缺字段情况 +2. 刷新工作台产物 +3. 生成可派给 Codex 的缺字段任务池 +4. 不自动补演员、导演、年份、地区、语言、发布日期 +5. 不自动偷偷调用 OpenAI API + +所以它适合作为: + +1. 其它项目同步代码后的首条启动命令 +2. 计划任务挂接入口 +3. 技术排查当前缺字段规模的统一入口 + +如果当前项目使用数据库计划任务表统一调度,也可以使用: + +1. `pt_code = REFRESH_VIDEO_METADATA_TASK_POOL` +2. 对应安全入口为 `PlanTask::refreshVideoMetadataTaskPool()` + +这条入口仍然遵守同一条公共边界: + +1. 生成扫描与派单产物 +2. 不自动补事实字段 +3. 不自动调用 OpenAI API +4. 不自动写回视频库 + +当前配套的两个运维入口也建议一起看: + +1. 状态总览页:用于看当前缺字段规模、任务池批次状态、计划任务状态 +2. 运维总览页:用于看当前推荐动作、常用命令、产物路径 + +对应产物目录为: + +```text +SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/ +``` + +如果你更习惯后台 JSON 入口,也可以直接用: + +1. `GET /admin/video/metadata/missing/task-pool/status` +2. `GET /admin/video/metadata/missing/task-pool/ops` + +### B.2 页面入口和数据接口要分开理解 + +从这轮开始,这条线的后台页面已经迁到 Vue 后台 `SEONexusAdmin`。 + +统一口径: + +1. `PHP /admin/video/...` + 主要提供数据接口 +2. `Vue #/video/...` + 主要提供后台实际操作页面 + +当前页面型入口统一使用: + +1. `#/video/metadata/missing/workbench` +2. `#/video/metadata/missing/task-pool` +3. `#/video/metadata/missing/task-pool/status` +4. `#/video/metadata/missing/task-pool/ops` + +这一步的意义是: + +1. 后台页面能力统一收回到 Vue 工程 +2. PHP 侧不再继续膨胀出一套“伪后台页面” +3. 同步到其它服务器时,更容易区分哪些是接口代码,哪些是前端页面代码 + +### C. 做复盘时 + +1. 不只看“命中没命中” +2. 要看字段缺失审计是否真实下降 +3. 要看是否误写了无效值、占位值、脏值 + +## 公共阅读入口 + +所有新接手 Codex,建议先按这个顺序读: + +1. [README.md](/www/wwwroot/diff-maccms/SEONexus/docs/README.md) +2. [Codex启动提示词模板.md](/www/wwwroot/diff-maccms/SEONexus/docs/Codex启动提示词模板.md) +3. [2026-04-18-AI文案与视频元数据公共规则主线.md](/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-AI文案与视频元数据公共规则主线.md) +4. [2026-04-18-视频元数据缺失处理公共执行规则.md](/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-视频元数据缺失处理公共执行规则.md) + +如果接手的是 GPT 模板支线,再继续看: + +1. [2026-04-18-GPT模板SEO公共主线汇总.md](/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-GPT模板SEO公共主线汇总.md) +2. [gpt-template-seo/README.md](/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/README.md) + +## 来源说明 + +这份公共规则主线并不是凭空新建,而是把原本已经在 GPT 模板线里验证通过的规则,提升成项目公共规则。 + +规则来源主要包括: + +1. [gpt-template-seo/17-GPT模板AI文案最高规则-2026-04-18.md](/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/17-GPT模板AI文案最高规则-2026-04-18.md) +2. [gpt-template-seo/18-GPT模板演员导演缺失重采Top50执行单-2026-04-18.md](/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/18-GPT模板演员导演缺失重采Top50执行单-2026-04-18.md) + +从现在开始,这两份属于“来源与实验沉淀文档”,而这份文档属于“全项目公共总口径文档”。 diff --git a/docs/2026-04-18-PHP与Vue收口交付总结.md b/docs/2026-04-18-PHP与Vue收口交付总结.md new file mode 100644 index 0000000..47c7907 --- /dev/null +++ b/docs/2026-04-18-PHP与Vue收口交付总结.md @@ -0,0 +1,223 @@ +# 2026-04-18 PHP 与 Vue 收口交付总结 + +## 本轮目标 + +本轮收口的目标不是只补一个页面,而是把以下三条线真正闭环: + +1. `Vue 后台` 成为视频缺字段工作台/任务池的正式入口,不再依赖错误的 PHP 后台页面直出。 +2. `PHP 后端` 提供完整的数据接口、计划任务、工作台产物、任务池产物、缺字段扫描与安全补全能力。 +3. `公共 AI 规则` 与 `视频元数据缺失处理规则` 提升为全项目共享规则,后续无论哪个模板、哪个 Codex 接手,都必须先读这条主线。 + +--- + +## 交付范围 + +### 1. Vue 后台侧 + +已迁移到 `SEONexusAdmin` 的后台能力: + +- 视频缺字段控制台首屏卡片 +- 视频缺字段工作台页 +- 视频缺字段任务池页 +- 视频缺字段状态总览页 +- 视频缺字段运维总览页 +- 系统配置页中的 `SEO AI 规则` 专属入口 +- 新域名导入成功后的“下一步选择”弹窗 + - `Codex继续` + - `OpenAI继续` + - 一键复制 Codex 提示词 + +### 2. PHP 后端侧 + +已补齐或增强的能力: + +- 视频缺字段工作台接口 +- 视频缺字段任务池接口 +- 视频缺字段计划任务状态接口 +- 视频缺字段批次状态保存接口 +- 视频缺字段批次提示词接口 +- 视频缺字段批次历史接口 +- 视频缺字段工作台/任务池命令行命令 +- 视频缺字段计划任务种子 +- 视频元数据空字段安全补全 helper +- 采集器回填逻辑 + - 仅在现有字段为空时,用新采集到的真实字段回填 + - 不覆盖已有非空值 +- 站点导入后自动生成基础产物 + - SEO state + - manifest + - site positioning + - sitemap 入队 + - starter published copy + +### 3. 公共规则侧 + +已确立为全项目公共主线的内容: + +- AI 文案最高指令 +- 详情摘要规则 +- 详情正文规则 +- 缺失字段处理规则 +- 电影 / 剧集 / 综艺 / 动漫 / 短剧 细分风格规则 +- 视频元数据缺失扫描与派单原则 + +--- + +## 重要路径说明 + +### Vue 后台真实入口 + +以下页面应从 `SEONexusAdmin` 的 `hash 路由` 进入: + +- `#/video/metadata/missing/workbench` +- `#/video/metadata/missing/task-pool` +- `#/video/metadata/missing/task-pool/status` +- `#/video/metadata/missing/task-pool/ops` +- `#/system/config/seo-ai-rules` + +### PHP 侧定位 + +PHP 这边现在主要负责: + +- 数据接口 +- 工作台产物生成 +- 任务池产物生成 +- 计划任务 +- 命令行工具 + +不再把视频缺字段后台页面直接渲染成 PHP 管理页。 + +--- + +## 更新其它源时要同步的重点 + +### PHP 仓库必须同步 + +重点包含: + +- `app/admin/controller/Video.php` +- `app/admin/controller/Site.php` +- `app/admin/controller/SystemConfig.php` +- `app/admin/config/router.php` +- `app/task/module/PlanTask.php` +- `config/console.php` +- `database/seeders/PlanTaskSeeder.php` +- `app/common/helper/SeoCopyAiRuleConfigHelper.php` +- `app/common/helper/VideoMetadata*` +- `app/command/VideoMetadata*` +- `app/model/VideoModel.php` +- `app/model/DomainModel.php` +- `app/services/SiteContext.php` +- 各采集器 `VideoInfoPage.php` 中的空字段回填逻辑 + +### Vue 仓库必须同步 + +重点包含: + +- `src/views/video/metadataMissing/index.vue` +- `src/views/home/index.vue` +- `src/views/home/index.scss` +- `src/views/system/config/seoAiRules.vue` +- `src/views/system/config/index.vue` +- `src/views/system/config/drawer.vue` +- `src/views/site/index.vue` +- `src/api/modules/video/list.ts` +- `src/api/interface/video/list.ts` +- `src/api/modules/system/config.ts` +- `src/api/interface/system/config.ts` +- `src/api/interface/site/list.ts` +- `src/assets/json/authMenuList.json` + +--- + +## 运行逻辑说明 + +### 1. 导入新域名后 + +系统现在会: + +1. 注册域名 +2. 初始化 SEO copy 基础状态 +3. 初始化 site positioning +4. 把 sitemap 生成加入队列 +5. 返回导入探测结果 +6. 在 Vue 后台弹出“下一步选择” + +系统不会再偷偷自动触发 OpenAI。 + +### 2. 运营继续 + +如果由运营继续: + +1. 先看导入探测摘要 +2. 确认页面无明显错误 +3. 再点击后台 AI 功能,显式触发 OpenAI 队列 + +### 3. 技术继续 + +如果由技术继续: + +1. 直接复制后台给出的 Codex 提示词 +2. 在 VS Code 中打开当前工作区 +3. 把提示词发给 Codex +4. 由 Codex 接管优化、复测、记录、收口 + +--- + +## 视频元数据空字段处理原则 + +这部分是公共规则,不只属于 GPT 模板: + +1. 如果采集回来的演员、导演、语言、地区、年份、备注、简介等字段为空,先记录并进入缺字段工作台/任务池。 +2. 如果后续同一资源再次采集到真实非空字段,只允许在“原字段为空”的前提下回填。 +3. 不允许用采集结果覆盖已有真实非空值。 +4. 不允许静默编造结构化事实字段。 +5. 本地安全补全仅允许补: + - `v_description` + - `v_remarks` +6. 本地安全补全只能基于已有真实字段组织资料型文案,不能编造演员、导演、年份、剧情。 + +--- + +## 计划任务与自动触发 + +当前安全边界是: + +1. 可以自动刷新工作台和任务池 +2. 可以自动扫描空字段 +3. 可以自动归档待处理批次 +4. 不自动调用远程 AI 去补结构化事实 +5. 不自动联网搜索演员/导演等真实资料 + +也就是说: + +- `自动扫描` 可以开 +- `自动派单` 可以开 +- `自动 OpenAI 补事实字段` 当前不建议默认开启 + +--- + +## 后续建议 + +### 建议继续保留的执行节奏 + +1. 每日先看蜘蛛日志 +2. 再看视频缺字段控制台 +3. 先处理高流量、高抓取、高优先级资源 +4. GPT 模板专属 SEO 文档继续写在 `docs/gpt-template-seo/` +5. 公共规则、公共机制、公共流程统一汇总在 `docs/` 根目录 + +### 下一批接手 Codex 必看 + +接手人至少先读: + +- `docs/2026-04-18-AI文案与视频元数据公共规则主线.md` +- `docs/Codex启动提示词模板.md` +- `docs/2026-04-18-视频缺失字段工作台V1说明.md` +- `docs/2026-04-18-PHP与Vue收口交付总结.md` + +--- + +## 一句话结论 + +这轮收口后,`视频缺字段工作台`、`任务池`、`公共 AI 规则`、`导入后继续分流` 已经形成一套可以复制到其它源的完整方案;后续其它服务器只要同步 `PHP + Vue` 两个仓库,就能拿到同一套后台能力和执行规则。 diff --git a/docs/2026-04-18-视频缺失字段工作台V1说明.md b/docs/2026-04-18-视频缺失字段工作台V1说明.md new file mode 100644 index 0000000..19a5010 --- /dev/null +++ b/docs/2026-04-18-视频缺失字段工作台V1说明.md @@ -0,0 +1,379 @@ +# 视频缺失字段工作台 V1 说明 + +## 目标 + +这套工作台用于把视频库里的空字段问题集中收口,给技术、运营、Codex 一个统一入口: + +1. 看当前哪些字段缺失最严重 +2. 看哪些视频优先处理 +3. 复制一份可以直接派给 Codex 的提示词 + +## 当前范围 + +V1 已上线的是“工作台产物 + 后台读取入口 + 手动触发生成 + 历史运行记录”。 + +当前不做: + +1. 不会因为发现空字段,就自动调用 AI API 去猜演员、导演、年份、地区、语言 +2. 不会在采集过程中,直接把不确定的结构化资料写回库 +3. 不会自动加入计划任务周期跑 + +## 命令行入口 + +### 1. 只生成工作台 + +```bash +php SEONexus/code/think video:metadata:workbench --sample=20 --queue-limit=100 --prompt-limit=20 +``` + +参数说明: + +1. `--sample` + 说明:缺失字段样本数,用于抽样展示典型问题视频 +2. `--queue-limit` + 说明:重采优先队列条数,用于给出优先处理的视频集合 +3. `--prompt-limit` + 说明:生成给 Codex 的派单样本数 + +### 2. 一次性生成“工作台 + 缺字段任务池” + +```bash +php SEONexus/code/think video:metadata:task-pool --sample=20 --queue-limit=100 --prompt-limit=20 --batch-size=20 --batch-limit=10 +``` + +参数说明: + +1. `--sample` + 说明:缺失字段样本数,用于抽样展示典型问题视频 +2. `--queue-limit` + 说明:重采优先队列条数,用于给出优先处理的视频集合 +3. `--prompt-limit` + 说明:生成给 Codex 的派单样本数 +4. `--batch-size` + 说明:每个待处理批次包含多少条视频 +5. `--batch-limit` + 说明:单次最多生成多少个批次 + +## 后台接口 + +1. `GET /admin/video/metadata/missing/workbench` + 作用:读取最新工作台摘要;如果没有现成产物,会现场生成一次 +2. `GET /admin/video/metadata/missing/prompt` + 作用:读取给 Codex 的最新提示词 +3. `GET /admin/video/metadata/missing/workbench/runs` + 作用:读取历史运行记录,方便做前后对比 +4. `POST /admin/video/metadata/missing/workbench/run` + 作用:强制刷新工作台产物 + +## 产物目录 + +生成后会写到: + +```text +SEONexus/code/app/public/_admin_templates/video-metadata-missing-workbench/ +``` + +主要文件: + +1. `index.json` + 作用:后台摘要数据源 +2. `index.html` + 作用:可直接打开看的工作台页面 +3. `prompts/latest.md` + 作用:可以复制给 Codex 的接手提示词 + +历史运行记录会写到: + +```text +SEONexus/code/app/public/_admin_templates/video-metadata-missing-workbench/runs/ +``` + +每次生成都会保存一份: + +1. `video-metadata-missing-workbench.summary.json` +2. `video-metadata-missing-workbench.summary.html` +3. `prompt.md` + +## 适合怎么用 + +### 场景一:技术自己接手 + +1. 后台刷新工作台 +2. 打开 `prompt` 或 `latest.md` +3. 把提示词发给 Codex +4. Codex 分析优先级、批次、风险和后续执行建议 + +### 场景二:运营先发现问题 + +1. 运营在后台看到字段缺失严重 +2. 让技术刷新工作台 +3. 技术把提示词发给 Codex 接手 + +## 为什么暂时不自动调 AI + +因为空字段里最重的是: + +1. `v_director` +2. `v_actor` + +这些都是结构化事实字段,不能靠 AI 猜。 +现阶段更优策略是: + +1. 先扫描并收集 +2. 先重采、补源、人工确认 +3. 只把文案型字段交给 AI + +## 当前是否自动跑 + +当前不是自动计划任务触发,也不是采集时自动偷偷触发。 + +现在的触发方式只有两种: + +1. 技术手动执行命令 +2. 后台手动点击刷新接口 + +如果技术希望挂到服务器计划任务,建议先挂下面这条命令: + +```bash +php /www/wwwroot/diff-maccms/SEONexus/code/think video:metadata:task-pool --sample=8 --queue-limit=50 --prompt-limit=10 --batch-size=10 --batch-limit=10 +``` + +推荐原因: + +1. 一次执行同时刷新工作台和缺字段任务池 +2. 不会自动补结构化事实字段 +3. 只生成产物,不会偷偷调用 OpenAI API +4. 更适合作为跨项目、跨模板的安全计划任务入口 + +如果你是通过数据库里的计划任务表来挂,不走系统 cron,也已经留好了安全入口: + +1. `pt_code` 使用:`REFRESH_VIDEO_METADATA_TASK_POOL` +2. 对应逻辑入口:`PlanTask::refreshVideoMetadataTaskPool()` +3. 默认行为: + - 只刷新工作台和任务池产物 + - 不自动补演员、导演、年份、地区、语言、发布日期 + - 不自动调用 OpenAI API + - 不自动写回视频库 + +建议口径: + +1. 先在测试环境或低频环境启用 +2. 先把 `pt_limit` 设成较大间隔,比如 `3600` 或 `21600` +3. 跑稳定后,再决定是否提升频率 + +也就是说,把代码同步到其它项目后,不会自己偷偷扫描全表,不会自己调用 AI。 + +如果别的项目想启用这条线,最小启动步骤是: + +1. 同步最新代码 +2. 执行一次 `video:metadata:task-pool` +3. 后台读取任务池接口或直接打开任务池产物页 + +这样就能先把“缺字段扫描、待处理批次、Codex 提示词”全跑起来。 + +## 其它项目怎么启动 + +如果把这套代码同步到其它项目,要启动这个工作台,只需要: + +1. 同步最新代码 +2. 执行一次 + +```bash +php SEONexus/code/think video:metadata:workbench +``` + +3. 后台读取对应接口或直接打开产物页 + +## 下一阶段建议 + +V2 可以继续做: + +1. 扫描全表空字段后,自动收集到“待处理池” +2. 后台展示“缺字段任务池” +3. 后台一键生成 Codex 派单提示词 +4. 再决定是否接计划任务定时刷新 +5. 给计划任务增加“只刷新工作台,不自动补事实字段”的安全模式 + +当前其中第 1 到 3 步已经具备基础能力,第 4 步现在可以通过挂 `video:metadata:task-pool` 命令实现。 + +## 当前新增:缺字段待处理池 + +当前已经补出第二层: + +1. `GET /admin/video/metadata/missing/task-pool` + 作用:读取缺字段待处理池 +2. `GET /admin/video/metadata/missing/task-pool/status` + 作用:读取缺字段任务池总览状态,包含工作台规模、任务池批次状态、计划任务状态 +3. `GET /admin/video/metadata/missing/task-pool/plan-task/status` + 作用:读取缺字段计划任务的启用情况和间隔 +4. `GET /admin/video/metadata/missing/task-pool/ops` + 作用:读取缺字段任务池运维总览,包含推荐动作、常用命令和产物路径 +5. `POST /admin/video/metadata/missing/task-pool/plan-task/status/save` + 作用:保存缺字段计划任务的启用状态和执行间隔 +6. `GET /admin/video/metadata/missing/task-pool/batch/detail` + 作用:读取单个批次详情 +7. `GET /admin/video/metadata/missing/task-pool/batch/prompt` + 作用:读取单个批次的 Codex 提示词 +8. `POST /admin/video/metadata/missing/task-pool/run` + 作用:重新生成缺字段待处理池 +9. `POST /admin/video/metadata/missing/task-pool/batch/state/save` + 作用:保存批次状态 + +对应产物目录: + +```text +SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/ +``` + +这套任务池现在已经包含: + +1. 最新总览 `index.json` / `index.html` +2. 单批次稳定详情 `batches/*.json` +3. 单批次稳定提示词 `batches/*.md` +4. 单批次状态历史 `state/history/*.json` +5. 单批次复制提示词按钮和动作链接 +6. 状态总览页 `status/index.json` / `status/index.html` + +状态总览页的作用: + +1. 直接展示当前缺字段视频规模 +2. 展示当前任务池批次数和批次状态统计 +3. 展示数据库计划任务状态 +4. 适合不进后台接口时,直接打开 HTML 快速查看 + +## 运维总览页 + +除了状态总览页,当前还补了一个更偏“后台操作指引”的运维总览页: + +```text +SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/ops/index.html +SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/ops/index.json +``` + +它的作用不是替代状态页,而是把“现在该做什么”讲得更直接: + +1. 当前推荐动作 +2. 当前可执行命令 +3. 当前工作台和任务池产物路径 +4. 当前计划任务是否启用 + +这个页面适合给: + +1. 运营看,少看接口字段 +2. 技术看,少翻文档 +3. 接手的 Codex 看,直接按页面里的命令继续执行 + +当前这页默认会在刷新任务池时一起生成,后续如果计划任务接上,也会同步刷新。 + +## 后台首页快捷入口 + +当前后台首页 `/admin` 已补成快捷入口页,直接提供下面这些跳转: + +1. 工作台 +2. 任务池 +3. 状态总览 +4. 运维总览 +5. 计划任务状态 + +如果你是新接手的 Codex,先打开后台首页,再按页内链接进入对应模块,会比直接记接口路径更快。 + +## Vue 后台迁移说明 + +这一轮已经把“视频缺字段工作台 / 任务池 / 状态总览 / 运维总览”的页面入口迁到 `SEONexusAdmin` 这个 Vue 后台。 + +现在要区分两类入口: + +1. PHP `/admin/video/...` + 作用:数据接口 +2. Vue `#/video/...` + 作用:后台实际页面入口 + +当前正确的后台页面路径是: + +1. `#/video/metadata/missing/workbench` +2. `#/video/metadata/missing/task-pool` +3. `#/video/metadata/missing/task-pool/status` +4. `#/video/metadata/missing/task-pool/ops` + +说明: + +1. 不要再把 `/admin/video/metadata/missing/task-pool/ops` 当成最终后台页面 URL +2. 这个路径在当前体系里更适合作为 PHP 接口或旧产物路径,不适合作为 Vue 后台页面入口 +3. 如果要同步到其它服务器,除了同步 `SEONexus/code` 里的 PHP 数据接口,还必须同步 `SEONexusAdmin` 的 Vue 页面和 `dist` + +## 批次状态说明 + +当前批次状态先走“文件态”,不走数据库表。 + +状态文件位置: + +```text +SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/state/batch-status.json +``` + +状态历史文件位置: + +```text +SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/state/history/ +``` + +可用状态: + +1. `pending` + 含义:待处理 +2. `dispatched` + 含义:已派单,已经发给 Codex 或某位技术 +3. `processing` + 含义:处理中 +4. `done` + 含义:已完成 +5. `skipped` + 含义:已跳过 + +当前这样设计的原因: + +1. 不需要改数据库 +2. 可以马上同步到其它项目使用 +3. 即使别的项目环境不一致,也能直接落地 +4. 可以先把“当前状态”和“历史轨迹”都收进文件,再决定是否升级成数据库态 + +## 批次历史接口 + +当前已经补出: + +1. `GET /admin/video/metadata/missing/task-pool/batch/history` + 作用:读取某个批次的状态变化历史 + +2. `POST /admin/video/metadata/missing/task-pool/batch/state/save` + 作用:保存某个批次的状态变化,同时写入历史 + +3. `GET /admin/video/metadata/missing/task-pool/batch/detail` + 作用:读取某个批次的完整内容、当前状态、历史 + +4. `GET /admin/video/metadata/missing/task-pool/batch/prompt` + 作用:直接拿某个批次专属的 Codex 接手提示词 + +说明: + +1. 每次状态变化都会追加写入历史 +2. 历史时间戳已经提升到微秒级,避免同一秒连续操作时顺序混乱 + +后面如果这套机制稳定,再考虑升级成数据库态任务池。 + +## 计划任务建议值 + +如果你准备把 `REFRESH_VIDEO_METADATA_TASK_POOL` 放进后台计划任务列表,建议先这样配: + +1. `pt_enable = 0` + 说明:默认先不启用,确认后台状态页和产物页正常后再打开 +2. `pt_limit = 86400` + 说明:先按 1 天跑一次,避免太频繁刷新 + +启用时可以先观察: + +1. 工作台是否持续刷新 +2. 任务池批次状态是否正常 +3. 是否有其它项目需要同步相同计划任务 + +如果后台先想看“该不该启用、启用后跑什么”,优先打开运维总览页; +如果只是想看当前规模和状态,优先打开状态总览页。 diff --git a/docs/Codex启动提示词模板.md b/docs/Codex启动提示词模板.md index 67aca7b..5eaf736 100644 --- a/docs/Codex启动提示词模板.md +++ b/docs/Codex启动提示词模板.md @@ -85,9 +85,121 @@ - 提交前确认 README 仍能作为入口索引使用 ``` +## 模板 4:视频元数据 / 重采队列接手 + +```md +你这次先不要自由发挥,先按这个顺序接手: + +1. 先看 `/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-AI文案与视频元数据公共规则主线.md` +2. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-视频元数据缺失处理公共执行规则.md` +3. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/17-GPT模板AI文案最高规则-2026-04-18.md` +4. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/18-GPT模板演员导演缺失重采Top50执行单-2026-04-18.md` +5. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/19-GPT模板演员导演缺失Top20现场执行单-2026-04-18.md` +6. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/20-GPT模板演员导演缺失Top21-50现场执行单-2026-04-18.md` +7. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/21-GPT模板演员导演缺失重采结果回填模板-2026-04-18.md` +8. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/22-GPT模板第一轮重采实战复盘记录单-2026-04-18.md` +9. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/25-GPT模板第一二轮重采对比复盘-2026-04-18.md` +10. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/26-GPT模板第三轮短剧演员放量验证复盘-2026-04-18.md` +11. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/27-GPT模板第四轮短剧演员主线确立复盘-2026-04-18.md` +12. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/28-GPT模板第五轮短剧演员批处理复盘-2026-04-18.md` +13. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/29-GPT模板短剧演员双源重采SOP-2026-04-18.md` +14. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/30-GPT模板第六轮短剧演员复盘与解析容错修复-2026-04-18.md` +15. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/31-GPT模板字段补齐对7天SEO收益链路说明-2026-04-18.md` +16. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/32-GPT模板第七轮短剧演员尾部样本复盘-2026-04-18.md` +17. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/34-GPT模板第八轮短剧演员新鲜样本复盘-2026-04-18.md` +18. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/35-GPT模板第九轮现代都市新样本复盘-2026-04-18.md` +19. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/36-GPT模板第十轮样本边界验证复盘-2026-04-18.md` +20. 再看 `/www/wwwroot/diff-maccms/SEONexus/code/storage/video-metadata-audit/latest.md` +21. 再看 `/www/wwwroot/diff-maccms/SEONexus/code/storage/video-metadata-recrawl-queue/latest.md` + +当前任务: +- 继续推进视频库元数据优化 +- 重点处理 `v_actor / v_director` 缺失 + +先记住: +- `AI文案规则` 和 `视频参数缺失处理规则` 现在属于全项目公共规则,不再只是 GPT 模板规则 +- GPT 目录里的文档主要是“这条支线已经跑出来的实战结果与样本经验” +- 当前“视频缺字段工作台 / 任务池 / 状态 / 运维”页面入口已经迁到 `SEONexusAdmin` 这个 Vue 后台 +- `PHP /admin/video/...` 主要看作数据接口,不再看作最终后台页面入口 +- 当前正确的后台页面路径应优先走: + - `#/video/metadata/missing/workbench` + - `#/video/metadata/missing/task-pool` + - `#/video/metadata/missing/task-pool/status` + - `#/video/metadata/missing/task-pool/ops` + +工作边界: +- 不允许用 AI 猜演员、导演、年份、地区、语言、发布日期 +- 结构化字段优先走重采 / 补源 / 人工校正 +- 文案型字段只补空,不覆盖已有内容 +- `内详 / 未知 / 不详 / 待补充 / 暂无 / 未知演员 / 未知导演` 这类占位值一律按缺失处理 +- 每次执行后必须复跑审计,确认缺口是否真实下降 +- 已验证主线:`现代都市短剧 + 双源 + v_actor` +- 当前已验证结果: + - 第二轮 `5/5` 演员补齐 + - 第三轮 `8/10` 演员补齐 + - 第四轮 `10/10` 演员补齐 +- 第五轮 `10/10` 演员补齐 +- 第六轮 `8/10` 演员补齐 +- 第七轮 `6/10` 演员补齐 +- 第八轮 `10/10` 演员补齐 +- 第九轮 `8/10` 演员补齐 +- 第十轮 `5/10` 演员补齐 +- 当前导演字段三轮连续 `0` 补齐,不要再把 `v_director` 当作短期主 KPI +- 当前已修复 `youzhi` 未知分类 `type_id=95` 导致整轮中断的问题,后续这类样本允许“正常失败”,不允许“整批崩溃” +- 当前阶段要认识到:越往后进入尾部样本,单轮成功率会回落,但只要 `v_actor` 仍持续净下降,这条线依然值得推进 +- 第八轮已经再次证明:第七轮的回落主要来自尾部样本池,不是主线失效;当前应坚持“新鲜短剧样本优先、低收益尾部样本降级” +- 第九轮进一步证明:换到另一批现代都市新样本后依然能拿到 `8/10`,当前主线已经具备连续复现性 +- 第十轮进一步证明:主线不能无限泛化;一旦混入 `短剧大全 / 古风替嫁 / 玄门侧`,收益会明显回落,因此核心批处理必须重新收窄到 `现代都市新鲜样本` +- 要理解当前动作的 SEO 目标:先看 `31-GPT模板字段补齐对7天SEO收益链路说明-2026-04-18.md` + +优先输出: +1. 当前主缺口是什么 +2. 当前 Top 队列准备怎么执行 +3. 本轮执行后哪些字段下降了 +4. 哪个源站更有效 +5. 下一轮优先补哪一批 + +优先命令: +1. `php think video:metadata:recrawl-queue --limit 50` +2. `php think video:metadata:recrawl-run --limit 10 --dry-run` +3. `php think video:metadata:recrawl-run --v-ids=156037 --sources=douban,youzhi` +4. `php think video:metadata:audit --sample 10` +``` + ## 使用建议 1. 新会话如果是“分析型任务”,优先用模板 1 2. 新会话如果是“线上辅助排障”,优先用模板 2 3. 新会话如果只是“整理文档 / 收口”,优先用模板 3 -4. 如果任务混合,可以把模板 1 和模板 3 合并后再发 +4. 新会话如果是“视频库字段补全 / 重采队列执行”,优先用模板 4 +5. 如果任务混合,可以把模板 1、3、4 按需合并后再发 + +## 模板 5:公共规则极简接手 + +```md +你先不要自由发挥,先按这个顺序接手: + +1. 先看 `/www/wwwroot/diff-maccms/SEONexus/docs/README.md` +2. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-AI文案与视频元数据公共规则主线.md` +3. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-视频元数据缺失处理公共执行规则.md` +4. 如果任务涉及多人协作或老时间线,再看: + `/www/wwwroot/diff-maccms/SEONexus/docs/1270-SEONexus-Codex多版本协作交接总文档.md` + +先记住: +- `AI文案规则` 和 `视频元数据缺失处理规则` 现在属于全项目公共规则 +- 结构化字段不能靠 AI 猜 +- 文案型字段只能在真实性边界内补空 +- 所有补齐结果优先写回视频库 + +然后你再判断任务是: +1. GPT 模板支线 +2. 蜘蛛 / nginx / 缓存排障 +3. 视频元数据重采 +4. 文档收口 + +优先输出: +1. 当前主结论 +2. 当前任务属于哪条线 +3. 当前适用的是哪套公共规则 +4. 下一步建议 +```