first commit

This commit is contained in:
Longtail committed 2026-10-31 16:15:04 +08:00
commit 1e6504b4a2
41 files changed
+4964

No files matched your search

+116
View File
@@ -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', []))