docs(seo): record recovery findings and add seo copy tools

This commit is contained in:
root
2026-04-17 13:02:13 +08:00
parent 56644e7f50
commit 0c534d2903
6 changed files with 1178 additions and 0 deletions

2
code/.gitignore vendored
View File

@@ -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/*

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

View File

@@ -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)) {

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

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