ChatGPT(GPT-4) で一撃でスクレイピングするコードを生成出来たので感想とコツ

# 今回やりたかったこと

- 目標：ChatGPT(GPT-4) で一撃でスクレイピングするコードを生成

するにはどうしたらいいのか、ChatGPT のハードルとかコツとかを知りたい。

※最終的なプロンプトの入力と出力の全文は本ページ下部に貼り付けてます。

# 作ったもの概要

保険組合のウォーキングイベントの会社内の3チームの歩数進捗の slack への自動投稿 bot を作成しました。
処理は大きく2つに分かれています。

1. ウォーキングイベントサイトから歩数をスクレイピング＆スプシへアップロード
2. スプシの GAS で投稿文字列作成＆ slack へ自動投稿

今回 ChatGPT でやったのは1の方です。
2は前回半年前開催分のコードをほぼそのまま流用しました。
運良く（？）今回のタイミングでウォーキングイベントのサービスサイトが変わり、 HTML がまるっと変わり1のスクレイピングコードは作り直しが必要だったので、チャレンジに丁度良いと思い ChatGPT を使ってコード生成してみました。

# 環境

* ChatGPT(GPT-4)
* Mac macOS 12.5.1

# 作成物スクリーンショット

## スプシ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35514/73b2d15b-f9db-6bf5-f22c-021c94a0a287.png)

## slack bot

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35514/328f6dc3-f5af-2e36-6f2b-7ee18c93d421.png)

# 対応時間

5月7日の1人日で対応しました。
体感としては「半年前に似たものを作った」経験を活かし、今回手動作成なら半日で行けたはずですが、ChatGPTへの慣れで1人日かかりました。
しかし多分今後更に似たような処理をするなら ChatGPT に任せたらより短く（半日以下で）出来るはずです。

ChatGPT の入力上限（執筆時点（2023/05/07）で3時間で25回）、に1回引っかかりました。
＃実際は2回で、子供が PC 画面覗き込んで「これなんで自動で文字出てくるの！？」と興味持ったので、途中一緒に「ドラえもんの誕生日」「面白い名前の魚」とか子供と一緒に遊んだ結果、1回制限に引っかかりました。
＃ChatGPT で出力させたいイメージが固まる前の試行錯誤段階は「ちょこちょこ試し、制限に引っかかった待ち時間は休憩時間に当てる」くらいが良いかもしれません。

# 感想

* まぁまぁ大変。ちゃんと動かせたかったらその分文章書く必要あり。
* 最初は試行錯誤する。けど今回 ChatGPT 自体に慣れたから次はもう少しスマート＆短時間で行けるはず
* 実際のプロンプトの操作イメージ（体感）
  * 最初に「やりたいことの50%」を入力しプロンプト出力。実行すると「エラー発生したり、最初の入力で言い忘れたこと」を認識。
  * 「エラー対処や次の処理」5%分追加し再出力。
  * 以下繰り返し
  * みたいな感じ

# 問題と対策（得られた知見）

* GPT-4 に慣れると GPT-3 は出力が弱い（上記 GPT-4 の入力上限に引っかかったときに GPT-3 で同じことさせたが全体的にイケてない。期待通りの出力にならない）
  * 対策→ GPT-4 の入力数上限に達したら、待つ
* ログインが必要なページのスクレイピングは基本やってくれない。「ログイン出来ない」と言われる
  * 対策→スクレイピングしたい HTML を DevTools で取り出し、サンプルで数行渡してあげると対応してくれる
* 生成毎にコードのスタイルがどかっと変わる。変わった場所とは全然無関係の処理を追加してるはずなのに。結果整合が取れず動かないがち。
  * 変数名。「TARGET_TEAM_NAMES」だったり「TARGET_TEAMS」だったり、等々。
  * 関数名
  * 使用ライブラリ
  * 関数化有無（処理ベタ書きだったり、関数化したり）
  * 変数を上部にまとめてくれたり、中に埋め込んだり
　→対策1：それらも指定する。「チーム名、ファイル名、シート名等あとから変更する可能性があるものはコードの上部にまとめて記載して」等。
　→対策2：下にコメントを続けず、冒頭の依頼文を編集して再生成させると全体の整合は取れやすい。
* ログイン画面の HTML は ChatGPT がアクセス出来るはずだが、何故か微妙に selector を間違えてた。
  * メールアドレス入力欄の ID が正しくは「sender-email」だが最初の出力は「sender_email」だった 
  * パスワード入力欄の ID が正しくは「user-pass」だが最初の出力は「user-password」だった
　→対策：それらも指定する。「ID、パスワードの selector はそれぞれ以下。sender-email user-pass」等。
* ライブラリ/ツールのバージョン問題にちょいちょいぶち当たる。僕の手元の selenium ver は4だったが、ランダムで3向けのコードを吐く
　→対策：バージョンも指定する。「selenium 4」等。
* バージョン指定しても誤出力することがある
　→対策：API も指定する。「driver.find_element_by_id 等は使用せず、driver.find_element(By.ID, 等を使用する」等。

# 未解決問題

* 生成毎にコードブロックが外れがち。外れると python 的にはインデントし直すのが手間。コードブロック成功率を上げるおまじない「テキストはコードブロックで」等はあるが、確実ではない。
　→暫定対策：少し上の「XXX 関数の頭から書いて」と言うと気持ちコードブロック化してくれやすい？コピペしやすい。
* 明示してるのに未考慮なコードがたまーに出力される。「headless モード有りオプション。デフォルトはheadlessモードオフ。」と言ってるのに、出力毎にたまに「デフォルトオン、オフ」が変わる。。
　→暫定対策：特になし。。気にせず他の処理追加してたら最後の出力で希望の「デフォルトオフ」になってくれてた。。

# コツ

* 続きの出力は「つづき」で OK。最初は丁寧に「続きを書いてください」と書いていたが。
* 実行時エラー発生したらエラーテキスト貼り付けるだけで OK。最初は丁寧に「エラー発生。<実際のエラーテキスト>」と書いていたが。

# 最終的なプロンプト

注：入力テキスト出力テキストどちらも基本はコピペですが、スプシの ID 等は「XXX」に置き換えてます。
注：入力テキストは試行錯誤＆継ぎ足し継ぎ足しした結果で未整理なので、必要条件ではあるけど十分条件ではないかもです（=もっと整理し短く出来るかも）

## 入力

```
以下の条件を満たすコードを python で作成お願いします。
コードはインデントに注意してコードブロックで出力お願いします。

■やりたいこと
ウォーキングイベントのチーム対抗ランキングの特定3チームの、順位、チーム名、総歩数の平均をgoogle スプレッドシートに保存する。

■条件
・使用ライブラリ/ツール
　・selenium 4
　　・driver.find_element_by_id 等は使用せず、
　　　driver.find_element(By.ID, 等を使用する
　・webdriver
　　webdriver.Chrome()のexecutable_path引数は使用しない。
　　Serviceオブジェクトを使用する。
　・webdriver-manager
　　https://pypi.org/project/webdriver-manager/
　・Google Sheets API
　　・認証情報は同じ場所に credentials.json として保存されている
　　・service_account.Credentials.from_service_account_file の引数は2つ
・特定3チームのチーム名
　・「ACCESS_A」が含まれるチーム。
　　実際は「【募集】ACCESS_A」等前後に別のテキストが含まれる可能性あり
　・「ACCESS_B」が含まれるチーム
　・「ACCESS_C」が含まれるチーム
・起動引数でオプション
　・headless モード有りオプション。
　・デフォルトはheadlessモードオフ。
・コードはベタ書きではなく適切に関数化
・チーム名、ファイル名、シート名等あとから変更する可能性があるものはコードの上部にまとめて記載
・IDとパスワードは login_info.json に記載し、そこから読み込むようにしてください
・credentials.json や login_info.json のローカルファイルは cron で実行できるように 本スクリプトの PATH を起点にしてファイルを開けるようにしてください
・特定チームが見つかった時点のスクリーンショットを取得
・まずアクセスする URL は
　https://pepup.life/campaign/XXXX/ranking?mode=team&page=1
　だが、ログインが必要なので
　https://pepup.life/users/sign_in
　に自動的にリダイレクトされる。
　ログイン完了後は
　https://pepup.life/campaign/XXXX/ranking?mode=team&page=1
　に自動的に遷移する。
　その後順次以下のように URL を変更し巡回する。
　https://pepup.life/campaign/XXXX/ranking?mode=team&page=2
　https://pepup.life/campaign/XXXX/ranking?mode=team&page=3
・エラー処理
　・ランキングの table タグの tr タグが空だったり、「集計中」が含まれたテキストが表示された場合はそこで終了。その旨エラー出力して。

■ログインページの selector
ID、パスワード、ログインボタン、それぞれ以下。
sender-email
user-pass
<input type="submit" name="commit" value="ログイン" class="btn btn-default btn btn-primary btn-block" data-disable-with="ログイン">

■取得するデータ
・ランキングの table タグ。
3列あり、左から「順位」、「チーム名」、「総歩数の平均」。
1ページ30位まで記載があり、何ページまであるかは分からない。
1つの順位分の tr タグ例は以下。
これらのHTMLを selector としてしてください。
<table>
  <tbody>
    <tr class="table-row head"><td style="text-align: right;">順位</td><td style="text-align: left;">チーム名</td><td style="text-align: right;">総歩数の平均</td></tr>
    <tr class="table-row"><td style="text-align: right;"><span class="num-value">768</span><span class="gray">位</span></td><td style="text-align: left;">ACCESS_A</td><td style="text-align: right;"><span class="num-value">61715</span><span class="gray">歩</span></td></tr>


■保存先
https://docs.google.com/spreadsheets/d/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/edit

シート名：ウオキング
A1セル：「順位」というテキスト
B1セル：「チーム名」というテキスト
C1セル：「総歩数の平均」というテキスト
D1セル：「URL」というテキスト

A2セル：ACCESS_A チームの順位
B2セル：「ACCESS_A」というテキスト
C2セル：「ACCESS_A チームの総歩数の平均」という数値
D2セル：ACCESS_A チームの URL

A3セル：ACCESS_B チームの順位
B3セル：「ACCESS_B」というテキスト
C3セル：「ACCESS_B チームの総歩数の平均」という数値
D3セル：ACCESS_B チームの URL

A4セル：ACCESS_C チームの順位
B4セル：「ACCESS_C」というテキスト
C4セル：「ACCESS_C チームの総歩数の平均」という数値
D4セル：ACCESS_C チームの URL
```

## 出力

```text
以下のコードをご確認ください。条件に合わせてコードを作成しました。
```

```python
import json
import os
import sys
from pathlib import Path

from google.oauth2 import service_account
from googleapiclient.discovery import build
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from webdriver_manager.chrome import ChromeDriverManager

# 設定
TARGET_TEAM_NAMES = ["ACCESS_A", "ACCESS_B", "ACCESS_C"]
SPREADSHEET_ID = "XXXX"
SHEET_NAME = "ウオキング"

HEADLESS = "--headless" in sys.argv

# Google API認証
CREDENTIALS_PATH = Path(__file__).resolve().parent / "credentials.json"
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
creds = service_account.Credentials.from_service_account_file(str(CREDENTIALS_PATH), scopes=SCOPES)

# ログイン情報取得
LOGIN_INFO_PATH = Path(__file__).resolve().parent / "login_info.json"
with open(LOGIN_INFO_PATH, "r") as f:
    login_info = json.load(f)

def init_driver(headless):
    options = webdriver.ChromeOptions()
    if headless:
        options.add_argument("--headless")
    service = Service(executable_path=ChromeDriverManager().install())
    driver = webdriver.Chrome(service=service, options=options)
    return driver

def login(driver, email, password):
    driver.get("https://pepup.life/users/sign_in")
    driver.find_element(By.ID, "sender-email").send_keys(email)
    driver.find_element(By.ID, "user-pass").send_keys(password)
    driver.find_element(By.XPATH, '//input[@type="submit" and @value="ログイン"]').click()

def find_target_teams(driver):
    found_teams = []
    page = 1

    while True:
        driver.get(f"https://pepup.life/campaign/XXXX/ranking?mode=team&page={page}")

        table_rows = driver.find_elements(By.CSS_SELECTOR, "table tr.table-row")
        if not table_rows or "集計中" in table_rows[0].text:
            print("集計中または空のページが見つかりました。終了します。")
            break

        for row in table_rows:
            team_name = row.find_element(By.CSS_SELECTOR, "td:nth-child(2)").text
            for target_team_name in TARGET_TEAM_NAMES:
                if target_team_name in team_name:
                    rank = row.find_element(By.CSS_SELECTOR, "td:nth-child(1) span.num-value").text
                    avg_steps = row.find_element(By.CSS_SELECTOR, "td:nth-child(3) span.num-value").text
                    found_teams.append((rank, team_name, avg_steps, driver.current_url))
                    driver.save_screenshot(f"{target_team_name}_screenshot.png")
                    TARGET_TEAM_NAMES.remove(target_team_name)
                    break

            if not TARGET_TEAM_NAMES:
                return found_teams

        page += 1


def save_to_sheet(teams_data):
    service = build("sheets", "v4", credentials=creds)

    for i, team_data in enumerate(teams_data, start=2):
        range_ = f"{SHEET_NAME}!A{i}:D{i}"
        values = [list(team_data)]
        body = {"values": values}
        service.spreadsheets().values().update(
            spreadsheetId=SPREADSHEET_ID,
            range=range_,
            valueInputOption="USER_ENTERED",
            body=body,
        ).execute()


def main():
    driver = init_driver(HEADLESS)
    login(driver, login_info["email"], login_info["password"])

    found_teams = find_target_teams(driver)
    if found_teams:
        save_to_sheet(found_teams)

    driver.quit()


if __name__ == "__main__":
    main()
```

```text
このコードは、指定された条件に従って特定のチームを探し、見つかったチームの情報をGoogleスプレッドシートに保存します。
```

# 最後に

この記事自体も ChatGPT さんに整形してもらいましたｗ
体感「箇条書きで言いたいことなぐり書きしたら、良い感じに敬語に直してくれる」。
敬語部分のタイプ量数割減、くらい？
便利は便利。

## 本記事のプロンプト抜粋

```text
このチャットで生成したコードを Qiita に記事化したいです。
https://qiita.com/naohikowatanabe
の文体で qiita 記事の作成作成お願い。
markdown 形式で記載し、全ての出力のプレーンテキストをまとめて一つのコードブロックで出力して。

記事タイトル：ChatGPT(GPT-4) で一撃でスクレイピングするコードを生成出来たので感想とコツ

内容は以下を含めてください。
■今回やりたかったこと
・ChatGPT(GPT-4) で一撃でスクレイピングするコードを生成

■作ったもの概要
・保険組合のウォーキングイベントの会社内の3チームの歩数進捗の slack への自動投稿 bot
　・処理は大きく2つ
　　・1. 「サイトのスクレイピング＆スプシへアップロード」
　　・2. スプシの GAS で投稿文字列作成＆ slack へ投稿
　・今回 ChatGPT でやったのは1の方。2は前回半年前開催分のコードをほぼそのまま流用。
　・運良く（？）今回のタイミングでウォーキングイベントの運営が変わり、
　　サイトがまるっと変わったので1のスクレイピングコードは作り直しが必要だった。
```