test_lp_analyzer.py
1 """LP analyzer テスト 2 3 HTML解析ロジックのテスト。外部HTTPなしでテスト可能(HTMLをモックデータで渡す)。 4 """ 5 6 from __future__ import annotations 7 8 import pytest 9 10 from mureo.analysis.lp_analyzer import ( 11 LPAnalyzer, 12 LPContent, 13 _BLOCKED_HOSTS, 14 _INDUSTRY_KEYWORDS, 15 ) 16 17 18 # --------------------------------------------------------------------------- 19 # テスト用HTML 20 # --------------------------------------------------------------------------- 21 22 _SAMPLE_HTML = """\ 23 <!DOCTYPE html> 24 <html> 25 <head> 26 <title>テスト商品 | テストブランド</title> 27 <meta name="description" content="テスト商品の説明文です。高品質な商品をお届けします。"> 28 <meta property="og:title" content="OGテスト商品"> 29 <meta property="og:description" content="OG説明文です"> 30 <meta property="og:site_name" content="テストブランド"> 31 <script type="application/ld+json"> 32 { 33 "@context": "https://schema.org", 34 "@type": "Product", 35 "name": "テスト商品" 36 } 37 </script> 38 </head> 39 <body> 40 <header>ヘッダー</header> 41 <h1>メイン見出し</h1> 42 <h2>サブ見出し1</h2> 43 <h2>サブ見出し2</h2> 44 <p>本文テキストです。商品の特徴を説明します。価格は¥10,000です。</p> 45 <ul> 46 <li>特徴1: 高品質な素材</li> 47 <li>特徴2: 送料無料</li> 48 <li>特徴3: 30日間返品保証</li> 49 <li>短い</li> 50 </ul> 51 <button>今すぐ購入</button> 52 <input type="submit" value="申し込み"> 53 <a class="btn-primary" href="/order">注文する</a> 54 <footer>フッター</footer> 55 <script>console.log('除外対象');</script> 56 <style>.excluded { display: none; }</style> 57 </body> 58 </html> 59 """ 60 61 _MINIMAL_HTML = """\ 62 <!DOCTYPE html> 63 <html><head><title>最小ページ</title></head> 64 <body><p>コンテンツ</p></body></html> 65 """ 66 67 68 @pytest.fixture 69 def analyzer() -> LPAnalyzer: 70 return LPAnalyzer() 71 72 73 # --------------------------------------------------------------------------- 74 # _parse_html テスト 75 # --------------------------------------------------------------------------- 76 77 78 @pytest.mark.unit 79 class TestParseHtml: 80 def test_タイトル抽出(self, analyzer: LPAnalyzer) -> None: 81 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 82 83 assert result.title == "テスト商品 | テストブランド" 84 85 def test_メタディスクリプション抽出(self, analyzer: LPAnalyzer) -> None: 86 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 87 88 assert "テスト商品の説明文" in result.meta_description 89 90 def test_h1抽出(self, analyzer: LPAnalyzer) -> None: 91 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 92 93 assert result.h1_texts == ("メイン見出し",) 94 95 def test_h2抽出(self, analyzer: LPAnalyzer) -> None: 96 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 97 98 assert result.h2_texts == ("サブ見出し1", "サブ見出し2") 99 100 def test_CTA抽出(self, analyzer: LPAnalyzer) -> None: 101 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 102 103 assert "今すぐ購入" in result.cta_texts 104 assert "申し込み" in result.cta_texts 105 assert "注文する" in result.cta_texts 106 107 def test_特徴抽出(self, analyzer: LPAnalyzer) -> None: 108 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 109 110 # 3文字以下のliは除外される 111 assert any("高品質" in f for f in result.features) 112 assert not any(f == "短い" for f in result.features) 113 114 def test_価格抽出(self, analyzer: LPAnalyzer) -> None: 115 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 116 117 assert "¥10,000" in result.prices 118 119 def test_ブランド名抽出_og_site_name(self, analyzer: LPAnalyzer) -> None: 120 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 121 122 assert result.brand_name == "テストブランド" 123 124 def test_ブランド名_フォールバック(self, analyzer: LPAnalyzer) -> None: 125 result = analyzer._parse_html("https://www.example.com", _MINIMAL_HTML) 126 127 assert result.brand_name == "example.com" 128 129 def test_OGP抽出(self, analyzer: LPAnalyzer) -> None: 130 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 131 132 assert result.og_title == "OGテスト商品" 133 assert result.og_description == "OG説明文です" 134 135 def test_構造化データ抽出(self, analyzer: LPAnalyzer) -> None: 136 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 137 138 assert len(result.structured_data) == 1 139 assert result.structured_data[0]["@type"] == "Product" 140 141 def test_本文テキスト_script_style除外(self, analyzer: LPAnalyzer) -> None: 142 result = analyzer._parse_html("https://example.com", _SAMPLE_HTML) 143 144 assert "console.log" not in result.main_text 145 assert ".excluded" not in result.main_text 146 147 def test_URLの保持(self, analyzer: LPAnalyzer) -> None: 148 result = analyzer._parse_html("https://example.com/test", _SAMPLE_HTML) 149 150 assert result.url == "https://example.com/test" 151 152 153 # --------------------------------------------------------------------------- 154 # 業界推定テスト 155 # --------------------------------------------------------------------------- 156 157 158 @pytest.mark.unit 159 class TestEstimateIndustry: 160 def test_SaaS業界推定(self, analyzer: LPAnalyzer) -> None: 161 text = "クラウドSaaSツールの無料トライアルをお試しください" 162 hints = analyzer._estimate_industry(text) 163 164 assert "SaaS" in hints 165 166 def test_美容業界推定(self, analyzer: LPAnalyzer) -> None: 167 text = "エステサロンの脱毛メニュー。美容のプロが対応" 168 hints = analyzer._estimate_industry(text) 169 170 assert "美容" in hints 171 172 def test_該当なし(self, analyzer: LPAnalyzer) -> None: 173 text = "一般的なテキストです" 174 hints = analyzer._estimate_industry(text) 175 176 assert hints == () 177 178 def test_2キーワード未満では判定しない(self, analyzer: LPAnalyzer) -> None: 179 text = "クラウドの活用" # SaaSキーワード1個のみ 180 hints = analyzer._estimate_industry(text) 181 182 assert "SaaS" not in hints 183 184 185 # --------------------------------------------------------------------------- 186 # SSRF対策テスト 187 # --------------------------------------------------------------------------- 188 189 190 @pytest.mark.unit 191 class TestValidateUrl: 192 def test_正常なURL(self, analyzer: LPAnalyzer) -> None: 193 # 例外が発生しなければOK 194 analyzer._validate_url("https://example.com") 195 196 def test_localhostブロック(self, analyzer: LPAnalyzer) -> None: 197 with pytest.raises(ValueError, match="(?i)internal network"): 198 analyzer._validate_url("http://localhost/test") 199 200 def test_127_0_0_1ブロック(self, analyzer: LPAnalyzer) -> None: 201 with pytest.raises(ValueError, match="(?i)internal network"): 202 analyzer._validate_url("http://127.0.0.1/test") 203 204 def test_メタデータサービスブロック(self, analyzer: LPAnalyzer) -> None: 205 with pytest.raises(ValueError, match="(?i)internal network"): 206 analyzer._validate_url("http://169.254.169.254/latest/meta-data") 207 208 def test_ftpスキームブロック(self, analyzer: LPAnalyzer) -> None: 209 with pytest.raises(ValueError, match="not allowed"): 210 analyzer._validate_url("ftp://example.com/file") 211 212 def test_ホスト名なしブロック(self, analyzer: LPAnalyzer) -> None: 213 with pytest.raises(ValueError, match="hostname"): 214 analyzer._validate_url("https://") 215 216 def test_プライベートIPブロック(self, analyzer: LPAnalyzer) -> None: 217 with pytest.raises(ValueError, match="(?i)internal network"): 218 analyzer._validate_url("http://10.0.0.1/test") 219 220 def test_ipv6_loopbackブロック(self, analyzer: LPAnalyzer) -> None: 221 # ::1はurlparseでhostnameがNoneになるため「ホスト名」エラー 222 # ブラケット付き[::1]の場合は「内部ネットワーク」エラー 223 with pytest.raises(ValueError): 224 analyzer._validate_url("http://[::1]/test") 225 226 227 # --------------------------------------------------------------------------- 228 # LPContent データクラステスト 229 # --------------------------------------------------------------------------- 230 231 232 @pytest.mark.unit 233 class TestLPContent: 234 def test_イミュータブル(self) -> None: 235 content = LPContent(url="https://example.com") 236 with pytest.raises(AttributeError): 237 content.url = "https://other.com" # type: ignore[misc] 238 239 def test_デフォルト値(self) -> None: 240 content = LPContent(url="https://example.com") 241 242 assert content.title == "" 243 assert content.h1_texts == () 244 assert content.error is None 245 246 def test_エラー付き(self) -> None: 247 content = LPContent(url="https://example.com", error="取得失敗") 248 249 assert content.error == "取得失敗"