Google Drive の画像を linebot へ送信する機能のモジュール化

Google Drive の画像を linebot へ送信する機能のモジュール化

import requests
import random
import time
from googleapiclient.discovery import build
from google.oauth2 import service_account

# ① Google Drive API の設定
SCOPES = ["https://www.googleapis.com/auth/drive"]
SERVICE_ACCOUNT_FILE = "service_account.json"

creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
drive_service = build("drive", "v3", credentials=creds)

# ② 送信先の LINE API 設定
LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push"
LINE_CHANNEL_ACCESS_TOKEN = ""

USER_ID = ""


# ③ Google Drive の「temp」フォルダID
FOLDER_ID = ""

def get_drive_images():
    """
    Google Drive の temp フォルダ内の画像リストを取得し、画像の `id` をリストで返す
    """
    query = f"'{FOLDER_ID}' in parents and mimeType contains 'image/' and trashed=false"
    results = drive_service.files().list(q=query, fields="files(id, name)").execute()
    files = results.get("files", [])

    if not files:
        print("No images found in the Drive folder.")
        return None

    return [file["id"] for file in files]

def get_drive_image_url(file_id):
    """
    Google Drive のファイルIDを公開URLに変換する
    """
    try:
        # ファイルのアクセス権限を「公開」に設定
        drive_service.permissions().create(
            fileId=file_id,
            body={"role": "reader", "type": "anyone"},
        ).execute()

        # 画像のダウンロードURLを取得
        return f"https://drive.google.com/thumbnail?id={file_id}&sz=w1000"
    except Exception as e:
        print(f"Error making image public: {e}")
        return None

def send_image(image_url):
    """
    LINE API を使い、取得した画像URLを LINE ユーザーに送信する
    """
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {LINE_CHANNEL_ACCESS_TOKEN}"
    }

    payload = {
        "to": USER_ID,
        "messages": [
            {
                "type": "image",
                "originalContentUrl": image_url,
                "previewImageUrl": image_url
            }
        ]
    }

    response = requests.post(LINE_PUSH_URL, headers=headers, json=payload)
    print(response.status_code, response.text)

def main():
    """
    画像リストを取得し、ランダムな画像を選んでLINEに送信
    """
    image_ids = get_drive_images()
    if not image_ids:
        print("No images found, skipping LINE push.")
        return

    # 画像をランダムに選択
    random_image_id = random.choice(image_ids)
    image_url = get_drive_image_url(random_image_id)

    if image_url:
        # LINE に画像を送信
        send_image(image_url)
    else:
        print("Failed to get a valid image URL.")

# スケジュール実行(30分ごと)
if __name__ == "__main__":
    while True:
        main()
        time.sleep(1800)  # 30分ごとに実行

をモジュールにして文字送信以外に画像も送信可能にする

 vim line_image_sender.py

内容は

import requests
import random
from googleapiclient.discovery import build
from google.oauth2 import service_account

# 設定
SCOPES = ["https://www.googleapis.com/auth/drive"]
SERVICE_ACCOUNT_FILE = "service_account.json"

LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push"
LINE_CHANNEL_ACCESS_TOKEN = "YOUR_LINE_CHANNEL_ACCESS_TOKEN"
USER_ID = "YOUR_LINE_USER_ID"
FOLDER_ID = "YOUR_GOOGLE_DRIVE_FOLDER_ID"

# 認証とサービスの初期化
creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
drive_service = build("drive", "v3", credentials=creds)

def get_drive_images(folder_id=FOLDER_ID):
    query = f"'{folder_id}' in parents and mimeType contains 'image/' and trashed=false"
    results = drive_service.files().list(q=query, fields="files(id, name)").execute()
    files = results.get("files", [])
    return [file["id"] for file in files] if files else []

def get_drive_image_url(file_id):
    try:
        drive_service.permissions().create(
            fileId=file_id,
            body={"role": "reader", "type": "anyone"},
        ).execute()
        return f"https://drive.google.com/thumbnail?id={file_id}&sz=w1000"
    except Exception as e:
        print(f"Error making image public: {e}")
        return None

def send_image(image_url, user_id=USER_ID, token=LINE_CHANNEL_ACCESS_TOKEN):
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {token}"
    }
    payload = {
        "to": user_id,
        "messages": [
            {
                "type": "image",
                "originalContentUrl": image_url,
                "previewImageUrl": image_url
            }
        ]
    }
    response = requests.post(LINE_PUSH_URL, headers=headers, json=payload)
    print(response.status_code, response.text)

def send_random_image():
    image_ids = get_drive_images()
    if not image_ids:
        print("No images found.")
        return
    random_image_id = random.choice(image_ids)
    image_url = get_drive_image_url(random_image_id)
    if image_url:
        send_image(image_url)
    else:
        print("Failed to get valid image URL.")

# スクリプトとして直接実行された場合の処理(30分おきに実行)
if __name__ == "__main__":
    import time
    while True:
        send_random_image()
        time.sleep(1800)

これを config.jsonから読み込むようにする

cp ../gas_bot/config.json .

でコピー

{
  "token": "",
  "ollama_model": "elyza:jp8b",
  "line_bot_channel_access_token": "",
  "channel_secret": "",
  "line_bot_user_id": "",
  "google_drive_folder_id": ""
}

というように
Google drive のIDを追加する

import requests
import random
import json
from googleapiclient.discovery import build
from google.oauth2 import service_account

# 設定ファイルの読み込み
with open("config.json", "r") as f:
    config = json.load(f)

LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push"
LINE_CHANNEL_ACCESS_TOKEN = config["line_bot_channel_access_token"]
USER_ID = config["line_bot_user_id"]
FOLDER_ID = config["google_drive_folder_id"]

# Google Drive API の設定
SCOPES = ["https://www.googleapis.com/auth/drive"]
SERVICE_ACCOUNT_FILE = "service_account.json"

creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
drive_service = build("drive", "v3", credentials=creds)

def get_drive_images(folder_id=FOLDER_ID):
    query = f"'{folder_id}' in parents and mimeType contains 'image/' and trashed=false"
    results = drive_service.files().list(q=query, fields="files(id, name)").execute()
    files = results.get("files", [])
    return [file["id"] for file in files] if files else []

def get_drive_image_url(file_id):
    try:
        drive_service.permissions().create(
            fileId=file_id,
            body={"role": "reader", "type": "anyone"},
        ).execute()
        return f"https://drive.google.com/thumbnail?id={file_id}&sz=w1000"
    except Exception as e:
        print(f"Error making image public: {e}")
        return None

def send_image(image_url, user_id=USER_ID, token=LINE_CHANNEL_ACCESS_TOKEN):
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {token}"
    }
    payload = {
        "to": user_id,
        "messages": [
            {
                "type": "image",
                "originalContentUrl": image_url,
                "previewImageUrl": image_url
            }
        ]
    }
    response = requests.post(LINE_PUSH_URL, headers=headers, json=payload)
    print(response.status_code, response.text)

def send_random_image():
    image_ids = get_drive_images()
    if not image_ids:
        print("No images found.")
        return
    random_image_id = random.choice(image_ids)
    image_url = get_drive_image_url(random_image_id)
    if image_url:
        send_image(image_url)
    else:
        print("Failed to get valid image URL.")

# スクリプトとして実行されたとき、30分おきに画像送信
if __name__ == "__main__":
    import time
    while True:
        send_random_image()
        time.sleep(1800)

というようにコード変更
これで設定ファイルから読み込むようになる

あとは linebot の画像の管理

これだとどんどん画像が増えていく

Google drive 公開設定

Google drive 公開設定

from googleapiclient.discovery import build
from google.oauth2 import service_account

# 認証情報のセットアップ(Google Drive API 用のサービスアカウント)
SCOPES = ["https://www.googleapis.com/auth/drive.metadata.readonly"]
SERVICE_ACCOUNT_FILE = "service_account.json"

creds = service_account.Credentials.from_service_account_file(
    SERVICE_ACCOUNT_FILE, scopes=SCOPES
)
service = build("drive", "v3", credentials=creds)

# 共有されているフォルダを検索
query = "mimeType='application/vnd.google-apps.folder' and sharedWithMe"
results = service.files().list(q=query, fields="files(id, name, owners, webViewLink)").execute()
folders = results.get("files", [])

if not folders:
    print("公開されているフォルダはありません。")
else:
    for folder in folders:
        print(f"フォルダ名: {folder['name']}, URL: {folder['webViewLink']}, 所有者: {folder['owners'][0]['emailAddress']}")

の結果からいくつか気になったのでGPTで確認

となったが
✅ Google Drive フォルダの webViewLink が表示されるなら公開URLになっているのか?
結論:まだ「完全な公開URL」にはなっていません! 🚨
* webViewLink は 「Google Drive 上でフォルダを閲覧できるURL」 であり、
誰でも直接アクセスできる公開URLではない 可能性があります。
* LINE Bot で画像を表示するには「画像自体のURL」が必要 であり、フォルダの webViewLink ではなく、画像ファイルごとのURLを取得する必要がある。

とのこと

🚀 1. webViewLink とは?
* webViewLink のURL(例: https://drive.google.com/drive/folders/…)は、Google Drive 上のフォルダを開くためのもの。
* これは「フォルダの中身をブラウザで表示する」ためのURLであり、LINE API で画像を表示するための直接URLではない。
✅ 公開されているフォルダなら、自分のアカウントでアクセス可能 ❌ ただし、画像を埋め込むには、個々の画像の公開URLが必要

本当にフォルダ内の画像が公開されているか確認
フォルダが「共有」されているだけでは、フォルダ内のファイルが「完全公開」されているとは限りません。
各画像の permissions を確認することで、画像が一般公開されているかどうかをチェック

python folder_access.py 

from googleapiclient.discovery import build
from google.oauth2 import service_account

# Google Drive API 認証設定
SCOPES = ["https://www.googleapis.com/auth/drive"]
SERVICE_ACCOUNT_FILE = "service_account.json"

creds = service_account.Credentials.from_service_account_file(
    SERVICE_ACCOUNT_FILE, scopes=SCOPES
)
drive_service = build("drive", "v3", credentials=creds)

# 共有フォルダ ID
FOLDER_ID = ""

def check_image_permissions():
    """フォルダ内の画像が公開されているかチェック"""
    query = f"'{FOLDER_ID}' in parents and mimeType contains 'image/' and trashed=false"
    results = drive_service.files().list(q=query, fields="files(id, name, permissions, webViewLink)").execute()
    files = results.get("files", [])

    if not files:
        print("フォルダ内に画像がありません。")
        return

    for file in files:
        file_id = file["id"]
        file_name = file["name"]
        webViewLink = file.get("webViewLink", "なし")

        # ファイルの公開設定を確認
        permissions = drive_service.permissions().list(fileId=file_id).execute()
        public_access = any(p["type"] == "anyone" for p in permissions.get("permissions", []))

        if public_access:
            print(f"✅ 画像: {file_name} は公開されています。URL: {webViewLink}")
        else:
            print(f"❌ 画像: {file_name} は非公開です。URL: {webViewLink} (アクセス不可)")

# 画像の公開設定を確認
check_image_permissions()

結果は

❌ 画像: PXL_20240617_182349485.jpg は非公開です。URL: https://drive.google.com/file/ (アクセス不可)

となるので
完全公開になっていないため画像が取得できていなかった

✅ 問題点
LINE の originalContentUrl / previewImageUrl に Google Drive の
「uc?id=…」形式のURL を指定しているため、画像が正しく表示されない可能性がある。
Google Drive の 「uc?id=…」形式のURLは、一部の環境では直接開けないことがある(プレビュー画面になってしまう)。

このためコードの変更

def get_drive_image_url(file_id):
    return f"https://drive.google.com/uc?id={file_id}"

から
修正後(公開URLを取得するコードに変更)

def get_drive_image_url(file_id):
    """Google Drive のファイルIDを公開URLに変換する"""
    try:
        # ファイルのアクセス権限を「公開」に設定
        drive_service.permissions().create(
            fileId=file_id,
            body={"role": "reader", "type": "anyone"},
        ).execute()

        # 画像のダウンロードURLを取得
        return f"https://drive.google.com/thumbnail?id={file_id}&sz=w1000"
    except Exception as e:
        print(f"Error making image public: {e}")
        return None

これで全体コードは

import requests
import random
import time
from googleapiclient.discovery import build
from google.oauth2 import service_account

# ① Google Drive API の設定
SCOPES = ["https://www.googleapis.com/auth/drive"]
SERVICE_ACCOUNT_FILE = "service_account.json"

creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
drive_service = build("drive", "v3", credentials=creds)

# ② 送信先の LINE API 設定
LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push"
LINE_CHANNEL_ACCESS_TOKEN = ""

USER_ID = "USER_LINE_ID"

# ③ Google Drive の「temp」フォルダID
FOLDER_ID = ""

def get_drive_images():
    """
    Google Drive の temp フォルダ内の画像リストを取得し、画像の `id` をリストで返す
    """
    query = f"'{FOLDER_ID}' in parents and mimeType contains 'image/' and trashed=false"
    results = drive_service.files().list(q=query, fields="files(id, name)").execute()
    files = results.get("files", [])

    if not files:
        print("No images found in the Drive folder.")
        return None

    return [file["id"] for file in files]

def get_drive_image_url(file_id):
    """
    Google Drive のファイルIDを公開URLに変換する
    """
    try:
        # ファイルのアクセス権限を「公開」に設定
        drive_service.permissions().create(
            fileId=file_id,
            body={"role": "reader", "type": "anyone"},
        ).execute()

        # 画像のダウンロードURLを取得
        return f"https://drive.google.com/thumbnail?id={file_id}&sz=w1000"
    except Exception as e:
        print(f"Error making image public: {e}")
        return None

def send_image(image_url):
    """
    LINE API を使い、取得した画像URLを LINE ユーザーに送信する
    """
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {LINE_CHANNEL_ACCESS_TOKEN}"
    }

    payload = {
        "to": USER_ID,
        "messages": [
            {
                "type": "image",
                "originalContentUrl": image_url,
                "previewImageUrl": image_url
            }
        ]
    }

    response = requests.post(LINE_PUSH_URL, headers=headers, json=payload)
    print(response.status_code, response.text)

def main():
    """
    画像リストを取得し、ランダムな画像を選んでLINEに送信
    """
    image_ids = get_drive_images()
    if not image_ids:
        print("No images found, skipping LINE push.")
        return

    # 画像をランダムに選択
    random_image_id = random.choice(image_ids)
    image_url = get_drive_image_url(random_image_id)

    if image_url:
        # LINE に画像を送信
        send_image(image_url)
    else:
        print("Failed to get a valid image URL.")

# スケジュール実行(30分ごと)
if __name__ == "__main__":
    while True:
        main()
        time.sleep(1800)  # 30分ごとに実行

として再度実行すると

400 {"message":"The property, 'to', in the request body is invalid (line: -, column: -)"}

よくみたらユーザIDを設定忘れていた…

import requests
import random
import time
from googleapiclient.discovery import build
from google.oauth2 import service_account

# ① Google Drive API の設定
SCOPES = ["https://www.googleapis.com/auth/drive"]
SERVICE_ACCOUNT_FILE = "service_account.json"

creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
drive_service = build("drive", "v3", credentials=creds)

# ② 送信先の LINE API 設定
LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push"
LINE_CHANNEL_ACCESS_TOKEN = ""

USER_ID = ""


# ③ Google Drive の「temp」フォルダID
FOLDER_ID = ""

def get_drive_images():
    """
    Google Drive の temp フォルダ内の画像リストを取得し、画像の `id` をリストで返す
    """
    query = f"'{FOLDER_ID}' in parents and mimeType contains 'image/' and trashed=false"
    results = drive_service.files().list(q=query, fields="files(id, name)").execute()
    files = results.get("files", [])

    if not files:
        print("No images found in the Drive folder.")
        return None

    return [file["id"] for file in files]

def get_drive_image_url(file_id):
    """
    Google Drive のファイルIDを公開URLに変換する
    """
    try:
        # ファイルのアクセス権限を「公開」に設定
        drive_service.permissions().create(
            fileId=file_id,
            body={"role": "reader", "type": "anyone"},
        ).execute()

        # 画像のダウンロードURLを取得
        return f"https://drive.google.com/thumbnail?id={file_id}&sz=w1000"
    except Exception as e:
        print(f"Error making image public: {e}")
        return None

def send_image(image_url):
    """
    LINE API を使い、取得した画像URLを LINE ユーザーに送信する
    """
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {LINE_CHANNEL_ACCESS_TOKEN}"
    }

    payload = {
        "to": USER_ID,
        "messages": [
            {
                "type": "image",
                "originalContentUrl": image_url,
                "previewImageUrl": image_url
            }
        ]
    }

    response = requests.post(LINE_PUSH_URL, headers=headers, json=payload)
    print(response.status_code, response.text)

def main():
    """
    画像リストを取得し、ランダムな画像を選んでLINEに送信
    """
    image_ids = get_drive_images()
    if not image_ids:
        print("No images found, skipping LINE push.")
        return

    # 画像をランダムに選択
    random_image_id = random.choice(image_ids)
    image_url = get_drive_image_url(random_image_id)

    if image_url:
        # LINE に画像を送信
        send_image(image_url)
    else:
        print("Failed to get a valid image URL.")

# スケジュール実行(30分ごと)
if __name__ == "__main__":
    while True:
        main()
        time.sleep(1800)  # 30分ごとに実行

で実行すると問題なく画像が表示される

次はこれをモジュールにしてLINEのメッセージ送信と組み合わせる

Linebot へ画像を送信する その2

Linebot へ画像を送信する

🚀 1. LINE Messaging API の画像送信の条件
LINE の push メッセージや reply メッセージで画像を送信する際、
originalContentUrl と previewImageUrl には 「インターネット上でアクセスできる URL」 を指定する必要があります。
✅ 使用できる URL の条件
1. インターネット上に公開されている
2. https でアクセスできる
3. 画像サイズが 1MB 以下(それ以上は送信できない)
4. MIMEタイプが image/jpeg, image/png, image/gif のいずれか
5. CORS制限がない(Google Drive の「共有リンクを取得」で”リンクを知っている全員が閲覧可能”にする)

ということで
Google drive へ保存し公開にすることで送信できるようにする
既に tmpフォルダを作成済みで
ここへ画像を保存することにする

Google drive API を使うためservice_account.jsonの取得が必要

なお権限の関係上、新しいプロジェクトを作成し
サービスアカウントの作成する

まずプロジェクト名を設定

次にこのプロジェクトに切り替え
Google Drive API を有効化

サービスアカウントを作成
1. 「APIとサービス」 → 「認証情報」 を開く
2. 「+ 認証情報を作成」 → 「サービスアカウント」 を選択
3. サービスアカウントの名前を入力(例: linebot-drive-access)
4. 「作成」ボタンをクリック
5. 「ロールの選択」で編集者を選択

ユーザーにこのサービスアカウントへのアクセスを許可(省略可)
は省力してOK

次に
Google Drive の「共有設定」にサービスアカウントのメールを追加

サービスアカウントのメルアドになるので
これを追加する

次に
サービスアカウントの JSON キー(service_account.json)を取得

APIとサービス」 → 「認証情報」 に戻る
作成したサービスアカウントをクリック
「キー」タブを開く
「鍵を追加」→「新しい鍵を作成」
「JSON」 を選択し、「作成」をクリック
自動的に service_account.json がダウンロードされる

 cp ~/Downloads/linebot-gdrive-fd48d69bd326.json .

でコピー

mv linebot-gdrive-fd48d69bd326.json service_account.json      

でファイル名を変える

次に動作確認

 vim drive_access.py

内容は

from googleapiclient.discovery import build
from google.oauth2 import service_account

# ① サービスアカウントの JSON キーファイル
SERVICE_ACCOUNT_FILE = "service_account.json"
SCOPES = ["https://www.googleapis.com/auth/drive"]

# ② 認証情報をセット
creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
drive_service = build("drive", "v3", credentials=creds)

# ③ Google Drive の temp フォルダの ID
FOLDER_ID = ""

def list_files():
    query = f"'{FOLDER_ID}' in parents and trashed=false"
    results = drive_service.files().list(q=query, fields="files(id, name)").execute()
    return results.get("files", [])

# ④ 画像一覧を取得
files = list_files()
if files:
    for file in files:
        print(f"File: {file['name']}, ID: {file['id']}")
else:
    print("No files found in the Drive folder.")

これで

python drive_access.py 

を実行すると

File: PXL_20240617_182349485.jpg, ID: 

というようにアクセスできるのがわかる

次に画像の送信のテストをする

その前に公開ディレクトリの確認をする

 vim open_folder_check.py

中身は

from googleapiclient.discovery import build
from google.oauth2 import service_account

# 認証情報のセットアップ(Google Drive API 用のサービスアカウント)
SCOPES = ["https://www.googleapis.com/auth/drive.metadata.readonly"]
SERVICE_ACCOUNT_FILE = "path/to/service_account.json"

creds = service_account.Credentials.from_service_account_file(
    SERVICE_ACCOUNT_FILE, scopes=SCOPES
)
service = build("drive", "v3", credentials=creds)

# 共有されているフォルダを検索
query = "mimeType='application/vnd.google-apps.folder' and sharedWithMe"
results = service.files().list(q=query, fields="files(id, name, owners, webViewLink)").execute()
folders = results.get("files", [])

if not folders:
    print("公開されているフォルダはありません。")
else:
    for folder in folders:
        print(f"フォルダ名: {folder['name']}, URL: {folder['webViewLink']}, 所有者: {folder['owners'][0]['emailAddress']}")

実行ログから公開されているのが確認できた

GASコードを

const LINEAPI_TOKEN = PropertiesService.getScriptProperties().getProperty('LINEAPI_TOKEN');
const PUSH_URL = 'https://api.line.me/v2/bot/message/push';

/**
 * Webhook (Pythonからリクエストを受け取る)
 * @param {Object} e - HTTP POST リクエスト
 * @returns {ContentService} - 成功・失敗のレスポンス
 */
function doPost(e) {
  try {
    const requestData = JSON.parse(e.postData.contents);
    const userId = requestData.userId;  // 送信先のLINEユーザーID
    const imageUrl = requestData.imageUrl;  // 送信する画像URL
    const message = requestData.message;  // 画像と一緒に送るテキスト

    if (!userId || !imageUrl) {
      throw new Error("userId または imageUrl がありません");
    }

    sendImageToLine(userId, imageUrl, message);
    return ContentService.createTextOutput(JSON.stringify({ status: "success" })).setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    console.error(error);
    return ContentService.createTextOutput(JSON.stringify({ status: "error", message: error.message })).setMimeType(ContentService.MimeType.JSON);
  }
}

/**
 * LINE に画像メッセージを送信
 * @param {string} userId - 送信先の LINE ユーザーID
 * @param {string} imageUrl - 送信する画像URL
 * @param {string} message - 画像と一緒に送るテキスト
 */
function sendImageToLine(userId, imageUrl, message) {
  const payload = {
    "to": userId,
    "messages": [
      {
        "type": "text",
        "text": message
      },
      {
        "type": "image",
        "originalContentUrl": imageUrl,
        "previewImageUrl": imageUrl
      }
    ]
  };

  const options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + LINEAPI_TOKEN
    },
    "payload": JSON.stringify(payload)
  };

  const response = UrlFetchApp.fetch(PUSH_URL, options);
  console.log("LINE送信結果: " + response.getContentText());
}

としてデプロイ

import requests
import random
import time
from googleapiclient.discovery import build
from google.oauth2 import service_account

# ① Google Drive API の設定
SCOPES = ["https://www.googleapis.com/auth/drive"]
SERVICE_ACCOUNT_FILE = "service_account.json"

creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
drive_service = build("drive", "v3", credentials=creds)

# ② 送信先の LINE API 設定
LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push"
LINE_CHANNEL_ACCESS_TOKEN = ""
USER_ID = ""

# ③ Google Drive の「temp」フォルダID
FOLDER_ID = ""

def get_drive_images():
    """
    Google Drive の temp フォルダ内の画像リストを取得し、画像の `id` をリストで返す
    """
    query = f"'{FOLDER_ID}' in parents and mimeType contains 'image/' and trashed=false"
    results = drive_service.files().list(q=query, fields="files(id, name)").execute()
    files = results.get("files", [])

    if not files:
        print("No images found in the Drive folder.")
        return None

    return [file["id"] for file in files]

def get_drive_image_url(file_id):
    """
    Google Drive のファイルIDを画像URLに変換する
    """
    return f"https://drive.google.com/uc?id={file_id}"

def send_image(image_url):
    """
    LINE API を使い、取得した画像URLを LINE ユーザーに送信する
    """
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {LINE_CHANNEL_ACCESS_TOKEN}"
    }

    payload = {
        "to": USER_ID,
        "messages": [
            {
                "type": "image",
                "originalContentUrl": image_url,  # オリジナル画像URL
                "previewImageUrl": image_url  # プレビュー用画像URL
            }
        ]
    }

    response = requests.post(LINE_PUSH_URL, headers=headers, json=payload)
    print(response.status_code, response.text)

def main():
    """
    画像リストを取得し、ランダムな画像を選んでLINEに送信
    """
    image_ids = get_drive_images()
    if not image_ids:
        print("No images found, skipping LINE push.")
        return

    # 画像をランダムに選択
    random_image_id = random.choice(image_ids)
    image_url = get_drive_image_url(random_image_id)

    # LINE に画像を送信
    send_image(image_url)

# スケジュール実行(30分ごと)
if __name__ == "__main__":
    while True:
        main()
        time.sleep(1800)  # 30分ごとに実行

で実行したが
画像が白いので共有フォルダにアクセスできていないらしい

GASのlinebot で画像を送信する

GASのlinebot で画像を送信する

Google Driveに画像を保存する

保存した画像のタイトルをLINEで返信する
みたいなかんじ?

とりあえず調べたところ
GoogleDriveへ画像を保存し
そのURLを渡して、LINEから送信というかんじみたい

https://developers.line.biz/ja/reference/messaging-api/#wh-image
がリファレンス

https://gas-4u.com/gas16/#index_id1
を参考に
GoogleDriveへ画像保存
それを送信を新しいLINEbotで試す

これにあたり
Googleドライブ上に画像ファイルを一時保存するため、一時保存領域を作成するが
公開されるのはこのディレクトリだけ?
それとも、このディレクトリの上のディレクトリも公開されるかを調べてみる

GPTによれば
Googleドライブで一時保存用のディレクトリを作成し、そのディレクトリだけを公開した場合、
**公開されるのはそのディレクトリとその中のファイル・フォルダのみ** です。
上位のディレクトリ(親ディレクトリ)は公開されません。

### **詳細な動作**
1. **特定のフォルダを公開設定にする**
– そのフォルダの共有設定を「リンクを知っている全員」に設定すると、そのフォルダ以下のファイルやフォルダは公開されます。
– 親フォルダは公開されず、アクセス権を持たないユーザーは親フォルダの存在を認識できません。

2. **親フォルダの共有設定が異なる場合**
– 親フォルダが「非公開」のままで、公開対象のフォルダだけを「リンクを知っている全員」などにすると、そのフォルダ内のファイルのみアクセス可能。
– ただし、親フォルダがすでに「公開」されている場合、その影響を受ける可能性があります。

3. **一時保存ディレクトリの推奨設定**
– **Googleドライブのルート直下に作成する** → 親フォルダの影響を受けにくい。
– **特定の共有フォルダの中に作らない** → 上位フォルダがすでに共有されていると、意図しない範囲が公開される可能性あり。
– **「共有」設定を個別に管理する** → フォルダごとに適切な共有設定をすることで、必要な範囲のみ公開できる。

### **確認方法**
– Googleドライブの共有設定で「このフォルダの上の階層に共有設定があるか」を確認。
– `https://drive.google.com/drive/u/0/my-drive` にアクセスし、フォルダの「詳細情報」→「共有」をチェック。

### **補足**
**もし間違えて親フォルダを公開してしまった場合**、親フォルダの共有設定を見直し、適切に制限をかけることで、意図しない公開を防げます。

適切な設定を行えば、一時保存用のフォルダだけを公開し、それ以上の階層が公開される心配はありません。

とのこと

つまりルートディレクトリで作成すれば心配はなさそう

### **🔍 My Drive が公開されていないことを確認する方法**
Googleドライブの「My Drive」全体が誤って公開されていないかを確認するには、以下の方法でチェックできます。

## **✅ 方法 1: 共有設定を確認する(手動)**
1. **Google ドライブを開く**([Google Drive](https://drive.google.com/))
2. 左側の **「マイドライブ」** をクリック
3. **「マイドライブ」直下のフォルダやファイルを1つずつ右クリック → 「共有」** を開く
4. 共有設定の状態を確認:
– **「リンクを知っている全員が閲覧可能」になっていないか**
– **「組織全体で共有」などが設定されていないか**
– **特定のユーザーのみがアクセスできるようになっているか**

📌 **ポイント:**
「マイドライブ」自体は **共有設定ができない** ので、**個別のフォルダやファイルが公開されていないか** を確認する。

## **✅ 方法 2: Google ドライブの「共有アイテム」から確認**
1. **Google ドライブを開く**([Google Drive](https://drive.google.com/))
2. 左側のメニューで **「共有アイテム」** をクリック
3. **自分がオーナーではないフォルダやファイルがあるか確認**
– **不審なフォルダやファイルがないかチェック**
– **公開されているフォルダがあれば、その共有設定を確認**

📌 **ポイント:**
「共有アイテム」内に **意図しない公開フォルダがある場合、設定を変更する** 必要がある。

## **✅ 方法 3: Google ドライブ API を使って公開されているフォルダを一覧取得**
もし大量のフォルダがあり手動で確認が難しい場合、**Google ドライブ API** を使って **「公開されているフォルダ」を一覧取得** できます。

### **📌 Python スクリプトでチェック**

from googleapiclient.discovery import build
from google.oauth2 import service_account

# 認証情報のセットアップ(Google Drive API 用のサービスアカウント)
SCOPES = ["https://www.googleapis.com/auth/drive.metadata.readonly"]
SERVICE_ACCOUNT_FILE = "path/to/service_account.json"

creds = service_account.Credentials.from_service_account_file(
    SERVICE_ACCOUNT_FILE, scopes=SCOPES
)
service = build("drive", "v3", credentials=creds)

# 共有されているフォルダを検索
query = "mimeType='application/vnd.google-apps.folder' and sharedWithMe"
results = service.files().list(q=query, fields="files(id, name, owners, webViewLink)").execute()
folders = results.get("files", [])

if not folders:
    print("公開されているフォルダはありません。")
else:
    for folder in folders:
        print(f"フォルダ名: {folder['name']}, URL: {folder['webViewLink']}, 所有者: {folder['owners'][0]['emailAddress']}")

📌 **このスクリプトでできること**
– **「共有されているフォルダ」** を一覧取得
– **「どのメールアドレスがオーナーか」** も確認できる
– **「公開URL」** を取得して意図しないフォルダがないか確認

## **✅ 方法 4: Google Drive の「詳細設定」で確認**
Google Drive の **管理コンソール(Google Workspace の場合)** では、すべての公開フォルダを一覧で確認できます。

📌 **Google Drive の管理画面から確認**
1. **Google 管理コンソール**([admin.google.com](https://admin.google.com))を開く
2. **「アプリ」→「Google Workspace」→「ドライブとドキュメント」** を選択
3. **「共有設定」** を開く
4. **「外部共有が許可されているフォルダやファイルの一覧」を確認**

👉 これは **Google Workspace(企業用アカウント)** 向けの設定ですが、管理者なら **公開されているフォルダを一括でチェック** できます。

## **✅ 方法 5: 実際に「ログアウトした状態」でアクセスを試す**
1. **Google Chrome の「シークレットモード」を開く**
2. 共有URL(`https://drive.google.com/drive/folders/フォルダID`)を貼り付けてアクセス
3. **「権限がありません」と表示されればOK**(公開されていない)
4. **フォルダが開ける場合は、公開設定を見直す必要あり**

📌 **ポイント:**
– **他のアカウントでアクセスできるか** をテストすると、誤って公開されているかすぐに分かる
– **スマホや別のPCから試す** のも有効

## **🚀 まとめ**
✅ **Google ドライブの「My Drive」自体は共有されないが、フォルダやファイルの個別設定を確認する必要あり**
✅ **「共有アイテム」や「シークレットモードでアクセス」して、意図しない公開がないかチェック**
✅ **大量のフォルダがある場合、Google ドライブ API を使って「公開されているフォルダ一覧」を取得**
✅ **Google Workspace を使っている場合は、管理コンソールで公開フォルダをチェック可能**

この手順で確認すれば、**Google ドライブの個人データが意図せず公開されることは防げます!** 🚀

ということなので
デフォルトは共有されないらしい

ということでmy drive に共有フォルダtmp を作成する

これで
https://drive.google.com/drive/folders/【フォルダID】
ということ

とりあえずこれで準備はOK

次に新しいLINEbot の作成をする

https://gas-4u.com/gas14/#index_id1
を参考に

https://developers.line.biz/console/?status=success
へアクセスし
QRでログイン

公式アカウントを作成する
image_sending

業種を個人、個人その他
運用目的をその他
主な使い道をメッセージ配信用

これで作成

これで公式アカウントはできたので
設定アイコンをクリックし
MessaginAPI を使う

プロバイダー選択し

チャネルシークレット

ユーザID

channelID

Chanel secret
をメモしておく

チャネルシークレット
ユーザIDは
LINE Developers で確認することになる

MessaginAPI の設定で
チャネルアクセストークンを発行

次にGASでの設定

コードは

const REPLY_URL     = 'https://api.line.me/v2/bot/message/reply';
const LINEAPI_TOKEN = '**STEP.1のNo.17で取得したチャネルアクセストークンを記載する**';

/**
 * LINEのトークでメッセージが送信された際に起動するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function doPost(e){
  // イベントデータはJSON形式となっているため、parseして取得
  const eventData = JSON.parse(e.postData.contents).events[0]
        , repToken = eventData.replyToken;
  replyTxt(repToken, `LINEへの返信確認`);
}

/**
 * LINEのトークにメッセージを返却するメソッド
 * @param {String} token - メッセージ返却用のtoken
 * @param {String} text - 返却テキスト
 */
function replyTxt(token, txt){
  const message = {
                    'replyToken' : token,
                    'messages' : [{
                      'type': 'text',
                      'text': txt
                    }]
                  }
        , options = {
                    'method' : 'post',
                    'headers' : {
                      'Content-Type': 'application/json; charset=UTF-8',
                      'Authorization': 'Bearer ' + LINEAPI_TOKEN,
                    },
                    'payload' : JSON.stringify(message)
                  };
  UrlFetchApp.fetch(REPLY_URL, options);
}

これを

mkdir gas_iamge_bot 
cd gas_iamge_bot 


保存しておく

また
https://script.google.com/home
へアクセスし
新しいプロジェクトを作成

またこのままだとセキュリティリスクがあるので
スクリプトプロパティの設定をして
LINEAPI_TOKEN に LINE のチャネルアクセストークンを登録

値にはチャネルアクセストークンを入れる

そして

const LINEAPI_TOKEN = PropertiesService.getScriptProperties().getProperty('LINEAPI_TOKEN');

というようにトークン読み込み方法を変更する

そしてデプロイ
この時にデプロイタイプを聞かれるので
ウェブアプリを選択

新しい説明文には
画像送信ボット

アクセスユーザを全員に変更

このURLをweb hook に設定

また応答設定で
Webhookを有効にするのを忘れずに

応答メッセージ
応答時間
はオフにする

これでスマホからメッセージを送信して
自動返信ができれば
とりあえずの動作確認はOK

次に画像を送信するようにしていく

これを改良して GASを使い linebot を webhookで動作させる python でbot 画像を送信する というようにしたい なお、メッセージの受信した時に反応するのではなく 定期的にlinebot で画像を送信するbot にしたい

✅ 設計概要
* Google Apps Script(GAS)
* Webhook(エンドポイント)として動作する(メッセージを受信するが、反応しない)。
* 外部からリクエストを受けた際に LINE Bot で画像を送信する。
* Python
* 定期的に GAS のエンドポイントへリクエストを送信(Google Cloud Scheduler や cron で定期実行)。
* GAS のエンドポイントに 送信したい画像URLとメッセージをPOST する。

GAS は Webhook エンドポイント として動作し、Python からのリクエストを受けて LINE に画像を送信 する。

const LINEAPI_TOKEN = PropertiesService.getScriptProperties().getProperty('LINEAPI_TOKEN');
const PUSH_URL = 'https://api.line.me/v2/bot/message/push';

/**
 * Webhook (Pythonからリクエストを受け取る)
 * @param {Object} e - HTTP POST リクエスト
 * @returns {ContentService} - 成功・失敗のレスポンス
 */
function doPost(e) {
  try {
    const requestData = JSON.parse(e.postData.contents);
    const userId = requestData.userId;  // 送信先のLINEユーザーID
    const imageUrl = requestData.imageUrl;  // 送信する画像URL
    const message = requestData.message;  // 画像と一緒に送るテキスト

    if (!userId || !imageUrl) {
      throw new Error("userId または imageUrl がありません");
    }

    sendImageToLine(userId, imageUrl, message);
    return ContentService.createTextOutput(JSON.stringify({ status: "success" })).setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    console.error(error);
    return ContentService.createTextOutput(JSON.stringify({ status: "error", message: error.message })).setMimeType(ContentService.MimeType.JSON);
  }
}

/**
 * LINE に画像メッセージを送信
 * @param {string} userId - 送信先の LINE ユーザーID
 * @param {string} imageUrl - 送信する画像URL
 * @param {string} message - 画像と一緒に送るテキスト
 */
function sendImageToLine(userId, imageUrl, message) {
  const payload = {
    "to": userId,
    "messages": [
      {
        "type": "text",
        "text": message
      },
      {
        "type": "image",
        "originalContentUrl": imageUrl,
        "previewImageUrl": imageUrl
      }
    ]
  };

  const options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + LINEAPI_TOKEN
    },
    "payload": JSON.stringify(payload)
  };

  const response = UrlFetchApp.fetch(PUSH_URL, options);
  console.log("LINE送信結果: " + response.getContentText());
}

次に画像送信するpythonコード

vim image_bot.py

import requests
import json

# GAS の Webhook エンドポイント(GAS のデプロイ URL)
GAS_WEBHOOK_URL = "https://script.google.com/macros/s/XXXXXXXX/exec"

# 送信する LINE ユーザーID(LINE Developers で取得)
LINE_USER_ID = "Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# 送信する画像URL(Google Drive / Web上の画像)
IMAGE_URL = "https://example.com/path/to/image.jpg"

# 画像と一緒に送るメッセージ
MESSAGE = "こちらが最新の画像です!"

def send_image():
    payload = {
        "userId": LINE_USER_ID,
        "imageUrl": IMAGE_URL,
        "message": MESSAGE
    }

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

    if response.status_code == 200:
        print("送信成功:", response.json())
    else:
        print("送信失敗:", response.text)

if __name__ == "__main__":
    send_image()

これをさらにgoogle drive の画像を送信するようにしたい

GASへ画像送信機能の追加

GASへ画像送信機能の追加

APIキーはできたので
次にGAS側のコードを書き換える

念の為バックアップをとっておく

var SS = SpreadsheetApp.getActiveSpreadsheet();
var sheet = SS.getSheetByName("問い合わせ内容");
var Trainingsheet = SS.getSheetByName('応答メッセージ');
var Settingsheet = SS.getSheetByName('設定');
var DBsheet = SS.getSheetByName('チャットボット用DB');
var keywordsheet = SS.getSheetByName('キーワード登録済み単語');

// セキュアな方法でアクセストークンを取得
const CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("CHANNEL_ACCESS_TOKEN");

// DBデータを一括取得
var kensaku_words1 = DBsheet.getDataRange().getValues().map(row => row[0]).flat();
var kaitou_array1 = DBsheet.getDataRange().getValues().map(row => row[1]).flat();
var keyword_array1 = keywordsheet.getDataRange().getValues().map(row => row[0]).flat();

function doPost(request) {
  try {
    // POSTデータをパース
    const receiveJSON = JSON.parse(request.postData.contents);

    // イベントがない、または不正な場合は処理しない
    if (!receiveJSON.events || receiveJSON.events.length === 0) {
      return ContentService.createTextOutput("No event").setMimeType(ContentService.MimeType.TEXT);
    }

    const event = receiveJSON.events[0];

    // メッセージがない、またはテキストがない場合は処理しない
    if (!event.message || !event.message.text) {
      return ContentService.createTextOutput("No message text").setMimeType(ContentService.MimeType.TEXT);
    }

    var replytext = "";

    // 検索ワードDBにメッセージがあるかチェック
    var j = kensaku_words1.indexOf(event.message.text);

    if (j !== -1) {
      // 返信メッセージを取得
      replytext = kaitou_array1[j];
    } else if (keyword_array1.includes(event.message.text)) {
      // 登録済みの応答メッセージ
      replytext = "登録済応答メッセージ回答";
    } else {
      // デフォルトメッセージを取得
      replytext = keywordsheet.getRange(2, 5).getValue();
    }

    // LINEに返信
    replyToUser(event.replyToken, replytext);

    // 送信データをスプレッドシートに記録
    sheet.appendRow([getCurrentTime(), event.message.text, replytext]);

    return ContentService.createTextOutput("Success").setMimeType(ContentService.MimeType.TEXT);

  } catch (error) {
    Logger.log("Error in doPost: " + error.toString());
    return ContentService.createTextOutput("Error processing request").setMimeType(ContentService.MimeType.TEXT);
  }
}

// 現在の時間を取得
function getCurrentTime() {
  return Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
}

// LINEに返信
function replyToUser(replyToken, message) {
  var url = "https://api.line.me/v2/bot/message/reply";
  var payload = {
    "replyToken": replyToken,
    "messages": [{ "type": "text", "text": message }]
  };

  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(payload)
  };

  try {
    UrlFetchApp.fetch(url, options);
  } catch (error) {
    Logger.log("Error in replyToUser: " + error.toString());
  }
}

このcode.js を書き換える

config.json に api_key を追加するらしいが
その時に
“gas_webhook_url”: “https://script.google.com/macros/s/XXXXXX/exec”,
が気になったので調べたら
Webhook生成の時のURLで良いらしい

これはデプロイするたびに変わるので設定ファイルに書くのがベスト

そもそもが
originalContentUrl と previewImageUrl には “HTTPSの公開URL” が必要
Base64 で直接送ることはできない
そのため、LINE Bot では画像が表示されない可能性が高い

とのこと
リファレンスを調べた

https://developers.line.biz/ja/reference/messaging-api/#image-message

{
  "type": "image",
  "originalContentUrl": "https://example.com/original.jpg",
  "previewImageUrl": "https://example.com/preview.jpg"
}

というように
URLを指定するため画像の保存先が必要らしい

試しにスマホから送信してみたが
スマホから bot への画像送信はできるけど
Bot から画像送信はできなかった

このため保存先が必要

保存先としては
Google Drive や firebaseが候補になるらしい

GASを使用するのと、画像を後々学習に使えそうなので
GoogleDrive へ保存を試すことにする
あとは不要になった時に削除もしやすいのと課金しなくて良い方法を持っておきたいので

GASを使い、LINE Botで送信した画像をOCRし、文字情報をLINE Botへ返信 + GoogleSpreadsheetへ記録する

を参考に行う

Google Drive のmy drive で
LINE_BOT_IMAGES
という新規フォルダを作成

フォルダの値は
https://drive.google.com/drive/folders/
の後の文字列がフォルダIDになるので
GAS の「スクリプトプロパティ」に DRIVE_FOLDER_ID を追加

コード変更してデプロイURLを変更

import requests
import json
import os
from io import BytesIO
from PIL import Image

class LineBotSender:
    def __init__(self, config_path):
        """設定ファイルからLINE Botの情報を読み込む"""
        with open(config_path, 'r') as file:
            config = json.load(file)
        self.channel_access_token = config.get('line_bot_channel_access_token')
        self.channel_secret = config.get('channel_secret')
        self.user_id = config.get('line_bot_user_id')  # ユーザーID
        self.image_file_path = config.get('image_file_path')  # YOLOの検出画像ディレクトリ
        self.api_endpoint = 'https://api.line.me/v2/bot/message/push'

    def send_message(self, message):
        """テキストメッセージを送信"""
        headers = {
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {self.channel_access_token}'
        }
        data = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(self.api_endpoint, headers=headers, json=data)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

    def send_image(self, image_path, message="検出された画像を送信します"):
        """画像を送信(3MB超えた場合はリサイズ)"""
        if not os.path.exists(image_path):
            print(f"Error: 画像が見つかりません: {image_path}")
            return
        
        headers = {
            'Authorization': f'Bearer {self.channel_access_token}',
            'Content-Type': 'application/json'
        }

        with open(image_path, 'rb') as img_file:
            image_data = img_file.read()
            image_data = self.resize_image_if_needed(image_data)  # 3MB超えたらリサイズ

            # LINE Bot の API 用データを作成
            payload = {
                "to": self.user_id,
                "messages": [
                    {
                        "type": "text",
                        "text": message
                    },
                    {
                        "type": "image",
                        "originalContentUrl": f"https://your-server.com/images/{os.path.basename(image_path)}",
                        "previewImageUrl": f"https://your-server.com/images/{os.path.basename(image_path)}"
                    }
                ]
            }

            response = requests.post(self.api_endpoint, headers=headers, json=payload)

            if response.status_code != 200:
                raise Exception(f"Error sending image to LINE bot: {response.status_code}, {response.text}")

    def resize_image_if_needed(self, image_data, max_size=3 * 1024 * 1024):
        """画像が max_size (3MB) を超える場合はリサイズ"""
        while len(image_data) > max_size:
            image = Image.open(BytesIO(image_data))
            new_size = (image.width // 2, image.height // 2)  # 縦横 50% 縮小
            image = image.resize(new_size, Image.LANCZOS)

            output = BytesIO()
            image_format = image.format if image.format else 'JPEG'
            image.save(output, format=image_format)
            image_data = output.getvalue()

        return image_data  # 3MB 以下になった画像を返す

    def get_latest_detected_image(self):
        """ `image_file_path` ディレクトリから最新の検出画像を取得 """
        if not os.path.exists(self.image_file_path):
            print("Error: 指定されたディレクトリが存在しません:", self.image_file_path)
            return None

        images = sorted(
            [os.path.join(self.image_file_path, f) for f in os.listdir(self.image_file_path)
             if f.lower().endswith(('.png', '.jpg', '.jpeg'))],
            key=os.path.getmtime, reverse=True
        )

        return images[0] if images else None


if __name__ == "__main__":
    sender = LineBotSender("config.json")  # 設定ファイルのパスを指定
    sender.send_message("こんにちは!")  # LINE Bot で送信

    latest_image = sender.get_latest_detected_image()
    if latest_image:
        sender.send_image(latest_image, "最新の検出画像を送信します")


変えたら今度は動かない

とりあえず機能をシンプルにするため
新しいプロジェクトで画像送信だけのものを作成する

APIキーの生成

GAS の Webhook エンドポイントを利用し、ローカルの画像を LINE に送信 できるようにする

✅ GAS を利用し、ローカル画像を LINE Bot に送信できるように修正
✅ 画像を Base64 エンコードし、GAS に POST で送信
✅ GAS 側で画像を受け取り、LINE API で送信
✅ LINE Notify を使わず、LINE Bot で画像を送信可能に!

GASでは画像は保持されるのか? またセキュリティの問題などはあるか?
で調べる

GAS(Google Apps Script)自体は画像データを保存しません。

画像を LINE Bot に送信するだけであれば、GAS での保持は発生しません。
ただし、Base64 エンコードした画像を HTTP リクエストとして送信 するため、
Google の内部ログには一時的にデータが記録される可能性がありますが、

GAS で doPost(e) を処理する際に 明示的にデータを保存しない限り、GAS 側には一切画像は残りません
ということ

リスク1】GAS の Webhook URL が外部に漏れると誰でも画像送信できる
GAS の Webhook URL (https://script.google.com/macros/s/XXXXXX/exec) が漏れると、誰でも画像を送信できてしまう
➡ 対策:リクエストの認証を行う
✅ 対策1: シークレットキーを使って認証

リスク 対策
GAS の Webhook URL が外部に漏れる API キーで認証を追加
GAS のログに画像データが保存される ログに画像データを記録しない
GAS の公開範囲の設定ミス 「自分のみ」または「特定のGoogleアカウント」に制限
✅ GAS は画像を保存しないのでセキュリティ的には問題なし
✅ API キーによる認証を追加すれば、不正利用も防げる
✅ ログに画像データを残さないように修正すれば安心

秘密の API_KEY には LINE Bot のシークレットキーを使って良いか?
🔴 いいえ、LINE Bot の channel_secret を API_KEY に使うのは推奨されません。
➡ 理由:LINE Bot の channel_secret は、署名検証や API 呼び出しの認証に使用されるため、外部に渡さない方が良い。

✅ API_KEY は独自のランダムなシークレットキーを設定
🔹 API_KEY は LINE Bot の channel_secret とは別に、GAS 専用の認証キーとして作成する のがベスト。
🔹 Python 側(line_bot_sender.py)と GAS 側(コード.gs)で一致するように設定すればOK。
🔹 これは Webhook URL が漏洩しても、不正アクセスを防ぐ

このため自分でキーの値を作成すればOK

まずはApiキーを生成する

不正使用防止のためAPIキーを設定する

リスク 対策
GAS の Webhook URL が外部に漏れる API キーで認証を追加
GAS のログに画像データが保存される ログに画像データを記録しない
GAS の公開範囲の設定ミス 「自分のみ」または「特定のGoogleアカウント」に制限
✅ GAS は画像を保存しないのでセキュリティ的には問題なし
✅ API キーによる認証を追加すれば、不正利用も防げる
✅ ログに画像データを残さないように修正すれば安心
これで、GAS を経由して 安全に画像を LINE Bot に送信できる ようになります! 🚀✨

✅ API_KEY は独自のランダムなシークレットキーを設定
🔹 API_KEY は LINE Bot の channel_secret とは別に、GAS 専用の認証キーとして作成する のがベスト。
🔹 Python 側(line_bot_sender.py)と GAS 側(コード.gs)で一致するように設定すればOK。
🔹 これは Webhook URL が漏洩しても、不正アクセスを防ぐための対策 になります。

API_KEY は ランダムな英数字(32文字程度) にするのが理想

まずはファイルの作成

touch create_api_key.py

内容は

import secrets

api_key = secrets.token_hex(32) # 32バイト(64文字)のランダムなキーを生成
print(api_key)

実行するとキーが生成される

この値を
config.jsonへ追加する

{
“token”: “”,
“image_file_path”: “runs/detect”,
“line_bot_channel_access_token”: “”,
“channel_secret”: “”,
“line_bot_user_id”: “”,
“api_key”: “”
}
[・shell]

次にGASで
PropertiesService を使って APIキー を設定
APIキーをスクリプトプロパティに設定するには

1. Google Apps Script (GAS) のエディタを開く
2. [ファイル] → [プロジェクトのプロパティ] を選択
3. [スクリプトプロパティ] タブを開く
4. 新しいプロパティを追加
* キー名: API_KEY
* 値: your_random_api_key_xxxxxxx
5. 保存ボタンを押す

これでAPI設定はできた

Linebot 画像送信機能の追加

Linebot 画像送信機能の追加

{
  "token": "",
  "image_file_path": "runs/detect",
  "line_bot_channel_access_token": "",
  "channel_secret": "",
  "line_bot_user_id": ""
}

というように
Config.jsonの内容を追加

import json
import cv2
import configparser
from ultralytics import YOLO
from collections import defaultdict
from datetime import datetime
import os

from line_bot_sender import LineBotSender  # LineBotSender をインポート
from inventory_database_module import save_detection_to_db  # データベース保存用の関数をインポート

# 設定ファイルの読み込み
config = configparser.ConfigParser()
config.read('config.ini')

# 設定ファイルからモデルパスと画像ディレクトリを取得
model_path = config['Settings']['model_path']
image_directory = config['Settings']['image_directory']

# ラベルマッピングファイルのパス
label_mapping_path = 'label_mapping.json'

# JSONファイルからクラスラベルのマッピングを読み込み
with open(label_mapping_path, 'r', encoding='utf-8') as f:
    label_mapping = json.load(f)

# LINE Bot 送信クラスのインスタンスを作成
line_bot = LineBotSender("config.json")  # `config.json` を指定

# YOLOv8モデルのロード
model = YOLO(model_path)  # 設定ファイルからモデルパスを使用

# 画像ディレクトリ内の全画像ファイルを処理
for image_filename in os.listdir(image_directory):
    image_path = os.path.join(image_directory, image_filename)
    if os.path.isfile(image_path) and image_path.lower().endswith(('.png', '.jpg', '.jpeg')):
        # 画像のロード
        image = cv2.imread(image_path)

        # 画像の検出
        results = model(image, save=True, conf=0.2, iou=0.5)

        # 検出結果の取得
        detections = results[0]  # 最初の結果を取得
        classes = detections.boxes.cls

        # 検出物体のカウント
        object_counts = defaultdict(int)
        for cls in classes:
            class_label = model.names[int(cls)]
            if class_label in label_mapping:
                label = label_mapping[class_label]
            else:
                label = class_label
            object_counts[label] += 1

        # 検出結果のフィルタリング(1以下のもの)
        filtered_object_counts = {label: count for label, count in object_counts.items() if count <= 1}

        # フィルタリングされた検出結果のメッセージ生成
        message_lines = [f'{label}: {count}個' for label, count in filtered_object_counts.items()]
        message = '\n'.join(message_lines)

        # 現在の時刻を取得
        current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        message = f"{message}\n\nMessage sent at: {current_time}"

        # 検出結果の表示
        for line in message_lines:
            print(line)

        # LINE Bot にメッセージを送信(フィルタリングされた結果のみ)
        if message_lines:
            line_bot.send_message(message)  # `LineBotSender` を使用
            save_detection_to_db(filtered_object_counts)  # データベースに検出結果を保存
        else:
            print("No objects with counts of 1 or less detected in file:", image_filename)

というように

cp count_inventory.py count_inventory_send_bot.py


Bot送信のテストのためソースを分ける

変更点は
send_line_notify(message) を
line_bot.send_message(message) に変更

LineBotSender を line_bot_sender.py からインポートして、インスタンスを作成
config.json を LINE Bot の設定情報取得用 に使用

from line_bot_sender import LineBotSender  # LineBotSender をインポート

line_bot = LineBotSender("config.json")  # `config.json` を指定

# 変更前(LINE Notify を使っていた部分)
# send_line_notify(message)

# 変更後(LINE Bot を使用)
line_bot.send_message(message)

そしてDBへの保存を
# LINE Bot にメッセージを送信(フィルタリングされた結果のみ)
if message_lines:
    line_bot.send_message(message)  # `LineBotSender` を使用
    save_detection_to_db(filtered_object_counts)  # データベースに検出結果を保存

へ変更

python count_inventory_send_bot.py

で実行すると

0: 640x512 1 baskulin, 74.1ms
Speed: 5.2ms preprocess, 74.1ms inference, 4.6ms postprocess per image at shape (1, 3, 640, 512)
Results saved to runs/detect/predict24
バスクリン: 1個

とbot への送信ができたけど
Notifyの時の画像送信機能を作成していなかったので
これを追加する

import requests
import json
import os
from io import BytesIO
from PIL import Image

class LineBotSender:
    def __init__(self, config_path):
        with open(config_path, 'r') as file:
            config = json.load(file)
        self.channel_access_token = config.get('line_bot_channel_access_token')
        self.channel_secret = config.get('channel_secret')
        self.user_id = config.get('line_bot_user_id')  # ユーザーID
        self.api_endpoint = 'https://api.line.me/v2/bot/message/push'  # メッセージ送信用API
        self.image_api_endpoint = 'https://api.line.me/v2/bot/message/push'  # 画像送信用API

    def send_message(self, message):
        headers = {
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {self.channel_access_token}'
        }
        data = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(self.api_endpoint, headers=headers, json=data)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

    def send_image(self, image_path, message="画像を送信します"):
        headers = {
            'Authorization': f'Bearer {self.channel_access_token}'
        }
        with open(image_path, 'rb') as img_file:
            image_data = img_file.read()
            image_data = self.resize_image_if_needed(image_data)

            files = {'imageFile': BytesIO(image_data)}
            files['imageFile'].name = os.path.basename(image_path)

            payload = {
                "to": self.user_id,
                "messages": [
                    {
                        "type": "text",
                        "text": message
                    },
                    {
                        "type": "image",
                        "originalContentUrl": f"https://your-server.com/images/{os.path.basename(image_path)}",
                        "previewImageUrl": f"https://your-server.com/images/{os.path.basename(image_path)}"
                    }
                ]
            }

            response = requests.post(self.image_api_endpoint, headers=headers, json=payload, files=files)

            if response.status_code != 200:
                raise Exception(f"Error sending image to LINE bot: {response.status_code}, {response.text}")

    def resize_image_if_needed(self, image_data, max_size=3 * 1024 * 1024):
        if len(image_data) > max_size:
            image = Image.open(BytesIO(image_data))
            new_size = (image.width // 2, image.height // 2)
            image = image.resize(new_size, Image.LANCZOS)

            output = BytesIO()
            image_format = image.format if image.format else 'JPEG'
            image.save(output, format=image_format)
            return output.getvalue()
        return image_data

✅ send_image() メソッドを追加し、画像を送信できるようにした
✅ resize_image_if_needed() で 3MB 超過時にリサイズする処理を追加
✅ originalContentUrl と previewImageUrl は 公開 URL に設定する必要があるため、適宜サーバーにアップロードする処理が必要

しかし
また config.json で “image_file_path”: “runs/detect”,
が設定してあるので
コードを変更する

import requests
import json
import os
from io import BytesIO
from PIL import Image

class LineBotSender:
    def __init__(self, config_path):
        with open(config_path, 'r') as file:
            config = json.load(file)
        self.channel_access_token = config.get('line_bot_channel_access_token')
        self.channel_secret = config.get('channel_secret')
        self.user_id = config.get('line_bot_user_id')  # ユーザーID
        self.image_file_path = config.get('image_file_path')  # YOLOの検出画像ディレクトリ
        self.api_endpoint = 'https://api.line.me/v2/bot/message/push'

    def send_message(self, message):
        headers = {
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {self.channel_access_token}'
        }
        data = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(self.api_endpoint, headers=headers, json=data)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

    def send_image(self, image_path, message="検出された画像を送信します"):
        headers = {
            'Authorization': f'Bearer {self.channel_access_token}',
            'Content-Type': 'application/json'
        }

        payload = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                },
                {
                    "type": "image",
                    "originalContentUrl": f"https://your-server.com/images/{os.path.basename(image_path)}",
                    "previewImageUrl": f"https://your-server.com/images/{os.path.basename(image_path)}"
                }
            ]
        }

        response = requests.post(self.api_endpoint, headers=headers, json=payload)

        if response.status_code != 200:
            raise Exception(f"Error sending image to LINE bot: {response.status_code}, {response.text}")

    def get_latest_detected_image(self):
        """ `image_file_path` ディレクトリから最新の検出画像を取得 """
        if not os.path.exists(self.image_file_path):
            print("Error: 指定されたディレクトリが存在しません:", self.image_file_path)
            return None

        images = sorted(
            [os.path.join(self.image_file_path, f) for f in os.listdir(self.image_file_path)
             if f.lower().endswith(('.png', '.jpg', '.jpeg'))],
            key=os.path.getmtime, reverse=True
        )

        return images[0] if images else None

なお
https://kotovuki.co.jp/archives/17715
によれば
1つの画像の最大ファイルサイズは 10MBらしい

1回のメッセージには吹き出しの上限が5つ
テキストの場合は 1つの吹き出しには最大5000文字まで入力可能

とりあえずサイズを縮小する機能が必要

縦横 50% に縮小

new_size = (image.width // 2, image.height // 2)  # 縦横 50% 縮小
image = image.resize(new_size, Image.LANCZOS)

JPEG/PNG を維持して保存

image_format = image.format if image.format else 'JPEG'
image.save(output, format=image_format)

リサイズ後の画像データを返す

return output.getvalue()

画像サイズ リサイズ処理
2MB そのまま送信
4MB リサイズして 50% 縮小
8MB リサイズして 50% 縮小(さらに 3MB 超えていれば再縮小)

✅ 3MB 以上の画像は自動で縮小
✅ JPEG/PNG フォーマットを保持
✅ LINE Bot に適したサイズで送信可能

ということで

import requests
import json
import os
from io import BytesIO
from PIL import Image

class LineBotSender:
    def __init__(self, config_path):
        """設定ファイルからLINE Botの情報を読み込む"""
        with open(config_path, 'r') as file:
            config = json.load(file)
        self.channel_access_token = config.get('line_bot_channel_access_token')
        self.channel_secret = config.get('channel_secret')
        self.user_id = config.get('line_bot_user_id')  # ユーザーID
        self.image_file_path = config.get('image_file_path')  # YOLOの検出画像ディレクトリ
        self.api_endpoint = 'https://api.line.me/v2/bot/message/push'

    def send_message(self, message):
        """テキストメッセージを送信"""
        headers = {
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {self.channel_access_token}'
        }
        data = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(self.api_endpoint, headers=headers, json=data)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

    def send_image(self, image_path, message="検出された画像を送信します"):
        """画像を送信(3MB超えた場合はリサイズ)"""
        if not os.path.exists(image_path):
            print(f"Error: 画像が見つかりません: {image_path}")
            return
        
        headers = {
            'Authorization': f'Bearer {self.channel_access_token}',
            'Content-Type': 'application/json'
        }

        with open(image_path, 'rb') as img_file:
            image_data = img_file.read()
            image_data = self.resize_image_if_needed(image_data)  # 3MB超えたらリサイズ

            # LINE Bot の API 用データを作成
            payload = {
                "to": self.user_id,
                "messages": [
                    {
                        "type": "text",
                        "text": message
                    },
                    {
                        "type": "image",
                        "originalContentUrl": f"https://your-server.com/images/{os.path.basename(image_path)}",
                        "previewImageUrl": f"https://your-server.com/images/{os.path.basename(image_path)}"
                    }
                ]
            }

            response = requests.post(self.api_endpoint, headers=headers, json=payload)

            if response.status_code != 200:
                raise Exception(f"Error sending image to LINE bot: {response.status_code}, {response.text}")

    def resize_image_if_needed(self, image_data, max_size=3 * 1024 * 1024):
        """画像が max_size (3MB) を超える場合はリサイズ"""
        while len(image_data) > max_size:
            image = Image.open(BytesIO(image_data))
            new_size = (image.width // 2, image.height // 2)  # 縦横 50% 縮小
            image = image.resize(new_size, Image.LANCZOS)

            output = BytesIO()
            image_format = image.format if image.format else 'JPEG'
            image.save(output, format=image_format)
            image_data = output.getvalue()

        return image_data  # 3MB 以下になった画像を返す

    def get_latest_detected_image(self):
        """ `image_file_path` ディレクトリから最新の検出画像を取得 """
        if not os.path.exists(self.image_file_path):
            print("Error: 指定されたディレクトリが存在しません:", self.image_file_path)
            return None

        images = sorted(
            [os.path.join(self.image_file_path, f) for f in os.listdir(self.image_file_path)
             if f.lower().endswith(('.png', '.jpg', '.jpeg'))],
            key=os.path.getmtime, reverse=True
        )

        return images[0] if images else None


if __name__ == "__main__":
    sender = LineBotSender("config.json")  # 設定ファイルのパスを指定
    sender.send_message("こんにちは!")  # LINE Bot で送信

    latest_image = sender.get_latest_detected_image()
    if latest_image:
        sender.send_image(latest_image, "最新の検出画像を送信します")

これだけで実行しても画像がつかない

とりあえず
GAS の Webhook エンドポイントを利用し、ローカルの画像を LINE に送信 できるようにする

LINE botにメッセージを送信するためのモジュールを作る

linebot の動作確認できたので
過去に通知に line notify を使っていたものを調べて
Linebot に変える

リポジトリは
https://github.com/Snowpooll
にあるので

https://github.com/Snowpooll/store_adversting_lists
gmailで件名を指定し、未読の最新のメールを取得後にURLを抽出、
抽出したURLを元にブラウザを開き画像をダウンロード
ダウンロード画像へcloud vision api を実行し
購入リストにマッチしたものを LINE norifyで買い物リストが送信

https://github.com/Snowpooll/inventory_count
yoov8と学習したモデルで 指定した画像から物体検出をして在庫数が1以下になったときに
LINE norifyで買い物リストが送信
検出時の結果はsqlite3データベースに保存

とりあえずこの2つの機能を変えることにする

以前

LINE botにメッセージを送信するためのモジュールを作る

touch line_bot_sender.py

中身は

# line_bot_sender.py
import requests
import json

class LineBotSender:
    def __init__(self, config_file_path):
        self.config = self._load_config(config_file_path)
        self.channel_access_token = self.config["line_bot_channel_access_token"]
        self.user_id = self.config["line_bot_user_id"]

    def _load_config(self, config_file_path):
        with open(config_file_path, 'r') as file:
            return json.load(file)

    def send_message(self, message):
        url = "https://api.line.me/v2/bot/message/push"
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.channel_access_token}"
        }
        payload = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(url, headers=headers, json=payload)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

として保存

config.json にLINE botの情報を追加

{
    "line_notify_token": "YOUR_LINE_NOTIFY_TOKEN",
    "ollama_model": "elyza:jp8b",
    "line_bot_channel_access_token": "YOUR_LINE_BOT_CHANNEL_ACCESS_TOKEN",
    "line_bot_user_id": "TARGET_USER_ID"
}

Main.pyでは linenotifyで送信
Main2.pyで lintbot へ送信
Main3.pyで firestoeeへメッセージ保存
Main4.pyで指定のユーザメッセージを指定できるようにしている

今回GASを使うので
使用するのはlinebot のみなので
Main2.pyの機能になる

また

from line_bot_sender import LineBotSender  # LINE bot送信用のモジュールをインポート

となっているので
line_bot_sender.pyを使えばいいのでコピー

設定ファイルとして
Config.jsonを読み込んでいるので
これも使うことにする

まずGAS関連をフォルダにまとめて移動しておく

mkdir gas
mv *.gs gas 

そして linebot 関連をコピー

cp ../linebot/config.json .
 cp ../linebot/line_bot_sender.py .

次にGASへの送信テスト

Channel ID

Channel secret

チャネルアクセストークン

これを設定ファイルに書き込む

なおユーザは指定しないので省いたらバグったので必要

過去ログを調べたら
確認方法は
https://developers.line.biz/ja/docs/messaging-api/getting-user-ids/#get-own-user-id
によると
開発者は自分自身のユーザーIDをLINE Developersコンソールのチャネルの[チャネル基本設定]タブにある[あなたのユーザーID]で確認

開発者が自分自身のユーザーIDを取得するためのAPIはありません
とのこと

これでわかったので

Config.jsonに記述する

これで動作したので
次に
inventoryの仕組みを notify から bot へ変更する

count_inventory.py

line_notifyの部分を書き換えると動作するはず
コードは gpt にあるので
これを試すことにする

config.jsonの項目にbot 関連を追加して
メソッドを変更すればできるはず

LinebotとGASの設定

【初めてでも10分で作れる】GASで作るお問い合わせLINEbot【GAS×LINE Messaging API】

Gemini APIとGASを活用したLINEBOT開発レシピ
を参考に
スマホから送信するLINEbot を作成できるようにする

現状だとスマホからの送信のみ対応できていない

これを使うには
Google Apps Scriptの料金を調べる
調べた結果
料金は無料だけど制約がある

とりあえず LINE bot の設定をする
https://developers.line.biz/ja/docs/messaging-api/getting-started/
へアクセスし
コンソールをクリックし
LINEアカウントでログイン

ログインはQRコードからできるので
LINEアプリを起動しQRを読み込み
そこに表示された数字を入力すればログインできる

ログインしたら新規プロパイダーを作成

これは他と被らないものにすればOK

チャネル設定をmessaging API にして
作成しようとしたら
公式アカウントを作成しないとだめになっている

とりあえず以前作成したことはあるので
新しい公式アカウントを作成する

次にチャネル作成

これは設定が変わっていて
公式アカウントリストの中から
今回作成した公式あかうんとをクリックし
設定をクリックすると
Messaging API の項目が出てくる

これで
Messaging API を利用する
をクリックし
プロバイダーを選択する

WebhookのURLは後で設定できるのでスキップしてもOK

Channel ID

Channel secret
をコピーしておく

次に
応答設定をクリックし
応答メッセージをOFFにする

次に
LINE Developers
https://developers.line.biz/ja/
へアクセスし
コンソールをクリック

プロバイダー一覧から
先ほど設定したプロバイダーを選択

チャネル選択しクリック

Messaging API設定
をクリック

チャネルアクセストークン
で発行をクリックする

次に
チャットボットの裏側となるGoogleAppsScriptの設定を実施

Google アカウントでログインした状態でアクセスし
コピーを作成する

コピーができたら、【設定】シートの【セルC2】に、先ほどメモしましたLINEのチャネルアクセストークンを貼り付け

次に
【拡張機能】⇒【Apps Script】を選択すると、AppsScriptの画面が開く

【デプロイ】⇒【新しいデプロイ】を選択し
* 次のユーザーとして実行 自分(〇〇〇〇〇@gmail.com)
* アクセスできるユーザー 全員
を選択し、【デプロイ】を選択

新しいデプロイを初回実行すると、【アクセスの承認】を押下して、その後利用するアカウントを選択します。
すると、【このアプリはGoogleで確認されていません】と注意書きがでますので
左下の詳細を選択します。(ここで【安全なページに戻る】を押下すると元に戻ってしまうので、デプロイからやり直してください。)
詳細が表示されたら、【ファイル名+(安全でないページ)に移動】が表示されますので、そちらを選択します。
Googleアカウントへのアクセスをリクエストしてくるので【許可】を選択

デプロイ ID
ウェブアプリ
URL
が表示されているので
このURLを LINEbot の web hook に設定する

そして
LINE Developers
https://developers.line.biz/ja/

Messaging API設定

Webhookの利用
をONにする

これで
スマホからlinebot で
こんにちわ
と入力すると
自動でメッセージが返ってくる

とりあえず実験は成功

続いて

Gemini APIとGASを活用したLINEBOT開発レシピ
を実践する

* 1分間あたり15リクエスト
* 1分あたり100万トークン
•1日あたり1500回のリクエスト
がgemini の無料枠

コードをそのまま使うので
前回と同じようにコピーする

Channel ID

Channel secret

チャネルアクセストークン
を忘れないようにメモしておく

GASでlinebot

GASでlinebot

Line notify が廃止されるので代替を linebot にする必要がある

var SS = SpreadsheetApp.getActiveSpreadsheet(); //SpreadsheetのURL
var sheet = SS.getSheetByName("問い合わせ内容"); //Spreadsheetのシート名(タブ名)


var Trainingsheet= SS.getSheetByName('応答メッセージ');
var Settingsheet= SS.getSheetByName('設定');
var DBsheet= SS.getSheetByName('チャットボット用DB');
var keywordsheet= SS.getSheetByName('キーワード登録済み単語');


const CHANNEL_ACCESS_TOKEN = Settingsheet.getRange("C2").getValue();

//DBシート
var kensaku_words = DBsheet.getRange("A:A").getValues();
var kaitou_array = DBsheet.getRange("B:B").getValues();
var keyword_array = keywordsheet.getRange("A:A").getValues();

var kensaku_words1 = Array.prototype.concat.apply([], kensaku_words);
var kaitou_array1 = Array.prototype.concat.apply([], kaitou_array);
var keyword_array1 = Array.prototype.concat.apply([], keyword_array);




function doPost(request) {



 //POSTリクエストをJSONデータにパース
 const receiveJSON = JSON.parse(request.postData.contents);
 const event = receiveJSON.events[0];

var replytext =""


//検索ワードDBの何個目に送信されたメッセージが該当するか検索(ない場合は【-1】が帰ってきます。)
  j = kensaku_words1.indexOf(event.message.text) 


  if (j != -1.0){ // messageに、DBの文字列が含まれるか検索、含まれていたら
    replytext =  kaitou_array1[j];
    replyToUser(event.replyToken, replytext)  

    }else if(keyword_array1.indexOf(event.message.text)!= -1.0 ){
    replytext = "登録済応答メッセージ回答"
    }else{
    replytext =  keywordsheet.getRange(2,5).getValue();
    replyToUser(event.replyToken, replytext)  
    }

//送られてきたデータをlogシートに記載
sheet.appendRow([getCurrentTime(),event.message.text,replytext]);


}

function getCurrentTime() {
  //日付の宣言
  return Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
}

の問題点として
問題点と改善点
1. CHANNEL_ACCESS_TOKEN を直接 Settingsheet から取得している
* 問題点:
* CHANNEL_ACCESS_TOKEN をスプレッドシートから取得するのは安全ではない(シートが漏洩した場合、外部から悪用される可能性)。
* 改善策:
* Google Apps Script のスクリプトプロパティ(PropertiesService)を使用する。
* Settingsheet.getRange(“C2”).getValue(); を PropertiesService.getScriptProperties().getProperty(“CHANNEL_ACCESS_TOKEN”) に変更。
2. kensaku_words1, kaitou_array1, keyword_array1 の取得方法
* 問題点:
* スプレッドシートのデータ全体を毎回読み込むのは非効率(特に行数が増えた場合、実行速度が遅くなる)。
* 改善策:
* 1回の getDataRange().getValues() で配列を取得し、flat() でフラット化する。
3. doPost のエラーハンドリングがない
* 問題点:
* 予期しないデータ(不正なJSON、nullなど)が来たときの処理がない。
* 改善策:
* try-catch を導入し、エラーが発生した場合のログ出力と適切なエラーレスポンスを返す。
4. event.message.text が undefined の場合の処理がない
* 問題点:
* event.message.text が undefined の場合、.indexOf() の実行時にエラーが発生する可能性がある。
* 改善策:
* event.message && event.message.text を確認して処理する。
5. replyToUser() の呼び出しが一部抜けている
* 問題点:
* keyword_array1.indexOf(event.message.text)!= -1.0 の場合、replyToUser() を呼んでいない。
* 改善策:
* replyToUser() を適切に呼び出す。

となっている

これを

var SS = SpreadsheetApp.getActiveSpreadsheet();
var sheet = SS.getSheetByName("問い合わせ内容");
var Trainingsheet = SS.getSheetByName('応答メッセージ');
var Settingsheet = SS.getSheetByName('設定');
var DBsheet = SS.getSheetByName('チャットボット用DB');
var keywordsheet = SS.getSheetByName('キーワード登録済み単語');

// セキュアな方法でアクセストークンを取得
const CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("CHANNEL_ACCESS_TOKEN");

// DBデータを一括取得
var kensaku_words1 = DBsheet.getDataRange().getValues().map(row => row[0]).flat();
var kaitou_array1 = DBsheet.getDataRange().getValues().map(row => row[1]).flat();
var keyword_array1 = keywordsheet.getDataRange().getValues().map(row => row[0]).flat();

function doPost(request) {
  try {
    // POSTデータをパース
    const receiveJSON = JSON.parse(request.postData.contents);

    // イベントがない、または不正な場合は処理しない
    if (!receiveJSON.events || receiveJSON.events.length === 0) {
      return ContentService.createTextOutput("No event").setMimeType(ContentService.MimeType.TEXT);
    }

    const event = receiveJSON.events[0];

    // メッセージがない、またはテキストがない場合は処理しない
    if (!event.message || !event.message.text) {
      return ContentService.createTextOutput("No message text").setMimeType(ContentService.MimeType.TEXT);
    }

    var replytext = "";

    // 検索ワードDBにメッセージがあるかチェック
    var j = kensaku_words1.indexOf(event.message.text);

    if (j !== -1) {
      // 返信メッセージを取得
      replytext = kaitou_array1[j];
    } else if (keyword_array1.includes(event.message.text)) {
      // 登録済みの応答メッセージ
      replytext = "登録済応答メッセージ回答";
    } else {
      // デフォルトメッセージを取得
      replytext = keywordsheet.getRange(2, 5).getValue();
    }

    // LINEに返信
    replyToUser(event.replyToken, replytext);

    // 送信データをスプレッドシートに記録
    sheet.appendRow([getCurrentTime(), event.message.text, replytext]);

    return ContentService.createTextOutput("Success").setMimeType(ContentService.MimeType.TEXT);

  } catch (error) {
    Logger.log("Error in doPost: " + error.toString());
    return ContentService.createTextOutput("Error processing request").setMimeType(ContentService.MimeType.TEXT);
  }
}

// 現在の時間を取得
function getCurrentTime() {
  return Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
}

// LINEに返信
function replyToUser(replyToken, message) {
  var url = "https://api.line.me/v2/bot/message/reply";
  var payload = {
    "replyToken": replyToken,
    "messages": [{ "type": "text", "text": message }]
  };

  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(payload)
  };

  try {
    UrlFetchApp.fetch(url, options);
  } catch (error) {
    Logger.log("Error in replyToUser: " + error.toString());
  }
}

として保存

改善点まとめ
✅ アクセストークンを PropertiesService に保存し、スプレッドシートの流出リスクを防止。
✅ スプレッドシートのデータ取得を最適化し、getDataRange().getValues() で一括取得。

✅ エラーハンドリング (try-catch) を追加し、不正なリクエストの処理を安全に。

✅ イベントデータの存在確認 (event.message && event.message.text) を追加し、エラー防止。

✅ replyToUser() のエラーハンドリングを強化し、ログ出力を追加。

✅ keyword_array1.indexOf() の処理を includes() に変更し、可読性を向上。

✅ 関数を整理し、再利用しやすく。

Google Apps Script の スクリプトプロパティ (ScriptProperties) を使うと、スプレッドシートに機密情報を保存せずに管理できます。CHANNEL_ACCESS_TOKEN のような LINEのアクセストークン を安全に保存するのに適しています

スクリプトエディタで スクリプトプロパティ を設定するには、以下の手順を実行します。
① スクリプトプロパティにアクセストークンを保存
1. Google Apps Script エディタ を開く
* スプレッドシートの 拡張機能 → Apps Script を開く
2. エディタの左側メニュー で ⚙️プロジェクトの設定 を開く
3. 下の方にある スクリプトプロパティ の + プロパティを追加 をクリック
4. プロパティ名: CHANNEL_ACCESS_TOKEN
5. 値: <あなたのLINEアクセストークン> を入力して 保存 をクリック

ということなので
プロジェクトの設定をクリック
スクリプトプロパティの追加

プロパティ名: CHANNEL_ACCESS_TOKEN
値: <あなたのLINEアクセストークン> を入力して 保存

アクセストークンの値を一度削除して

var SS = SpreadsheetApp.getActiveSpreadsheet();
var sheet = SS.getSheetByName("問い合わせ内容");
var Trainingsheet = SS.getSheetByName('応答メッセージ');
var Settingsheet = SS.getSheetByName('設定');
var DBsheet = SS.getSheetByName('チャットボット用DB');
var keywordsheet = SS.getSheetByName('キーワード登録済み単語');

// セキュアな方法でアクセストークンを取得
const CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("CHANNEL_ACCESS_TOKEN");

// DBデータを一括取得
var kensaku_words1 = DBsheet.getDataRange().getValues().map(row => row[0]).flat();
var kaitou_array1 = DBsheet.getDataRange().getValues().map(row => row[1]).flat();
var keyword_array1 = keywordsheet.getDataRange().getValues().map(row => row[0]).flat();

function doPost(request) {
  try {
    // POSTデータをパース
    const receiveJSON = JSON.parse(request.postData.contents);

    // イベントがない、または不正な場合は処理しない
    if (!receiveJSON.events || receiveJSON.events.length === 0) {
      return ContentService.createTextOutput("No event").setMimeType(ContentService.MimeType.TEXT);
    }

    const event = receiveJSON.events[0];

    // メッセージがない、またはテキストがない場合は処理しない
    if (!event.message || !event.message.text) {
      return ContentService.createTextOutput("No message text").setMimeType(ContentService.MimeType.TEXT);
    }

    var replytext = "";

    // 検索ワードDBにメッセージがあるかチェック
    var j = kensaku_words1.indexOf(event.message.text);

    if (j !== -1) {
      // 返信メッセージを取得
      replytext = kaitou_array1[j];
    } else if (keyword_array1.includes(event.message.text)) {
      // 登録済みの応答メッセージ
      replytext = "登録済応答メッセージ回答";
    } else {
      // デフォルトメッセージを取得
      replytext = keywordsheet.getRange(2, 5).getValue();
    }

    // LINEに返信
    replyToUser(event.replyToken, replytext);

    // 送信データをスプレッドシートに記録
    sheet.appendRow([getCurrentTime(), event.message.text, replytext]);

    return ContentService.createTextOutput("Success").setMimeType(ContentService.MimeType.TEXT);

  } catch (error) {
    Logger.log("Error in doPost: " + error.toString());
    return ContentService.createTextOutput("Error processing request").setMimeType(ContentService.MimeType.TEXT);
  }
}

// 現在の時間を取得
function getCurrentTime() {
  return Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
}

// LINEに返信
function replyToUser(replyToken, message) {
  var url = "https://api.line.me/v2/bot/message/reply";
  var payload = {
    "replyToken": replyToken,
    "messages": [{ "type": "text", "text": message }]
  };

  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(payload)
  };

  try {
    UrlFetchApp.fetch(url, options);
  } catch (error) {
    Logger.log("Error in replyToUser: " + error.toString());
  }
}

とコード変更

比べたらやはりURLが変わっている

この変更後のURLをlinebot の web hook に再度設定すれば動作する

次はDB関連の変更

function training() {
  
  var lastrow = Trainingsheet.getLastRow();
  var lastcolumn = Trainingsheet.getLastColumn();
  
  //Trainingシートのタイトル行除く全てのデータを取得
  // var Alldata  = Trainingsheet.getRange(2,2,lastrow-1,lastcolumn-1).getValues();
  var Alldata  = Trainingsheet.getRange(2,1,lastrow,lastcolumn).getValues();
  
  var DB_lastrow = DBsheet.getLastRow();
  var DB_lastcolumn = DBsheet.getLastColumn();
  
  //DBシートのデータ消去
  if(DB_lastrow!=2){
    DBsheet.getRange(3,1,DB_lastrow-2,DB_lastcolumn).clear();
  }
  
  var count = 0;
  
  for(var i = 0; i < Alldata.length; i++) {
    
    for(var j = 0; j < Alldata[i].length-1; j++){
      
      if(Alldata[i][j+1] != ""){
        DBsheet.getRange(3+count,1).setValue(Alldata[i][j+1]);//応答メッセージ
        
        DBsheet.getRange(3+count,2).setValue(Alldata[i][0]);//キーワード
        count++
          
      }
    }
  }
  
}

のコードレビュー

この training() 関数は、Trainingsheet のデータを DBsheet に転記する処理を行っていますが、以下の点で改善

### **コードレビュー & 改善点**

この `training()` 関数は、`Trainingsheet` のデータを `DBsheet` に転記する処理を行っていますが、以下の点で改善できます。

## **🔹 改善点と修正方法**
### **1. `DBsheet` のデータ消去処理のバグ**
#### **🔹 問題点**

if(DB_lastrow!=2){
  DBsheet.getRange(3,1,DB_lastrow-2,DB_lastcolumn).clear();
}

– **`DB_lastrow != 2` のチェックが不適切**:
– `DBsheet` が空の場合 (`lastRow == 0` など)、`DBsheet.getRange(3,1,DB_lastrow-2,DB_lastcolumn)` の範囲指定でエラーが発生する可能性がある。
– `lastRow < 3` の場合 (`DBsheet` に2行未満のデータしかない場合)、`DBsheet.getRange(3,1,DB_lastrow-2,DB_lastcolumn)` の範囲が **負の値になりエラー** になる。 #### **✅ 改善策** [shell] if(DB_lastrow > 2){ DBsheet.getRange(3, 1, DB_lastrow - 2, DB_lastcolumn).clear(); } [/shell] - **`DB_lastrow > 2` の場合のみクリアする** ことで **エラー防止**。

### **2. `getValues()` を `getValues()` + `setValues()` に最適化**
#### **🔹 問題点**

DBsheet.getRange(3+count,1).setValue(Alldata[i][j+1]);//応答メッセージ
DBsheet.getRange(3+count,2).setValue(Alldata[i][0]);//キーワード

– **`setValue()` をループ内で何回も呼んでいる**(パフォーマンスが悪い)。
– **Google Apps Script は I/O 操作が遅い** ため、`getValues()` と `setValues()` を使って **一括処理** する方が高速。

#### **✅ 改善策**
– **データを `配列` に格納して、最後に `setValues()` で一括書き込み** する。

### **3. `Alldata[i][j+1]` の条件チェックを厳密に**
#### **🔹 問題点**

if(Alldata[i][j+1] != ""){

– `Alldata[i][j+1]` が `undefined` の場合 **比較が正しく機能しない可能性がある**。
– `null` や `空白スペース (” “)` のみのデータも無視した方がよい。

#### **✅ 改善策**

if (Alldata[i][j+1] && Alldata[i][j+1].trim() !== "") {

– `&& Alldata[i][j+1].trim() !== “”` を追加して、 **空白のみの値も無視** する。

## **🔹 改善後のコード**

function training() {
  var lastrow = Trainingsheet.getLastRow();
  var lastcolumn = Trainingsheet.getLastColumn();
  
  // Trainingシートのタイトル行を除く全データ取得
  var Alldata = Trainingsheet.getRange(2, 1, lastrow - 1, lastcolumn).getValues();
  
  var DB_lastrow = DBsheet.getLastRow();
  var DB_lastcolumn = DBsheet.getLastColumn();

  // DBシートのデータをクリア(タイトル行以降を削除)
  if (DB_lastrow > 2) {
    DBsheet.getRange(3, 1, DB_lastrow - 2, DB_lastcolumn).clear();
  }
  
  var dataToWrite = [];
  
  for (var i = 0; i < Alldata.length; i++) {
    for (var j = 0; j < Alldata[i].length - 1; j++) {
      if (Alldata[i][j+1] && Alldata[i][j+1].trim() !== "") {
        // データを配列に追加
        dataToWrite.push([Alldata[i][j+1], Alldata[i][0]]);
      }
    }
  }
  
  // まとめてDBシートに書き込み
  if (dataToWrite.length > 0) {
    DBsheet.getRange(3, 1, dataToWrite.length, 2).setValues(dataToWrite);
  }
}

## **🔹 改善点まとめ**
✅ **エラー防止:** `DB_lastrow > 2` のチェックを修正し、**負の範囲エラーを回避**
✅ **高速化:** `setValue()` の代わりに **配列にデータを溜めて `setValues()` で一括書き込み**
✅ **データチェックの厳密化:** `Alldata[i][j+1] && Alldata[i][j+1].trim() !== “”` で **空白や `undefined` を無視**
✅ **リファクタリング:** 変数名やロジックを **シンプルかつ分かりやすく** 変更

この修正により、処理速度と信頼性が向上し、エラーのリスクを減らせます!🚀

これを実行し再度デプロイ
URLが変更されるのでwebhookへ再度設定する

次に
replytouser.gs のコードレビュー

/**
 * 該当ユーザーへの
 * @param {*} replyToken 
 * @param {*} text 
 */
function replyToUser(replyToken, text) {

    const replyText = {
        "replyToken": replyToken,
        "messages": [{
            "type": "text",
            "text": text,
        }]
    }

    const options = {
        "method": "post",
        "headers":
        {
            "Content-Type": "application/json",
            "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN,
        },
        "payload": JSON.stringify(replyText)
    };

    // Line該当ユーザーに応答を返している
    UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply", options);
}

この replyToUser() 関数は、LINE Bot を使って特定のユーザーに応答メッセージを送る処理を行っています。
基本的な構造は問題ありませんが、以下の改善点があり
### **コードレビュー & 改善点**

この `replyToUser()` 関数は、LINE Bot を使って特定のユーザーに応答メッセージを送る処理を行っています。
基本的な構造は問題ありませんが、以下の改善点があります。

## **🔹 改善点と修正方法**

### **1. `CHANNEL_ACCESS_TOKEN` の存在チェック**
#### **🔹 問題点**

"Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN,

– **`CHANNEL_ACCESS_TOKEN` が `null` または `undefined` の場合、エラーになる可能性がある**。
– もし `CHANNEL_ACCESS_TOKEN` の取得に失敗した場合、LINE API へのリクエストが **無駄に送信されてしまう**。

#### **✅ 改善策**
– `CHANNEL_ACCESS_TOKEN` の **存在を事前にチェック** し、エラーが発生する前に処理を停止する。

if (!CHANNEL_ACCESS_TOKEN) {
    throw new Error("CHANNEL_ACCESS_TOKEN が設定されていません");
}

### **2. `UrlFetchApp.fetch()` のエラーハンドリング**
#### **🔹 問題点**
– `UrlFetchApp.fetch()` で **LINE API へのリクエストが失敗してもエラーログが記録されない**。
– 例えば、ネットワークエラーや無効な `replyToken` の場合、原因が分からない。

#### **✅ 改善策**
– `try-catch` を使用してエラー発生時に `Logger.log()` でエラーメッセージを記録する。

try {
    const response = UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply", options);
    Logger.log("LINE API Response: " + response.getContentText());
} catch (error) {
    Logger.log("Error in replyToUser: " + error.toString());
}

### **3. LINE API のレスポンスをチェック**
#### **🔹 問題点**
– `UrlFetchApp.fetch()` のレスポンスを **無視している**。
– **エラーメッセージやステータスコードを確認しないと、何が問題か分からない**。

#### **✅ 改善策**
– `response.getResponseCode()` を使って **200(成功)以外の場合にログを記録する**。

const response = UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply", options);
const responseCode = response.getResponseCode();

if (responseCode !== 200) {
    Logger.log("LINE API Error: " + response.getContentText());
}

### **4. コメントの改善**
#### **🔹 問題点**
– 関数の説明コメントが **途中で途切れている**。
– `@param` の説明が不十分(`replyToken` の説明がない)。
– **関数が何をするのか明確に記述すべき**。

#### **✅ 改善策**
– **関数の説明を具体化** し、`@param` も明確に。

/**
 * LINE Bot で特定のユーザーにメッセージを送信する関数
 *
 * @param {string} replyToken - LINEから受け取った返信用トークン
 * @param {string} text - 送信するメッセージ内容
 */

## **🔹 改善後のコード**

/**
 * LINE Bot で特定のユーザーにメッセージを送信する関数
 *
 * @param {string} replyToken - LINEから受け取った返信用トークン
 * @param {string} text - 送信するメッセージ内容
 */
function replyToUser(replyToken, text) {
    // CHANNEL_ACCESS_TOKEN が設定されているか確認
    if (!CHANNEL_ACCESS_TOKEN) {
        throw new Error("CHANNEL_ACCESS_TOKEN が設定されていません");
    }

    const replyText = {
        "replyToken": replyToken,
        "messages": [{
            "type": "text",
            "text": text,
        }]
    };

    const options = {
        "method": "post",
        "headers": {
            "Content-Type": "application/json",
            "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN,
        },
        "payload": JSON.stringify(replyText)
    };

    try {
        // LINE API へリクエストを送信
        const response = UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply", options);
        const responseCode = response.getResponseCode();

        // レスポンスをログに記録
        if (responseCode === 200) {
            Logger.log("LINE API Response: " + response.getContentText());
        } else {
            Logger.log("LINE API Error: " + response.getContentText());
        }
    } catch (error) {
        // エラーハンドリング
        Logger.log("Error in replyToUser: " + error.toString());
    }
}

## **🔹 改善点まとめ**
✅ **`CHANNEL_ACCESS_TOKEN` が `null` の場合にエラーをスロー**(事前チェック)
✅ **`try-catch` を追加し、`UrlFetchApp.fetch()` のエラーハンドリングを強化**
✅ **`response.getResponseCode()` をチェックし、API のレスポンスを記録**
✅ **コメント (`@param` の説明) を改善し、関数の目的を明確化**

この修正により、**エラーハンドリングが強化され、問題が発生したときにすぐ原因を特定できるようになります!** 🚀

再度デプロイして
webhookのURL更新

これでlinebot の動作確認できたので
過去に通知に line notify を使っていたものを調べて
Linebot に変える

リポジトリは
https://github.com/Snowpooll
にあるので

https://github.com/Snowpooll/store_adversting_lists
gmailで件名を指定し、未読の最新のメールを取得後にURLを抽出、
抽出したURLを元にブラウザを開き画像をダウンロード
ダウンロード画像へcloud vision api を実行し
購入リストにマッチしたものを LINE norifyで買い物リストが送信

https://github.com/Snowpooll/inventory_count
yoov8と学習したモデルで 指定した画像から物体検出をして在庫数が1以下になったときに
LINE norifyで買い物リストが送信
検出時の結果はsqlite3データベースに保存

とりあえずこの2つの機能を変えることにする