121 lines
7.0 KiB
PHP
121 lines
7.0 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
|
||
{
|
||
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]];
|
||
}
|
||
}
|