Files
master-teacher-studio/scripts/restore-yuan-documents.py
zhongyidianandClaude Code 45973a5145 feat: 完善名师工作室内容与成员悬浮导航
更新工作室页面、成员资料与静态资源,保留成员导航的渐变及定位交互。排除视频和超过 10 MiB 的新增部署资源。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 11:36:05 +08:00

321 lines
17 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.
"""Restore reviewed DOCX layout and media without exposing excluded source data.
Run with python3 -I and explicit --source / --project paths. Source files are
never modified. --check only validates the generated packages and references.
Old .doc files remain the existing clearly-labelled text conversions: this tool
never invokes an unapproved Office converter. No new third-party dependency is
needed beyond lxml (already installed alongside python-docx).
"""
import argparse
import copy
import hashlib
import io
import json
import posixpath
import re
import tempfile
import zipfile
from pathlib import Path
from lxml import etree as ET
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
PKG = 'http://schemas.openxmlformats.org/package/2006/relationships'
NS = {'w': W, 'r': R}
# Explicitly reviewed media allowlists: unknown new images stop the import.
MEDIA = {
'yy-e8aba6fc581d': {'image1.jpeg'},
'yy-d1ac14d56e79': {'image1.jpeg', 'image2.jpeg', 'image3.jpeg', 'image4.jpeg'},
'yy-1be8039474b0': {'image1.jpeg'},
'yy-46cd9da9d1c7': {'image1.jpeg'},
'yy-e44ee7185d30': {'image1.jpeg'},
'yy-c018fd1d6577': {f'image{i}.jpeg' for i in range(1, 6)},
'yy-4a9181c97fae': {'image1.png', 'image2.jpeg', 'image3.jpeg'},
'yy-4beac9bea263': {f'image{i}.jpeg' for i in range(1, 10)},
}
EXCLUDE = {
'yy-1be8039474b0': {'image1.jpeg': '专家手写签名,展示副本移除'},
'yy-46cd9da9d1c7': {'image1.jpeg': '专家手写签名,展示副本移除'},
'yy-4beac9bea263': {
f'image{i}.jpeg': '颁奖投屏可读学生姓名和班级,待提供脱敏图片'
for i in (4, 5, 6)
},
}
# These events lacked any standalone photographs. Other restored photographs
# stay in their original document context rather than duplicating existing galleries.
GALLERY = {
'yy-e44ee7185d30': {'image1.jpeg': '智慧康养人才培养改革方案教研现场'},
'yy-4beac9bea263': {
'image1.jpeg': '护士节主题活动致辞',
'image2.jpeg': '护士节主题活动舞台展示',
'image3.jpeg': '护理服饰演绎与集体展示',
'image7.jpeg': '护理学子宣誓仪式',
'image8.jpeg': '护理传光授帽仪式',
'image9.jpeg': '护士节主题活动集体合影',
},
}
PATTERNS = [
(re.compile(r'1982\s*年生[,,]?'), ''),
(re.compile(r'1999\.10[,,]?'), ''),
(re.compile(r'(?<!\d)1[3-9]\d{9}(?!\d)'), '〔个人手机信息已略〕'),
(re.compile(r'(?<!\d)\d{17}[\dXx](?!\d)'), '〔证件信息已略〕'),
]
def digest(blob):
return hashlib.sha256(blob).hexdigest()
def xml_bytes(root):
return ET.tostring(root, encoding='UTF-8', xml_declaration=True, standalone=True)
def remove(node):
if node.getparent() is not None:
node.getparent().remove(node)
def redact_runs(paragraph):
"""Edit only matched text spans, including matches split across Word runs."""
for regex, replacement in PATTERNS:
nodes = paragraph.findall('.//w:t', NS)
text = ''.join(node.text or '' for node in nodes)
matches = list(regex.finditer(text))
if not matches:
continue
offset = 0
for node in nodes:
value = node.text or ''
end = offset + len(value)
result = []
for i, char in enumerate(value):
position = offset + i
match = next((m for m in matches if m.start() <= position < m.end()), None)
if match is None:
result.append(char)
elif position == match.start():
result.append(replacement)
node.text = ''.join(result)
offset = end
def restore_package(source_blob, attachment_id):
with zipfile.ZipFile(io.BytesIO(source_blob)) as archive:
package = {name: archive.read(name) for name in archive.namelist() if not name.endswith('/')}
media = {Path(name).name for name in package if name.startswith('word/media/')}
if media != MEDIA.get(attachment_id, set()):
raise ValueError(f'Unexpected media requires review: {attachment_id}: {sorted(media)}')
if any(name.startswith('word/embeddings/') or name.endswith('vbaProject.bin') for name in package):
raise ValueError(f'Embedded executable/object requires review: {attachment_id}')
blocked_media = {'word/media/' + name for name in EXCLUDE.get(attachment_id, {})}
dropped = {name for name in package if name.startswith('customXml/')
or name.startswith('docProps/thumbnail')
or '/comments' in name or '/people' in name}
dropped |= blocked_media
rel_root = ET.fromstring(package['word/_rels/document.xml.rels']) if 'word/_rels/document.xml.rels' in package else None
blocked_ids = set()
if rel_root is not None:
for relationship in rel_root:
target = posixpath.normpath(posixpath.join('word', relationship.get('Target', '')))
if target in blocked_media:
blocked_ids.add(relationship.get('Id'))
before_root = ET.fromstring(package['word/document.xml'])
before_tables = len(before_root.findall('.//w:tbl', NS))
removed_drawings = 0
for name in list(package):
if name in dropped:
continue
if name.endswith('.rels'):
root = ET.fromstring(package[name])
base = '' if name == '_rels/.rels' else posixpath.dirname(posixpath.dirname(name))
for relationship in list(root):
target = posixpath.normpath(posixpath.join(base, relationship.get('Target', '')))
if target in dropped:
root.remove(relationship)
elif relationship.get('TargetMode') == 'External':
raise ValueError(f'Unexpected external relation requires review: {attachment_id}')
package[name] = xml_bytes(root)
elif name.endswith('.xml') and name.startswith('word/'):
root = ET.fromstring(package[name])
# Keep current visible content, not hidden revision histories or comments.
for node in list(root.xpath('//w:del|//w:moveFrom|//w:commentRangeStart|//w:commentRangeEnd|//w:commentReference', namespaces=NS)):
remove(node)
for node in list(root.xpath('//w:ins|//w:moveTo', namespaces=NS)):
parent = node.getparent()
if parent is not None:
position = parent.index(node)
for child in list(node):
parent.insert(position, child)
position += 1
remove(node)
for run in list(root.xpath('//w:r[w:rPr/w:vanish]', namespaces=NS)):
remove(run)
for drawing in list(root.xpath('//w:drawing|//w:pict', namespaces=NS)):
if any(value in blocked_ids for element in drawing.iter() for key, value in element.attrib.items() if key.startswith('{' + R + '}')):
remove(drawing)
removed_drawings += 1
for paragraph in root.findall('.//w:p', NS):
redact_runs(paragraph)
# Strip creator/path data in drawing alternative descriptions and edit IDs.
for element in root.iter():
for key in list(element.attrib):
local = ET.QName(key).localname
if local.startswith('rsid') or local in ('author', 'initials', 'descr'):
del element.attrib[key]
if ET.QName(element).localname in ('docPr', 'cNvPr'):
element.set('name', 'Reviewed illustration')
element.attrib.pop('title', None)
package[name] = xml_bytes(root)
core = ET.Element('{http://schemas.openxmlformats.org/package/2006/metadata/core-properties}coreProperties', nsmap={'cp': 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties'})
package['docProps/core.xml'] = xml_bytes(core)
if 'docProps/app.xml' in package:
app = ET.fromstring(package['docProps/app.xml'])
for element in app.iter():
if ET.QName(element).localname in ('Company', 'Manager', 'Template', 'HyperlinkBase'):
element.text = ''
package['docProps/app.xml'] = xml_bytes(app)
types = ET.fromstring(package['[Content_Types].xml'])
for element in list(types):
if element.get('PartName', '').lstrip('/') in dropped:
types.remove(element)
package['[Content_Types].xml'] = xml_bytes(types)
for name in dropped:
package.pop(name, None)
final_root = ET.fromstring(package['word/document.xml'])
assert len(final_root.findall('.//w:tbl', NS)) == before_tables
result = io.BytesIO()
with zipfile.ZipFile(result, 'w', zipfile.ZIP_DEFLATED) as archive:
for name, blob in package.items():
archive.writestr(name, blob)
return result.getvalue(), package, before_tables, removed_drawings
def validate(project, data, manifest):
attachments = data['attachments']
assert len({item['id'] for item in attachments}) == len(attachments)
assert len({item['path'] for item in attachments}) == len(attachments)
ids = {item['id'] for item in attachments}
for activity in data['activities']:
assert set(activity['attachmentIds']) <= ids
for item in attachments:
path = project / 'public' / item['path']
assert path.is_file(), str(path)
if item['source'].endswith('.docx'):
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
assert not any(name.startswith('customXml/') or '/comments' in name for name in names)
excluded = {'word/media/' + x for x in EXCLUDE.get(item['id'], {})}
assert not excluded.intersection(names)
root = ET.fromstring(archive.read('word/document.xml'))
for paragraph in root.findall('.//w:p', NS):
text = ''.join(paragraph.xpath('.//w:t/text()', namespaces=NS))
assert not any(regex.search(text) for regex, replacement in PATTERNS), item['id']
assert len(manifest['files']) == 109
assert sum(row['status'] == '待确认,不发布' for row in manifest['files']) == 7
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, required=True)
parser.add_argument('--project', type=Path, required=True)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
source, project = args.source.resolve(), args.project.resolve()
data_path = project / 'src/data/yuanYuanMaterials.json'
manifest_path = project / 'yuan-yuan-material-manifest.json'
data_blob, manifest_blob = data_path.read_bytes(), manifest_path.read_bytes()
data, manifest = json.loads(data_blob), json.loads(manifest_blob)
if args.check:
validate(project, data, manifest)
print('PASS: references, IDs, source counts, selective media exclusions and text redactions')
return
rows = {row['source']: row for row in manifest['files']}
attachments = {item['id']: item for item in data['attachments']}
changes, originals, report = {}, {}, []
# Prepare and verify all output in memory before replacing any published file.
for item in list(data['attachments']):
if not item['source'].endswith('.docx'):
if item['source'].endswith('.doc'):
item['note'] = '旧DOC文字转换版;尚未恢复原表格/图形,待版式转换核验;原件保留,出生信息已略'
rows[item['source']]['note'] = item['note']
rows[item['source']]['layoutStatus'] = '待核验旧DOC版式转换,不冒称完整原版'
continue
src = source / item['source']
original = src.read_bytes()
originals[src] = digest(original)
target = project / 'public' / item['path']
# Read target package before replacement; also save a non-public backup.
existing = target.read_bytes()
with zipfile.ZipFile(io.BytesIO(existing)) as archive:
archive.read('word/document.xml')
blob, package, tables, removed = restore_package(original, item['id'])
changes[target] = blob
restored_media = sorted(name for name in package if name.startswith('word/media/'))
item['title'] = item['title'].replace('(文字整理版)', '(审核展示版)')
item['note'] = '审核展示版;保留原文段落、表格及核阅通过图片,去除批注/元数据及非必要个人信息;原件保留'
excluded = EXCLUDE.get(item['id'], {})
if excluded:
item['note'] += ';已移除' + ('学生名单颁奖照片' if item['id'] == 'yy-4beac9bea263' else '手写签名')
row = rows[item['source']]
previous_note = row.get('note', '')
row.update(status='已接入审核图文副本', note=item['note'], sourceSha256=originals[src], originalTables=tables, restoredTables=tables, restoredImages=len(restored_media), excludedEmbeddedImages=excluded)
if '日期口径待确认' in previous_note:
row['note'] += '。标题为2024学年度,但正文涉及2025年10月活动,日期口径待确认;不推测发布日期。'
row['derivedImages'] = []
for image_name, title in GALLERY.get(item['id'], {}).items():
media_path = 'word/media/' + image_name
image_blob = package[media_path]
embedded_source = item['source'] + '#' + media_path
image_id = 'yy-embedded-' + digest(embedded_source.encode())[:12]
path = 'teachers/yuan-yuan/images/' + image_id + '.jpg'
new = dict(id=image_id, title=title, format='jpg', path=path, source=embedded_source, category=item['category'], note='来自已提供Word的核阅通过内嵌照片,并非新增原始资料')
if image_id not in attachments:
data['attachments'].append(new)
attachments[image_id] = new
else:
attachments[image_id].update(new)
changes[project / 'public' / path] = image_blob
for activity in data['activities']:
if item['id'] in activity['attachmentIds'] and image_id not in activity['attachmentIds']:
activity['attachmentIds'].append(image_id)
row['derivedImages'].append({'part': media_path, 'attachmentId': image_id, 'path': path, 'sha256': digest(image_blob)})
report.append({'id': item['id'], 'source': item['source'], 'tables': tables, 'images': len(restored_media), 'excludedImages': len(excluded), 'removedDrawings': removed})
for resource in data['resources']:
if resource.get('attachment'):
current = attachments[resource['attachment']['id']]
resource['attachment'] = copy.deepcopy(current)
resource['title'] = current['title']
resource['description'] = current.get('note', resource['description'])
# Check source and JSON versions before committing the prepared changes.
assert data_path.read_bytes() == data_blob and manifest_path.read_bytes() == manifest_blob, 'Metadata changed concurrently; retry from latest files'
assert all(digest(path.read_bytes()) == hash_ for path, hash_ in originals.items())
backup = Path(tempfile.mkdtemp(prefix='yuan-before-layout-restore-'))
for i, (path, blob) in enumerate(changes.items()):
if path.exists():
(backup / f'{i}-{path.name}').write_bytes(path.read_bytes())
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(blob)
(backup / 'yuanYuanMaterials.json').write_bytes(data_blob)
(backup / 'manifest.json').write_bytes(manifest_blob)
data_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
manifest['documentRestoration'] = {
'method': '保留原DOCX结构,按已核阅图片清单逐项脱敏;不整体剥离图表',
'docxFiles': len(report), 'restoredTables': sum(x['tables'] for x in report),
'restoredEmbeddedImages': sum(x['images'] for x in report),
'excludedEmbeddedImages': sum(x['excludedImages'] for x in report),
'derivedGalleryImages': sum(len(x) for x in GALLERY.values()),
'oldDocStatus': '4份旧DOC仍为文字转换版,未宣称恢复原表格或图形',
'files': report,
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')
validate(project, data, manifest)
assert all(digest(path.read_bytes()) == hash_ for path, hash_ in originals.items())
print(json.dumps({'restoredDocx': len(report), 'tables': sum(x['tables'] for x in report), 'embeddedImages': sum(x['images'] for x in report), 'excludedImages': sum(x['excludedImages'] for x in report), 'galleryImages': sum(len(x) for x in GALLERY.values()), 'attachments': len(data['attachments']), 'sourceFiles': len(manifest['files']), 'backup': str(backup)}, ensure_ascii=False))
if __name__ == '__main__':
main()