[object Object]

← back to Marketing Command Center

Image Bank: Instagram ingest — public-permalink og:image grab (no token, tagged uncleared) + our/vendor IG roster (33 accounts) with regram allow/review/block (Kravet-family blocked) + instagram source filter

961a1bb380abfc4902d6391b1f9a5e2b2b04b213 · 2026-06-13 09:27:44 -0700 · Steve Abrams

Files touched

Diff

commit 961a1bb380abfc4902d6391b1f9a5e2b2b04b213
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jun 13 09:27:44 2026 -0700

    Image Bank: Instagram ingest — public-permalink og:image grab (no token, tagged uncleared) + our/vendor IG roster (33 accounts) with regram allow/review/block (Kravet-family blocked) + instagram source filter
---
 modules/assets/index.js   | 29 +++++++++++++++++++++++++
 public/panels/assets.html | 17 +++++++++++++++
 public/panels/assets.js   | 55 ++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 100 insertions(+), 1 deletion(-)

diff --git a/modules/assets/index.js b/modules/assets/index.js
index 25b0904..8b9f461 100644
--- a/modules/assets/index.js
+++ b/modules/assets/index.js
@@ -335,6 +335,35 @@ module.exports = {
       res.json({ ok: true, found: urls.size, added: added.length, assets: added.map(withSrc) });
     });
 
+    // ── Instagram: pull a PUBLIC post/reel image via its og:image (no token) ────
+    // Tagged 'uncleared' — per compliance, pulled third-party media is staging
+    // only; reposting requires the gated Repost flow (settlement + rights + credit).
+    router.post('/import-ig-url', async (req, res) => {
+      const url = String((req.body && req.body.url) || '').trim();
+      if (!/^https?:\/\/(www\.)?instagram\.com\//i.test(url)) return res.status(400).json({ error: 'paste a full instagram.com post/reel URL' });
+      let html;
+      try {
+        const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36', accept: 'text/html' }, redirect: 'follow' });
+        html = await r.text();
+      } catch (e) { return res.status(502).json({ error: 'fetch failed: ' + e.message }); }
+      const og = html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
+              || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
+      if (!og) return res.status(422).json({ error: 'no og:image found — Instagram likely served a login wall (post must be public). Use the Harvest tab with the page HTML, or save the image manually.' });
+      const imgUrl = og[1].replace(/&amp;/g, '&');
+      const hm = url.match(/instagram\.com\/([^/?#]+)/i);
+      const handle = hm && !['p', 'reel', 'reels', 'tv', 'stories'].includes(hm[1].toLowerCase()) ? '@' + hm[1] : '';
+      const store = readStore();
+      const dup = store.find(a => a.url === imgUrl);
+      if (dup) return res.json({ asset: withSrc(dup), already: true });
+      const asset = {
+        id: newId(), name: handle ? `${handle} post` : 'Instagram post', kind: 'url', url: imgUrl,
+        mime: 'image/jpeg', size: 0, tags: ['instagram', 'uncleared'].concat(handle ? [handle] : []),
+        created_at: new Date().toISOString(),
+      };
+      store.push(asset); writeStore(store);
+      res.json({ asset: withSrc(asset) });
+    });
+
     // ── Constant Contact image library (Phase 2 — OAuth-gated) ──────────────────
     router.post('/import-cc-library', async (_req, res) => {
       const token = (process.env.CTCT_ACCESS_TOKEN || '').trim();
diff --git a/public/panels/assets.html b/public/panels/assets.html
index ec7b817..b89c75d 100644
--- a/public/panels/assets.html
+++ b/public/panels/assets.html
@@ -17,6 +17,7 @@
       <button class="as-mode btn ghost" data-mode="catalog" style="border:0;border-radius:0;border-bottom:2px solid transparent;">🏛 DW Catalog</button>
       <button class="as-mode btn ghost" data-mode="gdrive" style="border:0;border-radius:0;border-bottom:2px solid transparent;">☁ GDrive</button>
       <button class="as-mode btn ghost" data-mode="harvest" style="border:0;border-radius:0;border-bottom:2px solid transparent;">✂ Harvest</button>
+      <button class="as-mode btn ghost" data-mode="instagram" style="border:0;border-radius:0;border-bottom:2px solid transparent;">📸 Instagram</button>
       <button class="as-mode btn ghost" data-mode="cc" style="border:0;border-radius:0;border-bottom:2px solid transparent;">📧 CC Library</button>
     </div>
 
@@ -88,6 +89,21 @@
       <button id="as-hv-go" class="btn gold" style="margin-top:8px;">Harvest images</button>
     </div>
 
+    <!-- instagram pane -->
+    <div class="as-pane" data-pane="instagram" style="display:none;">
+      <div class="muted" style="font-size:12px;margin-bottom:8px;">Paste a <b>public</b> Instagram post/reel URL to pull its image into the bank (tagged <code>uncleared</code>). Reposting to the DW account needs the gated <b>Repost</b> flow — settlement check + per-post rights approval + auto-credit (Phase 2).</div>
+      <div class="row" style="align-items:flex-end;margin-bottom:16px;">
+        <div style="flex:2;min-width:240px;">
+          <label for="as-ig-url">Instagram post URL</label>
+          <input id="as-ig-url" type="url" placeholder="https://www.instagram.com/p/…">
+        </div>
+        <button id="as-ig-add" class="btn gold" style="height:38px;">Grab image</button>
+      </div>
+      <div style="font:600 13px/1.2 'Cormorant Garamond',Georgia,serif;margin:6px 0 2px;">Our + vendor Instagram accounts <span id="as-ig-vcount" class="pill">0</span></div>
+      <div class="muted" style="font-size:11px;margin-bottom:8px;">Regram status — <b style="color:#3a6b3a">allow</b>: repost OK with credit · <b style="color:#8a6a2a">review</b>: confirm rights first · <b style="color:#9a5a5a">block</b>: brand-controlled (Kravet family), do not repost. Open an account, copy a post URL, paste it above.</div>
+      <div id="as-ig-vendors" style="max-height:320px;overflow:auto;border:1px solid var(--line);border-radius:10px;"></div>
+    </div>
+
     <!-- cc library pane -->
     <div class="as-pane" data-pane="cc" style="display:none;">
       <div class="muted" style="font-size:12px;margin-bottom:8px;">Import your Constant Contact image library directly. Requires the CC connection (Phase 2 OAuth) — until then this stages.</div>
@@ -115,6 +131,7 @@
         <option value="url">URL</option>
         <option value="catalog">DW Catalog</option>
         <option value="gdrive">GDrive</option>
+        <option value="instagram">Instagram</option>
         <option value="constant-contact">Constant Contact</option>
       </select>
       <label for="as-density" style="margin:0;font-size:12px;">Size</label>
diff --git a/public/panels/assets.js b/public/panels/assets.js
index 2ba648a..a0dd34f 100644
--- a/public/panels/assets.js
+++ b/public/panels/assets.js
@@ -45,7 +45,9 @@ window.MCC_PANELS['assets'] = {
     const sortSel = $('#as-sort');
     const sourceSel = $('#as-source');
     function assetSource(a) { // normalize to a filter key
-      if ((a.tags || []).includes('constant-contact')) return 'constant-contact';
+      const t = a.tags || [];
+      if (t.includes('constant-contact')) return 'constant-contact';
+      if (t.includes('instagram')) return 'instagram';
       return a.kind || 'url';
     }
     function renderLibrary() {
@@ -249,6 +251,57 @@ window.MCC_PANELS['assets'] = {
         loadLibrary();
       } catch (e) { setStatus('Harvest failed: ' + e.message, true); }
     });
+    // ── Instagram: grab a public post image ─────────────────────────────────────
+    $('#as-ig-add').addEventListener('click', async () => {
+      const url = $('#as-ig-url').value.trim();
+      if (!url) { setStatus('Paste an Instagram post URL first.', true); return; }
+      setStatus('Fetching Instagram image…');
+      try {
+        const r = await fetch('/api/assets/import-ig-url', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ url }),
+        });
+        const d = await r.json();
+        if (!r.ok) throw new Error(d.error || r.status);
+        $('#as-ig-url').value = '';
+        setStatus(d.already ? 'Already in your library.' : 'Added Instagram image (tagged uncleared).');
+        loadLibrary();
+      } catch (e) { setStatus('Could not grab IG image: ' + e.message, true); }
+    });
+    // ── Vendor IG roster with regram status ─────────────────────────────────────
+    const KRAVET = /kravet|lee jofa|groundworks|brunschwig|cole\s*&\s*son|gp\s*&?\s*j?\s*baker|colefax|clarke|mulberry|threads|baker lifestyle|andrew martin|nicolette mayer|aerin|barclay butera|thom filicia/i;
+    function regramStatus(v) {
+      if (v.vendorCode === 'dw' || /our account/i.test(v.note || '')) return 'allow';
+      if (KRAVET.test(v.brand || '') || KRAVET.test(v.vendorCode || '')) return 'block';
+      return 'review';
+    }
+    const RG_COLOR = { allow: '#3a6b3a', review: '#8a6a2a', block: '#9a5a5a' };
+    async function loadVendorIG() {
+      const box = $('#as-ig-vendors'); if (!box) return;
+      let accounts = [];
+      try { accounts = (await (await fetch('/api/vendors/accounts')).json()).accounts || []; }
+      catch { box.innerHTML = '<div class="muted" style="padding:12px">Could not load vendor roster.</div>'; return; }
+      const withIG = accounts.filter(a => a.hasIG && a.url);
+      $('#as-ig-vcount').textContent = withIG.length;
+      box.innerHTML = withIG.map(v => {
+        const rg = regramStatus(v);
+        return `<div style="display:flex;align-items:center;gap:8px;padding:7px 10px;border-bottom:1px solid var(--line);">
+          <div style="flex:1;min-width:0;">
+            <div style="font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(v.brand)}</div>
+            <div class="muted" style="font-size:11px;">${esc(v.handle || '')}${v.followersDisplay ? ' · ' + esc(v.followersDisplay) : ''}</div>
+          </div>
+          <span class="pill" style="font-size:9.5px;background:#f3efe7;color:${RG_COLOR[rg]};">${rg}</span>
+          <a class="btn ghost" href="${esc(v.url)}" target="_blank" rel="noopener noreferrer" style="font-size:11px;padding:5px 9px;">Open ↗</a>
+          <button class="btn ghost ig-use" data-url="${esc(v.url)}" style="font-size:11px;padding:5px 9px;">Use ↑</button>
+        </div>`;
+      }).join('');
+      box.querySelectorAll('.ig-use').forEach(b => b.addEventListener('click', () => {
+        $('#as-ig-url').value = b.dataset.url; $('#as-ig-url').focus();
+        setStatus('Open the account, copy a specific post URL, then Grab image.');
+      }));
+    }
+    loadVendorIG();
+
     // ── Constant Contact library (Phase 2 gated) ────────────────────────────────
     $('#as-cc-go').addEventListener('click', async () => {
       setStatus('Importing from Constant Contact…');

← 267a4bb Asset Library = Image Bank: add GDrive (folder rclone-import  ·  back to Marketing Command Center  ·  Image Bank: gated STAGE-ONLY /repost endpoint (rights-confir c04d2b7 →