thumbgate 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.claude-plugin/README.md +2 -2
  2. package/.claude-plugin/marketplace.json +4 -2
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +115 -312
  6. package/adapters/README.md +1 -1
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/codex/config.toml +4 -4
  9. package/adapters/mcp/server-stdio.js +61 -1
  10. package/adapters/opencode/opencode.json +4 -2
  11. package/bin/cli.js +156 -8
  12. package/bin/memory.sh +3 -3
  13. package/config/e2e-critical-flows.json +4 -0
  14. package/config/gates/default.json +74 -2
  15. package/config/github-about.json +1 -1
  16. package/config/mcp-allowlists.json +27 -0
  17. package/package.json +22 -5
  18. package/plugins/amp-skill/INSTALL.md +1 -0
  19. package/plugins/amp-skill/SKILL.md +1 -0
  20. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  21. package/plugins/claude-codex-bridge/.mcp.json +4 -2
  22. package/plugins/claude-skill/INSTALL.md +1 -0
  23. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  24. package/plugins/codex-profile/.mcp.json +4 -2
  25. package/plugins/codex-profile/INSTALL.md +1 -1
  26. package/plugins/codex-profile/README.md +1 -1
  27. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  28. package/plugins/cursor-marketplace/README.md +3 -3
  29. package/plugins/cursor-marketplace/mcp.json +3 -1
  30. package/plugins/cursor-marketplace/scripts/gate-check.sh +15 -5
  31. package/plugins/gemini-extension/INSTALL.md +3 -3
  32. package/plugins/opencode-profile/INSTALL.md +1 -1
  33. package/public/dashboard.html +15 -8
  34. package/public/index.html +125 -185
  35. package/public/js/buyer-intent.js +252 -0
  36. package/public/pro.html +1085 -0
  37. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  38. package/scripts/adk-consolidator.js +14 -2
  39. package/scripts/agent-readiness.js +3 -1
  40. package/scripts/agent-security-hardening.js +4 -4
  41. package/scripts/auto-promote-gates.js +2 -0
  42. package/scripts/auto-wire-hooks.js +105 -17
  43. package/scripts/behavioral-extraction.js +2 -6
  44. package/scripts/billing.js +107 -3
  45. package/scripts/budget-guard.js +2 -2
  46. package/scripts/build-metadata.js +14 -0
  47. package/scripts/context-engine.js +1 -0
  48. package/scripts/deploy-policy.js +3 -17
  49. package/scripts/dpo-optimizer.js +3 -6
  50. package/scripts/ensure-repo-bootstrap.js +129 -0
  51. package/scripts/export-dpo-pairs.js +2 -3
  52. package/scripts/export-kto-pairs.js +3 -4
  53. package/scripts/export-training.js +8 -6
  54. package/scripts/feedback-attribution.js +23 -11
  55. package/scripts/feedback-loop.js +40 -2
  56. package/scripts/feedback-to-rules.js +2 -1
  57. package/scripts/filesystem-search.js +3 -2
  58. package/scripts/gates-engine.js +760 -29
  59. package/scripts/generate-pretool-hook.sh +0 -0
  60. package/scripts/gtm-revenue-loop.js +20 -1
  61. package/scripts/hook-auto-capture.sh +8 -3
  62. package/scripts/hook-runtime.js +81 -0
  63. package/scripts/hook-stop-self-score.sh +3 -3
  64. package/scripts/hook-thumbgate-cache-updater.js +99 -38
  65. package/scripts/hosted-config.js +4 -16
  66. package/scripts/hybrid-feedback-context.js +54 -14
  67. package/scripts/install-mcp.js +13 -3
  68. package/scripts/intent-router.js +2 -2
  69. package/scripts/license.js +52 -14
  70. package/scripts/local-model-profile.js +3 -2
  71. package/scripts/mcp-config.js +62 -7
  72. package/scripts/meta-policy.js +4 -8
  73. package/scripts/money-watcher.js +166 -16
  74. package/scripts/obsidian-export.js +1 -0
  75. package/scripts/operational-integrity.js +480 -0
  76. package/scripts/post-everywhere.js +35 -12
  77. package/scripts/pr-manager.js +14 -11
  78. package/scripts/profile-router.js +2 -0
  79. package/scripts/prompt-dlp.js +1 -0
  80. package/scripts/publish-decision.js +10 -0
  81. package/scripts/published-cli.js +61 -0
  82. package/scripts/risk-scorer.js +3 -2
  83. package/scripts/rlhf_session_start.sh +32 -0
  84. package/scripts/skill-quality-tracker.js +3 -5
  85. package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
  86. package/scripts/social-analytics/db/social-analytics.db-wal +0 -0
  87. package/scripts/social-analytics/engagement-audit.js +202 -0
  88. package/scripts/social-analytics/instagram-thumbgate-post.js +45 -7
  89. package/scripts/social-analytics/install-growth-automation.js +114 -0
  90. package/scripts/social-analytics/load-env.js +46 -0
  91. package/scripts/social-analytics/poll-all.js +23 -23
  92. package/scripts/social-analytics/pollers/plausible.js +2 -4
  93. package/scripts/social-analytics/pollers/zernio.js +3 -0
  94. package/scripts/social-analytics/publish-instagram-thumbgate.js +22 -3
  95. package/scripts/social-analytics/publish-thumbgate-launch.js +322 -0
  96. package/scripts/social-analytics/publishers/reddit.js +7 -12
  97. package/scripts/social-analytics/publishers/zernio.js +301 -22
  98. package/scripts/social-analytics/reconcile-thumbgate-campaign.js +165 -0
  99. package/scripts/social-analytics/schedule-thumbgate-campaign.js +275 -0
  100. package/scripts/social-analytics/sync-launch-assets.js +185 -0
  101. package/scripts/social-post-hourly.js +185 -0
  102. package/scripts/social-quality-gate.js +119 -3
  103. package/scripts/social-reply-monitor.js +184 -37
  104. package/scripts/statusline-cache-path.js +27 -0
  105. package/scripts/statusline-local-stats.js +16 -0
  106. package/scripts/statusline-meta.js +22 -0
  107. package/scripts/statusline.sh +40 -33
  108. package/scripts/sync-version.js +24 -3
  109. package/scripts/test-coverage.js +21 -13
  110. package/scripts/tool-registry.js +97 -0
  111. package/scripts/train_from_feedback.py +32 -9
  112. package/scripts/validate-feedback.js +3 -2
  113. package/scripts/vector-store.js +2 -3
  114. package/scripts/verify-obsidian-setup.sh +3 -3
  115. package/src/api/server.js +281 -33
@@ -8,11 +8,69 @@
8
8
  * ZERNIO_API_KEY — Bearer token for https://zernio.com/api/v1
9
9
  */
10
10
 
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const crypto = require('node:crypto');
11
14
  const { tagUrlsInText } = require('../utm');
15
+ const { loadLocalEnv } = require('../load-env');
12
16
 
13
17
  const ZERNIO_UTM = { source: 'zernio', medium: 'social', campaign: 'organic' };
14
18
 
15
19
  const ZERNIO_BASE = 'https://zernio.com/api/v1';
20
+ const DEFAULT_DEDUP_LOG_PATH = path.join(__dirname, '..', '..', '..', '.thumbgate', 'zernio-dedup-log.json');
21
+
22
+ loadLocalEnv();
23
+
24
+ /**
25
+ * Content-hash dedup: prevents the same content from being posted to the same
26
+ * platform twice within a 24-hour window.
27
+ */
28
+ function getDedupLogPath() {
29
+ return process.env.THUMBGATE_DEDUP_LOG_PATH || DEFAULT_DEDUP_LOG_PATH;
30
+ }
31
+
32
+ function buildDedupKey(content, platform) {
33
+ const hash = crypto.createHash('sha256').update(content.trim()).digest('hex').slice(0, 16);
34
+ return `${platform}::${hash}`;
35
+ }
36
+
37
+ function loadDedupLog() {
38
+ const logPath = getDedupLogPath();
39
+ try {
40
+ if (fs.existsSync(logPath)) {
41
+ return JSON.parse(fs.readFileSync(logPath, 'utf8'));
42
+ }
43
+ } catch { /* ignore corrupt log */ }
44
+ return {};
45
+ }
46
+
47
+ function saveDedupLog(log) {
48
+ const logPath = getDedupLogPath();
49
+ const dir = path.dirname(logPath);
50
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
51
+ fs.writeFileSync(logPath, JSON.stringify(log, null, 2));
52
+ }
53
+
54
+ function isDuplicate(content, platform) {
55
+ const log = loadDedupLog();
56
+ const key = buildDedupKey(content, platform);
57
+ const entry = log[key];
58
+ if (!entry) return false;
59
+ const ageMs = Date.now() - new Date(entry.postedAt).getTime();
60
+ return ageMs < 24 * 60 * 60 * 1000; // 24-hour dedup window
61
+ }
62
+
63
+ function recordPost(content, platform) {
64
+ const log = loadDedupLog();
65
+ const key = buildDedupKey(content, platform);
66
+ log[key] = { platform, postedAt: new Date().toISOString() };
67
+ // Prune entries older than 7 days
68
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
69
+ for (const [k, v] of Object.entries(log)) {
70
+ if (new Date(v.postedAt).getTime() < cutoff) delete log[k];
71
+ }
72
+ saveDedupLog(log);
73
+ }
16
74
 
17
75
  function requireApiKey() {
18
76
  const key = process.env.ZERNIO_API_KEY;
@@ -22,6 +80,82 @@ function requireApiKey() {
22
80
  return key;
23
81
  }
24
82
 
83
+ function resolveAccountId(account) {
84
+ if (!account || typeof account !== 'object') {
85
+ return '';
86
+ }
87
+ return String(account.accountId || account._id || account.id || '').trim();
88
+ }
89
+
90
+ function normalizeAccount(account) {
91
+ if (!account || typeof account !== 'object') {
92
+ return null;
93
+ }
94
+ const platform = String(account.platform || '').trim();
95
+ const accountId = resolveAccountId(account);
96
+ return {
97
+ ...account,
98
+ platform,
99
+ accountId,
100
+ };
101
+ }
102
+
103
+ function normalizePlatforms(platforms) {
104
+ return platforms
105
+ .map(normalizeAccount)
106
+ .filter(Boolean)
107
+ .map((platform) => ({
108
+ platform: platform.platform,
109
+ accountId: platform.accountId,
110
+ }));
111
+ }
112
+
113
+ function groupAccountsByPlatform(accounts) {
114
+ const groups = new Map();
115
+ for (const account of accounts) {
116
+ if (!account || !account.platform || !account.accountId) {
117
+ continue;
118
+ }
119
+ const existing = groups.get(account.platform) || [];
120
+ existing.push({
121
+ platform: account.platform,
122
+ accountId: account.accountId,
123
+ });
124
+ groups.set(account.platform, existing);
125
+ }
126
+ return groups;
127
+ }
128
+
129
+ function inferContentType(filePath) {
130
+ const ext = path.extname(String(filePath || '')).toLowerCase();
131
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
132
+ if (ext === '.png') return 'image/png';
133
+ if (ext === '.webp') return 'image/webp';
134
+ if (ext === '.gif') return 'image/gif';
135
+ if (ext === '.mp4') return 'video/mp4';
136
+ if (ext === '.mov') return 'video/quicktime';
137
+ if (ext === '.webm') return 'video/webm';
138
+ return 'application/octet-stream';
139
+ }
140
+
141
+ function inferMediaType(contentType) {
142
+ if (String(contentType || '').startsWith('video/')) {
143
+ return 'video';
144
+ }
145
+ return 'image';
146
+ }
147
+
148
+ function normalizePostResult(payload) {
149
+ const data = payload && typeof payload === 'object' ? (payload.data ?? payload) : {};
150
+ const post = data.post && typeof data.post === 'object' ? data.post : null;
151
+ const id = data.id || data._id || post?._id || post?.id || null;
152
+ return {
153
+ ...data,
154
+ id,
155
+ post: post || data.post,
156
+ };
157
+ }
158
+
25
159
  async function zernioFetch(method, endpoint, body = null) {
26
160
  const apiKey = requireApiKey();
27
161
  const url = `${ZERNIO_BASE}${endpoint}`;
@@ -48,20 +182,92 @@ async function zernioFetch(method, endpoint, body = null) {
48
182
  return res.json();
49
183
  }
50
184
 
185
+ async function listPosts(options = {}) {
186
+ const query = new URLSearchParams();
187
+ if (options.limit) query.set('limit', String(options.limit));
188
+ if (options.page) query.set('page', String(options.page));
189
+ if (options.status) query.set('status', String(options.status));
190
+
191
+ const suffix = query.toString() ? `?${query.toString()}` : '';
192
+ const json = await zernioFetch('GET', `/posts${suffix}`);
193
+ return Array.isArray(json.posts) ? json.posts : (json.data?.posts || json.data || []);
194
+ }
195
+
196
+ async function deletePost(postId) {
197
+ if (!postId) throw new Error('deletePost: postId is required');
198
+ const json = await zernioFetch('DELETE', `/posts/${encodeURIComponent(String(postId).trim())}`);
199
+ return json.data ?? json;
200
+ }
201
+
202
+ async function requestMediaPresign(filename, contentType, size) {
203
+ if (!filename) throw new Error('requestMediaPresign: filename is required');
204
+ if (!contentType) throw new Error('requestMediaPresign: contentType is required');
205
+
206
+ const json = await zernioFetch('POST', '/media/presign', {
207
+ filename,
208
+ contentType,
209
+ size,
210
+ });
211
+
212
+ return json.data ?? json;
213
+ }
214
+
215
+ async function uploadLocalMedia(filePath, options = {}) {
216
+ const resolvedPath = path.resolve(String(filePath || ''));
217
+ if (!fs.existsSync(resolvedPath)) {
218
+ throw new Error(`uploadLocalMedia: file not found at ${resolvedPath}`);
219
+ }
220
+
221
+ const stats = fs.statSync(resolvedPath);
222
+ const filename = options.filename || path.basename(resolvedPath);
223
+ const contentType = options.contentType || inferContentType(resolvedPath);
224
+ const presign = await requestMediaPresign(filename, contentType, stats.size);
225
+
226
+ if (!presign.uploadUrl || !presign.publicUrl) {
227
+ throw new Error('uploadLocalMedia: presign response missing uploadUrl or publicUrl');
228
+ }
229
+
230
+ const uploadResponse = await fetch(presign.uploadUrl, {
231
+ method: 'PUT',
232
+ headers: {
233
+ 'Content-Type': contentType,
234
+ },
235
+ body: fs.readFileSync(resolvedPath),
236
+ });
237
+
238
+ if (!uploadResponse.ok) {
239
+ const errorText = await uploadResponse.text().catch(() => '');
240
+ throw new Error(`uploadLocalMedia: upload failed with ${uploadResponse.status}: ${errorText}`);
241
+ }
242
+
243
+ return {
244
+ contentType,
245
+ key: presign.key || '',
246
+ size: stats.size,
247
+ type: presign.type || inferMediaType(contentType),
248
+ url: presign.publicUrl,
249
+ };
250
+ }
251
+
51
252
  /**
52
253
  * Publishes a post immediately to one or more platforms.
53
254
  * @param {string} content
54
255
  * @param {Array<{platform: string, accountId: string}>} platforms
55
256
  * @returns {Promise<object>}
56
257
  */
57
- async function publishPost(content, platforms) {
258
+ async function publishPost(content, platforms, options = {}) {
58
259
  if (!content) throw new Error('publishPost: content is required');
59
260
  if (!Array.isArray(platforms) || platforms.length === 0) {
60
261
  throw new Error('publishPost: platforms must be a non-empty array');
61
262
  }
62
263
 
264
+ const normalizedPlatforms = normalizePlatforms(platforms);
265
+ if (normalizedPlatforms.length === 0 || normalizedPlatforms.some((entry) => !entry.platform || !entry.accountId)) {
266
+ throw new Error('publishPost: each platform entry requires platform and accountId');
267
+ }
268
+
63
269
  // Tag trackable URLs with Zernio UTM parameters before publishing
64
- content = tagUrlsInText(content, ZERNIO_UTM);
270
+ content = tagUrlsInText(content, options.utm || ZERNIO_UTM);
65
271
 
66
272
  const qualityGate = require('../../social-quality-gate');
67
273
  const gateResult = qualityGate.gatePost(content);
@@ -71,15 +277,35 @@ async function publishPost(content, platforms) {
71
277
  return { blocked: true, reasons: gateResult.findings };
72
278
  }
73
279
 
74
- console.log(`[zernio:publisher] Publishing to ${platforms.length} platform(s): ${platforms.map((p) => p.platform).join(', ')}`);
280
+ // Dedup: filter out platforms where identical content was posted in last 24h
281
+ const dedupedPlatforms = normalizedPlatforms.filter((p) => {
282
+ if (isDuplicate(content, p.platform)) {
283
+ console.log(`[zernio:publisher] SKIPPED ${p.platform} — duplicate content within 24h`);
284
+ return false;
285
+ }
286
+ return true;
287
+ });
288
+
289
+ if (dedupedPlatforms.length === 0) {
290
+ console.log('[zernio:publisher] All platforms skipped (duplicate content)');
291
+ return { blocked: true, reasons: [{ reason: 'duplicate_content_all_platforms' }] };
292
+ }
293
+
294
+ console.log(`[zernio:publisher] Publishing to ${dedupedPlatforms.length} platform(s): ${dedupedPlatforms.map((p) => p.platform).join(', ')}`);
75
295
 
76
296
  const json = await zernioFetch('POST', '/posts', {
77
297
  content,
298
+ firstComment: options.firstComment,
299
+ mediaItems: options.mediaItems,
78
300
  publishNow: true,
79
- platforms,
301
+ platforms: dedupedPlatforms,
80
302
  });
81
303
 
82
- const data = json.data ?? json;
304
+ const data = normalizePostResult(json);
305
+ // Record each platform to prevent future dupes
306
+ for (const p of dedupedPlatforms) {
307
+ recordPost(content, p.platform);
308
+ }
83
309
  console.log(`[zernio:publisher] Post published. id=${data.id ?? 'unknown'}`);
84
310
  return data;
85
311
  }
@@ -92,7 +318,7 @@ async function publishPost(content, platforms) {
92
318
  * @param {string} timezone IANA timezone string
93
319
  * @returns {Promise<object>}
94
320
  */
95
- async function schedulePost(content, platforms, scheduledFor, timezone) {
321
+ async function schedulePost(content, platforms, scheduledFor, timezone, options = {}) {
96
322
  if (!content) throw new Error('schedulePost: content is required');
97
323
  if (!Array.isArray(platforms) || platforms.length === 0) {
98
324
  throw new Error('schedulePost: platforms must be a non-empty array');
@@ -100,17 +326,43 @@ async function schedulePost(content, platforms, scheduledFor, timezone) {
100
326
  if (!scheduledFor) throw new Error('schedulePost: scheduledFor is required');
101
327
  if (!timezone) throw new Error('schedulePost: timezone is required');
102
328
 
103
- console.log(`[zernio:publisher] Scheduling post for ${scheduledFor} (${timezone}) to ${platforms.length} platform(s)`);
329
+ const normalizedPlatforms = normalizePlatforms(platforms);
330
+ if (normalizedPlatforms.length === 0 || normalizedPlatforms.some((entry) => !entry.platform || !entry.accountId)) {
331
+ throw new Error('schedulePost: each platform entry requires platform and accountId');
332
+ }
333
+
334
+ content = tagUrlsInText(content, options.utm || ZERNIO_UTM);
335
+
336
+ // Dedup: filter out platforms where identical content was scheduled in last 24h
337
+ const dedupedPlatforms = normalizedPlatforms.filter((p) => {
338
+ if (isDuplicate(content, p.platform)) {
339
+ console.log(`[zernio:publisher] SKIPPED ${p.platform} schedule — duplicate content within 24h`);
340
+ return false;
341
+ }
342
+ return true;
343
+ });
344
+
345
+ if (dedupedPlatforms.length === 0) {
346
+ console.log('[zernio:publisher] All platforms skipped (duplicate content)');
347
+ return { blocked: true, reasons: [{ reason: 'duplicate_content_all_platforms' }] };
348
+ }
349
+
350
+ console.log(`[zernio:publisher] Scheduling post for ${scheduledFor} (${timezone}) to ${dedupedPlatforms.length} platform(s)`);
104
351
 
105
352
  const json = await zernioFetch('POST', '/posts', {
106
353
  content,
354
+ firstComment: options.firstComment,
355
+ mediaItems: options.mediaItems,
107
356
  publishNow: false,
108
- platforms,
357
+ platforms: dedupedPlatforms,
109
358
  scheduledFor,
110
359
  timezone,
111
360
  });
112
361
 
113
- const data = json.data ?? json;
362
+ const data = normalizePostResult(json);
363
+ for (const p of dedupedPlatforms) {
364
+ recordPost(content, p.platform);
365
+ }
114
366
  console.log(`[zernio:publisher] Post scheduled. id=${data.id ?? 'unknown'}`);
115
367
  return data;
116
368
  }
@@ -123,7 +375,9 @@ async function getConnectedAccounts() {
123
375
  console.log('[zernio:publisher] Fetching connected accounts');
124
376
 
125
377
  const json = await zernioFetch('GET', '/accounts');
126
- const accounts = Array.isArray(json) ? json : (json.data ?? json.accounts ?? []);
378
+ const accounts = (Array.isArray(json) ? json : (json.data ?? json.accounts ?? []))
379
+ .map(normalizeAccount)
380
+ .filter((account) => account && account.platform && account.accountId);
127
381
 
128
382
  console.log(`[zernio:publisher] ${accounts.length} connected account(s) found`);
129
383
  return accounts;
@@ -134,7 +388,7 @@ async function getConnectedAccounts() {
134
388
  * @param {string} content
135
389
  * @returns {Promise<{ published: object[], errors: object[] }>}
136
390
  */
137
- async function publishToAllPlatforms(content) {
391
+ async function publishToAllPlatforms(content, options = {}) {
138
392
  if (!content) throw new Error('publishToAllPlatforms: content is required');
139
393
 
140
394
  console.log('[zernio:publisher] Fetching all connected accounts for bulk publish');
@@ -145,20 +399,31 @@ async function publishToAllPlatforms(content) {
145
399
  return { published: [], errors: [] };
146
400
  }
147
401
 
148
- const platforms = accounts.map((acc) => ({
149
- platform: acc.platform,
150
- accountId: acc.accountId,
151
- }));
152
-
153
402
  const published = [];
154
403
  const errors = [];
404
+ const requestedPlatforms = Array.isArray(options.platforms) && options.platforms.length > 0
405
+ ? new Set(options.platforms.map((platform) => String(platform || '').trim()).filter(Boolean))
406
+ : null;
407
+ const groupedAccounts = groupAccountsByPlatform(accounts);
408
+
409
+ for (const [platform, platformAccounts] of groupedAccounts.entries()) {
410
+ if (requestedPlatforms && !requestedPlatforms.has(platform)) {
411
+ continue;
412
+ }
155
413
 
156
- try {
157
- const result = await publishPost(content, platforms);
158
- published.push(result);
159
- } catch (err) {
160
- console.error(`[zernio:publisher] Bulk publish failed: ${err.message}`);
161
- errors.push({ error: err.message, platforms: platforms.map((p) => p.platform) });
414
+ try {
415
+ const result = await publishPost(content, platformAccounts, {
416
+ utm: {
417
+ source: platform,
418
+ medium: options.medium || ZERNIO_UTM.medium,
419
+ campaign: options.campaign || ZERNIO_UTM.campaign,
420
+ },
421
+ });
422
+ published.push({ platform, result });
423
+ } catch (err) {
424
+ console.error(`[zernio:publisher] Bulk publish failed for ${platform}: ${err.message}`);
425
+ errors.push({ error: err.message, platform });
426
+ }
162
427
  }
163
428
 
164
429
  console.log(`[zernio:publisher] Bulk publish complete. published=${published.length} errors=${errors.length}`);
@@ -166,10 +431,24 @@ async function publishToAllPlatforms(content) {
166
431
  }
167
432
 
168
433
  module.exports = {
434
+ buildDedupKey,
435
+ deletePost,
436
+ isDuplicate,
437
+ listPosts,
169
438
  publishPost,
439
+ recordPost,
170
440
  schedulePost,
171
441
  publishToAllPlatforms,
172
442
  getConnectedAccounts,
443
+ groupAccountsByPlatform,
444
+ inferContentType,
445
+ inferMediaType,
446
+ normalizeAccount,
447
+ normalizePlatforms,
448
+ normalizePostResult,
449
+ requestMediaPresign,
450
+ resolveAccountId,
451
+ uploadLocalMedia,
173
452
  };
174
453
 
175
454
  if (require.main === module) {
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const {
5
+ buildCampaignEntries,
6
+ defaultCampaignSchedule,
7
+ } = require('./publish-thumbgate-launch');
8
+ const {
9
+ deletePost,
10
+ listPosts,
11
+ } = require('./publishers/zernio');
12
+ const {
13
+ buildScheduleKey,
14
+ DEFAULT_STATE_PATH,
15
+ writeScheduleState,
16
+ } = require('./schedule-thumbgate-campaign');
17
+
18
+ const CAMPAIGN_MARKERS = {
19
+ proof_pack: 'campaign_proof_pack',
20
+ free_local: 'campaign_free_local',
21
+ checkout_path: 'campaign_checkout_path',
22
+ };
23
+
24
+ function parseArgs(argv = []) {
25
+ const options = {
26
+ cancelDuplicates: false,
27
+ limit: 50,
28
+ scheduleTimes: [],
29
+ statePath: DEFAULT_STATE_PATH,
30
+ };
31
+
32
+ for (let index = 0; index < argv.length; index += 1) {
33
+ const token = argv[index];
34
+ if (token === '--cancel-duplicates') {
35
+ options.cancelDuplicates = true;
36
+ continue;
37
+ }
38
+ if (token.startsWith('--limit=')) {
39
+ options.limit = Number(token.slice('--limit='.length)) || options.limit;
40
+ continue;
41
+ }
42
+ if (token.startsWith('--times=')) {
43
+ options.scheduleTimes = token.slice('--times='.length).split(',').map((value) => value.trim()).filter(Boolean);
44
+ continue;
45
+ }
46
+ if (token.startsWith('--state-path=')) {
47
+ options.statePath = token.slice('--state-path='.length).trim() || DEFAULT_STATE_PATH;
48
+ }
49
+ }
50
+
51
+ return options;
52
+ }
53
+
54
+ function normalizePlatform(post) {
55
+ return String(post?.platforms?.[0]?.platform || '').trim().toLowerCase();
56
+ }
57
+
58
+ function selectCanonicalPost(posts = []) {
59
+ return [...posts].sort((left, right) => {
60
+ const leftCreated = new Date(left.createdAt || 0).getTime();
61
+ const rightCreated = new Date(right.createdAt || 0).getTime();
62
+ return leftCreated - rightCreated;
63
+ })[0] || null;
64
+ }
65
+
66
+ async function reconcileCampaignState(options = {}, api = {}) {
67
+ const zernio = {
68
+ deletePost: api.deletePost || deletePost,
69
+ listPosts: api.listPosts || listPosts,
70
+ };
71
+ const scheduleTimes = options.scheduleTimes && options.scheduleTimes.length > 0
72
+ ? options.scheduleTimes
73
+ : defaultCampaignSchedule();
74
+ const posts = await zernio.listPosts({ limit: options.limit || 50 });
75
+ const scheduled = {};
76
+ const duplicates = [];
77
+ const kept = [];
78
+ const entries = buildCampaignEntries();
79
+
80
+ for (let index = 0; index < entries.length; index += 1) {
81
+ const entry = entries[index];
82
+ const scheduleLocal = scheduleTimes[index];
83
+ if (!scheduleLocal) {
84
+ continue;
85
+ }
86
+
87
+ const scheduledForUtc = new Date(scheduleLocal).toISOString();
88
+
89
+ for (const platform of Object.keys(entry.posts)) {
90
+ const marker = CAMPAIGN_MARKERS[entry.slug];
91
+ const matchingPosts = posts.filter((post) => (
92
+ post.status === 'scheduled' &&
93
+ normalizePlatform(post) === platform &&
94
+ String(post.content || '').includes(`utm_content=${marker}`) &&
95
+ String(post.scheduledFor || '') === scheduledForUtc
96
+ ));
97
+
98
+ if (matchingPosts.length === 0) {
99
+ continue;
100
+ }
101
+
102
+ const canonical = selectCanonicalPost(matchingPosts);
103
+ if (!canonical) {
104
+ continue;
105
+ }
106
+
107
+ const scheduleKey = buildScheduleKey({
108
+ slug: entry.slug,
109
+ platform,
110
+ scheduledFor: scheduleLocal,
111
+ });
112
+ scheduled[scheduleKey] = {
113
+ id: canonical._id,
114
+ scheduledFor: scheduleLocal,
115
+ slug: entry.slug,
116
+ platform,
117
+ recordedAt: new Date().toISOString(),
118
+ };
119
+ kept.push({
120
+ key: scheduleKey,
121
+ id: canonical._id,
122
+ });
123
+
124
+ const extras = matchingPosts.filter((post) => post._id !== canonical._id);
125
+ for (const duplicate of extras) {
126
+ const duplicateRecord = {
127
+ key: scheduleKey,
128
+ id: duplicate._id,
129
+ platform,
130
+ slug: entry.slug,
131
+ };
132
+ if (options.cancelDuplicates) {
133
+ duplicateRecord.cancelled = await zernio.deletePost(duplicate._id);
134
+ }
135
+ duplicates.push(duplicateRecord);
136
+ }
137
+ }
138
+ }
139
+
140
+ writeScheduleState(options.statePath || DEFAULT_STATE_PATH, { scheduled });
141
+ return {
142
+ duplicates,
143
+ kept,
144
+ statePath: options.statePath || DEFAULT_STATE_PATH,
145
+ };
146
+ }
147
+
148
+ if (require.main === module) {
149
+ reconcileCampaignState(parseArgs(process.argv.slice(2)))
150
+ .then((result) => {
151
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
152
+ })
153
+ .catch((error) => {
154
+ console.error(error && error.message ? error.message : error);
155
+ process.exit(1);
156
+ });
157
+ }
158
+
159
+ module.exports = {
160
+ CAMPAIGN_MARKERS,
161
+ normalizePlatform,
162
+ parseArgs,
163
+ reconcileCampaignState,
164
+ selectCanonicalPost,
165
+ };