@profullstack/builtindie 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/OpenStack.md +74 -0
  3. package/README.md +63 -2
  4. package/bin/builtindie +3 -0
  5. package/package.json +35 -4
  6. package/public/app.js +35 -0
  7. package/public/favicon.svg +1 -0
  8. package/public/icon-192.png +0 -0
  9. package/public/icon-512.png +0 -0
  10. package/public/icons/openicon.json +574 -0
  11. package/public/icons/svg/apple.svg +1 -0
  12. package/public/icons/svg/arrow-up.svg +1 -0
  13. package/public/icons/svg/bluesky.svg +1 -0
  14. package/public/icons/svg/check.svg +1 -0
  15. package/public/icons/svg/chrome.svg +1 -0
  16. package/public/icons/svg/code.svg +1 -0
  17. package/public/icons/svg/discord.svg +1 -0
  18. package/public/icons/svg/dollar.svg +1 -0
  19. package/public/icons/svg/external-link.svg +1 -0
  20. package/public/icons/svg/github.svg +1 -0
  21. package/public/icons/svg/globe.svg +1 -0
  22. package/public/icons/svg/instagram.svg +1 -0
  23. package/public/icons/svg/layers.svg +1 -0
  24. package/public/icons/svg/link.svg +1 -0
  25. package/public/icons/svg/linkedin.svg +1 -0
  26. package/public/icons/svg/mail.svg +1 -0
  27. package/public/icons/svg/product-hunt.svg +1 -0
  28. package/public/icons/svg/reddit.svg +1 -0
  29. package/public/icons/svg/rocket.svg +1 -0
  30. package/public/icons/svg/search.svg +1 -0
  31. package/public/icons/svg/share.svg +1 -0
  32. package/public/icons/svg/star.svg +1 -0
  33. package/public/icons/svg/tag.svg +1 -0
  34. package/public/icons/svg/telegram.svg +1 -0
  35. package/public/icons/svg/terminal.svg +1 -0
  36. package/public/icons/svg/tiktok.svg +1 -0
  37. package/public/icons/svg/trophy.svg +1 -0
  38. package/public/icons/svg/user.svg +1 -0
  39. package/public/icons/svg/x.svg +1 -0
  40. package/public/icons/svg/youtube.svg +1 -0
  41. package/public/install.sh +44 -0
  42. package/public/manifest.webmanifest +20 -0
  43. package/public/moshpit-root-ca.crt +11 -0
  44. package/public/style.css +162 -0
  45. package/public/sw.js +30 -0
  46. package/src/auth.ts +96 -0
  47. package/src/cli.ts +203 -0
  48. package/src/client.ts +115 -0
  49. package/src/db.ts +412 -0
  50. package/src/fetch.ts +708 -0
  51. package/src/html.ts +525 -0
  52. package/src/icons.ts +56 -0
  53. package/src/mcp.ts +79 -0
  54. package/src/media.ts +160 -0
  55. package/src/ring.ts +86 -0
  56. package/src/server.ts +655 -0
  57. package/src/service.ts +369 -0
  58. package/src/tui.ts +127 -0
  59. package/src/version.ts +1 -0
package/src/fetch.ts ADDED
@@ -0,0 +1,708 @@
1
+ /**
2
+ * Autofetch: everything we can learn about a product from its own homepage.
3
+ *
4
+ * Give it a URL; it reads the page (meta tags, Open Graph, Twitter cards,
5
+ * JSON-LD, icons, the web manifest, <video>/<iframe> embeds, social links,
6
+ * big images) plus /llms.txt, the manifest and the LogicSRC files a site may
7
+ * serve about itself (logicsrc.com/specs): opensite.json (what it is),
8
+ * openprofile.md (who runs it), openstack.md (what it is built on),
9
+ * opensaas.json (its plans and the way out) and openicon.json (its icon set).
10
+ * Then it picks a name, a tagline,
11
+ * a description, a logo, screenshots and videos. Nothing is stored here: the
12
+ * caller decides what to download (see media.ts).
13
+ *
14
+ * Every fetch goes through safeFetch: http(s) only, public addresses only
15
+ * (checked on every redirect hop), a timeout and a byte cap.
16
+ */
17
+ import { lookup } from 'node:dns/promises'
18
+ import { isIP } from 'node:net'
19
+ import { accounts, identityValue, kindOf as kindOfProfile, parseOpenProfile, web } from '@profullstack/openprofile'
20
+
21
+ export type Video = { kind: 'youtube' | 'vimeo' | 'loom' | 'file'; url: string; embed: string | null }
22
+
23
+ export type SiteInfo = {
24
+ url: string
25
+ domain: string
26
+ name: string
27
+ tagline: string
28
+ description: string
29
+ logo: string | null
30
+ ogImage: string | null
31
+ images: string[]
32
+ videos: Video[]
33
+ socials: Record<string, string>
34
+ tags: string[]
35
+ themeColor: string | null
36
+ llms: { url: string; title: string; summary: string } | null
37
+ manifest: boolean
38
+ /** The company or person behind it, from their OpenProfile.md (logicsrc.com/docs/openprofile). */
39
+ profile: Profile | null
40
+ /** OpenSite reading (logicsrc.com/docs/opensite): kept so we can publish the record. */
41
+ canonical: string
42
+ kind: string
43
+ language: string | null
44
+ feeds: string[]
45
+ cards: Record<string, Record<string, string>>
46
+ jsonld: unknown[]
47
+ declared: boolean
48
+ /** OpenStack.md (logicsrc.com/docs/openstack): what it is built on. */
49
+ stack: Stack | null
50
+ /** OpenSaaS (logicsrc.com/docs/opensaas): its plans, and where to cancel. */
51
+ pricing: Pricing | null
52
+ /** OpenIcon (logicsrc.com/docs/openicon): the icon set it publishes. */
53
+ openicon: string | null
54
+ sources: string[]
55
+ }
56
+
57
+ export type Stack = { url: string; items: { layer: string; name: string }[] }
58
+ export type Pricing = { url: string; currency: string; plans: { name: string; price: number | null; period: string | null; trial: string | null; url: string | null }[]; cancel: string | null }
59
+
60
+ export type Profile = { url: string; kind: 'person' | 'agent' | 'organization' | null; name: string; headline: string; avatar: string | null; web: string | null; accounts: string[] }
61
+
62
+ export class FetchError extends Error {}
63
+
64
+ const UA = 'Mozilla/5.0 (compatible; built.indie/1.0; +https://built.indie.pit.world/help#bot)'
65
+
66
+ /** Loopback, private, link-local, CGNAT, multicast, reserved: never fetched. */
67
+ export function privateAddress(ip: string): boolean {
68
+ if (isIP(ip) === 4) {
69
+ const [a, b] = ip.split('.').map(Number) as [number, number]
70
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || a >= 224
71
+ }
72
+ const v6 = ip.toLowerCase()
73
+ if (v6.startsWith('::ffff:')) return privateAddress(v6.slice(7))
74
+ return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || v6.startsWith('ff') || v6.startsWith('64:ff9b') || v6.startsWith('2001:db8')
75
+ }
76
+
77
+ /** A product URL as typed: adds https://, drops the fragment, refuses anything that is not a public web address. */
78
+ export function normalizeUrl(input: string): URL {
79
+ let raw = String(input ?? '').trim()
80
+ if (!raw) throw new FetchError('Paste your product’s URL')
81
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) raw = `https://${raw}`
82
+ let url: URL
83
+ try {
84
+ url = new URL(raw)
85
+ } catch {
86
+ throw new FetchError('That is not a URL')
87
+ }
88
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new FetchError('Only http and https URLs')
89
+ if (url.username || url.password) throw new FetchError('No passwords in the URL')
90
+ const host = url.hostname.replace(/^\[|\]$/g, '')
91
+ if (!host.includes('.') && !isIP(host)) throw new FetchError('That address is not on the public internet')
92
+ if (host.endsWith('.local') || host.endsWith('.internal') || host === 'localhost' || (isIP(host) && privateAddress(host))) throw new FetchError('That address is not on the public internet')
93
+ url.hash = ''
94
+ return url
95
+ }
96
+
97
+ type Resolve = (host: string) => Promise<string[]>
98
+ const dnsResolve: Resolve = async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address)
99
+
100
+ export type FetchOptions = { fetch?: typeof fetch; resolve?: Resolve; timeoutMs?: number; maxBytes?: number; accept?: string }
101
+
102
+ /** GET a public URL, following up to 5 redirects by hand so every hop is checked. */
103
+ export async function safeFetch(input: string | URL, o: FetchOptions = {}): Promise<{ url: URL; status: number; type: string; body: Uint8Array }> {
104
+ const doFetch = o.fetch ?? fetch
105
+ const resolve = o.resolve ?? dnsResolve
106
+ const maxBytes = o.maxBytes ?? 3 * 1024 * 1024
107
+ const deadline = AbortSignal.timeout(o.timeoutMs ?? 12_000)
108
+ let url = normalizeUrl(String(input))
109
+ for (let hop = 0; hop < 6; hop++) {
110
+ const host = url.hostname.replace(/^\[|\]$/g, '')
111
+ const addresses = isIP(host) ? [host] : await resolve(host).catch(() => [] as string[])
112
+ if (!addresses.length) throw new FetchError(`${host} does not resolve`)
113
+ if (addresses.some(privateAddress)) throw new FetchError(`${host} is not on the public internet`)
114
+ const res = await doFetch(url, { redirect: 'manual', signal: deadline, headers: { 'user-agent': UA, accept: o.accept ?? '*/*', 'accept-language': 'en' } })
115
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
116
+ await res.body?.cancel().catch(() => {})
117
+ url = normalizeUrl(new URL(res.headers.get('location')!, url).href)
118
+ continue
119
+ }
120
+ const reader = res.body?.getReader()
121
+ const chunks: Uint8Array[] = []
122
+ let size = 0
123
+ while (reader) {
124
+ const { done, value } = await reader.read()
125
+ if (done) break
126
+ size += value.byteLength
127
+ if (size > maxBytes) {
128
+ await reader.cancel().catch(() => {})
129
+ throw new FetchError(`${url.hostname} sent more than ${Math.round(maxBytes / 1024 / 1024)} MB`)
130
+ }
131
+ chunks.push(value)
132
+ }
133
+ const body = new Uint8Array(size)
134
+ let at = 0
135
+ for (const c of chunks) body.set(c, at), (at += c.byteLength)
136
+ return { url, status: res.status, type: (res.headers.get('content-type') ?? '').toLowerCase(), body }
137
+ }
138
+ throw new FetchError('Too many redirects')
139
+ }
140
+
141
+ const SOCIALS: [string, RegExp][] = [
142
+ ['x', /^https?:\/\/(www\.)?(x|twitter)\.com\/(?!intent|share|home|search|hashtag|i\/)[A-Za-z0-9_]{1,15}\/?$/],
143
+ ['github', /^https?:\/\/(www\.)?github\.com\/(?!sponsors|features|about|pricing|login|signup)[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)?\/?$/],
144
+ ['linkedin', /^https?:\/\/([a-z]+\.)?linkedin\.com\/(company|in)\/[^/?#]+\/?$/],
145
+ ['discord', /^https?:\/\/(www\.)?(discord\.gg|discord\.com\/invite)\/[A-Za-z0-9-]+\/?$/],
146
+ ['youtube', /^https?:\/\/(www\.)?youtube\.com\/(@|c\/|channel\/|user\/)[^/?#]+\/?$/],
147
+ ['bluesky', /^https?:\/\/bsky\.app\/profile\/[^/?#]+\/?$/],
148
+ ['instagram', /^https?:\/\/(www\.)?instagram\.com\/[A-Za-z0-9_.]+\/?$/],
149
+ ['tiktok', /^https?:\/\/(www\.)?tiktok\.com\/@[A-Za-z0-9_.]+\/?$/],
150
+ ['reddit', /^https?:\/\/(www\.)?reddit\.com\/r\/[A-Za-z0-9_]+\/?$/],
151
+ ['telegram', /^https?:\/\/(t\.me|telegram\.me)\/[A-Za-z0-9_]+\/?$/],
152
+ ['producthunt', /^https?:\/\/(www\.)?producthunt\.com\/(products|posts)\/[^/?#]+\/?$/],
153
+ ['appstore', /^https?:\/\/apps\.apple\.com\/[^?#]+\/id\d+/],
154
+ ['playstore', /^https?:\/\/play\.google\.com\/store\/apps\/details\?id=[A-Za-z0-9_.]+/],
155
+ ['chrome', /^https?:\/\/chrome(webstore)?\.google\.com\/(webstore\/)?detail\//],
156
+ ]
157
+
158
+ export const SOCIAL_KINDS = SOCIALS.map(([k]) => k)
159
+
160
+ /** A YouTube, Vimeo or Loom link or embed, as a privacy-friendly embed URL; or a direct video file. */
161
+ export function videoFrom(raw: string): Video | null {
162
+ let u: URL
163
+ try {
164
+ u = new URL(raw)
165
+ } catch {
166
+ return null
167
+ }
168
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null
169
+ const host = u.hostname.replace(/^www\.|^m\./, '')
170
+ let id: string | null = null
171
+ if (host === 'youtu.be') id = u.pathname.slice(1)
172
+ else if (host === 'youtube.com' || host === 'youtube-nocookie.com') id = u.searchParams.get('v') ?? u.pathname.match(/^\/(?:embed|shorts|live|v)\/([^/?#]+)/)?.[1] ?? null
173
+ if (id && /^[A-Za-z0-9_-]{6,20}$/.test(id)) return { kind: 'youtube', url: `https://www.youtube.com/watch?v=${id}`, embed: `https://www.youtube-nocookie.com/embed/${id}` }
174
+ if (host === 'vimeo.com' || host === 'player.vimeo.com') {
175
+ const v = u.pathname.match(/(\d{5,})/)?.[1]
176
+ if (v) return { kind: 'vimeo', url: `https://vimeo.com/${v}`, embed: `https://player.vimeo.com/video/${v}` }
177
+ }
178
+ if (host === 'loom.com') {
179
+ const v = u.pathname.match(/^\/(?:share|embed)\/([a-f0-9]{20,})/)?.[1]
180
+ if (v) return { kind: 'loom', url: `https://www.loom.com/share/${v}`, embed: `https://www.loom.com/embed/${v}` }
181
+ }
182
+ if (/\.(mp4|webm|mov)$/i.test(u.pathname)) return { kind: 'file', url: u.href, embed: null }
183
+ return null
184
+ }
185
+
186
+ export const decode = (s: string) =>
187
+ s
188
+ .replace(/&(#x?[0-9a-f]+|amp|lt|gt|quot|apos|nbsp|#39);/gi, (m, e: string) => {
189
+ const k = e.toLowerCase()
190
+ if (k === 'amp') return '&'
191
+ if (k === 'lt') return '<'
192
+ if (k === 'gt') return '>'
193
+ if (k === 'quot') return '"'
194
+ if (k === 'apos' || k === '#39') return "'"
195
+ if (k === 'nbsp') return ' '
196
+ const n = k.startsWith('#x') ? parseInt(k.slice(2), 16) : parseInt(k.slice(1), 10)
197
+ return Number.isFinite(n) && n > 0 && n < 0x110000 ? String.fromCodePoint(n) : m
198
+ })
199
+ .replace(/\s+/g, ' ')
200
+ .trim()
201
+
202
+ export const clip = (s: string, n: number) => {
203
+ const t = s.replace(/\s+/g, ' ').trim()
204
+ if (t.length <= n) return t
205
+ const cut = t.slice(0, n - 1)
206
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), Math.floor(n * 0.6)))}…`
207
+ }
208
+
209
+ type Icon = { href: string; size: number; rel: string }
210
+
211
+ /** What the homepage's HTML says, before any choosing. */
212
+ export type PageFacts = {
213
+ meta: Record<string, string>
214
+ title: string
215
+ h1: string
216
+ icons: Icon[]
217
+ manifest: string | null
218
+ jsonld: unknown[]
219
+ links: string[]
220
+ images: { src: string; width: number; hint: string }[]
221
+ videos: string[]
222
+ firstParagraph: string
223
+ canonical: string | null
224
+ lang: string | null
225
+ feeds: string[]
226
+ profileLinks: string[]
227
+ /** <link rel="opensaas|openstack|openicon">, by rel. */
228
+ specLinks: Record<string, string>
229
+ }
230
+
231
+ export async function readPage(html: string, base: URL): Promise<PageFacts> {
232
+ const f: PageFacts = { meta: {}, title: '', h1: '', icons: [], manifest: null, jsonld: [], links: [], images: [], videos: [], firstParagraph: '', canonical: null, lang: null, feeds: [], profileLinks: [], specLinks: {} }
233
+ const abs = (v: string | null | undefined) => {
234
+ if (!v) return null
235
+ try {
236
+ const u = new URL(decode(v), base)
237
+ return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null
238
+ } catch {
239
+ return null
240
+ }
241
+ }
242
+ let inTitle = false
243
+ let inH1 = false
244
+ let h1Done = false
245
+ let inP = false
246
+ let ld: string | null = null
247
+ let p = ''
248
+ await new HTMLRewriter()
249
+ .on('html', {
250
+ element(e) {
251
+ f.lang ??= e.getAttribute('lang')?.trim() || null
252
+ },
253
+ })
254
+ .on('meta', {
255
+ element(e) {
256
+ const key = (e.getAttribute('property') ?? e.getAttribute('name') ?? e.getAttribute('itemprop') ?? '').toLowerCase().trim()
257
+ const value = e.getAttribute('content')
258
+ if (key && value && !(key in f.meta)) f.meta[key] = decode(value)
259
+ },
260
+ })
261
+ .on('title', {
262
+ element(e) {
263
+ if (f.title) return
264
+ inTitle = true
265
+ e.onEndTag(() => void (inTitle = false))
266
+ },
267
+ text(t) {
268
+ if (inTitle) f.title += t.text
269
+ },
270
+ })
271
+ .on('h1', {
272
+ element(e) {
273
+ if (h1Done) return
274
+ inH1 = true
275
+ e.onEndTag(() => {
276
+ inH1 = false
277
+ h1Done = Boolean(decode(f.h1))
278
+ })
279
+ },
280
+ text(t) {
281
+ if (inH1) f.h1 += t.text
282
+ },
283
+ })
284
+ .on('h1 br, h1 div, h1 span, h1 p', {
285
+ element() {
286
+ if (inH1) f.h1 += ' '
287
+ },
288
+ })
289
+ .on('p', {
290
+ element(e) {
291
+ if (f.firstParagraph) return
292
+ inP = true
293
+ p = ''
294
+ e.onEndTag(() => {
295
+ inP = false
296
+ if (!f.firstParagraph && decode(p).length >= 60) f.firstParagraph = decode(p)
297
+ })
298
+ },
299
+ text(t) {
300
+ if (inP) p += t.text
301
+ },
302
+ })
303
+ .on('link', {
304
+ element(e) {
305
+ const rel = (e.getAttribute('rel') ?? '').toLowerCase()
306
+ const href = abs(e.getAttribute('href'))
307
+ if (!href) return
308
+ const rels = rel.split(/\s+/)
309
+ if (rels.includes('manifest')) f.manifest ??= href
310
+ if (rels.includes('canonical')) f.canonical ??= href
311
+ if (rels.includes('openprofile') || ((rels.includes('me') || rels.includes('author')) && /openprofile\.md(\?|$)/i.test(href))) f.profileLinks.push(href)
312
+ for (const r of ['opensaas', 'openstack', 'openicon', 'opensite']) if (rels.includes(r)) f.specLinks[r] ??= href
313
+ if (rels.includes('alternate') && /(rss|atom|feed\+json)/i.test(e.getAttribute('type') ?? '')) f.feeds.push(href)
314
+ if (/(^|\s)(icon|apple-touch-icon|apple-touch-icon-precomposed|mask-icon)(\s|$)/.test(rel)) {
315
+ const size = Math.max(0, ...(e.getAttribute('sizes') ?? '').split(/\s+/).map((s) => (s === 'any' ? 512 : Number(s.split('x')[0]) || 0)))
316
+ f.icons.push({ href, rel, size: size || (rel.includes('apple') ? 180 : 0) })
317
+ }
318
+ },
319
+ })
320
+ .on('script', {
321
+ element(e) {
322
+ if ((e.getAttribute('type') ?? '').toLowerCase() !== 'application/ld+json') return
323
+ ld = ''
324
+ e.onEndTag(() => {
325
+ try {
326
+ const doc = JSON.parse(ld ?? '')
327
+ for (const d of [doc, ...(Array.isArray(doc) ? doc : []), ...(Array.isArray(doc?.['@graph']) ? doc['@graph'] : [])]) if (d && typeof d === 'object' && !Array.isArray(d)) f.jsonld.push(d)
328
+ } catch {}
329
+ ld = null
330
+ })
331
+ },
332
+ text(t) {
333
+ if (ld !== null) ld += t.text
334
+ },
335
+ })
336
+ .on('a', {
337
+ element(e) {
338
+ const href = abs(e.getAttribute('href'))
339
+ if (href && f.links.length < 400) f.links.push(href)
340
+ },
341
+ })
342
+ .on('img', {
343
+ element(e) {
344
+ const src = abs(e.getAttribute('src') ?? e.getAttribute('data-src') ?? (e.getAttribute('srcset') ?? '').split(',').pop()?.trim().split(/\s+/)[0])
345
+ if (!src || f.images.length >= 60) return
346
+ const hint = `${e.getAttribute('alt') ?? ''} ${e.getAttribute('class') ?? ''} ${e.getAttribute('id') ?? ''} ${src}`.toLowerCase()
347
+ f.images.push({ src, width: Number(e.getAttribute('width')) || 0, hint })
348
+ },
349
+ })
350
+ .on('iframe', {
351
+ element(e) {
352
+ const src = abs(e.getAttribute('src') ?? e.getAttribute('data-src'))
353
+ if (src) f.videos.push(src)
354
+ },
355
+ })
356
+ .on('video', {
357
+ element(e) {
358
+ const src = abs(e.getAttribute('src'))
359
+ if (src) f.videos.push(src)
360
+ const poster = abs(e.getAttribute('poster'))
361
+ if (poster) f.images.push({ src: poster, width: 1280, hint: 'video poster' })
362
+ },
363
+ })
364
+ .on('video source', {
365
+ element(e) {
366
+ const src = abs(e.getAttribute('src'))
367
+ if (src) f.videos.push(src)
368
+ },
369
+ })
370
+ .transform(new Response(html))
371
+ .text()
372
+ f.title = decode(f.title)
373
+ f.h1 = decode(f.h1)
374
+ return f
375
+ }
376
+
377
+ const SEPARATORS = /\s+[|–—·•:-]\s+/
378
+
379
+ /** "Acme — Invoices that pay themselves" → ["Acme", "Invoices that pay themselves"] */
380
+ export function splitTitle(title: string): [string, string] {
381
+ const parts = title.split(SEPARATORS).map((s) => s.trim()).filter(Boolean)
382
+ if (parts.length < 2) return [title.trim(), '']
383
+ // The shorter end is usually the brand.
384
+ const [first, last] = [parts[0]!, parts[parts.length - 1]!]
385
+ return first.length <= last.length ? [first, parts.slice(1).join(' – ')] : [last, parts.slice(0, -1).join(' – ')]
386
+ }
387
+
388
+ /** llms.txt (llmstxt.org): "# Name", then "> one-line summary", then prose. */
389
+ export function readLlms(text: string): { title: string; summary: string } | null {
390
+ if (/^\s*<(!doctype|html|head|body)/i.test(text)) return null
391
+ const lines = text.split(/\r?\n/)
392
+ const title = lines.find((l) => /^#\s+\S/.test(l))?.replace(/^#\s+/, '').trim() ?? ''
393
+ const quote = lines.filter((l) => /^>\s?/.test(l)).map((l) => l.replace(/^>\s?/, '')).join(' ').trim()
394
+ const prose = lines.find((l) => l.trim() && !/^[#>\-*[!|`]/.test(l.trim()))?.trim() ?? ''
395
+ if (!title && !quote) return null
396
+ return { title: clip(title, 80), summary: clip(quote || prose, 400) }
397
+ }
398
+
399
+ const asArray = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null ? [] : [v])
400
+ const ldUrl = (v: unknown): string | null => {
401
+ if (typeof v === 'string') return v
402
+ if (v && typeof v === 'object') return ldUrl((v as any).url ?? (v as any).contentUrl ?? (v as any)['@id'])
403
+ return null
404
+ }
405
+
406
+ const BAD_IMAGE = /(sprite|pixel|tracking|spacer|badge|avatar|gravatar|emoji|icon|logo|favicon|\.svg(\?|$)|1x1|analytics|doubleclick|facebook\.com\/tr|^data:)/
407
+ const GOOD_IMAGE = /screenshot|hero|preview|demo|product|dashboard|poster|showcase|app-?shot|mockup/
408
+
409
+ /** Read a product's homepage (and its llms.txt + manifest) into a SiteInfo. */
410
+ export async function autofetch(input: string, o: FetchOptions = {}): Promise<SiteInfo> {
411
+ const start = normalizeUrl(input)
412
+ const page = await safeFetch(start, { ...o, accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5' }).catch((e) => {
413
+ throw e instanceof FetchError ? e : new FetchError(`Could not reach ${start.hostname}: ${e instanceof Error ? e.message : e}`)
414
+ })
415
+ if (page.status >= 400) throw new FetchError(`${page.url.hostname} answered ${page.status}`)
416
+ if (page.type && !page.type.includes('html')) throw new FetchError(`${page.url.hostname} sent ${page.type.split(';')[0]}, not a web page`)
417
+ const base = page.url
418
+ const facts = await readPage(new TextDecoder().decode(page.body), base)
419
+ const m = facts.meta
420
+ const sources = ['homepage']
421
+
422
+ // Side files, in parallel; any of them may be missing.
423
+ const side = (url: URL | string, accept: string) =>
424
+ safeFetch(url, { ...o, accept, maxBytes: 512 * 1024, timeoutMs: 6000 })
425
+ .then((r) => (r.status < 300 ? new TextDecoder().decode(r.body) : null))
426
+ .catch(() => null)
427
+ const json = (text: string | null) => {
428
+ try {
429
+ const v = text ? JSON.parse(text) : null
430
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : null
431
+ } catch {
432
+ return null
433
+ }
434
+ }
435
+ const profileUrl = facts.profileLinks[0] ?? new URL('/.well-known/openprofile.md', base.origin).href
436
+ const wellKnown = (name: string, rel: string) => facts.specLinks[rel] ?? new URL(`/.well-known/${name}`, base.origin).href
437
+ const stackUrl = wellKnown('openstack.md', 'openstack')
438
+ const saasUrl = wellKnown('opensaas.json', 'opensaas')
439
+ const iconUrl = wellKnown('openicon.json', 'openicon')
440
+ const [llmsText, manifestText, descriptorText, profileText, robotsText, stackText, saasText, iconText] = await Promise.all([
441
+ side(new URL('/llms.txt', base.origin), 'text/plain,text/markdown;q=0.9,*/*;q=0.1'),
442
+ facts.manifest ? side(facts.manifest, 'application/manifest+json,application/json') : null,
443
+ side(wellKnown('opensite.json', 'opensite'), 'application/json'),
444
+ side(profileUrl, 'text/markdown,text/plain;q=0.9'),
445
+ side(new URL('/robots.txt', base.origin), 'text/plain'),
446
+ side(stackUrl, 'text/markdown,text/plain;q=0.9'),
447
+ side(saasUrl, 'application/json'),
448
+ side(iconUrl, 'application/json'),
449
+ ])
450
+
451
+ // OpenSite: a site may say no to readers, by robots.txt or its descriptor.
452
+ const descriptor = json(descriptorText)
453
+ if (descriptor?.index?.allow === false) throw new FetchError(`${base.hostname} asks not to be indexed (opensite.json index.allow is false)`)
454
+ if (robotsText && robotsBlocks(robotsText, base.pathname)) throw new FetchError(`${base.hostname}’s robots.txt asks readers like us to stay out`)
455
+ const declared: any = descriptor?.site && typeof descriptor.site === 'object' ? descriptor.site : null
456
+ if (declared) sources.push('opensite.json')
457
+
458
+ const llms = llmsText ? readLlms(llmsText) : null
459
+ if (llms) sources.push('llms.txt')
460
+ const manifest: any = json(manifestText)
461
+ if (manifest) sources.push('manifest')
462
+ const profile = profileText ? readProfile(profileText, profileUrl) : null
463
+ if (profile) sources.push('openprofile.md')
464
+ const stack = stackText ? readStack(stackText, stackUrl) : null
465
+ if (stack) sources.push('openstack.md')
466
+ const pricing = readSaas(json(saasText), saasUrl)
467
+ if (pricing) sources.push('opensaas.json')
468
+ const openicon = json(iconText)?.openicon ? iconUrl : null
469
+ if (openicon) sources.push('openicon.json')
470
+
471
+ const ld = facts.jsonld as any[]
472
+ if (ld.length) sources.push('json-ld')
473
+ const ldOf = (...types: string[]) => ld.find((d) => asArray(d['@type']).some((t) => types.includes(String(t))))
474
+ const app = ldOf('SoftwareApplication', 'WebApplication', 'MobileApplication', 'Product', 'VideoGame') ?? null
475
+ const org = ldOf('Organization', 'Corporation', 'LocalBusiness') ?? null
476
+ const site = ldOf('WebSite') ?? null
477
+ if (m['og:title'] || m['og:image']) sources.push('open graph')
478
+ if (m['twitter:card']) sources.push('twitter card')
479
+
480
+ const str = (v: unknown) => (typeof v === 'string' ? decode(v) : '')
481
+ const [titleName, titleRest] = splitTitle(str(m['og:title']) || facts.title)
482
+ const name = clip(
483
+ // A manifest's short_name is a home-screen label ("Family" for family.ancestry): last resort.
484
+ [declared?.name, m['og:site_name'], app?.name, m['application-name'], manifest?.name, site?.name, org?.name, llms?.title, titleName, manifest?.short_name].map(str).find((v) => v && v.length <= 40) ??
485
+ base.hostname.replace(/^www\./, ''),
486
+ 60,
487
+ )
488
+
489
+ const description = [declared?.description, llms?.summary, m['description'], m['og:description'], m['twitter:description'], app?.description, manifest?.description, facts.firstParagraph].map(str).find((v) => v.length >= 20) ?? ''
490
+
491
+ const flat = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '')
492
+ const firstSentence = (s: string) => s.split(/(?<=[.!?])\s/)[0] ?? s
493
+ const tagline = clip([titleRest, facts.h1, firstSentence(str(m['description'])), firstSentence(description)].map(str).find((v) => v.length >= 8 && flat(v) !== flat(name)) ?? '', 90)
494
+
495
+ const absolute = (v: unknown, from: URL = base) => {
496
+ const s = ldUrl(v)
497
+ if (!s) return null
498
+ try {
499
+ const u = new URL(s, from)
500
+ return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null
501
+ } catch {
502
+ return null
503
+ }
504
+ }
505
+
506
+ // Logo: something square and big. JSON-LD logo, apple-touch-icon, manifest icon, any icon, /favicon.ico.
507
+ const manifestBase = facts.manifest ? new URL(facts.manifest) : base
508
+ const manifestIcons: Icon[] = asArray(manifest?.icons)
509
+ .map((i: any) => ({ href: absolute(i?.src, manifestBase) ?? '', size: Math.max(0, ...String(i?.sizes ?? '').split(/\s+/).map((s) => Number(s.split('x')[0]) || 0)), rel: 'manifest' }))
510
+ .filter((i) => i.href)
511
+ const icons = [...facts.icons, ...manifestIcons].filter((i) => !i.rel.includes('mask')).sort((a, b) => b.size - a.size)
512
+ const logo = absolute(declared?.icon) ?? absolute(org?.logo) ?? absolute(app?.logo) ?? icons.find((i) => i.size >= 96)?.href ?? icons[0]?.href ?? (profile?.kind === 'organization' ? profile.avatar : null) ?? new URL('/favicon.ico', base).href
513
+
514
+ const ogImage = absolute(declared?.image) ?? absolute(m['og:image:secure_url'] ?? m['og:image'] ?? m['og:image:url'] ?? m['twitter:image'] ?? m['twitter:image:src'] ?? asArray(app?.image)[0])
515
+
516
+ // Screenshots: JSON-LD screenshots first, then big content images from the page.
517
+ const seen = new Set([logo, ogImage].filter(Boolean) as string[])
518
+ const images: string[] = []
519
+ const add = (src: string | null) => {
520
+ if (!src || seen.has(src) || images.length >= 6) return
521
+ seen.add(src)
522
+ images.push(src)
523
+ }
524
+ for (const s of asArray(app?.screenshot)) add(absolute(s))
525
+ for (const s of asArray(app?.image).slice(1)) add(absolute(s))
526
+ facts.images
527
+ .filter((i) => GOOD_IMAGE.test(i.hint) || !BAD_IMAGE.test(i.hint))
528
+ .map((i) => ({ ...i, score: (GOOD_IMAGE.test(i.hint) ? 2 : 0) + (i.width >= 600 ? 2 : i.width >= 300 ? 1 : i.width === 0 ? 0.5 : -3) }))
529
+ .filter((i) => i.score > 0)
530
+ .sort((a, b) => b.score - a.score)
531
+ .forEach((i) => add(i.src))
532
+
533
+ // Videos: og:video, JSON-LD VideoObject, embeds on the page, links to them.
534
+ const videos: Video[] = []
535
+ const addVideo = (raw: string | null) => {
536
+ const v = raw ? videoFrom(raw) : null
537
+ if (v && !videos.some((x) => x.url === v.url) && videos.length < 4) videos.push(v)
538
+ }
539
+ addVideo(absolute(m['og:video:secure_url'] ?? m['og:video'] ?? m['og:video:url'] ?? m['twitter:player']))
540
+ for (const d of ld) if (asArray(d['@type']).includes('VideoObject')) addVideo(absolute(d.embedUrl) ?? absolute(d.contentUrl))
541
+ for (const v of asArray(app?.video)) addVideo(absolute((v as any)?.embedUrl) ?? absolute(v))
542
+ for (const v of facts.videos) addVideo(v)
543
+ for (const l of facts.links) if (/youtu\.?be|vimeo\.com|loom\.com\/share/.test(l)) addVideo(l)
544
+
545
+ // Socials: sameAs, twitter:site, and links on the page.
546
+ const socials: Record<string, string> = {}
547
+ const addSocial = (raw: string | null) => {
548
+ if (!raw) return
549
+ for (const [kind, re] of SOCIALS) if (!socials[kind] && re.test(raw)) socials[kind] = raw.replace(/\/+$/, '')
550
+ }
551
+ for (const s of [...asArray(org?.sameAs), ...asArray(app?.sameAs), ...asArray(site?.sameAs)]) addSocial(absolute(s))
552
+ const handle = (m['twitter:site'] ?? m['twitter:creator'] ?? '').replace(/^@/, '')
553
+ if (/^[A-Za-z0-9_]{1,15}$/.test(handle)) socials.x ??= `https://x.com/${handle}`
554
+ for (const a of profile?.accounts ?? []) addSocial(a)
555
+ for (const l of facts.links) addSocial(l)
556
+
557
+ const tags = [
558
+ ...new Set(
559
+ [...String(m['keywords'] ?? '').split(','), ...asArray(app?.applicationCategory).map(String), ...asArray(manifest?.categories).map(String)]
560
+ .map((t) => t.toLowerCase().replace(/application$/, '').replace(/[^a-z0-9 +#.-]/g, '').trim())
561
+ .filter((t) => t.length >= 2 && t.length <= 24),
562
+ ),
563
+ ].slice(0, 6)
564
+
565
+ return {
566
+ url: base.href,
567
+ domain: base.hostname.replace(/^www\./, ''),
568
+ name,
569
+ tagline,
570
+ description: clip(description, 1200),
571
+ logo,
572
+ ogImage,
573
+ images,
574
+ videos,
575
+ socials,
576
+ tags,
577
+ themeColor: /^#[0-9a-f]{3,8}$/i.test(m['theme-color'] ?? '') ? m['theme-color']! : null,
578
+ llms: llms ? { url: new URL('/llms.txt', base.origin).href, ...llms } : null,
579
+ manifest: Boolean(manifest),
580
+ profile,
581
+ canonical: facts.canonical ?? absolute(m['og:url']) ?? base.href,
582
+ kind: kindOf(ld, m['og:type']),
583
+ language: facts.lang ?? m['og:locale'] ?? null,
584
+ feeds: [...new Set([...facts.feeds, ...asArray(declared?.feeds).map(String)])].slice(0, 5),
585
+ cards: cardsOf(m),
586
+ jsonld: ld.slice(0, 20),
587
+ declared: Boolean(declared),
588
+ stack,
589
+ pricing,
590
+ openicon,
591
+ sources,
592
+ }
593
+ }
594
+
595
+ /** OpenStack.md: every bullet under a `##` (or `###` interface) heading is one item; we keep its name, not its notes. */
596
+ export function readStack(text: string, url: string): Stack | null {
597
+ if (/^\s*<(!doctype|html|head|body)/i.test(text) || !/^#\s+\S/m.test(text)) return null
598
+ const items: Stack['items'] = []
599
+ let layer = ''
600
+ let inIdentity = true
601
+ for (const line of text.split(/\r?\n/)) {
602
+ const h = line.match(/^(#{2,3})\s+(.+)$/)
603
+ if (h) {
604
+ inIdentity = false
605
+ layer = h[1] === '##' ? h[2]!.trim() : `${layer.split(' / ')[0]} / ${h[2]!.trim()}`
606
+ continue
607
+ }
608
+ const b = line.match(/^\s*[-*]\s+(.+)$/)
609
+ if (!b || inIdentity || !layer || /^(rules|never|avoid|not)\b/i.test(layer)) continue
610
+ const name = b[1]!.replace(/\*\*/g, '').replace(/`/g, '').split(/:\s|\s[—–]\s|\s\(/)[0]!.trim()
611
+ if (name && name.length <= 60 && items.length < 60 && !items.some((i) => i.name === name)) items.push({ layer: clip(layer, 40), name })
612
+ }
613
+ return items.length ? { url, items } : null
614
+ }
615
+
616
+ /** OpenSaaS: the plans a person can buy, and the page that cancels them. */
617
+ export function readSaas(doc: any, url: string): Pricing | null {
618
+ if (!doc || !Array.isArray(doc.plans)) return null
619
+ const link = (v: unknown) => (typeof v === 'string' && /^https?:\/\//.test(v) ? v : null)
620
+ const plans = doc.plans
621
+ .filter((p: any) => p && typeof p.name === 'string' && (p.status ?? 'active') === 'active')
622
+ .slice(0, 8)
623
+ .map((p: any) => ({ name: clip(String(p.name), 40), price: typeof p.price === 'number' ? p.price : null, period: typeof p.period === 'string' ? p.period.slice(0, 12) : null, trial: typeof p.trial === 'string' ? p.trial.slice(0, 20) : null, url: link(p.url) }))
624
+ if (!plans.length) return null
625
+ return { url, currency: typeof doc.service?.currency === 'string' ? doc.service.currency.slice(0, 3).toUpperCase() : 'USD', plans, cancel: link(doc.actions?.cancel?.page) }
626
+ }
627
+
628
+ /** OpenSite step 7: the record's kind from JSON-LD @type, else og:type. A product directory mostly sees 'product' and 'site'. */
629
+ export function kindOf(ld: any[], ogType: string | undefined): string {
630
+ const types = ld.flatMap((d) => asArray(d['@type']).map(String))
631
+ const map: [string[], string][] = [
632
+ [['SoftwareApplication', 'WebApplication', 'MobileApplication', 'Product', 'VideoGame'], 'product'],
633
+ [['Article', 'NewsArticle', 'BlogPosting'], 'article'],
634
+ [['Person'], 'profile'],
635
+ [['Event'], 'event'],
636
+ [['VideoObject'], 'video'],
637
+ [['PodcastSeries'], 'podcast'],
638
+ [['WebSite'], 'site'],
639
+ ]
640
+ for (const [names, kind] of map) if (types.some((t) => names.includes(t))) return kind
641
+ const og = String(ogType ?? '').toLowerCase()
642
+ if (['article', 'profile', 'product'].includes(og)) return og
643
+ if (og.startsWith('video')) return 'video'
644
+ return og === 'website' ? 'site' : 'page'
645
+ }
646
+
647
+ /** OpenSite step 12: card tags verbatim, grouped by prefix. */
648
+ function cardsOf(meta: Record<string, string>): Record<string, Record<string, string>> {
649
+ const out: Record<string, Record<string, string>> = {}
650
+ for (const [k, v] of Object.entries(meta)) {
651
+ const at = k.indexOf(':')
652
+ if (at < 1) continue
653
+ const prefix = k.slice(0, at)
654
+ if (!/^[a-z]+$/.test(prefix) || Object.keys(out).length > 12) continue
655
+ ;(out[prefix] ??= {})[k.slice(at + 1)] = v.slice(0, 500)
656
+ }
657
+ return out
658
+ }
659
+
660
+ /** robots.txt: does the group for us (or *) disallow this path? Longest match wins; Allow beats Disallow on a tie. */
661
+ export function robotsBlocks(text: string, path: string, agents = ['built.indie', 'opensite']): boolean {
662
+ const groups: { agents: string[]; rules: [boolean, string][] }[] = []
663
+ let current: (typeof groups)[number] | null = null
664
+ let lastWasAgent = false
665
+ for (const raw of text.split(/\r?\n/)) {
666
+ const line = raw.replace(/#.*/, '').trim()
667
+ const m = line.match(/^([a-z-]+)\s*:\s*(.*)$/i)
668
+ if (!m) continue
669
+ const [, key, value] = m as unknown as [string, string, string]
670
+ if (key.toLowerCase() === 'user-agent') {
671
+ if (!current || !lastWasAgent) groups.push((current = { agents: [], rules: [] }))
672
+ current.agents.push(value.toLowerCase())
673
+ lastWasAgent = true
674
+ } else {
675
+ lastWasAgent = false
676
+ if (current && /^(allow|disallow)$/i.test(key)) current.rules.push([key.toLowerCase() === 'allow', value])
677
+ }
678
+ }
679
+ const mine = groups.find((g) => g.agents.some((a) => agents.some((x) => a !== '*' && x.startsWith(a)))) ?? groups.find((g) => g.agents.includes('*'))
680
+ let best: [boolean, number] = [true, -1]
681
+ for (const [allow, rule] of mine?.rules ?? []) {
682
+ if (!rule) continue
683
+ const re = new RegExp(`^${rule.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\\\$$/, '$')}`)
684
+ if (re.test(path) && (rule.length > best[1] || (rule.length === best[1] && allow))) best = [allow, rule.length]
685
+ }
686
+ return !best[0]
687
+ }
688
+
689
+ /** The essentials of an OpenProfile.md: who, what kind, their picture and accounts. */
690
+ export function readProfile(text: string, url: string): Profile | null {
691
+ if (/^\s*<(!doctype|html|head|body)/i.test(text) || !/^#\s+\S/m.test(text)) return null
692
+ try {
693
+ const doc = parseOpenProfile(text)
694
+ if (!doc.name) return null
695
+ const avatar = identityValue(doc, 'Avatar')
696
+ return {
697
+ url,
698
+ kind: kindOfProfile(doc),
699
+ name: clip(doc.name, 80),
700
+ headline: clip(doc.headline ?? '', 160),
701
+ avatar: avatar && /^https?:\/\//.test(avatar) ? avatar : null,
702
+ web: web(doc),
703
+ accounts: accounts(doc).map((a) => a.url).filter((u) => /^https?:\/\//.test(u)).slice(0, 20),
704
+ }
705
+ } catch {
706
+ return null
707
+ }
708
+ }