
Yuque Lakebook Export
- 39 installs
- 16 repo stars
- Updated July 13, 2026
- yangsonhung/awesome-agent-skills
Helps with ai & agent building tasks.
About
yuque-lakebook-export is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- yuque-lakebook-export
- AI & Agent Building
- AI-coding skill
Yuque Lakebook Export by the numbers
- 39 all-time installs (skills.sh)
- +2 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #8,302 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-exportAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 39 |
|---|---|
| repo stars | ★ 16 |
| Last updated | July 13, 2026 |
| Repository | yangsonhung/awesome-agent-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Yuque Lakebook Export
Convert one or more Yuque .lakebook files into local Markdown folders, with images and internal document links prepared for Obsidian.
When to Use
Use this skill when the user asks for:
- Exporting one or more Yuque
.lakebookfiles - Converting a Yuque knowledge base into Markdown
- Migrating Yuque content into Obsidian
- Fixing Yuque export issues around images, cropped images, internal links, hierarchy, or tables
Do not use
Do not use this skill for:
- Generic Markdown editing that does not involve Yuque or
.lakebook - Website scraping tasks
- Export tasks that already come from a non-Yuque format
Instructions
1. Prefer non-interactive execution so the agent can run deterministically. 2. Before any non-interactive export, the agent must confirm the output root directory with the user. Do not choose an output directory on the user's behalf. 3. If the user has not provided an output directory, ask a concise question and wait for the user's answer before running the export command. 4. Prefer uv consistently. Do not create temporary .venv or similar task-local environments in the working directory. 5. Before running any uv command, first check whether uv is available in the environment. 6. If uv is not installed or not available in PATH, the agent must ask the user for confirmation before installing uv. Do not install it silently. 7. Before running uv sync or uv run python scripts/cli.py ..., the agent must first switch into the installed skill tool directory, meaning the directory that contains this SKILL.md, pyproject.toml, uv.lock, and scripts/. Do not run these commands directly from the .lakebook source directory, the output directory, or the user's current workspace root. 8. Use this entrypoint for agent execution:
uv run python scripts/cli.py9. Sync dependencies before first use:
uv sync10. Recommended command order:
cd /path/to/installed-skill-root
uv sync
uv run python scripts/cli.py ...11. Standard single-file execution:
uv run python scripts/cli.py -l "/path/to/your_file.lakebook" -o "/target/root"12. Standard batch execution:
uv run python scripts/cli.py -l "/path/to/your_file_1.lakebook" "/path/to/your_file_2.lakebook" -o "/target/root"13. Although scripts/cli.py still supports interactive terminal selection for manual human use, agents must not rely on interactive mode because they cannot reliably capture terminal interaction state. Always pass explicit -l and -o arguments. 14. Do not manually create temporary virtual environments in the current working directory, the user's download directory, or any task directory; dependencies should be managed by uv from the skill tool directory. 15. Some Yuque exports include <!doctype lake> at the start of the document body; older implementations could render this as a stray lake## prefix in Markdown. The current skill implementation already handles this case. 16. After export, verify:
.mdfiles exist- sibling
.assetsfolders exist - internal links are relative Markdown paths
- images render in Obsidian
- exported documents do not start with an erroneous
lake##prefix
17. If export fails, inspect the batch log written next to the input .lakebook files.
For detailed behavior, troubleshooting, and output rules, read references/usage.md.
[project]
name = "yuque-lakebook-export-skill"
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",
]
Usage
Inputs
- One or more
.lakebookfiles - One output root directory
- Preferred entry point:
uv run python scripts/cli.py ...
Outputs
For a document named your_document:
your_document.md
your_document.assets/your_document.mdcontains the Markdown bodyyour_document.assets/stores downloaded images and attachments
Supported behavior
- Yuque internal links become relative Markdown links
- Titles containing
/or\are sanitized - Pages without children stay as a direct
.mdfile - Pages with children create a same-name folder and keep the page as
same-name.md - Images preserve Yuque crop settings when crop metadata exists
- Batch execution continues after individual file failures
Troubleshooting
- First switch into the installed skill tool directory, then run
uv sync; this is the directory that containsSKILL.md,pyproject.toml,uv.lock, andscripts/ - Both
uv syncanduv run python scripts/cli.py ...must be executed from that skill tool directory, not from the.lakebooksource directory, the output directory, or the user's current workspace root - Avoid creating task-local virtual environments under the user's working directory, download directory, or any task directory
- Missing
bs4: runuv sync - Missing images in Obsidian: check generated
.assetspaths and URL encoding - Wrong table rendering: re-export with the bundled parser because it normalizes Markdown spacing
- Broken internal links: ensure the source
.lakebookincludes complete toc metadata
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):
"""
Some lakebook documents retain a Yuque Lake format marker at the beginning,
for example `lake<h2>...`. Strip the accidental prefix so it does not render
as `lake##` in the exported Markdown.
"""
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"
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" },
]