game/novel/sync_novel.py

270 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
text = re.sub(r'(?<!\*)\*(?!\*)(.+?)\*(?!\*)', r'<em>\1</em>', text)
text = re.sub(r'`(.+?)`', r'<code>\1</code>', text)
return text
def md_to_html(md):
lines = md.split('\n')
out = []
para = []
def flush_para():
if para:
out.append('<p>' + md_inline(' '.join(para)).replace('\n', '<br>') + '</p>')
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('<h{0}>{1}</h{0}>'.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('<hr>')
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('<blockquote><p>' + '<br>'.join(md_inline(q) for q in quote) + '</p></blockquote>')
continue
if re.match(r'^\s*[-*]\s+', line):
flush_para()
items = []
while i < n and re.match(r'^\s*[-*]\s+', lines[i]):
items.append('<li>' + md_inline(re.sub(r'^\s*[-*]\s+', '', lines[i])) + '</li>')
i += 1
out.append('<ul>' + ''.join(items) + '</ul>')
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()