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

@@ -255,6 +255,7 @@ class DomainModel extends BaseModel
$arrTkdSeoCfg['provider'] = self::normalizeTkdProvider((string)($arrTkdSeoCfg['provider'] ?? self::TKD_PROVIDER_LOCAL));
$arrSeoCfg['tkd'] = $arrTkdSeoCfg;
$arrRawCopyGenerationSeoCfg = is_array($mSeoCfg['copy_generation'] ?? null) ? $mSeoCfg['copy_generation'] : [];
$arrCopyGenerationSeoCfg = is_array($arrSeoCfg['copy_generation'] ?? null) ? $arrSeoCfg['copy_generation'] : [];
$arrCopyGenerationSeoCfg = array_merge(self::defaultSeoCfg()['copy_generation'], $arrCopyGenerationSeoCfg);
$arrCopyGenerationSeoCfg['status'] = trim((string)($arrCopyGenerationSeoCfg['status'] ?? 'draft')) ?: 'draft';
@@ -262,6 +263,38 @@ class DomainModel extends BaseModel
$arrCopyGenerationSeoCfg['wait_for_ai_before_publish'] = (bool)($arrCopyGenerationSeoCfg['wait_for_ai_before_publish'] ?? false);
$arrCopyGenerationSeoCfg['provider_preference'] = trim((string)($arrCopyGenerationSeoCfg['provider_preference'] ?? 'local_first')) ?: 'local_first';
$arrCopyGenerationSeoCfg['ai_provider'] = self::normalizeTkdProvider((string)($arrCopyGenerationSeoCfg['ai_provider'] ?? self::TKD_PROVIDER_OPENAI));
$boolOpenAiCopyPreferred = $arrTkdSeoCfg['provider'] === self::TKD_PROVIDER_OPENAI
|| $arrTkdSeoCfg['mode'] === self::TKD_MODE_AI_OPTIMIZE;
if ($boolOpenAiCopyPreferred) {
$boolLegacyWaitForAiDefault = array_key_exists('wait_for_ai_before_publish', $arrRawCopyGenerationSeoCfg)
&& empty($arrRawCopyGenerationSeoCfg['wait_for_ai_before_publish']);
$boolLegacyProviderPreferenceDefault = array_key_exists('provider_preference', $arrRawCopyGenerationSeoCfg)
&& trim((string)($arrRawCopyGenerationSeoCfg['provider_preference'] ?? '')) === 'local_first';
$boolLegacyStatusDefault = array_key_exists('status', $arrRawCopyGenerationSeoCfg)
&& trim((string)($arrRawCopyGenerationSeoCfg['status'] ?? '')) === 'draft';
if (!array_key_exists('wait_for_ai_before_publish', $arrRawCopyGenerationSeoCfg) || $boolLegacyWaitForAiDefault) {
$arrCopyGenerationSeoCfg['wait_for_ai_before_publish'] = true;
}
if (!array_key_exists('provider_preference', $arrRawCopyGenerationSeoCfg)
|| trim((string)($arrRawCopyGenerationSeoCfg['provider_preference'] ?? '')) === ''
|| $boolLegacyProviderPreferenceDefault) {
$arrCopyGenerationSeoCfg['provider_preference'] = 'ai_first';
}
if (!array_key_exists('status', $arrRawCopyGenerationSeoCfg)
|| trim((string)($arrRawCopyGenerationSeoCfg['status'] ?? '')) === ''
|| $boolLegacyStatusDefault) {
$arrCopyGenerationSeoCfg['status'] = 'ai_pending';
}
if (!array_key_exists('ai_provider', $arrRawCopyGenerationSeoCfg) || trim((string)($arrRawCopyGenerationSeoCfg['ai_provider'] ?? '')) === '') {
$arrCopyGenerationSeoCfg['ai_provider'] = self::TKD_PROVIDER_OPENAI;
}
}
$arrSeoCfg['copy_generation'] = $arrCopyGenerationSeoCfg;
$arrRuntimeAcceptance = is_array($arrSeoCfg['runtime_acceptance'] ?? null)

View File

@@ -13,6 +13,19 @@ use Exception;
*/
class VideoModel extends MongoModel
{
protected array $fillableMetadataFields = [
'v_actor',
'v_director',
'v_lang',
'v_lang_en',
'v_area',
'v_area_en',
'v_year',
'v_remarks',
'v_description',
'v_publish_date',
];
/**
* Undocumented variable
*
@@ -203,6 +216,506 @@ class VideoModel extends MongoModel
return $this->findOne(['v_name_en' => $strNameEn]);
}
public function buildEmptyMetadataBackfillSet(array $arrExistingVideo, array $arrIncomingVideo, string $strSource = 'crawler_backfill'): array
{
$arrUpdate = [];
$arrFilledFields = [];
foreach ($this->fillableMetadataFields as $strField) {
$mixedExisting = $arrExistingVideo[$strField] ?? null;
$mixedIncoming = $arrIncomingVideo[$strField] ?? null;
if (!$this->isFieldMetadataMissing($strField, $mixedExisting)) {
continue;
}
if ($this->isFieldMetadataMissing($strField, $mixedIncoming)) {
continue;
}
$mixedNormalized = $this->normalizeMetadataValueByField($strField, $mixedIncoming);
if ($this->isFieldMetadataMissing($strField, $mixedNormalized)) {
continue;
}
$arrUpdate[$strField] = $mixedNormalized;
$arrFilledFields[] = $strField;
}
if (empty($arrFilledFields)) {
return [];
}
$arrUpdate['updated_at'] = new \MongoDB\BSON\UTCDateTime();
$arrUpdate['v_metadata_fill'] = [
'last_source' => $strSource,
'updated_at' => new \MongoDB\BSON\UTCDateTime(),
'filled_fields' => $arrFilledFields,
];
return $arrUpdate;
}
public function buildMissingMetadataAuditSummary(int $intSampleLimit = 20): array
{
$intSampleLimit = max(1, min($intSampleLimit, 100));
$intTotal = (int)$this->getCol()->countDocuments();
$arrFields = [];
$arrAnyMissingFilters = [];
foreach ($this->fillableMetadataFields as $strField) {
$arrFilter = $this->buildMissingMetadataFilter($strField);
$intMissingCount = (int)$this->getCol()->countDocuments($arrFilter);
$arrFields[] = [
'field' => $strField,
'missing_count' => $intMissingCount,
'missing_ratio' => $intTotal > 0 ? round($intMissingCount / $intTotal, 4) : 0,
];
$arrAnyMissingFilters[] = $arrFilter;
}
$arrAnyMissingFilter = empty($arrAnyMissingFilters) ? [] : ['$or' => $arrAnyMissingFilters];
$intAnyMissingCount = empty($arrAnyMissingFilter) ? 0 : (int)$this->getCol()->countDocuments($arrAnyMissingFilter);
$Cursor = $this->getCol()->find($arrAnyMissingFilter, [
'limit' => $intSampleLimit,
'sort' => ['updated_at' => -1, 'v_id' => -1],
'projection' => [
'_id' => 0,
'v_id' => 1,
'v_name' => 1,
'v_category' => 1,
'v_year' => 1,
'v_remarks' => 1,
'updated_at' => 1,
'v_actor' => 1,
'v_director' => 1,
'v_lang' => 1,
'v_lang_en' => 1,
'v_area' => 1,
'v_area_en' => 1,
'v_description' => 1,
'v_publish_date' => 1,
],
'typeMap' => self::$arrOptions['typeMap'],
]);
$arrSamples = [];
foreach (iterator_to_array($Cursor) as $arrVideo) {
$arrMissingFields = [];
foreach ($this->fillableMetadataFields as $strField) {
if ($this->isFieldMetadataMissing($strField, $arrVideo[$strField] ?? null)) {
$arrMissingFields[] = $strField;
}
}
applyToKeys($arrVideo, ['updated_at'], 'formatMongoDate');
$arrSamples[] = [
'v_id' => (int)($arrVideo['v_id'] ?? 0),
'v_name' => (string)($arrVideo['v_name'] ?? ''),
'v_category' => (string)($arrVideo['v_category'] ?? ''),
'v_year' => is_array($arrVideo['v_year'] ?? null) ? '' : (string)($arrVideo['v_year'] ?? ''),
'v_remarks' => is_array($arrVideo['v_remarks'] ?? null) ? '' : (string)($arrVideo['v_remarks'] ?? ''),
'updated_at' => (string)($arrVideo['updated_at'] ?? ''),
'missing_fields' => $arrMissingFields,
];
}
return [
'generated_at' => date(DATE_ATOM),
'total_videos' => $intTotal,
'videos_with_any_missing_metadata' => $intAnyMissingCount,
'fillable_fields' => $this->fillableMetadataFields,
'field_stats' => $arrFields,
'sample_limit' => $intSampleLimit,
'samples' => $arrSamples,
];
}
public function buildRecrawlPriorityQueueSummary(int $intLimit = 200, int $intScanLimit = 3000): array
{
$intLimit = max(1, min($intLimit, 5000));
$intScanLimit = max($intLimit, min($intScanLimit, 20000));
$arrCandidates = [];
$Cursor = $this->getCol()->find(
[
'$or' => [
$this->buildMissingMetadataFilter('v_actor'),
$this->buildMissingMetadataFilter('v_director'),
],
],
[
'projection' => [
'_id' => 0,
'v_id' => 1,
'v_name' => 1,
'v_category' => 1,
'v_parent_category' => 1,
'v_year' => 1,
'v_actor' => 1,
'v_director' => 1,
'v_remarks' => 1,
'v_isend' => 1,
'v_publish_date' => 1,
'v_play_url' => 1,
'updated_at' => 1,
],
'limit' => $intScanLimit,
'sort' => ['updated_at' => -1, 'v_id' => -1],
'typeMap' => self::$arrOptions['typeMap'],
]
);
foreach ($Cursor as $arrVideo) {
$intVId = (int)($arrVideo['v_id'] ?? 0);
if ($intVId <= 0) {
continue;
}
$arrStats = VideoClicksModel::getInstance()->getStats($intVId);
$arrPlayUrl = (array)($arrVideo['v_play_url'] ?? []);
$arrSources = array_values(array_filter(array_map(static function ($mixedKey): string {
return trim((string)$mixedKey);
}, array_keys($arrPlayUrl)), static function (string $strKey): bool {
return $strKey !== '';
}));
$boolMissingActor = $this->isFieldMetadataMissing('v_actor', $arrVideo['v_actor'] ?? null);
$boolMissingDirector = $this->isFieldMetadataMissing('v_director', $arrVideo['v_director'] ?? null);
$strCategory = trim((string)($arrVideo['v_category'] ?? ''));
$strParentCategory = trim((string)($arrVideo['v_parent_category'] ?? ''));
$intPriorityScore = $this->calculateRecrawlPriorityScore(
$arrVideo,
$arrStats,
$boolMissingActor,
$boolMissingDirector,
count($arrSources),
$strCategory,
$strParentCategory
);
$arrCandidates[] = [
'v_id' => $intVId,
'v_name' => (string)($arrVideo['v_name'] ?? ''),
'v_category' => $strCategory,
'v_parent_category' => $strParentCategory,
'v_year' => (string)($arrVideo['v_year'] ?? ''),
'v_remarks' => (string)($arrVideo['v_remarks'] ?? ''),
'v_isend' => (int)($arrVideo['v_isend'] ?? 0),
'v_publish_date' => (string)($arrVideo['v_publish_date'] ?? ''),
'missing_actor' => $boolMissingActor,
'missing_director' => $boolMissingDirector,
'missing_fields' => array_values(array_filter([
$boolMissingActor ? 'v_actor' : '',
$boolMissingDirector ? 'v_director' : '',
])),
'play_sources' => $arrSources,
'play_source_count' => count($arrSources),
'click_stats' => $arrStats,
'priority_score' => $intPriorityScore,
'updated_at' => ($arrVideo['updated_at'] ?? null) instanceof \MongoDB\BSON\UTCDateTime
? formatMongoDate($arrVideo['updated_at'])
: '',
'priority_reason' => $this->buildRecrawlPriorityReason(
$arrVideo,
$boolMissingActor,
$boolMissingDirector,
$arrStats,
(int)($arrVideo['v_isend'] ?? 0),
count($arrSources),
$strCategory,
$strParentCategory
),
];
}
usort($arrCandidates, static function (array $arrA, array $arrB): int {
$intScoreCompare = (int)($arrB['priority_score'] ?? 0) <=> (int)($arrA['priority_score'] ?? 0);
if ($intScoreCompare !== 0) {
return $intScoreCompare;
}
$intWeeklyCompare = (int)(($arrB['click_stats'] ?? [])['weekly'] ?? 0) <=> (int)(($arrA['click_stats'] ?? [])['weekly'] ?? 0);
if ($intWeeklyCompare !== 0) {
return $intWeeklyCompare;
}
$strPublishA = (string)($arrA['v_publish_date'] ?? '');
$strPublishB = (string)($arrB['v_publish_date'] ?? '');
$intPublishCompare = strcmp($strPublishB, $strPublishA);
if ($intPublishCompare !== 0) {
return $intPublishCompare;
}
return (int)($arrB['v_id'] ?? 0) <=> (int)($arrA['v_id'] ?? 0);
});
$arrCandidates = array_values(array_slice($arrCandidates, 0, $intLimit));
return [
'generated_at' => date(DATE_ATOM),
'limit' => $intLimit,
'scan_limit' => $intScanLimit,
'queue_size' => count($arrCandidates),
'items' => $arrCandidates,
];
}
protected function isEmptyMetadataValue($mixedValue): bool
{
if ($mixedValue === null) {
return true;
}
if (is_string($mixedValue)) {
return trim($mixedValue) === '';
}
if (is_array($mixedValue)) {
return count(array_filter($mixedValue, function ($mixedItem) {
if (is_string($mixedItem)) {
return trim($mixedItem) !== '';
}
return !empty($mixedItem);
})) === 0;
}
return empty($mixedValue);
}
public function isFieldMetadataMissing(string $strField, $mixedValue): bool
{
$mixedNormalized = $this->normalizeMetadataValueByField($strField, $mixedValue);
return $this->isEmptyMetadataValue($mixedNormalized);
}
protected function buildMissingMetadataFilter(string $strField): array
{
$arrInvalidValues = $this->getInvalidMetadataValuesByField($strField);
$arrOr = [
[$strField => ['$exists' => false]],
[$strField => null],
[$strField => ''],
[$strField => []],
];
foreach ($arrInvalidValues as $strInvalid) {
$arrOr[] = [$strField => $strInvalid];
}
return [
'$or' => $arrOr,
];
}
protected function buildRecrawlPriorityReason(
array $arrVideo,
bool $boolMissingActor,
bool $boolMissingDirector,
array $arrStats,
int $intIsEnd,
int $intSourceCount,
string $strCategory,
string $strParentCategory
): string {
$arrReasons = [];
if ($boolMissingActor) {
$arrReasons[] = '演员缺失';
}
if ($boolMissingDirector) {
$arrReasons[] = '导演缺失';
}
if ((int)($arrStats['weekly'] ?? 0) > 0 || (int)($arrStats['total'] ?? 0) > 0) {
$arrReasons[] = '已有访问信号';
}
$intFreshDays = $this->resolveFreshDays((string)($arrVideo['v_publish_date'] ?? ''));
if ($intFreshDays >= 0 && $intFreshDays <= 14) {
$arrReasons[] = '近14天新内容';
}
if ($intIsEnd !== 1) {
$arrReasons[] = '仍可能持续更新';
}
if ($intSourceCount >= 2) {
$arrReasons[] = '已有多播放源可交叉重采';
}
$strCategoryKey = $strCategory . ' ' . $strParentCategory;
if (preg_match('/综艺|动漫|连续剧|国产剧|韩剧|日剧/u', $strCategoryKey)) {
$arrReasons[] = '高频更新题材';
}
return implode(' / ', $arrReasons);
}
protected function calculateRecrawlPriorityScore(
array $arrVideo,
array $arrStats,
bool $boolMissingActor,
bool $boolMissingDirector,
int $intSourceCount,
string $strCategory,
string $strParentCategory
): int {
$intScore = 0;
if ($boolMissingActor) {
$intScore += 30;
}
if ($boolMissingDirector) {
$intScore += 35;
}
$intFreshDays = $this->resolveFreshDays((string)($arrVideo['v_publish_date'] ?? ''));
if ($intFreshDays >= 0 && $intFreshDays <= 3) {
$intScore += 28;
} elseif ($intFreshDays <= 7) {
$intScore += 22;
} elseif ($intFreshDays <= 14) {
$intScore += 16;
} elseif ($intFreshDays <= 30) {
$intScore += 10;
}
if ((int)($arrVideo['v_isend'] ?? 0) !== 1) {
$intScore += 15;
}
if ($intSourceCount >= 2) {
$intScore += 10;
} elseif ($intSourceCount === 1) {
$intScore += 4;
}
$strCategoryKey = $strCategory . ' ' . $strParentCategory;
if (preg_match('/综艺/u', $strCategoryKey)) {
$intScore += 10;
} elseif (preg_match('/动漫/u', $strCategoryKey)) {
$intScore += 9;
} elseif (preg_match('/连续剧|国产剧|韩剧|日剧/u', $strCategoryKey)) {
$intScore += 8;
} elseif (preg_match('/短剧|现代都市|古装仙侠/u', $strCategoryKey)) {
$intScore += 6;
}
if (trim((string)($arrVideo['v_remarks'] ?? '')) !== '') {
$intScore += 4;
}
if (!empty($arrStats['total'])) {
$intScore += min(18, (int)floor(log(max(1, (int)$arrStats['total']), 2)));
}
if (!empty($arrStats['weekly'])) {
$intScore += min(12, (int)$arrStats['weekly']);
}
return $intScore;
}
protected function resolveFreshDays(string $strPublishDate): int
{
$strPublishDate = trim($strPublishDate);
if ($strPublishDate === '') {
return -1;
}
$intTs = strtotime($strPublishDate);
if ($intTs === false) {
return -1;
}
$intDiff = time() - $intTs;
if ($intDiff < 0) {
return 0;
}
return (int)floor($intDiff / 86400);
}
protected function normalizeMetadataValue($mixedValue)
{
if (is_array($mixedValue)) {
$arrValue = array_values(array_filter($mixedValue, function ($mixedItem) {
if (is_string($mixedItem)) {
return trim($mixedItem) !== '';
}
return !empty($mixedItem);
}));
return array_map(function ($mixedItem) {
return is_string($mixedItem) ? trim($mixedItem) : $mixedItem;
}, $arrValue);
}
if (is_string($mixedValue)) {
return trim($mixedValue);
}
return $mixedValue;
}
protected function normalizeMetadataValueByField(string $strField, $mixedValue)
{
$mixedNormalized = $this->normalizeMetadataValue($mixedValue);
$arrInvalidValues = $this->getInvalidMetadataValuesByField($strField);
if (is_array($mixedNormalized)) {
$arrValue = array_values(array_filter($mixedNormalized, function ($mixedItem) use ($arrInvalidValues) {
if (!is_string($mixedItem)) {
return !empty($mixedItem);
}
$strValue = trim($mixedItem);
if ($strValue === '') {
return false;
}
return !in_array($strValue, $arrInvalidValues, true);
}));
return $arrValue;
}
if (is_string($mixedNormalized)) {
$strValue = trim($mixedNormalized);
if (in_array($strValue, $arrInvalidValues, true)) {
return '';
}
return $strValue;
}
return $mixedNormalized;
}
protected function getInvalidMetadataValuesByField(string $strField): array
{
$arrCommonInvalid = [
'内详',
'未知',
'不详',
'待补充',
'暂无',
'暂无信息',
];
return match ($strField) {
'v_actor' => array_values(array_unique(array_merge($arrCommonInvalid, [
'未知演员',
'演员未知',
]))),
'v_director' => array_values(array_unique(array_merge($arrCommonInvalid, [
'未知导演',
'导演未知',
]))),
default => $arrCommonInvalid,
};
}
/**
* 查询符合条件的随机多条小说