日記マン

動画広告プロダクトしてます。Go, Kubernetesが好きです。

Pythonで乃木坂46公式ブログをクローリング・スクレイピングしてCloud Storageに永続化する

tl;dr

2017年も残りわずかなので、乃木坂46のブログをスクレイピングし、Cloud Storageに保存しておきましょう。
卒業していくメンバーのブログも永遠のものとなる!!!!!優勝!!!!!!!!!!!!

注意: 勢いで書いたコードなので、例外周りとか適当です。見逃してください。

環境

  • Python 3.6.2
    • pyenv/virtualenv
  • tools
    • requests
    • BeautifulSoup
    • google.cloud.storage

Install

requirements.txt

requests
BeautifulSoup4
lxml
python-dotenv
google-cloud-storage
six
$ virtualenv -p python3 env
$ source env/bin/activate
$ pip install -r requirements.txt

サービスアカウントの準備

  • クライアントからGCPサービスを扱う際に認証が必要です
  • サービスアカウントを作成しjsonキーファイルのパスを環境変数GOOGLE_APPLICATION_CREDENTIALSに設定します

サービスアカウントの作成

必要な権限

  • ストレージのオブジェクト作成者
  • ストレージのオブジェクト管理者

注意: ストレージのオブジェクト作成者だけでは同名オブジェクトの上書きができません。 オブジェクトを上書きするには管理者の権限も必要です。

認証jsonキーをダウンロードします。

python上から環境変数を設定

import os
os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = 'KEY_FILE_PATH'

設計

バケット nogizaka 構造

  • nogizaka/
    • members.txt
    • member/
      • MEMBER_NAME/
        • detail_urls.txt
        • posts/
          • POST_BLOB

スクレイピング手順

  1. 公式ブログTOPからメンバーリストを抽出し members.txt に格納する
  2. メンバー別もしくは members.txt を基に全てのメンバーブログのスクレイピングを行う

メンバーブログのスクレイピング

  1. メンバーブログTOPから全記事のURLをクローリングする
  2. 各記事に対しスクレイピングを行う。タイトル、内容などを member/MEMBER_NAME/posts/ 配下にblobを保存する

コーディング

Cloud Storage

from google.cloud import storage
import six


def _get_storage_client():
    return storage.Client(
        project=os.environ.get('PROJECT_ID')
    )


def upload_file(file_stream, filename, content_type):
    blob = _get_blob(filename)

    blob.upload_from_string(
        file_stream,
        content_type=content_type)

    url = blob.public_url

    if isinstance(url, six.binary_type):
        url = url.decode('utf-8')

    return url


def download_string(filename):
    blob = _get_blob(filename)
    source = blob.download_as_string().decode()
    return source


def is_exists_file(filename):
    blob = _get_blob(filename)

    return blob.exists()


def _get_blob(filename):
    client = _get_storage_client()
    bucket = client.bucket(os.environ.get('CLOUD_STORAGE_BUCKET'))
    blob = bucket.blob(filename)

    if blob is None:
        from google.cloud.storage import Blob
        blob = Blob(filename, bucket)

    return blob


def read_lines(path):
    """
    ストレージからダウンロードしたファイルを行ごとにの要素で配列にする
    """
    source = download_string(path)
    rows = source.split('\n')
    return rows

これを storage.py として使うことにします。

スクレイピング部分

import requests
from bs4 import BeatulfulSoup

from . import storage

class Blog(object):
    """
    乃木坂公式ブログをスクレイピングする
    """


    URL_PREFIX = 'http://blog.nogizaka46.com/'
    HEADERS = {
        'User-Agent': 'HogeHoge',
    }


    @staticmethod
    def create_members_list(path=None):
        """
        全メンバー情報を更新し, 全記事に対してスクレイピングを行い結果をストレージに保存する
        """
        if path is None:
            path = 'members.txt'

        res = requests.get(Blog.URL_PREFIX, headers=Blog.HEADERS)
        if res.status_code != 200:
            return
        soup = BeautifulSoup(res.text, 'lxml')
        unit_tags = soup.find(attrs={'id': 'sidemember'}).findAll(attrs={'class': 'unit'})
        members = [unit_tag.find('a').get('href').rsplit('/', 1)[1] for unit_tag in unit_tags]
        raw = '\n'.join(members)
        return storage.upload_file(raw, path, 'text/plain')

Blog.create_members_list()公式ブログTOPからメンバーリストを作成し、 Cloud Storageにアップロードします。

members.txt はこんな感じになります。

manatsu.akimoto
erika.ikuta
rina.ikoma
karin.itou
junna.itou
marika.ito
sayuri.inoue
misa.eto
hina.kawago
mahiro.kawamura
hinako.kitano
asuka.saito
chiharu.saito
yuuri.saito
iori.sagara
reika.sakurai
kotoko.sasaki
mai.shiraishi
mai.shinuchi
ayane.suzuki
kazumi.takayama
ranze.terada
kana.nakada
himeka.nakamoto
nanase.nishino
ami.noujo
hina.higuchi
minami.hoshino
miona.hori
sayuri.matsumura
rena.yamazaki
yumi.wakatsuki
miria.watanabe
maaya.wada
third

メンバー別にスクレイピング

members.txt にあるメンバー全員分の全記事をスクレイピングするのはきついので、
個人別に操作が効くような設計にしています。

    def __init__(self, base_url=None, member=None, replace=False):
        if base_url is None and member is not None:
            self.member = member
            self.base_url = Blog.URL_PREFIX + self.member
        elif base_url is not None and member is None:
            self.base_url = base_url
            self.member = self.base_url.rsplit('/', 1)[1]
        elif base_url is None and member is None:
            print('error')
            return
        else:
            self.base_url = base_url
            self.member = member
        self.replace = replace
        self.headers = Blog.HEADERS
        self.detail_urls = []
        self.detail_list_url = 'member/' + self.member + '/detail_urls.txt'
        res = requests.get(self.base_url, headers=self.headers)
        if res.status_code != 200:
            raise Exception()

replace プロパティはBooleanで、False であれば既にスクレイピングし永続化し終わった記事に対してはスルーをします。

    def run_crawling_urls_by_member(self):
        """
        メンバーの全記事のURL情報を更新しストレージに保存する
        """
        self.crawl_urls()
        return self.upload_detail_urls(self.detail_list_url)


    def crawl_urls(self):
        """
        全記事のURLをdetail_urlsに格納する
        """
        month_urls = self._get_month_urls(self.base_url)
        for month in month_urls:
            self._add_detail_link(month)


    def _get_month_urls(self, base_url):
        res = requests.get(base_url, headers=self.headers)
        if res.status_code != 200:
            return
        soup = BeautifulSoup(res.text, 'lxml')
        month_tags = soup.find(attrs={'id': 'sidearchives'}).find('select').findAll('option')
        month_urls = [option['value'] for option in month_tags[1:]]
        return month_urls


    def _add_detail_link(self, url):
        res = requests.get(url, headers=self.headers)
        if res.status_code != 200:
            return
        soup = BeautifulSoup(res.text, 'lxml')
        day_table = soup.find(id='daytable')
        detail_urls = [detail_link.get('href') for detail_link in day_table.find_all('a')]
        self.detail_urls.extend(detail_urls)


    def upload_detail_urls(self, dst_filename):
        """
        detail_urls情報をストレージに保存する
        """
        raw = '\n'.join(self.detail_urls)
        return storage.upload_file(raw, dst_filename, 'text/plain')
  1. メンバーブログの月別リスト(id=sidearchives)から月別にURLを取得します。
  2. 日付テーブル(id=daytable)から日別にURLを取得し、detail_urls プロパティへ格納していきます。
  3. 記事URL情報をCloud Storageに保存します。

member/nanase.nishino/detail_urls.txt
f:id:i101330:20180101001128p:plain

    def run_scraping(self):
        """
        メンバーブログの全記事に対してスクレイピングを行い結果をストレージに保存する
        """
        if not storage.is_exists_file(self.detail_list_url):
            self.run_crawling_urls_by_member()

        detail_urls = storage.read_lines(self.detail_list_url)
        for detail_url in detail_urls:
            file_name = 'member/' + self.member + '/post/' + detail_url.rsplit('/', 1)[1]
            self.upload_post_detail(detail_url, file_name)


    def upload_post_detail(self, url, dst_filename):
        """
        author, title, contentをJson形式でストレージに格納する
        """

        if self.replace is False:
            exist = storage.is_exists_file(dst_filename)
            if exist:
                return

        res = requests.get(url, headers=self.headers)
        if res.status_code != 200:
            return

        soup = BeautifulSoup(res.text, 'lxml')
        author = soup.find(class_='author').text
        title = soup.find(class_='entrytitle').text
        content = soup.find(class_='entrybody')
        output = json.dumps({
            'postUrl': url,
            'author': author,
            'title': title,
            'content': content.prettify(),
        }, ensure_ascii=False)

        return storage.upload_file(
            output,
            dst_filename,
            'application/json',
        )

スクレイピング対象の記事URLとともに、投稿者名、記事タイトル、記事内容をJson形式でblobに保存します。

結果

例えば西野七瀬が2017年7月29日に投稿したブログは
gs://BUCKET_NAME/member/nanase.nishino/post/?d=20120729 に格納され、
中身はこんな感じです。

{
    "postUrl": "http://blog.nogizaka46.com/nanase.nishino/?d=20120729", 
    "author": "西野七瀬",
     "title": " ホームゲートに手を引き込まれないよう",
     "content": "<div class=\"entrybody\">\n <div>\n </div>\n <div>\n  ん~~~~~!ななせまる!!\n </div>\n <div>\n </div>\n <div>\n </div>\n <div>\n  今日は新技術イベントなり\n </div>\n <div>\n  起こしの方は楽しみにしててくださいね\n </div>\n <div>\n  そして乃木どこもぜひ見てください。\n </div>\n <div>\n </div>\n <div>\n </div>\n <div>\n  <img src=\"http://img.nogizaka46.com/blog/photos/entry/2012/07/29/3049950/0000.jpeg\" width=\"240\"/>\n </div>\n <div>\n  ネイル!\n </div>\n <div>\n </div>\n <div>\n </div>\n <div>\n  昨日の夜ぜんぜん寝られへんくて、なんかアプリ探したり考えごとしてました\n </div>\n <div>\n </div>\n <div>\n  そのなかのひとつは歯について!\n </div>\n <div>\n  そういえば小さい頃って歯抜けてたなーって思って\n </div>\n <div>\n  わたしははじめ、ぐらぐらしてきて、そのうち糸一本だけで繋がってる状態になって、それがめっちゃ気になるからベロで遊んでました( ・´ー・`)\n </div>\n <div>\n  歯を一回転してみたり変な方向にねじったりして、抜けることを期待してた\n </div>\n <div>\n  鏡をみながら歯をひっぱっても抜けへんから、諦めてご飯食べてたら、ブチっと歯がとれたことがあった\n </div>\n <div>\n  上の歯はトイレに流す\n </div>\n <div>\n  下の歯は屋根の上に投げる\n </div>\n <div>\n  これが西野家流やねん( ・´ー・`)\n </div>\n <div>\n </div>\n <div>\n  歯が抜けてから処理するまでは引き出しに保管してたんやけど\n </div>\n <div>\n  なんか\"抜けた歯\"が好きで何回も取り出してはいろんな方向から歯をまじまじ見てました\n </div>\n <div>\n  それで昨日、「あ!なな抜けた歯が好きやったんか」って思い出したのさ\n </div>\n <div>\n </div>\n <div>\n  親知らずを歯医者さんで抜いたときも\n </div>\n <div>\n  銀色のプレートに転がってる、すこし血のついた歯をみると「yeah!」って感じでしたな( ・´ー・`)ゼア\n </div>\n <div>\n </div>\n <div>\n  歯について語りすぎた?故に口の中がかゆくなってきた...\n </div>\n <div>\n </div>\n <div>\n  抜けた歯が好きな人\n </div>\n <div>\n  手あーげて!\n </div>\n <div>\n </div>\n <div>\n </div>\n <div>\n  <img src=\"http://img.nogizaka46.com/blog/photos/entry/2012/07/29/3049950/0001.jpeg\" width=\"241\"/>\n </div>\n <div>\n  浪漫より\n </div>\n <div>\n </div>\n <div>\n  バイッッッ\n </div>\n <div>\n </div>\n</div>\n"
}

はい可愛い!!!!!!!!!優勝!!!!!!!!!!!!!!

また、卒業するため近日にブログが閉鎖してしまう中元日芽香伊藤万理華のブログもこんな感じで無事確保できました。

f:id:i101330:20171231231004p:plain