449 lines
12 KiB
Python
Executable File
449 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Plan a MyBlog article path and frontmatter from a topic."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Rule:
|
|
keywords: tuple[str, ...]
|
|
directory: str
|
|
category: str
|
|
tags: tuple[str, ...]
|
|
icon: str
|
|
requires_sidebar_update: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NewCategoryRule:
|
|
keywords: tuple[str, ...]
|
|
top_directory: str
|
|
category: str
|
|
tags: tuple[str, ...]
|
|
icon: str
|
|
requires_sidebar_update: bool = False
|
|
nested_under_slug: str | None = None
|
|
|
|
|
|
RULES: tuple[Rule, ...] = (
|
|
Rule(
|
|
('ai', 'llm', 'agent', 'codex', 'claude', 'opencode', 'chatgpt', 'openai', 'gemini', 'iflow', 'openclaw'),
|
|
'src/ai',
|
|
'AI',
|
|
('AI', '工具'),
|
|
'fa6-solid:robot',
|
|
True,
|
|
),
|
|
Rule(
|
|
('docker', '容器', '镜像', 'compose', 'docker-compose'),
|
|
'src/programming/docker',
|
|
'Docker',
|
|
('Docker', '容器'),
|
|
'mdi:docker',
|
|
),
|
|
Rule(
|
|
('java', 'spring', 'spring boot', 'maven', 'jdk', 'jar'),
|
|
'src/programming/backend/java/功能整理',
|
|
'Java',
|
|
('Java', '后端'),
|
|
'mdi:language-java',
|
|
),
|
|
Rule(
|
|
('gin', 'gorm'),
|
|
'src/programming/backend/go/Web开发数据库',
|
|
'Go',
|
|
('Go', '后端'),
|
|
'mdi:language-go',
|
|
),
|
|
Rule(
|
|
('go', 'golang', '并发', 'goroutine', 'channel'),
|
|
'src/programming/backend/go/Go并发模型',
|
|
'Go',
|
|
('Go', '并发'),
|
|
'mdi:language-go',
|
|
),
|
|
Rule(
|
|
('linux mint', 'mint'),
|
|
'src/programming/linux/Linux_Mint',
|
|
'Linux',
|
|
('Linux Mint', '系统配置'),
|
|
'simple-icons:linuxmint',
|
|
),
|
|
Rule(
|
|
('linux', 'ssh', 'nginx', 'vnc', '系统配置', '凝思'),
|
|
'src/programming/linux/基础',
|
|
'Linux',
|
|
('Linux', '运维'),
|
|
'mdi:linux',
|
|
),
|
|
Rule(
|
|
('vue', '前端'),
|
|
'src/programming/frontend/vue',
|
|
'前端',
|
|
('Vue', '前端'),
|
|
'mdi:vuejs',
|
|
),
|
|
Rule(
|
|
('css',),
|
|
'src/programming/frontend/css',
|
|
'前端',
|
|
('CSS', '前端'),
|
|
'mdi:language-css3',
|
|
),
|
|
Rule(
|
|
('html',),
|
|
'src/programming/frontend/html',
|
|
'前端',
|
|
('HTML', '前端'),
|
|
'mdi:language-html5',
|
|
),
|
|
Rule(
|
|
('vscode', 'cursor'),
|
|
'src/programming/frontend/tools',
|
|
'前端工具',
|
|
('工具', '前端'),
|
|
'mdi:tools',
|
|
),
|
|
Rule(
|
|
('自建', 'jellyfin', 'rustdesk', 'nas', '服务'),
|
|
'src/apps',
|
|
'应用',
|
|
('自建服务', '应用'),
|
|
'mdi:apps',
|
|
),
|
|
Rule(
|
|
('windows', 'scoop', 'wsl', 'mobaxterm', 'google', 'gitee', '工具'),
|
|
'src/tools',
|
|
'工具',
|
|
('工具',),
|
|
'mdi:toolbox',
|
|
),
|
|
Rule(
|
|
('工作总结', '项目交付', '权限', '业务记录', '项目总结'),
|
|
'src/work/project-summary',
|
|
'工作',
|
|
('工作记录', '项目总结'),
|
|
'mdi:book-open-page-variant',
|
|
),
|
|
)
|
|
|
|
|
|
NEW_CATEGORY_RULES: tuple[NewCategoryRule, ...] = (
|
|
NewCategoryRule(
|
|
('ai', 'llm', 'agent', 'agents', '模型', '大模型', '智能体', 'codex', 'claude', 'opencode', 'chatgpt', 'openai', 'gemini', 'iflow', 'openclaw'),
|
|
'src/ai',
|
|
'AI',
|
|
('AI', '工具'),
|
|
'fa6-solid:robot',
|
|
True,
|
|
),
|
|
NewCategoryRule(
|
|
('自建', '部署', '服务', '相册', '影视', 'nas', 'docker compose', 'compose', 'immich', 'jellyfin', 'rustdesk'),
|
|
'src/apps',
|
|
'应用',
|
|
('自建服务', '应用'),
|
|
'mdi:apps',
|
|
),
|
|
NewCategoryRule(
|
|
('工具', '效率', '客户端', '笔记', '浏览器', '插件', '工作流', 'obsidian', 'scoop', 'wsl', 'mobaxterm', 'google', 'gitee'),
|
|
'src/tools',
|
|
'工具',
|
|
('工具', '效率'),
|
|
'mdi:toolbox',
|
|
),
|
|
NewCategoryRule(
|
|
('工作', '业务', '交付', '记录', '合同', '审批', '流程', '项目总结', '客户', '需求'),
|
|
'src/work/project-summary',
|
|
'工作',
|
|
('工作记录', '项目总结'),
|
|
'mdi:book-open-page-variant',
|
|
),
|
|
NewCategoryRule(
|
|
('python', '数据分析', '框架', '开发', '编程', '后端', '前端', '语言', '环境搭建', '数据库', '算法'),
|
|
'src/programming',
|
|
'编程',
|
|
('编程',),
|
|
'fa6-solid:code',
|
|
),
|
|
)
|
|
|
|
|
|
def normalize_topic(topic: str) -> str:
|
|
return topic.strip()
|
|
|
|
|
|
def slugify(topic: str) -> str:
|
|
cleaned = re.sub(r'[\\/:*?"<>|#%{}[\]^`]+', '', topic.strip())
|
|
cleaned = re.sub(r'\s+', '-', cleaned)
|
|
cleaned = cleaned.strip('.-_')
|
|
|
|
ascii_slug = re.sub(r'[^a-zA-Z0-9\u4e00-\u9fff._-]+', '-', cleaned)
|
|
ascii_slug = re.sub(r'-{2,}', '-', ascii_slug).strip('-')
|
|
|
|
if re.search(r'[\u4e00-\u9fff]', ascii_slug):
|
|
return ascii_slug or '未命名文章'
|
|
|
|
return ascii_slug.lower() or 'untitled'
|
|
|
|
|
|
def score_rule(topic_lower: str, rule: Rule) -> int:
|
|
return sum(1 for keyword in rule.keywords if keyword.lower() in topic_lower)
|
|
|
|
|
|
def specificity_score(topic_lower: str, rule: Rule) -> int:
|
|
return sum(len(keyword) for keyword in rule.keywords if keyword.lower() in topic_lower)
|
|
|
|
|
|
def score_new_category_rule(topic_lower: str, rule: NewCategoryRule) -> int:
|
|
return sum(1 for keyword in rule.keywords if keyword.lower() in topic_lower)
|
|
|
|
|
|
def new_category_specificity_score(topic_lower: str, rule: NewCategoryRule) -> int:
|
|
return sum(len(keyword) for keyword in rule.keywords if keyword.lower() in topic_lower)
|
|
|
|
|
|
def choose_rules(topic: str) -> tuple[list[tuple[Rule, int]], bool]:
|
|
topic_lower = topic.lower()
|
|
scored = [(rule, score_rule(topic_lower, rule)) for rule in RULES]
|
|
matches = sorted(
|
|
[(rule, score) for rule, score in scored if score > 0],
|
|
key=lambda item: (item[1], specificity_score(topic_lower, item[0])),
|
|
reverse=True,
|
|
)
|
|
if not matches:
|
|
fallback = [
|
|
Rule((), 'src/programming', '编程', ('编程',), 'fa6-solid:code'),
|
|
Rule((), 'src/apps', '应用', ('自建服务', '应用'), 'mdi:apps'),
|
|
Rule((), 'src/tools', '工具', ('工具',), 'mdi:toolbox'),
|
|
Rule((), 'src/work/project-summary', '工作', ('工作记录',), 'mdi:book-open-page-variant'),
|
|
Rule((), 'src/ai', 'AI', ('AI', '工具'), 'fa6-solid:robot', True),
|
|
]
|
|
return [(rule, 0) for rule in fallback], False
|
|
|
|
top_score = matches[0][1]
|
|
top_specificity = specificity_score(topic_lower, matches[0][0])
|
|
top_matches = [
|
|
item
|
|
for item in matches
|
|
if item[1] == top_score and specificity_score(topic_lower, item[0]) == top_specificity
|
|
]
|
|
return matches[:3], len(top_matches) > 1
|
|
|
|
|
|
def choose_new_category_rule(topic: str) -> tuple[NewCategoryRule, int, bool]:
|
|
topic_lower = topic.lower()
|
|
scored = [(rule, score_new_category_rule(topic_lower, rule)) for rule in NEW_CATEGORY_RULES]
|
|
matches = sorted(
|
|
[(rule, score) for rule, score in scored if score > 0],
|
|
key=lambda item: (item[1], new_category_specificity_score(topic_lower, item[0])),
|
|
reverse=True,
|
|
)
|
|
if not matches:
|
|
return (
|
|
NewCategoryRule(
|
|
(),
|
|
'src/programming',
|
|
'编程',
|
|
('编程',),
|
|
'fa6-solid:code',
|
|
),
|
|
0,
|
|
False,
|
|
)
|
|
|
|
top_score = matches[0][1]
|
|
top_specificity = new_category_specificity_score(topic_lower, matches[0][0])
|
|
top_matches = [
|
|
item
|
|
for item in matches
|
|
if item[1] == top_score and new_category_specificity_score(topic_lower, item[0]) == top_specificity
|
|
]
|
|
return matches[0][0], matches[0][1], len(top_matches) > 1
|
|
|
|
|
|
def existing_files(directory: str) -> list[str]:
|
|
path = REPO_ROOT / directory
|
|
if not path.exists():
|
|
return []
|
|
return sorted(child.name for child in path.glob('*.md'))[:12]
|
|
|
|
|
|
def infer_category_slug(topic: str, rule: NewCategoryRule) -> str:
|
|
topic_lower = topic.lower()
|
|
latin_keywords = [
|
|
keyword
|
|
for keyword in rule.keywords
|
|
if re.search(r'[a-zA-Z0-9]', keyword) and keyword.lower() in topic_lower
|
|
]
|
|
if latin_keywords:
|
|
return slugify(max(latin_keywords, key=len))
|
|
|
|
if '合同' in topic and '审批' in topic:
|
|
return '合同审批'
|
|
|
|
chinese_keywords = [
|
|
keyword
|
|
for keyword in rule.keywords
|
|
if re.search(r'[\u4e00-\u9fff]', keyword) and keyword in topic
|
|
]
|
|
if chinese_keywords:
|
|
return slugify(''.join(chinese_keywords[:2]))
|
|
|
|
return slugify(topic)
|
|
|
|
|
|
def frontmatter(topic: str, rule: Rule, date: str) -> dict[str, object]:
|
|
return {
|
|
'title': topic,
|
|
'icon': rule.icon,
|
|
'date': date,
|
|
'category': [rule.category],
|
|
'tag': list(rule.tags),
|
|
}
|
|
|
|
|
|
def new_category_readme_frontmatter(topic: str, rule: NewCategoryRule) -> dict[str, object]:
|
|
return {
|
|
'title': topic,
|
|
'index': False,
|
|
'icon': rule.icon,
|
|
'category': [rule.category],
|
|
}
|
|
|
|
|
|
def article_frontmatter_from_new_category(
|
|
topic: str,
|
|
rule: NewCategoryRule,
|
|
date: str,
|
|
) -> dict[str, object]:
|
|
return {
|
|
'title': topic,
|
|
'icon': rule.icon,
|
|
'date': date,
|
|
'category': [rule.category],
|
|
'tag': list(rule.tags),
|
|
}
|
|
|
|
|
|
def build_new_category_suggestion(topic: str, date: str) -> tuple[dict[str, object], bool]:
|
|
rule, score, ambiguous = choose_new_category_rule(topic)
|
|
directory_slug = infer_category_slug(topic, rule)
|
|
if rule.nested_under_slug is not None:
|
|
directory = f'{rule.top_directory}/{rule.nested_under_slug}/{directory_slug}'
|
|
else:
|
|
directory = f'{rule.top_directory}/{directory_slug}'
|
|
|
|
filename = f'{slugify(topic)}.md'
|
|
readme_path = f'{directory}/README.md'
|
|
article_path = f'{directory}/{filename}'
|
|
|
|
return (
|
|
{
|
|
'top_directory': rule.top_directory,
|
|
'directory': directory,
|
|
'directory_slug': directory_slug,
|
|
'readme_path': readme_path,
|
|
'readme_frontmatter': new_category_readme_frontmatter(topic, rule),
|
|
'readme_body': '<Catalog />',
|
|
'article_path': article_path,
|
|
'article_frontmatter': article_frontmatter_from_new_category(topic, rule, date),
|
|
'requires_sidebar_update': rule.requires_sidebar_update,
|
|
'score': score,
|
|
},
|
|
ambiguous,
|
|
)
|
|
|
|
|
|
def build_plan(topic: str, date: str) -> dict[str, object]:
|
|
normalized = normalize_topic(topic)
|
|
candidates, needs_confirmation = choose_rules(normalized)
|
|
new_category_suggestion, new_category_ambiguous = build_new_category_suggestion(normalized, date)
|
|
primary = candidates[0][0]
|
|
filename = f'{slugify(normalized)}.md'
|
|
suggested_directory = str(new_category_suggestion['directory'])
|
|
broad_directory_match = primary.directory in {
|
|
'src/ai',
|
|
'src/apps',
|
|
'src/tools',
|
|
'src/work/project-summary',
|
|
}
|
|
should_create_new_category = (
|
|
candidates[0][1] == 0
|
|
or (broad_directory_match and not (REPO_ROOT / suggested_directory).exists())
|
|
)
|
|
recommended_directory = (
|
|
str(new_category_suggestion['directory'])
|
|
if should_create_new_category
|
|
else primary.directory
|
|
)
|
|
path = (
|
|
str(new_category_suggestion['article_path'])
|
|
if should_create_new_category
|
|
else f'{primary.directory}/{filename}'
|
|
)
|
|
recommended_frontmatter = (
|
|
new_category_suggestion['article_frontmatter']
|
|
if should_create_new_category
|
|
else frontmatter(normalized, primary, date)
|
|
)
|
|
requires_sidebar_update = (
|
|
bool(new_category_suggestion['requires_sidebar_update'])
|
|
if should_create_new_category
|
|
else primary.requires_sidebar_update
|
|
)
|
|
|
|
return {
|
|
'topic': normalized,
|
|
'recommended_directory': recommended_directory,
|
|
'filename': filename,
|
|
'path': path,
|
|
'frontmatter': recommended_frontmatter,
|
|
'requires_sidebar_update': requires_sidebar_update,
|
|
'should_create_new_category': should_create_new_category,
|
|
'new_category_suggestion': new_category_suggestion,
|
|
'needs_confirmation': (
|
|
new_category_ambiguous if should_create_new_category else needs_confirmation
|
|
),
|
|
'candidates': [
|
|
{
|
|
'directory': rule.directory,
|
|
'score': score,
|
|
'category': rule.category,
|
|
'tags': list(rule.tags),
|
|
'requires_sidebar_update': rule.requires_sidebar_update,
|
|
'existing_files_sample': existing_files(rule.directory),
|
|
}
|
|
for rule, score in candidates
|
|
],
|
|
}
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description='Plan a MyBlog article location.')
|
|
parser.add_argument('topic', help='Article topic text')
|
|
parser.add_argument(
|
|
'--date',
|
|
default=dt.date.today().isoformat(),
|
|
help='Frontmatter date in YYYY-MM-DD format',
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
print(json.dumps(build_plan(args.topic, args.date), ensure_ascii=False, indent=2))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|