feat: add metadata workbench and shared seo rules
This commit is contained in:
@@ -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 () {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\controller;
|
||||
|
||||
class Index
|
||||
{
|
||||
public function index()
|
||||
public function index(): string
|
||||
{
|
||||
return '您好!这是一个[admin]示例应用';
|
||||
return 'SEONexus Admin frontend has been migrated to the Vue console. Please open the Vue admin entry instead of this PHP page.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use app\common\helper\DomainBatchImportTemplateHelper;
|
||||
use app\common\helper\DomainImportProbeRunHelper;
|
||||
use app\common\helper\DomainImportAsyncJobHelper;
|
||||
use app\common\helper\DomainImportFailedQueueHelper;
|
||||
use app\common\helper\DomainSitemapGenerationHelper;
|
||||
use app\common\helper\DomainSpiderMdRunHelper;
|
||||
use app\common\helper\DomainImportHealthTrendHelper;
|
||||
use app\common\helper\DomainImportRunIndexHelper;
|
||||
@@ -1377,11 +1378,16 @@ class Site extends BaseController
|
||||
$DomainModel->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();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 资源端点列表
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
134
code/app/command/VideoMetadataAuditCommand.php
Normal file
134
code/app/command/VideoMetadataAuditCommand.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\model\VideoModel;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataAuditCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:audit')
|
||||
->addOption('sample', null, Option::VALUE_OPTIONAL, '抽样输出条数,默认 20', 20)
|
||||
->setDescription('审计视频库缺失字段情况,并生成历史回填分析文件');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intSample = max(1, (int)$input->getOption('sample'));
|
||||
$arrSummary = VideoModel::getInstance()->buildMissingMetadataAuditSummary($intSample);
|
||||
|
||||
$strRoot = rtrim((string)root_path(), '/');
|
||||
$strOutputRoot = $strRoot . '/storage/video-metadata-audit';
|
||||
if (!is_dir($strOutputRoot)) {
|
||||
@mkdir($strOutputRoot, 0777, true);
|
||||
}
|
||||
|
||||
$strJsonPath = $strOutputRoot . '/latest.json';
|
||||
$strMarkdownPath = $strOutputRoot . '/latest.md';
|
||||
|
||||
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL);
|
||||
file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary));
|
||||
|
||||
$output->writeln('视频元数据缺失审计已生成');
|
||||
$output->writeln('总视频数:' . (int)($arrSummary['total_videos'] ?? 0));
|
||||
$output->writeln('存在任一缺失字段的视频数:' . (int)($arrSummary['videos_with_any_missing_metadata'] ?? 0));
|
||||
$output->writeln('JSON:' . $strJsonPath);
|
||||
$output->writeln('Markdown:' . $strMarkdownPath);
|
||||
|
||||
foreach ((array)($arrSummary['field_stats'] ?? []) as $arrField) {
|
||||
$output->writeln(sprintf(
|
||||
'%s => 缺失 %d 条,占比 %.2f%%',
|
||||
(string)($arrField['field'] ?? ''),
|
||||
(int)($arrField['missing_count'] ?? 0),
|
||||
((float)($arrField['missing_ratio'] ?? 0)) * 100
|
||||
));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function buildMarkdown(array $arrSummary): string
|
||||
{
|
||||
$arrLines = [];
|
||||
$arrLines[] = '# 视频元数据缺失审计';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? '');
|
||||
$arrLines[] = '- 总视频数:' . (int)($arrSummary['total_videos'] ?? 0);
|
||||
$arrLines[] = '- 任一缺失字段视频数:' . (int)($arrSummary['videos_with_any_missing_metadata'] ?? 0);
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '## 字段缺失统计';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '| 字段 | 缺失数 | 缺失占比 | 建议动作 |';
|
||||
$arrLines[] = '| --- | ---: | ---: | --- |';
|
||||
|
||||
foreach ((array)($arrSummary['field_stats'] ?? []) as $arrField) {
|
||||
$strField = (string)($arrField['field'] ?? '');
|
||||
$intMissingCount = (int)($arrField['missing_count'] ?? 0);
|
||||
$floatRatio = ((float)($arrField['missing_ratio'] ?? 0)) * 100;
|
||||
$arrLines[] = sprintf(
|
||||
'| %s | %d | %.2f%% | %s |',
|
||||
$strField,
|
||||
$intMissingCount,
|
||||
$floatRatio,
|
||||
$this->buildSuggestedAction($strField)
|
||||
);
|
||||
}
|
||||
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '## 抽样样本';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '| v_id | 片名 | 分类 | 年份 | 备注 | 缺失字段 | 更新时间 |';
|
||||
$arrLines[] = '| ---: | --- | --- | --- | --- | --- | --- |';
|
||||
|
||||
foreach ((array)($arrSummary['samples'] ?? []) as $arrSample) {
|
||||
$arrLines[] = sprintf(
|
||||
'| %d | %s | %s | %s | %s | %s | %s |',
|
||||
(int)($arrSample['v_id'] ?? 0),
|
||||
$this->escapeMarkdown((string)($arrSample['v_name'] ?? '')),
|
||||
$this->escapeMarkdown((string)($arrSample['v_category'] ?? '')),
|
||||
$this->escapeMarkdown((string)($arrSample['v_year'] ?? '')),
|
||||
$this->escapeMarkdown((string)($arrSample['v_remarks'] ?? '')),
|
||||
$this->escapeMarkdown(implode(', ', (array)($arrSample['missing_fields'] ?? []))),
|
||||
$this->escapeMarkdown((string)($arrSample['updated_at'] ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '## 判读原则';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '1. 演员、导演、年份、地区、语言这类结构化字段,优先靠重采、补源、人工校正,不能让 AI 猜。';
|
||||
$arrLines[] = '2. 简介、备注这类文案型字段,如果事实基础足够,可以走 AI 生成并写回视频库,但必须只补空字段。';
|
||||
$arrLines[] = '3. 后续采集器已接入“只补空字段、不覆盖已有值”的写库逻辑,新采到的有效字段会逐步沉淀进库。';
|
||||
$arrLines[] = '';
|
||||
|
||||
return implode(PHP_EOL, $arrLines) . PHP_EOL;
|
||||
}
|
||||
|
||||
protected function buildSuggestedAction(string $strField): string
|
||||
{
|
||||
return match ($strField) {
|
||||
'v_actor', 'v_director', 'v_year', 'v_lang', 'v_lang_en', 'v_area', 'v_area_en', 'v_publish_date'
|
||||
=> '优先重采或补源,不建议 AI 猜测',
|
||||
'v_description', 'v_remarks'
|
||||
=> '可在事实边界内走 AI 补全并写库',
|
||||
default => '先审计,再决定重采或补写',
|
||||
};
|
||||
}
|
||||
|
||||
protected function escapeMarkdown(string $strValue): string
|
||||
{
|
||||
$strValue = trim($strValue);
|
||||
if ($strValue === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return str_replace('|', '\\|', $strValue);
|
||||
}
|
||||
}
|
||||
109
code/app/command/VideoMetadataCopyFillCommand.php
Normal file
109
code/app/command/VideoMetadataCopyFillCommand.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\helper\VideoMetadataCopyFillHelper;
|
||||
use app\model\VideoModel;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataCopyFillCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:fill-copy')
|
||||
->addOption('limit', null, Option::VALUE_OPTIONAL, '本次最多处理多少条,默认 500', 500)
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '只预览,不写库')
|
||||
->setDescription('批量补写视频库里为空的简介和备注,只补空字段');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intLimit = max(1, min((int)$input->getOption('limit'), 5000));
|
||||
$boolDryRun = (bool)$input->getOption('dry-run');
|
||||
$VideoModel = VideoModel::getInstance();
|
||||
|
||||
$Cursor = $VideoModel->getCol()->find(
|
||||
[
|
||||
'$or' => [
|
||||
['v_description' => ['$exists' => false]],
|
||||
['v_description' => ''],
|
||||
['v_description' => null],
|
||||
['v_remarks' => ['$exists' => false]],
|
||||
['v_remarks' => ''],
|
||||
['v_remarks' => null],
|
||||
],
|
||||
],
|
||||
[
|
||||
'limit' => $intLimit,
|
||||
'sort' => ['updated_at' => -1, 'v_id' => -1],
|
||||
'projection' => [
|
||||
'_id' => 0,
|
||||
'v_id' => 1,
|
||||
'v_name' => 1,
|
||||
'v_category' => 1,
|
||||
'v_parent_category' => 1,
|
||||
'v_year' => 1,
|
||||
'v_area' => 1,
|
||||
'v_lang' => 1,
|
||||
'v_director' => 1,
|
||||
'v_actor' => 1,
|
||||
'v_remarks' => 1,
|
||||
'v_description' => 1,
|
||||
'v_isend' => 1,
|
||||
'v_publish_date' => 1,
|
||||
],
|
||||
'typeMap' => VideoModel::$arrOptions['typeMap'],
|
||||
]
|
||||
);
|
||||
|
||||
$intScanned = 0;
|
||||
$intUpdated = 0;
|
||||
$arrSamples = [];
|
||||
|
||||
foreach ($Cursor as $arrVideo) {
|
||||
$intScanned++;
|
||||
$arrUpdate = VideoMetadataCopyFillHelper::buildFillPayload((array)$arrVideo, 'local_copy_fill_command');
|
||||
if (empty($arrUpdate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrSamples[] = [
|
||||
'v_id' => (int)($arrVideo['v_id'] ?? 0),
|
||||
'v_name' => (string)($arrVideo['v_name'] ?? ''),
|
||||
'filled_fields' => (array)($arrUpdate['v_metadata_fill']['filled_fields'] ?? []),
|
||||
'generated_description' => (string)($arrUpdate['v_description'] ?? ''),
|
||||
'generated_remarks' => (string)($arrUpdate['v_remarks'] ?? ''),
|
||||
];
|
||||
|
||||
if (!$boolDryRun) {
|
||||
$VideoModel->updateOne(
|
||||
['v_id' => (int)($arrVideo['v_id'] ?? 0)],
|
||||
['$set' => $arrUpdate]
|
||||
);
|
||||
}
|
||||
|
||||
$intUpdated++;
|
||||
}
|
||||
|
||||
$output->writeln('视频文案型元数据补写完成');
|
||||
$output->writeln('模式:' . ($boolDryRun ? 'dry-run' : 'write'));
|
||||
$output->writeln('扫描条数:' . $intScanned);
|
||||
$output->writeln('命中可补写条数:' . $intUpdated);
|
||||
|
||||
foreach (array_slice($arrSamples, 0, 5) as $arrSample) {
|
||||
$output->writeln(sprintf(
|
||||
'#%d %s => %s',
|
||||
(int)($arrSample['v_id'] ?? 0),
|
||||
(string)($arrSample['v_name'] ?? ''),
|
||||
implode(', ', (array)($arrSample['filled_fields'] ?? []))
|
||||
));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
50
code/app/command/VideoMetadataMissingPlanTaskSeedCommand.php
Normal file
50
code/app/command/VideoMetadataMissingPlanTaskSeedCommand.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\model\PlanTaskModel;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataMissingPlanTaskSeedCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('plan:seed:video-metadata-missing')
|
||||
->setDescription('补齐视频缺字段任务池计划任务 REFRESH_VIDEO_METADATA_TASK_POOL');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$strCode = 'REFRESH_VIDEO_METADATA_TASK_POOL';
|
||||
$arrData = [
|
||||
'pt_name' => '视频缺字段任务池刷新',
|
||||
'pt_code' => $strCode,
|
||||
'pt_enable' => 0,
|
||||
'pt_limit' => 24 * 3600,
|
||||
'pt_last_exec' => 0,
|
||||
];
|
||||
|
||||
$PlanTaskModel = PlanTaskModel::where('pt_code', $strCode)->find();
|
||||
if ($PlanTaskModel instanceof PlanTaskModel) {
|
||||
$output->writeln('计划任务已存在,无需重复创建:' . $strCode);
|
||||
$output->writeln('pt_id:' . (int)$PlanTaskModel->pt_id);
|
||||
$output->writeln('pt_name:' . (string)$PlanTaskModel->pt_name);
|
||||
$output->writeln('pt_enable:' . (int)$PlanTaskModel->pt_enable);
|
||||
$output->writeln('pt_limit:' . (int)$PlanTaskModel->pt_limit);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$intId = (int)PlanTaskModel::insertGetId($arrData);
|
||||
$output->writeln('计划任务已创建:' . $strCode);
|
||||
$output->writeln('pt_id:' . $intId);
|
||||
$output->writeln('pt_name:' . $arrData['pt_name']);
|
||||
$output->writeln('pt_enable:' . $arrData['pt_enable']);
|
||||
$output->writeln('pt_limit:' . $arrData['pt_limit']);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
55
code/app/command/VideoMetadataMissingTaskPoolCommand.php
Normal file
55
code/app/command/VideoMetadataMissingTaskPoolCommand.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\helper\VideoMetadataMissingRefreshHelper;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataMissingTaskPoolCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:task-pool')
|
||||
->addOption('sample', null, Option::VALUE_OPTIONAL, '缺失字段样本数,默认 20', 20)
|
||||
->addOption('queue-limit', null, Option::VALUE_OPTIONAL, '重采优先队列条数,默认 100', 100)
|
||||
->addOption('prompt-limit', null, Option::VALUE_OPTIONAL, 'Codex 派单样本数,默认 20', 20)
|
||||
->addOption('batch-size', null, Option::VALUE_OPTIONAL, '每批数量,默认 20', 20)
|
||||
->addOption('batch-limit', null, Option::VALUE_OPTIONAL, '最大批次数,默认 10', 10)
|
||||
->setDescription('生成视频缺字段任务池产物,供后台展示、Codex 接手和计划任务挂接');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intSample = max(1, min(100, (int)$input->getOption('sample')));
|
||||
$intQueueLimit = max(1, min(500, (int)$input->getOption('queue-limit')));
|
||||
$intPromptLimit = max(1, min(100, (int)$input->getOption('prompt-limit')));
|
||||
$intBatchSize = max(5, min(100, (int)$input->getOption('batch-size')));
|
||||
$intBatchLimit = max(1, min(50, (int)$input->getOption('batch-limit')));
|
||||
|
||||
$arrResult = VideoMetadataMissingRefreshHelper::refresh([
|
||||
'sample' => $intSample,
|
||||
'queue_limit' => $intQueueLimit,
|
||||
'prompt_limit' => $intPromptLimit,
|
||||
'batch_size' => $intBatchSize,
|
||||
'batch_limit' => $intBatchLimit,
|
||||
]);
|
||||
$arrWorkbenchSummary = (array)($arrResult['workbench'] ?? []);
|
||||
$arrTaskPoolSummary = (array)($arrResult['task_pool'] ?? []);
|
||||
|
||||
$output->writeln('视频缺字段任务池已生成');
|
||||
$output->writeln('工作台缺字段视频数:' . (int)(($arrWorkbenchSummary['audit'] ?? [])['videos_with_any_missing_metadata'] ?? 0));
|
||||
$output->writeln('工作台重采优先队列:' . (int)(($arrWorkbenchSummary['queue'] ?? [])['queue_size'] ?? 0));
|
||||
$output->writeln('任务池批次数:' . count((array)($arrTaskPoolSummary['batches'] ?? [])));
|
||||
$output->writeln('工作台 JSON:' . (string)($arrWorkbenchSummary['summary_json_path'] ?? ''));
|
||||
$output->writeln('工作台 HTML:' . (string)($arrWorkbenchSummary['summary_html_path'] ?? ''));
|
||||
$output->writeln('任务池 JSON:' . (string)($arrTaskPoolSummary['summary_json_path'] ?? ''));
|
||||
$output->writeln('任务池 HTML:' . (string)($arrTaskPoolSummary['summary_html_path'] ?? ''));
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
43
code/app/command/VideoMetadataMissingWorkbenchCommand.php
Normal file
43
code/app/command/VideoMetadataMissingWorkbenchCommand.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\helper\VideoMetadataMissingWorkbenchHelper;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataMissingWorkbenchCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:workbench')
|
||||
->addOption('sample', null, Option::VALUE_OPTIONAL, '缺失字段样本数,默认 20', 20)
|
||||
->addOption('queue-limit', null, Option::VALUE_OPTIONAL, '重采优先队列条数,默认 100', 100)
|
||||
->addOption('prompt-limit', null, Option::VALUE_OPTIONAL, 'Codex 派单样本数,默认 20', 20)
|
||||
->setDescription('生成视频缺失字段工作台产物,供后台和 Codex 派单使用');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intSample = max(1, min(100, (int)$input->getOption('sample')));
|
||||
$intQueueLimit = max(1, min(500, (int)$input->getOption('queue-limit')));
|
||||
$intPromptLimit = max(1, min(100, (int)$input->getOption('prompt-limit')));
|
||||
$strOutputRoot = rtrim((string)root_path(), '/') . '/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;
|
||||
}
|
||||
}
|
||||
106
code/app/command/VideoMetadataRecrawlQueueCommand.php
Normal file
106
code/app/command/VideoMetadataRecrawlQueueCommand.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\model\VideoModel;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataRecrawlQueueCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:recrawl-queue')
|
||||
->addOption('limit', null, Option::VALUE_OPTIONAL, '输出多少条重采优先项,默认 200', 200)
|
||||
->setDescription('生成演员/导演缺失的视频重采优先队列');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intLimit = max(1, min((int)$input->getOption('limit'), 5000));
|
||||
$arrSummary = VideoModel::getInstance()->buildRecrawlPriorityQueueSummary($intLimit);
|
||||
|
||||
$strRoot = rtrim((string)root_path(), '/');
|
||||
$strOutputRoot = $strRoot . '/storage/video-metadata-recrawl-queue';
|
||||
if (!is_dir($strOutputRoot)) {
|
||||
@mkdir($strOutputRoot, 0777, true);
|
||||
}
|
||||
|
||||
$strJsonPath = $strOutputRoot . '/latest.json';
|
||||
$strMarkdownPath = $strOutputRoot . '/latest.md';
|
||||
|
||||
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL);
|
||||
file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary));
|
||||
|
||||
$output->writeln('视频元数据重采优先队列已生成');
|
||||
$output->writeln('队列条数:' . (int)($arrSummary['queue_size'] ?? 0));
|
||||
$output->writeln('JSON:' . $strJsonPath);
|
||||
$output->writeln('Markdown:' . $strMarkdownPath);
|
||||
|
||||
foreach (array_slice((array)($arrSummary['items'] ?? []), 0, 10) as $arrItem) {
|
||||
$output->writeln(sprintf(
|
||||
'#%d %s => score:%d | %s',
|
||||
(int)($arrItem['v_id'] ?? 0),
|
||||
(string)($arrItem['v_name'] ?? ''),
|
||||
(int)($arrItem['priority_score'] ?? 0),
|
||||
(string)($arrItem['priority_reason'] ?? '')
|
||||
));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function buildMarkdown(array $arrSummary): string
|
||||
{
|
||||
$arrLines = [];
|
||||
$arrLines[] = '# 视频元数据重采优先队列';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? '');
|
||||
$arrLines[] = '- 队列条数:' . (int)($arrSummary['queue_size'] ?? 0);
|
||||
$arrLines[] = '- 当前规则:优先处理演员 / 导演缺失且已有访问信号、仍在更新、多线路可交叉重采的视频';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '| 优先分 | v_id | 片名 | 分类 | 缺失字段 | 周点击 | 总点击 | 播放源数 | 更新时间 | 优先原因 |';
|
||||
$arrLines[] = '| ---: | ---: | --- | --- | --- | ---: | ---: | ---: | --- | --- |';
|
||||
|
||||
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
|
||||
$arrLines[] = sprintf(
|
||||
'| %d | %d | %s | %s | %s | %d | %d | %d | %s | %s |',
|
||||
(int)($arrItem['priority_score'] ?? 0),
|
||||
(int)($arrItem['v_id'] ?? 0),
|
||||
$this->escapeMarkdown((string)($arrItem['v_name'] ?? '')),
|
||||
$this->escapeMarkdown((string)($arrItem['v_category'] ?? '')),
|
||||
$this->escapeMarkdown(implode(', ', (array)($arrItem['missing_fields'] ?? []))),
|
||||
(int)(($arrItem['click_stats'] ?? [])['weekly'] ?? 0),
|
||||
(int)(($arrItem['click_stats'] ?? [])['total'] ?? 0),
|
||||
(int)($arrItem['play_source_count'] ?? 0),
|
||||
$this->escapeMarkdown((string)($arrItem['updated_at'] ?? '')),
|
||||
$this->escapeMarkdown((string)($arrItem['priority_reason'] ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '## 执行建议';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '1. 先处理前 50 条高分项,优先尝试从已有多播放源重新抓取演员/导演。';
|
||||
$arrLines[] = '2. 如果多源都拿不到,再转人工补源或定向接第三方资料源。';
|
||||
$arrLines[] = '3. 不允许用 AI 猜演员/导演;结构化事实字段只能靠真实来源补齐。';
|
||||
$arrLines[] = '4. 每次重采后,复跑 `php think video:metadata:audit` 看缺口是否下降。';
|
||||
$arrLines[] = '';
|
||||
|
||||
return implode(PHP_EOL, $arrLines) . PHP_EOL;
|
||||
}
|
||||
|
||||
protected function escapeMarkdown(string $strValue): string
|
||||
{
|
||||
$strValue = trim($strValue);
|
||||
if ($strValue === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return str_replace('|', '\\|', $strValue);
|
||||
}
|
||||
}
|
||||
344
code/app/command/VideoMetadataRecrawlRunCommand.php
Normal file
344
code/app/command/VideoMetadataRecrawlRunCommand.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\model\VideoModel;
|
||||
use app\task\crawler\douban\page\Site as DoubanSite;
|
||||
use app\task\crawler\youzhi\page\Site as YouzhiSite;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataRecrawlRunCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:recrawl-run')
|
||||
->addOption('limit', null, Option::VALUE_OPTIONAL, '默认按重采队列取多少条,默认 10', 10)
|
||||
->addOption('v-ids', null, Option::VALUE_OPTIONAL, '指定 v_id,多个逗号分隔;指定后优先按 v_id 执行', '')
|
||||
->addOption('sources', null, Option::VALUE_OPTIONAL, '强制指定源站,多个逗号分隔,如 douban,youzhi', '')
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '只搜索和匹配,不真正回写')
|
||||
->setDescription('按视频元数据重采队列执行演员/导演定向重采,并生成执行结果报告');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intLimit = max(1, min((int)$input->getOption('limit'), 100));
|
||||
$arrForcedVIds = $this->parseIntList((string)$input->getOption('v-ids'));
|
||||
$arrForcedSources = $this->parseSourceList((string)$input->getOption('sources'));
|
||||
$boolDryRun = (bool)$input->getOption('dry-run');
|
||||
|
||||
$arrCandidates = $this->resolveCandidates($arrForcedVIds, $intLimit);
|
||||
|
||||
if (empty($arrCandidates)) {
|
||||
$output->writeln('没有可执行的候选视频');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$arrSummary = [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'mode' => !empty($arrForcedVIds) ? 'explicit_v_ids' : 'queue',
|
||||
'dry_run' => $boolDryRun,
|
||||
'limit' => $intLimit,
|
||||
'requested_v_ids' => $arrForcedVIds,
|
||||
'forced_sources' => $arrForcedSources,
|
||||
'attempted_videos' => count($arrCandidates),
|
||||
'success_actor_count' => 0,
|
||||
'success_director_count' => 0,
|
||||
'success_both_count' => 0,
|
||||
'failed_both_count' => 0,
|
||||
'items' => [],
|
||||
];
|
||||
|
||||
foreach ($arrCandidates as $arrCandidate) {
|
||||
$arrItemSummary = $this->processCandidate($arrCandidate, $arrForcedSources, $boolDryRun);
|
||||
$arrSummary['items'][] = $arrItemSummary;
|
||||
|
||||
if (!empty($arrItemSummary['actor_filled'])) {
|
||||
$arrSummary['success_actor_count']++;
|
||||
}
|
||||
if (!empty($arrItemSummary['director_filled'])) {
|
||||
$arrSummary['success_director_count']++;
|
||||
}
|
||||
if (!empty($arrItemSummary['actor_filled']) && !empty($arrItemSummary['director_filled'])) {
|
||||
$arrSummary['success_both_count']++;
|
||||
}
|
||||
if (empty($arrItemSummary['actor_filled']) && empty($arrItemSummary['director_filled'])) {
|
||||
$arrSummary['failed_both_count']++;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'#%d %s | actor:%s | director:%s | sources:%s',
|
||||
(int)$arrItemSummary['v_id'],
|
||||
(string)$arrItemSummary['v_name'],
|
||||
!empty($arrItemSummary['actor_filled']) ? 'filled' : 'no',
|
||||
!empty($arrItemSummary['director_filled']) ? 'filled' : 'no',
|
||||
implode(',', array_column((array)$arrItemSummary['source_runs'], 'source'))
|
||||
));
|
||||
}
|
||||
|
||||
$strRoot = rtrim((string)root_path(), '/');
|
||||
$strOutputRoot = $strRoot . '/storage/video-metadata-recrawl-run';
|
||||
if (!is_dir($strOutputRoot)) {
|
||||
@mkdir($strOutputRoot, 0777, true);
|
||||
}
|
||||
|
||||
$strJsonPath = $strOutputRoot . '/latest.json';
|
||||
$strMarkdownPath = $strOutputRoot . '/latest.md';
|
||||
|
||||
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL);
|
||||
file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary));
|
||||
|
||||
$output->writeln('视频元数据定向重采报告已生成');
|
||||
$output->writeln('JSON:' . $strJsonPath);
|
||||
$output->writeln('Markdown:' . $strMarkdownPath);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function resolveCandidates(array $arrForcedVIds, int $intLimit): array
|
||||
{
|
||||
if (!empty($arrForcedVIds)) {
|
||||
$arrCandidates = [];
|
||||
foreach ($arrForcedVIds as $intVId) {
|
||||
$arrVideo = VideoModel::getInstance()->getVideoByVId($intVId);
|
||||
if (empty($arrVideo)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrPlaySources = array_values(array_filter(array_map('trim', array_keys((array)($arrVideo['v_play_url'] ?? [])))));
|
||||
$arrCandidates[] = [
|
||||
'v_id' => (int)($arrVideo['v_id'] ?? 0),
|
||||
'v_name' => (string)($arrVideo['v_name'] ?? ''),
|
||||
'v_name_en' => (string)($arrVideo['v_name_en'] ?? ''),
|
||||
'play_sources' => $arrPlaySources,
|
||||
];
|
||||
}
|
||||
|
||||
return $arrCandidates;
|
||||
}
|
||||
|
||||
return (array)(VideoModel::getInstance()->buildRecrawlPriorityQueueSummary($intLimit)['items'] ?? []);
|
||||
}
|
||||
|
||||
protected function processCandidate(array $arrCandidate, array $arrForcedSources, bool $boolDryRun): array
|
||||
{
|
||||
$intVId = (int)($arrCandidate['v_id'] ?? 0);
|
||||
$strVName = trim((string)($arrCandidate['v_name'] ?? ''));
|
||||
$strVNameEn = trim((string)($arrCandidate['v_name_en'] ?? ''));
|
||||
|
||||
$arrBefore = VideoModel::getInstance()->getVideoByVId($intVId) ?? [];
|
||||
$arrSourceRuns = [];
|
||||
|
||||
$arrSources = !empty($arrForcedSources)
|
||||
? $arrForcedSources
|
||||
: $this->parseSourceList(implode(',', (array)($arrCandidate['play_sources'] ?? [])));
|
||||
|
||||
foreach ($arrSources as $strSource) {
|
||||
$arrSearchResult = $this->searchSourceVideo($strSource, $strVName, $strVNameEn);
|
||||
|
||||
$arrSourceRun = [
|
||||
'source' => $strSource,
|
||||
'matched' => !empty($arrSearchResult),
|
||||
'matched_vod_id' => (int)($arrSearchResult['vod_id'] ?? 0),
|
||||
'matched_vod_name' => (string)($arrSearchResult['vod_name'] ?? ''),
|
||||
'status' => 'no_match',
|
||||
];
|
||||
|
||||
if (empty($arrSearchResult)) {
|
||||
$arrSourceRuns[] = $arrSourceRun;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($boolDryRun) {
|
||||
$arrSourceRun['status'] = 'matched_dry_run';
|
||||
$arrSourceRuns[] = $arrSourceRun;
|
||||
continue;
|
||||
}
|
||||
|
||||
$boolSaved = $this->fetchAndSaveSourceVideo($strSource, (int)$arrSearchResult['vod_id']);
|
||||
$arrSourceRun['status'] = $boolSaved ? 'saved' : 'fetch_failed';
|
||||
$arrSourceRuns[] = $arrSourceRun;
|
||||
}
|
||||
|
||||
$arrAfter = VideoModel::getInstance()->getVideoByVId($intVId) ?? [];
|
||||
|
||||
$VideoModel = VideoModel::getInstance();
|
||||
$boolActorBefore = $VideoModel->isFieldMetadataMissing('v_actor', $arrBefore['v_actor'] ?? null);
|
||||
$boolDirectorBefore = $VideoModel->isFieldMetadataMissing('v_director', $arrBefore['v_director'] ?? null);
|
||||
$boolActorAfter = $VideoModel->isFieldMetadataMissing('v_actor', $arrAfter['v_actor'] ?? null);
|
||||
$boolDirectorAfter = $VideoModel->isFieldMetadataMissing('v_director', $arrAfter['v_director'] ?? null);
|
||||
|
||||
return [
|
||||
'v_id' => $intVId,
|
||||
'v_name' => $strVName,
|
||||
'sources_requested' => $arrSources,
|
||||
'actor_filled' => $boolActorBefore && !$boolActorAfter,
|
||||
'director_filled' => $boolDirectorBefore && !$boolDirectorAfter,
|
||||
'before' => [
|
||||
'actor_empty' => $boolActorBefore,
|
||||
'director_empty' => $boolDirectorBefore,
|
||||
],
|
||||
'after' => [
|
||||
'actor_empty' => $boolActorAfter,
|
||||
'director_empty' => $boolDirectorAfter,
|
||||
],
|
||||
'source_runs' => $arrSourceRuns,
|
||||
];
|
||||
}
|
||||
|
||||
protected function searchSourceVideo(string $strSource, string $strVName, string $strVNameEn = ''): array
|
||||
{
|
||||
$Site = $this->makeSite($strSource);
|
||||
$strUri = $this->buildSearchUri($strSource, $strVName);
|
||||
|
||||
$Response = $Site->getClient()->get($strUri);
|
||||
$strContent = (string)$Response->getBody()->getContents();
|
||||
$arrContent = json_decode($strContent, true);
|
||||
$arrList = (array)($arrContent['list'] ?? []);
|
||||
|
||||
if (empty($arrList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$strTargetName = $this->normalizeTitle($strVName);
|
||||
$strTargetNameEn = strtolower(trim($strVNameEn));
|
||||
|
||||
foreach ($arrList as $arrItem) {
|
||||
$strVodName = $this->normalizeTitle((string)($arrItem['vod_name'] ?? ''));
|
||||
$strVodNameEn = strtolower(trim((string)($arrItem['vod_en'] ?? '')));
|
||||
|
||||
if ($strVodName !== '' && $strVodName === $strTargetName) {
|
||||
return (array)$arrItem;
|
||||
}
|
||||
|
||||
if ($strTargetNameEn !== '' && $strVodNameEn !== '' && $strVodNameEn === $strTargetNameEn) {
|
||||
return (array)$arrItem;
|
||||
}
|
||||
}
|
||||
|
||||
return (array)$arrList[0];
|
||||
}
|
||||
|
||||
protected function fetchAndSaveSourceVideo(string $strSource, int $intVodId): bool
|
||||
{
|
||||
if ($intVodId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$Site = $this->makeSite($strSource);
|
||||
$arrPages = $Site->getVideoInfoPageList([
|
||||
['v_source_id' => $intVodId],
|
||||
]);
|
||||
|
||||
if (empty($arrPages)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$boolSaved = false;
|
||||
foreach ($arrPages as $VideoInfoPage) {
|
||||
$boolSaved = $VideoInfoPage->saveVideo() || $boolSaved;
|
||||
}
|
||||
|
||||
return $boolSaved;
|
||||
}
|
||||
|
||||
protected function makeSite(string $strSource)
|
||||
{
|
||||
return match ($strSource) {
|
||||
'douban' => new DoubanSite(),
|
||||
'youzhi' => new YouzhiSite(),
|
||||
default => throw new \InvalidArgumentException('不支持的源站:' . $strSource),
|
||||
};
|
||||
}
|
||||
|
||||
protected function buildSearchUri(string $strSource, string $strKeyword): string
|
||||
{
|
||||
$strKeyword = urlencode($strKeyword);
|
||||
|
||||
return match ($strSource) {
|
||||
'douban' => '/api.php/provide/vod/at/josn?ac=list&wd=' . $strKeyword,
|
||||
'youzhi' => '/inc/api_mac10.php?ac=list&wd=' . $strKeyword,
|
||||
default => throw new \InvalidArgumentException('不支持的源站:' . $strSource),
|
||||
};
|
||||
}
|
||||
|
||||
protected function parseIntList(string $strValue): array
|
||||
{
|
||||
return array_values(array_filter(array_map(static function (string $strItem): int {
|
||||
return (int)trim($strItem);
|
||||
}, explode(',', $strValue)), static function (int $intValue): bool {
|
||||
return $intValue > 0;
|
||||
}));
|
||||
}
|
||||
|
||||
protected function parseSourceList(string $strValue): array
|
||||
{
|
||||
$arrAllowed = ['douban', 'youzhi'];
|
||||
|
||||
return array_values(array_filter(array_unique(array_map(static function (string $strItem): string {
|
||||
return trim($strItem);
|
||||
}, explode(',', $strValue))), static function (string $strItem) use ($arrAllowed): bool {
|
||||
return in_array($strItem, $arrAllowed, true);
|
||||
}));
|
||||
}
|
||||
|
||||
protected function normalizeTitle(string $strValue): string
|
||||
{
|
||||
$strValue = trim(mb_strtolower($strValue));
|
||||
if ($strValue === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return preg_replace('/[\s\p{P}\p{S}]+/u', '', $strValue) ?? $strValue;
|
||||
}
|
||||
|
||||
protected function buildMarkdown(array $arrSummary): string
|
||||
{
|
||||
$arrLines = [];
|
||||
$arrLines[] = '# 视频元数据定向重采执行结果';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? '');
|
||||
$arrLines[] = '- 模式:' . (string)($arrSummary['mode'] ?? '');
|
||||
$arrLines[] = '- Dry Run:' . (!empty($arrSummary['dry_run']) ? 'yes' : 'no');
|
||||
$arrLines[] = '- 处理条数:' . (int)($arrSummary['attempted_videos'] ?? 0);
|
||||
$arrLines[] = '- 演员补齐条数:' . (int)($arrSummary['success_actor_count'] ?? 0);
|
||||
$arrLines[] = '- 导演补齐条数:' . (int)($arrSummary['success_director_count'] ?? 0);
|
||||
$arrLines[] = '- 双字段都补齐:' . (int)($arrSummary['success_both_count'] ?? 0);
|
||||
$arrLines[] = '- 双字段都未补齐:' . (int)($arrSummary['failed_both_count'] ?? 0);
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '| v_id | 片名 | actor补齐 | director补齐 | 源站执行摘要 |';
|
||||
$arrLines[] = '| ---: | --- | --- | --- | --- |';
|
||||
|
||||
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
|
||||
$arrSourceSummary = array_map(static function (array $arrRun): string {
|
||||
return sprintf(
|
||||
'%s:%s%s',
|
||||
(string)($arrRun['source'] ?? ''),
|
||||
(string)($arrRun['status'] ?? ''),
|
||||
!empty($arrRun['matched_vod_id']) ? '#' . (int)$arrRun['matched_vod_id'] : ''
|
||||
);
|
||||
}, (array)($arrItem['source_runs'] ?? []));
|
||||
|
||||
$arrLines[] = sprintf(
|
||||
'| %d | %s | %s | %s | %s |',
|
||||
(int)($arrItem['v_id'] ?? 0),
|
||||
$this->escapeMarkdown((string)($arrItem['v_name'] ?? '')),
|
||||
!empty($arrItem['actor_filled']) ? 'yes' : 'no',
|
||||
!empty($arrItem['director_filled']) ? 'yes' : 'no',
|
||||
$this->escapeMarkdown(implode(' / ', $arrSourceSummary))
|
||||
);
|
||||
}
|
||||
|
||||
$arrLines[] = '';
|
||||
return implode(PHP_EOL, $arrLines) . PHP_EOL;
|
||||
}
|
||||
|
||||
protected function escapeMarkdown(string $strValue): string
|
||||
{
|
||||
return str_replace('|', '\\|', trim($strValue));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
51
code/app/common/helper/DomainSitemapGenerationHelper.php
Normal file
51
code/app/common/helper/DomainSitemapGenerationHelper.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\DomainModel;
|
||||
use app\task\logic\VideoSiteMapLogic;
|
||||
use microserver\QueueManage;
|
||||
|
||||
class DomainSitemapGenerationHelper
|
||||
{
|
||||
public static function normalizeDomains(array $arrDomains): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn($mValue): string => 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
196
code/app/common/helper/SeoCopyAiRuleConfigHelper.php
Normal file
196
code/app/common/helper/SeoCopyAiRuleConfigHelper.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\SystemConfigModel;
|
||||
|
||||
class SeoCopyAiRuleConfigHelper
|
||||
{
|
||||
protected const DEFINITIONS = [
|
||||
'SEO_COPY_AI_RULE_HIGHEST' => [
|
||||
'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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,9 @@ class SeoCopyBatchPrepareHelper
|
||||
'- 文风要自然、克制、站内导航型,避免“全网最全”“免费观看”等夸张承诺。',
|
||||
'- 输出必须能直接写入目标 JSON 文件。',
|
||||
'',
|
||||
'## Highest Rules',
|
||||
SeoCopyAiProviderHelper::buildSharedAiRuleBlock(),
|
||||
'',
|
||||
'## Scene Rules',
|
||||
(string)$strRulesJson,
|
||||
'',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 增强。'
|
||||
|
||||
162
code/app/common/helper/VideoMetadataCopyFillHelper.php
Normal file
162
code/app/common/helper/VideoMetadataCopyFillHelper.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class VideoMetadataCopyFillHelper
|
||||
{
|
||||
public static function buildFillPayload(array $arrVideo, string $strSource = 'local_copy_fill'): array
|
||||
{
|
||||
$strDescription = trim((string)($arrVideo['v_description'] ?? ''));
|
||||
$strRemarks = trim((string)($arrVideo['v_remarks'] ?? ''));
|
||||
|
||||
$arrUpdate = [];
|
||||
$arrFilledFields = [];
|
||||
|
||||
if ($strDescription === '') {
|
||||
$strGeneratedDescription = self::buildDescription($arrVideo);
|
||||
if ($strGeneratedDescription !== '') {
|
||||
$arrUpdate['v_description'] = $strGeneratedDescription;
|
||||
$arrFilledFields[] = 'v_description';
|
||||
}
|
||||
}
|
||||
|
||||
if ($strRemarks === '') {
|
||||
$strGeneratedRemarks = self::buildRemarks($arrVideo);
|
||||
if ($strGeneratedRemarks !== '') {
|
||||
$arrUpdate['v_remarks'] = $strGeneratedRemarks;
|
||||
$arrFilledFields[] = 'v_remarks';
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($arrFilledFields)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrUpdate['updated_at'] = new \MongoDB\BSON\UTCDateTime();
|
||||
$arrUpdate['v_metadata_fill'] = [
|
||||
'last_source' => $strSource,
|
||||
'updated_at' => new \MongoDB\BSON\UTCDateTime(),
|
||||
'filled_fields' => $arrFilledFields,
|
||||
];
|
||||
|
||||
return $arrUpdate;
|
||||
}
|
||||
|
||||
public 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));
|
||||
}
|
||||
}
|
||||
62
code/app/common/helper/VideoMetadataMissingRefreshHelper.php
Normal file
62
code/app/common/helper/VideoMetadataMissingRefreshHelper.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class VideoMetadataMissingRefreshHelper
|
||||
{
|
||||
public static function refresh(array $arrOptions = []): array
|
||||
{
|
||||
$intSample = max(1, min(100, (int)($arrOptions['sample'] ?? 20)));
|
||||
$intQueueLimit = max(1, min(500, (int)($arrOptions['queue_limit'] ?? 100)));
|
||||
$intPromptLimit = max(1, min(100, (int)($arrOptions['prompt_limit'] ?? 20)));
|
||||
$intBatchSize = max(5, min(100, (int)($arrOptions['batch_size'] ?? 20)));
|
||||
$intBatchLimit = max(1, min(50, (int)($arrOptions['batch_limit'] ?? 10)));
|
||||
|
||||
$strPublicRoot = rtrim((string)root_path(), '/') . '/app/public';
|
||||
$strWorkbenchRoot = $strPublicRoot . '/_admin_templates/video-metadata-missing-workbench';
|
||||
$strTaskPoolRoot = $strPublicRoot . '/_admin_templates/video-metadata-missing-task-pool';
|
||||
|
||||
$arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::buildSummary(
|
||||
$intSample,
|
||||
$intQueueLimit,
|
||||
$intPromptLimit
|
||||
);
|
||||
$arrWorkbenchSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts(
|
||||
$strWorkbenchRoot,
|
||||
$arrWorkbenchSummary
|
||||
);
|
||||
|
||||
$arrTaskPoolSummary = VideoMetadataMissingTaskPoolHelper::buildSummary(
|
||||
$arrWorkbenchSummary,
|
||||
$intBatchSize,
|
||||
$intBatchLimit
|
||||
);
|
||||
$arrTaskPoolSummary = VideoMetadataMissingTaskPoolHelper::writeArtifacts(
|
||||
$strTaskPoolRoot,
|
||||
$arrTaskPoolSummary
|
||||
);
|
||||
$arrTaskPoolStatusSummary = VideoMetadataMissingTaskPoolStatusHelper::buildSummary(
|
||||
$strWorkbenchRoot,
|
||||
$strTaskPoolRoot
|
||||
);
|
||||
$arrTaskPoolStatusSummary = VideoMetadataMissingTaskPoolStatusHelper::writeArtifacts(
|
||||
$strTaskPoolRoot,
|
||||
$arrTaskPoolStatusSummary
|
||||
);
|
||||
|
||||
return [
|
||||
'options' => [
|
||||
'sample' => $intSample,
|
||||
'queue_limit' => $intQueueLimit,
|
||||
'prompt_limit' => $intPromptLimit,
|
||||
'batch_size' => $intBatchSize,
|
||||
'batch_limit' => $intBatchLimit,
|
||||
],
|
||||
'workbench' => $arrWorkbenchSummary,
|
||||
'task_pool' => $arrTaskPoolSummary,
|
||||
'task_pool_status' => $arrTaskPoolStatusSummary,
|
||||
];
|
||||
}
|
||||
}
|
||||
450
code/app/common/helper/VideoMetadataMissingTaskPoolHelper.php
Normal file
450
code/app/common/helper/VideoMetadataMissingTaskPoolHelper.php
Normal file
@@ -0,0 +1,450 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class VideoMetadataMissingTaskPoolHelper
|
||||
{
|
||||
public static function buildSummary(array $arrWorkbenchSummary, int $intBatchSize = 20, int $intBatchLimit = 10): array
|
||||
{
|
||||
$intBatchSize = max(5, min($intBatchSize, 100));
|
||||
$intBatchLimit = max(1, min($intBatchLimit, 50));
|
||||
|
||||
$arrAudit = (array)($arrWorkbenchSummary['audit'] ?? []);
|
||||
$arrQueue = (array)($arrWorkbenchSummary['queue'] ?? []);
|
||||
$arrItems = (array)($arrQueue['items'] ?? []);
|
||||
$arrFieldStats = (array)($arrAudit['field_stats'] ?? []);
|
||||
usort($arrFieldStats, static function (array $arrA, array $arrB): int {
|
||||
return (int)($arrB['missing_count'] ?? 0) <=> (int)($arrA['missing_count'] ?? 0);
|
||||
});
|
||||
|
||||
$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 .= '<tr>'
|
||||
. '<td>' . htmlspecialchars((string)($arrBatch['batch_id'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($arrBatch['batch_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($arrBatch['focus_field'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($arrBatch['priority_level'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($arrBatch['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($arrBatch['owner'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . (int)($arrBatch['history_count'] ?? 0) . '</td>'
|
||||
. '<td>' . (int)($arrBatch['items_count'] ?? 0) . '</td>'
|
||||
. '<td>'
|
||||
. '<button type="button" class="btn" data-copy-prompt="' . htmlspecialchars((string)($arrBatch['codex_prompt'] ?? ''), ENT_QUOTES, 'UTF-8') . '">复制提示词</button> '
|
||||
. ($strDetailPath !== '' ? '<a class="btn link" href="' . $strDetailPath . '" target="_blank">详情JSON</a> ' : '')
|
||||
. ($strPromptPath !== '' ? '<a class="btn link" href="' . $strPromptPath . '" target="_blank">提示词MD</a> ' : '')
|
||||
. ($strHistoryPath !== '' ? '<a class="btn link" href="' . $strHistoryPath . '" target="_blank">历史JSON</a>' : '')
|
||||
. '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
$strCards = '';
|
||||
foreach ($arrBatches as $arrBatch) {
|
||||
$strPromptPath = htmlspecialchars((string)($arrBatch['prompt_markdown_path'] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$strDetailPath = htmlspecialchars((string)($arrBatch['detail_json_path'] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$strHistoryPath = htmlspecialchars((string)($arrBatch['history_json_path'] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$strCards .= '<div class="batch-card">'
|
||||
. '<h3>' . htmlspecialchars((string)($arrBatch['batch_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</h3>'
|
||||
. '<p><strong>批次ID:</strong>' . htmlspecialchars((string)($arrBatch['batch_id'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
|
||||
. '<p><strong>重点字段:</strong>' . htmlspecialchars((string)($arrBatch['focus_field'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
|
||||
. '<p><strong>优先级:</strong>' . htmlspecialchars((string)($arrBatch['priority_level'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
|
||||
. '<p><strong>状态:</strong>' . htmlspecialchars((string)($arrBatch['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
|
||||
. '<p><strong>负责人:</strong>' . htmlspecialchars((string)($arrBatch['owner'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
|
||||
. '<p><strong>历史次数:</strong>' . (int)($arrBatch['history_count'] ?? 0) . '</p>'
|
||||
. '<p><strong>数量:</strong>' . (int)($arrBatch['items_count'] ?? 0) . '</p>'
|
||||
. '<p class="actions">'
|
||||
. '<button type="button" class="btn" data-copy-prompt="' . htmlspecialchars((string)($arrBatch['codex_prompt'] ?? ''), ENT_QUOTES, 'UTF-8') . '">复制提示词</button> '
|
||||
. ($strDetailPath !== '' ? '<a class="btn link" href="' . $strDetailPath . '" target="_blank">详情JSON</a> ' : '')
|
||||
. ($strPromptPath !== '' ? '<a class="btn link" href="' . $strPromptPath . '" target="_blank">提示词MD</a> ' : '')
|
||||
. ($strHistoryPath !== '' ? '<a class="btn link" href="' . $strHistoryPath . '" target="_blank">历史JSON</a>' : '')
|
||||
. '</p>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
return '<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>视频缺字段待处理池</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f8fb;color:#1f2937;margin:0;padding:24px;}
|
||||
.wrap{max-width:1200px;margin:0 auto;}
|
||||
.card{background:#fff;border-radius:16px;box-shadow:0 8px 24px rgba(15,23,42,.06);padding:20px;margin-bottom:20px;}
|
||||
.cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}
|
||||
.batch-card{border:1px solid #e5e7eb;border-radius:12px;padding:16px;background:#f8fafc;}
|
||||
.batch-card h3{margin:0 0 10px;}
|
||||
.batch-card p{margin:6px 0;font-size:14px;}
|
||||
.actions{margin-top:12px;}
|
||||
.btn{display:inline-block;border:1px solid #cbd5e1;background:#fff;color:#0f172a;border-radius:8px;padding:6px 10px;font-size:13px;text-decoration:none;cursor:pointer;}
|
||||
.btn.link{cursor:pointer;}
|
||||
.toolbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:12px;}
|
||||
.toolbar .msg{font-size:13px;color:#475569;}
|
||||
table{width:100%;border-collapse:collapse;}
|
||||
th,td{border-bottom:1px solid #e5e7eb;padding:10px;text-align:left;vertical-align:top;font-size:14px;}
|
||||
th{background:#f8fafc;}
|
||||
@media (max-width: 900px){.cards{grid-template-columns:1fr;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>视频缺字段待处理池</h1>
|
||||
<p>生成时间:' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>
|
||||
<p>说明:当前先做产物型任务池,不自动补事实字段,不直接入库。</p>
|
||||
<div class="toolbar"><span class="msg" id="copy-status">点击“复制提示词”后,可直接粘贴到 Codex 窗口。</span></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>批次卡片</h2>
|
||||
<div class="cards">' . $strCards . '</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>批次ID</th><th>批次名称</th><th>重点字段</th><th>优先级</th><th>状态</th><th>负责人</th><th>历史</th><th>数量</th><th>动作</th></tr></thead>
|
||||
<tbody>' . $strRows . '</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll("[data-copy-prompt]").forEach(function (btn) {
|
||||
btn.addEventListener("click", async function () {
|
||||
var text = btn.getAttribute("data-copy-prompt") || "";
|
||||
var status = document.getElementById("copy-status");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (status) status.textContent = "提示词已复制,可以直接粘贴给 Codex。";
|
||||
} catch (err) {
|
||||
if (status) status.textContent = "当前浏览器未允许剪贴板复制,请改为打开提示词 MD 手动复制。";
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>';
|
||||
}
|
||||
|
||||
protected static function writeRunArtifacts(string $strOutputRoot, array $arrSummary): void
|
||||
{
|
||||
$strRunId = trim((string)($arrSummary['run_id'] ?? ''));
|
||||
if ($strRunId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$strDay = substr((string)($arrSummary['generated_at'] ?? date('c')), 0, 10);
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $strDay)) {
|
||||
$strDay = date('Y-m-d');
|
||||
}
|
||||
|
||||
$strRunRoot = $strOutputRoot . '/runs/' . $strDay . '/' . $strRunId;
|
||||
self::writeFile(
|
||||
$strRunRoot . '/video-metadata-missing-task-pool.summary.json',
|
||||
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile(
|
||||
$strRunRoot . '/video-metadata-missing-task-pool.summary.html',
|
||||
self::renderHtml($arrSummary)
|
||||
);
|
||||
|
||||
foreach ((array)($arrSummary['batches'] ?? []) as $arrBatch) {
|
||||
$strBatchId = trim((string)($arrBatch['batch_id'] ?? ''));
|
||||
if ($strBatchId === '') {
|
||||
continue;
|
||||
}
|
||||
self::writeFile($strRunRoot . '/batches/' . $strBatchId . '.md', (string)($arrBatch['codex_prompt'] ?? ''));
|
||||
self::writeFile(
|
||||
$strRunRoot . '/batches/' . $strBatchId . '.json',
|
||||
json_encode($arrBatch, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function writeLatestBatchArtifacts(string $strOutputRoot, array $arrSummary): array
|
||||
{
|
||||
foreach ((array)($arrSummary['batches'] ?? []) as $intIndex => $arrBatch) {
|
||||
if (!is_array($arrBatch)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strBatchId = trim((string)($arrBatch['batch_id'] ?? ''));
|
||||
if ($strBatchId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strDetailPath = $strOutputRoot . '/batches/' . $strBatchId . '.json';
|
||||
$strPromptPath = $strOutputRoot . '/batches/' . $strBatchId . '.md';
|
||||
$strHistoryPath = $strOutputRoot . '/state/history/' . $strBatchId . '.json';
|
||||
|
||||
self::writeFile(
|
||||
$strDetailPath,
|
||||
json_encode($arrBatch, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile($strPromptPath, (string)($arrBatch['codex_prompt'] ?? ''));
|
||||
|
||||
$arrSummary['batches'][$intIndex]['detail_json_path'] = self::publicRelativePath($strDetailPath);
|
||||
$arrSummary['batches'][$intIndex]['prompt_markdown_path'] = self::publicRelativePath($strPromptPath);
|
||||
$arrSummary['batches'][$intIndex]['history_json_path'] = is_file($strHistoryPath)
|
||||
? self::publicRelativePath($strHistoryPath)
|
||||
: self::publicRelativePath($strHistoryPath);
|
||||
}
|
||||
|
||||
return $arrSummary;
|
||||
}
|
||||
|
||||
protected static function writeFile(string $strPath, string $strContent): void
|
||||
{
|
||||
$strDir = dirname($strPath);
|
||||
if (!is_dir($strDir)) {
|
||||
@mkdir($strDir, 0777, true);
|
||||
}
|
||||
file_put_contents($strPath, $strContent);
|
||||
}
|
||||
|
||||
protected static function buildRunId(): string
|
||||
{
|
||||
return 'video-metadata-missing-task-pool-' . date('Ymd-His');
|
||||
}
|
||||
|
||||
protected static function publicRelativePath(string $strPath): string
|
||||
{
|
||||
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 2) . '/public', '/'));
|
||||
$strPath = str_replace('\\', '/', $strPath);
|
||||
if (str_starts_with($strPath, $strPublicRoot . '/')) {
|
||||
return substr($strPath, strlen($strPublicRoot . '/'));
|
||||
}
|
||||
|
||||
return $strPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class VideoMetadataMissingTaskPoolStateHelper
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_DISPATCHED = 'dispatched';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_DONE = 'done';
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
public static function readStateMap(string $strOutputRoot): array
|
||||
{
|
||||
$strPath = self::stateFilePath($strOutputRoot);
|
||||
if (!is_file($strPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrData = json_decode((string)file_get_contents($strPath), true);
|
||||
if (!is_array($arrData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (array)($arrData['batches'] ?? []);
|
||||
}
|
||||
|
||||
public static function saveBatchState(
|
||||
string $strOutputRoot,
|
||||
string $strBatchId,
|
||||
string $strStatus,
|
||||
string $strOwner = '',
|
||||
string $strNote = ''
|
||||
): array {
|
||||
$strBatchId = trim($strBatchId);
|
||||
if ($strBatchId === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$strStatus = self::normalizeStatus($strStatus);
|
||||
$arrMap = self::readStateMap($strOutputRoot);
|
||||
$arrMap[$strBatchId] = [
|
||||
'batch_id' => $strBatchId,
|
||||
'status' => $strStatus,
|
||||
'owner' => trim($strOwner),
|
||||
'note' => trim($strNote),
|
||||
'updated_at' => self::nowIso8601WithMicroseconds(),
|
||||
];
|
||||
|
||||
self::writeStateMap($strOutputRoot, $arrMap);
|
||||
self::appendBatchHistory($strOutputRoot, $strBatchId, $arrMap[$strBatchId]);
|
||||
return $arrMap[$strBatchId];
|
||||
}
|
||||
|
||||
public static function readBatchHistory(string $strOutputRoot, string $strBatchId, int $intLimit = 50): array
|
||||
{
|
||||
$strBatchId = trim($strBatchId);
|
||||
if ($strBatchId === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$strPath = self::historyFilePath($strOutputRoot, $strBatchId);
|
||||
if (!is_file($strPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrData = json_decode((string)file_get_contents($strPath), true);
|
||||
if (!is_array($arrData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrItems = array_values((array)($arrData['items'] ?? []));
|
||||
usort($arrItems, static function (array $arrA, array $arrB): int {
|
||||
return strcmp((string)($arrB['updated_at'] ?? ''), (string)($arrA['updated_at'] ?? ''));
|
||||
});
|
||||
|
||||
return array_slice($arrItems, 0, max(1, min($intLimit, 200)));
|
||||
}
|
||||
|
||||
public static function applyStateMap(array $arrSummary, array $arrStateMap, string $strOutputRoot = ''): array
|
||||
{
|
||||
$arrBatches = (array)($arrSummary['batches'] ?? []);
|
||||
$arrStatusBuckets = [];
|
||||
$strOutputRoot = rtrim(str_replace('\\', '/', $strOutputRoot), '/');
|
||||
|
||||
foreach ($arrBatches as &$arrBatch) {
|
||||
$strBatchId = trim((string)($arrBatch['batch_id'] ?? ''));
|
||||
$arrState = (array)($arrStateMap[$strBatchId] ?? []);
|
||||
$strStatus = self::normalizeStatus((string)($arrState['status'] ?? self::STATUS_PENDING));
|
||||
$arrBatch['status'] = $strStatus;
|
||||
$arrBatch['status_label'] = self::statusLabel($strStatus);
|
||||
$arrBatch['owner'] = trim((string)($arrState['owner'] ?? ''));
|
||||
$arrBatch['note'] = trim((string)($arrState['note'] ?? ''));
|
||||
$arrBatch['state_updated_at'] = trim((string)($arrState['updated_at'] ?? ''));
|
||||
$arrBatch['history_count'] = $strOutputRoot !== ''
|
||||
? count(self::readBatchHistory($strOutputRoot, $strBatchId, 200))
|
||||
: 0;
|
||||
$arrStatusBuckets[$strStatus] = (int)($arrStatusBuckets[$strStatus] ?? 0) + 1;
|
||||
}
|
||||
unset($arrBatch);
|
||||
|
||||
$arrSummary['batches'] = $arrBatches;
|
||||
$arrSummary['status_buckets'] = $arrStatusBuckets;
|
||||
|
||||
$arrTodoSummary = [];
|
||||
foreach ($arrBatches as $arrBatch) {
|
||||
$arrTodoSummary[] = [
|
||||
'batch_id' => (string)($arrBatch['batch_id'] ?? ''),
|
||||
'label' => (string)($arrBatch['batch_label'] ?? ''),
|
||||
'focus_field' => (string)($arrBatch['focus_field'] ?? ''),
|
||||
'items_count' => (int)($arrBatch['items_count'] ?? 0),
|
||||
'priority_level' => (string)($arrBatch['priority_level'] ?? ''),
|
||||
'status' => (string)($arrBatch['status'] ?? self::STATUS_PENDING),
|
||||
'status_label' => (string)($arrBatch['status_label'] ?? self::statusLabel(self::STATUS_PENDING)),
|
||||
'owner' => (string)($arrBatch['owner'] ?? ''),
|
||||
];
|
||||
}
|
||||
$arrSummary['todo_summary'] = $arrTodoSummary;
|
||||
|
||||
return $arrSummary;
|
||||
}
|
||||
|
||||
public static function statusLabel(string $strStatus): string
|
||||
{
|
||||
return match (self::normalizeStatus($strStatus)) {
|
||||
self::STATUS_PENDING => '待处理',
|
||||
self::STATUS_DISPATCHED => '已派单',
|
||||
self::STATUS_PROCESSING => '处理中',
|
||||
self::STATUS_DONE => '已完成',
|
||||
self::STATUS_SKIPPED => '已跳过',
|
||||
default => '待处理',
|
||||
};
|
||||
}
|
||||
|
||||
protected static function normalizeStatus(string $strStatus): string
|
||||
{
|
||||
$strStatus = strtolower(trim($strStatus));
|
||||
$arrAllowed = [
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_DISPATCHED,
|
||||
self::STATUS_PROCESSING,
|
||||
self::STATUS_DONE,
|
||||
self::STATUS_SKIPPED,
|
||||
];
|
||||
|
||||
return in_array($strStatus, $arrAllowed, true) ? $strStatus : self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
protected static function stateFilePath(string $strOutputRoot): string
|
||||
{
|
||||
return rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/state/batch-status.json';
|
||||
}
|
||||
|
||||
protected static function writeStateMap(string $strOutputRoot, array $arrMap): void
|
||||
{
|
||||
$strPath = self::stateFilePath($strOutputRoot);
|
||||
$strDir = dirname($strPath);
|
||||
if (!is_dir($strDir)) {
|
||||
@mkdir($strDir, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($strPath, json_encode([
|
||||
'updated_at' => self::nowIso8601WithMicroseconds(),
|
||||
'batches' => $arrMap,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
}
|
||||
|
||||
protected static function appendBatchHistory(string $strOutputRoot, string $strBatchId, array $arrState): void
|
||||
{
|
||||
$strPath = self::historyFilePath($strOutputRoot, $strBatchId);
|
||||
$strDir = dirname($strPath);
|
||||
if (!is_dir($strDir)) {
|
||||
@mkdir($strDir, 0777, true);
|
||||
}
|
||||
|
||||
$arrData = [];
|
||||
if (is_file($strPath)) {
|
||||
$arrDecoded = json_decode((string)file_get_contents($strPath), true);
|
||||
if (is_array($arrDecoded)) {
|
||||
$arrData = $arrDecoded;
|
||||
}
|
||||
}
|
||||
|
||||
$arrItems = array_values((array)($arrData['items'] ?? []));
|
||||
$arrItems[] = [
|
||||
'batch_id' => $strBatchId,
|
||||
'status' => (string)($arrState['status'] ?? self::STATUS_PENDING),
|
||||
'status_label' => self::statusLabel((string)($arrState['status'] ?? self::STATUS_PENDING)),
|
||||
'owner' => (string)($arrState['owner'] ?? ''),
|
||||
'note' => (string)($arrState['note'] ?? ''),
|
||||
'updated_at' => (string)($arrState['updated_at'] ?? self::nowIso8601WithMicroseconds()),
|
||||
];
|
||||
|
||||
file_put_contents($strPath, json_encode([
|
||||
'updated_at' => self::nowIso8601WithMicroseconds(),
|
||||
'batch_id' => $strBatchId,
|
||||
'items' => $arrItems,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
}
|
||||
|
||||
protected static function historyFilePath(string $strOutputRoot, string $strBatchId): string
|
||||
{
|
||||
return rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/state/history/' . $strBatchId . '.json';
|
||||
}
|
||||
|
||||
protected static function nowIso8601WithMicroseconds(): string
|
||||
{
|
||||
$floatNow = microtime(true);
|
||||
$intNow = (int)$floatNow;
|
||||
$intMicro = (int)(($floatNow - $intNow) * 1000000);
|
||||
|
||||
return sprintf('%s.%06d%s', date('Y-m-d\\TH:i:s', $intNow), $intMicro, date('P', $intNow));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\PlanTaskModel;
|
||||
|
||||
class VideoMetadataMissingTaskPoolStatusHelper
|
||||
{
|
||||
public static function buildSummary(string $strWorkbenchRoot, string $strTaskPoolRoot): array
|
||||
{
|
||||
$arrWorkbench = VideoMetadataMissingWorkbenchHelper::readLatestSummary($strWorkbenchRoot);
|
||||
$arrTaskPool = VideoMetadataMissingTaskPoolHelper::readLatestSummary($strTaskPoolRoot);
|
||||
$arrBatchSummary = self::buildBatchSummary((array)($arrTaskPool['batches'] ?? []));
|
||||
$arrPlanTask = self::buildPlanTaskStatus();
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'workbench' => [
|
||||
'exists' => !empty($arrWorkbench),
|
||||
'generated_at' => (string)($arrWorkbench['generated_at'] ?? ''),
|
||||
'videos_with_any_missing_metadata' => (int)(($arrWorkbench['audit'] ?? [])['videos_with_any_missing_metadata'] ?? 0),
|
||||
'queue_size' => (int)(($arrWorkbench['queue'] ?? [])['queue_size'] ?? 0),
|
||||
'summary_html_path' => (string)($arrWorkbench['summary_html_path'] ?? ''),
|
||||
'summary_json_path' => (string)($arrWorkbench['summary_json_path'] ?? ''),
|
||||
],
|
||||
'task_pool' => [
|
||||
'exists' => !empty($arrTaskPool),
|
||||
'generated_at' => (string)($arrTaskPool['generated_at'] ?? ''),
|
||||
'batch_count' => count((array)($arrTaskPool['batches'] ?? [])),
|
||||
'todo_summary_count' => count((array)($arrTaskPool['todo_summary'] ?? [])),
|
||||
'summary_html_path' => (string)($arrTaskPool['summary_html_path'] ?? ''),
|
||||
'summary_json_path' => (string)($arrTaskPool['summary_json_path'] ?? ''),
|
||||
'batch_status_summary' => $arrBatchSummary,
|
||||
],
|
||||
'plan_task' => $arrPlanTask,
|
||||
'operator_hint' => self::buildOperatorHint($arrWorkbench, $arrTaskPool, $arrPlanTask),
|
||||
];
|
||||
}
|
||||
|
||||
public static function writeArtifacts(string $strTaskPoolRoot, array $arrSummary): array
|
||||
{
|
||||
$strTaskPoolRoot = rtrim(str_replace('\\', '/', $strTaskPoolRoot), '/');
|
||||
$strStatusRoot = $strTaskPoolRoot . '/status';
|
||||
$strOpsRoot = $strTaskPoolRoot . '/ops';
|
||||
$arrSummary['summary_json_path'] = $strStatusRoot . '/index.json';
|
||||
$arrSummary['summary_html_path'] = $strStatusRoot . '/index.html';
|
||||
$arrSummary['ops_json_path'] = $strOpsRoot . '/index.json';
|
||||
$arrSummary['ops_html_path'] = $strOpsRoot . '/index.html';
|
||||
|
||||
self::writeFile(
|
||||
$arrSummary['summary_json_path'],
|
||||
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile($arrSummary['summary_html_path'], self::renderHtml($arrSummary));
|
||||
self::writeFile(
|
||||
$arrSummary['ops_json_path'],
|
||||
json_encode(self::buildOpsSummary($arrSummary), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile($arrSummary['ops_html_path'], self::renderOpsHtml($arrSummary));
|
||||
|
||||
return $arrSummary;
|
||||
}
|
||||
|
||||
protected static function buildBatchSummary(array $arrBatches): array
|
||||
{
|
||||
$arrCounts = [
|
||||
'pending' => 0,
|
||||
'dispatched' => 0,
|
||||
'processing' => 0,
|
||||
'done' => 0,
|
||||
'skipped' => 0,
|
||||
];
|
||||
|
||||
foreach ($arrBatches as $arrBatch) {
|
||||
$strStatus = (string)($arrBatch['status'] ?? VideoMetadataMissingTaskPoolStateHelper::STATUS_PENDING);
|
||||
if (!array_key_exists($strStatus, $arrCounts)) {
|
||||
$arrCounts[$strStatus] = 0;
|
||||
}
|
||||
$arrCounts[$strStatus]++;
|
||||
}
|
||||
|
||||
return $arrCounts;
|
||||
}
|
||||
|
||||
protected static function buildPlanTaskStatus(): array
|
||||
{
|
||||
$strTaskCode = 'REFRESH_VIDEO_METADATA_TASK_POOL';
|
||||
$strDbError = '';
|
||||
|
||||
try {
|
||||
$PlanTaskModel = PlanTaskModel::where('pt_code', $strTaskCode)->find();
|
||||
} catch (\Throwable $Throwable) {
|
||||
$PlanTaskModel = null;
|
||||
$strDbError = $Throwable->getMessage();
|
||||
}
|
||||
|
||||
return [
|
||||
'task_code' => $strTaskCode,
|
||||
'db_ready' => $strDbError === '',
|
||||
'db_error' => $strDbError,
|
||||
'task_exists' => $PlanTaskModel instanceof PlanTaskModel,
|
||||
'pt_id' => (int)($PlanTaskModel->pt_id ?? 0),
|
||||
'pt_name' => (string)($PlanTaskModel->pt_name ?? '视频缺字段任务池刷新'),
|
||||
'pt_enable' => (int)($PlanTaskModel->pt_enable ?? 0),
|
||||
'pt_limit' => (int)($PlanTaskModel->pt_limit ?? 0),
|
||||
'pt_last_exec' => !empty($PlanTaskModel->pt_last_exec) ? date(DATE_ATOM, (int)$PlanTaskModel->pt_last_exec) : '',
|
||||
'status_label' => $strDbError !== ''
|
||||
? 'db_blocked'
|
||||
: (($PlanTaskModel instanceof PlanTaskModel)
|
||||
? ((int)($PlanTaskModel->pt_enable ?? 0) === 1 ? 'enabled' : 'disabled')
|
||||
: 'missing'),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildOperatorHint(array $arrWorkbench, array $arrTaskPool, array $arrPlanTask): array
|
||||
{
|
||||
$arrHint = [];
|
||||
|
||||
if (empty($arrWorkbench)) {
|
||||
$arrHint[] = '当前还没有工作台产物,建议先手动执行 video:metadata:task-pool 生成一轮。';
|
||||
}
|
||||
|
||||
if (!empty($arrWorkbench) && empty($arrTaskPool)) {
|
||||
$arrHint[] = '当前已有工作台,但还没有任务池产物,建议再执行一次 task-pool 刷新命令。';
|
||||
}
|
||||
|
||||
if (($arrPlanTask['status_label'] ?? '') === 'missing') {
|
||||
$arrHint[] = '如果要接入数据库计划任务,请新增 pt_code=REFRESH_VIDEO_METADATA_TASK_POOL。';
|
||||
}
|
||||
|
||||
if (($arrPlanTask['status_label'] ?? '') === 'disabled') {
|
||||
$arrHint[] = '当前数据库计划任务已存在但未启用,启用前建议先保持较大执行间隔。';
|
||||
}
|
||||
|
||||
if (($arrPlanTask['status_label'] ?? '') === 'enabled') {
|
||||
$arrHint[] = '当前数据库计划任务已启用;这条任务只刷新产物,不自动补事实字段。';
|
||||
}
|
||||
|
||||
if (($arrPlanTask['status_label'] ?? '') === 'db_blocked') {
|
||||
$arrHint[] = '当前脚本环境拿不到数据库任务表配置,因此这里只能返回任务池现状;如果在正式后台环境调用,一般会恢复数据库计划任务状态识别。';
|
||||
}
|
||||
|
||||
if (empty($arrHint)) {
|
||||
$arrHint[] = '当前工作台、任务池、计划任务状态都已具备,下一步可以做后台入口卡片或操作面板。';
|
||||
}
|
||||
|
||||
return $arrHint;
|
||||
}
|
||||
|
||||
protected static function renderHtml(array $arrSummary): string
|
||||
{
|
||||
$arrWorkbench = (array)($arrSummary['workbench'] ?? []);
|
||||
$arrTaskPool = (array)($arrSummary['task_pool'] ?? []);
|
||||
$arrBatchStatus = (array)($arrTaskPool['batch_status_summary'] ?? []);
|
||||
$arrPlanTask = (array)($arrSummary['plan_task'] ?? []);
|
||||
$arrHints = (array)($arrSummary['operator_hint'] ?? []);
|
||||
|
||||
$strHintRows = '';
|
||||
foreach ($arrHints as $strHint) {
|
||||
$strHintRows .= '<li>' . htmlspecialchars((string)$strHint, ENT_QUOTES, 'UTF-8') . '</li>';
|
||||
}
|
||||
|
||||
$strBatchRows = '';
|
||||
foreach ($arrBatchStatus as $strStatus => $intCount) {
|
||||
$strBatchRows .= '<tr>'
|
||||
. '<td>' . htmlspecialchars((string)$strStatus, ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . (int)$intCount . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return '<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>视频缺字段任务池状态总览</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f8fb;color:#1f2937;margin:0;padding:24px;}
|
||||
.wrap{max-width:1180px;margin:0 auto;}
|
||||
.card{background:#fff;border-radius:16px;box-shadow:0 8px 24px rgba(15,23,42,.06);padding:20px;margin-bottom:20px;}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;}
|
||||
.stat{background:#f8fafc;border:1px solid #e5e7eb;border-radius:12px;padding:16px;}
|
||||
.stat .num{font-size:28px;font-weight:700;margin-top:6px;}
|
||||
table{width:100%;border-collapse:collapse;}
|
||||
th,td{border-bottom:1px solid #e5e7eb;padding:10px;text-align:left;vertical-align:top;font-size:14px;}
|
||||
th{background:#f8fafc;}
|
||||
ul{margin:0;padding-left:20px;}
|
||||
.path{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:13px;color:#475569;word-break:break-all;}
|
||||
@media (max-width: 960px){.grid{grid-template-columns:1fr 1fr;}}
|
||||
@media (max-width: 640px){.grid{grid-template-columns:1fr;} body{padding:14px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>视频缺字段任务池状态总览</h1>
|
||||
<p>生成时间:' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>
|
||||
<div class="grid">
|
||||
<div class="stat"><div>缺字段视频数</div><div class="num">' . (int)($arrWorkbench['videos_with_any_missing_metadata'] ?? 0) . '</div></div>
|
||||
<div class="stat"><div>重采优先队列</div><div class="num">' . (int)($arrWorkbench['queue_size'] ?? 0) . '</div></div>
|
||||
<div class="stat"><div>任务池批次数</div><div class="num">' . (int)($arrTaskPool['batch_count'] ?? 0) . '</div></div>
|
||||
<div class="stat"><div>计划任务状态</div><div class="num">' . htmlspecialchars((string)($arrPlanTask['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>工作台 / 任务池产物</h2>
|
||||
<table>
|
||||
<thead><tr><th>模块</th><th>生成时间</th><th>HTML</th><th>JSON</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>工作台</td>
|
||||
<td>' . htmlspecialchars((string)($arrWorkbench['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
|
||||
<td class="path">' . htmlspecialchars((string)($arrWorkbench['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
|
||||
<td class="path">' . htmlspecialchars((string)($arrWorkbench['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>任务池</td>
|
||||
<td>' . htmlspecialchars((string)($arrTaskPool['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
|
||||
<td class="path">' . htmlspecialchars((string)($arrTaskPool['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
|
||||
<td class="path">' . htmlspecialchars((string)($arrTaskPool['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>批次状态统计</h2>
|
||||
<table>
|
||||
<thead><tr><th>状态</th><th>数量</th></tr></thead>
|
||||
<tbody>' . $strBatchRows . '</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>计划任务状态</h2>
|
||||
<table>
|
||||
<thead><tr><th>字段</th><th>值</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>task_code</td><td>' . htmlspecialchars((string)($arrPlanTask['task_code'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td></tr>
|
||||
<tr><td>status_label</td><td>' . htmlspecialchars((string)($arrPlanTask['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td></tr>
|
||||
<tr><td>pt_enable</td><td>' . (int)($arrPlanTask['pt_enable'] ?? 0) . '</td></tr>
|
||||
<tr><td>pt_limit</td><td>' . (int)($arrPlanTask['pt_limit'] ?? 0) . '</td></tr>
|
||||
<tr><td>pt_last_exec</td><td>' . htmlspecialchars((string)($arrPlanTask['pt_last_exec'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td></tr>
|
||||
<tr><td>db_error</td><td>' . htmlspecialchars((string)($arrPlanTask['db_error'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>操作提示</h2>
|
||||
<ul>' . $strHintRows . '</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>';
|
||||
}
|
||||
|
||||
public static function buildOpsSummary(array $arrSummary): array
|
||||
{
|
||||
$arrPlanTask = (array)($arrSummary['plan_task'] ?? []);
|
||||
$arrWorkbench = (array)($arrSummary['workbench'] ?? []);
|
||||
$arrTaskPool = (array)($arrSummary['task_pool'] ?? []);
|
||||
|
||||
return [
|
||||
'generated_at' => (string)($arrSummary['generated_at'] ?? ''),
|
||||
'commands' => [
|
||||
[
|
||||
'label' => '刷新工作台 + 任务池',
|
||||
'command' => 'php SEONexus/code/think video:metadata:task-pool --sample=8 --queue-limit=12 --prompt-limit=6 --batch-size=10 --batch-limit=4',
|
||||
],
|
||||
[
|
||||
'label' => '补齐计划任务种子',
|
||||
'command' => 'php SEONexus/code/think plan:seed:video-metadata-missing',
|
||||
],
|
||||
[
|
||||
'label' => '查看任务池状态接口',
|
||||
'command' => 'GET /admin/video/metadata/missing/task-pool/status',
|
||||
],
|
||||
[
|
||||
'label' => '查看任务池运维接口',
|
||||
'command' => 'GET /admin/video/metadata/missing/task-pool/ops',
|
||||
],
|
||||
[
|
||||
'label' => '保存任务池计划任务状态',
|
||||
'command' => 'POST /admin/video/metadata/missing/task-pool/plan-task/status/save',
|
||||
],
|
||||
],
|
||||
'plan_task' => $arrPlanTask,
|
||||
'workbench' => $arrWorkbench,
|
||||
'task_pool' => $arrTaskPool,
|
||||
'recommended_action' => (($arrPlanTask['status_label'] ?? '') === 'disabled')
|
||||
? '先在后台把 REFRESH_VIDEO_METADATA_TASK_POOL 打开,建议 pt_limit 保持 86400。'
|
||||
: '当前计划任务已具备基础状态,继续保持刷新即可。',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function renderOpsHtml(array $arrSummary): string
|
||||
{
|
||||
$arrOps = self::buildOpsSummary($arrSummary);
|
||||
$arrCommands = (array)($arrOps['commands'] ?? []);
|
||||
$arrPlanTask = (array)($arrOps['plan_task'] ?? []);
|
||||
$arrWorkbench = (array)($arrOps['workbench'] ?? []);
|
||||
$arrTaskPool = (array)($arrOps['task_pool'] ?? []);
|
||||
|
||||
$strCommandRows = '';
|
||||
foreach ($arrCommands as $arrCommand) {
|
||||
$strCommandRows .= '<tr><td>'
|
||||
. htmlspecialchars((string)($arrCommand['label'] ?? ''), ENT_QUOTES, 'UTF-8')
|
||||
. '</td><td><code>'
|
||||
. htmlspecialchars((string)($arrCommand['command'] ?? ''), ENT_QUOTES, 'UTF-8')
|
||||
. '</code></td></tr>';
|
||||
}
|
||||
|
||||
return '<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>视频缺字段任务池运维页</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f8fb;color:#1f2937;margin:0;padding:24px;}
|
||||
.wrap{max-width:1180px;margin:0 auto;}
|
||||
.card{background:#fff;border-radius:16px;box-shadow:0 8px 24px rgba(15,23,42,.06);padding:20px;margin-bottom:20px;}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;}
|
||||
.stat{background:#f8fafc;border:1px solid #e5e7eb;border-radius:12px;padding:16px;}
|
||||
.stat .num{font-size:28px;font-weight:700;margin-top:6px;}
|
||||
table{width:100%;border-collapse:collapse;}
|
||||
th,td{border-bottom:1px solid #e5e7eb;padding:10px;text-align:left;vertical-align:top;font-size:14px;}
|
||||
th{background:#f8fafc;}
|
||||
code{white-space:pre-wrap;word-break:break-all;}
|
||||
.hint{background:#f8fafc;border-left:4px solid #2563eb;padding:12px 14px;border-radius:8px;}
|
||||
.path{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:13px;color:#475569;word-break:break-all;}
|
||||
@media (max-width: 960px){.grid{grid-template-columns:1fr 1fr;}}
|
||||
@media (max-width: 640px){.grid{grid-template-columns:1fr;} body{padding:14px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>视频缺字段任务池运维页</h1>
|
||||
<p>生成时间:' . htmlspecialchars((string)($arrOps['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>
|
||||
<div class="grid">
|
||||
<div class="stat"><div>缺字段视频数</div><div class="num">' . (int)($arrWorkbench['videos_with_any_missing_metadata'] ?? 0) . '</div></div>
|
||||
<div class="stat"><div>任务池批次数</div><div class="num">' . (int)($arrTaskPool['batch_count'] ?? 0) . '</div></div>
|
||||
<div class="stat"><div>计划任务状态</div><div class="num">' . htmlspecialchars((string)($arrPlanTask['status_label'] ?? ''), ENT_QUOTES, 'UTF-8') . '</div></div>
|
||||
<div class="stat"><div>建议间隔</div><div class="num">' . (int)($arrPlanTask['pt_limit'] ?? 0) . '</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>推荐动作</h2>
|
||||
<div class="hint">' . htmlspecialchars((string)($arrOps['recommended_action'] ?? ''), ENT_QUOTES, 'UTF-8') . '</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>常用命令</h2>
|
||||
<table>
|
||||
<thead><tr><th>用途</th><th>命令</th></tr></thead>
|
||||
<tbody>' . $strCommandRows . '</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>当前产物</h2>
|
||||
<table>
|
||||
<thead><tr><th>模块</th><th>HTML</th><th>JSON</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>工作台</td>
|
||||
<td class="path">' . htmlspecialchars((string)($arrWorkbench['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
|
||||
<td class="path">' . htmlspecialchars((string)($arrWorkbench['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>任务池</td>
|
||||
<td class="path">' . htmlspecialchars((string)($arrTaskPool['summary_html_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
|
||||
<td class="path">' . htmlspecialchars((string)($arrTaskPool['summary_json_path'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>';
|
||||
}
|
||||
|
||||
protected static function writeFile(string $strPath, string $strContent): void
|
||||
{
|
||||
$strDir = dirname($strPath);
|
||||
if (!is_dir($strDir)) {
|
||||
mkdir($strDir, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($strPath, $strContent);
|
||||
}
|
||||
}
|
||||
329
code/app/common/helper/VideoMetadataMissingWorkbenchHelper.php
Normal file
329
code/app/common/helper/VideoMetadataMissingWorkbenchHelper.php
Normal file
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
use app\model\VideoModel;
|
||||
|
||||
class VideoMetadataMissingWorkbenchHelper
|
||||
{
|
||||
public static function buildSummary(
|
||||
int $intSampleLimit = 20,
|
||||
int $intQueueLimit = 100,
|
||||
int $intPromptLimit = 20
|
||||
): array {
|
||||
$VideoModel = VideoModel::getInstance();
|
||||
$arrAudit = $VideoModel->buildMissingMetadataAuditSummary($intSampleLimit);
|
||||
$intQueueScanLimit = max(500, min(5000, $intQueueLimit * 20));
|
||||
$arrQueue = $VideoModel->buildRecrawlPriorityQueueSummary($intQueueLimit, $intQueueScanLimit);
|
||||
|
||||
$arrFieldStats = (array)($arrAudit['field_stats'] ?? []);
|
||||
usort($arrFieldStats, static function (array $arrA, array $arrB): int {
|
||||
return (int)($arrB['missing_count'] ?? 0) <=> (int)($arrA['missing_count'] ?? 0);
|
||||
});
|
||||
|
||||
$arrTopFields = array_values(array_slice($arrFieldStats, 0, 5));
|
||||
$arrPromptItems = array_values(array_slice((array)($arrQueue['items'] ?? []), 0, max(1, min($intPromptLimit, 100))));
|
||||
|
||||
$arrSummary = [
|
||||
'generated_at' => date('c'),
|
||||
'sample_limit' => $intSampleLimit,
|
||||
'queue_limit' => $intQueueLimit,
|
||||
'queue_scan_limit' => $intQueueScanLimit,
|
||||
'prompt_limit' => $intPromptLimit,
|
||||
'audit' => $arrAudit,
|
||||
'queue' => $arrQueue,
|
||||
'top_missing_fields' => $arrTopFields,
|
||||
'next_actions' => self::buildNextActions($arrAudit, $arrQueue, $arrTopFields),
|
||||
'codex_dispatch_prompt' => self::buildCodexDispatchPrompt($arrPromptItems, $arrTopFields),
|
||||
'operator_notes' => self::buildOperatorNotes(),
|
||||
];
|
||||
|
||||
return $arrSummary;
|
||||
}
|
||||
|
||||
public static function writeArtifacts(string $strOutputRoot, array $arrSummary): array
|
||||
{
|
||||
$strOutputRoot = rtrim(str_replace('\\', '/', $strOutputRoot), '/');
|
||||
$arrSummary['summary_json_path'] = $strOutputRoot . '/index.json';
|
||||
$arrSummary['summary_html_path'] = $strOutputRoot . '/index.html';
|
||||
$arrSummary['prompt_markdown_path'] = $strOutputRoot . '/prompts/latest.md';
|
||||
$arrSummary['run_id'] = self::buildRunId();
|
||||
|
||||
self::writeFile(
|
||||
$strOutputRoot . '/index.json',
|
||||
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile($strOutputRoot . '/index.html', self::renderHtml($arrSummary));
|
||||
self::writeFile($strOutputRoot . '/prompts/latest.md', (string)($arrSummary['codex_dispatch_prompt'] ?? ''));
|
||||
self::writeRunArtifacts($strOutputRoot, $arrSummary);
|
||||
|
||||
return $arrSummary;
|
||||
}
|
||||
|
||||
public static function readLatestSummary(string $strOutputRoot): array
|
||||
{
|
||||
$strPath = rtrim(str_replace('\\', '/', $strOutputRoot), '/') . '/index.json';
|
||||
if (!is_file($strPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrSummary = json_decode((string)file_get_contents($strPath), true);
|
||||
return is_array($arrSummary) ? $arrSummary : [];
|
||||
}
|
||||
|
||||
protected static function buildNextActions(array $arrAudit, array $arrQueue, array $arrTopFields): array
|
||||
{
|
||||
$intAnyMissing = (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0);
|
||||
$intQueueSize = (int)($arrQueue['queue_size'] ?? 0);
|
||||
$strTopField = (string)(($arrTopFields[0] ?? [])['field'] ?? '');
|
||||
|
||||
$arrActions = [];
|
||||
$arrActions[] = [
|
||||
'key' => 'workbench_refresh',
|
||||
'label' => '先刷新缺失字段工作台',
|
||||
'summary' => '把当前视频库缺失字段、优先重采队列、Codex 派单提示词一起固化成后台可读产物。',
|
||||
];
|
||||
|
||||
if ($intQueueSize > 0) {
|
||||
$arrActions[] = [
|
||||
'key' => 'recrawl_priority',
|
||||
'label' => '优先处理演员 / 导演缺失高优先视频',
|
||||
'summary' => '当前重采优先队列 ' . $intQueueSize . ' 条,优先解决结构化字段空值,避免详情页长期资料残缺。',
|
||||
];
|
||||
}
|
||||
|
||||
if ($intAnyMissing > 0) {
|
||||
$arrActions[] = [
|
||||
'key' => 'codex_dispatch',
|
||||
'label' => '把缺失池派给 Codex 做人工接手优化',
|
||||
'summary' => '当前仍有 ' . $intAnyMissing . ' 条视频存在缺字段,建议技术从后台复制提示词,分批派给 Codex 跟进。',
|
||||
];
|
||||
}
|
||||
|
||||
if ($strTopField !== '') {
|
||||
$arrActions[] = [
|
||||
'key' => 'focus_field',
|
||||
'label' => '本轮重点字段:' . $strTopField,
|
||||
'summary' => '当前缺失量最大的字段先处理,收敛速度最快,也最容易改善详情页完整度。',
|
||||
];
|
||||
}
|
||||
|
||||
return $arrActions;
|
||||
}
|
||||
|
||||
protected static function buildOperatorNotes(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'title' => '这套工作台是干什么的',
|
||||
'content' => '它负责把“视频库哪些字段是空的、哪些视频优先补、给 Codex 的派单提示词”集中整理出来,方便技术和运营协同处理。',
|
||||
],
|
||||
[
|
||||
'title' => '当前不会自动做什么',
|
||||
'content' => '不会因为检测到空字段,就自动调用 AI API 去猜演员、导演、年份、地区等结构化资料;这类字段仍然优先重采或人工确认。',
|
||||
],
|
||||
[
|
||||
'title' => '适合 Codex 接手的场景',
|
||||
'content' => '当后台已经把缺失视频列表和重点字段列出来后,技术可以复制提示词给 Codex,让 Codex按批次分析、整理、制定补料策略,避免运营直接面对复杂技术字段。',
|
||||
],
|
||||
[
|
||||
'title' => '适合后台 AI 的场景',
|
||||
'content' => '仅适合在事实基础足够时补简介、备注、摘要类文案字段,不适合直接猜演员、导演、上映时间、地区、语言等结构化事实字段。',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildCodexDispatchPrompt(array $arrPromptItems, array $arrTopFields): string
|
||||
{
|
||||
$arrLines = [];
|
||||
$arrLines[] = '# 视频缺失字段 Codex 接手提示词';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '你现在接手的是“视频元数据缺失字段处理”任务。';
|
||||
$arrLines[] = '目标:优先提升详情页资料完整度,减少演员、导演、年份、地区、语言、发布时间、简介、备注为空的情况。';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '执行原则:';
|
||||
$arrLines[] = '1. 结构化字段如演员、导演、年份、地区、语言、发布时间,不允许编造。';
|
||||
$arrLines[] = '2. 简介、备注等文案字段,只能在现有事实边界内补全,不允许虚构剧情和角色关系。';
|
||||
$arrLines[] = '3. 优先处理高优先级、点击高、播放源多、近期更新的视频。';
|
||||
$arrLines[] = '4. 输出时请先给出分批处理建议,再给出每批次的风险说明。';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '当前缺失最严重字段:';
|
||||
foreach ($arrTopFields as $arrField) {
|
||||
$arrLines[] = sprintf(
|
||||
'- %s:缺失 %d 条,占比 %.2f%%',
|
||||
(string)($arrField['field'] ?? ''),
|
||||
(int)($arrField['missing_count'] ?? 0),
|
||||
((float)($arrField['missing_ratio'] ?? 0)) * 100
|
||||
);
|
||||
}
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '本轮优先样本:';
|
||||
foreach ($arrPromptItems as $arrItem) {
|
||||
$arrLines[] = sprintf(
|
||||
'- v_id=%d | %s | 分类=%s | 缺失=%s | 原因=%s',
|
||||
(int)($arrItem['v_id'] ?? 0),
|
||||
trim((string)($arrItem['v_name'] ?? '')),
|
||||
trim((string)($arrItem['v_category'] ?? '')),
|
||||
implode(',', (array)($arrItem['missing_fields'] ?? [])),
|
||||
trim((string)($arrItem['priority_reason'] ?? ''))
|
||||
);
|
||||
}
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '请输出:';
|
||||
$arrLines[] = '1. 本轮处理优先级和分批方案';
|
||||
$arrLines[] = '2. 哪些字段适合重采,哪些字段适合人工补充,哪些字段适合仅补文案';
|
||||
$arrLines[] = '3. 如果需要后续命令或脚本,请直接给出可执行建议';
|
||||
$arrLines[] = '';
|
||||
|
||||
return implode(PHP_EOL, $arrLines) . PHP_EOL;
|
||||
}
|
||||
|
||||
protected static function renderHtml(array $arrSummary): string
|
||||
{
|
||||
$arrAudit = (array)($arrSummary['audit'] ?? []);
|
||||
$arrQueue = (array)($arrSummary['queue'] ?? []);
|
||||
$arrTopFields = (array)($arrSummary['top_missing_fields'] ?? []);
|
||||
$arrActions = (array)($arrSummary['next_actions'] ?? []);
|
||||
$arrItems = array_values(array_slice((array)($arrQueue['items'] ?? []), 0, 20));
|
||||
|
||||
$strPrompt = htmlspecialchars((string)($arrSummary['codex_dispatch_prompt'] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$strTopRows = '';
|
||||
foreach ($arrTopFields as $arrField) {
|
||||
$strTopRows .= '<tr>'
|
||||
. '<td>' . htmlspecialchars((string)($arrField['field'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . (int)($arrField['missing_count'] ?? 0) . '</td>'
|
||||
. '<td>' . number_format(((float)($arrField['missing_ratio'] ?? 0)) * 100, 2) . '%</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
$strActionRows = '';
|
||||
foreach ($arrActions as $arrAction) {
|
||||
$strActionRows .= '<li><strong>'
|
||||
. htmlspecialchars((string)($arrAction['label'] ?? ''), ENT_QUOTES, 'UTF-8')
|
||||
. '</strong>:'
|
||||
. htmlspecialchars((string)($arrAction['summary'] ?? ''), ENT_QUOTES, 'UTF-8')
|
||||
. '</li>';
|
||||
}
|
||||
|
||||
$strItemRows = '';
|
||||
foreach ($arrItems as $arrItem) {
|
||||
$strItemRows .= '<tr>'
|
||||
. '<td>' . (int)($arrItem['v_id'] ?? 0) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($arrItem['v_name'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($arrItem['v_category'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . htmlspecialchars(implode(', ', (array)($arrItem['missing_fields'] ?? [])), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '<td>' . (int)($arrItem['priority_score'] ?? 0) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)($arrItem['priority_reason'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return '<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>视频缺失字段工作台</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f8fb;color:#1f2937;margin:0;padding:24px;}
|
||||
.wrap{max-width:1280px;margin:0 auto;}
|
||||
.card{background:#fff;border-radius:16px;box-shadow:0 8px 24px rgba(15,23,42,.06);padding:20px;margin-bottom:20px;}
|
||||
h1,h2{margin:0 0 12px;}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;}
|
||||
.stat{background:#f8fafc;border:1px solid #e5e7eb;border-radius:12px;padding:16px;}
|
||||
.stat .num{font-size:28px;font-weight:700;margin-top:6px;}
|
||||
table{width:100%;border-collapse:collapse;}
|
||||
th,td{border-bottom:1px solid #e5e7eb;padding:10px;text-align:left;vertical-align:top;font-size:14px;}
|
||||
th{background:#f8fafc;}
|
||||
pre{white-space:pre-wrap;word-break:break-word;background:#0f172a;color:#e2e8f0;padding:16px;border-radius:12px;overflow:auto;}
|
||||
ul{margin:0;padding-left:20px;}
|
||||
@media (max-width: 960px){.grid{grid-template-columns:1fr 1fr;}}
|
||||
@media (max-width: 640px){.grid{grid-template-columns:1fr;} body{padding:14px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>视频缺失字段工作台</h1>
|
||||
<p>生成时间:' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>
|
||||
<div class="grid">
|
||||
<div class="stat"><div>总视频数</div><div class="num">' . (int)($arrAudit['total_videos'] ?? 0) . '</div></div>
|
||||
<div class="stat"><div>存在缺字段视频</div><div class="num">' . (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0) . '</div></div>
|
||||
<div class="stat"><div>重采优先队列</div><div class="num">' . (int)($arrQueue['queue_size'] ?? 0) . '</div></div>
|
||||
<div class="stat"><div>Codex 派单样本</div><div class="num">' . (int)($arrSummary['prompt_limit'] ?? 0) . '</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>下一步动作</h2>
|
||||
<ul>' . $strActionRows . '</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>缺失最多字段 Top</h2>
|
||||
<table>
|
||||
<thead><tr><th>字段</th><th>缺失数</th><th>缺失占比</th></tr></thead>
|
||||
<tbody>' . $strTopRows . '</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>高优先级视频样本</h2>
|
||||
<table>
|
||||
<thead><tr><th>v_id</th><th>片名</th><th>分类</th><th>缺失字段</th><th>优先分</th><th>优先原因</th></tr></thead>
|
||||
<tbody>' . $strItemRows . '</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Codex 派单提示词</h2>
|
||||
<pre>' . $strPrompt . '</pre>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>';
|
||||
}
|
||||
|
||||
protected static function writeFile(string $strPath, string $strContent): void
|
||||
{
|
||||
$strDir = dirname($strPath);
|
||||
if (!is_dir($strDir)) {
|
||||
@mkdir($strDir, 0777, true);
|
||||
}
|
||||
file_put_contents($strPath, $strContent);
|
||||
}
|
||||
|
||||
protected static function writeRunArtifacts(string $strOutputRoot, array $arrSummary): void
|
||||
{
|
||||
$strRunId = trim((string)($arrSummary['run_id'] ?? ''));
|
||||
if ($strRunId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$strDay = substr((string)($arrSummary['generated_at'] ?? date('c')), 0, 10);
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $strDay)) {
|
||||
$strDay = date('Y-m-d');
|
||||
}
|
||||
|
||||
$strRunRoot = $strOutputRoot . '/runs/' . $strDay . '/' . $strRunId;
|
||||
self::writeFile(
|
||||
$strRunRoot . '/video-metadata-missing-workbench.summary.json',
|
||||
json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
self::writeFile(
|
||||
$strRunRoot . '/video-metadata-missing-workbench.summary.html',
|
||||
self::renderHtml($arrSummary)
|
||||
);
|
||||
self::writeFile(
|
||||
$strRunRoot . '/prompt.md',
|
||||
(string)($arrSummary['codex_dispatch_prompt'] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
protected static function buildRunId(): string
|
||||
{
|
||||
return 'video-metadata-missing-workbench-' . date('Ymd-His');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\helper;
|
||||
|
||||
class VideoMetadataMissingWorkbenchIndexHelper
|
||||
{
|
||||
public static function buildSummary(string $strRunRoot, int $intLimit = 20): array
|
||||
{
|
||||
$strRunRoot = rtrim(str_replace('\\', '/', $strRunRoot), '/');
|
||||
if ($strRunRoot === '' || !is_dir($strRunRoot)) {
|
||||
return [
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'latest_run' => [],
|
||||
'health_buckets' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$arrFiles = array_merge(
|
||||
(array)glob($strRunRoot . '/*/*/video-metadata-missing-workbench.summary.json')
|
||||
);
|
||||
$arrFiles = array_values(array_filter(array_unique($arrFiles), 'is_file'));
|
||||
usort($arrFiles, static function (string $strLeft, string $strRight): int {
|
||||
return ((int)(filemtime($strRight) ?: 0)) <=> ((int)(filemtime($strLeft) ?: 0));
|
||||
});
|
||||
|
||||
$arrItems = [];
|
||||
foreach (array_slice($arrFiles, 0, max(1, $intLimit)) as $strSummaryPath) {
|
||||
$arrData = json_decode((string)file_get_contents($strSummaryPath), true);
|
||||
if (!is_array($arrData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrAudit = (array)($arrData['audit'] ?? []);
|
||||
$arrQueue = (array)($arrData['queue'] ?? []);
|
||||
$arrTopFields = (array)($arrData['top_missing_fields'] ?? []);
|
||||
$intAnyMissing = (int)($arrAudit['videos_with_any_missing_metadata'] ?? 0);
|
||||
$intTotal = (int)($arrAudit['total_videos'] ?? 0);
|
||||
$floatRatio = $intTotal > 0 ? round($intAnyMissing / $intTotal, 4) : 0.0;
|
||||
[$strHealthLabel, $strHealthReason] = self::resolveHealth($floatRatio, $arrTopFields);
|
||||
|
||||
$strRunDir = dirname($strSummaryPath);
|
||||
$arrItems[] = [
|
||||
'run_id' => basename($strRunDir),
|
||||
'generated_at' => (string)($arrData['generated_at'] ?? date(DATE_ATOM, (int)(filemtime($strSummaryPath) ?: time()))),
|
||||
'videos_with_any_missing_metadata' => $intAnyMissing,
|
||||
'total_videos' => $intTotal,
|
||||
'missing_ratio' => $floatRatio,
|
||||
'queue_size' => (int)($arrQueue['queue_size'] ?? 0),
|
||||
'top_field' => (string)(($arrTopFields[0] ?? [])['field'] ?? ''),
|
||||
'top_field_missing_count' => (int)(($arrTopFields[0] ?? [])['missing_count'] ?? 0),
|
||||
'health_label' => $strHealthLabel,
|
||||
'health_reason' => $strHealthReason,
|
||||
'summary_json_path' => self::publicRelativePath($strSummaryPath),
|
||||
'summary_html_path' => self::publicRelativePath($strRunDir . '/video-metadata-missing-workbench.summary.html'),
|
||||
'prompt_markdown_path' => self::publicRelativePath($strRunDir . '/prompt.md'),
|
||||
];
|
||||
}
|
||||
|
||||
$arrHealthBuckets = [];
|
||||
foreach ($arrItems as $arrItem) {
|
||||
$strLabel = (string)($arrItem['health_label'] ?? 'unknown');
|
||||
$arrHealthBuckets[$strLabel] = (int)($arrHealthBuckets[$strLabel] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $arrItems,
|
||||
'total' => count($arrItems),
|
||||
'latest_run' => $arrItems[0] ?? [],
|
||||
'health_buckets' => $arrHealthBuckets,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function resolveHealth(float $floatRatio, array $arrTopFields): array
|
||||
{
|
||||
$strTopField = (string)(($arrTopFields[0] ?? [])['field'] ?? '');
|
||||
if ($floatRatio >= 0.30) {
|
||||
return ['high_attention', '当前整体缺字段占比仍高,且结构化字段缺失量较大。重点先收敛演员、导演。'];
|
||||
}
|
||||
if ($strTopField === 'v_actor' || $strTopField === 'v_director') {
|
||||
return ['structure_focus', '当前主要仍是演员 / 导演缺失,适合优先走重采和人工核验链。'];
|
||||
}
|
||||
|
||||
return ['steady', '当前缺字段问题相对可控,适合按优先队列持续收敛。'];
|
||||
}
|
||||
|
||||
protected static function publicRelativePath(string $strPath): string
|
||||
{
|
||||
$strPublicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 2) . '/public', '/'));
|
||||
$strPath = str_replace('\\', '/', $strPath);
|
||||
if (str_starts_with($strPath, $strPublicRoot . '/')) {
|
||||
return substr($strPath, strlen($strPublicRoot . '/'));
|
||||
}
|
||||
|
||||
return $strPath;
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,7 @@ class DomainModel extends BaseModel
|
||||
$arrTkdSeoCfg['provider'] = self::normalizeTkdProvider((string)($arrTkdSeoCfg['provider'] ?? self::TKD_PROVIDER_LOCAL));
|
||||
$arrSeoCfg['tkd'] = $arrTkdSeoCfg;
|
||||
|
||||
$arrRawCopyGenerationSeoCfg = is_array($mSeoCfg['copy_generation'] ?? null) ? $mSeoCfg['copy_generation'] : [];
|
||||
$arrCopyGenerationSeoCfg = is_array($arrSeoCfg['copy_generation'] ?? null) ? $arrSeoCfg['copy_generation'] : [];
|
||||
$arrCopyGenerationSeoCfg = array_merge(self::defaultSeoCfg()['copy_generation'], $arrCopyGenerationSeoCfg);
|
||||
$arrCopyGenerationSeoCfg['status'] = trim((string)($arrCopyGenerationSeoCfg['status'] ?? 'draft')) ?: 'draft';
|
||||
@@ -262,6 +263,38 @@ class DomainModel extends BaseModel
|
||||
$arrCopyGenerationSeoCfg['wait_for_ai_before_publish'] = (bool)($arrCopyGenerationSeoCfg['wait_for_ai_before_publish'] ?? false);
|
||||
$arrCopyGenerationSeoCfg['provider_preference'] = trim((string)($arrCopyGenerationSeoCfg['provider_preference'] ?? 'local_first')) ?: 'local_first';
|
||||
$arrCopyGenerationSeoCfg['ai_provider'] = self::normalizeTkdProvider((string)($arrCopyGenerationSeoCfg['ai_provider'] ?? self::TKD_PROVIDER_OPENAI));
|
||||
|
||||
$boolOpenAiCopyPreferred = $arrTkdSeoCfg['provider'] === self::TKD_PROVIDER_OPENAI
|
||||
|| $arrTkdSeoCfg['mode'] === self::TKD_MODE_AI_OPTIMIZE;
|
||||
if ($boolOpenAiCopyPreferred) {
|
||||
$boolLegacyWaitForAiDefault = array_key_exists('wait_for_ai_before_publish', $arrRawCopyGenerationSeoCfg)
|
||||
&& empty($arrRawCopyGenerationSeoCfg['wait_for_ai_before_publish']);
|
||||
$boolLegacyProviderPreferenceDefault = array_key_exists('provider_preference', $arrRawCopyGenerationSeoCfg)
|
||||
&& trim((string)($arrRawCopyGenerationSeoCfg['provider_preference'] ?? '')) === 'local_first';
|
||||
$boolLegacyStatusDefault = array_key_exists('status', $arrRawCopyGenerationSeoCfg)
|
||||
&& trim((string)($arrRawCopyGenerationSeoCfg['status'] ?? '')) === 'draft';
|
||||
|
||||
if (!array_key_exists('wait_for_ai_before_publish', $arrRawCopyGenerationSeoCfg) || $boolLegacyWaitForAiDefault) {
|
||||
$arrCopyGenerationSeoCfg['wait_for_ai_before_publish'] = true;
|
||||
}
|
||||
|
||||
if (!array_key_exists('provider_preference', $arrRawCopyGenerationSeoCfg)
|
||||
|| trim((string)($arrRawCopyGenerationSeoCfg['provider_preference'] ?? '')) === ''
|
||||
|| $boolLegacyProviderPreferenceDefault) {
|
||||
$arrCopyGenerationSeoCfg['provider_preference'] = 'ai_first';
|
||||
}
|
||||
|
||||
if (!array_key_exists('status', $arrRawCopyGenerationSeoCfg)
|
||||
|| trim((string)($arrRawCopyGenerationSeoCfg['status'] ?? '')) === ''
|
||||
|| $boolLegacyStatusDefault) {
|
||||
$arrCopyGenerationSeoCfg['status'] = 'ai_pending';
|
||||
}
|
||||
|
||||
if (!array_key_exists('ai_provider', $arrRawCopyGenerationSeoCfg) || trim((string)($arrRawCopyGenerationSeoCfg['ai_provider'] ?? '')) === '') {
|
||||
$arrCopyGenerationSeoCfg['ai_provider'] = self::TKD_PROVIDER_OPENAI;
|
||||
}
|
||||
}
|
||||
|
||||
$arrSeoCfg['copy_generation'] = $arrCopyGenerationSeoCfg;
|
||||
|
||||
$arrRuntimeAcceptance = is_array($arrSeoCfg['runtime_acceptance'] ?? null)
|
||||
|
||||
@@ -13,6 +13,19 @@ use Exception;
|
||||
*/
|
||||
class VideoModel extends MongoModel
|
||||
{
|
||||
protected array $fillableMetadataFields = [
|
||||
'v_actor',
|
||||
'v_director',
|
||||
'v_lang',
|
||||
'v_lang_en',
|
||||
'v_area',
|
||||
'v_area_en',
|
||||
'v_year',
|
||||
'v_remarks',
|
||||
'v_description',
|
||||
'v_publish_date',
|
||||
];
|
||||
|
||||
/**
|
||||
* Undocumented variable
|
||||
*
|
||||
@@ -203,6 +216,506 @@ class VideoModel extends MongoModel
|
||||
return $this->findOne(['v_name_en' => $strNameEn]);
|
||||
}
|
||||
|
||||
public function buildEmptyMetadataBackfillSet(array $arrExistingVideo, array $arrIncomingVideo, string $strSource = 'crawler_backfill'): array
|
||||
{
|
||||
$arrUpdate = [];
|
||||
$arrFilledFields = [];
|
||||
|
||||
foreach ($this->fillableMetadataFields as $strField) {
|
||||
$mixedExisting = $arrExistingVideo[$strField] ?? null;
|
||||
$mixedIncoming = $arrIncomingVideo[$strField] ?? null;
|
||||
|
||||
if (!$this->isFieldMetadataMissing($strField, $mixedExisting)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isFieldMetadataMissing($strField, $mixedIncoming)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mixedNormalized = $this->normalizeMetadataValueByField($strField, $mixedIncoming);
|
||||
if ($this->isFieldMetadataMissing($strField, $mixedNormalized)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrUpdate[$strField] = $mixedNormalized;
|
||||
$arrFilledFields[] = $strField;
|
||||
}
|
||||
|
||||
if (empty($arrFilledFields)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrUpdate['updated_at'] = new \MongoDB\BSON\UTCDateTime();
|
||||
$arrUpdate['v_metadata_fill'] = [
|
||||
'last_source' => $strSource,
|
||||
'updated_at' => new \MongoDB\BSON\UTCDateTime(),
|
||||
'filled_fields' => $arrFilledFields,
|
||||
];
|
||||
|
||||
return $arrUpdate;
|
||||
}
|
||||
|
||||
public function buildMissingMetadataAuditSummary(int $intSampleLimit = 20): array
|
||||
{
|
||||
$intSampleLimit = max(1, min($intSampleLimit, 100));
|
||||
$intTotal = (int)$this->getCol()->countDocuments();
|
||||
$arrFields = [];
|
||||
$arrAnyMissingFilters = [];
|
||||
|
||||
foreach ($this->fillableMetadataFields as $strField) {
|
||||
$arrFilter = $this->buildMissingMetadataFilter($strField);
|
||||
$intMissingCount = (int)$this->getCol()->countDocuments($arrFilter);
|
||||
$arrFields[] = [
|
||||
'field' => $strField,
|
||||
'missing_count' => $intMissingCount,
|
||||
'missing_ratio' => $intTotal > 0 ? round($intMissingCount / $intTotal, 4) : 0,
|
||||
];
|
||||
$arrAnyMissingFilters[] = $arrFilter;
|
||||
}
|
||||
|
||||
$arrAnyMissingFilter = empty($arrAnyMissingFilters) ? [] : ['$or' => $arrAnyMissingFilters];
|
||||
$intAnyMissingCount = empty($arrAnyMissingFilter) ? 0 : (int)$this->getCol()->countDocuments($arrAnyMissingFilter);
|
||||
|
||||
$Cursor = $this->getCol()->find($arrAnyMissingFilter, [
|
||||
'limit' => $intSampleLimit,
|
||||
'sort' => ['updated_at' => -1, 'v_id' => -1],
|
||||
'projection' => [
|
||||
'_id' => 0,
|
||||
'v_id' => 1,
|
||||
'v_name' => 1,
|
||||
'v_category' => 1,
|
||||
'v_year' => 1,
|
||||
'v_remarks' => 1,
|
||||
'updated_at' => 1,
|
||||
'v_actor' => 1,
|
||||
'v_director' => 1,
|
||||
'v_lang' => 1,
|
||||
'v_lang_en' => 1,
|
||||
'v_area' => 1,
|
||||
'v_area_en' => 1,
|
||||
'v_description' => 1,
|
||||
'v_publish_date' => 1,
|
||||
],
|
||||
'typeMap' => self::$arrOptions['typeMap'],
|
||||
]);
|
||||
|
||||
$arrSamples = [];
|
||||
foreach (iterator_to_array($Cursor) as $arrVideo) {
|
||||
$arrMissingFields = [];
|
||||
foreach ($this->fillableMetadataFields as $strField) {
|
||||
if ($this->isFieldMetadataMissing($strField, $arrVideo[$strField] ?? null)) {
|
||||
$arrMissingFields[] = $strField;
|
||||
}
|
||||
}
|
||||
|
||||
applyToKeys($arrVideo, ['updated_at'], 'formatMongoDate');
|
||||
|
||||
$arrSamples[] = [
|
||||
'v_id' => (int)($arrVideo['v_id'] ?? 0),
|
||||
'v_name' => (string)($arrVideo['v_name'] ?? ''),
|
||||
'v_category' => (string)($arrVideo['v_category'] ?? ''),
|
||||
'v_year' => is_array($arrVideo['v_year'] ?? null) ? '' : (string)($arrVideo['v_year'] ?? ''),
|
||||
'v_remarks' => is_array($arrVideo['v_remarks'] ?? null) ? '' : (string)($arrVideo['v_remarks'] ?? ''),
|
||||
'updated_at' => (string)($arrVideo['updated_at'] ?? ''),
|
||||
'missing_fields' => $arrMissingFields,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'total_videos' => $intTotal,
|
||||
'videos_with_any_missing_metadata' => $intAnyMissingCount,
|
||||
'fillable_fields' => $this->fillableMetadataFields,
|
||||
'field_stats' => $arrFields,
|
||||
'sample_limit' => $intSampleLimit,
|
||||
'samples' => $arrSamples,
|
||||
];
|
||||
}
|
||||
|
||||
public function buildRecrawlPriorityQueueSummary(int $intLimit = 200, int $intScanLimit = 3000): array
|
||||
{
|
||||
$intLimit = max(1, min($intLimit, 5000));
|
||||
$intScanLimit = max($intLimit, min($intScanLimit, 20000));
|
||||
$arrCandidates = [];
|
||||
|
||||
$Cursor = $this->getCol()->find(
|
||||
[
|
||||
'$or' => [
|
||||
$this->buildMissingMetadataFilter('v_actor'),
|
||||
$this->buildMissingMetadataFilter('v_director'),
|
||||
],
|
||||
],
|
||||
[
|
||||
'projection' => [
|
||||
'_id' => 0,
|
||||
'v_id' => 1,
|
||||
'v_name' => 1,
|
||||
'v_category' => 1,
|
||||
'v_parent_category' => 1,
|
||||
'v_year' => 1,
|
||||
'v_actor' => 1,
|
||||
'v_director' => 1,
|
||||
'v_remarks' => 1,
|
||||
'v_isend' => 1,
|
||||
'v_publish_date' => 1,
|
||||
'v_play_url' => 1,
|
||||
'updated_at' => 1,
|
||||
],
|
||||
'limit' => $intScanLimit,
|
||||
'sort' => ['updated_at' => -1, 'v_id' => -1],
|
||||
'typeMap' => self::$arrOptions['typeMap'],
|
||||
]
|
||||
);
|
||||
|
||||
foreach ($Cursor as $arrVideo) {
|
||||
$intVId = (int)($arrVideo['v_id'] ?? 0);
|
||||
if ($intVId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrStats = VideoClicksModel::getInstance()->getStats($intVId);
|
||||
$arrPlayUrl = (array)($arrVideo['v_play_url'] ?? []);
|
||||
$arrSources = array_values(array_filter(array_map(static function ($mixedKey): string {
|
||||
return trim((string)$mixedKey);
|
||||
}, array_keys($arrPlayUrl)), static function (string $strKey): bool {
|
||||
return $strKey !== '';
|
||||
}));
|
||||
|
||||
$boolMissingActor = $this->isFieldMetadataMissing('v_actor', $arrVideo['v_actor'] ?? null);
|
||||
$boolMissingDirector = $this->isFieldMetadataMissing('v_director', $arrVideo['v_director'] ?? null);
|
||||
$strCategory = trim((string)($arrVideo['v_category'] ?? ''));
|
||||
$strParentCategory = trim((string)($arrVideo['v_parent_category'] ?? ''));
|
||||
$intPriorityScore = $this->calculateRecrawlPriorityScore(
|
||||
$arrVideo,
|
||||
$arrStats,
|
||||
$boolMissingActor,
|
||||
$boolMissingDirector,
|
||||
count($arrSources),
|
||||
$strCategory,
|
||||
$strParentCategory
|
||||
);
|
||||
|
||||
$arrCandidates[] = [
|
||||
'v_id' => $intVId,
|
||||
'v_name' => (string)($arrVideo['v_name'] ?? ''),
|
||||
'v_category' => $strCategory,
|
||||
'v_parent_category' => $strParentCategory,
|
||||
'v_year' => (string)($arrVideo['v_year'] ?? ''),
|
||||
'v_remarks' => (string)($arrVideo['v_remarks'] ?? ''),
|
||||
'v_isend' => (int)($arrVideo['v_isend'] ?? 0),
|
||||
'v_publish_date' => (string)($arrVideo['v_publish_date'] ?? ''),
|
||||
'missing_actor' => $boolMissingActor,
|
||||
'missing_director' => $boolMissingDirector,
|
||||
'missing_fields' => array_values(array_filter([
|
||||
$boolMissingActor ? 'v_actor' : '',
|
||||
$boolMissingDirector ? 'v_director' : '',
|
||||
])),
|
||||
'play_sources' => $arrSources,
|
||||
'play_source_count' => count($arrSources),
|
||||
'click_stats' => $arrStats,
|
||||
'priority_score' => $intPriorityScore,
|
||||
'updated_at' => ($arrVideo['updated_at'] ?? null) instanceof \MongoDB\BSON\UTCDateTime
|
||||
? formatMongoDate($arrVideo['updated_at'])
|
||||
: '',
|
||||
'priority_reason' => $this->buildRecrawlPriorityReason(
|
||||
$arrVideo,
|
||||
$boolMissingActor,
|
||||
$boolMissingDirector,
|
||||
$arrStats,
|
||||
(int)($arrVideo['v_isend'] ?? 0),
|
||||
count($arrSources),
|
||||
$strCategory,
|
||||
$strParentCategory
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
usort($arrCandidates, static function (array $arrA, array $arrB): int {
|
||||
$intScoreCompare = (int)($arrB['priority_score'] ?? 0) <=> (int)($arrA['priority_score'] ?? 0);
|
||||
if ($intScoreCompare !== 0) {
|
||||
return $intScoreCompare;
|
||||
}
|
||||
|
||||
$intWeeklyCompare = (int)(($arrB['click_stats'] ?? [])['weekly'] ?? 0) <=> (int)(($arrA['click_stats'] ?? [])['weekly'] ?? 0);
|
||||
if ($intWeeklyCompare !== 0) {
|
||||
return $intWeeklyCompare;
|
||||
}
|
||||
|
||||
$strPublishA = (string)($arrA['v_publish_date'] ?? '');
|
||||
$strPublishB = (string)($arrB['v_publish_date'] ?? '');
|
||||
$intPublishCompare = strcmp($strPublishB, $strPublishA);
|
||||
if ($intPublishCompare !== 0) {
|
||||
return $intPublishCompare;
|
||||
}
|
||||
|
||||
return (int)($arrB['v_id'] ?? 0) <=> (int)($arrA['v_id'] ?? 0);
|
||||
});
|
||||
|
||||
$arrCandidates = array_values(array_slice($arrCandidates, 0, $intLimit));
|
||||
|
||||
return [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'limit' => $intLimit,
|
||||
'scan_limit' => $intScanLimit,
|
||||
'queue_size' => count($arrCandidates),
|
||||
'items' => $arrCandidates,
|
||||
];
|
||||
}
|
||||
|
||||
protected function isEmptyMetadataValue($mixedValue): bool
|
||||
{
|
||||
if ($mixedValue === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_string($mixedValue)) {
|
||||
return trim($mixedValue) === '';
|
||||
}
|
||||
|
||||
if (is_array($mixedValue)) {
|
||||
return count(array_filter($mixedValue, function ($mixedItem) {
|
||||
if (is_string($mixedItem)) {
|
||||
return trim($mixedItem) !== '';
|
||||
}
|
||||
|
||||
return !empty($mixedItem);
|
||||
})) === 0;
|
||||
}
|
||||
|
||||
return empty($mixedValue);
|
||||
}
|
||||
|
||||
public function isFieldMetadataMissing(string $strField, $mixedValue): bool
|
||||
{
|
||||
$mixedNormalized = $this->normalizeMetadataValueByField($strField, $mixedValue);
|
||||
return $this->isEmptyMetadataValue($mixedNormalized);
|
||||
}
|
||||
|
||||
protected function buildMissingMetadataFilter(string $strField): array
|
||||
{
|
||||
$arrInvalidValues = $this->getInvalidMetadataValuesByField($strField);
|
||||
|
||||
$arrOr = [
|
||||
[$strField => ['$exists' => false]],
|
||||
[$strField => null],
|
||||
[$strField => ''],
|
||||
[$strField => []],
|
||||
];
|
||||
|
||||
foreach ($arrInvalidValues as $strInvalid) {
|
||||
$arrOr[] = [$strField => $strInvalid];
|
||||
}
|
||||
|
||||
return [
|
||||
'$or' => $arrOr,
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildRecrawlPriorityReason(
|
||||
array $arrVideo,
|
||||
bool $boolMissingActor,
|
||||
bool $boolMissingDirector,
|
||||
array $arrStats,
|
||||
int $intIsEnd,
|
||||
int $intSourceCount,
|
||||
string $strCategory,
|
||||
string $strParentCategory
|
||||
): string {
|
||||
$arrReasons = [];
|
||||
|
||||
if ($boolMissingActor) {
|
||||
$arrReasons[] = '演员缺失';
|
||||
}
|
||||
if ($boolMissingDirector) {
|
||||
$arrReasons[] = '导演缺失';
|
||||
}
|
||||
if ((int)($arrStats['weekly'] ?? 0) > 0 || (int)($arrStats['total'] ?? 0) > 0) {
|
||||
$arrReasons[] = '已有访问信号';
|
||||
}
|
||||
$intFreshDays = $this->resolveFreshDays((string)($arrVideo['v_publish_date'] ?? ''));
|
||||
if ($intFreshDays >= 0 && $intFreshDays <= 14) {
|
||||
$arrReasons[] = '近14天新内容';
|
||||
}
|
||||
if ($intIsEnd !== 1) {
|
||||
$arrReasons[] = '仍可能持续更新';
|
||||
}
|
||||
if ($intSourceCount >= 2) {
|
||||
$arrReasons[] = '已有多播放源可交叉重采';
|
||||
}
|
||||
$strCategoryKey = $strCategory . ' ' . $strParentCategory;
|
||||
if (preg_match('/综艺|动漫|连续剧|国产剧|韩剧|日剧/u', $strCategoryKey)) {
|
||||
$arrReasons[] = '高频更新题材';
|
||||
}
|
||||
|
||||
return implode(' / ', $arrReasons);
|
||||
}
|
||||
|
||||
protected function calculateRecrawlPriorityScore(
|
||||
array $arrVideo,
|
||||
array $arrStats,
|
||||
bool $boolMissingActor,
|
||||
bool $boolMissingDirector,
|
||||
int $intSourceCount,
|
||||
string $strCategory,
|
||||
string $strParentCategory
|
||||
): int {
|
||||
$intScore = 0;
|
||||
|
||||
if ($boolMissingActor) {
|
||||
$intScore += 30;
|
||||
}
|
||||
if ($boolMissingDirector) {
|
||||
$intScore += 35;
|
||||
}
|
||||
|
||||
$intFreshDays = $this->resolveFreshDays((string)($arrVideo['v_publish_date'] ?? ''));
|
||||
if ($intFreshDays >= 0 && $intFreshDays <= 3) {
|
||||
$intScore += 28;
|
||||
} elseif ($intFreshDays <= 7) {
|
||||
$intScore += 22;
|
||||
} elseif ($intFreshDays <= 14) {
|
||||
$intScore += 16;
|
||||
} elseif ($intFreshDays <= 30) {
|
||||
$intScore += 10;
|
||||
}
|
||||
|
||||
if ((int)($arrVideo['v_isend'] ?? 0) !== 1) {
|
||||
$intScore += 15;
|
||||
}
|
||||
|
||||
if ($intSourceCount >= 2) {
|
||||
$intScore += 10;
|
||||
} elseif ($intSourceCount === 1) {
|
||||
$intScore += 4;
|
||||
}
|
||||
|
||||
$strCategoryKey = $strCategory . ' ' . $strParentCategory;
|
||||
if (preg_match('/综艺/u', $strCategoryKey)) {
|
||||
$intScore += 10;
|
||||
} elseif (preg_match('/动漫/u', $strCategoryKey)) {
|
||||
$intScore += 9;
|
||||
} elseif (preg_match('/连续剧|国产剧|韩剧|日剧/u', $strCategoryKey)) {
|
||||
$intScore += 8;
|
||||
} elseif (preg_match('/短剧|现代都市|古装仙侠/u', $strCategoryKey)) {
|
||||
$intScore += 6;
|
||||
}
|
||||
|
||||
if (trim((string)($arrVideo['v_remarks'] ?? '')) !== '') {
|
||||
$intScore += 4;
|
||||
}
|
||||
|
||||
if (!empty($arrStats['total'])) {
|
||||
$intScore += min(18, (int)floor(log(max(1, (int)$arrStats['total']), 2)));
|
||||
}
|
||||
if (!empty($arrStats['weekly'])) {
|
||||
$intScore += min(12, (int)$arrStats['weekly']);
|
||||
}
|
||||
|
||||
return $intScore;
|
||||
}
|
||||
|
||||
protected function resolveFreshDays(string $strPublishDate): int
|
||||
{
|
||||
$strPublishDate = trim($strPublishDate);
|
||||
if ($strPublishDate === '') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$intTs = strtotime($strPublishDate);
|
||||
if ($intTs === false) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$intDiff = time() - $intTs;
|
||||
if ($intDiff < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)floor($intDiff / 86400);
|
||||
}
|
||||
|
||||
protected function normalizeMetadataValue($mixedValue)
|
||||
{
|
||||
if (is_array($mixedValue)) {
|
||||
$arrValue = array_values(array_filter($mixedValue, function ($mixedItem) {
|
||||
if (is_string($mixedItem)) {
|
||||
return trim($mixedItem) !== '';
|
||||
}
|
||||
|
||||
return !empty($mixedItem);
|
||||
}));
|
||||
|
||||
return array_map(function ($mixedItem) {
|
||||
return is_string($mixedItem) ? trim($mixedItem) : $mixedItem;
|
||||
}, $arrValue);
|
||||
}
|
||||
|
||||
if (is_string($mixedValue)) {
|
||||
return trim($mixedValue);
|
||||
}
|
||||
|
||||
return $mixedValue;
|
||||
}
|
||||
|
||||
protected function normalizeMetadataValueByField(string $strField, $mixedValue)
|
||||
{
|
||||
$mixedNormalized = $this->normalizeMetadataValue($mixedValue);
|
||||
$arrInvalidValues = $this->getInvalidMetadataValuesByField($strField);
|
||||
|
||||
if (is_array($mixedNormalized)) {
|
||||
$arrValue = array_values(array_filter($mixedNormalized, function ($mixedItem) use ($arrInvalidValues) {
|
||||
if (!is_string($mixedItem)) {
|
||||
return !empty($mixedItem);
|
||||
}
|
||||
|
||||
$strValue = trim($mixedItem);
|
||||
if ($strValue === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !in_array($strValue, $arrInvalidValues, true);
|
||||
}));
|
||||
|
||||
return $arrValue;
|
||||
}
|
||||
|
||||
if (is_string($mixedNormalized)) {
|
||||
$strValue = trim($mixedNormalized);
|
||||
if (in_array($strValue, $arrInvalidValues, true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $strValue;
|
||||
}
|
||||
|
||||
return $mixedNormalized;
|
||||
}
|
||||
|
||||
protected function getInvalidMetadataValuesByField(string $strField): array
|
||||
{
|
||||
$arrCommonInvalid = [
|
||||
'内详',
|
||||
'未知',
|
||||
'不详',
|
||||
'待补充',
|
||||
'暂无',
|
||||
'暂无信息',
|
||||
];
|
||||
|
||||
return match ($strField) {
|
||||
'v_actor' => array_values(array_unique(array_merge($arrCommonInvalid, [
|
||||
'未知演员',
|
||||
'演员未知',
|
||||
]))),
|
||||
'v_director' => array_values(array_unique(array_merge($arrCommonInvalid, [
|
||||
'未知导演',
|
||||
'导演未知',
|
||||
]))),
|
||||
default => $arrCommonInvalid,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 查询符合条件的随机多条小说
|
||||
|
||||
@@ -767,8 +767,8 @@ class SiteContext
|
||||
|
||||
$arrCandidates = [];
|
||||
|
||||
if ($strPage === 'home') {
|
||||
// 首页优先吃 domain 表,避免已导入/AI 生成的首页 TKD 被旧标题池覆盖。
|
||||
if (in_array($strPage, ['home', 'search', 'category_index', 'category_list', 'rank_index', 'rank_list'], true)) {
|
||||
// 首页、搜索页、分类页、榜单页优先吃 domain/published 文案,避免旧标题池抢占。
|
||||
$arrCandidates[] = $this->renderSeoFromDomainRecord($strCode, $strPage);
|
||||
$arrCandidates[] = $this->renderSeoFromPool($strCode, $strPage);
|
||||
$arrCandidates[] = $this->renderSeoFromLegacyKey($strCode, $strPage);
|
||||
@@ -832,6 +832,10 @@ class SiteContext
|
||||
|
||||
$strSiteName = trim((string)($this->DomainModel->d_name ?? ''));
|
||||
$strHost = trim((string)($this->DomainModel->d_domain ?? ''));
|
||||
$strSeoCopyMeta = $this->renderSeoFromPublishedCopy($strCode, $strPage, $strHost, $strSiteName);
|
||||
if ($strSeoCopyMeta !== '') {
|
||||
return $strSeoCopyMeta;
|
||||
}
|
||||
|
||||
if ($strPage === 'home') {
|
||||
return match ($strCode) {
|
||||
@@ -897,6 +901,115 @@ class SiteContext
|
||||
return trim((string)($arrGeneric[$strCode] ?? ''));
|
||||
}
|
||||
|
||||
protected function renderSeoFromPublishedCopy(string $strCode, string $strPage, string $strHost, string $strSiteName): string
|
||||
{
|
||||
$strHost = trim($strHost);
|
||||
if ($strHost === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$arrPageConfig = $this->resolveSeoCopySceneConfig($strPage);
|
||||
if (empty($arrPageConfig['scene'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$strScene = (string)$arrPageConfig['scene'];
|
||||
$arrPageKeys = (array)($arrPageConfig['page_keys'] ?? []);
|
||||
$arrPayload = \app\common\helper\SeoCopyStore::getPreferredPageData(
|
||||
$strHost,
|
||||
$strScene,
|
||||
$arrPageKeys,
|
||||
[\app\common\helper\SeoCopyStore::publishedRoot()]
|
||||
);
|
||||
if (empty($arrPayload)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$arrTokens = [
|
||||
'site_name' => $strSiteName,
|
||||
'keyword' => trim((string)$this->Request->get('keyword', '')),
|
||||
'parent_category_name' => trim((string)(ConverterMovel::getVal('strVideoParentCategoryName') ?? '')),
|
||||
'category_name' => trim((string)(ConverterMovel::getVal('strVideoCategoryName') ?? '')),
|
||||
];
|
||||
|
||||
$strField = match ($strCode) {
|
||||
'title' => 'title',
|
||||
'keywords' => 'keywords',
|
||||
'description' => 'description',
|
||||
default => '',
|
||||
};
|
||||
if ($strField === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$strValue = trim((string)($arrPayload[$strField] ?? ''));
|
||||
if ($strValue === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return \app\common\helper\SeoCopyStore::interpolateTemplate($strValue, $arrTokens);
|
||||
}
|
||||
|
||||
protected function resolveSeoCopySceneConfig(string $strPage): array
|
||||
{
|
||||
return match ($strPage) {
|
||||
'home' => [
|
||||
'scene' => 'home',
|
||||
'page_keys' => ['index'],
|
||||
],
|
||||
'search' => [
|
||||
'scene' => 'search',
|
||||
'page_keys' => array_values(array_filter([
|
||||
\app\common\helper\SeoCopySchema::buildScenePageKey('search', [
|
||||
trim((string)$this->Request->get('keyword', '')),
|
||||
]),
|
||||
'landing',
|
||||
])),
|
||||
],
|
||||
'category_index' => [
|
||||
'scene' => 'category_index',
|
||||
'page_keys' => array_values(array_filter([
|
||||
\app\common\helper\SeoCopySchema::buildScenePageKey('category_index', [
|
||||
trim((string)$this->Request->route('strParentCategory')),
|
||||
]),
|
||||
'index',
|
||||
])),
|
||||
],
|
||||
'category_list' => [
|
||||
'scene' => 'category_list',
|
||||
'page_keys' => array_values(array_filter([
|
||||
\app\common\helper\SeoCopySchema::buildScenePageKey('category_list', [
|
||||
trim((string)$this->Request->route('strParentCategory')),
|
||||
trim((string)$this->Request->route('strCategory')),
|
||||
]),
|
||||
'default',
|
||||
])),
|
||||
],
|
||||
'rank_index' => [
|
||||
'scene' => 'rank_index',
|
||||
'page_keys' => ['index'],
|
||||
],
|
||||
'rank_list' => [
|
||||
'scene' => 'rank_list',
|
||||
'page_keys' => array_values(array_filter([
|
||||
\app\common\helper\SeoCopySchema::buildScenePageKey('rank_list', [
|
||||
trim((string)$this->Request->route('strSortType')),
|
||||
trim((string)$this->Request->route('strParentCategory')),
|
||||
trim((string)$this->Request->route('strCategory')),
|
||||
]),
|
||||
\app\common\helper\SeoCopySchema::buildScenePageKey('rank_list', [
|
||||
trim((string)$this->Request->route('strSortType')),
|
||||
]),
|
||||
'default',
|
||||
])),
|
||||
],
|
||||
default => [
|
||||
'scene' => '',
|
||||
'page_keys' => [],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
protected function normalizeSeoText(mixed $mValue): string
|
||||
{
|
||||
$strValue = trim((string)$mValue);
|
||||
@@ -983,6 +1096,13 @@ class SiteContext
|
||||
'高清免费',
|
||||
'免费播放',
|
||||
];
|
||||
$arrCoreTopicWords = [
|
||||
'电影',
|
||||
'电视剧',
|
||||
'短剧',
|
||||
'综艺',
|
||||
'动漫',
|
||||
];
|
||||
|
||||
$arrParts = [];
|
||||
foreach ($arrRawParts as $strPart) {
|
||||
@@ -1008,7 +1128,7 @@ class SiteContext
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mb_strlen($strPart) <= 2 && !preg_match('/^\d+$/u', $strPart)) {
|
||||
if (mb_strlen($strPart) <= 2 && !preg_match('/^\d+$/u', $strPart) && !in_array($strPart, $arrCoreTopicWords, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,16 @@ class VideoInfoPage extends BasePage
|
||||
'v_play_url' => $sortedPlayUrl,
|
||||
];
|
||||
|
||||
$arrMetadataBackfill = $this->getVideoModel()->buildEmptyMetadataBackfillSet(
|
||||
$arrExistsVideo,
|
||||
$arrPageVideo,
|
||||
'douban_crawler'
|
||||
);
|
||||
|
||||
if (!empty($arrMetadataBackfill)) {
|
||||
$arrUpdate = array_merge($arrUpdate, $arrMetadataBackfill);
|
||||
}
|
||||
|
||||
$this->getVideoModel()->updateOne(
|
||||
['v_name' => $arrPageVideo['v_name']],
|
||||
['$set' => $arrUpdate]
|
||||
|
||||
@@ -246,6 +246,16 @@ class VideoInfoPage extends BasePage
|
||||
'v_play_url' => $sortedPlayUrl,
|
||||
];
|
||||
|
||||
$arrMetadataBackfill = $this->getVideoModel()->buildEmptyMetadataBackfillSet(
|
||||
$arrExistsVideo,
|
||||
$arrPageVideo,
|
||||
'fengchao_crawler'
|
||||
);
|
||||
|
||||
if (!empty($arrMetadataBackfill)) {
|
||||
$arrUpdate = array_merge($arrUpdate, $arrMetadataBackfill);
|
||||
}
|
||||
|
||||
$this->getVideoModel()->updateOne(
|
||||
['v_name' => $arrPageVideo['v_name']],
|
||||
['$set' => $arrUpdate]
|
||||
|
||||
@@ -233,6 +233,16 @@ class VideoInfoPage extends BasePage
|
||||
'v_name_en' => zhToPinYin($arrPageVideo['v_name']),
|
||||
];
|
||||
|
||||
$arrMetadataBackfill = $this->getVideoModel()->buildEmptyMetadataBackfillSet(
|
||||
$arrExistsVideo,
|
||||
$arrPageVideo,
|
||||
'maotai_crawler'
|
||||
);
|
||||
|
||||
if (!empty($arrMetadataBackfill)) {
|
||||
$arrUpdate = array_merge($arrUpdate, $arrMetadataBackfill);
|
||||
}
|
||||
|
||||
$this->getVideoModel()->updateOne(
|
||||
['v_name' => $arrPageVideo['v_name']],
|
||||
['$set' => $arrUpdate]
|
||||
|
||||
@@ -252,6 +252,16 @@ class VideoInfoPage extends BasePage
|
||||
'v_play_url' => $sortedPlayUrl,
|
||||
];
|
||||
|
||||
$arrMetadataBackfill = $this->getVideoModel()->buildEmptyMetadataBackfillSet(
|
||||
$arrExistsVideo,
|
||||
$arrPageVideo,
|
||||
'wuxian_crawler'
|
||||
);
|
||||
|
||||
if (!empty($arrMetadataBackfill)) {
|
||||
$arrUpdate = array_merge($arrUpdate, $arrMetadataBackfill);
|
||||
}
|
||||
|
||||
$this->getVideoModel()->updateOne(
|
||||
['v_name' => $arrPageVideo['v_name']],
|
||||
['$set' => $arrUpdate]
|
||||
|
||||
@@ -169,6 +169,10 @@ class VideoCategory
|
||||
'name' => '体育',
|
||||
'pinyin' => 'ti-yu',
|
||||
],
|
||||
95 => [
|
||||
'name' => '短剧大全',
|
||||
'pinyin' => 'duan-ju-da-quan',
|
||||
],
|
||||
];
|
||||
|
||||
static public $arrParent = [
|
||||
@@ -212,5 +216,6 @@ class VideoCategory
|
||||
19 => 19,
|
||||
83 => 83,
|
||||
94 => 94,
|
||||
95 => 83,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -127,7 +127,8 @@ class VideoInfoPage extends BasePage
|
||||
}
|
||||
$arrPlayUrl = $cleanPlayUrl;
|
||||
|
||||
$arrVideo['type_id_1'] = VideoCategory::$arrParent[$arrVideo['type_id']];
|
||||
$arrVideo['type_id'] = (int)($arrVideo['type_id'] ?? 0);
|
||||
$arrVideo['type_id_1'] = VideoCategory::$arrParent[$arrVideo['type_id']] ?? $arrVideo['type_id'];
|
||||
|
||||
$arrVideo['vod_actor'] = trim($arrVideo['vod_actor']);
|
||||
$arrVideo['vod_director'] = trim($arrVideo['vod_director']);
|
||||
@@ -159,10 +160,16 @@ class VideoInfoPage extends BasePage
|
||||
$arrVideo['vod_content'] = mb_rtrim($arrVideo['vod_content'], '\ ');
|
||||
$arrVideo['vod_content'] = ensureUtf8($arrVideo['vod_content']);
|
||||
|
||||
$arrVideo['v_category'] = VideoCategory::$arrCategory[$arrVideo['type_id']]['name'];
|
||||
$arrVideo['v_category_en'] = VideoCategory::$arrCategory[$arrVideo['type_id']]['pinyin'];
|
||||
$arrVideo['v_parent_category'] = VideoCategory::$arrCategory[$arrVideo['type_id_1']]['name'];
|
||||
$arrVideo['v_parent_category_en'] = VideoCategory::$arrCategory[$arrVideo['type_id_1']]['pinyin'];
|
||||
$arrCurrentCategory = VideoCategory::$arrCategory[$arrVideo['type_id']] ?? VideoCategory::$arrCategory[$arrVideo['type_id_1']] ?? [
|
||||
'name' => '短剧大全',
|
||||
'pinyin' => 'duan-ju-da-quan',
|
||||
];
|
||||
$arrParentCategory = VideoCategory::$arrCategory[$arrVideo['type_id_1']] ?? $arrCurrentCategory;
|
||||
|
||||
$arrVideo['v_category'] = $arrCurrentCategory['name'];
|
||||
$arrVideo['v_category_en'] = $arrCurrentCategory['pinyin'];
|
||||
$arrVideo['v_parent_category'] = $arrParentCategory['name'];
|
||||
$arrVideo['v_parent_category_en'] = $arrParentCategory['pinyin'];
|
||||
|
||||
foreach ($arrVideo as $strKey => $mixedVal) {
|
||||
$strKey = str_replace('vod', 'v', $strKey);
|
||||
@@ -249,6 +256,16 @@ class VideoInfoPage extends BasePage
|
||||
'v_play_url' => $sortedPlayUrl,
|
||||
];
|
||||
|
||||
$arrMetadataBackfill = $this->getVideoModel()->buildEmptyMetadataBackfillSet(
|
||||
$arrExistsVideo,
|
||||
$arrPageVideo,
|
||||
'youzhi_crawler'
|
||||
);
|
||||
|
||||
if (!empty($arrMetadataBackfill)) {
|
||||
$arrUpdate = array_merge($arrUpdate, $arrMetadataBackfill);
|
||||
}
|
||||
|
||||
$this->getVideoModel()->updateOne(
|
||||
['v_name' => $arrPageVideo['v_name']],
|
||||
['$set' => $arrUpdate]
|
||||
|
||||
@@ -88,7 +88,7 @@ class VideoSiteMapLogic
|
||||
*/
|
||||
public function getDomain(): array
|
||||
{
|
||||
if (empty($this->arrDomain)) {
|
||||
if (empty($this->arrDomainModel)) {
|
||||
$this->arrDomainModel = DomainModel::getAllDomainOnCache();
|
||||
}
|
||||
return $this->arrDomainModel;
|
||||
@@ -100,8 +100,26 @@ class VideoSiteMapLogic
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function generateSiteMap()
|
||||
public function generateSiteMap(array $arrTaskData = [])
|
||||
{
|
||||
$arrDomains = array_values(array_unique(array_filter(array_map(
|
||||
static fn($mValue): string => DomainModel::normalizeStoredDomain((string)$mValue),
|
||||
(array)($arrTaskData['domains'] ?? [])
|
||||
))));
|
||||
|
||||
if (!empty($arrDomains)) {
|
||||
$arrAllDomainModel = DomainModel::getAllDomainOnCache();
|
||||
$this->arrDomainModel = array_values(array_filter(
|
||||
$arrAllDomainModel,
|
||||
static fn($DomainModel): bool => in_array((string)$DomainModel->d_domain, $arrDomains, true)
|
||||
));
|
||||
}
|
||||
|
||||
if (empty($this->getDomain())) {
|
||||
echo 'No domains matched for sitemap generation.' . PHP_EOL;
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 只查一次总数(非常重要)
|
||||
$arrResult = $this->VideoModel->findPaginatedWithCache([], 1, 1);
|
||||
$this->intTotal = $arrResult['total'];
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\task\module;
|
||||
|
||||
use app\common\helper\VideoMetadataMissingRefreshHelper;
|
||||
use app\model\PlanTaskModel;
|
||||
use app\task\logic\SiteMapLogic;
|
||||
use app\task\logic\VideoSiteMapLogic;
|
||||
@@ -94,6 +95,9 @@ class PlanTask
|
||||
case 'PUSH_BAIDU_VIDEO_URL':
|
||||
self::pushBaiduVideoUrl();
|
||||
break;
|
||||
case 'REFRESH_VIDEO_METADATA_TASK_POOL':
|
||||
self::refreshVideoMetadataTaskPool();
|
||||
break;
|
||||
|
||||
|
||||
}
|
||||
@@ -115,6 +119,28 @@ class PlanTask
|
||||
$QueueManage->set($arrTask);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全刷新视频缺字段工作台与任务池。
|
||||
*
|
||||
* 说明:
|
||||
* 1. 只生成扫描产物和派单产物
|
||||
* 2. 不自动补结构化事实字段
|
||||
* 3. 不自动调用 AI API
|
||||
* 4. 适合作为计划任务安全入口
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function refreshVideoMetadataTaskPool(): array
|
||||
{
|
||||
return VideoMetadataMissingRefreshHelper::refresh([
|
||||
'sample' => 8,
|
||||
'queue_limit' => 50,
|
||||
'prompt_limit' => 10,
|
||||
'batch_size' => 10,
|
||||
'batch_limit' => 10,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undocumented function
|
||||
*
|
||||
|
||||
@@ -11,5 +11,12 @@ return [
|
||||
'mongo:manage' => 'db\MongoMigrate',
|
||||
'novel:manage' => 'db\NovelManage',
|
||||
'bbc:manage' => 'bbc\Init',
|
||||
'video:metadata:audit' => 'app\\command\\VideoMetadataAuditCommand',
|
||||
'video:metadata:fill-copy' => 'app\\command\\VideoMetadataCopyFillCommand',
|
||||
'video:metadata:recrawl-queue' => 'app\\command\\VideoMetadataRecrawlQueueCommand',
|
||||
'video:metadata:recrawl-run' => 'app\\command\\VideoMetadataRecrawlRunCommand',
|
||||
'video:metadata:workbench' => 'app\\command\\VideoMetadataMissingWorkbenchCommand',
|
||||
'video:metadata:task-pool' => 'app\\command\\VideoMetadataMissingTaskPoolCommand',
|
||||
'plan:seed:video-metadata-missing' => 'app\\command\\VideoMetadataMissingPlanTaskSeedCommand',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -123,6 +123,12 @@ class PlanTaskSeeder
|
||||
'pt_enable' => 0,
|
||||
'pt_limit' => 1 * 24 * 3600,
|
||||
],
|
||||
[
|
||||
'pt_name' => '视频缺字段任务池刷新',
|
||||
'pt_code' => 'REFRESH_VIDEO_METADATA_TASK_POOL',
|
||||
'pt_enable' => 0,
|
||||
'pt_limit' => 1 * 24 * 3600,
|
||||
],
|
||||
|
||||
|
||||
|
||||
|
||||
222
docs/2026-04-18-AI文案与视频元数据公共规则主线.md
Normal file
222
docs/2026-04-18-AI文案与视频元数据公共规则主线.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 2026-04-18 AI文案与视频元数据公共规则主线
|
||||
|
||||
这份文档不是 GPT 模板专属文档,而是当前整个项目的公共规则主线。
|
||||
|
||||
适用范围:
|
||||
|
||||
1. GPT 模板
|
||||
2. 老模板
|
||||
3. 后续其它模板
|
||||
4. 后台人工触发 AI
|
||||
5. Codex 接手优化
|
||||
6. 视频采集、视频回填、SEO 文案生成、详情页正文补全
|
||||
|
||||
一句话:
|
||||
|
||||
> 从 2026-04-18 开始,`AI 文案规则` 和 `视频元数据缺失处理规则` 统一提升为全项目公共规则,所有接手 Codex 都必须先读。
|
||||
|
||||
## 为什么要升级成公共规则
|
||||
|
||||
前面这些规则虽然最早是在 GPT 模板线里沉淀出来的,但它们约束的不是某一个模板的视觉或 SEO 结构,而是整个项目共同面对的两类基础问题:
|
||||
|
||||
1. AI 文案怎么在真实边界内写得更像人工、又不伤 SEO
|
||||
2. 演员、导演、年份、地区、语言、简介等字段为空时,系统应该怎么处理,才能既不乱猜又能持续沉淀进视频库
|
||||
|
||||
这两件事天然就是全项目公共能力,不应该只埋在某条模板支线下面。
|
||||
|
||||
## 公共规则 1:AI 文案最高指令
|
||||
|
||||
所有 AI 文案,不管是后台 OpenAI、批处理提示词,还是 Codex 参考输出,都必须遵守:
|
||||
|
||||
1. 共同目标是:`SEO 效果 + 可读性 + 真实感 + 可持续收录`
|
||||
2. 只能基于已提供字段和事实写,不允许虚构剧情、角色关系、上映信息、评价内容、播放权益或不存在的资料
|
||||
3. 如果现有规则开始明显压制 SEO 增长、压制点击意图、压制更高质量发挥,不能静默硬套,必须明确提出冲突点,再讨论升级
|
||||
4. 不允许为了“守规则”写出机械化、模板化、低区分度文案
|
||||
5. 不允许写营销承诺词,例如:
|
||||
- `免费观看`
|
||||
- `高清完整版`
|
||||
- `极速播放`
|
||||
- `本站提供`
|
||||
- `全网最全`
|
||||
6. 不允许堆砌关键词,不允许片名大面积重复,通常不要超过 `2` 次
|
||||
7. 语言必须自然、克制、像人工整理,不要满篇泛称、代词和模板痕迹
|
||||
|
||||
## 公共规则 2:缺失字段绝不能靠 AI 猜
|
||||
|
||||
下面这些字段都属于结构化事实字段:
|
||||
|
||||
1. 演员
|
||||
2. 导演
|
||||
3. 年份
|
||||
4. 地区
|
||||
5. 语言
|
||||
6. 发布日期
|
||||
|
||||
统一规则:
|
||||
|
||||
1. 这些字段优先靠 `重采 / 补源 / 人工校正`
|
||||
2. 不允许 AI 猜测补齐
|
||||
3. `内详 / 未知 / 不详 / 待补充 / 暂无 / 未知演员 / 未知导演` 一律按缺失处理
|
||||
4. 搜到了详情,不等于补齐成功
|
||||
5. 只有拿到真实有效字段值,才算补齐
|
||||
|
||||
## 公共规则 3:文案型空字段可以补,但只能在真实性边界内补
|
||||
|
||||
下面这些字段属于文案型字段:
|
||||
|
||||
1. 简介
|
||||
2. 备注
|
||||
3. 详情页摘要
|
||||
4. 详情页正文补充
|
||||
|
||||
统一规则:
|
||||
|
||||
1. 可以在真实性边界内由 AI 补全
|
||||
2. 只能使用已有字段组织文案,不能脑补事实
|
||||
3. 优先写成自然、克制、资料型、可收录的内容
|
||||
4. 只补空字段,不覆盖已有非空字段
|
||||
5. 如果事实基础太弱,允许写短,不能硬凑长文
|
||||
|
||||
## 公共规则 4:缺失字段必须优先写回视频库
|
||||
|
||||
这是当前整个项目都要遵守的公共回写原则:
|
||||
|
||||
1. 缺失字段一旦补到,优先写回视频数据库
|
||||
2. 默认原则:`只补空字段,不覆盖已有非空字段`
|
||||
3. 不允许把空值、脏值、占位值写成“已补齐”
|
||||
4. 写库目标不是把库塞满,而是让视频记录后续越来越完整,减少反复空转
|
||||
|
||||
## 公共规则 5:当前全项目的字段优化主矛盾
|
||||
|
||||
到目前这一步,文案型空字段已经不是主矛盾,真正的主矛盾是:
|
||||
|
||||
1. `v_actor`
|
||||
2. `v_director`
|
||||
|
||||
所以全项目视频 SEO 元数据优化主线已经统一切成两条:
|
||||
|
||||
1. 结构化字段缺失:
|
||||
- 走重采、补源、人工校正
|
||||
2. 文案型字段缺失:
|
||||
- 在真实性边界内走 AI 补全并写回空字段
|
||||
|
||||
## 当前全项目统一执行口径
|
||||
|
||||
### A. 做 AI 文案时
|
||||
|
||||
1. 先看事实字段是否足够
|
||||
2. 足够才写
|
||||
3. 不足就克制写成资料型
|
||||
4. 不够就宁可短,不要乱编
|
||||
|
||||
### B. 做字段补齐时
|
||||
|
||||
1. 先区分结构化字段和文案型字段
|
||||
2. 结构化字段缺失优先排到重采队列
|
||||
3. 文案型字段缺失优先排到 AI 补全队列
|
||||
4. 所有结果最终都要沉淀回视频库
|
||||
|
||||
### B.1 做缺字段扫描和任务派发时
|
||||
|
||||
当前全项目建议统一使用下面这条命令做安全触发:
|
||||
|
||||
```bash
|
||||
php SEONexus/code/think video:metadata:task-pool --sample=8 --queue-limit=50 --prompt-limit=10 --batch-size=10 --batch-limit=10
|
||||
```
|
||||
|
||||
这条命令的统一含义是:
|
||||
|
||||
1. 扫描视频缺字段情况
|
||||
2. 刷新工作台产物
|
||||
3. 生成可派给 Codex 的缺字段任务池
|
||||
4. 不自动补演员、导演、年份、地区、语言、发布日期
|
||||
5. 不自动偷偷调用 OpenAI API
|
||||
|
||||
所以它适合作为:
|
||||
|
||||
1. 其它项目同步代码后的首条启动命令
|
||||
2. 计划任务挂接入口
|
||||
3. 技术排查当前缺字段规模的统一入口
|
||||
|
||||
如果当前项目使用数据库计划任务表统一调度,也可以使用:
|
||||
|
||||
1. `pt_code = REFRESH_VIDEO_METADATA_TASK_POOL`
|
||||
2. 对应安全入口为 `PlanTask::refreshVideoMetadataTaskPool()`
|
||||
|
||||
这条入口仍然遵守同一条公共边界:
|
||||
|
||||
1. 生成扫描与派单产物
|
||||
2. 不自动补事实字段
|
||||
3. 不自动调用 OpenAI API
|
||||
4. 不自动写回视频库
|
||||
|
||||
当前配套的两个运维入口也建议一起看:
|
||||
|
||||
1. 状态总览页:用于看当前缺字段规模、任务池批次状态、计划任务状态
|
||||
2. 运维总览页:用于看当前推荐动作、常用命令、产物路径
|
||||
|
||||
对应产物目录为:
|
||||
|
||||
```text
|
||||
SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/
|
||||
```
|
||||
|
||||
如果你更习惯后台 JSON 入口,也可以直接用:
|
||||
|
||||
1. `GET /admin/video/metadata/missing/task-pool/status`
|
||||
2. `GET /admin/video/metadata/missing/task-pool/ops`
|
||||
|
||||
### B.2 页面入口和数据接口要分开理解
|
||||
|
||||
从这轮开始,这条线的后台页面已经迁到 Vue 后台 `SEONexusAdmin`。
|
||||
|
||||
统一口径:
|
||||
|
||||
1. `PHP /admin/video/...`
|
||||
主要提供数据接口
|
||||
2. `Vue #/video/...`
|
||||
主要提供后台实际操作页面
|
||||
|
||||
当前页面型入口统一使用:
|
||||
|
||||
1. `#/video/metadata/missing/workbench`
|
||||
2. `#/video/metadata/missing/task-pool`
|
||||
3. `#/video/metadata/missing/task-pool/status`
|
||||
4. `#/video/metadata/missing/task-pool/ops`
|
||||
|
||||
这一步的意义是:
|
||||
|
||||
1. 后台页面能力统一收回到 Vue 工程
|
||||
2. PHP 侧不再继续膨胀出一套“伪后台页面”
|
||||
3. 同步到其它服务器时,更容易区分哪些是接口代码,哪些是前端页面代码
|
||||
|
||||
### C. 做复盘时
|
||||
|
||||
1. 不只看“命中没命中”
|
||||
2. 要看字段缺失审计是否真实下降
|
||||
3. 要看是否误写了无效值、占位值、脏值
|
||||
|
||||
## 公共阅读入口
|
||||
|
||||
所有新接手 Codex,建议先按这个顺序读:
|
||||
|
||||
1. [README.md](/www/wwwroot/diff-maccms/SEONexus/docs/README.md)
|
||||
2. [Codex启动提示词模板.md](/www/wwwroot/diff-maccms/SEONexus/docs/Codex启动提示词模板.md)
|
||||
3. [2026-04-18-AI文案与视频元数据公共规则主线.md](/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-AI文案与视频元数据公共规则主线.md)
|
||||
4. [2026-04-18-视频元数据缺失处理公共执行规则.md](/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-视频元数据缺失处理公共执行规则.md)
|
||||
|
||||
如果接手的是 GPT 模板支线,再继续看:
|
||||
|
||||
1. [2026-04-18-GPT模板SEO公共主线汇总.md](/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-GPT模板SEO公共主线汇总.md)
|
||||
2. [gpt-template-seo/README.md](/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/README.md)
|
||||
|
||||
## 来源说明
|
||||
|
||||
这份公共规则主线并不是凭空新建,而是把原本已经在 GPT 模板线里验证通过的规则,提升成项目公共规则。
|
||||
|
||||
规则来源主要包括:
|
||||
|
||||
1. [gpt-template-seo/17-GPT模板AI文案最高规则-2026-04-18.md](/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/17-GPT模板AI文案最高规则-2026-04-18.md)
|
||||
2. [gpt-template-seo/18-GPT模板演员导演缺失重采Top50执行单-2026-04-18.md](/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/18-GPT模板演员导演缺失重采Top50执行单-2026-04-18.md)
|
||||
|
||||
从现在开始,这两份属于“来源与实验沉淀文档”,而这份文档属于“全项目公共总口径文档”。
|
||||
223
docs/2026-04-18-PHP与Vue收口交付总结.md
Normal file
223
docs/2026-04-18-PHP与Vue收口交付总结.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# 2026-04-18 PHP 与 Vue 收口交付总结
|
||||
|
||||
## 本轮目标
|
||||
|
||||
本轮收口的目标不是只补一个页面,而是把以下三条线真正闭环:
|
||||
|
||||
1. `Vue 后台` 成为视频缺字段工作台/任务池的正式入口,不再依赖错误的 PHP 后台页面直出。
|
||||
2. `PHP 后端` 提供完整的数据接口、计划任务、工作台产物、任务池产物、缺字段扫描与安全补全能力。
|
||||
3. `公共 AI 规则` 与 `视频元数据缺失处理规则` 提升为全项目共享规则,后续无论哪个模板、哪个 Codex 接手,都必须先读这条主线。
|
||||
|
||||
---
|
||||
|
||||
## 交付范围
|
||||
|
||||
### 1. Vue 后台侧
|
||||
|
||||
已迁移到 `SEONexusAdmin` 的后台能力:
|
||||
|
||||
- 视频缺字段控制台首屏卡片
|
||||
- 视频缺字段工作台页
|
||||
- 视频缺字段任务池页
|
||||
- 视频缺字段状态总览页
|
||||
- 视频缺字段运维总览页
|
||||
- 系统配置页中的 `SEO AI 规则` 专属入口
|
||||
- 新域名导入成功后的“下一步选择”弹窗
|
||||
- `Codex继续`
|
||||
- `OpenAI继续`
|
||||
- 一键复制 Codex 提示词
|
||||
|
||||
### 2. PHP 后端侧
|
||||
|
||||
已补齐或增强的能力:
|
||||
|
||||
- 视频缺字段工作台接口
|
||||
- 视频缺字段任务池接口
|
||||
- 视频缺字段计划任务状态接口
|
||||
- 视频缺字段批次状态保存接口
|
||||
- 视频缺字段批次提示词接口
|
||||
- 视频缺字段批次历史接口
|
||||
- 视频缺字段工作台/任务池命令行命令
|
||||
- 视频缺字段计划任务种子
|
||||
- 视频元数据空字段安全补全 helper
|
||||
- 采集器回填逻辑
|
||||
- 仅在现有字段为空时,用新采集到的真实字段回填
|
||||
- 不覆盖已有非空值
|
||||
- 站点导入后自动生成基础产物
|
||||
- SEO state
|
||||
- manifest
|
||||
- site positioning
|
||||
- sitemap 入队
|
||||
- starter published copy
|
||||
|
||||
### 3. 公共规则侧
|
||||
|
||||
已确立为全项目公共主线的内容:
|
||||
|
||||
- AI 文案最高指令
|
||||
- 详情摘要规则
|
||||
- 详情正文规则
|
||||
- 缺失字段处理规则
|
||||
- 电影 / 剧集 / 综艺 / 动漫 / 短剧 细分风格规则
|
||||
- 视频元数据缺失扫描与派单原则
|
||||
|
||||
---
|
||||
|
||||
## 重要路径说明
|
||||
|
||||
### Vue 后台真实入口
|
||||
|
||||
以下页面应从 `SEONexusAdmin` 的 `hash 路由` 进入:
|
||||
|
||||
- `#/video/metadata/missing/workbench`
|
||||
- `#/video/metadata/missing/task-pool`
|
||||
- `#/video/metadata/missing/task-pool/status`
|
||||
- `#/video/metadata/missing/task-pool/ops`
|
||||
- `#/system/config/seo-ai-rules`
|
||||
|
||||
### PHP 侧定位
|
||||
|
||||
PHP 这边现在主要负责:
|
||||
|
||||
- 数据接口
|
||||
- 工作台产物生成
|
||||
- 任务池产物生成
|
||||
- 计划任务
|
||||
- 命令行工具
|
||||
|
||||
不再把视频缺字段后台页面直接渲染成 PHP 管理页。
|
||||
|
||||
---
|
||||
|
||||
## 更新其它源时要同步的重点
|
||||
|
||||
### PHP 仓库必须同步
|
||||
|
||||
重点包含:
|
||||
|
||||
- `app/admin/controller/Video.php`
|
||||
- `app/admin/controller/Site.php`
|
||||
- `app/admin/controller/SystemConfig.php`
|
||||
- `app/admin/config/router.php`
|
||||
- `app/task/module/PlanTask.php`
|
||||
- `config/console.php`
|
||||
- `database/seeders/PlanTaskSeeder.php`
|
||||
- `app/common/helper/SeoCopyAiRuleConfigHelper.php`
|
||||
- `app/common/helper/VideoMetadata*`
|
||||
- `app/command/VideoMetadata*`
|
||||
- `app/model/VideoModel.php`
|
||||
- `app/model/DomainModel.php`
|
||||
- `app/services/SiteContext.php`
|
||||
- 各采集器 `VideoInfoPage.php` 中的空字段回填逻辑
|
||||
|
||||
### Vue 仓库必须同步
|
||||
|
||||
重点包含:
|
||||
|
||||
- `src/views/video/metadataMissing/index.vue`
|
||||
- `src/views/home/index.vue`
|
||||
- `src/views/home/index.scss`
|
||||
- `src/views/system/config/seoAiRules.vue`
|
||||
- `src/views/system/config/index.vue`
|
||||
- `src/views/system/config/drawer.vue`
|
||||
- `src/views/site/index.vue`
|
||||
- `src/api/modules/video/list.ts`
|
||||
- `src/api/interface/video/list.ts`
|
||||
- `src/api/modules/system/config.ts`
|
||||
- `src/api/interface/system/config.ts`
|
||||
- `src/api/interface/site/list.ts`
|
||||
- `src/assets/json/authMenuList.json`
|
||||
|
||||
---
|
||||
|
||||
## 运行逻辑说明
|
||||
|
||||
### 1. 导入新域名后
|
||||
|
||||
系统现在会:
|
||||
|
||||
1. 注册域名
|
||||
2. 初始化 SEO copy 基础状态
|
||||
3. 初始化 site positioning
|
||||
4. 把 sitemap 生成加入队列
|
||||
5. 返回导入探测结果
|
||||
6. 在 Vue 后台弹出“下一步选择”
|
||||
|
||||
系统不会再偷偷自动触发 OpenAI。
|
||||
|
||||
### 2. 运营继续
|
||||
|
||||
如果由运营继续:
|
||||
|
||||
1. 先看导入探测摘要
|
||||
2. 确认页面无明显错误
|
||||
3. 再点击后台 AI 功能,显式触发 OpenAI 队列
|
||||
|
||||
### 3. 技术继续
|
||||
|
||||
如果由技术继续:
|
||||
|
||||
1. 直接复制后台给出的 Codex 提示词
|
||||
2. 在 VS Code 中打开当前工作区
|
||||
3. 把提示词发给 Codex
|
||||
4. 由 Codex 接管优化、复测、记录、收口
|
||||
|
||||
---
|
||||
|
||||
## 视频元数据空字段处理原则
|
||||
|
||||
这部分是公共规则,不只属于 GPT 模板:
|
||||
|
||||
1. 如果采集回来的演员、导演、语言、地区、年份、备注、简介等字段为空,先记录并进入缺字段工作台/任务池。
|
||||
2. 如果后续同一资源再次采集到真实非空字段,只允许在“原字段为空”的前提下回填。
|
||||
3. 不允许用采集结果覆盖已有真实非空值。
|
||||
4. 不允许静默编造结构化事实字段。
|
||||
5. 本地安全补全仅允许补:
|
||||
- `v_description`
|
||||
- `v_remarks`
|
||||
6. 本地安全补全只能基于已有真实字段组织资料型文案,不能编造演员、导演、年份、剧情。
|
||||
|
||||
---
|
||||
|
||||
## 计划任务与自动触发
|
||||
|
||||
当前安全边界是:
|
||||
|
||||
1. 可以自动刷新工作台和任务池
|
||||
2. 可以自动扫描空字段
|
||||
3. 可以自动归档待处理批次
|
||||
4. 不自动调用远程 AI 去补结构化事实
|
||||
5. 不自动联网搜索演员/导演等真实资料
|
||||
|
||||
也就是说:
|
||||
|
||||
- `自动扫描` 可以开
|
||||
- `自动派单` 可以开
|
||||
- `自动 OpenAI 补事实字段` 当前不建议默认开启
|
||||
|
||||
---
|
||||
|
||||
## 后续建议
|
||||
|
||||
### 建议继续保留的执行节奏
|
||||
|
||||
1. 每日先看蜘蛛日志
|
||||
2. 再看视频缺字段控制台
|
||||
3. 先处理高流量、高抓取、高优先级资源
|
||||
4. GPT 模板专属 SEO 文档继续写在 `docs/gpt-template-seo/`
|
||||
5. 公共规则、公共机制、公共流程统一汇总在 `docs/` 根目录
|
||||
|
||||
### 下一批接手 Codex 必看
|
||||
|
||||
接手人至少先读:
|
||||
|
||||
- `docs/2026-04-18-AI文案与视频元数据公共规则主线.md`
|
||||
- `docs/Codex启动提示词模板.md`
|
||||
- `docs/2026-04-18-视频缺失字段工作台V1说明.md`
|
||||
- `docs/2026-04-18-PHP与Vue收口交付总结.md`
|
||||
|
||||
---
|
||||
|
||||
## 一句话结论
|
||||
|
||||
这轮收口后,`视频缺字段工作台`、`任务池`、`公共 AI 规则`、`导入后继续分流` 已经形成一套可以复制到其它源的完整方案;后续其它服务器只要同步 `PHP + Vue` 两个仓库,就能拿到同一套后台能力和执行规则。
|
||||
379
docs/2026-04-18-视频缺失字段工作台V1说明.md
Normal file
379
docs/2026-04-18-视频缺失字段工作台V1说明.md
Normal file
@@ -0,0 +1,379 @@
|
||||
# 视频缺失字段工作台 V1 说明
|
||||
|
||||
## 目标
|
||||
|
||||
这套工作台用于把视频库里的空字段问题集中收口,给技术、运营、Codex 一个统一入口:
|
||||
|
||||
1. 看当前哪些字段缺失最严重
|
||||
2. 看哪些视频优先处理
|
||||
3. 复制一份可以直接派给 Codex 的提示词
|
||||
|
||||
## 当前范围
|
||||
|
||||
V1 已上线的是“工作台产物 + 后台读取入口 + 手动触发生成 + 历史运行记录”。
|
||||
|
||||
当前不做:
|
||||
|
||||
1. 不会因为发现空字段,就自动调用 AI API 去猜演员、导演、年份、地区、语言
|
||||
2. 不会在采集过程中,直接把不确定的结构化资料写回库
|
||||
3. 不会自动加入计划任务周期跑
|
||||
|
||||
## 命令行入口
|
||||
|
||||
### 1. 只生成工作台
|
||||
|
||||
```bash
|
||||
php SEONexus/code/think video:metadata:workbench --sample=20 --queue-limit=100 --prompt-limit=20
|
||||
```
|
||||
|
||||
参数说明:
|
||||
|
||||
1. `--sample`
|
||||
说明:缺失字段样本数,用于抽样展示典型问题视频
|
||||
2. `--queue-limit`
|
||||
说明:重采优先队列条数,用于给出优先处理的视频集合
|
||||
3. `--prompt-limit`
|
||||
说明:生成给 Codex 的派单样本数
|
||||
|
||||
### 2. 一次性生成“工作台 + 缺字段任务池”
|
||||
|
||||
```bash
|
||||
php SEONexus/code/think video:metadata:task-pool --sample=20 --queue-limit=100 --prompt-limit=20 --batch-size=20 --batch-limit=10
|
||||
```
|
||||
|
||||
参数说明:
|
||||
|
||||
1. `--sample`
|
||||
说明:缺失字段样本数,用于抽样展示典型问题视频
|
||||
2. `--queue-limit`
|
||||
说明:重采优先队列条数,用于给出优先处理的视频集合
|
||||
3. `--prompt-limit`
|
||||
说明:生成给 Codex 的派单样本数
|
||||
4. `--batch-size`
|
||||
说明:每个待处理批次包含多少条视频
|
||||
5. `--batch-limit`
|
||||
说明:单次最多生成多少个批次
|
||||
|
||||
## 后台接口
|
||||
|
||||
1. `GET /admin/video/metadata/missing/workbench`
|
||||
作用:读取最新工作台摘要;如果没有现成产物,会现场生成一次
|
||||
2. `GET /admin/video/metadata/missing/prompt`
|
||||
作用:读取给 Codex 的最新提示词
|
||||
3. `GET /admin/video/metadata/missing/workbench/runs`
|
||||
作用:读取历史运行记录,方便做前后对比
|
||||
4. `POST /admin/video/metadata/missing/workbench/run`
|
||||
作用:强制刷新工作台产物
|
||||
|
||||
## 产物目录
|
||||
|
||||
生成后会写到:
|
||||
|
||||
```text
|
||||
SEONexus/code/app/public/_admin_templates/video-metadata-missing-workbench/
|
||||
```
|
||||
|
||||
主要文件:
|
||||
|
||||
1. `index.json`
|
||||
作用:后台摘要数据源
|
||||
2. `index.html`
|
||||
作用:可直接打开看的工作台页面
|
||||
3. `prompts/latest.md`
|
||||
作用:可以复制给 Codex 的接手提示词
|
||||
|
||||
历史运行记录会写到:
|
||||
|
||||
```text
|
||||
SEONexus/code/app/public/_admin_templates/video-metadata-missing-workbench/runs/
|
||||
```
|
||||
|
||||
每次生成都会保存一份:
|
||||
|
||||
1. `video-metadata-missing-workbench.summary.json`
|
||||
2. `video-metadata-missing-workbench.summary.html`
|
||||
3. `prompt.md`
|
||||
|
||||
## 适合怎么用
|
||||
|
||||
### 场景一:技术自己接手
|
||||
|
||||
1. 后台刷新工作台
|
||||
2. 打开 `prompt` 或 `latest.md`
|
||||
3. 把提示词发给 Codex
|
||||
4. Codex 分析优先级、批次、风险和后续执行建议
|
||||
|
||||
### 场景二:运营先发现问题
|
||||
|
||||
1. 运营在后台看到字段缺失严重
|
||||
2. 让技术刷新工作台
|
||||
3. 技术把提示词发给 Codex 接手
|
||||
|
||||
## 为什么暂时不自动调 AI
|
||||
|
||||
因为空字段里最重的是:
|
||||
|
||||
1. `v_director`
|
||||
2. `v_actor`
|
||||
|
||||
这些都是结构化事实字段,不能靠 AI 猜。
|
||||
现阶段更优策略是:
|
||||
|
||||
1. 先扫描并收集
|
||||
2. 先重采、补源、人工确认
|
||||
3. 只把文案型字段交给 AI
|
||||
|
||||
## 当前是否自动跑
|
||||
|
||||
当前不是自动计划任务触发,也不是采集时自动偷偷触发。
|
||||
|
||||
现在的触发方式只有两种:
|
||||
|
||||
1. 技术手动执行命令
|
||||
2. 后台手动点击刷新接口
|
||||
|
||||
如果技术希望挂到服务器计划任务,建议先挂下面这条命令:
|
||||
|
||||
```bash
|
||||
php /www/wwwroot/diff-maccms/SEONexus/code/think video:metadata:task-pool --sample=8 --queue-limit=50 --prompt-limit=10 --batch-size=10 --batch-limit=10
|
||||
```
|
||||
|
||||
推荐原因:
|
||||
|
||||
1. 一次执行同时刷新工作台和缺字段任务池
|
||||
2. 不会自动补结构化事实字段
|
||||
3. 只生成产物,不会偷偷调用 OpenAI API
|
||||
4. 更适合作为跨项目、跨模板的安全计划任务入口
|
||||
|
||||
如果你是通过数据库里的计划任务表来挂,不走系统 cron,也已经留好了安全入口:
|
||||
|
||||
1. `pt_code` 使用:`REFRESH_VIDEO_METADATA_TASK_POOL`
|
||||
2. 对应逻辑入口:`PlanTask::refreshVideoMetadataTaskPool()`
|
||||
3. 默认行为:
|
||||
- 只刷新工作台和任务池产物
|
||||
- 不自动补演员、导演、年份、地区、语言、发布日期
|
||||
- 不自动调用 OpenAI API
|
||||
- 不自动写回视频库
|
||||
|
||||
建议口径:
|
||||
|
||||
1. 先在测试环境或低频环境启用
|
||||
2. 先把 `pt_limit` 设成较大间隔,比如 `3600` 或 `21600`
|
||||
3. 跑稳定后,再决定是否提升频率
|
||||
|
||||
也就是说,把代码同步到其它项目后,不会自己偷偷扫描全表,不会自己调用 AI。
|
||||
|
||||
如果别的项目想启用这条线,最小启动步骤是:
|
||||
|
||||
1. 同步最新代码
|
||||
2. 执行一次 `video:metadata:task-pool`
|
||||
3. 后台读取任务池接口或直接打开任务池产物页
|
||||
|
||||
这样就能先把“缺字段扫描、待处理批次、Codex 提示词”全跑起来。
|
||||
|
||||
## 其它项目怎么启动
|
||||
|
||||
如果把这套代码同步到其它项目,要启动这个工作台,只需要:
|
||||
|
||||
1. 同步最新代码
|
||||
2. 执行一次
|
||||
|
||||
```bash
|
||||
php SEONexus/code/think video:metadata:workbench
|
||||
```
|
||||
|
||||
3. 后台读取对应接口或直接打开产物页
|
||||
|
||||
## 下一阶段建议
|
||||
|
||||
V2 可以继续做:
|
||||
|
||||
1. 扫描全表空字段后,自动收集到“待处理池”
|
||||
2. 后台展示“缺字段任务池”
|
||||
3. 后台一键生成 Codex 派单提示词
|
||||
4. 再决定是否接计划任务定时刷新
|
||||
5. 给计划任务增加“只刷新工作台,不自动补事实字段”的安全模式
|
||||
|
||||
当前其中第 1 到 3 步已经具备基础能力,第 4 步现在可以通过挂 `video:metadata:task-pool` 命令实现。
|
||||
|
||||
## 当前新增:缺字段待处理池
|
||||
|
||||
当前已经补出第二层:
|
||||
|
||||
1. `GET /admin/video/metadata/missing/task-pool`
|
||||
作用:读取缺字段待处理池
|
||||
2. `GET /admin/video/metadata/missing/task-pool/status`
|
||||
作用:读取缺字段任务池总览状态,包含工作台规模、任务池批次状态、计划任务状态
|
||||
3. `GET /admin/video/metadata/missing/task-pool/plan-task/status`
|
||||
作用:读取缺字段计划任务的启用情况和间隔
|
||||
4. `GET /admin/video/metadata/missing/task-pool/ops`
|
||||
作用:读取缺字段任务池运维总览,包含推荐动作、常用命令和产物路径
|
||||
5. `POST /admin/video/metadata/missing/task-pool/plan-task/status/save`
|
||||
作用:保存缺字段计划任务的启用状态和执行间隔
|
||||
6. `GET /admin/video/metadata/missing/task-pool/batch/detail`
|
||||
作用:读取单个批次详情
|
||||
7. `GET /admin/video/metadata/missing/task-pool/batch/prompt`
|
||||
作用:读取单个批次的 Codex 提示词
|
||||
8. `POST /admin/video/metadata/missing/task-pool/run`
|
||||
作用:重新生成缺字段待处理池
|
||||
9. `POST /admin/video/metadata/missing/task-pool/batch/state/save`
|
||||
作用:保存批次状态
|
||||
|
||||
对应产物目录:
|
||||
|
||||
```text
|
||||
SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/
|
||||
```
|
||||
|
||||
这套任务池现在已经包含:
|
||||
|
||||
1. 最新总览 `index.json` / `index.html`
|
||||
2. 单批次稳定详情 `batches/*.json`
|
||||
3. 单批次稳定提示词 `batches/*.md`
|
||||
4. 单批次状态历史 `state/history/*.json`
|
||||
5. 单批次复制提示词按钮和动作链接
|
||||
6. 状态总览页 `status/index.json` / `status/index.html`
|
||||
|
||||
状态总览页的作用:
|
||||
|
||||
1. 直接展示当前缺字段视频规模
|
||||
2. 展示当前任务池批次数和批次状态统计
|
||||
3. 展示数据库计划任务状态
|
||||
4. 适合不进后台接口时,直接打开 HTML 快速查看
|
||||
|
||||
## 运维总览页
|
||||
|
||||
除了状态总览页,当前还补了一个更偏“后台操作指引”的运维总览页:
|
||||
|
||||
```text
|
||||
SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/ops/index.html
|
||||
SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/ops/index.json
|
||||
```
|
||||
|
||||
它的作用不是替代状态页,而是把“现在该做什么”讲得更直接:
|
||||
|
||||
1. 当前推荐动作
|
||||
2. 当前可执行命令
|
||||
3. 当前工作台和任务池产物路径
|
||||
4. 当前计划任务是否启用
|
||||
|
||||
这个页面适合给:
|
||||
|
||||
1. 运营看,少看接口字段
|
||||
2. 技术看,少翻文档
|
||||
3. 接手的 Codex 看,直接按页面里的命令继续执行
|
||||
|
||||
当前这页默认会在刷新任务池时一起生成,后续如果计划任务接上,也会同步刷新。
|
||||
|
||||
## 后台首页快捷入口
|
||||
|
||||
当前后台首页 `/admin` 已补成快捷入口页,直接提供下面这些跳转:
|
||||
|
||||
1. 工作台
|
||||
2. 任务池
|
||||
3. 状态总览
|
||||
4. 运维总览
|
||||
5. 计划任务状态
|
||||
|
||||
如果你是新接手的 Codex,先打开后台首页,再按页内链接进入对应模块,会比直接记接口路径更快。
|
||||
|
||||
## Vue 后台迁移说明
|
||||
|
||||
这一轮已经把“视频缺字段工作台 / 任务池 / 状态总览 / 运维总览”的页面入口迁到 `SEONexusAdmin` 这个 Vue 后台。
|
||||
|
||||
现在要区分两类入口:
|
||||
|
||||
1. PHP `/admin/video/...`
|
||||
作用:数据接口
|
||||
2. Vue `#/video/...`
|
||||
作用:后台实际页面入口
|
||||
|
||||
当前正确的后台页面路径是:
|
||||
|
||||
1. `#/video/metadata/missing/workbench`
|
||||
2. `#/video/metadata/missing/task-pool`
|
||||
3. `#/video/metadata/missing/task-pool/status`
|
||||
4. `#/video/metadata/missing/task-pool/ops`
|
||||
|
||||
说明:
|
||||
|
||||
1. 不要再把 `/admin/video/metadata/missing/task-pool/ops` 当成最终后台页面 URL
|
||||
2. 这个路径在当前体系里更适合作为 PHP 接口或旧产物路径,不适合作为 Vue 后台页面入口
|
||||
3. 如果要同步到其它服务器,除了同步 `SEONexus/code` 里的 PHP 数据接口,还必须同步 `SEONexusAdmin` 的 Vue 页面和 `dist`
|
||||
|
||||
## 批次状态说明
|
||||
|
||||
当前批次状态先走“文件态”,不走数据库表。
|
||||
|
||||
状态文件位置:
|
||||
|
||||
```text
|
||||
SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/state/batch-status.json
|
||||
```
|
||||
|
||||
状态历史文件位置:
|
||||
|
||||
```text
|
||||
SEONexus/code/app/public/_admin_templates/video-metadata-missing-task-pool/state/history/
|
||||
```
|
||||
|
||||
可用状态:
|
||||
|
||||
1. `pending`
|
||||
含义:待处理
|
||||
2. `dispatched`
|
||||
含义:已派单,已经发给 Codex 或某位技术
|
||||
3. `processing`
|
||||
含义:处理中
|
||||
4. `done`
|
||||
含义:已完成
|
||||
5. `skipped`
|
||||
含义:已跳过
|
||||
|
||||
当前这样设计的原因:
|
||||
|
||||
1. 不需要改数据库
|
||||
2. 可以马上同步到其它项目使用
|
||||
3. 即使别的项目环境不一致,也能直接落地
|
||||
4. 可以先把“当前状态”和“历史轨迹”都收进文件,再决定是否升级成数据库态
|
||||
|
||||
## 批次历史接口
|
||||
|
||||
当前已经补出:
|
||||
|
||||
1. `GET /admin/video/metadata/missing/task-pool/batch/history`
|
||||
作用:读取某个批次的状态变化历史
|
||||
|
||||
2. `POST /admin/video/metadata/missing/task-pool/batch/state/save`
|
||||
作用:保存某个批次的状态变化,同时写入历史
|
||||
|
||||
3. `GET /admin/video/metadata/missing/task-pool/batch/detail`
|
||||
作用:读取某个批次的完整内容、当前状态、历史
|
||||
|
||||
4. `GET /admin/video/metadata/missing/task-pool/batch/prompt`
|
||||
作用:直接拿某个批次专属的 Codex 接手提示词
|
||||
|
||||
说明:
|
||||
|
||||
1. 每次状态变化都会追加写入历史
|
||||
2. 历史时间戳已经提升到微秒级,避免同一秒连续操作时顺序混乱
|
||||
|
||||
后面如果这套机制稳定,再考虑升级成数据库态任务池。
|
||||
|
||||
## 计划任务建议值
|
||||
|
||||
如果你准备把 `REFRESH_VIDEO_METADATA_TASK_POOL` 放进后台计划任务列表,建议先这样配:
|
||||
|
||||
1. `pt_enable = 0`
|
||||
说明:默认先不启用,确认后台状态页和产物页正常后再打开
|
||||
2. `pt_limit = 86400`
|
||||
说明:先按 1 天跑一次,避免太频繁刷新
|
||||
|
||||
启用时可以先观察:
|
||||
|
||||
1. 工作台是否持续刷新
|
||||
2. 任务池批次状态是否正常
|
||||
3. 是否有其它项目需要同步相同计划任务
|
||||
|
||||
如果后台先想看“该不该启用、启用后跑什么”,优先打开运维总览页;
|
||||
如果只是想看当前规模和状态,优先打开状态总览页。
|
||||
@@ -85,9 +85,121 @@
|
||||
- 提交前确认 README 仍能作为入口索引使用
|
||||
```
|
||||
|
||||
## 模板 4:视频元数据 / 重采队列接手
|
||||
|
||||
```md
|
||||
你这次先不要自由发挥,先按这个顺序接手:
|
||||
|
||||
1. 先看 `/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-AI文案与视频元数据公共规则主线.md`
|
||||
2. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-视频元数据缺失处理公共执行规则.md`
|
||||
3. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/17-GPT模板AI文案最高规则-2026-04-18.md`
|
||||
4. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/18-GPT模板演员导演缺失重采Top50执行单-2026-04-18.md`
|
||||
5. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/19-GPT模板演员导演缺失Top20现场执行单-2026-04-18.md`
|
||||
6. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/20-GPT模板演员导演缺失Top21-50现场执行单-2026-04-18.md`
|
||||
7. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/21-GPT模板演员导演缺失重采结果回填模板-2026-04-18.md`
|
||||
8. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/22-GPT模板第一轮重采实战复盘记录单-2026-04-18.md`
|
||||
9. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/25-GPT模板第一二轮重采对比复盘-2026-04-18.md`
|
||||
10. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/26-GPT模板第三轮短剧演员放量验证复盘-2026-04-18.md`
|
||||
11. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/27-GPT模板第四轮短剧演员主线确立复盘-2026-04-18.md`
|
||||
12. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/28-GPT模板第五轮短剧演员批处理复盘-2026-04-18.md`
|
||||
13. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/29-GPT模板短剧演员双源重采SOP-2026-04-18.md`
|
||||
14. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/30-GPT模板第六轮短剧演员复盘与解析容错修复-2026-04-18.md`
|
||||
15. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/31-GPT模板字段补齐对7天SEO收益链路说明-2026-04-18.md`
|
||||
16. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/32-GPT模板第七轮短剧演员尾部样本复盘-2026-04-18.md`
|
||||
17. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/34-GPT模板第八轮短剧演员新鲜样本复盘-2026-04-18.md`
|
||||
18. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/35-GPT模板第九轮现代都市新样本复盘-2026-04-18.md`
|
||||
19. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/gpt-template-seo/36-GPT模板第十轮样本边界验证复盘-2026-04-18.md`
|
||||
20. 再看 `/www/wwwroot/diff-maccms/SEONexus/code/storage/video-metadata-audit/latest.md`
|
||||
21. 再看 `/www/wwwroot/diff-maccms/SEONexus/code/storage/video-metadata-recrawl-queue/latest.md`
|
||||
|
||||
当前任务:
|
||||
- 继续推进视频库元数据优化
|
||||
- 重点处理 `v_actor / v_director` 缺失
|
||||
|
||||
先记住:
|
||||
- `AI文案规则` 和 `视频参数缺失处理规则` 现在属于全项目公共规则,不再只是 GPT 模板规则
|
||||
- GPT 目录里的文档主要是“这条支线已经跑出来的实战结果与样本经验”
|
||||
- 当前“视频缺字段工作台 / 任务池 / 状态 / 运维”页面入口已经迁到 `SEONexusAdmin` 这个 Vue 后台
|
||||
- `PHP /admin/video/...` 主要看作数据接口,不再看作最终后台页面入口
|
||||
- 当前正确的后台页面路径应优先走:
|
||||
- `#/video/metadata/missing/workbench`
|
||||
- `#/video/metadata/missing/task-pool`
|
||||
- `#/video/metadata/missing/task-pool/status`
|
||||
- `#/video/metadata/missing/task-pool/ops`
|
||||
|
||||
工作边界:
|
||||
- 不允许用 AI 猜演员、导演、年份、地区、语言、发布日期
|
||||
- 结构化字段优先走重采 / 补源 / 人工校正
|
||||
- 文案型字段只补空,不覆盖已有内容
|
||||
- `内详 / 未知 / 不详 / 待补充 / 暂无 / 未知演员 / 未知导演` 这类占位值一律按缺失处理
|
||||
- 每次执行后必须复跑审计,确认缺口是否真实下降
|
||||
- 已验证主线:`现代都市短剧 + 双源 + v_actor`
|
||||
- 当前已验证结果:
|
||||
- 第二轮 `5/5` 演员补齐
|
||||
- 第三轮 `8/10` 演员补齐
|
||||
- 第四轮 `10/10` 演员补齐
|
||||
- 第五轮 `10/10` 演员补齐
|
||||
- 第六轮 `8/10` 演员补齐
|
||||
- 第七轮 `6/10` 演员补齐
|
||||
- 第八轮 `10/10` 演员补齐
|
||||
- 第九轮 `8/10` 演员补齐
|
||||
- 第十轮 `5/10` 演员补齐
|
||||
- 当前导演字段三轮连续 `0` 补齐,不要再把 `v_director` 当作短期主 KPI
|
||||
- 当前已修复 `youzhi` 未知分类 `type_id=95` 导致整轮中断的问题,后续这类样本允许“正常失败”,不允许“整批崩溃”
|
||||
- 当前阶段要认识到:越往后进入尾部样本,单轮成功率会回落,但只要 `v_actor` 仍持续净下降,这条线依然值得推进
|
||||
- 第八轮已经再次证明:第七轮的回落主要来自尾部样本池,不是主线失效;当前应坚持“新鲜短剧样本优先、低收益尾部样本降级”
|
||||
- 第九轮进一步证明:换到另一批现代都市新样本后依然能拿到 `8/10`,当前主线已经具备连续复现性
|
||||
- 第十轮进一步证明:主线不能无限泛化;一旦混入 `短剧大全 / 古风替嫁 / 玄门侧`,收益会明显回落,因此核心批处理必须重新收窄到 `现代都市新鲜样本`
|
||||
- 要理解当前动作的 SEO 目标:先看 `31-GPT模板字段补齐对7天SEO收益链路说明-2026-04-18.md`
|
||||
|
||||
优先输出:
|
||||
1. 当前主缺口是什么
|
||||
2. 当前 Top 队列准备怎么执行
|
||||
3. 本轮执行后哪些字段下降了
|
||||
4. 哪个源站更有效
|
||||
5. 下一轮优先补哪一批
|
||||
|
||||
优先命令:
|
||||
1. `php think video:metadata:recrawl-queue --limit 50`
|
||||
2. `php think video:metadata:recrawl-run --limit 10 --dry-run`
|
||||
3. `php think video:metadata:recrawl-run --v-ids=156037 --sources=douban,youzhi`
|
||||
4. `php think video:metadata:audit --sample 10`
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. 新会话如果是“分析型任务”,优先用模板 1
|
||||
2. 新会话如果是“线上辅助排障”,优先用模板 2
|
||||
3. 新会话如果只是“整理文档 / 收口”,优先用模板 3
|
||||
4. 如果任务混合,可以把模板 1 和模板 3 合并后再发
|
||||
4. 新会话如果是“视频库字段补全 / 重采队列执行”,优先用模板 4
|
||||
5. 如果任务混合,可以把模板 1、3、4 按需合并后再发
|
||||
|
||||
## 模板 5:公共规则极简接手
|
||||
|
||||
```md
|
||||
你先不要自由发挥,先按这个顺序接手:
|
||||
|
||||
1. 先看 `/www/wwwroot/diff-maccms/SEONexus/docs/README.md`
|
||||
2. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-AI文案与视频元数据公共规则主线.md`
|
||||
3. 再看 `/www/wwwroot/diff-maccms/SEONexus/docs/2026-04-18-视频元数据缺失处理公共执行规则.md`
|
||||
4. 如果任务涉及多人协作或老时间线,再看:
|
||||
`/www/wwwroot/diff-maccms/SEONexus/docs/1270-SEONexus-Codex多版本协作交接总文档.md`
|
||||
|
||||
先记住:
|
||||
- `AI文案规则` 和 `视频元数据缺失处理规则` 现在属于全项目公共规则
|
||||
- 结构化字段不能靠 AI 猜
|
||||
- 文案型字段只能在真实性边界内补空
|
||||
- 所有补齐结果优先写回视频库
|
||||
|
||||
然后你再判断任务是:
|
||||
1. GPT 模板支线
|
||||
2. 蜘蛛 / nginx / 缓存排障
|
||||
3. 视频元数据重采
|
||||
4. 文档收口
|
||||
|
||||
优先输出:
|
||||
1. 当前主结论
|
||||
2. 当前任务属于哪条线
|
||||
3. 当前适用的是哪套公共规则
|
||||
4. 下一步建议
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user