270 lines
11 KiB
Python
270 lines
11 KiB
Python
# -*- 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'<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()
|