X 피드 기반으로 Discourse 게시물을 자동으로 업데이트하는 방법

저는 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 || [] };

이 코드에는 일부 구현 세부 사항이 드러나기 시작합니다 :slight_smile: 저는 에이전트를 사용하여 워크플로를 구축했고, 비용 절약을 위해 프로세스 중에 "가짜 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 환경을 갖는 것이 핵심 기능입니다.

제 단계는 다음과 같습니다:

  1. dv new workflow-build - 새로운 깨끗한 환경을 생성하기 위해.

    1. 저는 제 허브로 다시 연결되는 term-llm serve 웹 서비스를 실행하는 특별한 dv 훅을 사용합니다. 이를 통해 폐기할 수 있는 환경에서 자유롭게 실행할 수 있는 즉각적인 "리치 에이전트"를 얻을 수 있습니다.
  2. 저는 다음과 같이 프롬프트를 입력했습니다:


구축에는 GPT 6.1 Sol을 사용했으며, 한 번의 시도만으로 이미 무언가가 작동하기 시작했습니다:

  1. 저는 이를 정교하게 다듬었습니다
  • 첫 번째 시도는 데이터 테이블을 사용하지 않았습니다 - 저장을 그곳으로 이동하도록 프롬프트를 입력했습니다.

  • 다음으로, 관리가 더 쉬워지도록 모든 설정을 중앙 노드로 이동하도록 반복했습니다.

  1. 워크플로를 내보낸 다음 프로덕션에 가져왔습니다.

  2. 프로덕션에는 몇 가지 누락된 엣지 케이스가 있었기 때문에 에이전트와 조금 더 반복했습니다(우리의 내보내기 XML에는 데이터 테이블 정의와 에이전트 정의가 포함되어 있지 않습니다).

이와 유사한 무언가를 구축해야 한다면?

이 게시물은 에이전트에게 매우 도움이 될 것입니다. 다음과 같은 간단한 프롬프트:

"X"에 대한 중요한 논의를 계정 A, B, C에서 제 토픽으로 동기화하세요. 메타 링크에 있는 유사한 방법을 따르세요.

이를 통해 X에서 임의의 데이터 동기화를 수행할 수 있습니다.

소셜 → Discourse는 커뮤니티를 위한 매우 중요한 벡터이며, 워크플로는 회사 내 기존 사람들에게 Discourse에서 모든 콘텐츠 게시를 중앙화하도록 강제하지 않으면서 더 정교한 논의에 열려 있는 장기간의 기록을 유지할 수 있게 합니다.

워크플로는 이러한 종류의 동기화에 사용할 수 있는 기본 요소이며, 여기에는 매우 다양한 옵션이 있습니다.

  • 특정 사람이 소셜에 콘텐츠를 게시할 때 그늘 토픽을 게시합니다.
  • 소셜 활동에 기반하여 장기간의 토픽을 최신 상태로 유지합니다.
  • … 그리고 훨씬 더 많은 것들
1개의 좋아요