跳到主要内容
2026-07-13
5 分钟
...
编程

文章摘要

博主因Twikoo样式与shadcn不匹配,改用Artalk并自行编写了数据迁移脚本(借助DeepSeek),同时使用React Island和shadcn组件重构了评论前端。

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('&lt;', '<')
    s = s.replace('&gt;', '>')
    s = s.replace('&amp;', '&')
    s = s.replace('&quot;', '"')
    s = s.replace('&#39;', "'")

    # 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

评论迁移及样式更新
作者
序炁
发布于
2026-07-13
更新于
2026-07-13
许可协议
BY - 署名NC - 非商业性使用SA - 相同方式共享
QQ空间微博豆瓣XFacebookLinkedIn复制链接