From e03dedd91e96caa8917915ba846d7cc777cbeb40 Mon Sep 17 00:00:00 2001 From: wxs Date: Mon, 27 Jul 2026 16:00:57 +0800 Subject: [PATCH] feat: honor all-range export and batch scope --- docs/项目流程说明文档.md | 47 +++-- src/content/market/index.ts | 76 +++++--- .../market/silent-export-controller.ts | 3 +- tests/market-content-entry.test.ts | 164 +++++++++++++++++- tests/silent-export-controller.test.ts | 50 ++++++ 5 files changed, 293 insertions(+), 47 deletions(-) diff --git a/docs/项目流程说明文档.md b/docs/项目流程说明文档.md index e28ff92..be931bc 100644 --- a/docs/项目流程说明文档.md +++ b/docs/项目流程说明文档.md @@ -9,7 +9,7 @@ 项目输入包括: - 巨量星图达人市场页面中的达人列表、筛选条件和分页结果; -- 使用者在页面上勾选的达人; +- 使用者在页面上勾选的达人(可选); - 使用者粘贴的达人星图 ID; - 使用者填写的批次名称; - 使用者选择的导出字段和传播指标筛选规则; @@ -105,12 +105,12 @@ ### 2.5 导出选中达人数据 - 触发者:使用者点击 `导出选中达人数据`。 -- 输入:当前勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。 -- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留该范围内已勾选的达人,然后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出。 +- 输入:可选的当前勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。 +- 处理:选择 `全部` 且没有勾选达人时,插件从第 1 页收集当前筛选结果的全部分页并全部导出;只要存在勾选达人,则勾选优先,只保留导出范围内已勾选的达人。其他范围仍需要勾选达人。随后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出。 - 输出:CSV 文件下载到浏览器默认下载目录。 - 下一步:使用者检查 CSV 内容。 -- 人工操作:需要使用者勾选达人并点击按钮。 -- 条件分支:如果当前导出范围内没有选中的达人,则不下载 CSV 并提示;单项补充失败不阻止下载,CSV 的 `导出状态` 和 `失败原因` 会标明部分成功及失败来源。 +- 人工操作:选择 `全部` 时可直接点击按钮;选择其他范围时需要先勾选达人。 +- 条件分支:存在勾选但全部范围内没有勾选达人时,不下载 CSV 并提示;单项补充失败不阻止下载,CSV 的 `导出状态` 和 `失败原因` 会标明部分成功及失败来源。 ### 2.6 按星图 ID 导出 @@ -126,7 +126,7 @@ - 触发者:使用者点击 `提交批次`。 - 输入:当前范围或已勾选达人、批次名称、登录用户信息。 -- 处理:插件先要求输入批次名称,再收集达人数据,应用传播指标规则和选中规则,检查登录状态,组装批次 payload,提交到后端。 +- 处理:插件先要求输入批次名称,再收集达人数据并应用传播指标规则。选择 `全部` 且没有勾选达人时,提交当前筛选结果的全部分页;只要存在勾选达人,则勾选优先,不会回退为全部结果。最后检查登录状态,组装批次 payload,提交到后端。 - 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。 - 下一步:在后端系统中继续处理批次。 - 人工操作:需要使用者输入批次名称。 @@ -211,11 +211,11 @@ ### 3.6 导出选中达人数据 - 步骤目的:把使用者选定的达人数据导出为 CSV。 -- 输入内容:已勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。 +- 输入内容:可选的已勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。 - 处理规则: - - 必须先勾选达人; - - 先按导出范围收集达人; - - 再严格保留当前导出范围内已勾选的达人; + - 选择 `全部` 且没有勾选达人时,从第 1 页收集并导出当前筛选结果的全部分页; + - 存在勾选达人时,勾选优先,严格保留导出范围内已勾选的达人; + - 选择当前页、前 5 页、前 10 页或自定义页数且没有勾选时,提示并停止; - 只补充已选字段对应的画像、效果预估、传播指标、看后搜率和秒思指标,未选数据类别不调用对应接口; - 基础字段、导出状态和失败原因等固定保留; - 画像请求全部失败时仍下载 CSV,已成功的效果预估等字段正常写入; @@ -223,8 +223,8 @@ - 输出结果:CSV 文件。 - 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。 - 失败后如何处理: - - 没有勾选达人:提示并停止; - - 当前范围内无选中达人:提示并停止; + - 非 `全部` 范围没有勾选达人:提示并停止; + - 存在勾选但全部范围内无选中达人:提示并停止; - 单个达人部分失败:记录为部分成功或失败; - 画像失败:对应画像单元格留空,失败来源写入 `失败原因`,其他成功数据仍保留并下载。 - 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。 @@ -277,9 +277,9 @@ - 点击后先输入批次名称; - 取消输入则停止; - 批次名为空则提示并停止; - - 如果存在已勾选达人,则优先提交当前范围内已勾选达人; - - 如果没有勾选达人,则提交当前范围内所有达人; - - 如果已勾选达人不在当前范围内,则回退为提交当前范围内所有达人; + - 选择 `全部` 且没有勾选达人时,从第 1 页提交当前筛选结果的全部分页; + - 选择 `全部` 且存在已勾选达人时,严格提交全部范围内已勾选达人; + - 选择 `全部` 时,如果已勾选达人不在结果内,则提示并停止,不会回退为提交全部达人; - 前端不生成批次 ID,批次 ID 由后端生成; - payload 包含登录用户 ID、创建人名称、资源地址、批次名称、创建时间和达人列表; - 达人列表包含达人 ID、达人名称,存在核心用户 ID 时额外带上。 @@ -356,10 +356,9 @@ ### 5.3 过滤规则 - 星图页面中缺少达人 ID 或达人名称的行会跳过; -- 导出选中达人数据必须有已勾选达人; -- 画像导出只保留当前导出范围内的已勾选达人; -- 提交批次如果存在已选达人,会优先保留当前范围内的已选达人; -- 如果当前范围内没有任何已选达人,提交批次会回退为当前范围全部达人; +- 导出选中达人数据在选择 `全部` 且没有勾选时,保留当前筛选结果的全部分页;其他范围必须有已勾选达人; +- 选择 `全部` 且存在勾选达人时,画像导出和提交批次都只保留全部范围内已勾选达人; +- 选择 `全部` 时,勾选达人不在当前筛选结果内则不回退为全部达人; - 传播指标规则启用后,不满足全部已选规则的达人会被过滤; - 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。 @@ -485,7 +484,7 @@ 1. 在星图市场中完成筛选; 2. 等待页面列表加载完成; -3. 勾选需要导出的达人; +3. 选择 `全部` 时可不勾选;选择其他范围时勾选需要导出的达人; 4. 可选:点击 `选择字段` 调整 CSV 字段; 5. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径; 6. 点击 `导出选中达人数据`; @@ -589,7 +588,7 @@ | 批次提交后端地址 | 提交达人批次的目标服务 | 当前工作区为 `http://localhost:8083` | 其他后端地址未确认 | 影响批次提交去向 | 需要重新构建并重新加载插件 | 提交到错误环境、重复或丢失业务数据 | | 后端指标服务地址 | 查询秒思 api 指标 | `https://talent-search.intelligrow.cn` | 未确认 | 影响页面增强列和 CSV 秒思字段 | 需要重新构建并重新加载插件 | 指标为空、权限错误或消耗错误环境额度 | | COS 更新清单 URL | 插件弹窗检查新版本 | `https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json` | 其他 HTTPS URL | 影响更新提示和安装包下载 | 需要重新构建并重新加载插件 | 用户无法更新或下载错误包 | -| 导出范围 | 决定收集哪些页面达人 | 当前工具栏默认隐藏,默认值为前 5 页;当前用户主入口通常要求勾选达人 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 | +| 导出范围 | 决定收集和最终处理哪些页面达人 | 当前工具栏默认值为前 5 页;选择 `全部` 且未勾选时处理当前筛选结果全量,存在勾选时勾选优先 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 | | 传播指标规则 | 导出或提交前二次过滤达人 | 默认不选择任何指标 | 完播率、互动率;每个已选指标填写非负阈值并选择独立视频口径 | 影响最终保留达人集合和星图接口请求口径 | 不需要重启 | 指标、口径或阈值选错会过滤掉目标达人 | | 字段选择 | 控制 CSV 可选字段 | 默认全选 | 可选字段集合 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 | @@ -630,8 +629,8 @@ ### 9.5 失败表现 - 未登录:星图页面显示登录提示,不出现业务工具栏; -- 没勾选就导出选中达人数据:提示请先勾选; -- 当前范围无选中达人:提示当前导出范围内没有选中的达人; +- 非 `全部` 范围没勾选就导出选中达人数据:提示请先勾选; +- 选择 `全部` 且勾选达人不在当前筛选结果内:提示全部范围内没有选中的达人; - 全部已选补充数据都失败:CSV 仍会下载,该行标记为部分成功或失败并列明失败原因; - 按 ID 没有有效 ID:提示请输入有效的达人星图 ID; - 批次提交失败:状态区显示接口错误或通用失败提示; @@ -699,7 +698,7 @@ - Drone 发布是否为唯一正式发布方式:未确认。 - 插件是否有统一日志、错误上报或审计记录:未确认。 - 页面增强指标是否有跨页面或跨浏览器持久缓存:未确认;当前仅确认有页面会话内记录。 -- 导出全部页面时最多导出多少页:后台静默导出当前最多尝试 200 页;真实星图侧上限未确认。 +- 导出全部页面时最多导出多少页:后台静默导出从第 1 页开始,当前最多尝试 200 页;真实星图侧上限未确认。 - 传播指标请求是否应该限制并发:当前筛选已按同一达人相同口径去重,但真实业务级并发上限仍未确认。 - 后续业务系统如何消费批次:未确认。 diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 41fdd94..5e6d67e 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -339,16 +339,15 @@ export function createMarketController(options: CreateMarketControllerOptions) { const toolbarHandlers = { onExportAudienceProfile: async () => { syncSelectionStateFromDom(); - if (selectedAuthorIds.size === 0) { - setToolbarExportStatus(toolbar, "请先勾选需要导出数据的达人"); - return; - } - const exportTarget = readToolbarExportTarget(toolbar); if (!exportTarget.target) { setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效"); return; } + if (selectedAuthorIds.size === 0 && exportTarget.target.mode !== "all") { + setToolbarExportStatus(toolbar, "请先勾选需要导出数据的达人"); + return; + } setToolbarBusyState(toolbar, true); try { @@ -356,16 +355,27 @@ export function createMarketController(options: CreateMarketControllerOptions) { const exportSelection = resolveAudienceProfileExportSelection( selectedHeaders ); - const selectedRecords = filterRecordsBySelectionStrict( - await exportRecords(exportTarget.target, "画像导出中", { + const resolvedRecords = await exportRecords( + exportTarget.target, + "画像导出中", + { includeBackendMetrics: exportSelection.includeBackendMetrics, includeRates: exportSelection.includeRates, showDetailedProgress: false, spreadInfoConfigs: exportSelection.spreadInfoConfigs - }) + } + ); + const selectedRecords = filterRecordsForAudienceProfileExport( + resolvedRecords, + exportTarget.target ); if (selectedRecords.length === 0) { - setToolbarExportStatus(toolbar, "当前导出范围内没有选中的达人"); + setToolbarExportStatus( + toolbar, + exportTarget.target.mode === "all" && selectedAuthorIds.size > 0 + ? "全部范围内没有选中的达人" + : "当前导出范围内没有选中的达人" + ); return; } @@ -510,18 +520,29 @@ export function createMarketController(options: CreateMarketControllerOptions) { setToolbarBusyState(toolbar, true); try { const hasSelectedAuthors = selectedAuthorIds.size > 0; - const records = filterRecordsBySelection( - await applySpreadThresholdFilter( - await exportRecords( - exportTarget.target, - hasSelectedAuthors ? "提交已选达人中" : "提交中", - { - showDetailedProgress: !hasSelectedAuthors - } - ), - spreadFilter.filter - ) + const resolvedRecords = await applySpreadThresholdFilter( + await exportRecords( + exportTarget.target, + hasSelectedAuthors ? "提交已选达人中" : "提交中", + { + showDetailedProgress: !hasSelectedAuthors + } + ), + spreadFilter.filter ); + const records = + exportTarget.target.mode === "all" + ? filterRecordsForAllRange(resolvedRecords) + : filterRecordsBySelection(resolvedRecords); + if (records.length === 0) { + setToolbarExportStatus( + toolbar, + exportTarget.target.mode === "all" && hasSelectedAuthors + ? "全部范围内没有选中的达人" + : "当前范围内没有可提交的达人" + ); + return; + } const authState = await getAuthState(); if (!authState.isAuthenticated) { throw new Error("请先登录插件"); @@ -1168,6 +1189,21 @@ export function createMarketController(options: CreateMarketControllerOptions) { return records.filter((record) => selectedAuthorIds.has(record.authorId)); } + function filterRecordsForAllRange(records: MarketRecord[]): MarketRecord[] { + return selectedAuthorIds.size === 0 + ? records + : filterRecordsBySelectionStrict(records); + } + + function filterRecordsForAudienceProfileExport( + records: MarketRecord[], + target: MarketExportTarget + ): MarketRecord[] { + return target.mode === "all" + ? filterRecordsForAllRange(records) + : filterRecordsBySelectionStrict(records); + } + async function loadAudienceProfileSet( record: MarketRecord, exportSelection: AudienceProfileExportSelection diff --git a/src/content/market/silent-export-controller.ts b/src/content/market/silent-export-controller.ts index 526584e..0407f5d 100644 --- a/src/content/market/silent-export-controller.ts +++ b/src/content/market/silent-export-controller.ts @@ -51,10 +51,11 @@ export function createSilentExportController( const mergedRecords = new Map(); const maxPageCount = target.mode === "count" ? target.pageCount : 200; + const initialPage = target.mode === "all" ? 1 : baseRequest.initialPage; let totalPagesHint: number | undefined; for (let offset = 0; offset < maxPageCount; offset += 1) { - const pageNumber = baseRequest.initialPage + offset; + const pageNumber = initialPage + offset; options.onProgress?.({ currentPage: offset + 1, totalPages: target.mode === "count" ? target.pageCount : totalPagesHint diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index dd82dc5..a2a28e2 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -1791,7 +1791,7 @@ describe("market-content-entry", () => { }); }); - test("audience profile export requires selected creators", async () => { + test("audience profile export requires selected creators outside of the all range", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); @@ -1825,6 +1825,109 @@ describe("market-content-entry", () => { ).toContain("请先勾选需要导出数据的达人"); }); + test("audience profile export includes all filtered creators when all is selected without checks", async () => { + const records = [ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ]; + document.body.innerHTML = buildRealMarketFixture(records); + 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" + }) + ); + ( + globalThis as typeof globalThis & { fetch?: typeof fetch } + ).fetch = vi.fn(async () => ({ + json: async () => ({ + data: { + marketList: buildMarketListResponseRows(records), + totalPages: 1 + } + }), + ok: true + })); + const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildAudienceProfileCsv, + document, + loadAudienceProfile: async () => ({ status: "success" as const }), + 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"]'); + click('[data-plugin-export-audience-profile="button"]'); + await waitForMockCall(buildAudienceProfileCsv, 40, 50); + + expect( + buildAudienceProfileCsv.mock.calls[0]?.[0].map(({ record }) => record.authorId) + ).toEqual(["111", "222"]); + }); + + test("audience profile export gives checked creators priority in the all range", async () => { + const records = [ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ]; + document.body.innerHTML = buildRealMarketFixture(records); + 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" + }) + ); + ( + globalThis as typeof globalThis & { fetch?: typeof fetch } + ).fetch = vi.fn(async () => ({ + json: async () => ({ + data: { + marketList: buildMarketListResponseRows(records), + totalPages: 1 + } + }), + ok: true + })); + const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildAudienceProfileCsv, + document, + loadAudienceProfile: async () => ({ status: "success" as const }), + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady: vi.fn(), + window + })); + + await controller.ready; + clickSelectionCheckboxForAuthor("222"); + setSelectValue('[data-plugin-export-range="select"]', "all"); + dispatchChange('[data-plugin-export-range="select"]'); + click('[data-plugin-export-audience-profile="button"]'); + await waitForMockCall(buildAudienceProfileCsv, 40, 50); + + expect( + buildAudienceProfileCsv.mock.calls[0]?.[0].map(({ record }) => record.authorId) + ).toEqual(["222"]); + }); + test("audience profile export loads profiles only for selected creators", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, @@ -2967,8 +3070,62 @@ describe("market-content-entry", () => { ); }); + test("all-range batch submit does not fall back when checked creators are outside the results", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + 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" + }) + ); + ( + globalThis as typeof globalThis & { fetch?: typeof fetch } + ).fetch = vi.fn(async () => ({ + json: async () => ({ + data: { + marketList: buildMarketListResponseRows([ + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ]), + totalPages: 1 + } + }), + ok: true + })); + const submitBatch = vi.fn(async () => ({ ok: true })); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + promptBatchName: vi.fn(() => "自动选择批次"), + submitBatch, + window + })); + + await controller.ready; + clickSelectionCheckboxForAuthor("111"); + setSelectValue('[data-plugin-export-range="select"]', "all"); + dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); + click('[data-plugin-batch-submit="button"]'); + + await waitForCondition(() => + document.querySelector('[data-plugin-export-status="text"]')?.textContent === + "全部范围内没有选中的达人" + ); + + expect(submitBatch).not.toHaveBeenCalled(); + }); + test( - "default paged batch submit keeps detailed progress when no creators are selected", + "all-range batch submit keeps detailed progress when no creators are selected", async () => { const pages = [ [ @@ -3042,6 +3199,9 @@ describe("market-content-entry", () => { await controller.ready; + setSelectValue('[data-plugin-export-range="select"]', "all"); + dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { diff --git a/tests/silent-export-controller.test.ts b/tests/silent-export-controller.test.ts index 24d212d..895f207 100644 --- a/tests/silent-export-controller.test.ts +++ b/tests/silent-export-controller.test.ts @@ -155,6 +155,56 @@ describe("silent-export-controller", () => { expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]); }); + test("starts all-result exports from page 1 even after browsing to a later page", async () => { + document.documentElement.setAttribute( + "data-sces-market-request-snapshot", + JSON.stringify({ + body: JSON.stringify({ + page_param: { + page: 2 + } + }), + method: "POST", + url: "https://xingtu.cn/api/mock-market-search" + }) + ); + + const requestedPages: number[] = []; + const controller = createSilentExportController({ + document, + fetchImpl: async (_url, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { + page_param?: { page?: number }; + }; + const pageNo = body.page_param?.page ?? 0; + requestedPages.push(pageNo); + + return { + json: async () => ({ + authors: [ + { + attribute_datas: { + nickname: `达人${pageNo}` + }, + star_id: String(pageNo) + } + ], + pagination: { + page: pageNo, + totalPages: 3 + } + }), + ok: true + }; + } + }); + + const records = await controller.exportRecords({ mode: "all" }); + + expect(requestedPages).toEqual([1, 2, 3]); + expect(records?.map((record) => record.authorId)).toEqual(["1", "2", "3"]); + }); + test("keeps attribute_datas.id as the spread author id while preserving star_id as row id", async () => { document.documentElement.setAttribute( "data-sces-market-request-snapshot",