Twikoo 的样式还是跟 shadcn 不太匹配,研究了下最后还是决定自己写一个前端。但 Twikoo 的 api 兼容性不好,故换成了 Artalk.
数据迁移
Artalk 官方有数据迁移工具,能够把 Twikoo 评论数据转换为 Artrans 数据,但实测效果不怎么好。
具体而言,Twikoo 和 Artalk 的回复存储数据模式不同,Twikoo 为平铺,Artalk 为嵌套,而转换工具并不能很好的处理这一点,导致回复有些乱。
此外,我还有一些特定需求(统一 tailing slash 等),故而就需要自己转换数据。
好在现在乃是 Vibe Coding 时代,DeepSeek 很快就给了我一个符合我要求的转换脚本:
PYTHON
import json
import re
from datetime import datetime, timezone, timedelta
# China timezone (UTC+8)
CN_TZ = timezone(timedelta(hours=8))
def ts_to_iso(ts_ms):
"""Convert millisecond timestamp to ISO 8601 with +0800 timezone."""
dt = datetime.fromtimestamp(ts_ms / 1000.0, tz=CN_TZ)
return dt.strftime("%Y-%m-%d %H:%M:%S +0800")
def html_to_markdown(html_str):
"""Convert HTML comment content to Markdown, with special emoji handling."""
s = html_str
# 1. Handle emoji images FIRST (before other img handling)
# Pattern 1: <img alt=":emoji_name:" src="..." class="tk-owo-emotion">
# Pattern 2: <img loading="lazy" title="..." class="tk-owo-emotion" alt="..." src="...">
# Pattern 3: <img alt="" src="..."> (waline emoji, no class but empty alt)
# Pattern 4: <img alt="no-colon-emoji" src="..." class="tk-owo-emotion">
def replace_emoji_img(match):
tag = match.group(0)
# Extract alt
alt_match = re.search(r'alt="([^"]*)"', tag)
alt = alt_match.group(1) if alt_match else ""
# Extract src
src_match = re.search(r'src="([^"]*)"', tag)
src = src_match.group(1) if src_match else ""
# Strip surrounding colons from alt
emoji_name = alt.strip(":")
return f'<img atk-emoticon="{emoji_name}" src="{src}">'
# Find all img tags that are emojis (have tk-owo-emotion class or are likely emoji images)
s = re.sub(r'<img[^>]*class="[^"]*tk-owo-emotion[^"]*"[^>]*/?>', replace_emoji_img, s)
# Also handle emoji images without the class but with emoji-like alt (surrounded by colons)
s = re.sub(r'<img[^>]*alt=":[^"]*:"[^>]*/?>', replace_emoji_img, s)
# 2. Convert links: <a href="...">text</a> -> [text](url)
s = re.sub(r'<a\s+href="([^"]*)"[^>]*>([^<]*)</a>', r'[\2](\1)', s)
# 3. Convert <code>text</code> -> `text`
s = re.sub(r'<code>([^<]*)</code>', r'`\1`', s)
# 4. Convert <del>text</del> -> ~~text~~
s = re.sub(r'<del>([^<]*)</del>', r'~~\1~~', s)
# 5. Convert <em>text</em> -> *text*
s = re.sub(r'<em>([^<]*)</em>', r'*\1*', s)
# 6. Convert <strong>text</strong> -> **text**
s = re.sub(r'<strong>([^<]*)</strong>', r'**\1**', s)
# 7. Convert <br> or <br/> -> newline
s = re.sub(r'<br\s*/?>', '\n', s)
# 8. Convert <hr> or <hr/> -> ---
s = re.sub(r'<hr\s*/?>', '\n---\n', s)
# 9. Convert heading tags
for i in range(1, 7):
s = re.sub(rf'<h{i}[^>]*>([^<]*)</h{i}>', lambda m, level=i: '#' * level + ' ' + m.group(1).strip(), s)
# 10. Convert paragraph: <p>text</p> -> text\n\n
s = re.sub(r'<p[^>]*>\s*', '', s)
s = re.sub(r'\s*</p>', '\n\n', s)
# 11. Remove any remaining block-level wrapper artifacts
# Keep <span class="heimu"> as is (no markdown equivalent for spoiler)
# Keep <img atk-emoticon="..."> as is (already converted)
# 12. Unescape common HTML entities
s = s.replace('<', '<')
s = s.replace('>', '>')
s = s.replace('&', '&')
s = s.replace('"', '"')
s = s.replace(''', "'")
# 13. Clean up: trim each line, collapse multiple blank lines
lines = s.split('\n')
cleaned_lines = []
prev_blank = False
for line in lines:
stripped = line.rstrip()
is_blank = (stripped == '')
if is_blank and prev_blank:
continue
cleaned_lines.append(stripped)
prev_blank = is_blank
result = '\n'.join(cleaned_lines).strip()
return result
def get_page_key(url):
"""Ensure page_key ends with trailing slash."""
if not url:
return url
if not url.endswith('/'):
return url + '/'
return url
def main():
input_file = 'path_to_input_file'
output_file = 'path_to_output_file'
with open(input_file, 'r', encoding='utf-8') as f:
twikoo_data = json.load(f)
# Step 1: Build mapping from original _id -> new sequential id
id_map = {} # old _id -> new id
artrans_list = []
for idx, item in enumerate(twikoo_data):
new_id = idx + 1
old_id = item.get('_id', '')
id_map[old_id] = new_id
# Step 2: Convert each item
for idx, item in enumerate(twikoo_data):
new_id = idx + 1
old_id = item.get('_id', '')
# Build the artran object
artran = {}
artran['id'] = str(new_id)
# Handle rid: twikoo pid -> artrans rid (using new ids)
pid = item.get('pid', '')
if pid and pid in id_map:
artran['rid'] = str(id_map[pid])
# Convert content from HTML to Markdown
raw_comment = item.get('comment', '')
raw_comment = raw_comment.strip()
artran['content'] = html_to_markdown(raw_comment)
artran['ua'] = item.get('ua', '')
artran['ip'] = item.get('ip', '')
# is_collapsed always false; is_pending from isSpam
artran['is_collapsed'] = 'false'
artran['is_pending'] = 'true' if item.get('isSpam', False) else 'false'
# Timestamps
created_ts = item.get('created', 0)
updated_ts = item.get('updated', 0)
artran['created_at'] = ts_to_iso(created_ts)
artran['updated_at'] = ts_to_iso(updated_ts)
# User info
artran['nick'] = item.get('nick', '')
artran['email'] = item.get('mail', '')
artran['link'] = item.get('link', '')
# page_key from url, with trailing slash
artran['page_key'] = get_page_key(item.get('url', ''))
artrans_list.append(artran)
# Write output
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(artrans_list, f, ensure_ascii=False, indent=2)
print(f"Conversion complete! {len(artrans_list)} comments converted.")
print(f"Output written to: {output_file}")
# Print some stats
root_comments = sum(1 for a in artrans_list if 'rid' not in a)
reply_comments = sum(1 for a in artrans_list if 'rid' in a)
print(f"Root comments: {root_comments}")
print(f"Reply comments: {reply_comments}")
if __name__ == '__main__':
main()
这个脚本会顺便把 html 转换为等效 markdown,方便前端做防恶意代码。
然后正常搭建 Artalk 后端并导入数据即可。
样式
用的 React Island(整个博客都迁移到 Astro 上了)。
配合一些 shadcn 组件,写的很快。毕竟通信方面不用发愁,Artalk 有完善的 http api,实在不行就翻一下前端源码,总能搞定。
验证码卡了我几下,但还好,顺利解决。
想看代码的可以在Kapium-Neo仓库直接翻出来。
题外话
好水啊 awa