Files
cms-SiteForge/app/wp_migration_v82.py
T
浅念 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

351 lines
18 KiB
Python
Raw 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.
"""SiteForge V8.2: bounded WordPress sitemap discovery, safe media localization
and durable worker-backed REST scanning. All flows require explicit authorization
and remain subject to the existing SiteForge release-review gates.
"""
from __future__ import annotations
import re
import hashlib
import json
import uuid
import zlib
import time
from urllib.parse import urljoin, urlsplit
from bs4 import BeautifulSoup
from defusedxml import ElementTree
from fastapi import APIRouter, Body, HTTPException, Request
from . import db, enterprise, jobs, safe_http, wp_migration_v81 as wp
from .core import route_path
router=APIRouter(prefix='/api/migrations/sites',tags=['V8.2 WordPress migration'])
MAX_SITEMAP_URLS=20_000
MAX_SITEMAP_FILES=60
MAX_MEDIA_BYTES=8_000_000
ALLOWED={'image/png':'.png','image/jpeg':'.jpg','image/webp':'.webp','image/gif':'.gif','image/avif':'.avif'}
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS wp_migration_sitemap(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
route TEXT NOT NULL, original_url TEXT NOT NULL,
PRIMARY KEY(session_id,route));
CREATE TABLE IF NOT EXISTS wp_migration_media(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
original_url TEXT NOT NULL, local_url TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL, bytes INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL,
PRIMARY KEY(session_id,original_url));
''')
def _site_url(origin,url):
"""Same host and scoped WordPress base directory (no external sitemap)."""
p=urlsplit(url)
root=urlsplit(origin)
if (p.scheme!=root.scheme or p.username or p.password or p.query or p.fragment
or p.hostname != root.hostname or p.port != root.port):
raise ValueError('仅允许同一 WordPress 站点的无查询参数 Sitemap 地址')
prefix=root.path.rstrip('/')+'/'
if not p.path.startswith(prefix):raise ValueError('Sitemap 超出了来源站点的路径范围')
return url
def _xml_bytes(response):
raw=response.content
if len(raw)>2_000_000:raise ValueError('Sitemap 文件大于 2MB')
if raw[:2]==b'\x1f\x8b':
z=zlib.decompressobj(16+zlib.MAX_WBITS)
raw=z.decompress(raw,3_000_001)
if len(raw)>3_000_000 or z.unconsumed_tail or not z.eof:
raise ValueError('Sitemap 压缩包解压后超出限制')
if len(raw)>3_000_000:raise ValueError('Sitemap 解压后超出限制')
if b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
raise ValueError('Sitemap 禁止 DTD/实体声明')
return raw
def discover(site_id,session_id,sitemap_url=None):
s=wp._one(site_id,session_id)
if s['state'] in ('cancelled','completed','completed_with_warnings','running'):
raise HTTPException(409,'当前迁移状态不能修改 Sitemap 清单')
base=s['origin']
start=_site_url(base,sitemap_url or urljoin(base,'wp-sitemap.xml'))
queue=[start];processed=set();urls={}
while queue:
if len(processed)>=MAX_SITEMAP_FILES:raise ValueError('Sitemap 文件数超过上限')
target=queue.pop(0)
if target in processed:continue
_site_url(base,target)
processed.add(target)
response=safe_http.fetch(target,max_bytes=2_000_000,timeout=15,redirects=0,
headers={'Accept':'application/xml,text/xml'})
response.raise_for_status()
root=ElementTree.fromstring(_xml_bytes(response))
root_name=root.tag.split('}')[-1]
if root_name not in ('urlset','sitemapindex'):
raise ValueError('不是有效的 XML Sitemap 或 Sitemap Index')
for node in root:
if node.tag.split('}')[-1] not in ('sitemap','url'):continue
raw=next((v.text.strip() for v in node if v.tag.split('}')[-1]=='loc' and v.text),None)
if not raw:continue
if root_name=='sitemapindex':
queue.append(_site_url(base,raw))
if len(queue)+len(processed)>MAX_SITEMAP_FILES:raise ValueError('Sitemap Index 数量超过上限')
else:
# Different domains and out-of-scope paths cannot enter our baseline.
_site_url(base,raw)
urlp=urlsplit(raw)
normalized=route_path(urlp.path)
if normalized in urls and urls[normalized]!=raw:raise ValueError('Sitemap 中存在冲突 URL: '+normalized)
urls[normalized]=raw
if len(urls)>MAX_SITEMAP_URLS:raise ValueError('Sitemap URL 数量超过上限')
if not urls:raise ValueError('Sitemap 没有找到任何合法页面 URL')
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
c.execute('DELETE FROM wp_migration_sitemap WHERE session_id=?',(session_id,))
c.executemany('INSERT INTO wp_migration_sitemap(session_id,route,original_url) VALUES(?,?,?)',
((session_id,r,u) for r,u in urls.items()))
return sitemap_status(site_id,session_id)
def sitemap_status(site_id,session_id):
wp._one(site_id,session_id)
total=db.one('SELECT COUNT(*) n FROM wp_migration_sitemap WHERE session_id=?',(session_id,))['n']
missing=db.rows('''SELECT m.route,m.original_url FROM wp_migration_sitemap m
LEFT JOIN wp_migration_urls u ON u.session_id=m.session_id AND u.route=m.route
WHERE m.session_id=? AND u.route IS NULL ORDER BY m.route LIMIT 100''',(session_id,))
missing_count=db.one('''SELECT COUNT(*) n FROM wp_migration_sitemap m LEFT JOIN wp_migration_urls u
ON u.session_id=m.session_id AND u.route=m.route
WHERE m.session_id=? AND u.route IS NULL''',(session_id,))['n']
return {'discovered':total,'missing_count':missing_count,'missing_sample':missing}
def _image_valid(mime,raw):
if mime=='image/jpeg':return raw.startswith(b'\xff\xd8\xff')
if mime=='image/png':return raw.startswith(b'\x89PNG\r\n\x1a\n')
if mime=='image/gif':return raw.startswith((b'GIF87a',b'GIF89a'))
if mime=='image/webp':return len(raw)>12 and raw[:4]==b'RIFF' and raw[8:12]==b'WEBP'
if mime=='image/avif':return len(raw)>12 and raw[4:8]==b'ftyp' and b'avif' in raw[8:32]
return False
def _download_image(origin,url,site_id,session_id):
p=urlsplit(url);base=urlsplit(origin)
# Explicitly don't follow offsite CDNs or credential-bearing URLs automatically.
if p.scheme not in ('http','https') or p.hostname!=base.hostname or p.port!=base.port or p.username or p.password:
raise ValueError('外部 CDN 或跨域资源需要单独授权')
r=safe_http.fetch(url,max_bytes=MAX_MEDIA_BYTES,timeout=15,redirects=0)
r.raise_for_status()
mime=r.headers.get('content-type','').split(';',1)[0].lower().strip()
if mime not in ALLOWED or not _image_valid(mime,r.content):raise ValueError('不支持或无效的图片类型')
# Store only simple flat images; never use remote paths as local filenames.
name='wp-'+hashlib.sha256(url.encode()).hexdigest()[:24]+ALLOWED[mime]
target=db.DATA/'media'/site_id/name
target.parent.mkdir(parents=True,exist_ok=True)
if not target.exists():
temp=target.with_suffix(target.suffix+'.'+uuid.uuid4().hex[:8]+'.tmp')
try:temp.write_bytes(r.content);temp.replace(target)
finally:temp.unlink(missing_ok=True)
return '/media/'+name,len(r.content)
def _srcset_urls(value):
"""Extract standard WordPress srcset URL candidates, preserving width/density hints."""
if not isinstance(value,str):return []
result=[]
for candidate in value.split(','):
fields=candidate.strip().split()
if fields and fields[0].startswith(('http://','https://','//')):
result.append(fields[0])
return result
# CSS background images are common in Elementor containers and ACF galleries.
# Restrict to syntactically explicit remote URLs, never evaluate arbitrary CSS.
CSS_URL = re.compile(r'''url\(\s*([\"\']?)((?:https?:)?//[^\"\')\s]+)\1\s*\)''', re.I)
def _css_urls(raw):
return [match.group(2) for match in CSS_URL.finditer(str(raw or ''))]
def _rewrite_css_urls(raw,converted):
def replace(match):
url=match.group(2)
if url not in converted:return match.group(0)
quote=match.group(1)
return f'url({quote}{converted[url]}{quote})'
updated=CSS_URL.sub(replace,str(raw or ''))
return updated,updated!=str(raw or '')
def _media_urls(content):
"""Collect images plus responsive/lazy/CSS backgrounds for explicit review."""
urls=[content.get('cover') or '']
soup=BeautifulSoup(content.get('body') or '','html.parser')
for tag in soup.find_all(True):
for key in ('src','data-src','poster','data-background','data-background-image'):
if tag.get(key):urls.append(tag[key])
for key in ('srcset','data-srcset'):
urls.extend(_srcset_urls(tag.get(key)))
if tag.get('style'):urls.extend(_css_urls(tag['style']))
if tag.name=='style':urls.extend(_css_urls(tag.get_text()))
return list(dict.fromkeys(u for u in urls if isinstance(u,str)
and u.startswith(('https://','http://','//'))))
def _rewrite_srcset(value,converted):
candidates=[];changed=False
for part in str(value).split(','):
fields=part.strip().split()
if not fields:continue
url=fields[0]
if url in converted:
fields[0]=converted[url];changed=True
candidates.append(' '.join(fields))
return ', '.join(candidates),changed
def _rewrite_media_html(body,converted):
soup=BeautifulSoup(body,'html.parser');changed=False
for tag in soup.find_all(True):
for key in ('src','data-src','poster','data-background','data-background-image'):
if tag.get(key) in converted:
tag[key]=converted[tag[key]];changed=True
for key in ('srcset','data-srcset'):
if not tag.get(key):continue
new,updated=_rewrite_srcset(tag[key],converted)
if updated:
tag[key]=new;changed=True
if tag.name=='img' and key=='srcset' and len(new.split(','))==1:
fields=new.strip().split()
if fields and fields[0]==tag.get('src'):
tag.attrs.pop(key,None)
if tag.get('style'):
value,updated=_rewrite_css_urls(tag['style'],converted)
if updated:tag['style']=value;changed=True
if tag.name=='style':
value,updated=_rewrite_css_urls(tag.string if tag.string is not None else tag.get_text(),converted)
if updated:tag.string=value;changed=True
return str(soup) if changed else body,changed
def localize_media(site_id,session_id,limit=20):
s=wp._one(site_id,session_id)
if not 1<=int(limit)<=40:raise ValueError('单次处理媒体数量须为 1–40')
if s['state'] in ('running','cancelled','completed','completed_with_warnings'):
raise HTTPException(409,'请先暂停采集,且必须在确认导入前执行媒体本地化')
batches=db.rows('''SELECT b.id FROM wp_migration_pages p JOIN v8_import_batches b ON b.id=p.batch_id
WHERE p.session_id=? AND b.site_id=? AND b.state='staged' ORDER BY p.phase,p.page''',(session_id,site_id))
items=[]
for b in batches:
items.extend(db.rows('SELECT id,content_json FROM v8_import_items WHERE batch_id=? ORDER BY row_number',(b['id'],)))
unique=list(dict.fromkeys(url for item in items
for url in _media_urls(json.loads(item['content_json']))))
if len(unique)>4000:raise ValueError('媒体引用数量超过单次迁移安全上限 4000')
failures=[];done=0
for url in unique:
existing=db.one('SELECT * FROM wp_migration_media WHERE session_id=? AND original_url=?',(session_id,url))
if existing and existing['state']=='localized':continue
if done>=int(limit):break
done+=1
try:
local,size=_download_image(s['origin'],url,site_id,session_id)
state='localized';error=''
except Exception as exc:
local='';size=0;state='review_required';error=str(exc)[:240]
failures.append({'url':url,'reason':error})
db.execute('''INSERT INTO wp_migration_media(session_id,original_url,local_url,state,bytes,error,updated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(session_id,original_url) DO UPDATE SET
local_url=excluded.local_url,state=excluded.state,bytes=excluded.bytes,
error=excluded.error,updated_at=excluded.updated_at''',
(session_id,url,local,state,size,error,db.now()))
# Rewrite staged content only, never an already-committed or hand-edited entry.
converted={r['original_url']:r['local_url'] for r in db.rows('SELECT * FROM wp_migration_media WHERE session_id=? AND state=?',(session_id,'localized'))}
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
for item in items:
obj=json.loads(item['content_json']);modified=False
if obj.get('cover') in converted:obj['cover']=converted[obj['cover']];modified=True
if obj.get('body'):
updated,replaced=_rewrite_media_html(obj['body'],converted)
if replaced:obj['body']=updated;modified=True
if modified:c.execute('UPDATE v8_import_items SET content_json=? WHERE id=?',(json.dumps(obj,ensure_ascii=False),item['id']))
return {'attempted':done,'localized':len(converted),'failed':failures,'total_candidates':len(unique),
'remaining':len([x for x in unique if x not in converted]),'requires_review':bool(failures)}
def _findings(site_id,session_id):
# Sitemap-only URLs must block claims of complete migration.
sitemap=sitemap_status(site_id,session_id)
issues=[f'站点地图中未由 REST 采集的页面:{r["route"]}' for r in sitemap['missing_sample']]
if sitemap['missing_count']>len(sitemap['missing_sample']):
issues.append(f'其他未采集 Sitemap URL 数量:{sitemap["missing_count"]-len(sitemap["missing_sample"])}')
for r in db.rows('SELECT original_url,error FROM wp_migration_media WHERE session_id=? AND state!=?',
(session_id,'localized')):
issues.append('媒体未本地化:'+r['original_url'][:170]+';'+r['error'][:90])
for b in db.rows('''SELECT p.batch_id FROM wp_migration_pages p JOIN v8_import_batches b ON b.id=p.batch_id
WHERE p.session_id=? AND b.site_id=?''',(session_id,site_id)):
if not b['batch_id']:continue
for record in db.rows('SELECT route,content_json FROM v8_import_items WHERE batch_id=?',(b['batch_id'],)):
content=json.loads(record['content_json'])
urls=_media_urls(content)
if urls:
issues.append('正文或封面仍引用外部图片:'+record['route'])
return list(dict.fromkeys(issues))[:500]
def submit_scan(site_id,session_id,actor,key=None):
s=wp._one(site_id,session_id)
if s['state'] in ('ready','cancelled','completed','completed_with_warnings','limit_reached'):
raise HTTPException(409,'迁移采集已经完成或被取消')
return jobs.submit_migration(site_id,session_id,actor,key)
def run_scan(site_id,session_id,job_id,max_pages=500):
"""Run by isolated Worker subprocess only; do not auto-commit staged data."""
count=0
while count<max_pages:
job=db.one('SELECT cancel_requested FROM build_jobs WHERE id=? AND site_id=?',(job_id,site_id))
if job is None or job['cancel_requested']:return {'state':'cancelled','pages_processed':count}
s=wp._one(site_id,session_id)
if s['state']=='running' and s['lease_until'] and s['lease_until']>db.now():
# Previous worker may have died while fetching; wait for its per-page
# lease instead of burning all task retries during the 60-second lease.
waited=0
while waited<70 and wp._one(site_id,session_id)['lease_until'] and wp._one(site_id,session_id)['lease_until']>db.now():
row=db.one('SELECT cancel_requested FROM build_jobs WHERE id=?',(job_id,))
if not row or row['cancel_requested']:return {'state':'cancelled','pages_processed':count}
time.sleep(1);waited+=1
if waited>=70:raise TimeoutError('迁移页锁未释放,等待后再重试')
if s['state'] in ('ready','cancelled','limit_reached','completed','completed_with_warnings'):
return {'state':s['state'],'pages_processed':count,'fetched':s['fetched']}
wp.advance(site_id,session_id)
count+=1
return {'state':'page_limit','pages_processed':count}
@router.post('/{site_id}/wordpress/sessions/{session_id}/sitemap')
def api_sitemap(request:Request,site_id:str,session_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return discover(site_id,session_id,payload.get('url'))
except (ValueError,safe_http.OutboundError,ElementTree.ParseError) as exc:raise HTTPException(400,str(exc)) from exc
@router.get('/{site_id}/wordpress/sessions/{session_id}/sitemap')
def api_sitemap_status(request:Request,site_id:str,session_id:str):
enterprise.require(request,site_id,'viewer')
return sitemap_status(site_id,session_id)
@router.post('/{site_id}/wordpress/sessions/{session_id}/media/localize')
def api_media(request:Request,site_id:str,session_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return localize_media(site_id,session_id,int(payload.get('limit',20)))
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc)) from exc
@router.post('/{site_id}/wordpress/sessions/{session_id}/scan')
def api_scan(request:Request,site_id:str,session_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return submit_scan(site_id,session_id,request.state.actor,payload.get('idempotency_key'))
except (jobs.JobConflict,jobs.JobCapacityError) as exc:raise HTTPException(409,str(exc)) from exc