211 lines
6.2 KiB
Python
211 lines
6.2 KiB
Python
import asyncio
|
||
import hashlib
|
||
import os
|
||
import re
|
||
import time
|
||
import zipfile
|
||
from pathlib import Path
|
||
|
||
from yuxi.storage.minio import get_minio_client
|
||
from yuxi.utils import logger
|
||
|
||
DEFAULT_IMAGE_BUCKET = "public"
|
||
DEFAULT_IMAGE_PREFIX = "unknown/kb-images"
|
||
|
||
|
||
def _normalize_object_prefix(prefix: str | None) -> str:
|
||
normalized = (prefix or DEFAULT_IMAGE_PREFIX).strip("/")
|
||
return normalized or DEFAULT_IMAGE_PREFIX
|
||
|
||
|
||
async def process_zip_file(
|
||
zip_path: str,
|
||
image_bucket: str = DEFAULT_IMAGE_BUCKET,
|
||
image_prefix: str = DEFAULT_IMAGE_PREFIX,
|
||
) -> dict:
|
||
"""
|
||
处理ZIP文件,提取markdown内容和图片
|
||
|
||
Args:
|
||
zip_path: ZIP文件路径
|
||
image_bucket: 图片上传的目标 bucket
|
||
image_prefix: 图片上传对象前缀
|
||
|
||
Returns:
|
||
dict: {
|
||
"markdown_content": str,
|
||
"content_hash": str,
|
||
"images_info": list[dict]
|
||
}
|
||
"""
|
||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||
for name in zf.namelist():
|
||
if name.startswith("/") or name.startswith("\\"):
|
||
raise ValueError(f"ZIP 包含不安全路径: {name}")
|
||
if ".." in Path(name).parts:
|
||
raise ValueError(f"ZIP 路径包含上级引用: {name}")
|
||
|
||
md_files = [n for n in zf.namelist() if n.lower().endswith(".md")]
|
||
if not md_files:
|
||
raise ValueError("压缩包中未找到 .md 文件")
|
||
|
||
md_file = next((n for n in md_files if Path(n).name == "full.md"), md_files[0])
|
||
|
||
with zf.open(md_file) as f:
|
||
markdown_content = f.read().decode("utf-8")
|
||
|
||
images_info = []
|
||
images_dir = find_images_directory(zf, md_file)
|
||
normalized_prefix = _normalize_object_prefix(image_prefix)
|
||
|
||
if images_dir:
|
||
images_info = await process_images(
|
||
zf,
|
||
images_dir,
|
||
image_bucket=image_bucket,
|
||
image_prefix=normalized_prefix,
|
||
)
|
||
markdown_content = replace_image_links(markdown_content, images_info)
|
||
|
||
content_hash = hashlib.sha256(markdown_content.encode("utf-8")).hexdigest()
|
||
|
||
return {
|
||
"markdown_content": markdown_content,
|
||
"content_hash": content_hash,
|
||
"images_info": images_info,
|
||
}
|
||
|
||
|
||
def process_zip_file_sync(
|
||
zip_path: str,
|
||
image_bucket: str = DEFAULT_IMAGE_BUCKET,
|
||
image_prefix: str = DEFAULT_IMAGE_PREFIX,
|
||
) -> dict:
|
||
"""同步调用 ZIP 处理,供同步解析器使用。"""
|
||
try:
|
||
asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
return asyncio.run(process_zip_file(zip_path, image_bucket=image_bucket, image_prefix=image_prefix))
|
||
|
||
result: dict | None = None
|
||
error: Exception | None = None
|
||
|
||
def runner() -> None:
|
||
nonlocal result, error
|
||
try:
|
||
result = asyncio.run(process_zip_file(zip_path, image_bucket=image_bucket, image_prefix=image_prefix))
|
||
except Exception as exc: # pragma: no cover - pass through outer raise
|
||
error = exc
|
||
|
||
import threading
|
||
|
||
thread = threading.Thread(target=runner, daemon=True)
|
||
thread.start()
|
||
thread.join()
|
||
|
||
if error is not None:
|
||
raise error
|
||
|
||
if result is None:
|
||
raise RuntimeError("ZIP 处理失败: 未返回结果")
|
||
|
||
return result
|
||
|
||
|
||
def find_images_directory(zip_file: zipfile.ZipFile, md_file_path: str) -> str | None:
|
||
"""查找images目录"""
|
||
md_parent = Path(md_file_path).parent
|
||
|
||
candidates = []
|
||
if str(md_parent) != ".":
|
||
candidates.extend([str(md_parent / "images"), str(md_parent.parent / "images")])
|
||
candidates.append("images")
|
||
|
||
for cand in candidates:
|
||
cand_clean = cand.rstrip("/")
|
||
if any(n.startswith(cand_clean + "/") for n in zip_file.namelist()):
|
||
return cand_clean
|
||
|
||
return None
|
||
|
||
|
||
async def process_images(
|
||
zip_file: zipfile.ZipFile,
|
||
images_dir: str,
|
||
image_bucket: str,
|
||
image_prefix: str,
|
||
) -> list[dict]:
|
||
"""处理图片:上传到MinIO并返回信息"""
|
||
supported_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||
|
||
images = []
|
||
image_names = [n for n in zip_file.namelist() if n.startswith(images_dir + "/")]
|
||
normalized_prefix = _normalize_object_prefix(image_prefix)
|
||
|
||
minio_client = get_minio_client()
|
||
await asyncio.to_thread(minio_client.ensure_bucket_exists, image_bucket)
|
||
|
||
for img_name in image_names:
|
||
suffix = Path(img_name).suffix.lower()
|
||
if suffix not in supported_extensions:
|
||
continue
|
||
|
||
try:
|
||
with zip_file.open(img_name) as f:
|
||
data = f.read()
|
||
|
||
timestamp = int(time.time() * 1000000)
|
||
object_name = f"{normalized_prefix}/{timestamp}_{Path(img_name).name}"
|
||
|
||
result = await minio_client.aupload_file(
|
||
bucket_name=image_bucket,
|
||
object_name=object_name,
|
||
data=data,
|
||
)
|
||
|
||
img_info = {
|
||
"name": Path(img_name).name,
|
||
"url": result.url,
|
||
"path": f"images/{Path(img_name).name}",
|
||
}
|
||
images.append(img_info)
|
||
|
||
logger.debug(f"图片上传成功: {Path(img_name).name} -> {result.url}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"上传图片失败 {Path(img_name).name}: {e}")
|
||
continue
|
||
|
||
return images
|
||
|
||
|
||
def replace_image_links(markdown_content: str, images: list[dict]) -> str:
|
||
"""替换markdown中的图片链接为MinIO URL"""
|
||
if not images:
|
||
return markdown_content
|
||
|
||
image_map = {}
|
||
for img in images:
|
||
path = img["path"]
|
||
url = img["url"]
|
||
image_map[path] = url
|
||
image_map[f"/{path}"] = url
|
||
image_map[img["name"]] = url
|
||
|
||
def replace_link(match):
|
||
alt_text = match.group(1) or ""
|
||
img_path = match.group(2)
|
||
|
||
for pattern, url in image_map.items():
|
||
if img_path.endswith(pattern) or img_path == pattern:
|
||
return f""
|
||
|
||
filename = os.path.basename(img_path)
|
||
if filename in image_map:
|
||
return f""
|
||
|
||
return match.group(0)
|
||
|
||
pattern = r"!\[([^\]]*)\]\(([^)]+)\)"
|
||
return re.sub(pattern, replace_link, markdown_content)
|