実装設計の方針
- URL を受け取り、
InputEntityのリストを返す関数extract_static(url)を実装する - 除外する type(submit / button / reset)はリストで管理して拡張可能にする
- label の紐付けは
for→ラップ→aria-labelの優先順位で探索する - エラー処理は呼び出し元に任せ、関数内は
raise_for_status()のみ
static.py の実装
"""static.py — requests + BeautifulSoup による静的ページ INPUT 抽出"""
from __future__ import annotations
import requests
from bs4 import BeautifulSoup, Tag
from typing import Optional
from .models import InputEntity
# 除外する input type
SKIP_TYPES = {"submit", "button", "reset", "image"}
# BeautifulSoup パーサー(lxml → html.parser にフォールバック)
try:
import lxml # noqa
_PARSER = "lxml"
except ImportError:
_PARSER = "html.parser"
def _get_label(soup: BeautifulSoup, tag: Tag) -> Optional[str]:
"""INPUT 要素に対応する label テキストを探索する"""
# 優先度 1: for 属性と id の対応
tag_id = tag.get("id")
if tag_id:
label = soup.find("label", attrs={"for": tag_id})
if label:
return label.get_text(strip=True)
# 優先度 2: label タグでラップされている
parent = tag.parent
while parent:
if parent.name == "label":
# label 内テキストから INPUT のテキストを除いたものを返す
texts = [t.strip() for t in parent.strings if t.strip()]
return " ".join(texts) if texts else None
parent = parent.parent
# 優先度 3: aria-label 属性
aria = tag.get("aria-label")
if aria:
return aria.strip()
return None
def _extract_tag(soup: BeautifulSoup, tag: Tag, url: str) -> Optional[InputEntity]:
"""タグ 1 件を InputEntity に変換する(除外対象は None を返す)"""
tag_name = tag.name.lower()
if tag_name == "input":
input_type = (tag.get("type") or "text").lower()
if input_type in SKIP_TYPES:
return None
return InputEntity(
tag=tag_name,
type=input_type,
name=tag.get("name"),
id=tag.get("id"),
label=_get_label(soup, tag),
placeholder=tag.get("placeholder"),
required="required" in tag.attrs or tag.get("required") is not None,
maxlength=tag.get("maxlength"),
minlength=tag.get("minlength"),
min=tag.get("min"),
max=tag.get("max"),
pattern=tag.get("pattern"),
value=tag.get("value"),
page_url=url,
)
elif tag_name == "select":
options = [
opt.get_text(strip=True)
for opt in tag.find_all("option")
if opt.get("value") != "" # 空の「選択してください」等は除く
]
return InputEntity(
tag=tag_name,
type="select",
name=tag.get("name"),
id=tag.get("id"),
label=_get_label(soup, tag),
required="required" in tag.attrs or tag.get("required") is not None,
options=options,
page_url=url,
)
elif tag_name == "textarea":
return InputEntity(
tag=tag_name,
type="textarea",
name=tag.get("name"),
id=tag.get("id"),
label=_get_label(soup, tag),
placeholder=tag.get("placeholder"),
required="required" in tag.attrs or tag.get("required") is not None,
maxlength=tag.get("maxlength"),
page_url=url,
)
return None
def extract_static(url: str, timeout: int = 10) -> list[InputEntity]:
"""
静的ページから INPUT / SELECT / TEXTAREA 要素を抽出して InputEntity リストを返す。
Args:
url: 抽出対象ページの URL
timeout: requests タイムアウト秒数
Returns:
InputEntity のリスト
"""
response = requests.get(url, timeout=timeout)
response.encoding = response.apparent_encoding
response.raise_for_status()
soup = BeautifulSoup(response.content, _PARSER)
results: list[InputEntity] = []
for tag in soup.find_all(["input", "select", "textarea"]):
entity = _extract_tag(soup, tag, url)
if entity is not None:
results.append(entity)
return results
label 紐付けロジック詳解
_get_label() は 3 段階の優先順位で label テキストを探す。
それぞれのパターンが実際の HTML でどう見えるかを確認しておこう。
# 優先度 1: <label for="username"> ... </label> + <input id="username">
label = soup.find("label", attrs={"for": tag_id})
# 優先度 2: <label>ユーザー名<input ...></label>
# parent を遡って label タグを探す
while parent:
if parent.name == "label": ...
parent = parent.parent
# 優先度 3: <input aria-label="検索ワード">
aria = tag.get("aria-label")
select / textarea の処理
📌 select の option 取得
<option value="">選択してください</option> のような空値の選択肢は除外している。実際の選択可能な値だけを options フィールドに詰める。
JSON 出力
import json
import dataclasses
from pathlib import Path
def save_json(entities: list, output_path: str) -> None:
"""InputEntity リストを JSON ファイルに保存する"""
data = [dataclasses.asdict(e) for e in entities]
Path(output_path).write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8"
)
print(f"保存完了: {output_path} ({len(data)} 件)")
動作確認
from extractor.static import extract_static
from extractor.utils import save_json # 上記 save_json をutils.pyに配置
url = "https://httpbin.org/forms/post" # テスト用フォームページ
entities = extract_static(url)
print(f"抽出件数: {len(entities)} 件")
for e in entities:
print(f" [{e.type:10}] name={e.name!r:20} label={e.label!r}")
save_json(entities, "output/result.json")
実行結果例
抽出件数: 6 件 [text ] name='custname' label='Customer name' [tel ] name='custtel' label='Telephone' [email ] name='custemail' label='E-mail address' [select ] name='size' label='Pizza Size' [checkbox ] name='topping' label=None [textarea ] name='comments' label='Any comments?'
✅ 次の章では…
PART 07 では JavaScript で描画される動的ページを Playwright で処理する実装を解説します。静的版との差分を中心に説明します。