← 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
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">— ${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 — 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 & Outbound Links — The Design World in One Place</div>
<div class="masthead-meta">Updated hourly • Wallpaper Watch every 30 min • ${now} PT</div>
</header>
<!-- TOP STORY -->
${topHtml}
<!-- WALLPAPER WATCH -->
<div class="wallpaper-watch-bar">
<h2>★ Wallpaper Watch ★</h2>
<div class="ww-tagline">New Arrivals • Collections • Press Releases — Updated Every 30 Minutes</div>
</div>
<div class="wallpaper-grid">
${buildWallpaperCols(wallpaperHeadlines)}
</div>
<!-- SECTION HEADER: NEWS -->
<div class="section-header">Trade • Consumer • 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>
© ${new Date().getFullYear()} Abrams Report — Design Industry Intelligence —
Headlines & links only — No excerpts — All content © 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…</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}`);
});