feat: make audience export fields selective
This commit is contained in:
+18
-18
@@ -106,17 +106,17 @@
|
||||
|
||||
- 触发者:使用者点击 `导出选中达人数据`。
|
||||
- 输入:当前勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。
|
||||
- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留该范围内已勾选的达人,然后补充内容数据、效果预估、画像、秒思指标等字段。
|
||||
- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留该范围内已勾选的达人,然后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出。
|
||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
||||
- 下一步:使用者检查 CSV 内容。
|
||||
- 人工操作:需要使用者勾选达人并点击按钮。
|
||||
- 条件分支:如果当前导出范围内没有选中的达人,则不下载 CSV 并提示。
|
||||
- 条件分支:如果当前导出范围内没有选中的达人,则不下载 CSV 并提示;单项补充失败不阻止下载,CSV 的 `导出状态` 和 `失败原因` 会标明部分成功及失败来源。
|
||||
|
||||
### 2.6 按星图 ID 导出
|
||||
|
||||
- 触发者:使用者点击 `按星图ID导出`。
|
||||
- 输入:弹窗中粘贴的达人星图 ID。
|
||||
- 处理:插件校验 ID 格式、去重、忽略非法 token,然后逐个 ID 请求基础信息、看后搜率、传播指标、画像、商业能力和后端秒思指标。
|
||||
- 处理:插件校验 ID 格式、去重、忽略非法 token,然后逐个 ID 获取基础信息,并按字段选择配置请求看后搜率、传播指标、画像、效果预估和后端秒思指标。未勾选的数据类别不调用对应补充接口。
|
||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
||||
- 下一步:使用者检查 CSV 中每个 ID 的导出状态和失败原因。
|
||||
- 人工操作:需要使用者粘贴 ID 并确认。
|
||||
@@ -216,9 +216,9 @@
|
||||
- 必须先勾选达人;
|
||||
- 先按导出范围收集达人;
|
||||
- 再严格保留当前导出范围内已勾选的达人;
|
||||
- 对每个达人补充画像、商业能力、传播指标、看后搜率、秒思指标;
|
||||
- 字段选择只控制可选字段,基础字段、导出状态和失败原因等固定保留;
|
||||
- 如果全部画像请求失败,则不下载 CSV 并提示画像导出失败;
|
||||
- 只补充已选字段对应的画像、效果预估、传播指标、看后搜率和秒思指标,未选数据类别不调用对应接口;
|
||||
- 基础字段、导出状态和失败原因等固定保留;
|
||||
- 画像请求全部失败时仍下载 CSV,已成功的效果预估等字段正常写入;
|
||||
- 单个达人部分接口失败时,CSV 保留该行,并写入导出状态和失败原因。
|
||||
- 输出结果:CSV 文件。
|
||||
- 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。
|
||||
@@ -226,7 +226,7 @@
|
||||
- 没有勾选达人:提示并停止;
|
||||
- 当前范围内无选中达人:提示并停止;
|
||||
- 单个达人部分失败:记录为部分成功或失败;
|
||||
- 全部画像失败:不下载 CSV。
|
||||
- 画像失败:对应画像单元格留空,失败来源写入 `失败原因`,其他成功数据仍保留并下载。
|
||||
- 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。
|
||||
|
||||
### 3.7 按星图 ID 导出
|
||||
@@ -238,7 +238,7 @@
|
||||
- 只接受 16 到 20 位纯数字;
|
||||
- 重复 ID 会去重;
|
||||
- 非法 token 会计入提示,但不会进入导出;
|
||||
- 对有效 ID 逐个补齐基础信息、看后搜率、传播指标、画像、商业能力和后端秒思指标;
|
||||
- 对有效 ID 逐个获取基础信息,只补齐已选字段对应的看后搜率、传播指标、画像、效果预估和后端秒思指标;
|
||||
- 每个 ID 生成一行 CSV,并标记成功、部分成功或失败。
|
||||
- 输出结果:按 ID 导出的 CSV 文件。
|
||||
- 外部依赖:星图基础信息接口、星图指标接口、公司后端指标接口、浏览器下载能力。
|
||||
@@ -301,7 +301,7 @@
|
||||
- 优先通过 Chrome 扩展后台下载;
|
||||
- 如果扩展下载通道不可用,则使用页面中的临时下载链接;
|
||||
- CSV 带 UTF-8 BOM,方便表格软件识别中文;
|
||||
- 普通导出文件名使用插件名加时间戳;
|
||||
- 选中达人导出文件名使用画像导出标识加时间戳;
|
||||
- 按 ID 导出文件名包含按 ID 导出标识。
|
||||
- 输出结果:浏览器下载列表中出现 CSV 文件。
|
||||
- 外部依赖:Chrome downloads 能力或浏览器下载能力。
|
||||
@@ -316,11 +316,11 @@
|
||||
| `search_for_author_square` | 获取星图达人市场列表数据 | 后台分页导出、读取达人基础字段 | 当前星图列表请求参数、页码 | 达人列表、分页信息、基础字段 | 是 | 未确认 | 后台分页串行请求;`全部` 最多尝试 200 页 | 未单独设置 fetch 超时 | 无自动重试 | 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 | 未确认 | 失败后标记该达人看后搜率失败 |
|
||||
| `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 | 未确认 | 单项失败写入失败原因 |
|
||||
| `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_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_spread_info` | 获取内容传播指标,并用于规则筛选 | 内容数据导出、传播指标筛选 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`type`、`flow_type`、`only_assign`、`range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 指标补充对达人并发;单个达人内多组参数串行;筛选对达人并发,同一达人相同口径的规则复用一次响应 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时字段留空;筛选请求失败、缺少请求 ID 或缺少已选指标时该达人不满足筛选 |
|
||||
| `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 或缺少已选指标时该达人不满足筛选 |
|
||||
| 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 或非成功响应会终止本次提交 |
|
||||
| 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 导出时,非 16 到 20 位纯数字 token 会过滤。
|
||||
|
||||
@@ -376,7 +376,7 @@
|
||||
- 看后搜率优先使用列表中已有值,不完整时再请求星图指标接口;
|
||||
- 秒思指标按 star_id 从后端补充;
|
||||
- 传播指标按多组参数生成不同列,不合并同名业务指标;
|
||||
- `代表视频`可能被读取,但不会进入最终普通市场 CSV;
|
||||
- `代表视频`可能被读取,但不会进入最终达人数据 CSV;
|
||||
- 画像和商业能力字段追加在基础字段之后;
|
||||
- 传播指标字段追加在基础字段、看后搜率和秒思 api 字段之后。
|
||||
|
||||
@@ -624,7 +624,7 @@
|
||||
- 单个达人部分接口失败时,CSV 中该行 `导出状态` 为部分成功或失败,并在 `失败原因` 中列明失败项;
|
||||
- 秒思 api 指标失败时,对应字段为空或页面显示失败,不一定影响 CSV 下载;
|
||||
- 传播指标某组参数失败时,对应字段为空;
|
||||
- 画像部分失败时,其他画像或商业能力字段仍可保留;
|
||||
- 画像部分或全部失败时,对应单元格留空并写入失败原因,其他画像或效果预估字段仍可保留;
|
||||
- 后端指标查询不到某个达人时显示暂无数据。
|
||||
|
||||
### 9.5 失败表现
|
||||
@@ -632,7 +632,7 @@
|
||||
- 未登录:星图页面显示登录提示,不出现业务工具栏;
|
||||
- 没勾选就导出选中达人数据:提示请先勾选;
|
||||
- 当前范围无选中达人:提示当前导出范围内没有选中的达人;
|
||||
- 全部画像失败:提示画像导出失败,不下载 CSV;
|
||||
- 全部已选补充数据都失败:CSV 仍会下载,该行标记为部分成功或失败并列明失败原因;
|
||||
- 按 ID 没有有效 ID:提示请输入有效的达人星图 ID;
|
||||
- 批次提交失败:状态区显示接口错误或通用失败提示;
|
||||
- 已选传播指标未填写合法阈值:状态区提示具体指标,导出或提交不会开始;
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface AudienceProfileFailure {
|
||||
status: "failed";
|
||||
}
|
||||
|
||||
export interface AudienceProfileSkipped {
|
||||
status: "skipped";
|
||||
}
|
||||
|
||||
export interface AudienceProfileSet {
|
||||
audience: AudienceProfileResult;
|
||||
fans: AudienceProfileResult;
|
||||
@@ -34,7 +38,8 @@ export interface AudienceProfileSet {
|
||||
|
||||
export type AudienceProfileResult =
|
||||
| AudienceProfileSuccess
|
||||
| AudienceProfileFailure;
|
||||
| AudienceProfileFailure
|
||||
| AudienceProfileSkipped;
|
||||
|
||||
export interface AudienceProfileExportRow {
|
||||
businessAbility?: BusinessAbilityResult;
|
||||
|
||||
@@ -8,6 +8,10 @@ export type CsvColumn = {
|
||||
readValue: (record: MarketRecord) => string;
|
||||
};
|
||||
|
||||
export interface MarketCsvOptions {
|
||||
selectedHeaders?: string[];
|
||||
}
|
||||
|
||||
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
|
||||
{
|
||||
header: "达人ID",
|
||||
@@ -80,6 +84,13 @@ const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) =
|
||||
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
|
||||
}));
|
||||
|
||||
const FIXED_MARKET_CSV_HEADERS = new Set([
|
||||
"达人ID",
|
||||
"达人名称",
|
||||
"导出状态",
|
||||
"失败原因"
|
||||
]);
|
||||
|
||||
export function listRateCsvHeaders(): string[] {
|
||||
return RATE_COLUMNS.map((column) => column.header);
|
||||
}
|
||||
@@ -88,8 +99,20 @@ export function listBackendMetricCsvHeaders(): string[] {
|
||||
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
|
||||
}
|
||||
|
||||
export function buildMarketCsv(records: MarketRecord[]): string {
|
||||
const csvColumns = buildMarketCsvColumns(records);
|
||||
export function buildMarketCsv(
|
||||
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 rowLines = records.map((record) =>
|
||||
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||
@@ -133,3 +156,68 @@ export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
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 {
|
||||
buildAudienceProfileCsv,
|
||||
listAudienceProfileSelectableFieldGroups,
|
||||
@@ -52,8 +56,11 @@ import { createSilentExportController } from "./silent-export-controller";
|
||||
import {
|
||||
buildSpreadInfoConfigKey,
|
||||
createSpreadInfoClient,
|
||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||
filterUnloadedSpreadInfoConfigs,
|
||||
matchesSpreadMetricRule,
|
||||
normalizeSpreadInfoConfig,
|
||||
selectSpreadInfoConfigsForHeaders,
|
||||
type MappedSpreadInfoResponse
|
||||
} from "./spread-info";
|
||||
import {
|
||||
@@ -91,12 +98,21 @@ interface MutationObserverLike {
|
||||
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 {
|
||||
buildAudienceProfileCsv?: (
|
||||
rows: AudienceProfileExportRow[],
|
||||
options?: AudienceProfileCsvOptions
|
||||
) => string;
|
||||
buildCsv?: (records: MarketRecord[]) => string;
|
||||
confirmFavoriteDelete?: (message: string) => boolean;
|
||||
confirmFavoriteImport?: (message: string) => boolean;
|
||||
document: Document;
|
||||
@@ -115,7 +131,10 @@ export interface CreateMarketControllerOptions {
|
||||
spreadAuthorId: string,
|
||||
config: SpreadInfoConfig
|
||||
) => Promise<MappedSpreadInfoResponse>;
|
||||
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
|
||||
loadSpreadMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
configs?: SpreadInfoConfig[]
|
||||
) => Promise<Record<string, string>>;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||
Array<BackendMetrics & { starId: string }>
|
||||
>;
|
||||
@@ -158,7 +177,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const searchBackendMetrics =
|
||||
options.searchBackendMetrics ??
|
||||
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
||||
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
||||
const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv;
|
||||
const loadAudienceProfile =
|
||||
options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile;
|
||||
@@ -319,41 +337,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
startObserving();
|
||||
|
||||
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 () => {
|
||||
syncSelectionStateFromDom();
|
||||
if (selectedAuthorIds.size === 0) {
|
||||
@@ -369,10 +352,16 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
setToolbarBusyState(toolbar, true);
|
||||
try {
|
||||
const selectedHeaders = readAudienceProfileSelectedHeaders();
|
||||
const exportSelection = resolveAudienceProfileExportSelection(
|
||||
selectedHeaders
|
||||
);
|
||||
const selectedRecords = filterRecordsBySelectionStrict(
|
||||
await exportRecords(exportTarget.target, "画像导出中", {
|
||||
includeSpreadMetrics: true,
|
||||
showDetailedProgress: false
|
||||
includeBackendMetrics: exportSelection.includeBackendMetrics,
|
||||
includeRates: exportSelection.includeRates,
|
||||
showDetailedProgress: false,
|
||||
spreadInfoConfigs: exportSelection.spreadInfoConfigs
|
||||
})
|
||||
);
|
||||
if (selectedRecords.length === 0) {
|
||||
@@ -388,28 +377,21 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
`画像导出中 ${index + 1}/${selectedRecords.length}...`
|
||||
);
|
||||
const [profiles, businessAbility] = await Promise.all([
|
||||
loadAudienceProfileSet(record),
|
||||
loadBusinessAbilitySafe(record)
|
||||
loadAudienceProfileSet(record, exportSelection),
|
||||
exportSelection.businessAbility
|
||||
? loadBusinessAbilitySafe(record)
|
||||
: Promise.resolve(undefined)
|
||||
]);
|
||||
rows.push({
|
||||
rows.push(finalizeAudienceProfileExportRow({
|
||||
businessAbility,
|
||||
profiles,
|
||||
record
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
rows.every((row) =>
|
||||
Object.values(row.profiles).every((profile) => profile.status === "failed")
|
||||
)
|
||||
) {
|
||||
setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试");
|
||||
return;
|
||||
}));
|
||||
}
|
||||
|
||||
options.onCsvReady?.(
|
||||
buildAudienceCsv(rows, {
|
||||
selectedHeaders: readAudienceProfileSelectedHeaders()
|
||||
selectedHeaders
|
||||
}),
|
||||
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} 个`
|
||||
);
|
||||
|
||||
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[] = [];
|
||||
for (let index = 0; index < parsed.ids.length; index += 1) {
|
||||
const authorId = parsed.ids[index];
|
||||
@@ -453,14 +441,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
rows.push(
|
||||
await loadAudienceProfileRowById(
|
||||
authorId,
|
||||
backendMetricsByAuthorId.get(authorId)
|
||||
backendMetricsByAuthorId.get(authorId),
|
||||
exportSelection
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
options.onCsvReady?.(
|
||||
buildAudienceCsv(rows, {
|
||||
selectedHeaders: readAudienceProfileSelectedHeaders()
|
||||
selectedHeaders
|
||||
}),
|
||||
buildAudienceProfileFilename(new Date(), "按ID导出")
|
||||
);
|
||||
@@ -1050,8 +1039,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
target: MarketExportTarget,
|
||||
inProgressLabel = "导出中",
|
||||
progressOptions: {
|
||||
includeSpreadMetrics?: boolean;
|
||||
includeBackendMetrics?: boolean;
|
||||
includeRates?: boolean;
|
||||
showDetailedProgress?: boolean;
|
||||
spreadInfoConfigs?: SpreadInfoConfig[];
|
||||
} = {}
|
||||
): Promise<MarketRecord[]> {
|
||||
activeProgressLabel = inProgressLabel;
|
||||
@@ -1061,7 +1052,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
if (target.mode === "count" && target.pageCount <= 1) {
|
||||
await prepareCurrentPageForExport();
|
||||
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"
|
||||
})),
|
||||
{
|
||||
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>();
|
||||
await Promise.all(
|
||||
Array.from(configsByKey.entries()).map(async ([key, config]) => {
|
||||
try {
|
||||
snapshots.set(
|
||||
key,
|
||||
await loadSpreadFilterMetrics(spreadAuthorId, config)
|
||||
);
|
||||
} catch {
|
||||
snapshots.set(key, {});
|
||||
}
|
||||
snapshots.set(
|
||||
key,
|
||||
await loadSpreadFilterMetrics(spreadAuthorId, config)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1178,11 +1169,17 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
|
||||
async function loadAudienceProfileSet(
|
||||
record: MarketRecord
|
||||
record: MarketRecord,
|
||||
exportSelection: AudienceProfileExportSelection
|
||||
): Promise<AudienceProfileExportRow["profiles"]> {
|
||||
const profiles = {} as AudienceProfileExportRow["profiles"];
|
||||
|
||||
for (const { kind, target } of audienceProfileTargets) {
|
||||
if (!exportSelection[kind]) {
|
||||
profiles[kind] = { status: "skipped" };
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
profiles[kind] = await loadAudienceProfile(record, target);
|
||||
} catch (error) {
|
||||
@@ -1213,54 +1210,45 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
async function loadAudienceProfileRowById(
|
||||
authorId: string,
|
||||
backendMetrics?: BackendMetrics
|
||||
backendMetrics: BackendMetrics | undefined,
|
||||
exportSelection: AudienceProfileExportSelection
|
||||
): Promise<AudienceProfileExportRow> {
|
||||
const [baseRecord, metricsResult] = await Promise.all([
|
||||
loadAuthorBaseInfoSafe(authorId),
|
||||
loadAuthorMetricsSafe(authorId)
|
||||
exportSelection.includeRates
|
||||
? loadAuthorMetricsSafe(authorId)
|
||||
: Promise.resolve(undefined)
|
||||
]);
|
||||
const spreadMetrics = baseRecord.spreadAuthorId
|
||||
? await loadSpreadMetrics(baseRecord.spreadAuthorId)
|
||||
: {};
|
||||
const spreadMetrics =
|
||||
exportSelection.spreadInfoConfigs.length > 0 && baseRecord.spreadAuthorId
|
||||
? await loadSpreadMetrics(
|
||||
baseRecord.spreadAuthorId,
|
||||
exportSelection.spreadInfoConfigs
|
||||
)
|
||||
: {};
|
||||
const recordForRequests = {
|
||||
...baseRecord,
|
||||
authorName: baseRecord.authorName || authorId,
|
||||
...(metricsResult.success ? { rates: metricsResult.rates } : {}),
|
||||
...(metricsResult?.success ? { rates: metricsResult.rates } : {}),
|
||||
...(backendMetrics
|
||||
? { backendMetrics, backendMetricsStatus: "success" as const }
|
||||
: {}),
|
||||
...(Object.keys(spreadMetrics).length > 0 ? { spreadMetrics } : {})
|
||||
};
|
||||
const [profiles, businessAbility] = await Promise.all([
|
||||
loadAudienceProfileSet(recordForRequests),
|
||||
loadBusinessAbilitySafe(recordForRequests)
|
||||
loadAudienceProfileSet(recordForRequests, exportSelection),
|
||||
exportSelection.businessAbility
|
||||
? loadBusinessAbilitySafe(recordForRequests)
|
||||
: Promise.resolve(undefined)
|
||||
]);
|
||||
const failureReasons = collectAudienceProfileRowFailures(
|
||||
baseRecord,
|
||||
profiles,
|
||||
businessAbility
|
||||
return finalizeAudienceProfileExportRow(
|
||||
{
|
||||
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> {
|
||||
@@ -1314,10 +1302,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
function collectAudienceProfileRowFailures(
|
||||
baseRecord: MarketRecord,
|
||||
profiles: AudienceProfileExportRow["profiles"],
|
||||
businessAbility: BusinessAbilityResult
|
||||
businessAbility: BusinessAbilityResult | undefined,
|
||||
includeBaseRecordFailure: boolean
|
||||
): string[] {
|
||||
const failures: string[] = [];
|
||||
if (baseRecord.status === "failed") {
|
||||
if (includeBaseRecordFailure && baseRecord.status === "failed") {
|
||||
failures.push(`基础信息:${baseRecord.failureReason ?? "request-failed"}`);
|
||||
}
|
||||
|
||||
@@ -1327,8 +1316,8 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
});
|
||||
|
||||
if (businessAbility.status === "failed") {
|
||||
failures.push(`商业能力:${businessAbility.failureReason ?? "request-failed"}`);
|
||||
if (businessAbility?.status === "failed") {
|
||||
failures.push(`效果预估:${businessAbility.failureReason ?? "request-failed"}`);
|
||||
}
|
||||
|
||||
return failures;
|
||||
@@ -1337,15 +1326,55 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
function hasAudienceProfileRowSuccess(
|
||||
baseRecord: MarketRecord,
|
||||
profiles: AudienceProfileExportRow["profiles"],
|
||||
businessAbility: BusinessAbilityResult
|
||||
businessAbility: BusinessAbilityResult | undefined,
|
||||
includeBaseRecordFailure: boolean
|
||||
): boolean {
|
||||
return (
|
||||
!includeBaseRecordFailure ||
|
||||
baseRecord.status === "success" ||
|
||||
businessAbility.status === "success" ||
|
||||
businessAbility?.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 {
|
||||
if (kind === "audience") {
|
||||
return "观众画像";
|
||||
@@ -1358,6 +1387,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
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[] {
|
||||
return listAudienceProfileSelectableFieldGroups(marketListHeaders).flatMap(
|
||||
(group) => group.headers
|
||||
@@ -1408,39 +1469,44 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
async function hydrateExportRecords(
|
||||
records: MarketRecord[],
|
||||
options: {
|
||||
includeSpreadMetrics?: boolean;
|
||||
includeBackendMetrics?: boolean;
|
||||
includeRates?: boolean;
|
||||
spreadInfoConfigs?: SpreadInfoConfig[];
|
||||
} = {}
|
||||
): Promise<MarketRecord[]> {
|
||||
for (const record of records) {
|
||||
resultStore.upsertMarketRow(record);
|
||||
const existingRecord = resultStore.getRecord(record.authorId);
|
||||
if (existingRecord?.status === "success" && existingRecord.rates) {
|
||||
continue;
|
||||
}
|
||||
records.forEach((record) => resultStore.upsertMarketRow(record));
|
||||
|
||||
if (record.hasDirectRatesSource) {
|
||||
const directRates = record.rates ?? {};
|
||||
const hasAllRates =
|
||||
Boolean(directRates.singleVideoAfterSearchRate) &&
|
||||
Boolean(directRates.personalVideoAfterSearchRate);
|
||||
|
||||
resultStore.setAuthorSuccess(record.authorId, directRates);
|
||||
if (hasAllRates) {
|
||||
if (options.includeRates ?? true) {
|
||||
for (const record of records) {
|
||||
const existingRecord = resultStore.getRecord(record.authorId);
|
||||
if (existingRecord?.status === "success" && existingRecord.rates) {
|
||||
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 (record.hasDirectRatesSource) {
|
||||
const directRates = record.rates ?? {};
|
||||
const hasAllRates =
|
||||
Boolean(directRates.singleVideoAfterSearchRate) &&
|
||||
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 existingRecord = resultStore.getRecord(record.authorId);
|
||||
return !(
|
||||
@@ -1476,25 +1542,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeSpreadMetrics) {
|
||||
await hydrateSpreadMetricsForRecords(records);
|
||||
if (options.spreadInfoConfigs?.length) {
|
||||
await hydrateSpreadMetricsForRecords(records, options.spreadInfoConfigs);
|
||||
}
|
||||
|
||||
return records.map((record) => toMarketRecord(record));
|
||||
}
|
||||
|
||||
async function hydrateSpreadMetricsForRecords(
|
||||
records: MarketRecord[]
|
||||
records: MarketRecord[],
|
||||
selectedConfigs: SpreadInfoConfig[]
|
||||
): 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) {
|
||||
if (!spreadAuthorId) {
|
||||
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) {
|
||||
resultStore.setSpreadMetricsSuccess(record.authorId, spreadMetrics);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
onExport(): Promise<void> | void;
|
||||
onExportAudienceProfile(): Promise<void> | void;
|
||||
onExportAudienceProfileByIds(): Promise<void> | void;
|
||||
onConfigureAudienceProfileFields(): Promise<void> | void;
|
||||
@@ -65,7 +64,6 @@ export interface PluginToolbarDom {
|
||||
audienceProfileExportButton: HTMLButtonElement;
|
||||
audienceProfileFieldButton: HTMLButtonElement;
|
||||
batchSubmitButton: HTMLButtonElement;
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
exportStatusText: HTMLElement;
|
||||
@@ -110,7 +108,7 @@ export function ensurePluginToolbar(
|
||||
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||
) &&
|
||||
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"]')
|
||||
) {
|
||||
@@ -145,12 +143,6 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput.placeholder = "页数";
|
||||
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");
|
||||
audienceProfileExportButton.type = "button";
|
||||
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
|
||||
@@ -204,15 +196,15 @@ export function ensurePluginToolbar(
|
||||
const dataGroup = document.createElement("div");
|
||||
dataGroup.dataset.pluginToolbarGroup = "data";
|
||||
applyToolbarGroupStyles(dataGroup);
|
||||
const csvExportGroup = createToolbarActionGroup(document, "csv-export", [
|
||||
exportButton,
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput
|
||||
]);
|
||||
const selectedAudienceExportGroup = createToolbarActionGroup(
|
||||
document,
|
||||
"selected-audience-export",
|
||||
[audienceProfileExportButton, audienceProfileFieldButton]
|
||||
[
|
||||
audienceProfileExportButton,
|
||||
audienceProfileFieldButton,
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput
|
||||
]
|
||||
);
|
||||
const idExportGroup = createToolbarActionGroup(document, "id-export", [
|
||||
audienceProfileByIdExportButton
|
||||
@@ -221,8 +213,6 @@ export function ensurePluginToolbar(
|
||||
batchSubmitButton
|
||||
]);
|
||||
dataGroup.append(
|
||||
csvExportGroup,
|
||||
createToolbarActionDivider(document),
|
||||
selectedAudienceExportGroup,
|
||||
createToolbarActionDivider(document),
|
||||
idExportGroup,
|
||||
@@ -253,16 +243,12 @@ export function ensurePluginToolbar(
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
spreadMetricRules
|
||||
});
|
||||
ensureToolbarMounted(root, document);
|
||||
|
||||
exportButton.addEventListener("click", () => {
|
||||
void handlers.onExport();
|
||||
});
|
||||
audienceProfileExportButton.addEventListener("click", () => {
|
||||
void handlers.onExportAudienceProfile();
|
||||
});
|
||||
@@ -281,7 +267,6 @@ export function ensurePluginToolbar(
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
@@ -352,7 +337,6 @@ function createToolbarActionGroup(
|
||||
document: Document,
|
||||
groupName:
|
||||
| "batch-submit"
|
||||
| "csv-export"
|
||||
| "id-export"
|
||||
| "selected-audience-export",
|
||||
controls: HTMLElement[]
|
||||
@@ -660,9 +644,6 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
batchSubmitButton: root.querySelector(
|
||||
'[data-plugin-batch-submit="button"]'
|
||||
) as HTMLButtonElement,
|
||||
exportButton: root.querySelector(
|
||||
'[data-plugin-export="button"]'
|
||||
) as HTMLButtonElement,
|
||||
exportCustomPagesInput: root.querySelector(
|
||||
'[data-plugin-export-custom-pages="input"]'
|
||||
) as HTMLInputElement,
|
||||
@@ -780,7 +761,6 @@ export function setToolbarBusyState(
|
||||
toolbar.audienceProfileFieldButton,
|
||||
toolbar.audienceProfileByIdExportButton,
|
||||
toolbar.audienceProfileExportButton,
|
||||
toolbar.exportButton,
|
||||
toolbar.exportRangeSelect,
|
||||
toolbar.exportCustomPagesInput,
|
||||
toolbar.metricCatalog.addButton,
|
||||
@@ -1362,7 +1342,6 @@ function applyNativeControlStyles(
|
||||
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||
audienceProfileFieldButton: HTMLButtonElement;
|
||||
batchSubmitButton: HTMLButtonElement;
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
spreadMetricRules: SpreadMetricRuleDomMap;
|
||||
@@ -1377,7 +1356,6 @@ function applyNativeControlStyles(
|
||||
findNativeActionButton(document, "导出");
|
||||
|
||||
if (nativeButton) {
|
||||
controls.exportButton.className = nativeButton.className;
|
||||
controls.audienceProfileExportButton.className = nativeButton.className;
|
||||
controls.audienceProfileByIdExportButton.className = nativeButton.className;
|
||||
controls.audienceProfileFieldButton.className = nativeButton.className;
|
||||
@@ -1385,7 +1363,6 @@ function applyNativeControlStyles(
|
||||
}
|
||||
|
||||
const secondaryButtons = [
|
||||
controls.exportButton,
|
||||
controls.audienceProfileExportButton,
|
||||
controls.audienceProfileByIdExportButton,
|
||||
controls.audienceProfileFieldButton
|
||||
@@ -1515,7 +1492,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
||||
const style = document.createElement("style");
|
||||
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
||||
style.textContent = `
|
||||
[data-plugin-export="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-audience-profile-fields="button"]:hover:not(:disabled) {
|
||||
@@ -1528,7 +1504,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
||||
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-by-id="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);
|
||||
}
|
||||
|
||||
[data-plugin-export="button"]:focus-visible,
|
||||
[data-plugin-export-audience-profile="button"]:focus-visible,
|
||||
[data-plugin-export-audience-profile-by-id="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;
|
||||
}
|
||||
|
||||
[data-plugin-export="button"]:disabled,
|
||||
[data-plugin-export-audience-profile="button"]:disabled,
|
||||
[data-plugin-export-audience-profile-by-id="button"]:disabled,
|
||||
[data-plugin-audience-profile-fields="button"]:disabled {
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
@@ -90,10 +92,13 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
|
||||
return {
|
||||
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
|
||||
async loadAuthorSpreadMetrics(
|
||||
authorId: string,
|
||||
selectedConfigs: SpreadInfoConfig[] = configs
|
||||
): Promise<SpreadInfoMetrics> {
|
||||
const metrics: SpreadInfoMetrics = {};
|
||||
|
||||
for (const config of configs) {
|
||||
for (const config of selectedConfigs) {
|
||||
const mappedResponse = await loadSpreadInfoFromUrl(
|
||||
buildSpreadInfoUrl(authorId, config, baseUrl)
|
||||
);
|
||||
@@ -127,12 +132,22 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
throw new Error(
|
||||
`星图传播数据接口请求失败: HTTP ${response.status ?? "unknown"}${
|
||||
response.statusText ? `: ${response.statusText}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
return mapSpreadInfoResponse(await response.json());
|
||||
} catch {
|
||||
return {};
|
||||
const payload = await response.json();
|
||||
const rateLimitMessage = readRateLimitMessage(payload);
|
||||
if (rateLimitMessage) {
|
||||
throw new Error(
|
||||
`星图传播数据接口被限流 (status_code=31157): ${rateLimitMessage}`
|
||||
);
|
||||
}
|
||||
|
||||
return mapSpreadInfoResponse(payload);
|
||||
} finally {
|
||||
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(
|
||||
config: SpreadInfoConfig,
|
||||
metrics: MappedSpreadInfoResponse
|
||||
@@ -278,6 +317,26 @@ function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
||||
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 {
|
||||
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", () => {
|
||||
const csv = buildMarketCsv([
|
||||
{
|
||||
|
||||
+301
-1779
File diff suppressed because it is too large
Load Diff
+129
-1
@@ -6,9 +6,11 @@ import {
|
||||
buildSpreadInfoUrl,
|
||||
createSpreadInfoClient,
|
||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||
filterUnloadedSpreadInfoConfigs,
|
||||
matchesSpreadMetricRule,
|
||||
normalizeSpreadInfoConfig,
|
||||
mapSpreadInfoResponse
|
||||
mapSpreadInfoResponse,
|
||||
selectSpreadInfoConfigsForHeaders
|
||||
} from "../src/content/market/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", () => {
|
||||
expect(
|
||||
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", () => {
|
||||
expect(
|
||||
normalizeSpreadInfoConfig({
|
||||
|
||||
Reference in New Issue
Block a user