@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.
- package/LICENSE +21 -0
- package/OpenStack.md +74 -0
- package/README.md +63 -2
- package/bin/builtindie +3 -0
- package/package.json +35 -4
- package/public/app.js +35 -0
- package/public/favicon.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icons/openicon.json +574 -0
- package/public/icons/svg/apple.svg +1 -0
- package/public/icons/svg/arrow-up.svg +1 -0
- package/public/icons/svg/bluesky.svg +1 -0
- package/public/icons/svg/check.svg +1 -0
- package/public/icons/svg/chrome.svg +1 -0
- package/public/icons/svg/code.svg +1 -0
- package/public/icons/svg/discord.svg +1 -0
- package/public/icons/svg/dollar.svg +1 -0
- package/public/icons/svg/external-link.svg +1 -0
- package/public/icons/svg/github.svg +1 -0
- package/public/icons/svg/globe.svg +1 -0
- package/public/icons/svg/instagram.svg +1 -0
- package/public/icons/svg/layers.svg +1 -0
- package/public/icons/svg/link.svg +1 -0
- package/public/icons/svg/linkedin.svg +1 -0
- package/public/icons/svg/mail.svg +1 -0
- package/public/icons/svg/product-hunt.svg +1 -0
- package/public/icons/svg/reddit.svg +1 -0
- package/public/icons/svg/rocket.svg +1 -0
- package/public/icons/svg/search.svg +1 -0
- package/public/icons/svg/share.svg +1 -0
- package/public/icons/svg/star.svg +1 -0
- package/public/icons/svg/tag.svg +1 -0
- package/public/icons/svg/telegram.svg +1 -0
- package/public/icons/svg/terminal.svg +1 -0
- package/public/icons/svg/tiktok.svg +1 -0
- package/public/icons/svg/trophy.svg +1 -0
- package/public/icons/svg/user.svg +1 -0
- package/public/icons/svg/x.svg +1 -0
- package/public/icons/svg/youtube.svg +1 -0
- package/public/install.sh +44 -0
- package/public/manifest.webmanifest +20 -0
- package/public/moshpit-root-ca.crt +11 -0
- package/public/style.css +162 -0
- package/public/sw.js +30 -0
- package/src/auth.ts +96 -0
- package/src/cli.ts +203 -0
- package/src/client.ts +115 -0
- package/src/db.ts +412 -0
- package/src/fetch.ts +708 -0
- package/src/html.ts +525 -0
- package/src/icons.ts +56 -0
- package/src/mcp.ts +79 -0
- package/src/media.ts +160 -0
- package/src/ring.ts +86 -0
- package/src/server.ts +655 -0
- package/src/service.ts +369 -0
- package/src/tui.ts +127 -0
- 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
|
+
}
|