supeng 1 час назад
Родитель
Сommit
327c50eabf

+ 110 - 91
examples/content_finder/README.md

@@ -1,79 +1,118 @@
-# 内容寻找 Agent - MVP 版本
+# 内容寻找 Agent
 
-## 项目简介
-
-这是一个基于 AI Agent 的抖音内容寻找工具,能够根据用户需求智能搜索和筛选符合目标受众(主要是老年人群体)的视频内容。
+基于 AI Agent 的抖音内容寻找工具,根据用户需求智能搜索和筛选符合目标受众的视频内容。
 
 ## 核心功能
 
-1. **智能搜索**:根据用户需求解析关键词,搜索抖音视频内容
-2. **画像筛选**:基于热点宝画像数据,筛选符合老年人群体的内容
+1. **智能搜索**:根据用户需求解析关键词,调用抖音搜索 API
+2. **画像筛选**:基于热点宝画像数据,分析内容受众特征
 3. **深度挖掘**:对优质作者进行深度挖掘,获取更多相关作品
 4. **综合评估**:多维度评估内容质量和受众匹配度
 
+## 平台背景
+
+- **载体**:微信小程序
+- **核心用户群**:95% 是 50 岁以上中老年人
+- **增长方式**:微信分享裂变
+- **核心指标**:分享率、DAU
+
 ## 工具列表
 
 ### 1. douyin_search
 通过关键词搜索抖音视频内容
-- 支持关键词搜索
-- 支持播放量、点赞数筛选
-- 返回视频列表及基础数据
+
+**参数**:
+- `keyword` (必需): 搜索关键词
+- `content_type`: 内容类型,默认 "视频"
+- `sort_type`: 排序方式,默认 "综合排序"
+- `publish_time`: 发布时间范围,默认 "不限"
+- `cursor`: 分页游标,默认 "0"
+- `account_id`: 账号ID(可选)
+- `timeout`: 超时时间(秒),默认 60
+
+**返回**:搜索结果 JSON
 
 ### 2. douyin_user_videos
-获取抖音用户的作品列表
-- 根据用户ID获取作品
-- 用于深度挖掘优质作者
-
-### 3. get_video_audience_profile
-获取视频的点赞观众画像
-- 年龄分布(占比&偏好度)
-- 性别分布(占比&偏好度)
-- 城市等级分布(占比&偏好度)
-- 地域分布(占比&偏好度)
-
-### 4. get_user_fans_profile
-获取用户的粉丝画像
-- 粉丝年龄分布(占比&偏好度)
-- 粉丝性别分布(占比&偏好度)
-- 粉丝城市等级分布(占比&偏好度)
-- 粉丝地域分布(占比&偏好度)
+获取抖音账号的历史作品列表
+
+**参数**:
+- `account_id` (必需): 抖音账号ID
+- `sort_type`: 排序方式,默认 "最新"
+- `cursor`: 分页游标,默认 ""
+- `timeout`: 超时时间(秒),默认 60
+
+**返回**:作品列表 JSON
+
+### 3. get_account_fans_portrait
+获取抖音账号的粉丝画像(热点宝)
+
+**参数**:
+- `account_id` (必需): 抖音账号ID
+- `need_province`: 是否获取省份分布,默认 False
+- `need_city`: 是否获取城市分布,默认 False
+- `need_city_level`: 是否获取城市等级分布,默认 False
+- `need_gender`: 是否获取性别分布,默认 False
+- `need_age`: 是否获取年龄分布,默认 True
+- `need_phone_brand`: 是否获取手机品牌分布,默认 False
+- `need_phone_price`: 是否获取手机价格分布,默认 False
+- `timeout`: 超时时间(秒),默认 60
+
+**返回**:粉丝画像 JSON(包含占比和偏好度)
+
+### 4. get_content_fans_portrait
+获取抖音内容的点赞用户画像(热点宝)
+
+**参数**:
+- `content_id` (必需): 抖音内容ID
+- `need_province`: 是否获取省份分布,默认 False
+- `need_city`: 是否获取城市分布,默认 False
+- `need_city_level`: 是否获取城市等级分布,默认 False
+- `need_gender`: 是否获取性别分布,默认 False
+- `need_age`: 是否获取年龄分布,默认 True
+- `need_phone_brand`: 是否获取手机品牌分布,默认 False
+- `need_phone_price`: 是否获取手机价格分布,默认 False
+- `timeout`: 超时时间(秒),默认 60
+
+**返回**:点赞用户画像 JSON(包含占比和偏好度)
 
 ## Skills 策略
 
-### 1. content_finding_strategy(内容寻找策略)
-- 需求解析:提取关键词和目标受众特征
-- 初步搜索:使用关键词搜索内容
-- 内容筛选:基于热度指标筛选
-- 深度挖掘:获取优质作者的其他作品
-- 画像验证:验证是否符合目标受众
+### 1. content_finding_strategy(内容寻找方法论)
+教授如何系统化地寻找符合任意需求的内容:
+- 需求拆解技巧
+- 搜索策略制定
+- 迭代优化方法
 
-### 2. content_filtering_strategy(内容筛选策略)
-- 热度指标筛选:播放量、点赞量、分享量
-- 互动率筛选:点赞率、分享率、评论率
-- 观众画像筛选:年龄分布、偏好度、城市等级
-- 粉丝画像筛选:粉丝量、粉丝年龄分布
-- 综合评分机制
+### 2. content_filtering_strategy(内容筛选方法论)
+教授如何评估内容是否符合要求:
+- 从需求中提取评估标准
+- 多维度评估框架
+- 分层推荐策略
 
 ## 快速开始
 
 ### 1. 安装依赖
 
 ```bash
-pip install python-dotenv
+pip install python-dotenv httpx
 ```
 
 ### 2. 配置环境变量
 
-复制 `.env` 文件并配置:
+复制 `.env.example` 为 `.env` 并配置:
 
 ```bash
 OPEN_ROUTER_API_KEY=your_api_key_here
-MODEL=anthropic/claude-sonnet-4.5
+MODEL=anthropic/claude-sonnet-4.6
 TEMPERATURE=0.3
 MAX_ITERATIONS=30
 ```
 
-### 3. 运行
+### 3. 配置需求
+
+编辑 `content_finder.prompt` 文件的 `$user$` 段,填写你的内容需求。
+
+### 4. 运行
 
 ```bash
 cd examples/content_finder
@@ -82,80 +121,60 @@ python run.py
 
 ## 使用示例
 
-**用户需求**:
+**需求示例**(在 `content_finder.prompt` 中配置)
 ```
 孩子军抗日,让人感动。找这样的视频。
 
 要求:
-- 内容要感人,有情感共鸣
+- 内容要有情感共鸣
 - 适合老年人观看
 - 热度要高,质量要好
 ```
 
 **执行流程**:
-1. Agent 解析需求,提取关键词:"孩子军抗日"、"感人"
+1. Agent 解析需求,提取关键词和评估标准
 2. 使用 `douyin_search` 搜索相关内容
-3. 使用 `get_video_audience_profile` 获取观众画像
-4. 筛选符合老年人群体的内容(41岁以上占比高、偏好度高)
+3. 使用 `get_content_fans_portrait` 获取内容画像
+4. 根据年龄分布和偏好度筛选符合老年人群体的内容
 5. 对优质内容作者使用 `douyin_user_videos` 获取更多作品
-6. 使用 `get_user_fans_profile` 验证作者粉丝画像
+6. 使用 `get_account_fans_portrait` 验证作者粉丝画像
 7. 综合评估并推荐最合适的内容
 
 ## 项目结构
 
 ```
 content_finder/
-├── .env                    # 环境变量配置
-├── run.py                  # 主程序入口
-├── README.md              # 项目文档
-├── tools/                 # 工具包
+├── .env                           # 环境变量配置
+├── run.py                         # 主程序入口
+├── content_finder.prompt          # Prompt 配置文件
+├── README.md                      # 项目文档
+├── tools/                         # 工具包
 │   ├── __init__.py
-│   ├── douyin_search.py          # 抖音搜索
-│   ├── douyin_user_videos.py     # 用户作品列表
-│   └── hotspot_profile.py        # 热点宝画像数据
-├── skills/                # Skills 策略
-│   ├── content_finding_strategy.md    # 内容寻找策略
-│   └── content_filtering_strategy.md  # 内容筛选策略
-└── .cache/                # 缓存目录
-    ├── traces/            # Trace 存储
-    ├── output/            # 输出文件
-    └── agent.log          # 日志文件
+│   ├── douyin_search.py           # 抖音搜索
+│   ├── douyin_user_videos.py      # 用户作品列表
+│   └── hotspot_profile.py         # 热点宝画像数据
+├── skills/                        # Skills 策略
+│   ├── content_finding_strategy.md    # 内容寻找方法论
+│   └── content_filtering_strategy.md  # 内容筛选方法论
+└── .cache/                        # 缓存目录
+    ├── traces/                    # Trace 存储
+    └── agent.log                  # 日志文件
 ```
 
-## 筛选标准
-
-### 热度指标
-- 播放量:≥ 50,000
-- 点赞量:≥ 2,000
-- 分享量:≥ 500
-
-### 互动率
-- 点赞率:≥ 3%
-- 分享率:≥ 1%
-- 评论率:≥ 0.5%
+## API 配置
 
-### 老年人群体画像
-- 41岁以上占比:≥ 35%
-- 41-50岁偏好度:≥ 1.1
-- 51-60岁偏好度:≥ 1.2
-- 60岁以上偏好度:≥ 1.3
+工具调用的爬虫服务 API 地址:
+- 抖音搜索:`http://crawapi.piaoquantv.com/crawler/dou_yin/keyword`
+- 账号作品:`http://crawapi.piaoquantv.com/crawler/dou_yin/blogger`
+- 账号粉丝画像:`http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/account_fans_portrait`
+- 内容点赞画像:`http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/video_like_portrait`
 
 ## 注意事项
 
 1. **API 密钥**:需要配置有效的 OPEN_ROUTER_API_KEY
-2. **模拟数据**:当前版本使用模拟数据,实际使用需要对接真实的抖音API和热点宝API
-3. **筛选条件**:可根据实际需求调整筛选标准
-4. **画像数据**:画像数据仅作为参考,需结合多个维度综合判断
-
-## 后续优化
-
-- [ ] 对接真实的抖音API
-- [ ] 对接真实的热点宝API
-- [ ] 增加更多筛选维度
-- [ ] 优化评分算法
-- [ ] 增加结果导出功能
-- [ ] 增加批量处理功能
-- [ ] 增加可视化界面
+2. **爬虫服务**:确保爬虫服务 API 可访问
+3. **超时设置**:默认 60 秒,可根据网络情况调整
+4. **画像数据**:默认只获取年龄分布,需要其他维度时设置对应参数为 True
 
 ## License
 

+ 38 - 0
examples/content_finder/content_finder.prompt

@@ -12,6 +12,44 @@ $system$
 - 增长方式:微信分享裂变
 - 核心指标:分享率、DAU
 
+## 工具使用说明
+
+### 1. douyin_search(抖音搜索)
+- 每次搜索默认返回约 10-20 条结果
+- 如果结果不够,使用返回的 cursor 参数继续获取下一页
+- 关键字段:
+  - aweme_id: 视频ID(用于获取画像)
+  - author.sec_uid: 作者ID(用于获取作者作品和粉丝画像)
+  - statistics.digg_count: 点赞数
+  - statistics.comment_count: 评论数
+  - statistics.share_count: 分享数
+
+### 2. douyin_user_videos(账号作品)
+- 获取指定账号的历史作品
+- 支持 cursor 分页
+- 参数 account_id 使用 author.sec_uid
+
+### 3. get_content_fans_portrait(内容点赞用户画像)
+- 参数 content_id 使用 aweme_id
+- 默认只返回年龄分布(need_age=True)
+- 如需其他维度,设置对应参数:
+  - need_gender=True: 性别分布
+  - need_province=True: 省份分布
+  - need_city_level=True: 城市等级分布
+- 偏好度(tgi)> 1.0 表示该人群偏好高于平均水平
+
+### 4. get_account_fans_portrait(账号粉丝画像)
+- 参数 account_id 使用 author.sec_uid
+- 维度设置同上
+
+## 热度参考标准
+
+抖音视频点赞量参考:
+- 1000+: 一般热度
+- 5000+: 较高热度
+- 10000+: 高热度
+- 50000+: 爆款
+
 请按照 content_finding_strategy 和 content_filtering_strategy 中的方法论执行任务。
 
 $user$

+ 10 - 0
examples/content_finder/skills/content_finding_strategy.md

@@ -23,6 +23,11 @@
 - 根据约束条件设置搜索参数
 - 不确定时使用宽松参数,宁可多搜再筛选
 
+**分页策略**:
+- 第一次搜索使用默认 cursor("0" 或 "")
+- 如果结果不够,从返回数据中提取 cursor 值继续获取下一页
+- 示例:`douyin_search(keyword="...", cursor="返回的cursor值")`
+
 **迭代策略**:
 - 第一轮搜索结果不够好时,调整关键词或参数再搜
 - 不要在一次失败后就放弃
@@ -37,6 +42,11 @@
 
 **如果结果不满足要求**:调整搜索策略,再次尝试,而不是凑合推荐。
 
+**如果工具返回错误**:
+- 分析错误原因(API 超时、参数错误、资源不存在等)
+- 调整策略重试(换关键词、调整参数、使用其他工具)
+- 如果多次失败,如实告知用户并说明原因
+
 ## 关键原则
 
 **忠实需求**:用户要什么就找什么,不要基于自己的判断替换用户意图。

+ 38 - 2
examples/content_finder/tools/douyin_search.py

@@ -62,10 +62,46 @@ async def douyin_search(
             )
             response.raise_for_status()
             data = response.json()
+
+        # 格式化输出摘要
+        summary_lines = [f"搜索关键词「{keyword}」"]
+
+        items = data.get("data", {}).get("data", []) if isinstance(data.get("data"), dict) else data.get("data", [])
+        has_more = data.get("has_more", False)
+        cursor_value = data.get("cursor", "")
+
+        summary_lines.append(f"找到 {len(items)} 条结果" + (f",还有更多(cursor={cursor_value})" if has_more else ""))
+        summary_lines.append("")
+
+        # 显示前5条
+        for i, item in enumerate(items[:5], 1):
+            aweme_info = item.get("aweme_info", {})
+            aweme_id = aweme_info.get("aweme_id", "unknown")
+            desc = aweme_info.get("desc", "无标题")[:50]
+
+            author = aweme_info.get("author", {})
+            author_name = author.get("nickname", "未知作者")
+            author_id = author.get("sec_uid", "")
+
+            stats = aweme_info.get("statistics", {})
+            digg_count = stats.get("digg_count", 0)
+            comment_count = stats.get("comment_count", 0)
+            share_count = stats.get("share_count", 0)
+
+            summary_lines.append(f"{i}. {desc}")
+            summary_lines.append(f"   ID: {aweme_id}")
+            summary_lines.append(f"   作者: {author_name} (sec_uid: {author_id})")
+            summary_lines.append(f"   数据: 点赞 {digg_count:,} | 评论 {comment_count:,} | 分享 {share_count:,}")
+            summary_lines.append("")
+
+        if len(items) > 5:
+            summary_lines.append(f"... 还有 {len(items) - 5} 条结果")
+
         return ToolResult(
             title=f"抖音搜索: {keyword}",
-            output=json.dumps(data, ensure_ascii=False, indent=2),
-            long_term_memory=f"Searched Douyin for '{keyword}'"
+            output="\n".join(summary_lines),
+            long_term_memory=f"Searched Douyin for '{keyword}', found {len(items)} results",
+            metadata={"raw_data": data}
         )
     except httpx.HTTPStatusError as e:
         return ToolResult(

+ 34 - 2
examples/content_finder/tools/douyin_user_videos.py

@@ -54,10 +54,42 @@ async def douyin_user_videos(
             response.raise_for_status()
             data = response.json()
 
+        # 格式化输出摘要
+        summary_lines = [f"账号 {account_id} 的作品列表"]
+
+        aweme_list = data.get("aweme_list", [])
+        has_more = data.get("has_more", False)
+        cursor_value = data.get("cursor", "")
+
+        summary_lines.append(f"找到 {len(aweme_list)} 个作品" + (f",还有更多(cursor={cursor_value})" if has_more else ""))
+        summary_lines.append("")
+
+        # 显示前5条
+        for i, aweme in enumerate(aweme_list[:5], 1):
+            aweme_id = aweme.get("aweme_id", "unknown")
+            desc = aweme.get("desc", "无标题")[:50]
+
+            stats = aweme.get("statistics", {})
+            digg_count = stats.get("digg_count", 0)
+            comment_count = stats.get("comment_count", 0)
+            share_count = stats.get("share_count", 0)
+
+            create_time = aweme.get("create_time", 0)
+
+            summary_lines.append(f"{i}. {desc}")
+            summary_lines.append(f"   ID: {aweme_id}")
+            summary_lines.append(f"   数据: 点赞 {digg_count:,} | 评论 {comment_count:,} | 分享 {share_count:,}")
+            summary_lines.append(f"   发布时间: {create_time}")
+            summary_lines.append("")
+
+        if len(aweme_list) > 5:
+            summary_lines.append(f"... 还有 {len(aweme_list) - 5} 个作品")
+
         return ToolResult(
             title=f"账号作品: {account_id}",
-            output=json.dumps(data, ensure_ascii=False, indent=2),
-            long_term_memory=f"Fetched Douyin blogger videos for '{account_id}'",
+            output="\n".join(summary_lines),
+            long_term_memory=f"Fetched {len(aweme_list)} videos for account '{account_id}'",
+            metadata={"raw_data": data}
         )
     except httpx.HTTPStatusError as e:
         return ToolResult(

+ 105 - 7
examples/content_finder/tools/hotspot_profile.py

@@ -55,10 +55,59 @@ async def get_account_fans_portrait(
             response.raise_for_status()
             data = response.json()
 
+        # 格式化输出摘要
+        summary_lines = [f"账号 {account_id} 的粉丝画像"]
+        summary_lines.append("")
+
+        # 年龄分布
+        if need_age and "age" in data:
+            summary_lines.append("【年龄分布】")
+            age_data = data["age"]
+            for item in age_data:
+                name = item.get("name", "")
+                ratio = item.get("ratio", 0)
+                tgi = item.get("tgi", 0)
+                summary_lines.append(f"  {name}: {ratio:.1f}% (偏好度: {tgi:.2f})")
+            summary_lines.append("")
+
+        # 性别分布
+        if need_gender and "gender" in data:
+            summary_lines.append("【性别分布】")
+            gender_data = data["gender"]
+            for item in gender_data:
+                name = item.get("name", "")
+                ratio = item.get("ratio", 0)
+                tgi = item.get("tgi", 0)
+                summary_lines.append(f"  {name}: {ratio:.1f}% (偏好度: {tgi:.2f})")
+            summary_lines.append("")
+
+        # 城市等级
+        if need_city_level and "city_level" in data:
+            summary_lines.append("【城市等级】")
+            city_level_data = data["city_level"]
+            for item in city_level_data:
+                name = item.get("name", "")
+                ratio = item.get("ratio", 0)
+                tgi = item.get("tgi", 0)
+                summary_lines.append(f"  {name}: {ratio:.1f}% (偏好度: {tgi:.2f})")
+            summary_lines.append("")
+
+        # 省份分布 TOP5
+        if need_province and "province" in data:
+            summary_lines.append("【省份分布 TOP5】")
+            province_data = data["province"][:5]
+            for i, item in enumerate(province_data, 1):
+                name = item.get("name", "")
+                ratio = item.get("ratio", 0)
+                tgi = item.get("tgi", 0)
+                summary_lines.append(f"  {i}. {name}: {ratio:.1f}% (偏好度: {tgi:.2f})")
+            summary_lines.append("")
+
         return ToolResult(
             title=f"账号粉丝画像: {account_id}",
-            output=json.dumps(data, ensure_ascii=False, indent=2),
+            output="\n".join(summary_lines),
             long_term_memory=f"Fetched fans portrait for account '{account_id}'",
+            metadata={"raw_data": data}
         )
     except httpx.HTTPStatusError as e:
         return ToolResult(
@@ -74,7 +123,7 @@ async def get_account_fans_portrait(
         )
 
 
-@tool(description="获取抖音内容粉丝画像(热点宝),支持选择画像维度")
+@tool(description="获取抖音内容点赞用户画像(热点宝),支持选择画像维度")
 async def get_content_fans_portrait(
     content_id: str,
     need_province: bool = False,
@@ -87,7 +136,7 @@ async def get_content_fans_portrait(
     timeout: Optional[float] = None,
 ) -> ToolResult:
     """
-    获取抖音内容粉丝画像
+    获取抖音内容点赞用户画像
     """
     try:
         payload = {
@@ -112,20 +161,69 @@ async def get_content_fans_portrait(
             response.raise_for_status()
             data = response.json()
 
+        # 格式化输出摘要
+        summary_lines = [f"内容 {content_id} 的点赞用户画像"]
+        summary_lines.append("")
+
+        # 年龄分布
+        if need_age and "age" in data:
+            summary_lines.append("【年龄分布】")
+            age_data = data["age"]
+            for item in age_data:
+                name = item.get("name", "")
+                ratio = item.get("ratio", 0)
+                tgi = item.get("tgi", 0)
+                summary_lines.append(f"  {name}: {ratio:.1f}% (偏好度: {tgi:.2f})")
+            summary_lines.append("")
+
+        # 性别分布
+        if need_gender and "gender" in data:
+            summary_lines.append("【性别分布】")
+            gender_data = data["gender"]
+            for item in gender_data:
+                name = item.get("name", "")
+                ratio = item.get("ratio", 0)
+                tgi = item.get("tgi", 0)
+                summary_lines.append(f"  {name}: {ratio:.1f}% (偏好度: {tgi:.2f})")
+            summary_lines.append("")
+
+        # 城市等级
+        if need_city_level and "city_level" in data:
+            summary_lines.append("【城市等级】")
+            city_level_data = data["city_level"]
+            for item in city_level_data:
+                name = item.get("name", "")
+                ratio = item.get("ratio", 0)
+                tgi = item.get("tgi", 0)
+                summary_lines.append(f"  {name}: {ratio:.1f}% (偏好度: {tgi:.2f})")
+            summary_lines.append("")
+
+        # 省份分布 TOP5
+        if need_province and "province" in data:
+            summary_lines.append("【省份分布 TOP5】")
+            province_data = data["province"][:5]
+            for i, item in enumerate(province_data, 1):
+                name = item.get("name", "")
+                ratio = item.get("ratio", 0)
+                tgi = item.get("tgi", 0)
+                summary_lines.append(f"  {i}. {name}: {ratio:.1f}% (偏好度: {tgi:.2f})")
+            summary_lines.append("")
+
         return ToolResult(
-            title=f"内容粉丝画像: {content_id}",
-            output=json.dumps(data, ensure_ascii=False, indent=2),
+            title=f"内容点赞用户画像: {content_id}",
+            output="\n".join(summary_lines),
             long_term_memory=f"Fetched fans portrait for content '{content_id}'",
+            metadata={"raw_data": data}
         )
     except httpx.HTTPStatusError as e:
         return ToolResult(
-            title="内容粉丝画像获取失败",
+            title="内容点赞用户画像获取失败",
             output="",
             error=f"HTTP error {e.response.status_code}: {e.response.text}",
         )
     except Exception as e:
         return ToolResult(
-            title="内容粉丝画像获取失败",
+            title="内容点赞用户画像获取失败",
             output="",
             error=str(e),
         )