feat: add metadata workbench and shared seo rules

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

View File

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

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

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

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

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

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

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