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