gemini で広告解析とレシピ提案と読み上げ

https://axross-recipe.com/recipes/1741
を参考いgemini で広告解析とレシピ提案と読み上げ

pip install streamlit google-generativeai pillow pyttsx3

で必要なライブラリインストール

import streamlit as st
import google.generativeai as genai
from io import BytesIO
from PIL import Image  
import io
import pyttsx3
import queue

# Gemini API設定
genai.configure(api_key="YOUR_API_KEY_HERE")

# TTSエンジンの初期化
engine = pyttsx3.init()
is_speaking = False  # 音声合成中かどうかを示すフラグ
tts_queue = queue.Queue()  # 音声合成リクエストのキュー

def extract_text_from_image(image):
    model = genai.GenerativeModel(model_name="gemini-1.5-pro-latest")
    response = model.generate_content([
        image,
        {"text": "画像からテキストを抽出してください。"}
    ])
    return response.text if response else ""

def parse_product_data(text):
    lines = text.split("\n")
    ingredients = set()

    for line in lines:
        line = line.strip()
        ingredients.update(line.split())
    return list(ingredients)

def suggest_recipes(ingredients):
    model = genai.GenerativeModel(model_name="gemini-1.5-pro-latest")
    prompt = f"以下の食材を使って作れる料理のレシピを提案してください: {', '.join(ingredients)}"
    response = model.generate_content(prompt)
    return response.text if response else ""

def read_aloud(text):
    tts_queue.put(text)  # リクエストをキューに追加
    process_queue()  # キューを処理

def process_queue():
    global is_speaking

    if not tts_queue.empty() and not is_speaking:
        text = tts_queue.get()  # キューからテキストを取得
        is_speaking = True  # 音声合成中フラグを立てる
        engine.say(text)  # 音声合成を実行
        engine.runAndWait()  # 音声合成が完了するまで待機
        is_speaking = False  # 音声合成が完了したらフラグをリセット
        process_queue()  # 次のリクエストを処理

def start_reading(text):
    read_aloud(text)

st.title("チラシ画像からレシピ提案")

uploaded_file = st.file_uploader("チラシ画像をアップロード", type=["jpg", "png", "jpeg"])
if uploaded_file:
    st.image(uploaded_file, caption="アップロードされた画像", use_column_width=True)
    image_bytes = uploaded_file.read()
    image = Image.open(BytesIO(image_bytes))

    # 一時的に画像ファイルとして保存する例(環境に合わせて変更すること)
    img_byte_arr = io.BytesIO()
    image.save(img_byte_arr, format='PNG')
    img_byte_arr = img_byte_arr.getvalue()

    with open("uploaded_tirashi_image.png", "wb") as f:
        f.write(img_byte_arr)
    input_file = genai.upload_file(path="uploaded_tirashi_image.png", display_name="image")
    
    with st.spinner("データを解析中..."):
        extracted_text = extract_text_from_image(input_file)
    
    st.subheader("抽出されたテキスト")
    st.text_area("テキスト", extracted_text, height=200)

    ingredients = parse_product_data(extracted_text)

    if ingredients:
        st.subheader("提案されたレシピ")
        recipes = suggest_recipes(ingredients)
        st.write(recipes)
        if st.button("提案されたレシピを読み上げる"):
            # レシピが長すぎる場合は最初の部分だけ読み上げるなどの調整も可能
            start_reading("提案されたレシピは次の通りです。 " + recipes)
    
    # 停止ボタンの実装
    if st.button("読み上げ停止"):
        engine.stop()
        engine.endLoop()
        st.info("読み上げを停止しました。")

が全体コード

このコードはダウンロードできる
あとチラシ画像もダウンロードしておく

これでやることは
アップロードされたチラシ画像をGemini APIに送信し、商品情報やキャンペーン内容などのテキストを抽出

抽出されたテキストから使用可能な食材を解析し、Gemini APIにより最適なレシピを生成

Pyttsx3ライブラリを用い、提案された情報やレシピを音声で読み上げます。
ユーザーは「停止」ボタンにより、いつでも読み上げを中断

この中で欲しい機能は
Gemini のチラシ解析
Python でgemini の結果の読み上げ

 touch voice_resipi.py

でファイルを作成

import streamlit as st
import google.generativeai as genai
from io import BytesIO
from PIL import Image  
import io
import pyttsx3
import queue

で必要なライブラリのインポート

* streamlit: Webアプリ開発を簡単に行えるライブラリ。
* google.generativeai (Gemini): 画像やテキストの解析・生成を行うGoogleの生成AI。
* BytesIO, PIL.Image, io: 画像データの取り扱いに使用。
* pyttsx3: テキストを音声に変換するためのTTSライブラリ。
* queue: 複数の処理を順次実行するための仕組みを提供。

次にGemini APIとTTSエンジンの初期設定

# Gemini APIの設定
genai.configure(api_key="YOUR_API_KEY_HERE")

# TTSエンジンの初期化
engine = pyttsx3.init()
is_speaking = False  # 音声合成中かどうかのフラグ
tts_queue = queue.Queue()  # 音声合成リクエストのキュー

* APIキーを設定し、Geminiサービスの利用を可能にします。
* pyttsx3を初期化して音声読み上げの準備を行い、フラグとキューで複数のリクエストを管理

やっぱり設定は gemini.configure で行っている
以前参考にしたサイトのコードは間違っていた

https://qiita.com/shisuto3141/items/2f9349e96703c189f572
のように

self.client = genai.Client(api_key=API_KEY)

は間違い

次に画像からテキストを抽出する関数

def extract_text_from_image(image):
    model = genai.GenerativeModel(model_name="gemini-1.5-pro-latest")
    response = model.generate_content([
        image,
        {"text": "画像からテキストを抽出してください。"}
    ])
    return response.text if response else ""

* アップロードされた画像をGemini APIに送信し、画像内の文字情報を抽出します。
* エラーが発生した場合は、空文字を返す

もし動かないのならモデル名を変更する

次に
テキストから商品情報を解析する関数

def parse_product_data(text):
    lines = text.split("\n")
    ingredients = set()
    
    for line in lines:
        line = line.strip()
        ingredients.update(line.split())
    return list(ingredients)

* 各行の単語をセットに追加して重複を排除し、食材や商品名を抽出

次に
Gemini APIでレシピを提案する関数

def suggest_recipes(ingredients):
    model = genai.GenerativeModel(model_name="gemini-1.5-pro-latest")
    prompt = f"以下の食材を使って作れる料理のレシピを提案してください: {', '.join(ingredients)}"
    response = model.generate_content(prompt)
    return response.text if response else ""

* 解析された食材情報を基に、Gemini APIにレシピ生成を依頼します。
* 提案されたレシピをテキスト形式で返す

なおingreduebtsはリストで

def parse_product_data(text):

の処理結果の食材のリストになる

これを使って処理して結果はtext に入る
つまりgeminiの処理結果、つまり提案されたレシピがテキスト情報として格納される

次に音声読み上げリクエストを管理する関数群

リクエスト追加用関数を作成しておく

def read_aloud(text):
    tts_queue.put(text)  # 読み上げリクエストをキューに追加
    process_queue()      # キューを処理

これで
ユーザーが「読み上げ」ボタンを押すと、対象のテキストがキューに追加され、順次処理

次にリクエスト処理用関数

def process_queue():
    global is_speaking

    if not tts_queue.empty() and not is_speaking:
        text = tts_queue.get()  # キューからテキストを取得
        is_speaking = True      # 読み上げ中のフラグを立てる
        engine.say(text)        # テキストを音声合成
        engine.runAndWait()     # 読み上げ完了まで待機
        is_speaking = False     # フラグをリセット
        process_queue()         # 次のリクエストを処理

キューに溜まったリクエストを1件ずつ順次処理し、同時に複数の読み上げが行われないよう制御

次に ラッパー関数

def start_reading(text):
    read_aloud(text)

これは
「read_aloud」を呼び出すラッパー関数で、コード全体の可読性を向上

次にStreamlitによるWeb画面の基本設定

st.title("チラシ画像からレシピ提案")

アプリのタイトルをWeb画面に表示し、ユーザーに内容を即座に伝える

次に画像アップロード機能の実装

uploaded_file = st.file_uploader("チラシ画像をアップロード", type=["jpg", "png", "jpeg"])
if uploaded_file:
    st.image(uploaded_file, caption="アップロードされた画像", use_column_width=True)

ユーザーがチラシ画像をアップロードできるようにし、選択した画像を画面に表示

次に画像の読み込みと変換

    image_bytes = uploaded_file.read()
    image = Image.open(BytesIO(image_bytes))

アップロードされた画像ファイルをバイナリデータとして読み込み、PILで画像オブジェクトに変換

次に画像を一時ファイルとして保存

    # 一時的に画像ファイルとして保存する例(環境に合わせて調整してください)
    img_byte_arr = io.BytesIO()
    image.save(img_byte_arr, format='PNG')
    img_byte_arr = img_byte_arr.getvalue()

画像データをPNG形式で一時ファイルに変換し、Gemini APIに渡す準備を行う

次に画像ファイルのアップロードとGemini API連携

    with open("uploaded_tirashi_image.png", "wb") as f:
        f.write(img_byte_arr)
    input_file = genai.upload_file(path="uploaded_tirashi_image.png", display_name="image")

一時ファイルとして保存した画像をGemini APIへアップロードし、テキスト抽出を実行可能にする

次にテキスト抽出と結果表示

    with st.spinner("データを解析中..."):
        extracted_text = extract_text_from_image(input_file)
    
    st.subheader("抽出されたテキスト")
    st.text_area("テキスト", extracted_text, height=200)

処理中はスピナーを表示し、抽出されたテキストをテキストエリアでユーザーに提示

次にレシピの生成

    ingredients = parse_product_data(extracted_text)

抽出テキストから食材情報を解析し、該当する場合は音声読み上げ機能でお知らせ

次にレシピ提案の表示と音声読み上げ

    if ingredients:
        st.subheader("提案されたレシピ")
        recipes = suggest_recipes(ingredients)
        st.write(recipes)
        if st.button("提案されたレシピを読み上げる"):
            start_reading("提案されたレシピは次の通りです。 " + recipes)

解析した食材情報を元に、Gemini APIで生成されたレシピを画面に表示し、音声でも確認

次に読み上げ停止機能の実装

    if st.button("読み上げ停止"):
        engine.stop()
        engine.endLoop()
        st.info("読み上げを停止しました。")

ユーザーが「読み上げ停止」ボタンを押すと、現在の音声再生を即座に中断し、停止完了のメッセージを表示

これらを保存し

streamlit run voice_resipi.py 

で起動
自動でブラウザが立ち上がるので
テスト画像をアップ
すると食材とレシピが表示される

なお読み上げには1分以上経ってからにしないとAPIリクエストが多すぎてエラーになるので注意

不要な情報(価格、電話番号、住所、営業時間など)を取り除き、食材に焦点を当ててレシピを提案します。
鶏むね肉と夏野菜の炒め物
材料:
* 鶏むね肉 (100g):一口大に切る
* トウモロコシ:実を外す
* ピーマン (1パック):種を取り、細切りにする
* トマト (1パック):くし切りにする
* キュウリ (1パック):薄切りにする
* 旬彩盛 (1パック):内容に応じて適当な大きさに切る (例:もやしならそのまま、ニラなら3cm程度に切る)
* サラダ油:大さじ1
* 塩コショウ:少々
* 醤油:小さじ1
* 酒:小さじ1
* 鶏ガラスープの素:小さじ1/2
作り方:
1. フライパンにサラダ油を熱し、鶏むね肉を炒める。
2. 鶏肉の色が変わったら、ピーマン、キュウリ、旬彩盛を加えて炒める。
3. 野菜がしんなりしてきたら、トウモロコシ、トマトを加えて軽く炒める。
4. 塩コショウ、醤油、酒、鶏ガラスープの素で調味する。
5. 全体に味がなじんだら、火を止めて完成。
うなぎと野菜の丼
材料:
* うなぎ (1串):温める
* ピーマン (1パック):千切りにする
* キュウリ (1パック):千切りにする
* トマト (1パック):薄切りにする
* 温かいご飯:適量
* 刻み海苔:適量
作り方:
1. 温かいご飯を丼によそう。
2. ピーマン、キュウリ、トマトを丼に盛り付ける。
3. うなぎを乗せる。
4. 刻み海苔を散らして完成。
国産牛ロースのミニステーキ
材料:
* 国産牛ロース (100g):軽く塩コショウを振る
* サラダ油:小さじ1
* 醤油:小さじ1/2
* わさび:お好みで
作り方:
1. フライパンにサラダ油を熱し、牛ロースを焼く。
2. 片面1~2分ずつ焼き、焼き加減を調整する。
3. 醤油を回し入れて香りを出す。
4. 皿に盛り付け、お好みでわさびを添えて完成。
これらのレシピは、提示された少ない食材で簡単に作れるものです。旬彩盛の内容がわからないため、具体的な調理法は示していませんが、他の野菜と同様に炒めたり、丼に添えたりできます。 必要に応じて、他の調味料や食材を追加してアレンジしてみてください。

この内容の長さでも pyttxs なら macなら読み上げ可能

Ubuntuだと音源がないのでできないので
別のものを試す

チラシの解析(gemini)

チラシの解析(gemini)

【AI × チラシ解析】忙しい社会人のための“節約レシピ提案アプリ”を作ってみた

がまさに答えっぽい

touch image_analysis.py

でファイルを作成

イメージは.pngなので
これを読み込むようにする

from google import genai
from PIL import Image

class Gemini:
    def __init__(self):
        API_KEY = "生成したAPIキー"
        self.model = "gemini-2.0-flash"
        self.client = genai.Client(api_key=API_KEY)
        self.prompt = "スーパーマーケットの広告画像です。それぞれの広告に掲載されている商品と価格を”全て”抽出してリストにしてください。また掲載されている食材を使った今晩のレシピを提案してください。その際、1人前のおおよその価格も計算して教えてください。なお、調味料や米などは自由に使えるものとします。"

    def loadImage(self):
        # スクレイピングした画像データをロード
        image_paths = glob.glob("./source/*.jpg")
        images = []
        for image_path in image_paths:
            images.append(Image.open(image_path))
        self.images = images

    def run(self,prompt="",image=""):
        prompt = self.prompt
        images = self.images

        response = self.client.models.generate_content(
            model=self.model
            ,contents=[images,prompt]
        )
        self.response = response.text
        print(self.response)

が参考もとコード

これを商品リストのみに変更する

mkdir source
cp step-1.png source 

でファイルを移動

実行したら

Traceback (most recent call last):
  File "/Users/snowpool/aw10s/gemini/image_analysis.py", line 1, in <module>
    from google import genai
ImportError: cannot import name 'genai' from 'google' (unknown location)

となった

どうやらインポートの文が間違いらしい

これを

import google.generativeai as genai

としたけど動作しない

Mainの記述がないで

if __name__ == "__main__":
    gemini = Gemini()
    gemini.loadImage()
    gemini.run()

を追加したが

python image_analysis.py
Traceback (most recent call last):
  File "/Users/snowpool/aw10s/gemini/image_analysis.py", line 34, in <module>
    gemini = Gemini()
             ^^^^^^^^
  File "/Users/snowpool/aw10s/gemini/image_analysis.py", line 9, in __init__
    self.client = genai.Client(api_key=API_KEY)
                  ^^^^^^^^^^^^
AttributeError: module 'google.generativeai' has no attribute 'Client'

となった

Google の google-generativeai ライブラリには Client というクラスは存在しません
とのこと

以下のように genai.configure() を使い、GenerativeModel を直接生成
gemini-2.0-flash → テキスト特化(画像には非対応)
gemini-pro-vision → 画像入力に対応 ✅

ということなので

import glob
from PIL import Image
import google.generativeai as genai

class Gemini:
    def __init__(self):
        API_KEY = "AIzaSyBGtutzF_xdEWcPf8343jyAt_Qq3t1cFIQ"  # セキュアな方法で管理を推奨
        genai.configure(api_key=API_KEY)

        # モデルを設定(画像対応モデルは gemini-pro-vision)
        self.model = genai.GenerativeModel("gemini-pro-vision")
        self.prompt = "スーパーマーケットの広告画像です。それぞれの広告に掲載されている商品と価格を”全て”抽出してリストにしてください。"

    def loadImage(self):
        # ./source/*.png を取得
        image_paths = glob.glob("./source/*.png")
        if not image_paths:
            print("⚠️ 画像が見つかりません")
        self.images = [Image.open(path) for path in image_paths]

    def run(self):
        for idx, image in enumerate(self.images):
            print(f"🖼 画像{idx+1} を処理中...")
            try:
                response = self.model.generate_content(
                    [self.prompt, image],
                    stream=False
                )
                print("✅ 結果:")
                print(response.text)
            except Exception as e:
                print("⚠️ エラー:", e)

# 実行用ブロック
if __name__ == "__main__":
    gemini = Gemini()
    gemini.loadImage()
    gemini.run()

としたが

🖼 画像1 を処理中...
⚠️ エラー: 404 Gemini 1.0 Pro Vision has been deprecated on July 12, 2024. Consider switching to different model, for example gemini-1.5-flash.
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1745014048.933884 7409777 init.cc:232] grpc_wait_for_shutdown_with_timeout() timed out.

gemini-1.5-pro-vision
にしても

🖼 画像1 を処理中...
⚠️ エラー: 404 models/gemini-1.5-pro-vision is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1745014171.856705 7412024 init.cc:232] grpc_wait_for_shutdown_with_timeout() timed out.

ということで使用するモデルバージョンを変更

import glob
from PIL import Image
import google.generativeai as genai

class Gemini:
    def __init__(self):
        API_KEY = "AIzaSyBGtutzF_xdEWcPf8343jyAt_Qq3t1cFIQ"  # セキュアな方法で管理を推奨
        genai.configure(api_key=API_KEY)

        # モデルを設定(画像対応モデルは gemini-pro-vision)
        self.model = genai.GenerativeModel("gemini-1.5-pro")
        self.prompt = "スーパーマーケットの広告画像です。それぞれの広告に掲載されている商品と価格を変更せずに”全て”抽出してリストにしてください。"

    def loadImage(self):
        # ./source/*.png を取得
        image_paths = glob.glob("./source/*.png")
        if not image_paths:
            print("⚠️ 画像が見つかりません")
        self.images = [Image.open(path) for path in image_paths]

    def run(self):
        for idx, image in enumerate(self.images):
            print(f"🖼 画像{idx+1} を処理中...")
            try:
                response = self.model.generate_content(
                    [self.prompt, image],
                    stream=False
                )
                print("✅ 結果:")
                print(response.text)
            except Exception as e:
                print("⚠️ エラー:", e)

# 実行用ブロック
if __name__ == "__main__":
    gemini = Gemini()
    gemini.loadImage()
    gemini.run()

として実行すると

✅ 結果:
リストは以下の通りです。

* カップヌードル:125円
* 柔軟剤:699円
* 冷凍食品:109円
* ポカリスエット:329円
* キリンレモン:459円
* お茶漬け:179円
* 大人用オムツ:299円
* ミックスナッツ:329円
* 牛乳:299円
* ドレッシング:249円
* 救急バン:169円
* フェイスマスク:99円
* 豆乳飲料:259円
* ティッシュペーパー:399円
* ハーゲンダッツ:199円
* 海苔:199円
* 食パン:89円
* ウェットティッシュ:99円
* バナナ:139円
* 卵:128円
* ポテトチップス:159円
* ベーコン:89円
* プチトマト:299円
* キュウリ:249円
* サラダチキン:299円
* 豆腐:369円
* ヨーグルト:179円
* 冷凍うどん:848円
* 冷凍食品:880円
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1745015805.274105 7441488 init.cc:232] grpc_wait_for_shutdown_with_timeout() timed out.

元々のプロンプトは

        self.prompt = "スーパーマーケットの広告画像です。それぞれの広告に掲載されている商品と価格を”全て”抽出してリストにしてください。"

これだと

🖼 画像1 を処理中...
✅ 結果:
画像に掲載されている商品と価格のリストです。

* カップ麺:109円
* ドリンク:30円/329円
* 食パン:125円/249円
* 殺虫剤:50円/699円
* ポカリスエット:150円/459円
* 鶏肉:179円/329円
* 牛乳:299円
* ペン:20円/249円
* 救急バン:169円/119円
* パック飲料:10円/299円
* シャンプー:259円/399円
* ハーゲンダッツ:250円/199円
* 緑茶:199円
* トイレットペーパー:89円
* 卵:99円
* バナナ:139円/128円
* 豆腐:159円
* 納豆:89円
* 油揚げ:299円
* ヨーグルト:249円
* みかん:299円/369円
* ソーセージ:179円/848円
* ハム:880円

チラシ解析を cloud vision API で行う

チラシ解析を cloud vision API で行う

Python のバージョンを変えたので

pip install google-auth google-auth-oauthlib google-auth-httplib2 google-api-python-client pillow selenium google-cloud-vision requests

で必要なライブラリを再インストール
こういう時に
Requestments.txt を作ってあると何が必要かわかる

次に

from google.cloud import vision

client = vision.ImageAnnotatorClient()

with open("test.jpg", "rb") as fb:
    content = fb.read()

image = vision.Image(content=content)

response = client.document_text_detection(image=image)
texts = response.text_annotations
print(texts[0].description)

でまずは動作するかテスト
成功したら

cp ../gemini/step-1.png .

でこのスクショでも読み込めるかテストする

from google.cloud import vision

client = vision.ImageAnnotatorClient()

# with open("test.jpg", "rb") as fb:
with open("step-1.png", "rb") as fb:
    
    content = fb.read()

image = vision.Image(content=content)

response = client.document_text_detection(image=image)
texts = response.text_annotations
print(texts[0].description)

これを

python vision_api_test.py

で実行

結果は

4月 16日
水餃
125
249
本日限定
50
699
涼食品3a
10
30
$150
179
ジャクエッセン
POCA
109 329 459
SWEAT
ちゃんぽん
299
399
介護オムツ
20
10
$299
249
169
クレラップレ
299
救急バン
259
クレラッ 399
250
199119
Häagen-Dazs
99
89-35
139 128
159
89
99 299
299
249
30
299 399 179848
179 848 880
Q
Q
?
搞大
縮小 ブラウザ表示 プリント ヘルプ

というようになる

大きな文字は読み取れるが
小さな文字は解像度の問題か読み取れていない

次はgemini を試す

Yomitokuでチラシを解析Colab

Yomitokuでチラシを解析Colab

Yomitokuで写真やレシートを解析してみる

Colab で行なってるのがあったので参考にする

出力形式によって得られる情報が異なるらしい

A100で実験する

https://github.com/kotaro-kinoshita/yomitoku
でライブラリが公開されているので
ドキュメントなどはこれを読む

基本的にGPUが必要

! pip install yomitoku

でライブラリインストール

最後にsession restartしろといわれたので、ダイアログボックスに促されるままrestartする

次に写真の用意

google colabなので、/content以下にimageフォルダを作成し、その中にJPGをいれ
フォルダをまるっと指定すると、その中の画像ファイルをすべて解析してくれます

!mkdir image

でフォルダ作成
ここに写真をアップロードする

/Users/snowpool/aw10s/gemini/step-1.png

をアップロード

! yomitoku /content/image/ -f md -o results -v --figure

で解析

結果は自動作成される results の中にある

ファイル名_p1.md
という感じで出力される

Linebot の画像の管理

Linebot の画像の管理

line へ投稿した画像が保持される期間を調べる

✅ LINE Bot API による画像メッセージの保持期間
項目 内容
originalContentUrl / previewImageUrl 外部URLのため、LINEサーバーには保存されない
LINEサーバー側での画像保存 ❌ なし(開発者が画像URLを提供するだけ)
ユーザー端末での表示保持 一時的にキャッシュされるが、LINE側での保証なし
画像が消える条件 URL先のファイルが削除・非公開になると表示不能になる

originalContentUrl に指定された画像は LINEアプリで直接URLにアクセスして表示しているだけ。
そのため、Google Drive 上の画像を削除すると即時表示できなくなります。
LINE自体には画像が保存されていないため、画像の「保持期間」という概念は存在しません。

既読前に削除されると見えない:「タップしても開けない」状態に。
表示後に削除されると再表示できないこともある:一部キャッシュは残るが再読み込み時にエラーになる可能性。

✅ 安全な運用方針(おすすめ)
* 送信後に数分~数時間だけDriveに画像を保持
→ ユーザーが確実に見られる時間を確保
* ログに送信済みの画像IDと時刻を記録し、後でバッチ削除

とのこと

つまり削除してしまうと閲覧不可能になる

ただ現状の用途は
在庫管理の動作の画像確認
チラシの商品の画像の確認
なので
2日、つまり48時間後には不要になる

このため48時間経過後に削除するようにモジュールを作成する
これでストレージ圧迫を回避できるはず

なお設定時間は今後変更する可能性が高いため
Config.jsonで設定値を変更可能にする

"image_retention_hours": 48  // ← 追加(削除までの保持時間)

の項目を追加

そして削除モジュール

vim delete_old_images.py

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

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

FOLDER_ID = config["google_drive_folder_id"]
RETENTION_HOURS = config.get("image_retention_hours", 48)  # 設定がなければ48時間

# 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, createdTime)").execute()
    return results.get("files", [])

def delete_old_images(files, hours_threshold):
    now = datetime.datetime.utcnow()
    for file in files:
        created_time_str = file.get("createdTime")
        if created_time_str:
            created_time = datetime.datetime.strptime(created_time_str, "%Y-%m-%dT%H:%M:%S.%fZ")
            delta = now - created_time
            if delta.total_seconds() > hours_threshold * 3600:
                try:
                    drive_service.files().delete(fileId=file["id"]).execute()
                    print(f"✅ Deleted: {file['name']} (created: {created_time})")
                except Exception as e:
                    print(f"⚠️ Error deleting file {file['id']}: {e}")

if __name__ == "__main__":
    files = get_drive_images()
    if not files:
        print("No images found.")
    else:
        delete_old_images(files, hours_threshold=RETENTION_HOURS)

しかし実行すると

⚠️ Error deleting file : <HttpError 403 when requesting https://www.googleapis.com/drive/v3/files/? returned "The user does not have sufficient permissions for this file.". Details: "[{'message': 'The user does not have sufficient permissions for this file.', 'domain': 'global', 'reason': 'insufficientFilePermissions'}]">

となる

これはファイルの所有者が自分のアカウントで
サービスアカウントでアップしたファイルではないため

このためサービスアカウント権限に編集権限を与えるか
サービスアカウントでgoogle drive へアップする必要がある

現在 yolov8 で画像の出力はできているので
サービスアカウントで出力された画像をGoogle Drive へアップロードするモジュールが欲しい

ということで作成

drive_uploader.py

内容を

import os
import json
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
from google.oauth2 import service_account

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

FOLDER_ID = config["google_drive_folder_id"]
SERVICE_ACCOUNT_FILE = "service_account.json"
SCOPES = ["https://www.googleapis.com/auth/drive"]

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

def upload_image_to_drive(filepath, folder_id=FOLDER_ID):
    """
    指定した画像ファイルを Google Drive にアップロードする
    :param filepath: 画像ファイルのパス
    :param folder_id: Drive フォルダ ID
    :return: アップロードされたファイルの ID(失敗時は None)
    """
    if not os.path.exists(filepath):
        print(f"❌ File not found: {filepath}")
        return None

    filename = os.path.basename(filepath)
    file_metadata = {
        "name": filename,
        "parents": [folder_id],
        "mimeType": "image/jpeg"
    }

    media = MediaFileUpload(filepath, mimetype="image/jpeg")

    try:
        file = drive_service.files().create(
            body=file_metadata,
            media_body=media,
            fields="id"
        ).execute()
        print(f"✅ Uploaded to Drive: {filename} (ID: {file['id']})")
        return file["id"]
    except Exception as e:
        print(f"⚠️ Upload failed: {e}")
        return None

def get_public_url(file_id):
    """
    指定されたファイルIDを誰でも見られるように公開設定し、表示用URLを返す
    :param file_id: アップロード済みファイルの ID
    :return: 公開URL(失敗時は None)
    """
    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"⚠️ Failed to make public: {e}")
        return None

として保存

次にアップロードテスト
Yolov8の結果をアップしたいので

from drive_uploader import upload_image_to_drive, get_public_url

file_id = upload_image_to_drive("runs/detect/predict/image1.jpg")
if file_id:
    url = get_public_url(file_id)
    print("✅ 公開URL:", url)

でパスを

../inventory/runs/detect/predict26/image0.jpg 

に変更して実行

なお、コピペするときに改行してしまうと

  File "/Users/snowpool/aw10s/gas_iamge_bot/upload_test.py", line 3
    file_id = upload_image_to_drive("../inventory/runs/detect/predict26/image0.jpg 
                                    ^
SyntaxError: unterminated string literal (detected at line 3)

というようにエラーになるので注意

修正後再度実行すると

python upload_test.py

✅ Uploaded to Drive: image0.jpg (ID: )
✅ 公開URL: https://drive.google.com/thumbnail?id=&sz=w1000

というように成功する

GoogleDrive の tmpフォルダにファイルがあるのも確認

次にこのファイルを削除できるかテストする

ただし48時間経っていないと削除対象にならないためとりあえず保留

48時間経過したので実験

 python delete_old_images.py
✅ Deleted: image0.jpg (created: 2025-03-27 21:04:02.459000)
⚠️ Error deleting file : <HttpError 403 when requesting https://www.googleapis.com/drive/v3/files/? returned "The user does not have sufficient permissions for this file.". Details: "[{'message': 'The user does not have sufficient permissions for this file.', 'domain': 'global', 'reason': 'insufficientFilePermissions'}]">

となって削除完了

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, "最新の検出画像を送信します")


変えたら今度は動かない

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