@petercjl/taobao-ai-ops 0.1.0 → 0.1.1

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 (76) hide show
  1. package/README.md +29 -22
  2. package/package.json +2 -7
  3. package/skills/taobao-ai-operations/SKILL.md +5 -1
  4. package/src/cli.mjs +16 -6
  5. package/src/components.mjs +73 -0
  6. package/src/core.mjs +5 -1
  7. package/src/runtime.mjs +11 -15
  8. package/src/skill-manager.mjs +4 -4
  9. package/src/update.mjs +5 -5
  10. package/src/workflow.mjs +3 -3
  11. package/suite-manifest.json +163 -10
  12. package/skills/taobao-search-product-form/SKILL.md +0 -74
  13. package/skills/taobao-search-product-form/adapters/codex.json +0 -139
  14. package/skills/taobao-search-product-form/adapters/sealseek.json +0 -24
  15. package/skills/taobao-search-product-form/agents/openai.yaml +0 -3
  16. package/skills/taobao-search-product-form/capabilities.json +0 -148
  17. package/skills/taobao-search-product-form/references/SCHEMA.md +0 -3
  18. package/skills/taobao-search-product-form/references/index.md +0 -10
  19. package/skills/taobao-search-product-form/references/log.md +0 -35
  20. package/skills/taobao-search-product-form/references/queries/evaluate-white-label-forms.md +0 -3
  21. package/skills/taobao-search-product-form/references/queries/identify-advertisers.md +0 -5
  22. package/skills/taobao-search-product-form/references/queries/identify-geography.md +0 -5
  23. package/skills/taobao-search-product-form/references/queries/identify-price-bands.md +0 -3
  24. package/skills/taobao-search-product-form/references/queries/identify-product-forms.md +0 -3
  25. package/skills/taobao-search-product-form/references/queries/identify-title-roots.md +0 -5
  26. package/skills/taobao-search-product-form/references/report-output.md +0 -23
  27. package/skills/taobao-search-product-form/references/source-manifest.md +0 -37
  28. package/skills/taobao-search-product-form/references/title-lexicon.json +0 -55
  29. package/skills/taobao-search-product-form/references/topics/advertiser-analysis-method.md +0 -43
  30. package/skills/taobao-search-product-form/references/topics/geography-analysis-method.md +0 -23
  31. package/skills/taobao-search-product-form/references/topics/price-band-analysis-method.md +0 -27
  32. package/skills/taobao-search-product-form/references/topics/title-root-analysis-method.md +0 -37
  33. package/skills/taobao-search-product-form/references/topics/visual-form-method.md +0 -31
  34. package/skills/taobao-search-product-form/references/topics/white-label-evidence.md +0 -38
  35. package/skills/taobao-search-product-form/references/workflow-ledger.md +0 -28
  36. package/skills/taobao-search-product-form/report_runtime/VERSION +0 -1
  37. package/skills/taobao-search-product-form/report_runtime/renderer.py +0 -485
  38. package/skills/taobao-search-product-form/report_runtime/templates/workbench.html +0 -67
  39. package/skills/taobao-search-product-form/report_runtime/validator.py +0 -117
  40. package/skills/taobao-search-product-form/scripts/analyze.py +0 -119
  41. package/skills/taobao-search-product-form/scripts/analyze_advertisers.py +0 -352
  42. package/skills/taobao-search-product-form/scripts/analyze_geography.py +0 -316
  43. package/skills/taobao-search-product-form/scripts/analyze_price_bands.py +0 -257
  44. package/skills/taobao-search-product-form/scripts/analyze_title_roots.py +0 -327
  45. package/skills/taobao-search-product-form/scripts/assemble_opportunity_evidence.py +0 -322
  46. package/skills/taobao-search-product-form/scripts/build_report_viewmodel.py +0 -822
  47. package/skills/taobao-search-product-form/scripts/build_visual_review_viewmodel.py +0 -177
  48. package/skills/taobao-search-product-form/scripts/clean_search_export.mjs +0 -61
  49. package/skills/taobao-search-product-form/scripts/cluster_visual_forms.py +0 -193
  50. package/skills/taobao-search-product-form/scripts/compile_style_prototypes.py +0 -115
  51. package/skills/taobao-search-product-form/scripts/compile_visual_review.py +0 -60
  52. package/skills/taobao-search-product-form/scripts/compile_visual_selection.py +0 -148
  53. package/skills/taobao-search-product-form/scripts/prepare.py +0 -180
  54. package/skills/taobao-search-product-form/scripts/render_report.py +0 -31
  55. package/skills/taobao-search-product-form/scripts/validate_opportunity_cards.py +0 -84
  56. package/skills/taobao-search-product-form/scripts/validate_report_viewmodel.py +0 -152
  57. package/skills/taobao-search-product-form/scripts/visual_form_pipeline.py +0 -247
  58. package/skills/taobao-search-product-form/scripts/visual_observation_batches.py +0 -117
  59. package/skills/taobao-search-product-form/scripts/workflow_ledger.py +0 -177
  60. package/skills/yuce-category-opportunity-report/SKILL.md +0 -151
  61. package/skills/yuce-category-opportunity-report/adapters/codex.json +0 -113
  62. package/skills/yuce-category-opportunity-report/adapters/sealseek.json +0 -39
  63. package/skills/yuce-category-opportunity-report/agents/openai.yaml +0 -3
  64. package/skills/yuce-category-opportunity-report/assets/chart-views.js +0 -80
  65. package/skills/yuce-category-opportunity-report/assets/package.json +0 -1
  66. package/skills/yuce-category-opportunity-report/assets/report-template.html +0 -62
  67. package/skills/yuce-category-opportunity-report/capabilities.json +0 -48
  68. package/skills/yuce-category-opportunity-report/references/SCHEMA.md +0 -5
  69. package/skills/yuce-category-opportunity-report/references/data-contract.md +0 -40
  70. package/skills/yuce-category-opportunity-report/references/index.md +0 -6
  71. package/skills/yuce-category-opportunity-report/references/log.md +0 -29
  72. package/skills/yuce-category-opportunity-report/references/queries/assess-category-opportunity.md +0 -7
  73. package/skills/yuce-category-opportunity-report/references/source-manifest.md +0 -14
  74. package/skills/yuce-category-opportunity-report/references/topics/category-opportunity-method.md +0 -39
  75. package/skills/yuce-category-opportunity-report/scripts/build_report.py +0 -779
  76. package/skills/yuce-category-opportunity-report/scripts/market_routes.py +0 -139
@@ -1,117 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Deterministic contract checks for commerce-ui HTML artifacts."""
3
-
4
- from __future__ import annotations
5
-
6
- import pathlib
7
- import re
8
-
9
-
10
- REQUIRED = {
11
- "responsive viewport": r'<meta\s+name=["\']viewport["\']',
12
- "generator metadata": r'<meta\s+name=["\']generator["\']\s+content=["\']commerce-ui/',
13
- "contract metadata": r'<meta\s+name=["\']commerce-ui-contract["\']\s+content=["\']compact-workbench@1\.0["\']',
14
- "external image policy": r'<meta\s+name=["\']commerce-ui-external-images["\']\s+content=["\'](?:forbid|allow_https)["\']',
15
- "left navigation": r'class=["\'][^"\']*sidebar',
16
- "lower-left status": r'id=["\']sideStatus["\']',
17
- "upper-right tools": r'class=["\'][^"\']*tools',
18
- "modular cards": r'class=["\'][^"\']*card',
19
- "mobile rule": r'@media\s*\(max-width:',
20
- "print rule": r'@media\s+print',
21
- "view targets": r'<[^>]+\bdata-view-target=["\'][^"\']+',
22
- "independent views": r'<[^>]+\bdata-view=["\'][^"\']+',
23
- "view router": r'function\s+showView\s*\(',
24
- }
25
-
26
- FORBIDDEN = {
27
- "unresolved template marker": r'__[A-Z][A-Z0-9_]+__|\{\{[^}]+\}\}',
28
- "private home path": r'/Users/[^/]+/|[A-Za-z]:\\Users\\[^\\]+\\',
29
- "credential-like value": r'(?i)(api[_-]?key|access[_-]?token|secret)\s*[:=]\s*["\'][A-Za-z0-9_\-]{16,}',
30
- "top-level anchor scrolling": r'scrollIntoView\s*\(',
31
- }
32
-
33
-
34
- def validate_file(path_value: pathlib.Path | str) -> dict:
35
- path = pathlib.Path(path_value).expanduser().resolve()
36
- errors: list[str] = []
37
- if not path.is_file():
38
- return {"ok": False, "path": str(path), "errors": ["file not found"]}
39
- text = path.read_text(encoding="utf-8")
40
- for label, pattern in REQUIRED.items():
41
- if not re.search(pattern, text, re.I):
42
- errors.append(f"missing {label}")
43
- for label, pattern in FORBIDDEN.items():
44
- if re.search(pattern, text):
45
- errors.append(f"found {label}")
46
-
47
- targets = re.findall(r'<[^>]+\bdata-view-target=["\']([^"\']+)', text, re.I)
48
- views = re.findall(r'<[^>]+\bdata-view=["\']([^"\']+)', text, re.I)
49
- if len(targets) < 2:
50
- errors.append("fewer than two top-level view targets")
51
- if len(views) < 2:
52
- errors.append("fewer than two independent views")
53
- if len(targets) != len(set(targets)):
54
- errors.append("duplicate top-level view targets")
55
- if len(views) != len(set(views)):
56
- errors.append("duplicate independent view ids")
57
- if set(targets) != set(views):
58
- errors.append(f"view target/view mismatch: targets={sorted(set(targets))}, views={sorted(set(views))}")
59
-
60
- initially_visible = len(re.findall(r'<section\s+class=["\'][^"\']*\bview\b[^"\']*["\'][^>]*\bdata-view=["\'][^"\']+["\'](?![^>]*\bhidden\b)[^>]*>', text, re.I))
61
- if initially_visible != 1:
62
- errors.append(f"expected exactly one initially visible view, found {initially_visible}")
63
-
64
- if 'class="card conclusion-card"' in text:
65
- conclusion_items = re.findall(r'<article\s+class="conclusion-item\s+conclusion-tone-(?:info|positive|warning|danger)\s+searchable"', text)
66
- if len(conclusion_items) < 2:
67
- errors.append("structured conclusion has fewer than two items")
68
- if not re.search(r'class="conclusion-segment[^\"]*emphasis-(?:strong|strong-italic)', text):
69
- errors.append("structured conclusion has no bold emphasis")
70
- if not re.search(r'class="conclusion-segment[^\"]*emphasis-(?:italic|strong-italic)', text):
71
- errors.append("structured conclusion has no italic emphasis")
72
- if not re.search(r'class="conclusion-segment[^\"]*conclusion-color-(?:info|positive|warning|danger)', text):
73
- errors.append("structured conclusion has no color emphasis")
74
-
75
- sizes = [float(x) for x in re.findall(r'font-size\s*:\s*(\d+(?:\.\d+)?)px', text, re.I)]
76
- if sizes and min(sizes) < 12:
77
- errors.append(f"font size below 12px found: {min(sizes):g}px")
78
- if not re.search(r'font-size\s*:\s*13px', text, re.I):
79
- errors.append("missing 13px body/table baseline")
80
-
81
- policy_match = re.search(r'<meta\s+name=["\']commerce-ui-external-images["\']\s+content=["\']([^"\']+)', text, re.I)
82
- external_images_approved = bool(policy_match and policy_match.group(1) == "allow_https")
83
- remote_dependencies = re.findall(r'<([a-z0-9]+)\b[^>]*\b(src|href)=["\'](https?://[^"\']+)', text, re.I)
84
- invalid_remote = [item for item in remote_dependencies if not item[2].startswith("https://") or
85
- (item[0].lower(), item[1].lower()) not in {("img", "src"), ("a", "href")}]
86
- if invalid_remote:
87
- errors.append(f"found {len(invalid_remote)} unsupported remote rendering dependency(s)")
88
-
89
- images = re.findall(r'<img\b[^>]*\bsrc=["\']([^"\']+)', text, re.I)
90
- external_images = [src for src in images if src.startswith("https://")]
91
- invalid_images = [src for src in images if not src.startswith("data:image/") and not src.startswith("https://")]
92
- if external_images and not external_images_approved:
93
- errors.append(f"found {len(external_images)} unapproved external image asset(s)")
94
- if invalid_images:
95
- errors.append(f"found {len(invalid_images)} invalid image asset(s)")
96
- product_links = re.findall(r'<a\b[^>]*class="product-(?:media|title)-link"[^>]*>', text)
97
- if product_links and any('target="_blank"' not in link or 'rel="noopener noreferrer"' not in link for link in product_links):
98
- errors.append("product external links require new-tab noopener noreferrer")
99
- galleries = re.findall(r'<section\s+class="product-gallery"[^>]*\bdata-product-gallery\b[^>]*>', text)
100
- if galleries:
101
- if not re.search(r'function\s+updateGallery\s*\(', text):
102
- errors.append("product gallery pagination runtime missing")
103
- if text.count('data-gallery-sort') < len(galleries) or text.count('data-page-next') < len(galleries):
104
- errors.append("product gallery controls missing")
105
-
106
- return {
107
- "ok": not errors,
108
- "path": str(path),
109
- "contract": "compact-workbench@1.0",
110
- "view_count": len(views),
111
- "image_count": len(images),
112
- "external_image_count": len(external_images),
113
- "product_link_count": len(product_links),
114
- "external_images_approved": external_images_approved,
115
- "product_gallery_count": len(galleries),
116
- "errors": errors,
117
- }
@@ -1,119 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Reconcile visual product labels with organic placement and advertiser evidence."""
3
-
4
- import argparse
5
- import json
6
- import statistics
7
- from collections import Counter, defaultdict
8
- from pathlib import Path
9
-
10
-
11
- def median(values):
12
- usable = [float(value) for value in values if value is not None]
13
- return round(statistics.median(usable), 2) if usable else None
14
-
15
-
16
- def main():
17
- parser = argparse.ArgumentParser()
18
- parser.add_argument("--manifest", required=True)
19
- parser.add_argument("--labels", required=True)
20
- parser.add_argument("--output", required=True)
21
- args = parser.parse_args()
22
- manifest_path = Path(args.manifest).expanduser().resolve()
23
- labels_path = Path(args.labels).expanduser().resolve()
24
- output_path = Path(args.output).expanduser().resolve()
25
- if output_path.exists():
26
- parser.error("output already exists; select a new path")
27
- manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
28
- ledger = json.loads(labels_path.read_text(encoding="utf-8"))
29
- products = {p["product_id"]: p for p in manifest["products"]}
30
- selected = set(manifest["selected_organic_ids"]) | set(manifest["selected_ad_only_ids"])
31
- labels = {}
32
- for item in ledger["labels"]:
33
- pid = str(item["product_id"])
34
- if pid in labels or pid not in selected:
35
- raise ValueError(f"duplicate or out-of-scope visual label: {pid}")
36
- eligibility = item.get("eligibility")
37
- if eligibility not in ("target", "non_target", "uncertain"):
38
- raise ValueError(f"invalid eligibility for {pid}")
39
- form = item.get("form")
40
- if eligibility == "target" and (not form or not item.get("visible_evidence")):
41
- raise ValueError(f"target needs form and visible evidence: {pid}")
42
- if eligibility != "target" and form:
43
- raise ValueError(f"non-target/uncertain must not have form: {pid}")
44
- fetched_path = manifest["fetches"].get(pid, {}).get("path")
45
- if fetched_path is None or item.get("image_path") != fetched_path:
46
- raise ValueError(f"label lacks exact fetched-image evidence: {pid}")
47
- labels[pid] = item
48
- organic_ids = set(manifest["selected_organic_ids"])
49
- ad_only_ids = set(manifest["selected_ad_only_ids"])
50
- organic_labels = {pid: label for pid, label in labels.items() if pid in organic_ids}
51
- ad_labels = {pid: label for pid, label in labels.items() if pid in ad_only_ids}
52
- counts = Counter(label["eligibility"] for label in organic_labels.values())
53
- ad_counts = Counter(label["eligibility"] for label in ad_labels.values())
54
- by_form = defaultdict(list)
55
- for pid, label in organic_labels.items():
56
- if label["eligibility"] == "target":
57
- by_form[label["form"]].append(products[pid])
58
- forms = []
59
- for form, items in by_form.items():
60
- items.sort(key=lambda p: p["organic_rank"])
61
- forms.append({
62
- "form": form,
63
- "organic_unique_products": len(items),
64
- "shops": len({p["shop"] for p in items if p["shop"]}),
65
- "top_100_organic": sum(p["organic_rank"] <= 100 for p in items),
66
- "top_300_organic": sum(p["organic_rank"] <= 300 for p in items),
67
- "median_price": median(p["price"] for p in items),
68
- "median_payment_count": median(p["payment_count"] for p in items),
69
- "advertised_also": sum(bool(p["ad_positions"]) for p in items),
70
- "representative_ids": [p["product_id"] for p in items[:8]],
71
- })
72
- forms.sort(key=lambda f: (-f["top_100_organic"], -f["organic_unique_products"], f["form"]))
73
- advertised = [p for p in products.values() if p["ad_positions"]]
74
- overlap = [p for p in advertised if p["natural_positions"]]
75
- analysis = {
76
- "schema_version": 1,
77
- "source": manifest["source"],
78
- "search_term": manifest["search_term"],
79
- "scope": manifest["scope"],
80
- "source_counts": {
81
- "display_rows": len(manifest["source_rows"]),
82
- "unique_products": len(products),
83
- "organic_unique_products": sum(bool(p["natural_positions"]) for p in products.values()),
84
- "advertised_unique_products": len(advertised),
85
- "advertised_distinct_shops": len({p["shop"] for p in advertised if p["shop"]}),
86
- "advertised_with_natural": len(overlap),
87
- "ad_only_unique_products": len(advertised) - len(overlap),
88
- "ad_only_distinct_shops": len({p["shop"] for p in advertised if not p["natural_positions"] and p["shop"]}),
89
- "ad_display_rows": sum(row["placement"] == "广告位" for row in manifest["source_rows"]),
90
- },
91
- "image_fetch": {
92
- "requested": len(manifest["fetches"]),
93
- "ok": sum(result["status"] == "ok" for result in manifest["fetches"].values()),
94
- "failures": {pid: result["status"] for pid, result in manifest["fetches"].items() if result["status"] != "ok"},
95
- },
96
- "visual_coverage": {
97
- "organic_selected": len(organic_ids),
98
- "organic_labeled": len(organic_labels),
99
- "organic_target": counts["target"],
100
- "organic_non_target": counts["non_target"],
101
- "organic_uncertain": counts["uncertain"],
102
- "ad_only_selected": len(ad_only_ids),
103
- "ad_only_labeled": len(ad_labels),
104
- "ad_only_target": ad_counts["target"],
105
- "ad_only_non_target": ad_counts["non_target"],
106
- "ad_only_uncertain": ad_counts["uncertain"],
107
- },
108
- "forms": forms,
109
- "unlabeled_organic_ids": sorted(organic_ids - set(organic_labels)),
110
- "unlabeled_ad_only_ids": sorted(ad_only_ids - set(ad_labels)),
111
- }
112
- output_path.parent.mkdir(parents=True, exist_ok=True)
113
- with output_path.open("x", encoding="utf-8") as handle:
114
- json.dump(analysis, handle, ensure_ascii=False, indent=2)
115
- print(json.dumps({"output": str(output_path), "source_counts": analysis["source_counts"], "visual_coverage": analysis["visual_coverage"], "form_count": len(forms)}, ensure_ascii=False))
116
-
117
-
118
- if __name__ == "__main__":
119
- main()
@@ -1,352 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Profile advertisers in a cleaned two-sheet Taobao search workbook."""
3
-
4
- import argparse
5
- import bisect
6
- import hashlib
7
- import json
8
- from collections import Counter
9
- from pathlib import Path
10
-
11
- import pandas as pd
12
-
13
-
14
- REQUIRED = {"商品ID", "店铺名称", "占位类型"}
15
- SYSTEM_DISPLAY_SHOP_LABELS = {"百亿补贴品牌优选"}
16
-
17
-
18
- def text(value):
19
- return "" if pd.isna(value) else str(value).strip()
20
-
21
-
22
- def position(value):
23
- if pd.isna(value):
24
- return None
25
- number = float(value)
26
- return int(number) if number.is_integer() else number
27
-
28
-
29
- def number(value):
30
- if pd.isna(value):
31
- return None
32
- return float(value)
33
-
34
-
35
- def total_or_none(values):
36
- numeric = pd.to_numeric(pd.Series(values), errors="coerce")
37
- return int(numeric.sum()) if numeric.notna().any() else None
38
-
39
-
40
- def median_or_none(values):
41
- numeric = pd.to_numeric(pd.Series(values), errors="coerce")
42
- return float(numeric.median()) if numeric.notna().any() else None
43
-
44
-
45
- def price_band(value, edges):
46
- return None if value is None else bisect.bisect_right(edges, value)
47
-
48
-
49
- def price_band_labels(edges):
50
- fmt = lambda value: f"¥{value:g}"
51
- return [f"<{fmt(edges[0])}"] + [f"{fmt(a)}–<{fmt(b)}" for a, b in zip(edges, edges[1:])] + [f"≥{fmt(edges[-1])}"]
52
-
53
-
54
- def load_sheet(source, sheet_name, placement):
55
- frame = pd.read_excel(source, sheet_name=sheet_name, dtype={"商品ID": str})
56
- missing = REQUIRED - set(frame.columns)
57
- if missing:
58
- raise ValueError(f"{sheet_name}: missing columns {sorted(missing)}")
59
- frame["商品ID"] = frame["商品ID"].map(text)
60
- frame["店铺名称"] = frame["店铺名称"].map(text)
61
- if (frame["商品ID"] == "").any() or (frame["店铺名称"] == "").any():
62
- raise ValueError(f"{sheet_name}: blank product ID or shop name")
63
- if not frame["占位类型"].map(text).eq(placement).all():
64
- raise ValueError(f"{sheet_name}: unexpected placement label")
65
- return frame
66
-
67
-
68
- def resolve_advertiser_shops(ads):
69
- """Keep source display labels while resolving corroborated platform aliases."""
70
- ads = ads.copy()
71
- ads["_resolved_shop_name"] = ads["店铺名称"]
72
- ads["_shop_resolution"] = "exact_display_name"
73
- aliases = []
74
- for product_id, group in ads.groupby("商品ID", sort=False):
75
- names = set(group["店铺名称"])
76
- aliases_for_product = names & SYSTEM_DISPLAY_SHOP_LABELS
77
- if not aliases_for_product:
78
- if len(names) != 1:
79
- raise ValueError(f"广告位 product {product_id} has unresolved multiple shop names: {sorted(names)}")
80
- continue
81
- actual_names = names - SYSTEM_DISPLAY_SHOP_LABELS
82
- sellers = {text(value) for value in group.get("掌柜名", pd.Series(dtype=str)) if text(value)}
83
- if len(actual_names) != 1 or sellers != actual_names:
84
- raise ValueError(
85
- f"广告位 product {product_id} has an unresolved display shop label: "
86
- f"display names={sorted(names)}, seller names={sorted(sellers)}"
87
- )
88
- actual_shop = next(iter(actual_names))
89
- alias_mask = (ads["商品ID"] == product_id) & ads["店铺名称"].isin(aliases_for_product)
90
- ads.loc[alias_mask, "_resolved_shop_name"] = actual_shop
91
- ads.loc[alias_mask, "_shop_resolution"] = "system_display_label_corroborated_by_seller_and_same_id_shop"
92
- aliases.append({
93
- "product_id": product_id,
94
- "resolved_shop_name": actual_shop,
95
- "display_shop_names": sorted(names),
96
- "alias_appearance_count": int(alias_mask.sum()),
97
- "basis": "same product ID has an actual shop display name matching every seller name",
98
- })
99
- return ads, aliases
100
-
101
-
102
- def analyze(source, supplied_price_bands=None):
103
- natural = load_sheet(source, "自然位", "自然位")
104
- ads = load_sheet(source, "广告位", "广告位")
105
- if natural["商品ID"].duplicated().any():
106
- raise ValueError("自然位 must contain one first-seen row per product ID")
107
- if ads.empty:
108
- raise ValueError("广告位 is empty; no advertiser profile can be calculated")
109
- if "现价" not in ads or "现价" not in natural or "付款人数" not in natural:
110
- raise ValueError("price comparison needs 现价 in both sheets and 付款人数 in 自然位")
111
-
112
- natural_by_id = natural.set_index("商品ID")
113
- natural_ids = set(natural["商品ID"])
114
- natural_shop_counts = natural.groupby("店铺名称")["商品ID"].nunique().to_dict()
115
- ads, alias_resolutions = resolve_advertiser_shops(ads)
116
- ads["_resolved_shop_type"] = ads.get("店铺类型", pd.Series([None] * len(ads)))
117
- for entry in alias_resolutions:
118
- actual_rows = ads[(ads["商品ID"] == entry["product_id"]) & (ads["店铺名称"] == entry["resolved_shop_name"])]
119
- actual_types = [text(value) for value in actual_rows.get("店铺类型", []) if text(value)]
120
- if actual_types:
121
- alias_mask = (ads["商品ID"] == entry["product_id"]) & (ads["店铺名称"] != entry["resolved_shop_name"])
122
- ads.loc[alias_mask, "_resolved_shop_type"] = actual_types[0]
123
- ad_product_counts = ads["商品ID"].value_counts()
124
- ad_unique = ads.drop_duplicates("商品ID", keep="first")
125
- ad_unique_prices = pd.to_numeric(ad_unique["现价"], errors="coerce")
126
- if supplied_price_bands is None:
127
- edges = sorted(set(round(float(value), 2) for value in ad_unique_prices.quantile([0.25, 0.5, 0.75]).dropna()))
128
- band_source = "advertised-product price quartiles"
129
- else:
130
- edges = supplied_price_bands
131
- band_source = "run-supplied boundaries after inspecting the source prices"
132
- if not edges or any(edge <= 0 for edge in edges) or edges != sorted(set(edges)):
133
- raise ValueError("price band boundaries must be positive, unique and ascending")
134
- labels = price_band_labels(edges)
135
-
136
- products = []
137
- for product_id, group in ads.groupby("商品ID", sort=False):
138
- shop_names = group["_resolved_shop_name"].unique().tolist()
139
- if len(shop_names) != 1:
140
- raise ValueError(f"广告位 product {product_id} has unresolved multiple shop names: {shop_names}")
141
- first = group.iloc[0]
142
- actual_rows = group[group["店铺名称"] == shop_names[0]]
143
- shop_type_row = actual_rows.iloc[0] if not actual_rows.empty else first
144
- natural_row = natural_by_id.loc[product_id] if product_id in natural_ids else None
145
- natural_shop = text(natural_row["店铺名称"]) if natural_row is not None else None
146
- ad_prices = sorted(set(float(value) for value in pd.to_numeric(group["现价"], errors="coerce").dropna()))
147
- first_ad_price = number(pd.to_numeric(first["现价"], errors="coerce"))
148
- products.append({
149
- "product_id": product_id,
150
- "title": text(first.get("商品名称")),
151
- "shop_name": shop_names[0],
152
- "shop_type": text(shop_type_row.get("店铺类型")) or None,
153
- "display_shop_names_in_source": list(dict.fromkeys(group["店铺名称"].tolist())),
154
- "system_display_alias_appearances": int(group["_shop_resolution"].ne("exact_display_name").sum()),
155
- "ad_appearances": len(group),
156
- "ad_positions": [position(value) for value in group.get("序号", pd.Series([None] * len(group)))],
157
- "ad_price_first_appearance": first_ad_price,
158
- "ad_price_band": labels[price_band(first_ad_price, edges)] if first_ad_price is not None else None,
159
- "ad_price_values_in_source": ad_prices,
160
- "ad_price_varies_across_appearances": len(ad_prices) > 1,
161
- "in_natural": natural_row is not None,
162
- "natural_position": position(natural_row.get("序号")) if natural_row is not None else None,
163
- "natural_shop_name": natural_shop,
164
- "natural_price": number(pd.to_numeric(natural_row.get("现价"), errors="coerce")) if natural_row is not None else None,
165
- "natural_payment_people": number(pd.to_numeric(natural_row.get("付款人数"), errors="coerce")) if natural_row is not None else None,
166
- "cross_placement_shop_name_differs": natural_shop is not None and natural_shop != shop_names[0],
167
- })
168
- product_by_id = {row["product_id"]: row for row in products}
169
-
170
- shops = []
171
- for shop_name, group in ads.groupby("_resolved_shop_name", sort=False):
172
- product_ids = list(dict.fromkeys(group["商品ID"].tolist()))
173
- shop_types = sorted({text(value) for value in group["_resolved_shop_type"] if text(value)})
174
- overlap_count = sum(product_by_id[pid]["in_natural"] for pid in product_ids)
175
- shop_natural = natural[natural["店铺名称"] == shop_name]
176
- natural_advertised = shop_natural[shop_natural["商品ID"].isin(product_ids)]
177
- natural_unadvertised = shop_natural[~shop_natural["商品ID"].isin(product_ids)]
178
- matching_ad_natural = natural[natural["商品ID"].isin(product_ids)]
179
- top_natural = shop_natural.sort_values("付款人数", ascending=False).iloc[0] if not shop_natural.empty else None
180
- first_ad_prices = [product_by_id[pid]["ad_price_first_appearance"] for pid in product_ids]
181
- shops.append({
182
- "shop_name": shop_name,
183
- "shop_types": shop_types,
184
- "ad_product_count": len(product_ids),
185
- "ad_appearance_count": len(group),
186
- "ad_products_in_natural": overlap_count,
187
- "ad_only_products": len(product_ids) - overlap_count,
188
- "natural_product_count_by_exact_shop_name": natural_shop_counts.get(shop_name, 0),
189
- "shop_name_present_in_natural": shop_name in natural_shop_counts,
190
- "ad_product_ids": product_ids,
191
- "ad_price_median_by_unique_product": median_or_none(first_ad_prices),
192
- "natural_shop_payment_people_sum": total_or_none(shop_natural["付款人数"]),
193
- "natural_shop_payment_people_median_per_product": median_or_none(shop_natural["付款人数"]),
194
- "natural_advertised_product_count_by_same_shop_name": len(natural_advertised),
195
- "natural_advertised_product_payment_people_sum_by_same_shop_name": total_or_none(natural_advertised["付款人数"]),
196
- "natural_unadvertised_product_count_by_same_shop_name": len(natural_unadvertised),
197
- "natural_unadvertised_product_payment_people_sum_by_same_shop_name": total_or_none(natural_unadvertised["付款人数"]),
198
- "matching_ad_product_payment_people_sum_by_id": total_or_none(matching_ad_natural["付款人数"]),
199
- "highest_payment_natural_product": None if top_natural is None else {
200
- "product_id": text(top_natural["商品ID"]),
201
- "title": text(top_natural.get("商品名称")),
202
- "payment_people": number(top_natural["付款人数"]),
203
- "advertised_by_this_shop": text(top_natural["商品ID"]) in product_ids,
204
- },
205
- "natural_unadvertised_product_ids_by_same_shop_name": natural_unadvertised["商品ID"].tolist(),
206
- })
207
- product_ranks = {row["shop_name"]: rank for rank, row in enumerate(sorted(shops, key=lambda row: (-row["ad_product_count"], -row["ad_appearance_count"], row["shop_name"])), start=1)}
208
- shops.sort(key=lambda row: (-row["ad_appearance_count"], -row["ad_product_count"], row["shop_name"]))
209
- cumulative_products = cumulative_appearances = 0
210
- for rank, row in enumerate(shops, start=1):
211
- cumulative_products += row["ad_product_count"]
212
- cumulative_appearances += row["ad_appearance_count"]
213
- row["rank_by_ad_appearances"] = rank
214
- row["rank_by_ad_products"] = product_ranks[row["shop_name"]]
215
- row["ad_product_share"] = round(row["ad_product_count"] / len(products), 6)
216
- row["ad_appearance_share"] = round(row["ad_appearance_count"] / len(ads), 6)
217
- row["cumulative_ad_product_share_in_appearance_rank"] = round(cumulative_products / len(products), 6)
218
- row["cumulative_ad_appearance_share"] = round(cumulative_appearances / len(ads), 6)
219
-
220
- def type_breakdown(frame, grain):
221
- if "店铺类型" not in frame.columns:
222
- return {}
223
- result = {}
224
- for shop_type, group in frame.groupby(frame["店铺类型"].map(lambda x: text(x) or "未标注")):
225
- result[shop_type] = {
226
- "shop_count": group["_resolved_shop_name"].nunique() if grain == "ads" else group["店铺名称"].nunique(),
227
- "product_count": group["商品ID"].nunique(),
228
- }
229
- if grain == "ads":
230
- result[shop_type]["appearance_count"] = len(group)
231
- return result
232
-
233
- overlap_products = sum(row["in_natural"] for row in products)
234
- overlap_shops = sum(row["shop_name_present_in_natural"] for row in shops)
235
- repeat_distribution = dict(sorted(Counter(ad_product_counts.tolist()).items()))
236
- advertiser_shop_names = {row["shop_name"] for row in shops}
237
- natural_layout = [
238
- {
239
- "product_id": text(row["商品ID"]),
240
- "shop_name": text(row["店铺名称"]),
241
- "title": text(row.get("商品名称")),
242
- "natural_price": number(pd.to_numeric(row["现价"], errors="coerce")),
243
- "natural_payment_people": number(pd.to_numeric(row["付款人数"], errors="coerce")),
244
- "advertised_by_same_shop_name": text(row["商品ID"]) in product_by_id and product_by_id[text(row["商品ID"])]["shop_name"] == text(row["店铺名称"]),
245
- "advertised_by_any_shop_name": text(row["商品ID"]) in product_by_id,
246
- }
247
- for _, row in natural[natural["店铺名称"].isin(advertiser_shop_names)].iterrows()
248
- ]
249
-
250
- def price_distribution(rows, key, shop_key=None):
251
- result = []
252
- valid = [row for row in rows if row[key] is not None]
253
- for index, label in enumerate(labels):
254
- selected = [row for row in valid if price_band(row[key], edges) == index]
255
- entry = {"band": label, "product_count": len(selected), "share_of_priced_products": round(len(selected) / len(valid), 6) if valid else None}
256
- if shop_key:
257
- entry["shop_count"] = len({row[shop_key] for row in selected})
258
- result.append(entry)
259
- return {"priced_product_count": len(valid), "missing_price_count": len(rows) - len(valid), "bands": result}
260
-
261
- ad_price_distribution = price_distribution(products, "ad_price_first_appearance", "shop_name")
262
- natural_price_rows = [{"price": number(pd.to_numeric(row["现价"], errors="coerce"))} for _, row in natural.iterrows()]
263
- natural_price_distribution = price_distribution(natural_price_rows, "price")
264
- non_advertised_layout = [row for row in natural_layout if not row["advertised_by_same_shop_name"]]
265
- natural_non_ad_price_distribution = price_distribution(non_advertised_layout, "natural_price", "shop_name")
266
- counts = {
267
- "natural_products": len(natural),
268
- "natural_shops_by_exact_name": natural["店铺名称"].nunique(),
269
- "ad_appearances": len(ads),
270
- "ad_products": len(products),
271
- "advertiser_shops_by_exact_name": len(shops),
272
- "ad_appearances_with_resolved_system_display_label": sum(row["alias_appearance_count"] for row in alias_resolutions),
273
- "ad_products_with_resolved_system_display_label": len(alias_resolutions),
274
- "ad_products_in_natural": overlap_products,
275
- "ad_only_products": len(products) - overlap_products,
276
- "advertiser_shops_in_natural_by_exact_name": overlap_shops,
277
- "advertiser_shops_not_in_natural_by_exact_name": len(shops) - overlap_shops,
278
- "ad_products_repeated_in_ad_slots": sum(count > 1 for count in ad_product_counts),
279
- "ad_products_with_cross_placement_shop_name_difference": sum(row["cross_placement_shop_name_differs"] for row in products),
280
- "advertiser_shops_with_one_ad_product": sum(row["ad_product_count"] == 1 for row in shops),
281
- "natural_products_in_advertiser_shops_by_exact_name": len(natural_layout),
282
- "natural_products_not_advertised_by_same_shop_name": len(non_advertised_layout),
283
- "advertised_products_with_ad_price_variation": sum(row["ad_price_varies_across_appearances"] for row in products),
284
- "ad_rows_with_zero_displayed_payment_people": int(pd.to_numeric(ads["付款人数"], errors="coerce").eq(0).sum()) if "付款人数" in ads else None,
285
- }
286
- if sum(row["ad_product_count"] for row in shops) != len(products):
287
- raise ValueError("QA failed: shop-level product counts do not reconcile")
288
- if sum(row["ad_appearance_count"] for row in shops) != len(ads):
289
- raise ValueError("QA failed: shop-level appearance counts do not reconcile")
290
-
291
- return {
292
- "schema_version": "1.1",
293
- "source": {"file_name": source.name, "sha256": hashlib.sha256(source.read_bytes()).hexdigest(), "sheets": ["自然位", "广告位"]},
294
- "definitions": {
295
- "ad_appearance": "One row in the advertising-position sheet; not an impression, click, spend, or campaign count.",
296
- "ad_product": "One unique product ID in advertising positions, regardless of repeated appearances.",
297
- "advertiser_shop": "One resolved shop name in advertising positions; a known platform display label is assigned to the same-ID actual shop only when every seller name corroborates it. Matching names are not proof of a shared legal entity or brand.",
298
- "product_natural_overlap": "The same product ID appears in the natural-position sheet.",
299
- "shop_natural_overlap": "The same exact shop name appears in the natural-position sheet.",
300
- "natural_payment_people": "The source field 付款人数 on first-seen natural-position product rows; its time window is unspecified. A shop-level sum adds displayed product values and is not storewide sales or unique shop buyers.",
301
- "natural_shop_layout": "Natural-position products under the same exact shop name, split by whether that shop advertises the same product ID.",
302
- "ad_price": "The first advertising appearance's 现价 for each product ID; all observed ad prices and any variation remain in the product detail.",
303
- },
304
- "counts": counts,
305
- "ad_appearances_per_product_distribution": {str(k): v for k, v in repeat_distribution.items()},
306
- "shop_type_breakdown": {"advertising": type_breakdown(ads.assign(店铺类型=ads["_resolved_shop_type"]), "ads"), "natural": type_breakdown(natural, "natural")},
307
- "system_display_label_resolutions": alias_resolutions,
308
- "price_bands": {
309
- "boundaries_yuan": edges,
310
- "boundary_source": band_source,
311
- "interval_rule": "left-inclusive, right-exclusive",
312
- "advertised_unique_products": ad_price_distribution,
313
- "all_natural_unique_products": natural_price_distribution,
314
- "natural_products_in_advertiser_shops_not_advertised_by_same_shop": natural_non_ad_price_distribution,
315
- },
316
- "advertiser_shops": shops,
317
- "ad_products": products,
318
- "natural_products_of_advertiser_shops": natural_layout,
319
- "ad_appearance_records": [
320
- {"product_id": text(row["商品ID"]), "shop_name": text(row["_resolved_shop_name"]), "display_shop_name": text(row["店铺名称"]), "shop_resolution": text(row["_shop_resolution"]), "position": position(row.get("序号"))}
321
- for _, row in ads.iterrows()
322
- ],
323
- "quality": {"shop_identity_key": "resolved_shop_name_with_source_display_preserved", "product_identity_key": "product_id", "reconciled_shop_product_count": True, "reconciled_shop_appearance_count": True, "natural_payment_field": "付款人数", "ad_payment_field_used_for_sales_conclusion": False},
324
- }
325
-
326
-
327
- def main():
328
- parser = argparse.ArgumentParser(description=__doc__)
329
- parser.add_argument("--input", required=True, help="Cleaned XLSX with 自然位 and 广告位 sheets")
330
- parser.add_argument("--output", required=True, help="New JSON file; existing files are refused")
331
- parser.add_argument("--price-bands", help="Optional ascending comma-separated price boundaries in yuan; defaults to advertised-product quartiles")
332
- args = parser.parse_args()
333
- source = Path(args.input).expanduser().resolve()
334
- output = Path(args.output).expanduser().resolve()
335
- if not source.is_file() or source.suffix.lower() != ".xlsx":
336
- parser.error("input must be an existing XLSX file")
337
- if output.exists():
338
- parser.error("output already exists")
339
- try:
340
- price_bands = [float(value.strip()) for value in args.price_bands.split(",")] if args.price_bands else None
341
- except ValueError as error:
342
- parser.error(f"invalid --price-bands: {error}")
343
- result = analyze(source, price_bands)
344
- output.parent.mkdir(parents=True, exist_ok=True)
345
- with output.open("x", encoding="utf-8") as handle:
346
- json.dump(result, handle, ensure_ascii=False, indent=2)
347
- handle.write("\n")
348
- print(json.dumps({"output": str(output), "counts": result["counts"]}, ensure_ascii=False))
349
-
350
-
351
- if __name__ == "__main__":
352
- main()