111 lines
6.3 KiB
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]]];
|
|
}
|
|
}
|