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,
|
||||
],
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user