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,79 @@
|
|
|
1
|
+
"""Abstract base class for search engine parsers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from bs4 import BeautifulSoup, Tag
|
|
8
|
+
|
|
9
|
+
from search_engine_parser.core.models import SearchResults
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseParser(ABC):
|
|
13
|
+
"""Abstract base class for search engine parsers.
|
|
14
|
+
|
|
15
|
+
All search engine parsers must implement this interface.
|
|
16
|
+
To add a new search engine, subclass this and implement the
|
|
17
|
+
abstract methods, then register in parsers/__init__.py.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def engine_name(self) -> str:
|
|
23
|
+
"""Name of the search engine (e.g., 'google')."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def parse(self, html: str) -> SearchResults:
|
|
28
|
+
"""Parse HTML and extract search results.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
html: Raw HTML string from the search engine.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Parsed search results.
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def can_parse(self, soup: BeautifulSoup) -> float:
|
|
40
|
+
"""Check if this parser can handle the given HTML.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
soup: Parsed BeautifulSoup object.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Confidence score from 0.0 (cannot parse) to 1.0 (certain).
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def extract_query(self, soup: BeautifulSoup) -> str | None:
|
|
51
|
+
"""Extract the search query from the HTML if possible.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
soup: Parsed BeautifulSoup object.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The search query string, or None if not found.
|
|
58
|
+
"""
|
|
59
|
+
# Common: check <input> with name="q"
|
|
60
|
+
q_input = soup.find("input", attrs={"name": "q"})
|
|
61
|
+
if isinstance(q_input, Tag):
|
|
62
|
+
value = q_input.get("value")
|
|
63
|
+
if value:
|
|
64
|
+
return str(value)
|
|
65
|
+
|
|
66
|
+
# Check title tag
|
|
67
|
+
title_tag = soup.find("title")
|
|
68
|
+
if isinstance(title_tag, Tag) and title_tag.string:
|
|
69
|
+
text = title_tag.string.strip()
|
|
70
|
+
for suffix in [
|
|
71
|
+
" - Google Search",
|
|
72
|
+
" - Bing",
|
|
73
|
+
" - Search",
|
|
74
|
+
" at DuckDuckGo",
|
|
75
|
+
]:
|
|
76
|
+
if text.endswith(suffix):
|
|
77
|
+
return text[: -len(suffix)]
|
|
78
|
+
|
|
79
|
+
return None
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Bing search results parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from bs4 import BeautifulSoup, Tag
|
|
8
|
+
|
|
9
|
+
from search_engine_parser.core.models import SearchResult, SearchResults
|
|
10
|
+
from search_engine_parser.parsers.base import BaseParser
|
|
11
|
+
from search_engine_parser.utils import clean_text, make_soup
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BingParser(BaseParser):
|
|
17
|
+
"""Parser for Bing search result pages."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def engine_name(self) -> str:
|
|
21
|
+
return "bing"
|
|
22
|
+
|
|
23
|
+
def can_parse(self, soup: BeautifulSoup) -> float:
|
|
24
|
+
"""Check if this HTML is from Bing."""
|
|
25
|
+
confidence = 0.0
|
|
26
|
+
|
|
27
|
+
ms_app = soup.find("meta", attrs={"name": "ms.application"})
|
|
28
|
+
if isinstance(ms_app, Tag) and "bing" in str(ms_app.get("content", "")).lower():
|
|
29
|
+
confidence = max(confidence, 0.95)
|
|
30
|
+
|
|
31
|
+
if soup.find_all("li", class_="b_algo"):
|
|
32
|
+
confidence = max(confidence, 0.85)
|
|
33
|
+
|
|
34
|
+
for meta in soup.find_all("meta"):
|
|
35
|
+
content = str(meta.get("content", "")).lower()
|
|
36
|
+
if "bing" in content:
|
|
37
|
+
confidence = max(confidence, 0.7)
|
|
38
|
+
|
|
39
|
+
return confidence
|
|
40
|
+
|
|
41
|
+
def parse(self, html: str) -> SearchResults:
|
|
42
|
+
"""Parse Bing search results HTML."""
|
|
43
|
+
soup = make_soup(html)
|
|
44
|
+
results: list[SearchResult] = []
|
|
45
|
+
position = 1
|
|
46
|
+
|
|
47
|
+
# Extract featured snippet
|
|
48
|
+
featured = self._extract_featured_snippet(soup)
|
|
49
|
+
if featured:
|
|
50
|
+
results.append(featured)
|
|
51
|
+
|
|
52
|
+
# Extract organic results
|
|
53
|
+
for item in soup.find_all("li", class_="b_algo"):
|
|
54
|
+
if not isinstance(item, Tag):
|
|
55
|
+
continue
|
|
56
|
+
result = self._parse_organic_result(item, position)
|
|
57
|
+
if result:
|
|
58
|
+
results.append(result)
|
|
59
|
+
position += 1
|
|
60
|
+
|
|
61
|
+
query = self.extract_query(soup)
|
|
62
|
+
confidence = self.can_parse(soup)
|
|
63
|
+
|
|
64
|
+
return SearchResults(
|
|
65
|
+
search_engine=self.engine_name,
|
|
66
|
+
query=query,
|
|
67
|
+
results=results,
|
|
68
|
+
detection_confidence=confidence,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _parse_organic_result(self, item: Tag, position: int) -> SearchResult | None:
|
|
72
|
+
"""Parse a single Bing organic result."""
|
|
73
|
+
h2 = item.find("h2")
|
|
74
|
+
if not isinstance(h2, Tag):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
link = h2.find("a")
|
|
78
|
+
if not isinstance(link, Tag):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
title = clean_text(link.get_text())
|
|
82
|
+
url = str(link.get("href", ""))
|
|
83
|
+
|
|
84
|
+
if not title or not url:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
# Find description
|
|
88
|
+
desc_div = item.find("div", class_="b_caption")
|
|
89
|
+
description = None
|
|
90
|
+
if isinstance(desc_div, Tag):
|
|
91
|
+
p = desc_div.find("p")
|
|
92
|
+
if isinstance(p, Tag):
|
|
93
|
+
description = clean_text(p.get_text())
|
|
94
|
+
|
|
95
|
+
return SearchResult(
|
|
96
|
+
title=title,
|
|
97
|
+
url=url,
|
|
98
|
+
description=description,
|
|
99
|
+
position=position,
|
|
100
|
+
result_type="organic",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _extract_featured_snippet(self, soup: BeautifulSoup) -> SearchResult | None:
|
|
104
|
+
"""Extract featured snippet if present."""
|
|
105
|
+
snippet = soup.find("div", class_="b_ans")
|
|
106
|
+
if not isinstance(snippet, Tag):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
h2 = snippet.find("h2")
|
|
110
|
+
link = snippet.find("a")
|
|
111
|
+
if not isinstance(h2, Tag) or not isinstance(link, Tag):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
title = clean_text(h2.get_text())
|
|
115
|
+
url = str(link.get("href", ""))
|
|
116
|
+
if not title or not url:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
# Get snippet body text
|
|
120
|
+
body = snippet.find("div", class_="b_rich")
|
|
121
|
+
if not isinstance(body, Tag):
|
|
122
|
+
body = snippet.find("p")
|
|
123
|
+
description = clean_text(body.get_text()) if isinstance(body, Tag) else None
|
|
124
|
+
|
|
125
|
+
return SearchResult(
|
|
126
|
+
title=title,
|
|
127
|
+
url=url,
|
|
128
|
+
description=description,
|
|
129
|
+
position=0,
|
|
130
|
+
result_type="featured_snippet",
|
|
131
|
+
metadata={"snippet_type": "paragraph"},
|
|
132
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""DuckDuckGo search results parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from bs4 import BeautifulSoup, Tag
|
|
8
|
+
|
|
9
|
+
from search_engine_parser.core.models import SearchResult, SearchResults
|
|
10
|
+
from search_engine_parser.parsers.base import BaseParser
|
|
11
|
+
from search_engine_parser.utils import clean_text, make_soup
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DuckDuckGoParser(BaseParser):
|
|
17
|
+
"""Parser for DuckDuckGo search result pages."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def engine_name(self) -> str:
|
|
21
|
+
return "duckduckgo"
|
|
22
|
+
|
|
23
|
+
def can_parse(self, soup: BeautifulSoup) -> float:
|
|
24
|
+
"""Check if this HTML is from DuckDuckGo."""
|
|
25
|
+
confidence = 0.0
|
|
26
|
+
|
|
27
|
+
for meta in soup.find_all("meta"):
|
|
28
|
+
content = str(meta.get("content", "")).lower()
|
|
29
|
+
if "duckduckgo" in content:
|
|
30
|
+
confidence = max(confidence, 0.95)
|
|
31
|
+
|
|
32
|
+
if soup.find_all("article", attrs={"data-testid": "result"}):
|
|
33
|
+
confidence = max(confidence, 0.85)
|
|
34
|
+
|
|
35
|
+
if soup.find_all("div", class_="result"):
|
|
36
|
+
confidence = max(confidence, 0.5)
|
|
37
|
+
|
|
38
|
+
# Check for DDG-specific link patterns
|
|
39
|
+
for link in soup.find_all("link"):
|
|
40
|
+
href = str(link.get("href", "")).lower()
|
|
41
|
+
if "duckduckgo.com" in href:
|
|
42
|
+
confidence = max(confidence, 0.8)
|
|
43
|
+
|
|
44
|
+
return confidence
|
|
45
|
+
|
|
46
|
+
def parse(self, html: str) -> SearchResults:
|
|
47
|
+
"""Parse DuckDuckGo search results HTML."""
|
|
48
|
+
soup = make_soup(html)
|
|
49
|
+
results: list[SearchResult] = []
|
|
50
|
+
position = 1
|
|
51
|
+
|
|
52
|
+
# Try article-based results (newer DDG layout)
|
|
53
|
+
articles = soup.find_all("article", attrs={"data-testid": "result"})
|
|
54
|
+
if articles:
|
|
55
|
+
for article in articles:
|
|
56
|
+
if not isinstance(article, Tag):
|
|
57
|
+
continue
|
|
58
|
+
result = self._parse_article_result(article, position)
|
|
59
|
+
if result:
|
|
60
|
+
results.append(result)
|
|
61
|
+
position += 1
|
|
62
|
+
else:
|
|
63
|
+
# Fallback to div.result layout
|
|
64
|
+
for item in soup.find_all("div", class_="result"):
|
|
65
|
+
if not isinstance(item, Tag):
|
|
66
|
+
continue
|
|
67
|
+
result = self._parse_div_result(item, position)
|
|
68
|
+
if result:
|
|
69
|
+
results.append(result)
|
|
70
|
+
position += 1
|
|
71
|
+
|
|
72
|
+
query = self.extract_query(soup)
|
|
73
|
+
confidence = self.can_parse(soup)
|
|
74
|
+
|
|
75
|
+
return SearchResults(
|
|
76
|
+
search_engine=self.engine_name,
|
|
77
|
+
query=query,
|
|
78
|
+
results=results,
|
|
79
|
+
detection_confidence=confidence,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _parse_article_result(self, article: Tag, position: int) -> SearchResult | None:
|
|
83
|
+
"""Parse a DDG article-based result."""
|
|
84
|
+
h2 = article.find("h2")
|
|
85
|
+
if not isinstance(h2, Tag):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
link = h2.find("a")
|
|
89
|
+
if not isinstance(link, Tag):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
title = clean_text(link.get_text())
|
|
93
|
+
url = str(link.get("href", ""))
|
|
94
|
+
|
|
95
|
+
if not title or not url:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# Find description
|
|
99
|
+
desc = article.find("span", attrs={"data-testid": "result-snippet"})
|
|
100
|
+
if not isinstance(desc, Tag):
|
|
101
|
+
desc = article.find("div", class_="snippet")
|
|
102
|
+
description = clean_text(desc.get_text()) if isinstance(desc, Tag) else None
|
|
103
|
+
|
|
104
|
+
return SearchResult(
|
|
105
|
+
title=title,
|
|
106
|
+
url=url,
|
|
107
|
+
description=description,
|
|
108
|
+
position=position,
|
|
109
|
+
result_type="organic",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _parse_div_result(self, item: Tag, position: int) -> SearchResult | None:
|
|
113
|
+
"""Parse a DDG div.result-based result."""
|
|
114
|
+
link = item.find("a", class_="result__a")
|
|
115
|
+
if not isinstance(link, Tag):
|
|
116
|
+
link = item.find("a")
|
|
117
|
+
if not isinstance(link, Tag):
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
title = clean_text(link.get_text())
|
|
121
|
+
url = str(link.get("href", ""))
|
|
122
|
+
|
|
123
|
+
if not title or not url:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
desc = item.find("a", class_="result__snippet")
|
|
127
|
+
if not isinstance(desc, Tag):
|
|
128
|
+
desc = item.find("div", class_="result__snippet")
|
|
129
|
+
description = clean_text(desc.get_text()) if isinstance(desc, Tag) else None
|
|
130
|
+
|
|
131
|
+
return SearchResult(
|
|
132
|
+
title=title,
|
|
133
|
+
url=url,
|
|
134
|
+
description=description,
|
|
135
|
+
position=position,
|
|
136
|
+
result_type="organic",
|
|
137
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Google search results parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from bs4 import BeautifulSoup, Tag
|
|
8
|
+
|
|
9
|
+
from search_engine_parser.core.models import SearchResult, SearchResults
|
|
10
|
+
from search_engine_parser.parsers.base import BaseParser
|
|
11
|
+
from search_engine_parser.utils import clean_text, make_soup
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GoogleParser(BaseParser):
|
|
17
|
+
"""Parser for Google search result pages."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def engine_name(self) -> str:
|
|
21
|
+
return "google"
|
|
22
|
+
|
|
23
|
+
def can_parse(self, soup: BeautifulSoup) -> float:
|
|
24
|
+
"""Check if this HTML is from Google."""
|
|
25
|
+
confidence = 0.0
|
|
26
|
+
|
|
27
|
+
og_site = soup.find("meta", attrs={"property": "og:site_name"})
|
|
28
|
+
if isinstance(og_site, Tag) and str(og_site.get("content", "")).lower() == "google":
|
|
29
|
+
confidence = max(confidence, 0.95)
|
|
30
|
+
|
|
31
|
+
if soup.find("div", id="search"):
|
|
32
|
+
confidence = max(confidence, 0.8)
|
|
33
|
+
|
|
34
|
+
if soup.find_all("div", class_="g"):
|
|
35
|
+
confidence = max(confidence, 0.7)
|
|
36
|
+
|
|
37
|
+
return confidence
|
|
38
|
+
|
|
39
|
+
def parse(self, html: str) -> SearchResults:
|
|
40
|
+
"""Parse Google search results HTML."""
|
|
41
|
+
soup = make_soup(html)
|
|
42
|
+
results: list[SearchResult] = []
|
|
43
|
+
position = 1
|
|
44
|
+
|
|
45
|
+
# Extract sponsored results
|
|
46
|
+
sponsored = self._extract_sponsored_results(soup)
|
|
47
|
+
results.extend(sponsored)
|
|
48
|
+
|
|
49
|
+
# Extract featured snippet
|
|
50
|
+
featured = self._extract_featured_snippet(soup)
|
|
51
|
+
if featured:
|
|
52
|
+
results.append(featured)
|
|
53
|
+
|
|
54
|
+
# Extract organic results
|
|
55
|
+
for item in self._find_organic_results(soup):
|
|
56
|
+
result = self._parse_organic_result(item, position)
|
|
57
|
+
if result:
|
|
58
|
+
results.append(result)
|
|
59
|
+
position += 1
|
|
60
|
+
|
|
61
|
+
query = self.extract_query(soup)
|
|
62
|
+
confidence = self.can_parse(soup)
|
|
63
|
+
|
|
64
|
+
return SearchResults(
|
|
65
|
+
search_engine=self.engine_name,
|
|
66
|
+
query=query,
|
|
67
|
+
results=results,
|
|
68
|
+
detection_confidence=confidence,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _find_organic_results(self, soup: BeautifulSoup) -> list[Tag]:
|
|
72
|
+
"""Find all organic result containers."""
|
|
73
|
+
search_div = soup.find("div", id="search")
|
|
74
|
+
if isinstance(search_div, Tag):
|
|
75
|
+
g_divs = [
|
|
76
|
+
t
|
|
77
|
+
for t in search_div.find_all("div", class_="g", recursive=True)
|
|
78
|
+
if isinstance(t, Tag)
|
|
79
|
+
]
|
|
80
|
+
if g_divs:
|
|
81
|
+
return g_divs
|
|
82
|
+
# Fallback: locate results by their yuRUbf (title/link) sections
|
|
83
|
+
# and walk up to find a container that also holds the description.
|
|
84
|
+
return self._find_results_by_title_links(search_div)
|
|
85
|
+
return [t for t in soup.find_all("div", class_="g") if isinstance(t, Tag)]
|
|
86
|
+
|
|
87
|
+
def _find_results_by_title_links(self, root: Tag) -> list[Tag]:
|
|
88
|
+
"""Find result containers by locating yuRUbf divs and their ancestors."""
|
|
89
|
+
containers: list[Tag] = []
|
|
90
|
+
seen: set[int] = set()
|
|
91
|
+
for yu in root.find_all("div", class_="yuRUbf", recursive=True):
|
|
92
|
+
container = self._find_result_container(yu, root)
|
|
93
|
+
if isinstance(container, Tag) and id(container) not in seen:
|
|
94
|
+
seen.add(id(container))
|
|
95
|
+
containers.append(container)
|
|
96
|
+
return containers
|
|
97
|
+
|
|
98
|
+
def _find_result_container(self, title_link: Tag, root: Tag) -> Tag | None:
|
|
99
|
+
"""Walk up from a yuRUbf div to find the closest ancestor with a description."""
|
|
100
|
+
ancestor = title_link.parent
|
|
101
|
+
for _ in range(5):
|
|
102
|
+
if not isinstance(ancestor, Tag) or ancestor is root:
|
|
103
|
+
break
|
|
104
|
+
if ancestor.find("div", class_="VwiC3b") or ancestor.find("span", class_="st"):
|
|
105
|
+
return ancestor
|
|
106
|
+
ancestor = ancestor.parent
|
|
107
|
+
# No description ancestor found; use direct parent of yuRUbf
|
|
108
|
+
return title_link.parent if isinstance(title_link.parent, Tag) else None
|
|
109
|
+
|
|
110
|
+
def _parse_organic_result(self, item: Tag, position: int) -> SearchResult | None:
|
|
111
|
+
"""Parse a single organic result div."""
|
|
112
|
+
# Find the title link
|
|
113
|
+
link_container = item.find("div", class_="yuRUbf")
|
|
114
|
+
if not isinstance(link_container, Tag):
|
|
115
|
+
# Try finding a direct link with h3
|
|
116
|
+
link = item.find("a")
|
|
117
|
+
h3 = item.find("h3")
|
|
118
|
+
if not isinstance(link, Tag) or not isinstance(h3, Tag):
|
|
119
|
+
return None
|
|
120
|
+
url = str(link.get("href", ""))
|
|
121
|
+
title = clean_text(h3.get_text())
|
|
122
|
+
else:
|
|
123
|
+
link = link_container.find("a")
|
|
124
|
+
if not isinstance(link, Tag):
|
|
125
|
+
return None
|
|
126
|
+
url = str(link.get("href", ""))
|
|
127
|
+
h3 = link.find("h3")
|
|
128
|
+
title = clean_text(h3.get_text()) if isinstance(h3, Tag) else ""
|
|
129
|
+
|
|
130
|
+
if not url or not title:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Find description
|
|
134
|
+
desc_div = item.find("div", class_="VwiC3b")
|
|
135
|
+
if not isinstance(desc_div, Tag):
|
|
136
|
+
desc_div = item.find("span", class_="st")
|
|
137
|
+
description = clean_text(desc_div.get_text()) if isinstance(desc_div, Tag) else None
|
|
138
|
+
|
|
139
|
+
return SearchResult(
|
|
140
|
+
title=title,
|
|
141
|
+
url=url,
|
|
142
|
+
description=description,
|
|
143
|
+
position=position,
|
|
144
|
+
result_type="organic",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _extract_sponsored_results(self, soup: BeautifulSoup) -> list[SearchResult]:
|
|
148
|
+
"""Extract sponsored (ad) results from Google search pages."""
|
|
149
|
+
results: list[SearchResult] = []
|
|
150
|
+
for block in soup.find_all("div", class_="vbIt3d"):
|
|
151
|
+
if not isinstance(block, Tag):
|
|
152
|
+
continue
|
|
153
|
+
for ad in block.find_all("div", class_="uEierd"):
|
|
154
|
+
result = self._parse_sponsored_ad(ad)
|
|
155
|
+
if result:
|
|
156
|
+
results.append(result)
|
|
157
|
+
return results
|
|
158
|
+
|
|
159
|
+
def _parse_sponsored_ad(self, ad: Tag) -> SearchResult | None:
|
|
160
|
+
"""Parse a single Google sponsored ad container."""
|
|
161
|
+
link = ad.find("a", class_="sVXRqc")
|
|
162
|
+
if not isinstance(link, Tag):
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
url = str(link.get("href", ""))
|
|
166
|
+
if not url or not url.startswith("http"):
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
heading = link.find(attrs={"role": "heading"})
|
|
170
|
+
title = clean_text(heading.get_text()) if isinstance(heading, Tag) else ""
|
|
171
|
+
if not title:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
# Find description outside the main ad link
|
|
175
|
+
description = None
|
|
176
|
+
for desc_div in ad.find_all("div", class_="Va3FIb"):
|
|
177
|
+
if not isinstance(desc_div, Tag):
|
|
178
|
+
continue
|
|
179
|
+
if desc_div.find_parent("a", class_="sVXRqc"):
|
|
180
|
+
continue
|
|
181
|
+
text = clean_text(desc_div.get_text())
|
|
182
|
+
if text and text != title:
|
|
183
|
+
description = text
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
return SearchResult(
|
|
187
|
+
title=title,
|
|
188
|
+
url=url,
|
|
189
|
+
description=description,
|
|
190
|
+
position=0,
|
|
191
|
+
result_type="sponsored",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _extract_featured_snippet(self, soup: BeautifulSoup) -> SearchResult | None:
|
|
195
|
+
"""Extract featured snippet if present."""
|
|
196
|
+
snippet_container = soup.find("div", class_="xpdopen")
|
|
197
|
+
if not isinstance(snippet_container, Tag):
|
|
198
|
+
snippet_container = soup.find("block-component")
|
|
199
|
+
if not isinstance(snippet_container, Tag):
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
h3 = snippet_container.find("h3")
|
|
203
|
+
link = snippet_container.find("a")
|
|
204
|
+
if not isinstance(h3, Tag) or not isinstance(link, Tag):
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
title = clean_text(h3.get_text())
|
|
208
|
+
url = str(link.get("href", ""))
|
|
209
|
+
|
|
210
|
+
# Get snippet text
|
|
211
|
+
desc_span = snippet_container.find("span", class_="hgKElc")
|
|
212
|
+
if not isinstance(desc_span, Tag):
|
|
213
|
+
desc_span = snippet_container.find("div", class_="LGOjhe")
|
|
214
|
+
description = clean_text(desc_span.get_text()) if isinstance(desc_span, Tag) else None
|
|
215
|
+
|
|
216
|
+
if not title or not url:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
return SearchResult(
|
|
220
|
+
title=title,
|
|
221
|
+
url=url,
|
|
222
|
+
description=description,
|
|
223
|
+
position=0,
|
|
224
|
+
result_type="featured_snippet",
|
|
225
|
+
metadata={"snippet_type": "paragraph"},
|
|
226
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Utility functions for search engine parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from bs4 import BeautifulSoup
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_soup(html: str) -> BeautifulSoup:
|
|
13
|
+
"""Parse HTML string into a BeautifulSoup object.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
html: Raw HTML string to parse.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Parsed BeautifulSoup object.
|
|
20
|
+
"""
|
|
21
|
+
return BeautifulSoup(html, "lxml")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def clean_text(text: str | None) -> str:
|
|
25
|
+
"""Clean and normalize text extracted from HTML.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
text: Raw text that may contain extra whitespace.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Cleaned text with normalized whitespace.
|
|
32
|
+
"""
|
|
33
|
+
if not text:
|
|
34
|
+
return ""
|
|
35
|
+
return " ".join(text.split()).strip()
|