エロゲ価格 スクレイピング

はじめに

お久しぶりです、ピクおうじです。
リアル多忙のため記事の投稿が遅くなってしまいました。
今回はエロゲーマーなら1回は思ったことがあるであろう「FANZAやDLsiteの価格を自動で取得できないかなぁ」というのを叶えたいと思います!
2022/8/2追記
プログラムの改良版を公開しました。こちらからどうぞ。
https://note.com/pikuouji/n/n51d17363353d

注意書き

・スクレイピングによって何かしらの不利益を受けたりしても当サイトは責任を負わないものとします。くれぐれも自己責任でお願いします。

・この記事は2022/7/26に作成されたものです。FANZA様やDLsite様がサイトの仕様変更を行いプログラムが使用できなくなっても、当サイトは責任を負わないとします。

・プログラムが正常に動作しない場合はTwitterのDMにて質問してください。
@pikuouji_erogeにて受け付けます。(フォロワー限定)
但し、確実に動く保証はありませんのでご了承ください。

・以下で配布するスプレッドシート、Pythonのプログラムの二次配布を有償・無償問わず禁止します。それぞれの著作権はピクおうじに帰属します。
発見した場合は何かしらの形で警告、または削除要請をさせて頂きます。

では注意書きも済んだところで、今回使用するものはGoogleスプレッドシートとGoogle Colaboratoryです!
以下Googleスプレッドシートをスプシ、Google ColaboratoryをColabと略します。

スプシについて

以下のファイルをダウンロードして新規でスプシを作成しファイル→インポート→アップロードで、以下のxlsxファイルをアップロードしてください。
それぞれで作成しても構いませんが、Pythonに詳しくない人は以下のファイルをダウンロードしたほうがそのまま使えるのでおすすめです!
というのも下に行ったら分かりますが、スプレッドシートの値とプログラムを関連付けて記述していますので、以下のファイル以外のものを使用するとPythonのプログラムを一部書き直す必要性が出てきます。
例として約60作品ほど記入していますので、既に持っている作品だったり不要な場合は消してもらって構いません。
また、スプシはセルを適当に移動させさえしなければ値の色変更やテキストの折り返し設定等しても影響が出ないようになっています。
ついでにおまけ機能も色々つけてたりします。

Colabについて

導入につきましては調べたらたくさんの記事が出てきますのでここでは省略します。

ではここからColabを導入できた前提で話を進めていきます。
Colabを新規で作成してもらい、左上のファイル→ノートブックを開く→アップロードを押してもらい、そこで以下のファイルをアップロードしてください。
それぞれFANZA用、DLsite用に調整してありますので別々に作成してください。
どちらも通常価格ならその価格、セール価格ならその価格を取得するようにしています。(つまり最安値を取得できる)
ここで1つ注意点。FANZA用に関してですが、メーカーが出しているR18ゲーム以外には価格取得が出来ません。例えば「星空鉄道とシロの旅」のような同人で尚且つ全年齢の作品とかだとサイトの仕様が違うのでスクレイピングをパスするようにプログラムを組んであります。ご了承ください。

ではプログラムのセットアップについて説明します。
プログラムを使用できるようにするためには

url = "ここにスプレッドシートのURLを挿入"

この部分に使用するスプシのURLをコピペで貼り付けてください。(決してダブルクォーテーションを消さないでください。)
FANZA用、DLsite用どちらにも結構上の方に記述してあります。(見つからない場合はCtrl+Fで検索してください。)
そして上からセルを順番に実行することでプログラムが動き出します。
また1つ目のセル実行時に以下のようなポップアップが表示された場合は許可を押して、そしてアカウント選択後また許可を押してください。
そしたら正常に動くはずです。

Python 許可画面

ここまでざっくりと説明しましたが、以上の設定が出来れば正常にプログラムが動きます。プログラムを実行してスプシに価格が書き込まれていれば問題ありません。
ここから先は更に詳しい解説になりますので、正常に動作してプログラムとかには特に興味が無い方とはここでお別れです。ここまで読んでいただきありがとうございました!


詳しい解説(スプシ)

では詳しい解説を始めさせていただきます!
まずスプシの方から

一番左上の価格更新日ですが、concatenate関数を使用して色々結合しています。そして更新日ですが、下の「日付入力」のシートから更新できるようになっています。日付が記入している場所を左クリックでダブルクリックするとカレンダーが表示され、選択した日付が反映されるようになっています。
毎回記述してもいいんですが、めんどくさいんでこのような形にしています。

次に、「批評空間 中央値」の列にimportxml関数を仕込んでいます。
これは「批評空間 URL」の列を記入すると自動的に中央値をエロゲ批評空間のサイトから取得してくれます。
ここで勘の良い読者はこう思ったはずです。「importxml関数でFANZAやDLsiteの価格も取ってこれるやろ!」
筆者も最初はそう思いましたが、1つ落とし穴があったんですね。
それがこいつ

年齢認証ページ FANZA

はい、皆さん一度は見たことのある年齢認証のページです。(俗に言うクッションページ)
こいつのせいでimportxml関数が使用できず、なくなくPythonでプログラムを書くはめになったわけですね。
これに関しての説明はあとでPythonの説明の時に詳しく書きます。

そして「一番安い店舗」の列ですが、if関数とjoin関数とfilter関数を使用しています。
例として配布しているスプシのF3セルの中身を持ってきます。

=if(G3=0, "",join(",",FILTER($H$1:$O$1,H3:O3=G3)))

これ何をしているかというと、G列でまずmin関数を使用してその行の一番小さい数字を取ってきます。
そしてその値が0、つまりまだ何も入っていない場合は値を何も入れない状態にします。
そして何かしら値が入ったらその部分に対応する店舗を取ってきますが、ここで同じ価格が複数あった場合filter関数だけだとバグるので、join関数で繋げて表示させています。

そして「安い順」のシートに移動しますが、これ関数自体はA2セルにしか入っていないです。
データベース言語を扱ったことがある人なら分かると思いますが、これクエリ文です。
「メイン」のシートのA3からG1000(大本のシートはA3:Gと記述していますが、変換する過程で勝手に変更された模様。まあ1000行も埋める人なんていないと思うので特に何も言ってないです)のデータを全て取得してきて、空白の行を削除し、価格を安い順で表示させています。価格が同じ場合は批評空間の中央値が高い順に表示させています。
そして、そもそもデータが無い場合はiferrorで「条件に一致する作品がありませんでした。」と表示させています。

=iferror(query('メイン'!A3:G1000,"select * where G is not null order by G asc, E desc"),"条件に一致する作品がありませんでした。")

シート「90点以上」「80~89点」「70~79点」「69点以下」も同じくクエリ文で記述しています。
上の関数の記述とは多少違いますが、本質は同じです。


詳しい解説(Python,FANZA)

ではそろそろPythonの方の説明に移ります。
FANZA用、DLsite用どちらも最初のセルは全く同じです。

# 認証のためのコード
from google.colab import auth
auth.authenticate_user()

import gspread
from google.auth import default
creds, _ = default()

gc = gspread.authorize(creds)

この1つ目のセルのプログラムはColab上でスプシを使用できるようにするためのおまじないコードのようなものなので特に気にしなくていいです。詳しく知りたい人は適当にググってください('ω')

ではFANZA用の方から説明します。
ここからはPython、もしくはプログラミング言語についてある程度理解がある前提で話していきますので、ご了承ください。

import requests
from bs4 import BeautifulSoup
from urllib import request

url = "ここにスプシのURLを挿入"

ss = gc.open_by_url(url)

st = ss.get_worksheet(0)
list_of_lists = st.get_all_values()
rangelist = len(list_of_lists)

listURL = []

list_of_dicts = st.get_all_records()
for r in list_of_dicts:
  r = r["FANZAリンク"]
  listURL.append(r)

for x in range (0, rangelist - 1):
  print('進行率','{:.2%}'.format(x / (rangelist - 2)))
  if listURL[x] == "":
    print('-----')
  else:
    print(listURL[x])

    # スクレイピング
    # URLの指定
    url = listURL[x]
    #ユーザーエージェントの設定(設定必須)
    headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36"}
    # ここでcookieを指定
    cookie = {'age_check_done': '1'}  #クッキーの指定
    #htmlの取得
    response = requests.get(url=url, headers=headers, cookies=cookie)
    html = response.content
    #BeautifulSoupで扱えるようにパースします
    soup = BeautifulSoup(html, "html.parser")

    #要素取得
    a = soup.find(class_='campaign-price red')
    b = soup.find(class_='normal-price-discount')
    c = soup.find(class_='normal-price red')

    listG = []

    #リストに値が何も入ってない場合
    if a == None and b == None and c == None:
      print('-----')
    else:
      listA = [a,b,c]
      for i in listA:
        if i != None:
          i = i.text
          i = i.replace('円','').replace(',','')          
          i = int(i)
          listG.append(i)

      print(listG)
      print(min(listG))
      z = 'H' + str(x + 2)
      print(z)
      print('-----')
      st.update_acell(z , min(listG))

最初のimportはいつものライブラリインストールです。
ではこのプログラムをさらに分けます。

url = "ここにスプシのURLを挿入"

ss = gc.open_by_url(url)

st = ss.get_worksheet(0)
list_of_lists = st.get_all_values()
rangelist = len(list_of_lists)

listURL = []

list_of_dicts = st.get_all_records()
for r in list_of_dicts:
  r = r["FANZAリンク"]
  listURL.append(r)

まずここの部分。
urlを入れて、3行目でそのスプシを開きます
5行目でシート「メイン」を選択しています。ここの0の値を変えたら他のシートも選択可能です。
6~7行目で入力されている列を取得し、値をrangelistという変数にぶち込みます。
9行目でlistURLという名前の空配列を作成しています。
そして11~14行目でFANZAリンクの列からURLを取得しlistURLの配列に追加しています。

次に本体であるスクレイピングして書き込む部分です。

for x in range (0, rangelist - 1):
  print('進行率','{:.2%}'.format(x / (rangelist - 2)))
  if listURL[x] == "":
    print('-----')
  else:
    print(listURL[x])

    # スクレイピング
    # URLの指定
    url = listURL[x]
    #ユーザーエージェントの設定(設定必須)
    headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36"}
    # ここでcookieを指定
    cookie = {'age_check_done': '1'}  #クッキーの指定
    #htmlの取得
    response = requests.get(url=url, headers=headers, cookies=cookie)
    html = response.content
    #BeautifulSoupで扱えるようにパースします
    soup = BeautifulSoup(html, "html.parser")

    #要素取得
    a = soup.find(class_='campaign-price red')
    b = soup.find(class_='normal-price-discount')
    c = soup.find(class_='normal-price red')

    listG = []

    #リストに値が何も入ってない場合
    if a == None and b == None and c == None:
      print('-----')
    else:
      listA = [a,b,c]
      for i in listA:
        if i != None:
          i = i.text
          i = i.replace('円','').replace(',','')          
          i = int(i)
          listG.append(i)

      print(listG)
      print(min(listG))
      z = 'H' + str(x + 2)
      print(z)
      print('-----')
      st.update_acell(z , min(listG))

for文で適当に回しているだけですが、一応説明していきます。
最初のfor文の条件ですがスプシの列は1から始まるのに対し、for文では0から始めているのでrangelistを-1して帳尻を合わせています。(まあfor x in range(1,rangelist):でも多分通る。試してはいないので結果は知らんw)
そしてプログラムの進捗を把握するために実行率を計算して出力しています

print('進行率','{:.2%}'.format(x / (rangelist - 2)))

この部分ですね。これ詳しくは自分もよく分かっていないんですが、-2しないと最後100%にならないんですよね。-1ではだめだった(´・ω・`)
次に先ほどぶち込んだlistURLの配列で、要素が空の場合「-----」と出力します。
値が入っている場合はスクレイピングの実行です。
まず前半の説明

# スクレイピング
# URLの指定
url = listURL[x]
#ユーザーエージェントの設定(設定必須)
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36"}
# ここでcookieを指定
cookie = {'age_check_done': '1'}  #クッキーの指定
#htmlの取得
response = requests.get(url=url, headers=headers, cookies=cookie)
html = response.content
#BeautifulSoupで扱えるようにパースします
soup = BeautifulSoup(html, "html.parser")

最初に添え字の値のURLを取得しています。
そしてユーザーエージェントの設定ですが、これ自分もよく知らないのでググってください(ぶん投げ)
んで問題のcookie指定。
年齢認証のページをcookieを保持することで回避します。
FANZAの場合はage_check_doneというcookieで年齢認証を行っているので、この値を常に1にしておくことで年齢認証のページを出さずにスクレイピング出来るという仕組みなんですね。
これさえ無ければimportxml関数で楽にスクレイピングできたのに(血涙)そしてhtmlの取得ですが、ここもなんとなーく書いたものなので詳しく知りたい方はググってください(ぶん投げ2回目)。多分出てくるはず。
そして最初にインストールしたライブラリの1つであるBeautifulSoupで扱えるようにパースしています。

#要素取得
a = soup.find(class_='campaign-price red')
b = soup.find(class_='normal-price-discount')
c = soup.find(class_='normal-price red')

listG = []

次にこの部分。class「campaign-price red」「normal-price-discount」「normal-price red」の中に価格が入っています。
これなんでsoup.find_allにしないかというと、そっちで取得した場合謎の価格が入ってきたんですねー。
9800円のはずなのにどこから出てきたのか分からない8500円とか。
なのでクラスの一番最初の値のみ取得しています。
ただ、この影響で「Making*Lovers」みたいにデジタル原画集付属セットと通常版の2種類以上あった場合最初の方の価格(デジタル原画付属セット)しか出せないという……(これ2種類以上あってもタイトルが同じならURLは変わらないんですよね……)
ここ改良できる人がもしいれば是非教えてくださいm(__)m
listGに関しては空配列作ってるだけなので割愛

if a == None and b == None and c == None:
      print('-----')
    else:
      listA = [a,b,c]
      for i in listA:
        if i != None:
          i = i.text
          i = i.replace('円','').replace(',','')          
          i = int(i)
          listG.append(i)

      print(listG)
      print(min(listG))
      z = 'H' + str(x + 2)
      print(z)
      print('-----')
      st.update_acell(z , min(listG))

そしてif文の部分。
もしa、b、cに値が何も入っていない場合は「-----」と表示、それ以外の場合は値を抽出して最安値をセルにぶち込んでいます。
いつぞやに作成した空配列のlistAにa、b、cをぶち込んで処理させています。
そしてその中の要素を更にfor文とif文でぶん回しています。
例えば[5,586円, 7,980円,””]とかの場合、値が入っているものは上から順に
・要素の文字列化
・「円」、「,」を取り除く(何もないに置換しているともいう)
・int型に変換(ここで整数にしないとこの後がおかしくなる)
・何時ぞやに作った空配列listGに値を追加
という感じにしています。そしてその値を入れるセルをz = 'H' + str(x + 2)の部分で決定しています。
ここの'H'は固定していますので、それぞれで用意したスプシを使う際にはここの値も変えないといけません。
そしてあとは、st.update_acell(z , min(listG))の部分で最小値を書き込むセルを特定して上書きという形で入れています。

長文のため結構難しく感じるかと思いますが、実際にやってることはそんなに複雑じゃないです。正直多少知識のある人なら直ぐに作れるレベルです。


詳しい解説(Python,DLsite)

ではDLsiteの方の説明です。FANZAと同じ部分は割愛して説明します。
2022/8/2追記
現在、プログラムの中身を確認したら本来想定していた挙動と別の動きをしているにも関わらず、なぜか最終的な結果は同じという事態が発生しました。よって、以下の解説は参考にしないようにお願いします。
分かり次第修正いたします。

import requests
from bs4 import BeautifulSoup
from urllib import request
import numpy as np

url = "ここにスプシのURLを挿入"


ss = gc.open_by_url(url)

st = ss.get_worksheet(0)
list_of_lists = st.get_all_values()
rangelist = len(list_of_lists)

listURL = []

list_of_dicts = st.get_all_records()
for r in list_of_dicts:
  r = r["DLsiteリンク"]
  listURL.append(r)

for x in range (0, rangelist - 1):
  print('進行率','{:.2%}'.format(x / (rangelist - 2)))
  if listURL[x] == "":
    print('-----')
  else:
    print(listURL[x])

    # スクレイピング
    # URLの指定
    url = listURL[x]
    #ユーザーエージェントの設定(設定必須)
    headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36"}
    # ここでcookieを指定
    cookie = {'adultchecked': '1'}  #クッキーの指定
    #htmlの取得
    response = requests.get(url=url, headers=headers, cookies=cookie)
    html = response.content
    #BeautifulSoupで扱えるようにパースします
    soup = BeautifulSoup(html, "html.parser")
    #表示

    listA = []
    listH = []
    listZ = []

    a = soup.find_all('span', class_='price')
    listA.append(a)

    listG = np.array(listA)
    listG = listG.flatten()
    
    listG_in_not = [s for s in listG if 'product' not in s]

    for a in listG_in_not:
      listH.append(str(a))

    listH_in_not = [s for s in listH if 'i' not in s]
    print(listH_in_not)

    print(min(listH_in_not))
    z = 'J' + str(x + 2)
    print(z)
    print('-----')
    st.update_acell(z , min(listH_in_not))

まず最初のインポートするライブラリが1個増えています。
もうPythonを使っている人なら見飽きたであろう「import numpy as np」です。
このプログラム、仕様上どうしても途中で2次元配列になるので1次元に戻すために使用します。

a = soup.find_all('span', class_='price')
listA.append(a)

listG = np.array(listA)
listG = listG.flatten()
    
listG_in_not = [s for s in listG if 'product' not in s]

ではここの部分の説明から。
こっちではsoup.find_allを使用しています。というのも、「price」のクラスだけじゃhtml上でヒットするものが多すぎて一発で価格を取得できません。
なのでとりあえずlistAに全てぶち込んでいます。そしてここが2次元配列の原因です。空配列に配列をぶち込んでしまっているんですねー。
なので[["","",""]]みたいな感じになってるわけです。
それを4~5行目で1次元配列に戻しています。
そして7行目でproductという文字が入っている要素を取り除きます。
ここで同時にiという文字が入っている要素も取り除ければよかったんですが、試行錯誤してもうまくいかなかったんで追加でこの後にまたプログラム書いてます。


2022/7/29追記
soup = BeautifulSoup(html, "html.parser")の後のプログラムはこれで動くことが判明しました。多少ですが最適化されたのでより動作が早くなるかと思います。

listA = []

listA.append(soup.find_all('span', class_='price'))

listG = np.array(listA)
listG = listG.flatten()
    
listG_in_not = [s for s in listG if 'product' not in s and 'i' not in s]

listH = [str(a) for a in listG_in_not]
print(min(listH))
z = 'J' + str(x + 2)
print(z)
print('-----')
st.update_acell(z , min(listH))



for a in listG_in_not:
  listH.append(str(a))

listH_in_not = [s for s in listH if 'i' not in s]
print(listH_in_not)

ここ1~2行目でまず文字列型に変換しlistHに代入しています。
そして下の4~5行目で先ほど書いたiの文字が入っている要素を取り除きます。
これでようやく価格だけが取り出せました。

print(min(listH_in_not))
z = 'J' + str(x + 2)
print(z)
print('-----')
st.update_acell(z , min(listH_in_not))

あとはFANZAの方でも見た流れですね。
最小値を求めて書き込むセルを特定し、上書きという形で書き込む。


終わりに

さて、ここまで長々とお付き合いしていただきありがとうございます。
果たして何人の人がここまで真面目に見たのかとても疑問ではありますが……
プログラミング歴1年半くらいの初心者が書いたプログラムなので結構最適化の余地があると思います。FANZAの方の2重ループになっている部分とかリスト内包表記とかで上手くできそうですしね。(ていうか処理が遅いのは多分その部分が原因)

2022/8/2追記
処理の遅い原因が恐らく判明しました。
スクレイピングの際に複数のクラスから取得しているのが原因だと思われます。ただこれに関してはどうしようもないので諦めてください。
一応マルチスレッドを使用することで高速化は可能ですが、問題はColabという仮想環境上で実行しているので仮想マシンが対応しているのか、そして、そもそもマルチスレッドに処理する方法が分からないので現在手詰まり状態です。

ここからは完全に愚痴ですが、個人的にはPythonという言語はあまり好きじゃありません。というのも筆者はjavaメインでやっているのですが、if文やfor文をjavaでは{}で区切るんですよ(確かC言語も同じ仕様)。それに比べてPythonはインデントで判断します。なので1か所インデントをミスったら動かないんですねー。このせいで何度プログラムがエラーを吐いたことか……
javaはインデントが多少おかしくても結局{}でまとめてあるのでしっかり動くんですよ。正直Pythonのメリットってライブラリが豊富なこと以外にあるんですかねぇとか思ってますw
まあこれもプログラミングについて浅い人の意見なので、違う意見を持ってるよーという方は適当に聞き流してもらって構いません。
ちなみにこのプログラムの完成にかかった時間はFANZAの方が約4時間、DLsiteの方が約2時間です。FANZAの方から先に作ったので、下地があった分DLsiteの方は短くなっています。BeautifulSoupというライブラリを使うのが初めてだった分、結構てこずりましたね。まさか抽出したままの状態だと文字列型じゃなくてBeautifulSoup用のよく分からん型になっているとは思いもしませんでした。
まあ結構勉強にはなったのでよかったとは思いますけどね。

さて、愚痴も一通り書き終わったところでこの記事も〆させて頂きたいと思います。落ちはないです。
これからも適当に色々記事を書いていく予定なので、暇な時にでも見に来てください。
ではまたノシ

この記事が気に入ったらサポートをしてみませんか?