@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,316 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Analyze listing-location evidence from a cleaned Taobao search workbook.
|
|
3
|
-
|
|
4
|
-
The source's 地址 is an observed listing field, not verified warehouse or factory location.
|
|
5
|
-
Creates a new directory with geography_detail.json and geography_summary.json.
|
|
6
|
-
Requires pandas and an XLSX reader. A title-root JSON is optional.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import argparse
|
|
10
|
-
import hashlib
|
|
11
|
-
import json
|
|
12
|
-
import unicodedata
|
|
13
|
-
from collections import Counter, defaultdict
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
import pandas as pd
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
NATURAL_REQUIRED = {"商品ID", "商品名称", "店铺名称", "地址", "现价", "付款人数", "占位类型"}
|
|
20
|
-
AD_REQUIRED = {"商品ID", "店铺名称", "地址", "占位类型"}
|
|
21
|
-
MUNICIPALITIES = {"北京", "天津", "上海", "重庆"}
|
|
22
|
-
EXCLUDED_ROOT_FACETS = {"unclassified", "marketing", "size_claim"}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def text(value):
|
|
26
|
-
if value is None or pd.isna(value):
|
|
27
|
-
return ""
|
|
28
|
-
return str(value).strip()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def number(value):
|
|
32
|
-
value = pd.to_numeric(value, errors="coerce")
|
|
33
|
-
return None if pd.isna(value) else float(value)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def sha256(path):
|
|
37
|
-
digest = hashlib.sha256()
|
|
38
|
-
with path.open("rb") as handle:
|
|
39
|
-
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
40
|
-
digest.update(chunk)
|
|
41
|
-
return digest.hexdigest()
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def location(value):
|
|
45
|
-
raw = " ".join(unicodedata.normalize("NFKC", text(value)).split())
|
|
46
|
-
if not raw:
|
|
47
|
-
return {"address": None, "province": None, "city": None, "level": "unknown"}
|
|
48
|
-
parts = raw.split(" ")
|
|
49
|
-
if len(parts) == 1:
|
|
50
|
-
return {"address": raw, "province": raw, "city": raw if raw in MUNICIPALITIES else None,
|
|
51
|
-
"level": "municipality" if raw in MUNICIPALITIES else "province_only"}
|
|
52
|
-
if len(parts) == 2:
|
|
53
|
-
return {"address": raw, "province": parts[0], "city": parts[1], "level": "province_city"}
|
|
54
|
-
return {"address": raw, "province": None, "city": None, "level": "unparsed"}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def median(values):
|
|
58
|
-
series = pd.Series([value for value in values if value is not None], dtype="float64")
|
|
59
|
-
return None if series.empty else round(float(series.median()), 4)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def quantile(values, q):
|
|
63
|
-
series = pd.Series([value for value in values if value is not None], dtype="float64")
|
|
64
|
-
return None if series.empty else round(float(series.quantile(q)), 4)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def validate_frame(frame, required, placement):
|
|
68
|
-
missing = required - set(frame.columns)
|
|
69
|
-
if missing:
|
|
70
|
-
raise ValueError(f"{placement} sheet missing columns: {sorted(missing)}")
|
|
71
|
-
if not frame["占位类型"].map(text).eq(placement).all():
|
|
72
|
-
raise ValueError(f"{placement} sheet contains another placement type")
|
|
73
|
-
if frame["商品ID"].map(text).eq("").any():
|
|
74
|
-
raise ValueError(f"{placement} sheet has blank product IDs")
|
|
75
|
-
if frame["店铺名称"].map(text).eq("").any():
|
|
76
|
-
raise ValueError(f"{placement} sheet has blank shop names")
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def band(price, cutoffs):
|
|
80
|
-
if price is None or any(value is None for value in cutoffs):
|
|
81
|
-
return "unknown"
|
|
82
|
-
for index, cutoff in enumerate(cutoffs):
|
|
83
|
-
if price <= cutoff:
|
|
84
|
-
return f"Q{index + 1}"
|
|
85
|
-
return "Q4"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def product_summary(rows, threshold, cutoffs):
|
|
89
|
-
shops = Counter(row["shop_name"] for row in rows)
|
|
90
|
-
prices = [row["price_yuan"] for row in rows]
|
|
91
|
-
payments = [row["payment_people"] for row in rows]
|
|
92
|
-
high = sum(value is not None and threshold is not None and value >= threshold for value in payments)
|
|
93
|
-
count = len(rows)
|
|
94
|
-
return {
|
|
95
|
-
"product_count": count,
|
|
96
|
-
"shop_count": len(shops),
|
|
97
|
-
"price_p25": quantile(prices, 0.25), "price_median": median(prices), "price_p75": quantile(prices, 0.75),
|
|
98
|
-
"payment_people_median": median(payments),
|
|
99
|
-
"high_payment_product_count": high,
|
|
100
|
-
"high_payment_product_share": round(high / count, 6) if count else None,
|
|
101
|
-
"top_shop_product_share": round(max(shops.values()) / count, 6) if count else None,
|
|
102
|
-
"shop_product_hhi": round(sum((n / count) ** 2 for n in shops.values()), 6) if count else None,
|
|
103
|
-
"price_quartile_band_counts": dict(sorted(Counter(band(value, cutoffs) for value in prices).items())),
|
|
104
|
-
"top_shops": [{"shop_name": name, "product_count": n} for name, n in sorted(shops.items(), key=lambda pair: (-pair[1], pair[0]))[:5]],
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def records_from_frame(frame, natural):
|
|
109
|
-
result = []
|
|
110
|
-
for _, source in frame.iterrows():
|
|
111
|
-
result.append({
|
|
112
|
-
"product_id": text(source["商品ID"]),
|
|
113
|
-
"title": text(source.get("商品名称")) if natural else None,
|
|
114
|
-
"shop_name": text(source["店铺名称"]),
|
|
115
|
-
"shop_type": text(source.get("店铺类型")) or None,
|
|
116
|
-
"address_raw": text(source["地址"]) or None,
|
|
117
|
-
**location(source["地址"]),
|
|
118
|
-
"price_yuan": number(source.get("现价")) if natural else None,
|
|
119
|
-
"payment_people": number(source.get("付款人数")) if natural else None,
|
|
120
|
-
"source_position": number(source.get("序号")),
|
|
121
|
-
})
|
|
122
|
-
return result
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def load_title_roots(path, source_hash, products):
|
|
126
|
-
document = json.loads(path.read_text(encoding="utf-8"))
|
|
127
|
-
if document.get("source", {}).get("sha256") != source_hash or document.get("source", {}).get("sheet") != "自然位":
|
|
128
|
-
raise ValueError("title-root source hash or sheet does not match cleaned workbook")
|
|
129
|
-
by_id = {row["product_id"]: row for row in products}
|
|
130
|
-
source_products = document.get("products")
|
|
131
|
-
if not isinstance(source_products, list) or len(source_products) != len(products):
|
|
132
|
-
raise ValueError("title-root product population differs from natural sheet")
|
|
133
|
-
if {row.get("product_id") for row in source_products} != set(by_id):
|
|
134
|
-
raise ValueError("title-root product IDs differ from natural sheet")
|
|
135
|
-
for row in source_products:
|
|
136
|
-
current = by_id.get(row.get("product_id"))
|
|
137
|
-
if current is None or row.get("title") != current["title"]:
|
|
138
|
-
raise ValueError("title-root product identity or title differs from natural sheet")
|
|
139
|
-
relations = document.get("product_roots")
|
|
140
|
-
if not isinstance(relations, list):
|
|
141
|
-
raise ValueError("title-root relations missing")
|
|
142
|
-
seen = set()
|
|
143
|
-
selected = []
|
|
144
|
-
for row in relations:
|
|
145
|
-
key = (row.get("product_id"), row.get("facet"), row.get("root"))
|
|
146
|
-
if key[0] not in by_id or not all(key) or key in seen:
|
|
147
|
-
raise ValueError("title-root relation identity invalid or duplicated")
|
|
148
|
-
seen.add(key)
|
|
149
|
-
if key[1] not in EXCLUDED_ROOT_FACETS:
|
|
150
|
-
selected.append({"product_id": key[0], "facet": key[1], "root": key[2]})
|
|
151
|
-
return selected
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def analyze(natural_frame, ad_frame, root_relations=None):
|
|
155
|
-
validate_frame(natural_frame, NATURAL_REQUIRED, "自然位")
|
|
156
|
-
validate_frame(ad_frame, AD_REQUIRED, "广告位")
|
|
157
|
-
products = records_from_frame(natural_frame, True)
|
|
158
|
-
ads = records_from_frame(ad_frame, False)
|
|
159
|
-
ids = [row["product_id"] for row in products]
|
|
160
|
-
if len(ids) != len(set(ids)):
|
|
161
|
-
raise ValueError("natural-position product IDs must be unique")
|
|
162
|
-
if not products:
|
|
163
|
-
raise ValueError("natural-position sheet is empty")
|
|
164
|
-
by_id = {row["product_id"]: row for row in products}
|
|
165
|
-
threshold = quantile([row["payment_people"] for row in products], 0.9)
|
|
166
|
-
cutoffs = [quantile([row["price_yuan"] for row in products], q) for q in (0.25, 0.5, 0.75)]
|
|
167
|
-
|
|
168
|
-
city_groups = defaultdict(list)
|
|
169
|
-
province_groups = defaultdict(list)
|
|
170
|
-
for row in products:
|
|
171
|
-
if row["address"] is not None:
|
|
172
|
-
city_groups[row["address"]].append(row)
|
|
173
|
-
if row["province"] is not None:
|
|
174
|
-
province_groups[row["province"]].append(row)
|
|
175
|
-
shop_locations = defaultdict(set)
|
|
176
|
-
for row in products:
|
|
177
|
-
shop_locations[row["shop_name"]].add(row["address"])
|
|
178
|
-
shop_region_pairs = sorted({(row["shop_name"], row["address"]) for row in products}, key=lambda pair: (pair[0], pair[1] or ""))
|
|
179
|
-
|
|
180
|
-
def region_rows(groups, level):
|
|
181
|
-
items = []
|
|
182
|
-
for name, rows in groups.items():
|
|
183
|
-
item = {"level": level, "region": name, **product_summary(rows, threshold, cutoffs)}
|
|
184
|
-
item["product_share_of_all_natural"] = round(len(rows) / len(products), 6)
|
|
185
|
-
items.append(item)
|
|
186
|
-
return sorted(items, key=lambda item: (-item["product_count"], item["region"]))
|
|
187
|
-
|
|
188
|
-
ad_groups = defaultdict(list)
|
|
189
|
-
for row in ads:
|
|
190
|
-
if row["address"] is not None:
|
|
191
|
-
ad_groups[row["address"]].append(row)
|
|
192
|
-
ad_regions = []
|
|
193
|
-
for name, rows in ad_groups.items():
|
|
194
|
-
matched = {row["product_id"] for row in rows if row["product_id"] in by_id}
|
|
195
|
-
ad_regions.append({
|
|
196
|
-
"region": name,
|
|
197
|
-
"appearance_count": len(rows),
|
|
198
|
-
"unique_ad_product_count": len({row["product_id"] for row in rows}),
|
|
199
|
-
"unique_ad_shop_count": len({row["shop_name"] for row in rows}),
|
|
200
|
-
"product_ids_also_natural_count": len(matched),
|
|
201
|
-
"product_ids_with_different_natural_address_count": sum(by_id[pid]["address"] != name for pid in matched),
|
|
202
|
-
})
|
|
203
|
-
ad_regions.sort(key=lambda item: (-item["appearance_count"], item["region"]))
|
|
204
|
-
|
|
205
|
-
root_regions = []
|
|
206
|
-
root_totals = []
|
|
207
|
-
if root_relations is not None:
|
|
208
|
-
root_groups = defaultdict(list)
|
|
209
|
-
root_city_groups = defaultdict(list)
|
|
210
|
-
root_province_groups = defaultdict(list)
|
|
211
|
-
for relation in root_relations:
|
|
212
|
-
row = by_id[relation["product_id"]]
|
|
213
|
-
key = (relation["facet"], relation["root"])
|
|
214
|
-
root_groups[key].append(row)
|
|
215
|
-
if row["address"] is not None:
|
|
216
|
-
root_city_groups[("city", row["address"], *key)].append(row)
|
|
217
|
-
if row["province"] is not None:
|
|
218
|
-
root_province_groups[("province", row["province"], *key)].append(row)
|
|
219
|
-
for (facet, root), rows in root_groups.items():
|
|
220
|
-
root_totals.append({"facet": facet, "root": root, **product_summary(rows, threshold, cutoffs)})
|
|
221
|
-
for groups, populations in ((root_city_groups, city_groups), (root_province_groups, province_groups)):
|
|
222
|
-
for (level, region, facet, root), rows in groups.items():
|
|
223
|
-
region_n = len(populations[region])
|
|
224
|
-
root_n = len(root_groups[(facet, root)])
|
|
225
|
-
item = {
|
|
226
|
-
"level": level, "region": region, "facet": facet, "root": root,
|
|
227
|
-
"region_product_count": region_n, "root_product_count_all_natural": root_n,
|
|
228
|
-
"root_coverage_within_region": round(len(rows) / region_n, 6),
|
|
229
|
-
"region_share_within_root": round(len(rows) / root_n, 6),
|
|
230
|
-
"location_quotient": round((len(rows) / region_n) / (root_n / len(products)), 4),
|
|
231
|
-
**product_summary(rows, threshold, cutoffs),
|
|
232
|
-
}
|
|
233
|
-
item["interpretation_sample_ok"] = len(rows) >= 10 and item["shop_count"] >= 3
|
|
234
|
-
root_regions.append(item)
|
|
235
|
-
root_totals.sort(key=lambda item: (-item["product_count"], item["facet"], item["root"]))
|
|
236
|
-
root_regions.sort(key=lambda item: (item["level"], -item["product_count"], item["region"], item["facet"], item["root"]))
|
|
237
|
-
|
|
238
|
-
quality = {
|
|
239
|
-
"natural_product_count": len(products),
|
|
240
|
-
"natural_unique_product_id_count": len(by_id),
|
|
241
|
-
"natural_shop_count": len(shop_locations),
|
|
242
|
-
"natural_shop_address_pair_count": len(shop_region_pairs),
|
|
243
|
-
"multi_address_shop_count": sum(len(addresses) > 1 for addresses in shop_locations.values()),
|
|
244
|
-
"natural_unknown_address_count": sum(row["address"] is None for row in products),
|
|
245
|
-
"natural_unparsed_address_count": sum(row["level"] == "unparsed" for row in products),
|
|
246
|
-
"natural_missing_price_count": sum(row["price_yuan"] is None for row in products),
|
|
247
|
-
"natural_missing_payment_count": sum(row["payment_people"] is None for row in products),
|
|
248
|
-
"ad_appearance_count": len(ads),
|
|
249
|
-
"ad_unique_product_id_count": len({row["product_id"] for row in ads}),
|
|
250
|
-
"ad_unknown_address_count": sum(row["address"] is None for row in ads),
|
|
251
|
-
"ad_product_ids_also_natural_count": len({row["product_id"] for row in ads if row["product_id"] in by_id}),
|
|
252
|
-
"title_root_relation_count_used": len(root_relations) if root_relations is not None else None,
|
|
253
|
-
"payment_p90_threshold": threshold,
|
|
254
|
-
"natural_products_at_or_above_payment_p90": sum(row["payment_people"] is not None and threshold is not None and row["payment_people"] >= threshold for row in products),
|
|
255
|
-
"price_quartile_cutoffs_yuan": cutoffs,
|
|
256
|
-
}
|
|
257
|
-
return {
|
|
258
|
-
"detail": {"natural_products": products, "ad_appearances": ads, "shop_address_pairs": [{"shop_name": shop, "address": address} for shop, address in shop_region_pairs], "product_root_relations_used": root_relations},
|
|
259
|
-
"summary": {
|
|
260
|
-
"definitions": {
|
|
261
|
-
"address": "Normalized source 地址 as shown in the listing export; actual dispatch warehouse, merchant registration and factory location are unverified.",
|
|
262
|
-
"population": "Natural-position products are unique by product ID; advertising records are appearances and remain separate.",
|
|
263
|
-
"shop_count": "Distinct exact shop names within each region. A multi-address shop may appear in several regions; regional shop counts are not additive.",
|
|
264
|
-
"payment": "Source-sheet 付款人数 with unspecified period; not sales units, GMV or causality.",
|
|
265
|
-
"price_bands": "Q1 <= p25; Q2 <= p50; Q3 <= p75; Q4 > p75, using natural-position price quartiles; ties may make bands uneven.",
|
|
266
|
-
"high_payment": "Product at or above the all-natural 90th percentile of displayed 付款人数; ties may make the group larger than 10%.",
|
|
267
|
-
"shop_hhi": "Sum of squared within-region listing shares across exact shop names; a descriptive concentration measure, not market share.",
|
|
268
|
-
"location_quotient": "Root coverage within region divided by root coverage across all natural-position products; descriptive title-language specialization, not buyer demand or production origin.",
|
|
269
|
-
"interpretation_sample_ok": "At least 10 products and 3 shops in a root-region cell; a screening guard, not a significance or opportunity test.",
|
|
270
|
-
},
|
|
271
|
-
"quality": quality,
|
|
272
|
-
"city_regions": region_rows(city_groups, "city_or_address"),
|
|
273
|
-
"province_regions": region_rows(province_groups, "province"),
|
|
274
|
-
"ad_city_regions": ad_regions,
|
|
275
|
-
"root_totals": root_totals,
|
|
276
|
-
"root_regions": root_regions,
|
|
277
|
-
},
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
def main():
|
|
282
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
283
|
-
parser.add_argument("--input", required=True, help="Cleaned two-sheet workbook")
|
|
284
|
-
parser.add_argument("--output-dir", required=True, help="New directory for geography JSON artifacts")
|
|
285
|
-
parser.add_argument("--title-roots", help="Optional product_roots.json from the same cleaned workbook")
|
|
286
|
-
args = parser.parse_args()
|
|
287
|
-
source = Path(args.input).expanduser().resolve()
|
|
288
|
-
output = Path(args.output_dir).expanduser().resolve()
|
|
289
|
-
title_path = Path(args.title_roots).expanduser().resolve() if args.title_roots else None
|
|
290
|
-
if not source.is_file() or source.suffix.lower() != ".xlsx":
|
|
291
|
-
parser.error("input must be an existing XLSX file")
|
|
292
|
-
if output.exists():
|
|
293
|
-
parser.error("output directory already exists")
|
|
294
|
-
if title_path and not title_path.is_file():
|
|
295
|
-
parser.error("title-roots must be an existing JSON file")
|
|
296
|
-
source_hash = sha256(source)
|
|
297
|
-
natural = pd.read_excel(source, sheet_name="自然位", dtype={"商品ID": str})
|
|
298
|
-
ad = pd.read_excel(source, sheet_name="广告位", dtype={"商品ID": str})
|
|
299
|
-
validate_frame(natural, NATURAL_REQUIRED, "自然位")
|
|
300
|
-
validate_frame(ad, AD_REQUIRED, "广告位")
|
|
301
|
-
products = records_from_frame(natural, True)
|
|
302
|
-
roots = load_title_roots(title_path, source_hash, products) if title_path else None
|
|
303
|
-
result = analyze(natural, ad, roots)
|
|
304
|
-
source_meta = {"file_name": source.name, "sha256": source_hash, "title_roots_file_name": title_path.name if title_path else None,
|
|
305
|
-
"title_roots_sha256": sha256(title_path) if title_path else None}
|
|
306
|
-
output.mkdir(parents=True, exist_ok=False)
|
|
307
|
-
for name, body in (("geography_detail.json", result["detail"]), ("geography_summary.json", result["summary"])):
|
|
308
|
-
with (output / name).open("x", encoding="utf-8") as handle:
|
|
309
|
-
json.dump({"schema_version": "1.0", "source": source_meta, **body}, handle, ensure_ascii=False, indent=2)
|
|
310
|
-
handle.write("\n")
|
|
311
|
-
print(json.dumps({"output_dir": str(output), "files": ["geography_detail.json", "geography_summary.json"],
|
|
312
|
-
"quality": result["summary"]["quality"]}, ensure_ascii=False))
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if __name__ == "__main__":
|
|
316
|
-
main()
|
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Find data-dependent Jenks price bands in a cleaned search workbook.
|
|
3
|
-
|
|
4
|
-
Natural-position products define the fitted population. Advertising rows are
|
|
5
|
-
reported against those same boundaries but never influence the fit.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import argparse
|
|
9
|
-
import hashlib
|
|
10
|
-
import json
|
|
11
|
-
import math
|
|
12
|
-
from collections import Counter
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
import numpy as np
|
|
16
|
-
import pandas as pd
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
NATURAL_REQUIRED = {"商品ID", "商品名称", "店铺名称", "现价", "付款人数", "占位类型"}
|
|
20
|
-
AD_REQUIRED = {"商品ID", "现价", "占位类型"}
|
|
21
|
-
METHOD = "log-price-weighted-jenks-marginal-gain-v1"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def sha256(path):
|
|
25
|
-
digest = hashlib.sha256()
|
|
26
|
-
with path.open("rb") as handle:
|
|
27
|
-
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
28
|
-
digest.update(chunk)
|
|
29
|
-
return digest.hexdigest()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def required(frame, fields, placement):
|
|
33
|
-
missing = fields - set(frame.columns)
|
|
34
|
-
if missing:
|
|
35
|
-
raise ValueError(f"{placement} sheet missing columns: {sorted(missing)}")
|
|
36
|
-
if not frame["占位类型"].astype(str).eq(placement).all():
|
|
37
|
-
raise ValueError(f"{placement} sheet contains another placement type")
|
|
38
|
-
ids = frame["商品ID"].fillna("").astype(str).str.strip()
|
|
39
|
-
if ids.eq("").any():
|
|
40
|
-
raise ValueError(f"{placement} sheet has blank product IDs")
|
|
41
|
-
return ids
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def positive_prices(frame, placement):
|
|
45
|
-
prices = pd.to_numeric(frame["现价"], errors="coerce").to_numpy(dtype=float)
|
|
46
|
-
if not np.isfinite(prices).all() or (prices <= 0).any():
|
|
47
|
-
raise ValueError(f"{placement} sheet has missing, non-finite or non-positive current prices")
|
|
48
|
-
return prices
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def jenks_models(prices, *, max_bands=10, minimum_share=0.02):
|
|
52
|
-
"""Exact 1-D Jenks on log(price), with repeated prices kept in one band."""
|
|
53
|
-
prices = np.asarray(prices, dtype=float)
|
|
54
|
-
if len(prices) < 2 or not np.isfinite(prices).all() or (prices <= 0).any():
|
|
55
|
-
raise ValueError("at least two finite, positive prices are required")
|
|
56
|
-
values, counts = np.unique(prices, return_counts=True)
|
|
57
|
-
if len(values) < 2:
|
|
58
|
-
return [{"band_count": 1, "sse": 0.0, "explained_variance": 1.0,
|
|
59
|
-
"marginal_gain": None, "boundaries": [], "counts": [len(prices)]}]
|
|
60
|
-
if len(values) > 3000:
|
|
61
|
-
raise ValueError("more than 3000 distinct prices; exact Jenks needs a scalable implementation")
|
|
62
|
-
floor = max(2, math.ceil(len(prices) * minimum_share))
|
|
63
|
-
limit = min(max_bands, len(values), len(prices) // floor)
|
|
64
|
-
logs = np.log(values)
|
|
65
|
-
cumulative_n = np.r_[0, np.cumsum(counts)]
|
|
66
|
-
cumulative_x = np.r_[0, np.cumsum(counts * logs)]
|
|
67
|
-
cumulative_x2 = np.r_[0, np.cumsum(counts * logs * logs)]
|
|
68
|
-
width = len(values)
|
|
69
|
-
dp = np.full((limit + 1, width + 1), np.inf)
|
|
70
|
-
previous = np.full((limit + 1, width + 1), -1, dtype=int)
|
|
71
|
-
dp[0, 0] = 0.0
|
|
72
|
-
for bands in range(1, limit + 1):
|
|
73
|
-
for end in range(bands, width + 1):
|
|
74
|
-
start = np.arange(bands - 1, end)
|
|
75
|
-
population = cumulative_n[end] - cumulative_n[start]
|
|
76
|
-
eligible = (population >= floor) & (cumulative_n[start] >= (bands - 1) * floor)
|
|
77
|
-
if not eligible.any():
|
|
78
|
-
continue
|
|
79
|
-
start = start[eligible]
|
|
80
|
-
population = population[eligible]
|
|
81
|
-
total = cumulative_x[end] - cumulative_x[start]
|
|
82
|
-
cost = cumulative_x2[end] - cumulative_x2[start] - total * total / population
|
|
83
|
-
scores = dp[bands - 1, start] + np.maximum(cost, 0)
|
|
84
|
-
winner = int(np.argmin(scores))
|
|
85
|
-
if np.isfinite(scores[winner]):
|
|
86
|
-
dp[bands, end] = scores[winner]
|
|
87
|
-
previous[bands, end] = start[winner]
|
|
88
|
-
baseline = float(dp[1, width])
|
|
89
|
-
models = []
|
|
90
|
-
for bands in range(1, limit + 1):
|
|
91
|
-
if not np.isfinite(dp[bands, width]):
|
|
92
|
-
continue
|
|
93
|
-
end = width
|
|
94
|
-
ranges = []
|
|
95
|
-
for tier in range(bands, 0, -1):
|
|
96
|
-
start = int(previous[tier, end])
|
|
97
|
-
ranges.append((start, end))
|
|
98
|
-
end = start
|
|
99
|
-
ranges.reverse()
|
|
100
|
-
boundaries = [float(values[start]) for start, _ in ranges[1:]]
|
|
101
|
-
sizes = [int(cumulative_n[end] - cumulative_n[start]) for start, end in ranges]
|
|
102
|
-
explained = 1 - float(dp[bands, width]) / baseline if baseline > 0 else 1.0
|
|
103
|
-
prior = models[-1]["explained_variance"] if models else None
|
|
104
|
-
models.append({"band_count": bands, "sse": round(float(dp[bands, width]), 8),
|
|
105
|
-
"explained_variance": round(explained, 8),
|
|
106
|
-
"marginal_gain": round(explained - prior, 8) if prior is not None else None,
|
|
107
|
-
"boundaries": boundaries, "counts": sizes})
|
|
108
|
-
return models
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def select_model(models, complexity_penalty=0.03):
|
|
112
|
-
"""Stop before an extra band explains less than the declared complexity cost."""
|
|
113
|
-
if not 0 < complexity_penalty < 1:
|
|
114
|
-
raise ValueError("complexity penalty must be between zero and one")
|
|
115
|
-
selected = models[0]
|
|
116
|
-
for model in models[1:]:
|
|
117
|
-
if model["marginal_gain"] < complexity_penalty:
|
|
118
|
-
break
|
|
119
|
-
selected = model
|
|
120
|
-
return selected
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def assign(price, boundaries):
|
|
124
|
-
return int(np.searchsorted(boundaries, price, side="right")) + 1
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def bootstrap_stability(prices, selected_count, complexity_penalty, replicates, seed):
|
|
128
|
-
if replicates < 0:
|
|
129
|
-
raise ValueError("bootstrap replicates must not be negative")
|
|
130
|
-
if not replicates:
|
|
131
|
-
return None
|
|
132
|
-
rng = np.random.default_rng(seed)
|
|
133
|
-
selections = Counter()
|
|
134
|
-
same_count_boundaries = []
|
|
135
|
-
for _ in range(replicates):
|
|
136
|
-
sample = rng.choice(prices, size=len(prices), replace=True)
|
|
137
|
-
chosen = select_model(jenks_models(sample), complexity_penalty)
|
|
138
|
-
selections[chosen["band_count"]] += 1
|
|
139
|
-
if chosen["band_count"] == selected_count:
|
|
140
|
-
same_count_boundaries.append(chosen["boundaries"])
|
|
141
|
-
intervals = []
|
|
142
|
-
if same_count_boundaries and selected_count > 1:
|
|
143
|
-
matrix = np.asarray(same_count_boundaries)
|
|
144
|
-
for index in range(selected_count - 1):
|
|
145
|
-
intervals.append({"boundary_number": index + 1,
|
|
146
|
-
"p10": round(float(np.quantile(matrix[:, index], 0.1)), 4),
|
|
147
|
-
"median": round(float(np.median(matrix[:, index])), 4),
|
|
148
|
-
"p90": round(float(np.quantile(matrix[:, index], 0.9)), 4)})
|
|
149
|
-
return {"replicates": replicates, "seed": seed,
|
|
150
|
-
"selected_band_count_frequency": {str(k): selections[k] for k in sorted(selections)},
|
|
151
|
-
"same_band_count_share": round(selections[selected_count] / replicates, 6),
|
|
152
|
-
"same_count_boundary_intervals": intervals,
|
|
153
|
-
"note": "Intervals use only resamples selecting the original band count; low same-count share means boundaries are provisional."}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def analyze(natural, advertising, source_hash, *, complexity_penalty=0.03,
|
|
157
|
-
bootstrap_replicates=0, bootstrap_seed=20260927):
|
|
158
|
-
ids = required(natural, NATURAL_REQUIRED, "自然位")
|
|
159
|
-
required(advertising, AD_REQUIRED, "广告位")
|
|
160
|
-
if ids.duplicated().any():
|
|
161
|
-
raise ValueError("natural-position product IDs must be unique")
|
|
162
|
-
prices = positive_prices(natural, "自然位")
|
|
163
|
-
ad_prices = positive_prices(advertising, "广告位") if len(advertising) else np.array([])
|
|
164
|
-
models = jenks_models(prices)
|
|
165
|
-
selected = select_model(models, complexity_penalty)
|
|
166
|
-
boundaries = selected["boundaries"]
|
|
167
|
-
payments = pd.to_numeric(natural["付款人数"], errors="coerce")
|
|
168
|
-
if ((payments.dropna() < 0) | ~np.isfinite(payments.dropna())).any():
|
|
169
|
-
raise ValueError("natural-position payment counts must be finite and non-negative")
|
|
170
|
-
products = []
|
|
171
|
-
for row, product_id, price, payment in zip(natural.to_dict("records"), ids, prices, payments):
|
|
172
|
-
products.append({"product_id": product_id, "title": str(row["商品名称"]),
|
|
173
|
-
"shop_name": str(row["店铺名称"]), "price_yuan": float(price),
|
|
174
|
-
"payment_people": None if pd.isna(payment) else float(payment),
|
|
175
|
-
"band": assign(price, boundaries)})
|
|
176
|
-
ad_ids = advertising["商品ID"].astype(str).tolist()
|
|
177
|
-
ad_rows = [{"product_id": product_id, "price_yuan": float(price),
|
|
178
|
-
"band": assign(price, boundaries)} for product_id, price in zip(ad_ids, ad_prices)]
|
|
179
|
-
first_ads = {}
|
|
180
|
-
for row in ad_rows:
|
|
181
|
-
first_ads.setdefault(row["product_id"], row)
|
|
182
|
-
ad_unique = list(first_ads.values())
|
|
183
|
-
groups = []
|
|
184
|
-
for band in range(1, selected["band_count"] + 1):
|
|
185
|
-
members = [item for item in products if item["band"] == band]
|
|
186
|
-
displayed = [item["price_yuan"] for item in members]
|
|
187
|
-
paid = [item["payment_people"] for item in members if item["payment_people"] is not None]
|
|
188
|
-
groups.append({"band": band,
|
|
189
|
-
"lower_inclusive": float(prices.min()) if band == 1 else boundaries[band - 2],
|
|
190
|
-
"upper_exclusive": boundaries[band - 1] if band <= len(boundaries) else None,
|
|
191
|
-
"product_count": len(members), "product_share": round(len(members) / len(products), 6),
|
|
192
|
-
"shop_count": len({item["shop_name"] for item in members}),
|
|
193
|
-
"price_min": min(displayed), "price_median": float(np.median(displayed)),
|
|
194
|
-
"price_max": max(displayed),
|
|
195
|
-
"payment_known_count": len(paid),
|
|
196
|
-
"payment_people_median": float(np.median(paid)) if paid else None,
|
|
197
|
-
"ad_appearance_count": sum(item["band"] == band for item in ad_rows),
|
|
198
|
-
"ad_unique_product_count": sum(item["band"] == band for item in ad_unique)})
|
|
199
|
-
if sum(item["product_count"] for item in groups) != len(products):
|
|
200
|
-
raise RuntimeError("natural product bands do not reconcile")
|
|
201
|
-
if sum(item["ad_appearance_count"] for item in groups) != len(ad_rows):
|
|
202
|
-
raise RuntimeError("advertising appearances do not reconcile")
|
|
203
|
-
detail = {"source": {"sha256": source_hash, "sheet": "自然位"},
|
|
204
|
-
"products": products, "advertising_appearances": ad_rows}
|
|
205
|
-
summary = {"source": {"sha256": source_hash, "natural_sheet": "自然位", "advertising_sheet": "广告位"},
|
|
206
|
-
"method": METHOD,
|
|
207
|
-
"definitions": {"fit_population": "Every cleaned natural-position product ID; no product eligibility filter in this module.",
|
|
208
|
-
"fit_field": "现价, the displayed current price; not a verified transaction price.",
|
|
209
|
-
"transform": "Natural logarithm of positive price; Jenks minimizes within-band squared log-price deviation.",
|
|
210
|
-
"selection": "Choose the largest sequential band count whose additional explained variance meets the complexity penalty; each band needs minimum support.",
|
|
211
|
-
"advertising": "Advertising appearances and first-seen unique advertised IDs are mapped to fitted natural boundaries but do not fit them.",
|
|
212
|
-
"payment": "付款人数 is a source display field with unspecified observation window; it is not conversion, units or GMV.",
|
|
213
|
-
"interpretation": "Price-density breaks describe visible supply, not proven buyer psychological thresholds or opportunity."},
|
|
214
|
-
"parameters": {"complexity_penalty": complexity_penalty, "minimum_share": 0.02,
|
|
215
|
-
"minimum_count": max(2, math.ceil(len(products) * 0.02)), "max_bands": 10},
|
|
216
|
-
"candidate_models": models, "selected_model": selected, "bands": groups,
|
|
217
|
-
"bootstrap_stability": bootstrap_stability(prices, selected["band_count"],
|
|
218
|
-
complexity_penalty, bootstrap_replicates, bootstrap_seed),
|
|
219
|
-
"sensitivity": {str(value): select_model(models, value)["band_count"] for value in (0.02, 0.03, 0.04)},
|
|
220
|
-
"quality": {"natural_product_count": len(products),
|
|
221
|
-
"natural_unique_product_id_count": len(set(ids)),
|
|
222
|
-
"advertising_appearance_count": len(ad_rows),
|
|
223
|
-
"advertising_unique_product_count": len(ad_unique),
|
|
224
|
-
"missing_payment_count": int(payments.isna().sum()),
|
|
225
|
-
"boundary_count": len(boundaries),
|
|
226
|
-
"all_natural_assigned": True}}
|
|
227
|
-
return detail, summary
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def main():
|
|
231
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
232
|
-
parser.add_argument("--input", type=Path, required=True)
|
|
233
|
-
parser.add_argument("--output-dir", type=Path, required=True)
|
|
234
|
-
parser.add_argument("--complexity-penalty", type=float, default=0.03)
|
|
235
|
-
parser.add_argument("--bootstrap-replicates", type=int, default=30)
|
|
236
|
-
parser.add_argument("--bootstrap-seed", type=int, default=20260927)
|
|
237
|
-
args = parser.parse_args()
|
|
238
|
-
if not args.input.is_file():
|
|
239
|
-
parser.error(f"input workbook not found: {args.input}")
|
|
240
|
-
if args.output_dir.exists():
|
|
241
|
-
parser.error(f"output path already exists: {args.output_dir}")
|
|
242
|
-
natural = pd.read_excel(args.input, sheet_name="自然位", dtype={"商品ID": str})
|
|
243
|
-
advertising = pd.read_excel(args.input, sheet_name="广告位", dtype={"商品ID": str})
|
|
244
|
-
detail, summary = analyze(natural, advertising, sha256(args.input),
|
|
245
|
-
complexity_penalty=args.complexity_penalty,
|
|
246
|
-
bootstrap_replicates=args.bootstrap_replicates,
|
|
247
|
-
bootstrap_seed=args.bootstrap_seed)
|
|
248
|
-
args.output_dir.mkdir(parents=True, exist_ok=False)
|
|
249
|
-
for name, document in (("price_band_detail.json", detail), ("price_band_summary.json", summary)):
|
|
250
|
-
(args.output_dir / name).write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
251
|
-
print(json.dumps({"output_dir": str(args.output_dir), "selected_band_count": summary["selected_model"]["band_count"],
|
|
252
|
-
"boundaries": summary["selected_model"]["boundaries"], "quality": summary["quality"]},
|
|
253
|
-
ensure_ascii=False))
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if __name__ == "__main__":
|
|
257
|
-
main()
|