# -*- 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()