更新工作室页面、成员资料与静态资源,保留成员导航的渐变及定位交互。排除视频和超过 10 MiB 的新增部署资源。 Co-Authored-By: Claude Code <noreply@anthropic.com>
321 lines
17 KiB
Python
321 lines
17 KiB
Python
"""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()
|