← back to Yolo Agent

scripts/body-html-cleanup.js

149 lines

#!/usr/bin/env node
const path = require('path');
try { require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); } catch (_) {}
/**
 * Body HTML Cleanup — Remove specs tables from product descriptions.
 * Rewrites body_html to clean 3-sentence descriptions.
 * Skips Phillip Jeffries (browse-hidden).
 */

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const API = `https://${SHOP}/admin/api/2024-10`;
const fs = require('fs');
if (!TOKEN) {
  console.error('Missing SHOPIFY_ADMIN_TOKEN.');
  process.exit(1);
}

const SPECS_PATTERNS = [
  /<table[\s>]/i,
  /<ul>\s*<li>\s*Width/i,
  /Specifications/i,
  /Material\s*:/i,
  /Collection\s*:/i,
  /<li>\s*Width/i,
  /<li>\s*Content/i,
  /\d+\s*in\.\s*wide\s*x\s*\d+/i,
  /Colors may be/i,
  /per single roll/i,
  /printed on Demand/i,
];

const SKIP_VENDORS = ['Phillip Jeffries'];

async function shopifyGet(url) {
  const resp = await fetch(url, {
    headers: { 'X-Shopify-Access-Token': TOKEN }
  });
  if (resp.status === 429) {
    const wait = parseFloat(resp.headers.get('Retry-After') || '4');
    await new Promise(r => setTimeout(r, (wait + 1) * 1000));
    return shopifyGet(url);
  }
  if (!resp.ok) throw new Error(`GET ${resp.status}: ${url}`);
  return resp.json();
}

async function shopifyPut(url, data) {
  await new Promise(r => setTimeout(r, 500));
  const resp = await fetch(url, {
    method: 'PUT',
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  if (resp.status === 429) {
    const wait = parseFloat(resp.headers.get('Retry-After') || '4');
    await new Promise(r => setTimeout(r, (wait + 1) * 1000));
    return shopifyPut(url, data);
  }
  if (!resp.ok) {
    const body = await resp.text();
    throw new Error(`PUT ${resp.status}: ${body.slice(0, 200)}`);
  }
  return resp.json();
}

function hasSpecs(bodyHtml) {
  if (!bodyHtml) return false;
  return SPECS_PATTERNS.some(p => p.test(bodyHtml));
}

function escapeHtml(value) {
  return String(value ?? '').replace(/[&<>"']/g, ch => ({
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;'
  }[ch]));
}

function generateCleanBody(title, vendor) {
  const parts = title.split('|').map(s => s.trim());
  const nameColor = parts[0] || title;
  const brand = parts[1] || vendor;
  const nameParts = nameColor.split(' - ');
  const pattern = nameParts[0] || nameColor;
  const color = nameParts[1] || '';
  const colorPhrase = color ? ` in ${escapeHtml(color)}` : '';
  return `<p><strong>${escapeHtml(pattern)}</strong>${colorPhrase} by ${escapeHtml(brand)}. A premium wallcovering designed to bring refined elegance to any interior. Perfect for residential and commercial applications.</p>`;
}

async function run() {
  const logFile = path.join(__dirname, '..', 'logs', 'body-html-cleanup.log');
  let checked = 0, cleaned = 0, skipped = 0, failed = 0;
  let sinceId = 0;
  const LIMIT = 250;
  const MAX_CLEAN = 500;

  console.log(`Body HTML Cleanup — Starting at ${new Date().toISOString()}`);
  fs.appendFileSync(logFile, `\n=== Run ${new Date().toISOString()} ===\n`);

  while (cleaned < MAX_CLEAN) {
    const url = `${API}/products.json?limit=${LIMIT}&fields=id,title,body_html,vendor,status&since_id=${sinceId}`;
    let data;
    try {
      data = await shopifyGet(url);
    } catch (e) {
      console.error(`Fetch error: ${e.message}`);
      break;
    }
    const products = data.products || [];
    if (products.length === 0) break;

    for (const p of products) {
      checked++;
      sinceId = p.id;
      if (SKIP_VENDORS.includes(p.vendor)) { skipped++; continue; }
      if (p.status === 'archived') { skipped++; continue; }
      if (!hasSpecs(p.body_html)) continue;

      const cleanBody = generateCleanBody(p.title, p.vendor);
      try {
        await shopifyPut(`${API}/products/${p.id}.json`, { product: { id: p.id, body_html: cleanBody } });
        cleaned++;
        const msg = `CLEANED: ${p.id} | ${p.title.slice(0, 50)} | ${p.vendor}`;
        console.log(msg);
        fs.appendFileSync(logFile, msg + '\n');
      } catch (e) {
        failed++;
        console.error(`FAIL: ${p.id} | ${e.message.slice(0, 80)}`);
        fs.appendFileSync(logFile, `FAIL: ${p.id} | ${e.message.slice(0, 80)}\n`);
      }
      await new Promise(r => setTimeout(r, 500));
    }

    if (checked % 1000 === 0) {
      console.log(`Progress: checked=${checked} cleaned=${cleaned} skipped=${skipped} failed=${failed}`);
    }
    await new Promise(r => setTimeout(r, 300));
  }

  const summary = `DONE: checked=${checked} cleaned=${cleaned} skipped=${skipped} failed=${failed}`;
  console.log(summary);
  fs.appendFileSync(logFile, summary + '\n');
}

run().catch(e => { console.error('Fatal:', e); process.exit(1); });