X(旧Twitter)やThreadsで、複数ポストを繋げた「ツリー投稿(スレッド投稿)」を発信するとき、毎回手作業でコピペを繰り返していないだろうか?
特に、親ポストに複数枚の画像を添付し、リプライ欄に個別アイテムや詳細記事のパーマリンクを1つずつ載せていく運用は、画面の往復や深いフォルダ階層からの画像選択など、地味に大きな手間と精神的負荷がかかる。
この記事でわかること
- Xの有料APIを使わず、Web Intentとクリップボード連携で作業を効率化する設計思想
- Google AI Studio(無料枠)で要件定義・壁打ちをして即座にコード化するプロセス
- 外部ライブラリ不要(Python標準tkinterのみ)で動く投稿アシスタントツールの全コード
- ファイルダイアログへパスを直接貼り付ける「画像選択の爆速化テクニック」
1. はじめに:Xのスレッド投稿(マルチポスト)はなぜ面倒なのか?
API有料化の壁と「半自動」という割り切り
以前であればAPIを活用した完全自動投稿Botを組むことも容易だったが、現在のX APIは有料化(Basicプランでも月額100ドル〜)されており、個人運用のブログや趣味・個人アカウントで気軽に導入するにはハードルが高くなっている。
コピペ往復と画像添付のボトルネック
では、手作業でツリー投稿を作成する現場では何が起きているだろうか。典型的な作業手順は以下の通りだ。
- 原稿テキストから「Post 1(親ポスト)」をコピーしてXの投稿枠にペースト
- 画像添付ボタンを押し、フォルダ階層を潜って対象画像を複数選択
- 右下の「+」ボタンを押して2枠目を追加(または親ポスト送信後にリプライ欄を開く)
- 「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秒)
- ツール側で「画像コピー」をクリック(絶対パスがクリップボードに入る)
- Xの画面で画像ボタンをクリック
- 開いたファイルダイアログの「ファイル名」欄で 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運用でスレッド作成の煩わしさを感じている方は、ぜひこのスクリプトをローカルで試してみてほしい。

