feat: add selected audience profile csv export
This commit is contained in:
@@ -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);
|
||||
},
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { AuthConfig } from "../../shared/auth-config";
|
||||
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||
|
||||
export function createLoggedOutAuthState(
|
||||
config?: Pick<AuthConfig, "apiResource">
|
||||
config?: Pick<AuthConfig, "apiResource">,
|
||||
lastError?: string | null
|
||||
): AuthStateValue {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
lastError: lastError ?? null,
|
||||
resource: config?.apiResource ?? null
|
||||
};
|
||||
}
|
||||
|
||||
+29
-7
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] ?? [] : [];
|
||||
}
|
||||
@@ -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(
|
||||
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 = `
|
||||
<strong>请先登录插件</strong>
|
||||
<strong></strong>
|
||||
<p>打开扩展弹窗完成登录后刷新本页</p>
|
||||
<button type="button" data-market-auth-help="button">去登录</button>
|
||||
`;
|
||||
const title = root.querySelector("strong");
|
||||
if (title) {
|
||||
title.textContent = message;
|
||||
}
|
||||
|
||||
root
|
||||
.querySelector('[data-market-auth-help="button"]')
|
||||
|
||||
@@ -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<string>();
|
||||
const excludedHeaders = new Set(["代表视频"]);
|
||||
|
||||
@@ -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<AuthStateValue>;
|
||||
loadAudienceProfile?: (record: MarketRecord) => Promise<AudienceProfileResult>;
|
||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||
Array<BackendMetrics & { starId: string }>
|
||||
@@ -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> | string | null;
|
||||
resultStore?: ReturnType<typeof createMarketResultStore>;
|
||||
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
||||
@@ -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<void> {
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import type {
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
onExport(): Promise<void> | void;
|
||||
onExportAudienceProfile(): Promise<void> | void;
|
||||
onSubmitBatch(): Promise<void> | 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;
|
||||
|
||||
Reference in New Issue
Block a user