bookskill
§ Source-level Observatory

book-to-skill tests quality 测试与质量门

把一本技术书或文档文件夹转成 Claude Code skill 的工具。值得拆的不是 README 里的承诺,而是它怎样处理多格式抽取、fallback、Unicode 清洗、输出目录权限和生成 skill 的扫描门禁。

★ 1,030
GitHub Stars
6.7K
LOC scanned
113
Files
today
Last commit
PDF / EPUB / DOCX / HTML / RTF │ ▼ extract_single_file │ ├─ dependency fallback ├─ invisible Unicode sanitizer ├─ structure detector ▼ full_text.txt + metadata.json │ ▼ generated skill scan / validation
§ 00 · QUALITY

测试覆盖的是格式边界,不是只有 smoke test

测试入口导入了核心函数和解析器:sources/book-to-skill/tests/test_book_to_skill.py:10 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:11 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:13 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:15 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:17,说明它不是纯 CLI 黑盒测试。EPUB、DOCX、RTF、PDF 和解析器 fallback 都有可单测接口,适合维护回归。

sources/book-to-skill/tests/test_book_to_skill.py · L1-18Python
1import json\n2import sys\n3import textwrap\n4import zipfile\n5from pathlib import Path\n6from unittest import mock\n7import pytest\n8ROOT_DIR = Path(__file__).resolve().parent.parent\n9sys.path.insert(0, str(ROOT_DIR))\n10from book_to_skill.exceptions import ExtractionError\n11from book_to_skill.utils import resolve_input_files, extract_single_file, parse_arguments, estimate_tokens, detect_structure, _cn_numeral_to_int, main\n12from book_to_skill.config import SUPPORTED_EXTENSIONS\n13from book_to_skill.parsers import pdf as pdf_parser\n14from book_to_skill.parsers.text import read_text_file\n15from book_to_skill.parsers.docx import extract_docx_with_zipfile\n16from book_to_skill.parsers.rtf import strip_rtf_fallback\n17from book_to_skill.parsers.epub import extract_with_zipfile\n18
§ 01 · EPUB

EPUB OPF 和 spine 顺序测试

▸构造最小 EPUB 和 OEBPS EPUB

sources/book-to-skill/tests/test_book_to_skill.py:31 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:32 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:34 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:50 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:53 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:54 直接构造 zip 内部结构,避免依赖外部 fixture;sources/book-to-skill/tests/test_book_to_skill.py:87 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:90 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:91 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:104 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:109 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:111 验证 OEBPS 路径、container.xml 和章节计数。

sources/book-to-skill/tests/test_book_to_skill.py · L31-57Python
31def _make_minimal_epub(path: Path) -> Path:\n32    with zipfile.ZipFile(path, 'w') as zf:\n33        zf.writestr('mimetype', 'application/epub+zip')\n34        zf.writestr('content.opf', textwrap.dedent('                <?xml version="1.0"?>\n                <package xmlns="http://www.idpf.org/2007/opf" version="3.0">\n                  <metadata/>\n                  <manifest>\n                    <item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>\n                  </manifest>\n                  <spine>\n                    <itemref idref="ch1"/>\n                  </spine>\n                </package>\n            '))\n35        zf.writestr('chapter1.xhtml', '<html><body><p>EPUB chapter one content.</p></body></html>')\n36    return path\n37\n38def _make_minimal_docx(path: Path) -> Path:\n39    ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'\n40    xml = textwrap.dedent(f'        <?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n        <w:document xmlns:w="{ns}">\n          <w:body>\n            <w:p><w:r><w:t>DOCX test paragraph</w:t></w:r></w:p>\n          </w:body>\n        </w:document>\n    ')\n41    with zipfile.ZipFile(path, 'w') as zf:\n42        zf.writestr('word/document.xml', xml)\n43        zf.writestr('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>')\n44    return path\n45\n46def _make_unsupported_file(path: Path) -> Path:\n47    path.write_bytes(b'unsupported binary junk data')\n48    return path\n49\n50def _make_oebps_epub(path: Path) -> Path:\n51    with zipfile.ZipFile(path, 'w') as zf:\n52        zf.writestr('mimetype', 'application/epub+zip')\n53        zf.writestr('META-INF/container.xml', textwrap.dedent('                <?xml version="1.0"?>\n                <container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"\n                           version="1.0">\n                  <rootfiles>\n                    <rootfile full-path="OEBPS/content.opf"\n                              media-type="application/oebps-package+xml"/>\n                  </rootfiles>\n                </container>\n            '))\n54        zf.writestr('OEBPS/content.opf', textwrap.dedent('                <?xml version="1.0"?>\n                <package xmlns="http://www.idpf.org/2007/opf" version="3.0">\n                  <metadata/>\n                  <manifest>\n                    <item id="ch1" href="sections/ch1.xhtml" media-type="application/xhtml+xml"/>\n                    <item id="ch2" href="sections/ch2.xhtml" media-type="application/xhtml+xml"/>\n                  </manifest>\n                  <spine>\n                    <itemref idref="ch1"/>\n                    <itemref idref="ch2"/>\n                  </spine>\n                </package>\n            '))\n55        zf.writestr('OEBPS/sections/ch1.xhtml', '<html><body><p>Chapter one from OEBPS.</p></body></html>')\n56        zf.writestr('OEBPS/sections/ch2.xhtml', '<html><body><p>Chapter two from OEBPS.</p></body></html>')\n57    return path

▸fallback 不再 tuple unpack

sources/book-to-skill/tests/test_book_to_skill.py:66 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:69 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:70 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:76 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:80 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:82 锁定此前的 EPUB regression;这类测试对格式 parser 工具比 benchmark 更重要,因为用户输入经常来自真实电子书。

sources/book-to-skill/tests/test_book_to_skill.py · L59-83Python
59class TestEpubExtractionFix:\n60\n61    def test_epub_extract_with_ebooklib_returns_str_or_none(self):\n62        from book_to_skill.parsers.epub import extract_with_ebooklib\n63        result = extract_with_ebooklib('nonexistent.epub')\n64        assert result is None or isinstance(result, str), f'extract_with_ebooklib should return str|None, got {type(result)}'\n65\n66    def test_epub_extraction_via_zipfile_fallback(self, tmp_path):\n67        epub_path = _make_minimal_epub(tmp_path / 'test.epub')\n68        with mock.patch('book_to_skill.utils.prepare_dependencies'):\n69            result = extract_single_file(epub_path, 'text', 'no')\n70        assert result['format'] == 'epub'\n71        assert result['extraction_method'] in ('ebooklib', 'zipfile')\n72        assert 'EPUB chapter one content' in result['text']\n73        assert result['chars'] > 0\n74        assert result['words'] > 0\n75\n76    def test_epub_no_tuple_unpack_error(self, tmp_path):\n77        epub_path = _make_minimal_epub(tmp_path / 'test.epub')\n78        with mock.patch('book_to_skill.utils.prepare_dependencies'):\n79            try:\n80                result = extract_single_file(epub_path, 'text', 'no')\n81            except (TypeError, ValueError) as exc:\n82                pytest.fail(f'Tuple-unpack regression! Got: {exc}')\n83        assert result['text']
§ 02 · SECURITY TESTS

Unicode 和输出目录安全测试

▸清洗前后指标一致

sources/book-to-skill/tests/test_sanitize_extracted_text.py:10 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:11 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:13 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:22 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:27 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:31 确认隐形字符被移除、stderr 会报告数量,且 chars/words 用清洗后的文本计算;sources/book-to-skill/tests/test_sanitize_extracted_text.py:40 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:51 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:55 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:56 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:57 还验证主流程写出的 full_text 与 metadata 不含隐形字符。

sources/book-to-skill/tests/test_sanitize_extracted_text.py · L10-58Python
10def test_sanitizer_removes_zero_width_and_tag_block_codepoints():\n11    sanitized, removed = sanitize_extracted_text(f'before{INVISIBLE_CODEPOINTS}after')\n12    assert sanitized == 'beforeafter'\n13    assert removed == len(INVISIBLE_CODEPOINTS)\n14\n15def test_sanitizer_preserves_normal_multilingual_text_byte_for_byte():\n16    original = 'Café - 中文 - 🙂 - plain ASCII\n'\n17    sanitized, removed = sanitize_extracted_text(original)\n18    assert sanitized == original\n19    assert sanitized.encode('utf-8') == original.encode('utf-8')\n20    assert removed == 0\n21\n22def test_extract_single_file_sanitizes_before_metrics(tmp_path, capsys):\n23    source = tmp_path / 'source.txt'\n24    source.write_text('No customer PII\u200b leaves the repo.\U000e0069', encoding='utf-8')\n25    with mock.patch('book_to_skill.utils.prepare_dependencies'):\n26        result = extract_single_file(source, 'text', 'no')\n27    assert result['text'] == 'No customer PII leaves the repo.'\n28    assert result['chars'] == len(result['text'])\n29    assert result['words'] == 6\n30    captured = capsys.readouterr()\n31    assert 'removed 2 invisible Unicode code point(s)' in captured.err\n32\n33def test_extract_single_file_rejects_invisible_only_source(tmp_path):\n34    source = tmp_path / 'invisible.txt'\n35    source.write_text(INVISIBLE_CODEPOINTS, encoding='utf-8')\n36    with mock.patch('book_to_skill.utils.prepare_dependencies'):\n37        with pytest.raises(ExtractionError, match='no visible content'):\n38            extract_single_file(source, 'text', 'no')\n39\n40def test_main_writes_only_sanitized_text_and_metrics(tmp_path, monkeypatch):\n41    source = tmp_path / 'source.txt'\n42    source.write_text('Chapter 1\nPolicy\u200b text.\U000e0069', encoding='utf-8')\n43    output_dir = tmp_path / 'output'\n44    output_text = output_dir / 'full_text.txt'\n45    output_meta = output_dir / 'metadata.json'\n46    monkeypatch.setattr(sys, 'argv', ['extract.py', str(source)])\n47    monkeypatch.setattr('book_to_skill.utils.OUTPUT_DIR', output_dir)\n48    monkeypatch.setattr('book_to_skill.utils.OUTPUT_TEXT', output_text)\n49    monkeypatch.setattr('book_to_skill.utils.OUTPUT_META', output_meta)\n50    monkeypatch.setattr('book_to_skill.utils.prepare_dependencies', lambda *args: None)\n51    main()\n52    extracted = output_text.read_text(encoding='utf-8')\n53    metadata = json.loads(output_meta.read_text(encoding='utf-8'))\n54    expected_source, _ = sanitize_extracted_text(source.read_bytes().decode('utf-8'))\n55    assert 'Policy text.' in extracted\n56    assert all((character not in extracted for character in INVISIBLE_CODEPOINTS))\n57    assert metadata['chars'] == len(extracted)\n58    assert metadata['sources'][0]['chars'] == len(expected_source)

▸输出目录不是随便写

sources/book-to-skill/tests/test_output_dir_security.py:7 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:11 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:13 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:18 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:21 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:24 覆盖 0700 权限、symlink 拒绝和非目录拒绝;sources/book-to-skill/tests/test_output_dir_security.py:27 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:31 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:35 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:40 覆盖已有目录权限收紧和异主目录拒绝。

sources/book-to-skill/tests/test_output_dir_security.py · L7-41Python
7def test_prepare_output_dir_creates_dir_with_restrictive_permissions(tmp_path):\n8    target = tmp_path / 'work'\n9    prepare_output_dir(target)\n10    assert target.is_dir()\n11    assert stat.S_IMODE(target.stat().st_mode) == 448\n12\n13def test_prepare_output_dir_rejects_symlink(tmp_path):\n14    real_dir = tmp_path / 'real'\n15    real_dir.mkdir()\n16    link = tmp_path / 'work'\n17    link.symlink_to(real_dir)\n18    with pytest.raises(ExtractionError, match='symbolic link'):\n19        prepare_output_dir(link)\n20\n21def test_prepare_output_dir_rejects_non_directory(tmp_path):\n22    target = tmp_path / 'work'\n23    target.write_text('not a directory')\n24    with pytest.raises(ExtractionError, match='not a directory'):\n25        prepare_output_dir(target)\n26\n27def test_prepare_output_dir_tightens_permissions_on_existing_own_dir(tmp_path):\n28    target = tmp_path / 'work'\n29    target.mkdir()\n30    os.chmod(target, 511)\n31    prepare_output_dir(target)\n32    assert stat.S_IMODE(target.stat().st_mode) == 448\n33\n34@pytest.mark.skipif(not hasattr(os, 'getuid'), reason='POSIX-only ownership check')\n35def test_prepare_output_dir_rejects_directory_owned_by_another_user(tmp_path, monkeypatch):\n36    target = tmp_path / 'work'\n37    target.mkdir()\n38    real_getuid = os.getuid\n39    monkeypatch.setattr(os, 'getuid', lambda: real_getuid() + 1)\n40    with pytest.raises(ExtractionError, match='owned by a different user'):\n41        prepare_output_dir(target)
§ 03 · SCANNER

生成 skill 的扫描门禁

sources/book-to-skill/tools/scan_generated_skill.py:16 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:17 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:18 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:19 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:113 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:122 定义 prompt override、fake system prefix、tool_call 和 exfiltration shape;sources/book-to-skill/tools/scan_generated_skill.py:134 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:140 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:149 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:159 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:166 汇总 finding 并以非零退出提醒用户复查。

sources/book-to-skill/tools/scan_generated_skill.py · L113-169Python
113def _scan_text(relative_path: str, text: str) -> list[Finding]:\n114    findings: list[Finding] = []\n115    lines = text.splitlines()\n116    frontmatter_lines = _frontmatter_line_numbers(lines)\n117    for line_number, line in enumerate(lines, start=1):\n118        invisible = sorted({ord(char) for char in line if _is_invisible(ord(char))})\n119        if invisible:\n120            codepoints = ', '.join((f'U+{value:04X}' for value in invisible))\n121            findings.append(Finding(relative_path, line_number, 'unicode.invisible', f'contains invisible Unicode code point(s): {codepoints}'))\n122        for rule_id, pattern, message in _CONTENT_RULES:\n123            if pattern.search(line):\n124                findings.append(Finding(relative_path, line_number, rule_id, message))\n125        if _EXFILTRATION_TERM.search(line) or (_OUTBOUND_TERM.search(line) and _SENSITIVE_TERM.search(line)):\n126            findings.append(Finding(relative_path, line_number, 'tool.exfiltration_shape', 'contains exfiltration-shaped tool or sensitive-data language'))\n127        if line_number in frontmatter_lines:\n128            if re.match('^\\s*allowed-tools\\s*:', line, re.IGNORECASE):\n129                findings.append(Finding(relative_path, line_number, 'frontmatter.allowed_tools', 'generated frontmatter declares or widens tool authority'))\n130            if re.match('^\\s*disable-model-invocation\\s*:\\s*[\\"\']?(?:false|no|0)[\\"\']?\\s*(?:#.*)?$', line, re.IGNORECASE):\n131                findings.append(Finding(relative_path, line_number, 'frontmatter.model_invocation_enabled', 'generated frontmatter explicitly enables model invocation'))\n132    return findings\n133\n134def scan_generated_skill(path: Path) -> list[Finding]:\n135    requested = path.expanduser()\n136    skill_dir = requested.parent if requested.name.lower() == 'skill.md' else requested\n137    files = _collect_skill_files(requested)\n138    root = skill_dir.resolve(strict=True)\n139    findings: list[Finding] = []\n140    for relative_path, text in _read_skill_files(root, files):\n141        findings.extend(_scan_text(relative_path, text))\n142    return sorted(findings, key=lambda item: (item.path.lower(), item.line, item.rule_id))\n143\n144def main(argv: Sequence[str] | None=None) -> int:\n145    parser = argparse.ArgumentParser(description=__doc__)\n146    parser.add_argument('path', help='Generated skill directory or its SKILL.md')\n147    args = parser.parse_args(argv)\n148    try:\n149        findings = scan_generated_skill(Path(args.path))\n150        skipped = unscanned_markdown(Path(args.path))\n151    except ScanError as exc:\n152        print(f'ERROR generated-skill scan incomplete: {exc}', file=sys.stderr)\n153        return 2\n154    if skipped:\n155        print(f'Note: {len(skipped)} Markdown file(s) in the skill directory are outside the generated-skill scope and were NOT scanned:')\n156        for relative in skipped:\n157            print(f'  SKIP {_terminal_safe(relative)}')\n158        print('  Scope is SKILL.md, glossary/patterns/cheatsheet, and chapters/. Move generated content there to have it scanned.')\n159    if findings:\n160        print(f'Generated-skill scan found {len(findings)} advisory finding(s):')\n161        for finding in findings:\n162            print(f'  WARN {_terminal_safe(finding.path)}:{finding.line} [{finding.rule_id}] {finding.message}')\n163        print('Review the generated files before loading, installing, or publishing them.')\n164        print('Rules are intentionally broad and may match legitimate AI/LLM or systems-topic text; review each finding in context.')\n165        print('No files were modified by this scan.')\n166        return 1\n167    scope = ' in the scanned scope' if skipped else ''\n168    print(f'Generated-skill scan passed: no known injection or authority patterns found{scope}.')\n169    return 0
§ 04 · RELATED

关联模块

主流程
sources/book-to-skill/book_to_skill/utils.py:414 是 CLI 转换主入口。
validate tool
sources/book-to-skill/tools/validate_skill.py:48 校验生成 skill 的 frontmatter。