Files
cms-SiteForge/app/builder.py
T
浅念 8cea7dee20 chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests)
- Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR
  imports keep post/product types instead of collapsing to page
- Docs: trade-pro theme port spec (R3) and implementation plan

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

470 lines
30 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.
"""Static site assembly, audit, ZIP export, immutable build versions."""
import json
import re
import shutil
import hashlib
import mimetypes
import zipfile
from pathlib import Path
from urllib.parse import urlparse, urljoin
from html import escape
from xml.sax.saxutils import escape as xml_escape
from bs4 import BeautifulSoup
import httpx
from . import safe_http
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
from . import db, blueprints, theme_store, static_site, responsive, theme_pro, site_design_v87
from .core import uid, route_path, ensure_public_url, sanitize, THEMES
ROOT=Path(__file__).resolve().parent.parent
THEME_DIR=ROOT/'themes'
ENV=Environment(loader=FileSystemLoader(str(THEME_DIR)),autoescape=select_autoescape(['html','xml']))
TYPE_NAMES={'page':'页面','post':'新闻','product':'产品','service':'服务','case':'案例','brand':'品牌','vehicle':'车型','other':'内容'}
def output_path(dist,route):
path=route_path(route)
if path=='/':return dist/'index.html'
clean=path.lstrip('/')
# Treat explicit .html/.htm as files and other endpoints as directory/index.
if clean.lower().endswith(('.html','.htm')):
out=dist/clean
else:
out=dist/clean/'index.html'
target=out.resolve()
if not target.is_relative_to(dist.resolve()):raise ValueError('非法生成目标目录')
return out
def link(path):
return (path or '/').lstrip('/') or './'
def public_url(site,route):
origin=site['domain'].strip().rstrip('/')
if not origin.startswith(('https://','http://')):return ''
return origin+route
def render_blocks(blocks):
parts=[]
if not isinstance(blocks,list):return ''
for block in blocks[:30]:
if not isinstance(block,dict):continue
typ=str(block.get('type','text'))
title=escape(str(block.get('title','')))
sub=escape(str(block.get('subtitle','')))
body=sanitize(str(block.get('body','')))
if typ=='hero':
parts.append(f'<section class="block-hero"><div class="container"><span class="eyebrow">{sub}</span><h1>{title}</h1><p>{escape(str(block.get("description","")))}</p></div></section>')
elif typ=='features':
features=block.get('items',[])
cards=''.join(f'<div class="feature"><div class="feature-mark">0{i+1}</div><h3>{escape(str(x.get("title","")))}</h3><p>{escape(str(x.get("text","")))}</p></div>' for i,x in enumerate(features[:12]) if isinstance(x,dict))
parts.append(f'<section class="section"><div class="container"><div class="section-heading"><h2>{title}</h2></div><div class="feature-grid">{cards}</div></div></section>')
elif typ=='text':
parts.append(f'<section class="section"><div class="container prose"><h2>{title}</h2>{body}</div></section>')
elif typ=='stats':
vals=''.join(f'<div class="stat"><strong>{escape(str(x.get("value","")))}</strong><span>{escape(str(x.get("label","")))}</span></div>' for x in block.get('items',[])[:8] if isinstance(x,dict))
parts.append(f'<section class="section stats-section"><div class="container stats">{vals}</div></section>')
elif typ=='cta':
target=block.get('href','/contact/')
try:target=link(route_path(target))
except ValueError:target='./'
parts.append(f'<section class="section"><div class="container cta"><h2>{title}</h2><p>{sub}</p><a class="btn" href="{escape(target,quote=True)}">{escape(str(block.get("button","Contact us")))}</a></div></section>')
return ''.join(parts)
def url_fix(link_value,origin_host,known_routes):
"""Convert imported internal absolute URLs to static-site-relative links."""
if not link_value:return link_value
parsed=urlparse(link_value)
if parsed.scheme in ('mailto','tel'):return link_value
if parsed.scheme in ('https','http') and parsed.hostname==origin_host and parsed.path in known_routes:
return link(parsed.path) + (('?' + parsed.query) if parsed.query else '')
if not parsed.scheme and link_value.startswith('/') and parsed.path in known_routes:
return link(parsed.path)
return link_value
def rewrite_internal_links(body,origin_host,known_routes):
soup=BeautifulSoup(body or '', 'html.parser')
for a in soup.find_all('a',href=True):
a['href']=url_fix(a['href'],origin_host,known_routes)
return str(soup)
def copy_media(site_id,dist):
src=db.DATA/'media'/site_id
target=dist/'media'
if src.exists():shutil.copytree(src,target,dirs_exist_ok=True)
legacy=db.DATA/'legacy'/site_id
if legacy.exists():shutil.copytree(legacy,dist,dirs_exist_ok=True)
def normalize_local_images(html):
soup=BeautifulSoup(html or '', 'html.parser')
for tag in soup.find_all('img',src=True):
src=tag['src']
if src.startswith('media/'):
tag['src']='/'+src
# Keep /media/... absolute to the deployed site root, including in nested pages.
return str(soup)
def mirror_images(html,dist,origin_host,client,cache,warnings):
soup=BeautifulSoup(html or '', 'html.parser')
for tag in soup.find_all('img',src=True):
tag['src']=mirror_image_url(tag['src'],dist,origin_host,client,cache,warnings)
return str(soup)
def mirror_image_url(raw,dist,origin_host,client,cache,warnings):
if not raw:return raw
if raw.startswith('/media/'):return raw # already localized, root-relative static asset
parsed=urlparse(raw)
if parsed.scheme not in ('http','https'):
if raw.startswith('/'):
return raw.lstrip('/')
return raw
if raw in cache:return cache[raw]
try:
ensure_public_url(raw)
response=safe_http.fetch(raw,max_bytes=8_000_000,timeout=10,redirects=0)
response.raise_for_status()
mime=response.headers.get('content-type','').split(';')[0].lower()
allowed={'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','image/svg+xml':'.svg','image/avif':'.avif'}
if mime not in allowed:raise ValueError(f'图片类型不支持:{mime}')
buf=response.content
# SVG can have scripts; disallow external SVG for safety.
if mime=='image/svg+xml':raise ValueError('SVG 外部资源需人工审查')
orig=parsed.path.lstrip('/')
# Original path is preserved for same-domain assets with safe extensions.
if parsed.hostname==origin_host and orig and re.fullmatch(r'[\w\-./%]+',orig) and not any(p in ('.','..') for p in orig.split('/')) and Path(orig).suffix.lower() in allowed.values():
rel=orig
else:
rel='media/remote-'+hashlib.sha256(raw.encode()).hexdigest()[:16]+allowed[mime]
dst=dist/rel
if not dst.resolve().is_relative_to(dist.resolve()):raise ValueError('图片路径越界')
dst.parent.mkdir(parents=True,exist_ok=True)
dst.write_bytes(buf)
cache[raw]=rel
return rel
except Exception as exc:
warnings.append(f'外部图片未本地化: {raw[:100]} ({str(exc)[:120]})')
return raw
def emit_nginx(dist,redirects,slashless):
# Include snippets to be inserted inside the real site's `server {}` block.
lines=['# SiteForge 生成:将本文件内容合并到 Nginx server {} 内','index index.html;','error_page 404 /404.html;',
'location / { try_files $uri $uri/ =404; }']
for old,new,code in redirects:
lines.append(f'location = {old} {{ return {int(code)} {new}; }}')
for route in slashless:
lines.append(f'location = {route} {{ try_files {route}/index.html =404; }}')
(dist/'nginx-snippet.conf').write_text('\n'.join(lines)+'\n',encoding='utf-8')
def build(site_id,mirror=False):
site=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
if not site:raise ValueError('网站不存在')
if site['theme'] not in theme_store.ids():raise ValueError('无效的主题')
records=db.rows("SELECT * FROM entries WHERE site_id=? AND status='published' ORDER BY type,title",(site_id,))
redirects=db.rows('SELECT * FROM redirects WHERE site_id=?',(site_id,))
settings=json.loads(site['settings_json'] or '{}')
site['schemas']={k:v for k,v in blueprints.CONTENT_MODELS.items()}
for row in db.rows('SELECT content_type,fields_json FROM content_schemas WHERE site_id=?',(site_id,)):
site['schemas'][row['content_type']]=json.loads(row['fields_json'])
site['email']=settings.get('email','')
site['footer_text']=settings.get('footer_text','')
routes={e['route'] for e in records}
issues=[];warnings=[];slashless=[]
if '/' not in routes:warnings.append('缺少 / 首页:已自动生成默认首页')
if not site['domain'].startswith(('http://','https://')):warnings.append('尚未配置完整域名:未生成绝对 canonical')
route_set=set(routes)
reserved={'/sitemap.xml','/robots.txt','/404.html'}
for e in records:
if e['route'] in reserved:issues.append('页面路由与系统文件冲突: '+e['route'])
if e['route']!='/' and not e['route'].endswith('/') and not e['route'].endswith(('.html','.htm')):
slashless.append(e['route'])
if any(r['old_path'] in route_set for r in redirects):issues.append('重定向来源和有效页面冲突')
if issues:raise ValueError('构建失败:'+';'.join(issues))
bid=uid()
dist=db.DATA/'builds'/site_id/bid/'dist'
dist.mkdir(parents=True,exist_ok=True)
copy_media(site_id,dist)
(dist/'assets').mkdir(exist_ok=True)
theme_pin=theme_store.pinned(site_id)
theme_dir=theme_store.resolve(site['theme'],theme_pin['version'] if theme_pin and theme_pin['theme_id']==site['theme'] else None)
shutil.copy2(theme_dir/'theme.css',dist/'assets'/'theme.css')
shutil.copy2(ROOT/'app'/'static'/'blocks.css',dist/'assets'/'blocks.css')
shutil.copy2(ROOT/'app'/'static'/'pro-base.css',dist/'assets'/'pro-base.css')
shutil.copy2(ROOT/'app'/'static'/'commercial.css',dist/'assets'/'commercial.css')
visuals=theme_dir/'visual'
if visuals.is_dir():
shutil.copytree(visuals,dist/'assets'/'visual',dirs_exist_ok=True)
shutil.copy2(ROOT/'app'/'static'/'pro.js',dist/'assets'/'pro.js')
if (theme_dir/'pro.css').exists():
shutil.copy2(theme_dir/'pro.css',dist/'assets'/'pro.css')
else:
(dist/'assets'/'pro.css').write_text('',encoding='utf-8')
accent=site['accent'] if re.fullmatch(r'#[0-9a-fA-F]{6}',site['accent'] or '') else '#2563eb'
tokens=settings.get('design_tokens') or {}
allowed_fonts={'system':'system-ui,-apple-system,BlinkMacSystemFont,sans-serif',
'serif':'Georgia,Times New Roman,serif',
'modern':'Arial,Helvetica,sans-serif'}
font=allowed_fonts.get(tokens.get('font'),'system-ui,-apple-system,sans-serif')
radius=int(max(0,min(30,tokens.get('radius',12))))
space=int(max(32,min(140,tokens.get('section_spacing',72))))
width=int(max(900,min(1600,tokens.get('container_width',1200))))
scale=float(max(.75,min(1.5,tokens.get('heading_scale',1))))
button={'rounded':f'{radius}px','pill':'999px','square':'0px'}.get(tokens.get('button_style'),f'{radius}px')
theme_css=(f':root{{--accent:{accent};--sf-font:{font};--sf-radius:{radius}px;'
f'--sf-space:{space}px;--sf-width:{width}px;--sf-heading-scale:{scale};}}'
f'body{{font-family:var(--sf-font)}} .sf-canvas .sf-section,.sf-canvas .section{{padding-block:var(--sf-space)}}'
f'.container,.sf-container{{max-width:var(--sf-width)}}'
f'.btn,.primary,.request,.button,.nav-btn{{background-color:{accent};border-radius:{button}}}'
f'body .sf-canvas h1{{font-size:clamp(2.5rem,5vw,calc(4.8rem * var(--sf-heading-scale)))}}')
(dist/'assets'/'custom.css').write_text(theme_css+responsive.site_css(records)+site_design_v87.responsive_css(site_design_v87.published(settings)),encoding='utf-8')
# For installed theme packages, Jinja loads only the trusted included template.
professional_template=(theme_dir/'pro.html').exists()
if professional_template and site['theme'] not in theme_pro.THEMES:
shutil.copy2(theme_dir/'theme.css',dist/'assets'/'theme-overrides.css')
template=ENV.get_template(site['theme']+'/site.html') if site['theme'] in THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve() else Environment(loader=FileSystemLoader(str(theme_dir)),autoescape=True).get_template('site.html')
orig=urlparse(settings.get('origin') or site['domain'])
origin_host=orig.hostname
local_records=[dict(e) for e in records]
remote_cache={}
try:
with httpx.Client(timeout=httpx.Timeout(10,connect=4),trust_env=False,headers={'User-Agent':'SiteForgeAssetMirror/0.1'}) as client:
for e in local_records:
e['body']=normalize_local_images(rewrite_internal_links(e['body'],origin_host,route_set))
if mirror:
e['body']=mirror_images(e['body'],dist,origin_host,client,remote_cache,warnings)
if e['cover']:
e['cover']=mirror_image_url(e['cover'],dist,origin_host,client,remote_cache,warnings)
if mirror and site['logo']:
site['logo']=mirror_image_url(site['logo'],dist,origin_host,client,remote_cache,warnings)
except Exception as exc:
warnings.append('资源镜像操作异常: '+str(exc))
# Precompute immutable catalogue indexes once per build. 5,000+ detail pages
# must not repeatedly regroup every product and parse every metadata JSON.
prepared_catalogue=theme_pro.prepare(local_records)
# Derive brand and category landing pages from actual content, not fabricated business data.
# Existing imported URLs always take priority; derived routes never rewrite source records.
derived=[]
product_records=[x for x in local_records if x.get('type') in ('product','vehicle')]
for cat in (x['name'] for x in prepared_catalogue['categories']):
path='/categories/'+theme_pro.slug(cat)+'/'
if path not in route_set:
derived.append({'id':'derived-category','route':path,'title':cat,'category':cat,'type':'page',
'summary':'分类产品与相关信息','body':'','cover':'','meta_json':'{}','seo_title':'','seo_description':'','canonical':'','robots':'index,follow'})
route_set.add(path)
for brand in prepared_catalogue['brands']:
if brand['url'] not in route_set:
derived.append({'id':'derived-brand','route':brand['url'],'title':brand['name'],'type':'brand',
'summary':'相关品牌及产品','body':'','cover':'','meta_json':'{}','seo_title':'','seo_description':'','canonical':'','robots':'index,follow'})
route_set.add(brand['url'])
# Detail pages, including custom-type documents.
totals=0
def render_one(page,mode='detail',listing_items=None):
nonlocal totals
page=page or {'title':site['name'],'route':'/','summary':site['tagline'],'body':'','cover':'','seo_title':'','seo_description':'','robots':'index,follow','canonical':'','type':'page','meta_json':'{}'}
meta=json.loads(page.get('meta_json') or '{}')
if not isinstance(meta,dict):meta={}
defined_blocks=meta.get('blocks',[])
use_blueprint=isinstance(defined_blocks,list) and len(defined_blocks)>0
blocks_html=blueprints.render_blocks(defined_blocks,site,page,local_records) if use_blueprint else ''
# Starter/scaffold preset blocks are layout defaults, not additional content;
# the full theme already renders the corresponding page family. Keep edited
# blocks, even on starter pages, so the editor remains effective.
if professional_template and use_blueprint and meta.get('layout_mode')!='replace' and page.get('source') in ('starter','site-bootstrap','scaffold'):
kind=theme_pro.infer_kind(page,mode)
try:
def signature(rows):
return [{k:v for k,v in row.items() if k!='id'} for row in rows]
# Starter layouts may belong to any previously selected theme.
is_default=any(signature(defined_blocks)==signature(blueprints.theme_blocks(t,kind)) for t in theme_pro.THEMES)
if not is_default and page.get('source')=='site-bootstrap' and kind=='listing':
# Bootstrap customizes collection title/source only; native listing
# already renders that content without the duplicate block.
is_default=all(b.get('type') in ('collection','spacer') for b in defined_blocks)
if is_default:blocks_html=''
except (ValueError,TypeError,KeyError):pass
canon=page.get('canonical') or public_url(site,page['route'])
if professional_template:
# Version-resolved trusted templates; uploaded themes cannot execute arbitrary code.
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve():
html=theme_pro.render(site['theme'],site,page,local_records,mode,listing_items,blocks_html,prepared=prepared_catalogue)
else:
env=Environment(loader=FileSystemLoader([str(theme_dir),str(THEME_DIR)]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,page,local_records,mode,listing_items,prepared=prepared_catalogue)
ctx.update(custom_blocks=Markup(blocks_html),has_custom=bool(blocks_html),noindex=(ctx['kind']=='notfound'),theme_overrides=True,designer_replace=meta.get('layout_mode')=='replace')
html=env.get_template('pro.html').render(**ctx)
else:
html=template.render(site=site,page=page,mode=mode,content=Markup(page.get('body') or ''),
blocks=Markup(blocks_html),blueprint_mode=use_blueprint,entries=local_records,listing_items=listing_items or [],
type_names=TYPE_NAMES,nav=settings.get('nav') or [e for e in local_records if e['type']=='page' and e['route'] in ('/','/about/','/about-us/','/contact/','/contact-us/','/services/','/products/')],
link=link, asset=lambda x: x.lstrip('/') if x.startswith('/') else x, canonical=canon, seo_title=page.get('seo_title') or page['title'],
seo_description=page.get('seo_description') or page.get('summary') or site['tagline'])
html=static_site.shell(html,site,page,local_records,settings.get('nav') or [])
# Each page supplies a stable selector to scope responsive rules in
# global custom.css. Reused templates can share block IDs safely.
html=__import__('re').sub(r'<body(?=[\s>])',
'<body data-siteforge-route="'+responsive.route_key(page['route'])+'"',html,count=1,flags=__import__('re').I)
target=output_path(dist,page['route'])
if target.exists():raise ValueError('多个路由写入同一文件: '+page['route'])
target.parent.mkdir(parents=True,exist_ok=True)
target.write_text(html,encoding='utf-8')
totals+=1
# Static pagination: large product collections must not embed 5,000 cards
# in one HTML page. Every item remains reachable, including in the sitemap.
LIST_PAGE_SIZE=100
def render_paginated(page,items):
root_route=page['route'].rstrip('/')+'/'
pages=max(1,(len(items)+LIST_PAGE_SIZE-1)//LIST_PAGE_SIZE)
for number in range(1,pages+1):
current=dict(page)
current['route']=root_route if number==1 else root_route+'page/'+str(number)+'/'
if current['route'] in route_set and current['route']!=root_route:
raise ValueError('自动分页 URL 与原始内容冲突:'+current['route'])
current['_pagination']={'page':number,'pages':pages,
'previous':(root_route if number==2 else root_route+'page/'+str(number-1)+'/') if number>1 else '',
'next':root_route+'page/'+str(number+1)+'/' if number<pages else ''}
if number>1:
current['title']=str(page.get('title',''))+' · 第 '+str(number)+' 页'
current['seo_description']=str(page.get('summary') or page.get('title') or '')+' · 第 '+str(number)+' 页'
render_one(current,'listing',items[(number-1)*LIST_PAGE_SIZE:number*LIST_PAGE_SIZE])
route_set.add(current['route'])
for e in local_records:render_one(e,'home' if e['route']=='/' else 'detail')
category_items={}
for item in product_records:
category=str(theme_pro.fields(item).get('category') or '').strip().casefold()
if category:category_items.setdefault(category,[]).append(item)
for e in derived:
if e.get('category'):render_paginated(e,category_items.get(e['category'].casefold(),[]))
else:render_one(e,'detail')
if '/' not in routes:render_one(None,'home')
for typ in sorted({e['type'] for e in local_records if e['type']!='page'} | {'product'}):
list_route='/'+typ+'/'
if list_route in route_set:continue
itemlist=[e for e in local_records if e['type']==typ]
render_paginated({'type':'page','route':list_route,'title':TYPE_NAMES.get(typ,typ)+'展示',
'summary':'Explore our '+typ,'meta_json':'{}'},itemlist)
# Theme links must never lead to 404 simply because a new site has not
# entered its first products or company profile. These are explicitly
# noindex *empty placeholders*, not fabricated imported content.
generated_noindex=set()
required_sections={
'/products/':('产品展示','listing'),'/brands/':('品牌中心','listing'),
'/news/':('新闻资讯','listing'),'/cases/':('项目案例','listing'),
'/about/':('关于我们','detail'),'/contact/':('联系我们','detail'),
'/services/':('服务介绍','listing'),'/faq/':('常见问题','detail'),
}
for route,(title,mode) in required_sections.items():
if route in route_set:continue
page={'title':title,'route':route,'type':'page',
'summary':'请在后台填写真实企业资料及栏目内容后再开放收录。',
'body':'','cover':'','meta_json':'{}','robots':'noindex,follow'}
if route=='/products/':
# Standard product hub is also paginated. Otherwise thousands of
# vehicles would still be embedded into one generated scaffold page.
render_paginated(page,product_records)
else:
render_one(page,mode)
route_set.add(route)
generated_noindex.add(route)
if generated_noindex:
warnings.append(f'{len(generated_noindex)} 个尚无真实内容的标准栏目已生成 noindex 占位页,发布前请补充。')
notfound={'title':'404 · 页面不存在','route':'/404.html','summary':'','body':'','cover':'','meta_json':'{}','robots':'noindex,follow'}
if professional_template:
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve():
missing_html=theme_pro.render(site['theme'],site,notfound,local_records,'notfound',prepared=prepared_catalogue)
else:
env=Environment(loader=FileSystemLoader([str(theme_dir),str(THEME_DIR)]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,notfound,local_records,'notfound',prepared=prepared_catalogue)
ctx.update(custom_blocks=Markup(''),has_custom=False,noindex=True,theme_overrides=True)
missing_html=env.get_template('pro.html').render(**ctx)
else:
missing_html=template.render(site=site,page=notfound,mode='notfound',content='',blocks='',blueprint_mode=False,entries=local_records,listing_items=[],type_names=TYPE_NAMES,nav=[],link=link,seo_title='404',seo_description='Page not found',canonical='')
(dist/'404.html').write_text(missing_html,encoding='utf-8')
# A complete static search page is packaged for every theme, not a live API.
static_site.write_assets(dist,local_records)
if '/search/' not in route_set:
search_file=dist/'search'/'index.html'
search_file.parent.mkdir(parents=True,exist_ok=True)
search_entry={'title':'站内搜索','route':'/search/','type':'page','summary':'搜索本站内容','body':'','meta_json':'{}','robots':'noindex,follow'}
if professional_template:
if site['theme'] in theme_pro.THEMES and theme_dir.resolve()==(THEME_DIR/site['theme']).resolve():
search_html=theme_pro.render(site['theme'],site,search_entry,local_records,prepared=prepared_catalogue)
else:
env=Environment(loader=FileSystemLoader([str(theme_dir),str(THEME_DIR)]),autoescape=select_autoescape(['html']))
ctx=theme_pro.make_context(site,search_entry,local_records,prepared=prepared_catalogue)
ctx.update(custom_blocks=Markup(''),has_custom=False,noindex=True,theme_overrides=True)
search_html=env.get_template('pro.html').render(**ctx)
else:
search_html=static_site.search_page(site)
search_file.write_text(search_html,encoding='utf-8')
route_set.add('/search/')
noindex_routes={e['route'] for e in records if 'noindex' in (e['robots'] or '').lower()} | {'/search/'} | generated_noindex
canonical_routes=sorted([x for x in route_set if not x.startswith('/404') and x not in noindex_routes])
if site['domain'].startswith(('https://','http://')):
urls='\n'.join('<url><loc>'+xml_escape(public_url(site,r))+'</loc></url>' for r in canonical_routes)
sitemap='<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+urls+'</urlset>'
(dist/'sitemap.xml').write_text(sitemap,encoding='utf-8')
(dist/'robots.txt').write_text('User-agent: *\nAllow: /\n'+(f'Sitemap: {site["domain"].rstrip("/")}/sitemap.xml\n' if site['domain'].startswith('http') else ''),encoding='utf-8')
rlist=[]
for r in redirects:
old,new=route_path(r['old_path']),r['new_path']
if not (new.startswith('/') or new.startswith('https://')):raise ValueError('非法重定向目标')
rlist.append((old,new,int(r['status_code'])))
emit_nginx(dist,rlist,slashless)
(dist/'.htaccess').write_text('Options -MultiViews\nRewriteEngine On\n'+''.join(f'RewriteRule ^{re.escape(old.lstrip("/"))}$ {new} [R={code},L,NE]\n' for old,new,code in rlist),encoding='utf-8')
# Evaluate packaged files; external src references are reported (not silently asserted migrated).
external=[];missing=[];broken_links=[];legacy_links=[];canonical_mismatches=[]
target_host=urlparse(site['domain']).hostname
for e in records:
c=urlparse(e['canonical'] or '')
if c.hostname and target_host and c.hostname != target_host:
canonical_mismatches.append(e['route'])
for f in dist.rglob('*.html'):
soup=BeautifulSoup(f.read_text(encoding='utf-8'),'html.parser')
for a in soup.select('a[href]'):
href=a.get('href','')
p=urlparse(href)
if p.scheme in ('http','https'):
if p.hostname==origin_host and p.path not in route_set and href not in legacy_links:
legacy_links.append(href)
elif href and href not in ('.','./','index.html') and not href.startswith(('#','mailto:','tel:','javascript:')):
candidate=p.path
if candidate and not candidate.startswith('/'):
candidate='/'+candidate
if candidate and candidate not in route_set and candidate not in ('/index.html',) and not (dist/candidate.lstrip('/')).is_file() and candidate not in broken_links:
broken_links.append(candidate)
for img in soup.select('img[src]'):
src=img.get('src','')
if src.startswith(('http://','https://')):
if src not in external:external.append(src)
else:
rel=src.lstrip('/')
if rel and not (dist/rel).is_file() and rel not in missing:missing.append(rel)
if external:warnings.append(f'{len(external)} 个外部图片 URL 尚未打包(可开启镜像重建)')
if broken_links:warnings.append(f'{len(broken_links)} 个内部链接无对应页面或文件')
if legacy_links:warnings.append(f'{len(legacy_links)} 个指向源站但未能映射的链接,需人工核验')
if canonical_mismatches:warnings.append(f'{len(canonical_mismatches)} 个页面 canonical 域名与新站域名不一致')
if missing:warnings.append(f'{len(missing)} 个本地引用图片缺失')
if slashless:warnings.append(f'{len(slashless)} 个无后缀、无尾斜杠 URL 需要按照生成的 Nginx 规则部署')
report={'site':site['name'],'version':bid,'pages':totals,'theme':site['theme'],'theme_version':theme_pin['version'] if theme_pin else theme_store.latest(site['theme']),
'warnings':warnings,'errors':issues,'external_assets':external[:200],
'missing_local_images':missing[:200],'broken_links':broken_links[:200],'legacy_links':legacy_links[:200],'canonical_mismatches':canonical_mismatches[:200],'slashless_routes':slashless,
'redirects':len(redirects),'mirrored_images':len(remote_cache),
'quality_status':'warning' if warnings else 'passed','blueprint_blocks':sum(len(json.loads(x.get('meta_json') or '{}').get('blocks',[])) for x in local_records)}
# Separate static quality from readiness for commercial hand-off.
from . import commercial_readiness
handoff=commercial_readiness.inspect(dist)
report['commercial_readiness']={'ready_for_review':handoff['ready_for_review'],
'commercially_certified':False,'blockers':len(handoff['blockers']),
'warnings':len(handoff['warnings'])}
(dist/'commercial-readiness.json').write_text(json.dumps(handoff,ensure_ascii=False,indent=2),encoding='utf-8')
(dist/'siteforge-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
archive_dir=db.DATA/'archives';archive_dir.mkdir(parents=True,exist_ok=True)
archive=archive_dir/(site_id+'-'+bid+'.zip')
with zipfile.ZipFile(archive,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=6) as z:
for file in dist.rglob('*'):
if file.is_file():z.write(file,file.relative_to(dist))
db.insert('INSERT INTO builds (id,site_id,created_at,status,total_pages,report_json,zip_path) VALUES (?,?,?,?,?,?,?)',
(bid,site_id,db.now(),'complete',totals,json.dumps(report,ensure_ascii=False),str(archive)))
return report