저는 OpenAI에서 다음을 설정했습니다.
Tibo가 X에 게시한 대로, Discourse는 이제 트윗을 추적하는 게시물을 자동으로 업데이트합니다.
이 기능의 작동 방식
캐시로 사용되는 데이터 테이블
Discourse Workflows에는 데이터 테이블 기능이 포함되어 있습니다.
이를 통해 워크플로 실행 사이에 구조화된 정보를 저장할 수 있습니다.
X와 통합할 때 API 비용 초과를 피하는 것이 중요한 부분입니다. 이를 위해 스크래핑한 모든 정보를 캐시로 저장하여 오래된 트윗을 다시 조회하는 것을 방지합니다.
데이터 테이블은 이 용도에 완벽합니다.
설정 노드
"필드 설정(Set fields)"은 설정 노드에 완벽합니다. 이 경우 워크플로 전체에서 사용되는 “워크플로별” 설정(게시할 토픽 ID, 캠페인 시작 시간, 사용자 이름 등)이 필요했습니다.
이는 워크플로를 더 쉽게 이해하고 추론하는 데 도움이 됩니다.
X에 대한 HTTP 요청
이 워크플로는 두 개의 X 엔드포인트에 의존합니다:
Tibo의 X ID를 조회하기 위한 매우 간단한 엔드포인트입니다.
타임라인을 조회하기 위한 두 번째 엔드포인트:
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의 좋은 구현 세부 사항은 실제 게시물을 가져오는 것만 비용이 발생하며, 결과가 0개인 조회는 비용이 들지 않는다는 것입니다.
트윗 파싱은 다음 작은 스크립트 노드를 사용하여 수행됩니다:
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 };
지능(Intelligence) 추가
콘텐츠를 성공적으로 동기화하는 핵심 요소는 지능입니다:
트윗 목록을 입력으로 전달하면 → 적절하게 추출된 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- 새로운 깨끗한 환경을 생성하기 위해.- 저는 제 허브로 다시 연결되는 term-llm serve 웹 서비스를 실행하는 특별한 dv 훅을 사용합니다. 이를 통해 폐기할 수 있는 환경에서 자유롭게 실행할 수 있는 즉각적인 "리치 에이전트"를 얻을 수 있습니다.
-
저는 다음과 같이 프롬프트를 입력했습니다:
구축에는 GPT 6.1 Sol을 사용했으며, 한 번의 시도만으로 이미 무언가가 작동하기 시작했습니다:
- 저는 이를 정교하게 다듬었습니다
-
첫 번째 시도는 데이터 테이블을 사용하지 않았습니다 - 저장을 그곳으로 이동하도록 프롬프트를 입력했습니다.
-
다음으로, 관리가 더 쉬워지도록 모든 설정을 중앙 노드로 이동하도록 반복했습니다.
-
워크플로를 내보낸 다음 프로덕션에 가져왔습니다.
-
프로덕션에는 몇 가지 누락된 엣지 케이스가 있었기 때문에 에이전트와 조금 더 반복했습니다(우리의 내보내기 XML에는 데이터 테이블 정의와 에이전트 정의가 포함되어 있지 않습니다).
이와 유사한 무언가를 구축해야 한다면?
이 게시물은 에이전트에게 매우 도움이 될 것입니다. 다음과 같은 간단한 프롬프트:
"X"에 대한 중요한 논의를 계정 A, B, C에서 제 토픽으로 동기화하세요. 메타 링크에 있는 유사한 방법을 따르세요.
이를 통해 X에서 임의의 데이터 동기화를 수행할 수 있습니다.
소셜 → Discourse는 커뮤니티를 위한 매우 중요한 벡터이며, 워크플로는 회사 내 기존 사람들에게 Discourse에서 모든 콘텐츠 게시를 중앙화하도록 강제하지 않으면서 더 정교한 논의에 열려 있는 장기간의 기록을 유지할 수 있게 합니다.
워크플로는 이러한 종류의 동기화에 사용할 수 있는 기본 요소이며, 여기에는 매우 다양한 옵션이 있습니다.
- 특정 사람이 소셜에 콘텐츠를 게시할 때 그늘 토픽을 게시합니다.
- 소셜 활동에 기반하여 장기간의 토픽을 최신 상태로 유지합니다.
- … 그리고 훨씬 더 많은 것들
















