feat: add myblog blog writer skill
This commit is contained in:
+448
@@ -0,0 +1,448 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user