101 lines
4.6 KiB
Python
101 lines
4.6 KiB
Python
|
|
#!/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())
|