search-parser 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ """Search Engine Parser - Parse search engine HTML results into structured data."""
2
+
3
+ from search_engine_parser.__version__ import __version__
4
+ from search_engine_parser.core.models import SearchResult, SearchResults
5
+ from search_engine_parser.core.parser import SearchParser
6
+
7
+ __all__ = [
8
+ "__version__",
9
+ "SearchParser",
10
+ "SearchResult",
11
+ "SearchResults",
12
+ ]
@@ -0,0 +1,3 @@
1
+ """Version information."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,79 @@
1
+ """Command-line interface for search engine parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ try:
9
+ import click
10
+ from rich.console import Console
11
+ from rich.syntax import Syntax
12
+ except ImportError:
13
+ print(
14
+ 'CLI dependencies not installed. Install with: pip install "search-engine-parser[cli]"',
15
+ file=sys.stderr,
16
+ )
17
+ sys.exit(1)
18
+
19
+ from search_engine_parser.core.parser import SearchParser
20
+ from search_engine_parser.exceptions import SearchEngineParserError
21
+
22
+
23
+ @click.command()
24
+ @click.argument("input_file", type=click.Path(exists=True, path_type=Path))
25
+ @click.option(
26
+ "--format",
27
+ "output_format",
28
+ type=click.Choice(["json", "markdown", "dict"]),
29
+ default="json",
30
+ help="Output format (default: json).",
31
+ )
32
+ @click.option(
33
+ "--engine",
34
+ type=click.Choice(["google", "bing", "duckduckgo"]),
35
+ default=None,
36
+ help="Manually specify the search engine.",
37
+ )
38
+ @click.option(
39
+ "--pretty/--no-pretty",
40
+ default=True,
41
+ help="Pretty-print JSON output (default: true).",
42
+ )
43
+ def main(
44
+ input_file: Path,
45
+ output_format: str,
46
+ engine: str | None,
47
+ pretty: bool,
48
+ ) -> None:
49
+ """Parse search engine HTML results into structured data.
50
+
51
+ INPUT_FILE is the path to an HTML file containing search results.
52
+ """
53
+ console = Console()
54
+ html = input_file.read_text(encoding="utf-8")
55
+
56
+ parser = SearchParser()
57
+ try:
58
+ # dict format is not useful for CLI, convert to json for display
59
+ fmt = output_format if output_format != "dict" else "json"
60
+ result = parser.parse(html, engine=engine, output_format=fmt) # type: ignore[arg-type]
61
+ except SearchEngineParserError as e:
62
+ console.print(f"[red]Error:[/red] {e}")
63
+ sys.exit(1)
64
+
65
+ if isinstance(result, str):
66
+ if output_format == "json" and pretty:
67
+ import json
68
+
69
+ formatted = json.dumps(json.loads(result), indent=2)
70
+ syntax = Syntax(formatted, "json", theme="monokai")
71
+ console.print(syntax)
72
+ elif output_format == "markdown":
73
+ from rich.markdown import Markdown
74
+
75
+ console.print(Markdown(result))
76
+ else:
77
+ console.print(result)
78
+ else:
79
+ console.print(result)
@@ -0,0 +1 @@
1
+ """Core module for search engine parser."""
@@ -0,0 +1,130 @@
1
+ """Search engine auto-detection from HTML content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+
8
+ from bs4 import BeautifulSoup, Tag
9
+
10
+ from search_engine_parser.utils import make_soup
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class DetectionResult:
17
+ """Result of search engine detection."""
18
+
19
+ engine: str
20
+ confidence: float
21
+
22
+
23
+ class SearchEngineDetector:
24
+ """Detects which search engine produced the given HTML.
25
+
26
+ Detection is performed in priority order:
27
+ 1. HTML meta tags
28
+ 2. DOM structure patterns
29
+ 3. URL patterns in the HTML
30
+ """
31
+
32
+ def detect(self, html: str) -> DetectionResult | None:
33
+ """Detect the search engine from HTML content.
34
+
35
+ Args:
36
+ html: Raw HTML string to analyze.
37
+
38
+ Returns:
39
+ DetectionResult with engine name and confidence, or None if
40
+ confidence is below threshold.
41
+ """
42
+ soup = make_soup(html)
43
+ candidates: list[DetectionResult] = []
44
+
45
+ for check in [
46
+ self._check_meta_tags,
47
+ self._check_dom_structure,
48
+ self._check_url_patterns,
49
+ ]:
50
+ result = check(soup)
51
+ if result:
52
+ candidates.append(result)
53
+
54
+ if not candidates:
55
+ return None
56
+
57
+ best = max(candidates, key=lambda r: r.confidence)
58
+ if best.confidence < 0.3:
59
+ return None
60
+ return best
61
+
62
+ def _check_meta_tags(self, soup: BeautifulSoup) -> DetectionResult | None:
63
+ """Check HTML meta tags for search engine identification."""
64
+ # Google: <meta property="og:site_name" content="Google">
65
+ og_site = soup.find("meta", attrs={"property": "og:site_name"})
66
+ if isinstance(og_site, Tag) and str(og_site.get("content", "")).lower() == "google":
67
+ return DetectionResult(engine="google", confidence=0.95)
68
+
69
+ # Bing: <meta name="ms.application" content="Bing">
70
+ ms_app = soup.find("meta", attrs={"name": "ms.application"})
71
+ if isinstance(ms_app, Tag) and "bing" in str(ms_app.get("content", "")).lower():
72
+ return DetectionResult(engine="bing", confidence=0.95)
73
+
74
+ # Check for Bing in any meta content
75
+ for meta in soup.find_all("meta"):
76
+ content = str(meta.get("content", "")).lower()
77
+ name = str(meta.get("name", "")).lower()
78
+ if "bing" in content or "bing" in name:
79
+ return DetectionResult(engine="bing", confidence=0.85)
80
+
81
+ # DuckDuckGo: Check for DDG-specific meta tags
82
+ for meta in soup.find_all("meta"):
83
+ content = str(meta.get("content", "")).lower()
84
+ if "duckduckgo" in content:
85
+ return DetectionResult(engine="duckduckgo", confidence=0.95)
86
+
87
+ return None
88
+
89
+ def _check_dom_structure(self, soup: BeautifulSoup) -> DetectionResult | None:
90
+ """Check DOM structure patterns for search engine identification."""
91
+ # Google: div#search or div.g
92
+ if soup.find("div", id="search") or soup.find_all("div", class_="g"):
93
+ return DetectionResult(engine="google", confidence=0.8)
94
+
95
+ # Bing: div.b_algo
96
+ if soup.find_all("li", class_="b_algo"):
97
+ return DetectionResult(engine="bing", confidence=0.85)
98
+
99
+ # DuckDuckGo: various DDG-specific classes
100
+ if soup.find_all("article", attrs={"data-testid": "result"}):
101
+ return DetectionResult(engine="duckduckgo", confidence=0.85)
102
+ if soup.find_all("div", class_="result"):
103
+ return DetectionResult(engine="duckduckgo", confidence=0.6)
104
+
105
+ return None
106
+
107
+ def _check_url_patterns(self, soup: BeautifulSoup) -> DetectionResult | None:
108
+ """Check URL patterns in HTML for search engine identification."""
109
+ # Check canonical URL
110
+ canonical = soup.find("link", attrs={"rel": "canonical"})
111
+ if isinstance(canonical, Tag):
112
+ href = str(canonical.get("href", "")).lower()
113
+ if "google.com" in href:
114
+ return DetectionResult(engine="google", confidence=0.9)
115
+ if "bing.com" in href:
116
+ return DetectionResult(engine="bing", confidence=0.9)
117
+ if "duckduckgo.com" in href:
118
+ return DetectionResult(engine="duckduckgo", confidence=0.9)
119
+
120
+ # Check for search engine URLs in links
121
+ for link in soup.find_all("link"):
122
+ href = str(link.get("href", "")).lower()
123
+ if "google.com" in href or "gstatic.com" in href:
124
+ return DetectionResult(engine="google", confidence=0.6)
125
+ if "bing.com" in href:
126
+ return DetectionResult(engine="bing", confidence=0.6)
127
+ if "duckduckgo.com" in href:
128
+ return DetectionResult(engine="duckduckgo", confidence=0.6)
129
+
130
+ return None
@@ -0,0 +1,33 @@
1
+ """Pydantic data models for search results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Literal, Optional
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class SearchResult(BaseModel):
12
+ """Single search result item."""
13
+
14
+ title: str
15
+ url: str
16
+ description: Optional[str] = None # noqa: UP045 - Pydantic evaluates at runtime; breaks on 3.9
17
+ position: int
18
+ result_type: Literal[
19
+ "organic", "featured_snippet", "knowledge_panel", "news", "image", "sponsored"
20
+ ] = "organic"
21
+ metadata: dict[str, object] = Field(default_factory=dict)
22
+
23
+
24
+ class SearchResults(BaseModel):
25
+ """Collection of search results from a page."""
26
+
27
+ search_engine: str
28
+ query: Optional[str] = None # noqa: UP045
29
+ total_results: Optional[int] = None # noqa: UP045
30
+ results: list[SearchResult]
31
+ detection_confidence: float = Field(ge=0.0, le=1.0)
32
+ parsed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
33
+ metadata: dict[str, object] = Field(default_factory=dict)
@@ -0,0 +1,124 @@
1
+ """Main SearchParser class - the primary public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any, Literal
8
+
9
+ from search_engine_parser.core.detector import SearchEngineDetector
10
+ from search_engine_parser.core.models import SearchResults
11
+ from search_engine_parser.exceptions import (
12
+ ParseError,
13
+ ParserNotFoundError,
14
+ SearchEngineDetectionError,
15
+ )
16
+ from search_engine_parser.formatters.json_formatter import JSONFormatter
17
+ from search_engine_parser.formatters.markdown_formatter import MarkdownFormatter
18
+ from search_engine_parser.parsers import PARSER_REGISTRY
19
+ from search_engine_parser.parsers.base import BaseParser
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class SearchParser:
25
+ """Main entry point for parsing search engine HTML.
26
+
27
+ Automatically detects the search engine and parses results
28
+ into structured data in the requested format.
29
+
30
+ Example:
31
+ >>> parser = SearchParser()
32
+ >>> json_output = parser.parse(html_string)
33
+ >>> markdown_output = parser.parse(html_string, output_format="markdown")
34
+ >>> dict_output = parser.parse(html_string, output_format="dict")
35
+ """
36
+
37
+ def __init__(self) -> None:
38
+ self._detector = SearchEngineDetector()
39
+ self._json_formatter = JSONFormatter()
40
+ self._markdown_formatter = MarkdownFormatter()
41
+ self._parsers: dict[str, BaseParser] = {
42
+ name: cls() for name, cls in PARSER_REGISTRY.items()
43
+ }
44
+
45
+ def parse(
46
+ self,
47
+ html: str,
48
+ engine: str | None = None,
49
+ output_format: Literal["json", "markdown", "dict"] = "json",
50
+ ) -> str | dict[str, Any]:
51
+ """Parse search engine HTML and return results.
52
+
53
+ Args:
54
+ html: HTML string to parse.
55
+ engine: Optionally specify engine ("google", "bing", "duckduckgo").
56
+ If None, will auto-detect.
57
+ output_format: Output format - "json" (default), "markdown", or "dict".
58
+ - "json": Returns JSON string
59
+ - "markdown": Returns Markdown string
60
+ - "dict": Returns Python dictionary
61
+
62
+ Returns:
63
+ JSON string, Markdown string, or Python dictionary depending
64
+ on output_format.
65
+
66
+ Raises:
67
+ SearchEngineDetectionError: If engine cannot be detected and not specified.
68
+ ParserNotFoundError: If no parser available for the detected/specified engine.
69
+ ParseError: If parsing fails.
70
+ """
71
+ # Determine which engine to use
72
+ engine_name = self._resolve_engine(html, engine)
73
+
74
+ # Get the parser
75
+ parser = self._parsers.get(engine_name)
76
+ if not parser:
77
+ raise ParserNotFoundError(
78
+ f"No parser available for engine: {engine_name!r}. "
79
+ f"Available parsers: {list(self._parsers.keys())}"
80
+ )
81
+
82
+ # Parse the HTML
83
+ try:
84
+ results = parser.parse(html)
85
+ except Exception as e:
86
+ raise ParseError(f"Failed to parse HTML: {e}") from e
87
+
88
+ # Format the output
89
+ return self._format_output(results, output_format)
90
+
91
+ def _resolve_engine(self, html: str, engine: str | None) -> str:
92
+ """Resolve the search engine name, either from user input or auto-detection."""
93
+ if engine:
94
+ return engine.lower()
95
+
96
+ detection = self._detector.detect(html)
97
+ if not detection:
98
+ raise SearchEngineDetectionError(
99
+ "Could not detect the search engine. "
100
+ "Please specify the engine manually using the 'engine' parameter."
101
+ )
102
+
103
+ logger.info(
104
+ "Detected engine: %s (confidence: %.2f)",
105
+ detection.engine,
106
+ detection.confidence,
107
+ )
108
+ return detection.engine
109
+
110
+ def _format_output(
111
+ self,
112
+ results: SearchResults,
113
+ output_format: Literal["json", "markdown", "dict"],
114
+ ) -> str | dict[str, Any]:
115
+ """Format results into the requested output format."""
116
+ if output_format == "json":
117
+ return self._json_formatter.format(results)
118
+ elif output_format == "markdown":
119
+ return self._markdown_formatter.format(results)
120
+ elif output_format == "dict":
121
+ result: dict[str, Any] = json.loads(results.model_dump_json())
122
+ return result
123
+ else:
124
+ raise ValueError(f"Unknown output format: {output_format!r}")
@@ -0,0 +1,21 @@
1
+ """Custom exceptions for search engine parser."""
2
+
3
+
4
+ class SearchEngineParserError(Exception):
5
+ """Base exception for search engine parser."""
6
+
7
+
8
+ class SearchEngineDetectionError(SearchEngineParserError):
9
+ """Cannot determine which search engine the HTML came from."""
10
+
11
+
12
+ class ParserNotFoundError(SearchEngineParserError):
13
+ """No parser available for the detected or specified search engine."""
14
+
15
+
16
+ class ParseError(SearchEngineParserError):
17
+ """Failed to parse the HTML content."""
18
+
19
+
20
+ class InvalidHTMLError(SearchEngineParserError):
21
+ """The HTML structure is invalid or cannot be processed."""
@@ -0,0 +1,11 @@
1
+ """Output formatters for search results."""
2
+
3
+ from search_engine_parser.formatters.base import BaseFormatter
4
+ from search_engine_parser.formatters.json_formatter import JSONFormatter
5
+ from search_engine_parser.formatters.markdown_formatter import MarkdownFormatter
6
+
7
+ __all__ = [
8
+ "BaseFormatter",
9
+ "JSONFormatter",
10
+ "MarkdownFormatter",
11
+ ]
@@ -0,0 +1,27 @@
1
+ """Abstract base class for output formatters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from search_engine_parser.core.models import SearchResults
9
+
10
+
11
+ class BaseFormatter(ABC):
12
+ """Abstract base class for output formatters.
13
+
14
+ Subclass this to create custom output formats.
15
+ """
16
+
17
+ @abstractmethod
18
+ def format(self, results: SearchResults) -> Any:
19
+ """Format search results into the desired output.
20
+
21
+ Args:
22
+ results: Parsed search results to format.
23
+
24
+ Returns:
25
+ Formatted output (type depends on implementation).
26
+ """
27
+ ...
@@ -0,0 +1,21 @@
1
+ """JSON output formatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from search_engine_parser.core.models import SearchResults
6
+ from search_engine_parser.formatters.base import BaseFormatter
7
+
8
+
9
+ class JSONFormatter(BaseFormatter):
10
+ """Formats search results as a JSON string using Pydantic serialization."""
11
+
12
+ def format(self, results: SearchResults) -> str:
13
+ """Format search results as a JSON string.
14
+
15
+ Args:
16
+ results: Parsed search results.
17
+
18
+ Returns:
19
+ JSON string representation.
20
+ """
21
+ return results.model_dump_json(indent=2)
@@ -0,0 +1,119 @@
1
+ """Markdown output formatter for LLM-friendly consumption."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from search_engine_parser.__version__ import __version__
6
+ from search_engine_parser.core.models import SearchResult, SearchResults
7
+ from search_engine_parser.formatters.base import BaseFormatter
8
+
9
+
10
+ class MarkdownFormatter(BaseFormatter):
11
+ """Formats search results as Markdown text.
12
+
13
+ Produces a clean, hierarchical Markdown document suitable for
14
+ both human reading and LLM consumption.
15
+ """
16
+
17
+ def format(self, results: SearchResults) -> str:
18
+ """Format search results as Markdown.
19
+
20
+ Args:
21
+ results: Parsed search results.
22
+
23
+ Returns:
24
+ Markdown string.
25
+ """
26
+ lines: list[str] = []
27
+
28
+ # Header
29
+ title = f"Search Results: {results.query}" if results.query else "Search Results"
30
+ lines.append(f"# {title}")
31
+ lines.append("")
32
+ lines.append(f"**Search Engine:** {results.search_engine.title()}")
33
+
34
+ if results.total_results is not None:
35
+ lines.append(f"**Total Results:** ~{results.total_results:,}")
36
+
37
+ parsed_time = results.parsed_at.strftime("%Y-%m-%d %H:%M:%S UTC")
38
+ lines.append(f"**Parsed:** {parsed_time}")
39
+ lines.append("")
40
+ lines.append("---")
41
+ lines.append("")
42
+
43
+ # Group results by type
44
+ featured = [r for r in results.results if r.result_type == "featured_snippet"]
45
+ organic = [r for r in results.results if r.result_type == "organic"]
46
+ knowledge = [r for r in results.results if r.result_type == "knowledge_panel"]
47
+ news = [r for r in results.results if r.result_type == "news"]
48
+
49
+ if featured:
50
+ lines.append("## Featured Snippet")
51
+ lines.append("")
52
+ for result in featured:
53
+ lines.extend(self._format_featured(result))
54
+ lines.append("---")
55
+ lines.append("")
56
+
57
+ if knowledge:
58
+ lines.append("## Knowledge Panel")
59
+ lines.append("")
60
+ for result in knowledge:
61
+ lines.extend(self._format_result(result))
62
+ lines.append("---")
63
+ lines.append("")
64
+
65
+ if organic:
66
+ lines.append("## Organic Results")
67
+ lines.append("")
68
+ for result in organic:
69
+ lines.extend(self._format_organic(result))
70
+
71
+ if news:
72
+ lines.append("## News Results")
73
+ lines.append("")
74
+ for result in news:
75
+ lines.extend(self._format_result(result))
76
+
77
+ lines.append("---")
78
+ lines.append("")
79
+ lines.append(f"*Parsed with search-engine-parser v{__version__}*")
80
+ lines.append("")
81
+
82
+ return "\n".join(lines)
83
+
84
+ def _format_featured(self, result: SearchResult) -> list[str]:
85
+ """Format a featured snippet result."""
86
+ lines: list[str] = []
87
+ lines.append(f"### {result.title}")
88
+ lines.append("")
89
+ if result.description:
90
+ lines.append(result.description)
91
+ lines.append("")
92
+ # Extract domain from URL for source
93
+ lines.append(f"**Source:** [{result.url}]({result.url})")
94
+ lines.append("")
95
+ return lines
96
+
97
+ def _format_organic(self, result: SearchResult) -> list[str]:
98
+ """Format an organic search result."""
99
+ lines: list[str] = []
100
+ lines.append(f"### {result.position}. {result.title}")
101
+ lines.append("")
102
+ if result.description:
103
+ lines.append(result.description)
104
+ lines.append("")
105
+ lines.append(f"**URL:** {result.url}")
106
+ lines.append("")
107
+ return lines
108
+
109
+ def _format_result(self, result: SearchResult) -> list[str]:
110
+ """Format a generic result."""
111
+ lines: list[str] = []
112
+ lines.append(f"### {result.title}")
113
+ lines.append("")
114
+ if result.description:
115
+ lines.append(result.description)
116
+ lines.append("")
117
+ lines.append(f"**URL:** {result.url}")
118
+ lines.append("")
119
+ return lines
@@ -0,0 +1,21 @@
1
+ """Search engine parser implementations."""
2
+
3
+ from search_engine_parser.parsers.base import BaseParser
4
+ from search_engine_parser.parsers.bing import BingParser
5
+ from search_engine_parser.parsers.duckduckgo import DuckDuckGoParser
6
+ from search_engine_parser.parsers.google import GoogleParser
7
+
8
+ # Registry of all available parsers
9
+ PARSER_REGISTRY: dict[str, type[BaseParser]] = {
10
+ "google": GoogleParser,
11
+ "bing": BingParser,
12
+ "duckduckgo": DuckDuckGoParser,
13
+ }
14
+
15
+ __all__ = [
16
+ "BaseParser",
17
+ "BingParser",
18
+ "DuckDuckGoParser",
19
+ "GoogleParser",
20
+ "PARSER_REGISTRY",
21
+ ]