LLMでニュース本文の最適CSSセレクタ自動発見を試す【DOM解析×Ollama】

AIテキスト

前回の記事では、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()
タイトルとURLをコピーしました