Unify FNA homepage, archives, canonical URLs and durable SEO publishing

This commit is contained in:
Codex 2026-10-31 15:41:14 +08:00
parent a820755e7e
commit 7c11ca77bc
41 changed files with 808 additions and 493 deletions

View File

@ -13,3 +13,7 @@ ## AI 文章写作(必读)
周期文章按[固定发布封装](docs/maintenance/EDITORIAL_RELEASE.md)执行:本机SSH必须经白色机到正式业务服务器,使用服务器进程内API,不临时开放公网接口。每周三篇、每天15:00检查、错过窗口回补及文章随机展示时间见[周期写作](docs/maintenance/WEEKLY_TUTORIALS.md);这一流程替代周期写作中的手动开关API步骤,不扩大其他实例或全站改版权限。 周期文章按[固定发布封装](docs/maintenance/EDITORIAL_RELEASE.md)执行:本机SSH必须经白色机到正式业务服务器,使用服务器进程内API,不临时开放公网接口。每周三篇、每天15:00检查、错过窗口回补及文章随机展示时间见[周期写作](docs/maintenance/WEEKLY_TUTORIALS.md);这一流程替代周期写作中的手动开关API步骤,不扩大其他实例或全站改版权限。
教程SEO、编辑API的IndexNow结果与重试、栏目sitemap的唯一维护说明见[教程SEO通知](docs/maintenance/EDITORIAL_INDEXNOW.md)。`sitemap: done`不代表搜索引擎已收到通知或已收录。 教程SEO、编辑API的IndexNow结果与重试、栏目sitemap的唯一维护说明见[教程SEO通知](docs/maintenance/EDITORIAL_INDEXNOW.md)。`sitemap: done`不代表搜索引擎已收到通知或已收录。
## SEO统一合同(2026-10-31)
首页、归档、教程、分页、索引、sitemap、内容更新时间和IndexNow统一按[SEO实施维护](docs/maintenance/SEO_IMPLEMENTATION_2026-10-31.md)。禁止恢复全query robots屏蔽、分页canonical第一页、按180天批量删正文、用点赞updated_at作为lastmod,或让源图删除影响本站独立副本。当前状态和未取得的搜索平台证据见该文档;实施不等于收录/排名已验证。

15
app/Console/Commands/ClearDb.php Executable file → Normal file
View File

@ -25,20 +25,9 @@ public function handle(): void
private function article() private function article()
{ {
$aCateId = Categories::whereIn("sSlug", ["freenode", "shadowrocketids"])->pluck("id")->toArray(); $this->info('正文按内容保留策略维护,不再按180天自动删除。');
if (empty($aCateId)) {
$this->info("没有匹配的分类,跳过清理");
return;
}
$sDate = date('Y-m-d', strtotime('-180 days'));
$i = Articles::whereIn("iCategoryId", $aCateId)->whereDate("created_at", "<", $sDate)->delete();
$this->info("清理 {$i} 条数据");
} }
private function articleLikes() private function articleLikes()
{ {
// 1. 确定 30 天前的时间点 // 1. 确定 30 天前的时间点

View File

@ -0,0 +1,13 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class RetrySeoNotifications extends Command
{
protected $signature = 'seo:indexnow-retry';
protected $description = 'Retry durable resource, collection and deletion notifications';
public function handle(): int
{
$this->line(json_encode(['processed'=>app(\App\Services\SeoNotifications::class)->retry()]));
return self::SUCCESS;
}
}

16
app/Http/Controllers/Api/v1/ArticlesController.php Executable file → Normal file
View File

@ -66,6 +66,9 @@ public function receive(Request $oRequest)
$oArticle = Articles::where("sSlug", $sSlug)->first(); $oArticle = Articles::where("sSlug", $sSlug)->first();
if ($oArticle && \Illuminate\Support\Facades\DB::table('editorial_documents')->where('article_id',$oArticle->id)->exists()) {
abort(409, 'This article is managed by the editorial API');
}
if (!$oArticle) { if (!$oArticle) {
$oArticle = new Articles(); $oArticle = new Articles();
$oArticle->sSlug = $sSlug; $oArticle->sSlug = $sSlug;
@ -167,8 +170,7 @@ public function receiveShadowrocketids(Request $oRequest)
$sArticleUrl = config("app.url")."/a/".$sSlug; $sArticleUrl = config("app.url")."/a/".$sSlug;
$oIndexNow = new IndexNowService(); $bIndexR = '待服务器队列处理';
$bIndexR = $oIndexNow->pushUrls([$sArticleUrl.".html"]);
TeleSlave::notify()->send($sArticleUrl." 发布成功 | "."indexNow推送:".$bIndexR); TeleSlave::notify()->send($sArticleUrl." 发布成功 | "."indexNow推送:".$bIndexR);
@ -182,6 +184,7 @@ public function receiveFreenode(Request $oRequest)
$aData = $oRequest->all(); $aData = $oRequest->all();
$aArticle = $aData["article"]; $aArticle = $aData["article"];
$resourceState = \App\Services\ResourcePublication::validate($aArticle);
$sArticleTitle = $aArticle["title"]; $sArticleTitle = $aArticle["title"];
$sArticleTitleSub = $aArticle["title_sub"]; $sArticleTitleSub = $aArticle["title_sub"];
$sArticleContent = $aArticle["content"]; $sArticleContent = $aArticle["content"];
@ -189,7 +192,7 @@ public function receiveFreenode(Request $oRequest)
$sSlug = 'fn'.date("Ymd"); $sSlug = 'fn'.date("Ymd");
// $sImgAuthor = "tg_xiuren_a"; // $sImgAuthor = "tg_xiuren_a";
$sSubKey = $_ENV['site_code_short'].'::fn'; $sSubKey = $_ENV['site_code_short'].'::fn';
$i7like = $aArticle["7like"]; $i7like = $aArticle["7like"] ?? 0;
$oThumbNum = ArticleFreenodeThumbNum::firstOrCreate(["author_code" => $sSubKey]); $oThumbNum = ArticleFreenodeThumbNum::firstOrCreate(["author_code" => $sSubKey]);
$iThumbNum = $oThumbNum->num; $iThumbNum = $oThumbNum->num;
@ -237,7 +240,7 @@ public function receiveFreenode(Request $oRequest)
$oArticle = Articles::updateOrCreate( $oArticle = Articles::updateOrCreate(
['sSlug' => $sSlug], ['sSlug' => $sSlug],
$aAttributes $aAttributes + $resourceState
); );
(new TagService())->syncArticleTagsByName($oArticle, $aArticleTags); (new TagService())->syncArticleTagsByName($oArticle, $aArticleTags);
@ -246,8 +249,7 @@ public function receiveFreenode(Request $oRequest)
$sArticleUrl = config("app.url")."/a/".$sSlug; $sArticleUrl = config("app.url")."/a/".$sSlug;
$oIndexNow = new IndexNowService(); $bIndexR = '待服务器队列处理';
$bIndexR = $oIndexNow->pushUrls([$sArticleUrl.".html"]);
TeleSlave::notify()->send($sArticleUrl.".html"." 发布成功 | "."indexNow推送:".$bIndexR); // indexR是true或false,发送到tg什么都没有? TeleSlave::notify()->send($sArticleUrl.".html"." 发布成功 | "."indexNow推送:".$bIndexR); // indexR是true或false,发送到tg什么都没有?
@ -267,7 +269,7 @@ public function receiveFreenode_laji(Request $oRequest)
$aArticleTags = $aArticle["tags"]; $aArticleTags = $aArticle["tags"];
$sSlug = 'fn'.date("Ymd"); $sSlug = 'fn'.date("Ymd");
$sImgAuthor = "tg_xiuren_a"; $sImgAuthor = "tg_xiuren_a";
$i7like = $aArticle["7like"]; $i7like = $aArticle["7like"] ?? 0;
$oThumbNum = ArticleFreenodeThumbNum::firstOrCreate(["author_code" => $sImgAuthor]); $oThumbNum = ArticleFreenodeThumbNum::firstOrCreate(["author_code" => $sImgAuthor]);
$iThumbNum = $oThumbNum->num; $iThumbNum = $oThumbNum->num;

View File

@ -12,10 +12,10 @@ public function index(): Response
$sContent .= "Allow: /\n"; $sContent .= "Allow: /\n";
$sContent .= "Disallow: /admin/\n"; $sContent .= "Disallow: /admin/\n";
$sContent .= "Disallow: /api/\n"; $sContent .= "Disallow: /api/\n";
$sContent .= "Disallow: /storage/\n"; $sContent .= "\n";
$sContent .= "Disallow: /vendor/\n"; $sContent .= "Disallow: /vendor/\n";
$sContent .= "Disallow: /test/\n"; $sContent .= "Disallow: /test/\n";
$sContent .= "Disallow: /*?*\n\n"; $sContent .= "Disallow: /like/\n\n";
$sContent .= "Sitemap: " . config('app.url') . "/sitemap.xml\n"; $sContent .= "Sitemap: " . config('app.url') . "/sitemap.xml\n";
// 核心修复:强制指定 text/plain 响应头 // 核心修复:强制指定 text/plain 响应头

View File

@ -1,37 +1,24 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Articles; use App\Models\Articles;
use App\Models\Categories; use App\Models\Categories;
use App\Services\Seo;
use App\Services\ArticlesService;
use Illuminate\Http\Response; use Illuminate\Http\Response;
class SitemapController extends Controller class SitemapController extends Controller
{ {
public function index(): Response public function index(): Response
{ {
// Use the same expiry rule as article metadata, including on SQLite in isolated tests. $articles = Articles::publicVisible()->with('oCategory.oParent')->orderByDesc('created_at')->orderByDesc('id')->get();
$cArticles = Articles::where('iStatus', 1) $latest = app(ArticlesService::class)->getLatestFreeNodeSlug();
->where('created_at', '<=', now()) $home = $latest ? $articles->firstWhere('sSlug',$latest) : null;
->whereHas('oCategory', fn ($q) => $q->where('iStatus', 1)) $entries = $home ? [['url'=>Seo::url(),'modified'=>Seo::modified($home)]] : [];
->with('oCategory') foreach (Categories::where('iStatus',1)->with('oParent')->orderBy('id')->get() as $category) {
->orderBy('created_at', 'desc') if ($category->iParentId && (!$category->oParent || !$category->oParent->iStatus)) continue;
->get()->reject(fn ($article) => $article->bIsSeoExpired()); $members = $articles->filter(fn($a)=>$a->iCategoryId === $category->id || ($category->iShowChildren && $a->oCategory->iParentId === $category->id));
if ($members->isNotEmpty()) $entries[] = ['url'=>Seo::url(Seo::categoryPath($category)),'modified'=>null];
$visible = fn ($q) => $q->where('iStatus', 1)->where('created_at', '<=', now()); }
$cCategories = Categories::whereIn('sSlug', ['client', 'advanced']) foreach ($articles->filter(fn($a)=>Seo::indexable($a)) as $a) $entries[] = ['url'=>Seo::articleUrl($a),'modified'=>Seo::modified($a)];
->where('iParentId', 0)->where('iStatus', 1) return response()->view('sitemap',['entries'=>$entries])->header('Content-Type','application/xml; charset=UTF-8');
->where(function ($q) use ($visible) {
$q->whereHas('aArticles', $visible)->orWhereHas('aChildren', function ($children) use ($visible) {
$children->where('iStatus', 1)->whereHas('aArticles', $visible);
});
})->orderBy('sSlug')->get();
$sXmlContent = view('sitemap', [
'cArticles' => $cArticles,
'cCategories' => $cCategories,
])->render();
return response($sXmlContent, 200)->header('Content-Type', 'text/xml');
} }
} }

79
app/Http/Controllers/Web/ArticlesController.php Executable file → Normal file
View File

@ -19,10 +19,12 @@ public function __construct(ArticlesService $oService)
public function search(Request $oRequest, string $sKeyword) public function search(Request $oRequest, string $sKeyword)
{ {
$xData = $this->oArticlesService->search($sKeyword, 'view'); $page = \App\Services\Seo::page($oRequest);
return view('pages.articles.default.index', $xData); $data = $this->oArticlesService->search($sKeyword, 'view');
$data['seo'] = \App\Services\Seo::listMeta('搜索:'.$data['sKeywords'], '本站文章搜索结果', '/search/'.rawurlencode($data['sKeywords']), $page, false);
return $this->listing($oRequest, $data);
} }
public function like(int $iArticleId, Request $oRequest) public function like(int $iArticleId, Request $oRequest)
{ {
// 1. 获取/生成指纹 // 1. 获取/生成指纹
@ -43,44 +45,55 @@ public function like(int $iArticleId, Request $oRequest)
public function listByTag(Request $oRequest, string $sSlug) public function listByTag(Request $oRequest, string $sSlug)
{ {
$xData = $this->oArticlesService->getTagArticles($sSlug, 'view'); $page = \App\Services\Seo::page($oRequest);
return view('pages.articles.default.index', $xData); $data = $this->oArticlesService->getTagArticles($sSlug, 'view');
$data['seo'] = \App\Services\Seo::listMeta($data['oTag']->sName.'相关文章', '按标签浏览本站内容', '/tag/'.$sSlug, $page, false);
return $this->listing($oRequest, $data);
} }
public function home(Request $oRequest) public function home(Request $oRequest)
{ {
$sSlug = $this->oArticlesService->getLatestFreeNodeSlug(); if ($oRequest->filled('s')) {
abort_unless(is_string($oRequest->query('s')),404);
if ($sSlug === null) { return redirect(\App\Services\Seo::url('/search/'.rawurlencode(trim($oRequest->query('s')))),301);
return view('pages.articles.default.index', ['oArticles' => collect()]);
} }
$page = \App\Services\Seo::page($oRequest);
return $this->show($sSlug, $oRequest); if ($oRequest->has('page')) return redirect(\App\Services\Seo::url($page>1?'/freenode':'/', $page),301);
$slug = $this->oArticlesService->getLatestFreeNodeSlug();
if (!$slug) return response()->view('pages.articles.default.unavailable', [], 503)->header('Retry-After','3600');
$data = $this->articleData($slug, $oRequest);
$data['bIsHome'] = true;
$data['seo'] = \App\Services\Seo::articleMeta($data['oArticle'], true);
return view('pages.articles.default.show', $data);
} }
public function list(Request $oRequest, string $sSlug1, ?string $sSlug2 = null)
public function list(Request $oRequest, string $sSlug1, string $sSlug2 = null)
{ {
$sSlug = $sSlug2 ?? $sSlug1; $page = \App\Services\Seo::page($oRequest);
$xData = $this->oArticlesService->getCategoryArticles($sSlug, 'view'); $data = $this->oArticlesService->getCategoryArticles($sSlug2 ?? $sSlug1, 'view');
return view('pages.articles.default.index', $xData); $category = $data['oCategory'];
$path = \App\Services\Seo::categoryPath($category);
if ($sSlug2) abort_unless($path === '/'.$sSlug1.'/'.$sSlug2, 404);
$heading = $category->sSlug === 'freenode' ? '免费节点更新记录' : \App\Services\Seo::categoryName($category);
$desc = $category->sSlug === 'freenode' ? '按发布时间查看免费节点历史记录;获取当前订阅请访问首页。' : ($category->sDescription ?: $heading.'相关文章与使用说明');
$data['seo'] = \App\Services\Seo::listMeta($heading, $desc, $path, $page, $data['oArticles']->total()>0);
if ('/'.$sSlug1 !== $path && !$sSlug2) return redirect(\App\Services\Seo::url($path,$page),301);
return $this->listing($oRequest, $data);
}
private function listing(Request $request, array $data)
{
$page = \App\Services\Seo::page($request);
abort_if($page > $data['oArticles']->lastPage(),404);
if ($request->has('page') && $page === 1) return redirect($data['seo']['canonical'],301);
$data['oArticles']->withPath(strtok($data['seo']['canonical'],'?'));
return view('pages.articles.default.index', $data);
}
private function articleData(string $slug, Request $request): array
{
return $this->oArticlesService->getArticle($slug, $request->ip(), $request->cookie('visitor_token') ?? md5($request->ip().$request->userAgent()));
} }
public function show(string $sArticleSlug, Request $oRequest) public function show(string $sArticleSlug, Request $oRequest)
{ {
$sFingerprint = $oRequest->cookie('visitor_token') $data = $this->articleData($sArticleSlug,$oRequest);
?? md5($oRequest->ip() . $oRequest->userAgent()); $data['seo'] = \App\Services\Seo::articleMeta($data['oArticle']);
return view('pages.articles.default.show',$data);
$xData = $this->oArticlesService->getArticle($sArticleSlug, $oRequest->ip(), $sFingerprint);
// $xData["siteUrl"] = config("app.url");
$sViewPath = 'pages.articles.default.show';
//
// if ($xData["oArticle"]->oCategory->sSlug == "freenode") {
// $sViewPath = 'pages.articles.freenode.show';
// }
return view($sViewPath, $xData);
} }
} }

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
final class SeoHeaders
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
if ($response->getStatusCode() >= 400 || $request->is('api/*','like/*','up','test/*')) $response->headers->set('X-Robots-Tag','noindex, nofollow');
return $response;
}
}

44
app/Models/Articles.php Executable file → Normal file
View File

@ -67,7 +67,7 @@ protected function sCreatedAtDiff(): Attribute
public function shouldBeSearchable(): bool public function shouldBeSearchable(): bool
{ {
return (int)$this->iStatus === 1; return \App\Services\Seo::public($this);
} }
public function searchableAs(): string public function searchableAs(): string
@ -97,34 +97,34 @@ public function toSearchableArray(): array
* 判断当前文章是否超过分类设置的 SEO 暴露天数 * 判断当前文章是否超过分类设置的 SEO 暴露天数
* @return bool * @return bool
*/ */
public function bIsSeoExpired(): bool public function bIsSeoExpired(): bool { return !\App\Services\Seo::indexable($this); }
public function scopePublicVisible($query)
{ {
$oCategory = $this->oCategory; return $query->where('articles.iStatus', 1)->where('articles.created_at', '<=', now())
->whereHas('oCategory', fn($c) => $c->where('iStatus', 1)->where(fn($p) => $p
// 未关联分类,或分类下的 aOpt 没设置,默认不处理(不过期) ->where('iParentId', 0)->orWhereHas('oParent', fn($parent) => $parent->where('iStatus', 1))));
if (!$oCategory || empty($oCategory->aOpt) || !isset($oCategory->aOpt['noindexday'])) {
return false;
}
$iNoindexDay = (int) $oCategory->aOpt['noindexday'];
// 0 或负数代表不限制,一直暴露给搜索引擎
if ($iNoindexDay <= 0) {
return false;
}
// 判断文章创建时间是否早于 N 天前
$oCreateDate = \Carbon\Carbon::parse($this->created_at);
return $oCreateDate->lt(\Carbon\Carbon::now()->subDays($iNoindexDay));
} }
protected static function booted() protected static function booted()
{ {
static::saved(function () { static::saving(function ($article) {
// Historical test fixtures and migration tooling may use the pre-SEO schema.
if (!\Illuminate\Support\Facades\Schema::hasColumn('articles', 'sContentUpdatedAt')) return;
if (!$article->exists || $article->isDirty(['sTitle','sTitleSub','sContent','sThumb','iCategoryId','iStatus','bPageThumb'])) {
$article->sContentUpdatedAt = now();
}
if (!$article->exists && $article->oCategory?->sSlug === 'freenode') $article->sSeoPolicy = 'archive';
});
static::saved(function ($article) {
if ($article->wasRecentlyCreated || $article->wasChanged(['sTitle','sTitleSub','sContent','sThumb','iCategoryId','iStatus'])) {
app(\App\Services\SeoNotifications::class)->changed($article);
}
ResponseCache::forget('/'); // 要改成具体文章 ResponseCache::forget('/'); // 要改成具体文章
}); });
static::deleted(function () { static::deleted(function ($article) {
app(\App\Services\SeoNotifications::class)->changed($article,true);
ResponseCache::forget('/'); // 要改成具体文章 ResponseCache::forget('/'); // 要改成具体文章
}); });
} }

4
app/Providers/ArticleOrderLikesProvider.php Executable file → Normal file
View File

@ -21,7 +21,9 @@ public function boot(): void
$aHotArticles = CacheService::updateOrderLikes(); $aHotArticles = CacheService::updateOrderLikes();
} }
$oView->with('oOrderLikes', $aHotArticles); $ids = collect($aHotArticles)->pluck('oArticle.id');
$visible = \App\Models\Articles::publicVisible()->whereIn('id',$ids)->pluck('id')->all();
$oView->with('oOrderLikes', array_values(array_filter($aHotArticles, fn($item)=>in_array($item['oArticle']['id'],$visible))));
}); });
} }

View File

@ -10,6 +10,6 @@ class ArticleResponseCacheProfile extends CacheAllSuccessfulGetRequests
public function enabled(Request $request): bool public function enabled(Request $request): bool
{ {
// 首页跟随最新免费节点文章,不能沿用整页的七天缓存。 // 首页跟随最新免费节点文章,不能沿用整页的七天缓存。
return ! $request->is('/', 'api/editorial/*') && parent::enabled($request); return false; // Public HTML contains session/like state and time-dependent visibility; static assets remain cacheable.
} }
} }

169
app/Services/ArticlesService.php Executable file → Normal file
View File

@ -143,151 +143,46 @@ public function like(int $iArticleId, string $sIp, string $sFingerprint): array
} }
} }
public function search(string $sKeywords, string $sResultType) public function search(string $keywords, string $resultType = 'view')
{ {
if (empty($sKeywords)) { $term = trim(strip_tags($keywords));
return []; abort_if($term === '' || mb_strlen($term) > 100, 404);
} // Live DB authorization avoids stale external-index documents and cross-site results.
$literal = '%'.str_replace(['!','%','_'], ['!!','!%','!_'], $term).'%';
$sKeywords = strip_tags(trim($sKeywords)); $articles = Articles::publicVisible()->with('oCategory.oParent')
->where(fn($q) => $q->whereRaw("sTitle LIKE ? ESCAPE '!'", [$literal])->orWhereRaw("sContent LIKE ? ESCAPE '!'", [$literal]))
$oArticles = Articles::search($sKeywords) ->orderByDesc('iIsTop')->orderByDesc('created_at')->orderByDesc('id')->paginate(config('seo.page_size'));
->options([ return ['oArticles'=>$articles, 'sKeywords'=>$term];
'attributesToHighlight' => ['sTitle', 'sContent', 'sCategoryName'],
// 1. 把 attributesToSnippet 改成 attributesToCrop
'attributesToCrop' => ['sContent'],
// 2. 设置裁剪后的长度(单位是字符,按需调整)
'cropLength' => 100,
'highlightPreTag' => '<mark class="highlight">',
'highlightPostTag' => '</mark>',
])
->orderByDesc('iIsTop')
->paginate(config('app.page_limit'));
$oArticles->getCollection()->transform(function ($oArticle) {
$aMeta = $oArticle->scoutMetadata();
$aFormatted = $aMeta['_formatted'] ?? [];
if (!empty($aFormatted)) {
$oArticle->sTitle = $aFormatted['sTitle'] ?? $oArticle->sTitle;
$oArticle->sContent = $aFormatted['sContent'] ?? $oArticle->sContent;
if (isset($aFormatted['sCategoryName'])) {
$oArticle->sCategoryName = $aFormatted['sCategoryName'];
$oArticle->setRelation('oCategory', (object)[
'sName' => $aFormatted['sCategoryName'],
'sSlug' => $oArticle->sCategorySlug ?? ''
]);
}
}
return $oArticle;
});
if ($sResultType === "ajax") {
$sHtml = '';
foreach ($oArticles as $oArticle) {
$sHtml .= view('components.article-item', ['oArticle' => $oArticle])->render();
}
$xResult = $sHtml;
} else {
$xResult = [
'oArticles' => $oArticles,
];
}
return $xResult;
} }
public function getTagArticles(string $slug, string $resultType = 'view')
public function getTagArticles(string $sSlug, string $sResultType)
{ {
$oTags = Tags::where('sSlug', $sSlug)->firstOrFail(); $tag = Tags::where('sSlug', $slug)->firstOrFail();
return ['oTag'=>$tag, 'oArticles'=>$tag->oArticles()->publicVisible()->with('oCategory.oParent')
$oArticles = $oTags->oArticles() ->orderByDesc('iIsTop')->orderByDesc('created_at')->orderByDesc('id')->paginate(config('seo.page_size'))];
->where('iStatus', 1) // 只拿已发布的
->with(['oCategory']) // 预加载关联,拒绝 N+1
->orderByDesc('iIsTop') // 置顶优先
->orderByDesc('created_at') // 最新优先
->paginate(config('app.page_limit')); // 必须分页,否则数据多了会爆内存
if ($sResultType === "ajax") {
$sHtml = '';
foreach ($oArticles as $oArticle) {
$sHtml .= view('components.article-item', ['oArticle' => $oArticle])->render();
}
$xResult = $sHtml;
} else {
$xResult = [
'oArticles' => $oArticles,
];
}
return $xResult;
} }
public function getLatestFreeNodeSlug(): ?string public function getLatestFreeNodeSlug(): ?string
{ {
return Articles::where('iStatus', 1) foreach (Articles::publicVisible()->whereHas('oCategory', fn($q)=>$q->where('sSlug','freenode'))
->whereHas('oCategory', fn ($query) => $query->where('sSlug', 'freenode')) ->orderByDesc('created_at')->orderByDesc('id')->cursor() as $article) {
->where('created_at', '<=', now()) if (ResourcePublication::usable($article)) return $article->sSlug;
->orderByDesc('created_at')
->orderByDesc('id')
->value('sSlug');
}
public function getCategoryArticles(string $sSlug, string $sResultType)
{
$oCategory = Categories::where('sSlug', $sSlug)->firstOrFail();
if ($oCategory->iShowChildren) {
$aCategoryIds = Categories::where('iParentId', $oCategory->id)
->pluck('id')
->push($oCategory->id)
->toArray();
} else {
$aCategoryIds = [$oCategory->id];
} }
return null;
$oArticles = Articles::whereIn('iCategoryId', $aCategoryIds)
->with(['oUser', 'oCategory'])
->orderBy('iIsTop', 'desc')
->orderBy('created_at', 'desc')
->simplePaginate(config('app.page_limit'));
if ($sResultType === "ajax") {
$sHtml = '';
foreach ($oArticles as $oArticle) {
$sHtml .= view('components.article-item', ['oArticle' => $oArticle])->render();
}
$aResult = $sHtml;
} else {
$aResult = [
'oCategory' => $oCategory,
'iArticleCount' => isset($oCategory->aBanner) ? Articles::whereIn('iCategoryId', $aCategoryIds)->count() : null,
'oArticles' => $oArticles,
];
}
return $aResult;
} }
public function getCategoryArticles(string $slug, string $resultType = 'view')
public function getArticle(string $sArticleSlug, string $sIp, string $sFingerprint)
{ {
$oArticle = Articles::where('sSlug', $sArticleSlug) $category = Categories::where('sSlug', $slug)->where('iStatus',1)->with('oParent')->firstOrFail();
->where('iStatus', 1) abort_if($category->iParentId && (!$category->oParent || !$category->oParent->iStatus),404);
->with(['oCategory.oParent', 'oTags']) $ids = [$category->id];
->firstOrFail(); if ($category->iShowChildren) $ids = array_merge($ids, Categories::where('iParentId',$category->id)->where('iStatus',1)->pluck('id')->all());
$articles = Articles::publicVisible()->whereIn('iCategoryId',$ids)->with('oCategory.oParent')
$oArticle->bIsLiked = ArticleLikes::where('iArticleId', $oArticle->id) ->orderByDesc('iIsTop')->orderByDesc('created_at')->orderByDesc('id')->paginate(config('seo.page_size'));
->where(function($q) use ($sIp, $sFingerprint) { return ['oCategory'=>$category,'oArticles'=>$articles,'iArticleCount'=>$articles->total()];
$q->where('sIp', $sIp) }
->orWhere('sFingerprint', $sFingerprint); public function getArticle(string $slug, string $ip, string $fingerprint)
})->exists(); {
$article = Articles::publicVisible()->where('sSlug',$slug)->with(['oCategory.oParent','oTags'])->firstOrFail();
// return $oArticle; $article->bIsLiked = ArticleLikes::where('iArticleId',$article->id)
return [ ->where(fn($q)=>$q->where('sIp',$ip)->orWhere('sFingerprint',$fingerprint))->exists();
'oArticle' => $oArticle return ['oArticle'=>$article];
];
} }
} }

2
app/Services/CacheService.php Executable file → Normal file
View File

@ -68,7 +68,7 @@ class CacheService
public static function updateOrderLikes(): array public static function updateOrderLikes(): array
{ {
// 1. 基于 Articles 表按最后点赞时间倒序,涵盖真实赞与算法注入赞 // 1. 基于 Articles 表按最后点赞时间倒序,涵盖真实赞与算法注入赞
$cArticles = Articles::where('iStatus', 1) $cArticles = Articles::publicVisible()
->whereNotNull('sLastLikedAt') ->whereNotNull('sLastLikedAt')
->orderBy('sLastLikedAt', 'desc') ->orderBy('sLastLikedAt', 'desc')
->limit(5) ->limit(5)

View File

@ -27,6 +27,34 @@ public function synchronize(array $state, array $published): array
return $state; return $state;
} }
$base = rtrim((string) config('editorial.base_url'), '/');
$urls = [];
foreach ($published as $item) {
if (! ($item['content_changed'] ?? false)) {
continue;
}
$article = Articles::with('oCategory')->find($item['article_id']);
if (! $article || !\App\Services\Seo::indexable($article)) {
continue;
}
$url = $base.'/a/'.$article->sSlug.'.html';
if ($url === $item['url'] && preg_match('/\A[a-z0-9_-]+\z/', $article->sSlug)) {
$urls[] = $url;
$urls[] = \App\Services\Seo::url(\App\Services\Seo::categoryPath($article->oCategory));
if ($article->oCategory->oParent) $urls[] = \App\Services\Seo::url(\App\Services\Seo::categoryPath($article->oCategory->oParent));
}
}
$urls = array_values(array_unique($urls));
if (! $urls) {
return array_replace($state, ['status' => 'skipped', 'reason' => 'no-current-indexable-url']);
}
return $this->submit($state, $urls);
}
public function submit(array $state, array $urls): array
{
if (!$this->enabled() || ($state['next_attempt_at'] ?? 0) > now()->timestamp) return $state;
$base = rtrim((string) config('editorial.base_url'), '/'); $base = rtrim((string) config('editorial.base_url'), '/');
$host = parse_url($base, PHP_URL_HOST); $host = parse_url($base, PHP_URL_HOST);
// No per-article target, redirect, or credentials in URLs. Never probe public pages from local. // No per-article target, redirect, or credentials in URLs. Never probe public pages from local.
@ -40,27 +68,10 @@ public function synchronize(array $state, array $published): array
return $this->pending($state, 'key-missing-or-invalid'); return $this->pending($state, 'key-missing-or-invalid');
} }
$urls = []; foreach ($urls as $url) {
foreach ($published as $item) { if (!str_starts_with($url, $base.'/') || parse_url($url, PHP_URL_HOST) !== $host || parse_url($url, PHP_URL_QUERY) || parse_url($url, PHP_URL_FRAGMENT)) return $this->pending($state,'invalid-target');
if (! ($item['content_changed'] ?? false)) {
continue;
}
$article = Articles::with('oCategory')->find($item['article_id']);
if (! $article || (int) $article->iStatus !== 1 || ! $article->oCategory
|| (int) $article->oCategory->iStatus !== 1 || $article->created_at->isFuture()
|| $article->bIsSeoExpired()) {
continue;
}
$url = $base.'/a/'.$article->sSlug.'.html';
if ($url === $item['url'] && preg_match('/\A[a-z0-9_-]+\z/', $article->sSlug)) {
$urls[] = $url;
}
} }
$urls = array_values(array_unique($urls)); if (!$urls) return ['status'=>'skipped','reason'=>'no-current-indexable-url'];
if (! $urls) {
return array_replace($state, ['status' => 'skipped', 'reason' => 'no-current-indexable-url']);
}
try { try {
$response = Http::connectTimeout(5)->timeout(15)->withoutRedirecting()->acceptJson() $response = Http::connectTimeout(5)->timeout(15)->withoutRedirecting()->acceptJson()
->post('https://api.indexnow.org/indexnow', [ ->post('https://api.indexnow.org/indexnow', [

View File

@ -203,6 +203,10 @@ public function publish(string $id, array $input): array
foreach (array_unique(array_merge($oldTags, $item['tag_ids'])) as $tag) { foreach (array_unique(array_merge($oldTags, $item['tag_ids'])) as $tag) {
DB::table('tags')->where('id', $tag)->update(['iArticleCount' => DB::table('article_tag')->where('iTagId', $tag)->count()]); DB::table('tags')->where('id', $tag)->update(['iArticleCount' => DB::table('article_tag')->where('iTagId', $tag)->count()]);
} }
$contentChanged = $item['base_article_hash'] !== $this->articleHash(DB::table('articles')->where('id', $articleId)->first());
if ($contentChanged && \Illuminate\Support\Facades\Schema::hasColumn('articles','sContentUpdatedAt')) {
DB::table('articles')->where('id',$articleId)->update(['sContentUpdatedAt'=>now()]);
}
DB::table('editorial_documents')->where('content_key', $item['content_key'])->update(['article_id' => $articleId, 'published_revision' => $item['revision'], 'updated_at' => now()]); DB::table('editorial_documents')->where('content_key', $item['content_key'])->update(['article_id' => $articleId, 'published_revision' => $item['revision'], 'updated_at' => now()]);
DB::table('editorial_versions')->insert(['content_key' => $item['content_key'], 'revision' => $item['revision'], 'snapshot' => json_encode($item, JSON_UNESCAPED_UNICODE), 'created_at' => now()]); DB::table('editorial_versions')->insert(['content_key' => $item['content_key'], 'revision' => $item['revision'], 'snapshot' => json_encode($item, JSON_UNESCAPED_UNICODE), 'created_at' => now()]);
$published[] = ['content_key' => $item['content_key'], 'article_id' => $articleId, 'revision' => $item['revision'], $published[] = ['content_key' => $item['content_key'], 'article_id' => $articleId, 'revision' => $item['revision'],

View File

@ -0,0 +1,24 @@
<?php
namespace App\Services;
use App\Models\Articles;
final class ResourcePublication
{
public static function usable(Articles $article): bool
{
if (in_array($article->sResourceStatus, ['failed','empty'], true)) return false;
// Legacy explicit zero is evidence of no result, not a successful refresh.
return !preg_match('/(?:共|更新)\s*0\s*个可用节点/u', Seo::text($article->sTitleSub));
}
public static function validate(array $article): array
{
validator($article, ['title'=>'required|string|max:250','title_sub'=>'present|string|max:4000',
'content'=>'required|string|max:2000000','tags'=>'present|array','7like'=>'nullable|integer|min:0',
'check_status'=>'nullable|in:success,failed,unknown','valid_count'=>'nullable|integer|min:0'])->validate();
$count = isset($article['valid_count']) ? (int)$article['valid_count'] : null;
$state = $article['check_status'] ?? 'unknown';
// Compatibility for the current upstream; a reported count is not a new live check.
if ($count === null && preg_match('/(?:共|更新)\s*(\d+)\s*个可用节点/u', Seo::text($article['title_sub']), $m)) $count = (int)$m[1];
abort_if($state === 'failed' || $count === 0 || trim(Seo::text($article['content'])) === '', 422, 'Resource update has no usable result; previous publication retained');
return ['sResourceStatus'=>$state === 'success' && $count > 0 ? 'success' : 'unknown','iResourceCount'=>$count];
}
}

110
app/Services/Seo.php Normal file
View File

@ -0,0 +1,110 @@
<?php
namespace App\Services;
use App\Models\Articles;
use App\Models\Categories;
use Carbon\Carbon;
use Illuminate\Http\Request;
/** One URL, visibility and indexing contract for HTML, sitemap and notifications. */
final class Seo
{
public static function url(string $path = '/', int $page = 1): string
{
return rtrim(config('app.url'), '/').'/'.ltrim($path, '/').($page > 1 ? '?page='.$page : '');
}
public static function articleUrl(Articles $article): string { return self::url('/a/'.$article->sSlug.'.html'); }
public static function categoryPath(Categories $category): string
{
return ($category->oParent ? '/'.$category->oParent->sSlug : '').'/'.$category->sSlug;
}
public static function categoryName(Categories $category): string
{
return match ($category->sSlug) { 'andorid' => 'Android', 'ios' => 'iOS', default => $category->sName };
}
public static function text(?string $html, int $limit = 200): string
{
$text = html_entity_decode(strip_tags($html ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return mb_substr(trim(preg_replace('/\s+/u', ' ', $text)), 0, $limit);
}
public static function imageSize(?string $url): array
{
$path = parse_url($url ?? '', PHP_URL_PATH);
if (!$path || str_contains($path,'..') || !str_starts_with($path,'/')) return [];
if (parse_url($url,PHP_URL_HOST) && parse_url($url,PHP_URL_HOST) !== parse_url(config('app.url'),PHP_URL_HOST)) return [];
$file = public_path(ltrim($path,'/'));
if (!is_file($file)) return [];
$size = @getimagesize($file);
return $size ? ['width'=>$size[0],'height'=>$size[1]] : [];
}
public static function body(string $html): string
{
// Preserve editorial HTML byte-for-byte except confirmed commercial links.
return preg_replace_callback('~<a\b[^>]*>~i', function($match) {
$tag = $match[0];
if (!preg_match('~\bhref\s*=\s*([\x22\x27])(.*?)\1~i',$tag,$href)) return $tag;
$host = parse_url(html_entity_decode($href[2],ENT_QUOTES),PHP_URL_HOST);
if (!in_array($host,['fuuu.cloud','xn--123-9i9dj97q.com','hardnerd.net'],true)) return $tag;
$tokens = ['sponsored','noopener'];
if (preg_match('~\brel\s*=\s*([\x22\x27])(.*?)\1~i',$tag,$rel)) {
$tokens = array_unique(array_merge(preg_split('/\s+/',trim($rel[2])),$tokens));
return str_replace($rel[0],'rel="'.implode(' ',$tokens).'"',$tag);
}
return substr($tag,0,-1).' rel="'.implode(' ',$tokens).'">';
}, $html);
}
public static function public(Articles $a): bool
{
return (int)$a->iStatus === 1 && $a->created_at && !$a->created_at->isFuture()
&& $a->oCategory && (int)$a->oCategory->iStatus === 1
&& (!$a->oCategory->iParentId || ($a->oCategory->oParent && (int)$a->oCategory->oParent->iStatus === 1));
}
public static function indexable(Articles $a): bool
{
if (!self::public($a) || in_array($a->sSeoPolicy, ['archive','noindex'], true)) return false;
if ($a->sSeoPolicy === 'index') return true;
// Legacy records retain their existing age policy; tutorials never inherit it.
if (!in_array($a->oCategory->sSlug, ['freenode','shadowrocketids'])) return true;
$days = (int)($a->oCategory->aOpt['noindexday'] ?? 0);
return $days <= 0 || !$a->created_at->copy()->addDays($days)->isPast();
}
public static function modified(Articles $a): ?string
{
return $a->sContentUpdatedAt ? Carbon::parse($a->sContentUpdatedAt)->toAtomString() : null;
}
public static function page(Request $r): int
{
$raw = $r->query('page', '1');
$keys = array_filter(explode('&', $r->server('QUERY_STRING', '')), fn($p) => urldecode(explode('=', $p, 2)[0]) === 'page');
abort_if(count($keys) > 1 || !is_scalar($raw) || !preg_match('/\A[1-9][0-9]{0,5}\z/', (string)$raw), 404);
return (int)$raw;
}
public static function articleMeta(Articles $a, bool $home = false): array
{
$canonical = $home ? self::url() : self::articleUrl($a);
$title = $home ? config('seo.home_title') : self::text($a->sTitle, 250);
$description = $home ? config('seo.home_description') : self::text($a->sTitleSub ?: $a->sContent);
$schema = [['@context'=>'https://schema.org', '@type'=>$home ? 'WebSite' : 'Article',
'name'=>$home ? config('seo.brand') : $title, 'url'=>$canonical]];
if (!$home) {
$schema[0] += ['headline'=>$title,'mainEntityOfPage'=>$canonical,'datePublished'=>$a->created_at->toAtomString(),
'author'=>['@type'=>'Organization','name'=>config('seo.brand'),'url'=>self::url()]];
if ($modified = self::modified($a)) $schema[0]['dateModified'] = $modified;
if ($a->sThumb) $schema[0]['image'] = str_starts_with($a->sThumb, '/') ? self::url($a->sThumb) : $a->sThumb;
$crumbs = [['name'=>'首页','item'=>self::url()]];
if ($a->oCategory->oParent) $crumbs[] = ['name'=>self::categoryName($a->oCategory->oParent),'item'=>self::url(self::categoryPath($a->oCategory->oParent))];
$crumbs[] = ['name'=>self::categoryName($a->oCategory),'item'=>self::url(self::categoryPath($a->oCategory))];
$crumbs[] = ['name'=>$title,'item'=>$canonical];
$schema[] = ['@context'=>'https://schema.org','@type'=>'BreadcrumbList','itemListElement'=>array_map(fn($c,$i)=>['@type'=>'ListItem','position'=>$i+1]+$c,$crumbs,array_keys($crumbs))];
}
return ['title'=>$title.' - '.config('seo.brand'),'heading'=>$title,'description'=>$description,'canonical'=>$canonical,
'robots'=>$home || self::indexable($a) ? 'index, follow' : 'noindex, follow','type'=>$home ? 'website' : 'article','schema'=>$schema];
}
public static function listMeta(string $heading, string $description, string $path, int $page, bool $index = true): array
{
$url = self::url($path, $page);
return ['heading'=>$heading, 'title'=>$heading.($page>1?' · 第'.$page.'页':'').' - '.config('seo.brand'),
'description'=>self::text($description), 'canonical'=>$url, 'robots'=>$index?'index, follow':'noindex, follow',
'schema'=>[['@context'=>'https://schema.org','@type'=>'CollectionPage','name'=>$heading,'url'=>$url]]];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Services;
use App\Models\Articles;
use App\Services\Editorial\IndexNowNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class SeoNotifications
{
public function changed(Articles $a, bool $deleted = false): void
{
if (!Schema::hasTable('seo_notifications')) return;
$urls = [];
if ($deleted || !Seo::public($a)) $urls[] = Seo::articleUrl($a);
elseif (Seo::indexable($a)) $urls[] = Seo::articleUrl($a);
if ($a->oCategory?->sSlug === 'freenode') $urls[] = Seo::url();
if ($a->oCategory) $urls[] = Seo::url(Seo::categoryPath($a->oCategory));
$event = $deleted || !Seo::public($a) ? 'delete' : 'update';
$key = hash('sha256',json_encode([$a->id,$event,$a->sContentUpdatedAt,$a->sTitle,$a->sContent,$a->sThumb,$a->iStatus]));
$this->enqueue($key,$urls,$event);
}
public function enqueue(string $key, array $urls, string $event = 'update'): void
{
if (!app()->environment(['production','testing']) || !config('editorial.indexnow_enabled',app()->environment('production'))) return;
DB::table('seo_notifications')->insertOrIgnore(['event_key'=>$key,'urls'=>json_encode(array_values(array_unique($urls))),
'event'=>$event,'state'=>json_encode(['status'=>'pending','attempts'=>0]),'created_at'=>now(),'updated_at'=>now()]);
}
public function retry(int $limit = 10): int
{
$processed = 0;
foreach (DB::table('seo_notifications')->orderBy('id')->cursor() as $candidate) {
$s = json_decode($candidate->state,true);
if (($s['status'] ?? '') !== 'pending' || ($s['next_attempt_at'] ?? 0) > now()->timestamp) continue;
DB::transaction(function () use ($candidate,&$processed) {
$row = DB::table('seo_notifications')->where('id',$candidate->id)->lockForUpdate()->first();
$state = json_decode($row->state,true);
if (($state['status'] ?? '') !== 'pending' || ($state['next_attempt_at'] ?? 0) > now()->timestamp) return;
$urls = json_decode($row->urls,true);
if ($row->event !== 'delete') $urls = array_values(array_filter($urls,function($url) {
$path = parse_url($url,PHP_URL_PATH);
if (preg_match('~^/a/([a-zA-Z0-9_-]+)\.html$~',$path,$m)) {
$a = Articles::where('sSlug',$m[1])->first(); return $a && Seo::indexable($a);
}
if ($path === '/') return app(ArticlesService::class)->getLatestFreeNodeSlug() !== null;
return true;
}));
$state = app(IndexNowNotification::class)->submit($state,$urls);
DB::table('seo_notifications')->where('id',$row->id)->update(['state'=>json_encode($state),'updated_at'=>now()]);
$processed++;
});
if ($processed >= $limit) break;
}
return $processed;
}
}

1
bootstrap/app.php Executable file → Normal file
View File

@ -12,6 +12,7 @@
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware->append(\App\Http\Middleware\SeoHeaders::class);
$middleware->prepend(\Spatie\ResponseCache\Middlewares\CacheResponse::class); $middleware->prepend(\Spatie\ResponseCache\Middlewares\CacheResponse::class);
$middleware->alias([ $middleware->alias([
'doCache' => \Spatie\ResponseCache\Middlewares\CacheResponse::class, 'doCache' => \Spatie\ResponseCache\Middlewares\CacheResponse::class,

7
config/seo.php Normal file
View File

@ -0,0 +1,7 @@
<?php
return [
'brand' => env('SEO_BRAND', env('site_code_short') === 'cfn' ? 'ClashFreeNode' : env('APP_NAME', '网站')),
'home_title' => env('SEO_HOME_TITLE', '免费节点与订阅使用指南'),
'home_description' => '获取最近更新的免费节点与订阅,查看 Clash、V2ray 客户端入门教程和连接排错指南。资源状态随时间和网络环境变化。',
'page_size' => 20,
];

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::table('articles', function (Blueprint $t) {
$t->timestamp('sContentUpdatedAt')->nullable();
$t->string('sSeoPolicy', 24)->nullable();
$t->string('sResourceStatus', 24)->nullable();
$t->unsignedInteger('iResourceCount')->nullable();
});
Schema::create('seo_notifications', function (Blueprint $t) {
$t->id(); $t->string('event_key', 64)->unique(); $t->text('urls');
$t->string('event', 16); $t->text('state'); $t->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('seo_notifications');
Schema::table('articles', fn (Blueprint $t) => $t->dropColumn(['sContentUpdatedAt','sSeoPolicy','sResourceStatus','iResourceCount']));
}
};

View File

@ -0,0 +1,41 @@
# FNA_V3 SEO 实施与发布 · 2026-10-31
用户授权:落实已讨论方案,并全权优化 FNA_V3 SEO。范围为正式 cfn 的增量;开发初始基线 `77f9b09c58b46ef2bbe0882ec89194bf25aa82cd`;上线前发现HN广告并行发布,最终基线改为 `a820755e7e914180178e6928f73d5b42b0b24c86`,4个并行文件均不在本批覆盖范围,经白色机到 VPS-003 `/www/wwwroot/clashfreenode-new.com`。本地隔离工作副本 `output/seo-20261031/stage`。本文件是实现和本批验收唯一正文,36项总状态仍在 [SEO_PLAN](SEO_PLAN.md)。
## 当前实现
- 首页 `/` 使用稳定标题/H1、WebSite、独立 canonical,显示最近可用记录全文及真实记录时间;明确零结果或失败不顶替上次记录,无可展示记录返回503/Retry-After。`/freenode` 是带历史说明的分页归档。旧的 `/?page=N` 定向到对应归档页;首页不是分页第一页。
- 新免费节点记录创建即 `sSeoPolicy=archive`,可浏览、noindex、退出sitemap;已有资源仍保留分类7天策略,教程不继承资源期限。没有批量删除、合并、301到首页或恢复历史收录。取消 ClearDb 的180天正文删除,30天点赞明细清理保留。
- `Seo` + `Articles::publicVisible` 统一公开、索引、URL、元信息。草稿、未来文章、隐藏分类或父分类在详情、列表、标签、搜索、推荐、sitemap中一致排除。已知平级子栏目一跳到真实父路径,错误父路径404,旧文章路径不变。Android/iOS纠正显示名称,保留原andorid路径。
- 合法分页每页20条、自引用canonical带页码,移除跟踪参数;page=1归一,0/负数/非数字/数组/重复/越界404。HTML上一页下一页,JS读取实际下一页链接,追加去重、更新末页导航。robots放行查询参数,保留非内容路径限制。
- 搜索使用当前站点数据库公开查询,转义LIKE通配字符,支持中文、空结果、无JS GET表单;不依赖过期的Meilisearch结果,也不删除/重建共享索引。现有发布索引副作用保留。普通浏览标签noindex,不做无流量证据的同义合并。
- 去掉全局“今天”简介;纯文本实体解码、空白清洗、标题转义。首页、栏目、标签、搜索和文章各有主题。分享复制当前规范文章URL,OG一致,绝对图片URL,增加Twitter和真实Article/BreadcrumbList/CollectionPage结构;不输出评分或伪造作者资历。
- 新增nullable `sContentUpdatedAt`:仅有实质内容变动时写入,点赞不改变。旧数据未知修改时间省略lastmod,不以updated_at冒充。新sitemap包含非空有效栏目、首页及可索引文章,排除日更新快照;栏目未建立可信历史时钟时不输出lastmod。
- 免费节点接收校验字段与结果,失败、零节点、空正文拒绝并保留旧文。兼容旧摘要中的明确数量,状态默认unknown;数量不等于客户端连接实测,未向上游伪造check字段。已被编辑API接管的教程拒绝旧接口覆盖。
- 原编辑API的持久IndexNow回执继续使用,实质更新通知文章与相关栏目;日更通知首页/栏目,取消无差别直接推送快照。新增 `seo_notifications` 出站表及业务服务器 `seo:indexnow-retry` 每5分钟最多10条、去重、5分钟到24小时退避;删除事件可以通知404地址,日志不含密钥。既有回执不自动补推,200/202仅代表接收。
- HTML包含会话/点赞和随时间变化策略,因此关闭整页响应缓存路径(原生产也已关闭);静态资源缓存继续使用。GET点赞不再写数据,改POST+CSRF。非内容请求输出noindex头,旧/test路由仅local注册。
- 保留教程横幅和既有广告;封面从CSS背景改为可发现img且保持原卡片比例,本地图片取真实尺寸;确认的福云/Hardnerd推广链接补sponsored,普通引用不一律nofollow。移动菜单增加可访问名称、展开状态、Escape关闭与焦点返回;无JS有导航和搜索后备。
- 不新增后台:本次设置由现有应用配置维护,后续若做可视管理沿ATOM规范。域名不变无需DomainHub变更;未新增TeleHub消息,原日更通知调用保持。
## 验证
- 独立SQLite、冻结时钟和HTTP替身,62项业务测试523断言通过,无警告(APP_URL显式提供)。包含原编辑发布/重试、插画交付与独立副本、年度点赞、新URL/可见性/时间/清理/IndexNow合同。未往业务库插入测试文章。
- Vite正式构建通过;JS gzip约80.25KB,主题CSS gzip141.79KB。仅性能体积基线,不是线上CWV成绩;未重写框架。
- 临时回环合成数据浏览器:从第2页滚动到第3页,26条且26个唯一ID、末页无next;390/320宽无横向溢出,菜单展开/键盘Escape;禁用JS仍有下一页链接。临时浏览器设置已复原。该验收不是生产页面验收。
- 生产验收与精确提交、恢复点见本文件发布记录;尚未发布时不能以以上本地证据代替。
## 保留边界与后续证据
历史正文、ID、URL、发布时间、插画关联和现有点赞设置不批量改动。旧资源7天noindex是过渡保留规则,不是给首页“转权重”的工具;未来按真实流量/外链与独立价值逐条处理。旧账号重复组保留既有noindex,不机械删文。既有客户端教程已经上线,本批不重写正文,未做客户端实机联网验证。
旧文章接收接口缺少入站认证;master01 `FnSitePublish::fn_b` 当前本地调用实现不带token,Nginx也未发现该路径专门认证。不得单方面启用token导致日更中断;需要在master01/FNA共同核对真实调用方并完成有兼容期的认证切换。当前编辑API认证保持,公网默认关闭。本批不把SEO发布宣称为旧接口安全问题全部解决。
未获取Search Console/Bing/Yandex的查询、外链、索引报告,不能据此承诺排名或声称历史URL没有流量;搜索平台效果、真实用户CWV、全部设备体验仍须后续数据观察。作者/运营反馈资料不虚构,现有点赞业务按用户此前明确设定保留,未作为搜索结构化评分。
## 回滚合同
代码回到本批base;只恢复本批覆盖的构建文件及缓存。新增列/出站表可保留,不还原整库覆盖发布后新文章。若需要撤销迁移,先确认无新写入并导出出站状态;默认不执行down。保留并行generated-tags-cloud、data.ms和public/images/editorial,禁止reset --hard/clean。不重启共享PHP-FPM;只定点失效本站变更文件OPcache。
## 发布记录
状态:本地实现及验收已完成,正在准备Git发布;尚未切换正式实例。

View File

@ -1,3 +1,5 @@
> 2026-10-31最新状态:已完成本批技术实现和本地验收,正式发布进行中。实现、限制及精确发布证据见 [SEO实施记录](SEO_IMPLEMENTATION_2026-10-31.md)。原审计的“未实施/未发布”仅描述其当时状态。
# FNA_V3 SEO 优化建议与执行清单 # FNA_V3 SEO 优化建议与执行清单
评估日期:2026-10-22,Asia/Shanghai。状态:**分析与建议文档已完成;SEO-001 的部署定位已由用户确认,配置改造尚未实施;SEO-020 于2026-10-27完成本地教程正文更新,设备实测及生产验收未做,D02首页最新免费节点全文展示于2026-10-27本地完成,其余项目按分项记录;2026-10-31已发布编辑文章IndexNow通知与两个教程栏目sitemap(见SEO-013/033)**。本文件是后续 SEO 工作的唯一执行清单;按 `SEO-001` 至 `SEO-036` 跟踪,实施后在本文件更新结果,不另建重复的“最新版计划”。 评估日期:2026-10-22,Asia/Shanghai。状态:**分析与建议文档已完成;SEO-001 的部署定位已由用户确认,配置改造尚未实施;SEO-020 于2026-10-27完成本地教程正文更新,设备实测及生产验收未做,D02首页最新免费节点全文展示于2026-10-27本地完成,其余项目按分项记录;2026-10-31已发布编辑文章IndexNow通知与两个教程栏目sitemap(见SEO-013/033)**。本文件是后续 SEO 工作的唯一执行清单;按 `SEO-001` 至 `SEO-036` 跟踪,实施后在本文件更新结果,不另建重复的“最新版计划”。
@ -94,7 +96,7 @@ ### 2.4 关键因果关系
## 3 实施前需要确定的产品决策 ## 3 实施前需要确定的产品决策
D01、D02 已由用户确认;其余条目仍是建议,未因这些确认而自动获批。 D01、D02早期已确认;2026-10-31用户确认讨论方案并授权全面优化。历史去留仍须逐条依据内容/流量证据,不授权无差别删除。
| 决策 | 方案与确认状态 | 为什么 | 影响范围 | | 决策 | 方案与确认状态 | 为什么 | 影响范围 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -106,7 +108,7 @@ ## 3 实施前需要确定的产品决策
| D06 渠道和目标 | 首先定义有效自然访问和实际使用完成率,Google 为初始技术基线;其他渠道按目标市场补充 | 没有搜索量和 Search Console 数据时不能编造关键词机会或流量预测 | 002、035 | | D06 渠道和目标 | 首先定义有效自然访问和实际使用完成率,Google 为初始技术基线;其他渠道按目标市场补充 | 没有搜索量和 Search Console 数据时不能编造关键词机会或流量预测 | 002、035 |
| D07 样式边界 | 保持既有视觉语言和已批准广告方案;标题语义、链接、数据真实性先行;新增可见信息或调整广告占比单独评审 | SEO 改动不默认授权重设计整站或移除已批准广告 | 010、023、027 | | D07 样式边界 | 保持既有视觉语言和已批准广告方案;标题语义、链接、数据真实性先行;新增可见信息或调整广告占比单独评审 | SEO 改动不默认授权重设计整站或移除已批准广告 | 010、023、027 |
D01 的部署角色已明确。具体生产映射、新增实例清单以及多站相同内容的收录归属仍需在相应实施项确定;不阻塞本地分页、URL 规则和元数据开发。用户尚未授权本次上线或修改现有 cfn。 D01 的部署角色已明确。具体生产映射、新增实例清单以及多站相同内容的收录归属仍需在相应实施项确定;不阻塞本地分页、URL 规则和元数据开发。该段为2026-10-22审计历史;2026-10-31用户已授权全面落实,当前发布状态见本批实施记录。
## 4 建议采用的 URL 与索引规则 ## 4 建议采用的 URL 与索引规则
@ -166,46 +168,46 @@ ### 4.3 内容模型的最小改造方向
## 5 执行总表 ## 5 执行总表
所有项目当前均未实施。依赖列用于确定顺序,不是要求一次性同时完成全部项目。 2026-10-31用户授权全权落实。最新实现和验证见[本批实施记录](SEO_IMPLEMENTATION_2026-10-31.md);下方历史审计描述保留日期语义,以下总表以本次状态为准。
| 完成 | 编号 | 项目 | 优先级 | 主要依赖 | 当前性质 | | 完成 | 编号 | 项目 | 优先级 | 主要依赖 | 当前性质 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| [ ] | SEO-001 | 明确公开身份、主域名和环境边界 | P0 发布前 | D01 | 部署定位已确认,配置待实施 | | [~] | SEO-001 | 明确公开身份、主域名和环境边界 | P0 发布前 | D01 | 本地已验收,待本批发布 |
| [ ] | SEO-002 | 确定搜索意图、目标用户和效果基线 | P1 | D06 | 待决策/待数据 | | [~] | SEO-002 | 确定搜索意图、目标用户和效果基线 | P1 | D06 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-003 | 确定内容生命周期与索引资格 | P0 策略 | D03、D04 | 待决策 | | [~] | SEO-003 | 确定内容生命周期与索引资格 | P0 策略 | D03、D04 | 本地已验收,待本批发布 |
| [ ] | SEO-004 | 统一规范 URL 和历史别名 | P0 | D02 | 已确认 | | [~] | SEO-004 | 统一规范 URL 和历史别名 | P0 | D02 | 本地已验收,待本批发布 |
| [ ] | SEO-005 | 修复分页发现与滚动加载 | P0 | 004 | 已确认 | | [~] | SEO-005 | 修复分页发现与滚动加载 | P0 | 004 | 本地已验收,待本批发布 |
| [ ] | SEO-006 | 协调 robots、参数和 noindex | P0 | 003、005 | 已确认 | | [~] | SEO-006 | 协调 robots、参数和 noindex | P0 | 003、005 | 本地已验收,待本批发布 |
| [ ] | SEO-007 | 修复无效分页、空结果与错误状态 | P0 | 004、005 | 已确认 | | [~] | SEO-007 | 修复无效分页、空结果与错误状态 | P0 | 004、005 | 本地已验收,待本批发布 |
| [ ] | SEO-008 | 统一公开可见性与发布时间判断 | P0 发布前 | 003 | 条件性风险 | | [~] | SEO-008 | 统一公开可见性与发布时间判断 | P0 发布前 | 003 | 本地已验收,待本批发布 |
| [ ] | SEO-009 | 修复面包屑和全站内链地址 | P1 | 004 | 已确认 | | [~] | SEO-009 | 修复面包屑和全站内链地址 | P1 | 004 | 本地已验收,待本批发布 |
| [ ] | SEO-010 | 分类、标签、搜索的标题与语义层级 | P1 | 001、004 | 已确认 | | [~] | SEO-010 | 分类、标签、搜索的标题与语义层级 | P1 | 001、004 | 本地已验收,待本批发布 |
| [ ] | SEO-011 | 生成真实、干净的页面简介 | P1 | 010 | 已确认 | | [~] | SEO-011 | 生成真实、干净的页面简介 | P1 | 010 | 本地已验收,待本批发布 |
| [ ] | SEO-012 | 分离真实内容日期并统一时间基准 | P1 | 003 | 已确认/条件性风险 | | [~] | SEO-012 | 分离真实内容日期并统一时间基准 | P1 | 003 | 本地已验收,待本批发布 |
| [ ] | SEO-013 | 统一 sitemap 的集合和 lastmod | P1 | 003、004、008、012 | 部分实施:教程栏目,见正文 | | [~] | SEO-013 | 统一 sitemap 的集合和 lastmod | P1 | 003、004、008、012 | 本地已验收,待本批发布 |
| [ ] | SEO-014 | 建立长期内链和内容导航 | P1 | 005、009、018 | 已确认/策略建议 | | [~] | SEO-014 | 建立长期内链和内容导航 | P1 | 005、009、018 | 本地已验收,待本批发布 |
| [ ] | SEO-015 | 整理标签与重复主题页 | P1 | 002、003 | 策略建议 | | [~] | SEO-015 | 整理标签与重复主题页 | P1 | 002、003 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-016 | 隔离并同步本站搜索索引 | P1 | 008、032 | 已确认 | | [~] | SEO-016 | 隔离并同步本站搜索索引 | P1 | 008、032 | 本地已验收,待本批发布 |
| [ ] | SEO-017 | 建立空资源与检测失败的发布门槛 | P0 内容 | 003 | 已确认/待上游核实 | | [~] | SEO-017 | 建立空资源与检测失败的发布门槛 | P0 内容 | 003 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-018 | 建立稳定资源入口和日更归档策略 | P1 | 002、003、004、017 | 策略建议 | | [~] | SEO-018 | 建立稳定资源入口和日更归档策略 | P1 | 002、003、004、017 | 本地已验收,待本批发布 |
| [~] | SEO-019 | 处理真实重复文章与跨平台内容(本地Mac/Linux教程已区分) | P1 | 003、004 | 已确认 | | [~] | SEO-019 | 处理真实重复文章与跨平台内容(本地Mac/Linux教程已区分) | P1 | 003、004 | 按本批记录分项保留证据缺口 |
| [~] | SEO-020 | 提升客户端教程的任务完成能力(本地全栏目正文完成,实机/生产未验收) | P1 | 002、019 | 已确认/内容建议 | | [~] | SEO-020 | 提升客户端教程的任务完成能力(本地全栏目正文完成,实机/生产未验收) | P1 | 002、019 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-021 | 明确来源、验证和跨站内容差异 | P1 | 017、020 | 策略建议 | | [~] | SEO-021 | 明确来源、验证和跨站内容差异 | P1 | 017、020 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-022 | 作者、编辑责任与真实互动数据 | P1 | 001、012 | 已确认/策略建议 | | [~] | SEO-022 | 作者、编辑责任与真实互动数据 | P1 | 001、012 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-023 | 推广链接、广告与正文体验 | P1 | D07 | 部分已具备,仍有缺口 | | [~] | SEO-023 | 推广链接、广告与正文体验 | P1 | D07 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-024 | 添加匹配真实内容的结构化数据 | P2 | 009、010、012、022 | 增强项 | | [~] | SEO-024 | 添加匹配真实内容的结构化数据 | P2 | 009、010、012、022 | 本地已验收,待本批发布 |
| [ ] | SEO-025 | 修复分享地址和社交预览信息 | P2 | 001、004、011 | 已确认 | | [~] | SEO-025 | 修复分享地址和社交预览信息 | P2 | 001、004、011 | 本地已验收,待本批发布 |
| [ ] | SEO-026 | 图片语义、尺寸和加载方式 | P2 | 020、028 | 已确认/待测量 | | [~] | SEO-026 | 图片语义、尺寸和加载方式 | P2 | 020、028 | 本地已验收,待本批发布 |
| [ ] | SEO-027 | 手机端与无障碍完整验收 | P2 | 005、010 | 部分样本通过 | | [~] | SEO-027 | 手机端与无障碍完整验收 | P2 | 005、010 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-028 | 建立正式构建性能基线并按瓶颈优化 | P2 | 026、027 | 待测量 | | [~] | SEO-028 | 建立正式构建性能基线并按瓶颈优化 | P2 | 026、027 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-029 | 缓存与发布、撤稿、到期一致性 | P1 启用前 | 003、008、012 | 条件性风险 | | [~] | SEO-029 | 缓存与发布、撤稿、到期一致性 | P1 启用前 | 003、008、012 | 本地已验收,待本批发布 |
| [ ] | SEO-030 | 文章接收、更新与删除的发布合同 | P0 发布前 | 003、008、017、029 | 已确认/条件性风险 | | [~] | SEO-030 | 文章接收、更新与删除的发布合同 | P0 发布前 | 003、008、017、029 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-031 | 正式主机、HTTPS 与 HTTP 服务质量 | P0 发布前 | 001、004 | 待部署验证 | | [~] | SEO-031 | 正式主机、HTTPS 与 HTTP 服务质量 | P0 发布前 | 001、004 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-032 | 本地、预发布和生产资源隔离 | P0 发布前 | 001 | 已确认/条件性风险 | | [~] | SEO-032 | 本地、预发布和生产资源隔离 | P0 发布前 | 001 | 本地已验收,待本批发布 |
| [ ] | SEO-033 | 规范 IndexNow 及搜索提交 | P2 | 003、004、030、032 | 部分实施:编辑发布通知,见正文 | | [~] | SEO-033 | 规范 IndexNow 及搜索提交 | P2 | 003、004、030、032 | 本地已验收,待本批发布 |
| [ ] | SEO-034 | SEO 自动回归与数据样例 | P1 | 跟随各批次 | 当前测试缺口 | | [~] | SEO-034 | SEO 自动回归与数据样例 | P1 | 跟随各批次 | 本地已验收,待本批发布 |
| [ ] | SEO-035 | 搜索平台与业务效果监测 | P1 上线后 | 001、002、036 | 待线上数据 | | [~] | SEO-035 | 搜索平台与业务效果监测 | P1 上线后 | 001、002、036 | 按本批记录分项保留证据缺口 |
| [ ] | SEO-036 | 发布、迁移、回滚与复核 | P0 发布时 | 前述必需项 | 待发布授权 | | [~] | SEO-036 | 发布、迁移、回滚与复核 | P0 发布时 | 前述必需项 | 按本批记录分项保留证据缺口 |
## 6 各项优化的实施说明 ## 6 各项优化的实施说明

File diff suppressed because one or more lines are too long

View File

@ -1,19 +1,19 @@
{ {
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2": { "../../../node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2": {
"file": "assets/fa-brands-400-BfBXV7Mm.woff2", "file": "assets/fa-brands-400-BfBXV7Mm.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2" "src": "../../../node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2"
}, },
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2": { "../../../node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2": {
"file": "assets/fa-regular-400-BVHPE7da.woff2", "file": "assets/fa-regular-400-BVHPE7da.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2" "src": "../../../node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2"
}, },
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2": { "../../../node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2": {
"file": "assets/fa-solid-900-8GirhLYJ.woff2", "file": "assets/fa-solid-900-8GirhLYJ.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2" "src": "../../../node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2"
}, },
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-v4compatibility.woff2": { "../../../node_modules/@fortawesome/fontawesome-free/webfonts/fa-v4compatibility.woff2": {
"file": "assets/fa-v4compatibility-DnhYSyY-.woff2", "file": "assets/fa-v4compatibility-DnhYSyY-.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-v4compatibility.woff2" "src": "../../../node_modules/@fortawesome/fontawesome-free/webfonts/fa-v4compatibility.woff2"
}, },
"resources/css/app.css": { "resources/css/app.css": {
"file": "assets/app-BCx10ygF.css", "file": "assets/app-BCx10ygF.css",
@ -28,7 +28,7 @@
"src": "resources/fonts/iconfont/iconfont.woff" "src": "resources/fonts/iconfont/iconfont.woff"
}, },
"resources/js/app.js": { "resources/js/app.js": {
"file": "assets/app-DDSp2ths.js", "file": "assets/app-VvJM4xzJ.js",
"name": "app", "name": "app",
"src": "resources/js/app.js", "src": "resources/js/app.js",
"isEntry": true "isEntry": true

1
public/css/seo.css Normal file
View File

@ -0,0 +1 @@
.seo-card-image{display:block;width:100%;height:100%;object-fit:cover}.seo-pagination{display:flex;gap:1.5rem;align-items:center;justify-content:center}.resource-context p:last-child{margin-bottom:0}.seo-noscript-nav{display:flex;flex-wrap:wrap;gap:1rem;padding:1rem}.post-content img{height:auto;max-width:100%}a:focus-visible,button:focus-visible{outline:2px solid currentColor;outline-offset:3px}

49
resources/js/app.js Executable file → Normal file
View File

@ -7,7 +7,6 @@ import ClipboardJS from 'clipboard';
const initHeaderScroll = (sHeaderSelector) => { const initHeaderScroll = (sHeaderSelector) => {
console.log('检查点1:函数跑起来了,选择器是:');
const $oHeader = $(sHeaderSelector); const $oHeader = $(sHeaderSelector);
const iHeaderHeight = $oHeader.outerHeight(); const iHeaderHeight = $oHeader.outerHeight();
const oRoot = document.documentElement; const oRoot = document.documentElement;
@ -17,7 +16,6 @@ const initHeaderScroll = (sHeaderSelector) => {
const iThreshold = 5; // 加上一个细微的阈值,防止手抖产生的位移 const iThreshold = 5; // 加上一个细微的阈值,防止手抖产生的位移
$(window).on('scroll', () => { $(window).on('scroll', () => {
console.log('检查点3:你在滚,我也在动'); // 看看控制台刷不刷屏
let iCurrentScroll = $(window).scrollTop(); let iCurrentScroll = $(window).scrollTop();
// 1. 忽略极小的滚动(防止灵敏度过高) // 1. 忽略极小的滚动(防止灵敏度过高)
@ -47,26 +45,11 @@ const initHeaderScroll = (sHeaderSelector) => {
const initArticleLoader = (sContainerSelector) => { const initArticleLoader = (sContainerSelector) => {
const $oContainer = $(sContainerSelector); const $oContainer = $(sContainerSelector);
if ($oContainer.length === 0) return; if ($oContainer.length === 0 || !document.querySelector('.pagination-next')) return;
const oInfScroll = new InfiniteScroll($oContainer[0], { const oInfScroll = new InfiniteScroll($oContainer[0], {
// 使用函数式 path,灵活得飞起 path: '.pagination-next',
path: function() { checkLastPage: true,
let sUrl = window.location.origin + window.location.pathname; // 当前基础路径
let sSearch = window.location.search; // 当前已有的参数(比如 ?s=关键字)
let iNextPage = this.loadCount + 2; // InfiniteScroll 内部计数从0开始,第二页就是2
// 原则:判断当前 URL 是否已经有了其他参数
if (sSearch) {
// 如果已有参数,判断是否带 page,没有就拼 &page=,有就替换(正则处理最稳)
if (sSearch.indexOf('page=') > -1) {
return sSearch.replace(/page=\d+/, 'page=' + iNextPage);
}
return sSearch + '&page=' + iNextPage;
} else {
return '?page=' + iNextPage;
}
},
append: '.ajax-item', append: '.ajax-item',
history: false, history: false,
// prefill: true, // prefill: true,
@ -75,7 +58,14 @@ const initArticleLoader = (sContainerSelector) => {
responseBody: 'text', responseBody: 'text',
}); });
$oContainer.on('append.infiniteScroll', (oEvent, xResponse, sPath, aItems) => { oInfScroll.on('append', (xResponse, sPath, aItems) => {
const seen = new Set();
document.querySelectorAll('.ajax-item[data-article-id]').forEach(item => {
if (seen.has(item.dataset.articleId)) item.remove(); else seen.add(item.dataset.articleId);
});
const nextDoc = typeof xResponse === 'string' ? new DOMParser().parseFromString(xResponse, 'text/html') : xResponse;
const nav = nextDoc?.querySelector('.seo-pagination');
if (nav) document.querySelector('.seo-pagination').replaceWith(nav.cloneNode(true));
const $aNewItems = $(aItems); const $aNewItems = $(aItems);
$aNewItems.addClass('ajax-item-fade-in'); $aNewItems.addClass('ajax-item-fade-in');
@ -84,7 +74,6 @@ const initArticleLoader = (sContainerSelector) => {
$aNewItems.addClass('is-visible'); $aNewItems.addClass('is-visible');
}, 50); }, 50);
console.log(`[ArticleLoader] s-Hungarian 风格追加成功: ${sPath}`);
}); });
window.oMainInfScroll = oInfScroll; window.oMainInfScroll = oInfScroll;
@ -117,7 +106,8 @@ $(() => {
$this.addClass("requesting"); $this.addClass("requesting");
$.ajax({ $.ajax({
url: "/like/"+id, url: "/like/"+id,
type: "GET", type: "POST",
headers: {"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content},
dataType: "json", dataType: "json",
}).done(function(data) { }).done(function(data) {
ncPopupTips(1, data.msg); ncPopupTips(1, data.msg);
@ -135,9 +125,12 @@ $(() => {
return false; return false;
}); });
$(document).on('keydown', function(event) {
if (event.key === 'Escape' && $('.mobile-sidebar').hasClass('active')) $('.sidebar-close').trigger('click');
});
initHeaderScroll('#header'); initHeaderScroll('#header');
document.getElementById('searchform').addEventListener('submit', function(e) { document.getElementById('searchform')?.addEventListener('submit', function(e) {
e.preventDefault(); // 拦截默认提交 e.preventDefault(); // 拦截默认提交
let sKeywords = document.getElementById('s').value; let sKeywords = document.getElementById('s').value;
if (sKeywords.trim() !== '') { if (sKeywords.trim() !== '') {
@ -285,7 +278,6 @@ $(() => {
const bIsInitialized = fnTryInit(oSettings, oObjects); const bIsInitialized = fnTryInit(oSettings, oObjects);
if (!bIsInitialized) { if (!bIsInitialized) {
console.log("TSS: Body width smaller than options.minWidth. Init delayed.");
$(document).on("scroll." + oSettings.sNamespace, function (e) { $(document).on("scroll." + oSettings.sNamespace, function (e) {
if (fnTryInit(oSettings, oObjects)) { if (fnTryInit(oSettings, oObjects)) {
@ -484,12 +476,15 @@ $(() => {
function toggleSidebar() { function toggleSidebar() {
$(".sidebar-close, .mobile-overlay").on("click", function() { $(".sidebar-close, .mobile-overlay").on("click", function() {
$("body").removeClass("modal-open"); $("body").removeClass("modal-open");
$(".mobile-sidebar").removeClass("active"); $(".mobile-sidebar").removeClass("active").attr("aria-hidden", "true");
$("#sidebarCollapse").attr("aria-expanded", "false").trigger("focus");
$(".mobile-overlay").removeClass("active") $(".mobile-overlay").removeClass("active")
}); });
$("#sidebarCollapse").on("click", function() { $("#sidebarCollapse").on("click", function() {
$("body").addClass("modal-open"); $("body").addClass("modal-open");
$(".mobile-sidebar").addClass("active"); $(".mobile-sidebar").addClass("active").attr("aria-hidden", "false");
$("#sidebarCollapse").attr("aria-expanded", "true");
$(".sidebar-close").trigger("focus");
$(".mobile-overlay").addClass("active"); $(".mobile-overlay").addClass("active");
$(".collapse.in").toggleClass("in"); $(".collapse.in").toggleClass("in");
$("a[aria-expanded=true]").attr("aria-expanded", "false") $("a[aria-expanded=true]").attr("aria-expanded", "false")

34
resources/views/components/app-layout.blade.php Executable file → Normal file
View File

@ -7,8 +7,17 @@
'sOgImg' => '/sites/'.config("app.site_code_short").'/siteimg.jpg', 'sOgImg' => '/sites/'.config("app.site_code_short").'/siteimg.jpg',
'sRobots' => 'index, follow', 'sRobots' => 'index, follow',
'bEditorialPreview' => false, 'bEditorialPreview' => false,
'seo' => [],
]) ])
@php
$sTitle = $seo['title'] ?? $sTitle;
$sDesc = \App\Services\Seo::text($seo['description'] ?? $sDesc);
$sCanonical = $seo['canonical'] ?? \App\Services\Seo::url(request()->path());
$sRobots = $bEditorialPreview ? 'noindex, nofollow' : ($seo['robots'] ?? $sRobots);
$sOgType = $seo['type'] ?? $sOgType;
$sOgImg = str_starts_with($sOgImg ?? '', '/') ? \App\Services\Seo::url($sOgImg) : $sOgImg;
@endphp
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
@ -17,25 +26,34 @@
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="slug" content="{{ $sSlug }}"> <meta name="slug" content="{{ $sSlug }}">
<meta name="robots" content="{{ app()->environment('staging') ? 'noindex, nofollow' : $sRobots }}"> <meta name="robots" content="{{ !app()->environment(['production','testing']) ? 'noindex, nofollow' : $sRobots }}">
@php
$sTodayDate = now()->format('Y年m月d日'); //有问题?
@endphp
{{-- SEO 三杰 --}} {{-- SEO 三杰 --}}
<title>{{ $sTitle }}</title> <title>{{ $sTitle }}</title>
<meta name="description" content="{{ $sTodayDate.$sDesc }}"> <meta name="description" content="{{ $sDesc }}">
<meta name="keywords" content="{{ $sKeywords }}"> <meta name="keywords" content="{{ $sKeywords }}">
<link rel="canonical" href="{{ url()->current() }}"> <link rel="canonical" href="{{ $sCanonical }}">
{{-- 社交媒体 Meta (Open Graph) - 现在的标准配置 --}} {{-- 社交媒体 Meta (Open Graph) - 现在的标准配置 --}}
<meta property="og:title" content="{{ $sTitle }}"> <meta property="og:title" content="{{ $sTitle }}">
<meta property="og:description" content="{{ $sDesc }}"> <meta property="og:description" content="{{ $sDesc }}">
<meta property="og:type" content="{{ $sOgType }}"> <meta property="og:type" content="{{ $sOgType }}">
<meta property="og:url" content="{{ url()->current() }}"> <meta property="og:url" content="{{ $sCanonical }}">
<meta property="og:image" content="{{ str_starts_with($sOgImg ?? '', '/') ? url($sOgImg) : $sOgImg }}"> <meta property="og:image" content="{{ str_starts_with($sOgImg ?? '', '/') ? url($sOgImg) : $sOgImg }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $sTitle }}">
<meta name="twitter:description" content="{{ $sDesc }}">
<meta name="twitter:image" content="{{ $sOgImg }}">
@unless($bEditorialPreview)
@foreach($seo['schema'] ?? [] as $schema)
<script type="application/ld+json">{!! json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) !!}</script>
@endforeach
@endunless
<link rel="stylesheet" href="/css/seo.css">
{{-- 声明它是 PNG,并告诉浏览器它的实际尺寸 --}} {{-- 声明它是 PNG,并告诉浏览器它的实际尺寸 --}}
<link rel="icon" type="image/png" href="/sites/{{ config('app.site_code_short') }}/icon.png" sizes="166x166"> <link rel="icon" type="image/png" href="/sites/{{ config('app.site_code_short') }}/icon.png" sizes="166x166">
@ -49,7 +67,7 @@
@stack('styles') @stack('styles')
@if(! app()->environment('staging') && ! $bEditorialPreview && config('app.site_code_short') == 'cfn') @if(app()->environment('production') && ! $bEditorialPreview && config('app.site_code_short') == 'cfn')
<script defer src="https://umami.gemcook.net/script.js" data-website-id="c553e118-942e-4d6b-9e6d-9d7e696ab5fd"></script> <script defer src="https://umami.gemcook.net/script.js" data-website-id="c553e118-942e-4d6b-9e6d-9d7e696ab5fd"></script>
@endif @endif

14
resources/views/components/article-item.blade.php Executable file → Normal file
View File

@ -1,13 +1,13 @@
@props(['oArticle']) @props(['oArticle'])
<div class="list-item default block ajax-item"> <div class="list-item default block ajax-item" data-article-id="{{ $oArticle->id }}">
<div class="media media-3x2 col-4 col-md-3 col-xl-4"> <div class="media media-3x2 col-4 col-md-3 col-xl-4">
<a class="media-content" href="{{ url('/a/'.$oArticle->sSlug).'.html' }}" title="{{ $oArticle->sTitle }}" style="background-image:url('{{ $oArticle->sThumb }}')"></a> <a class="media-content" href="{{ url('/a/'.$oArticle->sSlug).'.html' }}" title="{{ $oArticle->sTitle }}" >@if($oArticle->sThumb)<img class="seo-card-image" src="{{ $oArticle->sThumb }}" alt="" @foreach(\App\Services\Seo::imageSize($oArticle->sThumb) as $dimension => $value) {{ $dimension }}="{{ $value }}" @endforeach loading="lazy" decoding="async">@endif</a>
</div> </div>
<div class="list-content"> <div class="list-content">
<div class="list-body"> <div class="list-body">
<a href=" {{ url('/a/'.$oArticle->sSlug.'.html') }} " title="{{$oArticle->sTitle}}" class="list-title h5 h-2x"> <h2 class="h5 m-0"><a href="{{ url('/a/'.$oArticle->sSlug.'.html') }}" title="{{$oArticle->sTitle}}" class="list-title h5 h-2x">
{!! $oArticle->sTitle !!} {{ $oArticle->sTitle }}
</a> </a></h2>
<div class="list-desc d-none d-xl-block text-secondary text-sm mt-2"> <div class="list-desc d-none d-xl-block text-secondary text-sm mt-2">
<div class="h-2x"> <div class="h-2x">
{!! $oArticle->sTitleSub !!} {!! $oArticle->sTitleSub !!}
@ -16,13 +16,13 @@
</div> </div>
<div class="list-footer"> <div class="list-footer">
<div class="d-flex flex-fill align-items-center text-xs text-muted"> <div class="d-flex flex-fill align-items-center text-xs text-muted">
<a href="{{ url($oArticle->oCategory->sSlug) }}" class="me-2 me-md-3"><i class="text-sm iconfont icon-dots-circle me-1"></i>{!! $oArticle->oCategory->sName !!}</a> <a href="{{ \App\Services\Seo::url(\App\Services\Seo::categoryPath($oArticle->oCategory)) }}" class="me-2 me-md-3"><i class="text-sm iconfont icon-dots-circle me-1"></i>{{ \App\Services\Seo::categoryName($oArticle->oCategory) }}</a>
<div class="flex-fill"></div> <div class="flex-fill"></div>
@if ($oArticle->iIsTop) @if ($oArticle->iIsTop)
<span class="d-none d-md-inline-block ms-2 ms-md-3" style="color: var(--color-primary);">置顶</span> <span class="d-none d-md-inline-block ms-2 ms-md-3" style="color: var(--color-primary);">置顶</span>
<!-- <i class="text-sm iconfont icon-pushpin me-1" style="color: var(--color-primary);"></i> --> <!-- <i class="text-sm iconfont icon-pushpin me-1" style="color: var(--color-primary);"></i> -->
@endif @endif
<time class="d-none d-md-inline-block ms-2 ms-md-3">{{ $oArticle->sCreatedAtDiff }}</time> <time datetime="{{ $oArticle->created_at?->toAtomString() }}" class="d-none d-md-inline-block ms-2 ms-md-3">{{ $oArticle->sCreatedAtDiff }}</time>
</div> </div>
</div> </div>
</div> </div>

4
resources/views/components/footer.blade.php Executable file → Normal file
View File

@ -1,14 +1,14 @@
<div id="sentinel" style="height: 20px;"></div> <div id="sentinel" style="height: 20px;"></div>
<footer class="footer bg-white py-3 py-md-4"> <footer class="footer bg-white py-3 py-md-4">
<div class="container text-center text-lg-start text-xs text-muted"> <div class="container text-center text-lg-start text-xs text-muted">
Copyright © 2024 <a href="/" title="{{config('app.name')}}">{{env('APP_NAME')}}</a>. Copyright © 2024 <a href="/" title="{{config('seo.brand')}}">{{config('seo.brand')}}</a>.
</div> </div>
</footer> </footer>
{{-- Fixed Menu --}} {{-- Fixed Menu --}}
<ul class="scroll-fixed-menu"> <ul class="scroll-fixed-menu">
<li id="scrollToTOP" class="scroll-to-top"> <li id="scrollToTOP" class="scroll-to-top">
<button class="btn btn-primary btn-icon btn-rounded"> <button aria-label="回到顶部" class="btn btn-primary btn-icon btn-rounded">
<span><i class="iconfont icon-arrowup"></i></span> <span><i class="iconfont icon-arrowup"></i></span>
</button> </button>
</li> </li>

30
resources/views/components/nav-bar.blade.php Executable file → Normal file
View File

@ -3,18 +3,18 @@
<div class="container"> <div class="container">
<a href="/" rel="home" class="logo navbar-brand order-2 order-xl-1"> <a href="/" rel="home" class="logo navbar-brand order-2 order-xl-1">
<img style="border-radius:8px;" src="/sites/{{ config('app.site_code_short') }}/images/logo/logo.png" <img style="border-radius:8px;" src="/sites/{{ config('app.site_code_short') }}/images/logo/logo.png"
class="d-inline-block logo-light nc-no-lazy" @foreach(\App\Services\Seo::imageSize("/sites/".config("app.site_code_short")."/images/logo/logo.png") as $dimension => $value) {{ $dimension }}="{{ $value }}" @endforeach class="d-inline-block logo-light nc-no-lazy"
alt="{{ config('app.name') }}"> alt="{{ config('seo.brand') }}">
<img style="border-radius:8px;" src="/sites/{{ config('app.site_code_short') }}/images/logo/logo.png" <img style="border-radius:8px;" src="/sites/{{ config('app.site_code_short') }}/images/logo/logo.png"
class="d-none logo-dark nc-no-lazy" @foreach(\App\Services\Seo::imageSize("/sites/".config("app.site_code_short")."/images/logo/logo.png") as $dimension => $value) {{ $dimension }}="{{ $value }}" @endforeach class="d-none logo-dark nc-no-lazy"
alt="{{ config('app.name') }}"> alt="{{ config('seo.brand') }}">
</a> </a>
<button class="btn btn-link btn-icon btn-sm btn-rounded navbar-toggler order-1" type="button" <button class="btn btn-link btn-icon btn-sm btn-rounded navbar-toggler order-1" type="button"
id="sidebarCollapse"> id="sidebarCollapse" aria-label="打开导航菜单" aria-controls="mobile-navigation" aria-expanded="false">
<span><i class="iconfont icon-menu"></i></span> <span><i class="iconfont icon-menu"></i></span>
</button> </button>
<button class="btn btn-link btn-icon btn-sm btn-rounded navbar-toggler nav-search order-3 collapsed" <button class="btn btn-link btn-icon btn-sm btn-rounded navbar-toggler nav-search order-3 collapsed"
data-bs-target="#navbar-search" data-bs-toggle="collapse" aria-expanded="false" aria-label="搜索本站" data-bs-target="#navbar-search" data-bs-toggle="collapse" aria-expanded="false"
aria-controls="navbar-search"> aria-controls="navbar-search">
<span> <span>
<i class="iconfont icon-search"></i> <i class="iconfont icon-search"></i>
@ -34,14 +34,14 @@ class="d-none logo-dark nc-no-lazy"
<li class=" menu-item menu-item-type-taxonomy menu-item-object-category current-post-ancestor current-menu-parent current-post-parent {{ $oNav->aChildren->isNotEmpty() ? 'menu-item-has-children' : '' }}"> <li class=" menu-item menu-item-type-taxonomy menu-item-object-category current-post-ancestor current-menu-parent current-post-parent {{ $oNav->aChildren->isNotEmpty() ? 'menu-item-has-children' : '' }}">
<a class="menua {{ $sActiveAttr }}" href="{{ url($oNav->sSlug) }}"> <a class="menua {{ $sActiveAttr }}" href="{{ url($oNav->sSlug) }}">
<i class="{{ $oNav->sHtmlClass }}"></i> <i class="{{ $oNav->sHtmlClass }}"></i>
{{ $oNav->sName }} {{ \App\Services\Seo::categoryName($oNav) }}
</a> </a>
@if($oNav->aChildren->isNotEmpty()) @if($oNav->aChildren->isNotEmpty())
<div class="submenu-arrow iconfont icon-jiantou1"></div> <div class="submenu-arrow iconfont icon-jiantou1"></div>
<ul class="sub-menu"> <ul class="sub-menu">
@foreach($oNav->aChildren as $oSub) @foreach($oNav->aChildren as $oSub)
<li class="menu-item menu-item-type-taxonomy menu-item-object-category"><a class="menuaSub" href="{{ url($oNav->sSlug.'/'.$oSub->sSlug) }}">{{ $oSub->sName }}</a></li> <li class="menu-item menu-item-type-taxonomy menu-item-object-category"><a class="menuaSub" href="{{ url($oNav->sSlug.'/'.$oSub->sSlug) }}">{{ \App\Services\Seo::categoryName($oSub) }}</a></li>
@endforeach @endforeach
</ul> </ul>
@endif @endif
@ -60,7 +60,7 @@ class="d-none logo-dark nc-no-lazy"
</a> </a>
<button class="btn btn-link btn-icon btn-sm btn-rounded nav-search collapsed" <button class="btn btn-link btn-icon btn-sm btn-rounded nav-search collapsed"
data-bs-toggle="collapse" data-bs-target="#navbar-search" aria-expanded="false" data-bs-toggle="collapse" aria-label="搜索本站" data-bs-target="#navbar-search" aria-expanded="false"
aria-controls="navbar-search"> aria-controls="navbar-search">
<span> <span>
<i class="iconfont icon-search"></i> <i class="iconfont icon-search"></i>
@ -72,10 +72,10 @@ class="d-none logo-dark nc-no-lazy"
</div> </div>
</div> </div>
</nav> </nav>
<div class="mobile-sidebar"> <div class="mobile-sidebar" id="mobile-navigation" aria-hidden="true">
<div class="mobile-sidebar-inner"> <div class="mobile-sidebar-inner">
<div class="mobile-sidebar-header text-end p-3"> <div class="mobile-sidebar-header text-end p-3">
<button type="button" class="btn btn-text btn-icon btn-rounded sidebar-close"> <button type="button" class="btn btn-text btn-icon btn-rounded sidebar-close" aria-label="关闭导航菜单">
<span><i class="text-xl iconfont icon-close-circle-fill"></i></span> <span><i class="text-xl iconfont icon-close-circle-fill"></i></span>
</button> </button>
</div> </div>
@ -85,12 +85,12 @@ class="d-none logo-dark nc-no-lazy"
<li class="menu-item menu-item-type-taxonomy menu-item-object-category {{ $oNav->aChildren->isNotEmpty() ? 'menu-item-has-children' : '' }}"> <li class="menu-item menu-item-type-taxonomy menu-item-object-category {{ $oNav->aChildren->isNotEmpty() ? 'menu-item-has-children' : '' }}">
<a href="{{ url($oNav->sSlug) }}"> <a href="{{ url($oNav->sSlug) }}">
<i class="{{ $oNav->sHtmlClass }}"></i> <i class="{{ $oNav->sHtmlClass }}"></i>
{{ $oNav->sName }} {{ \App\Services\Seo::categoryName($oNav) }}
</a> </a>
@if($oNav->aChildren->isNotEmpty()) @if($oNav->aChildren->isNotEmpty())
<ul class="sub-menu"> <ul class="sub-menu">
@foreach($oNav->aChildren as $oSub) @foreach($oNav->aChildren as $oSub)
<li class="menu-item menu-item-type-taxonomy menu-item-object-category"><a class="menuaSub" href="{{ url($oNav->sSlug.'/'.$oSub->sSlug) }}">{{ $oSub->sName }}</a></li> <li class="menu-item menu-item-type-taxonomy menu-item-object-category"><a class="menuaSub" href="{{ url($oNav->sSlug.'/'.$oSub->sSlug) }}">{{ \App\Services\Seo::categoryName($oSub) }}</a></li>
@endforeach @endforeach
</ul> </ul>
@endif @endif
@ -106,11 +106,11 @@ class="d-none logo-dark nc-no-lazy"
<form method="get" role="search" id="searchform" action="/"> <form method="get" role="search" id="searchform" action="/">
<div class="search-form"> <div class="search-form">
<input type="text" class="search-input form-control form-control-lg" name="s" id="s" <input type="text" class="search-input form-control form-control-lg" name="s" id="s"
placeholder="请输入搜索关键词并按回车键…"> aria-label="搜索关键词" placeholder="请输入搜索关键词并按回车键…">
<i class="search-toggle iconfont icon-search"></i> <i class="search-toggle iconfont icon-search"></i>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</header> <noscript><nav class="seo-noscript-nav" aria-label="栏目导航"><a href="/">当前节点</a>@foreach($aNavs as $oNav)<a href="{{ \App\Services\Seo::url(\App\Services\Seo::categoryPath($oNav)) }}">{{ \App\Services\Seo::categoryName($oNav) }}</a>@endforeach<form action="/" method="get"><label>搜索 <input name="s" maxlength="100" required></label><button type="submit">搜索</button></form></nav></noscript></header>

105
resources/views/pages/articles/default/index.blade.php Executable file → Normal file
View File

@ -1,79 +1,30 @@
<x-app-layout <x-app-layout :seo="$seo" :sSlug="$oCategory->sSlug ?? ''">
:sSlug="$oCategory->sSlug ?? ''" <main class="site-main"><div class="container">
:sTitle="isset($oCategory) && in_array($oCategory->sSlug, ['client', 'advanced']) ? $oCategory->sName . ' - ' . config('app.name') : config('app.title') . ' - ' . config('app.name')" @if(isset($oCategory) && in_array($oCategory->sSlug, ['client','advanced']))
:sDesc="isset($oCategory) && in_array($oCategory->sSlug, ['client', 'advanced']) ? $oCategory->sDescription : config('app.desc')" <link rel="stylesheet" href="/css/tutorials.css">
> @endif
<main class="site-main"> @if(isset($oCategory) && !empty($oCategory->aBanner['img_file']))
<div class="container"> @php($banner = $oCategory->aBanner)
@if (isset($oCategory) && in_array($oCategory->sSlug, ['client', 'advanced'])) <div class="list-cover rounded mb-3"><div class="media media-21x9"><div class="media-content" style="background-image:url('/sites/{{ config('app.site_code_short') }}/images/banner/{{ $banner['img_file'] }}');background-position:center {{ $banner['img_top'] ?? 'center' }};"><div class="overlay-grad"></div></div></div><div class="cover-body"><h1 class="cover-title">{{ $seo['heading'] }}</h1></div></div>
<link rel="stylesheet" href="/css/tutorials.css"> <p>{{ $seo['description'] }}</p>
@if (!isset($oCategory->aBanner) || count($oArticles) == 0) @else
<section class="tutorial-category" aria-label="教程栏目介绍"> <section class="archive-header block p-4 mb-3"><h1>{{ $seo['heading'] }}</h1><p>{{ $seo['description'] }}</p></section>
<h1>{{ $oCategory->sName }}</h1> @endif
<p>{{ $oCategory->sDescription }}</p> @if(isset($oCategory) && $oCategory->sSlug === 'freenode')<p><a href="/">查看当前免费节点与订阅</a></p>@endif
</section> <div class="row listAjax"><div class="content-wrapper col-lg-8">
@endif @if(count($oArticles) === 0)
@endif <p class="p-4">{{ isset($sKeywords) ? '没有找到匹配的文章,请尝试其他关键词。' : '这个栏目暂时没有文章。' }}</p>
@if (count($oArticles) == 0) @else
{{-- Empty State --}} <div class="list-home list-grid list-grid-padding">
<div class="content-error d-flex flex-fill align-items-center h-v-80"> @foreach($oArticles as $oArticle)<x-article-item :oArticle="$oArticle" />@endforeach
<div class="text-center p-5 mx-auto">
<div class="d-inline-block svg-lg svg-empty"></div>
<p class="text-lg text-muted mt-3 mt-md-4">看起来这里没有任何东西…</p>
</div>
</div>
@else
<div class="row listAjax">
<div class="content-wrapper col-lg-8">
@if (@$aTag['name'])
<div class="archive-header block p-4 p-xl-5 mb-3">
<div class="archive-icon btn btn-primary btn-icon btn-md btn-rounded">
<span><i class="iconfont icon-tag-fill"></i></span>
</div>
<div class="archive-body">
<h1>{{@$aTag['name']}}<sup class="text-muted mx-1">{{@$aTag['count']}}</sup></h1>
</div>
</div>
@endif
@isset($oCategory->aBanner)
@php
$sBannerPath = '/sites/' . config('app.site_code_short') . '/images/banner/' . $oCategory->aBanner['img_file'];
@endphp
<div class="list-cover rounded mb-3">
<div class="media media-21x9">
<div class="media-content" style="background-image:url({{ $sBannerPath }}); background-position: center {{ $oCategory->aBanner['img_top'] }};">
<div class="overlay-grad"></div>
</div>
</div>
<div class="cover-body">
<h1 class="cover-title">{{ in_array($oCategory->sSlug, ['client', 'advanced']) ? $oCategory->sName : $oCategory->sSlug }}<sup class="text-light mx-1">{{ $iArticleCount ?? count($oArticles) }}</sup></h1>
</div>
</div>
@endisset
<div class="list-home list-grid list-grid-padding">
@foreach ($oArticles as $oArticle)
<x-article-item :oArticle="$oArticle" />
@endforeach
</div>
<div class="page-load-status" style="display:none; padding: 20px; text-align: center;">
<div class="infinite-scroll-request">正在加载更多...</div>
<div class="infinite-scroll-last text-muted"> 已经到底了 </div>
</div>
</div>
<div class="sidebar col-lg-4 d-none d-lg-block ms-lg-auto">
@include('components.order-likes')
<!-- @include('components.generated-tags-cloud') -->
@include('components.banners')
</div>
</div>
@endif
</div> </div>
</main> <div class="page-load-status" style="display:none"><span class="infinite-scroll-request">正在加载更多…</span><span class="infinite-scroll-last">已经到底了</span></div>
@endif
<nav class="seo-pagination my-4" aria-label="文章分页">
@if($oArticles->previousPageUrl())<a rel="prev" href="{{ $oArticles->currentPage() === 2 ? $oArticles->path() : $oArticles->previousPageUrl() }}">上一页</a>@endif
<span>第 {{ $oArticles->currentPage() }} / {{ $oArticles->lastPage() }} 页</span>
@if($oArticles->nextPageUrl())<a class="pagination-next" rel="next" href="{{ $oArticles->nextPageUrl() }}">下一页</a>@endif
</nav>
</div><div class="sidebar col-lg-4 d-none d-lg-block ms-lg-auto">@include('components.order-likes')</div></div>
</div></main>
</x-app-layout> </x-app-layout>

View File

@ -1,17 +1,16 @@
@php @php
$iNoIndexDays = (int) ($oArticle->oCategory->aOpt['noindexday'] ?? 0); $bIsHome = $bIsHome ?? false;
$bIsExpired = $iNoIndexDays > 0 && $oArticle->created_at->copy()->addDays($iNoIndexDays)->isPast(); $seo = $seo ?? \App\Services\Seo::articleMeta($oArticle);
$bIsHome = request()->routeIs('articles.home');
$sRobotsStatus = ! $bIsHome && $bIsExpired ? 'noindex, follow' : 'index, follow';
@endphp @endphp
<x-app-layout <x-app-layout
:seo="$seo"
:sSlug="$oCategory->sSlug ?? ''" :sSlug="$oCategory->sSlug ?? ''"
:sTitle="$oArticle->sTitle . ' - ' . $oArticle->oCategory->sName" :sTitle="$oArticle->sTitle . ' - ' . $oArticle->oCategory->sName"
:sDesc="$oArticle->sTitleSub" :sDesc="$oArticle->sTitleSub"
sOgType="article" sOgType="article"
:sOgImg="$oArticle->sThumb" :sOgImg="$oArticle->sThumb"
:sRobots="($bEditorialPreview ?? false) ? 'noindex, nofollow' : $sRobotsStatus" :sRobots="($bEditorialPreview ?? false) ? 'noindex, nofollow' : $seo['robots']"
:bEditorialPreview="$bEditorialPreview ?? false" :bEditorialPreview="$bEditorialPreview ?? false"
> >
@ -37,7 +36,7 @@
<i class="text-primary iconfont icon-dot"></i> <i class="text-primary iconfont icon-dot"></i>
</span> </span>
<span itemprop="itemListElement"> <span itemprop="itemListElement">
<a href="{{ url($oArticle->oCategory->oParent->sSlug) }}" itemprop="item"> <a href="{{ \App\Services\Seo::url(\App\Services\Seo::categoryPath($oArticle->oCategory->oParent)) }}" itemprop="item">
<span itemprop="name">{{ $oArticle->oCategory->oParent->sName }}</span> <span itemprop="name">{{ $oArticle->oCategory->oParent->sName }}</span>
</a> </a>
</span> </span>
@ -46,8 +45,8 @@
<i class="text-primary iconfont icon-dot"></i> <i class="text-primary iconfont icon-dot"></i>
</span> </span>
<span itemprop="itemListElement"> <span itemprop="itemListElement">
<a href="{{ url($oArticle->oCategory->sSlug) }}" itemprop="item"> <a href="{{ \App\Services\Seo::url(\App\Services\Seo::categoryPath($oArticle->oCategory)) }}" itemprop="item">
<span itemprop="name">{{ $oArticle->oCategory->sName }}</span> <span itemprop="name">{{ \App\Services\Seo::categoryName($oArticle->oCategory) }}</span>
</a> </a>
</span> </span>
<span class="sep"> <span class="sep">
@ -60,20 +59,34 @@
<div class="post card card-md"> <div class="post card card-md">
<div class="card-body"> <div class="card-body">
<div class="post-title mb-3"> <div class="post-title mb-3">
<h1 class="mb-3">{{ $oArticle->sTitle }}</h1> <h1 class="mb-3">{{ $seo['heading'] ?? $oArticle->sTitle }}</h1>
<div class="post-meta text-xs"> <div class="post-meta text-xs">
<a href="/"><i class="text-sm iconfont icon-user1 me-1"></i>{{ config('app.name') }}</a> <a href="/"><i class="text-sm iconfont icon-user1 me-1"></i>{{ config('seo.brand') }}</a>
<i class="text-primary iconfont icon-dot mx-2"></i> <i class="text-primary iconfont icon-dot mx-2"></i>
<span class="text-muted">发布于 {{ $oArticle->sCreatedAtDiff }}</span> <time class="text-muted" datetime="{{ $oArticle->created_at?->toAtomString() }}">{{ $bIsHome ? '最近资源记录' : '发布于' }} {{ $oArticle->created_at?->format('Y-m-d H:i') }} UTC</time>
@if($modified = \App\Services\Seo::modified($oArticle))
<time class="text-muted ms-2" datetime="{{ $modified }}">内容更新于 {{ \Carbon\Carbon::parse($modified)->format('Y-m-d H:i') }} UTC</time>
@endif
</div> </div>
</div> </div>
@if($oArticle->oCategory->sSlug === 'freenode')
<div class="alert alert-light resource-context">
@if($bIsHome)
<p>以下为最近一条资源记录。连接情况会随时间、地区和网络变化,请以客户端实际连接为准。</p>
<a href="{{ \App\Services\Seo::articleUrl($oArticle) }}">查看这次更新记录</a> · <a href="/freenode">全部历史记录</a>
@else
<p>这是 {{ $oArticle->created_at?->format('Y-m-d') }} 的历史记录,订阅与节点可能已经变化。</p><a href="/">查看当前免费节点与订阅</a>
@endif
· <a href="/client">入门教程</a> · <a href="/advanced">连接与配置排错</a>
</div>
@endif
<div class="post-content"> <div class="post-content">
@if ($oArticle->bPageThumb && $oArticle->sThumb) @if ($oArticle->bPageThumb && $oArticle->sThumb)
<p> <p>
<img decoding="async" class="size-full wp-image-28 aligncenter" src="{{ $oArticle->sThumb }}" style="border-radius: 6px;"> <img decoding="async" alt="" @foreach(\App\Services\Seo::imageSize($oArticle->sThumb) as $dimension => $value) {{ $dimension }}="{{ $value }}" @endforeach class="size-full wp-image-28 aligncenter" src="{{ $oArticle->sThumb }}" style="border-radius: 6px;">
</p> </p>
@endif @endif
{!! nl2br($oArticle->sContent) !!} {!! nl2br(\App\Services\Seo::body($oArticle->sContent)) !!}
</div> </div>
@ -82,7 +95,7 @@
<div class="post-tags mt-3 mt-md-4"> <div class="post-tags mt-3 mt-md-4">
<div class=""> <div class="">
@foreach($oArticle->oTags as $oTag) @foreach($oArticle->oTags as $oTag)
<a href="/tag/{{ $oTag->sSlug }}" rel"rag"><span># {{ $oTag->sName }}</span></a> <a href="/tag/{{ $oTag->sSlug }}" rel="tag"><span># {{ $oTag->sName }}</span></a>
@endforeach @endforeach
</div> </div>
</div> </div>
@ -90,19 +103,19 @@
@unless($bEditorialPreview ?? false) @unless($bEditorialPreview ?? false)
<div class="post-actions d-flex align-items-center my-3 my-md-4"> <div class="post-actions d-flex align-items-center my-3 my-md-4">
<a href="javascript:;" <a href="javascript:;"
class="post-like-button btn btn-light btn-w-md btn-rounded me-2" aria-label="喜欢这篇文章" class="post-like-button btn btn-light btn-w-md btn-rounded me-2"
data-action="like" data-id="{{ $oArticle->id }}"> data-action="like" data-id="{{ $oArticle->id }}">
<i class="text-lg iconfont icon-like"></i> <i class="text-lg iconfont icon-like"></i>
<span class="like-count">{{ $oArticle->iLikeCount }}</span> <span class="like-count">{{ $oArticle->iLikeCount }}</span>
</a> </a>
<div class="flex-fill"></div> <div class="flex-fill"></div>
<a href="javascript:;" class="btn btn-light btn-icon btn-rounded btn-share-toggler"> <a href="javascript:;" aria-label="分享这篇文章" class="btn btn-light btn-icon btn-rounded btn-share-toggler">
<span><i class="iconfont icon-share-square"></i></span> <span><i class="iconfont icon-share-square"></i></span>
</a> </a>
<template id="single-share-template"> <template id="single-share-template">
<div class="row g-0 text-center justify-content-center py-3 py-md-4 mx-md-0"> <div class="row g-0 text-center justify-content-center py-3 py-md-4 mx-md-0">
<div class="col-4 col-md-3"> <div class="col-4 col-md-3">
<a href="javascript:;" id="urlCopy" data-clipboard-text="{{ config('app.url') }}" class="d-inline-block copy-permalink"> <a href="javascript:;" id="urlCopy" data-clipboard-text="{{ $seo['canonical'] }}" class="d-inline-block copy-permalink">
<div class="btn btn-light btn-icon btn-md btn-rounded mb-1"> <div class="btn btn-light btn-icon btn-md btn-rounded mb-1">
<span><i class="iconfont icon-link"></i></span> <span><i class="iconfont icon-link"></i></span>
</div> </div>

View File

@ -0,0 +1 @@
<x-app-layout sTitle="资源暂不可用" sRobots="noindex, follow"><main class="container py-5"><h1>资源暂不可用</h1><p>暂时没有可展示的节点记录,请稍后再试。</p><a href="/client">查看入门教程</a></main></x-app-layout>

View File

@ -1,26 +1,6 @@
{!! '<' . '?xml version="1.0" encoding="UTF-8"?' . '>' !!} {!! '<' . '?xml version="1.0" encoding="UTF-8"?' . '>' !!}
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{{-- 首页 --}} @foreach($entries as $entry)
<url> <url><loc>{{ $entry['url'] }}</loc>@if($entry['modified'])<lastmod>{{ $entry['modified'] }}</lastmod>@endif</url>
<loc>{{ config('app.url') }}</loc> @endforeach
<priority>1.0</priority>
<changefreq>daily</changefreq>
</url>
{{-- Only established tutorial landing pages; omit lastmod until a reliable content clock exists. --}}
@foreach($cCategories as $oCategory)
<url>
<loc>{{ rtrim(config('app.url'), '/') }}/{{ $oCategory->sSlug }}</loc>
</url>
@endforeach
{{-- 未过期的文章 --}}
@foreach($cArticles as $oArticles)
<url>
<loc>{{ config('app.url') }}/a/{{ $oArticles->sSlug }}.html</loc>
<lastmod>{{ \Carbon\Carbon::parse($oArticles->updated_at)->tz('UTC')->toAtomString() }}</lastmod>
<priority>0.8</priority>
<changefreq>daily</changefreq>
</url>
@endforeach
</urlset> </urlset>

2
routes/console.php Executable file → Normal file
View File

@ -54,3 +54,5 @@
// //
// $this->info("表 [{$sTable}] 重建成功!任务完成!"); // $this->info("表 [{$sTable}] 重建成功!任务完成!");
//})->purpose('定点爆破并重建指定的表,不带参数不动手'); //})->purpose('定点爆破并重建指定的表,不带参数不动手');
Schedule::command('seo:indexnow-retry')->everyFiveMinutes()->environments(['production'])->withoutOverlapping(10);

5
routes/web.php Executable file → Normal file
View File

@ -15,11 +15,14 @@
Route::get('robots.txt', [RobotsController::class, 'index']); Route::get('robots.txt', [RobotsController::class, 'index']);
Route::get('sitemap.xml', [SitemapController::class, 'index']); Route::get('sitemap.xml', [SitemapController::class, 'index']);
if (app()->environment('local')) {
Route::get('/test/hello', [TestController::class, 'hello']); // 找个控制器为什么无效了 Route::get('/test/hello', [TestController::class, 'hello']); // 找个控制器为什么无效了
Route::get('/test/test1', [TestController::class, 'test1']); Route::get('/test/test1', [TestController::class, 'test1']);
Route::get('/test/test2', [TestController::class, 'test2']); Route::get('/test/test2', [TestController::class, 'test2']);
Route::get('/test/test3', [TestController::class, 'test3']); Route::get('/test/test3', [TestController::class, 'test3']);
}
Route::post('/api/article/receive/shadowrocketids', [ApiV1ArticlesController::class, 'receiveShadowrocketids']); Route::post('/api/article/receive/shadowrocketids', [ApiV1ArticlesController::class, 'receiveShadowrocketids']);
Route::post('/api/article/receive/freenode', [ApiV1ArticlesController::class, 'receiveFreenode']); Route::post('/api/article/receive/freenode', [ApiV1ArticlesController::class, 'receiveFreenode']);
Route::post('/api/article/receive', [ApiV1ArticlesController::class, 'receive']); Route::post('/api/article/receive', [ApiV1ArticlesController::class, 'receive']);
@ -29,7 +32,7 @@
Route::get('/', [ArticlesController::class, 'home']) Route::get('/', [ArticlesController::class, 'home'])
->name('articles.home'); ->name('articles.home');
Route::get('/like/{iId}', [ArticlesController::class, 'like'])->name('article.like'); Route::post('/like/{iId}', [ArticlesController::class, 'like'])->name('article.like');
Route::get('/search/{sKeyword}', [ArticlesController::class, 'search'])->where('sKeywords', '.*')->name('article.list.search'); Route::get('/search/{sKeyword}', [ArticlesController::class, 'search'])->where('sKeywords', '.*')->name('article.list.search');
//Route::get('/s/ajax/{sKeyword}', [ArticlesController::class, 'searchAjax'])->name('article.list.search.ajax'); //Route::get('/s/ajax/{sKeyword}', [ArticlesController::class, 'searchAjax'])->name('article.list.search.ajax');

View File

@ -76,6 +76,7 @@ protected function setUp(): void
(require database_path('migrations/2026_10_27_190000_create_editorial_tables.php'))->up(); (require database_path('migrations/2026_10_27_190000_create_editorial_tables.php'))->up();
(require database_path('migrations/2026_10_29_200000_add_one_year_article_likes.php'))->up(); (require database_path('migrations/2026_10_29_200000_add_one_year_article_likes.php'))->up();
(require database_path('migrations/2026_10_31_150000_add_article_display_timestamp.php'))->up(); (require database_path('migrations/2026_10_31_150000_add_article_display_timestamp.php'))->up();
(require database_path('migrations/2026_10_31_160000_add_article_seo_state.php'))->up();
DB::table('categories')->insert([ DB::table('categories')->insert([
['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'], ['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'],
['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'], ['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'],
@ -182,7 +183,7 @@ public function test_authentication_and_environment_binding_and_cache_bypass():
$this->getJson('/api/editorial/v1/catalog')->assertOk()->assertHeader('Cache-Control', 'no-store, private'); $this->getJson('/api/editorial/v1/catalog')->assertOk()->assertHeader('Cache-Control', 'no-store, private');
config(['responsecache.enabled' => true]); config(['responsecache.enabled' => true]);
$this->assertFalse(app(ArticleResponseCacheProfile::class)->enabled(Request::create('/api/editorial/v1/catalog'))); $this->assertFalse(app(ArticleResponseCacheProfile::class)->enabled(Request::create('/api/editorial/v1/catalog')));
$this->assertTrue(app(ArticleResponseCacheProfile::class)->enabled(Request::create('/windows'))); $this->assertFalse(app(ArticleResponseCacheProfile::class)->enabled(Request::create('/windows')));
file_put_contents($this->credential, json_encode(['project' => 'fna-v3', 'environment' => 'production', 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => $this->token]])); file_put_contents($this->credential, json_encode(['project' => 'fna-v3', 'environment' => 'production', 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => $this->token]]));
$this->getJson('/api/editorial/v1/catalog')->assertStatus(503); $this->getJson('/api/editorial/v1/catalog')->assertStatus(503);
} }
@ -413,7 +414,7 @@ public function test_editorial_indexnow_notifies_new_and_changed_content_once_bu
$this->postJson('/api/editorial/v1/publications/'.$id.'/synchronize')->assertOk(); $this->postJson('/api/editorial/v1/publications/'.$id.'/synchronize')->assertOk();
Http::assertSentCount(1); Http::assertSentCount(1);
Http::assertSent(fn ($r) => $r->url() === 'https://api.indexnow.org/indexnow' Http::assertSent(fn ($r) => $r->url() === 'https://api.indexnow.org/indexnow'
&& $r['host'] === 'example.org' && $r['urlList'] === ['https://example.org/a/example.html']); && $r['host'] === 'example.org' && $r['urlList'] === ['https://example.org/a/example.html', 'https://example.org/advanced']);
$noop = $this->draft([$this->item('example', 1)]); $noop = $this->draft([$this->item('example', 1)]);
// The category change in this revision is itself meaningful. // The category change in this revision is itself meaningful.
$this->publish($noop)->assertOk()->assertJsonPath('effects.indexnow.status', 'done'); $this->publish($noop)->assertOk()->assertJsonPath('effects.indexnow.status', 'done');
@ -482,7 +483,7 @@ public function test_sitemap_contains_only_active_nonempty_tutorial_roots_and_in
config(['app.url' => 'http://localhost']); config(['app.url' => 'http://localhost']);
$this->publish($this->draft([$this->item()]))->assertOk(); $this->publish($this->draft([$this->item()]))->assertOk();
DB::table('categories')->where('id', 3)->update(['iParentId' => 2]); DB::table('categories')->where('id', 3)->update(['iParentId' => 2]);
DB::table('categories')->insert(['id' => 2, 'sSlug' => 'client', 'sName' => '入门教程', 'iStatus' => 1]); DB::table('categories')->insert(['id' => 2, 'sSlug' => 'client', 'sName' => '入门教程', 'iStatus' => 1, 'iShowChildren' => 1]);
$xml = $this->get('/sitemap.xml')->assertOk()->getContent(); $xml = $this->get('/sitemap.xml')->assertOk()->getContent();
$this->assertStringContainsString('<loc>http://localhost/client</loc>', $xml); $this->assertStringContainsString('<loc>http://localhost/client</loc>', $xml);
$this->assertStringNotContainsString('<loc>http://localhost/advanced</loc>', $xml); $this->assertStringNotContainsString('<loc>http://localhost/advanced</loc>', $xml);

View File

@ -24,6 +24,8 @@ protected function setUp(): void
Schema::create('categories', function (Blueprint $table) { Schema::create('categories', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('sSlug'); $table->string('sSlug');
$table->integer('iStatus')->default(1);
$table->integer('iParentId')->default(0);
}); });
Schema::create('articles', function (Blueprint $table) { Schema::create('articles', function (Blueprint $table) {
$table->id(); $table->id();
@ -70,11 +72,11 @@ public function test_empty_pool_and_equal_dates_are_deterministic(): void
$this->assertSame('second', $service->getLatestFreeNodeSlug()); $this->assertSame('second', $service->getLatestFreeNodeSlug());
} }
public function test_home_bypasses_cache_and_categories_keep_existing_cache_policy(): void public function test_personalized_html_bypasses_response_cache(): void
{ {
config(['responsecache.enabled' => true]); config(['responsecache.enabled' => true]);
$profile = app(ArticleResponseCacheProfile::class); $profile = app(ArticleResponseCacheProfile::class);
$this->assertFalse($profile->enabled(Request::create('/'))); $this->assertFalse($profile->enabled(Request::create('/')));
$this->assertTrue($profile->enabled(Request::create('/freenode'))); $this->assertFalse($profile->enabled(Request::create('/freenode')));
} }
} }

View File

@ -19,9 +19,12 @@ protected function setUp(): void
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:', config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'scout.driver' => null, 'cache.default' => 'array']); 'scout.driver' => null, 'cache.default' => 'array']);
DB::purge('sqlite'); DB::purge('sqlite');
Schema::create('categories', function (Blueprint $t) { $t->id(); $t->integer('iStatus')->default(1); $t->integer('iParentId')->default(0); });
DB::table('categories')->insert(['id'=>1]);
Schema::create('articles', function (Blueprint $t) { Schema::create('articles', function (Blueprint $t) {
$t->id(); $t->id();
$t->integer('iStatus')->default(1); $t->integer('iStatus')->default(1);
$t->integer('iCategoryId')->default(1);
foreach (['iLikeCount', 'i7like', 'i365like'] as $field) { foreach (['iLikeCount', 'i7like', 'i365like'] as $field) {
$t->integer($field)->default(0); $t->integer($field)->default(0);
} }

View File

@ -0,0 +1,142 @@
<?php
namespace Tests\Feature;
use App\Models\Articles;
use App\Services\Seo;
use App\Services\ArticlesService;
use App\Services\ResourcePublication;
use App\Services\SeoNotifications;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
class SeoContractTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default'=>'sqlite','database.connections.sqlite.database'=>':memory:','scout.driver'=>'collection',
'app.url'=>'https://example.org','editorial.base_url'=>'https://example.org','app.site_code'=>'example.org',
'app.site_code_short'=>'cfn','seo.brand'=>'Example','responsecache.enabled'=>false,'seo.page_size'=>2,
'illustrations.enabled'=>false,'editorial.indexnow_enabled'=>false]);
DB::purge('sqlite'); Http::preventStrayRequests(); $this->withoutVite();
$this->travelTo(\Carbon\Carbon::parse('2026-10-31 12:00:00','UTC'));
Schema::create('categories',function(Blueprint $t){$t->id();$t->string('sSlug');$t->string('sName');$t->integer('iParentId')->default(0);$t->integer('iStatus')->default(1);$t->integer('iSort')->default(0);$t->integer('iShowChildren')->default(0);$t->string('sHtmlClass')->nullable();$t->text('aOpt')->nullable();$t->text('sDescription')->nullable();$t->text('aBanner')->nullable();});
Schema::create('articles',function(Blueprint $t){$t->id();$t->integer('iCategoryId');$t->integer('iUserId')->default(1);$t->string('sSlug')->unique();$t->string('sTitle');$t->text('sTitleSub')->nullable();$t->text('sContent');$t->string('sThumb')->nullable();$t->integer('iStatus')->default(1);$t->boolean('bPageThumb')->default(false);foreach(['iViews','iLikeCount','i7like','i365like','iIsTop'] as $f)$t->integer($f)->default(0);$t->timestamp('sLastLikedAt')->nullable();$t->timestamps();});
Schema::create('tags',function(Blueprint $t){$t->id();$t->string('sSlug');$t->string('sName');});
Schema::create('article_tag',function(Blueprint $t){$t->integer('iArticleId');$t->integer('iTagId');});
Schema::create('article_likes',function(Blueprint $t){$t->id();$t->integer('iArticleId');$t->string('sIp');$t->string('sFingerprint');$t->timestamps();});
(require database_path('migrations/2026_10_31_160000_add_article_seo_state.php'))->up();
foreach([[1,'freenode','免费节点',0],[2,'client','入门教程',0],[3,'windows','Windows',2],[4,'advanced','综合教程',0]] as [$id,$slug,$name,$parent]) DB::table('categories')->insert(['id'=>$id,'sSlug'=>$slug,'sName'=>$name,'iParentId'=>$parent,'iShowChildren'=>$id===2?1:0,'aOpt'=>$id===1?' {"noindexday":7}':null]);
DB::table('tags')->insert(['id'=>1,'sSlug'=>'clash','sName'=>'Clash']);
}
protected function tearDown(): void { $this->travelBack(); parent::tearDown(); }
private function article(string $slug, array $extra=[]): int
{
return DB::table('articles')->insertGetId($extra+['sSlug'=>$slug,'sTitle'=>'教程 '.$slug,'sTitleSub'=>'<b>示例</b> &amp; 摘要','sContent'=>'<h2>操作步骤</h2><p>示例正文</p>','iCategoryId'=>3,'created_at'=>'2026-10-30 10:00:00','updated_at'=>'2026-10-31 01:00:00']);
}
public function test_category_aliases_pagination_and_parameters(): void
{
foreach(range(1,5) as $n)$this->article('guide'.$n);
$this->get('/windows?page=2&utm_source=x')->assertRedirect('https://example.org/client/windows?page=2')->assertStatus(301);
foreach(['/wrong/windows','/wrong/freenode','/client/freenode','/client/windows?page=0','/client/windows?page=-1','/client/windows?page=abc','/client/windows?page=99','/client/windows?page[]=2','/client/windows?page=2&page=3'] as $url)$this->get($url)->assertNotFound();
$this->get('/client/windows?page=1')->assertStatus(301)->assertRedirect('https://example.org/client/windows');
$html=$this->get('/client/windows?page=2&utm_source=x')->assertOk()->getContent();
$this->assertStringContainsString('rel="canonical" href="https://example.org/client/windows?page=2"',$html);
$this->assertStringContainsString('rel="next" href="https://example.org/client/windows?page=3"',$html);
$this->assertStringContainsString('rel="prev" href="https://example.org/client/windows"',$html);
$this->assertStringNotContainsString('guide5.html',$html);
$this->get('/client/windows?page=3')->assertOk()->assertDontSee('class="pagination-next"',false);
$this->get('/robots.txt')->assertOk()->assertDontSee('Disallow: /*?*',false);
}
public function test_hidden_and_future_content_are_absent_from_every_public_query(): void
{
$good=$this->article('good');
foreach(['draft'=>['iStatus'=>0],'future'=>['created_at'=>'2026-11-01 00:00:00']] as $slug=>$extra){$id=$this->article($slug,$extra);DB::table('article_tag')->insert(['iArticleId'=>$id,'iTagId'=>1]);$this->get('/a/'.$slug.'.html')->assertNotFound();}
$this->get('/search/'.rawurlencode('教程'))->assertOk()->assertSee('good.html',false)->assertDontSee('draft.html',false)->assertDontSee('future.html',false)->assertSee('noindex, follow',false);
$this->get('/?s='.rawurlencode('教程'))->assertStatus(301)->assertRedirect(Seo::url('/search/'.rawurlencode('教程')));
$this->get('/search/'.rawurlencode('不存在的内容'))->assertOk()->assertSee('没有找到匹配的文章');
$this->get('/tag/clash')->assertOk()->assertDontSee('draft.html',false);
DB::table('categories')->where('id',2)->update(['iStatus'=>0]);
$this->get('/a/good.html')->assertNotFound();$this->get('/client/windows')->assertNotFound();
$this->get('/sitemap.xml')->assertOk()->assertDontSee('good.html',false);
$this->get('/search/'.rawurlencode('教程'))->assertOk()->assertDontSee('good.html',false);
}
public function test_home_is_stable_and_failed_refresh_does_not_replace_it(): void
{
$this->article('fn-good',['iCategoryId'=>1,'sTitle'=>'日期标题','sTitleSub'=>'共20个可用节点']);
$this->article('fn-zero',['iCategoryId'=>1,'sTitleSub'=>'本次更新共0个可用节点','created_at'=>'2026-10-31 11:00:00']);
$this->assertSame('fn-good',app(ArticlesService::class)->getLatestFreeNodeSlug());
$html=$this->get('/')->assertOk()->getContent();
$this->assertStringContainsString('<h1 class="mb-3">免费节点与订阅使用指南</h1>',$html);
$this->assertStringContainsString('rel="canonical" href="https://example.org/"',$html);
$this->assertStringContainsString('property="og:type" content="website"',$html);
$this->assertStringContainsString('fn-good.html',$html);
$this->travel(10)->days();$this->get('/')->assertOk()->assertSee('content="index, follow"',false);
$this->get('/a/fn-good.html')->assertOk()->assertSee('content="noindex, follow"',false)->assertSee('历史记录');
DB::table('articles')->delete();$this->get('/')->assertStatus(503)->assertHeader('Retry-After','3600');
}
public function test_new_snapshots_are_archives_and_content_clock_ignores_likes(): void
{
$id=$this->article('old',['iCategoryId'=>1]);$old=Articles::find($id);
$this->assertTrue(Seo::indexable($old));$this->assertNull(Seo::modified($old));
$new=Articles::create(['sSlug'=>'new','sTitle'=>'新的记录','sTitleSub'=>'共10个可用节点','sContent'=>'<p>有效资源</p>','iCategoryId'=>1,'iStatus'=>1]);
$this->assertSame('archive',$new->sSeoPolicy);$this->assertFalse(Seo::indexable($new));
$clock=Seo::modified($new);$this->travel(1)->hours();$new->increment('iLikeCount');$this->assertSame($clock,Seo::modified($new->fresh()));
$new->sContent='<p>更新资源内容</p>';$new->save();$this->assertNotSame($clock,Seo::modified($new->fresh()));
$this->get('/sitemap.xml')->assertOk()->assertDontSee('/a/new.html',false)->assertSee('/a/old.html',false);
$this->travelTo(\Carbon\Carbon::parse('2026-11-06 10:00:01','UTC'));
$this->get('/sitemap.xml')->assertOk()->assertDontSee('/a/old.html',false);
}
public function test_metadata_and_structured_data_are_clean_and_share_article_url(): void
{
$this->article('clean',['sTitle'=>'标题 </script> & "引用"']);
$html=$this->get('/a/clean.html?utm_source=test')->assertOk()->getContent();
$dom=new \DOMDocument();@$dom->loadHTML($html);$xp=new \DOMXPath($dom);
$this->assertSame('示例 & 摘要',$xp->query('//meta[@name="description"]')->item(0)->getAttribute('content'));
$this->assertSame('https://example.org/a/clean.html',$xp->query('//a[@id="urlCopy"]')->item(0)->getAttribute('data-clipboard-text'));
foreach($xp->query('//script[@type="application/ld+json"]') as $script)$this->assertIsArray(json_decode($script->textContent,true,512,JSON_THROW_ON_ERROR));
$this->assertStringNotContainsString('dateModified',$html);$this->assertStringNotContainsString('AggregateRating',$html);
$this->assertStringContainsString('href="https://example.org/client/windows"',$html);
}
public function test_rejected_resource_does_not_change_articles(): void
{
$id=$this->article('last',['iCategoryId'=>1]);
$payload=['title'=>'更新','title_sub'=>'共12个可用节点','content'=>'<p>内容</p>','tags'=>[]];
$this->assertSame(12,ResourcePublication::validate($payload)['iResourceCount']);
$this->assertSame('unknown',ResourcePublication::validate($payload)['sResourceStatus']);
foreach([['check_status'=>'failed'],['valid_count'=>0],['title_sub'=>'本次更新共0个可用节点'],['content'=>'']] as $bad){
try { ResourcePublication::validate(array_replace($payload,$bad));$this->fail('Invalid refresh accepted'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException|\Illuminate\Validation\ValidationException $e){$this->assertNotEmpty($e->getMessage());}
}
$this->assertSame('last',Articles::find($id)->sSlug);$this->assertSame(1,Articles::count());
}
public function test_durable_home_notifications_retry_and_deletions_need_no_http_probe(): void
{
config(['editorial.indexnow_enabled'=>true,'key.indexnow'=>'test-key-12345678']);
Http::fake(['api.indexnow.org/*'=>Http::sequence()->push('',429)->push('',202)->push('',200)]);
$id=$this->article('snapshot',['iCategoryId'=>1,'sSeoPolicy'=>'archive']);$service=app(SeoNotifications::class);
$service->changed(Articles::find($id));$service->changed(Articles::find($id));$this->assertSame(1,DB::table('seo_notifications')->count());
$service->retry();Http::assertSentCount(1);$service->retry();Http::assertSentCount(1);
$this->travel(6)->minutes();$service->retry();Http::assertSentCount(2);
Http::assertSent(fn($req)=>$req['urlList']===['https://example.org/','https://example.org/freenode']);
$service->changed(Articles::find($id),true);DB::table('articles')->where('id',$id)->delete();$service->retry();Http::assertSentCount(3);
Http::assertSent(fn($req)=>in_array('https://example.org/a/snapshot.html',$req['urlList']));
}
public function test_cleanup_retains_old_resource_urls_and_get_cannot_like(): void
{
$id=$this->article('historical',['iCategoryId'=>1,'created_at'=>'2025-01-01 00:00:00']);
$this->artisan('clear_db')->assertExitCode(0);$this->assertNotNull(Articles::find($id));
$this->get('/like/'.$id)->assertNotFound();$this->assertSame(0,Articles::find($id)->iLikeCount);
}
public function test_commercial_attributes_and_zero_string_validation(): void
{
$html='<a href="https://xn--123-9i9dj97q.com/" rel="nofollow">推广</a><a href="https://example.net/doc">参考</a>';
$body=Seo::body($html);$this->assertStringContainsString('rel="nofollow sponsored noopener"',$body);
$this->assertStringContainsString('<a href="https://example.net/doc">参考</a>',$body);
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
ResourcePublication::validate(['title'=>'资源','title_sub'=>'数量未知','content'=>'内容','tags'=>[],'valid_count'=>'0']);
}
}