@flitzrrr/agent-skills 1.0.2 → 1.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/.cursorrules +2 -2
- package/.github/copilot-instructions.md +59 -0
- package/.lovable +1 -1
- package/AGENTS.md +2 -2
- package/CHEATSHEET.md +84 -86
- package/CLAUDE.md +2 -2
- package/LICENSE +27 -0
- package/README.md +147 -100
- package/bin/build-catalog.js +208 -0
- package/bin/cli.js +7 -3
- package/bin/sync-docs.js +147 -0
- package/bin/sync-skills.sh +17 -0
- package/bin/test-cli.js +115 -0
- package/bin/update-wiki.js +102 -0
- package/package.json +9 -2
- package/skills/dispatch-parallel-agents/skill.md +95 -0
- package/skills/execute-work-package/SKILL.md +279 -0
- package/skills/execute-work-package/tpl-execution-blueprint.md +39 -0
- package/skills/execute-work-package/tpl-execution-digest.md +24 -0
- package/skills/execute-work-package/tpl-implementer-execute-prompt.md +57 -0
- package/skills/execute-work-package/tpl-implementer-preflight-prompt.md +66 -0
- package/skills/product-description-seo/CROSS-SELL.md +31 -0
- package/skills/product-description-seo/KEYWORDS.md +35 -0
- package/skills/product-description-seo/SKILL.md +361 -0
- package/skills/product-description-seo/scripts/analyze_catalog.py +136 -0
- package/skills/product-description-seo/scripts/check_quality.py +204 -0
- package/skills/product-description-seo/scripts/extract_category.py +88 -0
- package/skills/product-description-seo/scripts/track_progress.py +140 -0
- package/skills/product-description-seo/scripts/update_catalog.py +80 -0
- package/skills/product-description-seo/scripts/validate_json.py +87 -0
- package/skills/systematic-debugging/skill.md +87 -0
- package/skills/tob-gh-cli/SKILL.md +71 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Analyze a JSON product catalog for thin or missing descriptions.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python analyze_catalog.py <catalog.json> [--category <name>] [--min-words 200]
|
|
6
|
+
|
|
7
|
+
Shows per-category stats, identifies the thinnest descriptions,
|
|
8
|
+
and exports batch-ready JSON when filtering by category.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import argparse
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
|
|
16
|
+
# Default field names (override via config)
|
|
17
|
+
DESC_FIELD = "beschreibung"
|
|
18
|
+
CAT_FIELD = "kategorieName"
|
|
19
|
+
CAT_FIELD_ALT = "kategorie"
|
|
20
|
+
SKU_FIELD = "sku"
|
|
21
|
+
NAME_FIELD = "name"
|
|
22
|
+
STATUS_FIELD = "status"
|
|
23
|
+
STATUS_ACTIVE = "aktiv"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def count_words(text: str) -> int:
|
|
27
|
+
if not text or not text.strip():
|
|
28
|
+
return 0
|
|
29
|
+
return len(text.split())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def analyze(catalog_path: str, category_filter: str | None = None, min_words: int = 200):
|
|
33
|
+
with open(catalog_path, "r", encoding="utf-8") as f:
|
|
34
|
+
data = json.load(f)
|
|
35
|
+
|
|
36
|
+
products = data.get("products", [])
|
|
37
|
+
products = [p for p in products if p.get(STATUS_FIELD, STATUS_ACTIVE) == STATUS_ACTIVE]
|
|
38
|
+
|
|
39
|
+
if category_filter:
|
|
40
|
+
cf = category_filter.lower()
|
|
41
|
+
products = [p for p in products
|
|
42
|
+
if cf in p.get(CAT_FIELD_ALT, "").lower()
|
|
43
|
+
or cf in p.get(CAT_FIELD, "").lower()
|
|
44
|
+
or p.get(CAT_FIELD_ALT, "").lower() == cf
|
|
45
|
+
or p.get(CAT_FIELD, "").lower() == cf]
|
|
46
|
+
|
|
47
|
+
if not products:
|
|
48
|
+
print("No matching products found.")
|
|
49
|
+
all_cats = sorted(set(
|
|
50
|
+
p.get(CAT_FIELD, p.get(CAT_FIELD_ALT, "?"))
|
|
51
|
+
for p in data.get("products", [])
|
|
52
|
+
))
|
|
53
|
+
if all_cats:
|
|
54
|
+
print("Available categories:")
|
|
55
|
+
for c in all_cats:
|
|
56
|
+
print(f" - {c}")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
categories = defaultdict(list)
|
|
60
|
+
for p in products:
|
|
61
|
+
cat = p.get(CAT_FIELD, p.get(CAT_FIELD_ALT, "Unknown"))
|
|
62
|
+
wc = count_words(p.get(DESC_FIELD, ""))
|
|
63
|
+
categories[cat].append({
|
|
64
|
+
SKU_FIELD: p.get(SKU_FIELD, ""),
|
|
65
|
+
NAME_FIELD: p.get(NAME_FIELD, ""),
|
|
66
|
+
"word_count": wc,
|
|
67
|
+
DESC_FIELD: p.get(DESC_FIELD, ""),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
total = len(products)
|
|
71
|
+
below_threshold = sum(1 for p in products if count_words(p.get(DESC_FIELD, "")) < min_words)
|
|
72
|
+
|
|
73
|
+
print(f"{'=' * 70}")
|
|
74
|
+
print(f"CATALOG DESCRIPTION ANALYSIS")
|
|
75
|
+
print(f"{'=' * 70}")
|
|
76
|
+
print(f"Active products: {total}")
|
|
77
|
+
below_pct = below_threshold * 100 // total if total > 0 else 0
|
|
78
|
+
above_pct = (total - below_threshold) * 100 // total if total > 0 else 0
|
|
79
|
+
print(f"Below {min_words} words: {below_threshold} ({below_pct}%)")
|
|
80
|
+
print(f"Above {min_words} words: {total - below_threshold} ({above_pct}%)")
|
|
81
|
+
print()
|
|
82
|
+
|
|
83
|
+
print(f"{'Category':<45} {'Count':>5} {'Avg Words':>9} {'< {}'.format(min_words):>7}")
|
|
84
|
+
print(f"{'-' * 45} {'-' * 5} {'-' * 9} {'-' * 7}")
|
|
85
|
+
|
|
86
|
+
sorted_cats = sorted(categories.items(),
|
|
87
|
+
key=lambda x: sum(p["word_count"] for p in x[1]) / len(x[1]))
|
|
88
|
+
|
|
89
|
+
for cat_name, prods in sorted_cats:
|
|
90
|
+
avg_wc = sum(p["word_count"] for p in prods) / len(prods)
|
|
91
|
+
below = sum(1 for p in prods if p["word_count"] < min_words)
|
|
92
|
+
print(f"{cat_name:<45} {len(prods):>5} {avg_wc:>9.1f} {below:>7}")
|
|
93
|
+
|
|
94
|
+
# Thinnest descriptions
|
|
95
|
+
print(f"\n{'=' * 70}")
|
|
96
|
+
print(f"THINNEST DESCRIPTIONS (Top 20)")
|
|
97
|
+
print(f"{'=' * 70}")
|
|
98
|
+
|
|
99
|
+
all_products = []
|
|
100
|
+
for cat_name, prods in categories.items():
|
|
101
|
+
for p in prods:
|
|
102
|
+
p["_category"] = cat_name
|
|
103
|
+
all_products.append(p)
|
|
104
|
+
|
|
105
|
+
all_products.sort(key=lambda x: x["word_count"])
|
|
106
|
+
|
|
107
|
+
for i, p in enumerate(all_products[:20]):
|
|
108
|
+
print(f"\n{i + 1}. {p[NAME_FIELD]} (SKU {p[SKU_FIELD]}) — {p['_category']}")
|
|
109
|
+
print(f" Words: {p['word_count']}")
|
|
110
|
+
desc = p[DESC_FIELD]
|
|
111
|
+
desc_preview = desc[:120] + "..." if len(desc) > 120 else (desc or "(empty)")
|
|
112
|
+
print(f' Text: "{desc_preview}"')
|
|
113
|
+
|
|
114
|
+
# JSON export when filtering by category
|
|
115
|
+
if category_filter:
|
|
116
|
+
print(f"\n{'=' * 70}")
|
|
117
|
+
print(f"BATCH-READY JSON (Category: {category_filter})")
|
|
118
|
+
print(f"{'=' * 70}")
|
|
119
|
+
export = [{SKU_FIELD: p[SKU_FIELD], NAME_FIELD: p[NAME_FIELD],
|
|
120
|
+
DESC_FIELD: p[DESC_FIELD], "word_count": p["word_count"]}
|
|
121
|
+
for p in all_products]
|
|
122
|
+
print(json.dumps(export, ensure_ascii=False, indent=2))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main():
|
|
126
|
+
parser = argparse.ArgumentParser(description="Analyze catalog descriptions")
|
|
127
|
+
parser.add_argument("catalog_path", help="Path to catalog JSON file")
|
|
128
|
+
parser.add_argument("--category", "-c", help="Filter by category name (fuzzy match)")
|
|
129
|
+
parser.add_argument("--min-words", "-m", type=int, default=200,
|
|
130
|
+
help="Minimum word count threshold (default: 200)")
|
|
131
|
+
args = parser.parse_args()
|
|
132
|
+
analyze(args.catalog_path, args.category, args.min_words)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Quality-check product descriptions against SEO requirements.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python check_quality.py <updates.json> [--strict] [--min-words 200] [--max-words 350]
|
|
6
|
+
|
|
7
|
+
Checks each description against 8 criteria:
|
|
8
|
+
1. Word count within range
|
|
9
|
+
2. 4-paragraph structure
|
|
10
|
+
3. Focus keyword in first sentence
|
|
11
|
+
4. No banned superlatives
|
|
12
|
+
5. Cross-sell reference present
|
|
13
|
+
6. Formal address (no informal pronouns)
|
|
14
|
+
7. Meta-description-ready opening
|
|
15
|
+
8. Plain text only (no HTML/Markdown)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import re
|
|
21
|
+
import argparse
|
|
22
|
+
|
|
23
|
+
DEFAULT_BANNED_WORDS = [
|
|
24
|
+
# English
|
|
25
|
+
"best-in-class", "unparalleled", "revolutionary", "sensational",
|
|
26
|
+
"unmatched", "world-leading", "game-changing",
|
|
27
|
+
# German
|
|
28
|
+
"erstklassig", "herausragend", "unschlagbar", "einzigartig",
|
|
29
|
+
"revolutionaer", "sensationell", "unvergleichlich",
|
|
30
|
+
"hoechstem niveau",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# Indicators that a cross-sell reference is present
|
|
34
|
+
CROSS_SELL_INDICATORS = [
|
|
35
|
+
"combination with", "kombination mit", "in combination",
|
|
36
|
+
"pairs well", "complement", "together with", "zusammen mit",
|
|
37
|
+
"ergaenzend", "empfehlen wir", "passend dazu",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Informal pronouns that indicate wrong tone (German du-form)
|
|
41
|
+
INFORMAL_PRONOUNS = [
|
|
42
|
+
r'\bdu\b', r'\bdein\b', r'\bdeine\b', r'\bdeinem\b',
|
|
43
|
+
r'\bdeinen\b', r'\bdeiner\b', r'\bdir\b', r'\bdich\b',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_description(product: dict, min_words: int = 200, max_words: int = 350,
|
|
48
|
+
strict: bool = False) -> dict:
|
|
49
|
+
sku = product.get("sku", "?")
|
|
50
|
+
name = product.get("name", "?")
|
|
51
|
+
text = product.get("beschreibung", product.get("description", ""))
|
|
52
|
+
|
|
53
|
+
results = {"sku": sku, "name": name, "checks": [], "passed": 0, "failed": 0, "warnings": 0}
|
|
54
|
+
|
|
55
|
+
# 1. Word count
|
|
56
|
+
words = len(text.split())
|
|
57
|
+
if min_words <= words <= max_words:
|
|
58
|
+
results["checks"].append(("PASS", f"Word count: {words} (target: {min_words}-{max_words})"))
|
|
59
|
+
results["passed"] += 1
|
|
60
|
+
elif words > max_words:
|
|
61
|
+
results["checks"].append(("WARN", f"Word count: {words} -- above {max_words}, consider trimming"))
|
|
62
|
+
results["warnings"] += 1
|
|
63
|
+
else:
|
|
64
|
+
results["checks"].append(("FAIL", f"Word count: {words} -- below {min_words} minimum"))
|
|
65
|
+
results["failed"] += 1
|
|
66
|
+
|
|
67
|
+
# 2. Paragraph structure
|
|
68
|
+
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
|
69
|
+
if len(paragraphs) == 4:
|
|
70
|
+
results["checks"].append(("PASS", f"4-paragraph structure: {len(paragraphs)} paragraphs"))
|
|
71
|
+
results["passed"] += 1
|
|
72
|
+
elif len(paragraphs) >= 3:
|
|
73
|
+
results["checks"].append(("WARN", f"Paragraph structure: {len(paragraphs)} paragraphs (target: 4)"))
|
|
74
|
+
results["warnings"] += 1
|
|
75
|
+
else:
|
|
76
|
+
results["checks"].append(("FAIL", f"Paragraph structure: only {len(paragraphs)} paragraphs (target: 4)"))
|
|
77
|
+
results["failed"] += 1
|
|
78
|
+
|
|
79
|
+
# 3. Focus keyword in first sentence
|
|
80
|
+
first_sentence = text.split(".")[0].lower() if text else ""
|
|
81
|
+
name_parts = name.lower().split()
|
|
82
|
+
main_word = max(name_parts, key=len) if name_parts else ""
|
|
83
|
+
if main_word and main_word in first_sentence:
|
|
84
|
+
results["checks"].append(("PASS", f"Focus keyword in first sentence: '{main_word}' found"))
|
|
85
|
+
results["passed"] += 1
|
|
86
|
+
else:
|
|
87
|
+
results["checks"].append(("FAIL", f"Focus keyword missing in first sentence (expected: '{main_word}')"))
|
|
88
|
+
results["failed"] += 1
|
|
89
|
+
|
|
90
|
+
# 4. Banned superlatives
|
|
91
|
+
text_lower = text.lower()
|
|
92
|
+
found_banned = [w for w in DEFAULT_BANNED_WORDS if w in text_lower]
|
|
93
|
+
if not found_banned:
|
|
94
|
+
results["checks"].append(("PASS", "Tone: no banned superlatives"))
|
|
95
|
+
results["passed"] += 1
|
|
96
|
+
else:
|
|
97
|
+
level = "FAIL" if strict else "WARN"
|
|
98
|
+
results["checks"].append((level, f"Banned superlatives found: {', '.join(found_banned)}"))
|
|
99
|
+
if strict:
|
|
100
|
+
results["failed"] += 1
|
|
101
|
+
else:
|
|
102
|
+
results["warnings"] += 1
|
|
103
|
+
|
|
104
|
+
# 5. Cross-sell reference (warning only — not all products have natural cross-sells)
|
|
105
|
+
has_cross_sell = any(ind in text_lower for ind in CROSS_SELL_INDICATORS)
|
|
106
|
+
if has_cross_sell:
|
|
107
|
+
results["checks"].append(("PASS", "Cross-sell reference present"))
|
|
108
|
+
results["passed"] += 1
|
|
109
|
+
else:
|
|
110
|
+
level = "FAIL" if strict else "WARN"
|
|
111
|
+
results["checks"].append((level, "No cross-sell reference found"))
|
|
112
|
+
if strict:
|
|
113
|
+
results["failed"] += 1
|
|
114
|
+
else:
|
|
115
|
+
results["warnings"] += 1
|
|
116
|
+
|
|
117
|
+
# 6. Formal address
|
|
118
|
+
informal_found = []
|
|
119
|
+
for pattern in INFORMAL_PRONOUNS:
|
|
120
|
+
matches = re.findall(pattern, text_lower)
|
|
121
|
+
informal_found.extend(matches)
|
|
122
|
+
if not informal_found:
|
|
123
|
+
results["checks"].append(("PASS", "Formal address maintained"))
|
|
124
|
+
results["passed"] += 1
|
|
125
|
+
else:
|
|
126
|
+
results["checks"].append(("FAIL", f"Informal pronouns found: {', '.join(set(informal_found))}"))
|
|
127
|
+
results["failed"] += 1
|
|
128
|
+
|
|
129
|
+
# 7. Meta description
|
|
130
|
+
first_155 = text[:155]
|
|
131
|
+
if len(first_155) >= 100 and "." in first_155:
|
|
132
|
+
results["checks"].append(("PASS", f"Meta description: {len(first_155)} chars, contains sentence end"))
|
|
133
|
+
results["passed"] += 1
|
|
134
|
+
else:
|
|
135
|
+
results["checks"].append(("WARN", "Meta description: first 155 chars may need adjustment"))
|
|
136
|
+
results["warnings"] += 1
|
|
137
|
+
|
|
138
|
+
# 8. No HTML/Markdown
|
|
139
|
+
if re.search(r'<[^>]+>|#{1,6}\s|\*\*|__|\[.*\]\(.*\)', text):
|
|
140
|
+
results["checks"].append(("FAIL", "HTML or Markdown detected -- plain text only"))
|
|
141
|
+
results["failed"] += 1
|
|
142
|
+
else:
|
|
143
|
+
results["checks"].append(("PASS", "Plain text format (no HTML/Markdown)"))
|
|
144
|
+
results["passed"] += 1
|
|
145
|
+
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main():
|
|
150
|
+
parser = argparse.ArgumentParser(description="Quality-check product descriptions")
|
|
151
|
+
parser.add_argument("updates_path", help="Path to updates JSON file")
|
|
152
|
+
parser.add_argument("--strict", action="store_true", help="Treat warnings as failures")
|
|
153
|
+
parser.add_argument("--min-words", type=int, default=200, help="Minimum word count (default: 200)")
|
|
154
|
+
parser.add_argument("--max-words", type=int, default=350, help="Maximum word count (default: 350)")
|
|
155
|
+
args = parser.parse_args()
|
|
156
|
+
|
|
157
|
+
with open(args.updates_path, "r", encoding="utf-8") as f:
|
|
158
|
+
updates = json.load(f)
|
|
159
|
+
|
|
160
|
+
print(f"{'=' * 70}")
|
|
161
|
+
print(f"QUALITY CHECK -- {len(updates)} descriptions")
|
|
162
|
+
print(f"{'=' * 70}")
|
|
163
|
+
|
|
164
|
+
total_pass = 0
|
|
165
|
+
total_fail = 0
|
|
166
|
+
total_warn = 0
|
|
167
|
+
failed_products = []
|
|
168
|
+
|
|
169
|
+
for product in updates:
|
|
170
|
+
result = check_description(product, args.min_words, args.max_words, args.strict)
|
|
171
|
+
total_pass += result["passed"]
|
|
172
|
+
total_fail += result["failed"]
|
|
173
|
+
total_warn += result["warnings"]
|
|
174
|
+
|
|
175
|
+
status = "OK" if result["failed"] == 0 else "FAIL"
|
|
176
|
+
print(f"\n[{status}] SKU {result['sku']}: {result['name']}"
|
|
177
|
+
f" ({result['passed']}P/{result['failed']}F/{result['warnings']}W)")
|
|
178
|
+
|
|
179
|
+
for check_type, msg in result["checks"]:
|
|
180
|
+
icon = {"PASS": " [+]", "FAIL": " [-]", "WARN": " [!]"}[check_type]
|
|
181
|
+
print(f" {icon} {msg}")
|
|
182
|
+
|
|
183
|
+
if result["failed"] > 0:
|
|
184
|
+
failed_products.append(f"SKU {result['sku']}: {result['name']}")
|
|
185
|
+
|
|
186
|
+
total_checks = total_pass + total_fail + total_warn
|
|
187
|
+
print(f"\n{'=' * 70}")
|
|
188
|
+
print(f"SUMMARY")
|
|
189
|
+
print(f"{'=' * 70}")
|
|
190
|
+
print(f"Products: {len(updates)}")
|
|
191
|
+
print(f"Checks: {total_checks} ({total_pass} passed, {total_fail} failed, {total_warn} warnings)")
|
|
192
|
+
if total_checks > 0:
|
|
193
|
+
print(f"Pass rate: {total_pass * 100 // total_checks}%")
|
|
194
|
+
|
|
195
|
+
if failed_products:
|
|
196
|
+
print(f"\nNeeds rework:")
|
|
197
|
+
for fp in failed_products:
|
|
198
|
+
print(f" -> {fp}")
|
|
199
|
+
|
|
200
|
+
sys.exit(1 if total_fail > 0 else 0)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract products of a category as JSON for batch prompting.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python extract_category.py <catalog.json> <category-name> [--limit 8]
|
|
6
|
+
|
|
7
|
+
Supports fuzzy category matching and pagination.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
import argparse
|
|
13
|
+
|
|
14
|
+
DESC_FIELD = "beschreibung"
|
|
15
|
+
CAT_FIELD = "kategorieName"
|
|
16
|
+
CAT_FIELD_ALT = "kategorie"
|
|
17
|
+
SKU_FIELD = "sku"
|
|
18
|
+
NAME_FIELD = "name"
|
|
19
|
+
STATUS_FIELD = "status"
|
|
20
|
+
STATUS_ACTIVE = "aktiv"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main():
|
|
24
|
+
parser = argparse.ArgumentParser(description="Extract category products for batch prompting")
|
|
25
|
+
parser.add_argument("catalog_path", help="Path to catalog JSON file")
|
|
26
|
+
parser.add_argument("category", help="Category name (fuzzy match)")
|
|
27
|
+
parser.add_argument("--limit", "-l", type=int, default=8,
|
|
28
|
+
help="Max products per batch (default: 8)")
|
|
29
|
+
parser.add_argument("--offset", "-o", type=int, default=0,
|
|
30
|
+
help="Skip first N products for pagination")
|
|
31
|
+
parser.add_argument("--thin-first", "-t", action="store_true",
|
|
32
|
+
help="Sort thinnest descriptions first")
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
|
|
35
|
+
with open(args.catalog_path, "r", encoding="utf-8") as f:
|
|
36
|
+
data = json.load(f)
|
|
37
|
+
|
|
38
|
+
search = args.category.lower()
|
|
39
|
+
products = [
|
|
40
|
+
p for p in data.get("products", [])
|
|
41
|
+
if p.get(STATUS_FIELD, STATUS_ACTIVE) == STATUS_ACTIVE
|
|
42
|
+
and (search in p.get(CAT_FIELD_ALT, "").lower()
|
|
43
|
+
or search in p.get(CAT_FIELD, "").lower()
|
|
44
|
+
or p.get(CAT_FIELD_ALT, "").lower() == search
|
|
45
|
+
or p.get(CAT_FIELD, "").lower() == search)
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
if not products:
|
|
49
|
+
all_cats = sorted(set(
|
|
50
|
+
p.get(CAT_FIELD, p.get(CAT_FIELD_ALT, "?"))
|
|
51
|
+
for p in data.get("products", [])
|
|
52
|
+
))
|
|
53
|
+
print(f"No active products found for '{args.category}'.", file=sys.stderr)
|
|
54
|
+
print("Available categories:", file=sys.stderr)
|
|
55
|
+
for c in all_cats:
|
|
56
|
+
print(f" - {c}", file=sys.stderr)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
if args.thin_first:
|
|
60
|
+
products.sort(key=lambda p: len(p.get(DESC_FIELD, "").split()))
|
|
61
|
+
|
|
62
|
+
batch = products[args.offset:args.offset + args.limit]
|
|
63
|
+
|
|
64
|
+
output = []
|
|
65
|
+
for p in batch:
|
|
66
|
+
entry = {
|
|
67
|
+
SKU_FIELD: p.get(SKU_FIELD, ""),
|
|
68
|
+
NAME_FIELD: p.get(NAME_FIELD, ""),
|
|
69
|
+
DESC_FIELD: p.get(DESC_FIELD, ""),
|
|
70
|
+
}
|
|
71
|
+
# Include optional fields if present
|
|
72
|
+
for field in ["variante", "preis", "preisAufAnfrage", "einheit"]:
|
|
73
|
+
if field in p:
|
|
74
|
+
entry[field] = p[field]
|
|
75
|
+
output.append(entry)
|
|
76
|
+
|
|
77
|
+
total = len(products)
|
|
78
|
+
shown = len(batch)
|
|
79
|
+
remaining = total - args.offset - shown
|
|
80
|
+
|
|
81
|
+
print(json.dumps(output, ensure_ascii=False, indent=2))
|
|
82
|
+
print(f"\n// Category: {args.category} | Shown: {shown}/{total}"
|
|
83
|
+
f" | Offset: {args.offset} | Remaining: {max(0, remaining)}",
|
|
84
|
+
file=sys.stderr)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Track progress of a product description update campaign.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python track_progress.py <catalog.json> [--config product-seo-config.json] [--min-words 200]
|
|
6
|
+
|
|
7
|
+
Shows overall progress, per-priority breakdown, and a next-action queue.
|
|
8
|
+
Priorities are loaded from config; without config all categories are equal.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import argparse
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
|
|
16
|
+
DESC_FIELD = "beschreibung"
|
|
17
|
+
CAT_FIELD = "kategorieName"
|
|
18
|
+
CAT_FIELD_ALT = "kategorie"
|
|
19
|
+
SKU_FIELD = "sku"
|
|
20
|
+
NAME_FIELD = "name"
|
|
21
|
+
STATUS_FIELD = "status"
|
|
22
|
+
STATUS_ACTIVE = "aktiv"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_priorities(config_path: str | None) -> dict[str, int]:
|
|
26
|
+
"""Load priority map from config. Returns {category_name: priority_level}."""
|
|
27
|
+
if not config_path:
|
|
28
|
+
return {}
|
|
29
|
+
try:
|
|
30
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
31
|
+
config = json.load(f)
|
|
32
|
+
prio_map = {}
|
|
33
|
+
for level, cats in config.get("priorities", {}).items():
|
|
34
|
+
for cat in cats:
|
|
35
|
+
prio_map[cat] = int(level)
|
|
36
|
+
return prio_map
|
|
37
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_priority(cat_name: str, prio_map: dict[str, int]) -> int:
|
|
42
|
+
if not prio_map:
|
|
43
|
+
return 1 # No config = all equal priority
|
|
44
|
+
return prio_map.get(cat_name, 3)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
parser = argparse.ArgumentParser(description="Track description update progress")
|
|
49
|
+
parser.add_argument("catalog_path", help="Path to catalog JSON file")
|
|
50
|
+
parser.add_argument("--config", help="Path to product-seo-config.json")
|
|
51
|
+
parser.add_argument("--min-words", "-m", type=int, default=200,
|
|
52
|
+
help="Word count threshold (default: 200)")
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
|
|
55
|
+
with open(args.catalog_path, "r", encoding="utf-8") as f:
|
|
56
|
+
data = json.load(f)
|
|
57
|
+
|
|
58
|
+
prio_map = load_priorities(args.config)
|
|
59
|
+
products = [p for p in data.get("products", [])
|
|
60
|
+
if p.get(STATUS_FIELD, STATUS_ACTIVE) == STATUS_ACTIVE]
|
|
61
|
+
|
|
62
|
+
categories = defaultdict(lambda: {"total": 0, "done": 0, "todo": 0, "products_todo": []})
|
|
63
|
+
|
|
64
|
+
for p in products:
|
|
65
|
+
cat = p.get(CAT_FIELD, p.get(CAT_FIELD_ALT, "Unknown"))
|
|
66
|
+
wc = len(p.get(DESC_FIELD, "").split())
|
|
67
|
+
categories[cat]["total"] += 1
|
|
68
|
+
if wc >= args.min_words:
|
|
69
|
+
categories[cat]["done"] += 1
|
|
70
|
+
else:
|
|
71
|
+
categories[cat]["todo"] += 1
|
|
72
|
+
categories[cat]["products_todo"].append({
|
|
73
|
+
SKU_FIELD: p.get(SKU_FIELD, ""),
|
|
74
|
+
NAME_FIELD: p.get(NAME_FIELD, ""),
|
|
75
|
+
"words": wc,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
total_products = len(products)
|
|
79
|
+
total_done = sum(c["done"] for c in categories.values())
|
|
80
|
+
total_todo = total_products - total_done
|
|
81
|
+
pct = total_done * 100 // total_products if total_products > 0 else 0
|
|
82
|
+
|
|
83
|
+
bar_len = 40
|
|
84
|
+
filled = bar_len * total_done // total_products if total_products > 0 else 0
|
|
85
|
+
bar = "#" * filled + "-" * (bar_len - filled)
|
|
86
|
+
|
|
87
|
+
print(f"{'=' * 70}")
|
|
88
|
+
print(f"PRODUCT DESCRIPTION PROGRESS")
|
|
89
|
+
print(f"{'=' * 70}")
|
|
90
|
+
print(f"\n [{bar}] {pct}%")
|
|
91
|
+
print(f" {total_done}/{total_products} products with >={args.min_words} words")
|
|
92
|
+
print(f" {total_todo} remaining")
|
|
93
|
+
print(f"\n Estimated effort: ~{total_todo // 6 + 1} batch runs at 5-8 products each")
|
|
94
|
+
|
|
95
|
+
# Determine which priority levels exist
|
|
96
|
+
prio_levels = sorted(set(get_priority(name, prio_map) for name in categories))
|
|
97
|
+
|
|
98
|
+
for prio in prio_levels:
|
|
99
|
+
prio_cats = [(name, stats) for name, stats in categories.items()
|
|
100
|
+
if get_priority(name, prio_map) == prio]
|
|
101
|
+
if not prio_cats:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
prio_total = sum(s["total"] for _, s in prio_cats)
|
|
105
|
+
prio_done = sum(s["done"] for _, s in prio_cats)
|
|
106
|
+
prio_pct = prio_done * 100 // prio_total if prio_total > 0 else 0
|
|
107
|
+
|
|
108
|
+
label = f"PRIORITY {prio}" if prio_map else "ALL CATEGORIES"
|
|
109
|
+
print(f"\n{'-' * 70}")
|
|
110
|
+
print(f"{label} -- {prio_done}/{prio_total} done ({prio_pct}%)")
|
|
111
|
+
print(f"{'-' * 70}")
|
|
112
|
+
print(f"{'Category':<45} {'Done':>10} {'Open':>7}")
|
|
113
|
+
|
|
114
|
+
for name, stats in sorted(prio_cats, key=lambda x: x[1]["todo"], reverse=True):
|
|
115
|
+
done_str = f"{stats['done']}/{stats['total']}"
|
|
116
|
+
check = " done" if stats["todo"] == 0 else ""
|
|
117
|
+
print(f" {name:<43} {done_str:>10} {stats['todo']:>5} {check}")
|
|
118
|
+
|
|
119
|
+
# Next action queue
|
|
120
|
+
print(f"\n{'=' * 70}")
|
|
121
|
+
print(f"NEXT ACTIONS")
|
|
122
|
+
print(f"{'=' * 70}")
|
|
123
|
+
|
|
124
|
+
shown = 0
|
|
125
|
+
for name, stats in sorted(categories.items(),
|
|
126
|
+
key=lambda x: (get_priority(x[0], prio_map), -x[1]["todo"])):
|
|
127
|
+
if stats["todo"] == 0:
|
|
128
|
+
continue
|
|
129
|
+
prio = get_priority(name, prio_map)
|
|
130
|
+
label = f"Prio {prio}" if prio_map else ""
|
|
131
|
+
print(f"\n -> {name} ({label + ', ' if label else ''}{stats['todo']} open):")
|
|
132
|
+
for p in sorted(stats["products_todo"], key=lambda x: x["words"])[:5]:
|
|
133
|
+
print(f" SKU {p[SKU_FIELD]}: {p[NAME_FIELD]} ({p['words']} words)")
|
|
134
|
+
shown += 1
|
|
135
|
+
if shown >= 5:
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Write updated product descriptions back to a catalog JSON file.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python update_catalog.py <catalog.json> <updates.json>
|
|
6
|
+
|
|
7
|
+
updates.json format:
|
|
8
|
+
[
|
|
9
|
+
{"sku": "401", "beschreibung": "New description text..."},
|
|
10
|
+
{"sku": "402", "beschreibung": "Another description..."}
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
Creates an automatic backup before writing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import argparse
|
|
19
|
+
import shutil
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
DESC_FIELD = "beschreibung"
|
|
23
|
+
SKU_FIELD = "sku"
|
|
24
|
+
NAME_FIELD = "name"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
parser = argparse.ArgumentParser(description="Write updated descriptions back to catalog JSON")
|
|
29
|
+
parser.add_argument("catalog_path", help="Path to catalog JSON file")
|
|
30
|
+
parser.add_argument("updates_path", help="Path to updates JSON file")
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
catalog_path = args.catalog_path
|
|
34
|
+
updates_path = args.updates_path
|
|
35
|
+
|
|
36
|
+
with open(catalog_path, "r", encoding="utf-8") as f:
|
|
37
|
+
catalog = json.load(f)
|
|
38
|
+
|
|
39
|
+
with open(updates_path, "r", encoding="utf-8") as f:
|
|
40
|
+
updates = json.load(f)
|
|
41
|
+
|
|
42
|
+
# Support both "beschreibung" and "description" field names in updates
|
|
43
|
+
update_map = {}
|
|
44
|
+
for u in updates:
|
|
45
|
+
desc = u.get(DESC_FIELD, u.get("description", ""))
|
|
46
|
+
update_map[u[SKU_FIELD]] = desc
|
|
47
|
+
|
|
48
|
+
# Create backup
|
|
49
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
50
|
+
backup_path = f"{catalog_path}.backup_{timestamp}"
|
|
51
|
+
shutil.copy2(catalog_path, backup_path)
|
|
52
|
+
print(f"Backup created: {backup_path}")
|
|
53
|
+
|
|
54
|
+
updated = 0
|
|
55
|
+
not_found = []
|
|
56
|
+
|
|
57
|
+
for product in catalog.get("products", []):
|
|
58
|
+
sku = product.get(SKU_FIELD, "")
|
|
59
|
+
if sku in update_map:
|
|
60
|
+
old_wc = len(product.get(DESC_FIELD, "").split())
|
|
61
|
+
product[DESC_FIELD] = update_map[sku]
|
|
62
|
+
new_wc = len(update_map[sku].split())
|
|
63
|
+
print(f" [+] SKU {sku}: {product.get(NAME_FIELD, '?')} ({old_wc} -> {new_wc} words)")
|
|
64
|
+
updated += 1
|
|
65
|
+
del update_map[sku]
|
|
66
|
+
|
|
67
|
+
for sku in update_map:
|
|
68
|
+
not_found.append(sku)
|
|
69
|
+
print(f" [-] SKU {sku}: Not found in catalog")
|
|
70
|
+
|
|
71
|
+
with open(catalog_path, "w", encoding="utf-8") as f:
|
|
72
|
+
json.dump(catalog, f, ensure_ascii=False, indent="\t")
|
|
73
|
+
|
|
74
|
+
print(f"\n{updated} descriptions updated.")
|
|
75
|
+
if not_found:
|
|
76
|
+
print(f"{len(not_found)} SKUs not found: {', '.join(not_found)}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
main()
|