Qiitaの類似記事を見つける（TF-IDF+コサイン類似度）

# 概要
Qiitaの記事に類似する他の記事を検索するプログラムを作成します。
Qiita APIから記事を取得し、TF-IDFとコサイン類似度を用いて、指定された記事との類似度を計算し、類似している記事を表示します。

※ 本記事は、個人の趣味の記事です。自然言語処理やデータ分析の素人なので、多少の間違いはご容赦ください。

# 流れ
1. Qiita APIを使って、検索対象となる記事群を取得します。
1. MeCabを使って、記事の本文を分かち書きします。その結果は、SQLiteのDBに保存します。
1. Qiita APIを使って、同様に、類似記事を探したい記事を取得し、MeCabを使って、分かち書きをします。
1. scikit-learnを利用して、それぞれの記事のTF-IDFとコサイン類似度を計算し、類似度の大きな記事を表示します。

# 利用しているもの
本記事では、以下の手法やライブラリ、サービスを利用しています。
- [TF-IDF](https://qiita.com/tags/tf-idf)
- [scikit-learn](https://qiita.com/tags/scikit-learn)
- [コサイン類似度](https://qiita.com/tags/%e3%82%b3%e3%82%b5%e3%82%a4%e3%83%b3%e9%a1%9e%e4%bc%bc%e5%ba%a6)
- [MeCab](https://qiita.com/tags/mecab)
- [Google Colaboratory](https://qiita.com/tags/googlecolaboratory)
- [Qiita API](https://qiita.com/tags/qiitaapi)
- [BeautifulSoup](https://qiita.com/tags/beautifulsoup)
- [SQLite](https://qiita.com/tags/sqlite3)

今回のメインの1つとなるTF-IDFについては、以下の記事で詳しく解説されているので、そちらをご参照ください。

https://qiita.com/g-k/items/2a5c2334ad2c4a8315ac

コサイン類似度については、下記の記事が参考になりました。

https://tex2e.github.io/blog/python/docs-similarity

# 環境
- Google Colaboratory（Google Colab）

作成環境としては、Google Colabを利用して、Webブラウザ上で作業しました。

https://colab.research.google.com/notebooks/welcome.ipynb?hl=ja

# 実装
ここからは、実際のコードについて紹介していきます。
## MeCabの準備
```python
!apt update
!apt install fonts-ipafont fonts-ipaexfont
!pip install mecab-python3 unidic-lite

## MeCabが利用できるかの確認
import MeCab
print(MeCab.Tagger().parse("すもももももももものうち"))
```

[mecab-python3](https://github.com/SamuraiT/mecab-python3)のREADMEに従って、MeCabのPythonライブラリと辞書、日本語フォントをインストールしました。
MeCabは、取得した記事を単語に分割する「分かち書き」に利用します。

## Googleドライブのマウント
```python
# Googleドライブをマウント
from google.colab import drive
drive.mount('/content/drive')
```

SQLiteのDBファイルは、Googleドライブ上に保存します。Google Colabから利用するためにマウントします。
詳細は、以下の記事に解説されていたので、そちらをご参照ください。

https://qiita.com/kado_u/items/45b76f9a6f920bf0f786

## 取得した記事の分かち書き
```python
import MeCab

# 引数で与えられた文章を分かち書きしたテキストを返す
def mecab_analysis(text):
    tagger = MeCab.Tagger()

    node = tagger.parseToNode(text)
    output_text = ''
    while(node):
        if node.surface != "":
            word_type = node.feature.split(",")[0]
            # あらかじめ一部の品詞の単語を除外しておく
            if word_type not in ["助詞","助動詞","副詞","連体詞","記号"]:
                output_text += node.surface + " "
        node = node.next

    return output_text
```

- MeCabを使って、引数で与えられた記事文章の分かち書きを行う関数です。
- TF-IDFを利用するので、類似度の算出の際に日本語に頻出する単語の影響は少ないかもしれませんが、データ量の削減の観点で、予め一部の品詞を省いておきます。

## 記事取得とSQLiteへの保存
```python
import urllib.request
import json
import sqlite3
from bs4 import BeautifulSoup

# QiitaAPIを利用して、記事を取得する
# DBのパスが与えられた場合には、SQLiteに取得データを保存する
def get_articles(query = None, token = None, db_path = None):
    ret = {
        "id_list": [],
        "url_list": [],
        "separated_word_list": [],
        "title_list": []
    }
    page_num = 0

    while page_num < 100:
        page_num += 1
        # クエリパラメータの準備
        params = {
            'page': str(page_num),
            'per_page': '100'
        }
        if (query is not None):
            params['query'] = query
        # アクセストークンが指定された場合に付与
        req_headers = {}
        if (token is not None):
            req_headers = {
                'Authorization': 'Bearer ' + token
            }

        url = "https://qiita.com/api/v2/items?" + urllib.parse.urlencode(params)
        # Qiita APIから結果を取得
        req = urllib.request.Request(url, headers=req_headers)
        with urllib.request.urlopen(req) as res:
            body = json.load(res)
            for article in body:
                # BeautifulSoupで記事本文を解析して、文章部分を取得
                content = article["rendered_body"]
                soup = BeautifulSoup(content, "html.parser")
                text = ""
                for valid_tag in soup.find_all(["p","li","h1","h2","h3","h4","table","span"]):
                    text += valid_tag.text
                # 分かち書きを実行
                separated_text = mecab_analysis(text)
                # 記事のID、URL、タイトル、分かち書き後のテキストを保持しておく
                ret["id_list"].append(article["id"])
                ret["url_list"].append(article["url"])
                ret["title_list"].append(article["title"])
                ret["separated_word_list"].append(separated_text)
                    
            print("Page: " + str(page_num))
            res_headers = res.info()
            # 最後のページまで取得したかを判定
            if page_num >= (int(res_headers['Total-Count']) + 99 ) // 100:
                print('# of articles: ', res_headers['Total-Count'])
                break

    # SQLiteに保存
    if (db_path is not None):
        # DBへ接続
        conn = sqlite3.connect(db_path)
        # カーソルを取得
        c = conn.cursor()
        # テーブルを作成
        c.execute('create table if not exists articles (id text primary key, title text, url text, body text)')
        # コミット
        conn.commit()
        # executemanyメソッドを使って、取得した記事をDBに挿入
        data = []
        for i in range(len(ret["id_list"])):   
            data.append((ret["id_list"][i], ret["title_list"][i], ret["url_list"][i], ret["separated_word_list"][i]))
        sql = "replace into articles (id, title, url, body) values (?, ?, ?, ?)"
        c.executemany(sql, data)
        # コミット
        conn.commit()
        # DBコネクションをクローズ
        conn.close()

    return ret
```

- Qiita APIを使っての記事の取得と、先程の関数を使っての分かち書きを行い、記事のタイトルとURL、分かち書きした結果を返却する関数です。
- [Qiita API](https://qiita.com/api/v2/docs#%E3%83%9A%E3%83%BC%E3%82%B8%E3%83%8D%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3)では、100記事 X 100ページの取得をすることができるので、この関数では、最大1万記事の情報を取得します。ページネーションの方法については、下記のリンク先の記事をご参照ください。

https://qiita.com/mochi_yu2/items/556b91e9f25b2eec83de

- また、Qiita APIでの記事取得と本文の取り出しのコードは、以下の記事を参考にさせていただきました。

https://qiita.com/kiyokiyo_kzsby/items/554c571404f3f9844aaf#%E3%82%B9%E3%82%AF%E3%83%AC%E3%82%A4%E3%83%94%E3%83%B3%E3%82%B0

- 記事の取得に時間がかかるのと、時間あたりのAPI実行回数の制限もあるため、取得した結果をSQLiteに保存しておきます。

## 入力記事取得
```python
# 類似記事を探したい入力記事の取得用関数
def get_input_article(query, token = None, db_path = None):
    print("入力記事取得開始")
    articles = get_articles(query, token, db_path)
    print("入力記事取得完了\n")

    if (len(articles["url_list"]) == 0):
        return {}
    
    article = {
        "url": articles["url_list"][0],
        "title": articles["title_list"][0],
        "separated_word": articles["separated_word_list"][0]
    }

    print("■入力記事")
    print("  タイトル: " + article["title"])
    print("  URL: " + article["url"])
    print("\n")

    return article
```

- 類似記事を探したい入力となる記事の情報を1記事分取得します。
- QiitaのAPIで指定の1記事分を取得する方法はないように見えたので、検索クエリで見つかった最初の1記事の情報を返すようにしています。

## 類似記事表示
```python
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

### 入力記事に類似する記事を検索対象記事から探す関数
def search_similar_articles(input_article, corpus_articles, display_num):
    if (len(input_article) == 0):
        print("入力記事が見つかりませんでした")
        return
    
    if (len(corpus_articles["url_list"]) == 0):
        print("検索対象の記事が見つかりませんでした")
        return

    print("■検索対象記事")
    print("  件数: " + str(len(corpus_articles["url_list"])) + "\n")

    # 検索対象記事のTF-IDFの計算。token_pattern=u'(?u)\\b\\w+\\b'で1文字の語を含む設定
    vectorizer = TfidfVectorizer(token_pattern=u'(?u)\\b\\w+\\b',max_features=100000)
    tfidf_corpus = vectorizer.fit_transform(corpus_articles["separated_word_list"])

    # (文書の数、単語の数)
    print(tfidf_corpus.shape)

    # 入力記事を分かち書きしたものをリストに入れて渡して、TF-IDFを計算
    tfidf_input = vectorizer.transform([input_article["separated_word"]])

    # コサイン類似度の計算
    similarity = cosine_similarity(tfidf_input, tfidf_corpus)[0]
    sim_list = similarity.tolist()

    # 引数で指定した表示数と類似度の配列のサイズの小さいほうの値を取得
    display_range= min(display_num, len((sim_list)))
    topn_indices = np.argsort(similarity)[::-1][:display_range]
    # 類似記事の表示
    for i in range(display_range):
        index = topn_indices[i]
        print('{:.03f}'.format(sim_list[index]) + ' ' + corpus_articles["url_list"][index] + ' ' + corpus_articles["title_list"][index])
```

- 上記の部分が、類似記事を探すメインの処理部分です。
- Pythonの機械学習用のライブラリである[scikit-learn](https://scikit-learn.org/stable/)にある[TfidfVectorizer](https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html#sklearn.feature_extraction.text.TfidfVectorizer)を使って、検索対象記事と入力記事のTF-IDFの計算を行います。
- TF-IDF計算後に[cosine_similarity](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.cosine_similarity.html#sklearn-metrics-pairwise-cosine-similarity)を使って、コサイン類似度を計算し、類似度の大きい記事を指定された個数分表示します。
- TF-IDFやコサイン類似度については、以下の記事を参考にさせていただきました

https://gotutiyan.hatenablog.com/entry/2020/09/10/181919

https://tat-pytone.hatenablog.com/entry/2019/06/22/143042

https://www.pytry3g.com/entry/cosine_similarity

## SQLiteに格納された記事から類似記事を探す関数
```python
import sqlite3

### SQLiteに格納した記事から類似記事を検索
def search_similar_articles_from_db(input_article_query, db_path, token = None, display_num = 10):
    # 入力記事をQiita APIで取得
    input_article = get_input_article(input_article_query, token, db_path)

    corpus_articles = {
        "url_list": [],
        "separated_word_list": [],
        "title_list": []
    }
    # DBから対象記事を取得
    conn = sqlite3.connect(db_path)
    # カーソルを取得
    c = conn.cursor()
    # SQLを実行して、取得結果を格納
    for r in c.execute('select * from articles').fetchall():
       corpus_articles["title_list"].append(r[1])
       corpus_articles["url_list"].append(r[2])
       corpus_articles["separated_word_list"].append(r[3])
 
    # コネクションをクローズ
    conn.close()

    search_similar_articles(input_article, corpus_articles, display_num)
```

- 類似記事を探したい入力記事をQiita APIで取得し、SQLiteに保存された記事から類似記事を探す関数です。

## Qiita APIで検索対象記事を取得し類似記事を探す関数
```python
### Webから検索
def search_similar_articles_from_web(input_article_query, corpus_articles_query, token = None, display_num = 10, db_path = None):
    # ベース記事の取得
    input_article = get_input_article(input_article_query, token, db_path)

    print("検索対象記事取得開始")
    corpus_articles = get_articles(corpus_articles_query, token, db_path)
    print("検索対象記事取得完了\n")

    search_similar_articles(input_article, corpus_articles, display_num)
```

- こちらの関数では、入力記事と検索対象記事の双方をQiita APIから取得します。

# 使い方
上記の関数を使って、類似記事を探す方法は、以下となります。

## Qiita APIを使って検索対象記事を取得する場合
Qiita APIを使って、Web上から検索対象記事を取得する場合は、以下のように実行します。

```python:実行コード
TOKEN = 'YOUR ACCESS TOKEN'
search_similar_articles_from_web(input_article_query = 'title:SeleniumとHeadless ChromeでページをPDFに保存する', corpus_articles_query = 'tag:selenium', token = TOKEN)
```

```text:実行結果
入力記事取得開始
Page: 1
# of articles:  1
入力記事取得完了

■入力記事
  タイトル: SeleniumとHeadless ChromeでページをPDFに保存する
  URL: https://qiita.com/mochi_yu2/items/a845e52b8aa677f132bf


検索対象記事取得開始
Page: 1
〜〜(中略)〜〜
Page: 20
# of articles:  1914
検索対象記事取得完了

■検索対象記事
  件数: 1914

(1914, 41392)
1.000 https://qiita.com/mochi_yu2/items/a845e52b8aa677f132bf SeleniumとHeadless ChromeでページをPDFに保存する
0.563 https://qiita.com/KWS_0901/items/33ae052e2e4694a6b4f1 [Python]Seleniumを利用したWebページのPDF保存方法 メモ
0.399 https://qiita.com/masaton/items/2dbef2cb4026d4a29974 Selenium WebDriver、Firefox を使ってPDFを自動ダウンロード
0.393 https://qiita.com/perpetualburn11/items/3dc8e7959d2d7cdc0d4f 【備忘】SeleniumでJPXから時価総額順位表（PDF）をスクレイピング
0.378 https://qiita.com/mochi_yu2/items/e2480ae3b2a6db9d7a98 Selenium(Python)の個人的スニペット集
0.352 https://qiita.com/ttn_tt/items/81d215683e7fbf0ebd81 （Python Selenium）自動ダウンロードした PDFファイル名を取得する
0.336 https://qiita.com/NauSakaguchi/items/76d66683f3c54e9d2a34 SeleniumでWebサイトをPDFとして保存するとき、デフォルトの保存先を変更する方法（chromedirver）（Python）
0.334 https://qiita.com/cozy16/items/9448203691e206072558 SeleniumとPythonを用いて複数のウェブサイトをPDF保存
0.316 https://qiita.com/cow_milk/items/ad146962a483cacf0344 【Ruby】SeleniumでPDFをダウンロードする
0.302 https://qiita.com/K_SIO/items/f442dd419f1d8fdd8689 Python と selenium でAmazonの購入履歴を取得する
```

# DBから検索対象記事を取得する場合
SQLiteのDBに格納した記事を検索対象として、類似記事を探す場合は以下のようにします。

```python:実行コード
TOKEN = 'YOUR ACCESS TOKEN'
DB_PATH = "/content/drive/MyDrive/Colab/QiitaArticles.db"
search_similar_articles_from_db(input_article_query = 'title:QiitaのSelenium全記事からワードクラウドを作る', db_path = DB_PATH, token = TOKEN)
```

```text:実行結果
入力記事取得開始
Page: 1
# of articles:  1
入力記事取得完了

■入力記事
  タイトル: QiitaのSelenium全記事からワードクラウドを作る
  URL: https://qiita.com/mochi_yu2/items/c8980e888ab82b949abf


■検索対象記事
  件数: 39800

(39800, 100000)
1.000 https://qiita.com/mochi_yu2/items/c8980e888ab82b949abf QiitaのSelenium全記事からワードクラウドを作る
0.313 https://qiita.com/m1z0/items/681c7221ce4214cc1f22 2019年のnemをWordCloudで可視化する
0.303 https://qiita.com/rh_/items/dd98aed7f95ca766c371 Quill EditorでHTML5をそのまま挿入する方法
0.293 https://qiita.com/taka221/items/bd9eea2a3fbb3f09dc45 小説「天気の子」の文章をWordCloudで可視化してみた
0.289 https://qiita.com/yoshi-taka/items/38bbb072c2e88d163f82 Systems Manager メンテナンスウィンドウでハマりかけたところ
0.283 https://qiita.com/CozyCorner/items/693fa00aa0348edd1e8a 転職サイトの口コミを分析して分かったこと！？
0.278 https://qiita.com/litharge3141/items/7c1c879240d6c9d46166 Lorenz96モデルのデータ同化:Extended Kalman Filter
0.268 https://qiita.com/typeR_Anonymous/items/31d1895d03476e70d5f2 Noodl×Node-REDでペルソナ５のイセカイナビを再現する
0.265 https://qiita.com/nyax/items/7c36b420092655349254 乃木坂46 堀未央奈のブログで遊んでみた(1) 〜テキストマイニング編〜
0.258 https://qiita.com/hatopoppoK3/items/1397e31a17e9549dd81c Flask+WordCloud+MeCabで入力テキストのワードクラウドを表示するWebアプリケーションの作成
```


# 類似検索結果の例
自分の書いた直近3件の記事の類似記事を上記を使って、探してみます。
検索対象記事は、2020年と2019年に投稿されたストック数2以上の記事、91,160件です。
検索対象記事を取得し、SQLiteに保存したものから検索します。

```python:実行コード
TOKEN = 'YOUR ACCESS TOKEN'
DB_PATH = "/content/drive/MyDrive/Colab/QiitaArticles-2020-2019-stocks2.db"
search_similar_articles_from_db(input_article_query = 'title:iPadからVSCodeとJupyter Notebookを使う', db_path = DB_PATH, token = TOKEN, display_num = 11)
```

## 記事1: 「iPadからVSCodeとJupyter Notebookを使う」

https://qiita.com/mochi_yu2/items/860ea0abbc9101a90afa

| 順位 | 類似度 | 記事 |
|----:|------:|:-----|
| 1 | 0.639 | [[備忘録]Let's Encrypt でSSL化してからDocumentRootを変更する方法](https://qiita.com/ZAKILOG1/items/9b171b28e69afc9063bf) |
| 2 | 0.587 | [【逆引き】Spring Security（随時更新）](https://qiita.com/mr-hisa-child/items/173289e47b4f21022562) |
| 3 | 0.570 | [Let's Encrypt の設定（Apache / OpenLDAP / Dovecot / Postfix on Debian9）](https://qiita.com/tsuchm/items/cf3957f5413ca4f56317) |
| 4 | 0.567 | [【AndroidStudio】ERROR: Failed to install the following Android SDK packages as some licences have not been accepted.](https://qiita.com/yamahei/items/499ef3d39b275b108ffe) |
| 5 | 0.567 | [Docker CentOSイメージを使用してPHP-FPMを実行する](https://qiita.com/ykakizaki/items/47b486fb91a3802d441d) |
| 6 | 0.566 | [Spring MVCでイチからJava Configを設定してみた](https://qiita.com/HiroyaEnd/items/17175e947911d84c1b3b) |
| 7 | 0.558 | [64bit VBAでクリップボードに文字列を設定・取得](https://qiita.com/7shi/items/61f4c4e132835b26b3ea) |
| 8 | 0.551 | [Boto3 に追加されたリトライ処理モードを利用する](https://qiita.com/hayao_k/items/1f20df24927a9a7776ca) |
| 9 | 0.551 | [スクリーンショットをExcelシートに半自動でペーストする(エビデンス取得用)](https://qiita.com/ktrarai/items/636812bd5b74e554bb2a) |
| 10 | 0.546 | [TextFieldの長押しでクラッシュする問題の対応方法](https://qiita.com/ko2ic/items/7bc67494cf8d03f4f4e0) |

1個目の記事については、正直似ているなという記事は抽出されていないように感じました。

## 記事2: 「VPSのDocker上のアプリにAuth0を使ってアクセス制限をかける（多要素認証導入まで）」

https://qiita.com/mochi_yu2/items/239c1b4261bafece42f3


| 順位 | 類似度 | 記事 |
|----:|------:|:-----|
| 1 | 0.419 | [Macのlocalhostで複数ドメインを管理する](https://qiita.com/mgmgmogumi/items/0077f454ed5691e8dd16) |
| 2 | 0.399 | [Django2デプロイ(centos7 apache2.4 mod_wsgi)](https://qiita.com/s-katsumata/items/d2ff1d0dadc2b35a02b1) |
| 3 | 0.389 | [flask+apacheでAPI公開](https://qiita.com/nabion/items/88528ad5eaf6fdad992e) |
| 4 | 0.387 | [Let's Encrypt の設定（Apache / OpenLDAP / Dovecot / Postfix on Debian9）](https://qiita.com/tsuchm/items/cf3957f5413ca4f56317) |
| 5 | 0.387 | [Amazon Linux2にPython3 -Django - mod_wsgi をインストール](https://qiita.com/mighty-n/items/0e3f24d1768366296f74) |
| 6 | 0.383 | [irohaboardをUbuntu環境で構築する](https://qiita.com/kawadumax/items/dc2c51552074e393bd2c) |
| 7 | 0.372 | [CentOS+ApacheにLet's Encryptを導入](https://qiita.com/toshiok/items/42d1f023eeb79a1ddfe6) |
| 8 | 0.367 | [マルチドメイン化とLet's Encrypt証明書導入](https://qiita.com/mindwood/items/4f09da35fa9ffff6f53d) |
| 9 | 0.366 | [[備忘録]Let's Encrypt でSSL化してからDocumentRootを変更する方法](https://qiita.com/ZAKILOG1/items/9b171b28e69afc9063bf) |
| 10 | 0.354 | [Ubuntu で apache2のDocument rootを変更する方法](https://qiita.com/bluesDD/items/3cf77298ece0c83e9968) |

類似の評価が難しいですが　ApacheやLet's Encryptに関連する記事をいくつか抽出することができました。入力記事でApacheやLet's Encryptに触れているので、その部分の類似と思われます。

## 記事3: 「【Java】Apache HttpClientの使い方メモ」

https://qiita.com/mochi_yu2/items/c88dba5cc7d2f1f81c0b


| 順位 | 類似度 | 記事 |
|----:|------:|:-----|
| 1 | 0.698 | [Maven Dependency Plugin で system スコープの jar ファイルもひとつにまとめる](https://qiita.com/niwasawa/items/766ba558678ac9bb85aa) |
| 2 | 0.650 | [Mavenの基本勉強メモ](https://qiita.com/opengl-8080/items/bb32732f9aa5cb3495d2) |
| 3 | 0.636 | [Maven ことはじめ (Java プロジェクトを作成して、外部ライブラリをまとめてひとつの実行可能 JAR を生成するまで)](https://qiita.com/niwasawa/items/b3199d6d81bd3215fe0a) |
| 4 | 0.633 | [MicronautでHello World](https://qiita.com/charon/items/e1cdbbf05a08e8d5ea21) |
| 5 | 0.596 | [EclipseでSpring Boot使用時にpom.xmlのline 1にUnknown error](https://qiita.com/kagamihoge/items/fbfe90837192fd88fee9) |
| 6 | 0.587 | [SpringでMySQLを利用してみる（Part 1: プロジェクトの作成） ](https://qiita.com/shoko93/items/99d95c292a78f3a77597) |
| 7 | 0.580 | [EclipseでMavenプロジェクトを作成しSpringMVCを用いた簡易サイトを構築してみる。](https://qiita.com/ElCondor24/items/be2460472833c5c39179) |
| 8 | 0.578 | [【Maven】プロジェクト作成からWebアプリ起動まで](https://qiita.com/j-work/items/afbabe1e500a3c7f6aa0) |
| 9 | 0.569 | [Spring Security + OAuth2でシングルサインオンSSO機能を実装してみる](https://qiita.com/Hyman1993/items/38c58e04dc1392e0901d) |
| 10 | 0.566 | [AWS CodeArtifactでJavaのライブラリを管理してみる](https://qiita.com/neruneruo/items/c231d64bb1eb52ca8fa9) |

3つ目の記事については、MavenやJava関係の記事が類似として、出力されました。
入力記事の内容もJavaでMavenを使っているので、的外れではないかと思います。

# コード全体
今回利用した`.ipynb`ファイル全体を載せます。
長いので折りたたんでいます。

<details><summary>.ipynbファイル(クリックで展開)</summary><div>

```python
{
  "nbformat": 4,
  "nbformat_minor": 0,
  "metadata": {
    "colab": {
      "name": "tf-idf-cos.ipynb",
      "provenance": [],
      "collapsed_sections": []
    },
    "kernelspec": {
      "name": "python3",
      "display_name": "Python 3"
    },
    "language_info": {
      "name": "python"
    }
  },
  "cells": [
    {
      "cell_type": "code",
      "metadata": {
        "id": "CmSbKmdNplIx"
      },
      "source": [
        "!apt update\n",
        "!apt install fonts-ipafont fonts-ipaexfont\n",
        "!pip install mecab-python3 unidic-lite\n",
        "\n",
        "# MeCabが利用できるかの確認\n",
        "import MeCab\n",
        "print(MeCab.Tagger().parse(\"すもももももももものうち\"))"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "code",
      "metadata": {
        "id": "JCNuUIu8bJh5"
      },
      "source": [
        "# Googleドライブをマウント\n",
        "from google.colab import drive\n",
        "drive.mount('/content/drive')"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "code",
      "metadata": {
        "id": "bmfX1uquwhOy"
      },
      "source": [
        "import MeCab\n",
        "\n",
        "# 引数で与えられた文章を分かち書きしたテキストを返す\n",
        "def mecab_analysis(text):\n",
        "    tagger = MeCab.Tagger()\n",
        "\n",
        "    node = tagger.parseToNode(text)\n",
        "    output_text = ''\n",
        "    while(node):\n",
        "        if node.surface != \"\":\n",
        "            word_type = node.feature.split(\",\")[0]\n",
        "            # あらかじめ一部の品詞の単語を除外しておく\n",
        "            if word_type not in [\"助詞\",\"助動詞\",\"副詞\",\"連体詞\",\"記号\"]:\n",
        "                output_text += node.surface + \" \"\n",
        "        node = node.next\n",
        "\n",
        "    return output_text"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "code",
      "metadata": {
        "id": "QhMAUJR7p5-Q"
      },
      "source": [
        "import urllib.request\n",
        "import json\n",
        "import sqlite3\n",
        "from bs4 import BeautifulSoup\n",
        "\n",
        "# QiitaAPIを利用して、記事を取得する\n",
        "# DBのパスが与えられた場合には、SQLiteに取得データを保存する\n",
        "def get_articles(query = None, token = None, db_path = None):\n",
        "    ret = {\n",
        "        \"id_list\": [],\n",
        "        \"url_list\": [],\n",
        "        \"separated_word_list\": [],\n",
        "        \"title_list\": []\n",
        "    }\n",
        "    page_num = 0\n",
        "\n",
        "    while page_num < 100:\n",
        "        page_num += 1\n",
        "        # クエリパラメータの準備\n",
        "        params = {\n",
        "            'page': str(page_num),\n",
        "            'per_page': '100'\n",
        "        }\n",
        "        if (query is not None):\n",
        "            params['query'] = query\n",
        "        # アクセストークンが指定された場合に付与\n",
        "        req_headers = {}\n",
        "        if (token is not None):\n",
        "            req_headers = {\n",
        "                'Authorization': 'Bearer ' + token\n",
        "            }\n",
        "\n",
        "        url = \"https://qiita.com/api/v2/items?\" + urllib.parse.urlencode(params)\n",
        "        # Qiita APIから結果を取得\n",
        "        req = urllib.request.Request(url, headers=req_headers)\n",
        "        with urllib.request.urlopen(req) as res:\n",
        "            body = json.load(res)\n",
        "            for article in body:\n",
        "                # BeautifulSoupで記事本文を解析して、文章部分を取得\n",
        "                content = article[\"rendered_body\"]\n",
        "                soup = BeautifulSoup(content, \"html.parser\")\n",
        "                text = \"\"\n",
        "                for valid_tag in soup.find_all([\"p\",\"li\",\"h1\",\"h2\",\"h3\",\"h4\",\"table\",\"span\"]):\n",
        "                    text += valid_tag.text\n",
        "                # 分かち書きを実行\n",
        "                separated_text = mecab_analysis(text)\n",
        "                # 記事のID、URL、タイトル、分かち書き後のテキストを保持しておく\n",
        "                ret[\"id_list\"].append(article[\"id\"])\n",
        "                ret[\"url_list\"].append(article[\"url\"])\n",
        "                ret[\"title_list\"].append(article[\"title\"])\n",
        "                ret[\"separated_word_list\"].append(separated_text)\n",
        "                    \n",
        "            print(\"Page: \" + str(page_num))\n",
        "            res_headers = res.info()\n",
        "            # 最後のページまで取得したかを判定\n",
        "            if page_num >= (int(res_headers['Total-Count']) + 99 ) // 100:\n",
        "                print('# of articles: ', res_headers['Total-Count'])\n",
        "                break\n",
        "\n",
        "    # SQLiteに保存\n",
        "    if (db_path is not None):\n",
        "        # DBへ接続\n",
        "        conn = sqlite3.connect(db_path)\n",
        "        # カーソルを取得\n",
        "        c = conn.cursor()\n",
        "        # テーブルを作成\n",
        "        c.execute('create table if not exists articles (id text primary key, title text, url text, body text)')\n",
        "        # コミット\n",
        "        conn.commit()\n",
        "        # executemanyメソッドを使って、取得した記事をDBに挿入\n",
        "        data = []\n",
        "        for i in range(len(ret[\"id_list\"])):   \n",
        "            data.append((ret[\"id_list\"][i], ret[\"title_list\"][i], ret[\"url_list\"][i], ret[\"separated_word_list\"][i]))\n",
        "        sql = \"replace into articles (id, title, url, body) values (?, ?, ?, ?)\"\n",
        "        c.executemany(sql, data)\n",
        "        # コミット\n",
        "        conn.commit()\n",
        "        # DBコネクションをクローズ\n",
        "        conn.close()\n",
        "\n",
        "    return ret"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "code",
      "metadata": {
        "id": "1I_OSUD2zASx"
      },
      "source": [
        "# 類似記事を探したい入力記事の取得用関数\n",
        "def get_input_article(query, token = None, db_path = None):\n",
        "    print(\"入力記事取得開始\")\n",
        "    articles = get_articles(query, token, db_path)\n",
        "    print(\"入力記事取得完了\\n\")\n",
        "\n",
        "    if (len(articles[\"url_list\"]) == 0):\n",
        "        return {}\n",
        "    \n",
        "    article = {\n",
        "        \"url\": articles[\"url_list\"][0],\n",
        "        \"title\": articles[\"title_list\"][0],\n",
        "        \"separated_word\": articles[\"separated_word_list\"][0]\n",
        "    }\n",
        "\n",
        "    print(\"■入力記事\")\n",
        "    print(\"  タイトル: \" + article[\"title\"])\n",
        "    print(\"  URL: \" + article[\"url\"])\n",
        "    print(\"\\n\")\n",
        "\n",
        "    return article"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "code",
      "metadata": {
        "id": "lCtf1jflsNl3"
      },
      "source": [
        "import numpy as np\n",
        "from sklearn.feature_extraction.text import TfidfVectorizer\n",
        "from sklearn.metrics.pairwise import cosine_similarity\n",
        "\n",
        "### 入力記事に類似する記事を検索対象記事から探す関数\n",
        "def search_similar_articles(input_article, corpus_articles, display_num):\n",
        "    if (len(input_article) == 0):\n",
        "        print(\"入力記事が見つかりませんでした\")\n",
        "        return\n",
        "    \n",
        "    if (len(corpus_articles[\"url_list\"]) == 0):\n",
        "        print(\"検索対象の記事が見つかりませんでした\")\n",
        "        return\n",
        "\n",
        "    print(\"■検索対象記事\")\n",
        "    print(\"  件数: \" + str(len(corpus_articles[\"url_list\"])) + \"\\n\")\n",
        "\n",
        "    # 検索対象記事のTF-IDFの計算。token_pattern=u'(?u)\\\\b\\\\w+\\\\b'で1文字の語を含む設定\n",
        "    vectorizer = TfidfVectorizer(token_pattern=u'(?u)\\\\b\\\\w+\\\\b',max_features=100000)\n",
        "    tfidf_corpus = vectorizer.fit_transform(corpus_articles[\"separated_word_list\"])\n",
        "\n",
        "    # (文書の数、単語の数)\n",
        "    print(tfidf_corpus.shape)\n",
        "\n",
        "    # 入力記事を分かち書きしたものをリストに入れて渡して、TF-IDFを計算\n",
        "    tfidf_input = vectorizer.transform([input_article[\"separated_word\"]])\n",
        "\n",
        "    # コサイン類似度の計算\n",
        "    similarity = cosine_similarity(tfidf_input, tfidf_corpus)[0]\n",
        "    sim_list = similarity.tolist()\n",
        "\n",
        "    # 引数で指定した表示数と類似度の配列のサイズの小さいほうの値を取得\n",
        "    display_range= min(display_num, len((sim_list)))\n",
        "    topn_indices = np.argsort(similarity)[::-1][:display_range]\n",
        "    # 類似記事の表示\n",
        "    for i in range(display_range):\n",
        "        index = topn_indices[i]\n",
        "        # print('{:.03f}'.format(sim_list[index]) + ' ' + corpus_articles[\"url_list\"][index] + ' ' + corpus_articles[\"title_list\"][index])\n",
        "        print('| ' + str(i + 1) + ' | ' + '{:.03f}'.format(sim_list[index]) + ' | [' + corpus_articles[\"title_list\"][index] + '](' + corpus_articles[\"url_list\"][index] + ') |')"
      ],
      "execution_count": 20,
      "outputs": []
    },
    {
      "cell_type": "code",
      "metadata": {
        "id": "uo6Sng5jbGFL"
      },
      "source": [
        "import sqlite3\n",
        "\n",
        "### SQLiteに格納した記事から類似記事を検索\n",
        "def search_similar_articles_from_db(input_article_query, db_path, token = None, display_num = 10):\n",
        "    # 入力記事をQiita APIで取得\n",
        "    input_article = get_input_article(input_article_query, token, db_path)\n",
        "\n",
        "    corpus_articles = {\n",
        "        \"url_list\": [],\n",
        "        \"separated_word_list\": [],\n",
        "        \"title_list\": []\n",
        "    }\n",
        "    # DBから対象記事を取得\n",
        "    conn = sqlite3.connect(db_path)\n",
        "    # カーソルを取得\n",
        "    c = conn.cursor()\n",
        "    # SQLを実行して、取得結果を格納\n",
        "    for r in c.execute('select * from articles').fetchall():\n",
        "       corpus_articles[\"title_list\"].append(r[1])\n",
        "       corpus_articles[\"url_list\"].append(r[2])\n",
        "       corpus_articles[\"separated_word_list\"].append(r[3])\n",
        " \n",
        "    # コネクションをクローズ\n",
        "    conn.close()\n",
        "\n",
        "    search_similar_articles(input_article, corpus_articles, display_num)\n"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "code",
      "metadata": {
        "id": "47NDq769E2JY"
      },
      "source": [
        "### Webから検索\n",
        "def search_similar_articles_from_web(input_article_query, corpus_articles_query, token = None, display_num = 10, db_path = None):\n",
        "    # ベース記事の取得\n",
        "    input_article = get_input_article(input_article_query, token, db_path)\n",
        "\n",
        "    print(\"検索対象記事取得開始\")\n",
        "    corpus_articles = get_articles(corpus_articles_query, token, db_path)\n",
        "    print(\"検索対象記事取得完了\\n\")\n",
        "\n",
        "    search_similar_articles(input_article, corpus_articles, display_num)"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "code",
      "metadata": {
        "id": "4fWe7zQfmp_e"
      },
      "source": [
        "TOKEN = 'YOUR ACCESS TOKEN'\n",
        "DB_PATH = \"/content/drive/MyDrive/Colab/QiitaArticles.db\"\n",
        "# get_articles(token = TOKEN, query = 'created:2020-01', db_path = DB_PATH)\n",
        "# search_similar_articles_from_db(input_article_query = 'title:QiitaのSelenium全記事からワードクラウドを作る', db_path = DB_PATH, token = TOKEN)\n",
        "search_similar_articles_from_web(input_article_query = 'title:iPadからVSCodeとJupyter Notebookを使う', corpus_articles_query = 'created:2020', db_path = DB_PATH, token = TOKEN, display_num = 11)"
      ],
      "execution_count": null,
      "outputs": []
    }
  ]
}
```

</div></details>

# 最後に
今回は、TF-IDFとコサイン類似度を使って、Qiitaから類似記事を探すプログラムを作成しました。自然言語処理や機械学習の分野は、素人でわからないことだらけですが、様々な記事やライブラリの力を借りて、何とか動くものはできました。
大量に記事を収集したり、利用する手法やチューニングについては、まだまだ課題が多いと感じました。

# 参考
作成にあたり以下の記事を参考にさせていただきました。

https://tat-pytone.hatenablog.com/entry/2019/06/22/143042

https://www.pytry3g.com/entry/cosine_similarity

https://gotutiyan.hatenablog.com/entry/2020/09/10/181919