🐦数据无价!自己写个"语雀" 自动备份脚本
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图片的常见语法有下述两种:

![图片描述][12]
随手找一个语雀的图片标签样本:

语雀默认采用第一种,写出匹配正则:
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啥的也欢迎反馈,感谢~
转载自:https://juejin.cn/post/7294079777711931431