3c57c167f2
导入 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>
57 lines
3.4 KiB
Python
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]}
|