スクレイピングとデータ保存

素人が投稿サイトから情報をスクレイピングし、sqlite3で保存するまでのメモです。
[雑談Slack Advent Calendar 2019](https://adventar.org/calendars/4684) 18日目の関連記事でもあります。

先人の多くの記事を参考にしました。
`Beautifulsoup4`で他の記事を検索されると、そちらのほうが詳しいでしょう。

##やりたいこと
- 投稿のサムネイルページをみて、概要情報をDBに保存する。
- 必要があれば各投稿ページをみて、詳細な投稿情報をDBに保存する。

##環境
- Windows10 Pro + Python3.7.1 x64 (BeautifulSoup4 , SQLite3)  

##対象ページの情報調査
スクレイピング対象のページから使えそうなテキストデータをピックアップします。

- __サムネイルページ__
![Qiita説明用１-サムネイルページ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/223386/07a7aea3-a8a8-3f8a-5439-d3817940a28a.png)  
各投稿ごとに以下の情報が見えます。
 1. __投稿タイトル__
 2. __投稿者名__
 3. __再生回数__
 4. __お気に入り回数__

- __投稿ページ__
![Qiita説明用２-投稿ページ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/223386/ccb94551-190e-1655-92c9-6d407551d787.png)

 1. __投稿ページURL__  
 末尾はハッシュでしょうか。各投稿のユニークなキーとして使えそうです。
 2. __タイトル__  
 サムネイルページでは15文字程度で"..."と切れていましたが、こちらではフル表示です。
 3. __投稿日時__  
 新しい情報！ただ海外のサーバーなのに、最新記事から考えて日本時間で表示されています。アクセス者の時間帯を見て出力しているようです。
 4. __キャプション__  
 投稿内容の説明など、投稿の主要な情報です。表示は「show all」で途中から隠れていますがHTML上では全テキストが最初から表示されていました。
 5. __投稿者アイコン__  
 このアイコン画像にマウスカーソルを合わせると、` _投稿者名_ のプロフィール `のようにフルネームが表示されます。imgタグのALT属性ですね。下に投稿者名の欄がありますがそちらはサムネイルページと同様に10文字程度で"..."のように切れていました。
 6. __再生回数__  
 サムネイルページでは、1000を超えると1.1K、100万をこえると1.1Mのように表示されていましたが、こちらでは細かい数字が読めます。
 7. __お気に入り回数__  
 再生回数と同様です。

上記の他、投稿者アイコン画像のurlには__ユーザーのユニークID__が含まれていました。良く名前を変える投稿者が居るため、取得しておきます。

#インストール
大体情報がみえたので、実行環境を用意します。

- [Python3.7](https://www.python.jp/install/windows/index.html)
ライブラリ `urllib` `beautifulsoup4` `sqlite3`

```console
D:\script> pip install urllib
D:\script> pip install beautifulsoup4
D:\script> pip install sqlite3
```
- [DBbrowser(SQLite3用)](https://sqlitebrowser.org/)  
調査結果を参考にDBbrowserで次のようにテーブルを設計して"hogeimages.db"というファイル名で保存しました。  
![Qiita説明用３-DBスキーマ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/223386/fce29401-563d-4338-9a4e-4b3ed9d47753.png)

##初期設定
```py
from urllib.request import urlopen
from bs4 import BeautifulSoup
import ssl
import sqlite3,sys,os,json,re,datetime,time

#SQLite3のDB読み込み。
conn = sqlite3.connect("hogeimages.db")
conn.text_factory = str
csr = conn.cursor()
```

##HTML取得
HTMLをBeautifulSoupでパースします

```py
headers = {
        "User-Agent": "Mozilla/今風のユーザーエージェント文字列をググレ",
        }

#メイン処理
for page in range(0, 10):
    #サムネイルURLの指定
    url = "https://fushiginachikara.de/shinukotoninaru/images?page=" + str(page)
    request = urllib.request.Request(url=url, headers=headers)
    try:
        html = urlopen(request)
    except urllib.error.HTTPError:
        #エラー処理などは最低限。一回待ちリトライし、だめならエラーを吐くに任せる。
        print('page: '+ str(page) +' '+ url + '---HTTPERROR--- sleep 300sec...')
        time.sleep(300)
        html = urlopen(request)

    #取得したHTMLを、BeautifulSoupでパース
    bsThumbsPage = BeautifulSoup(html, "html.parser")
```

##サムネイルページから情報抽出
`BeautifulSoup4`の機能を使い、HTMLタグ名・含まれるCSSのクラスやIDをターゲットとして抽出します。
ブラウザで対象ページのソース・F12のインスペクタをみて抽出タグ・クラスを考えます。

```py
    # タイトル、ユーザ名リスト取得
    # サムネイルページのように、同じクラスで複数件の情報がある場合は「findAll」でリストとして取得できます。
    titles  = bsThumbsPage.findAll("h3", {"class":"title"})
    users   = bsThumbsPage.findAll("a", {"class":"username"})
    #サムネの閲覧データ部取得
    dat_bgs    = bsThumbsPage.findAll("div", {"class":"dat-bg"})

    #タイトル数とユーザID数と一致していれば各ページ取得
    #壊れたデータを取得した場合を考え、エラーや個数の異常がある場合は抽出をスキップします。
    if len(titles) == len(users): 
        for i in range(len(titles)):
            usernameTb    = users[i].string
            viewsTb  = dat_bgs[i].find("div", {"class":"left-dat views-icon"}).text
            #抽出テキストからさらに詳細なデータを正規表現で抽出する場合は"re"などを利用します。
            viewsTb = re.sub('[\t\n ]','',viewsTb)
            favTb    = dat_bgs[i].find("div", {"class":"right-dat likes-icon"})
            imagehash   = titles[i].a.get('href').replace('/images/', '').split('?',1)[0]
            imageurl    = 'https://fushiginachikara.de/shinukotoninaru/images/' + titles[i].a.get('href')
            #飛び先URLは末尾に各種の引継ぎ情報を付与されている場合があり、不要なら削除します。
            userUrl = 'https://fushiginachikara.de/shinukotoninaru/' + users[i].get('href').split('?',1)[0]
```
##投稿内容詳細の取得を行うか判定
DBファイルに保存されているデータと照合します。

```py
            #取得済hashのチェック
            csr.execute('select imgurl,status,fav,views,username,title from imageinfo where imgurl = ' + "'" + imghash + "'" )
            dbdat = csr.fetchone()
            if dbdat is not None :
                #取得済の場合、投稿内容詳細ページを取得します。詳細は略。

```
##投稿内容詳細ページから情報抽出  
サムネイルページと大体同じ感じなので省略。書くのがめんどくなってきた。

##取得データの保存
`urllib`でHTML取得した際、投稿日時はUTCでした。ここでは`datetime`で決め打ち+9時間しています。

```py
            #更新時刻
            lastupdate = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')

            #行リストROWデータ作成
            #key    = ('imgurl','status','userid','username','userurl','title','postdate','caption','lastupdate')
            row     = (imgurl,status,fav,views,userid,username,userUrl,title,postdate,caption,lastupdate)
            #カラム名をキーにしてデータリストを辞書形式にする data=dict(zip(keys,row))
            d=dict(zip(keys,row))
            #一括更新
            csr.execute('insert into videoinfo (imgurl,status,fav,views,userid,username,userurl,title,postdate,caption,lastupdate) values(:imgurl, :status, :fav, :views, :userid, :username, :userurl, :title, datetime(:postdate, "+9 hours"), :caption, :lastupdate)' , d)
        conn.commit()
```

##取得データの活用
DBに残すことで、SQLを使い様々なデータ活用が可能です。具体的には…

- 取得と同時に未チェックの投稿URLを一覧抽出、ブラウザのアドオンから開いて確認
- 削除された投稿の検知、あとから非公開解除された投稿の自動検知
- タイトル変更、ユーザー名変更の自動検知
- 再生回数、お気に入り数から人気投稿を自動抽出
- 気になるキーワードやキャプションを含む投稿を抽出

などです。

##ちょっと困ったポイントと対応
- ユーザー名表示の文字数が制限で変わったりする  
⇒関連ページのHTMLテキストを舐めて確実に取れそうな情報元を選ぶ
- 各種項目内容NULLで投稿した場合にHTMLのタグやクラスが生成されない  
⇒常に取得文字列is NULL判定を入れよう

##まとめ
このスクリプトを書いたおかげで毎日1時間の作業がなくなり、
見たくない投稿者の投稿を間違えて開くこともなくなり、
無駄なタブ開きによる投稿サイトへのアクセス負荷も減りました。
（個人の感想です）

後にRaspbian busterのlinux上に移植しましたが
sqliteのファイル読込パスの書き方を修正するだけで動作しました。pythonすごーい。

参考になれば幸いです。