OpenAIで以下を設定しました
TiboがXで投稿している通り、Discourseは現在、ツイートを追跡する投稿を自動的に更新します。
これはどのように機能しますか?
キャッシュとしてのデータテーブル
Discourse Workflowsにはデータテーブル機能が組み込まれています。
これにより、ワークフローの実行間で構造化された情報を保存できます。
Xとの統合において、APIコストの超過を避けることが重要なポイントです。そのために、スクレイピングしたすべての情報のキャッシュを保存し、古いツイートを再クエリすることを避けています。
データテーブルはこれに最適です。
設定ノード
「フィールドの設定」は、設定ノードに最適です。この場合、ワークフロー全体で使用される「ワークフローごとの」設定(投稿先のトピックID、キャンペーン開始時刻、ユーザー名など)が必要でした。
これにより、ワークフローの論理をより把握しやすくなります。
XへのHTTPリクエスト
このワークフローは、Xのエンドポイント2つに依存しています:
TiboのX IDを参照するための非常にシンプルなものです。
タイムラインを参照するための2つ目のものです:
const data = $json;
const checkpoint = $("Read checkpoint").first().json;
const userId = data.user_id || data.data?.id;
if (!userId || !/^[A-Za-z0-9-]+$/.test(userId)) {
throw new Error("Unexpected X user lookup response");
}
const configuration = $("Configuration").first().json;
const apiBase = configuration.api_base_url.replace(/\/$/, "");
const demo = /^http:\/\/(?:localhost|127\.0\.0\.1):[1-9]\d*$/.test(apiBase);
const token = data.next_token || "";
const base = apiBase + "/2/users/" + userId + "/tweets";
const params = "max_results=" + (demo ? "5" : "100") + "&post.fields=id,text,created_at,entities,attachments,note_post&expansions=referenced_posts";
const boundary = checkpoint.cursor ? "since_id=" + encodeURIComponent(checkpoint.cursor) : "start_time=" + encodeURIComponent(configuration.campaign_start_time);
const url = base + "?" + params + "&" + boundary + (token ? "&pagination_token=" + encodeURIComponent(token) : "");
return { user_id: userId, url: url, tweets: data.tweets || [], candidate_cursor: data.candidate_cursor || checkpoint.cursor,
page_count: data.page_count || 0, tokens: data.tokens || [] };
これにより、いくつかの実装詳細が明らかになります
ワークフローはエージェントを使用して構築し、コスト削減のためにプロセス中に「偽のX API」を実行しました(そのため、そこにlocalhostがあります)。
フローの「Ifブロック」を使用することで、ページがまだある間、ユーザーのタイムラインを反復的に処理し続けることができます。X APIの素晴らしい実装詳細は、実際に取得した投稿に対してのみ課金されるため、結果がゼロのクエリはコストがかからないことです。
ツイートのパースは、この小さなスクリプトノードを使用して行われます:
const previous = $("Build page URL").item.json;
const response = $json;
if (!response || !response.meta || !Array.isArray(response.data || [])) {
throw new Error("Incomplete X timeline response; refuse to update the post");
}
if (response.errors?.length) { throw new Error("X returned partial data/errors; refuse to update the post"); }
const next = response.meta.next_token || "";
if (next && (previous.tokens.includes(next) || previous.page_count >= 39)) {
throw new Error("X pagination repeated or exceeded the 40-page safety limit");
}
const greater = (a, b) => a.length > b.length || (a.length === b.length && a > b);
let candidate = previous.candidate_cursor;
for (const tweet of response.data || []) {
if (typeof tweet.id !== "string" || !/^\d+$/.test(tweet.id)) { throw new Error("X post ID must be a decimal string"); }
if (!candidate || greater(tweet.id, candidate)) { candidate = tweet.id; }
}
const normalize = post => {
const note = post.note_post || post.note_tweet;
return { ...post, text: note?.text || post.text, entities: note?.entities || post.entities,
referenced_tweets: post.referenced_posts || post.referenced_tweets || [] };
};
const expanded = Object.fromEntries((response.includes?.posts || response.includes?.tweets || [])
.map(post => [post.id, normalize(post)]));
const page = (response.data || []).map(normalize).map(post => ({ ...post,
referenced_detail_urls: post.referenced_tweets.flatMap(ref => {
const quote = expanded[ref.id];
return quote ? [quote.text || "", ...(quote.entities?.urls || []).map(url => url.unwound_url || url.expanded_url || url.url)] : [];
}) }));
const tweets = previous.tweets.concat(page);
return { user_id: previous.user_id, tweets: tweets, candidate_cursor: candidate, next_token: next,
has_more: !!next, has_tweets: tweets.length > 0, page_count: previous.page_count + 1,
tokens: next ? previous.tokens.concat(next) : previous.tokens };
インテリジェンスの追加
コンテンツの同期を成功させるための重要な要素はインテリジェンスです:
ツイートの一覧を入力として渡し、適切に抽出されたJSONドキュメントを取得します。この種の処理にはインテリジェンスが必要です。
これはコンテンツをフォーマットするだけでなく、最も関連性の高い情報を見つけ出します。
Discourse投稿の更新
スクリプトノードを使用して、テーブルをレンダリングします:
const extracted = $("Poll result").first().json;
const checkpoint = $("Read checkpoint").first().json;
const apiBase = $("Configuration").first().json.api_base_url.replace(/\/$/, "");
const demo = /^http:\/\/(?:localhost|127\.0\.0\.1):[1-9]\d*$/.test(apiBase);
const raw = String($json.post?.raw || "");
const start = "<!-- x-ships-table:start -->";
const end = "<!-- x-ships-table:end -->";
const hasStart = raw.includes(start);
const hasEnd = raw.includes(end);
if (hasStart !== hasEnd ||
(hasStart && (raw.indexOf(end) < raw.indexOf(start) ||
raw.indexOf(start, raw.indexOf(start) + start.length) !== -1 ||
raw.indexOf(end, raw.indexOf(end) + end.length) !== -1))) {
throw new Error("Ambiguous workflow-owned table markers in designated post");
}
const greater = (a, b) => a.length > b.length || (a.length === b.length && a > b);
if (checkpoint.cursor && extracted.candidate_cursor && greater(checkpoint.cursor, extracted.candidate_cursor)) {
throw new Error("Refusing to move the timeline cursor backwards");
}
const bySlot = {};
for (const ship of checkpoint.ships.concat(extracted.ships)) {
if (!Number.isInteger(ship.day) || ship.day < 1 || ship.day > 28 ||
!new RegExp("^" + ship.day + "(?:\\.[1-9][0-9]?)?$").test(ship.slot) ||
typeof ship.source_id !== "string" || !/^\d+$/.test(ship.source_id) ||
typeof ship.announcement !== "string" || !ship.announcement || ship.announcement.length > 400 ||
(ship.details_url && !/^https?:\/\/[^\s<>()[\]|\\]+$/.test(ship.details_url))) {
throw new Error("Invalid stored ship slot");
}
if (!bySlot[ship.slot] || greater(ship.source_id, bySlot[ship.slot].source_id)) { bySlot[ship.slot] = ship; }
}
const escapeCell = value => String(value).replace(/[|\r\n]/g, " ").replace(/\\/g, "\\\\").replace(/([\[\]_*`])/g, "\\$1");
const rows = [];
for (let day = 1; day <= 28; day++) {
const ships = Object.values(bySlot).filter(ship => ship.day === day)
.sort((a, b) => a.slot.localeCompare(b.slot, undefined, { numeric: true }));
if (!ships.length) { rows.push("| " + day + " | Pending | — | — | — |"); }
else for (const ship of ships) {
const provenance = demo
? "[Demo post](" + apiBase + "/demo/posts/" + encodeURIComponent(ship.source_id) + ") (synthetic)"
: "[X post](https://x.com/" + encodeURIComponent($("Configuration").first().json.username) + "/status/" + encodeURIComponent(ship.source_id) + ")";
const details = ship.details_url ? "[Details](" + ship.details_url + ")" : "—";
rows.push("| " + day + " | " + escapeCell(ship.slot) + " | " + escapeCell(ship.announcement) + " | " + details + " | " + provenance + " |");
}
}
const ships = Object.values(bySlot).sort((a, b) => a.day - b.day || a.slot.localeCompare(b.slot, undefined, { numeric: true }));
const updated = start + "\n| Day | Ship | Announcement | Details | Source |\n| --- | --- | --- | --- | --- |\n" + rows.join("\n") + "\n" + end;
const replacement = hasStart
? raw.slice(0, raw.indexOf(start)) + updated + raw.slice(raw.indexOf(end) + end.length)
: raw + (raw ? (raw.endsWith("\n") ? "\n" : "\n\n") : "") + updated;
return { changed: replacement !== raw, raw: replacement, ships_json: JSON.stringify(ships),
since_id: extracted.candidate_cursor, user_id: extracted.user_id };
小さなトリックは、Discourseの投稿に特別なマーカーがあることです:
これにより、テーブルを生成した後、テーブルを更新する前に何か変更があったかを確認できます。
テーブルが変更された場合、追跡用のデータテーブルを更新します:
私はこれをどのように構築しましたか?
ワークフローは非常に強力な機能ですが、これほど大きなものを手作業で構築するには長い時間がかかります。
代わりに、エージェントに頼りました。開発にはすべてterm-llm.comを使用しており、dvに非常にクリーンに統合されています。もちろん、お好みのエージェントを使用できますが、ここで重要な機能は、動作するDiscourse環境を持っていることです。
私の手順は以下の通りです:
-
dv new workflow-build- 新しいクリーンな環境を作成します。- 特別なdvフックを使用しており、これによりterm-llm serve Webサービスが起動し、私のハブに接続します。これにより、使い捨ての環境で自由に実行できる即席の「リッチエージェント」が手に入ります。
-
プロンプトを入力しました:
構築にはGPT 6.1 Solを使用し、1回の試行で既に何か形になるものがありました:
- 洗練させました
-
最初の試みではデータテーブルを使用していませんでした - ストレージをそこに移動するようにプロンプトしました
-
次に、管理を容易にするために、すべての設定を中央ノードに移動するように反復しました
-
ワークフローをエクスポートし、本番環境にインポートしました
-
本番環境ではいくつかのエッジケースが欠落していたため、エージェントと少し反復しました(エクスポートされたXMLにはデータテーブル定義やエージェント定義が含まれていないため)
このようなものを構築する必要がある場合
この投稿はエージェントにとって非常に役立つでしょう。以下の様なシンプルなプロンプト:
"X"に関する重要な議論を、アカウントA、B、Cから私のトピックに同期してください。LINK TO METAの同様の方法に従ってください
これにより、Xからの任意のデータ同期を行うことができます。
ソーシャル → Discourseはコミュニティにとって非常に重要なベクトルであり、ワークフローを使用することで、既存の社員にDiscourseでのコンテンツ投稿をすべて集中させることを強制することなく、より詳細な議論に開かれた長期的な記録を維持できます。
ワークフローは、この種の同期に使用できる基盤となるブロックであり、ここでの選択肢は非常に広いです。
- 特定の人物がソーシャルにコンテンツを投稿した際に、シャドウトピックを投稿する
- ソーシャル上の活動に基づいて、長期的なトピックを最新の状態に保つ
- … など、さらに多くのことが可能です
















