从 Markdown 文本中批量制作Anki卡片的Python脚本

标签: anki, markdown ;


Anki的卡片本质上是一个html页面,Anki虽然提供了富文本编辑,但是在批量制作卡片进并没有用处。于是我动手写了这个脚本。

首先,本程序只支持两种类型的卡片:基础(Basic),填空题(Cloze)。我觉得这两种类型的卡片已经足以应付大多数需求。

格式约定:

卡片之间用独占一行的至少3个短横---分隔

“基础”卡片有两个字段:正面和背面,用3个连续的回车分隔。

“填空题”只有一个字段,需要挖空的填空部分用大括号括起来就行了。比如“这是一个{填空题}”,在导入后会变成“这是一个{{c1::填空题}”。

除此之外,几乎所有的Markdown语法都支持。

另外,程序会在运行时从 style.css导入CSS,我只在style.css中美化了一下表格,如有其它需求可自行添加。

用法:

mkcards.py [-d 记忆库] cards.md

源码:

# 从文本文件中批量制作 Anki 记忆卡片
# 本程序需要在 Anki 中安装 AnkiConnect 插件,该插件会在本地启动一个Web API服务
# 按 Ctrl + Shift + A 组合键,在对话框中点“获取插件”, 在“代码”处输入:2055492159,点“确定”
# 即可安装 AnkiConnect 插件

# 本程序支持两种卡片类型:基础(Basic), 填空题(Cloze)
# 基础卡片支持两个字段,对应正面和背面,字段间用至少两个连续的换行进行分隔
# 填空题只支持一个字段,在文本中使用大括号插入填空
# 不同的卡片之间用顶格的三个短横分隔,类型于 Markdown 语法

import json
import requests
import re
import argparse

import mistune
#from markupsafe import Markup

mark = mistune.create_markdown(escape=True, plugins=[
    'strikethrough', 
    'table',
    'url',
    'task_lists',
    'mark',
    'insert',
    'superscript',
    'subscript'
])

DECK = ''

CLOZE_MOD = None
BASIC_MOD = None

def read_file(f):
    with open(f, 'r') as f:
        return f.read()

def apply_css(model):
    data = {
        'action': 'updateModelStyling',
        'version': 6,
        'params': {
            'model': {
                'name': model,
                'css': read_file('style.css')
            }
        }
    }
    return post(data)

MODELS = {}

def post(data):
    res = requests.post('http://localhost:8765', data=json.dumps(data)).json()
    if res['error']:
        print(res['error'])
    return res

def create_card(deck_name, model_name, fields):
    field_names = MODELS[model_name]
    fields_dic = dict(zip(field_names, fields))
    # print(fields_dic)
    data = {
        'action': 'addNote',
        'version': 6,
        'params': {
            'note': {
                'deckName': deck_name,
                'modelName': model_name,
                'fields': fields_dic,
                'options': {
                    'allowDuplicate': False
                },
                'tags': []
            }
        }
    }
    res = post(data)
    if res['error']: 
        print(model_name, fields_dic)

def parse_file(path):
    with open(path, 'r') as file:
        return [s.strip() for s in re.split('^-{3,}', file.read(), flags=re.M)]

def add_card(text):
    rcloze = r'\{([^}]+)\}'
    m = re.findall(rcloze, text)
    if m:
        cloze_card = mark(re.sub(r'\{([^}]+)\}', r'{{c1::\1}}', text))
        fields = [cloze_card, '']
        create_card(DECK, CLOZE_MOD, fields)
    else:
        basic_card = re.split(r'(?:\r?\n){3,}|(?:\r){3,}', text)
        if len(basic_card) != 2:
            print(f'字段解析失败:{text}')
        else:
            fields = [mark(basic_card[0]), mark(basic_card[1])]
            create_card(DECK, BASIC_MOD, fields)

def envinit():
    global CLOZE_MOD
    global BASIC_MOD
    data = {
        'action': 'modelNames',
        'version': 5
    }
    res = post(data)
    if not res['error']:
        models = res['result']
        for model in models:
            fields = model_fields(model)
            MODELS[model] = fields

        if 'Cloze' in models:
            CLOZE_MOD = 'Cloze'
        elif '填空题' in models:
            CLOZE_MOD = '填空题'
        else:
            print('示找到填空题卡片类型')
            exit()

        if 'Basic' in models:
            BASIC_MOD = 'Basic'
        elif '基础' in models:
            BASIC_MOD = '基础'
        else:
            print('未找到基础卡片类型')
            exit()

        for model in (CLOZE_MOD, BASIC_MOD):
            apply_css(model)
    else:
        print(res['error'])

def list_decks():
    data = {
        'action': 'deckNames',
        'version': 5
    }
    res = post(data)
    if not res['error']:
        return res['result']

def model_fields(model):
    data = {
        'action': 'modelFieldNames',
        'version': 5,
        'params': {
            'modelName': model
        }
    }
    return post(data)['result']

if __name__ == '__main__':
    envinit()

    parser = argparse.ArgumentParser(description='批量制作Anki记忆卡')
    parser.add_argument('file', type=str, help='指定文件')
    parser.add_argument('-d', '--deck', type=str, help='指定要添加的记忆库')
    args = parser.parse_args()

    decks = list_decks()

    if args.deck:
        if not args.deck in decks:
            print(f'记忆库不存在: {args.deck}')
            exit()
        DECK = args.deck
    else:
        # if '默认' in decks or 'Default' in decks:
        #     DECK = '默认' if '默认' in decks else 'Default'
        print('必须指定要添加的记忆库')
        exit()

    for card in parse_file(args.file):
        add_card(card)