本記事は次の連載の一部です。
No. 03-03では、カクヨムに登録されているユーザーのブックマーク情報を抽出し、小説のリストを生成します。
準備
ディレクトリ構成は次のとおりです。
.
├── .devcontainer
├── scraping_modules
│ └── get_kakuyomu_bookmarks.py
└── secret_info.json
また、secret_info.jsonに小説家にハーメルンのユーザー情報を入力してください(hameln)
{
"plattform_users":{
"narou":{
"id":"xxx",
"pass":"xxx"
},
"hameln":{
"id":"xxx",
"pass":"xxx"
},
"kakuyomu":{
"id":"xxx",
"pass":"xxx"
}
}
}
プログラム
get_kakuyomu_bookmarks.pyを幾つかのブロックで解説します。
import
importはseleniumの使うものと、json、re、datetime、localeを入れました。pprintは表示確認用です
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.select import Select
import json
import re
from datetime import datetime
import locale
locale.setlocale(locale.LC_TIME, 'ja_JP.UTF-8')
from pprint import pprint
from selenium.common.exceptions import NoSuchElementException
import time
main
このプログラムは他のプログラムのモジュールとして利用します。そのため、mainは動作確認用となっています。
mainでは、secret_infoを読み込んで、get_bookmarksを呼び出すだけです。
if __name__ == '__main__':
with open('../secret_info.json', 'r') as f:
secret_info = json.load(f)
get_bookmarks(secret_info)
get_bookmarks
get_bookmarksの流れは次のとおりです。
- カクヨムにログインする
- フォローしている小説リストのページへ行く
- 小説リストを抽出する(次のページがあったら、それも抽出する)
- 全部の小説リストを作成する
- 各小説の小説情報から、小説のタグやあらすじなどを抽出する
なろうやハーメルンとは違って、カクヨムの小説目次ページは、動的なHTMLになっています。そのためBeautifulsoupは使えないので、Seleniumを駆使して小説情報を抜きます。
def get_bookmarks(secret_info):
with webdriver.Remote(
command_executor="http://chrome:4444/wd/hub",
options=webdriver.ChromeOptions()
) as browser:
browser.implicitly_wait(3)
browser.get('https://kakuyomu.jp/login')
user_info = secret_info['plattform_users']['kakuyomu']
input_id_elem = browser.find_element(by=By.NAME, value='email_address')
input_id_elem.send_keys(user_info['id'])
input_pass_elem = browser.find_element(by=By.NAME, value='password')
input_pass_elem.send_keys(user_info['pass'])
login_button = browser.find_element(by=By.CLASS_NAME, value="ui-button-blue")
login_button.submit()
mainheader = browser.find_element(by=By.ID, value='mainHeader').text
print(f'login : {mainheader}')
browser.get('https://kakuyomu.jp/my/antenna/works/all')
novel_list = get_list(browser)
print(f'total novel : {len(novel_list)}')
for info in novel_list:
new_info = get_novel_info(browser, info['url'])
for key, value in new_info.items():
if key not in info:
info[key] = value
pprint(info, sort_dicts=False)
time.sleep(1)
return novel_list
カクヨムのHTML構造は、なろうやハーメルンと違って少し複雑で、名前付けなどもわかり易くないので、大変です。
また、この関数では次の2つの関数を呼び出します。
- get_list(browser)
- get_novel_info(browser, url)
それぞれの関数についても、解説します。
get_list
get_listは、表示されているブラウザのブックマークにある小説リストを整形してリストにいれる関数です。
また、next_pageのボタンが押せる場合は押して、ループします。
ループのブレイク条件は、同じ小説をリストに入れた場合です。
def get_list(browser):
ret = []
check = set()
looping = True
while looping:
bookmark_items = browser.find_elements(by=By.CLASS_NAME, value='widget-antennaList-workInfo.widget-antennaList-itemDefault.js-antenna-works-default-mode-view.isShown')
for item in bookmark_items:
url = item.get_attribute('href')
info = {
'url':url,
}
other_text = item.find_element(by=By.CLASS_NAME, value='widget-antennaList-event').text
if '未読' in other_text:
other_texts = other_text.split(' ')
unread_num = int(other_texts[0][2:-1])
ep_num = int(other_texts[1][3:-1])
info['siori_ep'] = ep_num - unread_num
else:
info['siori_ep'] = None
if info['url'] in check:
looping = False
break
check.add(info['url'])
ret.append(info)
print(info)
paging_elems = browser.find_elements(by=By.CLASS_NAME, value='widget-pagerNext.js-antenna-works-pager')
if len(paging_elems) > 0:
next_button_ = paging_elems[0].find_elements(by=By.TAG_NAME, value='a')
next_button_[0].click()
print('click next page')
return ret
この関数で取ってくる小説のデータは、次の2つです
- url : 小説のURL
- siori_ep : ここではNone
栞情報とurlを保存して、このurlを使ってget_novel_infoを後で呼び出します。
get_novel_info
get_novel_infoでは、urlから小説情報を抽出します。
なろうやハーメルンと違ってwebdriver(browser)の引数が必要になります。また、小説情報やあらすじなどの情報が複雑に配置されているので、コードは汚くなりました。
def get_novel_info(browser, url):
browser.get(url)
novel_id = url.split('/')[-1]
if novel_id == '':
novel_id = url.split('/')[-2]
print(novel_id)
print(url)
info = {
'url':url,
'site_type':'kakuyomu',
'site_id':novel_id,
'state':None,
'title':None,
'auther_name':None,
'auther_url':None,
'novel_update_date':None,
'siori_ep':None,
'least_ep':None,
'summary':None,
'series':None,
'series_url':None,
'tags':None,
'genre':None,
'novel_init_date':None,
'string_num':None,
'thoughts_num':None,
'review_num':None,
'bookmark_num':None,
'overall_eval_point':None,
'eval_point':None
}
auther = browser.find_element(by=By.CLASS_NAME, value='partialGiftWidgetActivityName')
info['auther_name'] = auther.text
info['auther_url'] = auther.find_element(by=By.TAG_NAME, value='a').get_attribute('href')
summary_elem = browser.find_elements(by=By.CLASS_NAME, value='CollapseTextWithKakuyomuLinks_collapseText__XSlmz')
if len(summary_elem) > 0:
summary_button = summary_elem[0].find_elements(by=By.CLASS_NAME, value='Button_button__kcHya')
if len(summary_button) > 0:
summary_button[0].click()
info['summary'] = summary_elem[0].text
tabs = browser.find_element(by=By.CLASS_NAME, value='Tab_ul__raAoj')
buttons = tabs.find_elements(by=By.TAG_NAME, value='li')
detail_button = buttons[-1]
detail_button.click()
detail_data_elem = browser.find_element(by=By.CLASS_NAME, value='WorkDetail_basicInformation__bpm_r')
dd_list = detail_data_elem.find_elements(by=By.TAG_NAME, value='dd')
dt_list = detail_data_elem.find_elements(by=By.TAG_NAME, value='dt')
dd_dict = {dt.text:dd.text for dt, dd in zip(dt_list, dd_list)}
info['title'] = dd_dict['タイトル']
info['state'] = dd_dict['執筆状況']
info['least_ep'] = dd_dict['エピソード'][:-1]
info['novel_init_date'] = datetime.strptime(dd_dict['公開日'], '%Y年%m月%d日 %H:%M')
info['novel_update_date'] = datetime.strptime(dd_dict['最終更新日'], '%Y年%m月%d日 %H:%M')
info['string_num'] = int(dd_dict['総文字数'][:-2].replace(',', ''))
detail_data_list = browser.find_elements(by=By.CLASS_NAME, value='WorkDetail_link__Z6c7y')
#detail_dict = {item.text.split('\n')[0]:item.text.split('\n')[1] for item in detail_data_list}
detail_dict = {item.text.split('\n')[0]:item.find_element(by=By.CLASS_NAME, value='WorkDetail_linkDetail__Ts3O0') for item in detail_data_list}
print(detail_dict['おすすめレビュー'])
if 'おすすめレビュー' in detail_dict:
eval_reviews = detail_dict['おすすめレビュー'].find_elements(by=By.TAG_NAME, value='span')
if len(eval_reviews) == 2:
info['review_num'] = int(eval_reviews[1].text[:-1].replace(',', ''))
info['overall_eval_point'] = int(eval_reviews[0].text[1:].replace(',', ''))
if '応援コメント' in detail_dict:
info['thoughts_num'] = int(detail_dict['応援コメント'].text[:-1].replace(',', ''))
if '小説のフォロワー' in detail_dict:
info['bookmark_num'] = int(detail_dict['小説のフォロワー'].text[:-1].replace(',', ''))
tags_list = browser.find_elements(by=By.CLASS_NAME, value='WorkDetail_buttonInner__BUHfg')
info['genre'] = tags_list[0].text
info['tags'] = [tag.text for tag in tags_list[1:]]
if 'セルフレイティング' in dd_dict:
self_rating = dd_dict['セルフレイティング']
self_rating = [text for text in self_rating.split('有り') if text != '']
info['tags'].extend(self_rating)
#pprint(info, sort_dicts=False)
return info
セルフレイティングの情報がなろうやハーメルンではタグに入っていました。ですので、ここではセルフレイティングの情報もタグに追加しました。
実行
実行してみます。main文は、同じディレクトリで実行されることを想定しているので、ディレクトリを移動します。
cd scraping_modules
python get_kakuyomu_bookmarks.py
実行すると、ログインが正しくされていれば、ユーザー名が表示されます。
その後、ブックマークの読み込みが始まります。
login : xxxさんのダッシュボード
...
{'state': '完結済', 'title': '文明崩壊後の世界を女の子をバイクの後ろに乗せて旅している', 'url': 'https://kakuyomu.jp/works/1177354054882431571', 'auther': '新木伸', 'auther_url': 'https://kakuyomu.jp/users/araki_shin', 'update_date': datetime.datetime(2017, 2, 9, 9, 44), 'siori_ep': None, 'least_ep': '33'}
{'state': '連載中', 'title': 'ひげを剃る。そして女子高生を拾う。', 'url': 'https://kakuyomu.jp/works/1177354054882739112', 'auther': 'しめさば', 'auther_url': 'https://kakuyomu.jp/users/smsb_create', 'update_date': datetime.datetime(2018, 8, 2, 15, 41), 'siori_ep': None, 'least_ep': '39'}
{'state': '連載中', 'title': 'つまり、雑念の沙鳥さん。', 'url': 'https://kakuyomu.jp/works/1177354054882979595', 'auther': '関根パン', 'auther_url': 'https://kakuyomu.jp/users/sekinepan', 'update_date': datetime.datetime(2023, 12, 25, 11, 6), 'siori_ep': None, 'least_ep': '68'}
...
すべてのブックマークが読み込み終わると、全ブックマーク数が表示されます。
total novel : 84
そして、各小説についての詳細情報の読み込みが始まります。
詳細情報を読み込むと、各小説について以下のログが流れます。
{'state': '連載中',
'title': '無双ゲーに転生したと思ったら、どうやらここはハードな鬱ゲーだったらしい',
'url': 'https://kakuyomu.jp/works/16818093074058384419',
'auther': '久路途緑',
'auther_url': 'https://kakuyomu.jp/users/kurotomidori',
'update_date': datetime.datetime(2024, 6, 10, 20, 2),
'siori_ep': None,
'least_ep': '50',
'summary': 'ゲームの世界に転生したとして、その元になったゲームのストーリーを知らなかった場合。\n'
'\n'
'勇者の死後という、魔族の支配が強まる世界で一人の外道が原作を無視して魔族狩りに執心してしまったのが、彼らの運の尽きであった。\n'
'\n'
'主人公(元男)の目的は「スキルの育成」。それには魔族の弱点である魔核こそが最もいい栄養。ならば人を狩る魔族を殺すことになんの躊躇いがあるだろうか。',
'series': None,
'series_url': None,
'init_date': datetime.datetime(2024, 4, 1, 14, 41),
'string_num': 148812,
'thoughts_num': 939,
'review_num': None,
'bookmark_num': 14416,
'overall_eval_point': None,
'eval_point': None,
'genre': '異世界ファンタジー',
'tags': ['カクヨムオンリー', 'TS主人公', '前世男、今世女', '主人公の気質はチンピラ', '主人公最強', 'チート']}
最終的には、この辞書のリストが今回得られます。
あとは、各プラットフォームの表記を合わせてからデータベースへと挿入します。