Files
cms-SiteForge/app/importers.py
T
浅念 3c57c167f2 chore: import SiteForge Factory V5 source (baseline)
导入 SiteForge Factory V5 基线源码:
- FastAPI + SQLite + Docker Compose(Web + Worker)架构
- 四套商业主题 × 14 页面模板(1.1.0,含历史版本快照)
- 四种建站模式骨架 + 迁移审计 + 发布门禁
- 52 项 pytest 测试
- 补全计划主文档 docs/superpowers/plans/

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 10:42:19 +08:00

240 lines
13 KiB
Python

"""Data-import adapters for CSV/JSON/XLSX/WordPress WXR/public HTML crawl."""
import csv
import io
import json
import re
import time
from urllib.parse import urlparse, urljoin, urldefrag
from collections import deque
from defusedxml import ElementTree as SafeET
from bs4 import BeautifulSoup
import httpx
from openpyxl import load_workbook
from .core import route_path, save_entry, ensure_public_url, slug_of
from . import db,wordpress_compat
KEY_ALIASES={
'title':('title','name','标题','名称','产品名称','post_title'),
'type':('type','post_type','类型','内容类型'),
'route':('route','url','path','permalink','link','路径','链接'),
'summary':('summary','excerpt','description','摘要','简介'),
'body':('body','content','text','details','正文','内容'),
'cover':('cover','image','image_url','featured_image','图片','封面'),
'seo_title':('seo_title','meta_title','SEO标题'),
'seo_description':('seo_description','meta_description','SEO描述'),
'canonical':('canonical',),
'status':('status',),
}
def normalize(rec):
r={str(k).strip().lower():v for k,v in rec.items()}
clean={}
used=set()
for canonical,aliases in KEY_ALIASES.items():
for alias in aliases:
if alias.lower() in r:
clean[canonical]=r[alias.lower()] if r[alias.lower()] is not None else ''
used.add(alias.lower());break
extra={k:v for k,v in r.items() if k not in used}
embedded=r.get('meta') if isinstance(r.get('meta'),dict) else {}
clean['meta']={**embedded,**{k:v for k,v in extra.items() if k not in ('id','meta')}}
if not clean.get('type'):clean['type']='product'
return clean
def import_records(site_id, records, origin='file', on_conflict='skip'):
if on_conflict not in ('skip','update'):
raise ValueError('无效的冲突策略')
result={'imported':0,'updated':0,'skipped':0,'errors':[]}
for index,rec in enumerate(records,1):
try:
data=normalize(rec)
if not data.get('title'):raise ValueError('缺少 title/name')
kind=data.get('type') or 'product'
if not data.get('route'):
data['route']=f'/{kind}/{slug_of(str(data["title"]))}/'
if '?' in str(data['route']) and not str(data['route']).startswith('http'):
raise ValueError('带查询参数的动态 URL 需要专项映射,不能直接转换为静态路由')
route=route_path(data['route'])
existing=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route))
if existing and on_conflict=='skip':
result['skipped']+=1;continue
save_entry(site_id,data,existing_id=existing['id'] if existing else None,source=origin)
result['updated' if existing else 'imported']+=1
except Exception as exc:
if len(result['errors'])<80:
result['errors'].append({'row':index,'message':str(exc)[:250]})
return result
def import_data_file(site_id, filename, content, conflict):
lower=filename.lower()
if lower.endswith('.csv'):
txt=content.decode('utf-8-sig')
records=list(csv.DictReader(io.StringIO(txt)))
elif lower.endswith('.json'):
parsed=json.loads(content.decode('utf-8-sig'))
if isinstance(parsed,dict):parsed=parsed.get('entries',parsed.get('items',[]))
if not isinstance(parsed,list):raise ValueError('JSON 应当为数据数组或包含 entries/items 数组')
records=parsed
elif lower.endswith('.xlsx'):
wb=load_workbook(io.BytesIO(content),read_only=True,data_only=True)
ws=wb.active
values=ws.iter_rows(values_only=True)
headers=[str(x or '').strip() for x in next(values)]
records=(dict(zip(headers,row)) for row in values)
elif lower.endswith('.xml'):
return import_wxr(site_id,content,conflict)
else:
raise ValueError('仅支持 .csv/.json/.xlsx/.xml')
return import_records(site_id,records,filename,conflict)
def import_wxr(site_id,content,conflict='skip'):
root=SafeET.fromstring(content)
ns={'content':'http://purl.org/rss/1.0/modules/content/',
'excerpt':'http://wordpress.org/export/1.2/excerpt/',
'wp':'http://wordpress.org/export/1.2/'}
# Support alternate WXR versions (e.g. 1.1)
raw_content=None; raw_wp=None; raw_excerpt=None
for e in root.iter():
if e.tag.startswith('{'):
uri=e.tag.split('}')[0][1:]
if uri.endswith('/modules/content/'):raw_content=uri
if 'wordpress.org/export/' in uri and uri.endswith('/') :raw_wp=uri
if uri.endswith('/modules/excerpt/'):raw_excerpt=uri
if raw_content:ns['content']=raw_content
if raw_wp:ns['wp']=raw_wp
if raw_excerpt:ns['excerpt']=raw_excerpt
entries=[]
media_index=wordpress_compat.wp_media_map(root)
for it in root.findall('./channel/item'):
typ=(it.findtext('wp:post_type',default='post',namespaces=ns) or 'post')
status=(it.findtext('wp:status',default='publish',namespaces=ns) or 'publish')
if typ in ('attachment','nav_menu_item','revision') or status not in ('publish','private','draft'):
continue
slug=it.findtext('wp:post_name',namespaces=ns) or slug_of(it.findtext('title') or 'page')
title=it.findtext('title') or slug
link=it.findtext('link') or ''
path=urlparse(link).path if link.startswith(('http://','https://')) else ''
if not path: path=f'/{slug}/' if typ=='page' else f'/{typ}/{slug}/'
wpmeta={}
for pm in it.findall('wp:postmeta',ns):
key=pm.findtext('wp:meta_key',namespaces=ns)
if key:wpmeta[key]=pm.findtext('wp:meta_value',default='',namespaces=ns)
body=it.findtext('content:encoded',default='',namespaces=ns)
taxonomy=wordpress_compat.wp_taxonomy(it)
# ACF stores human-readable fields as plain postmeta keys, while
# underscore-prefixed field references point to the ACF field schema.
# Preserve *all* raw metadata, but expose only safe scalar values as
# structured content fields for the visual parameter component.
fields={k:v[:10000] for k,v in wpmeta.items()
if k and not k.startswith('_') and not k.lower().startswith('field_')
and isinstance(v,str) and len(v)<=10000}
for source,dest in (('_sku','sku'),('_regular_price','regular_price'),
('_sale_price','sale_price'),('_price','price'),
('_stock_status','stock_status'),('_weight','weight')):
if wpmeta.get(source):fields[dest]=str(wpmeta[source])[:1000]
fields.update(taxonomy)
wp_featured=media_index.get(wpmeta.get('_thumbnail_id',''),'')
gallery_ids=[x.strip() for x in wpmeta.get('_product_image_gallery','').split(',') if x.strip()]
gallery=[media_index[x] for x in gallery_ids if x in media_index]
if gallery:fields['gallery']=gallery
warnings=[]
if wpmeta.get('_elementor_data'):
elementor=wordpress_compat.elementor_to_html(wpmeta['_elementor_data'])
if not body.strip() and elementor['html']:
body=elementor['html'];warnings.append('Elementor 仅转换已支持的文本与图片组件,原始布局需人工检查')
warnings.extend(elementor['warnings'])
warnings.append('检测到 Elementor 页面构建器 JSON;原生布局不能直接转换为 SiteForge 区块,需人工对照检查。')
if any(k.startswith('_product_attributes') for k in wpmeta):
warnings.append('WooCommerce 序列化商品属性待专项解析,原始 metadata 已保留。')
body=body or ''
entries.append({'type':typ if typ in ('page','post','product','service','case','brand','vehicle') else 'other',
'title':title,'route':path,'body':body,'cover':wp_featured,
'summary':it.findtext('excerpt:encoded',default='',namespaces=ns),
'seo_title':wpmeta.get('_yoast_wpseo_title') or wpmeta.get('rank_math_title') or '',
'seo_description':wpmeta.get('_yoast_wpseo_metadesc') or wpmeta.get('rank_math_description') or '',
'canonical':wpmeta.get('_yoast_wpseo_canonical') or wpmeta.get('rank_math_canonical_url') or '',
'robots':'noindex,follow' if wpmeta.get('_yoast_wpseo_meta-robots-noindex')=='1' or wpmeta.get('rank_math_robots')=='noindex' else 'index,follow',
'status':'published' if status=='publish' else 'draft',
'meta':{'elementor_widgets':elementor['widgets'] if wpmeta.get('_elementor_data') else 0,'wp_post_id':it.findtext('wp:post_id',default='',namespaces=ns),'wp_type':typ,'wp_metadata':wpmeta,'fields':fields,'migration_warnings':warnings}})
return import_records(site_id,entries,'wordpress-wxr',conflict)
def safe_get(client,url,max_bytes=3_000_000):
"""Validate each hop to prevent trivial redirect-to-private host SSRF."""
for _ in range(4):
ensure_public_url(url)
with client.stream('GET',url, follow_redirects=False) as response:
if response.status_code in (301,302,303,307,308):
loc=response.headers.get('location')
if not loc: raise ValueError('重定向缺少 Location')
url=urljoin(url,loc);continue
response.raise_for_status()
if 'text/html' not in response.headers.get('content-type','text/html'):
raise ValueError('页面不是 HTML')
buff=bytearray()
for part in response.iter_bytes():
buff.extend(part)
if len(buff)>max_bytes:raise ValueError('HTML 页面超过 3 MB')
return bytes(buff),str(response.url)
raise ValueError('页面重定向次数过多')
def extract_page(html,url):
s=BeautifulSoup(html,'html.parser')
title=s.title.get_text(' ',strip=True) if s.title else ''
description=s.find('meta',attrs={'name':re.compile('^description$',re.I)})
canonical=s.find('link',attrs={'rel':lambda val: bool(val) and ('canonical' in val if isinstance(val,list) else 'canonical' in val)})
robots=s.find('meta',attrs={'name':re.compile('^robots$',re.I)})
h1=s.find('h1')
heading=h1.get_text(' ',strip=True) if h1 else title
main=s.find('main') or s.find('article') or s.body
body=''
if main:
for tag in main.select('script,style,nav,footer,header,aside,form,iframe,svg'):
tag.decompose()
for image in main.find_all('img'):
src=image.get('src') or image.get('data-src')
if src:image['src']=urljoin(url,src)
for a in main.find_all('a',href=True):
if a['href'].startswith(('http','/','.')):a['href']=urljoin(url,a['href'])
body=str(main)
links=[]
for a in s.find_all('a',href=True):
abs_url=urljoin(url,a['href'])
if urlparse(abs_url).scheme in ('http','https'):
links.append(urldefrag(abs_url)[0])
return ({'type':'page' if urlparse(url).path=='/' else 'post',
'title':heading or 'Untitled','route':urlparse(url).path or '/',
'body':body,'summary':description.get('content','') if description else '',
'seo_title':title,'seo_description':description.get('content','') if description else '',
'canonical':canonical.get('href','') if canonical else '',
'robots':robots.get('content','index,follow') if robots else 'index,follow'},links)
def crawl_site(site_id,start,max_pages=25,conflict='skip'):
ensure_public_url(start)
max_pages=max(1,min(int(max_pages),60))
root=urlparse(start)
host=root.hostname
queue=deque([start]);visited=set();pages=[];errors=[]
# Strict same-host traversal, not open web crawling.
with httpx.Client(timeout=httpx.Timeout(12,connect=5),headers={'User-Agent':'SiteForgeLocalMigration/0.1'},trust_env=False) as client:
while queue and len(visited)<max_pages:
url=queue.popleft()
if url in visited:continue
parsed=urlparse(url)
if parsed.hostname!=host:continue
if re.search(r'/(wp-admin|wp-login|cart|checkout|admin)(/|$)',parsed.path,re.I):continue
visited.add(url)
try:
data,actual=safe_get(client,url)
if urlparse(actual).hostname!=host:raise ValueError('跳转至站外地址')
page,links=extract_page(data,actual)
pages.append(page)
for link in links:
if urlparse(link).hostname==host and link not in visited and link not in queue:
if not re.search(r'\.(pdf|zip|jpg|jpeg|png|gif|webp|js|css)(\?|$)',link,re.I):
queue.append(link)
except Exception as exc:
if len(errors)<40:errors.append({'url':url,'error':str(exc)[:180]})
result=import_records(site_id,pages,'crawler',conflict)
result.update({'crawled':len(visited),'discovered':len(pages),'fetch_errors':errors})
return result