"""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, multilingual 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 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 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'
{sub}

{title}

{escape(str(block.get("description","")))}

') elif typ=='features': features=block.get('items',[]) cards=''.join(f'
0{i+1}

{escape(str(x.get("title","")))}

{escape(str(x.get("text","")))}

' for i,x in enumerate(features[:12]) if isinstance(x,dict)) parts.append(f'

{title}

{cards}
') elif typ=='text': parts.append(f'

{title}

{body}
') elif typ=='stats': vals=''.join(f'
{escape(str(x.get("value","")))}{escape(str(x.get("label","")))}
' for x in block.get('items',[])[:8] if isinstance(x,dict)) parts.append(f'
{vals}
') elif typ=='cta': target=block.get('href','/contact/') try:target=link(route_path(target)) except ValueError:target='./' parts.append(f'

{title}

{sub}

{escape(str(block.get("button","Contact us")))}
') 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('无效的主题') multilingual.assert_build_safe(site_id) locale=multilingual.site_locale(site_id) language_alternates=multilingual.alternates(site_id) 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') shutil.copy2(ROOT/'app'/'static'/'auto31.js',dist/'assets'/'auto31.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'])', '1 else '', 'next':root_route+'page/'+str(number+1)+'/' if number1: 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='') missing_html=multilingual.inject_seo(missing_html,locale,[]) (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) # V8.31.1: only the opt-in automotive theme uses the single-request asset # bundles. Older pinned theme versions retain their exact original HTML and # resource order, so existing sites are not silently changed. if site['theme']=='automotive' and (theme_dir/'manifest.json').is_file() and json.loads((theme_dir/'manifest.json').read_text(encoding='utf-8')).get('version')=='1.4.1': # Preserve CSS cascade by concatenating precisely in template order. css_names=('theme.css','blocks.css','factory.css','pro-base.css','pro.css','commercial.css','custom.css') for resource in css_names: if not (dist/'assets'/resource).is_file(): raise ValueError('汽车资源缺失: '+resource) (dist/'assets'/'auto-compiled.css').write_text( '\n'.join((dist/'assets'/name).read_text(encoding='utf-8') for name in css_names),encoding='utf-8') js_names=('site-runtime.js','pro.js','auto31.js') for resource in js_names: if not (dist/'assets'/resource).is_file(): raise ValueError('汽车脚本资源缺失: '+resource) (dist/'assets'/'auto-compiled.js').write_text( '\n;\n'.join((dist/'assets'/name).read_text(encoding='utf-8') for name in js_names),encoding='utf-8') shutil.copy2(theme_dir/'auto-icons.svg',dist/'assets'/'auto-icons.svg') 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_html=multilingual.inject_seo(search_html,locale,[]) 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(''+xml_escape(public_url(site,r))+''+multilingual.sitemap_alternates(language_alternates.get(r,[]))+'' for r in canonical_routes) sitemap='\n'+urls+'' (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'],'locale':locale,'alternate_pages':len(language_alternates),'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