[object Object]

← back to Dw Launches

Media Library: vendor-IG store + ingest (Graph/permalink), repost/use flow, image proxy+cache

29df20330ef67924c4ce2f9eeda9b4efc0738882 · 2026-06-19 15:30:23 -0700 · Steve

Files touched

Diff

commit 29df20330ef67924c4ce2f9eeda9b4efc0738882
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 19 15:30:23 2026 -0700

    Media Library: vendor-IG store + ingest (Graph/permalink), repost/use flow, image proxy+cache
---
 .gitignore               |   1 +
 data/vendor-ig.json      | 105 +++++++++++++++++++++++++++++++
 data/vendor-ig.seed.json |  25 ++++++++
 media.js                 |  86 +++++++++++++-------------
 public/index.html        |  72 ++++++++++++++++++----
 server.js                |  59 ++++++++++++++++++
 vendor-ig.js             | 157 +++++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 451 insertions(+), 54 deletions(-)

diff --git a/.gitignore b/.gitignore
index 1924158..e5ba47b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ tmp/
 dist/
 build/
 .next/
+data/img-cache/
diff --git a/data/vendor-ig.json b/data/vendor-ig.json
new file mode 100644
index 0000000..f43c213
--- /dev/null
+++ b/data/vendor-ig.json
@@ -0,0 +1,105 @@
+{
+  "handles": [
+    {
+      "handle": "schumacher1889",
+      "vendor": "Schumacher",
+      "name": "Schumacher"
+    },
+    {
+      "handle": "thibaut1886",
+      "vendor": "Thibaut",
+      "name": "Thibaut"
+    },
+    {
+      "handle": "kravetinc",
+      "vendor": "Kravet",
+      "name": "Kravet"
+    },
+    {
+      "handle": "leejofa",
+      "vendor": "Lee Jofa",
+      "name": "Lee Jofa"
+    },
+    {
+      "handle": "brunschwigfils",
+      "vendor": "Brunschwig & Fils",
+      "name": "Brunschwig & Fils"
+    },
+    {
+      "handle": "arte_international",
+      "vendor": "Arte",
+      "name": "Arte International"
+    },
+    {
+      "handle": "cole_and_son",
+      "vendor": "Cole & Son",
+      "name": "Cole & Son"
+    },
+    {
+      "handle": "maisonpierrefrey",
+      "vendor": "Pierre Frey",
+      "name": "Pierre Frey"
+    },
+    {
+      "handle": "degournay",
+      "vendor": "de Gournay",
+      "name": "de Gournay"
+    },
+    {
+      "handle": "fromental",
+      "vendor": "Fromental",
+      "name": "Fromental"
+    },
+    {
+      "handle": "phillipjeffries",
+      "vendor": "Phillip Jeffries",
+      "name": "Phillip Jeffries"
+    },
+    {
+      "handle": "osborneandlittle",
+      "vendor": "Osborne & Little",
+      "name": "Osborne & Little"
+    },
+    {
+      "handle": "designersguild",
+      "vendor": "Designers Guild",
+      "name": "Designers Guild"
+    },
+    {
+      "handle": "farrowandball",
+      "vendor": "Farrow & Ball",
+      "name": "Farrow & Ball"
+    },
+    {
+      "handle": "zoffanyofficial",
+      "vendor": "Zoffany",
+      "name": "Zoffany"
+    },
+    {
+      "handle": "scalamandre",
+      "vendor": "Scalamandre",
+      "name": "Scalamandré"
+    },
+    {
+      "handle": "gpandjbaker",
+      "vendor": "GP & J Baker",
+      "name": "GP & J Baker"
+    },
+    {
+      "handle": "harlequin_today",
+      "vendor": "Harlequin",
+      "name": "Harlequin"
+    },
+    {
+      "handle": "mayaromanoff",
+      "vendor": "Maya Romanoff",
+      "name": "Maya Romanoff"
+    },
+    {
+      "handle": "wolfgordon",
+      "vendor": "Wolf-Gordon",
+      "name": "Wolf-Gordon"
+    }
+  ],
+  "posts": []
+}
\ No newline at end of file
diff --git a/data/vendor-ig.seed.json b/data/vendor-ig.seed.json
new file mode 100644
index 0000000..8ae2d29
--- /dev/null
+++ b/data/vendor-ig.seed.json
@@ -0,0 +1,25 @@
+{
+  "handles": [
+    { "handle": "schumacher1889", "vendor": "Schumacher", "name": "Schumacher" },
+    { "handle": "thibaut1886", "vendor": "Thibaut", "name": "Thibaut" },
+    { "handle": "kravetinc", "vendor": "Kravet", "name": "Kravet" },
+    { "handle": "leejofa", "vendor": "Lee Jofa", "name": "Lee Jofa" },
+    { "handle": "brunschwigfils", "vendor": "Brunschwig & Fils", "name": "Brunschwig & Fils" },
+    { "handle": "arte_international", "vendor": "Arte", "name": "Arte International" },
+    { "handle": "cole_and_son", "vendor": "Cole & Son", "name": "Cole & Son" },
+    { "handle": "maisonpierrefrey", "vendor": "Pierre Frey", "name": "Pierre Frey" },
+    { "handle": "degournay", "vendor": "de Gournay", "name": "de Gournay" },
+    { "handle": "fromental", "vendor": "Fromental", "name": "Fromental" },
+    { "handle": "phillipjeffries", "vendor": "Phillip Jeffries", "name": "Phillip Jeffries" },
+    { "handle": "osborneandlittle", "vendor": "Osborne & Little", "name": "Osborne & Little" },
+    { "handle": "designersguild", "vendor": "Designers Guild", "name": "Designers Guild" },
+    { "handle": "farrowandball", "vendor": "Farrow & Ball", "name": "Farrow & Ball" },
+    { "handle": "zoffanyofficial", "vendor": "Zoffany", "name": "Zoffany" },
+    { "handle": "scalamandre", "vendor": "Scalamandre", "name": "Scalamandré" },
+    { "handle": "gpandjbaker", "vendor": "GP & J Baker", "name": "GP & J Baker" },
+    { "handle": "harlequin_today", "vendor": "Harlequin", "name": "Harlequin" },
+    { "handle": "mayaromanoff", "vendor": "Maya Romanoff", "name": "Maya Romanoff" },
+    { "handle": "wolfgordon", "vendor": "Wolf-Gordon", "name": "Wolf-Gordon" }
+  ],
+  "posts": []
+}
diff --git a/media.js b/media.js
index 76f6934..34e292e 100644
--- a/media.js
+++ b/media.js
@@ -12,6 +12,7 @@
  * Each row is normalised to: { url, title, source, vendor, caption, link }.
  */
 const { q } = require('./db');
+const vendorIg = require('./vendor-ig');
 
 const SOURCES = ['shopify', 'instagram', 'vendor'];
 
@@ -26,13 +27,14 @@ async function counts() {
     `select count(*) c from shopify_products where image_url ilike 'http%'`);
   out.instagram = await safe(
     `select count(*) c from instagram_posts where image_url ilike 'http%'`);
+  // vendor IG = the self-contained vendor-ig store (real cached posts) +
+  // whatever IG-platform rows exist in the DB social tables.
   const vIg = await safe(
     `select count(*) c from social_media_posts where image_url ilike 'http%' and platform='instagram'`);
   const vTrend = await safe(
     `select count(*) c from social_trending_posts where post_image_url ilike 'http%'`);
-  const vImg = await safe(
-    `select count(*) c from vendor_images where image_url ilike 'http%'`);
-  out.vendor = vIg + vTrend + vImg;
+  let vStore = 0; try { vStore = vendorIg.count(); } catch {}
+  out.vendor = vStore + vIg + vTrend;
   return out;
 }
 
@@ -86,55 +88,53 @@ async function fetchInstagram({ search, limit, offset }) {
   }));
 }
 
-// vendor = union of three small tables, each contributing what it has.
+// vendor IG = the local vendor-ig store (real cached posts, images that paint),
+// then any IG-platform rows from the DB social tables. We deliberately DROP the
+// old `vendor_images` website scrapes — they WAF-block on fetch and aren't IG.
 async function fetchVendor({ search, limit, offset }) {
+  let store = [];
+  try { store = vendorIg.list({ search, limit, offset }); } catch {}
+  if (store.length >= limit) return store;
+
+  // top up from DB social IG rows (usually empty today, but real if present)
+  const need = limit - store.length;
   const params = [];
-  let s1 = '', s2 = '', s3 = '';
+  let s1 = '', s2 = '';
   if (search) {
     params.push(`%${search}%`);
     const p = `$${params.length}`;
     s1 = ` and (coalesce(caption,'') ilike ${p} or coalesce(vendor,'') ilike ${p} or coalesce(account_username,'') ilike ${p})`;
     s2 = ` and (coalesce(post_snippet,'') ilike ${p} or coalesce(author_handle,'') ilike ${p})`;
-    s3 = ` and (coalesce(alt_text,'') ilike ${p} or coalesce(vendor_id::text,'') ilike ${p})`;
   }
-  params.push(limit, offset);
+  const dbOffset = Math.max(offset - vendorIg.count(), 0);
+  params.push(need, dbOffset);
   const lim = `$${params.length - 1}`, off = `$${params.length}`;
-  const sql = `
-    select * from (
-      select image_url as url,
-             coalesce(product_title, account_username, 'Vendor Instagram') as title,
-             coalesce(vendor, account_username, '') as vendor,
-             coalesce(caption,'') as caption,
-             coalesce(post_url,'') as link,
-             coalesce(posted_at, created_at) as ts
-        from social_media_posts
-       where image_url ilike 'http%' and platform='instagram'${s1}
-      union all
-      select post_image_url as url,
-             coalesce(author_display_name, author_handle, 'Trending') as title,
-             coalesce(author_handle,'') as vendor,
-             coalesce(post_snippet,'') as caption,
-             coalesce(post_url,'') as link,
-             last_seen_at as ts
-        from social_trending_posts
-       where post_image_url ilike 'http%'${s2}
-      union all
-      select image_url as url,
-             coalesce(alt_text, 'Vendor image') as title,
-             coalesce(vendor_id::text,'') as vendor,
-             '' as caption,
-             '' as link,
-             captured_at as ts
-        from vendor_images
-       where image_url ilike 'http%'${s3}
-    ) u
-    order by ts desc nulls last
-    limit ${lim} offset ${off}`;
-  const r = await q(sql, params);
-  return r.rows.map(row => ({
-    url: row.url, title: row.title || 'Vendor', source: 'vendor',
-    vendor: row.vendor || '', caption: row.caption || '', link: row.link || ''
-  }));
+  let rows = [];
+  try {
+    const r = await q(`
+      select * from (
+        select image_url as url,
+               coalesce(product_title, account_username, 'Vendor Instagram') as title,
+               coalesce(vendor, account_username, '') as vendor,
+               coalesce(caption,'') as caption, coalesce(post_url,'') as link,
+               coalesce(posted_at, created_at) as ts
+          from social_media_posts
+         where image_url ilike 'http%' and platform='instagram'${s1}
+        union all
+        select post_image_url as url,
+               coalesce(author_display_name, author_handle, 'Trending') as title,
+               coalesce(author_handle,'') as vendor,
+               coalesce(post_snippet,'') as caption, coalesce(post_url,'') as link,
+               last_seen_at as ts
+          from social_trending_posts
+         where post_image_url ilike 'http%'${s2}
+      ) u order by ts desc nulls last limit ${lim} offset ${off}`, params);
+    rows = r.rows.map(row => ({
+      url: row.url, title: row.title || 'Vendor', source: 'vendor',
+      vendor: row.vendor || '', caption: row.caption || '', link: row.link || ''
+    }));
+  } catch {}
+  return [...store, ...rows];
 }
 
 // --- public: paged media across one or all sources -------------------------
diff --git a/public/index.html b/public/index.html
index 9cd42a5..4410a75 100644
--- a/public/index.html
+++ b/public/index.html
@@ -452,13 +452,18 @@
   .ml-card .mv{font-size:9px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.4px}
   .ml-card .src{position:absolute;top:5px;left:5px;font-size:8px;background:rgba(0,0,0,.7);
     color:#e9d9a8;padding:1px 6px;border-radius:4px;text-transform:uppercase;letter-spacing:.4px}
-  .ml-card .att{
-    border:none;border-top:1px solid var(--line);background:var(--gold-faint);
-    color:var(--gold);font:inherit;font-size:11px;padding:6px;cursor:pointer;letter-spacing:.3px;
+  .ml-card .ml-acts{display:flex;border-top:1px solid var(--line)}
+  .ml-card .ml-acts button{
+    flex:1;border:none;background:var(--gold-faint);
+    color:var(--gold);font:inherit;font-size:11px;padding:6px 4px;cursor:pointer;letter-spacing:.2px;
   }
-  .ml-card .att:hover{background:var(--gold-faint2);color:var(--ink)}
+  .ml-card .ml-acts button+button{border-left:1px solid var(--line)}
+  .ml-card .ml-acts button:hover{background:var(--gold-faint2);color:var(--ink)}
   .ml-card .att.added{background:#1d3a1d;color:#9fe09f}
+  .ml-card .rep{color:var(--ink-2)!important}
+  .ml-card .rep:hover{color:var(--ink)!important}
   .ml-more{display:flex;align-items:center;gap:14px;padding:0 22px 30px}
+  .ml-empty{grid-column:1/-1;text-align:center;padding:48px 20px;color:var(--ink-2);line-height:1.7;font-size:14px}
 
   /* ─── Counters ───────────────────────────────────────────────── */
   .counter{font-size:11px;color:var(--muted);text-align:right;margin-top:3px}
@@ -1644,33 +1649,78 @@ async function mediaLoadMore(){
     (d.items||[]).forEach(m=>grid.appendChild(mlCard(m)));
     mlState.offset += (d.items||[]).length;
     btn.style.display = d.hasMore ? '' : 'none';
-    status.textContent = (grid.children.length).toLocaleString()+' shown'+(d.items.length===0&&mlState.offset===0?' — no matches':'');
+    if(grid.children.length===0){
+      if(mlState.source==='vendor' && !mlState.q){
+        grid.innerHTML=`<div class="ml-empty">No vendor Instagram posts ingested yet.<br>`+
+          `<span class="muted">Handles for 20 major houses are seeded (Schumacher, Thibaut, de Gournay, Cole &amp; Son…). `+
+          `Populate via Meta Graph (token) or by pasting post URLs — then real, cached images appear here.</span></div>`;
+      } else {
+        grid.innerHTML=`<div class="ml-empty muted">No matches.</div>`;
+      }
+    }
+    status.textContent = (grid.querySelectorAll('.ml-card').length).toLocaleString()+' shown';
   }catch(e){ status.textContent='Error: '+e.message; }
   finally{ mlState.loading=false; btn.disabled=false; }
 }
 
 function mlCard(m){
   const c=document.createElement('div'); c.className='ml-card';
-  const srcLabel={shopify:'Shopify',instagram:'Instagram',vendor:'Vendor'}[m.source]||m.source;
+  const srcLabel={shopify:'Shopify',instagram:'Instagram',vendor:'Vendor IG'}[m.source]||m.source;
   const safe=s=>(s||'').replace(/"/g,'&quot;').replace(/</g,'&lt;');
+  const proxied='/api/media/img?u='+encodeURIComponent(m.url);
+  // vendor/IG images are hotlink-blocked or expiring → load via proxy/cache up
+  // front. Shopify CDN is fast+public → load direct, fall back to proxy on error.
+  const initialSrc = (m.source==='shopify') ? safe(m.url) : proxied;
   c.innerHTML=
     `<span class="src">${srcLabel}</span>`+
-    `<img class="thumb" loading="lazy" src="${safe(m.url)}" alt="${safe(m.title)}" `+
-      `onerror="this.style.opacity=.25;this.alt='(image failed)'">`+
+    `<img class="thumb" loading="lazy" src="${initialSrc}" alt="${safe(m.title)}" `+
+      `data-raw="${safe(m.url)}" data-proxied="0">`+
     `<div class="meta"><div class="mt" title="${safe(m.title)}">${safe(m.title)}</div>`+
-      (m.vendor?`<div class="mv">${safe(m.vendor)}</div>`:'')+`</div>`+
-    `<button class="att">+ Attach to compose</button>`;
+      (m.vendor?`<div class="mv">${safe(m.vendor)}</div>`:'')+
+      (m.link?`<a class="mv" style="color:var(--gold-dim)" href="${safe(m.link)}" target="_blank" rel="noopener noreferrer">view post ↗</a>`:'')+`</div>`+
+    `<div class="ml-acts"><button class="att">+ Attach</button><button class="rep">↻ Repost / Use</button></div>`;
+  // proxy fallback on load error (one retry), then dim.
+  const img=c.querySelector('img.thumb');
+  img.onerror=function(){
+    if(this.dataset.proxied==='0'){ this.dataset.proxied='1'; this.src='/api/media/img?u='+encodeURIComponent(this.dataset.raw); }
+    else { this.style.opacity=.2; this.alt='(image unavailable)'; this.onerror=null; }
+  };
   c.querySelector('.att').onclick=(e)=>{
     const b=e.currentTarget;
     state.global.media.push({type:guessType(m.url),ref:m.url,alt:m.title||'',name:(m.title||'').slice(0,40)});
     refreshMedia();
     b.textContent='✓ Attached'; b.classList.add('added');
-    setTimeout(()=>{ b.textContent='+ Attach to compose'; b.classList.remove('added'); },1600);
+    setTimeout(()=>{ b.textContent='+ Attach'; b.classList.remove('added'); },1600);
     toast('Added to compose — '+(state.global.media.length)+' media item'+(state.global.media.length>1?'s':''));
   };
+  c.querySelector('.rep').onclick=()=>mlRepost(m);
   return c;
 }
 
+// Repost / Use — start a NEW draft launch pre-filled with this asset + an
+// attribution line, then jump to Compose. Nothing posts live: it flows through
+// the normal Save → Submit → Steve sign-off → (simulated) publish gate.
+function mlRepost(m){
+  const credit = m.source==='vendor'
+    ? `\n\n📷 via ${m.vendor||'vendor'}${m.link?` — original: ${m.link}`:''}`
+    : '';
+  const body = (m.caption || m.title || '').slice(0,400) + credit;
+  state = {
+    id:null,
+    title: (m.source==='vendor'?'Repost — ':'Use — ')+(m.title||'asset').slice(0,50),
+    channelsSelected:['instagram'],
+    global:{ body, media:[{type:guessType(m.url),ref:m.url,alt:m.title||'',name:(m.title||'').slice(0,40)}] },
+    channels:{}, schedule:{mode:'draft',datetime:''}, status:'draft',
+    approval:{approved:false,by:null,at:null}, published:false,
+    repostOf: m.link||m.url, repostSource:m.source
+  };
+  activeChanTab=null; activePvTab=null;
+  showView('compose'); hydrate();
+  toast(m.source==='vendor'
+    ? 'Repost draft started — review credit/caption, then Save → Submit for sign-off.'
+    : 'Draft started from asset — edit, then Save.');
+}
+
 async function renderDashboard(){
   const dash=await api('/api/dashboard');
   const c=dash.counts;
diff --git a/server.js b/server.js
index e66c974..6942204 100644
--- a/server.js
+++ b/server.js
@@ -549,6 +549,65 @@ app.get('/api/media', async (req, res) => {
   }
 });
 
+// Image proxy + on-disk cache. Lets hotlink-blocked / expiring CDN images
+// (vendor IG, vendor sites) actually paint by fetching server-side with a
+// browser UA and caching the bytes locally. Read-only fetch of public URLs.
+const IMG_CACHE = path.join(DATA_DIR, 'img-cache');
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
+app.get('/api/media/img', async (req, res) => {
+  const u = req.query.u;
+  if (!u || !/^https?:\/\//i.test(u)) return res.status(400).end('bad url');
+  try {
+    if (!fs.existsSync(IMG_CACHE)) fs.mkdirSync(IMG_CACHE, { recursive: true });
+    const key = crypto.createHash('sha1').update(u).digest('hex');
+    const hit = fs.readdirSync(IMG_CACHE).find(f => f.startsWith(key + '.'));
+    if (hit) {
+      res.set('Cache-Control', 'public, max-age=604800');
+      return res.sendFile(path.join(IMG_CACHE, hit));
+    }
+    let ref;
+    try { ref = new URL(u).origin + '/'; } catch { ref = undefined; }
+    const r = await fetch(u, { headers: { 'User-Agent': UA, ...(ref ? { Referer: ref } : {}), Accept: 'image/*,*/*' } });
+    if (!r.ok) return res.status(502).end('upstream ' + r.status);
+    const ct = r.headers.get('content-type') || 'image/jpeg';
+    if (!/^image\//.test(ct)) return res.status(415).end('not an image');
+    const buf = Buffer.from(await r.arrayBuffer());
+    const ext = ct.includes('png') ? 'png' : ct.includes('webp') ? 'webp' : ct.includes('gif') ? 'gif' : 'jpg';
+    fs.writeFileSync(path.join(IMG_CACHE, key + '.' + ext), buf);
+    res.set('Content-Type', ct);
+    res.set('Cache-Control', 'public, max-age=604800');
+    res.end(buf);
+  } catch (e) {
+    res.status(502).end('proxy error');
+  }
+});
+
+// ---- VENDOR INSTAGRAM ingest -------------------------------------------
+// Populate the vendor-IG store with real posts. Two paths:
+//  - graph:      Meta business_discovery (needs META_ACCESS_TOKEN + IG_USER_ID)
+//  - permalinks: public /embed/ extraction for a list of post URLs (no token)
+const vendorIg = require('./vendor-ig');
+app.get('/api/vendor-ig/handles', (_req, res) => {
+  res.json({ handles: vendorIg.handles(), posts: vendorIg.count() });
+});
+app.post('/api/vendor-ig/ingest-graph', async (_req, res) => {
+  try {
+    if (!process.env.META_ACCESS_TOKEN || !process.env.IG_USER_ID)
+      return res.status(400).json({ error: 'NO_TOKEN', message: 'Set META_ACCESS_TOKEN and IG_USER_ID (our IG Business account) to ingest via Meta Graph.' });
+    const out = await vendorIg.ingestViaGraph({});
+    res.json({ ok: true, ...out });
+  } catch (e) { res.status(500).json({ error: 'INGEST_FAILED', message: e.message }); }
+});
+app.post('/api/vendor-ig/ingest-permalinks', async (req, res) => {
+  try {
+    const urls = (req.body && req.body.urls) || [];
+    if (!Array.isArray(urls) || !urls.length)
+      return res.status(400).json({ error: 'NO_URLS', message: 'POST { urls: ["https://instagram.com/p/...", ...] }' });
+    const out = await vendorIg.ingestPermalinks(urls);
+    res.json({ ok: true, ...out });
+  } catch (e) { res.status(500).json({ error: 'INGEST_FAILED', message: e.message }); }
+});
+
 app.get('/api/health', (_req, res) => res.json({ ok: true, port: PORT }));
 
 app.listen(PORT, () => {
diff --git a/vendor-ig.js b/vendor-ig.js
new file mode 100644
index 0000000..aafe237
--- /dev/null
+++ b/vendor-ig.js
@@ -0,0 +1,157 @@
+'use strict';
+/*
+ * Vendor-Instagram store + ingest.
+ *
+ * Other vendors' Instagram posts are not in dw_unified in any usable form
+ * (the old `vendor_images` rows are website scrapes that WAF-block on fetch and
+ * aren't IG). This module owns a self-contained JSON store of REAL vendor IG
+ * posts whose images are cached to disk, so the Media → Vendor IG tab paints
+ * reliably and supports Repost / Use.
+ *
+ * Store: data/vendor-ig.json
+ *   { handles: [{handle, vendor, name}],
+ *     posts:   [{id, handle, vendor, image_url, cached, caption, permalink, posted_at}] }
+ * Cached image bytes live in data/img-cache (shared with the server img proxy);
+ * `cached` holds the /api/media/img?... URL that serves the local copy.
+ *
+ * Ingest paths (both populate `posts`):
+ *   A) ingestViaGraph()  — Meta Graph `business_discovery` (TOS-compliant). Needs
+ *      env META_ACCESS_TOKEN + IG_USER_ID (our IG Business account). FREE.
+ *   B) ingestPermalinks() — for a list of public post URLs, pull the image via
+ *      Instagram's public /embed/ endpoint (no token). FREE, works today, but
+ *      requires knowing the post URLs up front.
+ */
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const DATA_DIR = path.join(__dirname, 'data');
+const STORE = path.join(DATA_DIR, 'vendor-ig.json');
+const IMG_CACHE = path.join(DATA_DIR, 'img-cache');
+const SEED = path.join(DATA_DIR, 'vendor-ig.seed.json');
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
+
+function load() {
+  try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); }
+  catch {
+    try { const s = JSON.parse(fs.readFileSync(SEED, 'utf8')); save(s); return s; }
+    catch { return { handles: [], posts: [] }; }
+  }
+}
+function save(s) {
+  if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
+  fs.writeFileSync(STORE, JSON.stringify(s, null, 2));
+}
+
+// normalised rows for the media library (newest first)
+function list({ search = '', limit = 60, offset = 0 } = {}) {
+  const s = load();
+  let posts = s.posts.slice();
+  if (search) {
+    const q = search.toLowerCase();
+    posts = posts.filter(p =>
+      (p.caption || '').toLowerCase().includes(q) ||
+      (p.vendor || '').toLowerCase().includes(q) ||
+      (p.handle || '').toLowerCase().includes(q));
+  }
+  posts.sort((a, b) => new Date(b.posted_at || 0) - new Date(a.posted_at || 0));
+  return posts.slice(offset, offset + limit).map(p => ({
+    url: p.cached || p.image_url,
+    title: p.caption ? p.caption.slice(0, 70) : ('@' + p.handle),
+    source: 'vendor',
+    vendor: '@' + p.handle + (p.vendor ? ' · ' + p.vendor : ''),
+    caption: p.caption || '',
+    link: p.permalink || ''
+  }));
+}
+function count() { return load().posts.length; }
+function handles() { return load().handles; }
+
+// --- image caching (writes into the shared img-cache the proxy reads) -------
+async function cacheImage(url) {
+  if (!/^https?:\/\//i.test(url)) return null;
+  if (!fs.existsSync(IMG_CACHE)) fs.mkdirSync(IMG_CACHE, { recursive: true });
+  const key = crypto.createHash('sha1').update(url).digest('hex');
+  const existing = fs.readdirSync(IMG_CACHE).find(f => f.startsWith(key + '.'));
+  if (!existing) {
+    let ref; try { ref = new URL(url).origin + '/'; } catch {}
+    const r = await fetch(url, { headers: { 'User-Agent': UA, ...(ref ? { Referer: ref } : {}), Accept: 'image/*,*/*' } });
+    if (!r.ok) throw new Error('img ' + r.status);
+    const ct = r.headers.get('content-type') || 'image/jpeg';
+    const ext = ct.includes('png') ? 'png' : ct.includes('webp') ? 'webp' : ct.includes('gif') ? 'gif' : 'jpg';
+    fs.writeFileSync(path.join(IMG_CACHE, key + '.' + ext), Buffer.from(await r.arrayBuffer()));
+  }
+  return '/api/media/img?u=' + encodeURIComponent(url);
+}
+
+function upsert(store, post) {
+  const i = store.posts.findIndex(p => p.id === post.id);
+  if (i >= 0) store.posts[i] = { ...store.posts[i], ...post };
+  else store.posts.push(post);
+}
+
+// --- A) Meta Graph business_discovery --------------------------------------
+async function ingestViaGraph({ token = process.env.META_ACCESS_TOKEN, igUserId = process.env.IG_USER_ID, perHandle = 12 } = {}) {
+  if (!token || !igUserId) throw new Error('META_ACCESS_TOKEN and IG_USER_ID required for Graph ingest');
+  const store = load();
+  const out = { ok: [], failed: [], posts: 0 };
+  for (const h of store.handles) {
+    try {
+      const fields = `business_discovery.username(${h.handle}){username,media.limit(${perHandle}){id,media_url,thumbnail_url,permalink,caption,timestamp,media_type}}`;
+      const u = `https://graph.facebook.com/v21.0/${igUserId}?fields=${encodeURIComponent(fields)}&access_token=${token}`;
+      const r = await fetch(u);
+      const j = await r.json();
+      if (j.error) throw new Error(j.error.message);
+      const media = (j.business_discovery && j.business_discovery.media && j.business_discovery.media.data) || [];
+      for (const m of media) {
+        const img = m.media_type === 'VIDEO' ? (m.thumbnail_url || m.media_url) : m.media_url;
+        if (!img) continue;
+        let cached = null; try { cached = await cacheImage(img); } catch {}
+        upsert(store, {
+          id: 'ig_' + m.id, handle: h.handle, vendor: h.vendor || h.name,
+          image_url: img, cached, caption: m.caption || '',
+          permalink: m.permalink || '', posted_at: m.timestamp || null
+        });
+        out.posts++;
+      }
+      out.ok.push(h.handle);
+    } catch (e) { out.failed.push({ handle: h.handle, error: e.message }); }
+  }
+  save(store);
+  return out;
+}
+
+// --- B) public /embed/ extraction (no token) -------------------------------
+async function ingestPermalinks(urls = []) {
+  const store = load();
+  const out = { ok: [], failed: [], posts: 0 };
+  for (const url of urls) {
+    try {
+      const m = url.match(/instagram\.com\/(?:[^/]+\/)?(p|reel|tv)\/([A-Za-z0-9_-]+)/);
+      if (!m) throw new Error('not an IG post url');
+      const shortcode = m[2];
+      const r = await fetch(`https://www.instagram.com/p/${shortcode}/embed/captioned/`, { headers: { 'User-Agent': UA } });
+      if (!r.ok) throw new Error('embed ' + r.status);
+      const html = await r.text();
+      // the embed inlines the display image and (often) the caption
+      const img = (html.match(/"display_url":"([^"]+)"/) || html.match(/<img[^>]+class="EmbeddedMediaImage"[^>]+src="([^"]+)"/) || [])[1];
+      const imgUrl = img ? img.replace(/\\u0026/g, '&').replace(/\\\//g, '/') : null;
+      if (!imgUrl) throw new Error('no image in embed');
+      const handleMatch = html.match(/instagram\.com\/([A-Za-z0-9_.]+)\/\?utm/) || html.match(/"owner":\{"username":"([^"]+)"/);
+      const handle = (handleMatch && handleMatch[1]) || 'unknown';
+      const capMatch = html.match(/class="Caption"[^>]*>([\s\S]*?)<\/div>/);
+      const caption = capMatch ? capMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400) : '';
+      let cached = null; try { cached = await cacheImage(imgUrl); } catch {}
+      const known = store.handles.find(x => x.handle.toLowerCase() === handle.toLowerCase());
+      upsert(store, {
+        id: 'ig_' + shortcode, handle, vendor: known ? (known.vendor || known.name) : '',
+        image_url: imgUrl, cached, caption, permalink: `https://www.instagram.com/p/${shortcode}/`, posted_at: null
+      });
+      out.posts++; out.ok.push(shortcode);
+    } catch (e) { out.failed.push({ url, error: e.message }); }
+  }
+  save(store);
+  return out;
+}
+
+module.exports = { load, save, list, count, handles, cacheImage, ingestViaGraph, ingestPermalinks };

← b51421c Add unified Inbox (Hootsuite parity): triage all mentions/co  ·  back to Dw Launches  ·  Vendor-IG: Browserbase+Picuki ingest live — 73 real IG image 626db0f →