@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.
Files changed (50) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +75 -0
  3. package/bin/install.js +45 -0
  4. package/package.json +29 -0
  5. package/skills/architecture-walkthrough/SKILL.md +223 -0
  6. package/skills/architecture-walkthrough/references/sections.md +29 -0
  7. package/skills/architecture-walkthrough/scripts/check_structure.py +200 -0
  8. package/skills/autotest-webapp-ui/SKILL.md +58 -0
  9. package/skills/backend-code-review/SKILL.md +386 -0
  10. package/skills/backend-code-review/references/report-format.md +333 -0
  11. package/skills/backend-code-review/scripts/list_routes.py +269 -0
  12. package/skills/backend-code-review/scripts/sweep.py +550 -0
  13. package/skills/backend-code-review/scripts/verify_citations.py +201 -0
  14. package/skills/be-brief/SKILL.md +18 -0
  15. package/skills/clarke-list-excel/SKILL.md +51 -0
  16. package/skills/clarke-list-excel/references/output-schema.md +125 -0
  17. package/skills/clarke-list-excel/scripts/clarke_common.py +251 -0
  18. package/skills/clarke-list-excel/scripts/clarke_extract.py +487 -0
  19. package/skills/clarke-list-excel/scripts/load_clarke.py +322 -0
  20. package/skills/clarke-list-excel/scripts/run_all.py +63 -0
  21. package/skills/datalab-api/SKILL.md +163 -0
  22. package/skills/datalab-api/references/parameters-and-payload.md +121 -0
  23. package/skills/datalab-api/references/table-selection.md +35 -0
  24. package/skills/datalab-api/scripts/datalab_tables.py +365 -0
  25. package/skills/find-test-seam/SKILL.md +41 -0
  26. package/skills/frontend-code-review/SKILL.md +247 -0
  27. package/skills/frontend-code-review-2/SKILL.md +192 -0
  28. package/skills/frontend-code-review-2/scripts/fetch_pr_comments.py +65 -0
  29. package/skills/frontend-code-review-2/scripts/render_report.py +139 -0
  30. package/skills/murtaza-breif/SKILL.md +143 -0
  31. package/skills/murtaza-breif/scripts/save_brief.py +128 -0
  32. package/skills/pdf-to-json/SKILL.md +42 -0
  33. package/skills/pdf-to-json/references/output-schema.md +168 -0
  34. package/skills/pdf-to-json/scripts/extract_figures.py +319 -0
  35. package/skills/pdf-to-json/scripts/load_mongo.py +287 -0
  36. package/skills/pdf-to-json/scripts/pdf_extract.py +1313 -0
  37. package/skills/record-api-traffic/SKILL.md +434 -0
  38. package/skills/record-api-traffic/references/reading-recordings.md +224 -0
  39. package/skills/record-api-traffic/scripts/check-schema.mjs +184 -0
  40. package/skills/record-api-traffic/scripts/dump-quotation.mjs +67 -0
  41. package/skills/record-api-traffic/scripts/dump-source-excel.mjs +75 -0
  42. package/skills/record-api-traffic/scripts/lib/repo.mjs +109 -0
  43. package/skills/record-api-traffic/scripts/preflight.py +528 -0
  44. package/skills/record-api-traffic/scripts/record-api-traffic.py +720 -0
  45. package/skills/refac-wrt-business-goal/SKILL.md +305 -0
  46. package/skills/refac-wrt-business-goal/references/critic.md +170 -0
  47. package/skills/system-resource-triage/SKILL.md +180 -0
  48. package/skills/system-resource-triage/scripts/reap.sh +116 -0
  49. package/skills/system-resource-triage/scripts/triage.sh +111 -0
  50. 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())