M4: 插件框架(钩子系统/ZIP安装/市场客户端)+ blog-seo 插件(JSON-LD/OG/llms.txt/Markdown导出)+ 后台插件/主题管理页
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"title": "SEO & GEO",
|
||||
"version": "1.0.0",
|
||||
"description": "完善 SEO(结构化数据/OG 标签)+ GEO(llms.txt / Markdown 导出,面向 AI 搜索引擎优化)",
|
||||
"author": "LaraLog",
|
||||
"type": "core",
|
||||
"provider": "Plugins\\Neatstudio\\BlogSeo\\ServiceProvider"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Neatstudio\BlogSeo\Http\SeoController;
|
||||
|
||||
// GEO:面向 AI 搜索引擎(LLM 爬虫)
|
||||
Route::get('/llms.txt', [SeoController::class, 'llmsTxt'])->name('geo.llms');
|
||||
Route::get('/posts/{slug}.md', [SeoController::class, 'postMarkdown'])->where('slug', '[A-Za-z0-9\-]+')->name('geo.post.markdown');
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
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'));
|
||||
|
||||
$route = $request->route();
|
||||
|
||||
if ($route && $route->getName() === 'posts.show') {
|
||||
$slug = $route->parameter('slug');
|
||||
$post = Cache::remember('seo.post.'.$slug, now()->addHour(), function () use ($slug) {
|
||||
return Post::query()->where('slug', $slug)->first();
|
||||
});
|
||||
|
||||
if ($post && $post->status === 'published') {
|
||||
return [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'BlogPosting',
|
||||
'headline' => $post->title,
|
||||
'url' => route('posts.show', $post->slug ?: $post->id),
|
||||
'datePublished' => $post->published_at?->toIso8601String(),
|
||||
'dateModified' => $post->updated_at?->toIso8601String(),
|
||||
'author' => ['@type' => 'Person', 'name' => $post->author?->name ?? $siteName],
|
||||
'publisher' => ['@type' => 'Organization', 'name' => $siteName],
|
||||
'description' => Str::limit(strip_tags($post->excerpt_or_fallback), 200),
|
||||
'mainEntityOfPage' => route('posts.show', $post->slug ?: $post->id),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'WebSite',
|
||||
'name' => $siteName,
|
||||
'url' => url('/'),
|
||||
];
|
||||
}
|
||||
|
||||
private function ogTags(Request $request, array $data): string
|
||||
{
|
||||
$siteName = blog_setting('site_name', config('blog.name'));
|
||||
$isArticle = ($data['@type'] ?? '') === 'BlogPosting';
|
||||
|
||||
$tags = [
|
||||
'<meta property="og:site_name" content="'.e($siteName).'">',
|
||||
'<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="summary_large_image">',
|
||||
'<meta name="twitter:title" content="'.e($data['headline'] ?? $siteName).'">',
|
||||
];
|
||||
|
||||
if ($isArticle) {
|
||||
$tags[] = '<meta property="article:published_time" content="'.e($data['datePublished'] ?? '').'">';
|
||||
}
|
||||
|
||||
return implode("\n", $tags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\BlogSeo\Http;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SeoController
|
||||
{
|
||||
/**
|
||||
* GEO:/llms.txt — 面向 LLM 的站点索引
|
||||
*/
|
||||
public function llmsTxt()
|
||||
{
|
||||
$siteName = blog_setting('site_name', config('blog.name'));
|
||||
$siteDescription = blog_setting('site_description', config('blog.description'));
|
||||
|
||||
$posts = Post::published()->latest('published_at')->limit(200)->get();
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '# '.$siteName;
|
||||
$lines[] = '';
|
||||
$lines[] = '> '.$siteDescription;
|
||||
$lines[] = '';
|
||||
$lines[] = '## 文章(Markdown 全文可访问 /posts/{slug}.md)';
|
||||
$lines[] = '';
|
||||
|
||||
foreach ($posts as $post) {
|
||||
$lines[] = '- ['.$post->title.']('.route('posts.show', $post->slug ?: $post->id).') — '.Str::limit(strip_tags($post->excerpt_or_fallback), 120);
|
||||
}
|
||||
|
||||
return response(implode("\n", $lines)."\n")
|
||||
->header('Content-Type', 'text/plain; charset=utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* GEO:/posts/{slug}.md — 面向 AI 爬虫的纯 Markdown 导出
|
||||
*/
|
||||
public function postMarkdown(Request $request, string $slug)
|
||||
{
|
||||
$post = Post::query()->where('slug', $slug)->firstOrFail();
|
||||
|
||||
if ($post->status !== 'published') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$body = $post->content_format === 'markdown' ? $post->content : $this->htmlToMarkdown($post->content);
|
||||
|
||||
$out = "# {$post->title}\n\n";
|
||||
if ($post->excerpt) {
|
||||
$out .= '> '.$post->excerpt."\n\n";
|
||||
}
|
||||
$out .= $body."\n";
|
||||
|
||||
return response($out)->header('Content-Type', 'text/markdown; charset=utf-8');
|
||||
}
|
||||
|
||||
private function htmlToMarkdown(string $html): string
|
||||
{
|
||||
$converter = new \League\HTMLToMarkdown\HtmlConverter([
|
||||
'strip_tags' => false,
|
||||
'hard_break' => true,
|
||||
]);
|
||||
|
||||
return $converter->convert($html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\BlogSeo;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use App\Blog\Support\PluginServiceProvider;
|
||||
use Plugins\Neatstudio\BlogSeo\Http\Middleware\SeoMiddleware;
|
||||
use Plugins\Neatstudio\BlogSeo\Http\SeoController;
|
||||
|
||||
class ServiceProvider extends PluginServiceProvider
|
||||
{
|
||||
protected function boot(PluginManager $manager): void
|
||||
{
|
||||
$this->loadRoutes(__DIR__.'/../routes/web.php');
|
||||
|
||||
app('router')->pushMiddlewareToGroup('web', SeoMiddleware::class);
|
||||
|
||||
$manager->addFilter('seo.structured_data', function (array $data) {
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user