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.
- package/.claude-plugin/README.md +2 -2
- package/.claude-plugin/marketplace.json +4 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +115 -312
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -4
- package/adapters/mcp/server-stdio.js +61 -1
- package/adapters/opencode/opencode.json +4 -2
- package/bin/cli.js +156 -8
- package/bin/memory.sh +3 -3
- package/config/e2e-critical-flows.json +4 -0
- package/config/gates/default.json +74 -2
- package/config/github-about.json +1 -1
- package/config/mcp-allowlists.json +27 -0
- package/package.json +22 -5
- package/plugins/amp-skill/INSTALL.md +1 -0
- package/plugins/amp-skill/SKILL.md +1 -0
- package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-codex-bridge/.mcp.json +4 -2
- package/plugins/claude-skill/INSTALL.md +1 -0
- package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
- package/plugins/codex-profile/.mcp.json +4 -2
- package/plugins/codex-profile/INSTALL.md +1 -1
- package/plugins/codex-profile/README.md +1 -1
- package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-marketplace/README.md +3 -3
- package/plugins/cursor-marketplace/mcp.json +3 -1
- package/plugins/cursor-marketplace/scripts/gate-check.sh +15 -5
- package/plugins/gemini-extension/INSTALL.md +3 -3
- package/plugins/opencode-profile/INSTALL.md +1 -1
- package/public/dashboard.html +15 -8
- package/public/index.html +125 -185
- package/public/js/buyer-intent.js +252 -0
- package/public/pro.html +1085 -0
- package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
- package/scripts/adk-consolidator.js +14 -2
- package/scripts/agent-readiness.js +3 -1
- package/scripts/agent-security-hardening.js +4 -4
- package/scripts/auto-promote-gates.js +2 -0
- package/scripts/auto-wire-hooks.js +105 -17
- package/scripts/behavioral-extraction.js +2 -6
- package/scripts/billing.js +107 -3
- package/scripts/budget-guard.js +2 -2
- package/scripts/build-metadata.js +14 -0
- package/scripts/context-engine.js +1 -0
- package/scripts/deploy-policy.js +3 -17
- package/scripts/dpo-optimizer.js +3 -6
- package/scripts/ensure-repo-bootstrap.js +129 -0
- package/scripts/export-dpo-pairs.js +2 -3
- package/scripts/export-kto-pairs.js +3 -4
- package/scripts/export-training.js +8 -6
- package/scripts/feedback-attribution.js +23 -11
- package/scripts/feedback-loop.js +40 -2
- package/scripts/feedback-to-rules.js +2 -1
- package/scripts/filesystem-search.js +3 -2
- package/scripts/gates-engine.js +760 -29
- package/scripts/generate-pretool-hook.sh +0 -0
- package/scripts/gtm-revenue-loop.js +20 -1
- package/scripts/hook-auto-capture.sh +8 -3
- package/scripts/hook-runtime.js +81 -0
- package/scripts/hook-stop-self-score.sh +3 -3
- package/scripts/hook-thumbgate-cache-updater.js +99 -38
- package/scripts/hosted-config.js +4 -16
- package/scripts/hybrid-feedback-context.js +54 -14
- package/scripts/install-mcp.js +13 -3
- package/scripts/intent-router.js +2 -2
- package/scripts/license.js +52 -14
- package/scripts/local-model-profile.js +3 -2
- package/scripts/mcp-config.js +62 -7
- package/scripts/meta-policy.js +4 -8
- package/scripts/money-watcher.js +166 -16
- package/scripts/obsidian-export.js +1 -0
- package/scripts/operational-integrity.js +480 -0
- package/scripts/post-everywhere.js +35 -12
- package/scripts/pr-manager.js +14 -11
- package/scripts/profile-router.js +2 -0
- package/scripts/prompt-dlp.js +1 -0
- package/scripts/publish-decision.js +10 -0
- package/scripts/published-cli.js +61 -0
- package/scripts/risk-scorer.js +3 -2
- package/scripts/rlhf_session_start.sh +32 -0
- package/scripts/skill-quality-tracker.js +3 -5
- package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
- package/scripts/social-analytics/db/social-analytics.db-wal +0 -0
- package/scripts/social-analytics/engagement-audit.js +202 -0
- package/scripts/social-analytics/instagram-thumbgate-post.js +45 -7
- package/scripts/social-analytics/install-growth-automation.js +114 -0
- package/scripts/social-analytics/load-env.js +46 -0
- package/scripts/social-analytics/poll-all.js +23 -23
- package/scripts/social-analytics/pollers/plausible.js +2 -4
- package/scripts/social-analytics/pollers/zernio.js +3 -0
- package/scripts/social-analytics/publish-instagram-thumbgate.js +22 -3
- package/scripts/social-analytics/publish-thumbgate-launch.js +322 -0
- package/scripts/social-analytics/publishers/reddit.js +7 -12
- package/scripts/social-analytics/publishers/zernio.js +301 -22
- package/scripts/social-analytics/reconcile-thumbgate-campaign.js +165 -0
- package/scripts/social-analytics/schedule-thumbgate-campaign.js +275 -0
- package/scripts/social-analytics/sync-launch-assets.js +185 -0
- package/scripts/social-post-hourly.js +185 -0
- package/scripts/social-quality-gate.js +119 -3
- package/scripts/social-reply-monitor.js +184 -37
- package/scripts/statusline-cache-path.js +27 -0
- package/scripts/statusline-local-stats.js +16 -0
- package/scripts/statusline-meta.js +22 -0
- package/scripts/statusline.sh +40 -33
- package/scripts/sync-version.js +24 -3
- package/scripts/test-coverage.js +21 -13
- package/scripts/tool-registry.js +97 -0
- package/scripts/train_from_feedback.py +32 -9
- package/scripts/validate-feedback.js +3 -2
- package/scripts/vector-store.js +2 -3
- package/scripts/verify-obsidian-setup.sh +3 -3
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
};
|