diff --git a/blog/views.py b/blog/views.py index 4af9242..fc5e346 100644 --- a/blog/views.py +++ b/blog/views.py @@ -17,6 +17,8 @@ from haystack.views import SearchView from blog.models import Article, Category, LinkShowType, Links, Tag from comments.forms import CommentForm +from djangoblog.plugin_manage import hooks +from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME from djangoblog.utils import cache, get_blog_setting, get_sha256 logger = logging.getLogger(__name__) @@ -154,7 +156,14 @@ class ArticleDetailView(DetailView): kwargs['next_article'] = self.object.next_article kwargs['prev_article'] = self.object.prev_article - return super(ArticleDetailView, self).get_context_data(**kwargs) + context = super(ArticleDetailView, self).get_context_data(**kwargs) + article = self.object + # Action Hook, 通知插件"文章详情已获取" + hooks.run_action('after_article_body_get', article=article, request=self.request) + # Filter Hook, 允许插件修改文章正文 + article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article, request=self.request) + + return context class CategoryDetailView(ArticleListView): diff --git a/djangoblog/plugin_manage/base_plugin.py b/djangoblog/plugin_manage/base_plugin.py new file mode 100644 index 0000000..2b4be5c --- /dev/null +++ b/djangoblog/plugin_manage/base_plugin.py @@ -0,0 +1,41 @@ +import logging + +logger = logging.getLogger(__name__) + + +class BasePlugin: + # 插件元数据 + PLUGIN_NAME = None + PLUGIN_DESCRIPTION = None + PLUGIN_VERSION = None + + def __init__(self): + if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]): + raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.") + self.init_plugin() + self.register_hooks() + + def init_plugin(self): + """ + 插件初始化逻辑 + 子类可以重写此方法来实现特定的初始化操作 + """ + logger.info(f'{self.PLUGIN_NAME} initialized.') + + def register_hooks(self): + """ + 注册插件钩子 + 子类可以重写此方法来注册特定的钩子 + """ + pass + + def get_plugin_info(self): + """ + 获取插件信息 + :return: 包含插件元数据的字典 + """ + return { + 'name': self.PLUGIN_NAME, + 'description': self.PLUGIN_DESCRIPTION, + 'version': self.PLUGIN_VERSION + } diff --git a/djangoblog/plugin_manage/hook_constants.py b/djangoblog/plugin_manage/hook_constants.py new file mode 100644 index 0000000..6685b7c --- /dev/null +++ b/djangoblog/plugin_manage/hook_constants.py @@ -0,0 +1,7 @@ +ARTICLE_DETAIL_LOAD = 'article_detail_load' +ARTICLE_CREATE = 'article_create' +ARTICLE_UPDATE = 'article_update' +ARTICLE_DELETE = 'article_delete' + +ARTICLE_CONTENT_HOOK_NAME = "the_content" + diff --git a/djangoblog/plugin_manage/hooks.py b/djangoblog/plugin_manage/hooks.py new file mode 100644 index 0000000..d712540 --- /dev/null +++ b/djangoblog/plugin_manage/hooks.py @@ -0,0 +1,44 @@ +import logging + +logger = logging.getLogger(__name__) + +_hooks = {} + + +def register(hook_name: str, callback: callable): + """ + 注册一个钩子回调。 + """ + if hook_name not in _hooks: + _hooks[hook_name] = [] + _hooks[hook_name].append(callback) + logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") + + +def run_action(hook_name: str, *args, **kwargs): + """ + 执行一个 Action Hook。 + 它会按顺序执行所有注册到该钩子上的回调函数。 + """ + if hook_name in _hooks: + logger.debug(f"Running action hook '{hook_name}'") + for callback in _hooks[hook_name]: + try: + callback(*args, **kwargs) + except Exception as e: + logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + + +def apply_filters(hook_name: str, value, *args, **kwargs): + """ + 执行一个 Filter Hook。 + 它会把 value 依次传递给所有注册的回调函数进行处理。 + """ + if hook_name in _hooks: + logger.debug(f"Applying filter hook '{hook_name}'") + for callback in _hooks[hook_name]: + try: + value = callback(value, *args, **kwargs) + except Exception as e: + logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + return value diff --git a/djangoblog/settings.py b/djangoblog/settings.py index fa970b5..9b527da 100644 --- a/djangoblog/settings.py +++ b/djangoblog/settings.py @@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/1.10/ref/settings/ """ import os import sys +from pathlib import Path from django.utils.translation import gettext_lazy as _ @@ -20,9 +21,8 @@ def env_to_bool(env, default): return default if str_val is None else str_val == 'True' -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) - -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ @@ -330,3 +330,20 @@ if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', }, } + +# Plugin System +PLUGINS_DIR = BASE_DIR / 'plugins' +ACTIVE_PLUGINS = [ + 'article_copyright', + 'reading_time', + 'external_links', +] + +# 加载插件 +for plugin_dir in os.listdir(PLUGINS_DIR): + plugin_path = os.path.join(PLUGINS_DIR, plugin_dir) + if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, '__init__.py')): + try: + __import__(f'plugins.{plugin_dir}.plugin') + except ImportError as e: + print("Failed to import plugin:", plugin_dir, e) diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/plugins/article_copyright/__init__.py b/plugins/article_copyright/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/plugins/article_copyright/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/plugins/article_copyright/plugin.py b/plugins/article_copyright/plugin.py new file mode 100644 index 0000000..317fed2 --- /dev/null +++ b/plugins/article_copyright/plugin.py @@ -0,0 +1,32 @@ +from djangoblog.plugin_manage.base_plugin import BasePlugin +from djangoblog.plugin_manage import hooks +from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME + + +class ArticleCopyrightPlugin(BasePlugin): + PLUGIN_NAME = '文章结尾版权声明' + PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。' + PLUGIN_VERSION = '0.2.0' + PLUGIN_AUTHOR = 'liangliangyy' + + # 2. 实现 register_hooks 方法,专门用于注册钩子 + def register_hooks(self): + # 在这里将插件的方法注册到指定的钩子上 + hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_copyright_to_content) + + def add_copyright_to_content(self, content, *args, **kwargs): + """ + 这个方法会被注册到 'the_content' 过滤器钩子上。 + 它接收原始内容,并返回添加了版权信息的新内容。 + """ + article = kwargs.get('article') + if not article: + return content + + copyright_info = f"\n
本文由 {article.author.username} 原创,转载请注明出处。
" + return content + copyright_info + + +# 3. 实例化插件。 +# 这会自动调用 BasePlugin.__init__,然后 BasePlugin.__init__ 会调用我们上面定义的 register_hooks 方法。 +plugin = ArticleCopyrightPlugin() diff --git a/plugins/external_links/__init__.py b/plugins/external_links/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/plugins/external_links/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/plugins/external_links/plugin.py b/plugins/external_links/plugin.py new file mode 100644 index 0000000..5b2ef14 --- /dev/null +++ b/plugins/external_links/plugin.py @@ -0,0 +1,48 @@ +import re +from urllib.parse import urlparse +from djangoblog.plugin_manage.base_plugin import BasePlugin +from djangoblog.plugin_manage import hooks +from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME + + +class ExternalLinksPlugin(BasePlugin): + PLUGIN_NAME = '外部链接处理器' + PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。' + PLUGIN_VERSION = '0.1.0' + PLUGIN_AUTHOR = 'liangliangyy' + + def register_hooks(self): + hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.process_external_links) + + def process_external_links(self, content, *args, **kwargs): + from djangoblog.utils import get_current_site + site_domain = get_current_site().domain + + # 正则表达式查找所有 标签 + link_pattern = re.compile(r'(]*?\s+)?href=")([^"]*)(".*?/a>)', re.IGNORECASE) + + def replacer(match): + # match.group(1) 是 ... + href = match.group(2) + + # 如果链接已经有 target 属性,则不处理 + if 'target=' in match.group(0).lower(): + return match.group(0) + + # 解析链接 + parsed_url = urlparse(href) + + # 如果链接是外部的 (有域名且域名不等于当前网站域名) + if parsed_url.netloc and parsed_url.netloc != site_domain: + # 添加 target 和 rel 属性 + return f'{match.group(1)}{href}" target="_blank" rel="noopener noreferrer"{match.group(3)}' + + # 否则返回原样 + return match.group(0) + + return link_pattern.sub(replacer, content) + + +plugin = ExternalLinksPlugin() diff --git a/plugins/reading_time/__init__.py b/plugins/reading_time/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/plugins/reading_time/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/plugins/reading_time/plugin.py b/plugins/reading_time/plugin.py new file mode 100644 index 0000000..35f9db1 --- /dev/null +++ b/plugins/reading_time/plugin.py @@ -0,0 +1,43 @@ +import math +import re +from djangoblog.plugin_manage.base_plugin import BasePlugin +from djangoblog.plugin_manage import hooks +from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME + + +class ReadingTimePlugin(BasePlugin): + PLUGIN_NAME = '阅读时间预测' + PLUGIN_DESCRIPTION = '估算文章阅读时间并显示在文章开头。' + PLUGIN_VERSION = '0.1.0' + PLUGIN_AUTHOR = 'liangliangyy' + + def register_hooks(self): + hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_reading_time) + + def add_reading_time(self, content, *args, **kwargs): + """ + 计算阅读时间并添加到内容开头。 + """ + # 移除HTML标签和空白字符,以获得纯文本 + clean_content = re.sub(r'<[^>]*>', '', content) + clean_content = clean_content.strip() + + # 中文和英文单词混合计数的一个简单方法 + # 匹配中文字符或连续的非中文字符(视为单词) + words = re.findall(r'[\u4e00-\u9fa5]|\w+', clean_content) + word_count = len(words) + + # 按平均每分钟200字的速度计算 + reading_speed = 200 + reading_minutes = math.ceil(word_count / reading_speed) + + # 如果阅读时间少于1分钟,则显示为1分钟 + if reading_minutes < 1: + reading_minutes = 1 + + reading_time_html = f'预计阅读时间:{reading_minutes} 分钟
' + + return reading_time_html + content + + +plugin = ReadingTimePlugin() \ No newline at end of file