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することで、何期生のブログがボリュームがあるとか、色々分析できるからです。(スクレイピングしたかっただけ)
テーブル定義
公式サイトのメンバー紹介に載ってる情報と、出身地も欲しいなと思ったのでそれらを格納するテーブル
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接続とモデル定義
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のスクレイピング
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が羨ましい今日この頃)
公式ブログのスクレイピング
テーブル定義
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期生ブログもスクレイピングしています。
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のテーブルに紐づけを行っています。
これですべての記事がスクレイピングできたぞ!
2019年11月までで約12000件の記事がありました。そのうちかなりんの記事数はなんと1800件以上! 15%もあるのか...凄い
ブログ写真の保存
先ほどまでのブログスクレイピングで、image_urlsというカラムにブログ内画像のurlをスペース区切りで保存したのでこれを好きなメンバーを指定したら全て保存できるようにしました。
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ディレクトリ下にディレクトリを作成して保存します。絵文字とかのアイコンが一緒に取れてしまうんですが、画像サイズからはじいた方がいいですね。
これで飛鳥ちゃんの画像を取ってきたらこんな感じ!
昔は飛鳥ちゃんたくさん自撮りあげてますね。中にはこんな画像が!
橋本奈々未は俺の嫁!!(by齋藤飛鳥)
終わりに
自分の永遠の推しである奈々未さんのブログは公式に残っていないので今回のクローラーではスクレイピングできないのが残念で仕方ないですが、皆さんも自分の推しが卒業する前の備えとしてクローラー開発してみてはいかがでしょうか。井上小百合さんが卒業発表済みですね。1期生の卒業ラッシュは続きそうだ…
コード中の説明をかなり端折ってしまっているので、分からない部分があればコメントやtwitterで聞いていただければ答えます。気軽に聞いてください。乃木オタエンジニアの絡み大歓迎!(コードの内容読まずに動かないんですけどみたいなコメントは遠慮していただけるとありがたいです。)
ここまで読んでいただきありがとうございました。