"""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)