"""Local article validation and portable packages. Never changes downloaded files.""" import hashlib import json from pathlib import Path import re import unicodedata from urllib.parse import unquote from modules.package import make_package MAX_PACKAGE_BYTES = 48 * 1024 * 1024 FOOTER = re.compile(r'(?:^|\n)来源:([^\n]+),如有侵权,请联系删除。\s*$') def validate_images(body, directory): """Conservative inline-image preflight; the importer remains the final parser.""" if re.search(r'!\[[^\]]*\]\s*\[', body): raise ValueError('不支持引用式图片,请改成内联图片路径') for start in re.finditer(r'!\[[^\]]*\]\(', body): pos, depth, quote, angle = start.end(), 1, '', False begin = pos while pos < len(body): ch = body[pos] if ch == '\\': pos += 2 continue if quote: if ch == quote: quote = '' elif not angle and depth == 1 and body[pos - 1].isspace() and ch in ('"', "'"): quote = ch elif ch == '<': angle = True elif ch == '>': angle = False elif not angle and ch == '(': depth += 1 elif not angle and ch == ')': depth -= 1 if not depth: break pos += 1 if depth: raise ValueError('图片引用括号不完整') target = body[begin:pos].strip() target = target[1:target.index('>')] if target.startswith('<') and '>' in target else re.sub(r'\s+["\'][\s\S]*["\']\s*$', '', target) target = unquote(re.sub(r'\\([()\\ ])', r'\1', target)) if re.match(r'^(?:[a-zA-Z][\w+.-]*:|/|\\)', target): raise ValueError('图片必须使用目录内相对路径:' + target) image = (directory / target).resolve() if not image.is_relative_to(directory) or not image.is_file(): raise ValueError('缺图或图片路径超出目录:' + target) if image.suffix.lower() not in ('.jpg', '.jpeg', '.png', '.gif'): raise ValueError('不支持的配图格式:' + target) if not 0 < image.stat().st_size < 20 * 1024 * 1024: raise ValueError('配图为空或达到 20 MiB:' + target) def short_title(title, budget=75): """Keep a UTF-8 prefix within budget, adding three ASCII dots if shortened.""" if len(title.encode('utf-8')) <= budget or (title.endswith('...') and len(title.encode('utf-8')) <= budget + 3): return title out = '' used = 0 for ch in title: size = len(ch.encode('utf-8')) if used + size > budget: break out += ch used += size return out + '...' def inspect_article(directory): directory = Path(directory).resolve(strict=True) candidates = sorted(directory.glob('*.md')) if len(candidates) != 1: raise ValueError('每个文章目录必须恰好包含一个 Markdown 文件') md = candidates[0] if not md.resolve().is_relative_to(directory): raise ValueError('Markdown 不得链接到目录外') raw = md.read_text(encoding='utf-8-sig') heading = re.match(r'^\s*#\s+([^\n]+)\n?', raw) title = (heading[1] if heading else md.stem).strip() body = raw[heading.end():].strip() if heading else raw.strip() footer = FOOTER.search(body) if not footer: raise ValueError('文末缺少标准来源尾注,请补充来源后再扫描') source = footer[1].strip() body = body[:footer.start()].strip() if not title or not body: raise ValueError('标题或正文为空') validate_images(body, directory) for file in directory.rglob('*'): if file.suffix.lower() in ('.webp', '.avif', '.svg'): raise ValueError('发现暂不支持的图片格式:' + file.name + ';请另存为 JPG/PNG/GIF 并更新引用') pack = make_package(directory) if sum(len(i['base64']) for i in pack['images']) > MAX_PACKAGE_BYTES: raise ValueError('文章包超过 48 MiB,请减少单篇图片体积') pack['title'] = short_title(title) # Preserve body and exact final footer for the formatter; the original file stays intact. pack['markdown'] = '# ' + pack['title'] + '\n\n' + body + '\n\n来源:' + source + ',如有侵权,请联系删除。\n' content = unicodedata.normalize('NFC', body) # Hash file contents, not directory/title; renamed copies share their article identity. image_hashes = [] for item in pack['images']: import base64 digest = hashlib.sha256(base64.b64decode(item['base64'])).hexdigest() content = content.replace(item['path'], 'image:' + digest) image_hashes.append(digest) identity = hashlib.sha256((content + '\n' + '\n'.join(sorted(set(image_hashes)))).encode()).hexdigest() metadata = {} marker = directory / '.article.json' if marker.is_file(): metadata = json.loads(marker.read_text()) return dict(article_key=identity, markdown_sha256=hashlib.sha256(md.read_bytes()).hexdigest(), directory=str(directory), original_title=title, title=pack['title'], source=source, image_count=len(pack['images']), package=pack, source_keys=metadata.get('fingerprints', []))