本页内容
将 500 个页面从 WordPress 迁移到无头 CMS 本应只需一个周末。但现实中它要花 3 到 6 周 —— 因为得有人手动复制内容、修正格式、重新链接图片,并逐页核对。内容迁移是 web 开发中最令人头疼的任务,而它几乎可以完全自动化。
CrawlForge 以编程方式提取你整个站点的内容:页面、元数据、图片、链接和文档结构。本指南将向你展示如何构建一个迁移 pipeline,在数小时(而非数周)内将成千上万的页面在任意两个平台之间迁移。
目录
- 为什么内容迁移如此痛苦
- 架构概览
- 步骤 1:盘点你的源站点
- 步骤 2:提取内容和元数据
- 步骤 3:保留文档结构
- 步骤 4:为目标平台进行转换
- 步骤 5:验证迁移结果
- credits 成本分析
- 结果与收益
- 常见问题
为什么内容迁移如此痛苦
内容迁移失败有三个原因:
- 数量:即便是一个小型企业站点也有 200 到 500 个页面。每个页面都需要保留内容、元数据、图片和内部链接
- 格式不匹配:源 CMS 和目标 CMS 使用不同的内容模型(WordPress 区块 vs. MDX vs. Contentful 富文本)
- 隐藏的复杂性:shortcode、嵌入式媒体、自定义字段、重定向 —— 全都需要处理
手动迁移按分析人员的工时计算,每个页面大约花费 5 至 15 USD。一次 500 页的迁移,按每页 10 USD 计算,仅人力就要 5,000 USD。用 CrawlForge 进行自动化迁移,credits 成本不到 50 USD。
| 迁移方式 | 成本(500 页) | 耗时 | 错误率 |
|---|---|---|---|
| 手动复制粘贴 | 5,000-7,500 USD | 3-6 周 | 5-10% |
| 半自动(脚本) | 2,000-3,000 USD | 1-2 周 | 2-5% |
| CrawlForge pipeline | 20-50 USD | 2-4 小时 | <1% |
架构概览
这个迁移 pipeline 使用五个 CrawlForge 工具:
| 阶段 | 工具 | Credits | 用途 |
|---|---|---|---|
| 盘点 | map_site | 3 | 发现所有页面及其结构 |
| 内容提取 | extract_content | 2 | 从每个页面拉取干净的内容 |
| 元数据采集 | extract_metadata | 1 | 保留 SEO 标签和 Open Graph 数据 |
| 链接映射 | extract_links | 1 | 映射内部链接以便重写 |
| 批量处理 | batch_scrape | 5 | 高效处理数百个页面 |
步骤 1:盘点你的源站点
映射你源站点上的每一个页面,包括可能不在导航中的页面。
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
const client = new Client({
name: 'content-migrator',
version: '1.0.0',
});
interface SiteInventory {
pages: Array<{
url: string;
path: string;
depth: number;
type: 'page' | 'post' | 'category' | 'media' | 'other';
}>;
totalPages: number;
pathGroups: Record<string, number>;
}
async function inventorySite(siteUrl: string): Promise<SiteInventory> {
const mapResult = await client.callTool({
name: 'map_site',
arguments: {
url: siteUrl,
max_urls: 2000,
include_sitemap: true,
group_by_path: true,
include_metadata: true,
},
});
const result = JSON.parse(mapResult.content[0].text);
// Classify pages by URL pattern
const pages = result.urls.map((url: string) => {
const path = new URL(url).pathname;
let type: 'page' | 'post' | 'category' | 'media' | 'other' = 'other';
if (path.match(/\/\d{4}\/\d{2}\//)) type = 'post'; // WordPress date URLs
else if (path.match(/\/blog\//)) type = 'post';
else if (path.match(/\/category\//)) type = 'category';
else if (path.match(/\.(pdf|doc|jpg|png)/)) type = 'media';
else type = 'page';
return { url, path, depth: path.split('/').length - 1, type };
});
return {
pages,
totalPages: pages.length,
pathGroups: result.groupedByPath || {},
};
}步骤 2:提取内容和元数据
从每个页面提取干净的内容和全部元数据,同时保留标题结构和格式。
interface MigratedPage {
sourceUrl: string;
slug: string;
title: string;
content: string; // Clean markdown or HTML
excerpt: string;
metadata: {
title: string;
description: string;
ogImage: string;
canonical: string;
publishedAt: string;
author: string;
};
internalLinks: string[];
images: string[];
}
async function extractPage(url: string): Promise<MigratedPage> {
// Extract clean content (readability-optimized)
const contentResult = await client.callTool({
name: 'extract_content',
arguments: { url },
});
const content = JSON.parse(contentResult.content[0].text);
// Extract metadata separately for completeness
const metaResult = await client.callTool({
name: 'extract_metadata',
arguments: { url },
});
const meta = JSON.parse(metaResult.content[0].text);
// Extract links for internal link mapping
const linksResult = await client.callTool({
name: 'extract_links',
arguments: { url },
});
const links = JSON.parse(linksResult.content[0].text);
const internalLinks = (links.internal || []).map(
(l: { href: string }) => l.href
);
// Extract image URLs from content
const imageRegex = /!\[.*?\]\((.*?)\)/g;
const images: string[] = [];
let match;
while ((match = imageRegex.exec(content.content)) !== null) {
images.push(match[1]);
}
const slug = new URL(url).pathname
.replace(/^\//,'')
.replace(/\/$/,'')
.replace(/\//g, '-');
return {
sourceUrl: url,
slug,
title: meta.title || content.title || '',
content: content.content,
excerpt: meta.description || content.excerpt || '',
metadata: {
title: meta.title || '',
description: meta.description || '',
ogImage: meta.ogImage || '',
canonical: meta.canonical || url,
publishedAt: meta.publishedDate || '',
author: meta.author || '',
},
internalLinks,
images,
};
}步骤 3:保留文档结构
对于大型站点,使用批量处理并构建一个完整的链接映射用于 URL 重写。
type UrlMap = Map<string, string>; // old URL -> new slug
async function batchExtract(
inventory: SiteInventory
): Promise<{ pages: MigratedPage[]; urlMap: UrlMap }> {
const pages: MigratedPage[] = [];
const urlMap: UrlMap = new Map();
// Process in batches of 20
const batchSize = 20;
const urls = inventory.pages
.filter(p => p.type === 'page' || p.type === 'post')
.map(p => p.url);
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
console.log(
`Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(urls.length / batchSize)}`
);
// Extract each page in the batch
for (const url of batch) {
try {
const page = await extractPage(url);
pages.push(page);
urlMap.set(url, page.slug);
} catch (error) {
console.error(`Failed to extract ${url}:`, error);
}
}
}
return { pages, urlMap };
}步骤 4:为目标平台进行转换
重写内部链接,并将内容转换为与你的目标 CMS 格式相匹配。
function transformForTarget(
page: MigratedPage,
urlMap: UrlMap,
targetFormat: 'mdx' | 'contentful' | 'sanity'
): Record<string, unknown> {
// Rewrite internal links to new URL structure
let content = page.content;
for (const [oldUrl, newSlug] of urlMap) {
content = content.replace(
new RegExp(oldUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
`/${newSlug}`
);
}
switch (targetFormat) {
case 'mdx':
return {
slug: page.slug,
title: page.title,
description: page.metadata.description,
date: page.metadata.publishedAt || new Date().toISOString(),
content: content, // Already markdown from extract_content
};
case 'contentful':
return {
fields: {
title: { 'en-US': page.title },
slug: { 'en-US': page.slug },
body: { 'en-US': content },
description: { 'en-US': page.metadata.description },
publishedAt: { 'en-US': page.metadata.publishedAt },
},
contentType: 'blogPost',
};
case 'sanity':
return {
_type: 'post',
title: page.title,
slug: { current: page.slug },
body: content,
excerpt: page.excerpt,
publishedAt: page.metadata.publishedAt,
};
default:
return { ...page, content };
}
}步骤 5:验证迁移结果
转换之后,核对每个页面是否都正确迁移。
interface ValidationResult {
totalPages: number;
successCount: number;
failedCount: number;
warnings: Array<{
slug: string;
issue: string;
}>;
}
function validateMigration(
original: SiteInventory,
migrated: MigratedPage[]
): ValidationResult {
const warnings: Array<{ slug: string; issue: string }> = [];
for (const page of migrated) {
// Check for empty content
if (!page.content || page.content.trim().length < 50) {
warnings.push({
slug: page.slug,
issue: 'Content appears empty or very short',
});
}
// Check for missing metadata
if (!page.metadata.title) {
warnings.push({ slug: page.slug, issue: 'Missing title' });
}
if (!page.metadata.description) {
warnings.push({ slug: page.slug, issue: 'Missing meta description' });
}
// Check for broken internal links
for (const link of page.internalLinks) {
const isLinkMigrated = migrated.some(
p => p.sourceUrl === link || link.includes(p.slug)
);
if (!isLinkMigrated) {
warnings.push({
slug: page.slug,
issue: `Internal link may be broken: ${link}`,
});
}
}
}
const contentPages = original.pages.filter(
p => p.type === 'page' || p.type === 'post'
);
return {
totalPages: contentPages.length,
successCount: migrated.length,
failedCount: contentPages.length - migrated.length,
warnings,
};
}credits 成本分析
以一次 500 页的网站迁移为例:
| 操作 | 工具 | Credits | 数量 | 小计 |
|---|---|---|---|---|
| 站点盘点 | map_site | 3 | 1 | 3 |
| 内容提取 | extract_content | 2 | 500 页 | 1,000 |
| 元数据提取 | extract_metadata | 1 | 500 页 | 500 |
| 链接提取 | extract_links | 1 | 500 页 | 500 |
| 合计 | 2,003 credits |
一次完整的 500 页迁移大约花费 2,000 credits。Hobby 套餐($19/月,5,000 credits)处理它绰绰有余。对于更大的站点(1,000 页以上),Professional 套餐($99/月,50,000 credits)提供了充足的余量。
结果与收益
自动化内容迁移带来:
- 速度:在 2-4 小时内迁移 500 个页面,而不是 3-6 周
- 准确性:没有复制粘贴错误、格式损坏或遗漏的页面
- 完整性:每一个页面、每一个 meta 标签、每一条内部链接都被捕获
- 成本节省:用 19-99 USD 的工具 credits 取代 5,000 USD 以上的人工
CrawlForge 最适合需要保留 SEO 权重的内容迁移 —— meta 标签、内部链接、canonical URL 和内容结构都会被干净地迁移过去。
常见问题
CrawlForge 能处理 WordPress 的 shortcode 吗?
CrawlForge 的 extract_content 工具处理的是渲染后的 HTML,而不是 WordPress 的原始源码。当 CrawlForge 提取时,shortcode 已经被展开为它们的输出 HTML。你得到的是渲染后的内容,而这正是迁移所需要的。
图片和媒体文件怎么办?
CrawlForge 会从内容中提取图片 URL。你需要一个单独的步骤来下载图片并将其重新托管到目标平台。fetch_url 工具(1 credit)可以下载单个媒体文件。
迁移之后我该如何处理重定向?
步骤 3 中生成的 urlMap 为你提供了一份完整的「旧 URL 到新 slug」的映射。将其导出为你托管平台的重定向映射(Vercel 的 vercel.json、Netlify 的 _redirects,或 nginx 配置)。
这个周末就迁移你的站点。 免费开始,赠送 1,000 credits —— 足以迁移 250 个以上的页面。无需信用卡。
相关资源:
亲自试一试——无需注册
在 Playground 中运行 CrawlForge 的 27 个抓取与提取工具中的任意一个,然后免费开始,获取 1,000 credits。
1,000 免费 credits • 每月补充 • 无需信用卡
标签
及时获取最新洞察
将教程、产品更新与 Web 抓取技巧直接发送到你的收件箱。
拒绝垃圾邮件,随时可取消订阅。