check_sphinx_api_html.py ダウンロード/コピー

check_sphinx_api_html.py をダウンロード

check_sphinx_api_html.py
check_sphinx_api_html.py
  1#!/usr/bin/env python
  2# -*- coding: utf-8 -*-
  3
  4"""
  5Sphinxで生成された *_api.html を走査し、autodoc/API取り込みに失敗して本文が空っぽに近いHTMLを検出するスクリプト。
  6
  7概要:
  8    Sphinxで生成されたAPIドキュメントHTMLファイル(通常 `*_api.html`)を分析し、
  9    Sphinxのautodoc機能がAPI要素を適切に抽出し、本文コンテンツを生成できなかったページを特定します。
 10    これにより、ドキュメント生成プロセスの問題を早期に発見するのに役立ちます。
 11
 12詳細説明:
 13    スクリプトは指定されたディレクトリ以下のHTMLファイルを走査し、各ファイルのメインコンテンツ領域を抽出します。
 14    抽出されたコンテンツからHTMLタグを除去してプレーンテキストを生成し、
 15    そのテキストの長さや、典型的なSphinx autodocマーカーの存在をチェックします。
 16    本文が極端に短い、またはAPIマーカーが見つからない場合、そのページはAPI取り込みに失敗したと判断され、
 17    そのファイルパスと失敗理由が出力されます。
 18
 19例:
 20    python check_sphinx_api_html.py
 21    python check_sphinx_api_html.py --root _build/html
 22    python check_sphinx_api_html.py --root _build/html --files "*_api.html"
 23    python check_sphinx_api_html.py --outfile failed_api_files.txt
 24
 25関連リンク:
 26    :doc:`check_sphinx_api_html_usage`
 27"""
 28
 29import argparse
 30import re
 31import sys
 32from pathlib import Path
 33from html import unescape
 34
 35
 36def read_text(path: Path) -> str:
 37    """
 38    HTMLファイルを文字コード推定つきで読む。
 39
 40    複数の文字コード(utf-8, cp932, shift_jis, latin-1)を試行し、
 41    デコードに成功した場合はそのテキストを返します。
 42    すべての試行が失敗した場合は、バイト列を `utf-8` でエラーを置き換えてデコードします。
 43
 44    :param path: Path HTMLファイルのパス。
 45    :returns: str デコードされたテキスト。
 46    """
 47    for enc in ("utf-8", "cp932", "shift_jis", "latin-1"):
 48        try:
 49            return path.read_text(encoding=enc)
 50        except UnicodeDecodeError:
 51            continue
 52    return path.read_bytes().decode("utf-8", errors="replace")
 53
 54
 55def strip_html(html: str) -> str:
 56    """
 57    HTMLタグをざっくり除去して本文テキスト化する。
 58
 59    <script>, <style>, <nav>, <footer>, <header> タグとその内容を除去し、
 60    その後すべてのHTMLタグを除去します。HTMLエンティティをデコードし、
 61    複数の空白を単一の空白に変換して、前後の空白を除去します。
 62
 63    :param html: str 処理対象のHTML文字列。
 64    :returns: str HTMLタグが除去され、整形されたテキスト。
 65    """
 66    html = re.sub(r"(?is)<script.*?</script>", " ", html)
 67    html = re.sub(r"(?is)<style.*?</style>", " ", html)
 68    html = re.sub(r"(?is)<nav.*?</nav>", " ", html)
 69    html = re.sub(r"(?is)<footer.*?</footer>", " ", html)
 70    html = re.sub(r"(?is)<header.*?</header>", " ", html)
 71    text = re.sub(r"(?is)<[^>]+>", " ", html)
 72    text = unescape(text)
 73    text = re.sub(r"\s+", " ", text)
 74    return text.strip()
 75
 76
 77def extract_main_html(html: str) -> str:
 78    """
 79    Sphinx ReadTheDocs系HTMLから本文領域っぽい部分を抜く。
 80
 81    複数の正規表現パターンを順に試し、`role="main"` や `class="document"` などの要素を含む
 82    HTMLの主要コンテンツ領域を抽出します。
 83    いずれのパターンにもマッチしない場合は、元のHTML全体を返します。
 84
 85    :param html: str 処理対象のHTML文字列。
 86    :returns: str 抽出された本文領域のHTML、または元のHTML全体。
 87    """
 88    patterns = [
 89        r'(?is)<div[^>]+role=["\']main["\'][^>]*>(.*?)</div>\s*</div>\s*</section>',
 90        r'(?is)<div[^>]+class=["\'][^"\']*document[^"\']*["\'][^>]*>(.*?)</div>\s*</section>',
 91        r'(?is)<section[^>]*>(.*?)</section>',
 92        r'(?is)<main[^>]*>(.*?)</main>',
 93        r'(?is)<article[^>]*>(.*?)</article>',
 94    ]
 95
 96    for pat in patterns:
 97        m = re.search(pat, html)
 98        if m:
 99            return m.group(1)
100
101    return html
102
103
104def check_api_html(path: Path, min_text_len: int = 300) -> tuple[bool, list[str]]:
105    """
106    指定されたHTMLファイルがSphinx autodocによるAPIドキュメントとして適切に生成されているかをチェックする。
107
108    ファイルを読み込み、メインのHTMLコンテンツを抽出し、そこからテキストを整形します。
109    Sphinx autodocの典型的なマーカーが存在するか、本文のテキスト長が短いか、
110    あるいはタイトルのみのページのように見えるかなどを基準に、API取り込みの失敗を判定します。
111
112    :param path: Path チェック対象のHTMLファイルのパス。
113    :param min_text_len: int 本文テキストの最小許容長さ。これより短い場合は失敗と見なされる可能性がある。
114    :returns: tuple[bool, list[str]]
115        `failed` (bool): 失敗の疑いがある場合は `True`、そうでない場合は `False`。
116        `reasons` (list[str]): 失敗と判断された理由のリスト。
117    """
118    html = read_text(path)
119    main_html = extract_main_html(html)
120    main_text = strip_html(main_html)
121
122    reasons = []
123
124    # Sphinx autodocの典型的なAPI要素
125    api_markers = [
126        "py function",
127        "py class",
128        "py method",
129        "py attribute",
130        "py data",
131        "sig-name",
132        "sig-prename",
133        "descname",
134        "descclassname",
135        "dl class=\"py",
136        "dl class='py",
137    ]
138
139    marker_count = sum(1 for s in api_markers if s in html)
140
141    # Python API名らしい signature 行
142    signature_like = re.findall(
143        r"<dt[^>]*class=[\"'][^\"']*sig[^\"']*[\"'][^>]*>",
144        html,
145        flags=re.IGNORECASE,
146    )
147
148    # 失敗ページでよくある「タイトルだけ」の判定
149    has_program_spec_title = "プログラム仕様" in main_text or "API" in main_text
150    text_len = len(main_text)
151
152    if marker_count == 0 and len(signature_like) == 0:
153        reasons.append("no autodoc API markers")
154
155    if text_len < min_text_len:
156        reasons.append(f"main text too short: {text_len} < {min_text_len}")
157
158    # タイトルだけで終わっている感じの補助判定
159    if has_program_spec_title and marker_count == 0 and text_len < 1000:
160        reasons.append("looks like title-only API page")
161
162    failed = len(reasons) > 0
163    return failed, reasons
164
165
166def find_files(root: Path, pattern: str) -> list[Path]:
167    """
168    指定されたルートディレクトリ以下からワイルドカードパターンに一致するファイルを再帰的に検索する。
169
170    `Path.rglob()` を使用して、ルートディレクトリ以下のすべてのファイルを再帰的に探索し、
171    指定されたワイルドカードパターン(例: `*_api.html`)に一致し、かつファイルであるパスを収集します。
172    結果はパスのリストとしてソートされて返されます。
173
174    :param root: Path 検索を開始するルートディレクトリのパス。
175    :param pattern: str 検索するファイルのワイルドカードパターン。
176    :returns: list[Path] パターンに一致したファイルのパスのリスト。
177    """
178    return sorted(p for p in root.rglob(pattern) if p.is_file())
179
180
181def main() -> int:
182    """
183    スクリプトのメインエントリポイント。SphinxのAPIドキュメントの健全性チェックを実行する。
184
185    コマンドライン引数を解析し、指定されたルートディレクトリ内のファイルに対してAPIドキュメントのチェックを行います。
186    失敗したファイルをリストアップし、指定された出力ファイルにそれらのパスを書き込みます。
187
188    :returns: int 正常終了した場合は0、エラーが発生した場合は1。
189    """
190    parser = argparse.ArgumentParser(
191        description="Check Sphinx *_api.html files and list pages where autodoc output looks empty."
192    )
193    parser.add_argument(
194        "--root",
195        type=str,
196        default="./build/html",
197        help="search root directory. default: .",
198    )
199    parser.add_argument(
200        "--files",
201        type=str,
202        default="*_api.html",
203        help='wildcard pattern for target files. default: "*_api.html"',
204    )
205    parser.add_argument(
206        "--outfile",
207        type=str,
208        default="failed_api_html.txt",
209        help="output file path. default: failed_api_html.txt",
210    )
211    parser.add_argument(
212        "--exclude",
213        action="append",
214        default=[],
215        help="exclude path keyword. Can be used multiple times.",
216    )
217    parser.add_argument(
218        "--min-text-len",
219        type=int,
220        default=300,
221        help="minimum main text length. default: 300",
222    )
223    parser.add_argument(
224        "--show-ok",
225        type=int,
226        default=0,
227        choices=[0, 1],
228        help="show OK files too. default: 0",
229    )
230
231    args = parser.parse_args()
232
233    root = Path(args.root).resolve()
234    outfile = Path(args.outfile)
235
236    if not root.exists():
237        print(f"ERROR: root directory not found: {root}")
238        return 1
239
240    files = find_files(root, args.files)
241
242    print(f"root    : {root}")
243    print(f"pattern : {args.files}")
244    print(f"found   : {len(files)} files")
245    print()
246
247    failed_files: list[tuple[Path, list[str]]] = []
248
249    for path in files:
250        rel_str = str(path.relative_to(root)).replace("\\", "/")
251
252        if any(x in rel_str for x in args.exclude):
253            if args.show_ok:
254                print(f"[SKIP]   {rel_str}")
255            continue
256
257        failed, reasons = check_api_html(path, min_text_len=args.min_text_len)
258        rel = path.relative_to(root)
259
260        if failed:
261            failed_files.append((path, reasons))
262            print(f"[FAILED] {rel}")
263            for r in reasons:
264                print(f"         - {r}")
265        elif args.show_ok:
266            print(f"[OK]     {rel}")
267
268    print()
269    print(f"failed  : {len(failed_files)} / {len(files)}")
270
271    with outfile.open("w", encoding="utf-8") as f:
272        for path, reasons in failed_files:
273            try:
274                rel = path.relative_to(root)
275            except ValueError:
276                rel = path
277            f.write(str(rel).replace("\\", "/"))
278            f.write("\n")
279
280    print(f"output  : {outfile.resolve()}")
281
282    return 0
283
284
285if __name__ == "__main__":
286    sys.exit(main())