#!/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 "" 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)