main.ts (3655B)
1 import { DOMParser } from "linkedom"; 2 3 const port = Number(Deno.env.get("PORT") || "8000"); 4 const hostname = Deno.env.get("HOST") || "127.0.0.1"; 5 6 const SLUG_RE = /^[a-z0-9-]+$/i; 7 8 function escapeHtml(s: string): string { 9 return s 10 .replace(/&/g, "&") 11 .replace(/</g, "<") 12 .replace(/>/g, ">") 13 .replace(/"/g, """); 14 } 15 16 type FeedItem = { 17 id: string; 18 url: string; 19 title: string; 20 content_html: string; 21 image?: string; 22 }; 23 24 async function fetchDocument(url: string, label: string) { 25 const res = await fetch(url); 26 if (!res.ok) { 27 return { 28 error: Response.json( 29 { error: `Could not fetch data for ${label}` }, 30 { status: res.status }, 31 ), 32 }; 33 } 34 const html = await res.text(); 35 return { document: new DOMParser().parseFromString(html, "text/html") }; 36 } 37 38 function feedResponse(title: string, homePageUrl: string, items: FeedItem[]) { 39 return Response.json({ 40 version: "https://jsonfeed.org/version/1.1", 41 title, 42 home_page_url: homePageUrl, 43 items, 44 }); 45 } 46 47 Deno.serve({ port, hostname }, async (req: Request) => { 48 const url = new URL(req.url); 49 const artist = url.searchParams.get("artist"); 50 const fan = url.searchParams.get("fan"); 51 52 if (artist) { 53 if (!SLUG_RE.test(artist)) { 54 return Response.json({ error: "invalid artist" }, { status: 400 }); 55 } 56 return handleArtist(artist); 57 } else if (fan) { 58 if (!SLUG_RE.test(fan)) { 59 return Response.json({ error: "invalid fan" }, { status: 400 }); 60 } 61 return handleFan(fan); 62 } else { 63 return Response.json( 64 { error: "Our princess is in another castle!" }, 65 { status: 400 }, 66 ); 67 } 68 }); 69 70 async function handleArtist(artist: string) { 71 const baseUrl = `https://${artist}.bandcamp.com`; 72 const result = await fetchDocument(`${baseUrl}/music`, artist); 73 if (result.error) return result.error; 74 const { document } = result; 75 76 // deno-lint-ignore no-explicit-any 77 const items = Array.from(document.querySelectorAll("#music-grid li")).map((item: any) => { 78 const titleEl = item.querySelector(".title"); 79 const title = titleEl 80 ? titleEl.textContent.replace(/(\r\n|\n|\r)/gm, "").trim() 81 : "Unknown Title"; 82 83 const href = item.querySelector("a")?.getAttribute("href") ?? ""; 84 const url = href 85 ? href.startsWith("http") ? href : `${baseUrl}${href}` 86 : ""; 87 88 return { id: url, url, title, content_html: title } satisfies FeedItem; 89 }); 90 91 return feedResponse(artist, baseUrl, items); 92 } 93 94 async function handleFan(fan: string) { 95 const homePageUrl = `https://bandcamp.com/${fan}`; 96 const result = await fetchDocument(homePageUrl, fan); 97 if (result.error) return result.error; 98 const { document } = result; 99 100 const items = Array.from( 101 document.querySelectorAll("#collection-grid .collection-item-container"), 102 // deno-lint-ignore no-explicit-any 103 ).map((item: any) => { 104 const titleEl = item.querySelector(".collection-item-title"); 105 const title = titleEl 106 ? titleEl.textContent.replace(/(\r\n|\n|\r)/gm, "").replace("(gift given)", "").trim() 107 : "Unknown Title"; 108 109 const artistEl = item.querySelector(".collection-item-artist"); 110 const artistName = artistEl 111 ? artistEl.textContent.replace(/(\r\n|\n|\r)/gm, "").trim().replace(/^by\s+/, "") 112 : "Unknown Artist"; 113 114 const href = item.querySelector(".item-link")?.getAttribute("href") ?? ""; 115 const imgSrc = item.querySelector(".collection-item-art")?.getAttribute("src") ?? ""; 116 117 return { 118 id: href, 119 url: href, 120 title: `${escapeHtml(title)} by ${escapeHtml(artistName)}`, 121 content_html: `<img src="${escapeHtml(imgSrc)}" /><br/>${escapeHtml(title)} by ${escapeHtml(artistName)}`, 122 image: imgSrc, 123 } satisfies FeedItem; 124 }); 125 126 return feedResponse(fan, homePageUrl, items); 127 }