349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import { NextResponse } from 'next/server'
|
||
import { auth } from '@/lib/auth'
|
||
|
||
export const runtime = 'edge'
|
||
|
||
interface WebsiteMetadata {
|
||
title: string
|
||
description: string
|
||
icon: string
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
try {
|
||
const session = await auth()
|
||
if (!session?.user?.accessToken) {
|
||
return new Response('Unauthorized', { status: 401 })
|
||
}
|
||
|
||
const { url } = await request.json()
|
||
|
||
if (!url || !isValidUrl(url)) {
|
||
return NextResponse.json({ error: '请提供有效的网站链接' }, { status: 400 })
|
||
}
|
||
|
||
const metadata = await fetchWebsiteMetadata(url)
|
||
|
||
// 确保 metadata 对象包含所有必需的属性
|
||
if (!metadata || typeof metadata !== 'object') {
|
||
throw new Error('Failed to fetch valid metadata')
|
||
}
|
||
|
||
// 如果获取到了 favicon,下载并上传到 GitHub
|
||
if (metadata.icon) {
|
||
try {
|
||
const iconUrl = await downloadAndUploadIcon(metadata.icon, session.user.accessToken)
|
||
metadata.icon = iconUrl
|
||
|
||
} catch (error) {
|
||
console.warn('Failed to download icon:', error)
|
||
// 如果图标下载失败,尝试使用 Google favicon 服务
|
||
try {
|
||
const domain = new URL(url).hostname
|
||
const fallbackIconUrl = await downloadGoogleFavicon(domain, session.user.accessToken)
|
||
metadata.icon = fallbackIconUrl
|
||
} catch (fallbackError) {
|
||
console.warn('Failed to download Google favicon:', fallbackError)
|
||
// 保持原始 URL
|
||
}
|
||
}
|
||
}
|
||
|
||
return NextResponse.json(metadata)
|
||
} catch (error) {
|
||
console.error('Failed to fetch website metadata:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : '获取网站信息失败' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|
||
function isValidUrl(string: string): boolean {
|
||
try {
|
||
new URL(string)
|
||
return true
|
||
} catch (_) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function fetchWebsiteMetadata(url: string): Promise<WebsiteMetadata> {
|
||
try {
|
||
const headers = {
|
||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||
'Accept-Encoding': 'gzip, deflate, br, zstd',
|
||
'Cache-Control': 'max-age=0',
|
||
'Sec-Ch-Ua': '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
|
||
'Sec-Ch-Ua-Mobile': '?0',
|
||
'Sec-Ch-Ua-Platform': '"macOS"',
|
||
'Sec-Fetch-Dest': 'document',
|
||
'Sec-Fetch-Mode': 'navigate',
|
||
'Sec-Fetch-Site': 'none',
|
||
'Sec-Fetch-User': '?1',
|
||
'Upgrade-Insecure-Requests': '1'
|
||
}
|
||
|
||
const response = await fetch(url, {
|
||
headers: headers,
|
||
redirect: 'follow',
|
||
signal: AbortSignal.timeout(1500)
|
||
})
|
||
|
||
if (response.ok) {
|
||
const html = await response.text()
|
||
return parseMetadataFromHtml(html, url)
|
||
} else if (response.status === 403) {
|
||
console.warn(`网站拒绝访问 (403): 该网站可能阻止了自动化访问`)
|
||
return getFallbackMetadata(url)
|
||
} else if (response.status === 404) {
|
||
return getFallbackMetadata(url)
|
||
} else if (response.status >= 500) {
|
||
return getFallbackMetadata(url)
|
||
} else {
|
||
console.warn(`无法访问网站: ${response.status}`)
|
||
return getFallbackMetadata(url)
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||
console.warn('请求超时,网站响应过慢')
|
||
} else {
|
||
console.warn('获取网站元数据失败:', error)
|
||
}
|
||
return getFallbackMetadata(url)
|
||
}
|
||
}
|
||
|
||
function getFallbackMetadata(url: string): WebsiteMetadata {
|
||
try {
|
||
const urlObj = new URL(url)
|
||
const hostname = urlObj.hostname
|
||
|
||
// 生成基本的网站信息
|
||
const title = hostname.replace(/^www\./, '').split('.')[0]
|
||
const capitalizedTitle = title.charAt(0).toUpperCase() + title.slice(1)
|
||
|
||
return {
|
||
title: capitalizedTitle,
|
||
description: `访问 ${hostname}`,
|
||
icon: `https://www.google.com/s2/favicons?sz=128&domain=${hostname}`
|
||
}
|
||
} catch {
|
||
return {
|
||
title: '未知网站',
|
||
description: '无法获取网站信息',
|
||
icon: ''
|
||
}
|
||
}
|
||
}
|
||
|
||
function parseMetadataFromHtml(html: string, url: string): WebsiteMetadata {
|
||
|
||
// 解析 HTML 获取元数据
|
||
const title = extractMetaContent(html, 'title') ||
|
||
extractMetaContent(html, 'og:title') ||
|
||
extractMetaContent(html, 'twitter:title') ||
|
||
new URL(url).hostname
|
||
|
||
const description = extractMetaContent(html, 'description') ||
|
||
extractMetaContent(html, 'og:description') ||
|
||
extractMetaContent(html, 'twitter:description') ||
|
||
''
|
||
|
||
// 获取 favicon
|
||
let icon = extractFavicon(html, url)
|
||
|
||
return {
|
||
title: title.trim(),
|
||
description: description.trim(),
|
||
icon: icon || ''
|
||
}
|
||
}
|
||
|
||
function extractMetaContent(html: string, name: string): string | null {
|
||
// 匹配 title 标签
|
||
if (name === 'title') {
|
||
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i)
|
||
return titleMatch ? titleMatch[1] : null
|
||
}
|
||
|
||
// 匹配 meta 标签
|
||
const patterns = [
|
||
new RegExp(`<meta[^>]*name=["']${name}["'][^>]*content=["']([^"']*)["']`, 'i'),
|
||
new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*name=["']${name}["']`, 'i'),
|
||
new RegExp(`<meta[^>]*property=["']${name}["'][^>]*content=["']([^"']*)["']`, 'i'),
|
||
new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']${name}["']`, 'i')
|
||
]
|
||
|
||
for (const pattern of patterns) {
|
||
const match = html.match(pattern)
|
||
if (match) {
|
||
return match[1]
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function extractFavicon(html: string, baseUrl: string): string | null {
|
||
const base = new URL(baseUrl)
|
||
|
||
// 尝试从 HTML 中提取 favicon
|
||
const faviconPatterns = [
|
||
/<link[^>]*rel=["']icon["'][^>]*href=["']([^"']*)["']/i,
|
||
/<link[^>]*href=["']([^"']*)["'][^>]*rel=["']icon["']/i,
|
||
/<link[^>]*rel=["']shortcut icon["'][^>]*href=["']([^"']*)["']/i,
|
||
/<link[^>]*href=["']([^"']*)["'][^>]*rel=["']shortcut icon["']/i,
|
||
/<link[^>]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']*)["']/i,
|
||
/<link[^>]*href=["']([^"']*)["'][^>]*rel=["']apple-touch-icon["']/i
|
||
]
|
||
|
||
for (const pattern of faviconPatterns) {
|
||
const match = html.match(pattern)
|
||
if (match) {
|
||
const href = match[1]
|
||
if (href.startsWith('http')) {
|
||
return href
|
||
} else if (href.startsWith('//')) {
|
||
return base.protocol + href
|
||
} else if (href.startsWith('/')) {
|
||
return base.origin + href
|
||
} else {
|
||
return base.origin + '/' + href
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果没有找到,使用 Google 的 favicon 服务作为备用
|
||
return `https://www.google.com/s2/favicons?sz=128&domain=${base.hostname}`
|
||
}
|
||
|
||
async function downloadGoogleFavicon(domain: string, token: string): Promise<string> {
|
||
const googleFaviconUrl = `https://www.google.com/s2/favicons?sz=128&domain=${domain}`
|
||
|
||
try {
|
||
const response = await fetch(googleFaviconUrl, {
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||
'Accept': 'image/*,*/*'
|
||
},
|
||
signal: AbortSignal.timeout(10000)
|
||
})
|
||
|
||
if (response.ok) {
|
||
const arrayBuffer = await response.arrayBuffer()
|
||
const binaryData = new Uint8Array(arrayBuffer)
|
||
const { path } = await uploadImageToGitHub(binaryData, token, 'png')
|
||
return path
|
||
} else {
|
||
throw new Error(`Failed to download Google favicon: ${response.status}`)
|
||
}
|
||
} catch (error) {
|
||
throw new Error(`Google favicon download failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||
}
|
||
}
|
||
|
||
async function downloadAndUploadIcon(iconUrl: string, token: string): Promise<string> {
|
||
// 多种策略尝试下载favicon
|
||
const strategies: Array<{ headers: HeadersInit; delay?: number }> = [
|
||
// 策略1: 完整浏览器模拟
|
||
{
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||
'Accept-Encoding': 'gzip, deflate, br, zstd',
|
||
'Referer': new URL(iconUrl).origin + '/',
|
||
'Sec-Ch-Ua': '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
|
||
'Sec-Ch-Ua-Mobile': '?0',
|
||
'Sec-Ch-Ua-Platform': '"macOS"',
|
||
'Sec-Fetch-Dest': 'image',
|
||
'Sec-Fetch-Mode': 'no-cors',
|
||
'Sec-Fetch-Site': 'same-origin'
|
||
},
|
||
delay: 1000
|
||
}
|
||
]
|
||
|
||
let lastError: Error | null = null
|
||
|
||
for (const strategy of strategies) {
|
||
try {
|
||
const response = await fetch(iconUrl, {
|
||
headers: strategy.headers,
|
||
redirect: 'follow',
|
||
signal: AbortSignal.timeout(15000)
|
||
})
|
||
|
||
if (response.ok) {
|
||
const arrayBuffer = await response.arrayBuffer()
|
||
const binaryData = new Uint8Array(arrayBuffer)
|
||
|
||
// 上传到 GitHub
|
||
const { path } = await uploadImageToGitHub(binaryData, token, getFileExtension(iconUrl))
|
||
return path
|
||
} else {
|
||
lastError = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||
console.warn(`Strategy failed with status ${response.status}, trying next strategy...`)
|
||
}
|
||
} catch (error) {
|
||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||
console.warn(`Strategy failed with error:`, error)
|
||
}
|
||
}
|
||
|
||
// 如果所有策略都失败了,抛出最后一个错误
|
||
throw lastError || new Error('All download strategies failed')
|
||
}
|
||
|
||
function getFileExtension(url: string): string {
|
||
try {
|
||
const pathname = new URL(url).pathname
|
||
const extension = pathname.split('.').pop()?.toLowerCase()
|
||
|
||
if (extension && ['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico'].includes(extension)) {
|
||
return extension
|
||
}
|
||
return 'png' // 默认扩展名
|
||
} catch {
|
||
return 'png'
|
||
}
|
||
}
|
||
|
||
async function uploadImageToGitHub(binaryData: Uint8Array, token: string, extension: string = 'png'): Promise<{ path: string, commitHash: string }> {
|
||
const owner = process.env.GITHUB_OWNER!
|
||
const repo = process.env.GITHUB_REPO!
|
||
const branch = process.env.GITHUB_BRANCH || 'main'
|
||
const path = `/assets/favicon_${Date.now()}.${extension}`
|
||
const githubPath = 'public' + path
|
||
|
||
// Convert Uint8Array to Base64
|
||
const base64String = Buffer.from(binaryData).toString('base64')
|
||
const currentFileUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${githubPath}?ref=${branch}`
|
||
|
||
const response = await fetch(currentFileUrl, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `token ${token}`,
|
||
'Accept': 'application/vnd.github.v3+json',
|
||
},
|
||
body: JSON.stringify({
|
||
message: `Upload favicon ${githubPath}`,
|
||
content: base64String,
|
||
branch: branch,
|
||
}),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json()
|
||
console.error('Failed to upload image to GitHub:', errorData)
|
||
throw new Error(`Failed to upload image to GitHub: ${errorData.message || 'Unknown error'}`)
|
||
}
|
||
|
||
const responseData = await response.json()
|
||
const commitHash = responseData.commit.sha
|
||
|
||
return { path, commitHash }
|
||
} |