docs(seo): record recovery findings and add seo copy tools
This commit is contained in:
2
code/.gitignore
vendored
2
code/.gitignore
vendored
@@ -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/*
|
||||
|
||||
293
code/scripts/seo_copy_published_audit.php
Normal file
293
code/scripts/seo_copy_published_audit.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/app/common/helper/SeoCopyStore.php';
|
||||
require dirname(__DIR__) . '/app/common/helper/SeoCopySchema.php';
|
||||
|
||||
use app\common\helper\SeoCopySchema;
|
||||
|
||||
function printUsage(): void
|
||||
{
|
||||
echo "Usage:\n";
|
||||
echo " php scripts/seo_copy_published_audit.php [--root=/abs/path] [--host=host-dir] [--format=json|text] [--sample-detail=<videoId>] [--sample-play=<videoId>:<playType>:<episode>]\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;
|
||||
@@ -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)) {
|
||||
|
||||
184
code/scripts/seo_copy_restore_from_publish_logs.php
Normal file
184
code/scripts/seo_copy_restore_from_publish_logs.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\helper\SeoCopyStore;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
if (!function_exists('base_path')) {
|
||||
function base_path(string $path = ''): string
|
||||
{
|
||||
$base = dirname(__DIR__);
|
||||
return $path !== '' ? $base . '/' . ltrim($path, '/') : $base;
|
||||
}
|
||||
}
|
||||
|
||||
function usage(): void
|
||||
{
|
||||
$script = basename(__FILE__);
|
||||
echo <<<TXT
|
||||
Usage:
|
||||
php code/scripts/{$script} --host=chuanjiafeng-net [--source=/abs/log/root] [--target=/abs/target/root] [--dry-run]
|
||||
|
||||
Options:
|
||||
--host Host directory name under seo_copy, e.g. chuanjiafeng-net
|
||||
--source Publish log root. Defaults to code/storage/seo_copy_publish_logs
|
||||
--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 --dry-run
|
||||
php code/scripts/{$script} --host=liangzuan-net
|
||||
|
||||
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'] ?? base_path('storage/seo_copy_publish_logs')));
|
||||
$strTarget = trim((string)($arrArgs['target'] ?? base_path('data/seo_copy_published')));
|
||||
$boolDryRun = !empty($arrArgs['dry-run']);
|
||||
|
||||
if ($strHost === '') {
|
||||
usage();
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$strSource = resolve_path($strSource);
|
||||
$strTarget = resolve_path($strTarget);
|
||||
|
||||
if (!is_dir($strSource)) {
|
||||
fwrite(STDERR, "publish log root not found: {$strSource}\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$arrScenes = ['home', 'category_index', 'category_list', 'search', 'rank_index', 'rank_list', 'detail', 'forge', 'play'];
|
||||
$arrFilesByScene = [];
|
||||
$arrMatchedPaths = [];
|
||||
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($strSource, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($it as $file) {
|
||||
if (!$file->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";
|
||||
}
|
||||
}
|
||||
152
code/scripts/seo_copy_restore_from_source.php
Normal file
152
code/scripts/seo_copy_restore_from_source.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\helper\SeoCopyStore;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
if (!function_exists('base_path')) {
|
||||
function base_path(string $path = ''): string
|
||||
{
|
||||
$base = dirname(__DIR__);
|
||||
return $path !== '' ? $base . '/' . ltrim($path, '/') : $base;
|
||||
}
|
||||
}
|
||||
|
||||
function usage(): void
|
||||
{
|
||||
$script = basename(__FILE__);
|
||||
echo <<<TXT
|
||||
Usage:
|
||||
php code/scripts/{$script} --host=chuanjiafeng-net --source=/abs/source/path [--target=/abs/target/path] [--dry-run]
|
||||
|
||||
Options:
|
||||
--host Host directory name under seo_copy, e.g. chuanjiafeng-net
|
||||
--source Source root that contains <host>/detail/*.json and <host>/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";
|
||||
}
|
||||
}
|
||||
@@ -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|首页导览|<title>|<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”的现象
|
||||
|
||||
Reference in New Issue
Block a user