|
|
|
|
@ -1,142 +1,225 @@
|
|
|
|
|
# 导入JSON模块,用于将结构化数据转换为JSON格式
|
|
|
|
|
import json
|
|
|
|
|
# 导入Django工具函数,用于移除HTML标签(提取纯文本)
|
|
|
|
|
from django.utils.html import strip_tags
|
|
|
|
|
# 导入Django模板过滤器(当前未使用,预留用于文本截断)
|
|
|
|
|
from django.template.defaultfilters import truncatewords
|
|
|
|
|
# 导入插件基类,所有插件需继承此类实现标准化接口
|
|
|
|
|
from djangoblog.plugin_manage.base_plugin import BasePlugin
|
|
|
|
|
# 导入插件钩子管理模块,用于注册插件功能到指定钩子
|
|
|
|
|
from djangoblog.plugin_manage import hooks
|
|
|
|
|
# 导入博客数据模型,用于获取文章、分类、标签等数据
|
|
|
|
|
from blog.models import Article, Category, Tag
|
|
|
|
|
# 导入工具函数,用于获取博客站点的基础配置(如站点名称、关键词等)
|
|
|
|
|
from djangoblog.utils import get_blog_setting
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SeoOptimizerPlugin(BasePlugin):
|
|
|
|
|
# 插件名称,用于在插件管理界面展示
|
|
|
|
|
PLUGIN_NAME = 'SEO 优化器'
|
|
|
|
|
# 插件功能描述,说明插件的核心作用
|
|
|
|
|
PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。'
|
|
|
|
|
# 插件版本号,用于版本管理
|
|
|
|
|
PLUGIN_VERSION = '0.2.0'
|
|
|
|
|
# 插件作者信息
|
|
|
|
|
PLUGIN_AUTHOR = 'liuangliangyy'
|
|
|
|
|
|
|
|
|
|
def register_hooks(self):
|
|
|
|
|
"""注册插件钩子,将SEO处理逻辑绑定到页面<head>区域的meta标签生成环节"""
|
|
|
|
|
# 当系统渲染<head>中的meta标签时,触发dispatch_seo_generation方法
|
|
|
|
|
hooks.register('head_meta', self.dispatch_seo_generation)
|
|
|
|
|
|
|
|
|
|
def _get_article_seo_data(self, context, request, blog_setting):
|
|
|
|
|
"""
|
|
|
|
|
生成文章详情页的SEO数据(私有方法,仅内部调用)
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
context:模板上下文,包含当前页面的文章对象等数据
|
|
|
|
|
request:HTTP请求对象,用于构建绝对URL
|
|
|
|
|
blog_setting:博客站点配置信息(如站点名称、关键词)
|
|
|
|
|
返回:
|
|
|
|
|
包含SEO相关数据的字典,若上下文无有效文章对象则返回None
|
|
|
|
|
"""
|
|
|
|
|
# 从上下文获取文章对象
|
|
|
|
|
article = context.get('article')
|
|
|
|
|
# 校验是否为有效的Article实例,避免非文章页误处理
|
|
|
|
|
if not isinstance(article, Article):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# 生成描述信息:移除文章内容中的HTML标签,截取前150字符(符合多数搜索引擎的描述长度建议)
|
|
|
|
|
description = strip_tags(article.body)[:150]
|
|
|
|
|
# 生成关键词:优先使用文章标签,若无标签则使用站点默认关键词
|
|
|
|
|
keywords = ",".join([tag.name for tag in article.tags.all()]) or blog_setting.site_keywords
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 生成Open Graph(OG)协议标签,用于优化社交平台分享效果
|
|
|
|
|
meta_tags = f'''
|
|
|
|
|
<meta property="og:type" content="article"/>
|
|
|
|
|
<meta property="og:title" content="{article.title}"/>
|
|
|
|
|
<meta property="og:description" content="{description}"/>
|
|
|
|
|
<meta property="og:url" content="{request.build_absolute_uri()}"/>
|
|
|
|
|
<meta property="article:published_time" content="{article.pub_time.isoformat()}"/>
|
|
|
|
|
<meta property="article:modified_time" content="{article.last_modify_time.isoformat()}"/>
|
|
|
|
|
<meta property="article:author" content="{article.author.username}"/>
|
|
|
|
|
<meta property="article:section" content="{article.category.name}"/>
|
|
|
|
|
<meta property="og:type" content="article"/> <!-- 内容类型为文章 -->
|
|
|
|
|
<meta property="og:title" content="{article.title}"/> <!-- 社交分享标题 -->
|
|
|
|
|
<meta property="og:description" content="{description}"/> <!-- 社交分享描述 -->
|
|
|
|
|
<meta property="og:url" content="{request.build_absolute_uri()}"/> <!-- 文章完整URL -->
|
|
|
|
|
<meta property="article:published_time" content="{article.pub_time.isoformat()}"/> <!-- 发布时间(ISO标准格式) -->
|
|
|
|
|
<meta property="article:modified_time" content="{article.last_modify_time.isoformat()}"/> <!-- 最后修改时间 -->
|
|
|
|
|
<meta property="article:author" content="{article.author.username}"/> <!-- 作者信息 -->
|
|
|
|
|
<meta property="article:section" content="{article.category.name}"/> <!-- 所属分类 -->
|
|
|
|
|
'''
|
|
|
|
|
# 为文章的每个标签添加OG标签
|
|
|
|
|
for tag in article.tags.all():
|
|
|
|
|
meta_tags += f'<meta property="article:tag" content="{tag.name}"/>'
|
|
|
|
|
# 添加站点名称OG标签,关联文章所属站点
|
|
|
|
|
meta_tags += f'<meta property="og:site_name" content="{blog_setting.site_name}"/>'
|
|
|
|
|
|
|
|
|
|
# 生成JSON-LD结构化数据(帮助搜索引擎理解页面内容结构)
|
|
|
|
|
structured_data = {
|
|
|
|
|
"@context": "https://schema.org",
|
|
|
|
|
"@type": "Article",
|
|
|
|
|
"mainEntityOfPage": {"@type": "WebPage", "@id": request.build_absolute_uri()},
|
|
|
|
|
"headline": article.title,
|
|
|
|
|
"description": description,
|
|
|
|
|
"@context": "https://schema.org", # 遵循schema.org的结构化数据标准
|
|
|
|
|
"@type": "Article", # 内容类型为文章
|
|
|
|
|
"mainEntityOfPage": { # 声明页面的主体内容
|
|
|
|
|
"@type": "WebPage",
|
|
|
|
|
"@id": request.build_absolute_uri() # 页面唯一标识(完整URL)
|
|
|
|
|
},
|
|
|
|
|
"headline": article.title, # 文章标题
|
|
|
|
|
"description": description, # 文章描述
|
|
|
|
|
# 文章首图(通过绝对URL访问,增强内容丰富度)
|
|
|
|
|
"image": request.build_absolute_uri(article.get_first_image_url()),
|
|
|
|
|
"datePublished": article.pub_time.isoformat(),
|
|
|
|
|
"dateModified": article.last_modify_time.isoformat(),
|
|
|
|
|
"author": {"@type": "Person", "name": article.author.username},
|
|
|
|
|
"publisher": {"@type": "Organization", "name": blog_setting.site_name}
|
|
|
|
|
"datePublished": article.pub_time.isoformat(), # 发布时间
|
|
|
|
|
"dateModified": article.last_modify_time.isoformat(), # 修改时间
|
|
|
|
|
"author": { # 作者信息
|
|
|
|
|
"@type": "Person",
|
|
|
|
|
"name": article.author.username
|
|
|
|
|
},
|
|
|
|
|
"publisher": { # 发布机构信息
|
|
|
|
|
"@type": "Organization",
|
|
|
|
|
"name": blog_setting.site_name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
# 若文章无图片,则移除image字段(避免空值影响结构化数据有效性)
|
|
|
|
|
if not structured_data.get("image"):
|
|
|
|
|
del structured_data["image"]
|
|
|
|
|
|
|
|
|
|
# 返回整合后的文章页SEO数据
|
|
|
|
|
return {
|
|
|
|
|
"title": f"{article.title} | {blog_setting.site_name}",
|
|
|
|
|
"title": f"{article.title} | {blog_setting.site_name}", # 页面标题(文章标题+站点名称,增强品牌关联)
|
|
|
|
|
"description": description,
|
|
|
|
|
"keywords": keywords,
|
|
|
|
|
"meta_tags": meta_tags,
|
|
|
|
|
"json_ld": structured_data
|
|
|
|
|
"meta_tags": meta_tags, # 包含OG标签的HTML片段
|
|
|
|
|
"json_ld": structured_data # JSON-LD结构化数据
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _get_category_seo_data(self, context, request, blog_setting):
|
|
|
|
|
"""生成分类页面的SEO数据(私有方法)"""
|
|
|
|
|
# 从上下文获取分类名称(注:变量名tag_name可能为笔误,实际应为分类名称)
|
|
|
|
|
category_name = context.get('tag_name')
|
|
|
|
|
if not category_name:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 根据名称查询分类对象
|
|
|
|
|
category = Category.objects.filter(name=category_name).first()
|
|
|
|
|
if not category:
|
|
|
|
|
if not category: # 若分类不存在,返回None
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# 页面标题:分类名称+站点名称
|
|
|
|
|
title = f"{category.name} | {blog_setting.site_name}"
|
|
|
|
|
# 描述:使用分类名称或站点默认描述
|
|
|
|
|
description = strip_tags(category.name) or blog_setting.site_description
|
|
|
|
|
# 关键词:使用分类名称
|
|
|
|
|
keywords = category.name
|
|
|
|
|
|
|
|
|
|
# BreadcrumbList structured data for category page
|
|
|
|
|
breadcrumb_items = [{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}]
|
|
|
|
|
breadcrumb_items.append({"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()})
|
|
|
|
|
|
|
|
|
|
# 生成面包屑导航的JSON-LD数据(帮助搜索引擎理解页面在站点中的层级位置)
|
|
|
|
|
breadcrumb_items = [
|
|
|
|
|
# 首页面包屑项(位置1)
|
|
|
|
|
{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}
|
|
|
|
|
]
|
|
|
|
|
# 当前分类面包屑项(位置2)
|
|
|
|
|
breadcrumb_items.append({
|
|
|
|
|
"@type": "ListItem",
|
|
|
|
|
"position": 2,
|
|
|
|
|
"name": category.name,
|
|
|
|
|
"item": request.build_absolute_uri()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
structured_data = {
|
|
|
|
|
"@context": "https://schema.org",
|
|
|
|
|
"@type": "BreadcrumbList",
|
|
|
|
|
"itemListElement": breadcrumb_items
|
|
|
|
|
"@type": "BreadcrumbList", # 类型为面包屑列表
|
|
|
|
|
"itemListElement": breadcrumb_items # 面包屑项列表
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 返回分类页SEO数据
|
|
|
|
|
return {
|
|
|
|
|
"title": title,
|
|
|
|
|
"description": description,
|
|
|
|
|
"keywords": keywords,
|
|
|
|
|
"meta_tags": "",
|
|
|
|
|
"meta_tags": "", # 分类页暂无需额外meta标签
|
|
|
|
|
"json_ld": structured_data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _get_default_seo_data(self, context, request, blog_setting):
|
|
|
|
|
# Homepage and other default pages
|
|
|
|
|
"""生成默认页面(如首页、未匹配的页面)的SEO数据(私有方法)"""
|
|
|
|
|
# 生成网站级别的JSON-LD数据,包含站点基本信息和搜索功能描述
|
|
|
|
|
structured_data = {
|
|
|
|
|
"@context": "https://schema.org",
|
|
|
|
|
"@type": "WebSite",
|
|
|
|
|
"url": request.build_absolute_uri('/'),
|
|
|
|
|
"@type": "WebSite", # 类型为网站
|
|
|
|
|
"url": request.build_absolute_uri('/'), # 网站首页URL
|
|
|
|
|
# 描述站点的搜索功能(帮助搜索引擎识别并支持站内搜索)
|
|
|
|
|
"potentialAction": {
|
|
|
|
|
"@type": "SearchAction",
|
|
|
|
|
# 搜索结果页URL模板({search_term_string}为搜索关键词占位符)
|
|
|
|
|
"target": f"{request.build_absolute_uri('/search/')}?q={{search_term_string}}",
|
|
|
|
|
"query-input": "required name=search_term_string"
|
|
|
|
|
"query-input": "required name=search_term_string" # 声明搜索参数为必填项
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
# 返回默认页SEO数据
|
|
|
|
|
return {
|
|
|
|
|
"title": f"{blog_setting.site_name} | {blog_setting.site_description}",
|
|
|
|
|
"description": blog_setting.site_description,
|
|
|
|
|
"keywords": blog_setting.site_keywords,
|
|
|
|
|
"meta_tags": "",
|
|
|
|
|
"title": f"{blog_setting.site_name} | {blog_setting.site_description}", # 首页标题(站点名称+描述)
|
|
|
|
|
"description": blog_setting.site_description, # 站点描述
|
|
|
|
|
"keywords": blog_setting.site_keywords, # 站点默认关键词
|
|
|
|
|
"meta_tags": "", # 默认页无需额外meta标签
|
|
|
|
|
"json_ld": structured_data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def dispatch_seo_generation(self, metas, context):
|
|
|
|
|
"""
|
|
|
|
|
分发SEO数据生成逻辑(核心方法):根据当前页面类型调用对应的数据生成方法
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
metas:原始的meta标签内容(未使用,预留用于扩展)
|
|
|
|
|
context:模板上下文,包含请求对象和页面数据
|
|
|
|
|
返回:
|
|
|
|
|
生成的完整SEO标签(包含title、meta标签、JSON-LD脚本等)
|
|
|
|
|
"""
|
|
|
|
|
# 从上下文获取请求对象,用于判断页面类型和构建URL
|
|
|
|
|
request = context.get('request')
|
|
|
|
|
if not request:
|
|
|
|
|
if not request: # 若无请求对象,返回原始内容
|
|
|
|
|
return metas
|
|
|
|
|
|
|
|
|
|
# 获取当前视图的名称(通过Django的URL解析器),用于区分页面类型
|
|
|
|
|
view_name = request.resolver_match.view_name
|
|
|
|
|
# 获取博客站点配置
|
|
|
|
|
blog_setting = get_blog_setting()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 根据页面类型(视图名称)生成对应的SEO数据
|
|
|
|
|
seo_data = None
|
|
|
|
|
if view_name == 'blog:detailbyid':
|
|
|
|
|
# 文章详情页:调用文章SEO数据生成方法
|
|
|
|
|
seo_data = self._get_article_seo_data(context, request, blog_setting)
|
|
|
|
|
elif view_name == 'blog:category_detail':
|
|
|
|
|
# 分类详情页:调用分类SEO数据生成方法
|
|
|
|
|
seo_data = self._get_category_seo_data(context, request, blog_setting)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 若未匹配到特定页面类型,使用默认SEO数据
|
|
|
|
|
if not seo_data:
|
|
|
|
|
seo_data = self._get_default_seo_data(context, request, blog_setting)
|
|
|
|
|
seo_data = self._get_default_seo_data(context, request, blog_setting)
|
|
|
|
|
|
|
|
|
|
# 生成JSON-LD脚本标签:将结构化数据转换为JSON字符串,确保非ASCII字符正常显示
|
|
|
|
|
json_ld_script = f'<script type="application/ld+json">{json.dumps(seo_data.get("json_ld", {}), ensure_ascii=False, indent=4)}</script>'
|
|
|
|
|
|
|
|
|
|
# 拼接所有SEO相关标签并返回,最终会被插入到页面的<head>区域
|
|
|
|
|
return f"""
|
|
|
|
|
<title>{seo_data.get("title", "")}</title>
|
|
|
|
|
<meta name="description" content="{seo_data.get("description", "")}">
|
|
|
|
|
<meta name="keywords" content="{seo_data.get("keywords", "")}">
|
|
|
|
|
{seo_data.get("meta_tags", "")}
|
|
|
|
|
{json_ld_script}
|
|
|
|
|
<title>{seo_data.get("title", "")}</title> <!-- 页面标题(SEO核心要素) -->
|
|
|
|
|
<meta name="description" content="{seo_data.get("description", "")}"> <!-- 描述标签(影响搜索结果展示) -->
|
|
|
|
|
<meta name="keywords" content="{seo_data.get("keywords", "")}"> <!-- 关键词标签 -->
|
|
|
|
|
{seo_data.get("meta_tags", "")} <!-- 额外meta标签(如Open Graph) -->
|
|
|
|
|
{json_ld_script} <!-- JSON-LD结构化数据脚本(提升搜索结果丰富度) -->
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
plugin = SeoOptimizerPlugin()
|
|
|
|
|
|
|
|
|
|
# 实例化插件,使插件系统能够识别并加载该插件
|
|
|
|
|
plugin = SeoOptimizerPlugin()
|