← back to Abrams Report

server.js

443 lines

'use strict';
const express = require('express');
const path = require('path');
const { getHeadlines } = require('./scrapers/db');
const { CAT, SOCIAL_LINKS } = require('./scrapers/sources');

const PORT = process.env.PORT || 9779;
const app = express();

app.set('trust proxy', true);

// Cache-Control: no-store on all HTML responses (Cloudflare-aware)
app.use((req, res, next) => {
  res.setHeader('Cache-Control', 'no-store, must-revalidate');
  next();
});

// Hard-block snapshot/backup file paths so accidental commits never serve.
app.use((req, res, next) => {
  if (/\.(bak|orig|rej)(\.|$)|\.pre-|~$/i.test(req.path)) {
    return res.status(404).send('Not Found');
  }
  next();
});

app.use(express.static(path.join(__dirname, 'public')));

// ─── Helper: build page HTML ─────────────────────────────────────────────────
function renderPage(data) {
  const {
    wallpaperHeadlines,
    tradePubHeadlines,
    consumerHeadlines,
    designCenterHeadlines,
    topStory,
    socialLinks,
    fetchedAt,
  } = data;

  function headlineLink(h) {
    let prefix = '';
    if (h.is_breaking) prefix = '<span class="tag tag-breaking">BREAKING:</span> ';
    else if (h.is_developing) prefix = '<span class="tag tag-developing">DEVELOPING:</span> ';
    return `<li><a href="${escHtml(h.url)}" target="_blank" rel="noopener noreferrer">${prefix}${escHtml(h.title)}</a></li>`;
  }

  function col(headlines, max = 18) {
    return headlines.slice(0, max).map(headlineLink).join('\n');
  }

  function escHtml(s) {
    return String(s || '')
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }

  const topHtml = topStory
    ? `<div class="top-story">
        🚨 <a href="${escHtml(topStory.url)}" target="_blank" rel="noopener noreferrer">${escHtml(topStory.title)}</a>
        <span class="top-source">&mdash; ${escHtml(topStory.source)}</span>
      </div>`
    : '';

  const socialHtml = socialLinks.map(s => `
    <div class="social-item">
      <span class="social-name">${escHtml(s.name)}</span>
      <a href="${escHtml(s.ig)}" target="_blank" rel="noopener noreferrer">IG</a>
      <a href="${escHtml(s.li)}" target="_blank" rel="noopener noreferrer">LinkedIn</a>
    </div>`).join('\n');

  const now = new Date(fetchedAt * 1000).toLocaleString('en-US', {
    timeZone: 'America/Los_Angeles',
    month: 'short', day: 'numeric', year: 'numeric',
    hour: 'numeric', minute: '2-digit', hour12: true,
  });

  // Split trade + consumer headlines across 3 columns
  const trade = tradePubHeadlines.slice(0, 24);
  const consumer = consumerHeadlines.slice(0, 24);
  const centers = designCenterHeadlines.slice(0, 24);

  // Interleave trade + consumer for left 2 columns; design centers get col 3
  const leftCol = [...trade.slice(0, 12), ...consumer.slice(0, 6)];
  const midCol = [...trade.slice(12, 24), ...consumer.slice(6, 12)];
  const rightCol = centers;

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ABRAMS REPORT &mdash; Design Industry Intelligence</title>
  <meta name="description" content="The design industry's headline service. Trade news, market intelligence, and wallcovering watch — updated hourly.">
  <style>
    /* ── Reset + Base ──────────────────────────────────────────── */
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    html { font-size: 16px; }
    body {
      background: #f5f0e8;
      color: #111;
      font-family: Georgia, 'Times New Roman', Times, serif;
      line-height: 1.45;
    }
    a { color: #111; text-decoration: underline; }
    a:hover { color: #8b0000; }

    /* ── Masthead ──────────────────────────────────────────────── */
    .masthead {
      border-top: 6px solid #111;
      border-bottom: 3px double #111;
      text-align: center;
      padding: 18px 20px 14px;
      background: #f5f0e8;
    }
    .masthead-eyebrow {
      font-size: 0.65rem;
      letter-spacing: 0.18em;
      text-transform: uppercase;
      margin-bottom: 4px;
      color: #555;
    }
    .masthead-title {
      font-size: clamp(2.4rem, 6vw, 4.8rem);
      font-weight: 700;
      letter-spacing: -0.01em;
      line-height: 1;
      text-transform: uppercase;
    }
    .masthead-tagline {
      font-size: 0.75rem;
      font-style: italic;
      color: #444;
      margin-top: 6px;
      letter-spacing: 0.08em;
    }
    .masthead-meta {
      font-size: 0.65rem;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      margin-top: 10px;
      color: #666;
      border-top: 1px solid #bbb;
      padding-top: 8px;
    }

    /* ── Top Story Banner ───────────────────────────────────────── */
    .top-story {
      background: #111;
      color: #f5f0e8;
      text-align: center;
      padding: 10px 20px;
      font-size: 1.05rem;
      font-weight: 700;
      border-bottom: 3px solid #8b0000;
    }
    .top-story a { color: #fff; }
    .top-story a:hover { color: #ffcccc; }
    .top-source {
      font-weight: 400;
      font-style: italic;
      font-size: 0.85rem;
      opacity: 0.85;
    }

    /* ── WALLPAPER WATCH ──────────────────────────────────────── */
    .wallpaper-watch-bar {
      background: #8b0000;
      color: #fff;
      text-align: center;
      padding: 12px 20px 10px;
      border-bottom: 2px solid #5c0000;
    }
    .wallpaper-watch-bar h2 {
      font-size: clamp(1.4rem, 4vw, 2.4rem);
      font-style: italic;
      letter-spacing: 0.06em;
      text-transform: uppercase;
    }
    .wallpaper-watch-bar .ww-tagline {
      font-size: 0.7rem;
      letter-spacing: 0.15em;
      text-transform: uppercase;
      opacity: 0.85;
      margin-top: 3px;
    }

    .wallpaper-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
      gap: 0;
      border-bottom: 3px double #111;
    }
    .wallpaper-col {
      padding: 12px 14px;
      border-right: 1px solid #ccc;
    }
    .wallpaper-col:last-child { border-right: none; }
    .wallpaper-col h3 {
      font-size: 0.65rem;
      text-transform: uppercase;
      letter-spacing: 0.14em;
      color: #8b0000;
      border-bottom: 1px solid #8b0000;
      padding-bottom: 4px;
      margin-bottom: 8px;
    }
    .wallpaper-col ul { list-style: none; }
    .wallpaper-col li {
      border-bottom: 1px dotted #ccc;
      padding: 4px 0;
      font-size: 0.78rem;
      line-height: 1.35;
    }
    .wallpaper-col li a { text-decoration: none; }
    .wallpaper-col li a:hover { text-decoration: underline; color: #8b0000; }

    /* ── Three-column main ───────────────────────────────────────── */
    .section-header {
      background: #111;
      color: #f5f0e8;
      text-align: center;
      padding: 7px 20px;
      font-size: 0.65rem;
      letter-spacing: 0.18em;
      text-transform: uppercase;
    }

    .three-col {
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      border-bottom: 3px double #111;
    }
    @media (max-width: 768px) {
      .three-col { grid-template-columns: 1fr; }
      .wallpaper-grid { grid-template-columns: 1fr 1fr; }
    }
    @media (max-width: 480px) {
      .wallpaper-grid { grid-template-columns: 1fr; }
    }

    .col {
      padding: 14px 16px;
      border-right: 1px solid #bbb;
    }
    .col:last-child { border-right: none; }
    .col h3 {
      font-size: 0.65rem;
      text-transform: uppercase;
      letter-spacing: 0.14em;
      color: #555;
      border-bottom: 1px solid #bbb;
      padding-bottom: 5px;
      margin-bottom: 10px;
    }
    .col ul { list-style: none; }
    .col li {
      border-bottom: 1px dotted #ccc;
      padding: 5px 0;
      font-size: 0.8rem;
      line-height: 1.38;
    }
    .col li a { text-decoration: none; }
    .col li a:hover { text-decoration: underline; color: #8b0000; }

    /* ── Tags ────────────────────────────────────────────────────── */
    .tag { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; }
    .tag-breaking { color: #8b0000; }
    .tag-developing { font-style: italic; color: #333; }

    /* ── Social Row ─────────────────────────────────────────────── */
    .social-section {
      border-top: 1px solid #bbb;
      padding: 14px 20px;
      background: #ede8de;
    }
    .social-section h3 {
      font-size: 0.65rem;
      text-transform: uppercase;
      letter-spacing: 0.16em;
      margin-bottom: 10px;
      color: #555;
    }
    .social-row {
      display: flex;
      flex-wrap: wrap;
      gap: 14px;
    }
    .social-item {
      font-size: 0.72rem;
      display: flex;
      align-items: center;
      gap: 6px;
    }
    .social-name { font-style: italic; color: #444; }
    .social-item a {
      font-size: 0.65rem;
      font-weight: 700;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      color: #8b0000;
      text-decoration: none;
    }
    .social-item a:hover { text-decoration: underline; }

    /* ── Footer ─────────────────────────────────────────────────── */
    footer {
      text-align: center;
      padding: 14px;
      font-size: 0.62rem;
      color: #888;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      border-top: 2px solid #111;
    }
  </style>
</head>
<body>

  <!-- MASTHEAD -->
  <header class="masthead">
    <div class="masthead-eyebrow">The Design Industry's Source</div>
    <div class="masthead-title">Abrams Report</div>
    <div class="masthead-tagline">Headlines &amp; Outbound Links &mdash; The Design World in One Place</div>
    <div class="masthead-meta">Updated hourly &bull; Wallpaper Watch every 30 min &bull; ${now} PT</div>
  </header>

  <!-- TOP STORY -->
  ${topHtml}

  <!-- WALLPAPER WATCH -->
  <div class="wallpaper-watch-bar">
    <h2>&#9733; Wallpaper Watch &#9733;</h2>
    <div class="ww-tagline">New Arrivals &bull; Collections &bull; Press Releases &mdash; Updated Every 30 Minutes</div>
  </div>

  <div class="wallpaper-grid">
    ${buildWallpaperCols(wallpaperHeadlines)}
  </div>

  <!-- SECTION HEADER: NEWS -->
  <div class="section-header">Trade &bull; Consumer &bull; Design Centers</div>

  <!-- THREE COLUMNS -->
  <div class="three-col">
    <div class="col">
      <h3>Trade Publications</h3>
      <ul>${col(leftCol)}</ul>
    </div>
    <div class="col">
      <h3>Consumer Design Press</h3>
      <ul>${col(midCol)}</ul>
    </div>
    <div class="col">
      <h3>Design Centers</h3>
      <ul>${col(rightCol)}</ul>
    </div>
  </div>

  <!-- SOCIAL FOLLOW -->
  <div class="social-section">
    <h3>Follow the Sources</h3>
    <div class="social-row">
      ${socialHtml}
    </div>
  </div>

  <footer>
    &copy; ${new Date().getFullYear()} Abrams Report &mdash; Design Industry Intelligence &mdash;
    Headlines &amp; links only &mdash; No excerpts &mdash; All content &copy; respective publishers
  </footer>

</body>
</html>`;

  // ── Wallpaper cols helper ──────────────────────────────────────────────────
  function buildWallpaperCols(headlines) {
    // Group by source
    const bySource = {};
    for (const h of headlines) {
      if (!bySource[h.source]) bySource[h.source] = [];
      bySource[h.source].push(h);
    }
    const cols = Object.entries(bySource).map(([sourceName, items]) => {
      const links = items.slice(0, 6).map(h =>
        `<li><a href="${escHtml(h.url)}" target="_blank" rel="noopener noreferrer">${escHtml(h.title)}</a></li>`
      ).join('\n');
      return `<div class="wallpaper-col">
        <h3>${escHtml(sourceName)}</h3>
        <ul>${links}</ul>
      </div>`;
    });
    return cols.join('\n') || '<div class="wallpaper-col"><p style="font-size:.8rem;color:#888;padding:10px">Scraping in progress&hellip;</p></div>';
  }
}

// ─── Routes ─────────────────────────────────────────────────────────────────

app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));

app.get('/', (req, res) => {
  try {
    const since = Math.floor(Date.now() / 1000) - 7 * 24 * 3600; // 7 days window

    const wallpaper = getHeadlines({ category: CAT.WALLPAPER, limit: 100, since });
    const trade = getHeadlines({ category: CAT.TRADE, limit: 60, since });
    const consumer = getHeadlines({ category: CAT.CONSUMER, limit: 60, since });
    const centers = getHeadlines({ category: CAT.DESIGN_CENTER, limit: 40, since });

    // Top story: most recent breaking item, or just most recent
    const all = [...wallpaper, ...trade, ...consumer, ...centers];
    const topStory = all.find(h => h.is_breaking) || all[0] || null;

    const html = renderPage({
      wallpaperHeadlines: wallpaper,
      tradePubHeadlines: trade,
      consumerHeadlines: consumer,
      designCenterHeadlines: centers,
      topStory,
      socialLinks: SOCIAL_LINKS,
      fetchedAt: Math.floor(Date.now() / 1000),
    });

    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(html);
  } catch (err) {
    console.error('[server] render error:', err);
    res.status(500).send('<h1>Abrams Report — Temporarily Unavailable</h1>');
  }
});

app.get('/api/headlines', (req, res) => {
  const since = Math.floor(Date.now() / 1000) - 7 * 24 * 3600;
  const { category, limit = 50 } = req.query;
  const headlines = getHeadlines({ category, limit: parseInt(limit), since });
  res.json({ ok: true, count: headlines.length, headlines });
});

app.listen(PORT, () => {
  console.log(`[abrams-report] Listening on :${PORT}`);
});