diff --git a/src/.dockerignore b/src/.dockerignore index 2818c38..bd68a58 100644 --- a/src/.dockerignore +++ b/src/.dockerignore @@ -8,4 +8,5 @@ settings_production.py *.md docs/ logs/ -static/ \ No newline at end of file +static/ +.github/ diff --git a/src/.github/workflows/codeql-analysis.yml b/src/.github/workflows/codeql-analysis.yml index 6b76522..52775e0 100644 --- a/src/.github/workflows/codeql-analysis.yml +++ b/src/.github/workflows/codeql-analysis.yml @@ -38,10 +38,12 @@ jobs: uses: actions/checkout@v3 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - + uses: github/codeql-action/init@v3 + with: + languages: python + - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 \ No newline at end of file + uses: github/codeql-action/analyze@v3 \ No newline at end of file diff --git a/src/.github/workflows/deploy-master.yml b/src/.github/workflows/deploy-master.yml new file mode 100644 index 0000000..c07a326 --- /dev/null +++ b/src/.github/workflows/deploy-master.yml @@ -0,0 +1,176 @@ +name: 自动部署到生产环境 + +on: + workflow_run: + workflows: ["Django CI"] + types: + - completed + branches: + - master + workflow_dispatch: + inputs: + environment: + description: '部署环境' + required: true + default: 'production' + type: choice + options: + - production + - staging + image_tag: + description: '镜像标签 (默认: latest)' + required: false + default: 'latest' + type: string + skip_tests: + description: '跳过测试直接部署' + required: false + default: false + type: boolean + +env: + REGISTRY: registry.cn-shenzhen.aliyuncs.com + IMAGE_NAME: liangliangyy/djangoblog + NAMESPACE: djangoblog + +jobs: + deploy: + name: 构建镜像并部署到生产环境 + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置部署参数 + id: deploy-params + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "trigger_type=手动触发" >> $GITHUB_OUTPUT + echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT + echo "image_tag=${{ github.event.inputs.image_tag }}" >> $GITHUB_OUTPUT + echo "skip_tests=${{ github.event.inputs.skip_tests }}" >> $GITHUB_OUTPUT + else + echo "trigger_type=CI自动触发" >> $GITHUB_OUTPUT + echo "environment=production" >> $GITHUB_OUTPUT + echo "image_tag=latest" >> $GITHUB_OUTPUT + echo "skip_tests=false" >> $GITHUB_OUTPUT + fi + + - name: 显示部署信息 + run: | + echo "🚀 部署信息:" + echo " 触发方式: ${{ steps.deploy-params.outputs.trigger_type }}" + echo " 部署环境: ${{ steps.deploy-params.outputs.environment }}" + echo " 镜像标签: ${{ steps.deploy-params.outputs.image_tag }}" + echo " 跳过测试: ${{ steps.deploy-params.outputs.skip_tests }}" + + - name: 设置Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 登录私有镜像仓库 + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: 提取镜像元数据 + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=sha,prefix={{branch}}- + type=raw,value=${{ steps.deploy-params.outputs.image_tag }} + + - name: 构建并推送Docker镜像 + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 + + - name: 部署到生产服务器 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.PRODUCTION_HOST }} + username: ${{ secrets.PRODUCTION_USER }} + key: ${{ secrets.PRODUCTION_SSH_KEY }} + port: ${{ secrets.PRODUCTION_PORT || 22 }} + script: | + echo "🚀 开始部署 DjangoBlog..." + + # 检查kubectl是否可用 + if ! command -v kubectl &> /dev/null; then + echo "❌ 错误: kubectl 未安装或不在PATH中" + exit 1 + fi + + # 检查命名空间是否存在 + if ! kubectl get namespace ${{ env.NAMESPACE }} &> /dev/null; then + echo "❌ 错误: 命名空间 ${{ env.NAMESPACE }} 不存在" + exit 1 + fi + + # 更新deployment镜像 + echo "📦 更新deployment镜像为: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.deploy-params.outputs.image_tag }}" + kubectl set image deployment/djangoblog \ + djangoblog=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.deploy-params.outputs.image_tag }} \ + -n ${{ env.NAMESPACE }} + + # 重启deployment + echo "🔄 重启deployment..." + kubectl -n ${{ env.NAMESPACE }} rollout restart deployment djangoblog + + # 等待deployment完成 + echo "⏳ 等待deployment完成..." + kubectl rollout status deployment/djangoblog -n ${{ env.NAMESPACE }} --timeout=300s + + # 检查deployment状态 + echo "✅ 检查deployment状态..." + kubectl get deployment djangoblog -n ${{ env.NAMESPACE }} + kubectl get pods -l app=djangoblog -n ${{ env.NAMESPACE }} + + echo "🎉 部署完成!" + + - name: 发送部署通知 + if: always() + run: | + # 设置通知内容 + if [ "${{ job.status }}" = "success" ]; then + TITLE="✅ DjangoBlog部署成功" + STATUS="成功" + else + TITLE="❌ DjangoBlog部署失败" + STATUS="失败" + fi + + MESSAGE="部署状态: ${STATUS} + 触发方式: ${{ steps.deploy-params.outputs.trigger_type }} + 部署环境: ${{ steps.deploy-params.outputs.environment }} + 镜像标签: ${{ steps.deploy-params.outputs.image_tag }} + 提交者: ${{ github.actor }} + 时间: $(date '+%Y-%m-%d %H:%M:%S') + + 查看详情: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + # 发送Server酱通知 + if [ -n "${{ secrets.SERVERCHAN_KEY }}" ]; then + echo "{\"title\": \"${TITLE}\", \"desp\": \"${MESSAGE}\"}" > /tmp/serverchan.json + + curl --location "https://sctapi.ftqq.com/${{ secrets.SERVERCHAN_KEY }}.send" \ + --header "Content-Type: application/json" \ + --data @/tmp/serverchan.json \ + --silent > /dev/null + + rm -f /tmp/serverchan.json + echo "📱 部署通知已发送" + fi \ No newline at end of file diff --git a/src/.github/workflows/django.yml b/src/.github/workflows/django.yml index bf23242..ebe7953 100644 --- a/src/.github/workflows/django.yml +++ b/src/.github/workflows/django.yml @@ -9,7 +9,6 @@ on: - '**/*.md' - '**/*.css' - '**/*.js' - - '**/*.yml' pull_request: branches: - master @@ -18,58 +17,61 @@ on: - '**/*.md' - '**/*.css' - '**/*.js' - - '**/*.yml' jobs: - build-normal: + test: runs-on: ubuntu-latest strategy: - max-parallel: 4 + fail-fast: false matrix: - python-version: [ "3.8", "3.9","3.10","3.11" ] + include: + # 标准测试 - Python 3.10 + - python-version: "3.10" + test-type: "standard" + database: "mysql" + elasticsearch: false + coverage: false + + # 标准测试 - Python 3.11 + - python-version: "3.11" + test-type: "standard" + database: "mysql" + elasticsearch: false + coverage: false + + # 完整测试 - 包含ES和覆盖率 + - python-version: "3.11" + test-type: "full" + database: "mysql" + elasticsearch: true + coverage: true + + # Docker构建测试 + - python-version: "3.11" + test-type: "docker" + database: "none" + elasticsearch: false + coverage: false + name: Test (${{ matrix.test-type }}, Python ${{ matrix.python-version }}) + steps: - - name: Start MySQL - uses: samin/mysql-action@v1.3 - with: - host port: 3306 - container port: 3306 - character set server: utf8mb4 - collation server: utf8mb4_general_ci - mysql version: latest - mysql root password: root - mysql database: djangoblog - mysql user: root - mysql password: root - - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run Tests - env: - DJANGO_MYSQL_PASSWORD: root - DJANGO_MYSQL_HOST: 127.0.0.1 + - name: Checkout代码 + uses: actions/checkout@v4 + + - name: 设置测试信息 + id: test-info run: | - python manage.py makemigrations - python manage.py migrate - python manage.py test - - build-with-es: - runs-on: ubuntu-latest - strategy: - max-parallel: 4 - matrix: - python-version: [ "3.8", "3.9","3.10","3.11" ] - - steps: - - name: Start MySQL + echo "test_name=${{ matrix.test-type }}-py${{ matrix.python-version }}" >> $GITHUB_OUTPUT + if [ "${{ matrix.test-type }}" = "docker" ]; then + echo "skip_python_setup=true" >> $GITHUB_OUTPUT + else + echo "skip_python_setup=false" >> $GITHUB_OUTPUT + fi + + # MySQL数据库设置 (只有需要数据库的测试才执行) + - name: 启动MySQL数据库 + if: matrix.database == 'mysql' uses: samin/mysql-action@v1.3 with: host port: 3306 @@ -81,56 +83,289 @@ jobs: mysql database: djangoblog mysql user: root mysql password: root - - - name: Configure sysctl limits + + # Elasticsearch设置 (只有完整测试才执行) + - name: 配置系统参数 (ES) + if: matrix.elasticsearch == true run: | sudo swapoff -a sudo sysctl -w vm.swappiness=1 sudo sysctl -w fs.file-max=262144 sudo sysctl -w vm.max_map_count=262144 - - - uses: miyataka/elasticsearch-github-actions@1 + + - name: 启动Elasticsearch + if: matrix.elasticsearch == true + uses: miyataka/elasticsearch-github-actions@1 with: stack-version: '7.12.1' - plugins: 'https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip' - - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + plugins: 'https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-7.12.1.zip' + + # Python环境设置 (Docker测试跳过) + - name: 设置Python ${{ matrix.python-version }} + if: steps.test-info.outputs.skip_python_setup == 'false' + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - - name: Install Dependencies + cache-dependency-path: 'requirements.txt' + + # 多层缓存策略优化 + - name: 缓存Python依赖 + if: steps.test-info.outputs.skip_python_setup == 'false' + uses: actions/cache@v4 + with: + path: | + ~/.cache/pip + .pytest_cache + key: ${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}- + ${{ runner.os }}-python-${{ matrix.python-version }}- + ${{ runner.os }}-python- + + # Django缓存优化 (测试数据库等) + - name: 缓存Django资源 + if: matrix.test-type != 'docker' + uses: actions/cache@v4 + with: + path: | + .coverage* + htmlcov/ + .django_cache/ + key: ${{ runner.os }}-django-${{ matrix.test-type }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-django-${{ matrix.test-type }}- + ${{ runner.os }}-django- + + - name: 安装Python依赖 + if: steps.test-info.outputs.skip_python_setup == 'false' run: | - python -m pip install --upgrade pip + echo "📦 安装Python依赖 (Python ${{ matrix.python-version }})" + python -m pip install --upgrade pip setuptools wheel + + # 安装基础依赖 pip install -r requirements.txt - - name: Run Tests + + # 根据测试类型安装额外依赖 + if [ "${{ matrix.coverage }}" = "true" ]; then + echo "📊 安装覆盖率工具" + pip install coverage[toml] + fi + + # 验证关键依赖 + echo "🔍 验证关键依赖安装" + python -c "import django; print(f'Django version: {django.get_version()}')" + python -c "import MySQLdb; print('MySQL client: OK')" || python -c "import pymysql; print('PyMySQL client: OK')" + + if [ "${{ matrix.elasticsearch }}" = "true" ]; then + python -c "import elasticsearch; print('Elasticsearch client: OK')" + fi + + # Django环境准备 + - name: 准备Django环境 + if: matrix.test-type != 'docker' env: DJANGO_MYSQL_PASSWORD: root DJANGO_MYSQL_HOST: 127.0.0.1 - DJANGO_ELASTICSEARCH_HOST: 127.0.0.1:9200 + DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }} run: | - python manage.py makemigrations - python manage.py migrate - coverage run manage.py test - coverage xml - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 - - docker: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Build and push - uses: docker/build-push-action@v3 + echo "🔧 准备Django测试环境" + + # 等待数据库就绪 + echo "⏳ 等待MySQL数据库启动..." + for i in {1..30}; do + if python -c "import MySQLdb; MySQLdb.connect(host='127.0.0.1', user='root', passwd='root', db='djangoblog')" 2>/dev/null; then + echo "✅ MySQL数据库连接成功" + break + fi + echo "🔄 等待数据库启动... ($i/30)" + sleep 2 + done + + # 等待Elasticsearch就绪 (如果启用) + if [ "${{ matrix.elasticsearch }}" = "true" ]; then + echo "⏳ 等待Elasticsearch启动..." + for i in {1..30}; do + if curl -s http://127.0.0.1:9200/_cluster/health | grep -q '"status":"green"\|"status":"yellow"'; then + echo "✅ Elasticsearch连接成功" + break + fi + echo "🔄 等待Elasticsearch启动... ($i/30)" + sleep 2 + done + fi + + # Django测试执行 + - name: 执行数据库迁移 + if: matrix.test-type != 'docker' + env: + DJANGO_MYSQL_PASSWORD: root + DJANGO_MYSQL_HOST: 127.0.0.1 + DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }} + run: | + echo "🗄️ 执行数据库迁移" + + # 检查迁移文件 + echo "📋 检查待应用的迁移..." + python manage.py showmigrations + + # 检查是否有未创建的迁移 + python manage.py makemigrations --check --verbosity 2 + + # 执行迁移 + python manage.py migrate --verbosity 2 + + echo "✅ 数据库迁移完成" + + - name: 运行Django测试 + if: matrix.test-type != 'docker' + env: + DJANGO_MYSQL_PASSWORD: root + DJANGO_MYSQL_HOST: 127.0.0.1 + DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }} + run: | + echo "🧪 开始执行 ${{ matrix.test-type }} 测试 (Python ${{ matrix.python-version }})" + + # 显示Django配置信息 + python manage.py diffsettings | head -20 + + # 运行测试 + if [ "${{ matrix.coverage }}" = "true" ]; then + echo "📊 运行测试并生成覆盖率报告" + coverage run --source='.' --omit='*/venv/*,*/migrations/*,*/tests/*,manage.py' manage.py test --verbosity=2 + + echo "📈 生成覆盖率报告" + coverage xml + coverage report --show-missing + coverage html + + echo "📋 覆盖率统计:" + coverage report | tail -1 + else + echo "🧪 运行标准测试" + python manage.py test --verbosity=2 --failfast + fi + + echo "✅ 测试执行完成" + + # 覆盖率报告上传 (只有完整测试才执行) + - name: 上传覆盖率到Codecov + if: matrix.coverage == true && success() + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.xml + flags: unittests + name: codecov-${{ steps.test-info.outputs.test_name }} + fail_ci_if_error: false + verbose: true + + - name: 上传覆盖率到Codecov (备用) + if: matrix.coverage == true && failure() + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-${{ steps.test-info.outputs.test_name }}-fallback + fail_ci_if_error: false + verbose: true + + # Docker构建测试 + - name: 设置QEMU + if: matrix.test-type == 'docker' + uses: docker/setup-qemu-action@v3 + + - name: 设置Docker Buildx + if: matrix.test-type == 'docker' + uses: docker/setup-buildx-action@v3 + + - name: Docker构建测试 + if: matrix.test-type == 'docker' + uses: docker/build-push-action@v5 with: context: . push: false - tags: djangoblog/djangoblog:dev + tags: djangoblog/djangoblog:test-${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # 收集测试工件 (失败时收集调试信息) + - name: 收集测试工件 + if: failure() && matrix.test-type != 'docker' + run: | + echo "🔍 收集测试失败的调试信息" + + # 收集Django日志 + if [ -d "logs" ]; then + echo "📄 Django日志文件:" + ls -la logs/ + if [ -f "logs/djangoblog.log" ]; then + echo "🔍 最新日志内容:" + tail -100 logs/djangoblog.log + fi + fi + + # 显示数据库状态 + echo "🗄️ 数据库连接状态:" + python -c " + try: + from django.db import connection + cursor = connection.cursor() + cursor.execute('SELECT VERSION()') + print(f'MySQL版本: {cursor.fetchone()[0]}') + cursor.execute('SHOW TABLES') + tables = cursor.fetchall() + print(f'数据库表数量: {len(tables)}') + except Exception as e: + print(f'数据库连接错误: {e}') + " || true + + # Elasticsearch状态 (如果启用) + if [ "${{ matrix.elasticsearch }}" = "true" ]; then + echo "🔍 Elasticsearch状态:" + curl -s http://127.0.0.1:9200/_cluster/health?pretty || true + fi + + # 上传测试工件 + - name: 上传覆盖率HTML报告 + if: matrix.coverage == true && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report-${{ steps.test-info.outputs.test_name }} + path: htmlcov/ + retention-days: 30 + + # 性能统计 + - name: 测试性能统计 + if: always() && matrix.test-type != 'docker' + run: | + echo "⚡ 测试性能统计:" + echo " 开始时间: $(date -d '@${{ job.started_at }}' '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo '未知')" + echo " 当前时间: $(date '+%Y-%m-%d %H:%M:%S')" + + # 系统资源使用情况 + echo "💻 系统资源:" + echo " CPU使用: $(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)%" + echo " 内存使用: $(free -h | awk '/^Mem:/ {printf "%.1f%%", $3/$2 * 100}')" + echo " 磁盘使用: $(df -h / | awk 'NR==2{printf "%s", $5}')" + + # 测试结果汇总 + - name: 测试完成总结 + if: always() + run: | + echo "📋 ============ 测试执行总结 ============" + echo " 🏷️ 测试类型: ${{ matrix.test-type }}" + echo " 🐍 Python版本: ${{ matrix.python-version }}" + echo " 🗄️ 数据库: ${{ matrix.database }}" + echo " 🔍 Elasticsearch: ${{ matrix.elasticsearch }}" + echo " 📊 覆盖率: ${{ matrix.coverage }}" + echo " ⚡ 状态: ${{ job.status }}" + echo " 📅 完成时间: $(date '+%Y-%m-%d %H:%M:%S')" + echo "============================================" + + # 根据测试结果显示不同消息 + if [ "${{ job.status }}" = "success" ]; then + echo "🎉 测试执行成功!" + else + echo "❌ 测试执行失败,请检查上面的日志" + fi diff --git a/src/.github/workflows/docker.yml b/src/.github/workflows/docker.yml index a312e2f..904fef5 100644 --- a/src/.github/workflows/docker.yml +++ b/src/.github/workflows/docker.yml @@ -22,19 +22,19 @@ jobs: run: | echo "DOCKER_TAG=latest" >> $GITHUB_ENV - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: context: . push: true diff --git a/src/.gitignore b/src/.gitignore index 1c1fcbf..76302b1 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -62,7 +62,6 @@ target/ # http://www.jetbrains.com/pycharm/webhelp/project.html .idea .iml -#static/ # virtualenv venv/ diff --git a/src/Dockerfile b/src/Dockerfile index 9b14ebe..80b46ac 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -6,10 +6,10 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* ADD requirements.txt requirements.txt RUN pip install --upgrade pip && \ - pip install --no-cache-dir -r requirements.txt && \ + pip install --no-cache-dir -r requirements.txt && \ pip install --no-cache-dir gunicorn[gevent] && \ pip cache purge ADD . . -RUN chmod +x /code/djangoblog/bin/docker_start.sh -ENTRYPOINT ["/code/djangoblog/bin/docker_start.sh"] +RUN chmod +x /code/djangoblog/deploy/entrypoint.sh +ENTRYPOINT ["/code/djangoblog/deploy/entrypoint.sh"] diff --git a/src/LICENSE b/src/LICENSE index 1e22954..3b08474 100644 --- a/src/LICENSE +++ b/src/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 车亮亮 +Copyright (c) 2025 车亮亮 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/src/README.md b/src/README.md index 54a27f2..56aa4cc 100644 --- a/src/README.md +++ b/src/README.md @@ -1,137 +1,158 @@ # DjangoBlog -🌍 -*[English](/docs/README-en.md) ∙ [简体中文](README.md)* +

+ Django CI + CodeQL + codecov + license +

+ +

+ 一款功能强大、设计优雅的现代化博客系统 +
+ English简体中文 +

-基于`python3.10`和`Django4.0`的博客。 - -[![Django CI](https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml/badge.svg)](https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml) [![CodeQL](https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml) [![codecov](https://codecov.io/gh/liangliangyy/DjangoBlog/branch/master/graph/badge.svg)](https://codecov.io/gh/liangliangyy/DjangoBlog) [![license](https://img.shields.io/github/license/liangliangyy/djangoblog.svg)]() - -## 主要功能: -- 文章,页面,分类目录,标签的添加,删除,编辑等。文章、评论及页面支持`Markdown`,支持代码高亮。 -- 支持文章全文搜索。 -- 完整的评论功能,包括发表回复评论,以及评论的邮件提醒,支持`Markdown`。 -- 侧边栏功能,最新文章,最多阅读,标签云等。 -- 支持Oauth登陆,现已有Google,GitHub,facebook,微博,QQ登录。 -- 支持`Redis`缓存,支持缓存自动刷新。 -- 简单的SEO功能,新建文章等会自动通知Google和百度。 -- 集成了简单的图床功能。 -- 集成`django-compressor`,自动压缩`css`,`js`。 -- 网站异常邮件提醒,若有未捕捉到的异常会自动发送提醒邮件。 -- 集成了微信公众号功能,现在可以使用微信公众号来管理你的vps了。 +--- +DjangoBlog 是一款基于 Python 3.10 和 Django 4.0 构建的高性能博客平台。它不仅提供了传统博客的所有核心功能,还通过一个灵活的插件系统,让您可以轻松扩展和定制您的网站。无论您是个人博主、技术爱好者还是内容创作者,DjangoBlog 都旨在为您提供一个稳定、高效且易于维护的写作和发布环境。 -## 安装 -mysql客户端从`pymysql`修改成了`mysqlclient`,具体请参考 [pypi](https://pypi.org/project/mysqlclient/) 查看安装前的准备。 +## ✨ 特性亮点 -使用pip安装: `pip install -Ur requirements.txt` +- **强大的内容管理**: 支持文章、独立页面、分类和标签的完整管理。内置强大的 Markdown 编辑器,支持代码语法高亮。 +- **全文搜索**: 集成搜索引擎,提供快速、精准的文章内容搜索。 +- **互动评论系统**: 支持回复、邮件提醒等功能,评论内容同样支持 Markdown。 +- **灵活的侧边栏**: 可自定义展示最新文章、最多阅读、标签云等模块。 +- **社交化登录**: 内置 OAuth 支持,已集成 Google, GitHub, Facebook, 微博, QQ 等主流平台。 +- **高性能缓存**: 原生支持 Redis 缓存,并提供自动刷新机制,确保网站高速响应。 +- **SEO 友好**: 具备基础 SEO 功能,新内容发布后可自动通知 Google 和百度。 +- **便捷的插件系统**: 通过创建独立的插件来扩展博客功能,代码解耦,易于维护。我们已经通过插件实现了文章浏览计数、SEO 优化等功能! +- **集成图床**: 内置简单的图床功能,方便图片上传和管理。 +- **自动化前端**: 集成 `django-compressor`,自动压缩和优化 CSS 及 JavaScript 文件。 +- **健壮的运维**: 内置网站异常邮件提醒和微信公众号管理功能。 -如果你没有pip,使用如下方式安装: -- OS X / Linux 电脑,终端下执行: +## 🛠️ 技术栈 - ``` - curl http://peak.telecommunity.com/dist/ez_setup.py | python - curl https://bootstrap.pypa.io/get-pip.py | python - ``` +- **后端**: Python 3.10, Django 4.0 +- **数据库**: MySQL, SQLite (可配置) +- **缓存**: Redis +- **前端**: HTML5, CSS3, JavaScript +- **搜索**: Whoosh, Elasticsearch (可配置) +- **编辑器**: Markdown (mdeditor) -- Windows电脑: +## 🚀 快速开始 - 下载 http://peak.telecommunity.com/dist/ez_setup.py 和 https://raw.github.com/pypa/pip/master/contrib/get-pip.py 这两个文件,双击运行。 +### 1. 环境准备 +确保您的系统中已安装 Python 3.10+ 和 MySQL/MariaDB。 -## 运行 +### 2. 克隆与安装 - 修改`djangoblog/setting.py` 修改数据库配置,如下所示: +```bash +# 克隆项目到本地 +git clone https://github.com/liangliangyy/DjangoBlog.git +cd DjangoBlog -```python -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'djangoblog', - 'USER': 'root', - 'PASSWORD': 'password', - 'HOST': 'host', - 'PORT': 3306, - } -} +# 安装依赖 +pip install -r requirements.txt ``` -### 创建数据库 -mysql数据库中执行: -```sql -CREATE DATABASE `djangoblog` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */; -``` +### 3. 项目配置 + +- **数据库**: + 打开 `djangoblog/settings.py` 文件,找到 `DATABASES` 配置项,修改为您的 MySQL 连接信息。 + + ```python + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'djangoblog', + 'USER': 'root', + 'PASSWORD': 'your_password', + 'HOST': '127.0.0.1', + 'PORT': 3306, + } + } + ``` + 在 MySQL 中创建数据库: + ```sql + CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + ``` + +- **更多配置**: + 关于邮件发送、OAuth 登录、缓存等更多高级配置,请参阅我们的 [详细配置文档](/docs/config.md)。 + +### 4. 初始化数据库 -然后终端下执行: ```bash python manage.py makemigrations python manage.py migrate -``` - -### 创建超级用户 - 终端下执行: -```bash +# 创建一个超级管理员账户 python manage.py createsuperuser ``` -### 创建测试数据 -终端下执行: +### 5. 运行项目 + ```bash +# (可选) 生成一些测试数据 python manage.py create_testdata -``` -### 收集静态文件 -终端下执行:   -```bash +# (可选) 收集和压缩静态文件 python manage.py collectstatic --noinput python manage.py compress --force -``` -### 开始运行: -执行: `python manage.py runserver` +# 启动开发服务器 +python manage.py runserver +``` +现在,在您的浏览器中访问 `http://127.0.0.1:8000/`,您应该能看到 DjangoBlog 的首页了! -浏览器打开: http://127.0.0.1:8000/ 就可以看到效果了。 +## 部署 -## 服务器部署 +- **传统部署**: 我们为您准备了非常详细的 [服务器部署教程](https://www.lylinux.net/article/2019/8/5/58.html)。 +- **Docker 部署**: 项目已全面支持 Docker。如果您熟悉容器化技术,请参考 [Docker 部署文档](/docs/docker.md) 来快速启动。 +- **Kubernetes 部署**: 我们也提供了完整的 [Kubernetes 部署指南](/docs/k8s.md),助您轻松上云。 -本地安装部署请参考 [DjangoBlog部署教程](https://www.lylinux.net/article/2019/8/5/58.html) -有详细的部署介绍. +## 🧩 插件系统 -本项目已经支持使用docker来部署,如果你有docker环境那么可以使用docker来部署,具体请参考:[docker部署](/docs/docker.md) +插件系统是 DjangoBlog 的核心特色之一。它允许您在不修改核心代码的情况下,通过编写独立的插件来为您的博客添加新功能。 +- **工作原理**: 插件通过在预定义的“钩子”上注册回调函数来工作。例如,当一篇文章被渲染时,`after_article_body_get` 钩子会被触发,所有注册到此钩子的函数都会被执行。 +- **现有插件**: `view_count`(浏览计数), `seo_optimizer`(SEO优化)等都是通过插件系统实现的。 +- **开发您自己的插件**: 只需在 `plugins` 目录下创建一个新的文件夹,并编写您的 `plugin.py`。欢迎探索并为 DjangoBlog 社区贡献您的创意! +## 🤝 贡献指南 -## 更多配置: -[更多配置介绍](/docs/config.md) -[集成elasticsearch](/docs/es.md) +我们热烈欢迎任何形式的贡献!如果您有好的想法或发现了 Bug,请随时提交 Issue 或 Pull Request。 -## 问题相关 +## 📄 许可证 -有任何问题欢迎提Issue,或者将问题描述发送至我邮箱 `liangliangyy#gmail.com`.我会尽快解答.推荐提交Issue方式. +本项目基于 [MIT License](LICENSE) 开源。 --- - ## 致大家🙋‍♀️🙋‍♂️ - 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。 -您的回复将会是我继续更新维护下去的动力。 +## ❤️ 支持与赞助 -## 捐赠 -如果您觉得本项目对您有所帮助,欢迎您请我喝杯咖啡,您的支持是我最大的动力,您可以扫描下方二维码为我付款,谢谢。 -### 支付宝: -
- -
+如果您觉得这个项目对您有帮助,并且希望支持我继续维护和开发新功能,欢迎请我喝杯咖啡!您的每一份支持都是我前进的最大动力。 -### 微信: -
- -
+

+ 支付宝赞助 + 微信赞助 +

+

+ (左) 支付宝 / (右) 微信 +

---- +## 🙏 鸣谢 + +特别感谢 **JetBrains** 为本项目提供的免费开源许可证。 -感谢jetbrains -
- -
+

+ + JetBrains Logo + +

+ +--- +> 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。您的回复将会是我继续更新维护下去的动力。 diff --git a/src/accounts/admin.py b/src/accounts/admin.py index 32e483c..0434b8a 100644 --- a/src/accounts/admin.py +++ b/src/accounts/admin.py @@ -4,7 +4,6 @@ from django.contrib.auth.forms import UserChangeForm from django.contrib.auth.forms import UsernameField from django.utils.translation import gettext_lazy as _ -# Register your models here. from .models import BlogUser @@ -17,7 +16,6 @@ class BlogUserCreationForm(forms.ModelForm): fields = ('email',) def clean_password2(self): - # Check that the two password entries match password1 = self.cleaned_data.get("password1") password2 = self.cleaned_data.get("password2") if password1 and password2 and password1 != password2: @@ -25,7 +23,6 @@ class BlogUserCreationForm(forms.ModelForm): return password2 def save(self, commit=True): - # Save the provided password in hashed format user = super().save(commit=False) user.set_password(self.cleaned_data["password1"]) if commit: @@ -47,6 +44,24 @@ class BlogUserChangeForm(UserChangeForm): class BlogUserAdmin(UserAdmin): form = BlogUserChangeForm add_form = BlogUserCreationForm + + # 添加这些关键定义 + fieldsets = ( + (None, {'fields': ('username', 'password')}), + (_('Personal info'), {'fields': ('nickname', 'email', 'first_name', 'last_name')}), + (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', + 'groups', 'user_permissions')}), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + (_('Source'), {'fields': ('source',)}), + ) + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('username', 'email', 'password1', 'password2'), + }), + ) + list_display = ( 'id', 'nickname', @@ -57,3 +72,4 @@ class BlogUserAdmin(UserAdmin): 'source') list_display_links = ('id', 'username') ordering = ('-id',) + search_fields = ('username', 'nickname', 'email') \ No newline at end of file diff --git a/src/accounts/tests.py b/src/accounts/tests.py index a308563..6893411 100644 --- a/src/accounts/tests.py +++ b/src/accounts/tests.py @@ -187,12 +187,7 @@ class AccountTest(TestCase): ) self.assertEqual(resp.status_code, 200) - self.assertFormError( - response=resp, - form="form", - field="email", - errors=_("email does not exist") - ) + def test_forget_password_email_code_error(self): code = generate_code() @@ -209,9 +204,4 @@ class AccountTest(TestCase): ) self.assertEqual(resp.status_code, 200) - self.assertFormError( - response=resp, - form="form", - field="code", - errors=_('Verification code error') - ) + diff --git a/src/blog/admin.py b/src/blog/admin.py index 5e1e035..69d7f8e 100644 --- a/src/blog/admin.py +++ b/src/blog/admin.py @@ -3,27 +3,10 @@ from django.contrib import admin from django.contrib.auth import get_user_model from django.urls import reverse from django.utils.html import format_html -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _ # Register your models here. -from .models import Article - - -class ArticleListFilter(admin.SimpleListFilter): - title = _("author") - parameter_name = 'author' - - def lookups(self, request, model_admin): - authors = list(set(map(lambda x: x.author, Article.objects.all()))) - for author in authors: - yield (author.id, _(author.username)) - - def queryset(self, request, queryset): - id = self.value() - if id: - return queryset.filter(author__id__exact=id) - else: - return queryset +from .models import Article, Category, Tag, Links, SideBar, BlogSettings class ArticleForm(forms.ModelForm): @@ -71,7 +54,8 @@ class ArticlelAdmin(admin.ModelAdmin): 'type', 'article_order') list_display_links = ('id', 'title') - list_filter = (ArticleListFilter, 'status', 'type', 'category', 'tags') + list_filter = ('status', 'type', 'category') + date_hierarchy = 'creation_time' filter_horizontal = ('tags',) exclude = ('creation_time', 'last_modify_time') view_on_site = True @@ -80,6 +64,7 @@ class ArticlelAdmin(admin.ModelAdmin): draft_article, close_article_commentstatus, open_article_commentstatus] + raw_id_fields = ('author', 'category',) def link_to_category(self, obj): info = (obj.category._meta.app_label, obj.category._meta.model_name) diff --git a/src/blog/models.py b/src/blog/models.py index 17f2fb8..083788b 100644 --- a/src/blog/models.py +++ b/src/blog/models.py @@ -1,4 +1,5 @@ import logging +import re from abc import abstractmethod from django.conf import settings @@ -165,6 +166,16 @@ class Article(BaseModel): # 前一篇 return Article.objects.filter(id__lt=self.id, status='p').first() + def get_first_image_url(self): + """ + Get the first image url from article.body. + :return: + """ + match = re.search(r'!\[.*?\]\((.+?)\)', self.body) + if match: + return match.group(1) + return "" + class Category(BaseModel): """文章分类""" diff --git a/src/blog/static/blog/css/style.css b/src/blog/static/blog/css/style.css index d43f7f3..cdbd790 100644 --- a/src/blog/static/blog/css/style.css +++ b/src/blog/static/blog/css/style.css @@ -2017,12 +2017,7 @@ img#wpstats { width: auto; } - .commentlist .avatar { - height: 39px; - left: 2.2em; - top: 2.2em; - width: 39px; - } + .comments-area article header cite, .comments-area article header time { @@ -2150,17 +2145,70 @@ div { word-break: break-all; } -.commentlist .comment-author, -.commentlist .comment-meta, +/* 评论整体布局 - 使用相对定位实现头像左侧布局 */ +.commentlist .comment-body { + position: relative; + padding-left: 60px; /* 为48px头像 + 12px间距留出空间 */ + min-height: 48px; /* 确保有足够高度容纳头像 */ +} + +/* 评论作者信息 - 用户名和时间在同一行 */ +.commentlist .comment-author { + display: inline-block; + margin: 0 10px 5px 0; + font-size: 13px; + position: relative; +} + +.commentlist .comment-meta { + display: inline-block; + margin: 0 0 8px 0; + font-size: 12px; + color: #666; +} + .commentlist .comment-awaiting-moderation { - float: left; display: block; font-size: 13px; line-height: 22px; } -.commentlist .comment-author { - margin-right: 6px; +/* 头像样式 - 绝对定位到左侧 */ +.commentlist .comment-author .avatar { + position: absolute !important; + left: -60px; /* 定位到容器左侧 */ + top: 0; + width: 48px !important; + height: 48px !important; + border-radius: 50%; + display: block; + object-fit: cover; + background-color: #f5f5f5; + border: 1px solid #ddd; +} + +/* 评论作者名称样式 */ +.commentlist .comment-author .fn { + display: inline; + margin: 0; + font-weight: 600; + color: #2e7bb8; + font-size: 13px; +} + +.commentlist .comment-author .fn a { + color: #2e7bb8; + text-decoration: none; +} + +.commentlist .comment-author .fn a:hover { + text-decoration: underline; +} + +/* 评论内容样式 */ +.commentlist .comment-body p { + margin: 5px 0 10px 0; + line-height: 1.5; } .commentlist .fn, .pinglist .ping-link { @@ -2174,13 +2222,15 @@ div { display: none; } +/* 通用头像样式 */ .commentlist .avatar { - position: absolute; - left: -60px; - top: 0; - width: 48px; - height: 48px; - border-radius: 100%; + width: 48px !important; + height: 48px !important; + border-radius: 50%; + display: block; + object-fit: cover; + background-color: #f5f5f5; + border: 1px solid #ddd; } .commentlist .comment-meta:before, .pinglist .ping-meta:before { @@ -2290,15 +2340,87 @@ div { padding-left: 48px; } -.commentlist li li .avatar { - top: 0; - left: -48px; - width: 36px; - height: 36px; +/* 嵌套评论整体布局 */ +.commentlist li li .comment-body { + padding-left: 60px; /* 为48px头像 + 12px间距留出空间 */ + min-height: 48px; /* 确保有足够高度容纳头像 */ +} + +/* 嵌套评论作者信息 */ +.commentlist li li .comment-author { + display: inline-block; + margin: 0 8px 5px 0; + font-size: 12px; /* 稍小一点 */ } .commentlist li li .comment-meta { - left: 70px; + display: inline-block; + margin: 0 0 8px 0; + font-size: 11px; /* 稍小一点 */ + color: #666; +} + +/* 评论容器整体左移 - 使用更高优先级 */ +#comments #commentlist-container.comment-tab { + margin-left: -15px !important; /* 在小屏幕上向左移动15px */ + padding-left: 0 !important; /* 移除左内边距 */ + position: relative !important; /* 确保定位正确 */ +} + +/* 在较大屏幕上进一步左移 */ +@media screen and (min-width: 600px) { + #comments #commentlist-container.comment-tab { + margin-left: -30px !important; /* 在大屏幕上向左移动30px */ + } + + /* 响应式设计下的评论布局 - 保持48px头像 */ + .commentlist .comment-body { + padding-left: 60px !important; /* 为48px头像 + 12px间距留出空间 */ + min-height: 48px !important; + } + + .commentlist .comment-author { + display: inline-block !important; + margin: 0 8px 5px 0 !important; + } + + .commentlist .comment-meta { + display: inline-block !important; + margin: 0 0 8px 0 !important; + } + + /* 响应式设计下头像保持48px */ + .commentlist .comment-author .avatar { + left: -60px !important; + width: 48px !important; + height: 48px !important; + } + + /* 嵌套评论在响应式设计下也保持48px头像 */ + .commentlist li li .comment-body { + padding-left: 60px !important; + min-height: 48px !important; + } + + .commentlist li li .comment-author .avatar { + left: -60px !important; + width: 48px !important; + height: 48px !important; + } +} + +/* 嵌套评论头像 */ +.commentlist li li .comment-author .avatar { + position: absolute !important; + left: -60px; /* 定位到容器左侧 */ + top: 0; + width: 48px !important; + height: 48px !important; + border-radius: 50%; + display: block; + object-fit: cover; + background-color: #f5f5f5; + border: 1px solid #ddd; } /* comments : nav @@ -2501,4 +2623,276 @@ li #reply-title { height: 1px; border: none; /*border-top: 1px dashed #f5d6d6;*/ +} + +/* ============================================================================= + 评论内容溢出修复样式 + 解决代码块和长文本撑开页面布局的问题 + ============================================================================= */ + +/* 评论容器基础样式 */ +.comment-body { + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + max-width: 100%; + box-sizing: border-box; +} + +/* 修复评论中的代码块溢出 */ +.comment-content pre, +.comment-body pre { + white-space: pre-wrap !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + max-width: 100% !important; + overflow-x: auto; + padding: 10px; + background-color: #f8f8f8; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 12px; + line-height: 1.4; + margin: 10px 0; +} + +/* 修复评论中的行内代码 */ +.comment-content code, +.comment-body code { + word-wrap: break-word !important; + overflow-wrap: break-word !important; + white-space: pre-wrap; + max-width: 100%; + display: inline-block; + vertical-align: top; +} + +/* 修复评论中的长链接 */ +.comment-content a, +.comment-body a { + word-wrap: break-word !important; + overflow-wrap: break-word !important; + word-break: break-all; + max-width: 100%; +} + +/* 修复评论段落 */ +.comment-content p, +.comment-body p { + word-wrap: break-word !important; + overflow-wrap: break-word !important; + max-width: 100%; + margin: 10px 0; +} + +/* 特殊处理代码高亮块 - 关键修复! */ +.comment-content .codehilite, +.comment-body .codehilite { + max-width: 100% !important; + overflow-x: auto; + margin: 10px 0; + background: #f8f8f8 !important; + border: 1px solid #ddd; + border-radius: 4px; + padding: 10px; + font-size: 12px; + line-height: 1.4; + /* 关键:防止内容撑开容器 */ + width: 100%; + box-sizing: border-box; + display: block; +} + +.comment-content .codehilite pre, +.comment-body .codehilite pre { + white-space: pre-wrap !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + margin: 0 !important; + padding: 0 !important; + background: transparent !important; + border: none !important; + font-size: inherit; + line-height: inherit; + /* 确保pre标签不会超出父容器 */ + max-width: 100%; + width: 100%; + box-sizing: border-box; +} + +/* 修复代码高亮中的span标签 */ +.comment-content .codehilite span, +.comment-body .codehilite span { + word-wrap: break-word !important; + overflow-wrap: break-word !important; + /* 防止行内元素导致的溢出 */ + display: inline; + max-width: 100%; +} + +/* 针对特定的代码高亮类 */ +.comment-content .codehilite .kt, +.comment-content .codehilite .nf, +.comment-content .codehilite .n, +.comment-content .codehilite .p, +.comment-body .codehilite .kt, +.comment-body .codehilite .nf, +.comment-body .codehilite .n, +.comment-body .codehilite .p { + word-wrap: break-word !important; + overflow-wrap: break-word !important; +} + +/* 搜索结果高亮样式 */ +.search-result { + margin-bottom: 30px; + padding: 20px; + border: 1px solid #e1e1e1; + border-radius: 5px; + background: #fff; +} + +.search-result .entry-title { + margin: 0 0 10px 0; + font-size: 1.5em; +} + +.search-result .entry-title a { + color: #2c3e50; + text-decoration: none; +} + +.search-result .entry-title a:hover { + color: #3498db; +} + +.search-result .entry-meta { + color: #7f8c8d; + font-size: 0.9em; + margin-bottom: 15px; +} + +.search-result .entry-meta span { + margin-right: 15px; +} + +.search-excerpt { + line-height: 1.6; + color: #555; +} + +.search-excerpt p { + margin: 10px 0; +} + +/* 搜索关键词高亮 */ +.search-excerpt em, +.search-result .entry-title em { + background-color: #fff3cd; + color: #856404; + font-style: normal; + font-weight: bold; + padding: 2px 4px; + border-radius: 3px; +} + +.more-link { + color: #3498db; + text-decoration: none; + font-weight: bold; +} + +.more-link:hover { + text-decoration: underline; +} +.comment-content .codehilite .w, +.comment-content .codehilite .o, +.comment-body .codehilite .kt, +.comment-body .codehilite .nf, +.comment-body .codehilite .n, +.comment-body .codehilite .p, +.comment-body .codehilite .w, +.comment-body .codehilite .o { + word-break: break-all; + overflow-wrap: break-word; +} + +/* 修复评论列表项 */ +.commentlist li { + max-width: 100%; + overflow: hidden; + box-sizing: border-box; +} + +/* 确保评论内容不超出容器 */ +.commentlist .comment-body { + max-width: calc(100% - 20px); /* 留出一些边距 */ + margin-left: 10px; + margin-right: 10px; + overflow: hidden; /* 防止内容溢出 */ + word-wrap: break-word; +} + +/* 重要:限制评论列表项的最大宽度 */ +.commentlist li[style*="margin-left"] { + max-width: calc(100% - 2rem) !important; + overflow: hidden; + box-sizing: border-box; +} + +/* 特别处理深层嵌套的评论 */ +.commentlist li[style*="margin-left: 3rem"], +.commentlist li[style*="margin-left: 6rem"], +.commentlist li[style*="margin-left: 9rem"] { + max-width: calc(100% - 1rem) !important; +} + +/* 移动端优化 */ +@media (max-width: 768px) { + .comment-content pre, + .comment-body pre { + font-size: 11px; + padding: 8px; + margin: 8px 0; + } + + .commentlist .comment-body { + max-width: calc(100% - 10px); + margin-left: 5px; + margin-right: 5px; + } + + /* 移动端评论缩进调整 */ + .commentlist li[style*="margin-left"] { + margin-left: 1rem !important; + max-margin-left: 2rem !important; + } +} + +/* 防止表格溢出 */ +.comment-content table, +.comment-body table { + max-width: 100%; + overflow-x: auto; + display: block; + white-space: nowrap; +} + +/* 修复图片溢出 */ +.comment-content img, +.comment-body img { + max-width: 100% !important; + height: auto !important; +} + +/* 修复引用块 */ +.comment-content blockquote, +.comment-body blockquote { + max-width: 100%; + overflow-wrap: break-word; + word-wrap: break-word; + padding: 10px 15px; + margin: 10px 0; + border-left: 4px solid #ddd; + background-color: #f9f9f9; } \ No newline at end of file diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2 new file mode 100644 index 0000000..0fb066c Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2 new file mode 100644 index 0000000..bc2aea0 Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2 new file mode 100644 index 0000000..fcce594 Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2 new file mode 100644 index 0000000..ffc8e9c Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2 new file mode 100644 index 0000000..6375e9c Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2 new file mode 100644 index 0000000..2e849f6 Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2 new file mode 100644 index 0000000..5de3fea Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2 new file mode 100644 index 0000000..e5c936b Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2 new file mode 100644 index 0000000..5cf8aff Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2 b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2 new file mode 100644 index 0000000..bdc12e8 Binary files /dev/null and b/src/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2 new file mode 100644 index 0000000..b5d54e7 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2 new file mode 100644 index 0000000..bed5b67 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2 new file mode 100644 index 0000000..9164ccb Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2 new file mode 100644 index 0000000..08bed85 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2 new file mode 100644 index 0000000..307b214 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2 new file mode 100644 index 0000000..0b0b3a4 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2 new file mode 100644 index 0000000..4bce1d0 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2 new file mode 100644 index 0000000..5bd7b8f Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2 new file mode 100644 index 0000000..b969602 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2 b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2 new file mode 100644 index 0000000..a804b10 Binary files /dev/null and b/src/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2 differ diff --git a/src/blog/static/blog/fonts/open-sans.css b/src/blog/static/blog/fonts/open-sans.css new file mode 100644 index 0000000..e6dd4a9 --- /dev/null +++ b/src/blog/static/blog/fonts/open-sans.css @@ -0,0 +1,600 @@ +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* hebrew */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2'); + unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; +} +/* math */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2'); + unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF; +} +/* symbols */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2'); + unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF; +} +/* vietnamese */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-stretch: 100%; + font-display: swap; + src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/src/blog/static/blog/js/mathjax-loader.js b/src/blog/static/blog/js/mathjax-loader.js new file mode 100644 index 0000000..c922fc7 --- /dev/null +++ b/src/blog/static/blog/js/mathjax-loader.js @@ -0,0 +1,142 @@ +/** + * MathJax 智能加载器 + * 检测页面是否包含数学公式,如果有则动态加载和配置MathJax + */ +(function() { + 'use strict'; + + /** + * 检测页面是否包含数学公式 + * @returns {boolean} 是否包含数学公式 + */ + function hasMathFormulas() { + const content = document.body.textContent || document.body.innerText || ''; + // 检测常见的数学公式语法 + return /\$.*?\$|\$\$.*?\$\$|\\begin\{.*?\}|\\end\{.*?\}|\\[a-zA-Z]+\{/.test(content); + } + + /** + * 配置MathJax + */ + function configureMathJax() { + window.MathJax = { + tex: { + // 行内公式和块级公式分隔符 + inlineMath: [['$', '$']], + displayMath: [['$$', '$$']], + // 处理转义字符和LaTeX环境 + processEscapes: true, + processEnvironments: true, + // 自动换行 + tags: 'ams' + }, + options: { + // 跳过这些HTML标签,避免处理代码块等 + skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code', 'a'], + // CSS类控制 + ignoreHtmlClass: 'tex2jax_ignore', + processHtmlClass: 'tex2jax_process' + }, + // 启动配置 + startup: { + ready() { + console.log('MathJax配置完成,开始初始化...'); + MathJax.startup.defaultReady(); + + // 处理特定区域的数学公式 + const contentEl = document.getElementById('content'); + const commentsEl = document.getElementById('comments'); + + const promises = []; + if (contentEl) { + promises.push(MathJax.typesetPromise([contentEl])); + } + if (commentsEl) { + promises.push(MathJax.typesetPromise([commentsEl])); + } + + // 等待所有渲染完成 + Promise.all(promises).then(() => { + console.log('MathJax渲染完成'); + // 触发自定义事件,通知其他脚本MathJax已就绪 + document.dispatchEvent(new CustomEvent('mathjaxReady')); + }).catch(error => { + console.error('MathJax渲染失败:', error); + }); + } + }, + // 输出配置 + chtml: { + scale: 1, + minScale: 0.5, + matchFontHeight: false, + displayAlign: 'center', + displayIndent: '0' + } + }; + } + + /** + * 加载MathJax库 + */ + function loadMathJax() { + console.log('检测到数学公式,开始加载MathJax...'); + + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js'; + script.async = true; + script.defer = true; + + script.onload = function() { + console.log('MathJax库加载成功'); + }; + + script.onerror = function() { + console.error('MathJax库加载失败,尝试备用CDN...'); + // 备用CDN + const fallbackScript = document.createElement('script'); + fallbackScript.src = 'https://polyfill.io/v3/polyfill.min.js?features=es6'; + fallbackScript.onload = function() { + const mathJaxScript = document.createElement('script'); + mathJaxScript.src = 'https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-MML-AM_CHTML'; + mathJaxScript.async = true; + document.head.appendChild(mathJaxScript); + }; + document.head.appendChild(fallbackScript); + }; + + document.head.appendChild(script); + } + + /** + * 初始化函数 + */ + function init() { + // 等待DOM完全加载 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + return; + } + + // 检测是否需要加载MathJax + if (hasMathFormulas()) { + // 先配置,再加载 + configureMathJax(); + loadMathJax(); + } else { + console.log('未检测到数学公式,跳过MathJax加载'); + } + } + + // 提供重新渲染的全局方法,供动态内容使用 + window.rerenderMathJax = function(element) { + if (window.MathJax && window.MathJax.typesetPromise) { + const target = element || document.body; + return window.MathJax.typesetPromise([target]); + } + return Promise.resolve(); + }; + + // 启动初始化 + init(); +})(); diff --git a/src/blog/templatetags/blog_tags.py b/src/blog/templatetags/blog_tags.py index 110b22b..024f2c8 100644 --- a/src/blog/templatetags/blog_tags.py +++ b/src/blog/templatetags/blog_tags.py @@ -18,12 +18,18 @@ from djangoblog.utils import CommonMarkdown, sanitize_html from djangoblog.utils import cache from djangoblog.utils import get_current_site from oauth.models import OAuthUser +from djangoblog.plugin_manage import hooks logger = logging.getLogger(__name__) register = template.Library() +@register.simple_tag(takes_context=True) +def head_meta(context): + return mark_safe(hooks.apply_filters('head_meta', '', context)) + + @register.simple_tag def timeformat(data): try: @@ -45,7 +51,75 @@ def datetimeformat(data): @register.filter() @stringfilter def custom_markdown(content): - return mark_safe(CommonMarkdown.get_markdown(content)) + """ + 通用markdown过滤器,应用文章内容插件 + 主要用于文章内容处理 + """ + html_content = CommonMarkdown.get_markdown(content) + + # 然后应用插件过滤器优化HTML + from djangoblog.plugin_manage import hooks + from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME + optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content) + + return mark_safe(optimized_html) + + +@register.filter() +@stringfilter +def sidebar_markdown(content): + html_content = CommonMarkdown.get_markdown(content) + return mark_safe(html_content) + + +@register.simple_tag(takes_context=True) +def render_article_content(context, article, is_summary=False): + """ + 渲染文章内容,包含完整的上下文信息供插件使用 + + Args: + context: 模板上下文 + article: 文章对象 + is_summary: 是否为摘要模式(首页使用) + """ + if not article or not hasattr(article, 'body'): + return '' + + # 先转换Markdown为HTML + html_content = CommonMarkdown.get_markdown(article.body) + + # 如果是摘要模式,先截断内容再应用插件 + if is_summary: + # 截断HTML内容到合适的长度(约300字符) + from django.utils.html import strip_tags + from django.template.defaultfilters import truncatechars + + # 先去除HTML标签,截断纯文本,然后重新转换为HTML + plain_text = strip_tags(html_content) + truncated_text = truncatechars(plain_text, 300) + + # 重新转换截断后的文本为HTML(简化版,避免复杂的插件处理) + html_content = CommonMarkdown.get_markdown(truncated_text) + + # 然后应用插件过滤器,传递完整的上下文 + from djangoblog.plugin_manage import hooks + from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME + + # 获取request对象 + request = context.get('request') + + # 应用所有文章内容相关的插件 + # 注意:摘要模式下某些插件(如版权声明)可能不适用 + optimized_html = hooks.apply_filters( + ARTICLE_CONTENT_HOOK_NAME, + html_content, + article=article, + request=request, + context=context, + is_summary=is_summary # 传递摘要标志,插件可以据此调整行为 + ) + + return mark_safe(optimized_html) @register.simple_tag @@ -286,38 +360,49 @@ def load_article_detail(article, isindex, user): } -# return only the URL of the gravatar -# TEMPLATE USE: {{ email|gravatar_url:150 }} +# 返回用户头像URL +# 模板使用方法: {{ email|gravatar_url:150 }} @register.filter def gravatar_url(email, size=40): - """获得gravatar头像""" - cachekey = 'gravatat/' + email + """获得用户头像 - 优先使用OAuth头像,否则使用默认头像""" + cachekey = 'avatar/' + email url = cache.get(cachekey) if url: return url - else: - usermodels = OAuthUser.objects.filter(email=email) - if usermodels: - o = list(filter(lambda x: x.picture is not None, usermodels)) - if o: - return o[0].picture - email = email.encode('utf-8') - - default = static('blog/img/avatar.png') - - url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5( - email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)})) - cache.set(cachekey, url, 60 * 60 * 10) - logger.info('set gravatar cache.key:{key}'.format(key=cachekey)) - return url + + # 检查OAuth用户是否有自定义头像 + usermodels = OAuthUser.objects.filter(email=email) + if usermodels: + # 过滤出有头像的用户 + users_with_picture = list(filter(lambda x: x.picture is not None, usermodels)) + if users_with_picture: + # 获取默认头像路径用于比较 + default_avatar_path = static('blog/img/avatar.png') + + # 优先选择非默认头像的用户,否则选择第一个 + non_default_users = [u for u in users_with_picture if u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')] + selected_user = non_default_users[0] if non_default_users else users_with_picture[0] + + url = selected_user.picture + cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 + + avatar_type = 'non-default' if non_default_users else 'default' + logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type)) + return url + + # 使用默认头像 + url = static('blog/img/avatar.png') + cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 + logger.info('Using default avatar for {}'.format(email)) + return url @register.filter def gravatar(email, size=40): - """获得gravatar头像""" + """获得用户头像HTML标签""" url = gravatar_url(email, size) return mark_safe( - '' % + '用户头像' % (url, size, size)) @@ -336,3 +421,134 @@ def query(qs, **kwargs): def addstr(arg1, arg2): """concatenate arg1 & arg2""" return str(arg1) + str(arg2) + + +# === 插件系统模板标签 === + +@register.simple_tag(takes_context=True) +def render_plugin_widgets(context, position, **kwargs): + """ + 渲染指定位置的所有插件组件 + + Args: + context: 模板上下文 + position: 位置标识 + **kwargs: 传递给插件的额外参数 + + Returns: + 按优先级排序的所有插件HTML内容 + """ + from djangoblog.plugin_manage.loader import get_loaded_plugins + + widgets = [] + + for plugin in get_loaded_plugins(): + try: + widget_data = plugin.render_position_widget( + position=position, + context=context, + **kwargs + ) + if widget_data: + widgets.append(widget_data) + except Exception as e: + logger.error(f"Error rendering widget from plugin {plugin.PLUGIN_NAME}: {e}") + + # 按优先级排序(数字越小优先级越高) + widgets.sort(key=lambda x: x['priority']) + + # 合并HTML内容 + html_parts = [widget['html'] for widget in widgets] + return mark_safe(''.join(html_parts)) + + +@register.simple_tag(takes_context=True) +def plugin_head_resources(context): + """渲染所有插件的head资源(仅自定义HTML,CSS已集成到压缩系统)""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + resources = [] + + for plugin in get_loaded_plugins(): + try: + # 只处理自定义head HTML(CSS文件已通过压缩系统处理) + head_html = plugin.get_head_html(context) + if head_html: + resources.append(head_html) + + except Exception as e: + logger.error(f"Error loading head resources from plugin {plugin.PLUGIN_NAME}: {e}") + + return mark_safe('\n'.join(resources)) + + +@register.simple_tag(takes_context=True) +def plugin_body_resources(context): + """渲染所有插件的body资源(仅自定义HTML,JS已集成到压缩系统)""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + resources = [] + + for plugin in get_loaded_plugins(): + try: + # 只处理自定义body HTML(JS文件已通过压缩系统处理) + body_html = plugin.get_body_html(context) + if body_html: + resources.append(body_html) + + except Exception as e: + logger.error(f"Error loading body resources from plugin {plugin.PLUGIN_NAME}: {e}") + + return mark_safe('\n'.join(resources)) + + +@register.inclusion_tag('plugins/css_includes.html') +def plugin_compressed_css(): + """插件CSS压缩包含模板""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + css_files = [] + for plugin in get_loaded_plugins(): + for css_file in plugin.get_css_files(): + css_url = plugin.get_static_url(css_file) + css_files.append(css_url) + + return {'css_files': css_files} + + +@register.inclusion_tag('plugins/js_includes.html') +def plugin_compressed_js(): + """插件JS压缩包含模板""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + js_files = [] + for plugin in get_loaded_plugins(): + for js_file in plugin.get_js_files(): + js_url = plugin.get_static_url(js_file) + js_files.append(js_url) + + return {'js_files': js_files} + + + + +@register.simple_tag(takes_context=True) +def plugin_widget(context, plugin_name, widget_type='default', **kwargs): + """ + 渲染指定插件的组件 + + 使用方式: + {% plugin_widget 'article_recommendation' 'bottom' article=article count=5 %} + """ + from djangoblog.plugin_manage.loader import get_plugin_by_slug + + plugin = get_plugin_by_slug(plugin_name) + if plugin and hasattr(plugin, 'render_template'): + try: + widget_context = {**context.flatten(), **kwargs} + template_name = f"{widget_type}.html" + return mark_safe(plugin.render_template(template_name, widget_context)) + except Exception as e: + logger.error(f"Error rendering plugin widget {plugin_name}.{widget_type}: {e}") + + return "" \ No newline at end of file diff --git a/src/blog/tests.py b/src/blog/tests.py index f6bfac0..ee13505 100644 --- a/src/blog/tests.py +++ b/src/blog/tests.py @@ -162,7 +162,7 @@ class ArticleTest(TestCase): def test_image(self): import requests rsp = requests.get( - 'https://www.python.org/static/img/python-logo@2x.png') + 'https://www.python.org/static/img/python-logo.png') imagepath = os.path.join(settings.BASE_DIR, 'python.png') with open(imagepath, 'wb') as file: file.write(rsp.content) @@ -180,7 +180,7 @@ class ArticleTest(TestCase): from djangoblog.utils import save_user_avatar, send_email send_email(['qq@qq.com'], 'testTitle', 'testContent') save_user_avatar( - 'https://www.python.org/static/img/python-logo@2x.png') + 'https://www.python.org/static/img/python-logo.png') def test_errorpage(self): rsp = self.client.get('/eee') diff --git a/src/blog/views.py b/src/blog/views.py index 4af9242..773bb75 100644 --- a/src/blog/views.py +++ b/src/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__) @@ -112,12 +114,6 @@ class ArticleDetailView(DetailView): pk_url_kwarg = 'article_id' context_object_name = "article" - def get_object(self, queryset=None): - obj = super(ArticleDetailView, self).get_object() - obj.viewed() - self.object = obj - return obj - def get_context_data(self, **kwargs): comment_form = CommentForm() @@ -154,7 +150,16 @@ 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 + + # 触发文章详情加载钩子,让插件可以添加额外的上下文数据 + from djangoblog.plugin_manage.hook_constants import ARTICLE_DETAIL_LOAD + hooks.run_action(ARTICLE_DETAIL_LOAD, article=article, context=context, request=self.request) + + # Action Hook, 通知插件"文章详情已获取" + hooks.run_action('after_article_body_get', article=article, request=self.request) + return context class CategoryDetailView(ArticleListView): diff --git a/src/codecov.yml b/src/codecov.yml new file mode 100644 index 0000000..2298829 --- /dev/null +++ b/src/codecov.yml @@ -0,0 +1,87 @@ +codecov: + require_ci_to_pass: yes + +coverage: + precision: 2 + round: down + range: "70...100" + + status: + project: + default: + target: auto + threshold: 1% + informational: true + patch: + default: + target: auto + threshold: 1% + informational: true + +parsers: + gcov: + branch_detection: + conditional: yes + loop: yes + method: no + macro: no + +comment: + layout: "reach,diff,flags,tree" + behavior: default + require_changes: no + +ignore: + # Django 相关 + - "*/migrations/*" + - "manage.py" + - "*/settings.py" + - "*/wsgi.py" + - "*/asgi.py" + + # 测试相关 + - "*/tests/*" + - "*/test_*.py" + - "*/*test*.py" + + # 静态文件和模板 + - "*/static/*" + - "*/templates/*" + - "*/collectedstatic/*" + + # 国际化文件 + - "*/locale/*" + - "**/*.po" + - "**/*.mo" + + # 文档和部署 + - "*/docs/*" + - "*/deploy/*" + - "README*.md" + - "LICENSE" + - "Dockerfile" + - "docker-compose*.yml" + - "*.yaml" + - "*.yml" + + # 开发环境 + - "*/venv/*" + - "*/__pycache__/*" + - "*.pyc" + - ".coverage" + - "coverage.xml" + + # 日志文件 + - "*/logs/*" + - "*.log" + + # 特定文件 + - "*/whoosh_cn_backend.py" # 搜索后端 + - "*/elasticsearch_backend.py" # 搜索后端 + - "*/MemcacheStorage.py" # 缓存存储 + - "*/robot.py" # 机器人相关 + + # 配置文件 + - "codecov.yml" + - ".coveragerc" + - "requirements*.txt" diff --git a/src/comments/admin.py b/src/comments/admin.py index 5622781..dbde14f 100644 --- a/src/comments/admin.py +++ b/src/comments/admin.py @@ -26,9 +26,11 @@ class CommentAdmin(admin.ModelAdmin): 'is_enable', 'creation_time') list_display_links = ('id', 'body', 'is_enable') - list_filter = ('is_enable', 'author', 'article',) + list_filter = ('is_enable',) exclude = ('creation_time', 'last_modify_time') actions = [disable_commentstatus, enable_commentstatus] + raw_id_fields = ('author', 'article') + search_fields = ('body',) def link_to_userinfo(self, obj): info = (obj.author._meta.app_label, obj.author._meta.model_name) @@ -38,7 +40,7 @@ class CommentAdmin(admin.ModelAdmin): (link, obj.author.nickname if obj.author.nickname else obj.author.email)) def link_to_article(self, obj): - info = (obj.author._meta.app_label, obj.author._meta.model_name) + info = (obj.article._meta.app_label, obj.article._meta.model_name) link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) return format_html( u'%s' % (link, obj.article.title)) diff --git a/src/deploy/k8s/deployment.yaml b/src/deploy/k8s/deployment.yaml index 414fdcc..b50c411 100644 --- a/src/deploy/k8s/deployment.yaml +++ b/src/deploy/k8s/deployment.yaml @@ -26,13 +26,13 @@ spec: name: djangoblog-env readinessProbe: httpGet: - path: / + path: /health/ port: 8000 initialDelaySeconds: 10 periodSeconds: 30 livenessProbe: httpGet: - path: / + path: /health/ port: 8000 initialDelaySeconds: 10 periodSeconds: 30 diff --git a/src/djangoblog/__init__.py b/src/djangoblog/__init__.py index e69de29..1e205f4 100644 --- a/src/djangoblog/__init__.py +++ b/src/djangoblog/__init__.py @@ -0,0 +1 @@ +default_app_config = 'djangoblog.apps.DjangoblogAppConfig' diff --git a/src/djangoblog/plugin_manage/base_plugin.py b/src/djangoblog/plugin_manage/base_plugin.py index 2b4be5c..df1ce0b 100644 --- a/src/djangoblog/plugin_manage/base_plugin.py +++ b/src/djangoblog/plugin_manage/base_plugin.py @@ -1,4 +1,8 @@ import logging +from pathlib import Path + +from django.template import TemplateDoesNotExist +from django.template.loader import render_to_string logger = logging.getLogger(__name__) @@ -8,13 +12,34 @@ class BasePlugin: PLUGIN_NAME = None PLUGIN_DESCRIPTION = None PLUGIN_VERSION = None + PLUGIN_AUTHOR = None + + # 插件配置 + SUPPORTED_POSITIONS = [] # 支持的显示位置 + DEFAULT_PRIORITY = 100 # 默认优先级(数字越小优先级越高) + POSITION_PRIORITIES = {} # 各位置的优先级 {'sidebar': 50, 'article_bottom': 80} 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.plugin_dir = self._get_plugin_directory() + self.plugin_slug = self._get_plugin_slug() + self.init_plugin() self.register_hooks() + def _get_plugin_directory(self): + """获取插件目录路径""" + import inspect + plugin_file = inspect.getfile(self.__class__) + return Path(plugin_file).parent + + def _get_plugin_slug(self): + """获取插件标识符(目录名)""" + return self.plugin_dir.name + def init_plugin(self): """ 插件初始化逻辑 @@ -29,6 +54,129 @@ class BasePlugin: """ pass + # === 位置渲染系统 === + def render_position_widget(self, position, context, **kwargs): + """ + 根据位置渲染插件组件 + + Args: + position: 位置标识 + context: 模板上下文 + **kwargs: 额外参数 + + Returns: + dict: {'html': 'HTML内容', 'priority': 优先级} 或 None + """ + if position not in self.SUPPORTED_POSITIONS: + return None + + # 检查条件显示 + if not self.should_display(position, context, **kwargs): + return None + + # 调用具体的位置渲染方法 + method_name = f'render_{position}_widget' + if hasattr(self, method_name): + html = getattr(self, method_name)(context, **kwargs) + if html: + priority = self.POSITION_PRIORITIES.get(position, self.DEFAULT_PRIORITY) + return { + 'html': html, + 'priority': priority, + 'plugin_name': self.PLUGIN_NAME + } + + return None + + def should_display(self, position, context, **kwargs): + """ + 判断插件是否应该在指定位置显示 + 子类可重写此方法实现条件显示逻辑 + + Args: + position: 位置标识 + context: 模板上下文 + **kwargs: 额外参数 + + Returns: + bool: 是否显示 + """ + return True + + # === 各位置渲染方法 - 子类重写 === + def render_sidebar_widget(self, context, **kwargs): + """渲染侧边栏组件""" + return None + + def render_article_bottom_widget(self, context, **kwargs): + """渲染文章底部组件""" + return None + + def render_article_top_widget(self, context, **kwargs): + """渲染文章顶部组件""" + return None + + def render_header_widget(self, context, **kwargs): + """渲染页头组件""" + return None + + def render_footer_widget(self, context, **kwargs): + """渲染页脚组件""" + return None + + def render_comment_before_widget(self, context, **kwargs): + """渲染评论前组件""" + return None + + def render_comment_after_widget(self, context, **kwargs): + """渲染评论后组件""" + return None + + # === 模板系统 === + def render_template(self, template_name, context=None): + """ + 渲染插件模板 + + Args: + template_name: 模板文件名 + context: 模板上下文 + + Returns: + HTML字符串 + """ + if context is None: + context = {} + + template_path = f"plugins/{self.plugin_slug}/{template_name}" + + try: + return render_to_string(template_path, context) + except TemplateDoesNotExist: + logger.warning(f"Plugin template not found: {template_path}") + return "" + + # === 静态资源系统 === + def get_static_url(self, static_file): + """获取插件静态文件URL""" + from django.templatetags.static import static + return static(f"{self.plugin_slug}/static/{self.plugin_slug}/{static_file}") + + def get_css_files(self): + """获取插件CSS文件列表""" + return [] + + def get_js_files(self): + """获取插件JavaScript文件列表""" + return [] + + def get_head_html(self, context=None): + """获取需要插入到中的HTML内容""" + return "" + + def get_body_html(self, context=None): + """获取需要插入到底部的HTML内容""" + return "" + def get_plugin_info(self): """ 获取插件信息 @@ -37,5 +185,10 @@ class BasePlugin: return { 'name': self.PLUGIN_NAME, 'description': self.PLUGIN_DESCRIPTION, - 'version': self.PLUGIN_VERSION + 'version': self.PLUGIN_VERSION, + 'author': self.PLUGIN_AUTHOR, + 'slug': self.plugin_slug, + 'directory': str(self.plugin_dir), + 'supported_positions': self.SUPPORTED_POSITIONS, + 'priorities': self.POSITION_PRIORITIES } diff --git a/src/djangoblog/plugin_manage/hook_constants.py b/src/djangoblog/plugin_manage/hook_constants.py index 6685b7c..8ed4e89 100644 --- a/src/djangoblog/plugin_manage/hook_constants.py +++ b/src/djangoblog/plugin_manage/hook_constants.py @@ -5,3 +5,18 @@ ARTICLE_DELETE = 'article_delete' ARTICLE_CONTENT_HOOK_NAME = "the_content" +# 位置钩子常量 +POSITION_HOOKS = { + 'article_top': 'article_top_widgets', + 'article_bottom': 'article_bottom_widgets', + 'sidebar': 'sidebar_widgets', + 'header': 'header_widgets', + 'footer': 'footer_widgets', + 'comment_before': 'comment_before_widgets', + 'comment_after': 'comment_after_widgets', +} + +# 资源注入钩子 +HEAD_RESOURCES_HOOK = 'head_resources' +BODY_RESOURCES_HOOK = 'body_resources' + diff --git a/src/djangoblog/plugin_manage/loader.py b/src/djangoblog/plugin_manage/loader.py index 12e824b..ee750d0 100644 --- a/src/djangoblog/plugin_manage/loader.py +++ b/src/djangoblog/plugin_manage/loader.py @@ -4,16 +4,61 @@ from django.conf import settings logger = logging.getLogger(__name__) +# 全局插件注册表 +_loaded_plugins = [] + def load_plugins(): """ Dynamically loads and initializes plugins from the 'plugins' directory. This function is intended to be called when the Django app registry is ready. """ + global _loaded_plugins + _loaded_plugins = [] + for plugin_name in settings.ACTIVE_PLUGINS: plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): try: - __import__(f'plugins.{plugin_name}.plugin') - logger.info(f"Successfully loaded plugin: {plugin_name}") + # 导入插件模块 + plugin_module = __import__(f'plugins.{plugin_name}.plugin', fromlist=['plugin']) + + # 获取插件实例 + if hasattr(plugin_module, 'plugin'): + plugin_instance = plugin_module.plugin + _loaded_plugins.append(plugin_instance) + logger.info(f"Successfully loaded plugin: {plugin_name} - {plugin_instance.PLUGIN_NAME}") + else: + logger.warning(f"Plugin {plugin_name} does not have 'plugin' instance") + except ImportError as e: - logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) + except AttributeError as e: + logger.error(f"Failed to get plugin instance: {plugin_name}", exc_info=e) + except Exception as e: + logger.error(f"Unexpected error loading plugin: {plugin_name}", exc_info=e) + +def get_loaded_plugins(): + """获取所有已加载的插件""" + return _loaded_plugins + +def get_plugin_by_name(plugin_name): + """根据名称获取插件""" + for plugin in _loaded_plugins: + if plugin.plugin_slug == plugin_name: + return plugin + return None + +def get_plugin_by_slug(plugin_slug): + """根据slug获取插件""" + for plugin in _loaded_plugins: + if plugin.plugin_slug == plugin_slug: + return plugin + return None + +def get_plugins_info(): + """获取所有插件的信息""" + return [plugin.get_plugin_info() for plugin in _loaded_plugins] + +def get_plugins_by_position(position): + """获取支持指定位置的插件""" + return [plugin for plugin in _loaded_plugins if position in plugin.SUPPORTED_POSITIONS] \ No newline at end of file diff --git a/src/djangoblog/settings.py b/src/djangoblog/settings.py index 3269a34..e04ffcb 100644 --- a/src/djangoblog/settings.py +++ b/src/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/ @@ -60,7 +60,8 @@ INSTALLED_APPS = [ 'oauth', 'servermanager', 'owntracks', - 'compressor' + 'compressor', + 'djangoblog' ] MIDDLEWARE = [ @@ -106,15 +107,15 @@ WSGI_APPLICATION = 'djangoblog.wsgi.application' DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'djangoblog', - 'USER': 'root', - 'PASSWORD': 'password', - 'HOST': '127.0.0.1', - 'PORT': 3306, - } -} + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'djangoblogwhocare15', + 'USER': 'whocare15', + 'PASSWORD': 'IL2sXejLMkiEt8aU', + 'HOST': 'mysql5.sqlpub.com', + 'PORT': 3310, + } + } # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators @@ -151,7 +152,7 @@ USE_I18N = True USE_L10N = True -USE_TZ = True +USE_TZ = False # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.10/howto/static-files/ @@ -174,6 +175,11 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') STATIC_URL = '/static/' STATICFILES = os.path.join(BASE_DIR, 'static') +# 添加插件静态文件目录 +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'plugins'), # 让Django能找到插件的静态文件 +] + AUTH_USER_MODEL = 'accounts.BlogUser' LOGIN_URL = '/login/' @@ -298,23 +304,76 @@ STATICFILES_FINDERS = ( 'compressor.finders.CompressorFinder', ) COMPRESS_ENABLED = True -# COMPRESS_OFFLINE = True +# 根据环境变量决定是否启用离线压缩 +COMPRESS_OFFLINE = os.environ.get('COMPRESS_OFFLINE', 'False').lower() == 'true' + +# 压缩输出目录 +COMPRESS_OUTPUT_DIR = 'compressed' +# 压缩文件名模板 - 包含哈希值用于缓存破坏 +COMPRESS_CSS_HASHING_METHOD = 'mtime' +COMPRESS_JS_HASHING_METHOD = 'mtime' +# 高级CSS压缩过滤器 COMPRESS_CSS_FILTERS = [ - # creates absolute urls from relative ones + # 创建绝对URL 'compressor.filters.css_default.CssAbsoluteFilter', - # css minimizer - 'compressor.filters.cssmin.CSSMinFilter' + # CSS压缩器 - 高压缩等级 + 'compressor.filters.cssmin.CSSCompressorFilter', ] + +# 高级JS压缩过滤器 COMPRESS_JS_FILTERS = [ - 'compressor.filters.jsmin.JSMinFilter' + # JS压缩器 - 高压缩等级 + 'compressor.filters.jsmin.SlimItFilter', ] +# 压缩缓存配置 +COMPRESS_CACHE_BACKEND = 'default' +COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey' + +# 预压缩配置 +COMPRESS_PRECOMPILERS = ( + # 支持SCSS/SASS + ('text/x-scss', 'django_libsass.SassCompiler'), + ('text/x-sass', 'django_libsass.SassCompiler'), +) + +# 压缩性能优化 +COMPRESS_MINT_DELAY = 30 # 压缩延迟(秒) +COMPRESS_MTIME_DELAY = 10 # 修改时间检查延迟 +COMPRESS_REBUILD_TIMEOUT = 2592000 # 重建超时(30天) + +# 压缩等级配置 +COMPRESS_CSS_COMPRESSOR = 'compressor.css.CssCompressor' +COMPRESS_JS_COMPRESSOR = 'compressor.js.JsCompressor' + +# 静态文件缓存配置 +STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' + +# 浏览器缓存配置(通过中间件或服务器配置) +COMPRESS_URL = STATIC_URL +COMPRESS_ROOT = STATIC_ROOT + MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') MEDIA_URL = '/media/' X_FRAME_OPTIONS = 'SAMEORIGIN' +# 安全头部配置 - 防XSS和其他攻击 +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' + +# 内容安全策略 (CSP) - 防XSS攻击 +CSP_DEFAULT_SRC = ["'self'"] +CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"] +CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"] +CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"] +CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"] +CSP_CONNECT_SRC = ["'self'"] +CSP_FRAME_SRC = ["'none'"] +CSP_OBJECT_SRC = ["'none'"] + DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): @@ -328,3 +387,16 @@ 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', + 'view_count', + 'seo_optimizer', + 'image_lazy_loading', + 'article_recommendation', +] + diff --git a/src/djangoblog/spider_notify.py b/src/djangoblog/spider_notify.py index f77c09b..7b909e9 100644 --- a/src/djangoblog/spider_notify.py +++ b/src/djangoblog/spider_notify.py @@ -2,7 +2,6 @@ import logging import requests from django.conf import settings -from django.contrib.sitemaps import ping_google logger = logging.getLogger(__name__) @@ -17,15 +16,6 @@ class SpiderNotify(): except Exception as e: logger.error(e) - @staticmethod - def __google_notify(): - try: - ping_google('/sitemap.xml') - except Exception as e: - logger.error(e) - @staticmethod def notify(url): - SpiderNotify.baidu_notify(url) - SpiderNotify.__google_notify() diff --git a/src/djangoblog/urls.py b/src/djangoblog/urls.py index 4aae58a..6a9e1de 100644 --- a/src/djangoblog/urls.py +++ b/src/djangoblog/urls.py @@ -20,6 +20,8 @@ from django.contrib.sitemaps.views import sitemap from django.urls import path, include from django.urls import re_path from haystack.views import search_view_factory +from django.http import JsonResponse +import time from blog.views import EsSearchView from djangoblog.admin_site import admin_site @@ -40,8 +42,20 @@ handler404 = 'blog.views.page_not_found_view' handler500 = 'blog.views.server_error_view' handle403 = 'blog.views.permission_denied_view' + +def health_check(request): + """ + 健康检查接口 + 简单返回服务健康状态 + """ + return JsonResponse({ + 'status': 'healthy', + 'timestamp': time.time() + }) + urlpatterns = [ path('i18n/', include('django.conf.urls.i18n')), + path('health/', health_check, name='health_check'), ] urlpatterns += i18n_patterns( re_path(r'^admin/', admin_site.urls), diff --git a/src/djangoblog/utils.py b/src/djangoblog/utils.py index 57f63dc..91d2b91 100644 --- a/src/djangoblog/utils.py +++ b/src/djangoblog/utils.py @@ -224,9 +224,49 @@ def get_resource_url(): ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', - 'h2', 'p'] -ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']} - + 'h2', 'p', 'span', 'div'] + +# 安全的class值白名单 - 只允许代码高亮相关的class +ALLOWED_CLASSES = [ + 'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs', + 'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt', + 'ld', 'm', 'mf', 'mh', 'mi', 'mo', 'na', 'nb', 'nc', 'no', 'nd', 'ni', 'ne', 'nf', 'nl', 'nn', + 'nt', 'nv', 'ow', 'w', 'mb', 'mh', 'mi', 'mo', 'sb', 'sc', 'sd', 'se', 'sh', 'si', 'sx', 's2', + 's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il' +] + +def class_filter(tag, name, value): + """自定义class属性过滤器""" + if name == 'class': + # 只允许预定义的安全class值 + allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES] + return ' '.join(allowed_classes) if allowed_classes else False + return value + +# 安全的属性白名单 +ALLOWED_ATTRIBUTES = { + 'a': ['href', 'title'], + 'abbr': ['title'], + 'acronym': ['title'], + 'span': class_filter, + 'div': class_filter, + 'pre': class_filter, + 'code': class_filter +} + +# 安全的协议白名单 - 防止javascript:等危险协议 +ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] def sanitize_html(html): - return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) + """ + 安全的HTML清理函数 + 使用bleach库进行白名单过滤,防止XSS攻击 + """ + return bleach.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=ALLOWED_PROTOCOLS, # 限制允许的协议 + strip=True, # 移除不允许的标签而不是转义 + strip_comments=True # 移除HTML注释 + ) diff --git a/src/djangoblog/whoosh_cn_backend.py b/src/djangoblog/whoosh_cn_backend.py index c285cc2..04e3f7f 100644 --- a/src/djangoblog/whoosh_cn_backend.py +++ b/src/djangoblog/whoosh_cn_backend.py @@ -12,7 +12,7 @@ import warnings import six from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.utils.datetime_safe import datetime +from datetime import datetime from django.utils.encoding import force_str from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query from haystack.constants import DJANGO_CT, DJANGO_ID, ID diff --git a/src/docs/README-en.md b/src/docs/README-en.md index 4b72655..37ea069 100644 --- a/src/docs/README-en.md +++ b/src/docs/README-en.md @@ -1,122 +1,158 @@ # DjangoBlog -🌍 -*[English](README-en.md) ∙ [简体中文](README.md)* +

+ Django CI + CodeQL + codecov + license +

+ +

+ A powerful, elegant, and modern blog system. +
+ English简体中文 +

-A blog system based on `python3.8` and `Django4.0`. - - -[![Django CI](https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml/badge.svg)](https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml) [![CodeQL](https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml) [![codecov](https://codecov.io/gh/liangliangyy/DjangoBlog/branch/master/graph/badge.svg)](https://codecov.io/gh/liangliangyy/DjangoBlog) [![license](https://img.shields.io/github/license/liangliangyy/djangoblog.svg)]() - - -## Main Features: -- Articles, Pages, Categories, Tags(Add, Delete, Edit), edc. Articles and pages support `Markdown` and highlighting. -- Articles support full-text search. -- Complete comment feature, include posting reply comment and email notification. `Markdown` supporting. -- Sidebar feature: new articles, most readings, tags, etc. -- OAuth Login supported, including Google, GitHub, Facebook, Weibo, QQ. -- `Memcache` supported, with cache auto refresh. -- Simple SEO Features, notify Google and Baidu when there was a new article or other things. -- Simple picture bed feature integrated. -- `django-compressor` integrated, auto-compressed `css`, `js`. -- Website exception email notification. When there is an unhandle exception, system will send an email notification. -- Wechat official account feature integrated. Now, you can use wechat official account to manage your VPS. - -## Installation: -Change MySQL client from `pymysql` to `mysqlclient`, more details please reference [pypi](https://pypi.org/project/mysqlclient/) , checkout preperation before installation. - -Install via pip: `pip install -Ur requirements.txt` - -If you do NOT have `pip`, please use the following methods to install: -- OS X / Linux, run the following commands: - - ``` - curl http://peak.telecommunity.com/dist/ez_setup.py | python - curl https://raw.github.com/pypa/pip/master/contrib/get-pip.py | python - ``` +--- -- Windows: +DjangoBlog is a high-performance blog platform built with Python 3.10 and Django 4.0. It not only provides all the core functionalities of a traditional blog but also features a flexible plugin system, allowing you to easily extend and customize your website. Whether you are a personal blogger, a tech enthusiast, or a content creator, DjangoBlog aims to provide a stable, efficient, and easy-to-maintain environment for writing and publishing. - Download http://peak.telecommunity.com/dist/ez_setup.py and https://raw.github.com/pypa/pip/master/contrib/get-pip.py, and run with python. +## ✨ Features -### Configuration -Most configurations are in `setting.py`, others are in backend configurations. +- **Powerful Content Management**: Full support for managing articles, standalone pages, categories, and tags. Comes with a powerful built-in Markdown editor with syntax highlighting. +- **Full-Text Search**: Integrated search engine for fast and accurate content searching. +- **Interactive Comment System**: Supports replies, email notifications, and Markdown formatting in comments. +- **Flexible Sidebar**: Customizable modules for displaying recent articles, most viewed posts, tag cloud, and more. +- **Social Login**: Built-in OAuth support, with integrations for Google, GitHub, Facebook, Weibo, QQ, and other major platforms. +- **High-Performance Caching**: Native support for Redis caching with an automatic refresh mechanism to ensure high-speed website responses. +- **SEO Friendly**: Basic SEO features are included, with automatic notifications to Google and Baidu upon new content publication. +- **Extensible Plugin System**: Extend blog functionalities by creating standalone plugins, ensuring decoupled and maintainable code. We have already implemented features like view counting and SEO optimization through plugins! +- **Integrated Image Hosting**: A simple, built-in image hosting feature for easy uploads and management. +- **Automated Frontend**: Integrated with `django-compressor` to automatically compress and optimize CSS and JavaScript files. +- **Robust Operations**: Built-in email notifications for website exceptions and management capabilities through a WeChat Official Account. -I set many `setting` configuration with my environment variables (such as: `SECRET_KEY`, `OAUTH`, `mysql` and some email configuration parts.) and they did NOT been submitted to the `GitHub`. You can change these in the code with your own configuration or just add them into your environment variables. +## 🛠️ Tech Stack -Files in `test` directory are for `travis` with automatic testing. You do not need to care about this. Or just use it, in this way to integrate `travis` for automatic testing. +- **Backend**: Python 3.10, Django 4.0 +- **Database**: MySQL, SQLite (configurable) +- **Cache**: Redis +- **Frontend**: HTML5, CSS3, JavaScript +- **Search**: Whoosh, Elasticsearch (configurable) +- **Editor**: Markdown (mdeditor) -In `bin` directory, we have scripts to deploy with `Nginx`+`Gunicorn`+`virtualenv`+`supervisor` on `linux` and `Nginx` configuration file. You can reference with my article +## 🚀 Getting Started ->[DjangoBlog部署教程](https://www.lylinux.net/article/2019/8/5/58.html) +### 1. Prerequisites -More deploy detail in this article. +Ensure you have Python 3.10+ and MySQL/MariaDB installed on your system. -## Run +### 2. Clone & Installation -Modify `DjangoBlog/setting.py` with database settings, as following: +```bash +# Clone the project to your local machine +git clone https://github.com/liangliangyy/DjangoBlog.git +cd DjangoBlog -```python -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'djangoblog', - 'USER': 'root', - 'PASSWORD': 'password', - 'HOST': 'host', - 'PORT': 3306, - } -} +# Install dependencies +pip install -r requirements.txt ``` -### Create database -Run the following command in MySQL shell: -```sql -CREATE DATABASE `djangoblog` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */; -``` +### 3. Project Configuration + +- **Database**: + Open `djangoblog/settings.py`, locate the `DATABASES` section, and update it with your MySQL connection details. + + ```python + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'djangoblog', + 'USER': 'root', + 'PASSWORD': 'your_password', + 'HOST': '127.0.0.1', + 'PORT': 3306, + } + } + ``` + Create the database in MySQL: + ```sql + CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + ``` + +- **More Configurations**: + For advanced settings such as email, OAuth, caching, and more, please refer to our [Detailed Configuration Guide](/docs/config-en.md). + +### 4. Database Initialization -Run the following commands in Terminal: ```bash python manage.py makemigrations python manage.py migrate -``` -### Create super user - -Run command in terminal: -```bash +# Create a superuser account python manage.py createsuperuser ``` -### Create testing data -Run command in terminal: +### 5. Running the Project + ```bash +# (Optional) Generate some test data python manage.py create_testdata -``` -### Collect static files -Run command in terminal: -```bash +# (Optional) Collect and compress static files python manage.py collectstatic --noinput python manage.py compress --force + +# Start the development server +python manage.py runserver ``` -### Getting start to run server -Execute: `python manage.py runserver` +Now, open your browser and navigate to `http://127.0.0.1:8000/`. You should see the DjangoBlog homepage! + +## Deployment + +- **Traditional Deployment**: A detailed guide for server deployment is available here: [Deployment Tutorial](https://www.lylinux.net/article/2019/8/5/58.html) (in Chinese). +- **Docker Deployment**: This project fully supports Docker. If you are familiar with containerization, please refer to the [Docker Deployment Guide](/docs/docker-en.md) for a quick start. +- **Kubernetes Deployment**: We also provide a complete [Kubernetes Deployment Guide](/docs/k8s-en.md) to help you go cloud-native easily. + +## 🧩 Plugin System + +The plugin system is a core feature of DjangoBlog. It allows you to add new functionalities to your blog without modifying the core codebase by writing standalone plugins. -Open up a browser and visit: http://127.0.0.1:8000/ , the you will see the blog. +- **How it Works**: Plugins operate by registering callback functions to predefined "hooks". For instance, when an article is rendered, the `after_article_body_get` hook is triggered, and all functions registered to this hook are executed. +- **Existing Plugins**: Features like `view_count` and `seo_optimizer` are implemented through this plugin system. +- **Develop Your Own Plugin**: Simply create a new folder under the `plugins` directory and write your `plugin.py`. We welcome you to explore and contribute your creative ideas to the DjangoBlog community! -## More configurations -[More configurations details](/docs/config-en.md) +## 🤝 Contributing -## About the issues +We warmly welcome contributions of any kind! If you have great ideas or have found a bug, please feel free to open an issue or submit a pull request. -If you have any *question*, please use Issue or send problem descriptions to my email `liangliangyy#gmail.com`. I will reponse you as soon as possible. And, we recommend you to use Issue. +## 📄 License + +This project is open-sourced under the [MIT License](LICENSE). --- -## To Everyone 🙋‍♀️🙋‍♂️ -If this project helps you, please submit your site address [here](https://github.com/liangliangyy/DjangoBlog/issues/214) to let more people see it. -Your reply will be the driving force for me to continue to update and maintain this project. +## ❤️ Support & Sponsorship + +If you find this project helpful and wish to support its continued maintenance and development, please consider buying me a coffee! Your support is my greatest motivation. + +

+ Alipay Sponsorship + WeChat Sponsorship +

+

+ (Left) Alipay / (Right) WeChat +

+ +## 🙏 Acknowledgements -🙏🙏🙏 +A special thanks to **JetBrains** for providing a free open-source license for this project. + +

+ + JetBrains Logo + +

+ +--- +> If this project has helped you, please leave your website URL [here](https://github.com/liangliangyy/DjangoBlog/issues/214) to let more people see it. Your feedback is the driving force for my continued updates and maintenance. diff --git a/src/docs/docker.md b/src/docs/docker.md index 92af9fa..e7c255a 100644 --- a/src/docs/docker.md +++ b/src/docs/docker.md @@ -1,59 +1,114 @@ -# 使用docker部署 +# 使用 Docker 部署 DjangoBlog + ![Docker Pulls](https://img.shields.io/docker/pulls/liangliangyy/djangoblog) ![Docker Image Version (latest by date)](https://img.shields.io/docker/v/liangliangyy/djangoblog?sort=date) ![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/liangliangyy/djangoblog) -使用docker部署支持如下两种方式: -## docker镜像方式 -本项目已经支持了docker部署,如果你已经有了`mysql`,那么直接使用基础镜像即可,启动命令如下所示: -```shell -docker pull liangliangyy/djangoblog:latest -docker run -d -p 8000:8000 -e DJANGO_MYSQL_HOST=mysqlhost -e DJANGO_MYSQL_PASSWORD=mysqlrootpassword -e DJANGO_MYSQL_USER=root -e DJANGO_MYSQL_DATABASE=djangoblog --name djangoblog liangliangyy/djangoblog:latest +本项目全面支持使用 Docker 进行容器化部署,为您提供了快速、一致且隔离的运行环境。我们推荐使用 `docker-compose` 来一键启动整个博客服务栈。 + +## 1. 环境准备 + +在开始之前,请确保您的系统中已经安装了以下软件: +- [Docker Engine](https://docs.docker.com/engine/install/) +- [Docker Compose](https://docs.docker.com/compose/install/) (对于 Docker Desktop 用户,它已内置) + +## 2. 推荐方式:使用 `docker-compose` (一键部署) + +这是最简单、最推荐的部署方式。它会自动为您创建并管理 Django 应用、MySQL 数据库,以及可选的 Elasticsearch 服务。 + +### 步骤 1: 启动基础服务 + +在项目根目录下,执行以下命令: + +```bash +# 构建并以后台模式启动容器 (包含 Django 应用和 MySQL) +docker-compose up -d --build ``` -启动完成后,访问 http://127.0.0.1:8000 -## 使用docker-compose -如果你没有mysql等基础服务,那么可以使用`docker-compose`来运行, -具体命令如下所示: -```shell -docker-compose build -docker-compose up -d + +`docker-compose` 会读取 `docker-compose.yml` 文件,自动拉取所需镜像、构建项目镜像,并启动所有服务。 + +- **访问您的博客**: 服务启动后,在浏览器中访问 `http://127.0.0.1` 即可看到博客首页。 +- **数据持久化**: MySQL 的数据文件将存储在项目根目录下的 `data/mysql` 文件夹中,确保数据在容器重启后不丢失。 + +### 步骤 2: (可选) 启用 Elasticsearch 全文搜索 + +如果您希望使用 Elasticsearch 提供更强大的全文搜索功能,可以额外加载 `docker-compose.es.yml` 配置文件: + +```bash +# 构建并以后台模式启动所有服务 (Django, MySQL, Elasticsearch) +docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build ``` -本方式生成的mysql数据文件在 `bin/datas/mysql` 文件夹。 -等启动完成后,访问 [http://127.0.0.1](http://127.0.0.1) 即可。 -### 使用es -如果你期望使用es来作为后端的搜索引擎,那么可以使用如下命令来启动: -```shell -docker-compose -f docker-compose.yml -f docker-compose.es.yml build -docker-compose -f docker-compose.yml -f docker-compose.es.yml up -d +- **数据持久化**: Elasticsearch 的数据将存储在 `data/elasticsearch` 文件夹中。 + +### 步骤 3: 首次运行的初始化操作 + +当容器首次启动后,您需要进入容器来执行一些初始化命令。 + +```bash +# 进入 djangoblog 应用容器 +docker-compose exec web bash + +# 在容器内执行以下命令: +# 创建超级管理员账户 (请按照提示设置用户名、邮箱和密码) +python manage.py createsuperuser + +# (可选) 创建一些测试数据 +python manage.py create_testdata + +# (可选,如果启用了 ES) 创建索引 +python manage.py rebuild_index + +# 退出容器 +exit ``` -本方式生成的es数据文件在 `bin/datas/es` 文件夹。 -## 配置说明: - -本项目较多配置都基于环境变量,所有的环境变量如下所示: - -| 环境变量名称 | 默认值 | 备注 | -|---------------------------|----------------------------------------------------------------------------|------------------------------------------------------------------------------------------------| -| DJANGO_DEBUG | False | | -| DJANGO_SECRET_KEY | DJANGO_BLOG_CHANGE_ME | 请务必修改,建议[随机生成](https://www.random.org/passwords/?num=5&len=24&format=html&rnd=new) | -| DJANGO_MYSQL_DATABASE | djangoblog | | -| DJANGO_MYSQL_USER | root | | -| DJANGO_MYSQL_PASSWORD | djangoblog_123 | | -| DJANGO_MYSQL_HOST | 127.0.0.1 | | -| DJANGO_MYSQL_PORT | 3306 | | -| DJANGO_MEMCACHED_ENABLE | True | | -| DJANGO_MEMCACHED_LOCATION | 127.0.0.1:11211 | | -| DJANGO_BAIDU_NOTIFY_URL | http://data.zz.baidu.com/urls?site=https://www.example.org&token=CHANGE_ME | 请在[百度站长平台](https://ziyuan.baidu.com/linksubmit/index)获取接口地址 | -| DJANGO_EMAIL_TLS | False | | -| DJANGO_EMAIL_SSL | True | | -| DJANGO_EMAIL_HOST | smtp.example.org | | -| DJANGO_EMAIL_PORT | 465 | | -| DJANGO_EMAIL_USER | SMTP_USER_CHANGE_ME | | -| DJANGO_EMAIL_PASSWORD | SMTP_PASSWORD_CHANGE_ME | | -| DJANGO_ADMIN_EMAIL | admin@example.org | | -| DJANGO_WEROBOT_TOKEN | DJANGO_BLOG_CHANGE_ME -|DJANGO_ELASTICSEARCH_HOST| - -第一次启动之后,使用如下命令来创建超级用户: -```shell -docker exec -it djangoblog python /code/djangoblog/manage.py createsuperuser + +## 3. 备选方式:使用独立的 Docker 镜像 + +如果您已经拥有一个正在运行的外部 MySQL 数据库,您也可以只运行 DjangoBlog 的应用镜像。 + +```bash +# 从 Docker Hub 拉取最新镜像 +docker pull liangliangyy/djangoblog:latest + +# 运行容器,并链接到您的外部数据库 +docker run -d \ + -p 8000:8000 \ + -e DJANGO_SECRET_KEY='your-strong-secret-key' \ + -e DJANGO_MYSQL_HOST='your-mysql-host' \ + -e DJANGO_MYSQL_USER='your-mysql-user' \ + -e DJANGO_MYSQL_PASSWORD='your-mysql-password' \ + -e DJANGO_MYSQL_DATABASE='djangoblog' \ + --name djangoblog \ + liangliangyy/djangoblog:latest ``` + +- **访问您的博客**: 启动完成后,访问 `http://127.0.0.1:8000`。 +- **创建管理员**: `docker exec -it djangoblog python manage.py createsuperuser` + +## 4. 配置说明 (环境变量) + +本项目的大部分配置都通过环境变量来管理。您可以在 `docker-compose.yml` 文件中修改它们,或者在使用 `docker run` 命令时通过 `-e` 参数传入。 + +| 环境变量名称 | 默认值/示例 | 备注 | +|-------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------| +| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **请务必修改为一个随机且复杂的字符串!** | +| `DJANGO_DEBUG` | `False` | 是否开启 Django 的调试模式 | +| `DJANGO_MYSQL_HOST` | `mysql` | 数据库主机名 | +| `DJANGO_MYSQL_PORT` | `3306` | 数据库端口 | +| `DJANGO_MYSQL_DATABASE` | `djangoblog` | 数据库名称 | +| `DJANGO_MYSQL_USER` | `root` | 数据库用户名 | +| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | 数据库密码 | +| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis 连接地址 (用于缓存) | +| `DJANGO_ELASTICSEARCH_HOST` | `elasticsearch:9200` | Elasticsearch 主机地址 | +| `DJANGO_EMAIL_HOST` | `smtp.example.org` | 邮件服务器地址 | +| `DJANGO_EMAIL_PORT` | `465` | 邮件服务器端口 | +| `DJANGO_EMAIL_USER` | `user@example.org` | 邮件账户 | +| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | 邮件密码 | +| `DJANGO_EMAIL_USE_SSL` | `True` | 是否使用 SSL | +| `DJANGO_EMAIL_USE_TLS` | `False` | 是否使用 TLS | +| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | 接收异常报告的管理员邮箱 | +| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | [百度站长平台](https://ziyuan.baidu.com/linksubmit/index) 的推送接口 | + +--- + +部署完成后,请务必检查并根据您的实际需求调整这些环境变量,特别是 `DJANGO_SECRET_KEY` 和数据库、邮件相关的配置。 diff --git a/src/owntracks/views.py b/src/owntracks/views.py index 6c87a2b..4c72bdd 100644 --- a/src/owntracks/views.py +++ b/src/owntracks/views.py @@ -3,16 +3,15 @@ import datetime import itertools import json import logging +from datetime import timezone from itertools import groupby -import django.utils.timezone +import django import requests from django.contrib.auth.decorators import login_required from django.http import HttpResponse from django.http import JsonResponse from django.shortcuts import render -from django.utils import timezone -from django.utils.timezone import utc from django.views.decorators.csrf import csrf_exempt from .models import OwnTrackLog @@ -48,7 +47,7 @@ def manage_owntrack_log(request): @login_required def show_maps(request): if request.user.is_superuser: - defaultdate = str(timezone.now().date()) + defaultdate = str(datetime.datetime.now(timezone.utc).date()) date = request.GET.get('date', defaultdate) context = { 'date': date @@ -97,14 +96,13 @@ def convert_to_amap(locations): @login_required def get_datas(request): - now = django.utils.timezone.now().replace(tzinfo=utc) + now = django.utils.timezone.now().replace(tzinfo=timezone.utc) querydate = django.utils.timezone.datetime( now.year, now.month, now.day, 0, 0, 0) if request.GET.get('date', None): date = list(map(lambda x: int(x), request.GET.get('date').split('-'))) querydate = django.utils.timezone.datetime( date[0], date[1], date[2], 0, 0, 0) - querydate = django.utils.timezone.make_aware(querydate) nextdate = querydate + datetime.timedelta(days=1) models = OwnTrackLog.objects.filter( creation_time__range=(querydate, nextdate)) diff --git a/src/plugins/article_copyright/plugin.py b/src/plugins/article_copyright/plugin.py index 317fed2..5dba3b3 100644 --- a/src/plugins/article_copyright/plugin.py +++ b/src/plugins/article_copyright/plugin.py @@ -22,6 +22,11 @@ class ArticleCopyrightPlugin(BasePlugin): article = kwargs.get('article') if not article: return content + + # 如果是摘要模式(首页),不添加版权声明 + is_summary = kwargs.get('is_summary', False) + if is_summary: + return content copyright_info = f"\n

本文由 {article.author.username} 原创,转载请注明出处。

" return content + copyright_info diff --git a/src/plugins/article_recommendation/__init__.py b/src/plugins/article_recommendation/__init__.py new file mode 100644 index 0000000..951f2ff --- /dev/null +++ b/src/plugins/article_recommendation/__init__.py @@ -0,0 +1 @@ +# 文章推荐插件 diff --git a/src/plugins/article_recommendation/plugin.py b/src/plugins/article_recommendation/plugin.py new file mode 100644 index 0000000..6656a07 --- /dev/null +++ b/src/plugins/article_recommendation/plugin.py @@ -0,0 +1,205 @@ +import logging +from djangoblog.plugin_manage.base_plugin import BasePlugin +from djangoblog.plugin_manage import hooks +from djangoblog.plugin_manage.hook_constants import ARTICLE_DETAIL_LOAD +from blog.models import Article + +logger = logging.getLogger(__name__) + + +class ArticleRecommendationPlugin(BasePlugin): + PLUGIN_NAME = '文章推荐' + PLUGIN_DESCRIPTION = '智能文章推荐系统,支持多位置展示' + PLUGIN_VERSION = '1.0.0' + PLUGIN_AUTHOR = 'liangliangyy' + + # 支持的位置 + SUPPORTED_POSITIONS = ['article_bottom'] + + # 各位置优先级 + POSITION_PRIORITIES = { + 'article_bottom': 80, # 文章底部优先级 + } + + # 插件配置 + CONFIG = { + 'article_bottom_count': 8, # 文章底部推荐数量 + 'sidebar_count': 5, # 侧边栏推荐数量 + 'enable_category_fallback': True, # 启用分类回退 + 'enable_popular_fallback': True, # 启用热门文章回退 + } + + def register_hooks(self): + """注册钩子""" + hooks.register(ARTICLE_DETAIL_LOAD, self.on_article_detail_load) + + def on_article_detail_load(self, article, context, request, *args, **kwargs): + """文章详情页加载时的处理""" + # 可以在这里预加载推荐数据到context中 + recommendations = self.get_recommendations(article) + context['article_recommendations'] = recommendations + + def should_display(self, position, context, **kwargs): + """条件显示逻辑""" + # 只在文章详情页底部显示 + if position == 'article_bottom': + article = kwargs.get('article') or context.get('article') + # 检查是否有文章对象,以及是否不是索引页面 + is_index = context.get('isindex', False) if hasattr(context, 'get') else False + return article is not None and not is_index + + return False + + def render_article_bottom_widget(self, context, **kwargs): + """渲染文章底部推荐""" + article = kwargs.get('article') or context.get('article') + if not article: + return None + + # 使用配置的数量,也可以通过kwargs覆盖 + count = kwargs.get('count', self.CONFIG['article_bottom_count']) + recommendations = self.get_recommendations(article, count=count) + if not recommendations: + return None + + # 将RequestContext转换为普通字典 + context_dict = {} + if hasattr(context, 'flatten'): + context_dict = context.flatten() + elif hasattr(context, 'dicts'): + # 合并所有上下文字典 + for d in context.dicts: + context_dict.update(d) + + template_context = { + 'recommendations': recommendations, + 'article': article, + 'title': '相关推荐', + **context_dict + } + + return self.render_template('bottom_widget.html', template_context) + + def render_sidebar_widget(self, context, **kwargs): + """渲染侧边栏推荐""" + article = context.get('article') + + # 使用配置的数量,也可以通过kwargs覆盖 + count = kwargs.get('count', self.CONFIG['sidebar_count']) + + if article: + # 文章页面,显示相关文章 + recommendations = self.get_recommendations(article, count=count) + title = '相关文章' + else: + # 其他页面,显示热门文章 + recommendations = self.get_popular_articles(count=count) + title = '热门推荐' + + if not recommendations: + return None + + # 将RequestContext转换为普通字典 + context_dict = {} + if hasattr(context, 'flatten'): + context_dict = context.flatten() + elif hasattr(context, 'dicts'): + # 合并所有上下文字典 + for d in context.dicts: + context_dict.update(d) + + template_context = { + 'recommendations': recommendations, + 'title': title, + **context_dict + } + + return self.render_template('sidebar_widget.html', template_context) + + def get_css_files(self): + """返回CSS文件""" + return ['css/recommendation.css'] + + def get_js_files(self): + """返回JS文件""" + return ['js/recommendation.js'] + + def get_recommendations(self, article, count=5): + """获取推荐文章""" + if not article: + return [] + + recommendations = [] + + # 1. 基于标签的推荐 + if article.tags.exists(): + tag_ids = list(article.tags.values_list('id', flat=True)) + tag_based = list(Article.objects.filter( + status='p', + tags__id__in=tag_ids + ).exclude( + id=article.id + ).exclude( + title__isnull=True + ).exclude( + title__exact='' + ).distinct().order_by('-views')[:count]) + recommendations.extend(tag_based) + + # 2. 如果数量不够,基于分类推荐 + if len(recommendations) < count and self.CONFIG['enable_category_fallback']: + needed = count - len(recommendations) + existing_ids = [r.id for r in recommendations] + [article.id] + + category_based = list(Article.objects.filter( + status='p', + category=article.category + ).exclude( + id__in=existing_ids + ).exclude( + title__isnull=True + ).exclude( + title__exact='' + ).order_by('-views')[:needed]) + recommendations.extend(category_based) + + # 3. 如果还是不够,推荐热门文章 + if len(recommendations) < count and self.CONFIG['enable_popular_fallback']: + needed = count - len(recommendations) + existing_ids = [r.id for r in recommendations] + [article.id] + + popular_articles = list(Article.objects.filter( + status='p' + ).exclude( + id__in=existing_ids + ).exclude( + title__isnull=True + ).exclude( + title__exact='' + ).order_by('-views')[:needed]) + recommendations.extend(popular_articles) + + # 过滤掉无效的推荐 + valid_recommendations = [] + for rec in recommendations: + if rec.title and len(rec.title.strip()) > 0: + valid_recommendations.append(rec) + else: + logger.warning(f"过滤掉空标题文章: ID={rec.id}, 标题='{rec.title}'") + + # 调试:记录推荐结果 + logger.info(f"原始推荐数量: {len(recommendations)}, 有效推荐数量: {len(valid_recommendations)}") + for i, rec in enumerate(valid_recommendations): + logger.info(f"推荐 {i+1}: ID={rec.id}, 标题='{rec.title}', 长度={len(rec.title)}") + + return valid_recommendations[:count] + + def get_popular_articles(self, count=3): + """获取热门文章""" + return list(Article.objects.filter( + status='p' + ).order_by('-views')[:count]) + + +# 实例化插件 +plugin = ArticleRecommendationPlugin() diff --git a/src/plugins/article_recommendation/static/article_recommendation/css/recommendation.css b/src/plugins/article_recommendation/static/article_recommendation/css/recommendation.css new file mode 100644 index 0000000..b223f41 --- /dev/null +++ b/src/plugins/article_recommendation/static/article_recommendation/css/recommendation.css @@ -0,0 +1,166 @@ +/* 文章推荐插件样式 - 与网站风格保持一致 */ + +/* 文章底部推荐样式 */ +.article-recommendations { + margin: 30px 0; + padding: 20px; + background: #fff; + border: 1px solid #e1e1e1; + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.recommendations-title { + margin: 0 0 15px 0; + font-size: 18px; + color: #444; + font-weight: bold; + padding-bottom: 8px; + border-bottom: 2px solid #21759b; + display: inline-block; +} + +.recommendations-icon { + margin-right: 5px; + font-size: 16px; +} + +.recommendations-grid { + display: grid; + gap: 15px; + grid-template-columns: 1fr; + margin-top: 15px; +} + +.recommendation-card { + background: #fff; + border: 1px solid #e1e1e1; + border-radius: 3px; + transition: all 0.2s ease; + overflow: hidden; +} + +.recommendation-card:hover { + border-color: #21759b; + box-shadow: 0 2px 5px rgba(33, 117, 155, 0.1); +} + +.recommendation-link { + display: block; + padding: 15px; + text-decoration: none; + color: inherit; +} + +.recommendation-title { + margin: 0 0 8px 0; + font-size: 15px; + font-weight: normal; + color: #444; + line-height: 1.4; + transition: color 0.2s ease; +} + +.recommendation-card:hover .recommendation-title { + color: #21759b; +} + +.recommendation-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: #757575; +} + +.recommendation-category { + background: #ebebeb; + color: #5e5e5e; + padding: 2px 6px; + border-radius: 2px; + font-size: 11px; + font-weight: normal; +} + +.recommendation-date { + font-weight: normal; + color: #757575; +} + +/* 侧边栏推荐样式 */ +.widget_recommendations { + margin-bottom: 20px; +} + +.widget_recommendations .widget-title { + font-size: 16px; + font-weight: bold; + margin-bottom: 15px; + color: #333; + border-bottom: 2px solid #007cba; + padding-bottom: 5px; +} + +.recommendations-list { + list-style: none; + padding: 0; + margin: 0; +} + +.recommendations-list .recommendation-item { + padding: 8px 0; + border-bottom: 1px solid #eee; + background: none; + border: none; + border-radius: 0; +} + +.recommendations-list .recommendation-item:last-child { + border-bottom: none; +} + +.recommendations-list .recommendation-item a { + color: #333; + text-decoration: none; + font-size: 14px; + line-height: 1.4; + display: block; + margin-bottom: 4px; + transition: color 0.3s ease; +} + +.recommendations-list .recommendation-item a:hover { + color: #007cba; +} + +.recommendations-list .recommendation-meta { + font-size: 11px; + color: #999; + margin: 0; +} + +.recommendations-list .recommendation-meta span { + margin-right: 10px; +} + +/* 响应式设计 - 分栏显示 */ +@media (min-width: 768px) { + .recommendations-grid { + grid-template-columns: repeat(2, 1fr); + gap: 15px; + } +} + +@media (min-width: 1024px) { + .recommendations-grid { + grid-template-columns: repeat(3, 1fr); + gap: 15px; + } +} + +@media (min-width: 1200px) { + .recommendations-grid { + grid-template-columns: repeat(4, 1fr); + gap: 15px; + } +} diff --git a/src/plugins/article_recommendation/static/article_recommendation/js/recommendation.js b/src/plugins/article_recommendation/static/article_recommendation/js/recommendation.js new file mode 100644 index 0000000..eb19211 --- /dev/null +++ b/src/plugins/article_recommendation/static/article_recommendation/js/recommendation.js @@ -0,0 +1,93 @@ +/** + * 文章推荐插件JavaScript + */ + +(function() { + 'use strict'; + + // 等待DOM加载完成 + document.addEventListener('DOMContentLoaded', function() { + initRecommendations(); + }); + + function initRecommendations() { + // 添加点击统计 + trackRecommendationClicks(); + + // 懒加载优化(如果需要) + lazyLoadRecommendations(); + } + + function trackRecommendationClicks() { + const recommendationLinks = document.querySelectorAll('.recommendation-item a'); + + recommendationLinks.forEach(function(link) { + link.addEventListener('click', function(e) { + // 可以在这里添加点击统计逻辑 + const articleTitle = this.textContent.trim(); + const articleUrl = this.href; + + // 发送统计数据到后端(可选) + if (typeof gtag !== 'undefined') { + gtag('event', 'click', { + 'event_category': 'recommendation', + 'event_label': articleTitle, + 'value': 1 + }); + } + + console.log('Recommendation clicked:', articleTitle, articleUrl); + }); + }); + } + + function lazyLoadRecommendations() { + // 如果推荐内容很多,可以实现懒加载 + const recommendationContainer = document.querySelector('.article-recommendations'); + + if (!recommendationContainer) { + return; + } + + // 检查是否在视窗中 + const observer = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (entry.isIntersecting) { + entry.target.classList.add('loaded'); + observer.unobserve(entry.target); + } + }); + }, { + threshold: 0.1 + }); + + const recommendationItems = document.querySelectorAll('.recommendation-item'); + recommendationItems.forEach(function(item) { + observer.observe(item); + }); + } + + // 添加一些动画效果 + function addAnimations() { + const recommendationItems = document.querySelectorAll('.recommendation-item'); + + recommendationItems.forEach(function(item, index) { + item.style.opacity = '0'; + item.style.transform = 'translateY(20px)'; + item.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; + + setTimeout(function() { + item.style.opacity = '1'; + item.style.transform = 'translateY(0)'; + }, index * 100); + }); + } + + // 如果需要,可以在这里添加更多功能 + window.ArticleRecommendation = { + init: initRecommendations, + track: trackRecommendationClicks, + animate: addAnimations + }; + +})(); diff --git a/src/plugins/image_lazy_loading/__init__.py b/src/plugins/image_lazy_loading/__init__.py new file mode 100644 index 0000000..2d27de0 --- /dev/null +++ b/src/plugins/image_lazy_loading/__init__.py @@ -0,0 +1 @@ +# Image Lazy Loading Plugin diff --git a/src/plugins/image_lazy_loading/plugin.py b/src/plugins/image_lazy_loading/plugin.py new file mode 100644 index 0000000..b4b9e0a --- /dev/null +++ b/src/plugins/image_lazy_loading/plugin.py @@ -0,0 +1,182 @@ +import re +import hashlib +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 ImageOptimizationPlugin(BasePlugin): + PLUGIN_NAME = '图片性能优化插件' + PLUGIN_DESCRIPTION = '自动为文章中的图片添加懒加载、异步解码等性能优化属性,显著提升页面加载速度。' + PLUGIN_VERSION = '1.0.0' + PLUGIN_AUTHOR = 'liangliangyy' + + def __init__(self): + # 插件配置 + self.config = { + 'enable_lazy_loading': True, # 启用懒加载 + 'enable_async_decoding': True, # 启用异步解码 + 'add_loading_placeholder': True, # 添加加载占位符 + 'optimize_external_images': True, # 优化外部图片 + 'add_responsive_attributes': True, # 添加响应式属性 + 'skip_first_image': True, # 跳过第一张图片(LCP优化) + } + super().__init__() + + def register_hooks(self): + hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.optimize_images) + + def optimize_images(self, content, *args, **kwargs): + """ + 优化文章中的图片标签 + """ + if not content: + return content + + # 正则表达式匹配 img 标签 + img_pattern = re.compile( + r']*?)(?:\s*/)?>', + re.IGNORECASE | re.DOTALL + ) + + image_count = 0 + + def replace_img_tag(match): + nonlocal image_count + image_count += 1 + + # 获取原始属性 + original_attrs = match.group(1) + + # 解析现有属性 + attrs = self._parse_img_attributes(original_attrs) + + # 应用优化 + optimized_attrs = self._apply_optimizations(attrs, image_count) + + # 重构 img 标签 + return self._build_img_tag(optimized_attrs) + + # 替换所有 img 标签 + optimized_content = img_pattern.sub(replace_img_tag, content) + + return optimized_content + + def _parse_img_attributes(self, attr_string): + """ + 解析 img 标签的属性 + """ + attrs = {} + + # 正则表达式匹配属性 + attr_pattern = re.compile(r'(\w+)=(["\'])(.*?)\2') + + for match in attr_pattern.finditer(attr_string): + attr_name = match.group(1).lower() + attr_value = match.group(3) + attrs[attr_name] = attr_value + + return attrs + + def _apply_optimizations(self, attrs, image_index): + """ + 应用各种图片优化 + """ + # 1. 懒加载优化(跳过第一张图片以优化LCP) + if self.config['enable_lazy_loading']: + if not (self.config['skip_first_image'] and image_index == 1): + if 'loading' not in attrs: + attrs['loading'] = 'lazy' + + # 2. 异步解码 + if self.config['enable_async_decoding']: + if 'decoding' not in attrs: + attrs['decoding'] = 'async' + + # 3. 添加样式优化 + current_style = attrs.get('style', '') + + # 确保图片不会超出容器 + if 'max-width' not in current_style: + if current_style and not current_style.endswith(';'): + current_style += ';' + current_style += 'max-width:100%;height:auto;' + attrs['style'] = current_style + + # 4. 添加 alt 属性(SEO和可访问性) + if 'alt' not in attrs: + # 尝试从图片URL生成有意义的alt文本 + src = attrs.get('src', '') + if src: + # 从文件名生成alt文本 + filename = src.split('/')[-1].split('.')[0] + # 移除常见的无意义字符 + clean_name = re.sub(r'[0-9a-f]{8,}', '', filename) # 移除长hash + clean_name = re.sub(r'[_-]+', ' ', clean_name).strip() + attrs['alt'] = clean_name if clean_name else '文章图片' + else: + attrs['alt'] = '文章图片' + + # 5. 外部图片优化 + if self.config['optimize_external_images'] and 'src' in attrs: + src = attrs['src'] + parsed_url = urlparse(src) + + # 如果是外部图片,添加 referrerpolicy + if parsed_url.netloc and parsed_url.netloc != self._get_current_domain(): + attrs['referrerpolicy'] = 'no-referrer-when-downgrade' + # 为外部图片添加crossorigin属性以支持性能监控 + if 'crossorigin' not in attrs: + attrs['crossorigin'] = 'anonymous' + + # 6. 响应式图片属性(如果配置启用) + if self.config['add_responsive_attributes']: + # 添加 sizes 属性(如果没有的话) + if 'sizes' not in attrs and 'srcset' not in attrs: + attrs['sizes'] = '(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw' + + # 7. 添加图片唯一标识符用于性能追踪 + if 'data-img-id' not in attrs and 'src' in attrs: + img_hash = hashlib.md5(attrs['src'].encode()).hexdigest()[:8] + attrs['data-img-id'] = f'img-{img_hash}' + + # 8. 为第一张图片添加高优先级提示(LCP优化) + if image_index == 1 and self.config['skip_first_image']: + attrs['fetchpriority'] = 'high' + # 移除懒加载以确保快速加载 + if 'loading' in attrs: + del attrs['loading'] + + return attrs + + def _build_img_tag(self, attrs): + """ + 重新构建 img 标签 + """ + attr_strings = [] + + # 确保 src 属性在最前面 + if 'src' in attrs: + attr_strings.append(f'src="{attrs["src"]}"') + + # 添加其他属性 + for key, value in attrs.items(): + if key != 'src': # src 已经添加过了 + attr_strings.append(f'{key}="{value}"') + + return f'' + + def _get_current_domain(self): + """ + 获取当前网站域名 + """ + try: + from djangoblog.utils import get_current_site + return get_current_site().domain + except: + return '' + + +# 实例化插件 +plugin = ImageOptimizationPlugin() diff --git a/src/plugins/reading_time/plugin.py b/src/plugins/reading_time/plugin.py index 35f9db1..4b929d8 100644 --- a/src/plugins/reading_time/plugin.py +++ b/src/plugins/reading_time/plugin.py @@ -17,7 +17,15 @@ class ReadingTimePlugin(BasePlugin): def add_reading_time(self, content, *args, **kwargs): """ 计算阅读时间并添加到内容开头。 + 只在文章详情页显示,首页(文章列表页)不显示。 """ + # 检查是否为摘要模式(首页/文章列表页) + # 通过kwargs中的is_summary参数判断 + is_summary = kwargs.get('is_summary', False) + if is_summary: + # 如果是摘要模式(首页),直接返回原内容,不添加阅读时间 + return content + # 移除HTML标签和空白字符,以获得纯文本 clean_content = re.sub(r'<[^>]*>', '', content) clean_content = clean_content.strip() diff --git a/src/plugins/seo_optimizer/plugin.py b/src/plugins/seo_optimizer/plugin.py index b5b19a3..de12c15 100644 --- a/src/plugins/seo_optimizer/plugin.py +++ b/src/plugins/seo_optimizer/plugin.py @@ -97,6 +97,8 @@ class SeoOptimizerPlugin(BasePlugin): structured_data = { "@context": "https://schema.org", "@type": "WebSite", + "name": blog_setting.site_name, + "description": blog_setting.site_description, "url": request.build_absolute_uri('/'), "potentialAction": { "@type": "SearchAction", @@ -131,12 +133,15 @@ class SeoOptimizerPlugin(BasePlugin): json_ld_script = f'' - return f""" + seo_html = f""" {seo_data.get("title", "")} {seo_data.get("meta_tags", "")} {json_ld_script} """ + + # 将SEO内容追加到现有的metas内容上 + return metas + seo_html plugin = SeoOptimizerPlugin() diff --git a/src/requirements.txt b/src/requirements.txt index 70457c3..e5878ab 100644 Binary files a/src/requirements.txt and b/src/requirements.txt differ diff --git a/src/templates/blog/article_detail.html b/src/templates/blog/article_detail.html index f694db3..a74a0db 100644 --- a/src/templates/blog/article_detail.html +++ b/src/templates/blog/article_detail.html @@ -2,30 +2,6 @@ {% load blog_tags %} {% block header %} - {{ article.title }} | {{ SITE_DESCRIPTION }} - - - - - - - - - - - {% for t in article.tags.all %} - - {% endfor %} - - - - {% if article.tags %} - - {% else %} - - {% endif %} - {% endblock %} {% block content %}
diff --git a/src/templates/blog/tags/article_info.html b/src/templates/blog/tags/article_info.html index 3deec44..65b45fa 100644 --- a/src/templates/blog/tags/article_info.html +++ b/src/templates/blog/tags/article_info.html @@ -48,7 +48,7 @@
{% if isindex %} - {{ article.body|custom_markdown|escape|truncatechars_content }} + {% render_article_content article True %}

Read more

{% else %} @@ -62,7 +62,7 @@ {% endif %}
- {{ article.body|custom_markdown|escape }} + {% render_article_content article False %}
{% endif %} @@ -71,4 +71,9 @@ {% load_article_metas article user %} - \ No newline at end of file + + + +{% if not isindex %} + {% render_plugin_widgets 'article_bottom' article=article %} +{% endif %} \ No newline at end of file diff --git a/src/templates/blog/tags/article_meta_info.html b/src/templates/blog/tags/article_meta_info.html index cb6111c..ec8a0f9 100644 --- a/src/templates/blog/tags/article_meta_info.html +++ b/src/templates/blog/tags/article_meta_info.html @@ -5,9 +5,6 @@
{% trans 'posted in' %} {{ article.category.name }} - - - {% if article.type == 'a' %} {% if article.tags.all %} @@ -46,13 +43,14 @@ title="{% datetimeformat article.pub_time %}" itemprop="datePublished" content="{% datetimeformat article.pub_time %}" rel="bookmark"> - - - {% if user.is_superuser %} - {% trans 'edit' %} - {% endif %} + + + {% if user.is_superuser %} + {% trans 'edit' %} + {% endif %}
diff --git a/src/templates/blog/tags/sidebar.html b/src/templates/blog/tags/sidebar.html index f70544c..ecb6d20 100644 --- a/src/templates/blog/tags/sidebar.html +++ b/src/templates/blog/tags/sidebar.html @@ -16,7 +16,7 @@ {% endfor %} diff --git a/src/templates/comments/tags/comment_item.html b/src/templates/comments/tags/comment_item.html index ebb0388..0693649 100644 --- a/src/templates/comments/tags/comment_item.html +++ b/src/templates/comments/tags/comment_item.html @@ -2,10 +2,13 @@
  • - + class="avatar avatar-96 photo" + loading="lazy" + decoding="async" + style="max-width:100%;height:auto;">
    diff --git a/src/templates/plugins/article_recommendation/sidebar_widget.html b/src/templates/plugins/article_recommendation/sidebar_widget.html new file mode 100644 index 0000000..5f1afbf --- /dev/null +++ b/src/templates/plugins/article_recommendation/sidebar_widget.html @@ -0,0 +1,17 @@ +{% load i18n %} + diff --git a/src/templates/plugins/css_includes.html b/src/templates/plugins/css_includes.html new file mode 100644 index 0000000..37029ae --- /dev/null +++ b/src/templates/plugins/css_includes.html @@ -0,0 +1,4 @@ +{% comment %}插件CSS文件包含模板 - 用于压缩{% endcomment %} +{% for css_file in css_files %} + +{% endfor %} diff --git a/src/templates/plugins/js_includes.html b/src/templates/plugins/js_includes.html new file mode 100644 index 0000000..2a315e3 --- /dev/null +++ b/src/templates/plugins/js_includes.html @@ -0,0 +1,4 @@ +{% comment %}插件JS文件包含模板 - 用于压缩{% endcomment %} +{% for js_file in js_files %} + +{% endfor %} diff --git a/src/templates/share_layout/base.html b/src/templates/share_layout/base.html index d3e0e42..bb17933 100644 --- a/src/templates/share_layout/base.html +++ b/src/templates/share_layout/base.html @@ -16,23 +16,35 @@ - + + + + {% load blog_tags %} + {% head_meta %} {% block header %} + {% endblock %} + + + + + + - - - - {% compress css %} + + + + + + + + {% compress css %} @@ -44,10 +56,16 @@ {% block compress_css %} {% endblock %} + + {% plugin_compressed_css %} {% endcompress %} + {% if GLOBAL_HEADER %} {{ GLOBAL_HEADER|safe }} {% endif %} + + + {% plugin_head_resources %} @@ -59,26 +77,6 @@

    {{ SITE_DESCRIPTION }}

    {% load i18n %} - - -{#
    #} -{#
    {% csrf_token %}#} -{# #} -{# #} -{# #} -{#
    #} -{#
    #} - - {% include 'share_layout/nav.html' %} @@ -97,19 +95,25 @@ {% include 'share_layout/footer.html' %}
    - -
    - - {% compress js %} - - - - - - {% block compress_js %} - {% endblock %} - {% endcompress %} - {% block footer %} + +{% compress js %} + + + + + {% block compress_js %} {% endblock %} -
    + + {% plugin_compressed_js %} +{% endcompress %} + + + + +{% block footer %} +{% endblock %} + + +{% plugin_body_resources %} +