@petercjl/taobao-ai-ops 0.1.0 → 0.1.2
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/README.md +30 -22
- package/package.json +2 -7
- package/requirements.txt +1 -0
- package/skills/taobao-ai-operations/SKILL.md +8 -1
- package/src/cli.mjs +16 -6
- package/src/components.mjs +73 -0
- package/src/core.mjs +5 -1
- package/src/runtime.mjs +18 -16
- package/src/skill-manager.mjs +4 -4
- package/src/update.mjs +5 -5
- package/src/workflow.mjs +3 -3
- package/suite-manifest.json +186 -10
- package/skills/taobao-search-product-form/SKILL.md +0 -74
- package/skills/taobao-search-product-form/adapters/codex.json +0 -139
- package/skills/taobao-search-product-form/adapters/sealseek.json +0 -24
- package/skills/taobao-search-product-form/agents/openai.yaml +0 -3
- package/skills/taobao-search-product-form/capabilities.json +0 -148
- package/skills/taobao-search-product-form/references/SCHEMA.md +0 -3
- package/skills/taobao-search-product-form/references/index.md +0 -10
- package/skills/taobao-search-product-form/references/log.md +0 -35
- package/skills/taobao-search-product-form/references/queries/evaluate-white-label-forms.md +0 -3
- package/skills/taobao-search-product-form/references/queries/identify-advertisers.md +0 -5
- package/skills/taobao-search-product-form/references/queries/identify-geography.md +0 -5
- package/skills/taobao-search-product-form/references/queries/identify-price-bands.md +0 -3
- package/skills/taobao-search-product-form/references/queries/identify-product-forms.md +0 -3
- package/skills/taobao-search-product-form/references/queries/identify-title-roots.md +0 -5
- package/skills/taobao-search-product-form/references/report-output.md +0 -23
- package/skills/taobao-search-product-form/references/source-manifest.md +0 -37
- package/skills/taobao-search-product-form/references/title-lexicon.json +0 -55
- package/skills/taobao-search-product-form/references/topics/advertiser-analysis-method.md +0 -43
- package/skills/taobao-search-product-form/references/topics/geography-analysis-method.md +0 -23
- package/skills/taobao-search-product-form/references/topics/price-band-analysis-method.md +0 -27
- package/skills/taobao-search-product-form/references/topics/title-root-analysis-method.md +0 -37
- package/skills/taobao-search-product-form/references/topics/visual-form-method.md +0 -31
- package/skills/taobao-search-product-form/references/topics/white-label-evidence.md +0 -38
- package/skills/taobao-search-product-form/references/workflow-ledger.md +0 -28
- package/skills/taobao-search-product-form/report_runtime/VERSION +0 -1
- package/skills/taobao-search-product-form/report_runtime/renderer.py +0 -485
- package/skills/taobao-search-product-form/report_runtime/templates/workbench.html +0 -67
- package/skills/taobao-search-product-form/report_runtime/validator.py +0 -117
- package/skills/taobao-search-product-form/scripts/analyze.py +0 -119
- package/skills/taobao-search-product-form/scripts/analyze_advertisers.py +0 -352
- package/skills/taobao-search-product-form/scripts/analyze_geography.py +0 -316
- package/skills/taobao-search-product-form/scripts/analyze_price_bands.py +0 -257
- package/skills/taobao-search-product-form/scripts/analyze_title_roots.py +0 -327
- package/skills/taobao-search-product-form/scripts/assemble_opportunity_evidence.py +0 -322
- package/skills/taobao-search-product-form/scripts/build_report_viewmodel.py +0 -822
- package/skills/taobao-search-product-form/scripts/build_visual_review_viewmodel.py +0 -177
- package/skills/taobao-search-product-form/scripts/clean_search_export.mjs +0 -61
- package/skills/taobao-search-product-form/scripts/cluster_visual_forms.py +0 -193
- package/skills/taobao-search-product-form/scripts/compile_style_prototypes.py +0 -115
- package/skills/taobao-search-product-form/scripts/compile_visual_review.py +0 -60
- package/skills/taobao-search-product-form/scripts/compile_visual_selection.py +0 -148
- package/skills/taobao-search-product-form/scripts/prepare.py +0 -180
- package/skills/taobao-search-product-form/scripts/render_report.py +0 -31
- package/skills/taobao-search-product-form/scripts/validate_opportunity_cards.py +0 -84
- package/skills/taobao-search-product-form/scripts/validate_report_viewmodel.py +0 -152
- package/skills/taobao-search-product-form/scripts/visual_form_pipeline.py +0 -247
- package/skills/taobao-search-product-form/scripts/visual_observation_batches.py +0 -117
- package/skills/taobao-search-product-form/scripts/workflow_ledger.py +0 -177
- package/skills/yuce-category-opportunity-report/SKILL.md +0 -151
- package/skills/yuce-category-opportunity-report/adapters/codex.json +0 -113
- package/skills/yuce-category-opportunity-report/adapters/sealseek.json +0 -39
- package/skills/yuce-category-opportunity-report/agents/openai.yaml +0 -3
- package/skills/yuce-category-opportunity-report/assets/chart-views.js +0 -80
- package/skills/yuce-category-opportunity-report/assets/package.json +0 -1
- package/skills/yuce-category-opportunity-report/assets/report-template.html +0 -62
- package/skills/yuce-category-opportunity-report/capabilities.json +0 -48
- package/skills/yuce-category-opportunity-report/references/SCHEMA.md +0 -5
- package/skills/yuce-category-opportunity-report/references/data-contract.md +0 -40
- package/skills/yuce-category-opportunity-report/references/index.md +0 -6
- package/skills/yuce-category-opportunity-report/references/log.md +0 -29
- package/skills/yuce-category-opportunity-report/references/queries/assess-category-opportunity.md +0 -7
- package/skills/yuce-category-opportunity-report/references/source-manifest.md +0 -14
- package/skills/yuce-category-opportunity-report/references/topics/category-opportunity-method.md +0 -39
- package/skills/yuce-category-opportunity-report/scripts/build_report.py +0 -779
- package/skills/yuce-category-opportunity-report/scripts/market_routes.py +0 -139
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Build auditable product-title roots from a cleaned natural-position sheet.
|
|
3
|
-
|
|
4
|
-
Requires pandas, an XLSX reader, and a Python package exposing ``jieba``.
|
|
5
|
-
Writes a new output directory; never changes the workbook or shared lexicon.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import argparse
|
|
9
|
-
import hashlib
|
|
10
|
-
import importlib.metadata
|
|
11
|
-
import itertools
|
|
12
|
-
import json
|
|
13
|
-
import re
|
|
14
|
-
import unicodedata
|
|
15
|
-
from collections import Counter, defaultdict
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
|
|
18
|
-
import pandas as pd
|
|
19
|
-
|
|
20
|
-
try:
|
|
21
|
-
import jieba
|
|
22
|
-
except ImportError as error:
|
|
23
|
-
raise SystemExit("jieba is required; install the documented jieba-compatible Python package") from error
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
REQUIRED = {"商品ID", "商品名称", "商品图片链接", "现价", "付款人数", "店铺名称", "占位类型"}
|
|
27
|
-
DEFAULT_LEXICON = Path(__file__).resolve().parents[1] / "references" / "title-lexicon.json"
|
|
28
|
-
WORDLIKE = re.compile(r"[\u3400-\u9fffA-Za-z0-9]")
|
|
29
|
-
SIZE = re.compile(r"(?<!\d)(\d{2}(?:\.\d+)?)\s*(cm|厘米|寸)(?![A-Za-z])", re.IGNORECASE)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def clean_text(value):
|
|
33
|
-
return "" if pd.isna(value) else str(value).strip()
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def normalized(value):
|
|
37
|
-
return unicodedata.normalize("NFKC", clean_text(value))
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def optional_number(value):
|
|
41
|
-
numeric = pd.to_numeric(value, errors="coerce")
|
|
42
|
-
return None if pd.isna(numeric) else float(numeric)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def file_sha(path):
|
|
46
|
-
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def load_lexicon(base_path, project_path=None):
|
|
50
|
-
paths = [base_path] + ([project_path] if project_path else [])
|
|
51
|
-
entries = []
|
|
52
|
-
surface_map = {}
|
|
53
|
-
sources = []
|
|
54
|
-
for path in paths:
|
|
55
|
-
document = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
-
if document.get("schema_version") != "1.0" or not isinstance(document.get("entries"), list):
|
|
57
|
-
raise ValueError(f"invalid title lexicon schema: {path}")
|
|
58
|
-
sources.append({"file_name": path.name, "sha256": file_sha(path), "entry_count": len(document["entries"])})
|
|
59
|
-
for raw in document["entries"]:
|
|
60
|
-
canonical = normalized(raw.get("canonical"))
|
|
61
|
-
facet = clean_text(raw.get("facet"))
|
|
62
|
-
surfaces = raw.get("surfaces")
|
|
63
|
-
if not canonical or not facet or not isinstance(surfaces, list) or not surfaces:
|
|
64
|
-
raise ValueError(f"invalid lexicon entry in {path}: {raw}")
|
|
65
|
-
item = {"canonical": canonical, "facet": facet, "surfaces": []}
|
|
66
|
-
for raw_surface in surfaces:
|
|
67
|
-
surface = normalized(raw_surface)
|
|
68
|
-
if not surface or not WORDLIKE.search(surface):
|
|
69
|
-
raise ValueError(f"invalid lexicon surface in {path}: {raw_surface}")
|
|
70
|
-
key = surface.casefold()
|
|
71
|
-
current = surface_map.get(key)
|
|
72
|
-
if current and current != (canonical, facet):
|
|
73
|
-
raise ValueError(f"conflicting lexicon surface {surface}: {current} versus {(canonical, facet)}")
|
|
74
|
-
surface_map[key] = (canonical, facet)
|
|
75
|
-
if surface not in item["surfaces"]:
|
|
76
|
-
item["surfaces"].append(surface)
|
|
77
|
-
entries.append(item)
|
|
78
|
-
return {"schema_version": "1.0", "entries": entries, "sources": sources}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def match_title(title, lexicon, tokenizer):
|
|
82
|
-
"""Return longest mapped phrases per facet plus unclaimed jieba candidates."""
|
|
83
|
-
matches = []
|
|
84
|
-
occupied_by_facet = defaultdict(set)
|
|
85
|
-
surfaces = [
|
|
86
|
-
(surface, entry["canonical"], entry["facet"])
|
|
87
|
-
for entry in lexicon["entries"] for surface in entry["surfaces"]
|
|
88
|
-
]
|
|
89
|
-
for surface, canonical, facet in sorted(surfaces, key=lambda item: (-len(item[0]), item[0])):
|
|
90
|
-
lower_title = title.casefold()
|
|
91
|
-
lower_surface = surface.casefold()
|
|
92
|
-
start = 0
|
|
93
|
-
while True:
|
|
94
|
-
found = lower_title.find(lower_surface, start)
|
|
95
|
-
if found < 0:
|
|
96
|
-
break
|
|
97
|
-
end = found + len(surface)
|
|
98
|
-
if not any(index in occupied_by_facet[facet] for index in range(found, end)):
|
|
99
|
-
matches.append({"root": canonical, "facet": facet, "surface": title[found:end], "start": found, "end": end, "source": "lexicon"})
|
|
100
|
-
occupied_by_facet[facet].update(range(found, end))
|
|
101
|
-
start = found + 1
|
|
102
|
-
|
|
103
|
-
protected = set().union(*occupied_by_facet.values()) if occupied_by_facet else set()
|
|
104
|
-
for found in SIZE.finditer(title):
|
|
105
|
-
root = f"{found.group(1)}{found.group(2).lower()}"
|
|
106
|
-
matches.append({"root": root, "facet": "size_claim", "surface": found.group(), "start": found.start(), "end": found.end(), "source": "size_pattern"})
|
|
107
|
-
protected.update(range(found.start(), found.end()))
|
|
108
|
-
for word, start, end in tokenizer.tokenize(title, HMM=False):
|
|
109
|
-
candidate = word.strip()
|
|
110
|
-
if not candidate or not WORDLIKE.search(candidate) or all(char.isdigit() for char in candidate):
|
|
111
|
-
continue
|
|
112
|
-
if any(index in protected for index in range(start, end)):
|
|
113
|
-
continue
|
|
114
|
-
if len(candidate) < 2:
|
|
115
|
-
continue
|
|
116
|
-
matches.append({"root": candidate.casefold(), "facet": "unclassified", "surface": word, "start": start, "end": end, "source": "jieba_candidate"})
|
|
117
|
-
|
|
118
|
-
return sorted(matches, key=lambda item: (item["start"], item["end"], item["facet"], item["root"]))
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def percentile(values, quantile):
|
|
122
|
-
numeric = pd.Series([value for value in values if value is not None], dtype="float64")
|
|
123
|
-
return None if numeric.empty else round(float(numeric.quantile(quantile)), 4)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def analyze_frame(frame, lexicon):
|
|
127
|
-
missing = REQUIRED - set(frame.columns)
|
|
128
|
-
if missing:
|
|
129
|
-
raise ValueError(f"natural-position sheet missing columns: {sorted(missing)}")
|
|
130
|
-
if frame.empty:
|
|
131
|
-
raise ValueError("natural-position sheet is empty")
|
|
132
|
-
ids = frame["商品ID"].map(clean_text)
|
|
133
|
-
if ids.eq("").any() or ids.duplicated().any():
|
|
134
|
-
raise ValueError("natural-position product IDs must be nonblank and unique")
|
|
135
|
-
if not frame["占位类型"].map(clean_text).eq("自然位").all():
|
|
136
|
-
raise ValueError("title analysis accepts natural-position rows only")
|
|
137
|
-
|
|
138
|
-
tokenizer = jieba.Tokenizer()
|
|
139
|
-
for entry in lexicon["entries"]:
|
|
140
|
-
for surface in entry["surfaces"]:
|
|
141
|
-
tokenizer.add_word(surface, freq=2_000_000)
|
|
142
|
-
|
|
143
|
-
products = []
|
|
144
|
-
relations = []
|
|
145
|
-
for _, row in frame.iterrows():
|
|
146
|
-
product_id = clean_text(row["商品ID"])
|
|
147
|
-
title = clean_text(row["商品名称"])
|
|
148
|
-
title_normalized = normalized(title)
|
|
149
|
-
product = {
|
|
150
|
-
"product_id": product_id,
|
|
151
|
-
"title": title,
|
|
152
|
-
"title_normalized": title_normalized,
|
|
153
|
-
"image_url": clean_text(row["商品图片链接"]),
|
|
154
|
-
"price_yuan": optional_number(row["现价"]),
|
|
155
|
-
"payment_people": optional_number(row["付款人数"]),
|
|
156
|
-
"shop_name": clean_text(row["店铺名称"]),
|
|
157
|
-
"shop_type": clean_text(row.get("店铺类型")) or None,
|
|
158
|
-
"natural_position": optional_number(row.get("序号")),
|
|
159
|
-
}
|
|
160
|
-
if not title_normalized or not product["shop_name"]:
|
|
161
|
-
raise ValueError(f"natural-position product {product_id} has a blank title or shop name")
|
|
162
|
-
products.append(product)
|
|
163
|
-
grouped = defaultdict(list)
|
|
164
|
-
for match in match_title(title_normalized, lexicon, tokenizer):
|
|
165
|
-
grouped[(match["facet"], match["root"])].append(match)
|
|
166
|
-
for (facet, root), occurrences in sorted(grouped.items()):
|
|
167
|
-
relations.append({
|
|
168
|
-
"product_id": product_id,
|
|
169
|
-
"root": root,
|
|
170
|
-
"facet": facet,
|
|
171
|
-
"surfaces": sorted({hit["surface"] for hit in occurrences}),
|
|
172
|
-
"occurrence_count_in_title": len(occurrences),
|
|
173
|
-
"spans_in_normalized_title": [[hit["start"], hit["end"]] for hit in occurrences],
|
|
174
|
-
"sources": sorted({hit["source"] for hit in occurrences}),
|
|
175
|
-
"title": title,
|
|
176
|
-
"image_url": product["image_url"],
|
|
177
|
-
"price_yuan": product["price_yuan"],
|
|
178
|
-
"payment_people": product["payment_people"],
|
|
179
|
-
"shop_name": product["shop_name"],
|
|
180
|
-
"shop_type": product["shop_type"],
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
by_id = {row["product_id"]: row for row in products}
|
|
184
|
-
payment_threshold = percentile([row["payment_people"] for row in products], 0.9)
|
|
185
|
-
by_root = defaultdict(list)
|
|
186
|
-
by_shop = defaultdict(list)
|
|
187
|
-
mapped_by_product = defaultdict(set)
|
|
188
|
-
for relation in relations:
|
|
189
|
-
by_root[(relation["facet"], relation["root"])].append(relation)
|
|
190
|
-
if relation["facet"] != "unclassified":
|
|
191
|
-
mapped_by_product[relation["product_id"]].add((relation["facet"], relation["root"]))
|
|
192
|
-
by_shop[relation["shop_name"]].append(relation)
|
|
193
|
-
|
|
194
|
-
roots = []
|
|
195
|
-
for (facet, root), rows in by_root.items():
|
|
196
|
-
prices = [row["price_yuan"] for row in rows]
|
|
197
|
-
payments = [row["payment_people"] for row in rows]
|
|
198
|
-
shop_groups = defaultdict(list)
|
|
199
|
-
for row in rows:
|
|
200
|
-
shop_groups[row["shop_name"]].append(row)
|
|
201
|
-
top_shops = sorted(
|
|
202
|
-
({"shop_name": shop, "product_count": len(group), "displayed_payment_people_sum": round(sum(item["payment_people"] or 0 for item in group), 2)} for shop, group in shop_groups.items()),
|
|
203
|
-
key=lambda item: (-item["product_count"], -item["displayed_payment_people_sum"], item["shop_name"]),
|
|
204
|
-
)[:5]
|
|
205
|
-
high = sum(value is not None and payment_threshold is not None and value >= payment_threshold for value in payments)
|
|
206
|
-
roots.append({
|
|
207
|
-
"root": root, "facet": facet, "product_count": len(rows),
|
|
208
|
-
"product_share": round(len(rows) / len(products), 6),
|
|
209
|
-
"shop_count": len(shop_groups),
|
|
210
|
-
"price_p25": percentile(prices, 0.25), "price_median": percentile(prices, 0.5), "price_p75": percentile(prices, 0.75),
|
|
211
|
-
"payment_people_median": percentile(payments, 0.5),
|
|
212
|
-
"payment_people_p75": percentile(payments, 0.75),
|
|
213
|
-
"displayed_payment_people_sum_within_root": round(sum(value or 0 for value in payments), 2),
|
|
214
|
-
"high_payment_product_count": high,
|
|
215
|
-
"high_payment_product_share": round(high / len(rows), 6),
|
|
216
|
-
"top_shops_by_product_count": top_shops,
|
|
217
|
-
})
|
|
218
|
-
roots.sort(key=lambda item: (-item["product_count"], item["facet"], item["root"]))
|
|
219
|
-
root_counts = {(item["facet"], item["root"]): item["product_count"] for item in roots}
|
|
220
|
-
|
|
221
|
-
shops = []
|
|
222
|
-
for shop_name, group in frame.groupby("店铺名称", sort=False):
|
|
223
|
-
shop_ids = {clean_text(value) for value in group["商品ID"]}
|
|
224
|
-
shop_relations = by_shop.get(shop_name, [])
|
|
225
|
-
root_groups = defaultdict(list)
|
|
226
|
-
for relation in shop_relations:
|
|
227
|
-
root_groups[(relation["facet"], relation["root"])].append(relation)
|
|
228
|
-
shop_roots = sorted(
|
|
229
|
-
({"facet": facet, "root": root, "product_count": len(rows), "displayed_payment_people_sum_within_root": round(sum(row["payment_people"] or 0 for row in rows), 2)} for (facet, root), rows in root_groups.items()),
|
|
230
|
-
key=lambda item: (-item["product_count"], -item["displayed_payment_people_sum_within_root"], item["root"]),
|
|
231
|
-
)
|
|
232
|
-
distinctive = []
|
|
233
|
-
for item in shop_roots:
|
|
234
|
-
corpus_count = root_counts[(item["facet"], item["root"])]
|
|
235
|
-
if item["product_count"] < 2 or corpus_count < 5:
|
|
236
|
-
continue
|
|
237
|
-
shop_share = item["product_count"] / len(shop_ids)
|
|
238
|
-
corpus_share = corpus_count / len(products)
|
|
239
|
-
distinctive.append({
|
|
240
|
-
**item,
|
|
241
|
-
"shop_product_share": round(shop_share, 6),
|
|
242
|
-
"corpus_product_share": round(corpus_share, 6),
|
|
243
|
-
"shop_to_corpus_coverage_ratio": round(shop_share / corpus_share, 4),
|
|
244
|
-
})
|
|
245
|
-
distinctive.sort(key=lambda item: (-item["shop_to_corpus_coverage_ratio"], -item["product_count"], item["root"]))
|
|
246
|
-
shops.append({
|
|
247
|
-
"shop_name": shop_name,
|
|
248
|
-
"natural_product_count": len(shop_ids),
|
|
249
|
-
"displayed_payment_people_sum_across_visible_products": round(sum(by_id[pid]["payment_people"] or 0 for pid in shop_ids), 2),
|
|
250
|
-
"top_mapped_roots": shop_roots[:15],
|
|
251
|
-
"top_distinctive_roots": distinctive[:15],
|
|
252
|
-
})
|
|
253
|
-
shops.sort(key=lambda item: (-item["natural_product_count"], item["shop_name"]))
|
|
254
|
-
|
|
255
|
-
pairs = Counter()
|
|
256
|
-
for roots_in_product in mapped_by_product.values():
|
|
257
|
-
for first, second in itertools.combinations(sorted(roots_in_product), 2):
|
|
258
|
-
if first[0] != second[0]:
|
|
259
|
-
pairs[(first, second)] += 1
|
|
260
|
-
pair_rows = [
|
|
261
|
-
{"first": {"facet": first[0], "root": first[1]}, "second": {"facet": second[0], "root": second[1]}, "product_count": count}
|
|
262
|
-
for (first, second), count in sorted(pairs.items(), key=lambda item: (-item[1], item[0]))
|
|
263
|
-
if count >= 5
|
|
264
|
-
][:500]
|
|
265
|
-
|
|
266
|
-
candidate_rows = [
|
|
267
|
-
{"candidate": root["root"], "product_count": root["product_count"], "shop_count": root["shop_count"], "example_product_ids": [row["product_id"] for row in by_root[("unclassified", root["root"])][:5]]}
|
|
268
|
-
for root in roots if root["facet"] == "unclassified" and root["product_count"] >= 3
|
|
269
|
-
][:500]
|
|
270
|
-
quality = {
|
|
271
|
-
"natural_product_count": len(products),
|
|
272
|
-
"distinct_product_ids": len(by_id),
|
|
273
|
-
"product_root_rows": len(relations),
|
|
274
|
-
"products_with_mapped_root": len(mapped_by_product),
|
|
275
|
-
"mapped_product_root_rows": sum(row["facet"] != "unclassified" for row in relations),
|
|
276
|
-
"candidate_product_root_rows": sum(row["facet"] == "unclassified" for row in relations),
|
|
277
|
-
"missing_price_count": sum(row["price_yuan"] is None for row in products),
|
|
278
|
-
"missing_payment_count": sum(row["payment_people"] is None for row in products),
|
|
279
|
-
"payment_high_threshold_p90": payment_threshold,
|
|
280
|
-
"actual_products_at_or_above_p90": sum(row["payment_people"] is not None and row["payment_people"] >= payment_threshold for row in products) if payment_threshold is not None else 0,
|
|
281
|
-
}
|
|
282
|
-
if len(products) != len(by_id) or any(len({row["product_id"] for row in group}) != len(group) for group in by_root.values()):
|
|
283
|
-
raise ValueError("product/root identity reconciliation failed")
|
|
284
|
-
return {"products": products, "product_roots": relations, "roots": roots, "shops": shops, "root_pairs": pair_rows, "candidate_terms": candidate_rows, "quality": quality}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def main():
|
|
288
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
289
|
-
parser.add_argument("--input", required=True, help="Cleaned XLSX with a 自然位 sheet")
|
|
290
|
-
parser.add_argument("--output-dir", required=True, help="New directory for title-root JSON artifacts")
|
|
291
|
-
parser.add_argument("--project-lexicon", help="Optional reviewed lexicon JSON for this project; never modified")
|
|
292
|
-
args = parser.parse_args()
|
|
293
|
-
source = Path(args.input).expanduser().resolve()
|
|
294
|
-
output = Path(args.output_dir).expanduser().resolve()
|
|
295
|
-
project = Path(args.project_lexicon).expanduser().resolve() if args.project_lexicon else None
|
|
296
|
-
if not source.is_file() or source.suffix.lower() != ".xlsx":
|
|
297
|
-
parser.error("input must be an existing XLSX file")
|
|
298
|
-
if output.exists():
|
|
299
|
-
parser.error("output directory already exists")
|
|
300
|
-
if project and not project.is_file():
|
|
301
|
-
parser.error("project lexicon must be an existing JSON file")
|
|
302
|
-
lexicon = load_lexicon(DEFAULT_LEXICON, project)
|
|
303
|
-
frame = pd.read_excel(source, sheet_name="自然位", dtype={"商品ID": str})
|
|
304
|
-
result = analyze_frame(frame, lexicon)
|
|
305
|
-
try:
|
|
306
|
-
jieba_version = importlib.metadata.version("jieba-py")
|
|
307
|
-
distribution = "jieba-py"
|
|
308
|
-
except importlib.metadata.PackageNotFoundError:
|
|
309
|
-
jieba_version = importlib.metadata.version("jieba")
|
|
310
|
-
distribution = "jieba"
|
|
311
|
-
source_meta = {"file_name": source.name, "sha256": file_sha(source), "sheet": "自然位", "jieba_distribution": distribution, "jieba_version": jieba_version}
|
|
312
|
-
output.mkdir(parents=True, exist_ok=False)
|
|
313
|
-
artifacts = {
|
|
314
|
-
"product_roots.json": {"schema_version": "1.0", "source": source_meta, "products": result["products"], "product_roots": result["product_roots"]},
|
|
315
|
-
"root_summary.json": {"schema_version": "1.0", "source": source_meta, "definitions": {"root_coverage": "Distinct natural-position product IDs containing this title-root claim; not buyer search-query volume.", "payment": "Source-sheet 付款人数 with unspecified period; a product can belong to several roots, so sums across roots are non-additive.", "form": "Title-stated form only; real product form requires image review.", "shop_to_corpus_coverage_ratio": "Share of one shop's visible products using a root divided by the share of all visible products using that root; descriptive, not proof of merchant intent."}, "quality": result["quality"], "roots": result["roots"], "shops": result["shops"], "root_pairs": result["root_pairs"]},
|
|
316
|
-
"lexicon_snapshot.json": {"schema_version": "1.0", "source": source_meta, "dictionary_policy": "Versioned bundled vocabulary plus optional reviewed project vocabulary; each run stores a snapshot and candidates but never mutates either lexicon.", **lexicon},
|
|
317
|
-
"lexicon_candidates.json": {"schema_version": "1.0", "source": source_meta, "promotion_policy": "Candidates need semantic review before inclusion in a later versioned lexicon.", "candidates": result["candidate_terms"]},
|
|
318
|
-
}
|
|
319
|
-
for filename, document in artifacts.items():
|
|
320
|
-
with (output / filename).open("x", encoding="utf-8") as handle:
|
|
321
|
-
json.dump(document, handle, ensure_ascii=False, indent=2)
|
|
322
|
-
handle.write("\n")
|
|
323
|
-
print(json.dumps({"output_dir": str(output), "files": list(artifacts), "quality": result["quality"]}, ensure_ascii=False))
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if __name__ == "__main__":
|
|
327
|
-
main()
|
|
@@ -1,322 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Join approved search modules into product-keyed opportunity evidence.
|
|
3
|
-
|
|
4
|
-
This script describes observed groups; it does not score or recommend entry.
|
|
5
|
-
Inputs are local stage artifacts. The output path must not exist.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import argparse
|
|
9
|
-
import hashlib
|
|
10
|
-
import json
|
|
11
|
-
import statistics
|
|
12
|
-
from collections import Counter, defaultdict
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def read_json(path):
|
|
17
|
-
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def digest(path):
|
|
21
|
-
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def unique_rows(rows, label):
|
|
25
|
-
result = {}
|
|
26
|
-
for row in rows:
|
|
27
|
-
key = str(row.get("product_id", "")).strip()
|
|
28
|
-
if not key or key in result:
|
|
29
|
-
raise ValueError(f"{label}: blank or duplicate product_id: {key!r}")
|
|
30
|
-
result[key] = row
|
|
31
|
-
return result
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def percentile(values, fraction):
|
|
35
|
-
if not values:
|
|
36
|
-
return None
|
|
37
|
-
ordered = sorted(values)
|
|
38
|
-
position = (len(ordered) - 1) * fraction
|
|
39
|
-
lo = int(position)
|
|
40
|
-
hi = min(lo + 1, len(ordered) - 1)
|
|
41
|
-
return round(ordered[lo] + (ordered[hi] - ordered[lo]) * (position - lo), 2)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def number(value, label):
|
|
45
|
-
try:
|
|
46
|
-
result = float(value)
|
|
47
|
-
except (TypeError, ValueError) as exc:
|
|
48
|
-
raise ValueError(f"invalid {label}: {value!r}") from exc
|
|
49
|
-
if result < 0 or result != result or result == float("inf"):
|
|
50
|
-
raise ValueError(f"invalid {label}: {value!r}")
|
|
51
|
-
return result
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def same_fact(left, right):
|
|
55
|
-
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
|
|
56
|
-
return abs(float(left) - float(right)) < 0.011
|
|
57
|
-
return left == right
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def candidate_id(axis, labels):
|
|
61
|
-
payload = json.dumps([axis, labels], ensure_ascii=False, separators=(",", ":"))
|
|
62
|
-
return "c-" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def build(args):
|
|
66
|
-
if not getattr(args, "prototype_labels", None):
|
|
67
|
-
raise ValueError("whole-product prototype labels are required for cross-module comparison")
|
|
68
|
-
paths = {"visual": args.visual_labels, "price": args.price_detail,
|
|
69
|
-
"title": args.title_roots, "geography": args.geography_detail,
|
|
70
|
-
"advertising": args.advertiser_detail}
|
|
71
|
-
data = {name: read_json(path) for name, path in paths.items()}
|
|
72
|
-
price = data["price"]
|
|
73
|
-
roots = data["title"]
|
|
74
|
-
geo = data["geography"]
|
|
75
|
-
ads = data["advertising"]
|
|
76
|
-
visual = data["visual"]
|
|
77
|
-
|
|
78
|
-
source_hashes = {"price": price["source"]["sha256"],
|
|
79
|
-
"title": roots["source"]["sha256"],
|
|
80
|
-
"geography": geo["source"]["sha256"],
|
|
81
|
-
"advertising": ads["source"]["sha256"]}
|
|
82
|
-
if len(set(source_hashes.values())) != 1:
|
|
83
|
-
raise ValueError(f"cleaned-workbook source hash mismatch: {source_hashes}")
|
|
84
|
-
|
|
85
|
-
base = unique_rows(price["products"], "price products")
|
|
86
|
-
title = unique_rows(roots["products"], "title products")
|
|
87
|
-
location = unique_rows(geo["natural_products"], "geography products")
|
|
88
|
-
observed = unique_rows(visual["records"], "visual records")
|
|
89
|
-
prototype_rows = unique_rows(read_json(args.prototype_labels), "prototype labels")
|
|
90
|
-
if set(base) != set(title) or set(base) != set(location):
|
|
91
|
-
raise ValueError("price, title and geography natural product IDs differ")
|
|
92
|
-
if not set(observed).issubset(base):
|
|
93
|
-
raise ValueError("visual IDs are not a subset of cleaned natural products")
|
|
94
|
-
if set(prototype_rows) != set(observed):
|
|
95
|
-
raise ValueError("prototype labels and visual records must cover the same image IDs")
|
|
96
|
-
for pid, row in prototype_rows.items():
|
|
97
|
-
if row["image_sha256"] != observed[pid]["image_sha256"]:
|
|
98
|
-
raise ValueError(f"prototype image hash disagrees for {pid}")
|
|
99
|
-
if bool(row.get("style_ids")) == bool(row.get("boundary_reason")):
|
|
100
|
-
raise ValueError(f"prototype label needs style membership or boundary reason: {pid}")
|
|
101
|
-
|
|
102
|
-
for pid, row in base.items():
|
|
103
|
-
for name, item, fields in (
|
|
104
|
-
("title", title[pid], ("title", "shop_name", "price_yuan", "payment_people")),
|
|
105
|
-
("geography", location[pid], ("title", "shop_name", "price_yuan", "payment_people")),
|
|
106
|
-
):
|
|
107
|
-
for field in fields:
|
|
108
|
-
if not same_fact(row[field], item[field]):
|
|
109
|
-
raise ValueError(f"{name} disagrees on {field} for product {pid}")
|
|
110
|
-
|
|
111
|
-
roots_by_id = defaultdict(set)
|
|
112
|
-
for relation in roots["product_roots"]:
|
|
113
|
-
pid = str(relation["product_id"])
|
|
114
|
-
if pid not in base:
|
|
115
|
-
raise ValueError(f"title root references unknown product {pid}")
|
|
116
|
-
roots_by_id[pid].add((relation["facet"], relation["root"]))
|
|
117
|
-
|
|
118
|
-
ads_by_id = defaultdict(list)
|
|
119
|
-
ads_by_shop = defaultdict(list)
|
|
120
|
-
for row in ads["ad_appearance_records"]:
|
|
121
|
-
pid = str(row["product_id"])
|
|
122
|
-
ads_by_id[pid].append(row)
|
|
123
|
-
ads_by_shop[row["shop_name"]].append(row)
|
|
124
|
-
if len(ads["ad_appearance_records"]) != ads["counts"]["ad_appearances"]:
|
|
125
|
-
raise ValueError("advertising appearance count does not reconcile")
|
|
126
|
-
if len(ads_by_id) != ads["counts"]["ad_products"]:
|
|
127
|
-
raise ValueError("advertising unique-product count does not reconcile")
|
|
128
|
-
|
|
129
|
-
products = []
|
|
130
|
-
for pid, row in base.items():
|
|
131
|
-
v = observed.get(pid)
|
|
132
|
-
status = v["status"] if v else "not_observed"
|
|
133
|
-
labels = {axis: v[axis].get("label") if v else None
|
|
134
|
-
for axis in ("form", "style", "audience")}
|
|
135
|
-
form_variant = v["form"].get("variant") if v else None
|
|
136
|
-
form_source_label = v["form"].get("source_label") if v else None
|
|
137
|
-
form_features = v["form"].get("features", []) if v else []
|
|
138
|
-
if status == "tagged" and not labels["form"]:
|
|
139
|
-
raise ValueError(f"tagged product {pid} lacks a form")
|
|
140
|
-
price_yuan = number(row["price_yuan"], "price_yuan")
|
|
141
|
-
payment = number(row["payment_people"], "payment_people")
|
|
142
|
-
ad_rows = ads_by_id.get(pid, [])
|
|
143
|
-
shop = row["shop_name"]
|
|
144
|
-
products.append({
|
|
145
|
-
"product_id": pid,
|
|
146
|
-
"title": row["title"],
|
|
147
|
-
"image_url": title[pid].get("image_url"),
|
|
148
|
-
"image_path": v.get("image_path") if v else None,
|
|
149
|
-
"visual_status": status,
|
|
150
|
-
"form": labels["form"], "form_variant": form_variant,
|
|
151
|
-
"product_style_ids": prototype_rows.get(pid, {}).get("style_ids", []),
|
|
152
|
-
"product_style_boundary": prototype_rows.get(pid, {}).get("boundary_reason"),
|
|
153
|
-
"form_source_label": form_source_label, "form_features": form_features,
|
|
154
|
-
"style": labels["style"],
|
|
155
|
-
"task_audience": labels["audience"],
|
|
156
|
-
"visible_evidence": v.get("visible_evidence") if v else None,
|
|
157
|
-
"style_product_delta": v["style"].get("physical_delta") if v else None,
|
|
158
|
-
"task_product_delta": v["audience"].get("physical_delta") if v else None,
|
|
159
|
-
"shop_name": shop,
|
|
160
|
-
"shop_type_label": title[pid].get("shop_type"),
|
|
161
|
-
"price_yuan": price_yuan, "price_band": row["band"],
|
|
162
|
-
"payment_people_displayed": payment,
|
|
163
|
-
"natural_position": title[pid].get("natural_position"),
|
|
164
|
-
"listing_address": location[pid].get("address_raw"),
|
|
165
|
-
"title_roots": [{"facet": facet, "root": root}
|
|
166
|
-
for facet, root in sorted(roots_by_id[pid])],
|
|
167
|
-
"ad_appearances_same_shop": sum(a["shop_name"] == shop for a in ad_rows),
|
|
168
|
-
"ad_appearances_other_shop": sum(a["shop_name"] != shop for a in ad_rows),
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
by_id = {p["product_id"]: p for p in products}
|
|
172
|
-
by_shop = defaultdict(list)
|
|
173
|
-
for p in products:
|
|
174
|
-
by_shop[p["shop_name"]].append(p)
|
|
175
|
-
|
|
176
|
-
member_sets = defaultdict(set)
|
|
177
|
-
definitions = {}
|
|
178
|
-
for p in products:
|
|
179
|
-
if p["visual_status"] != "tagged" and not p["product_style_ids"]:
|
|
180
|
-
continue
|
|
181
|
-
axes = [("product_style", {"product_style_id": style_id}) for style_id in p["product_style_ids"]]
|
|
182
|
-
if p["visual_status"] != "tagged":
|
|
183
|
-
for axis, labels in axes:
|
|
184
|
-
cid = candidate_id(axis, labels)
|
|
185
|
-
member_sets[cid].add(p["product_id"])
|
|
186
|
-
definitions[cid] = (axis, labels)
|
|
187
|
-
continue
|
|
188
|
-
axes.append(("form", {"form": p["form"]}))
|
|
189
|
-
if p["form_variant"]:
|
|
190
|
-
axes.append(("form_variant", {"form": p["form"], "variant": p["form_variant"]}))
|
|
191
|
-
if p["style"]:
|
|
192
|
-
axes.append(("form_style", {"form": p["form"], "style": p["style"]}))
|
|
193
|
-
if p["form_variant"]:
|
|
194
|
-
axes.append(("form_variant_style", {"form": p["form"], "variant": p["form_variant"], "style": p["style"]}))
|
|
195
|
-
if p["task_audience"]:
|
|
196
|
-
axes.append(("form_task", {"form": p["form"], "task_audience": p["task_audience"]}))
|
|
197
|
-
if p["form_variant"]:
|
|
198
|
-
axes.append(("form_variant_task", {"form": p["form"], "variant": p["form_variant"], "task_audience": p["task_audience"]}))
|
|
199
|
-
if p["style"] and p["task_audience"]:
|
|
200
|
-
axes.append(("form_style_task", {"form": p["form"], "style": p["style"],
|
|
201
|
-
"task_audience": p["task_audience"]}))
|
|
202
|
-
if p["form_variant"]:
|
|
203
|
-
axes.append(("form_variant_style_task", {"form": p["form"], "variant": p["form_variant"],
|
|
204
|
-
"style": p["style"], "task_audience": p["task_audience"]}))
|
|
205
|
-
for axis, labels in axes:
|
|
206
|
-
cid = candidate_id(axis, labels)
|
|
207
|
-
member_sets[cid].add(p["product_id"])
|
|
208
|
-
definitions[cid] = (axis, labels)
|
|
209
|
-
|
|
210
|
-
candidates = []
|
|
211
|
-
for cid, ids in member_sets.items():
|
|
212
|
-
axis, labels = definitions[cid]
|
|
213
|
-
members = [by_id[pid] for pid in ids]
|
|
214
|
-
members.sort(key=lambda p: (-p["payment_people_displayed"], p["product_id"]))
|
|
215
|
-
prices = [p["price_yuan"] for p in members]
|
|
216
|
-
payments = [p["payment_people_displayed"] for p in members]
|
|
217
|
-
shop_groups = defaultdict(list)
|
|
218
|
-
for p in members:
|
|
219
|
-
shop_groups[p["shop_name"]].append(p)
|
|
220
|
-
shops = []
|
|
221
|
-
for name, shop_members in shop_groups.items():
|
|
222
|
-
full_line = by_shop[name]
|
|
223
|
-
shops.append({
|
|
224
|
-
"shop_name": name,
|
|
225
|
-
"candidate_product_count": len(shop_members),
|
|
226
|
-
"candidate_payment_people_sum_displayed": sum(p["payment_people_displayed"] for p in shop_members),
|
|
227
|
-
"natural_product_count_in_export": len(full_line),
|
|
228
|
-
"natural_form_counts": dict(Counter(p["form"] or "unconfirmed" for p in full_line)),
|
|
229
|
-
"natural_price_band_counts": dict(Counter(str(p["price_band"]) for p in full_line)),
|
|
230
|
-
"same_shop_ad_appearance_count": len(ads_by_shop.get(name, [])),
|
|
231
|
-
"same_shop_ad_product_count": len({a["product_id"] for a in ads_by_shop.get(name, [])}),
|
|
232
|
-
"candidate_product_ids": [p["product_id"] for p in shop_members],
|
|
233
|
-
})
|
|
234
|
-
shops.sort(key=lambda s: (-s["candidate_payment_people_sum_displayed"], s["shop_name"]))
|
|
235
|
-
top = shops[0]
|
|
236
|
-
payment_sum = sum(payments)
|
|
237
|
-
root_counts = Counter((r["facet"], r["root"]) for p in members for r in p["title_roots"])
|
|
238
|
-
parent_id = candidate_id("form", {"form": labels["form"]}) if axis not in ("form", "product_style") else None
|
|
239
|
-
candidates.append({
|
|
240
|
-
"candidate_id": cid, "axis": axis, "labels": labels,
|
|
241
|
-
"parent_form_candidate_id": parent_id,
|
|
242
|
-
"product_ids": sorted(ids),
|
|
243
|
-
"product_count": len(members), "shop_count": len(shops),
|
|
244
|
-
"displayed_payment_people": {
|
|
245
|
-
"sum_across_products": payment_sum,
|
|
246
|
-
"median_per_product": round(statistics.median(payments), 2),
|
|
247
|
-
"p75_per_product": percentile(payments, .75),
|
|
248
|
-
"p90_per_product": percentile(payments, .90),
|
|
249
|
-
"highest_product_share_of_sum": round(max(payments) / payment_sum, 4) if payment_sum else None,
|
|
250
|
-
"top_shop_share_of_sum": round(top["candidate_payment_people_sum_displayed"] / payment_sum, 4) if payment_sum else None,
|
|
251
|
-
"sum_after_excluding_top_shop": payment_sum - top["candidate_payment_people_sum_displayed"],
|
|
252
|
-
},
|
|
253
|
-
"displayed_price_yuan": {"min": min(prices), "p25": percentile(prices, .25),
|
|
254
|
-
"median": round(statistics.median(prices), 2),
|
|
255
|
-
"p75": percentile(prices, .75), "max": max(prices)},
|
|
256
|
-
"price_band_product_counts": dict(sorted(Counter(str(p["price_band"]) for p in members).items())),
|
|
257
|
-
"natural_advertised_product_count_same_shop": sum(p["ad_appearances_same_shop"] > 0 for p in members),
|
|
258
|
-
"ad_appearance_count_same_shop": sum(p["ad_appearances_same_shop"] for p in members),
|
|
259
|
-
"ad_appearance_count_other_shop": sum(p["ad_appearances_other_shop"] for p in members),
|
|
260
|
-
"title_roots_top": [{"facet": facet, "root": root, "product_count": count}
|
|
261
|
-
for (facet, root), count in root_counts.most_common(12)],
|
|
262
|
-
"listing_address_top": [{"address": address, "product_count": count}
|
|
263
|
-
for address, count in Counter(p["listing_address"] for p in members).most_common(8)],
|
|
264
|
-
"leading_products": [{"product_id": p["product_id"], "shop_name": p["shop_name"],
|
|
265
|
-
"price_yuan": p["price_yuan"], "payment_people_displayed": p["payment_people_displayed"],
|
|
266
|
-
"image_url": p["image_url"]} for p in members[:10]],
|
|
267
|
-
"leading_shops": shops[:10],
|
|
268
|
-
"physical_deltas_observed": list(dict.fromkeys(
|
|
269
|
-
delta for p in members for delta in (p["style_product_delta"], p["task_product_delta"]) if delta))[:12],
|
|
270
|
-
})
|
|
271
|
-
candidate_axis_order = ("product_style", "form", "form_variant", "form_style", "form_task", "form_style_task",
|
|
272
|
-
"form_variant_style", "form_variant_task", "form_variant_style_task")
|
|
273
|
-
candidates.sort(key=lambda c: candidate_axis_order.index(c["axis"]) * 100000 - c["product_count"])
|
|
274
|
-
|
|
275
|
-
output = {
|
|
276
|
-
"schema_version": 1,
|
|
277
|
-
"purpose": "descriptive evidence for user-reviewed opportunity cards; not an entry score",
|
|
278
|
-
"sources": {**{name: {"sha256": digest(path), "file_name": Path(path).name}
|
|
279
|
-
for name, path in paths.items()},
|
|
280
|
-
"prototype_labels": {"sha256": digest(args.prototype_labels),
|
|
281
|
-
"file_name": Path(args.prototype_labels).name}},
|
|
282
|
-
"cleaned_workbook_sha256": next(iter(source_hashes.values())),
|
|
283
|
-
"population": {
|
|
284
|
-
"natural_products": len(products),
|
|
285
|
-
"visual_observed": len(observed),
|
|
286
|
-
"visual_tagged": sum(p["visual_status"] == "tagged" for p in products),
|
|
287
|
-
"visual_uncertain": sum(p["visual_status"] == "uncertain" for p in products),
|
|
288
|
-
"visual_not_observed": sum(p["visual_status"] == "not_observed" for p in products),
|
|
289
|
-
"visual_product_style_classified": sum(bool(p["product_style_ids"]) for p in products),
|
|
290
|
-
"advertising_appearances": len(ads["ad_appearance_records"]),
|
|
291
|
-
"advertising_unique_products": len(ads_by_id),
|
|
292
|
-
},
|
|
293
|
-
"grain_notes": [
|
|
294
|
-
"Each natural product ID appears once; candidate families overlap and must not be summed together.",
|
|
295
|
-
"Displayed payment people have an unknown time window and are not units, GMV, conversion or unique buyers across products.",
|
|
296
|
-
"Ad appearances are sampled positions, not spend or impressions; cross-shop ID matches are separate.",
|
|
297
|
-
"Listing address is not a verified factory or dispatch warehouse.",
|
|
298
|
-
"Task-audience labels indicate image-grounded use hypotheses, not actual buyer demographics.",
|
|
299
|
-
],
|
|
300
|
-
"products": products,
|
|
301
|
-
"candidates": candidates,
|
|
302
|
-
}
|
|
303
|
-
path = Path(args.output)
|
|
304
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
305
|
-
with path.open("x", encoding="utf-8") as stream:
|
|
306
|
-
json.dump(output, stream, ensure_ascii=False, indent=2)
|
|
307
|
-
stream.write("\n")
|
|
308
|
-
print(json.dumps({"output": str(path.resolve()), "products": len(products),
|
|
309
|
-
"candidates": len(candidates), "tagged": output["population"]["visual_tagged"]}, ensure_ascii=False))
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if __name__ == "__main__":
|
|
313
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
314
|
-
parser.add_argument("--visual-labels", required=True)
|
|
315
|
-
parser.add_argument("--prototype-labels", required=True,
|
|
316
|
-
help="compiled product_style_labels.json for whole-product prototypes")
|
|
317
|
-
parser.add_argument("--price-detail", required=True)
|
|
318
|
-
parser.add_argument("--title-roots", required=True)
|
|
319
|
-
parser.add_argument("--geography-detail", required=True)
|
|
320
|
-
parser.add_argument("--advertiser-detail", required=True)
|
|
321
|
-
parser.add_argument("--output", required=True)
|
|
322
|
-
build(parser.parse_args())
|