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())