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