feat: 联盟返佣系统规范化并打通技能广场(无 ICP 证合规变现主路径)
- 新增 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>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user