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.
Files changed (73) hide show
  1. {search_parser-0.3.0 → search_parser-0.4.0}/.github/workflows/codeql.yml +1 -1
  2. {search_parser-0.3.0 → search_parser-0.4.0}/.github/workflows/publish.yml +1 -0
  3. {search_parser-0.3.0 → search_parser-0.4.0}/.github/workflows/test.yml +1 -1
  4. {search_parser-0.3.0 → search_parser-0.4.0}/.gitignore +2 -1
  5. {search_parser-0.3.0 → search_parser-0.4.0}/CHANGELOG.md +17 -1
  6. {search_parser-0.3.0 → search_parser-0.4.0}/PKG-INFO +58 -2
  7. {search_parser-0.3.0 → search_parser-0.4.0}/README.md +56 -0
  8. {search_parser-0.3.0 → search_parser-0.4.0}/pyproject.toml +2 -2
  9. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/__version__.py +1 -1
  10. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/core/models.py +4 -0
  11. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/formatters/markdown_formatter.py +30 -0
  12. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/parsers/google.py +107 -0
  13. {search_parser-0.3.0 → search_parser-0.4.0}/tests/conftest.py +5 -0
  14. search_parser-0.4.0/tests/fixtures/google/supply-chain-director-jobs.html +92 -0
  15. {search_parser-0.3.0 → search_parser-0.4.0}/tests/unit/test_google_parser.py +81 -0
  16. {search_parser-0.3.0 → search_parser-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  17. {search_parser-0.3.0 → search_parser-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  18. {search_parser-0.3.0 → search_parser-0.4.0}/.github/dependabot.yml +0 -0
  19. {search_parser-0.3.0 → search_parser-0.4.0}/.github/pull_request_template.md +0 -0
  20. {search_parser-0.3.0 → search_parser-0.4.0}/.github/workflows/coverage-badge.yml +0 -0
  21. {search_parser-0.3.0 → search_parser-0.4.0}/.github/workflows/lint.yml +0 -0
  22. {search_parser-0.3.0 → search_parser-0.4.0}/.pre-commit-config.yaml +0 -0
  23. {search_parser-0.3.0 → search_parser-0.4.0}/CLAUDE.md +0 -0
  24. {search_parser-0.3.0 → search_parser-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  25. {search_parser-0.3.0 → search_parser-0.4.0}/CONTRIBUTING.md +0 -0
  26. {search_parser-0.3.0 → search_parser-0.4.0}/LICENSE +0 -0
  27. {search_parser-0.3.0 → search_parser-0.4.0}/docs/CNAME +0 -0
  28. {search_parser-0.3.0 → search_parser-0.4.0}/docs/adding_search_engine.md +0 -0
  29. {search_parser-0.3.0 → search_parser-0.4.0}/docs/api_reference.md +0 -0
  30. {search_parser-0.3.0 → search_parser-0.4.0}/docs/contributing.md +0 -0
  31. {search_parser-0.3.0 → search_parser-0.4.0}/docs/examples/advanced_usage.md +0 -0
  32. {search_parser-0.3.0 → search_parser-0.4.0}/docs/examples/basic_usage.md +0 -0
  33. {search_parser-0.3.0 → search_parser-0.4.0}/docs/getting_started.md +0 -0
  34. {search_parser-0.3.0 → search_parser-0.4.0}/docs/index.md +0 -0
  35. {search_parser-0.3.0 → search_parser-0.4.0}/examples/basic_parsing.py +0 -0
  36. {search_parser-0.3.0 → search_parser-0.4.0}/examples/batch_processing.py +0 -0
  37. {search_parser-0.3.0 → search_parser-0.4.0}/examples/custom_formatter.py +0 -0
  38. {search_parser-0.3.0 → search_parser-0.4.0}/mkdocs.yml +0 -0
  39. {search_parser-0.3.0 → search_parser-0.4.0}/scripts/update_fixtures.py +0 -0
  40. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/__init__.py +0 -0
  41. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/cli.py +0 -0
  42. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/core/__init__.py +0 -0
  43. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/core/detector.py +0 -0
  44. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/core/parser.py +0 -0
  45. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/exceptions.py +0 -0
  46. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/formatters/__init__.py +0 -0
  47. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/formatters/base.py +0 -0
  48. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/formatters/json_formatter.py +0 -0
  49. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/parsers/__init__.py +0 -0
  50. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/parsers/base.py +0 -0
  51. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/parsers/bing.py +0 -0
  52. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/parsers/duckduckgo.py +0 -0
  53. {search_parser-0.3.0 → search_parser-0.4.0}/src/search_parser/utils.py +0 -0
  54. {search_parser-0.3.0 → search_parser-0.4.0}/tests/__init__.py +0 -0
  55. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/bing/organic_results.html +0 -0
  56. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/bing/search_github_repos.html +0 -0
  57. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/duckduckgo/organic_results.html +0 -0
  58. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/duckduckgo/search_github_repos.html +0 -0
  59. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/featured_snippet.html +0 -0
  60. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/knowledge_panel.html +0 -0
  61. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/need_turn_on_javascript.html +0 -0
  62. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/organic_results.html +0 -0
  63. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/search_best_employee_scheduling_app.html +0 -0
  64. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/search_github_repos.html +0 -0
  65. {search_parser-0.3.0 → search_parser-0.4.0}/tests/fixtures/google/search_python_web_scraping.html +0 -0
  66. {search_parser-0.3.0 → search_parser-0.4.0}/tests/test_cli.py +0 -0
  67. {search_parser-0.3.0 → search_parser-0.4.0}/tests/test_coverage_boost.py +0 -0
  68. {search_parser-0.3.0 → search_parser-0.4.0}/tests/test_formatters.py +0 -0
  69. {search_parser-0.3.0 → search_parser-0.4.0}/tests/unit/__init__.py +0 -0
  70. {search_parser-0.3.0 → search_parser-0.4.0}/tests/unit/test_bing_parser.py +0 -0
  71. {search_parser-0.3.0 → search_parser-0.4.0}/tests/unit/test_detector.py +0 -0
  72. {search_parser-0.3.0 → search_parser-0.4.0}/tests/unit/test_duckduckgo_parser.py +0 -0
  73. {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@v4
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`
@@ -3,6 +3,7 @@ name: Publish to PyPI
3
3
  on:
4
4
  release:
5
5
  types: [published]
6
+ workflow_dispatch:
6
7
 
7
8
  jobs:
8
9
  deploy:
@@ -41,7 +41,7 @@ jobs:
41
41
  token: ${{ secrets.CODECOV_TOKEN }}
42
42
 
43
43
  - name: Upload coverage HTML artifact
44
- uses: actions/upload-artifact@v6
44
+ uses: actions/upload-artifact@v7
45
45
  if: matrix.python-version == '3.11'
46
46
  with:
47
47
  name: coverage-report
@@ -48,4 +48,5 @@ Thumbs.db
48
48
 
49
49
  # uv
50
50
 
51
- search_parser_prompt.md
51
+ search_parser_prompt.md
52
+
@@ -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.2.0...HEAD
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.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: Your Name <you@example.com>
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.3.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 = "Your Name", email = "you@example.com"}
13
+ {name = "linksc", email = "hello@link.sc"}
14
14
  ]
15
15
  keywords = ["search", "parser", "scraping", "google", "bing", "duckduckgo"]
16
16
  classifiers = [
@@ -1,3 +1,3 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -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))
@@ -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()