Qiitaの本文をAIが要約するツール作ってみた(chrome拡張機能とPythonで)

Qiitaの本文を読む際に記事全体で何を言っているのかの要約をしてくれると便利ですよね？私はせっかちなので全部読むのがめんどくさいです．また，全体の記事の中身の流れを知ることで頭に入りやすいなと思います．とりあえずchrome拡張機能でやってみた．

一応完成から見せるとこんな感じです．

![スクリーンショット 2024-06-30 22.07.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3757442/ef10811b-ffbf-e976-4886-7d22541831df.png)

# 仕様
**モデル**
gpt-3.5-turboを使って記事を要約させる．

**動作環境/条件**
ChromeでQiita記事内を開いた際，タイトルや見出しにカーソルを合わせた時
> Qiitaのホーム(https://qiita.com)で動作するように開発しなかったのは単純に記事のインプレが減ってしまうことを避けるためです．

**今回の作品のgithubリポジトリ**

https://github.com/anshibagundan/Qiita_Summary

# ほんとにできるのか試行
まず試しにPythonで標準入力としてURLを叩き，出力で要約を出すようにした．そのコードがこちら．
```py
import requests
from bs4 import BeautifulSoup
from openai import OpenAI


def fetch_webpage_text(url):
    response = requests.get(url)
    if response.status_code != 200:
        return "ウェブページを取得できませんでした。"

    soup = BeautifulSoup(response.text, 'html.parser')

    all_wrapper = soup.find('div', class_='allWrapper')
    if not all_wrapper:
        return "allWrapperクラスが見つかりません。"

    main_wrapper = all_wrapper.find('div', class_='mainWrapper')
    if not main_wrapper:
        return "mainWrapperクラスが見つかりません。"

    personal_article = main_wrapper.find('div', id=lambda x: x and x.startswith('PersonalArticlePage'))
    if not personal_article:
        return "PersonalArticlePageで始まるIDのdivが見つかりません。"

    p_items_main = personal_article.find('div', class_='p-items_main')
    if not p_items_main:
        return "p-items_mainクラスのdivが見つかりません。"

    paragraphs = p_items_main.find_all('p')
    text_content = ' '.join([paragraph.get_text() for paragraph in paragraphs])
    return text_content


def summarize_text(text):
    client = OpenAI(api_key='OpenAIのAPI')

    prompt = f"以下の文章を要約してください：\n\n{text}"
    response = client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "system", "content": "あなたは文章を要約する便利なアシスタントです。日本語で要約を提供してください。"},
            {"role": "user", "content": prompt}
        ],
        max_tokens=300
    )
    return response.choices[0].message.content.strip()


def main():
    url = input("URLを入力してください\n")
    print("ページを取得しています...")
    text = fetch_webpage_text(url)
    print(text)
    print("テキストを要約しています...")

    summary = summarize_text(text)
    print("要約:")
    print(summary)


if __name__ == "__main__":
    main()

```

試しに入力値として[自分の記事](https://qiita.com/tarakokko3233/items/711170f6c83ffb08a1e4)を叩いてみた．
```md:入力例
URLを入力してください
https://qiita.com/tarakokko3233/items/711170f6c83ffb08a1e4
```

すると，本文として以下の文を抽出できた．
```md:出力結果
ページを取得しています...
Webアプリを作る際よくこの2つをみないだろうか？
 どちらもコンポーネントベースアーキテクチャを採用している． 例えばWebアプリを作る際に1つのwebページごとに毎回 HTML/CSS/JS（TS）を書くのは勿体無い． そもそもコンポーネントベースしか知らない人もいるかもしれないが，全部生のHTMLで各ページごとに全体タイトルからサブタイトル，本文まで同じものを毎度毎度作るということである．  これら全てページごとにテンプレートを書くのではなく，
共通コンポーネントとして を作成し，
sam1ページでは， を作成し，
sam2ページでは， このように作成する．
これにより記述する量がへり，簡単にWebページを作成できる．
まずはReactとVue.jsの書き方を見てコンポーネントベースアーキテクチャを見てみよう． プロジェクトがあることを前提とする． デフォルトでこうなっていると仮定 最後はこのような形になると仮定して進める．

 まず，共通コンポーネントとして
1.メインタイトルの作成 2.広告欄の作成 3.サブタイトルの雛形作成 1./sam1ページの作成 2./sam2ページの作成 ここで各ページ（Sam1Page.js,Sam2Page.js）のメインとなるコンポーネントを表すJSを指定する． このコマンドで実行する． Javascriptはブラウザで動く言語であるのにサーバーを立ち上げられたり，できる理由を知りたい方はこちらの記事で
https://qiita.com/tarakokko3233/items/79660fe37930952ae319 Vue.jsのプロジェクトがあることを前提とする． Vueのインストール Vueプロジェクトの作成 必要なパッケージのインストール このような構造であることを前提とする 1.メインタイトルの作成 2.広告欄の作成 3.サブタイトルの雛形作成 1./sam1ページの作成 2./sam2ページの作成 ここで各ページ（Sam1Page.vue,Sam2Page.vue）のメインとなるコンポーネントを表すJSを指定する． このコマンドで実行する． コンポーネントの考え方は，現代のフロントエンド開発において中核を成す概念である．ReactやVue.jsなどのフレームワークを通じて，この考え方の重要性と利点が明確になっている．私なりにどのようなことを目的に考えているのかいくつかあげてみた． コンポーネントは，ユーザーインターフェイス（UI）を独立した，再利用可能な部品に分割する方法である．これにより複雑なアプリケーションを管理しやすい小さな単位(コンポーネント)に分解できる．例えば，ボタン，フォーム，ナビゲーションバーなどのUI要素を個別のコンポーネントとして作成し，アプリケーション全体で繰り返し使用することができる．これにより，コードの重複を減らし，一貫性のあるUIを維持しやすくなる．
 コンポーネントは，その機能に必要なすべての要素（マークアップ，ロジック，スタイル）を一つのユニットにカプセル化する．この特性により，コードの管理が容易になり，他の部分に影響を与えることなく個々のコンポーネントを変更したり改善したりできる．カプセル化は，コンポーネント間の依存関係を最小限に抑え，アプリケーションの保守性を高める．
 コンポーネントは，データの流れと状態管理を明確にする．親コンポーネントから子コンポーネントへのデータの受け渡し（プロップス）や，コンポーネント内部での状態管理の仕組みが整理される．この明確なデータフローにより，アプリケーション全体の状態変化を追跡しやすくなり，デバッグや機能拡張が容易になる．また，単方向データフローを採用することで，予測可能性が高まり，複雑なアプリケーションでも状態の一貫性を保ちやすくなる．
 各コンポーネントは，特定の機能や表示に責任を持つべきである．これは，ソフトウェア設計の「単一責任の原則」に沿っている．コンポーネントが一つの明確な役割を持つことで，コードの保守性と拡張性が向上する．例えば，ユーザー認証を扱うコンポーネントは認証のロジックのみを含み，UIの表示は別のコンポーネントに任せるといった具合である．このアプローチにより，各コンポーネントの機能が明確になり，必要に応じて容易に修正や置換が可能になる．
 コンポーネントは階層構造を形成し，より小さなコンポーネントを組み合わせてより大きなコンポーネントを作成できる．この「コンポジション」により，複雑なUIを構築する際の柔軟性が高まる．例えば，「ボタン」コンポーネントと「入力フィールド」コンポーネントを組み合わせて「検索バー」コンポーネントを作成し，さらにそれを「ヘッダー」コンポーネントの一部として使用するといった具合である．この階層構造により，UIの構成を論理的に整理でき，開発者間でのコードの理解と共有が簡単になる．
 個々のコンポーネントは独立しているため，単体テストが容易になる．各コンポーネントに対して専用のテストを書くことで，アプリケーション全体の品質と信頼性を向上させることができる．また，コンポーネントのプロップスや状態を操作してさまざまな条件下でのテストが可能になり，エッジケースの検出も容易になる．さらに，モックやスタブを使用して，コンポーネントの依存関係を分離したテストも実施しやすくなる．
 コンポーネントベースのアプローチでは，必要な部分だけを更新することが可能になる．これにより，アプリケーション全体のパフォーマンスを向上させることができる．例えば，大規模なリストの一部だけが変更された場合，そのリスト全体ではなく変更されたアイテムのコンポーネントのみを再描画することができる．また，コンポーネントの純粋性（同じ入力に対して常に同じ出力を返す性質）を保つことで，不要な再描画を防ぎ，メモ化などの最適化技術も適用しやすくなる．
 コンポーネントベースの開発により，チームメンバーが並行して異なるコンポーネントを開発することができる．これにより，開発プロセスが効率化され，大規模プロジェクトの管理が容易になる．各開発者が担当するコンポーネントの責任範囲が明確になるため，コードの競合も減少する．また，コンポーネントライブラリを作成することで，チーム内での知識の共有や再利用可能なコードの蓄積が促進される．
 ReactやVue.jsでは以上のメリットがあってコンポーネントベースがよく使われるのかなと思う．ただし，コンポーネントベースが全ていいわけではなく，デメリットも存在することもある．(例えば学習コストの高さなど)しかし開発者，ユーザーの目線では使いやすい開発手法なのかなとは思うのでぜひ使ってみてほしい．
```
BeautifulSoupというhtml parserでhtmlをスクレイピングするようにして本文を抽出した．
Qiitaでは`allWrapperクラス`の中の`mainWrapperクラス`の中の`PersonalArticlePageで始まるID`の中の`p-items_mainクラス`に本文が存在した．その中の`pタグ`にある文字を抽出した．

> ここのせいでQiita専用要約ツールになってしまった．なんとか他のサイトに転用できる良い実装方法はないものか

改行とかはめちゃくちゃだが，まあ大丈夫だろう...

```md:要約結果
テキストを要約しています...
要約:
Webアプリを作る際には、コンポーネントベースのアーキテクチャを採用することが重要です。例えば、共通のコンポーネントを作成し、それを各ページで再利用することでコードの記述量を減らし、簡単にWebページを作成することができます。コンポーネントベースのアプローチは、UIを独立した部品に分割し、状態管理やテストが容易になるなど多くのメリットがあります。ReactやVue.jsなどのフレームワークを通じて、コンポーネントベースの開発手法が普及しています。デメリットもあるものの、使いやすさや効率性から考えると、コンポーネントベースのアーキテクチャは価値があると言えます。
```
上記の出力内容を`gpt-3.5-turbo`の入力内容として叩き，`あなたは文章を要約する便利なアシスタントです。日本語で要約を提供してください。`と聞いた結果，返ってきた結果がこれである．
まあまああってるような気がする．

# 実際にChrome拡張機能として実装してみる
拡張機能の名前と動作するページと要約対象となるページを記している．
```manifest.json
{
  "manifest_version": 3,
  "name": "Qiita記事要約ツール",
  "version": "1.0",
  "permissions": [
    "activeTab"
  ],
  "host_permissions": [
    "https://qiita.com/*"
  ],
  "content_scripts": [
    {
      "matches": ["https://qiita.com/*/items/*"],
      "js": ["content.js"]
    }
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html"
  }
}

```
マウスカーソルが記事内のタイトルや見出しの上にあった場合要約を生成する対象の記事のURLを取り出している．本文の探し方はPythonの時と同じである．同じ記事は重複して要約をしないように60秒のタイムアウトを設ける．
> ここに関しては60秒とかではなく他のアプローチがあった気がする
```content.js
let tooltip;
const displayedUrls = new Set();
const displayTimeout = 60000; // 60秒

function showTooltip(element, text) {
  if (tooltip) {
    document.body.removeChild(tooltip);
  }

  tooltip = document.createElement('div');
  tooltip.textContent = text;
  tooltip.style.position = 'absolute';
  tooltip.style.backgroundColor = 'white';
  tooltip.style.border = '1px solid black';
  tooltip.style.padding = '10px';
  tooltip.style.zIndex = '1000';
  tooltip.style.maxWidth = '300px';

  let rect = element.getBoundingClientRect();
  tooltip.style.top = `${rect.bottom + window.scrollY}px`;
  tooltip.style.left = `${rect.left + window.scrollX}px`;

  document.body.appendChild(tooltip);

  element.addEventListener('mouseout', function handleMouseOut() {
    if (tooltip) {
      document.body.removeChild(tooltip);
      tooltip = null;
    }
    element.removeEventListener('mouseout', handleMouseOut);
  });
}

document.addEventListener('mouseover', function(event) {
  if ((event.target.tagName === 'H1' || event.target.tagName === 'H2') && !displayedUrls.has(window.location.href)) {
    let url = window.location.href;

    displayedUrls.add(url);
    setTimeout(() => {
      displayedUrls.delete(url);
    }, displayTimeout);

    fetch(url)
      .then(response => response.text())
      .then(html => {
        const parser = new DOMParser();
        const doc = parser.parseFromString(html, 'text/html');

        const allWrapper = doc.querySelector('.allWrapper');
        if (!allWrapper) return;

        const mainWrapper = allWrapper.querySelector('.mainWrapper');
        if (!mainWrapper) return;

        const personalArticle = mainWrapper.querySelector('[id^="PersonalArticlePage"]');
        if (!personalArticle) return;

        const pItemsMain = personalArticle.querySelector('.p-items_main');
        if (!pItemsMain) return;

        const paragraphs = pItemsMain.querySelectorAll('p');
        const textContent = Array.from(paragraphs).map(p => p.textContent).join(' ');

        chrome.runtime.sendMessage({action: "summarize", text: textContent}, function(response) {
          if (response.summary) {
            showTooltip(event.target, response.summary);
          }
        });
      });
  }
});


```
ここで`gpt-3,5-turbo`とやりとりしている．
```background.js
console.log("Background script loaded");

chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
  console.log("Message received in background:", request);

  if (request.action === "summarize") {
    summarizeText(request.text)
      .then(summary => {
        console.log("Summary generated:", summary);
        sendResponse({summary: summary});
      })
      .catch(error => {
        console.error('Error:', error);
        sendResponse({summary: "要約の取得に失敗しました。"});
      });
    return true;  
  }
});

async function summarizeText(text) {
  const apiKey = 'OpenAIのAPIキー';
  try {
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        model: "gpt-3.5-turbo",
        messages: [
          {role: "system", content: "あなたは文章を要約する便利なアシスタントです。日本語で要約を提供してください。"},
          {role: "user", content: `以下の文章を要約してください：\n\n${text}`}
        ],
        max_tokens: 300
      })
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const data = await response.json();
    return data.choices[0].message.content.trim();
  } catch (error) {
    console.error('OpenAI API error:', error);
    return "テキストの要約中にエラーが発生しました。";
  }
}

```
ポップアップの基本形の定義をする．
```popup.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>記事要約ツール</title>
  <style>
    body {
      font-family: 'Segoe UI', 'Meiryo', sans-serif;
      width: 300px;
      padding: 10px;
    }
    h1 {
      font-size: 18px;
      color: #333;
    }
    p {
      font-size: 14px;
      color: #666;
    }
  </style>
</head>
<body>
  <h1>記事要約ツール</h1>
  <p>記事のタイトルにカーソルを合わせると要約が表示されます。</p>
</body>
</html>

```
これをChromeで読み込んで使用した結果が以下の通りである．
![スクリーンショット 2024-06-30 22.07.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3757442/7edeac64-8b17-d3d2-b646-51f479675afb.png)
このようにカーソルを合わせると要約をしてくれる．
これで本文を読む前になんとなくのノリがわかるようになる．これで技術ブログ速読マスターだ．(?)
> ポップアップが小さくてみにくかったりするので直します