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.
- {search_parser-0.5.2 → search_parser-0.5.4}/CHANGELOG.md +23 -1
- {search_parser-0.5.2 → search_parser-0.5.4}/PKG-INFO +1 -1
- {search_parser-0.5.2 → search_parser-0.5.4}/docs/examples/advanced_usage.md +9 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/docs/examples/basic_usage.md +11 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/docs/index.md +5 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/pyproject.toml +1 -1
- search_parser-0.5.4/src/search_parser/parsers/google_finance.py +403 -0
- search_parser-0.5.4/src/search_parser/scrapers/__init__.py +23 -0
- search_parser-0.5.4/src/search_parser/scrapers/google_finance.py +393 -0
- search_parser-0.5.4/tests/unit/test_google_finance_parser.py +346 -0
- search_parser-0.5.4/uv.lock +1646 -0
- search_parser-0.5.2/uv.lock +0 -1566
- {search_parser-0.5.2 → search_parser-0.5.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/.github/dependabot.yml +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/.github/pull_request_template.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/.github/workflows/codeql.yml +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/.github/workflows/coverage-badge.yml +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/.github/workflows/lint.yml +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/.github/workflows/publish.yml +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/.github/workflows/test.yml +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/.gitignore +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/.pre-commit-config.yaml +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/CLAUDE.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/CODE_OF_CONDUCT.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/CONTRIBUTING.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/LICENSE +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/README.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/docs/CNAME +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/docs/adding_search_engine.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/docs/api_reference.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/docs/contributing.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/docs/getting_started.md +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/examples/basic_parsing.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/examples/batch_processing.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/examples/custom_formatter.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/mkdocs.yml +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/scripts/update_fixtures.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/__init__.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/__version__.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/cli.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/core/__init__.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/core/detector.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/core/models.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/core/parser.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/exceptions.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/formatters/__init__.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/formatters/base.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/formatters/json_formatter.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/formatters/markdown_formatter.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/parsers/__init__.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/parsers/base.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/parsers/bing.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/parsers/duckduckgo.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/parsers/google.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/src/search_parser/utils.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/__init__.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/conftest.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/bing/organic_results.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/bing/search_github_repos.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/duckduckgo/organic_results.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/duckduckgo/search_github_repos.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/contact_lens_weekly_20260528_203836.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/featured_snippet.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/home_loan_interest_rates_20260528_203450.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/iphone_15_review_20260528_204042.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/knowledge_panel.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/need_turn_on_javascript.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/news_tab_search.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/organic_results.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/personal_injury_lawyer_20260528_203801.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/search_best_employee_scheduling_app.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/search_github_repos.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/search_python_web_scraping.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/search_seven_cloak.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/supply-chain-director-jobs.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google/weekly_contacts_with_mobile.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google_finance/crypto.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/fixtures/google_finance/quote.html +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/test_cli.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/test_coverage_boost.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/test_formatters.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/unit/__init__.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/unit/test_bing_parser.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/unit/test_detector.py +0 -0
- {search_parser-0.5.2 → search_parser-0.5.4}/tests/unit/test_duckduckgo_parser.py +0 -0
- {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
|
|
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.
|
|
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.
|
|
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
|
+
]
|