@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,201 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resolve every file:LINE citation in a review against the source tree.
|
|
3
|
+
|
|
4
|
+
Prints the real source slice back for each citation so the claim riding on it can be
|
|
5
|
+
checked, not just the line number, and flags three failure modes:
|
|
6
|
+
|
|
7
|
+
NOT-FOUND path does not resolve, or the line is past end of file
|
|
8
|
+
AMBIGUOUS a bare filename matches more than one file in the tree
|
|
9
|
+
TRUNCATED-CONSTRUCT a cited range ends mid-construct (braces unbalanced across it),
|
|
10
|
+
so anything the finding claims is absent may sit just past it
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python3 verify_citations.py REPORT.md [REPORT.md ...] --root <backend>
|
|
14
|
+
python3 verify_citations.py REPORT.md --root <backend> --problems # flagged rows only
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
|
|
23
|
+
CITATION = re.compile(
|
|
24
|
+
r"(?<![\w/.])"
|
|
25
|
+
r"((?:[A-Za-z0-9_.\-]+/)*[A-Za-z0-9_.\-]+\.(?:js|cjs|mjs|jsx|ts|tsx|json|env|yml|yaml))"
|
|
26
|
+
r":(\d+)(?:\s*[-–—]\s*(\d+))?"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
SKIP_DIRS = {"node_modules", ".git", "dist", "build", "coverage", "__pycache__", ".next"}
|
|
30
|
+
CODE_EXT = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".tsx")
|
|
31
|
+
|
|
32
|
+
# A finding that asserts something is ABSENT is only checkable over a complete construct: if
|
|
33
|
+
# the cited range stops mid-function, the thing claimed missing may sit on the next line. On a
|
|
34
|
+
# positive claim, citing a fragment is normal, so the same imbalance is not worth reporting.
|
|
35
|
+
NEGATION = re.compile(
|
|
36
|
+
r"\b(?:no|never|lacks?|lacking|missing|absent|without|none|nothing|un(?:guarded|validated"
|
|
37
|
+
r"|checked|handled|caught)|do(?:es)?\s+not|doesn't|is\s+not|are\s+not|fails?\s+to"
|
|
38
|
+
r"|neither|nor|omits?|omitted|skips?|bypasses)\b",
|
|
39
|
+
re.I,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def index_tree(root):
|
|
44
|
+
"""Map every relative path, and every path suffix, to the files it can name."""
|
|
45
|
+
by_suffix = defaultdict(set)
|
|
46
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
47
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
48
|
+
for fn in filenames:
|
|
49
|
+
full = os.path.join(dirpath, fn)
|
|
50
|
+
relpath = os.path.relpath(full, root)
|
|
51
|
+
parts = relpath.split(os.sep)
|
|
52
|
+
for i in range(len(parts)):
|
|
53
|
+
by_suffix["/".join(parts[i:])].add(relpath)
|
|
54
|
+
return by_suffix
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve(cited, by_suffix):
|
|
58
|
+
"""Return (relpath, None) or (None, reason). Citations are usually partial paths."""
|
|
59
|
+
key = cited.lstrip("./")
|
|
60
|
+
hits = by_suffix.get(key)
|
|
61
|
+
if not hits:
|
|
62
|
+
return None, "NOT-FOUND"
|
|
63
|
+
if len(hits) > 1:
|
|
64
|
+
# An exact whole-path match beats suffix matches.
|
|
65
|
+
exact = {h for h in hits if h.replace(os.sep, "/") == key}
|
|
66
|
+
if len(exact) == 1:
|
|
67
|
+
return exact.pop(), None
|
|
68
|
+
return None, "AMBIGUOUS (" + ", ".join(sorted(hits)) + ")"
|
|
69
|
+
return next(iter(hits)), None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def strip_noncode(text):
|
|
73
|
+
"""Blank out strings, template literals and comments so brace counting means something."""
|
|
74
|
+
out = []
|
|
75
|
+
i, n = 0, len(text)
|
|
76
|
+
while i < n:
|
|
77
|
+
c = text[i]
|
|
78
|
+
if c == "/" and i + 1 < n and text[i + 1] == "/":
|
|
79
|
+
while i < n and text[i] != "\n":
|
|
80
|
+
i += 1
|
|
81
|
+
elif c == "/" and i + 1 < n and text[i + 1] == "*":
|
|
82
|
+
i += 2
|
|
83
|
+
while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
|
|
84
|
+
if text[i] == "\n":
|
|
85
|
+
out.append("\n")
|
|
86
|
+
i += 1
|
|
87
|
+
i += 2
|
|
88
|
+
elif c in "\"'`":
|
|
89
|
+
quote = c
|
|
90
|
+
i += 1
|
|
91
|
+
while i < n and text[i] != quote:
|
|
92
|
+
if text[i] == "\\":
|
|
93
|
+
i += 1
|
|
94
|
+
elif text[i] == "\n":
|
|
95
|
+
out.append("\n")
|
|
96
|
+
i += 1
|
|
97
|
+
i += 1
|
|
98
|
+
else:
|
|
99
|
+
out.append(c)
|
|
100
|
+
i += 1
|
|
101
|
+
return "".join(out)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def brace_delta(slice_text):
|
|
105
|
+
code = strip_noncode(slice_text)
|
|
106
|
+
return (
|
|
107
|
+
code.count("{") - code.count("}"),
|
|
108
|
+
code.count("(") - code.count(")"),
|
|
109
|
+
code.count("[") - code.count("]"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check(report, root, by_suffix, problems_only):
|
|
114
|
+
with open(report, encoding="utf-8", errors="replace") as fh:
|
|
115
|
+
report_lines = fh.readlines()
|
|
116
|
+
|
|
117
|
+
seen = set()
|
|
118
|
+
rows = []
|
|
119
|
+
for lineno, text in enumerate(report_lines, 1):
|
|
120
|
+
for m in CITATION.finditer(text):
|
|
121
|
+
cited, start, end = m.group(1), int(m.group(2)), m.group(3)
|
|
122
|
+
end = int(end) if end else start
|
|
123
|
+
key = (cited, start, end)
|
|
124
|
+
if key in seen:
|
|
125
|
+
continue
|
|
126
|
+
seen.add(key)
|
|
127
|
+
rows.append((lineno, cited, start, end, bool(NEGATION.search(text))))
|
|
128
|
+
|
|
129
|
+
results = []
|
|
130
|
+
for report_line, cited, start, end, negated in rows:
|
|
131
|
+
relpath, reason = resolve(cited, by_suffix)
|
|
132
|
+
if reason:
|
|
133
|
+
results.append((report_line, cited, start, end, relpath, [], [reason]))
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
with open(os.path.join(root, relpath), encoding="utf-8", errors="replace") as fh:
|
|
137
|
+
src = fh.readlines()
|
|
138
|
+
|
|
139
|
+
flags = []
|
|
140
|
+
if start < 1 or start > len(src):
|
|
141
|
+
flags.append(f"NOT-FOUND (file has {len(src)} lines)")
|
|
142
|
+
results.append((report_line, cited, start, end, relpath, [], flags))
|
|
143
|
+
continue
|
|
144
|
+
if end > len(src):
|
|
145
|
+
flags.append(f"RANGE-PAST-EOF (file has {len(src)} lines)")
|
|
146
|
+
|
|
147
|
+
body = src[start - 1:min(end, len(src))]
|
|
148
|
+
if negated and end > start and relpath.endswith(CODE_EXT):
|
|
149
|
+
unbal = [f"{n}{d:+d}" for n, d in zip(("{}", "()", "[]"), brace_delta("".join(body))) if d]
|
|
150
|
+
if unbal:
|
|
151
|
+
flags.append(
|
|
152
|
+
"TRUNCATED-CONSTRUCT (" + ", ".join(unbal) + ") — this row claims an "
|
|
153
|
+
"absence, so the range must span the whole construct"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
results.append((report_line, cited, start, end, relpath, body, flags))
|
|
157
|
+
|
|
158
|
+
return results, seen
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render(report, results, problems_only):
|
|
162
|
+
print(f"\n=== {os.path.basename(report)} ===")
|
|
163
|
+
bad = 0
|
|
164
|
+
for report_line, cited, start, end, relpath, body, flags in results:
|
|
165
|
+
if flags:
|
|
166
|
+
bad += 1
|
|
167
|
+
elif problems_only:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
span = f"{start}" if start == end else f"{start}-{end}"
|
|
171
|
+
target = f" -> {relpath}" if relpath else ""
|
|
172
|
+
print(f"\n[{'FLAG' if flags else 'OK'}] {cited}:{span}{target} (report line {report_line})")
|
|
173
|
+
for f in flags:
|
|
174
|
+
print(f" !! {f}")
|
|
175
|
+
for i, src_line in enumerate(body, start):
|
|
176
|
+
print(f" {i:>5} | {src_line.rstrip()}")
|
|
177
|
+
return bad
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main():
|
|
181
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
182
|
+
ap.add_argument("reports", nargs="+")
|
|
183
|
+
ap.add_argument("--root", required=True, help="backend root the citations are relative to")
|
|
184
|
+
ap.add_argument("--problems", action="store_true", help="print only flagged citations")
|
|
185
|
+
args = ap.parse_args()
|
|
186
|
+
|
|
187
|
+
root = os.path.abspath(args.root)
|
|
188
|
+
by_suffix = index_tree(root)
|
|
189
|
+
|
|
190
|
+
total_bad = total = 0
|
|
191
|
+
for report in args.reports:
|
|
192
|
+
results, seen = check(report, root, by_suffix, args.problems)
|
|
193
|
+
total_bad += render(report, results, args.problems)
|
|
194
|
+
total += len(seen)
|
|
195
|
+
|
|
196
|
+
print(f"\n{'=' * 60}\n{total} citations checked, {total_bad} flagged.")
|
|
197
|
+
return 1 if total_bad else 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
sys.exit(main())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: be-brief
|
|
3
|
+
description: User invoked only
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Intent
|
|
7
|
+
User is trying to understand OR problem solve something on a high level
|
|
8
|
+
|
|
9
|
+
# Brief
|
|
10
|
+
To make an answer brief, write it as a 30-60 word sentence. For technical things, use 10-15 word bullet points.
|
|
11
|
+
|
|
12
|
+
# User tech stack
|
|
13
|
+
## Good proficiency
|
|
14
|
+
Python, SQL, Linux, relational database, NoSQL database, System design, data flow, ETL pipeline
|
|
15
|
+
## Medium proficiency
|
|
16
|
+
Spark, Java, Bash, Infrastructure as Code, AWS, Claude code, CLI
|
|
17
|
+
## Bad proficiency
|
|
18
|
+
Frontend, Full stack, JavaScript, Node, NPM, Vercel, Render
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clarke-list-excel
|
|
3
|
+
description: Use and invoke
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Clarke List Excel Skill
|
|
7
|
+
|
|
8
|
+
## Goal
|
|
9
|
+
|
|
10
|
+
Extract Clarke XLSX → JSON/Markdown → map images to Part No → upload to S3 → upsert MongoDB `clarke`.
|
|
11
|
+
|
|
12
|
+
## Use When
|
|
13
|
+
|
|
14
|
+
Use for Clarke price-list extraction, re-import, refresh, product/image extraction, S3 upload, or MongoDB loading.
|
|
15
|
+
|
|
16
|
+
## Workflow
|
|
17
|
+
|
|
18
|
+
1. Read values/images with openpyxl and table/image spans with Datalab.
|
|
19
|
+
2. Detect products by valid Part No; identify category/sub-category headings from formatting.
|
|
20
|
+
3. Map each image to every product row covered by its Datalab `Picture` bbox.
|
|
21
|
+
4. Stop spans at headings; for overlapping spans, nearest anchor above wins.
|
|
22
|
+
5. Use nearest-row matching only within 3 rows when no span match exists.
|
|
23
|
+
6. Upload valid images first, then upsert products into MongoDB.
|
|
24
|
+
7. Validate the database write and report all important counts/issues.
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python scripts/run_all.py "<xlsx>" --out-dir /home/dev/workspaces/murtaza-workspaces/clarke-products
|
|
30
|
+
python scripts/clarke_extract.py "<xlsx>" --out-dir <dir>
|
|
31
|
+
python scripts/load_clarke.py <dir>/clarke-products.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Rules
|
|
35
|
+
|
|
36
|
+
* Datalab = image coordinates/spans; openpyxl = values and original image bytes.
|
|
37
|
+
* Read columns by position, never by printed header names.
|
|
38
|
+
* Store images at `products/catalog/clarke/<part-no>.<ext>` and upsert by unique `partNo`.
|
|
39
|
+
* Preserve `supplier: "Clarke"` and `source`; run `--check` before writes.
|
|
40
|
+
* Never use grid indexes/equal row bands, treat headings as products, or silently drop rows/issues.
|
|
41
|
+
* Keep duplicates, missing prices, unmatched images, held-back rows and unsupported formats visible.
|
|
42
|
+
* EMF uploads require `--upload-unrenderable`; re-runs must be idempotent.
|
|
43
|
+
* Never overwrite another catalogue on collection clashes.
|
|
44
|
+
|
|
45
|
+
## Validation
|
|
46
|
+
|
|
47
|
+
Report product/duplicate/held-back counts, image coverage, span vs nearest matches, overridden/unmatched images, truncated spans, missing prices, unsupported formats, and Datalab/openpyxl anchor agreement.
|
|
48
|
+
|
|
49
|
+
## Wrong Tool
|
|
50
|
+
|
|
51
|
+
UKEN PDF → `uken-data-export`; other PDFs → `pdf-to-json`; unknown/general spreadsheets → `datalab-api`.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# What the files hold
|
|
2
|
+
|
|
3
|
+
Read this when you need a specific field — what it means, when it is null, and
|
|
4
|
+
which numbers are safe to quote. Figures are from the 2023 Clarke list.
|
|
5
|
+
|
|
6
|
+
## `clarke-products.json`
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
source where it came from
|
|
10
|
+
summary counts, and everything that did not come through
|
|
11
|
+
products one entry per product row, in sheet order
|
|
12
|
+
rowsHeldBack rows that looked like products and were not
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### `source`
|
|
16
|
+
|
|
17
|
+
| field | example | note |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `file` | `CLARKE PRICE LIST-2023.xlsx` | |
|
|
20
|
+
| `path` | absolute path | what was actually read |
|
|
21
|
+
| `sheet` | `Sheet1` | first worksheet unless `--sheet` said otherwise |
|
|
22
|
+
| `headerRow` | `8` | the row printing `PART NO`; rows above are a title block |
|
|
23
|
+
| `tableRange` | `A5:G2035` | Datalab's view of the table's extent, null with `--no-datalab` |
|
|
24
|
+
| `rows`, `columns` | `2035`, `8` | the sheet's extent, not the table's |
|
|
25
|
+
|
|
26
|
+
### `summary`
|
|
27
|
+
|
|
28
|
+
| field | 2023 file | what it tells you |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `products` | 1558 | rows that parsed as products. **Not** the document count — see `duplicate_part_numbers` |
|
|
31
|
+
| `categories` | 26 | merged 14pt headings |
|
|
32
|
+
| `subCategories` | 193 | unmerged 10pt headings |
|
|
33
|
+
| `image_placements` | 249 | photos in the sheet, including the logo |
|
|
34
|
+
| `products_with_image` | 986 | every product a photo's span covers, not just the first |
|
|
35
|
+
| `products_without_image` | 572 | the document, not a failure |
|
|
36
|
+
| `distinct_images` | 204 | far fewer than 986: one photo illustrates a whole group |
|
|
37
|
+
| `media_files_in_workbook` | 216 | everything in `xl/media`, used or not |
|
|
38
|
+
| `images_matched_by_span` | 986 | confident: the photo's own extent covered the row |
|
|
39
|
+
| `images_matched_by_nearest` | 0 | inferred: it covered no product row. Any rise is a warning |
|
|
40
|
+
| `images_spanning_multiple_products` | 177 | placements attached to more than one product |
|
|
41
|
+
| `products_sharing_a_spanned_image` | 926 | products carrying a group photo rather than their own |
|
|
42
|
+
| `spans_truncated_at_heading` | 32 | spans cut at a sub-category row instead of running into the next group |
|
|
43
|
+
| `images_overridden` | 11 | placements every row of which a nearer-anchored photo won |
|
|
44
|
+
| `images_unmatched` | 1 | attached to nothing. One is expected — the row-1 logo |
|
|
45
|
+
| `unsupported_image_formats` | `["emf"]` | present in the workbook; not uploaded by default |
|
|
46
|
+
| `products_without_price` | 1 | kept with a null price |
|
|
47
|
+
| `rows_held_back` | 2 | see `rowsHeldBack` |
|
|
48
|
+
| `duplicate_part_numbers` | `{HS20C: [420, 1150], HS24C: [421, 1153]}` | part number → the rows printing it. These collapse on upsert |
|
|
49
|
+
| `cross_check` | see below | the completeness check |
|
|
50
|
+
|
|
51
|
+
### `summary.cross_check`
|
|
52
|
+
|
|
53
|
+
Datalab and openpyxl each report where every photo is anchored, by different
|
|
54
|
+
code paths. This is where they are compared.
|
|
55
|
+
|
|
56
|
+
| field | 2023 file | |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `ran` | `true` | false under `--no-datalab`, and then nothing below exists |
|
|
59
|
+
| `datalab_pictures` | 249 | |
|
|
60
|
+
| `openpyxl_placements` | 249 | |
|
|
61
|
+
| `anchor_rows_agree` | `true` | **the number to quote.** False means photos may be on the wrong products |
|
|
62
|
+
| `rows_only_in_datalab` | `[]` | photos openpyxl could not read — likely a format it drops |
|
|
63
|
+
| `rows_only_in_openpyxl` | `[]` | photos Datalab did not report |
|
|
64
|
+
| `table_range`, `table_blocks` | `A5:G2035`, 1 | more than one table block means the sheet segmented unexpectedly |
|
|
65
|
+
|
|
66
|
+
### `products[]`
|
|
67
|
+
|
|
68
|
+
| field | example | null when |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `partNo` | `CP6CL` | never — it is the key |
|
|
71
|
+
| `partNoRaw` | `CP6CL` | never; differs from `partNo` only where the sheet printed odd spacing |
|
|
72
|
+
| `description` | `COMBINATION PLIERS 6"` | never — a row without one is held back |
|
|
73
|
+
| `category` | `PLIERS` | products appearing before any heading |
|
|
74
|
+
| `subCategory` | `COMBINATION PLIER` | the category has no sub-headings |
|
|
75
|
+
| `pktQty`, `ctnQty` | `6`, `60` | the cell is blank or not numeric |
|
|
76
|
+
| `price` | `8.0` | the sheet printed no price (1 row) |
|
|
77
|
+
| `priceLabel` | `TRADER Price` | never — the tier name, kept so the number is not bare |
|
|
78
|
+
| `currency` | `AED` | never |
|
|
79
|
+
| `supplier` | `Clarke` | never — this is what separates the two catalogues sharing the collection |
|
|
80
|
+
| `sheetRow` | `14` | never — the row in the *sheet*, not in Datalab's grid |
|
|
81
|
+
| `imageSha256` | `760cd2a2…` | the product has no photo (572 of them) |
|
|
82
|
+
| `imageFormat` | `jpeg` | as above |
|
|
83
|
+
| `imageMatch` | `span` \| `nearest` | as above. `nearest` is inference — see SKILL.md |
|
|
84
|
+
| `imageAnchorRow` | `14` | as above. The top of the photo, which may be many rows above `sheetRow` |
|
|
85
|
+
| `imageSpanRows` | `[166, 185]` | as above. The rows the photo covers; every product in them carries it |
|
|
86
|
+
| `imageShared` | `true` | absent unless the photo covers more than one product — a group photo |
|
|
87
|
+
|
|
88
|
+
### `rowsHeldBack[]`
|
|
89
|
+
|
|
90
|
+
`{row, reason, partNo, description}`. Two reasons occur: `missing part number
|
|
91
|
+
or description`, and `part number is not code-like` — prose in column A, which
|
|
92
|
+
is a heading that lost its formatting. These are listed rather than dropped
|
|
93
|
+
because the count of what did not load is the number worth seeing.
|
|
94
|
+
|
|
95
|
+
## `images/`
|
|
96
|
+
|
|
97
|
+
One file per distinct photo, named `<sha256>.<format>`, written beside the
|
|
98
|
+
JSON. These are the **original bytes out of the xlsx zip**, not Datalab's
|
|
99
|
+
re-encodings, so they are reproducible across runs. `load_clarke.py` reads this
|
|
100
|
+
directory; stage one rewrites it, so re-run the two together.
|
|
101
|
+
|
|
102
|
+
## `clarke-products.md`
|
|
103
|
+
|
|
104
|
+
The same data rendered to read: a heading per category breadcrumb, then a table
|
|
105
|
+
of part number, description, pkt, ctn, price and the first 8 characters of the
|
|
106
|
+
photo hash. For eyeballing whether the grouping and the photo mapping came out
|
|
107
|
+
right — the JSON is what anything downstream should read.
|
|
108
|
+
|
|
109
|
+
## The MongoDB document
|
|
110
|
+
|
|
111
|
+
`clarke`, upserted on `partNo` (unique index). Same fields as
|
|
112
|
+
`products[]` minus the image internals, plus:
|
|
113
|
+
|
|
114
|
+
| field | example | |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `source` | `CLARKE PRICE LIST-2023.xlsx` | which file this came from |
|
|
117
|
+
| `imageKey` | `products/catalog/clarke/CP6CL.jpeg` | S3 key, no leading slash; null when the product has no photo |
|
|
118
|
+
|
|
119
|
+
| `imageShared` | `true` | the photo covers several products; this is the group's picture, not this part's alone |
|
|
120
|
+
|
|
121
|
+
`imageSha256`, `imageFormat`, `imageAnchorRow` and `imageSpanRows` stay in the
|
|
122
|
+
JSON — they are extraction diagnostics, not product data. `imageMatch` and
|
|
123
|
+
`imageShared` are carried through, because a downstream reader should be able
|
|
124
|
+
to tell an inferred photo from a confident one, and a group photo from a
|
|
125
|
+
product's own.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared plumbing for the Clarke export: credentials, the Datalab call, the workbook.
|
|
3
|
+
|
|
4
|
+
Two readers, deliberately. Datalab segments the sheet and reports where the
|
|
5
|
+
pictures are anchored; openpyxl reads the cells. Neither is redundant:
|
|
6
|
+
|
|
7
|
+
Datalab's table HTML drops empty rows, so its grid is 1,783 rows against the
|
|
8
|
+
sheet's 2,031. Grid index is therefore *not* sheet row, and anything that maps
|
|
9
|
+
pictures onto products through the grid drifts by hundreds of rows by the
|
|
10
|
+
bottom of the file. openpyxl addresses cells by their real coordinates, so it
|
|
11
|
+
is what the parser reads values from.
|
|
12
|
+
|
|
13
|
+
What Datalab gives that openpyxl cannot is an independent opinion. Its Picture
|
|
14
|
+
bboxes are cell coordinates on spreadsheets, and `floor(y0)` is the anchoring
|
|
15
|
+
row — the same number openpyxl reports from the drawing anchor, arrived at
|
|
16
|
+
through an entirely different code path. Agreement between the two is the
|
|
17
|
+
completeness check this skill quotes, and it is worth more than either reader's
|
|
18
|
+
own confidence.
|
|
19
|
+
|
|
20
|
+
The credential search is a fixed default rather than a walk up from the input
|
|
21
|
+
file, for the same reason as the sibling uken-data-export skill: the price list
|
|
22
|
+
sits in the workspace root and the .env is in Backend/ of a different repo, so
|
|
23
|
+
walking up finds nothing. Guessing a path and half-working is worse than saying
|
|
24
|
+
plainly which file was read.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import time
|
|
31
|
+
import zipfile
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
DEFAULT_BASE_URL = "https://www.datalab.to/api/v1"
|
|
35
|
+
POLL_SECONDS = 2
|
|
36
|
+
POLL_ATTEMPTS = 300 # 10 minutes; a one-sheet workbook lands in well under one
|
|
37
|
+
|
|
38
|
+
DEFAULT_ENV = Path(
|
|
39
|
+
"/home/dev/workspaces/murtaza-workspaces/murtaza-hotel-project/Backend/.env")
|
|
40
|
+
|
|
41
|
+
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
42
|
+
MIMES = {
|
|
43
|
+
".xlsx": XLSX_MIME,
|
|
44
|
+
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
|
|
45
|
+
".xls": "application/vnd.ms-excel",
|
|
46
|
+
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
47
|
+
".csv": "text/csv",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------ credentials
|
|
52
|
+
|
|
53
|
+
def load_env(explicit: str | None = None) -> tuple[dict, Path | None]:
|
|
54
|
+
"""Return (values, path_read). Real environment variables always win.
|
|
55
|
+
|
|
56
|
+
Returning the path matters: every stage prints which .env it used, because
|
|
57
|
+
"1,558 products loaded" is not a useful sentence until you know which
|
|
58
|
+
cluster it wrote them to.
|
|
59
|
+
"""
|
|
60
|
+
candidates = []
|
|
61
|
+
if explicit:
|
|
62
|
+
candidates.append(Path(explicit).expanduser())
|
|
63
|
+
elif os.environ.get("CLARKE_ENV_FILE"):
|
|
64
|
+
candidates.append(Path(os.environ["CLARKE_ENV_FILE"]).expanduser())
|
|
65
|
+
else:
|
|
66
|
+
candidates.append(DEFAULT_ENV)
|
|
67
|
+
|
|
68
|
+
values, used = {}, None
|
|
69
|
+
for path in candidates:
|
|
70
|
+
if not path.is_file():
|
|
71
|
+
continue
|
|
72
|
+
used = path
|
|
73
|
+
for line in path.read_text(errors="replace").splitlines():
|
|
74
|
+
line = line.strip()
|
|
75
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
76
|
+
continue
|
|
77
|
+
key, value = line.split("=", 1)
|
|
78
|
+
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
if explicit and used is None:
|
|
82
|
+
raise SystemExit(f"--env {explicit} does not exist")
|
|
83
|
+
return values, used
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def credential(values: dict, *keys: str) -> str | None:
|
|
87
|
+
"""First of `keys` set in the real environment, else in the .env."""
|
|
88
|
+
for key in keys:
|
|
89
|
+
if os.environ.get(key):
|
|
90
|
+
return os.environ[key]
|
|
91
|
+
for key in keys:
|
|
92
|
+
if values.get(key):
|
|
93
|
+
return values[key]
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def require(values: dict, *keys: str) -> str:
|
|
98
|
+
got = credential(values, *keys)
|
|
99
|
+
if not got:
|
|
100
|
+
raise SystemExit(
|
|
101
|
+
f"{keys[0]} not found. Set it in the environment or in the .env "
|
|
102
|
+
f"(default {DEFAULT_ENV}); point elsewhere with --env.")
|
|
103
|
+
return got
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------- datalab
|
|
107
|
+
|
|
108
|
+
def strip_images(node):
|
|
109
|
+
"""Drop base64 payloads before caching.
|
|
110
|
+
|
|
111
|
+
On this workbook they are ~98% of the response and none of them are used:
|
|
112
|
+
the pictures uploaded to S3 are the original media out of the xlsx zip, not
|
|
113
|
+
Datalab's re-encodings. The `Picture` blocks themselves are kept, because
|
|
114
|
+
their bboxes are the cross-check.
|
|
115
|
+
"""
|
|
116
|
+
if isinstance(node, dict):
|
|
117
|
+
node.pop("images", None)
|
|
118
|
+
for value in node.values():
|
|
119
|
+
strip_images(value)
|
|
120
|
+
elif isinstance(node, list):
|
|
121
|
+
for value in node:
|
|
122
|
+
strip_images(value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def convert(path: Path, key: str, cache_dir: Path, mode: str = "fast",
|
|
126
|
+
refresh: bool = False, base: str = DEFAULT_BASE_URL) -> dict:
|
|
127
|
+
"""Submit the workbook to Datalab and poll until it finishes.
|
|
128
|
+
|
|
129
|
+
`mode` is `fast` because on spreadsheets it changes nothing — fast,
|
|
130
|
+
balanced and accurate return identical block trees at identical cost. The
|
|
131
|
+
sibling datalab-api skill documents that experiment.
|
|
132
|
+
|
|
133
|
+
Cached on the resolved path plus the options that change the request, so
|
|
134
|
+
re-running the parser is free. The conversion is the slow, paid and
|
|
135
|
+
non-deterministic part; the parse is none of those.
|
|
136
|
+
"""
|
|
137
|
+
import requests
|
|
138
|
+
|
|
139
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
digest = hashlib.sha256(
|
|
141
|
+
f"{path.resolve()}|{path.stat().st_size}|{mode}".encode()).hexdigest()[:10]
|
|
142
|
+
cached = cache_dir / f"{path.stem}.{mode}.{digest}.json"
|
|
143
|
+
if cached.is_file() and not refresh:
|
|
144
|
+
return json.loads(cached.read_text())
|
|
145
|
+
|
|
146
|
+
mime = MIMES.get(path.suffix.lower(), XLSX_MIME)
|
|
147
|
+
with path.open("rb") as fh:
|
|
148
|
+
response = requests.post(
|
|
149
|
+
f"{base}/convert",
|
|
150
|
+
headers={"X-API-Key": key},
|
|
151
|
+
files={"file": (path.name, fh, mime)},
|
|
152
|
+
data={"output_format": "json", "mode": mode},
|
|
153
|
+
timeout=300)
|
|
154
|
+
if response.status_code != 200:
|
|
155
|
+
raise SystemExit(f"Datalab rejected the upload: "
|
|
156
|
+
f"{response.status_code} {response.text[:400]}")
|
|
157
|
+
check_url = response.json().get("request_check_url")
|
|
158
|
+
if not check_url:
|
|
159
|
+
raise SystemExit(f"no request_check_url in response: {response.text[:400]}")
|
|
160
|
+
|
|
161
|
+
for _ in range(POLL_ATTEMPTS):
|
|
162
|
+
time.sleep(POLL_SECONDS)
|
|
163
|
+
poll = requests.get(check_url, headers={"X-API-Key": key}, timeout=120).json()
|
|
164
|
+
if poll.get("status") == "complete":
|
|
165
|
+
if not poll.get("success", True):
|
|
166
|
+
raise SystemExit(f"Datalab failed: {poll.get('error')}")
|
|
167
|
+
strip_images(poll)
|
|
168
|
+
cached.write_text(json.dumps(poll))
|
|
169
|
+
return poll
|
|
170
|
+
raise SystemExit("Datalab did not finish within the polling window")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def walk(node, out=None):
|
|
174
|
+
"""Every block in the response, in reading order."""
|
|
175
|
+
out = [] if out is None else out
|
|
176
|
+
if isinstance(node, dict):
|
|
177
|
+
if "block_type" in node:
|
|
178
|
+
out.append(node)
|
|
179
|
+
for child in node.get("children") or []:
|
|
180
|
+
walk(child, out)
|
|
181
|
+
elif isinstance(node, list):
|
|
182
|
+
for child in node:
|
|
183
|
+
walk(child, out)
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def picture_rows(response: dict) -> list[dict]:
|
|
188
|
+
"""Datalab's opinion of where each picture sits, as 1-indexed sheet rows.
|
|
189
|
+
|
|
190
|
+
On spreadsheets a Picture bbox is [col0, row0, col1, row1] in *cell*
|
|
191
|
+
coordinates, where the integer part is the 1-indexed cell and the fraction
|
|
192
|
+
is the offset within it. `[1.74, 14.48, 2.69, 16.17]` is a photo anchored
|
|
193
|
+
in B14 hanging down into row 16.
|
|
194
|
+
|
|
195
|
+
Carrying the PDF convention here instead — pixels — puts every coordinate
|
|
196
|
+
out by three orders of magnitude, and the failure is quiet because the
|
|
197
|
+
numbers still look like plausible row indices.
|
|
198
|
+
"""
|
|
199
|
+
out = []
|
|
200
|
+
for block in walk(response.get("json") or response):
|
|
201
|
+
if block.get("block_type") != "Picture":
|
|
202
|
+
continue
|
|
203
|
+
bbox = block.get("bbox") or []
|
|
204
|
+
if len(bbox) < 4:
|
|
205
|
+
continue
|
|
206
|
+
html = block.get("html") or ""
|
|
207
|
+
src = None
|
|
208
|
+
if 'src="' in html:
|
|
209
|
+
src = html.split('src="', 1)[1].split('"', 1)[0]
|
|
210
|
+
out.append({
|
|
211
|
+
"row_from": int(bbox[1]),
|
|
212
|
+
"row_to": int(bbox[3]),
|
|
213
|
+
"col": int(bbox[0]),
|
|
214
|
+
"src": src,
|
|
215
|
+
})
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def table_blocks(response: dict) -> list[dict]:
|
|
220
|
+
"""Table blocks with their cell range. Datalab sometimes types a plain
|
|
221
|
+
lead-in table as TableOfContents, so match that too."""
|
|
222
|
+
return [b for b in walk(response.get("json") or response)
|
|
223
|
+
if b.get("block_type") in ("Table", "TableOfContents")]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def excel_range(bbox) -> str:
|
|
227
|
+
"""[1, 5, 7, 2035] -> 'A5:G2035'. 1-indexed and inclusive."""
|
|
228
|
+
def column(index: int) -> str:
|
|
229
|
+
name = ""
|
|
230
|
+
while index > 0:
|
|
231
|
+
index, remainder = divmod(index - 1, 26)
|
|
232
|
+
name = chr(65 + remainder) + name
|
|
233
|
+
return name or "A"
|
|
234
|
+
c0, r0, c1, r1 = (int(v) for v in bbox[:4])
|
|
235
|
+
return f"{column(c0)}{r0}:{column(c1)}{r1}"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ----------------------------------------------------------- the workbook
|
|
239
|
+
|
|
240
|
+
def media_index(path: Path) -> dict:
|
|
241
|
+
"""Every file under xl/media, archive name -> bytes.
|
|
242
|
+
|
|
243
|
+
Read from the zip rather than through openpyxl because openpyxl silently
|
|
244
|
+
drops formats it cannot decode — this workbook holds one EMF, and openpyxl
|
|
245
|
+
warns and moves on. A picture missing from the upload because a library
|
|
246
|
+
could not parse it should be reported, not lost.
|
|
247
|
+
"""
|
|
248
|
+
with zipfile.ZipFile(path) as archive:
|
|
249
|
+
return {name: archive.read(name)
|
|
250
|
+
for name in archive.namelist()
|
|
251
|
+
if name.startswith("xl/media/")}
|