restore gpt seo copy pipeline and stabilize seo tkd output

This commit is contained in:
root
2026-04-16 22:45:00 +08:00
parent 09b2137fa6
commit 2a905bbf0f
18 changed files with 3587 additions and 85 deletions

View File

@@ -458,10 +458,14 @@ class SiteContext
$strCategory = $this->Request->param('strCategory');
$strSortType = $this->Request->param('strSortType');
$intPage = $this->Request->param('intPage');
$strOrderName = '';
if (!empty($strSortType) && $strSortType !== 'all' && isset(StaticConfig::$arrVideoRankSortType[$strSortType])) {
$strOrderName = StaticConfig::$arrVideoRankSortType[$strSortType];
}
ConverterMovel::setVal([
'strVideoParentCategoryName' => empty($strParentCategory) || $strParentCategory == 'all' ? '' : StaticConfig::$arrVideoClass[$strParentCategory],
'strVideoCategoryName' => empty($strCategory) || $strCategory == 'all' ? '' : VideoCategoryModel::$arrCategory[$strCategory],
'strOrderName' => empty($strSortType) || $strSortType == 'all' ? '' : StaticConfig::$arrVideoRankSortType[$strSortType],
'strOrderName' => $strOrderName,
'intPage' => $intPage ?? 1,
]);
}
@@ -746,23 +750,377 @@ class SiteContext
*/
public function getSeoTkd(string $strCode, string $strPage): string
{
// TpStyle 已在 initCfg() 中注入
$TpStyle = $this->TpStyle;
$strCode = strtolower(trim($strCode));
$strPage = strtolower(trim($strPage));
if (empty($TpStyle)) {
if (!in_array($strCode, ['title', 'keywords', 'description'], true)) {
return '';
}
// 1⃣ 选 SEO 模板
$renderer = new SeoRenderer($TpStyle);
$tpl = $renderer->getTemplate($strCode, $strPage);
$arrCandidates = [];
// 1. 新 GPT SEO 池
$arrCandidates[] = $this->renderSeoFromPool($strCode, $strPage);
// 2. 旧 SubjectFomart key 兼容层
$arrCandidates[] = $this->renderSeoFromLegacyKey($strCode, $strPage);
// 3. domain 表兜底
$arrCandidates[] = $this->renderSeoFromDomainRecord($strCode, $strPage);
foreach ($arrCandidates as $strCandidate) {
$strCandidate = $this->normalizeSeoText($strCandidate);
if ($strCode === 'keywords') {
$strCandidate = $this->normalizeSeoKeywords($strCandidate);
} elseif ($strCode === 'description') {
$strCandidate = $this->normalizeSeoDescription($strCandidate, $strPage);
}
if ($strCandidate !== '') {
return $strCandidate;
}
}
return '';
}
protected function renderSeoFromPool(string $strCode, string $strPage): string
{
$TpStyle = $this->TpStyle;
if (empty($TpStyle) || !is_array($TpStyle)) {
return '';
}
$renderer = new SeoRenderer($TpStyle);
$tpl = trim((string)$renderer->getTemplate($strCode, $strPage));
if ($tpl === '') {
return '';
}
// var_dump($tpl);
// 2⃣ 统一走 Converter你现有系统
return ConverterMovel::convert($tpl);
return $this->normalizeSeoText(ConverterMovel::convert($tpl));
}
protected function renderSeoFromLegacyKey(string $strCode, string $strPage): string
{
$strLegacyKey = $this->resolveLegacySeoKey($strCode, $strPage);
if ($strLegacyKey === '') {
return '';
}
try {
return $this->normalizeSeoText($this->converterTemplate($strLegacyKey));
} catch (\Throwable $Throwable) {
return '';
}
}
protected function renderSeoFromDomainRecord(string $strCode, string $strPage): string
{
if (!$this->DomainModel instanceof DomainModel) {
return '';
}
$strSiteName = trim((string)($this->DomainModel->d_name ?? ''));
$strHost = trim((string)($this->DomainModel->d_domain ?? ''));
if ($strPage === 'home') {
return match ($strCode) {
'title' => trim((string)($this->DomainModel->d_index_title ?? $strSiteName)),
'keywords' => trim((string)($this->DomainModel->d_index_keywords ?? $this->DomainModel->d_keywords ?? $strSiteName)),
'description' => trim((string)($this->DomainModel->d_index_description ?? $this->DomainModel->d_description ?? $strSiteName)),
default => '',
};
}
if ($strCode === 'title') {
$strEntityTitle = trim((string)(
ConverterMovel::getVal('strVideoName')
?? ConverterMovel::getVal('strVideoCategoryName')
?? ConverterMovel::getVal('strVideoParentCategoryName')
?? ''
));
if ($strEntityTitle !== '' && $strSiteName !== '') {
return $strEntityTitle . ' - ' . $strSiteName;
}
}
if ($strCode === 'keywords') {
$arrKeywords = array_filter([
trim((string)(ConverterMovel::getVal('strVideoName') ?? '')),
trim((string)(ConverterMovel::getVal('strVideoCategoryName') ?? '')),
trim((string)($this->DomainModel->d_index_keywords ?? '')),
trim((string)($this->DomainModel->d_keywords ?? '')),
], static function (string $strValue): bool {
return $strValue !== '';
});
if (!empty($arrKeywords)) {
return implode(',', array_values(array_unique($arrKeywords)));
}
}
if ($strCode === 'description') {
$strEntityDescription = trim((string)(
ConverterMovel::getVal('strVideoDescription')
?? ConverterMovel::getVal('strVideoCategoryName')
?? ConverterMovel::getVal('strVideoParentCategoryName')
?? ''
));
if ($strEntityDescription !== '') {
return $strEntityDescription;
}
$strIndexDescription = trim((string)($this->DomainModel->d_index_description ?? ''));
if ($strIndexDescription !== '') {
return $strIndexDescription;
}
}
$arrGeneric = [
'title' => $strSiteName !== '' ? $strSiteName : $strHost,
'keywords' => trim((string)($this->DomainModel->d_keywords ?? $this->DomainModel->d_index_keywords ?? $strSiteName)),
'description' => trim((string)($this->DomainModel->d_description ?? $this->DomainModel->d_index_description ?? $strSiteName)),
];
return trim((string)($arrGeneric[$strCode] ?? ''));
}
protected function normalizeSeoText(mixed $mValue): string
{
$strValue = trim((string)$mValue);
if ($strValue === '') {
return '';
}
$strValue = html_entity_decode($strValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$strValue = strip_tags($strValue);
$strValue = preg_replace('/\s+/u', ' ', $strValue) ?? $strValue;
$strValue = preg_replace('/\{[A-Z0-9@_:-]+\}/iu', '', $strValue) ?? $strValue;
$strValue = preg_replace('/^[\\s\\-|_,,。;:]+/u', '', $strValue) ?? $strValue;
$strValue = preg_replace('/[\\s\\-|_,,。;:]+$/u', '', $strValue) ?? $strValue;
if ($strValue === '' || preg_match('/^[-|_,,。;:\\s]+$/u', $strValue)) {
return '';
}
return trim($strValue);
}
protected function normalizeSeoKeywords(string $strValue): string
{
$strValue = str_replace(['', '、', ';', '', '|', ''], ',', $strValue);
$arrRawParts = preg_split('/\s*,\s*/u', $strValue) ?: [];
$arrNoiseSuffixes = [
'叙事模型',
'英雄之旅',
'成长母题',
'精神图腾',
'精神内核',
'热血传奇',
'荒诞美学',
'流浪美学',
'怀旧元素',
'世界观构建',
'文化符号',
'末日隐喻',
'双重叙事结构',
'叙事策略',
'氛围营造手法',
'角色塑造',
'诗化表达手法',
'史诗建构',
'疗愈力',
'英雄塑造',
'人性挖掘',
];
$arrStopWords = [
'在线',
'播放',
'观看',
'免费',
'高清',
'热播',
'推荐',
'完整',
'完整版',
'全集',
'中字',
'未删减',
'在线播放',
'在线观看',
'免费高清',
'高清免费视频',
'高清免费',
'免费播放',
];
$arrParts = [];
foreach ($arrRawParts as $strPart) {
$strPart = trim((string)$strPart);
if ($strPart === '') {
continue;
}
foreach ($arrNoiseSuffixes as $strSuffix) {
if ($strSuffix !== '' && str_ends_with($strPart, $strSuffix)) {
$strPart = trim(mb_substr($strPart, 0, mb_strlen($strPart) - mb_strlen($strSuffix)));
break;
}
}
$strPart = trim($strPart, " \t\n\r\0\x0B-_|,。;、,;");
if (in_array($strPart, $arrStopWords, true)) {
continue;
}
if (mb_strlen($strPart) <= 2 && !preg_match('/^\d+$/u', $strPart)) {
continue;
}
if (!in_array($strPart, $arrParts, true)) {
$arrParts[] = $strPart;
}
}
return implode(',', $arrParts);
}
protected function normalizeSeoDescription(string $strValue, string $strPage): string
{
$strValue = trim($strValue);
if ($strValue === '') {
return '';
}
$strSiteName = trim((string)($this->DomainModel->d_name ?? ''));
$strSiteKeywords = trim((string)(ConverterMovel::getVal('strSiteKeywords') ?? ''));
$strVideoName = trim((string)(ConverterMovel::getVal('strVideoName') ?? ''));
$strVideoDescription = trim((string)(ConverterMovel::getVal('strVideoDescription') ?? ''));
$strVideoCategoryName = trim((string)(ConverterMovel::getVal('strVideoCategoryName') ?? ''));
if ($strSiteKeywords !== '') {
$strValue = str_replace($strSiteKeywords, '', $strValue);
}
if ($strVideoName !== '') {
$strQuotedVideoName = preg_quote($strVideoName, '/');
// 合并连续重复片名,如 “片名……片名”
$strValue = preg_replace('/(' . $strQuotedVideoName . ')(?:\s*[,。;、,\-]?\s*\1){1,}/u', '$1', $strValue) ?? $strValue;
// 清掉 “片名的热血传奇片名” 这类硬拼句式
$strValue = preg_replace('/' . $strQuotedVideoName . '(的)?(热血传奇|精神图腾|叙事模型|英雄之旅|成长母题)' . $strQuotedVideoName . '/u', $strVideoName, $strValue) ?? $strValue;
}
$arrNoisePhrases = [
'的热血传奇',
'的精神图腾',
'的叙事模型',
'的英雄之旅',
'的成长母题',
];
$strValue = str_replace($arrNoisePhrases, '', $strValue);
$strValue = preg_replace('/([,。;、])\1+/u', '$1', $strValue) ?? $strValue;
$strValue = preg_replace('/\s+/u', ' ', $strValue) ?? $strValue;
$strValue = preg_replace('/\s*([,。;、])/u', '$1', $strValue) ?? $strValue;
$strValue = preg_replace('/([,。;、])(?=[,。;、])/u', '', $strValue) ?? $strValue;
$strValue = trim($strValue, " \t\n\r\0\x0B,。;、,;");
$boolDescriptionLooksWeak = false;
if ($strVideoName !== '') {
$intNameCount = preg_match_all('/' . preg_quote($strVideoName, '/') . '/u', $strValue);
if ($intNameCount >= 2) {
$boolDescriptionLooksWeak = true;
}
}
if ($strVideoDescription !== '' && $strVideoName !== '' && $strVideoDescription === $strVideoName) {
$boolDescriptionLooksWeak = true;
}
if (mb_strlen($strValue) < 20) {
$boolDescriptionLooksWeak = true;
}
if ($boolDescriptionLooksWeak) {
return $this->buildSeoDescriptionFallback($strPage, $strSiteName, $strVideoName, $strVideoCategoryName);
}
return $strValue;
}
protected function buildSeoDescriptionFallback(string $strPage, string $strSiteName, string $strVideoName, string $strVideoCategoryName): string
{
$strSiteName = trim($strSiteName);
$strVideoName = trim($strVideoName);
$strVideoCategoryName = trim($strVideoCategoryName);
if ($strVideoName === '') {
return trim((string)($this->DomainModel->d_index_description ?? $strSiteName));
}
$strCategorySuffix = $strVideoCategoryName !== '' ? $strVideoCategoryName . '内容' : '相关内容';
return match ($strPage) {
'play' => $strSiteName . '提供' . $strVideoName . '播放入口与剧集浏览信息,方便继续查看' . $strCategorySuffix . '与线路安排。',
'detail' => $strSiteName . '整理' . $strVideoName . '的详情信息、演员资料与观看线索,方便继续了解' . $strCategorySuffix . '与播放入口。',
default => $strSiteName . '提供' . $strVideoName . '相关介绍与浏览入口,方便继续查看' . $strCategorySuffix . '。',
};
}
protected function resolveLegacySeoKey(string $strCode, string $strPage): string
{
$arrMap = [
'home' => [
'title' => 'VIDEO@INDEX@INDEX@TITLE',
'keywords' => 'VIDEO@INDEX@INDEX@KEYWORDS',
'description' => 'VIDEO@INDEX@INDEX@DESCRIPTION',
],
'detail' => [
'title' => 'VIDEO@GETVIDEOINFO@TITLE',
'keywords' => 'VIDEO@GETVIDEOINFO@KEYWORDS',
'description' => 'VIDEO@GETVIDEOINFO@DESCRIPTION',
],
'play' => [
'title' => 'VIDEO@GETVIDEOPLAY@TITLE',
'keywords' => 'VIDEO@GETVIDEOPLAY@KEYWORDS',
'description' => 'VIDEO@GETVIDEOPLAY@DESCRIPTION',
],
'search' => [
'title' => 'VIDEO@GETSEARCHVIDEO@TITLE',
'keywords' => 'VIDEO@GETSEARCHVIDEO@KEYWORDS',
'description' => 'VIDEO@GETSEARCHVIDEO@DESCRIPTION',
],
'rank_index' => [
'title' => 'VIDEO@GETVIDEORANKINDEX@TITLE',
'keywords' => 'VIDEO@GETVIDEORANKINDEX@KEYWORDS',
'description' => 'VIDEO@GETVIDEORANKINDEX@DESCRIPTION',
],
'rank_list' => [
'title' => 'VIDEO@GETVIDEORANKLIST@TITLE',
'keywords' => 'VIDEO@GETVIDEORANKLIST@KEYWORDS',
'description' => 'VIDEO@GETVIDEORANKLIST@DESCRIPTION',
],
'category_home' => [
'title' => 'VIDEO@GETSEARCHVIDEO@TITLE',
'keywords' => 'VIDEO@GETSEARCHVIDEO@KEYWORDS',
'description' => 'VIDEO@GETSEARCHVIDEO@DESCRIPTION',
],
'category_index' => [
'title' => 'VIDEO@GETCATEGORYINDEX@TITLE',
'keywords' => 'VIDEO@GETCATEGORYINDEX@KEYWORDS',
'description' => 'VIDEO@GETCATEGORYINDEX@DESCRIPTION',
],
'category_list' => [
'title' => 'VIDEO@GETCATEGORY@TITLE',
'keywords' => 'VIDEO@GETCATEGORY@KEYWORDS',
'description' => 'VIDEO@GETCATEGORY@DESCRIPTION',
],
];
return (string)($arrMap[$strPage][$strCode] ?? '');
}
public function getTemplate(): string

View File

@@ -2,6 +2,9 @@
namespace app\services;
use app\common\helper\SeoCopyFallbackBuilder;
use app\common\helper\SeoCopySchema;
use app\common\helper\SeoCopyStore;
use app\model\CategoryModel;
use app\model\ChapterModel;
use app\model\ConverterMovel;
@@ -48,14 +51,18 @@ class VideoService
/**
* 获取视频分类首页 URl
*
* @param string $strParentCategory
* @param string|null $strParentCategory
* @return string
*/
public function getVideoCategoryIndexUrl(string $strParentCategory): string
public function getVideoCategoryIndexUrl(?string $strParentCategory): string
{
$strParentCategory = trim((string)$strParentCategory);
$strTmpCode = $this->SiteContext->TemplatesModel['t_code'];
if ($strTmpCode == 'videoGpt1') {
if ($strParentCategory === '') {
return $this->SiteContext->UrlBuilder->categoryHome();
}
return $this->SiteContext->UrlBuilder->categoryParent($strParentCategory);
} else {
$strKey = 'VIDEO_CATEGORY_INDEX_URL';
@@ -184,17 +191,23 @@ class VideoService
/**
* 排行榜 列表 url
*
* @param string $strCategory
* @param string $strParentCategory
* @param string $strSortType
* @param integer $intPage
* @param string|null $strCategory
* @param string|null $strParentCategory
* @param string|null $strSortType
* @param integer|null $intPage
* @return string
*/
public function getVideoRankUrl(string $strCategory, string $strParentCategory, string $strSortType, int $intPage): string
public function getVideoRankUrl(?string $strCategory, ?string $strParentCategory, ?string $strSortType, ?int $intPage): string
{
$strCategory = trim((string)$strCategory);
$strParentCategory = trim((string)$strParentCategory);
$strSortType = trim((string)$strSortType);
$strTmpCode = $this->SiteContext->TemplatesModel['t_code'];
if ($strTmpCode == 'videoGpt1') {
if ($strSortType === '') {
return $this->SiteContext->UrlBuilder->rankIndex();
}
return $this->SiteContext->UrlBuilder->rankList($strSortType);
} else {
$strKey = 'VIDEO_RANK_LIST_URL';
@@ -244,7 +257,17 @@ class VideoService
$strTmpCode = $this->SiteContext->TemplatesModel['t_code'];
if ($strTmpCode == 'videoGpt1') {
return $this->SiteContext->UrlBuilder->searchResult($strKeyWords);
$strUrl = $this->SiteContext->UrlBuilder->searchResult($strKeyWords);
if (is_string($intPage) && strpos($intPage, '{page}') !== false) {
return $strUrl . '&page={page}';
}
$intPage = max(1, (int)$intPage);
if ($intPage <= 1) {
return $strUrl;
}
return $strUrl . '&page=' . $intPage;
} else {
$strKey = 'VIDEO_SEARCH_LIST_URL';
$strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [
@@ -791,6 +814,11 @@ class VideoService
throw new HttpException(404, '视频不存在');
}
return $this->hydrateResolvedVideo($arrVideo, $intVId, $intVForgeId);
}
protected function hydrateResolvedVideo(array $arrVideo, int $intRequestedVId, int|NULL $intVForgeId): array
{
if ($intVForgeId >= 0 && !empty($arrVideo['v_seo_words'])) {
$intForgeOffset = max(0, $intVForgeId - 1);
if (!empty($arrVideo['v_seo_words'][$intForgeOffset])) {
@@ -809,14 +837,23 @@ class VideoService
$strTopLang = '2025';
// 点击数
$arrVideoClicks = $this->VideoClicksModel->getStats((int)($arrVideo['v_id'] ?? $intVId));
$arrVideoClicks = $this->VideoClicksModel->getStats((int)($arrVideo['v_id'] ?? $intRequestedVId));
$strVideoParentCategoryName = trim((string)($arrVideo['v_parent_category'] ?? ''));
$strVideoCategoryName = trim((string)($arrVideo['v_category'] ?? ''));
// 一部分视频数据里父分类与子分类会被写成同一个值,例如“伦理片 / 伦理片”。
// 这里在注入 SEO/TKD 变量前做一次去重,避免标题拼出“伦理片伦理片”。
if ($strVideoParentCategoryName !== '' && $strVideoParentCategoryName === $strVideoCategoryName) {
$strVideoParentCategoryName = '';
}
// 更新值
ConverterMovel::setVal([
'intPage' => 1,
'strVideoName' => $arrVideo['v_name'],
'strVideoParentCategoryName' => $arrVideo['v_parent_category'],
'strVideoCategoryName' => $arrVideo['v_category'],
'strVideoParentCategoryName' => $strVideoParentCategoryName,
'strVideoCategoryName' => $strVideoCategoryName,
'strVideoStatus' => $arrVideo['v_isend'],
'strVideoDescription' => $arrVideo['v_description'],
'strLangName' => $strTopLang,
@@ -827,7 +864,27 @@ class VideoService
$arrVideo['arrVideoClicks'] = $arrVideoClicks;
return $arrVideo;
return $arrVideo;
}
public function getVideoByRouteContext(int|string|null $intVId, int|string|null $intVForgeId, string|null $strPinyin = '')
{
$intVId = (int)$intVId;
$intVForgeId = $intVForgeId === null ? -1 : (int)$intVForgeId;
$strPinyin = trim((string)$strPinyin);
if ($intVId > 0) {
return $this->getVideoByVId($intVId, $intVForgeId);
}
if ($strPinyin !== '') {
$arrVideo = $this->VideoModel->getVideoByNameEn($strPinyin);
if (!empty($arrVideo)) {
return $this->hydrateResolvedVideo($arrVideo, (int)($arrVideo['v_id'] ?? 0), $intVForgeId);
}
}
return $this->getVideoByVId($intVId, $intVForgeId);
}
protected function resolveFallbackVideoByRequest(int $requestedVId, bool $requirePlayable = false): ?array
@@ -1103,6 +1160,82 @@ class VideoService
return SiteStyle::buildDetailSeoAddon($arrTpStyle, $arrVideo);
}
/**
* 统一获取页面补料块
*
* 优先读取 seo_copy 发布/本地文件;
* 缺失时退回 fallback builder保证页面不空。
*/
public function getSeoCopyBlock(string $strScene, array $arrOptions = []): array
{
$strScene = trim(strtolower($strScene));
if ($strScene === '') {
return [];
}
$strHost = trim((string)($this->SiteContext->DomainModel->d_domain ?? ''));
if ($strHost === '') {
return [];
}
$arrPageKeys = $this->buildSeoCopyPageKeys($strScene, $arrOptions);
if ($this->shouldSuppressDefaultOnlySeoCopy($strScene, $strHost, $arrPageKeys)) {
return [];
}
$arrData = !empty($arrPageKeys)
? SeoCopyStore::getPreferredPageData($strHost, $strScene, $arrPageKeys)
: [];
if (!empty($arrData)) {
$arrData = $this->interpolateSeoCopyData($arrData, $this->buildSeoCopyTokens($strScene, $arrOptions));
}
if (empty($arrData)) {
$arrData = SeoCopyFallbackBuilder::build($strScene, $this->buildSeoCopyFacts($strScene, $arrOptions));
}
return $this->normalizeSeoCopyBlock($strScene, $arrData);
}
private function shouldSuppressDefaultOnlySeoCopy(string $strScene, string $strHost, array $arrPageKeys): bool
{
if (!in_array($strScene, ['detail', 'play'], true)) {
return false;
}
$arrSpecificKeys = array_values(array_filter($arrPageKeys, static function ($strPageKey): bool {
$strPageKey = trim((string)$strPageKey);
return $strPageKey !== '' && $strPageKey !== 'default';
}));
if (empty($arrSpecificKeys)) {
return false;
}
foreach (SeoCopyStore::preferredRoots() as $strRoot) {
$strRoot = rtrim(trim((string)$strRoot), '/');
if ($strRoot === '' || !is_dir($strRoot)) {
continue;
}
foreach ($arrSpecificKeys as $strPageKey) {
$strPath = SeoCopyStore::resolvePagePathFromRoot($strRoot, $strHost, $strScene, $strPageKey);
if ($strPath !== '' && is_file($strPath)) {
return false;
}
}
$strDefaultPath = SeoCopyStore::resolvePagePathFromRoot($strRoot, $strHost, $strScene, 'default');
if ($strDefaultPath !== '' && is_file($strDefaultPath)) {
return true;
}
}
return false;
}
/**
* BSONDocument / 对象 / array 统一转数组(递归)
*/
@@ -1144,4 +1277,435 @@ class VideoService
}
return $mixVal;
}
private function buildSeoCopyPageKeys(string $strScene, array $arrOptions): array
{
$arrKeys = [];
switch ($strScene) {
case 'home':
case 'rank_index':
$arrKeys[] = SeoCopySchema::buildScenePageKey($strScene, 'index');
break;
case 'category_index':
$strParentCategory = trim((string)($arrOptions['parent_category'] ?? request()->route('strParentCategory')));
if ($strParentCategory !== '') {
$arrKeys[] = SeoCopySchema::buildScenePageKey($strScene, [$strParentCategory]);
}
$arrKeys[] = 'index';
break;
case 'category_list':
$strParentCategory = trim((string)($arrOptions['parent_category'] ?? request()->route('strParentCategory')));
$strCategory = trim((string)($arrOptions['category'] ?? request()->route('strCategory')));
$arrKeys[] = SeoCopySchema::buildScenePageKey($strScene, [$strParentCategory, $strCategory]);
$arrKeys[] = 'default';
break;
case 'search':
$strKeyword = trim((string)($arrOptions['keyword'] ?? request()->get('keyword', '')));
if ($strKeyword !== '') {
$arrKeys[] = SeoCopySchema::buildScenePageKey($strScene, [$strKeyword]);
}
$arrKeys[] = 'landing';
break;
case 'rank_list':
$strSortType = trim((string)($arrOptions['sort_type'] ?? request()->route('strSortType')));
$strParentCategory = trim((string)($arrOptions['parent_category'] ?? request()->route('strParentCategory')));
$strCategory = trim((string)($arrOptions['category'] ?? request()->route('strCategory')));
if ($strSortType !== '') {
$arrKeys[] = SeoCopySchema::buildScenePageKey($strScene, [$strSortType, $strParentCategory, $strCategory]);
$arrKeys[] = SeoCopySchema::buildScenePageKey($strScene, [$strSortType]);
}
$arrKeys[] = 'default';
break;
case 'detail':
$arrVideo = $this->toArraySafe($arrOptions['video'] ?? []);
$intVideoId = (int)($arrVideo['v_id'] ?? ($arrOptions['video_id'] ?? 0));
if ($intVideoId > 0) {
$arrKeys[] = SeoCopySchema::buildScenePageKey($strScene, [$intVideoId]);
}
$arrKeys[] = 'default';
break;
case 'forge':
$arrVideo = $this->toArraySafe($arrOptions['video'] ?? []);
$intVideoId = (int)($arrVideo['v_id'] ?? ($arrOptions['video_id'] ?? 0));
$intForgeId = (int)($arrOptions['forge_id'] ?? request()->route('intVForgeId'));
if ($intVideoId > 0 && $intForgeId > 0) {
$arrKeys[] = SeoCopySchema::buildScenePageKey($strScene, [$intVideoId, $intForgeId]);
}
$arrKeys[] = 'default';
break;
case 'play':
$arrVideo = $this->toArraySafe($arrOptions['video'] ?? []);
$intVideoId = (int)($arrVideo['v_id'] ?? ($arrOptions['video_id'] ?? 0));
$strPlayType = trim((string)($arrOptions['play_type'] ?? request()->route('strPlayType')));
$intPlayIndex = (int)($arrOptions['play_index'] ?? request()->route('intPlayIndex'));
if ($intVideoId > 0) {
$arrKeys[] = SeoCopySchema::buildScenePageKey($strScene, [$intVideoId, $strPlayType, $intPlayIndex]);
}
$arrKeys[] = 'default';
break;
}
return array_values(array_unique(array_filter($arrKeys, static function ($strKey): bool {
return trim((string)$strKey) !== '';
})));
}
private function buildSeoCopyFacts(string $strScene, array $arrOptions): array
{
$arrVideo = $this->toArraySafe($arrOptions['video'] ?? []);
$arrPagerData = $this->toArraySafe($arrOptions['pager_data'] ?? []);
$arrPageStats = [
'page' => (int)($arrPagerData['page'] ?? ($arrOptions['page'] ?? 1)),
'pages' => (int)($arrPagerData['pages'] ?? ($arrOptions['pages'] ?? 1)),
'total' => (int)($arrPagerData['total'] ?? ($arrOptions['total'] ?? 0)),
'visible' => (int)($arrOptions['visible'] ?? 0),
];
return match ($strScene) {
'home' => [
'site_name' => (string)($this->SiteContext->DomainModel->d_name ?? ''),
'content_scopes' => $this->extractHomeContentScopes(),
'navigation_paths' => ['首页 -> 分类频道 -> 详情页', '首页 -> 搜索页 -> 详情页 -> 播放页'],
],
'category_index' => [
'parent_category_name' => $this->resolveCategoryLabel((string)($arrOptions['parent_category'] ?? request()->route('strParentCategory'))),
'sub_category_examples' => $this->extractCategoryExamples((string)($arrOptions['parent_category'] ?? request()->route('strParentCategory'))),
'search_path' => '如果题材仍然不够精确,可继续走站内搜索。',
],
'category_list' => [
'parent_category_name' => $this->resolveCategoryLabel((string)($arrOptions['parent_category'] ?? request()->route('strParentCategory'))),
'category_name' => $this->resolveCategoryLabel((string)($arrOptions['category'] ?? request()->route('strCategory'))),
'page_stats' => $arrPageStats,
],
'search' => [
'keyword' => (string)($arrOptions['keyword'] ?? request()->get('keyword', '')),
'result_stats' => $arrPageStats,
],
'rank_index' => [
'rank_periods' => $this->extractRankPeriods(),
'rank_path' => '榜单首页 -> 榜单列表 -> 详情页 -> 播放页',
],
'rank_list' => [
'rank_period_name' => $this->resolveRankSortLabel((string)($arrOptions['sort_type'] ?? request()->route('strSortType'))),
'rank_scope_name' => $this->resolveRankScopeName(
(string)($arrOptions['parent_category'] ?? request()->route('strParentCategory')),
(string)($arrOptions['category'] ?? request()->route('strCategory'))
),
'page_stats' => $arrPageStats,
],
'detail' => [
'video_name' => (string)($arrVideo['v_name'] ?? ''),
'category_name' => (string)($arrVideo['v_category'] ?? ''),
'year' => $this->normalizeSeoCopyYear($arrVideo),
'core_plot_or_positioning' => (string)($arrVideo['v_description'] ?? ''),
],
'play' => [
'video_name' => (string)($arrVideo['v_name'] ?? ''),
'play_type' => $this->getConverterPlayLineVal((string)($arrOptions['play_type'] ?? request()->route('strPlayType'))),
'episode_label_or_index' => (string)($arrOptions['play_index'] ?? request()->route('intPlayIndex') ?? 1),
'play_index' => (int)($arrOptions['play_index'] ?? request()->route('intPlayIndex') ?? 1),
'core_plot_or_positioning' => (string)($arrVideo['v_description'] ?? ''),
],
default => [],
};
}
private function normalizeSeoCopyBlock(string $strScene, array $arrData): array
{
$arrTemplate = SeoCopySchema::getSceneTemplate($strScene);
$arrMerged = array_merge($arrTemplate, $arrData);
$arrGuideCards = [];
foreach ((array)($arrMerged['guide_cards'] ?? []) as $arrCard) {
if (!is_array($arrCard)) {
continue;
}
$strTitle = trim((string)($arrCard['title'] ?? ''));
$strText = trim((string)($arrCard['text'] ?? ''));
$strHref = trim((string)($arrCard['href'] ?? ''));
if ($strTitle === '' && $strText === '') {
continue;
}
$arrItem = [
'title' => $strTitle,
'text' => $strText,
];
if ($strHref !== '') {
$arrItem['href'] = $strHref;
}
$arrGuideCards[] = $arrItem;
}
$arrMerged['guide_cards'] = $arrGuideCards;
$arrMerged['_scene'] = $strScene;
return $arrMerged;
}
private function interpolateSeoCopyData(array $arrData, array $arrTokens): array
{
foreach ($arrData as $strKey => $mValue) {
$arrData[$strKey] = $this->interpolateSeoCopyValue($mValue, $arrTokens);
}
return $arrData;
}
private function interpolateSeoCopyValue($mValue, array $arrTokens)
{
if (is_array($mValue)) {
foreach ($mValue as $strKey => $mChildValue) {
$mValue[$strKey] = $this->interpolateSeoCopyValue($mChildValue, $arrTokens);
}
return $mValue;
}
if (!is_string($mValue)) {
return $mValue;
}
return SeoCopyStore::interpolateTemplate($mValue, $arrTokens);
}
private function buildSeoCopyTokens(string $strScene, array $arrOptions): array
{
$arrVideo = $this->toArraySafe($arrOptions['video'] ?? []);
$strPlayType = trim((string)($arrOptions['play_type'] ?? request()->route('strPlayType')));
$intPlayIndex = (int)($arrOptions['play_index'] ?? request()->route('intPlayIndex') ?? 1);
$arrFacts = $this->buildSeoCopyFacts($strScene, $arrOptions);
$strSiteName = trim((string)($this->SiteContext->DomainModel->d_name ?? ''));
$strVideoName = trim((string)($arrVideo['v_name'] ?? ''));
$strYear = $this->normalizeSeoCopyYear($arrVideo);
$strAreaName = $this->implodeSeoCopyScalar($arrVideo['v_area'] ?? '');
$strLangName = $this->implodeSeoCopyScalar($arrVideo['v_lang'] ?? '');
$strDirectorNames = $this->implodeSeoCopyScalar($arrVideo['v_director'] ?? '', 3);
$strActorNames = $this->implodeSeoCopyScalar($arrVideo['v_actor'] ?? '', 4);
$strRemarks = trim((string)($arrVideo['v_remarks'] ?? ''));
$strVideoAlias = $this->implodeSeoCopyScalar($arrVideo['v_alias'] ?? ($arrVideo['v_aliases'] ?? ''), 3);
$strPlayLine = $this->getConverterPlayLineVal($strPlayType);
$strEpisodeName = $this->buildSeoCopyEpisodeLabel($arrVideo, $intPlayIndex);
$arrTokens = [
'site_name' => $strSiteName,
'video_name' => $strVideoName,
'video_alias' => $strVideoAlias,
'year' => $strYear,
'area_name' => $strAreaName,
'lang_name' => $strLangName,
'director_names' => $strDirectorNames,
'actor_names' => $strActorNames,
'remarks' => $strRemarks,
'play_line' => $strPlayLine,
'play_type' => $strPlayType,
'episode_name' => $strEpisodeName,
'episode_label_or_index' => $strEpisodeName,
'category_name' => trim((string)($arrVideo['v_category'] ?? '')),
'scene' => $strScene,
];
$arrKnownFactKeys = [
'keyword',
'parent_category_name',
'category_name',
'rank_period_name',
'rank_scope_name',
'search_path',
'rank_path',
];
foreach ($arrKnownFactKeys as $strFactKey) {
if (!isset($arrFacts[$strFactKey])) {
continue;
}
$arrTokens[$strFactKey] = $this->implodeSeoCopyScalar($arrFacts[$strFactKey]);
}
return array_map(static function ($mValue): string {
return trim((string)$mValue);
}, $arrTokens);
}
private function normalizeSeoCopyYear(array $arrVideo): string
{
$strYear = trim((string)($arrVideo['v_year'] ?? ''));
if ($strYear !== '') {
if (preg_match('/\b(\d{4})\b/u', $strYear, $arrMatches) === 1) {
return $arrMatches[1];
}
return $strYear;
}
$strPublishDate = trim((string)($arrVideo['v_publish_date'] ?? ''));
if ($strPublishDate !== '' && preg_match('/\b(\d{4})\b/u', $strPublishDate, $arrMatches) === 1) {
return $arrMatches[1];
}
return '';
}
private function implodeSeoCopyScalar($mValue, int $intLimit = 0): string
{
if (is_string($mValue)) {
return trim($mValue);
}
if (!is_array($mValue)) {
return trim((string)$mValue);
}
$arrItems = array_values(array_filter(array_map(static function ($mItem): string {
return trim((string)$mItem);
}, $mValue), static function (string $strVal): bool {
return $strVal !== '';
}));
if ($intLimit > 0) {
$arrItems = array_slice($arrItems, 0, $intLimit);
}
return implode('、', $arrItems);
}
private function buildSeoCopyEpisodeLabel(array $arrVideo, int $intPlayIndex): string
{
$intPlayIndex = max(1, $intPlayIndex);
$arrPlayFrom = (array)($arrVideo['v_play_from'] ?? []);
$arrPlayUrl = (array)($arrVideo['v_play_url'] ?? []);
$strPlayType = trim((string)(request()->route('strPlayType') ?? ''));
if ($strPlayType !== '' && isset($arrPlayFrom[$strPlayType]) && !empty($arrPlayUrl[$strPlayType][$intPlayIndex - 1]['p_title'])) {
return trim((string)$arrPlayUrl[$strPlayType][$intPlayIndex - 1]['p_title']);
}
return '第' . $intPlayIndex . '集';
}
private function extractHomeContentScopes(): array
{
$arrCategories = (array)($this->SiteContext->TpStyle['template_cfg']['pages']['home']['categories'] ?? []);
$arrOut = [];
foreach ($arrCategories as $arrCategory) {
$strTitle = $this->resolveDisplayText($arrCategory['title_text'] ?? ($arrCategory['name'] ?? $arrCategory['key'] ?? ''));
if ($strTitle !== '') {
$arrOut[] = $strTitle;
}
}
return !empty($arrOut) ? array_slice(array_values(array_unique($arrOut)), 0, 5) : ['电影', '电视剧', '综艺'];
}
private function extractCategoryExamples(string $strParentCategory): array
{
$arrMap = (array)($this->SiteContext->TpStyle['template_cfg']['pages']['category']['cat1_map'][$strParentCategory]['subcat_slots'] ?? []);
$arrOut = [];
foreach ($arrMap as $arrItem) {
$strTitle = $this->resolveDisplayText($arrItem['title_text'] ?? ($arrItem['name'] ?? $arrItem['key'] ?? ''));
if ($strTitle !== '') {
$arrOut[] = $strTitle;
}
}
return !empty($arrOut) ? array_slice(array_values(array_unique($arrOut)), 0, 4) : [];
}
private function extractRankPeriods(): array
{
$arrSlots = (array)($this->SiteContext->TpStyle['template_cfg']['pages']['rank_home']['slots'] ?? []);
$arrOut = [];
foreach ($arrSlots as $arrSlot) {
$strSortType = trim((string)($arrSlot['sort_type'] ?? ''));
if ($strSortType !== '') {
$arrOut[] = $this->resolveRankSortLabel($strSortType);
}
}
return !empty($arrOut) ? array_slice(array_values(array_unique($arrOut)), 0, 4) : ['日榜', '周榜', '月榜'];
}
private function resolveCategoryLabel(string $strCategory): string
{
$strCategory = trim($strCategory);
if ($strCategory === '') {
return '';
}
$arrFlatMap = (array)VideoCategoryModel::$arrCategory;
if (isset($arrFlatMap[$strCategory])) {
return trim((string)$arrFlatMap[$strCategory]);
}
foreach ((array)VideoCategoryModel::getCustomCategory('ONE') as $arrParentCategory) {
if ((string)($arrParentCategory['v_category_en'] ?? '') === $strCategory) {
return trim((string)($arrParentCategory['v_category'] ?? $strCategory));
}
foreach ((array)($arrParentCategory['children'] ?? []) as $arrChildCategory) {
if ((string)($arrChildCategory['v_category_en'] ?? '') === $strCategory) {
return trim((string)($arrChildCategory['v_category'] ?? $strCategory));
}
}
}
return $strCategory;
}
private function resolveRankSortLabel(string $strSortType): string
{
return match (trim(strtolower($strSortType))) {
'daily' => '日榜',
'weekly' => '周榜',
'monthly' => '月榜',
default => $strSortType !== '' ? $strSortType : '榜单',
};
}
private function resolveRankScopeName(string $strParentCategory, string $strCategory): string
{
$strParentLabel = $this->resolveCategoryLabel($strParentCategory);
$strCategoryLabel = $this->resolveCategoryLabel($strCategory);
if ($strParentLabel !== '' && $strCategoryLabel !== '' && $strCategoryLabel !== 'all') {
return $strParentLabel . ' / ' . $strCategoryLabel;
}
if ($strParentLabel !== '') {
return $strParentLabel;
}
return '当前范围';
}
private function resolveDisplayText($mValue): string
{
if (is_array($mValue)) {
foreach (['primary', 'seo', 'secondary', 'name', 'title', 'text'] as $strKey) {
$strVal = trim((string)($mValue[$strKey] ?? ''));
if ($strVal !== '') {
return $strVal;
}
}
foreach ($mValue as $mItem) {
$strVal = $this->resolveDisplayText($mItem);
if ($strVal !== '') {
return $strVal;
}
}
return '';
}
return trim((string)$mValue);
}
}