28 Commits
Author SHA1 Message Date
wangshaoqing 8aca116949 chore: refresh release archive
continuous-integration/drone/tag Build is passing
2026-05-25 16:55:00 +08:00
wangshaoqing 3a80ef9859 chore: unify zip installs around dist 2026-05-25 16:48:59 +08:00
wangshaoqing 1a7b025aee docs: add unified dist distribution plan 2026-05-25 16:41:45 +08:00
wangshaoqing b3b916c6bc docs: add unified dist distribution design spec 2026-05-25 16:37:27 +08:00
wangshaoqing e1cf2970da feat: redesign popup update panel 2026-05-25 15:50:51 +08:00
wangshaoqing 18b9d8eee5 docs: add popup update panel implementation plan 2026-05-25 15:29:16 +08:00
wangshaoqing 48362bd85f docs: add popup update panel design spec 2026-05-25 15:20:56 +08:00
wangshaoqing 6e06a67bde chore: remove dist-release and keep dist only 2026-05-25 14:42:17 +08:00
wangshaoqing d7b35d6149 chore: use dist as the single extension output dir 2026-05-25 14:33:52 +08:00
wangshaoqing f683b1db4f fix: keep release archive wrapped in a folder
continuous-integration/drone/tag Build is passing
2026-05-25 14:22:59 +08:00
wangshaoqing 66e814b30f fix: package release zip without system binary
continuous-integration/drone/tag Build is passing
2026-05-25 11:57:22 +08:00
wangshaoqing d302614b99 feat: automate tag release pipeline
continuous-integration/drone/tag Build is failing
2026-05-25 11:26:02 +08:00
wangshaoqing 57e4dc72aa feat: switch extension updates to COS 2026-05-25 10:32:39 +08:00
wangshaoqing 8fa9fc4469 docs: add COS update design spec 2026-05-25 10:19:14 +08:00
wangshaoqing 02d9063a11 feat: add extension update check 2026-05-19 18:50:03 +08:00
wangshaoqing 703a095c08 chore: hide export range selector 2026-05-19 17:57:09 +08:00
wangshaoqing 75294ca8c7 chore: simplify export action labels 2026-05-19 17:39:57 +08:00
wangshaoqing 303efcdc8f chore: clarify export labels and tooltips 2026-05-19 16:46:49 +08:00
wangshaoqing 50933af0a6 feat: allow selecting audience export fields 2026-05-19 16:26:04 +08:00
wangshaoqing db95a1f565 chore: refresh internal release package 2026-05-19 13:51:27 +08:00
wangshaoqing 37f7e0b5e6 fix: hydrate id export metrics and remove new tier columns 2026-05-18 19:50:14 +08:00
wangshaoqing 38da39589f feat: export audience profiles by author ids 2026-05-18 19:24:15 +08:00
wangshaoqing 39c4191a95 fix: format business ability estimates 2026-05-18 18:40:34 +08:00
wangshaoqing 249e6a5971 feat: export business ability metrics 2026-05-18 18:27:47 +08:00
wangshaoqing ca9ce02db5 fix: match Xingtu profile endpoints 2026-05-18 17:54:37 +08:00
wangshaoqing c8287e8d8e fix: fill missing audience profile buckets 2026-05-18 17:40:21 +08:00
wangshaoqing 26ae3bb4b6 feat: refine audience profile csv columns 2026-05-18 17:25:58 +08:00
wangshaoqing 66bc49d498 feat: add selected audience profile csv export 2026-05-18 16:59:05 +08:00
85 changed files with 7145 additions and 8873 deletions
+34
View File
@@ -0,0 +1,34 @@
kind: pipeline
type: docker
name: release-tag
trigger:
event:
- tag
steps:
- name: install
image: node:20-alpine
commands:
- npm ci
- name: test
image: node:20-alpine
depends_on:
- install
commands:
- npm test
- name: release
image: node:20-alpine
depends_on:
- test
environment:
COS_BUCKET: wksgx-1343191620
COS_REGION: ap-nanjing
COS_SECRET_ID:
from_secret: cos_secret_id
COS_SECRET_KEY:
from_secret: cos_secret_key
commands:
- npm run release:tag
-2
View File
@@ -2,12 +2,10 @@
.old-reference/
.local/
dist/
# dist-release/
# release/
node_modules/
# Build artifacts
dist-release/
dist-release.pem
dist-release.crx
+12 -5
View File
@@ -21,9 +21,7 @@
- `src/`
- 插件源码
- `dist/`
- 开发构建产物
- `dist-release/`
- 内部分发构建产物
- 开发和发布构建产物
- `release/`
- 打包后的内部交付压缩包
- `docs/`
@@ -76,16 +74,25 @@ npm run build:release
npm run package:internal
```
生成更新清单:
```bash
npm run write:latest
```
生成结果:
- 构建目录:`dist-release/`
- 构建目录:`dist/`
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
- 更新清单:`release/latest.json`
说明:
- 这个压缩包不是给 Chrome 商店上传的
- 它是发给公司内部同事使用的交付包
- 同事收到后需要解压,再到 `chrome://extensions``Load unpacked`
- COS 发布时,`latest.json` 放在 `star-chart-search-enhancer/latest.json`,ZIP 和 PDF 放在对应版本目录下
- 打 tag 后会触发 Drone 发布,推荐格式:`0.MMDD.N`
---
@@ -97,7 +104,7 @@ npm run package:internal
2. 打开 `chrome://extensions`
3. 打开右上角 `开发者模式`
4. 点击 `加载已解压的扩展程序`
5. 选择解压后的插件文件夹
5. 选择解压后的 `dist/` 文件夹
安装后请确认扩展 ID 是:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none">
<defs>
<linearGradient id="bg" x1="16" y1="12" x2="114" y2="116" gradientUnits="userSpaceOnUse">
<stop stop-color="#9F1239"/>
<stop offset="1" stop-color="#4C0519"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#bg)"/>
<path d="M34 80L50 62L64 70L83 46" stroke="#FFF7ED" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
<circle cx="86" cy="44" r="8" fill="#FFF7ED"/>
<path d="M79 80C79 71.7157 85.7157 65 94 65C102.284 65 109 71.7157 109 80C109 88.2843 102.284 95 94 95C85.7157 95 79 88.2843 79 80Z" stroke="#FFF7ED" stroke-width="8"/>
<path d="M104 91L114 101" stroke="#FFF7ED" stroke-linecap="round" stroke-width="8"/>
</svg>

Before

Width:  |  Height:  |  Size: 822 B

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-585
View File
@@ -1,585 +0,0 @@
"use strict";
(() => {
// src/shared/rate-normalizer.ts
function normalizeFractionRateDisplay(value) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return null;
}
const percentageValue = numericValue * 100;
return `${trimTrailingZeros(percentageValue.toFixed(6))}%`;
}
function trimTrailingZeros(value) {
return value.replace(/\.?0+$/, "");
}
// src/content/market/market-list-request-snapshot.ts
var MARKET_REQUEST_SNAPSHOT_ATTRIBUTE = "data-sces-market-request-snapshot";
function writeMarketListRequestSnapshot(document2, snapshot) {
document2.documentElement.setAttribute(
MARKET_REQUEST_SNAPSHOT_ATTRIBUTE,
JSON.stringify(snapshot)
);
}
// src/content/market/market-list-row.ts
var PAGE_NUMBER_KEYS = [
"currentPage",
"page",
"pageNo",
"pageNum",
"page_no",
"page_num"
];
var PAGE_SIZE_KEYS = [
"limit",
"pageSize",
"page_size",
"size"
];
var TOTAL_COUNT_KEYS = [
"total",
"totalCount",
"total_count"
];
var TOTAL_PAGE_KEYS = [
"pageCount",
"page_count",
"totalPage",
"totalPages",
"total_page",
"total_pages"
];
function mapMarketListRow(row) {
const attributeDatas = readMarketAttributeDatas(row);
const singleVideoAfterSearchRate = normalizeMarketListRate(
readMarketFieldValue(row, attributeDatas, "avg_search_after_view_rate_30d")
);
return {
authorId: readString(readMarketFieldValue(row, attributeDatas, "star_id")) ?? readString(readMarketFieldValue(row, attributeDatas, "id")) ?? "",
authorName: readString(readMarketFieldValue(row, attributeDatas, "nickname")) ?? readString(readMarketFieldValue(row, attributeDatas, "nick_name")) ?? "",
coreUserId: readString(readMarketFieldValue(row, attributeDatas, "core_user_id")) ?? void 0,
exportFields: buildMarketExportFieldFallbacks(row, attributeDatas),
hasDirectRatesSource: true,
location: readMarketLocation(row, attributeDatas),
price21To60s: readMarketPrice21To60s(row, attributeDatas),
rates: singleVideoAfterSearchRate ? {
singleVideoAfterSearchRate
} : void 0
};
}
function parseMarketListResponse(payload) {
const container = findMarketListContainer(payload);
if (!container) {
return null;
}
const marketList = readMarketListArray(container);
if (!marketList) {
return null;
}
return {
currentPage: readKnownNumberDeep(container, PAGE_NUMBER_KEYS) ?? void 0,
pageSize: readKnownNumberDeep(container, PAGE_SIZE_KEYS) ?? void 0,
records: marketList.map((row) => isRecord(row) ? mapMarketListRow(row) : null).filter(
(row) => row !== null && Boolean(row.authorId || row.authorName)
),
totalCount: readKnownNumberDeep(container, TOTAL_COUNT_KEYS) ?? void 0,
totalPages: readKnownNumberDeep(container, TOTAL_PAGE_KEYS) ?? void 0
};
}
function findMarketListContainer(value) {
const queue = [value];
while (queue.length > 0) {
const current = queue.shift();
if (!isRecord(current)) {
continue;
}
if (readMarketListArray(current)) {
return current;
}
Object.values(current).forEach((entry) => {
queue.push(unwrapVueRef(entry));
});
}
return null;
}
function readMarketListArray(record) {
const marketList = unwrapVueRef(record.marketList);
if (Array.isArray(marketList)) {
return marketList;
}
const authors = unwrapVueRef(record.authors);
if (Array.isArray(authors)) {
return authors;
}
return null;
}
function unwrapVueRef(value) {
if (isRecord(value) && "value" in value) {
return value.value;
}
return value;
}
function isRecord(value) {
return typeof value === "object" && value !== null;
}
function readMarketAttributeDatas(record) {
return isRecord(record.attribute_datas) ? record.attribute_datas : {};
}
function readMarketFieldValue(record, attributeDatas, field) {
return record[field] ?? attributeDatas[field];
}
function readString(value) {
return typeof value === "string" ? value : null;
}
function normalizeMarketListRate(value) {
if (typeof value === "number") {
return normalizeFractionRateDisplay(String(value));
}
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
}
function normalizeExportCellText(value) {
return value?.replace(/\s+/g, " ").trim() ?? "";
}
function buildMarketExportFieldFallbacks(record, attributeDatas) {
const exportFields = {};
const authorInfo = buildMarketAuthorInfo(record, attributeDatas);
const authorType = buildMarketAuthorType(record, attributeDatas);
const contentTheme = buildMarketContentTheme(record, attributeDatas);
const connectedUsers = formatWanValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "link_link_cnt_by_industry"))
);
const followerCount = formatWanValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "follower"))
);
const expectedCpm = formatDecimalDisplay(
readNumericValue(readMarketFieldValue(record, attributeDatas, "prospective_20_60_cpm"))
);
const expectedPlayCount = formatWanValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "expected_play_num"))
);
const interactionRate = formatFractionPercent(
readNumericValue(readMarketFieldValue(record, attributeDatas, "interact_rate_within_30d"))
);
const finishRate = formatFractionPercent(
readNumericValue(readMarketFieldValue(record, attributeDatas, "play_over_rate_within_30d"))
);
const burstRate = readBurstRateDisplay(
readNumericValue(readMarketFieldValue(record, attributeDatas, "burst_text_rate"))
);
const price21To60s = readMarketPrice21To60s(record, attributeDatas);
const representativeVideo = readMarketRepresentativeVideo(record, attributeDatas);
assignExportField(exportFields, "\u8FBE\u4EBA\u4FE1\u606F", authorInfo);
assignExportField(exportFields, "\u4EE3\u8868\u89C6\u9891", representativeVideo);
assignExportField(exportFields, "\u8FBE\u4EBA\u7C7B\u578B", authorType);
assignExportField(exportFields, "\u5185\u5BB9\u4E3B\u9898", contentTheme);
assignExportField(exportFields, "\u8FDE\u63A5\u7528\u6237\u6570", connectedUsers);
assignExportField(exportFields, "\u7C89\u4E1D\u6570", followerCount);
assignExportField(exportFields, "\u9884\u671FCPM", expectedCpm);
assignExportField(exportFields, "\u9884\u671F\u64AD\u653E\u91CF", expectedPlayCount);
assignExportField(exportFields, "\u4E92\u52A8\u7387", interactionRate);
assignExportField(exportFields, "\u5B8C\u64AD\u7387", finishRate);
assignExportField(exportFields, "\u7206\u6587\u7387", burstRate);
assignExportField(exportFields, "21-60s\u62A5\u4EF7", price21To60s);
return Object.keys(exportFields).length > 0 ? exportFields : void 0;
}
function assignExportField(exportFields, key, value) {
if (hasTextValue(value)) {
exportFields[key] = value;
}
}
function hasTextValue(value) {
return Boolean(value && value.trim().length > 0);
}
function buildMarketAuthorInfo(record, attributeDatas) {
const nickname = readString(readMarketFieldValue(record, attributeDatas, "nickname")) ?? readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ?? "";
const parts = [
nickname,
readMarketGenderLabel(readMarketFieldValue(record, attributeDatas, "gender")),
readString(readMarketFieldValue(record, attributeDatas, "city")) ?? ""
].filter((value) => Boolean(value));
return parts.length > 0 ? parts.join(" ") : void 0;
}
function buildMarketAuthorType(record, attributeDatas) {
const tagsRelation = readRecordLike(
readMarketFieldValue(record, attributeDatas, "tags_relation")
);
if (tagsRelation) {
const primaryTag = Object.keys(tagsRelation)[0];
if (hasTextValue(primaryTag)) {
return primaryTag;
}
}
return void 0;
}
function buildMarketContentTheme(record, attributeDatas) {
const themes = readStringArray(
readMarketFieldValue(record, attributeDatas, "content_theme_labels_180d")
);
if (themes.length === 0) {
return void 0;
}
if (themes.length <= 2) {
return themes.join(" ");
}
return `${themes.slice(0, 2).join(" ")} ${themes.length - 2}+`;
}
function readMarketLocation(record, attributeDatas) {
return readString(readMarketFieldValue(record, attributeDatas, "city")) ?? void 0;
}
function readMarketPrice21To60s(record, attributeDatas) {
return formatCurrencyValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "price_20_60"))
);
}
function readMarketRepresentativeVideo(record, attributeDatas) {
const items = readArrayLike(readMarketFieldValue(record, attributeDatas, "items"));
for (const item of items) {
if (!isRecord(item)) {
continue;
}
const title = readString(item.title);
if (hasTextValue(title)) {
return normalizeExportCellText(title);
}
}
return void 0;
}
function readMarketGenderLabel(value) {
const rawValue = typeof value === "number" ? String(value) : readString(value);
if (rawValue === "1") {
return "\u7537";
}
if (rawValue === "2") {
return "\u5973";
}
return void 0;
}
function readBurstRateDisplay(value) {
if (value === null) {
return void 0;
}
if (value <= 0) {
return "-";
}
return formatFractionPercent(value);
}
function formatCurrencyValue(value) {
if (value === null) {
return void 0;
}
return `\xA5${value.toLocaleString("en-US", {
maximumFractionDigits: 0
})}`;
}
function formatWanValue(value) {
if (value === null) {
return void 0;
}
return `${formatDecimalWithGrouping(value / 1e4)}w`;
}
function formatFractionPercent(value) {
if (value === null) {
return void 0;
}
return `${formatDecimalDisplay(value * 100)}%`;
}
function formatDecimalDisplay(value) {
if (value === null) {
return void 0;
}
return value.toLocaleString("en-US", {
maximumFractionDigits: 1,
minimumFractionDigits: 0,
useGrouping: false
});
}
function formatDecimalWithGrouping(value) {
return value.toLocaleString("en-US", {
maximumFractionDigits: 1,
minimumFractionDigits: 0
});
}
function readNumericValue(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
const parsedValue = Number(trimmedValue);
return Number.isFinite(parsedValue) ? parsedValue : null;
}
return null;
}
function readStringArray(value) {
if (Array.isArray(value)) {
return value.filter((item) => typeof item === "string");
}
if (typeof value === "string") {
try {
const parsedValue = JSON.parse(value);
return Array.isArray(parsedValue) ? parsedValue.filter((item) => typeof item === "string") : [];
} catch {
return [];
}
}
return [];
}
function readArrayLike(value) {
if (Array.isArray(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsedValue = JSON.parse(value);
return Array.isArray(parsedValue) ? parsedValue : [];
} catch {
return [];
}
}
return [];
}
function readRecordLike(value) {
if (isRecord(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsedValue = JSON.parse(value);
return isRecord(parsedValue) ? parsedValue : null;
} catch {
return null;
}
}
return null;
}
function readKnownNumber(record, keys) {
for (const key of keys) {
const value = readNumericValue(record[key]);
if (value !== null) {
return value;
}
}
return void 0;
}
function readKnownNumberDeep(value, keys) {
if (!isRecord(value)) {
return null;
}
const directValue = readKnownNumber(value, keys);
if (typeof directValue === "number") {
return directValue;
}
for (const nestedValue of Object.values(value)) {
const candidate = readKnownNumberDeep(unwrapVueRef(nestedValue), keys);
if (typeof candidate === "number") {
return candidate;
}
}
return null;
}
// src/content/market/page-bridge.ts
var BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__";
var MARKET_SEARCH_REQUEST_PATH = "/gw/api/gsearch/search_for_author_square";
var SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
installMarketPageBridge();
function installMarketPageBridge() {
if (window[BRIDGE_MARKER]) {
syncSerializedMarketRows();
return;
}
window[BRIDGE_MARKER] = true;
installMarketRequestSnapshotBridge();
syncSerializedMarketRows();
const observer = new MutationObserver(() => {
syncSerializedMarketRows();
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
window.setInterval(() => {
syncSerializedMarketRows();
}, 1e3);
}
function installMarketRequestSnapshotBridge() {
installFetchSnapshotBridge();
installXmlHttpRequestSnapshotBridge();
}
function syncSerializedMarketRows() {
if (typeof document === "undefined") {
return;
}
const nextSerializedRows = JSON.stringify(readSerializedMarketRows());
if (document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !== nextSerializedRows) {
document.documentElement.setAttribute(
SERIALIZED_MARKET_ROWS_ATTRIBUTE,
nextSerializedRows
);
}
}
function installFetchSnapshotBridge() {
if (typeof window.fetch !== "function") {
return;
}
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const requestSnapshot = readFetchSnapshot(input, init);
const response = await originalFetch(input, init);
if (requestSnapshot) {
const clonedResponse = response.clone();
void captureMarketSnapshotFromResponse(
requestSnapshot,
() => clonedResponse.json()
);
}
return response;
};
}
function installXmlHttpRequestSnapshotBridge() {
const OriginalXmlHttpRequest = window.XMLHttpRequest;
if (!OriginalXmlHttpRequest) {
return;
}
const originalOpen = OriginalXmlHttpRequest.prototype.open;
const originalSend = OriginalXmlHttpRequest.prototype.send;
const originalSetRequestHeader = OriginalXmlHttpRequest.prototype.setRequestHeader;
OriginalXmlHttpRequest.prototype.open = function(method, url, ...rest) {
this.__scesMarketSnapshot = {
headers: {},
method,
url: String(url)
};
return originalOpen.call(this, method, url, ...rest);
};
OriginalXmlHttpRequest.prototype.setRequestHeader = function(name, value) {
this.__scesMarketSnapshot?.headers && (this.__scesMarketSnapshot.headers[name] = value);
return originalSetRequestHeader.call(this, name, value);
};
OriginalXmlHttpRequest.prototype.send = function(body) {
const snapshotState = this.__scesMarketSnapshot;
if (snapshotState) {
snapshotState.body = typeof body === "string" ? body : void 0;
this.addEventListener("load", () => {
if (this.status < 200 || this.status >= 300 || typeof this.responseText !== "string") {
return;
}
void captureMarketSnapshotFromResponse(
snapshotState,
async () => JSON.parse(this.responseText)
);
});
}
return originalSend.call(this, body);
};
}
async function captureMarketSnapshotFromResponse(snapshot, readPayload) {
if (!isMarketSearchRequest(snapshot.url)) {
return;
}
try {
const payload = await readPayload();
if (!parseMarketListResponse(payload)) {
return;
}
writeMarketListRequestSnapshot(document, {
body: snapshot.body,
headers: snapshot.headers,
method: snapshot.method,
url: snapshot.url
});
} catch {
}
}
function readSerializedMarketRows() {
const marketList = readMarketList();
return marketList.map((row) => {
const attributeDatas = isRecord2(row.attribute_datas) ? row.attribute_datas : {};
const singleVideoAfterSearchRate = readNormalizedFractionRate(
attributeDatas.avg_search_after_view_rate_30d
);
return {
authorId: readString2(row.star_id) ?? readString2(attributeDatas.id) ?? "",
authorName: readString2(attributeDatas.nickname) ?? readString2(row.nick_name) ?? "",
coreUserId: readString2(attributeDatas.core_user_id) ?? void 0,
singleVideoAfterSearchRate
};
}).filter((row) => Boolean(row.authorId || row.authorName));
}
function readFetchSnapshot(input, init) {
const request = input instanceof Request ? input : null;
const method = init?.method ?? request?.method ?? "GET";
const url = request?.url ?? String(input);
const body = typeof init?.body === "string" ? init.body : typeof request?.bodyUsed === "boolean" && request.bodyUsed ? void 0 : void 0;
const headers = serializeHeaders(init?.headers ?? request?.headers);
return {
body,
headers,
method,
url
};
}
function serializeHeaders(headers) {
if (!headers) {
return void 0;
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, String(value)])
);
}
function readMarketList() {
if (typeof document === "undefined") {
return [];
}
const marketRoot = document.querySelector(".base-author-list");
const setupState = marketRoot?.__vue__?._setupState;
if (!setupState) {
return [];
}
for (const value of Object.values(setupState)) {
const candidate = unwrapVueRef2(value);
if (Array.isArray(candidate) && looksLikeMarketList(candidate)) {
return candidate;
}
if (!isRecord2(candidate) || !Array.isArray(candidate.marketList)) {
continue;
}
if (looksLikeMarketList(candidate.marketList)) {
return candidate.marketList;
}
}
return [];
}
function isMarketSearchRequest(url) {
return url === MARKET_SEARCH_REQUEST_PATH || url.startsWith(`${MARKET_SEARCH_REQUEST_PATH}?`) || url.includes(`${MARKET_SEARCH_REQUEST_PATH}?`) || url.endsWith(MARKET_SEARCH_REQUEST_PATH);
}
function looksLikeMarketList(value) {
const firstRow = value[0];
return isRecord2(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow);
}
function unwrapVueRef2(value) {
if (isRecord2(value) && "value" in value) {
return value.value;
}
return value;
}
function isRecord2(value) {
return typeof value === "object" && value !== null;
}
function readString2(value) {
return typeof value === "string" ? value : null;
}
function readNormalizedFractionRate(value) {
return typeof value === "string" ? normalizeFractionRateDisplay(value) ?? void 0 : void 0;
}
})();
-58
View File
@@ -1,58 +0,0 @@
{
"action": {
"default_icon": {
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png"
},
"default_popup": "popup/index.html"
},
"background": {
"service_worker": "background/index.js"
},
"content_scripts": [
{
"js": [
"content/index.js"
],
"matches": [
"https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*"
],
"run_at": "document_start"
}
],
"description": "Bootstraps the Xingtu creator market content script.",
"icons": {
"16": "assets/icons/icon-16.png",
"32": "assets/icons/icon-32.png",
"48": "assets/icons/icon-48.png",
"128": "assets/icons/icon-128.png"
},
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB",
"manifest_version": 3,
"name": "Star Chart Search Enhancer",
"permissions": [
"downloads",
"identity",
"storage"
],
"version": "0.2.0421.2",
"web_accessible_resources": [
{
"matches": [
"https://xingtu.cn/*",
"https://*.xingtu.cn/*"
],
"resources": [
"content/market-page-bridge.js"
]
}
],
"host_permissions": [
"https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*",
"https://login-api.intelligrow.cn/*",
"https://talent-search.intelligrow.cn/*",
"http://192.168.31.21:8083/*"
]
}
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Chart Search Enhancer</title>
</head>
<body>
<main id="app"></main>
<script src="./index.js"></script>
</body>
</html>
-219
View File
@@ -1,219 +0,0 @@
"use strict";
(() => {
// src/popup/view.ts
function renderLoggedOut(root, error) {
root.innerHTML = `
<section data-popup-state="logged-out">
<h1>Star Chart Search Enhancer</h1>
<p>\u767B\u5F55\u540E\u624D\u80FD\u4F7F\u7528\u661F\u56FE\u589E\u5F3A\u529F\u80FD</p>
${error ? `<p data-popup-error="true">${error}</p>` : ""}
<button type="button" data-popup-sign-in="button">\u767B\u5F55 Logto</button>
</section>
`;
}
function renderLoggedIn(root, authState) {
const userInfo = authState.userInfo;
root.innerHTML = `
<section data-popup-state="logged-in">
<h1>Star Chart Search Enhancer</h1>
<p>\u5DF2\u767B\u5F55</p>
<p>${userInfo?.name ?? userInfo?.username ?? "\u672A\u77E5\u7528\u6237"}</p>
<p>${userInfo?.email ?? ""}</p>
<button type="button" data-popup-sign-out="button">\u9000\u51FA\u767B\u5F55</button>
</section>
`;
}
function renderDevPanel(root, authState) {
const panel = root.ownerDocument.createElement("section");
panel.dataset.popupDevPanel = "root";
panel.innerHTML = `
<h2>dev auth panel</h2>
<p>resource: ${authState.resource ?? ""}</p>
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
<p>error: ${authState.lastError ?? ""}</p>
<button type="button" data-popup-test-protected-api="button">\u6D4B\u8BD5\u53D7\u4FDD\u62A4\u63A5\u53E3</button>
<pre data-popup-protected-api-result="output"></pre>
`;
root.appendChild(panel);
}
function setProtectedApiResult(root, value) {
const output = root.querySelector(
'[data-popup-protected-api-result="output"]'
);
if (!output) {
return;
}
output.textContent = value;
}
// src/shared/auth-config.ts
var defaultAuthConfig = {
apiResource: "https://talent-search.intelligrow.cn",
appId: "i4jkllbvih0554r4n0fd3",
enableDevAuthPanel: false,
logtoEndpoint: "https://login-api.intelligrow.cn",
scopes: ["openid", "profile", "offline_access", "talent-search:read"]
};
function readAuthConfig(overrides = {}) {
const nextConfig = {
...defaultAuthConfig,
...overrides
};
if (!nextConfig.logtoEndpoint.trim()) {
throw new Error("auth config logtoEndpoint is required");
}
if (!nextConfig.appId.trim()) {
throw new Error("auth config appId is required");
}
if (!nextConfig.apiResource.trim()) {
throw new Error("auth config apiResource is required");
}
return nextConfig;
}
// src/shared/auth-messages.ts
function isAuthResponseMessage(value) {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value;
if (candidate.ok === false) {
return candidate.type === "auth:error" && typeof candidate.error === "string";
}
if (candidate.ok !== true || typeof candidate.type !== "string") {
return false;
}
if (candidate.type === "auth:ack") {
return true;
}
if (candidate.type === "auth:token") {
return Boolean(
candidate.value && typeof candidate.value === "object" && typeof candidate.value.accessToken === "string"
);
}
if (candidate.type === "auth:state") {
return Boolean(
candidate.value && typeof candidate.value === "object" && typeof candidate.value.isAuthenticated === "boolean"
);
}
return false;
}
// src/shared/protected-api-client.ts
function createProtectedApiClient(options) {
const fetchImpl = options.fetchImpl ?? fetch;
return {
async loadProtectedMockData() {
const token = await readAccessToken(options.sendMessage);
const response = await fetchImpl(
new URL("/api/mock/protected", options.baseUrl).toString(),
{
headers: {
Authorization: `Bearer ${token}`
},
method: "GET"
}
);
if (response.status === 401 || response.status === 403) {
throw new Error("protected api unauthorized");
}
if (!response.ok) {
throw new Error(`protected api request failed: ${response.status}`);
}
return response.json();
}
};
}
async function readAccessToken(sendMessage) {
const response = await sendMessage({ type: "auth:get-access-token" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:token" || !response.value.accessToken.trim()) {
throw new Error("protected api token unavailable");
}
return response.value.accessToken;
}
// src/popup/index.ts
async function bootPopup(options = {}) {
const currentDocument = options.document ?? document;
const popupConfig = readAuthConfig(options.config);
const root = currentDocument.querySelector("#app");
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
if (!root || HTMLElementCtor && !(root instanceof HTMLElementCtor)) {
throw new Error("popup root #app is required");
}
const sendMessage = options.sendMessage ?? ((message) => Promise.resolve(
globalThis.chrome?.runtime?.sendMessage?.(message)
));
const fetchProtectedApi = options.fetchProtectedApi ?? createProtectedApiClient({
baseUrl: "http://127.0.0.1:4319",
sendMessage
}).loadProtectedMockData;
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi);
}
async function renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi) {
const response = await sendMessage({ type: "auth:get-state" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
renderLoggedOut(root, "\u8BA4\u8BC1\u72B6\u6001\u8BFB\u53D6\u5931\u8D25");
return;
}
if (!response.value.isAuthenticated) {
renderLoggedOut(root, response.value.lastError);
root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, {
actionMessage: { type: "auth:sign-in" },
fetchProtectedApi
});
});
return;
}
renderLoggedIn(root, response.value);
root.querySelector('[data-popup-sign-out="button"]')?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, {
actionMessage: { type: "auth:sign-out" },
fetchProtectedApi
});
});
if (popupConfig.enableDevAuthPanel) {
renderDevPanel(root, response.value);
root.querySelector('[data-popup-test-protected-api="button"]')?.addEventListener("click", () => {
void runProtectedApiProbe(root, fetchProtectedApi);
});
}
}
async function runAuthAction(root, popupConfig, sendMessage, options) {
const response = await sendMessage(options.actionMessage);
if (isActionError(response)) {
renderLoggedOut(root, response.error);
root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, options);
});
return;
}
await renderCurrentAuthState(
root,
popupConfig,
sendMessage,
options.fetchProtectedApi
);
}
function isActionError(response) {
return isAuthResponseMessage(response) && !response.ok && response.type === "auth:error";
}
async function runProtectedApiProbe(root, fetchProtectedApi) {
setProtectedApiResult(root, "\u8BF7\u6C42\u4E2D...");
try {
const result = await fetchProtectedApi();
setProtectedApiResult(root, JSON.stringify(result, null, 2));
} catch (error) {
setProtectedApiResult(
root,
error instanceof Error ? error.message : String(error)
);
}
}
if (typeof document !== "undefined") {
void bootPopup();
}
})();
+80 -42
View File
@@ -190,15 +190,14 @@ https://xingtu.cn/ad/creator/market
在星图页面中,插件会新增一组自己的操作区,常见元素包括:
- 导出范围下拉框
- `导出CSV` 按钮
- `导出选中达人数据` 按钮
- `提交批次` 按钮
- 状态提示文字
- 每一行达人前面的勾选框
其中:
- `导出CSV`:把当前选择的数据导出为表格文件
- `导出选中达人数据`:导出已勾选达人,包含内容数据、效果预估、画像等维度
- `提交批次`:把当前选择的数据提交为批次
---
@@ -215,37 +214,67 @@ https://xingtu.cn/ad/creator/market
规则说明:
- 如果你勾选了达人,再点击导出或提交,系统会优先处理你勾选的这些达人
- 如果你没有勾选任何达人,则默认按当前导出范围处理全部达人
- 导出达人数据必须先勾选达人
- 提交批次时,如果你没有勾选任何达人,则默认处理当前列表范围内的达人
---
## 九、导出 CSV 的方法
## 九、导出达人数据 CSV 的方法
### 1. 选择导出范围
导出达人数据 CSV 用来导出内容数据、效果预估、画像和秒思 api 数据。
在插件工具栏中,你会看到导出范围下拉框,可选:
### 1. 勾选达人
- `当前页`
- `前5页`
- `前10页`
- `全部`
- `自定义`
导出达人数据必须先勾选达人。
如果选择 `自定义`,需要再输入页数。
原因:
### 2. 如果只想导出部分达人
- 导出达人数据会额外读取达人详情页数据
- 为了避免请求太多,只处理你勾选的达人
先勾选你想要的达人,再点击导出。
### 2. 可选:选择需要导出的字段
这样导出的 CSV 只会包含你勾选的人。
如果只想导出一部分字段,点击:
- `选择字段`
在弹出的窗口里:
1. 勾选需要的字段
2. 取消不需要的字段
3. 点击 `保存`
基础字段会固定保留,例如:
- 达人ID
- 达人名称
- 导出状态
- 失败原因
保存后,下次再导出 CSV,会自动沿用这次勾选结果,不需要重新勾选。
### 3. 点击导出
点击:
- `导出CSV`
- `导出选中达人数据`
### 4. 等待导出完成
如果已经有达人星图 ID 列表,也可以点击:
- `按星图ID导出`
然后把达人 ID 粘贴进去,每行一个。
### 4. 导出内容
CSV 会包含:
- 内容数据
- 效果预估
- 画像
- 秒思 api 数据
### 5. 等待导出完成
页面上会显示状态,例如:
@@ -264,29 +293,19 @@ https://xingtu.cn/ad/creator/market
---
## 十、提交批次的方法
## 十、提交批次的方法
### 1. 选择范围
和导出一样,先选择导出范围:
- 当前页
- 前5页
- 前10页
- 全部
- 自定义
### 2. 如果只想提交部分达人
### 1. 如果只想提交部分达人
先勾选想提交的达人。
### 3. 点击提交
### 2. 点击提交
点击:
- `提交批次`
### 4. 输入批次名称
### 3. 输入批次名称
这时会弹出一个自定义输入框,不再是浏览器原生弹窗。
@@ -303,7 +322,7 @@ https://xingtu.cn/ad/creator/market
- `食品饮料-KOL测试批次`
- `5月女装达人候选`
### 5. 确认提交
### 4. 确认提交
输入后点击:
@@ -311,7 +330,7 @@ https://xingtu.cn/ad/creator/market
也可以直接按回车提交。
### 6. 提交成功的提示
### 5. 提交成功的提示
如果成功,页面状态会显示:
@@ -321,7 +340,7 @@ https://xingtu.cn/ad/creator/market
---
## 十、批次名称填写建议
## 十、批次名称填写建议
建议使用容易看懂的命名方式:
@@ -348,14 +367,33 @@ https://xingtu.cn/ad/creator/market
---
## 十、如何更新插件
## 十、如何更新插件
插件弹窗会检查是否有新版本。
### 方法一:从插件弹窗下载新版本
1. 点击浏览器右上角的插件图标
2. 查看 `版本更新` 区域
3. 如果提示发现新版本,点击:
- `下载更新包`
- `下载使用说明`
4. 解压下载到的新版本 zip
5. 打开:
- `chrome://extensions`
6. 找到:
- `Star Chart Search Enhancer`
7. 点击:
- `重新加载`
如果没有看到新版本提示,可能是网络暂时无法访问更新清单,也可能当前已经是最新版本。
### 方法二:收到压缩包后手动更新
当你收到新的插件压缩包时,不需要重新从零安装。
按照下面做:
### 方法一:替换文件夹后重新加载
1. 删除旧的解压文件夹,或用新的内容覆盖旧文件夹
2. 打开:
- `chrome://extensions`
@@ -364,7 +402,7 @@ https://xingtu.cn/ad/creator/market
4. 点击:
- `重新加载`
### 方法:重新解压到新文件夹再重新加载
### 方法:重新解压到新文件夹再重新加载
1. 解压新的压缩包
2. 打开:
@@ -380,7 +418,7 @@ https://xingtu.cn/ad/creator/market
---
## 十、常见问题
## 十、常见问题
### 1. 看不到插件按钮
@@ -465,8 +503,8 @@ https://xingtu.cn/ad/creator/market
3. 点击插件图标确认登录状态
4. 打开星图达人市场页
5. 等待页面数据加载完成
6. 先勾选需要的人,或直接选择范围
7. 需要表格时点 `导出CSV`
6. 先勾选需要的人
7. 需要表格时点 `导出选中达人数据`
8. 需要进入后续流程时点 `提交批次`
---
+54 -2
View File
@@ -13,7 +13,59 @@
1. Run `npm test`.
2. Run `npm run package:internal`.
3. Send `release/star-chart-search-enhancer-internal.zip` to coworkers.
3. Run `npm run write:latest`.
4. Send `release/star-chart-search-enhancer-internal.zip` to coworkers.
## COS Update Manifest
The popup checks `src/shared/update-config.ts` for the update manifest URL.
Before publishing the COS-based update flow:
1. Upload these files to COS:
- `star-chart-search-enhancer/latest.json`
- `star-chart-search-enhancer/releases/<version>/star-chart-search-enhancer-internal.zip`
- `star-chart-search-enhancer/releases/<version>/星图增强插件-超简单安装使用指南.pdf`
2. Make the COS path publicly readable.
3. Replace the placeholder `UPDATE_MANIFEST_URL` in `src/shared/update-config.ts` if your COS bucket changes.
4. Rebuild and package the extension.
The release manifest can be generated with a real public base URL:
```bash
UPDATE_PUBLIC_BASE_URL="https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/<version>" npm run write:latest
```
Quick access check:
```bash
curl -I https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json
```
## Drone Release Flow
Tag the repo to trigger the release pipeline:
```bash
git tag 0.0525.1
git push origin 0.0525.1
```
The Drone job will:
1. Run `npm ci`.
2. Run `npm test`.
3. Run `npm run release:tag`.
4. Build the release bundle.
5. Write `release/latest.json`.
6. Upload `latest.json`, the ZIP, and the PDF to COS.
Drone secrets required:
- `cos_secret_id`
- `cos_secret_key`
The pipeline uses the tag as the release version. Recommended format: `0.MMDD.N`.
## Coworker Install Steps
@@ -21,7 +73,7 @@
2. Open `chrome://extensions`.
3. Enable developer mode.
4. Click `Load unpacked`.
5. Select the unzipped folder.
5. Select the unzipped `dist/` folder.
6. Confirm the extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
## Notes
@@ -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,128 @@
# COS Extension Update 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:** Point the popup update flow at the real COS release bucket and keep the generated release manifest, docs, and tests aligned with that COS-based distribution path.
**Architecture:** Reuse the existing update-check flow already in `src/shared/update-check.ts`, `src/popup/index.ts`, `src/popup/view.ts`, and `src/background/index.ts`. The only behavior change is the source of truth: the popup should fetch a stable COS-hosted `latest.json`, while `scripts/write-latest-manifest.mjs` should keep generating versioned asset URLs under the COS release folder. Everything else stays manual and user-driven.
**Tech Stack:** TypeScript, Chrome MV3, Vitest, Node.js ESM scripts, Tencent COS public HTTPS URLs
---
### Task 1: Lock the popup manifest URL to COS
**Files:**
- Modify: `src/shared/update-config.ts`
- Test: `tests/update-config.test.ts`
- [ ] **Step 1: Write the failing URL test**
Add a small test that asserts `UPDATE_MANIFEST_URL` points at the stable COS-hosted `latest.json` URL for this bucket:
```ts
expect(UPDATE_MANIFEST_URL).toBe(
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json"
);
```
- [ ] **Step 2: Run the focused test**
Run:
```bash
npm test -- tests/update-config.test.ts
```
Expected: FAIL because the current constant still uses the placeholder example URL.
- [ ] **Step 3: Update the constant**
Replace the placeholder string in `src/shared/update-config.ts` with the COS `latest.json` URL above.
- [ ] **Step 4: Verify**
Run:
```bash
npm test -- tests/update-config.test.ts tests/popup-entry.test.ts
```
Expected: PASS.
### Task 2: Generate release assets from the COS base
**Files:**
- Modify: `scripts/write-latest-manifest.mjs`
- Modify: `release/latest.json`
- Test: `tests/update-check.test.ts`
- [ ] **Step 1: Write a manifest generation regression test**
Add or extend a test that proves the generated manifest uses the COS release base for assets, not `example.com`. Use the COS base:
```ts
https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2
```
Assert that `zipUrl` and `guideUrl` are derived from that base.
- [ ] **Step 2: Run the focused test**
Run:
```bash
npm test -- tests/update-check.test.ts
```
Expected: FAIL until the generator default points at COS.
- [ ] **Step 3: Update the generator default**
Change `publicBaseUrl` in `scripts/write-latest-manifest.mjs` to default to the COS release base for this bucket and region, while keeping `UPDATE_PUBLIC_BASE_URL` as the override path for future releases.
- [ ] **Step 4: Regenerate the tracked manifest**
Run:
```bash
npm run write:latest
```
Then confirm `release/latest.json` contains the COS URLs.
- [ ] **Step 5: Verify**
Run:
```bash
npm test -- tests/update-check.test.ts tests/popup-entry.test.ts tests/background-index.test.ts
```
Expected: PASS.
### Task 3: Update distribution docs and verify COS access
**Files:**
- Modify: `docs/internal-extension-distribution.md`
- Modify: `README.md`
- [ ] **Step 1: Update the release instructions**
Document the stable manifest URL, the versioned asset base, and the upload flow to COS. Keep the user-facing manual install steps unchanged.
- [ ] **Step 2: Add the COS verification command**
Document a `curl -I` check for the public `latest.json` URL and the uploaded ZIP/PDF so a failed COS ACL is caught before release.
- [ ] **Step 3: Run the final verification**
Run:
```bash
npm test
npm run build:release
```
Expected: PASS, and the generated release bundle should still open the popup update card correctly.
@@ -0,0 +1,179 @@
# Popup Update Panel 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:** Redesign the extension popup into a compact enterprise-style tool panel with clearer grouping, better spacing, and more readable update actions while keeping existing behavior unchanged.
**Architecture:** Keep the popup behavior in `src/popup/index.ts` and focus most UI changes in `src/popup/view.ts`. Add only the minimum structural changes needed to support grouped cards, clearer update states, and button hierarchy, then verify the existing popup tests still cover the update workflow.
**Tech Stack:** TypeScript, Chrome MV3 popup UI, Vitest, jsdom
---
## File Map
- Modify: `src/popup/view.ts`
- Replace the current long text flow with grouped cards and clearer status blocks.
- Modify: `src/popup/index.html`
- Add popup-level CSS for layout, spacing, typography, and button hierarchy.
- Modify: `src/popup/index.ts`
- Keep logic unchanged unless the new view structure needs small status-render support changes.
- Modify: `tests/popup-entry.test.ts`
- Update popup rendering expectations for the redesigned layout.
### Task 1: Lock the desired popup structure in tests
**Files:**
- Modify: `tests/popup-entry.test.ts`
- [ ] **Step 1: Write the failing layout assertions**
Update the logged-in and update-available popup tests to assert the new structure exists. Add checks for:
- a compact product header
- an account card/root section
- an update card/root section
- a primary update button and secondary guide button
Use selectors that match the intended new DOM shape, for example:
```ts
expect(
dom.window.document.querySelector('[data-popup-account="card"]')
).not.toBeNull();
expect(
dom.window.document.querySelector('[data-popup-update="card"]')
).not.toBeNull();
```
- [ ] **Step 2: Run the focused popup test**
Run:
```bash
npm test -- tests/popup-entry.test.ts
```
Expected: FAIL because the current popup view does not expose the new grouped structure.
### Task 2: Redesign the logged-in popup shell
**Files:**
- Modify: `src/popup/view.ts`
- Modify: `src/popup/index.html`
- [ ] **Step 1: Implement the grouped popup shell**
Change the logged-in renderer so it produces:
- a compact header
- an account card
- an update card container
- a low-emphasis footer action area
Keep the same text content, just reorganized.
- [ ] **Step 2: Add popup CSS**
Add scoped CSS in `src/popup/index.html` for:
- wider popup body
- neutral background
- white cards
- consistent spacing
- smaller title scale
- primary / secondary / tertiary button styles
Do not add animation-heavy or branding-heavy styles.
- [ ] **Step 3: Re-run popup tests**
Run:
```bash
npm test -- tests/popup-entry.test.ts
```
Expected: PASS for structure-related checks, with any remaining failures isolated to update-state details.
### Task 3: Redesign update-state rendering
**Files:**
- Modify: `src/popup/view.ts`
- Modify: `src/popup/index.ts` (only if required)
- [ ] **Step 1: Write or update state-specific assertions**
Ensure tests cover:
- `checking` state shows a compact progress line
- `latest` state hides download actions
- `available` state shows current version, latest version, notes, and action buttons
- `error` state renders a readable warning block
- [ ] **Step 2: Run the focused tests**
Run:
```bash
npm test -- tests/popup-entry.test.ts
```
Expected: FAIL on any states not yet aligned to the new view.
- [ ] **Step 3: Implement state-specific card rendering**
Refactor `renderUpdateStatus()` to keep one card shell and swap state bodies inside it. Make the available-update state visually prominent but restrained.
- [ ] **Step 4: Verify**
Run:
```bash
npm test -- tests/popup-entry.test.ts tests/update-check.test.ts
```
Expected: PASS.
### Task 4: Verify the popup still works end-to-end
**Files:**
- Modify: `src/popup/view.ts` if polish is needed
- Modify: `src/popup/index.html` if polish is needed
- [ ] **Step 1: Build the release popup**
Run:
```bash
npm run build:release
```
Expected: PASS and updated popup assets written to `dist/`.
- [ ] **Step 2: Run the full test suite**
Run:
```bash
npm test
```
Expected: PASS.
- [ ] **Step 3: Manual smoke check**
Open the unpacked extension popup from `dist/` and confirm:
- title no longer wraps into a giant stacked block
- account status is easy to scan
- update information reads clearly
- buttons are visually distinct and not cramped
- [ ] **Step 4: Commit**
```bash
git add src/popup/view.ts src/popup/index.html src/popup/index.ts tests/popup-entry.test.ts
git commit -m "feat: redesign popup update panel"
```
@@ -0,0 +1,188 @@
# Unified Dist Distribution 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:** Make first-time install, Git-based install, ZIP-based install, and later updates all use the same unpacked extension directory named `dist/`.
**Architecture:** Standardize the extension output path through one shared build-path helper, then make ZIP packaging wrap that exact `dist/` directory, and finally update all teammate-facing docs to reference only that path. The release pipeline keeps its existing behavior, but the user-facing artifact shape becomes stable and singular.
**Tech Stack:** TypeScript, Node.js ESM scripts, Chrome MV3 extension packaging, Vitest
---
## File Map
- Create: `scripts/build-output-path.mjs`
- Single source of truth for the unpacked extension output path.
- Modify: `scripts/build.mjs`
- Write release builds to `dist/`.
- Modify: `scripts/package-release.mjs`
- Package the shared `dist/` output instead of any alternate directory.
- Modify: `scripts/package-release-archive.mjs`
- Ensure ZIP output preserves a top-level `dist/` folder.
- Modify: `tests/package-release-archive.test.ts`
- Verify ZIP layout unpacks as `dist/...`.
- Create: `tests/build-output-path.test.ts`
- Verify the shared helper resolves `dist/`.
- Modify: `playwright.config.js`
- Load the extension from `dist/`.
- Modify: `e2e-tests/extension-load.spec.js`
- Verify files under `dist/`.
- Modify: `README.md`
- Remove `dist-release` references and describe `dist/` as the only unpacked directory.
- Modify: `docs/internal-extension-distribution.md`
- Align the release flow language with `dist/`.
- Modify: `docs/【给同事】从Git下载使用说明.md`
- Instruct coworkers to load `dist/`.
- Modify: `.gitignore`
- Remove obsolete `dist-release/` ignore entry if no longer needed.
- Delete tracked directory: `dist-release/`
- Remove the confusing second unpacked extension directory from the repo.
### Task 1: Lock the single output path in tests
**Files:**
- Create: `tests/build-output-path.test.ts`
- Modify: `tests/package-release-archive.test.ts`
- [ ] **Step 1: Write the failing helper test**
Create a small test asserting both development and release builds resolve to:
```ts
path.join("/repo", "dist")
```
- [ ] **Step 2: Write the failing ZIP layout assertion**
Update the archive test so it expects:
```ts
"dist/hello.txt"
```
not a flat file list or any alternate top-level folder.
- [ ] **Step 3: Run focused tests**
Run:
```bash
npm test -- tests/build-output-path.test.ts tests/package-release-archive.test.ts
```
Expected: FAIL until the shared path helper and ZIP layout are implemented.
### Task 2: Standardize build and package scripts
**Files:**
- Create: `scripts/build-output-path.mjs`
- Modify: `scripts/build.mjs`
- Modify: `scripts/package-release.mjs`
- Modify: `scripts/package-release-archive.mjs`
- [ ] **Step 1: Implement the shared output-path helper**
Add `resolveExtensionBuildDir(projectRoot, buildTarget)` and return `dist/` for both release and development flows.
- [ ] **Step 2: Update the build script**
Replace any hard-coded directory switching logic so the release build writes into `dist/`.
- [ ] **Step 3: Update the package script**
Point ZIP packaging at the same `dist/` directory.
- [ ] **Step 4: Keep ZIP layout stable**
Ensure the archive helper stores files under a top-level `dist/` directory inside the ZIP.
- [ ] **Step 5: Verify**
Run:
```bash
npm test -- tests/build-output-path.test.ts tests/package-release-archive.test.ts
npm run build:release
npm run package:internal
```
Expected: PASS, and the ZIP should unpack as `dist/...`.
### Task 3: Remove `dist-release` from tooling and docs
**Files:**
- Modify: `playwright.config.js`
- Modify: `e2e-tests/extension-load.spec.js`
- Modify: `README.md`
- Modify: `docs/internal-extension-distribution.md`
- Modify: `docs/【给同事】从Git下载使用说明.md`
- Modify: `.gitignore`
- Delete tracked directory: `dist-release/`
- [ ] **Step 1: Update tool references**
Point Playwright and debug paths at `dist/`.
- [ ] **Step 2: Update teammate docs**
Make every install/update instruction reference only `dist/`.
- [ ] **Step 3: Remove the obsolete tracked directory**
Delete the committed `dist-release/` tree so future contributors cannot mistake it for the live extension directory.
- [ ] **Step 4: Verify references**
Run:
```bash
rg -n "dist-release" README.md docs package.json scripts e2e-tests playwright.config.js src tests .gitignore
```
Expected: no remaining user-facing `dist-release` references.
### Task 4: Final verification
**Files:**
- Modify any of the above only if fixes are required
- [ ] **Step 1: Run targeted tests**
Run:
```bash
npm test -- tests/build-output-path.test.ts tests/package-release-archive.test.ts tests/manifest.test.ts
```
Expected: PASS.
- [ ] **Step 2: Rebuild and inspect the artifact**
Run:
```bash
npm run build:release
npm run package:internal
unzip -l release/star-chart-search-enhancer-internal.zip | sed -n '1,20p'
```
Expected: the archive contents start with `dist/...`.
- [ ] **Step 3: Full verification**
Run:
```bash
npm test
```
Expected: PASS, or if unrelated pre-existing failures remain, capture them explicitly before completion.
- [ ] **Step 4: Commit**
```bash
git add scripts/build-output-path.mjs scripts/build.mjs scripts/package-release.mjs scripts/package-release-archive.mjs tests/build-output-path.test.ts tests/package-release-archive.test.ts README.md docs/internal-extension-distribution.md docs/【给同事】从Git下载使用说明.md playwright.config.js e2e-tests/extension-load.spec.js .gitignore
git commit -m "chore: unify extension distribution around dist"
```
@@ -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.
@@ -0,0 +1,84 @@
# COS Extension Update Design
## Goal
Use COS as the release source for extension updates. When the popup opens, it checks a public `latest.json` on COS. If the COS version is newer than the installed extension version, the popup shows an update card with download actions for the ZIP and the PDF guide, plus manual reload instructions.
## Confirmed Scope
- Update prompt appears only in the extension popup.
- No star chart page banner in this change.
- The user keeps the current manual install flow: download, unzip, replace the folder, then reload in `chrome://extensions`.
## Reusable Implementation
The current repo already has most of the flow:
- `src/shared/update-check.ts` parses the manifest and compares versions.
- `src/popup/index.ts` checks for updates when the popup boots.
- `src/popup/view.ts` renders the update status and download actions.
- `src/background/index.ts` downloads the ZIP/PDF through `chrome.downloads`.
- `scripts/write-latest-manifest.mjs` generates `release/latest.json`.
This change is mostly activation and configuration, not a rewrite.
## Manifest Contract
The public COS manifest must keep these fields:
- `latestVersion`
- `minSupportedVersion`
- `publishedAt`
- `releaseNotes`
- `zipUrl`
- `guideUrl`
Rules:
- All asset URLs must be public HTTPS URLs.
- Version comparison stays numeric dotted comparison.
- Popup logic only needs `latestVersion` to decide whether to show the update card.
- `minSupportedVersion` stays in the manifest for forward compatibility.
## COS Layout
Use a fixed release layout like:
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/latest.json`
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/star-chart-search-enhancer-internal.zip`
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/星图增强插件-超简单安装使用指南.pdf`
## User Flow
1. User opens the popup.
2. Popup reads the current extension version.
3. Popup fetches COS `latest.json` with `no-store`.
4. If `latestVersion` is not newer, show “当前已是最新版本”.
5. If `latestVersion` is newer, show “发现新版本” plus release notes.
6. User clicks:
- `下载更新包` for the ZIP
- `下载使用说明` for the PDF
7. Popup shows the manual upgrade instructions after download starts.
## Error Handling
- If the manifest is missing, invalid, or unreachable, the popup should show a non-blocking update error.
- If download fails, the popup should show a download error and keep the plugin usable.
- Update check failures must not block auth or normal plugin behavior.
## Release Process
1. Build the release package.
2. Package the internal ZIP.
3. Generate `latest.json` with the real COS base URL.
4. Upload `latest.json`, the ZIP, and the PDF to the COS folder.
5. Replace the placeholder manifest URL in `src/shared/update-config.ts`.
6. Rebuild and verify the popup update card.
## Out of Scope
- Automatic in-place extension updates.
- Auto-reload after download.
- Star chart page update prompts.
- Chrome Web Store publishing.
@@ -0,0 +1,100 @@
# Popup Update Panel Design
## Goal
Redesign the extension popup so it feels like a compact internal tool instead of a stretched document. The new popup should prioritize readability, clear grouping, and obvious update actions while keeping the interaction model unchanged.
## Confirmed Direction
- Visual style: enterprise tool
- Primary problem to solve: information feels crowded and hard to read
- Layout freedom: use whatever structure best improves clarity
## Scope
This change only redesigns the popup presentation layer:
- improve popup layout
- improve spacing and typography
- improve button hierarchy
- improve update-status presentation
This change does not alter:
- auth flow
- update-check logic
- download behavior
- background message behavior
## Layout
Use a grouped card layout instead of one long text column.
Recommended structure:
1. Header
- compact product title on one line
2. Account card
- login state
- current user name
3. Update card
- current version
- latest version or current status
- release notes
- primary and secondary action buttons
- short follow-up instruction text
4. Footer action
- low-emphasis sign-out button
## Visual Rules
- Increase popup width to a more usable fixed tool width.
- Use a soft neutral page background with white content cards.
- Reduce title size significantly from the current oversized stacked text.
- Use a clear type scale:
- product title: medium emphasis
- section title: medium emphasis
- status/version rows: normal emphasis
- helper text: smaller and lighter
- Keep the palette restrained and neutral.
- Make `下载更新包` the primary button.
- Make `下载使用说明` a secondary button.
- Make `退出登录` a tertiary or low-emphasis button.
## Update State Presentation
- `checking`: show a compact in-progress line inside the update card.
- `latest`: show a simple success-style status without action buttons.
- `available`: show a clear “发现新版本” status block with versions and actions.
- `error`: show a readable warning block instead of generic broken-looking text flow.
## Content Rules
- Keep product name to one visual line or two short lines max.
- Avoid large paragraphs in the popup.
- Release notes should stay as a compact bullet list.
- The post-download instruction should be one concise sentence.
- Version labels should align consistently so the user can compare current/latest quickly.
## Implementation Notes
- Keep the existing popup DOM entrypoints and rendering flow.
- Focus changes in `src/popup/view.ts` first.
- Only adjust popup controller code if needed to support cleaner status rendering.
- Prefer CSS embedded in the popup HTML/view flow only as needed; do not expand scope into unrelated refactors.
## Testing
Add or update popup rendering tests for:
- logged-in layout still renders correctly
- available update state still shows current/latest versions
- update action buttons still exist
- latest/error states still render expected text
## Out of Scope
- new popup features
- star chart page banners
- animation-heavy UI
- branding-heavy marketing visuals
@@ -0,0 +1,122 @@
# Unified Dist Distribution Design
## Goal
Make first-time installation and later updates use the exact same extension directory structure so coworkers never need to learn different paths or loading rules.
## Confirmed Direction
- The only user-facing unpacked extension directory should be `dist/`.
- First install and later update must produce the same directory name and shape.
- Coworkers should always load the same directory in `chrome://extensions`.
## Problem
The current delivery flow has created multiple mental models:
- sometimes coworkers are told to load `dist-release/`
- sometimes the ZIP extracts to a differently named top-level folder
- sometimes Git-based installation and ZIP-based installation do not look identical
This is not user-friendly and increases support cost.
## Scope
This change standardizes the user-facing extension directory across:
- local release builds
- ZIP packaging
- Git-based teammate installation
- later update downloads
- installation and update documentation
This change does not alter:
- extension runtime behavior
- update-check logic itself
- popup auth flow
- COS manifest format
## Distribution Rule
There must be exactly one user-facing extension directory:
- `dist/`
This rule must hold in all paths:
1. Git install
- teammate runs the release build
- resulting unpacked extension directory is `dist/`
2. ZIP install
- teammate unzips the package
- resulting top-level extension directory is `dist/`
3. ZIP update
- teammate downloads the newer ZIP
- unzip result is also `dist/`
- teammate replaces the previous `dist/`
## Packaging Rule
The release ZIP should unpack into a single top-level folder named `dist/`.
Expected unpack result:
- `dist/manifest.json`
- `dist/background/`
- `dist/content/`
- `dist/popup/`
- `dist/assets/`
The ZIP must not unpack as:
- a flat file list
- `dist-release/`
- `star-chart-search-enhancer-internal/`
- any other user-facing top-level folder name
## Build Rule
Release builds should write the unpacked extension directly to `dist/`.
There should not be a second extension build directory that teammates might mistake as the correct one.
If internal scripts need a concept of “release build,” that should be represented by:
- environment/config
- release manifest values
- packaging flow
but not by exposing a second unpacked directory name to users.
## Documentation Rule
All teammate-facing docs must say the same thing:
- first install: load `dist/`
- later update: replace `dist/` and reload
No user-facing doc should mention `dist-release/`.
## CI / Release Rule
Drone and local release scripts must publish only one ZIP layout:
- unzip result is `dist/`
The release flow must not generate a user ZIP whose folder layout differs from the Git-based install path.
## Acceptance Criteria
- `npm run build:release` writes the unpacked extension to `dist/`
- release ZIP unpacks into a top-level `dist/` folder
- first install and later update ZIPs unpack to the same structure
- coworker docs consistently reference `dist/`
- repository no longer contains a second tracked unpacked extension directory that conflicts with `dist/`
## Out of Scope
- changing extension features
- changing the update notification UX
- switching away from unpacked-extension installation
- Chrome Web Store or enterprise force-install deployment
@@ -40,10 +40,10 @@ git clone https://git.internal.intelligrow.cn/wangshaoqing/star-chart-search-enh
4. 点击 **"加载已解压的扩展程序"**
5. 选择桌面上的这个路径:
```
star-chart-search-enhancer/dist-release/
star-chart-search-enhancer/dist/
```
⚠️ **重要**:必须选择 `dist-release` 这个子文件夹,不要选外层文件夹
⚠️ **重要**:必须选择 `dist` 这个子文件夹,不要选外层文件夹
✅ 安装成功!你会看到插件卡片。
@@ -86,7 +86,7 @@ git clone https://git.internal.intelligrow.cn/wangshaoqing/star-chart-search-enh
```bash
cd Desktop/star-chart-search-enhancer
git pull
npm run build:release
npm run build:release
```
然后到 `chrome://extensions` 页面点击插件卡片的 **"重新加载"** 🔄
@@ -109,7 +109,7 @@ npm run build:release
⚠️ **如果重新加载后还是旧版本**:
- 先点击插件卡片的 **"移除"** 删除旧版本
- 然后重新点击 **"加载已解压的扩展程序"**
- 再次选择 `dist-release` 文件夹
- 再次选择 `dist` 文件夹
---
@@ -118,11 +118,11 @@ npm run build:release
**Q: 提示 "git 不是内部或外部命令"**
A: Git 没装好,请先安装 Git。
**Q: 下载后找不到 dist-release 文件夹?**
A: 请确认下载的是最新版本,可以重新执行 `git pull`。
**Q: 下载后找不到 dist 文件夹?**
A: 请确认下载的是最新版本,可以重新执行 `git pull` 并重新执行 `npm run build:release`
**Q: 加载后扩展 ID 不对?**
A: 请检查是否选择了 `dist-release` 文件夹,而不是外层文件夹。
A: 请检查是否选择了 `dist` 文件夹,而不是外层文件夹。
---
@@ -18,9 +18,9 @@
1. 在桌面上找到这个压缩包
2. **右键** → 选择"解压到当前文件夹"(或"Extract Here"
3. 会多出一个文件夹,名字类似 `star-chart-search-enhancer-internal`
3. 会多出一个文件夹,名字 `dist`
⚠️ **重要**:这个文件夹要一直放在桌面,不要删、不要改名
⚠️ **重要**:这个 `dist` 文件夹要一直放在桌面,不要删、不要改名
---
@@ -37,9 +37,9 @@
4. 点击左上角出现的 **"加载已解压的扩展程序"**
5. 选择刚才解压出来的文件夹里的 **`dist-release`** 文件夹
5. 选择刚才解压出来的 `dist` 文件夹
⚠️ **重要**必须选择 `dist-release` 这个子文件夹,不要选外层文件夹
⚠️ **重要**如果 `dist` 文件夹里能看到 `manifest.json`、`content`、`background`、`popup` 这些文件和文件夹,说明选对了。
6. 看到绿色的插件卡片出现,就装好了!
@@ -72,20 +72,58 @@ https://xingtu.cn/ad/creator/market
## 📝 主要功能
### 1️⃣ 导出 Excel 表格
### 1️⃣ 导出达人数据(CSV
- 勾选你想导出达人(不勾就选全部)
- 选择范围:当前页 / 前5页 / 全部
- 点击 **"导出CSV"**
当你需要导出达人的内容数据、效果预估、画像时使用:
- 先勾选你想导出数据的达人
- 点击 **"导出选中达人数据"**
- 等待下载完成
- 文件自动下载到电脑的"下载"文件夹
### 2️⃣ 提交批次
⚠️ **重要**:导出达人数据必须先勾选达人,因为它会额外请求达人详情页数据,不能默认导出全部。
- 内容数据:个人视频/星图视频的播放量中位数、完播率、互动率、发布作品、平均时长、平均点赞、平均评论、平均转发
- 效果预估:不同视频时长的预期CPM、预期CPE、预期播放量、爆文率
- 观众画像、粉丝画像、铁粉画像
- 秒思api数据:看后搜率、看后搜数、新增A3数、新增A3率、CPA3、cp_search
**只导出部分字段**
- 点击 **"选择字段"**
- 勾选你需要的字段,取消不需要的字段
- 点击 **"保存"**
- 再点击 **"导出选中达人数据"** 或 **"按星图ID导出"**
说明:达人ID、达人名称、导出状态、失败原因等基础字段会固定保留;你保存过一次后,下次导出会自动沿用这次勾选结果,不需要重新勾选。
### 2️⃣ 按ID导出达人数据
当你想批量查询特定达人ID的数据时使用:
- 点击 **"按星图ID导出"**
- 在弹出的对话框中输入达人ID(每行一个)
- 点击确认
- 等待下载完成
**适用场景**:已知一批达人星图ID,需要批量导出这些达人的CSV。
### 3️⃣ 提交批次
- 勾选你想提交的达人
- 点击 **"提交批次"**
- 输入批次名称(例如:`5月母婴达人第一批`
- 点击确认
### 4️⃣ 更新插件
- 点击浏览器右上角的插件图标
- 在 **"版本更新"** 区域查看是否有新版本
- 如果提示发现新版本,点击 **"下载更新包"** 和 **"下载使用说明"**
- 解压下载到的新版本 zip
- 打开 `chrome://extensions`
- 找到 `Star Chart Search Enhancer`
- 点击 **"重新加载"**,或重新选择解压后的 `dist` 文件夹
---
## 🔄 如何更新插件
@@ -100,7 +138,7 @@ https://xingtu.cn/ad/creator/market
⚠️ **如果重新加载后还是旧版本**:
- 先点击插件卡片的 **"移除"** 删除旧版本
- 然后重新点击 **"加载已解压的扩展程序"**
- 再次选择 `dist-release` 文件夹
- 再次选择新解压出来的 `dist` 文件夹
---
@@ -136,4 +174,4 @@ A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
2. 页面截图
3. 扩展 ID(从 chrome://extensions 里看)
**记住正确的 ID`**pkjopdibdnomhogjheclhnknmejccffg**`
**记住正确的 ID`pkjopdibdnomhogjheclhnknmejccffg`**
+891 -1
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -7,17 +7,21 @@
"build": "node scripts/build.mjs",
"build:release": "BUILD_TARGET=release node scripts/build.mjs",
"mock:protected-api": "node scripts/mock-protected-api.mjs",
"release:tag": "node scripts/ci/release-tag.mjs",
"package:internal": "npm run build:release && node scripts/package-release.mjs",
"package:release": "npm run build:release && node scripts/package-release.mjs",
"write:latest": "node scripts/write-latest-manifest.mjs",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest --passWithNoTests"
},
"dependencies": {
"@logto/chrome-extension": "^0.1.27"
"@logto/chrome-extension": "^0.1.27",
"yazl": "^3.3.1"
},
"license": "UNLICENSED",
"devDependencies": {
"@playwright/test": "^1.59.1",
"cos-nodejs-sdk-v5": "^2.15.4",
"jsdom": "^29.0.2",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
+11
View File
@@ -0,0 +1,11 @@
{
"guideUrl": "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.0525.6/星图增强插件-超简单安装使用指南.pdf",
"latestVersion": "0.0525.6",
"minSupportedVersion": "0.0525.6",
"publishedAt": "2026-05-25",
"releaseNotes": [
"支持在插件弹窗中检查新版本",
"支持一键下载最新版插件压缩包和使用说明"
],
"zipUrl": "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.0525.6/star-chart-search-enhancer-internal.zip"
}
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
import path from "node:path";
export function resolveExtensionBuildDir(projectRoot, _buildTarget) {
return path.join(projectRoot, "dist");
}
+2 -4
View File
@@ -3,15 +3,13 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "tsup";
import { createManifest } from "./manifest.mjs";
import { resolveExtensionBuildDir } from "./build-output-path.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const buildTarget = process.env.BUILD_TARGET === "release" ? "release" : "development";
const distDir = path.join(
projectRoot,
buildTarget === "release" ? "dist-release" : "dist"
);
const distDir = resolveExtensionBuildDir(projectRoot, buildTarget);
await rm(distDir, { recursive: true, force: true });
await mkdir(path.join(distDir, "content"), { recursive: true });
+71
View File
@@ -0,0 +1,71 @@
import { execFile } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { resolveReleaseVersion } from "../release-version.mjs";
import { uploadReleaseAssets } from "./upload-release-assets.mjs";
const execFileAsync = promisify(execFile);
export async function runReleaseTagPipeline(env = process.env) {
const projectRoot = resolveProjectRoot();
const releaseVersion = resolveReleaseVersion(env);
const publicBaseUrl =
env.UPDATE_PUBLIC_BASE_URL ??
`https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/${releaseVersion}`;
console.log(`release version: ${releaseVersion}`);
console.log("running build:release");
await runNpmScript("build:release", projectRoot, {
...env,
EXTENSION_VERSION: releaseVersion
});
console.log("running package-release");
await runNodeScript("scripts/package-release.mjs", projectRoot, {
...env,
EXTENSION_VERSION: releaseVersion
});
console.log("writing latest manifest");
await runNpmScript("write:latest", projectRoot, {
...env,
EXTENSION_VERSION: releaseVersion,
UPDATE_PUBLIC_BASE_URL: publicBaseUrl
});
console.log("uploading release assets to COS");
await uploadReleaseAssets({
env: {
...env,
EXTENSION_VERSION: releaseVersion
},
projectRoot,
releaseVersion
});
}
async function runNpmScript(scriptName, cwd, env) {
await execFileAsync("npm", ["run", scriptName], {
cwd,
env,
stdio: "inherit"
});
}
async function runNodeScript(scriptPath, cwd, env) {
await execFileAsync("node", [scriptPath], {
cwd,
env,
stdio: "inherit"
});
}
function resolveProjectRoot() {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await runReleaseTagPipeline();
}
+83
View File
@@ -0,0 +1,83 @@
import COS from "cos-nodejs-sdk-v5";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { buildReleaseUploadTargets } from "../release-assets.mjs";
export async function uploadReleaseAssets(options = {}) {
const env = options.env ?? process.env;
const projectRoot = options.projectRoot ?? resolveProjectRoot();
const releaseVersion = options.releaseVersion ?? env.EXTENSION_VERSION ?? env.DRONE_TAG;
if (!releaseVersion) {
throw new Error("release version is required for COS upload");
}
const cos = options.cosClient ?? createCosClient(env);
const targets =
options.targets ??
buildReleaseUploadTargets({
projectRoot,
releaseVersion
});
for (const target of targets) {
const body = await readFile(target.localPath);
await putObjectAsync(cos, {
Bucket: getRequiredEnv(env, "COS_BUCKET"),
Body: body,
ContentType: getContentType(target.cosKey),
Key: target.cosKey,
Region: getRequiredEnv(env, "COS_REGION")
});
}
}
async function putObjectAsync(client, params) {
return await new Promise((resolve, reject) => {
client.putObject(params, (error, data) => {
if (error) {
reject(error);
return;
}
resolve(data);
});
});
}
function createCosClient(env) {
return new COS({
SecretId: getRequiredEnv(env, "COS_SECRET_ID"),
SecretKey: getRequiredEnv(env, "COS_SECRET_KEY")
});
}
function getContentType(key) {
if (key.endsWith(".json")) {
return "application/json";
}
if (key.endsWith(".pdf")) {
return "application/pdf";
}
if (key.endsWith(".zip")) {
return "application/zip";
}
return "application/octet-stream";
}
function getRequiredEnv(env, name) {
const value = env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function resolveProjectRoot() {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
}
+5 -2
View File
@@ -1,3 +1,5 @@
import { resolveReleaseVersion } from "./release-version.mjs";
const sharedIcons = {
16: "assets/icons/icon-16.png",
32: "assets/icons/icon-32.png",
@@ -34,7 +36,6 @@ const sharedManifest = {
manifest_version: 3,
name: "Star Chart Search Enhancer",
permissions: ["downloads", "identity", "storage"],
version: "0.2.0421.2",
web_accessible_resources: [
{
matches: [
@@ -58,7 +59,8 @@ const hostPermissionsByTarget = {
"https://*.xingtu.cn/ad/creator/market*",
"https://login-api.intelligrow.cn/*",
"https://talent-search.intelligrow.cn/*",
"http://192.168.31.21:8083/*"
"http://192.168.31.21:8083/*",
"https://*/*"
]
};
@@ -71,6 +73,7 @@ export function createManifest(options = {}) {
return {
...sharedManifest,
version: resolveReleaseVersion(),
host_permissions: hostPermissions
};
}
+37
View File
@@ -0,0 +1,37 @@
import { createWriteStream } from "node:fs";
import { readdir } from "node:fs/promises";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import yazl from "yazl";
export async function createReleaseArchive({
archivePath,
rootDirName = "dist",
sourceDir
}) {
const zip = new yazl.ZipFile();
const output = createWriteStream(archivePath);
await addDirectory(zip, sourceDir, sourceDir, rootDirName);
zip.end();
await pipeline(zip.outputStream, output);
}
async function addDirectory(zip, rootDir, currentDir, rootDirName) {
const entries = await readdir(currentDir, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const absolutePath = path.join(currentDir, entry.name);
const relativePath = path.relative(rootDir, absolutePath);
if (entry.isDirectory()) {
await addDirectory(zip, rootDir, absolutePath, rootDirName);
continue;
}
if (entry.isFile()) {
zip.addFile(absolutePath, path.join(rootDirName, relativePath));
}
}
}
+6 -7
View File
@@ -1,14 +1,12 @@
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
import { createReleaseArchive } from "./package-release-archive.mjs";
import { resolveExtensionBuildDir } from "./build-output-path.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const sourceDir = path.join(projectRoot, "dist-release");
const sourceDir = resolveExtensionBuildDir(projectRoot, "release");
const releaseDir = path.join(projectRoot, "release");
const archivePath = path.join(
releaseDir,
@@ -17,8 +15,9 @@ const archivePath = path.join(
await mkdir(releaseDir, { recursive: true });
await rm(archivePath, { force: true });
await execFileAsync("zip", ["-X", "-r", archivePath, "."], {
cwd: sourceDir
await createReleaseArchive({
archivePath,
sourceDir
});
console.log(`Internal archive created at ${archivePath}`);
+25
View File
@@ -0,0 +1,25 @@
import path from "node:path";
export function buildReleaseUploadTargets({
projectRoot,
releaseVersion
}) {
const releaseDir = path.join(projectRoot, "release");
const releasePrefix = "star-chart-search-enhancer";
const releaseVersionPrefix = `${releasePrefix}/releases/${releaseVersion}`;
return [
{
cosKey: `${releasePrefix}/latest.json`,
localPath: path.join(releaseDir, "latest.json")
},
{
cosKey: `${releaseVersionPrefix}/star-chart-search-enhancer-internal.zip`,
localPath: path.join(releaseDir, "star-chart-search-enhancer-internal.zip")
},
{
cosKey: `${releaseVersionPrefix}/星图增强插件-超简单安装使用指南.pdf`,
localPath: path.join(releaseDir, "星图增强插件-超简单安装使用指南.pdf")
}
];
}
+30
View File
@@ -0,0 +1,30 @@
const RELEASE_VERSION_PATTERN = /^\d+(?:\.\d+)*$/;
export function normalizeReleaseVersionTag(value) {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().replace(/^v/i, "");
if (!RELEASE_VERSION_PATTERN.test(normalized)) {
return null;
}
return normalized;
}
export function resolveReleaseVersion(
env = process.env,
fallbackVersion = "0.2.0421.2"
) {
const candidates = [env.EXTENSION_VERSION, env.DRONE_TAG, fallbackVersion];
for (const candidate of candidates) {
const normalized = normalizeReleaseVersionTag(candidate);
if (normalized) {
return normalized;
}
}
throw new Error("unable to resolve a valid release version");
}
+15
View File
@@ -0,0 +1,15 @@
export function createLatestManifest(options) {
const publishedAt = options.publishedAt ?? new Date().toISOString().slice(0, 10);
return {
guideUrl: `${options.publicBaseUrl}/星图增强插件-超简单安装使用指南.pdf`,
latestVersion: options.latestVersion,
minSupportedVersion: options.minSupportedVersion,
publishedAt,
releaseNotes: [
"支持在插件弹窗中检查新版本",
"支持一键下载最新版插件压缩包和使用说明"
],
zipUrl: `${options.publicBaseUrl}/star-chart-search-enhancer-internal.zip`
};
}
+31
View File
@@ -0,0 +1,31 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createManifest } from "./manifest.mjs";
import { createLatestManifest } from "./write-latest-manifest-data.mjs";
import { resolveReleaseVersion } from "./release-version.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const releaseDir = path.join(projectRoot, "release");
const releaseManifest = createManifest({ target: "release" });
const latestVersion =
process.env.LATEST_VERSION ?? resolveReleaseVersion(process.env, releaseManifest.version);
const publicBaseUrl =
process.env.UPDATE_PUBLIC_BASE_URL ??
`https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/${latestVersion}`;
const latestManifest = createLatestManifest({
latestVersion,
minSupportedVersion: releaseManifest.version,
publicBaseUrl
});
await mkdir(releaseDir, { recursive: true });
await writeFile(
path.join(releaseDir, "latest.json"),
`${JSON.stringify(latestManifest, null, 2)}\n`,
"utf8"
);
console.log(`Update manifest written to ${path.join(releaseDir, "latest.json")}`);
+9
View File
@@ -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);
},
+3 -1
View File
@@ -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
};
}
+55
View File
@@ -49,6 +49,12 @@ type BatchSubmitMessage = {
type: "batch:submit";
};
type DownloadUpdateMessage = {
filename: string;
type: "update:download";
url: string;
};
export function registerBackgroundMessageHandler(
chromeLike: ChromeLike = readChromeLike(),
dependencies: {
@@ -77,6 +83,22 @@ export function registerBackgroundMessageHandler(
return true;
}
if (isDownloadUpdateMessage(message)) {
void triggerUpdateDownload(chromeLike, message)
.then(() => {
sendResponse({ ok: true, type: "update:download-ack" });
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false,
type: "update:download-error"
});
});
return true;
}
if (isBatchSubmitMessage(message)) {
authController ??= createAuthController({
authClient: createLogtoAuthClient()
@@ -161,6 +183,23 @@ export function registerBackgroundMessageHandler(
});
}
async function triggerUpdateDownload(
chromeLike: ChromeLike,
message: DownloadUpdateMessage
): Promise<void> {
if (!chromeLike.downloads?.download) {
throw new Error("chrome.downloads.download is unavailable");
}
await Promise.resolve(
chromeLike.downloads.download({
filename: message.filename,
saveAs: true,
url: message.url
})
);
}
async function handleAuthMessage(
authController: AuthController,
message: Parameters<typeof isAuthRequestMessage>[0] & { type: string }
@@ -239,6 +278,22 @@ function isDownloadMarketCsvMessage(
);
}
function isDownloadUpdateMessage(
message: unknown
): message is DownloadUpdateMessage {
if (!message || typeof message !== "object") {
return false;
}
const candidate = message as Partial<DownloadUpdateMessage>;
return (
candidate.type === "update:download" &&
typeof candidate.filename === "string" &&
typeof candidate.url === "string" &&
candidate.url.startsWith("https://")
);
}
function isBatchSubmitMessage(message: unknown): message is BatchSubmitMessage {
if (!message || typeof message !== "object") {
return false;
+29 -7
View File
@@ -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,303 @@
import type { MarketRecord } from "./types";
import type {
AudienceProfileDistributionItem,
AudienceProfileKind,
AudienceProfileResult,
AudienceProfileSuccess
} from "./audience-profile-types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
export type AudienceProfileRequestTarget =
| {
linkType: number;
source: "audienceDistribution";
}
| {
authorType: number;
source: "fansDistribution";
};
interface AudienceProfileClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
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 const AUDIENCE_PROFILE_TARGETS: Record<
AudienceProfileKind,
AudienceProfileRequestTarget
> = {
audience: { linkType: 5, source: "audienceDistribution" },
fans: { authorType: 1, source: "fansDistribution" },
longtimeFans: { authorType: 5, source: "fansDistribution" }
};
export function createAudienceProfileClient(
options: AudienceProfileClientOptions = {}
) {
const baseUrl = options.baseUrl ?? resolveBaseUrl();
const fetchImpl = options.fetchImpl ?? defaultFetch;
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadAudienceProfile(
record: MarketRecord,
target: AudienceProfileRequestTarget
): Promise<AudienceProfileResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(
buildAudienceProfileUrl(record.authorId, baseUrl, target),
{
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,
target: AudienceProfileRequestTarget
): string {
const url = new URL(
target.source === "audienceDistribution"
? "/gw/api/data_sp/author_audience_distribution"
: "/gw/api/data_sp/get_author_fans_distribution",
baseUrl
);
url.searchParams.set("o_author_id", authorId);
url.searchParams.set("platform_source", "1");
if (target.source === "audienceDistribution") {
url.searchParams.set("platform_channel", "1");
url.searchParams.set("link_type", String(target.linkType));
} else {
url.searchParams.set("author_type", String(target.authorType));
}
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;
}
+312
View File
@@ -0,0 +1,312 @@
import { escapeCsvCell } from "../../shared/csv";
import {
buildMarketCsvColumns,
listBackendMetricCsvHeaders,
listRateCsvHeaders,
type CsvColumn
} from "./csv-exporter";
import type {
AudienceProfileDistributionItem,
AudienceProfileExportRow,
AudienceProfileKind,
AudienceProfileResult,
BusinessAbilityDurationKind,
BusinessAbilityEstimateMetrics,
BusinessAbilityVideoKind,
BusinessAbilityVideoMetrics
} from "./audience-profile-types";
type AudienceProfileCsvColumn = {
header: string;
readValue: (row: AudienceProfileExportRow) => string;
};
export interface AudienceProfileCsvOptions {
selectedHeaders?: string[];
}
export type AudienceProfileCsvFieldGroup = {
headers: string[];
label: string;
};
const PROFILE_LAYOUTS: Array<{
includeGender: boolean;
kind: AudienceProfileKind;
label: string;
}> = [
{ includeGender: true, kind: "audience", label: "观众画像" },
{ includeGender: true, kind: "fans", label: "粉丝画像" },
{ includeGender: false, kind: "longtimeFans", label: "铁粉画像" }
];
const GENDER_LABELS = ["男性", "女性"];
const AGE_LABELS = ["18-23", "24-30", "31-40", "41-50", "50+"];
const CITY_TIER_LABELS = [
"一线城市",
"二线城市",
"三线城市",
"四线城市",
"五线城市"
];
const CROWD_LABELS = [
"精致妈妈",
"都市银发",
"新锐白领",
"资深中产",
"都市蓝领",
"Z世代",
"小镇中老年",
"小镇青年"
];
const BUSINESS_VIDEO_LAYOUTS: Array<{
key: BusinessAbilityVideoKind;
label: string;
}> = [
{ key: "personalVideo", label: "个人视频" },
{ key: "xingtuVideo", label: "星图视频" }
];
const BUSINESS_VIDEO_METRIC_LAYOUTS: Array<{
key: keyof BusinessAbilityVideoMetrics;
label: string;
}> = [
{ key: "medianPlay", label: "播放量中位数" },
{ key: "finishRate", label: "完播率" },
{ key: "interactionRate", label: "互动率" },
{ key: "publishedItems", label: "发布作品" },
{ key: "averageDuration", label: "平均时长" },
{ key: "averageLike", label: "平均点赞" },
{ key: "averageComment", label: "平均评论" },
{ key: "averageShare", label: "平均转发" }
];
const BUSINESS_VIDEO_SECTION_LABEL = "内容数据";
const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估";
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
key: BusinessAbilityDurationKind;
label: string;
}> = [
{ key: "oneToTwenty", label: "1-20s视频" },
{ key: "twentyToSixty", label: "20-60s视频" },
{ key: "overSixty", label: "60s以上视频" }
];
const BUSINESS_ESTIMATE_METRIC_LAYOUTS: Array<{
key: keyof BusinessAbilityEstimateMetrics;
label: string;
}> = [
{ key: "expectedCpm", label: "预期CPM" },
{ key: "expectedCpe", label: "预期CPE" },
{ key: "expectedPlay", label: "预期播放量" },
{ key: "hotRate", label: "爆文率" }
];
export function buildAudienceProfileCsv(
rows: AudienceProfileExportRow[],
options: AudienceProfileCsvOptions = {}
): string {
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
const csvColumns = filterAudienceProfileCsvColumns([
...marketColumns.map(toMarketColumn),
...buildBusinessAbilityColumns(),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
], options.selectedHeaders);
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");
}
export function listAudienceProfileCsvHeaders(
rows: AudienceProfileExportRow[] = []
): string[] {
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
return [
...marketColumns.map((column) => column.header),
...buildBusinessAbilityColumns().map((column) => column.header),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)).map(
(column) => column.header
)
];
}
export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFieldGroup[] {
return [
{
headers: listRateCsvHeaders(),
label: "看后搜率"
},
{
headers: listBackendMetricCsvHeaders(),
label: "秒思api数据"
},
{
headers: buildBusinessVideoColumns().map((column) => column.header),
label: "内容数据"
},
{
headers: buildBusinessEstimateColumns().map((column) => column.header),
label: "效果预估"
},
...PROFILE_LAYOUTS.map((layout) => ({
headers: buildProfileColumns(layout).map((column) => column.header),
label: layout.label
}))
];
}
function filterAudienceProfileCsvColumns(
columns: AudienceProfileCsvColumn[],
selectedHeaders: string[] | undefined
): AudienceProfileCsvColumn[] {
if (!selectedHeaders) {
return columns;
}
const selectableHeaderSet = new Set(listAudienceProfileSelectableHeaders());
const selectedHeaderSet = new Set(selectedHeaders);
return columns.filter(
(column) =>
!selectableHeaderSet.has(column.header) ||
selectedHeaderSet.has(column.header)
);
}
function listAudienceProfileSelectableHeaders(): string[] {
return listAudienceProfileSelectableFieldGroups().flatMap(
(group) => group.headers
);
}
function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()];
}
function buildBusinessVideoColumns(): AudienceProfileCsvColumn[] {
return [
...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) =>
BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({
header: `${BUSINESS_VIDEO_SECTION_LABEL}-${videoLayout.label}-${metricLayout.label}`,
readValue: (row: AudienceProfileExportRow) =>
readBusinessVideoValue(row, videoLayout.key, metricLayout.key)
}))
)
];
}
function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
return [
...BUSINESS_ESTIMATE_LAYOUTS.flatMap((durationLayout) =>
BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({
header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${durationLayout.label}-${metricLayout.label}`,
readValue: (row: AudienceProfileExportRow) =>
readBusinessEstimateValue(row, durationLayout.key, metricLayout.key)
}))
)
];
}
function readBusinessVideoValue(
row: AudienceProfileExportRow,
videoKey: BusinessAbilityVideoKind,
metricKey: keyof BusinessAbilityVideoMetrics
): string {
const businessAbility = row.businessAbility;
if (!businessAbility || businessAbility.status !== "success") {
return "";
}
return businessAbility.videos[videoKey]?.[metricKey] ?? "";
}
function readBusinessEstimateValue(
row: AudienceProfileExportRow,
durationKey: BusinessAbilityDurationKind,
metricKey: keyof BusinessAbilityEstimateMetrics
): string {
const businessAbility = row.businessAbility;
if (!businessAbility || businessAbility.status !== "success") {
return "";
}
return businessAbility.estimates[durationKey]?.[metricKey] ?? "";
}
function toMarketColumn(column: CsvColumn): AudienceProfileCsvColumn {
return {
header: column.header,
readValue: (row) => column.readValue(row.record)
};
}
function buildProfileColumns(layout: {
includeGender: boolean;
kind: AudienceProfileKind;
label: string;
}): AudienceProfileCsvColumn[] {
const columns: AudienceProfileCsvColumn[] = [];
if (layout.includeGender) {
columns.push(
...buildFixedDistributionColumns(
layout.label,
layout.kind,
"gender",
GENDER_LABELS
)
);
}
columns.push(
...buildFixedDistributionColumns(layout.label, layout.kind, "age", AGE_LABELS),
...buildFixedDistributionColumns(
layout.label,
layout.kind,
"cityTier",
CITY_TIER_LABELS
),
...buildFixedDistributionColumns(layout.label, layout.kind, "crowd", CROWD_LABELS)
);
return columns;
}
function buildFixedDistributionColumns(
prefix: string,
kind: AudienceProfileKind,
key: "age" | "cityTier" | "crowd" | "gender",
labels: string[]
): AudienceProfileCsvColumn[] {
return labels.map((label) => ({
header: `${prefix}-${label}占比`,
readValue: (row) => readDistributionValue(row.profiles[kind], key, label)
}));
}
function readDistributionValue(
profile: AudienceProfileResult,
key: "age" | "cityTier" | "crowd" | "gender",
label: string
): string {
if (profile.status !== "success") {
return "";
}
return (
readProfileDistributionItems(profile, key).find(
(candidate) => candidate.label === label
)?.value ?? "0%"
);
}
function readProfileDistributionItems(
profile: AudienceProfileResult,
key: "age" | "cityTier" | "crowd" | "gender"
): AudienceProfileDistributionItem[] {
return profile.status === "success" ? profile[key] ?? [] : [];
}
@@ -0,0 +1,295 @@
import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv";
export function promptForAudienceProfileFields(
document: Document,
groups: AudienceProfileCsvFieldGroup[],
selectedHeaders: string[]
): Promise<string[] | null> {
return new Promise((resolve) => {
const selectableHeaders = groups.flatMap((group) => group.headers);
const selectedHeaderSet = new Set(
selectedHeaders.filter((header) => selectableHeaders.includes(header))
);
if (selectedHeaderSet.size === 0) {
selectableHeaders.forEach((header) => selectedHeaderSet.add(header));
}
const overlay = document.createElement("div");
overlay.dataset.audienceProfileFieldDialog = "overlay";
applyOverlayStyles(overlay);
const dialog = document.createElement("section");
applyDialogStyles(dialog);
const title = document.createElement("h2");
applyTitleStyles(title);
const hint = document.createElement("p");
hint.textContent = "基础字段会固定保留。取消勾选后,本次及后续CSV将不包含对应列。";
applyHintStyles(hint);
const toolbar = document.createElement("div");
applyToolbarStyles(toolbar);
const selectAllButton = document.createElement("button");
selectAllButton.type = "button";
selectAllButton.textContent = "全选";
applySecondaryButtonStyles(selectAllButton);
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = "恢复默认";
applySecondaryButtonStyles(resetButton);
toolbar.append(selectAllButton, resetButton);
const groupContainer = document.createElement("div");
applyGroupContainerStyles(groupContainer);
const fieldInputs: HTMLInputElement[] = [];
groups.forEach((group) => {
const groupSection = document.createElement("section");
groupSection.dataset.audienceProfileFieldDialogGroup = "section";
applyGroupSectionStyles(groupSection);
const groupHeader = document.createElement("label");
applyGroupHeaderStyles(groupHeader);
const groupInput = document.createElement("input");
groupInput.type = "checkbox";
const groupTitle = document.createElement("span");
groupTitle.textContent = group.label;
groupHeader.append(groupInput, groupTitle);
const fieldList = document.createElement("div");
applyFieldListStyles(fieldList);
const groupFieldInputs = group.headers.map((header) => {
const fieldLabel = document.createElement("label");
applyFieldLabelStyles(fieldLabel);
const input = document.createElement("input");
input.type = "checkbox";
input.value = header;
input.dataset.audienceProfileFieldDialogField = "checkbox";
input.checked = selectedHeaderSet.has(header);
const text = document.createElement("span");
text.textContent = header;
fieldLabel.append(input, text);
fieldList.append(fieldLabel);
fieldInputs.push(input);
return input;
});
const syncGroupInput = () => {
const checkedCount = groupFieldInputs.filter((input) => input.checked).length;
groupInput.checked = checkedCount === groupFieldInputs.length;
groupInput.indeterminate = checkedCount > 0 && checkedCount < groupFieldInputs.length;
};
groupInput.addEventListener("change", () => {
groupFieldInputs.forEach((input) => {
input.checked = groupInput.checked;
});
syncTitle();
});
groupFieldInputs.forEach((input) => {
input.addEventListener("change", () => {
syncGroupInput();
syncTitle();
});
});
syncGroupInput();
groupSection.append(groupHeader, fieldList);
groupContainer.append(groupSection);
});
const actions = document.createElement("div");
applyActionsStyles(actions);
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.textContent = "取消";
applySecondaryButtonStyles(cancelButton);
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.dataset.audienceProfileFieldDialogSave = "button";
confirmButton.textContent = "保存";
applyPrimaryButtonStyles(confirmButton);
actions.append(cancelButton, confirmButton);
dialog.append(title, hint, toolbar, groupContainer, actions);
overlay.append(dialog);
document.body.appendChild(overlay);
function syncTitle() {
const checkedCount = fieldInputs.filter((input) => input.checked).length;
title.textContent = `可选字段(已选 ${checkedCount}/${fieldInputs.length} 个字段)`;
}
function close(value: string[] | null) {
overlay.remove();
resolve(value);
}
selectAllButton.addEventListener("click", () => {
fieldInputs.forEach((input) => {
input.checked = true;
});
syncTitle();
syncAllGroupInputs(dialog);
});
resetButton.addEventListener("click", () => {
fieldInputs.forEach((input) => {
input.checked = true;
});
syncTitle();
syncAllGroupInputs(dialog);
});
cancelButton.addEventListener("click", () => close(null));
confirmButton.addEventListener("click", () => {
const nextHeaders = fieldInputs
.filter((input) => input.checked)
.map((input) => input.value);
close(nextHeaders);
});
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close(null);
}
});
syncTitle();
});
}
function syncAllGroupInputs(dialog: HTMLElement): void {
dialog
.querySelectorAll('[data-audience-profile-field-dialog-group="section"]')
.forEach((section) => {
const groupInput = section.querySelector(":scope > label > input");
const fieldInputs = Array.from(
section.querySelectorAll(":scope > div input")
) as HTMLInputElement[];
if (!(groupInput instanceof HTMLInputElement) || fieldInputs.length === 0) {
return;
}
const checkedCount = fieldInputs.filter((input) => input.checked).length;
groupInput.checked = checkedCount === fieldInputs.length;
groupInput.indeterminate = checkedCount > 0 && checkedCount < fieldInputs.length;
});
}
function applyOverlayStyles(overlay: HTMLElement): void {
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.zIndex = "2147483647";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.background = "rgba(15, 23, 42, 0.38)";
}
function applyDialogStyles(dialog: HTMLElement): void {
dialog.style.width = "680px";
dialog.style.maxWidth = "calc(100vw - 32px)";
dialog.style.maxHeight = "calc(100vh - 48px)";
dialog.style.display = "flex";
dialog.style.flexDirection = "column";
dialog.style.background = "#ffffff";
dialog.style.borderRadius = "8px";
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
dialog.style.padding = "20px";
dialog.style.boxSizing = "border-box";
}
function applyTitleStyles(title: HTMLElement): void {
title.style.margin = "0 0 8px";
title.style.fontSize = "18px";
title.style.fontWeight = "700";
title.style.color = "#1f2329";
}
function applyHintStyles(hint: HTMLElement): void {
hint.style.margin = "0 0 12px";
hint.style.fontSize = "13px";
hint.style.lineHeight = "20px";
hint.style.color = "#64748b";
}
function applyToolbarStyles(toolbar: HTMLElement): void {
toolbar.style.display = "flex";
toolbar.style.gap = "8px";
toolbar.style.marginBottom = "12px";
}
function applyGroupContainerStyles(container: HTMLElement): void {
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.gap = "10px";
container.style.overflow = "auto";
container.style.paddingRight = "4px";
}
function applyGroupSectionStyles(section: HTMLElement): void {
section.style.border = "1px solid #e5e7eb";
section.style.borderRadius = "8px";
section.style.padding = "10px";
}
function applyGroupHeaderStyles(label: HTMLElement): void {
label.style.display = "flex";
label.style.alignItems = "center";
label.style.gap = "8px";
label.style.fontWeight = "700";
label.style.color = "#1f2329";
label.style.marginBottom = "8px";
}
function applyFieldListStyles(list: HTMLElement): void {
list.style.display = "grid";
list.style.gridTemplateColumns = "repeat(auto-fit, minmax(220px, 1fr))";
list.style.gap = "8px";
}
function applyFieldLabelStyles(label: HTMLElement): void {
label.style.display = "flex";
label.style.alignItems = "center";
label.style.gap = "6px";
label.style.fontSize = "13px";
label.style.lineHeight = "18px";
label.style.color = "#374151";
}
function applyActionsStyles(actions: HTMLElement): void {
actions.style.display = "flex";
actions.style.justifyContent = "flex-end";
actions.style.columnGap = "8px";
actions.style.marginTop = "14px";
}
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.border = "1px solid #7f1d2d";
button.style.borderRadius = "8px";
button.style.background = "#7f1d2d";
button.style.color = "#ffffff";
button.style.fontWeight = "600";
}
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.border = "1px solid #d0d7de";
button.style.borderRadius = "8px";
button.style.background = "#ffffff";
button.style.color = "#1f2329";
button.style.fontWeight = "600";
}
@@ -0,0 +1,87 @@
import type { MarketRecord } from "./types";
export type AudienceProfileKind =
| "audience"
| "fans"
| "longtimeFans";
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 interface AudienceProfileSet {
audience: AudienceProfileResult;
fans: AudienceProfileResult;
longtimeFans: AudienceProfileResult;
}
export type AudienceProfileResult =
| AudienceProfileSuccess
| AudienceProfileFailure;
export interface AudienceProfileExportRow {
businessAbility?: BusinessAbilityResult;
profiles: AudienceProfileSet;
record: MarketRecord;
}
export type BusinessAbilityVideoKind =
| "personalVideo"
| "xingtuVideo";
export interface BusinessAbilityVideoMetrics {
averageComment: string;
averageDuration: string;
averageLike: string;
averageShare: string;
finishRate: string;
interactionRate: string;
medianPlay: string;
publishedItems: string;
}
export type BusinessAbilityDurationKind =
| "oneToTwenty"
| "twentyToSixty"
| "overSixty";
export interface BusinessAbilityEstimateMetrics {
expectedCpe: string;
expectedCpm: string;
expectedPlay: string;
hotRate: string;
}
export interface BusinessAbilitySuccess {
estimates: Partial<
Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>
>;
status: "success";
videos: Partial<Record<BusinessAbilityVideoKind, BusinessAbilityVideoMetrics>>;
}
export interface BusinessAbilityFailure {
failureReason?: string;
status: "failed";
}
export type BusinessAbilityResult =
| BusinessAbilitySuccess
| BusinessAbilityFailure;
+7 -2
View File
@@ -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"]')
+122
View File
@@ -0,0 +1,122 @@
import type { MarketRecord } from "./types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface AuthorBaseClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
timeoutMs?: number;
}
export function createAuthorBaseClient(options: AuthorBaseClientOptions = {}) {
const baseUrl = options.baseUrl ?? resolveBaseUrl();
const fetchImpl = options.fetchImpl ?? defaultFetch;
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadAuthorBaseInfo(authorId: string): Promise<MarketRecord> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(
buildAuthorBaseInfoUrl(authorId, baseUrl),
{
credentials: "include",
method: "GET",
signal: controller.signal
}
);
if (!response.ok) {
return buildFailedRecord(authorId, "request-failed");
}
return mapAuthorBaseInfoResponse(authorId, await response.json());
} catch (error) {
return buildFailedRecord(
authorId,
error instanceof Error && error.name === "AbortError"
? "timeout"
: "request-failed"
);
} finally {
clearTimeout(timeoutId);
}
}
};
}
export function buildAuthorBaseInfoUrl(
authorId: string,
baseUrl: string
): string {
const url = new URL("/gw/api/author/get_author_base_info", baseUrl);
url.searchParams.set("o_author_id", authorId);
url.searchParams.set("platform_source", "1");
url.searchParams.set("platform_channel", "1");
url.searchParams.set("recommend", "true");
url.searchParams.set("need_sec_uid", "true");
url.searchParams.set("need_linkage_info", "true");
return url.toString();
}
export function mapAuthorBaseInfoResponse(
authorId: string,
payload: unknown
): MarketRecord {
if (!isRecord(payload)) {
return buildFailedRecord(authorId, "bad-response");
}
const authorName = readString(payload.nick_name);
if (!authorName) {
return buildFailedRecord(authorId, "missing-rate");
}
return {
authorId,
authorName,
status: "success"
};
}
function buildFailedRecord(
authorId: string,
failureReason: MarketRecord["failureReason"]
): MarketRecord {
return {
authorId,
authorName: "",
failureReason,
status: "failed"
};
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : 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;
}
+130
View File
@@ -0,0 +1,130 @@
export function promptForAuthorIds(document: Document): Promise<string | null> {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.dataset.authorIdDialog = "overlay";
applyOverlayStyles(overlay);
const dialog = document.createElement("section");
applyDialogStyles(dialog);
const title = document.createElement("h2");
title.textContent = "按星图ID导出";
applyTitleStyles(title);
const textarea = document.createElement("textarea");
textarea.dataset.authorIdDialogInput = "textarea";
textarea.placeholder = "每行一个星图ID,也支持逗号、空格分隔";
applyTextareaStyles(textarea);
const hint = document.createElement("p");
hint.textContent = "粘贴客户提供的达人星图ID,确认后将批量导出达人数据。";
applyHintStyles(hint);
const actions = document.createElement("div");
applyActionsStyles(actions);
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.textContent = "取消";
applySecondaryButtonStyles(cancelButton);
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.textContent = "开始导出";
applyPrimaryButtonStyles(confirmButton);
actions.append(cancelButton, confirmButton);
dialog.append(title, hint, textarea, actions);
overlay.append(dialog);
document.body.appendChild(overlay);
const close = (value: string | null) => {
overlay.remove();
resolve(value);
};
cancelButton.addEventListener("click", () => close(null));
confirmButton.addEventListener("click", () => close(textarea.value));
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close(null);
}
});
textarea.focus();
});
}
function applyOverlayStyles(overlay: HTMLElement): void {
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.zIndex = "2147483647";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.background = "rgba(15, 23, 42, 0.38)";
}
function applyDialogStyles(dialog: HTMLElement): void {
dialog.style.width = "520px";
dialog.style.maxWidth = "calc(100vw - 32px)";
dialog.style.background = "#ffffff";
dialog.style.borderRadius = "8px";
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
dialog.style.padding = "20px";
dialog.style.boxSizing = "border-box";
}
function applyTitleStyles(title: HTMLElement): void {
title.style.margin = "0 0 8px";
title.style.fontSize = "18px";
title.style.fontWeight = "700";
title.style.color = "#1f2329";
}
function applyHintStyles(hint: HTMLElement): void {
hint.style.margin = "0 0 12px";
hint.style.fontSize = "13px";
hint.style.lineHeight = "20px";
hint.style.color = "#64748b";
}
function applyTextareaStyles(textarea: HTMLTextAreaElement): void {
textarea.style.width = "100%";
textarea.style.height = "220px";
textarea.style.resize = "vertical";
textarea.style.border = "1px solid #d0d7de";
textarea.style.borderRadius = "6px";
textarea.style.padding = "10px";
textarea.style.boxSizing = "border-box";
textarea.style.fontSize = "13px";
textarea.style.lineHeight = "20px";
textarea.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
textarea.style.color = "#1f2329";
}
function applyActionsStyles(actions: HTMLElement): void {
actions.style.display = "flex";
actions.style.justifyContent = "flex-end";
actions.style.columnGap = "8px";
actions.style.marginTop = "14px";
}
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.border = "1px solid #7f1d2d";
button.style.borderRadius = "8px";
button.style.background = "#7f1d2d";
button.style.color = "#ffffff";
button.style.fontWeight = "600";
}
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px";
button.style.padding = "0 15px";
button.style.border = "1px solid #d0d7de";
button.style.borderRadius = "8px";
button.style.background = "#ffffff";
button.style.color = "#1f2329";
button.style.fontWeight = "600";
}
+39
View File
@@ -0,0 +1,39 @@
export interface ParsedAuthorIds {
duplicates: string[];
invalidTokens: string[];
ids: string[];
}
const AUTHOR_ID_PATTERN = /^\d{16,20}$/;
export function parseAuthorIds(input: string): ParsedAuthorIds {
const ids: string[] = [];
const duplicates: string[] = [];
const invalidTokens: string[] = [];
const seen = new Set<string>();
input
.split(/[\s,;]+/)
.map((token) => token.trim())
.filter(Boolean)
.forEach((token) => {
if (!/^\d+$/.test(token) || !AUTHOR_ID_PATTERN.test(token)) {
invalidTokens.push(token);
return;
}
if (seen.has(token)) {
duplicates.push(token);
return;
}
seen.add(token);
ids.push(token);
});
return {
duplicates,
ids,
invalidTokens
};
}
@@ -0,0 +1,289 @@
import type { MarketRecord } from "./types";
import type {
BusinessAbilityDurationKind,
BusinessAbilityEstimateMetrics,
BusinessAbilityResult,
BusinessAbilitySuccess,
BusinessAbilityVideoMetrics
} from "./audience-profile-types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface BusinessAbilityClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
timeoutMs?: number;
}
const VIDEO_TYPES = {
personalVideo: 1,
xingtuVideo: 2
} as const;
export function createBusinessAbilityClient(
options: BusinessAbilityClientOptions = {}
) {
const baseUrl = options.baseUrl ?? resolveBaseUrl();
const fetchImpl = options.fetchImpl ?? defaultFetch;
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadBusinessAbility(record: MarketRecord): Promise<BusinessAbilityResult> {
const personalVideo = await loadJson(
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.personalVideo)
);
const xingtuVideo = await loadJson(
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.xingtuVideo)
);
const estimates = await loadJson(
buildBusinessAbilityEstimateUrl(record.authorId, baseUrl)
);
if (!personalVideo.ok || !xingtuVideo.ok || !estimates.ok) {
return {
failureReason:
personalVideo.failureReason ??
xingtuVideo.failureReason ??
estimates.failureReason,
status: "failed"
};
}
return {
estimates: mapBusinessAbilityEstimateResponse(estimates.payload),
status: "success",
videos: {
personalVideo: mapBusinessAbilityVideoResponse(personalVideo.payload),
xingtuVideo: mapBusinessAbilityVideoResponse(xingtuVideo.payload)
}
} satisfies BusinessAbilitySuccess;
}
};
async function loadJson(url: string): Promise<
| { ok: true; payload: unknown }
| { failureReason: string; ok: false }
> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, {
credentials: "include",
method: "GET",
signal: controller.signal
});
if (!response.ok) {
return { failureReason: "request-failed", ok: false };
}
return { ok: true, payload: await response.json() };
} catch (error) {
return {
failureReason:
error instanceof Error && error.name === "AbortError"
? "timeout"
: "request-failed",
ok: false
};
} finally {
clearTimeout(timeoutId);
}
}
}
export function buildBusinessAbilityVideoUrl(
authorId: string,
baseUrl: string,
videoType: number
): string {
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
url.searchParams.set("o_author_id", authorId);
url.searchParams.set("platform_source", "1");
url.searchParams.set("platform_channel", "1");
url.searchParams.set("type", String(videoType));
url.searchParams.set("flow_type", "0");
url.searchParams.set("only_assign", "true");
url.searchParams.set("range", "2");
return url.toString();
}
export function buildBusinessAbilityEstimateUrl(
authorId: string,
baseUrl: string
): string {
const url = new URL(
"/gw/api/aggregator/get_author_commerce_spread_info",
baseUrl
);
url.searchParams.set("o_author_id", authorId);
return url.toString();
}
export function mapBusinessAbilityVideoResponse(
payload: unknown
): BusinessAbilityVideoMetrics {
const data = getPayloadData(payload);
return {
averageComment: formatWan(readNumber(data?.comment_avg)),
averageDuration: formatDuration(readNumber(data?.avg_duration)),
averageLike: formatWan(readNumber(data?.like_avg)),
averageShare: formatWan(readNumber(data?.share_avg)),
finishRate: formatBasisPointRate(readNestedNumber(data, "play_over_rate", "value")),
interactionRate: formatBasisPointRate(
readNestedNumber(data, "interact_rate", "value")
),
medianPlay: formatWan(readNumber(data?.play_mid)),
publishedItems: formatPublishedItems(readNumber(data?.item_num))
};
}
export function mapBusinessAbilityEstimateResponse(
payload: unknown
): Partial<Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>> {
const data = getPayloadData(payload);
const expectedPlay = formatWan(readNumber(data?.vv));
const hotRate = formatDecimalRate(readNumber(data?.platform_hot_rate));
return {
oneToTwenty: {
expectedCpe: formatDecimal(readNumber(data?.cpe_1_20), 1),
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_1_20), 1),
expectedPlay,
hotRate
},
overSixty: {
expectedCpe: formatDecimal(readNumber(data?.cpe_60), 1),
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_60), 1),
expectedPlay,
hotRate
},
twentyToSixty: {
expectedCpe: formatDecimal(readNumber(data?.cpe_20_60), 1),
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_20_60), 1),
expectedPlay,
hotRate
}
};
}
function formatPublishedItems(value: number | null): string {
if (value === null) {
return "";
}
return value > 0 && value < 5 ? "<5" : formatDecimal(value, 0);
}
function formatDuration(value: number | null): string {
if (value === null) {
return "";
}
return `${formatDecimal(value / 100, 0)}s`;
}
function formatBasisPointRate(value: number | null): string {
if (value === null) {
return "";
}
return `${formatDecimal(value / 100, 1)}%`;
}
function formatDecimalRate(value: number | null): string {
if (value === null) {
return "缺失";
}
return `${formatDecimal(value * 100, 0)}%`;
}
function formatWan(value: number | null): string {
if (value === null) {
return "";
}
if (Math.abs(value) >= 10000) {
return `${formatDecimal(value / 10000, 1)}w`;
}
return formatDecimal(value, 0);
}
function formatDecimal(value: number | null, digits: number): string {
if (value === null || !Number.isFinite(value)) {
return "";
}
const fixed = value.toFixed(digits);
return fixed.replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
}
function formatFixedDecimal(value: number | null, digits: number): string {
if (value === null || !Number.isFinite(value)) {
return "";
}
return value.toFixed(digits);
}
function readNestedNumber(
data: Record<string, unknown> | null,
objectKey: string,
valueKey: string
): number | null {
const objectValue = data?.[objectKey];
if (!isRecord(objectValue)) {
return null;
}
return readNumber(objectValue[valueKey]);
}
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 getPayloadData(payload: unknown): Record<string, unknown> | null {
if (!isRecord(payload)) {
return null;
}
return isRecord(payload.data) ? payload.data : payload;
}
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;
}
+23 -11
View File
@@ -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;
};
@@ -28,7 +28,7 @@ const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
const RATE_COLUMNS: CsvColumn[] = [
{
header: "单视频看后搜率",
header: "单视频看后搜率",
readValue: (record: MarketRecord) =>
record.rates?.singleVideoAfterSearchRate
? normalizeRateDisplay(record.rates.singleVideoAfterSearchRate)
@@ -45,38 +45,45 @@ const RATE_COLUMNS: CsvColumn[] = [
const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
{
header: "看后搜率",
header: "秒思api-看后搜率",
readValue: (record: MarketRecord) =>
record.backendMetrics?.afterViewSearchRate ?? ""
},
{
header: "看后搜数",
header: "秒思api-看后搜数",
readValue: (record: MarketRecord) =>
record.backendMetrics?.afterViewSearchCount ?? ""
},
{
header: "新增A3数",
header: "秒思api-新增A3数",
readValue: (record: MarketRecord) =>
record.backendMetrics?.a3IncreaseCount ?? ""
},
{
header: "新增A3率",
header: "秒思api-新增A3率",
readValue: (record: MarketRecord) =>
record.backendMetrics?.newA3Rate ?? ""
},
{
header: "CPA3",
header: "秒思api-CPA3",
readValue: (record: MarketRecord) => record.backendMetrics?.cpa3 ?? ""
},
{
header: "cp_search",
header: "秒思api-cp_search",
readValue: (record: MarketRecord) => record.backendMetrics?.cpSearch ?? ""
}
];
export function listRateCsvHeaders(): string[] {
return RATE_COLUMNS.map((column) => column.header);
}
export function listBackendMetricCsvHeaders(): string[] {
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
}
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 +92,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(["代表视频"]);
+3 -3
View File
@@ -298,7 +298,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
const selectionHeader = ensureSyntheticHeaderCell(header, SELECTION_COLUMN_KEY, "");
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeader);
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
BACKEND_METRIC_COLUMNS.forEach(({ field, label }) => {
ensureSyntheticHeaderCell(header, field, label);
@@ -532,7 +532,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
pluginHeaderSection,
headerTemplateCell,
SINGLE_COLUMN_KEY,
"单视频看后搜率"
"单视频看后搜率"
);
ensureDivHeaderCell(
pluginHeaderSection,
@@ -1428,7 +1428,7 @@ function shouldExportColumn(label: string): boolean {
label !== ACTION_HEADER_TEXT &&
label !== BACKEND_HEADER_TEXT &&
!excludedBackendLabels.has(label) &&
label !== "单视频看后搜率" &&
label !== "单视频看后搜率" &&
label !== "个人视频看后搜率"
);
}
+446 -1
View File
@@ -1,4 +1,19 @@
import { buildMarketCsv } from "./csv-exporter";
import {
buildAudienceProfileCsv,
listAudienceProfileSelectableFieldGroups,
type AudienceProfileCsvOptions
} from "./audience-profile-csv";
import {
AUDIENCE_PROFILE_TARGETS,
createAudienceProfileClient,
type AudienceProfileRequestTarget
} from "./audience-profile-client";
import { createAuthorBaseClient } from "./author-base-client";
import { parseAuthorIds } from "./author-id-input";
import { createBusinessAbilityClient } from "./business-ability-client";
import { promptForAudienceProfileFields } from "./audience-profile-field-dialog";
import { promptForAuthorIds } from "./author-id-dialog";
import { promptForBatchName } from "./batch-name-dialog";
import { createBatchPayload, type BatchPayload } from "./batch-payload";
import {
@@ -27,6 +42,12 @@ import {
type AuthStateValue
} from "../../shared/auth-messages";
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
import type {
AudienceProfileExportRow,
AudienceProfileKind,
AudienceProfileResult,
BusinessAbilityResult
} from "./audience-profile-types";
import type {
BackendMetrics,
MarketApiResult,
@@ -42,9 +63,21 @@ interface MutationObserverLike {
}
export interface CreateMarketControllerOptions {
buildAudienceProfileCsv?: (
rows: AudienceProfileExportRow[],
options?: AudienceProfileCsvOptions
) => string;
buildCsv?: (records: MarketRecord[]) => string;
document: Document;
getAuthState?: () => Promise<AuthStateValue>;
loadAuthorBaseInfo?: (authorId: string) => Promise<MarketRecord>;
loadBusinessAbility?: (
record: MarketRecord
) => Promise<BusinessAbilityResult>;
loadAudienceProfile?: (
record: MarketRecord,
target: AudienceProfileRequestTarget
) => Promise<AudienceProfileResult>;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
searchBackendMetrics?: (starIds: string[]) => Promise<
Array<BackendMetrics & { starId: string }>
@@ -52,15 +85,22 @@ export interface CreateMarketControllerOptions {
mutationObserverFactory?: (
callback: MutationCallback
) => MutationObserverLike;
onCsvReady?: (csv: string) => void;
onCsvReady?: (csv: string, filename?: string) => void;
promptAuthorIds?: () => Promise<string | null> | string | null;
promptBatchName?: () => Promise<string | null> | string | null;
resultStore?: ReturnType<typeof createMarketResultStore>;
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
window: Window;
}
const AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY =
"sces:audience-profile:selectedHeaders";
export function createMarketController(options: CreateMarketControllerOptions) {
const marketApiClient = createMarketApiClient();
const audienceProfileClient = createAudienceProfileClient();
const authorBaseClient = createAuthorBaseClient();
const businessAbilityClient = createBusinessAbilityClient();
const sendRuntimeMessage = createRuntimeMessageSender();
const resultStore = options.resultStore ?? createMarketResultStore();
const loadAuthorMetrics =
@@ -69,6 +109,13 @@ 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 loadAuthorBaseInfo =
options.loadAuthorBaseInfo ?? authorBaseClient.loadAuthorBaseInfo;
const loadBusinessAbility =
options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility;
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
const mutationObserverFactory =
options.mutationObserverFactory ??
@@ -76,10 +123,24 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const promptBatchName =
options.promptBatchName ??
(() => promptForBatchName(options.document));
const promptAuthorIds =
options.promptAuthorIds ??
(() => promptForAuthorIds(options.document));
const submitBatch =
options.submitBatch ??
((payload: BatchPayload) =>
readBatchSubmitAck(sendRuntimeMessage, payload));
const audienceProfileTargets: Array<{
kind: AudienceProfileKind;
target: AudienceProfileRequestTarget;
}> = [
{ kind: "audience", target: AUDIENCE_PROFILE_TARGETS.audience },
{ kind: "fans", target: AUDIENCE_PROFILE_TARGETS.fans },
{
kind: "longtimeFans",
target: AUDIENCE_PROFILE_TARGETS.longtimeFans
}
];
let activeProgressLabel = "导出中";
let shouldShowDetailedProgress = true;
const exportRangeController = createExportRangeController({
@@ -164,6 +225,143 @@ 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 [profiles, businessAbility] = await Promise.all([
loadAudienceProfileSet(record),
loadBusinessAbilitySafe(record)
]);
rows.push({
businessAbility,
profiles,
record
});
}
if (
rows.every((row) =>
Object.values(row.profiles).every((profile) => profile.status === "failed")
)
) {
setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试");
return;
}
options.onCsvReady?.(
buildAudienceCsv(rows, {
selectedHeaders: readAudienceProfileSelectedHeaders()
}),
buildAudienceProfileFilename()
);
setToolbarExportStatus(toolbar, "");
} catch (error) {
setToolbarExportStatus(
toolbar,
error instanceof Error ? error.message : "画像导出失败,请稍后重试"
);
} finally {
setToolbarBusyState(toolbar, false);
}
},
onExportAudienceProfileByIds: async () => {
const input = await promptAuthorIds();
if (input === null) {
return;
}
const parsed = parseAuthorIds(input);
if (parsed.ids.length === 0) {
setToolbarExportStatus(toolbar, "请输入有效的达人星图ID");
return;
}
setToolbarBusyState(toolbar, true);
try {
setToolbarExportStatus(
toolbar,
`识别 ${parsed.ids.length + parsed.duplicates.length + parsed.invalidTokens.length} 个,去重后 ${parsed.ids.length} 个,非法 ${parsed.invalidTokens.length}`
);
const backendMetricsByAuthorId = await loadBackendMetricsMap(parsed.ids);
const rows: AudienceProfileExportRow[] = [];
for (let index = 0; index < parsed.ids.length; index += 1) {
const authorId = parsed.ids[index];
setToolbarExportStatus(
toolbar,
`按ID画像导出中 ${index + 1}/${parsed.ids.length}...`
);
rows.push(
await loadAudienceProfileRowById(
authorId,
backendMetricsByAuthorId.get(authorId)
)
);
}
options.onCsvReady?.(
buildAudienceCsv(rows, {
selectedHeaders: readAudienceProfileSelectedHeaders()
}),
buildAudienceProfileFilename(new Date(), "按ID导出")
);
setToolbarExportStatus(toolbar, "");
} catch (error) {
setToolbarExportStatus(
toolbar,
error instanceof Error ? error.message : "按ID导出失败,请稍后重试"
);
} finally {
setToolbarBusyState(toolbar, false);
}
},
onConfigureAudienceProfileFields: async () => {
const groups = listAudienceProfileSelectableFieldGroups();
const selectedHeaders = readAudienceProfileSelectedHeaders();
const nextHeaders = await promptForAudienceProfileFields(
options.document,
groups,
selectedHeaders
);
if (nextHeaders === null) {
return;
}
saveAudienceProfileSelectedHeaders(nextHeaders);
setToolbarExportStatus(
toolbar,
`字段已保存(已选 ${nextHeaders.length}/${readAudienceProfileSelectableHeaders().length} 个字段)`
);
},
onSubmitBatch: async () => {
syncSelectionStateFromDom();
const exportTarget = readToolbarExportTarget(toolbar);
@@ -562,6 +760,240 @@ 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 loadAudienceProfileSet(
record: MarketRecord
): Promise<AudienceProfileExportRow["profiles"]> {
const profiles = {} as AudienceProfileExportRow["profiles"];
for (const { kind, target } of audienceProfileTargets) {
try {
profiles[kind] = await loadAudienceProfile(record, target);
} catch (error) {
profiles[kind] = {
failureReason:
error instanceof Error ? error.message : "request-failed",
status: "failed"
};
}
}
return profiles;
}
async function loadBusinessAbilitySafe(
record: MarketRecord
): Promise<BusinessAbilityResult> {
try {
return await loadBusinessAbility(record);
} catch (error) {
return {
failureReason:
error instanceof Error ? error.message : "request-failed",
status: "failed"
};
}
}
async function loadAudienceProfileRowById(
authorId: string,
backendMetrics?: BackendMetrics
): Promise<AudienceProfileExportRow> {
const [baseRecord, metricsResult] = await Promise.all([
loadAuthorBaseInfoSafe(authorId),
loadAuthorMetricsSafe(authorId)
]);
const recordForRequests = {
...baseRecord,
authorName: baseRecord.authorName || authorId,
...(metricsResult.success ? { rates: metricsResult.rates } : {}),
...(backendMetrics
? { backendMetrics, backendMetricsStatus: "success" as const }
: {})
};
const [profiles, businessAbility] = await Promise.all([
loadAudienceProfileSet(recordForRequests),
loadBusinessAbilitySafe(recordForRequests)
]);
const failureReasons = collectAudienceProfileRowFailures(
baseRecord,
profiles,
businessAbility
);
const rowStatus =
failureReasons.length === 0
? "成功"
: hasAudienceProfileRowSuccess(baseRecord, profiles, businessAbility)
? "部分成功"
: "失败";
const authorName = baseRecord.authorName || "";
return {
businessAbility,
profiles,
record: {
...recordForRequests,
exportFields: {
达人ID: authorId,
达人名称: authorName,
导出状态: rowStatus,
失败原因: failureReasons.join("; ")
}
}
};
}
async function loadAuthorBaseInfoSafe(authorId: string): Promise<MarketRecord> {
try {
return await loadAuthorBaseInfo(authorId);
} catch (error) {
return {
authorId,
authorName: "",
failureReason:
error instanceof Error ? "request-failed" : "request-failed",
status: "failed"
};
}
}
async function loadAuthorMetricsSafe(
authorId: string
): Promise<MarketApiResult> {
try {
return await loadAuthorMetrics(authorId);
} catch {
return {
reason: "request-failed",
success: false
};
}
}
async function loadBackendMetricsMap(
authorIds: string[]
): Promise<Map<string, BackendMetrics>> {
const metricsMap = new Map<string, BackendMetrics>();
if (!searchBackendMetrics || authorIds.length === 0) {
return metricsMap;
}
try {
const rows = await searchBackendMetrics(authorIds);
rows.forEach((row) => {
const { starId, ...backendMetrics } = row;
metricsMap.set(starId, backendMetrics);
});
} catch {
return metricsMap;
}
return metricsMap;
}
function collectAudienceProfileRowFailures(
baseRecord: MarketRecord,
profiles: AudienceProfileExportRow["profiles"],
businessAbility: BusinessAbilityResult
): string[] {
const failures: string[] = [];
if (baseRecord.status === "failed") {
failures.push(`基础信息:${baseRecord.failureReason ?? "request-failed"}`);
}
Object.entries(profiles).forEach(([kind, profile]) => {
if (profile.status === "failed") {
failures.push(`${readAudienceProfileKindLabel(kind as AudienceProfileKind)}:${profile.failureReason ?? "request-failed"}`);
}
});
if (businessAbility.status === "failed") {
failures.push(`商业能力:${businessAbility.failureReason ?? "request-failed"}`);
}
return failures;
}
function hasAudienceProfileRowSuccess(
baseRecord: MarketRecord,
profiles: AudienceProfileExportRow["profiles"],
businessAbility: BusinessAbilityResult
): boolean {
return (
baseRecord.status === "success" ||
businessAbility.status === "success" ||
Object.values(profiles).some((profile) => profile.status === "success")
);
}
function readAudienceProfileKindLabel(kind: AudienceProfileKind): string {
if (kind === "audience") {
return "观众画像";
}
if (kind === "fans") {
return "粉丝画像";
}
return "铁粉画像";
}
function readAudienceProfileSelectableHeaders(): string[] {
return listAudienceProfileSelectableFieldGroups().flatMap(
(group) => group.headers
);
}
function readAudienceProfileSelectedHeaders(): string[] {
const selectableHeaders = readAudienceProfileSelectableHeaders();
const selectableHeaderSet = new Set(selectableHeaders);
try {
const rawValue = options.window.localStorage?.getItem(
AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY
);
if (!rawValue) {
return selectableHeaders;
}
const parsedValue = JSON.parse(rawValue) as unknown;
if (!Array.isArray(parsedValue)) {
return selectableHeaders;
}
const selectedHeaders = parsedValue.filter(
(header): header is string =>
typeof header === "string" && selectableHeaderSet.has(header)
);
return selectedHeaders.length > 0 ? selectedHeaders : selectableHeaders;
} catch {
return selectableHeaders;
}
}
function saveAudienceProfileSelectedHeaders(headers: string[]): void {
const selectableHeaderSet = new Set(readAudienceProfileSelectableHeaders());
const selectedHeaders = headers.filter((header) =>
selectableHeaderSet.has(header)
);
try {
options.window.localStorage?.setItem(
AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY,
JSON.stringify(selectedHeaders)
);
} catch {
// localStorage may be unavailable in hardened browser contexts.
}
}
async function prepareCurrentPageForExport(): Promise<void> {
await runSyncCycle();
await harvestCurrentPageForExport();
@@ -1270,3 +1702,16 @@ function hasRuntimeMessageSender(): boolean {
).chrome?.runtime?.sendMessage
);
}
function buildAudienceProfileFilename(
date = new Date(),
label?: string
): 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");
const labelPart = label ? `_${label}` : "";
return `达人连接用户画像${labelPart}_${year}${month}${day}_${hour}${minute}.csv`;
}
+100 -11
View File
@@ -5,10 +5,16 @@ import type {
export interface PluginToolbarHandlers {
onExport(): Promise<void> | void;
onExportAudienceProfile(): Promise<void> | void;
onExportAudienceProfileByIds(): Promise<void> | void;
onConfigureAudienceProfileFields(): Promise<void> | void;
onSubmitBatch(): Promise<void> | void;
}
export interface PluginToolbarDom {
audienceProfileByIdExportButton: HTMLButtonElement;
audienceProfileExportButton: HTMLButtonElement;
audienceProfileFieldButton: HTMLButtonElement;
batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
@@ -37,8 +43,16 @@ export function ensurePluginToolbar(
"[data-plugin-toolbar='root']"
) as HTMLElement | null;
if (existingRoot) {
ensureToolbarMounted(existingRoot, document);
return readToolbarDom(existingRoot);
if (
existingRoot.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
)
) {
ensureToolbarMounted(existingRoot, document);
return readToolbarDom(existingRoot);
}
existingRoot.remove();
}
const root = document.createElement("section");
@@ -47,6 +61,7 @@ export function ensurePluginToolbar(
const exportRangeSelect = document.createElement("select");
exportRangeSelect.dataset.pluginExportRange = "select";
exportRangeSelect.hidden = true;
appendOption(exportRangeSelect, "current", "当前页");
appendOption(exportRangeSelect, "first-5", "前5页");
appendOption(exportRangeSelect, "first-10", "前10页");
@@ -65,12 +80,35 @@ export function ensurePluginToolbar(
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
exportButton.hidden = true;
exportButton.tabIndex = -1;
const audienceProfileExportButton = document.createElement("button");
audienceProfileExportButton.type = "button";
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
audienceProfileExportButton.textContent = "导出选中达人数据";
audienceProfileExportButton.title =
"仅导出已勾选达人,包含内容数据、效果预估、画像等维度";
const audienceProfileByIdExportButton = document.createElement("button");
audienceProfileByIdExportButton.type = "button";
audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button";
audienceProfileByIdExportButton.textContent = "按星图ID导出";
audienceProfileByIdExportButton.title =
"粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选";
const audienceProfileFieldButton = document.createElement("button");
audienceProfileFieldButton.type = "button";
audienceProfileFieldButton.dataset.pluginAudienceProfileFields = "button";
audienceProfileFieldButton.textContent = "选择字段";
audienceProfileFieldButton.title =
"勾选本次CSV需要导出的字段,设置会自动保存";
const batchSubmitButton = document.createElement("button");
batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button";
batchSubmitButton.textContent = "提交批次";
batchSubmitButton.title = "将当前选中的达人提交到后续业务批次";
const exportStatusText = document.createElement("span");
exportStatusText.dataset.pluginExportStatus = "text";
@@ -80,12 +118,18 @@ export function ensurePluginToolbar(
exportRangeSelect,
exportCustomPagesInput,
exportButton,
audienceProfileExportButton,
audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton,
exportStatusText
);
document.body.appendChild(root);
applyNativeControlStyles(document, {
audienceProfileExportButton,
audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton,
exportButton,
exportCustomPagesInput,
@@ -96,12 +140,24 @@ export function ensurePluginToolbar(
exportButton.addEventListener("click", () => {
void handlers.onExport();
});
audienceProfileExportButton.addEventListener("click", () => {
void handlers.onExportAudienceProfile();
});
audienceProfileByIdExportButton.addEventListener("click", () => {
void handlers.onExportAudienceProfileByIds();
});
audienceProfileFieldButton.addEventListener("click", () => {
void handlers.onConfigureAudienceProfileFields();
});
batchSubmitButton.addEventListener("click", () => {
void handlers.onSubmitBatch();
});
exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({
batchSubmitButton,
audienceProfileFieldButton,
audienceProfileByIdExportButton,
audienceProfileExportButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect,
@@ -111,6 +167,9 @@ export function ensurePluginToolbar(
});
const toolbarDom = {
audienceProfileExportButton,
audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton,
exportButton,
exportCustomPagesInput,
@@ -136,6 +195,15 @@ function appendOption(
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
const toolbarDom = {
audienceProfileByIdExportButton: root.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
) as HTMLButtonElement,
audienceProfileExportButton: root.querySelector(
'[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement,
audienceProfileFieldButton: root.querySelector(
'[data-plugin-audience-profile-fields="button"]'
) as HTMLButtonElement,
batchSubmitButton: root.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement,
@@ -218,6 +286,9 @@ export function setToolbarBusyState(
): void {
[
toolbar.batchSubmitButton,
toolbar.audienceProfileFieldButton,
toolbar.audienceProfileByIdExportButton,
toolbar.audienceProfileExportButton,
toolbar.exportButton,
toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput
@@ -234,8 +305,8 @@ export function setToolbarExportStatus(
}
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportCustomPagesInput.hidden =
toolbar.exportRangeSelect.value !== "custom";
toolbar.exportRangeSelect.hidden = true;
toolbar.exportCustomPagesInput.hidden = true;
}
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
@@ -414,6 +485,9 @@ function applyToolbarRootStyles(root: HTMLElement): void {
function applyNativeControlStyles(
document: Document,
controls: {
audienceProfileExportButton: HTMLButtonElement;
audienceProfileByIdExportButton: HTMLButtonElement;
audienceProfileFieldButton: HTMLButtonElement;
batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
@@ -429,11 +503,18 @@ function applyNativeControlStyles(
findNativeActionButton(document, "导出");
if (nativeButton) {
controls.exportButton.className = nativeButton.className;
controls.audienceProfileExportButton.className = nativeButton.className;
controls.audienceProfileByIdExportButton.className = nativeButton.className;
controls.audienceProfileFieldButton.className = nativeButton.className;
controls.batchSubmitButton.className = nativeButton.className;
}
[controls.exportButton, controls.batchSubmitButton].forEach((button) => {
[
controls.audienceProfileExportButton,
controls.audienceProfileByIdExportButton,
controls.audienceProfileFieldButton,
controls.batchSubmitButton
].forEach((button) => {
applyPrimaryButtonStyles(button);
button.style.whiteSpace = "nowrap";
});
@@ -483,26 +564,34 @@ function ensurePluginActionButtonTheme(document: Document): void {
const style = document.createElement("style");
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
style.textContent = `
[data-plugin-export="button"]:hover:not(:disabled),
[data-plugin-export-audience-profile="button"]:hover:not(:disabled),
[data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled),
[data-plugin-audience-profile-fields="button"]:hover:not(:disabled),
[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-export-audience-profile-by-id="button"]:active:not(:disabled),
[data-plugin-audience-profile-fields="button"]:active:not(:disabled),
[data-plugin-batch-submit="button"]:active:not(:disabled) {
background-color: #58111f !important;
border-color: #58111f !important;
transform: translateY(1px);
}
[data-plugin-export="button"]:focus-visible,
[data-plugin-export-audience-profile="button"]:focus-visible,
[data-plugin-export-audience-profile-by-id="button"]:focus-visible,
[data-plugin-audience-profile-fields="button"]:focus-visible,
[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-export-audience-profile-by-id="button"]:disabled,
[data-plugin-audience-profile-fields="button"]:disabled,
[data-plugin-batch-submit="button"]:disabled {
background-color: #c89ca4 !important;
border-color: #c89ca4 !important;
+177
View File
@@ -4,6 +4,183 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Chart Search Enhancer</title>
<style>
:root {
color-scheme: light;
--popup-bg: #f3f4f6;
--popup-card: #ffffff;
--popup-text: #111827;
--popup-subtle: #6b7280;
--popup-border: #d1d5db;
--popup-accent: #8f1f4b;
--popup-accent-strong: #74183b;
--popup-success: #065f46;
--popup-warning: #92400e;
}
html,
body {
width: 380px;
min-height: 560px;
margin: 0;
background: var(--popup-bg);
color: var(--popup-text);
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
body {
box-sizing: border-box;
}
#app {
box-sizing: border-box;
padding: 14px;
}
.popup-shell {
display: grid;
gap: 12px;
}
.popup-header {
padding: 6px 2px 2px;
}
.popup-eyebrow {
margin: 0 0 6px;
color: var(--popup-subtle);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.popup-header h1 {
margin: 0;
font-size: 24px;
line-height: 1.08;
font-weight: 800;
letter-spacing: -0.03em;
}
.popup-card {
background: var(--popup-card);
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: 16px;
padding: 14px;
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.06);
}
.popup-card-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
color: var(--popup-subtle);
}
.popup-status {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
}
.popup-status--accent {
color: var(--popup-accent);
}
.popup-user {
margin: 0 0 2px;
font-size: 16px;
font-weight: 700;
}
.popup-copy,
.popup-error,
.popup-warning,
.popup-success {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.5;
color: var(--popup-subtle);
}
.popup-error {
color: #b42318;
}
.popup-warning {
color: var(--popup-warning);
font-weight: 600;
}
.popup-success {
color: var(--popup-success);
font-weight: 600;
}
.popup-notes {
margin: 0 0 12px;
padding-left: 18px;
color: var(--popup-text);
font-size: 13px;
line-height: 1.45;
}
.popup-notes li + li {
margin-top: 6px;
}
.popup-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.popup-footer {
display: flex;
justify-content: flex-start;
}
.popup-button {
appearance: none;
border: 1px solid var(--popup-border);
border-radius: 10px;
padding: 9px 12px;
font-size: 13px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.popup-button--primary {
border-color: var(--popup-accent);
background: var(--popup-accent);
color: #fff;
}
.popup-button--primary:hover {
background: var(--popup-accent-strong);
border-color: var(--popup-accent-strong);
}
.popup-button--secondary {
background: #fff;
color: var(--popup-text);
}
.popup-button--secondary:hover,
.popup-button--ghost:hover {
background: rgba(17, 24, 39, 0.04);
}
.popup-button--ghost {
background: transparent;
color: var(--popup-subtle);
border-color: transparent;
padding-inline: 0;
}
</style>
</head>
<body>
<main id="app"></main>
+138 -5
View File
@@ -2,6 +2,8 @@ import {
renderDevPanel,
renderLoggedIn,
renderLoggedOut,
renderUpdateStatus,
setUpdateDownloadStatus,
setProtectedApiResult
} from "./view";
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
@@ -10,17 +12,27 @@ import {
type AuthResponseMessage
} from "../shared/auth-messages";
import { createProtectedApiClient } from "../shared/protected-api-client";
import {
compareExtensionVersions,
fetchUpdateManifest as fetchUpdateManifestFromUrl,
type UpdateManifest
} from "../shared/update-check";
import { UPDATE_MANIFEST_URL } from "../shared/update-config";
interface BootPopupOptions {
config?: Partial<AuthConfig>;
currentVersion?: string;
document?: Document;
fetchProtectedApi?: () => Promise<unknown>;
fetchUpdateManifest?: () => Promise<UpdateManifest>;
sendMessage?: (message: unknown) => Promise<unknown>;
updateManifestUrl?: string;
}
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
const currentDocument = options.document ?? document;
const popupConfig = readAuthConfig(options.config);
const currentVersion = options.currentVersion ?? readCurrentVersion();
const root = currentDocument.querySelector("#app");
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
@@ -48,15 +60,28 @@ export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
baseUrl: "http://127.0.0.1:4319",
sendMessage
}).loadProtectedMockData;
const fetchUpdateManifest =
options.fetchUpdateManifest ??
(() =>
fetchUpdateManifestFromUrl(
options.updateManifestUrl ?? UPDATE_MANIFEST_URL
));
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi);
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, {
currentVersion,
fetchUpdateManifest
});
}
async function renderCurrentAuthState(
root: HTMLElement,
popupConfig: AuthConfig,
sendMessage: (message: unknown) => Promise<unknown>,
fetchProtectedApi: () => Promise<unknown>
fetchProtectedApi: () => Promise<unknown>,
updateOptions: {
currentVersion: string;
fetchUpdateManifest: () => Promise<UpdateManifest>;
}
): Promise<void> {
const response = await sendMessage({ type: "auth:get-state" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
@@ -71,19 +96,22 @@ async function renderCurrentAuthState(
?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, {
actionMessage: { type: "auth:sign-in" },
fetchProtectedApi
fetchProtectedApi,
updateOptions
});
});
return;
}
renderLoggedIn(root, response.value);
await runUpdateCheck(root, sendMessage, updateOptions);
root
.querySelector('[data-popup-sign-out="button"]')
?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, {
actionMessage: { type: "auth:sign-out" },
fetchProtectedApi
fetchProtectedApi,
updateOptions
});
});
if (popupConfig.enableDevAuthPanel) {
@@ -103,6 +131,10 @@ async function runAuthAction(
options: {
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
fetchProtectedApi: () => Promise<unknown>;
updateOptions: {
currentVersion: string;
fetchUpdateManifest: () => Promise<UpdateManifest>;
};
}
): Promise<void> {
const response = await sendMessage(options.actionMessage);
@@ -121,7 +153,8 @@ async function runAuthAction(
root,
popupConfig,
sendMessage,
options.fetchProtectedApi
options.fetchProtectedApi,
options.updateOptions
);
}
@@ -133,6 +166,106 @@ function isActionError(response: unknown): response is Extract<AuthResponseMessa
);
}
async function runUpdateCheck(
root: HTMLElement,
sendMessage: (message: unknown) => Promise<unknown>,
options: {
currentVersion: string;
fetchUpdateManifest: () => Promise<UpdateManifest>;
}
): Promise<void> {
renderUpdateStatus(root, {
currentVersion: options.currentVersion,
status: "checking"
});
try {
const manifest = await options.fetchUpdateManifest();
if (compareExtensionVersions(manifest.latestVersion, options.currentVersion) <= 0) {
renderUpdateStatus(root, {
currentVersion: options.currentVersion,
status: "latest"
});
return;
}
renderUpdateStatus(root, {
currentVersion: options.currentVersion,
manifest,
status: "available"
});
bindUpdateDownloadButtons(root, sendMessage, manifest);
} catch (error) {
renderUpdateStatus(root, {
currentVersion: options.currentVersion,
message: error instanceof Error ? error.message : String(error),
status: "error"
});
}
}
function bindUpdateDownloadButtons(
root: HTMLElement,
sendMessage: (message: unknown) => Promise<unknown>,
manifest: UpdateManifest
): void {
root
.querySelector('[data-popup-download-update="button"]')
?.addEventListener("click", () => {
void downloadUpdateAsset(root, sendMessage, {
filename: "star-chart-search-enhancer-internal.zip",
url: manifest.zipUrl
});
});
root
.querySelector('[data-popup-download-guide="button"]')
?.addEventListener("click", () => {
void downloadUpdateAsset(root, sendMessage, {
filename: "星图增强插件-超简单安装使用指南.pdf",
url: manifest.guideUrl
});
});
}
async function downloadUpdateAsset(
root: HTMLElement,
sendMessage: (message: unknown) => Promise<unknown>,
options: {
filename: string;
url: string;
}
): Promise<void> {
setUpdateDownloadStatus(root, "正在下载...");
try {
await sendMessage({
filename: options.filename,
type: "update:download",
url: options.url
});
setUpdateDownloadStatus(root, "已触发下载。下载后请解压新版 zip,并在 chrome://extensions 里重新加载插件。");
} catch (error) {
setUpdateDownloadStatus(
root,
error instanceof Error ? error.message : "下载失败,请稍后重试"
);
}
}
function readCurrentVersion(): string {
const runtime = (
globalThis as typeof globalThis & {
chrome?: {
runtime?: {
getManifest?: () => { version?: string };
};
};
}
).chrome?.runtime;
return runtime?.getManifest?.().version ?? "0.0.0";
}
async function runProtectedApiProbe(
root: HTMLElement,
fetchProtectedApi: () => Promise<unknown>
+127 -18
View File
@@ -1,12 +1,20 @@
import type { AuthStateValue } from "../shared/auth-messages";
import type { UpdateManifest } from "../shared/update-check";
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
root.innerHTML = `
<section data-popup-state="logged-out">
<h1>Star Chart Search Enhancer</h1>
<p>使</p>
${error ? `<p data-popup-error="true">${error}</p>` : ""}
<button type="button" data-popup-sign-in="button"> Logto</button>
<section class="popup-shell" data-popup-shell="root" data-popup-state="logged-out">
<header class="popup-header" data-popup-header="root">
<p class="popup-eyebrow"></p>
<h1>Star Chart Search Enhancer</h1>
</header>
<section class="popup-card popup-card--account" data-popup-account="card">
<div class="popup-card-title"></div>
<p class="popup-status"></p>
<p class="popup-copy">使</p>
${error ? `<p class="popup-error" data-popup-error="true">${escapeHtml(error)}</p>` : ""}
<button type="button" class="popup-button popup-button--primary" data-popup-sign-in="button"> Logto</button>
</section>
</section>
`;
}
@@ -18,30 +26,131 @@ export function renderLoggedIn(
const userInfo = authState.userInfo;
root.innerHTML = `
<section data-popup-state="logged-in">
<h1>Star Chart Search Enhancer</h1>
<p></p>
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
<p>${userInfo?.email ?? ""}</p>
<button type="button" data-popup-sign-out="button">退</button>
<section class="popup-shell" data-popup-shell="root" data-popup-state="logged-in">
<header class="popup-header" data-popup-header="root">
<p class="popup-eyebrow"></p>
<h1>Star Chart Search Enhancer</h1>
</header>
<section class="popup-card popup-card--account" data-popup-account="card">
<div class="popup-card-title"></div>
<p class="popup-status"></p>
<p class="popup-user">${escapeHtml(userInfo?.name ?? userInfo?.username ?? "未知用户")}</p>
<p class="popup-copy">${escapeHtml(userInfo?.email ?? "")}</p>
</section>
<section class="popup-card popup-card--update" data-popup-update="card" data-popup-update-root="root">
<div class="popup-card-title"></div>
<p data-popup-update-status="text" class="popup-status">...</p>
</section>
<footer class="popup-footer">
<button type="button" class="popup-button popup-button--ghost" data-popup-sign-out="button">退</button>
</footer>
</section>
`;
}
export function renderUpdateStatus(
root: HTMLElement,
options: {
currentVersion: string;
manifest?: UpdateManifest;
message?: string | null;
status: "checking" | "error" | "latest" | "available";
}
): void {
const container = root.querySelector('[data-popup-update-root="root"]');
if (!container) {
return;
}
if (options.status === "checking") {
container.innerHTML = `
<div class="popup-card-title"></div>
<p data-popup-update-status="text" class="popup-status">${options.currentVersion}</p>
<p class="popup-copy">...</p>
`;
return;
}
if (options.status === "error") {
container.innerHTML = `
<div class="popup-card-title"></div>
<p data-popup-update-status="text" class="popup-status">${options.currentVersion}</p>
<p class="popup-warning"></p>
${options.message ? `<p class="popup-error">${escapeHtml(options.message)}</p>` : ""}
<p class="popup-copy"></p>
`;
return;
}
if (options.status === "latest" || !options.manifest) {
container.innerHTML = `
<div class="popup-card-title"></div>
<p data-popup-update-status="text" class="popup-status">${options.currentVersion}</p>
<p class="popup-success"></p>
`;
return;
}
container.innerHTML = `
<div class="popup-card-title"></div>
<p data-popup-update-status="text" class="popup-status">${options.currentVersion}</p>
<p class="popup-status popup-status--accent">${options.manifest.latestVersion}</p>
${renderReleaseNotes(options.manifest.releaseNotes)}
<div class="popup-actions">
<button type="button" class="popup-button popup-button--primary" data-popup-download-update="button"></button>
<button type="button" class="popup-button popup-button--secondary" data-popup-download-guide="button">使</button>
</div>
<p data-popup-update-download-status="text" class="popup-copy"> zip chrome://extensions 里重新加载插件。</p>
`;
}
export function setUpdateDownloadStatus(
root: HTMLElement,
value: string
): void {
const output = root.querySelector('[data-popup-update-download-status="text"]');
if (!output) {
return;
}
output.textContent = value;
}
function renderReleaseNotes(releaseNotes: string[]): string {
if (releaseNotes.length === 0) {
return "";
}
return `
<ul class="popup-notes">
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
</ul>
`;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function renderDevPanel(
root: HTMLElement,
authState: AuthStateValue
): void {
const panel = root.ownerDocument.createElement("section");
panel.className = "popup-card popup-card--dev";
panel.dataset.popupDevPanel = "root";
panel.innerHTML = `
<h2>dev auth panel</h2>
<p>resource: ${authState.resource ?? ""}</p>
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
<p>error: ${authState.lastError ?? ""}</p>
<button type="button" data-popup-test-protected-api="button"></button>
<div class="popup-card-title">dev auth panel</div>
<p class="popup-copy">resource: ${escapeHtml(authState.resource ?? "")}</p>
<p class="popup-copy">scopes: ${escapeHtml((authState.scopes ?? []).join(", "))}</p>
<p class="popup-copy">token: ${authState.tokenAvailable ? "available" : "missing"}</p>
<p class="popup-copy">expires: ${escapeHtml(String(authState.accessTokenExpiresAt ?? "unknown"))}</p>
<p class="popup-copy">error: ${escapeHtml(authState.lastError ?? "")}</p>
<button type="button" class="popup-button popup-button--secondary" data-popup-test-protected-api="button"></button>
<pre data-popup-protected-api-result="output"></pre>
`;
root.appendChild(panel);
+94
View File
@@ -0,0 +1,94 @@
export interface UpdateManifest {
guideUrl: string;
latestVersion: string;
minSupportedVersion: string;
publishedAt: string;
releaseNotes: string[];
zipUrl: string;
}
export function compareExtensionVersions(left: string, right: string): number {
const leftParts = parseVersionParts(left);
const rightParts = parseVersionParts(right);
const maxLength = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < maxLength; index += 1) {
const leftValue = leftParts[index] ?? 0;
const rightValue = rightParts[index] ?? 0;
if (leftValue !== rightValue) {
return leftValue - rightValue;
}
}
return 0;
}
export function parseUpdateManifest(value: unknown): UpdateManifest | null {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value as Partial<UpdateManifest>;
if (
!isVersionString(candidate.latestVersion) ||
!isVersionString(candidate.minSupportedVersion) ||
!isHttpsUrl(candidate.zipUrl) ||
!isHttpsUrl(candidate.guideUrl) ||
typeof candidate.publishedAt !== "string" ||
!Array.isArray(candidate.releaseNotes) ||
!candidate.releaseNotes.every((note) => typeof note === "string")
) {
return null;
}
return {
guideUrl: candidate.guideUrl,
latestVersion: candidate.latestVersion,
minSupportedVersion: candidate.minSupportedVersion,
publishedAt: candidate.publishedAt,
releaseNotes: candidate.releaseNotes,
zipUrl: candidate.zipUrl
};
}
export async function fetchUpdateManifest(
manifestUrl: string,
fetchImpl: typeof fetch = fetch
): Promise<UpdateManifest> {
const response = await fetchImpl(manifestUrl, {
cache: "no-store"
});
if (!response.ok) {
throw new Error(`update manifest request failed: ${response.status}`);
}
const manifest = parseUpdateManifest(await response.json());
if (!manifest) {
throw new Error("update manifest is invalid");
}
return manifest;
}
function parseVersionParts(value: string): number[] {
return value.split(".").map((part) => {
const parsed = Number.parseInt(part, 10);
return Number.isFinite(parsed) ? parsed : 0;
});
}
function isVersionString(value: unknown): value is string {
return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value);
}
function isHttpsUrl(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
try {
return new URL(value).protocol === "https:";
} catch {
return false;
}
}
+2
View File
@@ -0,0 +1,2 @@
export const UPDATE_MANIFEST_URL =
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json";
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, test, vi } from "vitest";
import {
AUDIENCE_PROFILE_TARGETS,
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"
}, AUDIENCE_PROFILE_TARGETS.audience);
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=5",
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("loads fans and iron fan profiles from Xingtu fan distribution endpoint", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => buildAudiencePayload(),
ok: true
}));
const client = createAudienceProfileClient({
baseUrl: "https://www.xingtu.cn",
fetchImpl,
timeoutMs: 1000
});
await client.loadAudienceProfile({
authorId: "7294473194298146854",
authorName: "奇奇de海洋",
status: "success"
}, AUDIENCE_PROFILE_TARGETS.longtimeFans);
expect(fetchImpl).toHaveBeenCalledWith(
"https://www.xingtu.cn/gw/api/data_sp/get_author_fans_distribution?o_author_id=7294473194298146854&platform_source=1&author_type=5",
expect.objectContaining({
credentials: "include",
method: "GET"
})
);
});
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: "八大人群分布"
}
]
};
}
+331
View File
@@ -0,0 +1,331 @@
import { describe, expect, test } from "vitest";
import {
buildAudienceProfileCsv,
listAudienceProfileSelectableFieldGroups,
listAudienceProfileCsvHeaders
} from "../src/content/market/audience-profile-csv";
import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types";
describe("audience-profile-csv", () => {
test("exports only requested profile distribution columns", () => {
const csv = buildAudienceProfileCsv([
{
profiles: {
audience: {
age: [{ label: "31-40", value: "50%" }],
cityTier: [{ label: "一线城市", value: "100%" }],
crowd: [{ label: "都市蓝领", value: "100%" }],
gender: [
{ label: "男性", value: "71.7%" },
{ label: "女性", value: "28.3%" }
],
status: "success"
},
fans: {
age: [{ label: "31-40", value: "40%" }],
cityTier: [{ label: "一线城市", value: "80%" }],
crowd: [{ label: "都市蓝领", value: "60%" }],
gender: [
{ label: "男性", value: "60%" },
{ label: "女性", value: "40%" }
],
status: "success"
},
longtimeFans: {
age: [{ label: "31-40", value: "30%" }],
cityTier: [{ label: "一线城市", value: "70%" }],
crowd: [{ label: "都市蓝领", value: "50%" }],
status: "success"
}
},
businessAbility: {
estimates: {
oneToTwenty: {
expectedCpe: "2.1",
expectedCpm: "120.0",
expectedPlay: "250w",
hotRate: "100%"
},
twentyToSixty: {
expectedCpe: "3.7",
expectedCpm: "212.0",
expectedPlay: "250w",
hotRate: "缺失"
}
},
status: "success",
videos: {
personalVideo: {
averageComment: "4.5w",
averageDuration: "150s",
averageLike: "113.2w",
averageShare: "26.5w",
finishRate: "15.8%",
interactionRate: "3.9%",
medianPlay: "3738.4w",
publishedItems: "<5"
},
xingtuVideo: {
averageComment: "5.1w",
averageDuration: "170s",
averageLike: "150.3w",
averageShare: "68.4w",
finishRate: "19.9%",
interactionRate: "5.5%",
medianPlay: "4059.7w",
publishedItems: "<5"
}
}
},
record: {
authorId: "123",
authorName: "达人 A",
exportFields: {
: "达人 A",
: "300w"
},
status: "success"
}
} satisfies AudienceProfileExportRow
]);
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toContain("达人信息,连接用户数");
expect(headerLine).not.toContain("抓取状态");
expect(headerLine).not.toContain("失败原因");
expect(headerLine).toContain("内容数据-个人视频-播放量中位数");
expect(headerLine).toContain("内容数据-星图视频-平均转发");
expect(headerLine).toContain("效果预估-1-20s视频-预期CPM");
expect(headerLine).toContain("效果预估-20-60s视频-爆文率");
expect(headerLine).toContain("效果预估-60s以上视频-预期播放量");
expect(headerLine).not.toContain("商业能力-个人视频-播放量中位数");
expect(headerLine).not.toContain("商业能力-20-60s视频-预期CPM");
expect(headerLine).toContain("观众画像-男性占比");
expect(headerLine).toContain("粉丝画像-女性占比");
expect(headerLine).not.toContain("铁粉画像-男性占比");
expect(headerLine).toContain("观众画像-31-40占比");
expect(headerLine).toContain("粉丝画像-一线城市占比");
expect(headerLine).toContain("铁粉画像-都市蓝领占比");
expect(headerLine).not.toContain("观众画像-新一线城市占比");
expect(headerLine).not.toContain("粉丝画像-新一线城市占比");
expect(headerLine).not.toContain("铁粉画像-新一线城市占比");
expect(headerLine).not.toContain("省份");
expect(headerLine).not.toContain("地域TOP");
expect(headerLine).not.toContain("兴趣TOP");
expect(rowLine).toContain("71.7%");
expect(rowLine).toContain("60%");
expect(readCsvValue(csv, "内容数据-个人视频-播放量中位数")).toBe("3738.4w");
expect(readCsvValue(csv, "内容数据-星图视频-平均转发")).toBe("68.4w");
expect(readCsvValue(csv, "效果预估-1-20s视频-预期CPM")).toBe("120.0");
expect(readCsvValue(csv, "效果预估-20-60s视频-爆文率")).toBe("缺失");
});
test("leaves distribution cells empty when profile loading fails", () => {
const csv = buildAudienceProfileCsv([
{
profiles: {
audience: {
failureReason: "request-failed",
status: "failed"
},
fans: {
failureReason: "timeout",
status: "failed"
},
longtimeFans: {
status: "failed"
}
},
record: {
authorId: "123",
authorName: "达人 A",
status: "success"
}
} satisfies AudienceProfileExportRow
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).not.toContain("失败");
expect(rowLine).not.toContain("request-failed");
expect(rowLine).not.toContain("timeout");
});
test("fills missing fixed distribution buckets with zero for successful profiles", () => {
const csv = buildAudienceProfileCsv([
{
profiles: {
audience: { status: "success" },
fans: { status: "success" },
longtimeFans: {
age: [
{ label: "18-23", value: "11.1%" },
{ label: "24-30", value: "33.3%" },
{ label: "31-40", value: "55.6%" }
],
cityTier: [
{ label: "一线城市", value: "10%" },
{ label: "二线城市", value: "20%" },
{ label: "三线城市", value: "40%" },
{ label: "四线城市", value: "30%" }
],
crowd: [
{ label: "精致妈妈", value: "30%" },
{ label: "新锐白领", value: "20%" },
{ label: "资深中产", value: "10%" },
{ label: "都市蓝领", value: "20%" },
{ label: "小镇中老年", value: "10%" },
{ label: "小镇青年", value: "10%" }
],
status: "success"
}
},
record: {
authorId: "123",
authorName: "达人 A",
status: "success"
}
} satisfies AudienceProfileExportRow
]);
expect(readCsvValue(csv, "铁粉画像-41-50占比")).toBe("0%");
expect(readCsvValue(csv, "铁粉画像-50+占比")).toBe("0%");
expect(readCsvValue(csv, "铁粉画像-五线城市占比")).toBe("0%");
expect(readCsvValue(csv, "铁粉画像-都市银发占比")).toBe("0%");
expect(readCsvValue(csv, "铁粉画像-Z世代占比")).toBe("0%");
expect(csv.split("\n")[0]).not.toContain("新一线城市占比");
});
test("filters export columns by selected headers", () => {
const row = buildSuccessRow();
const csv = buildAudienceProfileCsv([row], {
selectedHeaders: [
"内容数据-个人视频-播放量中位数",
"观众画像-男性占比"
]
});
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
"达人信息,连接用户数,内容数据-个人视频-播放量中位数,观众画像-男性占比"
);
expect(rowLine).toBe("达人 A,300w,3738.4w,71.7%");
expect(headerLine).not.toContain("秒思api-看后搜数");
expect(headerLine).not.toContain("粉丝画像-女性占比");
});
test("always keeps fixed id export headers when filtering", () => {
const row = buildSuccessRow({
exportFields: {
ID: "123",
: "达人 A",
: "成功",
: ""
}
});
const csv = buildAudienceProfileCsv([row], {
selectedHeaders: ["内容数据-个人视频-播放量中位数"]
});
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-播放量中位数"
);
expect(rowLine).toBe("123,达人 A,成功,,3738.4w");
});
test("lists headers for field picker defaults", () => {
expect(listAudienceProfileCsvHeaders([buildSuccessRow()])).toEqual(
expect.arrayContaining([
"达人信息",
"连接用户数",
"秒思api-看后搜数",
"内容数据-个人视频-播放量中位数",
"效果预估-20-60s视频-预期CPM",
"观众画像-男性占比",
"铁粉画像-小镇青年占比"
])
);
});
test("groups selectable profile export fields", () => {
expect(listAudienceProfileSelectableFieldGroups()).toEqual(
expect.arrayContaining([
expect.objectContaining({
headers: expect.arrayContaining(["秒思api-看后搜数"]),
label: "秒思api数据"
}),
expect.objectContaining({
headers: expect.arrayContaining(["内容数据-个人视频-播放量中位数"]),
label: "内容数据"
}),
expect.objectContaining({
headers: expect.arrayContaining(["效果预估-20-60s视频-预期CPM"]),
label: "效果预估"
}),
expect.objectContaining({
headers: expect.arrayContaining(["观众画像-男性占比"]),
label: "观众画像"
})
])
);
});
});
function readCsvValue(csv: string, header: string): string {
const [headerLine, rowLine] = csv.split("\n");
const headers = headerLine.split(",");
const values = rowLine.split(",");
const index = headers.indexOf(header);
expect(index).toBeGreaterThanOrEqual(0);
return values[index] ?? "";
}
function buildSuccessRow(
overrides: Partial<AudienceProfileExportRow["record"]> = {}
): AudienceProfileExportRow {
return {
profiles: {
audience: {
age: [{ label: "31-40", value: "50%" }],
cityTier: [{ label: "一线城市", value: "100%" }],
crowd: [{ label: "都市蓝领", value: "100%" }],
gender: [{ label: "男性", value: "71.7%" }],
status: "success"
},
fans: { status: "success" },
longtimeFans: { status: "success" }
},
businessAbility: {
estimates: {
twentyToSixty: {
expectedCpe: "3.7",
expectedCpm: "212.0",
expectedPlay: "250w",
hotRate: "缺失"
}
},
status: "success",
videos: {
personalVideo: {
medianPlay: "3738.4w"
}
}
},
record: {
authorId: "123",
authorName: "达人 A",
exportFields: {
: "达人 A",
: "300w"
},
status: "success",
...overrides
}
};
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test, vi } from "vitest";
import {
buildAuthorBaseInfoUrl,
createAuthorBaseClient,
mapAuthorBaseInfoResponse
} from "../src/content/market/author-base-client";
describe("author-base-client", () => {
test("builds Xingtu author base info url", () => {
expect(
buildAuthorBaseInfoUrl("6866044569306267651", "https://www.xingtu.cn")
).toBe(
"https://www.xingtu.cn/gw/api/author/get_author_base_info?o_author_id=6866044569306267651&platform_source=1&platform_channel=1&recommend=true&need_sec_uid=true&need_linkage_info=true"
);
});
test("maps author nickname into a market record", () => {
expect(mapAuthorBaseInfoResponse("6866044569306267651", {
base_resp: { status_code: 0, status_message: "Success" },
nick_name: "小九儿"
})).toEqual({
authorId: "6866044569306267651",
authorName: "小九儿",
status: "success"
});
});
test("loads author base info from Xingtu", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => ({
base_resp: { status_code: 0, status_message: "Success" },
nick_name: "小九儿"
}),
ok: true
}));
const client = createAuthorBaseClient({
baseUrl: "https://www.xingtu.cn",
fetchImpl,
timeoutMs: 1000
});
await expect(client.loadAuthorBaseInfo("6866044569306267651")).resolves.toEqual({
authorId: "6866044569306267651",
authorName: "小九儿",
status: "success"
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://www.xingtu.cn/gw/api/author/get_author_base_info?o_author_id=6866044569306267651&platform_source=1&platform_channel=1&recommend=true&need_sec_uid=true&need_linkage_info=true",
expect.objectContaining({
credentials: "include",
method: "GET"
})
);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { parseAuthorIds } from "../src/content/market/author-id-input";
describe("author-id-input", () => {
test("parses newline comma and space separated Xingtu author ids", () => {
expect(parseAuthorIds(`
6866044569306267651
7040323176106033165,7088592143119286285
7222310247979810854
`)).toEqual({
duplicates: [],
ids: [
"6866044569306267651",
"7040323176106033165",
"7088592143119286285",
"7222310247979810854"
],
invalidTokens: []
});
});
test("deduplicates ids and reports invalid tokens", () => {
expect(parseAuthorIds(`
6866044569306267651
bad-id
123
6866044569306267651
`)).toEqual({
duplicates: ["6866044569306267651"],
ids: ["6866044569306267651"],
invalidTokens: ["bad-id", "123"]
});
});
});
+21
View File
@@ -21,6 +21,27 @@ describe("background-auth-controller", () => {
);
});
test("returns unauthenticated state when the access token cannot be read", async () => {
const controller = createAuthController({
authClient: {
getAccessToken: vi.fn(async () => {
throw new Error("token expired");
}),
getIdTokenClaims: vi.fn(),
isAuthenticated: vi.fn(async () => true),
signIn: vi.fn(),
signOut: vi.fn()
}
});
await expect(controller.getAuthState()).resolves.toEqual(
expect.objectContaining({
isAuthenticated: false,
lastError: "token expired"
})
);
});
test("delegates sign in to the auth client", async () => {
const signIn = vi.fn(async () => undefined);
const controller = createAuthController({
+44
View File
@@ -48,6 +48,50 @@ describe("background-index", () => {
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
});
test("downloads extension update assets", async () => {
const listeners: Array<
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
> = [];
const download = vi.fn(async () => undefined);
const sendResponse = vi.fn();
registerBackgroundMessageHandler({
downloads: {
download
},
runtime: {
onMessage: {
addListener(listener) {
listeners.push(listener);
}
}
}
});
const result = listeners[0](
{
filename: "star-chart-search-enhancer-internal.zip",
type: "update:download",
url: "https://cos.example.com/star-chart-search-enhancer/releases/0.2.0421.3/star-chart-search-enhancer-internal.zip"
},
{},
sendResponse
);
expect(result).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(download).toHaveBeenCalledWith({
filename: "star-chart-search-enhancer-internal.zip",
saveAs: true,
url: "https://cos.example.com/star-chart-search-enhancer/releases/0.2.0421.3/star-chart-search-enhancer-internal.zip"
});
expect(sendResponse).toHaveBeenCalledWith({
ok: true,
type: "update:download-ack"
});
});
test("responds to auth:get-state with auth status", async () => {
const listeners: Array<
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
+18
View File
@@ -0,0 +1,18 @@
import path from "node:path";
import { describe, expect, test } from "vitest";
import { resolveExtensionBuildDir } from "../scripts/build-output-path.mjs";
describe("build-output-path", () => {
test("uses dist for release builds", () => {
expect(resolveExtensionBuildDir("/repo", "release")).toBe(
path.join("/repo", "dist")
);
});
test("uses dist for development builds", () => {
expect(resolveExtensionBuildDir("/repo", "development")).toBe(
path.join("/repo", "dist")
);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, test, vi } from "vitest";
import {
buildBusinessAbilityEstimateUrl,
buildBusinessAbilityVideoUrl,
createBusinessAbilityClient,
mapBusinessAbilityEstimateResponse,
mapBusinessAbilityVideoResponse
} from "../src/content/market/business-ability-client";
describe("business-ability-client", () => {
test("builds commercial ability urls used by the Xingtu detail page", () => {
expect(
buildBusinessAbilityVideoUrl(
"6724241209444794382",
"https://www.xingtu.cn",
2
)
).toBe(
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2"
);
expect(
buildBusinessAbilityEstimateUrl(
"6724241209444794382",
"https://www.xingtu.cn"
)
).toBe(
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
);
});
test("maps video content metrics into page-style display values", () => {
expect(mapBusinessAbilityVideoResponse(buildVideoPayload())).toEqual({
averageComment: "5.1w",
averageDuration: "170s",
averageLike: "150.3w",
averageShare: "68.4w",
finishRate: "19.9%",
interactionRate: "5.5%",
medianPlay: "4059.7w",
publishedItems: "<5"
});
});
test("maps duration estimates into page-style display values", () => {
expect(mapBusinessAbilityEstimateResponse(buildEstimatePayload())).toEqual({
oneToTwenty: {
expectedCpe: "2.1",
expectedCpm: "120.0",
expectedPlay: "250w",
hotRate: "100%"
},
overSixty: {
expectedCpe: "4.2",
expectedCpm: "240.0",
expectedPlay: "250w",
hotRate: "100%"
},
twentyToSixty: {
expectedCpe: "3.7",
expectedCpm: "212.0",
expectedPlay: "250w",
hotRate: "100%"
}
});
});
test("keeps decimal CPM values and marks missing hot rate", () => {
expect(mapBusinessAbilityEstimateResponse({
base_resp: { status_code: 0, status_message: "" },
cpe_1_20: "1.6347",
cpe_20_60: "2.002",
cpe_60: "2.104",
cpm_1_20: "21.7955",
cpm_20_60: "27.5628",
cpm_60: "29.877",
vv: "1010234"
})).toEqual({
oneToTwenty: {
expectedCpe: "1.6",
expectedCpm: "21.8",
expectedPlay: "101w",
hotRate: "缺失"
},
overSixty: {
expectedCpe: "2.1",
expectedCpm: "29.9",
expectedPlay: "101w",
hotRate: "缺失"
},
twentyToSixty: {
expectedCpe: "2",
expectedCpm: "27.6",
expectedPlay: "101w",
hotRate: "缺失"
}
});
});
test("loads personal video, Xingtu video, and duration estimate metrics", async () => {
const requestedUrls: string[] = [];
const fetchImpl = vi.fn(async (input: string) => {
requestedUrls.push(input);
return {
json: async () =>
input.includes("get_author_commerce_spread_info")
? buildEstimatePayload()
: buildVideoPayload(),
ok: true
};
});
const client = createBusinessAbilityClient({
baseUrl: "https://www.xingtu.cn",
fetchImpl,
timeoutMs: 1000
});
await expect(
client.loadBusinessAbility({
authorId: "6724241209444794382",
authorName: "李蠕蠕",
status: "success"
})
).resolves.toMatchObject({
estimates: expect.objectContaining({
twentyToSixty: expect.objectContaining({ expectedCpm: "212.0" })
}),
status: "success",
videos: {
personalVideo: expect.objectContaining({ medianPlay: "4059.7w" }),
xingtuVideo: expect.objectContaining({ medianPlay: "4059.7w" })
}
});
expect(requestedUrls).toEqual([
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=true&range=2",
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2",
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
]);
});
});
function buildVideoPayload() {
return {
avg_duration: 17002,
base_resp: { status_code: 0, status_message: "" },
comment_avg: 51404,
interact_rate: { value: 551 },
item_num: 2,
like_avg: 1503028,
play_mid: 40596960,
play_over_rate: { value: 1991 },
share_avg: 684318
};
}
function buildEstimatePayload() {
return {
base_resp: { status_code: 0, status_message: "" },
cpe_1_20: "2.1035",
cpe_20_60: "3.7161",
cpe_60: "4.2069",
cpm_1_20: "119.9976",
cpm_20_60: "211.9958",
cpm_60: "239.9953",
platform_hot_rate: "1",
vv: "2500049"
};
}
+21 -21
View File
@@ -14,14 +14,14 @@ describe("csv-exporter", () => {
"达人名称",
"地区",
"21-60s报价",
"单视频看后搜率",
"单视频看后搜率",
"个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
"秒思api-看后搜率",
"秒思api-看后搜数",
"秒思api-新增A3数",
"秒思api-新增A3率",
"秒思api-CPA3",
"秒思api-cp_search"
].join(",")
);
});
@@ -59,14 +59,14 @@ describe("csv-exporter", () => {
"达人信息",
"粉丝数",
"21-60s报价",
"单视频看后搜率",
"单视频看后搜率",
"个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
"秒思api-看后搜率",
"秒思api-看后搜数",
"秒思api-新增A3数",
"秒思api-新增A3率",
"秒思api-CPA3",
"秒思api-cp_search"
].join(",")
);
expect(rowLine).toBe(
@@ -94,14 +94,14 @@ describe("csv-exporter", () => {
[
"达人信息",
"粉丝数",
"单视频看后搜率",
"单视频看后搜率",
"个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
"秒思api-看后搜率",
"秒思api-看后搜数",
"秒思api-新增A3数",
"秒思api-新增A3率",
"秒思api-CPA3",
"秒思api-cp_search"
].join(",")
);
expect(rowLine).toBe("Alice,100w,,,,,,,,");
+19 -2
View File
@@ -43,10 +43,11 @@ describe("manifest", () => {
"https://*.xingtu.cn/ad/creator/market*",
"https://login-api.intelligrow.cn/*",
"https://talent-search.intelligrow.cn/*",
"http://192.168.31.21:8083/*"
"http://192.168.31.21:8083/*",
"https://*/*"
]);
expect(releaseManifest.host_permissions).not.toEqual(
expect.arrayContaining(["http://*/*", "https://*/*", "http://127.0.0.1:4319/*"])
expect.arrayContaining(["http://*/*", "http://127.0.0.1:4319/*"])
);
});
@@ -65,4 +66,20 @@ describe("manifest", () => {
"32": "assets/icons/icon-32.png"
});
});
test("uses EXTENSION_VERSION for release builds", () => {
const previousVersion = process.env.EXTENSION_VERSION;
process.env.EXTENSION_VERSION = "v0.0525.1";
try {
const releaseManifest = createManifest({ target: "release" });
expect(releaseManifest.version).toBe("0.0525.1");
} finally {
if (previousVersion === undefined) {
delete process.env.EXTENSION_VERSION;
} else {
process.env.EXTENSION_VERSION = previousVersion;
}
}
});
});
+20
View File
@@ -24,4 +24,24 @@ describe("market-auth-gating", () => {
expect(createMarketController).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("请先登录插件");
});
test("shows an expired login message when auth state reports a token error", async () => {
document.body.innerHTML = "<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("登录已过期,请重新登录");
});
});
+439 -9
View File
@@ -13,6 +13,7 @@ describe("market-content-entry", () => {
document.documentElement.removeAttribute("data-sces-market-rows");
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
document.documentElement.removeAttribute("data-test-page-index");
window.localStorage.clear();
window.history.replaceState({}, "", "/");
});
@@ -209,6 +210,46 @@ describe("market-content-entry", () => {
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url");
});
test("booted export callback can download a custom csv filename", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
let clickedDownload: { download: string; href: string } | null = null;
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement
) {
clickedDownload = {
download: this.download,
href: this.href
};
});
window.history.replaceState({}, "", "/ad/creator/market");
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:test-url")
});
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: vi.fn()
});
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
});
const controllerOptions = createMarketController.mock.calls[0]?.[0];
controllerOptions.onCsvReady("列1,列2\n值1,值2", "达人连接用户画像_20260518_1530.csv");
expect(clickedDownload?.download).toBe("达人连接用户画像_20260518_1530.csv");
});
test("booted export callback sends the csv to extension runtime when available", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
@@ -245,6 +286,7 @@ describe("market-content-entry", () => {
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
csv: "列1,列2\n值1,值2",
filename: expect.stringMatching(/^star-chart-search-enhancer-/),
type: "download-market-csv"
})
);
@@ -282,21 +324,61 @@ describe("market-content-entry", () => {
expect(document.querySelector('[data-plugin-sort-field="select"]')).toBeNull();
expect(document.body.firstElementChild).not.toBe(toolbar);
expect(document.querySelector('[data-plugin-export-range="select"]')).not.toBeNull();
expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull();
expect(
(document.querySelector('[data-plugin-export-range="select"]') as HTMLSelectElement | null)
?.hidden
).toBe(true);
expect(
(
document.querySelector(
'[data-plugin-export-custom-pages="input"]'
) as HTMLInputElement | null
)?.hidden
).toBe(true);
expect(
(document.querySelector('[data-plugin-export="button"]') as HTMLButtonElement | null)
?.hidden
).toBe(true);
expect(
document.querySelector('[data-plugin-export-audience-profile="button"]')
).not.toBeNull();
expect(
document.querySelector('[data-plugin-export-audience-profile-by-id="button"]')
).not.toBeNull();
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
const exportButton = document.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement | null;
const batchSubmitButton = document.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null;
expect(exportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
const audienceProfileExportButton = document.querySelector(
'[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement | null;
const audienceProfileByIdExportButton = document.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
) as HTMLButtonElement | null;
const audienceProfileFieldButton = document.querySelector(
'[data-plugin-audience-profile-fields="button"]'
) as HTMLButtonElement | null;
expect(audienceProfileExportButton?.textContent).toBe("导出选中达人数据");
expect(audienceProfileExportButton?.title).toBe(
"仅导出已勾选达人,包含内容数据、效果预估、画像等维度"
);
expect(audienceProfileByIdExportButton?.textContent).toBe("按星图ID导出");
expect(audienceProfileByIdExportButton?.title).toBe(
"粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选"
);
expect(audienceProfileFieldButton?.textContent).toBe("选择字段");
expect(audienceProfileFieldButton?.title).toBe(
"勾选本次CSV需要导出的字段,设置会自动保存"
);
expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次");
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(exportButton?.style.color).toBe("rgb(255, 255, 255)");
expect(audienceProfileExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(audienceProfileByIdExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)");
expect(audienceProfileExportButton?.style.color).toBe("rgb(255, 255, 255)");
expect(audienceProfileByIdExportButton?.style.color).toBe("rgb(255, 255, 255)");
});
test("remounts the plugin action bar when the native market action row appears later", async () => {
@@ -1051,7 +1133,7 @@ describe("market-content-entry", () => {
expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]);
});
test("toolbar defaults export range to the first 5 pages and reveals custom input on demand", async () => {
test("toolbar keeps export range controls hidden while retaining the internal default range", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
@@ -1074,6 +1156,7 @@ describe("market-content-entry", () => {
) as HTMLInputElement | null;
expect(exportRangeSelect?.value).toBe("first-5");
expect(exportRangeSelect?.hidden).toBe(true);
expect(customPagesInput?.hidden).toBe(true);
expect(
document.querySelector('[data-plugin-batch-submit="button"]')
@@ -1082,7 +1165,8 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "custom");
dispatchChange('[data-plugin-export-range="select"]');
expect(customPagesInput?.hidden).toBe(false);
expect(exportRangeSelect?.hidden).toBe(true);
expect(customPagesInput?.hidden).toBe(true);
});
test("export uses the current page ordering without triggering a full scan", async () => {
@@ -1535,6 +1619,352 @@ 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 loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const,
videos: {}
}));
const loadAudienceProfile = vi.fn(async (_record, target) => {
if (target.source === "fansDistribution" && target.authorType === 5) {
return {
age: [{ label: "31-40", value: "30%" }],
crowd: [{ label: "都市蓝领", value: "50%" }],
cityTier: [{ label: "一线城市", value: "70%" }],
status: "success" as const
};
}
return {
age: [{ label: "31-40", value: "60%" }],
crowd: [{ label: "都市蓝领", value: "80%" }],
cityTier: [{ label: "一线城市", value: "90%" }],
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,
loadBusinessAbility,
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(3);
expect(loadBusinessAbility).toHaveBeenCalledTimes(1);
expect(loadBusinessAbility).toHaveBeenCalledWith(
expect.objectContaining({ authorId: "222" })
);
expect(loadAudienceProfile.mock.calls.map(([, target]) => target)).toEqual([
{ linkType: 5, source: "audienceDistribution" },
{ authorType: 1, source: "fansDistribution" },
{ authorType: 5, source: "fansDistribution" }
]);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith(
[
{
profiles: {
audience: expect.objectContaining({ status: "success" }),
fans: expect.objectContaining({ status: "success" }),
longtimeFans: expect.objectContaining({ status: "success" })
},
businessAbility: expect.objectContaining({ status: "success" }),
record: expect.objectContaining({ authorId: "222" })
}
],
expect.objectContaining({
selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"])
})
);
expect(onCsvReady).toHaveBeenCalledWith(
"profile-csv",
expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/)
);
});
test("audience profile export by id loads pasted creators without page selection", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadAuthorBaseInfo = vi.fn(async (authorId: string) => ({
authorId,
authorName: authorId === "6866044569306267651" ? "小九儿" : "达人 B",
status: "success" as const
}));
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const,
videos: {}
}));
const loadAudienceProfile = vi.fn(async () => ({
age: [{ label: "31-40", value: "60%" }],
crowd: [{ label: "都市蓝领", value: "80%" }],
cityTier: [{ label: "一线城市", value: "90%" }],
gender: [{ label: "男性", value: "60%" }],
status: "success" as const
}));
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
rates: {
personalVideoAfterSearchRate:
authorId === "6866044569306267651" ? "12.3%" : "45.6%",
singleVideoAfterSearchRate:
authorId === "6866044569306267651" ? "7.8%" : "9.1%"
},
success: true as const
}));
const searchBackendMetrics = vi.fn(async (starIds: string[]) =>
starIds.map((starId) => ({
a3IncreaseCount: starId === "6866044569306267651" ? "100" : "200",
afterViewSearchCount: starId === "6866044569306267651" ? "300" : "400",
afterViewSearchRate: starId === "6866044569306267651" ? "1.1%" : "2.2%",
cpSearch: starId === "6866044569306267651" ? "10" : "20",
cpa3: starId === "6866044569306267651" ? "30" : "40",
newA3Rate: starId === "6866044569306267651" ? "3.3%" : "4.4%",
starId
}))
);
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAuthorBaseInfo,
loadBusinessAbility,
loadAudienceProfile,
loadAuthorMetrics,
onCsvReady,
promptAuthorIds: () => `
6866044569306267651
7040323176106033165
6866044569306267651
bad-id
`,
searchBackendMetrics,
window
}));
await controller.ready;
loadAuthorMetrics.mockClear();
searchBackendMetrics.mockClear();
click('[data-plugin-export-audience-profile-by-id="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(loadAuthorBaseInfo.mock.calls.map(([authorId]) => authorId)).toEqual([
"6866044569306267651",
"7040323176106033165"
]);
expect(loadAudienceProfile).toHaveBeenCalledTimes(6);
expect(loadBusinessAbility).toHaveBeenCalledTimes(2);
expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual([
"6866044569306267651",
"7040323176106033165"
]);
expect(searchBackendMetrics).toHaveBeenCalledTimes(1);
expect(searchBackendMetrics).toHaveBeenCalledWith([
"6866044569306267651",
"7040323176106033165"
]);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith(
[
expect.objectContaining({
record: expect.objectContaining({
authorId: "6866044569306267651",
authorName: "小九儿",
backendMetrics: expect.objectContaining({
a3IncreaseCount: "100",
afterViewSearchCount: "300",
afterViewSearchRate: "1.1%",
cpSearch: "10",
cpa3: "30",
newA3Rate: "3.3%"
}),
exportFields: {
ID: "6866044569306267651",
: "小九儿",
: "成功",
: ""
},
rates: {
personalVideoAfterSearchRate: "12.3%",
singleVideoAfterSearchRate: "7.8%"
}
})
}),
expect.objectContaining({
record: expect.objectContaining({
authorId: "7040323176106033165",
authorName: "达人 B",
backendMetrics: expect.objectContaining({
a3IncreaseCount: "200",
afterViewSearchCount: "400",
afterViewSearchRate: "2.2%",
cpSearch: "20",
cpa3: "40",
newA3Rate: "4.4%"
}),
rates: {
personalVideoAfterSearchRate: "45.6%",
singleVideoAfterSearchRate: "9.1%"
}
})
})
],
expect.objectContaining({
selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"])
})
);
expect(onCsvReady).toHaveBeenCalledWith(
"profile-csv",
expect.stringMatching(/^达人连接用户画像_按ID导出_\d{8}_\d{4}\.csv$/)
);
});
test("audience profile export uses persisted selected csv fields", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
window.localStorage.setItem(
"sces:audience-profile:selectedHeaders",
JSON.stringify(["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"])
);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const,
videos: {}
}));
const loadAudienceProfile = vi.fn(async () => ({
age: [],
crowd: [],
cityTier: [],
gender: [],
status: "success" as const
}));
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadBusinessAbility,
loadAudienceProfile,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(buildAudienceProfileCsv.mock.calls[0][1]).toEqual({
selectedHeaders: ["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"]
});
});
test("audience profile field picker persists the next selected fields", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
click('[data-plugin-audience-profile-fields="button"]');
const afterSearchCountInput = document.querySelector(
'input[data-audience-profile-field-dialog-field="checkbox"][value="秒思api-看后搜数"]'
) as HTMLInputElement | null;
expect(afterSearchCountInput).not.toBeNull();
afterSearchCountInput!.checked = false;
afterSearchCountInput!.dispatchEvent(new Event("change", { bubbles: true }));
click('[data-audience-profile-field-dialog-save="button"]');
await flush();
const savedHeaders = JSON.parse(
window.localStorage.getItem("sces:audience-profile:selectedHeaders") ?? "[]"
) as string[];
expect(savedHeaders).not.toContain("秒思api-看后搜数");
expect(savedHeaders).toContain("内容数据-个人视频-播放量中位数");
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("字段已保存");
});
test(
"selected export keeps a generic loading status while exporting the default paged range",
async () => {
+1 -1
View File
@@ -181,7 +181,7 @@ describe("market-dom-sync", () => {
expect(readSelectionHeaderText()).toBe("全选");
expect(readSelectionRowCheckboxCount()).toBe(2);
expect(readPluginHeaderTexts()).toEqual([
"单视频看后搜率",
"单视频看后搜率",
"个人视频看后搜率",
"看后搜率",
"看后搜数",
+26
View File
@@ -0,0 +1,26 @@
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { createReleaseArchive } from "../scripts/package-release-archive.mjs";
describe("package-release-archive", () => {
test("creates a zip archive with a top-level dist folder", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "release-archive-"));
const sourceDir = path.join(tempDir, "source");
const archivePath = path.join(tempDir, "archive.zip");
await mkdir(sourceDir, { recursive: true });
await writeFile(path.join(sourceDir, "hello.txt"), "hello world", "utf8");
await createReleaseArchive({
archivePath,
sourceDir
});
const archive = await readFile(archivePath);
expect(archive.byteLength).toBeGreaterThan(0);
expect(archive.toString("utf8")).toContain("dist/hello.txt");
});
});
+173
View File
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { bootPopup } from "../src/popup/index";
function flushTasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe("popup-entry", () => {
let dom: JSDOM;
@@ -22,6 +26,12 @@ describe("popup-entry", () => {
}))
});
expect(
dom.window.document.querySelector('[data-popup-shell="root"]')
).not.toBeNull();
expect(
dom.window.document.querySelector('[data-popup-account="card"]')
).not.toBeNull();
expect(dom.window.document.querySelector("button")?.textContent).toContain(
"登录"
);
@@ -47,10 +57,116 @@ describe("popup-entry", () => {
}))
});
expect(
dom.window.document.querySelector('[data-popup-header="root"]')
).not.toBeNull();
expect(
dom.window.document.querySelector('[data-popup-account="card"]')
).not.toBeNull();
expect(dom.window.document.body.textContent).toContain("resource");
expect(dom.window.document.body.textContent).toContain("token");
});
test("shows available extension updates in the popup", async () => {
const fetchUpdateManifest = vi.fn(async () => ({
guideUrl: "https://cos.example.com/guide.pdf",
latestVersion: "0.2.0421.3",
minSupportedVersion: "0.2.0421.2",
publishedAt: "2026-05-19",
releaseNotes: ["支持检查更新"],
zipUrl: "https://cos.example.com/plugin.zip"
}));
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
currentVersion: "0.2.0421.2",
document: dom.window.document,
fetchUpdateManifest,
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
userInfo: { name: "Dev" }
}
}))
});
await flushTasks();
await flushTasks();
expect(fetchUpdateManifest).toHaveBeenCalledTimes(1);
expect(dom.window.document.body.textContent).toContain("当前版本:0.2.0421.2");
expect(dom.window.document.body.textContent).toContain("发现新版本:0.2.0421.3");
expect(dom.window.document.body.textContent).toContain("支持检查更新");
expect(
dom.window.document.querySelector('[data-popup-update="card"]')
).not.toBeNull();
expect(
dom.window.document.querySelector('[data-popup-download-update="button"]')
).not.toBeNull();
expect(
dom.window.document.querySelector('[data-popup-download-guide="button"]')
).not.toBeNull();
});
test("downloads update assets from popup buttons", async () => {
const sendMessage = vi
.fn()
.mockResolvedValueOnce({
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
userInfo: { name: "Dev" }
}
})
.mockResolvedValue({ ok: true, type: "update:download-ack" });
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
currentVersion: "0.2.0421.2",
document: dom.window.document,
fetchUpdateManifest: vi.fn(async () => ({
guideUrl: "https://cos.example.com/guide.pdf",
latestVersion: "0.2.0421.3",
minSupportedVersion: "0.2.0421.2",
publishedAt: "2026-05-19",
releaseNotes: [],
zipUrl: "https://cos.example.com/plugin.zip"
})),
sendMessage
});
await flushTasks();
await flushTasks();
(
dom.window.document.querySelector(
'[data-popup-download-update="button"]'
) as HTMLButtonElement | null
)?.click();
(
dom.window.document.querySelector(
'[data-popup-download-guide="button"]'
) as HTMLButtonElement | null
)?.click();
await Promise.resolve();
await Promise.resolve();
expect(sendMessage).toHaveBeenCalledWith({
filename: "star-chart-search-enhancer-internal.zip",
type: "update:download",
url: "https://cos.example.com/plugin.zip"
});
expect(sendMessage).toHaveBeenCalledWith({
filename: "星图增强插件-超简单安装使用指南.pdf",
type: "update:download",
url: "https://cos.example.com/guide.pdf"
});
});
test("renders a protected api test button in the dev panel", async () => {
dom.window.document.body.innerHTML = "<main id='app'></main>";
@@ -150,6 +266,63 @@ describe("popup-entry", () => {
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" });
});
test("shows the latest state without update actions", async () => {
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
currentVersion: "0.0525.5",
document: dom.window.document,
fetchUpdateManifest: vi.fn(async () => ({
guideUrl: "https://cos.example.com/guide.pdf",
latestVersion: "0.0525.5",
minSupportedVersion: "0.0525.5",
publishedAt: "2026-05-19",
releaseNotes: ["支持检查更新"],
zipUrl: "https://cos.example.com/plugin.zip"
})),
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
userInfo: { name: "Dev" }
}
}))
});
await flushTasks();
await flushTasks();
expect(dom.window.document.body.textContent).toContain("当前已是最新版本");
expect(
dom.window.document.querySelector('[data-popup-download-update="button"]')
).toBeNull();
});
test("shows a readable error state when the manifest fetch fails", async () => {
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
currentVersion: "0.0525.5",
document: dom.window.document,
fetchUpdateManifest: vi.fn(async () => {
throw new Error("network down");
}),
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
userInfo: { name: "Dev" }
}
}))
});
await flushTasks();
await flushTasks();
expect(dom.window.document.body.textContent).toContain("暂时无法检查更新");
expect(dom.window.document.body.textContent).toContain("network down");
});
test("shows the auth error when sign-in fails", async () => {
const sendMessage = vi
.fn()
+30
View File
@@ -0,0 +1,30 @@
import path from "node:path";
import { describe, expect, test } from "vitest";
import { buildReleaseUploadTargets } from "../scripts/release-assets.mjs";
describe("release-assets", () => {
test("maps release files to the COS object keys", () => {
expect(
buildReleaseUploadTargets({
projectRoot: "/repo",
releaseVersion: "0.0525.1"
})
).toEqual([
{
cosKey: "star-chart-search-enhancer/latest.json",
localPath: path.join("/repo", "release", "latest.json")
},
{
cosKey:
"star-chart-search-enhancer/releases/0.0525.1/star-chart-search-enhancer-internal.zip",
localPath: path.join("/repo", "release", "star-chart-search-enhancer-internal.zip")
},
{
cosKey:
"star-chart-search-enhancer/releases/0.0525.1/星图增强插件-超简单安装使用指南.pdf",
localPath: path.join("/repo", "release", "星图增强插件-超简单安装使用指南.pdf")
}
]);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from "vitest";
import {
normalizeReleaseVersionTag,
resolveReleaseVersion
} from "../scripts/release-version.mjs";
describe("release-version", () => {
test("normalizes a tag by stripping a leading v", () => {
expect(normalizeReleaseVersionTag("v0.0525.1")).toBe("0.0525.1");
expect(normalizeReleaseVersionTag("0.0525.1")).toBe("0.0525.1");
});
test("prefers EXTENSION_VERSION over DRONE_TAG and fallback", () => {
expect(
resolveReleaseVersion(
{
DRONE_TAG: "0.0525.2",
EXTENSION_VERSION: "v0.0525.3"
},
"0.2.0421.2"
)
).toBe("0.0525.3");
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import {
compareExtensionVersions,
parseUpdateManifest
} from "../src/shared/update-check";
describe("update-check", () => {
test("compares dotted extension versions numerically", () => {
expect(compareExtensionVersions("0.2.0421.3", "0.2.0421.2")).toBeGreaterThan(0);
expect(compareExtensionVersions("0.2.10.0", "0.2.9.9")).toBeGreaterThan(0);
expect(compareExtensionVersions("0.2.0421.2", "0.2.0421.2")).toBe(0);
expect(compareExtensionVersions("0.2.0421.1", "0.2.0421.2")).toBeLessThan(0);
});
test("parses a valid update manifest", () => {
expect(
parseUpdateManifest({
guideUrl: "https://cos.example.com/guide.pdf",
latestVersion: "0.2.0421.3",
minSupportedVersion: "0.2.0421.2",
publishedAt: "2026-05-19",
releaseNotes: ["支持检查更新"],
zipUrl: "https://cos.example.com/plugin.zip"
})
).toEqual({
guideUrl: "https://cos.example.com/guide.pdf",
latestVersion: "0.2.0421.3",
minSupportedVersion: "0.2.0421.2",
publishedAt: "2026-05-19",
releaseNotes: ["支持检查更新"],
zipUrl: "https://cos.example.com/plugin.zip"
});
});
test("rejects invalid update manifests", () => {
expect(parseUpdateManifest({ latestVersion: "0.2.0421.3" })).toBeNull();
expect(
parseUpdateManifest({
guideUrl: "javascript:alert(1)",
latestVersion: "0.2.0421.3",
minSupportedVersion: "0.2.0421.2",
publishedAt: "2026-05-19",
releaseNotes: [],
zipUrl: "https://cos.example.com/plugin.zip"
})
).toBeNull();
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, test } from "vitest";
import { UPDATE_MANIFEST_URL } from "../src/shared/update-config";
describe("update-config", () => {
test("points popup update checks at the COS latest manifest", () => {
expect(UPDATE_MANIFEST_URL).toBe(
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json"
);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { createLatestManifest } from "../scripts/write-latest-manifest-data.mjs";
describe("write-latest-manifest-data", () => {
test("builds COS asset URLs from the release base", () => {
expect(
createLatestManifest({
latestVersion: "0.2.0421.2",
minSupportedVersion: "0.2.0421.2",
publicBaseUrl:
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2",
publishedAt: "2026-05-25"
})
).toEqual({
guideUrl:
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2/星图增强插件-超简单安装使用指南.pdf",
latestVersion: "0.2.0421.2",
minSupportedVersion: "0.2.0421.2",
publishedAt: "2026-05-25",
releaseNotes: [
"支持在插件弹窗中检查新版本",
"支持一键下载最新版插件压缩包和使用说明"
],
zipUrl:
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2/star-chart-search-enhancer-internal.zip"
});
});
});