pipeline-bidding/pipeline_bidding/bid_chapter_mirror.py

137 lines
6.1 KiB
Python
Raw Permalink 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.

"""章节镜像文件(2026-09-16 用户裁定:章节写文件,用户参与度高,看文件即时发现问题)。
规则(用户定):
- 章节正文镜像为 md 文件,按章节命名:{项目根}/chapters/{order:03d}_{章节号}_{标题}.md
- 图片等媒体与文本放同一目录(chapters/media/),正文图片引用改写为相对路径,
用户本地打开 md 即可看到图。
- **库(bid_chapters.content)仍是机制正本**(评审/合成/评分读库),文件是查看镜像;
每次内容变更(write_chapter replace/append、fill_material_gap 补料)同步刷新。
- 镜像失败不阻塞写作主流程(try 包裹,warning 日志)——查看层不能反过来卡死生产层。
文件名安全:章节号/标题里的路径分隔符与特殊字符替换为 _;order_no 补零排序,
文件管理器里天然按章节顺序排列。
"""
import hashlib
import logging
import os
import re
logger = logging.getLogger("pipeline.bidding.mirror")
_IMG_RE = re.compile(r'!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)')
def _safe_name(s, maxlen=60):
"""文件名安全化:路径分隔符/空白/常见特殊字符 → _。"""
s = (s or "").strip()
for ch in ('/', '\\', ':', '*', '?', '"', '<', '>', '|', '\n', '\r', '\t'):
s = s.replace(ch, '_')
s = re.sub(r'\s+', '_', s)
return (s or "untitled")[:maxlen]
def _localize_images(content, media_dir):
"""正文图片本地化:网关/本地路径可映射的图 → 复制到 media/ 并改相对引用。
- /idfile|/download 网关 URL 与本地绝对路径:复制进 media/,引用改 media/xxx
(相对 chapters/ 目录),用户本地打开 md 直接可见图。
- 其余 http(s) URL:保留原样(查看端可联网拉取;镜像进程不代下载远端,
失败率与耗时不可控)。
返回 (新正文, 本地化图片数)。
"""
from .bid_compose_capability import _filesroot_path
n = 0
def _sub(m):
nonlocal n
alt, url = m.group(1), m.group(2)
src = ""
if url.startswith('http://') or url.startswith('https://'):
from urllib.parse import urlparse, unquote
path = unquote(urlparse(url).path or '')
for lead in ('/idfile/', '/download/'):
if path.startswith(lead):
cand = _filesroot_path(path[len(lead):])
if cand and os.path.isfile(cand):
src = cand
break
elif url.startswith('/') and os.path.isfile(url):
src = url
if not src:
return m.group(0) # 映射不到 → 原样保留
try:
os.makedirs(media_dir, exist_ok=True)
ext = os.path.splitext(src)[1] or '.png'
name = hashlib.sha1(src.encode('utf-8')).hexdigest()[:16] + ext
dst = os.path.join(media_dir, name)
if not os.path.isfile(dst):
with open(src, 'rb') as f1, open(dst, 'wb') as f2:
f2.write(f1.read())
n += 1
return '![%s](media/%s)' % (alt, name)
except Exception as e: # noqa: BLE001
logger.warning("mirror: 图片本地化失败 %s: %s", src[:120], e)
return m.group(0)
new_content = _IMG_RE.sub(_sub, content or "")
return new_content, n
async def sync_chapter_mirror(sor, project_id, chapter_id):
"""把章节正文(库内正本)镜像为 {项目根}/chapters/ 下的 md 文件。
幂等:同章节重复调用覆盖同名文件(重写轮次天然刷新)。
返回镜像文件路径(失败返回 '',不抛异常)。
"""
try:
from .bid_common import rec_to_dict
recs = await sor.sqlExe(
"SELECT id, chapter_no, title, order_no, content, version, status, "
"review_score, updated_at FROM bid_chapters WHERE id=${cid}$",
{"cid": chapter_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return ''
d = rec_to_dict(recs[0])
content = d.get("content") or ""
if not content.strip():
return '' # 无正文不建空文件
from .bid_compose_capability import _workspace_dir
ws = await _workspace_dir(sor, project_id)
if not ws:
logger.warning("mirror: 工作空间解析失败,跳过镜像 pid=%s", project_id)
return ''
chap_dir = os.path.join(ws, "chapters")
os.makedirs(chap_dir, exist_ok=True)
try:
order = int(d.get("order_no") or 0)
except (TypeError, ValueError):
order = 0
fname = "%03d_%s_%s.md" % (order, _safe_name(d.get("chapter_no")),
_safe_name(d.get("title")))
# 清掉同章节旧命名文件(标题变更后不留孤儿)
prefix = "%03d_%s_" % (order, _safe_name(d.get("chapter_no")))
for old in os.listdir(chap_dir):
if old.startswith(prefix) and old != fname and old.endswith(".md"):
try:
os.remove(os.path.join(chap_dir, old))
except OSError:
pass
body, n_img = _localize_images(content, os.path.join(chap_dir, "media"))
# 头部状态注记(镜像是查看件,标注库内正本状态便于用户判断新旧)
header = ("<!-- 章节镜像(库内为正本)|章节 %s|v%s|状态 %s|评审 %s 分|更新 %s -->\n\n"
% (d.get("chapter_no"), d.get("version"), d.get("status"),
d.get("review_score") if d.get("review_score") is not None else "-",
d.get("updated_at")))
fpath = os.path.join(chap_dir, fname)
with open(fpath, "w", encoding="utf-8") as f:
f.write(header + body.rstrip() + "\n")
if n_img:
logger.info("mirror: %s 本地化 %d 张图 → %s", fname, n_img, chap_dir)
return fpath
except Exception as e: # noqa: BLE001 镜像永不阻塞主流程
logger.warning("sync_chapter_mirror failed pid=%s cid=%s: %s",
project_id, chapter_id, str(e)[:200])
return ''