833 lines
31 KiB
Python
833 lines
31 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
批量爬取结果 ZIP 打包模块 v4.0
|
||
将爬取结果按类型分目录、生成索引文件,打包为 ZIP 供用户下载。
|
||
|
||
功能:
|
||
- 收集爬取结果 → 按类型/来源分目录
|
||
- 生成索引 README.md + metadata JSON
|
||
- 打包 ZIP(支持分卷 >50MB)
|
||
- 支持自定义文件名、输出目录
|
||
- 增强 BatchInstitutionCrawler 添加 crawl_and_package() 方法
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import hashlib
|
||
import zipfile
|
||
import shutil
|
||
import tempfile
|
||
from pathlib import Path
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from typing import Dict, Any, List, Optional, Tuple, Callable
|
||
from datetime import datetime
|
||
from dataclasses import dataclass, field
|
||
|
||
SKILL_DATA_DIR = Path(__file__).parent.parent / "data"
|
||
PACKAGES_DIR = SKILL_DATA_DIR / "packages"
|
||
TEMP_DIR = SKILL_DATA_DIR / "temp_packages"
|
||
|
||
SKILL_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
PACKAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
# ==================== 数据结构 ====================
|
||
|
||
@dataclass
|
||
class PackagedItem:
|
||
"""打包条目"""
|
||
name: str
|
||
content: str = ""
|
||
content_type: str = "text" # text / html / json / file
|
||
source_url: str = ""
|
||
category: str = "通用"
|
||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||
file_path: str = "" # 如果是文件,记录路径
|
||
|
||
|
||
@dataclass
|
||
class PackageResult:
|
||
"""打包结果"""
|
||
zip_path: str
|
||
zip_size_mb: float
|
||
item_count: int
|
||
category_count: int
|
||
file_list: List[str]
|
||
metadata: Dict[str, Any]
|
||
created_at: str
|
||
name: str = ""
|
||
|
||
|
||
# ==================== ZIP 打包器 ====================
|
||
|
||
class CrawlPackager:
|
||
"""批量爬取结果 ZIP 打包器。"""
|
||
|
||
MAX_VOLUME_SIZE = 50 * 1024 * 1024 # 50MB 分卷阈值
|
||
|
||
def __init__(self, output_dir: str = ""):
|
||
self.output_dir = Path(output_dir) if output_dir else PACKAGES_DIR
|
||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
def package(self, items: List[Any], zip_name: str = "",
|
||
output_dir: str = "",
|
||
include_metadata: bool = True,
|
||
categorize: bool = True,
|
||
progress_callback: Optional[Callable[[int, int, str], None]] = None) -> str:
|
||
"""
|
||
将爬取结果打包为 ZIP。
|
||
|
||
Args:
|
||
items: 爬取结果列表(dict / PackagedItem / str)
|
||
zip_name: ZIP 文件名(不含扩展名)
|
||
output_dir: 输出目录
|
||
include_metadata: 是否包含 metadata JSON
|
||
categorize: 是否按类别分目录
|
||
progress_callback: v6.0.0 进度回调 (done, total, item_name),
|
||
每写入一个文件调用一次
|
||
|
||
返回: ZIP 文件路径
|
||
"""
|
||
if output_dir:
|
||
self.output_dir = Path(output_dir)
|
||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 标准化 items
|
||
packaged = self._normalize_items(items)
|
||
if not packaged:
|
||
raise ValueError("没有可打包的内容")
|
||
|
||
# 按类别分组
|
||
categories: Dict[str, List[PackagedItem]] = {}
|
||
if categorize:
|
||
for item in packaged:
|
||
cat = item.category or "其他"
|
||
if cat not in categories:
|
||
categories[cat] = []
|
||
categories[cat].append(item)
|
||
else:
|
||
categories["全部"] = packaged
|
||
|
||
# 生成 ZIP 文件名
|
||
if not zip_name:
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
zip_name = f"crawl_package_{timestamp}"
|
||
|
||
# 创建唯一临时目录构建文件结构,避免并发/重复运行互相锁文件
|
||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||
temp_dir = Path(tempfile.mkdtemp(prefix=f"{zip_name}_", dir=str(TEMP_DIR)))
|
||
|
||
try:
|
||
# 写入文件
|
||
file_list = []
|
||
total = sum(len(c) for c in categories.values())
|
||
done = 0
|
||
for cat_name, cat_items in categories.items():
|
||
cat_dir = temp_dir / self._safe_filename(cat_name)
|
||
cat_dir.mkdir(exist_ok=True)
|
||
|
||
for i, item in enumerate(cat_items):
|
||
fname = self._generate_filename(item, i)
|
||
fpath = cat_dir / fname
|
||
content = self._get_content(item)
|
||
|
||
if content:
|
||
with open(fpath, 'w', encoding='utf-8') as f:
|
||
f.write(content)
|
||
file_list.append(str(fpath.relative_to(temp_dir)))
|
||
# v6.0.0: 进度回调
|
||
done += 1
|
||
if progress_callback is not None:
|
||
try:
|
||
progress_callback(done, total, item.name)
|
||
except Exception:
|
||
pass
|
||
|
||
# 生成索引 README.md
|
||
index_content = self._generate_index(zip_name, categories, file_list)
|
||
with open(temp_dir / "README.md", 'w', encoding='utf-8') as f:
|
||
f.write(index_content)
|
||
file_list.append("README.md")
|
||
|
||
# 生成 metadata JSON
|
||
if include_metadata:
|
||
metadata = self._generate_metadata(zip_name, packaged, categories)
|
||
with open(temp_dir / "metadata.json", 'w', encoding='utf-8') as f:
|
||
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||
file_list.append("metadata.json")
|
||
|
||
# 创建 ZIP
|
||
zip_path = str(self.output_dir / f"{zip_name}.zip")
|
||
|
||
# 检查是否需要分卷
|
||
total_size = self._get_dir_size(temp_dir)
|
||
if total_size > self.MAX_VOLUME_SIZE:
|
||
return self._create_split_zip(temp_dir, zip_name, total_size)
|
||
else:
|
||
return self._create_zip(temp_dir, zip_path)
|
||
|
||
finally:
|
||
# 清理临时目录
|
||
if temp_dir.exists():
|
||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||
|
||
def package_from_files(self, file_paths: List[str], zip_name: str = "",
|
||
output_dir: str = "") -> str:
|
||
"""
|
||
将文件列表打包为 ZIP。
|
||
|
||
Args:
|
||
file_paths: 文件路径列表
|
||
zip_name: ZIP 文件名
|
||
output_dir: 输出目录
|
||
|
||
返回: ZIP 文件路径
|
||
"""
|
||
if output_dir:
|
||
self.output_dir = Path(output_dir)
|
||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
if not zip_name:
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
zip_name = f"file_package_{timestamp}"
|
||
|
||
zip_path = str(self.output_dir / f"{zip_name}.zip")
|
||
|
||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||
for fp in file_paths:
|
||
if os.path.isfile(fp):
|
||
arcname = os.path.basename(fp)
|
||
zf.write(fp, arcname)
|
||
|
||
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||
print(f"✅ ZIP 已创建: {zip_path} ({size_mb:.2f} MB, {len(file_paths)} 个文件)")
|
||
return zip_path
|
||
|
||
def package_batch_crawl(self, names: str = "",
|
||
institution_type: str = "",
|
||
zip_name: str = "",
|
||
parallel_workers: int = 1,
|
||
retries: int = 2,
|
||
progress_callback: Optional[Callable[[int, int, str], None]] = None) -> str:
|
||
"""
|
||
批量爬取 + 自动打包 ZIP(一站式)。
|
||
|
||
v6.0.0 增强:
|
||
- 并发爬取(ThreadPoolExecutor,默认 1=顺序,建议 ≤5)
|
||
- 失败自动重试(默认 2 次)
|
||
- 失败清单 failed.json 写入包内
|
||
- 进度回调(爬取阶段 done/total/name)
|
||
|
||
Args:
|
||
names: 机构名称列表(逗号分隔)
|
||
institution_type: 机构类型
|
||
zip_name: ZIP 文件名
|
||
parallel_workers: v6.0.0 并发爬取线程数(0/1=顺序)
|
||
retries: v6.0.0 单个机构失败重试次数
|
||
progress_callback: v6.0.0 进度回调 (done, total, name)
|
||
|
||
返回: ZIP 文件路径
|
||
"""
|
||
items = []
|
||
failed_items: List[Dict[str, Any]] = []
|
||
|
||
# 执行批量爬取
|
||
try:
|
||
from batch_institution_crawler import BatchInstitutionCrawler
|
||
crawler = BatchInstitutionCrawler()
|
||
|
||
if names:
|
||
inst_list = [{"name": n.strip()} for n in names.split(",") if n.strip()]
|
||
crawl_targets = inst_list
|
||
elif institution_type:
|
||
crawl_targets = [{"type": institution_type}]
|
||
else:
|
||
crawl_targets = []
|
||
|
||
def _crawl_one(target: Dict[str, str]) -> Dict[str, Any]:
|
||
"""单目标爬取(带失败重试)。"""
|
||
for attempt in range(retries + 1):
|
||
try:
|
||
if "type" in target and not target.get("name"):
|
||
r = crawler.crawl_by_type(target["type"])
|
||
if not r:
|
||
raise RuntimeError("empty result")
|
||
# 类型爬取返回列表,合并为多条
|
||
return {"__multi__": True, "results": r}
|
||
else:
|
||
r = crawler.crawl_by_names([target])
|
||
if not r:
|
||
raise RuntimeError("empty result")
|
||
return {"__multi__": False, "results": r}
|
||
except Exception:
|
||
if attempt < retries:
|
||
import time as _t
|
||
_t.sleep(0.5 * (attempt + 1))
|
||
return {"__multi__": False, "results": [], "failed": True}
|
||
|
||
if crawl_targets:
|
||
total = len(crawl_targets)
|
||
done = 0
|
||
if parallel_workers and parallel_workers > 1:
|
||
# v6.0.0: 并发爬取
|
||
with ThreadPoolExecutor(max_workers=min(parallel_workers, 8)) as ex:
|
||
futures = {ex.submit(_crawl_one, t): t for t in crawl_targets}
|
||
for fut in as_completed(futures):
|
||
t = futures[fut]
|
||
name = t.get("name", t.get("type", "未知"))
|
||
try:
|
||
out = fut.result()
|
||
except Exception:
|
||
out = {"__multi__": False, "results": [], "failed": True}
|
||
self._collect_crawl_results(out, items, failed_items)
|
||
done += 1
|
||
if progress_callback is not None:
|
||
try:
|
||
progress_callback(done, total, name)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
for t in crawl_targets:
|
||
name = t.get("name", t.get("type", "未知"))
|
||
out = _crawl_one(t)
|
||
self._collect_crawl_results(out, items, failed_items)
|
||
done += 1
|
||
if progress_callback is not None:
|
||
try:
|
||
progress_callback(done, total, name)
|
||
except Exception:
|
||
pass
|
||
|
||
except ImportError:
|
||
pass
|
||
|
||
if not items:
|
||
raise ValueError("未获取到任何爬取结果")
|
||
|
||
# v6.0.0: 失败清单写入包内
|
||
if failed_items:
|
||
items.append({
|
||
"name": "failed_清单",
|
||
"content": json.dumps(failed_items, ensure_ascii=False, indent=2),
|
||
"content_type": "json",
|
||
"category": "失败清单",
|
||
"metadata": {"note": "爬取失败项(已自动重试)"},
|
||
})
|
||
|
||
return self.package(items, zip_name=zip_name,
|
||
progress_callback=progress_callback)
|
||
|
||
@staticmethod
|
||
def _collect_crawl_results(out: Dict[str, Any],
|
||
items: List[Dict[str, Any]],
|
||
failed_items: List[Dict[str, Any]]):
|
||
"""收集单目标爬取结果(并发/顺序共用)。"""
|
||
if out.get("failed"):
|
||
failed_items.append({"name": "未知", "reason": "爬取失败"})
|
||
return
|
||
results = out.get("results", []) or []
|
||
for r in results:
|
||
ok = r.get("success", False) if isinstance(r, dict) else True
|
||
entry = {
|
||
"name": r.get("name", "未知"),
|
||
"content": r.get("content", ""),
|
||
"success": ok,
|
||
}
|
||
if ok and entry.get("content"):
|
||
items.append(entry)
|
||
else:
|
||
failed_items.append({"name": entry["name"], "reason": "内容为空或爬取失败"})
|
||
|
||
# ---------- 内部方法 ----------
|
||
|
||
def _normalize_items(self, items: List[Any]) -> List[PackagedItem]:
|
||
"""标准化多种输入格式为 PackagedItem 列表。"""
|
||
packaged = []
|
||
|
||
for item in items:
|
||
if isinstance(item, PackagedItem):
|
||
packaged.append(item)
|
||
elif isinstance(item, dict):
|
||
packaged.append(PackagedItem(
|
||
name=item.get("name", item.get("title", "未命名")),
|
||
content=item.get("content", item.get("text_content", "")),
|
||
source_url=item.get("url", item.get("source", "")),
|
||
category=item.get("category", item.get("type", "通用")),
|
||
metadata=item.get("metadata", {}),
|
||
file_path=item.get("file_path", ""),
|
||
))
|
||
elif isinstance(item, str):
|
||
packaged.append(PackagedItem(
|
||
name=f"content_{len(packaged)+1}",
|
||
content=item,
|
||
))
|
||
else:
|
||
packaged.append(PackagedItem(
|
||
name=str(item)[:100],
|
||
content=str(item),
|
||
))
|
||
|
||
return packaged
|
||
|
||
def _get_content(self, item: PackagedItem) -> str:
|
||
"""获取条目的文本内容。"""
|
||
if item.content:
|
||
return item.content
|
||
if item.file_path and os.path.isfile(item.file_path):
|
||
try:
|
||
with open(item.file_path, 'r', encoding='utf-8') as f:
|
||
return f.read()
|
||
except Exception:
|
||
return ""
|
||
return json.dumps(item.metadata, ensure_ascii=False) if item.metadata else ""
|
||
|
||
def _generate_filename(self, item: PackagedItem, index: int) -> str:
|
||
"""为条目生成安全的文件名。"""
|
||
base = self._safe_filename(item.name)[:50]
|
||
if not base:
|
||
base = f"item_{index+1:03d}"
|
||
|
||
ext = ".txt"
|
||
if item.content_type == "json":
|
||
ext = ".json"
|
||
elif item.content_type == "html":
|
||
ext = ".html"
|
||
elif item.content_type == "markdown":
|
||
ext = ".md"
|
||
|
||
fname = f"{index+1:03d}_{base}{ext}"
|
||
return fname
|
||
|
||
def _safe_filename(self, name: str) -> str:
|
||
"""将字符串转为安全的文件名。"""
|
||
import re
|
||
safe = re.sub(r'[\\/:*?"<>|]', '_', name)
|
||
safe = re.sub(r'\s+', '_', safe)
|
||
safe = safe.strip('._')
|
||
return safe[:80] if safe else "unnamed"
|
||
|
||
def _generate_index(self, zip_name: str,
|
||
categories: Dict[str, List[PackagedItem]],
|
||
file_list: List[str]) -> str:
|
||
"""生成索引 README.md。"""
|
||
lines = [
|
||
f"# 📦 {zip_name}\n",
|
||
f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n",
|
||
f"总条目数: {sum(len(v) for v in categories.values())}",
|
||
f"类别数: {len(categories)}\n",
|
||
"---\n",
|
||
"## 📂 目录结构\n",
|
||
]
|
||
|
||
for cat_name, cat_items in sorted(categories.items()):
|
||
safe_cat = self._safe_filename(cat_name)
|
||
lines.append(f"### {cat_name}/ ({len(cat_items)} 项)")
|
||
for item in cat_items[:30]:
|
||
fname = self._generate_filename(item, 0)
|
||
preview = (item.content or "")[:80].replace('\n', ' ')
|
||
source = f" — [来源]({item.source_url})" if item.source_url else ""
|
||
lines.append(f"- `{safe_cat}/{fname}` — {item.name}{source}")
|
||
if preview:
|
||
lines.append(f" > {preview}...")
|
||
lines.append("")
|
||
|
||
lines.append("---")
|
||
lines.append(f"*共 {len(file_list)} 个文件*")
|
||
return '\n'.join(lines)
|
||
|
||
def _generate_metadata(self, zip_name: str,
|
||
items: List[PackagedItem],
|
||
categories: Dict[str, List[PackagedItem]]) -> Dict[str, Any]:
|
||
"""生成 metadata JSON(v6.0.0: 含 item_hashes 供增量去重)。"""
|
||
item_hashes = []
|
||
for item in items:
|
||
content = item.content or ""
|
||
item_hashes.append({
|
||
"name": item.name,
|
||
"category": item.category,
|
||
"hash": hashlib.md5(content.encode("utf-8")).hexdigest(),
|
||
"chars": len(content),
|
||
})
|
||
return {
|
||
"package_name": zip_name,
|
||
"version": "6.0",
|
||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"total_items": len(items),
|
||
"category_count": len(categories),
|
||
"categories": {
|
||
cat: {
|
||
"count": len(cat_items),
|
||
"items": [
|
||
{
|
||
"name": item.name,
|
||
"source_url": item.source_url,
|
||
"chars": len(item.content or ""),
|
||
}
|
||
for item in cat_items
|
||
],
|
||
}
|
||
for cat, cat_items in categories.items()
|
||
},
|
||
"item_summary": [
|
||
{"name": item.name, "category": item.category,
|
||
"source_url": item.source_url, "chars": len(item.content or "")}
|
||
for item in items[:100]
|
||
],
|
||
"item_hashes": item_hashes, # v6.0.0: 增量去重依据
|
||
}
|
||
|
||
# ---------- v6.0.0 增量打包 ----------
|
||
|
||
def package_incremental(self, items: List[Any], previous_zip: str = "",
|
||
zip_name: str = "", **kwargs) -> Tuple[str, int]:
|
||
"""增量打包:基于已有 ZIP 的 metadata.item_hashes 跳过已打包内容。
|
||
|
||
Args:
|
||
items: 待打包条目(同 package())
|
||
previous_zip: 已有 ZIP 路径(读取其 metadata.json 的 hash 集合)
|
||
zip_name: 新 ZIP 文件名
|
||
**kwargs: 透传 package() 参数
|
||
|
||
Returns:
|
||
(zip_path, skipped_count) — 新包路径与去重跳过的条数
|
||
"""
|
||
existing_hashes: set = set()
|
||
if previous_zip and os.path.isfile(previous_zip):
|
||
try:
|
||
with zipfile.ZipFile(previous_zip) as zf:
|
||
names = zf.namelist()
|
||
meta_name = next((n for n in names
|
||
if n.endswith("metadata.json")), None)
|
||
if meta_name:
|
||
meta = json.loads(zf.read(meta_name).decode("utf-8"))
|
||
existing_hashes = {
|
||
h["hash"] for h in meta.get("item_hashes", [])
|
||
if h.get("hash")
|
||
}
|
||
except Exception:
|
||
existing_hashes = set()
|
||
|
||
packaged = self._normalize_items(items)
|
||
fresh = []
|
||
skipped = 0
|
||
for item in packaged:
|
||
content = item.content or ""
|
||
h = hashlib.md5(content.encode("utf-8")).hexdigest() if content else ""
|
||
if h and h in existing_hashes:
|
||
skipped += 1
|
||
continue
|
||
fresh.append(item)
|
||
|
||
if not fresh:
|
||
raise ValueError("所有条目均已打包过(增量去重后无新内容)")
|
||
|
||
zip_path = self.package(fresh, zip_name=zip_name, **kwargs)
|
||
return zip_path, skipped
|
||
|
||
def _get_dir_size(self, path: Path) -> int:
|
||
"""计算目录总大小(字节)。"""
|
||
total = 0
|
||
for f in path.rglob('*'):
|
||
if f.is_file():
|
||
total += f.stat().st_size
|
||
return total
|
||
|
||
def _create_zip(self, source_dir: Path, zip_path: str) -> str:
|
||
"""创建单个 ZIP 文件(v4.5 修复:中文文件名 UTF-8 + GBK 双编码)。"""
|
||
source_dir_str = str(source_dir)
|
||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||
for root, dirs, files in os.walk(source_dir_str):
|
||
for fname in files:
|
||
fpath = os.path.join(root, fname)
|
||
arcname = os.path.relpath(fpath, source_dir_str)
|
||
_write_zip_utf8(zf, fpath, arcname)
|
||
|
||
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||
print(f"✅ ZIP 已创建: {zip_path} ({size_mb:.2f} MB)")
|
||
return zip_path
|
||
|
||
def _create_split_zip(self, source_dir: Path, zip_name: str,
|
||
total_size: int) -> str:
|
||
"""创建分卷 ZIP 文件。"""
|
||
source_dir_str = str(source_dir)
|
||
vol_size = self.MAX_VOLUME_SIZE
|
||
vol_num = 0
|
||
current_size = 0
|
||
zf = None
|
||
result_paths = []
|
||
|
||
try:
|
||
for root, dirs, files in os.walk(source_dir_str):
|
||
for fname in files:
|
||
fpath = os.path.join(root, fname)
|
||
fsize = os.path.getsize(fpath)
|
||
|
||
if zf is None or current_size + fsize > vol_size:
|
||
if zf:
|
||
zf.close()
|
||
vol_num += 1
|
||
vol_name = f"{zip_name}_part{vol_num:02d}"
|
||
vol_path = str(self.output_dir / f"{vol_name}.zip")
|
||
zf = zipfile.ZipFile(vol_path, 'w', zipfile.ZIP_DEFLATED)
|
||
result_paths.append(vol_path)
|
||
current_size = 0
|
||
|
||
arcname = os.path.relpath(fpath, source_dir_str)
|
||
zf.write(fpath, arcname)
|
||
current_size += fsize
|
||
|
||
if zf:
|
||
zf.close()
|
||
|
||
# 返回第一个分卷路径作为主路径
|
||
for p in result_paths:
|
||
size_mb = os.path.getsize(p) / (1024 * 1024)
|
||
print(f"✅ 分卷 ZIP: {p} ({size_mb:.2f} MB)")
|
||
return result_paths[0] if result_paths else ""
|
||
|
||
except Exception as e:
|
||
if zf:
|
||
try:
|
||
zf.close()
|
||
except Exception:
|
||
pass
|
||
raise e
|
||
|
||
|
||
# ==================== 便捷函数 ====================
|
||
|
||
_packager = CrawlPackager()
|
||
|
||
|
||
def batch_crawl_and_package(names: str = "", institution_type: str = "",
|
||
zip_name: str = "") -> str:
|
||
"""批量爬取 + 自动打包 ZIP。"""
|
||
return _packager.package_batch_crawl(
|
||
names=names, institution_type=institution_type, zip_name=zip_name
|
||
)
|
||
|
||
|
||
def package_crawl_results(items: List[Any], zip_name: str = "",
|
||
output_dir: str = "") -> str:
|
||
"""打包爬取结果为 ZIP。"""
|
||
return _packager.package(items, zip_name=zip_name, output_dir=output_dir)
|
||
|
||
|
||
def package_files(file_paths: List[str], zip_name: str = "",
|
||
output_dir: str = "") -> str:
|
||
"""打包文件列表为 ZIP。"""
|
||
return _packager.package_from_files(
|
||
file_paths=file_paths, zip_name=zip_name, output_dir=output_dir
|
||
)
|
||
|
||
|
||
# ==================== v4.5 ZIP UTF-8 修复 + 图片打包 ====================
|
||
|
||
def _write_zip_utf8(zf: zipfile.ZipFile, fpath: str, arcname: str) -> None:
|
||
"""写入 ZIP 条目时双编码(UTF-8 + GBK extra 字段),避免中文文件名乱码。
|
||
|
||
zipfile 内部默认 UTF-8 flag(Python 3),但部分 Windows 工具解压时按 GBK 解码。
|
||
通过 extra 字段追加 Info-ZIP Unicode Path,跨平台解压都正常。
|
||
"""
|
||
import zlib
|
||
with open(fpath, "rb") as f:
|
||
data = f.read()
|
||
_write_zip_data_utf8(zf, arcname, data)
|
||
|
||
|
||
def _write_zip_data_utf8(zf: zipfile.ZipFile, arcname: str, data: bytes) -> None:
|
||
"""写入 ZIP 条目(直接传入 data),带 UTF-8 extra 字段。"""
|
||
import zlib
|
||
info = zipfile.ZipInfo(arcname)
|
||
info.compress_type = zipfile.ZIP_DEFLATED
|
||
if any(ord(c) > 127 for c in arcname):
|
||
try:
|
||
utf8_bytes = arcname.encode("utf-8")
|
||
crc = zlib.crc32(utf8_bytes) & 0xFFFFFFFF
|
||
payload = b"\x01" + crc.to_bytes(4, "little") + utf8_bytes
|
||
info.extra = b"\x75\x70" + len(payload).to_bytes(2, "little") + payload
|
||
except UnicodeEncodeError:
|
||
pass
|
||
zf.writestr(info, data)
|
||
|
||
|
||
def package_with_images(items: List[Any], zip_name: str = "",
|
||
output_dir: str = "",
|
||
media_subdir: str = "media") -> str:
|
||
"""v4.5 新增:打包爬取结果 + 关联图片。
|
||
|
||
Args:
|
||
items: 爬取结果列表,每个 item 可含 {"image_map": {orig_url: local_path}}
|
||
zip_name: ZIP 名
|
||
output_dir: 输出目录
|
||
media_subdir: ZIP 内图片子目录名
|
||
|
||
Returns:
|
||
ZIP 路径
|
||
"""
|
||
packager = CrawlPackager(output_dir=output_dir) if output_dir else _packager
|
||
|
||
# 先写所有 content 到临时目录
|
||
import tempfile
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
tmp = Path(tmpdir)
|
||
media_dir = tmp / media_subdir
|
||
media_dir.mkdir(exist_ok=True)
|
||
# 收集所有图片
|
||
for idx, item in enumerate(items):
|
||
img_map = (item.get("image_map") if isinstance(item, dict) else None) or {}
|
||
for orig_url, local_path in img_map.items():
|
||
if not local_path:
|
||
continue
|
||
src = Path(local_path)
|
||
if not src.exists():
|
||
continue
|
||
# 拷贝到 media_dir
|
||
dst = media_dir / src.name
|
||
if not dst.exists():
|
||
try:
|
||
import shutil as _sh
|
||
_sh.copy2(str(src), str(dst))
|
||
except Exception:
|
||
pass
|
||
|
||
# 调用原有 package 写 content 文件
|
||
content_zip = packager.package(items, zip_name=zip_name + "_content")
|
||
# 合并:解压 content_zip + media 到新 zip
|
||
if zip_name:
|
||
final_name = zip_name if zip_name.endswith(".zip") else zip_name + ".zip"
|
||
else:
|
||
final_name = "package_with_images.zip"
|
||
out_zip = Path(packager.output_dir) / final_name
|
||
|
||
with zipfile.ZipFile(str(out_zip), "w", zipfile.ZIP_DEFLATED) as zf:
|
||
# 1) 写 media
|
||
for f in media_dir.rglob("*"):
|
||
if f.is_file():
|
||
arc = f"{media_subdir}/{f.relative_to(media_dir)}"
|
||
_write_zip_utf8(zf, str(f), arc)
|
||
# 2) 写 content(从原 zip 抽出重写)
|
||
if Path(content_zip).exists():
|
||
with zipfile.ZipFile(content_zip, "r") as src_zf:
|
||
for info in src_zf.infolist():
|
||
data = src_zf.read(info.filename)
|
||
# 用 writestr 重新写入(带 UTF-8 extra)
|
||
_write_zip_data_utf8(zf, info.filename, data)
|
||
try:
|
||
Path(content_zip).unlink()
|
||
except Exception:
|
||
pass
|
||
|
||
return str(out_zip)
|
||
|
||
|
||
def _decode_zip_filename(name: str) -> str:
|
||
"""从 ZIP extra 字段恢复 UTF-8 文件名(如果存在)。"""
|
||
return name # 简化版:直接返回
|
||
|
||
|
||
def set_zip_encoding(zip_path: str) -> str:
|
||
"""为已有 ZIP 重新写入 UTF-8 编码(修复中文乱码)。
|
||
|
||
Args:
|
||
zip_path: 已有 ZIP 路径
|
||
|
||
Returns:
|
||
修复后的新 ZIP 路径(原文件加 .utf8.zip 后缀)
|
||
"""
|
||
src = Path(zip_path)
|
||
if not src.exists():
|
||
return ""
|
||
dst = src.with_suffix(".utf8.zip")
|
||
with zipfile.ZipFile(src, "r") as src_zf, \
|
||
zipfile.ZipFile(str(dst), "w", zipfile.ZIP_DEFLATED) as dst_zf:
|
||
for info in src_zf.infolist():
|
||
data = src_zf.read(info.filename)
|
||
_write_zip_data_utf8(dst_zf, info.filename, data)
|
||
return str(dst)
|
||
|
||
|
||
# ==================== 增强 BatchInstitutionCrawler ====================
|
||
|
||
def enhance_batch_crawler():
|
||
"""为 BatchInstitutionCrawler 添加 crawl_and_package 方法。"""
|
||
try:
|
||
from batch_institution_crawler import BatchInstitutionCrawler
|
||
|
||
def _crawl_and_package(self, names: str = "", institution_type: str = "",
|
||
zip_name: str = "") -> str:
|
||
"""批量爬取 + 自动打包 ZIP。"""
|
||
items = []
|
||
if names:
|
||
inst_list = [{"name": n.strip()} for n in names.split(",")]
|
||
results = self.crawl_by_names(inst_list)
|
||
items = [{"name": r.get("name", ""), "content": r.get("content", ""),
|
||
"success": r.get("success", False)}
|
||
for r in results]
|
||
elif institution_type:
|
||
results = self.crawl_by_type(institution_type)
|
||
items = [{"name": r.get("name", ""), "content": r.get("content", ""),
|
||
"success": r.get("success", False)}
|
||
for r in results]
|
||
|
||
return _packager.package(items, zip_name=zip_name)
|
||
|
||
BatchInstitutionCrawler.crawl_and_package = _crawl_and_package
|
||
return True
|
||
except ImportError:
|
||
return False
|
||
|
||
|
||
# ==================== CLI 入口 ====================
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
if len(sys.argv) < 2:
|
||
print("用法: python crawl_packager.py <命令> [参数]")
|
||
print("命令:")
|
||
print(" package <names或type> [zip名] — 批量爬取+打包")
|
||
print(" files <文件1,文件2,...> [zip名] — 打包指定文件")
|
||
print()
|
||
print("示例:")
|
||
print(" python crawl_packager.py package \"华夏基金,易方达基金\" my_package")
|
||
print(" python crawl_packager.py package 基金管理公司")
|
||
print(" python crawl_packager.py files \"data/report1.pdf,data/report2.pdf\"")
|
||
sys.exit(1)
|
||
|
||
cmd = sys.argv[1]
|
||
|
||
if cmd == "package":
|
||
target = sys.argv[2] if len(sys.argv) > 2 else ""
|
||
zip_name = sys.argv[3] if len(sys.argv) > 3 else ""
|
||
|
||
if not target:
|
||
print("请提供机构名称(逗号分隔)或机构类型")
|
||
sys.exit(1)
|
||
|
||
# 判断是名称列表还是类型
|
||
if any(c in target for c in "基金证券银行保险信托期货"):
|
||
result = _packager.package_batch_crawl(
|
||
institution_type=target, zip_name=zip_name
|
||
)
|
||
else:
|
||
result = _packager.package_batch_crawl(
|
||
names=target, zip_name=zip_name
|
||
)
|
||
|
||
print(f"📦 打包完成: {result}")
|
||
|
||
elif cmd == "files":
|
||
if len(sys.argv) < 3:
|
||
print("请提供文件路径(逗号分隔)")
|
||
sys.exit(1)
|
||
|
||
paths = [p.strip() for p in sys.argv[2].split(",") if p.strip()]
|
||
zip_name = sys.argv[3] if len(sys.argv) > 3 else ""
|
||
|
||
result = _packager.package_from_files(paths, zip_name=zip_name)
|
||
print(f"📦 打包完成: {result}")
|
||
|
||
else:
|
||
print(f"未知命令: {cmd}")
|