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.
- search_engine_parser/__init__.py +12 -0
- search_engine_parser/__version__.py +3 -0
- search_engine_parser/cli.py +79 -0
- search_engine_parser/core/__init__.py +1 -0
- search_engine_parser/core/detector.py +130 -0
- search_engine_parser/core/models.py +33 -0
- search_engine_parser/core/parser.py +124 -0
- search_engine_parser/exceptions.py +21 -0
- search_engine_parser/formatters/__init__.py +11 -0
- search_engine_parser/formatters/base.py +27 -0
- search_engine_parser/formatters/json_formatter.py +21 -0
- search_engine_parser/formatters/markdown_formatter.py +119 -0
- search_engine_parser/parsers/__init__.py +21 -0
- search_engine_parser/parsers/base.py +79 -0
- search_engine_parser/parsers/bing.py +132 -0
- search_engine_parser/parsers/duckduckgo.py +137 -0
- search_engine_parser/parsers/google.py +226 -0
- search_engine_parser/utils.py +35 -0
- search_parser-0.0.1.dist-info/METADATA +214 -0
- search_parser-0.0.1.dist-info/RECORD +23 -0
- search_parser-0.0.1.dist-info/WHEEL +4 -0
- search_parser-0.0.1.dist-info/entry_points.txt +2 -0
- search_parser-0.0.1.dist-info/licenses/LICENSE +190 -0
|
@@ -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,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
|
+
]
|