Merge pull request #27 from dergigi/fixes-after-midnight-as-always

feat: improve OpenGraph extraction with fetch-opengraph library
This commit is contained in:
Gigi
2025-10-25 01:28:06 +02:00
committed by GitHub
12 changed files with 313 additions and 52 deletions

60
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "boris", "name": "boris",
"version": "0.10.19", "version": "0.10.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "boris", "name": "boris",
"version": "0.10.19", "version": "0.10.23",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0",
@@ -23,6 +23,7 @@
"applesauce-relay": "^4.0.0", "applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fast-average-color": "^9.5.0", "fast-average-color": "^9.5.0",
"fetch-opengraph": "^1.0.36",
"nostr-tools": "^2.4.0", "nostr-tools": "^2.4.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^18.2.0", "react": "^18.2.0",
@@ -4502,6 +4503,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/babel-plugin-polyfill-corejs2": { "node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.14", "version": "0.4.14",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
@@ -6171,6 +6181,16 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fetch-opengraph": {
"version": "1.0.36",
"resolved": "https://registry.npmjs.org/fetch-opengraph/-/fetch-opengraph-1.0.36.tgz",
"integrity": "sha512-w2Gs64zjL1O86E0I6E26MrxeXpTrR8Y1vWrgupmZN6NXKV8F5I3W0tlh+ZX686jZwxyilWnQjYwgnWpdETdHWw==",
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"html-entities": "^2.3.2"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -6264,6 +6284,26 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -6896,6 +6936,22 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",

View File

@@ -26,6 +26,7 @@
"applesauce-relay": "^4.0.0", "applesauce-relay": "^4.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fast-average-color": "^9.5.0", "fast-average-color": "^9.5.0",
"fetch-opengraph": "^1.0.36",
"nostr-tools": "^2.4.0", "nostr-tools": "^2.4.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^18.2.0", "react": "^18.2.0",

View File

@@ -4,41 +4,40 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons' import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton' import IconButton from './IconButton'
import { fetchReadableContent } from '../services/readerService' import { fetchReadableContent } from '../services/readerService'
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
interface AddBookmarkModalProps { interface AddBookmarkModalProps {
onClose: () => void onClose: () => void
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void> onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
} }
// Helper to extract metadata from HTML // Helper to extract tags from OpenGraph data
function extractMetaTag(html: string, patterns: string[]): string | null { function extractTagsFromOgData(ogData: Record<string, unknown>): string[] {
for (const pattern of patterns) {
const match = html.match(new RegExp(pattern, 'i'))
if (match) return match[1]
}
return null
}
function extractTags(html: string): string[] {
const tags: string[] = [] const tags: string[] = []
// Extract keywords meta tag // Extract keywords from OpenGraph data
const keywords = extractMetaTag(html, [ if (ogData.keywords && typeof ogData.keywords === 'string') {
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']' ogData.keywords.split(/[,;]/)
]) .map((k: string) => k.trim().toLowerCase())
if (keywords) { .filter((k: string) => k.length > 0 && k.length < 30)
keywords.split(/[,;]/) .forEach((k: string) => tags.push(k))
.map(k => k.trim().toLowerCase())
.filter(k => k.length > 0 && k.length < 30)
.forEach(k => tags.push(k))
} }
// Extract article:tag (multiple possible) // Extract article:tag from OpenGraph data
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi if (ogData['article:tag']) {
let match const articleTagValue = ogData['article:tag']
while ((match = articleTagRegex.exec(html)) !== null) { const articleTags = Array.isArray(articleTagValue)
const tag = match[1].trim().toLowerCase() ? articleTagValue
if (tag && tag.length < 30) tags.push(tag) : [articleTagValue]
articleTags.forEach((tag: unknown) => {
if (typeof tag === 'string') {
const cleanTag = tag.trim().toLowerCase()
if (cleanTag && cleanTag.length < 30) {
tags.push(cleanTag)
}
}
})
} }
return Array.from(new Set(tags)).slice(0, 5) return Array.from(new Set(tags)).slice(0, 5)
@@ -83,17 +82,34 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
fetchTimeoutRef.current = window.setTimeout(async () => { fetchTimeoutRef.current = window.setTimeout(async () => {
setIsFetchingMetadata(true) setIsFetchingMetadata(true)
try { try {
const content = await fetchReadableContent(normalizedUrl) // Fetch both readable content and OpenGraph data in parallel
lastFetchedUrlRef.current = normalizedUrl const [content, ogData] = await Promise.all([
fetchReadableContent(normalizedUrl),
fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails
])
console.log('🔍 Modal fetch debug:', {
url: normalizedUrl,
hasContent: !!content,
hasOgData: !!ogData,
ogDataKeys: ogData ? Object.keys(ogData) : null
})
lastFetchedUrlRef.current = normalizedUrl
let extractedAnything = false let extractedAnything = false
// Extract title: prioritize og:title > twitter:title > <title> // Extract title: prioritize og:title > twitter:title > content.title
if (!title && content.html) { if (!title) {
const extractedTitle = extractMetaTag(content.html, [ let extractedTitle = null
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']' if (ogData) {
]) || content.title extractedTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title
}
// Fallback to content.title if no OpenGraph title found
if (!extractedTitle) {
extractedTitle = content.title
}
if (extractedTitle) { if (extractedTitle) {
setTitle(extractedTitle) setTitle(extractedTitle)
@@ -102,12 +118,15 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
} }
// Extract description: prioritize og:description > twitter:description > meta description // Extract description: prioritize og:description > twitter:description > meta description
if (!description && content.html) { if (!description && ogData) {
const extractedDesc = extractMetaTag(content.html, [ const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']', console.log('🔍 Description extraction debug:', {
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']' currentDescription: description,
]) hasOgData: !!ogData,
extractedDesc: extractedDesc,
willSetDescription: !!extractedDesc
})
if (extractedDesc) { if (extractedDesc) {
setDescription(extractedDesc) setDescription(extractedDesc)
@@ -116,8 +135,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
} }
// Extract tags from keywords and article:tag (only if user hasn't modified tags) // Extract tags from keywords and article:tag (only if user hasn't modified tags)
if (!tagsInput && content.html) { if (!tagsInput && ogData) {
const extractedTags = extractTags(content.html) const extractedTags = extractTagsFromOgData(ogData)
// Only add boris tag if we extracted something // Only add boris tag if we extracted something
if (extractedAnything || extractedTags.length > 0) { if (extractedAnything || extractedTags.length > 0) {

View File

@@ -14,6 +14,7 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus' import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync' import { useOfflineSync } from '../hooks/useOfflineSync'
import { useEventLoader } from '../hooks/useEventLoader' import { useEventLoader } from '../hooks/useEventLoader'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import { Bookmark } from '../types/bookmarks' import { Bookmark } from '../types/bookmarks'
import ThreePaneLayout from './ThreePaneLayout' import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore' import Explore from './Explore'
@@ -58,6 +59,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({
const showSupport = location.pathname === '/support' const showSupport = location.pathname === '/support'
const eventId = eventIdParam const eventId = eventIdParam
// Manage document title based on current route
const isViewingContent = !!(naddr || externalUrl || eventId)
useDocumentTitle({
title: isViewingContent ? undefined : 'Boris - Read, Highlight, Explore'
})
// Extract tab from explore routes // Extract tab from explore routes
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights' const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'

View File

@@ -280,8 +280,8 @@ const Me: React.FC<MeProps> = ({
try { try {
if (!hasBeenLoaded) setLoading(true) if (!hasBeenLoaded) setLoading(true)
// Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx) // Derive links from bookmarks with OpenGraph enhancement
const initialLinks = deriveLinksFromBookmarks(bookmarks) const initialLinks = await deriveLinksFromBookmarks(bookmarks)
const initialMap = new Map(initialLinks.map(item => [item.id, item])) const initialMap = new Map(initialLinks.map(item => [item.id, item]))
setLinksMap(initialMap) setLinksMap(initialMap)
setLinks(initialLinks) setLinks(initialLinks)

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, Dispatch, SetStateAction } from 'react' import { useEffect, useRef, useState, Dispatch, SetStateAction } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import type { IEventStore } from 'applesauce-core' import type { IEventStore } from 'applesauce-core'
@@ -12,6 +12,7 @@ import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
import { useDocumentTitle } from './useDocumentTitle'
interface PreviewData { interface PreviewData {
title: string title: string
@@ -64,6 +65,10 @@ export function useArticleLoader({
// Extract preview data from navigation state (from blog post cards) // Extract preview data from navigation state (from blog post cards)
const previewData = (location.state as { previewData?: PreviewData })?.previewData const previewData = (location.state as { previewData?: PreviewData })?.previewData
// Track the current article title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
useDocumentTitle({ title: currentTitle })
useEffect(() => { useEffect(() => {
mountedRef.current = true mountedRef.current = true
@@ -82,6 +87,7 @@ export function useArticleLoader({
// If we have preview data from navigation, show it immediately (no skeleton!) // If we have preview data from navigation, show it immediately (no skeleton!)
if (previewData) { if (previewData) {
setCurrentTitle(previewData.title)
setReaderContent({ setReaderContent({
title: previewData.title, title: previewData.title,
markdown: '', // Will be loaded from store or relay markdown: '', // Will be loaded from store or relay
@@ -121,6 +127,7 @@ export function useArticleLoader({
latestEvent = storedEvent as NostrEvent latestEvent = storedEvent as NostrEvent
firstEmitted = true firstEmitted = true
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article' const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(storedEvent) const image = Helpers.getArticleImage(storedEvent)
const summary = Helpers.getArticleSummary(storedEvent) const summary = Helpers.getArticleSummary(storedEvent)
const published = Helpers.getArticlePublished(storedEvent) const published = Helpers.getArticlePublished(storedEvent)
@@ -167,6 +174,7 @@ export function useArticleLoader({
if (!firstEmitted) { if (!firstEmitted) {
firstEmitted = true firstEmitted = true
const title = Helpers.getArticleTitle(evt) || 'Untitled Article' const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(evt) const image = Helpers.getArticleImage(evt)
const summary = Helpers.getArticleSummary(evt) const summary = Helpers.getArticleSummary(evt)
const published = Helpers.getArticlePublished(evt) const published = Helpers.getArticlePublished(evt)
@@ -194,6 +202,7 @@ export function useArticleLoader({
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
if (finalEvent) { if (finalEvent) {
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article' const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
setCurrentTitle(title)
const image = Helpers.getArticleImage(finalEvent) const image = Helpers.getArticleImage(finalEvent)
const summary = Helpers.getArticleSummary(finalEvent) const summary = Helpers.getArticleSummary(finalEvent)
const published = Helpers.getArticlePublished(finalEvent) const published = Helpers.getArticlePublished(finalEvent)
@@ -215,6 +224,7 @@ export function useArticleLoader({
// As a last resort, fall back to the legacy helper (which includes cache) // As a last resort, fall back to the legacy helper (which includes cache)
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current) const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
setCurrentTitle(article.title)
setReaderContent({ setReaderContent({
title: article.title, title: article.title,
markdown: article.markdown, markdown: article.markdown,

View File

@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react'
const DEFAULT_TITLE = 'Boris - Read, Highlight, Explore'
interface UseDocumentTitleProps {
title?: string
fallback?: string
}
export function useDocumentTitle({ title, fallback }: UseDocumentTitleProps) {
const originalTitleRef = useRef<string>(document.title)
useEffect(() => {
// Store the original title on first mount
if (originalTitleRef.current === DEFAULT_TITLE) {
originalTitleRef.current = document.title
}
// Set the new title if provided, otherwise use fallback or default
const newTitle = title || fallback || DEFAULT_TITLE
document.title = newTitle
// Cleanup: restore original title when component unmounts
return () => {
document.title = originalTitleRef.current
}
}, [title, fallback])
// Return a function to manually reset to default
const resetTitle = () => {
document.title = DEFAULT_TITLE
}
return { resetTitle }
}

View File

@@ -1,10 +1,11 @@
import { useEffect, useCallback } from 'react' import { useEffect, useCallback, useState } from 'react'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core' import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { ReadableContent } from '../services/readerService' import { ReadableContent } from '../services/readerService'
import { eventManager } from '../services/eventManager' import { eventManager } from '../services/eventManager'
import { fetchProfiles } from '../services/profileService' import { fetchProfiles } from '../services/profileService'
import { useDocumentTitle } from './useDocumentTitle'
interface UseEventLoaderProps { interface UseEventLoaderProps {
eventId?: string eventId?: string
@@ -25,6 +26,9 @@ export function useEventLoader({
setReaderLoading, setReaderLoading,
setIsCollapsed setIsCollapsed
}: UseEventLoaderProps) { }: UseEventLoaderProps) {
// Track the current event title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
useDocumentTitle({ title: currentTitle })
const displayEvent = useCallback((event: NostrEvent) => { const displayEvent = useCallback((event: NostrEvent) => {
// Escape HTML in content and convert newlines to breaks for plain text display // Escape HTML in content and convert newlines to breaks for plain text display
const escapedContent = event.content const escapedContent = event.content
@@ -46,6 +50,7 @@ export function useEventLoader({
title, title,
published: event.created_at published: event.created_at
} }
setCurrentTitle(title)
setReaderContent(baseContent) setReaderContent(baseContent)
// Background: resolve author profile for kind:1 and update title // Background: resolve author profile for kind:1 and update title
@@ -80,7 +85,9 @@ export function useEventLoader({
} }
if (resolved) { if (resolved) {
setReaderContent({ ...baseContent, title: `Note by @${resolved}` }) const updatedTitle = `Note by @${resolved}`
setCurrentTitle(updatedTitle)
setReaderContent({ ...baseContent, title: updatedTitle })
} }
} catch { } catch {
// ignore profile failures; keep fallback title // ignore profile failures; keep fallback title
@@ -119,6 +126,7 @@ export function useEventLoader({
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`, html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
title: 'Error' title: 'Error'
} }
setCurrentTitle('Error')
setReaderContent(errorContent) setReaderContent(errorContent)
setReaderLoading(false) setReaderLoading(false)
} }

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useMemo } from 'react' import { useEffect, useRef, useMemo, useState } from 'react'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core' import { IEventStore } from 'applesauce-core'
import { fetchReadableContent, ReadableContent } from '../services/readerService' import { fetchReadableContent, ReadableContent } from '../services/readerService'
@@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights'
import { useStoreTimeline } from './useStoreTimeline' import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor' import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds' import { KINDS } from '../config/kinds'
import { useDocumentTitle } from './useDocumentTitle'
// Helper to extract filename from URL // Helper to extract filename from URL
function getFilenameFromUrl(url: string): string { function getFilenameFromUrl(url: string): string {
@@ -52,6 +53,10 @@ export function useExternalUrlLoader({
// Track in-flight request to prevent stale updates when switching quickly // Track in-flight request to prevent stale updates when switching quickly
const currentRequestIdRef = useRef(0) const currentRequestIdRef = useRef(0)
// Track the current content title for document title
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
useDocumentTitle({ title: currentTitle })
// Load cached URL-specific highlights from event store // Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => { const urlFilter = useMemo(() => {
if (!url) return null if (!url) return null
@@ -88,6 +93,7 @@ export function useExternalUrlLoader({
if (!mountedRef.current) return if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return if (currentRequestIdRef.current !== requestId) return
setCurrentTitle(content.title)
setReaderContent(content) setReaderContent(content)
setReaderLoading(false) setReaderLoading(false)

View File

@@ -0,0 +1,114 @@
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
import { ReadItem } from './readsService'
// Cache for OpenGraph data to avoid repeated requests
const ogCache = new Map<string, Record<string, unknown>>()
function getCachedOgData(url: string): Record<string, unknown> | null {
const cached = ogCache.get(url)
if (!cached) return null
return cached
}
function setCachedOgData(url: string, data: Record<string, unknown>): void {
ogCache.set(url, data)
}
/**
* Enhances a ReadItem with OpenGraph data
* Only fetches if the item doesn't already have good metadata
*/
export async function enhanceReadItemWithOpenGraph(item: ReadItem): Promise<ReadItem> {
// Skip if we already have good metadata
if (item.title && item.title !== fallbackTitleFromUrl(item.url || '') && item.image) {
return item
}
if (!item.url) return item
try {
// Check cache first
let ogData = getCachedOgData(item.url)
if (!ogData) {
// Fetch OpenGraph data
const fetchedOgData = await fetchOpenGraph(item.url)
if (fetchedOgData) {
ogData = fetchedOgData
setCachedOgData(item.url, fetchedOgData)
}
}
if (!ogData) return item
// Enhance the item with OpenGraph data
const enhanced: ReadItem = { ...item }
// Use OpenGraph title if we don't have a good title
if (!enhanced.title || enhanced.title === fallbackTitleFromUrl(item.url)) {
const ogTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title
if (typeof ogTitle === 'string') {
enhanced.title = ogTitle
}
}
// Use OpenGraph description if we don't have a summary
if (!enhanced.summary) {
const ogDescription = ogData['og:description'] || ogData['twitter:description'] || ogData.description
if (typeof ogDescription === 'string') {
enhanced.summary = ogDescription
}
}
// Use OpenGraph image if we don't have an image
if (!enhanced.image) {
const ogImage = ogData['og:image'] || ogData['twitter:image'] || ogData.image
if (typeof ogImage === 'string') {
enhanced.image = ogImage
}
}
return enhanced
} catch (error) {
console.warn('Failed to enhance ReadItem with OpenGraph data:', error)
return item
}
}
/**
* Enhances multiple ReadItems with OpenGraph data in parallel
* Uses batching to avoid overwhelming the service
*/
export async function enhanceReadItemsWithOpenGraph(items: ReadItem[]): Promise<ReadItem[]> {
const BATCH_SIZE = 5
const BATCH_DELAY = 1000 // 1 second between batches
const enhancedItems: ReadItem[] = []
for (let i = 0; i < items.length; i += BATCH_SIZE) {
const batch = items.slice(i, i + BATCH_SIZE)
// Process batch in parallel
const batchPromises = batch.map(item => enhanceReadItemWithOpenGraph(item))
const batchResults = await Promise.all(batchPromises)
enhancedItems.push(...batchResults)
// Add delay between batches to be respectful to the service
if (i + BATCH_SIZE < items.length) {
await new Promise(resolve => setTimeout(resolve, BATCH_DELAY))
}
}
return enhancedItems
}
// Helper function to generate fallback title from URL
function fallbackTitleFromUrl(url: string): string {
try {
const urlObj = new URL(url)
return urlObj.hostname.replace('www.', '')
} catch {
return url
}
}

View File

@@ -110,3 +110,4 @@ export async function fetchReadableContent(
} }

View File

@@ -2,13 +2,14 @@ import { Bookmark } from '../types/bookmarks'
import { ReadItem } from '../services/readsService' import { ReadItem } from '../services/readsService'
import { KINDS } from '../config/kinds' import { KINDS } from '../config/kinds'
import { fallbackTitleFromUrl } from './readItemMerge' import { fallbackTitleFromUrl } from './readItemMerge'
import { enhanceReadItemsWithOpenGraph } from '../services/opengraphEnhancer'
/** /**
* Derives ReadItems from bookmarks for external URLs: * Derives ReadItems from bookmarks for external URLs:
* - Web bookmarks (kind:39701) * - Web bookmarks (kind:39701)
* - Any bookmark with http(s) URLs in content or urlReferences * - Any bookmark with http(s) URLs in content or urlReferences
*/ */
export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { export async function deriveLinksFromBookmarks(bookmarks: Bookmark[]): Promise<ReadItem[]> {
const linksMap = new Map<string, ReadItem>() const linksMap = new Map<string, ReadItem>()
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
@@ -59,11 +60,14 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
} }
} }
// Sort by most recent bookmark activity // Get initial items sorted by most recent bookmark activity
return Array.from(linksMap.values()).sort((a, b) => { const initialItems = Array.from(linksMap.values()).sort((a, b) => {
const timeA = a.readingTimestamp || 0 const timeA = a.readingTimestamp || 0
const timeB = b.readingTimestamp || 0 const timeB = b.readingTimestamp || 0
return timeB - timeA return timeB - timeA
}) })
// Enhance with OpenGraph data
return await enhanceReadItemsWithOpenGraph(initialItems)
} }