isbnx 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- isbnx/__init__.py +20 -0
- isbnx/archive.py +570 -0
- isbnx/config.py +158 -0
- isbnx/detector.py +326 -0
- isbnx/epub.py +186 -0
- isbnx/isbnx.py +191 -0
- isbnx/mobi.py +258 -0
- isbnx/model/isbndetect_yolo.onnx +0 -0
- isbnx/models.py +333 -0
- isbnx/ocr/__init__.py +7 -0
- isbnx/ocr/isbnx_pyzbar.py +38 -0
- isbnx/ocr/isbnx_rapiocr.py +104 -0
- isbnx/pdf.py +272 -0
- isbnx/pdf_type.py +69 -0
- isbnx/pdgview/PdgView.dll +0 -0
- isbnx/pdgview/__init__.py +0 -0
- isbnx/utils/__init__.py +7 -0
- isbnx/utils/cip_rules.py +192 -0
- isbnx/utils/io.py +76 -0
- isbnx/utils/isbn_utils.py +135 -0
- isbnx-0.0.1.dist-info/METADATA +312 -0
- isbnx-0.0.1.dist-info/RECORD +23 -0
- isbnx-0.0.1.dist-info/WHEEL +4 -0
isbnx/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from isbnx.config import Settings, configure, settings
|
|
2
|
+
from isbnx.isbnx import ISBNX, extract
|
|
3
|
+
from isbnx.models import Detect, ExtractResult, Locate, Meta, OCRResult
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Settings",
|
|
7
|
+
"configure",
|
|
8
|
+
"settings",
|
|
9
|
+
"ISBNX",
|
|
10
|
+
"extract",
|
|
11
|
+
"Detect",
|
|
12
|
+
"Locate",
|
|
13
|
+
"Meta",
|
|
14
|
+
"OCRResult",
|
|
15
|
+
"ExtractResult",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
print("Hello from isbnx!")
|
isbnx/archive.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""压缩包(zip/rar/uvz)ISBN 提取模块。
|
|
2
|
+
|
|
3
|
+
数据来源优先级(按速度从高到低):
|
|
4
|
+
|
|
5
|
+
1. **meta.xml** — 压缩包内的 XML 元数据文件,含 ``<ssid>`` / ``<isbn>`` 字段。
|
|
6
|
+
纯文本解析,最快(~10-20ms)。编码兼容 UTF-8 / GB18030。
|
|
7
|
+
2. **bookinfo.dat** — 超星 PDG 专用 INI 配置文件,含 ISBN / SSID。
|
|
8
|
+
纯文本解析,较快(~5-10ms)。编码兼容 GB18030 / UTF-8。
|
|
9
|
+
3. **leg001.pdg** — 版权页图片(通常是封面页/书名页),
|
|
10
|
+
解码后 ONNX 检测 + OCR 提取。中等速度(~200-500ms)。
|
|
11
|
+
4. **兜底 PDG** — 前 N 个 PDG 文件(由 ``pdg_fallback_count`` 控制),
|
|
12
|
+
逐个解码 + ONNX + OCR。最慢(N × 200-500ms)。
|
|
13
|
+
|
|
14
|
+
合并策略: meta.xml 和 bookinfo.dat 的结果会**合并**(互不覆盖,
|
|
15
|
+
前面的来源优先级更高)。只要合并后 ISBN 或 SSID 任一有效即返回,
|
|
16
|
+
不再走更慢的图片路径。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ctypes
|
|
22
|
+
import importlib.resources as resources
|
|
23
|
+
import io
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
import zipfile
|
|
30
|
+
from abc import ABC, abstractmethod
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from PIL import Image
|
|
34
|
+
|
|
35
|
+
from isbnx.config import settings
|
|
36
|
+
from isbnx.detector import Detector, get_detector
|
|
37
|
+
from isbnx.models import BookInfo, ExtractResult, Locate, Meta
|
|
38
|
+
|
|
39
|
+
# ── 图片文件签名 ──
|
|
40
|
+
_JPEG_HEADER = b"\xff\xd8\xff"
|
|
41
|
+
_PNG_HEADER = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"
|
|
42
|
+
|
|
43
|
+
# ── 安全限制 ──
|
|
44
|
+
_MAX_BOOKINFO_SIZE = 1 * 1024 * 1024
|
|
45
|
+
_MAX_META_XML_SIZE = 256 * 1024
|
|
46
|
+
_MAX_PDG_SIZE = 50 * 1024 * 1024
|
|
47
|
+
|
|
48
|
+
# ── bookinfo.dat 字段映射(仅关注 ISBN) ──
|
|
49
|
+
_KEY_MAP: dict[str, str] = {
|
|
50
|
+
"isbn号": "isbn",
|
|
51
|
+
"isbn13": "isbn",
|
|
52
|
+
"isbn10": "isbn",
|
|
53
|
+
"isbn": "isbn",
|
|
54
|
+
"ss号": "ssid",
|
|
55
|
+
"ssid号": "ssid",
|
|
56
|
+
"ssid": "ssid",
|
|
57
|
+
"ss": "ssid",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ═══════════════════════════════════════════════════════════
|
|
62
|
+
# 文件级工具函数
|
|
63
|
+
# ═══════════════════════════════════════════════════════════
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_names(arc) -> set[str]:
|
|
67
|
+
"""获取压缩包内所有文件路径的集合。"""
|
|
68
|
+
return set(arc.namelist())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _count_pdg(names: set[str]) -> int:
|
|
72
|
+
"""统计压缩包内 *.pdg 文件数量。"""
|
|
73
|
+
return sum(1 for n in names if n.lower().endswith(".pdg"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _list_pdg(names: set[str]) -> list[str]:
|
|
77
|
+
"""列出压缩包内所有 *.pdg 文件路径(按文件名排序)。"""
|
|
78
|
+
return sorted(n for n in names if n.lower().endswith(".pdg"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── bookinfo.dat 解析 ──
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _decode_bookinfo(raw: bytes) -> tuple[str, str]:
|
|
85
|
+
"""解码 bookinfo.dat,返回 (文本, 编码)。"""
|
|
86
|
+
try:
|
|
87
|
+
text = raw.decode("gb18030")
|
|
88
|
+
if "\ufffd" in text:
|
|
89
|
+
text = raw.decode("utf-8", errors="replace")
|
|
90
|
+
return text, "utf-8"
|
|
91
|
+
return text, "gb18030"
|
|
92
|
+
except UnicodeDecodeError:
|
|
93
|
+
text = raw.decode("utf-8", errors="replace")
|
|
94
|
+
return text, "utf-8"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_bookinfo(text: str) -> dict[str, str | None]:
|
|
98
|
+
"""从 bookinfo.dat 文本中提取 ISBN 和 SSID。"""
|
|
99
|
+
result: dict[str, str | None] = {"isbn": None, "ssid": None}
|
|
100
|
+
section: str | None = None
|
|
101
|
+
for line in text.splitlines():
|
|
102
|
+
line = line.strip()
|
|
103
|
+
if not line:
|
|
104
|
+
continue
|
|
105
|
+
if line.startswith("[") and line.endswith("]"):
|
|
106
|
+
section = line[1:-1].strip().lower()
|
|
107
|
+
continue
|
|
108
|
+
if section is None:
|
|
109
|
+
continue
|
|
110
|
+
if "=" not in line:
|
|
111
|
+
continue
|
|
112
|
+
key, _, value = line.partition("=")
|
|
113
|
+
key = key.strip().lower()
|
|
114
|
+
value = value.strip()
|
|
115
|
+
if not value:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
mapped = _KEY_MAP.get(key)
|
|
119
|
+
if mapped is None:
|
|
120
|
+
continue
|
|
121
|
+
if result[mapped] is not None:
|
|
122
|
+
continue # 已有值则不覆盖
|
|
123
|
+
|
|
124
|
+
if mapped == "isbn":
|
|
125
|
+
cleaned = value.replace("-", "").replace(" ", "").upper()
|
|
126
|
+
if len(cleaned) == 10:
|
|
127
|
+
result["isbn"] = cleaned
|
|
128
|
+
elif len(cleaned) == 13 and cleaned.startswith(("978", "979")):
|
|
129
|
+
result["isbn"] = cleaned
|
|
130
|
+
else:
|
|
131
|
+
result[mapped] = value
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── PDG 文件检测与解码 ──
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_image_file(data: bytes) -> bool:
|
|
139
|
+
"""检查字节数据是否为常见图片格式(jpg/png)。"""
|
|
140
|
+
return data.startswith(_JPEG_HEADER) or data.startswith(_PNG_HEADER)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _pdg_to_image(data: bytes) -> Image.Image | None:
|
|
144
|
+
"""将 PDG 字节数据解码为 PIL Image。
|
|
145
|
+
|
|
146
|
+
流程:
|
|
147
|
+
1. 检查文件头是否为常见图片格式(jpg/png)
|
|
148
|
+
2. 是 → 直接用 PIL 读取(失败则继续)
|
|
149
|
+
3. 否 → PdgView.dll 解码
|
|
150
|
+
"""
|
|
151
|
+
if _is_image_file(data):
|
|
152
|
+
try:
|
|
153
|
+
return Image.open(io.BytesIO(data)).convert("RGB")
|
|
154
|
+
except Exception:
|
|
155
|
+
pass # 不要直接返回 None,继续尝试 PdgView.dll
|
|
156
|
+
|
|
157
|
+
# PdgView.dll 解码
|
|
158
|
+
try:
|
|
159
|
+
img = _pdg_decode_with_dll(data)
|
|
160
|
+
if img is not None:
|
|
161
|
+
return img
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
# 最后兜底
|
|
166
|
+
try:
|
|
167
|
+
return Image.open(io.BytesIO(data)).convert("RGB")
|
|
168
|
+
except Exception:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_pdg_header(data: bytes) -> tuple[int, int, int] | None:
|
|
173
|
+
"""解析 PDG 文件头,返回 (width, height, pdg_type) 或 None。"""
|
|
174
|
+
if len(data) < 140:
|
|
175
|
+
return None
|
|
176
|
+
pdg_type = data[15]
|
|
177
|
+
if pdg_type == 0xFF or pdg_type == 0x10:
|
|
178
|
+
return None
|
|
179
|
+
x_pix = int.from_bytes(data[16:18], "little", signed=False)
|
|
180
|
+
y_pix = int.from_bytes(data[18:20], "little", signed=False)
|
|
181
|
+
if pdg_type in (0xAA, 0xAC):
|
|
182
|
+
x_pix, y_pix = 1120, 1568
|
|
183
|
+
elif pdg_type == 0xAB:
|
|
184
|
+
x_pix, y_pix = y_pix, x_pix
|
|
185
|
+
return x_pix, y_pix, pdg_type
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _pdg_decode_with_dll(data: bytes) -> Image.Image | None:
|
|
189
|
+
"""使用 PdgView.dll 解码 PDG 文件(仅 Windows 平台支持)。"""
|
|
190
|
+
if sys.platform != "win32":
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
dll_path = resources.files(__package__) / settings.archive.pdgview_path
|
|
194
|
+
if not dll_path.is_file():
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
dll = ctypes.cdll.LoadLibrary(str(dll_path))
|
|
198
|
+
dll.pdgInit()
|
|
199
|
+
|
|
200
|
+
header = _parse_pdg_header(data)
|
|
201
|
+
if header is None:
|
|
202
|
+
return None
|
|
203
|
+
x_pix, y_pix, _ = header
|
|
204
|
+
|
|
205
|
+
fd, tmp_path = tempfile.mkstemp(suffix=".pdg")
|
|
206
|
+
decoded_ok = False
|
|
207
|
+
img_buffer_ptr = ctypes.c_void_p()
|
|
208
|
+
try:
|
|
209
|
+
os.write(fd, data)
|
|
210
|
+
os.close(fd)
|
|
211
|
+
|
|
212
|
+
size = ctypes.c_int()
|
|
213
|
+
imgtype = ctypes.c_int()
|
|
214
|
+
ret = dll.pdgDecode(
|
|
215
|
+
ctypes.c_char_p(tmp_path.encode("utf-8") + b"\0"),
|
|
216
|
+
ctypes.c_int(x_pix),
|
|
217
|
+
ctypes.c_int(y_pix),
|
|
218
|
+
ctypes.byref(img_buffer_ptr),
|
|
219
|
+
ctypes.byref(size),
|
|
220
|
+
ctypes.byref(imgtype),
|
|
221
|
+
)
|
|
222
|
+
if ret != 0 or not img_buffer_ptr:
|
|
223
|
+
return None
|
|
224
|
+
decoded_ok = True
|
|
225
|
+
|
|
226
|
+
buf = (ctypes.c_byte * size.value).from_address(img_buffer_ptr.value) # type: ignore[arg-type]
|
|
227
|
+
img = Image.open(io.BytesIO(bytes(buf))).convert("RGB")
|
|
228
|
+
return img
|
|
229
|
+
finally:
|
|
230
|
+
try:
|
|
231
|
+
os.unlink(tmp_path)
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
if decoded_ok:
|
|
235
|
+
try:
|
|
236
|
+
dll.pdgFreeBuffer(img_buffer_ptr)
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _extract_isbn_from_image(data: bytes, detector) -> str | None:
|
|
242
|
+
"""将 PDG 字节解码为图片后运行 ONNX 检测 + OCR,返回 ISBN 字符串。"""
|
|
243
|
+
img = _pdg_to_image(data)
|
|
244
|
+
if img is None:
|
|
245
|
+
return None
|
|
246
|
+
det = detector or get_detector()
|
|
247
|
+
result = det.process(img)
|
|
248
|
+
return result.bookinfo.isbn13 if result.bookinfo.isbn else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ═══════════════════════════════════════════════════════════
|
|
252
|
+
# 压缩包抽象层(统一 zip / rar / uvz 接口)
|
|
253
|
+
# ═══════════════════════════════════════════════════════════
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class _ArchiveReader(ABC):
|
|
257
|
+
"""压缩包读取器抽象基类。"""
|
|
258
|
+
|
|
259
|
+
@abstractmethod
|
|
260
|
+
def namelist(self) -> list[str]: ...
|
|
261
|
+
|
|
262
|
+
@abstractmethod
|
|
263
|
+
def read(self, name: str) -> bytes: ...
|
|
264
|
+
|
|
265
|
+
@abstractmethod
|
|
266
|
+
def getinfo(self, name: str): ...
|
|
267
|
+
|
|
268
|
+
@abstractmethod
|
|
269
|
+
def is_encrypted(self) -> bool: ...
|
|
270
|
+
|
|
271
|
+
@abstractmethod
|
|
272
|
+
def close(self) -> None: ...
|
|
273
|
+
|
|
274
|
+
def __enter__(self):
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
def __exit__(self, *args):
|
|
278
|
+
self.close()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class _ZipReader(_ArchiveReader):
|
|
282
|
+
"""ZIP / UVZ 读取器。"""
|
|
283
|
+
|
|
284
|
+
def __init__(self, path: str) -> None:
|
|
285
|
+
self._zf = zipfile.ZipFile(path, "r")
|
|
286
|
+
|
|
287
|
+
def namelist(self) -> list[str]:
|
|
288
|
+
return self._zf.namelist()
|
|
289
|
+
|
|
290
|
+
def read(self, name: str) -> bytes:
|
|
291
|
+
return self._zf.read(name)
|
|
292
|
+
|
|
293
|
+
def getinfo(self, name: str):
|
|
294
|
+
return self._zf.getinfo(name)
|
|
295
|
+
|
|
296
|
+
def is_encrypted(self) -> bool:
|
|
297
|
+
for info in self._zf.infolist():
|
|
298
|
+
if info.flag_bits & 0x1:
|
|
299
|
+
return True
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
def close(self) -> None:
|
|
303
|
+
self._zf.close()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class _RarReader(_ArchiveReader):
|
|
307
|
+
"""RAR 读取器。"""
|
|
308
|
+
|
|
309
|
+
def __init__(self, path: str) -> None:
|
|
310
|
+
import rarfile # noqa: PLC0415
|
|
311
|
+
|
|
312
|
+
self._rf = rarfile.RarFile(path)
|
|
313
|
+
|
|
314
|
+
def namelist(self) -> list[str]:
|
|
315
|
+
return self._rf.namelist()
|
|
316
|
+
|
|
317
|
+
def read(self, name: str) -> bytes:
|
|
318
|
+
return self._rf.read(name)
|
|
319
|
+
|
|
320
|
+
def getinfo(self, name: str):
|
|
321
|
+
return self._rf.getinfo(name)
|
|
322
|
+
|
|
323
|
+
def is_encrypted(self) -> bool:
|
|
324
|
+
return any(getattr(info, "encrypted", False) for info in self._rf.infolist())
|
|
325
|
+
|
|
326
|
+
def close(self) -> None:
|
|
327
|
+
self._rf.close()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _open_archive(path: Path) -> _ArchiveReader:
|
|
331
|
+
"""根据文件扩展名打开压缩包。"""
|
|
332
|
+
ext = path.suffix.lower()
|
|
333
|
+
if ext in (".zip", ".uvz"):
|
|
334
|
+
return _ZipReader(str(path))
|
|
335
|
+
if ext == ".rar":
|
|
336
|
+
return _RarReader(str(path))
|
|
337
|
+
raise ValueError(f"不支持的压缩包格式: {ext}(支持 zip/rar/uvz)")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _get_info_ignore_case(arc: _ArchiveReader, names: set[str], target: str):
|
|
341
|
+
"""按文件名(忽略大小写、忽略目录层级)查找文件信息。"""
|
|
342
|
+
target_lower = target.lower()
|
|
343
|
+
for name in names:
|
|
344
|
+
if Path(name).name.lower() == target_lower:
|
|
345
|
+
try:
|
|
346
|
+
return arc.getinfo(name)
|
|
347
|
+
except Exception:
|
|
348
|
+
return None
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _read_file_ignore_case(arc: _ArchiveReader, names: set[str], target: str) -> bytes | None:
|
|
353
|
+
"""按文件名(忽略大小写、忽略目录层级)读取文件。"""
|
|
354
|
+
target_lower = target.lower()
|
|
355
|
+
for name in names:
|
|
356
|
+
if Path(name).name.lower() == target_lower:
|
|
357
|
+
try:
|
|
358
|
+
return arc.read(name)
|
|
359
|
+
except Exception:
|
|
360
|
+
return None
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ── meta.xml 解析 ──
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _parse_meta_xml(raw: bytes) -> dict[str, str | None]:
|
|
368
|
+
"""解析压缩包内的 meta.xml,提取 ISBN 和 SSID。
|
|
369
|
+
|
|
370
|
+
XML 格式示例::
|
|
371
|
+
|
|
372
|
+
<meta>
|
|
373
|
+
<ssid>96391214</ssid>
|
|
374
|
+
<title>李鸿藻书札</title>
|
|
375
|
+
<isbn>9787550845091</isbn>
|
|
376
|
+
<creator>陆德富,谢亚衡编</creator>
|
|
377
|
+
<publisher>西泠印社出版社</publisher>
|
|
378
|
+
<date>2024.06</date>
|
|
379
|
+
</meta>
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
{"isbn": ..., "ssid": ...},未找到的字段为 None。
|
|
383
|
+
"""
|
|
384
|
+
import xml.etree.ElementTree as ET
|
|
385
|
+
|
|
386
|
+
def _parse(raw: bytes) -> ET.Element | None:
|
|
387
|
+
"""尝试解析 XML 字节数据,支持编码兜底。"""
|
|
388
|
+
# 1. 直接解析(适用于 UTF-8 / ASCII)
|
|
389
|
+
try:
|
|
390
|
+
return ET.fromstring(raw)
|
|
391
|
+
except (ET.ParseError, ValueError):
|
|
392
|
+
pass
|
|
393
|
+
# 2. 编码兜底:先按 GB18030 解码为文本,去除 XML 声明中的编码信息,
|
|
394
|
+
# 再重新编码为 UTF-8 后解析。解决 GB18030 编码或声明与实际不符的问题。
|
|
395
|
+
try:
|
|
396
|
+
text = raw.decode("gb18030")
|
|
397
|
+
# 移除/替换 XML 声明中的 encoding,避免声明与真实编码冲突
|
|
398
|
+
text = re.sub(
|
|
399
|
+
r'<\?xml\s+[^>]*encoding\s*=\s*["\'][^"\']+["\'][^>]*\?>',
|
|
400
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
401
|
+
text,
|
|
402
|
+
)
|
|
403
|
+
return ET.fromstring(text.encode("utf-8"))
|
|
404
|
+
except (ET.ParseError, UnicodeDecodeError, UnicodeError, ValueError):
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
result: dict[str, str | None] = {"isbn": None, "ssid": None}
|
|
408
|
+
root = _parse(raw)
|
|
409
|
+
if root is None:
|
|
410
|
+
return result
|
|
411
|
+
|
|
412
|
+
for field in ("isbn", "ssid"):
|
|
413
|
+
elem = root.find(field)
|
|
414
|
+
if elem is not None and elem.text:
|
|
415
|
+
value = elem.text.strip()
|
|
416
|
+
if field == "isbn":
|
|
417
|
+
cleaned = value.replace("-", "").replace(" ", "").upper()
|
|
418
|
+
if len(cleaned) == 10:
|
|
419
|
+
result["isbn"] = cleaned
|
|
420
|
+
elif len(cleaned) == 13 and cleaned.startswith(("978", "979")):
|
|
421
|
+
result["isbn"] = cleaned
|
|
422
|
+
else:
|
|
423
|
+
result[field] = value
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _merge_metadata(*sources: dict[str, str | None]) -> dict[str, str | None]:
|
|
428
|
+
"""合并多个来源的元数据,前面的来源优先级更高(已有值不覆写)。"""
|
|
429
|
+
merged: dict[str, str | None] = {}
|
|
430
|
+
for src in sources:
|
|
431
|
+
for key in ("isbn", "ssid"):
|
|
432
|
+
if key not in merged and src.get(key):
|
|
433
|
+
merged[key] = src[key]
|
|
434
|
+
return merged
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ═══════════════════════════════════════════════════════════
|
|
438
|
+
# ArchiveExtractor
|
|
439
|
+
# ═══════════════════════════════════════════════════════════
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class ArchiveExtractor:
|
|
443
|
+
"""压缩包(zip/rar/uvz)ISBN 提取器。"""
|
|
444
|
+
|
|
445
|
+
@classmethod
|
|
446
|
+
def extract(cls, archive_path: str | Path, detector: Detector | None = None) -> ExtractResult:
|
|
447
|
+
"""从压缩包(zip/rar/uvz)中提取 ISBN。
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
archive_path: 压缩包文件路径。
|
|
451
|
+
detector: 外部传入的 Detector 实例,为 None 时使用全局单例。
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
ExtractResult — 包含 ISBN、源信息等。
|
|
455
|
+
"""
|
|
456
|
+
t0 = time.perf_counter()
|
|
457
|
+
archive_path = Path(archive_path)
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
with _open_archive(archive_path) as arc:
|
|
461
|
+
names = _get_names(arc)
|
|
462
|
+
|
|
463
|
+
# ── 步骤 0: 密码检查 ──
|
|
464
|
+
if arc.is_encrypted():
|
|
465
|
+
return ExtractResult(
|
|
466
|
+
bookinfo=BookInfo(),
|
|
467
|
+
meta=Meta(source=str(archive_path), source_type="archive"),
|
|
468
|
+
error="压缩包有密码保护",
|
|
469
|
+
elapsed=time.perf_counter() - t0,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# ── 步骤 1: PDG 数量检查 ──
|
|
473
|
+
pdg_count = _count_pdg(names)
|
|
474
|
+
if pdg_count < settings.archive.pdg_min_count:
|
|
475
|
+
return ExtractResult(
|
|
476
|
+
bookinfo=BookInfo(),
|
|
477
|
+
meta=Meta(source=str(archive_path), source_type="archive"),
|
|
478
|
+
error=f"PDG 数量不足: {pdg_count} < {settings.archive.pdg_min_count}",
|
|
479
|
+
elapsed=time.perf_counter() - t0,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# ── 步骤 2: 元数据文件解析(meta.xml + bookinfo.dat)──
|
|
483
|
+
meta_parsed: dict[str, str | None] = {}
|
|
484
|
+
bookinfo_parsed: dict[str, str | None] = {}
|
|
485
|
+
encoding: str | None = None
|
|
486
|
+
locate_method: str | None = None
|
|
487
|
+
|
|
488
|
+
# 2a: meta.xml
|
|
489
|
+
mx_info = _get_info_ignore_case(arc, names, "meta.xml")
|
|
490
|
+
if mx_info is not None and getattr(mx_info, "file_size", 0) <= _MAX_META_XML_SIZE:
|
|
491
|
+
raw = _read_file_ignore_case(arc, names, "meta.xml")
|
|
492
|
+
if raw:
|
|
493
|
+
meta_parsed = _parse_meta_xml(raw)
|
|
494
|
+
if meta_parsed.get("isbn") or meta_parsed.get("ssid"):
|
|
495
|
+
locate_method = "meta"
|
|
496
|
+
|
|
497
|
+
# 2b: bookinfo.dat
|
|
498
|
+
bi_info = _get_info_ignore_case(arc, names, "bookinfo.dat")
|
|
499
|
+
if bi_info is not None and getattr(bi_info, "file_size", 0) <= _MAX_BOOKINFO_SIZE:
|
|
500
|
+
raw = _read_file_ignore_case(arc, names, "bookinfo.dat")
|
|
501
|
+
if raw:
|
|
502
|
+
text, enc = _decode_bookinfo(raw)
|
|
503
|
+
encoding = enc
|
|
504
|
+
bookinfo_parsed = _parse_bookinfo(text)
|
|
505
|
+
if bookinfo_parsed.get("isbn") or bookinfo_parsed.get("ssid"):
|
|
506
|
+
locate_method = "bookinfo"
|
|
507
|
+
|
|
508
|
+
# 2c: 合并两路结果
|
|
509
|
+
merged = _merge_metadata(meta_parsed, bookinfo_parsed)
|
|
510
|
+
if merged.get("isbn") or merged.get("ssid"):
|
|
511
|
+
bookinfo = BookInfo(**merged)
|
|
512
|
+
if bookinfo.is_valid():
|
|
513
|
+
# 页码区分来源: -20=bookinfo.dat, -21=meta.xml
|
|
514
|
+
locate_page = -20 if locate_method == "bookinfo" else -21
|
|
515
|
+
return ExtractResult(
|
|
516
|
+
bookinfo=bookinfo,
|
|
517
|
+
meta=Meta(source=str(archive_path), source_type="archive", encoding=encoding),
|
|
518
|
+
locate=Locate(page=locate_page, method=locate_method or "meta", extraction="text"),
|
|
519
|
+
elapsed=time.perf_counter() - t0,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# ── 步骤 3: leg001.pdg → 图片 → ONNX ──
|
|
523
|
+
leg_info = _get_info_ignore_case(arc, names, "leg001.pdg")
|
|
524
|
+
if leg_info is not None and getattr(leg_info, "file_size", 0) <= _MAX_PDG_SIZE:
|
|
525
|
+
raw_pdg = _read_file_ignore_case(arc, names, "leg001.pdg")
|
|
526
|
+
if raw_pdg:
|
|
527
|
+
isbn_str = _extract_isbn_from_image(raw_pdg, detector)
|
|
528
|
+
if isbn_str:
|
|
529
|
+
return ExtractResult(
|
|
530
|
+
bookinfo=BookInfo(isbn=isbn_str),
|
|
531
|
+
meta=Meta(source=str(archive_path), source_type="archive"),
|
|
532
|
+
locate=Locate(page=-10, method="leg001", extraction="ocr"),
|
|
533
|
+
elapsed=time.perf_counter() - t0,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# ── 步骤 4: 兜底 — 前 N 个 PDG ──
|
|
537
|
+
pdg_files = _list_pdg(names)
|
|
538
|
+
fallback_count = settings.archive.pdg_fallback_count
|
|
539
|
+
for idx, pdg_name in enumerate(pdg_files[:fallback_count]):
|
|
540
|
+
try:
|
|
541
|
+
info = arc.getinfo(pdg_name)
|
|
542
|
+
if getattr(info, "file_size", 0) > _MAX_PDG_SIZE:
|
|
543
|
+
continue
|
|
544
|
+
raw_pdg = arc.read(pdg_name)
|
|
545
|
+
except Exception:
|
|
546
|
+
continue
|
|
547
|
+
isbn_str = _extract_isbn_from_image(raw_pdg, detector)
|
|
548
|
+
if isbn_str:
|
|
549
|
+
return ExtractResult(
|
|
550
|
+
bookinfo=BookInfo(isbn=isbn_str),
|
|
551
|
+
meta=Meta(source=str(archive_path), source_type="archive"),
|
|
552
|
+
locate=Locate(page=idx + 1, method="pdg", extraction="ocr"),
|
|
553
|
+
elapsed=time.perf_counter() - t0,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# 所有步骤均失败
|
|
557
|
+
return ExtractResult(
|
|
558
|
+
bookinfo=BookInfo(),
|
|
559
|
+
meta=Meta(source=str(archive_path), source_type="archive"),
|
|
560
|
+
error="未提取到 ISBN",
|
|
561
|
+
elapsed=time.perf_counter() - t0,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
except Exception as e:
|
|
565
|
+
return ExtractResult(
|
|
566
|
+
bookinfo=BookInfo(),
|
|
567
|
+
meta=Meta(source=str(archive_path), source_type="archive"),
|
|
568
|
+
error=f"压缩包提取异常: {e}",
|
|
569
|
+
elapsed=time.perf_counter() - t0,
|
|
570
|
+
)
|