Publish FNA resource entrances, tutorials and quiet mode

This commit is contained in:
Codex 2026-11-03 13:58:11 +08:00
parent c2539f380d
commit d6a7d073ed
75 changed files with 1403 additions and 158 deletions

View File

@ -66,3 +66,6 @@ VITE_APP_NAME="${APP_NAME}"
# Approved illustration delivery (independent per environment) # Approved illustration delivery (independent per environment)
ILLUSTRATION_DELIVERY_ENABLED=false ILLUSTRATION_DELIVERY_ENABLED=false
# Runtime override: php artisan site:quiet on|off|status
SITE_QUIET_MODE=false

View File

@ -6,6 +6,8 @@ # FNA_V3 项目维护入口
## FNA_V3 本地副本维护 ## FNA_V3 本地副本维护
本地开发服务与Vite掉线自动恢复见[本地开发维护](docs/maintenance/LOCAL_DEV.md)。仅本用户127.0.0.1:5187,避免重复启动同端口进程。
CFN页头SVG猫和两行品牌文字的维护与回退见[品牌标志](docs/maintenance/BRANDING.md),已于2026-10-31发布,见[界面发布记录](docs/maintenance/UI_RELEASE_2026-10-31.md)。 CFN页头SVG猫和两行品牌文字的维护与回退见[品牌标志](docs/maintenance/BRANDING.md),已于2026-10-31发布,见[界面发布记录](docs/maintenance/UI_RELEASE_2026-10-31.md)。
本目录为独立 FNA_V3 副本,身份与环境以 GemDocs AI版 `10-项目/fna_v3.md` 为准;上述 FN_B 入口保留来源背景,不代表授权修改 FN_B。文章广告实现、配置与验收见 [广告维护说明](docs/maintenance/ADVERTISING.md)。统一机场推荐数据、正文替换和教程末尾展示见[机场推荐维护](docs/maintenance/AIRPORT_RECOMMENDATIONS.md),已于2026-10-31发布,见[界面发布记录](docs/maintenance/UI_RELEASE_2026-10-31.md)。免费节点详情页的只读结构化渲染、订阅保护与回退见[内容页重设计](docs/maintenance/FREENODE_ARTICLE_LAYOUT.md)。 本目录为独立 FNA_V3 副本,身份与环境以 GemDocs AI版 `10-项目/fna_v3.md` 为准;上述 FN_B 入口保留来源背景,不代表授权修改 FN_B。文章广告实现、配置与验收见 [广告维护说明](docs/maintenance/ADVERTISING.md)。统一机场推荐数据、正文替换和教程末尾展示见[机场推荐维护](docs/maintenance/AIRPORT_RECOMMENDATIONS.md),已于2026-10-31发布,见[界面发布记录](docs/maintenance/UI_RELEASE_2026-10-31.md)。免费节点详情页的只读结构化渲染、订阅保护与回退见[内容页重设计](docs/maintenance/FREENODE_ARTICLE_LAYOUT.md)。
@ -34,6 +36,10 @@ ## AI 文章写作(必读)
教程配图必须按[写作规范](docs/content/WRITING.md#教程双比例配图正式规则2026-11-01)交付3:2列表图和约2.4:1详情横幅:分别构图、统一纸艺质感、每篇不同场景、两图加网址水印;`cover`用于列表,`show_cover=false`,正文开头使用唯一`guide-figure tutorial-hero`。后续聊天及周期写作同样适用,发布前检查桌面和手机。 教程配图必须按[写作规范](docs/content/WRITING.md#教程双比例配图正式规则2026-11-01)交付3:2列表图和约2.4:1详情横幅:分别构图、统一纸艺质感、每篇不同场景、两图加网址水印;`cover`用于列表,`show_cover=false`,正文开头使用唯一`guide-figure tutorial-hero`。后续聊天及周期写作同样适用,发布前检查桌面和手机。
2026-11-02起客户端下载文章优先使用本站 Resource 软件安装包,官方来源作为补充;平台/架构与不可用回退规则统一见 [写作规范](docs/content/WRITING.md#客户端下载优先级2026-11-02)。本地下载总览已与新手指引共同置顶,此次下载/置顶更新未上线,回执见 [新手教程维护](docs/maintenance/NEWCOMER_TUTORIALS_2026-11-01.md)。
免费节点与小火箭 ID 的零基础说明和每日资源页自动关联见 [资源基础说明维护](docs/maintenance/RESOURCE_BASICS_2026-11-02.md);2026-11-02仅本地新增,文章关系继续维护在写作索引,未部署生产。
## 独立文章发布 API ## 独立文章发布 API
用户已明确文章直接发布到 FNA_V3,不经过 ATOM。实现、调用契约、凭据边界及验收见[独立文章 API](docs/maintenance/EDITORIAL_API.md)。后续写稿使用本站草稿→签名预览→按hash发布→回执核对流程;相关旧文内链修改可同批提交。不要再用临时SQL脚本发布普通编辑文章。旧文首次接管必须显式指定原文章ID并保留URL;发布结果未知先查原请求UUID。代码已于2026-10-29随正式切换部署,生产临时编辑密钥在教程迁移后删除、接口默认关闭;后续按明确写作/发布任务临时启用并在完成后关闭,不扩大线上访问或发布范围。正式部署、备份、Git SHA及验收统一见[发布记录](docs/maintenance/RELEASE_PREFLIGHT_2026-10-28.md#正式切换2026-10-29)。 用户已明确文章直接发布到 FNA_V3,不经过 ATOM。实现、调用契约、凭据边界及验收见[独立文章 API](docs/maintenance/EDITORIAL_API.md)。后续写稿使用本站草稿→签名预览→按hash发布→回执核对流程;相关旧文内链修改可同批提交。不要再用临时SQL脚本发布普通编辑文章。旧文首次接管必须显式指定原文章ID并保留URL;发布结果未知先查原请求UUID。代码已于2026-10-29随正式切换部署,生产临时编辑密钥在教程迁移后删除、接口默认关闭;后续按明确写作/发布任务临时启用并在完成后关闭,不扩大线上访问或发布范围。正式部署、备份、Git SHA及验收统一见[发布记录](docs/maintenance/RELEASE_PREFLIGHT_2026-10-28.md#正式切换2026-10-29)。
@ -50,6 +56,10 @@ ## 独立文章发布 API
2026-11-02本地新增独立“看看资讯”栏目 `/news`,无文章、未发布;创建命令、分类备份及验收见[资讯栏目维护](docs/maintenance/NEWS.md)。 2026-11-02本地新增独立“看看资讯”栏目 `/news`,无文章、未发布;创建命令、分类备份及验收见[资讯栏目维护](docs/maintenance/NEWS.md)。
2026-11-02本地将“综合教程”改为“进阶教程”,地址仍 `/advanced`,并统一桌面/手机顶级栏目图标对齐,尚未发布。当前名称、命令和验收见[栏目导航维护](docs/maintenance/NAVIGATION.md)。
2026-11-03本地新增“暂避风头模式”:暂停免费节点发布更新并隐藏机场广告/推荐,保留旧资源和其他文章。命令 `php artisan site:quiet on|off|status`,当前本地关闭、未发布;唯一实现、权限和验收见[暂避风头模式](docs/maintenance/QUIET_MODE.md)。
## SEO统一合同(2026-10-31) ## SEO统一合同(2026-10-31)
首页、归档、教程、分页、索引、sitemap、内容更新时间和IndexNow统一按[SEO实施维护](docs/maintenance/SEO_IMPLEMENTATION_2026-10-31.md)。禁止恢复全query robots屏蔽、分页canonical第一页、按180天批量删正文、用点赞updated_at作为lastmod,或让源图删除影响本站独立副本。当前状态和未取得的搜索平台证据见该文档;实施不等于收录/排名已验证。 首页、归档、教程、分页、索引、sitemap、内容更新时间和IndexNow统一按[SEO实施维护](docs/maintenance/SEO_IMPLEMENTATION_2026-10-31.md)。禁止恢复全query robots屏蔽、分页canonical第一页、按180天批量删正文、用点赞updated_at作为lastmod,或让源图删除影响本站独立副本。当前状态和未取得的搜索平台证据见该文档;实施不等于收录/排名已验证。
@ -58,3 +68,5 @@ ## SEO统一合同(2026-10-31)
2026-10-31用户纠正:SEO只做内部优化,用户看到的布局、样式、可见标题/说明、图片呈现、导航和广告位置必须保持原界面;不因SEO增加说明块、分页条或改版。后续可见改动须用户另行明确要求。恢复基线与验证见[界面保留修复](docs/maintenance/SEO_UI_RESTORE_2026-10-31.md)。 2026-10-31用户纠正:SEO只做内部优化,用户看到的布局、样式、可见标题/说明、图片呈现、导航和广告位置必须保持原界面;不因SEO增加说明块、分页条或改版。后续可见改动须用户另行明确要求。恢复基线与验证见[界面保留修复](docs/maintenance/SEO_UI_RESTORE_2026-10-31.md)。
2026-11-02用户授权全部待发布变更上线:暗色主题、苹果ID卡片与密码隐藏、新手教程与双比例图、七篇旧文归类、资讯栏目、点赞配置、品牌和最终HardNerd广告均已发布。当前正式范围、Git恢复点与服务器侧验收见[整批发布记录](docs/maintenance/FULL_RELEASE_2026-11-02.md),文章关系继续以写作索引为准;未做线上页面验收。 2026-11-02用户授权全部待发布变更上线:暗色主题、苹果ID卡片与密码隐藏、新手教程与双比例图、七篇旧文归类、资讯栏目、点赞配置、品牌和最终HardNerd广告均已发布。当前正式范围、Git恢复点与服务器侧验收见[整批发布记录](docs/maintenance/FULL_RELEASE_2026-11-02.md),文章关系继续以写作索引为准;未做线上页面验收。
2026-11-02用户明确往期noindex,并授权本地入口/往期栏目调整:两个导航显示最新可展示资源,新增 `/history` 及两个子栏目,正文加入往期链接,日期网址保留自指canonical;仅本地,未发布。唯一合同、测试和回退见[SEO实施维护](docs/maintenance/SEO_IMPLEMENTATION_2026-10-31.md#稳定入口与往期内容2026-11-02已本地实施未发布)。

View File

@ -10,36 +10,47 @@
/** Category and pin metadata only; article bodies/categories are published through the editorial API. */ /** Category and pin metadata only; article bodies/categories are published through the editorial API. */
final class OrganizeTutorials extends Command final class OrganizeTutorials extends Command
{ {
protected $signature = 'tutorials:organize {--apply : Save the category name and pin the published guide}'; protected $signature = 'tutorials:organize {--apply : Save the category name and pin its configured articles}';
protected $description = 'Rename the beginner category and pin its verified guide, with a private metadata backup'; protected $description = 'Rename the beginner category and pin its verified entry articles, with a private metadata backup';
public function handle(): int public function handle(): int
{ {
$category = DB::table('categories')->where('sSlug', config('tutorials.beginner_slug'))->where('iStatus', 1)->first(); $category = DB::table('categories')->where('sSlug', config('tutorials.beginner_slug'))->where('iStatus', 1)->first();
$guide = DB::table('articles')->where('sSlug', config('tutorials.guide_slug'))->where('iStatus', 1)->first(); $slugs = array_values(array_unique(config('tutorials.pinned_slugs', [config('tutorials.guide_slug')])));
if (! $category || ! $guide || $guide->iCategoryId !== $category->id) { $articles = DB::table('articles')->whereIn('sSlug', $slugs)->where('iStatus', 1)->get();
$this->error('Publish the guide in the beginner category before applying metadata.'); if (! $category || ! $slugs || $articles->count() !== count($slugs)
|| $articles->contains(fn ($article) => (int) $article->iCategoryId !== (int) $category->id)) {
$this->error('Publish every configured entry article in the beginner category before applying metadata.');
return self::FAILURE; return self::FAILURE;
} }
$this->line(json_encode(['category' => $category->sSlug, 'name' => config('tutorials.beginner_name'), 'guide' => $guide->sSlug, 'pinned' => true], JSON_UNESCAPED_UNICODE)); $this->line(json_encode(['category' => $category->sSlug, 'name' => config('tutorials.beginner_name'), 'articles' => $slugs, 'pinned' => true], JSON_UNESCAPED_UNICODE));
if (! $this->option('apply')) return self::SUCCESS; if (! $this->option('apply')) return self::SUCCESS;
$backup = storage_path('app/private/editorial/tutorial-metadata-'.Str::uuid().'.json'); $backup = storage_path('app/private/editorial/tutorial-metadata-'.Str::uuid().'.json');
$snapshot = ['category_id' => $category->id, 'sName' => $category->sName, 'guide_id' => $guide->id, 'iIsTop' => $guide->iIsTop]; $snapshot = ['category_id' => $category->id, 'sName' => $category->sName,
file_put_contents($backup, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); 'articles' => $articles->map(fn ($article) => ['id' => $article->id, 'sSlug' => $article->sSlug, 'iIsTop' => $article->iIsTop])->all()];
chmod($backup, 0600); $json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
DB::transaction(function () use ($category, $guide) { if (file_put_contents($backup, $json, LOCK_EX) !== strlen($json) || ! chmod($backup, 0600)) {
throw new \RuntimeException('Unable to save the private metadata backup.');
}
DB::transaction(function () use ($category, $articles) {
$currentCategory = DB::table('categories')->where('id', $category->id)->lockForUpdate()->first(); $currentCategory = DB::table('categories')->where('id', $category->id)->lockForUpdate()->first();
$currentGuide = DB::table('articles')->where('id', $guide->id)->lockForUpdate()->first(); if (! $currentCategory || $currentCategory->sName !== $category->sName || ! $currentCategory->iStatus) {
if ($currentCategory->sName !== $category->sName || $currentGuide->iCategoryId !== $category->id || ! $currentGuide->iStatus) { throw new \RuntimeException('Tutorial category changed; inspect before retrying.');
throw new \RuntimeException('Tutorial metadata changed; inspect before retrying.'); }
foreach ($articles as $article) {
$current = DB::table('articles')->where('id', $article->id)->lockForUpdate()->first();
if (! $current || (int) $current->iCategoryId !== (int) $category->id || ! $current->iStatus
|| $current->sSlug !== $article->sSlug || (int) $current->iIsTop !== (int) $article->iIsTop) {
throw new \RuntimeException('Tutorial metadata changed; inspect before retrying.');
}
DB::table('articles')->where('id', $article->id)->update(['iIsTop' => 1]);
} }
DB::table('categories')->where('id', $category->id)->update(['sName' => config('tutorials.beginner_name')]); DB::table('categories')->where('id', $category->id)->update(['sName' => config('tutorials.beginner_name')]);
DB::table('articles')->where('id', $guide->id)->update(['iIsTop' => 1]);
}); });
Cache::forget('global_nav_tree'); Cache::forget('global_nav_tree');
\Spatie\ResponseCache\Facades\ResponseCache::clear(); \Spatie\ResponseCache\Facades\ResponseCache::clear();
$this->info('Category renamed and guide pinned. Private backup: '.$backup); $this->info('Category name saved and configured entry articles pinned. Private backup: '.$backup);
return self::SUCCESS; return self::SUCCESS;
} }
} }

View File

@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Services\SiteMode;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
final class QuietMode extends Command
{
protected $signature = 'site:quiet {state=status : on, off, or status}';
protected $description = 'Pause free-node publishing and hide airport promotions without deleting data';
public function handle(): int
{
$state = $this->argument('state');
if (! in_array($state, ['on','off','status'], true)) {
$this->error('Use on, off, or status.');
return self::FAILURE;
}
if ($state !== 'status') {
$path = config('site_mode.state_path');
if (! is_dir(dirname($path))) mkdir(dirname($path), 0700, true);
$temporary = $path.'.'.Str::uuid().'.tmp';
try {
$handle = fopen($temporary, 'x');
if (! $handle) throw new \RuntimeException('Cannot create mode state.');
chmod($temporary, 0600);
$payload = json_encode(['quiet' => $state === 'on', 'changed_at' => now()->toIso8601String()]);
try {
if (fwrite($handle, $payload) !== strlen($payload) || ! fflush($handle)) {
throw new \RuntimeException('Cannot save mode state.');
}
} finally { fclose($handle); }
if (! rename($temporary, $path)) throw new \RuntimeException('Cannot activate mode state.');
} finally { if (is_file($temporary)) unlink($temporary); }
\Spatie\ResponseCache\Facades\ResponseCache::clear();
}
$this->info('暂避风头模式:'.(SiteMode::quiet() ? '开启(节点更新暂停,机场广告隐藏)' : '关闭(正常模式)'));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class RenameAdvancedCategory extends Command
{
protected $signature = 'tutorials:rename-advanced {--apply : Save the category display name}';
protected $description = 'Rename 综合教程 to 进阶教程, preserving URLs and article membership';
public function handle(): int
{
$category = DB::table('categories')->where('sSlug', 'advanced')->where('iParentId', 0)->where('iStatus', 1)->first();
if (! $category || ! in_array($category->sName, ['综合教程', '进阶教程'], true)) {
$this->error('Unexpected category state; inspect before changing it.');
return self::FAILURE;
}
$this->line($category->sName.' → 进阶教程; URL remains /advanced.');
if (! $this->option('apply')) return self::SUCCESS;
if ($category->sName !== '进阶教程') {
$directory = storage_path('app/private/category-backups');
if (! is_dir($directory)) mkdir($directory, 0700, true);
$backup = $directory.'/advanced-name-'.Str::uuid().'.json';
if (file_put_contents($backup, json_encode($category, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) === false) {
throw new \RuntimeException('Category backup failed.');
}
chmod($backup, 0600);
$updated = DB::table('categories')->where('id', $category->id)->where('sName', $category->sName)
->where('sSlug', 'advanced')->where('iParentId', 0)->where('iStatus', 1)->update(['sName' => '进阶教程']);
if ($updated !== 1) throw new \RuntimeException('Category changed; inspect before retrying.');
$this->info('Private backup: '.$backup);
}
Cache::forget('global_nav_tree');
\Spatie\ResponseCache\Facades\ResponseCache::clear();
$this->info('进阶教程 ready; article content and membership unchanged.');
return self::SUCCESS;
}
}

View File

@ -48,6 +48,7 @@ public function receive(Request $oRequest)
$aOptTag = explode(",", $sOptTag); $aOptTag = explode(",", $sOptTag);
$oCategory = Categories::where("sName", $sOptCategoryName)->first(); $oCategory = Categories::where("sName", $sOptCategoryName)->first();
\App\Services\SiteMode::assertUpdatesAllowed($oCategory?->sSlug);
if (!$oCategory) { if (!$oCategory) {
$sMsg = "不存在的文章分类:{$sOptCategoryName}"; $sMsg = "不存在的文章分类:{$sOptCategoryName}";
@ -63,6 +64,7 @@ public function receive(Request $oRequest)
]; ];
$oArticle = Articles::where("sSlug", $sSlug)->first(); $oArticle = Articles::where("sSlug", $sSlug)->first();
if ($oArticle) \App\Services\SiteMode::assertUpdatesAllowed($oArticle->oCategory?->sSlug);
if ($oArticle && \Illuminate\Support\Facades\DB::table('editorial_documents')->where('article_id',$oArticle->id)->exists()) { 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'); abort(409, 'This article is managed by the editorial API');
@ -186,6 +188,7 @@ public function receiveShadowrocketids(Request $oRequest)
public function receiveFreenode(Request $oRequest) public function receiveFreenode(Request $oRequest)
{ {
\App\Services\SiteMode::assertUpdatesAllowed('freenode');
$sObjName = "nodehub:article_receive_freenodeV2"; $sObjName = "nodehub:article_receive_freenodeV2";
$oFlow = Flow::make($sObjName); $oFlow = Flow::make($sObjName);
@ -265,6 +268,7 @@ public function receiveFreenode(Request $oRequest)
public function receiveFreenode_laji(Request $oRequest) public function receiveFreenode_laji(Request $oRequest)
{ {
\App\Services\SiteMode::assertUpdatesAllowed('freenode');
$sObjName = "nodehub:article_receive_freenode"; $sObjName = "nodehub:article_receive_freenode";
$oFlow = Flow::make($sObjName); $oFlow = Flow::make($sObjName);

View File

@ -4,6 +4,7 @@
use App\Models\Categories; use App\Models\Categories;
use App\Services\Seo; use App\Services\Seo;
use App\Services\ArticlesService; use App\Services\ArticlesService;
use App\Services\ResourcePages;
use Illuminate\Http\Response; use Illuminate\Http\Response;
class SitemapController extends Controller class SitemapController extends Controller
{ {
@ -13,7 +14,12 @@ public function index(): Response
$latest = app(ArticlesService::class)->getLatestFreeNodeSlug(); $latest = app(ArticlesService::class)->getLatestFreeNodeSlug();
$home = $latest ? $articles->firstWhere('sSlug',$latest) : null; $home = $latest ? $articles->firstWhere('sSlug',$latest) : null;
$entries = $home ? [['url'=>Seo::url(),'modified'=>Seo::modified($home)]] : []; $entries = $home ? [['url'=>Seo::url(),'modified'=>Seo::modified($home)]] : [];
$latestId = app(ArticlesService::class)->getLatestResourceSlug('shadowrocketids');
if ($latestId && ($idArticle = $articles->firstWhere('sSlug', $latestId))) {
$entries[] = ['url'=>Seo::url(ResourcePages::entryPath('shadowrocketids')), 'modified'=>Seo::modified($idArticle)];
}
foreach (Categories::where('iStatus',1)->with('oParent')->orderBy('id')->get() as $category) { foreach (Categories::where('iStatus',1)->with('oParent')->orderBy('id')->get() as $category) {
if (ResourcePages::supports($category->sSlug)) continue;
if ($category->iParentId && (!$category->oParent || !$category->oParent->iStatus)) continue; if ($category->iParentId && (!$category->oParent || !$category->oParent->iStatus)) continue;
$members = $articles->filter(fn($a)=>$a->iCategoryId === $category->id || ($category->iShowChildren && $a->oCategory->iParentId === $category->id)); $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]; if ($members->isNotEmpty()) $entries[] = ['url'=>Seo::url(Seo::categoryPath($category)),'modified'=>null];

View File

@ -4,6 +4,8 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\ArticlesService; use App\Services\ArticlesService;
use App\Services\ResourcePages;
use App\Services\Seo;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Articles; use App\Models\Articles;
@ -57,14 +59,33 @@ public function home(Request $oRequest)
return redirect(\App\Services\Seo::url('/search/'.rawurlencode(trim($oRequest->query('s')))),301); return redirect(\App\Services\Seo::url('/search/'.rawurlencode(trim($oRequest->query('s')))),301);
} }
$page = \App\Services\Seo::page($oRequest); $page = \App\Services\Seo::page($oRequest);
if ($oRequest->has('page')) return redirect(\App\Services\Seo::url($page>1?'/freenode':'/', $page),301); if ($oRequest->has('page')) return redirect(Seo::url($page>1?ResourcePages::historyPath('freenode'):'/', $page),301);
$slug = $this->oArticlesService->getLatestFreeNodeSlug(); return $this->resourceEntry($oRequest, 'freenode');
if (!$slug) return response()->view('pages.articles.default.unavailable', [], 503)->header('Retry-After','3600'); }
public function resource(Request $request, string $type)
{
$page = Seo::page($request);
if ($request->has('page')) return redirect(Seo::url($page > 1 ? ResourcePages::historyPath($type) : ResourcePages::entryPath($type), $page), 301);
if ($type === 'freenode') return redirect(Seo::url(), 301);
return $this->resourceEntry($request, $type);
}
private function resourceEntry(Request $oRequest, string $type)
{
$slug = $this->oArticlesService->getLatestResourceSlug($type);
if (!$slug) return response()->view('pages.articles.default.unavailable', ['resourceType'=>$type], 503)->header('Retry-After','3600');
$data = $this->articleData($slug, $oRequest); $data = $this->articleData($slug, $oRequest);
$data['bIsHome'] = true; $data['bIsHome'] = $type === 'freenode';
$data['seo'] = \App\Services\Seo::articleMeta($data['oArticle'], true); $data['bResourceEntry'] = true;
$data['seo'] = Seo::resourceEntryMeta($data['oArticle'], $type);
return view('pages.articles.default.show', $data); return view('pages.articles.default.show', $data);
} }
public function history(Request $request, ?string $type = null)
{
$page = Seo::page($request);
$data = $this->oArticlesService->getResourceHistory($type);
$data['seo'] = Seo::listMeta($data['historyHeading'], '按发布时间查看往期资源记录。', ResourcePages::historyPath($type), $page, false);
return $this->listing($request, $data);
}
public function list(Request $oRequest, string $sSlug1, ?string $sSlug2 = null) public function list(Request $oRequest, string $sSlug1, ?string $sSlug2 = null)
{ {
$page = \App\Services\Seo::page($oRequest); $page = \App\Services\Seo::page($oRequest);

View File

@ -109,12 +109,13 @@ public function scopePublicVisible($query)
protected static function booted() protected static function booted()
{ {
static::saving(function ($article) { static::saving(function ($article) {
\App\Services\SiteMode::guardArticle($article);
// Historical test fixtures and migration tooling may use the pre-SEO schema. // Historical test fixtures and migration tooling may use the pre-SEO schema.
if (!\Illuminate\Support\Facades\Schema::hasColumn('articles', 'sContentUpdatedAt')) return; if (!\Illuminate\Support\Facades\Schema::hasColumn('articles', 'sContentUpdatedAt')) return;
if (!$article->exists || $article->isDirty(['sTitle','sTitleSub','sContent','sThumb','iCategoryId','iStatus','bPageThumb'])) { if (!$article->exists || $article->isDirty(['sTitle','sTitleSub','sContent','sThumb','iCategoryId','iStatus','bPageThumb'])) {
$article->sContentUpdatedAt = now(); $article->sContentUpdatedAt = now();
} }
if (!$article->exists && $article->oCategory?->sSlug === 'freenode') $article->sSeoPolicy = 'archive'; if (!$article->exists && \App\Services\ResourcePages::supports($article->oCategory?->sSlug)) $article->sSeoPolicy = 'archive';
}); });
static::saved(function ($article) { static::saved(function ($article) {
if ($article->wasRecentlyCreated || $article->wasChanged(['sTitle','sTitleSub','sContent','sThumb','iCategoryId','iStatus'])) { if ($article->wasRecentlyCreated || $article->wasChanged(['sTitle','sTitleSub','sContent','sThumb','iCategoryId','iStatus'])) {

View File

@ -10,7 +10,7 @@ final class ArticleRecommendations
public static function providers(): array public static function providers(): array
{ {
if (! config('airport_recommendations.enabled')) return []; if (SiteMode::quiet() || ! config('airport_recommendations.enabled')) return [];
$providers = array_filter(config('airport_recommendations.providers', []), fn ($p) => $providers = array_filter(config('airport_recommendations.providers', []), fn ($p) =>
($p['enabled'] ?? false) && filter_var($p['url'] ?? '', FILTER_VALIDATE_URL) ($p['enabled'] ?? false) && filter_var($p['url'] ?? '', FILTER_VALIDATE_URL)
&& in_array(parse_url($p['url'], PHP_URL_SCHEME), ['https', 'http'], true)); && in_array(parse_url($p['url'], PHP_URL_SCHEME), ['https', 'http'], true));
@ -26,8 +26,9 @@ public static function prepare(Articles $article): array
$slug = $category?->sSlug; $slug = $category?->sSlug;
$scope = in_array($slug, ['freenode', 'shadowrocketids', 'advanced', 'client'], true) $scope = in_array($slug, ['freenode', 'shadowrocketids', 'advanced', 'client'], true)
|| $category?->oParent?->sSlug === 'client'; || $category?->oParent?->sSlug === 'client';
$quiet = SiteMode::quiet();
$enabled = $scope && self::providers() !== []; $enabled = $scope && self::providers() !== [];
if (! $enabled) return ['body' => $html, 'enabled' => false, 'inline' => false]; if (! $enabled && ! $quiet) return ['body' => $html, 'enabled' => false, 'inline' => false];
$inline = false; $inline = false;
if (in_array($slug, ['freenode', 'shadowrocketids'], true)) { if (in_array($slug, ['freenode', 'shadowrocketids'], true)) {
@ -48,6 +49,11 @@ public static function prepare(Articles $article): array
} }
} }
} }
if ($quiet) {
$html = str_replace(self::SLOT, '', $html);
$html = preg_replace('~<p\b[^>]*class=["\'][^"\']*airport-recommendation-jump[^"\']*["\'][^>]*>.*?</p>~si', '', $html);
return ['body' => $html, 'enabled' => false, 'inline' => false];
}
if ($slug === 'advanced') { if ($slug === 'advanced') {
$anchor = '#'.config('airport_recommendations.anchor'); $anchor = '#'.config('airport_recommendations.anchor');
if (! str_contains($html, $anchor)) { if (! str_contains($html, $anchor)) {

View File

@ -162,12 +162,39 @@ public function getTagArticles(string $slug, string $resultType = 'view')
} }
public function getLatestFreeNodeSlug(): ?string public function getLatestFreeNodeSlug(): ?string
{ {
foreach (Articles::publicVisible()->whereHas('oCategory', fn($q)=>$q->where('sSlug','freenode')) return $this->getLatestResourceSlug('freenode');
}
public function getLatestResourceSlug(string $type): ?string
{
abort_unless(ResourcePages::supports($type), 404);
foreach (Articles::publicVisible()->whereHas('oCategory', fn($q)=>$q->where('sSlug',$type))
->orderByDesc('created_at')->orderByDesc('id')->cursor() as $article) { ->orderByDesc('created_at')->orderByDesc('id')->cursor() as $article) {
if (ResourcePublication::usable($article)) return $article->sSlug; $usable = $type === 'freenode'
? ResourcePublication::usable($article)
: ResourcePublication::usableAppleId($article);
if ($usable) return $article->sSlug;
} }
return null; return null;
} }
public function getResourceHistory(?string $type = null): array
{
abort_if($type !== null && !ResourcePages::supports($type), 404);
$categories = Categories::whereIn('sSlug', ResourcePages::TYPES)->where('iStatus', 1)
->with('oParent')->get()->filter(fn($c) => !$c->iParentId || $c->oParent?->iStatus);
$category = $type ? $categories->firstWhere('sSlug', $type) : null;
abort_if($type && !$category, 404);
$latest = [];
foreach ($categories as $item) {
if ($slug = $this->getLatestResourceSlug($item->sSlug)) $latest[] = $slug;
}
$articles = Articles::publicVisible()->with('oCategory.oParent')
->whereIn('iCategoryId', $category ? [$category->id] : $categories->pluck('id'))
->whereNotIn('sSlug', $latest)->orderByDesc('created_at')->orderByDesc('id')
->paginate(config('seo.history_page_size', 10));
return ['oArticles' => $articles, 'oCategory' => $category, 'historyCategories' => $categories,
'historyType' => $type, 'bResourceHistory' => true,
'historyHeading' => $category ? $category->sName.' · 往期资源' : '往期资源'];
}
public function getCategoryArticles(string $slug, string $resultType = 'view') public function getCategoryArticles(string $slug, string $resultType = 'view')
{ {
$category = Categories::where('sSlug', $slug)->where('iStatus',1)->with('oParent')->firstOrFail(); $category = Categories::where('sSlug', $slug)->where('iStatus',1)->with('oParent')->firstOrFail();

View File

@ -0,0 +1,24 @@
<?php
namespace App\Services;
/** Stable resource entrances and read-only history views; no category/data migration. */
final class ResourcePages
{
public const TYPES = ['freenode', 'shadowrocketids'];
public static function supports(?string $type): bool
{
return in_array($type, self::TYPES, true);
}
public static function entryPath(string $type): string
{
return $type === 'freenode' ? '/' : '/shadowrocketids';
}
public static function historyPath(?string $type = null): string
{
return '/history'.($type ? '/'.$type : '');
}
}

View File

@ -6,9 +6,18 @@ final class ResourcePublication
public static function usable(Articles $article): bool public static function usable(Articles $article): bool
{ {
if (in_array($article->sResourceStatus, ['failed','empty'], true)) return false; if (in_array($article->sResourceStatus, ['failed','empty'], true)) return false;
if ($article->iResourceCount !== null && (int)$article->iResourceCount === 0) return false;
if (array_key_exists('sContent', $article->getAttributes()) && trim(Seo::text($article->sContent)) === '') return false;
// Legacy explicit zero is evidence of no result, not a successful refresh. // Legacy explicit zero is evidence of no result, not a successful refresh.
return !preg_match('/(?:共|更新)\s*0\s*个可用节点/u', Seo::text($article->sTitleSub)); return !preg_match('/(?:共|更新)\s*0\s*个可用节点/u', Seo::text($article->sTitleSub));
} }
public static function usableAppleId(Articles $article): bool
{
if (in_array($article->sResourceStatus, ['failed','empty'], true)) return false;
$layout = AppleIdArticleLayout::prepare($article->sContent ?? '');
// Unknown/empty payloads remain accessible as records, never replace the latest entrance.
return $layout !== null && count($layout['accounts']) > 0;
}
public static function validate(array $article): array public static function validate(array $article): array
{ {
validator($article, ['title'=>'required|string|max:250','title_sub'=>'present|string|max:4000', validator($article, ['title'=>'required|string|max:250','title_sub'=>'present|string|max:4000',

View File

@ -16,6 +16,7 @@ public static function url(string $path = '/', int $page = 1): string
public static function articleUrl(Articles $article): string { return self::url('/a/'.$article->sSlug.'.html'); } public static function articleUrl(Articles $article): string { return self::url('/a/'.$article->sSlug.'.html'); }
public static function categoryPath(Categories $category): string public static function categoryPath(Categories $category): string
{ {
if (ResourcePages::supports($category->sSlug)) return ResourcePages::entryPath($category->sSlug);
return ($category->oParent ? '/'.$category->oParent->sSlug : '').'/'.$category->sSlug; return ($category->oParent ? '/'.$category->oParent->sSlug : '').'/'.$category->sSlug;
} }
public static function categoryName(Categories $category): string public static function categoryName(Categories $category): string
@ -61,12 +62,10 @@ public static function public(Articles $a): bool
} }
public static function indexable(Articles $a): bool public static function indexable(Articles $a): bool
{ {
// Resource date URLs are snapshots. Only their stable entrances participate in search.
if (ResourcePages::supports($a->oCategory?->sSlug)) return false;
if (!self::public($a) || in_array($a->sSeoPolicy, ['archive','noindex'], true)) return false; if (!self::public($a) || in_array($a->sSeoPolicy, ['archive','noindex'], true)) return false;
if ($a->sSeoPolicy === 'index') return true; 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 public static function modified(Articles $a): ?string
{ {
@ -107,4 +106,15 @@ public static function listMeta(string $heading, string $description, string $pa
'description'=>self::text($description), 'canonical'=>$url, 'robots'=>$index?'index, follow':'noindex, follow', 'description'=>self::text($description), 'canonical'=>$url, 'robots'=>$index?'index, follow':'noindex, follow',
'schema'=>[['@context'=>'https://schema.org','@type'=>'CollectionPage','name'=>$heading,'url'=>$url]]]; 'schema'=>[['@context'=>'https://schema.org','@type'=>'CollectionPage','name'=>$heading,'url'=>$url]]];
} }
public static function resourceEntryMeta(Articles $article, string $type): array
{
if ($type === 'freenode') return self::articleMeta($article, true);
$title = self::categoryName($article->oCategory);
$url = self::url(ResourcePages::entryPath($type));
$schema = ['@context'=>'https://schema.org', '@type'=>'WebPage', 'name'=>$title, 'url'=>$url];
if ($modified = self::modified($article)) $schema['dateModified'] = $modified;
return ['title'=>$title.' - '.config('seo.brand'), 'heading'=>$title,
'description'=>'查看最近更新的免费小火箭ID,按地区选择共享账号,并查看使用说明和往期内容。',
'canonical'=>$url, 'robots'=>'index, follow', 'type'=>'website', 'schema'=>[$schema]];
}
} }

View File

@ -12,7 +12,6 @@ public function changed(Articles $a, bool $deleted = false): void
$urls = []; $urls = [];
if ($deleted || !Seo::public($a)) $urls[] = Seo::articleUrl($a); if ($deleted || !Seo::public($a)) $urls[] = Seo::articleUrl($a);
elseif (Seo::indexable($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)); if ($a->oCategory) $urls[] = Seo::url(Seo::categoryPath($a->oCategory));
$event = $deleted || !Seo::public($a) ? 'delete' : 'update'; $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])); $key = hash('sha256',json_encode([$a->id,$event,$a->sContentUpdatedAt,$a->sTitle,$a->sContent,$a->sThumb,$a->iStatus]));
@ -37,10 +36,13 @@ public function retry(int $limit = 10): int
$urls = json_decode($row->urls,true); $urls = json_decode($row->urls,true);
if ($row->event !== 'delete') $urls = array_values(array_filter($urls,function($url) { if ($row->event !== 'delete') $urls = array_values(array_filter($urls,function($url) {
$path = parse_url($url,PHP_URL_PATH); $path = parse_url($url,PHP_URL_PATH);
if ($path === '/history' || str_starts_with($path, '/history/')) return false;
if (preg_match('~^/a/([a-zA-Z0-9_-]+)\.html$~',$path,$m)) { if (preg_match('~^/a/([a-zA-Z0-9_-]+)\.html$~',$path,$m)) {
$a = Articles::where('sSlug',$m[1])->first(); return $a && Seo::indexable($a); $a = Articles::where('sSlug',$m[1])->first(); return $a && Seo::indexable($a);
} }
if ($path === '/') return app(ArticlesService::class)->getLatestFreeNodeSlug() !== null; if ($path === '/') return app(ArticlesService::class)->getLatestFreeNodeSlug() !== null;
if ($path === '/freenode') return false;
if ($path === '/shadowrocketids') return app(ArticlesService::class)->getLatestResourceSlug('shadowrocketids') !== null;
return true; return true;
})); }));
$state = app(IndexNowNotification::class)->submit($state,$urls); $state = app(IndexNowNotification::class)->submit($state,$urls);

40
app/Services/SiteMode.php Normal file
View File

@ -0,0 +1,40 @@
<?php
namespace App\Services;
use App\Models\Articles;
use App\Models\Categories;
use Illuminate\Http\Exceptions\HttpResponseException;
final class SiteMode
{
public static function quiet(): bool
{
$path = config('site_mode.state_path');
if (! $path || ! is_file($path)) return (bool) config('site_mode.quiet', false);
$state = json_decode((string) file_get_contents($path), true);
// An unreadable/invalid operational flag must not silently resume publishing.
return ! is_array($state) || ! is_bool($state['quiet'] ?? null) || $state['quiet'];
}
public static function assertUpdatesAllowed(?string $type): void
{
if ($type !== 'freenode' || ! self::quiet()) return;
throw new HttpResponseException(response()->json([
'isDone' => false, 'status' => 'paused', 'code' => 'FREE_NODE_UPDATES_PAUSED',
'message' => 'Free node updates are temporarily paused; existing publications are retained.',
], 503, ['Retry-After' => '3600']));
}
public static function guardArticle(Articles $article): void
{
if (! self::quiet()) return;
$fields = ['sSlug','iCategoryId','sTitle','sTitleSub','sContent','sThumb',
'iStatus','created_at','sDisplayPublishedAt','sResourceStatus','iResourceCount'];
if ($article->exists && ! $article->isDirty($fields)) return;
$ids = array_filter([$article->iCategoryId, $article->getRawOriginal('iCategoryId')]);
if ($ids && Categories::whereIn('id', $ids)->where('sSlug', 'freenode')->exists()) {
self::assertUpdatesAllowed('freenode');
}
}
}

View File

@ -4,4 +4,5 @@
'home_title' => env('SEO_HOME_TITLE', '免费节点与订阅使用指南'), 'home_title' => env('SEO_HOME_TITLE', '免费节点与订阅使用指南'),
'home_description' => '获取最近更新的免费节点与订阅,查看 Clash、V2ray 客户端新手教程和连接排错指南。资源状态随时间和网络环境变化。', 'home_description' => '获取最近更新的免费节点与订阅,查看 Clash、V2ray 客户端新手教程和连接排错指南。资源状态随时间和网络环境变化。',
'page_size' => env('page_limit', 10), 'page_size' => env('page_limit', 10),
'history_page_size' => 10,
]; ];

6
config/site_mode.php Normal file
View File

@ -0,0 +1,6 @@
<?php
return [
'quiet' => env('SITE_QUIET_MODE', false),
'state_path' => storage_path('app/private/site-mode.json'),
];

View File

@ -2,8 +2,13 @@
return [ return [
'guide_slug' => 'getting-started', 'guide_slug' => 'getting-started',
'pinned_slugs' => ['getting-started', 'proxy-clients', 'free-nodes-explained', 'shadowrocket-id-explained'],
'beginner_slug' => 'client', 'beginner_slug' => 'client',
'beginner_name' => '新手教程', 'beginner_name' => '新手教程',
'resource_explainers' => [
'freenode' => ['slug' => 'free-nodes-explained', 'title' => '免费节点是什么?', 'description' => '从网页打不开的例子,认识软件、线路和订阅。'],
'shadowrocketids' => ['slug' => 'shadowrocket-id-explained', 'title' => '免费小火箭 ID 是什么?有什么用?', 'description' => '账号用于获取应用,节点用于连接网络;先看懂两者的区别。'],
],
// Dedicated platform mastheads; the /client overview intentionally has none. // Dedicated platform mastheads; the /client overview intentionally has none.
'category_banners' => [ 'category_banners' => [
'windows' => ['image' => '/images/tutorial-categories/windows-paper-banner.jpg', 'title' => 'Windows'], 'windows' => ['image' => '/images/tutorial-categories/windows-paper-banner.jpg', 'title' => 'Windows'],

View File

@ -10,12 +10,15 @@ # AI 写作与文章关联索引
2026-10-31新增[统一机场推荐模块](../maintenance/AIRPORT_RECOMMENDATIONS.md):入门/综合教程自动在末尾展示,综合教程由展示层补入文内跳转;不要在正文源重复维护机场价格、地址和优惠码,也不要创建同名airport-recommendations锚点。已随[界面发布](../maintenance/UI_RELEASE_2026-10-31.md)上线。 2026-10-31新增[统一机场推荐模块](../maintenance/AIRPORT_RECOMMENDATIONS.md):入门/综合教程自动在末尾展示,综合教程由展示层补入文内跳转;不要在正文源重复维护机场价格、地址和优惠码,也不要创建同名airport-recommendations锚点。已随[界面发布](../maintenance/UI_RELEASE_2026-10-31.md)上线。
2026-11-02用户将本地“综合教程”改为“进阶教程”,保留 `/advanced`、文章归属和URL;本次改名尚未上线,后续本地写作沿新栏目名。导航图标对齐及元数据维护见[栏目导航](../maintenance/NAVIGATION.md)。此前发布记录中旧栏目名保留历史含义。
## 1 写作前先确定读者要完成的任务 ## 1 写作前先确定读者要完成的任务
- 新手教程(原“入门教程”,URL仍为`/client`):以第一次使用为前提,讲准备、下载、安装、配置、使用和基本排错。不要把客户端迁移作为普通入门教程的主线。 - 新手教程(原“入门教程”,URL仍为`/client`):以第一次使用为前提,讲准备、下载、安装、配置、使用和基本排错。不要把客户端迁移作为普通入门教程的主线。
- 下载与选型:讲适用平台、版本/架构、官方来源和选哪个安装包;引用官方发布入口,不把未经验证的第三方包当官方下载。 - 下载与选型:讲适用平台、架构、安装包选择和上游来源;下载优先使用本站 Resource 软件资源服务,官方发布页作补充,遵循下述客户端下载优先级。
- 综合教程(栏目原名“进阶教程”,URL仍为`/advanced`):讲具体机制、配置取舍、迁移或专项排错,并链接读者需要的基础操作。 - 进阶教程(2026-11-02本地由“综合教程”更名,URL仍为`/advanced`):讲具体机制、配置取舍、迁移或专项排错,并链接读者需要的基础操作。
- 看看资讯(`/news`):独立资讯栏目,2026-11-02本地与正式建立,当前无文章;具体栏目维护见[资讯栏目](../maintenance/NEWS.md)。创建栏目不表示已授权自动采集或写作。 - 看看资讯(`/news`):独立资讯栏目,2026-11-02建立后已按用户要求从本地及正式撤下,无文章;具体栏目维护见[资讯栏目](../maintenance/NEWS.md)。创建栏目不表示已授权自动采集或写作。
- 资源与时效内容:区分长期说明和当天资源状态。长期教程优先链接稳定说明/栏目,不依赖会过期的某一天资源页。 - 资源与时效内容:区分长期说明和当天资源状态。长期教程优先链接稳定说明/栏目,不依赖会过期的某一天资源页。
先查询下表和当前数据库,再决定重写已有文章还是新增。主题相同优先完善唯一正文;不同平台可以分别写,但需要实际的平台差异。标题、开头、步骤和关联链接都应服务于同一个任务。 先查询下表和当前数据库,再决定重写已有文章还是新增。主题相同优先完善唯一正文;不同平台可以分别写,但需要实际的平台差异。标题、开头、步骤和关联链接都应服务于同一个任务。
@ -30,6 +33,16 @@ ## 1 写作前先确定读者要完成的任务
自然、准确地表达;必要时用真实操作图、流程图、配置示例。软件版本、下载入口与兼容要求使用当次核对的官方来源;来源核验、页面检查和设备实测分别记录,未做实测不写“实测”。 自然、准确地表达;必要时用真实操作图、流程图、配置示例。软件版本、下载入口与兼容要求使用当次核对的官方来源;来源核验、页面检查和设备实测分别记录,未做实测不写“实测”。
### 客户端下载优先级(2026-11-02)
用户明确要求:客户端下载文章优先走我们的 **Resource**。本规则适用于后续新写和重写的下载/安装步骤,不仅限于当前聊天。
- 优先链接下载总览 `/a/proxy-clients.html` 的对应段落(`#verge`、`#flclash`、`#ios`、`#alternatives`、`#router`),把系统与架构选择集中维护;需要直接下载时使用 Resource 已核实的固定公开安装包链接。
- 软件服务为 `https://downloads.gemcook.net`,入口契约见 [Resource 软件模块](/Users/hellcat/Documents/GemCodes/resource.loc/v2/docs/SOFTWARE.md) 与 [软件部署](/Users/hellcat/Documents/GemCodes/resource.loc/v2/docs/SOFTWARE_DEPLOYMENT.md)。只使用实际目录中可用的 `download_url` / 固定公开路径,不猜文件名、不拼版本 URL、不向读者暴露目录 API 凭据。
- 核对软件、系统、架构、包格式与文件可用性;线上核对经白色机在服务器执行,不从本机请求生产下载源。固定链接会更新文件,但不要无依据称每个平台都是最新版本。
- 下载链接标作“本站下载”或“本站资源下载”;说明官方来源时区分 Resource 托管与官方站点,不把托管链接标成“官方下载”。官方项目、发布页和安装说明保留作补充。
- Resource 未收录或目标架构不可用时保留确切官方入口;iPhone / iPad 应用继续使用 App Store,不能用安卓或桌面安装包替代。当前 OpenClash 仍用官方入口。
### 文章封面与水印(2026-11-01) ### 文章封面与水印(2026-11-01)
每次新写的每篇文章都须独立设计并生成主题图片;不复用同一底图换标题,不沿用单调的标题横线卡片。根据文章的设备、操作或机制构思不同场景、构图和配色,为文章生成与具体主题相符的封面图,并加上本站网址 **clashfreenode.com** 水印。用户提供的参考图以画面表达主题,角落有低调的站点署名;借鉴这种主题视觉与水印的关系,不照搬其他网站的名称、标志或固定版式。封面应适合本站缩略图裁切,在小尺寸下仍能看清主体和水印;文字优先使用中文,应用名称及品牌名保留原名,水印不遮挡关键信息。生成后检查实际渲染的品牌拼写、可读性和裁切效果,必要时调整;按文章API的图片上传与发布流程交付。 每次新写的每篇文章都须独立设计并生成主题图片;不复用同一底图换标题,不沿用单调的标题横线卡片。根据文章的设备、操作或机制构思不同场景、构图和配色,为文章生成与具体主题相符的封面图,并加上本站网址 **clashfreenode.com** 水印。用户提供的参考图以画面表达主题,角落有低调的站点署名;借鉴这种主题视觉与水印的关系,不照搬其他网站的名称、标志或固定版式。封面应适合本站缩略图裁切,在小尺寸下仍能看清主体和水印;文字优先使用中文,应用名称及品牌名保留原名,水印不遮挡关键信息。生成后检查实际渲染的品牌拼写、可读性和裁切效果,必要时调整;按文章API的图片上传与发布流程交付。
@ -80,39 +93,41 @@ ### AI 新文章的点赞参数(2026-10-29)
## 2 当前文章登记 ## 2 当前文章登记
本地登记更新:2026-11-01。新手栏目现有8篇新文,原7篇平台教程移至综合教程,URL、正文、日期和点赞进度保留。分类与新稿已于2026-11-02通过正式API发布;正式ID、版本及验收见[整批发布记录](../maintenance/FULL_RELEASE_2026-11-02.md)。具体批次与回退见[新手教程调整](../maintenance/NEWCOMER_TUTORIALS_2026-11-01.md)。 本地登记更新:2026-11-02。新手栏目现有10篇新文,其中两篇资源概念说明仅本地;此前8篇新文,原7篇平台教程移至综合教程,URL、正文、日期和点赞进度保留。分类与新稿已于2026-11-02通过正式API发布;正式ID、版本及验收见[整批发布记录](../maintenance/FULL_RELEASE_2026-11-02.md)。具体批次与回退见[新手教程调整](../maintenance/NEWCOMER_TUTORIALS_2026-11-01.md)。
ID用于核对本地行,站内链接使用已确认路径。跨环境迁移后重新核对ID,不能假定生产ID相同。 ID用于核对本地行,站内链接使用已确认路径。跨环境迁移后重新核对ID,不能假定生产ID相同。
| 内容键 | 本地ID / 栏目 | 标题 | 实际路径 | 正文源 / 编辑状态 | 用途与引用边界 | | 内容键 | 本地ID / 栏目 | 标题 | 实际路径 | 正文源 / 编辑状态 | 用途与引用边界 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| verge-windows-start | 362 / 综合教程 | Clash Verge Rev 使用教程:从安装到第一次连接(Windows) | `/a/clash-verge-rev-windows.html` | [windows.md](tutorials/windows.md);本地API版本5 | Windows新用户操作入口;下载段落锚点为 `#install` | | verge-windows-start | 362 / 进阶教程 | Clash Verge Rev 使用教程:从安装到第一次连接(Windows) | `/a/clash-verge-rev-windows.html` | [windows.md](tutorials/windows.md);本地API版本5 | Windows新用户操作入口;下载段落锚点为 `#install` |
| cfw-to-verge | 212 / 综合教程 | 从 Clash for Windows 迁移到 Clash Verge Rev | `/a/m167.html` | [migration.md](tutorials/migration.md);本地API版本5 | 保留旧配置并更换客户端;不作为纯入门入口 | | cfw-to-verge | 212 / 进阶教程 | 从 Clash for Windows 迁移到 Clash Verge Rev | `/a/m167.html` | [migration.md](tutorials/migration.md);本地API版本5 | 保留旧配置并更换客户端;不作为纯入门入口 |
| mihomo-dns | 361 / 综合教程 | Clash 进阶:DNS 防泄漏配置与验证 | `/a/mihomo-dns-leak.html` | [dns.md](tutorials/dns.md);本地API版本6 | Mihomo桌面DNS专题,含配置片段与验证方法 | | mihomo-dns | 361 / 进阶教程 | Clash 进阶:DNS 防泄漏配置与验证 | `/a/mihomo-dns-leak.html` | [dns.md](tutorials/dns.md);本地API版本6 | Mihomo桌面DNS专题,含配置片段与验证方法 |
| cfa-android-legacy | 206 / 综合教程 | Clash Meta for Android 使用教程:导入订阅、选择节点与连接 | `/a/m169.html` | [clash-meta-android.md](tutorials/clash-meta-android.md);本地API版本7 | Android Clash配置入门;`#install` 安装、`#import` 导入;按MetaCubeX v2.11.36核对,保留原键和URL | | cfa-android-legacy | 206 / 进阶教程 | Clash Meta for Android 使用教程:导入订阅、选择节点与连接 | `/a/m169.html` | [clash-meta-android.md](tutorials/clash-meta-android.md);本地API版本7 | Android Clash配置入门;`#install` 安装、`#import` 导入;按MetaCubeX v2.11.36核对,保留原键和URL |
| quantumult-ios-legacy | 207 / 综合教程 | Quantumult X 使用教程:iOS 安装、订阅导入与连接 | `/a/m170.html` | [quantumult-x-ios.md](tutorials/quantumult-x-ios.md);本地API版本6 | iPhone/iPad入门;区分原生资源与本站通用订阅,#parser 解释第三方资源解析器,#import 导入;无真机实测 | | quantumult-ios-legacy | 207 / 进阶教程 | Quantumult X 使用教程:iOS 安装、订阅导入与连接 | `/a/m170.html` | [quantumult-x-ios.md](tutorials/quantumult-x-ios.md);本地API版本6 | iPhone/iPad入门;区分原生资源与本站通用订阅,#parser 解释第三方资源解析器,#import 导入;无真机实测 |
| v2rayng-android-legacy | 208 / 综合教程 | v2rayNG 使用教程:Android 安装、导入订阅与连接 | `/a/m171.html` | [v2rayng-android.md](tutorials/v2rayng-android.md);本地API版本8 | Android入门;`#install` 下载、`#import` 订阅导入;保留既有内容键和URL | | v2rayng-android-legacy | 208 / 进阶教程 | v2rayNG 使用教程:Android 安装、导入订阅与连接 | `/a/m171.html` | [v2rayng-android.md](tutorials/v2rayng-android.md);本地API版本8 | Android入门;`#install` 下载、`#import` 订阅导入;保留既有内容键和URL |
| clashx-mac-legacy | 209 / 综合教程 | Mac 代理客户端下载指南:ClashX 与 Clash Verge Rev 怎么选 | `/a/m172.html` | [mac-client-guide.md](tutorials/mac-client-guide.md);本地API版本6 | Mac选型与官方下载;#download 核对系统与芯片,链接实际安装教程;旧ClashX原入口不可核验不等于所有分支失效 | | clashx-mac-legacy | 209 / 进阶教程 | Mac 代理客户端下载指南:ClashX 与 Clash Verge Rev 怎么选 | `/a/m172.html` | [mac-client-guide.md](tutorials/mac-client-guide.md);本地API版本6 | Mac选型与官方下载;#download 核对系统与芯片,链接实际安装教程;旧ClashX原入口不可核验不等于所有分支失效 |
| cfw-mac-legacy | 210 / 综合教程 | Clash Verge Rev 使用教程:Mac 安装、订阅与系统代理 | `/a/m173.html` | [verge-mac.md](tutorials/verge-mac.md);本地API版本3 | Mac操作入口;#install DMG安装,#import 订阅,系统代理与退出排错;无设备联网实测 | | cfw-mac-legacy | 210 / 进阶教程 | Clash Verge Rev 使用教程:Mac 安装、订阅与系统代理 | `/a/m173.html` | [verge-mac.md](tutorials/verge-mac.md);本地API版本3 | Mac操作入口;#install DMG安装,#import 订阅,系统代理与退出排错;无设备联网实测 |
| cfw-linux-legacy | 211 / 综合教程 | Clash Verge Rev 使用教程:Linux 安装、订阅与代理设置 | `/a/m174.html` | [verge-linux.md](tutorials/verge-linux.md);本地API版本6 | Linux桌面入门;#install 包格式与架构,#import 订阅,#terminal 单条curl代理验证;不适用于无桌面的服务器 | | cfw-linux-legacy | 211 / 进阶教程 | Clash Verge Rev 使用教程:Linux 安装、订阅与代理设置 | `/a/m174.html` | [verge-linux.md](tutorials/verge-linux.md);本地API版本6 | Linux桌面入门;#install 包格式与架构,#import 订阅,#terminal 单条curl代理验证;不适用于无桌面的服务器 |
| system-proxy-vs-tun | 365 / 综合教程 | 系统代理和 TUN 有什么区别?接管范围、使用选择与排错 | `/a/system-proxy-vs-tun.html` | [system-proxy-tun.md](tutorials/system-proxy-tun.md);本地API版本1 | 桌面代理接管与出口选择;`#choose` 场景选择、`#switch` 切换检查、`#troubleshoot` 排错;三张流程图 | | system-proxy-vs-tun | 365 / 进阶教程 | 系统代理和 TUN 有什么区别?接管范围、使用选择与排错 | `/a/system-proxy-vs-tun.html` | [system-proxy-tun.md](tutorials/system-proxy-tun.md);本地API版本1 | 桌面代理接管与出口选择;`#choose` 场景选择、`#switch` 切换检查、`#troubleshoot` 排错;三张流程图 |
| clash-routing-modes | 366 / 综合教程 | Clash 规则、全局和直连怎么选?代理组与分流排错教程 | `/a/clash-rule-global-direct.html` | [clash-routing-modes.md](tutorials/clash-routing-modes.md);2026-10-31本地ID366/版本1,正式ID382/版本1已发布 | 模式选择与代理组分流;`#groups` 组内选择、`#custom` 添加规则、`#verify` 验证新连接 | | clash-routing-modes | 366 / 进阶教程 | Clash 规则、全局和直连怎么选?代理组与分流排错教程 | `/a/clash-rule-global-direct.html` | [clash-routing-modes.md](tutorials/clash-routing-modes.md);2026-10-31本地ID366/版本1,正式ID382/版本1已发布 | 模式选择与代理组分流;`#groups` 组内选择、`#custom` 添加规则、`#verify` 验证新连接 |
| newcomer-start | 373 / 新手教程(client) | 新手指引:从免费订阅到第一次连接 | `/a/getting-started.html` | [start.md](tutorials/newcomer/start.md);本地API版本3(双比例配图);2026-11-02正式ID393/版本1已发布 | 先选设备,再下载安装、导入对应格式的免费订阅并连接。Windows、Mac、安卓、iPhone、Linux和路由器的入口都在这里。 | | free-nodes-explained | 375 / 新手教程(client) | 免费节点是什么?能用来做什么?给第一次接触的人看 | `/a/free-nodes-explained.html` | [free-nodes-explained.md](tutorials/newcomer/free-nodes-explained.md);本地API版本2(日常网页场景;本地置顶),未上线 | 零基础概念说明;`#what` 中转机制、`#uses` 用途、`#three-things` 客户端/节点/订阅、`#start` 下一步。免费节点详情页自动关联。 |
| shadowrocket-id-explained | 376 / 新手教程(client) | 免费小火箭 ID 是什么?账号、客户端和节点有什么区别? | `/a/shadowrocket-id-explained.html` | [shadowrocket-id-explained.md](tutorials/newcomer/shadowrocket-id-explained.md);本地API版本1(本地置顶),未上线 | 解释 App Store 账号用途;`#difference` 概念区分、`#where` 与 iCloud 区别、`#resource-page` 账号页字段、`#installed` 安装后需要节点。免费小火箭ID详情页自动关联。 |
| newcomer-start | 373 / 新手教程(client) | 新手指引:从免费订阅到第一次连接 | `/a/getting-started.html` | [start.md](tutorials/newcomer/start.md);本地API版本4(补基础说明入口);2026-11-02正式ID393/版本1已发布 | 先选设备,再下载安装、导入对应格式的免费订阅并连接。Windows、Mac、安卓、iPhone、Linux和路由器的入口都在这里。 |
| newcomer-windows | 374 / 新手教程(windows) | Windows 新手教程:用 Clash Verge Rev 连接免费节点 | `/a/windows-free-nodes.html` | [windows.md](tutorials/newcomer/windows.md);本地API版本3(双比例配图);2026-11-02正式ID394/版本1已发布 | 从选择 Windows 安装包开始,复制本站 Clash 订阅、选中配置与节点,再开启系统代理完成第一次连接。 | | newcomer-windows | 374 / 新手教程(windows) | Windows 新手教程:用 Clash Verge Rev 连接免费节点 | `/a/windows-free-nodes.html` | [windows.md](tutorials/newcomer/windows.md);本地API版本3(双比例配图);2026-11-02正式ID394/版本1已发布 | 从选择 Windows 安装包开始,复制本站 Clash 订阅、选中配置与节点,再开启系统代理完成第一次连接。 |
| newcomer-mac | 371 / 新手教程(mac) | Mac 新手教程:安装 Clash Verge Rev 并导入免费订阅 | `/a/mac-free-nodes.html` | [mac.md](tutorials/newcomer/mac.md);本地API版本3(双比例配图);2026-11-02正式ID391/版本1已发布 | 区分 Apple 芯片和 Intel 安装包,将应用放入应用程序,导入 Clash 订阅并开启系统代理。 | | newcomer-mac | 371 / 新手教程(mac) | Mac 新手教程:安装 Clash Verge Rev 并导入免费订阅 | `/a/mac-free-nodes.html` | [mac.md](tutorials/newcomer/mac.md);本地API版本3(双比例配图);2026-11-02正式ID391/版本1已发布 | 区分 Apple 芯片和 Intel 安装包,将应用放入应用程序,导入 Clash 订阅并开启系统代理。 |
| newcomer-android | 367 / 新手教程(andorid) | Android 新手教程:用 FlClash 导入免费订阅 | `/a/android-flclash.html` | [android.md](tutorials/newcomer/android.md);本地API版本3(双比例配图);2026-11-02正式ID387/版本1已发布 | 在安卓手机安装 FlClash,导入 Clash 格式订阅、选择节点并允许 VPN 连接,附后台断连排查。 | | newcomer-android | 367 / 新手教程(andorid) | Android 新手教程:用 FlClash 导入免费订阅 | `/a/android-flclash.html` | [android.md](tutorials/newcomer/android.md);本地API版本3(双比例配图);2026-11-02正式ID387/版本1已发布 | 在安卓手机安装 FlClash,导入 Clash 格式订阅、选择节点并允许 VPN 连接,附后台断连排查。 |
| newcomer-ios | 369 / 新手教程(ios) | iPhone / iPad 新手教程:用 Hiddify 连接免费节点 | `/a/ios-hiddify.html` | [ios.md](tutorials/newcomer/ios.md);本地API版本3(双比例配图);2026-11-02正式ID389/版本1已发布 | 从 App Store 安装 Hiddify,添加本站订阅、允许 VPN 配置并检查连接,说明订阅更新与节点切换。 | | newcomer-ios | 369 / 新手教程(ios) | iPhone / iPad 新手教程:用 Hiddify 连接免费节点 | `/a/ios-hiddify.html` | [ios.md](tutorials/newcomer/ios.md);本地API版本3(双比例配图);2026-11-02正式ID389/版本1已发布 | 从 App Store 安装 Hiddify,添加本站订阅、允许 VPN 配置并检查连接,说明订阅更新与节点切换。 |
| newcomer-linux | 370 / 新手教程(linux) | Linux 新手教程:在桌面环境使用 Clash Verge Rev | `/a/linux-free-nodes.html` | [linux.md](tutorials/newcomer/linux.md);本地API版本3(双比例配图);2026-11-02正式ID390/版本1已发布 | 按发行版和 CPU 架构选择安装包,导入免费订阅、开启系统代理,并区分浏览器与终端的连接方式。 | | newcomer-linux | 370 / 新手教程(linux) | Linux 新手教程:在桌面环境使用 Clash Verge Rev | `/a/linux-free-nodes.html` | [linux.md](tutorials/newcomer/linux.md);本地API版本3(双比例配图);2026-11-02正式ID390/版本1已发布 | 按发行版和 CPU 架构选择安装包,导入免费订阅、开启系统代理,并区分浏览器与终端的连接方式。 |
| newcomer-router | 372 / 新手教程(client) | 路由器新手教程:用 OpenClash 导入免费订阅 | `/a/router-openclash.html` | [router.md](tutorials/newcomer/router.md);本地API版本3(双比例配图);2026-11-02正式ID392/版本1已发布 | 在兼容的 OpenWrt 路由器上安装插件与内核、添加 Clash 订阅、启用配置,再检查设备是否经过这台路由器。 | | newcomer-router | 372 / 新手教程(client) | 路由器新手教程:用 OpenClash 导入免费订阅 | `/a/router-openclash.html` | [router.md](tutorials/newcomer/router.md);本地API版本3(双比例配图);2026-11-02正式ID392/版本1已发布 | 在兼容的 OpenWrt 路由器上安装插件与内核、添加 Clash 订阅、启用配置,再检查设备是否经过这台路由器。 |
| newcomer-clients | 368 / 新手教程(client) | 客户端下载与选择:按设备找到适合的软件 | `/a/proxy-clients.html` | [clients.md](tutorials/newcomer/clients.md);本地API版本2(双比例配图);2026-11-02正式ID388/版本1已发布 | 汇总常用代理客户端的官方入口,说明系统、架构和订阅格式如何匹配,并连接到各平台的新手教程。 | | newcomer-clients | 368 / 新手教程(client) | 客户端下载与选择:按设备找到适合的软件 | `/a/proxy-clients.html` | [clients.md](tutorials/newcomer/clients.md);本地API版本3(Resource 优先下载;本地置顶);2026-11-02正式ID388/版本1已发布 | 按系统和架构选择客户端,优先提供本站资源下载入口,并连接到各平台的新手教程。 |
2026-10-31新教程正式ID382/版本1、本地ID366/版本1已发表;系统代理/TUN正文补入口后两环境均为API版本2(正式ID376、本地ID365)。正式发布时间14:58:28北京时间,IndexNow由VPS-003提交返回200,服务器HTTP与图哈希/锚点验收通过。唯一状态与恢复记录见[规则模式教程发布记录](../maintenance/ROUTING_MODES_2026-10-31.md)。 2026-10-31新教程正式ID382/版本1、本地ID366/版本1已发表;系统代理/TUN正文补入口后两环境均为API版本2(正式ID376、本地ID365)。正式发布时间14:58:28北京时间,IndexNow由VPS-003提交返回200,服务器HTTP与图哈希/锚点验收通过。唯一状态与恢复记录见[规则模式教程发布记录](../maintenance/ROUTING_MODES_2026-10-31.md)。
这里只登记长期编辑内容及值得复用的稳定资源说明,不逐条收录日更节点或账号文章。稳定入口为 `/`(当前免费节点)、`/freenode`(历史分享归档)、`/client`(新手教程)、`/advanced`(综合教程);平台入口和名称修改另按项目路由核对,不从名称自行拼接。 这里只登记长期编辑内容及值得复用的稳定资源说明,不逐条收录日更节点或账号文章。当前本地稳定入口为 `/`(免费节点)、`/shadowrocketids`(免费小火箭ID)、`/history/freenode` 与 `/history/shadowrocketids`(对应往期)、`/client`(新手教程)、`/advanced`(进阶教程)。2026-11-02入口/往期调整仅本地,实施及部署边界见[SEO实施维护](../maintenance/SEO_IMPLEMENTATION_2026-10-31.md#稳定入口与往期内容2026-11-02已本地实施未发布);平台入口和名称修改另按项目路由核对,不从名称自行拼接。
2026-10-29部署边界更新:上表10篇已通过本站API迁移到独立test并经用户确认后切到正式cfn,旧URL保留;生产发布以预览实际正文冻结再发布,未用test整库覆盖。test和生产的临时编辑密钥均在迁移后删除,接口默认关闭;后续本地写作继续使用本地API,正式编辑按明确任务与生产绑定启用。实际部署及验收统一见[发布记录](../maintenance/RELEASE_PREFLIGHT_2026-10-28.md#正式切换2026-10-29),上表ID仍为本地ID。 2026-10-29部署边界更新:上表10篇已通过本站API迁移到独立test并经用户确认后切到正式cfn,旧URL保留;生产发布以预览实际正文冻结再发布,未用test整库覆盖。test和生产的临时编辑密钥均在迁移后删除,接口默认关闭;后续本地写作继续使用本地API,正式编辑按明确任务与生产绑定启用。实际部署及验收统一见[发布记录](../maintenance/RELEASE_PREFLIGHT_2026-10-28.md#正式切换2026-10-29),上表ID仍为本地ID。
2026-10-31 SEO发布后,新稿获取当前免费资源优先链接 `/`;需要历史记录时链接 `/freenode`。下表保留旧文实际内链事实,旧栏目仍正常可用,不因入口规则变化虚报旧文正文已改;后续相关编辑时按内容用途维护。 2026-11-02本地入口调整后,新稿获取当前免费节点链接 `/`,当前小火箭ID链接 `/shadowrocketids`;往期使用相应history地址。`/freenode` 本地已改为301至首页,正式环境尚未随本次调整。下表保留旧文实际内链事实,不因路由变化虚报正文已改;发布含history链接的新稿前须先确认目标环境已部署对应路由。
## 3 已有文章关系 ## 3 已有文章关系
@ -170,7 +185,7 @@ ### 新手教程新增关系(2026-11-01建立,2026-11-02正式发布)
| newcomer-clients | `/a/windows-free-nodes.html`、`/a/mac-free-nodes.html`、`/a/android-flclash.html`、`/a/ios-hiddify.html`、`/a/linux-free-nodes.html`、`/a/router-openclash.html`、`/a/m167.html`、`/a/m170.html#import`、`/a/m171.html`、`/a/m169.html`、`/a/getting-started.html#subscription` | 正文中的获取订阅、设备操作、下载、专题与返回指引入口 | | newcomer-clients | `/a/windows-free-nodes.html`、`/a/mac-free-nodes.html`、`/a/android-flclash.html`、`/a/ios-hiddify.html`、`/a/linux-free-nodes.html`、`/a/router-openclash.html`、`/a/m167.html`、`/a/m170.html#import`、`/a/m171.html`、`/a/m169.html`、`/a/getting-started.html#subscription` | 正文中的获取订阅、设备操作、下载、专题与返回指引入口 |
| 免费节点页面展示层 | `/a/getting-started.html` | 获取免费订阅标题下;旧版正文回退页在正文前;仅目标公开可用时显示 | | 免费节点页面展示层 | `/a/getting-started.html` | 获取免费订阅标题下;旧版正文回退页在正文前;仅目标公开可用时显示 |
新手指引置顶于`/client`,六个设备入口与下载总览均为真实已保存页面;原七篇移栏不改正文内链。 新手指引置顶于`/client`;2026-11-02本地已将客户端下载总览、免费节点说明、免费小火箭ID说明也设为置顶,四篇共同排在普通文章前。按既有置顶/时间/ID顺序,两篇基础说明当前在列表最前;既有指引与下载置顶保留。此次新增置顶尚未上线。六个设备入口与下载总览均为真实已保存页面;原七篇移栏不改正文内链。
## 4 怎样安排链接 ## 4 怎样安排链接
@ -185,7 +200,7 @@ ## 4 怎样安排链接
### 下载文章的具体例子 ### 下载文章的具体例子
**跨平台下载入口已建立**:`/a/proxy-clients.html`,汇总六类设备的官方来源和对应新手操作入口;新文章优先引用这一下载入口或设备教程的下载段落。 **跨平台下载入口已建立**:`/a/proxy-clients.html`,汇总六类设备的下载和对应新手操作入口;安装包按本节规则优先使用 Resource,商店及未收录软件使用官方入口。新文章优先引用这一下载入口的对应锚点。
**Mac另有独立选型与下载指南**,可链接 `/a/m172.html#download`,其中也提供Mac安装与导入教程的入口。Windows当前可用目标仍是入门教程中的下载段落: **Mac另有独立选型与下载指南**,可链接 `/a/m172.html#download`,其中也提供Mac安装与导入教程的入口。Windows当前可用目标仍是入门教程中的下载段落:
@ -227,3 +242,12 @@ ## 6 登记规则与待补内容
| 订阅导入失败排查 | Windows入门的导入与常见问题段落 | 积累明确错误场景后再拆专题,避免重复原文 | | 订阅导入失败排查 | Windows入门的导入与常见问题段落 | 积累明确错误场景后再拆专题,避免重复原文 |
本次建立的是AI协作约定和已核实的关联索引,不是自动推荐程序、链接注入脚本或定时任务。正文关系变动时由执行写作的AI同任务更新本文。 本次建立的是AI协作约定和已核实的关联索引,不是自动推荐程序、链接注入脚本或定时任务。正文关系变动时由执行写作的AI同任务更新本文。
### 资源概念说明与每日文章入口(2026-11-02,仅本地)
- 免费节点详情页 → `/a/free-nodes-explained.html`;已有“新手指引”保留为“查看使用教程”次入口。节点说明版本2按用户最新要求采用普通网页打不开的日常场景,不再使用油管例子;原6个锚点保留。
- 免费小火箭 ID 详情页 → `/a/shadowrocket-id-explained.html`;解释账号用途,不在每日文章重复维护长篇教程。
- 两篇基础说明互链;共同链接下载总览、免费节点入口及新手指引。小火箭 ID 文还链接 `/shadowrocketids` 和 iPhone/Hiddify 教程;节点文链接规则模式说明。
- 现有 `/a/getting-started.html` 开头补两篇说明入口。上述路径由文章 API 同批校验,不编造未创建文章。
- 两类资源页的统一入口按 `config/tutorials.php` 的 `resource_explainers` 映射渲染,覆盖既有及未来每日文章;只对真实公开且属于新手栏目的目标展示链接。教程未发布或撤下时不输出死链,节点页可回退原新手指引。
- 后续涉及免费节点或小火箭 ID 的基础概念时复用这两篇,按读者任务自然内链。日更正文与复制载荷不做批量改写。实现、验收与回退见 [资源基础说明维护](../maintenance/RESOURCE_BASICS_2026-11-02.md)。

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

View File

@ -0,0 +1,29 @@
{
"tool": "built-in image_gen",
"prompts": [
[
"nodes-cover",
"Use case: stylized-concept editorial illustration. Create a finished Chinese tutorial cover, landscape EXACT 3:2 aspect ratio. Topic: what free proxy nodes do, for absolute beginners. Handcrafted tactile papercraft collage and miniature diorama, torn fibrous paper, layered matte cardstock, sculpted volumes, soft directional shadows. An inventive aerial miniature parcel-transfer harbor: a small phone-shaped dock sends glowing cream paper envelopes along a curving teal river through a distinctive central transfer station to tiny distant website buildings; traffic path visibly connects phone, relay station, destination. Rich petrol teal, butter yellow, terracotta and ivory. High quality editorial illustration, asymmetrical composition, cohesive but NOT a device tray, NOT a road through mountains, NOT computer UI. Clear large Chinese title on top left exactly '免费节点是什么?'. No other labels except subtle legible bottom right watermark exactly 'clashfreenode.com'. Keep title and subject inside generous safe margins. No borders, no cropped text. Full bleed 1536x1024."
],
[
"ids-cover",
"Use case: stylized-concept editorial illustration. Finished Chinese beginner article cover EXACT landscape 3:2 ratio, full bleed 1536x1024. Topic shared Apple accounts for obtaining Shadowrocket app, clearly about app acquisition not network connectivity. Tactile papercraft collage with miniature 3D craft objects, visible paper fibers, folded matte card, warm soft directional shadows. A whimsical elegant miniature library-style app pickup counter: a single small cream identity ticket with a simple person silhouette lies beside a lilac smartphone; a mechanical paper arm places a square app tile with a tiny generic rocket into the phone. Abstract shelves and tactile checkered floor, mint, mauve, warm ivory and coral color palette. No real account credentials, no Apple logo, no real app screenshot, no mountains, no harbor or devices in trays. Creative asymmetrical camera perspective and strong readable subject. Large correct Chinese title exactly '小火箭 ID 是什么?'. Subtle bottom right watermark exactly 'clashfreenode.com'. Only these texts. Wide safe margins and no cropped lettering."
],
[
"nodes-hero",
"Use case stylized-concept. Create original full bleed VERY WIDE 2.4:1 horizontal editorial banner, target 1920x800. Chinese tutorial topic free proxy nodes as connection relays. Tactile torn-paper collage plus miniature 3D handcrafted diorama, layered fibrous paper, matte teal/ochre/ivory/terracotta, soft directional light and believable shadows. Design specifically for a low panoramic canvas, no cropping from a taller picture. A paper harbor cross-section spanning the entire width: on left a cream phone on a short jetty; at center a warm terracotta oval transfer pavilion with two arch openings; on right a group of little paper website buildings. A broad teal ribbon river carries bright folded envelope boats from phone through pavilion toward websites. One visible continuous route, explanatory visual metaphor not a technical network diagram. Title exactly '免费节点是什么?' on large ivory paper strip at upper left, crisp readable dark teal Chinese. Small discreet but readable watermark exactly 'clashfreenode.com' lower right. Keep entire phone, pavilion and destination visible within the short canvas. Do not add other text, logos or repeated device trays. No outer border."
],
[
"ids-hero",
"Use case stylized-concept. New full-bleed panoramic tutorial header VERY WIDE 2.4:1 target 1920x800. Tactile paper collage and miniature physical craft diorama with fibrous paper and folded cardstock, softly lit warm ivory, dusky mauve, pale mint and coral. Explain the theme obtaining a proxy app via an Apple account, not obtaining a network service. Independently designed long horizontal miniature app pickup counter scene: at left elegant large Chinese lettering exactly '小火箭 ID 是什么?'; below it a small generic identity ticket with person icon; center an ivory paper counter and open app drawer with a few square app tiles; right a lilac upright smartphone receives one tile bearing a simple generic small rocket carried by a small articulated paper arm. Warm miniature library shelves recede subtly behind, avoid crowded details. Foreground horizontal counter guides eye across scene, no tray arrangement, no mountains, no rivers. Entire scene composed for this short-wide frame. Bottom right watermark exactly 'clashfreenode.com', legible and discreet. No other text, no real logos or credentials, no crop of title, no borders."
],
[
"nodes-hero-final",
"Recompose this illustration for an output canvas with aspect ratio exactly 12:5 (2.4:1), 1920 pixels wide and 800 pixels high. Current canvas is too wide at 3:1. Make the canvas proportionally TALLER and rearrange content to fill that taller 12:5 composition naturally. Preserve Chinese title and watermark exactly, the same paper collage material, subject and palette. Do not stretch, crop off text, add borders or letterboxing. The canvas itself must be 12:5, not 3:1. Keep all lettering and all the important objects fully inside the canvas."
],
[
"ids-hero-final",
"Recompose this illustration for an output canvas with aspect ratio exactly 12:5 (2.4:1), 1920 pixels wide and 800 pixels high. Current canvas is too wide at 3:1. Make the canvas proportionally TALLER and rearrange content to fill that taller 12:5 composition naturally. Preserve Chinese title and watermark exactly, the same paper collage material, subject and palette. Do not stretch, crop off text, add borders or letterboxing. The canvas itself must be 12:5, not 3:1. Keep all lettering and all the important objects fully inside the canvas."
]
]
}

File diff suppressed because one or more lines are too long

View File

@ -2,6 +2,8 @@
客户端负责读取配置并建立连接,本站提供的是可以导入的免费订阅。先按设备选一个客户端,完成一次连接即可,不用把表里的软件都装一遍。 客户端负责读取配置并建立连接,本站提供的是可以导入的免费订阅。先按设备选一个客户端,完成一次连接即可,不用把表里的软件都装一遍。
下方安装包优先由本站资源下载服务提供,按系统和处理器选择即可;官方发布页保留作补充。iPhone / iPad 应用仍从 App Store 安装。
<h2 id="recommended">第一次用,按这张表选择</h2> <h2 id="recommended">第一次用,按这张表选择</h2>
<div class="table-wrap"> <div class="table-wrap">
@ -19,19 +21,47 @@
<h2 id="verge">Clash Verge Rev:Windows、Mac 与 Linux</h2> <h2 id="verge">Clash Verge Rev:Windows、Mac 与 Linux</h2>
[官方安装入口](https://www.clashverge.dev/install.html) · [官方发行版本](https://github.com/clash-verge-rev/clash-verge-rev/releases) <div class="table-wrap">
| 系统 | x64 | ARM64 |
| --- | --- | --- |
| Windows | [Intel / AMD(x64)](https://downloads.gemcook.net/downloads/software/clash-verge-rev/clash-verge-rev-windows-x64.exe) | [ARM64](https://downloads.gemcook.net/downloads/software/clash-verge-rev/clash-verge-rev-windows-arm64.exe) |
| Mac | [Intel / AMD(x64)](https://downloads.gemcook.net/downloads/software/clash-verge-rev/clash-verge-rev-macos-x64.dmg) | [Apple M 系列](https://downloads.gemcook.net/downloads/software/clash-verge-rev/clash-verge-rev-macos-arm64.dmg) |
| Linux(DEB) | [Intel / AMD(x64)](https://downloads.gemcook.net/downloads/software/clash-verge-rev/clash-verge-rev-linux-x64.deb) | [ARM64](https://downloads.gemcook.net/downloads/software/clash-verge-rev/clash-verge-rev-linux-arm64.deb) |
| Linux(RPM) | [Intel / AMD(x64)](https://downloads.gemcook.net/downloads/software/clash-verge-rev/clash-verge-rev-linux-x64.rpm) | [ARM64](https://downloads.gemcook.net/downloads/software/clash-verge-rev/clash-verge-rev-linux-arm64.rpm) |
</div>
补充:[官方安装说明](https://www.clashverge.dev/install.html) · [官方发布页](https://github.com/clash-verge-rev/clash-verge-rev/releases)
Windows 选对应架构的安装包;Mac 选 DMG;Linux 按发行版选 DEB 或 RPM。用它导入本站的 **Clash / Mihomo** 订阅。旧 Clash for Windows 的用户可阅读[迁移教程](/a/m167.html)。 Windows 选对应架构的安装包;Mac 选 DMG;Linux 按发行版选 DEB 或 RPM。用它导入本站的 **Clash / Mihomo** 订阅。旧 Clash for Windows 的用户可阅读[迁移教程](/a/m167.html)。
<h2 id="flclash">FlClash:安卓教程的选择</h2> <h2 id="flclash">FlClash:安卓教程的选择</h2>
[官方项目](https://github.com/chen08209/FlClash) · [发行版本](https://github.com/chen08209/FlClash/releases) **本站下载:** [安卓 ARM64](https://downloads.gemcook.net/downloads/software/flclash/flclash-android-arm64.apk) · [安卓 ARMv7(32 位)](https://downloads.gemcook.net/downloads/software/flclash/flclash-android-armv7.apk)。多数近年的安卓手机选 ARM64;老设备先确认架构。
补充:[官方项目](https://github.com/chen08209/FlClash) · [官方发布页](https://github.com/chen08209/FlClash/releases)
FlClash 也提供桌面版本,本套新手教程使用它的 Android 版本。安卓手机下载 APK,先确认 CPU 架构;导入本站的 **Clash / Mihomo** 订阅。 FlClash 也提供桌面版本,本套新手教程使用它的 Android 版本。安卓手机下载 APK,先确认 CPU 架构;导入本站的 **Clash / Mihomo** 订阅。
<h2 id="ios">Hiddify 与其他 iPhone / iPad 客户端</h2> <h2 id="ios">Hiddify 与其他 iPhone / iPad 客户端</h2>
Hiddify:[官方项目](https://github.com/hiddify/hiddify-app) · [App Store](https://apps.apple.com/us/app/hiddify-proxy-vpn/id6596777532) iPhone / iPad:[App Store 下载 Hiddify](https://apps.apple.com/us/app/hiddify-proxy-vpn/id6596777532)。安卓 APK 和桌面安装包不能用于 iOS。
其他设备可用本站下载:
<div class="table-wrap">
| 系统 | 安装包 |
| --- | --- |
| Android | [ARM64](https://downloads.gemcook.net/downloads/software/hiddify/hiddify-android-arm64.apk) · [ARMv7](https://downloads.gemcook.net/downloads/software/hiddify/hiddify-android-armv7.apk) · [通用 APK](https://downloads.gemcook.net/downloads/software/hiddify/hiddify-android-universal.apk) |
| Windows | [x64 安装程序](https://downloads.gemcook.net/downloads/software/hiddify/hiddify-windows-x64.exe) |
| Mac | [通用 DMG](https://downloads.gemcook.net/downloads/software/hiddify/hiddify-macos-universal.dmg) |
| Linux | [x64 DEB](https://downloads.gemcook.net/downloads/software/hiddify/hiddify-linux-x64.deb) · [x64 AppImage](https://downloads.gemcook.net/downloads/software/hiddify/hiddify-linux-x64.appimage) |
</div>
补充:[Hiddify 官方项目](https://github.com/hiddify/hiddify-app)。
本站 iOS 教程使用 Hiddify,先导入 **Sing-box / Hiddify** 订阅。Hiddify 也支持其他平台,但它们的安装方式和系统授权不完全相同。 本站 iOS 教程使用 Hiddify,先导入 **Sing-box / Hiddify** 订阅。Hiddify 也支持其他平台,但它们的安装方式和系统授权不完全相同。
@ -43,16 +73,32 @@
<h2 id="alternatives">其他常用客户端</h2> <h2 id="alternatives">其他常用客户端</h2>
**v2rayN(桌面客户端)**
<div class="table-wrap"> <div class="table-wrap">
| 客户端 | 官方入口 | 选择时留意 | | 系统 | 本站下载 |
| --- | --- | --- | | --- | --- |
| v2rayN | [发行版本](https://github.com/2dust/v2rayN/releases) | 按系统选择包,不把桌面版本当安卓 APK | | Windows | [x64](https://downloads.gemcook.net/downloads/software/v2rayn/v2rayn-windows-x64.zip) · [ARM64](https://downloads.gemcook.net/downloads/software/v2rayn/v2rayn-windows-arm64.zip) |
| v2rayNG | [发行版本](https://github.com/2dust/v2rayNG/releases) | Android;本站 V2ray 格式;有[现成教程](/a/m171.html) | | Mac | [x64](https://downloads.gemcook.net/downloads/software/v2rayn/v2rayn-macos-x64.dmg) · [ARM64](https://downloads.gemcook.net/downloads/software/v2rayn/v2rayn-macos-arm64.dmg) |
| Clash Meta for Android | [发行版本](https://github.com/MetaCubeX/ClashMetaForAndroid/releases) | Android;本站 Clash 格式;有[现成教程](/a/m169.html) | | Linux DEB | [x64](https://downloads.gemcook.net/downloads/software/v2rayn/v2rayn-linux-x64.deb) · [ARM64](https://downloads.gemcook.net/downloads/software/v2rayn/v2rayn-linux-arm64.deb) |
| Linux RPM | [x64](https://downloads.gemcook.net/downloads/software/v2rayn/v2rayn-linux-x64.rpm) · [ARM64](https://downloads.gemcook.net/downloads/software/v2rayn/v2rayn-linux-arm64.rpm) |
</div> </div>
Windows 提供 ZIP 压缩包,解压后再运行;其他系统按对应格式安装。补充:[v2rayN 官方发布页](https://github.com/2dust/v2rayN/releases)。
<div class="table-wrap">
| 安卓客户端 | 本站下载 | 格式与教程 |
| --- | --- | --- |
| v2rayNG | [ARM64](https://downloads.gemcook.net/downloads/software/v2rayng/v2rayng-android-arm64.apk) · [ARMv7](https://downloads.gemcook.net/downloads/software/v2rayng/v2rayng-android-armv7.apk) · [通用 APK](https://downloads.gemcook.net/downloads/software/v2rayng/v2rayng-android-universal.apk) | 本站 V2ray 格式;[使用教程](/a/m171.html) |
| Clash Meta for Android | [ARM64](https://downloads.gemcook.net/downloads/software/clash-meta-android/clash-meta-android-android-arm64.apk) · [ARMv7](https://downloads.gemcook.net/downloads/software/clash-meta-android/clash-meta-android-android-armv7.apk) · [通用 APK](https://downloads.gemcook.net/downloads/software/clash-meta-android/clash-meta-android-android-universal.apk) | 本站 Clash 格式;[使用教程](/a/m169.html) |
</div>
补充:[v2rayNG 官方发布页](https://github.com/2dust/v2rayNG/releases) · [Clash Meta for Android 官方发布页](https://github.com/MetaCubeX/ClashMetaForAndroid/releases)。
<h2 id="router">OpenClash:只用于兼容路由器</h2> <h2 id="router">OpenClash:只用于兼容路由器</h2>
[官方安装说明](https://github.com/vernesong/OpenClash) · [发行版本](https://github.com/vernesong/OpenClash/releases) [官方安装说明](https://github.com/vernesong/OpenClash) · [发行版本](https://github.com/vernesong/OpenClash/releases)
@ -63,6 +109,6 @@
<figure class="starter-process"><div><b>先看系统</b><span>Windows / macOS / Linux / Android</span></div><div><b>再看架构</b><span>Intel / AMD 常见 x64<br>Apple M 系列为 ARM64</span></div><div><b>最后看包格式</b><span>EXE / DMG / DEB / RPM / APK</span></div><figcaption>架构、系统和包格式都匹配,才是适合当前设备的安装包。</figcaption></figure> <figure class="starter-process"><div><b>先看系统</b><span>Windows / macOS / Linux / Android</span></div><div><b>再看架构</b><span>Intel / AMD 常见 x64<br>Apple M 系列为 ARM64</span></div><div><b>最后看包格式</b><span>EXE / DMG / DEB / RPM / APK</span></div><figcaption>架构、系统和包格式都匹配,才是适合当前设备的安装包。</figcaption></figure>
GitHub 发布页里的 **Assets** 是发行文件列表,`Source code` 是源码。找下载文件时先读文件名中的系统与架构,不必下载整个源码仓库。 如需从官方发布页补充下载,GitHub 页里的 **Assets** 是发行文件列表,`Source code` 是源码。找下载文件时先读文件名中的系统与架构,不必下载整个源码仓库。
安装完成后,回[新手指引](/a/getting-started.html#subscription)选择订阅格式,再进入对应设备教程。客户端有了、配置有了,还要开启代理或 VPN,才能开始验证连接。 安装完成后,回[新手指引](/a/getting-started.html#subscription)选择订阅格式,再进入对应设备教程。客户端有了、配置有了,还要开启代理或 VPN,才能开始验证连接。

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,64 @@
<figure class="guide-figure tutorial-hero"><img src="/images/editorial/dd2519bb7747fec0f8b4d53716787d40c6cfed1ce4597e7539ec7b22bf0d69bc.jpg" width="1942" height="809" alt="免费节点把设备与目标网站连接起来的纸艺中转站" decoding="async"></figure>
你已经连上 Wi-Fi,平时聊天、浏览网页都正常,但有一个网站怎么也打不开。这时,除了直接连接网站,还可以尝试换一条访问线路。
**免费节点,就是别人分享给你免费使用的一条网络中转线路。** 手机或电脑先连到这条线路,再通过它访问网站。有的线路能用,有的会失效,所以需要实际连接后再看效果。
先记住三件事:**软件是连接工具,节点是可选线路,订阅链接是获取这些线路的地址。** 下面按你第一次使用时会遇到的顺序说明。
<h2 id="what">节点到底是什么?</h2>
节点背后通常是一台提供中转服务的服务器。服务器在别处,你不用把它搬回家,也不用自己搭建。你会在连接软件里看到它,名称可能是“日本01”“新加坡02”等。
<figure class="starter-process"><div><b>你的手机或电脑</b><span>通过连接软件选一条线路<br>↓</span></div><div><b>节点</b><span>在中间帮你转发这次连接<br>↓</span></div><div><b>你要访问的网站</b><span>把网页内容传回来</span></div><figcaption>简单理解:你的设备 → 中转节点 → 网站。节点能否正常工作,要以这次实际连接的结果为准。</figcaption></figure>
比如你选了“日本01”,就是让软件尝试使用这条线路。这个名字只是一个标签,不代表它一定能连接,也不代表速度一定快。
<h2 id="uses">它能做什么?“免费”又是什么意思?</h2>
它的作用是让需要的网络连接尝试走另一条路。例如,某个网页直接打不开,换一条可用节点后,可能就能打开。能正常直连的网站,通常可以继续直接访问,不必全部经过节点。
这里的“免费”,指使用这条分享线路不收费。你仍然需要 Wi-Fi 或手机流量,宽带和流量费用照常计算。网站自己的登录、会员要求也照常存在。
节点不会让没有网络的手机凭空上网,也不能保证所有网站都能打开。
<h2 id="three-things">软件、节点、订阅,怎么配合使用?</h2>
<div class="table-wrap">
| 你会看到的词 | 用大白话解释 | 你要做的事 |
| --- | --- | --- |
| 客户端 | 装在手机或电脑里的连接软件,比如 Clash Verge Rev、Hiddify、小火箭 | 按设备选一种,下载安装 |
| 节点 | 软件里可以选择的一条中转线路 | 从列表里选一条来连接 |
| 订阅链接 | 可以让软件获取、更新一批节点的地址 | 从本站复制,粘贴到软件的订阅或配置入口 |
</div>
**只安装软件,还没有线路可选。** 把订阅链接导入后,软件才会取得节点列表。接下来选中一条节点,再按对应教程开启代理或 VPN,连接才会开始工作。
这里的“订阅”只是更新线路信息的方式,不是订阅付费会员。本站页面上可能有几种订阅格式,你只需要复制适合自己软件的那一种;复制的是“复制订阅”按钮里的链接,不是浏览器顶部的文章网址。
<h2 id="start">第一次用,照着这个顺序来</h2>
1. **选软件。** 打开[新手指引](/a/getting-started.html#devices),按 Windows、Mac、安卓或 iPhone 选择教程。还没下载软件,可以先看[客户端下载与选择](/a/proxy-clients.html)。
2. **拿到订阅。** 到[免费节点首页](/),找到与你的软件对应的一行,点击“复制订阅”。
3. **导入并连接。** 回到软件,按教程添加订阅、选中配置和节点,再开启代理或 VPN。
4. **打开网页试试。** 回到浏览器,重新打开刚才的网页。能正常显示内容,才说明这一次访问成功;仅仅“已导入订阅”还不等于连接成功。
先把这一次操作走通。规则模式、全局模式等设置,可以之后再看[模式区别与使用方法](/a/clash-rule-global-direct.html)。
<h2 id="free">为什么昨天能用,今天又不行了?</h2>
免费线路可能同时有很多人在用,也可能被更换或停止服务。网页打不开、加载慢,不一定是你把软件装错了。
先在软件里换一条节点试试;如果列表里的线路普遍连不上,就更新订阅,再到[免费节点首页](/)查看当前分享。不要一遇到问题就反复卸载软件,或把所有开关都改一遍。
页面上的节点数量和速度是这篇记录提供的信息,不保证你现在的每次连接都一样。免费节点适合学习操作和临时尝试,不适合把重要的持续连接只押在一条线路上。
<h2 id="privacy">还需要注意什么?</h2>
节点由别人提供,不能把它当成“完全匿名”的保证。正常访问 HTTPS 网站时,传输内容有加密保护,但网站仍可能通过你登录的账户识别你。
导入本站订阅,不需要把邮箱、Apple 账户或其他网站的登录密码交给节点提供者。
如果你还在找“免费小火箭 ID”,记住:**ID 用来登录 App Store 获取应用,节点用来提供连接线路。** 两者不是一回事,详细说明见[免费小火箭 ID 介绍](/a/shadowrocket-id-explained.html)。

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,69 @@
<figure class="guide-figure tutorial-hero"><img src="/images/editorial/da88903c06de5f82e7f65418335f2ee726ad6ecb6a9da249d70238b7bab1b937.jpg" width="1942" height="809" alt="Apple 账户用于获取小火箭应用的纸艺领取柜" decoding="async"></figure>
**“免费小火箭 ID”通常指用于在 App Store 获取 Shadowrocket 的共享 Apple 账户。** 小火箭是 Shadowrocket 的俗称,一款代理客户端;ID 指账户。它不是免费节点,也不是“登录后就能上网”的通行证。
你可以把这两件事分开:先通过 App Store 获取应用,再在应用里添加能用的节点或订阅。账号解决的是取得软件的问题,节点解决的是代理连接的问题。
<h2 id="difference">小火箭、ID、节点,不是一回事</h2>
<div class="table-wrap">
| 你看到的名称 | 实际是什么 | 用来做什么 |
| --- | --- | --- |
| 小火箭 / Shadowrocket | 安装在设备上的代理客户端 | 读取配置、选择节点、建立代理连接 |
| 小火箭 ID | 通常是一个有相关应用购买记录的 Apple 账户 | 在 App Store 中获取对应应用 |
| 节点 / 订阅 | 代理服务器的连接信息,或获取这些信息的地址 | 让客户端知道连接哪条线路 |
</div>
下面这张图,把“获取软件”和“使用线路”分成了两个阶段:
<figure class="starter-process"><div><b>Apple 账户</b><span>用于 App Store<br>↓ 获取应用</span></div><div><b>Shadowrocket</b><span>安装在你的设备上<br>↓ 添加节点或订阅</span></div><div><b>代理连接</b><span>连接可用节点<br>再访问目标网站</span></div><figcaption>Apple 账户参与应用获取;代理连接使用的是节点配置。把 Apple 账户密码填进订阅框,不会得到节点。</figcaption></figure>
例如,你已经装好了 Shadowrocket,打开后却没有任何线路。这时你缺的是节点或订阅,继续寻找小火箭 ID 并不能解决问题。先看[免费节点是什么](/a/free-nodes-explained.html#three-things),再到[免费节点首页](/)获取与客户端匹配的订阅。
<h2 id="why-id">为什么有人会找这种 ID?</h2>
Shadowrocket 是付费应用,能否在商店中找到它,与账户的商店地区、应用供应和设备兼容性有关。所谓“小火箭 ID”分享,通常是提供一个用于获取该应用的账户;实际能否下载,还取决于账户是否可用、是否具有对应购买记录,以及商店当时的验证要求。
“免费”描述的是账号分享页面不向你收费,并不是说 Shadowrocket 已变成免费应用,也不代表账号中所有软件都能下载。应用与购买信息以 [Shadowrocket 的 App Store 页面](https://apps.apple.com/us/app/shadowrocket/id932747118)和当前登录账户的实际记录为准。
自己长期使用的软件,使用自己管理的账户获取,更新和重新下载会更好处理。共享账号的密码、验证方式和可用状态都不由你控制;不要把它当成属于自己的永久账户。
<h2 id="where">最容易弄错的地方:App Store 与 iCloud</h2>
Apple 账户可以用于不同服务。App Store 的购买登录与设备上用于 iCloud 的账户,可以是不同的账户;苹果称商店相关入口为“媒体与购买项目”。
<figure class="starter-process"><div><b>App Store</b><span>获取应用与管理购买<br>这里只涉及商店账户</span></div><div><b>iCloud</b><span>照片、备份与设备服务<br>保留你自己的账户</span></div><div><b>代理客户端</b><span>导入节点和订阅<br>不用 Apple 账户密码</span></div><figcaption>三处用途不同。为了下载应用,不需要把设备的 iCloud 换成共享账户。</figcaption></figure>
如果使用他人明确允许你使用的账户,应只在 App Store/媒体与购买项目的范围内处理,**不要退出自己的 iCloud,也不要把共享账户登录到设备主账户的位置。** 不需要为此打开“查找”、同步照片或接管对方的账户安全设置。苹果对两种登录用途的区分见[账户与购买项目说明](https://support.apple.com/zh-cn/117294)。
遇到验证码、账户锁定、需要账户所有者处理的提示,就停在这里。不要为了继续下载去改共享账户的密码、绑定自己的手机号,或尝试绕过验证。完成商店操作后,退出共享商店账户,恢复自己的商店账户;自己的 iCloud 登录保持不变。
<h2 id="resource-page">本站的账号页面怎么看?</h2>
[免费小火箭 ID 页面](/shadowrocketids)展示的是账号资源,而不是节点清单。常见字段可以这样理解:
- **账号、密码:** 用于对应账户的登录,不是代理客户端的配置。
- **地区:** 描述商店账户的地区,不是你的网络出口地区;美区账号不会把网络变成美国线路。
- **检测时间:** 表示页面记录的检测时间,不保证你打开页面这一刻仍可登录,也不保证下载时不会再次验证。
先理解用途,再看自己是否真的需要这个资源。已经安装了客户端,只差线路时,直接去找节点即可,不必再更换 Apple 账户。
<h2 id="installed">装好小火箭之后,还差什么?</h2>
还需要一份它支持的节点配置。本站免费节点文章中,对应的是 **V2ray / Shadowrocket** 订阅入口:复制订阅地址,在客户端中添加订阅,更新出节点列表,再选择节点并开启连接。系统出现 VPN 配置请求时,要确认发起者确实是你正在使用的客户端。
App Store 登录成功、应用下载完成、订阅导入成功和网页能正常访问,是四个不同的结果。卡在其中一步时,解决那一步的问题,不要把所有操作重新来一遍。
若没有必须使用 Shadowrocket 的理由,也可以先看[客户端下载与选择](/a/proxy-clients.html#ios);本站另有使用 Hiddify 的 [iPhone / iPad 新手教程](/a/ios-hiddify.html),不必为了使用免费节点而先去寻找小火箭 ID。
<h2 id="questions">几个常见疑问</h2>
**有了 ID,是否就能访问任何网站?** 不能。它用于获取应用;是否连通还要看节点、设备网络和目标服务。
**换成自己的商店账户,应用就会变成自己购买的吗?** 不会。切换登录不会自动转移应用购买记录。之后更新或重新下载时,可能仍需原购买账户;拿不到验证时,不要急着卸载仍能使用的应用。
**安卓或 Windows 也需要小火箭 ID 吗?** 安装本站推荐的安卓或 Windows 客户端,不需要这个 Apple 账户。按[设备选择客户端](/a/proxy-clients.html#recommended)就可以。
**我应该先看哪篇?** 连节点和订阅都不清楚,先看[免费节点说明](/a/free-nodes-explained.html);已经明白这些概念,直接从[新手指引](/a/getting-started.html)开始操作。

View File

@ -1 +1 @@
<link rel="stylesheet" href="/css/tutorials.css"><div class="tutorial-body"><figure class="guide-figure tutorial-hero"><img src="/images/editorial/3ae1e84d695eb3379692c23b7b5ce2d05f6fa57f30b0e2a1aa3c274be505ed98.jpg" width="1942" height="809" alt="新手指引:沿路线完成下载安装、导入配置与连接" decoding="async"></figure><p>第一次使用本站,可以从这里开始。你需要做三件事:<strong>安装客户端、导入免费订阅、开启连接</strong>。先选自己的设备,照着对应教程完成一遍;暂时不用研究 DNS、TUN 或复杂规则。</p><h2 id="devices">你准备在哪台设备上使用?</h2><nav class="starter-devices" aria-label="按设备选择教程"><a href="/a/windows-free-nodes.html"><strong>Windows</strong><span>Clash Verge Rev</span><small>安装 → 导入 → 系统代理</small></a><a href="/a/mac-free-nodes.html"><strong>Mac</strong><span>Clash Verge Rev</span><small>选芯片 → 安装 → 连接</small></a><a href="/a/android-flclash.html"><strong>Android</strong><span>FlClash</span><small>安装 APK → 导入 → 启动 VPN</small></a><a href="/a/ios-hiddify.html"><strong>iPhone / iPad</strong><span>Hiddify</span><small>App Store → 添加订阅 → 连接</small></a><a href="/a/linux-free-nodes.html"><strong>Linux 桌面</strong><span>Clash Verge Rev</span><small>选择安装包 → 导入 → 系统代理</small></a><a href="/a/router-openclash.html"><strong>OpenWrt 路由器</strong><span>OpenClash</span><small>备份 → 配置订阅 → 设备接入</small></a></nav><p>只想找下载地址,或者想比较其他客户端,可以看<a href="/a/proxy-clients.html">客户端下载与选择</a>。路由器教程需要兼容的 OpenWrt 固件;第一次使用,先在手机或电脑上连通更容易判断问题。</p><h2 id="subscription">订阅链接,应该复制哪一个?</h2><p>打开<a href="/">本站首页</a>,找到“获取免费订阅”。订阅是让客户端获取一组节点和配置的地址,<strong>复制订阅地址,不是复制当前文章的网址</strong>。也不用把整篇文章的文字粘贴进去。</p><div class="table-wrap"><table><thead><tr><th>你使用的客户端</th><th>在本站复制的订阅</th></tr></thead><tbody><tr><td>Clash Verge Rev、FlClash、OpenClash</td><td>Clash / Mihomo</td></tr><tr><td>Hiddify</td><td>Sing-box / Hiddify</td></tr><tr><td>v2rayNG、Shadowrocket</td><td>V2ray / Shadowrocket</td></tr></tbody></table></div><p>客户端下载和节点订阅是两件事。安装软件不会自动获得节点;导入成功,也不代表已经开启连接。免费节点的可用性会变化,页面里的节点数量和检测结果供挑选时参考,实际能否使用还要在自己的网络上打开网页确认。</p><h2 id="first-connection">从安装到连接</h2><figure class="starter-process"><div><b>01 安装</b><span>选对系统与处理器架构</span></div><div><b>02 导入</b><span>复制匹配的订阅,选中配置</span></div><div><b>03 连接</b><span>选择节点,打开代理或 VPN</span></div><figcaption>操作顺序:先让客户端有配置,再让设备的流量进入客户端。</figcaption></figure><p>电脑上按教程开启系统代理;手机上启动客户端并允许系统的 VPN 请求。保持客户端运行,打开一个平时需要通过代理访问的网页。能正常加载后,再试自己实际要用的应用。</p><h2 id="problems">卡在哪一步,就从哪一步检查</h2><ul><li><strong>安装不了:</strong> 先核对操作系统、处理器和安装包,不用换订阅。</li><li><strong>导入失败:</strong> 检查是否复制了完整订阅地址、格式是否选对,以及当前网络能否下载该订阅。</li><li><strong>导入成功但打不开网页:</strong> 确认配置已选中、代理或 VPN 已启动,再换一个节点试试。</li><li><strong>昨天可用,今天全超时:</strong> 先更新订阅;旧地址仍无可用节点时,到<a href="/">首页</a>获取新一批分享。</li><li><strong>网页能用,个别应用不能用:</strong> 基本连接通常已建立,再了解<a href="/a/system-proxy-vs-tun.html#choose">系统代理和 TUN 的区别</a>。</li></ul><h2 id="next">连通以后再看什么?</h2><p>日常先使用客户端的规则模式。想了解“为什么有的网站走节点、有的直接访问”,接着看<a href="/a/clash-rule-global-direct.html">规则、全局与直连</a>。如果需要更细的配置、旧客户端迁移或专项排错,可以进入<a href="/advanced">综合教程</a>。</p></div> <link rel="stylesheet" href="/css/tutorials.css"><div class="tutorial-body"><figure class="guide-figure tutorial-hero"><img src="/images/editorial/3ae1e84d695eb3379692c23b7b5ce2d05f6fa57f30b0e2a1aa3c274be505ed98.jpg" width="1942" height="809" alt="新手指引:沿路线完成下载安装、导入配置与连接" decoding="async"></figure><p>第一次使用本站,可以从这里开始。你需要做三件事:<strong>安装客户端、导入免费订阅、开启连接</strong>。先选自己的设备,照着对应教程完成一遍;暂时不用研究 DNS、TUN 或复杂规则。</p><p>还不清楚这些东西是什么?先看<a href="/a/free-nodes-explained.html">免费节点的用途说明</a>;如果你是来找账号的,先读<a href="/a/shadowrocket-id-explained.html">免费小火箭 ID 与节点的区别</a>,再回到这里操作。</p><h2 id="devices">你准备在哪台设备上使用?</h2><nav class="starter-devices" aria-label="按设备选择教程"><a href="/a/windows-free-nodes.html"><strong>Windows</strong><span>Clash Verge Rev</span><small>安装 → 导入 → 系统代理</small></a><a href="/a/mac-free-nodes.html"><strong>Mac</strong><span>Clash Verge Rev</span><small>选芯片 → 安装 → 连接</small></a><a href="/a/android-flclash.html"><strong>Android</strong><span>FlClash</span><small>安装 APK → 导入 → 启动 VPN</small></a><a href="/a/ios-hiddify.html"><strong>iPhone / iPad</strong><span>Hiddify</span><small>App Store → 添加订阅 → 连接</small></a><a href="/a/linux-free-nodes.html"><strong>Linux 桌面</strong><span>Clash Verge Rev</span><small>选择安装包 → 导入 → 系统代理</small></a><a href="/a/router-openclash.html"><strong>OpenWrt 路由器</strong><span>OpenClash</span><small>备份 → 配置订阅 → 设备接入</small></a></nav><p>只想找下载地址,或者想比较其他客户端,可以看<a href="/a/proxy-clients.html">客户端下载与选择</a>。路由器教程需要兼容的 OpenWrt 固件;第一次使用,先在手机或电脑上连通更容易判断问题。</p><h2 id="subscription">订阅链接,应该复制哪一个?</h2><p>打开<a href="/">本站首页</a>,找到“获取免费订阅”。订阅是让客户端获取一组节点和配置的地址,<strong>复制订阅地址,不是复制当前文章的网址</strong>。也不用把整篇文章的文字粘贴进去。</p><div class="table-wrap"><table><thead><tr><th>你使用的客户端</th><th>在本站复制的订阅</th></tr></thead><tbody><tr><td>Clash Verge Rev、FlClash、OpenClash</td><td>Clash / Mihomo</td></tr><tr><td>Hiddify</td><td>Sing-box / Hiddify</td></tr><tr><td>v2rayNG、Shadowrocket</td><td>V2ray / Shadowrocket</td></tr></tbody></table></div><p>客户端下载和节点订阅是两件事。安装软件不会自动获得节点;导入成功,也不代表已经开启连接。免费节点的可用性会变化,页面里的节点数量和检测结果供挑选时参考,实际能否使用还要在自己的网络上打开网页确认。</p><h2 id="first-connection">从安装到连接</h2><figure class="starter-process"><div><b>01 安装</b><span>选对系统与处理器架构</span></div><div><b>02 导入</b><span>复制匹配的订阅,选中配置</span></div><div><b>03 连接</b><span>选择节点,打开代理或 VPN</span></div><figcaption>操作顺序:先让客户端有配置,再让设备的流量进入客户端。</figcaption></figure><p>电脑上按教程开启系统代理;手机上启动客户端并允许系统的 VPN 请求。保持客户端运行,打开一个平时需要通过代理访问的网页。能正常加载后,再试自己实际要用的应用。</p><h2 id="problems">卡在哪一步,就从哪一步检查</h2><ul><li><strong>安装不了:</strong> 先核对操作系统、处理器和安装包,不用换订阅。</li><li><strong>导入失败:</strong> 检查是否复制了完整订阅地址、格式是否选对,以及当前网络能否下载该订阅。</li><li><strong>导入成功但打不开网页:</strong> 确认配置已选中、代理或 VPN 已启动,再换一个节点试试。</li><li><strong>昨天可用,今天全超时:</strong> 先更新订阅;旧地址仍无可用节点时,到<a href="/">首页</a>获取新一批分享。</li><li><strong>网页能用,个别应用不能用:</strong> 基本连接通常已建立,再了解<a href="/a/system-proxy-vs-tun.html#choose">系统代理和 TUN 的区别</a>。</li></ul><h2 id="next">连通以后再看什么?</h2><p>日常先使用客户端的规则模式。想了解“为什么有的网站走节点、有的直接访问”,接着看<a href="/a/clash-rule-global-direct.html">规则、全局与直连</a>。如果需要更细的配置、旧客户端迁移或专项排错,可以进入<a href="/advanced">综合教程</a>。</p></div>

View File

@ -2,6 +2,8 @@
第一次使用本站,可以从这里开始。你需要做三件事:**安装客户端、导入免费订阅、开启连接**。先选自己的设备,照着对应教程完成一遍;暂时不用研究 DNS、TUN 或复杂规则。 第一次使用本站,可以从这里开始。你需要做三件事:**安装客户端、导入免费订阅、开启连接**。先选自己的设备,照着对应教程完成一遍;暂时不用研究 DNS、TUN 或复杂规则。
还不清楚这些东西是什么?先看[免费节点的用途说明](/a/free-nodes-explained.html);如果你是来找账号的,先读[免费小火箭 ID 与节点的区别](/a/shadowrocket-id-explained.html),再回到这里操作。
<h2 id="devices">你准备在哪台设备上使用?</h2> <h2 id="devices">你准备在哪台设备上使用?</h2>
<nav class="starter-devices" aria-label="按设备选择教程"> <nav class="starter-devices" aria-label="按设备选择教程">

View File

@ -152,3 +152,5 @@ ## 本任务高度调整正式状态(2026-10-31)
[2026-10-31正式发布记录](UI_RELEASE_2026-10-31.md)已将正式广告位调整为3.675:1,并对生产原三张素材使用contain完整展示。此为页头/免费节点界面发布中的高度调整;compact重设计及其后续文案、cover取景仍为另一项本地任务,未随本批发布。生产article-ads模板、随机JS、广告配置及三张原图保持基线。各原图路线与本次精确SHA/恢复点以发布记录为准。 [2026-10-31正式发布记录](UI_RELEASE_2026-10-31.md)已将正式广告位调整为3.675:1,并对生产原三张素材使用contain完整展示。此为页头/免费节点界面发布中的高度调整;compact重设计及其后续文案、cover取景仍为另一项本地任务,未随本批发布。生产article-ads模板、随机JS、广告配置及三张原图保持基线。各原图路线与本次精确SHA/恢复点以发布记录为准。
2026-10-31用户再次指定品牌大小写为 `HardNerd`(H/N大写)。内置image_gen只替换字标大小写,保持上一版品牌/标语比例、间距、图标与背景。当前素材 `public/images/ads/hn-ad-compact-HardNerd-20261031.png`(2020×778),提示词 `output/compact-ads-20261031/hn-hardnerd-case-prompt.txt`;本地随机组件引用及alt同步,旧图保留,未发布。 2026-10-31用户再次指定品牌大小写为 `HardNerd`(H/N大写)。内置image_gen只替换字标大小写,保持上一版品牌/标语比例、间距、图标与背景。当前素材 `public/images/ads/hn-ad-compact-HardNerd-20261031.png`(2020×778),提示词 `output/compact-ads-20261031/hn-hardnerd-case-prompt.txt`;本地随机组件引用及alt同步,旧图保留,未发布。
2026-11-03本地新增统一[暂避风头模式](QUIET_MODE.md),可临时隐藏本站机场广告及推荐,单独广告开关和原数据保留。验收后本地恢复关闭,未部署;操作账号、状态优先级和发布范围以该文为准。

View File

@ -37,3 +37,5 @@ ## 本地验收
## Hardnerd优惠补齐(2026-10-31) ## Hardnerd优惠补齐(2026-10-31)
用户确认hardnerd同样使用`clashfreenode.com`享9折。仅更新共享推荐配置的HN coupon/discount及对应渲染断言,所有引用页随配置/响应缓存刷新生效,无需逐篇编辑正文。不创建或修改HN服务端优惠码,不执行下单或优惠核销。Git提交、正式HEAD、恢复点和服务器验收回执见`output/hn-coupon-20261031/`。 用户确认hardnerd同样使用`clashfreenode.com`享9折。仅更新共享推荐配置的HN coupon/discount及对应渲染断言,所有引用页随配置/响应缓存刷新生效,无需逐篇编辑正文。不创建或修改HN服务端优惠码,不执行下单或优惠核销。Git提交、正式HEAD、恢复点和服务器验收回执见`output/hn-coupon-20261031/`。
2026-11-03本地新增统一[暂避风头模式](QUIET_MODE.md),可临时隐藏本站机场广告及推荐,单独广告开关和原数据保留。验收后本地恢复关闭,未部署;操作账号、状态优先级和发布范围以该文为准。

View File

@ -43,3 +43,11 @@ ## 密码默认打码(2026-11-02,仅本地)
按用户截图要求,密码默认渲染固定八个圆点,眼睛按钮可显示/隐藏。无JavaScript时仍默认打码,复制保留原始完整密码;不写数据库、不改变剪贴板载荷。显示状态不持久化,刷新重新隐藏。打码仅改变视觉呈现,原复制载荷仍在按钮属性中。 按用户截图要求,密码默认渲染固定八个圆点,眼睛按钮可显示/隐藏。无JavaScript时仍默认打码,复制保留原始完整密码;不写数据库、不改变剪贴板载荷。显示状态不持久化,刷新重新隐藏。打码仅改变视觉呈现,原复制载荷仍在按钮属性中。
本次补跑此前审批超时的全库只读复核:82/82篇全部适配,3,124组账号密码逐项、逐序一致,无回退或不一致,原最终复核缺口已解决。7项/27断言专项测试、35/44组脱敏样例最终Blade复制载荷检查均通过。内置浏览器确认35张卡片初始全部打码、显示示例值/再次隐藏正确、复制成功、390px无横向溢出。截图只用示例账号,位于 `output/news-password-20261102/`;临时公开HTML已移除。尚未提交、推送或部署。 本次补跑此前审批超时的全库只读复核:82/82篇全部适配,3,124组账号密码逐项、逐序一致,无回退或不一致,原最终复核缺口已解决。7项/27断言专项测试、35/44组脱敏样例最终Blade复制载荷检查均通过。内置浏览器确认35张卡片初始全部打码、显示示例值/再次隐藏正确、复制成功、390px无横向溢出。截图只用示例账号,位于 `output/news-password-20261102/`;临时公开HTML已移除。尚未提交、推送或部署。
2026-11-02用户要求移除页头“共享账号”快捷按钮,已仅本地删除该锚点链接,保留“往期资源”入口及下方账号卡片、筛选、显示/隐藏与复制功能;未发布。
2026-11-02页头微调(仅本地):资源更新按钮组统一靠左;动物插画桌面从112×80放大为160×114,手机从68×76放大为80×90,小火箭页小于360px时使用72×84。沿用原图片、裁切方式及圆角,不改资源数据。桌面实测图160px且按钮左边距0,小火箭页390/320px无横向溢出。截图与CSS恢复点在 `output/resource-header-polish-20261102/`;未发布。
2026-11-02用户要求资源按钮紧接标题说明并调整留白:按钮组已移入两类资源页的页头文字列,位于说明段落下方,间距14px;免费节点页位于统计卡片之前。移除原页头之后独立工具条的边线与上下留白,图片尺寸保留。桌面截图和390px实测均无横向溢出,说明到按钮间距14px;原日期资源SEO/按钮合同1项17断言通过。截图 `output/resource-header-spacing-20261102/`。仅本地未发布。
2026-11-02本地新增两篇免费资源基础说明,并在相应资源详情页自动关联;源正文/复制载荷保持,未发布生产。当前实现和验收边界见[资源基础说明维护](RESOURCE_BASICS_2026-11-02.md)。

View File

@ -75,3 +75,28 @@ ## 整块使用方法移除(2026-11-01,仅本地)
用户截图要求去掉整块“使用方法”,已从免费节点共用模板移除标题、三步说明及底部Windows/Android/新手教程链接;同步删除页内导航的“使用说明”锚点,并清理只供该块使用的CSS,避免空跳转或占位。首页复用同一模板,因此同样生效。 用户截图要求去掉整块“使用方法”,已从免费节点共用模板移除标题、三步说明及底部Windows/Android/新手教程链接;同步删除页内导航的“使用说明”锚点,并清理只供该块使用的CSS,避免空跳转或占位。首页复用同一模板,因此同样生效。
订阅区上方的独立新手指引入口属于另一处内容,保持原样;原订阅地址/复制按钮、推荐模块、广告、文章数据和并行新手教程工作不改。浏览器本地确认使用方法相关DOM及锚点均0,指引入口1处、三种订阅复制按钮3个、无横向溢出,订阅补充说明后直接衔接机场推荐。截图[移除后](../../output/remove-usage-20261101/desktop.png),三个文件修改前副本保存在同目录before/。未提交、推送或发布。 订阅区上方的独立新手指引入口属于另一处内容,保持原样;原订阅地址/复制按钮、推荐模块、广告、文章数据和并行新手教程工作不改。浏览器本地确认使用方法相关DOM及锚点均0,指引入口1处、三种订阅复制按钮3个、无横向溢出,订阅补充说明后直接衔接机场推荐。截图[移除后](../../output/remove-usage-20261101/desktop.png),三个文件修改前副本保存在同目录before/。未提交、推送或发布。
2026-11-02用户要求删除免费节点概览下的“获取订阅/机场推荐”两个快捷按钮,已仅本地移除对应锚点链接,原订阅区和推荐内容保留;同排往期资源组件保留。未发布。
2026-11-02页头微调(仅本地):资源更新按钮组统一靠左;动物插画桌面从112×80放大为160×114,手机从68×76放大为80×90,小火箭页小于360px时使用72×84。沿用原图片、裁切方式及圆角,不改资源数据。桌面实测图160px且按钮左边距0,小火箭页390/320px无横向溢出。截图与CSS恢复点在 `output/resource-header-polish-20261102/`;未发布。
2026-11-02用户要求资源按钮紧接标题说明并调整留白:按钮组已移入两类资源页的页头文字列,位于说明段落下方,间距14px;免费节点页位于统计卡片之前。移除原页头之后独立工具条的边线与上下留白,图片尺寸保留。桌面截图和390px实测均无横向溢出,说明到按钮间距14px;原日期资源SEO/按钮合同1项17断言通过。截图 `output/resource-header-spacing-20261102/`。仅本地未发布。
2026-11-02本地新增两篇免费资源基础说明,并在相应资源详情页自动关联;源正文/复制载荷保持,未发布生产。当前实现和验收边界见[资源基础说明维护](RESOURCE_BASICS_2026-11-02.md)。
## 2026-11-02 页面层级与订阅列表整理(仅本地)
用户要求重新整理免费节点文章页,按“标题与说明/往期入口 → 本篇概览 → 紧凑新手入口 → 订阅列表 → 原补充内容与机场推荐”重新排版。沿用站点紫色、白色正文卡片和已有猫图;顶部插画仍桌面160×114、手机80×90。此前已移除的页内快捷按钮不恢复。概览采用分隔线,三种订阅合为同一列表中的独立行,客户端名称/原地址在左、复制按钮在右;窄屏长地址省略显示,完整值保留于title和复制属性,客户端标题左对齐自然换行。
主修改文件为`freenode/header.blade.php`、`freenode/content.blade.php`、`public/css/freenode-article.css`;新手入口仍复用现有组件,操作次入口文字改为“查看使用教程”。节点介绍改写与两篇置顶另见[资源基础说明](RESOURCE_BASICS_2026-11-02.md)及[新手教程维护](NEWCOMER_TUTORIALS_2026-11-01.md)。日更正文、订阅载荷、复制JS、图片关联、资源选取规则与SEO合同未改。
本次验收:
- 本地首页、0节点日期页和节点介绍真实页面已查看;当前首页按既有规则展示10月18日记录,10月21日的0节点仍原样显示并有最新入口。
- 内置浏览器桌面、390px及320px无横向溢出;三按钮边界在行内,`data-clipboard-text`与完整地址逐项一致。已操作复制按钮,但本轮未观察到“复制成功”提示,也未读取系统剪贴板;因此本轮不声称实际剪贴板写入通过。复制沿原ClipboardJS机制,本次未改相关脚本。
- 日间/夜间桌面视觉均已确认;临时视口恢复默认、主题恢复日间。未请求订阅地址、服务商或其他线上业务网站。
- `FreeNodeArticleLayoutTest`、`LatestFreeNodeHomeTest`、`SeoContractTest`以`--process-isolation`运行,23测试/242断言通过。首次同进程运行遇到既有`StatOrderLikes_meiyong.php`重复类声明,未修改无关文件。CSS最后只补标题左对齐,并在320px刷新回读确认。
- 截图及脱敏回读:`output/freenode-refresh-20261102/`;[桌面](../../output/freenode-refresh-20261102/desktop.png)、[手机](../../output/freenode-refresh-20261102/mobile.png)、[夜间](../../output/freenode-refresh-20261102/dark.png)。修改前定点副本及哈希在同目录`before/manifest.json`。撤回时按本次差异恢复,不整份覆盖后来或并行修改;无需回灌日更文章库。
未提交、推送、构建发布或部署;线上仍按此前正式发布记录运行。

View File

@ -0,0 +1,27 @@
# 栏目名称与导航图标
## 2026-11-02:进阶教程、图标对齐(仅本地)
用户要求将“综合教程”改为“进阶教程”,并对齐所有栏目图标。本次已本地实现与页面验收,未提交、推送或发布。
### 栏目名称
仅修改 `categories.sSlug=advanced` 的 `sName`,保留 `/advanced`、分类ID、排序、简介、文章归属、文章内容、正文链接及文章URL。导航、文章分类、面包屑和SEO标题通过现有数据库查询自动使用新名称,不对文章正文做全文替换。
可重复命令:`php artisan tutorials:rename-advanced` 预览;`--apply` 先保存0600私有分类快照,再有条件更新名称并清理 `global_nav_tree` 及响应缓存。异常分类名/身份则停止;重复运行不改数据。固定旧名称允许“综合教程”和“进阶教程”,不静默覆盖后续用户改名。
本地分类备份:`storage/app/private/category-backups/advanced-name-03fcc61b-8550-42ed-96f1-6d75664df69e.json`。上线时在目标运行该命令,不覆盖生产数据库或照搬本地ID。
### 图标对齐
`resources/views/components/nav-bar.blade.php` 为五个顶级栏目图标标记 `nav-category-icon`,文字包入 `nav-category-label`,装饰图标 aria-hidden。`public/css/cfn-brand.css` 中将桌面导航与手机侧栏顶级入口设为flex垂直居中,图标统一18×18px容器、16px字体,清掉旧样式的上移和不等外边距;文字列保持一致。图标含义、导航顺序、子菜单和原站点配色不变。
内置浏览器检查:1280px桌面5个图标均18px宽,图标/文字中心差均0;390px侧栏5个图标左边与文字起点分别一致,中心差均0,实际截图检查通过。原窗口尺寸已恢复。`/advanced` 标题为“进阶教程 - ClashFreeNode”,正常显示原文章,无水平溢出。PHP语法及diff空白检查通过。
截图与回执在 `output/nav-alignment-20261102/desktop.png` 和 `mobile.png`。浏览器初次连接失败,重试并重建工具会话后恢复,未使用外部浏览器。
回退:仅撤销导航模板的图标/文字标记及CSS末尾本批规则;分类名若无后续改动,可按该私有快照仅恢复 `sName` 并清缓存。不要整文件回退覆盖并行品牌、主题、侧栏或其他栏目名称修改。
## 2026-11-02:资源入口与往期导航(仅本地)
本批按用户新增要求,在原样式下增加“往期内容”及两个子栏目,免费节点和免费小火箭ID导航改为稳定最新入口;保留上述进阶教程名称与图标对齐。路径、索引、验收与回退唯一见[SEO实施维护](SEO_IMPLEMENTATION_2026-10-31.md#稳定入口与往期内容2026-11-02已本地实施未发布),未部署。

View File

@ -85,3 +85,24 @@ ## 新手栏目banner(同日用户追加)
映射入口:`config/tutorials.php`的`category_banners`;只应用于client真实子栏目,Android沿用既有`andorid`slug。模板为`resources/views/pages/articles/default/index.blade.php`,范围内样式在`public/css/tutorials.css`。公开JPG为`public/images/tutorial-categories/{platform}-paper-banner.jpg`(1916×821),原稿与内置imagegen完整提示词在`docs/content/tutorials/assets/categories/`;未写分类数据库或覆盖历史banner素材。 映射入口:`config/tutorials.php`的`category_banners`;只应用于client真实子栏目,Android沿用既有`andorid`slug。模板为`resources/views/pages/articles/default/index.blade.php`,范围内样式在`public/css/tutorials.css`。公开JPG为`public/images/tutorial-categories/{platform}-paper-banner.jpg`(1916×821),原稿与内置imagegen完整提示词在`docs/content/tutorials/assets/categories/`;未写分类数据库或覆盖历史banner素材。
六个页面桌面1280px及手机390px核对:父级0个banner;五个子栏目各1个且资源加载、平台标题和数量正确;无横向溢出。证据`output/newcomer-20261101/category-banner-checks.json`。回退只撤销本节映射、模板和局部CSS差异,旧数据库banner字段仍在。文章双比例配图、栏目图与相关代码目前均仅本地,未提交、推送或部署生产。 六个页面桌面1280px及手机390px核对:父级0个banner;五个子栏目各1个且资源加载、平台标题和数量正确;无横向溢出。证据`output/newcomer-20261101/category-banner-checks.json`。回退只撤销本节映射、模板和局部CSS差异,旧数据库banner字段仍在。文章双比例配图、栏目图与相关代码目前均仅本地,未提交、推送或部署生产。
## 2026-11-02 下载总览置顶与 Resource 优先(仅本地)
用户指定 `/a/proxy-clients.html` 置顶,并要求客户端下载文章优先走 Resource。下载总览 `newcomer-clients` 本地 ID368 已通过编辑 API 更新为版本3;正式仍为 ID388/版本1,此次未上线。新增31个按系统/架构区分的固定安装包入口,覆盖 Resource 现有六款客户端;官方来源降为补充,App Store 与未收录的 OpenClash 继续使用官方入口。
本批次将 `config/tutorials.php` 的 `pinned_slugs` 扩为 `getting-started`、`proxy-clients`(随后新增两篇基础说明,见下节)。`tutorials:organize` 默认只预览,`--apply` 验证每篇均已在新手栏目发布、保存私有元数据快照后置顶;保留其他文章的置顶状态。列表仍按现有置顶/时间/ID顺序,不改排序规则。元数据备份:`storage/app/private/editorial/tutorial-metadata-aaec110d-6c1c-4c80-978f-1f4858a50161.json`。
文章草稿 `289bcafb-1b55-44f9-98c0-8740e8d2fb14`,发布回执 `024f99ce-23df-4860-942c-e4be8e8fc523`,本地缓存/搜索/sitemap 均 done,IndexNow 按本地环境跳过。正文源/HTML/写作规则同步;保留 URL、栏目、原封面与横幅、日期、点赞设置及进度、标签和全部原锚点/内链。
Resource 清单通过本机 SSH→白色机→VPS-005 只读核对,31个安装包均在该服务器真实 HTTPS HEAD 返回200、长度与现存文件一致、无跳转;未下载整包或改 Resource 配置。选用清单、HEAD结果、本地前后正文、发布与置顶回读见 `output/client-downloads-20261102/`。本地真实 HTTP 文章和栏目入口检查通过,详情保留唯一横幅与5张表格;新手指引与下载总览排在普通文章前。内置浏览器本次返回 `Browser is not available: iab`,未做本次桌面/手机视觉验收,也未做线上页面验收。
回退正文使用本地 API 以当前版本建立版本2的恢复草稿再发布;置顶仅恢复快照内相关文章字段,确认无后续变化再操作,不重置日期或点赞。写作规范中的 Resource 优先规则是持续要求,非正文回退的自动撤销项。
2026-11-02本地新增两篇免费资源基础说明,并在相应资源详情页自动关联;源正文/复制载荷保持,未发布生产。当前实现和验收边界见[资源基础说明维护](RESOURCE_BASICS_2026-11-02.md)。
## 2026-11-02 两篇资源基础说明置顶(仅本地)
按用户截图要求,将本地ID375 `free-nodes-explained` 和ID376 `shadowrocket-id-explained`加入`pinned_slugs`,与既有`getting-started`、`proxy-clients`共同置顶。沿用`tutorials:organize --apply`,只更新置顶元数据,不改变文章时间、点赞或原排序规则;新手栏目名称仍为“新手教程”。本地`/client`首屏已核对两篇说明及“置顶”标记,原两篇指引置顶保留。
此次元数据快照:`storage/app/private/editorial/tutorial-metadata-6e762576-ee84-42be-abef-97207468496b.json`;截图`output/freenode-refresh-20261102/pinned.png`。撤销时只恢复该快照中本批新增的两个`iIsTop`字段并移除对应配置,不覆盖其他后续修改。免费节点说明同期改写到版本2,详情沿[资源基础说明](RESOURCE_BASICS_2026-11-02.md);未提交、推送或部署。

View File

@ -1,5 +1,7 @@
# 看看资讯栏目 # 看看资讯栏目
2026-11-02后续状态:用户要求去掉栏目。已确认本地/正式均为空,备份分类元数据后仅删除news分类,清除global_nav_tree与页面响应缓存。未删除文章或改变其他栏目;旧创建命令仅保留历史工具,不应自行重新执行。回执在output/remove-news-20261102/;本状态记录尚未Git发布,不影响已完成的数据库栏目撤下。
2026-11-02状态更新:本文所述待发布实现已随整批Git发布及固定文章API发布生效;当前范围、服务器侧验收与回退以[整批发布记录](FULL_RELEASE_2026-11-02.md)为准。下文原日期的“仅本地/未发布”保留为当时记录。未做线上页面验收。 2026-11-02状态更新:本文所述待发布实现已随整批Git发布及固定文章API发布生效;当前范围、服务器侧验收与回退以[整批发布记录](FULL_RELEASE_2026-11-02.md)为准。下文原日期的“仅本地/未发布”保留为当时记录。未做线上页面验收。
2026-11-02按用户要求建立独立栏目“看看资讯”,仅本地生效,未提交、推送或部署线上。 2026-11-02按用户要求建立独立栏目“看看资讯”,仅本地生效,未提交、推送或部署线上。

View File

@ -0,0 +1,47 @@
# 暂避风头模式
2026-11-03按用户要求新增统一开关:免费节点暂停更新,机场广告暂时下掉。仅本地实现与验收,未提交、推送或部署;页面验收时曾临时开启,收尾已恢复关闭。
## 操作
在本站项目目录运行:
```sh
php artisan site:quiet on
php artisan site:quiet off
php artisan site:quiet status
```
- on:阻止免费节点新发布和覆盖更新;隐藏机场随机横幅、推荐表格、已知旧福云推广段与自动推荐跳转。
- off:恢复正常接收与原广告配置,各服务商单独开关/链接/价格/优惠码保持原值。
- status:只读查询。没有给公众网页增加操作入口或状态横幅。
初始无运行状态文件时按 `SITE_QUIET_MODE` 配置,默认false。运行命令后以 `storage/app/private/site-mode.json` 内的布尔值为优先,包括false;不必重启PHP或重建配置缓存。CLI与网页使用同一实例目录,不共享其他实例状态。环境变量只作为不存在文件时的缺省值,不覆盖已有运行选择。
状态文件原子替换、0600,私有目录不存在时0700创建,已确认Git忽略。**发布后须以PHP运行账号执行开关命令**,先核实该账号(已有cfn操作常用nginx),不要用root生成网页进程无法读取的0600文件。状态文件损坏/不可读取时保持暂停与隐藏,不能静默恢复;修复同一文件及所有者后运行off或on并分别核对CLI和页面。
开启/关闭会清理响应缓存;既有HTML响应缓存合同本已关闭。切换后新请求生效,已打开的旧页面需刷新。开关保存在项目运行目录,发布不能打包覆盖/删除它。
## 范围与实现
- `app/Services/SiteMode.php` 读取开关,暂停返回HTTP503,JSON `isDone=false / status=paused / code=FREE_NODE_UPDATES_PAUSED`,带 `Retry-After: 3600`。不上报发布成功,不消耗正常发布回执;恢复后由正常上游重试/后续发布执行,不自动重放或补造历史文章。
- 两个免费节点接收方法在解析/Flow/配图调用之前阻断;通用接收在目标分类或原文章为freenode时阻断,不能通过换分类绕过。没有改上游任务/服务或服务器调度。
- 文章Eloquent保存事件补充检查:涉及节点正文、标题、图片、分类、发布时间、资源状态等发布字段时拒绝写入;同样保留旧数据。点赞、浏览等指标允许正常保存。直接绕过模型的SQL/人工运维不属于应用发布开关的保护范围。
- 仅暂停freenode,原文章和订阅仍可访问;小火箭ID、教程、资讯、资源列表和站点导航不停止。
- 随机广告组件及保留的三个旧广告组件均检查开关,关闭时不输出广告模板、图片或noscript广告。
- 推荐模块providers为空,隐藏共用推荐并清理匹配的已知旧推广及自动跳转。只处理明确的旧推广结构,不猜测删除普通正文,账号/订阅复制值与正文数据库不变。
- 该模式不删除数据、不撤下已发布资源、不切换现有SEO规则、域名、证书、图片池、上游服务器或其他项目。
## 验收
- 专项 `QuietModeTest`:5项/51断言通过,覆盖命令双向切换、状态持久化/权限、异常状态、专用及通用入口阻断、换分类绕过、模型新建/更新保护、真实点赞字段保存、其他类型放行、广告/旧推广隐藏、复制值及原文保护、恢复。
- 正常回归:QuietMode、ArticleRecommendations、SEO合同及苹果ID解析共32项/331断言通过;之后仅补充旧组件与真实点赞保存断言,再跑专项5项/51断言通过。测试基类隔离运行站点的开关,避免测试误继承本地暂时启用的状态;初轮未隔离的失败已修正。
- 本地IAB开启实测:首页3个复制按钮保留,随机广告0、推荐表格0、旧推广0;教程正文可读,广告0、推荐0、自动推荐跳转0。截图 `output/quiet-mode-20261103/quiet-on.png`。
- 关闭后同一首页恢复随机广告1、推荐模块1,3个订阅按钮保留。CLI回读明确关闭。未进行实际账号登录、上游真实发稿或线上访问。
- PHP语法与本次diff检查通过,未进行生产构建。
## 发布与回退
须用户另行授权Git发布。相关文件包括SiteMode服务、QuietMode命令、配置、接收器入口、模型保存事件、推荐展示服务、四个广告组件、环境模板与测试/维护入口;不能漏发模型或展示服务。默认缺省关闭,不应随代码发布自动切换生产模式。
回退前关闭模式并核对正常状态,再增量撤销本批调用;不得整文件还原覆盖并行SEO、文章渲染或其他修改。运行状态文件须单独保留/按明确运维操作处理,不以Git回退等同模式切换。

View File

@ -0,0 +1,46 @@
# 免费资源基础说明与文章关联
状态:2026-11-02已保存到本地新手教程并接入本地资源页;未提交、推送或发布生产。用户要求分别解释免费节点和免费小火箭 ID 的用途,面向完全不懂的人,图文并茂,并从相应资源文章关联。
## 内容与配图
- 本地ID375 / `free-nodes-explained` / 当前版本2:从普通网页打不开的日常场景说明免费节点、连接软件和订阅,再给第一次使用的四步;按用户最新要求不以油管举例。
- 本地ID376 / `shadowrocket-id-explained` / 版本1:账号获取应用、应用使用节点、App Store与iCloud区别、账号页字段与后续操作。
- 本地ID373 / `newcomer-start` / 版本4:仅追加两篇入口,旧属性、日期、图片与点赞逐项一致。
- 文章路径及相互关系以[写作索引](../content/WRITING.md)为准。源 Markdown 和 HTML 在 `docs/content/tutorials/newcomer/`。
- 两篇各有独立3:2封面(1536×1024)、约2.4:1横幅(1942×809),初版正文各两张可响应式排版的文字关系图;节点说明版本2改为一张设备→节点→网站图及一张三概念对照表。四张图均由内置 image_gen 生成,纸艺拼贴/微缩立体质感、不同题材,带 `clashfreenode.com` 水印;原PNG、交付JPG、完整提示词在 `docs/content/tutorials/assets/basics/`,未裁切、拉伸或替换其他文章图。
本轮官方资料:Clash Verge Rev快速入门(https://www.clashverge.dev/guide/quickstart.html)、Mihomo代理集合(https://wiki.metacubex.one/config/proxy-providers/)、Shadowrocket App Store(https://apps.apple.com/us/app/shadowrocket/id932747118)、Apple账户与媒体购买区分(https://support.apple.com/zh-cn/117294)、登录与其他账户购买内容限制(https://support.apple.com/zh-cn/111001)、账户安全(https://support.apple.com/zh-cn/102614)。用途与来源核验,不等于实机安装、账号登录或节点联网测试。公开正文没有内部核验说明,没有真实账号/密码或未经验证的可用保证。
## 本地发布回执
草稿 `736d132a-0ae0-4af3-b913-4680d6f547fa`;hash `78d29abf19abcc3360d6c47acf7219a643ee359145fcf9e0a27eb6a7292b5a75`;发布请求/回执 `e59fbcf9-b4e5-450f-bf09-424497bbd7e9`。本地文章API同批应用3篇;cache/search/sitemap均done,IndexNow按本地环境跳过。
新文点赞按配置冻结:节点说明 seven_day=1/yearly=50/one_year=50;ID说明18/50/145,开始时间均为实际本地发布时刻。旧指引不重置;未计入周期生产发布额度。
## 统一资源页入口
`config/tutorials.php` 新增 `resource_explainers`;组件 `components/resource-explainer-link` 查询公开目标及新手栏目。节点页既有 `newcomer-guide-link` 作为兼容包装,显示概念文主入口与操作指引次入口;目标概念文未发布时回退原指引。小火箭ID结构化详情与旧正文回退布局均加入一次入口;缺目标时隐藏。独立 `public/css/resource-explainer.css` 使用站点主题变量和手机堆叠布局,不改全站排版。
不批量改写每日文章、不改订阅/账号/密码。现有和未来每日文章通过公共模板自动关联,撤下教程时不生成无效入口。
## 验证与当前缺口
- 本地API真实模板预览3篇HTTP200;两篇新文各唯一横幅、2张正文图解,原新手指引保留单幅横图。全部内部链接与锚点经API校验。
- 最终两篇公开本地页面HTTP200,四张交付图片HTTP200且SHA256与资产回执一致;列表3:2、横幅约2.4:1尺寸一致,HTML源与API回读一致。`image-page-checks.json`保存结果;项目配置PHP语法与GemDocs离线文档检查通过。
- 原新手指引身份、栏目、日期、图片、标签和点赞设置/进度逐项保留。
- 资源页实际HTTP200:免费节点3个复制值、免费小火箭ID70个复制值与原始正文一致;两页各只出现一次概念入口,原正文哈希不变。结果仅保存统计与路径,不输出秘密。
- 既有FreeNodeArticleLayout与AppleIdArticleLayout测试:独立进程运行11测试42断言通过。默认同进程运行遇到既有StatOrderLikes_meiyong重复类声明,未修改该无关代码。
- 内置浏览器连接不可用,工具返回浏览器列表为空和transport closed。用户随后明确批准仅用Safari检查fna-v3.loc本地文章和入口;该授权持续有效,不重复索取。已在Safari新标签输入免费节点说明的准确本地地址,但读取画面、重新按应用名连接、按bundle ID连接连续三次超时并重置工具内核,未取得文章画面;桌面/手机视觉验收仍未完成,阻塞为浏览器控制工具,不是缺用户批准。未改用其他浏览器或访问生产站。
- 无生产操作,未做线上页面验收。
回执、发布请求、预览、读回、资源页检查及修改前文件见 `output/resource-basics-20261102/`。恢复正文走文章API新版本;入口按本任务差异撤回模板/配置/CSS,保留其他并行修改。正式上线须先经内容发布流程创建目标,再通过Git部署入口模板,回读映射与目标路径;不把本地ID当正式ID,不开放公网编辑API。
## 2026-11-02 介绍简化与页面复核(仅本地)
用户先要求油管例子,随后明确取消。节点说明以日常网页访问场景重写,保留标题、URL、原两张配图、6个既有锚点和自然内链;ID说明正文未改。源Markdown、HTML与本地运行文章同步。原文章ID、栏目、标签、创建/展示日期、封面、show_cover与点赞设置逐项一致。
本地草稿`e60c97fb-f03c-4000-967a-ef25046aeeb4`经签名预览后按hash发布,回执`064d2b0c-2414-4e24-a255-16078878b58f`:ID375/版本2、cache/search/sitemap均done,IndexNow因本地环境跳过。受限完整回执与发布前源在`storage/app/private/editorial/free-nodes-beginner-20261102/`,脱敏属性核对在`output/freenode-refresh-20261102/article-readback.json`;回退以当前版本建立版本1恢复草稿再发布,不直接SQL覆盖正文。
本次内置浏览器恢复可用:已查看节点说明的真实模板预览、最终公开本地页及320px排版;单幅横图、无油管文字、6个锚点、无横向溢出确认。两篇说明在`/client`首屏均显示置顶。资源页重排及复制验证边界沿[节点内容页维护](FREENODE_ARTICLE_LAYOUT.md#2026-11-02-页面层级与订阅列表整理仅本地)。此次验证补充此前浏览器故障记录,不代表两篇全部设备/浏览器均已验收;未发布生产。

View File

@ -4,6 +4,8 @@ # FNA_V3 SEO 实施与发布 · 2026-10-31
搜索渠道按用户此前与雷欧确认的 Yandex / Bing 优先,保留通用搜索兼容;两者均支持本批采用的规范URL、sitemap与IndexNow。依据:[Bing指南](https://www.bing.com/webmasters/help/webmaster-guidelines-30fba23a)、[Yandex IndexNow](https://www.yandex.com/support/webmaster/en/indexing-options/index-now.html)、[IndexNow协议](https://www.indexnow.org/documentation)(2026-10-31核对)。 搜索渠道按用户此前与雷欧确认的 Yandex / Bing 优先,保留通用搜索兼容;两者均支持本批采用的规范URL、sitemap与IndexNow。依据:[Bing指南](https://www.bing.com/webmasters/help/webmaster-guidelines-30fba23a)、[Yandex IndexNow](https://www.yandex.com/support/webmaster/en/indexing-options/index-now.html)、[IndexNow协议](https://www.indexnow.org/documentation)(2026-10-31核对)。
> 2026-11-02本地新增的稳定入口与往期内容合同见本文件对应章节,尚未发布;以下审计与首批发布保留当时状态。
> 用户于本批上线后明确纠正:只做内部SEO,界面必须与优化前一致。可见布局、说明、分页、标题和图片呈现已恢复到优化前,修复已上线;现行约束及验证以[界面保留修复](SEO_UI_RESTORE_2026-10-31.md)为准,下面保留首批发布历史。 > 用户于本批上线后明确纠正:只做内部SEO,界面必须与优化前一致。可见布局、说明、分页、标题和图片呈现已恢复到优化前,修复已上线;现行约束及验证以[界面保留修复](SEO_UI_RESTORE_2026-10-31.md)为准,下面保留首批发布历史。
## 日期文章内容性质核查(2026-11-02,只读) ## 日期文章内容性质核查(2026-11-02,只读)
@ -12,11 +14,11 @@ ## 日期文章内容性质核查(2026-11-02,只读)
- 最近30篇在屏蔽订阅URL、日期及两行资源结果(节点数量/速度、地区)后,正文模板完全相同;全88篇有6种模板,其中75篇使用同一模板。URL屏蔽后的相邻正文字符相似度中位数,最近30篇为96.6%。这是自定义文本比较结果,不是搜索引擎重复判定或排名分数。 - 最近30篇在屏蔽订阅URL、日期及两行资源结果(节点数量/速度、地区)后,正文模板完全相同;全88篇有6种模板,其中75篇使用同一模板。URL屏蔽后的相邻正文字符相似度中位数,最近30篇为96.6%。这是自定义文本比较结果,不是搜索引擎重复判定或排名分数。
- 88篇订阅地址集合均不同,不能称为字面上“只换日期”,也不能据地址不同就断言订阅文件内容或节点实际可用性不同。最近三篇正文所报节点数依次为1588、1660、1547,速度与地区也变化;这些是发布载荷中的声明,未在本次进行联网实测。 - 88篇订阅地址集合均不同,不能称为字面上“只换日期”,也不能据地址不同就断言订阅文件内容或节点实际可用性不同。最近三篇正文所报节点数依次为1588、1660、1547,速度与地区也变化;这些是发布载荷中的声明,未在本次进行联网实测。
- 正文主体是相同的订阅获取流程、推广、渠道与使用提示,没有按日新增的独立讲解、测评过程或故障分析。本次架构判断为“高度模板化的日更资源记录”,不足以仅凭日期和数字变化就把每篇当作独立编辑文章争取索引。 - 固定文字主要是订阅获取流程、推广、渠道与使用提示,没有按日新增的独立讲解、测评过程或故障分析。这能证明资源文章模板高度一致,但资源数据本身也是主要内容;剔除资源字段后的相似度不足以证明每篇没有独立价值,也不能代替搜索引擎的重复判定。
- 判断:稳定首页承担最新资源搜索入口,新日期记录保留公开访问并维持归档noindex方向;不依据假设批量恢复索引,不将canonical到首页承诺为历史权重转移。日期页自指canonical与noindex分别表达原始记录地址及不独立索引的意图。若目标改变为将日期页作为首页重复版本做信号合并,属于另一个需统一设计并验证搜索引擎选择的策略,不能与本次只读判断混作已实施。 - 早先据模板相似建议新日期记录统一归档noindex,推断超过了本次核查范围,已撤回该确定性结论。现行代码与建议方案分开:本次没有修改归档规则,后续用户最终选择及本地实施见下方“稳定入口与往期内容”。canonical只用于合适的重复版本关系,不能承诺历史权重转移。
- 当前新两篇`fn20261101`、`fn20261102`为archive/noindex,旧日期记录仍沿用原7天规则。本次没有修改该过渡边界;未取得Yandex/Bing流量、外链或实际canonical选择报告,不声称历史日期流量为零。 - 当前新两篇`fn20261101`、`fn20261102`为archive/noindex,旧日期记录仍沿用原7天规则。本次没有修改该过渡边界;未取得Yandex/Bing流量、外链或实际canonical选择报告,不声称历史日期流量为零。
纠正本聊天前一条建议:未检查实际正文便提出取消新日期文章统一noindex,依据不足,该建议撤回。上述内容性质判断来自本次生产正文核查;它不是扩大历史批量变更或改变界面的授权。 核查所得的内容统计继续有效;先后关于“全部放开必然更好”和“模板相似就应全部noindex”的确定性判断均没有搜索平台效果证据,不作为已验证结论。本段不是生产修改或界面变更授权。
## 免费苹果ID内容性质核查(2026-11-02,只读) ## 免费苹果ID内容性质核查(2026-11-02,只读)
@ -28,7 +30,7 @@ ## 免费苹果ID内容性质核查(2026-11-02,只读)
- 正式规则仍为分类7天索引窗口,没有像新免费节点一样创建即archive。核查时10月27日至11月2日7篇允许索引,较早日期页noindex;空账号接收没有免费节点同等的有效结果门槛。 - 正式规则仍为分类7天索引窗口,没有像新免费节点一样创建即archive。核查时10月27日至11月2日7篇允许索引,较早日期页noindex;空账号接收没有免费节点同等的有效结果门槛。
- 在业务服务器进程内渲染`/shadowrocketids`:200、index/follow、自指canonical,内容为日期文章列表而非直接提供账号的稳定详情页。此证据仅为服务器侧渲染,不是线上浏览器页面验收;本地账号卡片/筛选/密码默认打码改版仍未部署。 - 在业务服务器进程内渲染`/shadowrocketids`:200、index/follow、自指canonical,内容为日期文章列表而非直接提供账号的稳定详情页。此证据仅为服务器侧渲染,不是线上浏览器页面验收;本地账号卡片/筛选/密码默认打码改版仍未部署。
架构判断与建议:此类内容是有时效的账号资源快照,不是每天一篇独立编辑文章。长期搜索入口应落在苹果ID自己的固定地址,不能归并到展示免费节点的网站首页。先让既有`/shadowrocketids`稳定承接最新非空账号内容、准确的逐条检测时间与使用说明,再将日期快照作为可访问的noindex归档并保留原URL;不能先全部noindex而只留下无法直接提供账号的列表入口。空记录不替代最新有内容记录,历史条目不冒充当前有效账号。本段是建议及当前事实,未改变现行7天规则、栏目呈现或新建入口;实际账号可用性和搜索平台效果未验收。 内容性质判断:此类内容是有时效的账号资源快照,但资源页不必具有独立编辑长文才有搜索价值;本次没有比较账号身份和密码变化,不能证明所有日期页实质重复。此前单凭模板相似提出的统一noindex建议,不作为本次内容核查的必然结论;当时建议保留栏目列表与界面;后续用户另行批准本地改为稳定入口及往期列表,现行本地合同见下节。账号页不能批量canonical到免费节点首页,旧检测时间不等于已失效。实际账号可用性和搜索平台效果未验收。
原拟账号/密码跨日对比操作因潜在敏感派生数据外带被自动审批拒绝,未执行。随后获准的替代脚本彻底移除了取值、凭据哈希和身份对比,仅取得上述非敏感汇总;账号重合率及密码变化率不在本次结论范围。 原拟账号/密码跨日对比操作因潜在敏感派生数据外带被自动审批拒绝,未执行。随后获准的替代脚本彻底移除了取值、凭据哈希和身份对比,仅取得上述非敏感汇总;账号重合率及密码变化率不在本次结论范围。
@ -43,7 +45,51 @@ ## 第三方v2rayshare.net日期页对照(2026-11-02,只读)
公开证据:[当前日期页](https://v2rayshare.net/p/5017.html)、[历史日期页](https://v2rayshare.net/p/3327.html)、[文章sitemap](https://v2rayshare.net/wp-sitemap-posts-post-1.xml)。原始响应摘要保存在本次私有临时审计文件,文档只保留非敏感状态和URL。 公开证据:[当前日期页](https://v2rayshare.net/p/5017.html)、[历史日期页](https://v2rayshare.net/p/3327.html)、[文章sitemap](https://v2rayshare.net/wp-sitemap-posts-post-1.xml)。原始响应摘要保存在本次私有临时审计文件,文档只保留非敏感状态和URL。
## 首批实现记录 ## 稳定入口与往期内容(2026-11-02,已本地实施,未发布)
用户最终明确选择:“往期直接noindex”“入口文章里加上往期内容的指向”“弄,现在本地”。本节取代此前“日期文章默认开放索引”的待实施建议,也取代本地原7天窗口;属于用户选择的站点结构,不代表搜索平台认定全部日期内容无价值。下方首批发布及上方只读审计均保留其当时范围,不能作为本次已上线证据。
| 页面 | 本地行为 | 面向正式环境的索引合同 |
| --- | --- | --- |
| `/` | 免费节点导航直接进入,显示最近一篇非空且未明确失败的节点记录 | index, follow;canonical 自指;有效时进入 sitemap |
| `/shadowrocketids` | 免费小火箭ID导航直接进入,显示最近一篇结构可识别、至少一个完整账号的记录 | index, follow;canonical 自指;有效时进入 sitemap |
| `/history` | 往期内容总列表,提供两个子栏目入口 | noindex, follow;canonical 自指 |
| `/history/freenode`、`/history/shadowrocketids` | 对应往期列表;排除各自当前入口选中的记录,按发布时间、ID倒序 | noindex, follow;各分页 canonical 保留自己的页码 |
| 两类资源的 `/a/<slug>.html` | 原网址、原记录继续可读,包括当前入口对应的日期快照 | noindex, follow;canonical 指向原文章自身,不指入口或首页 |
| `/freenode` | 旧栏目兼容地址,301至 `/` | 不进入 sitemap、不作为更新通知目标 |
本地环境仍按既有保护统一输出 `noindex, nofollow`;上表的入口 index、往期 noindex 区别由隔离 testing 环境验证。没有改生产环境、搜索平台或本地索引保护配置。
- 导航新增“往期内容”,桌面与手机都有“免费节点”“免费小火箭ID”子项。入口文章操作栏增加对应“往期内容”按钮;日期详情还提供“最新内容”按钮。保持原卡片、正文布局、图片、广告与其他栏目样式,保留并行的进阶教程改名及图标对齐。
- 最新选择排除草稿、未来文章、隐藏分类及隐藏父分类,不采用置顶顺序。节点沿用失败/明确零数量保护并排除空正文;账号未知结构或空记录保留原网址,但不顶替最新入口。这里的“可展示”只指已存载荷,未联网验证节点或账号可用性。没有可展示记录时入口返回503及Retry-After,不伪造今日数据。
- 旧 `/?page=N`、`/freenode?page=N`、`/shadowrocketids?page=N` 在N>1时301到对应往期分页;page=1归一至入口。往期沿既有分页大小和滚动加载,页码非法、重复、数组、越界仍404。
- `ResourcePages` 集中两类入口/归档路径;`Seo::indexable` 对两类日期快照统一排除,覆盖旧的index覆写及分类7天窗口。新两类记录标记archive,已有数据库字段不批量回填。其他栏目与教程保留原索引合同。
- sitemap仅保留有效稳定资源入口及其他可索引栏目/文章,不列日期快照、history或301别名。日更更新通知对应稳定入口;重试队列过滤旧快照、history和/freenode更新目标,删除通知仍可报原删除网址。本地环境不发送真实IndexNow请求,测试使用HTTP替身。
- 没有修改数据库正文、分类、日期、账号/订阅载荷或图片关系;不用每日迁移文章分类。旧文已有 `/freenode` 内链保留并通过301兼容,新写链接按用途选择入口或history。noindex不代表历史权重自动转移,搜索效果未验证。
验收:61项隔离测试、571项断言全部通过,涵盖可见性、最新选择、空记录回退、原日期网址、自指canonical、分页/别名、菜单、sitemap、通知重试及既有编辑API/发布合同。浏览器本地1280px桌面与390px手机检查通过,子菜单可展开并进入分类;入口→往期→原日期文章、原文→最新入口可用,滚动加载从2条增至4条且无重复,下一页正确推进,无水平溢出。截图与本次文件恢复点在 `output/resource-archive-20261102/`。
2026-11-02用户针对独立文字链接要求融合设计,已本地将入口改为历史图标+“往期内容”次级按钮:免费节点并入“获取订阅/机场推荐”同一操作栏,小火箭页并入“共享账号”操作栏;桌面右对齐,窄屏自然换行,历史详情的“最新内容”使用同组按钮。未知正文格式保留独立按钮组回退;普通教程不出现。共享样式在 `public/css/resource-history.css`,无障碍名称保留完整资源类别,原路由、noindex/canonical、正文、复制载荷和广告位置不变。14项现有SEO合同测试/203断言通过,1128px桌面、390/320px窄屏及往期跳转已复核,无横向溢出;实际暗色视觉未单独验收(页面保留用户浅色选择)。浏览器连接异常后通过同一IAB的受支持标签接口恢复,未换外部浏览器,临时视口及颜色媒体模拟均已复原。局部恢复点和截图在 `output/resource-history-design-20261102/`;不以此前plain链接截图作为当前设计。仅本地,未提交、推送或部署。
2026-11-02用户再次调整可见文案:移除往期列表顶部整块标题、资源分类导航和“查看最新”链接,让列表直接开始;总导航及其往期子菜单保留。资源详情的历史按钮改为“往期+当前资源栏目名”,当前分别为“往期免费节点”“往期免费小火箭ID”。仅改展示模板,入口/日期网址、noindex、canonical、分页和数据筛选不变。2项专项合同测试/45断言通过,IAB确认列表头消失、原列表保留、两个按钮实际文案正确。截图与列表模板恢复点在 `output/history-header-removal-20261102/`。仅本地,未提交、推送或部署。
2026-11-02最新文案纠正:用户要求统一称“资源”,资源按钮现为“往期资源/最新资源”,导航总入口和往期页面元标题同步为“往期资源”;无障碍名称保留括号中的资源类别。此项替代前段按类别拼接按钮的文案,路由、隐藏列表头、数据和索引规则不变,仅本地未发布。
2026-11-02用户将资源按钮组统一移至左侧,替代前段桌面右对齐;移动只改 `resource-history.css` 中自动左外边距为0,路由、文案和索引合同不变。已本地复核,尚未发布。
2026-11-02往期资源父/子栏目体验调整:用户要求总栏目同时汇总免费节点与免费小火箭ID,子栏目分别筛选。原查询已合并两类,但本地page_limit=2使首屏两条均为节点;现增加独立 `seo.history_page_size=10`,仅往期总/子列表使用,其他列表分页不变。总列表仍按真实发布时间/ID倒序,不强行轮换、不造分类关系。往期卡片分类链接改指 `/history/{类型}`,非往期列表保留原链接。仍排除各类型当前稳定入口选中的记录,旧noindex/canonical合同保持。
本地只读核对158条往期记录,首批10条含节点5条、小火箭5条;IAB实际从总栏目点小火箭标签后仅显示小火箭,再点导航父栏目恢复两类;下一页链接为 `/history?page=2`,无横向溢出。2项专项测试/36断言通过,覆盖在两条节点更新较新时仍同页出现小火箭、类型过滤、分类链接、原排序与分页边界。截图及检查脚本在 `output/history-overview-20261102/`。未改数据库,未发布。
2026-11-02资源按钮最新位置:免费节点和小火箭页均放入页头左侧文字列,紧接说明段落(14px),不再位于整幅页头/统计卡片下方的独立工具条。只调整展示位置与间距,原入口/往期链接及索引合同不变,仍仅本地未发布。局部截图见 `output/resource-header-spacing-20261102/`。
本地内容快照中10月19—21日节点记录明确为0条,因此入口沿既有保护展示 `fn20261018`;账号入口展示10月21日记录。未同步生产内容,也未测试订阅下载或账号登录。
回退只撤销本次相对 `before/manifest.json` 的增量,并删除本批新建的三个资源入口/导航组件文件(`ResourcePages.php`、`resource-history-menu.blade.php`、`resource-history-link.blade.php`);不整文件回退覆盖并行工作,不还原整库。代码、测试与文档仅在本地,未提交、推送或部署。
官方原则来源保留:[Google规范页选择](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls)、[Bing重复内容说明](https://blogs.bing.com/webmaster/2025/12/Does-Duplicate-Content-Hurt-SEO-and-AI-Search-Visibility/)、[Yandex规范地址](https://yandex.ru/support/webmaster/en/robot-workings/canonical)。本节noindex选择是用户明确的资源入口/历史分工,不将其表述为引擎强制要求或保证排名提升。
## 首批实现记录(2026-10-31发布历史)
- 首页 `/` 使用稳定标题/H1、WebSite、独立 canonical,显示最近可用记录全文及真实记录时间;明确零结果或失败不顶替上次记录,无可展示记录返回503/Retry-After。`/freenode` 是带历史说明的分页归档。旧的 `/?page=N` 定向到对应归档页;首页不是分页第一页。 - 首页 `/` 使用稳定标题/H1、WebSite、独立 canonical,显示最近可用记录全文及真实记录时间;明确零结果或失败不顶替上次记录,无可展示记录返回503/Retry-After。`/freenode` 是带历史说明的分页归档。旧的 `/?page=N` 定向到对应归档页;首页不是分页第一页。
- 新免费节点记录创建即 `sSeoPolicy=archive`,可浏览、noindex、退出sitemap;已有资源仍保留分类7天策略,教程不继承资源期限。没有批量删除、合并、301到首页或恢复历史收录。取消 ClearDb 的180天正文删除,30天点赞明细清理保留。 - 新免费节点记录创建即 `sSeoPolicy=archive`,可浏览、noindex、退出sitemap;已有资源仍保留分类7天策略,教程不继承资源期限。没有批量删除、合并、301到首页或恢复历史收录。取消 ClearDb 的180天正文删除,30天点赞明细清理保留。

View File

@ -1,5 +1,7 @@
> 2026-10-31界面约束更新:只做内部SEO,保持优化前外观。可见分页、说明/H1及列表图片改造撤回;以[界面恢复](SEO_UI_RESTORE_2026-10-31.md)为准,不能沿旧清单重新改版。 > 2026-10-31界面约束更新:只做内部SEO,保持优化前外观。可见分页、说明/H1及列表图片改造撤回;以[界面恢复](SEO_UI_RESTORE_2026-10-31.md)为准,不能沿旧清单重新改版。
> 2026-11-02本地增量:按用户最终选择实现稳定资源入口及往期noindex;已本地验收、未发布。当前本地规则以[稳定入口与往期合同](SEO_IMPLEMENTATION_2026-10-31.md#稳定入口与往期内容2026-11-02已本地实施未发布)为准,下面历史审计不自动更新为线上状态。
> 2026-10-31最新状态:本批技术实现已发布正式cfn,189篇服务器侧验收通过、原数据保持;sitemap 35项,IndexNow首页/栏目通知HTTP200。实现、限制及精确发布证据见 [SEO实施记录](SEO_IMPLEMENTATION_2026-10-31.md)。原审计的“未实施/未发布”仅描述其当时状态。 > 2026-10-31最新状态:本批技术实现已发布正式cfn,189篇服务器侧验收通过、原数据保持;sitemap 35项,IndexNow首页/栏目通知HTTP200。实现、限制及精确发布证据见 [SEO实施记录](SEO_IMPLEMENTATION_2026-10-31.md)。原审计的“未实施/未发布”仅描述其当时状态。
# FNA_V3 SEO 优化建议与执行清单 # FNA_V3 SEO 优化建议与执行清单

View File

@ -11,7 +11,7 @@ ## 栏目与文章入口
| 对象 | 本地数据库身份 | URL | 本次状态 | | 对象 | 本地数据库身份 | URL | 本次状态 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 入门教程 | categories.id=2,原客户端 | `/client` | 仅更名和更新简介,原平台子分类及 URL 保留 | | 入门教程 | categories.id=2,原客户端 | `/client` | 仅更名和更新简介,原平台子分类及 URL 保留 |
| 综合教程 | categories.id=10,根分类,排序1 | `/advanced` | 原进阶教程更名,地址和文章归属保留;无资源文章的时效 noindex 策略 | | 进阶教程 | categories.id=10,根分类,排序1 | `/advanced` | 2026-11-02本地由综合教程改名,尚未发布;地址和文章归属保留 |
文章标题、ID、稳定URL、源文件和正文关联统一维护在[AI写作与文章关联索引](../content/WRITING.md),本页只保留实现、验收与回退资料。 文章标题、ID、稳定URL、源文件和正文关联统一维护在[AI写作与文章关联索引](../content/WRITING.md),本页只保留实现、验收与回退资料。
@ -192,3 +192,5 @@ ## 综合教程列表移除图片横幅(2026-10-31,仅本地)
按用户要求,`/advanced`不再渲染列表顶部的猫图横幅及叠加数量;用户随后明确标题介绍卡也不要,现与免费节点列表一样直接显示文章列表。页面title与meta description仍保留栏目名称和简介。仅调整列表模板的展示条件,栏目配置和文章内容未改;入门教程与平台子栏目仍按原配置显示横幅。未上线。 按用户要求,`/advanced`不再渲染列表顶部的猫图横幅及叠加数量;用户随后明确标题介绍卡也不要,现与免费节点列表一样直接显示文章列表。页面title与meta description仍保留栏目名称和简介。仅调整列表模板的展示条件,栏目配置和文章内容未改;入门教程与平台子栏目仍按原配置显示横幅。未上线。
2026-10-31综合教程列表去除横幅及介绍卡已随[2026-10-31正式发布记录](UI_RELEASE_2026-10-31.md)上线,入门和平台子栏目横幅保留;本次不发布新的教程正文。 2026-10-31综合教程列表去除横幅及介绍卡已随[2026-10-31正式发布记录](UI_RELEASE_2026-10-31.md)上线,入门和平台子栏目横幅保留;本次不发布新的教程正文。
2026-11-02本地新增改名与导航图标对齐,尚未发布,不能套用本文开头上一批“已发布”状态;唯一实现、备份、验收与回退见[栏目导航](NAVIGATION.md)。

View File

@ -4,7 +4,7 @@ .aid-header{display:flex;align-items:flex-start;gap:24px;margin-bottom:22px}
.aid-header__copy{flex:1;min-width:0} .aid-header__copy{flex:1;min-width:0}
.apple-id-page .aid-header h1{font-size:32px;line-height:1.3;letter-spacing:-.45px;font-weight:700;color:#30333c;margin:0} .apple-id-page .aid-header h1{font-size:32px;line-height:1.3;letter-spacing:-.45px;font-weight:700;color:#30333c;margin:0}
.aid-header__copy>p{font-size:14px;line-height:1.7;color:var(--aid-muted);margin:9px 0 0} .aid-header__copy>p{font-size:14px;line-height:1.7;color:var(--aid-muted);margin:9px 0 0}
.aid-header__art{width:112px;height:80px;object-fit:cover;flex:none;border-radius:8px} .aid-header__art{width:160px;height:114px;object-fit:cover;flex:none;border-radius:8px}
.aid-notice{background:#fcfaf6;border:1px solid #eee4d5;border-left:3px solid #cbb18c;border-radius:4px;padding:13px 16px;margin-bottom:26px} .aid-notice{background:#fcfaf6;border:1px solid #eee4d5;border-left:3px solid #cbb18c;border-radius:4px;padding:13px 16px;margin-bottom:26px}
.apple-id-page .post-content .aid-notice p{font-size:14px;line-height:1.9;color:#876b43;margin:0} .apple-id-page .post-content .aid-notice p{font-size:14px;line-height:1.9;color:#876b43;margin:0}
.aid-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px} .aid-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}
@ -30,8 +30,8 @@ .aid-copy:hover{background:#eee7f2;border-color:#dcd0e2}
.apple-id-page .post-content .aid-account__checked{font-size:12px;line-height:1.6;color:#958c9e;margin:13px 0 0;padding-top:11px;border-top:1px solid #f0edf3} .apple-id-page .post-content .aid-account__checked{font-size:12px;line-height:1.6;color:#958c9e;margin:13px 0 0;padding-top:11px;border-top:1px solid #f0edf3}
.aid-extra{font-size:16px;line-height:1.95} .aid-extra{font-size:16px;line-height:1.95}
.apple-id-page .post-content>.airport-recommendations{margin-top:28px;padding-top:25px;border-top:1px solid var(--aid-line)} .apple-id-page .post-content>.airport-recommendations{margin-top:28px;padding-top:25px;border-top:1px solid var(--aid-line)}
@media(max-width:767.98px){.apple-id-page .post>.card-body{padding:22px}.aid-header{gap:12px;margin-bottom:18px}.apple-id-page .aid-header h1{font-size:26px;line-height:1.35}.aid-header__art{width:68px;height:76px}.aid-header__copy>p{font-size:14px;margin-top:7px}.aid-notice{padding:12px 13px;margin-bottom:22px}.apple-id-page .post-content .aid-notice p{font-size:13px;line-height:1.9}.apple-id-page .post-content .aid-toolbar h2{font-size:19.5px}.aid-toolbar{gap:10px;flex-wrap:wrap}.aid-filter{gap:7px}.aid-filter select{max-width:145px}.aid-account-list{grid-template-columns:minmax(0,1fr);gap:12px}.aid-account{padding:15px}.apple-id-page .post-content .aid-field code{font-size:13px}.aid-copy{min-height:40px;padding:8px 11px}.aid-extra{font-size:15px}} @media(max-width:767.98px){.apple-id-page .post>.card-body{padding:22px}.aid-header{gap:12px;margin-bottom:18px}.apple-id-page .aid-header h1{font-size:26px;line-height:1.35}.aid-header__art{width:80px;height:90px}.aid-header__copy>p{font-size:14px;margin-top:7px}.aid-notice{padding:12px 13px;margin-bottom:22px}.apple-id-page .post-content .aid-notice p{font-size:13px;line-height:1.9}.apple-id-page .post-content .aid-toolbar h2{font-size:19.5px}.aid-toolbar{gap:10px;flex-wrap:wrap}.aid-filter{gap:7px}.aid-filter select{max-width:145px}.aid-account-list{grid-template-columns:minmax(0,1fr);gap:12px}.aid-account{padding:15px}.apple-id-page .post-content .aid-field code{font-size:13px}.aid-copy{min-height:40px;padding:8px 11px}.aid-extra{font-size:15px}}
@media(max-width:359.98px){.aid-toolbar{align-items:flex-start;flex-direction:column;gap:12px}.aid-filter{width:100%}.aid-filter select{flex:1;max-width:none}.aid-header__art{width:58px;height:68px}} @media(max-width:359.98px){.aid-toolbar{align-items:flex-start;flex-direction:column;gap:12px}.aid-filter{width:100%}.aid-filter select{flex:1;max-width:none}.aid-header__art{width:72px;height:84px}}
.apple-id-page .post-content .aid-empty{padding:22px 18px;border:1px solid var(--aid-line);border-radius:4px;font-size:14px;line-height:1.8;color:var(--aid-muted);margin:0} .apple-id-page .post-content .aid-empty{padding:22px 18px;border:1px solid var(--aid-line);border-radius:4px;font-size:14px;line-height:1.8;color:var(--aid-muted);margin:0}
body.nice-dark-mode .apple-id-page{--aid-line:#35303d;--aid-purple:#c7a4d1;--aid-muted:#aaa3b2} body.nice-dark-mode .apple-id-page{--aid-line:#35303d;--aid-purple:#c7a4d1;--aid-muted:#aaa3b2}

View File

@ -16,3 +16,8 @@ .cfn-brand__name{font-size:21px;letter-spacing:-.5px}
.cfn-brand__tagline{font-size:10px;letter-spacing:1px} .cfn-brand__tagline{font-size:10px;letter-spacing:1px}
.header .navbar-toggler{flex-shrink:0} .header .navbar-toggler{flex-shrink:0}
} }
/* Use the same icon box and text alignment for every top-level category. */
.header .main-menu>li>a{display:inline-flex;align-items:center;gap:7px}
.header .mobile-menu-inner>li>a{display:flex;align-items:center;gap:10px}
.header .main-menu>li>a>.nav-category-icon,.header .mobile-menu-inner>li>a>.nav-category-icon{display:inline-flex;align-items:center;justify-content:center;flex:0 0 18px;width:18px;height:18px;font-size:16px;line-height:1;margin:0;position:static;vertical-align:middle}
.header .nav-category-label{line-height:1.5}

View File

@ -1,45 +1,92 @@
.free-node-page{--fn-purple:#86608e;--fn-text:#303641;--fn-muted:#9299a5;--fn-line:#ececf1}.free-node-page .post.card{overflow:hidden}.free-node-page .post>.card-body{padding:28px}.free-node-page .breadcrumbs .current{max-width:540px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis} /* Daily resource pages: introduction, snapshot, subscriptions, supporting content. */
.fn-header{display:flex;gap:24px;align-items:flex-start;margin-bottom:20px}.fn-header__main{flex:1;min-width:0}.free-node-page .fn-header h1{font-size:32px;line-height:1.3;letter-spacing:-.45px;color:#30333c;margin:0;font-weight:700}.fn-header__lead{font-size:14px;color:#7c7a87;margin:8px 0 0;line-height:1.6}.fn-header__art{width:112px;height:80px;object-fit:cover;border-radius:10px;flex:none} .free-node-page { --fn-text:#303641; --fn-muted:#817b88; --fn-line:#eae6ee; --fn-purple:#86608e; }
.fn-overview{border:1px solid var(--fn-line);border-radius:10px;display:flex;padding:14px 18px;gap:20px;background:#fbfafc;margin-bottom:20px}.fn-metric{flex:none;padding-right:22px;border-right:1px solid #e9e5ed}.fn-metric>span,.fn-overview__regions>span{display:block;font-size:14px;color:#756e7c;margin-bottom:7px}.fn-metric>div{display:flex;gap:5px;align-items:baseline}.fn-metric strong{font-size:23px;font-weight:650;color:#6d4e76;letter-spacing:-.5px;line-height:1.25}.fn-metric>div>span{font-size:13px;color:#a19aa6}.fn-overview__regions{min-width:0}.fn-overview__regions>div{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:14px;color:#767181;line-height:1.8} .free-node-page .post.card { overflow:hidden; }
.fn-article-nav{display:flex;gap:8px;padding-bottom:18px;border-bottom:1px solid var(--fn-line);margin-bottom:22px}.fn-article-nav a{font-size:14px;color:#8c8794;background:#f7f6f9;padding:7px 14px;border-radius:6px;line-height:1.5;text-decoration:none}.fn-article-nav a:first-child{background:#ede6f1;color:#785282}.fn-article-nav a span{margin-left:20px}.fn-article-nav a:hover{color:#6e4d78;background:#eee7f2} .free-node-page .post > .card-body { padding:28px; }
.free-node-page .post-content{font-size:16px;line-height:1.95}.fn-subscriptions{scroll-margin-top:92px}.fn-section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;margin-bottom:14px}.free-node-page .post-content .fn-section-heading h2{font-size:22.72px;line-height:1.55;margin:0;padding:0;border:0;color:#38343e}.fn-subscription-list{display:grid;grid-template-columns:minmax(0,1fr);gap:11px}.fn-subscription{min-width:0;border:1px solid #e8e9ee;border-radius:10px;padding:17px 17px 14px;background:white}.fn-subscription__top{display:flex;gap:11px;align-items:center;margin-bottom:9px}.fn-subscription__name{flex:1;min-width:0}.free-node-page .post-content .fn-subscription__name h3{font-size:18.08px;line-height:1.5;color:#40404a;margin:0;font-weight:600} .free-node-page .breadcrumbs .current { max-width:540px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
.fn-subscription__address{display:flex;align-items:center;gap:12px;background:#f7f7fa;border:1px solid #f0eff4;padding:8px 9px 8px 11px;border-radius:6px}.free-node-page .post-content .fn-subscription__address code{color:#777180;background:none;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;padding:0;display:block;min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.fn-copy{border:0;border-radius:5px;background:#86618f;color:white;padding:8px 12px;display:inline-flex;align-items:center;justify-content:center;gap:5px;font-size:14px;line-height:1.5;white-space:nowrap;flex:none;cursor:pointer;min-height:40px}.fn-copy:hover{background:#765280}.fn-copy:focus-visible,.fn-article-nav a:focus-visible{outline:2px solid #9572b0;outline-offset:3px} .fn-header { display:grid; grid-template-columns:minmax(0,1fr) 160px; gap:8px 24px; align-items:start; margin-bottom:24px; }
.free-node-page .post-content .fn-subscription-note{font-size:14px;line-height:1.8;color:#7d7686;margin-top:13px}.free-node-page .post-content .fn-subscription-note p{margin:0!important}.fn-subscription-note span{color:inherit!important}.fn-subscription-note br{display:none}.fn-community{margin:24px 0 0;border-top:1px solid var(--fn-line);padding-top:18px;font-size:14px;color:#7f7386;display:flex;align-items:center;justify-content:space-between;gap:10px}.fn-community>div{display:flex;gap:14px}.fn-community a{color:#8c7996;text-decoration:none!important}.fn-empty-note{font-size:14px!important;color:#a58a62!important;padding:9px 12px;background:#fdf9f1;border-radius:6px;margin:0 0 13px!important}.fn-empty-note a{color:inherit;text-decoration:underline}.fn-extra-intro{font-size:16px}.free-node-page .post-content>.airport-recommendations{border-top:1px solid var(--fn-line);padding-top:25px;margin-top:28px}.free-node-page .post-tags{border-top:1px solid var(--fn-line);padding-top:18px}.free-node-page .post-actions{margin-bottom:0!important} .fn-header:not(:has(.fn-header__art)) { grid-template-columns:minmax(0,1fr); }
@media(min-width:992px){.free-node-page .sidebar{top:0}} .fn-header__main { grid-column:1; min-width:0; }
@media(max-width:767.98px){.free-node-page .post>.card-body{padding:22px}.fn-header{gap:12px;align-items:flex-start;margin-bottom:16px}.fn-header__art{width:68px;height:76px;object-fit:cover;border-radius:8px}.free-node-page .fn-header h1{font-size:26px;line-height:1.35;letter-spacing:-.3px}.fn-header__lead{font-size:14px;line-height:1.7;margin-top:6px}.fn-overview{flex-wrap:wrap;padding:15px;gap:15px}.fn-metric{padding-right:17px}.fn-metric strong{font-size:22px}.fn-overview__regions{flex-basis:100%;border-top:1px solid #ebe6ef;padding-top:10px}.fn-overview__regions>span{margin-bottom:4px}.fn-article-nav{gap:6px;padding-bottom:20px;margin-bottom:23px}.fn-article-nav a{padding:8px 12px;font-size:14px}.fn-article-nav a span{margin-left:7px}.free-node-page .post-content .fn-section-heading h2{font-size:19.5px}.fn-subscription{padding:15px 13px 12px}.fn-subscription__top{gap:9px}.free-node-page .post-content .fn-subscription__name h3{font-size:17px}.fn-subscription__address{gap:8px;padding:7px}.fn-copy{font-size:14px;padding:8px 10px}.fn-copy svg{display:none}.fn-community{align-items:flex-start;flex-direction:column;gap:7px}} .free-node-page .fn-header h1 { margin:0; font-size:32px; line-height:1.3; font-weight:700; letter-spacing:-.45px; color:var(--fn-text); }
@media(prefers-reduced-motion:no-preference){html:has(.free-node-page){scroll-behavior:smooth}} .fn-header h1 > span { display:inline-block; }
.fn-header__art { grid-column:2; grid-row:1 / span 3; width:160px; height:114px; object-fit:cover; border-radius:8px; }
.free-node-page .post-content .fn-subscription h3::before{display:none} .fn-header__lead { grid-column:1; margin:0; font-size:14px; line-height:1.7; color:var(--fn-muted); }
.fn-header .resource-header-actions { grid-column:1; margin-top:4px; }
/* Match the existing tutorial type scale; shared recommendation overrides stay local to this page. */ .fn-overview { display:grid; grid-template-columns:auto auto minmax(0,1fr); gap:0; padding:18px 0; margin-bottom:24px; border-top:1px solid var(--fn-line); border-bottom:1px solid var(--fn-line); }
.free-node-page .post-content .airport-recommendations__heading h2{font-size:22.72px} .fn-metric { padding:0 24px; border-right:1px solid var(--fn-line); }
.free-node-page .post-content .airport-recommendations__table{font-size:14px} .fn-metric:first-child { padding-left:0; }
.free-node-page .post-content .airport-recommendations__table thead th{font-size:13px} .fn-metric > span, .fn-overview__regions > span { display:block; margin-bottom:6px; font-size:12px; line-height:1.5; color:var(--fn-muted); }
.free-node-page .post-content .airport-provider a{font-size:14px} .fn-metric > div { display:flex; align-items:baseline; gap:5px; }
.free-node-page .airport-provider__label,.free-node-page .airport-coupon__discount{font-size:12px} .fn-metric strong { font-size:24px; font-weight:650; line-height:1.25; letter-spacing:-.4px; color:var(--fn-purple); }
.free-node-page .airport-features,.free-node-page .airport-description{font-size:14px} .fn-metric > div > span { font-size:12px; color:var(--fn-muted); }
.free-node-page .airport-coupon__copy,.free-node-page .airport-price>span{font-size:12px} .fn-overview__regions { min-width:0; padding-left:24px; }
.free-node-page .post-content p.airport-recommendations__note{font-size:12px} .fn-overview__regions > div { display:flex; flex-wrap:wrap; gap:3px 9px; font-size:12px; line-height:1.7; color:var(--fn-muted); }
@media(max-width:600px){ /* One compact learning entry before the actual copy task, rather than inside its heading. */
.free-node-page .post-content{font-size:15px;line-height:1.95} .free-node-page .post-content .resource-explainer { margin:0 0 26px; padding:15px 16px; gap:12px 18px; border:0; border-radius:6px; background:color-mix(in srgb,var(--color-primary) 6%,transparent); }
.free-node-page .fn-extra-intro{font-size:15px;line-height:1.95} .free-node-page .post-content .resource-explainer__title { font-size:15px; line-height:1.5; }
.free-node-page .post-content .fn-subscription__address code{font-size:11px} .free-node-page .post-content .resource-explainer p { font-size:12px; margin-top:4px; line-height:1.7; }
.free-node-page .post-content .airport-recommendations__heading h2{font-size:19.5px} .free-node-page .post-content .resource-explainer__next { padding-left:18px; border-left:1px solid color-mix(in srgb,var(--color-primary) 20%,transparent); font-size:13px; }
.free-node-page .post-content { font-size:16px; line-height:1.9; }
.fn-subscriptions { scroll-margin-top:92px; }
.fn-section-heading { margin-bottom:14px; }
.free-node-page .post-content .fn-section-heading h2 { margin:0; padding:0; border:0; font-size:22px; line-height:1.5; color:var(--fn-text); }
.free-node-page .post-content .fn-section-heading p { margin:5px 0 0; font-size:13px; line-height:1.7; color:var(--fn-muted); }
.fn-subscription-list { border:1px solid var(--fn-line); border-radius:8px; overflow:hidden; }
.fn-subscription { display:flex; align-items:center; gap:18px; min-width:0; padding:18px; background:var(--scheme-surface,#fff); }
.fn-subscription + .fn-subscription { border-top:1px solid var(--fn-line); }
.fn-subscription__main { flex:1; min-width:0; }
.free-node-page .post-content .fn-subscription__name h3 { margin:0 0 7px; padding:0; font-size:16px; font-weight:600; line-height:1.5; text-align:left; color:var(--fn-text); }
.free-node-page .post-content .fn-subscription h3::before { display:none; }
.fn-subscription__url { min-width:0; }
.free-node-page .post-content .fn-subscription__url code { display:block; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; padding:0; background:none; font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:11px; line-height:1.65; color:var(--fn-muted); }
.fn-copy { display:inline-flex; align-items:center; justify-content:center; flex:none; gap:6px; min-height:40px; padding:9px 12px; border:0; border-radius:5px; background:#86618f; color:#fff; font-size:13px; line-height:1.5; white-space:nowrap; cursor:pointer; }
.fn-copy:hover { background:#765280; }
.fn-copy:focus-visible { outline:2px solid var(--color-primary); outline-offset:3px; }
.free-node-page .post-content .fn-subscription-note { margin-top:12px; font-size:12px; line-height:1.8; color:var(--fn-muted); }
.free-node-page .post-content .fn-subscription-note p { margin:0!important; }
.fn-subscription-note span { color:inherit!important; }
.fn-subscription-note br { display:none; }
.free-node-page .post-content .fn-empty-note { margin:0 0 14px; padding:10px 12px; border-radius:6px; background:#fdf9f1; color:#8b704c; font-size:13px; line-height:1.8; }
.fn-empty-note a { color:inherit; text-decoration:underline; }
.fn-extra-intro { margin-bottom:20px; font-size:15px; }
.free-node-page .post-content > .airport-recommendations { border-top:1px solid var(--fn-line); padding-top:26px; margin-top:28px; }
.free-node-page .post-content .airport-recommendations__heading h2 { font-size:22px; }
.free-node-page .post-content .airport-recommendations__table,
.free-node-page .post-content .airport-recommendations__table tbody td.airport-description,
.free-node-page .post-content .airport-provider a,
.free-node-page .airport-features, .free-node-page .airport-description { font-size:14px; }
.free-node-page .post-content .airport-recommendations__table thead th { font-size:13px; }
.free-node-page .airport-provider__label, .free-node-page .airport-coupon__discount,
.free-node-page .airport-coupon__copy, .free-node-page .airport-price > span,
.free-node-page .post-content p.airport-recommendations__note { font-size:12px; }
.fn-community { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; padding-top:18px; margin-top:24px; border-top:1px solid var(--fn-line); font-size:13px; line-height:1.7; color:var(--fn-muted); }
.fn-community > div { display:flex; flex-wrap:wrap; gap:14px; }
.fn-community a { color:var(--color-primary); text-decoration:none!important; }
.free-node-page .post-tags { padding-top:18px; border-top:1px solid var(--fn-line); }
.free-node-page .post-actions { margin-bottom:0!important; }
@media (max-width:767.98px) {
.free-node-page .post > .card-body { padding:22px; }
.fn-header { grid-template-columns:minmax(0,1fr) 80px; gap:10px 14px; margin-bottom:20px; }
.free-node-page .fn-header h1 { font-size:26px; line-height:1.35; letter-spacing:-.3px; }
.fn-header__art { grid-row:1; width:80px; height:90px; }
.fn-header__lead, .fn-header .resource-header-actions { grid-column:1 / -1; }
.fn-header .resource-header-actions { margin-top:0; }
.fn-overview { grid-template-columns:1fr 1fr; padding:16px 0; margin-bottom:20px; row-gap:14px; }
.fn-metric { padding:0 16px; }
.fn-metric:nth-child(2) { border-right:0; }
.fn-metric strong { font-size:23px; }
.fn-overview__regions { display:flex; gap:12px; grid-column:1 / -1; padding:12px 0 0; border-top:1px solid var(--fn-line); }
.fn-overview__regions > span { flex:none; margin:0; line-height:1.7; }
.free-node-page .post-content { font-size:15px; }
.free-node-page .post-content .fn-section-heading h2,
.free-node-page .post-content .airport-recommendations__heading h2 { font-size:20px; }
.fn-subscription { padding:15px 12px; gap:12px; }
.free-node-page .post-content .fn-subscription__name h3 { font-size:15px; }
.fn-copy { padding:9px 10px; }
.fn-copy svg { display:none; }
.fn-community { align-items:flex-start; flex-direction:column; gap:8px; }
} }
@media (max-width:575.98px) {
.fn-header h1>span{display:inline-block} .free-node-page .post-content .resource-explainer { padding:14px; gap:10px; }
.fn-article-nav,.fn-community>div{flex-wrap:wrap} .free-node-page .post-content .resource-explainer__next { padding-left:0; border-left:0; }
.free-node-page .post-content .airport-recommendations__table tbody td.airport-description{font-size:14px} }
@media (prefers-reduced-motion:no-preference) { html:has(.free-node-page) { scroll-behavior:smooth; } }
@media(max-width:360px){.fn-article-nav a{padding-left:8px;padding-right:8px}}
/* A quiet reading route beside the subscription task; no extra modal. */
.post-content a.fn-guide-entry{display:flex;align-items:center;gap:14px;padding:16px 18px;margin:0 0 22px;border:1px solid var(--color-primary);border-color:color-mix(in srgb,var(--color-primary) 28%,transparent);border-radius:10px;background:color-mix(in srgb,var(--color-primary) 5%,transparent);color:inherit;text-decoration:none;transition:background .15s}
.post-content a.fn-guide-entry:hover{background:color-mix(in srgb,var(--color-primary) 10%,transparent)}
.fn-guide-entry:focus-visible{outline:2px solid var(--color-primary);outline-offset:4px}
.fn-guide-entry__icon{font-size:26px;color:var(--color-primary);line-height:1}
.fn-guide-entry strong{display:block;font-size:15px;line-height:1.5}
.fn-guide-entry small{display:block;font-size:13px;line-height:1.7;opacity:.75;margin-top:3px}
.fn-guide-entry__action{margin-left:auto;flex-shrink:0;font-size:13px;color:var(--color-primary)}
@media(max-width:575px){.post-content a.fn-guide-entry{gap:10px;padding:14px;flex-wrap:wrap}.fn-guide-entry__icon{display:none}.fn-guide-entry__action{margin-left:0}}

View File

@ -0,0 +1,2 @@
.post-content .resource-explainer{display:flex;align-items:center;gap:12px 24px;margin:0 0 22px;padding:16px 18px;border:1px solid color-mix(in srgb,var(--color-primary) 25%,transparent);border-radius:8px;background:color-mix(in srgb,var(--color-primary) 5%,transparent)}
.resource-explainer__text{flex:1;min-width:0}.post-content .resource-explainer__title{font-size:16px;font-weight:600;line-height:1.6;color:var(--color-primary);text-decoration:none}.post-content .resource-explainer p{margin:4px 0 0;font-size:13px;line-height:1.8;color:inherit;opacity:.8}.post-content .resource-explainer__next{font-size:13px;line-height:1.7;flex-shrink:0;color:var(--color-primary);text-decoration:none}.resource-explainer a:hover{text-decoration:underline}.resource-explainer a:focus-visible{outline:2px solid var(--color-primary);outline-offset:4px}@media(max-width:575px){.post-content .resource-explainer{align-items:flex-start;flex-direction:column;padding:14px;gap:10px}.resource-explainer__next{align-self:flex-start}}

View File

@ -0,0 +1,84 @@
/* Resource history is a secondary action in the existing article toolbar. */
.resource-article-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.resource-article-nav > a {
display: inline-flex;
align-items: center;
min-height: 36px;
}
.aid-article-nav {
padding-bottom: 18px;
margin-bottom: 22px;
border-bottom: 1px solid var(--aid-line);
}
.aid-article-nav > a {
gap: 20px;
padding: 7px 14px;
border-radius: 6px;
font-size: 14px;
line-height: 1.5;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
text-decoration: none;
}
.resource-history-link {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.resource-article-nav > .resource-history-link { margin-left: 0; }
.resource-header-actions { margin-top: 14px; }
.resource-history-link a.resource-history-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 36px;
padding: 6px 12px;
border: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
color: var(--color-primary);
font-size: 13px;
font-weight: 500;
line-height: 1.5;
text-decoration: none;
white-space: nowrap;
}
.resource-history-action svg {
width: 16px;
height: 16px;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.65;
stroke-linecap: round;
stroke-linejoin: round;
}
.resource-history-link a.resource-history-action:hover,
.aid-article-nav > a:hover {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
.resource-history-action:focus-visible,
.aid-article-nav > a:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
.post-content > .resource-history-link {
padding-bottom: 18px;
margin-bottom: 22px;
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 15%, transparent);
}
@media (max-width: 575.98px) {
.resource-article-nav > .resource-history-link { margin-left: 0; }
.resource-article-nav > a,
.resource-history-link a.resource-history-action { min-height: 40px; }
.aid-article-nav > a { gap: 12px; }
}

View File

@ -3,7 +3,7 @@
['key' => 'dpl', 'file' => 'dpl-ad-compact-wordmark-20261031.png', 'width' => 2169, 'height' => 725, 'alt' => 'DP-LINK.NET:畅连全球网络。日常访问、远程办公、在线娱乐,查看套餐。'], ['key' => 'dpl', 'file' => 'dpl-ad-compact-wordmark-20261031.png', 'width' => 2169, 'height' => 725, 'alt' => 'DP-LINK.NET:畅连全球网络。日常访问、远程办公、在线娱乐,查看套餐。'],
['key' => 'hn', 'file' => 'hn-ad-compact-HardNerd-20261031.png', 'width' => 2020, 'height' => 778, 'alt' => 'HardNerd:硬核架构,品质专线,全球网络畅通无阻。'], ['key' => 'hn', 'file' => 'hn-ad-compact-HardNerd-20261031.png', 'width' => 2020, 'height' => 778, 'alt' => 'HardNerd:硬核架构,品质专线,全球网络畅通无阻。'],
['key' => 'fuc', 'file' => 'fuc-ad-compact-five-years-20261031.png', 'width' => 2141, 'height' => 734, 'alt' => '福云:全球网络加速。五年稳定运营。开始使用。'], ['key' => 'fuc', 'file' => 'fuc-ad-compact-five-years-20261031.png', 'width' => 2141, 'height' => 734, 'alt' => '福云:全球网络加速。五年稳定运营。开始使用。'],
])->filter(fn ($ad) => config('advertising.'.$ad['key'].'.enabled') ])->filter(fn ($ad) => ! \App\Services\SiteMode::quiet() && config('advertising.'.$ad['key'].'.enabled')
&& config('advertising.'.$ad['key'].'.url') && config('advertising.'.$ad['key'].'.url')
&& is_file(public_path('images/ads/'.$ad['file'])))->values(); && is_file(public_path('images/ads/'.$ad['file'])))->values();
@endphp @endphp

View File

@ -1,4 +1,4 @@
@props(['oArticle']) @props(['oArticle', 'bResourceHistory' => false])
<div class="list-item default block ajax-item" data-article-id="{{ $oArticle->id }}"> <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 }}" style="background-image:url('{{ $oArticle->sThumb }}')"></a>
@ -16,7 +16,7 @@
</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="{{ \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>{!! $oArticle->oCategory->sName !!}</a> <a href="{{ \App\Services\Seo::url($bResourceHistory && \App\Services\ResourcePages::supports($oArticle->oCategory->sSlug) ? \App\Services\ResourcePages::historyPath($oArticle->oCategory->sSlug) : \App\Services\Seo::categoryPath($oArticle->oCategory)) }}" class="me-2 me-md-3"><i class="text-sm iconfont icon-dots-circle me-1"></i>{!! $oArticle->oCategory->sName !!}</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>

View File

@ -0,0 +1,7 @@
@if (! \App\Services\SiteMode::quiet() && config('advertising.dpl.enabled') && is_file(public_path('images/ads/dpl-ad-site-style-20261028.png')))
<aside class="dpl-article-ad dpl-image-ad" aria-label="DPL 图片广告">
<a class="dpl-image-ad__link" href="{{ config('advertising.dpl.url') }}" target="_blank" rel="sponsored noopener noreferrer" aria-label="广告:查看 DP-LINK 网络订阅套餐(新窗口打开)">
<img src="/images/ads/dpl-ad-site-style-20261028.png?v={{ filemtime(public_path('images/ads/dpl-ad-site-style-20261028.png')) }}" alt="DPL:畅连全球网络。日常访问、远程办公、在线娱乐,查看套餐。" width="1962" height="801">
</a>
</aside>
@endif

View File

@ -0,0 +1,7 @@
@if (! \App\Services\SiteMode::quiet() && config('advertising.fuc.enabled') && config('advertising.fuc.url') && is_file(public_path('images/ads/fuc-ad-site-style-20261028.png')))
<aside class="dpl-article-ad fuc-image-ad" aria-label="福云图片广告">
<a class="fuc-image-ad__link" href="{{ config('advertising.fuc.url') }}" target="_blank" rel="sponsored noopener noreferrer" aria-label="广告:了解福云网络服务(新窗口打开)">
<img src="/images/ads/fuc-ad-site-style-20261028.png?v={{ filemtime(public_path('images/ads/fuc-ad-site-style-20261028.png')) }}" alt="福云:全球网络中继服务。随时随处,尽情使用。了解福云。" width="1962" height="801">
</a>
</aside>
@endif

View File

@ -1,4 +1,4 @@
@if (config('advertising.hn.enabled') && config('advertising.hn.url') && is_file(public_path('images/ads/hn-ad-refined-20261031.png'))) @if (! \App\Services\SiteMode::quiet() && config('advertising.hn.enabled') && config('advertising.hn.url') && is_file(public_path('images/ads/hn-ad-refined-20261031.png')))
@once @once
@push('styles') @push('styles')
<link rel="stylesheet" href="/css/hn-article-ad.css?v={{ filemtime(public_path('css/hn-article-ad.css')) }}"> <link rel="stylesheet" href="/css/hn-article-ad.css?v={{ filemtime(public_path('css/hn-article-ad.css')) }}">

View File

@ -1,3 +1,6 @@
@php
$resourceNavs = $aNavs->filter(fn($nav) => \App\Services\ResourcePages::supports($nav->sSlug));
@endphp
<header class="header"> <header class="header">
<nav class="navbar navbar-expand-xl shadow" id="header"> <nav class="navbar navbar-expand-xl shadow" id="header">
<div class="container"> <div class="container">
@ -35,14 +38,15 @@ class="d-none logo-dark nc-no-lazy"
@foreach($aNavs as $oNav) @foreach($aNavs as $oNav)
@php @php
$bIsMatched = request()->is($oNav->sSlug) || request()->is($oNav->sSlug . '/*'); $navPath = \App\Services\Seo::categoryPath($oNav);
$bIsMatched = $navPath === '/' ? request()->is('/') : (request()->is(ltrim($navPath, '/')) || request()->is(ltrim($navPath, '/') . '/*'));
$sActiveAttr = $bIsMatched ? 'menua-checked' : ''; $sActiveAttr = $bIsMatched ? 'menua-checked' : '';
@endphp @endphp
<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($navPath) }}">
<i class="{{ $oNav->sHtmlClass }}"></i> <i class="{{ $oNav->sHtmlClass }} nav-category-icon" aria-hidden="true"></i>
{{ $oNav->sName }} <span class="nav-category-label">{{ $oNav->sName }}</span>
</a> </a>
@if($oNav->aChildren->isNotEmpty()) @if($oNav->aChildren->isNotEmpty())
@ -55,6 +59,9 @@ class="d-none logo-dark nc-no-lazy"
@endif @endif
</li> </li>
@endforeach @endforeach
@if($resourceNavs->isNotEmpty())
@include('components.resource-history-menu', ['mobileHistoryMenu' => false])
@endif
</ul> </ul>
<ul class="navbar-nav align-items-center"> <ul class="navbar-nav align-items-center">
@ -93,9 +100,9 @@ class="d-none logo-dark nc-no-lazy"
<ul class="mobile-menu-inner"> <ul class="mobile-menu-inner">
@foreach($aNavs as $oNav) @foreach($aNavs as $oNav)
<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(\App\Services\Seo::categoryPath($oNav)) }}">
<i class="{{ $oNav->sHtmlClass }}"></i> <i class="{{ $oNav->sHtmlClass }} nav-category-icon" aria-hidden="true"></i>
{{ $oNav->sName }} <span class="nav-category-label">{{ $oNav->sName }}</span>
</a> </a>
@if($oNav->aChildren->isNotEmpty()) @if($oNav->aChildren->isNotEmpty())
<ul class="sub-menu"> <ul class="sub-menu">
@ -106,6 +113,9 @@ class="d-none logo-dark nc-no-lazy"
@endif @endif
</li> </li>
@endforeach @endforeach
@if($resourceNavs->isNotEmpty())
@include('components.resource-history-menu', ['mobileHistoryMenu' => true])
@endif
</ul> </ul>
</div> </div>
<div class="mobile-sidebar-social-links" role="navigation" aria-label="社区链接"> <div class="mobile-sidebar-social-links" role="navigation" aria-label="社区链接">

View File

@ -1,13 +1 @@
@php @include('components.resource-explainer-link', ['resourceKind' => 'freenode'])
$guide = \App\Models\Articles::publicVisible()
->where('sSlug', config('tutorials.guide_slug'))
->whereHas('oCategory', fn($q) => $q->where('sSlug', config('tutorials.beginner_slug')))
->first(['id', 'sSlug']);
@endphp
@if($guide)
<a class="fn-guide-entry" href="/a/{{ $guide->sSlug }}.html">
<span class="fn-guide-entry__icon" aria-hidden="true">↗</span>
<span><strong>第一次使用免费节点?</strong><small>从新手指引开始,按设备选择客户端和订阅格式。</small></span>
<span class="fn-guide-entry__action">查看指引 <span aria-hidden="true">→</span></span>
</a>
@endif

View File

@ -0,0 +1,24 @@
@php
$entry = config('tutorials.resource_explainers.'.$resourceKind);
$intro = $entry ? \App\Models\Articles::publicVisible()
->where('sSlug', $entry['slug'])
->whereHas('oCategory', fn($q) => $q->where('sSlug', config('tutorials.beginner_slug')))
->first(['id', 'sSlug']) : null;
$guide = $resourceKind === 'freenode' ? \App\Models\Articles::publicVisible()
->where('sSlug', config('tutorials.guide_slug'))
->whereHas('oCategory', fn($q) => $q->where('sSlug', config('tutorials.beginner_slug')))
->first(['id', 'sSlug']) : null;
$destination = $intro ?? $guide;
@endphp
@if($destination)
<link rel="stylesheet" href="/css/resource-explainer.css?v={{ filemtime(public_path('css/resource-explainer.css')) }}">
<aside class="resource-explainer" aria-label="新手阅读入口">
<div class="resource-explainer__text">
<a class="resource-explainer__title" href="/a/{{ $destination->sSlug }}.html">{{ $intro ? $entry['title'] : '第一次使用免费节点?' }} <span aria-hidden="true">→</span></a>
<p>{{ $intro ? $entry['description'] : '从新手指引开始,按设备选择客户端和订阅格式。' }}</p>
</div>
@if($intro && $guide)
<a class="resource-explainer__next" href="/a/{{ $guide->sSlug }}.html">查看使用教程 →</a>
@endif
</aside>
@endif

View File

@ -0,0 +1,19 @@
@if(\App\Services\ResourcePages::supports($oArticle->oCategory?->sSlug) && !($bEditorialPreview ?? false))
@once
@push('styles')
<link rel="stylesheet" href="/css/resource-history.css?v={{ filemtime(public_path('css/resource-history.css')) }}">
@endpush
@endonce
<div class="resource-history-link" role="group" aria-label="资源更新记录">
@unless($bResourceEntry ?? false)
<a class="resource-history-action" href="{{ url(\App\Services\ResourcePages::entryPath($oArticle->oCategory->sSlug)) }}" aria-label="查看最新资源({{ $oArticle->oCategory->sName }})">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m13 3-8 11h6l-1 7 9-12h-7z"/></svg>
最新资源
</a>
@endunless
<a class="resource-history-action" href="{{ url(\App\Services\ResourcePages::historyPath($oArticle->oCategory->sSlug)) }}" aria-label="查看往期资源({{ $oArticle->oCategory->sName }})">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3 11a9 9 0 1 1 2.6 7.4M3 4v7h7M12 7v5l3 2"/></svg>
往期资源
</a>
</div>
@endif

View File

@ -0,0 +1,12 @@
<li class="menu-item menu-item-type-taxonomy menu-item-object-category menu-item-has-children">
<a class="menua {{ request()->is('history', 'history/*') ? 'menua-checked' : '' }}" href="{{ url('/history') }}">
<i class="iconfont icon-dots-circle nav-category-icon" aria-hidden="true"></i>
<span class="nav-category-label">往期资源</span>
</a>
@unless($mobileHistoryMenu)<div class="submenu-arrow iconfont icon-jiantou1"></div>@endunless
<ul class="sub-menu">
@foreach($resourceNavs as $resourceNav)
<li class="menu-item menu-item-type-taxonomy menu-item-object-category"><a class="menuaSub" href="{{ url(\App\Services\ResourcePages::historyPath($resourceNav->sSlug)) }}">{{ $resourceNav->sName }}</a></li>
@endforeach
</ul>
</li>

View File

@ -1,3 +1,4 @@
@include('components.resource-explainer-link', ['resourceKind' => 'shadowrocketids'])
@if($appleIdLayout['intro'])<div class="aid-extra">{!! nl2br(\App\Services\Seo::body($appleIdLayout['intro'])) !!}</div>@endif @if($appleIdLayout['intro'])<div class="aid-extra">{!! nl2br(\App\Services\Seo::body($appleIdLayout['intro'])) !!}</div>@endif
@if($appleIdLayout['warnings']) @if($appleIdLayout['warnings'])
<aside class="aid-notice" aria-label="共享账号使用提醒"> <aside class="aid-notice" aria-label="共享账号使用提醒">

View File

@ -9,9 +9,14 @@
<header class="aid-header"> <header class="aid-header">
<div class="aid-header__copy"> <div class="aid-header__copy">
<h1>{{ $oArticle->created_at?->format('n月j日') }}免费小火箭ID</h1> <h1>{{ $oArticle->created_at?->format('n月j日') }}免费小火箭ID</h1>
<p>用于下载小火箭的 App Store 共享账号,按地区选择使用。</p> <p>以购买小火箭(shadowrocket)的Apple App Store 共享账号,按地区选择使用。</p>
@unless($bEditorialPreview ?? false)
<nav class="resource-article-nav resource-header-actions" aria-label="资源更新记录">
@include('components.resource-history-link')
</nav>
@endunless
</div> </div>
@if($oArticle->sThumb) @if($oArticle->sThumb)
<img class="aid-header__art" src="{{ $oArticle->sThumb }}" width="112" height="80" alt="狗狗主题插画" decoding="async"> <img class="aid-header__art" src="{{ $oArticle->sThumb }}" width="160" height="114" alt="狗狗主题插画" decoding="async">
@endif @endif
</header> </header>

View File

@ -50,7 +50,7 @@
<h1 class="cover-title">{{ $aTutorialBanner['title'] }}<sup class="text-light mx-1">{{ $iArticleCount ?? count($oArticles) }}</sup></h1> <h1 class="cover-title">{{ $aTutorialBanner['title'] }}<sup class="text-light mx-1">{{ $iArticleCount ?? count($oArticles) }}</sup></h1>
</div> </div>
</div> </div>
@elseif (isset($oCategory->aBanner) && !in_array($oCategory->sSlug, ['client', 'advanced'])) @elseif (!($bResourceHistory ?? false) && isset($oCategory->aBanner) && !in_array($oCategory->sSlug, ['client', 'advanced']))
@php @php
$sBannerPath = '/sites/' . config('app.site_code_short') . '/images/banner/' . $oCategory->aBanner['img_file']; $sBannerPath = '/sites/' . config('app.site_code_short') . '/images/banner/' . $oCategory->aBanner['img_file'];
@endphp @endphp
@ -69,7 +69,7 @@
<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" /> <x-article-item :oArticle="$oArticle" :bResourceHistory="$bResourceHistory ?? false" />
@endforeach @endforeach
</div> </div>

View File

@ -80,6 +80,9 @@
</div> </div>
@endif @endif
<div class="post-content"> <div class="post-content">
@if(!$freeNodeLayout && !$appleIdLayout)
@include('components.resource-history-link')
@endif
@if($freeNodeLayout) @if($freeNodeLayout)
@include('pages.articles.freenode.content') @include('pages.articles.freenode.content')
@elseif($appleIdLayout) @elseif($appleIdLayout)
@ -88,6 +91,8 @@
@if($oArticle->oCategory?->sSlug === 'freenode') @if($oArticle->oCategory?->sSlug === 'freenode')
<link rel="stylesheet" href="/css/freenode-article.css?v={{ filemtime(public_path('css/freenode-article.css')) }}"> <link rel="stylesheet" href="/css/freenode-article.css?v={{ filemtime(public_path('css/freenode-article.css')) }}">
@include('components.newcomer-guide-link') @include('components.newcomer-guide-link')
@elseif($oArticle->oCategory?->sSlug === 'shadowrocketids')
@include('components.resource-explainer-link', ['resourceKind' => 'shadowrocketids'])
@endif @endif
@if ($oArticle->bPageThumb && $oArticle->sThumb) @if ($oArticle->bPageThumb && $oArticle->sThumb)
<p> <p>

View File

@ -1 +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> <x-app-layout sTitle="资源暂不可用" sRobots="noindex, follow"><main class="container py-5"><h1>资源暂不可用</h1><p>暂时没有可展示的{{ ($resourceType ?? 'freenode') === 'shadowrocketids' ? '账号' : '节点' }}记录,请稍后再试。</p><a href="/client">查看新手教程</a></main></x-app-layout>

View File

@ -1,15 +1,18 @@
@if($freeNodeLayout['intro'])<div class="fn-extra-intro">{!! nl2br(\App\Services\Seo::body($freeNodeLayout['intro'])) !!}</div>@endif @if($freeNodeLayout['intro'])<div class="fn-extra-intro">{!! nl2br(\App\Services\Seo::body($freeNodeLayout['intro'])) !!}</div>@endif
@include('components.newcomer-guide-link')
<section class="fn-subscriptions" id="node-subscriptions" aria-labelledby="node-subscriptions-title"> <section class="fn-subscriptions" id="node-subscriptions" aria-labelledby="node-subscriptions-title">
<div class="fn-section-heading"><div><h2 id="node-subscriptions-title">获取免费订阅</h2></div></div> <div class="fn-section-heading"><div><h2 id="node-subscriptions-title">获取免费订阅</h2><p>按你使用的软件,复制对应订阅。</p></div></div>
@include('components.newcomer-guide-link')
@if($freeNodeLayout['count'] === 0) @if($freeNodeLayout['count'] === 0)
<p class="fn-empty-note">这篇更新记录的可用节点为 0,导入前可先<a href="/">查看最新更新</a>。</p> <p class="fn-empty-note">这篇更新记录的可用节点为 0,导入前可先<a href="/">查看最新更新</a>。</p>
@endif @endif
<div class="fn-subscription-list"> <div class="fn-subscription-list">
@foreach($freeNodeLayout['subscriptions'] as $subscription) @foreach($freeNodeLayout['subscriptions'] as $subscription)
<div class="fn-subscription fn-subscription--{{ $subscription['key'] }}"> <div class="fn-subscription fn-subscription--{{ $subscription['key'] }}">
<div class="fn-subscription__top"><div class="fn-subscription__name"><h3>适用于:{{ $subscription['title'] }}</h3></div></div> <div class="fn-subscription__main">
<div class="fn-subscription__address"><code title="{{ $subscription['url'] }}">{{ $subscription['url'] }}</code><button type="button" class="fn-copy copy-permalink" data-clipboard-text="{{ $subscription['url'] }}" aria-label="复制 {{ $subscription['title'] }} 订阅"><svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="5" y="5" width="8" height="9" rx="1" stroke="currentColor"/><path d="M3 11H2V2h8v1" stroke="currentColor"/></svg><span>复制订阅</span></button></div> <div class="fn-subscription__name"><h3>{{ $subscription['title'] }}</h3></div>
<div class="fn-subscription__url"><code title="{{ $subscription['url'] }}">{{ $subscription['url'] }}</code></div>
</div>
<button type="button" class="fn-copy copy-permalink" data-clipboard-text="{{ $subscription['url'] }}" aria-label="复制 {{ $subscription['title'] }} 订阅"><svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="5" y="5" width="8" height="9" rx="1" stroke="currentColor"/><path d="M3 11H2V2h8v1" stroke="currentColor"/></svg><span>复制订阅</span></button>
</div> </div>
@endforeach @endforeach
</div> </div>

View File

@ -6,11 +6,16 @@
<header class="fn-header"> <header class="fn-header">
<div class="fn-header__main"> <div class="fn-header__main">
<h1><span>{{ $oArticle->created_at?->format('n月j日') }}</span><span>免费节点订阅</span></h1> <h1><span>{{ $oArticle->created_at?->format('n月j日') }}</span><span>免费节点订阅</span></h1>
<p class="fn-header__lead">支持 Clash / V2ray / Shadowrocket / Singbox / Hiddify</p>
</div> </div>
@if($oArticle->sThumb) @if($oArticle->sThumb)
<img class="fn-header__art" src="{{ $oArticle->sThumb }}" width="192" height="160" alt="猫咪主题插画" decoding="async"> <img class="fn-header__art" src="{{ $oArticle->sThumb }}" width="160" height="114" alt="猫咪主题插画" decoding="async">
@endif @endif
<p class="fn-header__lead">支持 Clash / V2ray / Shadowrocket / Singbox / Hiddify</p>
@unless($bEditorialPreview ?? false)
<nav class="resource-article-nav resource-header-actions" aria-label="资源更新记录">
@include('components.resource-history-link')
</nav>
@endunless
</header> </header>
<div class="fn-overview"> <div class="fn-overview">
@if($freeNodeLayout['count'] !== null) @if($freeNodeLayout['count'] !== null)
@ -21,4 +26,3 @@
@endif @endif
<div class="fn-overview__regions"><span>覆盖地区</span><div>@forelse($freeNodeLayout['regions'] as $region)<span>{{ $region }}</span>@empty<span>以订阅内容为准</span>@endforelse</div></div> <div class="fn-overview__regions"><span>覆盖地区</span><div>@forelse($freeNodeLayout['regions'] as $region)<span>{{ $region }}</span>@empty<span>以订阅内容为准</span>@endforelse</div></div>
</div> </div>
<nav class="fn-article-nav" aria-label="本页内容"><a href="#node-subscriptions">获取订阅<span>↗</span></a>@if($recommendations['enabled'])<a href="#{{ config('airport_recommendations.anchor') }}">机场推荐</a>@endif</nav>

View File

@ -47,6 +47,11 @@
Route::get('/a/{sArticleSlug}.html', [ArticlesController::class, 'show']) Route::get('/a/{sArticleSlug}.html', [ArticlesController::class, 'show'])
->name('article.show'); ->name('article.show');
Route::get('/freenode', [ArticlesController::class, 'resource'])->defaults('type', 'freenode')->name('resources.freenode');
Route::get('/shadowrocketids', [ArticlesController::class, 'resource'])->defaults('type', 'shadowrocketids')->name('resources.shadowrocketids');
Route::get('/history/{type?}', [ArticlesController::class, 'history'])
->where('type', 'freenode|shadowrocketids')->name('resources.history');
Route::get('/{sSlug1}', [ArticlesController::class, 'list']) Route::get('/{sSlug1}', [ArticlesController::class, 'list'])
->name('articles.list'); ->name('articles.list');
Route::get('/{sSlug1}/{sSlug2?}', [ArticlesController::class, 'list']) Route::get('/{sSlug1}/{sSlug2?}', [ArticlesController::class, 'list'])

View File

@ -0,0 +1,143 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\Api\v1\ArticlesController;
use App\Models\Articles;
use App\Models\Categories;
use App\Services\ArticleRecommendations;
use App\Services\SiteMode;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
final class QuietModeTest extends TestCase
{
private string $state;
protected function setUp(): void
{
parent::setUp();
$this->state = sys_get_temp_dir().'/fna-quiet-'.bin2hex(random_bytes(8)).'.json';
config(['site_mode.state_path'=>$this->state, 'site_mode.quiet'=>false,
'cache.default'=>'array', 'responsecache.enabled'=>false,
'database.default'=>'sqlite', 'database.connections.sqlite.database'=>':memory:',
'scout.driver'=>null, 'editorial.indexnow_enabled'=>false]);
DB::purge('sqlite'); Http::preventStrayRequests();
Schema::create('categories', function (Blueprint $t) {
$t->id(); $t->string('sSlug'); $t->string('sName');
});
DB::table('categories')->insert([
['id'=>1,'sSlug'=>'freenode','sName'=>'免费节点'],
['id'=>2,'sSlug'=>'shadowrocketids','sName'=>'免费小火箭ID'],
['id'=>3,'sSlug'=>'client','sName'=>'新手教程'],
]);
Schema::create('articles', function (Blueprint $t) {
$t->id(); $t->integer('iCategoryId'); $t->string('sSlug');
$t->text('sContent'); $t->integer('iLikeCount')->default(0); $t->timestamps();
});
DB::table('articles')->insert(['id'=>7,'iCategoryId'=>1,'sSlug'=>'m7','sContent'=>'original']);
}
protected function tearDown(): void
{
if (isset($this->state) && is_file($this->state)) unlink($this->state);
parent::tearDown();
}
private function paused(callable $action): void
{
try { $action(); $this->fail('Expected paused response.'); }
catch (HttpResponseException $e) {
$this->assertSame(503, $e->getResponse()->getStatusCode());
$this->assertSame('3600', $e->getResponse()->headers->get('Retry-After'));
$this->assertSame('FREE_NODE_UPDATES_PAUSED', json_decode($e->getResponse()->getContent(), true)['code']);
}
}
public function test_command_switch_is_persistent_reversible_and_private(): void
{
$this->assertFalse(SiteMode::quiet());
$this->artisan('site:quiet on')->assertExitCode(0);
$this->assertTrue(SiteMode::quiet());
$this->assertSame(0600, fileperms($this->state) & 0777);
$this->artisan('site:quiet status')->assertExitCode(0);
$this->assertTrue(json_decode(file_get_contents($this->state), true)['quiet']);
$this->artisan('site:quiet off')->assertExitCode(0);
$this->assertFalse(SiteMode::quiet());
$this->artisan('site:quiet invalid')->assertExitCode(1);
$this->assertFalse(SiteMode::quiet());
}
public function test_invalid_state_stays_paused_and_environment_fallback_works(): void
{
config(['site_mode.quiet'=>true]); $this->assertTrue(SiteMode::quiet());
config(['site_mode.quiet'=>false]);
file_put_contents($this->state, 'invalid'); $this->assertTrue(SiteMode::quiet());
file_put_contents($this->state, '{"quiet":"false"}'); $this->assertTrue(SiteMode::quiet());
}
public function test_receivers_pause_before_images_flows_and_any_database_write(): void
{
config(['site_mode.quiet'=>true]);
$controller = new ArticlesController();
$this->paused(fn () => $controller->receiveFreenode(new Request()));
$this->paused(fn () => $controller->receiveFreenode_laji(new Request()));
$this->paused(fn () => $controller->receive(new Request(['_raw'=>[
'id'=>8, 'opt'=>json_encode(['sCategoryName'=>'免费节点']),
]])));
// Moving an existing node article to another category must not bypass the pause.
$this->paused(fn () => $controller->receive(new Request(['_raw'=>[
'id'=>7, 'opt'=>json_encode(['sCategoryName'=>'新手教程']),
]])));
$this->assertSame(1, DB::table('articles')->count());
$this->assertSame('original', DB::table('articles')->where('id',7)->value('sContent'));
Http::assertNothingSent();
}
public function test_model_rejects_publication_changes_but_allows_metrics_and_other_types(): void
{
config(['site_mode.quiet'=>true]);
$existing = Articles::find(7); $existing->sContent = 'replacement';
$this->paused(fn () => $existing->save());
$this->assertSame('original', DB::table('articles')->where('id',7)->value('sContent'));
$new = new Articles(['iCategoryId'=>1,'sSlug'=>'fn-new','sContent'=>'new']);
$this->paused(fn () => $new->save());
$metrics = Articles::find(7); $metrics->iLikeCount = 10; $metrics->save();
$this->assertSame(10, DB::table('articles')->where('id',7)->value('iLikeCount'));
foreach ([2,3] as $id) SiteMode::guardArticle(new Articles(['iCategoryId'=>$id,'sContent'=>'unchanged scope']));
SiteMode::assertUpdatesAllowed('shadowrocketids');
$this->assertSame(1, DB::table('articles')->count());
config(['site_mode.quiet'=>false]); SiteMode::assertUpdatesAllowed('freenode');
}
public function test_promotions_disappear_without_mutating_original_body_or_copy_payload(): void
{
$category = new Categories(['sSlug'=>'freenode']);
$article = new Articles(['sContent'=>'<h2>机场推荐 - 福云:</h2><p>优惠</p><p><a href="https://example.invalid/">机场官网:福云</a></p><h2>订阅</h2><a data-clipboard-text="example-sub">复制</a>']);
$article->setRelation('oCategory', $category); $original = $article->sContent;
config(['site_mode.quiet'=>true]);
$result = ArticleRecommendations::prepare($article);
$this->assertFalse($result['enabled']);
$this->assertStringNotContainsString('福云', $result['body']);
$this->assertStringContainsString('data-clipboard-text="example-sub"', $result['body']);
$this->assertSame($original, $article->sContent);
$this->assertSame([], ArticleRecommendations::providers());
$this->assertStringNotContainsString('data-random-article-ad', view('components.article-ads')->render());
$this->assertStringNotContainsString('sponsored', view('components.article-ads')->render());
foreach (['dpl','hn','fuc'] as $provider) {
$this->assertStringNotContainsString('sponsored', view('components.'.$provider.'-article-ad')->render());
}
$this->assertStringNotContainsString('airport-recommendations', view('components.airport-recommendations')->render());
config(['site_mode.quiet'=>false]);
$this->assertTrue(ArticleRecommendations::prepare($article)['enabled']);
$this->assertStringContainsString('data-random-article-ad', view('components.article-ads')->render());
$this->assertSame($original, $article->sContent);
}
}

View File

@ -19,7 +19,7 @@ protected function setUp(): void
parent::setUp(); parent::setUp();
config(['database.default'=>'sqlite','database.connections.sqlite.database'=>':memory:','scout.driver'=>'collection', 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.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, 'app.site_code_short'=>'cfn','seo.brand'=>'Example','responsecache.enabled'=>false,'seo.page_size'=>2,'seo.history_page_size'=>2,
'illustrations.enabled'=>false,'editorial.indexnow_enabled'=>false]); 'illustrations.enabled'=>false,'editorial.indexnow_enabled'=>false]);
DB::purge('sqlite'); Http::preventStrayRequests(); $this->withoutVite(); DB::purge('sqlite'); Http::preventStrayRequests(); $this->withoutVite();
$this->travelTo(\Carbon\Carbon::parse('2026-10-31 12:00:00','UTC')); $this->travelTo(\Carbon\Carbon::parse('2026-10-31 12:00:00','UTC'));
@ -29,7 +29,7 @@ protected function setUp(): void
Schema::create('article_tag',function(Blueprint $t){$t->integer('iArticleId');$t->integer('iTagId');}); 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();}); 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(); (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]); foreach([[1,'freenode','免费节点',0],[2,'client','入门教程',0],[3,'windows','Windows',2],[4,'advanced','综合教程',0],[5,'shadowrocketids','免费小火箭ID',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']); DB::table('tags')->insert(['id'=>1,'sSlug'=>'clash','sName'=>'Clash']);
} }
protected function tearDown(): void { $this->travelBack(); parent::tearDown(); } protected function tearDown(): void { $this->travelBack(); parent::tearDown(); }
@ -88,12 +88,12 @@ public function test_home_is_stable_and_failed_refresh_does_not_replace_it(): vo
public function test_new_snapshots_are_archives_and_content_clock_ignores_likes(): void public function test_new_snapshots_are_archives_and_content_clock_ignores_likes(): void
{ {
$id=$this->article('old',['iCategoryId'=>1]);$old=Articles::find($id); $id=$this->article('old',['iCategoryId'=>1]);$old=Articles::find($id);
$this->assertTrue(Seo::indexable($old));$this->assertNull(Seo::modified($old)); $this->assertFalse(Seo::indexable($old));$this->assertNull(Seo::modified($old));
$new=Articles::create(['sSlug'=>'new','sTitle'=>'新的记录','sTitleSub'=>'共10个可用节点','sContent'=>'<p>有效资源</p>','iCategoryId'=>1,'iStatus'=>1]); $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)); $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())); $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())); $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->get('/sitemap.xml')->assertOk()->assertDontSee('/a/new.html',false)->assertDontSee('/a/old.html',false);
$this->travelTo(\Carbon\Carbon::parse('2026-11-06 10:00:01','UTC')); $this->travelTo(\Carbon\Carbon::parse('2026-11-06 10:00:01','UTC'));
$this->get('/sitemap.xml')->assertOk()->assertDontSee('/a/old.html',false); $this->get('/sitemap.xml')->assertOk()->assertDontSee('/a/old.html',false);
} }
@ -127,7 +127,7 @@ public function test_durable_home_notifications_retry_and_deletions_need_no_http
$service->changed(Articles::find($id));$service->changed(Articles::find($id));$this->assertSame(1,DB::table('seo_notifications')->count()); $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); $service->retry();Http::assertSentCount(1);$service->retry();Http::assertSentCount(1);
$this->travel(6)->minutes();$service->retry();Http::assertSentCount(2); $this->travel(6)->minutes();$service->retry();Http::assertSentCount(2);
Http::assertSent(fn($req)=>$req['urlList']===['https://example.org/','https://example.org/freenode']); Http::assertSent(fn($req)=>$req['urlList']===['https://example.org/']);
$service->changed(Articles::find($id),true);DB::table('articles')->where('id',$id)->delete();$service->retry();Http::assertSentCount(3); $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'])); Http::assertSent(fn($req)=>in_array('https://example.org/a/snapshot.html',$req['urlList']));
} }
@ -146,4 +146,135 @@ public function test_commercial_attributes_and_zero_string_validation(): void
ResourcePublication::validate(['title'=>'资源','title_sub'=>'数量未知','content'=>'内容','tags'=>[],'valid_count'=>'0']); ResourcePublication::validate(['title'=>'资源','title_sub'=>'数量未知','content'=>'内容','tags'=>[],'valid_count'=>'0']);
} }
private function appleBody(): string
{
return '<h2>免费苹果账号</h2><p>账号:<a data-clipboard-text="test@example.invalid">test@example.invalid</a>,密码:<a data-clipboard-text="synthetic-test">synthetic-test</a>,地区:美区,检测时间:<span>2026-10-30 10:00:00</span></p>';
}
private function xpath(string $html): \DOMXPath
{
$dom=new \DOMDocument();@$dom->loadHTML($html);return new \DOMXPath($dom);
}
public function test_id_entrance_uses_latest_nonempty_snapshot_without_changing_records(): void
{
$good=$this->article('id-good',['iCategoryId'=>5,'sContent'=>$this->appleBody()]);
$this->article('id-pinned',['iCategoryId'=>5,'sContent'=>$this->appleBody(),'iIsTop'=>1,'created_at'=>'2026-10-01 10:00:00']);
foreach(['empty'=>['sContent'=>'<h2>免费苹果账号</h2>'], 'unknown'=>['sContent'=>'<p>格式未知</p>'],
'failed'=>['sResourceStatus'=>'failed'], 'draft'=>['iStatus'=>0], 'future'=>['created_at'=>'2026-11-01 00:00:00']] as $slug=>$extra) {
$this->article('id-'.$slug,$extra+['iCategoryId'=>5,'sContent'=>$this->appleBody(),'created_at'=>'2026-10-31 11:00:00']);
}
$before=DB::table('articles')->orderBy('id')->get()->toJson();
$this->assertSame('id-good',app(ArticlesService::class)->getLatestResourceSlug('shadowrocketids'));
$html=$this->get('/shadowrocketids')->assertOk()->assertSee('查看往期资源(免费小火箭ID)')->getContent();
$xp=$this->xpath($html);
$this->assertSame('https://example.org/shadowrocketids',$xp->query('//link[@rel="canonical"]')->item(0)->getAttribute('href'));
$this->assertSame('index, follow',$xp->query('//meta[@name="robots"]')->item(0)->getAttribute('content'));
$this->assertSame('https://example.org/shadowrocketids',$xp->query('//a[@id="urlCopy"]')->item(0)->getAttribute('data-clipboard-text'));
$this->assertSame('10月30日免费小火箭ID',trim($xp->query('//h1')->item(0)->textContent));
$this->assertSame($before,DB::table('articles')->orderBy('id')->get()->toJson());
DB::table('categories')->where('id',5)->update(['iStatus'=>0]);
$this->assertNull(app(ArticlesService::class)->getLatestResourceSlug('shadowrocketids'));
$this->get('/shadowrocketids')->assertStatus(503)->assertHeader('Retry-After','3600');
$this->get('/history/shadowrocketids')->assertNotFound();
}
public function test_dated_resources_keep_their_urls_and_self_canonical_but_are_noindex(): void
{
foreach(['freenode'=>1,'shadowrocketids'=>5] as $type=>$category) {
$id=$this->article($type.'-date',['iCategoryId'=>$category,'sSeoPolicy'=>'index','sContent'=>$category===5?$this->appleBody():'<p>节点记录</p>']);
$html=$this->get('/a/'.$type.'-date.html')->assertOk()->assertSee('查看最新')->assertSee('查看往期')->getContent();
$xp=$this->xpath($html);
$this->assertSame('往期资源', trim($xp->query('//a[contains(@class,"resource-history-action") and contains(@href,"/history/")]')->item(0)->textContent));
$this->assertSame('noindex, follow',$xp->query('//meta[@name="robots"]')->item(0)->getAttribute('content'));
$this->assertSame('https://example.org/a/'.$type.'-date.html',$xp->query('//link[@rel="canonical"]')->item(0)->getAttribute('href'));
$this->assertFalse(Seo::indexable(Articles::find($id)));
}
$this->article('guide');$this->get('/a/guide.html')->assertOk()->assertSee('content="index, follow"',false)->assertDontSee('aria-label="资源更新记录"',false);
}
public function test_history_overview_combines_both_types_and_category_links_filter_history(): void
{
config(['seo.history_page_size'=>10]);
$this->article('latest-free',['iCategoryId'=>1,'created_at'=>'2026-10-31 10:00:00']);
$this->article('latest-id',['iCategoryId'=>5,'sContent'=>$this->appleBody(),'created_at'=>'2026-10-31 10:00:00']);
$a=$this->article('free-a',['iCategoryId'=>1,'created_at'=>'2026-10-29 10:00:00']);
$b=$this->article('free-b',['iCategoryId'=>1,'created_at'=>'2026-10-28 10:00:00']);
$c=$this->article('id-a',['iCategoryId'=>5,'sContent'=>$this->appleBody(),'created_at'=>'2026-10-27 10:00:00']);
$xp=$this->xpath($this->get('/history')->assertOk()->getContent());
$this->assertSame([(string)$a,(string)$b,(string)$c],array_map(fn($n)=>$n->getAttribute('data-article-id'),iterator_to_array($xp->query('//*[@data-article-id]'))));
foreach(['freenode','shadowrocketids'] as $type) {
$this->assertGreaterThan(0,$xp->query('//div[contains(@class,"list-home")]//div[contains(@class,"list-footer")]//a[@href="https://example.org/history/'.$type.'"]')->length);
}
$this->assertSame(2,$this->xpath($this->get('/history/freenode')->assertOk()->getContent())->query('//*[@data-article-id]')->length);
$this->assertSame(1,$this->xpath($this->get('/history/shadowrocketids')->assertOk()->getContent())->query('//*[@data-article-id]')->length);
}
public function test_history_excludes_current_entries_and_preserves_date_order_and_pagination(): void
{
$this->article('current-free',['iCategoryId'=>1,'created_at'=>'2026-10-31 10:00:00']);
$this->article('current-id',['iCategoryId'=>5,'sContent'=>$this->appleBody(),'created_at'=>'2026-10-31 10:00:00']);
$ids=[];foreach(range(1,5) as $n)$ids[]=$this->article('past-'.$n,['iCategoryId'=>1,'iIsTop'=>$n===1?1:0,'created_at'=>'2026-10-2'.$n.' 10:00:00']);
$this->article('old-id',['iCategoryId'=>5,'sContent'=>$this->appleBody(),'created_at'=>'2026-10-20 10:00:00']);
$this->article('hidden-free',['iCategoryId'=>1,'iStatus'=>0]);
$this->article('future-free',['iCategoryId'=>1,'created_at'=>'2026-11-01 00:00:00']);
$this->article('not-resource');
foreach(['/history','/history/freenode'] as $path) {
$xp=$this->xpath($this->get($path)->assertOk()->getContent());
$this->assertSame(0, $xp->query('//nav[@aria-label="往期内容分类"]')->length);
$this->assertSame(0, $xp->query('//main//h1')->length);
$cards=$xp->query('//*[@data-article-id]');
$this->assertSame([(string)$ids[4],(string)$ids[3]],array_map(fn($n)=>$n->getAttribute('data-article-id'),iterator_to_array($cards)));
$this->assertSame('noindex, follow',$xp->query('//meta[@name="robots"]')->item(0)->getAttribute('content'));
}
$html=$this->get('/history/freenode?page=2&utm_source=test')->assertOk()->getContent();
$xp=$this->xpath($html);$cards=$xp->query('//*[@data-article-id]');
$this->assertSame([(string)$ids[2],(string)$ids[1]],array_map(fn($n)=>$n->getAttribute('data-article-id'),iterator_to_array($cards)));
$this->assertSame('https://example.org/history/freenode?page=2',$xp->query('//link[@rel="canonical"]')->item(0)->getAttribute('href'));
$this->assertSame('https://example.org/history/freenode?page=3',$xp->query('//link[@rel="next"]')->item(0)->getAttribute('href'));
$xp=$this->xpath($this->get('/history/shadowrocketids')->assertOk()->getContent());
$this->assertSame(1,$xp->query('//*[@data-article-id]')->length);
$this->assertStringContainsString('old-id',$xp->query('//a[contains(@class,"list-title")]')->item(0)->getAttribute('href'));
$this->get('/history/freenode?page=3')->assertOk()->assertDontSee('class="pagination-next"',false);
foreach(['/history/freenode?page=4','/history/freenode?page=0','/history?page=abc','/history?page[]=2','/history?page=2&page=3','/history/client'] as $path)$this->get($path)->assertNotFound();
$this->get('/history/freenode?page=1')->assertStatus(301)->assertRedirect('https://example.org/history/freenode');
}
public function test_resource_navigation_redirects_and_sitemap_use_stable_entrances(): void
{
$this->article('current-free',['iCategoryId'=>1]);
$this->article('current-id',['iCategoryId'=>5,'sContent'=>$this->appleBody()]);
$this->article('guide');
$html=$this->get('/')->assertOk()->assertSee('查看往期资源(免费节点)')->getContent();
$xp=$this->xpath($html);
foreach(['往期资源'=>'/history','免费节点'=>'/','免费小火箭ID'=>'/shadowrocketids'] as $name=>$path) {
$links=$xp->query('//header//a[span[text()="'.$name.'"]]');$this->assertSame(2,$links->length);
foreach($links as $link)$this->assertSame($path,parse_url($link->getAttribute('href'),PHP_URL_PATH) ?: '/');
}
foreach(['/freenode'=>'/','/freenode?page=1'=>'/','/?page=2'=>'/history/freenode?page=2',
'/freenode?page=2'=>'/history/freenode?page=2','/shadowrocketids?page=2'=>'/history/shadowrocketids?page=2',
'/shadowrocketids?page=1'=>'/shadowrocketids'] as $old=>$target)$this->get($old)->assertStatus(301)->assertRedirect('https://example.org'.$target);
$this->get('/shadowrocketids?page=-1')->assertNotFound();
$xml=$this->get('/sitemap.xml')->assertOk()->getContent();
$this->assertStringContainsString('<loc>https://example.org/shadowrocketids</loc>',$xml);
$this->assertStringContainsString('<loc>https://example.org/</loc>',$xml);
foreach(['/freenode','/history','/a/current-free.html','/a/current-id.html'] as $path)$this->assertStringNotContainsString('https://example.org'.$path,$xml);
$this->assertStringContainsString('/a/guide.html',$xml);
DB::table('articles')->where('iCategoryId',5)->delete();
$this->get('/sitemap.xml')->assertOk()->assertDontSee('/shadowrocketids',false);
$this->get('/history/shadowrocketids')->assertOk()->assertSee('看起来这里没有任何东西…');
}
public function test_id_updates_notify_only_the_entrance_and_drop_queued_archive_updates(): void
{
config(['editorial.indexnow_enabled'=>true,'key.indexnow'=>'test-key-12345678']);Http::fake(['api.indexnow.org/*'=>Http::response('',200)]);
$id=$this->article('ids',['iCategoryId'=>5,'sContent'=>$this->appleBody()]);$service=app(SeoNotifications::class);
$service->changed(Articles::find($id));$service->retry();
Http::assertSentCount(1);Http::assertSent(fn($req)=>$req['urlList']===['https://example.org/shadowrocketids']);
$service->enqueue('legacy-queue',['https://example.org/freenode','https://example.org/history','https://example.org/history/shadowrocketids','https://example.org/a/ids.html']);
$service->retry();Http::assertSentCount(1);
$state=json_decode(DB::table('seo_notifications')->where('event_key','legacy-queue')->value('state'),true);
$this->assertSame('skipped',$state['status']);
}
} }

7
tests/TestCase.php Executable file → Normal file
View File

@ -6,5 +6,10 @@
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
// protected function setUp(): void
{
parent::setUp();
// Tests must never inherit the running site's operational switch.
config(['site_mode.quiet' => false, 'site_mode.state_path' => null]);
}
} }