feat: make audience export fields selective
This commit is contained in:
+18
-18
@@ -106,17 +106,17 @@
|
|||||||
|
|
||||||
- 触发者:使用者点击 `导出选中达人数据`。
|
- 触发者:使用者点击 `导出选中达人数据`。
|
||||||
- 输入:当前勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。
|
- 输入:当前勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。
|
||||||
- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留该范围内已勾选的达人,然后补充内容数据、效果预估、画像、秒思指标等字段。
|
- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留该范围内已勾选的达人,然后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出。
|
||||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
- 输出:CSV 文件下载到浏览器默认下载目录。
|
||||||
- 下一步:使用者检查 CSV 内容。
|
- 下一步:使用者检查 CSV 内容。
|
||||||
- 人工操作:需要使用者勾选达人并点击按钮。
|
- 人工操作:需要使用者勾选达人并点击按钮。
|
||||||
- 条件分支:如果当前导出范围内没有选中的达人,则不下载 CSV 并提示。
|
- 条件分支:如果当前导出范围内没有选中的达人,则不下载 CSV 并提示;单项补充失败不阻止下载,CSV 的 `导出状态` 和 `失败原因` 会标明部分成功及失败来源。
|
||||||
|
|
||||||
### 2.6 按星图 ID 导出
|
### 2.6 按星图 ID 导出
|
||||||
|
|
||||||
- 触发者:使用者点击 `按星图ID导出`。
|
- 触发者:使用者点击 `按星图ID导出`。
|
||||||
- 输入:弹窗中粘贴的达人星图 ID。
|
- 输入:弹窗中粘贴的达人星图 ID。
|
||||||
- 处理:插件校验 ID 格式、去重、忽略非法 token,然后逐个 ID 请求基础信息、看后搜率、传播指标、画像、商业能力和后端秒思指标。
|
- 处理:插件校验 ID 格式、去重、忽略非法 token,然后逐个 ID 获取基础信息,并按字段选择配置请求看后搜率、传播指标、画像、效果预估和后端秒思指标。未勾选的数据类别不调用对应补充接口。
|
||||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
- 输出:CSV 文件下载到浏览器默认下载目录。
|
||||||
- 下一步:使用者检查 CSV 中每个 ID 的导出状态和失败原因。
|
- 下一步:使用者检查 CSV 中每个 ID 的导出状态和失败原因。
|
||||||
- 人工操作:需要使用者粘贴 ID 并确认。
|
- 人工操作:需要使用者粘贴 ID 并确认。
|
||||||
@@ -216,9 +216,9 @@
|
|||||||
- 必须先勾选达人;
|
- 必须先勾选达人;
|
||||||
- 先按导出范围收集达人;
|
- 先按导出范围收集达人;
|
||||||
- 再严格保留当前导出范围内已勾选的达人;
|
- 再严格保留当前导出范围内已勾选的达人;
|
||||||
- 对每个达人补充画像、商业能力、传播指标、看后搜率、秒思指标;
|
- 只补充已选字段对应的画像、效果预估、传播指标、看后搜率和秒思指标,未选数据类别不调用对应接口;
|
||||||
- 字段选择只控制可选字段,基础字段、导出状态和失败原因等固定保留;
|
- 基础字段、导出状态和失败原因等固定保留;
|
||||||
- 如果全部画像请求失败,则不下载 CSV 并提示画像导出失败;
|
- 画像请求全部失败时仍下载 CSV,已成功的效果预估等字段正常写入;
|
||||||
- 单个达人部分接口失败时,CSV 保留该行,并写入导出状态和失败原因。
|
- 单个达人部分接口失败时,CSV 保留该行,并写入导出状态和失败原因。
|
||||||
- 输出结果:CSV 文件。
|
- 输出结果:CSV 文件。
|
||||||
- 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。
|
- 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
- 没有勾选达人:提示并停止;
|
- 没有勾选达人:提示并停止;
|
||||||
- 当前范围内无选中达人:提示并停止;
|
- 当前范围内无选中达人:提示并停止;
|
||||||
- 单个达人部分失败:记录为部分成功或失败;
|
- 单个达人部分失败:记录为部分成功或失败;
|
||||||
- 全部画像失败:不下载 CSV。
|
- 画像失败:对应画像单元格留空,失败来源写入 `失败原因`,其他成功数据仍保留并下载。
|
||||||
- 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。
|
- 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。
|
||||||
|
|
||||||
### 3.7 按星图 ID 导出
|
### 3.7 按星图 ID 导出
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
- 只接受 16 到 20 位纯数字;
|
- 只接受 16 到 20 位纯数字;
|
||||||
- 重复 ID 会去重;
|
- 重复 ID 会去重;
|
||||||
- 非法 token 会计入提示,但不会进入导出;
|
- 非法 token 会计入提示,但不会进入导出;
|
||||||
- 对有效 ID 逐个补齐基础信息、看后搜率、传播指标、画像、商业能力和后端秒思指标;
|
- 对有效 ID 逐个获取基础信息,只补齐已选字段对应的看后搜率、传播指标、画像、效果预估和后端秒思指标;
|
||||||
- 每个 ID 生成一行 CSV,并标记成功、部分成功或失败。
|
- 每个 ID 生成一行 CSV,并标记成功、部分成功或失败。
|
||||||
- 输出结果:按 ID 导出的 CSV 文件。
|
- 输出结果:按 ID 导出的 CSV 文件。
|
||||||
- 外部依赖:星图基础信息接口、星图指标接口、公司后端指标接口、浏览器下载能力。
|
- 外部依赖:星图基础信息接口、星图指标接口、公司后端指标接口、浏览器下载能力。
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
- 优先通过 Chrome 扩展后台下载;
|
- 优先通过 Chrome 扩展后台下载;
|
||||||
- 如果扩展下载通道不可用,则使用页面中的临时下载链接;
|
- 如果扩展下载通道不可用,则使用页面中的临时下载链接;
|
||||||
- CSV 带 UTF-8 BOM,方便表格软件识别中文;
|
- CSV 带 UTF-8 BOM,方便表格软件识别中文;
|
||||||
- 普通导出文件名使用插件名加时间戳;
|
- 选中达人导出文件名使用画像导出标识加时间戳;
|
||||||
- 按 ID 导出文件名包含按 ID 导出标识。
|
- 按 ID 导出文件名包含按 ID 导出标识。
|
||||||
- 输出结果:浏览器下载列表中出现 CSV 文件。
|
- 输出结果:浏览器下载列表中出现 CSV 文件。
|
||||||
- 外部依赖:Chrome downloads 能力或浏览器下载能力。
|
- 外部依赖:Chrome downloads 能力或浏览器下载能力。
|
||||||
@@ -316,11 +316,11 @@
|
|||||||
| `search_for_author_square` | 获取星图达人市场列表数据 | 后台分页导出、读取达人基础字段 | 当前星图列表请求参数、页码 | 达人列表、分页信息、基础字段 | 是 | 未确认 | 后台分页串行请求;`全部` 最多尝试 200 页 | 未单独设置 fetch 超时 | 无自动重试 | 0 | 无 | 请求失败、响应结构异常、解析失败 | 星图网页登录态和 cookie | 未确认 | 请求失败或解析失败时退回页面翻页读取 |
|
| `search_for_author_square` | 获取星图达人市场列表数据 | 后台分页导出、读取达人基础字段 | 当前星图列表请求参数、页码 | 达人列表、分页信息、基础字段 | 是 | 未确认 | 后台分页串行请求;`全部` 最多尝试 200 页 | 未单独设置 fetch 超时 | 无自动重试 | 0 | 无 | 请求失败、响应结构异常、解析失败 | 星图网页登录态和 cookie | 未确认 | 请求失败或解析失败时退回页面翻页读取 |
|
||||||
| `get_author_commerce_seed_base_info` | 优先获取看后搜率 | 页面增强、导出补充 | `o_author_id`、`range=90` | 商单视频/个人视频看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 有备用接口回退,但不是同接口重试 | 0 | 非超时失败且未成功时转备用接口 | 超时、备用接口也失败 | 星图网页登录态和 cookie | 未确认 | 请求失败时转备用接口;超时直接记为失败 |
|
| `get_author_commerce_seed_base_info` | 优先获取看后搜率 | 页面增强、导出补充 | `o_author_id`、`range=90` | 商单视频/个人视频看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 有备用接口回退,但不是同接口重试 | 0 | 非超时失败且未成功时转备用接口 | 超时、备用接口也失败 | 星图网页登录态和 cookie | 未确认 | 请求失败时转备用接口;超时直接记为失败 |
|
||||||
| `get_author_ase_info` | 备用获取看后搜率 | 页面增强、导出补充 | `author_id`、`range=30` | 看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、缺少指标 | 星图网页登录态和 cookie | 未确认 | 失败后标记该达人看后搜率失败 |
|
| `get_author_ase_info` | 备用获取看后搜率 | 页面增强、导出补充 | `author_id`、`range=30` | 看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、缺少指标 | 星图网页登录态和 cookie | 未确认 | 失败后标记该达人看后搜率失败 |
|
||||||
| `author_audience_distribution` | 获取观众画像 | 导出选中达人、按 ID 导出 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`link_type=5` | 性别、年龄、省份、城市、兴趣、人群等分布 | 否 | 未确认 | 单个达人内画像和商业能力并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因;全部画像失败时不下载选中导出 CSV |
|
| `author_audience_distribution` | 获取观众画像 | 导出选中达人、按 ID 导出 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`link_type=5` | 性别、年龄、省份、城市、兴趣、人群等分布 | 否 | 未确认 | 仅选择观众画像字段时调用;单个达人内与其他已选数据源并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入该行失败原因,其他已选数据继续并下载 CSV |
|
||||||
| `get_author_fans_distribution` | 获取粉丝画像和铁粉画像 | 导出选中达人、按 ID 导出 | `o_author_id`、`platform_source=1`、`author_type=1 或 5` | 粉丝或铁粉分布 | 否 | 未确认 | 单个达人内多个画像请求串行执行;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因 |
|
| `get_author_fans_distribution` | 获取粉丝画像和铁粉画像 | 导出选中达人、按 ID 导出 | `o_author_id`、`platform_source=1`、`author_type=1 或 5` | 粉丝或铁粉分布 | 否 | 未确认 | 粉丝画像和铁粉画像分别按是否选择对应字段决定是否调用;同一达人已选画像请求串行;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入该行失败原因,未选择的画像不请求也不记失败 |
|
||||||
| `get_author_base_info` | 按 ID 导出时获取达人基础信息 | 按星图 ID 导出 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`recommend=true` 等 | 达人名称等基础信息 | 否 | 未确认 | 按 ID 导出中与看后搜率并发;不同 ID 串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单个 ID 基础信息失败,CSV 中记录失败 |
|
| `get_author_base_info` | 按 ID 导出时获取达人基础信息 | 按星图 ID 导出 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`recommend=true` 等 | 达人名称等基础信息 | 否 | 未确认 | 按 ID 导出中与看后搜率并发;不同 ID 串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单个 ID 基础信息失败,CSV 中记录失败 |
|
||||||
| `get_author_commerce_spread_info` | 获取商业能力和效果预估 | 导出选中达人、按 ID 导出 | `o_author_id` | 预期 CPM、预期 CPE、预期播放量、爆文率 | 否 | 未确认 | 与画像请求并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败或超时 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因,其他数据继续 |
|
| `get_author_commerce_spread_info` | 获取商业能力和效果预估 | 导出选中达人、按 ID 导出 | `o_author_id` | 预期 CPM、预期 CPE、预期播放量、爆文率 | 否 | 未确认 | 仅选择效果预估字段时调用;与已选画像请求并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败或超时 | 星图网页登录态和 cookie | 未确认 | 单项失败写入该行失败原因,画像失败或未选择画像都不阻止效果预估结果下载 |
|
||||||
| `get_author_spread_info` | 获取内容传播指标,并用于规则筛选 | 内容数据导出、传播指标筛选 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`type`、`flow_type`、`only_assign`、`range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 指标补充对达人并发;单个达人内多组参数串行;筛选对达人并发,同一达人相同口径的规则复用一次响应 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时字段留空;筛选请求失败、缺少请求 ID 或缺少已选指标时该达人不满足筛选 |
|
| `get_author_spread_info` | 获取内容传播指标,并用于规则筛选 | 内容数据导出、传播指标筛选 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`type`、`flow_type`、`only_assign`、`range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 只请求已勾选内容字段所属的参数组合;同一参数组合下勾选多个字段仍只请求一次;单个达人的多组已选参数串行,达人之间并发;筛选对达人并发,同一达人相同口径的规则复用一次响应 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时字段留空;筛选请求失败、缺少请求 ID 或缺少已选指标时该达人不满足筛选 |
|
||||||
| talent-search 后端 `POST /api/v1/history/talents/search` | 查询秒思 api 指标 | 页面增强、CSV 导出、按 ID 导出 | Bearer token、`type=star_id`、`values`、`page=1`、`size=max(20, ID数量)` | 看后搜率、看后搜数、新增 A3、CPA3、cp_search 等 | 请求固定第一页;接口本身是否支持更多页未确认 | 未确认 | 按页面或导出集合批量请求;同一批只有一个请求 | 未确认 | 无自动重试 | 0 | 无 | token 失败、请求失败、响应结构异常 | Logto access token,当前 resource 为 talent-search | 未确认 | 页面增强中失败标记后端指标失败;导出中失败则相关字段为空 |
|
| talent-search 后端 `POST /api/v1/history/talents/search` | 查询秒思 api 指标 | 页面增强、CSV 导出、按 ID 导出 | Bearer token、`type=star_id`、`values`、`page=1`、`size=max(20, ID数量)` | 看后搜率、看后搜数、新增 A3、CPA3、cp_search 等 | 请求固定第一页;接口本身是否支持更多页未确认 | 未确认 | 按页面或导出集合批量请求;同一批只有一个请求 | 未确认 | 无自动重试 | 0 | 无 | token 失败、请求失败、响应结构异常 | Logto access token,当前 resource 为 talent-search | 未确认 | 页面增强中失败标记后端指标失败;导出中失败则相关字段为空 |
|
||||||
| 批次提交后端 `POST /api/v1/batch-status/batches` | 创建达人批次 | 提交批次 | Bearer token、批次名称、创建人、达人列表 | 成功标志和后端数据 | 否 | 未确认 | 每次点击提交只发一个请求;按钮忙碌态防止流程内重复点击 | 未确认 | 无自动重试 | 0 | 无 | 401、403、非 2xx、后端 `success` 非 true、网络失败 | Logto access token;写权限 scope 是否足够未确认 | 未确认 | 401/403 或非成功响应会终止本次提交 |
|
| 批次提交后端 `POST /api/v1/batch-status/batches` | 创建达人批次 | 提交批次 | Bearer token、批次名称、创建人、达人列表 | 成功标志和后端数据 | 否 | 未确认 | 每次点击提交只发一个请求;按钮忙碌态防止流程内重复点击 | 未确认 | 无自动重试 | 0 | 无 | 401、403、非 2xx、后端 `success` 非 true、网络失败 | Logto access token;写权限 scope 是否足够未确认 | 未确认 | 401/403 或非成功响应会终止本次提交 |
|
||||||
| Logto | 插件登录和获取访问 token | 登录、后端接口调用 | appId、resource、scope、Chrome redirect URL | 登录态、ID claims、access token | 否 | 未确认 | 由 Logto SDK 管理,项目内未设并发规则 | 未确认 | 项目内无自动重试;SDK 内部是否重试未确认 | 未确认 | 未确认 | 登录失败、token 不可用、授权不足 | 公司 Logto 账号和 Chrome identity 回调权限 | 未确认 | 登录失败或 token 不可用时不进入业务流程,或后端调用失败 |
|
| Logto | 插件登录和获取访问 token | 登录、后端接口调用 | appId、resource、scope、Chrome redirect URL | 登录态、ID claims、access token | 否 | 未确认 | 由 Logto SDK 管理,项目内未设并发规则 | 未确认 | 项目内无自动重试;SDK 内部是否重试未确认 | 未确认 | 未确认 | 登录失败、token 不可用、授权不足 | 公司 Logto 账号和 Chrome identity 回调权限 | 未确认 | 登录失败或 token 不可用时不进入业务流程,或后端调用失败 |
|
||||||
@@ -358,8 +358,8 @@
|
|||||||
- 星图页面中缺少达人 ID 或达人名称的行会跳过;
|
- 星图页面中缺少达人 ID 或达人名称的行会跳过;
|
||||||
- 导出选中达人数据必须有已勾选达人;
|
- 导出选中达人数据必须有已勾选达人;
|
||||||
- 画像导出只保留当前导出范围内的已勾选达人;
|
- 画像导出只保留当前导出范围内的已勾选达人;
|
||||||
- 普通导出或提交批次如果存在已选达人,会优先保留当前范围内的已选达人;
|
- 提交批次如果存在已选达人,会优先保留当前范围内的已选达人;
|
||||||
- 如果当前范围内没有任何已选达人,普通导出或提交批次会回退为当前范围全部达人;
|
- 如果当前范围内没有任何已选达人,提交批次会回退为当前范围全部达人;
|
||||||
- 传播指标规则启用后,不满足全部已选规则的达人会被过滤;
|
- 传播指标规则启用后,不满足全部已选规则的达人会被过滤;
|
||||||
- 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。
|
- 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@
|
|||||||
- 看后搜率优先使用列表中已有值,不完整时再请求星图指标接口;
|
- 看后搜率优先使用列表中已有值,不完整时再请求星图指标接口;
|
||||||
- 秒思指标按 star_id 从后端补充;
|
- 秒思指标按 star_id 从后端补充;
|
||||||
- 传播指标按多组参数生成不同列,不合并同名业务指标;
|
- 传播指标按多组参数生成不同列,不合并同名业务指标;
|
||||||
- `代表视频`可能被读取,但不会进入最终普通市场 CSV;
|
- `代表视频`可能被读取,但不会进入最终达人数据 CSV;
|
||||||
- 画像和商业能力字段追加在基础字段之后;
|
- 画像和商业能力字段追加在基础字段之后;
|
||||||
- 传播指标字段追加在基础字段、看后搜率和秒思 api 字段之后。
|
- 传播指标字段追加在基础字段、看后搜率和秒思 api 字段之后。
|
||||||
|
|
||||||
@@ -624,7 +624,7 @@
|
|||||||
- 单个达人部分接口失败时,CSV 中该行 `导出状态` 为部分成功或失败,并在 `失败原因` 中列明失败项;
|
- 单个达人部分接口失败时,CSV 中该行 `导出状态` 为部分成功或失败,并在 `失败原因` 中列明失败项;
|
||||||
- 秒思 api 指标失败时,对应字段为空或页面显示失败,不一定影响 CSV 下载;
|
- 秒思 api 指标失败时,对应字段为空或页面显示失败,不一定影响 CSV 下载;
|
||||||
- 传播指标某组参数失败时,对应字段为空;
|
- 传播指标某组参数失败时,对应字段为空;
|
||||||
- 画像部分失败时,其他画像或商业能力字段仍可保留;
|
- 画像部分或全部失败时,对应单元格留空并写入失败原因,其他画像或效果预估字段仍可保留;
|
||||||
- 后端指标查询不到某个达人时显示暂无数据。
|
- 后端指标查询不到某个达人时显示暂无数据。
|
||||||
|
|
||||||
### 9.5 失败表现
|
### 9.5 失败表现
|
||||||
@@ -632,7 +632,7 @@
|
|||||||
- 未登录:星图页面显示登录提示,不出现业务工具栏;
|
- 未登录:星图页面显示登录提示,不出现业务工具栏;
|
||||||
- 没勾选就导出选中达人数据:提示请先勾选;
|
- 没勾选就导出选中达人数据:提示请先勾选;
|
||||||
- 当前范围无选中达人:提示当前导出范围内没有选中的达人;
|
- 当前范围无选中达人:提示当前导出范围内没有选中的达人;
|
||||||
- 全部画像失败:提示画像导出失败,不下载 CSV;
|
- 全部已选补充数据都失败:CSV 仍会下载,该行标记为部分成功或失败并列明失败原因;
|
||||||
- 按 ID 没有有效 ID:提示请输入有效的达人星图 ID;
|
- 按 ID 没有有效 ID:提示请输入有效的达人星图 ID;
|
||||||
- 批次提交失败:状态区显示接口错误或通用失败提示;
|
- 批次提交失败:状态区显示接口错误或通用失败提示;
|
||||||
- 已选传播指标未填写合法阈值:状态区提示具体指标,导出或提交不会开始;
|
- 已选传播指标未填写合法阈值:状态区提示具体指标,导出或提交不会开始;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export interface AudienceProfileFailure {
|
|||||||
status: "failed";
|
status: "failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AudienceProfileSkipped {
|
||||||
|
status: "skipped";
|
||||||
|
}
|
||||||
|
|
||||||
export interface AudienceProfileSet {
|
export interface AudienceProfileSet {
|
||||||
audience: AudienceProfileResult;
|
audience: AudienceProfileResult;
|
||||||
fans: AudienceProfileResult;
|
fans: AudienceProfileResult;
|
||||||
@@ -34,7 +38,8 @@ export interface AudienceProfileSet {
|
|||||||
|
|
||||||
export type AudienceProfileResult =
|
export type AudienceProfileResult =
|
||||||
| AudienceProfileSuccess
|
| AudienceProfileSuccess
|
||||||
| AudienceProfileFailure;
|
| AudienceProfileFailure
|
||||||
|
| AudienceProfileSkipped;
|
||||||
|
|
||||||
export interface AudienceProfileExportRow {
|
export interface AudienceProfileExportRow {
|
||||||
businessAbility?: BusinessAbilityResult;
|
businessAbility?: BusinessAbilityResult;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export type CsvColumn = {
|
|||||||
readValue: (record: MarketRecord) => string;
|
readValue: (record: MarketRecord) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface MarketCsvOptions {
|
||||||
|
selectedHeaders?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
|
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
|
||||||
{
|
{
|
||||||
header: "达人ID",
|
header: "达人ID",
|
||||||
@@ -80,6 +84,13 @@ const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) =
|
|||||||
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
|
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const FIXED_MARKET_CSV_HEADERS = new Set([
|
||||||
|
"达人ID",
|
||||||
|
"达人名称",
|
||||||
|
"导出状态",
|
||||||
|
"失败原因"
|
||||||
|
]);
|
||||||
|
|
||||||
export function listRateCsvHeaders(): string[] {
|
export function listRateCsvHeaders(): string[] {
|
||||||
return RATE_COLUMNS.map((column) => column.header);
|
return RATE_COLUMNS.map((column) => column.header);
|
||||||
}
|
}
|
||||||
@@ -88,8 +99,20 @@ export function listBackendMetricCsvHeaders(): string[] {
|
|||||||
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
|
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMarketCsv(records: MarketRecord[]): string {
|
export function buildMarketCsv(
|
||||||
const csvColumns = buildMarketCsvColumns(records);
|
records: MarketRecord[],
|
||||||
|
options: MarketCsvOptions = {}
|
||||||
|
): string {
|
||||||
|
const optionalColumns = buildMarketCsvColumns(records);
|
||||||
|
const csvColumns = filterMarketCsvColumns(
|
||||||
|
options.selectedHeaders === undefined
|
||||||
|
? optionalColumns
|
||||||
|
: deduplicateCsvColumns([
|
||||||
|
...buildFixedMarketCsvColumns(),
|
||||||
|
...optionalColumns
|
||||||
|
]),
|
||||||
|
options.selectedHeaders
|
||||||
|
);
|
||||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||||
const rowLines = records.map((record) =>
|
const rowLines = records.map((record) =>
|
||||||
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||||
@@ -133,3 +156,68 @@ export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
|||||||
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
|
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterMarketCsvColumns(
|
||||||
|
columns: CsvColumn[],
|
||||||
|
selectedHeaders: string[] | undefined
|
||||||
|
): CsvColumn[] {
|
||||||
|
if (!selectedHeaders) {
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedHeaderSet = new Set(selectedHeaders);
|
||||||
|
return columns.filter(
|
||||||
|
(column) =>
|
||||||
|
FIXED_MARKET_CSV_HEADERS.has(column.header) ||
|
||||||
|
selectedHeaderSet.has(column.header)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFixedMarketCsvColumns(): CsvColumn[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: "达人ID",
|
||||||
|
readValue: (record) => record.exportFields?.达人ID ?? record.authorId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "达人名称",
|
||||||
|
readValue: (record) => record.exportFields?.达人名称 ?? record.authorName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "导出状态",
|
||||||
|
readValue: (record) => record.exportFields?.导出状态 ?? readExportStatus(record)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "失败原因",
|
||||||
|
readValue: (record) => record.exportFields?.失败原因 ?? record.failureReason ?? ""
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExportStatus(record: MarketRecord): string {
|
||||||
|
if (record.status === "failed") {
|
||||||
|
return "失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.status === "loading") {
|
||||||
|
return "加载中";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.status === "idle") {
|
||||||
|
return "待处理";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "成功";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicateCsvColumns(columns: CsvColumn[]): CsvColumn[] {
|
||||||
|
const seenHeaders = new Set<string>();
|
||||||
|
return columns.filter((column) => {
|
||||||
|
if (seenHeaders.has(column.header)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenHeaders.add(column.header);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+217
-138
@@ -1,4 +1,8 @@
|
|||||||
import { buildBaseColumns, buildMarketCsv } from "./csv-exporter";
|
import {
|
||||||
|
buildBaseColumns,
|
||||||
|
listBackendMetricCsvHeaders,
|
||||||
|
listRateCsvHeaders
|
||||||
|
} from "./csv-exporter";
|
||||||
import {
|
import {
|
||||||
buildAudienceProfileCsv,
|
buildAudienceProfileCsv,
|
||||||
listAudienceProfileSelectableFieldGroups,
|
listAudienceProfileSelectableFieldGroups,
|
||||||
@@ -52,8 +56,11 @@ import { createSilentExportController } from "./silent-export-controller";
|
|||||||
import {
|
import {
|
||||||
buildSpreadInfoConfigKey,
|
buildSpreadInfoConfigKey,
|
||||||
createSpreadInfoClient,
|
createSpreadInfoClient,
|
||||||
|
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||||
|
filterUnloadedSpreadInfoConfigs,
|
||||||
matchesSpreadMetricRule,
|
matchesSpreadMetricRule,
|
||||||
normalizeSpreadInfoConfig,
|
normalizeSpreadInfoConfig,
|
||||||
|
selectSpreadInfoConfigsForHeaders,
|
||||||
type MappedSpreadInfoResponse
|
type MappedSpreadInfoResponse
|
||||||
} from "./spread-info";
|
} from "./spread-info";
|
||||||
import {
|
import {
|
||||||
@@ -91,12 +98,21 @@ interface MutationObserverLike {
|
|||||||
observe(target: Node, options?: MutationObserverInit): void;
|
observe(target: Node, options?: MutationObserverInit): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AudienceProfileExportSelection {
|
||||||
|
audience: boolean;
|
||||||
|
businessAbility: boolean;
|
||||||
|
fans: boolean;
|
||||||
|
includeBackendMetrics: boolean;
|
||||||
|
includeRates: boolean;
|
||||||
|
longtimeFans: boolean;
|
||||||
|
spreadInfoConfigs: SpreadInfoConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateMarketControllerOptions {
|
export interface CreateMarketControllerOptions {
|
||||||
buildAudienceProfileCsv?: (
|
buildAudienceProfileCsv?: (
|
||||||
rows: AudienceProfileExportRow[],
|
rows: AudienceProfileExportRow[],
|
||||||
options?: AudienceProfileCsvOptions
|
options?: AudienceProfileCsvOptions
|
||||||
) => string;
|
) => string;
|
||||||
buildCsv?: (records: MarketRecord[]) => string;
|
|
||||||
confirmFavoriteDelete?: (message: string) => boolean;
|
confirmFavoriteDelete?: (message: string) => boolean;
|
||||||
confirmFavoriteImport?: (message: string) => boolean;
|
confirmFavoriteImport?: (message: string) => boolean;
|
||||||
document: Document;
|
document: Document;
|
||||||
@@ -115,7 +131,10 @@ export interface CreateMarketControllerOptions {
|
|||||||
spreadAuthorId: string,
|
spreadAuthorId: string,
|
||||||
config: SpreadInfoConfig
|
config: SpreadInfoConfig
|
||||||
) => Promise<MappedSpreadInfoResponse>;
|
) => Promise<MappedSpreadInfoResponse>;
|
||||||
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
|
loadSpreadMetrics?: (
|
||||||
|
spreadAuthorId: string,
|
||||||
|
configs?: SpreadInfoConfig[]
|
||||||
|
) => Promise<Record<string, string>>;
|
||||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||||
Array<BackendMetrics & { starId: string }>
|
Array<BackendMetrics & { starId: string }>
|
||||||
>;
|
>;
|
||||||
@@ -158,7 +177,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const searchBackendMetrics =
|
const searchBackendMetrics =
|
||||||
options.searchBackendMetrics ??
|
options.searchBackendMetrics ??
|
||||||
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
||||||
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
|
||||||
const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv;
|
const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv;
|
||||||
const loadAudienceProfile =
|
const loadAudienceProfile =
|
||||||
options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile;
|
options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile;
|
||||||
@@ -319,41 +337,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
startObserving();
|
startObserving();
|
||||||
|
|
||||||
const toolbarHandlers = {
|
const toolbarHandlers = {
|
||||||
onExport: async () => {
|
|
||||||
syncSelectionStateFromDom();
|
|
||||||
const exportTarget = readToolbarExportTarget(toolbar);
|
|
||||||
if (!exportTarget.target) {
|
|
||||||
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 applySpreadThresholdFilter(
|
|
||||||
await exportRecords(exportTarget.target, "导出中", {
|
|
||||||
includeSpreadMetrics: true,
|
|
||||||
showDetailedProgress: selectedAuthorIds.size === 0
|
|
||||||
}),
|
|
||||||
spreadFilter.filter
|
|
||||||
)
|
|
||||||
);
|
|
||||||
options.onCsvReady?.(buildCsv(records));
|
|
||||||
setToolbarExportStatus(toolbar, "");
|
|
||||||
} catch (error) {
|
|
||||||
setToolbarExportStatus(
|
|
||||||
toolbar,
|
|
||||||
error instanceof Error ? error.message : "导出失败,请稍后重试"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setToolbarBusyState(toolbar, false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onExportAudienceProfile: async () => {
|
onExportAudienceProfile: async () => {
|
||||||
syncSelectionStateFromDom();
|
syncSelectionStateFromDom();
|
||||||
if (selectedAuthorIds.size === 0) {
|
if (selectedAuthorIds.size === 0) {
|
||||||
@@ -369,10 +352,16 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
|
|
||||||
setToolbarBusyState(toolbar, true);
|
setToolbarBusyState(toolbar, true);
|
||||||
try {
|
try {
|
||||||
|
const selectedHeaders = readAudienceProfileSelectedHeaders();
|
||||||
|
const exportSelection = resolveAudienceProfileExportSelection(
|
||||||
|
selectedHeaders
|
||||||
|
);
|
||||||
const selectedRecords = filterRecordsBySelectionStrict(
|
const selectedRecords = filterRecordsBySelectionStrict(
|
||||||
await exportRecords(exportTarget.target, "画像导出中", {
|
await exportRecords(exportTarget.target, "画像导出中", {
|
||||||
includeSpreadMetrics: true,
|
includeBackendMetrics: exportSelection.includeBackendMetrics,
|
||||||
showDetailedProgress: false
|
includeRates: exportSelection.includeRates,
|
||||||
|
showDetailedProgress: false,
|
||||||
|
spreadInfoConfigs: exportSelection.spreadInfoConfigs
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (selectedRecords.length === 0) {
|
if (selectedRecords.length === 0) {
|
||||||
@@ -388,28 +377,21 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
`画像导出中 ${index + 1}/${selectedRecords.length}...`
|
`画像导出中 ${index + 1}/${selectedRecords.length}...`
|
||||||
);
|
);
|
||||||
const [profiles, businessAbility] = await Promise.all([
|
const [profiles, businessAbility] = await Promise.all([
|
||||||
loadAudienceProfileSet(record),
|
loadAudienceProfileSet(record, exportSelection),
|
||||||
loadBusinessAbilitySafe(record)
|
exportSelection.businessAbility
|
||||||
|
? loadBusinessAbilitySafe(record)
|
||||||
|
: Promise.resolve(undefined)
|
||||||
]);
|
]);
|
||||||
rows.push({
|
rows.push(finalizeAudienceProfileExportRow({
|
||||||
businessAbility,
|
businessAbility,
|
||||||
profiles,
|
profiles,
|
||||||
record
|
record
|
||||||
});
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
rows.every((row) =>
|
|
||||||
Object.values(row.profiles).every((profile) => profile.status === "failed")
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
options.onCsvReady?.(
|
options.onCsvReady?.(
|
||||||
buildAudienceCsv(rows, {
|
buildAudienceCsv(rows, {
|
||||||
selectedHeaders: readAudienceProfileSelectedHeaders()
|
selectedHeaders
|
||||||
}),
|
}),
|
||||||
buildAudienceProfileFilename()
|
buildAudienceProfileFilename()
|
||||||
);
|
);
|
||||||
@@ -442,7 +424,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
`识别 ${parsed.ids.length + parsed.duplicates.length + parsed.invalidTokens.length} 个,去重后 ${parsed.ids.length} 个,非法 ${parsed.invalidTokens.length} 个`
|
`识别 ${parsed.ids.length + parsed.duplicates.length + parsed.invalidTokens.length} 个,去重后 ${parsed.ids.length} 个,非法 ${parsed.invalidTokens.length} 个`
|
||||||
);
|
);
|
||||||
|
|
||||||
const backendMetricsByAuthorId = await loadBackendMetricsMap(parsed.ids);
|
const selectedHeaders = readAudienceProfileSelectedHeaders();
|
||||||
|
const exportSelection = resolveAudienceProfileExportSelection(
|
||||||
|
selectedHeaders
|
||||||
|
);
|
||||||
|
const backendMetricsByAuthorId = exportSelection.includeBackendMetrics
|
||||||
|
? await loadBackendMetricsMap(parsed.ids)
|
||||||
|
: new Map<string, BackendMetrics>();
|
||||||
const rows: AudienceProfileExportRow[] = [];
|
const rows: AudienceProfileExportRow[] = [];
|
||||||
for (let index = 0; index < parsed.ids.length; index += 1) {
|
for (let index = 0; index < parsed.ids.length; index += 1) {
|
||||||
const authorId = parsed.ids[index];
|
const authorId = parsed.ids[index];
|
||||||
@@ -453,14 +441,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
rows.push(
|
rows.push(
|
||||||
await loadAudienceProfileRowById(
|
await loadAudienceProfileRowById(
|
||||||
authorId,
|
authorId,
|
||||||
backendMetricsByAuthorId.get(authorId)
|
backendMetricsByAuthorId.get(authorId),
|
||||||
|
exportSelection
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
options.onCsvReady?.(
|
options.onCsvReady?.(
|
||||||
buildAudienceCsv(rows, {
|
buildAudienceCsv(rows, {
|
||||||
selectedHeaders: readAudienceProfileSelectedHeaders()
|
selectedHeaders
|
||||||
}),
|
}),
|
||||||
buildAudienceProfileFilename(new Date(), "按ID导出")
|
buildAudienceProfileFilename(new Date(), "按ID导出")
|
||||||
);
|
);
|
||||||
@@ -1050,8 +1039,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
target: MarketExportTarget,
|
target: MarketExportTarget,
|
||||||
inProgressLabel = "导出中",
|
inProgressLabel = "导出中",
|
||||||
progressOptions: {
|
progressOptions: {
|
||||||
includeSpreadMetrics?: boolean;
|
includeBackendMetrics?: boolean;
|
||||||
|
includeRates?: boolean;
|
||||||
showDetailedProgress?: boolean;
|
showDetailedProgress?: boolean;
|
||||||
|
spreadInfoConfigs?: SpreadInfoConfig[];
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<MarketRecord[]> {
|
): Promise<MarketRecord[]> {
|
||||||
activeProgressLabel = inProgressLabel;
|
activeProgressLabel = inProgressLabel;
|
||||||
@@ -1061,7 +1052,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
if (target.mode === "count" && target.pageCount <= 1) {
|
if (target.mode === "count" && target.pageCount <= 1) {
|
||||||
await prepareCurrentPageForExport();
|
await prepareCurrentPageForExport();
|
||||||
return hydrateExportRecords(getVisibleOrderedRecords(), {
|
return hydrateExportRecords(getVisibleOrderedRecords(), {
|
||||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
includeBackendMetrics: progressOptions.includeBackendMetrics,
|
||||||
|
includeRates: progressOptions.includeRates,
|
||||||
|
spreadInfoConfigs: progressOptions.spreadInfoConfigs
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,7 +1066,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
status: record.status ?? "idle"
|
status: record.status ?? "idle"
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
includeBackendMetrics: progressOptions.includeBackendMetrics,
|
||||||
|
includeRates: progressOptions.includeRates,
|
||||||
|
spreadInfoConfigs: progressOptions.spreadInfoConfigs
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1137,14 +1132,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const snapshots = new Map<string, MappedSpreadInfoResponse>();
|
const snapshots = new Map<string, MappedSpreadInfoResponse>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(configsByKey.entries()).map(async ([key, config]) => {
|
Array.from(configsByKey.entries()).map(async ([key, config]) => {
|
||||||
try {
|
snapshots.set(
|
||||||
snapshots.set(
|
key,
|
||||||
key,
|
await loadSpreadFilterMetrics(spreadAuthorId, config)
|
||||||
await loadSpreadFilterMetrics(spreadAuthorId, config)
|
);
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
snapshots.set(key, {});
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1178,11 +1169,17 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadAudienceProfileSet(
|
async function loadAudienceProfileSet(
|
||||||
record: MarketRecord
|
record: MarketRecord,
|
||||||
|
exportSelection: AudienceProfileExportSelection
|
||||||
): Promise<AudienceProfileExportRow["profiles"]> {
|
): Promise<AudienceProfileExportRow["profiles"]> {
|
||||||
const profiles = {} as AudienceProfileExportRow["profiles"];
|
const profiles = {} as AudienceProfileExportRow["profiles"];
|
||||||
|
|
||||||
for (const { kind, target } of audienceProfileTargets) {
|
for (const { kind, target } of audienceProfileTargets) {
|
||||||
|
if (!exportSelection[kind]) {
|
||||||
|
profiles[kind] = { status: "skipped" };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
profiles[kind] = await loadAudienceProfile(record, target);
|
profiles[kind] = await loadAudienceProfile(record, target);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1213,54 +1210,45 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
|
|
||||||
async function loadAudienceProfileRowById(
|
async function loadAudienceProfileRowById(
|
||||||
authorId: string,
|
authorId: string,
|
||||||
backendMetrics?: BackendMetrics
|
backendMetrics: BackendMetrics | undefined,
|
||||||
|
exportSelection: AudienceProfileExportSelection
|
||||||
): Promise<AudienceProfileExportRow> {
|
): Promise<AudienceProfileExportRow> {
|
||||||
const [baseRecord, metricsResult] = await Promise.all([
|
const [baseRecord, metricsResult] = await Promise.all([
|
||||||
loadAuthorBaseInfoSafe(authorId),
|
loadAuthorBaseInfoSafe(authorId),
|
||||||
loadAuthorMetricsSafe(authorId)
|
exportSelection.includeRates
|
||||||
|
? loadAuthorMetricsSafe(authorId)
|
||||||
|
: Promise.resolve(undefined)
|
||||||
]);
|
]);
|
||||||
const spreadMetrics = baseRecord.spreadAuthorId
|
const spreadMetrics =
|
||||||
? await loadSpreadMetrics(baseRecord.spreadAuthorId)
|
exportSelection.spreadInfoConfigs.length > 0 && baseRecord.spreadAuthorId
|
||||||
: {};
|
? await loadSpreadMetrics(
|
||||||
|
baseRecord.spreadAuthorId,
|
||||||
|
exportSelection.spreadInfoConfigs
|
||||||
|
)
|
||||||
|
: {};
|
||||||
const recordForRequests = {
|
const recordForRequests = {
|
||||||
...baseRecord,
|
...baseRecord,
|
||||||
authorName: baseRecord.authorName || authorId,
|
authorName: baseRecord.authorName || authorId,
|
||||||
...(metricsResult.success ? { rates: metricsResult.rates } : {}),
|
...(metricsResult?.success ? { rates: metricsResult.rates } : {}),
|
||||||
...(backendMetrics
|
...(backendMetrics
|
||||||
? { backendMetrics, backendMetricsStatus: "success" as const }
|
? { backendMetrics, backendMetricsStatus: "success" as const }
|
||||||
: {}),
|
: {}),
|
||||||
...(Object.keys(spreadMetrics).length > 0 ? { spreadMetrics } : {})
|
...(Object.keys(spreadMetrics).length > 0 ? { spreadMetrics } : {})
|
||||||
};
|
};
|
||||||
const [profiles, businessAbility] = await Promise.all([
|
const [profiles, businessAbility] = await Promise.all([
|
||||||
loadAudienceProfileSet(recordForRequests),
|
loadAudienceProfileSet(recordForRequests, exportSelection),
|
||||||
loadBusinessAbilitySafe(recordForRequests)
|
exportSelection.businessAbility
|
||||||
|
? loadBusinessAbilitySafe(recordForRequests)
|
||||||
|
: Promise.resolve(undefined)
|
||||||
]);
|
]);
|
||||||
const failureReasons = collectAudienceProfileRowFailures(
|
return finalizeAudienceProfileExportRow(
|
||||||
baseRecord,
|
{
|
||||||
profiles,
|
businessAbility,
|
||||||
businessAbility
|
profiles,
|
||||||
|
record: recordForRequests
|
||||||
|
},
|
||||||
|
{ includeBaseRecordFailure: true }
|
||||||
);
|
);
|
||||||
const rowStatus =
|
|
||||||
failureReasons.length === 0
|
|
||||||
? "成功"
|
|
||||||
: hasAudienceProfileRowSuccess(baseRecord, profiles, businessAbility)
|
|
||||||
? "部分成功"
|
|
||||||
: "失败";
|
|
||||||
const authorName = baseRecord.authorName || "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
businessAbility,
|
|
||||||
profiles,
|
|
||||||
record: {
|
|
||||||
...recordForRequests,
|
|
||||||
exportFields: {
|
|
||||||
达人ID: authorId,
|
|
||||||
达人名称: authorName,
|
|
||||||
导出状态: rowStatus,
|
|
||||||
失败原因: failureReasons.join("; ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAuthorBaseInfoSafe(authorId: string): Promise<MarketRecord> {
|
async function loadAuthorBaseInfoSafe(authorId: string): Promise<MarketRecord> {
|
||||||
@@ -1314,10 +1302,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
function collectAudienceProfileRowFailures(
|
function collectAudienceProfileRowFailures(
|
||||||
baseRecord: MarketRecord,
|
baseRecord: MarketRecord,
|
||||||
profiles: AudienceProfileExportRow["profiles"],
|
profiles: AudienceProfileExportRow["profiles"],
|
||||||
businessAbility: BusinessAbilityResult
|
businessAbility: BusinessAbilityResult | undefined,
|
||||||
|
includeBaseRecordFailure: boolean
|
||||||
): string[] {
|
): string[] {
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
if (baseRecord.status === "failed") {
|
if (includeBaseRecordFailure && baseRecord.status === "failed") {
|
||||||
failures.push(`基础信息:${baseRecord.failureReason ?? "request-failed"}`);
|
failures.push(`基础信息:${baseRecord.failureReason ?? "request-failed"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1327,8 +1316,8 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (businessAbility.status === "failed") {
|
if (businessAbility?.status === "failed") {
|
||||||
failures.push(`商业能力:${businessAbility.failureReason ?? "request-failed"}`);
|
failures.push(`效果预估:${businessAbility.failureReason ?? "request-failed"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return failures;
|
return failures;
|
||||||
@@ -1337,15 +1326,55 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
function hasAudienceProfileRowSuccess(
|
function hasAudienceProfileRowSuccess(
|
||||||
baseRecord: MarketRecord,
|
baseRecord: MarketRecord,
|
||||||
profiles: AudienceProfileExportRow["profiles"],
|
profiles: AudienceProfileExportRow["profiles"],
|
||||||
businessAbility: BusinessAbilityResult
|
businessAbility: BusinessAbilityResult | undefined,
|
||||||
|
includeBaseRecordFailure: boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
|
!includeBaseRecordFailure ||
|
||||||
baseRecord.status === "success" ||
|
baseRecord.status === "success" ||
|
||||||
businessAbility.status === "success" ||
|
businessAbility?.status === "success" ||
|
||||||
Object.values(profiles).some((profile) => profile.status === "success")
|
Object.values(profiles).some((profile) => profile.status === "success")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finalizeAudienceProfileExportRow(
|
||||||
|
row: AudienceProfileExportRow,
|
||||||
|
options: { includeBaseRecordFailure?: boolean } = {}
|
||||||
|
): AudienceProfileExportRow {
|
||||||
|
const includeBaseRecordFailure = options.includeBaseRecordFailure ?? false;
|
||||||
|
const failureReasons = collectAudienceProfileRowFailures(
|
||||||
|
row.record,
|
||||||
|
row.profiles,
|
||||||
|
row.businessAbility,
|
||||||
|
includeBaseRecordFailure
|
||||||
|
);
|
||||||
|
const rowStatus =
|
||||||
|
failureReasons.length === 0
|
||||||
|
? "成功"
|
||||||
|
: hasAudienceProfileRowSuccess(
|
||||||
|
row.record,
|
||||||
|
row.profiles,
|
||||||
|
row.businessAbility,
|
||||||
|
includeBaseRecordFailure
|
||||||
|
)
|
||||||
|
? "部分成功"
|
||||||
|
: "失败";
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
record: {
|
||||||
|
...row.record,
|
||||||
|
exportFields: {
|
||||||
|
...row.record.exportFields,
|
||||||
|
达人ID: row.record.authorId,
|
||||||
|
达人名称: row.record.authorName,
|
||||||
|
导出状态: rowStatus,
|
||||||
|
失败原因: failureReasons.join("; ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readAudienceProfileKindLabel(kind: AudienceProfileKind): string {
|
function readAudienceProfileKindLabel(kind: AudienceProfileKind): string {
|
||||||
if (kind === "audience") {
|
if (kind === "audience") {
|
||||||
return "观众画像";
|
return "观众画像";
|
||||||
@@ -1358,6 +1387,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return "铁粉画像";
|
return "铁粉画像";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAudienceProfileExportSelection(
|
||||||
|
selectedHeaders: string[] | undefined
|
||||||
|
): AudienceProfileExportSelection {
|
||||||
|
if (selectedHeaders === undefined) {
|
||||||
|
return {
|
||||||
|
audience: true,
|
||||||
|
businessAbility: true,
|
||||||
|
fans: true,
|
||||||
|
includeBackendMetrics: true,
|
||||||
|
includeRates: true,
|
||||||
|
longtimeFans: true,
|
||||||
|
spreadInfoConfigs: DEFAULT_SPREAD_INFO_CONFIGS
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedHeaderSet = new Set(selectedHeaders);
|
||||||
|
const includesAny = (headers: string[]) =>
|
||||||
|
headers.some((header) => selectedHeaderSet.has(header));
|
||||||
|
const includesPrefix = (prefix: string) =>
|
||||||
|
selectedHeaders.some((header) => header.startsWith(prefix));
|
||||||
|
|
||||||
|
return {
|
||||||
|
audience: includesPrefix("观众画像-"),
|
||||||
|
businessAbility: includesPrefix("效果预估-"),
|
||||||
|
fans: includesPrefix("粉丝画像-"),
|
||||||
|
includeBackendMetrics: includesAny(listBackendMetricCsvHeaders()),
|
||||||
|
includeRates: includesAny(listRateCsvHeaders()),
|
||||||
|
longtimeFans: includesPrefix("铁粉画像-"),
|
||||||
|
spreadInfoConfigs: selectSpreadInfoConfigsForHeaders(selectedHeaders)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readAudienceProfileSelectableHeaders(marketListHeaders: string[] = []): string[] {
|
function readAudienceProfileSelectableHeaders(marketListHeaders: string[] = []): string[] {
|
||||||
return listAudienceProfileSelectableFieldGroups(marketListHeaders).flatMap(
|
return listAudienceProfileSelectableFieldGroups(marketListHeaders).flatMap(
|
||||||
(group) => group.headers
|
(group) => group.headers
|
||||||
@@ -1408,39 +1469,44 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
async function hydrateExportRecords(
|
async function hydrateExportRecords(
|
||||||
records: MarketRecord[],
|
records: MarketRecord[],
|
||||||
options: {
|
options: {
|
||||||
includeSpreadMetrics?: boolean;
|
includeBackendMetrics?: boolean;
|
||||||
|
includeRates?: boolean;
|
||||||
|
spreadInfoConfigs?: SpreadInfoConfig[];
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<MarketRecord[]> {
|
): Promise<MarketRecord[]> {
|
||||||
for (const record of records) {
|
records.forEach((record) => resultStore.upsertMarketRow(record));
|
||||||
resultStore.upsertMarketRow(record);
|
|
||||||
const existingRecord = resultStore.getRecord(record.authorId);
|
|
||||||
if (existingRecord?.status === "success" && existingRecord.rates) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (record.hasDirectRatesSource) {
|
if (options.includeRates ?? true) {
|
||||||
const directRates = record.rates ?? {};
|
for (const record of records) {
|
||||||
const hasAllRates =
|
const existingRecord = resultStore.getRecord(record.authorId);
|
||||||
Boolean(directRates.singleVideoAfterSearchRate) &&
|
if (existingRecord?.status === "success" && existingRecord.rates) {
|
||||||
Boolean(directRates.personalVideoAfterSearchRate);
|
|
||||||
|
|
||||||
resultStore.setAuthorSuccess(record.authorId, directRates);
|
|
||||||
if (hasAllRates) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
resultStore.setAuthorLoading(record.authorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const metricsResult = await loadAuthorMetrics(record.authorId);
|
if (record.hasDirectRatesSource) {
|
||||||
if (metricsResult.success) {
|
const directRates = record.rates ?? {};
|
||||||
resultStore.setAuthorSuccess(record.authorId, metricsResult.rates);
|
const hasAllRates =
|
||||||
} else {
|
Boolean(directRates.singleVideoAfterSearchRate) &&
|
||||||
resultStore.setAuthorFailed(record.authorId, metricsResult.reason);
|
Boolean(directRates.personalVideoAfterSearchRate);
|
||||||
|
|
||||||
|
resultStore.setAuthorSuccess(record.authorId, directRates);
|
||||||
|
if (hasAllRates) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultStore.setAuthorLoading(record.authorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricsResult = await loadAuthorMetrics(record.authorId);
|
||||||
|
if (metricsResult.success) {
|
||||||
|
resultStore.setAuthorSuccess(record.authorId, metricsResult.rates);
|
||||||
|
} else {
|
||||||
|
resultStore.setAuthorFailed(record.authorId, metricsResult.reason);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchBackendMetrics) {
|
if ((options.includeBackendMetrics ?? true) && searchBackendMetrics) {
|
||||||
const backendTargetRecords = records.filter((record) => {
|
const backendTargetRecords = records.filter((record) => {
|
||||||
const existingRecord = resultStore.getRecord(record.authorId);
|
const existingRecord = resultStore.getRecord(record.authorId);
|
||||||
return !(
|
return !(
|
||||||
@@ -1476,25 +1542,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.includeSpreadMetrics) {
|
if (options.spreadInfoConfigs?.length) {
|
||||||
await hydrateSpreadMetricsForRecords(records);
|
await hydrateSpreadMetricsForRecords(records, options.spreadInfoConfigs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return records.map((record) => toMarketRecord(record));
|
return records.map((record) => toMarketRecord(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateSpreadMetricsForRecords(
|
async function hydrateSpreadMetricsForRecords(
|
||||||
records: MarketRecord[]
|
records: MarketRecord[],
|
||||||
|
selectedConfigs: SpreadInfoConfig[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
records.map(async (record) => {
|
records.map(async (record) => {
|
||||||
const storedRecord = resultStore.getRecord(record.authorId) ?? record;
|
const storedRecord = resultStore.getRecord(record.authorId) ?? record;
|
||||||
const spreadAuthorId = storedRecord.spreadAuthorId ?? record.spreadAuthorId;
|
const spreadAuthorId = storedRecord.spreadAuthorId ?? record.spreadAuthorId;
|
||||||
if (!spreadAuthorId || storedRecord.spreadMetrics) {
|
if (!spreadAuthorId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spreadMetrics = await loadSpreadMetrics(spreadAuthorId);
|
const existingMetrics = storedRecord.spreadMetrics ?? {};
|
||||||
|
const missingConfigs = filterUnloadedSpreadInfoConfigs(
|
||||||
|
selectedConfigs,
|
||||||
|
existingMetrics
|
||||||
|
);
|
||||||
|
if (missingConfigs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spreadMetrics = await loadSpreadMetrics(
|
||||||
|
spreadAuthorId,
|
||||||
|
missingConfigs
|
||||||
|
);
|
||||||
if (Object.keys(spreadMetrics).length > 0) {
|
if (Object.keys(spreadMetrics).length > 0) {
|
||||||
resultStore.setSpreadMetricsSuccess(record.authorId, spreadMetrics);
|
resultStore.setSpreadMetricsSuccess(record.authorId, spreadMetrics);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface PluginToolbarHandlers {
|
export interface PluginToolbarHandlers {
|
||||||
onExport(): Promise<void> | void;
|
|
||||||
onExportAudienceProfile(): Promise<void> | void;
|
onExportAudienceProfile(): Promise<void> | void;
|
||||||
onExportAudienceProfileByIds(): Promise<void> | void;
|
onExportAudienceProfileByIds(): Promise<void> | void;
|
||||||
onConfigureAudienceProfileFields(): Promise<void> | void;
|
onConfigureAudienceProfileFields(): Promise<void> | void;
|
||||||
@@ -65,7 +64,6 @@ export interface PluginToolbarDom {
|
|||||||
audienceProfileExportButton: HTMLButtonElement;
|
audienceProfileExportButton: HTMLButtonElement;
|
||||||
audienceProfileFieldButton: HTMLButtonElement;
|
audienceProfileFieldButton: HTMLButtonElement;
|
||||||
batchSubmitButton: HTMLButtonElement;
|
batchSubmitButton: HTMLButtonElement;
|
||||||
exportButton: HTMLButtonElement;
|
|
||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
exportRangeSelect: HTMLSelectElement;
|
exportRangeSelect: HTMLSelectElement;
|
||||||
exportStatusText: HTMLElement;
|
exportStatusText: HTMLElement;
|
||||||
@@ -110,7 +108,7 @@ export function ensurePluginToolbar(
|
|||||||
'[data-plugin-export-audience-profile-by-id="button"]'
|
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||||
) &&
|
) &&
|
||||||
existingRoot.querySelector(
|
existingRoot.querySelector(
|
||||||
'[data-plugin-toolbar-action-group="csv-export"]'
|
'[data-plugin-toolbar-action-group="selected-audience-export"]'
|
||||||
) &&
|
) &&
|
||||||
existingRoot.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]')
|
existingRoot.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]')
|
||||||
) {
|
) {
|
||||||
@@ -145,12 +143,6 @@ export function ensurePluginToolbar(
|
|||||||
exportCustomPagesInput.placeholder = "页数";
|
exportCustomPagesInput.placeholder = "页数";
|
||||||
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
|
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
|
||||||
|
|
||||||
const exportButton = document.createElement("button");
|
|
||||||
exportButton.type = "button";
|
|
||||||
exportButton.dataset.pluginExport = "button";
|
|
||||||
exportButton.textContent = "导出CSV";
|
|
||||||
exportButton.title = "按所选页数范围导出当前筛选结果";
|
|
||||||
|
|
||||||
const audienceProfileExportButton = document.createElement("button");
|
const audienceProfileExportButton = document.createElement("button");
|
||||||
audienceProfileExportButton.type = "button";
|
audienceProfileExportButton.type = "button";
|
||||||
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
|
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
|
||||||
@@ -204,15 +196,15 @@ export function ensurePluginToolbar(
|
|||||||
const dataGroup = document.createElement("div");
|
const dataGroup = document.createElement("div");
|
||||||
dataGroup.dataset.pluginToolbarGroup = "data";
|
dataGroup.dataset.pluginToolbarGroup = "data";
|
||||||
applyToolbarGroupStyles(dataGroup);
|
applyToolbarGroupStyles(dataGroup);
|
||||||
const csvExportGroup = createToolbarActionGroup(document, "csv-export", [
|
|
||||||
exportButton,
|
|
||||||
exportRangeSelect,
|
|
||||||
exportCustomPagesInput
|
|
||||||
]);
|
|
||||||
const selectedAudienceExportGroup = createToolbarActionGroup(
|
const selectedAudienceExportGroup = createToolbarActionGroup(
|
||||||
document,
|
document,
|
||||||
"selected-audience-export",
|
"selected-audience-export",
|
||||||
[audienceProfileExportButton, audienceProfileFieldButton]
|
[
|
||||||
|
audienceProfileExportButton,
|
||||||
|
audienceProfileFieldButton,
|
||||||
|
exportRangeSelect,
|
||||||
|
exportCustomPagesInput
|
||||||
|
]
|
||||||
);
|
);
|
||||||
const idExportGroup = createToolbarActionGroup(document, "id-export", [
|
const idExportGroup = createToolbarActionGroup(document, "id-export", [
|
||||||
audienceProfileByIdExportButton
|
audienceProfileByIdExportButton
|
||||||
@@ -221,8 +213,6 @@ export function ensurePluginToolbar(
|
|||||||
batchSubmitButton
|
batchSubmitButton
|
||||||
]);
|
]);
|
||||||
dataGroup.append(
|
dataGroup.append(
|
||||||
csvExportGroup,
|
|
||||||
createToolbarActionDivider(document),
|
|
||||||
selectedAudienceExportGroup,
|
selectedAudienceExportGroup,
|
||||||
createToolbarActionDivider(document),
|
createToolbarActionDivider(document),
|
||||||
idExportGroup,
|
idExportGroup,
|
||||||
@@ -253,16 +243,12 @@ export function ensurePluginToolbar(
|
|||||||
audienceProfileByIdExportButton,
|
audienceProfileByIdExportButton,
|
||||||
audienceProfileFieldButton,
|
audienceProfileFieldButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportButton,
|
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
spreadMetricRules
|
spreadMetricRules
|
||||||
});
|
});
|
||||||
ensureToolbarMounted(root, document);
|
ensureToolbarMounted(root, document);
|
||||||
|
|
||||||
exportButton.addEventListener("click", () => {
|
|
||||||
void handlers.onExport();
|
|
||||||
});
|
|
||||||
audienceProfileExportButton.addEventListener("click", () => {
|
audienceProfileExportButton.addEventListener("click", () => {
|
||||||
void handlers.onExportAudienceProfile();
|
void handlers.onExportAudienceProfile();
|
||||||
});
|
});
|
||||||
@@ -281,7 +267,6 @@ export function ensurePluginToolbar(
|
|||||||
audienceProfileByIdExportButton,
|
audienceProfileByIdExportButton,
|
||||||
audienceProfileFieldButton,
|
audienceProfileFieldButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportButton,
|
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportStatusText,
|
exportStatusText,
|
||||||
@@ -352,7 +337,6 @@ function createToolbarActionGroup(
|
|||||||
document: Document,
|
document: Document,
|
||||||
groupName:
|
groupName:
|
||||||
| "batch-submit"
|
| "batch-submit"
|
||||||
| "csv-export"
|
|
||||||
| "id-export"
|
| "id-export"
|
||||||
| "selected-audience-export",
|
| "selected-audience-export",
|
||||||
controls: HTMLElement[]
|
controls: HTMLElement[]
|
||||||
@@ -660,9 +644,6 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
|||||||
batchSubmitButton: root.querySelector(
|
batchSubmitButton: root.querySelector(
|
||||||
'[data-plugin-batch-submit="button"]'
|
'[data-plugin-batch-submit="button"]'
|
||||||
) as HTMLButtonElement,
|
) as HTMLButtonElement,
|
||||||
exportButton: root.querySelector(
|
|
||||||
'[data-plugin-export="button"]'
|
|
||||||
) as HTMLButtonElement,
|
|
||||||
exportCustomPagesInput: root.querySelector(
|
exportCustomPagesInput: root.querySelector(
|
||||||
'[data-plugin-export-custom-pages="input"]'
|
'[data-plugin-export-custom-pages="input"]'
|
||||||
) as HTMLInputElement,
|
) as HTMLInputElement,
|
||||||
@@ -780,7 +761,6 @@ export function setToolbarBusyState(
|
|||||||
toolbar.audienceProfileFieldButton,
|
toolbar.audienceProfileFieldButton,
|
||||||
toolbar.audienceProfileByIdExportButton,
|
toolbar.audienceProfileByIdExportButton,
|
||||||
toolbar.audienceProfileExportButton,
|
toolbar.audienceProfileExportButton,
|
||||||
toolbar.exportButton,
|
|
||||||
toolbar.exportRangeSelect,
|
toolbar.exportRangeSelect,
|
||||||
toolbar.exportCustomPagesInput,
|
toolbar.exportCustomPagesInput,
|
||||||
toolbar.metricCatalog.addButton,
|
toolbar.metricCatalog.addButton,
|
||||||
@@ -1362,7 +1342,6 @@ function applyNativeControlStyles(
|
|||||||
audienceProfileByIdExportButton: HTMLButtonElement;
|
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||||
audienceProfileFieldButton: HTMLButtonElement;
|
audienceProfileFieldButton: HTMLButtonElement;
|
||||||
batchSubmitButton: HTMLButtonElement;
|
batchSubmitButton: HTMLButtonElement;
|
||||||
exportButton: HTMLButtonElement;
|
|
||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
exportRangeSelect: HTMLSelectElement;
|
exportRangeSelect: HTMLSelectElement;
|
||||||
spreadMetricRules: SpreadMetricRuleDomMap;
|
spreadMetricRules: SpreadMetricRuleDomMap;
|
||||||
@@ -1377,7 +1356,6 @@ function applyNativeControlStyles(
|
|||||||
findNativeActionButton(document, "导出");
|
findNativeActionButton(document, "导出");
|
||||||
|
|
||||||
if (nativeButton) {
|
if (nativeButton) {
|
||||||
controls.exportButton.className = nativeButton.className;
|
|
||||||
controls.audienceProfileExportButton.className = nativeButton.className;
|
controls.audienceProfileExportButton.className = nativeButton.className;
|
||||||
controls.audienceProfileByIdExportButton.className = nativeButton.className;
|
controls.audienceProfileByIdExportButton.className = nativeButton.className;
|
||||||
controls.audienceProfileFieldButton.className = nativeButton.className;
|
controls.audienceProfileFieldButton.className = nativeButton.className;
|
||||||
@@ -1385,7 +1363,6 @@ function applyNativeControlStyles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secondaryButtons = [
|
const secondaryButtons = [
|
||||||
controls.exportButton,
|
|
||||||
controls.audienceProfileExportButton,
|
controls.audienceProfileExportButton,
|
||||||
controls.audienceProfileByIdExportButton,
|
controls.audienceProfileByIdExportButton,
|
||||||
controls.audienceProfileFieldButton
|
controls.audienceProfileFieldButton
|
||||||
@@ -1515,7 +1492,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
|||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
[data-plugin-export="button"]:hover:not(:disabled),
|
|
||||||
[data-plugin-export-audience-profile="button"]:hover:not(:disabled),
|
[data-plugin-export-audience-profile="button"]:hover:not(:disabled),
|
||||||
[data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled),
|
[data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled),
|
||||||
[data-plugin-audience-profile-fields="button"]:hover:not(:disabled) {
|
[data-plugin-audience-profile-fields="button"]:hover:not(:disabled) {
|
||||||
@@ -1528,7 +1504,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
|||||||
border-color: #6d1627 !important;
|
border-color: #6d1627 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-plugin-export="button"]:active:not(:disabled),
|
|
||||||
[data-plugin-export-audience-profile="button"]:active:not(:disabled),
|
[data-plugin-export-audience-profile="button"]:active:not(:disabled),
|
||||||
[data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled),
|
[data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled),
|
||||||
[data-plugin-audience-profile-fields="button"]:active:not(:disabled) {
|
[data-plugin-audience-profile-fields="button"]:active:not(:disabled) {
|
||||||
@@ -1543,7 +1518,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
|||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-plugin-export="button"]:focus-visible,
|
|
||||||
[data-plugin-export-audience-profile="button"]:focus-visible,
|
[data-plugin-export-audience-profile="button"]:focus-visible,
|
||||||
[data-plugin-export-audience-profile-by-id="button"]:focus-visible,
|
[data-plugin-export-audience-profile-by-id="button"]:focus-visible,
|
||||||
[data-plugin-audience-profile-fields="button"]:focus-visible {
|
[data-plugin-audience-profile-fields="button"]:focus-visible {
|
||||||
@@ -1556,7 +1530,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
|||||||
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
|
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-plugin-export="button"]:disabled,
|
|
||||||
[data-plugin-export-audience-profile="button"]:disabled,
|
[data-plugin-export-audience-profile="button"]:disabled,
|
||||||
[data-plugin-export-audience-profile-by-id="button"]:disabled,
|
[data-plugin-export-audience-profile-by-id="button"]:disabled,
|
||||||
[data-plugin-audience-profile-fields="button"]:disabled {
|
[data-plugin-audience-profile-fields="button"]:disabled {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
interface FetchResponseLike {
|
interface FetchResponseLike {
|
||||||
json(): Promise<unknown>;
|
json(): Promise<unknown>;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchLike = (
|
type FetchLike = (
|
||||||
@@ -90,10 +92,13 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
|||||||
const timeoutMs = options.timeoutMs ?? 8000;
|
const timeoutMs = options.timeoutMs ?? 8000;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
|
async loadAuthorSpreadMetrics(
|
||||||
|
authorId: string,
|
||||||
|
selectedConfigs: SpreadInfoConfig[] = configs
|
||||||
|
): Promise<SpreadInfoMetrics> {
|
||||||
const metrics: SpreadInfoMetrics = {};
|
const metrics: SpreadInfoMetrics = {};
|
||||||
|
|
||||||
for (const config of configs) {
|
for (const config of selectedConfigs) {
|
||||||
const mappedResponse = await loadSpreadInfoFromUrl(
|
const mappedResponse = await loadSpreadInfoFromUrl(
|
||||||
buildSpreadInfoUrl(authorId, config, baseUrl)
|
buildSpreadInfoUrl(authorId, config, baseUrl)
|
||||||
);
|
);
|
||||||
@@ -127,12 +132,22 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
|||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {};
|
throw new Error(
|
||||||
|
`星图传播数据接口请求失败: HTTP ${response.status ?? "unknown"}${
|
||||||
|
response.statusText ? `: ${response.statusText}` : ""
|
||||||
|
}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapSpreadInfoResponse(await response.json());
|
const payload = await response.json();
|
||||||
} catch {
|
const rateLimitMessage = readRateLimitMessage(payload);
|
||||||
return {};
|
if (rateLimitMessage) {
|
||||||
|
throw new Error(
|
||||||
|
`星图传播数据接口被限流 (status_code=31157): ${rateLimitMessage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapSpreadInfoResponse(payload);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
@@ -163,6 +178,30 @@ export function buildSpreadInfoColumns(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectSpreadInfoConfigsForHeaders(
|
||||||
|
selectedHeaders: readonly string[],
|
||||||
|
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
|
||||||
|
): SpreadInfoConfig[] {
|
||||||
|
const selectedHeaderSet = new Set(selectedHeaders);
|
||||||
|
|
||||||
|
return configs.filter((config) =>
|
||||||
|
buildSpreadInfoColumns([config]).some((header) =>
|
||||||
|
selectedHeaderSet.has(header)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterUnloadedSpreadInfoConfigs(
|
||||||
|
configs: SpreadInfoConfig[],
|
||||||
|
existingMetrics: SpreadInfoMetrics
|
||||||
|
): SpreadInfoConfig[] {
|
||||||
|
return configs.filter((config) =>
|
||||||
|
buildSpreadInfoColumns([config]).every(
|
||||||
|
(header) => !(header in existingMetrics)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSpreadInfoMetricMap(
|
export function buildSpreadInfoMetricMap(
|
||||||
config: SpreadInfoConfig,
|
config: SpreadInfoConfig,
|
||||||
metrics: MappedSpreadInfoResponse
|
metrics: MappedSpreadInfoResponse
|
||||||
@@ -278,6 +317,26 @@ function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
|||||||
return isRecord(payload.data) ? payload.data : payload;
|
return isRecord(payload.data) ? payload.data : payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readRateLimitMessage(payload: unknown): string | null {
|
||||||
|
const payloadRecord = isRecord(payload) ? payload : null;
|
||||||
|
const dataRecord = payloadRecord && isRecord(payloadRecord.data)
|
||||||
|
? payloadRecord.data
|
||||||
|
: null;
|
||||||
|
const baseResponse =
|
||||||
|
(payloadRecord && isRecord(payloadRecord.base_resp) && payloadRecord.base_resp) ||
|
||||||
|
(dataRecord && isRecord(dataRecord.base_resp) && dataRecord.base_resp);
|
||||||
|
|
||||||
|
if (!baseResponse || readNumberLike(baseResponse.status_code) !== 31157) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
readStringLike(baseResponse.status_message) ??
|
||||||
|
readStringLike(baseResponse.message) ??
|
||||||
|
"您访问过于频繁,请稍后重试"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function readNestedValue(value: unknown, key: string): unknown {
|
function readNestedValue(value: unknown, key: string): unknown {
|
||||||
return isRecord(value) ? value[key] : undefined;
|
return isRecord(value) ? value[key] : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,31 @@ describe("csv-exporter", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("omits unselected columns from the standard CSV after field settings are saved", () => {
|
||||||
|
const csv = buildMarketCsv(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
authorId: "123",
|
||||||
|
authorName: "Alice",
|
||||||
|
exportFields: {
|
||||||
|
达人信息: "Alice",
|
||||||
|
粉丝数: "100w",
|
||||||
|
预期CPM: "120"
|
||||||
|
},
|
||||||
|
status: "success"
|
||||||
|
} satisfies MarketRecord
|
||||||
|
],
|
||||||
|
{
|
||||||
|
selectedHeaders: ["粉丝数"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
|
expect(headerLine).toBe("达人ID,达人名称,导出状态,失败原因,粉丝数");
|
||||||
|
expect(rowLine).toBe("123,Alice,成功,,100w");
|
||||||
|
});
|
||||||
|
|
||||||
test("escapes commas and quotes", () => {
|
test("escapes commas and quotes", () => {
|
||||||
const csv = buildMarketCsv([
|
const csv = buildMarketCsv([
|
||||||
{
|
{
|
||||||
|
|||||||
+301
-1779
File diff suppressed because it is too large
Load Diff
+129
-1
@@ -6,9 +6,11 @@ import {
|
|||||||
buildSpreadInfoUrl,
|
buildSpreadInfoUrl,
|
||||||
createSpreadInfoClient,
|
createSpreadInfoClient,
|
||||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||||
|
filterUnloadedSpreadInfoConfigs,
|
||||||
matchesSpreadMetricRule,
|
matchesSpreadMetricRule,
|
||||||
normalizeSpreadInfoConfig,
|
normalizeSpreadInfoConfig,
|
||||||
mapSpreadInfoResponse
|
mapSpreadInfoResponse,
|
||||||
|
selectSpreadInfoConfigsForHeaders
|
||||||
} from "../src/content/market/spread-info";
|
} from "../src/content/market/spread-info";
|
||||||
|
|
||||||
describe("spread-info", () => {
|
describe("spread-info", () => {
|
||||||
@@ -90,6 +92,49 @@ describe("spread-info", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("selects only unique request configs required by chosen columns", () => {
|
||||||
|
expect(
|
||||||
|
selectSpreadInfoConfigsForHeaders([
|
||||||
|
"内容数据-个人视频-近30天-完播率",
|
||||||
|
"内容数据-个人视频-近30天-作品平均评论数",
|
||||||
|
"内容数据-只看指派-排除营销流量-星图视频-近90天-互动率",
|
||||||
|
"效果预估-20-60s视频-预期CPM"
|
||||||
|
])
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
flowType: 0,
|
||||||
|
onlyAssign: false,
|
||||||
|
range: 2,
|
||||||
|
type: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
flowType: 1,
|
||||||
|
onlyAssign: true,
|
||||||
|
range: 3,
|
||||||
|
type: 2
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps newly selected configs when other content configs are cached", () => {
|
||||||
|
const personalConfig = DEFAULT_SPREAD_INFO_CONFIGS[0];
|
||||||
|
const xingtuConfig = DEFAULT_SPREAD_INFO_CONFIGS.find(
|
||||||
|
(config) =>
|
||||||
|
config.type === 2 &&
|
||||||
|
config.onlyAssign &&
|
||||||
|
config.flowType === 1 &&
|
||||||
|
config.range === 3
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(xingtuConfig).toBeDefined();
|
||||||
|
expect(
|
||||||
|
filterUnloadedSpreadInfoConfigs(
|
||||||
|
[personalConfig, xingtuConfig!],
|
||||||
|
{ "内容数据-个人视频-近30天-完播率": "28%" }
|
||||||
|
)
|
||||||
|
).toEqual([xingtuConfig]);
|
||||||
|
});
|
||||||
|
|
||||||
test("maps spread info response values into display values", () => {
|
test("maps spread info response values into display values", () => {
|
||||||
expect(
|
expect(
|
||||||
mapSpreadInfoResponse({
|
mapSpreadInfoResponse({
|
||||||
@@ -169,6 +214,89 @@ describe("spread-info", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("requests only the explicitly selected spread configs", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => ({
|
||||||
|
json: async () => ({ play_over_rate: { value: 2824 } }),
|
||||||
|
ok: true
|
||||||
|
}));
|
||||||
|
const client = createSpreadInfoClient({ fetchImpl });
|
||||||
|
const selectedConfig = {
|
||||||
|
flowType: 1 as const,
|
||||||
|
onlyAssign: true,
|
||||||
|
range: 3 as const,
|
||||||
|
type: 2 as const
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.loadAuthorSpreadMetrics("7361012802036695050", [selectedConfig]);
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchImpl.mock.calls[0][0]).toContain(
|
||||||
|
"type=2&flow_type=1&only_assign=true&range=3"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws an HTTP error instead of returning empty metrics", async () => {
|
||||||
|
const client = createSpreadInfoClient({
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
json: async () => ({}),
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
statusText: "Too Many Requests"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.loadAuthorSpreadMetricSnapshot("7361012802036695050", {
|
||||||
|
flowType: 0,
|
||||||
|
onlyAssign: true,
|
||||||
|
range: 2,
|
||||||
|
type: 2
|
||||||
|
})
|
||||||
|
).rejects.toThrow("HTTP 429: Too Many Requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws an explicit error when Xingtu reports business rate limiting", async () => {
|
||||||
|
const client = createSpreadInfoClient({
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
json: async () => ({
|
||||||
|
base_resp: {
|
||||||
|
status_code: 31157,
|
||||||
|
status_message: "您访问过于频繁,请24小时后重试"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ok: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.loadAuthorSpreadMetricSnapshot("7361012802036695050", {
|
||||||
|
flowType: 0,
|
||||||
|
onlyAssign: true,
|
||||||
|
range: 2,
|
||||||
|
type: 2
|
||||||
|
})
|
||||||
|
).rejects.toThrow(
|
||||||
|
"星图传播数据接口被限流 (status_code=31157): 您访问过于频繁,请24小时后重试"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propagates network errors instead of returning empty metrics", async () => {
|
||||||
|
const client = createSpreadInfoClient({
|
||||||
|
fetchImpl: async () => {
|
||||||
|
throw new Error("network unavailable");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.loadAuthorSpreadMetricSnapshot("7361012802036695050", {
|
||||||
|
flowType: 0,
|
||||||
|
onlyAssign: true,
|
||||||
|
range: 2,
|
||||||
|
type: 2
|
||||||
|
})
|
||||||
|
).rejects.toThrow("network unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizes fixed personal-video parameters before grouping", () => {
|
test("normalizes fixed personal-video parameters before grouping", () => {
|
||||||
expect(
|
expect(
|
||||||
normalizeSpreadInfoConfig({
|
normalizeSpreadInfoConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user