From 66bc49d4983d74aa558765b5a89612afd05489aa Mon Sep 17 00:00:00 2001 From: admin123 Date: Mon, 18 May 2026 16:59:05 +0800 Subject: [PATCH] feat: add selected audience profile csv export --- ...26-05-18-market-audience-profile-export.md | 332 ++++++++++++++++++ ...8-market-audience-profile-export-design.md | 105 ++++++ src/background/auth/controller.ts | 9 + src/background/auth/state.ts | 4 +- src/content/index.ts | 36 +- src/content/market/audience-profile-client.ts | 273 ++++++++++++++ src/content/market/audience-profile-csv.ts | 180 ++++++++++ src/content/market/audience-profile-types.ts | 31 ++ src/content/market/auth-gate.ts | 9 +- src/content/market/csv-exporter.ts | 12 +- src/content/market/index.ts | 86 ++++- src/content/market/plugin-toolbar.ts | 30 +- tests/audience-profile-client.test.ts | 119 +++++++ tests/audience-profile-csv.test.ts | 72 ++++ tests/background-auth-controller.test.ts | 21 ++ tests/market-auth-gating.test.ts | 20 ++ tests/market-content-entry.test.ts | 135 +++++++ 17 files changed, 1458 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-18-market-audience-profile-export.md create mode 100644 docs/superpowers/specs/2026-05-18-market-audience-profile-export-design.md create mode 100644 src/content/market/audience-profile-client.ts create mode 100644 src/content/market/audience-profile-csv.ts create mode 100644 src/content/market/audience-profile-types.ts create mode 100644 tests/audience-profile-client.test.ts create mode 100644 tests/audience-profile-csv.test.ts diff --git a/docs/superpowers/plans/2026-05-18-market-audience-profile-export.md b/docs/superpowers/plans/2026-05-18-market-audience-profile-export.md new file mode 100644 index 0000000..572f680 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-market-audience-profile-export.md @@ -0,0 +1,332 @@ +# Market Audience Profile Export Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a selected-creators-only `导出画像CSV` flow that exports current market columns plus detail-page "连接用户" audience profile distributions. + +**Architecture:** Keep the existing CSV export untouched and add a separate profile export path. The content controller reuses current selection and market row hydration, loads one selected creator profile at a time through a focused detail-page profile client, then writes a separate CSV with structured audience columns. + +**Tech Stack:** TypeScript, Chrome MV3 content scripts, Xingtu authenticated pages, Vitest, jsdom, tsup + +--- + +## File Map + +- Modify: `src/background/auth/controller.ts` + - Keep token-readable auth state behavior from the previous fix. +- Modify: `src/background/auth/state.ts` + - Keep logged-out `lastError` support from the previous fix. +- Modify: `tests/background-auth-controller.test.ts` + - Keep token-expired regression coverage. +- Modify: `src/content/market/auth-gate.ts` + - Render expired-login text when auth state carries a token-expired error. +- Modify: `src/content/index.ts` + - Pass auth failure text into the market auth gate if needed. +- Modify: `src/content/market/plugin-toolbar.ts` + - Add a `导出画像CSV` button and handler. +- Create: `src/content/market/audience-profile-types.ts` + - Define normalized distribution and export-row types. +- Create: `src/content/market/audience-profile-client.ts` + - Load one creator detail page and extract normalized audience profile data. +- Create: `src/content/market/audience-profile-csv.ts` + - Build CSV columns from market records plus profile distributions. +- Modify: `src/content/market/index.ts` + - Add selected-only profile export flow and serial profile loading. +- Test: `tests/market-auth-gating.test.ts` + - Verify expired-login text. +- Test: `tests/plugin-toolbar.test.ts` + - Verify new toolbar button wiring. +- Test: `tests/audience-profile-csv.test.ts` + - Verify structured CSV column expansion. +- Test: `tests/audience-profile-client.test.ts` + - Verify parser behavior against representative detail-page payload/state shapes. +- Modify: `tests/market-content-entry.test.ts` + - Verify selected-only export behavior and failure handling. + +## Task 1: Expired Login Message + +**Files:** +- Modify: `src/content/market/auth-gate.ts` +- Modify: `src/content/index.ts` +- Test: `tests/market-auth-gating.test.ts` + +- [ ] **Step 1: Write the failing auth gate test** + +Add a test where `sendAuthMessage` returns: + +```ts +{ + ok: true, + type: "auth:state", + value: { + isAuthenticated: false, + lastError: "Token 已过期" + } +} +``` + +Assert the page shows `登录已过期,请重新登录`. + +- [ ] **Step 2: Run the failing test** + +Run: + +```bash +npm test -- tests/market-auth-gating.test.ts +``` + +Expected: FAIL because the gate only renders `请先登录插件`. + +- [ ] **Step 3: Implement minimal auth gate text support** + +Update `renderMarketAuthGate` to accept an optional message string and render it instead of the default title. Update `bootContentScript` to pass `登录已过期,请重新登录` when `lastError` contains `token` or `过期`. + +- [ ] **Step 4: Verify** + +Run: + +```bash +npm test -- tests/market-auth-gating.test.ts tests/popup-entry.test.ts +``` + +Expected: PASS. + +## Task 2: Toolbar Button + +**Files:** +- Modify: `src/content/market/plugin-toolbar.ts` +- Test: `tests/plugin-toolbar.test.ts` + +- [ ] **Step 1: Write failing toolbar tests** + +Create tests that: + +- render the toolbar +- assert a button with text `导出画像CSV` exists +- click it and assert `onExportAudienceProfile` was called +- assert `setToolbarBusyState` disables the new button + +- [ ] **Step 2: Run the failing tests** + +Run: + +```bash +npm test -- tests/plugin-toolbar.test.ts +``` + +Expected: FAIL because the button and handler do not exist. + +- [ ] **Step 3: Implement toolbar support** + +Add `onExportAudienceProfile` to `PluginToolbarHandlers`, add `audienceProfileExportButton` to `PluginToolbarDom`, render the new button, wire click handling, and include it in busy-state disabling. + +- [ ] **Step 4: Verify** + +Run: + +```bash +npm test -- tests/plugin-toolbar.test.ts tests/market-content-entry.test.ts +``` + +Expected: PASS. + +## Task 3: Profile CSV Builder + +**Files:** +- Create: `src/content/market/audience-profile-types.ts` +- Create: `src/content/market/audience-profile-csv.ts` +- Test: `tests/audience-profile-csv.test.ts` + +- [ ] **Step 1: Write failing CSV tests** + +Define a sample market record and sample profile: + +```ts +const profile = { + status: "success", + gender: [ + { label: "男性", value: "40.6%" }, + { label: "女性", value: "59.4%" } + ], + age: [{ label: "18-23", value: "28.6%" }], + province: [{ label: "广东", value: "15%" }], + regionTop: [{ label: "北京", value: "15%" }], + cityTier: [{ label: "一线", value: "20%" }], + interestTop: [{ label: "亲子", value: "18%" }], + crowd: [{ label: "精致妈妈", value: "12%" }] +}; +``` + +Assert the CSV contains separate headers like `连接用户-男性占比`, `省份-广东占比`, `地域TOP1名称`, `地域TOP1占比`. + +- [ ] **Step 2: Run the failing tests** + +Run: + +```bash +npm test -- tests/audience-profile-csv.test.ts +``` + +Expected: FAIL because the files do not exist. + +- [ ] **Step 3: Implement CSV builder** + +Create: + +- `AudienceProfileDistributionItem` +- `AudienceProfileResult` +- `buildAudienceProfileCsv(records, profilesByAuthorId)` + +Reuse `escapeCsvCell` and existing base/rate/backend metric column conventions. Add `画像抓取状态`. + +- [ ] **Step 4: Verify** + +Run: + +```bash +npm test -- tests/audience-profile-csv.test.ts tests/csv-exporter.test.ts +``` + +Expected: PASS. + +## Task 4: Detail Profile Client and Parser + +**Files:** +- Create: `src/content/market/audience-profile-client.ts` +- Test: `tests/audience-profile-client.test.ts` + +- [ ] **Step 1: Use a logged-in browser to identify the real data source** + +Run a Playwright probe against an authenticated `https://xingtu.cn/ad/creator/author-homepage/douyin-video/` page. Capture only `/gw/api/...` JSON responses and page Vue/ECharts state. Record representative payload/state samples in the test file as small fixtures. + +- [ ] **Step 2: Write failing parser tests** + +Use the captured fixture to assert the parser returns normalized arrays for gender, age, province, region top 10, city tier, interest top 10, and crowd. + +- [ ] **Step 3: Run the failing tests** + +Run: + +```bash +npm test -- tests/audience-profile-client.test.ts +``` + +Expected: FAIL because the client/parser does not exist. + +- [ ] **Step 4: Implement parser and client** + +Implement a small parser first. Then implement the client with injectable dependencies: + +```ts +createAudienceProfileClient({ + fetchDetailPage?: (authorId: string) => Promise; + readProfileFromPage?: (authorId: string) => Promise; +}) +``` + +Prefer parsed API JSON. Fall back to page state when API JSON is unavailable. + +- [ ] **Step 5: Verify** + +Run: + +```bash +npm test -- tests/audience-profile-client.test.ts +``` + +Expected: PASS. + +## Task 5: Controller Export Flow + +**Files:** +- Modify: `src/content/market/index.ts` +- Test: `tests/market-content-entry.test.ts` + +- [ ] **Step 1: Write failing selected-only export tests** + +Add tests that: + +- select one of two visible rows +- click `导出画像CSV` +- assert only the selected author profile is requested +- assert `onCsvReady` receives a CSV containing profile columns + +- [ ] **Step 2: Write failing no-selection test** + +Assert clicking `导出画像CSV` with no selected rows sets status to `请先勾选需要导出画像的达人` and makes no profile requests. + +- [ ] **Step 3: Write failing partial-failure test** + +Mock two selected profiles where one succeeds and one fails. Assert CSV is still generated with one success row and one `画像抓取状态=失败` row. + +- [ ] **Step 4: Run failing tests** + +Run: + +```bash +npm test -- tests/market-content-entry.test.ts +``` + +Expected: FAIL because the controller has no profile export flow. + +- [ ] **Step 5: Implement controller flow** + +Add an injected option `loadAudienceProfile?: (record: MarketRecord) => Promise`. Add handler: + +- sync selected state from DOM +- reject empty selection +- hydrate current-page selected records +- load profiles serially +- cache successful results by author ID +- build profile CSV +- call `onCsvReady` +- update toolbar progress/status + +- [ ] **Step 6: Verify** + +Run: + +```bash +npm test -- tests/market-content-entry.test.ts tests/audience-profile-csv.test.ts tests/plugin-toolbar.test.ts +``` + +Expected: PASS. + +## Task 6: Full Verification + +**Files:** +- Modify only if failures identify necessary scoped fixes. + +- [ ] **Step 1: Run focused suite** + +Run: + +```bash +npm test -- tests/market-auth-gating.test.ts tests/plugin-toolbar.test.ts tests/audience-profile-csv.test.ts tests/audience-profile-client.test.ts tests/market-content-entry.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run all tests** + +Run: + +```bash +npm test +``` + +Expected: PASS. + +- [ ] **Step 3: Run build** + +Run: + +```bash +npm run build +``` + +Expected: PASS. + +- [ ] **Step 4: Manual logged-in browser verification** + +Load the built extension in Chrome, select one or two market creators, click `导出画像CSV`, and verify the downloaded CSV contains structured profile columns with detail-page data. diff --git a/docs/superpowers/specs/2026-05-18-market-audience-profile-export-design.md b/docs/superpowers/specs/2026-05-18-market-audience-profile-export-design.md new file mode 100644 index 0000000..463851b --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-market-audience-profile-export-design.md @@ -0,0 +1,105 @@ +# Market Audience Profile Export Design + +## Goal + +Add a separate CSV export for selected creators that includes the current market export fields plus audience profile data from each creator detail page's "连接用户" tab. + +## User-Approved Decisions + +- Add a new toolbar button named `导出画像CSV`. +- Keep the existing `导出CSV` behavior unchanged. +- Only allow the new export when at least one creator row is selected. +- Do not support "export all" for profile data in this change because detail-page data costs extra API/page loads. +- Suggested downloaded filename: `达人连接用户画像_YYYYMMDD_HHmm.csv`. +- Export profile distributions as separate structured CSV columns, not as JSON blobs. + +## Data Scope + +Each exported row represents one selected creator. Start with the current market CSV columns, then append audience profile columns for: + +- 性别分布 +- 年龄分布 +- 全国省份分布 +- 地域占比 TOP10 +- 城市等级分布 +- 兴趣分布 +- 八大人群占比 + +Fixed distributions should become fixed columns, for example: + +- `连接用户-男性占比` +- `连接用户-女性占比` +- `连接用户-18-23占比` +- `连接用户-24-30占比` +- `省份-广东占比` +- `城市等级-一线占比` +- `八大人群-精致妈妈占比` + +Ranked distributions should become name/value column pairs: + +- `地域TOP1名称` +- `地域TOP1占比` +- ... +- `地域TOP10名称` +- `地域TOP10占比` +- `兴趣TOP1名称` +- `兴趣TOP1占比` +- ... +- `兴趣TOP10名称` +- `兴趣TOP10占比` + +Add a `画像抓取状态` column so partial failures are visible in CSV output. + +## Data Acquisition + +Use an on-demand detail-page probe. The implementation must first confirm the real data source from an authenticated creator detail page: + +1. Prefer Xingtu `/gw/api/...` JSON responses if they expose the required profile data. +2. If the API payload is difficult to locate or unstable, read the detail page's Vue/ECharts state from the page context. +3. Avoid screen/OCR parsing and avoid relying on rendered chart pixels. + +The export should process selected creators one at a time by default to respect API limits and reduce anti-abuse risk. Cache successful profile results in memory for the current page session. + +## UX + +Toolbar behavior: + +- Add `导出画像CSV` next to the existing export actions. +- Disable the button while any export/submission action is running. +- If no creators are selected, show `请先勾选需要导出画像的达人`. +- While exporting, show progress such as `画像导出中 3/12...`. +- If plugin auth is expired, show `登录已过期,请重新登录`. +- If one creator fails, keep the row in the CSV with `画像抓取状态=失败` and leave profile columns empty. +- If all creators fail, do not download a CSV and show a failure message. + +## Architecture + +Add the feature as a separate export path instead of extending the existing `导出CSV` action. Reuse current selection state, market record hydration, CSV escaping, and runtime download path. + +Proposed units: + +- `audience-profile-client`: load and parse audience profile data for one creator detail page. +- `audience-profile-csv`: combine existing market CSV columns with profile-specific columns. +- Toolbar additions: add a button and handler for profile export. +- Controller additions: filter to selected creators, call the profile client serially, build the CSV, then reuse `onCsvReady`. + +## Testing + +Use TDD. Add focused tests for: + +- Auth state expired detection and user-facing expired-login text. +- Toolbar renders and wires the new `导出画像CSV` button. +- New export refuses to run without selected creators. +- Profile CSV expands fixed and ranked distributions into separate columns. +- Controller exports only selected creators and fetches profiles serially. +- Failed creator profile fetches produce a failed row while successful rows still export. + +Run focused tests first, then full `npm test`, then `npm run build`. + +## Out of Scope + +- Unselected/all-page profile export. +- Persistent cross-session profile cache. +- Visual dashboard UI for profile data. +- Changing batch submission payloads. +- OCR or screenshot-based chart extraction. diff --git a/src/background/auth/controller.ts b/src/background/auth/controller.ts index ddb1c73..5fe29b6 100644 --- a/src/background/auth/controller.ts +++ b/src/background/auth/controller.ts @@ -26,6 +26,15 @@ export function createAuthController(options: { return createLoggedOutAuthState(config); } + try { + await options.authClient.getAccessToken(config.apiResource); + } catch (error) { + return createLoggedOutAuthState( + config, + error instanceof Error ? error.message : String(error) + ); + } + const claims = await options.authClient.getIdTokenClaims(); return createLoggedInAuthState(claims, config); }, diff --git a/src/background/auth/state.ts b/src/background/auth/state.ts index 56ad611..952227f 100644 --- a/src/background/auth/state.ts +++ b/src/background/auth/state.ts @@ -2,10 +2,12 @@ import type { AuthConfig } from "../../shared/auth-config"; import type { AuthStateValue } from "../../shared/auth-messages"; export function createLoggedOutAuthState( - config?: Pick + config?: Pick, + lastError?: string | null ): AuthStateValue { return { isAuthenticated: false, + lastError: lastError ?? null, resource: config?.apiResource ?? null }; } diff --git a/src/content/index.ts b/src/content/index.ts index 13f8af6..8315d3c 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -44,7 +44,11 @@ export async function bootContentScript( const authState = await readAuthState(sendAuthMessage); if (!authState?.isAuthenticated) { await waitForBodyReady(currentDocument, currentWindow); - renderMarketAuthGate(currentDocument, currentWindow); + renderMarketAuthGate( + currentDocument, + currentWindow, + isExpiredAuthState(authState) ? "登录已过期,请重新登录" : undefined + ); return { ready: Promise.resolve() }; @@ -54,12 +58,17 @@ export async function bootContentScript( return controllerFactory({ document: currentDocument, - onCsvReady: (csv: string) => { + onCsvReady: (csv: string, filename?: string) => { + if (filename) { + downloadCsv(currentDocument, currentWindow, csv, filename); + return; + } + if (requestCsvDownload(csv)) { return; } - downloadCsv(currentDocument, currentWindow, csv); + downloadCsv(currentDocument, currentWindow, csv, filename); }, window: currentWindow }); @@ -112,7 +121,7 @@ function bootstrapContentScript() { bootstrapContentScript(); -function requestCsvDownload(csv: string): boolean { +function requestCsvDownload(csv: string, filename?: string): boolean { const runtime = ( globalThis as typeof globalThis & { chrome?: { runtime?: ChromeRuntimeLike }; @@ -125,7 +134,7 @@ function requestCsvDownload(csv: string): boolean { runtime.sendMessage({ csv, - filename: `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`, + filename: filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`, type: DOWNLOAD_MARKET_CSV_MESSAGE }); return true; @@ -165,14 +174,19 @@ async function waitForBodyReady(document: Document, currentWindow: Window): Prom }); } -function downloadCsv(document: Document, window: Window, csv: string): void { +function downloadCsv( + document: Document, + window: Window, + csv: string, + filename?: string +): void { const blob = new Blob(["\uFEFF", csv], { type: "text/csv;charset=utf-8" }); const objectUrl = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = objectUrl; - link.download = `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`; + link.download = filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`; document.body.appendChild(link); link.click(); link.remove(); @@ -183,6 +197,14 @@ function formatTimestampForFilename(): string { return new Date().toISOString().replace(/[:.]/g, "-"); } +function isExpiredAuthState(authState: AuthStateValue | null): boolean { + const lastError = authState?.lastError; + return ( + typeof lastError === "string" && + (/token/i.test(lastError) || lastError.includes("过期")) + ); +} + function installMarketPageBridge(document: Document) { if ( document.documentElement.querySelector( diff --git a/src/content/market/audience-profile-client.ts b/src/content/market/audience-profile-client.ts new file mode 100644 index 0000000..2285048 --- /dev/null +++ b/src/content/market/audience-profile-client.ts @@ -0,0 +1,273 @@ +import type { MarketRecord } from "./types"; +import type { + AudienceProfileDistributionItem, + AudienceProfileResult, + AudienceProfileSuccess +} from "./audience-profile-types"; + +interface FetchResponseLike { + json(): Promise; + ok: boolean; +} + +type FetchLike = ( + input: string, + init?: RequestInit +) => Promise; + +interface AudienceProfileClientOptions { + baseUrl?: string; + fetchImpl?: FetchLike; + linkType?: number; + timeoutMs?: number; +} + +type DistributionSection = + | "age" + | "cityTier" + | "cityTop" + | "crowd" + | "gender" + | "interest" + | "province"; + +const SECTION_BY_DISPLAY: Array<[RegExp, DistributionSection]> = [ + [/性别/, "gender"], + [/年龄/, "age"], + [/省份|全国省份/, "province"], + [/城市分布|地域/, "cityTop"], + [/城市等级/, "cityTier"], + [/兴趣/, "interest"], + [/八大人群/, "crowd"] +]; + +const GENDER_LABELS: Record = { + female: "女性", + male: "男性" +}; + +const AGE_ORDER = ["18-23", "24-30", "31-40", "41-50", "50+"]; +const CITY_TIER_ORDER = ["一线", "新一线", "二线", "三线", "四线", "五线"]; + +export function createAudienceProfileClient( + options: AudienceProfileClientOptions = {} +) { + const baseUrl = options.baseUrl ?? resolveBaseUrl(); + const fetchImpl = options.fetchImpl ?? defaultFetch; + const timeoutMs = options.timeoutMs ?? 8000; + const linkType = options.linkType ?? 1; + + return { + async loadAudienceProfile(record: MarketRecord): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl( + buildAudienceProfileUrl(record.authorId, baseUrl, linkType), + { + credentials: "include", + method: "GET", + signal: controller.signal + } + ); + + if (!response.ok) { + return { + failureReason: "request-failed", + status: "failed" + }; + } + + return mapAudienceProfileResponse(await response.json()); + } catch (error) { + return { + failureReason: + error instanceof Error && error.name === "AbortError" + ? "timeout" + : "request-failed", + status: "failed" + }; + } finally { + clearTimeout(timeoutId); + } + } + }; +} + +export function buildAudienceProfileUrl( + authorId: string, + baseUrl: string, + linkType = 1 +): string { + const url = new URL("/gw/api/data_sp/author_audience_distribution", baseUrl); + url.searchParams.set("o_author_id", authorId); + url.searchParams.set("platform_source", "1"); + url.searchParams.set("platform_channel", "1"); + url.searchParams.set("link_type", String(linkType)); + return url.toString(); +} + +export function mapAudienceProfileResponse( + payload: unknown +): AudienceProfileResult { + if (!isRecord(payload) || !Array.isArray(payload.distributions)) { + return { + failureReason: "bad-response", + status: "failed" + }; + } + + const profile: AudienceProfileSuccess = { + status: "success" + }; + + payload.distributions.forEach((section) => { + if (!isRecord(section)) { + return; + } + + const display = readString(section.type_display); + const sectionName = resolveSection(display); + if (!sectionName || !Array.isArray(section.distribution_list)) { + return; + } + + profile[sectionName] = normalizeDistributionItems( + section.distribution_list, + sectionName + ); + }); + + if (Object.keys(profile).length === 1) { + return { + failureReason: "missing-profile", + status: "failed" + }; + } + + return profile; +} + +function normalizeDistributionItems( + rawItems: unknown[], + sectionName: DistributionSection +): AudienceProfileDistributionItem[] { + const parsedItems = rawItems + .map((item) => { + if (!isRecord(item)) { + return null; + } + + const key = readString(item.distribution_key); + const value = readNumber(item.distribution_value); + if (!key || value === null) { + return null; + } + + return { + label: normalizeLabel(key, sectionName), + rawLabel: key, + value + }; + }) + .filter((item): item is { label: string; rawLabel: string; value: number } => + Boolean(item) + ); + + const total = parsedItems.reduce((sum, item) => sum + item.value, 0); + if (total <= 0) { + return []; + } + + return parsedItems + .sort((left, right) => compareDistributionItems(left, right, sectionName)) + .map((item) => ({ + label: item.label, + value: formatPercent(item.value / total) + })); +} + +function compareDistributionItems( + left: { rawLabel: string; value: number }, + right: { rawLabel: string; value: number }, + sectionName: DistributionSection +): number { + if (sectionName === "age") { + return orderIndex(AGE_ORDER, left.rawLabel) - orderIndex(AGE_ORDER, right.rawLabel); + } + + if (sectionName === "cityTier") { + return ( + orderIndex(CITY_TIER_ORDER, left.rawLabel) - + orderIndex(CITY_TIER_ORDER, right.rawLabel) + ); + } + + return right.value - left.value; +} + +function orderIndex(order: string[], value: string): number { + const index = order.indexOf(value); + return index === -1 ? order.length : index; +} + +function normalizeLabel(label: string, sectionName: DistributionSection): string { + if (sectionName === "gender") { + return GENDER_LABELS[label] ?? label; + } + + if (sectionName === "cityTier" && !label.endsWith("城市")) { + return `${label}城市`; + } + + return label; +} + +function resolveSection(display: string | null): DistributionSection | null { + if (!display) { + return null; + } + + return ( + SECTION_BY_DISPLAY.find(([pattern]) => pattern.test(display))?.[1] ?? null + ); +} + +function formatPercent(value: number): string { + const percent = Math.round(value * 1000) / 10; + return `${Number.isInteger(percent) ? percent.toFixed(0) : percent.toFixed(1)}%`; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function readNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim()) { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : null; + } + + return null; +} + +function resolveBaseUrl(): string { + if (typeof location !== "undefined" && location.origin) { + return location.origin; + } + + return "https://xingtu.cn"; +} + +async function defaultFetch(input: string, init?: RequestInit) { + return fetch(input, init); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/content/market/audience-profile-csv.ts b/src/content/market/audience-profile-csv.ts new file mode 100644 index 0000000..51a727f --- /dev/null +++ b/src/content/market/audience-profile-csv.ts @@ -0,0 +1,180 @@ +import { escapeCsvCell } from "../../shared/csv"; +import { + buildMarketCsvColumns, + type CsvColumn +} from "./csv-exporter"; +import type { + AudienceProfileDistributionItem, + AudienceProfileExportRow +} from "./audience-profile-types"; + +type AudienceProfileCsvColumn = { + header: string; + readValue: (row: AudienceProfileExportRow) => string; +}; + +const GENDER_LABELS = ["男性", "女性"]; +const AGE_LABELS = ["18-23", "24-30", "31-40", "41-50", "50+"]; +const PROVINCE_LABELS = [ + "北京", + "天津", + "河北", + "山西", + "内蒙古", + "辽宁", + "吉林", + "黑龙江", + "上海", + "江苏", + "浙江", + "安徽", + "福建", + "江西", + "山东", + "河南", + "湖北", + "湖南", + "广东", + "广西", + "海南", + "重庆", + "四川", + "贵州", + "云南", + "西藏", + "陕西", + "甘肃", + "青海", + "宁夏", + "新疆", + "香港", + "澳门", + "台湾" +]; +const CITY_TIER_LABELS = [ + "一线城市", + "新一线城市", + "二线城市", + "三线城市", + "四线城市", + "五线城市" +]; +const CROWD_LABELS = [ + "精致妈妈", + "都市银发", + "新锐白领", + "资深中产", + "都市蓝领", + "Z世代", + "小镇中老年", + "小镇青年" +]; + +export function buildAudienceProfileCsv( + rows: AudienceProfileExportRow[] +): string { + const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); + const csvColumns = [ + ...marketColumns.map(toAudienceProfileColumn), + ...buildAudienceProfileColumns() + ]; + const headerLine = csvColumns.map((column) => column.header).join(","); + const rowLines = rows.map((row) => + csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",") + ); + + return [headerLine, ...rowLines].join("\n"); +} + +function toAudienceProfileColumn( + column: CsvColumn +): AudienceProfileCsvColumn { + return { + header: column.header, + readValue: (row) => column.readValue(row.record) + }; +} + +function buildAudienceProfileColumns(): AudienceProfileCsvColumn[] { + return [ + { + header: "画像抓取状态", + readValue: (row) => (row.profile.status === "success" ? "成功" : "失败") + }, + { + header: "画像失败原因", + readValue: (row) => + row.profile.status === "failed" ? row.profile.failureReason ?? "" : "" + }, + ...buildFixedDistributionColumns("连接用户", "gender", GENDER_LABELS), + ...buildFixedDistributionColumns("连接用户", "age", AGE_LABELS), + ...buildFixedDistributionColumns("省份", "province", PROVINCE_LABELS), + ...buildRankedDistributionColumns("地域", "cityTop", 10), + ...buildFixedDistributionColumns("城市等级", "cityTier", CITY_TIER_LABELS), + ...buildRankedDistributionColumns("兴趣", "interest", 10), + ...buildFixedDistributionColumns("八大人群", "crowd", CROWD_LABELS) + ]; +} + +function buildFixedDistributionColumns( + prefix: string, + key: "age" | "cityTier" | "crowd" | "gender" | "province", + labels: string[] +): AudienceProfileCsvColumn[] { + return labels.map((label) => ({ + header: `${prefix}-${label}占比`, + readValue: (row) => readDistributionValue(row, key, label) + })); +} + +function buildRankedDistributionColumns( + prefix: string, + key: "cityTop" | "interest", + count: number +): AudienceProfileCsvColumn[] { + const columns: AudienceProfileCsvColumn[] = []; + for (let index = 0; index < count; index += 1) { + columns.push( + { + header: `${prefix}TOP${index + 1}名称`, + readValue: (row) => readDistributionItem(row, key, index)?.label ?? "" + }, + { + header: `${prefix}TOP${index + 1}占比`, + readValue: (row) => readDistributionItem(row, key, index)?.value ?? "" + } + ); + } + + return columns; +} + +function readDistributionValue( + row: AudienceProfileExportRow, + key: "age" | "cityTier" | "crowd" | "gender" | "province", + label: string +): string { + return readDistributionItems(row, key).find((item) => item.label === label)?.value ?? ""; +} + +function readDistributionItem( + row: AudienceProfileExportRow, + key: "cityTop" | "interest", + index: number +): AudienceProfileDistributionItem | undefined { + return readDistributionItems(row, key)[index]; +} + +function readDistributionItems( + row: AudienceProfileExportRow, + key: + | "age" + | "cityTier" + | "cityTop" + | "crowd" + | "gender" + | "interest" + | "province" +): AudienceProfileDistributionItem[] { + return row.profile.status === "success" ? row.profile[key] ?? [] : []; +} diff --git a/src/content/market/audience-profile-types.ts b/src/content/market/audience-profile-types.ts new file mode 100644 index 0000000..c044f76 --- /dev/null +++ b/src/content/market/audience-profile-types.ts @@ -0,0 +1,31 @@ +import type { MarketRecord } from "./types"; + +export interface AudienceProfileDistributionItem { + label: string; + value: string; +} + +export interface AudienceProfileSuccess { + age?: AudienceProfileDistributionItem[]; + cityTier?: AudienceProfileDistributionItem[]; + cityTop?: AudienceProfileDistributionItem[]; + crowd?: AudienceProfileDistributionItem[]; + gender?: AudienceProfileDistributionItem[]; + interest?: AudienceProfileDistributionItem[]; + province?: AudienceProfileDistributionItem[]; + status: "success"; +} + +export interface AudienceProfileFailure { + failureReason?: string; + status: "failed"; +} + +export type AudienceProfileResult = + | AudienceProfileSuccess + | AudienceProfileFailure; + +export interface AudienceProfileExportRow { + profile: AudienceProfileResult; + record: MarketRecord; +} diff --git a/src/content/market/auth-gate.ts b/src/content/market/auth-gate.ts index 4f1efca..76580ea 100644 --- a/src/content/market/auth-gate.ts +++ b/src/content/market/auth-gate.ts @@ -1,6 +1,7 @@ export function renderMarketAuthGate( document: Document, - currentWindow: Window + currentWindow: Window, + message = "请先登录插件" ): HTMLElement { const existingGate = document.querySelector( '[data-market-auth-gate="root"]' @@ -13,10 +14,14 @@ export function renderMarketAuthGate( const root = document.createElement("section"); root.dataset.marketAuthGate = "root"; root.innerHTML = ` - 请先登录插件 +

打开扩展弹窗完成登录后刷新本页

`; + const title = root.querySelector("strong"); + if (title) { + title.textContent = message; + } root .querySelector('[data-market-auth-help="button"]') diff --git a/src/content/market/csv-exporter.ts b/src/content/market/csv-exporter.ts index b4cb64b..5d0b324 100644 --- a/src/content/market/csv-exporter.ts +++ b/src/content/market/csv-exporter.ts @@ -2,7 +2,7 @@ import { normalizeRateDisplay } from "../../shared/rate-normalizer"; import { escapeCsvCell } from "../../shared/csv"; import type { MarketRecord } from "./types"; -type CsvColumn = { +export type CsvColumn = { header: string; readValue: (record: MarketRecord) => string; }; @@ -75,8 +75,7 @@ const BACKEND_METRIC_COLUMNS: CsvColumn[] = [ ]; export function buildMarketCsv(records: MarketRecord[]): string { - const baseColumns = buildBaseColumns(records); - const csvColumns = [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS]; + const csvColumns = buildMarketCsvColumns(records); const headerLine = csvColumns.map((column) => column.header).join(","); const rowLines = records.map((record) => csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",") @@ -85,7 +84,12 @@ export function buildMarketCsv(records: MarketRecord[]): string { return [headerLine, ...rowLines].join("\n"); } -function buildBaseColumns(records: MarketRecord[]): CsvColumn[] { +export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] { + const baseColumns = buildBaseColumns(records); + return [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS]; +} + +export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] { const orderedHeaders: string[] = []; const seenHeaders = new Set(); const excludedHeaders = new Set(["代表视频"]); diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 9ef4d49..9d13089 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -1,4 +1,6 @@ import { buildMarketCsv } from "./csv-exporter"; +import { buildAudienceProfileCsv } from "./audience-profile-csv"; +import { createAudienceProfileClient } from "./audience-profile-client"; import { promptForBatchName } from "./batch-name-dialog"; import { createBatchPayload, type BatchPayload } from "./batch-payload"; import { @@ -27,6 +29,10 @@ import { type AuthStateValue } from "../../shared/auth-messages"; import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages"; +import type { + AudienceProfileExportRow, + AudienceProfileResult +} from "./audience-profile-types"; import type { BackendMetrics, MarketApiResult, @@ -42,9 +48,11 @@ interface MutationObserverLike { } export interface CreateMarketControllerOptions { + buildAudienceProfileCsv?: (rows: AudienceProfileExportRow[]) => string; buildCsv?: (records: MarketRecord[]) => string; document: Document; getAuthState?: () => Promise; + loadAudienceProfile?: (record: MarketRecord) => Promise; loadAuthorMetrics?: (authorId: string) => Promise; searchBackendMetrics?: (starIds: string[]) => Promise< Array @@ -52,7 +60,7 @@ export interface CreateMarketControllerOptions { mutationObserverFactory?: ( callback: MutationCallback ) => MutationObserverLike; - onCsvReady?: (csv: string) => void; + onCsvReady?: (csv: string, filename?: string) => void; promptBatchName?: () => Promise | string | null; resultStore?: ReturnType; submitBatch?: (payload: BatchPayload) => Promise; @@ -61,6 +69,7 @@ export interface CreateMarketControllerOptions { export function createMarketController(options: CreateMarketControllerOptions) { const marketApiClient = createMarketApiClient(); + const audienceProfileClient = createAudienceProfileClient(); const sendRuntimeMessage = createRuntimeMessageSender(); const resultStore = options.resultStore ?? createMarketResultStore(); const loadAuthorMetrics = @@ -69,6 +78,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { 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; const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage)); const mutationObserverFactory = options.mutationObserverFactory ?? @@ -164,6 +176,61 @@ export function createMarketController(options: CreateMarketControllerOptions) { setToolbarBusyState(toolbar, false); } }, + onExportAudienceProfile: async () => { + syncSelectionStateFromDom(); + if (selectedAuthorIds.size === 0) { + setToolbarExportStatus(toolbar, "请先勾选需要导出画像的达人"); + return; + } + + const exportTarget = readToolbarExportTarget(toolbar); + if (!exportTarget.target) { + setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效"); + return; + } + + setToolbarBusyState(toolbar, true); + try { + const selectedRecords = filterRecordsBySelectionStrict( + await exportRecords(exportTarget.target, "画像导出中", { + showDetailedProgress: false + }) + ); + if (selectedRecords.length === 0) { + setToolbarExportStatus(toolbar, "当前导出范围内没有选中的达人"); + return; + } + + const rows: AudienceProfileExportRow[] = []; + for (let index = 0; index < selectedRecords.length; index += 1) { + const record = selectedRecords[index]; + setToolbarExportStatus( + toolbar, + `画像导出中 ${index + 1}/${selectedRecords.length}...` + ); + const profile = await loadAudienceProfile(record); + rows.push({ + profile, + record + }); + } + + if (rows.every((row) => row.profile.status === "failed")) { + setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试"); + return; + } + + options.onCsvReady?.(buildAudienceCsv(rows), buildAudienceProfileFilename()); + setToolbarExportStatus(toolbar, ""); + } catch (error) { + setToolbarExportStatus( + toolbar, + error instanceof Error ? error.message : "画像导出失败,请稍后重试" + ); + } finally { + setToolbarBusyState(toolbar, false); + } + }, onSubmitBatch: async () => { syncSelectionStateFromDom(); const exportTarget = readToolbarExportTarget(toolbar); @@ -562,6 +629,14 @@ export function createMarketController(options: CreateMarketControllerOptions) { return selectedRecords.length > 0 ? selectedRecords : records; } + function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] { + if (selectedAuthorIds.size === 0) { + return []; + } + + return records.filter((record) => selectedAuthorIds.has(record.authorId)); + } + async function prepareCurrentPageForExport(): Promise { await runSyncCycle(); await harvestCurrentPageForExport(); @@ -1270,3 +1345,12 @@ function hasRuntimeMessageSender(): boolean { ).chrome?.runtime?.sendMessage ); } + +function buildAudienceProfileFilename(date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hour = String(date.getHours()).padStart(2, "0"); + const minute = String(date.getMinutes()).padStart(2, "0"); + return `达人连接用户画像_${year}${month}${day}_${hour}${minute}.csv`; +} diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index 0e1e577..60a69fe 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -5,10 +5,12 @@ import type { export interface PluginToolbarHandlers { onExport(): Promise | void; + onExportAudienceProfile(): Promise | void; onSubmitBatch(): Promise | void; } export interface PluginToolbarDom { + audienceProfileExportButton: HTMLButtonElement; batchSubmitButton: HTMLButtonElement; exportButton: HTMLButtonElement; exportCustomPagesInput: HTMLInputElement; @@ -67,6 +69,11 @@ export function ensurePluginToolbar( exportButton.dataset.pluginExport = "button"; exportButton.textContent = "导出CSV"; + const audienceProfileExportButton = document.createElement("button"); + audienceProfileExportButton.type = "button"; + audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button"; + audienceProfileExportButton.textContent = "导出画像CSV"; + const batchSubmitButton = document.createElement("button"); batchSubmitButton.type = "button"; batchSubmitButton.dataset.pluginBatchSubmit = "button"; @@ -80,12 +87,14 @@ export function ensurePluginToolbar( exportRangeSelect, exportCustomPagesInput, exportButton, + audienceProfileExportButton, batchSubmitButton, exportStatusText ); document.body.appendChild(root); applyNativeControlStyles(document, { + audienceProfileExportButton, batchSubmitButton, exportButton, exportCustomPagesInput, @@ -96,12 +105,16 @@ export function ensurePluginToolbar( exportButton.addEventListener("click", () => { void handlers.onExport(); }); + audienceProfileExportButton.addEventListener("click", () => { + void handlers.onExportAudienceProfile(); + }); batchSubmitButton.addEventListener("click", () => { void handlers.onSubmitBatch(); }); exportRangeSelect.addEventListener("change", () => { syncCustomPagesInputVisibility({ batchSubmitButton, + audienceProfileExportButton, exportButton, exportCustomPagesInput, exportRangeSelect, @@ -111,6 +124,7 @@ export function ensurePluginToolbar( }); const toolbarDom = { + audienceProfileExportButton, batchSubmitButton, exportButton, exportCustomPagesInput, @@ -136,6 +150,9 @@ function appendOption( function readToolbarDom(root: HTMLElement): PluginToolbarDom { const toolbarDom = { + audienceProfileExportButton: root.querySelector( + '[data-plugin-export-audience-profile="button"]' + ) as HTMLButtonElement, batchSubmitButton: root.querySelector( '[data-plugin-batch-submit="button"]' ) as HTMLButtonElement, @@ -218,6 +235,7 @@ export function setToolbarBusyState( ): void { [ toolbar.batchSubmitButton, + toolbar.audienceProfileExportButton, toolbar.exportButton, toolbar.exportRangeSelect, toolbar.exportCustomPagesInput @@ -414,6 +432,7 @@ function applyToolbarRootStyles(root: HTMLElement): void { function applyNativeControlStyles( document: Document, controls: { + audienceProfileExportButton: HTMLButtonElement; batchSubmitButton: HTMLButtonElement; exportButton: HTMLButtonElement; exportCustomPagesInput: HTMLInputElement; @@ -430,10 +449,15 @@ function applyNativeControlStyles( if (nativeButton) { controls.exportButton.className = nativeButton.className; + controls.audienceProfileExportButton.className = nativeButton.className; controls.batchSubmitButton.className = nativeButton.className; } - [controls.exportButton, controls.batchSubmitButton].forEach((button) => { + [ + controls.exportButton, + controls.audienceProfileExportButton, + controls.batchSubmitButton + ].forEach((button) => { applyPrimaryButtonStyles(button); button.style.whiteSpace = "nowrap"; }); @@ -484,12 +508,14 @@ function ensurePluginActionButtonTheme(document: Document): void { 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-batch-submit="button"]:hover:not(:disabled) { background-color: #6d1627 !important; border-color: #6d1627 !important; } [data-plugin-export="button"]:active:not(:disabled), + [data-plugin-export-audience-profile="button"]:active:not(:disabled), [data-plugin-batch-submit="button"]:active:not(:disabled) { background-color: #58111f !important; border-color: #58111f !important; @@ -497,12 +523,14 @@ function ensurePluginActionButtonTheme(document: Document): void { } [data-plugin-export="button"]:focus-visible, + [data-plugin-export-audience-profile="button"]:focus-visible, [data-plugin-batch-submit="button"]:focus-visible { outline: none !important; box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important; } [data-plugin-export="button"]:disabled, + [data-plugin-export-audience-profile="button"]:disabled, [data-plugin-batch-submit="button"]:disabled { background-color: #c89ca4 !important; border-color: #c89ca4 !important; diff --git a/tests/audience-profile-client.test.ts b/tests/audience-profile-client.test.ts new file mode 100644 index 0000000..8e39169 --- /dev/null +++ b/tests/audience-profile-client.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + createAudienceProfileClient, + mapAudienceProfileResponse +} from "../src/content/market/audience-profile-client"; + +describe("audience-profile-client", () => { + test("loads connection user audience distributions from Xingtu", async () => { + const fetchImpl = vi.fn(async () => ({ + json: async () => buildAudiencePayload(), + ok: true + })); + const client = createAudienceProfileClient({ + baseUrl: "https://www.xingtu.cn", + fetchImpl, + timeoutMs: 1000 + }); + + const result = await client.loadAudienceProfile({ + authorId: "7294473194298146854", + authorName: "奇奇de海洋", + status: "success" + }); + + expect(fetchImpl).toHaveBeenCalledWith( + "https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=1", + expect.objectContaining({ + credentials: "include", + method: "GET" + }) + ); + expect(result).toEqual( + expect.objectContaining({ + status: "success", + gender: [ + { label: "男性", value: "71.7%" }, + { label: "女性", value: "28.3%" } + ], + cityTop: expect.arrayContaining([{ label: "广州", value: "30.4%" }]) + }) + ); + }); + + test("maps Xingtu audience distribution payload into named profile sections", () => { + const result = mapAudienceProfileResponse(buildAudiencePayload()); + + expect(result).toEqual( + expect.objectContaining({ + status: "success", + age: [ + { label: "18-23", value: "20%" }, + { label: "24-30", value: "30%" }, + { label: "31-40", value: "50%" } + ], + province: [ + { label: "广东", value: "60%" }, + { label: "浙江", value: "40%" } + ], + cityTier: [{ label: "一线城市", value: "100%" }], + interest: [{ label: "随拍", value: "100%" }], + crowd: [{ label: "都市蓝领", value: "100%" }] + }) + ); + }); +}); + +function buildAudiencePayload() { + return { + base_resp: { + status_code: 0, + status_message: "" + }, + distributions: [ + { + distribution_list: [ + { distribution_key: "male", distribution_value: 717 }, + { distribution_key: "female", distribution_value: 283 } + ], + type_display: "性别分布" + }, + { + distribution_list: [ + { distribution_key: "31-40", distribution_value: 50 }, + { distribution_key: "18-23", distribution_value: 20 }, + { distribution_key: "24-30", distribution_value: 30 } + ], + type_display: "年龄分布" + }, + { + distribution_list: [ + { distribution_key: "浙江", distribution_value: 40 }, + { distribution_key: "广东", distribution_value: 60 } + ], + type_display: "省份分布" + }, + { + distribution_list: [ + { distribution_key: "广州", distribution_value: 304 }, + { distribution_key: "北京", distribution_value: 291 }, + { distribution_key: "上海", distribution_value: 405 } + ], + type_display: "城市分布" + }, + { + distribution_list: [{ distribution_key: "一线", distribution_value: 1 }], + type_display: "城市等级分布" + }, + { + distribution_list: [{ distribution_key: "随拍", distribution_value: 1 }], + type_display: "兴趣分布" + }, + { + distribution_list: [{ distribution_key: "都市蓝领", distribution_value: 1 }], + type_display: "八大人群分布" + } + ] + }; +} diff --git a/tests/audience-profile-csv.test.ts b/tests/audience-profile-csv.test.ts new file mode 100644 index 0000000..43a637d --- /dev/null +++ b/tests/audience-profile-csv.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; + +import { buildAudienceProfileCsv } from "../src/content/market/audience-profile-csv"; +import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types"; + +describe("audience-profile-csv", () => { + test("appends structured audience profile columns after the market export columns", () => { + const csv = buildAudienceProfileCsv([ + { + profile: { + age: [{ label: "31-40", value: "50%" }], + cityTier: [{ label: "一线城市", value: "100%" }], + cityTop: [{ label: "广州", value: "30.4%" }], + crowd: [{ label: "都市蓝领", value: "100%" }], + gender: [ + { label: "男性", value: "71.7%" }, + { label: "女性", value: "28.3%" } + ], + interest: [{ label: "随拍", value: "100%" }], + province: [{ label: "广东", value: "60%" }], + status: "success" + }, + record: { + authorId: "123", + authorName: "达人 A", + exportFields: { + 达人信息: "达人 A", + 连接用户数: "300w" + }, + status: "success" + } + } + ] satisfies AudienceProfileExportRow[]); + + const [headerLine, rowLine] = csv.split("\n"); + + expect(headerLine).toContain("达人信息,连接用户数"); + expect(headerLine).toContain("画像抓取状态"); + expect(headerLine).toContain("连接用户-男性占比"); + expect(headerLine).toContain("连接用户-31-40占比"); + expect(headerLine).toContain("省份-广东占比"); + expect(headerLine).toContain("地域TOP1名称,地域TOP1占比"); + expect(headerLine).toContain("城市等级-一线城市占比"); + expect(headerLine).toContain("兴趣TOP1名称,兴趣TOP1占比"); + expect(headerLine).toContain("八大人群-都市蓝领占比"); + expect(rowLine).toContain("成功"); + expect(rowLine).toContain("71.7%"); + expect(rowLine).toContain("广州,30.4%"); + expect(rowLine).toContain("随拍,100%"); + }); + + test("keeps failed profile rows and marks their failure reason", () => { + const csv = buildAudienceProfileCsv([ + { + profile: { + failureReason: "request-failed", + status: "failed" + }, + record: { + authorId: "123", + authorName: "达人 A", + status: "success" + } + } + ] satisfies AudienceProfileExportRow[]); + + const [, rowLine] = csv.split("\n"); + + expect(rowLine).toContain("失败"); + expect(rowLine).toContain("request-failed"); + }); +}); diff --git a/tests/background-auth-controller.test.ts b/tests/background-auth-controller.test.ts index 47e67b9..c6a2e9c 100644 --- a/tests/background-auth-controller.test.ts +++ b/tests/background-auth-controller.test.ts @@ -21,6 +21,27 @@ describe("background-auth-controller", () => { ); }); + test("returns unauthenticated state when the access token cannot be read", async () => { + const controller = createAuthController({ + authClient: { + getAccessToken: vi.fn(async () => { + throw new Error("token expired"); + }), + getIdTokenClaims: vi.fn(), + isAuthenticated: vi.fn(async () => true), + signIn: vi.fn(), + signOut: vi.fn() + } + }); + + await expect(controller.getAuthState()).resolves.toEqual( + expect.objectContaining({ + isAuthenticated: false, + lastError: "token expired" + }) + ); + }); + test("delegates sign in to the auth client", async () => { const signIn = vi.fn(async () => undefined); const controller = createAuthController({ diff --git a/tests/market-auth-gating.test.ts b/tests/market-auth-gating.test.ts index 321ebd2..6ca102f 100644 --- a/tests/market-auth-gating.test.ts +++ b/tests/market-auth-gating.test.ts @@ -24,4 +24,24 @@ describe("market-auth-gating", () => { expect(createMarketController).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("请先登录插件"); }); + + test("shows an expired login message when auth state reports a token error", async () => { + document.body.innerHTML = "
"; + + await bootContentScript({ + createMarketController: vi.fn(), + document, + sendAuthMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { + isAuthenticated: false, + lastError: "token expired" + } + })), + window + }); + + expect(document.body.textContent).toContain("登录已过期,请重新登录"); + }); }); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 0d3110f..5dc6a5e 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -209,6 +209,46 @@ describe("market-content-entry", () => { expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url"); }); + test("booted export callback can download a custom csv filename", async () => { + const createMarketController = vi.fn(() => ({ + ready: Promise.resolve() + })); + let clickedDownload: { download: string; href: string } | null = null; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement + ) { + clickedDownload = { + download: this.download, + href: this.href + }; + }); + + window.history.replaceState({}, "", "/ad/creator/market"); + Object.defineProperty(window.URL, "createObjectURL", { + configurable: true, + value: vi.fn(() => "blob:test-url") + }); + Object.defineProperty(window.URL, "revokeObjectURL", { + configurable: true, + value: vi.fn() + }); + + const { bootContentScript } = await import("../src/content/index"); + await bootContentScript({ + createMarketController, + sendAuthMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { isAuthenticated: true } + })) + }); + + const controllerOptions = createMarketController.mock.calls[0]?.[0]; + controllerOptions.onCsvReady("列1,列2\n值1,值2", "达人连接用户画像_20260518_1530.csv"); + + expect(clickedDownload?.download).toBe("达人连接用户画像_20260518_1530.csv"); + }); + test("booted export callback sends the csv to extension runtime when available", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() @@ -245,6 +285,7 @@ describe("market-content-entry", () => { expect(sendMessage).toHaveBeenCalledWith( expect.objectContaining({ csv: "列1,列2\n值1,值2", + filename: expect.stringMatching(/^star-chart-search-enhancer-/), type: "download-market-csv" }) ); @@ -284,6 +325,9 @@ describe("market-content-entry", () => { expect(document.body.firstElementChild).not.toBe(toolbar); expect(document.querySelector('[data-plugin-export-range="select"]')).not.toBeNull(); expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull(); + expect( + document.querySelector('[data-plugin-export-audience-profile="button"]') + ).not.toBeNull(); expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull(); expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull(); @@ -293,10 +337,15 @@ describe("market-content-entry", () => { const batchSubmitButton = document.querySelector( '[data-plugin-batch-submit="button"]' ) as HTMLButtonElement | null; + const audienceProfileExportButton = document.querySelector( + '[data-plugin-export-audience-profile="button"]' + ) as HTMLButtonElement | null; expect(exportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); + expect(audienceProfileExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); expect(exportButton?.style.color).toBe("rgb(255, 255, 255)"); expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)"); + expect(audienceProfileExportButton?.style.color).toBe("rgb(255, 255, 255)"); }); test("remounts the plugin action bar when the native market action row appears later", async () => { @@ -1535,6 +1584,92 @@ describe("market-content-entry", () => { ]); }); + test("audience profile export requires selected creators", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); + const loadAudienceProfile = vi.fn(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildAudienceProfileCsv, + document, + loadAudienceProfile, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady: vi.fn(), + window + })); + + await controller.ready; + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + + click('[data-plugin-export-audience-profile="button"]'); + await flush(); + + expect(loadAudienceProfile).not.toHaveBeenCalled(); + expect(buildAudienceProfileCsv).not.toHaveBeenCalled(); + expect( + document.querySelector('[data-plugin-export-status="text"]')?.textContent + ).toContain("请先勾选需要导出画像的达人"); + }); + + test("audience profile export loads profiles only for selected creators", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ]); + const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); + const loadAudienceProfile = vi.fn(async () => ({ + gender: [{ label: "男性", value: "60%" }], + status: "success" as const + })); + const onCsvReady = vi.fn(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildAudienceProfileCsv, + document, + loadAudienceProfile, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + window + })); + + await controller.ready; + clickSelectionCheckboxForAuthor("222"); + 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: "222" }) + ); + expect(buildAudienceProfileCsv).toHaveBeenCalledWith([ + { + profile: { + gender: [{ label: "男性", value: "60%" }], + status: "success" + }, + record: expect.objectContaining({ authorId: "222" }) + } + ]); + expect(onCsvReady).toHaveBeenCalledWith( + "profile-csv", + expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/) + ); + }); + test( "selected export keeps a generic loading status while exporting the default paged range", async () => {