fn_b/app/Services/Seo.php

121 lines
7.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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
{
if (ResourcePages::supports($category->sSlug)) return ResourcePages::entryPath($category->sSlug);
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
{
// 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;
return true;
}
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]]];
}
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]];
}
}