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