/ tests / test_lp_analyzer.py
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 == "取得失敗"