feat: SEO 优化与浏览器自动化测试
- 为 admin-frontend、user-frontend、uni-app 添加完整 SEO meta 标签 - 添加结构化数据 (JSON-LD) 提升搜索引擎理解 - 创建 robots.txt 和 sitemap.xml 文件 - 优化移动端 viewport、PWA 支持、theme-color - 添加 Open Graph 和 Twitter Card 元标签 - 创建浏览器自动化测试脚本 (11 项测试全部通过) - 修复 Nginx charset 配置解决编码问题
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TradeMate 前端 SEO 与浏览器自动化测试脚本
|
||||
使用 requests + BeautifulSoup 进行 SEO 审计
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
try:
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
HAS_REQUESTS = True
|
||||
except ImportError:
|
||||
HAS_REQUESTS = False
|
||||
print("⚠️ requests/bs4 未安装,跳过网络测试")
|
||||
|
||||
BASE_URL = "https://trade.yuzhiran.com"
|
||||
TEST_RESULTS = []
|
||||
|
||||
def run_test(name: str, test_func):
|
||||
"""运行单个测试并记录结果"""
|
||||
try:
|
||||
result = test_func()
|
||||
TEST_RESULTS.append({"name": name, "status": "PASS", "detail": result})
|
||||
print(f"✅ {name}")
|
||||
except Exception as e:
|
||||
TEST_RESULTS.append({"name": name, "status": "FAIL", "detail": str(e)})
|
||||
print(f"❌ {name}: {e}")
|
||||
|
||||
def get_page(url: str, timeout: int = 15) -> tuple:
|
||||
"""获取页面内容"""
|
||||
if not HAS_REQUESTS:
|
||||
raise RuntimeError("requests not available")
|
||||
response = requests.get(url, timeout=timeout, headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
})
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
def parse_html(html: str) -> BeautifulSoup:
|
||||
"""解析 HTML"""
|
||||
return BeautifulSoup(html, 'html.parser', from_encoding='utf-8')
|
||||
|
||||
def test_homepage_seo():
|
||||
"""测试首页 SEO 优化"""
|
||||
response = get_page(BASE_URL)
|
||||
soup = parse_html(response.text)
|
||||
|
||||
# 检查 title
|
||||
title = soup.title.string if soup.title else ""
|
||||
assert "TradeMate" in title, f"Title should contain TradeMate, got: {title}"
|
||||
|
||||
# 检查 meta description
|
||||
desc_tag = soup.find('meta', attrs={'name': 'description'})
|
||||
desc = desc_tag.get('content', '') if desc_tag else ""
|
||||
assert len(desc) > 50, f"Meta description too short: {desc}"
|
||||
|
||||
# 检查 viewport
|
||||
viewport_tag = soup.find('meta', attrs={'name': 'viewport'})
|
||||
viewport = viewport_tag.get('content', '') if viewport_tag else ""
|
||||
assert "width=device-width" in viewport, f"Viewport missing: {viewport}"
|
||||
|
||||
# 检查 canonical
|
||||
canonical_tag = soup.find('link', attrs={'rel': 'canonical'})
|
||||
canonical = canonical_tag.get('href', '') if canonical_tag else ""
|
||||
assert canonical == BASE_URL + "/", f"Canonical incorrect: {canonical}"
|
||||
|
||||
# 检查 Open Graph
|
||||
og_title_tag = soup.find('meta', attrs={'property': 'og:title'})
|
||||
og_title = og_title_tag.get('content', '') if og_title_tag else ""
|
||||
assert "TradeMate" in og_title, f"OG title missing: {og_title}"
|
||||
|
||||
# 检查结构化数据
|
||||
ld_scripts = soup.find_all('script', attrs={'type': 'application/ld+json'})
|
||||
assert len(ld_scripts) > 0, "No structured data found"
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"description": desc[:100] + "...",
|
||||
"canonical": canonical,
|
||||
"og_title": og_title,
|
||||
"structured_data_count": len(ld_scripts)
|
||||
}
|
||||
|
||||
def test_homepage_performance():
|
||||
"""测试首页性能(简化版)"""
|
||||
response = get_page(BASE_URL)
|
||||
|
||||
# 检查页面大小
|
||||
html_size = len(response.text.encode('utf-8'))
|
||||
assert html_size < 500000, f"HTML too large: {html_size} bytes"
|
||||
|
||||
# 检查 HTTP 状态
|
||||
assert response.status_code == 200, f"Status code: {response.status_code}"
|
||||
|
||||
# 检查压缩
|
||||
content_encoding = response.headers.get('Content-Encoding', '')
|
||||
|
||||
return {
|
||||
"html_size": html_size,
|
||||
"status_code": response.status_code,
|
||||
"content_encoding": content_encoding,
|
||||
"load_time_ms": response.elapsed.total_seconds() * 1000
|
||||
}
|
||||
|
||||
def test_workspace_seo():
|
||||
"""测试工作台 SEO"""
|
||||
response = get_page(BASE_URL + "/workspace/")
|
||||
soup = parse_html(response.text)
|
||||
|
||||
title = soup.title.string if soup.title else ""
|
||||
assert "工作台" in title or "TradeMate" in title, f"Workspace title incorrect: {title}"
|
||||
|
||||
desc_tag = soup.find('meta', attrs={'name': 'description'})
|
||||
desc = desc_tag.get('content', '') if desc_tag else ""
|
||||
assert len(desc) > 30, f"Workspace description too short: {desc}"
|
||||
|
||||
# 检查 robots
|
||||
robots_tag = soup.find('meta', attrs={'name': 'robots'})
|
||||
robots = robots_tag.get('content', '') if robots_tag else ""
|
||||
assert "noindex" in robots, f"Workspace should have noindex: {robots}"
|
||||
|
||||
return {"title": title, "description": desc[:100] + "...", "robots": robots}
|
||||
|
||||
def test_admin_seo():
|
||||
"""测试管理后台 SEO"""
|
||||
response = get_page(BASE_URL + "/admin/")
|
||||
soup = parse_html(response.text)
|
||||
|
||||
title = soup.title.string if soup.title else ""
|
||||
assert "管理后台" in title or "TradeMate" in title, f"Admin title incorrect: {title}"
|
||||
|
||||
# 检查 robots
|
||||
robots_tag = soup.find('meta', attrs={'name': 'robots'})
|
||||
robots = robots_tag.get('content', '') if robots_tag else ""
|
||||
assert "noindex" in robots, f"Admin should have noindex: {robots}"
|
||||
|
||||
return {"title": title, "robots": robots}
|
||||
|
||||
def test_app_seo():
|
||||
"""测试移动端 App SEO"""
|
||||
response = get_page(BASE_URL + "/app/")
|
||||
soup = parse_html(response.text)
|
||||
|
||||
title = soup.title.string if soup.title else ""
|
||||
# Accept both full title and short title
|
||||
assert "TradeMate" in title or "外贸" in title or "小助手" in title, f"App title incorrect: {title}"
|
||||
|
||||
desc_tag = soup.find('meta', attrs={'name': 'description'})
|
||||
desc = desc_tag.get('content', '') if desc_tag else ""
|
||||
assert len(desc) > 30, f"App description too short: {desc}"
|
||||
|
||||
# 检查 PWA meta
|
||||
apple_capable_tag = soup.find('meta', attrs={'name': 'apple-mobile-web-app-capable'})
|
||||
apple_capable = apple_capable_tag.get('content', '') if apple_capable_tag else ""
|
||||
assert apple_capable == "yes", f"Apple web app capable missing: {apple_capable}"
|
||||
|
||||
# 检查 theme-color
|
||||
theme_color_tag = soup.find('meta', attrs={'name': 'theme-color'})
|
||||
theme_color = theme_color_tag.get('content', '') if theme_color_tag else ""
|
||||
assert theme_color, f"Theme color missing: {theme_color}"
|
||||
|
||||
return {"title": title, "description": desc[:100] + "...", "apple_capable": apple_capable, "theme_color": theme_color}
|
||||
|
||||
def test_robots_txt():
|
||||
"""测试 robots.txt"""
|
||||
response = get_page(BASE_URL + "/robots.txt")
|
||||
assert response.status_code == 200, f"robots.txt returned {response.status_code}"
|
||||
|
||||
content = response.text
|
||||
assert "User-agent" in content, "robots.txt missing User-agent"
|
||||
assert "Disallow" in content, "robots.txt missing Disallow"
|
||||
assert "Sitemap" in content, "robots.txt missing Sitemap"
|
||||
|
||||
return {"status": response.status_code, "has_user_agent": True, "has_disallow": True, "has_sitemap": True}
|
||||
|
||||
def test_sitemap_xml():
|
||||
"""测试 sitemap.xml"""
|
||||
response = get_page(BASE_URL + "/sitemap.xml")
|
||||
assert response.status_code == 200, f"sitemap.xml returned {response.status_code}"
|
||||
|
||||
content = response.text
|
||||
assert "<urlset" in content, "sitemap.xml missing urlset"
|
||||
assert "<url>" in content, "sitemap.xml missing url entries"
|
||||
assert BASE_URL in content, "sitemap.xml missing base URL"
|
||||
|
||||
return {"status": response.status_code, "has_urlset": True, "has_urls": True}
|
||||
|
||||
def test_image_optimization():
|
||||
"""测试图片优化"""
|
||||
response = get_page(BASE_URL)
|
||||
soup = parse_html(response.text)
|
||||
|
||||
images = soup.find_all('img')
|
||||
results = []
|
||||
|
||||
for img in images:
|
||||
src = img.get('src', '')
|
||||
alt = img.get('alt', '')
|
||||
width = img.get('width')
|
||||
height = img.get('height')
|
||||
|
||||
result = {"src": src, "has_alt": bool(alt), "has_dimensions": bool(width or height)}
|
||||
if src:
|
||||
result["is_webp"] = src.endswith('.webp')
|
||||
result["is_lazy"] = img.get('loading') == 'lazy'
|
||||
results.append(result)
|
||||
|
||||
# 检查是否有图片缺少 alt
|
||||
missing_alt = [r for r in results if not r.get('has_alt')]
|
||||
|
||||
return {"total_images": len(results), "missing_alt": len(missing_alt), "images": results[:5]}
|
||||
|
||||
def test_links():
|
||||
"""测试链接有效性"""
|
||||
response = get_page(BASE_URL)
|
||||
soup = parse_html(response.text)
|
||||
|
||||
links = soup.find_all('a', href=True)
|
||||
broken_links = []
|
||||
|
||||
for link in links:
|
||||
href = link['href']
|
||||
if href.startswith('#') or href.startswith('mailto:'):
|
||||
continue
|
||||
|
||||
full_url = urljoin(BASE_URL, href)
|
||||
try:
|
||||
link_response = requests.get(full_url, timeout=5, allow_redirects=True)
|
||||
if link_response.status_code >= 400:
|
||||
broken_links.append({"href": href, "status": link_response.status_code})
|
||||
except:
|
||||
broken_links.append({"href": href, "status": "timeout"})
|
||||
|
||||
return {"total_links": len(links), "broken_links": broken_links}
|
||||
|
||||
def test_semantic_html():
|
||||
"""测试语义化 HTML"""
|
||||
response = get_page(BASE_URL)
|
||||
soup = parse_html(response.text)
|
||||
|
||||
# 检查语义化标签
|
||||
semantic_tags = ['header', 'nav', 'main', 'section', 'article', 'aside', 'footer']
|
||||
found_tags = []
|
||||
|
||||
for tag in semantic_tags:
|
||||
if soup.find(tag):
|
||||
found_tags.append(tag)
|
||||
|
||||
# 检查 heading 层级
|
||||
headings = soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
||||
h1_count = len(soup.find_all('h1'))
|
||||
|
||||
return {
|
||||
"semantic_tags_found": found_tags,
|
||||
"heading_count": len(headings),
|
||||
"h1_count": h1_count,
|
||||
"has_main": 'main' in found_tags
|
||||
}
|
||||
|
||||
def test_mobile_meta():
|
||||
"""测试移动端 meta 标签"""
|
||||
response = get_page(BASE_URL + "/app/")
|
||||
soup = parse_html(response.text)
|
||||
|
||||
viewport_tag = soup.find('meta', attrs={'name': 'viewport'})
|
||||
viewport = viewport_tag.get('content', '') if viewport_tag else ""
|
||||
|
||||
# 检查移动端相关 meta
|
||||
mobile_meta = {
|
||||
"viewport": viewport,
|
||||
"apple_capable": soup.find('meta', attrs={'name': 'apple-mobile-web-app-capable'}) is not None,
|
||||
"apple_status_bar": soup.find('meta', attrs={'name': 'apple-mobile-web-app-status-bar-style'}) is not None,
|
||||
"mobile_capable": soup.find('meta', attrs={'name': 'mobile-web-app-capable'}) is not None,
|
||||
"theme_color": soup.find('meta', attrs={'name': 'theme-color'}) is not None
|
||||
}
|
||||
|
||||
return mobile_meta
|
||||
|
||||
def main():
|
||||
"""主测试函数"""
|
||||
print("=" * 60)
|
||||
print("TradeMate 前端 SEO 与浏览器自动化测试")
|
||||
print("=" * 60)
|
||||
|
||||
if not HAS_REQUESTS:
|
||||
print("\n❌ requests/bs4 未安装,无法运行测试")
|
||||
print(" 请运行: pip install requests beautifulsoup4")
|
||||
return 1
|
||||
|
||||
# 首页测试
|
||||
print("\n📄 首页测试")
|
||||
run_test("首页 SEO 优化", test_homepage_seo)
|
||||
run_test("首页性能", test_homepage_performance)
|
||||
|
||||
# 子页面测试
|
||||
print("\n📊 子页面测试")
|
||||
run_test("工作台 SEO", test_workspace_seo)
|
||||
run_test("管理后台 SEO", test_admin_seo)
|
||||
run_test("移动端 App SEO", test_app_seo)
|
||||
|
||||
# SEO 文件测试
|
||||
print("\n🔍 SEO 文件测试")
|
||||
run_test("robots.txt", test_robots_txt)
|
||||
run_test("sitemap.xml", test_sitemap_xml)
|
||||
|
||||
# 其他测试
|
||||
print("\n🖼️ 其他测试")
|
||||
run_test("图片优化", test_image_optimization)
|
||||
run_test("链接有效性", test_links)
|
||||
run_test("语义化 HTML", test_semantic_html)
|
||||
run_test("移动端 meta", test_mobile_meta)
|
||||
|
||||
# 输出结果
|
||||
print("\n" + "=" * 60)
|
||||
print("测试结果汇总")
|
||||
print("=" * 60)
|
||||
|
||||
passed = sum(1 for r in TEST_RESULTS if r["status"] == "PASS")
|
||||
failed = sum(1 for r in TEST_RESULTS if r["status"] == "FAIL")
|
||||
|
||||
for result in TEST_RESULTS:
|
||||
status_icon = "✅" if result["status"] == "PASS" else "❌"
|
||||
detail = result['detail']
|
||||
if isinstance(detail, dict):
|
||||
detail = json.dumps(detail, ensure_ascii=False, indent=2)
|
||||
print(f"{status_icon} {result['name']}")
|
||||
if result['status'] == 'FAIL':
|
||||
print(f" → {detail}")
|
||||
|
||||
print(f"\n总计: {len(TEST_RESULTS)} 个测试, {passed} 通过, {failed} 失败")
|
||||
|
||||
# 保存结果到 JSON
|
||||
output_path = Path(__file__).parent / "test_results.json"
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(TEST_RESULTS, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n结果已保存到: {output_path}")
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = main()
|
||||
sys.exit(exit_code)
|
||||
Reference in New Issue
Block a user