feat: add metadata workbench and shared seo rules

This commit is contained in:
root
2026-04-18 20:23:30 +08:00
parent 2ea87050a5
commit cec1fdbc69
44 changed files with 5488 additions and 68 deletions

View File

@@ -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;

View File

@@ -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;

View 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,
]);
}
}

View File

@@ -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

View 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),
];
}
}

View File

@@ -101,6 +101,9 @@ class SeoCopyBatchPrepareHelper
'- 文风要自然、克制、站内导航型,避免“全网最全”“免费观看”等夸张承诺。',
'- 输出必须能直接写入目标 JSON 文件。',
'',
'## Highest Rules',
SeoCopyAiProviderHelper::buildSharedAiRuleBlock(),
'',
'## Scene Rules',
(string)$strRulesJson,
'',

View File

@@ -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;
}

View File

@@ -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 增强。'

View 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));
}
}

View 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,
];
}
}

View 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;
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View 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');
}
}

View File

@@ -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;
}
}