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 { 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) {
+12 -4
View File
@@ -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 {
+57 -23
View File
@@ -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(),
changeFrequency: route.changeFreq,
priority: route.priority,
alternates: {
languages: {
zh: `${baseUrl}/zh${route.path}`,
en: `${baseUrl}/en${route.path}`,
},
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: {
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 {
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
View File
@@ -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',