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 .= '
批次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' : '') + . '
' + . '生成时间:' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '
+说明:当前先做产物型任务池,不自动补事实字段,不直接入库。
+ +| 批次ID | 批次名称 | 重点字段 | 优先级 | 状态 | 负责人 | 历史 | 数量 | 动作 |
|---|
生成时间:' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '
+| 模块 | 生成时间 | HTML | JSON |
|---|---|---|---|
| 工作台 | +' . 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') . ' | +
| 状态 | 数量 |
|---|
| 字段 | 值 |
|---|---|
| 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') . ' |
'
+ . htmlspecialchars((string)($arrCommand['command'] ?? ''), ENT_QUOTES, 'UTF-8')
+ . '生成时间:' . htmlspecialchars((string)($arrOps['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '
+| 用途 | 命令 |
|---|
| 模块 | HTML | JSON |
|---|---|---|
| 工作台 | +' . 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') . ' | +
生成时间:' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '
+| 字段 | 缺失数 | 缺失占比 |
|---|
| v_id | 片名 | 分类 | 缺失字段 | 优先分 | 优先原因 |
|---|
' . $strPrompt . '+