diff --git a/.gitignore b/.gitignore
index 259c747..2850fb3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,4 @@ review_service/data/reformat_jobs/
!review_service/data/
review_service/tools/tectonic
review_service/data/tectonic-cache/
+review_service/data/format_cache/
diff --git a/review_service/app/services/format_extractor.py b/review_service/app/services/format_extractor.py
new file mode 100644
index 0000000..05a5c1e
--- /dev/null
+++ b/review_service/app/services/format_extractor.py
@@ -0,0 +1,232 @@
+"""services/format_extractor.py —— 投稿指南 → 格式规则 JSON(LLM 抽取,第一批)。
+
+- 调 services/llm.py 的 complete_json(DeepSeek json_object + 修复重试,零新依赖)
+- Prompt = 「格式规则抽取器」模板:目标字段与 journals.json format_rules_default
+ 同构(max_pages / min_font_pt / min_margin_mm / min_line_spacing / min_figures /
+ max_fonts / refs_style / anonymous),强制每字段带 source_quotes + confidence
+- 诚实原则:正文没写 → null,绝不编造(与 format_check 诚实原则一致)
+- 解析/校验失败 → {"ok": False, "error": ...}
+
+返回:
+ 成功 {"ok": True, "rules": {...}, "source_quotes": {...},
+ "confidence": {...}, "notes": str, "raw": LLM原始JSON, "error": None}
+ 失败 {"ok": False, "error": "...", 其余键为 None}
+source_url 由调用方(scripts/fetch_format_rules.py)在落盘时补入结果文件。
+"""
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any
+
+from .llm import LLMError, complete_json
+
+logger = logging.getLogger(__name__)
+
+# 「格式规则抽取器」模板(system prompt)
+_SYSTEM = """你是「格式规则抽取器」:从期刊官方投稿指南的 Markdown 正文中抽取格式规则,供投稿排版检查引擎使用。
+
+# 目标字段(rules 对象,严格用以下字段名)
+- max_pages:正文页数上限(整数;原文无明确上限 → null)
+- min_font_pt:最小字号(数字,单位 pt;原文若用 cm/mm/inch 给出则换算为 pt)
+- min_margin_mm:最小页边距(数字,单位 mm;原文若用 inch 给出则 ×25.4 换算)
+- min_line_spacing:最小行距倍数(数字;如 "double-spaced" → 2.0,"1.5 spacing" → 1.5)
+- min_figures:研究论文最少图数(整数;无明确要求 → null)
+- max_fonts:全文最多字体种类(整数;无明确要求 → null)
+- refs_style:参考文献样式,枚举 apa | gb7714 | author_year | numbered | vancouver | mla | chicago | other(无法判断 → null)
+- anonymous:是否要求匿名/双盲评审(true / false / null)
+
+# 凭证(强制)
+- 每个非 null 字段必须给 source_quotes[字段名]:从正文【逐字】摘录的 1-3 句原文,
+ 不改写、不翻译、不丢掉含关键数字的句子。
+- source_quotes["_general"]:整体最有凭证价值的 1-3 条原文摘录。
+- confidence:{"overall": 0-1, "per_field": {"字段名": 0-1}}。
+ 直接原文命中 ≥0.9;单位换算 0.6-0.9;靠上下文推断 <0.6。
+
+# 诚实原则
+- 正文没写到的字段一律 null,绝不用常识或训练记忆补值。
+- 单位换算允许做,但该字段 confidence 不得超过 0.9。
+- 换算过程、拿不准的点、页面疑似不完整,写进 notes。
+
+# 输出
+只输出一个 JSON 对象,不要代码块、不要解释文字:
+{"rules": {"max_pages": null, "min_font_pt": null, "min_margin_mm": null,
+ "min_line_spacing": null, "min_figures": null, "max_fonts": null,
+ "refs_style": null, "anonymous": null},
+ "source_quotes": {"字段名": ["原文1", "..."], "_general": ["原文1", "..."]},
+ "confidence": {"overall": 0.0, "per_field": {"字段名": 0.0}},
+ "notes": "一句话说明抽取依据与不确定点"}"""
+
+_USER_TMPL = """期刊:{journal_name}
+
+=== 官方投稿指南正文(Markdown,已截断) ===
+{markdown}"""
+
+_RULE_INT_KEYS = ("max_pages", "min_figures", "max_fonts")
+_REFS_ALLOWED = {"apa", "gb7714", "author_year", "numbered", "vancouver",
+ "mla", "chicago", "other"}
+_REFS_ALIAS = {
+ "apa-7": "apa", "apa7": "apa", "apa 7th": "apa", "apa_7th": "apa",
+ "harvard": "author_year", "author-year": "author_year", "authoryear": "author_year",
+ "gb/t 7714": "gb7714", "gb/t-7714": "gb7714", "gb7714-2015": "gb7714",
+ "gb/t7714": "gb7714", "chicago author-date": "chicago",
+}
+
+
+# 字符串值带显式单位时的目标单位换算表(LLM 若回传原文单位的兜底;无单位=数值即目标单位)
+_UNIT_TO_MM = {"mm": 1.0, "cm": 10.0, "in": 25.4, "inch": 25.4, "inches": 25.4, "pt": 25.4 / 72}
+_UNIT_TO_PT = {"pt": 1.0, "mm": 72 / 25.4, "cm": 72 / 2.54, "in": 72.0, "inch": 72.0, "inches": 72.0}
+
+
+def _to_int(v: Any) -> int | None:
+ if isinstance(v, bool) or v is None:
+ return None
+ if isinstance(v, int):
+ return v
+ if isinstance(v, float):
+ return int(round(v))
+ if isinstance(v, str):
+ m = re.search(r"-?\d+", v)
+ if m:
+ return int(m.group(0))
+ return None
+
+
+def _to_float(v: Any, units: dict[str, float] | None = None) -> float | None:
+ if isinstance(v, bool) or v is None:
+ return None
+ if isinstance(v, (int, float)):
+ return float(v)
+ if isinstance(v, str):
+ m = re.search(r"(-?\d+(?:\.\d+)?)\s*([a-zA-Z]+)?", v)
+ if not m:
+ return None
+ x = float(m.group(1))
+ unit = (m.group(2) or "").lower()
+ if units and unit:
+ factor = units.get(unit)
+ if factor is None:
+ return None # 未知单位不猜
+ x *= factor
+ return x
+ return None
+
+
+def _to_bool(v: Any) -> bool | None:
+ if isinstance(v, bool):
+ return v
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
+ if v in (0, 1):
+ return bool(v)
+ if isinstance(v, str):
+ low = v.strip().lower()
+ if low in ("true", "yes", "required"):
+ return True
+ if low in ("false", "no", "not required"):
+ return False
+ return None
+
+
+def _norm_refs(v: Any) -> str | None:
+ if not isinstance(v, str) or not v.strip():
+ return None
+ low = v.strip().lower()
+ low = _REFS_ALIAS.get(low, low)
+ return low if low in _REFS_ALLOWED else "other"
+
+
+def _norm_conf(v: Any) -> float | None:
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
+ return None
+ return max(0.0, min(1.0, float(v)))
+
+
+def _normalize(raw: dict[str, Any]) -> dict[str, Any]:
+ """白名单归一:类型错的值置 null(宁缺勿错,raw 全量保留在结果里)。"""
+ src = raw.get("rules")
+ src = src if isinstance(src, dict) else {}
+ rules: dict[str, Any] = {}
+ for k in _RULE_INT_KEYS:
+ rules[k] = _to_int(src.get(k))
+ rules["min_font_pt"] = _to_float(src.get("min_font_pt"), _UNIT_TO_PT)
+ rules["min_margin_mm"] = _to_float(src.get("min_margin_mm"), _UNIT_TO_MM)
+ rules["min_line_spacing"] = _to_float(src.get("min_line_spacing"))
+ rules["refs_style"] = _norm_refs(src.get("refs_style"))
+ rules["anonymous"] = _to_bool(src.get("anonymous"))
+
+ sq_src = raw.get("source_quotes")
+ sq_src = sq_src if isinstance(sq_src, dict) else {}
+ quotes: dict[str, list[str]] = {}
+ for key, val in sq_src.items():
+ if key != "_general" and key not in rules:
+ continue
+ items = val if isinstance(val, list) else [val]
+ cleaned = [str(x).strip() for x in items if isinstance(x, (str, int, float))
+ and str(x).strip()]
+ if cleaned:
+ quotes[key] = cleaned
+
+ conf_src = raw.get("confidence")
+ conf_src = conf_src if isinstance(conf_src, dict) else {}
+ pf_src = conf_src.get("per_field")
+ pf_src = pf_src if isinstance(pf_src, dict) else {}
+ per_field: dict[str, float] = {}
+ for k, v in pf_src.items():
+ c = _norm_conf(v)
+ if c is not None and (k in rules or k == "_general"):
+ per_field[k] = c
+ overall = _norm_conf(conf_src.get("overall"))
+ if overall is None and per_field:
+ overall = sum(per_field.values()) / len(per_field)
+ confidence = {"overall": overall, "per_field": per_field}
+
+ notes = raw.get("notes")
+ return {
+ "rules": rules,
+ "source_quotes": quotes,
+ "confidence": confidence,
+ "notes": str(notes) if notes else "",
+ }
+
+
+def _validation_error(norm: dict[str, Any]) -> str | None:
+ """强制凭证:任一非 null 字段缺 source_quotes → 拒收(ok=False)。"""
+ missing = [k for k, v in norm["rules"].items()
+ if v is not None and k not in norm["source_quotes"]]
+ if missing:
+ return f"LLM 输出缺少非 null 字段的 source_quotes:{', '.join(missing)}"
+ if not norm["source_quotes"].get("_general"):
+ return "LLM 输出缺少 source_quotes._general 整体引证"
+ if any(v is not None for v in norm["rules"].values()):
+ return None
+ return "LLM 未抽出任何非 null 字段(rules 全空)"
+
+
+async def extract_format_rules(markdown: str, journal_name: str) -> dict:
+ """markdown(≤30000 字符)+ 期刊名 → 抽取结果(schema 见模块 docstring)。"""
+ if not markdown or not markdown.strip():
+ return {"ok": False, "error": "markdown 为空", "rules": None,
+ "source_quotes": None, "confidence": None, "notes": None, "raw": None}
+ user = _USER_TMPL.format(journal_name=journal_name, markdown=markdown)
+ try:
+ raw = await complete_json(_SYSTEM, user, temperature=0.1, max_tokens=8000)
+ except LLMError as exc:
+ return {"ok": False, "error": f"LLM 调用失败:{exc}", "rules": None,
+ "source_quotes": None, "confidence": None, "notes": None, "raw": None}
+ if not isinstance(raw, dict):
+ return {"ok": False, "error": f"LLM 输出非 JSON 对象:{type(raw).__name__}",
+ "rules": None, "source_quotes": None, "confidence": None,
+ "notes": None, "raw": None}
+
+ norm = _normalize(raw)
+ err = _validation_error(norm)
+ if err:
+ logger.warning("[extract] %s 校验失败:%s", journal_name, err)
+ return {"ok": False, "error": err, "rules": None,
+ "source_quotes": norm["source_quotes"], "confidence": norm["confidence"],
+ "notes": norm["notes"], "raw": raw}
+ filled = sum(1 for v in norm["rules"].values() if v is not None)
+ logger.info("[extract] %s → 8 字段中 %d 个非 null", journal_name, filled)
+ return {"ok": True, "error": None, "rules": norm["rules"],
+ "source_quotes": norm["source_quotes"],
+ "confidence": norm["confidence"], "notes": norm["notes"], "raw": raw}
diff --git a/review_service/app/services/format_fetcher.py b/review_service/app/services/format_fetcher.py
new file mode 100644
index 0000000..1710d8e
--- /dev/null
+++ b/review_service/app/services/format_fetcher.py
@@ -0,0 +1,356 @@
+"""services/format_fetcher.py —— 投稿指南页面抓取(格式规则自动获取·第一批)。
+
+约束(用户拍板 2026-10-10):
+- 零新依赖:httpx + beautifulsoup4(venv 已装 4.15.0,用户明确指定使用;
+ lxml 6.1.2 在 requirements.lock 内,作 bs4 解析器)
+- 同域限速 ≥ 2s(robots.txt 与正文请求共用计时器)
+- 抓正文前先 GET /robots.txt,命中 Disallow → ok=False 跳过
+- UA: "PlantAI-RAG-Bot/1.0 (+contact: admin@example.com)"
+- 只提取正文(去导航/页脚/脚本),截断至 30000 字符
+
+返回固定四键:{"ok": bool, "markdown": str, "error": str | None, "fetched_at": str}
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+import time
+from datetime import datetime, timezone
+from urllib.parse import urlparse
+
+import httpx
+from bs4 import BeautifulSoup, NavigableString
+
+logger = logging.getLogger(__name__)
+
+USER_AGENT = "PlantAI-RAG-Bot/1.0 (+contact: admin@example.com)"
+TIMEOUT = 30.0
+MAX_CHARS = 30_000
+MIN_INTERVAL_PER_HOST = 2.0 # 同域请求间隔 ≥ 2s(robots 与正文共用)
+_MIN_TEXT_CHARS = 200 # 正文短于此 → 疑似 JS 渲染/反爬页
+_HEADERS = {
+ "User-Agent": USER_AGENT,
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "en-US,en;q=0.9",
+}
+
+# 同域限速:host → 上次请求完成时刻(monotonic)
+_last_hit: dict[str, float] = {}
+_throttle_lock = asyncio.Lock()
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+def _fail(error: str) -> dict:
+ return {"ok": False, "markdown": "", "error": error, "fetched_at": _now_iso()}
+
+
+async def _throttle(host: str) -> None:
+ """同域串行 + ≥2s 间隔(全局锁:本批抓取量小,简单可靠优先)。"""
+ async with _throttle_lock:
+ now = time.monotonic()
+ wait = _last_hit.get(host, now) + MIN_INTERVAL_PER_HOST - now
+ if wait > 0:
+ await asyncio.sleep(wait)
+ _last_hit[host] = time.monotonic()
+
+
+# ---------------------------------------------------------------- robots.txt
+
+def _parse_robots(text: str) -> list[tuple[list[str], list[tuple[str, str]]]]:
+ """→ [(agents, [(kind, pattern)...]), ...](连续 UA 行同组;规则行后出现 UA=新组)。"""
+ groups: list[tuple[list[str], list[tuple[str, str]]]] = []
+ agents: list[str] = []
+ rules: list[tuple[str, str]] = []
+ for raw in text.splitlines():
+ line = raw.split("#", 1)[0].strip()
+ if not line or ":" not in line:
+ continue
+ key, _, value = line.partition(":")
+ key, value = key.strip().lower(), value.strip()
+ if key == "user-agent":
+ if agents and rules: # 上一组已出规则 → 当前 UA 行开新组
+ groups.append((agents, rules))
+ agents, rules = [], []
+ agents.append(value)
+ elif key in ("allow", "disallow"):
+ if not agents: # 无前置 UA 的规则按 * 组处理
+ agents = ["*"]
+ rules.append((key, value))
+ if agents:
+ groups.append((agents, rules))
+ return groups
+
+
+def _robots_path_allowed(groups, path: str) -> tuple[bool, str]:
+ """最长匹配决胜(同长 Allow 优先;`$` 结尾=精确匹配;空 Disallow 跳过)。"""
+
+ def matched_by(pat: str) -> bool:
+ if pat.endswith("$"):
+ return path == pat[:-1]
+ return path.startswith(pat)
+
+ token = "plantai-rag-bot"
+ matched = [rules for agents, rules in groups
+ if any(token in a.lower() for a in agents)]
+ if not matched:
+ matched = [rules for agents, rules in groups
+ if any(a.lower() == "*" for a in agents)]
+ if not matched:
+ return True, "robots.txt 无匹配组(本 bot 不受限)"
+
+ best_len, best_allow, best_pat = -1, False, ""
+ for rules in matched:
+ for kind, pat in rules:
+ if not pat: # "Disallow:" 空值 = 放行一切(该组无需处理)
+ continue
+ if not matched_by(pat):
+ continue
+ length = len(pat)
+ allow = kind == "allow"
+ if length > best_len or (length == best_len and allow and not best_allow):
+ best_len, best_allow, best_pat = length, allow, pat
+ if best_len < 0:
+ return True, "robots.txt 允许(无路径匹配)"
+ if best_allow:
+ return True, f"robots.txt Allow 命中({best_pat})"
+ return False, f"robots.txt Disallow 命中({best_pat})"
+
+
+async def _robots_allows(client: httpx.AsyncClient, url: str) -> tuple[bool, str]:
+ p = urlparse(url)
+ robots_url = f"{p.scheme}://{p.netloc}/robots.txt"
+ await _throttle(p.netloc.lower())
+ try:
+ resp = await client.get(robots_url, headers=_HEADERS)
+ except httpx.HTTPError as exc:
+ return True, f"robots.txt 获取失败({type(exc).__name__}),按允许处理"
+ if resp.status_code != 200:
+ return True, f"robots.txt HTTP {resp.status_code},按允许处理"
+ groups = _parse_robots(resp.text)
+ return _robots_path_allowed(groups, p.path or "/")
+
+
+# ---------------------------------------------------------------- 正文提取
+
+_STRIP_TAGS = ("script", "style", "noscript", "iframe", "form", "nav",
+ "footer", "aside", "button", "select", "textarea", "input", "svg")
+_STRIP_ROLES = ("navigation", "contentinfo", "complementary", "search")
+# 正文容器候选(按优先级;首个文本 ≥500 字符者胜出)
+_CONTENT_SELECTORS = (
+ "main", "article", '[role="main"]', "#content", "#main", "#main-content",
+ ".article-body", "#article-body", ".article__body", ".hub-article-body",
+ ".page-content", ".main-content", ".content-area",
+)
+_CHALLENGE_MARKERS = ("just a moment", "attention required", "captcha",
+ "access denied", "ddos protection", "verify you are human")
+
+
+def _decode_html(resp: httpx.Response) -> str:
+ """header charset → → utf-8(errors=replace),防 OUP/Wiley 无 header 乱码。"""
+ enc = None
+ m = re.search(r"charset=([\w.-]+)", resp.headers.get("content-type", ""), re.I)
+ if m:
+ enc = m.group(1)
+ if not enc:
+ m = re.search(rb"""charset\s*=\s*["']?([\w.-]+)""", resp.content[:4096], re.I)
+ if m:
+ enc = m.group(1).decode("ascii", "ignore")
+ try:
+ return resp.content.decode(enc or "utf-8", errors="replace")
+ except LookupError:
+ return resp.content.decode("utf-8", errors="replace")
+
+
+def _squeeze(s: str) -> str:
+ """行内空白压缩 + 去空行(保留单换行)。"""
+ lines = [re.sub(r"[ \t\r\f\v]+", " ", line).strip() for line in s.split("\n")]
+ return "\n".join(line for line in lines if line)
+
+
+def _inline(el) -> str:
+ """行内渲染:a→md链接 / strong→** / em→* / code→` / br→换行;跳过列表与图片。"""
+ parts: list[str] = []
+ for child in el.children:
+ name = getattr(child, "name", None)
+ if name is None: # NavigableString
+ parts.append(str(child))
+ continue
+ if name in ("ul", "ol", "img", "picture", "figure"):
+ continue
+ if name == "a":
+ href = (child.get("href") or "").strip()
+ txt = _inline(child).strip()
+ if not txt:
+ continue
+ parts.append(f"[{txt}]({href})" if href.startswith(("http://", "https://")) else txt)
+ elif name in ("strong", "b"):
+ parts.append(f"**{_inline(child)}**")
+ elif name in ("em", "i"):
+ parts.append(f"*{_inline(child)}*")
+ elif name == "code":
+ parts.append(f"`{_inline(child).strip()}`")
+ elif name == "br":
+ parts.append("\n")
+ else:
+ parts.append(_inline(child))
+ return "".join(parts)
+
+
+def _list_md(el, ordered: bool) -> str:
+ lines: list[str] = []
+ idx = 1
+ for li in el.find_all("li", recursive=False):
+ # li 文本不含嵌套列表(嵌套列表随后缩进输出)
+ buf = []
+ for child in li.children:
+ if getattr(child, "name", None) in ("ul", "ol"):
+ continue
+ buf.append(_inline(child) if getattr(child, "name", None) else str(child))
+ txt = " ".join("".join(buf).split())
+ if txt:
+ lines.append(f"{idx}. {txt}" if ordered else f"- {txt}")
+ idx += 1
+ for nested in li.find_all(("ul", "ol"), recursive=False):
+ sub = _list_md(nested, nested.name == "ol")
+ if sub:
+ lines.extend(" " + line for line in sub.split("\n"))
+ return "\n".join(lines)
+
+
+def _table_md(table) -> str | None:
+ rows: list[list[str]] = []
+ for tr in table.find_all("tr"):
+ cells = [" ".join(_squeeze(_inline(c)).split())
+ for c in tr.find_all(("td", "th"))]
+ if cells:
+ rows.append(cells)
+ if not rows:
+ return None
+ out = ["| " + " | ".join(rows[0]) + " |"]
+ if table.find("th"): # 首行含表头 → 补分隔行(合法 GFM 表)
+ out.append("| " + " | ".join(["---"] * len(rows[0])) + " |")
+ out += ["| " + " | ".join(r) + " |" for r in rows[1:]]
+ return "\n".join(out)
+
+
+def _blocks(el, out: list[str]) -> None:
+ for child in el.children:
+ name = getattr(child, "name", None)
+ if name is None: # 裸文本
+ txt = _squeeze(str(child))
+ if txt:
+ out.append(txt)
+ continue
+ if name in _STRIP_TAGS:
+ continue
+ if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
+ txt = " ".join(_squeeze(_inline(child)).split())
+ if txt:
+ out.append("#" * int(name[1]) + " " + txt)
+ elif name == "p":
+ txt = _squeeze(_inline(child))
+ if txt:
+ out.append(txt)
+ elif name in ("ul", "ol"):
+ md = _list_md(child, name == "ol")
+ if md:
+ out.append(md)
+ elif name == "table":
+ md = _table_md(child)
+ if md:
+ out.append(md)
+ elif name == "blockquote":
+ txt = _squeeze(_inline(child))
+ if txt:
+ out.append("\n".join("> " + line for line in txt.split("\n")))
+ elif name == "pre":
+ code = child.get_text().strip("\n")
+ if code.strip():
+ out.append("```\n" + code + "\n```")
+ elif name == "hr":
+ out.append("---")
+ else: # div/section/article/main/figure/dl/details/… 容器递归
+ _blocks(child, out)
+
+
+def _html_to_markdown(html: str) -> tuple[str, str | None]:
+ """→ (markdown, error)。选正文容器 → 去壳 → DOM 递归转 Markdown。"""
+ soup = BeautifulSoup(html, "lxml") # lxml 在 lock 内;缺时 bs4 自动回退 html.parser
+
+ title = (soup.title.get_text() if soup.title else "") or ""
+ for tag in soup.find_all(_STRIP_TAGS):
+ tag.decompose()
+ for tag in soup.find_all(attrs={"role": _STRIP_ROLES}):
+ tag.decompose()
+
+ container = None
+ fallback_hits = []
+ for sel in _CONTENT_SELECTORS:
+ for node in soup.select(sel):
+ text_len = len(node.get_text(" ", strip=True))
+ fallback_hits.append((text_len, node))
+ if text_len >= 500:
+ container = node
+ break
+ if container is not None:
+ break
+ if container is None:
+ if fallback_hits:
+ container = max(fallback_hits, key=lambda x: x[0])[1]
+ else:
+ container = soup.body or soup
+
+ blocks: list[str] = []
+ _blocks(container, blocks)
+ md = "\n\n".join(blocks)
+
+ probe = (title + " " + md[:500]).lower()
+ marker = next((m for m in _CHALLENGE_MARKERS if m in probe), None)
+ if marker:
+ return "", f"疑似反爬/挑战页(命中 {marker!r},title={title[:80]!r})"
+ if len(md) < _MIN_TEXT_CHARS:
+ return "", f"正文提取过短({len(md)} < {_MIN_TEXT_CHARS} 字符),疑似 JS 渲染或空壳页"
+ if len(md) > MAX_CHARS:
+ md = md[:MAX_CHARS - 1] + "…"
+ return md, None
+
+
+# ---------------------------------------------------------------- 入口
+
+async def fetch_submission_guidelines(url: str) -> dict:
+ """抓投稿指南页面 → 正文 Markdown。
+
+ 流程:robots.txt 检查(≥2s 限速)→ 禁止即跳过 → GET 正文(≥2s 限速)
+ → 状态/类型校验 → 正文提取 → 截断 30000 字符。
+ """
+ parsed = urlparse(url)
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
+ return _fail(f"非法 URL:{url!r}")
+ host = parsed.netloc.lower()
+
+ async with httpx.AsyncClient(follow_redirects=True, timeout=TIMEOUT) as client:
+ allowed, robots_reason = await _robots_allows(client, url)
+ if not allowed:
+ logger.info("[fetch] robots 禁止,跳过 %s(%s)", url, robots_reason)
+ return _fail(robots_reason)
+
+ await _throttle(host)
+ try:
+ resp = await client.get(url, headers=_HEADERS)
+ except httpx.HTTPError as exc:
+ return _fail(f"请求失败({type(exc).__name__}):{exc}")
+ if resp.status_code != 200:
+ return _fail(f"HTTP {resp.status_code}")
+ ctype = resp.headers.get("content-type", "")
+ if "html" not in ctype and "xhtml" not in ctype:
+ return _fail(f"非 HTML 响应:{ctype or '无 Content-Type'}")
+
+ markdown, err = _html_to_markdown(_decode_html(resp))
+ if err:
+ return _fail(f"{err}(robots: {robots_reason})")
+ logger.info("[fetch] %s → %d 字符(robots: %s)", url, len(markdown), robots_reason)
+ return {"ok": True, "markdown": markdown, "error": None, "fetched_at": _now_iso()}
diff --git a/review_service/scripts/README.md b/review_service/scripts/README.md
new file mode 100644
index 0000000..c1f8d81
--- /dev/null
+++ b/review_service/scripts/README.md
@@ -0,0 +1,17 @@
+# scripts/ —— 离线工具(不被 app/ 服务代码引用,独立 CLI)
+
+## fetch_format_rules.py —— 投稿指南自动抓取(探索性,第一批)
+
+- 依赖:venv 已装的 httpx + beautifulsoup4 4.15.0(用户拍板 2026-10-10);
+ 解析器 lxml 6.1.2 在 `tooling/requirements.lock` 内。
+- 用法与流程见 `fetch_format_rules.py` 模块 docstring(CLI 一次一本期刊)。
+- 产物:`data/format_cache/{journal_id}.json`(运行产物,已 gitignore,不入库)。
+
+### 现状(2026-10-10)
+
+OUP / Wiley / Cell 官网对本运行环境返回 403,自动抓取路线失败;
+Genome Biology / BMC Plant Biology / Frontiers / Rice 可抓但 LLM 抽取结果
+仍需人工对照官网。当前 `journals.json` 中 5 本精选期刊的 `format_rules`
+为**人工核验官网原文后写入**(`rules_source=official` + `source_url` +
+`last_verified`),**不是**本脚本产物。本工具保留作为后续换网络环境 /
+换可达站点后的重跑手段。
diff --git a/review_service/scripts/fetch_format_rules.py b/review_service/scripts/fetch_format_rules.py
new file mode 100644
index 0000000..4b5dd3a
--- /dev/null
+++ b/review_service/scripts/fetch_format_rules.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""scripts/fetch_format_rules.py —— 格式规则自动获取(第一批,人工核验用 CLI)。
+
+用法(在 review_service/ 下):
+ /home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python \
+ scripts/fetch_format_rules.py
+
+流程:
+ 1. fetch_submission_guidelines(url) # robots.txt + 同域 ≥2s 限速 + 正文转 Markdown
+ 2. extract_format_rules(markdown, journal_name) # DeepSeek 抽取(llm.py)
+ 3. 存 data/format_cache/{journal_id}.json(失败也存,便于批量对账)
+ 4. 打印完整结果 JSON 供人工核验
+
+凭证:结果文件顶层 source_url(步骤 1 的实际抓取地址)+ source_quotes(LLM
+逐字引证,按字段分组);非 null 字段缺引证 → 抽取判失败(ok=false)。
+
+约束(第一批):只跑指定 3 本;不触 API / 前端 / journals.json(format_rules 只读
+查名,不写回)。journal_name 优先取 data/journals/journals.json 的 name(只读)。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import re
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parents[1] # review_service/
+sys.path.insert(0, str(_ROOT))
+
+_JOURNALS_JSON = _ROOT / "data" / "journals" / "journals.json"
+_CACHE_DIR = _ROOT / "data" / "format_cache"
+
+
+def _load_env() -> None:
+ """镜像 restart.sh 的 `set -a; source .env`:REVIEW_LLM_*/DOCGEN_LLM_* 裸键
+ 由 services/llm.py 在每次调用时读 os.environ(不缓存),故调用前灌入即可。"""
+ env_path = _ROOT / ".env"
+ if not env_path.is_file():
+ return
+ for raw in env_path.read_text("utf-8").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ if line.startswith("export "):
+ line = line[7:].strip()
+ key, _, val = line.partition("=")
+ os.environ.setdefault(key.strip(), val.strip().strip("\"'"))
+
+
+def _journal_name(journal_id: str) -> str:
+ """journals.json 只读查名;查不到 → id 连字符转空格并首字母大写(LLM 上下文可读)。"""
+ try:
+ data = json.loads(_JOURNALS_JSON.read_text("utf-8"))
+ for j in data.get("journals") or []:
+ if j.get("id") == journal_id:
+ return str(j.get("name") or journal_id)
+ except Exception: # noqa: BLE001
+ pass
+ return journal_id.replace("-", " ").title()
+
+
+def main() -> int:
+ if len(sys.argv) != 3:
+ print("用法: fetch_format_rules.py ", file=sys.stderr)
+ return 2
+ journal_id = re.sub(r"[^\w.-]", "_", sys.argv[1].strip())
+ url = sys.argv[2].strip()
+ _load_env()
+
+ from app.services.format_extractor import extract_format_rules
+ from app.services.format_fetcher import fetch_submission_guidelines
+
+ async def run() -> tuple[dict, dict]:
+ fetched = await fetch_submission_guidelines(url)
+ print(f"[1/2] 抓取 {url} → ok={fetched['ok']}"
+ + (f",markdown={len(fetched['markdown'])} 字符"
+ if fetched["ok"] else f",error={fetched['error']}"))
+ if not fetched["ok"]:
+ return fetched, {"ok": False, "error": f"抓取失败:{fetched['error']}",
+ "rules": None, "source_quotes": None,
+ "confidence": None, "notes": None, "raw": None}
+ extracted = await extract_format_rules(fetched["markdown"], _journal_name(journal_id))
+ if extracted["ok"]:
+ filled = sum(1 for v in extracted["rules"].values() if v is not None)
+ print(f"[2/2] LLM 抽取 → ok=True,8 字段中 {filled} 个非 null")
+ else:
+ print(f"[2/2] LLM 抽取 → ok=False,error={extracted['error']}")
+ return fetched, extracted
+
+ fetched, extracted = asyncio.run(run())
+
+ result = {
+ "journal_id": journal_id,
+ "journal_name": _journal_name(journal_id),
+ "source_url": url,
+ "fetched_at": fetched.get("fetched_at"),
+ "extracted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
+ "markdown_chars": len(fetched.get("markdown") or ""),
+ "ok": bool(extracted.get("ok")),
+ "error": extracted.get("error"),
+ "format_rules": ({**extracted["rules"], "rules_source": "fetched"}
+ if extracted.get("ok") else None),
+ "source_quotes": extracted.get("source_quotes"),
+ "confidence": extracted.get("confidence"),
+ "notes": extracted.get("notes"),
+ "raw_llm": extracted.get("raw"),
+ }
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ out_path = _CACHE_DIR / f"{journal_id}.json"
+ out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8")
+
+ print(f"[save] {out_path.relative_to(_ROOT)}")
+ print("===== 完整结果(人工核验)=====")
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0 if result["ok"] else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())