warn-scraper 1.2.169.dev0__py3-none-any.whl → 1.2.170.dev0__py3-none-any.whl

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.
warn/scrapers/ma.py ADDED
@@ -0,0 +1,271 @@
1
+ import csv
2
+ import logging
3
+ import os
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from urllib.parse import unquote, urljoin
7
+
8
+ from bs4 import BeautifulSoup
9
+ from openpyxl import load_workbook
10
+
11
+ from .. import utils
12
+ from ..cache import Cache
13
+
14
+ __authors__ = ["riordan"]
15
+ __tags__ = ["excel", "csv", "historical"]
16
+ __source__ = {
17
+ "name": "Massachusetts Executive Office of Labor and Workforce Development",
18
+ "url": "https://www.mass.gov/info-details/worker-adjustment-and-retraining-notification-act-warn-layoff-and-closure-updates",
19
+ }
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ BASE_URL = "https://www.mass.gov"
24
+
25
+ # The unified header for the combined export. It mirrors the columns the state
26
+ # currently publishes in its flat fiscal-year workbooks and its weekly CSV feed.
27
+ CANONICAL_HEADER = [
28
+ "RECEIVED",
29
+ "EMPLOYER",
30
+ "CITY/TOWN",
31
+ "REGION",
32
+ "DATE(S) OF LAYOFFS",
33
+ "# EMPLOYEES IMPACTED",
34
+ ]
35
+
36
+ # The older fiscal-year workbooks (FY2022, FY2023) split notices into one
37
+ # worksheet per region and lay the columns out in this fixed order. The region
38
+ # for those rows comes from the worksheet name rather than a column.
39
+ REGION_SHEET_ORDER = [
40
+ "RECEIVED",
41
+ "EMPLOYER",
42
+ "CITY/TOWN",
43
+ "DATE(S) OF LAYOFFS",
44
+ "# EMPLOYEES IMPACTED",
45
+ ]
46
+
47
+
48
+ def scrape(
49
+ data_dir: Path = utils.WARN_DATA_DIR,
50
+ cache_dir: Path = utils.WARN_CACHE_DIR,
51
+ ) -> Path:
52
+ """
53
+ Scrape data from Massachusetts.
54
+
55
+ Keyword arguments:
56
+ data_dir -- the Path were the result will be saved (default WARN_DATA_DIR)
57
+ cache_dir -- the Path where results can be cached (default WARN_CACHE_DIR)
58
+
59
+ Returns: the Path where the file is written
60
+ """
61
+ # Fire up the cache
62
+ cache = Cache(cache_dir)
63
+ state_code = "ma"
64
+
65
+ # Download the index page. Mass.gov sits behind Akamai bot protection that
66
+ # rejects ordinary requests, so we route everything through Zyte, as the
67
+ # Louisiana and Texas scrapers do.
68
+ _, html = utils.get_with_zyte(__source__["url"])
69
+ cache.write(f"{state_code}/index.html", html)
70
+
71
+ # Find the links to the fiscal-year workbooks and the weekly CSV feed.
72
+ soup = BeautifulSoup(html, "html.parser")
73
+ excel_urls, csv_urls = _find_source_links(soup)
74
+ logger.debug(
75
+ f"Found {len(excel_urls)} workbook link(s) and {len(csv_urls)} CSV link(s)"
76
+ )
77
+
78
+ master_list: list = []
79
+
80
+ # The fiscal-year workbooks hold the historical record.
81
+ for url in excel_urls:
82
+ name = url.split("/doc/")[1].split("/")[0]
83
+ raw, _ = utils.get_with_zyte(url)
84
+ excel_path = cache.write_binary(f"{state_code}/{name}.xlsx", raw)
85
+ master_list.extend(_parse_workbook(excel_path))
86
+
87
+ # The weekly CSV feed carries the current fiscal year's running notices,
88
+ # which are not yet folded into a workbook.
89
+ for url in csv_urls:
90
+ name = os.path.basename(unquote(url))
91
+ raw, text = utils.get_with_zyte(url)
92
+ cache.write_binary(f"{state_code}/{name}", raw)
93
+ rows = list(csv.reader(text.splitlines()))
94
+ master_list.extend(_parse_flat_rows(rows))
95
+
96
+ # Drop exact duplicate rows while preserving order. Duplicates can appear at
97
+ # fiscal-year boundaries or across re-runs; distinct notices are untouched.
98
+ deduped = _dedupe(master_list)
99
+ logger.debug(
100
+ f"Parsed {len(master_list):,} rows, {len(deduped):,} after removing duplicates"
101
+ )
102
+
103
+ # Write it out
104
+ data_path = data_dir / f"{state_code}.csv"
105
+ utils.write_dict_rows_to_csv(
106
+ data_path, CANONICAL_HEADER, deduped, extrasaction="raise"
107
+ )
108
+
109
+ return data_path
110
+
111
+
112
+ def _find_source_links(soup: BeautifulSoup) -> tuple:
113
+ """Pull the workbook and CSV download links off the index page.
114
+
115
+ Args:
116
+ soup (BeautifulSoup): the parsed index page
117
+
118
+ Returns: a tuple of (sorted workbook urls, sorted csv urls)
119
+ """
120
+ excel_urls = set()
121
+ csv_urls = set()
122
+ for link in soup.find_all("a", href=True):
123
+ href = link["href"]
124
+ lower = href.lower()
125
+ if "http" not in lower:
126
+ href = urljoin(BASE_URL, href)
127
+ # Fiscal-year workbooks live at /doc/fyNN-warn-report.../download
128
+ if "/doc/" in lower and "warn-report" in lower:
129
+ excel_urls.add(href)
130
+ # The weekly feed is a WARN Report CSV under /files/
131
+ elif lower.endswith(".csv") and "warn" in lower:
132
+ csv_urls.add(href)
133
+ return sorted(excel_urls), sorted(csv_urls)
134
+
135
+
136
+ def _parse_workbook(excel_path: Path) -> list:
137
+ """Parse a fiscal-year workbook into a list of canonical row dicts.
138
+
139
+ Args:
140
+ excel_path (Path): the path to a cached .xlsx file
141
+
142
+ Returns: a list of dicts keyed by CANONICAL_HEADER
143
+ """
144
+ workbook = load_workbook(filename=excel_path, read_only=True, data_only=True)
145
+ rows: list = []
146
+ for sheet in workbook.worksheets:
147
+ sheet_rows = [list(r) for r in sheet.iter_rows(values_only=True)]
148
+ if _has_region_column(sheet_rows):
149
+ rows.extend(_parse_flat_rows(sheet_rows))
150
+ else:
151
+ rows.extend(_parse_region_sheet(sheet_rows, sheet.title.strip()))
152
+ workbook.close()
153
+ return rows
154
+
155
+
156
+ def _has_region_column(rows: list) -> bool:
157
+ """Return True if the sheet is a flat table with its own REGION column."""
158
+ header_index = _find_header_row(rows)
159
+ if header_index is None:
160
+ return False
161
+ return any(_norm(cell) == "REGION" for cell in rows[header_index])
162
+
163
+
164
+ def _find_header_row(rows: list):
165
+ """Find the index of the header row, or None if there isn't one."""
166
+ for i, row in enumerate(rows):
167
+ labels = {_norm(cell) for cell in row}
168
+ if "EMPLOYER" in labels or "COMPANY NAME" in labels:
169
+ return i
170
+ return None
171
+
172
+
173
+ def _parse_flat_rows(rows: list) -> list:
174
+ """Parse a flat table (FY2024+ workbooks and the weekly CSV) into row dicts.
175
+
176
+ The header row names its own columns, including REGION, so we map each
177
+ canonical field to the column where its header appears.
178
+ """
179
+ header_index = _find_header_row(rows)
180
+ if header_index is None:
181
+ return []
182
+
183
+ # Map each canonical column name to its position in the header row.
184
+ positions = {}
185
+ for col, cell in enumerate(rows[header_index]):
186
+ label = _norm(cell)
187
+ if label in CANONICAL_HEADER and label not in positions:
188
+ positions[label] = col
189
+
190
+ parsed = []
191
+ for row in rows[header_index + 1 :]:
192
+ record = {
193
+ field: _clean(row[pos]) if pos < len(row) else ""
194
+ for field, pos in positions.items()
195
+ }
196
+ record = {field: record.get(field, "") for field in CANONICAL_HEADER}
197
+ if _keep_row(record):
198
+ parsed.append(record)
199
+ return parsed
200
+
201
+
202
+ def _parse_region_sheet(rows: list, region: str) -> list:
203
+ """Parse an older per-region worksheet (FY2022, FY2023) into row dicts.
204
+
205
+ These sheets have no REGION column and lay their columns out positionally,
206
+ so the region comes from the worksheet name.
207
+ """
208
+ header_index = _find_header_row(rows)
209
+ if header_index is None:
210
+ return []
211
+
212
+ parsed = []
213
+ for row in rows[header_index + 1 :]:
214
+ record = {field: "" for field in CANONICAL_HEADER}
215
+ for pos, field in enumerate(REGION_SHEET_ORDER):
216
+ if pos < len(row):
217
+ record[field] = _clean(row[pos])
218
+ record["REGION"] = region
219
+ if _keep_row(record):
220
+ parsed.append(record)
221
+ return parsed
222
+
223
+
224
+ def _keep_row(record: dict) -> bool:
225
+ """Decide whether a parsed row is a real notice worth keeping."""
226
+ employer = record.get("EMPLOYER", "")
227
+ if not employer:
228
+ return False
229
+ # Skip stray header echoes and any total/count footer rows.
230
+ if _norm(employer) in ("EMPLOYER", "COMPANY NAME"):
231
+ return False
232
+ if _norm(employer).startswith("TOTAL"):
233
+ return False
234
+ return True
235
+
236
+
237
+ def _dedupe(rows: list) -> list:
238
+ """Drop exact duplicate rows while preserving their first-seen order."""
239
+ seen = set()
240
+ deduped = []
241
+ for row in rows:
242
+ key = tuple(row[field] for field in CANONICAL_HEADER)
243
+ if key not in seen:
244
+ seen.add(key)
245
+ deduped.append(row)
246
+ return deduped
247
+
248
+
249
+ def _clean(value) -> str:
250
+ """Normalize a cell value to a clean string for output.
251
+
252
+ Dates arrive from openpyxl as datetime objects; render them the way the
253
+ state writes them elsewhere (M/D/YYYY). Everything else is stringified and
254
+ stripped.
255
+ """
256
+ if value is None:
257
+ return ""
258
+ if isinstance(value, datetime):
259
+ return f"{value.month}/{value.day}/{value.year}"
260
+ return str(value).strip()
261
+
262
+
263
+ def _norm(value) -> str:
264
+ """Uppercase, whitespace-collapsed form of a cell, for label matching."""
265
+ if value is None:
266
+ return ""
267
+ return " ".join(str(value).split()).upper()
268
+
269
+
270
+ if __name__ == "__main__":
271
+ scrape()
warn/scrapers/nd.py CHANGED
@@ -48,7 +48,7 @@ def scrape(
48
48
  for page_index, page in enumerate(pdf.pages):
49
49
  rows = page.extract_table() or []
50
50
 
51
- for row in rows:
51
+ for rowindex, row in enumerate(rows):
52
52
  # Standardize each cell
53
53
  output_row = [_clean_text(cell) for cell in row]
54
54
 
@@ -65,6 +65,9 @@ def scrape(
65
65
  continue
66
66
  header_written = True
67
67
 
68
+ if rowindex > 0: # If not a header row
69
+ if output_row[1].endswith(", ND"):
70
+ output_row[1] = output_row[1][:-4]
68
71
  output_rows.append(output_row)
69
72
 
70
73
  # Write out to CSV
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: warn-scraper
3
- Version: 1.2.169.dev0
3
+ Version: 1.2.170.dev0
4
4
  Summary: Command-line interface for downloading WARN Act notices of qualified plant closings and mass layoffs from state government websites
5
5
  Author-email: Big Local News <biglocalnews@stanford.edu>
6
6
  License-Expression: Apache-2.0
@@ -30,13 +30,14 @@ warn/scrapers/in.py,sha256=0hOCdbUyls3xX7Q1z5OB6yCrXJFpsu2CmvfwRh9NTfY,2202
30
30
  warn/scrapers/ks.py,sha256=F_3biEMF7zgCX2XVuUACR74Vyzapta4SaM9SY3EuZCU,1266
31
31
  warn/scrapers/ky.py,sha256=7kJTNOzxChyXlcyBImmdwwmrczYksU8XNxbhQ2owmJs,9688
32
32
  warn/scrapers/la.py,sha256=52i7ICYH0R7omFyu-kmKi_s-8Rhy0vaPjNIKlW_OMC8,6938
33
+ warn/scrapers/ma.py,sha256=oJa3GMBm2YsG2nFqGbb2ZKKhurfN59ETeTcLABcVF00,8809
33
34
  warn/scrapers/md.py,sha256=Zjudr5X8-E3fjBXbOqnB830XEZqLznGzQbaJ5HSY0zQ,4425
34
35
  warn/scrapers/me.py,sha256=q36F4yJ7hvZsLayA3uBS1romo4X3Qf-sEi2Y7LAQCi8,1172
35
36
  warn/scrapers/mi.py,sha256=Ppyawp4nbzSBODuzDKeqnO9_9do5MFwK4Y_f3uc6blE,5846
36
37
  warn/scrapers/mo.py,sha256=wnnwQAiVPwuheMqptMXZpyQdiKNghhKwTO-Bnh9oXoU,3492
37
38
  warn/scrapers/ms.py,sha256=BZZoMw3TNtwzBeBsqKLsPznBcDjPaO49I5-yBU0e9AI,5502
38
39
  warn/scrapers/mt.py,sha256=t2MP4OCcuCEnrnvNgOu289P0eekZq4XaCK65qzgZX88,2457
39
- warn/scrapers/nd.py,sha256=x-mLrysn-Ns3tXg6MmjUYZ-4T0x6YUix_fuIldO5Dk0,2657
40
+ warn/scrapers/nd.py,sha256=3hXgOpu59QElYf44mFWYlBbTWDOlgmwjo8XXaN1GS6k,2848
40
41
  warn/scrapers/ne.py,sha256=JawuGJ3tCKvMd-N-p03gnltB4rol4QUJshMk2oyMPO4,4143
41
42
  warn/scrapers/nj.py,sha256=nwbMbeQuUJbYRVoyUyKZBmNqvqsXu3Habt-10r8DvZE,2230
42
43
  warn/scrapers/nm.py,sha256=55kDh65uOc-uMO8wuxPHhHN4Aw8Di2Fx66JBds8f2Io,3694
@@ -55,11 +56,9 @@ warn/scrapers/va.py,sha256=7Nle7qL0VNPiE653XyaP9HQqSfuJFDRr2kEkjOqLvFM,11269
55
56
  warn/scrapers/vt.py,sha256=d-bo4WK2hkrk4BhCCmLpEovcoZltlvdIUB6O0uaMx5A,1186
56
57
  warn/scrapers/wa.py,sha256=UXdVtHZo_a-XfoiyOooTRfTb9W3PErSZdKca6SRORgs,4282
57
58
  warn/scrapers/wi.py,sha256=ClEzXkwZbop0W4fkQgsb5oHAPUrb4luUPGV-jOKwkcg,4855
58
- warn_scraper-1.2.169.dev0.dist-info/licenses/LICENSE,sha256=ZV-QHyqPwyMuwuj0lI05JeSjV1NyzVEk8Yeu7FPtYS0,585
59
- warn_scraper-1.2.169.dev0.dist-info/METADATA,sha256=idfAf3i-wQj6UbjYHXsG73B3pU8lvD4soFhnBis-e5c,1780
60
- warn_scraper-1.2.169.dev0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
61
- warn_scraper-1.2.169.dev0.dist-info/entry_points.txt,sha256=poh_oSweObGlBSs1_2qZmnTodlOYD0KfO7-h7W2UQIw,47
62
- warn_scraper-1.2.169.dev0.dist-info/scm_file_list.json,sha256=PsUNADDLfiY6WCIaKYrGF-hSOzV6HMBxtHeJPnHAOAo,4147
63
- warn_scraper-1.2.169.dev0.dist-info/scm_version.json,sha256=4YY6UpShLyl1mHlnjJR2Fi1BirY30YZrm1c8Wg1OkHA,161
64
- warn_scraper-1.2.169.dev0.dist-info/top_level.txt,sha256=dZfms6N3kqVXufiPOo7YqOrAcUtYfNH_oyGvYUk9FB4,5
65
- warn_scraper-1.2.169.dev0.dist-info/RECORD,,
59
+ warn_scraper-1.2.170.dev0.dist-info/licenses/LICENSE,sha256=ZV-QHyqPwyMuwuj0lI05JeSjV1NyzVEk8Yeu7FPtYS0,585
60
+ warn_scraper-1.2.170.dev0.dist-info/METADATA,sha256=J76_LvKxe-qvqSaB0Hqkhi-7bEfvP6q5_kieFszDXxY,1780
61
+ warn_scraper-1.2.170.dev0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
62
+ warn_scraper-1.2.170.dev0.dist-info/entry_points.txt,sha256=poh_oSweObGlBSs1_2qZmnTodlOYD0KfO7-h7W2UQIw,47
63
+ warn_scraper-1.2.170.dev0.dist-info/top_level.txt,sha256=dZfms6N3kqVXufiPOo7YqOrAcUtYfNH_oyGvYUk9FB4,5
64
+ warn_scraper-1.2.170.dev0.dist-info/RECORD,,
@@ -1,138 +0,0 @@
1
- {
2
- "files": [
3
- ".pre-commit-config.yaml",
4
- "README.md",
5
- "Makefile",
6
- "setup.cfg",
7
- "LICENSE",
8
- "setup.py",
9
- "pyproject.toml",
10
- "Pipfile.lock",
11
- "MANIFEST.in",
12
- "Pipfile",
13
- ".gitignore",
14
- ".devcontainer/devcontainer.json",
15
- "docs/reference.rst",
16
- "docs/releasing.md",
17
- "docs/Makefile",
18
- "docs/sources.md",
19
- "docs/contributing.rst",
20
- "docs/index.rst",
21
- "docs/requirements.txt",
22
- "docs/make.bat",
23
- "docs/usage.md",
24
- "docs/conf.py",
25
- "docs/scrapers/az.md",
26
- "docs/scrapers/de.md",
27
- "docs/scrapers/or.md",
28
- "docs/scrapers/tx.md",
29
- "docs/scrapers/sc.md",
30
- "docs/scrapers/vt.md",
31
- "docs/scrapers/job_center.md",
32
- "docs/scrapers/dc.md",
33
- "docs/scrapers/ut.md",
34
- "docs/scrapers/mo.md",
35
- "docs/scrapers/al.md",
36
- "docs/scrapers/me.md",
37
- "docs/scrapers/ks.md",
38
- "docs/scrapers/in.md",
39
- "docs/scrapers/ok.md",
40
- "docs/scrapers/co.md",
41
- "docs/scrapers/ia.md",
42
- "docs/scrapers/va.md",
43
- "docs/scrapers/md.md",
44
- "docs/scrapers/ny.md",
45
- "docs/scrapers/wi.md",
46
- "docs/scrapers/ca.md",
47
- "docs/_static/releasing-changelog-entered.png",
48
- "docs/_static/releasing-publish-button.png",
49
- "docs/_static/releasing-actions-start.png",
50
- "docs/_static/gao-03-1003.pdf",
51
- "docs/_static/releasing-tag-button.png",
52
- "docs/_static/releasing-name-tag.png",
53
- "docs/_static/releasing-draft-button.png",
54
- "docs/_static/releasing-releases-button.png",
55
- "docs/_static/R42693.pdf",
56
- "docs/_static/releasing-actions-finished.png",
57
- "docs/_static/releasing-pypi.png",
58
- "docs/_static/releasing-name-release.png",
59
- "docs/_static/releasing-release-published.png",
60
- "docs/_static/releasing-changelog-button.png",
61
- "docs/_templates/sources.md.tmpl",
62
- "warn/__init__.py",
63
- "warn/utils.py",
64
- "warn/runner.py",
65
- "warn/cache.py",
66
- "warn/cli.py",
67
- "warn/scrapers/az.py",
68
- "warn/scrapers/la.py",
69
- "warn/scrapers/tx.py",
70
- "warn/scrapers/wa.py",
71
- "warn/scrapers/wi.py",
72
- "warn/scrapers/ga.py",
73
- "warn/scrapers/co.py",
74
- "warn/scrapers/de.py",
75
- "warn/scrapers/__init__.py",
76
- "warn/scrapers/ia.py",
77
- "warn/scrapers/al.py",
78
- "warn/scrapers/fl.py",
79
- "warn/scrapers/nj.py",
80
- "warn/scrapers/nd.py",
81
- "warn/scrapers/mi.py",
82
- "warn/scrapers/vt.py",
83
- "warn/scrapers/mt.py",
84
- "warn/scrapers/md.py",
85
- "warn/scrapers/sc.py",
86
- "warn/scrapers/ut.py",
87
- "warn/scrapers/il.py",
88
- "warn/scrapers/ky.py",
89
- "warn/scrapers/or.py",
90
- "warn/scrapers/in.py",
91
- "warn/scrapers/ca.py",
92
- "warn/scrapers/hi.py",
93
- "warn/scrapers/ak.py",
94
- "warn/scrapers/ri.py",
95
- "warn/scrapers/ny.py",
96
- "warn/scrapers/pa.py",
97
- "warn/scrapers/dc.py",
98
- "warn/scrapers/ct.py",
99
- "warn/scrapers/mo.py",
100
- "warn/scrapers/ok.py",
101
- "warn/scrapers/tn.py",
102
- "warn/scrapers/ks.py",
103
- "warn/scrapers/oh.py",
104
- "warn/scrapers/id.py",
105
- "warn/scrapers/nm.py",
106
- "warn/scrapers/me.py",
107
- "warn/scrapers/va.py",
108
- "warn/scrapers/ne.py",
109
- "warn/scrapers/sd.py",
110
- "warn/scrapers/ms.py",
111
- "warn/platforms/__init__.py",
112
- "warn/platforms/job_center/__init__.py",
113
- "warn/platforms/job_center/utils.py",
114
- "warn/platforms/job_center/cache.py",
115
- "warn/platforms/job_center/site.py",
116
- "warn/platforms/job_center/urls.py",
117
- "warn/pdfrodent/__init__.py",
118
- "warn/pdfrodent/pdfrodent.py",
119
- "tests/__init__.py",
120
- "tests/test_job_center.py",
121
- "tests/test_job_center_cache.py",
122
- "tests/test_openpyxl.py",
123
- "tests/conftest.py",
124
- "tests/test_cache.py",
125
- "tests/test_delete.py",
126
- "tests/fixtures/2021_page_2.html",
127
- "tests/fixtures/2021_page_1.html",
128
- "tests/cassettes/test_scrape_integration.yaml",
129
- "tests/cassettes/test_cached_search_results.yaml",
130
- "tests/cassettes/test_no_results.yaml",
131
- "tests/cassettes/test_paged_results.yaml",
132
- "tests/cassettes/test_missing_detail_page_values.yaml",
133
- "tests/cassettes/test_cached_detail_pages.yaml",
134
- ".github/dependabot-disabled.yml",
135
- ".github/workflows/continuous-deployment.yml",
136
- ".github/workflows/continuous-deployment.yml.broken-tests"
137
- ]
138
- }
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "1.2.168",
3
- "distance": 0,
4
- "node": "gf7b3dd26af1f3ad700762504bd7c5c0d23979507",
5
- "dirty": true,
6
- "branch": "HEAD",
7
- "node_date": "2026-07-14"
8
- }