diff --git a/code/.gitignore b/code/.gitignore index 24233e4..2d80360 100644 --- a/code/.gitignore +++ b/code/.gitignore @@ -17,4 +17,6 @@ Thumbs.db public/static/css/compiled/* public/static/js/compiled/* public/static/favicon/generated/* +/public/_seo_copy_release/* +/data/seo_copy_published/* /storage/* diff --git a/code/scripts/seo_copy_published_audit.php b/code/scripts/seo_copy_published_audit.php new file mode 100644 index 0000000..03526a5 --- /dev/null +++ b/code/scripts/seo_copy_published_audit.php @@ -0,0 +1,293 @@ +] [--sample-play=::]\n\n"; + echo "Examples:\n"; + echo " php scripts/seo_copy_published_audit.php\n"; + echo " php scripts/seo_copy_published_audit.php --host=lmjcg-com --format=text\n"; + echo " php scripts/seo_copy_published_audit.php --host=lmjcg-com --sample-detail=76310 --sample-play=76310:default:1 --format=text\n"; +} + +function parseArgs(array $argv): array +{ + $arrOptions = [ + 'root' => dirname(__DIR__) . '/data/seo_copy_published', + 'host' => '', + 'format' => 'json', + 'sample_detail' => '', + 'sample_play' => '', + ]; + + array_shift($argv); + + foreach ($argv as $strArg) { + if (str_starts_with($strArg, '--root=')) { + $arrOptions['root'] = trim(substr($strArg, strlen('--root='))); + continue; + } + + if (str_starts_with($strArg, '--host=')) { + $arrOptions['host'] = trim(substr($strArg, strlen('--host='))); + continue; + } + + if (str_starts_with($strArg, '--format=')) { + $arrOptions['format'] = strtolower(trim(substr($strArg, strlen('--format=')))); + continue; + } + + if (str_starts_with($strArg, '--sample-detail=')) { + $arrOptions['sample_detail'] = trim(substr($strArg, strlen('--sample-detail='))); + continue; + } + + if (str_starts_with($strArg, '--sample-play=')) { + $arrOptions['sample_play'] = trim(substr($strArg, strlen('--sample-play='))); + continue; + } + + if (in_array($strArg, ['-h', '--help'], true)) { + printUsage(); + exit(0); + } + } + + return $arrOptions; +} + +function listHostDirs(string $strRoot, string $strHostFilter): array +{ + if (!is_dir($strRoot)) { + return []; + } + + $arrHosts = []; + $arrEntries = scandir($strRoot); + if (!is_array($arrEntries)) { + return []; + } + + foreach ($arrEntries as $strEntry) { + if ($strEntry === '.' || $strEntry === '..') { + continue; + } + + $strPath = $strRoot . '/' . $strEntry; + if (!is_dir($strPath)) { + continue; + } + + if ($strHostFilter !== '' && $strHostFilter !== $strEntry) { + continue; + } + + $arrHosts[] = $strEntry; + } + + sort($arrHosts); + return $arrHosts; +} + +function collectSceneSummary(string $strRoot, string $strHost, string $strScene): array +{ + $strSceneDir = $strRoot . '/' . $strHost . '/' . $strScene; + $arrSummary = [ + 'exists' => is_dir($strSceneDir), + 'total_files' => 0, + 'default_exists' => false, + 'non_default_count' => 0, + 'sample_non_default_keys' => [], + ]; + + if (!is_dir($strSceneDir)) { + return $arrSummary; + } + + $arrFiles = glob($strSceneDir . '/*.json') ?: []; + sort($arrFiles); + + foreach ($arrFiles as $strFile) { + if (!is_file($strFile)) { + continue; + } + + $arrSummary['total_files']++; + $strPageKey = preg_replace('/\.json$/i', '', basename($strFile)); + if ($strPageKey === 'default') { + $arrSummary['default_exists'] = true; + continue; + } + + $arrSummary['non_default_count']++; + if (count($arrSummary['sample_non_default_keys']) < 10) { + $arrSummary['sample_non_default_keys'][] = $strPageKey; + } + } + + return $arrSummary; +} + +function buildExpectedChecks(string $strRoot, string $strHost, string $strSampleDetail, string $strSamplePlay): array +{ + $arrChecks = []; + + if ($strSampleDetail !== '') { + $strPageKey = SeoCopySchema::buildScenePageKey('detail', [$strSampleDetail]); + $strPath = $strRoot . '/' . $strHost . '/detail/' . $strPageKey . '.json'; + $arrChecks[] = [ + 'scene' => 'detail', + 'input' => $strSampleDetail, + 'expected_page_key' => $strPageKey, + 'exists' => is_file($strPath), + 'path' => $strPath, + ]; + } + + if ($strSamplePlay !== '') { + $arrParts = explode(':', $strSamplePlay); + $strVideoId = trim((string)($arrParts[0] ?? '')); + $strPlayType = trim((string)($arrParts[1] ?? 'default')); + $strEpisode = trim((string)($arrParts[2] ?? '1')); + $strPageKey = SeoCopySchema::buildScenePageKey('play', [$strVideoId, $strPlayType, $strEpisode]); + $strPath = $strRoot . '/' . $strHost . '/play/' . $strPageKey . '.json'; + $arrChecks[] = [ + 'scene' => 'play', + 'input' => $strSamplePlay, + 'expected_page_key' => $strPageKey, + 'exists' => is_file($strPath), + 'path' => $strPath, + ]; + } + + return $arrChecks; +} + +function summarizeHost(string $strRoot, string $strHost, string $strSampleDetail, string $strSamplePlay): array +{ + $arrScenes = []; + foreach (SeoCopySchema::getSupportedScenes() as $strScene) { + $arrScenes[$strScene] = collectSceneSummary($strRoot, $strHost, $strScene); + } + + $arrDetail = $arrScenes['detail'] ?? []; + $arrPlay = $arrScenes['play'] ?? []; + + return [ + 'host' => $strHost, + 'detail_default_only' => !empty($arrDetail['default_exists']) && (int)($arrDetail['non_default_count'] ?? 0) === 0, + 'play_default_only' => !empty($arrPlay['default_exists']) && (int)($arrPlay['non_default_count'] ?? 0) === 0, + 'detail_has_non_default' => (int)($arrDetail['non_default_count'] ?? 0) > 0, + 'play_has_non_default' => (int)($arrPlay['non_default_count'] ?? 0) > 0, + 'scenes' => $arrScenes, + 'expected_checks' => buildExpectedChecks($strRoot, $strHost, $strSampleDetail, $strSamplePlay), + ]; +} + +function renderText(array $arrSummary): string +{ + $arrLines = [ + 'root: ' . $arrSummary['root'], + 'total_hosts: ' . $arrSummary['total_hosts'], + 'hosts_detail_default_only: ' . $arrSummary['hosts_detail_default_only'], + 'hosts_play_default_only: ' . $arrSummary['hosts_play_default_only'], + 'hosts_detail_has_non_default: ' . $arrSummary['hosts_detail_has_non_default'], + 'hosts_play_has_non_default: ' . $arrSummary['hosts_play_has_non_default'], + '', + ]; + + foreach ($arrSummary['hosts'] as $arrHost) { + $arrLines[] = '[host] ' . $arrHost['host']; + $arrLines[] = ' detail_default_only: ' . ($arrHost['detail_default_only'] ? 'yes' : 'no'); + $arrLines[] = ' play_default_only: ' . ($arrHost['play_default_only'] ? 'yes' : 'no'); + + foreach (['detail', 'play', 'home', 'category_index', 'category_list', 'search', 'rank_index', 'rank_list', 'forge'] as $strScene) { + $arrScene = $arrHost['scenes'][$strScene] ?? []; + if (empty($arrScene)) { + continue; + } + + $arrLines[] = sprintf( + ' %s: total=%d, default=%s, non_default=%d', + $strScene, + (int)($arrScene['total_files'] ?? 0), + !empty($arrScene['default_exists']) ? 'yes' : 'no', + (int)($arrScene['non_default_count'] ?? 0) + ); + + if (!empty($arrScene['sample_non_default_keys'])) { + $arrLines[] = ' sample_non_default: ' . implode(', ', $arrScene['sample_non_default_keys']); + } + } + + if (!empty($arrHost['expected_checks'])) { + $arrLines[] = ' expected_checks:'; + foreach ($arrHost['expected_checks'] as $arrCheck) { + $arrLines[] = sprintf( + ' %s | key=%s | exists=%s', + $arrCheck['scene'], + $arrCheck['expected_page_key'], + $arrCheck['exists'] ? 'yes' : 'no' + ); + } + } + + $arrLines[] = ''; + } + + return implode(PHP_EOL, $arrLines) . PHP_EOL; +} + +$arrOptions = parseArgs($argv); +$strRoot = rtrim($arrOptions['root'], '/'); + +if (!is_dir($strRoot)) { + fwrite(STDERR, "Root not found: {$strRoot}\n"); + printUsage(); + exit(1); +} + +$arrHosts = listHostDirs($strRoot, $arrOptions['host']); +$arrSummary = [ + 'root' => realpath($strRoot) ?: $strRoot, + 'total_hosts' => count($arrHosts), + 'hosts_detail_default_only' => 0, + 'hosts_play_default_only' => 0, + 'hosts_detail_has_non_default' => 0, + 'hosts_play_has_non_default' => 0, + 'hosts' => [], +]; + +foreach ($arrHosts as $strHost) { + $arrHostSummary = summarizeHost($strRoot, $strHost, $arrOptions['sample_detail'], $arrOptions['sample_play']); + if ($arrHostSummary['detail_default_only']) { + $arrSummary['hosts_detail_default_only']++; + } + if ($arrHostSummary['play_default_only']) { + $arrSummary['hosts_play_default_only']++; + } + if ($arrHostSummary['detail_has_non_default']) { + $arrSummary['hosts_detail_has_non_default']++; + } + if ($arrHostSummary['play_has_non_default']) { + $arrSummary['hosts_play_has_non_default']++; + } + + $arrSummary['hosts'][] = $arrHostSummary; +} + +if ($arrOptions['format'] === 'text') { + echo renderText($arrSummary); + exit(0); +} + +echo json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/code/scripts/seo_copy_release_run.php b/code/scripts/seo_copy_release_run.php index 83dcd5e..6705965 100644 --- a/code/scripts/seo_copy_release_run.php +++ b/code/scripts/seo_copy_release_run.php @@ -162,6 +162,7 @@ function startTempServer(string $strCodeRoot, string $strSourceRoot, int $intPor $Process = proc_open($strCommand, $arrDescriptors, $arrPipes, $strCodeRoot, array_merge($_ENV, [ 'SEO_COPY_ROOT_OVERRIDE' => $strSourceRoot, + 'SEO_COPY_PUBLISHED_ROOT' => $strSourceRoot, ])); if (!is_resource($Process)) { diff --git a/code/scripts/seo_copy_restore_from_publish_logs.php b/code/scripts/seo_copy_restore_from_publish_logs.php new file mode 100644 index 0000000..678113f --- /dev/null +++ b/code/scripts/seo_copy_restore_from_publish_logs.php @@ -0,0 +1,184 @@ +isFile()) { + continue; + } + + $strPath = $file->getPathname(); + if (!str_ends_with($strPath, '.json')) { + continue; + } + + if (!preg_match( + '#/backups/[^/]+/' . preg_quote($strHost, '#') . '/([^/]+)/([^/]+)\.json$#', + $strPath, + $arrMatch + )) { + continue; + } + + $strScene = $arrMatch[1]; + $strPageKey = $arrMatch[2]; + if (!in_array($strScene, $arrScenes, true)) { + continue; + } + + $arrFilesByScene[$strScene][$strPageKey] = $strPath; + $arrMatchedPaths[] = $strPath; +} + +if (empty($arrFilesByScene)) { + fwrite(STDERR, "no backup files found for host: {$strHost}\n"); + exit(3); +} + +ksort($arrFilesByScene); + +$arrSummary = []; +foreach ($arrScenes as $strScene) { + $arrPageMap = $arrFilesByScene[$strScene] ?? []; + ksort($arrPageMap); + + $arrSummary[$strScene] = [ + 'count' => count($arrPageMap), + 'files' => array_keys($arrPageMap), + ]; + + if ($boolDryRun) { + continue; + } + + foreach ($arrPageMap as $strPageKey => $strFile) { + $strPageKey = (string)$strPageKey; + $strJson = @file_get_contents($strFile); + if ($strJson === false || trim($strJson) === '') { + continue; + } + + $arrData = json_decode($strJson, true); + if (!is_array($arrData)) { + continue; + } + + SeoCopyStore::writePageDataToRoot($strTarget, $strHost, $strScene, $strPageKey, $arrData); + } +} + +echo "host: {$strHost}\n"; +echo "source: {$strSource}\n"; +echo "target: {$strTarget}\n"; +echo "mode: " . ($boolDryRun ? 'dry-run' : 'write') . "\n"; +echo "matched_paths: " . count($arrMatchedPaths) . "\n\n"; + +foreach ($arrSummary as $strScene => $arrInfo) { + if ($arrInfo['count'] === 0) { + continue; + } + + echo "[{$strScene}] count=" . $arrInfo['count'] . "\n"; + foreach ($arrInfo['files'] as $strName) { + echo " - {$strName}.json\n"; + } +} diff --git a/code/scripts/seo_copy_restore_from_source.php b/code/scripts/seo_copy_restore_from_source.php new file mode 100644 index 0000000..e9f1287 --- /dev/null +++ b/code/scripts/seo_copy_restore_from_source.php @@ -0,0 +1,152 @@ +/detail/*.json and /play/*.json + --target Target root. Defaults to code/data/seo_copy_published + --dry-run Preview only, do not write files + +Examples: + php code/scripts/{$script} \\ + --host=chuanjiafeng-net \\ + --source=code/storage/domain_bootstrap_bundles/chuanjiafeng-compact/data/seo_copy \\ + --dry-run + +TXT; +} + +function resolve_path(string $path): string +{ + $path = trim($path); + if ($path === '') { + return ''; + } + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + $arrCandidates = [ + getcwd() . '/' . ltrim($path, '/'), + base_path($path), + ]; + + foreach ($arrCandidates as $strCandidate) { + if (file_exists($strCandidate) || is_dir($strCandidate)) { + return rtrim($strCandidate, '/'); + } + } + + return rtrim($arrCandidates[0], '/'); +} + +$arrArgs = []; +foreach (array_slice($argv, 1) as $strArg) { + if (strncmp($strArg, '--', 2) !== 0) { + continue; + } + + $strArg = substr($strArg, 2); + if ($strArg === 'dry-run') { + $arrArgs['dry-run'] = true; + continue; + } + + $arrParts = explode('=', $strArg, 2); + $arrArgs[$arrParts[0]] = $arrParts[1] ?? ''; +} + +$strHost = trim((string)($arrArgs['host'] ?? '')); +$strSource = trim((string)($arrArgs['source'] ?? '')); +$strTarget = trim((string)($arrArgs['target'] ?? base_path('data/seo_copy_published'))); +$boolDryRun = !empty($arrArgs['dry-run']); + +if ($strHost === '' || $strSource === '') { + usage(); + exit(1); +} + +$strSource = resolve_path($strSource); +$strTarget = resolve_path($strTarget); + +$strHostSource = $strSource . '/' . $strHost; +if (!is_dir($strHostSource)) { + fwrite(STDERR, "source host dir not found: {$strHostSource}\n"); + exit(2); +} + +$arrScenes = ['detail', 'play']; +$arrSummary = []; + +foreach ($arrScenes as $strScene) { + $strSceneDir = $strHostSource . '/' . $strScene; + $arrFiles = []; + + if (is_dir($strSceneDir)) { + foreach (glob($strSceneDir . '/*.json') ?: [] as $strFile) { + if (!is_file($strFile)) { + continue; + } + + $arrFiles[] = $strFile; + } + } + + sort($arrFiles); + $arrSummary[$strScene] = [ + 'count' => count($arrFiles), + 'files' => array_map('basename', $arrFiles), + ]; + + if ($boolDryRun) { + continue; + } + + foreach ($arrFiles as $strFile) { + $strPageKey = basename($strFile, '.json'); + $strJson = file_get_contents($strFile); + if ($strJson === false || trim($strJson) === '') { + continue; + } + + $arrData = json_decode($strJson, true); + if (!is_array($arrData)) { + continue; + } + + SeoCopyStore::writePageDataToRoot($strTarget, $strHost, $strScene, $strPageKey, $arrData); + } +} + +echo "host: {$strHost}\n"; +echo "source: {$strSource}\n"; +echo "target: {$strTarget}\n"; +echo "mode: " . ($boolDryRun ? 'dry-run' : 'write') . "\n\n"; + +foreach ($arrSummary as $strScene => $arrInfo) { + echo "[{$strScene}] count=" . $arrInfo['count'] . "\n"; + foreach ($arrInfo['files'] as $strName) { + echo " - {$strName}\n"; + } +} diff --git a/docs/1270-SEONexus-Codex多版本协作交接总文档.md b/docs/1270-SEONexus-Codex多版本协作交接总文档.md index ed33e09..7c4c8cd 100644 --- a/docs/1270-SEONexus-Codex多版本协作交接总文档.md +++ b/docs/1270-SEONexus-Codex多版本协作交接总文档.md @@ -91,6 +91,234 @@ 5. 是否存在非 default `play/*.json` 6. 回灌后是否命中 `guide-detail / guide-play` +### 2026-04-16 晚间补充结论:GPT 模板“已做优化突然像没了”的真实根因 + +这段是给后续 Codex 专门避坑用的。 + +当出现下面这些现象时: + +- 详情页 / 播放页的引导文像突然没了 +- 播放页 canonical 又指回详情页 +- 详情页切集链接退化成 `/bf-76359-/douban-1` +- 你感觉“之前已经测试通过的 GPT 模板优化好像被覆盖了” + +不要先判断成: + +- `seo_copy` 数据丢了 +- `seotkd` 数据被删了 +- 只是数据库被覆盖了 + +本轮已经确认过,至少在 `videoGpt1` 这条线上,更常见的真实根因是: + +1. 活跃模板链路里重新混入了旧模板写法 +2. 旧写法继续使用 `{site:vpurl ...}`,没有稳定走 `VideoService::getVideoPlayUrl(...)` +3. 结果就是: + - 详情页切集列表可能退回旧格式 + - 播放页内部切集入口可能退回旧格式 + - 播放页 canonical / og:url / JSON-LD url 可能重新指错 +4. 所以最终表现会让人误以为“之前做过的 SEO/UI 优化全部消失了” + +### 这次已经确认修过的活跃入口 + +如果后面又出现类似问题,优先检查这些文件,而不是先去怀疑数据库: + +- `code/app/home/view/videoGpt1/video/getVideoPlayUrl.html` +- `code/app/home/view/videoGpt1/module/playline/layout/layout_A.html` +- `code/app/home/view/videoGpt1/module/playline/layout/layout_B.html` +- `code/app/home/view/videoGpt1/module/playline/layout/layout_C.html` +- `code/app/home/view/videoGpt1/module/play/play_01.html` +- `code/app/home/view/videoGpt1/module/play/play_02.html` +- `code/app/home/view/videoGpt1/module/play/play_03.html` +- `code/app/home/view/videoGpt1/module/play/play_04.html` +- `code/app/home/view/videoGpt1/module/play/play_05.html` +- `code/app/home/view/videoGpt1/module/detail_main/action.html` +- `code/app/home/view/videoGpt1/module/detail_main/cover.html` + +### 这次已经确认过的验证标准 + +后续 Codex 不要只看页面“能打开”,而要按下面标准验: + +1. 详情页包含 `guide-detail` +2. 播放页包含 `guide-play` +3. 详情页切集链接必须带 slug,例如: + - `/bf-76359-san-fen-zhi-yi-qing-ren/douban-1` +4. 不能再出现: + - `/bf-76359-/douban-1` +5. 播放页 canonical 必须指向播放页自己,而不是详情页 +6. `og:url` 与 JSON-LD `url` 也必须同步指向播放页自己 + +### 模板表达式的额外坑 + +本轮还确认了一个模板层坑: + +- 在模板 `{: ... }` 表达式里直接写 `app(\app\services\VideoService::class)`,某些场景下会被模板解析吞掉命名空间 +- 报错形式通常像: + - `类不存在: appservicesVideoService` + +因此模板内联调用更稳的写法是: + +- `app('app\\services\\VideoService')->getVideoPlayUrl(...)` + +如果后续又出现“明明逻辑没问题,但模板页直接系统错误”,优先先查这一条。 + +### DPlayer 那条旧问题的当前结论 + +之前出现过: + +- `TypeError: Cannot read properties of null (reading 'classList')` + +本轮复核后,当前正式生效的编译脚本是: + +- `code/public/static/js/compiled/48dd19fee8.js` + +其中与 DPlayer fullscreen 相关的 `classList` 访问已经带容器判空保护,因此: + +- 当前仓库主链并没有重新把这条空指针直接引回来 +- 如果后续线上再次出现这类报错,优先怀疑: + - 老旧静态资源缓存未清 + - 旧模板 JS 被覆盖回线上 + - 不是本轮这批 `videoGpt1` 模板修复本身造成的 + +### 2026-04-16 GPT 模板快速验收清单 + +这份清单给后续所有 Codex 共用。 + +目标不是“页面能打开就算过”,而是快速确认: + +- `seo_copy` 引导块还在 +- TKD 还在 +- URL 没退化 +- detail / play 主链没被旧模板重新接管 + +#### 验收前提 + +默认以某个已验证 host 为样本,例如: + +- `jpjdxs.com` + +本轮验证样本里常用的页面有: + +- 首页:`/` +- 榜单首页:`/phb-index` +- 一级分类页:`/videotype/1-dian-ying` +- 二级分类页:`/videotype/dian-ying/shao-shi-dian-ying-1` +- 搜索结果页:`/get-index?keyword=test` +- 详情页:`/neirong-76359-san-fen-zhi-yi-qing-ren` +- 播放页:`/bf-76359-san-fen-zhi-yi-qing-ren/douban-1` + +#### 页面级验收标准 + +1. 首页 + - 必须命中:`guide-collection` + - 必须能看到:首页导览、自定义首页引导块 + - 必须有正常 `title / keywords / description` + +2. 榜单首页 + - 必须命中:`guide-collection` + - 必须有正常榜单页 TKD + +3. 一级分类页 + - 必须命中:`guide-collection` + - 必须有正常分类页 TKD + +4. 二级分类列表页 + - 必须命中:`guide-collection` + - 必须有正常列表页 TKD + +5. 搜索结果页 + - 必须命中:`guide-collection` + - `canonical` 必须指向当前真实搜索页 + - `og:url` 必须与 `canonical` 一致 + - `CollectionPage.url` 必须与 `canonical` 一致 + +6. 详情页 + - 必须命中:`guide-detail` + - `canonical / og:url / JSON-LD url` 必须都指向详情页自己 + - 切集链接必须带 slug + - 不能出现 `/bf-76359-/douban-1` 这种退化链接 + +7. 播放页 + - 必须命中:`guide-play` + - `canonical / og:url / JSON-LD url` 必须都指向播放页自己 + - 播放页内部切集链接不能退化成无 slug 形式 + +#### 最小命令清单 + +以下命令是后续 Codex 可直接复用的最小验收方式。 + +假设当前本地回放入口是: + +- `http://127.0.0.1:18081` +- Host 头是:`jpjdxs.com` + +1. 首页 + +```bash +curl -s -H 'Host: jpjdxs.com' 'http://127.0.0.1:18081/' | rg -n 'guide-collection|首页导览||<meta name="keywords"|<meta name="description"' +``` + +2. 榜单首页 + +```bash +curl -s -H 'Host: jpjdxs.com' 'http://127.0.0.1:18081/phb-index' | rg -n 'guide-collection|<title>|排行榜|榜单' +``` + +3. 一级分类页 + +```bash +curl -s -H 'Host: jpjdxs.com' 'http://127.0.0.1:18081/videotype/1-dian-ying' | rg -n 'guide-collection|<title>|<meta name="keywords"|<meta name="description"' +``` + +4. 二级分类页 + +```bash +curl -s -H 'Host: jpjdxs.com' 'http://127.0.0.1:18081/videotype/dian-ying/shao-shi-dian-ying-1' | rg -n 'guide-collection|<title>|<meta name="keywords"|<meta name="description"' +``` + +5. 搜索结果页 + +```bash +curl -s -H 'Host: jpjdxs.com' 'http://127.0.0.1:18081/get-index?keyword=test' | rg -n 'guide-collection|canonical|og:url|<title>' +``` + +6. 详情页 + +```bash +curl -s -H 'Host: jpjdxs.com' 'http://127.0.0.1:18081/neirong-76359-san-fen-zhi-yi-qing-ren' | rg -n 'guide-detail|canonical|og:url|/bf-76359-san-fen-zhi-yi-qing-ren/douban-1|/bf-76359-/' +``` + +7. 播放页 + +```bash +curl -s -H 'Host: jpjdxs.com' 'http://127.0.0.1:18081/bf-76359-san-fen-zhi-yi-qing-ren/douban-1' | rg -n 'guide-play|canonical|og:url|https://jpjdxs.com/bf-76359-san-fen-zhi-yi-qing-ren/douban-1' +``` + +#### 通过 / 不通过的最小结论模板 + +后续 Codex 回报时,尽量只按这个格式,不要写散: + +1. 首页:通过 / 不通过 +2. 榜单首页:通过 / 不通过 +3. 一级分类页:通过 / 不通过 +4. 二级分类页:通过 / 不通过 +5. 搜索结果页:通过 / 不通过 +6. 详情页:通过 / 不通过 +7. 播放页:通过 / 不通过 +8. 当前唯一阻塞点: + +#### 如果搜索页再次异常,优先先查什么 + +搜索页这条容易和其它主链问题混在一起,后续若异常,先按这个顺序看: + +1. `getSearchVideo.html` 是否仍包含 `module/seo_copy/collection` +2. `canonical / og:url / CollectionPage.url` 是否一致 +3. 是否又误用了错误类名 + - 错误示例:`app\services\UrlBuilder` + - 正确类:`app\common\helper\UrlBuilder` +4. 如果是本地 PHP 内置服务回放异常,再区分: + - 是模板真报错 + - 还是仅本地回放对 query 参数存在边角波动 + ## 目的 这份文档用于让多个 Codex 在 `SEONexus` 新版与旧版本之间长期协作时,保持: @@ -2605,3 +2833,321 @@ php scripts/seo_copy_front_verify.php <host> play <videoId> <playType> <episode> 1. 深页资产缺失是真实存在的 2. 不是已经生成但前台没命中 + +## 2026-04-16 `jpjdxs.com` 第一批深页试点已落地 + +这段不是计划,是本地已经执行过的结果。 + +### 本次选择的最小样本 + +详情页: + +1. `76318` +2. `76342` +3. `76347` + +播放页: + +1. `76318-play-douban-1` +2. `76342-play-douban-1` +3. `76347-play-douban-1` + +### 已执行动作 + +1. 用 `seo_copy_domain_bootstrap.php` 分别为这 3 个样本生成 bundle +2. 从 bundle 中抽出真实 `detail/play` page-key JSON +3. 先在临时目录做了 `seo_copy_batch_import.php` 闭环验证 +4. 再把这 6 个深页 JSON 回灌到真实: + - `code/data/seo_copy_published/jpjdxs-com/detail/` + - `code/data/seo_copy_published/jpjdxs-com/play/` + +### 当前结果 + +当前 `jpjdxs-com` 已从原来的深页接近 `default-only`,变成: + +1. `detail` 目录: + - `default.json` + - `76318.json` + - `76342.json` + - `76347.json` +2. `play` 目录: + - `default.json` + - `76318-play-douban-1.json` + - `76342-play-douban-1.json` + - `76347-play-douban-1.json` + +### 审计结果 + +重新跑 `seo_copy_published_audit.php` 后已确认: + +1. `detail_default_only: no` +2. `play_default_only: no` +3. `detail non_default_count: 3` +4. `play non_default_count: 3` + +也就是说: + +1. 第一批深页资产链已经重新打通 +2. 当前不是停留在“理论上可恢复” +3. 而是 `jpjdxs.com` 已经具备最小规模的真实深页 page-key 资产 + +## 2026-04-16 再确认:当前仓库里的“生成能力”和“导入能力”边界 + +这段是给后续所有接手的 Codex 看的,避免再把问题判断错方向。 + +### 1. `SeoCopyAiProviderHelper` 这条通用链,只会发布固定槽位 + +已再次核对: + +- `code/app/common/helper/SeoCopyAiProviderHelper.php` +- `code/app/common/helper/SeoCopyGenerationHelper.php` + +当前通用发布链固定只处理: + +1. `home/index` +2. `category_index/index` +3. `category_list/default` +4. `search/landing` +5. `detail/default` +6. `play/default` + +也就是说: + +1. 它不会自动产出 `detail/<videoId>.json` +2. 它不会自动产出 `play/<videoId>-play-<line>-<episode>.json` +3. 所以某个 host 就算“通用 AI 发布成功”,深页也仍然可能是 `default-only` + +这一点非常重要,后面不要再把“通用发布成功”误判成“深页差异化一定已经恢复”。 + +### 2. 真正的深页差异化资产,来自 bootstrap 深页链 + +已再次核对: + +- `code/scripts/seo_copy_domain_bootstrap.php` + +这条链会直接生成真实 page-key: + +1. `detail/<videoId>.json` +2. `forge/<videoId>-forge-<n>.json` +3. `play/<videoId>-play-<line>-<episode>.json` + +因此: + +1. 你之前记得的那批 GPT 模板详情引导文、播放引导文、差异化块 +2. 本质上更接近 bootstrap 深页资产链的结果 +3. 不是后台那条“固定 6 槽位”通用发布链的自然产物 + +### 3. 系统并不是“不能导入深页文件”,而是“缺少深页源文件” + +已再次核对: + +- `code/scripts/seo_copy_batch_import.php` +- `code/app/common/helper/SeoCopyBatchImportHelper.php` + +当前导入器支持的目录结构就是: + +1. `<source-dir>/<host-dir>/<scene>/<page_key>.json` + +这意味着: + +1. 它支持导入任意 `detail/<videoId>.json` +2. 它支持导入任意 `play/<videoId>-play-<line>-<episode>.json` +3. 当前真正缺的不是导入能力 +4. 当前真正缺的是: + - 历史深页源文件 + - 或新生成出来的深页源文件 + +### 4. 以后再排查“引导文没了”,统一按这个顺序 + +1. 先看模板层是不是挂载还在 +2. 再看运行时是不是被 suppress / fallback 卡住 +3. 再看 `seo_copy_published/<host>/detail` 和 `play` 是否只剩 `default.json` +4. 再看 `storage/domain_bootstrap_bundles` / `storage/seo_copy_publish_logs` / `storage/seo_copy_release_runs` 里有没有历史深页成品 +5. 确认历史成品确实不存在后,才进入“小批量 bootstrap 深页重建” + +## 2026-04-16 深夜补充:不是“模板又没了”,而是 `runtime/home/temp` 权限污染 + +这次线上又出现了一次很容易误判的现象,必须单独记下来。 + +### 1. 现象 + +当时看到的是: + +1. 首页 `title/keywords/description` 正常 +2. 详情页大多正常 +3. 播放页有时正常,有时直接变成 ThinkPHP 错误页 +4. 从体感上很像: + - 之前做好的 GPT 模板引导文又没了 + - 页面又像被 git 覆盖回旧状态 + - SEO 头信息像“忽然空掉” + +### 2. 实际根因 + +实测抓到的不是模板逻辑丢失,而是: + +- `code/runtime/home/temp` 里混进了一批 `root:root` 的编译模板文件 +- 实际 PHP-FPM 进程是 `www` 用户 +- 所以当 ThinkPHP 需要重写这些模板缓存时,会报: + - `file_put_contents(...runtime/home/temp/...php): Failed to open stream: Permission denied` + +### 3. 为什么它会表现得像“优化被覆盖了” + +因为这个问题不是“所有页面一起死”,而是: + +1. 已经命中旧缓存的页面,可能还能正常显示 +2. 需要重新编译模板的页面,会直接报错 +3. 于是现场看起来就像: + - 一部分 GPT 引导文还在 + - 一部分页面像恢复成旧样子 + - 一部分页面干脆系统错误 + +所以这种现象不能第一时间认定成: + +1. git 又把模板覆盖了 +2. SEO key 又丢了 +3. 数据库把引导文清空了 + +### 4. 这次实际处理方式 + +已在正式排查中确认: + +1. `php-fpm` 运行用户是 `www` +2. `runtime/home/temp` 中存在多份 `root root` 文件 +3. 将 `runtime/home` 下属主纠正回 `www:www` 后 +4. 再复测: + - 详情页恢复正常 + - 播放页恢复正常 + - `guide-detail` / `guide-play` 正常输出 + - `canonical` / `og:url` / JSON-LD `url` 恢复到正确页面 + +### 5. 后续所有 Codex 统一排查顺序 + +以后再遇到“GPT 模板像突然失忆”时,统一先做这 4 步: + +1. 先抓线上实际 HTML,不要只凭浏览器体感判断 +2. 看是不是 ThinkPHP 错误页,尤其关注 `runtime/home/temp` 写入失败 +3. 检查 `runtime/home/temp` 是否混入 `root:root` +4. 只有确认运行时缓存正常后,才继续判断是不是模板 / SEO copy / git 历史问题 + +## 2026-04-17 凌晨补充:搜索页串页不是模板错,是前端缓存 key 漏了 query string + +这也是一个很容易把人带偏的问题,必须单列。 + +### 1. 实测现象 + +同一时间分别抓: + +1. `/get-index?keyword=爱情` +2. `/get-index?keyword=动作` + +结果两次返回: + +1. `x-cache-status: HIT` +2. HTML 里的 `title` +3. `canonical` +4. `og:url` + +全部都是“爱情”那一页的内容。 + +也就是说: + +1. 不同关键词请求 +2. 命中了同一份前端缓存 +3. 不是后端模板在实时生成对应 query 的页面 + +### 2. 结论 + +这说明当前前端缓存层对搜索页的 cache key 存在高概率配置问题: + +1. cache key 没有带上 query string +2. 或搜索页请求被错误归入“只按 URI 缓存”的 location + +它会直接造成: + +1. 搜索页串页 +2. `canonical` / `og:url` / `title` 与真实 query 不一致 +3. SEO 表现看起来像“模板或 SEO 数据随机失效” + +### 3. 这类问题的判定规则 + +以后凡是看到: + +1. 搜索页关键词 A 打开后像关键词 B +2. `canonical` 看着像随机错乱 +3. 源码明明已经修对,但线上输出不稳定 + +优先先看: + +1. 响应头里的 `x-cache-status` +2. 前端缓存 key 是否包含 query string + +不要第一时间误判为: + +1. 模板回滚 +2. seo_copy 丢失 +3. 数据库内容被覆盖 + +### 4. 当前前端 nginx 模板里的直接根因 + +已对照当前仓库内的前端配置模板: + +- [站点伪静态.txt](/www/wwwroot/diff-maccms/前端站群服务器nginx配置/站点伪静态.txt) + +问题点在于动态页缓存 key 原来是: + +```nginx +proxy_cache_key "$scheme$request_method$host$uri"; +``` + +这意味着: + +1. `/get-index?keyword=爱情` +2. `/get-index?keyword=动作` + +会共用同一个缓存 key,因为它们的 `uri` 都只是 `/get-index`。 + +### 5. 最小修复方案 + +动态页缓存 key 至少改成: + +```nginx +proxy_cache_key "$scheme$request_method$host$uri$is_args$args"; +``` + +这样: + +1. 不同 query string 会拆分缓存 +2. 搜索页不会再互相串页 +3. `canonical` / `og:url` / `title` 不会再被别的关键词页面污染 + +### 6. 推荐同步修复范围 + +不要只改搜索页单点 location。 + +当前建议直接同步到: + +1. `location /` +2. `location ~* \.(html|htm)$` + +原因是: + +1. 搜索页当前命中的是 `location /` +2. 但以后如果某些动态页走 `.html` 风格并带 query,也会遇到同类问题 + +### 7. 上线后复测方法 + +改完 nginx 并清缓存后,用两组不同关键词直接抓响应头和 HTML: + +```bash +curl -s -D - 'https://你的域名/get-index?keyword=爱情' -o /tmp/a.html +curl -s -D - 'https://你的域名/get-index?keyword=动作' -o /tmp/b.html +rg '<title>|canonical|og:url' /tmp/a.html +rg '<title>|canonical|og:url' /tmp/b.html +``` + +通过标准: + +1. 两个页面的 `title` 不同 +2. `canonical` 分别指向各自关键词 +3. `og:url` 分别指向各自关键词 +4. 不再出现“动作词页返回爱情 HTML”的现象