search-parser 0.5.2__tar.gz → 0.5.4__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 (87) hide show
  1. {search_parser-0.5.2 → search_parser-0.5.4}/CHANGELOG.md +23 -1
  2. {search_parser-0.5.2 → search_parser-0.5.4}/PKG-INFO +1 -1
  3. {search_parser-0.5.2 → search_parser-0.5.4}/docs/examples/advanced_usage.md +9 -0
  4. {search_parser-0.5.2 → search_parser-0.5.4}/docs/examples/basic_usage.md +11 -0
  5. {search_parser-0.5.2 → search_parser-0.5.4}/docs/index.md +5 -0
  6. {search_parser-0.5.2 → search_parser-0.5.4}/pyproject.toml +1 -1
  7. search_parser-0.5.4/src/search_parser/parsers/google_finance.py +403 -0
  8. search_parser-0.5.4/src/search_parser/scrapers/__init__.py +23 -0
  9. search_parser-0.5.4/src/search_parser/scrapers/google_finance.py +393 -0
  10. search_parser-0.5.4/tests/unit/test_google_finance_parser.py +346 -0
  11. search_parser-0.5.4/uv.lock +1646 -0
  12. search_parser-0.5.2/uv.lock +0 -1566
  13. {search_parser-0.5.2 → search_parser-0.5.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {search_parser-0.5.2 → search_parser-0.5.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  15. {search_parser-0.5.2 → search_parser-0.5.4}/.github/dependabot.yml +0 -0
  16. {search_parser-0.5.2 → search_parser-0.5.4}/.github/pull_request_template.md +0 -0
  17. {search_parser-0.5.2 → search_parser-0.5.4}/.github/workflows/codeql.yml +0 -0
  18. {search_parser-0.5.2 → search_parser-0.5.4}/.github/workflows/coverage-badge.yml +0 -0
  19. {search_parser-0.5.2 → search_parser-0.5.4}/.github/workflows/lint.yml +0 -0
  20. {search_parser-0.5.2 → search_parser-0.5.4}/.github/workflows/publish.yml +0 -0
  21. {search_parser-0.5.2 → search_parser-0.5.4}/.github/workflows/test.yml +0 -0
  22. {search_parser-0.5.2 → search_parser-0.5.4}/.gitignore +0 -0
  23. {search_parser-0.5.2 → search_parser-0.5.4}/.pre-commit-config.yaml +0 -0
  24. {search_parser-0.5.2 → search_parser-0.5.4}/CLAUDE.md +0 -0
  25. {search_parser-0.5.2 → search_parser-0.5.4}/CODE_OF_CONDUCT.md +0 -0
  26. {search_parser-0.5.2 → search_parser-0.5.4}/CONTRIBUTING.md +0 -0
  27. {search_parser-0.5.2 → search_parser-0.5.4}/LICENSE +0 -0
  28. {search_parser-0.5.2 → search_parser-0.5.4}/README.md +0 -0
  29. {search_parser-0.5.2 → search_parser-0.5.4}/docs/CNAME +0 -0
  30. {search_parser-0.5.2 → search_parser-0.5.4}/docs/adding_search_engine.md +0 -0
  31. {search_parser-0.5.2 → search_parser-0.5.4}/docs/api_reference.md +0 -0
  32. {search_parser-0.5.2 → search_parser-0.5.4}/docs/contributing.md +0 -0
  33. {search_parser-0.5.2 → search_parser-0.5.4}/docs/getting_started.md +0 -0
  34. {search_parser-0.5.2 → search_parser-0.5.4}/examples/basic_parsing.py +0 -0
  35. {search_parser-0.5.2 → search_parser-0.5.4}/examples/batch_processing.py +0 -0
  36. {search_parser-0.5.2 → search_parser-0.5.4}/examples/custom_formatter.py +0 -0
  37. {search_parser-0.5.2 → search_parser-0.5.4}/mkdocs.yml +0 -0
  38. {search_parser-0.5.2 → search_parser-0.5.4}/scripts/update_fixtures.py +0 -0
  39. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/__init__.py +0 -0
  40. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/__version__.py +0 -0
  41. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/cli.py +0 -0
  42. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/core/__init__.py +0 -0
  43. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/core/detector.py +0 -0
  44. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/core/models.py +0 -0
  45. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/core/parser.py +0 -0
  46. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/exceptions.py +0 -0
  47. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/formatters/__init__.py +0 -0
  48. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/formatters/base.py +0 -0
  49. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/formatters/json_formatter.py +0 -0
  50. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/formatters/markdown_formatter.py +0 -0
  51. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/parsers/__init__.py +0 -0
  52. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/parsers/base.py +0 -0
  53. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/parsers/bing.py +0 -0
  54. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/parsers/duckduckgo.py +0 -0
  55. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/parsers/google.py +0 -0
  56. {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/utils.py +0 -0
  57. {search_parser-0.5.2 → search_parser-0.5.4}/tests/__init__.py +0 -0
  58. {search_parser-0.5.2 → search_parser-0.5.4}/tests/conftest.py +0 -0
  59. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/bing/organic_results.html +0 -0
  60. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/bing/search_github_repos.html +0 -0
  61. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/duckduckgo/organic_results.html +0 -0
  62. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/duckduckgo/search_github_repos.html +0 -0
  63. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/contact_lens_weekly_20260528_203836.html +0 -0
  64. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/featured_snippet.html +0 -0
  65. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/home_loan_interest_rates_20260528_203450.html +0 -0
  66. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/iphone_15_review_20260528_204042.html +0 -0
  67. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/knowledge_panel.html +0 -0
  68. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/need_turn_on_javascript.html +0 -0
  69. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/news_tab_search.html +0 -0
  70. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/organic_results.html +0 -0
  71. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/personal_injury_lawyer_20260528_203801.html +0 -0
  72. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/search_best_employee_scheduling_app.html +0 -0
  73. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/search_github_repos.html +0 -0
  74. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/search_python_web_scraping.html +0 -0
  75. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/search_seven_cloak.html +0 -0
  76. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/supply-chain-director-jobs.html +0 -0
  77. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/weekly_contacts_with_mobile.html +0 -0
  78. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google_finance/crypto.html +0 -0
  79. {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google_finance/quote.html +0 -0
  80. {search_parser-0.5.2 → search_parser-0.5.4}/tests/test_cli.py +0 -0
  81. {search_parser-0.5.2 → search_parser-0.5.4}/tests/test_coverage_boost.py +0 -0
  82. {search_parser-0.5.2 → search_parser-0.5.4}/tests/test_formatters.py +0 -0
  83. {search_parser-0.5.2 → search_parser-0.5.4}/tests/unit/__init__.py +0 -0
  84. {search_parser-0.5.2 → search_parser-0.5.4}/tests/unit/test_bing_parser.py +0 -0
  85. {search_parser-0.5.2 → search_parser-0.5.4}/tests/unit/test_detector.py +0 -0
  86. {search_parser-0.5.2 → search_parser-0.5.4}/tests/unit/test_duckduckgo_parser.py +0 -0
  87. {search_parser-0.5.2 → search_parser-0.5.4}/tests/unit/test_google_parser.py +0 -0
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ---
11
11
 
12
+ ## [0.5.4] - 2026-06-06
13
+
14
+ ### Added
15
+
16
+ - `local_businesses: list[SearchResult]` field on `SearchResults` for Google local business pack results
17
+ - `"local_business"` added to the `result_type` Literal in `SearchResult`
18
+ - `_extract_local_businesses` and `_parse_local_business` methods on `GoogleParser` — extracts name, rating, review count, category, location, hours, phone, and sponsored flag from `div.cXedhc` cards
19
+ - Markdown formatter renders `## Local Businesses` section with all available metadata fields
20
+ - New fixtures (`personal_injury_lawyer_20260528_203801.html`, `iphone_15_review_20260528_204042.html`) and 16 new unit tests
21
+
22
+ ### Fixed
23
+
24
+ - `search_parser.parsers.google_finance` and `search_parser.scrapers` were missing from the built package because the source files had never been committed to git — any app importing `search_parser` crashed at startup with `ModuleNotFoundError`
25
+
26
+ ---
27
+
12
28
  ## [0.5.2] - 2026-04-26
13
29
 
14
30
  ### Added
@@ -190,7 +206,13 @@ sponsored = results.sponsored # list[SearchResult]
190
206
  - CI/CD workflows for testing, linting, coverage, and publishing.
191
207
  - Pre-commit hooks for ruff and mypy.
192
208
 
193
- [Unreleased]: https://github.com/getlinksc/search-parser/compare/v0.4.0...HEAD
209
+ [Unreleased]: https://github.com/getlinksc/search-parser/compare/v0.5.4...HEAD
210
+ [0.5.4]: https://github.com/getlinksc/search-parser/compare/v0.5.3...v0.5.4
211
+ [0.5.3]: https://github.com/getlinksc/search-parser/compare/v0.5.2...v0.5.3
212
+ [0.5.2]: https://github.com/getlinksc/search-parser/compare/v0.5.1...v0.5.2
213
+ [0.5.1]: https://github.com/getlinksc/search-parser/compare/v0.5.0...v0.5.1
214
+ [0.5.0]: https://github.com/getlinksc/search-parser/compare/v0.4.1...v0.5.0
215
+ [0.4.1]: https://github.com/getlinksc/search-parser/compare/v0.4.0...v0.4.1
194
216
  [0.4.0]: https://github.com/getlinksc/search-parser/compare/v0.3.0...v0.4.0
195
217
  [0.3.0]: https://github.com/getlinksc/search-parser/compare/v0.2.0...v0.3.0
196
218
  [0.2.0]: https://github.com/getlinksc/search-parser/compare/v0.1.0...v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: search-parser
3
- Version: 0.5.2
3
+ Version: 0.5.4
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
@@ -112,6 +112,15 @@ for article in results.news:
112
112
  print(article.title)
113
113
  print(f" {article.metadata['source']} · {article.metadata['published_time']}")
114
114
  print(f" {article.url}")
115
+
116
+ # Local business pack — metadata includes rating, reviews, category, location, hours, phone, sponsored
117
+ for biz in results.local_businesses:
118
+ print(biz.title)
119
+ print(f" {biz.metadata['rating']} ({biz.metadata.get('reviews')} reviews) · {biz.metadata.get('category')}")
120
+ print(f" {biz.metadata.get('location')} · {biz.metadata.get('phone')}")
121
+ print(f" {biz.metadata.get('hours')}")
122
+ if biz.metadata.get("sponsored"):
123
+ print(" (sponsored)")
115
124
  ```
116
125
 
117
126
  ## Using to_json() and to_markdown()
@@ -109,6 +109,17 @@ for article in data["news"]:
109
109
  print(" URL:", article["url"])
110
110
  print(" Source:", article["metadata"]["source"])
111
111
  print(" Published:", article["metadata"]["published_time"])
112
+
113
+ # Local business pack (Google only)
114
+ for biz in data["local_businesses"]:
115
+ print(biz["title"])
116
+ print(" Rating:", biz["metadata"].get("rating"), f"({biz['metadata'].get('reviews')} reviews)")
117
+ print(" Category:", biz["metadata"].get("category"))
118
+ print(" Location:", biz["metadata"].get("location"))
119
+ print(" Phone:", biz["metadata"].get("phone"))
120
+ print(" Hours:", biz["metadata"].get("hours"))
121
+ if biz["metadata"].get("sponsored"):
122
+ print(" (sponsored)")
112
123
  ```
113
124
 
114
125
  ## Specifying the Engine
@@ -44,6 +44,10 @@ for q in data["people_also_ask"]:
44
44
  # Shopping ads (Google only)
45
45
  for ad in data["shopping_ads"]:
46
46
  print(ad["title"], ad["metadata"]["price"], ad["metadata"]["merchant"])
47
+
48
+ # Local business pack (Google only)
49
+ for biz in data["local_businesses"]:
50
+ print(biz["title"], biz["metadata"].get("rating"), biz["metadata"].get("phone"))
47
51
  ```
48
52
 
49
53
  Or work directly with the typed model and use `to_json()` / `to_markdown()`:
@@ -76,3 +80,4 @@ md_str = results.to_markdown()
76
80
  | Jobs | `jobs` | ✓ | — | — |
77
81
  | Discussions and forums | `discussions` | ✓ | — | — |
78
82
  | Shopping ads | `shopping_ads` | ✓ | — | — |
83
+ | Local business pack | `local_businesses` | ✓ | — | — |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "search-parser"
7
- version = "0.5.2"
7
+ version = "0.5.4"
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"
@@ -0,0 +1,403 @@
1
+ """Google Finance HTML parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ from typing import Any
9
+ from urllib.parse import unquote
10
+
11
+ from bs4 import BeautifulSoup, Tag
12
+
13
+ from search_parser.core.models import SearchResult, SearchResults
14
+ from search_parser.parsers.base import BaseParser
15
+ from search_parser.utils import clean_text, make_soup
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _TYPE_MAP: dict[int, str] = {0: "stock", 1: "index", 3: "crypto", 5: "etf"}
20
+
21
+
22
+ class GoogleFinanceParser(BaseParser):
23
+ """Parser for Google Finance HTML pages.
24
+
25
+ Extracts embedded ``AF_initDataCallback`` data blocks to return structured
26
+ quote, company, chart, news, and financial-statement data. Supports stocks,
27
+ ETFs, indices, crypto, and FX pairs without making any network calls.
28
+ """
29
+
30
+ @property
31
+ def engine_name(self) -> str:
32
+ return "google_finance"
33
+
34
+ def can_parse(self, soup: BeautifulSoup) -> float:
35
+ """Detect Google Finance pages by canonical URL and embedded script markers."""
36
+ confidence = 0.0
37
+
38
+ canonical = soup.find("link", attrs={"rel": "canonical"})
39
+ if isinstance(canonical, Tag) and "google.com/finance" in str(canonical.get("href", "")):
40
+ confidence += 0.6
41
+
42
+ script_text = " ".join(s.string or "" for s in soup.find_all("script") if s.string)
43
+ if "AF_dataServiceRequests" in script_text:
44
+ confidence += 0.25
45
+ if "AF_initDataCallback" in script_text:
46
+ confidence += 0.15
47
+
48
+ return min(confidence, 1.0)
49
+
50
+ def extract_query(self, soup: BeautifulSoup) -> str | None:
51
+ """Extract the ticker symbol from the canonical URL or page title."""
52
+ canonical = soup.find("link", attrs={"rel": "canonical"})
53
+ if isinstance(canonical, Tag):
54
+ m = re.search(r"/finance/quote/([^?#/]+)", str(canonical.get("href", "")))
55
+ if m:
56
+ return unquote(m.group(1))
57
+
58
+ title_tag = soup.find("title")
59
+ if isinstance(title_tag, Tag) and title_tag.string:
60
+ m = re.match(r"^([A-Z0-9.\-]+(?::[A-Z0-9]+)?)\s*[-–]", title_tag.string.strip())
61
+ if m:
62
+ return m.group(1).strip()
63
+
64
+ return None
65
+
66
+ def parse(self, html: str) -> SearchResults:
67
+ """Parse a Google Finance HTML page into structured results.
68
+
69
+ Args:
70
+ html: Raw HTML from a ``google.com/finance/quote/`` page.
71
+
72
+ Returns:
73
+ :class:`SearchResults` with ``stock_quote``, ``company_info``,
74
+ ``stock_chart``, ``financial_statements``, and ``financial_news``
75
+ fields populated where data is available.
76
+ """
77
+ soup = make_soup(html)
78
+ confidence = self.can_parse(soup)
79
+ query = self.extract_query(soup)
80
+
81
+ script_text = "\n".join(s.string or "" for s in soup.find_all("script") if s.string)
82
+ method_map = _extract_method_map(script_text)
83
+ data_blocks = _extract_data_blocks(script_text)
84
+
85
+ stock_quote: SearchResult | None = None
86
+ company_info: SearchResult | None = None
87
+ stock_chart: SearchResult | None = None
88
+ financial_statements: SearchResult | None = None
89
+ financial_news: list[SearchResult] = []
90
+ position = 1
91
+ seen: set[str] = set()
92
+
93
+ for key, data in data_blocks:
94
+ method_id = method_map.get(key)
95
+ if not method_id or method_id in seen:
96
+ continue
97
+ seen.add(method_id)
98
+
99
+ if method_id == "xh8wxf":
100
+ result = _parse_quote(data, position, query)
101
+ if result:
102
+ stock_quote = result
103
+ position += 1
104
+ elif method_id == "HqGpWd":
105
+ result = _parse_company_info(data, position)
106
+ if result:
107
+ company_info = result
108
+ position += 1
109
+ elif method_id == "AiCwsd":
110
+ result = _parse_chart(data, position)
111
+ if result:
112
+ stock_chart = result
113
+ position += 1
114
+ elif method_id == "nBEQBc":
115
+ items = _parse_news(data, position)
116
+ financial_news = items
117
+ position += len(items)
118
+ elif method_id == "Pr8h2e":
119
+ result = _parse_financials(data, position)
120
+ if result:
121
+ financial_statements = result
122
+ position += 1
123
+
124
+ return SearchResults(
125
+ search_engine=self.engine_name,
126
+ query=query,
127
+ total_results=None,
128
+ results=[],
129
+ detection_confidence=confidence,
130
+ stock_quote=stock_quote,
131
+ company_info=company_info,
132
+ stock_chart=stock_chart,
133
+ financial_statements=financial_statements,
134
+ financial_news=financial_news,
135
+ )
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Module-level helpers (keep parser methods short)
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ def _extract_method_map(script_text: str) -> dict[str, str]:
144
+ """Return a ``ds:N → RPC-ID`` mapping from ``AF_dataServiceRequests``."""
145
+ method_map: dict[str, str] = {}
146
+ m = re.search(r"AF_dataServiceRequests\s*=\s*\{([\s\S]*?)\};", script_text)
147
+ if not m:
148
+ return method_map
149
+ for entry in re.finditer(r"'(ds:\d+)'\s*:\s*\{id:'([^']+)'", m.group(1)):
150
+ method_map[entry.group(1)] = entry.group(2)
151
+ return method_map
152
+
153
+
154
+ def _extract_data_blocks(script_text: str) -> list[tuple[str, Any]]:
155
+ """Extract every ``AF_initDataCallback`` data blob via bracket counting."""
156
+ results: list[tuple[str, Any]] = []
157
+ pattern = re.compile(r"AF_initDataCallback\(\{key:\s*'(ds:\d+)',\s*hash:\s*'\d+',\s*data:")
158
+ for m in pattern.finditer(script_text):
159
+ raw = _extract_balanced(script_text, m.end())
160
+ if raw is None:
161
+ continue
162
+ try:
163
+ results.append((m.group(1), json.loads(raw)))
164
+ except json.JSONDecodeError:
165
+ pass
166
+ return results
167
+
168
+
169
+ def _extract_balanced(text: str, start: int) -> str | None:
170
+ """Return the balanced JSON value (array or object) starting at *start*."""
171
+ depth = 0
172
+ in_str = False
173
+ escape = False
174
+ i = start
175
+ while i < len(text):
176
+ ch = text[i]
177
+ if escape:
178
+ escape = False
179
+ elif ch == "\\":
180
+ escape = True
181
+ elif ch == '"':
182
+ in_str = not in_str
183
+ elif not in_str:
184
+ if ch in ("[", "{"):
185
+ depth += 1
186
+ elif ch in ("]", "}"):
187
+ depth -= 1
188
+ if depth == 0:
189
+ return text[start : i + 1]
190
+ i += 1
191
+ return None
192
+
193
+
194
+ def _parse_quote(data: Any, position: int, query: str | None) -> SearchResult | None:
195
+ """Build a ``stock_quote`` SearchResult from the xh8wxf data blob."""
196
+ try:
197
+ q: list[Any] = data[0][0][0]
198
+ except (IndexError, TypeError):
199
+ return None
200
+ if not isinstance(q, list):
201
+ return None
202
+
203
+ is_crypto = q[3] == 3
204
+ ticker = str(q[21] or "") if is_crypto else str((q[1] or [""])[0])
205
+ exchange = "" if is_crypto else str((q[1] or ["", ""])[1])
206
+ price_arr: list[float] = q[5] if isinstance(q[5], list) else []
207
+ ah_arr: list[Any] = q[16] if len(q) > 16 and isinstance(q[16], list) else []
208
+ after_hours = (
209
+ {"price": ah_arr[0], "change": ah_arr[1], "change_pct": ah_arr[2]}
210
+ if len(ah_arr) >= 3 and ah_arr[0] is not None
211
+ else None
212
+ )
213
+
214
+ return SearchResult(
215
+ title=str(q[2] or ""),
216
+ url=f"https://www.google.com/finance/quote/{query or ticker}",
217
+ description=f"{ticker}{(':' + exchange) if exchange else ''} — {_TYPE_MAP.get(q[3], 'other')}",
218
+ position=position,
219
+ result_type="stock_quote",
220
+ metadata={
221
+ "ticker": ticker,
222
+ "exchange": exchange or None,
223
+ "name": str(q[2] or ""),
224
+ "type": _TYPE_MAP.get(q[3], "other"),
225
+ "price": price_arr[0] if price_arr else None,
226
+ "change": price_arr[1] if len(price_arr) > 1 else None,
227
+ "change_pct": price_arr[2] if len(price_arr) > 2 else None,
228
+ "previous_close": q[7] if len(q) > 7 else None,
229
+ "currency": str(q[4] or ""),
230
+ "timezone": str(q[12]) if len(q) > 12 and q[12] else None,
231
+ "after_hours": after_hours,
232
+ },
233
+ )
234
+
235
+
236
+ def _parse_company_info(data: Any, position: int) -> SearchResult | None:
237
+ """Build a ``company_info`` SearchResult from the HqGpWd data blob."""
238
+ try:
239
+ info: list[Any] = data[0][0]
240
+ except (IndexError, TypeError):
241
+ return None
242
+ if not isinstance(info, list):
243
+ return None
244
+
245
+ description = clean_text(str(info[2])) if len(info) > 2 and info[2] else None
246
+ if not description:
247
+ return None
248
+
249
+ def _get(idx: int) -> Any:
250
+ return info[idx] if len(info) > idx else None
251
+
252
+ hq_arr = _get(3)
253
+ hq = ", ".join(str(x) for x in hq_arr if x) if isinstance(hq_arr, list) else None
254
+
255
+ return SearchResult(
256
+ title="Company Information",
257
+ url="",
258
+ description=description,
259
+ position=position,
260
+ result_type="company_info",
261
+ metadata={
262
+ "description": description,
263
+ "headquarters": hq,
264
+ "ceo": _get(5),
265
+ "employees": _get(6),
266
+ "market_cap": _get(7),
267
+ "open": _get(9),
268
+ "day_high": _get(10),
269
+ "day_low": _get(11),
270
+ "fifty_two_week_high": _get(12),
271
+ "fifty_two_week_low": _get(13),
272
+ "pe_ratio": _get(16),
273
+ "volume": _get(18),
274
+ "sector": _get(71),
275
+ },
276
+ )
277
+
278
+
279
+ def _parse_chart(data: Any, position: int) -> SearchResult | None:
280
+ """Build a ``stock_chart`` SearchResult from the AiCwsd data blob."""
281
+ try:
282
+ chart_raw: list[Any] = data[0][0]
283
+ except (IndexError, TypeError):
284
+ return None
285
+ if not isinstance(chart_raw, list):
286
+ return None
287
+
288
+ points: list[dict[str, object]] = []
289
+ for period in (chart_raw[3] if len(chart_raw) > 3 and isinstance(chart_raw[3], list) else []):
290
+ for pt in (period[1] if isinstance(period, list) and len(period) > 1 else []):
291
+ if not (isinstance(pt, list) and len(pt) >= 2 and isinstance(pt[0], list) and isinstance(pt[1], list)):
292
+ continue
293
+ y, m, d = pt[0][0], pt[0][1], pt[0][2]
294
+ points.append({"date": f"{y}-{m:02d}-{d:02d}", "price": pt[1][0], "volume": pt[2] if len(pt) > 2 else None})
295
+
296
+ if not points:
297
+ return None
298
+
299
+ return SearchResult(
300
+ title="Price Chart",
301
+ url="",
302
+ description=None,
303
+ position=position,
304
+ result_type="stock_chart",
305
+ metadata={"previous_close": chart_raw[6] if len(chart_raw) > 6 else None, "points": points},
306
+ )
307
+
308
+
309
+ def _parse_news(data: Any, position: int) -> list[SearchResult]:
310
+ """Build ``financial_news`` SearchResults from the nBEQBc data blob."""
311
+ try:
312
+ news_arr: list[Any] = data[0]
313
+ except (IndexError, TypeError):
314
+ return []
315
+ if not isinstance(news_arr, list):
316
+ return []
317
+
318
+ items: list[SearchResult] = []
319
+ for i, item in enumerate(news_arr):
320
+ if not (isinstance(item, list) and len(item) > 1 and item[1]):
321
+ continue
322
+ items.append(
323
+ SearchResult(
324
+ title=clean_text(str(item[1])),
325
+ url=str(item[0] or ""),
326
+ description=None,
327
+ position=position + i,
328
+ result_type="financial_news",
329
+ metadata={
330
+ "source": item[2] if len(item) > 2 else None,
331
+ "timestamp": item[4] if len(item) > 4 else None,
332
+ },
333
+ )
334
+ )
335
+ return items
336
+
337
+
338
+ def _parse_financials(data: Any, position: int) -> SearchResult | None:
339
+ """Build a ``financials`` SearchResult from the Pr8h2e data blob."""
340
+ statements: list[dict[str, object]] = []
341
+ _find_financial_arrays(data, statements)
342
+ if not statements:
343
+ return None
344
+
345
+ return SearchResult(
346
+ title="Financial Statements",
347
+ url="",
348
+ description=None,
349
+ position=position,
350
+ result_type="financials",
351
+ metadata={"statements": statements},
352
+ )
353
+
354
+
355
+ def _find_financial_arrays(data: Any, results: list[dict[str, object]]) -> None:
356
+ """Recursively locate 39-field financial statement arrays."""
357
+ if not isinstance(data, list):
358
+ return
359
+ if (
360
+ len(data) >= 38
361
+ and isinstance(data[0], (int, float))
362
+ and data[0] > 1_000_000_000
363
+ and isinstance(data[2], (int, float))
364
+ and data[2] < 1000
365
+ ):
366
+ results.append(_parse_financial_row(data))
367
+ return
368
+ for item in data:
369
+ _find_financial_arrays(item, results)
370
+
371
+
372
+ def _parse_financial_row(row: list[Any]) -> dict[str, object]:
373
+ """Convert a positional financial array into a named dict."""
374
+ def _get(idx: int) -> Any:
375
+ return row[idx] if len(row) > idx else None
376
+
377
+ period = _get(17)
378
+ period_str: str | None = None
379
+ if isinstance(period, list) and len(period) >= 3:
380
+ period_str = f"{period[0]}-{int(period[1]):02d}-{int(period[2]):02d}"
381
+
382
+ return {
383
+ "revenue": _get(0),
384
+ "net_income": _get(1),
385
+ "eps": _get(2),
386
+ "operating_margin": _get(3),
387
+ "operating_income": _get(4),
388
+ "ebitda": _get(7),
389
+ "shares_outstanding": _get(8),
390
+ "eps_diluted": _get(9),
391
+ "revenue_growth_yoy": _get(11),
392
+ "currency": _get(16),
393
+ "period": period_str,
394
+ "period_type": "annual" if isinstance(period, list) and len(period) > 1 and period[1] == 12 else "quarterly",
395
+ "pe_ratio": _get(18),
396
+ "total_assets": _get(23),
397
+ "total_liabilities": _get(24),
398
+ "total_equity": _get(25),
399
+ "operating_cash_flow": _get(28),
400
+ "profit_margin": _get(33),
401
+ "free_cash_flow": _get(36),
402
+ "capital_expenditure": _get(38),
403
+ }
@@ -0,0 +1,23 @@
1
+ """Web scrapers that fetch live data from supported platforms."""
2
+
3
+ from search_parser.scrapers.google_finance import (
4
+ ChartData,
5
+ ChartPoint,
6
+ CompanyInfo,
7
+ FinancialQuote,
8
+ FinancialStatement,
9
+ GoogleFinanceData,
10
+ GoogleFinanceScraper,
11
+ NewsItem,
12
+ )
13
+
14
+ __all__ = [
15
+ "GoogleFinanceScraper",
16
+ "GoogleFinanceData",
17
+ "FinancialQuote",
18
+ "CompanyInfo",
19
+ "ChartData",
20
+ "ChartPoint",
21
+ "NewsItem",
22
+ "FinancialStatement",
23
+ ]