extract metadata ourselves

This commit is contained in:
Dhravya Shah 2026-01-23 17:39:23 -07:00
parent 1c6b7800a8
commit 35004c474a
10 changed files with 79 additions and 70 deletions

View file

@ -1,6 +1,4 @@
import ogs from "open-graph-scraper"
export const runtime = "nodejs"
export const runtime = "edge"
interface OGResponse {
title: string
@ -20,7 +18,6 @@ function isValidUrl(urlString: string): boolean {
function isPrivateHost(hostname: string): boolean {
const lowerHost = hostname.toLowerCase()
// Block localhost variants
if (
lowerHost === "localhost" ||
lowerHost === "127.0.0.1" ||
@ -31,7 +28,6 @@ function isPrivateHost(hostname: string): boolean {
return true
}
// Block RFC 1918 private IP ranges
const privateIpPatterns = [
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[01])\./,
@ -41,25 +37,20 @@ function isPrivateHost(hostname: string): boolean {
return privateIpPatterns.some((pattern) => pattern.test(hostname))
}
function extractImageUrl(image: unknown): string | undefined {
if (!image) return undefined
if (typeof image === "string") {
return image
}
if (Array.isArray(image) && image.length > 0) {
const first = image[0]
if (first && typeof first === "object" && "url" in first) {
return String(first.url)
function extractMetaTag(html: string, patterns: RegExp[]): string {
for (const pattern of patterns) {
const match = html.match(pattern)
if (match?.[1]) {
return match[1]
.replace(/&/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.trim()
}
}
if (typeof image === "object" && image !== null && "url" in image) {
return String(image.url)
}
return undefined
return ""
}
function resolveImageUrl(
@ -110,46 +101,68 @@ export async function GET(request: Request) {
)
}
const { result, error } = await ogs({
url: trimmedUrl,
timeout: 8000,
fetchOptions: {
headers: {
"User-Agent":
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
},
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 8000)
const response = await fetch(trimmedUrl, {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
},
})
if (error || !result) {
console.error("OG scraping error:", error)
clearTimeout(timeoutId)
if (!response.ok) {
return Response.json(
{ error: "Failed to fetch Open Graph data" },
{ status: 500 },
{ error: "Failed to fetch URL" },
{ status: response.status },
)
}
const ogTitle = result.ogTitle || result.twitterTitle || ""
const ogDescription =
result.ogDescription || result.twitterDescription || ""
const html = await response.text()
const ogImageUrl =
extractImageUrl(result.ogImage) || extractImageUrl(result.twitterImage)
const titlePatterns = [
/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i,
/<meta\s+content=["']([^"']+)["']\s+property=["']og:title["']/i,
/<meta\s+name=["']twitter:title["']\s+content=["']([^"']+)["']/i,
/<title>([^<]+)<\/title>/i,
]
const resolvedImageUrl = resolveImageUrl(ogImageUrl, trimmedUrl)
const descriptionPatterns = [
/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i,
/<meta\s+content=["']([^"']+)["']\s+property=["']og:description["']/i,
/<meta\s+name=["']twitter:description["']\s+content=["']([^"']+)["']/i,
/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i,
]
const response: OGResponse = {
title: ogTitle,
description: ogDescription,
const imagePatterns = [
/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i,
/<meta\s+content=["']([^"']+)["']\s+property=["']og:image["']/i,
/<meta\s+name=["']twitter:image["']\s+content=["']([^"']+)["']/i,
]
const title = extractMetaTag(html, titlePatterns)
const description = extractMetaTag(html, descriptionPatterns)
const imageUrl = extractMetaTag(html, imagePatterns)
const resolvedImageUrl = resolveImageUrl(imageUrl, trimmedUrl)
const ogResponse: OGResponse = {
title,
description,
...(resolvedImageUrl && { image: resolvedImageUrl }),
}
return Response.json(response, {
return Response.json(ogResponse, {
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
})
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return Response.json({ error: "Request timeout" }, { status: 504 })
}
console.error("OG route error:", error)
return Response.json({ error: "Internal server error" }, { status: 500 })
}

View file

@ -406,7 +406,9 @@ export function ChatSidebar({
whileTap={{ scale: 0.98 }}
>
<NovaOrb size={isMobile ? 26 : 24} className="blur-[0.6px]! z-10" />
<span className={cn(isMobile && "font-medium")}>Chat with Nova</span>
<span className={cn(isMobile && "font-medium")}>
Chat with Nova
</span>
</motion.button>
</motion.div>
) : (

View file

@ -86,7 +86,11 @@ export function FilePreview({ document }: { document: DocumentWithMemories }) {
) : (
<div className="p-3">
<div className="flex items-center gap-1 mb-2">
<DocumentIcon type={document.type} url={document.url} className="w-4 h-4" />
<DocumentIcon
type={document.type}
url={document.url}
className="w-4 h-4"
/>
<p
className={cn(dmSansClassName(), "text-[10px] font-semibold")}
style={{ color: color }}

View file

@ -22,7 +22,11 @@ export function GoogleDocsPreview({
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] gap-3">
<div className="flex items-center gap-2 mb-2">
<DocumentIcon type={document.type} url={document.url} className="w-4 h-4" />
<DocumentIcon
type={document.type}
url={document.url}
className="w-4 h-4"
/>
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
{label}
</p>

View file

@ -53,13 +53,7 @@ function getFaviconUrl(url: string): string {
}
}
function FaviconIcon({
url,
className,
}: {
url: string
className?: string
}) {
function FaviconIcon({ url, className }: { url: string; className?: string }) {
const [hasError, setHasError] = useState(false)
const faviconUrl = getFaviconUrl(url)

View file

@ -2,10 +2,7 @@
import { useState } from "react"
import { Loader2 } from "lucide-react"
import {
extractGoogleDocId,
getGoogleEmbedUrl,
} from "@/lib/url-helpers"
import { extractGoogleDocId, getGoogleEmbedUrl } from "@/lib/url-helpers"
interface GoogleDocViewerProps {
url: string | null | undefined

View file

@ -56,10 +56,7 @@ function getContentType(document: DocumentWithMemories | null): ContentType {
document.metadata?.mimeType?.toString().startsWith("image/")
if (isImage && document.url) return "image"
if (
document.type === "tweet" ||
(document.url && isTwitterUrl(document.url))
)
if (document.type === "tweet" || (document.url && isTwitterUrl(document.url)))
return "tweet"
if (document.type === "text") return "text"
if (document.type === "pdf") return "pdf"
@ -83,9 +80,7 @@ export function DocumentContent({
switch (contentType) {
case "image":
return (
<ImagePreview url={document.url ?? ""} title={document.title} />
)
return <ImagePreview url={document.url ?? ""} title={document.title} />
case "tweet":
return (

View file

@ -286,9 +286,7 @@ export function GraphListMemories({
type="button"
className={cn(
"text-xs text-[#525D6E] cursor-pointer transition-all text-left w-full",
expandedMemories.has(memory.id)
? ""
: "line-clamp-2",
expandedMemories.has(memory.id) ? "" : "line-clamp-2",
)}
onClick={() => toggleMemory(memory.id)}
>

View file

@ -1,7 +1,10 @@
import posthog from "posthog-js"
// Helper function to safely capture events
const safeCapture = (eventName: string, properties?: Record<string, unknown>) => {
const safeCapture = (
eventName: string,
properties?: Record<string, unknown>,
) => {
if (posthog.__loaded) {
posthog.capture(eventName, properties)
}

View file

@ -80,7 +80,6 @@
"next": "16.0.9",
"next-themes": "^0.4.6",
"nuqs": "^2.5.2",
"open-graph-scraper": "^6.11.0",
"pdfjs-dist": "5.4.296",
"posthog-js": "^1.257.0",
"random-word-slugs": "^0.1.7",