WindowsタスクスケジューラーからCloudFunctionsへ。Misskey自動投稿botの環境移行

## 背景
BeautyBot という記事投稿自動化スクリプト（[こちらの記事で作成しました。](https://qiita.com/ichisak/items/6faba3e352cfe5df91f6)）を Windows のタスクスケジューラで動かしていましたが、今回GCP の Cloud Functions に移行しました。
この記事では、その移行プロセスや得られた知見をまとめます。

## もともとの環境（Windowsタスクスケジューラ）
以前は以下のような環境で運用していました。

- Windows 10 上で Python スクリプトを実行
- タスクスケジューラを使って定期実行
    - Misskey への投稿処理を実行（投稿は1時間ごと）
    - 記事は1日1回スクレイピングして取得

タスクスケジューラは手軽に定期実行できるのですが、

- PC がスリープすると実行されない
- 起動する時間になると画面がコマンドプロンプトに占拠されるのがウザい（特にこれ）

といった課題がありました。

### Cloud Functions に移行するメリット
GCP の Cloud Functions を使うことで、

- PC が不要になり、クラウド上で安定して動作
- ログ管理が Cloud Logging で容易
- デプロイを行うだけでコード更新が反映される
- スケジューラと組み合わせることで定期実行が簡単

といったメリットを得られます。

## Cloud Functions への移行手順

### 1. 環境の準備
#### Cloud CDKを使用する準備
Google Cloud をどうやって使いたいか を考えたときに、「CLIで操作したい！」と思いました。理由はなんかかっこいいからです。ChatGPT とやり取りする中で、Linux コマンドの方が便利そうだと分かったので、WSL（Windows Subsystem for Linux）のセットアップ → Cloud SDK の導入 → Google Cloud の初期設定 の流れで進めることにしました。
まずPowerShellを起動してWSLをインストールしていきます。（Ubuntuを使用しました。）
```PowerShell
wsl --install
wsl --set-default-version 2
```
これでコマンドプロンプトでwslとコマンドを入れるだけでLinuxコマンドが使えるようになります。
次にUbuntu環境にCloudSDKをインストールします。
```sh
sudo apt update && sudo apt install curl -y
curl -O https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-441.0.0-linux-x86_64.tar.gz
tar -xvzf google-cloud-sdk-441.0.0-linux-x86_64.tar.gz
./google-cloud-sdk/install.sh
```
これでgcloudコマンドが使えるようになります。一旦下準備は完了です。

GCP の Cloud Functions を使うには、まず GCP プロジェクトを作成し、必要な API を有効化します。
```sh
# gcloud CLI を使ってプロジェクトを設定
PROJECT_ID=my-gcp-project
gcloud config set project $PROJECT_ID

# Cloud Functions API を有効化
gcloud services enable cloudfunctions.googleapis.com
```
※GCPにはすでに美容情報投稿bot用のプロジェクトを用意してあったので上記の手順は実際にはスキップしました。
### 2. ローカル環境での動作確認
Cloud Functions にデプロイする前に、ローカル環境で動作確認を行うこととしました。
まず、Python の仮想環境を作成します。
```sh
python -m venv venv
source venv/bin/activate  
```
次に、必要なパッケージ（Flask, google-cloud-storage など）をインストールします。
```sh
pip install flask google-cloud-storage
```
Flask を使ってローカルで API を動作確認できるようにしました。

### 3. Cloud Functions 用のコードを準備
タスクスケジューラで実行していた Python スクリプトを、Cloud Functions 用に変更しました。

```python:main.py
import os
import json
from google.cloud import storage
import logging
import google.cloud.logging
from google.cloud.logging import DESCENDING
from datetime import datetime
from flask import Flask, request
from post_to_misskey import post_to_misskey # Misskey投稿関数をインポート

app = Flask(__name__)

#Cloud Loggingの初期化
client = google.cloud.logging.Client()
client.setup_logging()
logging.basicConfig(level=logging.INFO)
logging.info("ログ開始")

#日付設定
exectuion_date = datetime.today().strftime("%Y-%m-%d")

#Loggingへログの書き込み
def log_message(message):
    logging.info(f"{datetime.now()} - {message}")

#GCSの設定
BUCKET_NAME = "beauty-info-bot"
JSON_FILE = "articles.json"
LOCAL_JSON_FILE ="/tmp/articles.json"

#GCSから記事データをダウンロード
def load_articles(filename):
    storage_client = storage.Client()
    bucket = storage_client.bucket(BUCKET_NAME)
    blob = bucket.blob(JSON_FILE)

    if not blob.exists():
        log_message("GCSに記事ファイルがありません。")
        return []
    
    #一時的にローカルにダウンロード
    blob.download_to_filename(LOCAL_JSON_FILE)

    #Json読み込み
    with open(LOCAL_JSON_FILE, "r", encoding="utf-8") as f:
        try:
            return json.load(f)
        except json.JSONDecodeError:
            log_message("JSONデコードエラー")
            return []


#JSONファイルをtmpに保存する
def save_articles(articles):
    # GCSにアップロード
    storage_client = storage.Client()
    bucket = storage_client.bucket(BUCKET_NAME)
    blob = bucket.blob(JSON_FILE)

    try:    
        blob.upload_from_string(json.dumps(articles, ensure_ascii=False, indent=4))
        log_message(f"GCSにファイルをアップロードしました: {JSON_FILE}")
    except Exception as e:
        log_message(f"GCSアップロードエラー: {e}")


# Misskeyに投稿
POST_COUNT = 2

def post_articles():
    articles = load_articles(JSON_FILE)

    if not articles:
        log_message("投稿する記事がありません。")
        return
    
    remaining_articles = []
    post_count = 0

    for article in articles[:POST_COUNT]:
        title = article["title"]
        link = article["link"]
        text = f"📰 {title}\n🔗 {link}"
            
        response = post_to_misskey(text)

        if response:
            post_count += 1 #投稿したらカウントを増やす
            log_message(f"投稿成功: {title}")
        else:
            remaining_articles.append(article) #投稿に失敗した記事は残す
            log_message(f"投稿失敗: {title}")

    # 投稿に成功した記事は削除する
    remaining_articles.extend(articles[POST_COUNT:])
    save_articles(remaining_articles)
    log_message(f"{post_count}件の記事を投稿しました。")


@app.route("/", methods=["GET", "POST"])
def index():
    return beauty_bot_function(request)

# Cloud Functions 用のエントリポイント
def beauty_bot_function(request):
    log_message("Beauty Bot function started.")  # ログを追加
    try:
        post_articles()
    except Exception as e:
        logging.error(f"エラーが発生しました: {e}")
    return "Beauty Bot is working!"

if __name__ == "__main__":
    app.run(debug=True)
```
```python:post_to_misskey.py
import requests
from dotenv import load_dotenv
import os
import logging
import google.cloud.logging
from datetime import datetime

#Cloud Loggingの初期化
client = google.cloud.logging.Client()
client.setup_logging()
logging.basicConfig(level=logging.INFO)

#日付設定
exectuion_date = datetime.today().strftime("%Y-%m-%d")

#Loggingへログの書き込み
def log_message(message):
    logging.info(f"{datetime.now()} - {message}")


load_dotenv()

MISSKEY_INSTANCE = os.getenv("MISSKEY_URL")  # 自分のMisskeyインスタンスのURL
API_TOKEN = os.getenv("API_TOKEN") # 発行したAPIトークン

def post_to_misskey(text):
    url = f"{MISSKEY_INSTANCE}/api/notes/create"
    payload = {
        "i": API_TOKEN,
        "visibility": "home",
        "text": text
    }

    try:
        headers = {
            "Content-Type": "application/json"
        }
        response = requests.post(url, json=payload,headers=headers)

        response.raise_for_status()
        
        if response.status_code == 200:
            data = response.json()
            if "createdNote" in data:
                return True
            else:
                log_message(f"投稿失敗（レスポンス異常）：{data}")
                return False
        else:
            log_message(f"投稿失敗（ステータスコード: {response.status_code}）：{response.text}")
            return False
    except requests.exceptions.RequestException as e:
        log_message(f"Misskey投稿エラー：{e}")
        return False
```
ローカル環境で Flask サーバーを起動し、http://127.0.0.1:5000/ にアクセスして動作確認を行いました。

#### 環境変数を外部管理する
ローカル環境では、一応APIトークンやMisskeyのURLなどの機密情報を .env ファイルに保存し、Python の dotenv ライブラリで読み込んでいました。しかし、クラウド環境に移行すると、環境変数を直接 .env に保存してアップロードしてしまうのは適切ではないように思われます。「.env」から yaml ファイル（.env.yaml）へ移行しました。
```yaml
MISSKEY_URL: "https://yout-misskey.com/"
API_TOKEN: "your_secret_token"
```

#### CloudFunctionsを利用する上での構成変更
元々ローカル環境で動かしているときは以下のように構成していました。全部1つのフォルダに入れていても特に問題はありませんでした。
```
BeautyBot
┣ main.py
┣ post_to_missky.py
┣ get_articles_maquia.py
┣ get_articles_biteki.py
┣ get_articles_voce.py
┣ articles.json
┣ .env
┗ log.txt
```
しかし記事のスクレイピングは1日1回の実行でいいですが、Misskeyへの投稿は毎時行いたいため、関数を2つ作る必要があること、また、記事を保存するarticles.jsonは「ファイル」であり、スクレイピング関数も投稿関数からもアクセスしたいので、CloudStorageへ置きたい、という要件を元に考えた構成が以下です。
① 投稿する関数としてbeauty_bot_function
② スクレイピングする関数としてbeauty_bot_scapper
の2本立てです。
また、ローカルにおいているlog.txtにベタ書きでログを吐き出していた部分もGCPのCloudLoggingで見れるように修正しました。
```
BeautyBot
┣ beauty_bot_function
┃ ┣ main.py
┃ ┣ post_to_missky.py
┃ ┣ .env.yaml
┃ ┗ requirements.txt
┗ beauty_bot_scrapper
  ┣ main.py
  ┣ get_articles_maquia.py
  ┣ get_articles_biteki.py
  ┣ get_articles_voce.py
  ┗ requirements.txt
```
このように構成変更をし、仮想環境での動作確認を行った上で、Cloud Functionsにデプロイしていきます。

### 4. Cloud Functions にデプロイ

Cloud Functions へデプロイするには、以下のコマンドを実行します。

```sh
# 関数をデプロイ
gcloud functions deploy beauty-bot \
    --runtime python39 \
    --trigger-http \
    --allow-unauthenticated
```

### 5. Cloud Scheduler で定期実行
Cloud Functions は HTTP トリガーで実行するため、定期実行するには Cloud Scheduler を使います。

```sh
# Cloud Scheduler で1時間ごとに関数を呼び出す
gcloud scheduler jobs create http beauty-bot-job \
    --schedule "0 * * * *" \
    --uri "https://REGION-PROJECT_ID.cloudfunctions.net/beauty-bot" \
    --http-method=GET
```

## まとめ
### 移行後の感想

Cloud Functions への移行によって、

- 手元のPCを起動しなくても動くようになった
- ログ管理が楽になった
- デプロイによる更新がスムーズになった

といったメリットがあり、運用の負担が大幅に減りました。

### 今回の作業を通して成長したポイント
* WSLを入れてLinuxコマンドを使えるようになった
* CloudSDKを入れてCLI操作を覚えた
* 環境変数の外部管理を学んだ
* ローカル環境で、仮想環境を立ててPython プロジェクトを管理できるようになった
* Cloud Functions をデプロイして動作させた
* Cloud Functions から Cloud Storage へ接続し、データを操作できるようになった