31cc1e02ce
- 新增 AffiliateProgram / AffiliateLink / AffiliateClick 规范化 Prisma 模型 取代原手写裸表 affiliate_stats / affiliate_clicks - 新增迁移 prisma/migrations/20260711000000_add_affiliate_models - 重构 affiliate.service.ts 改用 Prisma ORM,消除 $queryRawUnsafe SQL 注入 - 重构 affiliate.controller.ts 接口:programs / links(?skillId,?toolId) / stats / click - 前端 affiliate 页接入真实接口,移除硬编码 demo 数据 - 技能详情页新增「学此技能推荐使用的工具」联盟链接区块 - 新增 affiliate.service.spec.ts(4 用例通过)与幂等种子 seed-affiliate.ts - 更新 docs/progress/current.md,明确无 ICP 经营许可证下以联盟返佣为合规变现主路径 - 含此前工作区未提交改动(工具 slug 路由、支付/订单、SEO 等) Co-Authored-By: opencode <opencode@anthropic.com>
283 lines
11 KiB
Python
283 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ai-learning-platform (yuzhiran.com) 浏览器自动化测试
|
|
测试 SEO 优化、页面加载、功能完整性
|
|
"""
|
|
|
|
import subprocess
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
from urllib.request import urlopen, Request
|
|
from urllib.error import HTTPError, URLError
|
|
from html.parser import HTMLParser
|
|
|
|
BASE_URL = "https://yuzhiran.com"
|
|
results = []
|
|
|
|
def run_test(name, test_func):
|
|
"""运行单个测试"""
|
|
try:
|
|
test_func()
|
|
results.append({"name": name, "status": "PASS", "error": None})
|
|
print(f" ✅ {name}")
|
|
except AssertionError as e:
|
|
results.append({"name": name, "status": "FAIL", "error": str(e)})
|
|
print(f" ❌ {name}: {e}")
|
|
except Exception as e:
|
|
results.append({"name": name, "status": "ERROR", "error": str(e)})
|
|
print(f" ⚠️ {name}: {e}")
|
|
|
|
def fetch_page(path, timeout=15):
|
|
"""获取页面内容"""
|
|
url = f"{BASE_URL}{path}"
|
|
req = Request(url, headers={
|
|
"User-Agent": "Mozilla/5.0 (compatible; Hermes-Test/1.0)",
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
})
|
|
resp = urlopen(req, timeout=timeout)
|
|
return resp.read().decode('utf-8', errors='replace'), resp
|
|
|
|
def check_meta_tags(html, path="/"):
|
|
"""检查 meta 标签"""
|
|
# Title
|
|
title_match = re.search(r'<title[^>]*>(.*?)</title>', html, re.DOTALL | re.IGNORECASE)
|
|
assert title_match, f"Missing <title> tag on {path}"
|
|
title = title_match.group(1).strip()
|
|
assert len(title) > 5, f"Title too short: '{title}' on {path}"
|
|
|
|
# Description
|
|
desc_match = re.search(r'<meta[^>]+name=["\']description["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
|
|
assert desc_match, f"Missing description meta tag on {path}"
|
|
desc = desc_match.group(1).strip()
|
|
assert len(desc) > 20, f"Description too short: '{desc[:50]}' on {path}"
|
|
|
|
# Keywords
|
|
kw_match = re.search(r'<meta[^>]+name=["\']keywords["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
|
|
assert kw_match, f"Missing keywords meta tag on {path}"
|
|
|
|
# Viewport
|
|
vp_match = re.search(r'<meta[^>]+name=["\']viewport["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
|
|
assert vp_match, f"Missing viewport meta tag on {path}"
|
|
vp = vp_match.group(1).strip()
|
|
assert "width=device-width" in vp.lower(), f"Viewport missing width=device-width on {path}"
|
|
|
|
# Canonical
|
|
canon_match = re.search(r'<link[^>]+rel=["\']canonical["\'][^>]+href=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
|
|
assert canon_match, f"Missing canonical link tag on {path}"
|
|
|
|
# Open Graph
|
|
og_match = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
|
|
assert og_match, f"Missing og:title meta tag on {path}"
|
|
|
|
# Theme color
|
|
tc_match = re.search(r'<meta[^>]+name=["\']theme-color["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
|
|
assert tc_match, f"Missing theme-color meta tag on {path}"
|
|
|
|
def check_structured_data(html, path="/"):
|
|
"""检查结构化数据 (JSON-LD)"""
|
|
ld_match = re.search(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.DOTALL | re.IGNORECASE)
|
|
assert ld_match, f"Missing JSON-LD structured data on {path}"
|
|
ld_content = ld_match.group(1).strip()
|
|
try:
|
|
data = json.loads(ld_content)
|
|
assert '@context' in data, f"JSON-LD missing @context on {path}"
|
|
assert '@type' in data, f"JSON-LD missing @type on {path}"
|
|
except json.JSONDecodeError:
|
|
assert False, f"Invalid JSON-LD on {path}"
|
|
|
|
def check_performance(html, path="/"):
|
|
"""检查性能相关优化"""
|
|
# Check for inline scripts (should be minimal)
|
|
inline_scripts = re.findall(r'<script[^>]*>(?!.*?</script>)([^<]{100,})', html, re.DOTALL | re.IGNORECASE)
|
|
# Large inline scripts are bad for performance
|
|
large_scripts = [s for s in inline_scripts if len(s) > 5000]
|
|
assert len(large_scripts) <= 2, f"Too many large inline scripts ({len(large_scripts)}) on {path}"
|
|
|
|
# Check for preconnect hints
|
|
preconnect = re.findall(r'<link[^>]+rel=["\']preconnect["\']', html, re.IGNORECASE)
|
|
# Not required but good to have
|
|
print(f" Preconnect hints: {len(preconnect)}")
|
|
|
|
def check_semantic_html(html, path="/"):
|
|
"""检查语义化 HTML"""
|
|
# Should have proper heading structure
|
|
h1_count = len(re.findall(r'<h1[^>]*>', html, re.IGNORECASE))
|
|
assert h1_count >= 1, f"No <h1> tag on {path}"
|
|
|
|
# Should have main element or equivalent
|
|
main_match = re.search(r'<main[^>]*>', html, re.IGNORECASE)
|
|
nav_match = re.search(r'<nav[^>]*>', html, re.IGNORECASE)
|
|
assert main_match or nav_match, f"No <main> or <nav> element on {path}"
|
|
|
|
def check_image_optimization(html, path="/"):
|
|
"""检查图片优化"""
|
|
# Check for alt attributes on images
|
|
images = re.findall(r'<img[^>]*>', html, re.IGNORECASE)
|
|
for img in images[:10]: # Check first 10 images
|
|
alt_match = re.search(r'alt=["\'](.*?)["\']', img, re.IGNORECASE)
|
|
if not alt_match:
|
|
assert False, f"Image missing alt attribute on {path}"
|
|
|
|
def check_link_validity(html, path="/"):
|
|
"""检查链接有效性"""
|
|
links = re.findall(r'<a[^>]+href=["\'](.*?)["\'][^>]*>', html, re.IGNORECASE)
|
|
# Check for broken internal links
|
|
broken = []
|
|
for link in links[:20]: # Check first 20 links
|
|
if link.startswith('#') or link.startswith('mailto:') or link.startswith('tel:'):
|
|
continue
|
|
if link.startswith('http'):
|
|
continue
|
|
try:
|
|
req = Request(f"{BASE_URL}{link}", headers={"User-Agent": "Hermes-Test/1.0"})
|
|
resp = urlopen(req, timeout=5)
|
|
if resp.status >= 400:
|
|
broken.append(link)
|
|
except:
|
|
pass
|
|
assert len(broken) == 0, f"Broken links found: {broken}"
|
|
|
|
# ==================== TESTS ====================
|
|
|
|
print("=" * 60)
|
|
print("🧪 ai-learning-platform (yuzhiran.com) 浏览器自动化测试")
|
|
print("=" * 60)
|
|
|
|
# 1. 首页 SEO
|
|
print("\n📄 首页 SEO 测试")
|
|
run_test("首页 SEO 优化", lambda: check_meta_tags(*fetch_page("/")))
|
|
run_test("首页结构化数据", lambda: check_structured_data(*fetch_page("/")))
|
|
run_test("首页性能检查", lambda: check_performance(*fetch_page("/")))
|
|
run_test("首页语义化 HTML", lambda: check_semantic_html(*fetch_page("/")))
|
|
run_test("首页图片优化", lambda: check_image_optimization(*fetch_page("/")))
|
|
run_test("首页链接有效性", lambda: check_link_validity(*fetch_page("/")))
|
|
|
|
# 2. 重要页面 SEO
|
|
print("\n📄 重要页面 SEO 测试")
|
|
important_pages = ["/tools", "/courses", "/skills", "/prompts", "/practices", "/sandbox", "/about"]
|
|
for page in important_pages:
|
|
run_test(f"{page} SEO 优化", lambda p=page: check_meta_tags(*fetch_page(p)))
|
|
run_test(f"{page} 结构化数据", lambda p=page: check_structured_data(*fetch_page(p)))
|
|
|
|
# 3. robots.txt
|
|
print("\n🤖 robots.txt 测试")
|
|
def test_robots():
|
|
html, resp = fetch_page("/robots.txt")
|
|
assert "User-agent" in html, "robots.txt missing User-agent"
|
|
assert "Disallow" in html, "robots.txt missing Disallow rules"
|
|
assert "Sitemap" in html, "robots.txt missing Sitemap"
|
|
assert "/admin/" in html, "robots.txt should disallow /admin/"
|
|
assert "/my/" in html, "robots.txt should disallow /my/"
|
|
test_robots()
|
|
|
|
# 4. sitemap.xml
|
|
print("\n🗺️ sitemap.xml 测试")
|
|
def test_sitemap():
|
|
html, resp = fetch_page("/sitemap.xml")
|
|
assert "<urlset" in html, "Invalid sitemap format"
|
|
assert "<loc>" in html, "Sitemap missing <loc> entries"
|
|
url_count = len(re.findall(r'<loc>', html))
|
|
assert url_count > 10, f"Sitemap has only {url_count} URLs"
|
|
test_sitemap()
|
|
|
|
# 5. PWA manifest
|
|
print("\n📱 PWA manifest 测试")
|
|
def test_manifest():
|
|
html, resp = fetch_page("/site.webmanifest")
|
|
data = json.loads(html)
|
|
assert "name" in data, "Manifest missing name"
|
|
assert "start_url" in data, "Manifest missing start_url"
|
|
assert "icons" in data, "Manifest missing icons"
|
|
assert "theme_color" in data, "Manifest missing theme_color"
|
|
assert "shortcuts" in data, "Manifest missing shortcuts"
|
|
test_manifest()
|
|
|
|
# 6. 页面加载性能
|
|
print("\n⚡ 页面加载性能测试")
|
|
def test_homepage_load():
|
|
start = time.time()
|
|
html, resp = fetch_page("/")
|
|
load_time = time.time() - start
|
|
assert load_time < 5, f"Homepage took {load_time:.2f}s to load"
|
|
print(f" Load time: {load_time:.2f}s")
|
|
test_homepage_load()
|
|
|
|
def test_tools_load():
|
|
start = time.time()
|
|
html, resp = fetch_page("/tools")
|
|
load_time = time.time() - start
|
|
assert load_time < 5, f"/tools took {load_time:.2f}s to load"
|
|
print(f" Load time: {load_time:.2f}s")
|
|
test_tools_load()
|
|
|
|
# 7. 移动端适配
|
|
print("\n📱 移动端适配测试")
|
|
def test_mobile_viewport():
|
|
html, resp = fetch_page("/")
|
|
vp_match = re.search(r'<meta[^>]+name=["\']viewport["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
|
|
assert vp_match, "Missing viewport meta tag"
|
|
vp = vp_match.group(1).strip()
|
|
assert "initial-scale=1" in vp.lower(), "Viewport missing initial-scale"
|
|
test_mobile_viewport()
|
|
|
|
# 8. 暗黑模式支持
|
|
print("\n🌙 暗黑模式测试")
|
|
def test_dark_mode():
|
|
html, resp = fetch_page("/")
|
|
# Check for dark mode CSS variables or class
|
|
dark_match = re.search(r'dark:', html)
|
|
assert dark_match, "No dark mode CSS found"
|
|
test_dark_mode()
|
|
|
|
# 9. 国际化支持
|
|
print("\n🌐 国际化测试")
|
|
def test_i18n():
|
|
html, resp = fetch_page("/")
|
|
# Check for lang attribute
|
|
lang_match = re.search(r'<html[^>]+lang=["\']zh-CN["\']', html, re.IGNORECASE)
|
|
assert lang_match, "Missing lang='zh-CN' attribute"
|
|
test_i18n()
|
|
|
|
# 10. 安全头检查
|
|
print("\n🔒 安全头检查")
|
|
def test_security_headers():
|
|
html, resp = fetch_page("/")
|
|
# Check if HTTPS
|
|
assert BASE_URL.startswith("https"), "Not using HTTPS"
|
|
# Check for CSP (optional but good)
|
|
# Check for HSTS (optional)
|
|
print(" HTTPS: ✅")
|
|
test_security_headers()
|
|
|
|
# ==================== SUMMARY ====================
|
|
print("\n" + "=" * 60)
|
|
print("📊 测试总结")
|
|
print("=" * 60)
|
|
|
|
passed = sum(1 for r in results if r["status"] == "PASS")
|
|
failed = sum(1 for r in results if r["status"] == "FAIL")
|
|
errors = sum(1 for r in results if r["status"] == "ERROR")
|
|
total = len(results)
|
|
|
|
print(f"\n总计: {total} 项测试")
|
|
print(f" ✅ 通过: {passed}")
|
|
print(f" ❌ 失败: {failed}")
|
|
print(f" ⚠️ 错误: {errors}")
|
|
print(f" 📈 通过率: {passed/total*100:.1f}%")
|
|
|
|
if failed > 0:
|
|
print("\n❌ 失败的测试:")
|
|
for r in results:
|
|
if r["status"] == "FAIL":
|
|
print(f" - {r['name']}: {r['error']}")
|
|
|
|
if errors > 0:
|
|
print("\n⚠️ 出错的测试:")
|
|
for r in results:
|
|
if r["status"] == "ERROR":
|
|
print(f" - {r['name']}: {r['error']}")
|
|
|
|
sys.exit(0 if failed == 0 and errors == 0 else 1) |