- 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.
274 lines
8.9 KiB
TypeScript
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(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/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 });
|