大版本更新
一口气全面统一博客样式,移除部分冗余导航页面,更专注内容展示。
不因虚度年华而悔恨,不因碌碌无为而羞愧
适用于 Firefly Astro 博客,Twikoo ↔ Waline 评论数据双向转换、导入全流程,修复路径尾斜杠匹配失败、时间格式 500 错误、回复层级丢失等问题

本文档适配基于 Firefly 主题的 Astro 静态博客,完整覆盖 Twikoo ↔ Waline 评论数据双向转换全流程,解决四大高频痛点:
500: invalid input syntax for type timestamp;https://waline.fqzlr.com);.json 文件。https://waline.fqzlr.com/ui;.json 文件。重要:Twikoo 导出每条评论需包含
_id、pid、rid字段;Waline 导出需包含data.Comment、data.Users、data.Counter三表。
新建文本文件,复制下方全部代码,保存为 twikoo-to-waline.html,双击用浏览器打开即可离线使用:
1<!DOCTYPE html>2<html lang="zh-CN">3<head>4<meta charset="UTF-8">5<meta name="viewport" content="width=device-width, initial-scale=1.0">6<title>Twikoo ↔ Waline 评论数据双向转换工具</title>7<style>8* { margin: 0; padding: 0; box-sizing: border-box; }9body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f6fa; color: #2c3e50; min-height: 100vh; }10.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 32px; }11.header h1 { font-size: 22px; font-weight: 600; }12.header p { font-size: 13px; opacity: 0.85; margin-top: 4px; }13.container { max-width: 1100px; margin: 0 auto; padding: 24px; }14.card { background: #fff; border-radius: 10px; padding: 24px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }15.step-label { display: inline-block; background: #667eea; color: #fff; font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 10px; margin-right: 8px; }16.card h3 { font-size: 16px; margin-bottom: 16px; display: flex; align-items: center; }17.mode-switch { display: flex; gap: 0; margin-bottom: 20px; border-radius: 8px; overflow: hidden; border: 2px solid #667eea; }18.mode-btn { flex: 1; padding: 10px 16px; border: none; font-size: 14px; font-weight: 600; cursor: pointer; background: #fff; color: #667eea; transition: all 0.2s; }19.mode-btn.active { background: #667eea; color: #fff; }20.mode-btn:hover:not(.active) { background: #f0f2ff; }21.drop-zone { border: 2px dashed #d0d5dd; border-radius: 8px; padding: 40px; text-align: center; cursor: pointer; transition: all 0.2s; margin-bottom: 8px; }22.drop-zone:hover, .drop-zone.dragover { border-color: #667eea; background: #f0f2ff; }23.drop-zone p { color: #888; font-size: 14px; }24.drop-zone p strong { color: #667eea; }25.file-hint { font-size: 12px; color: #aaa; margin-top: 4px; }26.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }27.stat-box { background: #f8f9fc; border-radius: 8px; padding: 12px; text-align: center; }28.stat-box .num { font-size: 26px; font-weight: 700; color: #667eea; }29.stat-box .label { font-size: 11px; color: #888; margin-top: 3px; }30.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }31.btn { padding: 8px 18px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; font-weight: 500; transition: all 0.2s; }32.btn-primary { background: #667eea; color: #fff; }33.btn-primary:hover { background: #5a6fd6; }34.btn-secondary { background: #e8eaed; color: #333; }35.btn-secondary:hover { background: #d8dbe0; }36.btn-success { background: #27ae60; color: #fff; }37.btn-success:hover { background: #219a52; }38.btn:disabled { opacity: 0.45; cursor: not-allowed; }39.comment-list { max-height: 500px; overflow-y: auto; border: 1px solid #eee; border-radius: 8px; }40.comment-item { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; display: flex; gap: 10px; align-items: flex-start; }41.comment-item:last-child { border-bottom: none; }42.comment-item .oid { background: #667eea; color: #fff; font-size: 10px; min-width: 24px; height: 20px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }43.comment-item .indent { color: #aaa; font-size: 12px; margin-right: 4px; }44.comment-item.level-1 { padding-left: 44px; }45.comment-item.level-2 { padding-left: 66px; }46.comment-item.level-3 { padding-left: 88px; }47.comment-info { flex: 1; min-width: 0; }48.comment-info .author { font-weight: 600; font-size: 13px; }49.comment-info .meta { font-size: 11px; color: #999; margin-top: 2px; }50.comment-info .text { font-size: 13px; margin-top: 3px; color: #444; word-break: break-all; }51.log { background: #1e1e1e; color: #d4d4d4; padding: 12px 16px; border-radius: 8px; font-family: "SF Mono", "Cascadia Code", Consolas, monospace; font-size: 12px; max-height: 200px; overflow-y: auto; white-space: pre-wrap; }52.log .ok { color: #6a9955; }53.log .warn { color: #dcdcaa; }54.log .err { color: #f44747; }55.note { font-size: 12px; color: #999; background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; padding: 10px 14px; margin-top: 12px; line-height: 1.6; }56</style>57</head>58<body>59
60<div class="header">61 <h1>Twikoo ↔ Waline 评论数据双向转换工具</h1>62 <p>保留留言回复层级关系 · 支持双向互转 · 纯浏览器端运行,数据不上传</p>63</div>64
65<div class="container">66
67 <!-- 方向选择 -->68 <div class="card">69 <h3><span class="step-label">1</span> 选择转换方向</h3>70 <div class="mode-switch">71 <button class="mode-btn active" id="modeTW" onclick="setMode('tw2wl')">Twikoo → Waline</button>72 <button class="mode-btn" id="modeWT" onclick="setMode('wl2tw')">Waline → Twikoo</button>73 </div>74 <div id="modeDesc" style="font-size:13px;color:#666;">将 Twikoo 导出的 JSON 数组转换为 Waline 导入格式(含 Comment / Users / Counter 三表)</div>75 </div>76
77 <!-- 加载 -->78 <div class="card">79 <h3><span class="step-label">2</span> 加载源文件</h3>80 <div class="drop-zone" id="dropZone">81 <p id="dropText">拖拽 <strong>twikoo-comment.json</strong> 到此处<br>或 <strong>点击选择文件</strong></p>82 <input type="file" id="fileInput" accept=".json" style="display:none">83 </div>84 <div class="file-hint" id="fileHint">Twikoo 导出文件通常是一个 JSON 数组,Waline 导出文件是一个含 data.Comment 的对象</div>85 <div id="fileInfo" style="margin-top:8px;font-size:13px;color:#888;"></div>86 </div>87
88 <!-- 统计 -->89 <div class="card" id="statsCard" style="display:none">90 <h3>数据概览</h3>91 <div class="stats" id="statsGrid"></div>92 </div>93
94 <!-- 转换 -->95 <div class="card" id="convertCard" style="display:none">96 <h3><span class="step-label">3</span> 转换 & 预览</h3>97 <div class="toolbar">98 <button class="btn btn-primary" id="btnConvert">执行转换</button>99 <button class="btn btn-success" id="btnDownload" disabled>下载结果 JSON</button>100 <button class="btn btn-secondary" id="btnPreview" disabled>预览评论列表</button>101 </div>102 <div class="log" id="logArea" style="display:none;"></div>103 <div class="comment-list" id="previewList" style="display:none;margin-top:12px;"></div>104 <div class="note" id="noteArea" style="display:none;"></div>105 </div>106
107</div>108
109<script>110// ==================== 状态 ====================111let currentMode = 'tw2wl'; // 'tw2wl' | 'wl2tw'112let sourceData = null;113let outputData = null;114let convertLog = [];115let convertStats = null;116
117// ==================== UUID 生成 ====================118function generateUUID() {119 return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => {120 const r = Math.random() * 16 | 0;121 return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);122 });123}124
125// ==================== 工具函数 ====================126function stripHtml(html) {127 const div = document.createElement('div');128 div.innerHTML = html;129 return div.textContent || div.innerText || '';130}131
132function tsToISO(ms) {133 return new Date(ms).toISOString();134}135
136function isoToTs(iso) {137 return new Date(iso).getTime();138}139
140function normalizeUrl(url) {141 if (!url) return '/';142 return url.endsWith('/') ? url : url + '/';143}144
145function wrapHtml(text) {146 if (!text || text.trim() === '') return '';147 return '<p>' + text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\\n/g, '</p>\\n<p>') + '</p>\\n';148}149
150function parseIPRegion(ipRegion) {151 if (!ipRegion) return '';152 const parts = ipRegion.split('|');153 return [parts[0], parts[2], parts[3]].filter(Boolean).join(' ');154}155
156// ==================== 核心:Twikoo → Waline ====================157function convertTW2WL(data) {158 const comments = Array.isArray(data) ? data : (data.data || data.comments || data);159 if (!Array.isArray(comments) || comments.length === 0) {160 throw new Error('未识别到评论数组');161 }162
163 const log = [];164 log.push('[Twikoo → Waline] 开始转换...');165 log.push(`源评论数: ${comments.length}`);166
167 // 分配数字 objectId168 let nextId = 1;169 const idMap = {}; // twikoo _id → waline objectId170 for (const c of comments) {171 idMap[c._id] = nextId++;172 }173
174 // Users 按邮箱去重175 const userMap = {};176 const users = [];177 let uidCounter = 0;178 const usedEmails = new Set();179
180 for (const c of comments) {181 const email = (c.mail || '').trim().toLowerCase();182 if (email && !usedEmails.has(email)) {183 usedEmails.add(email);184 uidCounter++;185 userMap[email] = uidCounter;186 users.push({187 objectId: uidCounter,188 display_name: c.nick || '',189 email: email,190 password: '',191 type: c.master === 1 ? 'administrator' : 'guest',192 label: c.master === 1 ? '主理人' : null,193 url: c.link || null,194 avatar: c.avatar || null,195 github: null, twitter: null, facebook: null, google: null,196 weibo: null, qq: null, oidc: null, huawei: null, "2fa": null,197 createdAt: tsToISO(c.created),198 updatedAt: tsToISO(c.updated || c.created)199 });200 }201 }202 log.push(`去重用户: ${users.length}`);203
204 // Counter 按 url 去重205 const urlCountMap = {};206 for (const c of comments) {207 const url = normalizeUrl(c.url);208 urlCountMap[url] = (urlCountMap[url] || 0) + 1;209 }210 const counters = [];211 let cidCounter = 0;212 for (const [url, count] of Object.entries(urlCountMap)) {213 cidCounter++;214 counters.push({215 objectId: cidCounter, url, time: count,216 reaction0: null, reaction1: null, reaction2: null,217 reaction3: null, reaction4: null, reaction5: null,218 reaction6: null, reaction7: null, reaction8: null,219 createdAt: tsToISO(Date.now()),220 updatedAt: tsToISO(Date.now())221 });222 }223
224 // 转换 Comment225 const walineComments = [];226 let topCount = 0, replyCount = 0, orphanCount = 0;227
228 for (const c of comments) {229 const email = (c.mail || '').trim().toLowerCase();230 const userId = userMap[email] || 0;231 let pid = null, rid = null;232
233 if (c.pid && c.pid.trim() !== '') {234 pid = idMap[c.pid] || null;235 if (pid === null) { orphanCount++; log.push(`⚠ 孤儿回复: ${c._id} → 父 ${c.pid} 不存在`); }236 }237 if (c.rid && c.rid.trim() !== '') {238 rid = idMap[c.rid] || null;239 }240 if (pid !== null && rid === null) rid = pid;241
242 pid === null ? topCount++ : replyCount++;243
244 walineComments.push({245 objectId: idMap[c._id],246 user_id: userId,247 comment: stripHtml(c.comment || ''),248 ip: c.ip || '',249 link: c.link || null,250 mail: c.mail || '',251 nick: c.nick || '',252 pid, rid,253 sticky: c.top === 1 ? 1 : null,254 status: c.isSpam ? 'spam' : 'approved',255 like: null,256 ua: c.ua || '',257 url: normalizeUrl(c.url),258 insertedAt: tsToISO(c.created),259 createdAt: tsToISO(c.created),260 updatedAt: tsToISO(c.updated || c.created)261 });262 }263
264 log.push(`顶级: ${topCount} · 回复: ${replyCount} · 孤儿: ${orphanCount}`);265
266 return {267 result: {268 __version: '1.41.3', type: 'waline', version: 1, time: Date.now(),269 tables: ['Comment', 'Counter', 'Users'],270 data: { Comment: walineComments, Counter: counters, Users: users }271 },272 log, stats: { total: comments.length, top: topCount, reply: replyCount, users: users.length, urls: counters.length, orphan: orphanCount }273 };274}275
276// ==================== 核心:Waline → Twikoo ====================277function convertWL2TW(data) {278 const log = [];279 log.push('[Waline → Twikoo] 开始转换...');280
281 // 提取数据282 let walineObj = data;283 if (Array.isArray(data)) {284 // 可能是单纯的 Comment 数组285 walineObj = { data: { Comment: data, Users: [], Counter: [] } };286 }287
288 const wlComments = walineObj?.data?.Comment || walineObj?.Comment || walineObj?.comments || [];289 const wlUsers = walineObj?.data?.Users || walineObj?.Users || [];290 const wlCounters = walineObj?.data?.Counter || walineObj?.Counter || [];291
292 if (!Array.isArray(wlComments) || wlComments.length === 0) {293 throw new Error('未识别到 Waline Comment 数据');294 }295
296 log.push(`源评论数: ${wlComments.length}`);297 log.push(`源用户数: ${wlUsers.length}`);298 log.push(`源计数器: ${wlCounters.length}`);299
300 // 构建 user_id → 用户信息 的映射301 const userInfoMap = {};302 for (const u of wlUsers) {303 userInfoMap[u.objectId] = u;304 }305
306 // 分配 Twikoo 的 _id(UUID)307 const idMap = {}; // waline objectId → twikoo _id308 for (const c of wlComments) {309 idMap[c.objectId] = generateUUID();310 }311 log.push(`生成 UUID _id: ${Object.keys(idMap).length} 个`);312
313 // 构建 url → 页面信息映射(用于补充 href)314 const urlInfoMap = {};315 for (const ct of wlCounters) {316 if (ct.url) urlInfoMap[ct.url] = ct;317 }318
319 // 转换320 const twikooComments = [];321 let topCount = 0, replyCount = 0, orphanCount = 0;322
323 for (const c of wlComments) {324 const userInfo = userInfoMap[c.user_id] || {};325 const email = c.mail || userInfo.email || '';326 const nick = c.nick || userInfo.display_name || '';327
328 let pid = '';329 let rid = '';330
331 if (c.pid != null) {332 pid = idMap[c.pid] || '';333 if (pid === '') { orphanCount++; log.push(`⚠ 孤儿回复: objectId=${c.objectId} → 父 ${c.pid} 不存在`); }334 }335 if (c.rid != null) {336 rid = idMap[c.rid] || '';337 }338
339 pid ? replyCount++ : topCount++;340
341 const created = typeof c.createdAt === 'number' ? c.createdAt : isoToTs(c.createdAt || c.insertedAt || new Date().toISOString());342 const updated = typeof c.updatedAt === 'number' ? c.updatedAt : isoToTs(c.updatedAt || c.createdAt || new Date().toISOString());343
344 // 构建 ipRegion(近似还原)345 // Waline 没有 ipRegion 字段,只能留空或尝试从其他字段推断346 const ipRegion = '';347
348 twikooComments.push({349 _id: idMap[c.objectId],350 uid: '',351 nick: nick,352 mail: email,353 mailMd5: '',354 link: c.link || userInfo.url || '',355 ua: c.ua || '',356 ip: c.ip || '',357 ipRegion: ipRegion,358 master: (userInfo.type === 'administrator') ? 1 : 0,359 url: c.url || '/',360 href: c.url ? ('https://fqzlr.com' + c.url + '/') : '',361 comment: wrapHtml(c.comment || ''),362 pid: pid,363 rid: rid,364 isSpam: c.status === 'spam' ? 1 : 0,365 created: created,366 updated: updated,367 like: c.like ? JSON.stringify(c.like) : '[]',368 top: c.sticky ? 1 : 0,369 avatar: userInfo.avatar || ''370 });371 }372
373 log.push(`顶级: ${topCount} · 回复: ${replyCount} · 孤儿: ${orphanCount}`);374
375 return {376 result: twikooComments,377 log, stats: { total: twikooComments.length, top: topCount, reply: replyCount, users: wlUsers.length, orphan: orphanCount }378 };379}380
381// ==================== UI ====================382const dropZone = document.getElementById('dropZone');383const fileInput = document.getElementById('fileInput');384const fileInfo = document.getElementById('fileInfo');385const statsCard = document.getElementById('statsCard');386const statsGrid = document.getElementById('statsGrid');387const convertCard = document.getElementById('convertCard');388const btnConvert = document.getElementById('btnConvert');389const btnDownload = document.getElementById('btnDownload');390const btnPreview = document.getElementById('btnPreview');391const logArea = document.getElementById('logArea');392const previewList = document.getElementById('previewList');393const noteArea = document.getElementById('noteArea');394const dropText = document.getElementById('dropText');395const fileHint = document.getElementById('fileHint');396const modeDesc = document.getElementById('modeDesc');397
398function setMode(mode) {399 currentMode = mode;400 document.getElementById('modeTW').classList.toggle('active', mode === 'tw2wl');401 document.getElementById('modeWT').classList.toggle('active', mode === 'wl2tw');402
403 if (mode === 'tw2wl') {404 modeDesc.textContent = '将 Twikoo 导出的 JSON 数组转换为 Waline 导入格式(含 Comment / Users / Counter 三表)';405 dropText.innerHTML = '拖拽 <strong>twikoo-comment.json</strong> 到此处<br>或 <strong>点击选择文件</strong>';406 fileHint.textContent = 'Twikoo 导出文件通常是一个 JSON 数组,每个元素是一条评论';407 } else {408 modeDesc.textContent = '将 Waline 导出的 JSON 对象转换为 Twikoo 评论数组格式(含层级关系)';409 dropText.innerHTML = '拖拽 <strong>waline-export.json</strong> 到此处<br>或 <strong>点击选择文件</strong>';410 fileHint.textContent = 'Waline 导出文件是一个含 data.Comment / data.Users / data.Counter 的 JSON 对象';411 }412
413 // 重置414 sourceData = null;415 outputData = null;416 convertLog = [];417 convertStats = null;418 fileInfo.textContent = '';419 statsCard.style.display = 'none';420 convertCard.style.display = 'none';421 logArea.style.display = 'none';422 previewList.style.display = 'none';423 noteArea.style.display = 'none';424 btnDownload.disabled = true;425 btnPreview.disabled = true;426}427
428dropZone.addEventListener('click', () => fileInput.click());429dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });430dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));431dropZone.addEventListener('drop', e => {432 e.preventDefault();433 dropZone.classList.remove('dragover');434 const file = e.dataTransfer.files[0];435 if (file) loadFile(file);436});437fileInput.addEventListener('change', e => {438 const file = e.target.files[0];439 if (file) loadFile(file);440});441
442function loadFile(file) {443 fileInfo.textContent = `已选择: ${file.name} (${(file.size / 1024).toFixed(1)} KB)`;444 const reader = new FileReader();445 reader.onload = function() {446 try {447 sourceData = JSON.parse(reader.result);448
449 let arr;450 if (currentMode === 'tw2wl') {451 arr = Array.isArray(sourceData) ? sourceData : (sourceData.data || sourceData.comments || []);452 } else {453 arr = sourceData?.data?.Comment || sourceData?.Comment || sourceData?.comments || [];454 }455
456 statsCard.style.display = 'block';457 convertCard.style.display = 'block';458
459 const topArr = currentMode === 'tw2wl'460 ? arr.filter(c => !c.pid || c.pid === '')461 : arr.filter(c => c.pid == null);462 const replyArr = currentMode === 'tw2wl'463 ? arr.filter(c => c.pid && c.pid !== '')464 : arr.filter(c => c.pid != null);465 const userCount = currentMode === 'tw2wl'466 ? new Set(arr.map(c => c.mail?.trim().toLowerCase()).filter(Boolean)).size467 : (sourceData?.data?.Users || sourceData?.Users || []).length || '未知';468
469 statsGrid.innerHTML = `470 <div class="stat-box"><div class="num">${arr.length}</div><div class="label">评论总数</div></div>471 <div class="stat-box"><div class="num">${topArr.length}</div><div class="label">顶级评论</div></div>472 <div class="stat-box"><div class="num">${replyArr.length}</div><div class="label">回复评论</div></div>473 <div class="stat-box"><div class="num">${userCount}</div><div class="label">关联用户</div></div>474 `;475
476 logArea.style.display = 'none';477 previewList.style.display = 'none';478 noteArea.style.display = 'none';479 btnDownload.disabled = true;480 btnPreview.disabled = true;481 } catch (err) {482 fileInfo.innerHTML = `<span style="color:#e74c3c">JSON 解析失败: ${err.message}</span>`;483 }484 };485 reader.readAsText(file);486}487
488btnConvert.addEventListener('click', () => {489 if (!sourceData) return alert('请先加载源 JSON 文件');490 try {491 let result;492 if (currentMode === 'tw2wl') {493 result = convertTW2WL(sourceData);494 } else {495 result = convertWL2TW(sourceData);496 }497 outputData = result.result;498 convertLog = result.log;499 convertStats = result.stats;500
501 logArea.style.display = 'block';502 logArea.innerHTML = convertLog.map(l => {503 if (l.startsWith('⚠')) return `<span class="warn">${l}</span>`;504 if (l.startsWith('❌')) return `<span class="err">${l}</span>`;505 return `<span class="ok">${l}</span>`;506 }).join('\\n');507
508 btnDownload.disabled = false;509 btnPreview.disabled = false;510 previewList.style.display = 'none';511
512 // 显示注意事项513 if (currentMode === 'wl2tw') {514 noteArea.style.display = 'block';515 noteArea.innerHTML = `516 <strong>注意:</strong>Waline 原始数据不包含 <code>uid</code>、<code>mailMd5</code>、<code>ipRegion</code>、<code>href</code> 等字段,转换后这些字段将为空或使用默认值。<br>517 建议导入 Twikoo 后手动补充站点域名等配置。518 `;519 } else {520 noteArea.style.display = 'block';521 noteArea.innerHTML = `522 <strong>注意:</strong>转换后的 Waline JSON 可直接通过 Waline 管理后台"导入数据"功能导入。<br>523 评论中的 HTML 标签已去除,纯文本保留。回复层级关系通过 <code>pid</code>/<code>rid</code> 数字 ID 保留。524 `;525 }526 } catch (err) {527 alert('转换失败: ' + err.message);528 }529});530
531btnDownload.addEventListener('click', () => {532 if (!outputData) return;533 const filename = currentMode === 'tw2wl' ? 'waline-import.json' : 'twikoo-export.json';534 const blob = new Blob([JSON.stringify(outputData, null, '\\t')], { type: 'application/json' });535 const url = URL.createObjectURL(blob);536 const a = document.createElement('a');537 a.href = url; a.download = filename; a.click();538 URL.revokeObjectURL(url);539});540
541btnPreview.addEventListener('click', () => {542 if (!outputData) return;543
544 let comments;545 if (currentMode === 'tw2wl') {546 comments = outputData.data.Comment;547 } else {548 comments = outputData;549 }550
551 previewList.style.display = 'block';552
553 // 构建映射 + 计算层级554 const commentMap = {};555 for (const c of comments) {556 const id = currentMode === 'tw2wl' ? c.objectId : c._id;557 commentMap[id] = c;558 }559
560 function getPid(c) {561 if (currentMode === 'tw2wl') return c.pid;562 return c.pid || null;563 }564
565 function getId(c) {566 if (currentMode === 'tw2wl') return c.objectId;567 return c._id;568 }569
570 function getLevel(comment) {571 let level = 0;572 let current = comment;573 const pid = getPid(current);574 if (pid && commentMap[pid]) {575 level++;576 current = commentMap[pid];577 let safety = 0;578 while (safety < 50) {579 const p = getPid(current);580 if (p && commentMap[p]) {581 level++;582 current = commentMap[p];583 } else break;584 safety++;585 }586 }587 return level;588 }589
590 function getNick(c) { return c.nick || '匿名'; }591 function getText(c) { const t = c.comment || ''; return t.length > 100 ? t.slice(0, 100) + '...' : (t || '(空)'); }592 function getUrl(c) { return c.url || '/'; }593 function getTime(c) {594 const t = c.createdAt || c.created || '';595 if (typeof t === 'number') return new Date(t).toISOString().slice(0, 10);596 return String(t).slice(0, 10);597 }598 function getStatus(c) { return currentMode === 'tw2wl' ? c.status : (c.isSpam ? 'spam' : 'approved'); }599 function getDisplayId(c) { return currentMode === 'tw2wl' ? c.objectId : c._id.slice(0, 8); }600 function getPidDisplay(c) {601 if (currentMode === 'tw2wl') return c.pid ? ' → 回复 #' + c.pid : '';602 return c.pid ? ' → 回复 ' + c.pid.slice(0, 8) : '';603 }604
605 // 保持原始顺序606 previewList.innerHTML = comments.map(c => {607 const level = getLevel(c);608 const indent = '└ '.repeat(Math.min(level, 10));609 return `610 <div class="comment-item level-${Math.min(level, 3)}">611 <div class="oid">${getDisplayId(c)}</div>612 <div class="comment-info">613 <div class="author">${indent}${getNick(c)}${getPidDisplay(c)}</div>614 <div class="meta">${getUrl(c)} · ${getTime(c)} · ${getStatus(c)}</div>615 <div class="text">${getText(c)}</div>616 </div>617 </div>618 `;619 }).join('');620});621</script>622</body>623</html>在上方内嵌工具中,顶部切换按钮选择「Twikoo → Waline」;
导入数据,二选一:
(可选)路径前缀批量替换:填写旧/新路径前缀后点击「一键替换全部 url」,替换后自动补尾斜杠;
点击「开始转换」;
预览校验:
"url": "/xxx/" 末尾已自动补 /;pid 为数字 ID,代表父子关联正常;点击「下载结果 JSON」保存 waline-import.json。
twikoo-export.json;uid、mailMd5、ipRegion、href 等 Twikoo 特有字段,转换后这些字段为空或默认值。https://waline.fqzlr.com/ui;waline-import.json,点击执行导入;Ctrl + F5 强制刷新后台页面,即可在评论列表看到全部迁移历史评论,嵌套回复正常分层展示。转换工具已自动为所有评论 url 末尾添加 /,Waline 使用精确字符串匹配页面路径,和 Firefly 前台手动提交评论的路径格式完全一致。
fqzlr.com/friends/;Ctrl + F5 强制清除浏览器缓存刷新页面;path 是否为带尾斜杠格式(/friends/);url 缺少末尾 /:重新转换,确认路径已自动补斜杠;pid 为 null:原始 Twikoo 数据缺失 _id/pid/rid,清理脏数据后重新导出转换;invalid input syntax for type timestamp#500: invalid input syntax for type timestamp: "2026-04-25T11:23:19.928.000Z"tsToISO 使用了 toISOString().replace('Z', '.000Z'),当毫秒本身已有 3 位时(如 .928Z),替换后变成 .928.000Z,PostgreSQL 无法解析双重小数点格式;new Date(ms).toISOString() 直接输出标准 ISO 8601 格式,无需额外拼接。/friends vs /friends/);url 补尾斜杠,清空旧评论后重新导入。createdAt/user_id/like 数据库必填字段;Counter、Users 空数组;_id 与 Waline objectId ID 映射,父子评论 pid 关联断裂;若 Waline 后台导入接口持续丢失数据、层级错乱,可直接写入数据库绕过解析器:
waline-import.json,复制 data.Comment 数组内全部评论对象;wl_Comment 数据表,批量插入复制的评论数组;生成精美分享图或复制链接,与更多人分享本文。
最后更新于 ,距今已过 94 天
部分内容可能已过时