thumbgate 0.9.9
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 +134 -0
- package/.claude-plugin/bundle/icon.png +0 -0
- package/.claude-plugin/bundle/icon.svg +18 -0
- package/.claude-plugin/bundle/server/index.js +24 -0
- package/.claude-plugin/marketplace.json +36 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.well-known/mcp/server-card.json +231 -0
- package/LICENSE +21 -0
- package/README.md +375 -0
- package/adapters/README.md +9 -0
- package/adapters/amp/skills/rlhf-feedback/SKILL.md +22 -0
- package/adapters/chatgpt/INSTALL.md +83 -0
- package/adapters/chatgpt/openapi.yaml +1281 -0
- package/adapters/claude/.mcp.json +14 -0
- package/adapters/codex/config.toml +9 -0
- package/adapters/gemini/function-declarations.json +224 -0
- package/adapters/mcp/server-stdio.js +788 -0
- package/adapters/opencode/opencode.json +15 -0
- package/bin/cli.js +1483 -0
- package/bin/memory.sh +64 -0
- package/bin/obsidian-sync.sh +20 -0
- package/bin/postinstall.js +37 -0
- package/config/build-metadata.json +4 -0
- package/config/e2e-critical-flows.json +45 -0
- package/config/gate-templates.json +77 -0
- package/config/gates/claim-verification.json +29 -0
- package/config/gates/computer-use.json +39 -0
- package/config/gates/default.json +117 -0
- package/config/github-about.json +25 -0
- package/config/mcp-allowlists.json +135 -0
- package/config/model-tiers.json +33 -0
- package/config/partner-routing.json +132 -0
- package/config/policy-bundles/constrained-v1.json +64 -0
- package/config/policy-bundles/default-v1.json +91 -0
- package/config/rubrics/default-v1.json +52 -0
- package/config/skill-packs/react-testing.json +23 -0
- package/config/skill-packs/stripe-integration/references/api-spec.json +1 -0
- package/config/skill-packs/stripe-integration/references/webhook-guide.md +3 -0
- package/config/skill-specs/pr-reviewer.json +9 -0
- package/config/skill-specs/release-status.json +9 -0
- package/config/skill-specs/ticket-triage.json +9 -0
- package/config/subagent-profiles.json +32 -0
- package/config/tessl-tiles.json +29 -0
- package/config/thumbgate-settings.managed.json +12 -0
- package/openapi/openapi.yaml +1281 -0
- package/package.json +286 -0
- package/plugins/amp-skill/INSTALL.md +52 -0
- package/plugins/amp-skill/SKILL.md +64 -0
- package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +22 -0
- package/plugins/claude-codex-bridge/.mcp.json +12 -0
- package/plugins/claude-codex-bridge/INSTALL.md +43 -0
- package/plugins/claude-codex-bridge/README.md +46 -0
- package/plugins/claude-codex-bridge/scripts/codex-bridge.js +288 -0
- package/plugins/claude-codex-bridge/skills/adversarial-review/SKILL.md +24 -0
- package/plugins/claude-codex-bridge/skills/result/SKILL.md +22 -0
- package/plugins/claude-codex-bridge/skills/review/SKILL.md +28 -0
- package/plugins/claude-codex-bridge/skills/second-pass/SKILL.md +27 -0
- package/plugins/claude-codex-bridge/skills/setup/SKILL.md +21 -0
- package/plugins/claude-codex-bridge/skills/status/SKILL.md +19 -0
- package/plugins/claude-skill/INSTALL.md +55 -0
- package/plugins/claude-skill/SKILL.md +46 -0
- package/plugins/codex-profile/.codex-plugin/plugin.json +43 -0
- package/plugins/codex-profile/.mcp.json +12 -0
- package/plugins/codex-profile/AGENTS.md +20 -0
- package/plugins/codex-profile/INSTALL.md +66 -0
- package/plugins/codex-profile/README.md +37 -0
- package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +23 -0
- package/plugins/cursor-marketplace/CHANGELOG.md +30 -0
- package/plugins/cursor-marketplace/LICENSE +21 -0
- package/plugins/cursor-marketplace/README.md +124 -0
- package/plugins/cursor-marketplace/agents/reliability-reviewer.md +31 -0
- package/plugins/cursor-marketplace/assets/logo-400x400.png +0 -0
- package/plugins/cursor-marketplace/commands/capture-feedback.md +33 -0
- package/plugins/cursor-marketplace/commands/check-gates.md +25 -0
- package/plugins/cursor-marketplace/commands/show-lessons.md +27 -0
- package/plugins/cursor-marketplace/hooks/hooks.json +10 -0
- package/plugins/cursor-marketplace/mcp.json +12 -0
- package/plugins/cursor-marketplace/rules/feedback-capture.mdc +34 -0
- package/plugins/cursor-marketplace/rules/pre-action-gates.mdc +30 -0
- package/plugins/cursor-marketplace/rules/session-continuity.mdc +28 -0
- package/plugins/cursor-marketplace/scripts/gate-check.sh +11 -0
- package/plugins/cursor-marketplace/skills/capture-feedback/SKILL.md +47 -0
- package/plugins/cursor-marketplace/skills/prevention-rules/SKILL.md +31 -0
- package/plugins/cursor-marketplace/skills/recall-context/SKILL.md +30 -0
- package/plugins/cursor-marketplace/skills/search-lessons/SKILL.md +33 -0
- package/plugins/gemini-extension/INSTALL.md +92 -0
- package/plugins/gemini-extension/gemini_prompt.txt +14 -0
- package/plugins/gemini-extension/tool_contract.json +45 -0
- package/plugins/opencode-profile/INSTALL.md +57 -0
- package/public/assets/instagram-card.png +0 -0
- package/public/assets/tiktok-agent-memory.mp4 +0 -0
- package/public/blog.html +400 -0
- package/public/dashboard.html +1093 -0
- package/public/guide.html +317 -0
- package/public/index.html +1195 -0
- package/public/learn/agent-harness-pattern.html +180 -0
- package/public/learn/ai-agent-persistent-memory.html +202 -0
- package/public/learn/learn.css +45 -0
- package/public/learn/mcp-pre-action-gates-explained.html +172 -0
- package/public/learn/stop-ai-agent-force-push.html +134 -0
- package/public/learn/vibe-coding-safety-net.html +142 -0
- package/public/learn.html +213 -0
- package/public/lessons.html +650 -0
- package/public/vercel.json +8 -0
- package/scripts/__pycache__/train_from_feedback.cpython-314.pyc +0 -0
- package/scripts/a2ui-engine.js +73 -0
- package/scripts/access-anomaly-detector.js +12 -0
- package/scripts/adk-consolidator.js +266 -0
- package/scripts/agent-readiness.js +220 -0
- package/scripts/agent-security-hardening.js +227 -0
- package/scripts/agentic-data-pipeline.js +847 -0
- package/scripts/analytics-report.js +328 -0
- package/scripts/analytics-window.js +158 -0
- package/scripts/async-job-runner.js +1001 -0
- package/scripts/audit-trail.js +398 -0
- package/scripts/auto-promote-gates.js +293 -0
- package/scripts/auto-wire-hooks.js +316 -0
- package/scripts/autonomous-sales-agent.js +39 -0
- package/scripts/autoresearch-runner.js +216 -0
- package/scripts/background-agent-governance.js +237 -0
- package/scripts/behavioral-extraction.js +93 -0
- package/scripts/belief-update.js +84 -0
- package/scripts/billing.js +2438 -0
- package/scripts/bot-detector.js +50 -0
- package/scripts/budget-guard.js +173 -0
- package/scripts/build-claude-mcpb.js +189 -0
- package/scripts/build-metadata.js +97 -0
- package/scripts/check-congruence.js +322 -0
- package/scripts/cli-feedback.js +135 -0
- package/scripts/cli-telemetry.js +87 -0
- package/scripts/cloudflare-dynamic-sandbox.js +315 -0
- package/scripts/code-reasoning.js +350 -0
- package/scripts/codegraph-context.js +466 -0
- package/scripts/commercial-offer.js +56 -0
- package/scripts/computer-use-firewall.js +250 -0
- package/scripts/context-engine.js +694 -0
- package/scripts/contextfs.js +1287 -0
- package/scripts/conversation-context.js +119 -0
- package/scripts/creator-campaigns.js +239 -0
- package/scripts/daemon-manager.js +108 -0
- package/scripts/daily-digest.js +11 -0
- package/scripts/dashboard-render-spec.js +395 -0
- package/scripts/dashboard.js +1058 -0
- package/scripts/data-governance.js +173 -0
- package/scripts/delegation-runtime.js +900 -0
- package/scripts/deploy-gcp.sh +44 -0
- package/scripts/deploy-policy.js +231 -0
- package/scripts/disagreement-mining.js +315 -0
- package/scripts/dispatch-brief.js +159 -0
- package/scripts/distribution-surfaces.js +44 -0
- package/scripts/dpo-optimizer.js +206 -0
- package/scripts/ensure-repo-bootstrap.js +129 -0
- package/scripts/ephemeral-agent-store.js +219 -0
- package/scripts/eval-harness.js +56 -0
- package/scripts/evolution-state.js +241 -0
- package/scripts/experiment-tracker.js +267 -0
- package/scripts/export-databricks-bundle.js +242 -0
- package/scripts/export-dpo-pairs.js +344 -0
- package/scripts/export-kto-pairs.js +309 -0
- package/scripts/export-training.js +450 -0
- package/scripts/failure-diagnostics.js +558 -0
- package/scripts/feedback-attribution.js +313 -0
- package/scripts/feedback-fallback.js +110 -0
- package/scripts/feedback-history-distiller.js +391 -0
- package/scripts/feedback-inbox-read.js +162 -0
- package/scripts/feedback-loop.js +1887 -0
- package/scripts/feedback-paths.js +145 -0
- package/scripts/feedback-quality.js +139 -0
- package/scripts/feedback-root-consolidator.js +238 -0
- package/scripts/feedback-schema.js +426 -0
- package/scripts/feedback-session.js +286 -0
- package/scripts/feedback-to-memory.js +185 -0
- package/scripts/feedback-to-rules.js +164 -0
- package/scripts/filesystem-search.js +405 -0
- package/scripts/funnel-analytics.js +35 -0
- package/scripts/gate-satisfy.js +42 -0
- package/scripts/gate-stats.js +116 -0
- package/scripts/gate-templates.js +70 -0
- package/scripts/gates-engine.js +816 -0
- package/scripts/generate-paperbanana-diagrams.sh +99 -0
- package/scripts/generate-pretool-hook.sh +40 -0
- package/scripts/github-about.js +350 -0
- package/scripts/github-outreach.js +65 -0
- package/scripts/gtm-revenue-loop.js +520 -0
- package/scripts/hallucination-detector.js +226 -0
- package/scripts/hf-papers.js +317 -0
- package/scripts/history-distiller.js +200 -0
- package/scripts/hook-auto-capture.sh +100 -0
- package/scripts/hook-stop-pr-thread-check.sh +68 -0
- package/scripts/hook-stop-self-score.sh +51 -0
- package/scripts/hook-stop-verify-deploy.sh +31 -0
- package/scripts/hook-thumbgate-cache-updater.js +48 -0
- package/scripts/hook-verify-before-done.sh +20 -0
- package/scripts/hosted-config.js +156 -0
- package/scripts/hybrid-feedback-context.js +675 -0
- package/scripts/install-mcp.js +159 -0
- package/scripts/intent-router.js +392 -0
- package/scripts/internal-agent-bootstrap.js +490 -0
- package/scripts/jsonl-watcher.js +155 -0
- package/scripts/lesson-db.js +613 -0
- package/scripts/lesson-inference.js +310 -0
- package/scripts/lesson-retrieval.js +95 -0
- package/scripts/lesson-rotation.js +137 -0
- package/scripts/lesson-search.js +644 -0
- package/scripts/lesson-synthesis.js +196 -0
- package/scripts/license.js +50 -0
- package/scripts/local-model-profile.js +384 -0
- package/scripts/markdown-escape.js +12 -0
- package/scripts/marketing-experiment.js +671 -0
- package/scripts/mcp-config.js +149 -0
- package/scripts/mcp-policy.js +99 -0
- package/scripts/memalign-recall.js +111 -0
- package/scripts/memory-firewall.js +222 -0
- package/scripts/memory-migration.js +296 -0
- package/scripts/meta-policy.js +190 -0
- package/scripts/metered-billing.js +16 -0
- package/scripts/model-tier-router.js +301 -0
- package/scripts/money-watcher.js +71 -0
- package/scripts/multi-hop-recall.js +240 -0
- package/scripts/natural-language-harness.js +330 -0
- package/scripts/obsidian-export.js +713 -0
- package/scripts/operational-dashboard.js +103 -0
- package/scripts/operational-summary.js +93 -0
- package/scripts/optimize-context.js +17 -0
- package/scripts/org-dashboard.js +201 -0
- package/scripts/partner-orchestration.js +146 -0
- package/scripts/per-step-scoring.js +165 -0
- package/scripts/perplexity-marketing.js +466 -0
- package/scripts/pii-scanner.js +153 -0
- package/scripts/plan-gate.js +154 -0
- package/scripts/post-everywhere.js +308 -0
- package/scripts/post-to-x-retry.sh +22 -0
- package/scripts/post-to-x.js +369 -0
- package/scripts/pr-manager.js +236 -0
- package/scripts/predictive-insights.js +356 -0
- package/scripts/principle-extractor.js +162 -0
- package/scripts/pro-features.js +40 -0
- package/scripts/pro-local-dashboard.js +174 -0
- package/scripts/problem-detail.js +53 -0
- package/scripts/product-feedback.js +134 -0
- package/scripts/profile-router.js +245 -0
- package/scripts/prompt-dlp.js +221 -0
- package/scripts/prompt-guard.js +83 -0
- package/scripts/prove-adapters.js +863 -0
- package/scripts/prove-attribution.js +365 -0
- package/scripts/prove-automation.js +653 -0
- package/scripts/prove-autoresearch.js +304 -0
- package/scripts/prove-claim-verification.js +277 -0
- package/scripts/prove-cloudflare-sandbox.js +163 -0
- package/scripts/prove-data-pipeline.js +410 -0
- package/scripts/prove-data-quality.js +227 -0
- package/scripts/prove-evolution.js +352 -0
- package/scripts/prove-harnesses.js +287 -0
- package/scripts/prove-intelligence.js +259 -0
- package/scripts/prove-lancedb.js +371 -0
- package/scripts/prove-local-intelligence.js +342 -0
- package/scripts/prove-loop-closure.js +263 -0
- package/scripts/prove-predictive-insights.js +357 -0
- package/scripts/prove-runtime.js +350 -0
- package/scripts/prove-seo-gsd.js +234 -0
- package/scripts/prove-settings.js +279 -0
- package/scripts/prove-subway-upgrades.js +277 -0
- package/scripts/prove-tessl.js +229 -0
- package/scripts/prove-training-export.js +327 -0
- package/scripts/prove-workflow-contract.js +116 -0
- package/scripts/prove-xmemory.js +332 -0
- package/scripts/publish-decision.js +133 -0
- package/scripts/pulse.js +80 -0
- package/scripts/rate-limiter.js +125 -0
- package/scripts/reddit-dm-outreach.js +182 -0
- package/scripts/reddit-monitor-cron.sh +26 -0
- package/scripts/reflector-agent.js +221 -0
- package/scripts/reminder-engine.js +132 -0
- package/scripts/revenue-status.js +472 -0
- package/scripts/risk-scorer.js +459 -0
- package/scripts/rlaif-self-audit.js +129 -0
- package/scripts/rlhf_session_start.sh +32 -0
- package/scripts/rubric-engine.js +230 -0
- package/scripts/schedule-manager.js +251 -0
- package/scripts/secret-scanner.js +414 -0
- package/scripts/self-heal.js +147 -0
- package/scripts/self-healing-check.js +188 -0
- package/scripts/semantic-layer.js +98 -0
- package/scripts/seo-gsd.js +1153 -0
- package/scripts/settings-hierarchy.js +214 -0
- package/scripts/shieldcortex-memory-firewall-runner.mjs +53 -0
- package/scripts/skill-exporter.js +262 -0
- package/scripts/skill-generator.js +446 -0
- package/scripts/skill-materializer.js +134 -0
- package/scripts/skill-packs.js +136 -0
- package/scripts/skill-proposer.js +99 -0
- package/scripts/skill-quality-tracker.js +282 -0
- package/scripts/slo-alert-engine.js +14 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/social-analytics/db/schema.sql +32 -0
- package/scripts/social-analytics/db/social-analytics.db +0 -0
- package/scripts/social-analytics/digest.js +256 -0
- package/scripts/social-analytics/generate-instagram-card.js +97 -0
- package/scripts/social-analytics/instagram-thumbgate-post.js +107 -0
- package/scripts/social-analytics/load-env.js +46 -0
- package/scripts/social-analytics/mcp-server.js +289 -0
- package/scripts/social-analytics/normalizer.js +580 -0
- package/scripts/social-analytics/notify.js +162 -0
- package/scripts/social-analytics/poll-all.js +92 -0
- package/scripts/social-analytics/pollers/github.js +195 -0
- package/scripts/social-analytics/pollers/instagram.js +253 -0
- package/scripts/social-analytics/pollers/linkedin.js +330 -0
- package/scripts/social-analytics/pollers/plausible.js +247 -0
- package/scripts/social-analytics/pollers/reddit.js +306 -0
- package/scripts/social-analytics/pollers/threads.js +233 -0
- package/scripts/social-analytics/pollers/tiktok.js +203 -0
- package/scripts/social-analytics/pollers/x.js +227 -0
- package/scripts/social-analytics/pollers/youtube.js +304 -0
- package/scripts/social-analytics/pollers/zernio.js +183 -0
- package/scripts/social-analytics/publish-instagram-thumbgate.js +98 -0
- package/scripts/social-analytics/publish-thumbgate-launch.js +316 -0
- package/scripts/social-analytics/publishers/devto.js +122 -0
- package/scripts/social-analytics/publishers/instagram.js +317 -0
- package/scripts/social-analytics/publishers/linkedin.js +294 -0
- package/scripts/social-analytics/publishers/reddit.js +390 -0
- package/scripts/social-analytics/publishers/threads.js +275 -0
- package/scripts/social-analytics/publishers/tiktok.js +217 -0
- package/scripts/social-analytics/publishers/x.js +259 -0
- package/scripts/social-analytics/publishers/youtube.js +223 -0
- package/scripts/social-analytics/publishers/zernio.js +378 -0
- package/scripts/social-analytics/run-digest.js +34 -0
- package/scripts/social-analytics/store.js +257 -0
- package/scripts/social-analytics/utm.js +143 -0
- package/scripts/social-pipeline.js +2628 -0
- package/scripts/social-quality-gate.js +18 -0
- package/scripts/social-reply-monitor.js +445 -0
- package/scripts/status-dashboard.js +155 -0
- package/scripts/statusline-lesson.js +16 -0
- package/scripts/statusline-tower.js +8 -0
- package/scripts/statusline.sh +116 -0
- package/scripts/stripe-live-status.js +115 -0
- package/scripts/subagent-profiles.js +79 -0
- package/scripts/sync-gh-secrets-from-env.sh +70 -0
- package/scripts/sync-github-about.js +52 -0
- package/scripts/sync-version.js +447 -0
- package/scripts/synthetic-dpo.js +234 -0
- package/scripts/telemetry-analytics.js +821 -0
- package/scripts/tessl-export.js +371 -0
- package/scripts/test-coverage.js +120 -0
- package/scripts/thompson-sampling.js +417 -0
- package/scripts/thumbgate-search.js +189 -0
- package/scripts/tool-kpi-tracker.js +12 -0
- package/scripts/tool-registry.js +811 -0
- package/scripts/train_from_feedback.py +933 -0
- package/scripts/user-profile.js +78 -0
- package/scripts/validate-feedback.js +581 -0
- package/scripts/validate-workflow-contract.js +287 -0
- package/scripts/vector-store.js +197 -0
- package/scripts/verification-loop.js +291 -0
- package/scripts/verify-obsidian-setup.sh +269 -0
- package/scripts/verify-run.js +269 -0
- package/scripts/webhook-delivery.js +62 -0
- package/scripts/weekly-auto-post.js +124 -0
- package/scripts/workflow-runs.js +154 -0
- package/scripts/workflow-sprint-intake.js +475 -0
- package/scripts/workspace-evolver.js +374 -0
- package/scripts/x-autonomous-marketing.js +139 -0
- package/scripts/xmemory-lite.js +405 -0
- package/skills/agent-memory/SKILL.md +97 -0
- package/skills/rlhf-feedback/SKILL.md +49 -0
- package/skills/solve-architecture-autonomy/SKILL.md +17 -0
- package/skills/solve-architecture-autonomy/tool.js +33 -0
- package/skills/thumbgate/SKILL.md +114 -0
- package/src/api/server.js +4206 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
7
|
+
const DEFAULT_DIGEST_PATH = path.join(REPO_ROOT, '.artifacts', 'social', 'digests', 'digest.json');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sends a formatted digest to Slack via an incoming webhook.
|
|
11
|
+
* Skips silently if SLACK_WEBHOOK_URL is not set.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} digest
|
|
14
|
+
* @returns {Promise<void>}
|
|
15
|
+
*/
|
|
16
|
+
async function sendSlackDigest(digest) {
|
|
17
|
+
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
|
|
18
|
+
|
|
19
|
+
if (!webhookUrl) {
|
|
20
|
+
console.log('Slack webhook not configured, skipping notification');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { period, summary, top_content } = digest;
|
|
25
|
+
|
|
26
|
+
const followerLines = Object.entries(summary.follower_delta)
|
|
27
|
+
.map(([platform, delta]) => {
|
|
28
|
+
const sign = delta >= 0 ? '+' : '';
|
|
29
|
+
return `• ${platform}: ${sign}${delta}`;
|
|
30
|
+
})
|
|
31
|
+
.join('\n');
|
|
32
|
+
|
|
33
|
+
const topThree = (top_content || []).slice(0, 3);
|
|
34
|
+
const topLines = topThree.length
|
|
35
|
+
? topThree
|
|
36
|
+
.map((item, idx) => {
|
|
37
|
+
const urlPart = item.post_url ? ` — <${item.post_url}|link>` : '';
|
|
38
|
+
return `${idx + 1}. *${item.platform}* \`${item.post_id}\` — ${item.total_engagement} eng / ${(item.impressions || 0).toLocaleString()} impressions${urlPart}`;
|
|
39
|
+
})
|
|
40
|
+
.join('\n')
|
|
41
|
+
: '_No content recorded._';
|
|
42
|
+
|
|
43
|
+
const payload = {
|
|
44
|
+
blocks: [
|
|
45
|
+
{
|
|
46
|
+
type: 'header',
|
|
47
|
+
text: {
|
|
48
|
+
type: 'plain_text',
|
|
49
|
+
text: `Weekly Social Digest (${period.start} → ${period.end})`,
|
|
50
|
+
emoji: false,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: 'section',
|
|
55
|
+
text: {
|
|
56
|
+
type: 'mrkdwn',
|
|
57
|
+
text: [
|
|
58
|
+
`*Impressions:* ${summary.total_impressions.toLocaleString()}`,
|
|
59
|
+
`*Likes:* ${summary.total_likes.toLocaleString()}`,
|
|
60
|
+
`*Comments:* ${summary.total_comments.toLocaleString()}`,
|
|
61
|
+
`*Shares:* ${summary.total_shares.toLocaleString()}`,
|
|
62
|
+
`*Engagement Rate:* ${summary.engagement_rate}`,
|
|
63
|
+
'',
|
|
64
|
+
'*Follower Delta:*',
|
|
65
|
+
followerLines,
|
|
66
|
+
].join('\n'),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: 'section',
|
|
71
|
+
text: {
|
|
72
|
+
type: 'mrkdwn',
|
|
73
|
+
text: `*Top 3 Content:*\n${topLines}`,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const response = await fetch(webhookUrl, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify(payload),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
const body = await response.text();
|
|
87
|
+
throw new Error(`Slack webhook failed: ${response.status} ${response.statusText} — ${body}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(`Slack digest sent for period ${period.start} → ${period.end}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns a formatted string for terminal output. Always works — no Slack needed.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} digest
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
function formatDigestForConsole(digest) {
|
|
100
|
+
const { period, summary, top_content } = digest;
|
|
101
|
+
|
|
102
|
+
const lines = [];
|
|
103
|
+
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push(`Weekly Social Digest: ${period.start} -> ${period.end}`);
|
|
106
|
+
lines.push('='.repeat(60));
|
|
107
|
+
lines.push('');
|
|
108
|
+
lines.push('SUMMARY');
|
|
109
|
+
lines.push(` Impressions : ${summary.total_impressions.toLocaleString()}`);
|
|
110
|
+
lines.push(` Likes : ${summary.total_likes.toLocaleString()}`);
|
|
111
|
+
lines.push(` Comments : ${summary.total_comments.toLocaleString()}`);
|
|
112
|
+
lines.push(` Shares : ${summary.total_shares.toLocaleString()}`);
|
|
113
|
+
lines.push(` Engagement Rate: ${summary.engagement_rate}`);
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push('FOLLOWER DELTA');
|
|
116
|
+
for (const [platform, delta] of Object.entries(summary.follower_delta)) {
|
|
117
|
+
const sign = delta >= 0 ? '+' : '';
|
|
118
|
+
lines.push(` ${platform.padEnd(12)}: ${sign}${delta}`);
|
|
119
|
+
}
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push('TOP CONTENT');
|
|
122
|
+
|
|
123
|
+
const topThree = (top_content || []).slice(0, 3);
|
|
124
|
+
if (topThree.length === 0) {
|
|
125
|
+
lines.push(' No content recorded in this period.');
|
|
126
|
+
} else {
|
|
127
|
+
topThree.forEach((item, idx) => {
|
|
128
|
+
lines.push(` ${idx + 1}. [${item.platform}] ${item.post_id}`);
|
|
129
|
+
lines.push(` Engagement: ${item.total_engagement} Impressions: ${(item.impressions || 0).toLocaleString()}`);
|
|
130
|
+
if (item.post_url) {
|
|
131
|
+
lines.push(` URL: ${item.post_url}`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
lines.push('');
|
|
137
|
+
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
sendSlackDigest,
|
|
143
|
+
formatDigestForConsole,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Run as main: load latest digest and send.
|
|
147
|
+
if (require.main === module) {
|
|
148
|
+
if (!fs.existsSync(DEFAULT_DIGEST_PATH)) {
|
|
149
|
+
console.error(`Digest file not found: ${DEFAULT_DIGEST_PATH}`);
|
|
150
|
+
console.error('Run scripts/social-analytics/digest.js first to generate it.');
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const digest = JSON.parse(fs.readFileSync(DEFAULT_DIGEST_PATH, 'utf8'));
|
|
155
|
+
|
|
156
|
+
console.log(formatDigestForConsole(digest));
|
|
157
|
+
|
|
158
|
+
sendSlackDigest(digest).catch((err) => {
|
|
159
|
+
console.error('Failed to send Slack notification:', err.message);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { loadLocalEnv } = require('./load-env');
|
|
5
|
+
|
|
6
|
+
loadLocalEnv({ envPath: path.resolve(__dirname, '..', '..', '.env') });
|
|
7
|
+
|
|
8
|
+
const { initDb } = require('./store');
|
|
9
|
+
|
|
10
|
+
const POLLERS = [
|
|
11
|
+
{ name: 'github', module: './pollers/github', envRequired: ['GITHUB_TOKEN'] },
|
|
12
|
+
{ name: 'instagram', module: './pollers/instagram', envRequired: ['INSTAGRAM_ACCESS_TOKEN', 'INSTAGRAM_USER_ID'] },
|
|
13
|
+
{ name: 'tiktok', module: './pollers/tiktok', envRequired: ['TIKTOK_ACCESS_TOKEN'] },
|
|
14
|
+
{ name: 'linkedin', module: './pollers/linkedin', envRequired: ['LINKEDIN_ACCESS_TOKEN', 'LINKEDIN_PERSON_URN'] },
|
|
15
|
+
{ name: 'x', module: './pollers/x', envRequired: ['X_BEARER_TOKEN', 'X_USER_ID'] },
|
|
16
|
+
{ name: 'reddit', module: './pollers/reddit', envRequired: ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET', 'REDDIT_USERNAME', 'REDDIT_PASSWORD'] },
|
|
17
|
+
{ name: 'threads', module: './pollers/threads', envRequired: ['THREADS_ACCESS_TOKEN', 'THREADS_USER_ID'] },
|
|
18
|
+
{ name: 'youtube', module: './pollers/youtube', envRequired: ['YOUTUBE_API_KEY', 'YOUTUBE_CHANNEL_ID'] },
|
|
19
|
+
{ name: 'plausible', module: './pollers/plausible', envRequired: ['PLAUSIBLE_API_KEY', 'PLAUSIBLE_SITE_ID'] },
|
|
20
|
+
{ name: 'zernio', module: './pollers/zernio', envRequired: ['ZERNIO_API_KEY'] },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function hasEnv(keys) {
|
|
24
|
+
return keys.every((k) => process.env[k]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function pollAll(options = {}) {
|
|
28
|
+
const db = initDb(options.dbPath);
|
|
29
|
+
const results = { succeeded: [], skipped: [], failed: [] };
|
|
30
|
+
|
|
31
|
+
for (const poller of POLLERS) {
|
|
32
|
+
if (!hasEnv(poller.envRequired)) {
|
|
33
|
+
console.log(`⏭ ${poller.name}: skipped (missing env: ${poller.envRequired.filter((k) => !process.env[k]).join(', ')})`);
|
|
34
|
+
results.skipped.push(poller.name);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const mod = require(poller.module);
|
|
40
|
+
const fn = mod[`poll${poller.name.charAt(0).toUpperCase()}${poller.name.slice(1)}`]
|
|
41
|
+
|| mod.pollGitHub || mod.pollInstagram || mod.pollTikTok
|
|
42
|
+
|| mod.pollLinkedIn || mod.pollX || mod.pollReddit
|
|
43
|
+
|| mod.pollThreads || mod.pollPlausible || mod.pollZernio;
|
|
44
|
+
|
|
45
|
+
if (!fn) {
|
|
46
|
+
console.log(`⚠ ${poller.name}: no poll function found in module`);
|
|
47
|
+
results.skipped.push(poller.name);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(`🔄 ${poller.name}: polling...`);
|
|
52
|
+
await fn(db);
|
|
53
|
+
console.log(`✅ ${poller.name}: complete`);
|
|
54
|
+
results.succeeded.push(poller.name);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error(`❌ ${poller.name}: ${err.message}`);
|
|
57
|
+
results.failed.push({ name: poller.name, error: err.message });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
db.close();
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function main() {
|
|
66
|
+
console.log('=== Social Analytics Poll All ===');
|
|
67
|
+
console.log(`Time: ${new Date().toISOString()}`);
|
|
68
|
+
console.log('');
|
|
69
|
+
|
|
70
|
+
const results = await pollAll();
|
|
71
|
+
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log('=== Summary ===');
|
|
74
|
+
console.log(`Succeeded: ${results.succeeded.join(', ') || 'none'}`);
|
|
75
|
+
console.log(`Skipped: ${results.skipped.join(', ') || 'none'}`);
|
|
76
|
+
console.log(`Failed: ${results.failed.map((f) => f.name).join(', ') || 'none'}`);
|
|
77
|
+
|
|
78
|
+
// Exit non-zero only if nothing succeeded AND there were failures.
|
|
79
|
+
// Partial success (some pollers skipped/failed but at least one succeeded) is OK.
|
|
80
|
+
if (results.succeeded.length === 0 && results.failed.length > 0) {
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (require.main === module) {
|
|
86
|
+
main().catch((err) => {
|
|
87
|
+
console.error(err);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { pollAll, POLLERS };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* github.js
|
|
5
|
+
* Polls GitHub REST API for repository traffic data.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: GitHub only retains traffic data for 14 days.
|
|
8
|
+
* This poller must run at least daily to avoid gaps.
|
|
9
|
+
*
|
|
10
|
+
* Required env vars:
|
|
11
|
+
* GITHUB_TOKEN — personal access token with repo scope (required)
|
|
12
|
+
* GITHUB_REPO — owner/repo slug (default: IgorGanapolsky/ThumbGate)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const GITHUB_API_BASE = 'https://api.github.com';
|
|
18
|
+
const DEFAULT_REPO = 'IgorGanapolsky/ThumbGate';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build standard GitHub API request headers.
|
|
22
|
+
* @param {string} token
|
|
23
|
+
* @returns {Record<string, string>}
|
|
24
|
+
*/
|
|
25
|
+
function buildHeaders(token) {
|
|
26
|
+
return {
|
|
27
|
+
Authorization: `Bearer ${token}`,
|
|
28
|
+
Accept: 'application/vnd.github+json',
|
|
29
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
30
|
+
'User-Agent': 'social-analytics-poller/1.0',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetch a single GitHub API endpoint and return parsed JSON.
|
|
36
|
+
* Throws on non-2xx responses.
|
|
37
|
+
* @param {string} url
|
|
38
|
+
* @param {string} token
|
|
39
|
+
* @returns {Promise<unknown>}
|
|
40
|
+
*/
|
|
41
|
+
async function ghFetch(url, token) {
|
|
42
|
+
const res = await fetch(url, { headers: buildHeaders(token) });
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const body = await res.text().catch(() => '');
|
|
45
|
+
throw new Error(`GitHub API ${res.status} for ${url}: ${body}`);
|
|
46
|
+
}
|
|
47
|
+
return res.json();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fetch all traffic data for a repository.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} token - GitHub personal access token
|
|
54
|
+
* @param {string} [repo] - owner/repo slug
|
|
55
|
+
* @returns {Promise<{
|
|
56
|
+
* views: object,
|
|
57
|
+
* clones: object,
|
|
58
|
+
* referrers: object[],
|
|
59
|
+
* repoStats: object
|
|
60
|
+
* }>}
|
|
61
|
+
*/
|
|
62
|
+
async function fetchGitHubTraffic(token, repo) {
|
|
63
|
+
if (!token) {
|
|
64
|
+
throw new Error('GITHUB_TOKEN is required');
|
|
65
|
+
}
|
|
66
|
+
const slug = repo || DEFAULT_REPO;
|
|
67
|
+
const base = `${GITHUB_API_BASE}/repos/${slug}`;
|
|
68
|
+
|
|
69
|
+
console.log(`[github] Fetching traffic for ${slug}`);
|
|
70
|
+
|
|
71
|
+
const [views, clones, referrers, repoStats] = await Promise.all([
|
|
72
|
+
ghFetch(`${base}/traffic/views`, token),
|
|
73
|
+
ghFetch(`${base}/traffic/clones`, token),
|
|
74
|
+
ghFetch(`${base}/traffic/popular/referrers`, token),
|
|
75
|
+
ghFetch(base, token),
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
console.log(
|
|
79
|
+
`[github] views=${views.count} uniques=${views.uniques} ` +
|
|
80
|
+
`clones=${clones.count} stars=${repoStats.stargazers_count} ` +
|
|
81
|
+
`forks=${repoStats.forks_count}`
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return { views, clones, referrers, repoStats };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Main polling entry point.
|
|
89
|
+
*
|
|
90
|
+
* Fetches GitHub traffic, normalizes each daily view entry, and upserts
|
|
91
|
+
* into the analytics database. Also records a follower_snapshot using
|
|
92
|
+
* stargazers_count as the follower-count equivalent.
|
|
93
|
+
*
|
|
94
|
+
* @param {import('better-sqlite3').Database} db
|
|
95
|
+
* @returns {Promise<void>}
|
|
96
|
+
*/
|
|
97
|
+
async function pollGitHub(db) {
|
|
98
|
+
const token = process.env.GITHUB_TOKEN;
|
|
99
|
+
if (!token) {
|
|
100
|
+
throw new Error('GITHUB_TOKEN environment variable is required');
|
|
101
|
+
}
|
|
102
|
+
const repo = process.env.GITHUB_REPO || DEFAULT_REPO;
|
|
103
|
+
|
|
104
|
+
const { views, clones, referrers, repoStats } = await fetchGitHubTraffic(token, repo);
|
|
105
|
+
|
|
106
|
+
// Lazy-require sibling modules so they can be built/tested independently.
|
|
107
|
+
const { normalizeGitHubMetric: normalizeMetric } = require('../normalizer');
|
|
108
|
+
const { upsertMetric, upsertFollowerSnapshot } = require('../store');
|
|
109
|
+
|
|
110
|
+
const repoName = repo.split('/').pop();
|
|
111
|
+
const fetchedAt = new Date().toISOString();
|
|
112
|
+
|
|
113
|
+
// Build a date-keyed map of clone counts for merge into view records.
|
|
114
|
+
const clonesByDate = {};
|
|
115
|
+
if (Array.isArray(clones.views)) {
|
|
116
|
+
for (const entry of clones.views) {
|
|
117
|
+
const date = entry.timestamp.slice(0, 10);
|
|
118
|
+
clonesByDate[date] = (clonesByDate[date] || 0) + entry.count;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const upsertedDates = [];
|
|
123
|
+
|
|
124
|
+
for (const entry of views.views || []) {
|
|
125
|
+
const metricDate = entry.timestamp.slice(0, 10);
|
|
126
|
+
const cloneCount = clonesByDate[metricDate] || 0;
|
|
127
|
+
|
|
128
|
+
const record = {
|
|
129
|
+
platform: 'github',
|
|
130
|
+
content_type: 'repo',
|
|
131
|
+
post_id: repoName,
|
|
132
|
+
post_url: `https://github.com/${repo}`,
|
|
133
|
+
metric_date: metricDate,
|
|
134
|
+
impressions: entry.count,
|
|
135
|
+
reach: entry.uniques,
|
|
136
|
+
clicks: cloneCount,
|
|
137
|
+
likes: repoStats.stargazers_count,
|
|
138
|
+
shares: repoStats.forks_count,
|
|
139
|
+
comments: 0,
|
|
140
|
+
saves: 0,
|
|
141
|
+
video_views: 0,
|
|
142
|
+
followers_delta: 0,
|
|
143
|
+
extra_json: JSON.stringify({ referrers }),
|
|
144
|
+
fetched_at: fetchedAt,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
upsertMetric(db, record);
|
|
148
|
+
upsertedDates.push(metricDate);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(`[github] Upserted ${upsertedDates.length} daily metric records: ${upsertedDates.join(', ')}`);
|
|
152
|
+
|
|
153
|
+
// Record follower snapshot (stars as follower-count equivalent).
|
|
154
|
+
const snapshotDate = fetchedAt.slice(0, 10);
|
|
155
|
+
upsertFollowerSnapshot(db, {
|
|
156
|
+
platform: 'github',
|
|
157
|
+
follower_count: repoStats.stargazers_count,
|
|
158
|
+
snapshot_date: snapshotDate,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
console.log(
|
|
162
|
+
`[github] Follower snapshot upserted: platform=github ` +
|
|
163
|
+
`follower_count=${repoStats.stargazers_count} date=${snapshotDate}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { fetchGitHubTraffic, pollGitHub };
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Stand-alone execution
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
if (require.main === module) {
|
|
173
|
+
const Database = require('better-sqlite3');
|
|
174
|
+
const schemaPath = path.join(__dirname, '..', 'db', 'schema.sql');
|
|
175
|
+
const dbPath = path.join(__dirname, '..', 'db', 'social-analytics.db');
|
|
176
|
+
const fs = require('fs');
|
|
177
|
+
|
|
178
|
+
const db = new Database(dbPath);
|
|
179
|
+
db.pragma('busy_timeout = 3000');
|
|
180
|
+
db.pragma('journal_mode = WAL');
|
|
181
|
+
|
|
182
|
+
const schema = fs.readFileSync(schemaPath, 'utf8');
|
|
183
|
+
db.exec(schema);
|
|
184
|
+
|
|
185
|
+
pollGitHub(db)
|
|
186
|
+
.then(() => {
|
|
187
|
+
console.log('[github] Poll complete.');
|
|
188
|
+
db.close();
|
|
189
|
+
})
|
|
190
|
+
.catch((err) => {
|
|
191
|
+
console.error('[github] Poll failed:', err.message);
|
|
192
|
+
db.close();
|
|
193
|
+
process.exit(1);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Instagram engagement poller using the Instagram Graph API v21.0.
|
|
5
|
+
*
|
|
6
|
+
* Required environment variables:
|
|
7
|
+
* INSTAGRAM_ACCESS_TOKEN — long-lived page/user access token
|
|
8
|
+
* INSTAGRAM_USER_ID — Instagram business/creator account user ID
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { normalize } = require('../normalizer');
|
|
12
|
+
const { upsertMetric, upsertFollowerSnapshot, initDb } = require('../store');
|
|
13
|
+
|
|
14
|
+
const GRAPH_BASE = 'https://graph.facebook.com/v21.0';
|
|
15
|
+
|
|
16
|
+
// Map Instagram media_type to our normalised content_type.
|
|
17
|
+
const MEDIA_TYPE_MAP = {
|
|
18
|
+
CAROUSEL_ALBUM: 'carousel',
|
|
19
|
+
IMAGE: 'image',
|
|
20
|
+
VIDEO: 'reel',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fetches the most recent 25 media items for a user.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} token - Instagram access token.
|
|
27
|
+
* @param {string} userId - Instagram user ID.
|
|
28
|
+
* @returns {Promise<object[]>} Array of media objects.
|
|
29
|
+
*/
|
|
30
|
+
async function fetchInstagramMedia(token, userId) {
|
|
31
|
+
const fields = [
|
|
32
|
+
'id',
|
|
33
|
+
'caption',
|
|
34
|
+
'media_type',
|
|
35
|
+
'permalink',
|
|
36
|
+
'timestamp',
|
|
37
|
+
'like_count',
|
|
38
|
+
'comments_count',
|
|
39
|
+
'children{media_type,media_url}',
|
|
40
|
+
].join(',');
|
|
41
|
+
|
|
42
|
+
const url =
|
|
43
|
+
`${GRAPH_BASE}/${userId}/media` +
|
|
44
|
+
`?fields=${fields}&limit=25&access_token=${token}`;
|
|
45
|
+
|
|
46
|
+
const res = await fetch(url);
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const body = await res.text();
|
|
49
|
+
throw new Error(`fetchInstagramMedia HTTP ${res.status}: ${body}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const json = await res.json();
|
|
53
|
+
if (json.error) {
|
|
54
|
+
throw new Error(`fetchInstagramMedia API error: ${JSON.stringify(json.error)}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return json.data ?? [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fetches insights for a single media item.
|
|
62
|
+
* Some media types (e.g. stories) do not support all metrics — errors are
|
|
63
|
+
* caught and logged so polling continues for other posts.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} token - Instagram access token.
|
|
66
|
+
* @param {string} mediaId - Instagram media ID.
|
|
67
|
+
* @returns {Promise<object>} Map of metric name -> value (defaults to 0 on error).
|
|
68
|
+
*/
|
|
69
|
+
async function fetchMediaInsights(token, mediaId) {
|
|
70
|
+
const metrics = 'impressions,reach,saved,shares';
|
|
71
|
+
const url =
|
|
72
|
+
`${GRAPH_BASE}/${mediaId}/insights` +
|
|
73
|
+
`?metric=${metrics}&access_token=${token}`;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(url);
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
const body = await res.text();
|
|
79
|
+
console.warn(`fetchMediaInsights HTTP ${res.status} for ${mediaId}: ${body}`);
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const json = await res.json();
|
|
84
|
+
if (json.error) {
|
|
85
|
+
// Some media types (Reels, Stories older than 24 h, etc.) return errors
|
|
86
|
+
// for certain metrics. Log and return an empty result rather than throw.
|
|
87
|
+
console.warn(
|
|
88
|
+
`fetchMediaInsights API warning for ${mediaId}: ${JSON.stringify(json.error)}`
|
|
89
|
+
);
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Reshape array of { name, values } -> flat { name: value }
|
|
94
|
+
const result = {};
|
|
95
|
+
for (const item of json.data ?? []) {
|
|
96
|
+
// Instagram returns period-scoped values; take the first value entry.
|
|
97
|
+
const val = Array.isArray(item.values) ? item.values[0]?.value : item.value;
|
|
98
|
+
result[item.name] = typeof val === 'number' ? val : 0;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.warn(`fetchMediaInsights error for ${mediaId}: ${err.message}`);
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fetches account-level insights: reach, impressions, follower_count.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} token - Instagram access token.
|
|
111
|
+
* @param {string} userId - Instagram user ID.
|
|
112
|
+
* @param {string} since - Unix timestamp string (start of window).
|
|
113
|
+
* @param {string} until - Unix timestamp string (end of window).
|
|
114
|
+
* @returns {Promise<object[]>} Raw data array from the API response.
|
|
115
|
+
*/
|
|
116
|
+
async function fetchAccountInsights(token, userId, since, until) {
|
|
117
|
+
const metrics = 'reach,impressions,follower_count';
|
|
118
|
+
const url =
|
|
119
|
+
`${GRAPH_BASE}/${userId}/insights` +
|
|
120
|
+
`?metric=${metrics}&period=day&since=${since}&until=${until}&access_token=${token}`;
|
|
121
|
+
|
|
122
|
+
const res = await fetch(url);
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
const body = await res.text();
|
|
125
|
+
throw new Error(`fetchAccountInsights HTTP ${res.status}: ${body}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const json = await res.json();
|
|
129
|
+
if (json.error) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`fetchAccountInsights API error: ${JSON.stringify(json.error)}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return json.data ?? [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Main entry point. Polls Instagram for recent media and account insights,
|
|
140
|
+
* normalises the data, and upserts it into the local SQLite database.
|
|
141
|
+
*
|
|
142
|
+
* @param {import('better-sqlite3').Database} db - Initialised db instance.
|
|
143
|
+
*/
|
|
144
|
+
async function pollInstagram(db) {
|
|
145
|
+
const token = process.env.INSTAGRAM_ACCESS_TOKEN;
|
|
146
|
+
const userId = process.env.INSTAGRAM_USER_ID;
|
|
147
|
+
|
|
148
|
+
if (!token) throw new Error('INSTAGRAM_ACCESS_TOKEN is not set');
|
|
149
|
+
if (!userId) throw new Error('INSTAGRAM_USER_ID is not set');
|
|
150
|
+
|
|
151
|
+
const fetchedAt = new Date().toISOString();
|
|
152
|
+
const today = fetchedAt.slice(0, 10);
|
|
153
|
+
|
|
154
|
+
console.log(`[instagram] Fetching media for user ${userId}…`);
|
|
155
|
+
const mediaItems = await fetchInstagramMedia(token, userId);
|
|
156
|
+
console.log(`[instagram] Got ${mediaItems.length} media items`);
|
|
157
|
+
|
|
158
|
+
for (const item of mediaItems) {
|
|
159
|
+
const mediaId = item.id;
|
|
160
|
+
|
|
161
|
+
// Fetch per-post insights (best-effort; some types may not support all).
|
|
162
|
+
const insights = await fetchMediaInsights(token, mediaId);
|
|
163
|
+
|
|
164
|
+
const rawContentType = MEDIA_TYPE_MAP[item.media_type] ?? 'image';
|
|
165
|
+
|
|
166
|
+
// Build a raw record compatible with the normalizer contract.
|
|
167
|
+
const raw = {
|
|
168
|
+
platform: 'instagram',
|
|
169
|
+
content_type: rawContentType,
|
|
170
|
+
post_id: mediaId,
|
|
171
|
+
post_url: item.permalink ?? null,
|
|
172
|
+
published_at: item.timestamp ?? null,
|
|
173
|
+
metric_date: today,
|
|
174
|
+
impressions: insights.impressions ?? 0,
|
|
175
|
+
reach: insights.reach ?? 0,
|
|
176
|
+
likes: item.like_count ?? 0,
|
|
177
|
+
comments: item.comments_count ?? 0,
|
|
178
|
+
shares: insights.shares ?? 0,
|
|
179
|
+
saves: insights.saved ?? 0,
|
|
180
|
+
clicks: 0,
|
|
181
|
+
video_views: 0,
|
|
182
|
+
followers_delta: 0,
|
|
183
|
+
fetched_at: fetchedAt,
|
|
184
|
+
extra_json: {
|
|
185
|
+
media_type: item.media_type,
|
|
186
|
+
caption_snippet: (item.caption ?? '').slice(0, 120),
|
|
187
|
+
children_count: item.children?.data?.length ?? null,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const normalised = normalize(raw);
|
|
192
|
+
upsertMetric(db, normalised);
|
|
193
|
+
console.log(`[instagram] Upserted metric for post ${mediaId} (${rawContentType})`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Fetch account-level follower count for today's snapshot.
|
|
197
|
+
const nowTs = Math.floor(Date.now() / 1000);
|
|
198
|
+
const dayAgoTs = nowTs - 86400;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
console.log('[instagram] Fetching account insights for follower snapshot…');
|
|
202
|
+
const accountData = await fetchAccountInsights(
|
|
203
|
+
token,
|
|
204
|
+
userId,
|
|
205
|
+
String(dayAgoTs),
|
|
206
|
+
String(nowTs)
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Find the follower_count series and take the latest value.
|
|
210
|
+
const followerSeries = accountData.find((d) => d.name === 'follower_count');
|
|
211
|
+
if (followerSeries) {
|
|
212
|
+
const latestEntry = (followerSeries.values ?? []).slice(-1)[0];
|
|
213
|
+
const followerCount = latestEntry?.value ?? 0;
|
|
214
|
+
|
|
215
|
+
upsertFollowerSnapshot(db, {
|
|
216
|
+
platform: 'instagram',
|
|
217
|
+
follower_count: followerCount,
|
|
218
|
+
snapshot_date: today,
|
|
219
|
+
});
|
|
220
|
+
console.log(`[instagram] Upserted follower snapshot: ${followerCount} followers`);
|
|
221
|
+
} else {
|
|
222
|
+
console.warn('[instagram] follower_count not found in account insights response');
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
// Account insights failure should not abort per-post metric storage.
|
|
226
|
+
console.warn(`[instagram] Account insights error (non-fatal): ${err.message}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log('[instagram] Poll complete.');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
fetchInstagramMedia,
|
|
234
|
+
fetchMediaInsights,
|
|
235
|
+
fetchAccountInsights,
|
|
236
|
+
pollInstagram,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Allow running directly: node scripts/social-analytics/pollers/instagram.js
|
|
240
|
+
if (require.main === module) {
|
|
241
|
+
(async () => {
|
|
242
|
+
const { initDb } = require('../store');
|
|
243
|
+
const db = initDb();
|
|
244
|
+
try {
|
|
245
|
+
await pollInstagram(db);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error('[instagram] Fatal error:', err.message);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
} finally {
|
|
250
|
+
db.close();
|
|
251
|
+
}
|
|
252
|
+
})();
|
|
253
|
+
}
|