"""章节镜像文件(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 = ("\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 ''