@sitar_fiercer4c/skills 0.1.0
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.
- package/LICENSE +5 -0
- package/README.md +75 -0
- package/bin/install.js +45 -0
- package/package.json +29 -0
- package/skills/architecture-walkthrough/SKILL.md +223 -0
- package/skills/architecture-walkthrough/references/sections.md +29 -0
- package/skills/architecture-walkthrough/scripts/check_structure.py +200 -0
- package/skills/autotest-webapp-ui/SKILL.md +58 -0
- package/skills/backend-code-review/SKILL.md +386 -0
- package/skills/backend-code-review/references/report-format.md +333 -0
- package/skills/backend-code-review/scripts/list_routes.py +269 -0
- package/skills/backend-code-review/scripts/sweep.py +550 -0
- package/skills/backend-code-review/scripts/verify_citations.py +201 -0
- package/skills/be-brief/SKILL.md +18 -0
- package/skills/clarke-list-excel/SKILL.md +51 -0
- package/skills/clarke-list-excel/references/output-schema.md +125 -0
- package/skills/clarke-list-excel/scripts/clarke_common.py +251 -0
- package/skills/clarke-list-excel/scripts/clarke_extract.py +487 -0
- package/skills/clarke-list-excel/scripts/load_clarke.py +322 -0
- package/skills/clarke-list-excel/scripts/run_all.py +63 -0
- package/skills/datalab-api/SKILL.md +163 -0
- package/skills/datalab-api/references/parameters-and-payload.md +121 -0
- package/skills/datalab-api/references/table-selection.md +35 -0
- package/skills/datalab-api/scripts/datalab_tables.py +365 -0
- package/skills/find-test-seam/SKILL.md +41 -0
- package/skills/frontend-code-review/SKILL.md +247 -0
- package/skills/frontend-code-review-2/SKILL.md +192 -0
- package/skills/frontend-code-review-2/scripts/fetch_pr_comments.py +65 -0
- package/skills/frontend-code-review-2/scripts/render_report.py +139 -0
- package/skills/murtaza-breif/SKILL.md +143 -0
- package/skills/murtaza-breif/scripts/save_brief.py +128 -0
- package/skills/pdf-to-json/SKILL.md +42 -0
- package/skills/pdf-to-json/references/output-schema.md +168 -0
- package/skills/pdf-to-json/scripts/extract_figures.py +319 -0
- package/skills/pdf-to-json/scripts/load_mongo.py +287 -0
- package/skills/pdf-to-json/scripts/pdf_extract.py +1313 -0
- package/skills/record-api-traffic/SKILL.md +434 -0
- package/skills/record-api-traffic/references/reading-recordings.md +224 -0
- package/skills/record-api-traffic/scripts/check-schema.mjs +184 -0
- package/skills/record-api-traffic/scripts/dump-quotation.mjs +67 -0
- package/skills/record-api-traffic/scripts/dump-source-excel.mjs +75 -0
- package/skills/record-api-traffic/scripts/lib/repo.mjs +109 -0
- package/skills/record-api-traffic/scripts/preflight.py +528 -0
- package/skills/record-api-traffic/scripts/record-api-traffic.py +720 -0
- package/skills/refac-wrt-business-goal/SKILL.md +305 -0
- package/skills/refac-wrt-business-goal/references/critic.md +170 -0
- package/skills/system-resource-triage/SKILL.md +180 -0
- package/skills/system-resource-triage/scripts/reap.sh +116 -0
- package/skills/system-resource-triage/scripts/triage.sh +111 -0
- package/skills/using-git-worktrees/SKILL.md +167 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read the Clarke price list into JSON and Markdown, photos mapped to part numbers.
|
|
3
|
+
|
|
4
|
+
python scripts/clarke_extract.py "<xlsx>" --out-dir <dir>
|
|
5
|
+
|
|
6
|
+
Writes `clarke-products.json` (every product, category and image mapping, plus
|
|
7
|
+
a summary that says what did *not* come through) and `clarke-products.md` (the
|
|
8
|
+
same thing rendered to read).
|
|
9
|
+
|
|
10
|
+
The interesting work is mapping 249 floating photos onto 1,558 products. See
|
|
11
|
+
`assign_images` — the naive version of that mapping loses 41 photos and looks
|
|
12
|
+
perfectly healthy while doing it. A photo's span is read as the set of
|
|
13
|
+
products it illustrates, so a picture drawn across several rows lands on every
|
|
14
|
+
product in those rows.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import warnings
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
26
|
+
from clarke_common import (convert, credential, excel_range, load_env,
|
|
27
|
+
media_index, picture_rows, table_blocks)
|
|
28
|
+
|
|
29
|
+
warnings.filterwarnings("ignore", module="openpyxl")
|
|
30
|
+
|
|
31
|
+
# Column letters as the sheet lays them out. Read by position rather than by
|
|
32
|
+
# matching the printed header, because the header prints "CRTN QTY" and
|
|
33
|
+
# "TRADER Price- Aed" — names that invite a fuzzy match that would just as
|
|
34
|
+
# happily bind to the wrong column on next year's edition.
|
|
35
|
+
COL_PART, COL_IMAGE, COL_DESC, COL_PKT, COL_CTN, COL_PRICE = 1, 2, 3, 4, 5, 6
|
|
36
|
+
|
|
37
|
+
SUPPLIER = "Clarke"
|
|
38
|
+
CURRENCY = "AED"
|
|
39
|
+
|
|
40
|
+
# A part number is a code. Prose in the part-number column is a heading that
|
|
41
|
+
# lost its formatting, and loading it makes a product called "Hand Tools Price
|
|
42
|
+
# List- UAE".
|
|
43
|
+
PART_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9./\-+&\" ]{0,29}$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def text(cell) -> str:
|
|
47
|
+
value = cell.value
|
|
48
|
+
if value is None:
|
|
49
|
+
return ""
|
|
50
|
+
return " ".join(str(value).split())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_number(value):
|
|
54
|
+
"""A price or quantity as a number, or None if the cell does not hold one.
|
|
55
|
+
|
|
56
|
+
openpyxl hands back real numbers for numeric cells, so the string path is
|
|
57
|
+
only for the ones typed as text — which in this workbook includes prices
|
|
58
|
+
written "8.00 " and quantities written "1 PC".
|
|
59
|
+
"""
|
|
60
|
+
if value is None:
|
|
61
|
+
return None
|
|
62
|
+
if isinstance(value, (int, float)):
|
|
63
|
+
return float(value)
|
|
64
|
+
cleaned = str(value).replace(",", "").strip()
|
|
65
|
+
match = re.search(r"\d+(?:\.\d+)?", cleaned)
|
|
66
|
+
return float(match.group()) if match else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find_header(sheet) -> int | None:
|
|
70
|
+
"""The row printing PART NO in column A. Everything above it is a title."""
|
|
71
|
+
for row in range(1, min(sheet.max_row, 60) + 1):
|
|
72
|
+
if text(sheet.cell(row, COL_PART)).upper().replace(" ", "") == "PARTNO":
|
|
73
|
+
return row
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def category_rows(sheet) -> dict:
|
|
78
|
+
"""Which rows are headings, and at which level.
|
|
79
|
+
|
|
80
|
+
A heading and a product look identical from the values alone — both put
|
|
81
|
+
text in column A — so the level comes from the formatting the sheet
|
|
82
|
+
actually uses: a top-level category is merged across A:F and set in 14pt,
|
|
83
|
+
a sub-category is an unmerged 10pt cell. Reading the two apart matters
|
|
84
|
+
because the breadcrumb is the only grouping this document has; collapse
|
|
85
|
+
them and every product under "PLIERS" reports its sub-category as its
|
|
86
|
+
category instead.
|
|
87
|
+
"""
|
|
88
|
+
merged = {r.min_row for r in sheet.merged_cells.ranges
|
|
89
|
+
if r.min_row == r.max_row and r.min_col == COL_PART and r.max_col >= 6}
|
|
90
|
+
levels = {}
|
|
91
|
+
for row in range(1, sheet.max_row + 1):
|
|
92
|
+
label = text(sheet.cell(row, COL_PART))
|
|
93
|
+
if not label or text(sheet.cell(row, COL_DESC)):
|
|
94
|
+
continue
|
|
95
|
+
size = sheet.cell(row, COL_PART).font.size or 0
|
|
96
|
+
levels[row] = "category" if (row in merged or size >= 14) else "subcategory"
|
|
97
|
+
return levels
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_products(sheet, header_row: int, levels: dict):
|
|
101
|
+
"""Walk the sheet in order, carrying the current breadcrumb."""
|
|
102
|
+
products, held_back = [], []
|
|
103
|
+
category = subcategory = None
|
|
104
|
+
|
|
105
|
+
for row in range(header_row + 1, sheet.max_row + 1):
|
|
106
|
+
part = text(sheet.cell(row, COL_PART))
|
|
107
|
+
description = text(sheet.cell(row, COL_DESC))
|
|
108
|
+
|
|
109
|
+
if row in levels:
|
|
110
|
+
if levels[row] == "category":
|
|
111
|
+
category, subcategory = part, None
|
|
112
|
+
else:
|
|
113
|
+
subcategory = part
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if not part and not description:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if not part or not description:
|
|
120
|
+
held_back.append({"row": row, "reason": "missing part number or description",
|
|
121
|
+
"partNo": part, "description": description})
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
if not PART_RE.match(part):
|
|
125
|
+
held_back.append({"row": row, "reason": "part number is not code-like",
|
|
126
|
+
"partNo": part, "description": description})
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
price = to_number(sheet.cell(row, COL_PRICE).value)
|
|
130
|
+
products.append({
|
|
131
|
+
"partNo": " ".join(part.split()),
|
|
132
|
+
"partNoRaw": part,
|
|
133
|
+
"description": description,
|
|
134
|
+
"category": category,
|
|
135
|
+
"subCategory": subcategory,
|
|
136
|
+
"pktQty": to_number(sheet.cell(row, COL_PKT).value),
|
|
137
|
+
"ctnQty": to_number(sheet.cell(row, COL_CTN).value),
|
|
138
|
+
"price": price,
|
|
139
|
+
"priceLabel": "TRADER Price",
|
|
140
|
+
"currency": CURRENCY,
|
|
141
|
+
"supplier": SUPPLIER,
|
|
142
|
+
"sheetRow": row,
|
|
143
|
+
})
|
|
144
|
+
return products, held_back
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def placements(sheet, datalab_spans: dict):
|
|
148
|
+
"""Every photo in the sheet: its anchor row, its span, and its bytes.
|
|
149
|
+
|
|
150
|
+
Three things here are not what the obvious API suggests:
|
|
151
|
+
|
|
152
|
+
- `image.path` is a template, not a location. openpyxl reports
|
|
153
|
+
`/xl/media/image1.jpeg` for 244 of this workbook's 249 placements, so
|
|
154
|
+
keying media on it collapses the whole catalogue onto two pictures.
|
|
155
|
+
`image._data()` returns the real bytes and is what gets hashed.
|
|
156
|
+
- These are one-cell anchors, so `anchor.to` is None and openpyxl knows
|
|
157
|
+
only where a photo *starts*. The span comes from Datalab, whose Picture
|
|
158
|
+
bbox carries both edges.
|
|
159
|
+
- The same media file is placed under several part numbers. That is the
|
|
160
|
+
document repeating a photo, not an extraction fault, so identical bytes
|
|
161
|
+
are recorded once and referenced many times.
|
|
162
|
+
"""
|
|
163
|
+
out = []
|
|
164
|
+
by_row: dict[int, list] = {}
|
|
165
|
+
for image in sheet._images:
|
|
166
|
+
anchor = image.anchor._from
|
|
167
|
+
by_row.setdefault(anchor.row + 1, []).append(image)
|
|
168
|
+
|
|
169
|
+
for row in sorted(by_row):
|
|
170
|
+
spans = list(datalab_spans.get(row, []))
|
|
171
|
+
for index, image in enumerate(sorted(by_row[row],
|
|
172
|
+
key=lambda i: i.anchor._from.col)):
|
|
173
|
+
try:
|
|
174
|
+
data = image._data()
|
|
175
|
+
except Exception as error: # noqa: BLE001
|
|
176
|
+
out.append({"anchorRow": row, "error": str(error)})
|
|
177
|
+
continue
|
|
178
|
+
span_to = spans[index] if index < len(spans) else row
|
|
179
|
+
out.append({
|
|
180
|
+
"anchorRow": row,
|
|
181
|
+
"spanTo": max(row, span_to),
|
|
182
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
183
|
+
"format": (image.format or "").lower() or "unknown",
|
|
184
|
+
"bytes": len(data),
|
|
185
|
+
"data": data,
|
|
186
|
+
})
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def assign_images(photos, products, levels):
|
|
191
|
+
"""Attach each photo to every product its span covers, and say how confidently.
|
|
192
|
+
|
|
193
|
+
A photo is anchored in the IMAGES column beside its row, but "beside" is
|
|
194
|
+
approximate: the anchor lands on whatever cell the top-left corner falls
|
|
195
|
+
in, and these photos are taller than one row. Matching on the anchor row
|
|
196
|
+
alone attaches 208 of 249 — the other 41 are anchored on a blank spacer
|
|
197
|
+
row or on the heading above their product, so they are silently dropped
|
|
198
|
+
while the run reports no error at all.
|
|
199
|
+
|
|
200
|
+
Reading the photo's whole span fixes that, and the span is also the
|
|
201
|
+
document's own statement of which products the picture is *of*: the one
|
|
202
|
+
photo drawn beside twenty rows of combination spanners illustrates all
|
|
203
|
+
twenty sizes. So every product row a photo covers receives it, not just
|
|
204
|
+
the first. Only when a photo covers no data row at all does it fall back
|
|
205
|
+
to the nearest one, and that case is recorded as `nearest` rather than
|
|
206
|
+
`span` so a reader can see which attachment was inferred. Photos are
|
|
207
|
+
searched outward by at most three rows, because the failure that guards
|
|
208
|
+
against is the opposite one — a generous radius will happily attach the
|
|
209
|
+
logo in row 1 to the first product forty rows below it.
|
|
210
|
+
|
|
211
|
+
Two things bound the fan-out, and both exist because a wrong photo on a
|
|
212
|
+
right-looking product is the one error nothing downstream can show you:
|
|
213
|
+
|
|
214
|
+
- **A span stops at a heading.** Some spans run past a sub-category row
|
|
215
|
+
into the group below — the wood carving set photo on row 447 reaches row
|
|
216
|
+
450, a nail chisel — and a heading is the sheet's own statement that
|
|
217
|
+
what follows is a different product. Counted as
|
|
218
|
+
`spans_truncated_at_heading`.
|
|
219
|
+
- **Where spans overlap, the nearest anchor above the row wins.** Taking
|
|
220
|
+
the first span to reach a row instead lets one tall photo swallow rows
|
|
221
|
+
that a later photo is anchored directly beside, and costs ten distinct
|
|
222
|
+
pictures their only home. Eleven placements still lose every row they
|
|
223
|
+
cover, because the sheet stacks photos over the same rows and a product
|
|
224
|
+
carries one; they are counted as `images_overridden`.
|
|
225
|
+
"""
|
|
226
|
+
by_row = {p["sheetRow"]: p for p in products}
|
|
227
|
+
rows = sorted(by_row)
|
|
228
|
+
unmatched = []
|
|
229
|
+
candidates: dict[int, list] = {}
|
|
230
|
+
|
|
231
|
+
for photo in photos:
|
|
232
|
+
if "error" in photo:
|
|
233
|
+
unmatched.append(photo)
|
|
234
|
+
continue
|
|
235
|
+
anchor, span_to = photo["anchorRow"], photo["spanTo"]
|
|
236
|
+
|
|
237
|
+
covered, truncated = [], False
|
|
238
|
+
for row in range(anchor, span_to + 1):
|
|
239
|
+
if row in levels and covered:
|
|
240
|
+
truncated = True
|
|
241
|
+
break
|
|
242
|
+
if row in by_row:
|
|
243
|
+
covered.append(row)
|
|
244
|
+
photo["truncatedAtHeading"] = truncated
|
|
245
|
+
photo["how"] = "span"
|
|
246
|
+
|
|
247
|
+
if not covered:
|
|
248
|
+
for distance in range(1, 4):
|
|
249
|
+
for candidate in (anchor - distance, span_to + distance):
|
|
250
|
+
if candidate in by_row:
|
|
251
|
+
covered, photo["how"] = [candidate], "nearest"
|
|
252
|
+
break
|
|
253
|
+
if covered:
|
|
254
|
+
break
|
|
255
|
+
if not covered:
|
|
256
|
+
unmatched.append(photo)
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
photo["covers"] = covered
|
|
260
|
+
for row in covered:
|
|
261
|
+
candidates.setdefault(row, []).append(photo)
|
|
262
|
+
|
|
263
|
+
# One image per product: the photo anchored nearest above the row, which
|
|
264
|
+
# is the one the sheet draws beside it. A "span" match beats a "nearest"
|
|
265
|
+
# one at the same distance, because the second is inference.
|
|
266
|
+
for photo in photos:
|
|
267
|
+
photo["attachedRows"] = []
|
|
268
|
+
for row, contenders in candidates.items():
|
|
269
|
+
winner = max(contenders,
|
|
270
|
+
key=lambda ph: (ph["how"] == "span", ph["anchorRow"]))
|
|
271
|
+
product = by_row[row]
|
|
272
|
+
product["imageSha256"] = winner["sha256"]
|
|
273
|
+
product["imageFormat"] = winner["format"]
|
|
274
|
+
product["imageMatch"] = winner["how"]
|
|
275
|
+
product["imageAnchorRow"] = winner["anchorRow"]
|
|
276
|
+
product["imageSpanRows"] = [winner["anchorRow"], winner["spanTo"]]
|
|
277
|
+
winner["attachedRows"].append(row)
|
|
278
|
+
|
|
279
|
+
# A product sharing its photo with a sibling row is worth being able to
|
|
280
|
+
# see: it is the difference between "the sheet illustrated this product"
|
|
281
|
+
# and "the sheet illustrated this product's group".
|
|
282
|
+
for photo in photos:
|
|
283
|
+
if len(photo["attachedRows"]) > 1:
|
|
284
|
+
for target in photo["attachedRows"]:
|
|
285
|
+
by_row[target]["imageShared"] = True
|
|
286
|
+
|
|
287
|
+
return rows, unmatched
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def render_markdown(payload: dict) -> str:
|
|
291
|
+
summary = payload["summary"]
|
|
292
|
+
lines = [f"# {payload['source']['file']}", ""]
|
|
293
|
+
lines.append(f"{summary['products']:,} products · "
|
|
294
|
+
f"{summary['categories']} categories · "
|
|
295
|
+
f"{summary['subCategories']} sub-categories · "
|
|
296
|
+
f"{summary['products_with_image']:,} with a photo")
|
|
297
|
+
lines.append("")
|
|
298
|
+
current = None
|
|
299
|
+
for product in payload["products"]:
|
|
300
|
+
breadcrumb = " → ".join(x for x in (product["category"],
|
|
301
|
+
product["subCategory"]) if x)
|
|
302
|
+
if breadcrumb != current:
|
|
303
|
+
current = breadcrumb
|
|
304
|
+
lines += ["", f"## {breadcrumb or 'Uncategorised'}", "",
|
|
305
|
+
"| Part No | Description | Pkt | Ctn | Price (AED) | Photo |",
|
|
306
|
+
"|---|---|---|---|---|---|"]
|
|
307
|
+
price = "" if product["price"] is None else f"{product['price']:,.2f}"
|
|
308
|
+
pkt = "" if product["pktQty"] is None else f"{product['pktQty']:g}"
|
|
309
|
+
ctn = "" if product["ctnQty"] is None else f"{product['ctnQty']:g}"
|
|
310
|
+
photo = (product.get("imageSha256") or "")[:8]
|
|
311
|
+
lines.append(f"| {product['partNo']} | {product['description']} | "
|
|
312
|
+
f"{pkt} | {ctn} | {price} | {photo} |")
|
|
313
|
+
return "\n".join(lines) + "\n"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def main() -> int:
|
|
317
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
318
|
+
parser.add_argument("workbook")
|
|
319
|
+
parser.add_argument("--out-dir", default=".")
|
|
320
|
+
parser.add_argument("--env")
|
|
321
|
+
parser.add_argument("--sheet", help="worksheet name (default: the first)")
|
|
322
|
+
parser.add_argument("--refresh", action="store_true",
|
|
323
|
+
help="force a new Datalab conversion")
|
|
324
|
+
parser.add_argument("--no-datalab", action="store_true",
|
|
325
|
+
help="skip Datalab; photo spans and the cross-check are lost")
|
|
326
|
+
args = parser.parse_args()
|
|
327
|
+
|
|
328
|
+
path = Path(args.workbook).expanduser()
|
|
329
|
+
if not path.is_file():
|
|
330
|
+
raise SystemExit(f"no such file: {path}")
|
|
331
|
+
out_dir = Path(args.out_dir).expanduser()
|
|
332
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
|
|
334
|
+
import openpyxl
|
|
335
|
+
book = openpyxl.load_workbook(path)
|
|
336
|
+
sheet = book[args.sheet] if args.sheet else book.worksheets[0]
|
|
337
|
+
|
|
338
|
+
header_row = find_header(sheet)
|
|
339
|
+
if header_row is None:
|
|
340
|
+
raise SystemExit("no PART NO header found in the first 60 rows — "
|
|
341
|
+
"is this the right sheet? Use --sheet to choose another.")
|
|
342
|
+
|
|
343
|
+
# ---- Datalab: photo spans and an independent opinion on where they sit
|
|
344
|
+
spans: dict[int, list] = {}
|
|
345
|
+
cross_check = {"ran": False}
|
|
346
|
+
table_range = None
|
|
347
|
+
if not args.no_datalab:
|
|
348
|
+
values, env_path = load_env(args.env)
|
|
349
|
+
key = credential(values, "DATALAB_API_KEY")
|
|
350
|
+
if not key:
|
|
351
|
+
raise SystemExit(
|
|
352
|
+
"DATALAB_API_KEY not found. Set it, point --env at a file that "
|
|
353
|
+
"has it, or pass --no-datalab to run on openpyxl alone.")
|
|
354
|
+
print(f"credentials: {env_path}")
|
|
355
|
+
response = convert(path, key, out_dir / "datalab_raw", refresh=args.refresh)
|
|
356
|
+
pictures = picture_rows(response)
|
|
357
|
+
for picture in pictures:
|
|
358
|
+
spans.setdefault(picture["row_from"], []).append(picture["row_to"])
|
|
359
|
+
for row in spans:
|
|
360
|
+
spans[row].sort()
|
|
361
|
+
tables = table_blocks(response)
|
|
362
|
+
if tables:
|
|
363
|
+
table_range = excel_range(tables[0]["bbox"])
|
|
364
|
+
cross_check = {
|
|
365
|
+
"ran": True,
|
|
366
|
+
"datalab_pictures": len(pictures),
|
|
367
|
+
"openpyxl_placements": len(sheet._images),
|
|
368
|
+
"table_range": table_range,
|
|
369
|
+
"table_blocks": len(tables),
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
levels = category_rows(sheet)
|
|
373
|
+
products, held_back = read_products(sheet, header_row, levels)
|
|
374
|
+
photos = placements(sheet, spans)
|
|
375
|
+
_, unmatched = assign_images(photos, products, levels)
|
|
376
|
+
|
|
377
|
+
# The completeness check: two readers, two code paths, one answer. Rows
|
|
378
|
+
# where they disagree are where a photo is at risk of landing on the wrong
|
|
379
|
+
# product, and no other signal in the output would show it.
|
|
380
|
+
if cross_check["ran"]:
|
|
381
|
+
datalab_anchor_rows = sorted(r for r, v in spans.items() for _ in v)
|
|
382
|
+
openpyxl_anchor_rows = sorted(i.anchor._from.row + 1 for i in sheet._images)
|
|
383
|
+
cross_check["anchor_rows_agree"] = datalab_anchor_rows == openpyxl_anchor_rows
|
|
384
|
+
cross_check["rows_only_in_datalab"] = sorted(
|
|
385
|
+
set(datalab_anchor_rows) - set(openpyxl_anchor_rows))
|
|
386
|
+
cross_check["rows_only_in_openpyxl"] = sorted(
|
|
387
|
+
set(openpyxl_anchor_rows) - set(datalab_anchor_rows))
|
|
388
|
+
|
|
389
|
+
media = media_index(path)
|
|
390
|
+
unsupported = sorted({Path(name).suffix.lstrip(".").lower() for name in media
|
|
391
|
+
if Path(name).suffix.lower() in (".emf", ".wmf")})
|
|
392
|
+
|
|
393
|
+
duplicate_parts = {}
|
|
394
|
+
for product in products:
|
|
395
|
+
duplicate_parts.setdefault(product["partNo"], []).append(product["sheetRow"])
|
|
396
|
+
duplicates = {k: v for k, v in duplicate_parts.items() if len(v) > 1}
|
|
397
|
+
|
|
398
|
+
distinct_images = {p["imageSha256"] for p in products if p.get("imageSha256")}
|
|
399
|
+
payload = {
|
|
400
|
+
"source": {"file": path.name, "path": str(path.resolve()),
|
|
401
|
+
"sheet": sheet.title, "headerRow": header_row,
|
|
402
|
+
"tableRange": table_range,
|
|
403
|
+
"rows": sheet.max_row, "columns": sheet.max_column},
|
|
404
|
+
"summary": {
|
|
405
|
+
"products": len(products),
|
|
406
|
+
"categories": sum(1 for v in levels.values() if v == "category"),
|
|
407
|
+
"subCategories": sum(1 for v in levels.values() if v == "subcategory"),
|
|
408
|
+
"image_placements": len(photos),
|
|
409
|
+
"products_with_image": sum(1 for p in products if p.get("imageSha256")),
|
|
410
|
+
"products_without_image": sum(1 for p in products
|
|
411
|
+
if not p.get("imageSha256")),
|
|
412
|
+
"distinct_images": len(distinct_images),
|
|
413
|
+
"media_files_in_workbook": len(media),
|
|
414
|
+
"images_matched_by_span": sum(1 for p in products
|
|
415
|
+
if p.get("imageMatch") == "span"),
|
|
416
|
+
"images_matched_by_nearest": sum(1 for p in products
|
|
417
|
+
if p.get("imageMatch") == "nearest"),
|
|
418
|
+
# A photo whose span covers several product rows is attached to
|
|
419
|
+
# every one of them, so these two say how much of the coverage
|
|
420
|
+
# above comes from spans rather than from single rows.
|
|
421
|
+
"images_spanning_multiple_products": sum(
|
|
422
|
+
1 for ph in photos if len(ph.get("attachedRows") or []) > 1),
|
|
423
|
+
"products_sharing_a_spanned_image": sum(
|
|
424
|
+
1 for p in products if p.get("imageShared")),
|
|
425
|
+
# Spans that ran into the next group and were cut at its heading.
|
|
426
|
+
"spans_truncated_at_heading": sum(
|
|
427
|
+
1 for ph in photos if ph.get("truncatedAtHeading")),
|
|
428
|
+
# Placements every one of whose rows a nearer-anchored photo won.
|
|
429
|
+
"images_overridden": sum(
|
|
430
|
+
1 for ph in photos
|
|
431
|
+
if ph.get("covers") and not ph["attachedRows"]),
|
|
432
|
+
"images_unmatched": len(unmatched),
|
|
433
|
+
"unsupported_image_formats": unsupported,
|
|
434
|
+
"products_without_price": sum(1 for p in products
|
|
435
|
+
if p["price"] is None),
|
|
436
|
+
"rows_held_back": len(held_back),
|
|
437
|
+
"duplicate_part_numbers": duplicates,
|
|
438
|
+
"cross_check": cross_check,
|
|
439
|
+
},
|
|
440
|
+
"products": products,
|
|
441
|
+
"rowsHeldBack": held_back,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
json_path = out_dir / "clarke-products.json"
|
|
445
|
+
json_path.write_text(json.dumps(payload, indent=2))
|
|
446
|
+
md_path = out_dir / "clarke-products.md"
|
|
447
|
+
md_path.write_text(render_markdown(payload))
|
|
448
|
+
|
|
449
|
+
# Photo bytes go beside the JSON so the upload stage does not reopen the
|
|
450
|
+
# workbook — and so a human can look at them.
|
|
451
|
+
image_dir = out_dir / "images"
|
|
452
|
+
image_dir.mkdir(exist_ok=True)
|
|
453
|
+
written = set()
|
|
454
|
+
for photo in photos:
|
|
455
|
+
if "error" in photo or photo["sha256"] in written:
|
|
456
|
+
continue
|
|
457
|
+
extension = photo["format"] if photo["format"] != "unknown" else "bin"
|
|
458
|
+
(image_dir / f"{photo['sha256']}.{extension}").write_bytes(photo["data"])
|
|
459
|
+
written.add(photo["sha256"])
|
|
460
|
+
|
|
461
|
+
summary = payload["summary"]
|
|
462
|
+
print(f"{summary['products']:,} products, "
|
|
463
|
+
f"{summary['categories']} categories, "
|
|
464
|
+
f"{summary['subCategories']} sub-categories")
|
|
465
|
+
print(f"{summary['products_with_image']:,} products carry a photo "
|
|
466
|
+
f"({summary['images_matched_by_span']} by span, "
|
|
467
|
+
f"{summary['images_matched_by_nearest']} by nearest); "
|
|
468
|
+
f"{summary['images_unmatched']} placements unattached")
|
|
469
|
+
print(f"{summary['images_spanning_multiple_products']} photos span more "
|
|
470
|
+
f"than one product row and are attached to every product in the "
|
|
471
|
+
f"span ({summary['products_sharing_a_spanned_image']:,} products "
|
|
472
|
+
f"share a photo that way)")
|
|
473
|
+
if cross_check.get("ran"):
|
|
474
|
+
verdict = "agree" if cross_check["anchor_rows_agree"] else "DISAGREE"
|
|
475
|
+
print(f"cross-check: Datalab and openpyxl {verdict} on all "
|
|
476
|
+
f"{cross_check['datalab_pictures']} photo anchor rows")
|
|
477
|
+
if unsupported:
|
|
478
|
+
print(f"note: workbook holds {', '.join(unsupported)} media, "
|
|
479
|
+
f"which browsers cannot display")
|
|
480
|
+
print(f"wrote {json_path}")
|
|
481
|
+
print(f"wrote {md_path}")
|
|
482
|
+
print(f"wrote {len(written)} image files to {image_dir}")
|
|
483
|
+
return 0
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
if __name__ == "__main__":
|
|
487
|
+
raise SystemExit(main())
|