Files
cms-SiteForge/app/quality.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

57 lines
3.4 KiB
Python

"""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]}