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>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""Offline static-build quality gate. No external network requests or speculative SEO scores."""
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit,unquote
|
||||
from bs4 import BeautifulSoup
|
||||
from . import db
|
||||
|
||||
def report(site_id, build_id):
|
||||
root=(db.DATA/'builds'/site_id/build_id/'dist').resolve()
|
||||
if not root.is_dir():raise FileNotFoundError('构建版本不存在')
|
||||
files=list(root.rglob('*.html'))
|
||||
site=db.one('SELECT domain FROM sites WHERE id=?',(site_id,)) or {}
|
||||
hostname=urlsplit(site.get('domain') or '').hostname
|
||||
issues=[];seen_titles={};seen_descriptions={}
|
||||
for p in files:
|
||||
rel=p.relative_to(root).as_posix()
|
||||
soup=BeautifulSoup(p.read_text(encoding='utf-8',errors='replace'),'html.parser')
|
||||
title=(soup.title.get_text(' ',strip=True) if soup.title else '')
|
||||
h1=soup.find_all('h1')
|
||||
if not title:issues.append({'file':rel,'type':'missing_title','severity':'error'})
|
||||
if len(h1)!=1:issues.append({'file':rel,'type':'h1_count','severity':'warning','count':len(h1)})
|
||||
if title and title in seen_titles and p.name!='404.html':issues.append({'file':rel,'type':'duplicate_title','severity':'warning','other':seen_titles[title]})
|
||||
seen_titles[title]=rel
|
||||
description=soup.find('meta',attrs={'name':'description'})
|
||||
desc=description.get('content','').strip() if description else ''
|
||||
if not desc:issues.append({'file':rel,'type':'missing_description','severity':'warning'})
|
||||
if desc and desc in seen_descriptions:issues.append({'file':rel,'type':'duplicate_description','severity':'warning','other':seen_descriptions[desc]})
|
||||
seen_descriptions[desc]=rel
|
||||
for img in soup.find_all('img'):
|
||||
if not img.get('alt'):issues.append({'file':rel,'type':'missing_image_alt','severity':'warning','src':img.get('src','')[:180]})
|
||||
for node in soup.find_all(['a','img','script','link']):
|
||||
attr='href' if node.name in ('a','link') else 'src'
|
||||
target=node.get(attr)
|
||||
if not target:continue
|
||||
parsed=urlsplit(target)
|
||||
if parsed.scheme in ('http','https'):
|
||||
if parsed.hostname!=hostname:continue
|
||||
path=parsed.path
|
||||
elif parsed.scheme or target.startswith('//'):continue
|
||||
elif target.startswith('#'):continue
|
||||
else:path=parsed.path
|
||||
if not path:continue
|
||||
if node.name=='link' and node.get('rel') and 'canonical' in node.get('rel'):continue
|
||||
from_root=path.startswith('/')
|
||||
# HTML uses <base href="/">, which makes relative paths site-root relative.
|
||||
base=soup.find('base',href=True)
|
||||
if base and base.get('href')=='/':from_root=True
|
||||
raw=unquote(path.lstrip('/'))
|
||||
if not from_root:raw=(p.relative_to(root).parent/raw).as_posix()
|
||||
test=(root/raw)
|
||||
if target.endswith('/') or not Path(raw).suffix:test=test/'index.html'
|
||||
if not test.resolve().is_relative_to(root) or not test.is_file():
|
||||
if node.name=='link' and ('alternate' in (node.get('rel') or [])):continue
|
||||
issues.append({'file':rel,'type':'broken_internal_reference','severity':'error','target':target[:220]})
|
||||
return {'files':len(files),'errors':sum(i['severity']=='error' for i in issues),
|
||||
'warnings':sum(i['severity']=='warning' for i in issues),
|
||||
'passed':not any(i['severity']=='error' for i in issues),'issues':issues[:500]}
|
||||
Reference in New Issue
Block a user