feat: 完善名师工作室内容与成员悬浮导航
更新工作室页面、成员资料与静态资源,保留成员导航的渐变及定位交互。排除视频和超过 10 MiB 的新增部署资源。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
53d0eeac6b
commit
45973a5145
374 files changed
+9793
-1292
No files matched your search
@@ -0,0 +1,320 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user