Compare commits

...

2 Commits

Author SHA1 Message Date
root
7fff1a9944 restore admin helper chain and stabilize seo routes 2026-04-15 18:51:27 +08:00
root
bd073c6795 fix id-first deep links for videoGpt1 seo 2026-04-15 16:06:43 +08:00
25 changed files with 2289 additions and 38 deletions

View File

@@ -157,9 +157,11 @@ Route::group("/site", function () {
Route::get("/bootstrap/workbench/summary", [Site::class, "getBootstrapWorkbenchSummary"])->name("Site@getBootstrapWorkbenchSummary");
Route::post("/bootstrap/env/template/preview", [Site::class, "previewBootstrapEnvTemplate"])->name("Site@previewBootstrapEnvTemplate");
Route::post("/bootstrap/env/template/apply", [Site::class, "applyBootstrapEnvTemplate"])->name("Site@applyBootstrapEnvTemplate");
Route::post("/bootstrap/env/template/history/restore/preview", [Site::class, "previewBootstrapEnvHistoryRestore"])->name("Site@previewBootstrapEnvHistoryRestore");
Route::post("/bootstrap/env/template/history/restore/apply", [Site::class, "applyBootstrapEnvHistoryRestore"])->name("Site@applyBootstrapEnvHistoryRestore");
# TKD 参数模板
Route::get("/tkdarg/list", [Site::class, "getTKDArgList"])->name("Site@getTKDArgList");
Route::get("/tkdarg/list", [Site::class, "getTKDArgList"])->name("Site@getTKDArgList");
# 视图模板
Route::get("/template/list", [Site::class, "getTemplateList"])->name("Site@getTKDArgList");

View File

@@ -7,11 +7,14 @@ namespace app\admin\controller;
use app\admin\BaseController;
use app\common\helper\DomainBatchImportTemplateHelper;
use app\common\helper\DomainImportProbeRunHelper;
use app\common\helper\DomainImportAsyncJobHelper;
use app\common\helper\DomainImportFailedQueueHelper;
use app\common\helper\DomainSpiderMdRunHelper;
use app\common\helper\DomainImportHealthTrendHelper;
use app\common\helper\DomainImportRunIndexHelper;
use app\common\helper\DomainImportHealthWorkbenchHelper;
use app\common\helper\DomainImportHealthWorkbenchIndexHelper;
use app\common\helper\DomainImportHealthWorkbenchTrendHelper;
use app\common\helper\DomainImportExternalSeoClosureHelper;
use app\common\helper\DomainImportExternalSeoClosureActionHelper;
use app\common\helper\DomainImportExternalSeoClosureActionIndexHelper;
@@ -22,6 +25,8 @@ use app\common\helper\DomainImportManualAttentionHandleIndexHelper;
use app\common\helper\DomainImportSelfHealingIndexHelper;
use app\common\helper\DomainImportRemediationIndexHelper;
use app\common\helper\DomainImportRerunIndexHelper;
use app\common\helper\DomainImportRerunRunHelper;
use app\common\helper\DomainAutoSampleHelper;
use app\common\helper\DomainExternalSeoSummaryHelper;
use app\common\helper\DomainExternalSeoFailedQueueHelper;
use app\common\helper\DomainExternalSeoTrendHelper;
@@ -59,6 +64,7 @@ use app\common\helper\DomainSupplyRunIndexHelper;
use app\common\helper\DomainSupplyBatchTemplateHelper;
use app\common\helper\DomainBootstrapEnvTemplateHelper;
use app\common\helper\DomainSpiderCrawlWorkbenchHelper;
use app\common\helper\DomainTrajectoryProbeHelper;
use app\common\helper\SeoCopyAiProviderHelper;
use app\common\helper\SeoCopyGenerationHelper;
use app\common\helper\SeoCopyPortalHomeHelper;
@@ -67,8 +73,9 @@ use app\common\helper\SeoCopyStore;
use app\common\helper\SpiderMdConfigHelper;
use app\model\AdminUserModel;
use app\model\ConverterMovel;
use app\model\DomainModel;
use app\model\SubjectFomartGroupModel;
use app\model\DomainModel;
use app\model\PlanTaskModel;
use app\model\SubjectFomartGroupModel;
use app\model\SubjectFomartModel;
use app\model\TemplatesModel;
use app\Request;

View File

@@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
use app\model\VideoModel;
use app\model\VideoCategoryModel;
use think\App;
class DomainAutoSampleHelper
{
protected static bool $boolInitialized = false;
protected static function ensureAppInitialized(): void
{
if (self::$boolInitialized) {
return;
}
(new App())->initialize();
self::$boolInitialized = true;
}
public static function discover(string $strHost, array $arrOptions = []): array
{
self::ensureAppInitialized();
$strHost = DomainModel::normalizeHost($strHost);
if ($strHost === '') {
return [
'host' => '',
'status' => 'invalid_host',
'message' => 'Host empty.',
'sample_source' => 'video_auto_discovery',
'checked_candidates' => 0,
'sample' => null,
];
}
$intLimit = max(5, min(200, (int)($arrOptions['limit'] ?? 80)));
$intPreferredVideoId = max(0, (int)($arrOptions['preferred_video_id'] ?? 0));
$DomainRow = self::findDomainRow($strHost);
$arrCandidates = self::fetchCandidateVideos($intLimit, $intPreferredVideoId);
$intChecked = 0;
foreach ($arrCandidates as $arrVideo) {
$intChecked++;
$arrSample = self::buildSampleFromVideo($strHost, $DomainRow, $arrVideo);
if ($arrSample === null) {
continue;
}
return [
'host' => $strHost,
'status' => 'passed',
'message' => 'Auto sample discovered from video library.',
'sample_source' => 'video_auto_discovery',
'checked_candidates' => $intChecked,
'sample' => $arrSample,
];
}
return [
'host' => $strHost,
'status' => 'failed_no_candidate',
'message' => 'No valid default sample could be discovered from video library.',
'sample_source' => 'video_auto_discovery',
'checked_candidates' => $intChecked,
'sample' => null,
];
}
protected static function findDomainRow(string $strHost): ?DomainModel
{
$strExactDomain = DomainModel::normalizeStoredDomain($strHost, DomainModel::MATCH_TYPE_EXACT);
$DomainRow = app(DomainModel::class)->where('d_domain', $strExactDomain)->find();
return $DomainRow instanceof DomainModel ? $DomainRow : null;
}
protected static function fetchCandidateVideos(int $intLimit, int $intPreferredVideoId = 0): array
{
$VideoCollection = VideoModel::getInstance()->getCol();
$arrProjection = [
'_id' => 0,
'v_id' => 1,
'v_name' => 1,
'v_name_en' => 1,
'v_seo_words' => 1,
'v_play_url' => 1,
'v_parent_category' => 1,
'v_parent_category_en' => 1,
'v_category' => 1,
'v_category_en' => 1,
];
$arrCandidates = [];
if ($intPreferredVideoId > 0) {
$arrPreferred = $VideoCollection->findOne(['v_id' => $intPreferredVideoId], [
'typeMap' => [
'root' => 'array',
'document' => 'array',
'array' => 'array',
],
'projection' => $arrProjection,
]);
if (is_array($arrPreferred) && !empty($arrPreferred)) {
$arrCandidates[] = $arrPreferred;
}
}
$Cursor = $VideoCollection->find([], [
'typeMap' => [
'root' => 'array',
'document' => 'array',
'array' => 'array',
],
'projection' => $arrProjection,
'sort' => ['v_id' => -1],
'limit' => $intLimit,
]);
foreach (iterator_to_array($Cursor) as $arrVideo) {
if (!is_array($arrVideo)) {
continue;
}
if ($intPreferredVideoId > 0 && (int)($arrVideo['v_id'] ?? 0) === $intPreferredVideoId) {
continue;
}
$arrCandidates[] = $arrVideo;
}
return $arrCandidates;
}
protected static function buildSampleFromVideo(string $strHost, ?DomainModel $DomainRow, array $arrVideo): ?array
{
$intVideoId = (int)($arrVideo['v_id'] ?? 0);
$strVideoName = trim((string)($arrVideo['v_name'] ?? ''));
$strVideoSlug = trim((string)($arrVideo['v_name_en'] ?? ''));
if ($intVideoId <= 0 || $strVideoName === '' || $strVideoSlug === '') {
return null;
}
$arrPlay = self::resolvePlaySample($arrVideo);
if ($arrPlay === null) {
return null;
}
$arrCategory = self::resolveCategorySample($arrVideo);
if ($arrCategory === null) {
return null;
}
$strKeyword = self::resolveSearchKeyword($DomainRow, $arrVideo);
if ($strKeyword === '') {
return null;
}
$arrResult = [
'host' => $strHost,
'video' => [
'v_id' => $intVideoId,
'v_name' => $strVideoName,
'v_name_en' => $strVideoSlug,
],
'detail' => [
'detail_id' => $intVideoId,
'detail_slug' => $strVideoSlug,
],
'search' => [
'search_keyword' => $strKeyword,
],
'category' => $arrCategory,
'play' => $arrPlay,
'runtime_acceptance' => [
'samples' => [
[
'video_id' => $intVideoId,
'forge_id' => 1,
],
],
],
];
$arrUrls = self::buildUrls($strHost, $DomainRow, $arrResult);
if (!empty($arrUrls)) {
$arrResult['urls'] = $arrUrls;
}
return $arrResult;
}
protected static function resolvePlaySample(array $arrVideo): ?array
{
$arrPlayGroups = is_array($arrVideo['v_play_url'] ?? null) ? (array)($arrVideo['v_play_url'] ?? []) : [];
foreach ($arrPlayGroups as $strPlayType => $arrEpisodes) {
$strPlayType = trim((string)$strPlayType);
if ($strPlayType === '' || !is_array($arrEpisodes) || empty($arrEpisodes)) {
continue;
}
$arrEpisode = (array)($arrEpisodes[0] ?? []);
$strEpisodeName = trim((string)($arrEpisode['name'] ?? ''));
if ($strEpisodeName === '') {
$strEpisodeName = '第1集';
}
return [
'play_type' => $strPlayType,
'play_index' => 1,
'episode_name' => $strEpisodeName,
];
}
return null;
}
protected static function resolveCategorySample(array $arrVideo): ?array
{
$strParentSlug = trim((string)($arrVideo['v_parent_category_en'] ?? ''));
$strCategorySlug = trim((string)($arrVideo['v_category_en'] ?? ''));
$strParentName = trim((string)($arrVideo['v_parent_category'] ?? ''));
$strCategoryName = trim((string)($arrVideo['v_category'] ?? ''));
if ($strParentSlug === '' && $strParentName !== '') {
$strParentSlug = self::findCategorySlugByName($strParentName);
}
if ($strCategorySlug === '' && $strCategoryName !== '') {
$strCategorySlug = self::findCategorySlugByName($strCategoryName);
}
if ($strParentSlug === '' || $strCategorySlug === '') {
return null;
}
return [
'category_parent' => $strParentSlug,
'category_child' => $strCategorySlug,
];
}
protected static function resolveSearchKeyword(?DomainModel $DomainRow, array $arrVideo): string
{
$arrKeywordCandidates = [];
$strDomainKeyword = trim((string)($DomainRow?->d_name ?? ''));
if ($strDomainKeyword !== '') {
$arrKeywordCandidates[] = $strDomainKeyword;
}
$arrSeoWordValues = self::normalizeKeywordSource($arrVideo['v_seo_words'] ?? '');
foreach ($arrSeoWordValues as $strSeoWords) {
foreach (preg_split('/[,\x{3001}\x{ff0c}\s]+/u', $strSeoWords) as $strWord) {
$strWord = trim((string)$strWord);
if ($strWord !== '') {
$arrKeywordCandidates[] = $strWord;
}
}
}
$strVideoName = trim((string)($arrVideo['v_name'] ?? ''));
if ($strVideoName !== '') {
$arrKeywordCandidates[] = $strVideoName;
}
foreach ($arrKeywordCandidates as $strKeyword) {
$strKeyword = trim($strKeyword);
if ($strKeyword !== '') {
return mb_substr($strKeyword, 0, 20);
}
}
return '';
}
protected static function normalizeKeywordSource(mixed $value): array
{
if (is_string($value) || is_numeric($value)) {
$strValue = trim((string)$value);
return $strValue === '' ? [] : [$strValue];
}
if (!is_array($value)) {
return [];
}
$arrValues = [];
array_walk_recursive($value, static function ($item) use (&$arrValues): void {
if (is_string($item) || is_numeric($item)) {
$strItem = trim((string)$item);
if ($strItem !== '') {
$arrValues[] = $strItem;
}
}
});
return $arrValues;
}
protected static function buildUrls(string $strHost, ?DomainModel $DomainRow, array $arrSample): array
{
if (!$DomainRow instanceof DomainModel) {
return [];
}
$TpStyle = SiteStyle::getConfig($DomainRow, $strHost);
$UrlBuilder = new UrlBuilder($TpStyle);
$strSlug = (string)($arrSample['detail']['detail_slug'] ?? '');
$intVideoId = (int)($arrSample['detail']['detail_id'] ?? 0);
$strSearchKeyword = (string)($arrSample['search']['search_keyword'] ?? '');
$strCategoryParent = (string)($arrSample['category']['category_parent'] ?? '');
$strCategoryChild = (string)($arrSample['category']['category_child'] ?? '');
$strPlayType = (string)($arrSample['play']['play_type'] ?? '');
$intPlayIndex = max(1, (int)($arrSample['play']['play_index'] ?? 1));
$arrUrls = [
'home' => $UrlBuilder->home(),
];
if ($strSearchKeyword !== '') {
$arrUrls['search'] = $UrlBuilder->searchResult($strSearchKeyword);
}
if ($strCategoryParent !== '' && $strCategoryChild !== '') {
$arrUrls['category'] = $UrlBuilder->categoryChild($strCategoryParent, $strCategoryChild, 1);
}
if ($strSlug !== '' && $intVideoId > 0) {
$arrUrls['detail'] = $UrlBuilder->detail($strSlug, $intVideoId);
}
if ($strSlug !== '' && $intVideoId > 0 && $strPlayType !== '') {
$arrUrls['play'] = $UrlBuilder->play($strSlug, $intVideoId, $strPlayType, $intPlayIndex);
}
return $arrUrls;
}
protected static function findCategorySlugByName(string $strName): string
{
$strName = trim($strName);
if ($strName === '') {
return '';
}
$arrCategory = app(VideoCategoryModel::class)
->where('vc_name', $strName)
->field(['vc_name_en'])
->find();
return trim((string)($arrCategory['vc_name_en'] ?? ''));
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportAsyncJobHelper
{
public const STATUS_QUEUED = 'queued';
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
public static function createJob(string $type, array $payload = [], array $meta = []): array
{
$baseRoot = self::baseRoot();
self::ensureDir($baseRoot);
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$jobId = date('His') . '_' . trim($type, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$jobRoot = $dateDir . '/' . $jobId;
self::ensureDir($jobRoot);
$job = [
'job_id' => $jobId,
'type' => $type,
'label' => self::labelForType($type),
'status' => self::STATUS_QUEUED,
'progress_percent' => 0,
'progress_current' => 0,
'progress_total' => 0,
'current_step' => 'queued',
'message' => '任务已入队,等待执行',
'payload' => $payload,
'meta' => $meta,
'summary' => [],
'created_at' => date(DATE_ATOM),
'updated_at' => date(DATE_ATOM),
'started_at' => '',
'finished_at' => '',
'job_root' => $jobRoot,
'state_path' => self::statePath($jobRoot),
'log_path' => self::logPath($jobRoot),
];
file_put_contents($job['state_path'], json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($job['log_path'], '[' . date('Y-m-d H:i:s') . "] 已入队 {$job['label']}" . PHP_EOL);
return $job;
}
public static function readJob(string $jobId): array
{
$jobRoot = self::findJobRoot($jobId);
if ($jobRoot === '') {
return [];
}
$statePath = self::statePath($jobRoot);
if (!is_file($statePath)) {
return [];
}
$job = json_decode((string)file_get_contents($statePath), true);
return is_array($job) ? $job : [];
}
public static function updateJob(string $jobId, array $patch): array
{
$job = self::readJob($jobId);
if (empty($job)) {
throw new \RuntimeException('Job not found: ' . $jobId);
}
$job = array_merge($job, $patch);
$job['updated_at'] = date(DATE_ATOM);
if (($patch['status'] ?? '') === self::STATUS_RUNNING && empty($job['started_at'])) {
$job['started_at'] = date(DATE_ATOM);
}
if (in_array((string)($patch['status'] ?? ''), [self::STATUS_SUCCESS, self::STATUS_FAILED], true)) {
$job['finished_at'] = date(DATE_ATOM);
$job['progress_percent'] = 100;
}
file_put_contents((string)$job['state_path'], json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $job;
}
public static function appendLog(string $jobId, string $message): void
{
$job = self::readJob($jobId);
if (empty($job)) {
return;
}
$line = '[' . date('Y-m-d H:i:s') . '] ' . trim($message) . PHP_EOL;
file_put_contents((string)$job['log_path'], $line, FILE_APPEND);
self::updateJob($jobId, ['last_log' => trim($message)]);
}
public static function updateProgress(string $jobId, int $current, int $total, string $step = '', string $message = ''): array
{
$percent = $total > 0 ? (int)floor(($current / $total) * 100) : 0;
return self::updateJob($jobId, [
'progress_current' => $current,
'progress_total' => $total,
'progress_percent' => max(0, min(100, $percent)),
'current_step' => $step !== '' ? $step : 'running',
'message' => $message !== '' ? $message : (($step !== '' ? $step : 'running') . " {$current}/{$total}"),
]);
}
public static function readLogTail(string $jobId, int $lines = 120): array
{
$job = self::readJob($jobId);
if (empty($job) || empty($job['log_path']) || !is_file((string)$job['log_path'])) {
return [];
}
$content = trim((string)file_get_contents((string)$job['log_path']));
if ($content === '') {
return [];
}
$allLines = preg_split('/\r\n|\r|\n/', $content);
return array_values(array_slice(is_array($allLines) ? $allLines : [], -1 * max(1, $lines)));
}
public static function labelForType(string $type): string
{
$map = [
'failed_queue_rerun' => '失败队列重跑',
'failure_remediation' => '失败补料候选',
'self_healing' => '自动修复',
'health_workbench_refresh' => '健康台落盘',
'spider_md_generate' => '蜘蛛池MD生成',
'seo_copy_ai_generate' => 'AI文案生成',
'seo_copy_ai_optimize' => 'AI文案重优化',
'seo_copy_ai_rollback' => 'AI文案回滚',
];
return (string)($map[$type] ?? $type);
}
public static function baseRoot(): string
{
return dirname(__DIR__, 3) . '/storage/domain_import_async_jobs';
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
protected static function findJobRoot(string $jobId): string
{
$jobId = trim($jobId);
if ($jobId === '') {
return '';
}
$matches = glob(self::baseRoot() . '/*/' . $jobId);
if (!is_array($matches) || empty($matches)) {
return '';
}
return (string)$matches[0];
}
protected static function statePath(string $jobRoot): string
{
return rtrim($jobRoot, '/') . '/job.state.json';
}
protected static function logPath(string $jobRoot): string
{
return rtrim($jobRoot, '/') . '/job.log';
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportFailureRemediationHelper
{
public static function buildPlan(array $arrQueueItem): array
{
$strHost = trim((string)($arrQueueItem['host'] ?? ''));
$strFailedStage = trim((string)($arrQueueItem['failed_stage'] ?? ''));
$arrFailedStages = array_values(array_filter((array)($arrQueueItem['failed_stages'] ?? []), 'is_string'));
$strPrimaryStage = $strFailedStage !== '' ? $strFailedStage : (string)($arrFailedStages[0] ?? '');
$arrPlan = [
'status' => 'planned',
'kind' => 'reprobe',
'title' => '重新探测',
'summary' => '当前失败先按自动样本发现 + 页面弹道重探处理。',
'recommended_command' => self::buildProbeCommand($strHost),
'sample_command' => self::buildSampleCommand($strHost),
'probe_command' => self::buildProbeCommand($strHost),
'pipeline_command' => self::buildPipelineCommand($strHost, $strPrimaryStage),
'post_action' => '执行补料候选后,再重新跑页面弹道自检。',
'priority' => 'medium',
];
if ($strHost === '') {
$arrPlan['status'] = 'invalid';
$arrPlan['summary'] = 'host 缺失,暂时无法生成补料候选链。';
$arrPlan['recommended_command'] = '';
return $arrPlan;
}
if (in_array($strPrimaryStage, ['sample', 'domain', 'host'], true)) {
$arrPlan['kind'] = 'sample_rebuild';
$arrPlan['title'] = '重建样本并重探';
$arrPlan['summary'] = '当前优先重建自动样本,再重新跑首页/搜索/详情/播放弹道。';
$arrPlan['recommended_command'] = self::buildSampleCommand($strHost);
$arrPlan['post_action'] = '样本重建后继续执行页面弹道自检。';
$arrPlan['priority'] = 'high';
return $arrPlan;
}
if (in_array($strPrimaryStage, ['search', 'detail', 'play', 'home', 'category'], true)) {
$arrPlan['kind'] = 'pipeline_candidate';
$arrPlan['title'] = '补料候选链';
$arrPlan['summary'] = '当前页面链已能定位到失败阶段,优先走 bootstrap/pipeline dry-run 候选链,再回到弹道自检。';
$arrPlan['recommended_command'] = self::buildPipelineCommand($strHost, $strPrimaryStage);
$arrPlan['priority'] = in_array($strPrimaryStage, ['detail', 'play'], true) ? 'high' : 'medium';
return $arrPlan;
}
return $arrPlan;
}
protected static function buildSampleCommand(string $strHost): string
{
return 'php scripts/domain_auto_sample_discover.php ' . escapeshellarg($strHost) . ' --format=text';
}
protected static function buildProbeCommand(string $strHost): string
{
return 'php scripts/domain_trajectory_probe.php ' . escapeshellarg($strHost) . ' --format=text';
}
protected static function buildPipelineCommand(string $strHost, string $strFailedStage): string
{
$arrArgs = [
'php scripts/domain_bootstrap_pipeline_run.php',
'--host=' . escapeshellarg($strHost),
'--dry-run=1',
'--step-limit=3',
'--allow-prepare=1',
'--format=text',
];
if (in_array($strFailedStage, ['detail', 'play'], true)) {
$arrArgs[] = '--allow-release-dry-run=1';
}
return implode(' ', $arrArgs);
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportHealthWorkbenchIndexHelper
{
protected static function publicRelativePath(string $path): string
{
$publicRoot = str_replace('\\', '/', rtrim(dirname(__DIR__, 3) . '/public', '/'));
$path = str_replace('\\', '/', $path);
if (str_starts_with($path, $publicRoot . '/')) {
return substr($path, strlen($publicRoot . '/'));
}
return $path;
}
public static function buildSummary(string $runRoot, int $limit = 20): array
{
$runRoot = rtrim($runRoot, '/');
if ($runRoot === '' || !is_dir($runRoot)) {
return [
'items' => [],
'total' => 0,
'latest_run' => [],
];
}
$summaryFiles = array_merge(
(array)glob($runRoot . '/*/import-health-workbench.summary.json'),
(array)glob($runRoot . '/*/*/import-health-workbench.summary.json')
);
$summaryFiles = array_values(array_filter(array_unique($summaryFiles), 'is_file'));
usort($summaryFiles, static function (string $left, string $right): int {
return ((int)(filemtime($right) ?: 0)) <=> ((int)(filemtime($left) ?: 0));
});
$items = [];
foreach (array_slice($summaryFiles, 0, max(1, $limit)) as $summaryPath) {
$data = json_decode((string)file_get_contents($summaryPath), true);
if (!is_array($data)) {
continue;
}
$runDir = dirname($summaryPath);
$healthLabel = (string)($data['health_label'] ?? '');
$workbenchStage = (string)($data['workbench_stage'] ?? '');
$activeCount = (int)($data['active_count'] ?? 0);
$resolvedCount = (int)($data['resolved_count'] ?? 0);
[$alertLevel, $alertReason] = self::resolveAlert($healthLabel, $workbenchStage, $activeCount);
$items[] = [
'run_id' => basename($runDir),
'run_root_public' => self::publicRelativePath($runDir),
'health_label' => $healthLabel,
'workbench_stage' => $workbenchStage,
'hero_summary' => (string)($data['hero_summary'] ?? ''),
'active_count' => $activeCount,
'queue_count' => (int)($data['queue_count'] ?? 0),
'resolved_count' => $resolvedCount,
'alert_level' => $alertLevel,
'alert_reason' => $alertReason,
'summary_json_path' => self::publicRelativePath($summaryPath),
'summary_html_path' => self::publicRelativePath($runDir . '/import-health-workbench.summary.html'),
'updated_at' => date(DATE_ATOM, (int)(filemtime($summaryPath) ?: time())),
];
}
$alertBuckets = [
'high' => 0,
'medium' => 0,
'low' => 0,
'none' => 0,
];
foreach ($items as $item) {
$level = (string)($item['alert_level'] ?? 'none');
if (!isset($alertBuckets[$level])) {
$alertBuckets[$level] = 0;
}
$alertBuckets[$level]++;
}
$topAttentionRuns = array_values(array_filter($items, static function (array $item): bool {
return in_array((string)($item['alert_level'] ?? 'none'), ['high', 'medium'], true);
}));
usort($topAttentionRuns, static function (array $left, array $right): int {
$priority = ['high' => 3, 'medium' => 2, 'low' => 1, 'none' => 0];
return ($priority[(string)($right['alert_level'] ?? 'none')] ?? 0) <=> ($priority[(string)($left['alert_level'] ?? 'none')] ?? 0);
});
return [
'items' => $items,
'total' => count($items),
'latest_run' => $items[0] ?? [],
'alert_buckets' => $alertBuckets,
'top_attention_runs' => array_slice($topAttentionRuns, 0, 5),
];
}
protected static function resolveAlert(string $healthLabel, string $workbenchStage, int $activeCount): array
{
if ($activeCount > 0 && $healthLabel === 'worsening') {
return ['high', '当前失败仍存在且趋势上升'];
}
if ($activeCount > 0 && in_array($workbenchStage, ['needs_attention', 'needs_self_healing'], true)) {
return ['medium', '当前失败仍需要自动修复或重点关注'];
}
if ($activeCount === 0 && $workbenchStage === 'observe_recovery') {
return ['low', '当前失败已清空,继续观察恢复稳定性'];
}
return ['none', '当前导入主线平稳'];
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportHealthWorkbenchRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'health_workbench'): string
{
$baseRoot = rtrim($baseRoot, '/');
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$runId = date('His') . '_' . trim($prefix, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
self::ensureDir($runRoot);
return $runRoot;
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
public static function persist(array $summary, string $runRoot, array $meta = []): array
{
self::ensureDir($runRoot);
$summary['meta'] = array_merge((array)($summary['meta'] ?? []), $meta);
$summary['generated_at'] = (string)($summary['generated_at'] ?? date(DATE_ATOM));
$summaryJsonPath = $runRoot . '/import-health-workbench.summary.json';
$summaryHtmlPath = $runRoot . '/import-health-workbench.summary.html';
file_put_contents($summaryJsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($summaryHtmlPath, self::renderHtml($summary));
$summary['summary_json_path'] = $summaryJsonPath;
$summary['summary_html_path'] = $summaryHtmlPath;
file_put_contents($summaryJsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $summary;
}
protected static function renderHtml(array $summary): string
{
$priorityActions = (array)($summary['priority_actions'] ?? []);
$topFailedStages = (array)($summary['top_failed_stages'] ?? []);
$recoveryMix = (array)($summary['recovery_mix'] ?? []);
$renderList = static function (array $items, callable $formatter): string {
if (!$items) {
return '<li>-</li>';
}
$html = '';
foreach ($items as $item) {
$html .= '<li>' . $formatter($item) . '</li>';
}
return $html;
};
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>导入健康台摘要</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}h1{margin-bottom:8px;}ul{margin-top:8px;}code{background:#f3f3f3;padding:2px 4px;}table{border-collapse:collapse;width:100%;margin-top:16px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;}th{background:#f6f6f6;}</style>'
. '</head><body>'
. '<h1>导入健康台摘要</h1>'
. '<p>生成时间:<code>' . htmlspecialchars(DomainImportReportViewHelper::formatDateTime((string)($summary['generated_at'] ?? '')), ENT_QUOTES, 'UTF-8') . '</code></p>'
. '<p>健康状态:<strong>' . htmlspecialchars(DomainImportReportViewHelper::translateHealthLabel((string)($summary['health_label'] ?? '')), ENT_QUOTES, 'UTF-8') . '</strong> / 当前阶段:<strong>' . htmlspecialchars(DomainImportReportViewHelper::translateStage((string)($summary['workbench_stage'] ?? '')), ENT_QUOTES, 'UTF-8') . '</strong></p>'
. '<p>摘要:' . htmlspecialchars((string)($summary['hero_summary'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'
. '<h3>优先动作</h3><ul>'
. $renderList($priorityActions, static function (array $item): string {
return htmlspecialchars((string)($item['label'] ?? '-'), ENT_QUOTES, 'UTF-8') . ' / '
. htmlspecialchars((string)($item['summary'] ?? '-'), ENT_QUOTES, 'UTF-8');
})
. '</ul>'
. '<h3>主要失败阶段</h3><ul>'
. $renderList($topFailedStages, static function (array $item): string {
return htmlspecialchars(DomainImportReportViewHelper::translateStage((string)($item['stage'] ?? '-')), ENT_QUOTES, 'UTF-8') . '' . (int)($item['count'] ?? 0);
})
. '</ul>'
. '<h3>恢复路径</h3>'
. '<p>主路径:<strong>' . htmlspecialchars(DomainImportReportViewHelper::translatePrimaryPath((string)($recoveryMix['primary_path'] ?? '')), ENT_QUOTES, 'UTF-8') . '</strong></p>'
. '<p>已恢复总数:<strong>' . (int)($recoveryMix['resolved_total'] ?? 0) . '</strong> / 重跑恢复:<strong>' . (int)($recoveryMix['resolved_by_rerun_count'] ?? 0) . '</strong> / 补料恢复:<strong>' . (int)($recoveryMix['resolved_by_remediation_count'] ?? 0) . '</strong></p>'
. '</body></html>';
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportHealthWorkbenchTrendHelper
{
public static function buildSummary(string $runRoot, int $limit = 10): array
{
$indexSummary = DomainImportHealthWorkbenchIndexHelper::buildSummary($runRoot, $limit);
$items = (array)($indexSummary['items'] ?? []);
$latestRun = (array)($indexSummary['latest_run'] ?? []);
$previousRun = (array)($items[1] ?? []);
$latestActiveCount = (int)($latestRun['active_count'] ?? 0);
$previousActiveCount = (int)($previousRun['active_count'] ?? $latestActiveCount);
$direction = $latestActiveCount <=> $previousActiveCount;
$label = $latestActiveCount === $previousActiveCount
? 'flat'
: ($latestActiveCount < $previousActiveCount ? 'improving' : 'worsening');
$stageBuckets = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$stage = trim((string)($item['workbench_stage'] ?? ''));
if ($stage === '') {
$stage = 'unknown';
}
if (!isset($stageBuckets[$stage])) {
$stageBuckets[$stage] = 0;
}
$stageBuckets[$stage]++;
}
arsort($stageBuckets);
$bucketItems = [];
foreach (array_slice($stageBuckets, 0, 5, true) as $stage => $count) {
$bucketItems[] = [
'stage' => $stage,
'count' => (int)$count,
];
}
[$alertLevel, $alertReason] = self::resolveAlert(
$label,
(string)($latestRun['workbench_stage'] ?? ''),
$latestActiveCount,
$previousActiveCount
);
return [
'generated_at' => date(DATE_ATOM),
'runs_count' => (int)($indexSummary['total'] ?? 0),
'latest_run' => $latestRun,
'previous_run' => $previousRun,
'latest_active_count' => $latestActiveCount,
'previous_active_count' => $previousActiveCount,
'latest_health_label' => (string)($latestRun['health_label'] ?? ''),
'previous_health_label' => (string)($previousRun['health_label'] ?? ''),
'latest_stage' => (string)($latestRun['workbench_stage'] ?? ''),
'previous_stage' => (string)($previousRun['workbench_stage'] ?? ''),
'direction' => $direction,
'label' => $label,
'alert_level' => $alertLevel,
'alert_reason' => $alertReason,
'stage_buckets' => $bucketItems,
];
}
protected static function resolveAlert(string $label, string $latestStage, int $latestActiveCount, int $previousActiveCount): array
{
if ($label === 'worsening' && $latestActiveCount > 0) {
return ['high', '健康台趋势上升且当前仍有失败 host'];
}
if (in_array($latestStage, ['needs_attention', 'needs_self_healing'], true) && $latestActiveCount > 0) {
return ['medium', '当前趋势仍需重点观察自动修复是否接管'];
}
if ($label === 'flat' && $latestActiveCount === 0 && $previousActiveCount === 0) {
return ['low', '当前趋势持平且失败队列已清空,继续观察恢复稳定性'];
}
return ['none', '当前健康台趋势平稳'];
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportManualAttentionHandleRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'manual_attention'): string
{
$baseRoot = rtrim($baseRoot, '/');
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$runId = date('His') . '_' . trim($prefix, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
self::ensureDir($runRoot);
return $runRoot;
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
public static function persist(array $summary, string $runRoot, array $meta = []): array
{
self::ensureDir($runRoot);
$summary['meta'] = array_merge((array)($summary['meta'] ?? []), $meta);
$summary['generated_at'] = (string)($summary['generated_at'] ?? date(DATE_ATOM));
$summaryJsonPath = $runRoot . '/import-manual-attention.summary.json';
$summaryHtmlPath = $runRoot . '/import-manual-attention.summary.html';
file_put_contents($summaryJsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($summaryHtmlPath, self::renderHtml($summary));
$summary['summary_json_path'] = $summaryJsonPath;
$summary['summary_html_path'] = $summaryHtmlPath;
file_put_contents($summaryJsonPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $summary;
}
protected static function renderHtml(array $summary): string
{
$rows = [];
foreach ((array)($summary['items'] ?? []) as $item) {
$rows[] = '<tr>'
. '<td>' . htmlspecialchars((string)($item['host'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($item['action'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($item['status'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($item['reprobe_status'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($item['message'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Domain Import Manual Attention Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;margin-top:16px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{background:#f3f3f3;padding:2px 4px;}</style>'
. '</head><body>'
. '<h1>Domain Import Manual Attention Summary</h1>'
. '<p>generated_at: <code>' . htmlspecialchars((string)($summary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</code></p>'
. '<p>processed: <strong>' . (int)($summary['processed_count'] ?? 0) . '</strong> / resolved: <strong>' . (int)($summary['resolved_count'] ?? 0) . '</strong> / deferred: <strong>' . (int)($summary['deferred_count'] ?? 0) . '</strong> / ignored: <strong>' . (int)($summary['ignored_count'] ?? 0) . '</strong> / failed: <strong>' . (int)($summary['failed_count'] ?? 0) . '</strong></p>'
. '<table><thead><tr><th>host</th><th>action</th><th>status</th><th>reprobe_status</th><th>message</th></tr></thead><tbody>'
. implode('', $rows)
. '</tbody></table></body></html>';
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportRemediationRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'remediation'): string
{
$baseRoot = rtrim($baseRoot, '/');
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$runId = date('His') . '_' . trim($prefix, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
self::ensureDir($runRoot);
return $runRoot;
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
public static function persist(array $arrSummary, string $strRunRoot, array $arrMeta = []): array
{
self::ensureDir($strRunRoot);
$arrSummary['meta'] = array_merge((array)($arrSummary['meta'] ?? []), $arrMeta);
$arrSummary['generated_at'] = (string)($arrSummary['generated_at'] ?? date(DATE_ATOM));
$strSummaryJsonPath = $strRunRoot . '/import-remediation.summary.json';
$strSummaryHtmlPath = $strRunRoot . '/import-remediation.summary.html';
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strSummaryHtmlPath, self::renderHtml($arrSummary));
$arrSummary['summary_json_path'] = $strSummaryJsonPath;
$arrSummary['summary_html_path'] = $strSummaryHtmlPath;
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
$arrRows[] = '<tr>'
. '<td>' . htmlspecialchars((string)($arrItem['host'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['status'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['remediation_kind'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['remediation_status'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['failed_stage'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars((string)($arrItem['message'] ?? ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Domain Import Remediation Summary</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;margin-top:16px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{background:#f3f3f3;padding:2px 4px;}</style>'
. '</head><body>'
. '<h1>Domain Import Remediation Summary</h1>'
. '<p>generated_at: <code>' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '</code></p>'
. '<p>processed: <strong>' . (int)($arrSummary['processed_count'] ?? 0) . '</strong> / passed: <strong>' . (int)($arrSummary['passed_count'] ?? 0) . '</strong> / failed: <strong>' . (int)($arrSummary['failed_count'] ?? 0) . '</strong> / recovered: <strong>' . (int)($arrSummary['recovered_count'] ?? 0) . '</strong></p>'
. '<table><thead><tr><th>host</th><th>status</th><th>remediation_kind</th><th>remediation_status</th><th>failed_stage</th><th>message</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table></body></html>';
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportReportViewHelper
{
public static function formatDateTime(string $value): string
{
$value = trim($value);
if ($value === '') {
return '-';
}
$timestamp = strtotime($value);
if ($timestamp === false) {
return $value;
}
return date('Y-m-d H:i:s', $timestamp);
}
public static function translateStatus(string $status): string
{
$status = trim($status);
if ($status === '') {
return '-';
}
$map = [
'queued' => '已排队',
'running' => '执行中',
'success' => '成功',
'failed' => '失败',
'passed' => '通过',
'partial' => '部分通过',
'sample_discovery_failed' => '样本发现失败',
'pending' => '待处理',
'observing' => '观察中',
'done' => '已完成',
'ignored' => '已忽略',
'imported' => '已导入',
];
return $map[$status] ?? $status;
}
public static function translateStage(string $stage): string
{
$stage = trim($stage);
if ($stage === '') {
return '-';
}
$map = [
'home' => '首页',
'search' => '搜索页',
'detail' => '详情页',
'play' => '播放页',
'sample' => '样本发现',
'idle' => '未开始',
'steady' => '稳定',
'observe_recovery' => '恢复观察',
'needs_attention' => '需要关注',
'needs_self_healing' => '等待自动修复',
'needs_rerun' => '等待重跑',
'starting' => '开始执行',
'prepare' => '准备中',
'sample_discovery' => '样本发现',
'probing' => '页面探测',
'probe_failed' => '探测失败',
'health_workbench' => '健康台落盘',
'completed' => '已完成',
];
return $map[$stage] ?? $stage;
}
public static function translateMessage(string $message): string
{
$message = trim($message);
if ($message === '') {
return '-';
}
$map = [
'Trajectory probe found failed stages.' => '页面链路探测发现失败阶段。',
'Trajectory probe passed.' => '页面链路探测通过。',
'No probe checks were produced.' => '未生成有效的探测检查结果。',
];
return $map[$message] ?? $message;
}
public static function translateCheckDetail(string $detail): string
{
$detail = trim($detail);
if ($detail === '') {
return '-';
}
if (preg_match('/^http_(\d+)$/', $detail, $matches)) {
return 'HTTP 响应异常 ' . $matches[1];
}
if ($detail === 'ok') {
return '正常';
}
if ($detail === 'passed') {
return '通过';
}
return $detail;
}
public static function translateCheckStatus(int $status): string
{
return $status === 1 ? '通过' : '失败';
}
public static function translateHealthLabel(string $label): string
{
$label = trim($label);
if ($label === '') {
return '-';
}
$map = [
'steady' => '平稳',
'attention' => '需要关注',
'worsening' => '风险上升',
'waiting_self_healing' => '等待自动修复',
'growing' => '持续改善',
];
return $map[$label] ?? $label;
}
public static function translateTrendLabel(string $label): string
{
$label = trim($label);
if ($label === '') {
return '-';
}
$map = [
'flat' => '持平',
'improving' => '改善中',
'worsening' => '上升中',
];
return $map[$label] ?? $label;
}
public static function translatePrimaryPath(string $path): string
{
$path = trim($path);
if ($path === '') {
return '-';
}
$map = [
'none' => '暂无明显主路径',
'rerun' => '主要靠重跑恢复',
'remediation' => '主要靠补料恢复',
'balanced' => '重跑与补料共同恢复',
];
return $map[$path] ?? $path;
}
public static function translateClosureStage(string $stage): string
{
$stage = trim($stage);
if ($stage === '') {
return '-';
}
$map = [
'import_not_started' => '导入未开始',
'import_recovery_needed' => '导入恢复中',
'manual_intervention_needed' => '需要人工介入',
'external_waiting_data' => '等待站外数据',
'waiting_indexing' => '等待收录',
'indexing_without_keywords' => '已有收录待起词',
'seo_attention' => '站外效果需关注',
'seo_growing' => '站外效果增长中',
'closed_loop_running' => '闭环运行中',
];
return $map[$stage] ?? $stage;
}
public static function translateResultLabel(string $label): string
{
$label = trim($label);
if ($label === '') {
return '-';
}
$map = [
'pending' => '待观察',
'improved' => '有改善',
'no_change' => '无明显变化',
'regression' => '出现回退',
];
return $map[$label] ?? $label;
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportRerunRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'rerun'): string
{
$baseRoot = rtrim($baseRoot, '/');
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$runId = date('His') . '_' . trim($prefix, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
self::ensureDir($runRoot);
return $runRoot;
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
public static function persist(array $arrSummary, string $strRunRoot, array $arrMeta = []): array
{
self::ensureDir($strRunRoot);
$arrSummary['meta'] = array_merge((array)($arrSummary['meta'] ?? []), $arrMeta);
$arrSummary['generated_at'] = (string)($arrSummary['generated_at'] ?? date(DATE_ATOM));
$strSummaryJsonPath = $strRunRoot . '/import-rerun.summary.json';
$strSummaryHtmlPath = $strRunRoot . '/import-rerun.summary.html';
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strSummaryHtmlPath, self::renderHtml($arrSummary));
$arrSummary['summary_json_path'] = $strSummaryJsonPath;
$arrSummary['summary_html_path'] = $strSummaryHtmlPath;
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrRows = [];
foreach ((array)($arrSummary['items'] ?? []) as $arrItem) {
$strHost = htmlspecialchars((string)($arrItem['host'] ?? ''), ENT_QUOTES, 'UTF-8');
$strStatus = htmlspecialchars(DomainImportReportViewHelper::translateStatus((string)($arrItem['status'] ?? '')), ENT_QUOTES, 'UTF-8');
$strMessage = htmlspecialchars(DomainImportReportViewHelper::translateMessage((string)($arrItem['message'] ?? '')), ENT_QUOTES, 'UTF-8');
$strFailedStage = htmlspecialchars(DomainImportReportViewHelper::translateStage((string)($arrItem['failed_stage'] ?? '')), ENT_QUOTES, 'UTF-8');
$arrRows[] = '<tr>'
. '<td>' . $strHost . '</td>'
. '<td>' . $strStatus . '</td>'
. '<td>' . $strFailedStage . '</td>'
. '<td>' . $strMessage . '</td>'
. '</tr>';
}
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>站点导入重跑摘要</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;margin-top:16px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{background:#f3f3f3;padding:2px 4px;}</style>'
. '</head><body>'
. '<h1>站点导入重跑摘要</h1>'
. '<p>生成时间:<code>' . htmlspecialchars(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? '')), ENT_QUOTES, 'UTF-8') . '</code></p>'
. '<p>处理总数:<strong>' . (int)($arrSummary['processed_count'] ?? 0) . '</strong> / 通过:<strong>' . (int)($arrSummary['passed_count'] ?? 0) . '</strong> / 失败:<strong>' . (int)($arrSummary['failed_count'] ?? 0) . '</strong></p>'
. '<table><thead><tr><th>域名</th><th>状态</th><th>失败阶段</th><th>说明</th></tr></thead><tbody>'
. implode('', $arrRows)
. '</tbody></table></body></html>';
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportSelfHealingPolicyHelper
{
public static function buildPlan(
string $importRunRoot,
string $rerunRoot,
string $remediationRoot,
string $selfHealingRoot,
int $limit = 10,
int $cooldownSeconds = 14400,
int $failureThreshold = 3
): array {
$limit = max(1, min(100, $limit));
$cooldownSeconds = max(0, $cooldownSeconds);
$failureThreshold = max(1, min(20, $failureThreshold));
$queueSummary = DomainImportFailedQueueHelper::buildSummary($importRunRoot, 200);
$queueItems = array_values((array)($queueSummary['items'] ?? []));
$latestAttemptMap = self::buildLatestAttemptMap($rerunRoot, $remediationRoot);
$recentFailureCountMap = self::buildRecentFailureCountMap($rerunRoot, $remediationRoot, 20);
$eligibleItems = [];
$skippedItems = [];
$downgradedItems = [];
foreach ($queueItems as $item) {
if (!is_array($item)) {
continue;
}
$host = trim((string)($item['host'] ?? ''));
if ($host === '') {
continue;
}
$attempt = (array)($latestAttemptMap[$host] ?? []);
$recentFailureCount = (int)($recentFailureCountMap[$host] ?? 0);
$lastAttemptAt = (string)($attempt['updated_at'] ?? '');
$lastAttemptTimestamp = $lastAttemptAt !== '' ? (int)(strtotime($lastAttemptAt) ?: 0) : 0;
$cooldownRemaining = 0;
if ($cooldownSeconds > 0 && $lastAttemptTimestamp > 0) {
$cooldownRemaining = max(0, ($lastAttemptTimestamp + $cooldownSeconds) - time());
}
$policyItem = [
'host' => $host,
'failed_stage' => (string)($item['failed_stage'] ?? ''),
'updated_at' => (string)($item['updated_at'] ?? ''),
'recent_failure_count' => $recentFailureCount,
'last_attempt_kind' => (string)($attempt['kind'] ?? ''),
'last_attempt_status' => (string)($attempt['status'] ?? ''),
'last_attempt_updated_at' => $lastAttemptAt,
'cooldown_remaining_seconds' => $cooldownRemaining,
'policy_state' => 'eligible',
'policy_reason' => '可进入自动修复',
];
if ($recentFailureCount >= $failureThreshold) {
$policyItem['policy_state'] = 'downgraded';
$policyItem['policy_reason'] = sprintf('最近已连续失败 %d 次,建议转人工关注', $recentFailureCount);
$downgradedItems[] = $policyItem;
continue;
}
if ($cooldownRemaining > 0) {
$policyItem['policy_state'] = 'skipped';
$policyItem['policy_reason'] = sprintf('距离最近一次自动修复尝试过近,剩余冷却 %d 秒', $cooldownRemaining);
$skippedItems[] = $policyItem;
continue;
}
$eligibleItems[] = $policyItem;
}
usort($eligibleItems, static function (array $left, array $right): int {
return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? ''));
});
usort($skippedItems, static function (array $left, array $right): int {
return ((int)($right['cooldown_remaining_seconds'] ?? 0)) <=> ((int)($left['cooldown_remaining_seconds'] ?? 0));
});
usort($downgradedItems, static function (array $left, array $right): int {
return ((int)($right['recent_failure_count'] ?? 0)) <=> ((int)($left['recent_failure_count'] ?? 0));
});
$selectedHosts = array_map(
static fn (array $item): string => (string)($item['host'] ?? ''),
array_slice($eligibleItems, 0, $limit)
);
return [
'generated_at' => date(DATE_ATOM),
'queue_count' => count($queueItems),
'limit' => $limit,
'cooldown_seconds' => $cooldownSeconds,
'failure_threshold' => $failureThreshold,
'eligible_count' => count($eligibleItems),
'selected_count' => count($selectedHosts),
'skipped_count' => count($skippedItems),
'downgraded_count' => count($downgradedItems),
'selected_hosts' => array_values(array_filter($selectedHosts)),
'eligible_items' => $eligibleItems,
'skipped_items' => $skippedItems,
'downgraded_items' => $downgradedItems,
];
}
protected static function buildLatestAttemptMap(string $rerunRoot, string $remediationRoot): array
{
$runs = array_merge(
self::readAttemptRuns($rerunRoot, 'import-rerun.summary.json', 'rerun', 20),
self::readAttemptRuns($remediationRoot, 'import-remediation.summary.json', 'remediation', 20)
);
usort($runs, static function (array $left, array $right): int {
return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? ''));
});
$map = [];
foreach ($runs as $run) {
$host = trim((string)($run['host'] ?? ''));
if ($host === '' || isset($map[$host])) {
continue;
}
$map[$host] = $run;
}
return $map;
}
protected static function buildRecentFailureCountMap(string $rerunRoot, string $remediationRoot, int $maxFiles): array
{
$runs = array_merge(
self::readAttemptRuns($rerunRoot, 'import-rerun.summary.json', 'rerun', $maxFiles),
self::readAttemptRuns($remediationRoot, 'import-remediation.summary.json', 'remediation', $maxFiles)
);
$counts = [];
foreach ($runs as $run) {
$host = trim((string)($run['host'] ?? ''));
$status = trim((string)($run['status'] ?? ''));
if ($host === '' || $status === '' || $status === 'passed') {
continue;
}
if (!isset($counts[$host])) {
$counts[$host] = 0;
}
$counts[$host]++;
}
return $counts;
}
protected static function readAttemptRuns(string $baseRoot, string $summaryFileName, string $kind, int $maxFiles): array
{
$baseRoot = rtrim($baseRoot, '/');
if ($baseRoot === '' || !is_dir($baseRoot)) {
return [];
}
$summaryFiles = array_merge(
(array)glob($baseRoot . '/*/' . $summaryFileName),
(array)glob($baseRoot . '/*/*/' . $summaryFileName)
);
$summaryFiles = array_values(array_filter(array_unique($summaryFiles), 'is_file'));
usort($summaryFiles, static function (string $left, string $right): int {
return ((int)(filemtime($right) ?: 0)) <=> ((int)(filemtime($left) ?: 0));
});
$items = [];
foreach (array_slice($summaryFiles, 0, max(1, $maxFiles)) as $summaryPath) {
$data = json_decode((string)file_get_contents($summaryPath), true);
if (!is_array($data)) {
continue;
}
$updatedAt = date(DATE_ATOM, (int)(filemtime($summaryPath) ?: time()));
foreach ((array)($data['items'] ?? []) as $item) {
if (!is_array($item)) {
continue;
}
$host = trim((string)($item['host'] ?? ''));
if ($host === '') {
continue;
}
$items[] = [
'host' => $host,
'kind' => $kind,
'status' => (string)($item['status'] ?? ''),
'updated_at' => $updatedAt,
'run_id' => basename(dirname($summaryPath)),
];
}
}
return $items;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
class DomainImportSelfHealingRunHelper
{
public static function createRunRoot(string $baseRoot, string $prefix = 'self_healing'): string
{
$baseRoot = rtrim($baseRoot, '/');
$dateDir = $baseRoot . '/' . date('Ymd');
self::ensureDir($dateDir);
$runId = date('His') . '_' . trim($prefix, '_') . '_' . substr(md5(uniqid('', true)), 0, 6);
$runRoot = $dateDir . '/' . $runId;
self::ensureDir($runRoot);
return $runRoot;
}
public static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Failed to create directory: ' . $dir);
}
}
public static function persist(array $arrSummary, string $strRunRoot, array $arrMeta = []): array
{
self::ensureDir($strRunRoot);
$arrSummary['meta'] = array_merge((array)($arrSummary['meta'] ?? []), $arrMeta);
$arrSummary['generated_at'] = (string)($arrSummary['generated_at'] ?? date(DATE_ATOM));
$strSummaryJsonPath = $strRunRoot . '/import-self-healing.summary.json';
$strSummaryHtmlPath = $strRunRoot . '/import-self-healing.summary.html';
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($strSummaryHtmlPath, self::renderHtml($arrSummary));
$arrSummary['summary_json_path'] = $strSummaryJsonPath;
$arrSummary['summary_html_path'] = $strSummaryHtmlPath;
file_put_contents($strSummaryJsonPath, json_encode($arrSummary, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return $arrSummary;
}
protected static function renderHtml(array $arrSummary): string
{
$arrTrendSummary = (array)($arrSummary['trend_summary'] ?? []);
$arrFailureTrend = (array)($arrTrendSummary['failure_trend'] ?? []);
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>导入自动修复摘要</title>'
. '<style>body{font-family:Arial,sans-serif;padding:24px;}table{border-collapse:collapse;width:100%;margin-top:16px;}th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top;}th{background:#f6f6f6;}code{background:#f3f3f3;padding:2px 4px;}</style>'
. '</head><body>'
. '<h1>导入自动修复摘要</h1>'
. '<p>生成时间:<code>' . htmlspecialchars(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? '')), ENT_QUOTES, 'UTF-8') . '</code></p>'
. '<p>状态:<strong>' . htmlspecialchars(DomainImportReportViewHelper::translateStatus((string)($arrSummary['status'] ?? '')), ENT_QUOTES, 'UTF-8') . '</strong></p>'
. '<p>当前失败数:<strong>' . (int)($arrTrendSummary['queue_count'] ?? 0) . '</strong> / 重跑记录数:<strong>' . (int)($arrTrendSummary['rerun_runs_count'] ?? 0) . '</strong> / 补料记录数:<strong>' . (int)($arrTrendSummary['remediation_runs_count'] ?? 0) . '</strong></p>'
. '<p>补料恢复数:<strong>' . (int)($arrTrendSummary['resolved_by_remediation_count'] ?? 0) . '</strong> / 失败趋势:<strong>' . htmlspecialchars(DomainImportReportViewHelper::translateTrendLabel((string)($arrFailureTrend['label'] ?? '')), ENT_QUOTES, 'UTF-8') . '</strong></p>'
. '</body></html>';
}
}

View File

@@ -451,12 +451,13 @@ class DomainSpiderMdRunHelper
$forgePerVideo = max(0, (int)($forgePolicy['sitemap_count'] ?? 0));
foreach ($detailRows as $row) {
$strPinyin = (string)($row['v_name_en'] ?? '');
$detail[] = self::makeRecord(
$host,
'detail',
'详情页',
(string)$row['v_name'],
self::absoluteUrl($host, $urlBuilder->detail((string)$row['v_name_en'], (int)$row['v_id'])),
self::absoluteUrl($host, $urlBuilder->detail($strPinyin, (int)$row['v_id'])),
'detail'
);
@@ -488,7 +489,7 @@ class DomainSpiderMdRunHelper
'forge',
'详情泛链接',
$forgeKeyword,
self::absoluteUrl($host, $urlBuilder->detailForge((string)$row['v_name_en'], (int)$row['v_id'], $forgeId)),
self::absoluteUrl($host, $urlBuilder->detailForge($strPinyin, (int)$row['v_id'], $forgeId)),
'detail_forge'
);
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace app\common\helper;
use app\model\DomainModel;
use think\App;
class DomainTrajectoryProbeHelper
{
protected static bool $boolInitialized = false;
protected static function ensureAppInitialized(): void
{
if (self::$boolInitialized) {
return;
}
(new App())->initialize();
self::$boolInitialized = true;
}
public static function probe(string $strHost, array $arrSample, array $arrOptions = []): array
{
self::ensureAppInitialized();
$strHost = DomainModel::normalizeHost($strHost);
if ($strHost === '') {
return [
'host' => '',
'status' => 'invalid_host',
'message' => 'Host empty.',
'checks' => [],
'failed_stage' => 'host',
'all_passed' => false,
];
}
$DomainRow = self::findDomainRow($strHost);
if (!$DomainRow instanceof DomainModel) {
return [
'host' => $strHost,
'status' => 'domain_not_found',
'message' => 'Domain not found in site table.',
'checks' => [],
'failed_stage' => 'domain',
'all_passed' => false,
];
}
$TpStyle = SiteStyle::getConfig($DomainRow, $strHost);
$UrlBuilder = new UrlBuilder($TpStyle);
$strBaseUrl = rtrim((string)($arrOptions['base_url'] ?? ('https://' . $strHost)), '/');
$arrUrls = self::resolveUrls($UrlBuilder, $arrSample);
if (!empty($arrSample['urls']) && is_array($arrSample['urls'])) {
$arrUrls = array_merge($arrUrls, (array)$arrSample['urls']);
}
$arrChecks = [];
foreach ([
'home' => '首页',
'search' => '搜索',
'detail' => '详情',
'play' => '播放',
] as $strStage => $strLabel) {
$strPath = trim((string)($arrUrls[$strStage] ?? ''));
if ($strPath === '') {
$arrChecks[$strStage] = [
'label' => $strLabel,
'passed' => false,
'url' => '',
'status' => 0,
'detail' => 'url_missing',
];
continue;
}
$arrResponse = self::fetchPage($strBaseUrl . $strPath, $strHost);
$strBody = (string)($arrResponse['body'] ?? '');
$intStatus = (int)($arrResponse['status'] ?? 0);
$boolHasHtml = str_contains(strtolower($strBody), '<html');
$boolLooksLikeError = self::looksLikeErrorPage($strBody);
$boolPassed = $intStatus === 200 && $boolHasHtml && !$boolLooksLikeError;
$arrChecks[$strStage] = [
'label' => $strLabel,
'passed' => $boolPassed,
'url' => $strBaseUrl . $strPath,
'path' => $strPath,
'status' => $intStatus,
'detail' => $boolPassed ? 'ok' : self::resolveFailureDetail($intStatus, $boolHasHtml, $boolLooksLikeError),
];
}
$arrFailedStages = array_keys(array_filter($arrChecks, static fn(array $arrCheck): bool => empty($arrCheck['passed'])));
$boolAllPassed = empty($arrFailedStages);
return [
'host' => $strHost,
'status' => $boolAllPassed ? 'passed' : 'failed',
'message' => $boolAllPassed ? 'Trajectory probe passed.' : 'Trajectory probe found failed stages.',
'sample' => $arrSample,
'checks' => $arrChecks,
'failed_stage' => $arrFailedStages[0] ?? '',
'failed_stages' => $arrFailedStages,
'all_passed' => $boolAllPassed,
'probed_at' => date(DATE_ATOM),
];
}
protected static function findDomainRow(string $strHost): ?DomainModel
{
$strExactDomain = DomainModel::normalizeStoredDomain($strHost, DomainModel::MATCH_TYPE_EXACT);
$DomainRow = app(DomainModel::class)->where('d_domain', $strExactDomain)->find();
return $DomainRow instanceof DomainModel ? $DomainRow : null;
}
protected static function resolveUrls(UrlBuilder $UrlBuilder, array $arrSample): array
{
$strSlug = (string)($arrSample['detail']['detail_slug'] ?? '');
$intVideoId = (int)($arrSample['detail']['detail_id'] ?? 0);
$strSearchKeyword = (string)($arrSample['search']['search_keyword'] ?? '');
$strCategoryParent = (string)($arrSample['category']['category_parent'] ?? '');
$strCategoryChild = (string)($arrSample['category']['category_child'] ?? '');
$strPlayType = (string)($arrSample['play']['play_type'] ?? '');
$intPlayIndex = max(1, (int)($arrSample['play']['play_index'] ?? 1));
$arrUrls = [
'home' => $UrlBuilder->home(),
];
if ($strSearchKeyword !== '') {
$arrUrls['search'] = $UrlBuilder->searchResult($strSearchKeyword);
}
if ($strSlug !== '' && $intVideoId > 0) {
$arrUrls['detail'] = $UrlBuilder->detail($strSlug, $intVideoId);
}
if ($strSlug !== '' && $intVideoId > 0 && $strPlayType !== '') {
$arrUrls['play'] = $UrlBuilder->play($strSlug, $intVideoId, $strPlayType, $intPlayIndex);
}
if ($strCategoryParent !== '' && $strCategoryChild !== '') {
$arrUrls['category'] = $UrlBuilder->categoryChild($strCategoryParent, $strCategoryChild, 1);
}
return $arrUrls;
}
protected static function fetchPage(string $strUrl, string $strHost): array
{
$Context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => implode("\r\n", [
'Host: ' . $strHost,
'Connection: close',
]),
'ignore_errors' => true,
'timeout' => 20,
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
]);
$strBody = @file_get_contents($strUrl, false, $Context);
$arrHeaders = $http_response_header ?? [];
$intStatus = 0;
foreach ($arrHeaders as $strHeaderLine) {
if (preg_match('/^HTTP\/\S+\s+(\d{3})/i', $strHeaderLine, $arrMatches)) {
$intStatus = (int)($arrMatches[1] ?? 0);
}
}
return [
'status' => $intStatus,
'body' => $strBody === false ? '' : $strBody,
];
}
protected static function looksLikeErrorPage(string $strBody): bool
{
$strNormalized = strtolower($strBody);
foreach ([
'<title>thinkphp',
'think\\exception',
'undefined array key',
'uncaught exception',
'fatal error',
'parse error',
'call to undefined',
'stack trace',
'whoops',
'not found</title>',
'404 not found',
'500 internal server error',
] as $strNeedle) {
if (str_contains($strNormalized, $strNeedle)) {
return true;
}
}
return false;
}
protected static function resolveFailureDetail(int $intStatus, bool $boolHasHtml, bool $boolLooksLikeError): string
{
if ($intStatus !== 200) {
return 'http_' . $intStatus;
}
if (!$boolHasHtml) {
return 'html_missing';
}
if ($boolLooksLikeError) {
return 'error_page_detected';
}
return 'unknown_failure';
}
}

View File

@@ -1871,7 +1871,6 @@ class SiteStyle
$intId = (int)($arrVideo['v_id'] ?? 0);
if (!empty($strSlug) && $intId > 0) {
// 你当前详情 URL 示例:/voddetail/qian-long-zai-tian-247979
return '/voddetail/' . $strSlug . '-' . $intId;
}
if ($intId > 0) {

View File

@@ -185,6 +185,135 @@ if ($strTmpCode == 'videoGpt1') {
}
}
}
// ========== 4⃣ 历史深页兼容路由 ==========
// videoGpt1 主输出仍然只走当前 family
// 这里只补“旧 detail / play 深链入口”,避免历史外链、旧 sitemap、
// 百度已抓取的旧 family URL 在路由层直接 404。
$legacyCompatRoutes = [
'detail_forge' => [
'/vodinfo/:strPinyin-:intVId-:intVForgeId',
'/vod/:strPinyin-:intVId-:intVForgeId',
'/video-info/:strPinyin-:intVId-:intVForgeId',
'/video-detail/:strPinyin-:intVId-:intVForgeId',
'/video/:strPinyin-:intVId-:intVForgeId',
'/shipin/:strPinyin-:intVId-:intVForgeId',
'/shipin-xiangqing/:strPinyin-:intVId-:intVForgeId',
'/shipin-neiron/:strPinyin-:intVId-:intVForgeId',
'/voddetail/-:intVId-:intVForgeId',
'/vodinfo/-:intVId-:intVForgeId',
'/vod/-:intVId-:intVForgeId',
'/video-info/-:intVId-:intVForgeId',
'/video-detail/-:intVId-:intVForgeId',
'/video/-:intVId-:intVForgeId',
'/shipin/-:intVId-:intVForgeId',
'/shipin-xiangqing/-:intVId-:intVForgeId',
'/shipin-neiron/-:intVId-:intVForgeId',
'/voddetail/:intVId-:intVForgeId',
'/vodinfo/:intVId-:intVForgeId',
'/vod/:intVId-:intVForgeId',
'/video-info/:intVId-:intVForgeId',
'/video-detail/:intVId-:intVForgeId',
'/video/:intVId-:intVForgeId',
'/shipin/:intVId-:intVForgeId',
'/shipin-xiangqing/:intVId-:intVForgeId',
'/shipin-neiron/:intVId-:intVForgeId',
],
'detail' => [
'/voddetail/:strPinyin-:intVId',
'/vodinfo/:strPinyin-:intVId',
'/vod/:strPinyin-:intVId',
'/video-info/:strPinyin-:intVId',
'/video-detail/:strPinyin-:intVId',
'/video/:strPinyin-:intVId',
'/shipin/:strPinyin-:intVId',
'/shipin-xiangqing/:strPinyin-:intVId',
'/shipin-neiron/:strPinyin-:intVId',
'/voddetail/-:intVId',
'/vodinfo/-:intVId',
'/vod/-:intVId',
'/video-info/-:intVId',
'/video-detail/-:intVId',
'/video/-:intVId',
'/shipin/-:intVId',
'/shipin-xiangqing/-:intVId',
'/shipin-neiron/-:intVId',
'/voddetail/:intVId',
'/vodinfo/:intVId',
'/vod/:intVId',
'/video-info/:intVId',
'/video-detail/:intVId',
'/video/:intVId',
'/shipin/:intVId',
'/shipin-xiangqing/:intVId',
'/shipin-neiron/:intVId',
],
'play' => [
'/vodplay/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/vodbf/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/vodseed/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/video-play/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/video-bofang/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/video-show/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/shipin-play/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/shipin-bofang/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/shipin-kan/:strPinyin-:intVId-:strPlayType-:intPlayIndex',
'/vodplay/-:intVId-:strPlayType-:intPlayIndex',
'/vodbf/-:intVId-:strPlayType-:intPlayIndex',
'/vodseed/-:intVId-:strPlayType-:intPlayIndex',
'/video-play/-:intVId-:strPlayType-:intPlayIndex',
'/video-bofang/-:intVId-:strPlayType-:intPlayIndex',
'/video-show/-:intVId-:strPlayType-:intPlayIndex',
'/shipin-play/-:intVId-:strPlayType-:intPlayIndex',
'/shipin-bofang/-:intVId-:strPlayType-:intPlayIndex',
'/shipin-kan/-:intVId-:strPlayType-:intPlayIndex',
'/vodplay/:intVId-:strPlayType-:intPlayIndex',
'/vodbf/:intVId-:strPlayType-:intPlayIndex',
'/vodseed/:intVId-:strPlayType-:intPlayIndex',
'/video-play/:intVId-:strPlayType-:intPlayIndex',
'/video-bofang/:intVId-:strPlayType-:intPlayIndex',
'/video-show/:intVId-:strPlayType-:intPlayIndex',
'/shipin-play/:intVId-:strPlayType-:intPlayIndex',
'/shipin-bofang/:intVId-:strPlayType-:intPlayIndex',
'/shipin-kan/:intVId-:strPlayType-:intPlayIndex',
],
];
foreach ($legacyCompatRoutes as $page => $routes) {
foreach ($routes as $route) {
switch ($page) {
case 'detail':
$register($route, 'video/getVideoInfo.html', [
'intVId' => '\d+',
'strPinyin' => '[\w-]+',
]);
break;
case 'detail_forge':
$register($route, 'video/getVideoInfo.html', [
'intVId' => '\d+',
'strPinyin' => '[\w-]+',
'intVForgeId' => '\d+',
]);
break;
case 'play':
$register($route, 'video/getVideoPlayUrl.html', [
'intVId' => '\d+',
'strPinyin' => '[\w-]+',
'strPlayType' => '[\w-]+',
'intPlayIndex' => '\d+',
]);
break;
}
}
}
return;
} else {

View File

@@ -25,7 +25,7 @@
{elseif $variant == 4}
<div class="{$TpStyle.dom_prefix}-dm-action v4">
<button onclick="location.href='{$playUrl}'">立即观看</button>
<button onclick="location.href='{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'">立即观看</button>
</div>
{elseif $variant == 5}

View File

@@ -30,10 +30,10 @@
{block name="description"}{site:seotkd code="description" page="play" /}{/block}
{block name="head"}
<meta name="robots" content="index,follow">
<meta name="robots" content="noindex,follow">
{if $Request.route.intVId }
<link rel="canonical" href='https://{$DomainModel->d_domain}{site:vpurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" play_type="default" play_index="1" /}'>
<link rel="canonical" href='https://{$DomainModel->d_domain}{site:vurl v_id="$arrVideo.v_id" v_py="$arrVideo.v_name_en" /}'>
{/if}
@@ -214,4 +214,4 @@
<script src="https://cdn.jsdelivr.net/npm/dplayer/dist/DPlayer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js/dist/hls.min.js"></script>
{/block}
{/block}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace app\model;
class SeoExternalSnapshotModel extends BaseModel
{
protected $name = 'seo_external_snapshot';
protected $pk = 'id';
}

View File

@@ -83,14 +83,14 @@ class VideoService
if ($strTmpCode == 'videoGpt1') {
return $this->SiteContext->UrlBuilder->detail($strPinYin, $intVId);
} else {
$strKey = 'VIDEO_INFO_URL';
$strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [
'intVId' => $intVId,
'strPinYin' => $strPinYin
]);
return (string)$strUrl;
}
$strKey = 'VIDEO_INFO_URL';
$strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [
'intVId' => $intVId,
'strPinYin' => $strPinYin
]);
return (string)$strUrl;
}
/**
@@ -107,15 +107,15 @@ class VideoService
if ($strTmpCode == 'videoGpt1') {
return $this->SiteContext->UrlBuilder->detailForge($strPinYin, $intVId, $intVForgeId);
} else {
$strKey = 'FORGE_VIDEO_INFO_URL';
$strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [
'intVId' => $intVId,
'strPinYin' => $strPinYin,
'intVForgeId' => $intVForgeId,
]);
return (string)$strUrl;
}
$strKey = 'FORGE_VIDEO_INFO_URL';
$strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [
'intVId' => $intVId,
'strPinYin' => $strPinYin,
'intVForgeId' => $intVForgeId,
]);
return (string)$strUrl;
}
/**
@@ -133,16 +133,16 @@ class VideoService
if ($strTmpCode == 'videoGpt1') {
return $this->SiteContext->UrlBuilder->play($strPinYin, $intVId, $strPlayType, $intPlayIndex);
} else {
$strKey = 'VIDEO_PLAY_URL';
$strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [
'intVId' => $intVId,
'strPinYin' => $strPinYin,
'strPlayType' => $strPlayType,
'intPlayIndex' => $intPlayIndex,
]);
return (string)$strUrl;
}
$strKey = 'VIDEO_PLAY_URL';
$strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [
'intVId' => $intVId,
'strPinYin' => $strPinYin,
'strPlayType' => $strPlayType,
'intPlayIndex' => $intPlayIndex,
]);
return (string)$strUrl;
}
@@ -760,16 +760,42 @@ class VideoService
*/
public function getVideoByVId(int $intVId, int|NULL $intVForgeId)
{
$boolRequirePlayable = (string)request()->route('strPlayType') !== '' || (int)request()->route('intPlayIndex') > 0;
$this->debugFallbackProbe('enter_getVideoByVId', [
'requested_v_id' => $intVId,
'forge_id' => $intVForgeId,
'require_playable' => $boolRequirePlayable ? 1 : 0,
'host' => (string)(request()->host(true) ?: request()->host()),
'path' => (string)request()->pathinfo(),
]);
// 视频详情
$arrVideo = $this->VideoModel->getVideoByVId($intVId);
$this->debugFallbackProbe('after_direct_lookup', [
'requested_v_id' => $intVId,
'found' => empty($arrVideo) ? 0 : 1,
'resolved_v_id' => (int)($arrVideo['v_id'] ?? 0),
]);
if (empty($arrVideo)) {
$arrVideo = $this->resolveFallbackVideoByRequest($intVId, $boolRequirePlayable);
}
if (empty($arrVideo)) {
$this->debugFallbackProbe('final_404', [
'requested_v_id' => $intVId,
'path' => (string)request()->pathinfo(),
]);
throw new HttpException(404, '视频不存在');
}
if ($intVForgeId >= 0 && !empty($arrVideo['v_seo_words'])) {
$arrVideo['v_name'] = $arrVideo['v_seo_words'][$intVForgeId - 1];
$intForgeOffset = max(0, $intVForgeId - 1);
if (!empty($arrVideo['v_seo_words'][$intForgeOffset])) {
$arrVideo['v_name'] = $arrVideo['v_seo_words'][$intForgeOffset];
}
}
// 取前5个元素
@@ -783,7 +809,7 @@ class VideoService
$strTopLang = '2025';
// 点击数
$arrVideoClicks = $this->VideoClicksModel->getStats($intVId);
$arrVideoClicks = $this->VideoClicksModel->getStats((int)($arrVideo['v_id'] ?? $intVId));
// 更新值
ConverterMovel::setVal([
@@ -804,6 +830,185 @@ class VideoService
return $arrVideo;
}
protected function resolveFallbackVideoByRequest(int $requestedVId, bool $requirePlayable = false): ?array
{
$strHost = (string)($this->SiteContext->DomainModel->d_domain ?? 'default');
$arrBindings = $this->readFallbackBindings($strHost);
$strBindingKey = (string)$requestedVId;
$this->debugFallbackProbe('enter_resolve_fallback', [
'requested_v_id' => $requestedVId,
'require_playable' => $requirePlayable ? 1 : 0,
'host' => $strHost,
'existing_bindings' => count($arrBindings),
]);
$boundVId = (int)($arrBindings[$strBindingKey] ?? 0);
if ($boundVId > 0 && $boundVId !== $requestedVId) {
$arrVideo = $this->VideoModel->getVideoByVId($boundVId);
if (!empty($arrVideo) && (!$requirePlayable || $this->hasPlayableSource($arrVideo))) {
$this->debugFallbackProbe('reuse_bound_video', [
'requested_v_id' => $requestedVId,
'bound_v_id' => $boundVId,
]);
$arrVideo['_fallback_reason'] = 'bound_random_video';
$arrVideo['_requested_v_id'] = $requestedVId;
return $arrVideo;
}
}
$arrFilter = [];
if ($requirePlayable) {
$arrFilter['v_play_url'] = ['$exists' => true, '$ne' => []];
}
for ($attempt = 0; $attempt < 6; $attempt++) {
$cursor = $this->VideoModel->getCol()->aggregate([
['$match' => $arrFilter],
['$sample' => ['size' => 8]],
]);
$arrRows = iterator_to_array($cursor);
$this->debugFallbackProbe('sample_attempt', [
'requested_v_id' => $requestedVId,
'attempt' => $attempt + 1,
'sample_count' => count($arrRows),
]);
foreach ($arrRows as $row) {
$candidateId = (int)($row['v_id'] ?? 0);
if ($candidateId <= 0 || $candidateId === $requestedVId) {
continue;
}
$arrVideo = $this->VideoModel->getVideoByVId($candidateId);
if (empty($arrVideo)) {
continue;
}
if ($requirePlayable && !$this->hasPlayableSource($arrVideo)) {
continue;
}
$arrBindings[$strBindingKey] = $candidateId;
$this->writeFallbackBindings($strHost, $arrBindings);
$this->debugFallbackProbe('sample_success', [
'requested_v_id' => $requestedVId,
'candidate_v_id' => $candidateId,
'attempt' => $attempt + 1,
]);
$arrVideo['_fallback_reason'] = 'random_video';
$arrVideo['_requested_v_id'] = $requestedVId;
return $arrVideo;
}
}
$arrVideo = $this->VideoModel->findOne($arrFilter, ['sort' => ['v_level' => -1]]);
if (!empty($arrVideo)) {
$candidateId = (int)($arrVideo['v_id'] ?? 0);
if ($candidateId > 0 && $candidateId !== $requestedVId) {
$arrBindings[$strBindingKey] = $candidateId;
$this->writeFallbackBindings($strHost, $arrBindings);
$this->debugFallbackProbe('ordered_fallback_success', [
'requested_v_id' => $requestedVId,
'candidate_v_id' => $candidateId,
]);
$arrVideo['_fallback_reason'] = 'ordered_fallback_video';
$arrVideo['_requested_v_id'] = $requestedVId;
return $arrVideo;
}
}
$this->debugFallbackProbe('fallback_exhausted', [
'requested_v_id' => $requestedVId,
'require_playable' => $requirePlayable ? 1 : 0,
]);
return null;
}
protected function hasPlayableSource(array $arrVideo): bool
{
$arrPlayUrl = (array)($arrVideo['v_play_url'] ?? []);
if (empty($arrPlayUrl)) {
return false;
}
foreach ($arrPlayUrl as $episodes) {
if (is_array($episodes) && !empty($episodes)) {
return true;
}
}
return false;
}
protected function readFallbackBindings(string $host): array
{
$path = $this->fallbackBindingsPath($host);
if (!is_file($path)) {
return [];
}
$content = @file_get_contents($path);
if (!is_string($content) || trim($content) === '') {
return [];
}
$payload = json_decode($content, true);
return is_array($payload) ? $payload : [];
}
protected function writeFallbackBindings(string $host, array $bindings): void
{
$path = $this->fallbackBindingsPath($host);
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
@file_put_contents(
$path,
json_encode($bindings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
LOCK_EX
);
}
protected function fallbackBindingsPath(string $host): string
{
$host = trim(strtolower($host));
if ($host === '') {
$host = 'default';
}
$safeHost = preg_replace('/[^a-z0-9._-]+/i', '-', $host);
return rtrim((string)root_path(), DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR . 'storage'
. DIRECTORY_SEPARATOR . 'video_fallback_bindings'
. DIRECTORY_SEPARATOR . $safeHost . '.json';
}
protected function debugFallbackProbe(string $stage, array $payload = []): void
{
$path = rtrim((string)root_path(), DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR . 'storage'
. DIRECTORY_SEPARATOR . 'video_fallback_bindings'
. DIRECTORY_SEPARATOR . '_probe.log';
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$line = [
'ts' => date('c'),
'stage' => $stage,
'payload' => $payload,
];
@file_put_contents(
$path,
json_encode($line, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
}
/**
* 获取 播放线路-中文
*

View File

@@ -189,8 +189,8 @@ EOF;
$strAction = "";
$arrArgs = [
'intVId' => $Video->v_id,
'strPinYin' => $Video->v_name_en,
'strPinyin' => $Video->v_name_en,
'strPinYin' => '',
'strPinyin' => '',
];
if ($arrUrlFamily && !empty($arrUrlFamily)) {

View File

@@ -0,0 +1,49 @@
<?php
use think\migration\Migrator;
use think\migration\db\Column;
class CreateSeoExternalSnapshotTable extends Migrator
{
public function up()
{
$Table = $this->table('seo_external_snapshot', ['id' => false]);
$Table
->addColumn(Column::integer('id')->setUnsigned()->setIdentity(true)->setComment('主键'))
->addColumn(Column::integer('domain_id')->setUnsigned()->setDefault(0)->setComment('站点ID'))
->addColumn(Column::string('host', 255)->setDefault('')->setComment('站点域名'))
->addColumn(Column::string('provider', 100)->setDefault('')->setComment('数据提供方'))
->addColumn(Column::string('snapshot_type', 50)->setDefault('')->setComment('快照类型'))
->addColumn(Column::string('scope', 20)->setDefault('host')->setComment('粒度 host/keyword'))
->addColumn(Column::string('keyword', 255)->setDefault('')->setComment('关键词'))
->addColumn(Column::string('device', 20)->setDefault('')->setComment('设备 pc/mobile'))
->addColumn(Column::string('metric_date', 20)->setDefault('')->setComment('指标日期'))
->addColumn(Column::integer('queried_at')->setDefault(0)->setComment('采集时间'))
->addColumn(Column::string('source', 50)->setDefault('push_api')->setComment('来源'))
->addColumn(Column::string('status', 30)->setDefault('')->setComment('状态'))
->addColumn(Column::string('indexed_status', 30)->setDefault('')->setComment('收录状态'))
->addColumn(Column::string('result_count_text', 255)->setDefault('')->setComment('收录结果文本'))
->addColumn(Column::string('baidu_pc_ip_range', 50)->setDefault('')->setComment('百度PC来路区间'))
->addColumn(Column::string('baidu_mobile_ip_range', 50)->setDefault('')->setComment('百度移动来路区间'))
->addColumn(Column::integer('pc_keyword_count')->setDefault(0)->setComment('PC词数'))
->addColumn(Column::integer('mobile_keyword_count')->setDefault(0)->setComment('移动词数'))
->addColumn(Column::integer('rank_value')->setDefault(0)->setComment('关键词排名'))
->addColumn(Column::integer('search_volume')->setDefault(0)->setComment('搜索量'))
->addColumn(Column::text('payload')->setNullable(true)->setComment('扩展数据JSON'))
->addColumn(Column::string('snapshot_signature', 64)->setDefault('')->setComment('快照签名'))
->addColumn(Column::integer('created_at')->setDefault(0)->setComment('创建时间'))
->addColumn(Column::integer('updated_at')->setDefault(0)->setComment('更新时间'))
->addIndex(['id'], ['type' => 'primary'])
->addIndex(['snapshot_signature'])
->addIndex(['host', 'provider', 'metric_date'])
->addIndex(['host', 'scope'])
->addIndex(['keyword'])
->setComment('站外SEO历史快照表')
->create();
}
public function down()
{
$this->table('seo_external_snapshot')->drop()->save();
}
}

View File

@@ -0,0 +1,26 @@
<?php
use think\migration\Migrator;
class OptimizeSeoExternalSnapshotIndexes extends Migrator
{
public function up()
{
$this->table('seo_external_snapshot')
->addIndex(['provider', 'snapshot_type', 'scope', 'metric_date'], ['name' => 'idx_provider_type_scope_date'])
->addIndex(['snapshot_type', 'scope', 'metric_date'], ['name' => 'idx_type_scope_date'])
->addIndex(['host', 'snapshot_type', 'metric_date'], ['name' => 'idx_host_type_date'])
->addIndex(['host', 'keyword', 'device', 'metric_date'], ['name' => 'idx_host_keyword_device_date'])
->update();
}
public function down()
{
$this->table('seo_external_snapshot')
->removeIndexByName('idx_provider_type_scope_date')
->removeIndexByName('idx_type_scope_date')
->removeIndexByName('idx_host_type_date')
->removeIndexByName('idx_host_keyword_device_date')
->update();
}
}