关于 where-your-customer-lives
这项技能将理想客户画像的痛点信号追溯到具体线上社群,结合竞争对手讨论并按证据为渠道排序。它使用 Reddit、Hacker News、DuckDuckGo 及可选的 GitHub 数据,而非泛泛推荐渠道。
主要功能
- 01收集 ICP 痛点信号
- 02发现具体线上社群
- 03加入竞争对手讨论信号
- 04为渠道评分和排序
- 05生成证据型行动方案
适用场景
- 01为产品的 ICP 寻找社群
- 02依据实际讨论规划渠道切入方式
- 03比较不同客户画像的渠道优先级
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ varnan-tech/ opendirectory --skill where-your-customer-lives安装后在 Claude Code 中输入/where-your-customer-lives确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
.env.exampleREADME.mdevals/evals.jsonreferences/channel-types.mdentry-tactics.mdscoring-guide.mdscripts/fetch.py详细介绍
客户身在何处
根据产品用途和 ICP,追溯真实的 ICP 痛点帖子,找到其来源社区。加入竞品讨论信号。通过 DuckDuckGo 发现 Slack/Discord/Newsletter/播客/会议渠道。根据 ICP 信号数量、规模、活跃度和竞品存在情况为每个渠道评分。输出排名后的行动手册:证据、切入策略、内容角度、反模式——每个渠道一份。禁止猜测。只列出经过信号追踪的渠道。
**关键规则:**输出中的每个渠道名称都必须出现在本次运行的 Reddit API 响应或 DuckDuckGo 搜索结果中。每个成员数量都必须来自 about.json API 或搜索摘要,绝不能估算。每个 ICP 信号数量都必须与原始数据一致。如果某种渠道类型返回 0 条结果,就报告 0,不要编造渠道。
常见错误
| Agent 会想要…… | 为什么这是错的 |
|---|---|
| 推荐泛泛的渠道(“LinkedIn”“Twitter”) | 每个渠道都必须具体到名称、成员数量和 URL。“LinkedIn 群组:DevOps for Enterprise Teams(45K 名成员)”——不能只写“LinkedIn”。 |
| 所有 ICP 都用相同的渠道 | 信号追踪需针对具体 ICP。DevOps ICP 和 Finance ICP 得出的渠道列表截然不同。每个 ICP 都要重新运行脚本。 |
| 编造成员数量或社区名称 | 每个渠道名称都必须来自 DuckDuckGo 结果或 Reddit API。每个成员数量都必须来自 API 或搜索摘要。如果查不到,就写“未找到成员数量”。 |
| 跳过竞品分析 | 讨论竞品的地方 = 你的 ICP 正在评估替代方案 = 最适合开展外联的场景。即使用户没有要求,也要始终搜索竞品。 |
| 把产品推销当作切入策略 | “在 r/devops 发布关于你产品的内容”不是切入策略。切入策略要说明具体的帖子类型、内容形式和社区规范。 |
| 把 Reddit 当作唯一的渠道类型 | 输出必须至少包含 3 种渠道类型。如果只找到 Reddit,就先明确通过 DuckDuckGo 搜索 Slack/Discord/Newsletter/会议,再停止。 |
第 1 步:检查设置
echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-not set -- competitor layer runs at 60 req/hr unauthenticated}"
echo ""
echo "Data sources this run will use:"
echo " Reddit public JSON (no auth, signal-trace)"
echo " Reddit about.json (no auth, subreddit metadata)"
echo " HN Algolia API (no auth, signal-trace)"
echo " DuckDuckGo HTML (no auth, channel discovery)"
echo " GitHub API (${GITHUB_TOKEN:+authenticated, }optional for competitor enrichment)"
如果未设置 GITHUB_TOKEN:继续。所有核心渠道发现功能均无需该令牌。
第 2 步:解析 ICP
从对话中收集:
product-- 产品的用途(一句话)icp_role-- ICP 的身份(例如“技术联合创始人”“A 轮公司的 DevOps 工程师”)icp_pain-- 他们的主要问题(例如“客户获取”“告警疲劳”)category-- 市场类别关键词(例如“startup gtm sales”“devops monitoring”)competitors-- 可选的竞品名称(例如“Clay、Apollo、HubSpot”)
ICP 逐级补充:
- 如果用户的提示中包含 product + icp_role + icp_pain:直接提取并继续。
- 如果提示信息不足(只有类别或只有产品名称):检查
docs/icp.md中是否保存了 ICP 资料。与提示中的详细信息合并。 - 如果信息仍然不足(缺少 icp_role 或 icp_pain):逐一询问以下 3 个问题:
- “用一句话说明你的产品是做什么的。”
- “谁是你的理想客户?(职位、公司类型、团队规模)”
- “他们找到你的产品之前,面临的主要问题是什么?”
- 将最终 ICP 保存到
docs/icp.md,以便其他技能复用。
如果 docs/icp.md 中尚未包含此产品,则保存 ICP 文件:
python3 << 'PYEOF'
import json, os
icp = {
"product": "PRODUCT_HERE",
"icp_role": "ICP_ROLE_HERE",
"icp_pain": "ICP_PAIN_HERE",
"competitors": ["COMP_1", "COMP_2"],
"category": "CATEGORY_HERE"
}
os.makedirs("docs", exist_ok=True)
with open("/tmp/wcl-input.json", "w") as f:
json.dump(icp, f, indent=2)
# Update docs/icp.md
icp_md_path = "docs/icp.md"
new_block = f"""## {icp['product']}
- **ICP role:** {icp['icp_role']}
- **ICP pain:** {icp['icp_pain']}
- **Competitors:** {', '.join(icp['competitors']) if icp['competitors'] else 'none'}
- **Category:** {icp['category']}
"""
existing = open(icp_md_path).read() if os.path.exists(icp_md_path) else ""
if icp['product'] not in existing:
with open(icp_md_path, "a") as f:
f.write(new_block)
print(f"ICP saved to {icp_md_path}")
else:
print(f"ICP already in {icp_md_path}")
print(f"Product: {icp['product']}")
print(f"ICP role: {icp['icp_role']}")
print(f"ICP pain: {icp['icp_pain']}")
print(f"Competitors: {', '.join(icp['competitors']) if icp['competitors'] else 'none'}")
PYEOF
第 3 步:运行独立的数据收集脚本
检查脚本是否存在:
ls scripts/fetch.py 2>/dev/null && echo "script available" || echo "not found"
运行渠道发现:
GITHUB_TOKEN="${GITHUB_TOKEN:-}" python3 scripts/fetch.py \
"$(python3 -c "import json; d=json.load(open('/tmp/wcl-input.json')); print(d['category'])")" \
--icp-role "$(python3 -c "import json; d=json.load(open('/tmp/wcl-input.json')); print(d['icp_role'])")" \
--icp-pain "$(python3 -c "import json; d=json.load(open('/tmp/wcl-input.json')); print(d['icp_pain'])")" \
--product "$(python3 -c "import json; d=json.load(open('/tmp/wcl-input.json')); print(d['product'])")" \
--competitors "$(python3 -c "import json; d=json.load(open('/tmp/wcl-input.json')); print(','.join(d['competitors']))")" \
--output /tmp/wcl-raw.json
等待完成(最多等待 5 分钟——Reddit + DuckDuckGo 搜索总计约需 120 秒)。
验证输出:
python3 -c "
import json
with open('/tmp/wcl-raw.json') as f:
d = json.load(f)
print(f'Reddit posts found: {d[\"reddit_posts_found\"]}')
print(f'HN signals found: {d[\"hn_signals_found\"]}')
print(f'Channels discovered: {d[\"summary\"][\"total_channels\"]}')
print(f'Top priority: {len(d[\"summary\"][\"top_priority\"])}')
print(f'By type: {d[\"summary\"][\"by_type\"]}')
print(f'Competitor layer ran: {d[\"summary\"][\"competitor_layer_ran\"]}')
"
如果 total_channels < 3:告知用户:“找到的渠道少于 3 个。ICP 描述可能过于狭窄,无法覆盖 Reddit/DDG。请尝试使用更宽泛的类别关键词,或添加竞品名称以启用竞品分析。”然后使用更宽泛的类别关键词重试一次,再停止。
步骤 4:打印频道摘要
加载原始数据并打印排名摘要表:
python3 -c "
import json
with open('/tmp/wcl-raw.json') as f:
d = json.load(f)
channels = d['channels_discovered']
print(f'Channels found: {len(channels)}')
print()
print(f'{'#':<4} {'Channel':<35} {'Type':<14} {'Members':<12} {'ICP signals':<13} {'Score':<8} Tier')
print('-' * 100)
for i, ch in enumerate(channels[:15], 1):
members = ch.get('members', 0)
m_str = f'{members//1000}K' if members >= 1000 else str(members) if members else '?'
print(f'{i:<4} {ch[\"name\"]:<35} {ch[\"type\"]:<14} {m_str:<12} {ch.get(\"icp_signal_count\",0):<13} {ch.get(\"channel_score\",0):<8} {ch.get(\"tier\",\"\")}')
"
打印得分最高频道中的前 3 条证据帖子:
python3 -c "
import json
with open('/tmp/wcl-raw.json') as f:
d = json.load(f)
channels = d['channels_discovered']
if channels:
top = channels[0]
print(f'Top channel: {top[\"name\"]}')
print(f'Evidence posts:')
for ep in top.get('evidence_posts', [])[:3]:
print(f' [{ep.get(\"score\",0):.0f}] {ep.get(\"title\",\"\")}')
print(f' {ep.get(\"url\",\"\")}')
"
步骤 5:AI 频道补充信息
现在你已获得原始频道数据。针对优先级最高和高优先级中的每个频道,生成一条操作手册。
加载所有频道:
python3 -c "
import json
with open('/tmp/wcl-raw.json') as f:
d = json.load(f)
top_channels = [ch for ch in d['channels_discovered'] if ch.get('tier') in ('top-priority', 'high')]
print(json.dumps(top_channels, indent=2))
"
针对上述每个频道,生成:
who_is_here: 用 2 句话描述该频道中具体的 ICP 类型。根据证据帖子、subreddit 描述和 ICP 画像推导。不要写“你的目标受众”——要具体。例如:“在员工人数为 50–500 人的公司任职、负责整个基础设施技术栈且没有专职 SRE 团队的 DevOps 工程师。他们会发帖讨论值班倦怠、Kubernetes 泛滥,以及在云原生和自托管可观测性方案之间如何选择。”
entry_tactic: 一种具体、可执行的切入方式。写明帖子类型、发帖形式和社区规范。不要写“参与社区互动”。例如:“找到每周一由自动版主发布的‘你正在做什么?’帖子。用 3 句话回复,介绍你解决的一个技术难题——出了什么问题、尝试了什么、最终如何解决。不要提及产品。先积累 karma,再单独发帖。”
content_angle: 根据证据帖的标题和得分,说明该频道中互动率最高的内容形式。例如:“在这里,技术复盘帖的互动量是产品公告的 5 倍。形式:‘我们将 20 万用户从 X 迁移到 Y——以下是出问题的地方及原因。’具体数字加上失败经验最容易获得赞同。”
anti_patterns: 列出 2–3 种会导致帖子被删除或损害声誉的具体行为。根据 subreddit 规则(如描述中有提供)和证据帖模式推导。例如:["在非推广帖中发布产品链接——版主会在几小时内删除", "不提供具体背景就问‘你们用什么工具?’——会被视为借市场调研套取信息"]
将补充后的操作手册写入 /tmp/wcl-channels.json:
{
"playbook": [
{
"channel": "r/devops",
"evidence": "34 ICP signals traced here, avg pain score 180",
"who_is_here": "...",
"entry_tactic": "...",
"content_angle": "...",
"anti_patterns": ["...", "..."]
}
]
}
python3 -c "
import json
with open('/tmp/wcl-channels.json') as f:
d = json.load(f)
print(f'Playbook entries: {len(d[\"playbook\"])}')
for p in d['playbook']:
print(f' {p[\"channel\"]}')
"
步骤 6:生成完整排名输出
将完整的排名操作手册写入 /tmp/wcl-output.json:
python3 << 'PYEOF'
import json
from datetime import datetime
with open('/tmp/wcl-input.json') as f:
inp = json.load(f)
with open('/tmp/wcl-raw.json') as f:
raw = json.load(f)
with open('/tmp/wcl-channels.json') as f:
enriched = json.load(f)
playbook_by_channel = {p['channel']: p for p in enriched['playbook']}
channels = raw['channels_discovered']
output = {
"date": raw['date'],
"product": inp['product'],
"icp_role": inp['icp_role'],
"icp_pain": inp['icp_pain'],
"competitors": inp.get('competitors', []),
"total_channels": raw['summary']['total_channels'],
"channels": []
}
for ch in channels:
name = ch['name']
playbook = playbook_by_channel.get(name, {})
output['channels'].append({
"rank": channels.index(ch) + 1,
"name": name,
"type": ch['type'],
"url": ch['url'],
"members": ch.get('members', 0),
"active_users": ch.get('active_users', 0),
"icp_signal_count": ch.get('icp_signal_count', 0),
"competitor_mentions": ch.get('competitor_mentions', 0),
"channel_score": ch.get('channel_score', 0),
"tier": ch.get('tier', ''),
"entry_type": ch.get('entry_type', 'open'),
"evidence_posts": ch.get('evidence_posts', []),
"who_is_here": playbook.get('who_is_here', ''),
"entry_tactic": playbook.get('entry_tactic', ''),
"content_angle": playbook.get('content_angle', ''),
"anti_patterns": playbook.get('anti_patterns', []),
})
with open('/tmp/wcl-output.json', 'w') as f:
json.dump(output, f, indent=2)
print(f"Output written: /tmp/wcl-output.json")
print(f"Total channels: {len(output['channels'])}")
PYEOF
步骤 7:自检
python3 -c "
import json
with open('/tmp/wcl-raw.json') as f:
raw = json.load(f)
with open('/tmp/wcl-output.json') as f:
output = json.load(f)
full_text = json.dumps(output)
raw_channel_names = {ch['name'].lower() for ch in raw['channels_discovered']}
passes = 0
fails = 0
# Check 1: No em dashes
if chr(8212) in full_text:
print('FAIL: em dash found in output -- replace with hyphen')
fails += 1
else:
print('PASS: no em dashes')
passes += 1
# Check 2: No banned words
banned = ['powerful', 'robust', 'seamless', 'innovative', 'game-changing',
'streamline', 'leverage', 'transform', 'revolutionize']
found = [w for w in banned if w.lower() in full_text.lower()]
if found:
print(f'FAIL: banned words: {found}')
fails += 1
else:
print('PASS: no banned words')
passes += 1
# Check 3: At least 3 channel types
types = {ch['type'] for ch in output['channels']}
if len(types) < 3:
print(f'FAIL: only {len(types)} channel type(s) in output: {types}')
fails += 1
else:
print(f'PASS: {len(types)} channel types: {types}')
passes += 1
# Check 4: All channel names exist in raw data
for ch in output['channels']:
if ch['name'].lower() not in raw_channel_names:
print(f'FAIL: channel not in raw data: {ch[\"name\"]}')
fails += 1
if fails == 0:
print('PASS: all channel names verified in raw data')
passes += 1
# Check 5: No generic entry tactics
generic_phrases = ['engage with the community', 'post about your product', 'share your content']
for ch in output['channels']:
tactic = ch.get('entry_tactic', '').lower()
for phrase in generic_phrases:
if phrase in tactic:
print(f'FAIL: generic entry tactic in {ch[\"name\"]}: contains \"{phrase}\"')
fails += 1
if fails == 0:
print('PASS: entry tactics are channel-specific')
print()
print(f'Result: {passes} passed, {fails} failed')
if fails > 0:
print('Fix failures before saving.')
else:
print('All checks passed. Ready to save.')
"
继续执行步骤 8 前,修复所有未通过项。
步骤 8:保存输出并清理
python3 << 'PYEOF'
import json, os, re
from datetime import datetime
with open('/tmp/wcl-input.json') as f:
inp = json.load(f)
with open('/tmp/wcl-raw.json') as f:
raw = json.load(f)
with open('/tmp/wcl-output.json') as f:
output = json.load(f)
slug = re.sub(r'[^a-z0-9]+', '-', (inp.get('icp_role') or inp['category']).lower()).strip('-')[:40]
date = datetime.now().strftime('%Y-%m-%d')
os.makedirs('docs/channel-map', exist_ok=True)
outpath_md = f"docs/channel-map/{slug}-{date}.md"
outpath_json = f"docs/channel-map/{slug}-{date}.json"
channels = output['channels']
by_type = {}
for ch in channels:
by_type.setdefault(ch['type'], []).append(ch)
lines = [
f"# Where Your Customer Lives: {inp['product'] or inp['category'].title()}",
f"ICP: {inp['icp_role']} | Date: {date} | Channels found: {len(channels)}",
"",
"---",
"",
"## Channel Ranking",
"",
]
tier_labels = {"top-priority": "TOP PRIORITY", "high": "HIGH", "medium": "MEDIUM", "low": "LOW"}
for ch in channels:
members = ch.get('members', 0)
m_str = f"{members//1000}K" if members >= 1000 else str(members) if members else "member count not found"
tier_label = tier_labels.get(ch.get('tier', ''), ch.get('tier', '').upper())
lines.append(f"### #{ch['rank']}: {ch['name']} [score: {ch['channel_score']}] -- {tier_label}")
active = ch.get('active_users', 0)
active_str = f" | Active: {active//1000}K/day" if active >= 1000 else f" | Active: {active}/day" if active else ""
lines.append(f"Type: {ch['type'].title()} | Members: {m_str}{active_str} | {ch.get('entry_type', 'open').title()} to join")
evidence_str = f"{ch['icp_signal_count']} ICP signals traced here" if ch['icp_signal_count'] > 0 else "Discovered via DuckDuckGo search"
lines.append(f"Evidence: {evidence_str}")
if ch.get('competitor_mentions', 0) > 0 and inp.get('competitors'):
lines.append(f"Competitor mentions: {ch['competitor_mentions']} across {', '.join(inp['competitors'][:3])}")
lines.append("")
if ch.get('who_is_here'):
lines.append(f"**Who is here:** {ch['who_is_here']}")
lines.append("")
if ch.get('entry_tactic'):
lines.append(f"**Entry tactic:** {ch['entry_tactic']}")
lines.append("")
if ch.get('content_angle'):
lines.append(f"**Content angle:** {ch['content_angle']}")
lines.append("")
if ch.get('anti_patterns'):
lines.append("**Anti-patterns:**")
for ap in ch['anti_patterns']:
lines.append(f"- {ap}")
lines.append("")
lines.append("---")
lines.append("")
lines += [
"## Channel Summary by Type",
"",
"| Type | Count | Best channel | Score |",
"|---|---|---|---|",
]
for ch_type, chs in sorted(by_type.items(), key=lambda x: -max(c['channel_score'] for c in x[1])):
best = max(chs, key=lambda x: x['channel_score'])
lines.append(f"| {ch_type.title()} | {len(chs)} | {best['name']} | {best['channel_score']} |")
lines += [
"",
"---",
"",
"## Data Quality Notes",
f"- All channel names exist in Reddit API response or DuckDuckGo search results",
f"- Member counts from Reddit about.json API or search snippets",
f"- ICP signal counts match raw data ({raw['reddit_posts_found']} Reddit posts, {raw['hn_signals_found']} HN signals)",
f"- Competitor layer ran: {raw['summary']['competitor_layer_ran']}",
f"- Sources: Reddit signal-trace, HN signal-trace, DuckDuckGo channel discovery",
"",
f"Saved to: {outpath_md}",
f"JSON snapshot: {outpath_json}",
]
with open(outpath_md, 'w') as f:
f.write('\n'.join(lines))
# JSON snapshot
snapshot = {
"input": inp,
"channels": channels,
"summary": raw['summary'],
"date": date,
}
with open(outpath_json, 'w') as f:
json.dump(snapshot, f, indent=2)
print(f"Report saved: {outpath_md}")
print(f"JSON snapshot: {outpath_json}")
PYEOF
清理临时文件:
rm -f /tmp/wcl-input.json /tmp/wcl-raw.json /tmp/wcl-channels.json /tmp/wcl-output.json
echo "Done. Channel map saved to docs/channel-map/"
向用户展示已保存的 .md 文件的完整内容。