3 Commits
Author SHA1 Message Date
wxs 9eb1fe43cc feat: filter exports by spread thresholds 2026-06-29 16:11:52 +08:00
wxs 121977fd0d feat: export author spread metrics 2026-06-29 16:06:14 +08:00
wangshaoqing 7839380613 docs: clarify bridge upgrade for coworkers 2026-05-25 17:08:05 +08:00
20 changed files with 1970 additions and 40 deletions
+14
View File
@@ -76,6 +76,20 @@ The pipeline uses the tag as the release version. Recommended format: `0.MMDD.N`
5. Select the unzipped `dist/` folder.
6. Confirm the extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
## One-Time Bridge Upgrade
Some coworkers may still be using an older unpacked build whose popup cannot read the COS update manifest and only shows:
- `暂时无法检查更新`
For those users, ask them to do one manual bridge upgrade with the newest ZIP:
1. Download the newest `star-chart-search-enhancer-internal.zip`.
2. Unzip it and get the new `dist/` folder.
3. Re-load that `dist/` folder in `chrome://extensions`.
After this one-time bridge upgrade, future updates should continue using the same `dist/` layout.
## Notes
- Keep `.local/extension-key.pem` private and backed up internally.
@@ -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 和提交批次都应用二次筛选。
@@ -111,6 +111,22 @@ git pull
- 然后重新点击 **"加载已解压的扩展程序"**
- 再次选择 `dist` 文件夹
## ♻️ 老用户一次性升级说明
如果你之前已经装过比较早的旧版本,而且插件弹窗里一直显示:
- `暂时无法检查更新`
那通常说明你本地还是旧的更新机制,建议先手动完成一次升级:
1. 先获取我发出的最新压缩包
2. 解压后得到新的 `dist` 文件夹
3. 打开 `chrome://extensions`
4. 删除旧插件,或重新执行一次 **"加载已解压的扩展程序"**
5. 重新选择新的 `dist` 文件夹
完成这一次后,后续再更新时,就可以继续沿用统一的 `dist` 目录方式。
---
## ❓ 常见问题
@@ -124,6 +140,9 @@ A: 请确认下载的是最新版本,可以重新执行 `git pull` 并重新
**Q: 加载后扩展 ID 不对?**
A: 请检查是否选择了 `dist` 文件夹,而不是外层文件夹。
**Q: 我已经在用这个插件了,还需要再用压缩包更新一次吗?**
A: 不一定。只有那些当前弹窗仍然只显示 `暂时无法检查更新` 的旧用户,才建议手动用最新压缩包重新装一次 `dist` 来完成过桥升级。已经能正常发现新版本的同事,继续按普通更新流程走即可。
---
## 📞 需要帮助?
@@ -140,6 +140,24 @@ https://xingtu.cn/ad/creator/market
- 然后重新点击 **"加载已解压的扩展程序"**
- 再次选择新解压出来的 `dist` 文件夹
## ♻️ 老用户一次性升级说明
如果你之前安装的是较早的旧版本,并且插件弹窗里一直显示:
- `暂时无法检查更新`
那通常说明你本地还在使用“旧更新机制”的插件包。
这种情况下,需要**手动用一次最新压缩包升级**:
1. 下载最新的 `star-chart-search-enhancer-internal.zip`
2. 解压后得到新的 `dist` 文件夹
3. 打开 `chrome://extensions`
4. 删除旧插件,或重新点击 **"加载已解压的扩展程序"**
5. 重新选择新的 `dist` 文件夹
完成这一次“过桥升级”后,后面再看到新版本时,就可以继续按统一的 `dist` 更新方式操作。
---
## ❓ 常见问题
@@ -156,6 +174,9 @@ A: 检查浏览器的下载列表,文件可能已经下好了
### Q: 不小心把文件夹删了?
A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
### Q: 我已经在用插件了,还需要再用一次压缩包更新吗?
A: 如果你当前弹窗能正常显示 `发现新版本`,就不需要额外做特殊处理,按普通更新步骤走即可。如果弹窗一直只显示 `暂时无法检查更新`,建议手动用最新压缩包重新安装一次 `dist`,完成一次性升级。
---
## ✅ 每日使用 checklist
+12 -1
View File
@@ -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[] {
+10 -2
View File
@@ -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
View File
@@ -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
};
}
+3 -1
View File
@@ -68,7 +68,9 @@ export function mapMarketListRow(
? {
singleVideoAfterSearchRate
}
: undefined
: undefined,
spreadAuthorId:
readString(readMarketFieldValue(row, attributeDatas, "id")) ?? undefined
};
}
+2 -1
View File
@@ -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));
+345 -10
View File
@@ -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(
+17 -1
View File
@@ -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);
+349
View File
@@ -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;
}
+24
View File
@@ -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 {
+69 -9
View File
@@ -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, "\\$&");
}
+227 -1
View File
@@ -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 }));
+43
View File
@@ -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",
+214
View File
@@ -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);
});
});