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:
CoolChange.AI
2026-07-31 20:35:17 -07:00
co-authored by Claude Fable 5
parent 7fd9338f80
commit f3088e996f
9 changed files with 114 additions and 65 deletions
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 { getAllBlogs, getBlogModule } from "@/lib/blog";
import { locales, type Locale } from "@/i18n.config"; import { locales, type Locale } from "@/i18n.config";
import { generatePageMetadata } from "@/lib/metadata";
interface PageProps { interface PageProps {
params: Promise<{ params: Promise<{
@@ -32,18 +33,15 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
} }
const { blog } = blogModule; const { blog } = blogModule;
const metadata: Metadata = {
return generatePageMetadata({
locale,
path: `/blog/${slug}`,
title: blog.title, title: blog.title,
description: blog.description, description: blog.description,
}; ogImage: blog.image,
ogType: "article",
if (blog.image) { });
metadata.openGraph = {
images: [blog.image],
};
}
return metadata;
} }
export default async function BlogPostPage({ params }: PageProps) { 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 { ExternalLink, ArrowLeft, Tag } from "lucide-react";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { SoftwareApplicationJsonLd } from "@/components/json-ld"; import { SoftwareApplicationJsonLd } from "@/components/json-ld";
import { generatePageMetadata } from "@/lib/metadata";
interface ToolDetailPageProps { interface ToolDetailPageProps {
params: Promise<{ params: Promise<{
@@ -30,10 +31,13 @@ export async function generateMetadata({ params }: ToolDetailPageProps) {
? tool.descriptionZh ? tool.descriptionZh
: tool.descriptionEn; : tool.descriptionEn;
return { return generatePageMetadata({
locale,
path: `/tools/${slug}`,
title: `${name} - ${t("title")}`, title: `${name} - ${t("title")}`,
description: description || `${t("subtitle")} - ${name}`, description: description || `${t("subtitle")} - ${name}`,
}; ogImage: tool.coverImageUrl || tool.logoUrl || undefined,
});
} }
export default async function ToolDetailPage({ params }: ToolDetailPageProps) { export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
+12 -4
View File
@@ -2,13 +2,21 @@ import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/acti
import { ToolsList } from "@/features/tools/components/tools-list"; import { ToolsList } from "@/features/tools/components/tools-list";
import { CollectionPageJsonLd } from "@/components/json-ld"; import { CollectionPageJsonLd } from "@/components/json-ld";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata";
export async function generateMetadata() { export async function generateMetadata({
const t = await getTranslations("seo.tools"); params,
return { }: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "seo.tools" });
return generatePageMetadata({
locale,
path: "/tools",
title: t("title"), title: t("title"),
description: t("description"), description: t("description"),
}; });
} }
interface ToolsPageProps { interface ToolsPageProps {
+57 -23
View File
@@ -1,12 +1,19 @@
import { MetadataRoute } from 'next' import { MetadataRoute } from 'next'
import { eq } from 'drizzle-orm'
export default function sitemap(): MetadataRoute.Sitemap { // 每小时重新生成,保证新收录的工具能进入 sitemap
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai' 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 // Static routes with SEO priority for AI tools directory
const staticRoutes = [ const staticRoutes = [
{ path: '', changeFreq: 'daily' as const, priority: 1.0 }, { 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: '/blog', changeFreq: 'weekly' as const, priority: 0.8 },
{ path: '/contact', changeFreq: 'monthly' as const, priority: 0.5 }, { path: '/contact', changeFreq: 'monthly' as const, priority: 0.5 },
{ path: '/privacy', changeFreq: 'monthly' as const, priority: 0.3 }, { 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 }, { path: '/cookies', changeFreq: 'monthly' as const, priority: 0.3 },
] ]
// Generate sitemap entries for both languages const [tools, categories, blogs] = await Promise.all([
const locales = ['zh', 'en'] db
const sitemapEntries: MetadataRoute.Sitemap = [] .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) => { const dynamicRoutes = [
staticRoutes.forEach((route) => { ...tools.map((t) => ({
sitemapEntries.push({ path: `/tools/${t.slug}`,
url: `${baseUrl}/${locale}${route.path}`, changeFreq: 'weekly' as const,
lastModified: new Date(), priority: 0.9,
changeFrequency: route.changeFreq, lastModified: t.updatedAt,
priority: route.priority, })),
alternates: { ...categories.map((c) => ({
languages: { path: `/category/${c.slug}`,
zh: `${baseUrl}/zh${route.path}`, changeFreq: 'daily' as const,
en: `${baseUrl}/en${route.path}`, 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: {
en: absoluteUrl('en', route.path),
zh: absoluteUrl('zh', route.path),
}, },
}) },
}) }))
}) )
return sitemapEntries
} }
+4 -13
View File
@@ -1,4 +1,3 @@
import Script from 'next/script'
interface WebSiteJsonLdProps { interface WebSiteJsonLdProps {
name: string name: string
@@ -30,10 +29,8 @@ export function WebSiteJsonLd({ name, description, url, locale }: WebSiteJsonLdP
} }
return ( return (
<Script <script
id="website-jsonld"
type="application/ld+json" type="application/ld+json"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
) )
@@ -60,10 +57,8 @@ export function OrganizationJsonLd({ name, url, logo, sameAs = [] }: Organizatio
} }
return ( return (
<Script <script
id="organization-jsonld"
type="application/ld+json" type="application/ld+json"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
) )
@@ -103,10 +98,8 @@ export function SoftwareApplicationJsonLd({
} }
return ( return (
<Script <script
id="software-jsonld"
type="application/ld+json" type="application/ld+json"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
) )
@@ -143,10 +136,8 @@ export function CollectionPageJsonLd({
} }
return ( return (
<Script <script
id="collection-jsonld"
type="application/ld+json" type="application/ld+json"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
) )
+27 -13
View File
@@ -1,13 +1,33 @@
import { Metadata } from 'next' import { Metadata } from 'next'
import { defaultLocale } from '@/i18n.config'
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' 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 { interface GenerateMetadataProps {
locale: string locale: string
path: string path: string
title: string title: string
description: string description: string
ogImage?: string ogImage?: string
ogType?: 'website' | 'article'
} }
export function generatePageMetadata({ export function generatePageMetadata({
@@ -16,23 +36,17 @@ export function generatePageMetadata({
title, title,
description, description,
ogImage = `${baseUrl}/og-image.png`, ogImage = `${baseUrl}/og-image.png`,
ogType = 'website',
}: GenerateMetadataProps): Metadata { }: GenerateMetadataProps): Metadata {
const canonicalUrl = `${baseUrl}/${locale}${path}` const canonicalUrl = absoluteUrl(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
},
}
return { return {
title, title,
description, description,
alternates, alternates: {
canonical: canonicalUrl,
languages: languageAlternates(path),
},
openGraph: { openGraph: {
title, title,
description, description,
@@ -47,7 +61,7 @@ export function generatePageMetadata({
}, },
], ],
locale: locale === 'zh' ? 'zh_CN' : 'en_US', locale: locale === 'zh' ? 'zh_CN' : 'en_US',
type: 'website', type: ogType,
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',