Files
cms-SiteForge/scripts/export_wordpress_url_baseline.py
浅念 601451f22b upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)
Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.

compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).

Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 12:31:03 +08:00

101 lines
4.6 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.
#!/usr/bin/env python3
"""Convert an authorized WXR or Sitemap XML file into a URL-preservation baseline.
Offline only. Uses defusedxml; hard input limit 32 MiB. Does not download sites.
"""
import argparse
import sys
from pathlib import Path
from urllib.parse import urlsplit, unquote
try:
from defusedxml import ElementTree as DET
except ImportError:
print('需要先安装 defusedxml:python -m pip install defusedxml', file=sys.stderr)
raise SystemExit(3)
WP_NS = '{http://wordpress.org/export/1.2/}'
SITEMAP_NS = '{http://www.sitemaps.org/schemas/sitemap/0.9}'
MAX_XML = 32 * 1024 * 1024
def route_from_url(raw, allowed_hosts):
p = urlsplit(raw.strip())
if p.scheme not in ('http', 'https') or not p.hostname:
return None
if p.hostname.lower().rstrip('.') not in allowed_hosts:
return None
# Encoded dots/slashes would introduce ambiguous paths, so reject them.
path = unquote(p.path or '/')
if '\\' in path or '\x00' in path or '//' in path or any(seg in ('.', '..') for seg in path.split('/')):
return None
# Query-based legacy WordPress URLs require explicit redirect mapping.
if p.query or p.fragment:
return None
return path
def extract(root, hosts, include_unpublished=False):
routes = set()
ignored = []
if root.tag.endswith('urlset'):
items = root.findall(f'.//{SITEMAP_NS}url') or root.findall('.//url')
for item in items:
loc = item.findtext(f'{SITEMAP_NS}loc') or item.findtext('loc') or ''
route = route_from_url(loc, hosts)
if route: routes.add(route)
elif loc: ignored.append('sitemap: 无法映射的 loc')
elif root.tag == 'rss' or root.tag.endswith('rss'):
items = root.findall('./channel/item')
for item in items:
status = (item.findtext(f'{WP_NS}status') or '').strip()
kind = (item.findtext(f'{WP_NS}post_type') or '').strip()
# WXR can contain attachments and revision records: only published content pages.
if kind in ('attachment', 'revision', 'nav_menu_item'):
continue
if not include_unpublished and status != 'publish':
continue
link = item.findtext('link') or ''
route = route_from_url(link, hosts)
if route: routes.add(route)
elif link: ignored.append('WXR: 无法映射的 link')
else:
raise ValueError('仅支持 WXR RSS 或 sitemap urlset XML(不支持 sitemapindex 的递归远端访问)')
return sorted(routes), ignored
def main(argv=None):
ap = argparse.ArgumentParser(description='从授权的 WordPress WXR 或 Sitemap 导出原始 URL 基线,离线运行')
ap.add_argument('xml', type=Path)
ap.add_argument('--host', required=True, help='旧站域名,不带 scheme 或路径,例如 example.com')
ap.add_argument('--allow-host', action='append', default=[], help='手动允许其他同站主机名(例如 www.example.com)')
ap.add_argument('--include-unpublished', action='store_true', help='WXR 包含草稿(默认仅 publish)')
ap.add_argument('--output', type=Path, required=True)
args = ap.parse_args(argv)
allowed = set()
for host in [args.host] + args.allow_host:
if not host or any(c in host for c in '/:@?#') or any(c.isspace() for c in host):
ap.error('--host/--allow-host 只接受域名,不接受 URL、端口或路径')
allowed.add(host.lower().rstrip('.'))
try:
size = args.xml.stat().st_size
if size > MAX_XML: raise ValueError('XML 超过 32 MiB 上限,请分割或使用离线批处理')
if not size: raise ValueError('XML 文件为空')
root = DET.parse(args.xml).getroot()
routes, ignored = extract(root, allowed, args.include_unpublished)
except (OSError, ValueError, DET.ParseError, DET.EntitiesForbidden, DET.DTDForbidden) as e:
print('导入失败:' + str(e), file=sys.stderr)
return 2
if not routes:
print('没有找到匹配已发布内容的 URL,请检查旧站域名和 XML 格式', file=sys.stderr)
return 2
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text('# WordPress 旧站原始 URL 路径基线(不可修改路径语义)\n' + '\n'.join(routes) + '\n', encoding='utf-8')
print(f'导出 {len(routes)} 条;无法映射 {len(ignored)} 条。保存:{args.output}')
if ignored:
print('警告:有 URL 包含查询参数或使用了非指定域名;需人工建立 301 路径映射。', file=sys.stderr)
return 0
if __name__ == '__main__':
raise SystemExit(main())