Skill: wandervine/bbt-competitive-analysis (version 1.0.0)

--- name: competitive_analysis description: Poll competitive crawl triggers, aggregate the last 6 months of product, review, and QA data by category, produce structured analysis context and a report skeleton, upload outputs to OSS, then send a DingTalk summary. Use for database-driven scheduled competitor analysis in OpenClaw. compatibility: Requires Python 3.10+, PostgreSQL access, OSS access, and a DingTalk webhook. metadata: {"openclaw":{"requires":{"bins":["python3"],"env":["COMPETITIVE_ANALYSIS_DSN","DINGTALK_WEBHOOK","OSS_ENDPOINT","OSS_BUCKET","OSS_ACCESS_KEY_ID","OSS_ACCESS_KEY_SECRET"]}},"owner":"bbt","skillType":"enterprise","reportTemplate":".docs/竞品分析/BUBBLETREE儿童枕市场机会分析报告.pdf"} --- # Competitive Analysis ## Use When - The competitor analysis tables already exist. - You need to poll `competitive_crawl_trigger` on a schedule. - You need standardized reports grouped by `category`. - You need to send summaries to a DingTalk robot. Do not use this skill for: - one-off ad hoc analysis - open-ended research without database inputs - flexible report generation without a fixed template ## Required Inputs - Database connection: `COMPETITIVE_ANALYSIS_DSN` - OSS endpoint: `OSS_ENDPOINT` - OSS bucket: `OSS_BUCKET` - OSS access key id: `OSS_ACCESS_KEY_ID` - OSS access key secret: `OSS_ACCESS_KEY_SECRET` - DingTalk webhook: `DINGTALK_WEBHOOK` - Optional DingTalk signing secret: `DINGTALK_SECRET` - In OpenClaw, prefer environment injection through `skills.entries.competitive_analysis.env` ## Goal 1. Find unconsumed trigger rows where `status='success'`. 2. Load the last 6 months of product, review, and QA data. 3. Aggregate results by `category`. 4. Produce `analysis_context.json` for the host to continue narrative generation. 5. Generate a `Markdown/HTML` skeleton that follows the reference PDF structure. 6. Send a DingTalk summary. 7. Mark trigger rows as consumed after success. ## Entry Points Primary command: `python3 {baseDir}/scripts/run_report.py` Common arguments: - `--category CATEGORY` - `--since-months 6` - `--limit 20` ## Files - `SKILL.md`: skill entry instructions - `references/report-outline.md`: report structure contract - `references/data-contract.md`: data contract and field expectations - `references/openclaw-setup.md`: OpenClaw setup example - `scripts/run_report.py`: main CLI - `scripts/render_report.py`: Markdown/HTML rendering - `scripts/send_dingtalk.py`: DingTalk delivery - `analysis_context.json`: structured analysis context for the host runtime ## Rules - Follow the reference PDF for section order. - If fields are missing, keep the section and mark values as `未采集` or `待补充`. - Keep the CLI stateless and let an external scheduler trigger it. - Do not call any external LLM API from the script. - Let the host runtime generate deeper narrative content from `analysis_context.json` and `references/report-outline.md`. - In OpenClaw, prefer host-managed environment injection over `.env`. ## Minimal Workflow 1. Read `references/data-contract.md`. 2. Confirm that the trigger table already includes the consumption fields. 3. Configure `skills.entries.competitive_analysis.env` as shown in `references/openclaw-setup.md`. 4. Start a new OpenClaw session so the skill reloads. 5. Run `python3 {baseDir}/scripts/run_report.py` or invoke it from an external scheduler. 6. Read the generated `analysis_context.json`. 7. Let the host runtime generate the final narrative based on `references/report-outline.md`. 8. Validate the final output against the report outline. ## Success Criteria - New successful trigger rows are detected. - Reports are generated per `category`. - Section structure matches the reference report. - DingTalk receives the summary message. - Trigger rows are marked as consumed.

path: scripts/run_report.py
content: from __future__ import annotations

import argparse
import json
import os
import re
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any

try:
    import psycopg
    from psycopg.rows import dict_row
except ImportError as exc:  # pragma: no cover - runtime dependency check
    raise SystemExit(
        "缺少 psycopg。请先安装：python -m pip install psycopg[binary]"
    ) from exc

from render_report import ReportRenderer, ratio, sanitize_filename, star_score
from send_dingtalk import DingTalkWebhookClient, build_summary_markdown
from upload_oss import OssUploader


FEATURE_KEYWORDS = {
    "四季通用": ["四季", "四季通用"],
    "硅胶": ["硅胶"],
    "纱布": ["纱布"],
    "护脊": ["护脊", "护颈"],
    "可水洗": ["可水洗", "机洗", "水洗"],
    "分区": ["分区"],
    "记忆棉": ["记忆棉"],
    "蚕丝": ["蚕丝", "桑蚕丝"],
    "乳胶": ["乳胶"],
    "云朵": ["云朵"],
    "IP": ["ip", "卡通", "童话", "图案"],
}

NEED_KEYWORDS = {
    "柔软舒服": ["柔软", "舒服", "舒适"],
    "质量好": ["质量", "做工", "品质"],
    "尺寸大/够宽": ["尺寸", "够大", "够宽"],
    "护脊/护颈": ["护脊", "护颈"],
    "透气不闷汗": ["透气", "闷汗", "不闷"],
    "颜值高": ["颜值", "好看", "漂亮"],
    "可水洗/机洗": ["机洗", "水洗", "可水洗"],
    "高低合适": ["高度", "高低", "厚度"],
    "材质安全": ["安全", "异味小", "无毒"],
    "回购": ["回购", "复购"],
}

PAIN_KEYWORDS = {
    "异味": ["异味", "气味", "味道大"],
    "性价比/价格": ["贵", "价格", "性价比"],
    "缩水/变形": ["缩水", "变形", "塌陷"],
    "高度不合适": ["太高", "太低", "高度", "厚度"],
    "孩子不爱用": ["不爱用", "不用", "不喜欢"],
}

QA_BARRIERS = {
    "质量好吗": ["质量", "做工", "品质"],
    "适合多大年龄": ["年龄", "几岁", "多大"],
    "软硬度": ["软硬", "偏软", "偏硬"],
    "高度/厚度": ["高度", "厚度", "多高"],
    "怎么清洗": ["清洗", "机洗", "水洗"],
    "透气性": ["透气", "闷汗"],
    "味道": ["味道", "异味", "气味"],
}

ADVANTAGES = [
    ("纱布材质", "透气不闷汗", "⭐⭐⭐⭐⭐"),
    ("童话IP", "颜值与情感表达", "⭐⭐⭐⭐⭐"),
    ("小红书种草能力", "新品冷启动", "⭐⭐⭐⭐"),
    ("原创设计团队", "产品视觉差异化", "⭐⭐⭐⭐"),
    ("成熟供应链", "品质稳定性", "⭐⭐⭐⭐"),
    ("可水洗特性", "便捷清洁", "⭐⭐⭐⭐⭐"),
]


@dataclass
class CategoryDataset:
    category: str
    products: list[dict[str, Any]]
    reviews: list[dict[str, Any]]
    qas: list[dict[str, Any]]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="BBT 竞品分析报告 CLI")
    parser.add_argument("--dsn", default=os.getenv("COMPETITIVE_ANALYSIS_DSN"), help="PostgreSQL DSN")
    parser.add_argument("--category", default=None, help="只分析指定 category")
    parser.add_argument("--since-months", type=int, default=6, help="统计窗口（月）")
    parser.add_argument("--limit", type=int, default=20, help="单次最多消费多少条 trigger")
    parser.add_argument("--brand", default="BUBBLETREE", help="报告品牌名")
    parser.add_argument("--reporter", default="自动分析任务", help="报告人")
    return parser.parse_args()


class CompetitiveAnalysisRepository:
    def __init__(self, dsn: str) -> None:
        self.dsn = dsn

    def connect(self):
        return psycopg.connect(self.dsn, row_factory=dict_row)

    def ensure_trigger_columns(self, conn: psycopg.Connection) -> None:
        query = """
        SELECT column_name
        FROM information_schema.columns
        WHERE table_name = 'competitive_crawl_trigger'
          AND column_name IN (
              'is_consumed',
              'consumed_at',
              'consume_status',
              'consume_attempts',
              'consume_error',
              'last_report_path'
          )
        """
        with conn.cursor() as cur:
            cur.execute(query)
            present = {row["column_name"] for row in cur.fetchall()}
        required = {
            "is_consumed",
            "consumed_at",
            "consume_status",
            "consume_attempts",
            "consume_error",
            "last_report_path",
        }
        missing = required - present
        if missing:
            missing_text = ", ".join(sorted(missing))
            raise RuntimeError(
                f"competitive_crawl_trigger 缺少消费字段：{missing_text}。"
                "请先补齐数据库表结构后再运行该 skill。"
            )

    def fetch_pending_trigger_ids(self, conn: psycopg.Connection, limit: int) -> list[int]:
        sql = """
        SELECT id
        FROM competitive_crawl_trigger
        WHERE status = 'success'
          AND COALESCE(is_consumed, FALSE) = FALSE
        ORDER BY update_time ASC, id ASC
        LIMIT %s
        """
        with conn.cursor() as cur:
            cur.execute(sql, (limit,))
            return [row["id"] for row in cur.fetchall()]

    def increment_attempts(self, conn: psycopg.Connection, trigger_ids: list[int]) -> None:
        if not trigger_ids:
            return
        with conn.cursor() as cur:
            cur.execute(
                """
                UPDATE competitive_crawl_trigger
                SET consume_attempts = COALESCE(consume_attempts, 0) + 1,
                    consume_status = 'pending'
                WHERE id = ANY(%s)
                """,
                (trigger_ids,),
            )

    def mark_success(self, conn: psycopg.Connection, trigger_ids: list[int], manifest_path: str) -> None:
        if not trigger_ids:
            return
        with conn.cursor() as cur:
            cur.execute(
                """
                UPDATE competitive_crawl_trigger
                SET is_consumed = TRUE,
                    consumed_at = CURRENT_TIMESTAMP,
                    consume_status = 'success',
                    consume_error = NULL,
                    last_report_path = %s
                WHERE id = ANY(%s)
                """,
                (manifest_path, trigger_ids),
            )

    def mark_failure(self, conn: psycopg.Connection, trigger_ids: list[int], error_text: str) -> None:
        if not trigger_ids:
            return
        with conn.cursor() as cur:
            cur.execute(
                """
                UPDATE competitive_crawl_trigger
                SET consume_status = 'failed',
                    consume_error = %s
                WHERE id = ANY(%s)
                """,
                (error_text[:2000], trigger_ids),
            )

    def fetch_categories(self, conn: psycopg.Connection, since_date: date, category_filter: str | None) -> list[str]:
        sql = """
        SELECT DISTINCT category
        FROM competitive_product_list
        WHERE category IS NOT NULL
          AND COALESCE(etl_date, etl_time::date) >= %s
        """
        params: list[Any] = [since_date]
        if category_filter:
            sql += " AND category = %s"
            params.append(category_filter)
        sql += " ORDER BY category ASC"
        with conn.cursor() as cur:
            cur.execute(sql, params)
            return [row["category"] for row in cur.fetchall()]

    def fetch_dataset(self, conn: psycopg.Connection, category: str, since_date: date) -> CategoryDataset:
        products_sql = """
        SELECT product_id, category, shop_name, product_url, product_name, etl_date, etl_time
        FROM competitive_product_list
        WHERE category = %s
          AND COALESCE(etl_date, etl_time::date) >= %s
        """
        reviews_sql = """
        SELECT r.product_id, r.review_time, r.review_content, r.etl_date, r.etl_time
        FROM competitive_review r
        JOIN competitive_product_list p ON p.product_id = r.product_id
        WHERE p.category = %s
          AND COALESCE(r.review_time::date, r.etl_date, r.etl_time::date) >= %s
        """
        qa_sql = """
        SELECT q.product_id, q.question, q.answer, q.etl_date, q.etl_time
        FROM competitive_qa q
        JOIN competitive_product_list p ON p.product_id = q.product_id
        WHERE p.category = %s
          AND COALESCE(q.etl_date, q.etl_time::date) >= %s
        """
        with conn.cursor() as cur:
            cur.execute(products_sql, (category, since_date))
            products = cur.fetchall()
            cur.execute(reviews_sql, (category, since_date))
            reviews = cur.fetchall()
            cur.execute(qa_sql, (category, since_date))
            qas = cur.fetchall()
        return CategoryDataset(category=category, products=products, reviews=reviews, qas=qas)


def months_ago(months: int) -> date:
    return date.today() - timedelta(days=max(months, 1) * 30)


def normalize_brand(value: str | None) -> str:
    if not value:
        return "未识别品牌"
    result = value.strip()
    for suffix in ("旗舰店", "官方旗舰店", "官方店", "专营店", "专卖店", "企业店", "店"):
        result = result.replace(suffix, "")
    result = re.sub(r"\s+", "", result)
    return result[:20] or "未识别品牌"


def infer_brand(product: dict[str, Any]) -> str:
    shop_name = normalize_brand(product.get("shop_name"))
    if shop_name != "未识别品牌":
        return shop_name
    product_name = product.get("product_name") or ""
    [REDACTED_SECRET]"[\s/|·,【】（）()\-]", product_name)[0]
    return normalize_brand(token)


def collect_texts(dataset: CategoryDataset) -> list[str]:
    texts: list[str] = []
    for product in dataset.products:
        texts.append(product.get("product_name") or "")
    for review in dataset.reviews:
        texts.append(review.get("review_content") or "")
    for qa in dataset.qas:
        texts.append(qa.get("question") or "")
        texts.append(qa.get("answer") or "")
    return texts


def count_keyword_map(texts: list[str], mapping: dict[str, list[str]]) -> Counter[str]:
    counter: Counter[str] = Counter()
    joined = "\n".join(texts).lower()
    for label, keywords in mapping.items():
        for keyword in keywords:
            counter[label] += joined.count(keyword.lower())
    return counter


def brand_feature_profile(products: list[dict[str, Any]]) -> dict[str, Counter[str]]:
    profile: dict[str, Counter[str]] = defaultdict(Counter)
    for product in products:
        brand = infer_brand(product)
        text = (product.get("product_name") or "").lower()
        for label, keywords in FEATURE_KEYWORDS.items():
            for keyword in keywords:
                if keyword.lower() in text:
                    profile[brand][label] += 1
    return profile


def infer_positioning(feature_counter: Counter[str], brand_name: str) -> tuple[str, str]:
    brand_lower = brand_name.lower()
    if feature_counter["蚕丝"] > 0:
        return "高端型", "高端材质驱动"
    if feature_counter["IP"] > 0 or feature_counter["云朵"] > 0:
        return "外观型", "视觉或情绪表达"
    if feature_counter["护脊"] > 0 or feature_counter["分区"] > 0:
        return "功能型", "强调功能和支撑"
    if "官方" in brand_lower or feature_counter["可水洗"] > 0:
        return "便捷型", "强调便捷和基础功能"
    return "舒适型", "基础舒适诉求"


def top_rows(counter: Counter[str], label_name: str, total: int, limit: int = 10) -> list[dict[str, Any]]:
    rows = []
    for index, (name, count) in enumerate(counter.most_common(limit), start=1):
        rows.append({"排序": index, label_name: name, "频次": count, "占比": ratio(count, total)})
    return rows


def placeholder_price_distribution(brands: list[str]) -> list[dict[str, Any]]:
    brands = brands or ["待补充品牌"]
    return [{"品牌": brand, "主价格带": "未采集价格字段", "价格策略": "待补充"} for brand in brands[:10]]


def price_band_placeholder() -> list[dict[str, Any]]:
    return [
        {"价格带": "100元以下", "竞争状况": "未采集价格字段", "机会评估": "待补充"},
        {"价格带": "100-150元", "竞争状况": "未采集价格字段", "机会评估": "待补充"},
        {"价格带": "150-200元", "竞争状况": "未采集价格字段", "机会评估": "待补充"},
        {"价格带": "200-300元", "竞争状况": "未采集价格字段", "机会评估": "待补充"},
        {"价格带": "300元以上", "竞争状况": "未采集价格字段", "机会评估": "待补充"},
    ]


def build_report(dataset: CategoryDataset, brand: str, reporter: str, since_months: int) -> dict[str, Any]:
    texts = collect_texts(dataset)
    feature_counts = count_keyword_map(texts, FEATURE_KEYWORDS)
    need_counts = count_keyword_map([row.get("review_content") or "" for row in dataset.reviews], NEED_KEYWORDS)
    pain_counts = count_keyword_map([row.get("review_content") or "" for row in dataset.reviews], PAIN_KEYWORDS)
    qa_counts = count_keyword_map([row.get("question") or "" for row in dataset.qas], QA_BARRIERS)

    brands = Counter(infer_brand(product) for product in dataset.products)
    brand_profiles = brand_feature_profile(dataset.products)
    total_reviews = len(dataset.reviews)
    total_products = len(dataset.products)
    total_qas = len(dataset.qas)
    brand_total = sum(brands.values())

    dominant_material = feature_counts.most_common(1)[0][0] if feature_counts else "未明显集中"
    top_pain = pain_counts.most_common(1)[0][0] if pain_counts else "暂无明显痛点"
    whitespace_candidates = []
    if feature_counts["纱布"] == 0:
        whitespace_candidates.append(("纱布材质", 5, "当前样本中未发现明显纱布卖点"))
    if feature_counts["IP"] == 0:
        whitespace_candidates.append(("IP故事", 5, "样本缺少情绪化故事表达"))
    if feature_counts["可水洗"] == 0:
        whitespace_candidates.append(("整枕清洗", 4, "便捷清洗卖点较弱"))
    whitespace_candidates.append(("价格带洞察", 3, "当前缺少价格字段，建议补采后确认空白带"))

    if not whitespace_candidates:
        whitespace_candidates.append(("细分功能组合", 3, "可从人群与场景组合中继续寻找机会"))

    brand_ranking = []
    for index, (brand_name, count) in enumerate(brands.most_common(10), start=1):
        brand_ranking.append(
            {"排名": index, "品牌": brand_name, "商品数": count, "占比": ratio(count, brand_total)}
        )

    brand_matrix = []
    positioning_groups: dict[str, list[str]] = defaultdict(list)
    for brand_name, count in brands.most_common(10):
        features = brand_profiles.get(brand_name, Counter())
        positioning, positioning_summary = infer_positioning(features, brand_name)
        feature_labels = "、".join(name for name, _ in features.most_common(3)) or "待补充"
        diff_label = "、".join(name for name, _ in features.most_common(2)) or "待补充"
        brand_matrix.append(
            {
                "品牌": brand_name,
                "指标值": count,
                "占比": ratio(count, brand_total),
                "商品数": count,
                "定位": positioning,
                "核心功能": feature_labels,
                "差异化标签": diff_label,
            }
        )
        positioning_groups[positioning].append(brand_name)

    positioning_rows = []
    for positioning, names in positioning_groups.items():
        sample_counter = brand_profiles.get(names[0], Counter())
        _, core_characteristic = infer_positioning(sample_counter, names[0])
        positioning_rows.append(
            {
                "定位类型": positioning,
                "代表品牌": "、".join(names[:3]),
                "核心特点": core_characteristic,
                "机会分析": "保留结构化定位，但需结合价格与销量补强判断",
            }
        )

    whitespace_rows = [
        {"空白点": name, "机会等级": star_score(level), "说明": note}
        for name, level, note in whitespace_candidates[:5]
    ]

    feature_frequency = []
    for feature_name, count in feature_counts.most_common(10):
        feature_frequency.append(
            {"功能": feature_name, "出现次数": count, "占比": ratio(count, max(len(texts), 1)), "备注": "关键词命中"}
        )

    user_needs = top_rows(need_counts, "需求", max(total_reviews, 1))
    pain_points = []
    severity_map = {"异味": "高", "性价比/价格": "中", "缩水/变形": "中", "高度不合适": "中", "孩子不爱用": "低"}
    for index, (label, count) in enumerate(pain_counts.most_common(10), start=1):
        pain_points.append(
            {"排序": index, "痛点": label, "频次": count, "严重程度": severity_map.get(label, "中")}
        )

    purchase_barriers = []
    for label, count in qa_counts.most_common(10):
        purchase_barriers.append({"问题类型": label, "频次": count, "占比": ratio(count, max(total_qas, 1))})

    fallback_summary = (
        f"基于最近 {since_months} 个月 `{dataset.category}` 类目数据，"
        f"共纳入 {total_products} 个竞品商品、{total_reviews} 条评论、{total_qas} 条问答。"
        f"当前样本显示 `{dominant_material}` 相关卖点更常见，用户集中关注 "
        f"`{user_needs[0]['需求'] if user_needs else '舒适与安全'}`，"
        f"同时 `{top_pain}` 是最突出痛点。"
    )

    fallback_suggestions = [
        f"围绕 `{dataset.category}` 的高频需求做清晰卖点组合，优先强化 {user_needs[0]['需求'] if user_needs else '舒适与安全'}。",
        "价格字段当前待补充，建议先补齐价格与销量后再细化价格带决策。",
        "将评论痛点和问答疑虑前置到详情页与客服话术中，降低转化阻力。",
    ]
    fallback_feature_insights = [
        f"`{dominant_material}` 相关词在样本中更常见，可视为当前市场主流表达。",
        f"`{top_pain}` 是评论端最明显的问题，应优先纳入差异化改进。",
        f"`{whitespace_rows[0]['空白点']}` 可作为下一步重点验证方向。",
    ]
    fallback_product_definition = (
        f"围绕 `{dataset.category}` 构建“核心舒适体验 + 易清洁/易理解卖点 + 情绪化表达”的产品方案，"
        "价格待补充后再锁定主攻价格带。"
    )
    fallback_risk_controls = [
        "先小规模验证样本判断，再扩大生产和投放。",
        "补采价格、销量、年龄段字段，避免策略建立在信息缺口上。",
        "把评论高频痛点纳入质检和客服 SOP，降低上市初期负反馈。",
    ]

    report = {
        "title": f"{brand}{dataset.category}市场机会分析报告",
        "brand": brand,
        "category": dataset.category,
        "reporter": reporter,
        "analysis_date": datetime.now().strftime("%Y年%m月%d日"),
        "data_source": f"竞品商品({total_products}个) + 竞品评论({total_reviews}条) + 买家问答({total_qas}条)",
        "executive_summary": fallback_summary,
        "key_findings": [
            {"维度": "市场机会", "核心结论": whitespace_rows[0]["说明"]},
            {"维度": "材质趋势", "核心结论": f"{dominant_material} 相关表达更常见"},
            {"维度": "最大痛点", "核心结论": top_pain},
            {"维度": "竞品短板", "核心结论": "价格与品牌定位信息仍需补采，当前市场表达同质化"},
            {"维度": "你们优势", "核心结论": "纱布、IP、内容种草能力可作为差异化抓手"},
        ],
        "core_suggestions": fallback_suggestions[:3],
        "sample_overview": [
            {"数据类型": "竞品样本", "数量": total_products, "说明": f"{dataset.category} 商品清单"},
            {"数据类型": "买家评论", "数量": total_reviews, "说明": "评论文本采集"},
            {"数据类型": "买家问答", "数量": total_qas, "说明": "问答文本采集"},
            {"数据类型": "品牌数", "数量": len(brands), "说明": "按店铺/标题启发式推断"},
        ],
        "brand_ranking": brand_ranking or [{"排名": 1, "品牌": "待补充", "商品数": 0, "占比": "0.0%"}],
        "brand_matrix": brand_matrix or [{"品牌": "待补充", "指标值": 0, "占比": "0.0%", "商品数": 0, "定位": "待补充", "核心功能": "待补充", "差异化标签": "待补充"}],
        "brand_positioning": positioning_rows or [{"定位类型": "待补充", "代表品牌": "待补充", "核心特点": "待补充", "机会分析": "待补充"}],
        "price_distribution": placeholder_price_distribution([name for name, _ in brands.most_common(10)]),
        "brand_strategy": [
            {"打法": "功能表达", "代表品牌": "、".join(name for name, _ in brands.most_common(3)) or "待补充", "你们的应对": "围绕高频需求做更清晰的组合卖点"},
            {"打法": "材质表达", "代表品牌": dominant_material, "你们的应对": "用差异材质或触感体验形成记忆点"},
            {"打法": "详情页答疑", "代表品牌": "头部竞品", "你们的应对": "把 QA 高频问题前置到内容与客服话术"},
        ],
        "whitespace": whitespace_rows,
        "feature_frequency": feature_frequency or [{"功能": "待补充", "出现次数": 0, "占比": "0.0%", "备注": "无数据"}],
        "feature_insights": fallback_feature_insights[:5],
        "price_band_analysis": price_band_placeholder(),
        "user_needs": user_needs or [{"排序": 1, "需求": "待补充", "频次": 0, "占比": "0.0%"}],
        "pain_points": pain_points or [{"排序": 1, "痛点": "待补充", "频次": 0, "严重程度": "中"}],
        "purchase_barriers": purchase_barriers or [{"问题类型": "待补充", "频次": 0, "占比": "0.0%"}],
        "advantages": [{"你们的优势": name, "对应用户需求": need, "匹配度": score} for name, need, score in ADVANTAGES],
        "differentiation": [
            {"差异化维度": whitespace_rows[0]["空白点"], "竞品现状": whitespace_rows[0]["说明"], "你们机会": "优先验证并占位", "强度": whitespace_rows[0]["机会等级"]},
            {"差异化维度": "用户痛点修复", "竞品现状": f"当前突出痛点为 {top_pain}", "你们机会": "将痛点反向设计为核心卖点", "强度": "⭐⭐⭐⭐"},
            {"差异化维度": "内容表达", "竞品现状": "问答端顾虑分散", "你们机会": "用种草内容与详情页统一解释", "强度": "⭐⭐⭐⭐"},
        ],
        "product_definition": fallback_product_definition,
        "sku_suggestions": [
            {"SKU": "基础款", "定价": "待补充", "目标年龄": "待补充", "规格": f"{dataset.category}基础配置"},
            {"SKU": "升级款", "定价": "待补充", "目标年龄": "待补充", "规格": f"{dataset.category}差异化配置"},
        ],
        "selling_points": [
            f"解决 `{top_pain}` 的明确方案",
            f"围绕 `{user_needs[0]['需求'] if user_needs else '舒适与安全'}` 做一眼可懂的表达",
            "用内容化或 IP 化叙事提升记忆点",
        ],
        "product_actions": [
            {"阶段": "P0", "行动": "确认核心卖点与采样验证", "时间": "1-2周", "负责人": "产品"},
            {"阶段": "P1", "行动": "完成样品和体验验证", "时间": "2周", "负责人": "研发"},
            {"阶段": "P2", "行动": "整理详情页和客服答疑素材", "时间": "1周", "负责人": "运营"},
        ],
        "go_to_market_actions": [
            {"阶段": "P3", "行动": "内容种草测试", "时间": "2周", "渠道": "小红书/抖音"},
            {"阶段": "P4", "行动": "详情页迭代和首轮投放", "时间": "2周", "渠道": "电商平台"},
            {"阶段": "P5", "行动": "复盘评论与 QA 反馈", "时间": "持续", "渠道": "全渠道"},
        ],
        "goals": [
            {"指标": "转化效率", "3个月目标": "建立稳定基线", "6个月目标": "形成可复制打法"},
            {"指标": "内容验证", "3个月目标": "完成首轮卖点测试", "6个月目标": "沉淀高转化素材"},
            {"指标": "用户反馈", "3个月目标": "降低高频痛点", "6个月目标": "形成口碑词"},
        ],
        "risks": [
            {"风险": "关键字段缺失", "概率": "中", "影响": "高", "应对": "补采价格、销量、年龄层字段"},
            {"风险": "启发式品牌识别偏差", "概率": "中", "影响": "中", "应对": "补品牌主数据校准"},
            {"风险": "重复消费 trigger", "概率": "低", "影响": "中", "应对": "依赖消费字段和状态回写"},
        ],
        "risk_controls": fallback_risk_controls[:5],
        "methods": [
            "竞品商品清单聚合",
            "评论关键词统计与痛点提取",
            "买家问答意图分类",
            "启发式品牌与功能标签识别",
        ],
        "sources": [
            "competitive_product_list",
            "competitive_review",
            "competitive_qa",
            "competitive_crawl_trigger",
        ],
        "host_analysis_context": {
            "instruction": "该文件由脚本完成取数与基础聚合，执行摘要、建议、机会判断等深度分析应由 skill 宿主基于本上下文和 references/report-outline.md 继续生成。",
            "category": dataset.category,
            "since_months": since_months,
            "counts": {
                "product_count": total_products,
                "review_count": total_reviews,
                "qa_count": total_qas,
                "brand_count": len(brands),
            },
            "derived_insights": {
                "dominant_material": dominant_material,
                "top_need": user_needs[0]["需求"] if user_needs else "舒适与安全",
                "top_pain": top_pain,
                "whitespace": whitespace_rows,
            },
        },
    }
    return report


def write_analysis_context(report: dict[str, Any], rendered_dir: Path) -> Path:
    context_path = rendered_dir / "analysis_context.json"
    context_payload = {
        "title": report["title"],
        "brand": report["brand"],
        "category": report["category"],
        "analysis_date": report["analysis_date"],
        "data_source": report["data_source"],
        "report_outline_source": "skills/competitive_analysis/references/report-outline.md",
        "host_analysis_context": report["host_analysis_context"],
        "report_payload": report,
    }
    context_path.write_text(json.dumps(context_payload, ensure_ascii=False, indent=2), encoding="utf-8")
    return context_path


def main() -> int:
    args = parse_args()
    if not args.dsn:
        raise SystemExit("缺少 PostgreSQL DSN，请通过 --dsn 或 COMPETITIVE_ANALYSIS_DSN 传入。")

    repository = CompetitiveAnalysisRepository(args.dsn)
    oss_uploader = OssUploader.from_env()
    if not oss_uploader:
        raise SystemExit(
            "缺少 OSS 配置，请设置 "
            "OSS_ENDPOINT/OSS_BUCKET/OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET。"
        )

    webhook = os.getenv("DINGTALK_WEBHOOK")
    [REDACTED_SECRET]"DINGTALK_SECRET")
    if not webhook:
        raise SystemExit("缺少 DINGTALK_WEBHOOK。")
    dingtalk = DingTalkWebhookClient(webhook, secret)

    since_date = months_ago(args.since_months)
    with TemporaryDirectory(prefix="competitive-analysis-") as temp_dir:
        output_root = Path(temp_dir).resolve() / datetime.now().strftime("%Y%m%d-%H%M%S")
        output_root.mkdir(parents=True, exist_ok=True)
        renderer = ReportRenderer(output_root)

        with repository.connect() as conn:
            repository.ensure_trigger_columns(conn)
            trigger_ids = repository.fetch_pending_trigger_ids(conn, args.limit)
            if not trigger_ids:
                print("没有待消费的 success trigger，退出。")
                return 0

            repository.increment_attempts(conn, trigger_ids)
            conn.commit()

            try:
                categories = repository.fetch_categories(conn, since_date, args.category)
                reports_manifest: dict[str, Any] = {
                    "generated_at": datetime.now().isoformat(),
                    "trigger_ids": trigger_ids,
                    "since_date": since_date.isoformat(),
                    "categories": [],
                }

                if not categories:
                    reports_manifest["message"] = "分析窗口内无可分析 category"
                else:
                    for category in categories:
                        dataset = repository.fetch_dataset(conn, category, since_date)
                        report = build_report(dataset, args.brand, args.reporter, args.since_months)
                        rendered = renderer.render(report)
                        context_path = write_analysis_context(report, rendered.markdown_path.parent)

                        rel_base = f"{output_root.name}/{rendered.markdown_path.parent.name}"
                        md_key = oss_uploader.key_for(f"{rel_base}/report.md")
                        html_key = oss_uploader.key_for(f"{rel_base}/report.html")
                        ctx_key = oss_uploader.key_for(f"{rel_base}/analysis_context.json")
                        uploaded = {
                            "report_md": oss_uploader.upload_file(rendered.markdown_path, md_key).public_url,
                            "report_html": oss_uploader.upload_file(rendered.html_path, html_key).public_url,
                            "analysis_context": oss_uploader.upload_file(context_path, ctx_key).public_url,
                        }

                        summary = build_summary_markdown(
                            report_title=report["title"],
                            category=category,
                            executive_summary=report["executive_summary"],
                            key_findings=report["key_findings"],
                            markdown_url=uploaded["report_md"],
                            html_url=uploaded["report_html"],
                        )
                        result = dingtalk.send_markdown(report["title"], summary)
                        if not result.ok:
                            raise RuntimeError(f"钉钉发送失败：{result.response}")

                        manifest_entry: dict[str, Any] = {
                            "category": category,
                            "report_dir": rendered.markdown_path.parent.name,
                            "oss": uploaded,
                        }
                        reports_manifest["categories"].append(manifest_entry)

                manifest_path = output_root / f"{sanitize_filename(args.brand)}-manifest.json"
                manifest_path.write_text(json.dumps(reports_manifest, ensure_ascii=False, indent=2), encoding="utf-8")

                manifest_key = oss_uploader.key_for(f"{output_root.name}/{manifest_path.name}")
                manifest_url = oss_uploader.upload_file(manifest_path, manifest_key).public_url

                repository.mark_success(conn, trigger_ids, manifest_url)
                conn.commit()

                print(f"Manifest URL: {manifest_url}")
                print("报告生成、上传并发送完成，本地临时文件已清理。")
                return 0
            except Exception as exc:
                repository.mark_failure(conn, trigger_ids, str(exc))
                conn.commit()
                raise


if __name__ == "__main__":
    sys.exit(main())


path: scripts/upload_oss.py
content: from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional


@dataclass
class OssUploadResult:
    object_key: str
    public_url: str


class OssUploader:
    """
    Upload local files to Alibaba Cloud OSS and return public URLs.

    This uploader is intentionally optional at runtime:
    - If required env vars are missing, treat OSS as disabled.
    - If OSS is enabled but `oss2` is missing, fail with a clear message.
    """

    def __init__(
        self,
        endpoint: str,
        bucket: str,
        access_key_id: str,
        access_key_secret: str,
        prefix: str = "",
        public_base_url: str | None = None,
    ) -> None:
        endpoint = endpoint.strip()
        if endpoint and not endpoint.startswith(("http://", "https://")):
            endpoint = "https://" + endpoint
        self.endpoint = endpoint
        self.endpoint_host = endpoint.removeprefix("https://").removeprefix("http://")
        self.bucket = bucket
        self.[REDACTED_SECRET]
        self.[REDACTED_SECRET]
        self.prefix = prefix.strip("/")
        self.public_base_url = (public_base_url or "").rstrip("/")

    @staticmethod
    def from_env() -> Optional["OssUploader"]:
        endpoint = os.getenv("OSS_ENDPOINT", "").strip()
        bucket = os.getenv("OSS_BUCKET", "").strip()
        [REDACTED_SECRET]"OSS_ACCESS_KEY_ID", "").strip()
        [REDACTED_SECRET]"OSS_ACCESS_KEY_SECRET", "").strip()
        prefix = os.getenv("OSS_PREFIX", "bbt-skills/competitive-analysis").strip()
        public_base_url = os.getenv("OSS_PUBLIC_BASE_URL", "").strip() or None

        required = [endpoint, bucket, access_key_id, access_key_secret]
        if not all(required):
            return None
        return OssUploader(
            endpoint=endpoint,
            bucket=bucket,
            [REDACTED_SECRET],
            [REDACTED_SECRET],
            prefix=prefix,
            public_base_url=public_base_url,
        )

    def upload_file(self, local_path: Path, object_key: str) -> OssUploadResult:
        try:
            import oss2  # type: ignore
        except ImportError as exc:
            raise RuntimeError("已配置 OSS_* 环境变量，但缺少依赖 oss2。请安装：python3 -m pip install oss2") from exc

        auth = oss2.Auth(self.access_key_id, self.access_key_secret)
        bucket = oss2.Bucket(auth, self.endpoint, self.bucket)
        bucket.put_object_from_file(object_key, str(local_path))
        return OssUploadResult(object_key=object_key, public_url=self._public_url(object_key))

    def key_for(self, relative_path: str) -> str:
        cleaned = relative_path.lstrip("/")
        if self.prefix:
            return f"{self.prefix}/{cleaned}"
        return cleaned

    def _public_url(self, object_key: str) -> str:
        if self.public_base_url:
            return f"{self.public_base_url}/{object_key}"

        # Derive a public URL (works for public-read buckets or public endpoints).
        # Example endpoint: oss-cn-hangzhou.aliyuncs.com
        # Public URL: https://{bucket}.{endpoint}/{object_key}
        return f"https://{self.bucket}.{self.endpoint_host}/{object_key}"


path: scripts/render_report.py
content: from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from html import escape
from pathlib import Path
from typing import Any, Iterable


def sanitize_filename(value: str) -> str:
    cleaned = "".join(char if char.isalnum() or char in ("-", "_") else "-" for char in value.strip())
    return cleaned.strip("-") or "report"


def ratio(value: int, total: int) -> str:
    if total <= 0:
        return "0.0%"
    return f"{(value / total) * 100:.1f}%"


def star_score(level: int) -> str:
    level = max(1, min(level, 5))
    return "⭐" * level


@dataclass
class RenderedReport:
    markdown_path: Path
    html_path: Path


class ReportRenderer:
    def __init__(self, output_dir: Path) -> None:
        self.output_dir = output_dir

    def render(self, report: dict[str, Any]) -> RenderedReport:
        timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
        slug = sanitize_filename(f"{report['brand']}-{report['category']}-{timestamp}")
        target_dir = self.output_dir / slug
        target_dir.mkdir(parents=True, exist_ok=True)

        markdown = self._build_markdown(report)
        html = self._build_html(report, markdown)

        markdown_path = target_dir / "report.md"
        html_path = target_dir / "report.html"
        markdown_path.write_text(markdown, encoding="utf-8")
        html_path.write_text(html, encoding="utf-8")
        return RenderedReport(markdown_path=markdown_path, html_path=html_path)

    def _build_markdown(self, report: dict[str, Any]) -> str:
        lines: list[str] = [
            f"# {report['title']}",
            "",
            f"报告人：{report['reporter']}",
            f"分析日期：{report['analysis_date']}",
            f"数据来源：{report['data_source']}",
            "",
            "## 执行摘要",
            report["executive_summary"],
            "",
            "### 关键发现",
            self._table_to_markdown(report["key_findings"]),
            "",
            "### 核心建议",
        ]
        lines.extend(f"- {item}" for item in report["core_suggestions"])

        lines.extend(
            [
                "",
                "## 第一部分：数据概览",
                "### 1.1 分析样本",
                self._table_to_markdown(report["sample_overview"]),
                "",
                "### 1.2 样本品牌销售排名",
                self._table_to_markdown(report["brand_ranking"]),
                "",
                "## 第二部分：品牌维度深度分析",
                "### 2.1 核心竞品品牌矩阵",
                self._table_to_markdown(report["brand_matrix"]),
                "",
                "### 2.2 品牌定位分类",
                self._table_to_markdown(report["brand_positioning"]),
                "",
                "### 2.3 各品牌价格带分布",
                self._table_to_markdown(report["price_distribution"]),
                "",
                "### 2.4 品牌打法总结",
                self._table_to_markdown(report["brand_strategy"]),
                "",
                "### 2.5 竞争空白点",
                self._table_to_markdown(report["whitespace"]),
                "",
                "## 第三部分：功能趋势分析",
                "### 3.1 竞品功能词频",
                self._table_to_markdown(report["feature_frequency"]),
                "",
                "### 3.2 洞察",
            ]
        )
        lines.extend(f"- {item}" for item in report["feature_insights"])

        lines.extend(
            [
                "",
                "## 第四部分：价格带分析",
                self._table_to_markdown(report["price_band_analysis"]),
                "",
                "## 第五部分：用户需求洞察",
                "### 5.1 用户需求排名",
                self._table_to_markdown(report["user_needs"]),
                "",
                "### 5.2 用户痛点",
                self._table_to_markdown(report["pain_points"]),
                "",
                "### 5.3 购买决策障碍",
                self._table_to_markdown(report["purchase_barriers"]),
                "",
                f"## 第六部分：{report['brand']}机会分析",
                "### 6.1 竞争优势",
                self._table_to_markdown(report["advantages"]),
                "",
                "### 6.2 差异化机会",
                self._table_to_markdown(report["differentiation"]),
                "",
                "### 6.3 产品机会",
                "产品定义建议：",
                report["product_definition"],
                "",
                "SKU建议：",
                self._table_to_markdown(report["sku_suggestions"]),
                "",
                "核心卖点：",
            ]
        )
        lines.extend(f"- {item}" for item in report["selling_points"])

        lines.extend(
            [
                "",
                "## 第七部分：行动计划",
                "### 7.1 产品开发",
                self._table_to_markdown(report["product_actions"]),
                "",
                "### 7.2 上市准备",
                self._table_to_markdown(report["go_to_market_actions"]),
                "",
                "### 7.3 预期目标",
                self._table_to_markdown(report["goals"]),
                "",
                "## 第八部分：风险与建议",
                "### 8.1 潜在风险",
                self._table_to_markdown(report["risks"]),
                "",
                "### 8.2 风险控制建议",
            ]
        )
        lines.extend(f"- {item}" for item in report["risk_controls"])

        lines.extend(
            [
                "",
                "## 附录",
                "### 分析方法",
            ]
        )
        lines.extend(f"- {item}" for item in report["methods"])
        lines.extend(["", "### 数据来源"])
        lines.extend(f"- {item}" for item in report["sources"])
        lines.extend(["", "报告完毕。"])
        return "\n".join(lines).strip() + "\n"

    def _build_html(self, report: dict[str, Any], markdown: str) -> str:
        sections = [
            "<!DOCTYPE html>",
            "<html lang='zh-CN'>",
            "<head>",
            "<meta charset='utf-8'>",
            f"<title>{escape(report['title'])}</title>",
            "<style>",
            "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:1080px;margin:32px auto;padding:0 20px;color:#1f2328;line-height:1.7;}",
            "table{border-collapse:collapse;width:100%;margin:12px 0 24px;}",
            "th,td{border:1px solid #d0d7de;padding:8px 10px;text-align:left;vertical-align:top;}",
            "th{background:#f6f8fa;}",
            "h1,h2,h3{margin-top:24px;}",
            "ul{padding-left:20px;}",
            "code{background:#f6f8fa;padding:2px 4px;border-radius:4px;}",
            "</style>",
            "</head>",
            "<body>",
            f"<h1>{escape(report['title'])}</h1>",
            f"<p>报告人：{escape(report['reporter'])}<br>分析日期：{escape(report['analysis_date'])}<br>数据来源：{escape(report['data_source'])}</p>",
            self._section("执行摘要", escape(report["executive_summary"]).replace("\n", "<br>")),
            "<h3>关键发现</h3>",
            self._table_to_html(report["key_findings"]),
            "<h3>核心建议</h3>",
            self._list_to_html(report["core_suggestions"]),
            "<h2>第一部分：数据概览</h2>",
            "<h3>1.1 分析样本</h3>",
            self._table_to_html(report["sample_overview"]),
            "<h3>1.2 样本品牌销售排名</h3>",
            self._table_to_html(report["brand_ranking"]),
            "<h2>第二部分：品牌维度深度分析</h2>",
            "<h3>2.1 核心竞品品牌矩阵</h3>",
            self._table_to_html(report["brand_matrix"]),
            "<h3>2.2 品牌定位分类</h3>",
            self._table_to_html(report["brand_positioning"]),
            "<h3>2.3 各品牌价格带分布</h3>",
            self._table_to_html(report["price_distribution"]),
            "<h3>2.4 品牌打法总结</h3>",
            self._table_to_html(report["brand_strategy"]),
            "<h3>2.5 竞争空白点</h3>",
            self._table_to_html(report["whitespace"]),
            "<h2>第三部分：功能趋势分析</h2>",
            "<h3>3.1 竞品功能词频</h3>",
            self._table_to_html(report["feature_frequency"]),
            "<h3>3.2 洞察</h3>",
            self._list_to_html(report["feature_insights"]),
            "<h2>第四部分：价格带分析</h2>",
            self._table_to_html(report["price_band_analysis"]),
            "<h2>第五部分：用户需求洞察</h2>",
            "<h3>5.1 用户需求排名</h3>",
            self._table_to_html(report["user_needs"]),
            "<h3>5.2 用户痛点</h3>",
            self._table_to_html(report["pain_points"]),
            "<h3>5.3 购买决策障碍</h3>",
            self._table_to_html(report["purchase_barriers"]),
            f"<h2>第六部分：{escape(report['brand'])}机会分析</h2>",
            "<h3>6.1 竞争优势</h3>",
            self._table_to_html(report["advantages"]),
            "<h3>6.2 差异化机会</h3>",
            self._table_to_html(report["differentiation"]),
            "<h3>6.3 产品机会</h3>",
            f"<p><strong>产品定义建议：</strong>{escape(report['product_definition'])}</p>",
            "<p><strong>SKU建议：</strong></p>",
            self._table_to_html(report["sku_suggestions"]),
            "<p><strong>核心卖点：</strong></p>",
            self._list_to_html(report["selling_points"]),
            "<h2>第七部分：行动计划</h2>",
            "<h3>7.1 产品开发</h3>",
            self._table_to_html(report["product_actions"]),
            "<h3>7.2 上市准备</h3>",
            self._table_to_html(report["go_to_market_actions"]),
            "<h3>7.3 预期目标</h3>",
            self._table_to_html(report["goals"]),
            "<h2>第八部分：风险与建议</h2>",
            "<h3>8.1 潜在风险</h3>",
            self._table_to_html(report["risks"]),
            "<h3>8.2 风险控制建议</h3>",
            self._list_to_html(report["risk_controls"]),
            "<h2>附录</h2>",
            "<h3>分析方法</h3>",
            self._list_to_html(report["methods"]),
            "<h3>数据来源</h3>",
            self._list_to_html(report["sources"]),
            "<hr>",
            "<details><summary>Markdown 原文</summary>",
            f"<pre>{escape(markdown)}</pre>",
            "</details>",
            "</body></html>",
        ]
        return "\n".join(sections)

    def _section(self, title: str, body: str) -> str:
        return f"<h2>{escape(title)}</h2><p>{body}</p>"

    def _list_to_html(self, items: Iterable[str]) -> str:
        return "<ul>" + "".join(f"<li>{escape(item)}</li>" for item in items) + "</ul>"

    def _table_to_markdown(self, rows: list[dict[str, Any]]) -> str:
        if not rows:
            return "| 说明 |\n| --- |\n| 无数据 |\n"
        headers = list(rows[0].keys())
        lines = [
            "| " + " | ".join(headers) + " |",
            "| " + " | ".join("---" for _ in headers) + " |",
        ]
        for row in rows:
            lines.append("| " + " | ".join(str(row.get(header, "")) for header in headers) + " |")
        return "\n".join(lines)

    def _table_to_html(self, rows: list[dict[str, Any]]) -> str:
        if not rows:
            return "<table><thead><tr><th>说明</th></tr></thead><tbody><tr><td>无数据</td></tr></tbody></table>"
        headers = list(rows[0].keys())
        head = "".join(f"<th>{escape(header)}</th>" for header in headers)
        body_rows = []
        for row in rows:
            cells = "".join(f"<td>{escape(str(row.get(header, '')))}</td>" for header in headers)
            body_rows.append(f"<tr>{cells}</tr>")
        return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body_rows)}</tbody></table>"


path: scripts/send_dingtalk.py
content: from __future__ import annotations

import base64
import hashlib
import hmac
import json
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any


@dataclass
class DingTalkResult:
    ok: bool
    response: dict[str, Any]


class DingTalkWebhookClient:
    def __init__(self, webhook: str, secret: str | None = None, timeout_seconds: int = 20) -> None:
        self.webhook = webhook
        self.[REDACTED_SECRET]
        self.timeout_seconds = timeout_seconds

    def send_markdown(self, title: str, markdown_text: str) -> DingTalkResult:
        payload = {
            "msgtype": "markdown",
            "markdown": {"title": title, "text": markdown_text},
        }
        response = self._post(payload)
        success = response.get("errcode") in (0, "0", None)
        return DingTalkResult(ok=success, response=response)

    def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
        url = self._signed_url()
        request = urllib.request.Request(
            url=url,
            data=json.dumps(payload).encode("utf-8"),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
            return json.loads(response.read().decode("utf-8"))

    def _signed_url(self) -> str:
        if not self.[REDACTED_SECRET] self.webhook

        # DingTalk signed webhook:
        # sign = urlEncode(base64(hmac_sha256(secret, f"{timestamp}\n{secret}")))
        timestamp = str(round(time.time() * 1000))
        string_to_sign = f"{timestamp}\n{self.secret}".encode("utf-8")
        hmac_digest = hmac.new(self.secret.encode("utf-8"), string_to_sign, hashlib.sha256).digest()
        sign_b64 = base64.b64encode(hmac_digest).decode("utf-8")

        parsed = urllib.parse.urlparse(self.webhook)
        query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
        query.append(("timestamp", timestamp))
        # NOTE: let urlencode do the encoding exactly once
        query.append(("sign", sign_b64))
        new_query = urllib.parse.urlencode(query, quote_via=urllib.parse.quote_plus)
        return urllib.parse.urlunparse(parsed._replace(query=new_query))


def build_summary_markdown(
    report_title: str,
    category: str,
    executive_summary: str,
    key_findings: list[dict[str, Any]],
    markdown_url: str,
    html_url: str,
) -> str:
    lines = [
        f"## {report_title}",
        "",
        f"- 品类：`{category}`",
        f"- Markdown：{markdown_url}",
        f"- HTML：{html_url}",
        "",
        "### 执行摘要",
        executive_summary,
        "",
        "### 关键发现",
    ]
    for item in key_findings:
        dimension = item.get("维度") or item.get("项目") or "发现"
        conclusion = item.get("核心结论") or item.get("结论") or ""
        lines.append(f"- {dimension}：{conclusion}")
    return "\n".join(lines)


path: references/data-contract.md
content: ---
title: Competitive Analysis Data Contract
source: .docs/竞品分析/竞品分析建表.sql
---

# Competitive Analysis Data Contract

This file defines the data contract, field usage, and trigger-table expectations used by the CLI.

## Existing Tables

### `competitive_product_list`

Purpose:

- stores the product list to analyze
- provides the main `category` aggregation key
- acts as the parent table for reviews and QA

Key fields:

- `category`
- `product_id`
- `shop_name`
- `product_url`
- `product_name`
- `etl_date`
- `etl_time`

### `competitive_review`

Purpose:

- stores product reviews
- used to extract needs, pain points, sentiment, and feature keywords

Key fields:

- `product_id`
- `review_time`
- `review_content`
- `etl_date`
- `etl_time`

### `competitive_qa`

Purpose:

- stores product QA
- used to extract purchase concerns and pre-sale questions

Key fields:

- `product_id`
- `question`
- `answer`
- `etl_date`
- `etl_time`

### `competitive_crawl_trigger`

Purpose:

- records crawl completion events
- acts as the trigger source for the downstream CLI

Existing key fields:

- `update_time`
- `task_type`
- `status`
- `etl_date`
- `etl_time`

## Required Trigger Fields

This skill assumes these fields already exist on `competitive_crawl_trigger`. The skill package does not create or alter tables.

Required fields:

- `is_consumed BOOLEAN NOT NULL DEFAULT FALSE`
- `consumed_at TIMESTAMP NULL`
- `consume_status VARCHAR(20) NOT NULL DEFAULT 'pending'`
- `consume_attempts INTEGER NOT NULL DEFAULT 0`
- `consume_error TEXT NULL`
- `last_report_path TEXT NULL`

Why they exist:

- `is_consumed`: prevents duplicate processing
- `consumed_at`: records when consumption finished
- `consume_status`: tracks `pending/success/failed`
- `consume_attempts`: tracks retry attempts
- `consume_error`: stores the latest failure reason
- `last_report_path`: stores the latest report locator, typically the OSS manifest URL

## Query Rules

### Time Window

- default analysis window is the last 6 months
- product-level filtering uses `competitive_product_list.etl_date`
- review-level filtering prefers `review_time`, then falls back to `etl_time`
- QA-level filtering uses `etl_time`

### Aggregation Dimensions

- primary dimension: `category`
- secondary dimensions: brand, shop, feature terms, need terms, pain terms, QA intent categories

### Joins

- `competitive_review.product_id = competitive_product_list.product_id`
- `competitive_qa.product_id = competitive_product_list.product_id`

## Current Gaps

The following metrics from the reference PDF cannot be reliably computed from the current schema alone:

- sales revenue
- price
- explicit brand field
- target age
- explicit product positioning

Handling rules:

1. Keep the report structure unchanged.
2. Use explainable heuristics when inference is possible.
3. Use `未采集` or `待补充` when inference is not possible.
4. Never fabricate precise values.

## Heuristic Rules

### Brand

- prefer `shop_name`
- strip suffixes such as `旗舰店`, `专营店`, and `官方店`
- if missing, fall back to the leading part of `product_name`

### Core Features And Tags

- match keywords from `product_name`, reviews, and QA text
- current default dictionaries include:
  - feature terms: `护脊`, `分区`, `可水洗`, `四季通用`, `记忆棉`
  - material terms: `硅胶`, `纱布`, `蚕丝`, `乳胶`
  - visual or emotion terms: `云朵`, `IP`, `卡通`

### Needs And Pain Points

- count them using review keyword dictionaries
- feed the top needs and pain points into Section 5

### Purchase Barriers

- match intent keywords from QA `question`
- examples: quality, age, softness, height, cleaning, breathability, smell

## Pre-run Validation

The CLI should validate:

1. `competitive_crawl_trigger` contains all required consumption fields
2. there are unconsumed rows with `status='success'`
3. at least one `category` has product data inside the analysis window

If the consumption fields are missing, fail fast and ask the operator to fix the database schema first.


path: references/openclaw-setup.md
content: # OpenClaw Setup

When migrating this skill into OpenClaw, prefer OpenClaw-managed environment injection instead of `.env`.

## Placement

Place this directory under the OpenClaw workspace at `skills/competitive_analysis/`.

OpenClaw will automatically discover the skill from the workspace `skills/` directory.

## `openclaw.json` 示例

Add the following to `~/.openclaw/openclaw.json` or the workspace config:

```json5
{
  skills: {
    entries: {
      competitive_analysis: {
        enabled: true,
        env: {
          COMPETITIVE_ANALYSIS_DSN: "postgresql://postgres:password@127.0.0.1:5432/bbt",
          DINGTALK_WEBHOOK: "https://oapi.dingtalk.com/robot/send?access_token=your_access_token",
          DINGTALK_SECRET: "",
          OSS_ENDPOINT: "oss-cn-hangzhou.aliyuncs.com",
          OSS_BUCKET: "your-bucket-name",
          OSS_ACCESS_KEY_ID: "your-access-key-id",
          OSS_ACCESS_KEY_SECRET: "your-access-key-secret",
          OSS_PREFIX: "bbt-skills/competitive-analysis",
          OSS_PUBLIC_BASE_URL: "",
          COMPETITIVE_ANALYSIS_CATEGORY: "",
          COMPETITIVE_ANALYSIS_SINCE_MONTHS: "6",
          COMPETITIVE_ANALYSIS_LIMIT: "20",
          COMPETITIVE_ANALYSIS_BRAND: "BUBBLETREE",
          COMPETITIVE_ANALYSIS_REPORTER: "自动分析任务"
        }
      }
    }
  }
}
```

Notes:

- The config key must match the skill name: `competitive_analysis`.
- OpenClaw injects these environment variables at agent runtime.
- If the database password contains special characters, URL-encode it first.

## Reload

After changing the skill or `openclaw.json`, start a new session or restart the gateway:

```bash
/new
```

Or:

```bash
openclaw gateway restart
```

## Check Visibility

```bash
openclaw skills list
```

## Execution

Invoke the script from OpenClaw or an external scheduler:

```bash
python3 {baseDir}/scripts/run_report.py
```

## Agent Allowlist

If your OpenClaw agent uses a skills allowlist, include `competitive_analysis`.

Example:

```json5
{
  agents: {
    defaults: {
      skills: ["competitive_analysis"]
    }
  }
}
```


path: references/report-outline.md
content: ---
title: Competitive Analysis Report Outline
source: .docs/竞品分析/BUBBLETREE儿童枕市场机会分析报告.pdf
---

# Competitive Analysis Report Outline

This file defines the fixed report structure. The generator must preserve the section order and heading hierarchy.

## Cover Section

- Report title: `{brand_or_project}{category}市场机会分析报告`
- Reporter: default `自动分析任务`
- Analysis date: report generation date
- Data source: product list + reviews + QA + any additional connected metrics

## Executive Summary

Must include:

- one summary paragraph
- a `关键发现` table
- `核心建议` text

The `关键发现` table should keep these default rows:

- 市场机会
- 材质趋势
- 最大痛点
- 竞品短板
- 你们优势

## 第一部分：数据概览

### 1.1 分析样本

Table fields:

- 数据类型
- 数量
- 说明

Suggested rows:

- 竞品样本
- 买家评论
- 买家问答
- 其他指标

### 1.2 样本品牌销售排名

Table fields:

- 排名
- 品牌
- 指标值
- 占比

Notes:

- If sales revenue is unavailable, degrade this section to `商品数排名`.
- The `指标值` header may be replaced with `商品数` or another available metric.

## 第二部分：品牌维度深度分析

### 2.1 核心竞品品牌矩阵

Table fields:

- 品牌
- 指标值
- 占比
- 商品数
- 定位
- 核心功能
- 差异化标签

### 2.2 品牌定位分类

Table fields:

- 定位类型
- 代表品牌
- 核心特点
- 机会分析

### 2.3 各品牌价格带分布

Table fields:

- 品牌
- 主价格带
- 价格策略

Notes:

- If price is unavailable, keep the section and mark it as `未采集价格字段`.

### 2.4 品牌打法总结

Table fields:

- 打法
- 代表品牌
- 你们的应对

### 2.5 竞争空白点

Table fields:

- 空白点
- 机会等级
- 说明

## 第三部分：功能趋势分析

### 3.1 竞品功能词频

Table fields:

- 功能
- 出现次数
- 占比
- 备注

### 3.2 洞察

- Output 3 to 5 short insight bullets.

## 第四部分：价格带分析

Table fields:

- 价格带
- 竞争状况
- 机会评估

Notes:

- If price is unavailable, output placeholder conclusions and data-gap notes.

## 第五部分：用户需求洞察

### 5.1 用户需求排名

Table fields:

- 排序
- 需求
- 频次
- 占比

### 5.2 用户痛点

Table fields:

- 排序
- 痛点
- 频次
- 严重程度

### 5.3 购买决策障碍

Table fields:

- 问题类型
- 频次
- 占比

## 第六部分：BUBBLETREE机会分析

### 6.1 竞争优势

Table fields:

- 你们的优势
- 对应用户需求
- 匹配度

### 6.2 差异化机会

Table fields:

- 差异化维度
- 竞品现状
- 你们机会
- 强度

### 6.3 产品机会

Must include:

- `产品定义建议`
- `SKU建议` 表
- `核心卖点` 列表

`SKU建议` table fields:

- SKU
- 定价
- 目标年龄
- 规格

## 第七部分：行动计划

### 7.1 产品开发

Table fields:

- 阶段
- 行动
- 时间
- 负责人

### 7.2 上市准备

Table fields:

- 阶段
- 行动
- 时间
- 渠道

### 7.3 预期目标

Table fields:

- 指标
- 3个月目标
- 6个月目标

## 第八部分：风险与建议

### 8.1 潜在风险

Table fields:

- 风险
- 概率
- 影响
- 应对

### 8.2 风险控制建议

- Output 3 to 5 short control suggestions.

## Appendix

Must include:

- 分析方法
- 数据来源

## Generation Constraints

- Heading hierarchy must match this template.
- Metric labels in tables may change, but whole sections must not be removed.
- When data is missing, preserve the structure and explain the reason instead of silently omitting sections.


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)