search-parser 0.3.0__tar.gz → 0.4.0__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.3.0 → search_parser-0.4.0}/.github/workflows/codeql.yml +1 -1
- {search_parser-0.3.0 → search_parser-0.4.0}/.github/workflows/publish.yml +1 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/.github/workflows/test.yml +1 -1
- {search_parser-0.3.0 → search_parser-0.4.0}/.gitignore +2 -1
- {search_parser-0.3.0 → search_parser-0.4.0}/CHANGELOG.md +17 -1
- {search_parser-0.3.0 → search_parser-0.4.0}/PKG-INFO +58 -2
- {search_parser-0.3.0 → search_parser-0.4.0}/README.md +56 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/pyproject.toml +2 -2
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/__version__.py +1 -1
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/core/models.py +4 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/formatters/markdown_formatter.py +30 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/parsers/google.py +107 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/conftest.py +5 -0
- search_parser-0.4.0/tests/fixtures/google/supply-chain-director-jobs.html +92 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/unit/test_google_parser.py +81 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/.github/dependabot.yml +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/.github/pull_request_template.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/.github/workflows/coverage-badge.yml +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/.github/workflows/lint.yml +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/.pre-commit-config.yaml +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/CLAUDE.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/CONTRIBUTING.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/LICENSE +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/docs/CNAME +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/docs/adding_search_engine.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/docs/api_reference.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/docs/contributing.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/docs/examples/advanced_usage.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/docs/examples/basic_usage.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/docs/getting_started.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/docs/index.md +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/examples/basic_parsing.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/examples/batch_processing.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/examples/custom_formatter.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/mkdocs.yml +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/scripts/update_fixtures.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/__init__.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/cli.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/core/__init__.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/core/detector.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/core/parser.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/exceptions.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/formatters/__init__.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/formatters/base.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/formatters/json_formatter.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/parsers/__init__.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/parsers/base.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/parsers/bing.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/parsers/duckduckgo.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/utils.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/__init__.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/bing/organic_results.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/bing/search_github_repos.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/duckduckgo/organic_results.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/duckduckgo/search_github_repos.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/featured_snippet.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/knowledge_panel.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/need_turn_on_javascript.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/organic_results.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/search_best_employee_scheduling_app.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/search_github_repos.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/search_python_web_scraping.html +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/test_cli.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/test_coverage_boost.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/test_formatters.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/unit/__init__.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/unit/test_bing_parser.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/unit/test_detector.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/tests/unit/test_duckduckgo_parser.py +0 -0
- {search_parser-0.3.0 → search_parser-0.4.0}/uv.lock +0 -0
|
@@ -57,7 +57,7 @@ jobs:
|
|
|
57
57
|
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
|
58
58
|
steps:
|
|
59
59
|
- name: Checkout repository
|
|
60
|
-
uses: actions/checkout@
|
|
60
|
+
uses: actions/checkout@v6
|
|
61
61
|
|
|
62
62
|
# Add any setup steps before running the `github/codeql-action/init` action.
|
|
63
63
|
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
|
@@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## [0.4.0] - 2026-03-05
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Google parser: Jobs** — new `jobs` field on `SearchResults` containing job listings from the Google Jobs widget. Each entry has `result_type="job"` with `metadata["company"]` and `metadata["location"]` fields. Jobs are never included in the organic `results` list.
|
|
17
|
+
- **Google parser: Discussions and forums** — new `discussions` field on `SearchResults` containing entries from the "Discussions and forums" widget. Each entry has `result_type="discussion"` with a `description` excerpt and `metadata["source"]` (platform, forum, comment count, and date).
|
|
18
|
+
- New `result_type` values `"job"` and `"discussion"` added to the `SearchResult` model's Literal type.
|
|
19
|
+
- Markdown formatter now renders `## Jobs` and `## Discussions and Forums` sections when those fields are present.
|
|
20
|
+
- Unit tests for jobs and discussions parsing using the `supply-chain-director-jobs.html` fixture.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
12
24
|
## [0.3.0] - 2026-02-21
|
|
13
25
|
|
|
14
26
|
### Changed
|
|
@@ -16,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
28
|
- Renamed all stale `search_engine_parser` references to `search_parser` across config, docs, and CI workflows.
|
|
17
29
|
- Fixed `pyproject.toml`: CLI entry point, hatch build target, and mypy `files` path all now point to `src/search_parser`.
|
|
18
30
|
- Synced `__version__.py` to match `pyproject.toml`.
|
|
31
|
+
- Added author and publisher metadata (`linksc`, `hello@link.sc`) to `pyproject.toml`.
|
|
32
|
+
- Updated package description with explicit search engine names and output formats for better discoverability.
|
|
19
33
|
|
|
20
34
|
---
|
|
21
35
|
|
|
@@ -82,6 +96,8 @@ sponsored = results.sponsored # list[SearchResult]
|
|
|
82
96
|
- CI/CD workflows for testing, linting, coverage, and publishing.
|
|
83
97
|
- Pre-commit hooks for ruff and mypy.
|
|
84
98
|
|
|
85
|
-
[Unreleased]: https://github.com/getlinksc/search-parser/compare/v0.
|
|
99
|
+
[Unreleased]: https://github.com/getlinksc/search-parser/compare/v0.4.0...HEAD
|
|
100
|
+
[0.4.0]: https://github.com/getlinksc/search-parser/compare/v0.3.0...v0.4.0
|
|
101
|
+
[0.3.0]: https://github.com/getlinksc/search-parser/compare/v0.2.0...v0.3.0
|
|
86
102
|
[0.2.0]: https://github.com/getlinksc/search-parser/compare/v0.1.0...v0.2.0
|
|
87
103
|
[0.1.0]: https://github.com/getlinksc/search-parser/releases/tag/v0.1.0
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: search-parser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
7
7
|
Project-URL: Repository, https://github.com/getlinksc/search-parser
|
|
8
8
|
Project-URL: Issues, https://github.com/getlinksc/search-parser/issues
|
|
9
9
|
Project-URL: Changelog, https://github.com/getlinksc/search-parser/blob/main/CHANGELOG.md
|
|
10
|
-
Author-email:
|
|
10
|
+
Author-email: linksc <hello@link.sc>
|
|
11
11
|
License: Apache-2.0
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Keywords: bing,duckduckgo,google,parser,scraping,search
|
|
@@ -120,6 +120,8 @@ pip install search-parser
|
|
|
120
120
|
| What People Are Saying | `people_saying` | ✓ | — | — |
|
|
121
121
|
| People Also Search For | `people_also_search` | ✓ | — | — |
|
|
122
122
|
| Related Products & Services | `related_products` | ✓ | — | — |
|
|
123
|
+
| Jobs | `jobs` | ✓ | — | — |
|
|
124
|
+
| Discussions and forums | `discussions` | ✓ | — | — |
|
|
123
125
|
|
|
124
126
|
---
|
|
125
127
|
|
|
@@ -161,6 +163,15 @@ for ad in data["sponsored"]:
|
|
|
161
163
|
for product in data["related_products"]:
|
|
162
164
|
print(product["title"])
|
|
163
165
|
|
|
166
|
+
# Jobs (title, metadata["company"], metadata["location"])
|
|
167
|
+
for job in data["jobs"]:
|
|
168
|
+
print(job["title"], job["metadata"]["company"], job["metadata"]["location"])
|
|
169
|
+
|
|
170
|
+
# Discussions (title, url, description, metadata["source"])
|
|
171
|
+
for disc in data["discussions"]:
|
|
172
|
+
print(disc["title"], disc["url"])
|
|
173
|
+
print(disc["metadata"]["source"])
|
|
174
|
+
|
|
164
175
|
# Metadata
|
|
165
176
|
print(data["search_engine"]) # "google"
|
|
166
177
|
print(data["query"]) # "python web scraping"
|
|
@@ -244,6 +255,31 @@ md_str = results.to_markdown()
|
|
|
244
255
|
"people_saying": [],
|
|
245
256
|
"people_also_search": [],
|
|
246
257
|
"related_products": [],
|
|
258
|
+
"jobs": [
|
|
259
|
+
{
|
|
260
|
+
"title": "Global Supply Chain Director",
|
|
261
|
+
"url": "https://www.google.com/search?q=%22Supply+Chain+Director&udm=8",
|
|
262
|
+
"description": null,
|
|
263
|
+
"position": 0,
|
|
264
|
+
"result_type": "job",
|
|
265
|
+
"metadata": {
|
|
266
|
+
"company": "InterSources, Inc.",
|
|
267
|
+
"location": "San Jose, CA • via Ladders"
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
],
|
|
271
|
+
"discussions": [
|
|
272
|
+
{
|
|
273
|
+
"title": "Being considered for Director of Supply Chain",
|
|
274
|
+
"url": "https://www.reddit.com/r/supplychain/comments/1ib0c1a/being_considered_for_director_of_supply_chain/",
|
|
275
|
+
"description": "I work for a mid-sized company as a Procurement Manager...",
|
|
276
|
+
"position": 0,
|
|
277
|
+
"result_type": "discussion",
|
|
278
|
+
"metadata": {
|
|
279
|
+
"source": "Reddit · r/supplychain · 10+ comments · 1 year ago"
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
],
|
|
247
283
|
"detection_confidence": 0.95,
|
|
248
284
|
"parsed_at": "2026-02-21T00:00:00Z",
|
|
249
285
|
"metadata": {}
|
|
@@ -276,6 +312,26 @@ Web scraping is the process of extracting data from websites...
|
|
|
276
312
|
Learn how to scrape websites with Python...
|
|
277
313
|
|
|
278
314
|
**URL:** https://realpython.com/python-web-scraping/
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Jobs
|
|
319
|
+
|
|
320
|
+
### Global Supply Chain Director
|
|
321
|
+
|
|
322
|
+
**Company:** InterSources, Inc.
|
|
323
|
+
**Location:** San Jose, CA • via Ladders
|
|
324
|
+
**URL:** https://www.google.com/search?q=%22Supply+Chain+Director&udm=8
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Discussions and Forums
|
|
329
|
+
|
|
330
|
+
### Being considered for Director of Supply Chain
|
|
331
|
+
|
|
332
|
+
I work for a mid-sized company as a Procurement Manager...
|
|
333
|
+
|
|
334
|
+
**URL:** https://www.reddit.com/r/supplychain/comments/1ib0c1a/being_considered_for_director_of_supply_chain/
|
|
279
335
|
```
|
|
280
336
|
|
|
281
337
|
---
|
|
@@ -77,6 +77,8 @@ pip install search-parser
|
|
|
77
77
|
| What People Are Saying | `people_saying` | ✓ | — | — |
|
|
78
78
|
| People Also Search For | `people_also_search` | ✓ | — | — |
|
|
79
79
|
| Related Products & Services | `related_products` | ✓ | — | — |
|
|
80
|
+
| Jobs | `jobs` | ✓ | — | — |
|
|
81
|
+
| Discussions and forums | `discussions` | ✓ | — | — |
|
|
80
82
|
|
|
81
83
|
---
|
|
82
84
|
|
|
@@ -118,6 +120,15 @@ for ad in data["sponsored"]:
|
|
|
118
120
|
for product in data["related_products"]:
|
|
119
121
|
print(product["title"])
|
|
120
122
|
|
|
123
|
+
# Jobs (title, metadata["company"], metadata["location"])
|
|
124
|
+
for job in data["jobs"]:
|
|
125
|
+
print(job["title"], job["metadata"]["company"], job["metadata"]["location"])
|
|
126
|
+
|
|
127
|
+
# Discussions (title, url, description, metadata["source"])
|
|
128
|
+
for disc in data["discussions"]:
|
|
129
|
+
print(disc["title"], disc["url"])
|
|
130
|
+
print(disc["metadata"]["source"])
|
|
131
|
+
|
|
121
132
|
# Metadata
|
|
122
133
|
print(data["search_engine"]) # "google"
|
|
123
134
|
print(data["query"]) # "python web scraping"
|
|
@@ -201,6 +212,31 @@ md_str = results.to_markdown()
|
|
|
201
212
|
"people_saying": [],
|
|
202
213
|
"people_also_search": [],
|
|
203
214
|
"related_products": [],
|
|
215
|
+
"jobs": [
|
|
216
|
+
{
|
|
217
|
+
"title": "Global Supply Chain Director",
|
|
218
|
+
"url": "https://www.google.com/search?q=%22Supply+Chain+Director&udm=8",
|
|
219
|
+
"description": null,
|
|
220
|
+
"position": 0,
|
|
221
|
+
"result_type": "job",
|
|
222
|
+
"metadata": {
|
|
223
|
+
"company": "InterSources, Inc.",
|
|
224
|
+
"location": "San Jose, CA • via Ladders"
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
],
|
|
228
|
+
"discussions": [
|
|
229
|
+
{
|
|
230
|
+
"title": "Being considered for Director of Supply Chain",
|
|
231
|
+
"url": "https://www.reddit.com/r/supplychain/comments/1ib0c1a/being_considered_for_director_of_supply_chain/",
|
|
232
|
+
"description": "I work for a mid-sized company as a Procurement Manager...",
|
|
233
|
+
"position": 0,
|
|
234
|
+
"result_type": "discussion",
|
|
235
|
+
"metadata": {
|
|
236
|
+
"source": "Reddit · r/supplychain · 10+ comments · 1 year ago"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
],
|
|
204
240
|
"detection_confidence": 0.95,
|
|
205
241
|
"parsed_at": "2026-02-21T00:00:00Z",
|
|
206
242
|
"metadata": {}
|
|
@@ -233,6 +269,26 @@ Web scraping is the process of extracting data from websites...
|
|
|
233
269
|
Learn how to scrape websites with Python...
|
|
234
270
|
|
|
235
271
|
**URL:** https://realpython.com/python-web-scraping/
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Jobs
|
|
276
|
+
|
|
277
|
+
### Global Supply Chain Director
|
|
278
|
+
|
|
279
|
+
**Company:** InterSources, Inc.
|
|
280
|
+
**Location:** San Jose, CA • via Ladders
|
|
281
|
+
**URL:** https://www.google.com/search?q=%22Supply+Chain+Director&udm=8
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Discussions and Forums
|
|
286
|
+
|
|
287
|
+
### Being considered for Director of Supply Chain
|
|
288
|
+
|
|
289
|
+
I work for a mid-sized company as a Procurement Manager...
|
|
290
|
+
|
|
291
|
+
**URL:** https://www.reddit.com/r/supplychain/comments/1ib0c1a/being_considered_for_director_of_supply_chain/
|
|
236
292
|
```
|
|
237
293
|
|
|
238
294
|
---
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "search-parser"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
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"
|
|
11
11
|
license = {text = "Apache-2.0"}
|
|
12
12
|
authors = [
|
|
13
|
-
{name = "
|
|
13
|
+
{name = "linksc", email = "hello@link.sc"}
|
|
14
14
|
]
|
|
15
15
|
keywords = ["search", "parser", "scraping", "google", "bing", "duckduckgo"]
|
|
16
16
|
classifiers = [
|
|
@@ -27,6 +27,8 @@ class SearchResult(BaseModel):
|
|
|
27
27
|
"people_saying",
|
|
28
28
|
"people_also_search",
|
|
29
29
|
"related_products",
|
|
30
|
+
"job",
|
|
31
|
+
"discussion",
|
|
30
32
|
] = "organic"
|
|
31
33
|
metadata: dict[str, object] = Field(default_factory=dict)
|
|
32
34
|
|
|
@@ -53,6 +55,8 @@ class SearchResults(BaseModel):
|
|
|
53
55
|
people_saying: list[SearchResult] = Field(default_factory=list)
|
|
54
56
|
people_also_search: list[SearchResult] = Field(default_factory=list)
|
|
55
57
|
related_products: list[SearchResult] = Field(default_factory=list)
|
|
58
|
+
jobs: list[SearchResult] = Field(default_factory=list)
|
|
59
|
+
discussions: list[SearchResult] = Field(default_factory=list)
|
|
56
60
|
|
|
57
61
|
detection_confidence: float = Field(ge=0.0, le=1.0)
|
|
58
62
|
parsed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
{search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/formatters/markdown_formatter.py
RENAMED
|
@@ -70,6 +70,18 @@ class MarkdownFormatter(BaseFormatter):
|
|
|
70
70
|
for result in news:
|
|
71
71
|
lines.extend(self._format_result(result))
|
|
72
72
|
|
|
73
|
+
if results.jobs:
|
|
74
|
+
lines.append("## Jobs")
|
|
75
|
+
lines.append("")
|
|
76
|
+
for result in results.jobs:
|
|
77
|
+
lines.extend(self._format_job(result))
|
|
78
|
+
|
|
79
|
+
if results.discussions:
|
|
80
|
+
lines.append("## Discussions and Forums")
|
|
81
|
+
lines.append("")
|
|
82
|
+
for result in results.discussions:
|
|
83
|
+
lines.extend(self._format_result(result))
|
|
84
|
+
|
|
73
85
|
lines.append("---")
|
|
74
86
|
lines.append("")
|
|
75
87
|
lines.append(f"*Parsed with search-engine-parser v{__version__}*")
|
|
@@ -102,6 +114,24 @@ class MarkdownFormatter(BaseFormatter):
|
|
|
102
114
|
lines.append("")
|
|
103
115
|
return lines
|
|
104
116
|
|
|
117
|
+
def _format_job(self, result: SearchResult) -> list[str]:
|
|
118
|
+
"""Format a job listing result."""
|
|
119
|
+
lines: list[str] = []
|
|
120
|
+
lines.append(f"### {result.title}")
|
|
121
|
+
lines.append("")
|
|
122
|
+
if result.metadata.get("company"):
|
|
123
|
+
lines.append(f"**Company:** {result.metadata['company']}")
|
|
124
|
+
if result.metadata.get("location"):
|
|
125
|
+
lines.append(f"**Location:** {result.metadata['location']}")
|
|
126
|
+
if result.metadata.get("salary"):
|
|
127
|
+
lines.append(f"**Salary:** {result.metadata['salary']}")
|
|
128
|
+
if result.metadata.get("employment_type"):
|
|
129
|
+
lines.append(f"**Type:** {result.metadata['employment_type']}")
|
|
130
|
+
if result.url:
|
|
131
|
+
lines.append(f"**URL:** {result.url}")
|
|
132
|
+
lines.append("")
|
|
133
|
+
return lines
|
|
134
|
+
|
|
105
135
|
def _format_result(self, result: SearchResult) -> list[str]:
|
|
106
136
|
"""Format a generic result."""
|
|
107
137
|
lines: list[str] = []
|
|
@@ -65,6 +65,8 @@ class GoogleParser(BaseParser):
|
|
|
65
65
|
people_saying=self._extract_people_saying(soup),
|
|
66
66
|
people_also_search=self._extract_people_also_search(soup),
|
|
67
67
|
related_products=self._extract_related_products(soup),
|
|
68
|
+
jobs=self._extract_jobs(soup),
|
|
69
|
+
discussions=self._extract_discussions(soup),
|
|
68
70
|
detection_confidence=confidence,
|
|
69
71
|
)
|
|
70
72
|
|
|
@@ -344,6 +346,111 @@ class GoogleParser(BaseParser):
|
|
|
344
346
|
)
|
|
345
347
|
return results
|
|
346
348
|
|
|
349
|
+
def _extract_jobs(self, soup: BeautifulSoup) -> list[SearchResult]:
|
|
350
|
+
"""Extract Google Jobs widget listings.
|
|
351
|
+
|
|
352
|
+
Jobs are rendered inside a dedicated widget (``div.iYivne``) and are
|
|
353
|
+
intentionally kept out of the organic ``results`` list.
|
|
354
|
+
"""
|
|
355
|
+
results: list[SearchResult] = []
|
|
356
|
+
widget = soup.find("div", class_="iYivne")
|
|
357
|
+
if not isinstance(widget, Tag):
|
|
358
|
+
return results
|
|
359
|
+
for item in widget.find_all("div", class_="EimVGf"):
|
|
360
|
+
if not isinstance(item, Tag):
|
|
361
|
+
continue
|
|
362
|
+
title_div = item.find("div", class_="tNxQIb")
|
|
363
|
+
company_div = item.find("div", class_="a3jPc")
|
|
364
|
+
location_div = item.find("div", class_="FqK3wc")
|
|
365
|
+
link = item.find("a", class_="MQUd2b")
|
|
366
|
+
title = clean_text(title_div.get_text()) if isinstance(title_div, Tag) else ""
|
|
367
|
+
if not title:
|
|
368
|
+
continue
|
|
369
|
+
url = str(link.get("href", "")) if isinstance(link, Tag) else ""
|
|
370
|
+
company = clean_text(company_div.get_text()) if isinstance(company_div, Tag) else None
|
|
371
|
+
location = clean_text(location_div.get_text()) if isinstance(location_div, Tag) else None
|
|
372
|
+
|
|
373
|
+
# Salary has the extra QZEeP class; employment type and other tags share K3eUK only
|
|
374
|
+
salary_div = item.find("div", class_="QZEeP")
|
|
375
|
+
salary = clean_text(salary_div.get_text()) if isinstance(salary_div, Tag) else None
|
|
376
|
+
|
|
377
|
+
# Employment type: K3eUK entries that are NOT the salary and NOT a date ("X days ago")
|
|
378
|
+
employment_type: str | None = None
|
|
379
|
+
for tag_div in item.find_all("div", class_="K3eUK"):
|
|
380
|
+
if not isinstance(tag_div, Tag):
|
|
381
|
+
continue
|
|
382
|
+
if "QZEeP" in (tag_div.get("class") or []):
|
|
383
|
+
continue # skip salary
|
|
384
|
+
text = clean_text(tag_div.get_text())
|
|
385
|
+
if text and not re.search(r"\d+\s+\w+\s+ago", text):
|
|
386
|
+
employment_type = text
|
|
387
|
+
break
|
|
388
|
+
|
|
389
|
+
benefits = [
|
|
390
|
+
clean_text(b.get_text())
|
|
391
|
+
for b in item.find_all("div", class_="HvHIEc")
|
|
392
|
+
if isinstance(b, Tag) and clean_text(b.get_text())
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
metadata: dict[str, object] = {}
|
|
396
|
+
if company:
|
|
397
|
+
metadata["company"] = company
|
|
398
|
+
if location:
|
|
399
|
+
metadata["location"] = location
|
|
400
|
+
if salary:
|
|
401
|
+
metadata["salary"] = salary
|
|
402
|
+
if employment_type:
|
|
403
|
+
metadata["employment_type"] = employment_type
|
|
404
|
+
if benefits:
|
|
405
|
+
metadata["benefits"] = benefits
|
|
406
|
+
results.append(
|
|
407
|
+
SearchResult(
|
|
408
|
+
title=title,
|
|
409
|
+
url=url,
|
|
410
|
+
description=None,
|
|
411
|
+
position=0,
|
|
412
|
+
result_type="job",
|
|
413
|
+
metadata=metadata,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
return results
|
|
417
|
+
|
|
418
|
+
def _extract_discussions(self, soup: BeautifulSoup) -> list[SearchResult]:
|
|
419
|
+
"""Extract 'Discussions and forums' widget entries."""
|
|
420
|
+
results: list[SearchResult] = []
|
|
421
|
+
header = soup.find("div", class_="DuxCpf")
|
|
422
|
+
if not isinstance(header, Tag):
|
|
423
|
+
return results
|
|
424
|
+
container = header.find_parent("div", class_="Ww4FFb")
|
|
425
|
+
if not isinstance(container, Tag):
|
|
426
|
+
return results
|
|
427
|
+
for item in container.find_all("div", class_="xYkm8c"):
|
|
428
|
+
if not isinstance(item, Tag):
|
|
429
|
+
continue
|
|
430
|
+
title_div = item.find("div", class_="j29v2b")
|
|
431
|
+
link = item.find("a", class_="KYg7td")
|
|
432
|
+
meta_div = item.find("div", class_="LbKnXb")
|
|
433
|
+
desc_div = item.find("div", class_="bCOlv")
|
|
434
|
+
title = clean_text(title_div.get_text()) if isinstance(title_div, Tag) else ""
|
|
435
|
+
if not title:
|
|
436
|
+
continue
|
|
437
|
+
url = str(link.get("href", "")) if isinstance(link, Tag) else ""
|
|
438
|
+
description = clean_text(desc_div.get_text()) if isinstance(desc_div, Tag) else None
|
|
439
|
+
metadata: dict[str, object] = {}
|
|
440
|
+
if isinstance(meta_div, Tag):
|
|
441
|
+
metadata["source"] = clean_text(meta_div.get_text())
|
|
442
|
+
results.append(
|
|
443
|
+
SearchResult(
|
|
444
|
+
title=title,
|
|
445
|
+
url=url,
|
|
446
|
+
description=description,
|
|
447
|
+
position=0,
|
|
448
|
+
result_type="discussion",
|
|
449
|
+
metadata=metadata,
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
return results
|
|
453
|
+
|
|
347
454
|
def _extract_featured_snippet(self, soup: BeautifulSoup) -> SearchResult | None:
|
|
348
455
|
"""Extract featured snippet if present."""
|
|
349
456
|
snippet_container = soup.find("div", class_="xpdopen")
|
|
@@ -62,3 +62,8 @@ def duckduckgo_github_repos_html() -> str:
|
|
|
62
62
|
@pytest.fixture()
|
|
63
63
|
def google_web_scraping_html() -> str:
|
|
64
64
|
return (FIXTURES_DIR / "google" / "search_python_web_scraping.html").read_text()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture()
|
|
68
|
+
def google_supply_chain_jobs_html() -> str:
|
|
69
|
+
return (FIXTURES_DIR / "google" / "supply-chain-director-jobs.html").read_text()
|