← 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
M modules/assets/index.jsM public/panels/assets.htmlM public/panels/assets.js
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(/&/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 →