Xのツリー投稿が面倒すぎる問題を解決!AI StudioとPython(標準機能のみ)で作った半自動投稿アシスタント【コード全文公開】

Tech & AI

X(旧Twitter)やThreadsで、複数ポストを繋げた「ツリー投稿(スレッド投稿)」を発信するとき、毎回手作業でコピペを繰り返していないだろうか?

特に、親ポストに複数枚の画像を添付し、リプライ欄に個別アイテムや詳細記事のパーマリンクを1つずつ載せていく運用は、画面の往復や深いフォルダ階層からの画像選択など、地味に大きな手間と精神的負荷がかかる。

この記事でわかること

  • Xの有料APIを使わず、Web Intentとクリップボード連携で作業を効率化する設計思想
  • Google AI Studio(無料枠)で要件定義・壁打ちをして即座にコード化するプロセス
  • 外部ライブラリ不要(Python標準tkinterのみ)で動く投稿アシスタントツールの全コード
  • ファイルダイアログへパスを直接貼り付ける「画像選択の爆速化テクニック」

1. はじめに:Xのスレッド投稿(マルチポスト)はなぜ面倒なのか?

API有料化の壁と「半自動」という割り切り

以前であればAPIを活用した完全自動投稿Botを組むことも容易だったが、現在のX APIは有料化(Basicプランでも月額100ドル〜)されており、個人運用のブログや趣味・個人アカウントで気軽に導入するにはハードルが高くなっている。

コピペ往復と画像添付のボトルネック

では、手作業でツリー投稿を作成する現場では何が起きているだろうか。典型的な作業手順は以下の通りだ。

  1. 原稿テキストから「Post 1(親ポスト)」をコピーしてXの投稿枠にペースト
  2. 画像添付ボタンを押し、フォルダ階層を潜って対象画像を複数選択
  3. 右下の「+」ボタンを押して2枠目を追加(または親ポスト送信後にリプライ欄を開く)
  4. 「Post 2」のテキストをコピー&ペースト、対応する画像をフォルダから探して添付……(以下、Post 3, Post 4と続く)

この「エディタとブラウザの往復」や「Windowsのファイル選択ダイアログで目的の画像をマウスで探す手間」が、毎回の作業時間を大きく奪っていた。

そこで今回は、「完全自動化を目指すのではなく、人間が作業に関わりながら最も面倒な部分(コピペ・画像指定)を極限まで省力化する」という半自動アプローチをとることにした。

2. Google AI Studio(無料枠)でアイデアを即ツール化する

このツールの骨格は、Google AI Studioとの対話を通じてわずか数十分で組み上げた。

「Web Intentだけでスレッドを一括展開できないか?」という疑問

YouTubeの「共有ボタン」を押すと、Xの投稿画面に動画タイトルとURLが最初から入った状態で開く。これはX公式の「Web Intent(ウェブ・インテント)」というURLスキーム(https://x.com/intent/post?text=...)を利用した仕組みだ。APIキー不要・完全無料で利用できる。

「この仕組みを拡張して、複数ポスト(スレッド)も一撃で下書きセットできないか?」とAI Studioに相談したところ、Xの仕様上の明確な制約が判明した。

Web Intentの仕様制限

  • 単一ポスト限定: Web Intentで渡せるパラメータはtextやurlなど1件分のみ。スレッド用の複数枠を一括展開する公式パラメータは存在しない。
  • リプライ先のID未確定: スレッドは「親ポストのID」に対する返信構造を持つため、投稿前の下書き段階では2通目以降をURLから紐付けられない。

方針転換:人間とツールの「心地よい役割分担」

仕様上できないことを無理にハックするのではなく、以下のように人間とツールの役割を分担した。

アクション 担当 仕組み・工夫
親ポスト(Post 1)展開 ツール ➔ ブラウザ Web Intentを呼び出し、本文が入力済みの状態でブラウザを開く
画像添付 ツール ➔ 人間 画像の絶対パスをクリップボードにコピー。ファイル選択画面で Ctrl + V して Enter するだけ
ツリー枠追加(+ボタン) 人間 Xの画面上で「+」を1クリック
Post 2以降の本文流し込み ツール ➔ 人間 「記事nコピー」ボタンを押してX画面で Ctrl + V

この要件をAI Studioに投げかけると、Python標準のGUIライブラリ(tkinter)を使ったデスクトップアプリの完全なコードが即座に生成された。

3. 作成したツールの仕組みと操作の流れ

UI設計と操作コックピット

アプリを起動すると、左側に原稿テキストの貼り付け欄、右側に各ポストの操作カードが自動展開される。

┌─────────────────────────────┬─────────────────────────────────────────────────┐
│ 【左側: 記事案貼り付け】    │ 【右側: 操作パネル】                            │
│                             │                                                 │
│ 画像Baseフォルダ:           │ [親ポスト]                                      │
│ [ C:\Users\Public\Pictures ]│ [① 記事1をXで開く]  [📋 本文コピー]  [🖼️ 画像] │
│ --------------------------- │ ─────────────────────────────────────────────── │
│ ▼ 【Post 1】 親ポスト       │ [Post 2]                                        │
│ ...                         │ [📋 記事2コピー]  [🖼️ 画像コピー]               │
│ ▼ 【Post 2】 リプライ1      │ ─────────────────────────────────────────────── │
│ ...                         │ [Post 3]                                        │
│                             │ [📋 記事3コピー]  [🖼️ 画像コピー]               │
└─────────────────────────────┴─────────────────────────────────────────────────┘

時短の核:ファイル選択ダイアログへのパス直貼り

Windowsのファイル選択ダイアログ(エクスプローラー)は、下の「ファイル名」入力欄に画像のフルパスを貼り付けてEnterキーを押すと、そのファイルを即座に開いて選択状態にする仕様がある。

作業ステップ(1ポストあたり約3〜5秒)

  1. ツール側で「画像コピー」をクリック(絶対パスがクリップボードに入る)
  2. Xの画面で画像ボタンをクリック
  3. 開いたファイルダイアログの「ファイル名」欄で Ctrl + V ➔ Enter(一発で画像添付完了)

深いフォルダをマウスで探す手間が完全にゼロになる。

4. 【コード公開】Python投稿アシスタント(追加ライブラリ不要)

追加の pip install は一切不要だ。Pythonがインストールされている環境であれば、ファイルを保存して実行するだけで起動する。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
X (Twitter) スレッド投稿デスクトップアシスタント
- 追加ライブラリ不要(Python標準の tkinter, webbrowser, urllib のみで動作)
"""

import os
import re
import sys
import urllib.parse
import webbrowser
import tkinter as tk
from tkinter import ttk, scrolledtext

def parse_draft(raw_text):
    if not raw_text.strip():
        return []
    split_pattern = r'(?=(?:###\s*\[Post\s*\d+\]|▼\s*【Post\s*\d+】|【Post\s*\d+】))'
    sections = re.split(split_pattern, raw_text, flags=re.IGNORECASE)
    posts = []
    count = 0
    for sec in sections:
        sec = sec.strip()
        if not sec:
            continue
        m = re.match(r'^(?:###\s*\[Post\s*(\d+)\]|▼\s*【Post\s*(\d+)】|【Post\s*(\d+)】)(.*)', sec, re.IGNORECASE)
        if not m:
            continue
        count += 1
        p_num = int(m.group(1) or count)
        p_title = (m.group(2) or f"Post {p_num}").strip()
        is_parent = (p_num == 1) or ("親" in p_title)

        body_match = re.search(r'```(?:text)?\r?\n([\s\S]*?)```', sec)
        if body_match:
            content = body_match.group(1).strip()
        else:
            lines = [l for l in sec.split('\n') if not l.strip().startswith(('###', '▼', '---', '===')) and 'generated_cards' not in l and '添付' not in l]
            content = '\n'.join(lines).strip()

        img_pattern = r'(?:generated_cards/[^\s`"\'\)]+\.(?:png|jpg|jpeg|webp|gif)|https?://[^\s`"\'\)]+\.(?:png|jpg|jpeg|webp|gif)|(?:[a-zA-Z]:[\\/]|/)[^\s`"\'\)]+\.(?:png|jpg|jpeg|webp|gif))'
        meta_part = sec.split('```')[0] if '```' in sec else sec
        images = []
        for img in re.findall(img_pattern, meta_part, re.IGNORECASE):
            clean_p = img.strip('`"\' ')
            if clean_p not in images:
                images.append(clean_p)

        posts.append({
            'num': p_num,
            'title': p_title or ('親ポスト' if is_parent else f'Post {p_num}'),
            'is_parent': is_parent,
            'content': content,
            'images': images
        })
    return posts

def resolve_path(base_dir, img_path):
    if not base_dir.strip():
        return img_path.strip()
    clean_base = base_dir.strip().rstrip('\\/')
    clean_img = img_path.strip().lstrip('\\/')
    if re.match(r'^[a-zA-Z]:[\\/]', clean_img) or clean_img.startswith('/'):
        return clean_img
    if '\\' in clean_base or os.name == 'nt':
        return f"{clean_base}\\{clean_img.replace('/', '\\')}"
    return f"{clean_base}/{clean_img.replace('\\', '/')}"

class XPosterApp:
    def __init__(self, root):
        self.root = root
        self.root.title("X スレッド投稿ローカルアシスタント")
        self.root.geometry("1080x720")

        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)

        # 左側: 原稿入力
        left = ttk.LabelFrame(paned, text="【記事案マークダウン】", padding=6)
        paned.add(left, weight=1)

        ttk.Label(left, text="画像Baseフォルダ (例: C:\\Users\\Public\\Pictures):").pack(anchor=tk.W)
        self.base_entry = ttk.Entry(left)
        self.base_entry.insert(0, r"C:\Users\Public\Pictures")
        self.base_entry.pack(fill=tk.X, pady=(0, 6))

        # ボタンバー (クリアボタン)
        btn_bar = ttk.Frame(left)
        btn_bar.pack(fill=tk.X, pady=(0, 4))
        ttk.Button(btn_bar, text="🗑️ テキストをクリア", command=self.clear_text).pack(side=tk.LEFT)
        ttk.Button(btn_bar, text="🔄 解析・更新", command=self.update_posts).pack(side=tk.RIGHT)

        self.text_editor = scrolledtext.ScrolledText(left, wrap=tk.WORD, font=("Consolas", 10))
        self.text_editor.pack(fill=tk.BOTH, expand=True)
        self.text_editor.bind("", lambda e: self.update_posts())

        # 右側: 操作パネル
        right = ttk.LabelFrame(paned, text="【操作パネル (Web Intent & コピー)】", padding=6)
        paned.add(right, weight=2)

        self.status_var = tk.StringVar(value="準備完了: 左側に記事案を貼り付けてください")
        ttk.Label(right, textvariable=self.status_var, font=("Meiryo", 9, "bold"), foreground="#0284c7").pack(fill=tk.X, pady=(0, 6))

        canvas_frame = ttk.Frame(right)
        canvas_frame.pack(fill=tk.BOTH, expand=True)
        self.canvas = tk.Canvas(canvas_frame, borderwidth=0, highlightthickness=0)
        self.scrollbar = ttk.Scrollbar(canvas_frame, orient="vertical", command=self.canvas.yview)
        self.cards_inner = ttk.Frame(self.canvas)
        self.cards_inner.bind("", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
        self.win = self.canvas.create_window((0, 0), window=self.cards_inner, anchor="nw")
        self.canvas.configure(yscrollcommand=self.scrollbar.set)
        self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
        self.canvas.bind('', lambda e: self.canvas.itemconfig(self.win, width=e.width))

        self.update_posts()

    def clear_text(self):
        self.text_editor.delete("1.0", tk.END)
        self.update_posts()
        self.text_editor.focus()

    def update_posts(self):
        for c in self.cards_inner.winfo_children():
            c.destroy()
        raw = self.text_editor.get("1.0", tk.END)
        base = self.base_entry.get().strip()
        posts = parse_draft(raw)

        if not posts:
            self.status_var.set("記事案が入力されていません。左側に貼り付けてください。")
            empty_lbl = ttk.Label(self.cards_inner, text="左側の入力欄に記事案を貼り付けると、自動で各ポストのボタンが生成されます。", foreground="#64748b", padding=20)
            empty_lbl.pack()
            return

        self.status_var.set(f"検出: {len(posts)} 件のポスト")

        for p in posts:
            card = ttk.LabelFrame(
                self.cards_inner,
                text=f"{'🚀 [親ポスト]' if p['is_parent'] else f'🔹 [Post {p['num']}]'} {p['title']}",
                padding=6
            )
            card.pack(fill=tk.X, pady=5, padx=2)

            txt = tk.Text(card, height=3, wrap=tk.WORD, font=("Meiryo", 9), bg="#f8f9fa", relief=tk.SOLID, bd=1)
            txt.insert(tk.END, p['content'])
            txt.configure(state="disabled")
            txt.pack(fill=tk.X, pady=(0, 4))

            btns = ttk.Frame(card)
            btns.pack(fill=tk.X)

            if p['is_parent']:
                tk.Button(
                    btns,
                    text="① 記事1をXで開く (Web Intent)",
                    bg="#0284c7",
                    fg="white",
                    font=("Meiryo", 9, "bold"),
                    command=lambda c=p['content']: self.open_in_x(c)
                ).pack(side=tk.LEFT, padx=(0, 4))

            tk.Button(
                btns,
                text=f"📋 {'本文コピー' if p['is_parent'] else f'記事{p['num']}コピー'}",
                bg="#4f46e5",
                fg="white",
                font=("Meiryo", 9),
                command=lambda c=p['content'], n=p['num']: self.copy_clip(c, f"Post {n} 本文")
            ).pack(side=tk.LEFT, padx=(0, 4))

            if p['images']:
                for i, img in enumerate(p['images']):
                    fp = resolve_path(base, img)
                    label = f"画像{i+1}コピー" if len(p['images']) > 1 else "画像パスコピー"
                    tk.Button(
                        btns,
                        text=f"🖼️ {label}",
                        bg="#d97706",
                        fg="white",
                        font=("Meiryo", 9),
                        command=lambda path=fp: self.copy_clip(path, f"画像 ({os.path.basename(path)})")
                    ).pack(side=tk.LEFT, padx=(0, 4))

    def open_in_x(self, text):
        webbrowser.open(f"https://x.com/intent/post?text={urllib.parse.quote(text)}")
        self.status_var.set("✅ Xの新規投稿画面をブラウザで開きました!")

    def copy_clip(self, text, label):
        self.root.clipboard_clear()
        self.root.clipboard_append(text)
        self.root.update()
        self.status_var.set(f"✅ コピー完了: 【{label}】")

if __name__ == "__main__":
    root = tk.Tk()
    app = XPosterApp(root)
    root.mainloop()

5. 実際に運用してみて分かった効果とまとめ

このツールを実務に導入して以降、スレッド投稿にかかる時間が大幅に削減され、何より「投稿前の億劫さ」が解消された。

  • コピペミス・貼り付け順の混同がゼロに: 上から順にボタンを押していくだけで進むため、誤爆や順番ミスが起きない。
  • 完全無料&規約準拠: 有料APIを使わず、Webスクレイピング等の規約抵触リスクもないため、安心して使い続けられる。
  • 現場の課題をAIで即座にツール化: 「ここが面倒」と感じた運用上の摩擦を、Google AI Studioと相談しながら1〜2時間で自分専用ツールに仕立て上げる体験は、エンジニアリングとして非常に実用的だ。

日々のSNS運用でスレッド作成の煩わしさを感じている方は、ぜひこのスクリプトをローカルで試してみてほしい。

タイトルとURLをコピーしました