@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,319 @@
1
+ #!/usr/bin/env python3
2
+ """Recover product photos from a PDF and attach them to the table rows they belong to.
3
+
4
+ Datalab returns `Picture` blocks for the big illustrations on a page and ignores
5
+ the small in-cell product photos entirely — on the Stanley price list that is 14
6
+ figures against 328 images actually embedded in the file. No mode or flag
7
+ changes this, so the pictures have to come from the PDF itself.
8
+
9
+ Each image is cut out of the *rendered* page rather than pulled from the PDF's
10
+ object store. Catalogue photos are almost always a raster plus a soft mask, and
11
+ extracting the raster alone gives you a black rectangle where the transparency
12
+ should be; rendering flattens the mask onto white and sidesteps CMYK and
13
+ indexed-colour conversion at the same time.
14
+
15
+ Rows are located by finding where each SKU string physically sits on the page,
16
+ not by dividing the table's height into equal bands — a table with a merged
17
+ category column or a wrapped description has rows of visibly different heights,
18
+ and equal bands quietly shift half the photos onto the neighbouring product.
19
+
20
+ python scripts/extract_figures.py <stem>.json [--dpi 200]
21
+
22
+ Also importable: `attach_figures(doc, pdf, out_dir, stem, dpi)` mutates `doc`,
23
+ which is how `pdf_extract.py --page-images` uses it.
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import re
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ # Deliberately permissive. A catalogue photo of a screwdriver bit is genuinely
33
+ # 4pt wide, so anything stricter silently drops real products; only degenerate
34
+ # placements — hairline rules, spacer pixels — need excluding.
35
+ MIN_SIDE = 2.0 # points
36
+ MIN_AREA = 20.0 # points²
37
+ PAD = 1.5 # points of breathing room around a crop
38
+ MIN_OVERLAP = 1.0 # points a row and an image must share before they are related
39
+ MIN_SHARE = 0.15 # ...as a fraction of the shorter of the two, to reject grazing
40
+
41
+
42
+ def normalise_header(value: str) -> str:
43
+ return re.sub(r"[^a-z0-9]", "", (value or "").lower())
44
+
45
+
46
+ def page_images(page):
47
+ """Image placements on one page, clamped to the page and sorted top-down.
48
+
49
+ Placements are kept separate rather than clustered. On a price list the
50
+ photos in the image column butt up against each other with a 0.1pt gap, so
51
+ any merge-by-proximity rule collapses a whole column of products into one
52
+ picture. One placement is one photo unless the document proves otherwise.
53
+ """
54
+ found = []
55
+ for image in page.images:
56
+ x0 = max(0.0, min(image["x0"], page.width))
57
+ x1 = max(0.0, min(image["x1"], page.width))
58
+ top = max(0.0, min(image["top"], page.height))
59
+ bottom = max(0.0, min(image["bottom"], page.height))
60
+ # Off-page overhang is normal — one Stanley banner starts at top=-104 —
61
+ # and pdfplumber refuses to crop outside the page box, so clamp first
62
+ # and drop whatever is left with no visible area.
63
+ if x1 - x0 < MIN_SIDE or bottom - top < MIN_SIDE:
64
+ continue
65
+ if (x1 - x0) * (bottom - top) < MIN_AREA:
66
+ continue
67
+ found.append({"name": image.get("name"), "bbox": [x0, top, x1, bottom]})
68
+ return sorted(found, key=lambda i: (i["bbox"][1], i["bbox"][0]))
69
+
70
+
71
+ def word_index(page):
72
+ """Every word on the page, keyed by its normalised text.
73
+
74
+ A SKU can legitimately appear twice on a page (in the body and in a footer),
75
+ so the value is a list and the caller picks by position.
76
+ """
77
+ lut = {}
78
+ for word in page.extract_words():
79
+ lut.setdefault(normalise_header(word["text"]), []).append(word)
80
+ return lut
81
+
82
+
83
+ def sku_column(headers):
84
+ for i, header in enumerate(headers or []):
85
+ if normalise_header(header) == "sku":
86
+ return i
87
+ return None
88
+
89
+
90
+ def anchor_rows(tables, lut, page_height):
91
+ """Give every row the vertical band it occupies, read off the page.
92
+
93
+ Each row is anchored on its SKU, then the band is stretched to the midpoint
94
+ between neighbouring anchors so the gaps between text lines belong to
95
+ somebody. Rows whose SKU cannot be found are left unanchored rather than
96
+ interpolated: a guessed band silently mislabels a photo, and a missing one
97
+ is at least visible in the coverage count.
98
+ """
99
+ anchors = []
100
+ for table in tables:
101
+ index = sku_column(table.get("headers"))
102
+ for row_no, row in enumerate(table.get("rows_data") or []):
103
+ candidates = []
104
+ if index is not None and index < len(row):
105
+ candidates.append(row[index])
106
+ candidates += [c for c in row if (c or "").strip()]
107
+ word = None
108
+ for candidate in candidates:
109
+ hits = lut.get(normalise_header(candidate or ""))
110
+ if hits:
111
+ word = hits[0]
112
+ break
113
+ anchors.append({"table": table["id"], "row": row_no,
114
+ "sku": (row[index] if index is not None and index < len(row)
115
+ else ""),
116
+ "word": word})
117
+
118
+ placed = [a for a in anchors if a["word"]]
119
+ placed.sort(key=lambda a: a["word"]["top"])
120
+ for i, anchor in enumerate(placed):
121
+ word = anchor["word"]
122
+ above = placed[i - 1]["word"]["bottom"] if i else 0.0
123
+ below = placed[i + 1]["word"]["top"] if i + 1 < len(placed) else page_height
124
+ anchor["band"] = [max(0.0, (word["top"] + above) / 2),
125
+ min(page_height, (word["bottom"] + below) / 2)]
126
+ return anchors
127
+
128
+
129
+ def overlap(a, b) -> float:
130
+ return max(0.0, min(a[1], b[1]) - max(a[0], b[0]))
131
+
132
+
133
+ def safe_stem(page_no: int, sku: str, used: set) -> str:
134
+ base = re.sub(r"[^A-Za-z0-9._-]", "_", (sku or "").strip())
135
+ stem = f"p{page_no:02d}_{base}" if base else f"p{page_no:02d}_img"
136
+ name, n = stem, 1
137
+ while name in used:
138
+ n += 1
139
+ name = f"{stem}-{n}"
140
+ used.add(name)
141
+ return name
142
+
143
+
144
+ def image_column(table):
145
+ """Which column holds the picture, so Markdown can put the photo back in it."""
146
+ rows = table.get("rows_data") or []
147
+ headers = table.get("headers") or []
148
+ width = len(headers) or max((len(r) for r in rows), default=0)
149
+ best, score = None, 0.0
150
+ for c in range(width):
151
+ hits = sum(1 for r in rows
152
+ if c < len(r) and (r[c] or "").strip().lower().startswith("image:"))
153
+ if hits and hits / max(1, len(rows)) > score:
154
+ best, score = c, hits / max(1, len(rows))
155
+ if best is not None:
156
+ return best
157
+ # No cell says "Image:", so fall back to a trailing column that is blank
158
+ # everywhere — on a fragmented table that is where the picture used to be.
159
+ if width and rows and all(not (r[width - 1] if width - 1 < len(r) else "").strip()
160
+ for r in rows):
161
+ return width - 1
162
+ return None
163
+
164
+
165
+ def attach_figures(doc: dict, pdf_path: Path, out_dir: Path, stem: str,
166
+ dpi: int = 200) -> dict:
167
+ """Crop every embedded image and record which rows it sits beside."""
168
+ import pdfplumber
169
+
170
+ target = out_dir / f"{stem}_images"
171
+ tables_by_page = {}
172
+ for table in doc.get("tables") or []:
173
+ tables_by_page.setdefault(table["page"], []).append(table)
174
+ sizes = {p["page"]: p.get("size") or {} for p in doc.get("pages") or []}
175
+ wanted = {p["page"] for p in doc.get("pages") or []}
176
+
177
+ figures, used_names = [], set()
178
+ row_images = {t["id"]: [[] for _ in (t.get("rows_data") or [])]
179
+ for t in doc.get("tables") or []}
180
+ anchored = unanchored = 0
181
+
182
+ with pdfplumber.open(pdf_path) as pdf:
183
+ for page_no in sorted(wanted):
184
+ if page_no > len(pdf.pages):
185
+ continue
186
+ page = pdf.pages[page_no - 1]
187
+ images = page_images(page)
188
+ tables = tables_by_page.get(page_no, [])
189
+ anchors = anchor_rows(tables, word_index(page), page.height) if tables else []
190
+ anchored += sum(1 for a in anchors if a.get("band"))
191
+ unanchored += sum(1 for a in anchors if not a.get("band"))
192
+ banded = [a for a in anchors if a.get("band")]
193
+
194
+ # Asked row-first rather than image-first. The photo column keeps its
195
+ # own vertical rhythm — it drifts a few points away from the text
196
+ # rows over the length of a page — so "which rows does this image
197
+ # cover" leaves a row stranded in the gap between two photos, while
198
+ # "which image is this row nearest to" always answers. An image
199
+ # taller than one line is a merged cell covering a run of products,
200
+ # exactly like the category column, and several rows simply pick it.
201
+ claims = {}
202
+ for anchor in banded:
203
+ band = anchor["band"]
204
+ best = None
205
+ for index, image in enumerate(images):
206
+ _, top, _, bottom = image["bbox"]
207
+ covered = overlap((top, bottom), band)
208
+ if covered < MIN_OVERLAP:
209
+ continue
210
+ share = covered / max(1e-6, min(bottom - top, band[1] - band[0]))
211
+ if best is None or (share, covered) > best[0]:
212
+ best = ((share, covered), index)
213
+ if best and best[0][0] >= MIN_SHARE:
214
+ claims.setdefault(best[1], []).append(anchor)
215
+
216
+ target.mkdir(parents=True, exist_ok=True)
217
+ for number, image in enumerate(images):
218
+ x0, top, x1, bottom = image["bbox"]
219
+ touching = claims.get(number, [])
220
+ primary = touching[0] if touching else None
221
+
222
+ name = safe_stem(page_no, primary["sku"] if primary else "", used_names)
223
+ crop = page.crop((max(0.0, x0 - PAD), max(0.0, top - PAD),
224
+ min(page.width, x1 + PAD),
225
+ min(page.height, bottom + PAD)))
226
+ path = target / f"{name}.png"
227
+ # Saved through PIL rather than PageImage.save, which writes PNG
228
+ # bytes under whatever extension it is handed — a file called
229
+ # .jpg that is really a PNG breaks importers that trust the name.
230
+ crop.to_image(resolution=dpi).original.convert("RGB").save(path)
231
+
232
+ entry = {
233
+ "name": path.name,
234
+ "file": f"{stem}_images/{path.name}",
235
+ "page": page_no,
236
+ "source": "pdf-render",
237
+ "bbox_pdf": [round(v, 1) for v in image["bbox"]],
238
+ "rows": [{"table": a["table"], "row": a["row"], "sku": a["sku"]}
239
+ for a in touching],
240
+ }
241
+ size = sizes.get(page_no) or {}
242
+ if size.get("width") and size.get("height"):
243
+ sx, sy = size["width"] / page.width, size["height"] / page.height
244
+ entry["bbox"] = [round(x0 * sx), round(top * sy),
245
+ round(x1 * sx), round(bottom * sy)]
246
+ figures.append(entry)
247
+ for a in touching:
248
+ row_images[a["table"]][a["row"]].append(path.name)
249
+
250
+ # Datalab's own Picture blocks are rendered too. Their bytes only
251
+ # come back when the API was asked for them, so without this every
252
+ # figure link in the Markdown points at a file that was never
253
+ # written — a broken image reads as a failed extraction.
254
+ size = sizes.get(page_no) or {}
255
+ if size.get("width") and size.get("height"):
256
+ sx, sy = page.width / size["width"], page.height / size["height"]
257
+ for figure in doc.get("figures") or []:
258
+ if figure.get("page") != page_no or not figure.get("bbox"):
259
+ continue
260
+ fx0, ftop, fx1, fbot = figure["bbox"]
261
+ box = (max(0.0, fx0 * sx), max(0.0, ftop * sy),
262
+ min(page.width, fx1 * sx), min(page.height, fbot * sy))
263
+ if box[2] - box[0] < MIN_SIDE or box[3] - box[1] < MIN_SIDE:
264
+ continue
265
+ name = Path(figure["name"]).stem + ".png"
266
+ page.crop(box).to_image(resolution=dpi).original \
267
+ .convert("RGB").save(target / name)
268
+ figure["file"] = f"{stem}_images/{name}"
269
+ figure["file_source"] = "pdf-render"
270
+
271
+ for table in doc.get("tables") or []:
272
+ names = row_images.get(table["id"]) or []
273
+ if any(names):
274
+ table["row_images"] = names
275
+ table["image_column"] = image_column(table)
276
+ for i, record in enumerate(table.get("records") or []):
277
+ record["images"] = names[i]
278
+
279
+ for existing in doc.get("figures") or []:
280
+ existing.setdefault("source", "datalab")
281
+ doc.setdefault("figures", [])
282
+ doc["figures"] = list(doc["figures"]) + figures
283
+
284
+ rows_total = doc.get("summary", {}).get("table_rows", 0)
285
+ with_photo = sum(1 for t in doc.get("tables") or []
286
+ for names in (t.get("row_images") or []) if names)
287
+ doc.setdefault("summary", {})["page_images"] = {
288
+ "extracted": len(figures),
289
+ "dpi": dpi,
290
+ "rows_with_image": with_photo,
291
+ "rows_without_image": rows_total - with_photo,
292
+ "rows_anchored": anchored,
293
+ "rows_unanchored": unanchored,
294
+ "unplaced_images": sum(1 for f in figures if not f["rows"]),
295
+ }
296
+ return doc["summary"]["page_images"]
297
+
298
+
299
+ def main():
300
+ parser = argparse.ArgumentParser(description=__doc__,
301
+ formatter_class=argparse.RawDescriptionHelpFormatter)
302
+ parser.add_argument("document", type=Path, help="a <stem>.json from pdf_extract.py")
303
+ parser.add_argument("--pdf", type=Path, help="override the PDF path recorded in the JSON")
304
+ parser.add_argument("--dpi", type=int, default=200)
305
+ args = parser.parse_args()
306
+
307
+ doc = json.loads(args.document.read_text())
308
+ pdf = args.pdf or Path(doc["source"]["path"])
309
+ if not pdf.is_file():
310
+ parser.error(f"PDF not found: {pdf}")
311
+
312
+ stats = attach_figures(doc, pdf, args.document.parent, args.document.stem, args.dpi)
313
+ args.document.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
314
+ print(json.dumps(stats, indent=2), file=sys.stderr)
315
+ print(f" json -> {args.document}", file=sys.stderr)
316
+
317
+
318
+ if __name__ == "__main__":
319
+ main()
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env python3
2
+ """Load extracted price-list rows into MongoDB, keyed on SKU.
3
+
4
+ python scripts/load_mongo.py <stem>.json --collection stanley_tools [--dry-run]
5
+
6
+ Five fields per product: Category, Sku, Description, List Price AED and
7
+ Suggested End User Price (AED). Prices are stored as numbers rather than the
8
+ strings the PDF printed, because a price list you cannot sort or compare on is
9
+ a price list you have to re-parse at every query.
10
+
11
+ Two things this refuses to do quietly, both learned from the Stanley file:
12
+
13
+ Not every extracted row is a product. A reprinted column header that wrapped
14
+ across two lines, or a category label printed sideways in the page margin and
15
+ swept into the table body, arrives looking exactly like a row. Loaded, they
16
+ become products called "Category Sku" and "h al T ols nic Me a o c". Rows whose
17
+ SKU is not code-like are held back and listed, not dropped in silence — the
18
+ count of what did not load is the number worth seeing.
19
+
20
+ SKU is the upsert key, so a SKU the document uses twice collapses two products
21
+ into one. On the Stanley list `1-51-033` is both a 16oz and a 20oz hammer. That
22
+ is the document's error rather than the extraction's, but the loss happens here,
23
+ so it is reported here.
24
+
25
+ The connection string comes from MONGODB_URI (or MONGO_URI) in the environment,
26
+ or from any .env found walking up from the JSON, the working directory and this
27
+ script — the last of those is what finds the project's Backend/.env when the
28
+ command is run from wherever the PDF happens to live, which is usually outside
29
+ the repo. It is never printed: credentials in a scrollback outlive the session.
30
+ """
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ import re
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
40
+ from pdf_extract import find_env_value, normalise_header
41
+
42
+ URI_KEYS = ("MONGODB_URI", "MONGO_URI")
43
+
44
+ # What the PDF calls each field, normalised. The Stanley list runs two schemas
45
+ # at once and the narrower one truncates its last header to "Suggested End", so
46
+ # matching on the full name alone silently drops the price on 6 of 26 blocks.
47
+ FIELDS = {
48
+ "Category": ("category",),
49
+ "Sku": ("sku",),
50
+ "Description": ("description",),
51
+ "List Price AED": ("listpriceaed", "listprice"),
52
+ "Suggested End User Price (AED)": ("suggestedenduserpriceaed", "suggestedend",
53
+ "suggesteduserprice"),
54
+ }
55
+ NUMERIC = ("List Price AED", "Suggested End User Price (AED)")
56
+ SKU_RE = re.compile(r"[A-Za-z0-9][\w./-]*")
57
+
58
+
59
+ def to_number(value: str):
60
+ """A price as a number, or None if the cell does not hold one."""
61
+ text = (value or "").replace(",", "").strip()
62
+ if not text:
63
+ return None
64
+ match = re.fullmatch(r"(\d+(?:\.\d+)?)", text)
65
+ return float(match.group(1)) if match else None
66
+
67
+
68
+ def column_map(headers):
69
+ seen = {normalise_header(h): i for i, h in enumerate(headers or []) if h}
70
+ return {field: next((seen[a] for a in aliases if a in seen), None)
71
+ for field, aliases in FIELDS.items()}
72
+
73
+
74
+ def documents(doc: dict):
75
+ """Turn the tables into products, keeping what was rejected and why."""
76
+ products, rejected = [], []
77
+ for table in doc.get("tables") or []:
78
+ columns = column_map(table.get("headers"))
79
+ categories = table.get("categories") or []
80
+ fills = table.get("category_fill") or []
81
+ for i, row in enumerate(table.get("rows_data") or []):
82
+ def cell(field):
83
+ index = columns.get(field)
84
+ return (row[index] or "").strip() if index is not None and index < len(row) else ""
85
+
86
+ sku = cell("Sku")
87
+ # The filled category, not the printed cell: a rowspan-merged column
88
+ # prints its value once and leaves the rest of the run blank.
89
+ category = categories[i] if i < len(categories) else cell("Category")
90
+ record = {
91
+ "Category": category,
92
+ "Sku": sku,
93
+ "Description": cell("Description"),
94
+ "List Price AED": to_number(cell("List Price AED")),
95
+ "Suggested End User Price (AED)": to_number(
96
+ cell("Suggested End User Price (AED)")),
97
+ }
98
+ context = {"page": table.get("page"), "table": table.get("id"), "row": i,
99
+ "category_fill": fills[i] if i < len(fills) else None}
100
+
101
+ if not sku:
102
+ rejected.append((record, context, "no SKU"))
103
+ elif not SKU_RE.fullmatch(sku):
104
+ # Spaces and stray capitals are how a wrapped header line and a
105
+ # sideways margin label both present once they land in a cell.
106
+ rejected.append((record, context, "SKU is not a product code"))
107
+ elif record["List Price AED"] is None:
108
+ rejected.append((record, context, "no usable List Price AED"))
109
+ else:
110
+ products.append((record, context))
111
+ return products, rejected
112
+
113
+
114
+ def connection_uri(document: Path | None):
115
+ """The URI from the environment or a .env, and where it points, minus secrets.
116
+
117
+ The script's own directory is searched as well as the JSON's and the working
118
+ directory: a skill is invoked from wherever the PDF lives, which is often
119
+ outside the repo that holds Backend/.env, and walking up from the script is
120
+ what reaches it. Both the symlinked and the real path, since the skill is
121
+ reachable through either and only one of them sits under the repo.
122
+ """
123
+ here = Path(__file__).parent
124
+ roots = [Path.cwd(), here.absolute(), here.resolve()]
125
+ if document is not None:
126
+ roots.insert(0, document.resolve().parent)
127
+ for key in URI_KEYS:
128
+ uri = os.environ.get(key) or find_env_value(key, roots)
129
+ if uri:
130
+ # Host and database only. Everything before the '@' is the password.
131
+ tail = re.sub(r"^[^:]+://(?:[^@/]*@)?", "", uri)
132
+ host, _, path = tail.partition("/")
133
+ return uri, key, host, path.split("?")[0] or None
134
+ return None, None, None, None
135
+
136
+
137
+ def describe_target(uri, database_name, collection_name):
138
+ """What is actually at the other end, read-only — writes nothing."""
139
+ from pymongo import MongoClient
140
+ client = MongoClient(uri, serverSelectionTimeoutMS=15000, appname="pdf-to-json")
141
+ try:
142
+ database = client.get_database(database_name) if database_name \
143
+ else client.get_default_database()
144
+ if database is None:
145
+ raise SystemExit("the URI names no database — pass --database")
146
+ names = sorted(database.list_collection_names())
147
+ print(f"database: {database.name}", file=sys.stderr)
148
+ print(f"collections ({len(names)}):", file=sys.stderr)
149
+ for name in names:
150
+ mark = " <- target" if name == collection_name else ""
151
+ print(f" {database[name].estimated_document_count():7d} {name}{mark}",
152
+ file=sys.stderr)
153
+ if collection_name not in names:
154
+ print(f" '{collection_name}' is not in this database", file=sys.stderr)
155
+ finally:
156
+ client.close()
157
+
158
+
159
+ def main():
160
+ parser = argparse.ArgumentParser(description=__doc__,
161
+ formatter_class=argparse.RawDescriptionHelpFormatter)
162
+ parser.add_argument("document", type=Path, nargs="?",
163
+ help="a <stem>.json from pdf_extract.py")
164
+ parser.add_argument("--collection", required=True, help="collection to upsert into")
165
+ parser.add_argument("--database", help="defaults to the database named in the URI")
166
+ parser.add_argument("--dry-run", action="store_true",
167
+ help="report exactly what would be written, and connect to nothing")
168
+ parser.add_argument("--check", action="store_true",
169
+ help="report which host and database the URI points at, and what "
170
+ "collections are already there; writes nothing")
171
+ parser.add_argument("--emit", type=Path,
172
+ help="write the validated documents to a JSON file instead of "
173
+ "loading them, for review or for a loader of your own")
174
+ args = parser.parse_args()
175
+
176
+ if args.check:
177
+ uri, key, host, path = connection_uri(args.document)
178
+ if not uri:
179
+ parser.error(f"no connection string: set one of {', '.join(URI_KEYS)}")
180
+ print(f"{key} -> {host}, database in URI: {path or '(none)'}", file=sys.stderr)
181
+ describe_target(uri, args.database, args.collection)
182
+ return
183
+
184
+ if args.document is None:
185
+ parser.error("a <stem>.json is required unless --check is given")
186
+
187
+ doc = json.loads(args.document.read_text())
188
+ products, rejected = documents(doc)
189
+
190
+ counts = {}
191
+ for record, _, reason in rejected:
192
+ counts[reason] = counts.get(reason, 0) + 1
193
+ seen = {}
194
+ for record, context in products:
195
+ seen.setdefault(record["Sku"], []).append(context)
196
+ collisions = {s: c for s, c in seen.items() if len(c) > 1}
197
+
198
+ total = doc.get("summary", {}).get("table_rows", len(products) + len(rejected))
199
+ print(f"{total} extracted rows -> {len(products)} products "
200
+ f"({len(seen)} distinct SKUs), {len(rejected)} held back", file=sys.stderr)
201
+ for reason, n in sorted(counts.items(), key=lambda kv: -kv[1]):
202
+ print(f" {n:4d} {reason}", file=sys.stderr)
203
+ for sku, places in collisions.items():
204
+ pages = ", ".join(f"p{c['page']}" for c in places)
205
+ print(f" WARNING SKU {sku} appears {len(places)} times ({pages}); "
206
+ f"upserting on SKU keeps only the last", file=sys.stderr)
207
+ inferred = sum(1 for _, c in products if (c["category_fill"] or "").startswith("carried"))
208
+ if inferred:
209
+ print(f" NOTE {inferred} of {len(products)} categories are inferred from "
210
+ f"neighbouring rows, not stated by the document", file=sys.stderr)
211
+
212
+ if rejected:
213
+ print(" held back:", file=sys.stderr)
214
+ for record, context, reason in rejected[:25]:
215
+ print(f" p{context['page']:<3} {reason:<28} "
216
+ f"Sku={record['Sku']!r} Description={record['Description'][:34]!r}",
217
+ file=sys.stderr)
218
+ if len(rejected) > 25:
219
+ print(f" ... and {len(rejected) - 25} more", file=sys.stderr)
220
+
221
+ if args.emit:
222
+ # Last write wins, matching what upserting on Sku would leave behind, so
223
+ # the file is what the collection would contain rather than a longer list.
224
+ deduped = {r["Sku"]: r for r, _ in products}
225
+ args.emit.write_text(json.dumps(list(deduped.values()), indent=2,
226
+ ensure_ascii=False) + "\n")
227
+ print(f"\n{len(deduped)} documents -> {args.emit} (nothing loaded)", file=sys.stderr)
228
+ return
229
+
230
+ if args.dry_run:
231
+ print(f"\ndry run: nothing written. Would upsert {len(seen)} documents "
232
+ f"into '{args.collection}'.", file=sys.stderr)
233
+ print(json.dumps([r for r, _ in products[:3]], indent=2, ensure_ascii=False))
234
+ return
235
+
236
+ uri, key, host, _ = connection_uri(args.document)
237
+ if not uri:
238
+ parser.error(f"no connection string: set one of {', '.join(URI_KEYS)}")
239
+ # Naming the host is not the same as printing the URI, and without it a load
240
+ # that went to the wrong cluster looks identical to one that went to the right
241
+ # one — the collection is simply "missing" wherever you happen to be looking.
242
+ print(f"\nconnecting via {key} -> {host}", file=sys.stderr)
243
+
244
+ from pymongo import MongoClient, UpdateOne
245
+ from pymongo.errors import PyMongoError
246
+
247
+ client = MongoClient(uri, serverSelectionTimeoutMS=15000, appname="pdf-to-json")
248
+ database = client.get_database(args.database) if args.database \
249
+ else client.get_default_database()
250
+ if database is None:
251
+ parser.error("the URI names no database — pass --database")
252
+ collection = database[args.collection]
253
+
254
+ try:
255
+ collection.create_index("Sku", unique=True)
256
+ result = collection.bulk_write(
257
+ [UpdateOne({"Sku": r["Sku"]}, {"$set": r}, upsert=True) for r, _ in products],
258
+ ordered=False)
259
+ # Read the collection back rather than trusting the write acknowledgement.
260
+ # A driver report of "788 inserted" describes the request that was
261
+ # accepted, and it is possible to hold one of those in hand while the
262
+ # documents are not in the database you think you are looking at — so
263
+ # count them, name the database, and let the two figures disagree out loud.
264
+ stored = collection.count_documents({})
265
+ listed = args.collection in database.list_collection_names()
266
+ except PyMongoError as error:
267
+ # Deliberately not printing the URI: it carries the password.
268
+ print(f"MongoDB refused the write: {error}", file=sys.stderr)
269
+ raise SystemExit(1)
270
+ finally:
271
+ client.close()
272
+
273
+ print(f"\n{host}/{database.name}.{args.collection}: "
274
+ f"{result.upserted_count} inserted, {result.modified_count} updated, "
275
+ f"{len(seen) - result.upserted_count - result.modified_count} unchanged",
276
+ file=sys.stderr)
277
+ missing = "" if listed else " — but it is NOT in list_collection_names()"
278
+ print(f" verified: {stored} documents now in the collection{missing}",
279
+ file=sys.stderr)
280
+ if stored < len(seen) or not listed:
281
+ print(" WARNING the write was acknowledged but the collection does not read "
282
+ "back as expected — check you are inspecting this host and database",
283
+ file=sys.stderr)
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()