"""
SEO Auditor — 外部网站 SEO 审计脚本
功能:
- 抓取目标网站首页和关键页面
- 分析 Meta 标签、标题结构、内容质量、性能、链接、移动端适配
- 生成评分报告(0-100 分/维度)
- 自动生成优化建议
- 结果写入 DB(SEOAudit + OptimizationTask)
用法:
python3 scripts/seo_auditor.py --product-id 1
python3 scripts/seo_auditor.py --url https://example.com --name "我的网站"
"""
import sys
import os
import re
import json
import logging
from datetime import datetime, timezone
from urllib.parse import urlparse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'platform', 'backend'))
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
def extract_meta(html):
"""分析 Meta 标签"""
score = 100
issues = []
title = re.search(r'
]*>(.*?)', html, re.I | re.S)
if not title:
score -= 30
issues.append({"category": "meta", "severity": "high", "issue": "缺少 标签", "recommendation": "添加包含目标关键词的 title 标签,建议 50-60 字符"})
else:
t = title.group(1).strip()
if len(t) < 10:
score -= 15
issues.append({"category": "meta", "severity": "medium", "issue": f"title 过短 ({len(t)}字符)", "recommendation": "title 建议 50-60 字符"})
if len(t) > 80:
score -= 10
issues.append({"category": "meta", "severity": "low", "issue": f"title 过长 ({len(t)}字符)", "recommendation": "title 超过 80 字符可能在搜索结果中被截断"})
desc = re.search(r']*content=["\']([^"\']*)["\']', html, re.I)
if not desc:
score -= 25
issues.append({"category": "meta", "severity": "high", "issue": "缺少 meta description", "recommendation": "添加包含关键词的 meta description,建议 120-160 字符"})
else:
d = desc.group(1).strip()
if len(d) < 50:
score -= 10
issues.append({"category": "meta", "severity": "medium", "issue": f"description 过短 ({len(d)}字符)", "recommendation": "description 建议 120-160 字符"})
keywords = re.search(r']*content=["\']([^"\']*)["\']', html, re.I)
if not keywords:
score -= 5
issues.append({"category": "meta", "severity": "low", "issue": "缺少 meta keywords", "recommendation": "添加 meta keywords(虽然不是排名因素,但用于内容相关性提示)"})
og_title = re.search(r']*content=["\']([^"\']*)["\']', html, re.I)
og_desc = re.search(r']*content=["\']([^"\']*)["\']', html, re.I)
og_image = re.search(r']*content=["\']([^"\']*)["\']', html, re.I)
if not og_title:
score -= 10
issues.append({"category": "meta", "severity": "medium", "issue": "缺少 og:title", "recommendation": "添加 Open Graph title 改善社交分享展示"})
if not og_desc:
score -= 5
issues.append({"category": "meta", "severity": "low", "issue": "缺少 og:description", "recommendation": "添加 og:description 改善社交分享展示"})
if not og_image:
score -= 5
issues.append({"category": "meta", "severity": "low", "issue": "缺少 og:image", "recommendation": "添加 og:image 让分享链接显示缩略图"})
return max(0, score), issues
def extract_headings(html):
"""分析标题结构"""
score = 100
issues = []
h1s = re.findall(r']*>(.*?)
', html, re.I | re.S)
if len(h1s) == 0:
score -= 30
issues.append({"category": "heading", "severity": "high", "issue": "页面缺少 H1 标签", "recommendation": "每个页面应当只有一个 H1,包含主要关键词"})
elif len(h1s) > 1:
score -= 15
issues.append({"category": "heading", "severity": "medium", "issue": f"存在 {len(h1s)} 个 H1 标签", "recommendation": "每个页面应当只有一个 H1 标签"})
else:
h1_text = re.sub(r'<[^>]+>', '', h1s[0]).strip()
if len(h1_text) < 5:
score -= 5
issues.append({"category": "heading", "severity": "low", "issue": "H1 内容过短", "recommendation": "H1 应清晰描述页面主题"})
h2s = re.findall(r']*>(.*?)
', html, re.I | re.S)
if len(h2s) == 0 and len(re.findall(r'<(h[2-6])', html, re.I)) > 0:
pass
elif len(h2s) == 0:
score -= 10
issues.append({"category": "heading", "severity": "medium", "issue": "缺少 H2 子标题", "recommendation": "使用 H2 组织内容结构,提升可读性和 SEO"})
# Check heading hierarchy
all_headings = re.findall(r']*>', html, re.I)
if all_headings:
prev_level = int(all_headings[0])
for level in all_headings[1:]:
l = int(level)
if l > prev_level + 1:
score -= 5
issues.append({"category": "heading", "severity": "medium", "issue": f"标题层级跳级:H{prev_level} → H{l}", "recommendation": "标题层级应连续,不要跳级"})
break
prev_level = l
return max(0, score), issues
def extract_content(html):
"""分析内容质量"""
score = 100
issues = []
text = re.sub(r'<[^>]+>', ' ', html)
text = re.sub(r'\s+', ' ', text).strip()
word_count = len(text)
if word_count < 300:
score -= 30
issues.append({"category": "content", "severity": "high", "issue": f"内容过短({word_count}字符)", "recommendation": "正文建议至少 300 字符,优质内容建议 1000+ 字符"})
elif word_count < 1000:
score -= 15
issues.append({"category": "content", "severity": "medium", "issue": f"内容偏短({word_count}字符)", "recommendation": "增加内容深度,建议达到 1000+ 字符"})
# Check image alt
imgs = re.findall(r'
]+>', html, re.I)
no_alt = sum(1 for img in imgs if 'alt=' not in img.lower())
if imgs and no_alt == len(imgs):
score -= 10
issues.append({"category": "content", "severity": "medium", "issue": "所有图片缺少 alt 属性", "recommendation": "为图片添加描述性的 alt 文本"})
elif no_alt > 0:
score -= 5
issues.append({"category": "content", "severity": "low", "issue": f"{no_alt}/{len(imgs)} 张图片缺少 alt", "recommendation": "为剩余图片补全 alt 属性"})
return max(0, score), issues, {"word_count": word_count, "image_count": len(imgs)}
def extract_links(html, base_url):
"""分析链接"""
score = 100
issues = []
domain = urlparse(base_url).netloc
internal_links = re.findall(r'href=["\'](https?://[^"\']+)["\']', html, re.I)
external_links = [url for url in internal_links if urlparse(url).netloc != domain]
internal_links = [url for url in internal_links if urlparse(url).netloc == domain]
broken_keywords = re.findall(r'href=["\']([^"\']*(?:404|broken|dead)[^"\']*)["\']', html, re.I)
if len(internal_links) == 0:
score -= 15
issues.append({"category": "links", "severity": "medium", "issue": "页面没有内部链接", "recommendation": "添加指向站内其他页面的链接,改善爬虫抓取和用户导航"})
if len(external_links) == 0:
pass
ext_no_nofollow = re.findall(r']*href=["\']https?://(?!' + re.escape(domain) + r')["\'][^>]*>', html, re.I)
if ext_no_nofollow:
score -= 5
issues.append({"category": "links", "severity": "low", "issue": "外部链接缺少 nofollow", "recommendation": "对外部链接添加 rel=\"nofollow noopener\" 属性"})
# Check for broken link patterns
broken = re.findall(r'href=["\'](?:https?://[^"\']*?(?:404|error|not-found)[^"\']*)["\']', html, re.I)
if broken:
score -= 10
issues.append({"category": "links", "severity": "high", "issue": f"发现 {len(broken)} 个疑似断链", "recommendation": "检查并修复或删除失效链接"})
return max(0, score), issues, {"internal": len(internal_links), "external": len(external_links)}
def extract_performance(html):
"""分析性能(基础版 — 仅分析可前端检测的指标)"""
score = 100
issues = []
# Check for render-blocking resources
css_links = re.findall(r']*href=["\'].*?\.css["\']', html, re.I)
js_scripts = re.findall(r'