Files
cms-SiteForge/scripts/export_wordpress_url_baseline.py
T

101 lines
4.6 KiB
Python
Raw Normal View History

#!/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())