Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb1fe43cc | ||
|
|
121977fd0d |
@@ -0,0 +1,105 @@
|
||||
# 星图达人视频传播数据导出 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 导出 CSV 前按配置调用 `get_author_spread_info`,追加个人视频和星图视频传播指标列。
|
||||
|
||||
**Architecture:** 新增独立的 spread-info 模块负责参数配置、URL、响应映射和并发加载;列表解析保留 `authorId`,额外保存 `spreadAuthorId` 作为 `o_author_id`;CSV exporter 只负责把已加载的 spread metrics 输出成列。导出入口在生成 CSV 前补齐 spread metrics。
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome content script, Vitest, jsdom.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Spread Info Client And Mapping
|
||||
|
||||
**Files:**
|
||||
- Create: `src/content/market/spread-info.ts`
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Test: `tests/spread-info.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Cover URL construction, label/header generation, response mapping, personal-video fixed params, and Xingtu-video multi-param configs.
|
||||
|
||||
- [ ] **Step 2: Run failing tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
Expected: FAIL because `spread-info.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement spread-info module and types**
|
||||
|
||||
Implement typed configs, formatter helpers, response mapper, client, and limited-concurrency loader.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Preserve Spread Author ID From Search Rows
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Modify: `src/content/market/market-list-row.ts`
|
||||
- Modify: `src/content/market/page-bridge.ts`
|
||||
- Test: `tests/market-page-bridge.test.ts`
|
||||
- Test: `tests/silent-export-controller.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Verify `attribute_datas.id` is retained as `spreadAuthorId` and preferred over top-level `star_id` for spread-info requests.
|
||||
|
||||
- [ ] **Step 2: Run failing tests**
|
||||
|
||||
Run focused tests for row parsing and silent export.
|
||||
|
||||
- [ ] **Step 3: Implement parser changes**
|
||||
|
||||
Store `spreadAuthorId` on snapshots and merge it in result store.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: CSV Columns
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/csv-exporter.ts`
|
||||
- Test: `tests/csv-exporter.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Verify spread headers append after backend metrics and blank cells are exported when metrics are absent.
|
||||
|
||||
- [ ] **Step 2: Implement CSV spread columns**
|
||||
|
||||
Read `record.spreadMetrics` by generated header names.
|
||||
|
||||
- [ ] **Step 3: Run focused tests**
|
||||
|
||||
Run: `npx vitest run tests/csv-exporter.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Export Hydration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Modify: `src/content/market/result-store.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Verify export calls spread-info with `spreadAuthorId`, waits before CSV generation, preserves row order, and leaves blanks on failure.
|
||||
|
||||
- [ ] **Step 2: Implement hydration**
|
||||
|
||||
Inject `loadSpreadMetrics` for tests, default to spread-info loader, and hydrate records before `buildCsv`.
|
||||
|
||||
- [ ] **Step 3: Run focused tests**
|
||||
|
||||
Run focused content-entry tests.
|
||||
|
||||
### Task 5: Final Verification
|
||||
|
||||
- [ ] Run `npm test`.
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] Review `git diff`.
|
||||
@@ -0,0 +1,33 @@
|
||||
# 星图达人传播指标阈值筛选 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在导出 CSV 和提交批次前,按用户选择的 spread-info 参数组合和指标阈值过滤达人。
|
||||
|
||||
**Architecture:** 工具栏负责读取筛选配置;`spread-info.ts` 提供单参数组合加载与阈值比较;`index.ts` 在 export range 收集后、CSV/批次 payload 生成前统一应用筛选。
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 content script, Vitest, jsdom.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Toolbar Filter State
|
||||
|
||||
- [ ] 增加视频类别、指派、营销流量、数据范围和 7 个阈值输入控件。
|
||||
- [ ] 增加 `readToolbarSpreadFilter` 读取并校验筛选配置。
|
||||
- [ ] 测试个人视频时固定并禁用指派/营销流量。
|
||||
|
||||
### Task 2: Spread Filter Logic
|
||||
|
||||
- [ ] 在 `spread-info.ts` 增加单配置请求与阈值比较。
|
||||
- [ ] 测试百分比显示值、秒、普通数字比较。
|
||||
|
||||
### Task 3: Export And Batch Integration
|
||||
|
||||
- [ ] 在导出和提交批次流程中调用筛选逻辑。
|
||||
- [ ] 空阈值不触发筛选请求。
|
||||
- [ ] 测试导出和提交批次都只保留满足阈值的达人。
|
||||
|
||||
### Task 4: Verification
|
||||
|
||||
- [ ] 运行 focused tests。
|
||||
- [ ] 运行 `npm run build`。
|
||||
@@ -0,0 +1,242 @@
|
||||
# 星图达人视频传播数据导出需求文档
|
||||
|
||||
## 目标
|
||||
|
||||
在现有星图达人 CSV 导出流程中,额外调用星图接口 `get_author_spread_info`,获取达人视频传播相关指标,并把这些指标追加到导出表格中。
|
||||
|
||||
因为同一个指标在不同参数组合下含义不同,所以导出字段名必须带上参数前缀。例如:
|
||||
|
||||
```text
|
||||
只看指派_排除营销流量_星图视频_近30天_完播率
|
||||
```
|
||||
|
||||
这个字段表示:它不是普通的“完播率”,而是在“只看指派 + 排除营销流量 + 星图视频 + 近30天”这组参数下获取到的完播率。
|
||||
|
||||
字段名前缀只体现会造成数据差异、且在当前导出中可变化的参数。固定不变的参数不用写进字段名前缀。
|
||||
|
||||
## 接口
|
||||
|
||||
调用接口:
|
||||
|
||||
```text
|
||||
GET /gw/api/data_sp/get_author_spread_info
|
||||
```
|
||||
|
||||
请求参数:
|
||||
|
||||
| 参数 | 含义 |
|
||||
| --- | --- |
|
||||
| `o_author_id` | 达人的星图 ID |
|
||||
| `platform_source` | 固定传 `1` |
|
||||
| `platform_channel` | 固定传 `1` |
|
||||
| `type` | 视频类型 |
|
||||
| `flow_type` | 是否排除营销流量 |
|
||||
| `only_assign` | 是否只看指派 |
|
||||
| `range` | 数据时间范围 |
|
||||
|
||||
请求需要带上当前星图网页登录态,所以实现时请求要使用浏览器当前 cookie,也就是 `credentials: "include"`。
|
||||
|
||||
## 星图 ID 来源
|
||||
|
||||
`o_author_id` 需要从 `search_for_author_square` 接口返回值中获取:
|
||||
|
||||
```text
|
||||
authors[i].attribute_datas.id
|
||||
```
|
||||
|
||||
如果同一行数据里同时存在顶层 `star_id` 和 `attribute_datas.id`,这个接口优先使用 `attribute_datas.id` 作为 `o_author_id`。
|
||||
|
||||
## 参数含义
|
||||
|
||||
### only_assign
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `true` | 只看指派 | `只看指派` |
|
||||
| `false` | 取消“只看指派”勾选 | `不限指派` |
|
||||
|
||||
### flow_type
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `1` | 排除营销流量 | `排除营销流量` |
|
||||
| `0` | 不排除营销流量 | `不排除营销流量` |
|
||||
|
||||
### range
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `2` | 近 30 天 | `近30天` |
|
||||
| `3` | 近 90 天 | `近90天` |
|
||||
|
||||
### type
|
||||
|
||||
| 值 | 含义 | 字段名前缀 |
|
||||
| --- | --- | --- |
|
||||
| `1` | 个人视频 | `个人视频` |
|
||||
| `2` | 星图视频 | `星图视频` |
|
||||
|
||||
## 多组参数导出
|
||||
|
||||
第一版需要支持多组参数组合。
|
||||
|
||||
参数组合需要区分“个人视频”和“星图视频”两类处理:
|
||||
|
||||
- `type=1` 个人视频:`only_assign=false`、`flow_type=0` 固定,只允许调整 `range`。
|
||||
- `type=2` 星图视频:需要支持多组参数组合,因为 `only_assign`、`flow_type`、`range` 的不同设置会导致接口返回的数据不同。
|
||||
|
||||
个人视频固定参数:
|
||||
|
||||
```text
|
||||
type=1
|
||||
flow_type=0
|
||||
only_assign=false
|
||||
```
|
||||
|
||||
个人视频可变参数:
|
||||
|
||||
```text
|
||||
range=2 或 range=3
|
||||
```
|
||||
|
||||
因为个人视频里 `only_assign=false` 和 `flow_type=0` 是固定参数,所以它们不写入字段名前缀。个人视频字段只需要体现视频类型和时间范围,例如:
|
||||
|
||||
```text
|
||||
个人视频_近30天_完播率
|
||||
个人视频_近90天_完播率
|
||||
```
|
||||
|
||||
星图视频可以配置多组参数。每一组参数都会调用一次 `get_author_spread_info`,并为这一组参数生成 7 个导出字段。
|
||||
|
||||
例如某一组参数是:
|
||||
|
||||
```text
|
||||
only_assign=true
|
||||
flow_type=1
|
||||
type=2
|
||||
range=2
|
||||
```
|
||||
|
||||
那么这一组会生成:
|
||||
|
||||
- `只看指派_排除营销流量_星图视频_近30天_完播率`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_播放量中位数`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_互动率`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均时长`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均评论数`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均点赞数`
|
||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均转发数`
|
||||
|
||||
字段名规则固定为:
|
||||
|
||||
```text
|
||||
<会变化的参数文案>_<视频类型文案>_<时间范围文案>_<指标名>
|
||||
```
|
||||
|
||||
对星图视频来说,`only_assign`、`flow_type`、`range` 都可能变化,所以字段名要保留这些参数。对个人视频来说,只有 `range` 变化,所以字段名不需要写 `不限指派` 和 `不排除营销流量`。
|
||||
|
||||
这里必须保留会变化参数的前缀,不能把不同参数组合下的同名指标合并。例如下面两个字段都叫“完播率”,但数据含义不同,必须作为两个独立字段导出:
|
||||
|
||||
```text
|
||||
只看指派_排除营销流量_星图视频_近30天_完播率
|
||||
不限指派_不排除营销流量_星图视频_近30天_完播率
|
||||
```
|
||||
|
||||
## 需要导出的指标
|
||||
|
||||
每一组参数都要导出下面 7 个指标:
|
||||
|
||||
| 导出字段指标名 | 接口响应字段 | 示例值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 完播率 | `play_over_rate.value` | `2824` | 按万分比理解,导出时建议显示为 `28.24%` |
|
||||
| 播放量中位数 | `play_mid`,兜底 `item_rate.play_mid.value` | `10913233` | 播放量中位数 |
|
||||
| 互动率 | `interact_rate.value` | `402` | 按万分比理解,导出时建议显示为 `4.02%` |
|
||||
| 作品平均时长 | `avg_duration` | `5600` | 按百分之一秒理解,导出时显示为秒,例如 `56` |
|
||||
| 作品平均评论数 | `comment_avg` | `7502` | 平均评论数 |
|
||||
| 作品平均点赞数 | `like_avg` | `494458` | 平均点赞数 |
|
||||
| 作品平均转发数 | `share_avg` | `188267` | 平均转发数 |
|
||||
|
||||
示例响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"avg_duration": "5600",
|
||||
"comment_avg": "7502",
|
||||
"interact_rate": {
|
||||
"overtake": 5312,
|
||||
"value": 402
|
||||
},
|
||||
"item_rate": {
|
||||
"play_mid": {
|
||||
"label": "",
|
||||
"overtake": 10000,
|
||||
"value": 10913233
|
||||
}
|
||||
},
|
||||
"like_avg": "494458",
|
||||
"play_mid": "10913233",
|
||||
"play_over_rate": {
|
||||
"overtake": 9584,
|
||||
"value": 2824
|
||||
},
|
||||
"share_avg": "188267"
|
||||
}
|
||||
```
|
||||
|
||||
## 导出流程
|
||||
|
||||
1. 当前插件仍然先从星图达人搜索页收集达人列表。
|
||||
2. 从 `search_for_author_square` 的 `authors[i].attribute_datas.id` 取出每个达人的星图 ID。
|
||||
3. 用户导出 CSV 时,先按现有逻辑确定导出范围,例如当前页、前 5 页、前 10 页、全部或自定义页数。
|
||||
4. 对导出范围内的每个达人,先按个人视频参数调用 `get_author_spread_info`:`type=1`、`flow_type=0`、`only_assign=false` 固定,`range` 按配置取值。
|
||||
5. 如果配置了星图视频参数组合,再按每一组星图视频参数分别调用 `get_author_spread_info`。
|
||||
6. 把每次接口返回值解析成 7 个指标。
|
||||
7. CSV 保留原有字段顺序,在现有字段后追加这些带参数前缀的新字段。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 如果某个达人没有 `attribute_datas.id`,这一行的视频传播指标留空。
|
||||
- 如果某个参数组合请求失败,这一组参数对应的 7 个字段留空。
|
||||
- 如果接口响应结构异常,这一组参数对应的 7 个字段留空。
|
||||
- 某个达人失败不能影响其他达人导出。
|
||||
- 某组参数失败不能影响同一个达人的其他参数组导出。
|
||||
|
||||
## 性能要求
|
||||
|
||||
这个功能会产生比较多接口请求:
|
||||
|
||||
```text
|
||||
请求数 = 导出的达人数量 * 参数组合数量
|
||||
```
|
||||
|
||||
所以实现时需要:
|
||||
|
||||
- 做并发限制,避免一次性打太多请求。
|
||||
- 保持最终 CSV 行顺序和原导出顺序一致。
|
||||
- 给每个请求设置超时时间。
|
||||
- 第一版不做激进重试,避免接口压力过大。
|
||||
|
||||
## 测试要求
|
||||
|
||||
需要补充测试覆盖:
|
||||
|
||||
- `get_author_spread_info` URL 参数构造是否正确。
|
||||
- `type=1` 生成 `个人视频` 前缀。
|
||||
- `type=2` 生成 `星图视频` 前缀。
|
||||
- 个人视频是否固定使用:`type=1`、`flow_type=0`、`only_assign=false`。
|
||||
- 个人视频是否支持切换 `range=2` 和 `range=3`。
|
||||
- 个人视频字段名前缀是否不包含固定参数 `不限指派` 和 `不排除营销流量`。
|
||||
- 星图视频是否支持多组参数组合。
|
||||
- `only_assign`、`flow_type`、`range` 前缀是否正确。
|
||||
- 是否从 `attribute_datas.id` 读取 `o_author_id`。
|
||||
- 多组参数是否分别生成 7 个字段。
|
||||
- 响应字段是否正确映射到 7 个导出指标。
|
||||
- 接口失败时是否导出空字段。
|
||||
- 多个达人并发请求完成顺序不一致时,最终 CSV 行顺序是否保持不变。
|
||||
|
||||
## 暂不做的事情
|
||||
|
||||
- 暂不新增页面上的参数配置 UI。
|
||||
- 暂不改变星图搜索页原本的筛选条件。
|
||||
- 暂不改变现有后端指标字段。
|
||||
- 暂不改变批次提交 payload。
|
||||
@@ -0,0 +1,101 @@
|
||||
# 星图达人传播指标阈值筛选需求文档
|
||||
|
||||
## 目标
|
||||
|
||||
在导出 CSV 或提交批次之前,允许用户按一组视频传播数据参数和指标阈值对达人做二次筛选。
|
||||
|
||||
只有满足筛选条件的达人,才进入最终导出或提交批次。
|
||||
|
||||
## 筛选维度
|
||||
|
||||
筛选维度对应 `get_author_spread_info` 的请求参数:
|
||||
|
||||
| UI 维度 | 接口参数 | 可选值 |
|
||||
| --- | --- | --- |
|
||||
| 视频类别 | `type` | 个人视频 / 星图视频 |
|
||||
| 是否指派 | `only_assign` | 只看指派 / 不限指派 |
|
||||
| 是否排除营销流量 | `flow_type` | 排除营销流量 / 不排除营销流量 |
|
||||
| 数据范围 | `range` | 近30天 / 近90天 |
|
||||
|
||||
个人视频的参数约束:
|
||||
|
||||
- `type=1`
|
||||
- `only_assign=false`
|
||||
- `flow_type=0`
|
||||
- `range` 可选近30天或近90天
|
||||
|
||||
星图视频的参数约束:
|
||||
|
||||
- `type=2`
|
||||
- `only_assign` 可选
|
||||
- `flow_type` 可选
|
||||
- `range` 可选近30天或近90天
|
||||
|
||||
## 指标阈值
|
||||
|
||||
支持下面 7 个指标阈值:
|
||||
|
||||
- 完播率 >=
|
||||
- 播放量中位数 >=
|
||||
- 互动率 >=
|
||||
- 作品平均时长 >=
|
||||
- 作品平均评论数 >=
|
||||
- 作品平均点赞数 >=
|
||||
- 作品平均转发数 >=
|
||||
|
||||
规则:
|
||||
|
||||
- 没填的阈值不参与筛选。
|
||||
- 填了多个阈值时,必须全部满足才保留达人。
|
||||
- 完播率和互动率使用百分数显示值,例如填 `30` 表示 `30%`。
|
||||
- 作品平均时长使用秒,例如填 `56` 表示 `56秒`。
|
||||
- 播放量、评论、点赞、转发使用普通数字。
|
||||
- 如果某个达人在所选参数组合下接口请求失败或缺少被启用的指标,则视为不满足筛选。
|
||||
|
||||
## 生效范围
|
||||
|
||||
阈值筛选同时作用于:
|
||||
|
||||
- 导出 CSV
|
||||
- 提交批次
|
||||
|
||||
处理顺序:
|
||||
|
||||
1. 先按现有导出范围收集达人,例如当前页、前5页、前10页、全部或自定义页数。
|
||||
2. 如果用户没有填写任何阈值,保持现有导出/提交行为。
|
||||
3. 如果用户填写了阈值,对收集到的每个达人按当前筛选维度调用一次 `get_author_spread_info`。
|
||||
4. 将接口响应映射为显示值。
|
||||
5. 用已填写的阈值过滤达人。
|
||||
6. 过滤后的达人进入导出 CSV 或提交批次。
|
||||
|
||||
## UI 设计
|
||||
|
||||
在现有插件操作区中增加一组紧凑控件:
|
||||
|
||||
- 视频类别下拉框
|
||||
- 指派下拉框
|
||||
- 营销流量下拉框
|
||||
- 数据范围下拉框
|
||||
- 7 个数字输入框
|
||||
|
||||
当视频类别选择“个人视频”时:
|
||||
|
||||
- 指派固定为“不限指派”
|
||||
- 营销流量固定为“不排除营销流量”
|
||||
- 对应控件禁用
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 单个达人筛选请求失败:该达人不满足筛选。
|
||||
- 全部达人都不满足:导出空 CSV 表头;提交批次时按现有空记录处理。
|
||||
- 阈值输入非法:阻止导出/提交,并提示用户修正。
|
||||
|
||||
## 测试要求
|
||||
|
||||
- 读取 toolbar 中的筛选参数和阈值。
|
||||
- 个人视频禁用指派和营销流量控件。
|
||||
- 空阈值时不触发二次筛选。
|
||||
- 有阈值时按所选参数调用 `get_author_spread_info`。
|
||||
- 百分比阈值按显示值比较。
|
||||
- 多个阈值按 AND 关系过滤。
|
||||
- 导出 CSV 和提交批次都应用二次筛选。
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
||||
import { escapeCsvCell } from "../../shared/csv";
|
||||
import { buildSpreadInfoColumns } from "./spread-info";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
export type CsvColumn = {
|
||||
@@ -74,6 +75,11 @@ const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) => ({
|
||||
header,
|
||||
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
|
||||
}));
|
||||
|
||||
export function listRateCsvHeaders(): string[] {
|
||||
return RATE_COLUMNS.map((column) => column.header);
|
||||
}
|
||||
@@ -94,7 +100,12 @@ export function buildMarketCsv(records: MarketRecord[]): string {
|
||||
|
||||
export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
const baseColumns = buildBaseColumns(records);
|
||||
return [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
|
||||
return [
|
||||
...baseColumns,
|
||||
...RATE_COLUMNS,
|
||||
...BACKEND_METRIC_COLUMNS,
|
||||
...SPREAD_INFO_COLUMNS
|
||||
];
|
||||
}
|
||||
|
||||
export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
|
||||
@@ -76,6 +76,7 @@ type MarketDataRow = {
|
||||
location?: string;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
};
|
||||
|
||||
export interface MarketRowDom {
|
||||
@@ -88,6 +89,7 @@ export interface MarketRowDom {
|
||||
personalCell: HTMLElement;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
row: HTMLElement;
|
||||
selectionCheckbox: HTMLInputElement;
|
||||
singleCell: HTMLElement;
|
||||
@@ -679,6 +681,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
row: authorCell,
|
||||
selectionCheckbox,
|
||||
singleCell,
|
||||
spreadAuthorId: fallbackMarketRow?.spreadAuthorId,
|
||||
visibilityTargets: rowCells
|
||||
} satisfies MarketRowDom
|
||||
];
|
||||
@@ -1351,7 +1354,8 @@ function readSerializedMarketRows(
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
spreadAuthorId: readString(record.spreadAuthorId) ?? undefined
|
||||
};
|
||||
})
|
||||
.filter((row) => Boolean(row.authorId || row.authorName));
|
||||
@@ -1673,7 +1677,11 @@ function mergeMarketDataRows(
|
||||
baseRow.price21To60s,
|
||||
preferredRow.price21To60s
|
||||
),
|
||||
rates: mergeRates(baseRow.rates, preferredRow.rates)
|
||||
rates: mergeRates(baseRow.rates, preferredRow.rates),
|
||||
spreadAuthorId: mergeNonEmptyString(
|
||||
baseRow.spreadAuthorId,
|
||||
preferredRow.spreadAuthorId
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+120
-14
@@ -31,8 +31,10 @@ import { createMarketApiClient } from "./api-client";
|
||||
import { createExportRangeController } from "./export-range-controller";
|
||||
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
|
||||
import { createSilentExportController } from "./silent-export-controller";
|
||||
import { createSpreadInfoClient, matchesSpreadThresholds } from "./spread-info";
|
||||
import {
|
||||
readToolbarExportTarget,
|
||||
readToolbarSpreadFilter,
|
||||
setToolbarBusyState,
|
||||
setToolbarExportStatus
|
||||
} from "./plugin-toolbar";
|
||||
@@ -54,7 +56,8 @@ import type {
|
||||
MarketExportTarget,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot,
|
||||
MarketSortState
|
||||
MarketSortState,
|
||||
SpreadThresholdFilter
|
||||
} from "./types";
|
||||
|
||||
interface MutationObserverLike {
|
||||
@@ -79,6 +82,11 @@ export interface CreateMarketControllerOptions {
|
||||
target: AudienceProfileRequestTarget
|
||||
) => Promise<AudienceProfileResult>;
|
||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||
loadSpreadFilterMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadThresholdFilter["config"]
|
||||
) => Promise<Record<string, string | undefined>>;
|
||||
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||
Array<BackendMetrics & { starId: string }>
|
||||
>;
|
||||
@@ -101,10 +109,16 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const audienceProfileClient = createAudienceProfileClient();
|
||||
const authorBaseClient = createAuthorBaseClient();
|
||||
const businessAbilityClient = createBusinessAbilityClient();
|
||||
const spreadInfoClient = createSpreadInfoClient();
|
||||
const sendRuntimeMessage = createRuntimeMessageSender();
|
||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||
const loadAuthorMetrics =
|
||||
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
||||
const loadSpreadFilterMetrics =
|
||||
options.loadSpreadFilterMetrics ??
|
||||
spreadInfoClient.loadAuthorSpreadMetricSnapshot;
|
||||
const loadSpreadMetrics =
|
||||
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
|
||||
const searchBackendMetrics =
|
||||
options.searchBackendMetrics ??
|
||||
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
||||
@@ -206,13 +220,22 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||
return;
|
||||
}
|
||||
const spreadFilter = readToolbarSpreadFilter(toolbar);
|
||||
if (spreadFilter.error) {
|
||||
setToolbarExportStatus(toolbar, spreadFilter.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setToolbarBusyState(toolbar, true);
|
||||
try {
|
||||
const records = filterRecordsBySelection(
|
||||
await exportRecords(exportTarget.target, "导出中", {
|
||||
showDetailedProgress: selectedAuthorIds.size === 0
|
||||
})
|
||||
await applySpreadThresholdFilter(
|
||||
await exportRecords(exportTarget.target, "导出中", {
|
||||
includeSpreadMetrics: true,
|
||||
showDetailedProgress: selectedAuthorIds.size === 0
|
||||
}),
|
||||
spreadFilter.filter
|
||||
)
|
||||
);
|
||||
options.onCsvReady?.(buildCsv(records));
|
||||
setToolbarExportStatus(toolbar, "");
|
||||
@@ -369,6 +392,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||
return;
|
||||
}
|
||||
const spreadFilter = readToolbarSpreadFilter(toolbar);
|
||||
if (spreadFilter.error) {
|
||||
setToolbarExportStatus(toolbar, spreadFilter.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const batchName = await promptBatchName();
|
||||
if (batchName === null) {
|
||||
@@ -384,12 +412,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
try {
|
||||
const hasSelectedAuthors = selectedAuthorIds.size > 0;
|
||||
const records = filterRecordsBySelection(
|
||||
await exportRecords(
|
||||
exportTarget.target,
|
||||
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
||||
{
|
||||
showDetailedProgress: !hasSelectedAuthors
|
||||
}
|
||||
await applySpreadThresholdFilter(
|
||||
await exportRecords(
|
||||
exportTarget.target,
|
||||
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
||||
{
|
||||
showDetailedProgress: !hasSelectedAuthors
|
||||
}
|
||||
),
|
||||
spreadFilter.filter
|
||||
)
|
||||
);
|
||||
const authState = await getAuthState();
|
||||
@@ -707,6 +738,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
target: MarketExportTarget,
|
||||
inProgressLabel = "导出中",
|
||||
progressOptions: {
|
||||
includeSpreadMetrics?: boolean;
|
||||
showDetailedProgress?: boolean;
|
||||
} = {}
|
||||
): Promise<MarketRecord[]> {
|
||||
@@ -716,7 +748,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
if (target.mode === "count" && target.pageCount <= 1) {
|
||||
await prepareCurrentPageForExport();
|
||||
return getVisibleOrderedRecords();
|
||||
return hydrateExportRecords(getVisibleOrderedRecords(), {
|
||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
||||
});
|
||||
}
|
||||
|
||||
const silentExportRecords = await silentExportController.exportRecords(target);
|
||||
@@ -725,7 +759,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
silentExportRecords.map((record) => ({
|
||||
...record,
|
||||
status: record.status ?? "idle"
|
||||
}))
|
||||
})),
|
||||
{
|
||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -760,6 +797,37 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return selectedRecords.length > 0 ? selectedRecords : records;
|
||||
}
|
||||
|
||||
async function applySpreadThresholdFilter(
|
||||
records: MarketRecord[],
|
||||
filter: SpreadThresholdFilter | undefined
|
||||
): Promise<MarketRecord[]> {
|
||||
if (!filter) {
|
||||
return records;
|
||||
}
|
||||
|
||||
const matchedAuthorIds = new Set<string>();
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const spreadAuthorId = record.spreadAuthorId;
|
||||
if (!spreadAuthorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await loadSpreadFilterMetrics(
|
||||
spreadAuthorId,
|
||||
filter.config
|
||||
);
|
||||
if (matchesSpreadThresholds(metrics, filter.thresholds)) {
|
||||
matchedAuthorIds.add(record.authorId);
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
);
|
||||
|
||||
return records.filter((record) => matchedAuthorIds.has(record.authorId));
|
||||
}
|
||||
|
||||
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
||||
if (selectedAuthorIds.size === 0) {
|
||||
return [];
|
||||
@@ -1000,7 +1068,12 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
await runSyncCycle();
|
||||
}
|
||||
|
||||
async function hydrateExportRecords(records: MarketRecord[]): Promise<MarketRecord[]> {
|
||||
async function hydrateExportRecords(
|
||||
records: MarketRecord[],
|
||||
options: {
|
||||
includeSpreadMetrics?: boolean;
|
||||
} = {}
|
||||
): Promise<MarketRecord[]> {
|
||||
for (const record of records) {
|
||||
resultStore.upsertMarketRow(record);
|
||||
const existingRecord = resultStore.getRecord(record.authorId);
|
||||
@@ -1066,9 +1139,32 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeSpreadMetrics) {
|
||||
await hydrateSpreadMetricsForRecords(records);
|
||||
}
|
||||
|
||||
return records.map((record) => toMarketRecord(record));
|
||||
}
|
||||
|
||||
async function hydrateSpreadMetricsForRecords(
|
||||
records: MarketRecord[]
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const storedRecord = resultStore.getRecord(record.authorId) ?? record;
|
||||
const spreadAuthorId = storedRecord.spreadAuthorId ?? record.spreadAuthorId;
|
||||
if (!spreadAuthorId || storedRecord.spreadMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spreadMetrics = await loadSpreadMetrics(spreadAuthorId);
|
||||
if (Object.keys(spreadMetrics).length > 0) {
|
||||
resultStore.setSpreadMetricsSuccess(record.authorId, spreadMetrics);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function harvestCurrentPageForExport(): Promise<void> {
|
||||
let hydrationSnapshot = await collectCurrentPageSnapshotsUntilSettled();
|
||||
if (
|
||||
@@ -1148,6 +1244,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
existingRecord?.price21To60s,
|
||||
rowSnapshot.price21To60s
|
||||
);
|
||||
const spreadAuthorId = mergeStringValue(
|
||||
existingRecord?.spreadAuthorId,
|
||||
rowSnapshot.spreadAuthorId
|
||||
);
|
||||
return {
|
||||
...existingRecord,
|
||||
...rowSnapshot,
|
||||
@@ -1169,6 +1269,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
location,
|
||||
price21To60s,
|
||||
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
|
||||
spreadAuthorId,
|
||||
spreadMetrics: mergeFieldMap(
|
||||
existingRecord?.spreadMetrics,
|
||||
rowSnapshot.spreadMetrics
|
||||
),
|
||||
status: existingRecord?.status ?? "idle"
|
||||
} satisfies MarketRecord;
|
||||
}
|
||||
@@ -1477,7 +1582,8 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
||||
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
||||
location: rowDom.location,
|
||||
price21To60s: rowDom.price21To60s,
|
||||
rates: rowDom.rates
|
||||
rates: rowDom.rates,
|
||||
spreadAuthorId: rowDom.spreadAuthorId
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,9 @@ export function mapMarketListRow(
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
spreadAuthorId:
|
||||
readString(readMarketFieldValue(row, attributeDatas, "id")) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,8 @@ function readSerializedMarketRows() {
|
||||
authorName:
|
||||
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
|
||||
coreUserId: readString(attributeDatas.core_user_id) ?? undefined,
|
||||
singleVideoAfterSearchRate
|
||||
singleVideoAfterSearchRate,
|
||||
spreadAuthorId: readString(attributeDatas.id) ?? undefined
|
||||
};
|
||||
})
|
||||
.filter((row) => Boolean(row.authorId || row.authorName));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
MarketExportScope,
|
||||
MarketExportTarget
|
||||
MarketExportTarget,
|
||||
SpreadThresholdFilter
|
||||
} from "./types";
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
@@ -20,6 +21,11 @@ export interface PluginToolbarDom {
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
exportStatusText: HTMLElement;
|
||||
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
||||
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
||||
spreadFilterRangeSelect: HTMLSelectElement;
|
||||
spreadFilterTypeSelect: HTMLSelectElement;
|
||||
spreadThresholdInputs: Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement>;
|
||||
root: HTMLElement;
|
||||
}
|
||||
|
||||
@@ -114,15 +120,91 @@ export function ensurePluginToolbar(
|
||||
exportStatusText.dataset.pluginExportStatus = "text";
|
||||
applyStatusStyles(exportStatusText);
|
||||
|
||||
const spreadFilterTypeSelect = document.createElement("select");
|
||||
spreadFilterTypeSelect.dataset.pluginSpreadFilter = "type";
|
||||
appendOption(spreadFilterTypeSelect, "1", "个人视频");
|
||||
appendOption(spreadFilterTypeSelect, "2", "星图视频");
|
||||
spreadFilterTypeSelect.value = "1";
|
||||
|
||||
const spreadFilterOnlyAssignSelect = document.createElement("select");
|
||||
spreadFilterOnlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign";
|
||||
appendOption(spreadFilterOnlyAssignSelect, "false", "不限指派");
|
||||
appendOption(spreadFilterOnlyAssignSelect, "true", "只看指派");
|
||||
spreadFilterOnlyAssignSelect.value = "false";
|
||||
|
||||
const spreadFilterFlowTypeSelect = document.createElement("select");
|
||||
spreadFilterFlowTypeSelect.dataset.pluginSpreadFilter = "flowType";
|
||||
appendOption(spreadFilterFlowTypeSelect, "0", "不排除营销");
|
||||
appendOption(spreadFilterFlowTypeSelect, "1", "排除营销");
|
||||
spreadFilterFlowTypeSelect.value = "0";
|
||||
|
||||
const spreadFilterRangeSelect = document.createElement("select");
|
||||
spreadFilterRangeSelect.dataset.pluginSpreadFilter = "range";
|
||||
appendOption(spreadFilterRangeSelect, "2", "近30天");
|
||||
appendOption(spreadFilterRangeSelect, "3", "近90天");
|
||||
spreadFilterRangeSelect.value = "2";
|
||||
|
||||
const spreadThresholdInputs = createSpreadThresholdInputs(document);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.dataset.pluginToolbarPanel = "root";
|
||||
applyToolbarPanelStyles(panel);
|
||||
|
||||
const firstRow = document.createElement("div");
|
||||
firstRow.dataset.pluginToolbarRow = "primary";
|
||||
applyToolbarRowStyles(firstRow);
|
||||
|
||||
const secondRow = document.createElement("div");
|
||||
secondRow.dataset.pluginToolbarRow = "thresholds";
|
||||
applyToolbarRowStyles(secondRow);
|
||||
|
||||
const dataGroup = document.createElement("div");
|
||||
dataGroup.dataset.pluginToolbarGroup = "data";
|
||||
applyToolbarGroupStyles(dataGroup);
|
||||
dataGroup.append(
|
||||
createToolbarGroupTitle(document, "达人数据"),
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton
|
||||
);
|
||||
|
||||
const videoGroup = document.createElement("div");
|
||||
videoGroup.dataset.pluginToolbarGroup = "video";
|
||||
applyToolbarGroupStyles(videoGroup);
|
||||
videoGroup.append(
|
||||
createToolbarGroupTitle(document, "视频口径"),
|
||||
spreadFilterTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterRangeSelect
|
||||
);
|
||||
|
||||
const thresholdGroup = document.createElement("div");
|
||||
thresholdGroup.dataset.pluginToolbarGroup = "thresholds";
|
||||
applyToolbarGroupStyles(thresholdGroup);
|
||||
thresholdGroup.append(
|
||||
createToolbarGroupTitle(document, "传播指标"),
|
||||
...Object.values(spreadThresholdInputs)
|
||||
);
|
||||
|
||||
const divider = document.createElement("span");
|
||||
applyToolbarDividerStyles(divider);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.dataset.pluginToolbarActions = "root";
|
||||
applyToolbarActionStyles(actions);
|
||||
actions.append(batchSubmitButton);
|
||||
|
||||
firstRow.append(dataGroup, divider, videoGroup, exportStatusText);
|
||||
secondRow.append(thresholdGroup);
|
||||
panel.append(firstRow, secondRow);
|
||||
|
||||
root.append(
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput,
|
||||
exportButton,
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportStatusText
|
||||
panel,
|
||||
actions
|
||||
);
|
||||
|
||||
document.body.appendChild(root);
|
||||
@@ -133,7 +215,12 @@ export function ensurePluginToolbar(
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect
|
||||
exportRangeSelect,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
...Object.values(spreadThresholdInputs)
|
||||
});
|
||||
ensureToolbarMounted(root, document);
|
||||
|
||||
@@ -165,6 +252,21 @@ export function ensurePluginToolbar(
|
||||
root
|
||||
});
|
||||
});
|
||||
spreadFilterTypeSelect.addEventListener("change", () => {
|
||||
syncSpreadFilterControlState({
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
root,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
spreadThresholdInputs
|
||||
});
|
||||
});
|
||||
|
||||
const toolbarDom = {
|
||||
audienceProfileExportButton,
|
||||
@@ -175,9 +277,15 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
spreadThresholdInputs,
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
syncSpreadFilterControlState(toolbarDom);
|
||||
|
||||
return toolbarDom;
|
||||
}
|
||||
@@ -193,6 +301,73 @@ function appendOption(
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
function createSpreadThresholdInputs(
|
||||
document: Document
|
||||
): Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement> {
|
||||
return {
|
||||
averageCommentCount: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageCommentCount",
|
||||
"评论>="
|
||||
),
|
||||
averageDuration: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageDuration",
|
||||
"时长>="
|
||||
),
|
||||
averageLikeCount: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageLikeCount",
|
||||
"点赞>="
|
||||
),
|
||||
averageShareCount: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageShareCount",
|
||||
"转发>="
|
||||
),
|
||||
finishRate: createSpreadThresholdInput(document, "finishRate", "完播率>="),
|
||||
interactionRate: createSpreadThresholdInput(document, "interactionRate", "互动率>="),
|
||||
playMedian: createSpreadThresholdInput(document, "playMedian", "播放中位数>=")
|
||||
};
|
||||
}
|
||||
|
||||
function createSpreadThresholdInput(
|
||||
document: Document,
|
||||
key: keyof SpreadThresholdFilter["thresholds"],
|
||||
placeholder: string
|
||||
): HTMLInputElement {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "0";
|
||||
input.step = "0.01";
|
||||
input.placeholder = placeholder;
|
||||
input.dataset.pluginSpreadThreshold = key;
|
||||
return input;
|
||||
}
|
||||
|
||||
function readSpreadThresholdInputs(
|
||||
root: HTMLElement
|
||||
): Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement> {
|
||||
return {
|
||||
averageCommentCount: readSpreadThresholdInput(root, "averageCommentCount"),
|
||||
averageDuration: readSpreadThresholdInput(root, "averageDuration"),
|
||||
averageLikeCount: readSpreadThresholdInput(root, "averageLikeCount"),
|
||||
averageShareCount: readSpreadThresholdInput(root, "averageShareCount"),
|
||||
finishRate: readSpreadThresholdInput(root, "finishRate"),
|
||||
interactionRate: readSpreadThresholdInput(root, "interactionRate"),
|
||||
playMedian: readSpreadThresholdInput(root, "playMedian")
|
||||
};
|
||||
}
|
||||
|
||||
function readSpreadThresholdInput(
|
||||
root: HTMLElement,
|
||||
key: keyof SpreadThresholdFilter["thresholds"]
|
||||
): HTMLInputElement {
|
||||
return root.querySelector(
|
||||
`[data-plugin-spread-threshold="${key}"]`
|
||||
) as HTMLInputElement;
|
||||
}
|
||||
|
||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
const toolbarDom = {
|
||||
audienceProfileByIdExportButton: root.querySelector(
|
||||
@@ -219,9 +394,23 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
exportStatusText: root.querySelector(
|
||||
'[data-plugin-export-status="text"]'
|
||||
) as HTMLElement,
|
||||
spreadFilterFlowTypeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="flowType"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterOnlyAssignSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterRangeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="range"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterTypeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="type"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadThresholdInputs: readSpreadThresholdInputs(root),
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
syncSpreadFilterControlState(toolbarDom);
|
||||
return toolbarDom;
|
||||
}
|
||||
|
||||
@@ -280,6 +469,51 @@ export function readToolbarExportTarget(
|
||||
};
|
||||
}
|
||||
|
||||
export function readToolbarSpreadFilter(
|
||||
toolbar: PluginToolbarDom
|
||||
): { error?: string; filter?: SpreadThresholdFilter } {
|
||||
const thresholds: SpreadThresholdFilter["thresholds"] = {};
|
||||
|
||||
for (const [key, input] of Object.entries(toolbar.spreadThresholdInputs)) {
|
||||
const trimmedValue = input.value.trim();
|
||||
if (!trimmedValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const numericValue = Number(trimmedValue);
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return {
|
||||
error: "请输入有效筛选阈值"
|
||||
};
|
||||
}
|
||||
|
||||
thresholds[key as keyof SpreadThresholdFilter["thresholds"]] = numericValue;
|
||||
}
|
||||
|
||||
if (Object.keys(thresholds).length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const type = Number(toolbar.spreadFilterTypeSelect.value) === 2 ? 2 : 1;
|
||||
return {
|
||||
filter: {
|
||||
config: {
|
||||
flowType:
|
||||
type === 1
|
||||
? 0
|
||||
: Number(toolbar.spreadFilterFlowTypeSelect.value) === 1
|
||||
? 1
|
||||
: 0,
|
||||
onlyAssign:
|
||||
type === 1 ? false : toolbar.spreadFilterOnlyAssignSelect.value === "true",
|
||||
range: Number(toolbar.spreadFilterRangeSelect.value) === 3 ? 3 : 2,
|
||||
type
|
||||
},
|
||||
thresholds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setToolbarBusyState(
|
||||
toolbar: PluginToolbarDom,
|
||||
isBusy: boolean
|
||||
@@ -291,10 +525,18 @@ export function setToolbarBusyState(
|
||||
toolbar.audienceProfileExportButton,
|
||||
toolbar.exportButton,
|
||||
toolbar.exportRangeSelect,
|
||||
toolbar.exportCustomPagesInput
|
||||
toolbar.exportCustomPagesInput,
|
||||
toolbar.spreadFilterTypeSelect,
|
||||
toolbar.spreadFilterOnlyAssignSelect,
|
||||
toolbar.spreadFilterFlowTypeSelect,
|
||||
toolbar.spreadFilterRangeSelect,
|
||||
...Object.values(toolbar.spreadThresholdInputs)
|
||||
].forEach((element) => {
|
||||
element.disabled = isBusy;
|
||||
});
|
||||
if (!isBusy) {
|
||||
syncSpreadFilterControlState(toolbar);
|
||||
}
|
||||
}
|
||||
|
||||
export function setToolbarExportStatus(
|
||||
@@ -309,6 +551,16 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||
toolbar.exportCustomPagesInput.hidden = true;
|
||||
}
|
||||
|
||||
function syncSpreadFilterControlState(toolbar: PluginToolbarDom): void {
|
||||
const isPersonalVideo = toolbar.spreadFilterTypeSelect.value !== "2";
|
||||
if (isPersonalVideo) {
|
||||
toolbar.spreadFilterOnlyAssignSelect.value = "false";
|
||||
toolbar.spreadFilterFlowTypeSelect.value = "0";
|
||||
}
|
||||
toolbar.spreadFilterOnlyAssignSelect.disabled = isPersonalVideo;
|
||||
toolbar.spreadFilterFlowTypeSelect.disabled = isPersonalVideo;
|
||||
}
|
||||
|
||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||
const actionRow = findNativeActionRow(document);
|
||||
if (!actionRow) {
|
||||
@@ -482,6 +734,57 @@ function applyToolbarRootStyles(root: HTMLElement): void {
|
||||
root.style.flexWrap = "wrap";
|
||||
}
|
||||
|
||||
function applyToolbarPanelStyles(panel: HTMLElement): void {
|
||||
panel.style.display = "flex";
|
||||
panel.style.flexDirection = "column";
|
||||
panel.style.gap = "8px";
|
||||
panel.style.minWidth = "0";
|
||||
panel.style.padding = "4px 0";
|
||||
}
|
||||
|
||||
function applyToolbarRowStyles(row: HTMLElement): void {
|
||||
row.style.display = "flex";
|
||||
row.style.alignItems = "center";
|
||||
row.style.gap = "10px";
|
||||
row.style.minHeight = "32px";
|
||||
row.style.minWidth = "0";
|
||||
row.style.flexWrap = "wrap";
|
||||
}
|
||||
|
||||
function applyToolbarGroupStyles(group: HTMLElement): void {
|
||||
group.style.display = "flex";
|
||||
group.style.alignItems = "center";
|
||||
group.style.gap = "8px";
|
||||
group.style.minWidth = "0";
|
||||
group.style.flexWrap = "wrap";
|
||||
}
|
||||
|
||||
function createToolbarGroupTitle(document: Document, label: string): HTMLElement {
|
||||
const title = document.createElement("span");
|
||||
title.textContent = label;
|
||||
title.style.color = "#64748b";
|
||||
title.style.fontSize = "12px";
|
||||
title.style.fontWeight = "700";
|
||||
title.style.lineHeight = "32px";
|
||||
title.style.whiteSpace = "nowrap";
|
||||
return title;
|
||||
}
|
||||
|
||||
function applyToolbarDividerStyles(divider: HTMLElement): void {
|
||||
divider.style.width = "1px";
|
||||
divider.style.height = "24px";
|
||||
divider.style.background = "#e5e7eb";
|
||||
divider.style.flex = "0 0 auto";
|
||||
}
|
||||
|
||||
function applyToolbarActionStyles(actions: HTMLElement): void {
|
||||
actions.style.display = "flex";
|
||||
actions.style.alignItems = "center";
|
||||
actions.style.gap = "8px";
|
||||
actions.style.paddingLeft = "12px";
|
||||
actions.style.borderLeft = "1px solid #e5e7eb";
|
||||
}
|
||||
|
||||
function applyNativeControlStyles(
|
||||
document: Document,
|
||||
controls: {
|
||||
@@ -492,7 +795,14 @@ function applyNativeControlStyles(
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
}
|
||||
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
||||
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
||||
spreadFilterRangeSelect: HTMLSelectElement;
|
||||
spreadFilterTypeSelect: HTMLSelectElement;
|
||||
} & Record<
|
||||
keyof SpreadThresholdFilter["thresholds"],
|
||||
HTMLInputElement
|
||||
>
|
||||
): void {
|
||||
const primaryButton =
|
||||
findButtonContainingText(document, "发布任务") ??
|
||||
@@ -519,7 +829,13 @@ function applyNativeControlStyles(
|
||||
button.style.whiteSpace = "nowrap";
|
||||
});
|
||||
|
||||
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
|
||||
const nativeControls = Array.from(Object.values(controls)).filter(
|
||||
(element): element is HTMLInputElement | HTMLSelectElement =>
|
||||
element instanceof document.defaultView!.HTMLInputElement ||
|
||||
element instanceof document.defaultView!.HTMLSelectElement
|
||||
);
|
||||
|
||||
nativeControls.forEach((element) => {
|
||||
element.style.height = "32px";
|
||||
element.style.border = "1px solid #d0d7de";
|
||||
element.style.borderRadius = "6px";
|
||||
@@ -531,6 +847,25 @@ function applyNativeControlStyles(
|
||||
|
||||
controls.exportRangeSelect.style.minWidth = "104px";
|
||||
controls.exportCustomPagesInput.style.width = "72px";
|
||||
|
||||
[
|
||||
controls.spreadFilterTypeSelect,
|
||||
controls.spreadFilterOnlyAssignSelect,
|
||||
controls.spreadFilterFlowTypeSelect,
|
||||
controls.spreadFilterRangeSelect
|
||||
].forEach((select) => {
|
||||
select.style.minWidth = "92px";
|
||||
});
|
||||
|
||||
Object.values(controls).forEach((element) => {
|
||||
if (
|
||||
element instanceof document.defaultView!.HTMLInputElement &&
|
||||
element.dataset.pluginSpreadThreshold
|
||||
) {
|
||||
element.style.width =
|
||||
element.dataset.pluginSpreadThreshold === "playMedian" ? "112px" : "86px";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyPrimaryButtonStyles(
|
||||
|
||||
@@ -2,7 +2,8 @@ import type {
|
||||
BackendMetrics,
|
||||
MarketApiFailureReason,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot
|
||||
MarketRowSnapshot,
|
||||
SpreadInfoMetrics
|
||||
} from "./types";
|
||||
import type { AfterSearchRates } from "./types";
|
||||
|
||||
@@ -46,6 +47,13 @@ export function createMarketResultStore() {
|
||||
...backendMetrics
|
||||
};
|
||||
},
|
||||
setSpreadMetricsSuccess(authorId: string, spreadMetrics: SpreadInfoMetrics) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.spreadMetrics = {
|
||||
...existingRecord.spreadMetrics,
|
||||
...spreadMetrics
|
||||
};
|
||||
},
|
||||
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.status = "success";
|
||||
@@ -73,6 +81,10 @@ export function createMarketResultStore() {
|
||||
existingRecord.price21To60s,
|
||||
row.price21To60s
|
||||
);
|
||||
existingRecord.spreadAuthorId = mergeStringValue(
|
||||
existingRecord.spreadAuthorId,
|
||||
row.spreadAuthorId
|
||||
);
|
||||
existingRecord.exportFields = mergeFieldMap(
|
||||
existingRecord.exportFields,
|
||||
row.exportFields
|
||||
@@ -81,6 +93,10 @@ export function createMarketResultStore() {
|
||||
existingRecord.backendMetrics,
|
||||
row.backendMetrics
|
||||
);
|
||||
existingRecord.spreadMetrics = mergeFieldMap(
|
||||
existingRecord.spreadMetrics,
|
||||
row.spreadMetrics
|
||||
);
|
||||
existingRecord.hasDirectRatesSource =
|
||||
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
|
||||
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import type { SpreadInfoMetrics, SpreadMetricThresholds } from "./types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
export interface SpreadInfoConfig {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
}
|
||||
|
||||
interface SpreadInfoClientOptions {
|
||||
baseUrl?: string;
|
||||
configs?: SpreadInfoConfig[];
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface SpreadInfoMetricDefinition {
|
||||
key: keyof MappedSpreadInfoResponse;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface MappedSpreadInfoResponse {
|
||||
averageCommentCount?: string;
|
||||
averageDuration?: string;
|
||||
averageLikeCount?: string;
|
||||
averageShareCount?: string;
|
||||
finishRate?: string;
|
||||
interactionRate?: string;
|
||||
playMedian?: string;
|
||||
}
|
||||
|
||||
const SPREAD_INFO_METRICS: SpreadInfoMetricDefinition[] = [
|
||||
{
|
||||
key: "finishRate",
|
||||
label: "完播率"
|
||||
},
|
||||
{
|
||||
key: "playMedian",
|
||||
label: "播放量中位数"
|
||||
},
|
||||
{
|
||||
key: "interactionRate",
|
||||
label: "互动率"
|
||||
},
|
||||
{
|
||||
key: "averageDuration",
|
||||
label: "作品平均时长"
|
||||
},
|
||||
{
|
||||
key: "averageCommentCount",
|
||||
label: "作品平均评论数"
|
||||
},
|
||||
{
|
||||
key: "averageLikeCount",
|
||||
label: "作品平均点赞数"
|
||||
},
|
||||
{
|
||||
key: "averageShareCount",
|
||||
label: "作品平均转发数"
|
||||
}
|
||||
];
|
||||
|
||||
export const DEFAULT_SPREAD_INFO_CONFIGS: SpreadInfoConfig[] = [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
},
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
},
|
||||
...buildXingtuVideoConfigs()
|
||||
];
|
||||
|
||||
export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
||||
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||
const configs = options.configs ?? DEFAULT_SPREAD_INFO_CONFIGS;
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
|
||||
return {
|
||||
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
|
||||
const metrics: SpreadInfoMetrics = {};
|
||||
|
||||
for (const config of configs) {
|
||||
const mappedResponse = await loadSpreadInfoFromUrl(
|
||||
buildSpreadInfoUrl(authorId, config, baseUrl)
|
||||
);
|
||||
Object.entries(buildSpreadInfoMetricMap(config, mappedResponse)).forEach(
|
||||
([header, value]) => {
|
||||
metrics[header] = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
},
|
||||
async loadAuthorSpreadMetricSnapshot(
|
||||
authorId: string,
|
||||
config: SpreadInfoConfig
|
||||
): Promise<MappedSpreadInfoResponse> {
|
||||
return loadSpreadInfoFromUrl(buildSpreadInfoUrl(authorId, config, baseUrl));
|
||||
}
|
||||
};
|
||||
|
||||
async function loadSpreadInfoFromUrl(
|
||||
url: string
|
||||
): Promise<MappedSpreadInfoResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
credentials: "include",
|
||||
method: "GET",
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return mapSpreadInfoResponse(await response.json());
|
||||
} catch {
|
||||
return {};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpreadInfoUrl(
|
||||
authorId: string,
|
||||
config: SpreadInfoConfig,
|
||||
baseUrl: string
|
||||
): string {
|
||||
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
|
||||
url.searchParams.set("o_author_id", authorId);
|
||||
url.searchParams.set("platform_source", "1");
|
||||
url.searchParams.set("platform_channel", "1");
|
||||
url.searchParams.set("type", String(config.type));
|
||||
url.searchParams.set("flow_type", String(config.flowType));
|
||||
url.searchParams.set("only_assign", String(config.onlyAssign));
|
||||
url.searchParams.set("range", String(config.range));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildSpreadInfoColumns(
|
||||
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
|
||||
): string[] {
|
||||
return configs.flatMap((config) =>
|
||||
SPREAD_INFO_METRICS.map((metric) => buildSpreadInfoColumnHeader(config, metric))
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSpreadInfoMetricMap(
|
||||
config: SpreadInfoConfig,
|
||||
metrics: MappedSpreadInfoResponse
|
||||
): SpreadInfoMetrics {
|
||||
const values: SpreadInfoMetrics = {};
|
||||
|
||||
SPREAD_INFO_METRICS.forEach((metric) => {
|
||||
const value = metrics[metric.key];
|
||||
if (hasTextValue(value)) {
|
||||
values[buildSpreadInfoColumnHeader(config, metric)] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function mapSpreadInfoResponse(
|
||||
payload: unknown
|
||||
): MappedSpreadInfoResponse {
|
||||
const data = getPayloadData(payload);
|
||||
if (!data) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
averageCommentCount: readStringLike(data.comment_avg),
|
||||
averageDuration: formatMillisecondsAsSeconds(readNumberLike(data.avg_duration)),
|
||||
averageLikeCount: readStringLike(data.like_avg),
|
||||
averageShareCount: readStringLike(data.share_avg),
|
||||
finishRate: formatBasisPointPercent(
|
||||
readNumberLike(readNestedValue(data.play_over_rate, "value"))
|
||||
),
|
||||
interactionRate: formatBasisPointPercent(
|
||||
readNumberLike(readNestedValue(data.interact_rate, "value"))
|
||||
),
|
||||
playMedian:
|
||||
readStringLike(data.play_mid) ??
|
||||
readStringLike(readNestedValue(readNestedValue(data.item_rate, "play_mid"), "value"))
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesSpreadThresholds(
|
||||
metrics: MappedSpreadInfoResponse,
|
||||
thresholds: SpreadMetricThresholds
|
||||
): boolean {
|
||||
return Object.entries(thresholds).every(([key, threshold]) => {
|
||||
if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metricValue = metrics[key as keyof SpreadMetricThresholds];
|
||||
const numericValue = readDisplayNumber(metricValue);
|
||||
return numericValue !== null && numericValue >= threshold;
|
||||
});
|
||||
}
|
||||
|
||||
function buildSpreadInfoColumnHeader(
|
||||
config: SpreadInfoConfig,
|
||||
metric: SpreadInfoMetricDefinition
|
||||
): string {
|
||||
return [...buildConfigPrefixParts(config), metric.label].join("_");
|
||||
}
|
||||
|
||||
function buildConfigPrefixParts(config: SpreadInfoConfig): string[] {
|
||||
const typeLabel = config.type === 1 ? "个人视频" : "星图视频";
|
||||
const rangeLabel = config.range === 2 ? "近30天" : "近90天";
|
||||
|
||||
if (config.type === 1) {
|
||||
return [typeLabel, rangeLabel];
|
||||
}
|
||||
|
||||
return [
|
||||
config.onlyAssign ? "只看指派" : "不限指派",
|
||||
config.flowType === 1 ? "排除营销流量" : "不排除营销流量",
|
||||
typeLabel,
|
||||
rangeLabel
|
||||
];
|
||||
}
|
||||
|
||||
function buildXingtuVideoConfigs(): SpreadInfoConfig[] {
|
||||
const configs: SpreadInfoConfig[] = [];
|
||||
[false, true].forEach((onlyAssign) => {
|
||||
([0, 1] as const).forEach((flowType) => {
|
||||
([2, 3] as const).forEach((range) => {
|
||||
configs.push({
|
||||
flowType,
|
||||
onlyAssign,
|
||||
range,
|
||||
type: 2
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return configs;
|
||||
}
|
||||
|
||||
function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isRecord(payload.data) ? payload.data : payload;
|
||||
}
|
||||
|
||||
function readNestedValue(value: unknown, key: string): unknown {
|
||||
return isRecord(value) ? value[key] : undefined;
|
||||
}
|
||||
|
||||
function readStringLike(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumberLike(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDisplayNumber(value: string | undefined): number | null {
|
||||
if (!hasTextValue(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number(value.replace(/[% ,]/g, ""));
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
function formatBasisPointPercent(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${formatDecimal(value / 100)}%`;
|
||||
}
|
||||
|
||||
function formatMillisecondsAsSeconds(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formatDecimal(value / 100);
|
||||
}
|
||||
|
||||
function formatDecimal(value: number): string {
|
||||
return value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
if (typeof location !== "undefined" && location.origin) {
|
||||
return location.origin;
|
||||
}
|
||||
|
||||
return "https://www.xingtu.cn";
|
||||
}
|
||||
|
||||
async function defaultFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -12,6 +12,28 @@ export interface BackendMetrics {
|
||||
newA3Rate?: string;
|
||||
}
|
||||
|
||||
export type SpreadInfoMetrics = Record<string, string>;
|
||||
|
||||
export interface SpreadMetricThresholds {
|
||||
averageCommentCount?: number;
|
||||
averageDuration?: number;
|
||||
averageLikeCount?: number;
|
||||
averageShareCount?: number;
|
||||
finishRate?: number;
|
||||
interactionRate?: number;
|
||||
playMedian?: number;
|
||||
}
|
||||
|
||||
export interface SpreadThresholdFilter {
|
||||
config: {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
};
|
||||
thresholds: SpreadMetricThresholds;
|
||||
}
|
||||
|
||||
export type MarketSortField =
|
||||
| keyof Required<AfterSearchRates>
|
||||
| keyof Required<BackendMetrics>;
|
||||
@@ -28,6 +50,8 @@ export interface MarketRowSnapshot {
|
||||
location?: string;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
spreadMetrics?: SpreadInfoMetrics;
|
||||
}
|
||||
|
||||
export interface MarketRecord extends MarketRowSnapshot {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildMarketCsv } from "../src/content/market/csv-exporter";
|
||||
import { buildSpreadInfoColumns } from "../src/content/market/spread-info";
|
||||
import type { MarketRecord } from "../src/content/market/types";
|
||||
|
||||
describe("csv-exporter", () => {
|
||||
@@ -21,12 +22,13 @@ describe("csv-exporter", () => {
|
||||
"秒思api-新增A3数",
|
||||
"秒思api-新增A3率",
|
||||
"秒思api-CPA3",
|
||||
"秒思api-cp_search"
|
||||
"秒思api-cp_search",
|
||||
...buildSpreadInfoColumns()
|
||||
].join(",")
|
||||
);
|
||||
});
|
||||
|
||||
test("uses page export field order and appends the two plugin columns", () => {
|
||||
test("uses page export field order and appends the plugin columns", () => {
|
||||
const csv = buildMarketCsv([
|
||||
{
|
||||
authorId: "123",
|
||||
@@ -66,11 +68,16 @@ describe("csv-exporter", () => {
|
||||
"秒思api-新增A3数",
|
||||
"秒思api-新增A3率",
|
||||
"秒思api-CPA3",
|
||||
"秒思api-cp_search"
|
||||
"秒思api-cp_search",
|
||||
...buildSpreadInfoColumns()
|
||||
].join(",")
|
||||
);
|
||||
expect(rowLine).toBe(
|
||||
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
|
||||
expect(rowLine).toMatch(
|
||||
new RegExp(
|
||||
`^${escapeRegExp(
|
||||
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
|
||||
)}`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -101,10 +108,13 @@ describe("csv-exporter", () => {
|
||||
"秒思api-新增A3数",
|
||||
"秒思api-新增A3率",
|
||||
"秒思api-CPA3",
|
||||
"秒思api-cp_search"
|
||||
"秒思api-cp_search",
|
||||
...buildSpreadInfoColumns()
|
||||
].join(",")
|
||||
);
|
||||
expect(rowLine).toBe("Alice,100w,,,,,,,,");
|
||||
expect(rowLine.split(",").slice(0, 10).join(",")).toBe(
|
||||
"Alice,100w,,,,,,,,"
|
||||
);
|
||||
});
|
||||
|
||||
test("escapes commas and quotes", () => {
|
||||
@@ -137,7 +147,10 @@ describe("csv-exporter", () => {
|
||||
]);
|
||||
|
||||
const [, rowLine] = csv.split("\n");
|
||||
expect(rowLine).toBe("123,Alice,,,,,,,,,,");
|
||||
expect(rowLine.split(",").slice(0, 12).join(",")).toBe(
|
||||
"123,Alice,,,,,,,,,,"
|
||||
);
|
||||
expect(rowLine.split(",").slice(12).every((cell) => cell === "")).toBe(true);
|
||||
});
|
||||
|
||||
test("uses normalized display values in export rows", () => {
|
||||
@@ -172,6 +185,53 @@ describe("csv-exporter", () => {
|
||||
]);
|
||||
|
||||
const [, rowLine] = csv.split("\n");
|
||||
expect(rowLine).toBe("123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,");
|
||||
expect(rowLine.split(",").slice(0, 12).join(",")).toBe(
|
||||
"123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,"
|
||||
);
|
||||
});
|
||||
|
||||
test("appends spread info metric columns after backend metrics", () => {
|
||||
const csv = buildMarketCsv([
|
||||
{
|
||||
authorId: "123",
|
||||
authorName: "Alice",
|
||||
spreadMetrics: {
|
||||
"个人视频_近30天_完播率": "28.24%",
|
||||
"只看指派_排除营销流量_星图视频_近30天_互动率": "4.02%"
|
||||
},
|
||||
status: "success"
|
||||
} satisfies MarketRecord
|
||||
]);
|
||||
|
||||
const [headerLine, rowLine] = csv.split("\n");
|
||||
expect(headerLine).toContain(
|
||||
[
|
||||
"秒思api-cp_search",
|
||||
"个人视频_近30天_完播率",
|
||||
"个人视频_近30天_播放量中位数"
|
||||
].join(",")
|
||||
);
|
||||
expect(headerLine).toContain(
|
||||
"只看指派_排除营销流量_星图视频_近30天_互动率"
|
||||
);
|
||||
expect(rowLine).toContain("28.24%");
|
||||
expect(rowLine).toContain("4.02%");
|
||||
});
|
||||
|
||||
test("emits empty spread info cells when spread metrics are absent", () => {
|
||||
const csv = buildMarketCsv([
|
||||
{
|
||||
authorId: "123",
|
||||
authorName: "Alice",
|
||||
status: "success"
|
||||
} satisfies MarketRecord
|
||||
]);
|
||||
|
||||
const [, rowLine] = csv.split("\n");
|
||||
expect(rowLine.split(",").slice(-70).every((cell) => cell === "")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -293,7 +293,10 @@ describe("market-content-entry", () => {
|
||||
});
|
||||
|
||||
test("renders the plugin action bar inside the native market action row", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||
]);
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
@@ -1169,6 +1172,48 @@ describe("market-content-entry", () => {
|
||||
expect(customPagesInput?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
test("toolbar exposes spread threshold filters and disables fixed personal-video controls", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
|
||||
const videoTypeSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="type"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const assignSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const flowTypeSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="flowType"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const finishRateInput = document.querySelector(
|
||||
'[data-plugin-spread-threshold="finishRate"]'
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
expect(videoTypeSelect?.value).toBe("1");
|
||||
expect(assignSelect?.value).toBe("false");
|
||||
expect(assignSelect?.disabled).toBe(true);
|
||||
expect(flowTypeSelect?.value).toBe("0");
|
||||
expect(flowTypeSelect?.disabled).toBe(true);
|
||||
expect(finishRateInput?.placeholder).toBe("完播率>=");
|
||||
|
||||
setSelectValue('[data-plugin-spread-filter="type"]', "2");
|
||||
dispatchChange('[data-plugin-spread-filter="type"]');
|
||||
|
||||
expect(assignSelect?.disabled).toBe(false);
|
||||
expect(flowTypeSelect?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("export uses the current page ordering without triggering a full scan", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const resultStore = createMarketResultStore();
|
||||
@@ -1217,6 +1262,129 @@ describe("market-content-entry", () => {
|
||||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||||
});
|
||||
|
||||
test("export hydrates spread info with attribute_datas.id before building csv", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||
]);
|
||||
attachMarketListState([
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-a",
|
||||
nickname: "Alpha"
|
||||
},
|
||||
star_id: "a"
|
||||
},
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-b",
|
||||
nickname: "Beta"
|
||||
},
|
||||
star_id: "b"
|
||||
}
|
||||
]);
|
||||
const buildCsv = vi.fn(() => "csv-output");
|
||||
const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
||||
"个人视频_近30天_完播率": spreadAuthorId === "spread-a" ? "28.24%" : "18.24%"
|
||||
}));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
buildCsv,
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
loadSpreadMetrics,
|
||||
onCsvReady: vi.fn(),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-a");
|
||||
expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-b");
|
||||
expect(buildCsv.mock.calls[0][0]).toEqual([
|
||||
expect.objectContaining({
|
||||
authorId: "a",
|
||||
spreadAuthorId: "spread-a",
|
||||
spreadMetrics: {
|
||||
"个人视频_近30天_完播率": "28.24%"
|
||||
}
|
||||
}),
|
||||
expect.objectContaining({
|
||||
authorId: "b",
|
||||
spreadAuthorId: "spread-b",
|
||||
spreadMetrics: {
|
||||
"个人视频_近30天_完播率": "18.24%"
|
||||
}
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
test("export keeps only records that match spread threshold filters", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||
]);
|
||||
attachMarketListState([
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-a",
|
||||
nickname: "Alpha"
|
||||
},
|
||||
star_id: "a"
|
||||
},
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-b",
|
||||
nickname: "Beta"
|
||||
},
|
||||
star_id: "b"
|
||||
}
|
||||
]);
|
||||
const buildCsv = vi.fn(() => "csv-output");
|
||||
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%",
|
||||
interactionRate: "5%"
|
||||
}));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
buildCsv,
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
loadSpreadFilterMetrics,
|
||||
onCsvReady: vi.fn(),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
});
|
||||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||||
"a"
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
|
||||
async () => {
|
||||
@@ -2158,6 +2326,64 @@ describe("market-content-entry", () => {
|
||||
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
|
||||
});
|
||||
|
||||
test("batch submit keeps only records that match spread threshold filters", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||
]);
|
||||
attachMarketListState([
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-a",
|
||||
nickname: "Alpha"
|
||||
},
|
||||
star_id: "a"
|
||||
},
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-b",
|
||||
nickname: "Beta"
|
||||
},
|
||||
star_id: "b"
|
||||
}
|
||||
]);
|
||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
|
||||
}));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
getAuthState: async () => ({
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||
}),
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
loadSpreadFilterMetrics,
|
||||
promptBatchName: () => "筛选批次",
|
||||
submitBatch,
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 80, 50);
|
||||
|
||||
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
|
||||
expect.objectContaining({
|
||||
authorId: "a"
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
test("opens a custom batch name dialog before submitting", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
@@ -155,6 +155,49 @@ describe("silent-export-controller", () => {
|
||||
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
|
||||
});
|
||||
|
||||
test("keeps attribute_datas.id as the spread author id while preserving star_id as row id", async () => {
|
||||
document.documentElement.setAttribute(
|
||||
"data-sces-market-request-snapshot",
|
||||
JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
page_param: {
|
||||
page: 1
|
||||
}
|
||||
}),
|
||||
method: "POST",
|
||||
url: "https://xingtu.cn/api/mock-market-search"
|
||||
})
|
||||
);
|
||||
|
||||
const controller = createSilentExportController({
|
||||
document,
|
||||
fetchImpl: async () => ({
|
||||
json: async () => ({
|
||||
authors: [
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-1",
|
||||
nickname: "达人1"
|
||||
},
|
||||
star_id: "row-1"
|
||||
}
|
||||
]
|
||||
}),
|
||||
ok: true
|
||||
})
|
||||
});
|
||||
|
||||
const records = await controller.exportRecords({
|
||||
mode: "count",
|
||||
pageCount: 1
|
||||
});
|
||||
|
||||
expect(records?.[0]).toMatchObject({
|
||||
authorId: "row-1",
|
||||
spreadAuthorId: "spread-1"
|
||||
});
|
||||
});
|
||||
|
||||
test("starts from page 1 when the captured request omits an explicit page number", async () => {
|
||||
document.documentElement.setAttribute(
|
||||
"data-sces-market-request-snapshot",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildSpreadInfoColumns,
|
||||
buildSpreadInfoUrl,
|
||||
createSpreadInfoClient,
|
||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||
matchesSpreadThresholds,
|
||||
mapSpreadInfoResponse
|
||||
} from "../src/content/market/spread-info";
|
||||
|
||||
describe("spread-info", () => {
|
||||
test("builds the spread info url with all request parameters", () => {
|
||||
expect(
|
||||
buildSpreadInfoUrl(
|
||||
"7361012802036695050",
|
||||
{
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
},
|
||||
"https://www.xingtu.cn"
|
||||
)
|
||||
).toBe(
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=7361012802036695050&platform_source=1&platform_channel=1&type=2&flow_type=1&only_assign=true&range=2"
|
||||
);
|
||||
});
|
||||
|
||||
test("defines personal video ranges with fixed non-prefix parameters", () => {
|
||||
const personalConfigs = DEFAULT_SPREAD_INFO_CONFIGS.filter(
|
||||
(config) => config.type === 1
|
||||
);
|
||||
|
||||
expect(personalConfigs).toEqual([
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
},
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
}
|
||||
]);
|
||||
expect(buildSpreadInfoColumns(personalConfigs).slice(0, 2)).toEqual([
|
||||
"个人视频_近30天_完播率",
|
||||
"个人视频_近30天_播放量中位数"
|
||||
]);
|
||||
});
|
||||
|
||||
test("defines all xingtu video assign flow and range combinations", () => {
|
||||
const xingtuConfigs = DEFAULT_SPREAD_INFO_CONFIGS.filter(
|
||||
(config) => config.type === 2
|
||||
);
|
||||
|
||||
expect(xingtuConfigs).toHaveLength(8);
|
||||
expect(xingtuConfigs).toContainEqual({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
});
|
||||
expect(xingtuConfigs).toContainEqual({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 2
|
||||
});
|
||||
expect(buildSpreadInfoColumns([
|
||||
{
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
}
|
||||
])).toEqual([
|
||||
"只看指派_排除营销流量_星图视频_近30天_完播率",
|
||||
"只看指派_排除营销流量_星图视频_近30天_播放量中位数",
|
||||
"只看指派_排除营销流量_星图视频_近30天_互动率",
|
||||
"只看指派_排除营销流量_星图视频_近30天_作品平均时长",
|
||||
"只看指派_排除营销流量_星图视频_近30天_作品平均评论数",
|
||||
"只看指派_排除营销流量_星图视频_近30天_作品平均点赞数",
|
||||
"只看指派_排除营销流量_星图视频_近30天_作品平均转发数"
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps spread info response values into display values", () => {
|
||||
expect(
|
||||
mapSpreadInfoResponse({
|
||||
avg_duration: "5600",
|
||||
comment_avg: "7502",
|
||||
interact_rate: {
|
||||
value: 402
|
||||
},
|
||||
item_rate: {
|
||||
play_mid: {
|
||||
value: 10913233
|
||||
}
|
||||
},
|
||||
like_avg: "494458",
|
||||
play_over_rate: {
|
||||
value: 2824
|
||||
},
|
||||
share_avg: "188267"
|
||||
})
|
||||
).toEqual({
|
||||
averageCommentCount: "7502",
|
||||
averageDuration: "56",
|
||||
averageLikeCount: "494458",
|
||||
averageShareCount: "188267",
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%",
|
||||
playMedian: "10913233"
|
||||
});
|
||||
});
|
||||
|
||||
test("loads each configured spread metric column for one author", async () => {
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
json: async () => ({
|
||||
avg_duration: "5600",
|
||||
comment_avg: "7502",
|
||||
interact_rate: {
|
||||
value: 402
|
||||
},
|
||||
like_avg: "494458",
|
||||
play_mid: "10913233",
|
||||
play_over_rate: {
|
||||
value: 2824
|
||||
},
|
||||
share_avg: "188267"
|
||||
}),
|
||||
ok: true
|
||||
}));
|
||||
const client = createSpreadInfoClient({
|
||||
configs: [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
}
|
||||
],
|
||||
fetchImpl
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.loadAuthorSpreadMetrics("7361012802036695050")
|
||||
).resolves.toEqual({
|
||||
"个人视频_近30天_互动率": "4.02%",
|
||||
"个人视频_近30天_作品平均点赞数": "494458",
|
||||
"个人视频_近30天_作品平均评论数": "7502",
|
||||
"个人视频_近30天_作品平均时长": "56",
|
||||
"个人视频_近30天_作品平均转发数": "188267",
|
||||
"个人视频_近30天_完播率": "28.24%",
|
||||
"个人视频_近30天_播放量中位数": "10913233"
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=7361012802036695050&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=false&range=2",
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("matches thresholds using display values and requires every filled threshold", () => {
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
{
|
||||
averageDuration: "56",
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%",
|
||||
playMedian: "10913233"
|
||||
},
|
||||
{
|
||||
averageDuration: 50,
|
||||
finishRate: 28,
|
||||
interactionRate: 4,
|
||||
playMedian: 10000000
|
||||
}
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
{
|
||||
averageDuration: "56",
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%",
|
||||
playMedian: "10913233"
|
||||
},
|
||||
{
|
||||
averageDuration: 57
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
{
|
||||
finishRate: "28.24%"
|
||||
},
|
||||
{
|
||||
finishRate: 20,
|
||||
interactionRate: 1
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user