diff --git a/docs/项目流程说明文档.md b/docs/项目流程说明文档.md index bb39288..e28ff92 100644 --- a/docs/项目流程说明文档.md +++ b/docs/项目流程说明文档.md @@ -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; - 批次提交失败:状态区显示接口错误或通用失败提示; - 已选传播指标未填写合法阈值:状态区提示具体指标,导出或提交不会开始; diff --git a/src/content/market/audience-profile-types.ts b/src/content/market/audience-profile-types.ts index 5b8342b..c61c50a 100644 --- a/src/content/market/audience-profile-types.ts +++ b/src/content/market/audience-profile-types.ts @@ -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; diff --git a/src/content/market/csv-exporter.ts b/src/content/market/csv-exporter.ts index 5399f60..dc869da 100644 --- a/src/content/market/csv-exporter.ts +++ b/src/content/market/csv-exporter.ts @@ -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(); + return columns.filter((column) => { + if (seenHeaders.has(column.header)) { + return false; + } + + seenHeaders.add(column.header); + return true; + }); +} diff --git a/src/content/market/index.ts b/src/content/market/index.ts index f5f7412..41fdd94 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -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; - loadSpreadMetrics?: (spreadAuthorId: string) => Promise>; + loadSpreadMetrics?: ( + spreadAuthorId: string, + configs?: SpreadInfoConfig[] + ) => Promise>; searchBackendMetrics?: (starIds: string[]) => Promise< Array >; @@ -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(); 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 { 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(); 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 { 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 { 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 { @@ -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 { - 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 { 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); } diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index 6a234c1..5eb45f1 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -8,7 +8,6 @@ import type { } from "./types"; export interface PluginToolbarHandlers { - onExport(): Promise | void; onExportAudienceProfile(): Promise | void; onExportAudienceProfileByIds(): Promise | void; onConfigureAudienceProfileFields(): Promise | 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 { diff --git a/src/content/market/spread-info.ts b/src/content/market/spread-info.ts index 17f8ca3..cabc3f8 100644 --- a/src/content/market/spread-info.ts +++ b/src/content/market/spread-info.ts @@ -7,6 +7,8 @@ import type { interface FetchResponseLike { json(): Promise; 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 { + async loadAuthorSpreadMetrics( + authorId: string, + selectedConfigs: SpreadInfoConfig[] = configs + ): Promise { 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 | 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; } diff --git a/tests/csv-exporter.test.ts b/tests/csv-exporter.test.ts index 0f1f390..08fb823 100644 --- a/tests/csv-exporter.test.ts +++ b/tests/csv-exporter.test.ts @@ -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([ { diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 5d380e1..dd82dc5 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -345,10 +345,7 @@ describe("market-content-entry", () => { ) as HTMLInputElement | null )?.hidden ).toBe(true); - expect( - (document.querySelector('[data-plugin-export="button"]') as HTMLButtonElement | null) - ?.hidden - ).toBe(false); + expect(document.querySelector('[data-plugin-export="button"]')).toBeNull(); expect( document.querySelector('[data-plugin-export-audience-profile="button"]') ).not.toBeNull(); @@ -370,9 +367,6 @@ describe("market-content-entry", () => { const audienceProfileFieldButton = document.querySelector( '[data-plugin-audience-profile-fields="button"]' ) as HTMLButtonElement | null; - const exportButton = document.querySelector( - '[data-plugin-export="button"]' - ) as HTMLButtonElement | null; expect(audienceProfileExportButton?.textContent).toBe("导出选中达人数据"); expect(audienceProfileExportButton?.title).toBe( "仅导出已勾选达人,包含内容数据、效果预估、画像等维度" @@ -389,23 +383,19 @@ describe("market-content-entry", () => { expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)"); expect([ - exportButton?.style.backgroundColor, audienceProfileExportButton?.style.backgroundColor, audienceProfileByIdExportButton?.style.backgroundColor, audienceProfileFieldButton?.style.backgroundColor ]).toEqual([ - "rgb(255, 255, 255)", "rgb(255, 255, 255)", "rgb(255, 255, 255)", "rgb(255, 255, 255)" ]); expect([ - exportButton?.style.color, audienceProfileExportButton?.style.color, audienceProfileByIdExportButton?.style.color, audienceProfileFieldButton?.style.color ]).toEqual([ - "rgb(52, 64, 84)", "rgb(52, 64, 84)", "rgb(52, 64, 84)", "rgb(52, 64, 84)" @@ -442,9 +432,6 @@ describe("market-content-entry", () => { const dataGroup = document.querySelector( '[data-plugin-toolbar-group="data"]' ) as HTMLElement | null; - const csvExportGroup = document.querySelector( - '[data-plugin-toolbar-action-group="csv-export"]' - ) as HTMLElement | null; const selectedAudienceExportGroup = document.querySelector( '[data-plugin-toolbar-action-group="selected-audience-export"]' ) as HTMLElement | null; @@ -466,9 +453,6 @@ describe("market-content-entry", () => { const titles = Array.from( document.querySelectorAll("[data-plugin-toolbar-title]") ) as HTMLElement[]; - const exportButton = document.querySelector( - '[data-plugin-export="button"]' - ) as HTMLButtonElement | null; const exportRangeSelect = document.querySelector( '[data-plugin-export-range="select"]' ) as HTMLSelectElement | null; @@ -506,18 +490,17 @@ describe("market-content-entry", () => { expect(panel?.style.overflowX).toBe("visible"); expect(panel?.style.overflowY).toBe("visible"); expect(dataGroup?.parentElement).toBe(primaryRow); - expect(csvExportGroup?.parentElement).toBe(dataGroup); + expect( + document.querySelector('[data-plugin-toolbar-action-group="csv-export"]') + ).toBeNull(); expect(selectedAudienceExportGroup?.parentElement).toBe(dataGroup); expect(idExportGroup?.parentElement).toBe(dataGroup); expect(batchSubmitGroup?.parentElement).toBe(dataGroup); - expect(Array.from(csvExportGroup?.children ?? [])).toEqual([ - exportButton, - exportRangeSelect, - exportCustomPagesInput - ]); expect(Array.from(selectedAudienceExportGroup?.children ?? [])).toEqual([ audienceProfileExportButton, - audienceProfileFieldButton + audienceProfileFieldButton, + exportRangeSelect, + exportCustomPagesInput ]); expect(Array.from(idExportGroup?.children ?? [])).toEqual([ audienceProfileByIdExportButton @@ -559,20 +542,17 @@ describe("market-content-entry", () => { "0.1" ]); expect([ - exportButton?.textContent, audienceProfileExportButton?.textContent, audienceProfileByIdExportButton?.textContent, audienceProfileFieldButton?.textContent, batchSubmitButton?.textContent ]).toEqual([ - "导出CSV", "导出选中达人数据", "按星图ID导出", "选择字段", "提交批次" ]); expect([ - exportButton?.style.backgroundColor, audienceProfileExportButton?.style.backgroundColor, audienceProfileByIdExportButton?.style.backgroundColor, audienceProfileFieldButton?.style.backgroundColor, @@ -581,11 +561,9 @@ describe("market-content-entry", () => { "rgb(255, 255, 255)", "rgb(255, 255, 255)", "rgb(255, 255, 255)", - "rgb(255, 255, 255)", "rgb(127, 29, 45)" ]); expect([ - exportButton?.style.color, audienceProfileExportButton?.style.color, audienceProfileByIdExportButton?.style.color, audienceProfileFieldButton?.style.color, @@ -594,7 +572,6 @@ describe("market-content-entry", () => { "rgb(52, 64, 84)", "rgb(52, 64, 84)", "rgb(52, 64, 84)", - "rgb(52, 64, 84)", "rgb(255, 255, 255)" ]); }); @@ -1814,716 +1791,6 @@ describe("market-content-entry", () => { }); }); - test("export uses the current page ordering without triggering a full scan", async () => { - document.body.innerHTML = buildMarketFixture(); - const resultStore = createMarketResultStore(); - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - resultStore, - window - })); - - await controller.ready; - resultStore.setAuthorSuccess("a", { - singleVideoAfterSearchRate: "0.02% - 0.1%", - personalVideoAfterSearchRate: "0.03% - 0.2%" - }); - resultStore.setAuthorSuccess("b", { - singleVideoAfterSearchRate: "0.5%-1%", - personalVideoAfterSearchRate: "0.01% - 0.1%" - }); - click('[data-market-sort-field="singleVideoAfterSearchRate"]'); - await flush(); - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 80, 50); - - expect(buildCsv).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ authorId: "a" }), - expect.objectContaining({ authorId: "b" }) - ]) - ); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "b", - "a" - ]); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }); - - test("export hydrates spread info with attribute_datas.id before building csv", async () => { - document.body.innerHTML = buildRealMarketFixture([ - { authorId: "a", authorName: "Alpha", price21To60s: "450000" }, - { authorId: "b", authorName: "Beta", price21To60s: "70000" } - ]); - attachMarketListState([ - { - attribute_datas: { - id: "spread-a", - nickname: "Alpha" - }, - star_id: "a" - }, - { - attribute_datas: { - id: "spread-b", - nickname: "Beta" - }, - star_id: "b" - } - ]); - const buildCsv = vi.fn(() => "csv-output"); - const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => ({ - "内容数据-个人视频-近30天-完播率": spreadAuthorId === "spread-a" ? "28.24%" : "18.24%" - })); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - loadSpreadMetrics, - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 80, 50); - - expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-a"); - expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-b"); - expect(buildCsv.mock.calls[0][0]).toEqual([ - expect.objectContaining({ - authorId: "a", - spreadAuthorId: "spread-a", - spreadMetrics: { - "内容数据-个人视频-近30天-完播率": "28.24%" - } - }), - expect.objectContaining({ - authorId: "b", - spreadAuthorId: "spread-b", - spreadMetrics: { - "内容数据-个人视频-近30天-完播率": "18.24%" - } - }) - ]); - }); - - test("export requires independent spread metric configs to all match", async () => { - document.body.innerHTML = buildRealMarketFixture([ - { authorId: "a", authorName: "Alpha", price21To60s: "450000" }, - { authorId: "b", authorName: "Beta", price21To60s: "70000" } - ]); - attachMarketListState([ - { - attribute_datas: { - id: "spread-a", - nickname: "Alpha" - }, - star_id: "a" - }, - { - attribute_datas: { - id: "spread-b", - nickname: "Beta" - }, - star_id: "b" - } - ]); - const buildCsv = vi.fn(() => "csv-output"); - const loadSpreadFilterMetrics = vi.fn(async ( - spreadAuthorId: string, - config: SpreadInfoConfig - ) => { - if (config.type === 2) { - return { finishRate: "35%" }; - } - return { - interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%" - }; - }); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - loadSpreadFilterMetrics, - loadSpreadMetrics: async () => ({}), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - enableSpreadMetric("finishRate"); - enableSpreadMetric("interactionRate"); - setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); - setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); - setSpreadRuleSelect("finishRate", "type", "2"); - setSpreadRuleSelect("finishRate", "onlyAssign", "true"); - setSpreadRuleSelect("interactionRate", "type", "1"); - setSpreadRuleSelect("interactionRate", "range", "3"); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 80, 50); - - expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { - flowType: 0, - onlyAssign: true, - range: 2, - type: 2 - }); - expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { - flowType: 0, - onlyAssign: false, - range: 3, - type: 1 - }); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "a" - ]); - }); - - test("export reuses one spread snapshot when metric configs match", async () => { - document.body.innerHTML = buildRealMarketFixture([ - { authorId: "a", authorName: "Alpha", price21To60s: "450000" } - ]); - attachMarketListState([ - { - attribute_datas: { - id: "spread-a", - nickname: "Alpha" - }, - star_id: "a" - } - ]); - const buildCsv = vi.fn(() => "csv-output"); - const loadSpreadFilterMetrics = vi.fn(async () => ({ - finishRate: "35%", - interactionRate: "6%" - })); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - loadSpreadFilterMetrics, - loadSpreadMetrics: async () => ({}), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - enableSpreadMetric("finishRate"); - enableSpreadMetric("interactionRate"); - setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); - setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 80, 50); - - expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1); - expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { - flowType: 0, - onlyAssign: true, - range: 2, - type: 2 - }); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "a" - ]); - }); - - test("export excludes missing-id and failed spread metric records without aborting", async () => { - document.body.innerHTML = buildRealMarketFixture([ - { authorId: "a", authorName: "Alpha", price21To60s: "450000" }, - { authorId: "b", authorName: "Beta", price21To60s: "70000" } - ]); - attachMarketListState([ - { star_id: "a" }, - { - attribute_datas: { - id: "spread-b", - nickname: "Beta" - }, - star_id: "b" - } - ]); - const buildCsv = vi.fn(() => "csv-output"); - const loadSpreadFilterMetrics = vi.fn(async () => { - throw new Error("request failed"); - }); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - loadSpreadFilterMetrics, - loadSpreadMetrics: async () => ({}), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - enableSpreadMetric("finishRate"); - setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 80, 50); - - expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1); - expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-b", { - flowType: 0, - onlyAssign: true, - range: 2, - type: 2 - }); - expect(buildCsv.mock.calls[0][0]).toEqual([]); - }); - - test( - "default export captures the first 5 pages and keeps non-empty fields when merging duplicates", - async () => { - const pages = [ - [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], - [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], - [{ authorId: "222", authorName: "达人 B", price21To60s: "" }], - [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], - [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], - [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] - ]; - - document.body.innerHTML = buildRealMarketFixture(pages[0]); - const pagination = installAsyncPaginationHarness(pages); - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - window - })); - - await controller.ready; - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 100); - - expect(pagination.getClicks()).toBe(4); - expect(buildCsv).toHaveBeenCalledTimes(1); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "111", - "222", - "333", - "444" - ]); - expect(buildCsv.mock.calls[0][0]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - authorId: "222", - exportFields: expect.objectContaining({ - "21-60s报价": "¥22,000" - }) - }) - ]) - ); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }, - 15000 - ); - - test( - "default export replays captured market requests silently without paging the visible table", - async () => { - const pages = [ - [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], - [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], - [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], - [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], - [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] - ]; - - document.body.innerHTML = buildRealMarketFixture(pages[0]); - const pagination = installAsyncPaginationHarness(pages); - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { - const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; - const pageIndex = Math.max((body.page ?? 1) - 1, 0); - return { - json: async () => ({ - data: { - marketList: buildMarketListResponseRows(pages[pageIndex] ?? []) - } - }), - ok: true - }; - }); - - ( - globalThis as typeof globalThis & { - fetch?: typeof fetchMock; - } - ).fetch = fetchMock; - document.documentElement.setAttribute( - "data-sces-market-request-snapshot", - JSON.stringify({ - body: JSON.stringify({ - page: 1 - }), - method: "POST", - url: "https://xingtu.cn/api/mock-market-search" - }) - ); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - window - })); - - await controller.ready; - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 100); - - expect(pagination.getClicks()).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(5); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "111", - "222", - "333", - "444", - "555" - ]); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }, - 15000 - ); - - test( - "default export falls back to visible pagination when no captured market request is available", - async () => { - const pages = [ - [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], - [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], - [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], - [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], - [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] - ]; - - document.body.innerHTML = buildRealMarketFixture(pages[0]); - const pagination = installAsyncPaginationHarness(pages); - const buildCsv = vi.fn(() => "csv-output"); - - document.documentElement.removeAttribute("data-sces-market-request-snapshot"); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 100); - - expect(pagination.getClicks()).toBe(4); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "111", - "222", - "333", - "444", - "555" - ]); - }, - 15000 - ); - - test( - "default export waits for the next page rows instead of only the pager state", - async () => { - const pages = [ - [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], - [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], - [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], - [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], - [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] - ]; - - document.body.innerHTML = buildRealMarketFixture(pages[0]); - const pagination = installLaggyPaginationHarness(pages, { - renderDelayMs: 250 - }); - const buildCsv = vi.fn(() => "csv-output"); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 100); - - expect(pagination.getClicks()).toBe(4); - expect(buildCsv).toHaveBeenCalledTimes(1); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "111", - "222", - "333", - "444", - "555" - ]); - }, - 15000 - ); - - test( - "export waits for a slow page to finish rendering all rows before continuing", - async () => { - const pages = [ - [ - { authorId: "111", authorName: "达人 A1", price21To60s: "¥11,000" }, - { authorId: "112", authorName: "达人 A2", price21To60s: "¥12,000" }, - { authorId: "113", authorName: "达人 A3", price21To60s: "¥13,000" } - ], - [ - { authorId: "221", authorName: "达人 B1", price21To60s: "¥21,000" }, - { authorId: "222", authorName: "达人 B2", price21To60s: "¥22,000" }, - { authorId: "223", authorName: "达人 B3", price21To60s: "¥23,000" } - ], - [ - { authorId: "331", authorName: "达人 C1", price21To60s: "¥31,000" }, - { authorId: "332", authorName: "达人 C2", price21To60s: "¥32,000" }, - { authorId: "333", authorName: "达人 C3", price21To60s: "¥33,000" } - ] - ]; - - document.body.innerHTML = buildRealMarketFixture(pages[0]); - const pagination = installProgressivePaginationHarness(pages, { - firstRenderCount: 1, - firstRenderDelayMs: 100, - fullRenderDelayMs: 450 - }); - const buildCsv = vi.fn(() => "csv-output"); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "all"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 100); - - expect(pagination.getClicks()).toBe(2); - expect(buildCsv).toHaveBeenCalledTimes(1); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "111", - "112", - "113", - "221", - "222", - "223", - "331", - "332", - "333" - ]); - }, - 15000 - ); - - test( - "exporting all pages disables the native action bar controls during the task and stops at the final page", - async () => { - const pages = [ - [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], - [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], - [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }] - ]; - - document.body.innerHTML = buildRealMarketFixture(pages[0]); - const pagination = installPaginationHarness(pages); - const buildCsv = vi.fn(() => "csv-output"); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "all"); - dispatchChange('[data-plugin-export-range="select"]'); - - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - - expectButtonDisabled('[data-plugin-batch-submit="button"]', true); - expectButtonDisabled('[data-plugin-export="button"]', true); - expectSelectDisabled('[data-plugin-export-range="select"]', true); - expect( - document.querySelector('[data-plugin-export-status="text"]')?.textContent - ).toContain("导出中"); - - await waitForMockCall(buildCsv, 120, 100); - - expect(pagination.getClicks()).toBe(2); - expectButtonDisabled('[data-plugin-batch-submit="button"]', false); - expectButtonDisabled('[data-plugin-export="button"]', false); - expectSelectDisabled('[data-plugin-export-range="select"]', false); - expect(buildCsv).toHaveBeenCalledTimes(1); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual( - expect.arrayContaining(["222", "333"]) - ); - }, - 15000 - ); - - test("custom export range blocks invalid page counts", async () => { - document.body.innerHTML = buildMarketFixture(); - const buildCsv = vi.fn(() => "csv-output"); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "custom"); - dispatchChange('[data-plugin-export-range="select"]'); - setInputValue('[data-plugin-export-custom-pages="input"]', "0"); - - click('[data-plugin-export="button"]'); - await flush(); - - expect(buildCsv).not.toHaveBeenCalled(); - expect( - document.querySelector('[data-plugin-export-status="text"]')?.textContent - ).toContain("有效页数"); - }); - - test("selected export uses only creators selected in the current range", async () => { - document.body.innerHTML = buildRealMarketFixture([ - { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, - { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }, - { authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" } - ]); - const buildCsv = vi.fn(() => "csv-output"); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - clickSelectionCheckboxForAuthor("111"); - clickSelectionCheckboxForAuthor("333"); - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 40, 50); - - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "111", - "333" - ]); - }); - test("audience profile export requires selected creators", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } @@ -2642,6 +1909,213 @@ describe("market-content-entry", () => { ); }); + test("audience profile export requests only the selected data sources", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + window.localStorage.setItem( + "sces:audience-profile:selectedHeaders", + JSON.stringify([ + "效果预估-20-60s视频-预期CPM", + "观众画像-女性占比" + ]) + ); + const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); + const loadAudienceProfile = vi.fn(async () => ({ + gender: [{ label: "女性", value: "60%" }], + status: "success" as const + })); + const loadBusinessAbility = vi.fn(async () => ({ + estimates: {}, + status: "success" as const + })); + const loadAuthorMetrics = vi.fn(async () => ({ + reason: "request-failed" as const, + success: false as const + })); + const loadSpreadMetrics = vi.fn(async () => ({})); + const searchBackendMetrics = vi.fn(async () => []); + const onCsvReady = vi.fn(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildAudienceProfileCsv, + document, + loadAudienceProfile, + loadAuthorMetrics, + loadBusinessAbility, + loadSpreadMetrics, + onCsvReady, + searchBackendMetrics, + window + })); + + await controller.ready; + clickSelectionCheckboxForAuthor("111"); + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + + click('[data-plugin-export-audience-profile="button"]'); + await waitForMockCall(buildAudienceProfileCsv, 40, 50); + + expect(loadAudienceProfile).toHaveBeenCalledTimes(1); + expect(loadAudienceProfile).toHaveBeenCalledWith( + expect.objectContaining({ authorId: "111" }), + { linkType: 5, source: "audienceDistribution" } + ); + expect(loadBusinessAbility).toHaveBeenCalledTimes(1); + expect(loadSpreadMetrics).not.toHaveBeenCalled(); + expect(buildAudienceProfileCsv).toHaveBeenCalled(); + expect(buildAudienceProfileCsv.mock.calls[0][0][0]).toEqual( + expect.objectContaining({ + profiles: { + audience: expect.objectContaining({ status: "success" }), + fans: { status: "skipped" }, + longtimeFans: { status: "skipped" } + } + }) + ); + }); + + test("audience profile export requests only selected content-data parameter configs", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + attachMarketListState([ + { + attribute_datas: { + id: "spread-111", + nickname: "达人 A" + }, + star_id: "111" + } + ]); + window.localStorage.setItem( + "sces:audience-profile:selectedHeaders", + JSON.stringify([ + "内容数据-个人视频-近30天-完播率", + "内容数据-个人视频-近30天-作品平均评论数", + "内容数据-只看指派-排除营销流量-星图视频-近90天-互动率" + ]) + ); + const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); + const loadAudienceProfile = vi.fn(); + const loadAuthorMetrics = vi.fn(async () => ({ + reason: "request-failed" as const, + success: false as const + })); + const loadBusinessAbility = vi.fn(); + const loadSpreadMetrics = vi.fn(async () => ({})); + const searchBackendMetrics = vi.fn(async () => []); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildAudienceProfileCsv, + document, + loadAudienceProfile, + loadAuthorMetrics, + loadBusinessAbility, + loadSpreadMetrics, + searchBackendMetrics, + window + })); + + await controller.ready; + loadAuthorMetrics.mockClear(); + searchBackendMetrics.mockClear(); + clickSelectionCheckboxForAuthor("111"); + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + + click('[data-plugin-export-audience-profile="button"]'); + await waitForMockCall(buildAudienceProfileCsv, 40, 50); + + expect(loadSpreadMetrics).toHaveBeenCalledTimes(1); + expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-111", [ + { + flowType: 0, + onlyAssign: false, + range: 2, + type: 1 + }, + { + flowType: 1, + onlyAssign: true, + range: 3, + type: 2 + } + ]); + expect(loadAudienceProfile).not.toHaveBeenCalled(); + expect(loadBusinessAbility).not.toHaveBeenCalled(); + }); + + test("audience profile export downloads effect estimates and reports profile failures", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + window.localStorage.setItem( + "sces:audience-profile:selectedHeaders", + JSON.stringify([ + "效果预估-20-60s视频-预期CPM", + "观众画像-女性占比", + "粉丝画像-女性占比", + "铁粉画像-18-23占比" + ]) + ); + const loadAudienceProfile = vi.fn(async () => ({ + failureReason: "timeout", + status: "failed" as const + })); + const loadBusinessAbility = vi.fn(async () => ({ + estimates: { + twentyToSixty: { + expectedCpe: "3.7", + expectedCpm: "119.6", + expectedPlay: "20.9w", + hotRate: "缺失" + } + }, + status: "success" as const + })); + const onCsvReady = vi.fn(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAudienceProfile, + loadAuthorMetrics: async () => ({ + reason: "request-failed", + success: false + }), + loadBusinessAbility, + onCsvReady, + window + })); + + await controller.ready; + clickSelectionCheckboxForAuthor("111"); + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + + click('[data-plugin-export-audience-profile="button"]'); + await waitForMockCall(onCsvReady, 40, 50); + + expect(loadAudienceProfile).toHaveBeenCalledTimes(3); + const csv = onCsvReady.mock.calls[0][0]; + expect(readCsvCell(csv, "导出状态")).toBe("部分成功"); + expect(readCsvCell(csv, "失败原因")).toBe( + "观众画像:timeout; 粉丝画像:timeout; 铁粉画像:timeout" + ); + expect(readCsvCell(csv, "观众画像-女性占比")).toBe(""); + expect(readCsvCell(csv, "粉丝画像-女性占比")).toBe(""); + expect(readCsvCell(csv, "铁粉画像-18-23占比")).toBe(""); + expect(readCsvCell(csv, "效果预估-20-60s视频-预期CPM")).toBe("119.6"); + expect(onCsvReady).toHaveBeenCalledWith( + expect.stringContaining("观众画像:timeout"), + expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/) + ); + }); + test("audience profile export by id loads pasted creators without page selection", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } @@ -2780,6 +2254,73 @@ describe("market-content-entry", () => { ); }); + test("audience profile export by id skips every unselected data source", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + window.localStorage.setItem( + "sces:audience-profile:selectedHeaders", + JSON.stringify(["效果预估-20-60s视频-预期CPM"]) + ); + const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); + const loadAuthorBaseInfo = vi.fn(async (authorId: string) => ({ + authorId, + authorName: "达人 A", + status: "success" as const + })); + const loadAudienceProfile = vi.fn(async () => ({ + status: "success" as const + })); + const loadAuthorMetrics = vi.fn(async () => ({ + reason: "request-failed" as const, + success: false as const + })); + const loadBusinessAbility = vi.fn(async () => ({ + estimates: {}, + status: "success" as const + })); + const loadSpreadMetrics = vi.fn(async () => ({})); + const searchBackendMetrics = vi.fn(async () => []); + const onCsvReady = vi.fn(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildAudienceProfileCsv, + document, + loadAudienceProfile, + loadAuthorBaseInfo, + loadAuthorMetrics, + loadBusinessAbility, + loadSpreadMetrics, + onCsvReady, + promptAuthorIds: () => "6866044569306267651", + searchBackendMetrics, + window + })); + + await controller.ready; + loadAuthorMetrics.mockClear(); + searchBackendMetrics.mockClear(); + click('[data-plugin-export-audience-profile-by-id="button"]'); + await waitForMockCall(buildAudienceProfileCsv, 40, 50); + + expect(loadAuthorBaseInfo).toHaveBeenCalledTimes(1); + expect(loadBusinessAbility).toHaveBeenCalledTimes(1); + expect(loadAudienceProfile).not.toHaveBeenCalled(); + expect(loadAuthorMetrics).not.toHaveBeenCalled(); + expect(loadSpreadMetrics).not.toHaveBeenCalled(); + expect(searchBackendMetrics).not.toHaveBeenCalled(); + expect(buildAudienceProfileCsv.mock.calls[0][0][0]).toEqual( + expect.objectContaining({ + profiles: { + audience: { status: "skipped" }, + fans: { status: "skipped" }, + longtimeFans: { status: "skipped" } + } + }) + ); + }); + test("audience profile export uses persisted selected csv fields", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } @@ -2867,162 +2408,6 @@ describe("market-content-entry", () => { ).toContain("字段已保存"); }); - test( - "selected export keeps a generic loading status while exporting the default paged range", - async () => { - const pages = [ - [ - { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, - { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } - ], - [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], - [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], - [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }], - [{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }] - ]; - const secondPageDeferred = createDeferred<{ - json(): Promise; - ok: boolean; - }>(); - - document.body.innerHTML = buildRealMarketFixture(pages[0]); - const buildCsv = vi.fn(() => "csv-output"); - const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { - const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; - const pageNumber = body.page ?? 1; - const response = { - json: async () => ({ - data: { - marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []), - totalPages: 5 - } - }), - ok: true - }; - - if (pageNumber === 2) { - return secondPageDeferred.promise; - } - - return response; - }); - - ( - globalThis as typeof globalThis & { - fetch?: typeof fetchMock; - } - ).fetch = fetchMock; - document.documentElement.setAttribute( - "data-sces-market-request-snapshot", - JSON.stringify({ - body: JSON.stringify({ - page: 1 - }), - method: "POST", - url: "https://xingtu.cn/api/mock-market-search" - }) - ); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - clickSelectionCheckboxForAuthor("111"); - - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - for (let attempt = 0; attempt < 40; attempt += 1) { - if ( - fetchMock.mock.calls.some(([, init]) => { - const body = JSON.parse( - String((init as RequestInit | undefined)?.body ?? "{}") - ) as { page?: number }; - return body.page === 2; - }) - ) { - break; - } - - await new Promise((resolve) => setTimeout(resolve, 50)); - await Promise.resolve(); - } - - expect( - document.querySelector('[data-plugin-export-status="text"]')?.textContent - ).toBe("导出中..."); - - secondPageDeferred.resolve({ - json: async () => ({ - data: { - marketList: buildMarketListResponseRows(pages[1]), - totalPages: 5 - } - }), - ok: true - }); - - await waitForMockCall(buildCsv, 120, 50); - - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "111" - ]); - }, - 15000 - ); - - test("selected export falls back to all creators in the current range when no selection matches", async () => { - const pages = [ - [ - { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, - { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } - ], - [ - { authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }, - { authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" } - ] - ]; - document.body.innerHTML = buildRealMarketFixture(pages[0]); - installAsyncPaginationHarness(pages); - const buildCsv = vi.fn(() => "csv-output"); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - clickSelectionCheckboxForAuthor("111"); - click('[data-testid="next-page"]'); - await flushWithTimers(); - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 40, 50); - - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "333", - "444" - ]); - }); - test("prompts for a batch name before submitting the current range", async () => { document.body.innerHTML = buildMarketFixture(); const promptBatchName = vi.fn(() => "618达人筛选第一批"); @@ -3757,753 +3142,6 @@ describe("market-content-entry", () => { expect(submitBatch).not.toHaveBeenCalled(); }); - test("export only includes records that are present on the current page", async () => { - document.body.innerHTML = buildMarketFixture(); - const resultStore = createMarketResultStore(); - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - resultStore, - window - })); - - await controller.ready; - resultStore.setAuthorSuccess("a", { - singleVideoAfterSearchRate: "0.02% - 0.1%", - personalVideoAfterSearchRate: "0.03% - 0.2%" - }); - resultStore.setAuthorSuccess("b", { - singleVideoAfterSearchRate: "0.5%-1%", - personalVideoAfterSearchRate: "0.01% - 0.1%" - }); - resultStore.upsertMarketRow({ - authorId: "c", - authorName: "Gamma" - }); - resultStore.setAuthorSuccess("c", { - singleVideoAfterSearchRate: "9% - 10%", - personalVideoAfterSearchRate: "8% - 9%" - }); - - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 40, 50); - - expect(buildCsv).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ authorId: "a" }), - expect.objectContaining({ authorId: "b" }) - ]) - ); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).not.toContain( - "c" - ); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }); - - test("export prefers fresh current-page fields over stale store export fields", async () => { - document.body.innerHTML = buildMarketFixture(); - const resultStore = createMarketResultStore(); - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - resultStore, - window - })); - - await controller.ready; - resultStore.upsertMarketRow({ - authorId: "a", - authorName: "Old Alpha", - exportFields: { - 达人: "Old Alpha" - } - }); - resultStore.setAuthorSuccess("a", { - singleVideoAfterSearchRate: "0.02% - 0.1%", - personalVideoAfterSearchRate: "0.03% - 0.2%" - }); - - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 40, 50); - - expect(buildCsv.mock.calls[0][0][0]).toEqual( - expect.objectContaining({ - authorId: "a", - authorName: "Alpha", - exportFields: { - "21-60s报价": "450000", - 达人: "Alpha" - } - }) - ); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }); - - test("export harvests lazy current-page fields before building csv", async () => { - const rows = [ - { - authorId: "111", - authorName: "达人 A", - price21To60s: "¥450,000" - }, - { - authorId: "222", - authorName: "达人 B", - price21To60s: "¥20,000" - }, - { - authorId: "333", - authorName: "达人 C", - price21To60s: "¥30,000" - }, - { - authorId: "444", - authorName: "达人 D", - price21To60s: "¥40,000" - } - ]; - - document.body.innerHTML = ` -
- ${buildRealMarketFixture(rows)} -
- `; - installLazyFieldHydrationHarness({ - hiddenRowIndexes: [2, 3], - scrollContainer: document.querySelector( - '[data-testid="market-scroll-shell"]' - ) as HTMLElement - }); - - const resultStore = createMarketResultStore(); - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - resultStore, - window - })); - - await controller.ready; - rows.forEach((row, index) => { - resultStore.setAuthorSuccess(row.authorId, { - personalVideoAfterSearchRate: `0.0${index + 1}%`, - singleVideoAfterSearchRate: `0.1${index + 1}%` - }); - }); - - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 50); - - expect(buildCsv.mock.calls[0][0]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - authorId: "333", - exportFields: expect.objectContaining({ - "21-60s报价": "¥30,000", - 代表视频: "代表视频达人 C" - }) - }), - expect.objectContaining({ - authorId: "444", - exportFields: expect.objectContaining({ - "21-60s报价": "¥40,000", - 代表视频: "代表视频达人 D" - }) - }) - ]) - ); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }); - - test("export harvests lazy current-page fields from the effective scroll container", async () => { - const rows = [ - { - authorId: "111", - authorName: "达人 A", - price21To60s: "¥450,000" - }, - { - authorId: "222", - authorName: "达人 B", - price21To60s: "¥20,000" - }, - { - authorId: "333", - authorName: "达人 C", - price21To60s: "¥30,000" - }, - { - authorId: "444", - authorName: "达人 D", - price21To60s: "¥40,000" - } - ]; - - document.body.innerHTML = ` -
-
- ${buildRealMarketFixture(rows)} -
-
- `; - const outerScrollContainer = document.querySelector( - '[data-testid="market-outer-scroll-shell"]' - ) as HTMLElement; - const innerScrollContainer = document.querySelector( - '[data-testid="market-inner-scroll-shell"]' - ) as HTMLElement; - installLazyFieldHydrationHarness({ - hiddenRowIndexes: [2, 3], - scrollContainer: outerScrollContainer - }); - - let innerScrollTop = 0; - Object.defineProperty(innerScrollContainer, "clientHeight", { - configurable: true, - value: 120 - }); - Object.defineProperty(innerScrollContainer, "scrollHeight", { - configurable: true, - value: 240 - }); - Object.defineProperty(innerScrollContainer, "scrollTop", { - configurable: true, - get() { - return innerScrollTop; - }, - set(value: number) { - innerScrollTop = value; - } - }); - - const resultStore = createMarketResultStore(); - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - resultStore, - window - })); - - await controller.ready; - rows.forEach((row, index) => { - resultStore.setAuthorSuccess(row.authorId, { - personalVideoAfterSearchRate: `0.0${index + 1}%`, - singleVideoAfterSearchRate: `0.1${index + 1}%` - }); - }); - - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 50); - - expect(buildCsv.mock.calls[0][0]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - authorId: "333", - exportFields: expect.objectContaining({ - "21-60s报价": "¥30,000", - 代表视频: "代表视频达人 C" - }) - }), - expect.objectContaining({ - authorId: "444", - exportFields: expect.objectContaining({ - "21-60s报价": "¥40,000", - 代表视频: "代表视频达人 D" - }) - }) - ]) - ); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }); - - test("export reloads backend metrics for rows discovered during scroll harvest", async () => { - const rows = [ - { - authorId: "111", - authorName: "达人 A", - price21To60s: "¥450,000" - }, - { - authorId: "222", - authorName: "达人 B", - price21To60s: "¥20,000" - }, - { - authorId: "333", - authorName: "达人 C", - price21To60s: "¥30,000" - }, - { - authorId: "444", - authorName: "达人 D", - price21To60s: "¥40,000" - } - ]; - - document.body.innerHTML = ` -
- ${buildRealMarketFixture(rows)} -
- `; - installLazyFieldHydrationHarness({ - hiddenRowIndexes: [2, 3], - hideAuthorIdentity: true, - scrollContainer: document.querySelector( - '[data-testid="market-scroll-shell"]' - ) as HTMLElement - }); - - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - const searchBackendMetrics = vi.fn(async (starIds: string[]) => - starIds.map((starId) => ({ - a3IncreaseCount: `${starId}-a3`, - afterViewSearchCount: `${starId}-count`, - afterViewSearchRate: `${starId}-rate`, - cpSearch: `${starId}-cp-search`, - cpa3: `${starId}-cpa3`, - newA3Rate: `${starId}-new-a3`, - starId - })) - ); - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - searchBackendMetrics, - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 50); - - expect(buildCsv.mock.calls[0][0]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - authorId: "333", - backendMetrics: expect.objectContaining({ - afterViewSearchRate: "333-rate" - }) - }), - expect.objectContaining({ - authorId: "444", - backendMetrics: expect.objectContaining({ - afterViewSearchRate: "444-rate" - }) - }) - ]) - ); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }); - - test("export excludes rows whose author name is still empty", async () => { - document.body.innerHTML = buildMarketFixture(); - const blankRow = document.querySelector('[data-market-row=\"b\"]'); - if (!(blankRow instanceof HTMLElement)) { - throw new Error("Missing blank-row fixture"); - } - const authorNameCell = blankRow.querySelector('[data-market-field=\"authorName\"]'); - if (!(authorNameCell instanceof HTMLElement)) { - throw new Error("Missing author name cell"); - } - authorNameCell.textContent = ""; - - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 50); - - expect(buildCsv.mock.calls[0][0]).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - authorId: "b" - }) - ]) - ); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }); - - test( - "export waits for delayed lazy field hydration before reading current-page rows", - async () => { - const rows = [ - { - authorId: "111", - authorName: "达人 A", - price21To60s: "¥450,000" - }, - { - authorId: "222", - authorName: "达人 B", - price21To60s: "¥20,000" - }, - { - authorId: "333", - authorName: "达人 C", - price21To60s: "¥30,000" - }, - { - authorId: "444", - authorName: "达人 D", - price21To60s: "¥40,000" - } - ]; - - document.body.innerHTML = ` -
- ${buildRealMarketFixture(rows)} -
- `; - installLazyFieldHydrationHarness({ - hiddenRowIndexes: [2, 3], - hydrateDelayMs: 350, - scrollContainer: document.querySelector( - '[data-testid="market-scroll-shell"]' - ) as HTMLElement - }); - - const resultStore = createMarketResultStore(); - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - resultStore, - window - })); - - await controller.ready; - rows.forEach((row, index) => { - resultStore.setAuthorSuccess(row.authorId, { - personalVideoAfterSearchRate: `0.0${index + 1}%`, - singleVideoAfterSearchRate: `0.1${index + 1}%` - }); - }); - - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 50); - - expect(buildCsv).toHaveBeenCalledTimes(1); - expect(buildCsv.mock.calls[0][0]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - authorId: "333", - exportFields: expect.objectContaining({ - "21-60s报价": "¥30,000", - 代表视频: "代表视频达人 C" - }) - }), - expect.objectContaining({ - authorId: "444", - exportFields: expect.objectContaining({ - "21-60s报价": "¥40,000", - 代表视频: "代表视频达人 D" - }) - }) - ]) - ); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }, - 15000 - ); - - test( - "export waits for delayed rich field hydration before reading current-page rows", - async () => { - const rows = [ - { - authorId: "111", - authorName: "达人 A", - price21To60s: "¥11,000" - }, - { - authorId: "222", - authorName: "达人 B", - price21To60s: "¥22,000" - }, - { - authorId: "333", - authorName: "达人 C", - price21To60s: "¥33,000" - }, - { - authorId: "444", - authorName: "达人 D", - price21To60s: "¥44,000" - } - ]; - - document.body.innerHTML = ` -
- ${buildRichExportMarketFixture(rows)} -
- `; - installRichLazyFieldHydrationHarness({ - hiddenRowIndexes: [2, 3], - hydrateDelayMs: 350, - scrollContainer: document.querySelector( - '[data-testid="market-scroll-shell"]' - ) as HTMLElement - }); - - const buildCsv = vi.fn(() => "csv-output"); - const onCsvReady = vi.fn(); - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady, - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 120, 50); - - expect(buildCsv).toHaveBeenCalledTimes(1); - expect(buildCsv.mock.calls[0][0]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - authorId: "333", - exportFields: expect.objectContaining({ - "21-60s报价": "¥33,000", - 互动率: "7.3%", - 代表视频: "代表视频达人 C", - 内容主题: "内容主题达人 C", - 完播率: "26.3%", - 爆文率: "10%", - 粉丝数: "33.3w", - 达人信息: "达人 C", - 达人类型: "剧情", - 连接用户数: "300w", - 预期CPM: "23.3", - 预期播放量: "63.3w" - }) - }), - expect.objectContaining({ - authorId: "444", - exportFields: expect.objectContaining({ - "21-60s报价": "¥44,000", - 互动率: "7.4%", - 代表视频: "代表视频达人 D", - 内容主题: "内容主题达人 D", - 完播率: "26.4%", - 爆文率: "11%", - 粉丝数: "44.4w", - 达人信息: "达人 D", - 达人类型: "测评", - 连接用户数: "400w", - 预期CPM: "24.4", - 预期播放量: "64.4w" - }) - }) - ]) - ); - expect(onCsvReady).toHaveBeenCalledWith("csv-output"); - }, - 15000 - ); - - test( - "default export harvests lazy fields and backend metrics across the first 5 pages", - async () => { - const pages = [ - [ - { authorId: "111", authorName: "达人 A1", price21To60s: "¥11,000" }, - { authorId: "112", authorName: "达人 A2", price21To60s: "¥12,000" } - ], - [ - { authorId: "221", authorName: "达人 B1", price21To60s: "¥21,000" }, - { authorId: "222", authorName: "达人 B2", price21To60s: "¥22,000" } - ], - [ - { authorId: "331", authorName: "达人 C1", price21To60s: "¥31,000" }, - { authorId: "332", authorName: "达人 C2", price21To60s: "¥32,000" } - ], - [ - { authorId: "441", authorName: "达人 D1", price21To60s: "¥41,000" }, - { authorId: "442", authorName: "达人 D2", price21To60s: "¥42,000" } - ], - [ - { authorId: "551", authorName: "达人 E1", price21To60s: "¥51,000" }, - { authorId: "552", authorName: "达人 E2", price21To60s: "¥52,000" } - ] - ]; - - document.body.innerHTML = ` -
- ${buildRealMarketFixture(pages[0])} -
- `; - const pagination = installAsyncPaginationHarness(pages); - installPagedLazyFieldHydrationHarness({ - hiddenRowIndexes: [1], - hideAuthorIdentity: true, - scrollContainer: document.querySelector( - '[data-testid="market-scroll-shell"]' - ) as HTMLElement - }); - - const buildCsv = vi.fn(() => "csv-output"); - const searchBackendMetrics = vi.fn(async (starIds: string[]) => - starIds.map((starId) => ({ - a3IncreaseCount: `${starId}-a3`, - afterViewSearchCount: `${starId}-count`, - afterViewSearchRate: `${starId}-rate`, - cpSearch: `${starId}-cp-search`, - cpa3: `${starId}-cpa3`, - newA3Rate: `${starId}-new-a3`, - starId - })) - ); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady: vi.fn(), - searchBackendMetrics, - window - })); - - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "first-5"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 160, 100); - - expect(pagination.getClicks()).toBe(4); - expect(buildCsv).toHaveBeenCalledTimes(1); - expect( - buildCsv.mock.calls[0][0].map((record) => record.authorId).sort() - ).toEqual([ - "111", - "112", - "221", - "222", - "331", - "332", - "441", - "442", - "551", - "552" - ]); - expect(buildCsv.mock.calls[0][0]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - authorId: "112", - backendMetrics: expect.objectContaining({ - afterViewSearchRate: "112-rate" - }), - exportFields: expect.objectContaining({ - "21-60s报价": "¥12,000", - "达人信息": "达人 A2" - }) - }), - expect.objectContaining({ - authorId: "552", - backendMetrics: expect.objectContaining({ - afterViewSearchRate: "552-rate" - }), - exportFields: expect.objectContaining({ - "21-60s报价": "¥52,000", - "达人信息": "达人 E2" - }) - }) - ]) - ); - }, - 30_000 - ); - test("rehydrates rows when the market list DOM changes", async () => { document.body.innerHTML = buildMarketFixture(); const observer = createMutationObserverFactory(); @@ -4866,7 +3504,7 @@ describe("market-content-entry", () => { ); }); - test("shows a favorite mutation error without disabling CSV export", async () => { + test("shows a favorite mutation error without disabling selected export", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); @@ -4874,11 +3512,8 @@ describe("market-content-entry", () => { repository.createFolder = async () => { throw new Error(); }; - const buildCsv = vi.fn(() => "csv-output"); - const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ - buildCsv, document, favoritesRepository: repository, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), @@ -4896,16 +3531,10 @@ describe("market-content-entry", () => { "收藏夹操作失败,请稍后重试" ); - const exportButton = document.querySelector( - '[data-plugin-export="button"]' + const selectedExportButton = document.querySelector( + '[data-plugin-export-audience-profile="button"]' ) as HTMLButtonElement; - expect(exportButton.disabled).toBe(false); - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 40, 50); - expect(buildCsv).toHaveBeenCalledWith(expect.any(Array)); + expect(selectedExportButton.disabled).toBe(false); }); test("keeps the market toolbar available when favorites storage cannot be read", async () => { @@ -4929,7 +3558,10 @@ describe("market-content-entry", () => { expect( document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent ).toBe("收藏夹暂不可用"); - expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull(); + expect(document.querySelector('[data-plugin-export="button"]')).toBeNull(); + expect( + document.querySelector('[data-plugin-export-audience-profile="button"]') + ).not.toBeNull(); expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull(); }); @@ -4977,7 +3609,10 @@ describe("market-content-entry", () => { ); expect(document.querySelector('[data-sces-favorite-row-action="button"]')).toBeNull(); - expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull(); + expect(document.querySelector('[data-plugin-export="button"]')).toBeNull(); + expect( + document.querySelector('[data-plugin-export-audience-profile="button"]') + ).not.toBeNull(); shouldRejectRead = false; document.documentElement.setAttribute("data-test-page-index", "2"); @@ -4995,126 +3630,6 @@ describe("market-content-entry", () => { expect(document.querySelector('[data-sces-favorites-drawer="error"]')).toBeNull(); }); - test("settles the initial observer resync before ready enables an immediate export", async () => { - document.body.innerHTML = buildRealMarketFixture([ - { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } - ]); - const repository = createTestFavoritesRepository(); - const originalRead = repository.read.bind(repository); - const observer = createMutationObserverFactory(); - let shouldScheduleInitialResync = true; - repository.read = async () => { - if (shouldScheduleInitialResync) { - shouldScheduleInitialResync = false; - document.querySelector('[data-plugin-toolbar="root"]')?.remove(); - observer.trigger(); - } - return originalRead(); - }; - const buildCsv = vi.fn(() => "csv-output"); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - favoritesRepository: repository, - loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), - mutationObserverFactory: observer.factory, - onCsvReady: vi.fn(), - window - })); - - await controller.ready; - - expect(document.querySelector('[data-plugin-toolbar="root"]')).not.toBeNull(); - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 40, 50); - - expect(buildCsv).toHaveBeenCalledTimes(1); - }); - - test("awaits a scheduled initial hydration before ready enables an immediate export", async () => { - document.body.innerHTML = buildRealMarketFixture([ - { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } - ]); - const repository = createTestFavoritesRepository(); - const originalRead = repository.read.bind(repository); - const observer = createMutationObserverFactory(); - const successfulMetrics = { - rates: { - personalVideoAfterSearchRate: "0.2%", - singleVideoAfterSearchRate: "0.1%" - }, - success: true as const - }; - const deferredMetrics = createDeferred(); - let shouldReplaceInitialPage = true; - let activeMetricLoads = 0; - let maxActiveMetricLoads = 0; - repository.read = async () => { - const state = await originalRead(); - if (shouldReplaceInitialPage) { - shouldReplaceInitialPage = false; - document.body.innerHTML = buildRealMarketFixture([ - { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } - ]); - observer.trigger(); - } - return state; - }; - const loadAuthorMetrics = vi.fn(async (authorId: string) => { - activeMetricLoads += 1; - maxActiveMetricLoads = Math.max(maxActiveMetricLoads, activeMetricLoads); - try { - if (authorId === "222") { - return await deferredMetrics.promise; - } - return successfulMetrics; - } finally { - activeMetricLoads -= 1; - } - }); - const buildCsv = vi.fn(() => "csv-output"); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - favoritesRepository: repository, - loadAuthorMetrics, - mutationObserverFactory: observer.factory, - onCsvReady: vi.fn(), - window - })); - let readySettled = false; - void controller.ready.then(() => { - readySettled = true; - }); - - await waitForCondition(() => - loadAuthorMetrics.mock.calls.some(([authorId]) => authorId === "222") - ); - await Promise.resolve(); - - expect(readySettled).toBe(false); - - deferredMetrics.resolve(successfulMetrics); - await controller.ready; - - setSelectValue('[data-plugin-export-range="select"]', "current"); - dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); - click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 40, 50); - - expect(buildCsv).toHaveBeenCalledTimes(1); - expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual(["111", "222"]); - expect(maxActiveMetricLoads).toBe(1); - }); - test("awaits a follow-up sync triggered during initial scheduled hydration", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } @@ -6478,7 +4993,6 @@ function setSpreadRuleSelect( function createNoopToolbarHandlers() { return { onConfigureAudienceProfileFields: vi.fn(), - onExport: vi.fn(), onExportAudienceProfile: vi.fn(), onExportAudienceProfileByIds: vi.fn(), onSubmitBatch: vi.fn() @@ -6718,6 +5232,14 @@ async function flushWithTimers() { await new Promise((resolve) => setTimeout(resolve, 0)); } +function readCsvCell(csv: string, header: string): string { + const [headerLine, rowLine] = csv.split("\n"); + const columnIndex = headerLine.split(",").indexOf(header); + + expect(columnIndex).toBeGreaterThanOrEqual(0); + return rowLine.split(",")[columnIndex] ?? ""; +} + async function waitForMockCall( mockFn: { mock: { calls: unknown[][] } }, maxAttempts = 10, diff --git a/tests/spread-info.test.ts b/tests/spread-info.test.ts index 1947ba2..294a3ec 100644 --- a/tests/spread-info.test.ts +++ b/tests/spread-info.test.ts @@ -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({