新着記事を取得するツールを作って学んだこと


pythonでスクレイピングして新着ブログポストのタイトルとURLを取得するツールを作成しました。

#### 作った理由
記事の投稿によって記憶の定着、文章力の向上に努めようということで、友人と２人で刺激しあうために、お互いの記事の新着情報をグループラインに通知するために作りました。

### 内容
新しく投稿された記事のタイトルとURLを取得します。
既存の投稿一覧のURLが保存されたtxtファイルを使用し、最新の投稿一覧のURLと比較します。
タイトルや内容の変更などは検知しないようにしています。（タイトルを変えただけで通知を送らないように）

Qiitaとはてなで使えるように作っているので、htmlが以下のような形式になっているページで使えると思います

```html
<!-- aタグにclassあり。　aタグの要素としてタイトルが書かれている -->
<a class='articles'  href='#'>Title</a>
```

#### 条件
1. 記事の一覧を表示するページがあること
2. 各記事の`<a>`タグに、共通するselecterが設定されていること
3. タイトルはaタグの要素として書かれていること　　
4. あらかじめ空の.txtファイルを作成しておくこと


### コード全体
```python

import requests, bs4


def new_post_getter(url, selecter, txt):
    '''
    記事タイトル及びURLのbs4_elementを取得
    第1引数 : 投稿一覧のあるページのURL
    第２引数 : 各投稿の<a>タグに付与されているセレクター 頭に.をつけて
    第3引数 : 記録用txtのpath
    '''
    res = requests.get(url)
    posts = bs4.BeautifulSoup(res.text, 'html.parser').select(selecter)

    now_posts_url = [] #1 取得した記事一覧のURLのリスト, 今までの投稿データと比較し、新着投稿を割り出すために使う
    now_posts_url_title_set = [] # 取得した記事一覧のURLとタイトルのリスト、
    for post in posts:
        # URLを抽出
        index_first = int(str(post).find('href=')) + 6
        index_end = int(str(post).find('">'))
        url = (str(post)[index_first : index_end])
        # タイトルを抽出
        index_first = int(str(post).find('">')) + 2
        index_end = int(str(post).find('</a'))
        title = (str(post)[index_first : index_end].replace('\u3000', ' ')) # 空白置換

        now_posts_url.append(url)
        now_posts_url_title_set.append(f"{url}>>>{title}")

    old_post_text = open(txt)
    old_post = old_post_text.read().split(',') # テキストファイルからリスト型へ 
    # differences : 投稿済だが一覧画面に表示されていない投稿 + 新しい投稿
    differences = list(set(now_posts_url) - set(old_post))
    old_post_text.close()

    # 記録用txtを上書き all_postsは過去の投稿 + 新しい投稿
    all_posts = ",".join(old_post + differences)
    f = open(txt, mode='w')
    f.writelines(all_posts)
    f.close()

    new_post_info = []
    for new in now_posts_url_title_set:
        for incremental in differences:
            if incremental in new:
                new_post_info.append(new.split(">>>"))
    return new_post_info
```

### 使い方
`記事一覧のページ`,`各記事のaタグに付与されているセレクタ`,`投稿状況を保存するtxtファイルのパス`を引数に指定します

#### ★使ってみる

```python:使ってみる
url = 'https://qiita.com/takuto_neko_like'
selecter = '.u-link-no-underline'
file = 'neko.txt'

my_posts = new_post_getter(url, selecter, file)
print(my_posts)
```
上記のようにすることで...

```python:結果

[['/takuto_neko_like/items/93b3751984e5e3fd3670', 'fishが重い'], ['/takuto_neko_like/items/14e92797fa2b23a64adb', '【Python】多重継承で継承するものは何？']]
```

URLとタイトルをリストにした二重リストを得られます。
`[ [ URL, タイトル ], [ URL, タイトル ], [ URL, タイトル ], ....... ]`

2重リストをfor文で回して、文字列を整形することで...

```python

for url, title in my_posts:
    print(f'{title} : {url}')
```

みやすく出力↓

```:出力

fishの動きが遅い : /takuto_neko_like/items/93b3751984e5e3fd3670
【Python】多重継承で継承するものは何？ : /takuto_neko_like/items/14e92797fa2b23a64adb
```

#### ちなみに

neko.txtの中身はこのようになっています。

```
/takuto_neko_like/items/93b3751984e5e3fd3670,/takuto_neko_like/items/14e92797fa2b23a64adb,/takuto_neko_like/items/bb8d0957347636b5bf4f,/takuto_neko_like/items/62aeb4271614f6f0347f,/takuto_neko_like/items/c9c80ff453d0c4fad239,/takuto_neko_like/items/aed9dd5619d8457d4894,/takuto_neko_like/items/6cf9bade3d9515a724c0
```
URLのリストが入ってます。１つ目と最後を削除してみて...

```
/takuto_neko_like/items/14e92797fa2b23a64adb,/takuto_neko_like/items/bb8d0957347636b5bf4f,/takuto_neko_like/items/62aeb4271614f6f0347f,/takuto_neko_like/items/c9c80ff453d0c4fad239,/takuto_neko_like/items/aed9dd5619d8457d4894
```
実行すると...

```python3

my_posts = new_post_getter(url, selecter, file)
print(my_posts)
```

結果↓

```
[['/takuto_neko_like/items/c5791f267e0964e09d03', '新着記事を取得するツールを作って学んだこと'], ['/takuto_neko_like/items/93b3751984e5e3fd3670', 'fishの動きが遅い'], ['/takuto_neko_like/items/6cf9bade3d9515a724c0', '【Python】@classmethod及びデコレータとは？']]
```
差分だけを取得します





### やっていること
1. 記事一覧ページから、表示されているすべての記事の`<a>`タグを取得する
2. 取得した`<a>`タグの中からURLを抽出する。又、別でURLとタイトルのセットも抽出。
3. 既存の投稿一覧(txt)を使い、２．で得たURLと比較。差分を抽出する。
4. 新しい投稿のURLと、既存の投稿記録を１つにして、txtを上書きする
5. 2.で取得しておいたURLとタイトルのセットから、差分に該当したモノだけを抽出。整形しやすいように２重リスト型にする

### 各コード

#### 1. 記事一覧ページから、表示されているすべての記事の`<a>`タグを取得

```python:1.記事一覧ページから、表示されているすべての記事のaタグを取得

import requests, bs4


def new_post_getter(url, selecter, txt):
    '''
    記事タイトル及びURLのbs4_elementを取得
    第1引数 : 投稿一覧のあるページのURL
    第２引数 : 各投稿の<a>タグに付与されているセレクター 頭に.をつけて
    第3引数 : 記録用txtのpath
    '''
    res = requests.get(url)
    posts = bs4.BeautifulSoup(res.text, 'html.parser').select(selecter)

```
##### 使用するライブラリ
1. [request](https://requests-docs-ja.readthedocs.io/en/latest/)
　　WebAPIを使うことができるライブラリ。今回はGETメソッドを使い、レスポンスオブジェクトを取得しています。リクエストオブジェクトには様々な情報が含まれていますが、`.text`を使うことで文字列にすることが可能。2.のBeautifulSoupではレスポンスとして帰ってきたテキスト化したHTMLを使います。
2. [BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/)
　　取得したHTMLのテキストを構文解釈し、その後各種メソッドで属性を取得したり、セレクタを使って複数の要素を取得したり。今回は、テキスト化されたレスポンスをHTMLとして解釈し、その後、`.select`メソッドを使い、特定のセレクターを指定しています。これにより、該当したセレクターの要素が複数手に入ります。

上記の`★使ってみる`で実際に取得したデータは次の白枠部分です

<img width="923" alt="スクリーンショット 2020-03-08 0.07.31.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/441606/a0638632-c9dc-2e74-5701-0fbde67f7cbf.png">



#### 2. 取得した`<a>`タグの中からURLを抽出する。又、別でURLとタイトルのセットも抽出。


```python:2.取得したaタグの中からタイトルをURLを取得。又、別でURLとタイトルのセットも抽出。

    now_posts_url = [] #1 取得した記事一覧のURLのリスト, 今までの投稿データと比較し、新着投稿を割り出すために使う
    now_posts_url_title_set = [] # 取得した記事一覧のURLとタイトルのリスト、
    for post in posts:
        # URLを抽出
        index_first = int(str(post).find('href=')) + 6
        index_end = int(str(post).find('">'))
        url = (str(post)[index_first : index_end])
        # タイトルを抽出
        index_first = int(str(post).find('">')) + 2
        index_end = int(str(post).find('</a'))
        title = (str(post)[index_first : index_end].replace('\u3000', ' ')) # 空白置換

        now_posts_url.append(url)
        now_posts_url_title_set.append(f"{url}>>>{title}")
```
取得した`<a>`タグの要素たちをfor文で回します。`.find()`で文字列を指定することで、その文字列の開始位置のインデックスがわかるため、その値で文字列をスライスすることで、URL部分とタイトル部分を手に入れます。

<img width="388" alt="スクリーンショット 2020-03-08 0.17.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/441606/243f07f2-39b7-619a-b2ca-117c577897ba.png">

`now_posts_url`は今までの投稿データと比較し、差分（ページネーション等で一覧画面から消えた記事を省く）を取り出すために使うデータです。
今回は、記事の更新があっても変わることがないであろうURLを使って新着を感知しますが、後々タイトルとURLを出力するために、今のうちに`URL+タイトル`のセットを取っておきたい。そのため、`now_posts_url`を使って差分を得ておき、後ほど`now_posts_url_title_set`から差分のURLが含まれるデータだけを抽出します。





#### 3.既存の投稿一覧(txt)を使い、２．で得たURLと比較。差分を抽出する。

```python:3.既存の投稿一覧(txt)を使い、２．で得たURLと比較。差分を抽出する。
    old_post_text = open(txt)
    old_post = old_post_text.read().split(',') # テキストファイルからリスト型へ 
    # differences : 投稿済だが一覧画面に表示されていない投稿 + 新しい投稿
    differences = list(set(now_posts_url) - set(old_post))
    old_post_text.close()
```
今までの投稿記録が保存してあるtxtファイルと比較し、新しく取得した最新の投稿一覧から、差分を抽出したいです。
以下でいうと
　Aが過去の投稿一覧
　Bが最新の投稿一覧
　そして、斜線を引いている部分が差分で、全く新しい投稿。

![IMG_7686.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/441606/751b7651-2041-7960-3f9e-53304af6673c.jpeg)

計算対象をset型のオブジェクトにすることで集合演算が行なえるため、
今回は、txtファイルに記録してあるリスト型の文字列(`[ URL1, URL2, URL3 ]`)を`split()`でlist型に。
2.で取得した最新の投稿一覧と一緒にset型に変換しつつ、差を求めています。

#### 4.新しい投稿のURLと、既存の投稿記録を１つにして、txtを上書きする

```python:新しい投稿のURLと、既存の投稿記録を１つにして、txtを上書きする
    # 記録用txtを上書き all_postsは過去の投稿 + 新しい投稿
    all_posts = ",".join(old_post + differences)
    f = open(txt, mode='w')
    f.writelines(all_posts)
    f.close()
```
txtファイルも最新情報を追加して、次回に使えるようにしておかなければいけません。
今までの投稿に差分(新しい投稿)をプラスしてtxtファイルを上書きします。

#### 5.2.で取得しておいたURLとタイトルのセットから、差分に該当したモノだけを抽出。整形しやすいように２重リスト型にする

```python:新投稿のタイトルとURLを整形
    new_post_info = []
    for new in now_posts_url_title_set:
        for incremental in differences:
            if incremental in new:
                new_post_info.append(new.split(">>>"))
    return new_post_info
```

あらかじめ2.で取得しておいた"URL>>>タイトル"という文字列のデータから、差分と一致するURL（の文字列）を含むものだけを、得ます。

![IMG_7687 2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/441606/36b570db-d199-5c32-736a-9b18fe0f1513.jpeg)

文字列なので`in`演算子で、文字列内に同じ文字が含まれていればOK。
これにより、新着の記事のURLとタイトルをGETできました。



## LINEで通知したい

Line notifyを使いました。

```python

def send_line_notify(posts, token):
    '''
    # new_post_getterの返り値を引数に
    '''
    notice_url = "https://notify-api.line.me/api/notify"
    headers = {"Authorization" : "Bearer "+ token}
    for url, title in posts:
        if 'http' not in url:
            url = 'https://qiita.com/' + url
        message = f'{title}:{url}'
        payload = {'message': message}
        r = requests.post(notice_url, headers=headers, params=payload,)
```

```python:このようにして使う

token = '########'
neko_post = new_post_getter(neko_url, neko_selecter, neko_txt)
send_line_notify(neko_post, token)
```

`new_post_getter`関数の返り値とトークンを引数に指定すれば、`LINE Notify`に送信してくれます。
[こちら](https://qiita.com/moriita/items/5b199ac6b14ceaa4f7c9)を参考にさせていただきました

## 定期的に実行したい
pythonanywhereに各ファイルをコピーし、以下のとおり.shを作成

```sh:仮想環境をcronできるように

source /home/<アカウント>/blog_post_notice/venv/bin/activate
python3 /home/<アカウント>/blog_post_notice/send.py ## 
```

続いて、cronの設定の前に.shを実行してみると...

```:Error

requests.exceptions.ProxyError: HTTPSConnectionPool(host='qiita.com', port=443): Max retries exceeded with url: /takuto_neko_like (Caused by ProxyError('Canno
t connect to proxy.', OSError('Tunnel connection failed: 403 Forbidden')))
```

調べてみると、Pythonanywhereでは無料アカウントの場合、不正なアクセスを防ぐ目的で、[ホワイトリスト](https://www.pythonanywhere.com/whitelist/)に該当する外部サイトにのみアクセスできるようです。
ということで、pythonanywhereは諦めて...

Herokuにデプロイしてみました。
が、Herokuではファイルの保存などができないため、自分のPC上でcronを設定し、定期的に実行してみます。

`crontab -e`として...

```:とりあえず毎分ごとに実行してみる
0 * * * * sh /Users/ユーザ名/dir1/post_notice/notice.sh
```