初心者がスクレイピング、情報抽出を行った記録


集めたい情報があったのでスクレイピングを初めて行った。さまざまな知見が得られたので記録したいと思う。

### 目的
スタートアップの資金調達情報に興味があったので海外のサイトからそれに関する情報を入手することを考えた。
### 得られた知見
- request,selenium,BeautifulSoupの使い方
- ブラウザの開発者用ツールの使い方
- OpenAI APIでStructured Outputをさせる方法
### 対象サイト
- TechCrunch https://techcrunch.com/
- Ctech https://www.calcalistech.com/ctechnews/
### 使用モジュール
- request
- selenium
- BeautifulSoup
### 手法の概要
1. それぞれのサイトのスタートアップカテゴリーに属する記事をまとめているページから記事のURLを手にいれる
2. 記事のURLから記事のタイトルと本文を入手する
3. タイトルと本文からそれらが資金調達に関する記事であるか判断し、資金調達に関する記事のみを集める
### 難しかった点
- それぞれのオブジェクトがどの位置（XML pathなど）に存在しているか調べること
- 動的コンテンツをスクレイピングする方法
- 動的コンテンツがどのスクリプトによって生成されているか調べること
- OpenAI APIからStructured Outputをする方法
---
全体的にどのような流れで行ったのか解説する。特に難しかった点は個別記事にして解説する。(かも)

## スタートアップカテゴリーの記事をまとめる
### TechCrunch
TechCrunchは静的サイトであったためURLを入手しやすかった。
[スタートアップカテゴリー](https://techcrunch.com/category/startups/)に対して以下のコードを書くことでカテゴリーの記事のURLを入手した。
最初は記事のタイトルに"startup"や"raise"が入っているかどうかで資金調達の記事か判断しようかと思っていたが、その手法だと取りこぼしがありそうなので取りやめた。
```python
import requests
from bs4 import BeautifulSoup

url = "https://techcrunch.com/category/startups/"

response = requests.get(url)

html = response.content

soup = BeautifulSoup(html, 'html.parser')

title_linknames = soup.find_all('a', class_='loop-card__title-link')
```

### Ctech
https://www.calcalistech.com/ctechnews/category/5214
Ctechは動的にコンテンツを生成していたので少し難しかった。
Javascriptでアクセスの3000ms後にコンテンツを呼び出すことがわかったので3秒待機してからページのHTMLを取得するとうまくいった。
```python
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
from bs4 import BeautifulSoup
import time

url = "https://www.calcalistech.com/ctechnews/category/5214"

# WebDriver Managerを使用してChromeDriverを自動的にインストール
service = Service(ChromeDriverManager().install())

browser = webdriver.Chrome(service=service)

try:
	browser.get(url)

# ページが読み込まれてから3秒待機
	time.sleep(3)

# ページのHTMLを取得
	html = browser.page_source

finally:
	browser.quit()  

if html:
	print("Get HTML Successfully")
```
#### 難しかった点
##### web driver
web driverとChromeのバージョンが一致してない？というようなエラーが出たので`service`変数を使わないと動かなかった。
##### JavaScriptを見つける
探し方がわからなかったので次のような工程で地道に行った。
1.  requestでどの階層まで出力されているかを見つける
	- requestで出力されていないところは動的に生成されているところであるのでそれを見つける。
	- 今回は`#BkUaac0011xyJl > div > div.slotList` まで存在していることがわかった
2. Chromeの開発者ツールでURLを含むコンポーネント周辺の要素を含むコードがないか調べる
	- 地道に検索した。
	- `#BkUaac0011xyJl > div > div.slotList > div:nth-child(1) > a`に　URLが存在するため`div.slotList`から一階層ずつclass名を検索してその部分のHTMLを出力していそうなものを探した。
	- 今回はslotItemを含むコードが目標のコードであった
	
<details><summary>目標のコード</summary>

Xpath = /html/body/script[18]/text()
	
```javascript
setTimeout(function () {
    let strip_components = document.querySelectorAll(".ctech-infinite-headlines .slotItem");
    let strip_ar = Array.from(strip_components);
    for (let i in strip_ar) {
     strip_ar[i].addEventListener("click", function (event) {
        const clickText = strip_ar[i].querySelector(".slotTitle span:not(.roofTitle)").textContent;
        let a_link = strip_ar[i].querySelector("a").href;
        let split_link = a_link.split("/");
        let article_id = a_link.includes("article") ? split_link[split_link.length - 1]: "";         
    
        let authorName = strip_ar[i].querySelector(".author") ? strip_ar[i].querySelector(".author").innerText : "";
        let pageType = dataLayer && dataLayer[0] && dataLayer[0].contentPageType ? dataLayer[0].contentPageType : "";
        if (window.pushGa4DataLayer) {
             window.pushGa4DataLayer(event, {
                 event: "content_click",
                 click_text: clickText,
                 content_type: "componenta",
                 componenta_name: "ctech-infinite-headlines",
                 position_in_componenta: i,
                 article_id: article_id,
                 author_name: authorName,
                 page_type: pageType,
             });
         }
     });
    }
}, 3000);
```
</details>


## 記事のタイトルと本文の抽出
以下の関数を全てのリンクに対して適応した。
Techchrunch,Ctechの両方において記事ごとにHTML構造が全て同じだったので楽だった。
それぞれの位置に応じて`soup.find()`の要素を入れ替えるだけで抽出できた。
```python
def get_content(url):

response = requests.get(url)

html = response.content

  

soup = BeautifulSoup(html, 'html.parser')

titleAndContent = {}

title_tag = soup.find('title')

if title_tag:

title_text = title_tag.get_text()

titleAndContent["title"] = title_text

else:

titleAndContent["title"] = "None"

article_body = soup.find('div', class_='entry-content')

if article_body:

paragraphs = article_body.find_all('p')

extracted_text = "\n".join([para.get_text() for para in paragraphs])

titleAndContent["Main Content"] = extracted_text

else:

titleAndContent["Main Content"] = "None"

  

return titleAndContent
```

### タイトルと本文から資金調達の記事であるか判断し、資金調達の記事のみを集めた。
単純にOpenAI APIに本文と記事を与えて判断させた。
出力をsructuredにしたかったので少し工夫した。

まず出力の形式を以下のように定義した。
```python
from pydantic import BaseModel
class Summarize(BaseModel):
	fundingNews: bool
	
	companyName: str
	bussinessDomain: str
	fundraisingAmount: str
	leadInvestor: str
	fundingRound: str
	investor: str
	bussinessOverview: str
	subjectiveComment: str
```

次にプロンプトを以下のように定めた。
fundingNews  == False つまりニュースが資金調達のものでない場合それ以降の要素を空白にするように指示した。

```python
role = "You are good summarizer。"
prompt = f"Title: {titleAndContent['title']}\nContent: {titleAndContent['Main Content']}\n\nBase on the information, Please summarize it. Please express the bussiness domain as 1 phrase.If fundingNews == False, fill others with brank"
```

そして以下のようにAPIに入力して出力を得た。モデルはgpt-4o-miniを用いた。
```python
def generate_response(role, prompt, model="gpt-4o-mini-2024-07-18"):
	completion = client.beta.chat.completions.parse(
		model=model,
		messages=[
		{"role": "system", "content": role},		
		{"role": "user", "content": prompt},		
		],		
		response_format=Summarize,		
		)	
	response = completion.choices[0].message.parsed

return response
```
#### 注意事項
`cient.beta.chat`を用いる際pythonを3.12にしないと動かなかった。理由は定かでない。

## まとめ

以上で出力を構造化して取得できた。
seleniumは使ったことがなかったので難しかったが、どのJαvascriptで動いているか見つけれた時は気持ちよかった。もうちょっと楽できる方法はないのか調べたい。
Structured outputでbool値も出力できるのは驚きだった。またプロンプトで指示するだけでそれ以降の出力もしないようになるのは面白かった。色々なところで使えそうな気がする。