43号線を西へ東へ

フリーランスの備忘録、アウトプットの実験場

ブログ内容を一文でまとめて、ワードクラウドにするプログラムを作った

このブログはプロモーションリンクを含むときがあります


ブログ記事の内容を振り返るとき、「この記事って、結局何が言いたかったんだっけ?」と思うことはありませんか?

そんな悩みを解消するために、URLを入力するだけで、記事の主題を一文で要約し、キーワードをワードクラウドで視覚化できるツールを作成しました。

かなり昔にも同じようなことをした記憶がありますが、この春からメインPCをMacに変えたので、こちらでも形態素解析が出来るように環境を作りました。

プログラムは素人で全部AIに書いてもらっているので、達人からみればツッコミどころが多いと思いますが、生暖かく見守っていただけると幸いです。

このツールでできること

  1. ブログ記事のURLを入力する
  2. スクレイピングの実施、div id="container" 内の本文を自動抽出(不要部分を除外)
  3. ChatGPTで抽象的一文要約
  4. Sumyで重要文3つの抽出型要約
  5. MeCab連続名詞を含めた語彙抽出 → ワードクラウド作成

最初のバージョン0.1

バージョン0.3

バージョン0.9

バージョン0.9

処理の概要

1. 本文抽出:本文以外のノイズ除去

HTMLの構造をみると、記事の本文は div id="container" 内にある <p> タグから取得します。
ただし、以下のようなテンプレート的要素(フッターや広告枠など)は除外:

  • <div class="entry-header-html">
  • <div class="embed-group-content">

2. ChatGPTで抽象的一文要約

OpenAIのGPT-4 APIを活用し、以下のようなプロンプトを送っています:

「以下の日本語テキストの主題を、自然で完結した一文で要約してください。」

その結果、文章の背景や意図をくみ取った的を得た一文要約が得られます。

3. Sumyでの抽出的要約(3文)

Sumyライブラリ(LSA方式)を使用し、文章中から意味的に重要な3文を抽出します。

最初はSumyだけで要約を作成しようとしましたが、重要っぽい文章を抜粋するだけで、要約が出来なかったために、ChatGPTのAPIを用いて要約させました。

4. MeCab + WordCloudでの視覚化

形態素解析ツール「MeCab」を活用して、出現頻度の高い語句を抽出し、WordCloudで可視化します。

ここでの工夫ポイント:

  • 連続する名詞(例:「姿勢」+「改善」→「姿勢改善」)を1語として扱う
  • 意味の乏しい語を除外
    たとえば、「こと」「もの」「そう」など、ひらがなの代名詞や副詞のような曖昧語は、単独で取り出すと意味が薄く、話題の把握を妨げるため除外しています。

形態素解析とは

形態素解析とは、簡単に言うと文章を品詞ごとに分ける作業のこと。

表層形 読み 原形 品詞 品詞細分類1 品詞細分類2
形態素 ケイタイソ 形態素 名詞 一般 *
解析 カイセ 解析 名詞 サ変接続 *
助詞 格助詞 引用
助詞 係助詞 *
記号 読点 *
簡単 カンタン 簡単 名詞 形容動詞語幹 *
助詞 副詞化 *
言う イウ 言う 動詞 自立 五段・ワ行促音便
助詞 接続助詞 *
文章 ブンショウ 文章 名詞 一般 *
助詞 格助詞 一般
品詞 ヒンシ 品詞 名詞 一般 *
ごと ゴト ごと 名詞 接尾 一般
助詞 格助詞 一般
分ける ワケル 分ける 動詞 自立 一段
作業 サギョウ 作業 名詞 サ変接続 *
助詞 連体化 *
こと コト こと 名詞 非自立 一般
記号 句点 *

品詞ごとに分けることで、ワードクラウドが作成可能です。

🖥 実行例(出力イメージ)

URL:  [https://driveon43.com/entry/2025/07/24/162151]


🧠 ChatGPT一文要約:
落合陽一氏監修のシグネチャーパビリオンnull2の抽選に当選し、大阪・関西万博での目標がほぼ達成されたという内容です。

📄 Sumyによる重要3文:
1. この万博で一番予約が取りにくいのはここか、サウナか、と私の周りでは言われています。
2. (一般的にはイタリア館かもしれませんね) この度、落合陽一氏監修のシグネチャーパビリオンnull2の抽選に当選しました!
3. 大阪・関西万博が終わるまでに、体験できそうでよかったです。

🌈 WordCloud:
→ 万博、落合陽一氏監修、null2などが大きく表示される

バージョン1.1

ひらがなを除外する理由

ワードクラウドでは、頻出語を視覚的に見せますが、そのまま使うと「こと」「もの」「そう」などが大きく表示されてしまい、本質的なキーワードが埋もれてしまうことがあります。

これらの語は、品詞的には「非自立名詞」「代名詞」「副詞」などに分類され、文法的役割はあるが意味が曖昧で文脈依存なため、実質的なキーワードとは見なしにくいのです。

使用ライブラリ一覧とコード

今回使用したライブラリは以下の通りです。

処理内容 使用ライブラリ
本文抽出 BeautifulSoup
抽象的要約 OpenAI (gpt-4)
抽出的要約 sumy + tinysegmenter
形態素解析 MeCab
視覚化 wordcloud + matplotlib

▶コード(クリックで展開)

from bs4 import BeautifulSoup
from sumy.parsers.plaintext import PlaintextParser
from sumy.nlp.tokenizers import Tokenizer as SumyTokenizer
from sumy.summarizers.lsa import LsaSummarizer
from wordcloud import WordCloud
from PIL import Image, ImageDraw, ImageFont
import matplotlib.pyplot as plt
from datetime import datetime
import requests
import MeCab
import re
import os

# ------------------------------
# OpenAIクライアントの初期化
# ※ pip install openai が必要です
# ------------------------------
from openai import OpenAI
client = OpenAI(api_key="sk-*********")  # ←あなたのAPIキーをここに入力


# ------------------------------
# MeCabの初期化(環境に応じて修正)
# ------------------------------
MECABRC_PATH = "/opt/homebrew/etc/mecabrc"
tagger = MeCab.Tagger(f"-r {MECABRC_PATH}")
tagger.parse("")


# ------------------------------
# ブログ記事の本文とタイトルを抽出
# ------------------------------
def extract_article_text_and_title(url):
    try:
        res = requests.get(url, timeout=10)
        res.encoding = res.apparent_encoding
    except Exception as e:
        print(f"リクエストエラー: {e}")
        return "", ""

    soup = BeautifulSoup(res.text, "html.parser")
    container = soup.find("div", id="container")
    if not container:
        return "", ""

    # ✅ 不要要素を削除(対象クラスを持つタグをまるごと消す)
    for cls in ["entry-header-html", "embed-group-content", "embed-group"]:
        for tag in container.find_all(class_=cls):
            tag.decompose()  # 完全に削除

    # ✅ <p> タグの中から本文だけを抽出
    paragraphs = []
    for p in container.find_all("p"):
        text = p.get_text(strip=True)
        if len(text) > 20:
            paragraphs.append(text)

    # ✅ タイトルの取得
    title_tag = soup.find("title")
    title = title_tag.get_text(strip=True) if title_tag else "ブログ記事"

    return "\n".join(paragraphs), title


# ------------------------------
# ChatGPTによる抽象的な一文要約
# ------------------------------
def summarize_with_gpt(text):
    try:
        response = client.chat.completions.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": "以下の日本語テキストの主題を、自然で完結した一文で要約してください。"},
                {"role": "user", "content": text}
            ],
            temperature=0.3,
            max_tokens=300
        )
        return response.choices[0].message.content.strip()
    except Exception as e:
        return f"[GPT要約エラー]: {e}"


# ------------------------------
# Sumyによる重要文抽出(3文)
# ------------------------------
def summarize_with_sumy(text, num_sentences=3):
    formatted = "\n".join(s for s in text.replace("。", "。\n").splitlines() if len(s.strip()) > 10)
    parser = PlaintextParser.from_string(formatted, SumyTokenizer("japanese"))
    summarizer = LsaSummarizer()
    return [str(s) for s in summarizer(parser.document, num_sentences)]


# ------------------------------
# WordCloud生成 + タイトル追加
# ------------------------------
def generate_wordcloud(text, blog_title, font_path="/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc"):
    """
    ブログ本文テキストからワードクラウドを生成し、上部にタイトルを埋め込んだ画像を保存・表示します。
    保存先: ./wordcloud_images/
    ファイル名形式: WordCloud_ブログタイトル_タイムスタンプ.png
    """
    node = tagger.parseToNode(text)
    words = []
    buffer = []

    def is_valid_word(word):
        return not re.fullmatch(r'[ぁ-ん]{1,2}', word)

    while node:
        features = node.feature.split(",")
        surface = node.surface
        if features[0] == "名詞":
            buffer.append(surface)
        else:
            if buffer:
                compound = "".join(buffer)
                if is_valid_word(compound):
                    words.append(compound)
                buffer = []
        node = node.next

    if buffer:
        compound = "".join(buffer)
        if is_valid_word(compound):
            words.append(compound)

    text_for_wc = " ".join(words)

    # ✅ ワードクラウドを生成
    wc = WordCloud(
        font_path=font_path,
        width=800,
        height=400,
        background_color="white"
    ).generate(text_for_wc)

    # ✅ 一時ファイルとして保存
    raw_path = "temp_wordcloud.png"
    wc.to_file(raw_path)

    # ✅ 上部に余白を追加してタイトルを描画
    wc_img = Image.open(raw_path)
    w, h = wc_img.size
    padding_top = 60
    new_img = Image.new("RGB", (w, h + padding_top), "white")
    new_img.paste(wc_img, (0, padding_top))

    draw = ImageDraw.Draw(new_img)
    try:
        font = ImageFont.truetype(font_path, 32)
    except:
        font = ImageFont.load_default()

    # タイトルの位置を中央に配置
    bbox = draw.textbbox((0, 0), blog_title, font=font)
    tw = bbox[2] - bbox[0]
    th = bbox[3] - bbox[1]
    draw.text(((w - tw) // 2, (padding_top - th) // 2), blog_title, font=font, fill="black")

    # ✅ ファイル名を生成(記号除去+日時付加)
    safe_title = re.sub(r'[\\/*?:"<>|]', "", blog_title)[:30]
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    os.makedirs("wordcloud_images", exist_ok=True)
    final_path = f"wordcloud_images/WordCloud_{safe_title}_{timestamp}.png"

    new_img.save(final_path)

    # ✅ 画像表示
    plt.figure(figsize=(10, 6))
    plt.imshow(new_img)
    plt.axis("off")
    plt.tight_layout()
    plt.show()

    print(f"✅ タイトル付きWordCloud画像を保存しました: {final_path}")


# ------------------------------
# メイン処理(実行フロー)
# ------------------------------
def main():
    url = input("🔗 ブログ記事のURLを入力してください:\nURL: ").strip()
    if not url:
        print("❌ URLが入力されていません。")
        return

    print("\n⏳ 本文とタイトルを抽出中...")
    article_text, title = extract_article_text_and_title(url)
    if not article_text or len(article_text) < 100:
        print("❌ 本文が取得できませんでした。")
        return

    print("\n🧠 ChatGPTによる一文要約(抽象的):")
    print(summarize_with_gpt(article_text))

    print("\n📄 Sumyによる重要文抽出(3文):")
    for i, s in enumerate(summarize_with_sumy(article_text), 1):
        print(f"{i}. {s}")

    print("\n🌈 WordCloudを生成しています...")
    generate_wordcloud(article_text, title)


# スクリプト実行
if __name__ == "__main__":
    main()


まとめ

このツールを使えば、

  • 要点が一文で把握できる
  • 重要な文を読み直せる
  • キーワードが視覚的にわかる

という三拍子揃った「読解・整理・理解のための支援」が可能になります。

とはいえ、自分のブログに特化したスクレイピングなので、HTMLの構造によっては上手く読み取れないブログもありますが、今のところは自分のブログの振り返りに使うので、これで良しとします。