@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.
Files changed (77) hide show
  1. package/README.md +30 -22
  2. package/package.json +2 -7
  3. package/requirements.txt +1 -0
  4. package/skills/taobao-ai-operations/SKILL.md +8 -1
  5. package/src/cli.mjs +16 -6
  6. package/src/components.mjs +73 -0
  7. package/src/core.mjs +5 -1
  8. package/src/runtime.mjs +18 -16
  9. package/src/skill-manager.mjs +4 -4
  10. package/src/update.mjs +5 -5
  11. package/src/workflow.mjs +3 -3
  12. package/suite-manifest.json +186 -10
  13. package/skills/taobao-search-product-form/SKILL.md +0 -74
  14. package/skills/taobao-search-product-form/adapters/codex.json +0 -139
  15. package/skills/taobao-search-product-form/adapters/sealseek.json +0 -24
  16. package/skills/taobao-search-product-form/agents/openai.yaml +0 -3
  17. package/skills/taobao-search-product-form/capabilities.json +0 -148
  18. package/skills/taobao-search-product-form/references/SCHEMA.md +0 -3
  19. package/skills/taobao-search-product-form/references/index.md +0 -10
  20. package/skills/taobao-search-product-form/references/log.md +0 -35
  21. package/skills/taobao-search-product-form/references/queries/evaluate-white-label-forms.md +0 -3
  22. package/skills/taobao-search-product-form/references/queries/identify-advertisers.md +0 -5
  23. package/skills/taobao-search-product-form/references/queries/identify-geography.md +0 -5
  24. package/skills/taobao-search-product-form/references/queries/identify-price-bands.md +0 -3
  25. package/skills/taobao-search-product-form/references/queries/identify-product-forms.md +0 -3
  26. package/skills/taobao-search-product-form/references/queries/identify-title-roots.md +0 -5
  27. package/skills/taobao-search-product-form/references/report-output.md +0 -23
  28. package/skills/taobao-search-product-form/references/source-manifest.md +0 -37
  29. package/skills/taobao-search-product-form/references/title-lexicon.json +0 -55
  30. package/skills/taobao-search-product-form/references/topics/advertiser-analysis-method.md +0 -43
  31. package/skills/taobao-search-product-form/references/topics/geography-analysis-method.md +0 -23
  32. package/skills/taobao-search-product-form/references/topics/price-band-analysis-method.md +0 -27
  33. package/skills/taobao-search-product-form/references/topics/title-root-analysis-method.md +0 -37
  34. package/skills/taobao-search-product-form/references/topics/visual-form-method.md +0 -31
  35. package/skills/taobao-search-product-form/references/topics/white-label-evidence.md +0 -38
  36. package/skills/taobao-search-product-form/references/workflow-ledger.md +0 -28
  37. package/skills/taobao-search-product-form/report_runtime/VERSION +0 -1
  38. package/skills/taobao-search-product-form/report_runtime/renderer.py +0 -485
  39. package/skills/taobao-search-product-form/report_runtime/templates/workbench.html +0 -67
  40. package/skills/taobao-search-product-form/report_runtime/validator.py +0 -117
  41. package/skills/taobao-search-product-form/scripts/analyze.py +0 -119
  42. package/skills/taobao-search-product-form/scripts/analyze_advertisers.py +0 -352
  43. package/skills/taobao-search-product-form/scripts/analyze_geography.py +0 -316
  44. package/skills/taobao-search-product-form/scripts/analyze_price_bands.py +0 -257
  45. package/skills/taobao-search-product-form/scripts/analyze_title_roots.py +0 -327
  46. package/skills/taobao-search-product-form/scripts/assemble_opportunity_evidence.py +0 -322
  47. package/skills/taobao-search-product-form/scripts/build_report_viewmodel.py +0 -822
  48. package/skills/taobao-search-product-form/scripts/build_visual_review_viewmodel.py +0 -177
  49. package/skills/taobao-search-product-form/scripts/clean_search_export.mjs +0 -61
  50. package/skills/taobao-search-product-form/scripts/cluster_visual_forms.py +0 -193
  51. package/skills/taobao-search-product-form/scripts/compile_style_prototypes.py +0 -115
  52. package/skills/taobao-search-product-form/scripts/compile_visual_review.py +0 -60
  53. package/skills/taobao-search-product-form/scripts/compile_visual_selection.py +0 -148
  54. package/skills/taobao-search-product-form/scripts/prepare.py +0 -180
  55. package/skills/taobao-search-product-form/scripts/render_report.py +0 -31
  56. package/skills/taobao-search-product-form/scripts/validate_opportunity_cards.py +0 -84
  57. package/skills/taobao-search-product-form/scripts/validate_report_viewmodel.py +0 -152
  58. package/skills/taobao-search-product-form/scripts/visual_form_pipeline.py +0 -247
  59. package/skills/taobao-search-product-form/scripts/visual_observation_batches.py +0 -117
  60. package/skills/taobao-search-product-form/scripts/workflow_ledger.py +0 -177
  61. package/skills/yuce-category-opportunity-report/SKILL.md +0 -151
  62. package/skills/yuce-category-opportunity-report/adapters/codex.json +0 -113
  63. package/skills/yuce-category-opportunity-report/adapters/sealseek.json +0 -39
  64. package/skills/yuce-category-opportunity-report/agents/openai.yaml +0 -3
  65. package/skills/yuce-category-opportunity-report/assets/chart-views.js +0 -80
  66. package/skills/yuce-category-opportunity-report/assets/package.json +0 -1
  67. package/skills/yuce-category-opportunity-report/assets/report-template.html +0 -62
  68. package/skills/yuce-category-opportunity-report/capabilities.json +0 -48
  69. package/skills/yuce-category-opportunity-report/references/SCHEMA.md +0 -5
  70. package/skills/yuce-category-opportunity-report/references/data-contract.md +0 -40
  71. package/skills/yuce-category-opportunity-report/references/index.md +0 -6
  72. package/skills/yuce-category-opportunity-report/references/log.md +0 -29
  73. package/skills/yuce-category-opportunity-report/references/queries/assess-category-opportunity.md +0 -7
  74. package/skills/yuce-category-opportunity-report/references/source-manifest.md +0 -14
  75. package/skills/yuce-category-opportunity-report/references/topics/category-opportunity-method.md +0 -39
  76. package/skills/yuce-category-opportunity-report/scripts/build_report.py +0 -779
  77. 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()