2026年1月14日水曜日

AI エージェントを作ってみた感想

AI エージェントを作ってみた感想

これまでにBotはいくつか作っていました

https://blog.kakakikikeke.com/2025/10/no-life-no-bot.html

それもエージェントと言えばエージェントなのかもしれませんがもう少しエンジニアっぽい AI エージェントを作ってみたので感想やらつらつら残しておきます

作ったもの

  • 不正プロセス監視エージェント
  • 不正ログ監視エージェント
  • 株価予測エージェント

不正プロセス監視エージェントのソースコード

#!/usr/bin/env python3
import json
import os
import subprocess
import time
from collections import deque

from google import genai
from slack_sdk import WebClient

GEMINI_API_KEY = "xxx"
SLACK_TOKEN = "xoxb-xxx"
SLACK_CHANNEL = "#general"
CUSTOM_BASE_URL = "https://your-llm-domain/endpoint"

# バッチ処理の設定
BATCH_MODE = (
    os.getenv("BATCH_MODE", "true").lower() == "true"
)  # デフォルトはバッチモード
BATCH_SIZE = int(os.getenv("BATCH_SIZE", "5"))  # 一度に判定するプロセス数
BATCH_TIMEOUT = int(os.getenv("BATCH_TIMEOUT", "10"))  # タイムアウト時間(秒)
MONITOR_INTERVAL = int(os.getenv("MONITOR_INTERVAL", "30"))  # プロセス取得間隔(秒)

print(f"Mode: {'BATCH' if BATCH_MODE else 'SINGLE'}")
print(
    f"BATCH_SIZE: {BATCH_SIZE}, BATCH_TIMEOUT: {BATCH_TIMEOUT}s, MONITOR_INTERVAL: {MONITOR_INTERVAL}s"
)

client = genai.Client(
    api_key=GEMINI_API_KEY,
    http_options=genai.types.HttpOptions(base_url=CUSTOM_BASE_URL),
)

slack = WebClient(token=SLACK_TOKEN)

# 既に通知済みのプロセスを追跡(重複通知を避けるため)
notified_processes = set()


# --- プロセス情報取得 ---
def get_processes():
    """
    ps コマンドでシステム上の全プロセスを取得する。
    戻り値: [{"pid": "...", "user": "...", "cpu": "...", "mem": "...", "cmd": "..."}, ...]
    """
    try:
        # aux フラグで詳細情報を取得
        cmd = ["ps", "aux"]
        result = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            check=True,
        )

        processes = []
        lines = result.stdout.strip().split("\n")

        # ヘッダースキップ
        for line in lines[1:]:
            parts = line.split()
            if len(parts) >= 11:
                processes.append(
                    {
                        "user": parts[0],
                        "pid": parts[1],
                        "cpu": parts[2],
                        "mem": parts[3],
                        "vsz": parts[4],
                        "rss": parts[5],
                        "cmd": " ".join(parts[10:]),
                    }
                )

        return processes

    except Exception as e:
        print(f"Error getting processes: {e}")
        return []


# --- LLM にプロセスの異常判定を依頼 ---
def analyze_process_single(process_info: dict) -> bool:
    """
    単一のプロセスを LLM で判定する。
    怪しいなら True を返す。
    """
    prompt = f"""
あなたは Linux サーバーのセキュリティ監視 AI です。
以下のプロセス情報が不審かどうか、以下の形式で答えてください。

プロセス情報:
- PID: {process_info['pid']}
- ユーザー: {process_info['user']}
- CPU使用率: {process_info['cpu']}%
- メモリ使用率: {process_info['mem']}%
- VSZ: {process_info['vsz']}
- RSS: {process_info['rss']}
- コマンド: {process_info['cmd']}

判断:
- 不審なら "SUSPICIOUS"
- そうでなければ "OK"

通常のシステムプロセス(kernel threads、systemd関連など)は OK と判定してください。
考察や理由は出力しないでください。
"""

    response = client.models.generate_content(
        model="gemini-2.5-pro",
        contents=prompt,
    )

    if response.text is None:
        print("No response text from LLM")
        return False

    result_text = response.text.strip().upper()
    return "SUSPICIOUS" in result_text


def analyze_processes_batch(processes: list) -> dict:
    """
    複数のプロセスを一度に LLM で判定する。
    戻り値: {pid: bool} の辞書
    """
    if not processes:
        return {}

    # プロセス情報をまとめてプロンプト作成
    processes_text = "\n".join(
        [
            f"{i+1}. PID: {p['pid']}, User: {p['user']}, CPU: {p['cpu']}%, Mem: {p['mem']}%, Cmd: {p['cmd']}"
            for i, p in enumerate(processes)
        ]
    )

    prompt = f"""
あなたは Linux サーバーのセキュリティ監視 AI です。
以下のプロセス情報が不審かどうか、それぞれを判定してください。

プロセス一覧:
{processes_text}

判定結果を以下の JSON フォーマットで出力してください:
{{
  "1": "OK",
  "2": "SUSPICIOUS",
  "3": "OK",
  ...
}}

JSONのキーはプロセス番号、値は "SUSPICIOUS" または "OK" です。
通常のシステムプロセス(kernel threads、systemd関連など)は OK と判定してください。
"""

    response = client.models.generate_content(
        model="gemini-2.5-pro",
        contents=prompt,
    )

    if response.text is None:
        print("No response text from LLM")
        return {p["pid"]: False for p in processes}

    # JSON を抽出して解析
    result_dict = {}
    try:
        # レスポンスから JSON を抽出
        json_str = response.text
        # JSONブロックの開始と終了を探す
        start_idx = json_str.find("{")
        end_idx = json_str.rfind("}") + 1
        if start_idx >= 0 and end_idx > start_idx:
            json_str = json_str[start_idx:end_idx]
            parsed = json.loads(json_str)

            # プロセスごとに判定結果をマッピング
            for idx, process in enumerate(processes, 1):
                result = parsed.get(str(idx), "OK").upper()
                result_dict[process["pid"]] = "SUSPICIOUS" in result
        else:
            # JSON が見つからない場合は全て False
            result_dict = {p["pid"]: False for p in processes}
    except json.JSONDecodeError as e:
        print(f"JSON parse error: {e}")
        result_dict = {p["pid"]: False for p in processes}

    return result_dict


# --- Slack 通知 ---
def notify(process_info: dict):
    message = f"""
:rotating_light: *Suspicious process detected!*
PID: {process_info['pid']}
User: {process_info['user']}
CPU: {process_info['cpu']}% | Mem: {process_info['mem']}%
Command: {process_info['cmd']}
"""
    slack.chat_postMessage(channel=SLACK_CHANNEL, text=message)


# --- フィルタリング関数 ---
def should_check_process(process_info: dict) -> bool:
    """
    監視対象外のプロセスをフィルタリング
    """
    cmd = process_info["cmd"]
    user = process_info["user"]

    # self プロセスは除外
    if "process_monitor_agent" in cmd:
        return False

    # root 関連の明らかに安全なプロセスは除外
    safe_keywords = [
        "kernel",
        "[",
        "]",
        "systemd",
        "sshd",
        "agetty",
        "bash",
        "sh",
        "grep",
        "ps",
        "vim",
        "nano",
        "sudo",
        "root",
        "ls -l",
        "sd-pam",
        "unbound",
        "cron",
        "sleep",
        "less",
        "/sbin/init",
        "canonical-livepatch",
        "snapd",
        "ubuntu-advantage/timer.py",
        "multipathd",
        "journalctl",
        "bpftrace",
        # 開発ツール関連
        "tmux",
        "mysqld",
        "redis-server",
        "code-server",
        "mosquitto",
        "ollama",
        "nginx",
        # postfix 関連
        "pickup -l -t unix -u -c",
        "tlsmgr -l -t unix -u -c",
        "qmgr -l -t unix -u",
        "/usr/lib/postfix/sbin/master",
        # docker 関連
        "dockerd",
        "docker-proxy",
        # apt 関連
        "apt-get",
        "packagekitd",
        "/usr/lib/update-notifier/apt-check",
        "tar -df alternatives.tar.0 -C /var/lib/dpkg alternatives",
        # EDR 関連
        "cbram",
        "cybereason-sensor",
        "cybereason-activeconsole",
    ]
    if any(keyword in cmd for keyword in safe_keywords):
        return False

    return True


# --- プロセス監視メイン処理 ---
def monitor_processes():
    """
    定期的にプロセスを取得して監視
    """
    print("Process monitor started.")

    if BATCH_MODE:
        # バッチモード
        monitor_processes_batch()
    else:
        # 単発モード
        monitor_processes_single()


def monitor_processes_single():
    """単発モード: 各プロセスを即座に判定"""
    while True:
        try:
            processes = get_processes()

            # フィルタリング
            filtered_processes = [p for p in processes if should_check_process(p)]

            for process in filtered_processes:
                try:
                    suspicious = analyze_process_single(process)
                    if suspicious:
                        pid = process["pid"]
                        if pid not in notified_processes:
                            print(f"[!] Suspicious process: {process}")
                            notify(process)
                            notified_processes.add(pid)
                    else:
                        print(f"[OK] {process['cmd']}")

                except Exception as e:
                    print(f"Error analyzing process: {e}")

            time.sleep(MONITOR_INTERVAL)

        except Exception as e:
            print(f"Error in monitor loop: {e}")
            time.sleep(MONITOR_INTERVAL)


def monitor_processes_batch():
    """バッチモード: プロセスをバッチでまとめて判定"""
    process_buffer = deque()
    last_batch_time = time.time()

    while True:
        try:
            current_time = time.time()
            processes = get_processes()

            # フィルタリング
            filtered_processes = [p for p in processes if should_check_process(p)]

            # バッファに追加
            for process in filtered_processes:
                process_buffer.append(process)

            # バッチ処理の条件: BATCH_SIZE に達したか、BATCH_TIMEOUT を超えたか
            should_process = len(process_buffer) >= BATCH_SIZE or (
                len(process_buffer) > 0
                and current_time - last_batch_time >= BATCH_TIMEOUT
            )

            if should_process or current_time - last_batch_time >= MONITOR_INTERVAL:
                try:
                    # バッファ内のプロセスを一度に処理
                    processes_to_process = list(process_buffer)
                    process_buffer.clear()
                    last_batch_time = current_time

                    if processes_to_process:
                        results = analyze_processes_batch(processes_to_process)

                        for process in processes_to_process:
                            pid = process["pid"]
                            is_suspicious = results.get(pid, False)

                            if is_suspicious:
                                if pid not in notified_processes:
                                    print(f"[!] Suspicious process: {process}")
                                    notify(process)
                                    notified_processes.add(pid)
                            else:
                                print(f"[OK] {process['cmd']}")

                except Exception as e:
                    print(f"Error analyzing processes: {e}")

            time.sleep(1)  # CPU負荷を減らすため1秒待機

        except Exception as e:
            print(f"Fatal error: {e}")
            time.sleep(MONITOR_INTERVAL)


if __name__ == "__main__":
    while True:
        try:
            monitor_processes()
        except Exception as e:
            print(f"Fatal error: {e}, restarting in 5 seconds...")
            time.sleep(5)

これを systemd 配下で動作させています

簡単に流れの紹介

  1. ps aux の結果を取得
  2. LLM (gemini) に投げて各プロセスが怪しいか判定してもらう
  3. 怪しい場合は Slack に通知

他のログ監視も同じような仕組みです
株価予測エージェントは上記の流れとだいぶ違うのは違うのですが LLM に過去の株価情報を渡して予測してもらうだけです

感想

以下不正プロセス監視エージェントを使ってみた感想や所感です

お金がかかるので呼び出し方を工夫しなければならない

  • LLM にわたす情報を複数行にまとめて API をコールする
  • 1行ずつだと時間もかかるしお金もかかる
  • ローカル LLM が一つの解決策だがそれなりのマシンスペックは必要

判断するまでの速度

  • API (ネットワーク) + LLM の推論の時間が必ずかかるので判断までに数秒かかる
  • 完全なリアルタイムを実現するのは難しい
  • 一瞬で消えてしまうプロセスをキャッチできないケースがある
  • 速度もローカル LLM で解決できそうだが結局高スペックマシンでもそれなりの推論時間がかかるので数ミリ秒レベルのレスポンス速度は期待できない

精度が曖昧

  • 本当にアウトな場合のテストができない
  • アウトじゃないけどアウトと判定してしまう
  • アウトじゃないプロセスは無視するような仕組みが必要になる (コード内参照)
  • それ(特定のプロセス無視/ホワイトリスト形式)でいいのかという問題もある (同一プロセス名だと攻撃されても検知できなくなる)
  • 前はセーフだったけど次はアウトにしちゃう
  • 「怪しい」という LLM への命令が曖昧すぎるが故の過剰検知とハレーション

そもそもAI エージェントの具体的なユースケースは

  • 人間には難しい「判断」や「解釈」が必要なタスク+状況に応じて行動を変える仕事
  • ログから「人間レベルの洞察」を出す
  • 毎日の情報を「読む → 取捨選択 → 解釈 → 提案」する
  • インシデント予兆検知
  • コードのレポート・改善案
  • などなど、とにかく得意なのは「判断」と「解釈」あと「生成」

(現状は)何かに特化した AI エージェントしか作れない

  • 本当にやりたいことは「何でも」やってくれる汎用 AI エージェント
  • でも現状はやりたいことだけをやってくれる特化 AI エージェントしか作れない
  • もっというと「やりたいこと」すら AI エージェントが見つけて勝手にやってほしい
  • 特化エージェントを組み合わせて汎用っぽく見せかける

AI を使わなくてもいい問題

  • 今回作成した AI エージェントのようなログ監視やプロセス監視の製品はある
  • 判断が静的 (特定の文字列を含む場合にアラート/CPUが90%以上になったらなど) だがそれで十分なケースが多い

そもそも「AI エージェント」の定義は

  • 広義には「人の代わりに作業をやってくれるツール」だと思っている
  • 自分は今回のようなプロセス版のエージェントも VSCode の Copilot Chat のようなエディタもエージェントだと思っている
  • エージェントにもいろいろなタイプがあるので言葉に惑わされてはいけない

既製品を使ってもいい

ブースティング

  • 複数の LLM に判断させてその結果で最終的な判断をする
  • エヴァンゲリオンのマギシステム的なイメージ
  • ただしお金が倍々に増えていくので注意

最後に

AI もあるので AI エージェント自体を作ることは非常に簡単ですが本当に有用なものを作るのは難しいと言うか無くても何とかなるというのが正直なところです

株価予測エージェントは評価機能もあるので評価の結果が良ければ実践投入してどうなるか試してみようかなと思います
売買も自動でできると更に良いかなと思っています

2025年12月25日木曜日

2025 できたこと・できなかったこと

2025 できたこと・できなかったこと

去年に引き続き今年もやりました

できたこと

時系列

自分のプロダクトをしっかり整理できたのはよかった
すべてのプロダクトに基本的な CI がある状態になった

新規でプロダクションも追加できたのはよかった

これらもすべて AI 先生のおかげ
AI 先生の使い方もだいたいわかってきた (とりあえず質問 -> チャッピー、コーディング -> vscode + copilot chat、その他バッチなど -> API を使う)

断捨離も引き続き継続

Youtube、Podcast 活動は終わりを迎えた

その他

  • ブログ執筆数: 231 (12/24 時点)
  • Podcast エピソード数: 0

ブログの投稿数は前年度とほぼ横ばい (7記事減)
Podcast 3->0 と減少した

0 でもいいじゃない

できなかったこと

  • Youtube Live (去年に引き続き)

Youtube Live は戒め

来年の豊富

  • 引き続き断捨離 and アウトプット
  • 何か資格取る

アウトプットは全体的に多めにできたと思う
管理するアプリがまた増えたので効率的に管理できるようにしたい

ある程度の身辺整理が落ち着いたので挑戦というより安定と効率化を重視するようになった気がする

ほぼ上記は去年と同じ
やってきたことは 2024 と変わらないが更に磨きがかかった気がする

達観し始めているのかもしれない

2025年12月23日火曜日

Switch2 でダークソウルリマスタープレイメモ

Switch2 でダークソウルリマスタープレイメモ

これが死にゲーってやつか

価格

  • Switch2板 2365円

基本的な倒し方

  • 盾を構えて旋回して後ろに回って攻撃
  • スタミナに注意する
  • あとは敵のパターンを覚える(重要)
  • パリィとか後ろから致命とかは考えない
  • 初見で倒せると思わない

装備

  • 初期装備(ロングソードとハードレザー系)の強化と進化と派生で何とかなると言えばなる
  • 強化できる武器や防具のほうがおもしろい
  • ボス戦は大ダメージ武器使うと楽になるケースが多々あった
  • 自分は黒騎士の剣が序盤に取れてしまったのでそれでいった
  • クリアできないときはエスト20+軽ロリ

好きな武器

  • 黒騎士の剣

1周目クリア時ステータス

  • プレイ時間 -> 75時間
  • レベル -> 107
    • 体力40
    • 記憶力8
    • 持久力30
    • 筋力40
    • 技量30
    • 耐久力23
    • 理力9
    • 信仰9
  • 装備
    • 黒騎士の剣+5
    • 封印者シリーズ+5

強かった敵ランキング

全般的に前半の黒騎士シリーズは強すぎる印象

  1. 城下不死街 黒騎士
    • 初見で挑んで勝てるわけ無い
    • 低レベル
  2. 城下不死教区 鐘のガーゴイル
    • 二人ズルすぎ
  3. マヌス、アルトリウス、グウィン
    • タイマンだと強いと感じた2人
    • もはや何してるかよくわからない
    • 倒せないときはエスト20にしてからくればどっちも何とかなる
  4. 楔のデーモン
    • 知らんくて序盤で突っ込んでた
    • パターン覚えてからは楽
  5. はぐれデーモン
    • 衝撃波ずるすぎ
    • 衝撃波打ってこないことを祈る運ゲー
  6. 月光蝶
    • 強くはないが2回打ってくる光線が強すぎ
    • 光線を打ってこないことを祈る運ゲー

弱かった敵

  • 上記以外のボス
  • レベル上げて今日武器で適当に殴っていても勝てちゃうボスも多数
  • 中盤から強くなりすぎるとボスがだいぶ弱く感じた
  • 難しいのはダンジョン攻略になってくる

嫌いなダンジョンランキング

  1. 病み村
    • 毒うざすぎる
    • ハエみたいな敵うざすぎる
  2. 混沌の廃都イザリス
    • マグマの効果音うるさすぎ
    • マグマのせいですぐに防具が壊れる
    • 恐竜多すぎ
  3. 地下墓地
    • スケルトン無限湧き面倒すぎ
    • 滑車のやつ面倒すぎ
  4. 結晶洞窟
    • 道見えないのが意味不明

最強の敵

  • 車輪スケルトン
    • 強すぎる
  • 複数いる敵は基本強い

ソウル稼ぎ

序盤

  • 黒い森の庭
    • 紋章で入るところの篝火を使う
    • 草の敵もいて毒消し集めにもなる
    • 1周2:30で終わる
    • 坂を下って3匹の草を倒す
    • 森の広場に出て緑の鎧x2、草x4
    • 砦の前の緑の鎧x1
    • 砦近くの緑の鎧x1
    • 木に入るトカゲx1
    • で全部倒したら再度篝火に戻る

中盤/終盤

  • 黒い森の庭
    • シフの紋章のところにいるNPCを崖に落とす有名なやつ
    • 結局ここ
  • エレーミアス絵画世界
    • 真ん中に出るやりのキモいやつをまとめて倒す

アイテム稼ぎ

  • 貪欲な金の蛇の指輪+人間性を10にしてからやること

オープンワールド故の理不尽さと楽しさ

  • 小ロンド遺跡とかいきなり行けるが幽霊がいてダメージを与えれず進めないということに気づかずトライし続けてしまう
  • 自分は全くチャートがわからず普通に攻略サイトを見ながらプレイした
  • ショートカットの作成が大事
  • マップが広すぎるので覚えるのが大変

システムというか言葉も難しい

  • 亡者/生者
  • 人間性とアイテム発見率と結晶トカゲ
  • 篝火
  • 注ぎ火
  • 火防女
  • 鍛冶 (神聖,邪教,武器派生など)
  • あったかふわふわ

やりごたえはすごい

  • 周回システム
    • 2周目は強くてニューゲームだが敵も強くなっているので普通に死ねる
    • というか2周目からかなりキツくなる、敵のダメージが尋常じゃなくらい跳ね上がる
    • 脳筋レベル上げだと普通に詰むので持久力で勝負するしか無い
    • 火力も不足するので両手持ち+回避で倒せるようにするしかない部分もある
    • 周回を想定するなら魔法のほうがいいのかも
    • 7周目まで強化されるらしい
  • 縛りプレイ
  • タイムアタック

クラッシュする

  • ボス戦闘中何回かあった、頻繁という感じはしない
  • 地下墓地で車輪に引かれているとき
  • 湖獣バトル中
  • 基本背景の描画とか攻撃の描画処理が重いケースではクラッシュすることがあった

最後に

全然関係ないが Manus っていう完全自立型 AI があるらしい

2025年10月16日木曜日

ラブブを定価で買う方法

ラブブを定価で買う方法

ちゃんと正規のお店に行って買うのが一番です
一応買えたのでメモ
買い方が複雑すぎる、、

結論

  • LivePocket で抽選を受けて当選したら現地に行って購入する

お店に入るには抽選を受ける必要がある

  • ここで定期的に抽選しているのでまずはこれに応募し当選する
  • 1人1店舗しか応募できない
  • 平日の方が当選しやすいとかあるのだろうか
  • 普通にお店にいってもラブブのぬいぐるみのやつは買えない?
  • 確証はないが抽選のない通常営業日に朝から並べば買えたりするのかも (確証はない

そして抽選に当選しても買える保証がない

  • 自分はマカロンとエネジーというシリーズがほしかったが売り切れだった (しかも眼の前で
  • 購入制限はあるが売り切れることがある
  • ブラインドボックスなるガチャ箱が人気らしくそれが1人複数個買えるらしいので自分の番になる前に売り切れる可能性がある
  • 単品物(写真でいうところの紫のやつ)も人気のやつがあるらしいがこっちは比較的購入しやすそう

抽選の倍率は

  • 不明
  • とにかく抽選し続けるしかない
  • 店舗によっても変わりそう
  • 1ヶ月に1回程度なのでそもそも倍率は高くなりそう

購入制限について

  • ぬいぐるみ系は基本制限あり
  • ブラインドボックスは2種類までで個数は1ボックスに入ってる数まで
    • 例えばマカロンとエネジーでマカロン2個とエネジー3個とか
    • マカロンもエナジーもボックスは6個入っているので6個まで買えるということ
  • 単品系はそれぞれ1個まで買える
  • 事前に現在の売り切れ情報を教えてくれる
  • が自分はそうだったが入店後にエナジーが売り切れたのでこの段階で商品の在庫があるかどうか保証するものではない
  • また以下の商品が購入制限のある商品だったがこれらは店内になくレジで直接伝えて購入するのでこれらだけがほしい場合は手ぶらでレジに直行でOK
  • ただこれも説明があると思うが1人1会計のみなので店内に陳列してある商品もほしい場合はそれらを手に取ってからレジに並ばなければならない

当選番号もかなり重要

  • 自分は240番だったがその番号だと人気シリーズ(というかほしかったマカロンとエナジー)は売り切れ状態だった
  • 前にも書いたが購入制限はあるものの前の方々が上限いっぱいまで書い続ければそれだけ売り切れの可能性が増えるので若い番号のほうがいい
  • 100番内ならほぼほしいやつが買えるかもしれない
  • 当日並んだときもところどころ空き番号があったので常連は当選しても番号次第ではお目当てのものは買えないとわかっているので来ないのかもしれない

当日購入までの流れ

  • 当選したチケットの確認
  • 身分証明書を持参する
  • 当選番号で入れる時間が決まるのでその時間の確認
  • 時間の15分前に整列が始まるのでそれくらいに現地に到着すること
  • 整列
  • チケットのQRコードと身分証明書の確認
  • 購入制限の説明と現在の在庫状況の説明
  • 時間まで整列しながら待機
  • 入店
  • レジ直行
  • ほしいやつ伝えて購入
  • そく退店

という感じです
お店によりますが待機列が外の場合があるので冬は防寒対策も必須です

お店に行くとオリジナルの紙袋(40円)も購入できるのでファンには嬉しいのかも

ネットで買えるのか

  • PopMart の公式オンラインストアはある
  • がラブブ系はほぼ在庫切れ
  • 商品が入荷したら通知が来るようにできるが通知がきてすぐに行ってもほぼ売り切れ
  • 予約注文が可能になることがあるのでそのときに予約注文して届くのを待つのはあり
  • ただ箱1個とかはほぼなくボックス売りの予約注文がほぼ
  • 合計10,000万円以下の送料は660円
  • 箱1個だけの購入とかはほぼ不可能に近い印象

転売は未だに高い

  • 各種フリマサイトやショッピングサイト系を見ると未だに高い印象
  • 一時期よりかはだいぶ安くなったイメージもある?
    • エナジー定価 2,250円
    • 転売現状 5,000 - 6,000円
    • 転売少し前 10,000円

最後に

人気があるコンテンツなので仕方ないですが面倒だったので忘れないようにメモしました
入店の抽選方式は転売対策とは言えないかもですがそれでも長蛇の列を作らない、入店時のパニックなどを回避する手段としては有効かなと思いました

マカロンとエナジーは買えませんでしたがそれでも子どもたちがよろこんでくれたのでよかったです

昔のハイパーヨーヨーとかたまごっちみたいな一時的に爆発的に人気になるコンテンツは今も昔も変わらずあるんだろうなと感じた、、

2025年10月9日木曜日

Sinatra4.2.0にアップデートしたらホームページが動かなくなったので対応した

Sinatra4.2.0にアップデートしたらホームページが動かなくなったので対応した

私のホームページはSinatraでできています

背景

Sinatra が 4.2.0 になり PATH_INFO の扱いが変わりました https://github.com/sinatra/sinatra/compare/v4.1.1...f53a68482f3d5a1a868320f5da3ca7e14bfd8fdf

それに伴いルーティングの定義を仕方も変わったのでそれに対応しました

結論

ちゃんと get '/' do ... end を使いましょう

仕組み

自分のホームページは各ページをクラスとして定義しています
例えば /about を管理するページは以下のようにクラス化されています

# frozen_string_literal: true

require './libs/ogp'
require './controllers/base_page'

# 自己紹介ページのルーティングを定義するクラス
class AboutPage < BasePage
  def initialize
    @path = '/' # config.ru 側でパスを指定するのでここはすべて / になる
    @real_path = '/about' # sitemap 生成用のパス
    @tpl_name = :about
    @title = 'About @kakakikikeke'
    @description = '興味のある分野、職歴、資格について紹介しています'
    @image = OGP::DEFAULT_IMAGE
    @sitemap = { priority: 0.8 }
    super(@real_path, @sitemap)
    routing
  end

  def routing
    AboutPage.get @path do
      @ogp = OGP.new(@title, @description, @path, @image)
      erb @tpl_name
    end
  end
end

でこのクラスを config.ru で Rack::URLMap を使って読み込み

# frozen_string_literal: true

require './libs/routing'

run Rack::URLMap.new(Routing.definition)

展開しています

# frozen_string_literal: true

Dir[File.expand_path('../controllers', __dir__) << '/*.rb'].sort.each do |file|
  require file
end

# ルーティングを定義するモジュール、ちゃんとメニューの順番と同じになるようにrouting.jsonを定義すること
# また動的に生成するページは各Pageクラス内で定義すること
module Routing
  def self.definition
    JSON.parse(File.read('./routing.json'))['routing'].map do |info|
      [info['path'], Module.const_get(info['class']).new]
    end.to_h
  end
end

今回の現象

上記のように展開すると本来は 200 が返るのですがなぜか 404 になるようになってしまいました

対応策

で結果的に対応策は以下のようになりました
各ページで定義しているルーティング情報をメソッド内で呼び出すのではなくちゃんとクラス配下に定義してあげるようにしています

# frozen_string_literal: true

require './libs/ogp'
require './controllers/base_page'

# 自己紹介ページのルーティングを定義するクラス
class AboutPage < BasePage
  PATH = '' # config.ru 側でパスを指定するのでここはすべて空になる

  def initialize
    @real_path = '/about' # sitemap 生成用のパス
    @tpl_name = :about
    @title = 'About @kakakikikeke'
    @description = '興味のある分野、職歴、資格について紹介しています'
    @image = OGP::DEFAULT_IMAGE
    @sitemap = { priority: 0.8 }
    super(@real_path, @sitemap)
  end

  AboutPage.get PATH do
    @ogp = OGP.new(@title, @description, PATH, @image)
    erb @tpl_name
  end
end
  • @path を PATH として定数にする
  • かつ PATH は必ず空にする
  • routing メソッドを削除し get を直接クラス配下に定義する

これで一応動作するようになりました

おまけ: Rack::Lint::LintError: uppercase character in header name: Content-Security-Policy (Rack::Lint::LintError)

CSP ヘッダなどをミドルウェアで定義する場合は大文字を使ってはいけないようです

# frozen_string_literal: true

require './libs/routing'

# 静的ファイルに対するヘッダーを設定するミドルウェア
class StaticHeadersMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    status, headers, body = @app.call(env)

    path = env['PATH_INFO']
    if path =~ %r{^/(robots\.txt|favicon\.ico|img/|js/|css/|ipa/)}
      headers['content-security-policy'] ||= "default-src 'self';"
      headers['cross-origin-embedder-policy'] ||= 'require-corp'
      headers['cross-origin-opener-policy'] ||= 'same-origin'
      headers['cross-origin-resource-policy'] ||= 'same-origin'
      headers['permissions-policy'] = 'camera=(), microphone=(), geolocation=(), fullscreen=(self)'
    end

    [status, headers, body]
  end
end

use StaticHeadersMiddleware

run Rack::URLMap.new(Routing.definition)

最後に

メソッド内で get などを定義すると実際にアクセスされた際にルーティングの定義が見つからずエラーになるようです

2025年10月1日水曜日

ボットは絵も動画もブログも作成できるようになった

ボットは絵も動画もブログも作成できるようになった

人生とはボットを育てることなのかもしれない

これまで

https://blog.kakakikikeke.com/2025/04/kakakikikeke-die-twice.html

自分のツイートを学習させて自分っぽいツイートができるようになりました

アカウント

https://x.com/kakakikikekebot

現在のボットにできること

2025/09/26 現在ボットは成長し以下ができるようになりました

  • ツイートをする
  • ブログを書く
  • 絵を書く
  • ショート動画を作る

以下それぞれの仕組みを詳細に説明します

共通点

  • 動作マシンはローカルの M2 Pro mac mini
  • バックエンドでは「CPU50%のマイニング」「Ollamaサーバの起動」「SD-webui」が稼働している
  • 生成時は MPS にすべて対応しているものを採用
  • Python で実装
  • 生成時間は動画以外数分で完了 (絵、文章系)

ツイートをする

Twitter API + Ollama API (gemma3)

  • 自分のツイートを500件準備、そこから20件をプロンプトのコンテキストとして与える
  • ツイートを生成してもらうようなプロンプトを入力
  • レスポンスをパース、サニタイズなど調整してからツイート
  • ハッシュタグやリンクなどは含めないようにしている
  • プロンプトは以下
def build_prompt(past_tweets, sample_size=20):
    examples = "\n".join(random.sample(past_tweets, sample_size))
    return f"""以下は過去のツイートです:

----ここから過去のツイート----
{examples}
----ここまで過去のツイート----

これらの文体や話題を参考にして、あなたらしい新しいツイートを1つ生成してください。
日本語でかつ文章の意味がわかるような自然なツイートにしてください。
ツイートに「https://」から始まるようなURLは絶対に含めないでください。
ツイートの長さは50文字以内にしてください。
ツイートを作成した理由などは説明しないください、ツイートのみ生成してください。
"""

ブログを書く

Twitter API + Blogger API + Ollama (gemma3)

  • テーマを与えてそのテーマに基づいたブログ記事を書いてもらう
  • 出力は必ずHTMLになるようにプロンプトで調整、デフォルトがMarkdownっぽいので可能な限りMarkdownにならないようにする
  • テーマはランダムで100件保持、100件分のテーマを消費すると停止する
  • テーマはChatGPTに生成してもらっている
  • プロンプトは以下
def build_prompt_for_blog_post(theme):
    return f"""以下はブログ記事のテーマです。

----ここからテーマ----
{theme}
----ここまでテーマ----

このテーマを参考にして、あなたらしい新しいブログ記事を1つ生成してください。
ブログ記事内では Markdown 記法は使えません、Markdown 記法は自動で HTML に変換されません。絶対に Markdown 記法を使わないでください。
ブログ記事は Markdown 記法を使わずに生成してください。
ブログ記事内では HTML タグが使えます。
コードなどシンタックスハイライトが必要な場合は HTML タグを使ってください。
ブログ記事に画像は含めないでください。
ブログ記事内に StyleSheet や CSS の記述を含めることは可能ですがブログ本体の StyleSheet や CSS に影響を与えないようにしてください。
ブログ記事を作成した理由などは説明しないください、ブログ記事の内容のみ生成してください。
"""

絵を書く

Twitter API + Stable Diffusion WebUI API (model はランダム)

  • SD-Web UI を API モードで起動してそれをコールする
  • SD-Web UI の命令はそのまま JSON のペイロードとして API に送信できるので楽
  • 生成された画像のサイズは896x1152
  • モデルは固定ではなく10個ほど保持しておりそれを順番に使用していくように Python 側で制御
  • プロンプトもランダムになるようにしておりベース、性別、向き、服装、背景など要素ごとに毎回ランダムで生成するようにしている
def generate_prompt():
    # プロンプトの定義
    base = "masterpiece, best quality, ultra-detailed, 8k, cinematic lighting"
    gender = "".join(
        ("1 beautiful japanese young women, fair skin, light makeup, realistic face")
    )
    viewpoint = get_random_viewpoint()
    posing = get_random_posing()
    hair_cut = get_random_hair_cut()
    hair_color = get_random_hair_color()
    hair = f"{hair_color} {hair_cut}"
    face = get_random_face()
    background = get_random_backend()
    tops = get_random_tops()
    bottoms = get_random_bottoms()
    clothes = f"{tops}, {bottoms}"
    return "".join(
        f"{base}, {gender}, {viewpoint}, {posing}, {hair}, {face}, {background}, {clothes}",
    )

ショート動画を作る

Twitter API + Stable Diffusion WebUI API + LTX-Video (txv-2b-0.9.8-distilled)

  • text-to-video ではなく image-to-video の手法
  • 先に SD-WebUI を使って画像を作成してもらう
  • その画像を使って LTX-Video で動画にする
  • 256x384の4秒動画を作成 (num_frames=121、frame_rate=30)
  • 4秒以上の動画を生成する場合は192x256にすれば生成できる
  • 本当は896x1152で生成したいがリソースが足りない
  • 動画の生成はほぼマシンリソースをすべて使うので生成前にマイニングおよび ollama の runner を停止している
  • 各種プロセスを停止しないとメモリが枯渇してほぼ100%クラッシュする
  • 1動画生成するのに20-60分ほどかかる
  • 生成時間にムラがありおそらく入力している画像の影響だと思われるが安定しない
  • LTX-Video をサブプロセスとしてコールしているのでその作りが微妙
def convert_image_to_video(
    img_filename: str, width: int, height: int, prompt: str = ""
) -> Optional[str]:
    # 環境変数からパスを取得、設定がなければデフォルト値を使用
    work_dir = os.getenv("LTX_WORK_DIR", "/Users/kakakikikeke/Documents/work/LTX-Video")
    img_dir = os.getenv("LTX_IMG_DIR", "/Users/kakakikikeke/data/repo/videobot")

    # 仮想環境の Python を直接指定
    venv_python = os.path.join(work_dir, "env", "bin", "python")

    # 実行コマンド
    command = f"""
    cd {work_dir} && \
    PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0 \
    {venv_python} inference.py \
    --prompt "{prompt}" \
    --conditioning_media_paths {img_dir}/{img_filename} \
    --conditioning_start_frames 0 \
    --height {height} \
    --width {width} \
    --pipeline_config configs/ltxv-2b-0.9.8-distilled.yaml
    """

今後追加したい機能

  • 音楽の作成
    • AudioGen
    • RVC
  • 長めの動画
    • 今のマシンだと画像サイズを落としてフレーム数を上げるしかない
    • もっと軽量なモデルを探す
    • 本当はWan2.2やVeo3レベルの動画を生成したい
  • 俳句
    • gemma3 でプロントプト換えるだけ
  • 音楽+動画
    • 上記で生成した音楽と動画を何かしらで組み合わせる
    • 最終形はこれなのかもしれない
  • 昔の写真や自分で書いた絵を動画にしたい
    • 過去の復元
    • 手書きの絵 -> アニメ化 -> 動画化
  • NanoBanana 連携
    • 有料だとかなり強力
    • というかお金を使う前提ならローカルで全部動かさないで Google AI Studo API や Colab でガンガン動かして高品質、高画質、長尺コンテンツを作りまくれる
    • ただマネタイズ考えないと大変なことなりそう
    • 動画にするだけならVeo3でもいける

課題

  • Twitter API Free プランの上限
    • 500ツイート/1日にほぼ当たっている
    • 有料プランにすれば解決するがどうするか
    • コンテキスト(メディア系、テキスト系)にアカウントを分割するか、管理は面倒になる
  • Ollama で動作させるモデル
    • gemma3 はだいぶ軽量なので動作する
    • gpt-oss のような大規模LLMは動作しない (動作はするがスワップだらけでかなり遅くなる)
    • openhermes など試したがハッシュタグや無駄に絵文字を使うのでやめたりした
    • いいモデルを探すのが難しい
  • プロンプトだけで制御するのには限界がある
    • 含めないでと命令しても含めてくる
    • モデルに合ったプロンプトを調整しなければいけないのが辛いのでなんとかしたい (プロンプト最適化など)
  • プロンプトのパターン追加
    • テーマなど手動の部分があるので自動化したい
    • 絵を描く際のプロンプトのランダム抽出の候補を追加する
    • そもそも完全ランダムにする方法を考える
  • 絶望的にマシンスペックが足りない
    • やはり AppleSilliocn だとかなり辛い
    • 基本は RTX などの CUDA が使えるグラボがあることが前提 (xformers動作など)
    • 動画生成は特に VRAM を消費するので VRAM モリモリのグラボがないと厳しい (Wan2.2など)
  • ディスクも足りない
    • ローカルにモデルを置きまくっているのでディスクが枯渇する

最後に

多少の知識はいるが生成系は本当に楽な時代になった

2025年9月10日水曜日

NanoBanana の API を試す

NanoBanana の API を試す

ちゃんとこういうテック記事も投稿しないとダメだなと思い試しました
Python を使っています

環境

  • macOS 15.6.1
  • Python 3.12.11
    • google-genai==1.35.0
    • pillow==11.3.0

準備

必要なライブラリをインストールします

  • pipenv install google-genai
  • pipenv install pillow

google-generativeai は Deprecated なので注意してください
google-genai を使いましょう

API キーの取得

Google AI Studio (ここ)から生成しましょう

サンプルコード

比較的新しめの技術に関しては AI 先生に聞いても教えてくれないから公式ドキュメントを見るのが一番なんですよね

ファイルをアップロードして変換してもらうだけの簡単なスクリプトなのでリファクタなどはしていません

xxx の部分は上記で取得した API キーを入力してください

from io import BytesIO

from google import genai
from PIL import Image

client = genai.Client(api_key="xxx")

prompt = """
  背景を宇宙に変更してください。
"""
image = Image.open("reforg.jpeg")

# チャットクライアントの作成
chat = client.chats.create(model="gemini-2.5-flash-image-preview")
# イメージとプロンプトの送信
response = chat.send_message([prompt, image])

# 結果(テキストと画像)の取得、一応 pyright のエラーも対応
if response.candidates is None:
    raise

if response.candidates[0].content is None:
    raise

parts = response.candidates[0].content.parts
if parts is not None:
    for i, part in enumerate(parts):
        if part.text is not None:
            print(part.text)
        elif part.inline_data is not None and part.inline_data.data is not None:
            image = Image.open(BytesIO(part.inline_data.data))
            image.save(f"generated_image_{i}.png")

で入力した画像がこれで

出力された画像が以下です

すばらしい
特にレスポンスの速さがすばらしい

最後に

Google AI Studio の UI からだと制限があったりしてうまく画像が生成されないことがあるようなのでそんな場合は API を使いましょう

ただこれだとチャットというよりかは一問一答の会話なのでチャット的な感じでインタラクティブな感じにしたいのであればもう少し手を加える筆意ようがありそうです

それでもこれが無料で使えるのは本当に素晴らしいことだと思います

参考サイト