feat: add metadata workbench and shared seo rules
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user