見出し画像

ChatGPT + Embeddings + Google Custom Search APIで特定テーマをキュレーション「蒲蒲線」 - データ収集編

前回の続きやっていきます。


検索結果の収集

前回作った検索エンジンをつかってデータの収集をします。
Pythonで行いますが、以下のサービスを使うと環境構築を自力ですることなくコードの実行ができる上に、グーグルのサービスとの連携も簡単でおすすめです。

特定キーワードの検索結果について「タイトル」「URL」「スニペット」をcsvファイルに保存します。欲しいのはURLだけなんですが一応タイトルなども取得しました。

pip install --upgrade google-api-python-client

#キーワード検索して100件取得(APIの制約により100件まで)

import csv
from googleapiclient.discovery import build

# API キーを設定します
API_KEY = 'xxx'

# カスタム検索エンジン ID を設定します(前回つくった検索エンジンのIDです)
SEARCH_ENGINE_ID = "xxx"

# 検索するキーワード
search_query = "蒲蒲線"

# CSV ファイル名
csv_file = "search_results.csv"

# API クライアントを構築します
service = build("customsearch", "v1", developerKey=API_KEY)

# リクエストの最大回数を設定します
max_requests = 99
max_index = 100
request_count = 0

# CSV ファイルを開きます
with open(csv_file, mode="a", newline="", encoding="utf-8") as file:
    writer = csv.writer(file)

    # API リクエストを送信します(1回で10件までに制限されている)
    response = service.cse().list(q=search_query, cx=SEARCH_ENGINE_ID).execute()
    request_count += 1

    # 検索結果を CSV ファイルに追記します
    if "items" in response:
        print(response['searchInformation']['totalResults'])
        for item in response["items"]:
            link = item["link"]

            writer.writerow([item["title"], link, item["snippet"]])

    # ページ送りを実行して残りの検索結果を取得します
    while "nextPage" in response["queries"] and request_count < max_requests:
        nextPage = response["queries"]["nextPage"][0]
        start_index = nextPage["startIndex"]

        if  start_index > max_index:
            break

        response = service.cse().list( q=search_query, cx=SEARCH_ENGINE_ID, start=start_index).execute()
        request_count += 1

        # 検索結果を CSV ファイルに追記します
        if "items" in response:
            for item in response["items"]:
                link = item["link"]

                writer.writerow([item["title"], link, item["snippet"]])

        # 最大リクエスト数に達した場合は停止します
        if request_count >= max_requests:
            break

ポイントを解説します。

Custom Search API の使い方と料金

まずAPIの公式ドキュメントです。ある一定以上使うと料金がかかりますのでご確認ください。

API KEYの作り方

これはgoogleのAPI使う場合の定石です。新規プロジェクト作ってAPIキーを作ります。以下の記事を参考にしてください。

検索キーワードについて

いつものグーグル検索と同様です。複数のワードでも構いません。一つの検索について得られる検索結果は100件までに制限されています。

今回「蒲蒲線」「蒲蒲線 東急」の2つの検索結果を取得して利用しました(かなりの結果が重複しましたが)。

検索ワードを決めないで特定サイトのページを取得したい場合には「site:www.city.ota.tokyo.jp/」などの定番コマンドも使えます。

各ページのコンテンツを収集

これでURLのリストができましたので各ページの内容を取得していきます。
今回は定番のWeb スクレイピングライブラリ BeautifulSoupを使いました。

URL先のコンテンツを取得して、jsonファイルとして保存します。1ページ1ファイルとしました。こうする必要は必ずしもありませんが、一度取得したページのスキップを簡単に行うためこうしました。

このようなスクレイピング行為は相手に迷惑をかけるおそれがあるので、必要最小限で行ってください。

実行中、何らかのエラーで処理が止まってしまうことがあります。再試行の際、一度取得したページは再度取りに行かないようにしましょう。

import requests
import json
from bs4 import BeautifulSoup
from urllib.parse import urlparse, parse_qs
import time
import csv

#取得したコンテンツの保存場所
file_path = '/doc/'

import os

# 既存のJSONファイルがあるかどうかをチェックする関数
def check_existing_file(file):
    return os.path.exists(file)

def generate_file_name(url):
    parsed_url = urlparse(url)
    path = parsed_url.path

    # パスからファイル名を生成
    file_name = f"{path.strip('/').replace('/', '_')}"

    # クエリパラメータを取得
    query_params = parse_qs(parsed_url.query)

    # クエリパラメータを整形してファイル名に追加
    query_string = "&".join([f"{key}={','.join(value)}" for key, value in query_params.items()])

    # クエリパラメータをファイル名に追加
    if query_string:
        file_name += f"_{query_string}"

    return file_name

def save_article_text(url):
    try:
        # URLからHTMLを取得
        response = requests.get(url)
        response.raise_for_status()

        print('---')
        print(url)

        # URLからドメインを抽出
        parsed_url = urlparse(url)
        domain = parsed_url.netloc

        # ファイル名を生成
        file_name = generate_file_name(url)

        # ファイルにテキストを保存
        output_file = f"{file_path}{domain}_{file_name}_article.json"


        if check_existing_file(output_file):
            print("JSONファイルが既に存在するため、スキップしました。")
            return


        # BeautifulSoupを使ってHTMLを解析
        soup = BeautifulSoup(response.content, 'html.parser')

        # <title>要素を取得
        title_element = soup.find('title')

        if title_element:
            title_text = title_element.get_text()
            print("取得したタイトル:", title_text)
        else:
            title_text = ''
            print("タイトルが見つかりませんでした。")


        content_element = soup.find('article') or soup.find(id='article-body') or soup.find('main')


        content_text = ''

        if not content_element:
           print("コンテンツが見つかりませんでした。")
        else:
            content_text = content_element.get_text()
            print("取得したテキスト:", content_text)

        if content_text != '':
            # JSONデータを作成
            json_data = {
                "url": url,
                "title": title_text,
                "content": content_text,
                "timestamp": int(time.time())
            }

            with open(output_file, 'w', encoding='utf-8') as file:
                json.dump(json_data, file, ensure_ascii=False, indent=4)
            print("記事のデータをJSONファイルに保存しました。")

        else:
            print("内容が見つかりませんでした。")

    except requests.exceptions.RequestException as e:
        print("エラーが発生しました:", e)

def process_csv_file(csv_file):
    with open(csv_file, 'r', encoding='utf-8') as csvfile:
        csvreader = csv.reader(csvfile)

        for row in csvreader:
            title = row[0]
            url = row[1]
            if not url.endswith('.pdf'):
                save_article_text(url)

if __name__ == "__main__":
    csv_file_path = "/search_results.csv"  # CSVファイルのパスを指定
    process_csv_file(csv_file_path)

コンテンツ部分はどこ?

今回の最大ポイント。HTML全体を取得した後、コンテンツ部分をみつけないといけません。ブログ系のサイトであればarticle要素、そうじゃなくてもmain要素あたりが一番確実です。でも、htmlの書き方はそれぞれです。今回も上記の2要素だけではうまくいかず以下のようになりました。

content_element = soup.find('article') or soup.find(id='article-body') or soup.find('main')

article要素、id='article-body'、main要素の順で探します。

こんなマニュアルチックなことしなくてもChatGPTに以下のようなプロンプトとともに全文をなげればうまくいくのですが(どうせなら要約もさせたら良いかも)今回はそこまでしませんでした。

Delete anything but the text.

さてこれで各ページのコンテンツまで取得できましたのでembeddingしてデータベースに収めます。ここからは以前と基本的に同じことをするのでそちらの記事を見てください。

ただし、一度にembeddingするテキストの長さに制限があるので工夫を入れました。テキストの長さを測り、最大を超えていたら超えた部分を削除します。

#一度にembeddingできる文字数に制限があるので制限内に収める処理

import tiktoken

EMBEDDING_MODEL = "text-embedding-ada-002"
EMBEDDING_CTX_LENGTH = 8191
EMBEDDING_ENCODING = 'cl100k_base'

def truncate_text_tokens(text, encoding_name=EMBEDDING_ENCODING, max_tokens=EMBEDDING_CTX_LENGTH):
    encoding = tiktoken.get_encoding(encoding_name)
    encode = encoding.encode(text)[:max_tokens]
  #再decodeしなくてもembeddingできるはずなんだけどエラーが出ることがあったので
    return encoding.decode(encode)

この処理はちょっと雑でもっと工夫の余地があります。分割してembeddingしたり、コンテンツ取得時にChatGPTに要約してもらったり、いろいろ考えられそうですね。

蒲蒲ロープウェイ

僕は現在の蒲蒲線の計画には反対というわけではないのですが、なんかもうすこし素敵にできなかったのかと思ってしまいます。

そこでひとつ妄想を。その名も「蒲蒲ロープウェイ」。
東急プラザの屋上からアプリコ(アロマスクエア)を経て蒲田要塞へ至ります。

かまたえん(東急プラザの屋上)の観覧車乗るたびに妄想していました。

蒲蒲ロープウェイ

これなら京急蒲田の要塞化が生きてきますね。ロープウェイは3階ホームに直結します。

じつは東急はロープウェイやってたことあるんですよ。それも渋谷駅で!
「1世紀ぶりにひばり号復活!」なんてどうでしょう?

最後に今回の成果を使って、戦前のモノレールの計画について調べてみました。前回の蒲蒲線の説明の中に出てきて気になっていました。

Q: 戦前のモノレールの計画について詳しく教えてください

A:戦前には「羽田航空電鉄」と呼ばれるモノレールの計画が存在していました。この計画は、蒲田と羽田空港を結ぶもので、蒲田駅の東口を起点とし、呑川に沿って東進し、海老取川を渡って羽田飛行場(現・羽田空港)のある空港島に入り、終点の穴守駅(現在の羽田空港B滑走路南端付近)に至るルートが考えられていました。
線路は懸垂式モノレール(車両が軌道桁にぶら下がって走行する方式)の複線を採用し、途中には国道・運河・森崎・飛行場の4駅を設置する予定でした。このうち国道駅は、現在のJR鶴見線国道駅とは無関係で、京浜蒲田(現・京急蒲田)駅の東側に設けられる予定でした。しかし、この計画は実現には至りませんでした。発起人の資金力に問題はなかったものの、並行する京浜電気鉄道穴守線(現・京急空港線)に影響を与えることが懸念され、申請から7カ月後の1931年10月20日に却下処分となりました。

参照:
戦前にも練られていた「蒲蒲線」構想の全貌 モノレールで蒲田と羽田を結ぼうとしていた | 都会に眠る幻の鉄路 | 東洋経済オンライン https://toyokeizai.net/articles/-/104361
戦前にも練られていた「蒲蒲線」構想の全貌 モノレールで蒲田と羽田を結ぼうとしていた | 都会に眠る幻の鉄路 | 東洋経済オンライン https://toyokeizai.net/articles/-/104361?page=2
戦前にも練られていた「蒲蒲線」構想の全貌 モノレールで蒲田と羽田を結ぼうとしていた | 都会に眠る幻の鉄路 | 東洋経済オンライン https://toyokeizai.net/articles/-/104361?display=b

ChatGPT4

呑川の上を通すというのはかなり現実的ですね。京急との競合を懸念したとありますが、羽田空港がここまで発展するとは見通せなかったでしょうし、仮に完成していても戦争で破壊されてしまっていたでしょう。

カマカマの距離感

結局、蒲蒲間の距離800mというのが微妙なんでしょうね。
もっと近ければ川崎のように地下街でつなぐ。もっと離れていたらとっくに何らかの交通機関ができていたかもしれませんね。

一方、この絶妙な距離感が蒲田らしい繁華街とビジネス街を生み出す要因にもなっています。もっと近くても離れていても違う街の様相になっていたと思います。

蒲蒲線ができて街の表情がどう変わっていくか楽しみです(できるまで生きてるかな?)。

グーグルもいよいよ・・・

ところで、グーグルもいよいよ検索結果に要約を表示する機能を実装してきました。

使ってみましたが、広告より上に表示されますね。
グーグルとしてはこれが良い評判になってしまうと自分の首を締めることになりますのでつらいところですね。本当はやりたくなかったのではないかと思います。今後どうするのか注目しています。

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