search-parser 0.5.0__tar.gz → 0.5.2__tar.gz
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_parser-0.5.0 → search_parser-0.5.2}/CHANGELOG.md +71 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/PKG-INFO +23 -2
- {search_parser-0.5.0 → search_parser-0.5.2}/README.md +21 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/docs/examples/advanced_usage.md +6 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/docs/examples/basic_usage.md +7 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/docs/getting_started.md +12 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/pyproject.toml +2 -2
- search_parser-0.5.2/src/search_parser/__init__.py +32 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/__version__.py +1 -1
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/core/detector.py +11 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/core/models.py +17 -1
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/core/parser.py +1 -1
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/formatters/json_formatter.py +3 -1
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/formatters/markdown_formatter.py +28 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/parsers/__init__.py +3 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/parsers/google.py +91 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/conftest.py +20 -0
- search_parser-0.5.2/tests/fixtures/google/contact_lens_weekly_20260528_203836.html +47 -0
- search_parser-0.5.2/tests/fixtures/google/home_loan_interest_rates_20260528_203450.html +60 -0
- search_parser-0.5.2/tests/fixtures/google/iphone_15_review_20260528_204042.html +76 -0
- search_parser-0.5.2/tests/fixtures/google/personal_injury_lawyer_20260528_203801.html +40 -0
- search_parser-0.5.2/tests/fixtures/google/search_seven_cloak.html +95 -0
- search_parser-0.5.2/tests/fixtures/google_finance/crypto.html +23 -0
- search_parser-0.5.2/tests/fixtures/google_finance/quote.html +31 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/unit/test_google_parser.py +102 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/uv.lock +132 -134
- search_parser-0.5.0/src/search_parser/__init__.py +0 -12
- {search_parser-0.5.0 → search_parser-0.5.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/.github/dependabot.yml +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/.github/pull_request_template.md +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/.github/workflows/codeql.yml +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/.github/workflows/coverage-badge.yml +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/.github/workflows/lint.yml +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/.github/workflows/publish.yml +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/.github/workflows/test.yml +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/.gitignore +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/.pre-commit-config.yaml +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/CLAUDE.md +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/CODE_OF_CONDUCT.md +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/CONTRIBUTING.md +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/LICENSE +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/docs/CNAME +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/docs/adding_search_engine.md +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/docs/api_reference.md +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/docs/contributing.md +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/docs/index.md +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/examples/basic_parsing.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/examples/batch_processing.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/examples/custom_formatter.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/mkdocs.yml +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/scripts/update_fixtures.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/cli.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/core/__init__.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/exceptions.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/formatters/__init__.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/formatters/base.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/parsers/base.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/parsers/bing.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/parsers/duckduckgo.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/utils.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/__init__.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/bing/organic_results.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/bing/search_github_repos.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/duckduckgo/organic_results.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/duckduckgo/search_github_repos.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/featured_snippet.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/knowledge_panel.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/need_turn_on_javascript.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/news_tab_search.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/organic_results.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/search_best_employee_scheduling_app.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/search_github_repos.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/search_python_web_scraping.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/supply-chain-director-jobs.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/weekly_contacts_with_mobile.html +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/test_cli.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/test_coverage_boost.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/test_formatters.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/unit/__init__.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/unit/test_bing_parser.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/unit/test_detector.py +0 -0
- {search_parser-0.5.0 → search_parser-0.5.2}/tests/unit/test_duckduckgo_parser.py +0 -0
|
@@ -9,6 +9,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## [0.5.2] - 2026-04-26
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`GoogleFinanceParser`** — parses Google Finance HTML pages by extracting the
|
|
17
|
+
embedded `AF_initDataCallback` data blocks (no network calls required).
|
|
18
|
+
Supports stocks, ETFs, indices, crypto, and FX pairs. Returns a
|
|
19
|
+
`SearchResults` object with dedicated fields:
|
|
20
|
+
- `stock_quote` — price, change, change %, previous close, currency, timezone,
|
|
21
|
+
after-hours data
|
|
22
|
+
- `company_info` — description, CEO, employees, market cap, P/E ratio,
|
|
23
|
+
52-week range, sector, headquarters
|
|
24
|
+
- `stock_chart` — time-series of date / price / volume points
|
|
25
|
+
- `financial_news` — news items with title, URL, source, and Unix timestamp
|
|
26
|
+
- `financial_statements` — income statement, balance sheet, and cash flow
|
|
27
|
+
data (stocks only; uses recursive positional-array detection)
|
|
28
|
+
|
|
29
|
+
- **`GoogleFinanceScraper`** — standalone class that fetches live data from
|
|
30
|
+
Google Finance's internal `batchexecute` RPC endpoint using only the Python
|
|
31
|
+
standard library (`urllib`). No API key required. Returns typed dataclasses
|
|
32
|
+
(`GoogleFinanceData`, `FinancialQuote`, `CompanyInfo`, `ChartData`,
|
|
33
|
+
`ChartPoint`, `NewsItem`, `FinancialStatement`).
|
|
34
|
+
|
|
35
|
+
- **New `result_type` literals** — `stock_quote`, `company_info`, `stock_chart`,
|
|
36
|
+
`financials`, `financial_news` added to `SearchResult.result_type`.
|
|
37
|
+
|
|
38
|
+
- **New `SearchResults` fields** — `stock_quote`, `company_info`, `stock_chart`,
|
|
39
|
+
`financial_statements`, `financial_news` (all default to `None` / `[]` so
|
|
40
|
+
existing code is unaffected).
|
|
41
|
+
|
|
42
|
+
- **Auto-detection** — `SearchEngineDetector` now recognises Google Finance
|
|
43
|
+
pages (canonical URL or `AF_dataServiceRequests` script marker) before
|
|
44
|
+
falling through to the generic Google check.
|
|
45
|
+
|
|
46
|
+
### Usage
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# Parse a saved Google Finance HTML page
|
|
50
|
+
from search_parser import GoogleFinanceParser
|
|
51
|
+
|
|
52
|
+
parser = GoogleFinanceParser()
|
|
53
|
+
results = parser.parse(html)
|
|
54
|
+
|
|
55
|
+
print(results.query) # "GOOGL:NASDAQ"
|
|
56
|
+
print(results.stock_quote.metadata["price"]) # 339.32
|
|
57
|
+
print(results.company_info.metadata["ceo"]) # "Sundar Pichai"
|
|
58
|
+
print(results.financial_news[0].title)
|
|
59
|
+
|
|
60
|
+
# Fetch live data (no API key needed)
|
|
61
|
+
from search_parser import GoogleFinanceScraper
|
|
62
|
+
|
|
63
|
+
scraper = GoogleFinanceScraper()
|
|
64
|
+
data = scraper.scrape("GOOGL:NASDAQ") # stocks
|
|
65
|
+
data = scraper.scrape("BTC-USD") # crypto
|
|
66
|
+
data = scraper.scrape("EUR-USD") # FX
|
|
67
|
+
|
|
68
|
+
print(data.quote.price)
|
|
69
|
+
print(data.company.sector)
|
|
70
|
+
print(data.chart.points[0].date)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## [0.5.1] - 2026-04-08
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **JSON output: non-ASCII characters no longer escaped** — `JSONFormatter`, `SearchResults.to_json()`, and the `"dict"` output path in `SearchParser.parse()` all previously relied on Pydantic's `model_dump_json()` / `serde_json`, which escapes every non-ASCII character as `\uXXXX`. They now use `model_dump(mode="json")` + `json.dumps(..., ensure_ascii=False)`, so Korean, Japanese, Arabic, and all other non-Latin scripts are emitted as literal UTF-8 characters.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
12
83
|
## [0.5.0] - 2026-04-08
|
|
13
84
|
|
|
14
85
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: search-parser
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Parse Google, Bing, and DuckDuckGo HTML search results into JSON, Markdown, or dict — with auto-detection
|
|
5
5
|
Project-URL: Homepage, https://github.com/getlinksc/search-parser
|
|
6
6
|
Project-URL: Documentation, https://getlinksc.github.io/search-parser
|
|
@@ -24,7 +24,7 @@ Requires-Dist: beautifulsoup4>=4.12.0
|
|
|
24
24
|
Requires-Dist: lxml>=5.0.0
|
|
25
25
|
Requires-Dist: markdownify>=0.11.0
|
|
26
26
|
Requires-Dist: pydantic>=2.0.0
|
|
27
|
-
Requires-Dist: pytest
|
|
27
|
+
Requires-Dist: pytest>=7.4.0
|
|
28
28
|
Provides-Extra: cli
|
|
29
29
|
Requires-Dist: click>=8.1.0; extra == 'cli'
|
|
30
30
|
Requires-Dist: rich>=13.0.0; extra == 'cli'
|
|
@@ -123,6 +123,7 @@ pip install search-parser
|
|
|
123
123
|
| Jobs | `jobs` | ✓ | — | — |
|
|
124
124
|
| Discussions and forums | `discussions` | ✓ | — | — |
|
|
125
125
|
| Shopping ads | `shopping_ads` | ✓ | — | — |
|
|
126
|
+
| News tab articles | `news` | ✓ | — | — |
|
|
126
127
|
|
|
127
128
|
---
|
|
128
129
|
|
|
@@ -177,6 +178,11 @@ for disc in data["discussions"]:
|
|
|
177
178
|
for ad in data["shopping_ads"]:
|
|
178
179
|
print(ad["title"], ad["metadata"]["price"], ad["metadata"]["merchant"])
|
|
179
180
|
|
|
181
|
+
# News tab articles (Google News tab, tbm=nws) — metadata["source"] and metadata["published_time"]
|
|
182
|
+
for article in data["news"]:
|
|
183
|
+
print(article["title"], article["url"])
|
|
184
|
+
print(article["metadata"]["source"], article["metadata"]["published_time"])
|
|
185
|
+
|
|
180
186
|
# Metadata
|
|
181
187
|
print(data["search_engine"]) # "google"
|
|
182
188
|
print(data["query"]) # "python web scraping"
|
|
@@ -215,6 +221,10 @@ for post in results.people_saying:
|
|
|
215
221
|
for ad in results.shopping_ads:
|
|
216
222
|
print(ad.title, ad.metadata["price"], ad.metadata["merchant"])
|
|
217
223
|
|
|
224
|
+
for article in results.news:
|
|
225
|
+
print(article.title, article.url)
|
|
226
|
+
print(article.metadata["source"], article.metadata["published_time"])
|
|
227
|
+
|
|
218
228
|
# Convert to JSON or Markdown directly on the model
|
|
219
229
|
json_str = results.to_json()
|
|
220
230
|
json_str = results.to_json(indent=4) # custom indent
|
|
@@ -301,6 +311,7 @@ md_str = results.to_markdown()
|
|
|
301
311
|
}
|
|
302
312
|
}
|
|
303
313
|
],
|
|
314
|
+
"news": [],
|
|
304
315
|
"detection_confidence": 0.95,
|
|
305
316
|
"parsed_at": "2026-02-21T00:00:00Z",
|
|
306
317
|
"metadata": {}
|
|
@@ -361,6 +372,16 @@ I work for a mid-sized company as a Procurement Manager...
|
|
|
361
372
|
**Price:** $51.19
|
|
362
373
|
**Merchant:** Contacts Direct
|
|
363
374
|
**URL:** http://www.google.com/aclk?sa=L&ai=...
|
|
375
|
+
|
|
376
|
+
## News Results
|
|
377
|
+
|
|
378
|
+
### 1. Python web scraping tutorial released
|
|
379
|
+
|
|
380
|
+
**Source:** Real Python · 2 days ago
|
|
381
|
+
|
|
382
|
+
Learn how to scrape websites with Python using BeautifulSoup and Requests...
|
|
383
|
+
|
|
384
|
+
**URL:** https://realpython.com/python-web-scraping/
|
|
364
385
|
```
|
|
365
386
|
|
|
366
387
|
---
|
|
@@ -80,6 +80,7 @@ pip install search-parser
|
|
|
80
80
|
| Jobs | `jobs` | ✓ | — | — |
|
|
81
81
|
| Discussions and forums | `discussions` | ✓ | — | — |
|
|
82
82
|
| Shopping ads | `shopping_ads` | ✓ | — | — |
|
|
83
|
+
| News tab articles | `news` | ✓ | — | — |
|
|
83
84
|
|
|
84
85
|
---
|
|
85
86
|
|
|
@@ -134,6 +135,11 @@ for disc in data["discussions"]:
|
|
|
134
135
|
for ad in data["shopping_ads"]:
|
|
135
136
|
print(ad["title"], ad["metadata"]["price"], ad["metadata"]["merchant"])
|
|
136
137
|
|
|
138
|
+
# News tab articles (Google News tab, tbm=nws) — metadata["source"] and metadata["published_time"]
|
|
139
|
+
for article in data["news"]:
|
|
140
|
+
print(article["title"], article["url"])
|
|
141
|
+
print(article["metadata"]["source"], article["metadata"]["published_time"])
|
|
142
|
+
|
|
137
143
|
# Metadata
|
|
138
144
|
print(data["search_engine"]) # "google"
|
|
139
145
|
print(data["query"]) # "python web scraping"
|
|
@@ -172,6 +178,10 @@ for post in results.people_saying:
|
|
|
172
178
|
for ad in results.shopping_ads:
|
|
173
179
|
print(ad.title, ad.metadata["price"], ad.metadata["merchant"])
|
|
174
180
|
|
|
181
|
+
for article in results.news:
|
|
182
|
+
print(article.title, article.url)
|
|
183
|
+
print(article.metadata["source"], article.metadata["published_time"])
|
|
184
|
+
|
|
175
185
|
# Convert to JSON or Markdown directly on the model
|
|
176
186
|
json_str = results.to_json()
|
|
177
187
|
json_str = results.to_json(indent=4) # custom indent
|
|
@@ -258,6 +268,7 @@ md_str = results.to_markdown()
|
|
|
258
268
|
}
|
|
259
269
|
}
|
|
260
270
|
],
|
|
271
|
+
"news": [],
|
|
261
272
|
"detection_confidence": 0.95,
|
|
262
273
|
"parsed_at": "2026-02-21T00:00:00Z",
|
|
263
274
|
"metadata": {}
|
|
@@ -318,6 +329,16 @@ I work for a mid-sized company as a Procurement Manager...
|
|
|
318
329
|
**Price:** $51.19
|
|
319
330
|
**Merchant:** Contacts Direct
|
|
320
331
|
**URL:** http://www.google.com/aclk?sa=L&ai=...
|
|
332
|
+
|
|
333
|
+
## News Results
|
|
334
|
+
|
|
335
|
+
### 1. Python web scraping tutorial released
|
|
336
|
+
|
|
337
|
+
**Source:** Real Python · 2 days ago
|
|
338
|
+
|
|
339
|
+
Learn how to scrape websites with Python using BeautifulSoup and Requests...
|
|
340
|
+
|
|
341
|
+
**URL:** https://realpython.com/python-web-scraping/
|
|
321
342
|
```
|
|
322
343
|
|
|
323
344
|
---
|
|
@@ -106,6 +106,12 @@ for ad in results.shopping_ads:
|
|
|
106
106
|
print(ad.title)
|
|
107
107
|
print(f" {ad.metadata['price']} from {ad.metadata['merchant']}")
|
|
108
108
|
print(f" {ad.url}")
|
|
109
|
+
|
|
110
|
+
# News tab articles — metadata["source"] (publisher) and metadata["published_time"]
|
|
111
|
+
for article in results.news:
|
|
112
|
+
print(article.title)
|
|
113
|
+
print(f" {article.metadata['source']} · {article.metadata['published_time']}")
|
|
114
|
+
print(f" {article.url}")
|
|
109
115
|
```
|
|
110
116
|
|
|
111
117
|
## Using to_json() and to_markdown()
|
|
@@ -102,6 +102,13 @@ for ad in data["shopping_ads"]:
|
|
|
102
102
|
print(ad["title"])
|
|
103
103
|
print(" Price:", ad["metadata"]["price"])
|
|
104
104
|
print(" Merchant:", ad["metadata"]["merchant"])
|
|
105
|
+
|
|
106
|
+
# News tab articles (Google only — tbm=nws pages)
|
|
107
|
+
for article in data["news"]:
|
|
108
|
+
print(article["title"])
|
|
109
|
+
print(" URL:", article["url"])
|
|
110
|
+
print(" Source:", article["metadata"]["source"])
|
|
111
|
+
print(" Published:", article["metadata"]["published_time"])
|
|
105
112
|
```
|
|
106
113
|
|
|
107
114
|
## Specifying the Engine
|
|
@@ -105,6 +105,14 @@ for ad in data["shopping_ads"]:
|
|
|
105
105
|
print(" Price:", ad["metadata"]["price"])
|
|
106
106
|
print(" Merchant:", ad["metadata"]["merchant"])
|
|
107
107
|
|
|
108
|
+
# News tab articles (list) — Google only (tbm=nws pages)
|
|
109
|
+
# Each entry has metadata["source"] (publisher) and metadata["published_time"]
|
|
110
|
+
for article in data["news"]:
|
|
111
|
+
print("News:", article["title"])
|
|
112
|
+
print(" URL:", article["url"])
|
|
113
|
+
print(" Source:", article["metadata"]["source"])
|
|
114
|
+
print(" Published:", article["metadata"]["published_time"])
|
|
115
|
+
|
|
108
116
|
# Metadata
|
|
109
117
|
print(data["query"]) # "python web scraping"
|
|
110
118
|
print(data["total_results"]) # 26200000 or None
|
|
@@ -145,6 +153,10 @@ for disc in results.discussions:
|
|
|
145
153
|
for ad in results.shopping_ads:
|
|
146
154
|
print(ad.title, ad.metadata["price"], ad.metadata["merchant"])
|
|
147
155
|
|
|
156
|
+
for article in results.news:
|
|
157
|
+
print(article.title, article.url)
|
|
158
|
+
print(article.metadata["source"], article.metadata["published_time"])
|
|
159
|
+
|
|
148
160
|
# Serialize without going through SearchParser
|
|
149
161
|
json_str = results.to_json() # JSON string, indent=2 by default
|
|
150
162
|
json_str = results.to_json(indent=4) # custom indent
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "search-parser"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.2"
|
|
8
8
|
description = "Parse Google, Bing, and DuckDuckGo HTML search results into JSON, Markdown, or dict — with auto-detection"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -29,7 +29,7 @@ dependencies = [
|
|
|
29
29
|
"lxml>=5.0.0",
|
|
30
30
|
"markdownify>=0.11.0",
|
|
31
31
|
"pydantic>=2.0.0",
|
|
32
|
-
"pytest (>=
|
|
32
|
+
"pytest (>=7.4.0)",
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Search Engine Parser - Parse search engine HTML results into structured data."""
|
|
2
|
+
|
|
3
|
+
from search_parser.__version__ import __version__
|
|
4
|
+
from search_parser.core.models import SearchResult, SearchResults
|
|
5
|
+
from search_parser.core.parser import SearchParser
|
|
6
|
+
from search_parser.parsers.google_finance import GoogleFinanceParser
|
|
7
|
+
from search_parser.scrapers.google_finance import (
|
|
8
|
+
ChartData,
|
|
9
|
+
ChartPoint,
|
|
10
|
+
CompanyInfo,
|
|
11
|
+
FinancialQuote,
|
|
12
|
+
FinancialStatement,
|
|
13
|
+
GoogleFinanceData,
|
|
14
|
+
GoogleFinanceScraper,
|
|
15
|
+
NewsItem,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"__version__",
|
|
20
|
+
"SearchParser",
|
|
21
|
+
"SearchResult",
|
|
22
|
+
"SearchResults",
|
|
23
|
+
"GoogleFinanceParser",
|
|
24
|
+
"GoogleFinanceScraper",
|
|
25
|
+
"GoogleFinanceData",
|
|
26
|
+
"FinancialQuote",
|
|
27
|
+
"CompanyInfo",
|
|
28
|
+
"ChartData",
|
|
29
|
+
"ChartPoint",
|
|
30
|
+
"NewsItem",
|
|
31
|
+
"FinancialStatement",
|
|
32
|
+
]
|
|
@@ -44,6 +44,7 @@ class SearchEngineDetector:
|
|
|
44
44
|
candidates: list[DetectionResult] = []
|
|
45
45
|
|
|
46
46
|
for check in [
|
|
47
|
+
self._check_google_finance,
|
|
47
48
|
self._check_meta_tags,
|
|
48
49
|
self._check_dom_structure,
|
|
49
50
|
self._check_url_patterns,
|
|
@@ -60,6 +61,16 @@ class SearchEngineDetector:
|
|
|
60
61
|
return None
|
|
61
62
|
return best
|
|
62
63
|
|
|
64
|
+
def _check_google_finance(self, soup: BeautifulSoup) -> DetectionResult | None:
|
|
65
|
+
"""Detect Google Finance pages before the generic Google check."""
|
|
66
|
+
canonical = soup.find("link", attrs={"rel": "canonical"})
|
|
67
|
+
if isinstance(canonical, Tag) and "google.com/finance" in str(canonical.get("href", "")):
|
|
68
|
+
return DetectionResult(engine="google_finance", confidence=0.97)
|
|
69
|
+
for script in soup.find_all("script"):
|
|
70
|
+
if script.string and "AF_dataServiceRequests" in script.string:
|
|
71
|
+
return DetectionResult(engine="google_finance", confidence=0.85)
|
|
72
|
+
return None
|
|
73
|
+
|
|
63
74
|
def _check_meta_tags(self, soup: BeautifulSoup) -> DetectionResult | None:
|
|
64
75
|
"""Check HTML meta tags for search engine identification."""
|
|
65
76
|
# Google: <meta property="og:site_name" content="Google">
|
|
@@ -30,6 +30,12 @@ class SearchResult(BaseModel):
|
|
|
30
30
|
"job",
|
|
31
31
|
"discussion",
|
|
32
32
|
"shopping_ad",
|
|
33
|
+
"stock_quote",
|
|
34
|
+
"company_info",
|
|
35
|
+
"stock_chart",
|
|
36
|
+
"financials",
|
|
37
|
+
"financial_news",
|
|
38
|
+
"local_business",
|
|
33
39
|
] = "organic"
|
|
34
40
|
metadata: dict[str, object] = Field(default_factory=dict)
|
|
35
41
|
|
|
@@ -60,6 +66,14 @@ class SearchResults(BaseModel):
|
|
|
60
66
|
discussions: list[SearchResult] = Field(default_factory=list)
|
|
61
67
|
shopping_ads: list[SearchResult] = Field(default_factory=list)
|
|
62
68
|
news: list[SearchResult] = Field(default_factory=list)
|
|
69
|
+
local_businesses: list[SearchResult] = Field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
# Google Finance dedicated fields
|
|
72
|
+
stock_quote: Optional[SearchResult] = None # noqa: UP045
|
|
73
|
+
company_info: Optional[SearchResult] = None # noqa: UP045
|
|
74
|
+
stock_chart: Optional[SearchResult] = None # noqa: UP045
|
|
75
|
+
financial_statements: Optional[SearchResult] = None # noqa: UP045
|
|
76
|
+
financial_news: list[SearchResult] = Field(default_factory=list)
|
|
63
77
|
|
|
64
78
|
detection_confidence: float = Field(ge=0.0, le=1.0)
|
|
65
79
|
parsed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
@@ -74,7 +88,9 @@ class SearchResults(BaseModel):
|
|
|
74
88
|
Returns:
|
|
75
89
|
JSON string representation of all fields.
|
|
76
90
|
"""
|
|
77
|
-
|
|
91
|
+
import json # noqa: PLC0415
|
|
92
|
+
|
|
93
|
+
return json.dumps(self.model_dump(mode="json"), indent=indent, ensure_ascii=False)
|
|
78
94
|
|
|
79
95
|
def to_markdown(self) -> str:
|
|
80
96
|
"""Render results as a Markdown string suitable for human or LLM consumption.
|
|
@@ -118,7 +118,7 @@ class SearchParser:
|
|
|
118
118
|
elif output_format == "markdown":
|
|
119
119
|
return self._markdown_formatter.format(results)
|
|
120
120
|
elif output_format == "dict":
|
|
121
|
-
result: dict[str, Any] =
|
|
121
|
+
result: dict[str, Any] = results.model_dump(mode="json")
|
|
122
122
|
return result
|
|
123
123
|
else:
|
|
124
124
|
raise ValueError(f"Unknown output format: {output_format!r}")
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
6
|
+
|
|
5
7
|
from search_parser.core.models import SearchResults
|
|
6
8
|
from search_parser.formatters.base import BaseFormatter
|
|
7
9
|
|
|
@@ -18,4 +20,4 @@ class JSONFormatter(BaseFormatter):
|
|
|
18
20
|
Returns:
|
|
19
21
|
JSON string representation.
|
|
20
22
|
"""
|
|
21
|
-
return results.
|
|
23
|
+
return json.dumps(results.model_dump(mode="json"), indent=2, ensure_ascii=False)
|
{search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/formatters/markdown_formatter.py
RENAMED
|
@@ -75,6 +75,12 @@ class MarkdownFormatter(BaseFormatter):
|
|
|
75
75
|
for result in results.jobs:
|
|
76
76
|
lines.extend(self._format_job(result))
|
|
77
77
|
|
|
78
|
+
if results.local_businesses:
|
|
79
|
+
lines.append("## Local Businesses")
|
|
80
|
+
lines.append("")
|
|
81
|
+
for result in results.local_businesses:
|
|
82
|
+
lines.extend(self._format_local_business(result))
|
|
83
|
+
|
|
78
84
|
if results.discussions:
|
|
79
85
|
lines.append("## Discussions and Forums")
|
|
80
86
|
lines.append("")
|
|
@@ -169,6 +175,28 @@ class MarkdownFormatter(BaseFormatter):
|
|
|
169
175
|
lines.append("")
|
|
170
176
|
return lines
|
|
171
177
|
|
|
178
|
+
def _format_local_business(self, result: SearchResult) -> list[str]:
|
|
179
|
+
"""Format a local business pack result."""
|
|
180
|
+
lines: list[str] = []
|
|
181
|
+
sponsored = " (Sponsored)" if result.metadata.get("sponsored") else ""
|
|
182
|
+
lines.append(f"### {result.title}{sponsored}")
|
|
183
|
+
lines.append("")
|
|
184
|
+
if result.metadata.get("rating"):
|
|
185
|
+
rating_str = f"**Rating:** {result.metadata['rating']}"
|
|
186
|
+
if result.metadata.get("reviews"):
|
|
187
|
+
rating_str += f" ({result.metadata['reviews']} reviews)"
|
|
188
|
+
lines.append(rating_str)
|
|
189
|
+
if result.metadata.get("category"):
|
|
190
|
+
lines.append(f"**Category:** {result.metadata['category']}")
|
|
191
|
+
if result.metadata.get("location"):
|
|
192
|
+
lines.append(f"**Location:** {result.metadata['location']}")
|
|
193
|
+
if result.metadata.get("hours"):
|
|
194
|
+
lines.append(f"**Hours:** {result.metadata['hours']}")
|
|
195
|
+
if result.metadata.get("phone"):
|
|
196
|
+
lines.append(f"**Phone:** {result.metadata['phone']}")
|
|
197
|
+
lines.append("")
|
|
198
|
+
return lines
|
|
199
|
+
|
|
172
200
|
def _format_result(self, result: SearchResult) -> list[str]:
|
|
173
201
|
"""Format a generic result."""
|
|
174
202
|
lines: list[str] = []
|
|
@@ -4,12 +4,14 @@ from search_parser.parsers.base import BaseParser
|
|
|
4
4
|
from search_parser.parsers.bing import BingParser
|
|
5
5
|
from search_parser.parsers.duckduckgo import DuckDuckGoParser
|
|
6
6
|
from search_parser.parsers.google import GoogleParser
|
|
7
|
+
from search_parser.parsers.google_finance import GoogleFinanceParser
|
|
7
8
|
|
|
8
9
|
# Registry of all available parsers
|
|
9
10
|
PARSER_REGISTRY: dict[str, type[BaseParser]] = {
|
|
10
11
|
"google": GoogleParser,
|
|
11
12
|
"bing": BingParser,
|
|
12
13
|
"duckduckgo": DuckDuckGoParser,
|
|
14
|
+
"google_finance": GoogleFinanceParser,
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
__all__ = [
|
|
@@ -17,5 +19,6 @@ __all__ = [
|
|
|
17
19
|
"BingParser",
|
|
18
20
|
"DuckDuckGoParser",
|
|
19
21
|
"GoogleParser",
|
|
22
|
+
"GoogleFinanceParser",
|
|
20
23
|
"PARSER_REGISTRY",
|
|
21
24
|
]
|
|
@@ -76,6 +76,7 @@ class GoogleParser(BaseParser):
|
|
|
76
76
|
discussions=self._extract_discussions(soup),
|
|
77
77
|
shopping_ads=self._extract_shopping_ads(soup),
|
|
78
78
|
news=self._extract_news_results(soup),
|
|
79
|
+
local_businesses=self._extract_local_businesses(soup),
|
|
79
80
|
detection_confidence=confidence,
|
|
80
81
|
)
|
|
81
82
|
|
|
@@ -688,6 +689,96 @@ class GoogleParser(BaseParser):
|
|
|
688
689
|
metadata=metadata,
|
|
689
690
|
)
|
|
690
691
|
|
|
692
|
+
def _extract_local_businesses(self, soup: BeautifulSoup) -> list[SearchResult]:
|
|
693
|
+
"""Extract local business pack results (div.cXedhc cards)."""
|
|
694
|
+
results: list[SearchResult] = []
|
|
695
|
+
for item in soup.find_all("div", class_="cXedhc"):
|
|
696
|
+
if not isinstance(item, Tag):
|
|
697
|
+
continue
|
|
698
|
+
result = self._parse_local_business(item)
|
|
699
|
+
if result:
|
|
700
|
+
results.append(result)
|
|
701
|
+
return results
|
|
702
|
+
|
|
703
|
+
def _parse_local_business(self, item: Tag) -> SearchResult | None:
|
|
704
|
+
"""Parse a single local business pack card (div.cXedhc)."""
|
|
705
|
+
name_el = item.find("span", class_="OSrXXb")
|
|
706
|
+
if not isinstance(name_el, Tag):
|
|
707
|
+
return None
|
|
708
|
+
name = clean_text(name_el.get_text())
|
|
709
|
+
if not name:
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
rating_el = item.find("span", class_="yi40Hd")
|
|
713
|
+
rating = clean_text(rating_el.get_text()) if isinstance(rating_el, Tag) else None
|
|
714
|
+
|
|
715
|
+
reviews_el = item.find("span", class_="RDApEe")
|
|
716
|
+
reviews_raw = clean_text(reviews_el.get_text()) if isinstance(reviews_el, Tag) else None
|
|
717
|
+
reviews = re.sub(r"[()]", "", reviews_raw).strip() if reviews_raw else None
|
|
718
|
+
|
|
719
|
+
sponsored_el = item.find("span", class_="gghBu")
|
|
720
|
+
sponsored = isinstance(sponsored_el, Tag) and "sponsored" in sponsored_el.get_text().lower()
|
|
721
|
+
|
|
722
|
+
# Un-classed leaf divs carry category, location, hours, and phone
|
|
723
|
+
leaf_divs = [
|
|
724
|
+
d
|
|
725
|
+
for d in item.find_all("div")
|
|
726
|
+
if isinstance(d, Tag) and not d.get("class") and not d.find("div") and d.get_text(strip=True)
|
|
727
|
+
]
|
|
728
|
+
|
|
729
|
+
category: str | None = None
|
|
730
|
+
location: str | None = None
|
|
731
|
+
hours: str | None = None
|
|
732
|
+
phone: str | None = None
|
|
733
|
+
|
|
734
|
+
for div in leaf_divs:
|
|
735
|
+
text = div.get_text(separator="|", strip=True)
|
|
736
|
+
# "4.9|(813)|· Personal injury attorney" — category follows "· "
|
|
737
|
+
cat_match = re.search(r"·\s+(.+)$", text.replace("|", " "))
|
|
738
|
+
if cat_match and not category:
|
|
739
|
+
candidate = cat_match.group(1).strip()
|
|
740
|
+
if not re.search(r"\d+\s*(hours?|year|min)", candidate, re.I):
|
|
741
|
+
category = candidate
|
|
742
|
+
continue
|
|
743
|
+
# "15+ years in business · Chantilly, VA" — split on " · "
|
|
744
|
+
if "years in business" in text or re.search(r"\d+\+?\s+year", text):
|
|
745
|
+
parts = text.replace("|", " ").split(" · ", 1)
|
|
746
|
+
if len(parts) == 2:
|
|
747
|
+
location = parts[1].strip()
|
|
748
|
+
continue
|
|
749
|
+
# "Open 24 hours · (703) 952-3191"
|
|
750
|
+
if re.search(r"open|closed|\d{1,2}:\d{2}", text, re.I):
|
|
751
|
+
parts = text.replace("|", " ").split("· ", 1)
|
|
752
|
+
hours = parts[0].strip()
|
|
753
|
+
if len(parts) == 2:
|
|
754
|
+
phone = parts[1].strip()
|
|
755
|
+
continue
|
|
756
|
+
|
|
757
|
+
metadata: dict[str, object] = {}
|
|
758
|
+
if rating:
|
|
759
|
+
metadata["rating"] = rating
|
|
760
|
+
if reviews:
|
|
761
|
+
metadata["reviews"] = reviews
|
|
762
|
+
if category:
|
|
763
|
+
metadata["category"] = category
|
|
764
|
+
if location:
|
|
765
|
+
metadata["location"] = location
|
|
766
|
+
if hours:
|
|
767
|
+
metadata["hours"] = hours
|
|
768
|
+
if phone:
|
|
769
|
+
metadata["phone"] = phone
|
|
770
|
+
if sponsored:
|
|
771
|
+
metadata["sponsored"] = True
|
|
772
|
+
|
|
773
|
+
return SearchResult(
|
|
774
|
+
title=name,
|
|
775
|
+
url="",
|
|
776
|
+
description=None,
|
|
777
|
+
position=0,
|
|
778
|
+
result_type="local_business",
|
|
779
|
+
metadata=metadata,
|
|
780
|
+
)
|
|
781
|
+
|
|
691
782
|
def _extract_featured_snippet(self, soup: BeautifulSoup) -> SearchResult | None:
|
|
692
783
|
"""Extract featured snippet if present."""
|
|
693
784
|
snippet_container = soup.find("div", class_="xpdopen")
|
|
@@ -82,3 +82,23 @@ def google_seven_cloak_html() -> str:
|
|
|
82
82
|
@pytest.fixture()
|
|
83
83
|
def google_news_tab_html() -> str:
|
|
84
84
|
return (FIXTURES_DIR / "google" / "news_tab_search.html").read_text()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.fixture()
|
|
88
|
+
def google_personal_injury_lawyer_html() -> str:
|
|
89
|
+
return (FIXTURES_DIR / "google" / "personal_injury_lawyer_20260528_203801.html").read_text()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.fixture()
|
|
93
|
+
def google_iphone_15_review_html() -> str:
|
|
94
|
+
return (FIXTURES_DIR / "google" / "iphone_15_review_20260528_204042.html").read_text()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.fixture()
|
|
98
|
+
def google_finance_quote_html() -> str:
|
|
99
|
+
return (FIXTURES_DIR / "google_finance" / "quote.html").read_text()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.fixture()
|
|
103
|
+
def google_finance_crypto_html() -> str:
|
|
104
|
+
return (FIXTURES_DIR / "google_finance" / "crypto.html").read_text()
|