SkateSpots/scripts/newsletter-weekly.ts
SkateSpots f029b5a516 feat(newsletter): weekly generated drafts, covers, and admin preview
- Add a browser-safe editorial generator with rotating satire and shipped highlights.
- Generate a fresh 1200x630 cover from the Drop #006 foundation and upload it to Blossom.
- Publish weekly drafts as NIP-78; add the admin Load draft/Preview composer flow and banner.
- Add the Monday user timer (draft by default) and a manual newsletter:weekly script.
- Remove static/live issue duplicates; translate the new admin strings to all 24 locales.
2026-10-28 09:42:44 +07:00

274 lines
8.9 KiB
TypeScript

/**
* Weekly SkateSpots newsletter generator.
*
* - Picks the next issue number (latest published + 1).
* - Builds the full draft: title/slug/summary/cover/body/teaser.
* - Generates a fresh cover from the Drop #006 foundation and uploads it to Blossom.
* - Publishes a NIP-78 draft (kind 30078, d=skatespots:newsletter:draft) for the
* admin composer. Set NEWSLETTER_AUTOPUBLISH=1 to publish the NIP-23 issue and
* kind-1 teaser in the same run.
*
* Run: npx tsx scripts/newsletter-weekly.ts
* Signer: SIGN_WITH in ~/.config/skatespots/zsp.env (nsec; never printed).
*/
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import WebSocket from 'ws';
import { NRelay1 } from '@nostrify/nostrify';
import { BlossomUploader } from '@nostrify/nostrify/uploaders';
import { finalizeEvent, nip19 } from 'nostr-tools';
import { getPublicKey } from 'nostr-tools/pure';
import { ADMIN_PUBKEYS } from '../src/lib/admins';
import { NEWSLETTER_ISSUES } from '../src/lib/newsletterIssues';
import {
buildWeeklyDraft,
pickJokes,
WEEKLY_HIGHLIGHTS,
type WeeklyHeadline,
type WeeklyHighlight,
} from '../src/lib/newsletterContent';
import { generateNewsletterCover } from './newsletter-cover';
(globalThis as unknown as { WebSocket?: unknown }).WebSocket = WebSocket;
const RELAY = 'wss://relay.skate-spots.org';
const COVER_TITLES = [
'THE PRIVATE CHAT GROWS UP',
'SHIP HAPPENS',
'THE MAP IS THE POINT',
'BUILT FOR THE PARK',
'LESS SLOP, MORE SKATE',
'DO THE BORING PART',
];
function readSecret(): { sk: Uint8Array; pubkey: string } {
const file = join(process.env.HOME ?? '', '.config/skatespots/zsp.env');
const raw = process.env.SIGN_WITH || readFileSync(file, 'utf8').match(/^SIGN_WITH=(.*)$/m)?.[1]?.trim() || '';
if (!raw.startsWith('nsec1')) throw new Error('newsletter signer must be a local nsec (SIGN_WITH)');
const sk = nip19.decode(raw).data as Uint8Array;
const pubkey = getPublicKey(sk);
if (!ADMIN_PUBKEYS.includes(pubkey)) throw new Error('newsletter signer is not an app admin');
return { sk, pubkey };
}
function parseReleaseHighlights(): WeeklyHighlight[] {
try {
const notes = readFileSync(join(process.cwd(), 'release-notes.txt'), 'utf8');
const top = notes.split(/\n---\n/)[0] ?? '';
const lines = top.split('\n');
let heading = 'Shipped';
let tag = 'App';
const groups: Array<{ heading: string; bullets: string[] }> = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('- ')) {
if (trimmed.startsWith('- ')) {
if (groups.length === 0) groups.push({ heading, bullets: [] });
groups[groups.length - 1].bullets.push(trimmed.slice(2));
}
continue;
}
const clean = trimmed.replace(/^[^\p{L}\p{N}]+/u, '').trim();
if (clean) {
heading = clean;
tag = clean.split(':')[0].slice(0, 24);
groups.push({ heading, bullets: [] });
}
}
const out: WeeklyHighlight[] = [];
for (const group of groups) {
for (const bullet of group.bullets) {
out.push({
tag,
title: bullet.split('—')[0].split('.')[0].slice(0, 90),
body: bullet,
});
if (out.length >= 4) return out;
}
}
return out;
} catch {
return [];
}
}
function decodeXml(value: string): string {
return value
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
async function fetchHeadlines(): Promise<WeeklyHeadline[]> {
const feeds = [
{ url: 'https://news.ycombinator.com/rss', source: 'Hacker News' },
{ url: 'https://techcrunch.com/feed/', source: 'TechCrunch' },
{ url: 'https://www.coindesk.com/arc/outboundfeeds/rss/', source: 'CoinDesk' },
];
const out: WeeklyHeadline[] = [];
for (const feed of feeds) {
try {
const res = await fetch(feed.url, {
headers: { 'user-agent': 'SkateSpotsNewsletter/1.0' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) continue;
const xml = await res.text();
const item = xml.match(/<item>([\s\S]*?)<\/item>/i)?.[1];
if (!item) continue;
const title = decodeXml(item.match(/<title>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? '');
const link = decodeXml(item.match(/<link>([\s\S]*?)<\/link>/i)?.[1]?.trim() ?? '');
if (title && /^https?:\/\//.test(link)) out.push({ title, url: link, source: feed.source });
} catch {
// Feed unavailable — keep the newsletter self-contained.
}
}
return out.slice(0, 3);
}
async function latestIssueNumber(relay: NRelay1): Promise<number> {
const staticMax = Math.max(0, ...NEWSLETTER_ISSUES.map((issue) => issue.number));
try {
const events = await relay.query(
[{ kinds: [30023], authors: ADMIN_PUBKEYS, '#t': ['skatespotsnews'], limit: 50 }],
{ signal: AbortSignal.timeout(10_000) },
);
let max = staticMax;
for (const event of events) {
const title = event.tags.find(([name]) => name === 'title')?.[1] ?? '';
const match = title.match(/#0*(\d+)/);
if (match) max = Math.max(max, Number(match[1]));
}
return max;
} catch {
return staticMax;
}
}
const { sk, pubkey } = readSecret();
const relay = new NRelay1(RELAY);
const number = (await latestIssueNumber(relay)) + 1;
const dateIso = new Date().toISOString().slice(0, 10);
const dateLabel = new Date(`${dateIso}T12:00:00Z`)
.toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
.toUpperCase();
const parsed = number === 7 ? [] : parseReleaseHighlights();
const highlights = parsed.length >= 3 ? parsed : WEEKLY_HIGHLIGHTS;
const jokes = pickJokes(number, 3);
const headlines = await fetchHeadlines();
const coverTitle = COVER_TITLES[(number - 1) % COVER_TITLES.length];
const tempDir = mkdtempSync(join(tmpdir(), 'skatespots-newsletter-'));
const coverPath = join(tempDir, `drop-${String(number).padStart(3, '0')}-cover.png`);
await generateNewsletterCover({ number, dateLabel, coverTitle, outputPath: coverPath });
const signer = {
getPublicKey: async () => pubkey,
signEvent: async (event: Parameters<typeof finalizeEvent>[0]) => finalizeEvent(event, sk),
};
let coverUrl = 'https://skate-spots.org/images/drop-006-cover.png';
try {
const uploader = new BlossomUploader({ servers: ['https://blossom.skate-spots.org/'], signer });
const tags = await uploader.upload(
new File([readFileSync(coverPath)], `drop-${String(number).padStart(3, '0')}-cover.png`, {
type: 'image/png',
}),
);
coverUrl = tags.find(([name]) => name === 'url')?.[1] ?? coverUrl;
} catch (error) {
console.warn('[newsletter] cover upload failed, using the foundation cover:', error instanceof Error ? error.message : error);
}
const draft = buildWeeklyDraft({
number,
dateIso,
coverImage: coverUrl,
coverTitle,
highlights,
headlines,
jokes,
});
const draftEvent = finalizeEvent(
{
kind: 30078,
content: JSON.stringify(draft),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', 'skatespots:newsletter:draft'],
['t', 'skatespotsnewsdraft'],
['alt', `SkateSpots weekly newsletter draft #${String(number).padStart(3, '0')}`],
],
},
sk,
);
await relay.event(draftEvent, { signal: AbortSignal.timeout(15_000) });
console.log(
JSON.stringify(
{
issue: number,
title: draft.title,
date: dateIso,
cover: coverUrl,
highlights: highlights.length,
jokes: jokes.map((joke) => joke.id),
headlines: headlines.map((headline) => headline.source),
draftEvent: draftEvent.id,
autoPublished: process.env.NEWSLETTER_AUTOPUBLISH === '1',
},
null,
2,
),
);
if (process.env.NEWSLETTER_AUTOPUBLISH === '1') {
const now = Math.floor(Date.now() / 1000);
const issueEvent = finalizeEvent(
{
kind: 30023,
content: draft.body,
created_at: now,
tags: [
['d', draft.slug],
['title', draft.title],
['summary', draft.summary],
['image', draft.image],
['published_at', String(now)],
['t', 'skatespotsnews'],
['t', 'skatespots'],
['t', 'newsletter'],
['t', 'skateboarding'],
['alt', `SkateSpots newsletter issue: ${draft.title}`],
],
},
sk,
);
await relay.event(issueEvent, { signal: AbortSignal.timeout(15_000) });
const teaser = finalizeEvent(
{
kind: 1,
content: draft.nostrText,
created_at: now,
tags: [
['t', 'skatespotsnews'],
['t', 'skateboarding'],
['t', 'nostr'],
],
},
sk,
);
await relay.event(teaser, { signal: AbortSignal.timeout(15_000) });
console.log(JSON.stringify({ publishedIssue: issueEvent.id, teaser: teaser.id }));
}
try {
await relay.close?.();
} catch {
// ignore
}
rmSync(tempDir, { recursive: true, force: true });