Redesign free-node articles and recommendations; refine CFN navigation and lists

This commit is contained in:
Codex 2026-10-31 18:28:14 +08:00
parent a7adfac041
commit 7d564df7e9
20 changed files with 580 additions and 12 deletions

View File

@ -0,0 +1,66 @@
<?php
namespace App\Services;
use App\Models\Articles;
final class ArticleRecommendations
{
public const SLOT = '<!-- fna-airport-recommendations -->';
public static function providers(): array
{
if (! config('airport_recommendations.enabled')) return [];
$providers = array_filter(config('airport_recommendations.providers', []), fn ($p) =>
($p['enabled'] ?? false) && filter_var($p['url'] ?? '', FILTER_VALIDATE_URL)
&& in_array(parse_url($p['url'], PHP_URL_SCHEME), ['https', 'http'], true));
uasort($providers, fn ($a, $b) => ($a['order'] ?? 0) <=> ($b['order'] ?? 0));
return $providers;
}
/** Presentation only: article storage, API receipts and subscription bytes stay intact. */
public static function prepare(Articles $article): array
{
$html = $article->sContent ?? '';
$category = $article->oCategory;
$slug = $category?->sSlug;
$scope = in_array($slug, ['freenode', 'shadowrocketids', 'advanced', 'client'], true)
|| $category?->oParent?->sSlug === 'client';
$enabled = $scope && self::providers() !== [];
if (! $enabled) return ['body' => $html, 'enabled' => false, 'inline' => false];
$inline = false;
if (in_array($slug, ['freenode', 'shadowrocketids'], true)) {
// Match the known legacy heading and its own Fuyun link, bounded by the next heading.
if (preg_match('~(?:<p\b[^>]*>\s*)?<h[2-6]\b[^>]*>\s*机场推荐\s*[-—-::]*\s*福云\s*[::]?\s*</h[2-6]>~u', $html, $head, PREG_OFFSET_CAPTURE)) {
$start = $head[0][1];$from = $start + strlen($head[0][0]);
$tail = substr($html, $from);
if (preg_match('~<h[1-6]\b~i', $tail, $next, PREG_OFFSET_CAPTURE)) $tail = substr($tail, 0, $next[0][1]);
preg_match_all('~<a\b[^>]*>.*?</a>~si', $tail, $links, PREG_OFFSET_CAPTURE);
foreach ($links[0] as [$link, $offset]) {
$text = html_entity_decode(strip_tags($link), ENT_QUOTES | ENT_HTML5, 'UTF-8');
if (! str_contains($text, '机场官网') || ! str_contains($text, '福云')) continue;
$end = $from + $offset + strlen($link);
if (preg_match('~\A(?:\s|</(?:b|strong|p)>)*~i', substr($html, $end), $closing)) $end += strlen($closing[0]);
$html = substr($html, 0, $start).self::SLOT.substr($html, $end);
$inline = true;
break;
}
}
}
if ($slug === 'advanced') {
$anchor = '#'.config('airport_recommendations.anchor');
if (! str_contains($html, $anchor)) {
$jump = '<p class="airport-recommendation-jump">如果需要更稳定的线路,可以从文末的<a href="'.e($anchor).'">机场推荐</a>中选择。</p>';
$inserted = false;
$html = preg_replace_callback('~<p\b[^>]*>.*?</p>~si', function ($m) use (&$inserted, $jump) {
if ($inserted || ! preg_match('/免费节点|订阅|稳定|机场|线路/u', strip_tags($m[0]))) return $m[0];
$inserted = true;
return $m[0].$jump;
}, $html);
if (! $inserted) $html .= $jump;
}
}
return ['body' => $html, 'enabled' => true, 'inline' => $inline];
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Services;
use DOMDocument;
use DOMElement;
/** Read-only adaptation of the legacy daily-node article into a task-oriented layout. */
final class FreeNodeArticleLayout
{
public static function prepare(string $html): ?array
{
if (!preg_match('~(?:<p\b[^>]*>\s*)?<h[2-6]\b[^>]*>\s*免费节点订阅链接\s*[::]?\s*</h[2-6]>(?:\s*</p>)?~u', $html, $heading, PREG_OFFSET_CAPTURE)) return null;
if (!preg_match('~<p\b[^>]*>\s*温馨提示\s*[::]?\s*</p>~u', $html, $help, PREG_OFFSET_CAPTURE, $heading[0][1])) return null;
$start = $heading[0][1];$from = $start + strlen($heading[0][0]);$end = $help[0][1];
$intro = self::fragment(str_replace(ArticleRecommendations::SLOT, '', substr($html, 0, $start)));
$subscriptions = self::fragment(substr($html, $from, $end - $from));
if (!$intro || !$subscriptions) return null;
$result = ['count' => null, 'speed' => null, 'regions' => [], 'community' => [], 'intro' => '', 'notes' => '', 'subscriptions' => [], 'usage' => substr($html, $end + strlen($help[0][0]))];
foreach (iterator_to_array($intro->childNodes) as $node) {
$text = self::text($node->textContent);
if ($text === '') continue;
if (preg_match('/^本次更新共\s*(\d+)\s*个可用节点[,,]\s*最高速度\s*([^。]+)[。]?$/u', $text, $m)) {
$result['count'] = (int)$m[1];$result['speed'] = trim($m[2]);continue;
}
if (preg_match('/^覆盖(.+?)等多个区域[。]?$/u', $text, $m)) {
$result['regions'] = array_values(array_unique(array_filter(array_map('trim', preg_split('/[、,,]/u', $m[1])))));continue;
}
if (str_starts_with($text, '复制下方订阅链接')) continue;
if ($node instanceof DOMElement && preg_match('/点击加入我们的tg频道|点击关注我们的github仓库/u', $text)) {
$recognized = false;
foreach ($node->getElementsByTagName('a') as $a) {
$url = $a->getAttribute('href');$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
if (parse_url($url, PHP_URL_SCHEME) !== 'https' || !in_array($host, ['t.me', 'github.com'], true)) continue;
$result['community'][] = ['url' => $url, 'label' => $host === 't.me' ? 'Telegram 更新频道' : 'GitHub 项目仓库'];$recognized = true;
}
if ($recognized) continue;
}
$result['intro'] .= $intro->ownerDocument->saveHTML($node);
}
$urls = [];
foreach (iterator_to_array($subscriptions->getElementsByTagName('a')) as $a) {
if (!$a->hasAttribute('data-clipboard-text')) continue;
$url = $a->getAttribute('data-clipboard-text');
if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) return null;
$path = parse_url($url, PHP_URL_PATH) ?? '';
$type = match (true) {
(bool)preg_match('/-clash\.(yaml|yml)$/i', $path) => ['key'=>'clash', 'title'=>'Clash / Mihomo', 'format'=>'YAML', 'clients'=>'Clash Verge Rev、Mihomo 等客户端', 'symbol'=>'C'],
(bool)preg_match('/-singbox\.(txt|json)$/i', $path) => ['key'=>'singbox', 'title'=>'Sing-box / Hiddify', 'format'=>'SING-BOX', 'clients'=>'Sing-box、Hiddify 等客户端', 'symbol'=>'S'],
(bool)preg_match('/-v2ray\.txt$/i', $path) => ['key'=>'v2ray', 'title'=>'V2ray / Shadowrocket', 'format'=>'通用订阅', 'clients'=>'v2rayNG、Shadowrocket 等客户端', 'symbol'=>'V'],
default => null,
};
// Unknown formats keep the original article rather than dropping a subscription.
if (!$type || isset($result['subscriptions'][$type['key']])) return null;
$result['subscriptions'][$type['key']] = $type + ['url'=>$url];$urls[] = $url;
}
if ($result['subscriptions'] === []) return null;
// Keep every supplementary note while removing only labels/URLs now shown in cards.
foreach (iterator_to_array($subscriptions->childNodes) as $node) {
$text = self::text($node->textContent);
if ($text === '' || in_array($text, $urls, true) || preg_match('/订阅链接\s*[::]$/u', $text)) continue;
if ($node instanceof DOMElement && ($node->hasAttribute('data-clipboard-text') || $node->getElementsByTagName('a')->length)) {
foreach (iterator_to_array($node->getElementsByTagName('a')) as $link) {
if ($link->hasAttribute('data-clipboard-text')) $link->parentNode->removeChild($link);
}
if (self::text($node->textContent) === '' || in_array(self::text($node->textContent), $urls, true)) continue;
}
$result['notes'] .= $subscriptions->ownerDocument->saveHTML($node);
}
$order = ['clash'=>0, 'v2ray'=>1, 'singbox'=>2];
uasort($result['subscriptions'], fn ($a, $b) => $order[$a['key']] <=> $order[$b['key']]);
$result['subscriptions'] = array_values($result['subscriptions']);
return $result;
}
private static function text(string $text): string
{
return trim(preg_replace('/\s+/u', ' ', $text));
}
private static function fragment(string $html): ?DOMElement
{
$dom = new DOMDocument('1.0', 'UTF-8');$before = libxml_use_internal_errors(true);
try {
$dom->loadHTML('<?xml encoding="UTF-8"><html><body><div id="fn-root">'.$html.'</div></body></html>', LIBXML_NONET);
return $dom->getElementById('fn-root');
} catch (\Throwable) {
return null;
} finally {
libxml_clear_errors();libxml_use_internal_errors($before);
}
}
}

View File

@ -0,0 +1,33 @@
<?php
return [
'enabled' => env('AIRPORT_RECOMMENDATIONS_ENABLED', true),
'anchor' => 'airport-recommendations',
// User-supplied commercial copy. Edit here once, then clear config/response caches.
'providers' => [
'fuc' => [
'enabled' => true, 'order' => 10, 'name' => '福云', 'tagline' => '日常之选',
'url' => env('AIRPORT_FUC_URL', 'https://xn--123-9i9dj97q.com/'),
'price' => '18', 'period' => '元 / 月',
'features' => ['流媒体解锁', 'AI 解锁'],
'description' => '老牌机场,套餐性价比高,适合日常娱乐办公',
'coupon' => 'clashfreenode.com', 'discount' => '9 折',
],
'dpl' => [
'enabled' => true, 'order' => 20, 'name' => 'DP-LINK.NET', 'tagline' => '专线服务',
'url' => env('AIRPORT_DPL_URL', 'https://dp-link.net/'),
'price' => '26', 'period' => '元 / 月',
'features' => ['流媒体解锁', 'AI 解锁'],
'description' => '专线机场,适合对网络质量有要求的用户',
'coupon' => 'clashfreenode.com', 'discount' => '9 折',
],
'hn' => [
'enabled' => true, 'order' => 30, 'name' => 'hardnerd', 'tagline' => '灵活套餐',
'url' => env('AIRPORT_HN_URL', 'https://hardnerd.net/'),
'price' => '28', 'period' => '元 / 月',
'features' => ['流媒体解锁', 'AI 解锁'],
'description' => '专线机场,适合对网络质量有要求的用户,有一次性套餐',
'coupon' => null, 'discount' => null,
],
],
];

View File

@ -0,0 +1,27 @@
.airport-recommendations{margin:28px 0 24px;scroll-margin-top:100px;color:#424852;clear:both;font-size:14px;line-height:1.65}
.airport-recommendations__heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:15px}
.post-content .airport-recommendations__heading h2{font-size:21px;line-height:1.4;margin:0;padding:0;border:0;color:#29323d;font-weight:700}
.airport-recommendations__heading>span{font-size:11px;color:#8e98a5;letter-spacing:.06em}
.airport-recommendations__table-wrap{border:1px solid #e8edf2;border-radius:10px;overflow:hidden;background:#fff}
.post-content .airport-recommendations__table{width:100%;table-layout:fixed;border:0;border-collapse:collapse;margin:0;font-size:13px;line-height:1.65}
.post-content .airport-recommendations__table thead th{background:#f7f9fc;color:#7b8795;font-weight:500;font-size:12px;border:0;border-bottom:1px solid #e8edf2;padding:12px 11px;text-align:left}
.post-content .airport-recommendations__table thead th:nth-child(1){width:19%}.post-content .airport-recommendations__table thead th:nth-child(2){width:15%}.post-content .airport-recommendations__table thead th:nth-child(3){width:18%}.post-content .airport-recommendations__table thead th:nth-child(4){width:25%}.post-content .airport-recommendations__table thead th:nth-child(5){width:23%}
.post-content .airport-recommendations__table tbody th,.post-content .airport-recommendations__table tbody td{padding:18px 11px;border:0;border-bottom:1px solid #edf0f5;vertical-align:middle;background:transparent;text-align:left;overflow-wrap:anywhere}
.post-content .airport-recommendations__table tbody tr:last-child>*{border-bottom:0}
.post-content .airport-provider a{font-size:14px;font-weight:700;text-decoration:none;color:#2f4057;display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
.post-content .airport-provider a:hover{color:#238dce}.airport-provider a>span{font-weight:400;color:#96a4b5;font-size:12px}.airport-provider__label{display:block;font-size:10px;color:#9ba6b3;font-weight:400;margin-top:4px}
.airport-price strong{font-size:22px;font-weight:650;color:#35465d;letter-spacing:-.6px;display:block;line-height:1.15}.airport-price>span{font-size:10px;color:#8b96a6;white-space:nowrap}
.airport-features{display:grid;gap:5px;font-size:12px;white-space:nowrap}.airport-features i{font-style:normal;color:#27a781;margin-right:4px}.airport-description{color:#76818e;font-size:12px}
.airport-coupon__discount{font-size:10px;color:#248cca;background:#edf7fd;border-radius:4px;padding:2px 6px;display:inline-block;margin-bottom:5px}
.airport-coupon__copy{display:flex;align-items:center;gap:4px;background:none;border:0;border-bottom:1px dashed #c9d5e1;padding:0 0 2px;font:inherit;font-size:11px;color:#53667f;text-align:left;cursor:pointer;max-width:100%;white-space:nowrap}.airport-coupon__copy svg{flex:none;color:#9aaabd}.airport-coupon__copy.is-copied{color:#19856a;border-color:#19856a}.airport-coupon__copy:focus-visible,.airport-provider a:focus-visible{outline:2px solid #319bc9;outline-offset:4px}.airport-coupon__none{font-size:12px;color:#a3aab5}
.post-content p.airport-recommendations__note{font-size:10px;color:#a0a8b4;margin:10px 0 0;line-height:1.6}.post-content p.airport-recommendation-jump{font-size:14px;color:#6f7d8c;margin:14px 0 20px}.airport-recommendation-jump a{color:#238dce;text-underline-offset:3px}
@media(max-width:767.98px){
.airport-recommendations{margin:25px 0}.post-content .airport-recommendations__heading h2{font-size:20px}.airport-recommendations__heading>span{font-size:10px}
.airport-recommendations__table-wrap{border:0;border-radius:0;overflow:visible;background:transparent}.post-content .airport-recommendations__table,.post-content .airport-recommendations__table tbody{display:block;width:100%}.airport-recommendations__table thead{display:none}
.post-content .airport-recommendations__table tbody tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:13px 12px;border:1px solid #e8edf2;border-radius:10px;padding:18px;margin-bottom:12px;background:#fff}
.post-content .airport-recommendations__table tbody th,.post-content .airport-recommendations__table tbody td{display:block;border:0;padding:0;min-width:0}.post-content .airport-provider a{font-size:16px}.airport-provider__label{font-size:11px}.airport-price{grid-column:2;grid-row:1;text-align:right!important}.airport-price strong{display:inline;font-size:25px}.airport-price>span{display:inline;margin-left:5px;font-size:10px}
.post-content .airport-recommendations__table tbody td.airport-description{grid-column:1/-1;grid-row:2;font-size:12px;line-height:1.75}.airport-features{display:flex;gap:12px;font-size:11px;flex-wrap:wrap}
.post-content .airport-recommendations__table tbody td.airport-coupon{grid-column:1/-1;border-top:1px solid #edf0f5;padding-top:11px;display:flex;align-items:center;gap:8px}.airport-coupon:before{content:attr(data-label);font-size:11px;color:#9aa4b0;margin-right:auto}.airport-coupon__discount{margin:0}.airport-coupon__copy{font-size:11px}.post-content p.airport-recommendations__note{font-size:10px}
}
.airport-copy-status{display:block;color:#24846f;font-size:11px;margin-top:8px}.airport-copy-status:empty{display:none}

16
public/css/cfn-brand.css Normal file
View File

@ -0,0 +1,16 @@
.header .navbar-brand.cfn-brand{display:inline-flex;align-items:center;gap:10px;max-width:none;padding:0;line-height:1;text-decoration:none}
.header .navbar-brand.cfn-brand .cfn-brand__cat{display:block;width:44px;height:44px;max-height:none;flex:none;border-radius:0}
.cfn-brand__copy{display:flex;flex-direction:column;gap:5px;white-space:nowrap}
.cfn-brand__name{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:23px;font-weight:700;letter-spacing:-.65px;color:#555555;line-height:1.08}
.cfn-brand__accent{color:inherit;font-weight:650}
.cfn-brand__tagline{font-size:11px;font-weight:400;letter-spacing:1.25px;color:#938798;line-height:1.25}
body.dark .cfn-brand__tagline{color:#b7acbc}body.dark .cfn-brand__name{color:#eeeaf1}
.mobile-sidebar-social-links{display:flex;align-items:center;gap:12px;margin:20px 24px 0;padding-top:16px;border-top:1px solid rgba(127,127,127,.16)}
@media(max-width:1199.98px){
.header #header>.container{position:relative;flex-wrap:nowrap;min-height:44px}
.header .navbar-brand.cfn-brand{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);gap:8px;margin:0;flex-shrink:0}
.header .navbar-brand.cfn-brand .cfn-brand__cat{width:38px;height:38px}
.cfn-brand__name{font-size:21px;letter-spacing:-.5px}
.cfn-brand__tagline{font-size:10px;letter-spacing:1px}
.header .navbar-toggler{flex-shrink:0}
}

View File

@ -0,0 +1,37 @@
.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}
.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}
.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}
.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-content{font-size:16px;line-height:1.95}.fn-subscriptions,.fn-usage{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}
.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}
.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-usage{margin:32px 0 0;padding-top:27px;border-top:1px solid var(--fn-line)}.fn-steps{display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr));list-style:none!important;padding:0!important;margin:0 0 18px!important;gap:15px}.fn-steps>li{display:flex;gap:9px;padding:0!important;margin:0!important}.fn-steps>li>span{font-size:14px;font-weight:600;color:#bca5c5;line-height:1.8}.fn-steps strong{font-size:16px;font-weight:500;color:#6a6271}.free-node-page .post-content .fn-steps p{font-size:14px;color:#807788;margin:3px 0 0;line-height:1.8}
.fn-original-help{background:#faf9fb;border:1px solid #efecf2;border-radius:8px;padding:13px 16px;color:#786c84;font-size:12px;line-height:1.9}.free-node-page .post-content .fn-original-help p{font-size:12px;line-height:1.9;margin:0 0 7px}.free-node-page .post-content .fn-original-help p:last-child{margin-bottom:0}.fn-original-help p:empty,.fn-original-help br{display:none}.fn-tutorial-links{display:flex;align-items:center;gap:15px;flex-wrap:wrap;margin-top:13px;font-size:14px;color:#b0a4b5}.fn-tutorial-links a{color:#8a6b95;text-decoration:none!important}.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}
@media(min-width:992px){.free-node-page .sidebar{top: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-steps{grid-template-columns:1fr;gap:12px}.fn-steps>li>span{width:22px}.free-node-page .post-content .fn-steps p{margin-top:0}.fn-community{align-items:flex-start;flex-direction:column;gap:7px}.fn-tutorial-links{gap:8px 14px}.fn-original-help{padding:12px}.fn-usage{margin-top:26px;padding-top:22px}}
@media(prefers-reduced-motion:no-preference){html:has(.free-node-page){scroll-behavior:smooth}}
.free-node-page .post-content .fn-subscription h3::before{display:none}
/* Match the existing tutorial type scale; shared recommendation overrides stay local to this page. */
.free-node-page .post-content .airport-recommendations__heading h2{font-size:22.72px}
.free-node-page .post-content .airport-recommendations__table{font-size:14px}
.free-node-page .post-content .airport-recommendations__table thead th{font-size:13px}
.free-node-page .post-content .airport-provider a{font-size:14px}
.free-node-page .airport-provider__label,.free-node-page .airport-coupon__discount{font-size:12px}
.free-node-page .airport-features,.free-node-page .airport-description{font-size:14px}
.free-node-page .airport-coupon__copy,.free-node-page .airport-price>span{font-size:12px}
.free-node-page .post-content p.airport-recommendations__note{font-size:12px}
@media(max-width:600px){
.free-node-page .post-content{font-size:15px;line-height:1.95}
.free-node-page .post-content .fn-original-help p,.free-node-page .fn-original-help{font-size:12px;line-height:1.9}
.free-node-page .fn-extra-intro{font-size:15px;line-height:1.95}
.free-node-page .post-content .fn-subscription__address code{font-size:11px}
.free-node-page .post-content .airport-recommendations__heading h2{font-size:19.5px}
.fn-steps{grid-template-columns:1fr}
}
.fn-header h1>span{display:inline-block}
.fn-article-nav,.fn-community>div{flex-wrap:wrap}
.free-node-page .post-content .airport-recommendations__table tbody td.airport-description{font-size:14px}
@media(max-width:360px){.fn-article-nav a{padding-left:8px;padding-right:8px}}

View File

@ -1,5 +1,8 @@
.random-article-ad { position: relative; min-width: 0; aspect-ratio: 2.45; margin: 20px 0; overflow: hidden; border-radius: 12px; background: #181b1e; }
.random-article-ad { position: relative; min-width: 0; aspect-ratio: 3.675; margin: 20px 0; overflow: hidden; border-radius: 12px; background: #181b1e; }
.random-article-ad[hidden] { display: none !important; }
.random-article-ad > a { display: block; position: absolute; inset: 0; }
.random-article-ad img { display: block; width: 100%; height: 100%; object-fit: cover; }
.random-article-ad img { display: block; width: 100%; height: 100%; object-fit: contain; }
.random-article-ad > a:focus-visible { outline: 3px solid #8ab4ff; outline-offset: -4px; }
.random-article-ad[data-ad-selected="dpl"] { background: #0a0f1a; }
.random-article-ad[data-ad-selected="fuc"] { background: #f8f8fc; }

View File

@ -0,0 +1,34 @@
document.addEventListener('click', async (event) => {
const button = event.target.closest('[data-airport-coupon]');
if (!button) return;
const status = button.closest('.airport-recommendations').querySelector('.airport-copy-status');
try {
let copied = false;
if (navigator.clipboard?.writeText) {
try { await navigator.clipboard.writeText(button.dataset.airportCoupon); copied = true; } catch { /* Try the user-activated fallback below. */ }
}
if (!copied) {
const field = document.createElement('textarea');
field.value = button.dataset.airportCoupon;
field.setAttribute('readonly', '');
field.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.append(field);
try { field.select(); copied = document.execCommand('copy'); }
finally { field.remove(); button.focus({preventScroll:true}); }
}
if (!copied) throw new Error('Copy unavailable');
status.textContent = '优惠码已复制';
button.classList.add('is-copied');
button.setAttribute('aria-label', '优惠码已复制');
window.setTimeout(() => {
button.classList.remove('is-copied');
button.setAttribute('aria-label', `复制优惠码 ${button.dataset.airportCoupon}`);
}, 2000);
} catch {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(button.querySelector('span'));
selection.removeAllRanges(); selection.addRange(range);
status.textContent = '已选中优惠码,请长按或使用复制快捷键';
}
});

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -0,0 +1,42 @@
@php($providers = \App\Services\ArticleRecommendations::providers())
@if($providers)
@once
@push('styles')
<link rel="stylesheet" href="/css/airport-recommendations.css?v={{ filemtime(public_path('css/airport-recommendations.css')) }}">
@endpush
@push('scripts')
<script defer src="/js/airport-recommendations.js?v={{ filemtime(public_path('js/airport-recommendations.js')) }}"></script>
@endpush
@endonce
<section id="{{ config('airport_recommendations.anchor') }}" class="airport-recommendations" aria-labelledby="airport-recommendations-title">
<div class="airport-recommendations__heading">
<h2 id="airport-recommendations-title">机场推荐</h2>
</div>
<div class="airport-recommendations__table-wrap">
<table class="airport-recommendations__table">
<caption class="visually-hidden">机场套餐、解锁情况与优惠码对比</caption>
<thead><tr><th scope="col">机场名</th><th scope="col">套餐起售价</th><th scope="col">解锁情况</th><th scope="col">适合使用场景</th><th scope="col">优惠码</th></tr></thead>
<tbody>
@foreach($providers as $key => $provider)
<tr>
<th scope="row" class="airport-provider"><a href="{{ $provider['url'] }}" target="_blank" rel="sponsored nofollow noopener noreferrer">{{ $provider['name'] }}<span aria-hidden="true">↗</span></a><span class="airport-provider__label">{{ $provider['tagline'] ?? '' }}</span></th>
<td data-label="套餐起售价" class="airport-price"><strong>{{ $provider['price'] }}</strong><span>{{ $provider['period'] }}起</span></td>
<td data-label="解锁情况"><div class="airport-features">@foreach($provider['features'] as $feature)<span><i aria-hidden="true">✓</i>{{ $feature }}</span>@endforeach</div></td>
<td data-label="适合使用场景" class="airport-description">{{ $provider['description'] }}</td>
<td data-label="优惠码" class="airport-coupon">
@if($provider['coupon'])
<span class="airport-coupon__discount">{{ $provider['discount'] }}</span>
<button type="button" class="airport-coupon__copy" data-airport-coupon="{{ $provider['coupon'] }}" aria-label="复制{{ $provider['name'] }}优惠码 {{ $provider['coupon'] }}"><span>{{ $provider['coupon'] }}</span><svg width="13" height="13" 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></button>
@else
<span class="airport-coupon__none">无</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<p class="airport-recommendations__note">价格、套餐及优惠以服务商官网为准。</p>
<span class="airport-copy-status" role="status" aria-live="polite"></span>
</section>
@endif

View File

@ -69,6 +69,10 @@
@vite(['resources/css/app.css', "resources/sites/".config("app.site_code_short")."/css/app.css",'resources/js/app.js'])
@if(config('app.site_code_short') === 'cfn')
<link rel="stylesheet" href="/css/cfn-brand.css?v={{ filemtime(public_path('css/cfn-brand.css')) }}">
@endif
@stack('styles')
@if(app()->environment('production') && ! $bEditorialPreview && config('app.site_code_short') == 'cfn')

View File

@ -0,0 +1,2 @@
<img class="cfn-brand__cat" src="/sites/cfn/images/logo/cat.svg" width="44" height="44" alt="" aria-hidden="true">
<span class="cfn-brand__copy"><span class="cfn-brand__name"><span>Clash</span><span class="cfn-brand__accent">FreeNode</span></span><span class="cfn-brand__tagline">每天分享免费节点</span></span>

View File

@ -1,19 +1,23 @@
<header class="header">
<nav class="navbar navbar-expand-xl shadow" id="header">
<div class="container">
<a href="/" rel="home" class="logo navbar-brand order-2 order-xl-1">
<a href="/" rel="home" class="logo navbar-brand order-2 order-xl-1{{ config('app.site_code_short') === 'cfn' ? ' cfn-brand' : '' }}">
@if(config('app.site_code_short') === 'cfn')
@include('components.cfn-brand')
@else
<img style="border-radius:8px;" src="/sites/{{ config('app.site_code_short') }}/images/logo/logo.png"
class="d-inline-block logo-light nc-no-lazy"
alt="{{ config('app.name') }}">
<img style="border-radius:8px;" src="/sites/{{ config('app.site_code_short') }}/images/logo/logo.png"
class="d-none logo-dark nc-no-lazy"
alt="{{ config('app.name') }}">
@endif
</a>
<button class="btn btn-link btn-icon btn-sm btn-rounded navbar-toggler order-1" type="button"
id="sidebarCollapse" aria-label="打开导航菜单" aria-controls="mobile-navigation" aria-expanded="false">
<span><i class="iconfont icon-menu"></i></span>
</button>
<div class="d-xl-none order-3 ms-auto me-2">@include('components.telegram-link')</div>
<button class="btn btn-link btn-icon btn-sm btn-rounded navbar-toggler nav-search order-3 collapsed"
data-bs-target="#navbar-search" data-bs-toggle="collapse" aria-expanded="false"
aria-controls="navbar-search">
@ -100,6 +104,13 @@ class="d-none logo-dark nc-no-lazy"
@endforeach
</ul>
</div>
<div class="mobile-sidebar-social-links" role="navigation" aria-label="社区链接">
@include('components.telegram-link')
<a href="{{ config('path.url_github_href') }}" target="_blank" rel="noopener noreferrer"
class="btn btn-link btn-icon btn-sm btn-rounded mobile-github-link" aria-label="GitHub 项目仓库(新窗口打开)" title="GitHub 项目仓库">
<span><i class="iconfont icon-github-fill" aria-hidden="true"></i></span>
</a>
</div>
<div class="py-5"></div>
</div>
</div>

View File

@ -1,8 +1,9 @@
<a href="{{ config('community.telegram_url') }}" target="_blank" rel="noopener noreferrer"
class="btn btn-link btn-icon btn-sm btn-rounded community-telegram"
aria-label="加入 Telegram 频道(新窗口打开)" title="Telegram 频道"
style="display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:8px;color:#229ed9">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
aria-label="加入 Telegram 频道(新窗口打开)" title="Telegram 频道">
<span>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M21.94 4.67 18.9 19c-.23 1.01-.83 1.26-1.67.79l-4.63-3.41-2.23 2.15c-.25.25-.46.46-.95.46l.33-4.71 8.58-7.75c.37-.33-.08-.52-.57-.19L7.15 13.02l-4.57-1.43c-.99-.31-1.01-.99.21-1.47L20.66 3.23c.83-.31 1.56.2 1.28 1.44Z"/>
</svg>
</span>
</a>

View File

@ -9,7 +9,7 @@
<div class="container">
@if (isset($oCategory) && in_array($oCategory->sSlug, ['client', 'advanced']))
<link rel="stylesheet" href="/css/tutorials.css">
@if (!isset($oCategory->aBanner) || count($oArticles) == 0)
@if ($oCategory->sSlug === 'client' && (!isset($oCategory->aBanner) || count($oArticles) == 0))
<section class="tutorial-category" aria-label="教程栏目介绍">
<h1>{{ $oCategory->sName }}</h1>
<p>{{ $oCategory->sDescription }}</p>
@ -38,7 +38,7 @@
</div>
@endif
@isset($oCategory->aBanner)
@if (isset($oCategory->aBanner) && $oCategory->sSlug !== 'advanced')
@php
$sBannerPath = '/sites/' . config('app.site_code_short') . '/images/banner/' . $oCategory->aBanner['img_file'];
@endphp
@ -53,7 +53,7 @@
<h1 class="cover-title">{{ in_array($oCategory->sSlug, ['client', 'advanced']) ? $oCategory->sName : $oCategory->sSlug }}<sup class="text-light mx-1">{{ $iArticleCount ?? count($oArticles) }}</sup></h1>
</div>
</div>
@endisset
@endif
<div class="list-home list-grid list-grid-padding">
@foreach ($oArticles as $oArticle)

View File

@ -1,6 +1,11 @@
@php
$bIsHome = $bIsHome ?? false;
$seo = $seo ?? \App\Services\Seo::articleMeta($oArticle);
$recommendations = \App\Services\ArticleRecommendations::prepare($oArticle);
$recommendations['body'] = \App\Services\ResourceArticlePresentation::body($recommendations['body'], $oArticle->oCategory?->sSlug);
$articleBodyParts = explode(\App\Services\ArticleRecommendations::SLOT, $recommendations['body'], 2);
$freeNodeLayout = $oArticle->oCategory?->sSlug === 'freenode'
? \App\Services\FreeNodeArticleLayout::prepare($recommendations['body']) : null;
@endphp
<x-app-layout
@ -20,7 +25,7 @@
@section('sOgType', 'article')
--}}
<main class="site-main">
<main class="site-main{{ $freeNodeLayout ? ' free-node-page' : '' }}">
@if($bEditorialPreview ?? false)
<div class="container"><div class="alert alert-info">草稿预览 · 版本 {{ $editorialRevision }} · {{ ($editorialDraftStatus ?? 'draft') === 'published' ? '此版本已有发布记录' : '尚未发布' }}</div></div>
@endif
@ -58,6 +63,9 @@
<div class="content-wrapper col-lg-8">
<div class="post card card-md">
<div class="card-body">
@if($freeNodeLayout)
@include('pages.articles.freenode.header')
@else
<div class="post-title mb-3">
<h1 class="mb-3">{{ $oArticle->sTitle }}</h1>
<div class="post-meta text-xs">
@ -66,13 +74,24 @@
<time class="text-muted" datetime="{{ $oArticle->created_at?->toAtomString() }}">发布于 {{ $oArticle->sCreatedAtDiff }}</time>
</div>
</div>
@endif
<div class="post-content">
@if($freeNodeLayout)
@include('pages.articles.freenode.content')
@else
@if ($oArticle->bPageThumb && $oArticle->sThumb)
<p>
<img decoding="async" alt="" class="size-full wp-image-28 aligncenter" src="{{ $oArticle->sThumb }}" style="border-radius: 6px;">
</p>
@endif
{!! nl2br(\App\Services\Seo::body(\App\Services\ResourceArticlePresentation::body($oArticle->sContent ?? '', $oArticle->oCategory?->sSlug))) !!}
{!! nl2br(\App\Services\Seo::body($articleBodyParts[0])) !!}
@if($recommendations['inline'])
@include('components.airport-recommendations')
{!! nl2br(\App\Services\Seo::body($articleBodyParts[1])) !!}
@elseif($recommendations['enabled'])
@include('components.airport-recommendations')
@endif
@endif
</div>

View File

@ -0,0 +1,27 @@
@if($freeNodeLayout['intro'])<div class="fn-extra-intro">{!! nl2br(\App\Services\Seo::body($freeNodeLayout['intro'])) !!}</div>@endif
<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>
@if($freeNodeLayout['count'] === 0)
<p class="fn-empty-note">这篇更新记录的可用节点为 0,导入前可先<a href="/">查看最新更新</a>。</p>
@endif
<div class="fn-subscription-list">
@foreach($freeNodeLayout['subscriptions'] as $subscription)
<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__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>
@endforeach
</div>
@if($freeNodeLayout['notes'])<div class="fn-subscription-note">{!! nl2br(\App\Services\Seo::body($freeNodeLayout['notes'])) !!}</div>@endif
</section>
<section class="fn-usage" id="node-usage" aria-labelledby="node-usage-title">
<div class="fn-section-heading"><div><h2 id="node-usage-title">从订阅到连接,只需三步</h2></div></div>
<ol class="fn-steps"><li><span>01</span><div><strong>复制订阅</strong><p>选择与客户端匹配的格式。</p></div></li><li><span>02</span><div><strong>导入客户端</strong><p>在订阅管理中粘贴链接并更新。</p></div></li><li><span>03</span><div><strong>选择节点</strong><p>开启连接,再检查访问是否正常。</p></div></li></ol>
<div class="fn-tutorial-links"><span>第一次使用?</span><a href="/a/clash-verge-rev-windows.html">Windows 入门 ↗</a><a href="/a/m169.html">Android 入门 ↗</a><a href="/client">全部教程 ↗</a></div>
</section>
@if($recommendations['enabled'])
@include('components.airport-recommendations')
@endif
@if($freeNodeLayout['community'])
<div class="fn-community"><span>收藏更新,不必反复寻找</span><div>@foreach($freeNodeLayout['community'] as $link)<a href="{{ $link['url'] }}" target="_blank" rel="noopener noreferrer">{{ $link['label'] }} ↗</a>@endforeach</div></div>
@endif

View File

@ -0,0 +1,24 @@
@once
@push('styles')
<link rel="stylesheet" href="/css/freenode-article.css?v={{ filemtime(public_path('css/freenode-article.css')) }}">
@endpush
@endonce
<header class="fn-header">
<div class="fn-header__main">
<h1><span>{{ $oArticle->created_at?->format('n月j日') }}</span><span>免费节点订阅</span></h1>
<p class="fn-header__lead">支持 Clash / V2ray / Shadowrocket / Singbox / Hiddify</p>
</div>
@if($oArticle->sThumb)
<img class="fn-header__art" src="{{ $oArticle->sThumb }}" width="192" height="160" alt="猫咪主题插画" decoding="async">
@endif
</header>
<div class="fn-overview">
@if($freeNodeLayout['count'] !== null)
<div class="fn-metric"><span>本篇收录</span><div><strong>{{ $freeNodeLayout['count'] }}</strong><span>个节点</span></div></div>
@endif
@if($freeNodeLayout['speed'])
<div class="fn-metric"><span>最高速度</span><div><strong>{{ $freeNodeLayout['speed'] }}</strong></div></div>
@endif
<div class="fn-overview__regions"><span>覆盖地区</span><div>@forelse($freeNodeLayout['regions'] as $region)<span>{{ $region }}</span>@empty<span>以订阅内容为准</span>@endforelse</div></div>
</div>
<nav class="fn-article-nav" aria-label="本页内容"><a href="#node-subscriptions">获取订阅<span>↗</span></a><a href="#node-usage">使用说明</a>@if($recommendations['enabled'])<a href="#{{ config('airport_recommendations.anchor') }}">机场推荐</a>@endif</nav>

View File

@ -0,0 +1,76 @@
<?php
namespace Tests\Feature;
use App\Models\Articles;
use App\Models\Categories;
use App\Services\ArticleRecommendations;
use Tests\TestCase;
class ArticleRecommendationsTest extends TestCase
{
private function article(string $slug, string $html, ?string $parent = null): Articles
{
$category = new Categories();$category->setRawAttributes(['sSlug' => $slug]);
$ancestor = new Categories();$ancestor->setRawAttributes(['sSlug' => $parent]);
$category->setRelation('oParent', $parent ? $ancestor : null);
$article = new Articles();$article->setRawAttributes(['sContent' => $html]);
$article->setRelation('oCategory', $category);
return $article;
}
public function test_legacy_promotion_is_replaced_without_touching_subscription_or_account_content(): void
{
foreach (['freenode', 'shadowrocketids'] as $category) {
$tail = '<p><h2>免费资源:</h2></p><p><a data-clipboard-text="https://example.invalid/private-sub">复制订阅</a></p><pre>account:example</pre>';
$html = '<p>文章开头</p><p><h2>机场推荐 - 福云:</h2></p><p>* 七折,折后8元</p><p><b><a target="__blank" href="https://fuuu.cloud">👉 点击进入机场官网:福云。</a></b></p>'.$tail;
$article = $this->article($category, $html);
$prepared = ArticleRecommendations::prepare($article);
$this->assertTrue($prepared['inline']);
$this->assertSame('<p>文章开头</p>'.ArticleRecommendations::SLOT.$tail, $prepared['body']);
$this->assertSame($html, $article->sContent);
}
}
public function test_no_promotion_link_means_no_destructive_guess_and_a_single_append(): void
{
$html = '<h2>机场推荐 - 福云:</h2><p>用户另写的内容</p><h2>订阅</h2><p>保留</p>';
$result = ArticleRecommendations::prepare($this->article('freenode', $html));
$this->assertTrue($result['enabled']);$this->assertFalse($result['inline']);
$this->assertSame($html, $result['body']);
}
public function test_all_intro_subcategories_append_and_advanced_articles_have_one_working_jump(): void
{
$html = '<div class="tutorial-body"><p>先配置订阅。</p><p>连接线路。</p></div>';
foreach (['windows','andorid','ios','mac','linux'] as $category) {
$result = ArticleRecommendations::prepare($this->article($category, $html, 'client'));
$this->assertTrue($result['enabled']);$this->assertFalse($result['inline']);$this->assertSame($html, $result['body']);
}
$article = $this->article('advanced', $html);
$first = ArticleRecommendations::prepare($article);
$this->assertSame(1, substr_count($first['body'], 'href="#airport-recommendations"'));
$again = ArticleRecommendations::prepare($this->article('advanced', $first['body']));
$this->assertSame($first['body'], $again['body']);
}
public function test_disable_and_unrelated_categories_leave_content_unchanged(): void
{
$this->assertFalse(ArticleRecommendations::prepare($this->article('news', '<p>正文</p>'))['enabled']);
config(['airport_recommendations.enabled' => false]);
$result = ArticleRecommendations::prepare($this->article('advanced', '<p>订阅</p>'));
$this->assertFalse($result['enabled']);$this->assertSame('<p>订阅</p>', $result['body']);
}
public function test_shared_view_outputs_configured_prices_coupon_and_sponsored_links_without_javascript(): void
{
$html = view('components.airport-recommendations')->render();
foreach (['福云', 'DP-LINK.NET', 'hardnerd', '>18<', '>26<', '>28<'] as $text) $this->assertStringContainsString($text, $html);
$this->assertSame(1, substr_count($html, '<section id="airport-recommendations"'));
$this->assertSame(3, substr_count($html, 'rel="sponsored nofollow noopener noreferrer"'));
$this->assertSame(2, substr_count($html, 'data-airport-coupon="clashfreenode.com"'));
config(['airport_recommendations.providers.fuc.price' => '20', 'airport_recommendations.providers.dpl.enabled' => false]);
$updated = view('components.airport-recommendations')->render();
$this->assertStringContainsString('>20<', $updated);$this->assertStringNotContainsString('DP-LINK.NET', $updated);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace Tests\Feature;
use App\Services\FreeNodeArticleLayout;
use Tests\TestCase;
class FreeNodeArticleLayoutTest extends TestCase
{
private function html(bool $singbox = true): string
{
$html = '<p>本次更新共<b>0</b>个可用节点,最高速度3M/S。</p><p>覆盖日本、日本、新加坡等多个区域。</p><p>复制下方订阅链接,添加至客户端即可正常使用。</p><!-- fna-airport-recommendations --><p><h2>免费节点订阅链接:</h2></p>';
foreach (['v2ray.txt','clash.yaml',...($singbox ? ['singbox.txt'] : [])] as $format) {
$url = 'https://example.invalid/cfn/20261031-'.$format.'?key=a&amp;b=2';
$html .= '<p><b>客户端订阅链接:</b></p><p><a data-clipboard-text="'.$url.'" href="javascript:;"></p><p><span>'.$url.'</span></a></p>';
}
return $html.'<p>下载文件后再导入客户端。</p><p>温馨提示:</p><p>保留原来的使用提示。</p>';
}
public function test_malformed_legacy_markup_keeps_exact_urls_zero_count_notes_and_region_order(): void
{
$r = FreeNodeArticleLayout::prepare($this->html());
$this->assertNotNull($r);$this->assertSame(0, $r['count']);$this->assertSame('3M/S', $r['speed']);
$this->assertSame(['日本','新加坡'], $r['regions']);
$this->assertSame(['clash','v2ray','singbox'], array_column($r['subscriptions'], 'key'));
$this->assertSame('https://example.invalid/cfn/20261031-clash.yaml?key=a&b=2', $r['subscriptions'][0]['url']);
$this->assertStringContainsString('下载文件后再导入客户端', $r['notes']);
$this->assertStringContainsString('保留原来的使用提示', $r['usage']);
$this->assertStringNotContainsString('data-clipboard-text', $r['notes']);
}
public function test_older_two_format_articles_never_invent_a_singbox_url(): void
{
$r = FreeNodeArticleLayout::prepare($this->html(false));
$this->assertSame(['clash','v2ray'], array_column($r['subscriptions'], 'key'));
}
public function test_unknown_format_or_structure_returns_original_view_fallback(): void
{
$this->assertNull(FreeNodeArticleLayout::prepare('<p>自定义正文</p>'));
$this->assertNull(FreeNodeArticleLayout::prepare(str_replace('clash.yaml', 'unknown.txt', $this->html())));
$this->assertNull(FreeNodeArticleLayout::prepare(str_replace('https://example.invalid', 'javascript:alert(1)', $this->html())));
}
public function test_unknown_intro_and_ordinary_supplementary_links_are_not_removed(): void
{
$html = '<p><em>保留新的编辑说明。</em></p>'.str_replace('<p>温馨提示:</p>', '<p><a href="/client">查看更多教程</a></p><p>温馨提示:</p>', $this->html());
$r = FreeNodeArticleLayout::prepare($html);
$this->assertStringContainsString('保留新的编辑说明', $r['intro']);
$this->assertStringContainsString('href="/client"', $r['notes']);
}
}