
Yuque Lakebook Export Cn
- 33 installs
- 16 repo stars
- Updated July 13, 2026
- yangsonhung/awesome-agent-skills
Helps with ai & agent building tasks.
About
yuque-lakebook-export-cn is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- yuque-lakebook-export-cn
- AI & Agent Building
- AI-coding skill
Yuque Lakebook Export Cn by the numbers
- 33 all-time installs (skills.sh)
- +3 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #8,975 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yangsonhung/awesome-agent-skills --skill yuque-lakebook-export-cnAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 33 |
|---|---|
| repo stars | ★ 16 |
| Last updated | July 13, 2026 |
| Repository | yangsonhung/awesome-agent-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
语雀 Lakebook 导出
将一个或多个语雀 .lakebook 文件导出为本地 Markdown 目录,并优先适配 Obsidian。
何时使用
当用户需要以下能力时使用本技能:
- 导出一个或多个语雀
.lakebook - 将语雀知识库转换为 Markdown
- 将语雀内容迁移到 Obsidian
- 修复语雀导出后的图片、裁剪图、内部链接、目录层级、表格渲染问题
不要使用
不要将本技能用于:
- 与语雀或
.lakebook无关的普通 Markdown 编辑 - 网页抓取任务
- 非语雀来源的导出转换任务
使用说明
1. 优先使用非交互模式,便于 Agent 稳定执行。 2. Agent 在执行任何非交互导出前,必须先向用户确认输出根目录,不能自行决定输出目录。 3. 如果用户还没有明确给出输出目录,就先用一句简短问题询问,等用户答复后再执行导出。 4. 统一优先使用 uv,不要在任务目录里临时创建 .venv、.yuque-export-venv 这类环境。 5. 在执行任何 uv 命令前,先检查当前环境是否可用 uv。 6. 如果 uv 未安装,或者不在 PATH 中,Agent 必须先征求用户确认是否安装 uv,不能直接静默安装。 7. 在执行 uv sync 或 uv run python scripts/cli.py ... 前,必须先切换到当前已安装 skill 的工具目录,也就是包含本 SKILL.md、pyproject.toml、uv.lock 和 scripts/ 的那个目录。不要在 .lakebook 所在目录、目标输出目录、或用户当前工作区根目录直接执行这些命令。 8. Agent 执行时使用下面这个入口:
uv run python scripts/cli.py9. 首次使用前同步依赖:
uv sync10. 推荐执行顺序:
cd /path/to/installed-skill-root
uv sync
uv run python scripts/cli.py ...11. 单文件执行:
uv run python scripts/cli.py -l "/path/to/your_file.lakebook" -o "/target/root"12. 批量执行:
uv run python scripts/cli.py -l "/path/to/your_file_1.lakebook" "/path/to/your_file_2.lakebook" -o "/target/root"13. 虽然 scripts/cli.py 仍然保留了给人类终端手工使用的交互选择能力,但 Agent 不应依赖交互模式,因为无法稳定获取终端交互状态。执行时始终显式传入 -l 和 -o 参数。 14. 不要在当前工作目录、用户下载目录、或任意任务目录里手动创建临时虚拟环境;依赖应由 skill 工具目录中的 uv 环境统一管理。 15. 已知有些语雀导出的文档正文会包含 <!doctype lake>,旧实现可能导出成 lake## 标题;当前技能内置实现已经处理这个问题。 16. 导出完成后检查:
.md文件是否生成- 同名
.assets目录是否生成 - 内部链接是否转为相对 Markdown 路径
- 图片是否能在 Obsidian 中正常显示
- 文档首行是否存在异常的
lake##前缀
17. 如果导出失败,优先查看输入 .lakebook 同目录下生成的批量日志。
详细行为、输出规则和排查说明见 references/usage.md。
[project]
name = "yuque-lakebook-export-skill-cn"
version = "0.1.0"
description = "Skill-local Yuque lakebook export runtime"
requires-python = ">=3.13"
dependencies = [
"beautifulsoup4==4.12.2",
"pyyaml==6.0.1",
"requests==2.31.0",
]
使用说明
输入
- 一个或多个
.lakebook文件 - 一个目标根目录
- 优先使用入口:
uv run python scripts/cli.py ...
输出
假设文档名为 your_document,输出结构为:
your_document.md
your_document.assets/your_document.md保存正文your_document.assets/保存图片和附件
支持行为
- 语雀内部链接转换为相对 Markdown 链接
- 标题中的
/和\会自动清洗 - 没有子文档的页面直接导出为单个
.md - 有子文档的页面会创建同名目录,并保留同名页面文件
- 如果图片卡片包含裁剪信息,会下载裁剪后的图片版本
- 批量执行时单个文件失败不会中断整个批次
排查
- 先切换到当前已安装 skill 的工具目录,再执行
uv sync同步依赖;这个目录应包含SKILL.md、pyproject.toml、uv.lock和scripts/ uv sync和uv run python scripts/cli.py ...都必须在该 skill 工具目录执行,不要在.lakebook所在目录、输出目录或用户当前工作区根目录执行- 不要在用户当前工作目录、下载目录或任意任务目录下手动创建临时虚拟环境
- 缺少
bs4:执行uv sync - Obsidian 图片不显示:检查
.assets路径和链接编码 - 表格渲染异常:重新导出,脚本会做 Markdown 规范化
- 内部链接失效:确认源
.lakebook包含完整目录元数据
import argparse
import os
from pathlib import Path
import glob
import sys
import curses
from datetime import datetime
import json
try:
import readline
except ImportError:
readline = None
from yuque_lakebook_export.lake_handle import sanitize_path_segment
from yuque_lakebook_export.lake_setup import start_convert
STATE_FILE = Path.home() / ".agents" / "state" / "yuque-lakebook-export.json"
def parse_bool(value):
if isinstance(value, bool):
return value
normalized = str(value).strip().lower()
if normalized in {"true", "1", "yes", "y"}:
return True
if normalized in {"false", "0", "no", "n"}:
return False
raise argparse.ArgumentTypeError("downloadImage 仅支持 True/False")
def load_state():
if not STATE_FILE.exists():
return {}
try:
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
def save_state(state):
try:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
def complete_path(text, state):
expanded = os.path.expanduser(text or "")
if not expanded:
expanded = "."
if os.path.isdir(expanded):
pattern = os.path.join(expanded, "*")
else:
pattern = expanded + "*"
matches = []
for item in sorted(glob.glob(pattern)):
display = item
if os.path.isdir(item):
display = os.path.join(item, "")
if display.startswith(os.path.expanduser("~")):
display = "~" + display[len(os.path.expanduser("~")):]
matches.append(display)
if state < len(matches):
return matches[state]
return None
def setup_readline():
if readline is None:
return
doc = getattr(readline, "__doc__", "") or ""
if "libedit" in doc.lower():
readline.parse_and_bind("bind ^I rl_complete")
else:
readline.parse_and_bind("tab: complete")
readline.set_completer_delims(" \t\n;")
readline.set_completer(complete_path)
def discover_lakebooks(search_dir):
base_dir = Path(search_dir).expanduser().resolve()
return sorted(base_dir.glob("*.lakebook"))
def parse_multi_select(raw_value, max_index):
if not raw_value.strip():
return []
selected = set()
for part in raw_value.split(","):
item = part.strip()
if not item:
continue
if "-" in item:
start, end = item.split("-", 1)
start_index = int(start.strip())
end_index = int(end.strip())
if start_index > end_index:
start_index, end_index = end_index, start_index
for index in range(start_index, end_index + 1):
if 1 <= index <= max_index:
selected.add(index)
else:
index = int(item)
if 1 <= index <= max_index:
selected.add(index)
return sorted(selected)
def toggle_select_all(selected_indexes, total_count):
if len(selected_indexes) == total_count:
return set()
return set(range(total_count))
def draw_lakebook_selector(stdscr, lakebooks, cursor_index, selected_indexes, top_index):
stdscr.erase()
height, width = stdscr.getmaxyx()
title = "请选择要执行的 .lakebook 文件"
help_text = "↑↓移动 空格勾选/取消 a全选/取消全选 Enter确认 Ctrl+C退出"
stdscr.addnstr(0, 0, title, width - 1)
stdscr.addnstr(1, 0, help_text, width - 1)
list_top = 3
list_height = max(1, height - list_top - 1)
visible_items = lakebooks[top_index:top_index + list_height]
for offset, file_path in enumerate(visible_items):
actual_index = top_index + offset
marker = "[x]" if actual_index in selected_indexes else "[ ]"
line = f"{marker} {file_path.name}"
if actual_index == cursor_index:
stdscr.attron(curses.A_REVERSE)
stdscr.addnstr(list_top + offset, 0, line, width - 1)
stdscr.attroff(curses.A_REVERSE)
else:
stdscr.addnstr(list_top + offset, 0, line, width - 1)
footer = f"已选择 {len(selected_indexes)} / {len(lakebooks)}"
stdscr.addnstr(height - 1, 0, footer, width - 1)
stdscr.refresh()
def curses_select_lakebooks(stdscr, lakebooks):
curses.curs_set(0)
stdscr.keypad(True)
cursor_index = 0
top_index = 0
selected_indexes = set()
while True:
height, _ = stdscr.getmaxyx()
list_height = max(1, height - 4)
if cursor_index < top_index:
top_index = cursor_index
elif cursor_index >= top_index + list_height:
top_index = cursor_index - list_height + 1
draw_lakebook_selector(stdscr, lakebooks, cursor_index, selected_indexes, top_index)
key = stdscr.getch()
if key in (curses.KEY_UP, ord('k')):
cursor_index = (cursor_index - 1) % len(lakebooks)
elif key in (curses.KEY_DOWN, ord('j')):
cursor_index = (cursor_index + 1) % len(lakebooks)
elif key == ord(' '):
if cursor_index in selected_indexes:
selected_indexes.remove(cursor_index)
else:
selected_indexes.add(cursor_index)
elif key in (ord('a'), ord('A')):
selected_indexes = toggle_select_all(selected_indexes, len(lakebooks))
elif key in (10, 13, curses.KEY_ENTER):
if selected_indexes:
return [lakebooks[index] for index in sorted(selected_indexes)]
elif key == 3:
raise KeyboardInterrupt
def select_lakebooks_with_ui(lakebooks):
try:
return curses.wrapper(curses_select_lakebooks, lakebooks)
except KeyboardInterrupt:
print("\n已取消执行")
sys.exit(130)
except Exception:
print("交互式勾选界面不可用,回退到编号输入模式")
print("可选文件:")
for idx, file_path in enumerate(lakebooks, 1):
print(f"{idx}. {file_path.name}")
raw_selection = safe_input("请选择文件编号,支持多选(例如 1,3-5): ").strip()
selected_indexes = parse_multi_select(raw_selection, len(lakebooks))
if not selected_indexes:
print("未选择任何文件")
return []
return [lakebooks[index - 1] for index in selected_indexes]
def safe_input(prompt):
try:
return input(prompt)
except KeyboardInterrupt:
print("\n已取消执行")
sys.exit(130)
def prompt_lakebooks(state):
remembered_search_dir = state.get("last_search_dir")
default_search_dir = Path(remembered_search_dir or Path.cwd())
if remembered_search_dir:
prompt = f"请输入待扫描目录(上次使用: {default_search_dir},回车直接使用): "
else:
prompt = f"请输入待扫描目录(默认 {default_search_dir}): "
search_dir_input = safe_input(prompt).strip()
search_dir = search_dir_input or str(default_search_dir)
lakebooks = discover_lakebooks(search_dir)
if not lakebooks:
print("未找到 .lakebook 文件")
return [], None
selected_lakebooks = select_lakebooks_with_ui(lakebooks)
if not selected_lakebooks:
print("未选择任何文件")
return [], None
return selected_lakebooks, Path(search_dir).expanduser().resolve()
def prompt_output_root(default_root, state):
remembered_output_root = state.get("last_output_root")
if remembered_output_root:
default_root = Path(remembered_output_root)
prompt = f"目标根目录(上次使用: {default_root},回车直接使用): "
else:
prompt = f"目标根目录(默认 {default_root}): "
output_root_input = safe_input(prompt).strip()
return Path(output_root_input).expanduser() if output_root_input else default_root
def prompt_open_output(default_value=False, state=None):
if state is not None:
default_value = bool(state.get("last_open_output", default_value))
hint = "y/N" if not default_value else "Y/n"
if state is not None and "last_open_output" in state:
last_value = "是" if default_value else "否"
prompt = f"导出成功后是否自动打开目标目录?(上次使用: {last_value})[{hint}]: "
else:
prompt = f"导出成功后是否自动打开目标目录?[{hint}]: "
raw_value = safe_input(prompt).strip().lower()
if not raw_value:
return default_value
return raw_value in {"y", "yes"}
def build_output_dir(output_root, lakebook_path):
stem = sanitize_path_segment(lakebook_path.stem)
return output_root / stem
def get_log_output_dir(lakebooks):
if not lakebooks:
return None
parent_dirs = [str(Path(lakebook).expanduser().resolve().parent) for lakebook in lakebooks]
common_dir = os.path.commonpath(parent_dirs)
return Path(common_dir)
def run_batch(lakebooks, output_root, download_image, skip_existing, open_output=False):
output_root = output_root.expanduser().resolve()
output_root.mkdir(parents=True, exist_ok=True)
batch_results = []
for lakebook in lakebooks:
target_dir = build_output_dir(output_root, lakebook)
print(f"\n>>> 开始处理: {lakebook}")
print(f">>> 输出目录: {target_dir}")
result = start_convert(None, str(lakebook), str(target_dir), download_image, skip_existing, open_output=open_output)
batch_results.append({
"lakebook": str(lakebook),
"target_dir": str(target_dir),
**(result or {})
})
if result and not result.get("success"):
print(f">>> 当前文件处理失败,继续下一个: {lakebook}")
log_dir = get_log_output_dir(lakebooks)
log_path = write_batch_log(batch_results, log_dir)
print_batch_summary(batch_results)
if log_path:
print(f">>> 执行日志: {log_path}")
def print_batch_summary(batch_results):
if not batch_results:
return
success_results = [item for item in batch_results if item.get("success")]
failed_results = [item for item in batch_results if not item.get("success")]
print("\n>>> 批量执行完成")
print(f">>> 成功: {len(success_results)} 失败: {len(failed_results)}")
if success_results:
print(">>> 成功列表:")
for item in success_results:
print(f"- {item['lakebook']} -> {item['target_dir']} (导出 {item.get('file_count', 0)} 个文件)")
if failed_results:
print(">>> 失败列表:")
for item in failed_results:
print(f"- {item['lakebook']} -> {item['target_dir']}")
print(f" 错误: {item.get('error') or '未知错误'}")
def write_batch_log(batch_results, log_dir):
if not batch_results or log_dir is None:
return None
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"yuque-export-{timestamp}.log"
lines = [
f"执行时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"成功数量: {len([item for item in batch_results if item.get('success')])}",
f"失败数量: {len([item for item in batch_results if not item.get('success')])}",
""
]
for item in batch_results:
lines.append(f"源文件: {item.get('lakebook')}")
lines.append(f"输出目录: {item.get('target_dir')}")
lines.append(f"执行结果: {'成功' if item.get('success') else '失败'}")
lines.append(f"导出文件数: {item.get('file_count', 0)}")
if item.get("error"):
lines.append(f"错误信息: {item.get('error')}")
if item.get("traceback"):
lines.append("错误堆栈:")
lines.append(item.get("traceback", "").rstrip())
lines.append("-" * 60)
log_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return log_path
def preview_batch(lakebooks, output_root):
print("\n即将执行以下任务:")
for lakebook in lakebooks:
target_dir = build_output_dir(output_root, lakebook)
print(f"- {lakebook} -> {target_dir}")
def confirm_execution():
confirm = safe_input("确认开始执行?[Y/n]: ").strip().lower()
return confirm in {"", "y", "yes"}
def run_interactive(download_image, skip_existing, open_output=False):
state = load_state()
lakebooks, search_dir = prompt_lakebooks(state)
if not lakebooks:
return
output_root = prompt_output_root(search_dir, state)
open_output = prompt_open_output(default_value=open_output, state=state)
preview_batch(lakebooks, output_root)
if not confirm_execution():
print("已取消执行")
return
run_batch(lakebooks, output_root, download_image, skip_existing, open_output=open_output)
save_state({
"last_search_dir": str(search_dir),
"last_output_root": str(output_root.expanduser().resolve()),
"last_open_output": open_output
})
if __name__ == '__main__':
setup_readline()
parser = argparse.ArgumentParser(
description="Export one or more Yuque .lakebook files into local Markdown folders for Obsidian."
)
parser.add_argument('-i', '--meta', help="lake文件的meta.json路径", type=str)
parser.add_argument('-l', '--lake', help="lakebook文件路径,支持多个", nargs='*')
parser.add_argument('-o', '--output', help="生成markdown的根路径", type=str)
parser.add_argument('-d', '--downloadImage', help="是否下载图片", type=parse_bool, default=True)
parser.add_argument('-s', '--skip-existing-resources', help="是否跳过本地已存在的图片和附件文件", action='store_true')
parser.add_argument('--open-output', help="导出成功后自动打开目标目录", action='store_true')
parser.add_argument('--interactive', help="启用交互式选择", action='store_true')
args = parser.parse_args()
is_interactive_mode = args.interactive or (not args.meta and not args.lake)
if is_interactive_mode:
run_interactive(args.downloadImage, args.skip_existing_resources, open_output=args.open_output)
elif args.meta:
start_convert(args.meta, None, args.output, args.downloadImage, args.skip_existing_resources,
open_output=args.open_output)
else:
lakebooks = [Path(item).expanduser().resolve() for item in args.lake]
if len(lakebooks) == 1 and args.output:
output_root = Path(args.output).expanduser()
target_dir = build_output_dir(output_root, lakebooks[0])
start_convert(None, str(lakebooks[0]), str(target_dir), args.downloadImage, args.skip_existing_resources,
open_output=args.open_output)
else:
default_output_root = lakebooks[0].parent if lakebooks else Path.cwd()
output_root = Path(args.output).expanduser() if args.output else default_output_root
run_batch(lakebooks, output_root, args.downloadImage, args.skip_existing_resources,
open_output=args.open_output)
"""Self-contained Yuque lakebook export package for the skill."""
from bs4 import BeautifulSoup, NavigableString, Tag
import json
from urllib import parse
import urllib
import queue
import os
import requests
import time
import re
html_text = """
<div>
<h1>标题1</h1>
<div>
<h2>标题2</h2>
<p>这是什么文档</p>
</div>
</div>
"""
def remove_invalid_characters(filename):
p = r"^[a-zA-Z]:"
prefix = ""
h = re.match(p, filename)
if h:
regs = h.regs
prefix = filename[regs[0][0]:regs[0][1]]
filename = filename.replace(prefix, "")
# 定义要移除的字符列表
invalid_chars = r'[<>:"|?*]'
# 使用正则表达式替换不支持的字符为空字符串
cleaned_filename = re.sub(invalid_chars, "", filename)
return prefix + cleaned_filename
def sanitize_path_segment(name):
"""
清理单个路径片段,避免标题中的路径分隔符被当成目录。
"""
if name is None:
return ""
sanitized_name = str(name).replace("/", "/").replace("\\", "\")
sanitized_name = remove_invalid_characters(sanitized_name).strip()
return sanitized_name or "未命名"
def encode_markdown_path(path):
"""
将本地 markdown 链接路径编码,避免空格等字符导致渲染失败。
"""
return urllib.parse.quote(path, safe="/-_.~")
def build_resource_request_url(data_json):
src = data_json.get("src", "")
crop = data_json.get("crop")
origin_width = data_json.get("originWidth") or data_json.get("width")
origin_height = data_json.get("originHeight") or data_json.get("height")
if not src or not crop or len(crop) != 4 or not origin_width or not origin_height:
return src
left, top, right, bottom = crop
if [left, top, right, bottom] == [0, 0, 1, 1]:
return src
x = max(0, round(origin_width * left))
y = max(0, round(origin_height * top))
w = max(1, round(origin_width * (right - left)))
h = max(1, round(origin_height * (bottom - top)))
crop_query = f"image/crop,x_{x},y_{y},w_{w},h_{h}"
split_result = urllib.parse.urlsplit(src)
query = split_result.query
if query:
query = query + "&x-oss-process=" + urllib.parse.quote(crop_query, safe="/,")
else:
query = "x-oss-process=" + urllib.parse.quote(crop_query, safe="/,")
return urllib.parse.urlunsplit((
split_result.scheme,
split_result.netloc,
split_result.path,
query,
split_result.fragment
))
def get_resource_name(src):
split_result = urllib.parse.urlsplit(src)
resource_name = os.path.basename(split_result.path)
return resource_name or "resource"
class MyContext:
def __init__(self, filename="xxx", download_image=True, image_target="", skip_existing=False, current_file_path="",
doc_path_map=None):
self.template_queue = queue.Queue()
self.result = ""
# 存放图片的目录
self.filename = filename + ".assets"
self.image_target = image_target
# 下载失败的图片
self.failure_images = []
# 下载图片
self.download_image = download_image
# 是否跳过本地已存在的图片文件
self.skip_existing = skip_existing
self.current_file_path = current_file_path
self.doc_path_map = doc_path_map or {}
def append_failure(self, name, image_src):
r = "[{}]{}".format(name, image_src)
self.failure_images.append(r)
def find_file_path(self, file_uid):
target_path = self.doc_path_map.get(file_uid)
if not target_path:
return file_uid
current_dir = os.path.dirname(self.current_file_path) if self.current_file_path else ""
if not current_dir:
return encode_markdown_path(target_path.replace(os.path.sep, "/"))
relative_path = os.path.relpath(target_path, current_dir)
return encode_markdown_path(relative_path.replace(os.path.sep, "/"))
def eventual_tag(tag: Tag) -> bool:
return len(tag.contents) == 0 or (len(tag.contents) == 1 and isinstance(tag.contents[0], NavigableString))
class MyParser:
def __init__(self, htmlText):
self.soup = BeautifulSoup(htmlText, 'html.parser')
self.tagQueue = queue.Queue()
def traverse(self, tag: Tag, deep: int):
if isinstance(tag, NavigableString):
return
self.handle_tag(tag, deep)
for _tag in tag.contents:
_tag: Tag = _tag
if isinstance(_tag, NavigableString):
# print(_tag)
continue
self.traverse(_tag, deep + 1)
def handle_tag(self, tag1: Tag, deep: int):
prefix = " " * deep
print(prefix + tag1.name)
self.tagQueue.put(tag1)
def handle_descent(self, tag: Tag, context1: MyContext) -> str:
tag_name = tag.name
if tag_name == 'span':
return self.handle_span(tag, context1)
elif tag_name == 'p':
return self.handle_p(tag, context1)
elif tag_name == 'h1':
return self.handle_title(tag, 1, context1)
elif tag_name == 'h2':
return self.handle_title(tag, 2, context1)
elif tag_name == 'h3':
return self.handle_title(tag, 3, context1)
elif tag_name == 'h4':
return self.handle_title(tag, 4, context1)
elif tag_name == 'h5':
return self.handle_title(tag, 5, context1)
elif tag_name == 'h6':
return self.handle_title(tag, 6, context1)
elif tag_name == 'h7':
return self.handle_title(tag, 7, context1)
elif tag_name == 'blockquote':
return self.handle_blockquote(tag, context1)
elif tag_name == 'card':
return self.handle_card(tag, context1)
elif tag_name == 'strong':
return self.handle_strong(tag, context1)
elif tag_name == 'em':
return self.handle_em(tag, context1)
elif tag_name == 'del':
return self.handle_del(tag, context1)
elif tag_name == 'u':
return self.handle_u(tag, context1)
elif tag_name == 'sup':
return self.handle_sup(tag, context1)
elif tag_name == 'sub':
return self.handle_sub(tag, context1)
elif tag_name == 'code':
return self.handle_code(tag, context1)
elif tag_name == 'ul':
return self.handle_ul(tag, context1)
elif tag_name == 'ol':
return self.handle_ol(tag, context1)
elif tag_name == 'a':
return self.handle_a(tag, context1)
elif tag_name == 'table':
return self.handle_table(tag, context1)
else:
# print("meet the tag name : " + tag_name)
return self.handle_common(context1, tag)
def handle_title(self, tag: Tag, level: int, context1: MyContext):
prefix = "#" * level
prefix = prefix + " {}\n"
if eventual_tag(tag):
if len(tag) == 0:
return prefix.format("")
return prefix.format(tag.text)
else:
r = self.handle_common(context1, tag)
return prefix.format(r)
def handle_blockquote(self, tag: Tag, context1: MyContext):
template = "> {}\n"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("")
return template.format(tag.text)
else:
r = self.handle_common(context1, tag)
return template.format(r)
def handle_card(self, tag: Tag, context1: MyContext):
name = tag.attrs.get("name")
value = tag.attrs.get("value")
value = value[5:]
data = urllib.parse.unquote(value, encoding='utf-8')
data_json = json.loads(data)
if name == 'codeblock':
mode = data_json.get('mode')
code = data_json.get('code')
card_name = data_json.get('name')
if not mode:
mode = 'plain'
if not card_name:
card_name = ''
f_res = "{0}\n```{1}\n{2}\n```\n".format(card_name, mode, code)
return f_res
elif name == 'image':
card_name = data_json.get('name')
card_name, relative_image_path = self.download_resource(context1, data_json, card_name)
return "\n".format(card_name, "./" + encode_markdown_path(relative_image_path))
elif name == 'hr':
return "\n---\n"
elif name == 'label':
return data_json['label']
elif name == 'math':
la_tex = data_json['code']
card_name, relative_image_path = self.download_resource(context1, data_json, '数学公式')
return la_tex + '\n' + "\n".format(card_name, "./" + encode_markdown_path(relative_image_path))
elif name == 'file':
card_name = data_json.get('name')
card_name, relative_image_path = self.download_resource(context1, data_json, card_name)
return "\n".format(card_name, "./" + encode_markdown_path(relative_image_path))
elif name == 'bookmarklink':
detail = data_json.get("detail") or {}
title = detail.get("title") or data_json.get("text") or data_json.get("src") or ""
url = detail.get("url") or data_json.get("src") or ""
if not url:
return title + "\n" if title else "\n"
return "[{}]({})\n".format(title, url)
elif name == 'diagram':
return self.handle_diagram_card(data_json)
elif name == 'board':
return self.handle_board_card(data_json)
elif name == 'yuque':
src = data_json.get('src')
detail = data_json.get("detail") or {}
candidates = []
if src:
candidates.append(src.split("/")[-1].split("?")[0])
detail_url = detail.get("url")
if detail_url:
candidates.append(detail_url.split("/")[-1].split("?")[0])
target_doc_id = detail.get("doc_id") or data_json.get("doc_id")
if target_doc_id is not None:
candidates.append(str(target_doc_id))
path = None
for candidate in candidates:
path = context1.find_file_path(candidate)
if path != candidate:
break
if not path:
path = src or ""
title = detail.get("title") or src or "语雀文档"
return "[{}]({})".format(title, path)
else:
return "\n"
def download_resource(self, context1, data_json, name):
"""
下载图片
:param context1:
:param data_json:
:param name:
:return:
"""
src = data_json['src']
request_url = build_resource_request_url(data_json)
resource_name = get_resource_name(src)
full_image_name = "/".join([context1.image_target, context1.filename, resource_name])
full_image_path = "/".join([context1.image_target, context1.filename])
relative_image_path = "/".join([context1.filename, resource_name])
if not name:
name = full_image_name
if context1.filename != '':
if not os.path.exists(full_image_path):
full_image_path = remove_invalid_characters(full_image_path)
os.makedirs(full_image_path, exist_ok=True)
try:
if context1.download_image:
full_image_name = remove_invalid_characters(full_image_name)
# 检查文件是否已存在,如果存在且设置了跳过则跳过下载
if context1.skip_existing and os.path.exists(full_image_name):
print(f"图片已存在,跳过下载: {resource_name}")
return name, relative_image_path
time.sleep(0.5)
resp = requests.get(request_url)
if resp.status_code != 200:
raise Exception("失败链接:{},响应码:{}".format(request_url, resp.status_code))
with open(full_image_name, 'wb') as imageFp:
imageFp.write(resp.content)
imageFp.flush()
except Exception as ex:
# ex.with_traceback()
context1.append_failure(name, request_url)
print("附件 {0} 下载失败".format(request_url))
name = "附件下载失败"
return name, relative_image_path
def handle_diagram_card(self, data_json):
diagram_type = data_json.get("type")
code = data_json.get("code")
url = data_json.get("url")
if diagram_type == "mermaid" and code:
return "```mermaid\n{}\n```\n".format(code)
if url:
title = diagram_type or "diagram"
return "\n".format(title, url)
return "\n"
def handle_board_card(self, data_json):
diagram_data = data_json.get("diagramData") or {}
body = diagram_data.get("body") or []
lines = []
for node in body:
self.append_board_node(lines, node, 0)
if not lines:
return "\n"
return "\n".join(lines) + "\n"
def append_board_node(self, lines, node, depth):
text = self.extract_board_text(node.get("html", ""))
if text:
indent = " " * depth
lines.append("{}- {}".format(indent, text))
for child in node.get("children") or []:
self.append_board_node(lines, child, depth + 1)
def extract_board_text(self, html):
if not html:
return ""
if "<" not in html and ">" not in html:
return html.replace("\u200b", "").strip()
text = BeautifulSoup(html, 'html.parser').get_text(" ", strip=True)
text = text.replace("\u200b", "").strip()
return text
def handle_span(self, tag, context1: MyContext):
if eventual_tag(tag):
if len(tag) == 0:
return ""
return tag.text
else:
return self.handle_common(context1, tag)
def handle_p(self, tag, context1: MyContext):
if eventual_tag(tag):
if len(tag) == 0:
return "\n"
return tag.text + "\n"
else:
return self.handle_common(context1, tag) + "\n"
def handle_common(self, context1, tag):
temp_str = ""
for _tag in tag.contents:
if isinstance(_tag, NavigableString):
text = str(_tag)
if not text:
continue
temp_str = temp_str + text
continue
temp_str += self.handle_descent(_tag, context1)
return temp_str
def handle_strong(self, tag, context1: MyContext):
template = "**{}**"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("")
return template.format(tag.text)
else:
r = self.handle_common(context1, tag)
return template.format(r)
def handle_em(self, tag, context1: MyContext):
# 斜体字
template = "*{}*"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("")
return template.format(tag.text)
else:
r = self.handle_common(context1, tag)
return template.format(r)
def handle_del(self, tag, context1: MyContext):
"""
删除字
:param context1:
:param tag:
:return:
"""
template = "~~{}~~"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("")
return template.format(tag.text)
else:
r = self.handle_common(context1, tag)
return template.format(r)
def handle_u(self, tag, context1: MyContext):
"""
下划线
:param context1:
:param tag:
:return:
"""
template = "<u>{}</u>"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("")
return template.format(tag.text)
else:
r = self.handle_common(context1, tag)
return template.format(r)
def handle_sup(self, tag, context1: MyContext):
"""
上标
:param context1:
:param tag:
:return:
"""
template = "^{}"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("")
return template.format(tag.text)
else:
r = self.handle_common(context1, tag)
return template.format(r)
def handle_sub(self, tag, context1: MyContext):
"""
下标
:param context1:
:param tag:
:return:
"""
template = "~{}"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("")
return template.format(tag.text)
else:
r = self.handle_common(context1, tag)
return template.format(r)
def handle_code(self, tag, context1: MyContext):
"""
将ne-code转换为markdown行内代码格式
:param context1:
:param tag:
:return:
"""
template = "`{}`"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("")
return template.format(tag.text)
else:
r = self.handle_common(context1, tag)
return template.format(r)
def handle_ul(self, tag, context1: MyContext):
"""
无序列表
:param context1:
:param tag:
:return:
"""
template = "- {}\n"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("")
return template.format(tag.text)
else:
tempStr = ""
for _tag in tag.contents:
if isinstance(_tag, NavigableString):
tempStr += _tag
continue
if _tag.name == 'li':
template1 = "- {}\n"
r = self.handle_common(context1, _tag)
tempStr += template1.format(r)
else:
r = self.handle_common(context1, _tag)
tempStr += r
if tempStr and not tempStr.endswith("\n"):
tempStr += "\n"
return tempStr
def handle_ol(self, tag, context1: MyContext):
"""
有序列表
:param context1:
:param tag:
:return:
"""
template = "1. {}"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("")
return template.format(tag.text) + "\n"
else:
tempStr = ""
count = 1
for _tag in tag.contents:
if isinstance(_tag, NavigableString):
tempStr += _tag
continue
if _tag.name == 'li':
template1 = "{}. {}\n"
r = self.handle_common(context1, _tag)
tempStr += template1.format(count, r)
count += 1
else:
r = self.handle_common(context1, _tag)
tempStr += r
if tempStr and not tempStr.endswith("\n"):
tempStr += "\n"
return tempStr
def handle_a(self, tag, context1: MyContext):
"""
链接
:param context1:
:param tag:
:return:
"""
template = "[{}]({})"
if eventual_tag(tag):
if len(tag) == 0:
return template.format("temp", "temp")
return template.format(tag.text, tag.attrs.get("href"))
else:
href = tag.attrs.get("href")
r = self.handle_common(context1, tag)
return template.format(r, href)
def handle_table(self, tag, context1: MyContext):
"""
表格
:param context1:
:param tag:
:return:
"""
tbody: Tag = tag.tbody
# if not tbody:
# return self.handle_common(context1, tag)
table_str = ""
row_count = 0
col_num = 0
for _tr in tbody.contents:
cells = []
for _td in _tr.contents:
if row_count == 0:
col_num += 1
r = self.handle_common(context1, _td)
r = r.replace("\n", " ").strip()
cells.append(r)
row = "| " + " | ".join(cells) + " |\n"
table_str += row
if row_count == 0:
separate = "| " + " | ".join(["---" for _ in range(0, col_num)]) + " |\n"
table_str += separate
row_count += 1
return table_str
#
if __name__ == '__main__':
# fp = open('../html_test/lake_asl.html', mode='r', encoding='utf-8')
# allText = fp.readlines()
# fp.close()
my = MyParser(html_text)
# my = MyParser("".join(allText))
# my.traverse(my.soup, 0)
context = MyContext()
res = my.handle_descent(my.soup, context)
# content = "\n".join(context.result)
print(res)
f = open('../html_test/lake_data_t.md', 'w', encoding='utf-8')
f.writelines(res)
f.flush()
f.close()
# s = "{0},{0},{1}".format("nihao", "hello")
# print(s)
import os.path
import tarfile
__EMPTY_ = 0x00
__HEAD_LEN_ = 512
def unpack_lake_book_file(lake_file, extract_to):
"""
将lakebook文件抽出到指定目录
:param lake_file: lakebook文件
:param extract_to: 指定目录
:return: meta.json的目录
"""
if not os.path.exists(extract_to):
os.makedirs(extract_to)
with tarfile.open(lake_file, 'r') as tar_ref:
tar_ref.extractall(extract_to)
secondary_dir_name = get_lake_book_dir_name(lake_book_path=lake_file)
return os.path.join(extract_to, secondary_dir_name)
def get_lake_book_dir_name(lake_book_path):
"""
获取lakebook的次级目录
"""
with open(lake_book_path, 'rb') as f:
head_block_byte = f.read(512)
file_name = get_file_name(head_block_byte)
name = os.path.split(file_name)[0]
return name
def get_file_name(head_block):
file_name_part_byte = head_block[:100]
file_name_byte = []
for b in file_name_part_byte:
if b == __EMPTY_:
break
else:
file_name_byte.append(b)
return bytes(file_name_byte).decode("utf-8")
import json
import re
import traceback
import yaml
import os
import shutil
import tempfile
from yuque_lakebook_export.lake_handle import MyParser, MyContext, remove_invalid_characters, sanitize_path_segment
from yuque_lakebook_export.lake_reader import unpack_lake_book_file
class GlobalContext:
def __init__(self):
# parent_id和book
self.parent_id_and_child = {}
# 将id和book映射起来
self.id_and_book = {}
# 存放根目录的book
self.root_books = []
self.file_count = 0
self.all_file_count = 0
self.failure_image_download_list = []
self.file_total = 0
self.total = 0
self.download_image = True
self.skip_existing = False
self.root_path = ""
self.doc_path_map = {}
HEADING_RE = re.compile(r'^\s{0,3}#{1,6}\s+')
HR_RE = re.compile(r'^\s{0,3}([-*_])(\s*\1){2,}\s*$')
TABLE_ROW_RE = re.compile(r'^\s*\|.*\|\s*$')
LIST_RE = re.compile(r'^\s*(?:[-+*]|\d+\.)\s+')
LAKE_DOCTYPE_RE = re.compile(r'^\ufeff?(?:<!doctype\s+lake>\s*)?', re.IGNORECASE)
def strip_lake_prefix_artifact(text):
normalized = text.lstrip("\ufeff")
normalized = LAKE_DOCTYPE_RE.sub("", normalized, count=1)
if not normalized[:4].lower() == "lake":
return normalized
remainder = normalized[4:]
stripped_remainder = remainder.lstrip()
if not stripped_remainder:
return ""
marker = stripped_remainder[0]
if (
marker.isupper()
or not marker.isascii()
or marker in {"!", "#", "*", "-", "_", "`", "~", ">", "[", "(", "{", "|", ":"}
):
return stripped_remainder
return normalized
def normalize_markdown(text):
"""
统一整理块级元素之间的空行,提升 Obsidian 等 Markdown 渲染兼容性。
"""
text = strip_lake_prefix_artifact(text)
lines = text.splitlines()
if not lines:
return text
normalized = []
in_fence = False
def is_blank(value):
return value.strip() == ""
def is_fence(value):
stripped = value.strip()
return stripped.startswith("```") or stripped.startswith("~~~")
def is_heading(value):
return bool(HEADING_RE.match(value))
def is_hr(value):
return bool(HR_RE.match(value))
def is_table_row(value):
return bool(TABLE_ROW_RE.match(value))
def previous_nonblank():
for item in reversed(normalized):
if not is_blank(item):
return item
return None
for index, line in enumerate(lines):
stripped = line.rstrip()
next_line = lines[index + 1] if index + 1 < len(lines) else ""
prev = previous_nonblank()
current_is_fence = is_fence(stripped)
current_is_heading = is_heading(stripped)
current_is_hr = is_hr(stripped)
current_is_table = is_table_row(stripped)
next_is_table = is_table_row(next_line)
if not in_fence:
should_prepend_blank = False
if current_is_heading or current_is_hr or current_is_fence:
should_prepend_blank = prev is not None
elif current_is_table and prev is not None and not is_table_row(prev):
should_prepend_blank = True
if should_prepend_blank and normalized and not is_blank(normalized[-1]):
normalized.append("")
normalized.append(stripped)
if current_is_fence:
in_fence = not in_fence
if not in_fence and next_line and next_line.strip() and not is_blank(next_line):
if not is_blank(normalized[-1]):
normalized.append("")
continue
if in_fence:
continue
if current_is_table and not next_is_table:
if next_line and next_line.strip():
normalized.append("")
elif (current_is_heading or current_is_hr) and next_line and next_line.strip():
normalized.append("")
collapsed = []
blank_count = 0
for line in normalized:
if is_blank(line):
blank_count += 1
if blank_count > 1:
continue
else:
blank_count = 0
collapsed.append(line)
result = "\n".join(collapsed).strip() + "\n"
return result
# from lxml import etree
def load_meta_json(global_context: GlobalContext):
"""
解析meta.json中标注的文件关系
:return:
"""
full_path = "/".join([global_context.root_path, "$meta.json"])
fp = open(full_path, 'r', encoding='utf-8')
json_obj = json.load(fp)
meta = json_obj['meta']
# print(meta)
meta_obj = json.loads(meta)
book_yml = meta_obj['book']['tocYml']
# print(book_yml)
# with open('meta_book_yml.yaml', 'w+', encoding='utf-8') as yaml_fp:
# yaml_fp.write(book_yml)
# yaml_fp.flush()
books = yaml.load(book_yml, yaml.Loader)
for book in books:
if book.get('uuid'):
global_context.id_and_book[book['uuid']] = book
if book['type'] == 'META':
continue
if book['parent_uuid'] == '':
global_context.root_books.append(book)
continue
parent_uuid = book['parent_uuid']
if global_context.parent_id_and_child.get(parent_uuid):
global_context.parent_id_and_child[parent_uuid].append(book)
else:
global_context.parent_id_and_child[parent_uuid] = []
global_context.parent_id_and_child[parent_uuid].append(book)
# print(books)
global_context.file_total = len(global_context.id_and_book)
global_context.total = len(global_context.id_and_book)
def create_tree_dir(global_context, parent_dir, book):
"""
根据解析出的关系创建文档的目录树
:param parent_dir: 当前文档所在的父目录
:param book: 当前book对象
:param global_context 上下文
:return:
"""
if book is None:
return
uuid = book['uuid']
name = sanitize_path_segment(book['title'])
file_url = book['url']
book_children = global_context.parent_id_and_child.get(uuid)
has_children = bool(book_children)
current_dir = parent_dir
if has_children or file_url == '':
current_dir = remove_invalid_characters(os.path.join(parent_dir, name))
if not os.path.exists(current_dir):
os.makedirs(current_dir, exist_ok=True)
global_context.all_file_count += 1
if file_url != '':
ltm = LakeToMd(
os.path.join(global_context.root_path, "{}.json".format(file_url)),
target=os.path.join(current_dir if has_children else parent_dir, name)
)
ltm.to_md(global_context)
global_context.failure_image_download_list += ltm.image_download_failure
global_context.file_count += 1
# print("\r", end="")
# i = (file_count // file_total) * 100
print("\rprocess progress: {}/{}/{}. ".format(global_context.file_count, global_context.all_file_count,
global_context.file_total), end="")
# sys.stdout.flush()
# time.sleep(0.05)
if not book_children:
return
for child in book_children:
create_tree_dir(global_context, current_dir, child)
def register_doc_paths(global_context, parent_dir, book):
"""
预先计算所有导出 markdown 路径,供内部文档链接转相对路径使用。
"""
if book is None:
return
uuid = book['uuid']
name = sanitize_path_segment(book['title'])
file_url = book['url']
book_children = global_context.parent_id_and_child.get(uuid)
has_children = bool(book_children)
current_dir = parent_dir
if has_children or file_url == '':
current_dir = remove_invalid_characters(os.path.join(parent_dir, name))
if file_url != '':
target_base = os.path.join(current_dir if has_children else parent_dir, name)
target_md_path = remove_invalid_characters(target_base) + ".md"
global_context.doc_path_map[uuid] = target_md_path
global_context.doc_path_map[file_url] = target_md_path
if book.get('doc_id') is not None:
global_context.doc_path_map[str(book['doc_id'])] = target_md_path
if not book_children:
return
for child in book_children:
register_doc_paths(global_context, current_dir, child)
class LakeToMd:
body_html = None
image_download_failure = []
def __init__(self, filename, target):
self.filename = filename
self.target = target
self.__body_html()
def __body_html(self):
fp = open(file=self.filename, mode='r', encoding='utf-8')
file_json = json.load(fp)
fp.close()
self.body_html = self._extract_body(file_json)
@staticmethod
def _extract_body(file_json):
doc_json = file_json.get("doc") or {}
body_fields = (
"body_draft_asl",
"body_asl",
"body_draft",
"body",
)
for field in body_fields:
body = doc_json.get(field)
if isinstance(body, str) and body.strip():
return LakeToMd._normalize_body(body)
return ""
@staticmethod
def _normalize_body(body):
"""
某些 lakebook 文档正文会把语雀 Lake 格式标记残留在开头,例如 `lake<h2>...`。
这里在保留原有 ASL/Lake 解析能力的前提下,去掉这个误前缀,避免导出 `lake##`。
"""
return strip_lake_prefix_artifact(body)
def to_md(self, global_context):
mp = MyParser(self.body_html)
name = os.path.basename(self.target)
short_target = os.path.dirname(self.target)
current_file_path = remove_invalid_characters(self.target) + ".md"
if short_target and not os.path.exists(short_target):
os.makedirs(short_target, exist_ok=True)
context = MyContext(
filename=name,
image_target=short_target,
download_image=global_context.download_image,
skip_existing=global_context.skip_existing,
current_file_path=current_file_path,
doc_path_map=global_context.doc_path_map
)
res = mp.handle_descent(mp.soup, context)
res = normalize_markdown(res)
self.image_download_failure += context.failure_images
self.target = remove_invalid_characters(self.target)
if not res.strip():
print(f"\n警告: 文档导出结果为空 -> {self.filename}")
with open(self.target + ".md", 'w+', encoding='utf-8') as fp:
fp.writelines(res)
fp.flush()
def convert_to_md(global_context, file_path, open_output=False):
output_path = file_path
if not os.path.exists(output_path):
os.makedirs(output_path, exist_ok=True)
global_context.doc_path_map = {}
for root_book in global_context.root_books:
register_doc_paths(global_context, output_path, root_book)
for root_book in global_context.root_books:
create_tree_dir(global_context, output_path, root_book)
print("\n>>> markdown 转换完成")
if open_output:
# 根据操作系统选择合适的命令打开文件夹
import platform
system = platform.system()
if system == 'Windows':
os.system("explorer " + output_path)
elif system == 'Darwin': # macOS
os.system("open " + output_path)
elif system == 'Linux':
os.system("xdg-open " + output_path)
else:
print("未识别的操作系统,无法自动打开输出文件夹")
def start_convert(meta, lake_book, output, download_image_of_in, skip_existing=False, open_output=False):
global_context = GlobalContext()
temp_dir = tempfile.mkdtemp(prefix="yuque_export_")
result = {
"success": False,
"file_count": 0,
"output": os.path.abspath(output) if output else output,
"error": None,
"failure_images": []
}
if lake_book:
global_context.root_path = unpack_lake_book_file(lake_book, temp_dir)
print(">>> lake文件抽取完成")
else:
global_context.root_path = meta
if not global_context.root_path:
print("参数校验失败!-i或者-l二者必须有一个")
result["error"] = "参数校验失败!-i或者-l二者必须有一个"
return result
try:
load_meta_json(global_context)
print(">>> meta json解析完成")
global_context.download_image = download_image_of_in
global_context.skip_existing = skip_existing
abspath = os.path.abspath(output)
print(">>> 开始进行markdown转换")
convert_to_md(global_context, abspath, open_output=open_output)
print("共导出%s个文件" % global_context.file_count)
print("图片下载错误列表:")
print(' list: ', global_context.failure_image_download_list)
print(' count:', len(global_context.failure_image_download_list))
result["success"] = True
result["file_count"] = global_context.file_count
result["failure_images"] = list(global_context.failure_image_download_list)
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
except Exception as e:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
print(e)
result["error"] = str(e)
result["traceback"] = traceback.format_exc()
return result
# """
# 已经完成到根据meta生成目录了
# """
# if __name__ == '__main__':
# load_meta_json('data/$.meta.json')
# file_total = len(id_and_book)
# total = len(id_and_book)
# abspath = os.path.abspath("test")
# convert_to_md(abspath)
# print("共导出%s个文件" % file_count)
#
# print("图片下载错误列表:")
# print(failure_image_download_list)
# parse_failure_result(failure_image_download_list)
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "beautifulsoup4"
version = "4.12.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/0b/44c39cf3b18a9280950ad63a579ce395dda4c32193ee9da7ff0aed547094/beautifulsoup4-4.12.2.tar.gz", hash = "sha256:492bbc69dca35d12daac71c4db1bfff0c876c00ef4a2ffacce226d4638eb72da", size = 505113, upload-time = "2023-04-07T15:02:49.038Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/f4/a69c20ee4f660081a7dedb1ac57f29be9378e04edfcb90c526b923d4bebc/beautifulsoup4-4.12.2-py3-none-any.whl", hash = "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a", size = 142979, upload-time = "2023-04-07T15:02:50.77Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/af35f7ea75cf72f2cd079c95ee16797de7cd71f29ea7c68ae5ce7be1eda0/PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", size = 125201, upload-time = "2023-07-18T00:00:23.308Z" }
[[package]]
name = "requests"
version = "2.31.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/be/10918a2eac4ae9f02f6cfe6414b7a155ccd8f7f9d4380d62fd5b955065c3/requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1", size = 110794, upload-time = "2023-05-22T15:12:44.175Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", size = 62574, upload-time = "2023-05-22T15:12:42.313Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "yuque-lakebook-export-skill-cn"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "pyyaml" },
{ name = "requests" },
]
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = "==4.12.2" },
{ name = "pyyaml", specifier = "==6.0.1" },
{ name = "requests", specifier = "==2.31.0" },
]