feat: add metadata workbench and shared seo rules
This commit is contained in:
134
code/app/command/VideoMetadataAuditCommand.php
Normal file
134
code/app/command/VideoMetadataAuditCommand.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\model\VideoModel;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataAuditCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:audit')
|
||||
->addOption('sample', null, Option::VALUE_OPTIONAL, '抽样输出条数,默认 20', 20)
|
||||
->setDescription('审计视频库缺失字段情况,并生成历史回填分析文件');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intSample = max(1, (int)$input->getOption('sample'));
|
||||
$arrSummary = VideoModel::getInstance()->buildMissingMetadataAuditSummary($intSample);
|
||||
|
||||
$strRoot = rtrim((string)root_path(), '/');
|
||||
$strOutputRoot = $strRoot . '/storage/video-metadata-audit';
|
||||
if (!is_dir($strOutputRoot)) {
|
||||
@mkdir($strOutputRoot, 0777, true);
|
||||
}
|
||||
|
||||
$strJsonPath = $strOutputRoot . '/latest.json';
|
||||
$strMarkdownPath = $strOutputRoot . '/latest.md';
|
||||
|
||||
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL);
|
||||
file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary));
|
||||
|
||||
$output->writeln('视频元数据缺失审计已生成');
|
||||
$output->writeln('总视频数:' . (int)($arrSummary['total_videos'] ?? 0));
|
||||
$output->writeln('存在任一缺失字段的视频数:' . (int)($arrSummary['videos_with_any_missing_metadata'] ?? 0));
|
||||
$output->writeln('JSON:' . $strJsonPath);
|
||||
$output->writeln('Markdown:' . $strMarkdownPath);
|
||||
|
||||
foreach ((array)($arrSummary['field_stats'] ?? []) as $arrField) {
|
||||
$output->writeln(sprintf(
|
||||
'%s => 缺失 %d 条,占比 %.2f%%',
|
||||
(string)($arrField['field'] ?? ''),
|
||||
(int)($arrField['missing_count'] ?? 0),
|
||||
((float)($arrField['missing_ratio'] ?? 0)) * 100
|
||||
));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function buildMarkdown(array $arrSummary): string
|
||||
{
|
||||
$arrLines = [];
|
||||
$arrLines[] = '# 视频元数据缺失审计';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? '');
|
||||
$arrLines[] = '- 总视频数:' . (int)($arrSummary['total_videos'] ?? 0);
|
||||
$arrLines[] = '- 任一缺失字段视频数:' . (int)($arrSummary['videos_with_any_missing_metadata'] ?? 0);
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '## 字段缺失统计';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '| 字段 | 缺失数 | 缺失占比 | 建议动作 |';
|
||||
$arrLines[] = '| --- | ---: | ---: | --- |';
|
||||
|
||||
foreach ((array)($arrSummary['field_stats'] ?? []) as $arrField) {
|
||||
$strField = (string)($arrField['field'] ?? '');
|
||||
$intMissingCount = (int)($arrField['missing_count'] ?? 0);
|
||||
$floatRatio = ((float)($arrField['missing_ratio'] ?? 0)) * 100;
|
||||
$arrLines[] = sprintf(
|
||||
'| %s | %d | %.2f%% | %s |',
|
||||
$strField,
|
||||
$intMissingCount,
|
||||
$floatRatio,
|
||||
$this->buildSuggestedAction($strField)
|
||||
);
|
||||
}
|
||||
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '## 抽样样本';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '| v_id | 片名 | 分类 | 年份 | 备注 | 缺失字段 | 更新时间 |';
|
||||
$arrLines[] = '| ---: | --- | --- | --- | --- | --- | --- |';
|
||||
|
||||
foreach ((array)($arrSummary['samples'] ?? []) as $arrSample) {
|
||||
$arrLines[] = sprintf(
|
||||
'| %d | %s | %s | %s | %s | %s | %s |',
|
||||
(int)($arrSample['v_id'] ?? 0),
|
||||
$this->escapeMarkdown((string)($arrSample['v_name'] ?? '')),
|
||||
$this->escapeMarkdown((string)($arrSample['v_category'] ?? '')),
|
||||
$this->escapeMarkdown((string)($arrSample['v_year'] ?? '')),
|
||||
$this->escapeMarkdown((string)($arrSample['v_remarks'] ?? '')),
|
||||
$this->escapeMarkdown(implode(', ', (array)($arrSample['missing_fields'] ?? []))),
|
||||
$this->escapeMarkdown((string)($arrSample['updated_at'] ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '## 判读原则';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '1. 演员、导演、年份、地区、语言这类结构化字段,优先靠重采、补源、人工校正,不能让 AI 猜。';
|
||||
$arrLines[] = '2. 简介、备注这类文案型字段,如果事实基础足够,可以走 AI 生成并写回视频库,但必须只补空字段。';
|
||||
$arrLines[] = '3. 后续采集器已接入“只补空字段、不覆盖已有值”的写库逻辑,新采到的有效字段会逐步沉淀进库。';
|
||||
$arrLines[] = '';
|
||||
|
||||
return implode(PHP_EOL, $arrLines) . PHP_EOL;
|
||||
}
|
||||
|
||||
protected function buildSuggestedAction(string $strField): string
|
||||
{
|
||||
return match ($strField) {
|
||||
'v_actor', 'v_director', 'v_year', 'v_lang', 'v_lang_en', 'v_area', 'v_area_en', 'v_publish_date'
|
||||
=> '优先重采或补源,不建议 AI 猜测',
|
||||
'v_description', 'v_remarks'
|
||||
=> '可在事实边界内走 AI 补全并写库',
|
||||
default => '先审计,再决定重采或补写',
|
||||
};
|
||||
}
|
||||
|
||||
protected function escapeMarkdown(string $strValue): string
|
||||
{
|
||||
$strValue = trim($strValue);
|
||||
if ($strValue === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return str_replace('|', '\\|', $strValue);
|
||||
}
|
||||
}
|
||||
109
code/app/command/VideoMetadataCopyFillCommand.php
Normal file
109
code/app/command/VideoMetadataCopyFillCommand.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\helper\VideoMetadataCopyFillHelper;
|
||||
use app\model\VideoModel;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataCopyFillCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:fill-copy')
|
||||
->addOption('limit', null, Option::VALUE_OPTIONAL, '本次最多处理多少条,默认 500', 500)
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '只预览,不写库')
|
||||
->setDescription('批量补写视频库里为空的简介和备注,只补空字段');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intLimit = max(1, min((int)$input->getOption('limit'), 5000));
|
||||
$boolDryRun = (bool)$input->getOption('dry-run');
|
||||
$VideoModel = VideoModel::getInstance();
|
||||
|
||||
$Cursor = $VideoModel->getCol()->find(
|
||||
[
|
||||
'$or' => [
|
||||
['v_description' => ['$exists' => false]],
|
||||
['v_description' => ''],
|
||||
['v_description' => null],
|
||||
['v_remarks' => ['$exists' => false]],
|
||||
['v_remarks' => ''],
|
||||
['v_remarks' => null],
|
||||
],
|
||||
],
|
||||
[
|
||||
'limit' => $intLimit,
|
||||
'sort' => ['updated_at' => -1, 'v_id' => -1],
|
||||
'projection' => [
|
||||
'_id' => 0,
|
||||
'v_id' => 1,
|
||||
'v_name' => 1,
|
||||
'v_category' => 1,
|
||||
'v_parent_category' => 1,
|
||||
'v_year' => 1,
|
||||
'v_area' => 1,
|
||||
'v_lang' => 1,
|
||||
'v_director' => 1,
|
||||
'v_actor' => 1,
|
||||
'v_remarks' => 1,
|
||||
'v_description' => 1,
|
||||
'v_isend' => 1,
|
||||
'v_publish_date' => 1,
|
||||
],
|
||||
'typeMap' => VideoModel::$arrOptions['typeMap'],
|
||||
]
|
||||
);
|
||||
|
||||
$intScanned = 0;
|
||||
$intUpdated = 0;
|
||||
$arrSamples = [];
|
||||
|
||||
foreach ($Cursor as $arrVideo) {
|
||||
$intScanned++;
|
||||
$arrUpdate = VideoMetadataCopyFillHelper::buildFillPayload((array)$arrVideo, 'local_copy_fill_command');
|
||||
if (empty($arrUpdate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrSamples[] = [
|
||||
'v_id' => (int)($arrVideo['v_id'] ?? 0),
|
||||
'v_name' => (string)($arrVideo['v_name'] ?? ''),
|
||||
'filled_fields' => (array)($arrUpdate['v_metadata_fill']['filled_fields'] ?? []),
|
||||
'generated_description' => (string)($arrUpdate['v_description'] ?? ''),
|
||||
'generated_remarks' => (string)($arrUpdate['v_remarks'] ?? ''),
|
||||
];
|
||||
|
||||
if (!$boolDryRun) {
|
||||
$VideoModel->updateOne(
|
||||
['v_id' => (int)($arrVideo['v_id'] ?? 0)],
|
||||
['$set' => $arrUpdate]
|
||||
);
|
||||
}
|
||||
|
||||
$intUpdated++;
|
||||
}
|
||||
|
||||
$output->writeln('视频文案型元数据补写完成');
|
||||
$output->writeln('模式:' . ($boolDryRun ? 'dry-run' : 'write'));
|
||||
$output->writeln('扫描条数:' . $intScanned);
|
||||
$output->writeln('命中可补写条数:' . $intUpdated);
|
||||
|
||||
foreach (array_slice($arrSamples, 0, 5) as $arrSample) {
|
||||
$output->writeln(sprintf(
|
||||
'#%d %s => %s',
|
||||
(int)($arrSample['v_id'] ?? 0),
|
||||
(string)($arrSample['v_name'] ?? ''),
|
||||
implode(', ', (array)($arrSample['filled_fields'] ?? []))
|
||||
));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
50
code/app/command/VideoMetadataMissingPlanTaskSeedCommand.php
Normal file
50
code/app/command/VideoMetadataMissingPlanTaskSeedCommand.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\model\PlanTaskModel;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataMissingPlanTaskSeedCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('plan:seed:video-metadata-missing')
|
||||
->setDescription('补齐视频缺字段任务池计划任务 REFRESH_VIDEO_METADATA_TASK_POOL');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$strCode = 'REFRESH_VIDEO_METADATA_TASK_POOL';
|
||||
$arrData = [
|
||||
'pt_name' => '视频缺字段任务池刷新',
|
||||
'pt_code' => $strCode,
|
||||
'pt_enable' => 0,
|
||||
'pt_limit' => 24 * 3600,
|
||||
'pt_last_exec' => 0,
|
||||
];
|
||||
|
||||
$PlanTaskModel = PlanTaskModel::where('pt_code', $strCode)->find();
|
||||
if ($PlanTaskModel instanceof PlanTaskModel) {
|
||||
$output->writeln('计划任务已存在,无需重复创建:' . $strCode);
|
||||
$output->writeln('pt_id:' . (int)$PlanTaskModel->pt_id);
|
||||
$output->writeln('pt_name:' . (string)$PlanTaskModel->pt_name);
|
||||
$output->writeln('pt_enable:' . (int)$PlanTaskModel->pt_enable);
|
||||
$output->writeln('pt_limit:' . (int)$PlanTaskModel->pt_limit);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$intId = (int)PlanTaskModel::insertGetId($arrData);
|
||||
$output->writeln('计划任务已创建:' . $strCode);
|
||||
$output->writeln('pt_id:' . $intId);
|
||||
$output->writeln('pt_name:' . $arrData['pt_name']);
|
||||
$output->writeln('pt_enable:' . $arrData['pt_enable']);
|
||||
$output->writeln('pt_limit:' . $arrData['pt_limit']);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
55
code/app/command/VideoMetadataMissingTaskPoolCommand.php
Normal file
55
code/app/command/VideoMetadataMissingTaskPoolCommand.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\helper\VideoMetadataMissingRefreshHelper;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataMissingTaskPoolCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:task-pool')
|
||||
->addOption('sample', null, Option::VALUE_OPTIONAL, '缺失字段样本数,默认 20', 20)
|
||||
->addOption('queue-limit', null, Option::VALUE_OPTIONAL, '重采优先队列条数,默认 100', 100)
|
||||
->addOption('prompt-limit', null, Option::VALUE_OPTIONAL, 'Codex 派单样本数,默认 20', 20)
|
||||
->addOption('batch-size', null, Option::VALUE_OPTIONAL, '每批数量,默认 20', 20)
|
||||
->addOption('batch-limit', null, Option::VALUE_OPTIONAL, '最大批次数,默认 10', 10)
|
||||
->setDescription('生成视频缺字段任务池产物,供后台展示、Codex 接手和计划任务挂接');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intSample = max(1, min(100, (int)$input->getOption('sample')));
|
||||
$intQueueLimit = max(1, min(500, (int)$input->getOption('queue-limit')));
|
||||
$intPromptLimit = max(1, min(100, (int)$input->getOption('prompt-limit')));
|
||||
$intBatchSize = max(5, min(100, (int)$input->getOption('batch-size')));
|
||||
$intBatchLimit = max(1, min(50, (int)$input->getOption('batch-limit')));
|
||||
|
||||
$arrResult = VideoMetadataMissingRefreshHelper::refresh([
|
||||
'sample' => $intSample,
|
||||
'queue_limit' => $intQueueLimit,
|
||||
'prompt_limit' => $intPromptLimit,
|
||||
'batch_size' => $intBatchSize,
|
||||
'batch_limit' => $intBatchLimit,
|
||||
]);
|
||||
$arrWorkbenchSummary = (array)($arrResult['workbench'] ?? []);
|
||||
$arrTaskPoolSummary = (array)($arrResult['task_pool'] ?? []);
|
||||
|
||||
$output->writeln('视频缺字段任务池已生成');
|
||||
$output->writeln('工作台缺字段视频数:' . (int)(($arrWorkbenchSummary['audit'] ?? [])['videos_with_any_missing_metadata'] ?? 0));
|
||||
$output->writeln('工作台重采优先队列:' . (int)(($arrWorkbenchSummary['queue'] ?? [])['queue_size'] ?? 0));
|
||||
$output->writeln('任务池批次数:' . count((array)($arrTaskPoolSummary['batches'] ?? [])));
|
||||
$output->writeln('工作台 JSON:' . (string)($arrWorkbenchSummary['summary_json_path'] ?? ''));
|
||||
$output->writeln('工作台 HTML:' . (string)($arrWorkbenchSummary['summary_html_path'] ?? ''));
|
||||
$output->writeln('任务池 JSON:' . (string)($arrTaskPoolSummary['summary_json_path'] ?? ''));
|
||||
$output->writeln('任务池 HTML:' . (string)($arrTaskPoolSummary['summary_html_path'] ?? ''));
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
43
code/app/command/VideoMetadataMissingWorkbenchCommand.php
Normal file
43
code/app/command/VideoMetadataMissingWorkbenchCommand.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\helper\VideoMetadataMissingWorkbenchHelper;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataMissingWorkbenchCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:workbench')
|
||||
->addOption('sample', null, Option::VALUE_OPTIONAL, '缺失字段样本数,默认 20', 20)
|
||||
->addOption('queue-limit', null, Option::VALUE_OPTIONAL, '重采优先队列条数,默认 100', 100)
|
||||
->addOption('prompt-limit', null, Option::VALUE_OPTIONAL, 'Codex 派单样本数,默认 20', 20)
|
||||
->setDescription('生成视频缺失字段工作台产物,供后台和 Codex 派单使用');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intSample = max(1, min(100, (int)$input->getOption('sample')));
|
||||
$intQueueLimit = max(1, min(500, (int)$input->getOption('queue-limit')));
|
||||
$intPromptLimit = max(1, min(100, (int)$input->getOption('prompt-limit')));
|
||||
$strOutputRoot = rtrim((string)root_path(), '/') . '/app/public/_admin_templates/video-metadata-missing-workbench';
|
||||
|
||||
$arrSummary = VideoMetadataMissingWorkbenchHelper::buildSummary($intSample, $intQueueLimit, $intPromptLimit);
|
||||
$arrSummary = VideoMetadataMissingWorkbenchHelper::writeArtifacts($strOutputRoot, $arrSummary);
|
||||
|
||||
$output->writeln('视频缺失字段工作台已生成');
|
||||
$output->writeln('存在缺字段视频数:' . (int)(($arrSummary['audit'] ?? [])['videos_with_any_missing_metadata'] ?? 0));
|
||||
$output->writeln('重采优先队列:' . (int)(($arrSummary['queue'] ?? [])['queue_size'] ?? 0));
|
||||
$output->writeln('JSON:' . (string)($arrSummary['summary_json_path'] ?? ''));
|
||||
$output->writeln('HTML:' . (string)($arrSummary['summary_html_path'] ?? ''));
|
||||
$output->writeln('Prompt:' . (string)($arrSummary['prompt_markdown_path'] ?? ''));
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
106
code/app/command/VideoMetadataRecrawlQueueCommand.php
Normal file
106
code/app/command/VideoMetadataRecrawlQueueCommand.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\model\VideoModel;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataRecrawlQueueCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:recrawl-queue')
|
||||
->addOption('limit', null, Option::VALUE_OPTIONAL, '输出多少条重采优先项,默认 200', 200)
|
||||
->setDescription('生成演员/导演缺失的视频重采优先队列');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intLimit = max(1, min((int)$input->getOption('limit'), 5000));
|
||||
$arrSummary = VideoModel::getInstance()->buildRecrawlPriorityQueueSummary($intLimit);
|
||||
|
||||
$strRoot = rtrim((string)root_path(), '/');
|
||||
$strOutputRoot = $strRoot . '/storage/video-metadata-recrawl-queue';
|
||||
if (!is_dir($strOutputRoot)) {
|
||||
@mkdir($strOutputRoot, 0777, true);
|
||||
}
|
||||
|
||||
$strJsonPath = $strOutputRoot . '/latest.json';
|
||||
$strMarkdownPath = $strOutputRoot . '/latest.md';
|
||||
|
||||
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL);
|
||||
file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary));
|
||||
|
||||
$output->writeln('视频元数据重采优先队列已生成');
|
||||
$output->writeln('队列条数:' . (int)($arrSummary['queue_size'] ?? 0));
|
||||
$output->writeln('JSON:' . $strJsonPath);
|
||||
$output->writeln('Markdown:' . $strMarkdownPath);
|
||||
|
||||
foreach (array_slice((array)($arrSummary['items'] ?? []), 0, 10) as $arrItem) {
|
||||
$output->writeln(sprintf(
|
||||
'#%d %s => score:%d | %s',
|
||||
(int)($arrItem['v_id'] ?? 0),
|
||||
(string)($arrItem['v_name'] ?? ''),
|
||||
(int)($arrItem['priority_score'] ?? 0),
|
||||
(string)($arrItem['priority_reason'] ?? '')
|
||||
));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function buildMarkdown(array $arrSummary): string
|
||||
{
|
||||
$arrLines = [];
|
||||
$arrLines[] = '# 视频元数据重采优先队列';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? '');
|
||||
$arrLines[] = '- 队列条数:' . (int)($arrSummary['queue_size'] ?? 0);
|
||||
$arrLines[] = '- 当前规则:优先处理演员 / 导演缺失且已有访问信号、仍在更新、多线路可交叉重采的视频';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '| 优先分 | v_id | 片名 | 分类 | 缺失字段 | 周点击 | 总点击 | 播放源数 | 更新时间 | 优先原因 |';
|
||||
$arrLines[] = '| ---: | ---: | --- | --- | --- | ---: | ---: | ---: | --- | --- |';
|
||||
|
||||
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
|
||||
$arrLines[] = sprintf(
|
||||
'| %d | %d | %s | %s | %s | %d | %d | %d | %s | %s |',
|
||||
(int)($arrItem['priority_score'] ?? 0),
|
||||
(int)($arrItem['v_id'] ?? 0),
|
||||
$this->escapeMarkdown((string)($arrItem['v_name'] ?? '')),
|
||||
$this->escapeMarkdown((string)($arrItem['v_category'] ?? '')),
|
||||
$this->escapeMarkdown(implode(', ', (array)($arrItem['missing_fields'] ?? []))),
|
||||
(int)(($arrItem['click_stats'] ?? [])['weekly'] ?? 0),
|
||||
(int)(($arrItem['click_stats'] ?? [])['total'] ?? 0),
|
||||
(int)($arrItem['play_source_count'] ?? 0),
|
||||
$this->escapeMarkdown((string)($arrItem['updated_at'] ?? '')),
|
||||
$this->escapeMarkdown((string)($arrItem['priority_reason'] ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '## 执行建议';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '1. 先处理前 50 条高分项,优先尝试从已有多播放源重新抓取演员/导演。';
|
||||
$arrLines[] = '2. 如果多源都拿不到,再转人工补源或定向接第三方资料源。';
|
||||
$arrLines[] = '3. 不允许用 AI 猜演员/导演;结构化事实字段只能靠真实来源补齐。';
|
||||
$arrLines[] = '4. 每次重采后,复跑 `php think video:metadata:audit` 看缺口是否下降。';
|
||||
$arrLines[] = '';
|
||||
|
||||
return implode(PHP_EOL, $arrLines) . PHP_EOL;
|
||||
}
|
||||
|
||||
protected function escapeMarkdown(string $strValue): string
|
||||
{
|
||||
$strValue = trim($strValue);
|
||||
if ($strValue === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return str_replace('|', '\\|', $strValue);
|
||||
}
|
||||
}
|
||||
344
code/app/command/VideoMetadataRecrawlRunCommand.php
Normal file
344
code/app/command/VideoMetadataRecrawlRunCommand.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\model\VideoModel;
|
||||
use app\task\crawler\douban\page\Site as DoubanSite;
|
||||
use app\task\crawler\youzhi\page\Site as YouzhiSite;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class VideoMetadataRecrawlRunCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('video:metadata:recrawl-run')
|
||||
->addOption('limit', null, Option::VALUE_OPTIONAL, '默认按重采队列取多少条,默认 10', 10)
|
||||
->addOption('v-ids', null, Option::VALUE_OPTIONAL, '指定 v_id,多个逗号分隔;指定后优先按 v_id 执行', '')
|
||||
->addOption('sources', null, Option::VALUE_OPTIONAL, '强制指定源站,多个逗号分隔,如 douban,youzhi', '')
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '只搜索和匹配,不真正回写')
|
||||
->setDescription('按视频元数据重采队列执行演员/导演定向重采,并生成执行结果报告');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$intLimit = max(1, min((int)$input->getOption('limit'), 100));
|
||||
$arrForcedVIds = $this->parseIntList((string)$input->getOption('v-ids'));
|
||||
$arrForcedSources = $this->parseSourceList((string)$input->getOption('sources'));
|
||||
$boolDryRun = (bool)$input->getOption('dry-run');
|
||||
|
||||
$arrCandidates = $this->resolveCandidates($arrForcedVIds, $intLimit);
|
||||
|
||||
if (empty($arrCandidates)) {
|
||||
$output->writeln('没有可执行的候选视频');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$arrSummary = [
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'mode' => !empty($arrForcedVIds) ? 'explicit_v_ids' : 'queue',
|
||||
'dry_run' => $boolDryRun,
|
||||
'limit' => $intLimit,
|
||||
'requested_v_ids' => $arrForcedVIds,
|
||||
'forced_sources' => $arrForcedSources,
|
||||
'attempted_videos' => count($arrCandidates),
|
||||
'success_actor_count' => 0,
|
||||
'success_director_count' => 0,
|
||||
'success_both_count' => 0,
|
||||
'failed_both_count' => 0,
|
||||
'items' => [],
|
||||
];
|
||||
|
||||
foreach ($arrCandidates as $arrCandidate) {
|
||||
$arrItemSummary = $this->processCandidate($arrCandidate, $arrForcedSources, $boolDryRun);
|
||||
$arrSummary['items'][] = $arrItemSummary;
|
||||
|
||||
if (!empty($arrItemSummary['actor_filled'])) {
|
||||
$arrSummary['success_actor_count']++;
|
||||
}
|
||||
if (!empty($arrItemSummary['director_filled'])) {
|
||||
$arrSummary['success_director_count']++;
|
||||
}
|
||||
if (!empty($arrItemSummary['actor_filled']) && !empty($arrItemSummary['director_filled'])) {
|
||||
$arrSummary['success_both_count']++;
|
||||
}
|
||||
if (empty($arrItemSummary['actor_filled']) && empty($arrItemSummary['director_filled'])) {
|
||||
$arrSummary['failed_both_count']++;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'#%d %s | actor:%s | director:%s | sources:%s',
|
||||
(int)$arrItemSummary['v_id'],
|
||||
(string)$arrItemSummary['v_name'],
|
||||
!empty($arrItemSummary['actor_filled']) ? 'filled' : 'no',
|
||||
!empty($arrItemSummary['director_filled']) ? 'filled' : 'no',
|
||||
implode(',', array_column((array)$arrItemSummary['source_runs'], 'source'))
|
||||
));
|
||||
}
|
||||
|
||||
$strRoot = rtrim((string)root_path(), '/');
|
||||
$strOutputRoot = $strRoot . '/storage/video-metadata-recrawl-run';
|
||||
if (!is_dir($strOutputRoot)) {
|
||||
@mkdir($strOutputRoot, 0777, true);
|
||||
}
|
||||
|
||||
$strJsonPath = $strOutputRoot . '/latest.json';
|
||||
$strMarkdownPath = $strOutputRoot . '/latest.md';
|
||||
|
||||
file_put_contents($strJsonPath, json_encode($arrSummary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL);
|
||||
file_put_contents($strMarkdownPath, $this->buildMarkdown($arrSummary));
|
||||
|
||||
$output->writeln('视频元数据定向重采报告已生成');
|
||||
$output->writeln('JSON:' . $strJsonPath);
|
||||
$output->writeln('Markdown:' . $strMarkdownPath);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function resolveCandidates(array $arrForcedVIds, int $intLimit): array
|
||||
{
|
||||
if (!empty($arrForcedVIds)) {
|
||||
$arrCandidates = [];
|
||||
foreach ($arrForcedVIds as $intVId) {
|
||||
$arrVideo = VideoModel::getInstance()->getVideoByVId($intVId);
|
||||
if (empty($arrVideo)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arrPlaySources = array_values(array_filter(array_map('trim', array_keys((array)($arrVideo['v_play_url'] ?? [])))));
|
||||
$arrCandidates[] = [
|
||||
'v_id' => (int)($arrVideo['v_id'] ?? 0),
|
||||
'v_name' => (string)($arrVideo['v_name'] ?? ''),
|
||||
'v_name_en' => (string)($arrVideo['v_name_en'] ?? ''),
|
||||
'play_sources' => $arrPlaySources,
|
||||
];
|
||||
}
|
||||
|
||||
return $arrCandidates;
|
||||
}
|
||||
|
||||
return (array)(VideoModel::getInstance()->buildRecrawlPriorityQueueSummary($intLimit)['items'] ?? []);
|
||||
}
|
||||
|
||||
protected function processCandidate(array $arrCandidate, array $arrForcedSources, bool $boolDryRun): array
|
||||
{
|
||||
$intVId = (int)($arrCandidate['v_id'] ?? 0);
|
||||
$strVName = trim((string)($arrCandidate['v_name'] ?? ''));
|
||||
$strVNameEn = trim((string)($arrCandidate['v_name_en'] ?? ''));
|
||||
|
||||
$arrBefore = VideoModel::getInstance()->getVideoByVId($intVId) ?? [];
|
||||
$arrSourceRuns = [];
|
||||
|
||||
$arrSources = !empty($arrForcedSources)
|
||||
? $arrForcedSources
|
||||
: $this->parseSourceList(implode(',', (array)($arrCandidate['play_sources'] ?? [])));
|
||||
|
||||
foreach ($arrSources as $strSource) {
|
||||
$arrSearchResult = $this->searchSourceVideo($strSource, $strVName, $strVNameEn);
|
||||
|
||||
$arrSourceRun = [
|
||||
'source' => $strSource,
|
||||
'matched' => !empty($arrSearchResult),
|
||||
'matched_vod_id' => (int)($arrSearchResult['vod_id'] ?? 0),
|
||||
'matched_vod_name' => (string)($arrSearchResult['vod_name'] ?? ''),
|
||||
'status' => 'no_match',
|
||||
];
|
||||
|
||||
if (empty($arrSearchResult)) {
|
||||
$arrSourceRuns[] = $arrSourceRun;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($boolDryRun) {
|
||||
$arrSourceRun['status'] = 'matched_dry_run';
|
||||
$arrSourceRuns[] = $arrSourceRun;
|
||||
continue;
|
||||
}
|
||||
|
||||
$boolSaved = $this->fetchAndSaveSourceVideo($strSource, (int)$arrSearchResult['vod_id']);
|
||||
$arrSourceRun['status'] = $boolSaved ? 'saved' : 'fetch_failed';
|
||||
$arrSourceRuns[] = $arrSourceRun;
|
||||
}
|
||||
|
||||
$arrAfter = VideoModel::getInstance()->getVideoByVId($intVId) ?? [];
|
||||
|
||||
$VideoModel = VideoModel::getInstance();
|
||||
$boolActorBefore = $VideoModel->isFieldMetadataMissing('v_actor', $arrBefore['v_actor'] ?? null);
|
||||
$boolDirectorBefore = $VideoModel->isFieldMetadataMissing('v_director', $arrBefore['v_director'] ?? null);
|
||||
$boolActorAfter = $VideoModel->isFieldMetadataMissing('v_actor', $arrAfter['v_actor'] ?? null);
|
||||
$boolDirectorAfter = $VideoModel->isFieldMetadataMissing('v_director', $arrAfter['v_director'] ?? null);
|
||||
|
||||
return [
|
||||
'v_id' => $intVId,
|
||||
'v_name' => $strVName,
|
||||
'sources_requested' => $arrSources,
|
||||
'actor_filled' => $boolActorBefore && !$boolActorAfter,
|
||||
'director_filled' => $boolDirectorBefore && !$boolDirectorAfter,
|
||||
'before' => [
|
||||
'actor_empty' => $boolActorBefore,
|
||||
'director_empty' => $boolDirectorBefore,
|
||||
],
|
||||
'after' => [
|
||||
'actor_empty' => $boolActorAfter,
|
||||
'director_empty' => $boolDirectorAfter,
|
||||
],
|
||||
'source_runs' => $arrSourceRuns,
|
||||
];
|
||||
}
|
||||
|
||||
protected function searchSourceVideo(string $strSource, string $strVName, string $strVNameEn = ''): array
|
||||
{
|
||||
$Site = $this->makeSite($strSource);
|
||||
$strUri = $this->buildSearchUri($strSource, $strVName);
|
||||
|
||||
$Response = $Site->getClient()->get($strUri);
|
||||
$strContent = (string)$Response->getBody()->getContents();
|
||||
$arrContent = json_decode($strContent, true);
|
||||
$arrList = (array)($arrContent['list'] ?? []);
|
||||
|
||||
if (empty($arrList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$strTargetName = $this->normalizeTitle($strVName);
|
||||
$strTargetNameEn = strtolower(trim($strVNameEn));
|
||||
|
||||
foreach ($arrList as $arrItem) {
|
||||
$strVodName = $this->normalizeTitle((string)($arrItem['vod_name'] ?? ''));
|
||||
$strVodNameEn = strtolower(trim((string)($arrItem['vod_en'] ?? '')));
|
||||
|
||||
if ($strVodName !== '' && $strVodName === $strTargetName) {
|
||||
return (array)$arrItem;
|
||||
}
|
||||
|
||||
if ($strTargetNameEn !== '' && $strVodNameEn !== '' && $strVodNameEn === $strTargetNameEn) {
|
||||
return (array)$arrItem;
|
||||
}
|
||||
}
|
||||
|
||||
return (array)$arrList[0];
|
||||
}
|
||||
|
||||
protected function fetchAndSaveSourceVideo(string $strSource, int $intVodId): bool
|
||||
{
|
||||
if ($intVodId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$Site = $this->makeSite($strSource);
|
||||
$arrPages = $Site->getVideoInfoPageList([
|
||||
['v_source_id' => $intVodId],
|
||||
]);
|
||||
|
||||
if (empty($arrPages)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$boolSaved = false;
|
||||
foreach ($arrPages as $VideoInfoPage) {
|
||||
$boolSaved = $VideoInfoPage->saveVideo() || $boolSaved;
|
||||
}
|
||||
|
||||
return $boolSaved;
|
||||
}
|
||||
|
||||
protected function makeSite(string $strSource)
|
||||
{
|
||||
return match ($strSource) {
|
||||
'douban' => new DoubanSite(),
|
||||
'youzhi' => new YouzhiSite(),
|
||||
default => throw new \InvalidArgumentException('不支持的源站:' . $strSource),
|
||||
};
|
||||
}
|
||||
|
||||
protected function buildSearchUri(string $strSource, string $strKeyword): string
|
||||
{
|
||||
$strKeyword = urlencode($strKeyword);
|
||||
|
||||
return match ($strSource) {
|
||||
'douban' => '/api.php/provide/vod/at/josn?ac=list&wd=' . $strKeyword,
|
||||
'youzhi' => '/inc/api_mac10.php?ac=list&wd=' . $strKeyword,
|
||||
default => throw new \InvalidArgumentException('不支持的源站:' . $strSource),
|
||||
};
|
||||
}
|
||||
|
||||
protected function parseIntList(string $strValue): array
|
||||
{
|
||||
return array_values(array_filter(array_map(static function (string $strItem): int {
|
||||
return (int)trim($strItem);
|
||||
}, explode(',', $strValue)), static function (int $intValue): bool {
|
||||
return $intValue > 0;
|
||||
}));
|
||||
}
|
||||
|
||||
protected function parseSourceList(string $strValue): array
|
||||
{
|
||||
$arrAllowed = ['douban', 'youzhi'];
|
||||
|
||||
return array_values(array_filter(array_unique(array_map(static function (string $strItem): string {
|
||||
return trim($strItem);
|
||||
}, explode(',', $strValue))), static function (string $strItem) use ($arrAllowed): bool {
|
||||
return in_array($strItem, $arrAllowed, true);
|
||||
}));
|
||||
}
|
||||
|
||||
protected function normalizeTitle(string $strValue): string
|
||||
{
|
||||
$strValue = trim(mb_strtolower($strValue));
|
||||
if ($strValue === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return preg_replace('/[\s\p{P}\p{S}]+/u', '', $strValue) ?? $strValue;
|
||||
}
|
||||
|
||||
protected function buildMarkdown(array $arrSummary): string
|
||||
{
|
||||
$arrLines = [];
|
||||
$arrLines[] = '# 视频元数据定向重采执行结果';
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '- 生成时间:' . (string)($arrSummary['generated_at'] ?? '');
|
||||
$arrLines[] = '- 模式:' . (string)($arrSummary['mode'] ?? '');
|
||||
$arrLines[] = '- Dry Run:' . (!empty($arrSummary['dry_run']) ? 'yes' : 'no');
|
||||
$arrLines[] = '- 处理条数:' . (int)($arrSummary['attempted_videos'] ?? 0);
|
||||
$arrLines[] = '- 演员补齐条数:' . (int)($arrSummary['success_actor_count'] ?? 0);
|
||||
$arrLines[] = '- 导演补齐条数:' . (int)($arrSummary['success_director_count'] ?? 0);
|
||||
$arrLines[] = '- 双字段都补齐:' . (int)($arrSummary['success_both_count'] ?? 0);
|
||||
$arrLines[] = '- 双字段都未补齐:' . (int)($arrSummary['failed_both_count'] ?? 0);
|
||||
$arrLines[] = '';
|
||||
$arrLines[] = '| v_id | 片名 | actor补齐 | director补齐 | 源站执行摘要 |';
|
||||
$arrLines[] = '| ---: | --- | --- | --- | --- |';
|
||||
|
||||
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
|
||||
$arrSourceSummary = array_map(static function (array $arrRun): string {
|
||||
return sprintf(
|
||||
'%s:%s%s',
|
||||
(string)($arrRun['source'] ?? ''),
|
||||
(string)($arrRun['status'] ?? ''),
|
||||
!empty($arrRun['matched_vod_id']) ? '#' . (int)$arrRun['matched_vod_id'] : ''
|
||||
);
|
||||
}, (array)($arrItem['source_runs'] ?? []));
|
||||
|
||||
$arrLines[] = sprintf(
|
||||
'| %d | %s | %s | %s | %s |',
|
||||
(int)($arrItem['v_id'] ?? 0),
|
||||
$this->escapeMarkdown((string)($arrItem['v_name'] ?? '')),
|
||||
!empty($arrItem['actor_filled']) ? 'yes' : 'no',
|
||||
!empty($arrItem['director_filled']) ? 'yes' : 'no',
|
||||
$this->escapeMarkdown(implode(' / ', $arrSourceSummary))
|
||||
);
|
||||
}
|
||||
|
||||
$arrLines[] = '';
|
||||
return implode(PHP_EOL, $arrLines) . PHP_EOL;
|
||||
}
|
||||
|
||||
protected function escapeMarkdown(string $strValue): string
|
||||
{
|
||||
return str_replace('|', '\\|', trim($strValue));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user