【Python × Gemini API × GCP】AI要約させたニュースをメール送付するスクリプトをGCP上で日次実行するシステムを作ってみた


# 1. はじめに 
## 1-1. 今回紹介するシステム
### ニュース創作ボット<br>（News Generate Gemini Bot）
記事のタイトル/概要/本文を元にニュースの記事の要約をAIに作成してもらい、自分のメールにPDF形式でニュースを送ってもらうpythonスクリプト。
**↓以下コードのソース**
https://github.com/delphismk/news-generate-gemini-bot.git
<br>
このスクリプトをGCPのCloud RunにデプロイしCloud Schedulerで日次実行させることでクラウド上での運用自動化までを実現した。

## 1-2 OUTPUT
毎日朝8:00にPDFが添付された以下メールが届く。
![スクリーンショット 2025-02-04 17.12.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3999483/83044f99-49ec-7056-a4d6-0d5b50f7fa70.png)

PDFにはその日のニュースの
・タイトル
・サマリ
・記事ソースURL
が記載されている。

![スクリーンショット 2025-02-04 17.16.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3999483/7bf0fb5c-d957-efe2-fb03-cb6de946aae6.png)
---


# 2. スクリプトの概要 

## **2-1. 機能**
1. **NewsAPI** から最新ニュースのタイトル・概要・本文を取得
2. 取得した情報をもとに **Gemini API** にニュース記事の要約を生成させる
3. **生成された要約を PDF に変換**
4. **Gmail を利用して PDF を指定のメールアドレスへ送信**

## **2-2. 使用ライブラリ**
- `os`
- `google.generativeai`
- `requests`
- `smtplib`
- `email.message.EmailMessage`
- `weasyprint.HTML`

## 2-3. 環境変数について
- NEWSAPI_KEY=your_newsapi_key
- GEMINI_API_KEY=your_gemini_api_key
- GMAIL_USER=your_email
- GMAIL_PASS=your_email_password
- GMAIL_RECEIVER=receiver_email

---

# 3. GCPでの運用自動化
## 3-1. 概要

本スクリプトを GCP 上で自動実行するために、Cloud Run と Cloud Scheduler を活用し、
毎日決まった時間に自動でニュース要約を取得し、メールで送信するシステム を構築した。

- Cloud Run：スクリプトをデプロイしたサーバレス環境。環境変数もここで設定した。
- Cloud Scheduler：毎日08:00のCronトリガー、スクリプトのデプロイ先のアドレスを設定し、Cloud Run上スクリプトのトリガーとして機能するように設定した。

## 3-2. 利用したGCPサービスと稼働確認
### Cloud Run (Run関数)
以下Cloud RunにPythonスクリプトをサービスとしてデプロイした。
(news-generate-gemini-function)
![スクリーンショット 2025-02-04 16.32.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3999483/b364c3a5-2483-d479-c8a7-279f9d370602.png)

以下ログ指標。cronで設定した08:00にCloud Runへリクエストが送信されていることがわかる。
![スクリーンショット 2025-02-04 16.39.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3999483/5b6a0212-6fa2-5f83-d953-556287af7723.png)

---

### Cloud Scheduler

以下Cloud SchedulerにCronジョブとしてnews_generate_gemini_jobを設定。


![スクリーンショット 2025-02-04 16.35.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3999483/c64ad36f-75e2-bb3e-d16a-24509bee00c7.png)

日次で8:00実行できていることがわかる。

![スクリーンショット 2025-02-04 16.59.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3999483/f1aa1d30-7330-6652-868c-2fde17a54848.png)


---
# 4. コードの詳細解説  
この章ではPythonスクリプトの各処理を解説する。
※OUTPUTしたく詳細に解説しているため少し長いです

## 4-1. 事前準備（環境変数のロードと検証）
このスクリプトでは、外部APIのキーやメール送信のための認証情報を **環境変数** に保存し、プログラム内で取得して利用します。  
環境変数はCloud Functions の環境変数で設定しました。

まず、以下のコードで環境変数を取得します。

```python
import os

# 環境変数を取得
NEWSAPI_KEY = os.getenv("NEWSAPI_KEY")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
GMAIL_USER = os.getenv("GMAIL_USER")
GMAIL_APP_PASS = os.getenv("GMAIL_APP_PASS")
GMAIL_RECEIVER = os.getenv("GMAIL_RECEIVER")
```

## 4-2. main処理について
CloudFunction内でmain(request) をエントリーポイントとして設定した。
この関数の中で、必要な処理を順番に呼び出す。
```python
def main(request):
    try:
        validate_env()  # 環境変数のチェック
        configure_api()  # APIキーのセットアップ
        articles = get_latest_news(max_articles=5)  # NewsAPI から記事を取得
        if not articles:
            return "❌ ニュース記事が取得できませんでした。", 500
        
        pdf_filename = create_pdf(process_articles(articles))  # 記事をPDFに変換
        send_email(pdf_filename)  # Gmail でPDFを送信

        return "✅ ニュース要約を送信しました！", 200
    except Exception as e:
        return f"⚠️ エラー発生: {e}", 500
```
この main() 関数では、以下の処理を順番に実行。
	1.	環境変数のバリデーションチェック
	2.	APIキー等のセットアップ
	3.	NewsAPI から記事取得
	4.	取得した記事を Gemini API で要約
	5.	HTML 形式に変換し PDF を作成
	6.	Gmail で PDF を送信

## 4-3. 各関数について
### ①環境変数のバリデーションチェック
#### 概要
この関数は .env や環境変数から APIキーやメール認証情報が正しく設定されているかを検証する。  

#### 処理の流れ
1.	必要な環境変数をリスト required_keys に定義
2.	os.getenv(key) で各環境変数の値を取得し、値が None または空ならリスト missing_keys に追加
3.	missing_keys に不足しているキーがある場合は ValueError を発生させる

```python
def validate_env():
    required_keys = ["NEWSAPI_KEY", "GEMINI_API_KEY", "GMAIL_USER", "GMAIL_APP_PASS", "GMAIL_RECEIVER"]
    missing_keys = [key for key in required_keys if not os.getenv(key)]
    if missing_keys:
        raise ValueError(f"❌ 以下の環境変数が見つかりません: {', '.join(missing_keys)}")
```
#### 引数と戻り値
- 引数: なし
- 返り値: なし（環境変数が正しく設定されていれば何もしない）

#### エラーハンドリング
必要な環境変数が欠けている場合、ValueError を発生させる。

---

### ②APIKey等のセットアップ

#### 概要

この関数は環境変数から APIキーやメール設定をグローバル変数に格納し、Gemini API を初期化 する。

#### 処理の流れ
1. os.getenv("KEY_NAME") を使って各環境変数の値を取得し、グローバル変数にセット
2. genai.configure(api_key=GEMINI_API_KEY) で Gemini API を初期化

```python
def configure_api():
    global NEWSAPI_KEY, GEMINI_API_KEY, GMAIL_USER, GMAIL_APP_PASS, GMAIL_RECEIVER
    NEWSAPI_KEY = os.getenv("NEWSAPI_KEY")
    GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
    GMAIL_USER = os.getenv("GMAIL_USER")
    GMAIL_APP_PASS = os.getenv("GMAIL_APP_PASS")
    GMAIL_RECEIVER = os.getenv("GMAIL_RECEIVER")
    genai.configure(api_key=GEMINI_API_KEY)  # ← ここに追加
```

#### 引数と返り値
- 引数: なし
- 返り値: なし（グローバル変数を更新）

#### エラーハンドリング
なし（validate_env() で事前に環境変数があることを確認するため）

---


### ③NewsAPIから記事取得
#### 概要
この関数は NewsAPI を使用して最新のニュース記事を取得する。

#### 処理の流れ
1.	リクエストURLを構築（country=us&category=business でビジネスニュースを取得）
2.	requests.get(url, timeout=10) を使って API にリクエストを送る
3.	レスポンスの JSON を解析し、"articles" キーの値を取得
4.	"status" != "ok" の場合はエラーを発生

```python
def get_latest_news(max_articles):
    url = f"https://newsapi.org/v2/top-headlines?country=us&category=business&pageSize={max_articles}&apiKey={NEWSAPI_KEY}"
    try:
        response = requests.get(url, timeout=10)
        data = response.json()
        if data.get("status") != "ok":
            raise ValueError(f"⚠️ NewsAPI エラー: {data.get('message')}")
        return data.get("articles", [])
    except requests.RequestException as e:
        raise RuntimeError(f"❌ NewsAPI 取得中にエラー発生: {e}")
```

#### 引数と返り値
- 引数: max_articles: 取得する記事の最大数（例: 5）
- 返り値: articles: ニュース記事のリスト（各要素は辞書）

#### エラーハンドリング
- 通信エラー (requests.RequestException) が発生した場合、RuntimeError を発生
- APIエラー ("status" != "ok") の場合、ValueError を発生


---



### ④記事からタイトル・概要・本文を取得
#### 概要
この関数は、NewsAPI から取得した記事データ（辞書型）からタイトル・概要・本文・URL を抽出する。

#### 処理の流れ
1. 記事データ (article) から "title" を取得し、空の場合は "タイトルなし" をセット。
2. 記事データ (article) から "description" を取得し、空の場合は "概要なし" をセット。
3. 記事データ (article) から "content" を取得し、空の場合は "本文なし" をセット。
4. 記事データ (article) から "url" を取得し、空の場合は ""（空文字）をセット。



```python
def extract_element(article):
    return (
        article.get("title") or "タイトルなし",
        article.get("description") or "概要なし",
        article.get("content") or "本文なし",
        article.get("url") or ""
    )
```
#### 引数と返り値
- 引数:
article (dict): NewsAPI から取得した1つの記事データ（辞書型）
- 返り値:
title (str): 記事のタイトル（または "タイトルなし"）
description (str): 記事の概要（または "概要なし"）
content (str): 記事の本文（または "本文なし"）
url (str): 記事のURL（または ""）

#### エラーハンドリング
- article に "title", "description", "content", "url" のいずれかがない場合でも、デフォルト値を返す ことでエラーを防ぐ。
- article が None の場合は、TypeError が発生する可能性があるため、事前にチェックするのも有効。



---

### ⑤Gemini APIでニュース記事を要約
#### 概要
この関数は Gemini API を使って、英語のニュース記事を日本語で要約する。

#### 処理の流れ
1.	title_en, description_en, content_en の 空白をチェックし、デフォルト値をセット。
2.	Gemini API に渡す プロンプトを作成（要約ルールを指定）。
3.	genai.GenerativeModel("gemini-1.5-flash") を用いて、APIリクエストを送信。
4.	APIのレスポンスを解析し、タイトルと本文の要約を取得。


```python
def generate_news_summary(title_en, description_en, content_en):
    if not title_en.strip():
        title_en = "タイトルなし"
    if not description_en.strip():
        description_en = "概要なし"
    if not content_en.strip():
        content_en = "本文なし"

    prompt = f"""
    以下の英語のニュースタイトルと概要と本文を基に、記事内容を要約してわかりやすく教えて

    **条件**
    1. タイトルを **自然な日本語に翻訳** する
    2. 概要と本文を基に **日本語の詳細なニュース要約** を作成する
    3. **シンプルで分かりやすい文章** にする
    4. **元の英語のテキストは含めない**

    **入力**
    - タイトル: {title_en}
    - 概要: {description_en}
    - 本文: {content_en}

    **出力フォーマット**
    タイトル: (ここに日本語タイトル)
    記事: (ここに日本語ニュース要約)
    """

    try:
        model = genai.GenerativeModel("gemini-1.5-flash")
        response = model.generate_content(prompt, generation_config={"max_output_tokens": 500})
        content = response.candidates[0].content.parts[0].text.strip()
        lines = content.split("\n")
        title_ja = lines[0].replace("タイトル:", "").strip() if len(lines) > 0 else "翻訳エラー"
        article_ja = "\n".join(lines[1:]).replace("記事:", "").strip() if len(lines) > 1 else "要約エラー"
        return title_ja, article_ja
    except Exception as e:
        return "Gemini API エラー", str(e)
```
#### 引数と返り値
- 引数:
title_en (str): 記事の英語タイトル
description_en (str): 記事の英語の概要
content_en (str): 記事の英語の本文
- 返り値:
title_ja (str): 記事の日本語タイトル
article_ja (str): 記事の日本語要約

#### エラーハンドリング
APIのエラー発生時は "Gemini API エラー" を返す。

---



### ⑥要約した記事をHTML変換
#### 概要

この関数は Gemini API で要約した記事を HTML にフォーマットする。

#### 処理の流れ
1.	各記事について extract_element(article)[:3] を取得。
2.	generate_news_summary() を呼び出し、日本語のタイトル・要約を取得。
3.	HTML タグ `(<h2>, <p>, <a>) `を追加し、記事をフォーマット。


```python
def process_articles(articles):
    content = ""
    for article in articles:
        title, summary = generate_news_summary(*extract_element(article)[:3])
        content += f"<h2>📰 {title}</h2>"
        content += f"<p>{summary}</p>"
        content += f"<p>🔗 <a href='{extract_element(article)[3]}'>{extract_element(article)[3]}</a></p>"
    return content
```

#### 引数と返り値
- 引数:
articles (list[dict]): ニュース記事のリスト
- 返り値:
content (str): HTML 形式の文字列

#### エラーハンドリング
なし

---

### ⑦PDF作成
#### 概要
この関数は ニュースの要約を PDF に変換する。
#### 処理の流れ
1.	HTML 文字列を作成 (meta charset='UTF-8' を含む)
2.	WeasyPrint の HTML().write_pdf(filename) を使用し、HTML を PDF に変換
3.	変換が成功した場合、PDF のファイル名を返す


```python
def create_pdf(content, filename="news_summary.pdf"):
    try:
        HTML(string=f"<html><head><meta charset='UTF-8'></head><body><h1>📰 今日のニュース要約</h1>{content}</body></html>").write_pdf(filename)
        return filename
    except Exception as e:
        raise RuntimeError(f"⚠️ PDF作成に失敗しました: {e}")
```
#### 引数と返り値
- 引数:
content (str): PDF に変換する HTML 文字列
filename (str, デフォルト: "news_summary.pdf"): 保存するファイル名
- 返り値:
filename (str): 作成した PDF のファイル名

#### エラーハンドリング
- HTML 変換に失敗した場合、RuntimeError を発生させる。
- PDF 作成処理中にエラーが発生した場合、エラーメッセージとともに RuntimeError を発生させる。


---

### ⑧Gmail送信

#### 概要
この関数は 作成した PDF を Gmail で送信する。

#### 処理の流れ
1.	EmailMessage() を作成し、件名・送信元・宛先・本文を設定。
2.	msg.add_attachment() で PDF を添付。
3.	smtplib.SMTP_SSL() を使い、Gmail SMTP サーバー経由で送信。

```python
def send_email(pdf_filename):
    try:
        msg = EmailMessage()
        msg["Subject"] = "📩 今日のニュース要約"
        msg["From"] = GMAIL_USER
        msg["To"] = GMAIL_RECEIVER
        msg.set_content("今日のニュースを要約しました。添付PDFをご確認ください。")
        with open(pdf_filename, "rb") as f:
            msg.add_attachment(f.read(), maintype="application", subtype="pdf", filename=pdf_filename)
        with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
            server.login(GMAIL_USER, GMAIL_APP_PASS)
            server.send_message(msg)
    except Exception as e:
        raise RuntimeError(f"⚠️ メール送信に失敗しました: {e}")
```
#### 引数と返り値
- 引数:
pdf_filename (str): 送信する PDF のファイル名（フルパス）
- 返り値:
なし（成功時は特に値を返さず、処理を完了）

#### エラーハンドリング
- SMTP サーバーへの接続に失敗した場合、RuntimeError を発生させる。
- ログイン認証に失敗した場合、RuntimeError を発生させる。
- メール送信に失敗した場合、エラーメッセージとともに RuntimeError を発生させる。

---

# 5. 今後の課題
### 記事の要約結果の信頼性
NewsAPI を使用しているが、無料プランでは 一部のメディアしか取得できないかつ、記事本文の取得文字数が200文字までという制限がある。これでは記事の信頼性に欠けるため、スクレイピング可のニュースサイトを調査し、スクレイピングで直接データを取得し信頼性のあるニュースを出力できるようにすることが課題である。

### プロンプトの工夫
スクレイピングできないサービスが多かったためNewsAPIを利用して記事内容を取得する構成としたが、プロンプトを工夫して都度GeminiにWebブラウジングしてもらう形とすれば最新ニュースの記事の信頼性が上がるかつスクレイピングに当たらない可能性があると考えているためここは追加調査していきたい。

### 出力のUIの工夫
現状はUIライブラリの知識等がないのでHTML形式のPDF出力としているが将来的には綺麗なUIを出力したい


---