Unify FNA homepage, archives, canonical URLs and durable SEO publishing
This commit is contained in:
parent
a820755e7e
commit
7c11ca77bc
@ -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,或让源图删除影响本站独立副本。当前状态和未取得的搜索平台证据见该文档;实施不等于收录/排名已验证。
|
||||||
|
|||||||
13
app/Console/Commands/ClearDb.php
Executable file → Normal file
13
app/Console/Commands/ClearDb.php
Executable file → Normal file
@ -25,18 +25,7 @@ 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()
|
||||||
|
|||||||
13
app/Console/Commands/RetrySeoNotifications.php
Normal file
13
app/Console/Commands/RetrySeoNotifications.php
Normal 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
16
app/Http/Controllers/Api/v1/ArticlesController.php
Executable file → Normal 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;
|
||||||
|
|||||||
@ -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 响应头
|
||||||
|
|||||||
@ -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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
app/Http/Controllers/Web/ArticlesController.php
Executable file → Normal file
77
app/Http/Controllers/Web/ArticlesController.php
Executable file → Normal file
@ -19,8 +19,10 @@ 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)
|
||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
app/Http/Middleware/SeoHeaders.php
Normal file
13
app/Http/Middleware/SeoHeaders.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Models/Articles.php
Executable file → Normal file
42
app/Models/Articles.php
Executable file → Normal 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
4
app/Providers/ArticleOrderLikesProvider.php
Executable file → Normal 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))));
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
167
app/Services/ArticlesService.php
Executable file → Normal file
167
app/Services/ArticlesService.php
Executable file → Normal 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).'%';
|
||||||
|
$articles = Articles::publicVisible()->with('oCategory.oParent')
|
||||||
|
->where(fn($q) => $q->whereRaw("sTitle LIKE ? ESCAPE '!'", [$literal])->orWhereRaw("sContent LIKE ? ESCAPE '!'", [$literal]))
|
||||||
|
->orderByDesc('iIsTop')->orderByDesc('created_at')->orderByDesc('id')->paginate(config('seo.page_size'));
|
||||||
|
return ['oArticles'=>$articles, 'sKeywords'=>$term];
|
||||||
}
|
}
|
||||||
|
public function getTagArticles(string $slug, string $resultType = 'view')
|
||||||
$sKeywords = strip_tags(trim($sKeywords));
|
|
||||||
|
|
||||||
$oArticles = Articles::search($sKeywords)
|
|
||||||
->options([
|
|
||||||
'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 $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');
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
public function getCategoryArticles(string $sSlug, string $sResultType)
|
}
|
||||||
|
public function getCategoryArticles(string $slug, string $resultType = 'view')
|
||||||
{
|
{
|
||||||
$oCategory = Categories::where('sSlug', $sSlug)->firstOrFail();
|
$category = Categories::where('sSlug', $slug)->where('iStatus',1)->with('oParent')->firstOrFail();
|
||||||
|
abort_if($category->iParentId && (!$category->oParent || !$category->oParent->iStatus),404);
|
||||||
if ($oCategory->iShowChildren) {
|
$ids = [$category->id];
|
||||||
$aCategoryIds = Categories::where('iParentId', $oCategory->id)
|
if ($category->iShowChildren) $ids = array_merge($ids, Categories::where('iParentId',$category->id)->where('iStatus',1)->pluck('id')->all());
|
||||||
->pluck('id')
|
$articles = Articles::publicVisible()->whereIn('iCategoryId',$ids)->with('oCategory.oParent')
|
||||||
->push($oCategory->id)
|
->orderByDesc('iIsTop')->orderByDesc('created_at')->orderByDesc('id')->paginate(config('seo.page_size'));
|
||||||
->toArray();
|
return ['oCategory'=>$category,'oArticles'=>$articles,'iArticleCount'=>$articles->total()];
|
||||||
} else {
|
|
||||||
$aCategoryIds = [$oCategory->id];
|
|
||||||
}
|
}
|
||||||
|
public function getArticle(string $slug, string $ip, string $fingerprint)
|
||||||
$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 getArticle(string $sArticleSlug, string $sIp, string $sFingerprint)
|
|
||||||
{
|
{
|
||||||
$oArticle = Articles::where('sSlug', $sArticleSlug)
|
$article = Articles::publicVisible()->where('sSlug',$slug)->with(['oCategory.oParent','oTags'])->firstOrFail();
|
||||||
->where('iStatus', 1)
|
$article->bIsLiked = ArticleLikes::where('iArticleId',$article->id)
|
||||||
->with(['oCategory.oParent', 'oTags'])
|
->where(fn($q)=>$q->where('sIp',$ip)->orWhere('sFingerprint',$fingerprint))->exists();
|
||||||
->firstOrFail();
|
return ['oArticle'=>$article];
|
||||||
|
|
||||||
$oArticle->bIsLiked = ArticleLikes::where('iArticleId', $oArticle->id)
|
|
||||||
->where(function($q) use ($sIp, $sFingerprint) {
|
|
||||||
$q->where('sIp', $sIp)
|
|
||||||
->orWhere('sFingerprint', $sFingerprint);
|
|
||||||
})->exists();
|
|
||||||
|
|
||||||
// return $oArticle;
|
|
||||||
return [
|
|
||||||
'oArticle' => $oArticle
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
app/Services/CacheService.php
Executable file → Normal file
2
app/Services/CacheService.php
Executable file → Normal 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)
|
||||||
|
|||||||
@ -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 (!$urls) return ['status'=>'skipped','reason'=>'no-current-indexable-url'];
|
||||||
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 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', [
|
||||||
|
|||||||
@ -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'],
|
||||||
|
|||||||
24
app/Services/ResourcePublication.php
Normal file
24
app/Services/ResourcePublication.php
Normal 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
110
app/Services/Seo.php
Normal 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]]];
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Services/SeoNotifications.php
Normal file
54
app/Services/SeoNotifications.php
Normal 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
1
bootstrap/app.php
Executable file → Normal 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
7
config/seo.php
Normal 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,
|
||||||
|
];
|
||||||
@ -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']));
|
||||||
|
}
|
||||||
|
};
|
||||||
41
docs/maintenance/SEO_IMPLEMENTATION_2026-10-31.md
Normal file
41
docs/maintenance/SEO_IMPLEMENTATION_2026-10-31.md
Normal 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发布;尚未切换正式实例。
|
||||||
@ -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 各项优化的实施说明
|
||||||
|
|
||||||
|
|||||||
9
public/build/assets/app-VvJM4xzJ.js
Normal file
9
public/build/assets/app-VvJM4xzJ.js
Normal file
File diff suppressed because one or more lines are too long
@ -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
1
public/css/seo.css
Normal 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
49
resources/js/app.js
Executable file → Normal 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
34
resources/views/components/app-layout.blade.php
Executable file → Normal 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
14
resources/views/components/article-item.blade.php
Executable file → Normal 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
4
resources/views/components/footer.blade.php
Executable file → Normal 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
30
resources/views/components/nav-bar.blade.php
Executable file → Normal 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>
|
||||||
|
|
||||||
|
|||||||
93
resources/views/pages/articles/default/index.blade.php
Executable file → Normal file
93
resources/views/pages/articles/default/index.blade.php
Executable file → Normal 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')"
|
|
||||||
>
|
|
||||||
<main class="site-main">
|
|
||||||
<div class="container">
|
|
||||||
@if (isset($oCategory) && in_array($oCategory->sSlug, ['client', 'advanced']))
|
|
||||||
<link rel="stylesheet" href="/css/tutorials.css">
|
<link rel="stylesheet" href="/css/tutorials.css">
|
||||||
@if (!isset($oCategory->aBanner) || count($oArticles) == 0)
|
|
||||||
<section class="tutorial-category" aria-label="教程栏目介绍">
|
|
||||||
<h1>{{ $oCategory->sName }}</h1>
|
|
||||||
<p>{{ $oCategory->sDescription }}</p>
|
|
||||||
</section>
|
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@if(isset($oCategory) && !empty($oCategory->aBanner['img_file']))
|
||||||
@if (count($oArticles) == 0)
|
@php($banner = $oCategory->aBanner)
|
||||||
{{-- Empty State --}}
|
<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>
|
||||||
<div class="content-error d-flex flex-fill align-items-center h-v-80">
|
<p>{{ $seo['description'] }}</p>
|
||||||
<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
|
@else
|
||||||
<div class="row listAjax">
|
<section class="archive-header block p-4 mb-3"><h1>{{ $seo['heading'] }}</h1><p>{{ $seo['description'] }}</p></section>
|
||||||
<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
|
@endif
|
||||||
|
@if(isset($oCategory) && $oCategory->sSlug === 'freenode')<p><a href="/">查看当前免费节点与订阅</a></p>@endif
|
||||||
@isset($oCategory->aBanner)
|
<div class="row listAjax"><div class="content-wrapper col-lg-8">
|
||||||
@php
|
@if(count($oArticles) === 0)
|
||||||
$sBannerPath = '/sites/' . config('app.site_code_short') . '/images/banner/' . $oCategory->aBanner['img_file'];
|
<p class="p-4">{{ isset($sKeywords) ? '没有找到匹配的文章,请尝试其他关键词。' : '这个栏目暂时没有文章。' }}</p>
|
||||||
@endphp
|
@else
|
||||||
|
|
||||||
<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">
|
<div class="list-home list-grid list-grid-padding">
|
||||||
@foreach ($oArticles as $oArticle)
|
@foreach($oArticles as $oArticle)<x-article-item :oArticle="$oArticle" />@endforeach
|
||||||
<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>
|
</div>
|
||||||
|
<div class="page-load-status" style="display:none"><span class="infinite-scroll-request">正在加载更多…</span><span class="infinite-scroll-last">已经到底了</span></div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
<nav class="seo-pagination my-4" aria-label="文章分页">
|
||||||
</main>
|
@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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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
2
routes/console.php
Executable file → Normal 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
5
routes/web.php
Executable file → Normal 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');
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
142
tests/Feature/SeoContractTest.php
Normal file
142
tests/Feature/SeoContractTest.php
Normal 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> & 摘要','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']);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user