likes
comments
collection
share

🐦数据无价!自己写个"语雀" 自动备份脚本

作者站长头像
站长
· 阅读数 30

1、引言

前天下午2点多,语雀 出现大规模的服务器故障,在线文档和官网均无法打开,经历近 8h+ 的漫长等待,官博终于在22:24通知服务及功能全部恢复,这波宕机直接杀上 热搜...

🐦数据无价!自己写个"语雀" 自动备份脚本

因为官方没有发公告及告知修复的进度,大家对事故的缘由也是众说纷纭,甚至还有这样的 (🤣笑死):

🐦数据无价!自己写个"语雀" 自动备份脚本

昨晚,语雀公众号发布了一篇事故复盘文章《关于语雀 23 日故障的公告》简述故障原因:新的运维升级工具bug带来的一系列影响,对处理过程和评论感兴趣的可以点进原文可看看。

不感兴趣可以 直接领6个月语雀会员网页端依次点击账户设置会员信息立即领取,最重要数据没丢,这波道歉还算是诚意满满😁~

🐦数据无价!自己写个"语雀" 自动备份脚本

看了下官博的评论,除了喷之外,更多的是呼吁赶紧开发 本地离线/本地存储 功能,网站崩只影响上传,不影响本地。🐶不过我觉得 短时间不太可能弄这个,估计PC端就是网页套 Electron 壳 (纯猜测没去验证...)。

无独有偶,前阵子笔者还经历了另一波 "文档可能丢失""人祸" :之前一直用的 CMD Markdown 作为自己的知识管理工具,99一年,尽管作者没咋维护,官方仓库一堆issues,但它的 图床可以直接生成外链 真香。

不过,八月底突然发现访问不了,一直同步失败,官网也打不开。卧槽,作者不会跑路了吧???

🐦数据无价!自己写个"语雀" 自动备份脚本

几年的心血全在里面啊,800+的文章,说没就没,这谁顶得住啊,赶紧打开Github仓库康康其它人怎么说:

🐦数据无价!自己写个"语雀" 自动备份脚本

在评论区,作者道出了缘由:

🐦数据无价!自己写个"语雀" 自动备份脚本

有傻X乱发东西,导致网站被网信部禁止访问...

🐦数据无价!自己写个"语雀" 自动备份脚本

不过幸好 编辑器有做本地离线存储 还支持 一键导出所有文稿,赶紧导出备份了一波。也可以给作者发邮件让他从数据库导份excel或txt给你,不过需要自己手动划分章节。

当然,这里不是做广告,用它纯粹是笔者用习惯了,基本等于零售后,而且到现在都还没解封😅。

语雀和笔者的经历都说明了一点:只要你使用在线文档,就一定存在数据丢失的风险,还是得自己定期做下数据备份。平台打再多包票不会丢,等数据真的丢了。道歉 + 退钱,有用吗?毕竟 数据无价!!!

🐦数据无价!自己写个"语雀" 自动备份脚本

回到语雀这件事上,估计很多人跟我一样,怕了,赶紧把语雀上的文档本地备份一下。但找了一圈,并没有发现直接 批量导出 的功能,只能手动将一个个文档导出:选中文档点更多(三个点)导出选择导出格式导出

🐦数据无价!自己写个"语雀" 自动备份脚本

几百个文档导到啥时候,这种重复费时费力的活,肯定得让脚本来做的,所以,本节就来动手写一个 语🐦文档备份脚本, 对脚本编写思路不感兴趣的,可以直接下载 coder-pig/YuQueBackups 里的 语雀文档备份脚本.exe,认准这只鸭🦆:

🐦数据无价!自己写个"语雀" 自动备份脚本

双击运行后,粘贴下你的个人Token(没有的点 → Personal Access Token → 新建一个),回车,接着会 自动执行文档备份及Markdown本地化,在当前目录下生成一个 backups 的目录,其中的 origin_md在线文档备份目录local_md本地文档备份目录 (没网也可以看到文档中引用的图片),最小化挂着等它自动跑就好~:

🐦数据无价!自己写个"语雀" 自动备份脚本

对开发思路感兴趣的可以继续往下走~

2、脚本思路

备份的核心要点就两个:批量导出Markdown文件 + Markdown文件本地化,如何导出Markdown有三个思路:

  • ① 调API:看官方是否有提供、网上是否有大佬破解了导出接口、自己破解(真刑)
  • ② 写个Chrome插件来批量调用导出接口;
  • ③ 使用浏览器自动化 (如puppeteer、plplaywright) 模拟用户手动去导出文档;

✌ 很幸运,官方直接提供了API → Overview,照着文档调接口就好了~

3、利用API批量导出md文件

① 申请Token

语雀 API 目前使用 Token 机制来实现身份认证,只需在请求头 X-Auth-Token 带入 身份Token 即可通过验证。所以需要先新建一个Token,打开 网页端 依次点击:点击头像账户设置找到左侧开发者分组Token

🐦数据无价!自己写个"语雀" 自动备份脚本

点击新建后,会弹窗,让你描述Token的用户,以及设置授权的范围,这里我们没有写的需求,只勾选读取。

🐦数据无价!自己写个"语雀" 自动备份脚本

确定后会生成一个Token,点击 查看详情,可以看到生成的Token,平时有需要的也可以在这里重置下Token~

🐦数据无价!自己写个"语雀" 自动备份脚本

接着用requests调下接口,看Token是能用:

import requests as r

yq_token = "xxx"	# 填上面申请的Token
yq_headers = {'X-Auth-Token': yq_token}
yq_base_url = "https://www.yuque.com/api/v2/"

if __name__ == '__main__':
    resp = r.get(yq_base_url + "hello", headers=yq_headers)
    if resp:
        print(resp.content.decode())

运行后,终端如果打印下述返回数据 (Hello 用户名),说明Token有效:

🐦数据无价!自己写个"语雀" 自动备份脚本

弄完Token,接着就是撸接口WIKI,写代码调API,解析返回数据抠字段,没啥技术难度(Tips:这里的代码简单看下就好,后面会给出完整可运行的代码)~

② 获取用户id

def fetch_user_id():
    # 获取用户ID
    user_resp = send_request("获取用户ID", "user")
    user_id = None
    if user_resp:
        user_id = user_resp.json().get('data').get('id')
        print("当前用户ID:{}".format(user_id))
    if user_id is None:
        exit("用户ID获取失败,请检查后重试...")
    print("=" * 64)
    return user_id

③ 获取知识库列表

# 知识库实体类
class Repo:
    def __init__(self, repo_id, repo_type, repo_slug, repo_name, repo_namespace):
        self.repo_id = repo_id
        self.repo_ype = repo_type
        self.repo_slug = repo_slug
        self.repo_name = repo_name
        self.repo_namespace = repo_namespace

# 拉取知识库列表
def fetch_repo_list(user_id):
    repo_list_resp = send_request("知识库列表", "users/{}/repos".format(user_id))
    repo_list = []
    if repo_list_resp:
        repo_list_json = repo_list_resp.json()
        for repo in repo_list_json['data']:
            repo_list.append(
                Repo(repo.get('id'), repo.get('type'), repo.get('slug'), repo.get('name'), repo.get('namespace')))
    if len(repo_list) == 0:
        exit("知识库列表获取失败,请检查后重试...")
    else:
        print("解析知识库列表成功,共{}个知识库...".format(len(repo_list)))
    print("=" * 64)
    return repo_list

④ 获取某个知识库目录

语雀支持 分组,用了它,库目录会是这样的 多级树形结构,如果备份到本地时也想保持这样的层次结构 (文件夹嵌套),需要做下特殊处理。

🐦数据无价!自己写个"语雀" 自动备份脚本

如果 不需要保留层级结构,不介意所有文档在同一层目录下,那可以调这个接口: 获取一个仓库的文档列表。而笔者需要保留,所以这里调另一个接口:Toc 知识库目录

🐦数据无价!自己写个"语雀" 自动备份脚本

定义结点实体类:

# 目录结点实体类
class TocNode:
    def __init__(self, node_type, node_title, node_uuid, parent_uuid, doc_id, repo_id, repo_name):
        self.node_type = node_type
        self.node_title = node_title
        self.node_uuid = node_uuid
        self.parent_uuid = parent_uuid
        self.child_node_list = []	# 存储子目录结点
        self.doc_id = doc_id
        self.repo_id = repo_id
        self.repo_name = repo_name

解析目录列表,定义一个 根目录节点 配合 字典(uuid : node) ,来存储这个树形结构:

# 拉取知识库目录
def fetch_toc_list(repo_id, repo_name):
    toc_list_resp = send_request("目录列表", "repos/{}/toc".format(repo_id))
    id_order_dict = {}
    root_toc_node = TocNode(None, "根目录", None, None, None, repo_id, repo_name)
    id_order_dict["root"] = root_toc_node
    if toc_list_resp:
        toc_list_json = toc_list_resp.json()
        for toc in toc_list_json['data']:
            toc_node = TocNode(toc.get('type'), toc.get('title'), toc.get('uuid'), toc.get('parent_uuid'),
                               toc.get('doc_id'), repo_id, repo_name)
            id_order_dict[toc_node.node_uuid] = toc_node
            # 顶级目录
            if toc_node.parent_uuid is None or len(toc_node.parent_uuid) == 0:
                root_toc_node.child_node_list.append(toc_node)
            else:
                parent_node = id_order_dict.get(toc_node.parent_uuid)
                if parent_node is None:
                    exit("父目录不存在")
                else:
                    parent_node.child_node_list.append(toc_node)

定义一个 递归访问目录树的测试方法

def traverse_nodes(node, space_count=0):
    print("\t" * space_count + node.node_title)
    if node.child_node_list is None or len(node.child_node_list) == 0:
        return
    else:
        for node in node.child_node_list:
            traverse_nodes(node, space_count + 1)

for node in root_toc_node.child_node_list:
    traverse_nodes(node)

运行看下效果:

🐦数据无价!自己写个"语雀" 自动备份脚本

可以,树形的层次结构有体现出来,接着就是获取文档的具体内容,然后保存成md文件了~

⑤ 获取单篇文档的详细信息

上面的递归代码要改下,我们的目的是保存 文章类型 的md文件,而且要传递一个 完整文件的保存路径

# 递归访问目录树的测试方法
def traverse_nodes(node, save_path=""):
    save_path += "{}{}".format(os.sep, node.node_title)
    if node.child_node_list is None or len(node.child_node_list) == 0:
        if node.node_type == "DOC":
            # 需要对不合法的字符进行替换,就是不能作为文件/文件夹名称的
            md_save_path = "{}{}.md".format(backups_origin_md_dir, save_path) \
                .replace("|", "_").replace("/", "、").replace('"', "'").replace(":", ";")
            last_sep_index = md_save_path.rfind(os.sep)
            if last_sep_index != -1:
                save_dir = md_save_path[:last_sep_index]
                is_dir_existed(save_dir)
                fetch_doc_detail(node, md_save_path)
        return
    else:
        for node in node.child_node_list:
            traverse_nodes(node, save_path)


# 拉取单篇文章的详细内容
def fetch_doc_detail(node, save_path):
    print("{}{}".format(backups_origin_md_dir, save_path))

运行下看看效果:

🐦数据无价!自己写个"语雀" 自动备份脚本

好的,接着就是抠body字段,把文章内容保存成md文件了:

# 把文本写入到文件中
def write_text_to_file(content, file_path, mode="w+"):
    try:
        print("文件保存成功:{}".format(file_path))
        with open(file_path, mode, encoding='utf-8') as f:
            f.write(content + "\n", )
    except OSError as reason:
        print(str(reason))

# 拉取单篇文章的详细内容
def fetch_doc_detail(node, save_path):
    doc_detail_resp = send_request("文档详情", "repos/{}/docs/{}".format(node.repo_id, node.doc_id))
    if doc_detail_resp:
        doc_detail_json = doc_detail_resp.json()
        doc_detail = doc_detail_json.get('data').get('body')
        if doc_detail is not None and len(doc_detail) > 0:
            write_text_to_file(doc_detail, save_path)
            time.sleep(randint(2, 8))

上面加了随机休眠2-8秒,咋们也不急,细水长流,别等下搞到号被封了,得不偿失,挂着脚本后台跑就好了~

🐦数据无价!自己写个"语雀" 自动备份脚本

4、Markdown本地化

所谓的Markdown本地化,就是:把文章里用到图片下载下来,然后把URL替换为本地路径。不做本地化的话,站点做了防盗链图床平台跑路,预览md文件时你的图片就会变成这样:

🐦数据无价!自己写个"语雀" 自动备份脚本

这里的批处理思路是:

  • 1、循环遍历backups目录下所有md文件;
  • 2、编写正则匹配图片URL,利用 sub() 方法的 repl 参数,在替换成本地路径的同时,可以拿到图片URL;
  • 3、保存URL和本地路径的映射关系;
  • 4、根据上面保存的映射关系批量下载图片到本地;

接着代码实现一波~

① 遍历所有md文件

直接写出遍历特定后缀文件的工具代码:

backups_base_dir = os.path.join(os.getcwd(), "backups")

# 递归遍历文夹与子文件夹中的特定后缀文件
def search_all_file(file_dir=os.getcwd(), target_suffix_tuple=()):
    file_list = []
    # 切换到目录下
    os.chdir(file_dir)
    file_name_list = os.listdir(os.curdir)
    for file_name in file_name_list:
        # 获取文件绝对路径
        file_path = "{}{}{}".format(os.getcwd(), os.path.sep, file_name)
        # 判断是否为目录,是往下递归
        if os.path.isdir(file_path):
            file_list.extend(search_all_file(file_path, target_suffix_tuple))
            os.chdir(os.pardir)
        elif target_suffix_tuple is not None and file_name.endswith(target_suffix_tuple):
            file_list.append(file_path)
        else:
            pass
    return file_list

if __name__ == '__main__':
    for md_file_path in search_all_file(backups_base_dir, ".md"):
        print(md_file_path)

运行看看:

🐦数据无价!自己写个"语雀" 自动备份脚本

② 正则匹配图片URL并替换

Markdown图片的常见语法有下述两种:

![图片描述](http://xxx.yyy.zzz)
![图片描述][12]

随手找一个语雀的图片标签样本:

![image.png](https://cdn.nlark.com/yuque/0/2023/png/12364495/1684306952876-fff0a631-3267-4ceb-9993-e8264f09978f.png#averageHue=%23363331&clientId=u73edb3f8-9374-4&from=paste&height=304&id=u0dc71dbd&originHeight=327&originWidth=414&originalType=binary&ratio=1&rotation=0&showTitle=false&size=46938&status=done&style=none&taskId=u4fadb301-d08f-4db0-b38b-693d41c2a60&title=&width=385)

语雀默认采用第一种,写出匹配正则:

pic_match_pattern = re.compile(r'(![.*?]()(.*?)(.(png|PNG|jpg|JPG|jepg|gif|GIF|svg|SVG|webp|awebp))(.*?))', re.M)

思路那里说了,使用re.sub()来进行替换,它的完整语法如下:

re.sub(pattern, repl, string, count=0, flags=0)

# pattern → 表示正则中的模式字符串
# repl → 被替换的字符串,它可以是字符串,也可以是函数!!!
# string → 要被替换的字符串
# count → 只处理几个?0代表所有都处理
# flags → 编译标记,如上面的re.M

repl 这个参数的作用读者可能不太能理解,写个简单例子:

import re

match_pattern = re.compile(r"[a-z]+")


# 匹配小写字母串,首字母大写
def replace_lowercase(match_result):
    return match_result.group().title()


if __name__ == '__main__':
    test_str = "abc 123 def 4567 hijk 89"
    result = match_pattern.sub(replace_lowercase, test_str)
    print(result)

运行输出结果如下:

🐦数据无价!自己写个"语雀" 自动备份脚本

不难看出它的作用:

默认把匹配结果传递到函数中,调用 group() 方法可获得匹配字符串,然后返回的是替换后的字符串。

知道怎么用,然后就是套到我们的代码里了,这里我们先把正则匹配的每个分组内容打印出来:

🐦数据无价!自己写个"语雀" 自动备份脚本

截取部分运行结果:

🐦数据无价!自己写个"语雀" 自动备份脚本

2+5 就是图片的url,4是图片后缀,接着就是拼接 下载保存的本地图片路径,然后建立 和URL的映射关系 了。这里希望把图片保存到 新md文件 所在目录下的 images 文件夹中,所以需要另外传入一个 保存目录的参数,这里使用偏函数 functools.partial() 创建一个新的函数,它可以固定住原函数的部分参数,从而使得调用更加简单:

# 远程图片转换为本地图片
def pic_to_local(match_result, pic_save_dir):
    global pic_url_path_record_list
    pic_url = match_result[2] + match_result[5]
    print("替换前的图片路径:{}".format(pic_url))
    # 生成新的图片名
    img_file_name = "{}_{}.{}".format(int(round(time.time())), order_set.pop(), match_result[4])
    # 拼接图片相对路径(Markdown用到的)
    relative_path = 'images/{}'.format(img_file_name)
    # 拼接图片绝对路径,下载到本地
    absolute_path = os.path.join(pic_save_dir, img_file_name)
    print("替换后的图片路径:{}".format(relative_path))
    pic_url_path_record_list.append("{}\t{}".format(pic_url, absolute_path))
    # 拼接前后括号()
    return "{}{}{}".format(match_result[1], relative_path, ")")


# md文件本地化
def md_to_local():
    # 遍历backups/origin_md目录下所有md文件
    for md_file_path in search_all_file(backups_origin_md_dir, ".md"):
        # 读取md文件内容
        old_content = read_file_text_content(md_file_path)
        # 定位oring_md所在的下标,拼接新生成的md文件的目录要用到
        absolute_dir_index = md_file_path.find("origin_md")
        # 新md文件的相对路径
        md_relative_path = md_file_path[absolute_dir_index + 10:]
        # 新md文件所在目录
        new_md_dir = os.path.join(backups_local_md_dir, md_relative_path[:-3])
        # 新md文件的完整路径
        new_md_file_path = os.path.join(new_md_dir, os.path.basename(md_file_path))
        # 图片的保存路径
        new_picture_dir = os.path.join(new_md_dir, "images")
        # 路径不存在新建
        is_dir_existed(new_md_dir)
        is_dir_existed(new_picture_dir)
        # 替换后的md文件内容
        new_content = pic_match_pattern.sub(partial(pic_to_local, pic_save_dir=new_picture_dir), old_content)
        # 生成新的md文件
        write_text_to_file(new_content, new_md_file_path)
        print("新md文件已生成 → {}".format(new_md_file_path))
    print("所有本地md文件生成完毕!开始批量下载图片文件")

运行后,可以看到原图片都被替换成本地路径了,新的md文件也保存到local_md目录下了。

🐦数据无价!自己写个"语雀" 自动备份脚本

③ 图片批量下载

上面的 pic_url_path_record_list 列表保存了 图片URL保存路径 的映射关系,遍历这个列表,把图片下载到本地。这里就不用那么温柔了,直接上异步请求库 aiohttp,无脑并发下一波:

# 异步下载图片
async def download_pic(pic_path, url, headers=None):
    try:
        if headers is None:
            headers = default_headers
        if url.startswith("http") | url.startswith("https"):
            if os.path.exists(pic_path):
                print("图片已存在,跳过下载:%s" % pic_path)
            else:
                resp = await requests.get(url, headers=headers)
                print("下载图片:%s" % url)
                if resp is not None:
                    if resp.status != 404:
                        async with aiofiles.open(pic_path, "wb+") as f:
                            await f.write(await resp.read())
                            print("图片下载完毕:%s" % pic_path)
                    else:
                        print("图片不存在:{}".format(url))
        else:
            print("图片链接格式不正确:%s - %s" % (pic_path, url))
    except Exception as e:
        print("下载异常:{}\n{}".format(url, e))

运行后图片唰唰唰全下下来了:

🐦数据无价!自己写个"语雀" 自动备份脚本

此时随便打开 local_md 目录下的md文件:

🐦数据无价!自己写个"语雀" 自动备份脚本

可以,到此,全部文档就备份完毕啦,最后运行下代码跑完整个备份过程:

🐦数据无价!自己写个"语雀" 自动备份脚本

备份287篇文档,耗时500063.82ms ≈ 9 分钟10.06382 秒,还可以~

🐦数据无价!自己写个"语雀" 自动备份脚本

行吧,本节就到这里,对源码感兴趣的可以移步至:coder-pig/YuQueBackups 自行查阅,有BUG啥的也欢迎反馈,感谢~