How to create a pipeline where i take each Instagram post and extract its insights at 24 hour and 15 days and then at day 30.

Checking Date & Time
Let me clarify a few things before building this out for you.
Asking for Preferences

What tech stack / environment do you want the pipeline in? · Where should extracted insights be stored? · How do new posts enter the pipeline?

Good. The "how posts enter" is actually the most important design decision. Let me explain your two realistic options, then build the full pipeline.

How Posts Enter the Pipeline — Your Two Options

Option A: Webhook (Instagram Graph API)

Instagram can send a real-time webhook to your server the moment a new post is published. Your server receives the post_id, immediately records published_at, then schedules the 3 fetch jobs (24h, 15d, 30d) from that timestamp.
Pros: Instant, zero polling overhead Cons: Requires a publicly accessible HTTPS server, and webhook subscriptions need approval for some fields

Option B: Periodic Poll

A cron job runs every N minutes/hours, calls the Graph API to list recent media, compares against already-known posts, and registers any new ones.
Pros: Simpler to set up, no public server needed for ingestion Cons: Small detection lag, counts against your API rate limit

Recommended approach (and what I'll build below): Option A (Webhook) with a fallback poll, because it gives you the exact published_at timestamp which is critical for scheduling the precise 24h/15d/30d windows.

Full Pipeline Architecture

Instagram Post Published
        │
        ▼
[Webhook Receiver] ──────────────────────────────────┐
  POST /webhook/instagram                             │
  1. Verify hub.challenge                             │
  2. Extract media_id + timestamp                     │
  3. Insert into `scheduled_jobs` table:              │
     - job for T+24h                                  │
     - job for T+15d                                  │
     - job for T+30d                                  │
                                                      │
[Cron Job — runs every 5 min] ◄──────────────────────┘
  SELECT jobs WHERE run_at <= NOW() AND status='pending'
  For each job:
    1. Call Instagram Insights API
    2. Store results in your data store
    3. Mark job as 'done'

Code

File structure

instagram-insights-pipeline/
├── src/
│   ├── webhook.js        ← Express webhook receiver
│   ├── scheduler.js      ← Registers the 3 jobs when a post is detected
│   ├── runner.js         ← Cron job that processes due jobs
│   ├── instagram.js      ← Instagram Graph API client
│   └── db.js             ← DB adapter (swap for your store)
├── schema.sql            ← SQLite/Postgres schema for job queue
└── index.js              ← Entry point
Let me generate the complete code now.
Running Command

ls -la /home/daytona/workspace/ 2>/dev/null || echo "workspace empty or missing"

Running Command

mkdir -p /home/daytona/workspace/instagram-insights-pipeline/src

Writing File

~/instagram-insights-pipeline/schema.sql

-- ─────────────────────────────────────────────────────────────
-- Instagram Insights Pipeline — Job Queue Schema
-- Works with SQLite (default) or PostgreSQL (change AUTOINCREMENT
-- to SERIAL for Postgres, and INTEGER PRIMARY KEY → BIGSERIAL).
-- ─────────────────────────────────────────────────────────────

-- Tracks every Instagram post we need to monitor
CREATE TABLE IF NOT EXISTS posts (
  id              TEXT PRIMARY KEY,         -- Instagram media_id (string)
  account_id      TEXT NOT NULL,            -- IG Business Account ID
  permalink       TEXT,
  media_type      TEXT,                     -- IMAGE | VIDEO | CAROUSEL_ALBUM
  published_at    DATETIME NOT NULL,        -- Exact publish timestamp (UTC)
  created_at      DATETIME DEFAULT (datetime('now'))
);

-- One row per (post × window). Three rows per post: 24h, 15d, 30d.
CREATE TABLE IF NOT EXISTS insight_jobs (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  post_id         TEXT NOT NULL REFERENCES posts(id),
  window_label    TEXT NOT NULL,            -- '24h' | '15d' | '30d'
  run_at          DATETIME NOT NULL,        -- When to execute the fetch
  status          TEXT NOT NULL DEFAULT 'pending',  -- pending | running | done | failed
  attempts        INTEGER NOT NULL DEFAULT 0,
  last_error      TEXT,
  created_at      DATETIME DEFAULT (datetime('now')),
  updated_at      DATETIME DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_jobs_run_at_status ON insight_jobs (run_at, status);

-- Stores the raw insights payload for each completed job
CREATE TABLE IF NOT EXISTS insights (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  job_id          INTEGER NOT NULL REFERENCES insight_jobs(id),
  post_id         TEXT NOT NULL,
  window_label    TEXT NOT NULL,
  fetched_at      DATETIME DEFAULT (datetime('now')),
  -- Core reach / engagement metrics
  impressions     INTEGER,
  reach           INTEGER,
  likes           INTEGER,
  comments        INTEGER,
  shares          INTEGER,
  saves           INTEGER,
  video_views     INTEGER,
  -- Full raw response for future-proofing
  raw_json        TEXT
);
Writing File

~/instagram-insights-pipeline/src/db.js

/**
 * db.js — Lightweight SQLite adapter using the `better-sqlite3` package.
 *
 * If you already have a data store (Postgres, MySQL, etc.), replace this
 * file with your own adapter that exposes the same exported functions.
 * The rest of the codebase only imports from this file, so swapping is easy.
 */

'use strict';

const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');

const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'pipeline.db');

// Ensure the data directory exists
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });

const db = new Database(DB_PATH);

// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');

// ── Bootstrap schema on first run ──────────────────────────────────────────
const schemaPath = path.join(__dirname, '..', 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf8');
db.exec(schema);

// ── Posts ───────────────────────────────────────────────────────────────────

/**
 * Insert a new post. Silently ignores duplicates (idempotent).
 * @param {{ id, account_id, permalink, media_type, published_at }} post
 */
function upsertPost(post) {
  db.prepare(`
    INSERT INTO posts (id, account_id, permalink, media_type, published_at)
    VALUES (@id, @account_id, @permalink, @media_type, @published_at)
    ON CONFLICT(id) DO NOTHING
  `).run(post);
}

function getPost(postId) {
  return db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
}

// ── Jobs ────────────────────────────────────────────────────────────────────

/**
 * Insert the 3 scheduled jobs for a post (idempotent via UNIQUE constraint).
 * @param {{ post_id, window_label, run_at }[]} jobs
 */
function insertJobs(jobs) {
  const stmt = db.prepare(`
    INSERT INTO insight_jobs (post_id, window_label, run_at)
    VALUES (@post_id, @window_label, @run_at)
    ON CONFLICT DO NOTHING
  `);
  const insertMany = db.transaction((rows) => rows.forEach((r) => stmt.run(r)));
  insertMany(jobs);
}

/**
 * Fetch all pending jobs whose run_at has passed.
 * Marks them as 'running' atomically before returning.
 */
function claimDueJobs(limit = 20) {
  const now = new Date().toISOString();
  return db.transaction(() => {
    const jobs = db.prepare(`
      SELECT * FROM insight_jobs
      WHERE status = 'pending' AND run_at <= ?
      ORDER BY run_at ASC
      LIMIT ?
    `).all(now, limit);

    if (jobs.length === 0) return [];

    const ids = jobs.map((j) => j.id);
    db.prepare(`
      UPDATE insight_jobs
      SET status = 'running', updated_at = datetime('now')
      WHERE id IN (${ids.map(() => '?').join(',')})
    `).run(...ids);

    return jobs;
  })();
}

function markJobDone(jobId) {
  db.prepare(`
    UPDATE insight_jobs
    SET status = 'done', updated_at = datetime('now')
    WHERE id = ?
  `).run(jobId);
}

function markJobFailed(jobId, errorMessage) {
  db.prepare(`
    UPDATE insight_jobs
    SET status = 'failed',
        last_error = ?,
        attempts = attempts + 1,
        updated_at = datetime('now')
    WHERE id = ?
  `).run(errorMessage, jobId);
}

/**
 * Reset a failed job to pending so it will be retried.
 * Called automatically by runner.js after exponential backoff.
 */
function requeueJob(jobId, runAt) {
  db.prepare(`
    UPDATE insight_jobs
    SET status = 'pending', run_at = ?, updated_at = datetime('now')
    WHERE id = ?
  `).run(runAt, jobId);
}

// ── Insights ─────────────────────────────────────────────────────────────────

/**
 * Persist the fetched insights for a completed job.
 * @param {object} params
 */
function saveInsights({ job_id, post_id, window_label, metrics, raw }) {
  db.prepare(`
    INSERT INTO insights
      (job_id, post_id, window_label, impressions, reach, likes,
       comments, shares, saves, video_views, raw_json)
    VALUES
      (@job_id, @post_id, @window_label, @impressions, @reach, @likes,
       @comments, @shares, @saves, @video_views, @raw_json)
  `).run({
    job_id,
    post_id,
    window_label,
    impressions:  metrics.impressions  ?? null,
    reach:        metrics.reach        ?? null,
    likes:        metrics.likes        ?? null,
    comments:     metrics.comments     ?? null,
    shares:       metrics.shares       ?? null,
    saves:        metrics.saves        ?? null,
    video_views:  metrics.video_views  ?? null,
    raw_json:     JSON.stringify(raw),
  });
}

module.exports = {
  upsertPost,
  getPost,
  insertJobs,
  claimDueJobs,
  markJobDone,
  markJobFailed,
  requeueJob,
  saveInsights,
};
Writing File

~/instagram-insights-pipeline/src/instagram.js

/**
 * instagram.js — Instagram Graph API client
 *
 * Docs: https://developers.facebook.com/docs/instagram-api/guides/insights
 *
 * Required env vars:
 *   IG_ACCESS_TOKEN  — Long-lived Page / System User access token
 *   IG_API_VERSION   — e.g. "v19.0" (defaults to v19.0)
 */

'use strict';

const https = require('https');

const BASE = 'https://graph.facebook.com';
const API_VERSION = process.env.IG_API_VERSION || 'v19.0';
const ACCESS_TOKEN = process.env.IG_ACCESS_TOKEN;

if (!ACCESS_TOKEN) {
  console.warn('[instagram] WARNING: IG_ACCESS_TOKEN is not set. API calls will fail.');
}

// ── Generic fetch helper (no external dependencies, uses built-in https) ────

function get(url) {
  return new Promise((resolve, reject) => {
    https.get(url, (res) => {
      let body = '';
      res.on('data', (chunk) => (body += chunk));
      res.on('end', () => {
        try {
          const json = JSON.parse(body);
          if (json.error) {
            reject(new Error(`[IG API] ${json.error.message} (code ${json.error.code})`));
          } else {
            resolve(json);
          }
        } catch (e) {
          reject(new Error(`[IG API] Invalid JSON response: ${body.slice(0, 200)}`));
        }
      });
    }).on('error', reject);
  });
}

// ── Metrics per media type ───────────────────────────────────────────────────
//
// Instagram returns different metric names depending on media type.
// VIDEO and REELS additionally support video_views.
// CAROUSEL_ALBUM supports the same as IMAGE.
//
// Reference: https://developers.facebook.com/docs/instagram-api/reference/ig-media/insights

const METRICS_BY_TYPE = {
  IMAGE:          'impressions,reach,likes,comments,shares,saved',
  CAROUSEL_ALBUM: 'impressions,reach,likes,comments,shares,saved',
  VIDEO:          'impressions,reach,likes,comments,shares,saved,video_views',
  REEL:           'reach,likes,comments,shares,saved,plays',
  STORY:          'impressions,reach,replies,exits,taps_forward,taps_back',
};

function metricsForType(mediaType) {
  return METRICS_BY_TYPE[mediaType?.toUpperCase()] || METRICS_BY_TYPE.IMAGE;
}

// ── Public API ───────────────────────────────────────────────────────────────

/**
 * Fetch insights for a single media object.
 *
 * @param {string} mediaId    — Instagram media ID
 * @param {string} mediaType  — IMAGE | VIDEO | CAROUSEL_ALBUM | REEL | STORY
 * @returns {Promise<{ metrics: object, raw: object }>}
 */
async function fetchInsights(mediaId, mediaType = 'IMAGE') {
  const metric = metricsForType(mediaType);
  const url = `${BASE}/${API_VERSION}/${mediaId}/insights`
    + `?metric=${metric}&access_token=${ACCESS_TOKEN}`;

  const raw = await get(url);

  // Flatten the data array into a { metric_name: value } object
  const metrics = {};
  for (const item of (raw.data || [])) {
    // Each item: { name, period, values: [{ value }], ... }
    const val = item.values?.[0]?.value ?? item.value ?? null;
    const key = item.name === 'saved' ? 'saves' : item.name;
    metrics[key] = typeof val === 'object' ? JSON.stringify(val) : val;
  }

  return { metrics, raw };
}

/**
 * Fetch basic fields for a media object (used to get media_type and timestamp
 * when registering posts from a webhook that only delivers a media ID).
 *
 * @param {string} mediaId
 * @returns {Promise<{ id, media_type, timestamp, permalink }>}
 */
async function fetchMediaFields(mediaId) {
  const url = `${BASE}/${API_VERSION}/${mediaId}`
    + `?fields=id,media_type,timestamp,permalink&access_token=${ACCESS_TOKEN}`;
  return get(url);
}

module.exports = { fetchInsights, fetchMediaFields };
Writing File

~/instagram-insights-pipeline/src/scheduler.js

/**
 * scheduler.js — Registers the three insight-fetch jobs for a new post.
 *
 * Call registerPost() as soon as a new post is detected (from webhook or poll).
 * It is fully idempotent: safe to call multiple times for the same post_id.
 *
 * Windows:
 *   24h  → published_at + 24 hours
 *   15d  → published_at + 15 days
 *   30d  → published_at + 30 days
 */

'use strict';

const db = require('./db');

// ── Window definitions ────────────────────────────────────────────────────────

const WINDOWS = [
  { label: '24h', offsetMs: 24 * 60 * 60 * 1000 },
  { label: '15d', offsetMs: 15 * 24 * 60 * 60 * 1000 },
  { label: '30d', offsetMs: 30 * 24 * 60 * 60 * 1000 },
];

/**
 * Register a post and schedule its three insight-fetch jobs.
 *
 * @param {object} params
 * @param {string}  params.id           — Instagram media_id
 * @param {string}  params.account_id   — IG Business Account ID
 * @param {string}  [params.permalink]
 * @param {string}  [params.media_type] — IMAGE | VIDEO | CAROUSEL_ALBUM | REEL | STORY
 * @param {Date|string} params.published_at — Publish timestamp (UTC)
 */
function registerPost({ id, account_id, permalink, media_type, published_at }) {
  const publishedDate = new Date(published_at);

  if (isNaN(publishedDate.getTime())) {
    throw new Error(`[scheduler] Invalid published_at value: ${published_at}`);
  }

  // 1. Persist the post (no-op if already exists)
  db.upsertPost({
    id,
    account_id,
    permalink:    permalink  || null,
    media_type:   media_type || 'IMAGE',
    published_at: publishedDate.toISOString(),
  });

  // 2. Create the three scheduled jobs
  const jobs = WINDOWS.map(({ label, offsetMs }) => ({
    post_id:      id,
    window_label: label,
    run_at:       new Date(publishedDate.getTime() + offsetMs).toISOString(),
  }));

  db.insertJobs(jobs);

  console.log(
    `[scheduler] Registered post ${id} | jobs scheduled at: ` +
    jobs.map((j) => `${j.window_label} → ${j.run_at}`).join(', ')
  );

  return jobs;
}

module.exports = { registerPost };
Writing File

~/instagram-insights-pipeline/src/webhook.js

/**
 * webhook.js — Express router for receiving Instagram webhook events.
 *
 * Mount this in index.js:
 *   app.use('/webhook', require('./webhook'));
 *
 * Required env vars:
 *   WEBHOOK_VERIFY_TOKEN   — Your chosen verification token (set in Meta App Dashboard)
 *   IG_ACCOUNT_ID          — Default IG Business Account ID (used when not in payload)
 *
 * Meta Webhook docs:
 *   https://developers.facebook.com/docs/graph-api/webhooks/getting-started
 *   https://developers.facebook.com/docs/instagram-api/guides/webhooks
 */

'use strict';

const { Router } = require('express');
const { fetchMediaFields } = require('./instagram');
const { registerPost } = require('./scheduler');

const router = Router();

const VERIFY_TOKEN  = process.env.WEBHOOK_VERIFY_TOKEN;
const IG_ACCOUNT_ID = process.env.IG_ACCOUNT_ID;

// ── Step 1: Webhook verification (one-time, during Meta App setup) ────────────
router.get('/', (req, res) => {
  const mode      = req.query['hub.mode'];
  const token     = req.query['hub.verify_token'];
  const challenge = req.query['hub.challenge'];

  if (mode === 'subscribe' && token === VERIFY_TOKEN) {
    console.log('[webhook] Verification successful');
    return res.status(200).send(challenge);
  }

  console.warn('[webhook] Verification failed — token mismatch');
  res.sendStatus(403);
});

// ── Step 2: Receive real-time events ─────────────────────────────────────────
router.post('/', async (req, res) => {
  // Always respond quickly — Meta retries on non-2xx or slow responses
  res.sendStatus(200);

  const body = req.body;

  if (body.object !== 'instagram') {
    return; // Not an IG event
  }

  for (const entry of (body.entry || [])) {
    for (const change of (entry.changes || [])) {

      // We only care about new media publications
      if (change.field !== 'media') continue;

      const value   = change.value || {};
      const mediaId = value.media_id || value.id;

      if (!mediaId) {
        console.warn('[webhook] Received media event without media_id:', JSON.stringify(value));
        continue;
      }

      // Webhooks often deliver minimal data — fetch full fields from the API
      try {
        const media = await fetchMediaFields(mediaId);

        await registerPost({
          id:           media.id,
          account_id:   entry.id || IG_ACCOUNT_ID,
          permalink:    media.permalink,
          media_type:   media.media_type,
          published_at: media.timestamp,   // ISO 8601 string from IG API
        });

      } catch (err) {
        console.error(`[webhook] Failed to register post ${mediaId}:`, err.message);
      }
    }
  }
});

module.exports = router;
Writing File

~/instagram-insights-pipeline/src/runner.js

/**
 * runner.js — Cron job that processes due insight-fetch jobs.
 *
 * Run this file on a schedule (e.g. every 5 minutes via node-cron or an
 * external cron daemon). It:
 *   1. Claims all pending jobs whose run_at has passed
 *   2. Fetches insights from the Instagram Graph API
 *   3. Persists results and marks jobs done
 *   4. On failure, reschedules with exponential backoff (max 3 attempts)
 *
 * Usage:
 *   node src/runner.js          ← run once (suitable for external cron)
 *   RUN_INLINE=true node index.js  ← run on embedded schedule (via node-cron)
 */

'use strict';

const db          = require('./db');
const { fetchInsights } = require('./instagram');

// Max retry attempts before giving up
const MAX_ATTEMPTS = 3;

// Backoff delays (in ms) indexed by attempt number (0-based)
const BACKOFF_MS = [
  5  * 60 * 1000,  // attempt 1 failed → retry in 5 min
  30 * 60 * 1000,  // attempt 2 failed → retry in 30 min
  2  * 60 * 60 * 1000, // attempt 3 failed → give up
];

/**
 * Process all due jobs. Called by the cron schedule or directly.
 */
async function runDueJobs() {
  const jobs = db.claimDueJobs(20);

  if (jobs.length === 0) {
    console.log('[runner] No jobs due.');
    return;
  }

  console.log(`[runner] Processing ${jobs.length} due job(s)...`);

  for (const job of jobs) {
    await processJob(job);
  }
}

async function processJob(job) {
  const post = db.getPost(job.post_id);
  if (!post) {
    console.error(`[runner] Post ${job.post_id} not found in DB for job ${job.id}. Marking failed.`);
    db.markJobFailed(job.id, 'Post record not found');
    return;
  }

  console.log(`[runner] Fetching insights — post=${job.post_id} window=${job.window_label}`);

  try {
    const { metrics, raw } = await fetchInsights(post.id, post.media_type);

    db.saveInsights({
      job_id:       job.id,
      post_id:      post.id,
      window_label: job.window_label,
      metrics,
      raw,
    });

    db.markJobDone(job.id);

    console.log(
      `[runner] ✓ Saved insights for post=${post.id} window=${job.window_label}`,
      `reach=${metrics.reach ?? 'n/a'} impressions=${metrics.impressions ?? 'n/a'}`
    );

  } catch (err) {
    console.error(`[runner] ✗ Error for job ${job.id}:`, err.message);

    const attempt = (job.attempts || 0) + 1;

    if (attempt >= MAX_ATTEMPTS) {
      db.markJobFailed(job.id, err.message);
      console.error(`[runner] Job ${job.id} exceeded max attempts. Marked failed.`);
    } else {
      const delayMs = BACKOFF_MS[attempt - 1] || BACKOFF_MS[BACKOFF_MS.length - 1];
      const retryAt = new Date(Date.now() + delayMs).toISOString();
      db.requeueJob(job.id, retryAt);
      console.warn(`[runner] Job ${job.id} will retry at ${retryAt} (attempt ${attempt}/${MAX_ATTEMPTS})`);
    }
  }
}

// ── Direct execution (node src/runner.js) ────────────────────────────────────
if (require.main === module) {
  runDueJobs()
    .then(() => {
      console.log('[runner] Done.');
      process.exit(0);
    })
    .catch((err) => {
      console.error('[runner] Fatal error:', err);
      process.exit(1);
    });
}

module.exports = { runDueJobs };
Writing File

~/instagram-insights-pipeline/index.js

/**
 * index.js — Entry point
 *
 * Starts the Express webhook server and optionally runs the job runner
 * on an embedded cron schedule.
 *
 * Environment variables (create a .env file or set them in your environment):
 *
 *   IG_ACCESS_TOKEN       — Long-lived Instagram Graph API access token
 *   IG_API_VERSION        — e.g. "v19.0" (default: v19.0)
 *   IG_ACCOUNT_ID         — Your IG Business Account ID
 *   WEBHOOK_VERIFY_TOKEN  — Secret token you set in Meta App Dashboard
 *   PORT                  — HTTP server port (default: 3000)
 *   DB_PATH               — SQLite DB path (default: ./data/pipeline.db)
 *   CRON_SCHEDULE         — node-cron pattern (default: "*/5 * * * *" = every 5 min)
 *   RUN_RUNNER_INLINE     — Set to "true" to run the job runner inside this process
 *                           (alternative: run `node src/runner.js` via external cron)
 */

'use strict';

require('dotenv').config();

const express       = require('express');
const cron          = require('node-cron');
const webhookRouter = require('./src/webhook');
const { runDueJobs } = require('./src/runner');

const PORT          = process.env.PORT          || 3000;
const CRON_SCHEDULE = process.env.CRON_SCHEDULE || '*/5 * * * *';
const RUN_INLINE    = process.env.RUN_RUNNER_INLINE === 'true';

// ── Express app ───────────────────────────────────────────────────────────────
const app = express();
app.use(express.json());

// Health check
app.get('/health', (_req, res) => res.json({ status: 'ok', ts: new Date().toISOString() }));

// Instagram webhook endpoint
app.use('/webhook/instagram', webhookRouter);

// ── Optional: manual trigger for testing ─────────────────────────────────────
// POST /admin/run-jobs   (protect this in production!)
app.post('/admin/run-jobs', async (_req, res) => {
  try {
    await runDueJobs();
    res.json({ ok: true });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// ── Start server ──────────────────────────────────────────────────────────────
app.listen(PORT, () => {
  console.log(`[app] Server listening on port ${PORT}`);
  console.log(`[app] Webhook endpoint: POST /webhook/instagram`);

  // ── Inline cron runner ────────────────────────────────────────────────────
  if (RUN_INLINE) {
    if (!cron.validate(CRON_SCHEDULE)) {
      console.error(`[app] Invalid CRON_SCHEDULE: "${CRON_SCHEDULE}"`);
      process.exit(1);
    }
    console.log(`[app] Job runner cron active: "${CRON_SCHEDULE}"`);
    cron.schedule(CRON_SCHEDULE, async () => {
      try {
        await runDueJobs();
      } catch (err) {
        console.error('[cron] Error running jobs:', err.message);
      }
    });
  } else {
    console.log('[app] Inline cron disabled. Run `node src/runner.js` via external cron.');
  }
});
Writing File

~/instagram-insights-pipeline/package.json

{
  "name": "instagram-insights-pipeline",
  "version": "1.0.0",
  "description": "Automatically fetches Instagram post insights at 24h, 15d, and 30d after publication.",
  "main": "index.js",
  "scripts": {
    "start":       "node index.js",
    "start:cron":  "RUN_RUNNER_INLINE=true node index.js",
    "runner":      "node src/runner.js",
    "dev":         "RUN_RUNNER_INLINE=true node --watch index.js"
  },
  "dependencies": {
    "better-sqlite3": "^9.4.3",
    "dotenv":         "^16.4.5",
    "express":        "^4.19.2",
    "node-cron":      "^3.0.3"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}
Writing File

~/instagram-insights-pipeline/README.md

# Instagram Insights Pipeline

Automatically fetches Instagram post insights at **24 hours**, **15 days**, and **30 days** after publication.

## How it works

```
New Post Published on Instagram
        │
        ▼
  [Webhook Receiver]  (/webhook/instagram)
  Receives media_id → fetches media fields → calls registerPost()
        │
        ▼
  [Scheduler]
  Inserts 3 rows into insight_jobs table:
    ├─ run_at = published_at + 24h
    ├─ run_at = published_at + 15d
    └─ run_at = published_at + 30d
        │
        ▼ (every 5 minutes)
  [Job Runner]  (src/runner.js or cron)
  SELECT jobs WHERE run_at <= NOW() AND status='pending'
  For each:
    1. Call Instagram Insights API
    2. Save results to `insights` table
    3. Mark job as 'done'
    (On failure: retry with exponential backoff, max 3 attempts)
```

## Quick Start

### 1. Install dependencies
```bash
npm install
```

### 2. Configure environment
```bash
cp .env.example .env
# Fill in your values
```

### 3. Start the server (with embedded cron)
```bash
npm run start:cron
```

Or start the server and run the job processor via your own external cron:
```bash
npm start                       # webhook server
# Add to crontab:
# */5 * * * * /usr/bin/node /path/to/project/src/runner.js
```

## Environment Variables

| Variable | Required | Description |
|---|---|---|
| `IG_ACCESS_TOKEN` | ✅ | Long-lived Instagram Graph API access token |
| `IG_API_VERSION` | | API version, e.g. `v19.0` (default: `v19.0`) |
| `IG_ACCOUNT_ID` | ✅ | Your IG Business Account ID |
| `WEBHOOK_VERIFY_TOKEN` | ✅ | Secret token set in Meta App Dashboard |
| `PORT` | | HTTP port (default: `3000`) |
| `DB_PATH` | | SQLite file path (default: `./data/pipeline.db`) |
| `CRON_SCHEDULE` | | node-cron pattern (default: `*/5 * * * *`) |
| `RUN_RUNNER_INLINE` | | Set `true` to run cron inside the main process |

## Meta App Setup (Webhooks)

1. Go to [Meta Developer Dashboard](https://developers.facebook.com)
2. Your App → Webhooks → Instagram → Subscribe to `media` field
3. Callback URL: `https://your-domain.com/webhook/instagram`
4. Verify Token: same value as `WEBHOOK_VERIFY_TOKEN`

## Manually register a post (for backfilling)

```js
const { registerPost } = require('./src/scheduler');

registerPost({
  id:           '17854360229135492',
  account_id:   '1234567890',
  media_type:   'IMAGE',
  published_at: '2025-04-25T10:00:00Z',
  permalink:    'https://www.instagram.com/p/abc123/',
});
```

## Database schema

- **posts** — one row per Instagram post
- **insight_jobs** — job queue (3 rows per post: 24h / 15d / 30d)
- **insights** — stored results with all metrics + raw JSON

## Swap the data store

Replace `src/db.js` with your own adapter. The public interface is:

```
upsertPost(post)
getPost(postId)
insertJobs(jobs[])
claimDueJobs(limit)
markJobDone(jobId)
markJobFailed(jobId, error)
requeueJob(jobId, retryAt)
saveInsights({ job_id, post_id, window_label, metrics, raw })
```
Writing File

~/instagram-insights-pipeline/.env.example

IG_ACCESS_TOKEN=your_long_lived_access_token_here
IG_API_VERSION=v19.0
IG_ACCOUNT_ID=your_ig_business_account_id

WEBHOOK_VERIFY_TOKEN=choose_any_secret_string_here

PORT=3000
DB_PATH=./data/pipeline.db

# Cron: every 5 minutes (runs the job runner inside the main process)
CRON_SCHEDULE=*/5 * * * *
RUN_RUNNER_INLINE=true
All files are ready. Here's the complete picture:

Project files

file

File ·

instagram-insights-pipeline/
├── index.js          ← Entry point (Express server + optional inline cron)
├── package.json
├── schema.sql        ← DB schema (posts, insight_jobs, insights tables)
├── .env.example      ← Copy to .env and fill in your tokens
├── README.md
└── src/
    ├── webhook.js    ← Receives Instagram webhook events
    ├── scheduler.js  ← Registers 3 jobs per new post
    ├── runner.js     ← Cron job: fetches due insights from IG API
    ├── instagram.js  ← Graph API client (no external HTTP libs)
    └── db.js         ← SQLite adapter (swap for your own store)

How each piece fits together

StageFileWhat it does
Detectionwebhook.jsMeta sends a media event → extract media_id
Registrationscheduler.jsregisterPost() → insert post + 3 jobs (24h / 15d / 30d)
Executionrunner.jsEvery 5 min → SELECT jobs WHERE run_at <= NOW() → fetch API → save
APIinstagram.jsCalls /{media_id}/insights with correct metrics per media type
Storagedb.jsSQLite adapter — swap out for your existing DB

Setup steps

# 1. Install
npm install

# 2. Configure
cp .env.example .env
# Fill in: IG_ACCESS_TOKEN, IG_ACCOUNT_ID, WEBHOOK_VERIFY_TOKEN

# 3. Start (server + embedded cron every 5 min)
npm run start:cron
Then in Meta Developer Dashboard:
Webhooks → Instagram → Subscribe to media field → set callback to https://your-domain.com/webhook/instagram

Key design decisions

  • Idempotent — calling registerPost() twice for the same post ID is safe (no duplicate jobs)
  • Exact timing — jobs are scheduled from published_at, not from when you discovered the post
  • Retry logic — failed API calls retry with exponential backoff (5m → 30m → give up after 3 attempts)
  • Swap your DBdb.js exposes a clean interface; replace with Postgres/MySQL/your ORM without touching any other file
  • Media type aware — Instagram returns different metric names for VIDEO, REEL, STORY, IMAGE; instagram.js handles this automatically
This is a shared conversation. Sign in to Orris to start your own chat.