fn_b/app/Services/Seo.php

111 lines
6.3 KiB
PHP

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