first commit
This commit is contained in:
41 files changed
+4964
No files matched your search
+116
@@ -0,0 +1,116 @@
|
||||
"""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=80):
|
||||
# Conservative UTF-16 budget: do not split astral characters / surrogate pairs.
|
||||
if len(title.encode('utf-16-le')) // 2 <= budget:
|
||||
return title
|
||||
out = ''
|
||||
for ch in title:
|
||||
if len((out + ch).encode('utf-16-le')) // 2 > budget - 3:
|
||||
break
|
||||
out += ch
|
||||
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', []))
|
||||
Reference in new issue
Block a user