Compare commits
2 Commits
7ece23688f
...
7fff1a9944
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fff1a9944 | ||
|
|
bd073c6795 |
@@ -157,6 +157,8 @@ 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");
|
||||
|
||||
@@ -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;
|
||||
@@ -68,6 +74,7 @@ use app\common\helper\SpiderMdConfigHelper;
|
||||
use app\model\AdminUserModel;
|
||||
use app\model\ConverterMovel;
|
||||
use app\model\DomainModel;
|
||||
use app\model\PlanTaskModel;
|
||||
use app\model\SubjectFomartGroupModel;
|
||||
use app\model\SubjectFomartModel;
|
||||
use app\model\TemplatesModel;
|
||||
|
||||
355
code/app/common/helper/DomainAutoSampleHelper.php
Normal file
355
code/app/common/helper/DomainAutoSampleHelper.php
Normal 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'] ?? ''));
|
||||
}
|
||||
}
|
||||
183
code/app/common/helper/DomainImportAsyncJobHelper.php
Normal file
183
code/app/common/helper/DomainImportAsyncJobHelper.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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', '当前导入主线平稳'];
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
@@ -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', '当前健康台趋势平稳'];
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
70
code/app/common/helper/DomainImportRemediationRunHelper.php
Normal file
70
code/app/common/helper/DomainImportRemediationRunHelper.php
Normal 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>';
|
||||
}
|
||||
}
|
||||
212
code/app/common/helper/DomainImportReportViewHelper.php
Normal file
212
code/app/common/helper/DomainImportReportViewHelper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
72
code/app/common/helper/DomainImportRerunRunHelper.php
Normal file
72
code/app/common/helper/DomainImportRerunRunHelper.php
Normal 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>';
|
||||
}
|
||||
}
|
||||
202
code/app/common/helper/DomainImportSelfHealingPolicyHelper.php
Normal file
202
code/app/common/helper/DomainImportSelfHealingPolicyHelper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
60
code/app/common/helper/DomainImportSelfHealingRunHelper.php
Normal file
60
code/app/common/helper/DomainImportSelfHealingRunHelper.php
Normal 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>';
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
224
code/app/common/helper/DomainTrajectoryProbeHelper.php
Normal file
224
code/app/common/helper/DomainTrajectoryProbeHelper.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
11
code/app/model/SeoExternalSnapshotModel.php
Normal file
11
code/app/model/SeoExternalSnapshotModel.php
Normal 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';
|
||||
}
|
||||
@@ -83,7 +83,8 @@ 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,
|
||||
@@ -91,7 +92,6 @@ class VideoService
|
||||
]);
|
||||
return (string)$strUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 伪造视频
|
||||
@@ -107,7 +107,8 @@ 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,
|
||||
@@ -116,7 +117,6 @@ class VideoService
|
||||
]);
|
||||
return (string)$strUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频播放
|
||||
@@ -133,7 +133,8 @@ 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,
|
||||
@@ -143,7 +144,6 @@ class VideoService
|
||||
]);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 播放线路-中文
|
||||
*
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user