どうも、Xu です。
振り返ってみると、最初毎月投稿を維持するとか言っておいて全然できてなかったり、謎にプチバズしたり、Qiita で色々あったなと思いながら今回で今年最後の投稿になるかと思います。
ふと思いつたんですが、もしも人間が一人もいなくて、AI 同士が勝手にトレンドについて語り合っているだけの X に近いもの作ったらシュールすぎておもろいのでは?(くだらなさそう)
しばらく放置しておいて後からどんなカオスな世界ができているのか楽しみです(笑)。
完成品
かなり適当です、大した UI デザインもなく、アイコンも何も設定してないです。
見ての通り、ログイン画面もなく、投稿もできないです。
じゃあ人間は何をすればいいのか??
ないもしないんだよ、これが☻
設計図
そんなこんなでとりあえず図に落としてみると大体こんな感じのシンプルなものが構想できるかと。
解説
動きとしては、定期的に最新のトレンドを取ってきて、そのデータをもって AI Agent に送信し、ロールプレイをしてもらい、人間のふりをしたユーザー名とポスト文を返してもらう。
できるだけ 1 円もホスティングに払いたくないので、フロントエンドは Vercel の無料枠で、バックエンドは Salesforce の Apex クラスをスケジューラから呼び出して、API で通信をする感じにしました。
この設計図では Salesforce でいくつかのペルソナ(カスタムオブジェクト)を予め用意しておいて、都度ランダムに 1 つ引き出してロールプレイの基準にしようかと思いましたが、めんどくさくなったので全部 AI に考えてもらいましょう。
トレンドデータのソースは最初は NewsAPI を考えてましたが、日本語ニュースが有料になったらしく、NewsData.io を使うことにしました。
Salesforce からは上記流れを定期的に実行するほか、たまにすでに入っているデータを削除してきれいにするスケジュールを作ろうと思います。
Vercel 側
構成
Project
|-app
| |-page.tsx
| |-api
| |posts
| |-route.ts
|-components
| |-ChildPost.tsx
| |-ParentPost.tsx
|-types
| |-post.ts
|-utils
| |-formatTime.ts
|-public
| |-avatar.png
セッション切れてもポストが消えないようにしたいので、無料で使える Vercel Blob を使用することにしました。
Vercel Blob について
ポストに含める情報は、ポストの Id、ユーザー名、アイコン(ほぼいらないけど)、コンテスト内容、リプの場合は親ポスト Id、作成日付時間が必要だと想定してます。
使いまわせるようにまずは type を用意しておきます。
export interface Post {
id: string;
user: string;
avatar: string;
content: string;
parentId: string | null,
createdAt: number,
children?: Post[]; // Optional field for nested posts
}
親ポスト(普通にツイートした場合)の表示と子ポスト(リプライした場合)の表示が異なるので、別々にコンポーネントを作って POST Request した際にどっちらにあたるかを判断するようにしました。
import { formatTime } from "@/utils/formatTime";
import Image from "next/image";
interface ParentProps {
user: string;
avatar: string;
content: string;
createdAt: number;
children?: React.ReactNode;
}
export default function ParentPost({ user, avatar, content, createdAt, children }: ParentProps) {
return (
<div className="border-b border-neutral-800 px-4 py-3 flex gap-3 hover:bg-neutral-900 transition-colors">
<Image src={avatar} className="w-12 h-12 rounded-full" alt="Icon" width={40} height={40} />
<div className="flex-1">
<p className="font-semibold text-[15px]">{user}</p>
<div className="text-gray-500 text-sm">{formatTime(createdAt)}</div>
<p className="mt-1 whitespace-pre-line text-[15px]">{content}</p>
<div className="mt-3 space-y-3">
{children}
</div>
</div>
</div>
);
}
import { formatTime } from "@/utils/formatTime";
import Image from "next/image";
interface ChildProps {
user: string;
avatar: string;
content: string;
createdAt: number;
}
export default function ChildPost({ user, avatar, content, createdAt }: ChildProps) {
return (
<div className="flex gap-3 px-1">
<Image src={avatar} className="w-8 h-8 rounded-full" alt="Icon" width={32} height={32} />
<div>
<p className="font-semibold text-sm">{user}</p>
<div className="text-gray-500 text-xs">{formatTime(createdAt)}</div>
<p className="text-sm whitespace-pre-line mt-1">{content}</p>
</div>
</div>
);
}
createdAt は UTC (協定世界時) で 1970 年 1 月 1 日の夜半と定義されている元期からの経過時間を、ミリ秒単位で返してますので、そのままでは意味不明な数字になってしまいます。
なので一回変換を通します。
export function formatTime(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleString("ja-JP", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
API は主に GET POST DELETE さえできれば十分。
返すものは実行ステータスとポストの情報を JSON 形式で。
import { NextResponse } from "next/server";
import { put, list } from "@vercel/blob";
import { Post } from "@/types/post";
type PostWithChildren = Post & { children: Post[] };
const FILE_NAME = "posts.json";
async function loadPosts(): Promise<Post[]> {
const blobs = await list();
const file = blobs.blobs.find(b => b.pathname === FILE_NAME);
if (!file) return [];
const res = await fetch(file.url);
const text = await res.text();
if (!text.trim()) return [];
try {
return JSON.parse(text) as Post[];
} catch (e) {
console.error("JSON parse error:", e);
return [];
}
}
async function savePosts(posts: Post[]): Promise<void> {
await put(FILE_NAME, JSON.stringify(posts, null, 2), {
access: "public",
contentType: "application/json",
allowOverwrite: true,
});
}
/** GET: Get Posts */
export async function GET() {
const flat = await loadPosts();
flat.sort((a, b) => b.createdAt - a.createdAt);
const parents: PostWithChildren[] = [];
const childrenMap: Record<string, Post[]> = {};
flat.forEach(p => {
if (!p.parentId) {
parents.push({ ...p, children: [] });
} else {
if (!childrenMap[p.parentId]) childrenMap[p.parentId] = [];
childrenMap[p.parentId].push(p);
}
});
parents.forEach(parent => {
parent.children = childrenMap[parent.id] ?? [];
});
return NextResponse.json(parents);
}
/** POST: Add Posts */
export async function POST(req: Request) {
try {
const body = await req.json();
const posts = await loadPosts();
if (!body.user || !body.content) {
return NextResponse.json(
{ success: false, message: "Values: user content are required!" },
{ status: 400 }
);
}
const newPost: Post = {
id: crypto.randomUUID(),
user: body.user,
avatar: "/avatar.png",
content: body.content,
parentId: body.parentId ?? null,
createdAt: Date.now(),
};
posts.push(newPost);
await savePosts(posts);
return NextResponse.json({ success: true, post: newPost });
} catch (e) {
console.error("POST error:", e);
return NextResponse.json(
{ success: false, message: "Failed to post" },
{ status: 500 }
);
}
}
/** DELETE: Delete All Posts */
export async function DELETE() {
try {
await savePosts([]);
return NextResponse.json({
success: true,
message: "All posts have been deleted",
});
} catch (e) {
console.error("DELETE error:", e);
return NextResponse.json(
{ success: false, message: "Failed to delete posts!" },
{ status: 500 }
);
}
}
UI は X をイメージして、黒ベースでポストが縦に挿入さるようなものを作りました。
X って結構カオスなイメージがありますからね(笑)。
"use client";
import { useEffect, useState } from "react";
import ParentPost from "@/components/ParentPost";
import ChildPost from "@/components/ChildPost";
import { Post } from "@/types/post";
async function fetchPosts(): Promise<Post[]> {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || process.env.VERCEL_URL && `https://${process.env.VERCEL_URL}`;
// const basUrl = "http://localhost:3000"; // Local Test
const res = await fetch(`${baseUrl}/api/posts`, {
cache: "no-store",
});
return res.json();
}
export default function Home() {
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
let alive = true;
const load = async () => {
try {
const data = await fetchPosts();
if (alive) setPosts(data);
} catch (e) {
console.error(e);
}
};
load(); // First load
const timer = setInterval(load, 30 * 60 * 1000); // 30mins
return () => {
alive = false;
clearInterval(timer);
};
}, []);
return (
<main className="max-w-xl mx-auto border-x border-neutral-800 min-h-screen">
{posts.map((parent) => (
<ParentPost
key={parent.id}
user={parent.user}
avatar={parent.avatar}
content={parent.content}
createdAt={parent.createdAt}
>
{(parent.children ?? []).map((child) => (
<ChildPost
key={child.id}
user={child.user}
avatar={child.avatar}
content={child.content}
createdAt={child.createdAt}
/>
))}
</ParentPost>
))}
</main>
);
}
Vercel で API を動かす際は process.env.NEXT_PUBLIC_BASE_URL または process.env.VERCEL_URL を使います。
最初何も入ってないと Blob がエラーを起こしてしまうので、一旦存在チェックを入れる。
const blobs = await list();
const file = blobs.blobs.find(b => b.pathname === FILE_NAME);
if (!file) return [];
Salesforce 側
Apex スケジューラからはコールアウトと
設定値
public class AiConfig {
public static final String NEWS_API_KEY = 'YOUR_NEWS_API_KEY';
public static final String NEWS_URL =
'https://newsdata.io/api/1/latest?country=jp&size=1&removeduplicate=1&apikey='
+ NEWS_API_KEY;
public static final String AGENT_URL = 'Your_Agent_API_Endpoint';
public static final String SNS_URL = 'Your_SNS_API_Endpoint';
public static final String MODEL = 'YOUR_AI_MODEL';
}
ニュース取得
public class NewsService {
public class NewsResult {
public String title;
public String description;
}
public static NewsResult getNews() {
Http http = new Http();
HttpRequest req = new HttpRequest();
req.setEndpoint(AiConfig.NEWS_URL);
req.setMethod('GET');
HttpResponse res = http.send(req);
Map<String, Object> body =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
List<Object> results = (List<Object>) body.get('results');
if (results.isEmpty()) return null;
Map<String, Object> item =
(Map<String, Object>) results[0];
NewsResult news = new NewsResult();
news.title = (String) item.get('title');
news.description = (String) item.get('description');
return news;
}
}
AI ポスト生成
public class AiPostService {
public static Map<String, Object> generatePost(
String title,
String description,
String parentPost,
Boolean isParent,
List<String> otherReplyList
) {
String commonPrompt =
'出力形式:\n' +
'・user, content のJSONのみ\n' +
'・日本語・140文字以内\n' +
'・ユーザー名はランダムな日本人SNS名\nなお、一般的な SNS にいそうな日本人ユーザーの名前にしてください。例:Satomi.F、吉本ありさ@22日公演、TatsuyaMorimoto、【速報】お前らが好きなやつ!、今日も仕事しなかった人、Officialモテない男子、猫の写真まとめBotなど。
\n' +
'・ユーザーのペルソナは毎回ランダムに考えてください。例:何事にも反論したがる人、ポジティブ思考な人、明るい学生、皮肉屋なエリートサラリーマン、すぐ自慢する人、口が悪い人、何かと因縁をつけて人を攻撃する人、かっこよく見せたい人など。\n' +
'・ユーモアな表現、同情的を感じる表現、ポジティブナ表現、期待、煽動的な表現、攻撃的な表現、差別的な表現、皮肉な表現などいずれかを交えて、感情的な反応を引き出すようにしてください。\n';
String userPrompt;
if (isParent) {
userPrompt =
'ニュースタイトル:' + title + '\n' +
'ニュース内容:' + description + '\n' +
commonPrompt;
} else {
userPrompt =
'ニュースタイトル:' + title + '\n' +
'ニュース内容:' + description + '\n' +
'元投稿:' + parentPost + '\n' +
'既存返信:' + JSON.serialize(otherReplyList) + '\n' +
commonPrompt;
}
Map<String, Object> payload = new Map<String, Object>{
'model' => AiConfig.MODEL,
'messages' => new List<Object>{
new Map<String, Object>{
'role' => 'user',
'content' => userPrompt
}
}
};
HttpRequest req = new HttpRequest();
req.setEndpoint(AiConfig.AGENT_URL);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(payload));
HttpResponse res = new Http().send(req);
Map<String, Object> result =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
Map<String, Object> message =
(Map<String, Object>) ((Map<String, Object>) result.get('message'));
String content = ((String) message.get('content'))
.replace('```', '')
.replace('json', '')
.replace('\n', '');
if (!isParent) otherReplyList.add(content);
return (Map<String, Object>) JSON.deserializeUntyped(content);
}
}
投稿を実行
public class SnsService {
public static String post(Map<String, Object> snsJson, String parentId) {
if (parentId != null) {
snsJson.put('parentId', parentId);
}
HttpRequest req = new HttpRequest();
req.setEndpoint(AiConfig.SNS_URL);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(snsJson));
HttpResponse res = new Http().send(req);
Map<String, Object> body =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
return (String) body.get('postId');
}
}
メイン処理
public class AiSnsJob implements Queueable, Database.AllowsCallouts {
public void execute(QueueableContext context) {
List<String> otherReplies = new List<String>();
NewsService.NewsResult news = NewsService.getNews();
if (news == null) return;
Map<String, Object> parent =
AiPostService.generatePost(
news.title, news.description, null, true, otherReplies
);
String parentContent = (String) parent.get('content');
String parentId = SnsService.post(parent, null);
Integer childCount = Math.mod(Crypto.getRandomInteger(), 6);
for (Integer i = 0; i < childCount; i++) {
Map<String, Object> child =
AiPostService.generatePost(
news.title, news.description,
parentContent, false, otherReplies
);
SnsService.post(child, parentId);
}
}
}
より実際のツイートっぽくするために、ランダムでリプが付いたりつかなかったりするために
Integer childCount = Math.mod(Crypto.getRandomInteger(), 6);
for (Integer i = 0; i < childCount; i++) {
スケジュールされるクラス
public class AiSnsScheduler implements Schedulable {
public void execute(SchedulableContext sc) {
System.enqueueJob(new AiSnsJob());
}
}
スケジューラ登録
0 0,30 * * * ?
あとがき
色々とモデルを試してみましたが、ユーザー名がおかしかったり、同じものをひたすら繰り返したり、日本語がおかしかったりと、結局 ChatGPT が一番総合的にまともでした。

削除については一旦本番で無料枠でどこまで使えるか見てからスケジュール組もうと思います。
そんな感じで今年はこの記事で締めようと思います。
大分早いですが、よいお年を!

