Help us understand the problem. What is going on with this article?

乃木坂46公式ブログのスクレイピング~2011年からの全ての記事をDBに収めて画像も保存してやる!~

1年ちょっとの間スクレイピングエンジニアのアルバイトをしている大学院生です。
今回はタイトルの通り、乃木坂46が結成された2011年から2019年現在の、卒業メンバーを除くすべてのメンバーのブログ記事をスクレピングしてDBに格納するコードを書きました。乃木坂のブログをスクレイピングしている記事は何件かありましたが、Rubyで書いてる人や、DB保存までしている、2011年まで遡ってスクレイピングしてる記事は珍しいかなと思い投稿します。

open-uriだけでやります!

環境はWSLのUbuntu18.04で、Rubyとmysqlのバージョンは以下の通りです。

$ ruby --version
ruby 2.4.3p205 (2017-12-14 revision 61247) [x86_64-linux]
$ mysql --version
mysql  Ver 14.14 Distrib 5.7.23, for Linux (x86_64) using  EditLine wrapper

githubにコードをあげときます。

ディレクトリ構成

ディレクトリ構造
/nogi_scraping
  /img
    /saitoasuka/
    /shiraishimai/
    /hayakawaseira/
    ...(画像取得したメンバー毎のディレクトリ)
  # 以下はスクリプトファイル
  blog_crawler.rb  
  common.rb 
  error_img.log - 画像のダウンロードに失敗した場合にそのurlを保存しておくlogファイル
  image_crawler.rb  
  nogi_table.sql 
  nogizaka_crawler.rb

乃木坂メンバーのプロフィールを公式サイトとwikiからスクレイピング

まず、メンバーのプロフィールをスクレイピングしました。プロフィールもスクレイピングする理由としては、後でブログ記事のテーブルとjoinすることで、何期生のブログがボリュームがあるとか、色々分析できるからです。(スクレイピングしたかっただけ)

テーブル定義

公式サイトのメンバー紹介に載ってる情報と、出身地も欲しいなと思ったのでそれらを格納するテーブル

nogi_table.sql
CREATE TABLE `members` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `name` varchar(255) DEFAULT NULL,
  `name_english` text,
  `img_url` text CHARACTER SET utf8,
  `birthday` text,
  `blood_type` text,
  `twelve_signs` text,
  `height` int(11) DEFAULT NULL,
  `term` int(11) DEFAULT NULL,
  `birthplace` text,
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '作成日時',
  `updated_at` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新日時',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=44 DEFAULT CHARSET=utf8mb4

mysql接続とモデル定義

common.rb
require "active_record"
require 'open-uri'
require 'nokogiri'
require "logger"
require 'pp'

dbname = 'nogizaka'
host = '127.0.0.1'
user = 'root'
pass = ''
ActiveRecord::Base.logger = Logger.new(STDOUT)
# DB接続処理
ActiveRecord::Base.establish_connection(
  :adapter  => 'mysql2',
  :database => dbname,
  :host     => host,
  :username => user,
  :password => pass,
  :encoding => 'utf8mb4',
  :charset  => 'utf8mb4'
)
# DBのタイムゾーン設定
Time.zone_default =  Time.find_zone! 'Tokyo' # config.time_zone
ActiveRecord::Base.default_timezone = :local # config.active_record.default_timezone

class Member < ActiveRecord::Base; end
class BlogArticle < ActiveRecord::Base; end

公式とwikiのスクレイピング

nogizaka_crawler.rb
require_relative 'common.rb'

@ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36"

def parse_top_page
  members_url = 'http://www.nogizaka46.com/member/'
  member_detail_urls = []
  sleep 1
  html = open(members_url,"user-agent"=>@ua).read
  doc = Nokogiri::HTML::parse(html, nil, 'utf-8')
  doc.at_css('div#memberlist').css('div.unit').each do |member_unit|
    member_detail_urls << URI::join(members_url,member_unit.at_css('a').attribute('href').value).to_s
  end
  return member_detail_urls
end

def parse_detail_page(url)
  sleep 1
  html = open(url,"user-agent"=>@ua).read
  doc = Nokogiri::HTML::parse(html, nil, 'utf-8')
  detail_ele = doc.at_css('div#profile')
  detail_txt = detail_ele.at_css('div.txt')
  name_kana = detail_txt.at_css('h2').at_css('span').text
  name = detail_txt.at_css('h2').text[name_kana.size..-1]
  member_hash = {
    'name' => name.gsub(' ',''),
    'name_english' => url.split('/')[-1].split('.')[0],
    'img_url' => detail_ele.at_css('img').attribute('src').value,
    'birthday' => detail_txt.css('dd')[0].text,
    'blood_type' => detail_txt.css('dd')[1].text,
    'twelve_signs' => detail_txt.css('dd')[2].text,
    'height' => detail_txt.css('dd')[3].text,
    'term' => detail_txt.at_css('div.status').css('div')[0].text
  }
end

def parse_wiki
  wiki_url = "https://ja.wikipedia.org/wiki/%E4%B9%83%E6%9C%A8%E5%9D%8246"
  html = open(wiki_url,"user-agent"=>@ua).read
  doc = Nokogiri::HTML::parse(html, nil, 'utf-8')
  member_birthplace = {}
  first_table = doc.at_css('table.wikitable')
  first_table.css('tr')[1..-1].each do |row|
    member_birthplace[row.css('td')[0].text] = row.css('td')[3].text
  end
  yonki_table = doc.css('table.wikitable')[1]
  yonki_table.css('tr')[1..-1].each do |row|
    member_birthplace[row.css('td')[0].text] = row.css('td')[3].text
  end
  return member_birthplace
end

member_detail_urls = parse_top_page
birth_place_hash = parse_wiki
member_detail_urls.each do |url|
  member_hash = parse_detail_page(url)
  member_hash['birthplace'] = birth_place_hash[member_hash['name']]
  Member.create(member_hash)
end

やっている流れは、まずはメンバー紹介のトップページにアクセスし、それぞれのメンバーのプロフィールページにを取得。それらひとつずつにアクセスして各メンバーのプロフィールをスクレイピングしてハッシュに格納しています。それとは別に{'メンバー名'=>'出身地'}のハッシュをwikiから作っておき、ハッシュを結合してDBに格納しています。

できたテーブルはこんな感じ。chrome mysql adminの画像直貼りで失礼します。(macユーザーのSequel Proが羨ましい今日この頃)
members_table.png

公式ブログのスクレイピング

テーブル定義

nogi_table.sql
CREATE TABLE `blog_articles` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `member_id` varchar(255) DEFAULT NULL,
  `member_name` text,
  `title` text,
  `uploded_at` datetime DEFAULT NULL,
  `day_of_the_week` text CHARACTER SET utf8,
  `body` longtext,
  `image_urls` text CHARACTER SET utf8,
  `comment_cnt` int(11) DEFAULT NULL,
  `comment_url` text CHARACTER SET utf8,
  `page_url` text CHARACTER SET utf8,
  `html` longtext,
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '作成日時',
  `updated_at` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新日時',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=12048 DEFAULT CHARSET=utf8mb4

一応htmlのカラムが用意してありますが重くなるし今回はスクレイピングしてません。

全メンバー全記事一気にスクレイピング!

4期生はもちろん、ついでに運営ブログ、研究生、3期生ブログもスクレイピングしています。

blog_crawler.rb
require_relative 'common.rb'
require 'time'
require 'date'

@ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36"

def get_each_member_top_url
  blog_top_url = "http://blog.nogizaka46.com/"
  sleep 1
  html = open(blog_top_url,"user-agent"=>@ua).read
  doc = Nokogiri::HTML::parse(html, nil, 'utf-8')
  each_member_top_url = []
  doc.at_css('div#sidemember').css('a').each do |a|
    each_member_top_url << URI::join(blog_top_url,a.attribute('href').value).to_s
  end
  return each_member_top_url
end

def get_blog_articles(url)
  sleep 2
  html = open(url,"user-agent"=>@ua).read
  doc = Nokogiri::HTML::parse(html, nil, 'utf-8')
  return false unless doc.at_css('.twitter-share-button').attribute('data-url').value.include?('?')
  loop do
    title_eles = doc.css('h1.clearfix')
    body_eles = doc.css('div.entrybody')
    entrybottom_eles = doc.css('div.entrybottom')
    entry_cnt = title_eles.size
    for i in 0...entry_cnt
      blog_hash = create_blog_hash(title_eles[i], body_eles[i], entrybottom_eles[i])
      insert_blog_hash(blog_hash)
    end
    break if doc.at_css('div.paginate').nil? || doc.at_css('div.paginate').at_css(":contains('>')").nil?
    next_url = url + '&' + doc.at_css('div.paginate').at_css(":contains('>')").attribute('href').value[1..3]
    sleep 2
    html = open(next_url,"user-agent"=>@ua).read
    doc = Nokogiri::HTML::parse(html, nil, 'utf-8')
  end
end

def create_blog_hash(title_ele, body_ele, entrybottom_ele)
  img_urls = []
  body_ele.css('img').each do |img_ele|
    img_urls << img_ele.attribute('src')&.value
  end
  img_urls = img_urls.join(" ")
  blog_hash = {
    'title' => title_ele.at_css('span.entrytitle').text,
    'member_name' => title_ele.at_css('span.author').text,
    'page_url' => title_ele.at_css('a').attribute('href').value,
    'day_of_the_week' => title_ele.at_css('span.dd2').text,
    'body' => body_ele.text,
    'image_urls' => img_urls,
    'uploded_at' => Time.parse(entrybottom_ele.at_css('text()').text),
    'comment_url' => entrybottom_ele.css('a')[1].attribute('href').value,
    'comment_cnt' => entrybottom_ele.css('a')[1].text.delete("^0-9").to_i
  }
end

def insert_blog_hash(blog_hash)
  case blog_hash['member_name']
  when '4期生','3期生','研究生' then
    Member.all.each do |member|
      next unless blog_hash['title'].include?(member['name'])
      blog_hash['member_name'] = member['name']
      blog_hash['member_id'] = member['id']
    end
  when '運営スタッフ' then
    blog_hash['member_id'] = 100
  else
    begin
      member_record = Member.find_by(name: blog_hash['member_name'])
      blog_hash['member_id'] = member_record['id']
    rescue => e
      pp blog_hash['member_name']
    end
  end
  return if record_existing?(BlogArticle,blog_hash)
  BlogArticle.create(blog_hash)

end

def record_existing?(class_name,hash)
  same_record = class_name.find_by(hash)
  return same_record.present?
end


def main(month)
  each_member_top_url = get_each_member_top_url
  d = Date.today
  months = []
  loop do
    str = d.strftime("%Y%m")
    break if str == month
    break if str.to_i <= 201109
    months << str
    d = d << 1
  end

  each_member_top_url.each do |top_url|
    months.each do |month|
      url = top_url + '?d=' + month
      get_blog_articles(url)
    end
  end
end


require 'optparse'

month = '201110'
OptionParser.new do |opt|
  opt.on('-m','--month ARG','last scraping month') {|m| month = m}

  opt.parse!(ARGV)
end

main(month)

実行時に

$ ruby blog_crawler.rb -m 201911

のような感じで-mで前にクロールした日付を入れると、その月以降の記事だけ取得できます。

ブログ記事はメンバーそれぞれのブログトップページのurlに、年月とページ番号をクエリストリングとしてつけることで得られます。最初に取得する月のリストを生成。また、メンバーそれぞれのブログトップページのurlも公式のブログトップから取得。その2つのリストそれぞれをeachで回して全組み合わせをスクレイピングします。それぞれのページでは次ページが存在する間はloopして、次ページがなくなればbreakしてloopを抜けるといった作りになっています。
そして、DBに格納する時、'4期生','3期生','研究生'ならブログタイトルにメンバー名が書いてないか探して、メンバー名でmemberのテーブルに紐づけを行っています。

これですべての記事がスクレイピングできたぞ!

blog_table.png

2019年11月までで約12000件の記事がありました。そのうちかなりんの記事数はなんと1800件以上! 15%もあるのか...凄い

ブログ写真の保存

先ほどまでのブログスクレイピングで、image_urlsというカラムにブログ内画像のurlをスペース区切りで保存したのでこれを好きなメンバーを指定したら全て保存できるようにしました。

image_crawler.rb
require_relative 'common.rb'
require 'pp'
require 'open-uri'


ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36"

BlogArticle.where(member_name: ARGV[0]).find_each do |blog_record|
  member_name_english = Member.find_by(id: blog_record['member_id'])['name_english']
  dir_name = "img/#{member_name_english}"
  unless Dir.exist?(dir_name)
    Dir.mkdir(dir_name)
  end
  blog_record['image_urls'].split().each do |image_url|
    file_name = "#{dir_name}/#{image_url.split('/')[-5..-1].join('')}"
    sleep 2
    begin
      open(file_name, 'wb') do |file|
        file.puts(open(URI.parse(image_url),"user-agent"=>ua).read)
      end
    rescue => e
      open('error_img.log', 'a') do |f|
        f.puts(image_url)
      end
      pp e.message
    end
  end
end
$ ruby blog_crawler.rb 齋藤飛鳥

のようにコマンドライン引数を指定するとDBにたまってるそのメンバーの記事の画像を全てimgディレクトリ下にディレクトリを作成して保存します。絵文字とかのアイコンが一緒に取れてしまうんですが、画像サイズからはじいた方がいいですね。
これで飛鳥ちゃんの画像を取ってきたらこんな感じ!

asuka_images.png

昔は飛鳥ちゃんたくさん自撮りあげてますね。中にはこんな画像が!
2013031557961420001.jpeg

橋本奈々未は俺の嫁!!(by齋藤飛鳥)

終わりに

自分の永遠の推しである奈々未さんのブログは公式に残っていないので今回のクローラーではスクレイピングできないのが残念で仕方ないですが、皆さんも自分の推しが卒業する前の備えとしてクローラー開発してみてはいかがでしょうか。井上小百合さんが卒業発表済みですね。1期生の卒業ラッシュは続きそうだ…
コード中の説明をかなり端折ってしまっているので、分からない部分があればコメントやtwitterで聞いていただければ答えます。気軽に聞いてください。乃木オタエンジニアの絡み大歓迎!(コードの内容読まずに動かないんですけどみたいなコメントは遠慮していただけるとありがたいです。)
ここまで読んでいただきありがとうございました。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした