702253f387
Co-Authored-By: Claude Code <noreply@anthropic.com>
476 lines
30 KiB
Python
476 lines
30 KiB
Python
"""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 copy_blocks_css(theme_dir,dist_assets):
|
||
"""Theme-provided blocks.css wins; platform default is the fallback."""
|
||
candidate=theme_dir/'blocks.css'
|
||
src=candidate if candidate.is_file() else ROOT/'app'/'static'/'blocks.css'
|
||
shutil.copy2(src,dist_assets/'blocks.css')
|
||
|
||
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')
|
||
copy_blocks_css(theme_dir,dist/'assets')
|
||
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
|