# -*- coding: utf-8 -*-
"""
宇森小说同步脚本
================
把 novel/ 下的 Markdown 源同步进 Odoo 数据库(game.story.volume /
game.story.chapter),供前端「剧情站」阅读器使用。
目录约定(与作者约定一致):
novel/
剧情大纲.md # 顶层大纲,本脚本忽略(仅作者参考)
宇森创世正史_篇1-10.md # 每 ~10 章一个文件;文件以 `# 卷X · 名称` 开头
宇森创世正史_篇11-20.md # 同卷多文件靠相同的 `# 卷X` 标题聚合,按文件名排序拼接
...
* 章节以 `## 章标题` 分隔;章内可用 ### 小节、> 引用、--- 分隔、加粗等。
* 卷名 = 文件内 `# 卷X · 名称` 级标题;首个 `##` 之前为卷首语(intro)。
* 文件名建议零填充(篇01-10)便于排序;未填充也能按字典序工作。
同步语义:全量覆盖。每次运行先把库内 game.story.* 清空,再按 novel/ 重建,
保证「网站菜单 == md 源」。章节 id 会变化(阅读器每次实时拉取,无影响)。
用法:
python novel/sync_novel.py # 写库
python novel/sync_novel.py --dry-run # 只打印将要同步的内容,不写库
python novel/sync_novel.py --novel-dir X --db Y
依赖:psycopg2(项目 venv 已带)。直接写 Postgres,无需 Odoo 运行。
"""
import os
import re
import argparse
import psycopg2
from datetime import datetime
# --------------------------------------------------------------------------
# Markdown -> HTML(轻量,覆盖小说常用语法)
# --------------------------------------------------------------------------
def md_inline(text):
text = text.replace('&', '&').replace('<', '<').replace('>', '>')
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'(?\1', text)
text = re.sub(r'`(.+?)`', r'\1', text)
return text
def md_to_html(md):
lines = md.split('\n')
out = []
para = []
def flush_para():
if para:
out.append('
' + md_inline(' '.join(para)).replace('\n', '
') + '
')
para.clear()
i = 0
n = len(lines)
while i < n:
line = lines[i]
m = re.match(r'^(#{1,4})\s+(.*)$', line)
if m:
flush_para()
lvl = len(m.group(1))
out.append('{1}'.format(lvl, md_inline(m.group(2))))
i += 1
continue
if re.match(r'^\s*---\s*$', line) or re.match(r'^\s*\*\*\*\s*$', line):
flush_para()
out.append('
')
i += 1
continue
if line.lstrip().startswith('> '):
flush_para()
quote = []
while i < n and lines[i].lstrip().startswith('> '):
quote.append(lines[i].lstrip()[2:])
i += 1
out.append('' + '
'.join(md_inline(q) for q in quote) + '
')
continue
if re.match(r'^\s*[-*]\s+', line):
flush_para()
items = []
while i < n and re.match(r'^\s*[-*]\s+', lines[i]):
items.append('' + md_inline(re.sub(r'^\s*[-*]\s+', '', lines[i])) + '')
i += 1
out.append('')
continue
if line.strip() == '':
flush_para()
i += 1
continue
para.append(line)
i += 1
flush_para()
return '\n'.join(out)
# --------------------------------------------------------------------------
# 解析小说源(扁平结构:novel/ 下若干 *.md,按 # 卷X 标题分组为卷)
# 约定:
# - 每个 md 文件以 `# 卷X · 名称` 开头(卷标题),其下用 `## 章标题` 分章。
# - 一卷可拆多个文件(每 ~10 章一个,文件名如 篇1-10 / 篇11-20),
# 同卷文件靠相同的 `# 卷X` 标题聚合,按文件名排序拼接章节。
# - 首个 ## 之前的内容(去掉 # 卷标题行)作为卷首语(intro)。
# - 顶层 剧情大纲.md 仅作作者参考,忽略。
# --------------------------------------------------------------------------
def parse_file(md):
"""解析单个 md 文件 -> (vol_title|None, intro_html, [(ch_title, ch_body_html), ...])"""
segments = re.split(r'^##\s+', md, flags=re.M)
pre = segments[0]
m = re.search(r'^#\s+(.*)$', pre, re.M)
vol_title = m.group(1).strip() if m else None
intro_md = re.sub(r'^#\s+.*$', '', pre, flags=re.M).strip()
intro_html = md_to_html(intro_md) if intro_md else ''
chapters = []
for seg in segments[1:]:
nl = seg.find('\n')
if nl == -1:
title, body = seg.strip(), ''
else:
title = seg[:nl].strip()
body = seg[nl + 1:].strip()
chapters.append((title, md_to_html(body)))
return vol_title, intro_html, chapters
def collect_volumes(novel_dir):
# 收集源文件:递归扫描 *.md,排除 剧情大纲.md 与隐藏/缓存目录
files = []
for root, dirs, fnames in os.walk(novel_dir):
dirs[:] = [d for d in dirs if not d.startswith('.') and not d.startswith('__')]
for fn in fnames:
if not fn.lower().endswith('.md') or fn.startswith('.'):
continue
if fn == '剧情大纲.md':
continue
files.append(os.path.join(root, fn))
# 按文件名(建议零填充命名)排序,保证 篇1-10 < 篇11-20
files.sort(key=lambda p: os.path.basename(p))
volumes = {} # title -> {'intro':, 'chapters': [], 'order': int}
order = []
current = None
for fp in files:
with open(fp, encoding='utf-8') as fh:
md = fh.read()
vol_title, intro_html, chapters = parse_file(md)
if vol_title:
current = vol_title
if current is None:
# 跳过没有卷标题归属的文件
continue
if current not in volumes:
volumes[current] = {'intro': intro_html, 'chapters': [], 'order': len(order)}
order.append(current)
elif intro_html and not volumes[current]['intro']:
volumes[current]['intro'] = intro_html
volumes[current]['chapters'].extend(chapters)
out = []
for title in order:
v = volumes[title]
out.append((title, v['intro'], v['chapters'], title))
return out
# --------------------------------------------------------------------------
# 写库
# --------------------------------------------------------------------------
def sync(conn, volumes, dry_run=False):
print('\n========== 同步预览 ==========')
total_ch = 0
for vname, intro, chapters, folder in volumes:
print('卷:{0} (源 {1},{2} 章)'.format(vname, folder, len(chapters)))
for i, (t, _) in enumerate(chapters, 1):
print(' {0:>2}. {1}'.format(i, t))
total_ch += len(chapters)
print('--------------------------------')
print('共 {0} 卷 / {1} 章'.format(len(volumes), total_ch))
if dry_run:
print('[dry-run] 未写入数据库。')
return
cur = conn.cursor()
stats = {'vol_create': 0, 'vol_update': 0, 'ch_create': 0, 'ch_update': 0, 'ch_delete': 0}
seq_v = 10
for vname, intro, chapters, _folder in volumes:
# ---- 卷:按名称 upsert(保持 id 稳定)----
cur.execute('SELECT id FROM game_story_volume WHERE name=%s', (vname,))
row = cur.fetchone()
if row:
vid = row[0]
cur.execute(
'UPDATE game_story_volume SET sequence=%s, intro=%s, write_date=now() WHERE id=%s',
(seq_v, intro, vid))
stats['vol_update'] += 1
else:
cur.execute(
"""INSERT INTO game_story_volume
(name, sequence, intro, create_uid, write_uid, create_date, write_date)
VALUES (%s,%s,%s,1,1,now(),now()) RETURNING id""",
(vname, seq_v, intro))
vid = cur.fetchone()[0]
stats['vol_create'] += 1
seq_v += 10
# ---- 章:按 (卷, 章名) upsert,正文不变则跳过 ----
cur.execute('SELECT id, name, body_html FROM game_story_chapter WHERE volume_id=%s',
(vid,))
existing = {r[1]: (r[0], r[2]) for r in cur.fetchall()} # name -> (id, body)
kept_ids = []
seq_c = 10
for cname, cbody in chapters:
if cname in existing:
cid, old_body = existing[cname]
if (old_body or '') != cbody:
cur.execute(
'UPDATE game_story_chapter SET name=%s, sequence=%s, body_html=%s, write_date=now() WHERE id=%s',
(cname, seq_c, cbody, cid))
stats['ch_update'] += 1
# 正文未变:跳过(不写库,保持 id 稳定)
kept_ids.append(cid)
else:
cur.execute(
"""INSERT INTO game_story_chapter
(volume_id, name, sequence, body_html, create_uid, write_uid, create_date, write_date)
VALUES (%s,%s,%s,%s,1,1,now(),now()) RETURNING id""",
(vid, cname, seq_c, cbody))
kept_ids.append(cur.fetchone()[0])
stats['ch_create'] += 1
seq_c += 10
# 删除源里已不存在的章(保持菜单 == 源)
orphan_ids = [i for n, (i, _b) in existing.items() if n not in {c[0] for c in chapters}]
if orphan_ids:
cur.execute('DELETE FROM game_story_chapter WHERE id IN %s', (tuple(orphan_ids),))
stats['ch_delete'] += len(orphan_ids)
conn.commit()
print('[ok] 卷 新建{vol_create}/更新{vol_update};章 新建{ch_create}/更新{ch_update}/删除{ch_delete}'.format(**stats))
print(' (正文未变的章被跳过,不写库 —— 1000 章日常同步也是亚秒级)')
def main():
ap = argparse.ArgumentParser(description='宇森小说 md -> Odoo 同步')
ap.add_argument('--novel-dir', default=os.path.join(os.path.dirname(__file__)))
ap.add_argument('--host', default=os.environ.get('PGHOST', '127.0.0.1'))
ap.add_argument('--port', default=os.environ.get('PGPORT', '5432'))
ap.add_argument('--db', default=os.environ.get('PGDATABASE', 'yt_game'))
ap.add_argument('--user', default=os.environ.get('PGUSER', 'odoo'))
ap.add_argument('--password', default=os.environ.get('PGPASSWORD', 'odoo'))
ap.add_argument('--dry-run', action='store_true')
args = ap.parse_args()
volumes = collect_volumes(args.novel_dir)
if not volumes:
print('未在', args.novel_dir, '下找到任何卷目录(子目录)。')
return
conn = psycopg2.connect(host=args.host, port=args.port,
dbname=args.db, user=args.user, password=args.password)
try:
sync(conn, volumes, dry_run=args.dry_run)
finally:
conn.close()
if __name__ == '__main__':
main()