120 lines
5.3 KiB
Python
120 lines
5.3 KiB
Python
"""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', []))
|