Files
gplan-2/.qoder/skills/simplified-technical-chinese/tools/check.py
T

661 lines
30 KiB
Python
Raw 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.
#!/usr/bin/env python3
"""简明技术中文检查脚本。
零依赖,Python 3.9 及以上。读取 Markdown 或纯文本,按《简明技术中文规范》
和《词表》检查,输出每条发现的行号、规则号、级别和说明。
用法:
python3 tools/check.py 文件.md [更多文件...]
python3 tools/check.py --strict 文件.md # 有【必须】级别的发现时退出码为 1
python3 tools/check.py --json 文件.md # JSON 输出
python3 tools/check.py --kind 描述 回答.md # 把全文按描述句检查(例如聊天回答)
cat 回答.md | python3 tools/check.py -
脚本只做句法层面的检查,不判断事实是否正确。所有发现都需要人工确认。
"""
import argparse
import bisect
import json
import os
import re
import sys
from dataclasses import dataclass, asdict
from typing import Dict, List, Optional
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_VOCAB = os.path.normpath(os.path.join(HERE, "..", "词表.md"))
MUST = "必须"
SHOULD = "建议"
CJK = re.compile(r"[㐀-䶿一-鿿豈-﫿]")
LATIN_TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.+\-/]*")
ORDERED_ITEM = re.compile(r"^(\s*)(\d+)(?:[.)]\s+|[、)]\s*)(.*)$")
BULLET_ITEM = re.compile(r"^(\s*)[-*+]\s+(.*)$")
SENTENCE_SPLIT = re.compile(r"(?<=[。!?])(?![」』”’))])")
# 系统行为句的常见主语。编号步骤里以这些词开头的句子不按操作句检查(规则 4.4)。
SYSTEM_SUBJECTS = ("系统", "服务", "接口", "程序", "页面", "浏览器", "终端", "客户端", "服务端", "命令", "脚本")
# 编号列表项里以这些词开头的句子是说明,不是操作:指代、举例、原因、定义。校准发现编号列表常用来列举说明。
# 条件词(如果、当、在……之后)开头的句子仍算操作句:规则 2.5 要求条件写在动作前面。
NON_OPERATION_STARTS = ("对于", "由于", "因为", "例如", "比如", "这", "该", "此", "其", "它", "我们",
"注意", "说明", "即", "也就是", "每", "所有", "任何")
@dataclass
class Finding:
path: str
line: int
rule: str
level: str
message: str
text: str
def format(self) -> str:
return f"{self.path}:{self.line}: [{self.rule}][{self.level}] {self.message} ←「{self.text}」"
@dataclass
class Unit:
"""一个检查单元:一个段落、一个列表项、一个标题或一行表格。"""
kind: str # para | ordered | bullet | heading | table | quote
lines: List[str]
start_line: int
indented: bool = False # 首行有缩进。缩进的单元跟随前一个顶层单元的反例状态
skip: bool = False # 前面有 <!-- stc:skip --> 指令
# ---------------------------------------------------------------------------
# 词表
# ---------------------------------------------------------------------------
# 依赖语境的词用这里的正则替代简单匹配。键是词表中的词。
PATTERNS: Dict[str, str] = {
"进行": r"进行(?!中|时|曲|到底|了?\s*[A-Za-z])",
"相关": r"相关(?!性|系数|联)",
"等": r"(?:、[^、,。;:\n]{1,16}|(?:和|及|以及)[^,。;\n]{1,16})(?<![相不平均对同])等(?:等)?(?!待|候|级|于|同|价|号|式|效|比|分|距|量|温|高|长|边|到|着)",
"以上": r"\d\s*(?:个|次|秒|天|分钟|小时|倍|字|条|项|人|行|位|%|MB|GB|KB|TB|ms)?\s*以上",
"以下": r"\d\s*(?:个|次|秒|天|分钟|小时|倍|字|条|项|人|行|位|%|MB|GB|KB|TB|ms)?\s*以下",
"以内": r"\d[^。,;\n]{0,6}以内",
"左右": r"[\d个次秒天钟时元倍%米克条]\s*左右",
"及时": r"及时(?!性)",
"基本": r"基本(?=兼容|可用|可以|没有|不|都|相同|一致|正常|完成|稳定|无|能)",
"比较": r"比较(?=快|慢|大|小|多|少|高|低|长|短|好|差|稳定|复杂|简单|常见|重要|容易|困难|新|旧|久|频繁)",
"相对": r"相对(?=快|慢|大|小|多|少|高|低|简单|复杂|稳定|较|容易|困难)",
"相当": r"相当(?!于)",
"十分": r"十分(?!钟)",
"千万": r"千万(?!级|个|条|元|人|次|行|亿)",
"最好": r"最好(?!的)",
"请": r"(?<![申邀聘宴])请(?!求|假|教|示|柬|帖|见|参阅|参考|参见|注意)",
"展示": r"展示(?!层)",
"需要": r"^需要(?!不)",
"需": r"^需(?!要|求|不)",
"落地": r"落地(?!页|窗)",
"一些": r"(?<![这那])一些(?![,。;:、])",
"一下": r"一下(?!子)",
"显然": r"显然(?!易见)",
"注销": r"注销(?!账号|帐号)",
"开启": r"开启",
"去除": r"去除(?!噪|重)",
"选取": r"选取(?!区域|范围)",
# 下面这些是校准时发现的跨词误匹配:禁用词恰好是别的词的一部分
"入参": r"(?<![传输写导])入参(?!数|考)",
"出参": r"(?<![取给输导])出参(?!数|考|加|与|赛|观|展)",
"回包": r"(?<!返)回包(?!含|括|装|裹)",
"回传": r"(?<!返)回传",
"加以": r"(?<![添增附])加以(?!下|上|前|后)",
"予以": r"(?<![授给赋赐])予以(?!下|上)",
"做出": r"做出了?(?!贡献)",
"作出": r"作出了?(?!贡献)",
"对标": r"对标(?![签识题志量记准注杆])",
"应当": r"(?<!相)应当",
"其实": r"其实(?![施现行践际验例效体物质在习用时况])",
"不得": r"不得(?!不|到|了|已|其|而)",
"一定要": r"(?<![不必是否])一定要",
"点选": r"(?<!节)点选(?!择|项|中)",
"众所周知": r"众所周知(?!的)",
"方面": r"(?<![这那各多双单全一两几])方面",
"情况": r"的情况(?!下)", # 「在……的情况下」是固定搭配,校准精确率 32%,不查
"不准": r"不准(?![确备的,。;:、))((])(?!$)", # 「统计不准」是「不准确」,只查「不准 + 动词」
"心智": r"心智(?!模型|负担)",
}
# 句子里出现这些词时不报对应的禁用词:规则本身允许的用法
EXCLUDE_IF: Dict[str, str] = {
"等": r"例如|比如|诸如|譬如|包括|包含|如[::]|[((][^))]*等", # 3.5 允许描述句用「例如」引出不完整的例子;括号里的列举是举例
}
# 这些词的提示降为【建议】:校准显示它们常有正当用法,但出现时值得看一眼
WORD_LEVEL: Dict[str, str] = {
"然后": SHOULD, "接着": SHOULD, "之后再": SHOULD,
"痛点": SHOULD, "心智": SHOULD,
}
# 词的检查范围。op:只在操作句;ordered:只在编号列表项;para:只在正文段落。
SCOPE: Dict[str, str] = {
"请": "op",
"您": "op",
"你": "op",
"需要": "op",
"需": "op",
"用户可以": "op",
"用户需要": "op",
"然后": "ordered",
"接着": "ordered",
"之后再": "ordered",
"并且": "ordered",
"而且": "ordered",
"同时": "ordered",
"首先": "para",
"其次": "para",
"最后": "para",
# 2.3 的情态词只在操作句里查;描述句里的「应该」多表示预期(「输出应该是……」)
"应该": "op",
"应当": "op",
# 1.5 的程度词只在操作句里查;描述句里「很多」「大部分」常是没法给数值的正常表述
"很": "op", "非常": "op", "极其": "op", "相当": "op", "十分": "op",
"大量": "op", "少量": "op", "大部分": "op", "多数": "op", "少数": "op",
"合适": "op", "适当": "op", "适量": "op", "及时": "op", "尽快": "op",
# 5.4 的估计词只在操作句和参数表格里查;描述句可以用「约」
"一些": "op_table", "多次": "op_table", "若干": "op_table",
"大概": "op_table", "大约": "op_table", "左右": "op_table",
}
# 禁用词对应的规则号。没有列出的默认为 1.3。
RULE_OF_WORD: Dict[str, str] = {
"进行": "1.4", "加以": "1.4", "作出": "1.4", "做出": "1.4", "予以": "1.4",
"非常": "1.5", "很": "1.5", "极其": "1.5", "相当": "1.5", "十分": "1.5",
"比较": "1.5", "较为": "1.5", "相对": "1.5", "大量": "1.5", "少量": "1.5",
"大部分": "1.5", "多数": "1.5", "少数": "1.5", "尽快": "1.5", "及时": "1.5",
"适当": "1.5", "合适": "1.5", "适量": "1.5", "基本": "1.5", "基本上": "1.5",
"赋能": "1.7", "抓手": "1.7", "闭环": "1.7", "拉通": "1.7", "打通": "1.7",
"沉淀": "1.7", "落地": "1.7", "赛道": "1.7", "心智": "1.7", "兜底": "1.7",
"收口": "1.7", "透传": "1.7", "颗粒度": "1.7",
"打法": "1.7", "组合拳": "1.7", "痛点": "1.7", "牵头": "1.7", "倒逼": "1.7",
"体感": "1.7", "拉齐": "1.7", "对标": "1.7", "顶层设计": "1.7", "底层逻辑": "1.7",
"请": "2.3", "您": "2.3", "你": "2.3", "需要": "2.3", "需": "2.3",
"用户可以": "2.3", "用户需要": "2.3", "应该": "2.3", "应当": "2.3",
"然后": "4.1", "接着": "4.1", "之后再": "4.1", "并且": "4.1", "而且": "4.1", "同时": "4.1",
"首先": "3.3", "其次": "3.3", "最后": "3.3",
"等": "3.5",
"以上": "5.2", "以下": "5.2", "以内": "5.2",
"大概": "5.4", "大约": "5.4", "左右": "5.4", "若干": "5.4", "一些": "5.4", "多次": "5.4",
}
@dataclass
class VocabEntry:
words: List[str]
replace: str
note: str
rule: str
kind: str # banned | choice
def _parse_tables(md: str) -> Dict[str, List[List[str]]]:
sections: Dict[str, List[List[str]]] = {}
current: Optional[str] = None
for raw in md.splitlines():
line = raw.strip()
if line.startswith("## "):
current = line[3:].strip()
sections[current] = []
continue
if current is None or not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
continue
sections[current].append(cells)
return sections
def load_vocab(path: str = DEFAULT_VOCAB) -> List[VocabEntry]:
"""读取词表,返回标为「自动检查:是」的条目。"""
with open(path, encoding="utf-8") as f:
sections = _parse_tables(f.read())
entries: List[VocabEntry] = []
for title, rows in sections.items():
if "第一部分" in title:
for cells in rows[1:]:
if len(cells) < 4 or cells[3] != "是":
continue
words = [w for w in cells[0].split("、") if w]
for w in words:
entries.append(VocabEntry([w], cells[1], cells[2], RULE_OF_WORD.get(w, "1.3"), "banned"))
elif "第二部分" in title:
for cells in rows[1:]:
if len(cells) < 4 or cells[3] != "是":
continue
rejected = [w for w in cells[2].split("、") if w]
for w in rejected:
entries.append(VocabEntry([w], cells[1], cells[0], "1.1", "choice"))
return entries
# ---------------------------------------------------------------------------
# 文本预处理
# ---------------------------------------------------------------------------
COUNTEREXAMPLE_PREFIXES = ("不批准", "不推荐", "反例", "错误示例", "改写前")
def strip_markup(line: str) -> str:
"""去掉 Markdown 标记。行内代码记为 CODE,「」里的内容记为 Q。
「」里的内容是界面文字或被引用的词,按原样保留,不检查。
"""
line = re.sub(r"`[^`]*`", " CODE ", line)
line = re.sub(r"「[^」]*」", "「Q」", line)
line = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", line)
line = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", line)
line = re.sub(r"https?://\S+", " URL ", line)
line = re.sub(r"<[^>]+>", " ", line)
line = re.sub(r"(\*\*|~~|\*)(?=\S)", "", line)
line = re.sub(r"(?<=\S)(\*\*|~~|\*)", "", line)
return line
def is_counterexample(text: str) -> bool:
"""以「不批准:」这类前缀开头的行是反例,默认不检查。"""
return text.lstrip("-*+ \t").startswith(COUNTEREXAMPLE_PREFIXES)
def count_chars(text: str) -> int:
"""按附录 A 计算字数。"""
text = re.sub(r"`[^`]*`", " CODE ", text)
return len(CJK.findall(text)) + len(LATIN_TOKEN.findall(text))
def join_lines(lines: List[str]) -> tuple:
"""把一个单元的多行拼成一段。相邻两行都是西文时补一个空格。返回拼接结果和每行的起始偏移。"""
joined = ""
starts: List[int] = []
for line in lines:
if joined and re.search(r"[A-Za-z0-9]$", joined) and re.match(r"[A-Za-z0-9]", line):
joined += " "
starts.append(len(joined))
joined += line
return joined, starts
def split_sentences(text: str) -> List[str]:
parts = SENTENCE_SPLIT.split(text)
return [p.strip() for p in parts if p and p.strip()]
def unit_sentences(unit: Unit) -> List[tuple]:
"""一个单元里的句子和各自的起始行号:(句子, 行号)。
多行先拼成一段再分句:Markdown 里的硬换行不是句子边界。
"""
joined, line_starts = join_lines([strip_markup(l) for l in unit.lines])
out: List[tuple] = []
at = 0
for piece in SENTENCE_SPLIT.split(joined):
lead = len(piece) - len(piece.lstrip())
s = piece.strip()
if s:
out.append((s, unit.start_line + bisect.bisect_right(line_starts, at + lead) - 1))
at += len(piece)
return out
DIRECTIVE = re.compile(r"^\s*<!--\s*stc:(off|on|skip)\s*-->\s*$")
def parse_units(lines: List[str]) -> List[Unit]:
"""把文本切成检查单元。
跳过:代码块、文件开头的 YAML front matter、`<!-- stc:off -->` 到
`<!-- stc:on -->` 之间的内容。`<!-- stc:skip -->` 跳过它后面的一个单元。
"""
units: List[Unit] = []
in_fence = False
off = False
skip_next = False
current: Optional[Unit] = None
def close() -> None:
nonlocal current
if current is not None:
units.append(current)
current = None
def start(kind: str, text: str, idx: int, raw: str) -> Unit:
nonlocal skip_next
unit = Unit(kind, [text], idx, indented=raw[:1] in (" ", "\t"), skip=skip_next)
skip_next = False
return unit
start_at = 0
if lines and lines[0].strip() == "---":
for i in range(1, len(lines)):
if lines[i].strip() == "---":
start_at = i + 1
break
in_comment = False
for idx, raw in enumerate(lines[start_at:], start=start_at + 1):
if in_comment:
if "-->" not in raw:
continue
in_comment = False
raw = raw.split("-->", 1)[1]
d = DIRECTIVE.match(raw)
if d is None and "<!--" in raw:
# HTML 注释不是读者看到的内容。整行或行内的注释去掉;跨行注释到 --> 为止都跳过
raw = re.sub(r"<!--.*?-->", "", raw)
if "<!--" in raw:
raw = raw.split("<!--", 1)[0]
in_comment = True
stripped = raw.strip()
if d:
close()
if d.group(1) == "off":
off = True
elif d.group(1) == "on":
off = False
else:
skip_next = True
continue
if off:
continue
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
close()
continue
if in_fence:
continue
if not stripped:
close()
continue
if stripped.startswith("<") and stripped.endswith(">"):
# 整行是 HTML 标签(图片、组件示例、分隔),不是正文
close()
continue
if stripped.startswith("#"):
close()
units.append(start("heading", stripped.lstrip("#").strip(), idx, raw))
continue
if stripped.startswith("|"):
close()
units.append(start("table", stripped, idx, raw))
continue
if stripped.startswith(">"):
text = stripped.lstrip(">").strip()
if current is not None and current.kind == "quote":
current.lines.append(text)
else:
close()
current = start("quote", text, idx, raw)
continue
m = ORDERED_ITEM.match(raw)
if m:
close()
current = start("ordered", m.group(3).strip(), idx, raw)
continue
m = BULLET_ITEM.match(raw)
if m:
close()
current = start("bullet", m.group(2).strip(), idx, raw)
continue
if current is not None and current.kind in ("ordered", "bullet") and raw.startswith((" ", "\t")):
current.lines.append(stripped)
continue
if current is not None and current.kind == "para":
current.lines.append(stripped)
else:
close()
current = start("para", stripped, idx, raw)
close()
return units
# ---------------------------------------------------------------------------
# 规则
# ---------------------------------------------------------------------------
class Checker:
def __init__(self, vocab: Optional[List[VocabEntry]], max_op: int = 30, max_desc: int = 40,
kind: str = "auto", skip_counterexamples: bool = True) -> None:
self.vocab = vocab or []
self.max_op = max_op
self.max_desc = max_desc
self.kind = kind
self.skip_counterexamples = skip_counterexamples
self._compiled = [(e, re.compile(PATTERNS.get(e.words[0], re.escape(e.words[0])))) for e in self.vocab]
# -- 入口 --------------------------------------------------------------
def check_text(self, text: str, path: str = "<text>") -> List[Finding]:
units = parse_units(text.splitlines())
findings: List[Finding] = []
checked: List[Unit] = []
in_counterexample = False
for unit in units:
if not unit.indented:
in_counterexample = self.skip_counterexamples and is_counterexample(unit.lines[0])
if in_counterexample or unit.skip:
continue
checked.append(unit)
findings.extend(self._check_unit(unit, path))
# 1.6 缩写写法:脚本查不出来。大小写不同的写法几乎都来自代码、路径和链接锚点(校准精确率 0%)
findings = self._dedupe(findings)
findings.sort(key=lambda f: (f.line, f.rule))
return findings
def check_file(self, path: str) -> List[Finding]:
with open(path, encoding="utf-8") as f:
return self.check_text(f.read(), path)
# -- 单元 --------------------------------------------------------------
def _check_unit(self, unit: Unit, path: str) -> List[Finding]:
out: List[Finding] = []
clean_lines = [strip_markup(l) for l in unit.lines]
if unit.kind in ("heading", "table"):
for offset, line in enumerate(clean_lines):
out.extend(self._check_vocab(line, unit.start_line + offset, path, op=False, unit_kind=unit.kind))
return out
sentences = unit_sentences(unit) # (sentence, line_no)
# 3.2 段落句数。只数有句末标点的句子:没有标点的行是标签或列名,不是句子
n_sentences = sum(1 for s, _ in sentences if s.endswith(("。", "!", "?", ";", ".", "!", "?", ";")))
if unit.kind == "para" and n_sentences > 6:
out.append(Finding(path, unit.start_line, "3.2", MUST,
f"一段有 {n_sentences} 句,不超过 6 句", clean_lines[0][:30]))
# 3.3 正文里串联步骤:脚本不查。正文里的「首先……然后……」多数在描述执行顺序,不是在写步骤(校准精确率 40%、30%)
for pos, (sentence, line_no) in enumerate(sentences):
op = self.is_operation(unit, sentence, pos)
out.extend(self._check_sentence(sentence, line_no, path, op, unit.kind))
out.extend(self._check_vocab(sentence, line_no, path, op, unit.kind))
return out
def is_operation(self, unit: Unit, sentence: str, pos: int) -> bool:
"""这句话按操作句检查吗。auto 模式下:编号列表项里、不以系统主语或说明性词开头的句子。"""
if self.kind == "操作":
return True
if self.kind == "描述":
return False
if unit.kind != "ordered":
return False
if sentence.startswith(SYSTEM_SUBJECTS) or sentence.startswith(NON_OPERATION_STARTS):
return False
return True
# -- 句子规则 ----------------------------------------------------------
def _check_sentence(self, s: str, line: int, path: str, op: bool, unit_kind: str) -> List[Finding]:
out: List[Finding] = []
n = count_chars(s)
excerpt = s[:40]
# 2.1 句长
limit = self.max_op if op else self.max_desc
label = "操作句" if op else "描述句"
if n > limit:
out.append(Finding(path, line, "2.1", MUST, f"{label} {n} 字,不超过 {limit} 字", excerpt))
# 2.2 一句多事。「之后」「随后」多是时间状语,不算连接词。级别建议:校准精确率 53%
connectors = re.findall(r"然后|并且|而且|接着", s)
if not op and len(connectors) >= 2:
out.append(Finding(path, line, "2.2", SHOULD, "一句里有多个动作,拆句", excerpt))
# 2.3 操作句的祈使形式
if op and re.match(r"^(用户|使用者|管理员|开发者)(需要|可以|应该|应当|必须)", s):
out.append(Finding(path, line, "2.3", MUST, "操作句用祈使句,去掉主语和「需要」「可以」", excerpt))
# 2.4 被动。只查操作句,级别建议:描述句里的被动多是「执行者未知或不重要」的合规用法,
# 操作句里「被 X 的 Y」是定语不是谓语,脚本都分不出来(校准精确率 42% 和 43%)。「为……所」太弱,不查
if op:
passive = re.search(r"被(?!动|告|迫|称为|视为|子|窝|套|褥|面)(?![^,。]{1,4}的)|受到|遭到", s)
if passive is None:
passive = re.search(r"由[^,。于来此]{1,8}(?:负责|完成|执行|生成|处理|发送|收集|读取|管理|维护|调用|触发|创建|返回|接管|承担)", s)
if passive:
out.append(Finding(path, line, "2.4", SHOULD, "被动句,改为主动句并写出执行者", excerpt))
# 2.5 条件在后。只查操作句句尾的条件;描述句的条件从句位置自由,括号里的内容多是补充说明(校准精确率 34%、35%)
cond = r"(?:如果|若(?!干)|假如|当(?!前|时|天|地|然|中|作|成))"
if op and re.search(r"[,,]\s*" + cond + r"[^,。]{1,20}[。!?;]?$", s):
out.append(Finding(path, line, "2.5", MUST, "条件写在动作后面,移到动作前", excerpt))
# 2.6 否定。只查双重否定;「一句多个否定」多是独立分句各管各的(校准精确率 22%)。
# 级别建议:「不得不」这类固定搭配模型多判为正常用法(校准精确率 53%)
if re.search(r"不得不|不无|未尝不|无不|不能不|不会不|没有[^,。;:]{0,6}不(?!同|是|过|仅|但|论|管|错|止)|不[^,。]{0,4}不是不", s):
out.append(Finding(path, line, "2.6", SHOULD, "双重否定,改为肯定句", excerpt))
# 2.7 长定语、2.8 指代:脚本查不出来。「的」的数量和指代词的数量都不能代表定语长度和指代是否清楚(校准精确率 2% 和 0%)
# 2.9 反问、设问、感叹
if s.endswith(("?", "?", "!", "!")) and unit_kind != "heading":
out.append(Finding(path, line, "2.9", MUST, "不用问句和感叹句", excerpt))
if re.search(r"难道|岂不|何必", s):
out.append(Finding(path, line, "2.9", MUST, "反问,改为陈述句", excerpt))
# 4.3 警告、4.5 前置条件、4.6 原因:脚本查不出来。编号列表项不一定是步骤,「注意」「因为」「(版本)」在说明性列表里都是正常用法(校准精确率 19%、12%、34%)
# 5.1 中文数字和「百分之」。「一次」「两次」「一行」是量词习惯用法,不查;三以上和两位数才查
units = r"(?:秒|分钟|小时|天|周|次|倍|行|列|位|字节|毫秒)"
if re.search(r"(?<![一十第每这那上下前后头同几数好])(?:[三四五六七八九十]|[一二两三四五六七八九]十[一二三四五六七八九]?|十[一二三四五六七八九])" + units + r"(?!钟|数|性|制)", s):
out.append(Finding(path, line, "5.1", MUST, "用阿拉伯数字", excerpt))
if "百分之" in s and "百分之百" not in s:
out.append(Finding(path, line, "5.1", MUST, "「百分之」改为 %", excerpt))
if re.search(r"\d(?:MB|GB|KB|TB|ms)\b", s):
out.append(Finding(path, line, "5.1", SHOULD, "数字与单位之间空一格", excerpt))
# 5.3 相对时间。只查相对于写作时间的日期词;「稍后」「近期」在技术描述里多指程序时序,不查
# 「上个月」「下个月」「去年」在日期 API 文档里是计算语义,不查
if re.search(r"明天|后天|昨天|前天|下周(?!期)|上周|近期将|不久的将来|即将(?:发布|上线|下线|推出)|过几天", s):
out.append(Finding(path, line, "5.3", SHOULD, "相对时间,改为绝对日期或时长", excerpt))
# 5.4 操作句里的估计词
if op and re.search(r"(?<![预节合契违签规大])约(?!束|定|会|谈|稿)|几(?=次|个|秒|分钟|天|行|台|分)", s):
out.append(Finding(path, line, "5.4", MUST, "操作句里有估计词,写出数值", excerpt))
# 5.5 倍数表示减少
if re.search(r"(?:降低|减少|下降|缩小|降)[^,。]{0,6}\d+\s*倍", s):
out.append(Finding(path, line, "5.5", MUST, "减少不用「倍」,用「减少到原来的 1/n」或百分比", excerpt))
# 6.1 界面元素名
if op and re.search(r"点击(?!\s*「|\s*『|\s*CODE)", s):
out.append(Finding(path, line, "6.1", SHOULD, "界面元素名用「」括起", excerpt))
return out
# -- 词表 --------------------------------------------------------------
def _check_vocab(self, s: str, line: int, path: str, op: bool, unit_kind: str) -> List[Finding]:
out: List[Finding] = []
for entry, pattern in self._compiled:
word = entry.words[0]
scope = SCOPE.get(word)
if scope == "op" and not op:
continue
if scope == "op_table" and not (op or unit_kind == "table"):
continue
if scope == "ordered" and unit_kind != "ordered":
continue
if scope == "para" and unit_kind != "para":
continue
m = pattern.search(s)
if not m:
continue
if word in EXCLUDE_IF and re.search(EXCLUDE_IF[word], s):
continue
if entry.kind == "banned":
msg = f"禁用词「{word}」,改用:{entry.replace}"
else:
msg = f"「{word}」改用「{entry.replace}」({entry.note})"
out.append(Finding(path, line, entry.rule, WORD_LEVEL.get(word, MUST), msg, s[:40]))
return out
@staticmethod
def _dedupe(findings: List[Finding]) -> List[Finding]:
seen = set()
out = []
for f in findings:
key = (f.line, f.rule, f.message)
if key in seen:
continue
seen.add(key)
out.append(f)
return out
# ---------------------------------------------------------------------------
# 命令行
# ---------------------------------------------------------------------------
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description="简明技术中文检查脚本")
parser.add_argument("files", nargs="+", help="要检查的文件;用 - 表示标准输入")
parser.add_argument("--strict", action="store_true", help="有【必须】级别的发现时退出码为 1")
parser.add_argument("--json", action="store_true", help="JSON 输出")
parser.add_argument("--kind", choices=["auto", "操作", "描述"], default="auto",
help="句子类型。auto:编号列表项按操作句,其他按描述句")
parser.add_argument("--vocab", default=DEFAULT_VOCAB, help="词表路径")
parser.add_argument("--no-vocab", action="store_true", help="不检查词表")
parser.add_argument("--max-op", type=int, default=30, help="操作句字数上限,默认 30")
parser.add_argument("--max-desc", type=int, default=40, help="描述句字数上限,默认 40")
parser.add_argument("--quiet", action="store_true", help="只输出汇总")
parser.add_argument("--check-counterexamples", action="store_true",
help="也检查以「不批准:」「反例」等开头的行。默认跳过")
args = parser.parse_args(argv)
vocab = None if args.no_vocab else load_vocab(args.vocab)
checker = Checker(vocab, args.max_op, args.max_desc, args.kind,
skip_counterexamples=not args.check_counterexamples)
findings: List[Finding] = []
for path in args.files:
if path == "-":
findings.extend(checker.check_text(sys.stdin.read(), "<stdin>"))
else:
findings.extend(checker.check_file(path))
must = sum(1 for f in findings if f.level == MUST)
should = len(findings) - must
if args.json:
print(json.dumps({"findings": [asdict(f) for f in findings], "必须": must, "建议": should},
ensure_ascii=False, indent=2))
else:
if not args.quiet:
for f in findings:
print(f.format())
print(f"共 {len(findings)} 条:{MUST} {must},{SHOULD} {should}")
if args.strict and must > 0:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())