Discourse-Beitrag automatisch anhand eines X-Feeds aktualisieren

Ich habe gerade Folgendes auf OpenAI eingerichtet:

Wie Tibo auf X schreibt, aktualisiert Discourse nun automatisch einen Beitrag, der die Tweets im Blick behält.

Wie funktioniert das?

Eine Datentabelle als Cache

Discourse Workflows bietet eine Datentabelle-Funktion mit.

Damit können wir strukturierte Informationen zwischen Workflow-Ausführungen speichern.

Ein entscheidender Aspekt bei der Integration mit X ist die Vermeidung von API-Kostenüberschreitungen. Dazu speichern wir einen Cache aller gescrapten Informationen, um alte Tweets nicht erneut abfragen zu müssen.

Eine Datentabelle eignet sich dafür perfekt.

Ein Konfigurationsknoten

„Felder festlegen“ ist ideal für Konfigurationsknoten. In diesem Fall brauchte ich einige „pro Workflow“-Einstellungen, die im gesamten Workflow verwendet werden: die Topic-ID, in die gepostet wird, der Startzeitpunkt der Kampagne, Benutzernamen und so weiter.

Das hilft, den Workflow leichter nachvollziehbar zu halten.

HTTP-Anfragen an X

Der Workflow stützt sich auf zwei X-Endpunkte:

Einen sehr einfachen, um Tibos X-ID abzurufen.

Einen zweiten, um die Timeline abzurufen:

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("Unerwartete Antwort bei der X-Benutzerabfrage");
}
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 || [] };

Hier kommen auch schon einige Implementierungsdetails zum Vorschein :slight_smile: Ich habe den Workflow mit einem Agenten erstellt und dabei eine „Fake-X-API“ ausgeführt, um Kosten zu sparen (daher der localhost-Eintrag).

Mit „If-Blöcken“ im Flow können wir die Timeline eines Benutzers so lange durchlaufen, wie es weitere Seiten gibt. Ein nettes Implementierungsdetail der X-API ist, dass man nur für tatsächlich empfangene Posts zahlt, sodass Abfragen mit null Ergebnissen nichts kosten.

Das Parsen der Tweets erfolgt mit diesem kleinen Skriptknoten:

const previous = $("Build page URL").item.json;
const response = $json;
if (!response || !response.meta || !Array.isArray(response.data || [])) {
  throw new Error("Unvollständige X-Timeline-Antwort; Aktualisierung des Beitrags verweigert");
}
if (response.errors?.length) { throw new Error("X hat teilweise Daten/Fehler zurückgegeben; Aktualisierung des Beitrags verweigert"); }
const next = response.meta.next_token || "";
if (next && (previous.tokens.includes(next) || previous.page_count >= 39)) {
  throw new Error("X-Paginierung wiederholt oder 40-Seiten-Sicherheitslimit überschritten");
}
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 muss eine dezimale Zeichenkette sein"); }
  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 };

Intelligenz hinzufügen

Der Schlüssel zur erfolgreichen Synchronisierung von Inhalten ist Intelligenz:



Wir übergeben eine Liste von Tweets als Eingabe → wir erhalten ein korrekt extrahiertes JSON-Dokument. Dieser Prozess erfordert Intelligenz.

Er formatiert nicht nur den Inhalt, sondern findet auch die relevantesten Informationen.

Aktualisieren des Discourse-Beitrags

Wir verwenden einen Skriptknoten, um die Tabelle pro Folgendem zu rendern:

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("Mehrdeutige workflow-eigene Tabellenmarkierungen im zugewiesenen Beitrag");
}
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("Verweigerung der rückwärts gerichteten Verschiebung des Timeline-Cursors");
}
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("Ungültiger gespeicherter 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 + " | Ausstehend | — | — | — |"); }
  else for (const ship of ships) {
    const provenance = demo
      ? "[Demo-Beitrag](" + apiBase + "/demo/posts/" + encodeURIComponent(ship.source_id) + ") (synthetisch)"
      : "[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| Tag | Ship | Ankündigung | Details | Quelle |\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 };

Der kleine Trick besteht darin, dass der Discourse-Beitrag einen speziellen Marker hat:


So können wir nach der Generierung der Tabelle prüfen, ob sich etwas geändert hat, bevor wir die Tabelle aktualisieren.

Wenn sich die Tabelle ändert, aktualisieren wir die nachverfolgende Datentabelle:

Wie habe ich das gebaut?

Workflows sind unglaublich mächtige Funktionen; große wie diesen von Hand zu bauen, würde sehr lange dauern.

Anstatt das zu tun, habe ich mich auf einen Agenten gestützt. Ich verwende term-llm.com für meine gesamte Entwicklung, es integriert sich sehr sauber in dv. Natürlich auch deinen Agenten der Wahl, aber eine wichtige Funktion hier ist eine funktionierende Discourse-Umgebung.

Meine Schritte waren:

  1. dv new workflow-build – um eine neue, saubere Umgebung zu erstellen.

    1. Ich benutze einen speziellen dv-Hook, der einen term-llm serve Webdienst startet, der mit meinem Hub verbunden ist. Das gibt mir einen sofortigen „reichen Agenten“ in einer Wegwerf-Umgebung, in der ich wild experimentieren kann.
  2. Ich habe ihn promptet:


Ich habe GPT 6.1 Sol für den Build verwendet und hatte nach einem einzigen Durchlauf schon etwas Lauffähiges:

  1. Ich habe es verfeinert
  • Der erste Versuch verwendete keine Datentabelle – ich habe promptet, die Speicherung dorthin zu verlegen

  • Als Nächstes habe ich iteriert, um alle Konfigurationen in einen zentralen Knoten zu verlegen, damit sie leichter zu verwalten sind

  1. Ich habe den Workflow exportiert – und dann in die Produktion importiert

  2. Ich habe ein bisschen mit dem Agenten iteriert, da in der Produktion einige fehlende Randfälle auftraten (unser Export-XML enthält keine Datentabelle-Definitionen und Agenten-Definitionen)

Was, wenn du etwas Ähnliches bauen musst?

Dieser Beitrag wird Agenten unglaublich hilfreich sein, ein einfacher Prompt in etwa wie:

Synchronisiere wichtige Diskussionen über „X“ auf den Accounts A, B, C in mein Thema, indem du eine ähnliche Methode wie in LINK ZUM META-POST verwendest

Erlaubt es dir, beliebte Datensynchronisierung von X durchzuführen.

Sozial → Discourse ist ein sehr wichtiger Vektor für die Community. Workflows ermöglichen es dir, ein langlebiges Protokoll zu führen, das für differenziertere Diskussionen offen ist, ohne bestehende Personen in deinem Unternehmen zu zwingen, alle Inhaltsbeiträge auf Discourse zu zentralisieren.

Workflows sind der Baustein, den du für diese Art von Synchronisierung verwenden kannst, und die Optionen hier sind ziemlich weit gefächert.

  • Schatten-Themen posten, wenn bestimmte Personen Inhalte in sozialen Medien posten
  • Ein langlebiges Thema auf dem Laufenden halten, basierend auf Aktivität in sozialen Medien
  • … und vieles mehr
1 „Gefällt mir“