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.
Files changed (83) hide show
  1. {search_parser-0.5.0 → search_parser-0.5.2}/CHANGELOG.md +71 -0
  2. {search_parser-0.5.0 → search_parser-0.5.2}/PKG-INFO +23 -2
  3. {search_parser-0.5.0 → search_parser-0.5.2}/README.md +21 -0
  4. {search_parser-0.5.0 → search_parser-0.5.2}/docs/examples/advanced_usage.md +6 -0
  5. {search_parser-0.5.0 → search_parser-0.5.2}/docs/examples/basic_usage.md +7 -0
  6. {search_parser-0.5.0 → search_parser-0.5.2}/docs/getting_started.md +12 -0
  7. {search_parser-0.5.0 → search_parser-0.5.2}/pyproject.toml +2 -2
  8. search_parser-0.5.2/src/search_parser/__init__.py +32 -0
  9. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/__version__.py +1 -1
  10. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/core/detector.py +11 -0
  11. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/core/models.py +17 -1
  12. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/core/parser.py +1 -1
  13. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/formatters/json_formatter.py +3 -1
  14. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/formatters/markdown_formatter.py +28 -0
  15. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/parsers/__init__.py +3 -0
  16. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/parsers/google.py +91 -0
  17. {search_parser-0.5.0 → search_parser-0.5.2}/tests/conftest.py +20 -0
  18. search_parser-0.5.2/tests/fixtures/google/contact_lens_weekly_20260528_203836.html +47 -0
  19. search_parser-0.5.2/tests/fixtures/google/home_loan_interest_rates_20260528_203450.html +60 -0
  20. search_parser-0.5.2/tests/fixtures/google/iphone_15_review_20260528_204042.html +76 -0
  21. search_parser-0.5.2/tests/fixtures/google/personal_injury_lawyer_20260528_203801.html +40 -0
  22. search_parser-0.5.2/tests/fixtures/google/search_seven_cloak.html +95 -0
  23. search_parser-0.5.2/tests/fixtures/google_finance/crypto.html +23 -0
  24. search_parser-0.5.2/tests/fixtures/google_finance/quote.html +31 -0
  25. {search_parser-0.5.0 → search_parser-0.5.2}/tests/unit/test_google_parser.py +102 -0
  26. {search_parser-0.5.0 → search_parser-0.5.2}/uv.lock +132 -134
  27. search_parser-0.5.0/src/search_parser/__init__.py +0 -12
  28. {search_parser-0.5.0 → search_parser-0.5.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  29. {search_parser-0.5.0 → search_parser-0.5.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  30. {search_parser-0.5.0 → search_parser-0.5.2}/.github/dependabot.yml +0 -0
  31. {search_parser-0.5.0 → search_parser-0.5.2}/.github/pull_request_template.md +0 -0
  32. {search_parser-0.5.0 → search_parser-0.5.2}/.github/workflows/codeql.yml +0 -0
  33. {search_parser-0.5.0 → search_parser-0.5.2}/.github/workflows/coverage-badge.yml +0 -0
  34. {search_parser-0.5.0 → search_parser-0.5.2}/.github/workflows/lint.yml +0 -0
  35. {search_parser-0.5.0 → search_parser-0.5.2}/.github/workflows/publish.yml +0 -0
  36. {search_parser-0.5.0 → search_parser-0.5.2}/.github/workflows/test.yml +0 -0
  37. {search_parser-0.5.0 → search_parser-0.5.2}/.gitignore +0 -0
  38. {search_parser-0.5.0 → search_parser-0.5.2}/.pre-commit-config.yaml +0 -0
  39. {search_parser-0.5.0 → search_parser-0.5.2}/CLAUDE.md +0 -0
  40. {search_parser-0.5.0 → search_parser-0.5.2}/CODE_OF_CONDUCT.md +0 -0
  41. {search_parser-0.5.0 → search_parser-0.5.2}/CONTRIBUTING.md +0 -0
  42. {search_parser-0.5.0 → search_parser-0.5.2}/LICENSE +0 -0
  43. {search_parser-0.5.0 → search_parser-0.5.2}/docs/CNAME +0 -0
  44. {search_parser-0.5.0 → search_parser-0.5.2}/docs/adding_search_engine.md +0 -0
  45. {search_parser-0.5.0 → search_parser-0.5.2}/docs/api_reference.md +0 -0
  46. {search_parser-0.5.0 → search_parser-0.5.2}/docs/contributing.md +0 -0
  47. {search_parser-0.5.0 → search_parser-0.5.2}/docs/index.md +0 -0
  48. {search_parser-0.5.0 → search_parser-0.5.2}/examples/basic_parsing.py +0 -0
  49. {search_parser-0.5.0 → search_parser-0.5.2}/examples/batch_processing.py +0 -0
  50. {search_parser-0.5.0 → search_parser-0.5.2}/examples/custom_formatter.py +0 -0
  51. {search_parser-0.5.0 → search_parser-0.5.2}/mkdocs.yml +0 -0
  52. {search_parser-0.5.0 → search_parser-0.5.2}/scripts/update_fixtures.py +0 -0
  53. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/cli.py +0 -0
  54. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/core/__init__.py +0 -0
  55. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/exceptions.py +0 -0
  56. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/formatters/__init__.py +0 -0
  57. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/formatters/base.py +0 -0
  58. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/parsers/base.py +0 -0
  59. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/parsers/bing.py +0 -0
  60. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/parsers/duckduckgo.py +0 -0
  61. {search_parser-0.5.0 → search_parser-0.5.2}/src/search_parser/utils.py +0 -0
  62. {search_parser-0.5.0 → search_parser-0.5.2}/tests/__init__.py +0 -0
  63. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/bing/organic_results.html +0 -0
  64. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/bing/search_github_repos.html +0 -0
  65. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/duckduckgo/organic_results.html +0 -0
  66. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/duckduckgo/search_github_repos.html +0 -0
  67. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/featured_snippet.html +0 -0
  68. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/knowledge_panel.html +0 -0
  69. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/need_turn_on_javascript.html +0 -0
  70. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/news_tab_search.html +0 -0
  71. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/organic_results.html +0 -0
  72. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/search_best_employee_scheduling_app.html +0 -0
  73. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/search_github_repos.html +0 -0
  74. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/search_python_web_scraping.html +0 -0
  75. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/supply-chain-director-jobs.html +0 -0
  76. {search_parser-0.5.0 → search_parser-0.5.2}/tests/fixtures/google/weekly_contacts_with_mobile.html +0 -0
  77. {search_parser-0.5.0 → search_parser-0.5.2}/tests/test_cli.py +0 -0
  78. {search_parser-0.5.0 → search_parser-0.5.2}/tests/test_coverage_boost.py +0 -0
  79. {search_parser-0.5.0 → search_parser-0.5.2}/tests/test_formatters.py +0 -0
  80. {search_parser-0.5.0 → search_parser-0.5.2}/tests/unit/__init__.py +0 -0
  81. {search_parser-0.5.0 → search_parser-0.5.2}/tests/unit/test_bing_parser.py +0 -0
  82. {search_parser-0.5.0 → search_parser-0.5.2}/tests/unit/test_detector.py +0 -0
  83. {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.0
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<10.0.0,>=9.0.2
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.0"
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 (>=9.0.2,<10.0.0)",
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
+ ]
@@ -1,3 +1,3 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.5.3"
@@ -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
- return self.model_dump_json(indent=indent)
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] = json.loads(results.model_dump_json())
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.model_dump_json(indent=2)
23
+ return json.dumps(results.model_dump(mode="json"), indent=2, ensure_ascii=False)
@@ -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()