Typecho文章批量导出到Astro Markdown工具

4774 字
24 分钟
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 文档中自动替换为相对地址重新引用。


功能介绍#

  1. 按需导出:从 links.txt 读取指定文章链接进行导出,只导出清单内的文章。
  2. 分类组织:按 Typecho 文章分类目录导出到对应的子目录,保持原有分类结构。
  3. Astro 5.0 标准 Frontmatter:生成符合 Astro 5.0 标准的 YAML Frontmatter,包含标题、日期、作者、标签、分类、摘要等字段。
  4. 智能图片处理
    • 自动处理引用式图片(![alt][id])和直接链接图片(![alt](url)),转换为直接链接格式并下载到本地。
    • 清理图片 URL 中的查询参数(保留片段标识,避免误匹配 Markdown 标题)。
  5. 文章摘要生成:自动从内容中提取前 100 字作为描述(description)。
  6. 自定义封面图片:支持通过配置为文章添加封面图片(image 属性),可使用 Firefly 主题内置的随机二次元图片 API 或自定义 URL。
  7. 媒体资源下载
    • 可选下载图片和附件到本地。
    • 媒体文件按 images/[年份]/[文章ID]/attachments/[年份]/[文章ID]/ 组织。
    • Markdown 中的媒体链接自动替换为相对路径。
  8. 忽略头图:可选跳过第一张图片(头图)的下载,并删除其在正文中的引用标记。
  9. 安全文件名:自动清理文件名中的非法字符,限制长度,避免隐藏文件或非法文件。

注意事项#

  1. 运行环境:需要 PHP 7.4+,并启用 MySQL PDO 扩展。建议开启 cURL 扩展以获得更好的下载性能。
  2. 网站备份:使用前请务必全量备份 Typecho 数据库和网站文件,防止误操作!!!
  3. 文件权限:确保 PHP 有权限在当前目录创建文件夹和写入文件。
  4. 网络连接:下载图片和附件需要能访问原站点的域名(可通过 siteDomain 配置)。
  5. 链接文件格式links.txt 每行一个完整的文章 URL,如 https://www.moewah.com/archives/123.html,程序会自动提取文章 ID(123)。
  6. 导出目录:程序会在运行目录下创建 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 "![{$altText}]({$cleanUrl})";
},
$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 "![{$altText}]({$localPath})";
}, $content);
}
// 处理直接链接图片:![alt](url)
$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 "![{$altText}]({$localPath})";
},
$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.html
https://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.phplinks.txt 上传到网站根目录(两文件放在同一目录),通过命令行或浏览器访问该 PHP 文件(确保 PHP 环境支持)。例如在命令行中:

Terminal window
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 属性
downloadMediatrue是否下载图片和附件到本地
ignoreFirstImagetrue是否忽略第一张图片(头图),跳过下载且删除引用标记
maxFileSize10 * 1024 * 1024下载文件大小限制(10MB)
timeout30下载超时时间(秒)
retryCount2下载失败重试次数
skipFailedtrue跳过失败的下载(保留原 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 的数据,你可以在搬家时专注于保留真正带来流量和价值的页面,避免无谓的数据迁移。

如果你在使用过程中遇到问题,请检查以下几点:

  1. 数据库连接信息是否正确
  2. PHP 是否已安装 MySQL PDO 扩展
  3. links.txt 文件是否存在且格式正确
  4. 网站域名配置是否与图片/附件的原始域名一致

祝你的博客迁移顺利,在新平台上焕发新生!

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
Typecho文章批量导出到Astro Markdown工具
https://blog.moewah.com/posts/typecho-to-astro-markdown-migration-tool/
作者
MoeWah
发布于
2026-01-14
许可协议
CC BY-NC-SA 4.0
相关文章 智能推荐
1
Typecho文章批量导出至Astro Markdown工具
Astro教程 如何将Typecho博客内容高效迁移到Astro?本文提供适配Astro 5.0和Firefly主题的批量导出工具,自动转换Markdown格式并生成标准Frontmatter,解决图片处理、文件命名等迁移痛点,助力博主无缝迁移。
2
从 Hugo/Hexo/Next.js 迁移到 Astro 的3天指南
Astro教程 如何从Hugo、Hexo或Next.js迁移到Astro?本文详细拆解迁移流程,涵盖操作步骤、SEO影响和常见坑点,助你3天完成高性能博客迁移。
3
纳瓦尔的人生智慧:关于财富、幸福与自由的 18 条原则
认知与成长 硅谷投资人纳瓦尔·拉维坎特的人生洞见浓缩:从财富创造、幸福修炼到心智模型,18 条可执行原则帮你在长期主义中找到自由。
4
AI搜索吃掉一半流量?2026 你必须调整的内容推广方法
增长与SEO 2026年AI搜索覆盖48%查询,传统社群转发式推广失效。梳理五个按意图优先级排列的分发渠道——GEO优化、Reddit投放、战略合作、员工倡导、直接外联——附带推广时间线和内容再创作框架。
5
有了 tmux 还需要 herdr 吗?一个给 AI Agent 用的「终端管家」
AI实验室 herdr 是一个终端原生的 Agent 多路复用器,专为同时运行多个 AI Agent 的开发者设计。它解决了 Agent 管理混乱、会话丢失、远程协作等痛点,不替换终端、不依赖 Electron。本文从实际使用体验出发,带你了解它的核心功能和上手方法。
随机文章 随机推荐

评论区

Profile Image of the Author
MoeWah
Hello, I'm MoeWah.
专题文章
分类
站点统计
文章
198
分类
9
标签
434
总字数
373,761
运行时长
0
最后活动
0 天前

目录