fix: P0 SEO 修复(sitemap 动态化 / canonical / OG 图 / JSON-LD)
- sitemap 加入全部 published 工具详情页、分类页、博客文章(双语),每小时 revalidate - metadata.ts 新增 absoluteUrl 助手:canonical/hreflang 与 localePrefix 'as-needed' 对齐,英文页不再指向 301;x-default 修正为默认语言 en - 生成缺失的 public/og-image.png、icon-192.png、icon-512.png - 工具详情页、工具列表页、博客文章页补完整 metadata(canonical/hreflang/OG) - JSON-LD 从 next/script afterInteractive 改为原生 script 标签,进入初始 HTML Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7fd9338f80
commit
f3088e996f
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import { getAllBlogs, getBlogModule } from "@/lib/blog";
|
||||
import { locales, type Locale } from "@/i18n.config";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
@@ -32,18 +33,15 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
}
|
||||
|
||||
const { blog } = blogModule;
|
||||
const metadata: Metadata = {
|
||||
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: `/blog/${slug}`,
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
};
|
||||
|
||||
if (blog.image) {
|
||||
metadata.openGraph = {
|
||||
images: [blog.image],
|
||||
};
|
||||
}
|
||||
|
||||
return metadata;
|
||||
ogImage: blog.image,
|
||||
ogType: "article",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ExternalLink, ArrowLeft, Tag } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { SoftwareApplicationJsonLd } from "@/components/json-ld";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
|
||||
interface ToolDetailPageProps {
|
||||
params: Promise<{
|
||||
@@ -30,10 +31,13 @@ export async function generateMetadata({ params }: ToolDetailPageProps) {
|
||||
? tool.descriptionZh
|
||||
: tool.descriptionEn;
|
||||
|
||||
return {
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: `/tools/${slug}`,
|
||||
title: `${name} - ${t("title")}`,
|
||||
description: description || `${t("subtitle")} - ${name}`,
|
||||
};
|
||||
ogImage: tool.coverImageUrl || tool.logoUrl || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
|
||||
@@ -2,13 +2,21 @@ import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/acti
|
||||
import { ToolsList } from "@/features/tools/components/tools-list";
|
||||
import { CollectionPageJsonLd } from "@/components/json-ld";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("seo.tools");
|
||||
return {
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "seo.tools" });
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: "/tools",
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface ToolsPageProps {
|
||||
|
||||
+52
-18
@@ -1,12 +1,19 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai'
|
||||
// 每小时重新生成,保证新收录的工具能进入 sitemap
|
||||
export const revalidate = 3600
|
||||
import { db } from '@/lib/db'
|
||||
import { tool, category } from '@/lib/db/schema'
|
||||
import { getAllBlogs } from '@/lib/blog'
|
||||
import { locales } from '@/i18n.config'
|
||||
import { absoluteUrl } from '@/lib/metadata'
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// Static routes with SEO priority for AI tools directory
|
||||
const staticRoutes = [
|
||||
{ path: '', changeFreq: 'daily' as const, priority: 1.0 },
|
||||
{ path: '/tools', changeFreq: 'daily' as const, priority: 0.95 }, // AI tools directory - high priority
|
||||
{ path: '/tools', changeFreq: 'daily' as const, priority: 0.95 },
|
||||
{ path: '/blog', changeFreq: 'weekly' as const, priority: 0.8 },
|
||||
{ path: '/contact', changeFreq: 'monthly' as const, priority: 0.5 },
|
||||
{ path: '/privacy', changeFreq: 'monthly' as const, priority: 0.3 },
|
||||
@@ -14,26 +21,53 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
{ path: '/cookies', changeFreq: 'monthly' as const, priority: 0.3 },
|
||||
]
|
||||
|
||||
// Generate sitemap entries for both languages
|
||||
const locales = ['zh', 'en']
|
||||
const sitemapEntries: MetadataRoute.Sitemap = []
|
||||
const [tools, categories, blogs] = await Promise.all([
|
||||
db
|
||||
.select({ slug: tool.slug, updatedAt: tool.updatedAt })
|
||||
.from(tool)
|
||||
.where(eq(tool.status, 'published')),
|
||||
db.select({ slug: category.slug, updatedAt: category.updatedAt }).from(category),
|
||||
getAllBlogs(),
|
||||
])
|
||||
|
||||
locales.forEach((locale) => {
|
||||
staticRoutes.forEach((route) => {
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}${route.path}`,
|
||||
lastModified: new Date(),
|
||||
const dynamicRoutes = [
|
||||
...tools.map((t) => ({
|
||||
path: `/tools/${t.slug}`,
|
||||
changeFreq: 'weekly' as const,
|
||||
priority: 0.9,
|
||||
lastModified: t.updatedAt,
|
||||
})),
|
||||
...categories.map((c) => ({
|
||||
path: `/category/${c.slug}`,
|
||||
changeFreq: 'daily' as const,
|
||||
priority: 0.85,
|
||||
lastModified: c.updatedAt,
|
||||
})),
|
||||
...blogs.map((b) => ({
|
||||
path: `/blog/${b.slug}`,
|
||||
changeFreq: 'monthly' as const,
|
||||
priority: 0.7,
|
||||
lastModified: b.date ? new Date(b.date) : undefined,
|
||||
})),
|
||||
]
|
||||
|
||||
const allRoutes = [
|
||||
...staticRoutes.map((r) => ({ ...r, lastModified: undefined as Date | undefined })),
|
||||
...dynamicRoutes,
|
||||
]
|
||||
|
||||
return locales.flatMap((locale) =>
|
||||
allRoutes.map((route) => ({
|
||||
url: absoluteUrl(locale, route.path),
|
||||
lastModified: route.lastModified ?? new Date(),
|
||||
changeFrequency: route.changeFreq,
|
||||
priority: route.priority,
|
||||
alternates: {
|
||||
languages: {
|
||||
zh: `${baseUrl}/zh${route.path}`,
|
||||
en: `${baseUrl}/en${route.path}`,
|
||||
en: absoluteUrl('en', route.path),
|
||||
zh: absoluteUrl('zh', route.path),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return sitemapEntries
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Script from 'next/script'
|
||||
|
||||
interface WebSiteJsonLdProps {
|
||||
name: string
|
||||
@@ -30,10 +29,8 @@ export function WebSiteJsonLd({ name, description, url, locale }: WebSiteJsonLdP
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
id="website-jsonld"
|
||||
<script
|
||||
type="application/ld+json"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)
|
||||
@@ -60,10 +57,8 @@ export function OrganizationJsonLd({ name, url, logo, sameAs = [] }: Organizatio
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
id="organization-jsonld"
|
||||
<script
|
||||
type="application/ld+json"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)
|
||||
@@ -103,10 +98,8 @@ export function SoftwareApplicationJsonLd({
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
id="software-jsonld"
|
||||
<script
|
||||
type="application/ld+json"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)
|
||||
@@ -143,10 +136,8 @@ export function CollectionPageJsonLd({
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
id="collection-jsonld"
|
||||
<script
|
||||
type="application/ld+json"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)
|
||||
|
||||
+27
-13
@@ -1,13 +1,33 @@
|
||||
import { Metadata } from 'next'
|
||||
import { defaultLocale } from '@/i18n.config'
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
/**
|
||||
* 构建与 localePrefix: 'as-needed' 一致的绝对 URL:
|
||||
* 默认语言 (en) 不带前缀,其他语言带 /{locale} 前缀。
|
||||
* canonical / hreflang / sitemap 必须统一走这里,避免指向 301 重定向。
|
||||
*/
|
||||
export function absoluteUrl(locale: string, path: string): string {
|
||||
const prefix = locale === defaultLocale ? '' : `/${locale}`
|
||||
return `${baseUrl}${prefix}${path}` || baseUrl
|
||||
}
|
||||
|
||||
export function languageAlternates(path: string) {
|
||||
return {
|
||||
'en-US': absoluteUrl('en', path),
|
||||
'zh-CN': absoluteUrl('zh', path),
|
||||
'x-default': absoluteUrl(defaultLocale, path),
|
||||
}
|
||||
}
|
||||
|
||||
interface GenerateMetadataProps {
|
||||
locale: string
|
||||
path: string
|
||||
title: string
|
||||
description: string
|
||||
ogImage?: string
|
||||
ogType?: 'website' | 'article'
|
||||
}
|
||||
|
||||
export function generatePageMetadata({
|
||||
@@ -16,23 +36,17 @@ export function generatePageMetadata({
|
||||
title,
|
||||
description,
|
||||
ogImage = `${baseUrl}/og-image.png`,
|
||||
ogType = 'website',
|
||||
}: GenerateMetadataProps): Metadata {
|
||||
const canonicalUrl = `${baseUrl}/${locale}${path}`
|
||||
|
||||
// Generate alternate language URLs
|
||||
const alternates = {
|
||||
canonical: canonicalUrl,
|
||||
languages: {
|
||||
'zh-CN': `${baseUrl}/zh${path}`,
|
||||
'en-US': `${baseUrl}/en${path}`,
|
||||
'x-default': `${baseUrl}/zh${path}`, // Default to Chinese
|
||||
},
|
||||
}
|
||||
const canonicalUrl = absoluteUrl(locale, path)
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates,
|
||||
alternates: {
|
||||
canonical: canonicalUrl,
|
||||
languages: languageAlternates(path),
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
@@ -47,7 +61,7 @@ export function generatePageMetadata({
|
||||
},
|
||||
],
|
||||
locale: locale === 'zh' ? 'zh_CN' : 'en_US',
|
||||
type: 'website',
|
||||
type: ogType,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
|
||||
Reference in New Issue
Block a user