前回の記事では、VLモデルを用いたOCRによる本文抽出を試しました。
簡易的な用途であれば十分に機能しますが、精度や処理速度には限界があります。
そこで今回は、画像ではなくHTMLそのものに着目し、LLMを使ってDOM構造を解析するアプローチを試します。
LLMにDOM構造を分析させる
ニュースサイトを巡回し、LLMに要約させる使い方は一般的です。巡回自体はPlaywrightなどで実装できますが、RSSだけでは記事冒頭や要約しか取得できず、本文を扱うには個別ページからのテキスト抽出が必要になります。
しかしスクレイピングは、サイトごとのHTML構造の違いや動的レンダリング、JavaScriptによる後読み込みなどに影響を受けやすい手法です。さらに、ニュースアグリゲーション系メディアでは、リンク先に記事本体があるケースも少なくありません。
複数のニュースサイトを横断する場合、サイトごとにDOM構造を特定して対応するのは現実的ではありません。そこで本記事では、DOMをLLMに動的に解析させるアプローチを試します。
ChatGPTによるコード生成
ChatGPTに相談しながらコードを生成しました。ChatGPTを含む複数のLLMに「ニュースサイトで頻出する構造タグ」のリストを参照させ、本文候補を優先的に抽出しつつ、ヘッダやフッタなどのノイズを除外する方針で設計しています。
LLMを前提とした処理はやや試行錯誤が必要でしたが、最終的に整理されたプロンプトは以下の通りです。モデルや使用ライブラリはChatGPTの提案に基づいています。
RSSフィードの記事URLを使って、Webページから記事本文を抽出するための
最適なCSSセレクタを自動探索するPythonスクリプトを書いてください。
## 処理の流れ
1. RSS取得
- feedparserでRSSを取得し、記事のURL・タイトル・要約を得る
- 検証に使う記事数は引数で指定(デフォルト5件)
2. CSSセレクタ候補の生成(シードリストは使わない)
- 最初の記事のHTMLをrequests+BeautifulSoupで取得・解析する
- 以下のルールで各要素をスコアリングし、上位25件をセレクタ候補とする
- テキスト量が多い
- リンク密度が低い(ナビゲーション・フッタの除外)
- <p>タグが多い(記事は段落で構成される)
- DOMの深さが深い(本文は外枠より内側にある)
- 冒頭テキストが一致する親子要素は重複除外する
3. セレクタからCSSセレクタ文字列を生成する優先順位
- ID属性 → itemprop属性 → 特定クラス名(汎用クラスは除外)→ 親要素との組み合わせ
- 汎用クラス例: container, wrapper, inner, outer, wrap, row, col, block, box
4. テキスト抽出
- 各候補セレクタで要素を取得し、script/style/nav/footer/aside/広告系タグを除去してテキスト化
5. LLMによる一括スコアリング(Ollama使用)
- 1記事につき1回のLLM呼び出しで全候補を評価する(呼び出し回数 = 記事数のみ)
- プロンプトに全候補を番号付きで並べ、RSS要約・タイトルとの一致度を1〜10で採点させる
- 回答形式: `0=8` のような 番号=点数 を改行区切り
6. ランキングと出力
- 全記事の平均スコアで降順ソートし、上位10件を表示
- 最高スコアのセレクタを最適解として出力
## 設定値
- Ollama: http://127.0.0.1:11434
- モデル: qwen3-vl:8b
- 使用ライブラリ: feedparser, requests, beautifulsoup4, ollama
## 実行方法
python find_article_selector.py <RSS_URL> [記事数]
長いため、実際のコードは記事の最後に掲載します。
実際の実行例
ITmediaのRSSを読み込ませてみた結果が下記の通りです。
対象の記事のボリュームによってもばらつきますが、RTX4070(VRAM 12GB) CUDAありの環境で、gemma3:4bという軽量のモデルを使っても結果の出力に10分~数十分程度かかりました。
正解と言えるCSSセレクタ(青字)で見つかっていますが、実用には時間がかかり過ぎかもしれません。一度、構造を特定すればサイトリニューアル等までは問題なく動くため一応は可能というところです。
============================================================
結果(上位10件)
============================================================
スコア ヒット セレクタ
------------------------------------------------------------
8.60 5/10 #tmplNewsIn
8.60 5/10 #masterBodyInner
5.60 4/10 #endlinkConnection
5.20 5/10 #masterBodyIn
4.60 4/10 #colBoxRanking > div.colBoxOuter
4.60 4/10 #colBoxRanking > div.colBoxOuter > div.colBoxInner > div.colBoxIndex
4.60 4/10 #colBoxRanking > div.colBoxOuter > div.colBoxInner > div.colBoxIndex > div.colBoxOlist
3.80 5/10 #masterSub
2.00 4/10 #colBoxCalendar > div.colBoxOuter > div.colBox > div.colBoxOuter
2.00 4/10 #colBoxCalendar > div.colBoxOuter > div.colBox > div.colBoxOuter > div.colBoxInner
まとめ
従来のスクレイピングは、サイトごとのHTML構造に依存するため横断的な運用が難しい手法です。本記事では、その代替としてDOM構造そのものをLLMに解析させ、本文らしさを動的に評価するアプローチを試しました。
実行時間は課題として残りますが、一度セレクタを特定できれば実用は可能です。構造を固定的に決め打ちするのではなく、LLMに判断させる設計は、今後のWeb抽出手法の一つの方向性と言えるでしょう。
コード例
必要パッケージを事前にインストールしておきます。
pip install feedparser ollama requests BeautifulSoup
実行にはgemma3:4bが導入済みのOllamaがローカルで起動している必要があります。
対象サイトのRSSのURLを引数で与えれば動作します。
#!/usr/bin/env python3
"""
RSSフィードの記事を使って、Webページから記事本文を抽出する
最適なCSSセレクタを自動探索するツール。
RSSの要約・タイトルとOllama LLMを使い、複数記事でセレクタを評価・ランキングする。
使い方:
python find_article_selector.py <RSS_URL> [記事数(デフォルト5)]
例:
python find_article_selector.py https://example.com/feed 5
"""
import re
import sys
from dataclasses import dataclass, field
import feedparser
import ollama
import requests
from bs4 import BeautifulSoup, Tag
OLLAMA_HOST = "http://127.0.0.1:11434"
MODEL = "gemma3:4b"
@dataclass
class SelectorResult:
selector: str
scores: list[float] = field(default_factory=list)
hit_count: int = 0
@property
def avg_score(self) -> float:
return sum(self.scores) / len(self.scores) if self.scores else 0.0
def fetch_html(url: str) -> str:
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
r = requests.get(url, headers=headers, timeout=15)
r.raise_for_status()
return r.text
def strip_noise(tag: Tag) -> Tag:
"""広告・ナビ・スクリプト等のノイズタグを除去"""
for el in tag.select("script, style, nav, header, footer, aside, iframe, "
".ad, .advertisement, .sns-share, .related, .recommend"):
el.decompose()
return tag
def extract_text(html: str, selector: str) -> str:
soup = BeautifulSoup(html, "html.parser")
el = soup.select_one(selector)
if not el:
return ""
strip_noise(el)
text = el.get_text(separator="\n", strip=True)
return text
def build_selector(tag: Tag) -> str | None:
"""BeautifulSoupのタグからCSSセレクタを生成する"""
if not tag.name or tag.name in ["html", "body", "[document]"]:
return None
# IDがあれば最短・一意
if tag.get("id"):
return f"#{tag['id']}"
# itemprop(構造化データ)
if tag.get("itemprop"):
return f"[itemprop='{tag['itemprop']}']"
classes = tag.get("class", [])
# ノイズになりがちな汎用クラスを除外
noise_classes = {
"container", "wrapper", "inner", "outer", "wrap", "row",
"col", "block", "box", "section", "content", "main", "body",
}
specific = [c for c in classes if c.lower() not in noise_classes and len(c) > 3]
if specific:
# 親との組み合わせで精度を上げる
parent = tag.parent
if parent and parent.name not in ["html", "body", None]:
parent_sel = build_selector(parent)
if parent_sel and not parent_sel.startswith(tag.name):
return f"{parent_sel} > {tag.name}.{specific[0]}"
return f"{tag.name}.{specific[0]}"
return None
def dom_score(tag: Tag) -> float:
"""記事本文らしさをDOMの構造から算出する。
基準:
- テキスト量が多いほど高い
- リンク密度が低いほど高い(ナビ・フッタはリンクだらけ)
- <p> タグが多いほど高い(記事は段落で構成される)
- DOM の深さが深いほど高い(本文は外側の枠より内側にある)
"""
text = tag.get_text(strip=True)
text_len = len(text)
if text_len < 200:
return 0.0
link_len = sum(len(a.get_text(strip=True)) for a in tag.find_all("a"))
link_density = link_len / text_len # 0〜1、低いほど良い
p_count = len(tag.find_all("p"))
depth = len(list(tag.parents)) # bodyからの距離
return text_len * (1 - link_density) * (1 + p_count * 0.2) * (1 + depth * 0.05)
def generate_dom_candidates(html: str, top_n: int = 25) -> list[str]:
"""DOMヒューリスティックで記事本文らしい要素を特定し、セレクタ候補を返す"""
soup = BeautifulSoup(html, "html.parser")
# スコアリング対象:ブロック要素全般
scored: list[tuple[float, Tag]] = []
for tag in soup.find_all(["article", "section", "div", "main", "td"]):
s = dom_score(tag)
if s > 0:
scored.append((s, tag))
# スコア降順でソート
scored.sort(key=lambda x: x[0], reverse=True)
seen_texts: set[str] = set()
candidates: list[str] = []
for _, tag in scored:
# 冒頭100文字が一致する要素(親子の重複)はスキップ
snippet = tag.get_text(strip=True)[:100]
if snippet in seen_texts:
continue
seen_texts.add(snippet)
sel = build_selector(tag)
if sel and sel not in candidates:
candidates.append(sel)
if len(candidates) >= top_n:
break
return candidates
def score_candidates_batch(
candidates: list[tuple[str, str]], # [(selector, extracted_text), ...]
title: str,
summary: str,
client: ollama.Client,
) -> dict[str, float]:
"""全候補を1回のLLM呼び出しで一括スコアリングする"""
# 空テキストの候補は除外
valid = [(sel, text) for sel, text in candidates if text and len(text) >= 80]
scores = {sel: 0.0 for sel, _ in candidates}
if not valid:
return scores
items = "\n\n".join(
f"[{i}] セレクタ: {sel}\n{text[:300]}"
for i, (sel, text) in enumerate(valid)
)
prompt = (
f"記事タイトル: {title}\n"
f"RSS要約: {summary[:300]}\n\n"
"以下の各テキストが記事本文として適切かを1〜10で採点してください。\n"
"10=本文のみ / 7〜9=本文中心・少しノイズ / 4〜6=混在 / 1〜3=メニュー・広告\n"
"回答形式: 番号=点数 を改行区切りで。例: 0=8\n\n"
+ items
)
resp = client.chat(model=MODEL, messages=[{"role": "user", "content": prompt}])
for m in re.finditer(r"(\d+)\s*=\s*(\d+)", resp["message"]["content"]):
idx, score = int(m.group(1)), min(10.0, float(m.group(2)))
if idx < len(valid):
scores[valid[idx][0]] = score
return scores
def find_best_selector(rss_url: str, n_articles: int = 5) -> list[SelectorResult]:
client = ollama.Client(host=OLLAMA_HOST)
feed = feedparser.parse(rss_url)
articles = feed.entries[:n_articles]
if not articles:
print("RSSから記事を取得できませんでした")
return []
print(f"RSS: {feed.feed.get('title', rss_url)}")
print(f"検証記事数: {len(articles)}\n")
# 最初の記事からDOMを分析して候補セレクタを生成
first_html = fetch_html(articles[0].link)
all_selectors = generate_dom_candidates(first_html)
print(f"候補セレクタ数: {len(all_selectors)}\n")
results: dict[str, SelectorResult] = {sel: SelectorResult(sel) for sel in all_selectors}
for i, entry in enumerate(articles):
title = entry.get("title", "")
summary = BeautifulSoup(
entry.get("summary", entry.get("description", "")), "html.parser"
).get_text(strip=True)
print(f"[{i+1}/{len(articles)}] {title[:70]}")
try:
html = first_html if i == 0 else fetch_html(entry.link)
except Exception as e:
print(f" 取得失敗: {e}\n")
continue
# 全候補のテキストを抽出してからLLMに一括送信
candidates = [(sel, extract_text(html, sel)) for sel in all_selectors]
batch_scores = score_candidates_batch(candidates, title, summary, client)
for sel, score in batch_scores.items():
if score > 0:
results[sel].hit_count += 1
results[sel].scores.append(score)
print(f" {sel:50s} {score:4.1f}")
print()
ranked = sorted(results.values(), key=lambda r: r.avg_score, reverse=True)
return ranked
def main():
if len(sys.argv) < 2:
print("使い方: python find_article_selector.py <RSS_URL> [記事数]")
sys.exit(1)
rss_url = sys.argv[1]
n = int(sys.argv[2]) if len(sys.argv) > 2 else 5
ranked = find_best_selector(rss_url, n)
print("=" * 60)
print("結果(上位10件)")
print("=" * 60)
print(f"{'スコア':>6} {'ヒット':>5} セレクタ")
print("-" * 60)
for r in ranked[:10]:
print(f" {r.avg_score:4.2f} {r.hit_count:3}/{len(ranked)} {r.selector}")
if ranked and ranked[0].avg_score > 0:
best = ranked[0]
print(f"\n最適セレクタ: {best.selector}")
print(f"平均スコア: {best.avg_score:.2f} / 10.0")
if __name__ == "__main__":
main()




