这篇文章讲怎么给 Pelican 加独立内容板块——否则所有文章混在 articles/ 里,首页一个列表到底。方案本身只有几行配置 + 一个 28 行信号插件 + 模板过滤。
往下是完整实现和踩坑过程。
需求
目标是在现有博客里插入「AI 日报」板块:
- 日报文章放在独立目录
digests/(不跟articles/混) - 首页分两区:博客文章列表 + 日报板块
- 日报有独立列表页
/digests.html - 日报文章 URL 是
/digests/xxx.html,不是/articles/xxx.html - 一条命令完成全站构建
核心问题很明确:让 Pelican 从两个目录读文章,对其中一个做不同的路由和展示。
第一次尝试:只改 PATH
起初我以为改 ARTICLE_PATHS 就行,加上后 Pelican 确实把两个目录的文章都读了——但所有文章按同一套模板输出,URL 也是同一个模式。digests/ 的文章和 articles/ 的文章在输出目录里混在一起。
解决思路:用一个信号插件在文章生成后改写 URL,路由到不同子目录。
多源目录配置
改 pelicanconf.py:
PATH = '.'
OUTPUT_PATH = 'site/'
ARTICLE_PATHS = ['articles', 'digests']
ARTICLE_URL = '{slug}.html'
ARTICLE_SAVE_AS = '{slug}.html'
PATH = '.' 让 Pelican 从项目根目录扫描,ARTICLE_PATHS 指定只扫 articles/ 和 digests/ 两个子目录。ARTICLE_URL 从原来的 articles/{slug}.html 改为 {slug}.html——这个改变有副作用,下面会讲。
PATH 扫描范围的陷阱:
PATH = '.'后,如果项目根目录下有其他.md文件(如pelican-plugins/README.md),也会被 Pelican 当成文章读入——不是你想要的。ARTICLE_PATHS是对应路径的白名单,务必明确指定。
路由插件
配置改完后,两个目录的文章都按 {slug}.html 输出,堆在一起。需要让 articles/ 走默认,digests/ 走到子目录。
用 Pelican 的信号机制实现——article_generator_finalized 信号在文章对象生成后、写入磁盘前触发,是为 override_save_as/override_url 赋值的最佳时机:
"""Pelican plugin: route articles to subdirectories based on source path."""
import os
from pelican import signals
def route_by_source_dir(article_generator):
for article in article_generator.articles:
src = article.get_relative_source_path()
if not src:
continue
parent = os.path.dirname(src) # 'digests' 或 'articles'
if parent and parent != 'articles':
article.override_save_as = f'{parent}/{article.slug}.html'
article.override_url = f'{parent}/{article.slug}.html'
def register():
signals.article_generator_finalized.connect(route_by_source_dir)
放到 pelican-plugins/digest_dir.py,在 pelicanconf.py 末尾注册:
import importlib
digest_dir = importlib.import_module('digest_dir')
digest_dir.register()
先用 sys.path.insert(0, 'pelican-plugins') 把插件目录加到 Python 路径(如果还没加的话)。
关键设计点:
- 信号时机是硬约束:挂在
article_generator_finalized——此时文章对象已生成,但 writer 还没计算保存路径。如果选更晚的信号,override_save_as会被忽略。实测过挂到article_writer_finalized上,输出文件和site/里报错说明覆盖被忽略 - 只改非
articles/:articles/下的不设 override,维持默认输出路径 get_relative_source_path():返回相对于PATH的路径——不是绝对路径,不是文件名。这个 API 在 Pelican 4.x 稳定可用
用 Frontmatter 做类型标记
两类文章需要在模板里区分。两套方案对比:
方案一(我用的):在 frontmatter 加 type: digest。如果你用自定义 reader(比如 YamlMarkdownReader),它能通过 process_metadata 的 fallback 透传非保留字段。模板里用 article.type != 'digest' 过滤。
---
title: AI 编程工具日报:2026年6月10日
date: 2026-06-10
type: digest
slug: ai-digest-2026-06-10
---
方案二(更简单):用 Pelican 自带的 category 字段——category: digest,效果一样。但 category 在其他地方也会被用到(标签页、RSS),如果你不需要这些自动关联,用自定义 type 字段更干净。
首页模板分两区
首页 index.html 两处改动:博客列表过滤掉 digest,底部加日报板块。
博客列表过滤:
{% for article in articles_page.object_list %}
{% if article.type != 'digest' %}
<li>
<a href="{{ SITEURL }}/{{ article.url }}">{{ article.title }}</a>
<span class="date">{{ article.date.strftime('%Y-%m-%d') }}</span>
</li>
{% endif %}
{% endfor %}
日报板块——这里有一个容易踩的坑:
{# ❌ 这不行:{% set %} 在循环作用域内不持久 #}
{% set has_digests = false %}
{% for a in articles %}
{% if a.type == 'digest' %}
{% set has_digests = true %} {# 循环结束就丢了 #}
{% endif %}
{% endfor %}
{# ✅ 正确做法:用 namespace 跨作用域 #}
{% set ns = namespace(has_digests=false) %}
{% for a in articles %}
{% if a.type == 'digest' %}
{% set ns.has_digests = true %}
{% endif %}
{% endfor %}
{% if ns.has_digests %}
<h2 class="section-title">📡 AI 工具日报</h2>
<ul class="article-list">
{% for a in articles %}
{% if a.type == 'digest' %}
<li>
<a href="{{ SITEURL }}/{{ a.url }}">{{ a.title }}</a>
{% if a.description %}<div class="desc">{{ a.description }}</div>{% endif %}
<span class="date">{{ a.date.strftime('%Y-%m-%d') }}</span>
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
namespace 是 Jinja2 2.10+ 专门为跨作用域修改变量设计的机制。{% set ns.has_digests = true %} 里的 {% set %} 不能省略——直接在模板标签里写 ns.has_digests = true 会被 Jinja2 当作普通文本输出。
Slug 一致性
文件名 2026-06-09-ai-digest.md,Pelican 的 FILENAME_METADATA 配置是:
FILENAME_METADATA = r'(?P<date>\d{4}-\d{2}-\d{2})-(?P<slug>.*)'
从文件名提取的 slug 是 ai-digest。
但如果 frontmatter 里写了 slug: ai-digest-2026-06-09,Pelican 优先使用 frontmatter 的 slug。而 build-digests-page.py(下面会讲)也读 frontmatter 的 slug——如果两处不一致,链接就 404。
解决:frontmatter 里显式写 slug:,Pelican 和独立脚本读到同一个值:
slug: ai-digest-2026-06-09
日报列表页
首页展示最新几条日报就够了,但需要一个独立列表页 /digests.html。Pelican 没有原生机制为某个分类生成列表页(Page 和 Article 是两套管线,Page 模板拿不到 articles 列表),我写了一个独立脚本,在 Pelican 构建后运行:
"""Generate site/digests.html from digests/*.md frontmatter.
Run after Pelican build."""
import re, yaml
from pathlib import Path
PROJECT = Path(__file__).resolve().parent.parent
DIGESTS_DIR = PROJECT / 'digests'
OUTPUT = PROJECT / 'site' / 'digests.html'
YAML_RE = re.compile(r'^---\s*\n(.*?)\n(?:---|\.\.\.)\s*\n?', re.DOTALL)
def parse_frontmatter(md_path):
text = md_path.read_text('utf-8')
m = YAML_RE.match(text)
if not m:
return {}
try:
return yaml.safe_load(m.group(1)) or {}
except yaml.YAMLError:
return {}
def main():
entries = []
for f in sorted(DIGESTS_DIR.glob('*.md'), reverse=True):
meta = parse_frontmatter(f)
if meta.get('type') != 'digest': # 双重过滤
continue
slug = meta.get('slug', f.stem)
entries.append({
'title': meta.get('title', f.stem),
'description': meta.get('description', ''),
'url': f'digests/{slug}.html',
})
if not entries:
print("No digest entries found")
return
items_html = '\n'.join(
f'<li><a href="{e["url"]}">{e["title"]}</a></li>'
for e in entries
)
html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>AI 工具日报</title></head>
<body><h1>📡 AI 工具日报</h1><p class="subtitle">精选 AI 编程工具、Agent 框架、大模型动态</p><ul>{items_html}</ul><p><a href="index.html">← 返回首页</a></p></body>
</html>'''
OUTPUT.write_text(html, encoding='utf-8')
print(f"Generated {OUTPUT} ({len(entries)} digests)")
if __name__ == '__main__':
main()
脚本只依赖 yaml 和 pathlib,跟 Pelican 完全解耦。好处是双重过滤——首页模板用 article.type != 'digest',列表页脚本用 meta.get('type') != 'digest',各自验证各自的。如果某篇日报漏写 type: digest,两处都不显示——算是一种安全设计。
也可以用 Pelican 的
DIRECT_TEMPLATES配置配合自定义模板在 Pelican 管线内生成列表页。但我试过:要把articles变量注入到自定义模板需要额外 hack,不如独立脚本控制力强。如果你习惯在 Pelican 内完成一切,可以探索DIRECT_TEMPLATES路线。
构建流程
整合到部署脚本,三步完成:
# 1. Pelican 构建(生成 articles + digests 的 HTML)
source .venv-pelican/bin/activate
pelican -s pelicanconf.py
# 2. 日报列表页(独立生成)
python3 scripts/build-digests-page.py
deactivate
# 3. 部署
npx wrangler pages deploy site/ --project-name my-blog
# 4. 提交 Git
git add -A && git commit -m "docs: 新增日报文章"
git push origin main
构建顺序很重要:先 Pelican 生成单篇文章,再跑脚本生成列表页。顺序反了列表页的链接指向不存在的文件。
踩坑清单
整个实现改动量不到 100 行,但踩坑花了不止一倍时间:
1. 🔴 先确认当前构建引擎
项目从自制的 update-index.py 迁移到了 Pelican,我接手时没确认,对着废弃脚本改了半天。改代码前先看 Makefile / publish-website.sh / pelicanconf.py,确认哪个入口是活的。
2. 🟡 PATH = ‘.’ 后不要用裸目录名匹配 {path}% 变量
PATH = '.' 后,如果把路径变量写进 ARTICLE_URL(如 {path}/{slug}.html),{% emoji_ball %} {path} 变量返回的是文件的完整相对路径含文件名,不是纯目录名——比如 digests/2026-06-09-ai-digest.md。用它做 URL 会导致嵌套目录结构乱掉。所以改为 {slug}.html,通过插件做精细控制。
3. 🟡 ARTICLE_URL 变更影响现有链接
ARTICLE_URL 从 articles/{slug}.html 改为 {slug}.html,已有文章的 URL 全变了。如果旧链接有 SEO 积累或外部引用,这是破坏性变更。
我的做法:通过 digest_dir.py 插件控制——articles/ 的文章不设 override(维持扁平 URL),digests/ 的路由到子目录。但扁平化本身还是改变了旧文章的 URL。如果在乎旧链接,保留 articles/{slug}.html 模板,在插件里写对不同目录做不同前缀:articles/ 的设 articles/{slug}.html,digests/ 的设 digests/{slug}.html。
4. 🟡 Jinja2 namespace 作用域
{% set flag = True %} 在循环内不持久,这是 Jinja2 跟 Python 最大的不同。用 namespace 对象跨循环持久化变量。写模板时永远先确认变量作用域。
5. 🟡 双管道 slug 一致性
Pelican 从 FILENAME_METADATA 正则提取 slug,独立脚本读 frontmatter 的 slug。如果两处不一样——比如文件名是 2026-06-09-ai-digest.md(slug=ai-digest),而 frontmatter 里写了 slug: ai-digest-2026-06-09——日报列表页的链接会 404。
解决:frontmatter 里显式写 slug:,Pelican 优先使用 frontmatter 的 slug 覆盖文件名提取的值。
6. 🟢 type 字段透传
自定义 reader 的 process_metadata 对非保留字段的 fallback 是返回 value.strip()。如果 type 被意外过滤或转换(比如被误转为 None),模板中的 article.type 会是 None,过滤逻辑失效。检查自定义 reader 的 _build_metadata 方法,确保非保留字段不被丢弃。
7. 🟢 双重过滤是设计不是冗余
首页模板和列表页脚本各自做了一次 type != 'digest' 过滤。两条管道独立运行,各自负责自己的正确性。漏写 type: digest 的文章在两处都不显示——算是一种安全设计。
总结
给 Pelican 加独立内容板块的核心模式:多源目录(ARTICLE_PATHS)+ 信号路由插件 + frontmatter 分类 + 模板过滤。
改动量很小——配置 3 行、插件 28 行、模板若干。这套方案不限于「日报」场景,换成「周刊」「教程」「作品集」都能复用,只需要改 frontmatter 的 type 值和列表页标题。
如果你有更复杂的路由需求——比如三级目录、按标签自动分组——可以考虑用 Pelican 的 generators 钩子写自定义生成器。但对大多数静态博客来说,上面的模式已经足够灵活。
涉及文件一览
| 文件 | 操作 | 说明 |
|---|---|---|
pelican-plugins/digest_dir.py |
新增 | 28 行信号插件,按源目录路由 |
scripts/build-digests-page.py |
新增 | 独立日报列表页生成器 |
pelican-theme/templates/index.html |
修改 | 分板块展示 + namespace |
pelicanconf.py |
修改 | PATH/ARTICLE_PATHS/ARTICLE_URL |
scripts/publish-website.sh |
修改 | 构建步骤加日报页面生成 |