لقد قمت للتو بإعداد ما يلي على OpenAI
كما يذكر Tibo على منصة X، فإن Discourse سيقوم الآن بتحديث منشور بشكل تلقوي يتتبع التغريدات.
كيف يعمل هذا؟
جدول بيانات كذاكرة مؤقتة (Cache)
يأتي Discourse Workflows مع ميزة جدول البيانات (Data table).
هذا يتيح لنا تخزين معلومات منظمة بين عمليات تشغيل سير العمل.
قطعة حاسمة عند التكامل مع X هي تجنب تجاوز تكاليف API. للقيام بذلك، نخزن نسخة مؤقتة (Cache) من جميع المعلومات التي جمعناها لتجنب إعادة استعلام التغريدات القديمة.
جدول البيانات مثالي لهذا الغرض.
عقدة التهيئة (Configuration Node)
«تعيين الحقول» (Set fields) مثالي لعقد التهيئة. في هذه الحالة، كنت بحاجة إلى بعض التهيئة «لكل سير عمل» تُستخدم طوال سير العمل، مثل معرّف الموضوع (topic id) الذي سننشر فيه، وقت بدء الحملة، وأسماء المستخدمين، وما إلى ذلك.
يساعد ذلك في جعل سير العمل أسهل في الفهم والتعامل معه.
طلبات HTTP إلى X
يعتمد سير العمل على نقطتي نهاية (Endpoints) في X:
نقطة نهاية بسيطة جدًا للبحث عن معرّف X الخاص بـ Tibo.
ونقطة نهاية ثانية للبحث عن الخط الزمني (Timeline):
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 || [] };
يبدأ هذا أيضًا في الكشف عن بعض تفاصيل التنفيذ
لقد بنيت سير العمل باستخدام وكيل (Agent)، وشغّلت «X API وهمية» أثناء العملية لتوفير التكاليف (ولهذا السبب نرى localhost هنا)
باستخدام كتل «إذا» (If blocks) في التدفق، يمكننا الاستمرار في التكرار عبر الخط الزمني للمستخدم طالما كانت هناك صفحات إضافية. تفصيل تنفيذي رائع في 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 يحتوي على علامة مميزة خاصة:
بهذه الطريقة، بعد توليد الجدول، يمكننا التحقق مرتين مما إذا كان هناك أي تغيير قبل تحديث الجدول.
إذا تغيّر الجدول، نقوم بتحديث جدول البيانات التتبعي:
كيف بنيت هذا؟
سيرات العمل (Workflows) هي ميزات قوية للغاية، وبناء سترات ضخمة كهذه يدويًا سيستغرق وقتًا طويلاً.
بدلًا من القيام بذلك، اعتمدت على وكيل (Agent)، فأنا أستخدم term-llm.com لجميع عمليات التطوير الخاصة بي، وهو يتكامل بشكل نظيف جدًا مع dv. بالطبع يمكنك استخدام الوكيل الذي تفضله، لكن ميزة رئيسية هنا هي امتلاك بيئة Discourse عاملة.
خطواتي كانت:
-
dv new workflow-build- لإنشاء بيئة جديدة نظيفة.- أستخدم خطاف dv خاص يشغّل خدمة ويب لـ term-llm serve تتصل بـ hub الخاص بي، مما يمنحني «وكيلًا غنيًا» فوريًا في بيئة مؤقتة يمكنني فيها العمل بحرية.
-
قمت بإعطاءه الأمر (Prompt):
استخدمت GPT 6.1 Sol للبناء، وكان لديّ شيء ما يعمل بعد محاولة واحدة:
- قمت بتحسينه
-
المحاولة الأولى لم تكن تستخدم جدول بيانات - قمت بإعطاء الأمر للانتقال إلى التخزين هناك
-
بعد ذلك، قمت بالتكرار لنقل جميع التهيئة إلى عقدة مركزية ليكون من الأسهل إدارتها
-
قمت بتصدير سير العمل - ثم استوردته إلى بيئة الإنتاج
-
قمت بالتكرار قليلاً مع الوكيل لأن هناك بعض الحالات الحدية المفقودة في بيئة الإنتاج (ملف التصدير xml لدينا لا يتضمن تعريفات جداول البيانات وتعريفات الوكلاء)
ماذا لو كنت بحاجة إلى بناء شيء كهذا؟
سيكون هذا المنشور مفيدًا للغاية للوكلاء، فامر بسيط على النحو التالي:
مزامنة المناقشات المهمة حول «X» على الحسابات A، B، C في موضوعي باستخدام طريقة مشابهة في رابط الميتا (LINK TO META)
سيسمح لك بالقيام بمزامنة بيانات تعسفية من X.
الانتقال من وسائل التواصل الاجتماعي إلى Discourse هو اتجاه مهم جدًا للمجتمع، فسيرات العمل تتيح لك الحفاظ على سجل طويل الأمد مفتوح لمناقشات أكثر دقة دون إجبار الأشخاص الحاليين في شركتك على مركزية جميع نشرات المحتوى على Discourse.
سيرات العمل هي لبنة البناء التي يمكنك استخدامها لهذا النوع من المزامنة، والخيارات هنا واسعة جدًا.
- إنشاء مواضيع ظل (Shadow topics) عندما ينشر أشخاص معينون محتوى على وسائل التواصل
- الحفاظ على موضوع طويل الأمد محدثًا بناءً على النشاط على وسائل التواصل
- … وكثير من الأشياء الأخرى
















