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,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()