@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,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()
|