Files
cms-SiteForge/app/wp_migration_v83.py
T

283 lines
16 KiB
Python
Raw Normal View History

"""V8.3: checkpointed public WordPress category/tag/media catalog and staged references.
Catalogs are *metadata only*, not a full fidelity migration of WordPress custom
fields, image files, taxonomy archive templates or WooCommerce variations.
All remote traffic uses the pinned safe_http client. Nothing here publishes a site.
"""
from __future__ import annotations
import json
import re
from urllib.parse import urlencode, urlsplit
from fastapi import APIRouter, Body, HTTPException, Request
from . import db, enterprise, safe_http, wp_migration_v81 as wp
router = APIRouter(prefix='/api/migrations/sites', tags=['V8.3 WordPress catalog'])
RESOURCE = {'categories': 'category', 'tags': 'tag', 'media': 'media'}
PAGE_SIZE = 50
MAX_ROWS = 5000
MAX_STORED_META = 16000
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS wp_catalog_cursors(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
resource TEXT NOT NULL, next_page INTEGER NOT NULL DEFAULT 1,
fetched INTEGER NOT NULL DEFAULT 0, done INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL,
PRIMARY KEY(session_id,resource));
CREATE TABLE IF NOT EXISTS wp_catalog_items(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
resource TEXT NOT NULL, wp_id INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT '', slug TEXT NOT NULL DEFAULT '',
parent_id INTEGER NOT NULL DEFAULT 0, source_url TEXT NOT NULL DEFAULT '',
meta_json TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY(session_id,resource,wp_id));
CREATE INDEX IF NOT EXISTS wp_catalog_session_resource
ON wp_catalog_items(session_id,resource);
CREATE TABLE IF NOT EXISTS wp_catalog_refs(
session_id TEXT NOT NULL REFERENCES wp_migration_sessions(id) ON DELETE CASCADE,
source_key TEXT NOT NULL, resource TEXT NOT NULL,
target_id INTEGER NOT NULL,
PRIMARY KEY(session_id,source_key,resource,target_id));
CREATE INDEX IF NOT EXISTS wp_catalog_refs_by_target
ON wp_catalog_refs(session_id,resource,target_id);
''')
def _safe_id_list(values):
if not isinstance(values, list):return []
return list(dict.fromkeys(v for v in values[:100] if type(v) is int and 0 < v <= 2_000_000_000))
def _source_meta(source, phase):
"""Bounded public-source snapshot kept separate from SiteForge business copy."""
meta = {'platform':'wordpress','wp_id':source['id'],'source_type':phase,
'category_ids':_safe_id_list(source.get('categories')),
'tag_ids':_safe_id_list(source.get('tags')),
'featured_media_id':source.get('featured_media') if type(source.get('featured_media')) is int else 0}
acf=source.get('acf')
if isinstance(acf,dict):
packed=json.dumps(acf,ensure_ascii=False)
meta['acf_keys']=[str(k)[:100] for k in list(acf)[:80]]
if len(packed.encode('utf-8'))<=MAX_STORED_META:
meta['acf_snapshot']=acf
else:meta['acf_truncated']=True
meta['requires_builder_review']=bool(source.get('elementor_data') or source.get('_elementor_data'))
return meta
def _record(resource, obj):
if not isinstance(obj,dict) or type(obj.get('id')) is not int or obj['id']<1:
raise ValueError('WordPress 分类/标签/媒体记录缺少有效 ID')
raw_url=obj.get('source_url','') if resource=='media' else obj.get('link','')
meta={}
if resource=='media':
meta={'mime_type':str(obj.get('mime_type') or '')[:120],
'alt_text':str(obj.get('alt_text') or '')[:500],
'caption':str((obj.get('caption') or {}).get('rendered',''))[:1000] if isinstance(obj.get('caption'),dict) else '',
'media_details':{'width':(obj.get('media_details') or {}).get('width'),
'height':(obj.get('media_details') or {}).get('height')}}
title=obj.get('title')
name=obj.get('name') or (title.get('rendered','') if isinstance(title,dict) else '')
return (int(obj['id']),str(name or '')[:350],
str(obj.get('slug') or '')[:200],int(obj.get('parent') or 0) if resource=='categories' else 0,
str(raw_url or '')[:1000],json.dumps(meta,ensure_ascii=False))
def _fetch(origin,resource,page):
url=origin+'wp-json/wp/v2/'+resource+'?'+urlencode({'per_page':PAGE_SIZE,'page':page})
resp=safe_http.fetch(url,max_bytes=4_000_000,timeout=18,redirects=0,headers={'Accept':'application/json'})
if resp.status_code==400 and page>1:
# End of pagination only for REST invalid-page-number, not arbitrary 400s.
try: code=resp.json().get('code')
except (ValueError,AttributeError):code=None
if code=='rest_post_invalid_page_number':return []
resp.raise_for_status()
rows=resp.json()
if not isinstance(rows,list) or len(rows)>PAGE_SIZE:raise ValueError('REST 分类目录返回数据不符合单页限制')
return rows
def sync_page(site_id,session_id,resource):
s=wp._one(site_id,session_id)
if resource not in RESOURCE:raise ValueError('不支持的 WordPress 资源目录')
if s['state'] in ('running','cancelled','completed','completed_with_warnings'):
raise HTTPException(409,'当前会话状态不允许采集分类目录')
cursor=db.one('SELECT * FROM wp_catalog_cursors WHERE session_id=? AND resource=?',(session_id,resource))
if cursor and cursor['done']:return catalog_status(site_id,session_id)
page=cursor['next_page'] if cursor else 1
rows=_fetch(s['origin'],resource,page)
parsed=[_record(resource,obj) for obj in rows]
if (cursor['fetched'] if cursor else 0)+len(rows)>MAX_ROWS:
raise ValueError('单个分类目录最多采集 5000 条;请分批处理')
# Atomic cursor progression: a failure never skips an uncommitted page.
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
actual=c.execute('SELECT next_page,done FROM wp_catalog_cursors WHERE session_id=? AND resource=?',
(session_id,resource)).fetchone()
if (actual and (actual['next_page']!=page or actual['done'])) or (not actual and page!=1):
# Another operator already committed the same page. Never count it twice.
return catalog_status(site_id,session_id)
for wp_id,name,slug,parent,source,meta in parsed:
c.execute('''INSERT INTO wp_catalog_items(session_id,resource,wp_id,name,slug,parent_id,source_url,meta_json)
VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(session_id,resource,wp_id) DO UPDATE SET
name=excluded.name,slug=excluded.slug,parent_id=excluded.parent_id,
source_url=excluded.source_url,meta_json=excluded.meta_json''',
(session_id,resource,wp_id,name,slug,parent,source,meta))
c.execute('''INSERT INTO wp_catalog_cursors(session_id,resource,next_page,fetched,done,updated_at)
VALUES(?,?,?,?,?,?) ON CONFLICT(session_id,resource) DO UPDATE SET
next_page=excluded.next_page,fetched=excluded.fetched,done=excluded.done,updated_at=excluded.updated_at''',
(session_id,resource,page+1,(cursor['fetched'] if cursor else 0)+len(rows),int(len(rows)<PAGE_SIZE),db.now()))
refresh_staged_relations(site_id,session_id)
return catalog_status(site_id,session_id)
def catalog_status(site_id,session_id):
wp._one(site_id,session_id)
states={r['resource']:{'next_page':r['next_page'],'fetched':r['fetched'],'done':bool(r['done'])}
for r in db.rows('SELECT * FROM wp_catalog_cursors WHERE session_id=?',(session_id,))}
for resource in RESOURCE:
states.setdefault(resource,{'next_page':1,'fetched':0,'done':False,'not_started':True})
return {'session_id':session_id,'resources':states,
'term_counts':{r:db.one('SELECT COUNT(*) n FROM wp_catalog_items WHERE session_id=? AND resource=?',
(session_id,r))['n'] for r in RESOURCE}}
def staged_relations(site_id,session_id,source_rows,phase,batch_id):
"""Add WP references to staged items only; never overwrite committed edits."""
if not batch_id:return
sources={f'wp-rest:{phase}:{s["id"]}':s for s in source_rows if isinstance(s,dict) and type(s.get('id')) is int}
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
items=c.execute('SELECT * FROM v8_import_items WHERE batch_id=? AND state=?',(batch_id,'staged')).fetchall()
for item in items:
src=sources.get(item['source_key'])
if not src:continue
content=json.loads(item['content_json']);meta=content.get('meta') or {}
meta['wp_source']=_source_meta(src,phase)
content['meta']=meta
for resource,ids in (('categories',meta['wp_source']['category_ids']),('tags',meta['wp_source']['tag_ids']),
('media',[meta['wp_source']['featured_media_id']] if meta['wp_source']['featured_media_id'] else [])):
for target_id in ids:
c.execute('INSERT OR IGNORE INTO wp_catalog_refs(session_id,source_key,resource,target_id) VALUES(?,?,?,?)',
(session_id,item['source_key'],resource,target_id))
c.execute('UPDATE v8_import_items SET content_json=? WHERE id=?',
(json.dumps(content,ensure_ascii=False),item['id']))
refresh_staged_relations(site_id,session_id)
def refresh_staged_relations(site_id,session_id):
wp._one(site_id,session_id)
terms={kind:{r['wp_id']:r for r in db.rows('SELECT * FROM wp_catalog_items WHERE session_id=? AND resource=?',
(session_id,kind))} for kind in ('categories','tags','media')}
batches=db.rows('''SELECT DISTINCT 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=? AND b.state='staged' ''',
(session_id,site_id))
changed=0
for batch in batches:
if not batch['batch_id']:continue
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
for row in c.execute('SELECT * FROM v8_import_items WHERE batch_id=? AND state=?',(batch['batch_id'],'staged')).fetchall():
content=json.loads(row['content_json']);meta=content.get('meta') or {}
wp_source=meta.get('wp_source') or {}
if not wp_source:continue
fields=meta.setdefault('fields',{})
for kind,key in (('categories','category'),('tags','tags')):
names=[terms[kind][i]['name'] for i in wp_source.get('category_ids' if kind=='categories' else 'tag_ids',[])
if i in terms[kind] and terms[kind][i]['name']]
if names:
fields[key]=names[0] if key=='category' else ', '.join(names[:30])
wp_source[kind+'_resolved']=names
mid=wp_source.get('featured_media_id')
if mid and mid in terms['media']:
wp_source['featured_media_original']=terms['media'][mid]['source_url']
text=json.dumps(content,ensure_ascii=False)
if text!=row['content_json']:
c.execute('UPDATE v8_import_items SET content_json=? WHERE id=?',(text,row['id']))
changed+=1
return {'updated':changed}
def register_archive_baseline(site_id,session_id):
"""Add taxonomy archive URLs to the source audit, without fabricating pages."""
from .core import route_path
session=wp._one(site_id,session_id)
base=urlsplit(session['origin'])
added=0
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
items=c.execute("SELECT resource,source_url FROM wp_catalog_items WHERE session_id=? AND resource IN ('categories','tags')",(session_id,)).fetchall()
for term in items:
url=term['source_url']
if not url:continue
p=urlsplit(url)
if (p.scheme!=base.scheme or p.hostname!=base.hostname or p.port!=base.port or p.username or p.password
or p.query or p.fragment or not p.path.startswith(base.path)):
continue
try:route=route_path(p.path)
except ValueError:continue
if c.execute('SELECT route FROM wp_migration_urls WHERE session_id=? AND route=?',(session_id,route)).fetchone():
continue
c.execute('INSERT INTO wp_migration_urls(session_id,route,original_url,status) VALUES(?,?,?,200)',
(session_id,route,url))
added+=1
return {'taxonomy_archives_added':added}
def findings(site_id,session_id):
"""All discovered references must be either matched or explicitly reviewed."""
wp._one(site_id,session_id)
seen=[]
terms={kind:{r['wp_id'] for r in db.rows('SELECT wp_id FROM wp_catalog_items WHERE session_id=? AND resource=?',
(session_id,kind))} for kind in RESOURCE}
batches=db.rows('SELECT batch_id FROM wp_migration_pages WHERE session_id=? AND batch_id IS NOT NULL',(session_id,))
for b in batches:
for r in db.rows('SELECT route,content_json FROM v8_import_items WHERE batch_id=?',(b['batch_id'],)):
obj=json.loads(r['content_json']);source=(obj.get('meta') or {}).get('wp_source') or {}
for kind,key in (('categories','category_ids'),('tags','tag_ids')):
for n in source.get(key,[]):
if n not in terms[kind]:seen.append(f'缺少 WordPress {kind} #{n},内容 {r["route"]}')
mid=source.get('featured_media_id')
if mid and mid not in terms['media']:
seen.append(f'特色图片媒体记录 #{mid} 未核对,内容 {r["route"]}')
if source.get('acf_keys'):
seen.append(f'ACF 自定义字段需要人工核对 {r["route"]}: '+', '.join(source['acf_keys'][:8]))
if source.get('acf_truncated'):
seen.append(f'ACF 内容过大未保留完整快照 {r["route"]}')
if source.get('requires_builder_review'):
seen.append(f'页面构建器布局需要人工复核 {r["route"]}')
# Broken parent references are explicit review issues.
base=urlsplit(wp._one(site_id,session_id)['origin'])
for item in db.rows('SELECT resource,wp_id,name,parent_id,source_url FROM wp_catalog_items WHERE session_id=?',(session_id,)):
if item['resource']=='categories' and item['parent_id'] and item['parent_id'] not in terms['categories']:
seen.append(f'分类 #{item["wp_id"]} 的上级分类 #{item["parent_id"]} 未采集')
if item['resource'] in ('categories','tags') and not item['name']:
seen.append(f'WordPress {item["resource"]} #{item["wp_id"]} 缺少名称')
if item['resource'] in ('categories','tags') and item['source_url']:
parsed=urlsplit(item['source_url'])
if parsed.scheme!=base.scheme or parsed.hostname!=base.hostname or parsed.port!=base.port:
seen.append(f'WordPress 分类/标签归档 URL 与源站不同源:{item["source_url"][:150]}')
return list(dict.fromkeys(seen))[:500]
@router.get('/{site_id}/wordpress/sessions/{session_id}/catalog')
def api_catalog_status(request:Request,site_id:str,session_id:str):
enterprise.require(request,site_id,'viewer')
return catalog_status(site_id,session_id)
@router.post('/{site_id}/wordpress/sessions/{session_id}/catalog/{resource}/next')
def api_catalog_next(request:Request,site_id:str,session_id:str,resource:str):
enterprise.require(request,site_id,'editor')
try:return sync_page(site_id,session_id,resource)
except (ValueError,safe_http.OutboundError) as exc:raise HTTPException(400,str(exc)) from exc
@router.post('/{site_id}/wordpress/sessions/{session_id}/catalog/refresh')
def api_catalog_refresh(request:Request,site_id:str,session_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
return refresh_staged_relations(site_id,session_id)