162 lines
6.2 KiB
PHP
162 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
namespace Plugins\Neatstudio\BlogSeo\Http\Middleware;
|
|
|
|
use App\Models\Post;
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* SEO 增强中间件:注入 JSON-LD 结构化数据与 OG 标签。
|
|
* 主题无关:直接改写响应 HTML 的 </head>。
|
|
*/
|
|
class SeoMiddleware
|
|
{
|
|
public function handle(Request $request, Closure $next): Response
|
|
{
|
|
$response = $next($request);
|
|
|
|
if (! $this->shouldEnhance($response)) {
|
|
return $response;
|
|
}
|
|
|
|
$content = $response->getContent();
|
|
$data = $this->structuredData($request);
|
|
|
|
if (empty($data)) {
|
|
return $response;
|
|
}
|
|
|
|
$jsonLd = '<script type="application/ld+json">'.json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).'</script>';
|
|
$og = $this->ogTags($request, $data);
|
|
|
|
$content = preg_replace('/<\/head>/', $jsonLd."\n".$og."\n</head>", $content, 1);
|
|
|
|
$response->setContent($content);
|
|
|
|
return $response;
|
|
}
|
|
|
|
private function shouldEnhance(Response $response): bool
|
|
{
|
|
$contentType = $response->headers->get('Content-Type', '');
|
|
|
|
// 跳过后台与静态资源
|
|
if (str_starts_with(request()->path(), 'admin')) {
|
|
return false;
|
|
}
|
|
|
|
return $response->getStatusCode() === 200
|
|
&& str_contains($contentType, 'text/html');
|
|
}
|
|
|
|
private function structuredData(Request $request): array
|
|
{
|
|
$siteName = blog_setting('site_name', config('blog.name'));
|
|
$sameAs = $this->socialLinks();
|
|
|
|
$route = $request->route();
|
|
|
|
if ($route && $route->getName() === 'posts.show') {
|
|
$id = $route->parameter('id') ?? $route->parameter('slug');
|
|
$post = Cache::remember('seo.post.'.$id, now()->addHour(), function () use ($id) {
|
|
return Post::query()->where('id', (int) $id)->orWhere('slug', (string) $id)->first();
|
|
});
|
|
|
|
if ($post && $post->status === 'published') {
|
|
$data = [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'BlogPosting',
|
|
'headline' => $post->title,
|
|
'url' => route('posts.show', $post->id),
|
|
'datePublished' => $post->published_at?->toIso8601String(),
|
|
'dateModified' => $post->updated_at?->toIso8601String(),
|
|
'author' => ['@type' => 'Person', 'name' => $post->author?->name ?? $siteName] + ($sameAs ? ['sameAs' => $sameAs] : []),
|
|
'publisher' => ['@type' => 'Organization', 'name' => $siteName, 'url' => url('/')] + ($sameAs ? ['sameAs' => $sameAs] : []),
|
|
'description' => Str::limit(strip_tags($post->excerpt_or_fallback), 200),
|
|
'mainEntityOfPage' => route('posts.show', $post->id),
|
|
];
|
|
|
|
if ($cover = $post->coverImageUrl()) {
|
|
$data['image'] = [$cover];
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
}
|
|
|
|
// 分类页:面包屑结构化数据(GEO 友好)
|
|
if ($route && $route->getName() === 'category.show') {
|
|
$cid = $route->parameter('cid') ?? $route->parameter('slug');
|
|
$category = Cache::remember('seo.category.'.$cid, now()->addHour(), function () use ($cid) {
|
|
return \App\Models\Category::query()->where('id', (int) $cid)->orWhere('slug', (string) $cid)->first();
|
|
});
|
|
|
|
if ($category) {
|
|
return [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'BreadcrumbList',
|
|
'itemListElement' => [
|
|
['@type' => 'ListItem', 'position' => 1, 'name' => $siteName, 'item' => url('/')],
|
|
['@type' => 'ListItem', 'position' => 2, 'name' => $category->name, 'item' => route('category.show', $category->id)],
|
|
],
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'WebSite',
|
|
'name' => $siteName,
|
|
'url' => url('/'),
|
|
] + ($sameAs ? ['sameAs' => $sameAs] : []);
|
|
}
|
|
|
|
/**
|
|
* 后台配置的社交账号 -> schema sameAs(微博/公众号/抖音/小红书/GitHub 等全平台)。
|
|
*/
|
|
private function socialLinks(): array
|
|
{
|
|
return \App\Blog\Support\SocialLinks::sameAs();
|
|
}
|
|
|
|
private function ogTags(Request $request, array $data): string
|
|
{
|
|
$siteName = blog_setting('site_name', config('blog.name'));
|
|
$isArticle = ($data['@type'] ?? '') === 'BlogPosting';
|
|
$image = $data['image'][0] ?? null;
|
|
|
|
$tags = [
|
|
'<meta property="og:site_name" content="'.e($siteName).'">',
|
|
'<meta property="og:locale" content="zh_CN">',
|
|
'<meta property="og:type" content="'.($isArticle ? 'article' : 'website').'">',
|
|
'<meta property="og:title" content="'.e($data['headline'] ?? $siteName).'">',
|
|
'<meta property="og:url" content="'.e(url()->current()).'">',
|
|
'<meta property="og:description" content="'.e($data['description'] ?? blog_setting('site_description', '')).'">',
|
|
'<meta name="twitter:card" content="'.($image ? 'summary_large_image' : 'summary').'">',
|
|
'<meta name="twitter:title" content="'.e($data['headline'] ?? $siteName).'">',
|
|
'<meta name="twitter:description" content="'.e($data['description'] ?? blog_setting('site_description', '')).'">',
|
|
];
|
|
|
|
if ($image) {
|
|
$tags[] = '<meta property="og:image" content="'.e($image).'">';
|
|
$tags[] = '<meta property="og:image:alt" content="'.e($data['headline'] ?? $siteName).'">';
|
|
$tags[] = '<meta name="twitter:image" content="'.e($image).'">';
|
|
}
|
|
|
|
if ($isArticle) {
|
|
$tags[] = '<meta property="article:published_time" content="'.e($data['datePublished'] ?? '').'">';
|
|
$tags[] = '<meta property="article:modified_time" content="'.e($data['dateModified'] ?? '').'">';
|
|
}
|
|
|
|
return implode("\n", $tags);
|
|
}
|
|
}
|