Files

120 lines
5.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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', []))