feat: add selected audience profile csv export
This commit is contained in:
parent
03c2fe0cc7
commit
66bc49d498
@ -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/<authorId>` 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<unknown>;
|
||||||
|
readProfileFromPage?: (authorId: string) => Promise<unknown>;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
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<AudienceProfileResult>`. 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.
|
||||||
@ -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.
|
||||||
@ -26,6 +26,15 @@ export function createAuthController(options: {
|
|||||||
return createLoggedOutAuthState(config);
|
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();
|
const claims = await options.authClient.getIdTokenClaims();
|
||||||
return createLoggedInAuthState(claims, config);
|
return createLoggedInAuthState(claims, config);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,10 +2,12 @@ import type { AuthConfig } from "../../shared/auth-config";
|
|||||||
import type { AuthStateValue } from "../../shared/auth-messages";
|
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||||
|
|
||||||
export function createLoggedOutAuthState(
|
export function createLoggedOutAuthState(
|
||||||
config?: Pick<AuthConfig, "apiResource">
|
config?: Pick<AuthConfig, "apiResource">,
|
||||||
|
lastError?: string | null
|
||||||
): AuthStateValue {
|
): AuthStateValue {
|
||||||
return {
|
return {
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
lastError: lastError ?? null,
|
||||||
resource: config?.apiResource ?? null
|
resource: config?.apiResource ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,11 @@ export async function bootContentScript(
|
|||||||
const authState = await readAuthState(sendAuthMessage);
|
const authState = await readAuthState(sendAuthMessage);
|
||||||
if (!authState?.isAuthenticated) {
|
if (!authState?.isAuthenticated) {
|
||||||
await waitForBodyReady(currentDocument, currentWindow);
|
await waitForBodyReady(currentDocument, currentWindow);
|
||||||
renderMarketAuthGate(currentDocument, currentWindow);
|
renderMarketAuthGate(
|
||||||
|
currentDocument,
|
||||||
|
currentWindow,
|
||||||
|
isExpiredAuthState(authState) ? "登录已过期,请重新登录" : undefined
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ready: Promise.resolve()
|
ready: Promise.resolve()
|
||||||
};
|
};
|
||||||
@ -54,12 +58,17 @@ export async function bootContentScript(
|
|||||||
|
|
||||||
return controllerFactory({
|
return controllerFactory({
|
||||||
document: currentDocument,
|
document: currentDocument,
|
||||||
onCsvReady: (csv: string) => {
|
onCsvReady: (csv: string, filename?: string) => {
|
||||||
|
if (filename) {
|
||||||
|
downloadCsv(currentDocument, currentWindow, csv, filename);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (requestCsvDownload(csv)) {
|
if (requestCsvDownload(csv)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadCsv(currentDocument, currentWindow, csv);
|
downloadCsv(currentDocument, currentWindow, csv, filename);
|
||||||
},
|
},
|
||||||
window: currentWindow
|
window: currentWindow
|
||||||
});
|
});
|
||||||
@ -112,7 +121,7 @@ function bootstrapContentScript() {
|
|||||||
|
|
||||||
bootstrapContentScript();
|
bootstrapContentScript();
|
||||||
|
|
||||||
function requestCsvDownload(csv: string): boolean {
|
function requestCsvDownload(csv: string, filename?: string): boolean {
|
||||||
const runtime = (
|
const runtime = (
|
||||||
globalThis as typeof globalThis & {
|
globalThis as typeof globalThis & {
|
||||||
chrome?: { runtime?: ChromeRuntimeLike };
|
chrome?: { runtime?: ChromeRuntimeLike };
|
||||||
@ -125,7 +134,7 @@ function requestCsvDownload(csv: string): boolean {
|
|||||||
|
|
||||||
runtime.sendMessage({
|
runtime.sendMessage({
|
||||||
csv,
|
csv,
|
||||||
filename: `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
|
filename: filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
|
||||||
type: DOWNLOAD_MARKET_CSV_MESSAGE
|
type: DOWNLOAD_MARKET_CSV_MESSAGE
|
||||||
});
|
});
|
||||||
return true;
|
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], {
|
const blob = new Blob(["\uFEFF", csv], {
|
||||||
type: "text/csv;charset=utf-8"
|
type: "text/csv;charset=utf-8"
|
||||||
});
|
});
|
||||||
const objectUrl = window.URL.createObjectURL(blob);
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = objectUrl;
|
link.href = objectUrl;
|
||||||
link.download = `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
|
link.download = filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
@ -183,6 +197,14 @@ function formatTimestampForFilename(): string {
|
|||||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
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) {
|
function installMarketPageBridge(document: Document) {
|
||||||
if (
|
if (
|
||||||
document.documentElement.querySelector(
|
document.documentElement.querySelector(
|
||||||
|
|||||||
273
src/content/market/audience-profile-client.ts
Normal file
273
src/content/market/audience-profile-client.ts
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
import type { MarketRecord } from "./types";
|
||||||
|
import type {
|
||||||
|
AudienceProfileDistributionItem,
|
||||||
|
AudienceProfileResult,
|
||||||
|
AudienceProfileSuccess
|
||||||
|
} from "./audience-profile-types";
|
||||||
|
|
||||||
|
interface FetchResponseLike {
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
ok: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = (
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit
|
||||||
|
) => Promise<FetchResponseLike>;
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
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<AudienceProfileResult> {
|
||||||
|
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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
180
src/content/market/audience-profile-csv.ts
Normal file
180
src/content/market/audience-profile-csv.ts
Normal file
@ -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] ?? [] : [];
|
||||||
|
}
|
||||||
31
src/content/market/audience-profile-types.ts
Normal file
31
src/content/market/audience-profile-types.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
export function renderMarketAuthGate(
|
export function renderMarketAuthGate(
|
||||||
document: Document,
|
document: Document,
|
||||||
currentWindow: Window
|
currentWindow: Window,
|
||||||
|
message = "请先登录插件"
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
const existingGate = document.querySelector(
|
const existingGate = document.querySelector(
|
||||||
'[data-market-auth-gate="root"]'
|
'[data-market-auth-gate="root"]'
|
||||||
@ -13,10 +14,14 @@ export function renderMarketAuthGate(
|
|||||||
const root = document.createElement("section");
|
const root = document.createElement("section");
|
||||||
root.dataset.marketAuthGate = "root";
|
root.dataset.marketAuthGate = "root";
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<strong>请先登录插件</strong>
|
<strong></strong>
|
||||||
<p>打开扩展弹窗完成登录后刷新本页</p>
|
<p>打开扩展弹窗完成登录后刷新本页</p>
|
||||||
<button type="button" data-market-auth-help="button">去登录</button>
|
<button type="button" data-market-auth-help="button">去登录</button>
|
||||||
`;
|
`;
|
||||||
|
const title = root.querySelector("strong");
|
||||||
|
if (title) {
|
||||||
|
title.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
root
|
root
|
||||||
.querySelector('[data-market-auth-help="button"]')
|
.querySelector('[data-market-auth-help="button"]')
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
|||||||
import { escapeCsvCell } from "../../shared/csv";
|
import { escapeCsvCell } from "../../shared/csv";
|
||||||
import type { MarketRecord } from "./types";
|
import type { MarketRecord } from "./types";
|
||||||
|
|
||||||
type CsvColumn = {
|
export type CsvColumn = {
|
||||||
header: string;
|
header: string;
|
||||||
readValue: (record: MarketRecord) => string;
|
readValue: (record: MarketRecord) => string;
|
||||||
};
|
};
|
||||||
@ -75,8 +75,7 @@ const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function buildMarketCsv(records: MarketRecord[]): string {
|
export function buildMarketCsv(records: MarketRecord[]): string {
|
||||||
const baseColumns = buildBaseColumns(records);
|
const csvColumns = buildMarketCsvColumns(records);
|
||||||
const csvColumns = [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
|
|
||||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||||
const rowLines = records.map((record) =>
|
const rowLines = records.map((record) =>
|
||||||
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||||
@ -85,7 +84,12 @@ export function buildMarketCsv(records: MarketRecord[]): string {
|
|||||||
return [headerLine, ...rowLines].join("\n");
|
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 orderedHeaders: string[] = [];
|
||||||
const seenHeaders = new Set<string>();
|
const seenHeaders = new Set<string>();
|
||||||
const excludedHeaders = new Set(["代表视频"]);
|
const excludedHeaders = new Set(["代表视频"]);
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { buildMarketCsv } from "./csv-exporter";
|
import { buildMarketCsv } from "./csv-exporter";
|
||||||
|
import { buildAudienceProfileCsv } from "./audience-profile-csv";
|
||||||
|
import { createAudienceProfileClient } from "./audience-profile-client";
|
||||||
import { promptForBatchName } from "./batch-name-dialog";
|
import { promptForBatchName } from "./batch-name-dialog";
|
||||||
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
||||||
import {
|
import {
|
||||||
@ -27,6 +29,10 @@ import {
|
|||||||
type AuthStateValue
|
type AuthStateValue
|
||||||
} from "../../shared/auth-messages";
|
} from "../../shared/auth-messages";
|
||||||
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
|
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
|
||||||
|
import type {
|
||||||
|
AudienceProfileExportRow,
|
||||||
|
AudienceProfileResult
|
||||||
|
} from "./audience-profile-types";
|
||||||
import type {
|
import type {
|
||||||
BackendMetrics,
|
BackendMetrics,
|
||||||
MarketApiResult,
|
MarketApiResult,
|
||||||
@ -42,9 +48,11 @@ interface MutationObserverLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMarketControllerOptions {
|
export interface CreateMarketControllerOptions {
|
||||||
|
buildAudienceProfileCsv?: (rows: AudienceProfileExportRow[]) => string;
|
||||||
buildCsv?: (records: MarketRecord[]) => string;
|
buildCsv?: (records: MarketRecord[]) => string;
|
||||||
document: Document;
|
document: Document;
|
||||||
getAuthState?: () => Promise<AuthStateValue>;
|
getAuthState?: () => Promise<AuthStateValue>;
|
||||||
|
loadAudienceProfile?: (record: MarketRecord) => Promise<AudienceProfileResult>;
|
||||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||||
Array<BackendMetrics & { starId: string }>
|
Array<BackendMetrics & { starId: string }>
|
||||||
@ -52,7 +60,7 @@ export interface CreateMarketControllerOptions {
|
|||||||
mutationObserverFactory?: (
|
mutationObserverFactory?: (
|
||||||
callback: MutationCallback
|
callback: MutationCallback
|
||||||
) => MutationObserverLike;
|
) => MutationObserverLike;
|
||||||
onCsvReady?: (csv: string) => void;
|
onCsvReady?: (csv: string, filename?: string) => void;
|
||||||
promptBatchName?: () => Promise<string | null> | string | null;
|
promptBatchName?: () => Promise<string | null> | string | null;
|
||||||
resultStore?: ReturnType<typeof createMarketResultStore>;
|
resultStore?: ReturnType<typeof createMarketResultStore>;
|
||||||
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
||||||
@ -61,6 +69,7 @@ export interface CreateMarketControllerOptions {
|
|||||||
|
|
||||||
export function createMarketController(options: CreateMarketControllerOptions) {
|
export function createMarketController(options: CreateMarketControllerOptions) {
|
||||||
const marketApiClient = createMarketApiClient();
|
const marketApiClient = createMarketApiClient();
|
||||||
|
const audienceProfileClient = createAudienceProfileClient();
|
||||||
const sendRuntimeMessage = createRuntimeMessageSender();
|
const sendRuntimeMessage = createRuntimeMessageSender();
|
||||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||||
const loadAuthorMetrics =
|
const loadAuthorMetrics =
|
||||||
@ -69,6 +78,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
options.searchBackendMetrics ??
|
options.searchBackendMetrics ??
|
||||||
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
||||||
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
||||||
|
const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv;
|
||||||
|
const loadAudienceProfile =
|
||||||
|
options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile;
|
||||||
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
|
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
|
||||||
const mutationObserverFactory =
|
const mutationObserverFactory =
|
||||||
options.mutationObserverFactory ??
|
options.mutationObserverFactory ??
|
||||||
@ -164,6 +176,61 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
setToolbarBusyState(toolbar, false);
|
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 () => {
|
onSubmitBatch: async () => {
|
||||||
syncSelectionStateFromDom();
|
syncSelectionStateFromDom();
|
||||||
const exportTarget = readToolbarExportTarget(toolbar);
|
const exportTarget = readToolbarExportTarget(toolbar);
|
||||||
@ -562,6 +629,14 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return selectedRecords.length > 0 ? selectedRecords : records;
|
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<void> {
|
async function prepareCurrentPageForExport(): Promise<void> {
|
||||||
await runSyncCycle();
|
await runSyncCycle();
|
||||||
await harvestCurrentPageForExport();
|
await harvestCurrentPageForExport();
|
||||||
@ -1270,3 +1345,12 @@ function hasRuntimeMessageSender(): boolean {
|
|||||||
).chrome?.runtime?.sendMessage
|
).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`;
|
||||||
|
}
|
||||||
|
|||||||
@ -5,10 +5,12 @@ import type {
|
|||||||
|
|
||||||
export interface PluginToolbarHandlers {
|
export interface PluginToolbarHandlers {
|
||||||
onExport(): Promise<void> | void;
|
onExport(): Promise<void> | void;
|
||||||
|
onExportAudienceProfile(): Promise<void> | void;
|
||||||
onSubmitBatch(): Promise<void> | void;
|
onSubmitBatch(): Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginToolbarDom {
|
export interface PluginToolbarDom {
|
||||||
|
audienceProfileExportButton: HTMLButtonElement;
|
||||||
batchSubmitButton: HTMLButtonElement;
|
batchSubmitButton: HTMLButtonElement;
|
||||||
exportButton: HTMLButtonElement;
|
exportButton: HTMLButtonElement;
|
||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
@ -67,6 +69,11 @@ export function ensurePluginToolbar(
|
|||||||
exportButton.dataset.pluginExport = "button";
|
exportButton.dataset.pluginExport = "button";
|
||||||
exportButton.textContent = "导出CSV";
|
exportButton.textContent = "导出CSV";
|
||||||
|
|
||||||
|
const audienceProfileExportButton = document.createElement("button");
|
||||||
|
audienceProfileExportButton.type = "button";
|
||||||
|
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
|
||||||
|
audienceProfileExportButton.textContent = "导出画像CSV";
|
||||||
|
|
||||||
const batchSubmitButton = document.createElement("button");
|
const batchSubmitButton = document.createElement("button");
|
||||||
batchSubmitButton.type = "button";
|
batchSubmitButton.type = "button";
|
||||||
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||||
@ -80,12 +87,14 @@ export function ensurePluginToolbar(
|
|||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportButton,
|
exportButton,
|
||||||
|
audienceProfileExportButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportStatusText
|
exportStatusText
|
||||||
);
|
);
|
||||||
|
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
applyNativeControlStyles(document, {
|
applyNativeControlStyles(document, {
|
||||||
|
audienceProfileExportButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
@ -96,12 +105,16 @@ export function ensurePluginToolbar(
|
|||||||
exportButton.addEventListener("click", () => {
|
exportButton.addEventListener("click", () => {
|
||||||
void handlers.onExport();
|
void handlers.onExport();
|
||||||
});
|
});
|
||||||
|
audienceProfileExportButton.addEventListener("click", () => {
|
||||||
|
void handlers.onExportAudienceProfile();
|
||||||
|
});
|
||||||
batchSubmitButton.addEventListener("click", () => {
|
batchSubmitButton.addEventListener("click", () => {
|
||||||
void handlers.onSubmitBatch();
|
void handlers.onSubmitBatch();
|
||||||
});
|
});
|
||||||
exportRangeSelect.addEventListener("change", () => {
|
exportRangeSelect.addEventListener("change", () => {
|
||||||
syncCustomPagesInputVisibility({
|
syncCustomPagesInputVisibility({
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
|
audienceProfileExportButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
@ -111,6 +124,7 @@ export function ensurePluginToolbar(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
|
audienceProfileExportButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
@ -136,6 +150,9 @@ function appendOption(
|
|||||||
|
|
||||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
|
audienceProfileExportButton: root.querySelector(
|
||||||
|
'[data-plugin-export-audience-profile="button"]'
|
||||||
|
) as HTMLButtonElement,
|
||||||
batchSubmitButton: root.querySelector(
|
batchSubmitButton: root.querySelector(
|
||||||
'[data-plugin-batch-submit="button"]'
|
'[data-plugin-batch-submit="button"]'
|
||||||
) as HTMLButtonElement,
|
) as HTMLButtonElement,
|
||||||
@ -218,6 +235,7 @@ export function setToolbarBusyState(
|
|||||||
): void {
|
): void {
|
||||||
[
|
[
|
||||||
toolbar.batchSubmitButton,
|
toolbar.batchSubmitButton,
|
||||||
|
toolbar.audienceProfileExportButton,
|
||||||
toolbar.exportButton,
|
toolbar.exportButton,
|
||||||
toolbar.exportRangeSelect,
|
toolbar.exportRangeSelect,
|
||||||
toolbar.exportCustomPagesInput
|
toolbar.exportCustomPagesInput
|
||||||
@ -414,6 +432,7 @@ function applyToolbarRootStyles(root: HTMLElement): void {
|
|||||||
function applyNativeControlStyles(
|
function applyNativeControlStyles(
|
||||||
document: Document,
|
document: Document,
|
||||||
controls: {
|
controls: {
|
||||||
|
audienceProfileExportButton: HTMLButtonElement;
|
||||||
batchSubmitButton: HTMLButtonElement;
|
batchSubmitButton: HTMLButtonElement;
|
||||||
exportButton: HTMLButtonElement;
|
exportButton: HTMLButtonElement;
|
||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
@ -430,10 +449,15 @@ function applyNativeControlStyles(
|
|||||||
|
|
||||||
if (nativeButton) {
|
if (nativeButton) {
|
||||||
controls.exportButton.className = nativeButton.className;
|
controls.exportButton.className = nativeButton.className;
|
||||||
|
controls.audienceProfileExportButton.className = nativeButton.className;
|
||||||
controls.batchSubmitButton.className = nativeButton.className;
|
controls.batchSubmitButton.className = nativeButton.className;
|
||||||
}
|
}
|
||||||
|
|
||||||
[controls.exportButton, controls.batchSubmitButton].forEach((button) => {
|
[
|
||||||
|
controls.exportButton,
|
||||||
|
controls.audienceProfileExportButton,
|
||||||
|
controls.batchSubmitButton
|
||||||
|
].forEach((button) => {
|
||||||
applyPrimaryButtonStyles(button);
|
applyPrimaryButtonStyles(button);
|
||||||
button.style.whiteSpace = "nowrap";
|
button.style.whiteSpace = "nowrap";
|
||||||
});
|
});
|
||||||
@ -484,12 +508,14 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
|||||||
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
[data-plugin-export="button"]:hover:not(:disabled),
|
[data-plugin-export="button"]:hover:not(:disabled),
|
||||||
|
[data-plugin-export-audience-profile="button"]:hover:not(:disabled),
|
||||||
[data-plugin-batch-submit="button"]:hover:not(:disabled) {
|
[data-plugin-batch-submit="button"]:hover:not(:disabled) {
|
||||||
background-color: #6d1627 !important;
|
background-color: #6d1627 !important;
|
||||||
border-color: #6d1627 !important;
|
border-color: #6d1627 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-plugin-export="button"]:active:not(:disabled),
|
[data-plugin-export="button"]:active:not(:disabled),
|
||||||
|
[data-plugin-export-audience-profile="button"]:active:not(:disabled),
|
||||||
[data-plugin-batch-submit="button"]:active:not(:disabled) {
|
[data-plugin-batch-submit="button"]:active:not(:disabled) {
|
||||||
background-color: #58111f !important;
|
background-color: #58111f !important;
|
||||||
border-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="button"]:focus-visible,
|
||||||
|
[data-plugin-export-audience-profile="button"]:focus-visible,
|
||||||
[data-plugin-batch-submit="button"]:focus-visible {
|
[data-plugin-batch-submit="button"]:focus-visible {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
|
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-plugin-export="button"]:disabled,
|
[data-plugin-export="button"]:disabled,
|
||||||
|
[data-plugin-export-audience-profile="button"]:disabled,
|
||||||
[data-plugin-batch-submit="button"]:disabled {
|
[data-plugin-batch-submit="button"]:disabled {
|
||||||
background-color: #c89ca4 !important;
|
background-color: #c89ca4 !important;
|
||||||
border-color: #c89ca4 !important;
|
border-color: #c89ca4 !important;
|
||||||
|
|||||||
119
tests/audience-profile-client.test.ts
Normal file
119
tests/audience-profile-client.test.ts
Normal file
@ -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: "八大人群分布"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
72
tests/audience-profile-csv.test.ts
Normal file
72
tests/audience-profile-csv.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 () => {
|
test("delegates sign in to the auth client", async () => {
|
||||||
const signIn = vi.fn(async () => undefined);
|
const signIn = vi.fn(async () => undefined);
|
||||||
const controller = createAuthController({
|
const controller = createAuthController({
|
||||||
|
|||||||
@ -24,4 +24,24 @@ describe("market-auth-gating", () => {
|
|||||||
expect(createMarketController).not.toHaveBeenCalled();
|
expect(createMarketController).not.toHaveBeenCalled();
|
||||||
expect(document.body.textContent).toContain("请先登录插件");
|
expect(document.body.textContent).toContain("请先登录插件");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows an expired login message when auth state reports a token error", async () => {
|
||||||
|
document.body.innerHTML = "<div></div>";
|
||||||
|
|
||||||
|
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("登录已过期,请重新登录");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -209,6 +209,46 @@ describe("market-content-entry", () => {
|
|||||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url");
|
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 () => {
|
test("booted export callback sends the csv to extension runtime when available", async () => {
|
||||||
const createMarketController = vi.fn(() => ({
|
const createMarketController = vi.fn(() => ({
|
||||||
ready: Promise.resolve()
|
ready: Promise.resolve()
|
||||||
@ -245,6 +285,7 @@ describe("market-content-entry", () => {
|
|||||||
expect(sendMessage).toHaveBeenCalledWith(
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
csv: "列1,列2\n值1,值2",
|
csv: "列1,列2\n值1,值2",
|
||||||
|
filename: expect.stringMatching(/^star-chart-search-enhancer-/),
|
||||||
type: "download-market-csv"
|
type: "download-market-csv"
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -284,6 +325,9 @@ describe("market-content-entry", () => {
|
|||||||
expect(document.body.firstElementChild).not.toBe(toolbar);
|
expect(document.body.firstElementChild).not.toBe(toolbar);
|
||||||
expect(document.querySelector('[data-plugin-export-range="select"]')).not.toBeNull();
|
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="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-batch-submit="button"]')).not.toBeNull();
|
||||||
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
|
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
|
||||||
|
|
||||||
@ -293,10 +337,15 @@ describe("market-content-entry", () => {
|
|||||||
const batchSubmitButton = document.querySelector(
|
const batchSubmitButton = document.querySelector(
|
||||||
'[data-plugin-batch-submit="button"]'
|
'[data-plugin-batch-submit="button"]'
|
||||||
) as HTMLButtonElement | null;
|
) 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(exportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
||||||
expect(batchSubmitButton?.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(exportButton?.style.color).toBe("rgb(255, 255, 255)");
|
||||||
expect(batchSubmitButton?.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 () => {
|
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(
|
test(
|
||||||
"selected export keeps a generic loading status while exporting the default paged range",
|
"selected export keeps a generic loading status while exporting the default paged range",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user