Typecho文章批量导出到Astro Markdown工具
引言
作为站长,当你的网站拥有较庞大的文章及页面体量时,网站搬家总会遇到各类困惑。我们总希望原站点的数据“应搬尽搬”,但事实上很多文章本身可能并没有获得多少阅读与共鸣。
基于流量二八原则,支撑一个网站流量的几乎都来自那 20% 的页面。通过 Google Search Console 等平台拉取近一年的数据,可以佐证这个铁铮铮的事实:
“大部分人写的文章没价值,网站的大多数文章没流量。”
因此,当你的网站拥有较庞大的文章及页面体量时,可以通过 Google Search Console 的数据获取占比 80% 流量的页面列表(流量 TOP 20 的页面) + 近半年产生的新页面清单,合并得到的页面 URL 清单,单独创建一个包含这些页面 URL 列表的 links.txt 文件。这一部分才是整个网站的核心价值(当然,重要的笔记等内容另当别论)。
我想从 Typecho 导出有用的文章(包含站内图片和附件),同时希望导出的 md 文章页面能直接适配 Astro 静态博客框架并兼容 Firefly 主题,该怎么办呢?
这个工具正是面向这个需求而定制的。基于 links.txt 清单,批量导出文章 md 文件,并同时下载页面的图片和附件进行存储,在 md 文档中自动替换为相对地址重新引用。
功能介绍
- 按需导出:从
links.txt读取指定文章链接进行导出,只导出清单内的文章。 - 分类组织:按 Typecho 文章分类目录导出到对应的子目录,保持原有分类结构。
- Astro 5.0 标准 Frontmatter:生成符合 Astro 5.0 标准的 YAML Frontmatter,包含标题、日期、作者、标签、分类、摘要等字段。
- 智能图片处理:
- 自动处理引用式图片(
![alt][id])和直接链接图片(),转换为直接链接格式并下载到本地。 - 清理图片 URL 中的查询参数(保留片段标识,避免误匹配 Markdown 标题)。
- 自动处理引用式图片(
- 文章摘要生成:自动从内容中提取前 100 字作为描述(
description)。 - 自定义封面图片:支持通过配置为文章添加封面图片(
image属性),可使用 Firefly 主题内置的随机二次元图片 API 或自定义 URL。 - 媒体资源下载:
- 可选下载图片和附件到本地。
- 媒体文件按
images/[年份]/[文章ID]/和attachments/[年份]/[文章ID]/组织。 - Markdown 中的媒体链接自动替换为相对路径。
- 忽略头图:可选跳过第一张图片(头图)的下载,并删除其在正文中的引用标记。
- 安全文件名:自动清理文件名中的非法字符,限制长度,避免隐藏文件或非法文件。
注意事项
- 运行环境:需要 PHP 7.4+,并启用 MySQL PDO 扩展。建议开启 cURL 扩展以获得更好的下载性能。
- 网站备份:使用前请务必全量备份 Typecho 数据库和网站文件,防止误操作!!!
- 文件权限:确保 PHP 有权限在当前目录创建文件夹和写入文件。
- 网络连接:下载图片和附件需要能访问原站点的域名(可通过
siteDomain配置)。 - 链接文件格式:
links.txt每行一个完整的文章 URL,如https://www.moewah.com/archives/123.html,程序会自动提取文章 ID(123)。 - 导出目录:程序会在运行目录下创建
astro_posts(可配置)文件夹,所有导出的文章和媒体文件将存放在其中。
源代码
<?php/** * Typecho 文章导出工具 (适配 Astro 5.0 + Firefly 主题) * * 功能特性: * 1. 从链接文件读取指定文章链接进行导出(默认 links.txt) * 2. 按分类目录导出文章到对应的子目录 * 3. 构建符合 Astro 5.0 标准的 YAML Frontmatter * 4. 自动处理引用式图片,转换为直接链接格式并下载到本地 * 5. 清理图片 URL 中的查询参数(保留片段标识,避免误匹配Markdown标题) * 6. 生成文章摘要(自动截取前100字) * 7. 支持自定义文章封面图片配置 * 8. 可选下载媒体资源(图片/附件)到本地 * 9. 媒体文件按年份/文章ID组织:images/[年份]/[文章ID]/ 和 attachments/[年份]/[文章ID]/ * 10. Markdown 中媒体链接替换为相对路径 * 11. 可选忽略头图(跳过下载且删除引用标记) * * 使用前请先配置数据库连接信息和导出选项 * 运行后会在指定目录生成按分类组织的 Markdown 文件 */
// 1. 基础配置error_reporting(E_ALL);ini_set('display_errors', 1);
$dbConfig = [ 'host' => 'localhost', // 数据库主机地址 'name' => '数据库名', // 数据库名称 'user' => '数据库用户名', // 数据库用户名 'pass' => '数据库密码', // 数据库密码 'baseDir' => 'astro_posts', // 导出文件存放的目录 'image' => 'api', // 文章封面图片配置:默认值为 'api',将使用 Firefly 主题内置的随机二次元图片 API // 可自定义为其他图片 URL,例如:'https://example.com/image.jpg' // 或者保留为空字符串 '' 则不生成 image 属性
// 媒体下载配置 'downloadMedia' => true, // 是否下载媒体资源到本地 'ignoreFirstImage' => true, // 是否忽略第一张图片(头图):跳过下载且删除引用标记 'maxFileSize' => 10 * 1024 * 1024, // 10MB限制 'timeout' => 30, // 下载超时(秒) 'retryCount' => 2, // 失败重试次数 'skipFailed' => true, // 跳过失败的下载(保留原URL) 'userAgent' => 'Mozilla/5.0 (Typecho Exporter)', 'linksFile' => 'links.txt', // 文章链接列表文件路径 'siteDomain' => 'https://www.moewah.com', // 网站域名,用于构建完整图片URL];
try { // 创建数据库连接 $dsn = "mysql:host={$dbConfig['host']};dbname={$dbConfig['name']};charset=utf8mb4"; $pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC ]); // 1.1 从链接文件提取文章ID $articleIds = extractArticleIdsFromLinks($dbConfig['linksFile']); if (empty($articleIds)) { die("❌ " . $dbConfig['linksFile'] . "中没有有效的文章ID\n"); }
// 2. 执行 SQL 查询 $placeholders = implode(',', array_fill(0, count($articleIds), '?')); $sql = " SELECT c.cid, c.title, c.text, c.slug, c.created, c.modified, u.name as author, (SELECT GROUP_CONCAT(m.name) FROM typecho_relationships r JOIN typecho_metas m ON r.mid = m.mid WHERE r.cid = c.cid AND m.type = 'tag') as tags, (SELECT m.name FROM typecho_relationships r JOIN typecho_metas m ON r.mid = m.mid WHERE r.cid = c.cid AND m.type = 'category' LIMIT 1) as category FROM typecho_contents c LEFT JOIN typecho_users u ON c.authorId = u.uid WHERE c.type = 'post' AND c.status = 'publish' AND c.cid IN ($placeholders) ORDER BY c.created DESC ";
// 执行查询获取所有已发布的文章 $stmt = $pdo->prepare($sql); $stmt->execute($articleIds); $posts = $stmt->fetchAll();
if (!$posts) die("❌ 没有找到文章\n");
foreach ($posts as $index => $post) { echo "\n[" . ($index + 1) . "/" . count($posts) . "] 处理文章: {$post['title']} (ID: {$post['cid']})\n";
// 3. 处理分类目录:根据文章分类创建对应的导出目录 $categoryName = !empty($post['category']) ? $post['category'] : 'Uncategorized'; $targetFolder = $dbConfig['baseDir'] . DIRECTORY_SEPARATOR . sanitize_filename($categoryName); if (!is_dir($targetFolder)) { mkdir($targetFolder, 0755, true); }
// 4. 处理文件名:移除非法字符,确保文件名安全 $fileName = sanitize_filename($post['title']); $exportPath = $targetFolder . DIRECTORY_SEPARATOR . $fileName . ".md"; // Windows 系统需要转换编码 if (strpos(PHP_OS, "WIN") !== false) { $exportPath = iconv("UTF-8", "GBK//IGNORE", $exportPath); }
// 5. 处理文章内容:移除 Typecho 的 Markdown 注释标记 $rawContent = $post['text'];
$content = str_replace('<!--markdown-->', '', $rawContent);
// 6. 生成文章摘要:从内容中提取前100字作为描述 // 6.1 清理文本:移除所有 HTML 和 Markdown 标记 $cleanText = strip_tags($content); // 去除 HTML 标签 $cleanText = preg_replace('/!\[.*?\]\(.*?\)/s', '', $cleanText); // 去除图片标记 $cleanText = preg_replace('/!\[.*?\]\[.*?\]/s', '', $cleanText); // 去除引用式图片标记 $cleanText = preg_replace('/\[(.*?)\]\(.*?\)/s', '$1', $cleanText); // 去除链接标记但保留文字 $cleanText = preg_replace('/#+\s*/', '', $cleanText); // 去除标题标记 $cleanText = preg_replace('/\s+/', ' ', $cleanText); // 合并多余空格 $cleanText = trim($cleanText);
// 6.2 截取前100字作为摘要 $description = mb_substr($cleanText, 0, 100, 'UTF-8'); if (mb_strlen($cleanText, 'UTF-8') > 100) { $description .= '...'; }
// 7. 准备 Frontmatter(符合 Astro 5.0 标准) $pubDate = date('Y-m-d', $post['created']); // 发布日期,格式:YYYY-MM-DD $upDate = date('Y-m-d', $post['modified']); // 更新日期,格式:YYYY-MM-DD $tags = $post['tags'] ? explode(',', $post['tags']) : []; // 标签字符串转数组 $tags = array_filter(array_map('trim', $tags)); // 清理标签中的空格
$yaml = "---\n"; $yaml .= "title: " . json_encode($post['title'], JSON_UNESCAPED_UNICODE) . "\n"; $yaml .= "published: $pubDate\n"; $yaml .= "updated: $upDate\n"; $yaml .= "author: " . json_encode($post['author'], JSON_UNESCAPED_UNICODE) . "\n"; // 生成 image 属性,如果配置不为空则添加 if (!empty($dbConfig['image'])) { $yaml .= "image: " . json_encode($dbConfig['image'], JSON_UNESCAPED_UNICODE) . "\n"; } $yaml .= "description: " . json_encode($description, JSON_UNESCAPED_UNICODE) . "\n"; $yaml .= "category: " . json_encode($categoryName, JSON_UNESCAPED_UNICODE) . "\n";
// 7.2 处理标签:生成正确的 YAML 数组格式 if (count($tags) > 0) { $yaml .= "tags:\n - " . implode("\n - ", array_map(fn($t) => json_encode($t, JSON_UNESCAPED_UNICODE), $tags)) . "\n"; } else { $yaml .= "tags: []\n"; // 空数组,避免生成 `- []` 的错误格式 }
$yaml .= "slug: " . json_encode($post['slug'], JSON_UNESCAPED_UNICODE) . "\n"; $yaml .= "draft: false\n"; $yaml .= "toc: true\n"; $yaml .= "---\n\n";
// 8. 处理正文内容并下载媒体资源 $createYear = date('Y', $post['created']);
// 初始化媒体下载器 $downloader = null; if ($dbConfig['downloadMedia']) { $downloader = new MediaDownloader($dbConfig['baseDir'], $dbConfig); }
// 处理内容:下载媒体资源并替换为相对路径 if ($downloader) { $content = processContentWithMediaDownload($content, $downloader, $post['cid'], $createYear, $dbConfig['ignoreFirstImage']); }
// 9. 合并 Frontmatter 和正文内容 $fullOutput = $yaml . $content;
// 10. 写入文件到指定路径 if (file_put_contents($exportPath, $fullOutput)) { echo "[" . ($index + 1) . "/" . count($posts) . "] {$post['title']}\n"; } }
echo "✅ 完成!共导出 " . count($posts) . " 篇文章\n";
} catch (Exception $e) { die("❌ 错误: " . $e->getMessage() . "\n");}
/** * 从链接文件提取文章ID * * @param string $linksFile 链接文件路径 * @return array 文章ID数组 */function extractArticleIdsFromLinks($linksFile) { $ids = []; if (!file_exists($linksFile)) { die("❌ 找不到链接文件: {$linksFile}\n"); }
$lines = file($linksFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); foreach ($lines as $url) { if (preg_match('/\/(\d+)\.html$/', $url, $matches)) { $ids[] = (int)$matches[1]; } }
return array_unique($ids);}
/** * 媒体下载器类 */class MediaDownloader { // 支持的图片后缀 const IMAGE_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'ico'];
// 支持下载的附件后缀(白名单) const ATTACHMENT_EXTS = [ // 文档类 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'odt', // 压缩包 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', // 群晖相关 'spk', // 音视频 'mp3', 'mp4', 'avi', 'mkv', 'mov', 'flv', 'wav', 'ogg', // 其他 'apk', 'exe', 'dmg', 'iso', 'img' ];
private $baseDir; private $downloaded = []; // URL缓存 private $config; private $hasCurl; private $downloadCount = [ 'images' => 0, 'attachments' => 0, 'failed' => 0 ];
public function __construct($baseDir, $config) { $this->baseDir = rtrim($baseDir, '/\\'); $this->config = $config;
// 检查cURL扩展 $this->hasCurl = function_exists('curl_init'); }
/** * 构建完整URL */ private function buildFullUrl($url) { // 如果已经是完整URL,直接返回 if (preg_match('/^https?:\/\//i', $url)) { return $url; }
// 如果是相对路径,添加域名 $domain = $this->config['siteDomain'] ?? ''; if ($domain && strpos($url, '/') === 0) { // 移除域名末尾的斜杠 $domain = rtrim($domain, '/'); return $domain . $url; }
// 无法处理的URL return $url; }
/** * 下载媒体资源并返回相对路径 */ public function download($url, $articleId, $createYear) { // 构建完整URL(处理相对路径) $fullUrl = $this->buildFullUrl($url);
// 清理URL参数:移除查询字符串和Typecho特有的片段参数 // 处理格式: https://example.com/image.jpg?width=100#vwid=760&vhei=440 $cleanUrl = preg_replace('/\?[^#\s]+/', '', $fullUrl); // 移除查询参数 $cleanUrl = preg_replace('/#.*/', '', $cleanUrl); // 移除整个片段(Typecho使用片段存储尺寸参数)
// 检查缓存 $urlHash = md5($cleanUrl); if (isset($this->downloaded[$urlHash])) { return $this->downloaded[$urlHash]['relative_path']; }
// 确定资源类型 $type = $this->getMediaType($cleanUrl); if (!$type) { return $url; }
// 创建目标目录 $targetDir = "{$this->baseDir}/{$type}s/{$createYear}/{$articleId}"; if (!is_dir($targetDir)) { mkdir($targetDir, 0755, true); }
// 生成文件名 $filename = $this->generateFilename($cleanUrl, $articleId, $type); $fullPath = "{$targetDir}/{$filename}";
// 下载文件 $success = $this->downloadFile($cleanUrl, $fullPath);
if ($success) { $relativePath = "../{$type}s/{$createYear}/{$articleId}/{$filename}"; $this->downloaded[$urlHash] = [ 'url' => $cleanUrl, 'relative_path' => $relativePath, 'type' => $type, 'full_path' => $fullPath ]; $this->downloadCount[$type . 's']++;
return $relativePath; } else { $this->downloadCount['failed']++; return $url; // 失败返回原URL } }
/** * 判断是否为内部域名 */ private function isInternalUrl($url) { $domain = $this->config['siteDomain'] ?? ''; if (!$domain) return false;
$host = parse_url($url, PHP_URL_HOST); if (!$host) return false;
// 移除协议和端口,只比较域名 $cleanConfigDomain = parse_url($domain, PHP_URL_HOST) ?: $domain; $cleanUrlDomain = parse_url($host, PHP_URL_HOST) ?: $host;
return $cleanConfigDomain === $cleanUrlDomain; }
/** * 判断媒体类型 */ private function getMediaType($url) { // 1. 检查协议:只支持 http/https $scheme = parse_url($url, PHP_URL_SCHEME); if ($scheme && !in_array($scheme, ['http', 'https'])) { return null; }
// 2. 检查是否为内部域名:外部链接不下载 if (!$this->isInternalUrl($url)) { return null; }
$path = parse_url($url, PHP_URL_PATH); if (!$path || $path === '/') { return null; }
// 3. 提取文件后缀 $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
// 4. 无后缀URL跳过(内部链接但不带后缀,不处理) if (!$ext) { return null; }
// 5. 根据后缀判断类型 if (in_array($ext, self::IMAGE_EXTS)) { return 'image'; } if (in_array($ext, self::ATTACHMENT_EXTS)) { return 'attachment'; }
// 6. 其他后缀不下载 return null; }
/** * 生成安全文件名 */ private function generateFilename($url, $articleId, $type) { $path = parse_url($url, PHP_URL_PATH); $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $name = pathinfo($path, PATHINFO_FILENAME);
// 生成唯一文件名:文章ID_时间戳_哈希.扩展名 $timestamp = time(); $hash = substr(md5($url), 0, 6); $safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $name);
if (empty($safeName)) { $safeName = "{$type}_{$timestamp}"; }
return "{$articleId}_{$safeName}_{$hash}.{$ext}"; }
/** * 文件下载实现 */ private function downloadFile($url, $localPath) { $timeout = $this->config['timeout'] ?? 30; $maxSize = $this->config['maxFileSize'] ?? 10 * 1024 * 1024;
if ($this->hasCurl) { // 使用 cURL 下载 $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_MAXFILESIZE, $maxSize); curl_setopt($ch, CURLOPT_USERAGENT, $this->config['userAgent'] ?? 'Mozilla/5.0 (Typecho Exporter)');
$data = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch);
if ($httpCode === 200 && $data && strlen($data) > 0) { return file_put_contents($localPath, $data) !== false; } } else { // 使用 file_get_contents 作为备选 $context = stream_context_create([ 'http' => [ 'timeout' => $timeout, 'user_agent' => $this->config['userAgent'] ?? 'Mozilla/5.0 (Typecho Exporter)', ] ]);
$data = @file_get_contents($url, false, $context); if ($data !== false && strlen($data) > 0 && strlen($data) <= $maxSize) { return file_put_contents($localPath, $data) !== false; } }
return false; }
/** * 获取下载统计 */ public function getStats() { return $this->downloadCount; }}
/** * 处理内容并下载媒体资源 * * @param string $content 文章内容 * @param MediaDownloader $downloader 媒体下载器实例 * @param int $articleId 文章ID * @param int $createYear 文章创建年份 * @param bool $ignoreFirstImage 是否忽略头图(跳过下载且删除引用标记) * @return string 处理后的内容 */function processContentWithMediaDownload($content, $downloader, $articleId, $createYear, $ignoreFirstImage = true) { echo " 🔍 开始处理媒体资源 (文章ID: {$articleId}, 年份: {$createYear})\n";
// 收集引用定义(需在URL清理之前执行) $allRefs = []; if (preg_match_all('/\s*\[([^\]]+)\]:\s*(\S+)/im', $content, $refMatches, PREG_SET_ORDER)) { foreach ($refMatches as $match) { $refId = trim($match[1]); $url = $match[2]; $allRefs[$refId] = $url; } }
// 下载引用定义中的所有附件(即使未被引用) foreach ($allRefs as $refId => $url) { $downloader->download($url, $articleId, $createYear); }
// 忽略头图:删除正文开头直接引用的图片(无任何文本段落的图片) // 判断标准:内容最开头就是图片标记才算头图,段落后的图片保留 if ($ignoreFirstImage) { $pattern = '/^!\[([^\]]*)\]\(([^)]*)\)\s*/'; $content = preg_replace($pattern, '', $content, 1); $pattern = '/^!\[([^\]]*)\]\[([^\]]*)\]\s*/'; $content = preg_replace($pattern, '', $content, 1); }
// 清理URL参数:仅清理直接链接中的查询参数(不清理#,避免误匹配Markdown标题) $content = preg_replace_callback( '/!\[([^\]]*)\]\(([^)]*)\)/', function($matches) { $altText = $matches[1]; $url = $matches[2]; $cleanUrl = preg_replace('/\?[^)]*/', '', $url); return ""; }, $content );
// 处理引用式链接:[text][id] foreach ($allRefs as $refId => $url) { $pattern = '/\[([^\]]+)\]\[\s*' . preg_quote($refId, '/') . '\s*\]/'; $content = preg_replace_callback($pattern, function($matches) use ($url, $downloader, $articleId, $createYear) { $linkText = $matches[1]; $localPath = $downloader->download($url, $articleId, $createYear); return "[{$linkText}]({$localPath})"; }, $content); }
// 处理引用式图片:![alt][id] foreach ($allRefs as $refId => $url) { $pattern = '/!\[([^\]]*)\]\[\s*' . preg_quote($refId, '/') . '\s*\]/'; $content = preg_replace_callback($pattern, function($matches) use ($url, $downloader, $articleId, $createYear) { $altText = $matches[1]; $localPath = $downloader->download($url, $articleId, $createYear); return ""; }, $content); }
// 处理直接链接图片: $content = preg_replace_callback( '/!\[(.*?)\]\(([^\s\)]+)\)/i', function($matches) use ($downloader, $articleId, $createYear) { $altText = $matches[1]; $imageUrl = $matches[2]; $localPath = $downloader->download($imageUrl, $articleId, $createYear); return ""; }, $content );
// 处理附件链接:[文字](url) $content = preg_replace_callback( '/\[([^\]]+)\]\(([^\s)]+)\)/', function($matches) use ($downloader, $articleId, $createYear) { $linkText = $matches[1]; $fileUrl = $matches[2]; $localPath = $downloader->download($fileUrl, $articleId, $createYear); if ($localPath !== $fileUrl) { return "[{$linkText}]({$localPath})"; } return $matches[0]; }, $content );
// 移除引用定义行 $content = preg_replace('/^\s*\[[^\]]+\]:\s*\S+.*$/m', '', $content);
// 清理多余空行 $content = preg_replace('/\n\s*\n\s*\n/', "\n\n", $content);
return trim($content);}
/** * 文件名清理函数 * * @param string $filename 原始文件名 * @return string 清理后的安全文件名 * * 功能: * 1. 替换非法字符为连字符 * 2. 限制文件名长度不超过80个字符 * 3. 去除文件名两端的点和连字符 */function sanitize_filename($filename) { // 定义需要替换的非法字符 $invalid = array(" ", "?", "\\", "/", ":", "|", "*", "\"", "<", ">"); $filename = str_replace($invalid, '-', $filename);
// 限制文件名长度,避免过长的文件名 $filename = mb_substr($filename, 0, 80, 'utf-8');
// 去除文件名两端的点和连字符(避免隐藏文件或非法文件) return trim($filename, '.-');}?>👆完整复制上方代码,保存命名为typecho-export-md.php 文件。
使用方法步骤
第 1 步:准备链接文件
从 Google Search Console 或其他渠道获取高流量页面 URL 列表,创建一个名为 links.txt 的文件,每行一个完整的文章 URL,例如:
https://www.moewah.com/archives/123.htmlhttps://www.moewah.com/archives/456.html第 2 步:配置数据库连接
用文本编辑器打开 typecho-export-md.php,找到 $dbConfig 数组,修改以下配置:
host:Typecho 数据库主机地址(通常是localhost)name:Typecho 数据库名user:数据库用户名pass:数据库密码siteDomain:你的网站域名(用于构建完整的图片/附件 URL)
第 3 步:调整导出选项(可选)
根据需要调整其他配置项:
baseDir:导出文件存放的目录(默认astro_posts)image:文章封面图片配置(默认'api'使用 Firefly 主题内置随机二次元图片 API)downloadMedia:是否下载媒体资源(图片/附件)到本地ignoreFirstImage:是否忽略第一张图片(头图)
第 4 步:运行导出脚本
将 typecho-export-md.php 和 links.txt 上传到网站根目录(两文件放在同一目录),通过命令行或浏览器访问该 PHP 文件(确保 PHP 环境支持)。例如在命令行中:
php typecho-export-md.php第 5 步:获取导出结果
程序运行后,会在当前目录下创建 astro_posts 文件夹(或你指定的目录),其中按分类存放了所有导出的 Markdown 文件,同时图片和附件会按年份/文章ID组织在 images/ 和 attachments/ 子目录中。
第 6 步:导入 Astro 项目
将导出的 astro_posts 整个文件夹复制到你的 Astro 项目的 src/content/blog/(或你配置的 content 目录)下,即可直接使用。Frontmatter 已适配 Astro 5.0 标准,并兼容 Firefly 主题。
核心配置说明
| 配置项 | 默认值 | 说明 |
|---|---|---|
host | 'localhost' | Typecho 数据库主机地址 |
name | '数据库名' | Typecho 数据库名称 |
user | '数据库用户名' | 数据库用户名 |
pass | '数据库密码' | 数据库密码 |
baseDir | 'astro_posts' | 导出文件存放的根目录 |
image | 'api' | 文章封面图片配置。'api' 表示使用 Firefly 主题内置的随机二次元图片 API;可改为其他图片 URL 或留空 '' 不生成 image 属性 |
downloadMedia | true | 是否下载图片和附件到本地 |
ignoreFirstImage | true | 是否忽略第一张图片(头图),跳过下载且删除引用标记 |
maxFileSize | 10 * 1024 * 1024 | 下载文件大小限制(10MB) |
timeout | 30 | 下载超时时间(秒) |
retryCount | 2 | 下载失败重试次数 |
skipFailed | true | 跳过失败的下载(保留原 URL) |
userAgent | 'Mozilla/5.0 (Typecho Exporter)' | 下载时使用的 User-Agent |
linksFile | 'links.txt' | 文章链接列表文件路径(相对于脚本所在目录) |
siteDomain | 'https://www.moewah.com' | 网站域名,用于构建完整的图片/附件 URL(重要!) |
结语
这个工具帮助你从 Typecho 中精准导出 有价值的文章,并自动处理媒体资源,生成完全适配 Astro 5.0 + Firefly 主题 的 Markdown 文件。通过结合 Google Search Console 的数据,你可以在搬家时专注于保留真正带来流量和价值的页面,避免无谓的数据迁移。
如果你在使用过程中遇到问题,请检查以下几点:
- 数据库连接信息是否正确
- PHP 是否已安装 MySQL PDO 扩展
links.txt文件是否存在且格式正确- 网站域名配置是否与图片/附件的原始域名一致
祝你的博客迁移顺利,在新平台上焕发新生!
推荐文章
基于标签匹配 · 智能推荐支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!
喵斯基部落