Back

把neodb标记、嘟嘟、博客都收进ob来

写完才意识到这东西性质是写给自己的总结文档啊!大家来看的话可能很辛苦……提前致歉了!

起因是看到塔塔和羊羊的博客:
玩具箱 | 个人大生活家三件套之2026篇 | 小球飞鱼
Obsidian记录大一统

我非常喜欢看朋友们的赛博玩具箱系列!Ob 本身又是一个……似乎地图无尽冒险无止的玩具箱。得知可以借用 AI 的力量,制作 python 脚本就可以做到这种程度的赛博手帐,我非常心动!
只是对于我来讲,把嘟嘟搬运到 ob 除了检索方便外无甚他用,何况彼时我 ob 的文件夹结构还是捋不清的毛线团大挑战,我心动一下就算了。

直到 7 月中我在理 ob 毛线团时突然萌发了一个想法:我有一个专门用来发游戏的 misskey账号,还有一个在长期维护的 notion 游戏库(包含游戏基本信息、游玩状态及时间、购买价格等),那如果我把它们一股脑搬到 ob,就可以在某个具体的游戏类目下,看到我所有的游玩感受了不是吗!
此乃初心……然而在行动的过程中愈发有了野望,到最后已经变成:我要把已经接触过的作品、和为它们写过的感想全部放到 ob 里一起看!

这个过程确实进行得非常之不顺利 hhhh 以至于现在我也不能说是!大功告成!只是心态上感觉差不多了,于是来写这篇博客作为记录。大概只是我做了什么,以及我回顾时觉得怎么做会更好,这样的东西。
也因为是我闭门造车的结果,可能琐碎之余又没有太详尽。如果发现我的错误或者可以优化的步骤,非常欢迎您告诉我!

以下会用到:obsidian、python、会写脚本的 AI、VS code。

使唤 AI 写 python 脚本的经验

回顾整个过程,感觉自己仿佛一只绝望的猴子,从 AI 那里要来各种按钮,反正它做好了我就敲一下,敲出来不对就回去找它大喊大叫……
沟通、测试、再讨论,一再重复。我感到从拿到脚本这一过程本身就获得了太多教训。

阅读这部分之前,建议优先参考羊羊的脚本,注释十分详尽。是我一点都不想看代码,才会把一部分笔记拿出来记 hhhhhh
我也想过在脚本确认后,让 AI 给我一份同等规格的注释版,但即使到了写博客的此时,脚本都不算“被确认”,有许多待改未改。所以我最终采取了这种外行笨蛋的做法。

首先,脚本执行之前对要处理的文件夹做备份,并单独建一个测试文件夹。在确认脚本满足需要之前,都只让它染指到测试文件。
我备份得很复杂。现在会把备份压缩包存放在脚本文件夹下,意指它是当前脚本使用之前的备份,命名也比较长,真的很怕我后来分不清谁是谁。同时会在测试文件夹同路径下放备份压缩包,方便脚本处理失败时,立刻删除测试文件夹、解压备份成新的测试文件夹、重来。
至此我已经备了三次了,我也感觉自己比较有病。可能有更好的数据备份和版本管理方案吧,我这种杞人忧天故愚公移山的,大家领会一下意识即可。

其次,写脚本时可以稍微试着和 AI 磨合出一套脚本规范(?),我实践总结到的有:

  • 配置区置顶。所有路径、开关等配置项集中在文件上方,方便修改处理;
  • 控制台输出的同时,同步输出独立的日志文件。这里可以针对每个脚本的作用临时规定一下怎么写,我用的比较多的是:列出脚本处理过的文件名、处理的文件数量,有时候也输出每个文件的处理次数。
  • 设置测试开关与限额。例,导出嘟嘟的脚本在测试期间设置最大拉取条数,降低服务器负担;导出嘟嘟的脚本执行时会生成一份 archive_synced_ids.txt,是下次执行的时候参考去重用的。测试时得一遍遍删除,太麻烦了,让 AI 写了个开关,默认不生成。

经过以上两点,也很容易形成一个如何快速恢复存档重新测试的标准流程。

最后……没什么了 hhhh 只是跟大家讲一下,我的脚本记录很完善了!
现在是脚本们单独放一个大文件夹,同时在 ob 里存档脚本的名称、目的、执行步骤(会详细地写配置内容方便复制粘贴)、说明(会写脚本的执行逻辑、踩过的坑)、代码、致谢(我对 AI 如此有礼貌,AI 统治人类时一定会回报我自由(?。
感觉记录做到这里对明天失忆的自己也有了一丝信心。

需要说明的是,这篇博客非常遗憾地将会尽可能少地涉及代码内容,因为

  1. 我晕代码(?
  2. 如下所见,脚本怎么处理初始信息是重点,脚本怎么拉取决于人怎么记, 也就是适用于我个人习惯的脚本不一定适用于他人;
  3. 我对 AI 提要求时东一榔头西一棒子,最后几乎是写个脚本 A 再补个脚本 B 这样勉强地运转起来……实在不能展示啊! !!

跑一个基本款项目

好的,我们要开始了!

想要什么

回归我的目标,什么叫……“要把已经接触过的作品、和为它们写过的感想全部放到 ob 里一起看”?

我近两年的作品感想一般是在毛象随手一嘟,或者偶尔博客写到。主账号在发日常之外还会随机串各种媒介类型作品的嘟嘟:动画、影视剧、网文、电影。所以把毛象嘟嘟和博客都收拢回来就可以!
注:网文部分没有处理。估计从各大小说平台里拿作品基本信息是个比较麻烦的事情,可能以后有心力会再考虑。

但怎么把同一个作品的信息与感想连接起来呢?
依赖的是 ob 的双链功能。“双链”指双向链接(Bi-directional Linking),即在数字笔记或网页中,当页面 A 链接到页面 B 时,页面 B 会自动生成一个反向链接指向页面 A。
以上来自谷歌搜索

因此计划是,要建三大文件夹,分别放这些文件:作品基本信息(即媒体库)、可能会提到作品的嘟嘟、可能会提到作品的博客。
其中媒体库文件们以作品名称为标题。当嘟嘟或者博客提到这一作品名称时,设置作品名称为双链格式。当我点击嘟嘟/博客中的某一作品名时,可以跳转到其基本信息页,而在基本信息页,也可以看到反向链接列表——我什么时候提到它说了什么。当然也可以通过反向链接列表跳转回去。
这样就把作品基本信息和个人感想连接了起来。

此处让作品名去承担类似数据库主键的功能是大有问题的 hhhh 主键需要唯一性。当我们依赖脚本去建立链接时,如果出现多个同名作品,让脚本怎么办呢?
我知道这件事,但我只是做懒人版个人信息库,我不可能去给它建什么唯一性 id 啊!所以我要错误地进行下去。下文会看到我不得不为此打许多补丁。

建立媒体库

Ob 一个常见玩法就是建立媒体库,早几年很流行搞海报墙。上上次我就是因为搞不成才留下一地毛线团的 hhhh 总之,在 ob 里怎么建一个包含媒体基本信息的媒体库已经是一个早期问题,如今市面上存在诸多解法,一般是通过插件实现从豆瓣这类标记平台获取信息:可以把历史标记信息搬运甚至定期搬运过来,也可以做到联通平台,敲下一个文件名,如果它是平台可搜寻到的作品时,可以根据模板自动拉取相关信息。
未深入了解,故不作插件推荐。

我使用 neodb 标记作品。所以让 AI 写 python 脚本处理 neodb 的导出(ndjson)。未来仍然使用 neodb 打标记,用 API 做数据同步。
我不会在 neodb 上标记游戏,故此处也没有处理游戏标记。

直接用 API 或许也是可以的,我这样是因为

  1. 我笨蛋了忘了 API;
  2. 想先看看 neodb 对不同类型的作品都是怎么做分类,以及不同类型的作品都包含哪些信息。以此来决定我要在 ob 里留什么信息。下意识觉得从存档那看这些比较方便。

好的,我们解决了从哪里拿数据,拿哪些数据的问题,现在决定数据放哪里,怎么写。
脚本中我(记得)做(过)的处理:

  • 仅导入标记为看过的内容。
  • 考虑到存在同名改编作品,Media 文件夹下建不同媒介的次级文件夹来分隔。Book、Movie、Show、Game。
  • 未来同步新的 neodb 数据时,如某个已导入作品的数据变更,只做智能合并而不会全量覆盖。
  • 将 neodb 的 type 处理为 neodb_type。Ob 文件中的笔记属性是全仓库共用的,像 type\status 这种常用属性如果被不同的数据库共用,值也会互相污染。此处是为避让 hugo 博客 ymal 中的 type 字段。
  • Neodb 存档中 genres 的值为英文,在脚本中添加中英对照词典翻译。如存在词典中不包含的词,则原样按照英文输出。
  • 简介默认折叠。您看过书的简介吗我差点说把它们全删了得了。
以下是Gemini给我的,处理Neodb存档的注释版脚本:
import re
from pathlib import Path
from collections import defaultdict

# ================= 配置区 =================
# 请将下方路径替换为你自己电脑上的实际路径
# 注意:Windows 系统路径前面的 r 不要删掉,它用来防止路径中的反斜杠被转义

# 1. 作品元数据文件路径 (即包含作品信息的 catalog.ndjson)
CATALOG_FILE = r"C:\Your\Path\Here\catalog.ndjson"

# 2. 用户行为数据文件路径 (即包含你的打分、短评、状态的 journal.ndjson)
JOURNAL_FILE = r"C:\Your\Path\Here\journal.ndjson"

# 3. 输出大文件夹路径 (脚本运行后,会在这个路径下自动生成 Book/Movie/Show 三个文件夹)
OUTPUT_DIR = r"D:\Your\Obsidian\Vault\Media"
# ==========================================

# 通用流派(标签)翻译字典
# NeoDB 导出的流派通常是英文,这里做一个简单的映射,方便在 Obsidian 里用中文检索
# 如果导出的标签不在这个字典里,脚本会自动保留原有的英文
GENRE_MAP = {
    "action": "动作", "adventure": "冒险", "animation": "动画", "anime": "动画",
    "biography": "传记", "comedy": "喜剧", "crime": "犯罪", "documentary": "纪录片",
    "drama": "剧情", "family": "家庭", "fantasy": "奇幻", "history": "历史",
    "horror": "恐怖", "music": "音乐", "musical": "歌舞", "mystery": "悬疑",
    "romance": "爱情", "sci-fi": "科幻", "science fiction": "科幻", "short": "短片",
    "sport": "体育", "thriller": "惊悚", "war": "战争", "western": "西部",
    "donghua": "国创"
}

# ================= 辅助函数区 =================

def sanitize_filename(name):
    """
    清洗文件名:剔除 Windows/Mac 系统不允许出现在文件名中的特殊字符(\ / * ? : " < > |)
    防止因为作品名字里带有冒号等符号导致文件创建失败。
    """
    name = str(name)
    return re.sub(r'[\\/*?:"<>|]', "", name).strip()

def escape_yaml(text):
    """
    YAML 防报错处理:如果文本中包含双引号或换行符,会破坏 Obsidian 的 Frontmatter 结构。
    这里将双引号转义,并把回车换行替换为空格。
    """
    if not text: return ""
    return str(text).replace('"', '\\"').replace('\n', ' ').replace('\r', '')

def translate_genres(genre_list):
    """
    翻译流派:遍历作品的标签列表,去上面的 GENRE_MAP 字典里找对应的中文。
    """
    if not isinstance(genre_list, list): return genre_list
    return [GENRE_MAP.get(str(g).lower(), g) for g in genre_list]

def format_list(item_list):
    """
    列表格式化:将 Python 的列表安全地转换为 YAML 识别的数组格式,例如 ["标签1", "标签2"]。
    """
    if not item_list: return "[]"
    if isinstance(item_list, list):
        items = [f'"{(escape_yaml(i))}"' for i in item_list if i]
        return "[" + ", ".join(items) + "]"
    return f'["{escape_yaml(item_list)}"]'

# ================= 核心执行逻辑 =================

def process_neodb():
    cat_path = Path(CATALOG_FILE)
    jou_path = Path(JOURNAL_FILE)
    out_path = Path(OUTPUT_DIR)

    # 1. 前置检查:确保配置文件路径正确
    if not cat_path.exists() or not jou_path.exists():
        print("❌ 找不到 JSON 文件,请检查路径。")
        return

    # 2. 初始化分类文件夹:自动在目标路径下生成 Book, Movie, Show
    # 注:因本人不在 NeoDB 记录游戏,故此处不包含 Game 分类
    dirs = {'book': out_path / "Book", 'movie': out_path / "Movie", 'tv': out_path / "Show"}
    for d in dirs.values():
        d.mkdir(parents=True, exist_ok=True)

    # 3. 收集用户行为数据 (Journal)
    # 使用 defaultdict,如果遇到还没记录过的作品,自动初始化一组空数据
    user_records = defaultdict(lambda: {'status': '', 'rating': '', 'comment': '', 'date_planned': '9999-12-31', 'date_completed': ''})

    # 读取 journal.ndjson,逐行解析
    with open(jou_path, 'r', encoding='utf-8') as f:
        for line in f:
            if not line.strip(): continue
            row = json.loads(line)
            t = row.get('type')
            
            # 解析:标记状态 (看过/想看等)
            if t == 'ShelfMember':
                content = row.get('content', {})
                uid = content.get('withRegardTo') or row.get('withRegardTo')
                status = content.get('status') or row.get('status')
                if uid: user_records[uid]['status'] = status
            
            # 解析:用户打分
            elif t == 'Rating':
                content = row.get('content', {})
                uid = content.get('withRegardTo') or row.get('withRegardTo')
                if uid: user_records[uid]['rating'] = content.get('value', '')
            
            # 解析:用户短评
            elif t == 'Comment':
                content = row.get('content', {})
                uid = content.get('withRegardTo') or row.get('withRegardTo')
                if uid: user_records[uid]['comment'] = content.get('content', '')
            
            # 解析:时间线 (想看日期、看完日期)
            elif t == 'ShelfLog' or ('item' in row and 'timestamp' in row):
                content = row.get('content', {})
                uid = row.get('item') or row.get('withRegardTo') or content.get('withRegardTo')
                log_status = row.get('status') or content.get('status')
                ts = row.get('timestamp', '')[:10] if row.get('timestamp') else '' # 截取 YYYY-MM-DD
                
                if not uid or not ts: continue
                # 如果是“想看”,记录最早的时间
                if log_status == 'wishlist' and ts < user_records[uid]['date_planned']:
                    user_records[uid]['date_planned'] = ts
                # 如果是“看过/读过”,记录最晚的时间
                elif log_status == 'complete' and ts > user_records[uid]['date_completed']:
                    user_records[uid]['date_completed'] = ts

    # 4. 过滤规则:只提取标记为“看过/完成 (complete)”的作品 ID
    completed_ids = {uid for uid, data in user_records.items() if data['status'] == 'complete'}

    # 5. 组合数据并生成 Markdown 文件
    count = 0
    with open(cat_path, 'r', encoding='utf-8') as f:
        for line in f:
            if not line.strip(): continue
            item = json.loads(line)
            uid = item.get('id')
            
            # 如果这件作品不在“已完成”列表里,直接跳过
            if uid not in completed_ids: continue
            
            category = item.get('category')
            # 如果属于不处理的分类(如 podcast, game 等),直接跳过
            if category not in dirs: continue
            
            title = item.get('display_title') or item.get('title')
            if not title: continue

            # 取出之前解析好的个人打分、评价、日期等数据
            my_data = user_records[uid]
            planned = "" if my_data['date_planned'] == '9999-12-31' else my_data['date_planned']
            completed = my_data['date_completed']
            comment = my_data['comment']
            rating = my_data['rating']
            
            # 处理文件名和路径
            safe_title = sanitize_filename(title)
            file_path = dirs[category] / f"{safe_title}.md"
            
            # 处理简介格式
            brief = item.get('description') or item.get('brief') or ""
            brief = brief.replace('\r\n', '\n')
            
            neodb_url = uid
            aliases = format_list(item.get('orig_title', []))
            
            raw_genres = item.get('genre') or item.get('tags') or []
            translated_genres = translate_genres(raw_genres)
            genres = format_list(translated_genres)

            # --- 开始拼接 Markdown 文件内容 ---
            md = "---\n"
            md += f'title: "{escape_yaml(title)}"\n'
            md += f'aliases: {aliases}\n'
            md += f'category: "{category}"\n'
            md += f'genres: {genres}\n'
            md += f'neodb_url: "{neodb_url}"\n'
            md += f'cover: "{item.get("cover_image_url", "")}"\n'
            
            # 针对书籍的专属属性
            if category == 'book':
                md += f'type: "book"\n' # 刻意避让 Hugo 的 type 字段,使用了独立分类属性
                md += f'author: {format_list(item.get("author"))}\n'
                md += f'translator: {format_list(item.get("translator"))}\n'
                
                # 尝试通过正则从作者字段提取国籍(如 [日]太宰治 提取出“日”)
                authors = item.get('author', [])
                country = ""
                if authors:
                    match = re.search(r'\[(.*?)\]', str(authors[0]))
                    if match: country = match.group(1)
                md += f'country: "{country}"\n'
                
                publishers = item.get('publisher', [])
                pub = publishers[0] if isinstance(publishers, list) and publishers else publishers
                md += f'publisher: "{escape_yaml(pub)}"\n'
                md += f'publish_year: {item.get("pub_year", "")}\n'
                md += f'pages: {item.get("pages", "")}\n'
                md += f'isbn: "{item.get("isbn", "")}"\n'
                md += f'format: []\n'
                md += f'status: "读过"\n'
                md += f'date_planned: {planned}\n'
                md += f'date_read: {completed}\n'
                
            # 针对影视的专属属性
            elif category in ['movie', 'tv']:
                md += f'type: "{category}"\n'
                md += f'director: {format_list(item.get("director"))}\n'
                md += f'year: {item.get("year", "")}\n'
                
                areas = item.get('area', [])
                area = areas[0] if isinstance(areas, list) and areas else areas
                md += f'country: "{escape_yaml(area)}"\n'
                
                if category == 'movie':
                    md += f'duration: {item.get("duration", "")}\n'
                else:
                    md += f'show_type: "{item.get("type", "")}"\n'
                    # 处理电视剧的季数
                    if item.get("type") == "TVSeason":
                        md += f'season: {item.get("season_number", "")}\n'
                    else:
                        md += f'season: ""\n'
                    md += f'episodes: {item.get("episode_count", "")}\n'
                    
                md += f'status: "看过"\n'
                md += f'date_planned: {planned}\n'
                md += f'date_watched: {completed}\n'

            # 通用收尾属性
            md += f'awards: []\n'
            md += f'rating: {rating}\n'
            md += "---\n\n"
            
            # --- 正文部分 ---
            # 使用 Obsidian 的 Callout 语法将长简介默认折叠,保持页面清爽
            md += "> [!quote]- 简介\n"
            for p in brief.split('\n'):
                if p.strip(): md += f"> {p}\n"
                
            md += "\n## 短评\n"
            md += f"{comment}\n"
            
            # 将拼接好的内容写入物理文件
            with open(file_path, 'w', encoding='utf-8') as out_f:
                out_f.write(md)
            count += 1

    print(f"🎉 导出完成!共成功处理并生成 {count} 个文件。")

if __name__ == "__main__":
    process_neodb()
说不定是大家都知道的小建议:对于由脚本维护的文件们,做一下数据隔离,指尽量不要在这些文件里写其他东西。我很忧虑,都放在一个篮子里,万一被不小心犯错的脚本一网打尽了怎么办……

嘟嘟导入并连接

关于怎么把毛象嘟文挪进 ob 里,请参照开头指路的博客。
感谢塔塔!感谢羊羊!

塔塔博客的脚本处理的是 mastodon 账号,羊羊博客的脚本处理的是 misskey 账号。可以按需参考。
mastodon 账号与 misskey 账号似乎很多方面都不同,处理起来也不一样,在和 AI 沟通这步的时候,一开始就要说清楚,是 mastodon 账号还是 misskey 账号。

对于我这个情况,仅仅完成导入是不够的。
脚本需要知道哪些嘟嘟是作品感想类,它们各自都是关于具体哪个作品的感想,才能据此建立双链。

这一步非常看嘟嘟习惯!就我观察,很多象友写这类嘟串比我有条理很多。Cw 标题、自建话题、或者引用格式等,我觉得都可以作为对“作品感想类嘟嘟”的识别。
我的情况是这样的:

  1. 所有被《》包裹的内容必然是作品名。
    仅仅对我是必然,如果象友会对提及的长文章标题、播客标题、博客标题也有打《》,就要注意这个情况。
  2. 所有 cw 标题,如果与媒体库中文件名称同名,则必然是作品名。

保险起见——人的记录是非常随性的,心里估计大概是这么个记法吧,但实际执行情况可能有所不同!建议先让 AI 写一个脚本——像我的话,让它把所有被《》包裹的内容、所有 cw 标题都拉出来。这样整体过一下,确认自己的执行情况,就可以最大程度地预防错漏。

如果存在错漏,或者嘟嘟作品名不规范的情况但又实在想要找回,可以考虑:

  • 可以在媒体库中维护别名 Aliases 这一属性;
  • 建一套字典方便脚本做匹配,像这样:
CW_WORKS_MAP = {

    "黑街GANGSTA": "黑街 (GANGSTA匪徒)",

    "Miu404": "机动搜查队404",

    "格林德沃之罪": "神奇动物:格林德沃之罪",

    "小偷家族": "小偷家族"

}

此处我使用了方法二,3.2 使用了方法一。当然也可以结合使用。

到此,对 cw 标题的处理似乎有些复杂。整理一下。
获取到嘟文 cw 标题后,

  • 如 cw 标题被包含在字典,那么它是作品名,对其设置双链格式。
  • 如 cw 标题不被包含在字典,那么先把它看作一个平平无奇的 cw 标题,对其包裹 [] 格式,也就是我自定义的 cw 标题格式。
  • 如 cw 标题中如果存在被 《》 包裹的文本,那么它是作品名,对其设置双链格式。
  • 如 cw 标题中是被 《》 包裹的文本,那么它是作品名,对其设置双链格式且不再套上 []

另外,考虑到存在同名改编作品……在执行双链替换前,检查媒体库,如果存在两个及以上的同名文件,脚本将跳过对该词的双链处理。他日我回顾到这一作品,再自行挂上正确指向的双链。这样做让我多了一步工作量,但我暂时没有想到别的办法。

就此已经完成了关键一步。
接下来可以按照自己的个性化需要修改写入的格式。

  • 过滤回复他人账号的嘟嘟。
  • 嘟嘟导入 Journal 文件夹,路径设置为:(例)Journal/2026/2026-07/2026-07-24。Ob 的 Journal 文件生成路径也可以修改成如上形式:在 ob 核心插件-日记的设置页面,日期格式选择 自定义,自定义格式写 YYYY/YYYY-MM/YYYY-MM-DD 即可实现。
  • 考虑到我同时使用多个毛象账号,那同一天——同一个文件中可能会出现来自多个毛象号的嘟文。因此规定在写入该账号内容时,先挂一个二级标题,即:## @用户名@站点域名 嘟嘟。第 N+1 个账号嘟文导入时,只会去寻找/自建对应的标题,不会影响其他 N 个已导入账号的嘟文,也不会影响我自己写进 Journal 里 放在其他标题下的内容。
  • 嘟文以大纲格式写入,每条嘟文一个节点。所有嘟文平级,不会按照回复层级缩进。嘟文按照时间顺序。
  • 每条嘟文格式为:(例)
- **15:42** [🔗原嘟](https://...) [CW标题] 正文。
    
    ![媒体|300](图片链接1) ![媒体|300](图片链接2)
  • (据 Gemini 讲)为把“CW 标题、多段落正文、中间的空行、底部的图片”全部归属于同一个时间列表节点,对时间行以下的所有内容进行了强制缩进。
    • 时间头文本:- ** 15:42 ** [🔗原嘟](链接) ⁠(位于最左侧,无缩进)。
    • 正文与媒体:正文中的每一行(包括段落之间的纯空行),开头都会被脚本强制注入 4 个空格。
  • 时区纠偏。 API 传回的时间默认是 UTC(零时区),需要脚本修正。
  • 如上所见,所有不被脚本视为作品名、或文本中未包含作品名的 cw 标题,用 [] 包裹作强调。因为我想保留哪条嘟嘟做了折叠、折叠标题是什么这些信息;同时,如果有漏网于双链的作品名,一目了然!可以再人工补漏……
  • 图片写为 ![媒体|300]链接,告诉 ob 在渲染图片时把宽度限制在 300 像素。不然导入后会占据太多版面。
脚本是基于当前使用账号的 API 作导入,导入媒体为链接格式。账号注销后,媒体将不可见。
Gemini 给我的方案是:注销前导出并找到该账号的媒体信息,放到 ob 本地仓库;找 AI 写脚本:把 Markdown 里的网络链接 ⁠![](https...) ⁠ 自动替换成 Obsidian 的本地双链格式 ⁠ ![[图片名.jpg]] ⁠即可。

博客导入并连接

处理博客文件可能是最轻松的一步!只是把它们复制粘贴过来,执行一个加双链的脚本即可。

如果您和我一样有做游戏库的需求,建议做完游戏库再进行这一步。没有游戏基本信息文件,博客对其设置的双链仅仅是一个虚拟的指向,单击一下 ob 还会“贴心”地新建以双链文本为标题的空白文件。
当然游戏库维护好后,ob 会自动连接,但这需要双链文本与游戏名称完全一致。不要太低估人类记录之随便!最好先做库再来链。

与嘟嘟脚本的逻辑不同,博客文件寻找的作品名是:标题、加粗格式下被 《》 包裹的文本。
这是因为我的博客中有许多“仅仅是提一下”的提及,比如年终总结表格提到的作品、援引他人文章时的链接、正文中并未展开描述的仅提及……对作品主体的内容没有生发感受性表达,在我的标准里没有必要做链接,因此通过格式作了排除。
除此之外,与嘟嘟处理脚本一致,都需要对存在多个同名的作品名跳过处理。

麻烦的是,迁移导致的后续问题。
我打算借此机会把写博客一事转移到在 ob 中进行,写完后复制到 hugo 仓库——ob 写博客比 vs code 舒适太多。
首当其冲的问题就是:文件在本地存放了两份,而我对此没有办法 hhhh
其次也会在复制粘贴的过程中增加一些工作量:

  • 接下来写博客,要注意提及作品时手动打双链。但发布博客时,大家没必要看到。所以我让 AI 写了一个去双链的脚本备着,粘贴过去就敲一下。
  • Ob 中可以 enter 换行,但 hugo 好像理解不了这个。过去我都是在 md 文件里狂塞 <br> ……摆摆及时地教我:在 config.yaml 下设置 hardWraps: true,就可以让 hugo 理解换行。太好了。
    注意:过去博客中的 <br> 也需要删掉,否则 hugo 会帮我换两行。
如果想要更丝滑,当然最好是在 ob 新建博客文件时,就自动带出 hugo 建 posts 时的那套 yaml。
我做了 ob 模板,但时间日期的部分带不出来,不解……

还有游戏专用嘟

前文提及,我有一个专门用来发游戏的 misskey账号,现在来处理这个账号。其实这 part 才是我的初始工程,可能描述上也最混乱。

我会使用一部分示例内容,涉及我在玩某个游戏时的具体感想。此处会用到的示例内容我认为均不涉及主线剧透。您可以决定是否要信任我 hhhh
(其实好多话我现在也看不懂当时什么意思了(?

建立游戏库

我的游戏库是在 notion 维护的,故使用 ob 插件 importer 直接导入。数据库的字段将自动被写为 ob 文件的笔记属性。

注意:

  • importer 导入时,如果某条信息中某个字段值为空,则该字段不会被导入。我不是很介意这个问题就没管。
    其中,Notion 的数据库总页面虽然导入了但不参与以下内容处理。
  • 游戏库要记得维护游戏别名。
    • 除了游戏约定俗称的简称,有时候自己的嘟嘟也可能打错符号或空格就此生造别名……最好检查一遍。
    • 小心包含冒号的别名,录入时用引号包裹,例:
aliases:
  -  "Shadow Tactics: Blades of the Shogun"
  -  "Shadow Tactics"

嘟嘟导入并连接

首先是,脚本需要区分游戏嘟嘟与日常嘟嘟。我偶尔也会说些游戏无关的话,所以脚本最初需要做的识别类型并分流处理。

  • 该条嘟嘟所属的初始嘟文 cw 标题,与 game 库中文件的文件名称或属性中⁠aliases⁠的值匹配时,判断其为游戏嘟,按下文逻辑处理;
  • 除此之外,判断其为日常嘟,写入路径与写入格式参照 2.3

这样的处理方案是根据我的游戏嘟串法做的:

  • 玩游戏之初,发原初的嘟文(?)。关于这个游戏的所有感想都通过回复原嘟文形成嘟串记录下来。
  • 原初的嘟文折叠标题写游戏名称,内容先随便填个 ,这样嘟串下的嘟文折叠标题可以继承它的,能少打字。
  • 进行分串行动。不同游戏分法不同,例,乙游的男主路线、p5r 的宫殿,都是不同的“章节”。我会注意让各自章节的内容成串,平行挂在原初的嘟文下。

如图:

嘟串示例
嘟串示例

橘红色框的 cw 标题是游戏名,位于原初的嘟文;黄色框的 cw 标题看作章节名,也是开始分串的地方。

我当然会希望嘟文进入 ob 时保留这样的层级关系,因此倾向于把某个游戏的嘟嘟都放到同一页下——接下来将称这页为游玩日志。而且游戏方面我经常会嘟蛮多,只要提到游戏名就做双链的话,ob 的反向链接列表会非常长……反而不方便查看。按照游戏对嘟文分类、做统一管理,再去双链就会清爽很多。

此处存在一个对应关系:
原初嘟文 cw 标题将被脚本识别为“游戏名”,用来帮助脚本匹配 media/game 下对应的游戏基本信息文件、确定游玩日志的文件标题;
原初嘟下回复嘟文的 cw 标题将被脚本识别为“章节名”,用来作为游玩日志的二级标题,并根据回复关系把对应的嘟嘟串塞到对应的标题下。

最后的实现效果如图:

游玩日志示例
游玩日志示例

左侧导航的显示效果来自 Obsidian 插件 Notebook Navigator。
以上嘟嘟发布时大多有加 cw ,脚本处理时把 cw 标题取出作为二级标题了。

基本延续了 2.3 中的对他人回复过滤、媒体文件缩小、嘟文以大纲格式写入等,特别处理在:

  • 顶部增加通向 media/game 库对应的游戏信息页的双链。此处是别名双链,即显示文本与实际双链文本不同。我的方案里,文件标题与第一个次级标题大概率都会是游戏名,双链再重复一次就太难看了,因此所有的顶部双链写为:> [!info] [[游戏名称|Teleport to Save Point]]
  • “章节名”作为游玩日志的二级标题写入;
    • 找到当前嘟文的 cw 标题,作为它的“章节名”;
    • 当前嘟文未折叠,则顺着回复层级往前追溯,直到找到最近一次被折叠嘟文的 cw 标题,作为它的“章节名”。
  • 嘟文内容导入到其所属“章节名”的二级标题下;
  • 增加日期与时间的显示。
    • 我最关心游戏游玩的时间段,因此让脚本在处理时,“章节名”下加入年/月作为次级标题,格式是 Jul 2026;在嘟文前加入日期,格式为 24日
    • 对嘟文进行了时间线重排,确保嘟文归档进正确的年月并按照时间顺序展示。
    • 其他延续 2.3 中的写入格式,依次是时间、嘟文链接、(不再写折叠标题)嘟文内容。

还有一种方法:就我观察,象友会在置顶嘟文中记录“游戏名:对应嘟文链接”。可以把这些内容交给脚本,以此为绝对索引,脚本在处理每条嘟文时:

  • 如果当前嘟文为回复嘟文,且初始嘟文链接被包含到索引,则使用索引中游戏名称去 game 库匹配;
  • 除此之外,视作日常嘟处理。

我的已注销账号是这么处理的。

以上内容未涉及到

算是我力不能及的部分,只是列出来。如果大家也有意向做,可以注意一下。

增量数据

到这里,我只是把历史数据整理出来——没有增量数据的系统只是一次性展示柜。
日常账号不需要担心,定期敲嘟嘟导入脚本、Neodb 导入脚本即可。比较麻烦的是,游戏库怎么新建一条游戏信息呢?要手动在 ob 里录好多东西,想想就头大。即使换成在 neodb 录好后导入进来……轻松了一小下下吧。
因为我玩游戏太慢了,所以我决定先逃避这个问题(?

已注销账号与多账号导入

我只试做了一次,磕磕绊绊,记录也不多。有印象的点已在前文捎带提及:已注销账号的游戏记录与当前嘟嘟不同,所以处理方案不同;多账号导入时可以考虑是否按照嘟文时间对已导入内容与新导入内容重排。我的游玩日志,没有像Journal一样各个账号分区域写入,会有后脚本抽风掺和前脚本结果的风险。

Neodb 标记与嘟文重复

neodb打标记时可以一并转发到主账号,有此类习惯的话,同时导入neodb与嘟文会导致内容重复。我转发的量不多放任了。
如果象友介意重复的话,可能要对导入嘟文加一层过滤 neodb 类嘟嘟的处理。

一点拓展方向

阅读时的划线与笔记,也可以做到类似游玩日志那样融入这个结构中。如果长期使用微信读书的话,ob有微信读书同步的插件。

Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy
© Licensed Under CC BY-NC-SA 4.0