mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
extract metadata ourselves
This commit is contained in:
parent
1c6b7800a8
commit
35004c474a
10 changed files with 79 additions and 70 deletions
|
|
@ -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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue