Stock dashboard (index.html): - Replace all [DEMO]/demo badges on warehouse data with "FS.com" source labels (data was always real scraper data, never demo in the DB) - Update subtitle: "Scraper-Lagermengen: DEMO DATA" → "Wettbewerber-Marktdaten" - "Recently Restocked" badge: DEMO DATA → SCRAPER DATA Switch detail (queries.ts): - Fix getFlexoptixSuggestions: wavelength_nm → wavelength_tx_nm, price_verified_usd → street_price_usd (column mismatch with live schema) - DS5000 and other OSFP switches now show all 62 Flexoptix OSFP transceivers with direct shop links in the detail modal Hot Topics (hot-topics.ts): - NOG Talks + News Article clusters now fetch summary/mentioned_vendors/ mentioned_products/mentioned_standards from news_articles table - description field builds bullet-point list per article with summaries, key vendors/standards (vs. 3 bare titles joined with "|" before) - buildTopicBriefing() rewritten as structured LLM document with sections: Market Signals (bullets), Recommended Angle, Market Context (buy signal, technologies, impact horizon), Writing Instructions (600-900 words, actionable, opinionated, no generic summaries)
890 lines
39 KiB
TypeScript
890 lines
39 KiB
TypeScript
/**
|
||
* Hot Topics Engine — Discovers trending blog topics from multiple sources
|
||
*
|
||
* Sources:
|
||
* 1. OFC/ECOC/CIOE conference papers + announcements
|
||
* 2. Manufacturer press releases (InnoLight, Coherent, Broadcom, Marvell, Lumentum)
|
||
* 3. Trade press (Lightwave, Fibre Systems, Gazettabyte, LightCounting)
|
||
* 4. arXiv papers (cs.NI, eess.SP, physics.optics)
|
||
* 5. Internal: price movements, new competitor products, hype cycle shifts
|
||
* 6. University research groups (TU/e, UCL, DTU, Columbia, UCSB)
|
||
*/
|
||
import { Router } from "express";
|
||
import { pool } from "../db/client";
|
||
import { computeAllHypeCycles, TECH_GENERATIONS } from "../hype-cycle/norton-bass";
|
||
|
||
export const hotTopicsRouter = Router();
|
||
|
||
interface HotTopic {
|
||
title: string;
|
||
description: string;
|
||
blog_type: string;
|
||
urgency: "breaking" | "hot" | "trending" | "emerging";
|
||
source: string;
|
||
source_type: "conference" | "manufacturer" | "trade_press" | "research" | "internal_data" | "competitor";
|
||
data_context?: Record<string, unknown>;
|
||
suggested_angle?: string;
|
||
date?: string;
|
||
blog_title_created?: boolean;
|
||
last_blog_created_at?: string;
|
||
rank_score?: number;
|
||
llm_context?: string;
|
||
}
|
||
|
||
/**
|
||
* GET /api/hot-topics
|
||
*
|
||
* Returns dynamically ranked blog topics based on real signals.
|
||
*/
|
||
hotTopicsRouter.get("/", async (req, res) => {
|
||
try {
|
||
const topics: HotTopic[] = [];
|
||
const year = new Date().getFullYear();
|
||
const limit = Math.max(1, Math.min(50, parseInt(String(req.query.limit || "20"), 10) || 20));
|
||
|
||
// ═══ SOURCE 1: Internal Data — Price Movements ═══
|
||
const priceDrops = await pool.query(`
|
||
SELECT v.name AS vendor, t.form_factor, t.speed_gbps, t.reach_label,
|
||
pc.old_price, pc.new_price, pc.delta_pct, pc.currency, pc.detected_at
|
||
FROM price_changes pc
|
||
JOIN vendors v ON pc.vendor_id = v.id
|
||
JOIN transceivers t ON pc.transceiver_id = t.id
|
||
WHERE pc.delta_pct < -10 AND pc.detected_at > NOW() - INTERVAL '14 days'
|
||
ORDER BY pc.delta_pct ASC LIMIT 5
|
||
`).catch(() => ({ rows: [] }));
|
||
|
||
for (const drop of priceDrops.rows) {
|
||
topics.push({
|
||
title: `${drop.vendor} drops ${drop.form_factor} ${drop.speed_gbps}G prices by ${Math.abs(Math.round(drop.delta_pct))}%`,
|
||
description: `${drop.form_factor} ${drop.speed_gbps}G ${drop.reach_label} went from ${drop.currency} ${drop.old_price} to ${drop.currency} ${drop.new_price}. Market signal or clearance?`,
|
||
blog_type: "market_alert",
|
||
urgency: Math.abs(drop.delta_pct) > 20 ? "breaking" : "hot",
|
||
source: drop.vendor,
|
||
source_type: "competitor",
|
||
data_context: drop,
|
||
suggested_angle: `Price war analysis: Why ${drop.vendor} is cutting ${drop.speed_gbps}G pricing and what it means for procurement`,
|
||
date: drop.detected_at ? new Date(drop.detected_at).toISOString() : undefined,
|
||
});
|
||
}
|
||
|
||
// ═══ SOURCE 2: Internal Data — New Competitor Products ═══
|
||
const newProducts = await pool.query(`
|
||
SELECT ca.product_name, ca.form_factor, ca.speed_gbps, ca.source_url,
|
||
v.name AS vendor, ca.created_at
|
||
FROM competitor_alerts ca
|
||
JOIN vendors v ON ca.vendor_id = v.id
|
||
WHERE ca.alert_type = 'new_product' AND ca.created_at > NOW() - INTERVAL '14 days'
|
||
ORDER BY ca.speed_gbps DESC, ca.created_at DESC LIMIT 5
|
||
`).catch(() => ({ rows: [] }));
|
||
|
||
if (newProducts.rows.length > 0) {
|
||
const vendors = [...new Set(newProducts.rows.map(p => p.vendor))];
|
||
const speeds = [...new Set(newProducts.rows.map(p => p.speed_gbps + "G"))];
|
||
topics.push({
|
||
title: `${newProducts.rows.length} new transceiver products from ${vendors.slice(0, 3).join(", ")}`,
|
||
description: `New ${speeds.join("/")} products spotted. Competitive landscape shifting.`,
|
||
blog_type: "competitor_analysis",
|
||
urgency: "hot",
|
||
source: "TIP Scraper",
|
||
source_type: "internal_data",
|
||
data_context: { products: newProducts.rows },
|
||
suggested_angle: `Competitor roundup: What ${vendors[0]} and others just launched — and what it means for your next PO`,
|
||
date: newProducts.rows[0]?.created_at ? new Date(newProducts.rows[0].created_at).toISOString() : undefined,
|
||
});
|
||
}
|
||
|
||
// ═══ SOURCE 3: Internal Data — Hype Cycle Phase Transitions ═══
|
||
const hypes = computeAllHypeCycles(year);
|
||
const transitions = hypes.filter(h => {
|
||
const tech = TECH_GENERATIONS.find(t => t.name === h.technology);
|
||
if (!tech) return false;
|
||
// Technologies near phase boundaries are interesting
|
||
return (h.positionPct > 25 && h.positionPct < 35) || // Entering trough
|
||
(h.positionPct > 48 && h.positionPct < 55) || // Leaving trough
|
||
(h.positionPct > 78 && h.positionPct < 85); // Entering plateau
|
||
});
|
||
|
||
for (const t of transitions.slice(0, 2)) {
|
||
const phaseName = t.phase.replace(/_/g, " ").toLowerCase();
|
||
topics.push({
|
||
title: `${t.technology} entering ${phaseName}`,
|
||
description: `Adoption at ${Math.round(t.adoptionPct)}%. This phase transition means pricing and availability shifts.`,
|
||
blog_type: "hype_cycle",
|
||
urgency: "trending",
|
||
source: "Norton-Bass Model",
|
||
source_type: "internal_data",
|
||
data_context: { phase: t.phase, adoption: t.adoptionPct, position: t.positionPct },
|
||
suggested_angle: `${t.technology} phase shift: What it means for your ${year} infrastructure budget`,
|
||
});
|
||
}
|
||
|
||
// ═══ SOURCE 3b: Market Intelligence — Real scraped signals ═══
|
||
const marketIntel = await pool.query(`
|
||
SELECT title, summary, intel_type, relevance_score, buy_signal_implication,
|
||
technologies, source_name, published_at, impact_horizon_months
|
||
FROM market_intelligence
|
||
WHERE relevance_score > 0.6
|
||
ORDER BY relevance_score DESC, published_at DESC NULLS LAST
|
||
LIMIT 12
|
||
`).catch(() => ({ rows: [] }));
|
||
|
||
const intelTypeToUrgency: Record<string, HotTopic["urgency"]> = {
|
||
supply_chain: "hot", distributor_lead_time: "hot", capex_cycle: "trending",
|
||
standard_draft: "emerging", standard_ratified: "trending", trade_show: "hot",
|
||
tender: "trending", market_share: "trending", technology_launch: "hot", price_movement: "breaking"
|
||
};
|
||
const intelTypeToBlogType: Record<string, string> = {
|
||
supply_chain: "market_alert", distributor_lead_time: "market_alert", capex_cycle: "market_alert",
|
||
standard_draft: "technology_deep_dive", standard_ratified: "technology_deep_dive",
|
||
trade_show: "technology_deep_dive", tender: "market_alert", price_movement: "market_alert",
|
||
};
|
||
const buySignalToAngle: Record<string, string> = {
|
||
bullish: "Why now is the right time to buy",
|
||
bearish: "Why you should wait before ordering",
|
||
opportunity: "Strategic window: Short-term opportunity for procurement",
|
||
neutral: "Market context for your next procurement decision",
|
||
};
|
||
|
||
for (const m of marketIntel.rows) {
|
||
if (!isUsableMarketIntel(m)) continue;
|
||
const techList = Array.isArray(m.technologies) ? (m.technologies as string[]).join(", ") : "";
|
||
const angle = m.buy_signal_implication
|
||
? buySignalToAngle[m.buy_signal_implication] || m.buy_signal_implication
|
||
: "What this means for your network planning";
|
||
topics.push({
|
||
title: m.title,
|
||
description: m.summary || `${m.intel_type?.replace(/_/g, " ")} signal from ${m.source_name || "market data"}.`,
|
||
blog_type: intelTypeToBlogType[m.intel_type] || "market_alert",
|
||
urgency: intelTypeToUrgency[m.intel_type] || "trending",
|
||
source: m.source_name || "Market Intelligence",
|
||
source_type: "trade_press",
|
||
data_context: {
|
||
intel_type: m.intel_type,
|
||
relevance: m.relevance_score,
|
||
buy_signal: m.buy_signal_implication,
|
||
technologies: techList,
|
||
impact_months: m.impact_horizon_months,
|
||
},
|
||
suggested_angle: `${m.title}: ${angle}`,
|
||
date: m.published_at ? new Date(m.published_at).toISOString() : undefined,
|
||
});
|
||
}
|
||
|
||
// ═══ SOURCE 3c: NOG Conference Talks — scraped from NOG agendas ═══
|
||
const nogTalks = await pool.query(`
|
||
SELECT title, source, source_url, published_at, relevance_score,
|
||
summary, mentioned_vendors, mentioned_products, mentioned_standards
|
||
FROM news_articles
|
||
WHERE source LIKE 'NOG Talks:%'
|
||
AND relevance_score > 0.4
|
||
AND published_at > NOW() - INTERVAL '6 months'
|
||
ORDER BY relevance_score DESC, published_at DESC NULLS LAST
|
||
LIMIT 8
|
||
`).catch(() => ({ rows: [] }));
|
||
|
||
// Cluster NOG talks by NOG name
|
||
type NogRow = (typeof nogTalks.rows)[number];
|
||
const nogByEvent: Record<string, NogRow[]> = {};
|
||
for (const n of nogTalks.rows) {
|
||
const event = (n.source as string).replace("NOG Talks: ", "");
|
||
if (!nogByEvent[event]) nogByEvent[event] = [];
|
||
nogByEvent[event].push(n);
|
||
}
|
||
for (const [event, talks] of Object.entries(nogByEvent)) {
|
||
const topTalk = (talks as NogRow[])[0];
|
||
const talkBullets = (talks as NogRow[]).slice(0, 5).map(t => {
|
||
const vendors = Array.isArray(t.mentioned_vendors) ? (t.mentioned_vendors as string[]).slice(0, 3).join(", ") : "";
|
||
const products = Array.isArray(t.mentioned_products) ? (t.mentioned_products as string[]).slice(0, 3).join(", ") : "";
|
||
const extra = [vendors, products].filter(Boolean).join(" / ");
|
||
return `• ${t.title}${extra ? ` (${extra})` : ""}${t.summary ? ` — ${String(t.summary).slice(0, 120)}` : ""}`;
|
||
}).join("\n");
|
||
topics.push({
|
||
title: talks.length === 1
|
||
? `[${event}] ${topTalk.title}`
|
||
: `${event}: ${talks.length} optics-relevant talks`,
|
||
description: talkBullets || (talks as NogRow[]).map(t => t.title).slice(0, 3).join(" | "),
|
||
blog_type: "technology_deep_dive",
|
||
urgency: "hot",
|
||
source: event,
|
||
source_type: "conference",
|
||
data_context: { talks: (talks as NogRow[]).slice(0, 3) },
|
||
suggested_angle: `What ${event} presenters are actually deploying — lessons for your network refresh`,
|
||
date: topTalk.published_at ? new Date(topTalk.published_at).toISOString() : undefined,
|
||
});
|
||
}
|
||
|
||
// ═══ SOURCE 4: News Articles — Recent Industry News ═══
|
||
const recentNews = await pool.query(`
|
||
SELECT title, source, source_url, category, published_at,
|
||
COALESCE(relevance_score, 5) AS relevance,
|
||
summary, mentioned_vendors, mentioned_products, mentioned_standards, tags
|
||
FROM news_articles
|
||
WHERE source NOT LIKE 'NOG Talks:%'
|
||
AND published_at > NOW() - INTERVAL '14 days'
|
||
ORDER BY relevance_score DESC NULLS LAST, published_at DESC
|
||
LIMIT 12
|
||
`).catch(() => ({ rows: [] }));
|
||
|
||
// Cluster news by theme
|
||
type NewsRow = (typeof recentNews.rows)[number];
|
||
const newsThemes: Record<string, NewsRow[]> = {};
|
||
for (const n of recentNews.rows) {
|
||
const theme = detectNewsTheme(n.title);
|
||
if (!newsThemes[theme]) newsThemes[theme] = [];
|
||
newsThemes[theme].push(n);
|
||
}
|
||
|
||
for (const [theme, articles] of Object.entries(newsThemes)) {
|
||
if (articles.length >= 1) {
|
||
// Build rich description with article summaries, vendors, standards mentioned
|
||
const articleBullets = (articles as NewsRow[]).slice(0, 5).map(a => {
|
||
const vendors = Array.isArray(a.mentioned_vendors) ? (a.mentioned_vendors as string[]).slice(0, 3).join(", ") : "";
|
||
const stds = Array.isArray(a.mentioned_standards) ? (a.mentioned_standards as string[]).slice(0, 2).join(", ") : "";
|
||
const meta = [vendors, stds].filter(Boolean).join(" / ");
|
||
return `• ${a.title}${meta ? ` [${meta}]` : ""}${a.summary ? ` — ${String(a.summary).slice(0, 150)}` : ""}`;
|
||
}).join("\n");
|
||
|
||
topics.push({
|
||
title: `${theme}: ${articles.length} recent articles`,
|
||
description: articleBullets || articles.map(a => a.title).slice(0, 3).join(" | "),
|
||
blog_type: "technology_deep_dive",
|
||
urgency: "trending",
|
||
source: articles.map(a => a.source).filter(Boolean).slice(0, 2).join(", ") || "Trade Press",
|
||
source_type: "trade_press",
|
||
data_context: { articles: articles.slice(0, 5) },
|
||
suggested_angle: `${theme}: What the latest announcements actually mean for network operators`,
|
||
date: articles[0]?.published_at ? new Date(articles[0].published_at).toISOString() : undefined,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ═══ SOURCE 5: Conference Calendar — Upcoming/Recent Events ═══
|
||
const conferences = getConferenceTopics(year);
|
||
topics.push(...conferences);
|
||
|
||
// ═══ SOURCE 6: Emerging Tech — Research-Driven Topics ═══
|
||
topics.push(...getResearchTopics(year));
|
||
|
||
// ═══ SOURCE 7: Evergreen High-Value Topics ═══
|
||
topics.push(...getEvergreenTopics(year));
|
||
|
||
const cleanTopics = dedupeTopics(topics.filter(isUsableTopic));
|
||
|
||
// Mark already-created topics and rank with daily rotation + source diversity.
|
||
const recentDrafts = await pool.query(`
|
||
SELECT title, created_at
|
||
FROM blog_drafts
|
||
WHERE created_at > NOW() - INTERVAL '180 days'
|
||
ORDER BY created_at DESC
|
||
`).catch(() => ({ rows: [] }));
|
||
|
||
const createdByTitle = new Map<string, string>();
|
||
for (const draft of recentDrafts.rows) {
|
||
const key = normalizeTopicTitle(String(draft.title || ""));
|
||
if (key && !createdByTitle.has(key)) {
|
||
createdByTitle.set(key, draft.created_at ? new Date(draft.created_at).toISOString() : new Date().toISOString());
|
||
}
|
||
}
|
||
|
||
const daySeed = getDaySeed();
|
||
const rotationSeed = daySeed + getQuerySeed(req.query.shuffle);
|
||
for (const topic of cleanTopics) {
|
||
const createdAt = createdByTitle.get(normalizeTopicTitle(topic.title));
|
||
topic.blog_title_created = Boolean(createdAt);
|
||
topic.last_blog_created_at = createdAt;
|
||
topic.rank_score = scoreTopic(topic, rotationSeed);
|
||
topic.llm_context = buildTopicBriefing(topic);
|
||
}
|
||
|
||
const rankedTopics = selectDiverseTopics(cleanTopics, limit);
|
||
|
||
// Next daily rotation: tomorrow 00:00 UTC
|
||
const tomorrow = new Date();
|
||
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||
tomorrow.setUTCHours(0, 0, 0, 0);
|
||
|
||
res.json({
|
||
topics: rankedTopics,
|
||
total: cleanTopics.length,
|
||
generated_at: new Date().toISOString(),
|
||
refreshes_at: tomorrow.toISOString(),
|
||
day_seed: daySeed,
|
||
rotation_seed: rotationSeed,
|
||
sources: ["market_intelligence", "nog_talks", "internal_price_data", "competitor_alerts", "hype_cycle_model", "news_articles", "conference_calendar", "research_papers"],
|
||
});
|
||
} catch (err) {
|
||
console.error("Hot topics error:", err);
|
||
res.status(500).json({ error: "Internal server error" });
|
||
}
|
||
});
|
||
|
||
function isUsableMarketIntel(row: Record<string, unknown>): boolean {
|
||
const text = `${row.title || ""} ${row.summary || ""} ${row.source_name || ""}`.toLowerCase();
|
||
const tech = Array.isArray(row.technologies) ? row.technologies.join(" ").toLowerCase() : String(row.technologies || "").toLowerCase();
|
||
|
||
const rejectPatterns = [
|
||
/privacy shield/,
|
||
/personal data/,
|
||
/data transfer framework/,
|
||
/gdpr/,
|
||
/cookie/,
|
||
/privacy policy/,
|
||
/operational amplifier/,
|
||
/\boamp\b/,
|
||
];
|
||
if (rejectPatterns.some((pattern) => pattern.test(text))) return false;
|
||
|
||
const opticalSignals = [
|
||
"transceiver", "optic", "optical", "photonics", "fiber", "fibre", "400g", "800g",
|
||
"1.6t", "qsfp", "osfp", "sfp", "coherent", "zr", "cpo", "lpo", "silicon photonics",
|
||
"data center", "datacenter", "ai fabric", "ethernet", "dci",
|
||
];
|
||
return opticalSignals.some((signal) => text.includes(signal) || tech.includes(signal));
|
||
}
|
||
|
||
function isUsableTopic(topic: HotTopic): boolean {
|
||
const text = `${topic.title} ${topic.description} ${topic.source}`.toLowerCase();
|
||
return ![
|
||
/privacy shield/,
|
||
/personal data/,
|
||
/data transfer framework/,
|
||
/gdpr/,
|
||
/cookie/,
|
||
/privacy policy/,
|
||
/operational amplifier/,
|
||
/\boamp\b/,
|
||
].some((pattern) => pattern.test(text));
|
||
}
|
||
|
||
function dedupeTopics(topics: HotTopic[]): HotTopic[] {
|
||
const byKey = new Map<string, HotTopic>();
|
||
for (const topic of topics) {
|
||
const key = normalizeTopicTitle(topic.title);
|
||
const previous = byKey.get(key);
|
||
if (!previous || scorePreRankTopic(topic) > scorePreRankTopic(previous)) {
|
||
byKey.set(key, topic);
|
||
}
|
||
}
|
||
return Array.from(byKey.values());
|
||
}
|
||
|
||
function scorePreRankTopic(topic: HotTopic): number {
|
||
const urgencyScore: Record<HotTopic["urgency"], number> = {
|
||
breaking: 4,
|
||
hot: 3,
|
||
trending: 2,
|
||
emerging: 1,
|
||
};
|
||
const freshness = topic.date ? Math.max(0, 30 - ((Date.now() - new Date(topic.date).getTime()) / 86400000)) : 0;
|
||
const dataScore = topic.data_context ? 1 : 0;
|
||
return (urgencyScore[topic.urgency] || 0) * 100 + freshness + dataScore;
|
||
}
|
||
|
||
function normalizeTopicTitle(title: string): string {
|
||
return title
|
||
.toLowerCase()
|
||
.replace(/\b20\d{2}\b/g, "{year}")
|
||
.replace(/[^a-z0-9]+/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
function seededTopicJitter(title: string, seed: number): number {
|
||
let s = seed;
|
||
const normalized = normalizeTopicTitle(title);
|
||
for (let i = 0; i < normalized.length; i++) {
|
||
s = (s * 1664525 + normalized.charCodeAt(i) + 1013904223) & 0x7fffffff;
|
||
}
|
||
return s % 140;
|
||
}
|
||
|
||
function getQuerySeed(value: unknown): number {
|
||
if (!value) return 0;
|
||
const raw = Array.isArray(value) ? String(value[0] || "") : String(value);
|
||
if (!raw) return 0;
|
||
let hash = 0;
|
||
for (let i = 0; i < raw.length; i++) {
|
||
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
|
||
}
|
||
return Math.abs(hash % 100000);
|
||
}
|
||
|
||
function compactDataContext(data: Record<string, unknown> | undefined): string {
|
||
if (!data) return "";
|
||
|
||
const replacer = (_key: string, value: unknown) => {
|
||
if (Array.isArray(value)) return value.slice(0, 5);
|
||
if (typeof value === "string" && value.length > 260) return `${value.slice(0, 257)}...`;
|
||
return value;
|
||
};
|
||
|
||
return JSON.stringify(data, replacer, 2).slice(0, 1800);
|
||
}
|
||
|
||
function buildTopicBriefing(topic: HotTopic): string {
|
||
const lines = [
|
||
`=== BLOG BRIEFING: ${topic.title} ===`,
|
||
``,
|
||
`Urgency: ${topic.urgency.toUpperCase()}`,
|
||
`Source category: ${topic.source_type} | Source: ${topic.source}`,
|
||
];
|
||
|
||
if (topic.date) lines.push(`Signal date: ${new Date(topic.date).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" })}`);
|
||
|
||
// Core signal content — article bullets or summary
|
||
if (topic.description) {
|
||
lines.push(``, `--- Market Signals ---`);
|
||
// Already formatted as bullets for news/nog, or plain summary for market intel
|
||
lines.push(topic.description.includes("•") ? topic.description : `Signal summary: ${topic.description}`);
|
||
}
|
||
|
||
// Recommended editorial angle
|
||
if (topic.suggested_angle) {
|
||
lines.push(``, `--- Recommended Blog Angle ---`);
|
||
lines.push(topic.suggested_angle);
|
||
}
|
||
|
||
// Structured data from data_context (vendors, tech, buy signal, etc.)
|
||
const ctx = topic.data_context;
|
||
if (ctx) {
|
||
const extraLines: string[] = [];
|
||
if (ctx.buy_signal && typeof ctx.buy_signal === "string") {
|
||
const signalMap: Record<string, string> = { bullish: "BUY signal — demand growing, order soon", bearish: "WAIT signal — pricing softening or supply improving", opportunity: "SHORT-TERM OPPORTUNITY — act now", neutral: "Monitor — no immediate action needed" };
|
||
extraLines.push(`Buy signal: ${signalMap[ctx.buy_signal] ?? ctx.buy_signal}`);
|
||
}
|
||
if (ctx.technologies && String(ctx.technologies).length > 2) extraLines.push(`Key technologies: ${ctx.technologies}`);
|
||
if (ctx.impact_months) extraLines.push(`Expected market impact: within ${ctx.impact_months} months`);
|
||
if (extraLines.length > 0) {
|
||
lines.push(``, `--- Market Context ---`);
|
||
lines.push(...extraLines);
|
||
}
|
||
}
|
||
|
||
if (topic.blog_title_created && topic.last_blog_created_at) {
|
||
lines.push(``, `⚠ Editorial note: A similar blog already exists (created ${new Date(topic.last_blog_created_at).toLocaleDateString("de-DE")}). Choose a materially different angle — different structure, timeframe, or use-case focus.`);
|
||
}
|
||
|
||
lines.push(``, `--- Writing Instructions ---`);
|
||
lines.push(`Write a practical optical networking article (600–900 words) that a network engineer or procurement manager at an ISP, cloud provider, or enterprise can immediately use. Include:`);
|
||
lines.push(`1. What is actually happening in the market (fact-based, no generic intro)`);
|
||
lines.push(`2. Specific technical implications (form factors, speeds, reach, protocol implications)`);
|
||
lines.push(`3. Procurement/planning consequences — what to order, what to delay, what to watch`);
|
||
lines.push(`4. One concrete recommendation or action item`);
|
||
lines.push(`Do NOT write generic summaries or restate the title. Be opinionated and specific.`);
|
||
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function scoreTopic(topic: HotTopic, seed: number): number {
|
||
const urgencyScore: Record<HotTopic["urgency"], number> = {
|
||
breaking: 900,
|
||
hot: 760,
|
||
trending: 620,
|
||
emerging: 500,
|
||
};
|
||
const sourceScore: Record<HotTopic["source_type"], number> = {
|
||
internal_data: 90,
|
||
competitor: 85,
|
||
trade_press: 75,
|
||
conference: 70,
|
||
manufacturer: 65,
|
||
research: 60,
|
||
};
|
||
|
||
let freshness = 0;
|
||
if (topic.date) {
|
||
const ageDays = Math.max(0, (Date.now() - new Date(topic.date).getTime()) / 86400000);
|
||
freshness = Math.max(0, 90 - ageDays * 4);
|
||
}
|
||
|
||
const createdPenalty = topic.blog_title_created ? -950 : 0;
|
||
return (
|
||
(urgencyScore[topic.urgency] ?? 400) +
|
||
(sourceScore[topic.source_type] ?? 40) +
|
||
freshness +
|
||
seededTopicJitter(topic.title, seed) +
|
||
createdPenalty
|
||
);
|
||
}
|
||
|
||
function selectDiverseTopics(topics: HotTopic[], limit: number): HotTopic[] {
|
||
const sorted = [...topics].sort((a, b) => (b.rank_score ?? 0) - (a.rank_score ?? 0));
|
||
const selected: HotTopic[] = [];
|
||
const sourceTypeCount = new Map<string, number>();
|
||
const sourceCount = new Map<string, number>();
|
||
|
||
for (const topic of sorted) {
|
||
if (selected.length >= limit) break;
|
||
const sourceType = topic.source_type;
|
||
const source = topic.source || "unknown";
|
||
const typeCount = sourceTypeCount.get(sourceType) ?? 0;
|
||
const srcCount = sourceCount.get(source) ?? 0;
|
||
|
||
if (typeCount >= 5) continue;
|
||
if (srcCount >= 3) continue;
|
||
|
||
selected.push(topic);
|
||
sourceTypeCount.set(sourceType, typeCount + 1);
|
||
sourceCount.set(source, srcCount + 1);
|
||
}
|
||
|
||
if (selected.length < limit) {
|
||
for (const topic of sorted) {
|
||
if (selected.length >= limit) break;
|
||
if (!selected.includes(topic)) selected.push(topic);
|
||
}
|
||
}
|
||
|
||
return selected;
|
||
}
|
||
|
||
function detectNewsTheme(title: string): string {
|
||
const tl = title.toLowerCase();
|
||
if (tl.includes("800g") || tl.includes("osfp")) return "800G Deployment Wave";
|
||
if (tl.includes("1.6t") || tl.includes("1.6 t")) return "1.6T Race";
|
||
if (tl.includes("silicon photonics") || tl.includes("sipho")) return "Silicon Photonics";
|
||
if (tl.includes("cpo") || tl.includes("co-packaged")) return "Co-Packaged Optics";
|
||
if (tl.includes("lpo") || tl.includes("linear")) return "Linear-Drive Pluggable";
|
||
if (tl.includes("400zr") || tl.includes("coherent")) return "Coherent Pluggable";
|
||
if (tl.includes("ai") || tl.includes("gpu") || tl.includes("ml")) return "AI/ML Fabric Optics";
|
||
if (tl.includes("innolight") || tl.includes("coherent corp") || tl.includes("broadcom")) return "Manufacturer Moves";
|
||
if (tl.includes("supply") || tl.includes("shortage") || tl.includes("lead time")) return "Supply Chain Alert";
|
||
return "Industry Update";
|
||
}
|
||
|
||
function getConferenceTopics(year: number): HotTopic[] {
|
||
const now = new Date();
|
||
const month = now.getMonth() + 1;
|
||
const topics: HotTopic[] = [];
|
||
|
||
// OFC = March, ECOC = September, CIOE = September, Photonics West = January
|
||
if (month >= 1 && month <= 2) {
|
||
topics.push({
|
||
title: `Photonics West ${year}: What the Laser and Photonics Research Means for Transceivers`,
|
||
description: "SPIE Photonics West is where the component-level research lands first. What was shown and how long until it ships?",
|
||
blog_type: "technology_deep_dive",
|
||
urgency: month === 1 ? "hot" : "trending",
|
||
source: "SPIE Photonics West",
|
||
source_type: "conference",
|
||
suggested_angle: "Photonics West signal reading: The research demos that become products in 2-3 years",
|
||
});
|
||
}
|
||
if (month >= 2 && month <= 4) {
|
||
topics.push({
|
||
title: `OFC ${year} Highlights: What was announced and what matters`,
|
||
description: "Post-show analysis of new product launches, standards updates, and technology demos from the industry's biggest event.",
|
||
blog_type: "technology_deep_dive",
|
||
urgency: month === 3 ? "breaking" : "hot",
|
||
source: "OFC Conference",
|
||
source_type: "conference",
|
||
suggested_angle: "OFC show floor reality: 5 announcements that actually change your procurement strategy",
|
||
});
|
||
}
|
||
if (month >= 5 && month <= 7) {
|
||
topics.push({
|
||
title: `NFOEC / OFC ${year} Follow-Up: Which Products Actually Shipped?`,
|
||
description: "Six months after OFC demos — checking which announcements turned into shipping hardware and which were still vapor.",
|
||
blog_type: "market_alert",
|
||
urgency: "trending",
|
||
source: "OFC Conference Follow-Up",
|
||
source_type: "conference",
|
||
suggested_angle: "Post-OFC reality check: Announcement vs reality 6 months later",
|
||
});
|
||
}
|
||
if (month >= 8 && month <= 10) {
|
||
topics.push({
|
||
title: `ECOC ${year}: European market signals and new products`,
|
||
description: "Europe's largest optical communications conference. Key for understanding EU market direction.",
|
||
blog_type: "technology_deep_dive",
|
||
urgency: month === 9 ? "breaking" : "trending",
|
||
source: "ECOC Conference",
|
||
source_type: "conference",
|
||
suggested_angle: "ECOC takeaways: What European carriers are actually deploying (not just demoing)",
|
||
});
|
||
}
|
||
if (month >= 9 && month <= 11) {
|
||
topics.push({
|
||
title: `CIOE ${year} Shenzhen: Chinese Manufacturers Show Their Cards`,
|
||
description: "CIOE is where InnoLight, HG Genuine, and Chinese OEMs announce next-gen products. First look at the 2025 roadmap.",
|
||
blog_type: "technology_deep_dive",
|
||
urgency: "trending",
|
||
source: "CIOE Shenzhen",
|
||
source_type: "conference",
|
||
suggested_angle: "CIOE market signal: What Chinese manufacturers shipping in 12 months means for Western pricing",
|
||
});
|
||
}
|
||
if (month >= 11 || month <= 1) {
|
||
topics.push({
|
||
title: `${year} Year-End Optics Market Review: Who Won, Who Lost`,
|
||
description: "Annual review: which speeds gained share, which vendors shipped on roadmap, and what the price curves looked like.",
|
||
blog_type: "market_alert",
|
||
urgency: "hot",
|
||
source: "TIP Market Data",
|
||
source_type: "internal_data",
|
||
suggested_angle: `Year-end optics market: The numbers that matter for your ${year + 1} budget`,
|
||
});
|
||
}
|
||
|
||
// Always relevant — rotate between 3 variants using day seed
|
||
const standardsTopics: HotTopic[] = [
|
||
{
|
||
title: `${year} Standards Update: IEEE 802.3 / OIF / MSA tracker`,
|
||
description: "What's ratified, what's in draft, and what it means for your next-gen optics roadmap.",
|
||
blog_type: "technology_deep_dive",
|
||
urgency: "trending",
|
||
source: "IEEE / OIF / MSA",
|
||
source_type: "research",
|
||
suggested_angle: "Standards reality check: Which specs are production-ready vs. still in committee",
|
||
},
|
||
{
|
||
title: `SFF-8024 Transceiver Management Update ${year}: What's New in the Identifier Registry`,
|
||
description: "The SFF Committee's transceiver identifier registry updated again. New form factors, management interfaces, and what switches need firmware updates to support them.",
|
||
blog_type: "technology_deep_dive",
|
||
urgency: "trending",
|
||
source: "SFF Committee",
|
||
source_type: "research",
|
||
suggested_angle: "SFF registry changes: The low-level updates that cause high-level compatibility headaches",
|
||
},
|
||
{
|
||
title: `OIF CEI-112G Standard: What It Means for the Next Generation of DAC and AOC`,
|
||
description: "CEI-112G is the electrical interface spec that enables 112G PAM4 — the building block of 400G and 800G. What it enables and when it matters.",
|
||
blog_type: "technology_deep_dive",
|
||
urgency: "emerging",
|
||
source: "OIF CEI",
|
||
source_type: "research",
|
||
suggested_angle: "CEI-112G explained: Why the electrical interface standard matters more than the optics spec",
|
||
},
|
||
];
|
||
const seed = getDaySeed() + 3;
|
||
topics.push(seededShuffle(standardsTopics, seed)[0]);
|
||
|
||
return topics;
|
||
}
|
||
|
||
/**
|
||
* Seeded shuffle — same seed = same order. Uses day-of-year as seed so
|
||
* topics rotate daily but are stable within the same day.
|
||
*/
|
||
function seededShuffle<T>(arr: T[], seed: number): T[] {
|
||
const out = [...arr];
|
||
let s = seed;
|
||
for (let i = out.length - 1; i > 0; i--) {
|
||
s = (s * 1664525 + 1013904223) & 0x7fffffff;
|
||
const j = s % (i + 1);
|
||
[out[i], out[j]] = [out[j], out[i]];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function getDaySeed(): number {
|
||
const now = new Date();
|
||
const start = new Date(now.getFullYear(), 0, 0);
|
||
const dayOfYear = Math.floor((now.getTime() - start.getTime()) / 86400000);
|
||
return now.getFullYear() * 1000 + dayOfYear;
|
||
}
|
||
|
||
function getResearchTopics(year: number): HotTopic[] {
|
||
const ALL_RESEARCH: HotTopic[] = [
|
||
{
|
||
title: "Silicon Photonics: From Lab to Production — Where Are We Really?",
|
||
description: "Intel, Broadcom, Marvell, and startups (Lightmatter, Ayar Labs) are all pushing SiPho. But production yields and packaging costs tell a different story.",
|
||
blog_type: "technology_deep_dive",
|
||
urgency: "trending",
|
||
source: "Industry Research",
|
||
source_type: "research",
|
||
suggested_angle: "Silicon Photonics reality: What works in production vs what's still a conference demo",
|
||
},
|
||
{
|
||
title: "LPO vs DSP: The Power Efficiency Battle That Reshapes Data Centers",
|
||
description: "Linear-drive pluggable optics promise 50% power savings by eliminating the DSP. But interop and reach limitations are real.",
|
||
blog_type: "comparison",
|
||
urgency: "emerging",
|
||
source: "OIF / University Research",
|
||
source_type: "research",
|
||
suggested_angle: "LPO honest assessment: When the power savings justify the interop risk",
|
||
},
|
||
{
|
||
title: `CPO Roadmap ${year}: Co-Packaged Optics Timeline Reality Check`,
|
||
description: "Broadcom showed CPO demos at OFC. But what's the real timeline for rack-level deployment?",
|
||
blog_type: "hype_cycle",
|
||
urgency: "emerging",
|
||
source: "Broadcom / Intel / TSMC",
|
||
source_type: "manufacturer",
|
||
suggested_angle: "CPO: Why it's 3 years away (and has been for the last 5 years)",
|
||
},
|
||
{
|
||
title: "1.6T Optics: Who Ships First and What the Specs Actually Mean",
|
||
description: "Vendors are announcing 1.6T but the standards aren't final. Here's what's real, what's vaporware, and what lead times to expect.",
|
||
blog_type: "technology_deep_dive",
|
||
urgency: "emerging",
|
||
source: "OIF / IEEE",
|
||
source_type: "research",
|
||
suggested_angle: "1.6T reality check: Sorting PR from actual shipping hardware",
|
||
},
|
||
{
|
||
title: "OSFP vs QSFP-DD: The 400G/800G Form Factor Fight Isn't Over",
|
||
description: "Both coexist in the market. Hyperscalers went OSFP for 800G; enterprise is on QSFP-DD. What does this mean for your optics inventory?",
|
||
blog_type: "comparison",
|
||
urgency: "trending",
|
||
source: "QSFP-DD MSA / OSFP MSA",
|
||
source_type: "research",
|
||
suggested_angle: "Form factor fragmentation: How to not end up with the wrong 800G optics",
|
||
},
|
||
{
|
||
title: "Coherent ZR vs ZR+: The Spec Sheet Lies You Need to Know",
|
||
description: "OpenZR+ vs 400ZR implementations from different vendors are NOT interoperable by default. The details matter.",
|
||
blog_type: "comparison",
|
||
urgency: "trending",
|
||
source: "OIF Implementation Agreement",
|
||
source_type: "research",
|
||
suggested_angle: "Coherent interop truth: Why your ZR link won't work with mixed vendors out of the box",
|
||
},
|
||
{
|
||
title: "BiDi Optics: The Most Misunderstood Product in the Catalog",
|
||
description: "Single-strand operation sounds simple. The wavelength pairing rules, fiber polarity requirements, and what goes wrong when teams mix Tx/Rx wavelengths.",
|
||
blog_type: "tutorial",
|
||
urgency: "trending",
|
||
source: "Field Cases",
|
||
source_type: "internal_data",
|
||
suggested_angle: "BiDi done right: Why half your BiDi deployments have the wrong wavelength on the wrong end",
|
||
},
|
||
{
|
||
title: "Transceiver DDM/DOM: How to Actually Use the Diagnostic Data",
|
||
description: "Every modern optic reports temperature, voltage, Tx power, Rx power, and bias current. Most teams never check it. Here's what the numbers tell you.",
|
||
blog_type: "tutorial",
|
||
urgency: "trending",
|
||
source: "SFF Committee / Field Data",
|
||
source_type: "research",
|
||
suggested_angle: "DDM as a maintenance tool: Catching failing optics before they take down your link",
|
||
},
|
||
{
|
||
title: `800G Deployment Wave: What Hyperscalers Are Actually Installing in ${year}`,
|
||
description: "Meta, Google, and Microsoft are ordering 800G at scale. What form factors, vendors, and reach variants are winning?",
|
||
blog_type: "market_alert",
|
||
urgency: "hot",
|
||
source: "LightCounting / Hyperscaler CapEx Reports",
|
||
source_type: "research",
|
||
suggested_angle: "800G market signal: What hyperscaler spending tells enterprise about their 2027 refresh cycle",
|
||
},
|
||
];
|
||
// Pick 3 different research topics per day
|
||
const shuffled = seededShuffle(ALL_RESEARCH, getDaySeed() + 1);
|
||
return shuffled.slice(0, 3);
|
||
}
|
||
|
||
function getEvergreenTopics(year: number): HotTopic[] {
|
||
const ALL_EVERGREEN: HotTopic[] = [
|
||
{
|
||
title: `The ${year} Transceiver Buying Guide: OEM vs Compatible`,
|
||
description: "Updated pricing data, vendor quality tiers, and the procurement playbook for every speed class.",
|
||
blog_type: "buying_guide",
|
||
urgency: "trending",
|
||
source: "TIP Price Data",
|
||
source_type: "internal_data",
|
||
suggested_angle: "Stop overpaying: The definitive OEM vs compatible decision framework with real numbers",
|
||
},
|
||
{
|
||
title: `400G Migration Playbook: From 100G to 400G in 12 Months`,
|
||
description: "Step-by-step migration guide including cabling, switch selection, optics procurement, and what goes wrong.",
|
||
blog_type: "migration_guide",
|
||
urgency: "trending",
|
||
source: "Field Experience",
|
||
source_type: "internal_data",
|
||
suggested_angle: "Migration horror stories and how to avoid them: The 100G→400G field guide",
|
||
},
|
||
{
|
||
title: "MPO Connector Survival Guide: Polarity, Cleaning, and Why Your Links Keep Dying",
|
||
description: "The #1 cause of 40G/100G/400G deployment failures. Everything you need to know about MPO.",
|
||
blog_type: "tutorial",
|
||
urgency: "trending",
|
||
source: "Support Cases",
|
||
source_type: "internal_data",
|
||
suggested_angle: "Your $350 optic isn't broken — your connector is dirty. The MPO reality guide.",
|
||
},
|
||
{
|
||
title: "Transceiver Compatibility: Why Your New Optic Doesn't Work and How to Fix It",
|
||
description: "DOM lockout, software coding, MSA compliance, and the vendor unlock codes that nobody documents.",
|
||
blog_type: "tutorial",
|
||
urgency: "trending",
|
||
source: "Support Cases",
|
||
source_type: "internal_data",
|
||
suggested_angle: "Compatibility reality: The 4 reasons optics get rejected and exactly how to resolve each one",
|
||
},
|
||
{
|
||
title: `${year} Price Tracker: Which Transceiver Speeds Are Getting Cheaper Fastest?`,
|
||
description: "25G SFP28 hit floor pricing. 100G QSFP28 is still dropping. 400G is at inflection. Where to buy now, where to wait.",
|
||
blog_type: "market_alert",
|
||
urgency: "hot",
|
||
source: "TIP Price Observations",
|
||
source_type: "internal_data",
|
||
suggested_angle: "Buy signal analysis: The optic speeds where waiting 6 months saves 30%",
|
||
},
|
||
{
|
||
title: "Power Budget 101: Why Your 10km LR Link Only Reaches 7km",
|
||
description: "TX power, receiver sensitivity, fiber loss, connector loss, and the splice margin most engineers forget to account for.",
|
||
blog_type: "tutorial",
|
||
urgency: "trending",
|
||
source: "Field Experience",
|
||
source_type: "internal_data",
|
||
suggested_angle: "Loss budget done right: The calculation every engineer should do before ordering LR optics",
|
||
},
|
||
{
|
||
title: "Cisco vs Arista vs Juniper Optics Lock-In: The True Cost Analysis",
|
||
description: "OEM optics from major vendors carry a 300-800% premium. Here's the legal situation, the unlock process, and when it's actually worth paying OEM prices.",
|
||
blog_type: "buying_guide",
|
||
urgency: "trending",
|
||
source: "Vendor Pricing / Legal Docs",
|
||
source_type: "internal_data",
|
||
suggested_angle: "OEM lock-in economics: The numbers behind why compatible optics work and when they don't",
|
||
},
|
||
{
|
||
title: `40G End-of-Life: What to Do With Your QSFP+ Inventory in ${year}`,
|
||
description: "40G is in decline. Resale values, upgrade paths to 100G, and when to write off the inventory.",
|
||
blog_type: "market_alert",
|
||
urgency: "trending",
|
||
source: "TIP Price Data",
|
||
source_type: "internal_data",
|
||
suggested_angle: "40G sunset planning: The migration math that justifies skipping 40G refresh entirely",
|
||
},
|
||
{
|
||
title: "DAC vs AOC vs Optic+Cable: The Short-Reach Decision Tree",
|
||
description: "For ≤5m: DAC. For 5-100m: AOC. For >100m: pluggable. But the breakeven points shift with port density and heat budgets.",
|
||
blog_type: "comparison",
|
||
urgency: "trending",
|
||
source: "Field Data",
|
||
source_type: "internal_data",
|
||
suggested_angle: "Short-reach optics: The decision framework that saves budget without sacrificing reliability",
|
||
},
|
||
{
|
||
title: "Gray Optics: Market Pricing vs New — When the Risk Is Worth It",
|
||
description: "eBay, Alibaba, and gray market brokers sell transceiver inventory at 40-70% below retail. What to check, what can go wrong, and what the warranty situation actually is.",
|
||
blog_type: "buying_guide",
|
||
urgency: "trending",
|
||
source: "Market Research",
|
||
source_type: "internal_data",
|
||
suggested_angle: "Gray market guide: The due diligence checklist that separates a deal from a disaster",
|
||
},
|
||
{
|
||
title: `Wavelength Division Multiplexing in ${year}: CWDM4 vs LWDM vs DWDM Decision Guide`,
|
||
description: "WDM optics for data center interconnect. The wavelength plans, the reach limits, and when DWDM pays off vs CWDM.",
|
||
blog_type: "technology_deep_dive",
|
||
urgency: "trending",
|
||
source: "IEEE / OIF",
|
||
source_type: "research",
|
||
suggested_angle: "WDM selection guide: Matching wavelength technology to your actual DCI requirements",
|
||
},
|
||
{
|
||
title: "Temperature-Hardened Optics: When ETSI Class 4.1 Actually Matters",
|
||
description: "Industrial, outdoor, and telecom deployments require optics rated for -40°C to +85°C. What the specs mean and which products are actually qualified.",
|
||
blog_type: "buying_guide",
|
||
urgency: "emerging",
|
||
source: "ETSI / Telecom Field Cases",
|
||
source_type: "internal_data",
|
||
suggested_angle: "Industrial optics selection: When your data center optic will kill your outdoor deployment",
|
||
},
|
||
];
|
||
// Pick 4 different evergreen topics per day (rotates daily via seeded shuffle)
|
||
const shuffled = seededShuffle(ALL_EVERGREEN, getDaySeed() + 2);
|
||
return shuffled.slice(0, 4);
|
||
}
|