-
Notifications
You must be signed in to change notification settings - Fork 2
/
db.js
766 lines (642 loc) · 22.7 KB
/
db.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
/* globals DOMParser */
import { openDB } from './dependencies/idb/index.js'
export const DEFAULT_DB = 'default'
export const ACTORS_STORE = 'actors'
export const NOTES_STORE = 'notes'
export const ACTIVITIES_STORE = 'activities'
export const FOLLOWED_ACTORS_STORE = 'followedActors'
export const DEFAULT_LIMIT = 32
export const ID_FIELD = 'id'
export const URL_FIELD = 'url'
export const CREATED_FIELD = 'created'
export const UPDATED_FIELD = 'updated'
export const PUBLISHED_FIELD = 'published'
export const TO_FIELD = 'to'
export const CC_FIELD = 'cc'
export const IN_REPLY_TO_FIELD = 'inReplyTo'
export const TAG_NAMES_FIELD = 'tag_names'
export const ATTRIBUTED_TO_FIELD = 'attributedTo'
export const CONVERSATION_FIELD = 'conversation'
export const ACTOR_FIELD = 'actor'
export const PUBLISHED_SUFFIX = ', published'
export const TYPE_CREATE = 'Create'
export const TYPE_UPDATE = 'Update'
export const TYPE_NOTE = 'Note'
export const TYPE_DELETE = 'Delete'
export const HYPER_PREFIX = 'hyper://'
export const IPNS_PREFIX = 'ipns://'
const ACCEPT_HEADER =
'application/activity+json, application/ld+json, application/json, text/html'
const TIMELINE_ALL = 'all'
const TIMELINE_FOLLOWING = 'following'
// Global cache to store protocol reachability
const protocolSupportMap = new Map()
// TODO: When ingesting notes and actors, wrap any dates in `new Date()`
// TODO: When ingesting notes add a "tag_names" field which is just the names of the tag
// TODO: When ingesting notes, also load their replies
export function isP2P (url) {
return url.startsWith(HYPER_PREFIX) || url.startsWith(IPNS_PREFIX)
}
export async function supportsP2P (url) {
const urlObject = new URL(url)
const protocol = urlObject.protocol
if (protocolSupportMap.has(protocol)) {
return protocolSupportMap.get(protocol)
}
// Set a promise to avoid multiple simultaneous checks
const supportCheckPromise = fetch(url)
.then(response => {
const supported = response.ok
protocolSupportMap.set(protocol, supported)
return supported
})
.catch(error => {
console.error(`Error checking protocol support for ${protocol}:`, error)
protocolSupportMap.set(protocol, false)
return false
})
protocolSupportMap.set(protocol, supportCheckPromise)
return supportCheckPromise
}
export function resolveP2PUrl (url) {
if (!url) return url
if (url.startsWith(HYPER_PREFIX)) {
return url.replace(HYPER_PREFIX, 'https://hyper.hypha.coop/hyper/')
} else if (url.startsWith(IPNS_PREFIX)) {
return url.replace(IPNS_PREFIX, 'https://ipfs.hypha.coop/ipns/')
}
return url
}
export class ActivityPubDB extends EventTarget {
constructor (db, fetch = globalThis.fetch) {
super()
this.db = db
this.fetch = fetch
}
static async load (name = DEFAULT_DB, fetch = globalThis.fetch) {
const db = await openDB(name, 4, {
upgrade
})
return new ActivityPubDB(db, fetch)
}
resolveURL (url) {
// TODO: Check if mention
return this.#get(url)
}
getObjectPage (data) {
if (typeof data === 'string') return data
const { url, id } = data
if (!url) return id
if (typeof url === 'string') return url
if (Array.isArray(url)) {
const firstLink = url.find((item) => (typeof item === 'string') || item.href)
if (firstLink) return firstLink.href || firstLink
} else if (url.href) {
return url.href
}
return id
}
#fetch (...args) {
const { fetch } = this
return fetch(...args)
}
#gateWayFetch (url, options = {}) {
let gatewayUrl = url
// TODO: Don't hardcode the gateway
if (url.startsWith(HYPER_PREFIX)) {
gatewayUrl = url.replace(HYPER_PREFIX, 'https://hyper.hypha.coop/hyper/')
} else if (url.startsWith(IPNS_PREFIX)) {
gatewayUrl = url.replace(IPNS_PREFIX, 'https://ipfs.hypha.coop/ipns/')
}
return this.#fetch(gatewayUrl, options)
}
#proxiedFetch (url, options = {}) {
const proxiedURL = 'https://corsproxy.io/?' + encodeURIComponent(url)
return this.#fetch(proxiedURL, options)
}
async #get (url) {
if (url && typeof url === 'object') {
return url
}
let response
// Try fetching directly for all URLs (including P2P URLs)
// TODO: Signed fetch
try {
response = await this.#fetch(url, {
headers: {
Accept: ACCEPT_HEADER
}
})
} catch (error) {
if (isP2P(url)) {
// Maybe the browser can't load p2p URLs
response = await this.#gateWayFetch(url, {
headers: {
Accept: ACCEPT_HEADER
}
})
} else {
// Try the proxy, maybe it's cors?
response = await this.#proxiedFetch(url, {
headers: {
Accept: ACCEPT_HEADER
}
})
}
}
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}.\n${url}\n${await response.text()}`)
}
if (isResponseHTML(response)) {
const jsonLdUrl = await getResponseLink(response)
if (jsonLdUrl) return this.#get(jsonLdUrl)
// No JSON-LD link found in HTML
throw new Error('No JSON-LD link found in the response')
}
return await response.json()
}
async getActor (url) {
// TODO: Try to load from cache
const actor = await this.#get(url)
this.db.put(ACTORS_STORE, actor)
return this.db.get(ACTORS_STORE, actor.id)
}
async getAllActors () {
const tx = this.db.transaction(ACTORS_STORE)
const actors = []
for await (const cursor of tx.store) {
actors.push(cursor.value)
}
return actors
}
async getNote (url) {
try {
const note = await this.db.get(NOTES_STORE, url)
if (!note) throw new Error('Note not loaded')
return note // Simply return the locally found note.
} catch (error) {
// If the note is not in the local store, fetch it but don't automatically ingest it.
const note = await this.#get(url)
return note // Return the fetched note for further processing by the caller.
}
}
async getTotalNotesCount () {
const tx = this.db.transaction(NOTES_STORE, 'readonly')
const store = tx.objectStore(NOTES_STORE)
const totalNotes = await store.count()
return totalNotes
}
async getActivity (url) {
try {
return this.db.get(ACTIVITIES_STORE, url)
} catch {
const activity = await this.#get(url)
await this.ingestActivity(activity)
return activity
}
}
async * searchActivities (actor, { limit = DEFAULT_LIMIT, skip = 0 } = {}) {
const indexName = ACTOR_FIELD + ', published'
const tx = this.db.transaction(ACTIVITIES_STORE, 'read')
const index = tx.store.index(indexName)
let count = 0
for await (const cursor of index.iterate(actor)) {
if (count === 0) {
cursor.advance(skip)
}
yield cursor.value
count++
if (count >= limit) break
}
await tx.done()
}
async * searchNotes ({ timeline, attributedTo, inReplyTo, excludeReplies } = {}, { skip = 0, limit = DEFAULT_LIMIT, sort = -1 } = {}) {
const tx = this.db.transaction(NOTES_STORE, 'readonly')
let indexName, keyRange
if (inReplyTo) {
indexName = IN_REPLY_TO_FIELD
keyRange = IDBKeyRange.only(inReplyTo)
} else if (attributedTo) {
indexName = ATTRIBUTED_TO_FIELD
keyRange = IDBKeyRange.only(attributedTo)
} else {
indexName = PUBLISHED_FIELD
keyRange = null
}
const index = tx.store.index(indexName)
// For random sort
if (sort === 0) {
const totalNotes = await index.count()
let count = 0 // Add a count variable to keep track of successful yields
while (count < limit) { // Use while loop based on count to ensure we return the correct number of items
const randomSkip = Math.floor(Math.random() * totalNotes)
const cursor = await index.openCursor()
if (!cursor) break // Avoid null cursor cases
// Advance the cursor by randomSkip
if (randomSkip > 0) {
await cursor.advance(randomSkip)
}
const note = cursor.value
// Apply filtering logic
if ((!excludeReplies || !note.inReplyTo) && (!timeline || (note.timeline && note.timeline.includes(timeline)))) {
yield note
count++ // Increment count only after a successful yield
}
}
} else { // For regular sorting (newest/oldest)
const direction = sort > 0 ? 'next' : 'prev'
let cursor = await index.openCursor(keyRange, direction)
let skipped = 0
let count = 0
// Process cursor in a loop to keep the transaction active
while (cursor && count < limit) {
const note = cursor.value
let includeNote = true
// Filter by timeline
if (timeline && (!note.timeline || !note.timeline.includes(timeline))) {
includeNote = false
}
// Exclude replies if required
if (excludeReplies && note.inReplyTo) {
includeNote = false
}
// If the note matches the filter criteria, yield it
if (includeNote) {
if (skipped < skip) {
skipped++
} else {
yield note
count++
}
}
// Move to the next cursor
cursor = await cursor.continue()
}
}
// Ensure the transaction completes
await tx.done
}
async ingestActor (url, isInitial = false) {
console.log(`Starting ingestion for actor from URL: ${url}`)
const actor = await this.getActor(url)
console.log('Actor received:', actor)
// Add 'following' to timeline if the actor is followed
const isFollowing = await this.isActorFollowed(url)
if (isFollowing) {
const tx = this.db.transaction(NOTES_STORE, 'readwrite')
const store = tx.objectStore(NOTES_STORE)
const index = store.index(ATTRIBUTED_TO_FIELD)
const keyRange = IDBKeyRange.only(actor.id)
let cursor = await index.openCursor(keyRange)
while (cursor) {
const note = cursor.value
if (!note.timeline.includes(TIMELINE_FOLLOWING)) {
note.timeline.push(TIMELINE_FOLLOWING)
cursor.update(note)
}
cursor = await cursor.continue()
}
await tx.done
}
// If actor has an 'outbox', ingest it as a collection
if (actor.outbox) {
await this.ingestActivityCollection(actor.outbox, actor.id, isInitial)
} else {
console.error(`No outbox found for actor at URL ${url}`)
}
}
async ingestActivityCollection (collectionOrUrl, actorId, isInitial = false) {
console.log(
`Fetching collection for actor ID ${actorId}:`,
collectionOrUrl
)
const sort = isInitial ? -1 : 1
const cursor = this.iterateCollection(collectionOrUrl, {
limit: Infinity,
sort
})
for await (const activity of cursor) {
// Assume newest items will be first
const wasNew = await this.ingestActivity(activity, actorId)
if (!wasNew) {
console.log('Caught up with', actorId || collectionOrUrl)
break
}
}
}
async * iterateCollection (collectionOrUrl, { skip = 0, limit = DEFAULT_LIMIT, sort = 1 } = {}) {
const collection = await this.#get(collectionOrUrl)
let items = collection.orderedItems || collection.items || []
let next, prev
if (sort === -1) {
items = items.reverse()
prev = collection.last // Start from the last page if sorting in descending order
} else {
next = collection.first // Start from the first page if sorting in ascending order
}
let toSkip = skip
let count = 0
if (items) {
for await (const item of this.#getAll(items)) {
if (toSkip > 0) {
toSkip--
} else {
yield item
count++
if (count >= limit) return
}
}
}
// Iterate through pages in the specified order
while (sort === -1 ? prev : next) {
const page = await this.#get(sort === -1 ? prev : next)
next = page.next
prev = page.prev
items = page.orderedItems || page.items
if (sort === -1) {
items = items.reverse()
}
for await (const item of this.#getAll(items)) {
if (toSkip > 0) {
toSkip--
} else {
yield item
count++
if (count >= limit) return
}
}
}
}
async * #getAll (items) {
for (const itemOrUrl of items) {
const item = await this.#get(itemOrUrl)
if (item) {
yield item
}
}
}
async ingestActivity (activity) {
// Check if the activity has an 'id' and create one if it does not
if (!activity.id) {
if (typeof activity.object === 'string') {
// Use the URL of the object as the id for the activity
activity.id = activity.object
} else {
console.error(
'Activity does not have an ID and cannot be processed:',
activity
)
return // Skip this activity
}
}
const existing = await this.db.get(ACTIVITIES_STORE, activity.id)
if (existing) return false
// Convert the published date to a Date object
activity.published = new Date(activity.published)
// Store the activity in the ACTIVITIES_STORE
console.log('Ingesting activity:', activity)
await this.db.put(ACTIVITIES_STORE, activity)
if ((activity.type === TYPE_CREATE || activity.type === TYPE_UPDATE) && activity.actor) {
const note = await this.#get(activity.object)
if (note.type === TYPE_NOTE) {
// Only ingest the note if the note's attributed actor is the same as the activity's actor
if (note.attributedTo === activity.actor) {
console.log('Ingesting note:', note)
await this.ingestNote(note)
} else {
console.log(`Skipping note ingestion for actor mismatch: Note attributed to ${note.attributedTo}, but activity actor is ${activity.actor}`)
}
}
} else if (activity.type === TYPE_DELETE) {
// Handle 'Delete' activity type
await this.deleteNote(activity.object)
}
return true
}
async ingestNote (note) {
console.log('Ingesting note', note)
if (typeof note === 'string') {
note = await this.getNote(note) // Fetch the note if it's just a URL string
}
note.published = new Date(note.published) // Convert published to Date
note.tag_names = (note.tags || []).map(({ name }) => name) // Extract tag names
const isFollowingAuthor = await this.isActorFollowed(note.attributedTo)
note.timeline = []
if (isFollowingAuthor) {
note.timeline.push('all')
// Only add to the 'following' timeline if it's not a reply
if (!note.inReplyTo) {
note.timeline.push('following')
}
}
const existingNote = await this.db.get(NOTES_STORE, note.id)
if (existingNote && new Date(note.published) > new Date(existingNote.published)) {
console.log(`Updating note with newer version: ${note.id}`)
await this.db.put(NOTES_STORE, note)
} else if (!existingNote) {
console.log(`Adding new note: ${note.id}`)
await this.db.put(NOTES_STORE, note)
}
// Handle replies recursively
if (note.replies) {
console.log('Attempting to load replies for:', note.id)
await this.ingestReplies(note.replies)
}
}
async ingestReplies (url) {
console.log('Ingesting replies for URL:', url)
try {
const replies = await this.iterateCollection(url, { limit: Infinity })
for await (const reply of replies) {
await this.ingestNote(reply) // Recursively ingest replies
}
} catch (error) {
console.error('Error ingesting replies:', error)
}
}
async deleteNote (url) {
// delete note using the url as the `id` from the notes store
this.db.delete(NOTES_STORE, url)
}
// Method to follow an actor
async followActor (url) {
const followedAt = new Date()
await this.db.put(FOLLOWED_ACTORS_STORE, { url, followedAt })
await this.ingestActor(url, true)
console.log(`Followed actor: ${url} at ${followedAt}`)
this.dispatchEvent(new CustomEvent('actorFollowed', { detail: { url, followedAt } }))
}
// Method to unfollow an actor
async unfollowActor (url) {
await this.db.delete(FOLLOWED_ACTORS_STORE, url)
await this.purgeActor(url)
console.log(`Unfollowed and purged actor: ${url}`)
this.dispatchEvent(new CustomEvent('actorUnfollowed', { detail: { url } }))
}
async purgeActor (url) {
// First, remove the actor from the ACTORS_STORE
const actor = await this.getActor(url)
if (actor) {
await this.db.delete(ACTORS_STORE, actor.id)
console.log(`Removed actor: ${url}`)
}
// Remove all activities related to this actor from the ACTIVITIES_STORE using async iteration
const activitiesTx = this.db.transaction(ACTIVITIES_STORE, 'readwrite')
const activitiesStore = activitiesTx.objectStore(ACTIVITIES_STORE)
const activitiesIndex = activitiesStore.index(ACTOR_FIELD)
for await (const cursor of activitiesIndex.iterate(actor.id)) {
await activitiesStore.delete(cursor.primaryKey)
}
await activitiesTx.done
console.log(`Removed all activities related to actor: ${url}`)
// Additionally, remove notes associated with the actor's activities using async iteration
const notesTx = this.db.transaction(NOTES_STORE, 'readwrite')
const notesStore = notesTx.objectStore(NOTES_STORE)
const notesIndex = notesStore.index(ATTRIBUTED_TO_FIELD)
for await (const cursor of notesIndex.iterate(actor.id)) {
await notesStore.delete(cursor.primaryKey)
}
await notesTx.done
console.log(`Removed all notes related to actor: ${url}`)
}
// Method to retrieve all followed actors
async getFollowedActors () {
const tx = this.db.transaction(FOLLOWED_ACTORS_STORE, 'readonly')
const store = tx.objectStore(FOLLOWED_ACTORS_STORE)
const followedActors = []
for await (const cursor of store) {
followedActors.push(cursor.value)
}
return followedActors
}
// Method to check if an actor is followed
async isActorFollowed (url) {
try {
const record = await this.db.get(FOLLOWED_ACTORS_STORE, url)
return !!record // Convert the record to a boolean indicating if the actor is followed
} catch (error) {
console.error(`Error checking if actor is followed: ${url}`, error)
return false // Assume not followed if there's an error
}
}
async hasFollowedActors () {
const followedActors = await this.getFollowedActors()
return followedActors.length > 0
}
async replyCount (inReplyTo) {
console.log(`Counting replies for ${inReplyTo}`)
await this.ingestNote(inReplyTo) // Ensure the note and its replies are ingested before counting
const tx = this.db.transaction(NOTES_STORE, 'readonly')
const store = tx.objectStore(NOTES_STORE)
// Check if the index is correctly setup
const index = store.index(IN_REPLY_TO_FIELD)
const count = await index.count(inReplyTo)
console.log(`Found ${count} replies for ${inReplyTo}`)
return count
}
async setTheme (themeName) {
await this.db.put('settings', { key: 'theme', value: themeName })
}
async getTheme () {
const themeSetting = await this.db.get('settings', 'theme')
return themeSetting ? themeSetting.value : null
}
}
async function migrateNotes (db, transaction) {
const store = transaction.objectStore(NOTES_STORE)
for await (const cursor of store) {
const note = cursor.value
if (!note.timeline) {
note.timeline = ['all']
}
const isFollowing = await db.isActorFollowed(note.attributedTo)
if (isFollowing && !note.timeline.includes(TIMELINE_FOLLOWING)) {
note.timeline.push(TIMELINE_FOLLOWING)
}
cursor.update(note)
}
}
async function upgrade (db, oldVersion, newVersion, transaction) {
if (oldVersion < 1) {
const actors = db.createObjectStore(ACTORS_STORE, {
keyPath: 'id',
autoIncrement: false
})
actors.createIndex(CREATED_FIELD, CREATED_FIELD)
actors.createIndex(UPDATED_FIELD, UPDATED_FIELD)
actors.createIndex(URL_FIELD, URL_FIELD)
db.createObjectStore(FOLLOWED_ACTORS_STORE, {
keyPath: 'url'
})
const notes = db.createObjectStore(NOTES_STORE, {
keyPath: 'id',
autoIncrement: false
})
notes.createIndex(ATTRIBUTED_TO_FIELD, ATTRIBUTED_TO_FIELD, { unique: false })
notes.createIndex(IN_REPLY_TO_FIELD, IN_REPLY_TO_FIELD, { unique: false })
notes.createIndex(PUBLISHED_FIELD, PUBLISHED_FIELD, { unique: false })
addRegularIndex(notes, TO_FIELD)
addRegularIndex(notes, URL_FIELD)
addRegularIndex(notes, TAG_NAMES_FIELD, { multiEntry: true })
addSortedIndex(notes, IN_REPLY_TO_FIELD)
addSortedIndex(notes, ATTRIBUTED_TO_FIELD)
addSortedIndex(notes, CONVERSATION_FIELD)
addSortedIndex(notes, TO_FIELD)
const activities = db.createObjectStore(ACTIVITIES_STORE, {
keyPath: 'id',
autoIncrement: false
})
activities.createIndex(ACTOR_FIELD, ACTOR_FIELD)
addSortedIndex(activities, ACTOR_FIELD)
addSortedIndex(activities, TO_FIELD)
addRegularIndex(activities, PUBLISHED_FIELD)
db.createObjectStore('settings', { keyPath: 'key' })
}
if (oldVersion < 2) {
await migrateNotes(db, transaction)
}
if (oldVersion < 3) {
const notes = transaction.objectStore(NOTES_STORE)
notes.createIndex('timeline', 'timeline', { unique: false, multiEntry: true })
}
function addRegularIndex (store, field, options = {}) {
store.createIndex(field, field, options)
}
function addSortedIndex (store, field, options = {}) {
store.createIndex(field + ', published', [field, PUBLISHED_FIELD], options)
}
}
// TODO: prefer p2p alternate links when possible
async function getResponseLink (response) {
// For HTML responses, look for the link in the HTTP headers
const linkHeader = response.headers.get('Link')
if (linkHeader) {
const matches = linkHeader.match(
/<([^>]+)>;\s*rel="alternate";\s*type="application\/ld\+json"/
)
if (matches && matches[1]) {
// Found JSON-LD link in headers, fetch that URL
return matches[1]
}
}
// If no link header or alternate JSON-LD link is found, or response is HTML without JSON-LD link, process as HTML
const htmlContent = await response.text()
const jsonLdUrl = await parsePostHtml(htmlContent)
return jsonLdUrl
}
async function parsePostHtml (htmlContent) {
const parser = new DOMParser()
const doc = parser.parseFromString(htmlContent, 'text/html')
const alternateLinks = doc.querySelectorAll('link[rel="alternate"]')
console.log(...alternateLinks)
for (const link of alternateLinks) {
if (!link.type) continue
if (link.type.includes('application/ld+json') || link.type.includes('application/activity+json')) {
return link.href
}
}
return null
}
function isResponseHTML (response) {
return response.headers.get('content-type').includes('text/html')
}