24 Commits
Author SHA1 Message Date
wxs 5c21f631d7 docs: update independent spread filter flow 2026-07-10 17:57:36 +08:00
wxs 9326d676b7 feat: add independent spread metric filters 2026-07-10 17:48:02 +08:00
wxs b48e244a99 refactor: model independent spread metric rules 2026-07-10 17:18:01 +08:00
wxs 8eebb44551 docs: plan independent spread metric filters 2026-07-10 17:09:09 +08:00
wxs 720f5983dc docs: design independent spread metric filters 2026-07-10 16:58:34 +08:00
wxs 370cc0170b fix: refine market toolbar filters 2026-07-09 21:33:58 +08:00
wxs b4440e2ca3 style: simplify spread threshold controls 2026-06-30 18:07:40 +08:00
wxs 4c66262211 style: show spread threshold units 2026-06-30 17:56:49 +08:00
wxs 4b5515f6ec style: refine market toolbar layout 2026-06-30 17:44:31 +08:00
wxs ccfb8f14fe style: compact spread filter toolbar layout 2026-06-29 17:18:31 +08:00
wxs 0122e63872 refactor: use spread metrics for content data fields 2026-06-29 16:35:27 +08:00
wxs 9eb1fe43cc feat: filter exports by spread thresholds 2026-06-29 16:11:52 +08:00
wxs 121977fd0d feat: export author spread metrics 2026-06-29 16:06:14 +08:00
wangshaoqing 7839380613 docs: clarify bridge upgrade for coworkers 2026-05-25 17:08:05 +08:00
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
64 changed files with 6050 additions and 10825 deletions
-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
+3 -5
View File
@@ -21,9 +21,7 @@
- `src/`
- 插件源码
- `dist/`
- 开发构建产物
- `dist-release/`
- 内部分发构建产物
- 开发和发布构建产物
- `release/`
- 打包后的内部交付压缩包
- `docs/`
@@ -84,7 +82,7 @@ npm run write:latest
生成结果:
- 构建目录:`dist-release/`
- 构建目录:`dist/`
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
- 更新清单:`release/latest.json`
@@ -106,7 +104,7 @@ npm run write:latest
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;
}
})();
-59
View File
@@ -1,59 +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"
],
"web_accessible_resources": [
{
"matches": [
"https://xingtu.cn/*",
"https://*.xingtu.cn/*"
],
"resources": [
"content/market-page-bridge.js"
]
}
],
"version": "0.0525.4",
"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/*",
"https://*/*"
]
}
-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>
-422
View File
@@ -1,422 +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>
<section data-popup-update="root">
<h2>\u7248\u672C\u66F4\u65B0</h2>
<p data-popup-update-status="text">\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...</p>
</section>
<button type="button" data-popup-sign-out="button">\u9000\u51FA\u767B\u5F55</button>
</section>
`;
}
function renderUpdateStatus(root, options) {
const container = root.querySelector('[data-popup-update="root"]');
if (!container) {
return;
}
if (options.status === "checking") {
container.innerHTML = `
<h2>\u7248\u672C\u66F4\u65B0</h2>
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
<p>\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...</p>
`;
return;
}
if (options.status === "error") {
container.innerHTML = `
<h2>\u7248\u672C\u66F4\u65B0</h2>
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
<p>\u6682\u65F6\u65E0\u6CD5\u68C0\u67E5\u66F4\u65B0</p>
<p>\u5982\u679C\u9700\u8981\u65B0\u7248\uFF0C\u8BF7\u8054\u7CFB\u7EF4\u62A4\u540C\u4E8B\u83B7\u53D6\u66F4\u65B0\u5305\u3002</p>
`;
return;
}
if (options.status === "latest" || !options.manifest) {
container.innerHTML = `
<h2>\u7248\u672C\u66F4\u65B0</h2>
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
<p>\u5F53\u524D\u5DF2\u662F\u6700\u65B0\u7248\u672C</p>
`;
return;
}
container.innerHTML = `
<h2>\u7248\u672C\u66F4\u65B0</h2>
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
<p>\u53D1\u73B0\u65B0\u7248\u672C\uFF1A${options.manifest.latestVersion}</p>
${renderReleaseNotes(options.manifest.releaseNotes)}
<button type="button" data-popup-download-update="button">\u4E0B\u8F7D\u66F4\u65B0\u5305</button>
<button type="button" data-popup-download-guide="button">\u4E0B\u8F7D\u4F7F\u7528\u8BF4\u660E</button>
<p data-popup-update-download-status="text">\u4E0B\u8F7D\u540E\u8BF7\u89E3\u538B\u65B0\u7248 zip\uFF0C\u5E76\u5728 chrome://extensions \u91CC\u91CD\u65B0\u52A0\u8F7D\u63D2\u4EF6\u3002</p>
`;
}
function setUpdateDownloadStatus(root, value) {
const output = root.querySelector('[data-popup-update-download-status="text"]');
if (!output) {
return;
}
output.textContent = value;
}
function renderReleaseNotes(releaseNotes) {
if (releaseNotes.length === 0) {
return "";
}
return `
<ul>
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
</ul>
`;
}
function escapeHtml(value) {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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/shared/update-check.ts
function compareExtensionVersions(left, right) {
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;
}
function parseUpdateManifest(value) {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value;
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
};
}
async function fetchUpdateManifest(manifestUrl, fetchImpl = fetch) {
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) {
return value.split(".").map((part) => {
const parsed = Number.parseInt(part, 10);
return Number.isFinite(parsed) ? parsed : 0;
});
}
function isVersionString(value) {
return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value);
}
function isHttpsUrl(value) {
if (typeof value !== "string") {
return false;
}
try {
return new URL(value).protocol === "https:";
} catch {
return false;
}
}
// src/shared/update-config.ts
var UPDATE_MANIFEST_URL = "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json";
// src/popup/index.ts
async function bootPopup(options = {}) {
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;
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;
const fetchUpdateManifest2 = options.fetchUpdateManifest ?? (() => fetchUpdateManifest(
options.updateManifestUrl ?? UPDATE_MANIFEST_URL
));
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, {
currentVersion,
fetchUpdateManifest: fetchUpdateManifest2
});
}
async function renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, updateOptions) {
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,
updateOptions
});
});
return;
}
renderLoggedIn(root, response.value);
void runUpdateCheck(root, sendMessage, updateOptions);
root.querySelector('[data-popup-sign-out="button"]')?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, {
actionMessage: { type: "auth:sign-out" },
fetchProtectedApi,
updateOptions
});
});
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,
options.updateOptions
);
}
function isActionError(response) {
return isAuthResponseMessage(response) && !response.ok && response.type === "auth:error";
}
async function runUpdateCheck(root, sendMessage, options) {
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 {
renderUpdateStatus(root, {
currentVersion: options.currentVersion,
status: "error"
});
}
}
function bindUpdateDownloadButtons(root, sendMessage, manifest) {
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: "\u661F\u56FE\u589E\u5F3A\u63D2\u4EF6-\u8D85\u7B80\u5355\u5B89\u88C5\u4F7F\u7528\u6307\u5357.pdf",
url: manifest.guideUrl
});
});
}
async function downloadUpdateAsset(root, sendMessage, options) {
setUpdateDownloadStatus(root, "\u6B63\u5728\u4E0B\u8F7D...");
try {
await sendMessage({
filename: options.filename,
type: "update:download",
url: options.url
});
setUpdateDownloadStatus(root, "\u5DF2\u89E6\u53D1\u4E0B\u8F7D\u3002\u4E0B\u8F7D\u540E\u8BF7\u89E3\u538B\u65B0\u7248 zip\uFF0C\u5E76\u5728 chrome://extensions \u91CC\u91CD\u65B0\u52A0\u8F7D\u63D2\u4EF6\u3002");
} catch (error) {
setUpdateDownloadStatus(
root,
error instanceof Error ? error.message : "\u4E0B\u8F7D\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"
);
}
}
function readCurrentVersion() {
const runtime = globalThis.chrome?.runtime;
return runtime?.getManifest?.().version ?? "0.0.0";
}
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();
}
})();
+16 -2
View File
@@ -73,11 +73,25 @@ The pipeline uses the tag as the release version. Recommended format: `0.MMDD.N`
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`.
## One-Time Bridge Upgrade
Some coworkers may still be using an older unpacked build whose popup cannot read the COS update manifest and only shows:
- `暂时无法检查更新`
For those users, ask them to do one manual bridge upgrade with the newest ZIP:
1. Download the newest `star-chart-search-enhancer-internal.zip`.
2. Unzip it and get the new `dist/` folder.
3. Re-load that `dist/` folder in `chrome://extensions`.
After this one-time bridge upgrade, future updates should continue using the same `dist/` layout.
## Notes
- Keep `.local/extension-key.pem` private and backed up internally.
- Do not commit or share the private key with people who only need to install the extension.
- If the batch submit backend changes away from `192.168.31.21:8083`, update `scripts/manifest.mjs` before packaging.
- If the batch submit backend changes away from `localhost:8083`, update `scripts/manifest.mjs` before packaging.
@@ -0,0 +1,685 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>星图插件工具栏改版样例</title>
<style>
:root {
color-scheme: light;
--page: #f5f7fa;
--panel: #ffffff;
--line: #e7ebf0;
--text: #20242a;
--muted: #6b7280;
--soft: #f8fafc;
--brand: #ff2f6d;
--brand-dark: #85172d;
--brand-soft: #fff0f5;
--blue: #2563eb;
--green: #0f8a5f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 1180px;
background: var(--page);
color: var(--text);
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-size: 14px;
letter-spacing: 0;
}
button,
input,
select {
font: inherit;
}
.topbar {
height: 58px;
display: flex;
align-items: center;
gap: 28px;
padding: 0 24px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid var(--line);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 23px;
font-weight: 800;
}
.logo {
width: 34px;
height: 28px;
border-radius: 7px;
background:
linear-gradient(135deg, #ff245f 0 38%, transparent 39%),
linear-gradient(45deg, #27c7f2 0 50%, #3664ff 51%);
}
.nav {
display: flex;
align-items: center;
gap: 28px;
color: #2f3540;
font-weight: 600;
}
.nav .active {
color: var(--brand);
position: relative;
}
.nav .active::after {
content: "";
position: absolute;
left: 10px;
right: 10px;
bottom: -17px;
height: 4px;
border-radius: 10px;
background: var(--brand);
}
.top-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.small-btn,
.pink-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
border: 1px solid #d8dee8;
background: #fff;
color: #303744;
font-weight: 700;
}
.pink-btn {
border-color: var(--brand);
background: var(--brand);
color: #fff;
}
.page {
padding: 18px 24px 40px;
}
.banner {
height: 46px;
display: grid;
place-items: center;
margin-bottom: 16px;
border-radius: 9px;
background: #fff;
color: #111827;
font-size: 20px;
font-weight: 900;
}
.searchbar,
.filters,
.results {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
}
.searchbar {
height: 72px;
display: flex;
align-items: center;
gap: 16px;
padding: 0 24px;
margin-bottom: 16px;
}
.tab {
height: 34px;
border: 0;
border-radius: 7px;
padding: 0 18px;
background: var(--brand-soft);
color: var(--brand);
font-weight: 800;
}
.tab.active {
background: var(--brand);
color: #fff;
}
.search-input {
width: 440px;
height: 36px;
border: 1px solid #ff8ab2;
border-radius: 6px;
padding: 0 14px;
}
.filters {
display: grid;
grid-template-columns: 108px 1fr;
overflow: hidden;
margin-bottom: 16px;
}
.filter-side {
background: #fbfcfe;
border-right: 1px solid var(--line);
}
.filter-side div {
height: 72px;
display: grid;
place-items: center;
border-bottom: 1px solid var(--line);
font-weight: 800;
}
.filter-main {
padding: 16px 24px;
}
.filter-row {
min-height: 38px;
display: flex;
align-items: center;
gap: 18px;
border-bottom: 1px solid #f0f2f5;
}
.filter-row:last-child {
border-bottom: 0;
}
.filter-title {
width: 72px;
color: var(--muted);
font-weight: 700;
}
.chip,
.soft-chip {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 12px;
border-radius: 6px;
white-space: nowrap;
}
.soft-chip {
background: var(--brand-soft);
color: var(--brand);
font-weight: 800;
}
.chip {
color: #414854;
}
.results {
overflow: hidden;
}
.result-top {
display: flex;
align-items: center;
padding: 18px 24px 0;
}
.count {
font-size: 18px;
font-weight: 800;
}
.count strong {
font-size: 20px;
}
.toolbar-shell {
margin: 18px 24px 0;
border: 1px solid #dfe5ee;
border-radius: 10px;
background: #fff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.toolbar-head {
display: flex;
align-items: center;
min-height: 58px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #fff, #fbfcff);
}
.action-cluster {
display: flex;
align-items: center;
gap: 8px;
padding-right: 16px;
border-right: 1px solid var(--line);
}
.tool-btn {
height: 34px;
border: 1px solid #7b1a2d;
border-radius: 8px;
padding: 0 14px;
background: #7b1a2d;
color: #fff;
font-weight: 800;
white-space: nowrap;
}
.control-cluster {
min-width: 0;
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
padding-left: 16px;
}
.label {
display: flex;
align-items: center;
height: 34px;
padding: 0 10px;
border: 1px solid #cfe0ff;
border-radius: 8px;
background: #eef5ff;
color: #2563eb;
font-size: 12px;
font-weight: 900;
white-space: nowrap;
}
.select,
.input {
height: 34px;
border: 1px solid #d4dbe6;
border-radius: 8px;
background: #fff;
color: #202938;
padding: 0 10px;
}
.select {
min-width: 104px;
}
.status {
flex: 1 1 auto;
min-width: 120px;
text-align: center;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.toolbar-body {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px 14px;
background: #fbfcff;
}
.metric-title {
display: flex;
align-items: center;
height: 34px;
padding: 0 10px;
border: 1px solid #cfe0ff;
border-radius: 8px;
background: #eef5ff;
color: #2563eb;
font-size: 12px;
font-weight: 900;
white-space: nowrap;
}
.metric-rule {
display: flex;
align-items: center;
height: 34px;
padding: 0 10px;
border: 1px solid #d9efe7;
border-radius: 8px;
background: #f0fbf6;
color: var(--green);
font-size: 12px;
font-weight: 900;
white-space: nowrap;
}
.metric-rule strong {
margin-left: 3px;
}
.metric-rule small {
margin-left: 6px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.metric-grid {
min-width: 0;
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 7px;
overflow-x: auto;
}
.and {
color: #0f8a5f;
font-size: 12px;
font-weight: 900;
white-space: nowrap;
}
.metric {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 8px;
border: 1px solid #dbe2ec;
border-radius: 8px;
background: #fff;
}
.metric span,
.metric b {
color: #667085;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.metric b {
color: var(--green);
font-weight: 900;
}
.metric input {
min-width: 0;
width: 58px;
height: 26px;
border: 0;
outline: 0;
color: #1f2937;
font-weight: 700;
}
.native-actions {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
padding-left: 16px;
border-left: 1px solid var(--line);
}
.native-btn {
height: 34px;
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
color: #303744;
padding: 0 14px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 18px;
}
th,
td {
height: 64px;
padding: 0 20px;
border-top: 1px solid var(--line);
text-align: left;
white-space: nowrap;
}
th {
height: 46px;
background: #fbfcfe;
color: #5d6675;
font-size: 12px;
}
.author {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #f4b66b, #7660d6);
}
.price {
color: var(--brand);
font-size: 18px;
font-weight: 900;
}
@media (max-width: 1380px) {
.metric-grid {
padding-bottom: 2px;
}
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><span class="logo"></span>巨量星图</div>
<nav class="nav">
<span>首页</span>
<span>我的星图</span>
<span>找灵感</span>
<span class="active">找达人</span>
<span>找活动</span>
<span>助投放</span>
</nav>
<div class="top-actions">
<button class="small-btn">达人清单</button>
<button class="pink-btn">+ 发布任务</button>
</div>
</header>
<main class="page">
<section class="banner">「精选品牌伙伴计划」优质达人合作推荐</section>
<section class="searchbar">
<button class="tab">内容找人</button>
<button class="tab active">昵称找人</button>
<input class="search-input" value="抖音 输入达人昵称、抖音号或星图ID" />
</section>
<section class="filters">
<aside class="filter-side">
<div>合作诉求</div>
<div>匹配度</div>
<div>性价比</div>
<div>主题推荐</div>
</aside>
<div class="filter-main">
<div class="filter-row">
<span class="filter-title">合作对象</span>
<span class="soft-chip">不限</span>
<span class="chip">明星</span>
<span class="soft-chip">短视频达人</span>
<span class="chip">短剧演员</span>
<span class="chip">短直达人</span>
</div>
<div class="filter-row">
<span class="filter-title">适配行业</span>
<span class="chip">不限</span>
<span class="soft-chip">品牌曝光</span>
<span class="chip">破圈种草</span>
<span class="chip">行动转化</span>
<span class="chip">品牌5A</span>
</div>
<div class="filter-row">
<span class="filter-title">达人类型</span>
<span class="soft-chip">不限</span>
<span class="chip">美妆</span>
<span class="chip">萌宠</span>
<span class="chip">测评</span>
<span class="chip">旅行</span>
<span class="chip">母婴亲子</span>
<span class="chip">科技数码</span>
<span class="chip">生活家居</span>
</div>
<div class="filter-row">
<span class="filter-title">内容主题</span>
<span class="soft-chip">不限</span>
<span class="chip">妆容改造</span>
<span class="chip">亲子育儿</span>
<span class="chip">精彩生活</span>
<span class="chip">手机数码</span>
<span class="chip">萌宠养护</span>
</div>
</div>
</section>
<section class="results">
<div class="result-top">
<div class="count">找到 <strong>10000+</strong> 个达人</div>
</div>
<section class="toolbar-shell" aria-label="插件工具栏改版样例">
<div class="toolbar-head">
<div class="action-cluster">
<button class="tool-btn">导出选中达人数据</button>
<button class="tool-btn">按星图ID导出</button>
<button class="tool-btn">选择字段</button>
<button class="tool-btn">提交批次</button>
</div>
<div class="control-cluster">
<span class="label">视频口径</span>
<select class="select">
<option>星图视频</option>
<option>个人视频</option>
</select>
<select class="select">
<option>只看指派</option>
<option>不限指派</option>
</select>
<select class="select">
<option>排除营销</option>
<option>不排除营销</option>
</select>
<select class="select">
<option>近90天</option>
<option>近30天</option>
</select>
</div>
<span class="status">批次提交成功</span>
<div class="native-actions">
<button class="native-btn">自定义指标</button>
<button class="native-btn">导出</button>
</div>
</div>
<div class="toolbar-body">
<div class="metric-title">传播指标筛选</div>
<div class="metric-rule">全部满足<strong>AND</strong><small>每项取值 ≥ 输入值</small></div>
<div class="metric-grid">
<label class="metric"><span>评论</span><b></b><input value="1" /></label>
<span class="and"></span>
<label class="metric"><span>时长</span><b></b><input value="5" /></label>
<span class="and"></span>
<label class="metric"><span>点赞</span><b></b><input value="10" /></label>
<span class="and"></span>
<label class="metric"><span>转发</span><b></b><input value="0" /></label>
<span class="and"></span>
<label class="metric"><span>完播率</span><b></b><input value="1" /></label>
<span class="and"></span>
<label class="metric"><span>互动率</span><b></b><input value="0.1" /></label>
<span class="and"></span>
<label class="metric"><span>播放中位数</span><b></b><input value="1000" /></label>
</div>
</div>
</section>
<table>
<thead>
<tr>
<th><input type="checkbox" /> 全选</th>
<th>达人信息</th>
<th>代表视频</th>
<th>达人类型</th>
<th>内容主题</th>
<th>粉丝数</th>
<th>预期CPM</th>
<th>完播率</th>
<th>21-60s报价</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="checkbox" /></td>
<td>
<div class="author">
<span class="avatar"></span>
<div>
<strong>柯铭</strong><br />
<span style="color: #7a8493">男 · 北京市 · 抖音精选</span>
</div>
</div>
</td>
<td>2 个视频</td>
<td>萌宠</td>
<td>国内旅行 / 昆虫科普</td>
<td>1,471.4w</td>
<td>43.5</td>
<td>26.2%</td>
<td class="price">¥600,000</td>
<td><button class="pink-btn">下单</button></td>
</tr>
</tbody>
</table>
</section>
</main>
</body>
</html>
@@ -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 @@
# 星图达人视频传播数据导出 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:** 导出 CSV 前按配置调用 `get_author_spread_info`,追加个人视频和星图视频传播指标列。
**Architecture:** 新增独立的 spread-info 模块负责参数配置、URL、响应映射和并发加载;列表解析保留 `authorId`,额外保存 `spreadAuthorId` 作为 `o_author_id`CSV exporter 只负责把已加载的 spread metrics 输出成列。导出入口在生成 CSV 前补齐 spread metrics。
**Tech Stack:** TypeScript, Chrome content script, Vitest, jsdom.
---
### Task 1: Spread Info Client And Mapping
**Files:**
- Create: `src/content/market/spread-info.ts`
- Modify: `src/content/market/types.ts`
- Test: `tests/spread-info.test.ts`
- [ ] **Step 1: Write failing tests**
Cover URL construction, label/header generation, response mapping, personal-video fixed params, and Xingtu-video multi-param configs.
- [ ] **Step 2: Run failing tests**
Run: `npx vitest run tests/spread-info.test.ts`
Expected: FAIL because `spread-info.ts` does not exist.
- [ ] **Step 3: Implement spread-info module and types**
Implement typed configs, formatter helpers, response mapper, client, and limited-concurrency loader.
- [ ] **Step 4: Run tests**
Run: `npx vitest run tests/spread-info.test.ts`
Expected: PASS.
### Task 2: Preserve Spread Author ID From Search Rows
**Files:**
- Modify: `src/content/market/types.ts`
- Modify: `src/content/market/market-list-row.ts`
- Modify: `src/content/market/page-bridge.ts`
- Test: `tests/market-page-bridge.test.ts`
- Test: `tests/silent-export-controller.test.ts`
- [ ] **Step 1: Write failing tests**
Verify `attribute_datas.id` is retained as `spreadAuthorId` and preferred over top-level `star_id` for spread-info requests.
- [ ] **Step 2: Run failing tests**
Run focused tests for row parsing and silent export.
- [ ] **Step 3: Implement parser changes**
Store `spreadAuthorId` on snapshots and merge it in result store.
- [ ] **Step 4: Run focused tests**
Expected: PASS.
### Task 3: CSV Columns
**Files:**
- Modify: `src/content/market/csv-exporter.ts`
- Test: `tests/csv-exporter.test.ts`
- [ ] **Step 1: Write failing tests**
Verify spread headers append after backend metrics and blank cells are exported when metrics are absent.
- [ ] **Step 2: Implement CSV spread columns**
Read `record.spreadMetrics` by generated header names.
- [ ] **Step 3: Run focused tests**
Run: `npx vitest run tests/csv-exporter.test.ts`
Expected: PASS.
### Task 4: Export Hydration
**Files:**
- Modify: `src/content/market/index.ts`
- Modify: `src/content/market/result-store.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write failing tests**
Verify export calls spread-info with `spreadAuthorId`, waits before CSV generation, preserves row order, and leaves blanks on failure.
- [ ] **Step 2: Implement hydration**
Inject `loadSpreadMetrics` for tests, default to spread-info loader, and hydrate records before `buildCsv`.
- [ ] **Step 3: Run focused tests**
Run focused content-entry tests.
### Task 5: Final Verification
- [ ] Run `npm test`.
- [ ] Run `npm run build`.
- [ ] Review `git diff`.
@@ -0,0 +1,33 @@
# 星图达人传播指标阈值筛选 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:** 在导出 CSV 和提交批次前,按用户选择的 spread-info 参数组合和指标阈值过滤达人。
**Architecture:** 工具栏负责读取筛选配置;`spread-info.ts` 提供单参数组合加载与阈值比较;`index.ts` 在 export range 收集后、CSV/批次 payload 生成前统一应用筛选。
**Tech Stack:** TypeScript, Chrome MV3 content script, Vitest, jsdom.
---
### Task 1: Toolbar Filter State
- [ ] 增加视频类别、指派、营销流量、数据范围和 7 个阈值输入控件。
- [ ] 增加 `readToolbarSpreadFilter` 读取并校验筛选配置。
- [ ] 测试个人视频时固定并禁用指派/营销流量。
### Task 2: Spread Filter Logic
- [ ]`spread-info.ts` 增加单配置请求与阈值比较。
- [ ] 测试百分比显示值、秒、普通数字比较。
### Task 3: Export And Batch Integration
- [ ] 在导出和提交批次流程中调用筛选逻辑。
- [ ] 空阈值不触发筛选请求。
- [ ] 测试导出和提交批次都只保留满足阈值的达人。
### Task 4: Verification
- [ ] 运行 focused tests。
- [ ] 运行 `npm run build`
@@ -0,0 +1,665 @@
# Independent Spread Metric Filter 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:** Replace the single shared spread-video configuration with independently configurable completion-rate and interaction-rate rules that are combined with AND semantics.
**Architecture:** Represent each enabled metric as a `SpreadMetricFilterRule` containing its metric, threshold, and normalized `SpreadInfoConfig`. The toolbar owns metric selection and per-rule controls; the market controller groups rules by normalized config, loads one Xingtu snapshot per unique config and author, and evaluates every rule before export or batch submission.
**Tech Stack:** TypeScript 6, Chrome MV3 content scripts, DOM APIs, Vitest 4 with JSDOM, tsup build scripts.
---
## File Structure
- Modify `src/content/market/types.ts`: own shared spread config and filter rule types.
- Modify `src/content/market/spread-info.ts`: normalize configs, build stable config keys, and compare one metric rule against a mapped snapshot.
- Modify `src/content/market/plugin-toolbar.ts`: render metric checkboxes and per-metric dynamic rule controls, enforce personal-video constraints, and read validated rule arrays.
- Modify `src/content/market/index.ts`: group rules by config, reuse snapshots, and apply AND filtering for export and batch submission.
- Modify `tests/spread-info.test.ts`: cover config normalization, grouping keys, and single-rule comparison.
- Modify `tests/market-content-entry.test.ts`: cover toolbar interaction, validation, request grouping, AND behavior, export, and batch submission.
- Modify `docs/项目流程说明文档.md`: replace the obsolete seven-threshold/global-config description with the implemented two-metric independent-rule flow.
### Task 1: Shared Rule Model and Pure Spread Filter Logic
**Files:**
- Modify: `src/content/market/types.ts:15-35`
- Modify: `src/content/market/spread-info.ts:1-25, 210-223`
- Test: `tests/spread-info.test.ts:1-214`
- [ ] **Step 1: Write failing tests for normalized configs, stable keys, and metric-specific matching**
Replace the old `matchesSpreadThresholds` test with focused tests and imports:
```ts
import {
buildSpreadInfoConfigKey,
matchesSpreadMetricRule,
normalizeSpreadInfoConfig
} from "../src/content/market/spread-info";
test("normalizes fixed personal-video parameters before grouping", () => {
expect(normalizeSpreadInfoConfig({
flowType: 1,
onlyAssign: true,
range: 3,
type: 1
})).toEqual({
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
});
});
test("uses all normalized video dimensions in the config key", () => {
expect(buildSpreadInfoConfigKey({
flowType: 1,
onlyAssign: true,
range: 3,
type: 1
})).toBe(buildSpreadInfoConfigKey({
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
}));
expect(buildSpreadInfoConfigKey({
flowType: 0,
onlyAssign: false,
range: 2,
type: 2
})).not.toBe(buildSpreadInfoConfigKey({
flowType: 0,
onlyAssign: false,
range: 3,
type: 2
}));
});
test("matches only the metric named by one filter rule", () => {
expect(matchesSpreadMetricRule(
{ finishRate: "28.24%", interactionRate: "4.02%" },
{
config: { flowType: 0, onlyAssign: false, range: 2, type: 1 },
metric: "finishRate",
threshold: 28
}
)).toBe(true);
expect(matchesSpreadMetricRule(
{ finishRate: "28.24%" },
{
config: { flowType: 0, onlyAssign: false, range: 2, type: 1 },
metric: "interactionRate",
threshold: 1
}
)).toBe(false);
});
```
- [ ] **Step 2: Run the focused test and verify the new API is missing**
Run: `npx vitest run tests/spread-info.test.ts`
Expected: FAIL because `normalizeSpreadInfoConfig`, `buildSpreadInfoConfigKey`, and `matchesSpreadMetricRule` are not exported.
- [ ] **Step 3: Replace the global threshold type with shared rule types**
In `types.ts`, remove `SpreadMetricThresholds` and define:
```ts
export interface SpreadInfoConfig {
flowType: 0 | 1;
onlyAssign: boolean;
range: 2 | 3;
type: 1 | 2;
}
export type SpreadFilterMetric = "finishRate" | "interactionRate";
export interface SpreadMetricFilterRule {
config: SpreadInfoConfig;
metric: SpreadFilterMetric;
threshold: number;
}
export interface SpreadThresholdFilter {
rules: SpreadMetricFilterRule[];
}
```
Move `SpreadInfoConfig` ownership out of `spread-info.ts` and import it there from `types.ts`.
- [ ] **Step 4: Implement pure normalization, keying, and comparison helpers**
In `spread-info.ts`, replace `matchesSpreadThresholds` with:
```ts
export function normalizeSpreadInfoConfig(
config: SpreadInfoConfig
): SpreadInfoConfig {
return config.type === 1
? { ...config, flowType: 0, onlyAssign: false }
: { ...config };
}
export function buildSpreadInfoConfigKey(config: SpreadInfoConfig): string {
const normalized = normalizeSpreadInfoConfig(config);
return [
normalized.type,
normalized.onlyAssign ? 1 : 0,
normalized.flowType,
normalized.range
].join(":");
}
export function matchesSpreadMetricRule(
metrics: MappedSpreadInfoResponse,
rule: SpreadMetricFilterRule
): boolean {
const numericValue = readDisplayNumber(metrics[rule.metric]);
return numericValue !== null && numericValue >= rule.threshold;
}
```
- [ ] **Step 5: Run the focused tests**
Run: `npx vitest run tests/spread-info.test.ts`
Expected: PASS.
- [ ] **Step 6: Commit the shared rule model**
```bash
git add src/content/market/types.ts src/content/market/spread-info.ts tests/spread-info.test.ts
git commit -m "refactor: model independent spread metric rules"
```
### Task 2: Dynamic Per-Metric Toolbar Rules
**Files:**
- Modify: `src/content/market/plugin-toolbar.ts:1-590, 810-1035`
- Test: `tests/market-content-entry.test.ts:390-480, 1285-1340`
- [ ] **Step 1: Write failing DOM tests for the default and enabled states**
Update the toolbar assertions to use metric checkboxes and scoped rule rows:
```ts
const finishToggle = document.querySelector(
'[data-plugin-spread-metric="finishRate"]'
) as HTMLInputElement;
const interactionToggle = document.querySelector(
'[data-plugin-spread-metric="interactionRate"]'
) as HTMLInputElement;
const finishRule = document.querySelector(
'[data-plugin-spread-rule="finishRate"]'
) as HTMLElement;
expect(finishToggle.checked).toBe(false);
expect(interactionToggle.checked).toBe(false);
expect(finishRule.hidden).toBe(true);
finishToggle.checked = true;
finishToggle.dispatchEvent(new Event("change", { bubbles: true }));
expect(finishRule.hidden).toBe(false);
expect(finishRule.querySelector('[data-plugin-spread-filter="type"]')).not.toBeNull();
expect(finishRule.querySelector('[data-plugin-spread-threshold="finishRate"]')).not.toBeNull();
```
Add reusable test helpers:
```ts
function enableSpreadMetric(metric: "finishRate" | "interactionRate") {
const selector = `[data-plugin-spread-metric="${metric}"]`;
const input = document.querySelector(selector) as HTMLInputElement;
input.checked = true;
dispatchChange(selector);
}
function setSpreadRuleSelect(
metric: "finishRate" | "interactionRate",
field: "type" | "onlyAssign" | "flowType" | "range",
value: string
) {
const selector =
`[data-plugin-spread-rule="${metric}"] [data-plugin-spread-filter="${field}"]`;
setSelectValue(selector, value);
dispatchChange(selector);
}
```
Then verify per-rule isolation:
```ts
enableSpreadMetric("finishRate");
enableSpreadMetric("interactionRate");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("finishRate", "flowType", "1");
setSpreadRuleSelect("interactionRate", "type", "2");
setSpreadRuleSelect("interactionRate", "onlyAssign", "true");
setSpreadRuleSelect("interactionRate", "flowType", "1");
setSpreadRuleSelect("interactionRate", "type", "1");
const finishOnlyAssign = document.querySelector(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="onlyAssign"]'
) as HTMLSelectElement;
const interactionOnlyAssign = document.querySelector(
'[data-plugin-spread-rule="interactionRate"] [data-plugin-spread-filter="onlyAssign"]'
) as HTMLSelectElement;
expect(finishOnlyAssign.value).toBe("true");
expect(finishOnlyAssign.disabled).toBe(false);
expect(interactionOnlyAssign.value).toBe("false");
expect(interactionOnlyAssign.disabled).toBe(true);
```
- [ ] **Step 2: Write failing validation tests for rule-array parsing**
Exercise `readToolbarSpreadFilter` through export button integration. The validation path is synchronous before any export work starts:
```ts
enableSpreadMetric("finishRate");
click('[data-plugin-export="button"]');
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("完播率");
expect(buildCsv).not.toHaveBeenCalled();
setInputValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-threshold="finishRate"]',
"30"
);
```
Import `ensurePluginToolbar` and `readToolbarSpreadFilter` in a focused test, then assert the empty and reset states directly:
```ts
const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
"../src/content/market/plugin-toolbar"
);
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
expect(readToolbarSpreadFilter(toolbar)).toEqual({ filter: { rules: [] } });
enableSpreadMetric("finishRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setSpreadRuleSelect("finishRate", "type", "2");
const toggle = document.querySelector(
'[data-plugin-spread-metric="finishRate"]'
) as HTMLInputElement;
toggle.checked = false;
dispatchChange('[data-plugin-spread-metric="finishRate"]');
toggle.checked = true;
dispatchChange('[data-plugin-spread-metric="finishRate"]');
expect(
(document.querySelector('[data-plugin-spread-threshold="finishRate"]') as HTMLInputElement).value
).toBe("");
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]',
"1"
);
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]',
"2"
);
```
Define the no-op handlers next to the existing DOM test helpers:
```ts
function createNoopToolbarHandlers() {
return {
onConfigureAudienceProfileFields: vi.fn(),
onExport: vi.fn(),
onExportAudienceProfile: vi.fn(),
onExportAudienceProfileByIds: vi.fn(),
onSubmitBatch: vi.fn()
};
}
```
- [ ] **Step 3: Run the toolbar-focused test file and verify failure**
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread"`
Expected: FAIL because metric checkboxes and per-rule controls do not exist.
- [ ] **Step 4: Replace the global toolbar DOM contract with per-rule DOM objects**
Define this internal shape in `plugin-toolbar.ts`:
```ts
interface SpreadMetricRuleDom {
enabledInput: HTMLInputElement;
flowTypeSelect: HTMLSelectElement;
onlyAssignSelect: HTMLSelectElement;
rangeSelect: HTMLSelectElement;
root: HTMLElement;
thresholdInput: HTMLInputElement;
typeSelect: HTMLSelectElement;
}
type SpreadMetricRuleDomMap = Record<SpreadFilterMetric, SpreadMetricRuleDom>;
```
Replace the four global config selects and two loose threshold inputs in `PluginToolbarDom` with `spreadMetricRules: SpreadMetricRuleDomMap`.
- [ ] **Step 5: Build metric selectors and hidden rule rows**
Create one checkbox and one rule row for each fixed definition:
```ts
const SPREAD_FILTER_DEFINITIONS = [
{ label: "完播率", metric: "finishRate" },
{ label: "互动率", metric: "interactionRate" }
] as const;
```
Use these stable selectors:
```text
data-plugin-spread-metric="finishRate|interactionRate"
data-plugin-spread-rule="finishRate|interactionRate"
data-plugin-spread-filter="type|onlyAssign|flowType|range"
data-plugin-spread-threshold="finishRate|interactionRate"
```
Keep both rule rows in the DOM but set `hidden=true` until their checkboxes are enabled. Layout the selection controls before the rule container and remove the old global “视频口径” group.
- [ ] **Step 6: Implement independent state synchronization and reset behavior**
On each metric checkbox change:
```ts
function syncSpreadMetricRuleState(rule: SpreadMetricRuleDom): void {
rule.root.hidden = !rule.enabledInput.checked;
if (!rule.enabledInput.checked) {
rule.thresholdInput.value = "";
rule.typeSelect.value = "1";
rule.onlyAssignSelect.value = "false";
rule.flowTypeSelect.value = "0";
rule.rangeSelect.value = "2";
clearSpreadMetricRuleValidation(rule);
}
syncSpreadMetricVideoConstraints(rule);
}
```
`syncSpreadMetricVideoConstraints` must only touch the changed rule. For personal video it fixes and disables `onlyAssign` and `flowType`; for Xingtu video it enables them.
- [ ] **Step 7: Parse selected controls into validated rule arrays**
Rework `readToolbarSpreadFilter` to iterate in finish-rate then interaction-rate order:
```ts
const rules: SpreadMetricFilterRule[] = [];
for (const metric of ["finishRate", "interactionRate"] as const) {
const ruleDom = toolbar.spreadMetricRules[metric];
clearSpreadMetricRuleValidation(ruleDom);
if (!ruleDom.enabledInput.checked) continue;
const threshold = Number(ruleDom.thresholdInput.value.trim());
if (!ruleDom.thresholdInput.value.trim() || !Number.isFinite(threshold) || threshold < 0) {
markSpreadMetricRuleInvalid(ruleDom);
return { error: `请输入有效的${metric === "finishRate" ? "完播率" : "互动率"}筛选阈值` };
}
rules.push({ metric, threshold, config: readSpreadMetricConfig(ruleDom) });
}
return { filter: { rules } };
```
Update busy-state handling and native-control styling to walk every checkbox, threshold input, and select in `spreadMetricRules`.
- [ ] **Step 8: Run toolbar-focused tests**
Run: `npx vitest run tests/market-content-entry.test.ts -t "toolbar|spread"`
Expected: PASS.
- [ ] **Step 9: Commit the dynamic toolbar**
```bash
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
git commit -m "feat: add independent spread metric controls"
```
### Task 3: Config-Deduplicated AND Filtering for Export and Batch Submission
**Files:**
- Modify: `src/content/market/index.ts:30-95, 801-828`
- Test: `tests/market-content-entry.test.ts:1440-1496, 2436-2492`
- [ ] **Step 1: Write a failing export test for different configs and AND semantics**
Configure completion rate as Xingtu/assigned/30-day and interaction rate as personal/90-day. Mock snapshots by config:
```ts
const loadSpreadFilterMetrics = vi.fn(async (
spreadAuthorId: string,
config: SpreadInfoConfig
) => {
if (config.type === 2) {
return { finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%" };
}
return { interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%" };
});
```
Configure the controls and assert the exact result and calls:
```ts
enableSpreadMetric("finishRate");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("interactionRate", "range", "3");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual(["a"]);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
});
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
});
```
- [ ] **Step 2: Write a failing export test for same-config request reuse**
Configure both rules as personal/30-day, return both metrics in one snapshot, and assert:
```ts
enableSpreadMetric("finishRate");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
});
```
- [ ] **Step 3: Update the batch-submit integration test to enable and apply a rule**
Enable both rules before submitting and preserve the exact payload assertion:
```ts
enableSpreadMetric("finishRate");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("interactionRate", "range", "3");
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 80, 50);
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
expect.objectContaining({ authorId: "a" })
]);
```
- [ ] **Step 4: Run the new integration tests and verify failure**
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread threshold|independent spread|reuses"`
Expected: FAIL because the controller still reads one global config and threshold object.
- [ ] **Step 5: Update the controller dependency type**
Change `loadSpreadFilterMetrics` to accept `SpreadInfoConfig` and return `MappedSpreadInfoResponse`:
```ts
loadSpreadFilterMetrics?: (
spreadAuthorId: string,
config: SpreadInfoConfig
) => Promise<MappedSpreadInfoResponse>;
```
- [ ] **Step 6: Group normalized rules and reuse each snapshot**
Replace the one-request filter body with config groups:
```ts
const normalizedRules = filter.rules.map((rule) => ({
...rule,
config: normalizeSpreadInfoConfig(rule.config)
}));
const groups = new Map<string, {
config: SpreadInfoConfig;
rules: SpreadMetricFilterRule[];
}>();
for (const rule of normalizedRules) {
const key = buildSpreadInfoConfigKey(rule.config);
const group = groups.get(key) ?? { config: rule.config, rules: [] };
group.rules.push(rule);
groups.set(key, group);
}
```
For each record, load all unique groups, catch a group failure as an empty snapshot, and preserve the existing per-author isolation:
```ts
const snapshots = new Map<string, MappedSpreadInfoResponse>();
await Promise.all(Array.from(groups.entries()).map(async ([key, group]) => {
try {
snapshots.set(key, await loadSpreadFilterMetrics(spreadAuthorId, group.config));
} catch {
snapshots.set(key, {});
}
}));
const matchesAll = normalizedRules.every((rule) =>
matchesSpreadMetricRule(
snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {},
rule
)
);
```
Return records unchanged when `filter` is missing or `filter.rules.length === 0`.
- [ ] **Step 7: Run focused integration tests**
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread threshold|independent spread|reuses"`
Expected: PASS.
- [ ] **Step 8: Run all market content-entry tests**
Run: `npx vitest run tests/market-content-entry.test.ts`
Expected: PASS.
- [ ] **Step 9: Commit config-deduplicated filtering**
```bash
git add src/content/market/index.ts tests/market-content-entry.test.ts
git commit -m "feat: filter spread metrics by independent configs"
```
### Task 4: Process Documentation and Full Verification
**Files:**
- Modify: `docs/项目流程说明文档.md:251-269, 323, 363, 426, 490, 509, 593`
- [ ] **Step 1: Update the maintained process description**
Rewrite section `3.8 传播指标阈值筛选` to state:
```markdown
- 输入内容:可选的完播率、互动率规则;每条已选规则包含非负阈值和独立的视频类别、是否只看指派、是否排除营销流量、时间范围。
- 处理规则:
- 默认不选择任何指标,不启用二次筛选;
- 每个已选指标必须填写阈值,并使用自己的视频口径请求传播指标;
- 同时选择完播率和互动率时,两条规则必须全部满足;
- 两条规则口径相同时,同一达人复用一次接口响应;口径不同时分别请求;
- 个人视频固定为不限指派、不排除营销流量;
- 请求失败、缺少 ID 或缺少已选指标的达人视为不满足筛选。
```
Update the interface table's concurrency note to mention unique-config reuse, and change the configuration table row from one generic “传播指标阈值” to “传播指标规则”,defaulting to no selected metric.
- [ ] **Step 2: Run documentation consistency checks**
Run: `rg -n "七个指标阈值|没有填写任何阈值|平均时长按秒|播放量、评论、点赞、转发" docs/项目流程说明文档.md`
Expected: no output.
Run: `rg -n "每个已选指标|口径相同|传播指标规则" docs/项目流程说明文档.md`
Expected: matches in section 3.8, the `get_author_spread_info` interface row, and the configuration table.
- [ ] **Step 3: Run focused feature tests**
Run: `npx vitest run tests/spread-info.test.ts tests/market-content-entry.test.ts`
Expected: PASS.
- [ ] **Step 4: Run the full test suite**
Run: `npm test`
Expected: PASS with no failed test files.
- [ ] **Step 5: Build the extension**
Run: `npm run build`
Expected: exit code `0` and refreshed development bundle in `dist/`.
- [ ] **Step 6: Review the final diff and workspace scope**
Run: `git diff --check`
Expected: no whitespace errors.
Run: `git status --short`
Expected: only the planned source, test, and process-document files are modified; `.superpowers/` remains untracked and must not be staged.
- [ ] **Step 7: Commit documentation and verification-complete state**
```bash
git add docs/项目流程说明文档.md
git commit -m "docs: update independent spread filter flow"
```
@@ -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
@@ -0,0 +1,242 @@
# 星图达人视频传播数据导出需求文档
## 目标
在现有星图达人 CSV 导出流程中,额外调用星图接口 `get_author_spread_info`,获取达人视频传播相关指标,并把这些指标追加到导出表格中。
因为同一个指标在不同参数组合下含义不同,所以导出字段名必须带上参数前缀。例如:
```text
只看指派_排除营销流量_星图视频_近30天_完播率
```
这个字段表示:它不是普通的“完播率”,而是在“只看指派 + 排除营销流量 + 星图视频 + 近30天”这组参数下获取到的完播率。
字段名前缀只体现会造成数据差异、且在当前导出中可变化的参数。固定不变的参数不用写进字段名前缀。
## 接口
调用接口:
```text
GET /gw/api/data_sp/get_author_spread_info
```
请求参数:
| 参数 | 含义 |
| --- | --- |
| `o_author_id` | 达人的星图 ID |
| `platform_source` | 固定传 `1` |
| `platform_channel` | 固定传 `1` |
| `type` | 视频类型 |
| `flow_type` | 是否排除营销流量 |
| `only_assign` | 是否只看指派 |
| `range` | 数据时间范围 |
请求需要带上当前星图网页登录态,所以实现时请求要使用浏览器当前 cookie,也就是 `credentials: "include"`
## 星图 ID 来源
`o_author_id` 需要从 `search_for_author_square` 接口返回值中获取:
```text
authors[i].attribute_datas.id
```
如果同一行数据里同时存在顶层 `star_id``attribute_datas.id`,这个接口优先使用 `attribute_datas.id` 作为 `o_author_id`
## 参数含义
### only_assign
| 值 | 含义 | 字段名前缀 |
| --- | --- | --- |
| `true` | 只看指派 | `只看指派` |
| `false` | 取消“只看指派”勾选 | `不限指派` |
### flow_type
| 值 | 含义 | 字段名前缀 |
| --- | --- | --- |
| `1` | 排除营销流量 | `排除营销流量` |
| `0` | 不排除营销流量 | `不排除营销流量` |
### range
| 值 | 含义 | 字段名前缀 |
| --- | --- | --- |
| `2` | 近 30 天 | `近30天` |
| `3` | 近 90 天 | `近90天` |
### type
| 值 | 含义 | 字段名前缀 |
| --- | --- | --- |
| `1` | 个人视频 | `个人视频` |
| `2` | 星图视频 | `星图视频` |
## 多组参数导出
第一版需要支持多组参数组合。
参数组合需要区分“个人视频”和“星图视频”两类处理:
- `type=1` 个人视频:`only_assign=false``flow_type=0` 固定,只允许调整 `range`
- `type=2` 星图视频:需要支持多组参数组合,因为 `only_assign``flow_type``range` 的不同设置会导致接口返回的数据不同。
个人视频固定参数:
```text
type=1
flow_type=0
only_assign=false
```
个人视频可变参数:
```text
range=2 或 range=3
```
因为个人视频里 `only_assign=false``flow_type=0` 是固定参数,所以它们不写入字段名前缀。个人视频字段只需要体现视频类型和时间范围,例如:
```text
个人视频_近30天_完播率
个人视频_近90天_完播率
```
星图视频可以配置多组参数。每一组参数都会调用一次 `get_author_spread_info`,并为这一组参数生成 7 个导出字段。
例如某一组参数是:
```text
only_assign=true
flow_type=1
type=2
range=2
```
那么这一组会生成:
- `只看指派_排除营销流量_星图视频_近30天_完播率`
- `只看指派_排除营销流量_星图视频_近30天_播放量中位数`
- `只看指派_排除营销流量_星图视频_近30天_互动率`
- `只看指派_排除营销流量_星图视频_近30天_作品平均时长`
- `只看指派_排除营销流量_星图视频_近30天_作品平均评论数`
- `只看指派_排除营销流量_星图视频_近30天_作品平均点赞数`
- `只看指派_排除营销流量_星图视频_近30天_作品平均转发数`
字段名规则固定为:
```text
<会变化的参数文案>_<视频类型文案>_<时间范围文案>_<指标名>
```
对星图视频来说,`only_assign``flow_type``range` 都可能变化,所以字段名要保留这些参数。对个人视频来说,只有 `range` 变化,所以字段名不需要写 `不限指派``不排除营销流量`
这里必须保留会变化参数的前缀,不能把不同参数组合下的同名指标合并。例如下面两个字段都叫“完播率”,但数据含义不同,必须作为两个独立字段导出:
```text
只看指派_排除营销流量_星图视频_近30天_完播率
不限指派_不排除营销流量_星图视频_近30天_完播率
```
## 需要导出的指标
每一组参数都要导出下面 7 个指标:
| 导出字段指标名 | 接口响应字段 | 示例值 | 说明 |
| --- | --- | --- | --- |
| 完播率 | `play_over_rate.value` | `2824` | 按万分比理解,导出时建议显示为 `28.24%` |
| 播放量中位数 | `play_mid`,兜底 `item_rate.play_mid.value` | `10913233` | 播放量中位数 |
| 互动率 | `interact_rate.value` | `402` | 按万分比理解,导出时建议显示为 `4.02%` |
| 作品平均时长 | `avg_duration` | `5600` | 按百分之一秒理解,导出时显示为秒,例如 `56` |
| 作品平均评论数 | `comment_avg` | `7502` | 平均评论数 |
| 作品平均点赞数 | `like_avg` | `494458` | 平均点赞数 |
| 作品平均转发数 | `share_avg` | `188267` | 平均转发数 |
示例响应:
```json
{
"avg_duration": "5600",
"comment_avg": "7502",
"interact_rate": {
"overtake": 5312,
"value": 402
},
"item_rate": {
"play_mid": {
"label": "",
"overtake": 10000,
"value": 10913233
}
},
"like_avg": "494458",
"play_mid": "10913233",
"play_over_rate": {
"overtake": 9584,
"value": 2824
},
"share_avg": "188267"
}
```
## 导出流程
1. 当前插件仍然先从星图达人搜索页收集达人列表。
2.`search_for_author_square``authors[i].attribute_datas.id` 取出每个达人的星图 ID。
3. 用户导出 CSV 时,先按现有逻辑确定导出范围,例如当前页、前 5 页、前 10 页、全部或自定义页数。
4. 对导出范围内的每个达人,先按个人视频参数调用 `get_author_spread_info``type=1``flow_type=0``only_assign=false` 固定,`range` 按配置取值。
5. 如果配置了星图视频参数组合,再按每一组星图视频参数分别调用 `get_author_spread_info`
6. 把每次接口返回值解析成 7 个指标。
7. CSV 保留原有字段顺序,在现有字段后追加这些带参数前缀的新字段。
## 失败处理
- 如果某个达人没有 `attribute_datas.id`,这一行的视频传播指标留空。
- 如果某个参数组合请求失败,这一组参数对应的 7 个字段留空。
- 如果接口响应结构异常,这一组参数对应的 7 个字段留空。
- 某个达人失败不能影响其他达人导出。
- 某组参数失败不能影响同一个达人的其他参数组导出。
## 性能要求
这个功能会产生比较多接口请求:
```text
请求数 = 导出的达人数量 * 参数组合数量
```
所以实现时需要:
- 做并发限制,避免一次性打太多请求。
- 保持最终 CSV 行顺序和原导出顺序一致。
- 给每个请求设置超时时间。
- 第一版不做激进重试,避免接口压力过大。
## 测试要求
需要补充测试覆盖:
- `get_author_spread_info` URL 参数构造是否正确。
- `type=1` 生成 `个人视频` 前缀。
- `type=2` 生成 `星图视频` 前缀。
- 个人视频是否固定使用:`type=1``flow_type=0``only_assign=false`
- 个人视频是否支持切换 `range=2``range=3`
- 个人视频字段名前缀是否不包含固定参数 `不限指派``不排除营销流量`
- 星图视频是否支持多组参数组合。
- `only_assign``flow_type``range` 前缀是否正确。
- 是否从 `attribute_datas.id` 读取 `o_author_id`
- 多组参数是否分别生成 7 个字段。
- 响应字段是否正确映射到 7 个导出指标。
- 接口失败时是否导出空字段。
- 多个达人并发请求完成顺序不一致时,最终 CSV 行顺序是否保持不变。
## 暂不做的事情
- 暂不新增页面上的参数配置 UI。
- 暂不改变星图搜索页原本的筛选条件。
- 暂不改变现有后端指标字段。
- 暂不改变批次提交 payload。
@@ -0,0 +1,101 @@
# 星图达人传播指标阈值筛选需求文档
## 目标
在导出 CSV 或提交批次之前,允许用户按一组视频传播数据参数和指标阈值对达人做二次筛选。
只有满足筛选条件的达人,才进入最终导出或提交批次。
## 筛选维度
筛选维度对应 `get_author_spread_info` 的请求参数:
| UI 维度 | 接口参数 | 可选值 |
| --- | --- | --- |
| 视频类别 | `type` | 个人视频 / 星图视频 |
| 是否指派 | `only_assign` | 只看指派 / 不限指派 |
| 是否排除营销流量 | `flow_type` | 排除营销流量 / 不排除营销流量 |
| 数据范围 | `range` | 近30天 / 近90天 |
个人视频的参数约束:
- `type=1`
- `only_assign=false`
- `flow_type=0`
- `range` 可选近30天或近90天
星图视频的参数约束:
- `type=2`
- `only_assign` 可选
- `flow_type` 可选
- `range` 可选近30天或近90天
## 指标阈值
支持下面 7 个指标阈值:
- 完播率 >=
- 播放量中位数 >=
- 互动率 >=
- 作品平均时长 >=
- 作品平均评论数 >=
- 作品平均点赞数 >=
- 作品平均转发数 >=
规则:
- 没填的阈值不参与筛选。
- 填了多个阈值时,必须全部满足才保留达人。
- 完播率和互动率使用百分数显示值,例如填 `30` 表示 `30%`
- 作品平均时长使用秒,例如填 `56` 表示 `56秒`
- 播放量、评论、点赞、转发使用普通数字。
- 如果某个达人在所选参数组合下接口请求失败或缺少被启用的指标,则视为不满足筛选。
## 生效范围
阈值筛选同时作用于:
- 导出 CSV
- 提交批次
处理顺序:
1. 先按现有导出范围收集达人,例如当前页、前5页、前10页、全部或自定义页数。
2. 如果用户没有填写任何阈值,保持现有导出/提交行为。
3. 如果用户填写了阈值,对收集到的每个达人按当前筛选维度调用一次 `get_author_spread_info`
4. 将接口响应映射为显示值。
5. 用已填写的阈值过滤达人。
6. 过滤后的达人进入导出 CSV 或提交批次。
## UI 设计
在现有插件操作区中增加一组紧凑控件:
- 视频类别下拉框
- 指派下拉框
- 营销流量下拉框
- 数据范围下拉框
- 7 个数字输入框
当视频类别选择“个人视频”时:
- 指派固定为“不限指派”
- 营销流量固定为“不排除营销流量”
- 对应控件禁用
## 失败处理
- 单个达人筛选请求失败:该达人不满足筛选。
- 全部达人都不满足:导出空 CSV 表头;提交批次时按现有空记录处理。
- 阈值输入非法:阻止导出/提交,并提示用户修正。
## 测试要求
- 读取 toolbar 中的筛选参数和阈值。
- 个人视频禁用指派和营销流量控件。
- 空阈值时不触发二次筛选。
- 有阈值时按所选参数调用 `get_author_spread_info`
- 百分比阈值按显示值比较。
- 多个阈值按 AND 关系过滤。
- 导出 CSV 和提交批次都应用二次筛选。
@@ -0,0 +1,172 @@
# 传播指标独立视频口径筛选设计
## 背景
当前工具栏把视频类型、是否指派、是否排除营销流量和时间范围作为一套全局视频口径,再把完播率和互动率阈值同时应用到这套口径上。
正确的业务关系应为:用户先选择筛选指标,每个已选指标再分别配置阈值和一套独立视频口径。例如,完播率可以使用“星图视频、只看指派、近30天”,互动率同时使用“个人视频、近90天”。
## 目标
- 当前只提供完播率和互动率两个可选筛选指标。
- 默认不选择任何指标,不改变现有导出和提交结果。
- 支持同时选择两个指标。
- 每个已选指标必须填写阈值并配置一套独立视频口径。
- 同时启用多个指标时,达人必须满足全部指标规则才会被保留。
- CSV 导出和提交批次复用相同的筛选结果。
## 非目标
- 不新增完播率和互动率之外的筛选指标。
- 不修改传播数据 CSV 的可选字段和字段命名。
- 不修改批次提交接口或批次 payload。
- 不修改星图 `get_author_spread_info` 的接口参数和响应字段映射。
- 不调整导出范围、选择达人、字段选择等其他工具栏能力。
## 前端交互
### 初始状态
“传播指标筛选”区域显示完播率、互动率两个复选框,默认均未选中。
未选中任何指标时:
- 不显示指标规则行。
- 不调用传播指标接口。
- 导出 CSV 和提交批次保持原行为。
### 动态规则行
勾选一个指标后,显示该指标的规则行。规则行从左到右包含:
1. 指标名称。
2. `>=` 阈值输入框和 `%` 单位。
3. 视频类型:个人视频或星图视频。
4. 是否只看指派:不限指派或只看指派。
5. 是否排除营销流量:不排除营销或排除营销。
6. 时间范围:近30天或近90天。
新规则行沿用现有默认口径:个人视频、不限指派、不排除营销、近30天。阈值默认为空,必须由用户填写。
取消勾选指标后,移除并丢弃对应规则,该指标不参与筛选。重新勾选时按上述默认值创建新规则,不恢复之前输入的阈值和口径。
同时勾选完播率和互动率时,显示两条互相独立的规则行。界面明确提示“全部规则都达标才保留达人”。
### 个人视频约束
每条规则独立应用现有个人视频约束。选择个人视频后:
- `onlyAssign` 自动设为 `false`,显示“不限指派”。
- `flowType` 自动设为 `0`,显示“不排除营销”。
- 是否指派和营销流量两个下拉框禁用。
- 时间范围仍可选择近30天或近90天。
切换回星图视频后,重新启用是否指派和营销流量下拉框。
### 输入校验
每个已选指标都必须填写一个大于或等于 `0` 的有限数值阈值。阈值为空、非数字或小于 `0` 时:
- 阻止导出 CSV 或提交批次。
- 在对应指标规则行展示校验状态。
- 工具栏状态区域给出可理解的错误提示。
未选中的指标不参与校验。
## 数据模型
筛选模型从“一套全局口径 + 多个可选阈值”改为规则数组:
```ts
type SpreadFilterMetric = "finishRate" | "interactionRate";
interface SpreadMetricFilterRule {
config: SpreadInfoConfig;
metric: SpreadFilterMetric;
threshold: number;
}
interface SpreadThresholdFilter {
rules: SpreadMetricFilterRule[];
}
```
每条规则完整表达“用哪套视频口径读取哪个指标,并与什么阈值比较”。同一指标最多出现一条规则。
`SpreadInfoConfig``spread-info.ts` 移到 `types.ts`,由工具栏、筛选模型和传播接口客户端共同引用,避免公共筛选类型反向依赖接口实现模块。
传播数据导出仍保留完整的 `MappedSpreadInfoResponse` 和七项传播指标映射。筛选规则类型只收窄筛选入口,不影响已有 CSV 导出能力。
## 筛选执行
### 处理顺序
1. 从工具栏读取已选指标,生成 `SpreadMetricFilterRule[]`
2. 没有规则时直接返回原达人集合。
3. 按现有导出范围或已选达人规则收集候选达人。
4. 对每个达人按规则中的 `config` 分组。
5. 每个唯一 `config` 调用一次 `get_author_spread_info`
6. 从返回快照中读取每条规则对应的指标。
7. 使用显示百分数值与阈值做 `>=` 比较。
8. 对同一达人的所有规则执行 AND 汇总。
9. 只有全部规则通过的达人进入 CSV 或批次 payload。
### 请求复用
如果完播率和互动率使用完全相同的视频口径,同一达人只请求一次接口,并从同一响应中分别读取 `play_over_rate.value``interact_rate.value`
如果两条规则的视频口径不同,同一达人分别请求两次。
视频口径是否相同由 `type``onlyAssign``flowType``range` 四个规范化后的参数共同决定。个人视频规则必须在分组前规范化为 `onlyAssign=false``flowType=0`
### 指标映射
- 完播率规则读取映射后的 `finishRate`,来源为 `data.play_over_rate.value`
- 互动率规则读取映射后的 `interactionRate`,来源为 `data.interact_rate.value`
- 两个接口值继续沿用现有基点百分比格式化逻辑,再与用户输入的显示百分数比较。
## 失败处理
- 达人缺少传播指标请求所需 ID:该达人不满足筛选。
- 单次请求失败、超时或返回非成功状态:依赖该口径的规则不通过。
- 响应缺少已选指标:对应规则不通过。
- 一条规则不通过:该达人因 AND 关系被排除。
- 单个达人失败不终止整批导出或提交,其他达人继续处理。
- 没有达人满足规则时,保持现有空结果处理方式,不扩大本次改动范围。
## 组件与代码边界
- `plugin-toolbar.ts`:指标复选框、动态规则行、个人视频联动、逐行校验和规则读取。
- `types.ts`:共享的 `SpreadInfoConfig`、筛选指标联合类型、单条规则类型和规则数组模型。
- `spread-info.ts`:复用现有请求构造与响应映射;提供单指标规则比较或等价的可测试纯函数。
- `index.ts`:按唯一口径加载快照、对每个达人执行规则 AND 判断,并让导出和提交共享该流程。
- 现有 CSV 字段定义、批次 payload 构造和外部接口契约不变。
## 测试与验收
### 工具栏测试
- 默认两个指标均未选中,不显示规则行。
- 勾选或取消完播率、互动率时,正确新增或移除对应规则行。
- 两个规则行可以保存不同阈值和不同视频口径。
- 个人视频自动规范化并禁用受限选项;星图视频重新启用选项。
- 已选指标缺少合法阈值时返回逐行校验错误。
- 未选择任何指标时读取到空规则集。
### 纯逻辑测试
- 单规则按对应指标和显示百分数正确比较。
- 两条规则均通过时返回通过。
- 任意一条规则不通过或缺少指标值时返回不通过。
- 相同视频口径生成相同分组键,不同口径生成不同分组键。
- 个人视频在分组前强制规范化固定参数。
### 集成测试
- 未选择指标时,导出和提交均不调用传播指标筛选请求。
- 两个指标使用不同口径时,每个达人发起两个请求并按 AND 过滤。
- 两个指标使用相同口径时,每个达人只发起一个请求并复用响应。
- 请求失败、缺少传播 ID 或缺少指标值的达人被排除,不中断整批处理。
- CSV 导出和批次提交得到相同的保留达人集合。
- 现有传播数据导出、工具栏挂载和批次 payload 测试继续通过。
- 项目类型检查、测试和构建通过。
@@ -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,23 @@ npm run build:release
⚠️ **如果重新加载后还是旧版本**:
- 先点击插件卡片的 **"移除"** 删除旧版本
- 然后重新点击 **"加载已解压的扩展程序"**
- 再次选择 `dist-release` 文件夹
- 再次选择 `dist` 文件夹
## ♻️ 老用户一次性升级说明
如果你之前已经装过比较早的旧版本,而且插件弹窗里一直显示:
- `暂时无法检查更新`
那通常说明你本地还是旧的更新机制,建议先手动完成一次升级:
1. 先获取我发出的最新压缩包
2. 解压后得到新的 `dist` 文件夹
3. 打开 `chrome://extensions`
4. 删除旧插件,或重新执行一次 **"加载已解压的扩展程序"**
5. 重新选择新的 `dist` 文件夹
完成这一次后,后续再更新时,就可以继续沿用统一的 `dist` 目录方式。
---
@@ -118,11 +134,14 @@ 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` 文件夹,而不是外层文件夹。
**Q: 我已经在用这个插件了,还需要再用压缩包更新一次吗?**
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. 选择刚才解压出来的插件文件夹
5. 选择刚才解压出来的 `dist` 文件夹
⚠️ **重要**:如果文件夹里能看到 `manifest.json`、`content`、`background`、`popup` 这些文件和文件夹,说明选对了。
⚠️ **重要**:如果 `dist` 文件夹里能看到 `manifest.json`、`content`、`background`、`popup` 这些文件和文件夹,说明选对了。
6. 看到绿色的插件卡片出现,就装好了!
@@ -122,7 +122,7 @@ https://xingtu.cn/ad/creator/market
- 解压下载到的新版本 zip
- 打开 `chrome://extensions`
- 找到 `Star Chart Search Enhancer`
- 点击 **"重新加载"**,或重新选择解压后的新插件文件夹
- 点击 **"重新加载"**,或重新选择解压后的 `dist` 文件夹
---
@@ -138,7 +138,25 @@ https://xingtu.cn/ad/creator/market
⚠️ **如果重新加载后还是旧版本**:
- 先点击插件卡片的 **"移除"** 删除旧版本
- 然后重新点击 **"加载已解压的扩展程序"**
- 再次选择新解压出来的插件文件夹
- 再次选择新解压出来的 `dist` 文件夹
## ♻️ 老用户一次性升级说明
如果你之前安装的是较早的旧版本,并且插件弹窗里一直显示:
- `暂时无法检查更新`
那通常说明你本地还在使用“旧更新机制”的插件包。
这种情况下,需要**手动用一次最新压缩包升级**:
1. 下载最新的 `star-chart-search-enhancer-internal.zip`
2. 解压后得到新的 `dist` 文件夹
3. 打开 `chrome://extensions`
4. 删除旧插件,或重新点击 **"加载已解压的扩展程序"**
5. 重新选择新的 `dist` 文件夹
完成这一次“过桥升级”后,后面再看到新版本时,就可以继续按统一的 `dist` 更新方式操作。
---
@@ -156,6 +174,9 @@ A: 检查浏览器的下载列表,文件可能已经下好了
### Q: 不小心把文件夹删了?
A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
### Q: 我已经在用插件了,还需要再用一次压缩包更新吗?
A: 如果你当前弹窗能正常显示 `发现新版本`,就不需要额外做特殊处理,按普通更新步骤走即可。如果弹窗一直只显示 `暂时无法检查更新`,建议手动用最新压缩包重新安装一次 `dist`,完成一次性升级。
---
## ✅ 每日使用 checklist
+731
View File
@@ -0,0 +1,731 @@
# 项目流程说明文档
## 1. 项目用途
本项目是公司内部使用的 Chrome 插件,用于增强巨量星图达人市场页面的达人筛选、数据导出和批次提交效率。
它解决的业务问题是:使用者在星图达人市场中筛选达人后,可以直接在页面上补充查看看后搜率、秒思后台指标等数据,并把选中的达人导出为 CSV,或提交为后续业务处理批次。
项目输入包括:
- 巨量星图达人市场页面中的达人列表、筛选条件和分页结果;
- 使用者在页面上勾选的达人;
- 使用者粘贴的达人星图 ID
- 使用者填写的批次名称;
- 使用者选择的导出字段和传播指标筛选规则;
- 当前插件登录用户的 Logto 身份和访问令牌。
项目处理过程包括:
- 在星图达人市场页面挂载插件工具栏;
- 读取当前页面或星图列表接口返回的达人数据;
- 根据勾选范围、分页范围、传播指标规则确定最终达人集合;
- 调用星图接口补充看后搜率、画像、商业能力、传播指标等信息;
- 调用公司后端接口补充秒思 api 指标;
- 生成 CSV 文件,或组装批次 payload 提交到后端。
项目输出包括:
- 页面上新增的数据列和状态提示;
- 下载到本地的 CSV 文件;
- 提交到后端的达人批次;
- 插件弹窗中的登录状态和更新状态。
项目依赖的外部平台、数据源或服务包括:
- 巨量星图网页和星图接口;
- 公司 Logto 登录系统;
- 公司 talent-search 后端服务;
- 本地或内网批次提交后端;
- COS 上的插件更新清单和安装包。
主要使用者是 AIGC 部门或相关业务同事。通常在以下场景使用:
- 在星图市场中筛选达人后,需要快速导出达人数据;
- 已知一批星图 ID,需要批量补齐画像、效果预估和内容指标;
- 需要把一批达人提交给后续业务系统继续处理;
- 需要检查或更新内部插件版本。
## 2. 整体流程总览
完整主流程按真实使用顺序如下:
1. 安装或更新插件;
2. 登录插件;
3. 打开巨量星图达人市场页面;
4. 插件读取登录状态并挂载工具栏;
5. 插件读取当前星图达人列表,并补充页面展示指标;
6. 使用者选择达人、字段、传播指标筛选规则或输入星图 ID;
7. 使用者触发导出或提交批次;
8. 插件收集达人数据,按规则过滤、去重、补充字段;
9. 插件调用星图接口和公司后端接口补充数据;
10. 插件下载 CSV,或把批次提交给后端;
11. 使用者通过状态提示、下载文件或后端批次结果确认任务完成。
### 2.1 安装或更新插件
- 触发者:使用者或管理员。
- 输入:内部 ZIP 安装包,或插件弹窗中发现的新版本安装包。
- 处理:解压 ZIP,在 Chrome 扩展页加载 `dist` 文件夹;更新时下载新版 ZIP 后人工重新加载插件。
- 输出:Chrome 中安装好的 `Star Chart Search Enhancer` 插件。
- 下一步:登录插件。
- 人工操作:需要人工解压、加载或重新加载插件。
- 条件分支:如果旧版本无法检查更新,需要做一次手动过桥升级。
### 2.2 登录插件
- 触发者:使用者点击插件弹窗中的登录按钮。
- 输入:公司账号登录态。
- 处理:通过 Logto 完成 Chrome 扩展登录,并获取访问后端资源的 token。
- 输出:插件弹窗显示已登录状态,内容脚本后续可以挂载业务工具栏。
- 下一步:打开星图达人市场页面。
- 人工操作:需要使用者完成登录。
- 条件分支:未登录或登录过期时,星图页面不会进入导出/提交流程,只显示登录提示。
### 2.3 打开星图达人市场页面
- 触发者:使用者访问星图市场页面。
- 输入:星图网页登录态、当前页面筛选条件、星图市场列表。
- 处理:插件仅在 `xingtu.cn` 域名下的达人市场页面生效;进入页面后先安装页面桥接逻辑,再检查插件登录状态。
- 输出:页面上出现插件工具栏和增强列。
- 下一步:读取达人列表并补充数据。
- 人工操作:使用者需要在星图网页中完成筛选、搜索、翻页或勾选。
- 条件分支:如果页面不是星图达人市场页面,插件不启动主流程。
### 2.4 页面增强和数据补充
- 触发者:星图页面加载、翻页、列表变化或插件同步周期。
- 输入:页面可见达人行、星图列表接口返回值、当前登录用户 token。
- 处理:插件读取达人 ID、名称、地区、报价等基础数据,补充看后搜率列和秒思指标列。
- 输出:页面上显示加载中、成功、失败或暂无数据等状态。
- 下一步:使用者选择导出、按 ID 导出或提交批次。
- 人工操作:无。
- 条件分支:如果某个指标加载失败,只影响该达人对应指标,不影响页面整体使用。
### 2.5 导出选中达人数据
- 触发者:使用者点击 `导出选中达人数据`
- 输入:当前勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。
- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留该范围内已勾选的达人,然后补充内容数据、效果预估、画像、秒思指标等字段。
- 输出:CSV 文件下载到浏览器默认下载目录。
- 下一步:使用者检查 CSV 内容。
- 人工操作:需要使用者勾选达人并点击按钮。
- 条件分支:如果当前导出范围内没有选中的达人,则不下载 CSV 并提示。
### 2.6 按星图 ID 导出
- 触发者:使用者点击 `按星图ID导出`
- 输入:弹窗中粘贴的达人星图 ID。
- 处理:插件校验 ID 格式、去重、忽略非法 token,然后逐个 ID 请求基础信息、看后搜率、传播指标、画像、商业能力和后端秒思指标。
- 输出:CSV 文件下载到浏览器默认下载目录。
- 下一步:使用者检查 CSV 中每个 ID 的导出状态和失败原因。
- 人工操作:需要使用者粘贴 ID 并确认。
- 条件分支:如果没有有效 ID,不执行导出并提示。
### 2.7 提交批次
- 触发者:使用者点击 `提交批次`
- 输入:当前范围或已勾选达人、批次名称、登录用户信息。
- 处理:插件先要求输入批次名称,再收集达人数据,应用传播指标规则和选中规则,检查登录状态,组装批次 payload,提交到后端。
- 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。
- 下一步:在后端系统中继续处理批次。
- 人工操作:需要使用者输入批次名称。
- 条件分支:未登录、批次名为空、后端拒绝或接口失败都会导致本次提交失败。
## 3. 详细流程说明
### 3.1 插件安装与更新
- 步骤目的:让使用者在 Chrome 中获得可运行的内部插件。
- 输入内容:内部发布 ZIP、安装说明 PDF、Chrome 浏览器。
- 处理规则:
- 首次安装需要解压 ZIP
- Chrome 加载的是解压后的 `dist` 文件夹;
- 更新时仍然需要人工下载、解压并重新加载;
- 扩展 ID 应为 `pkjopdibdnomhogjheclhnknmejccffg`
- 输出结果:Chrome 扩展列表中出现正确插件。
- 外部依赖:Chrome 扩展能力;COS 更新文件。
- 失败后如何处理:安装失败不会影响外部数据;使用者无法进入后续流程。
- 是否影响后续步骤:影响。未安装或安装版本不正确时,后续导出和提交不可用。
### 3.2 插件登录
- 步骤目的:获得访问公司后端和受保护接口所需的用户身份。
- 输入内容:公司登录账号、Logto 登录配置。
- 处理规则:
- 登录通过 Chrome identity 回调完成;
- 插件读取用户 `sub`、用户名、资源地址和 scope
- 内容脚本进入星图页面时会先读取登录状态。
- 输出结果:已登录状态、可用 access token。
- 外部依赖:Logto 登录系统。
- 失败后如何处理:星图页面显示登录提示;不挂载业务工具栏。
- 是否影响后续步骤:影响。批次提交和后端指标查询都依赖 token。
### 3.3 星图市场页面启动
- 步骤目的:只在正确页面启用插件能力。
- 输入内容:当前浏览器 URL、页面 DOM、星图页面列表请求。
- 处理规则:
- 只匹配巨量星图达人市场页面;
- 进入页面后先安装桥接逻辑,用于捕获星图市场列表请求和页面列表数据;
- 未登录时不进入业务控制流程;
- 已登录时挂载工具栏和新增列。
- 输出结果:插件工具栏、选择框、增强数据列。
- 外部依赖:星图网页结构和浏览器内容脚本能力。
- 失败后如何处理:如果页面结构变化导致无法挂载,相关功能不可用;未确认是否有统一错误上报。
- 是否影响后续步骤:影响。工具栏未挂载时无法导出或提交。
### 3.4 读取达人列表
- 步骤目的:确定当前页面或导出范围内有哪些达人。
- 输入内容:星图页面列表行、星图列表接口返回值、当前分页状态。
- 处理规则:
- 优先从星图市场列表接口返回中读取达人;
- 关键字段包括达人 ID、达人名称、星图内部传播接口 ID、核心用户 ID、地区、报价和页面可导出字段;
- 如果没有捕获到接口请求,则从页面 DOM 读取可见行;
- 页面翻页导出时等待页面稳定后再读取;
- 同一个达人重复出现时按达人 ID 合并。
- 输出结果:标准化后的达人记录集合。
- 外部依赖:星图市场列表接口和页面 DOM。
- 失败后如何处理:
- 单页加载超时会终止本次范围导出;
- 单条达人缺少 ID 或名称会被跳过;
- 捕获接口失败时退回页面翻页读取。
- 是否影响后续步骤:影响。没有达人记录时,导出或提交结果为空或失败。
### 3.5 页面指标补充
- 步骤目的:在列表页直接显示补充指标,方便筛选和排序。
- 输入内容:当前页面达人 ID。
- 处理规则:
- 看后搜率优先使用星图列表中已有值;
- 如果列表值不完整,再调用星图看后搜率相关接口;
- 秒思指标按当前页达人 ID 批量查询后端;
- 已成功或已判定缺失的后端指标在当前页面会话中不重复查询;
- 指标列支持页面内排序。
- 输出结果:页面增强列显示成功值、加载中、加载失败或暂无数据。
- 外部依赖:星图接口、公司后端指标接口。
- 失败后如何处理:单个达人指标失败只显示失败,不阻塞其他达人。
- 是否影响后续步骤:部分影响。导出时会复用已缓存指标,缺失时可能再次补充。
### 3.6 导出选中达人数据
- 步骤目的:把使用者选定的达人数据导出为 CSV。
- 输入内容:已勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。
- 处理规则:
- 必须先勾选达人;
- 先按导出范围收集达人;
- 再严格保留当前导出范围内已勾选的达人;
- 对每个达人补充画像、商业能力、传播指标、看后搜率、秒思指标;
- 字段选择只控制可选字段,基础字段、导出状态和失败原因等固定保留;
- 如果全部画像请求失败,则不下载 CSV 并提示画像导出失败;
- 单个达人部分接口失败时,CSV 保留该行,并写入导出状态和失败原因。
- 输出结果:CSV 文件。
- 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。
- 失败后如何处理:
- 没有勾选达人:提示并停止;
- 当前范围内无选中达人:提示并停止;
- 单个达人部分失败:记录为部分成功或失败;
- 全部画像失败:不下载 CSV。
- 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。
### 3.7 按星图 ID 导出
- 步骤目的:在不依赖当前星图列表勾选的情况下,批量导出指定 ID 的达人数据。
- 输入内容:使用者粘贴的星图 ID 文本。
- 处理规则:
- 支持用空格、换行、英文逗号、中文逗号、英文分号、中文分号分隔;
- 只接受 16 到 20 位纯数字;
- 重复 ID 会去重;
- 非法 token 会计入提示,但不会进入导出;
- 对有效 ID 逐个补齐基础信息、看后搜率、传播指标、画像、商业能力和后端秒思指标;
- 每个 ID 生成一行 CSV,并标记成功、部分成功或失败。
- 输出结果:按 ID 导出的 CSV 文件。
- 外部依赖:星图基础信息接口、星图指标接口、公司后端指标接口、浏览器下载能力。
- 失败后如何处理:
- 没有有效 ID:提示并停止;
- 单个 ID 的部分接口失败:保留该行并写失败原因;
- 整体流程异常:提示按 ID 导出失败。
- 是否影响后续步骤:不写入外部系统,不影响批次。
### 3.8 传播指标规则筛选
- 步骤目的:在导出或提交前按内容传播表现过滤达人。
- 输入内容:可选的完播率、互动率规则;每条已选规则包含非负阈值和独立的视频类别、是否只看指派、是否排除营销流量、时间范围。
- 处理规则:
- 默认不选择任何指标,不启用二次筛选;
- 选择指标后必须填写该指标阈值,并为该指标选择一套独立视频口径;
- 同时选择完播率和互动率时,两条规则必须全部满足;
- 两条规则口径相同时,同一达人复用一次接口响应;口径不同时分别请求;
- 每条个人视频规则固定为不限指派、不排除营销流量;
- 每条星图视频规则可独立选择只看指派、不限指派、排除营销流量或不排除营销流量;
- 完播率和互动率按显示百分数比较,例如 `30` 表示 `30%`
- 请求失败、缺少传播指标请求 ID 或缺少已选指标的达人视为不满足筛选。
- 输出结果:过滤后的达人集合。
- 外部依赖:星图传播指标接口。
- 失败后如何处理:
- 已选指标阈值为空或非法:阻止导出或提交,并提示具体指标;
- 单个达人筛选请求失败:跳过该达人;
- 全部不满足:导出时可能生成只有表头的 CSV;提交批次时会按空记录继续组装并提交,后端是否接受未确认。
- 是否影响后续步骤:影响。过滤后的结果才进入 CSV 或批次 payload。
### 3.9 批次提交
- 步骤目的:把达人集合提交给后续业务系统。
- 输入内容:导出范围内达人、已选达人、传播指标筛选结果、批次名称、登录用户信息。
- 处理规则:
- 点击后先输入批次名称;
- 取消输入则停止;
- 批次名为空则提示并停止;
- 如果存在已勾选达人,则优先提交当前范围内已勾选达人;
- 如果没有勾选达人,则提交当前范围内所有达人;
- 如果已勾选达人不在当前范围内,则回退为提交当前范围内所有达人;
- 前端不生成批次 ID,批次 ID 由后端生成;
- payload 包含登录用户 ID、创建人名称、资源地址、批次名称、创建时间和达人列表;
- 达人列表包含达人 ID、达人名称,存在核心用户 ID 时额外带上。
- 输出结果:后端批次记录;页面提示提交成功或失败。
- 外部依赖:Logto token、批次提交后端。
- 失败后如何处理:
- 未登录:提示请先登录插件;
- token 不可用:提交失败;
- 401 或 403:提交失败并返回未授权错误;
- 后端返回非成功:提交失败并显示错误;
- 网络失败:提交失败。
- 是否影响后续步骤:影响外部后端数据。是否会重复创建批次取决于后端,当前项目前端没有确认幂等机制。
### 3.10 CSV 下载
- 步骤目的:把导出结果交付给使用者。
- 输入内容:生成好的 CSV 字符串和文件名。
- 处理规则:
- 优先通过 Chrome 扩展后台下载;
- 如果扩展下载通道不可用,则使用页面中的临时下载链接;
- CSV 带 UTF-8 BOM,方便表格软件识别中文;
- 普通导出文件名使用插件名加时间戳;
- 按 ID 导出文件名包含按 ID 导出标识。
- 输出结果:浏览器下载列表中出现 CSV 文件。
- 外部依赖:Chrome downloads 能力或浏览器下载能力。
- 失败后如何处理:下载失败会提示;已生成数据不会写入外部系统。
- 是否影响后续步骤:不影响外部系统。
## 4. 接口和外部服务说明
| 接口/服务 | 用途 | 使用环节 | 核心入参 | 核心返回 | 分页 | 限流 | 并发控制 | 超时 | 重试机制 | 重试次数 | 会重试的情况 | 不会重试的情况 | 凭证/权限 | 额度/成本 | 失败处理 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 巨量星图网页 | 提供达人市场页面和当前筛选结果 | 页面启动、读取达人列表、人工筛选 | 星图网页登录态、页面筛选条件 | 达人列表页面 | 通过页面分页 | 未确认 | 页面翻页串行执行 | 页面翻页等待约 3 秒,页面稳定等待约 12 秒 | 无自动重试 | 0 | 无 | 页面结构异常、未登录、加载失败 | 星图账号和 cookie | 未确认 | 页面不匹配或结构异常时插件功能不可用 |
| `search_for_author_square` | 获取星图达人市场列表数据 | 后台分页导出、读取达人基础字段 | 当前星图列表请求参数、页码 | 达人列表、分页信息、基础字段 | 是 | 未确认 | 后台分页串行请求;`全部` 最多尝试 200 页 | 未单独设置 fetch 超时 | 无自动重试 | 0 | 无 | 请求失败、响应结构异常、解析失败 | 星图网页登录态和 cookie | 未确认 | 请求失败或解析失败时退回页面翻页读取 |
| `get_author_commerce_seed_base_info` | 优先获取看后搜率 | 页面增强、导出补充 | `o_author_id``range=90` | 商单视频/个人视频看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 有备用接口回退,但不是同接口重试 | 0 | 非超时失败且未成功时转备用接口 | 超时、备用接口也失败 | 星图网页登录态和 cookie | 未确认 | 请求失败时转备用接口;超时直接记为失败 |
| `get_author_ase_info` | 备用获取看后搜率 | 页面增强、导出补充 | `author_id``range=30` | 看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、缺少指标 | 星图网页登录态和 cookie | 未确认 | 失败后标记该达人看后搜率失败 |
| `author_audience_distribution` | 获取观众画像 | 导出选中达人、按 ID 导出 | `o_author_id``platform_source=1``platform_channel=1``link_type=5` | 性别、年龄、省份、城市、兴趣、人群等分布 | 否 | 未确认 | 单个达人内画像和商业能力并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因;全部画像失败时不下载选中导出 CSV |
| `get_author_fans_distribution` | 获取粉丝画像和铁粉画像 | 导出选中达人、按 ID 导出 | `o_author_id``platform_source=1``author_type=1 或 5` | 粉丝或铁粉分布 | 否 | 未确认 | 单个达人内多个画像请求串行执行;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因 |
| `get_author_base_info` | 按 ID 导出时获取达人基础信息 | 按星图 ID 导出 | `o_author_id``platform_source=1``platform_channel=1``recommend=true` 等 | 达人名称等基础信息 | 否 | 未确认 | 按 ID 导出中与看后搜率并发;不同 ID 串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单个 ID 基础信息失败,CSV 中记录失败 |
| `get_author_commerce_spread_info` | 获取商业能力和效果预估 | 导出选中达人、按 ID 导出 | `o_author_id` | 预期 CPM、预期 CPE、预期播放量、爆文率 | 否 | 未确认 | 与画像请求并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败或超时 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因,其他数据继续 |
| `get_author_spread_info` | 获取内容传播指标,并用于规则筛选 | 内容数据导出、传播指标筛选 | `o_author_id``platform_source=1``platform_channel=1``type``flow_type``only_assign``range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 指标补充对达人并发;单个达人内多组参数串行;筛选对达人并发,同一达人相同口径的规则复用一次响应 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时字段留空;筛选请求失败、缺少请求 ID 或缺少已选指标时该达人不满足筛选 |
| talent-search 后端 `POST /api/v1/history/talents/search` | 查询秒思 api 指标 | 页面增强、CSV 导出、按 ID 导出 | Bearer token、`type=star_id``values``page=1``size=max(20, ID数量)` | 看后搜率、看后搜数、新增 A3、CPA3、cp_search 等 | 请求固定第一页;接口本身是否支持更多页未确认 | 未确认 | 按页面或导出集合批量请求;同一批只有一个请求 | 未确认 | 无自动重试 | 0 | 无 | token 失败、请求失败、响应结构异常 | Logto access token,当前 resource 为 talent-search | 未确认 | 页面增强中失败标记后端指标失败;导出中失败则相关字段为空 |
| 批次提交后端 `POST /api/v1/batch-status/batches` | 创建达人批次 | 提交批次 | Bearer token、批次名称、创建人、达人列表 | 成功标志和后端数据 | 否 | 未确认 | 每次点击提交只发一个请求;按钮忙碌态防止流程内重复点击 | 未确认 | 无自动重试 | 0 | 无 | 401、403、非 2xx、后端 `success` 非 true、网络失败 | Logto access token;写权限 scope 是否足够未确认 | 未确认 | 401/403 或非成功响应会终止本次提交 |
| Logto | 插件登录和获取访问 token | 登录、后端接口调用 | appId、resource、scope、Chrome redirect URL | 登录态、ID claims、access token | 否 | 未确认 | 由 Logto SDK 管理,项目内未设并发规则 | 未确认 | 项目内无自动重试;SDK 内部是否重试未确认 | 未确认 | 未确认 | 登录失败、token 不可用、授权不足 | 公司 Logto 账号和 Chrome identity 回调权限 | 未确认 | 登录失败或 token 不可用时不进入业务流程,或后端调用失败 |
| COS 更新清单 | 检查插件新版本 | 插件弹窗 | `latest.json` URL | 最新版本、ZIP URL、说明 PDF URL、发布时间、更新说明 | 否 | 未确认 | 弹窗打开后单次检查 | 未确认 | 无自动重试 | 0 | 无 | 请求失败、清单格式错误、URL 非 HTTPS | 清单和安装包需公开可读 | COS 存储和流量成本未确认 | 检查失败时弹窗显示无法检查更新,不影响已安装插件主功能 |
| Chrome downloads | 下载 CSV、更新包和说明 PDF | CSV 导出、插件更新 | 文件名、下载 URL 或 data URL | 浏览器下载任务 | 否 | 浏览器自身规则,未确认 | 由浏览器管理 | 未确认 | 无自动重试 | 0 | 无 | 下载权限不可用、浏览器拦截、URL 无效 | Chrome `downloads` 权限 | 未确认 | CSV 下载失败时回退页面链接下载;更新包下载失败时显示错误 |
凭证和权限说明:
- 星图接口依赖当前浏览器中的星图网页登录态和 cookie;
- 公司后端指标查询和批次提交依赖 Logto access token
- 插件需要 Chrome `downloads``identity``storage` 权限;
- COS 更新包需要公开可读;
- 具体接口额度、调用成本、账号级限制均未确认。
## 5. 数据处理规则
### 5.1 数据来源
数据主要来自四类来源:
- 星图市场页面和列表接口:达人 ID、名称、地区、报价、粉丝、内容主题、预期播放、互动率、完播率等基础字段;
- 星图详情类接口:看后搜率、画像、商业能力、传播指标;
- 公司 talent-search 后端:秒思 api 指标;
- 使用者输入:勾选状态、星图 ID、批次名称、字段选择和传播指标筛选规则。
### 5.2 保留规则
- 有有效达人 ID 和达人名称的记录会进入页面记录集合;
- 按 ID 导出时,有效 ID 即使部分接口失败也会生成 CSV 行;
- CSV 基础字段、导出状态、失败原因等固定字段会保留;
- 字段选择只影响可选数据字段,不删除固定标识字段。
### 5.3 过滤规则
- 星图页面中缺少达人 ID 或达人名称的行会跳过;
- 导出选中达人数据必须有已勾选达人;
- 画像导出只保留当前导出范围内的已勾选达人;
- 普通导出或提交批次如果存在已选达人,会优先保留当前范围内的已选达人;
- 如果当前范围内没有任何已选达人,普通导出或提交批次会回退为当前范围全部达人;
- 传播指标规则启用后,不满足全部已选规则的达人会被过滤;
- 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。
### 5.4 去重规则
- 多页导出和后台分页导出按达人 ID 合并去重;
- 按 ID 导出对输入 ID 去重;
- 合并时优先保留已有的非空字段;
- 指标字段会在有新非空值时补充。
### 5.5 字段补充和合并规则
- 星图列表数据作为基础;
- 看后搜率优先使用列表中已有值,不完整时再请求星图指标接口;
- 秒思指标按 star_id 从后端补充;
- 传播指标按多组参数生成不同列,不合并同名业务指标;
- `代表视频`可能被读取,但不会进入最终普通市场 CSV;
- 画像和商业能力字段追加在基础字段之后;
- 传播指标字段追加在基础字段、看后搜率和秒思 api 字段之后。
### 5.6 覆盖规则
- 当前页面会话内的内存记录会被补充和合并;
- 项目不会把 CSV 导出结果写回星图;
- 批次提交会向后端创建或提交数据,是否覆盖已有批次未确认;
- 字段选择配置会保存到浏览器 localStorage,下次导出沿用。
### 5.7 写入位置
- CSV 写入浏览器下载目录;
- 批次数据写入批次提交后端;
- 字段选择写入浏览器 localStorage
- 登录状态由 Logto Chrome 扩展 SDK 管理;
- 项目本身不维护持久任务数据库。
### 5.8 写入失败处理
- CSV 下载失败会提示或使用备用下载方式;
- 批次提交失败会提示失败原因,本次提交不视为成功;
- 字段选择保存失败会被忽略,后续可能恢复默认全选字段;
- 后端指标补充失败不会阻止 CSV 生成,只会导致字段为空。
### 5.9 重复运行时数据变化
- 重复导出会重新生成新的 CSV 文件;
- 重复按 ID 导出不会写入外部业务系统;
- 重复提交批次可能在后端产生重复批次,是否由后端去重未确认;
- 页面内已缓存的成功指标可能在当前会话中复用,刷新页面后会重新读取。
## 6. 重复执行、中断恢复和幂等性
- 任务可以重复执行。
- CSV 导出重复执行会产生新的下载文件,不会覆盖星图或后端数据。
- 按 ID 导出重复执行会重新请求接口并生成新 CSV,不会写入后端批次。
- 字段选择重复保存会覆盖浏览器本地保存的字段选择。
- 批次提交重复执行是否会重复创建批次:未确认。当前前端没有批次幂等键,也不生成 batchId。
- 批次提交是否覆盖已有结果:未确认,取决于后端。
- 任务跑到一半失败后,当前项目没有持久任务状态记录。
- 导出中断后再次执行会从本次流程开头重新收集和请求,不会从上次中断点恢复。
- 页面会话中的指标缓存可以减少同一页面内重复请求,但不等同于断点续跑。
- 浏览器刷新、插件重载或页面关闭会丢失内存中的中间状态。
- 多页导出按达人 ID 去重,重复分页读取同一达人不会在 CSV 中重复出现。
- 按 ID 导出对输入 ID 去重,重复输入同一 ID 不会产生重复行。
- 传播指标规则没有持久状态,重新执行时按当前页面选择和输入值重新判断。
重复执行相对安全的操作:
- 重新打开页面;
- 重新导出 CSV
- 重新按 ID 导出;
- 重新检查更新;
- 重新保存字段选择。
重复执行可能有风险的操作:
- 重复点击提交批次;
- 修改后端地址后提交批次;
- 使用不同星图筛选条件或不同字段选择重复导出后,拿多个 CSV 混用;
- 指标选择、视频口径或阈值变化后重复提交,可能导致提交达人集合变化。
未确认项:
- 后端批次接口是否按批次名称、用户或达人集合去重;
- 后端批次接口是否允许空达人列表;
- 后端是否有任务状态、失败重试或重复提交保护;
- Logto token 刷新失败后是否有 SDK 内部重试。
## 7. 项目使用方式
### 7.1 使用前准备
使用前需要准备:
- Google Chrome 浏览器;
- 内部发布的 `star-chart-search-enhancer-internal.zip`
- 可访问巨量星图的账号;
- 可访问公司 Logto 登录系统的账号;
- 如果要提交批次,需要批次提交后端可访问;
- 如果要查询秒思 api 指标,需要 talent-search 后端授权可用。
需要的权限和凭证:
- Chrome 扩展加载权限;
- 星图网页登录态;
- Logto 登录态;
- talent-search 后端读取权限;
- 批次提交后端所需权限,具体 scope 是否只需当前配置未确认。
### 7.2 本地安装使用
1. 解压内部 ZIP
2. 打开 `chrome://extensions`
3. 开启开发者模式;
4. 点击加载已解压的扩展程序;
5. 选择解压后的 `dist` 文件夹;
6. 确认扩展 ID 为 `pkjopdibdnomhogjheclhnknmejccffg`
7. 固定插件图标;
8. 点击插件图标并登录;
9. 打开 `https://xingtu.cn/ad/creator/market`
10. 等待插件工具栏出现。
### 7.3 执行一次完整导出任务
1. 在星图市场中完成筛选;
2. 等待页面列表加载完成;
3. 勾选需要导出的达人;
4. 可选:点击 `选择字段` 调整 CSV 字段;
5. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径;
6. 点击 `导出选中达人数据`
7. 等待状态提示从导出中消失或浏览器下载完成;
8. 在下载目录或 Chrome 下载列表中查看 CSV
9. 检查 `导出状态``失败原因` 字段。
### 7.4 按 ID 执行导出任务
1. 点击 `按星图ID导出`
2. 粘贴达人星图 ID,每行一个或用分隔符隔开;
3. 点击确认;
4. 查看识别数量、去重后数量和非法数量提示;
5. 等待 CSV 下载;
6. 检查每行导出状态和失败原因。
### 7.5 执行一次批次提交
1. 在星图市场中完成筛选;
2. 可选:勾选需要提交的达人;
3. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径;
4. 点击 `提交批次`
5. 输入批次名称;
6. 等待页面提示 `批次提交成功`
7. 到后端系统确认批次是否生成。
### 7.6 只执行某个子流程
- 只登录:打开插件弹窗并登录;
- 只检查更新:登录后打开插件弹窗查看版本更新区域;
- 只选择字段:在星图市场页点击 `选择字段` 并保存;
- 只按 ID 导出:不需要勾选页面达人,直接点击 `按星图ID导出`
- 只提交批次:不需要先导出 CSV,但需要登录并输入批次名称。
### 7.7 重新执行任务
- 重新导出:直接再次点击导出按钮;
- 重新按 ID 导出:再次打开 ID 输入弹窗并确认;
- 重新提交批次:再次点击提交批次并输入批次名称。注意可能创建重复批次,后端幂等未确认;
- 页面异常后重试:刷新星图页面,等待工具栏重新出现后再执行。
### 7.8 确认任务完成
- 导出任务:浏览器下载列表出现 CSV 文件;
- 按 ID 导出:CSV 文件名包含按 ID 导出标识,且文件中有导出状态列;
- 批次提交:页面显示 `批次提交成功`,并在后端系统中能看到对应批次;
- 插件更新:重新加载后插件版本显示为新版本。
### 7.9 危险操作
- 重复点击 `提交批次`
- 修改后端地址后未验证就发给同事使用;
- 删除正在被 Chrome 加载的 `dist` 文件夹;
- 随意修改 Logto 配置、后端地址、scope 或 manifest key
- 在未确认星图筛选条件的情况下提交全部范围达人;
- 传播指标、视频口径或阈值选择错误,导致提交集合被大幅改变。
### 7.10 不能随便改的参数
- 固定扩展 ID 相关配置;
- Logto appId、endpoint、resource、scope
- 批次提交后端地址;
- talent-search 后端地址;
- COS 更新清单 URL
- 星图接口参数含义;
- 传播指标列名规则;
- 批次 payload 字段。
### 7.11 运行和发布方式
本地开发运行:
- 安装依赖:`npm install`
- 运行测试:`npm test`
- 开发构建:`npm run build`
- 然后在 Chrome 中加载 `dist`
内部发布构建:
- 运行测试;
- 执行内部打包;
- 生成 ZIP 和更新清单;
- 上传或分发给同事;
- 同事仍需人工解压和加载。
定时任务运行:
- 当前项目未确认存在服务端定时任务。
- 插件更新发布可通过 tag 触发 Drone 发布流程。
## 8. 运行参数和配置说明
| 配置名称 | 作用 | 默认值 | 可选值 | 修改影响 | 是否需要重启/重载 | 风险 |
|---|---|---|---|---|---|---|
| 扩展 ID / manifest key | 固定 Chrome 扩展身份 | `pkjopdibdnomhogjheclhnknmejccffg` | 未确认 | 影响 Logto 回调、用户安装识别、更新连续性 | 需要重新构建并重新加载插件 | 改错会导致登录失败或同事装到不同插件 |
| Logto endpoint | 登录服务地址 | `https://login-api.intelligrow.cn` | 未确认 | 影响登录和 token 获取 | 需要重新构建并重新加载插件 | 登录不可用 |
| Logto appId | 插件登录应用 ID | `i4jkllbvih0554r4n0fd3` | 未确认 | 影响登录应用和回调校验 | 需要重新构建并重新加载插件 | 登录不可用 |
| apiResource | token 资源地址 | `https://talent-search.intelligrow.cn` | 未确认 | 影响后端 token audience/resource | 需要重新构建并重新加载插件 | 后端接口 401/403 |
| scopes | 登录申请权限 | `openid``profile``offline_access``talent-search:read` | 未确认 | 影响 token 权限 | 需要重新登录;通常也需重新构建 | 后端读写权限不足 |
| enableDevAuthPanel | 是否显示开发调试面板 | `false` | `true` / `false` | 影响插件弹窗是否显示调试入口 | 需要重新构建并重新加载插件 | 暴露调试入口 |
| 批次提交后端地址 | 提交达人批次的目标服务 | 当前工作区为 `http://localhost:8083` | 其他后端地址未确认 | 影响批次提交去向 | 需要重新构建并重新加载插件 | 提交到错误环境、重复或丢失业务数据 |
| 后端指标服务地址 | 查询秒思 api 指标 | `https://talent-search.intelligrow.cn` | 未确认 | 影响页面增强列和 CSV 秒思字段 | 需要重新构建并重新加载插件 | 指标为空、权限错误或消耗错误环境额度 |
| COS 更新清单 URL | 插件弹窗检查新版本 | `https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json` | 其他 HTTPS URL | 影响更新提示和安装包下载 | 需要重新构建并重新加载插件 | 用户无法更新或下载错误包 |
| 导出范围 | 决定收集哪些页面达人 | 当前工具栏默认隐藏,默认值为前 5 页;当前用户主入口通常要求勾选达人 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 |
| 传播指标规则 | 导出或提交前二次过滤达人 | 默认不选择任何指标 | 完播率、互动率;每个已选指标填写非负阈值并选择独立视频口径 | 影响最终保留达人集合和星图接口请求口径 | 不需要重启 | 指标、口径或阈值选错会过滤掉目标达人 |
| 字段选择 | 控制 CSV 可选字段 | 默认全选 | 可选字段集合 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 |
## 9. 任务执行和结果确认
### 9.1 任务开始标志
- 插件登录任务:点击插件弹窗登录按钮后跳转登录;
- 页面增强任务:星图市场页面出现插件工具栏和新增列;
- 导出任务:状态区出现 `画像导出中``按ID画像导出中` 或类似导出中提示;
- 批次提交任务:点击提交并输入批次名称后,状态区出现提交中提示;
- 更新检查任务:插件弹窗显示正在检查更新。
### 9.2 执行中状态
- 工具栏按钮会被禁用,防止同一流程中重复点击;
- 多页收集时会显示页码进度;
- 画像和按 ID 导出会显示当前处理序号;
- 页面指标列可能显示 `加载中...`
- 后端指标可能显示暂无数据或加载失败。
### 9.3 成功完成标志
- CSV 导出:浏览器下载列表出现 CSV 文件;
- 按 ID 导出:下载完成,CSV 中每行有导出状态;
- 批次提交:页面提示 `批次提交成功`
- 更新下载:弹窗提示已触发下载;
- 页面增强:指标列显示具体数值或明确的暂无数据状态。
### 9.4 部分成功表现
- 单个达人部分接口失败时,CSV 中该行 `导出状态` 为部分成功或失败,并在 `失败原因` 中列明失败项;
- 秒思 api 指标失败时,对应字段为空或页面显示失败,不一定影响 CSV 下载;
- 传播指标某组参数失败时,对应字段为空;
- 画像部分失败时,其他画像或商业能力字段仍可保留;
- 后端指标查询不到某个达人时显示暂无数据。
### 9.5 失败表现
- 未登录:星图页面显示登录提示,不出现业务工具栏;
- 没勾选就导出选中达人数据:提示请先勾选;
- 当前范围无选中达人:提示当前导出范围内没有选中的达人;
- 全部画像失败:提示画像导出失败,不下载 CSV;
- 按 ID 没有有效 ID:提示请输入有效的达人星图 ID;
- 批次提交失败:状态区显示接口错误或通用失败提示;
- 已选传播指标未填写合法阈值:状态区提示具体指标,导出或提交不会开始;
- 更新清单失败:弹窗显示暂时无法检查更新或错误信息。
### 9.6 最终结果查看位置
- CSV:浏览器默认下载目录或 Chrome 下载列表;
- 批次:批次提交后端系统,具体查看入口未确认;
- 插件版本:插件弹窗或 Chrome 扩展详情页;
- 登录状态:插件弹窗;
- 页面增强结果:星图达人市场页面新增列。
### 9.7 管理者确认标准
管理者确认一次任务是否达到预期时,应关注:
- 使用者是否登录了正确插件;
- 星图筛选条件是否符合业务目标;
- 导出的 CSV 行数是否符合已选达人或输入 ID 数量;
- CSV 中 `导出状态` 是否大部分为成功;
- 关键业务字段是否有值,例如内容数据、效果预估、画像、秒思 api 数据;
- 批次提交是否在后端生成对应批次;
- 批次名称、创建人和达人数量是否符合预期;
- 是否存在重复提交批次。
## 10. 重要限制和风险
- 星图接口调用额度限制:未确认。
- 星图接口限流规则:未确认。
- 公司后端接口额度限制:未确认。
- 批次提交接口幂等规则:未确认。
- 项目没有持久任务状态记录,不支持真正断点续跑。
- 导出范围过大时,会产生大量星图接口请求,运行时间会变长。
- 当前传播指标补充和筛选存在并发请求;筛选会复用同一达人相同视频口径的响应,但是否有显式并发上限未确认,当前未看到稳定的业务级并发限制配置。
- 星图页面结构变化可能导致工具栏挂载、列表读取或翻页失效。
- 星图网页登录态过期会导致接口失败。
- Logto token 不可用会导致后端指标和批次提交失败。
- 批次提交重复执行可能产生重复批次。
- 修改后端地址可能把数据提交到错误环境。
- 字段选择保存到本地浏览器,换浏览器或清理数据后会恢复默认。
- 更新包仍需人工解压和重载,下载新版本不等于插件已更新。
- 删除或移动本地 `dist` 文件夹会导致已加载插件失效。
- 扩展 ID、Logto 回调和 manifest key 强相关,改错会导致登录失败。
- `http://localhost:8083` 作为批次提交默认地址时,只适合本机后端可用的场景;生产或同事环境是否适用未确认。
- 下载 CSV 不会自动校验业务完整性,需要使用者或管理者检查导出状态和关键字段。
- 传播指标、视频口径或阈值选错会改变导出或提交达人集合。
## 11. 未确认项清单
- 星图各接口是否存在明确限流:未确认。
- 星图各接口账号级、IP 级或 cookie 级调用额度:未确认。
- 星图各接口失败后是否由浏览器或服务端内部重试:未确认。
- 公司后端 `history/talents/search` 是否有分页上限、查询数量上限或限流:未确认。
- 公司后端 `history/talents/search` 的接口超时时间:未确认。
- 批次提交后端的生产地址:未确认;当前工作区配置为 `http://localhost:8083`
- 批次提交后端是否支持幂等:未确认。
- 批次提交后端是否允许空达人列表:未确认。
- 批次提交后端是否会覆盖同名批次:未确认。
- 批次提交后端生成的 7 位数字批次 ID 的具体规则:未确认。
- 批次提交后端的查看入口和管理流程:未确认。
- 当前 Logto scope 是否足够覆盖批次写操作:未确认。
- COS 更新清单的权限、缓存和发布审核规则:未确认。
- Drone 发布是否为唯一正式发布方式:未确认。
- 插件是否有统一日志、错误上报或审计记录:未确认。
- 页面增强指标是否有跨页面或跨浏览器持久缓存:未确认;当前仅确认有页面会话内记录。
- 导出全部页面时最多导出多少页:后台静默导出当前最多尝试 200 页;真实星图侧上限未确认。
- 传播指标请求是否应该限制并发:当前筛选已按同一达人相同口径去重,但真实业务级并发上限仍未确认。
- 后续业务系统如何消费批次:未确认。
## 12. 文档维护规则
从现在开始,任何模型或工程师在修改本项目代码时,都必须遵守以下规则:
1. 修改代码前,先检查本流程文档是否描述了相关流程;
2. 如果代码改动影响流程、接口、配置、数据处理规则、使用方式、任务执行方式、结果确认方式、限流、重试、超时、并发或幂等性,必须同步更新本文档;
3. 如果代码行为和文档描述发生冲突,必须以当前真实行为为准更新文档;
4. 如果改动较大,但判断不需要更新文档,必须说明原因;
5. 每次完成较大代码改动后,最终回复必须明确说明:
- 是否检查了流程文档;
- 是否更新了流程文档;
- 更新了哪些部分;
- 如果没有更新,为什么不需要更新。
以下情况都视为必须检查文档的较大改动:
- 改变整体流程顺序;
- 新增、删除或调整流程步骤;
- 新增、删除或替换外部接口;
- 修改接口参数、鉴权方式、分页方式、限流方式、重试方式、超时规则或并发规则;
- 修改数据过滤、去重、合并、覆盖、写入规则;
- 修改任务启动方式、运行参数或配置项;
- 修改任务成功、失败、部分成功的判断方式;
- 修改重复执行、中断恢复或幂等性行为;
- 修改最终结果的产出位置或格式;
- 修改会影响项目使用者操作方式的任何内容。
+4 -4
View File
@@ -1,11 +1,11 @@
{
"guideUrl": "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.0525.4/星图增强插件-超简单安装使用指南.pdf",
"latestVersion": "0.0525.4",
"minSupportedVersion": "0.0525.4",
"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.4/star-chart-search-enhancer-internal.zip"
"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 });
+1 -1
View File
@@ -59,7 +59,7 @@ 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://localhost:8083/*",
"https://*/*"
]
};
+9 -5
View File
@@ -4,16 +4,20 @@ import path from "node:path";
import { pipeline } from "node:stream/promises";
import yazl from "yazl";
export async function createReleaseArchive({ archivePath, sourceDir }) {
export async function createReleaseArchive({
archivePath,
rootDirName = "dist",
sourceDir
}) {
const zip = new yazl.ZipFile();
const output = createWriteStream(archivePath);
await addDirectory(zip, sourceDir, sourceDir);
await addDirectory(zip, sourceDir, sourceDir, rootDirName);
zip.end();
await pipeline(zip.outputStream, output);
}
async function addDirectory(zip, rootDir, currentDir) {
async function addDirectory(zip, rootDir, currentDir, rootDirName) {
const entries = await readdir(currentDir, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
@@ -22,12 +26,12 @@ async function addDirectory(zip, rootDir, currentDir) {
const relativePath = path.relative(rootDir, absolutePath);
if (entry.isDirectory()) {
await addDirectory(zip, rootDir, absolutePath);
await addDirectory(zip, rootDir, absolutePath, rootDirName);
continue;
}
if (entry.isFile()) {
zip.addFile(absolutePath, relativePath);
zip.addFile(absolutePath, path.join(rootDirName, relativePath));
}
}
}
+2 -1
View File
@@ -2,10 +2,11 @@ import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
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,
+4 -53
View File
@@ -11,10 +11,9 @@ import type {
AudienceProfileKind,
AudienceProfileResult,
BusinessAbilityDurationKind,
BusinessAbilityEstimateMetrics,
BusinessAbilityVideoKind,
BusinessAbilityVideoMetrics
BusinessAbilityEstimateMetrics
} from "./audience-profile-types";
import { buildSpreadInfoColumns } from "./spread-info";
type AudienceProfileCsvColumn = {
header: string;
@@ -60,29 +59,6 @@ const CROWD_LABELS = [
"小镇青年"
];
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<{
@@ -146,7 +122,7 @@ export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFi
label: "秒思api数据"
},
{
headers: buildBusinessVideoColumns().map((column) => column.header),
headers: buildSpreadInfoColumns(),
label: "内容数据"
},
{
@@ -184,19 +160,7 @@ function listAudienceProfileSelectableHeaders(): string[] {
}
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)
}))
)
];
return buildBusinessEstimateColumns();
}
function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
@@ -211,19 +175,6 @@ function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
];
}
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,
@@ -42,21 +42,6 @@ export interface AudienceProfileExportRow {
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"
@@ -74,7 +59,6 @@ export interface BusinessAbilitySuccess {
Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>
>;
status: "success";
videos: Partial<Record<BusinessAbilityVideoKind, BusinessAbilityVideoMetrics>>;
}
export interface BusinessAbilityFailure {
+4
View File
@@ -35,6 +35,10 @@ export function createBatchPayload(options: {
throw new Error("batch submit batch name is required");
}
if (options.records.length === 0) {
throw new Error("当前没有可提交的达人");
}
return {
authors: options.records.map((record) => ({
authorId: record.authorId,
+4 -95
View File
@@ -3,8 +3,7 @@ import type {
BusinessAbilityDurationKind,
BusinessAbilityEstimateMetrics,
BusinessAbilityResult,
BusinessAbilitySuccess,
BusinessAbilityVideoMetrics
BusinessAbilitySuccess
} from "./audience-profile-types";
interface FetchResponseLike {
@@ -23,11 +22,6 @@ interface BusinessAbilityClientOptions {
timeoutMs?: number;
}
const VIDEO_TYPES = {
personalVideo: 1,
xingtuVideo: 2
} as const;
export function createBusinessAbilityClient(
options: BusinessAbilityClientOptions = {}
) {
@@ -37,33 +31,20 @@ export function createBusinessAbilityClient(
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) {
if (!estimates.ok) {
return {
failureReason:
personalVideo.failureReason ??
xingtuVideo.failureReason ??
estimates.failureReason,
failureReason: estimates.failureReason,
status: "failed"
};
}
return {
estimates: mapBusinessAbilityEstimateResponse(estimates.payload),
status: "success",
videos: {
personalVideo: mapBusinessAbilityVideoResponse(personalVideo.payload),
xingtuVideo: mapBusinessAbilityVideoResponse(xingtuVideo.payload)
}
status: "success"
} satisfies BusinessAbilitySuccess;
}
};
@@ -101,22 +82,6 @@ export function createBusinessAbilityClient(
}
}
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
@@ -129,25 +94,6 @@ export function buildBusinessAbilityEstimateUrl(
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>> {
@@ -177,30 +123,6 @@ export function mapBusinessAbilityEstimateResponse(
};
}
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 "缺失";
@@ -238,19 +160,6 @@ function formatFixedDecimal(value: number | null, digits: number): string {
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;
+12 -1
View File
@@ -1,5 +1,6 @@
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
import { escapeCsvCell } from "../../shared/csv";
import { buildSpreadInfoColumns } from "./spread-info";
import type { MarketRecord } from "./types";
export type CsvColumn = {
@@ -74,6 +75,11 @@ const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
}
];
const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) => ({
header,
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
}));
export function listRateCsvHeaders(): string[] {
return RATE_COLUMNS.map((column) => column.header);
}
@@ -94,7 +100,12 @@ export function buildMarketCsv(records: MarketRecord[]): string {
export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] {
const baseColumns = buildBaseColumns(records);
return [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
return [
...baseColumns,
...RATE_COLUMNS,
...BACKEND_METRIC_COLUMNS,
...SPREAD_INFO_COLUMNS
];
}
export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
+10 -2
View File
@@ -76,6 +76,7 @@ type MarketDataRow = {
location?: string;
price21To60s?: string;
rates?: AfterSearchRates;
spreadAuthorId?: string;
};
export interface MarketRowDom {
@@ -88,6 +89,7 @@ export interface MarketRowDom {
personalCell: HTMLElement;
price21To60s?: string;
rates?: AfterSearchRates;
spreadAuthorId?: string;
row: HTMLElement;
selectionCheckbox: HTMLInputElement;
singleCell: HTMLElement;
@@ -679,6 +681,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
row: authorCell,
selectionCheckbox,
singleCell,
spreadAuthorId: fallbackMarketRow?.spreadAuthorId,
visibilityTargets: rowCells
} satisfies MarketRowDom
];
@@ -1351,7 +1354,8 @@ function readSerializedMarketRows(
? {
singleVideoAfterSearchRate
}
: undefined
: undefined,
spreadAuthorId: readString(record.spreadAuthorId) ?? undefined
};
})
.filter((row) => Boolean(row.authorId || row.authorName));
@@ -1673,7 +1677,11 @@ function mergeMarketDataRows(
baseRow.price21To60s,
preferredRow.price21To60s
),
rates: mergeRates(baseRow.rates, preferredRow.rates)
rates: mergeRates(baseRow.rates, preferredRow.rates),
spreadAuthorId: mergeNonEmptyString(
baseRow.spreadAuthorId,
preferredRow.spreadAuthorId
)
};
}
+163 -15
View File
@@ -31,8 +31,16 @@ import { createMarketApiClient } from "./api-client";
import { createExportRangeController } from "./export-range-controller";
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
import { createSilentExportController } from "./silent-export-controller";
import {
buildSpreadInfoConfigKey,
createSpreadInfoClient,
matchesSpreadMetricRule,
normalizeSpreadInfoConfig,
type MappedSpreadInfoResponse
} from "./spread-info";
import {
readToolbarExportTarget,
readToolbarSpreadFilter,
setToolbarBusyState,
setToolbarExportStatus
} from "./plugin-toolbar";
@@ -54,7 +62,10 @@ import type {
MarketExportTarget,
MarketRecord,
MarketRowSnapshot,
MarketSortState
MarketSortState,
SpreadInfoConfig,
SpreadMetricFilterRule,
SpreadThresholdFilter
} from "./types";
interface MutationObserverLike {
@@ -79,6 +90,11 @@ export interface CreateMarketControllerOptions {
target: AudienceProfileRequestTarget
) => Promise<AudienceProfileResult>;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
loadSpreadFilterMetrics?: (
spreadAuthorId: string,
config: SpreadInfoConfig
) => Promise<MappedSpreadInfoResponse>;
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
searchBackendMetrics?: (starIds: string[]) => Promise<
Array<BackendMetrics & { starId: string }>
>;
@@ -101,10 +117,16 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const audienceProfileClient = createAudienceProfileClient();
const authorBaseClient = createAuthorBaseClient();
const businessAbilityClient = createBusinessAbilityClient();
const spreadInfoClient = createSpreadInfoClient();
const sendRuntimeMessage = createRuntimeMessageSender();
const resultStore = options.resultStore ?? createMarketResultStore();
const loadAuthorMetrics =
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
const loadSpreadFilterMetrics =
options.loadSpreadFilterMetrics ??
spreadInfoClient.loadAuthorSpreadMetricSnapshot;
const loadSpreadMetrics =
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
const searchBackendMetrics =
options.searchBackendMetrics ??
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
@@ -206,13 +228,22 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return;
}
const spreadFilter = readToolbarSpreadFilter(toolbar);
if (spreadFilter.error) {
setToolbarExportStatus(toolbar, spreadFilter.error);
return;
}
setToolbarBusyState(toolbar, true);
try {
const records = filterRecordsBySelection(
await exportRecords(exportTarget.target, "导出中", {
showDetailedProgress: selectedAuthorIds.size === 0
})
await applySpreadThresholdFilter(
await exportRecords(exportTarget.target, "导出中", {
includeSpreadMetrics: true,
showDetailedProgress: selectedAuthorIds.size === 0
}),
spreadFilter.filter
)
);
options.onCsvReady?.(buildCsv(records));
setToolbarExportStatus(toolbar, "");
@@ -242,6 +273,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
try {
const selectedRecords = filterRecordsBySelectionStrict(
await exportRecords(exportTarget.target, "画像导出中", {
includeSpreadMetrics: true,
showDetailedProgress: false
})
);
@@ -369,6 +401,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return;
}
const spreadFilter = readToolbarSpreadFilter(toolbar);
if (spreadFilter.error) {
setToolbarExportStatus(toolbar, spreadFilter.error);
return;
}
const batchName = await promptBatchName();
if (batchName === null) {
@@ -384,12 +421,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
try {
const hasSelectedAuthors = selectedAuthorIds.size > 0;
const records = filterRecordsBySelection(
await exportRecords(
exportTarget.target,
hasSelectedAuthors ? "提交已选达人中" : "提交中",
{
showDetailedProgress: !hasSelectedAuthors
}
await applySpreadThresholdFilter(
await exportRecords(
exportTarget.target,
hasSelectedAuthors ? "提交已选达人中" : "提交中",
{
showDetailedProgress: !hasSelectedAuthors
}
),
spreadFilter.filter
)
);
const authState = await getAuthState();
@@ -707,6 +747,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
target: MarketExportTarget,
inProgressLabel = "导出中",
progressOptions: {
includeSpreadMetrics?: boolean;
showDetailedProgress?: boolean;
} = {}
): Promise<MarketRecord[]> {
@@ -716,7 +757,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
if (target.mode === "count" && target.pageCount <= 1) {
await prepareCurrentPageForExport();
return getVisibleOrderedRecords();
return hydrateExportRecords(getVisibleOrderedRecords(), {
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
});
}
const silentExportRecords = await silentExportController.exportRecords(target);
@@ -725,7 +768,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
silentExportRecords.map((record) => ({
...record,
status: record.status ?? "idle"
}))
})),
{
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
}
);
}
@@ -760,6 +806,66 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return selectedRecords.length > 0 ? selectedRecords : records;
}
async function applySpreadThresholdFilter(
records: MarketRecord[],
filter: SpreadThresholdFilter | undefined
): Promise<MarketRecord[]> {
if (!filter || filter.rules.length === 0) {
return records;
}
const normalizedRules = filter.rules.map((rule) => ({
...rule,
config: normalizeSpreadInfoConfig(rule.config)
}));
const configsByKey = new Map<string, SpreadInfoConfig>();
normalizedRules.forEach((rule) => {
configsByKey.set(buildSpreadInfoConfigKey(rule.config), rule.config);
});
const matchedAuthorIds = new Set<string>();
await Promise.all(
records.map(async (record) => {
const spreadAuthorId = record.spreadAuthorId;
if (!spreadAuthorId) {
return;
}
const snapshots = new Map<string, MappedSpreadInfoResponse>();
await Promise.all(
Array.from(configsByKey.entries()).map(async ([key, config]) => {
try {
snapshots.set(
key,
await loadSpreadFilterMetrics(spreadAuthorId, config)
);
} catch {
snapshots.set(key, {});
}
})
);
if (matchesAllSpreadMetricRules(normalizedRules, snapshots)) {
matchedAuthorIds.add(record.authorId);
}
})
);
return records.filter((record) => matchedAuthorIds.has(record.authorId));
}
function matchesAllSpreadMetricRules(
rules: SpreadMetricFilterRule[],
snapshots: Map<string, MappedSpreadInfoResponse>
): boolean {
return rules.every((rule) =>
matchesSpreadMetricRule(
snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {},
rule
)
);
}
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
if (selectedAuthorIds.size === 0) {
return [];
@@ -810,13 +916,17 @@ export function createMarketController(options: CreateMarketControllerOptions) {
loadAuthorBaseInfoSafe(authorId),
loadAuthorMetricsSafe(authorId)
]);
const spreadMetrics = baseRecord.spreadAuthorId
? await loadSpreadMetrics(baseRecord.spreadAuthorId)
: {};
const recordForRequests = {
...baseRecord,
authorName: baseRecord.authorName || authorId,
...(metricsResult.success ? { rates: metricsResult.rates } : {}),
...(backendMetrics
? { backendMetrics, backendMetricsStatus: "success" as const }
: {})
: {}),
...(Object.keys(spreadMetrics).length > 0 ? { spreadMetrics } : {})
};
const [profiles, businessAbility] = await Promise.all([
loadAudienceProfileSet(recordForRequests),
@@ -1000,7 +1110,12 @@ export function createMarketController(options: CreateMarketControllerOptions) {
await runSyncCycle();
}
async function hydrateExportRecords(records: MarketRecord[]): Promise<MarketRecord[]> {
async function hydrateExportRecords(
records: MarketRecord[],
options: {
includeSpreadMetrics?: boolean;
} = {}
): Promise<MarketRecord[]> {
for (const record of records) {
resultStore.upsertMarketRow(record);
const existingRecord = resultStore.getRecord(record.authorId);
@@ -1066,9 +1181,32 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
}
if (options.includeSpreadMetrics) {
await hydrateSpreadMetricsForRecords(records);
}
return records.map((record) => toMarketRecord(record));
}
async function hydrateSpreadMetricsForRecords(
records: MarketRecord[]
): Promise<void> {
await Promise.all(
records.map(async (record) => {
const storedRecord = resultStore.getRecord(record.authorId) ?? record;
const spreadAuthorId = storedRecord.spreadAuthorId ?? record.spreadAuthorId;
if (!spreadAuthorId || storedRecord.spreadMetrics) {
return;
}
const spreadMetrics = await loadSpreadMetrics(spreadAuthorId);
if (Object.keys(spreadMetrics).length > 0) {
resultStore.setSpreadMetricsSuccess(record.authorId, spreadMetrics);
}
})
);
}
async function harvestCurrentPageForExport(): Promise<void> {
let hydrationSnapshot = await collectCurrentPageSnapshotsUntilSettled();
if (
@@ -1148,6 +1286,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
existingRecord?.price21To60s,
rowSnapshot.price21To60s
);
const spreadAuthorId = mergeStringValue(
existingRecord?.spreadAuthorId,
rowSnapshot.spreadAuthorId
);
return {
...existingRecord,
...rowSnapshot,
@@ -1169,6 +1311,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
location,
price21To60s,
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
spreadAuthorId,
spreadMetrics: mergeFieldMap(
existingRecord?.spreadMetrics,
rowSnapshot.spreadMetrics
),
status: existingRecord?.status ?? "idle"
} satisfies MarketRecord;
}
@@ -1477,7 +1624,8 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
hasDirectRatesSource: rowDom.hasDirectRatesSource,
location: rowDom.location,
price21To60s: rowDom.price21To60s,
rates: rowDom.rates
rates: rowDom.rates,
spreadAuthorId: rowDom.spreadAuthorId
};
}
+3 -1
View File
@@ -68,7 +68,9 @@ export function mapMarketListRow(
? {
singleVideoAfterSearchRate
}
: undefined
: undefined,
spreadAuthorId:
readString(readMarketFieldValue(row, attributeDatas, "id")) ?? undefined
};
}
+2 -1
View File
@@ -206,7 +206,8 @@ function readSerializedMarketRows() {
authorName:
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
coreUserId: readString(attributeDatas.core_user_id) ?? undefined,
singleVideoAfterSearchRate
singleVideoAfterSearchRate,
spreadAuthorId: readString(attributeDatas.id) ?? undefined
};
})
.filter((row) => Boolean(row.authorId || row.authorName));
+562 -35
View File
@@ -1,6 +1,10 @@
import type {
MarketExportScope,
MarketExportTarget
MarketExportTarget,
SpreadFilterMetric,
SpreadInfoConfig,
SpreadMetricFilterRule,
SpreadThresholdFilter
} from "./types";
export interface PluginToolbarHandlers {
@@ -11,6 +15,26 @@ export interface PluginToolbarHandlers {
onSubmitBatch(): Promise<void> | void;
}
interface SpreadMetricRuleDom {
enabledInput: HTMLInputElement;
flowTypeSelect: HTMLSelectElement;
onlyAssignSelect: HTMLSelectElement;
rangeSelect: HTMLSelectElement;
root: HTMLElement;
thresholdInput: HTMLInputElement;
typeSelect: HTMLSelectElement;
}
type SpreadMetricRuleDomMap = Record<SpreadFilterMetric, SpreadMetricRuleDom>;
const SPREAD_FILTER_DEFINITIONS: ReadonlyArray<{
label: string;
metric: SpreadFilterMetric;
}> = [
{ label: "完播率", metric: "finishRate" },
{ label: "互动率", metric: "interactionRate" }
];
export interface PluginToolbarDom {
audienceProfileByIdExportButton: HTMLButtonElement;
audienceProfileExportButton: HTMLButtonElement;
@@ -20,6 +44,7 @@ export interface PluginToolbarDom {
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
exportStatusText: HTMLElement;
spreadMetricRules: SpreadMetricRuleDomMap;
root: HTMLElement;
}
@@ -46,7 +71,8 @@ export function ensurePluginToolbar(
if (
existingRoot.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
)
) &&
existingRoot.querySelector('[data-plugin-spread-metric="finishRate"]')
) {
ensureToolbarMounted(existingRoot, document);
return readToolbarDom(existingRoot);
@@ -61,7 +87,6 @@ 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页");
@@ -80,8 +105,8 @@ export function ensurePluginToolbar(
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.hidden = true;
exportButton.tabIndex = -1;
exportButton.textContent = "导出CSV";
exportButton.title = "按所选页数范围导出当前筛选结果";
const audienceProfileExportButton = document.createElement("button");
audienceProfileExportButton.type = "button";
@@ -114,17 +139,51 @@ export function ensurePluginToolbar(
exportStatusText.dataset.pluginExportStatus = "text";
applyStatusStyles(exportStatusText);
root.append(
const spreadMetricRules = createSpreadMetricRuleDoms(document);
const panel = document.createElement("div");
panel.dataset.pluginToolbarPanel = "root";
applyToolbarPanelStyles(panel);
const firstRow = document.createElement("div");
firstRow.dataset.pluginToolbarRow = "primary";
applyToolbarRowStyles(firstRow);
const secondRow = document.createElement("div");
secondRow.dataset.pluginToolbarRow = "thresholds";
applyToolbarRowStyles(secondRow);
const dataGroup = document.createElement("div");
dataGroup.dataset.pluginToolbarGroup = "data";
applyToolbarGroupStyles(dataGroup);
dataGroup.append(
exportButton,
exportRangeSelect,
exportCustomPagesInput,
exportButton,
audienceProfileExportButton,
audienceProfileByIdExportButton,
audienceProfileFieldButton,
batchSubmitButton,
exportStatusText
batchSubmitButton
);
const thresholdTitle = createToolbarGroupTitle(document, "传播指标筛选");
const metricSelector = createSpreadMetricSelector(document, spreadMetricRules);
const rulesGroup = document.createElement("div");
rulesGroup.dataset.pluginSpreadRules = "root";
applySpreadRulesGroupStyles(rulesGroup);
rulesGroup.append(
...SPREAD_FILTER_DEFINITIONS.map(
({ metric }) => spreadMetricRules[metric].root
)
);
const filterNote = createSpreadFilterNote(document);
firstRow.append(dataGroup, exportStatusText);
secondRow.append(thresholdTitle, metricSelector, rulesGroup, filterNote);
panel.append(firstRow, secondRow);
root.append(panel);
document.body.appendChild(root);
applyNativeControlStyles(document, {
audienceProfileExportButton,
@@ -133,7 +192,8 @@ export function ensurePluginToolbar(
batchSubmitButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect
exportRangeSelect,
spreadMetricRules
});
ensureToolbarMounted(root, document);
@@ -152,19 +212,6 @@ export function ensurePluginToolbar(
batchSubmitButton.addEventListener("click", () => {
void handlers.onSubmitBatch();
});
exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({
batchSubmitButton,
audienceProfileFieldButton,
audienceProfileByIdExportButton,
audienceProfileExportButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
root
});
});
const toolbarDom = {
audienceProfileExportButton,
@@ -175,9 +222,27 @@ export function ensurePluginToolbar(
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
spreadMetricRules,
root
} satisfies PluginToolbarDom;
exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility(toolbarDom);
});
SPREAD_FILTER_DEFINITIONS.forEach(({ metric }) => {
const rule = spreadMetricRules[metric];
rule.enabledInput.addEventListener("change", () => {
syncSpreadMetricRuleState(rule);
syncSpreadFilterNote(toolbarDom);
});
rule.typeSelect.addEventListener("change", () => {
syncSpreadMetricVideoConstraints(rule);
});
});
syncCustomPagesInputVisibility(toolbarDom);
syncAllSpreadMetricRules(toolbarDom);
return toolbarDom;
}
@@ -193,6 +258,163 @@ function appendOption(
select.appendChild(option);
}
function createSpreadMetricRuleDoms(
document: Document
): SpreadMetricRuleDomMap {
return Object.fromEntries(
SPREAD_FILTER_DEFINITIONS.map(({ label, metric }) => [
metric,
createSpreadMetricRuleDom(document, metric, label)
])
) as unknown as SpreadMetricRuleDomMap;
}
function createSpreadMetricRuleDom(
document: Document,
metric: SpreadFilterMetric,
label: string
): SpreadMetricRuleDom {
const enabledInput = document.createElement("input");
enabledInput.type = "checkbox";
enabledInput.dataset.pluginSpreadMetric = metric;
enabledInput.setAttribute("aria-label", `启用${label}筛选`);
const thresholdInput = document.createElement("input");
thresholdInput.type = "number";
thresholdInput.min = "0";
thresholdInput.step = "0.1";
thresholdInput.dataset.pluginSpreadThreshold = metric;
thresholdInput.setAttribute("aria-label", `${label}筛选阈值`);
const typeSelect = document.createElement("select");
typeSelect.dataset.pluginSpreadFilter = "type";
appendOption(typeSelect, "1", "个人视频");
appendOption(typeSelect, "2", "星图视频");
typeSelect.value = "1";
const onlyAssignSelect = document.createElement("select");
onlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign";
appendOption(onlyAssignSelect, "false", "不限指派");
appendOption(onlyAssignSelect, "true", "只看指派");
onlyAssignSelect.value = "false";
const flowTypeSelect = document.createElement("select");
flowTypeSelect.dataset.pluginSpreadFilter = "flowType";
appendOption(flowTypeSelect, "0", "不排除营销");
appendOption(flowTypeSelect, "1", "排除营销");
flowTypeSelect.value = "0";
const rangeSelect = document.createElement("select");
rangeSelect.dataset.pluginSpreadFilter = "range";
appendOption(rangeSelect, "2", "近30天");
appendOption(rangeSelect, "3", "近90天");
rangeSelect.value = "2";
const root = document.createElement("div");
root.dataset.pluginSpreadRule = metric;
root.hidden = true;
applySpreadMetricRuleStyles(root);
const metricLabel = document.createElement("strong");
metricLabel.textContent = label;
applySpreadMetricRuleLabelStyles(metricLabel);
const thresholdControl = document.createElement("label");
thresholdControl.dataset.pluginSpreadThresholdControl = metric;
applySpreadThresholdControlStyles(thresholdControl);
const operator = document.createElement("b");
operator.dataset.pluginSpreadThresholdOperator = "gte";
operator.textContent = "≥";
const unitText = document.createElement("span");
unitText.dataset.pluginSpreadThresholdUnit = metric;
unitText.textContent = "%";
thresholdControl.append(operator, thresholdInput, unitText);
root.append(
metricLabel,
thresholdControl,
typeSelect,
onlyAssignSelect,
flowTypeSelect,
rangeSelect
);
return {
enabledInput,
flowTypeSelect,
onlyAssignSelect,
rangeSelect,
root,
thresholdInput,
typeSelect
};
}
function createSpreadMetricSelector(
document: Document,
rules: SpreadMetricRuleDomMap
): HTMLElement {
const selector = document.createElement("div");
selector.dataset.pluginSpreadMetrics = "root";
applySpreadMetricSelectorStyles(selector);
SPREAD_FILTER_DEFINITIONS.forEach(({ label, metric }) => {
const option = document.createElement("label");
applySpreadMetricOptionStyles(option);
const labelText = document.createElement("span");
labelText.textContent = label;
option.append(rules[metric].enabledInput, labelText);
selector.appendChild(option);
});
return selector;
}
function createSpreadFilterNote(document: Document): HTMLElement {
const note = document.createElement("span");
note.dataset.pluginSpreadFilterNote = "and";
note.textContent = "全部规则都达标才保留达人";
note.hidden = true;
applySpreadFilterNoteStyles(note);
return note;
}
function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap {
return Object.fromEntries(
SPREAD_FILTER_DEFINITIONS.map(({ metric }) => {
const ruleRoot = root.querySelector(
`[data-plugin-spread-rule="${metric}"]`
) as HTMLElement;
return [
metric,
{
enabledInput: root.querySelector(
`[data-plugin-spread-metric="${metric}"]`
) as HTMLInputElement,
flowTypeSelect: ruleRoot.querySelector(
'[data-plugin-spread-filter="flowType"]'
) as HTMLSelectElement,
onlyAssignSelect: ruleRoot.querySelector(
'[data-plugin-spread-filter="onlyAssign"]'
) as HTMLSelectElement,
rangeSelect: ruleRoot.querySelector(
'[data-plugin-spread-filter="range"]'
) as HTMLSelectElement,
root: ruleRoot,
thresholdInput: ruleRoot.querySelector(
`[data-plugin-spread-threshold="${metric}"]`
) as HTMLInputElement,
typeSelect: ruleRoot.querySelector(
'[data-plugin-spread-filter="type"]'
) as HTMLSelectElement
} satisfies SpreadMetricRuleDom
];
})
) as unknown as SpreadMetricRuleDomMap;
}
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
const toolbarDom = {
audienceProfileByIdExportButton: root.querySelector(
@@ -219,9 +441,11 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
exportStatusText: root.querySelector(
'[data-plugin-export-status="text"]'
) as HTMLElement,
spreadMetricRules: readSpreadMetricRuleDoms(root),
root
} satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom);
syncAllSpreadMetricRules(toolbarDom);
return toolbarDom;
}
@@ -280,6 +504,41 @@ export function readToolbarExportTarget(
};
}
export function readToolbarSpreadFilter(
toolbar: PluginToolbarDom
): { error?: string; filter?: SpreadThresholdFilter } {
const rules: SpreadMetricFilterRule[] = [];
for (const { label, metric } of SPREAD_FILTER_DEFINITIONS) {
const ruleDom = toolbar.spreadMetricRules[metric];
clearSpreadMetricRuleValidation(ruleDom);
if (!ruleDom.enabledInput.checked) {
continue;
}
const trimmedValue = ruleDom.thresholdInput.value.trim();
const threshold = Number(trimmedValue);
if (!trimmedValue || !Number.isFinite(threshold) || threshold < 0) {
markSpreadMetricRuleInvalid(ruleDom);
return {
error: `请输入有效的${label}筛选阈值`
};
}
rules.push({
config: readSpreadMetricConfig(ruleDom),
metric,
threshold
});
}
return {
filter: {
rules
}
};
}
export function setToolbarBusyState(
toolbar: PluginToolbarDom,
isBusy: boolean
@@ -291,10 +550,21 @@ export function setToolbarBusyState(
toolbar.audienceProfileExportButton,
toolbar.exportButton,
toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput
toolbar.exportCustomPagesInput,
...Object.values(toolbar.spreadMetricRules).flatMap((rule) => [
rule.enabledInput,
rule.thresholdInput,
rule.typeSelect,
rule.onlyAssignSelect,
rule.flowTypeSelect,
rule.rangeSelect
])
].forEach((element) => {
element.disabled = isBusy;
});
if (!isBusy) {
syncAllSpreadMetricRules(toolbar);
}
}
export function setToolbarExportStatus(
@@ -305,8 +575,72 @@ export function setToolbarExportStatus(
}
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportRangeSelect.hidden = true;
toolbar.exportCustomPagesInput.hidden = true;
toolbar.exportRangeSelect.hidden = false;
toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom";
}
function syncAllSpreadMetricRules(toolbar: PluginToolbarDom): void {
Object.values(toolbar.spreadMetricRules).forEach(syncSpreadMetricRuleState);
syncSpreadFilterNote(toolbar);
}
function syncSpreadMetricRuleState(rule: SpreadMetricRuleDom): void {
rule.root.hidden = !rule.enabledInput.checked;
if (!rule.enabledInput.checked) {
rule.thresholdInput.value = "";
rule.typeSelect.value = "1";
rule.onlyAssignSelect.value = "false";
rule.flowTypeSelect.value = "0";
rule.rangeSelect.value = "2";
clearSpreadMetricRuleValidation(rule);
}
syncSpreadMetricVideoConstraints(rule);
}
function syncSpreadMetricVideoConstraints(rule: SpreadMetricRuleDom): void {
const isPersonalVideo = rule.typeSelect.value !== "2";
if (isPersonalVideo) {
rule.onlyAssignSelect.value = "false";
rule.flowTypeSelect.value = "0";
}
rule.onlyAssignSelect.disabled = isPersonalVideo;
rule.flowTypeSelect.disabled = isPersonalVideo;
}
function syncSpreadFilterNote(toolbar: PluginToolbarDom): void {
const note = toolbar.root.querySelector(
'[data-plugin-spread-filter-note="and"]'
) as HTMLElement | null;
if (!note) {
return;
}
const enabledCount = Object.values(toolbar.spreadMetricRules).filter(
(rule) => rule.enabledInput.checked
).length;
note.hidden = enabledCount < 2;
}
function readSpreadMetricConfig(rule: SpreadMetricRuleDom): SpreadInfoConfig {
const type = rule.typeSelect.value === "2" ? 2 : 1;
return {
flowType:
type === 1 ? 0 : rule.flowTypeSelect.value === "1" ? 1 : 0,
onlyAssign:
type === 1 ? false : rule.onlyAssignSelect.value === "true",
range: rule.rangeSelect.value === "3" ? 3 : 2,
type
};
}
function clearSpreadMetricRuleValidation(rule: SpreadMetricRuleDom): void {
delete rule.root.dataset.pluginSpreadRuleInvalid;
rule.thresholdInput.removeAttribute("aria-invalid");
}
function markSpreadMetricRuleInvalid(rule: SpreadMetricRuleDom): void {
rule.root.dataset.pluginSpreadRuleInvalid = "true";
rule.thresholdInput.setAttribute("aria-invalid", "true");
}
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
@@ -467,8 +801,8 @@ function findNativeActionButton(
}
const candidates = Array.from(root.querySelectorAll("button")).filter(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement
(element): element is HTMLButtonElement =>
element instanceof document.defaultView!.HTMLButtonElement
);
return (
candidates.find((element) => normalizeText(element.textContent) === text) ?? null
@@ -478,8 +812,116 @@ function findNativeActionButton(
function applyToolbarRootStyles(root: HTMLElement): void {
root.style.display = "inline-flex";
root.style.alignItems = "center";
root.style.columnGap = "8px";
root.style.flexWrap = "wrap";
root.style.columnGap = "10px";
root.style.flex = "1 1 auto";
root.style.minWidth = "0";
root.style.flexWrap = "nowrap";
}
function applyToolbarPanelStyles(panel: HTMLElement): void {
panel.style.display = "flex";
panel.style.flexDirection = "column";
panel.style.alignItems = "center";
panel.style.gap = "6px";
panel.style.flex = "1 1 auto";
panel.style.minWidth = "0";
panel.style.padding = "0";
panel.style.overflowX = "hidden";
panel.style.overflowY = "hidden";
}
function applyToolbarRowStyles(row: HTMLElement): void {
row.style.display = "flex";
row.style.alignItems = "center";
row.style.justifyContent = "flex-start";
row.style.gap = "6px";
row.style.minHeight = "32px";
row.style.minWidth = "0";
row.style.width = "100%";
row.style.flexWrap = "nowrap";
}
function applyToolbarGroupStyles(group: HTMLElement): void {
group.style.display = "flex";
group.style.alignItems = "center";
group.style.gap = "8px";
group.style.minWidth = "0";
group.style.flex = "0 0 auto";
group.style.flexWrap = "nowrap";
}
function applySpreadMetricSelectorStyles(selector: HTMLElement): void {
selector.style.display = "flex";
selector.style.alignItems = "center";
selector.style.gap = "12px";
selector.style.flex = "0 0 auto";
selector.style.whiteSpace = "nowrap";
}
function applySpreadMetricOptionStyles(option: HTMLElement): void {
option.style.display = "inline-flex";
option.style.alignItems = "center";
option.style.gap = "5px";
option.style.height = "32px";
option.style.color = "#344054";
option.style.fontSize = "12px";
option.style.fontWeight = "700";
option.style.whiteSpace = "nowrap";
}
function applySpreadRulesGroupStyles(group: HTMLElement): void {
group.style.display = "flex";
group.style.flexDirection = "column";
group.style.alignItems = "stretch";
group.style.gap = "6px";
group.style.minWidth = "0";
group.style.flex = "1 1 auto";
group.style.overflowX = "auto";
group.style.overflowY = "hidden";
}
function applySpreadMetricRuleStyles(rule: HTMLElement): void {
rule.style.display = "flex";
rule.style.alignItems = "center";
rule.style.gap = "7px";
rule.style.minWidth = "max-content";
rule.style.minHeight = "32px";
rule.style.whiteSpace = "nowrap";
}
function applySpreadMetricRuleLabelStyles(label: HTMLElement): void {
label.style.width = "48px";
label.style.color = "#344054";
label.style.fontSize = "12px";
label.style.fontWeight = "800";
label.style.flex = "0 0 auto";
}
function applySpreadFilterNoteStyles(note: HTMLElement): void {
note.style.color = "#0f8a5f";
note.style.fontSize = "12px";
note.style.fontWeight = "800";
note.style.whiteSpace = "nowrap";
note.style.flex = "0 0 auto";
}
function createToolbarGroupTitle(document: Document, label: string): HTMLElement {
const title = document.createElement("span");
title.dataset.pluginToolbarTitle = label;
title.textContent = label;
title.style.display = "flex";
title.style.alignItems = "center";
title.style.height = "32px";
title.style.padding = "0 10px";
title.style.border = "1px solid #cfe0ff";
title.style.borderRadius = "8px";
title.style.background = "#eef5ff";
title.style.color = "#2563eb";
title.style.fontSize = "12px";
title.style.fontWeight = "900";
title.style.flex = "0 0 auto";
title.style.whiteSpace = "nowrap";
return title;
}
function applyNativeControlStyles(
@@ -492,6 +934,7 @@ function applyNativeControlStyles(
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
spreadMetricRules: SpreadMetricRuleDomMap;
}
): void {
const primaryButton =
@@ -503,6 +946,7 @@ 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;
@@ -510,6 +954,7 @@ function applyNativeControlStyles(
}
[
controls.exportButton,
controls.audienceProfileExportButton,
controls.audienceProfileByIdExportButton,
controls.audienceProfileFieldButton,
@@ -519,18 +964,62 @@ function applyNativeControlStyles(
button.style.whiteSpace = "nowrap";
});
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
const ruleControls = Object.values(controls.spreadMetricRules).flatMap(
(rule) => [
rule.enabledInput,
rule.thresholdInput,
rule.typeSelect,
rule.onlyAssignSelect,
rule.flowTypeSelect,
rule.rangeSelect
]
);
const nativeControls = [
controls.exportCustomPagesInput,
controls.exportRangeSelect,
...ruleControls
];
nativeControls.forEach((element) => {
if (element instanceof document.defaultView!.HTMLInputElement && element.type === "checkbox") {
element.style.width = "16px";
element.style.height = "16px";
element.style.padding = "0";
element.style.accentColor = "#7f1d2d";
element.style.flex = "0 0 auto";
return;
}
element.style.height = "32px";
element.style.border = "1px solid #d0d7de";
element.style.borderRadius = "6px";
element.style.padding = "0 10px";
element.style.padding = "0 8px";
element.style.background = "#fff";
element.style.color = "#1f2329";
element.style.boxSizing = "border-box";
element.style.flex = "0 0 auto";
});
controls.exportRangeSelect.style.minWidth = "104px";
controls.exportCustomPagesInput.style.width = "72px";
ruleControls.forEach((element) => {
if (element instanceof document.defaultView!.HTMLSelectElement) {
element.style.minWidth = "84px";
}
if (
element instanceof document.defaultView!.HTMLInputElement &&
element.dataset.pluginSpreadThreshold
) {
element.style.width = "58px";
element.style.minWidth = "0";
element.style.height = "26px";
element.style.border = "0";
element.style.borderRadius = "0";
element.style.padding = "0";
element.style.outline = "0";
element.style.fontWeight = "700";
}
});
}
function applyPrimaryButtonStyles(
@@ -548,11 +1037,27 @@ function applyPrimaryButtonStyles(
"background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease";
}
function applySpreadThresholdControlStyles(control: HTMLElement): void {
control.style.display = "grid";
control.style.gridTemplateColumns = "auto auto 1fr auto";
control.style.alignItems = "center";
control.style.gap = "6px";
control.style.height = "32px";
control.style.padding = "0 8px";
control.style.border = "1px solid #dbe2ec";
control.style.borderRadius = "8px";
control.style.background = "#ffffff";
control.style.flex = "0 0 auto";
}
function applyStatusStyles(statusText: HTMLElement): void {
statusText.style.color = "#64748b";
statusText.style.fontSize = "12px";
statusText.style.lineHeight = "20px";
statusText.style.marginLeft = "4px";
statusText.style.marginLeft = "0";
statusText.style.flex = "1 1 auto";
statusText.style.minWidth = "120px";
statusText.style.textAlign = "center";
statusText.style.whiteSpace = "nowrap";
}
@@ -564,6 +1069,7 @@ 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),
@@ -572,6 +1078,7 @@ function ensurePluginActionButtonTheme(document: Document): void {
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),
@@ -581,6 +1088,7 @@ function ensurePluginActionButtonTheme(document: Document): void {
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,
@@ -589,6 +1097,7 @@ function ensurePluginActionButtonTheme(document: Document): void {
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,
@@ -601,6 +1110,24 @@ function ensurePluginActionButtonTheme(document: Document): void {
transform: none !important;
box-shadow: none !important;
}
[data-plugin-spread-threshold-control] span,
[data-plugin-spread-threshold-control] b {
color: #667085 !important;
font-size: 12px !important;
font-weight: 700 !important;
white-space: nowrap !important;
}
[data-plugin-spread-threshold-control] b {
color: #0f8a5f !important;
font-weight: 900 !important;
}
[data-plugin-spread-rule-invalid="true"] [data-plugin-spread-threshold-control] {
border-color: #dc2626 !important;
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12) !important;
}
`;
document.head.appendChild(style);
}
@@ -619,8 +1146,8 @@ function findButtonContainingText(
}
const candidates = Array.from(root.querySelectorAll("button")).filter(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement
(element): element is HTMLButtonElement =>
element instanceof document.defaultView!.HTMLButtonElement
);
return candidates.find((element) => normalizeText(element.textContent).includes(text)) ?? null;
+17 -1
View File
@@ -2,7 +2,8 @@ import type {
BackendMetrics,
MarketApiFailureReason,
MarketRecord,
MarketRowSnapshot
MarketRowSnapshot,
SpreadInfoMetrics
} from "./types";
import type { AfterSearchRates } from "./types";
@@ -46,6 +47,13 @@ export function createMarketResultStore() {
...backendMetrics
};
},
setSpreadMetricsSuccess(authorId: string, spreadMetrics: SpreadInfoMetrics) {
const existingRecord = ensureRecord(authorId);
existingRecord.spreadMetrics = {
...existingRecord.spreadMetrics,
...spreadMetrics
};
},
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
const existingRecord = ensureRecord(authorId);
existingRecord.status = "success";
@@ -73,6 +81,10 @@ export function createMarketResultStore() {
existingRecord.price21To60s,
row.price21To60s
);
existingRecord.spreadAuthorId = mergeStringValue(
existingRecord.spreadAuthorId,
row.spreadAuthorId
);
existingRecord.exportFields = mergeFieldMap(
existingRecord.exportFields,
row.exportFields
@@ -81,6 +93,10 @@ export function createMarketResultStore() {
existingRecord.backendMetrics,
row.backendMetrics
);
existingRecord.spreadMetrics = mergeFieldMap(
existingRecord.spreadMetrics,
row.spreadMetrics
);
existingRecord.hasDirectRatesSource =
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
+357
View File
@@ -0,0 +1,357 @@
import type {
SpreadInfoConfig,
SpreadInfoMetrics,
SpreadMetricFilterRule
} from "./types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface SpreadInfoClientOptions {
baseUrl?: string;
configs?: SpreadInfoConfig[];
fetchImpl?: FetchLike;
timeoutMs?: number;
}
interface SpreadInfoMetricDefinition {
key: keyof MappedSpreadInfoResponse;
label: string;
}
export interface MappedSpreadInfoResponse {
averageCommentCount?: string;
averageDuration?: string;
averageLikeCount?: string;
averageShareCount?: string;
finishRate?: string;
interactionRate?: string;
playMedian?: string;
}
const SPREAD_INFO_METRICS: SpreadInfoMetricDefinition[] = [
{
key: "finishRate",
label: "完播率"
},
{
key: "playMedian",
label: "播放量中位数"
},
{
key: "interactionRate",
label: "互动率"
},
{
key: "averageDuration",
label: "作品平均时长"
},
{
key: "averageCommentCount",
label: "作品平均评论数"
},
{
key: "averageLikeCount",
label: "作品平均点赞数"
},
{
key: "averageShareCount",
label: "作品平均转发数"
}
];
export const DEFAULT_SPREAD_INFO_CONFIGS: SpreadInfoConfig[] = [
{
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
},
{
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
},
...buildXingtuVideoConfigs()
];
export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
const baseUrl = options.baseUrl ?? resolveBaseUrl();
const configs = options.configs ?? DEFAULT_SPREAD_INFO_CONFIGS;
const fetchImpl = options.fetchImpl ?? defaultFetch;
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
const metrics: SpreadInfoMetrics = {};
for (const config of configs) {
const mappedResponse = await loadSpreadInfoFromUrl(
buildSpreadInfoUrl(authorId, config, baseUrl)
);
Object.entries(buildSpreadInfoMetricMap(config, mappedResponse)).forEach(
([header, value]) => {
metrics[header] = value;
}
);
}
return metrics;
},
async loadAuthorSpreadMetricSnapshot(
authorId: string,
config: SpreadInfoConfig
): Promise<MappedSpreadInfoResponse> {
return loadSpreadInfoFromUrl(buildSpreadInfoUrl(authorId, config, baseUrl));
}
};
async function loadSpreadInfoFromUrl(
url: string
): Promise<MappedSpreadInfoResponse> {
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 {};
}
return mapSpreadInfoResponse(await response.json());
} catch {
return {};
} finally {
clearTimeout(timeoutId);
}
}
}
export function buildSpreadInfoUrl(
authorId: string,
config: SpreadInfoConfig,
baseUrl: string
): 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(config.type));
url.searchParams.set("flow_type", String(config.flowType));
url.searchParams.set("only_assign", String(config.onlyAssign));
url.searchParams.set("range", String(config.range));
return url.toString();
}
export function buildSpreadInfoColumns(
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
): string[] {
return configs.flatMap((config) =>
SPREAD_INFO_METRICS.map((metric) => buildSpreadInfoColumnHeader(config, metric))
);
}
export function buildSpreadInfoMetricMap(
config: SpreadInfoConfig,
metrics: MappedSpreadInfoResponse
): SpreadInfoMetrics {
const values: SpreadInfoMetrics = {};
SPREAD_INFO_METRICS.forEach((metric) => {
const value = metrics[metric.key];
if (hasTextValue(value)) {
values[buildSpreadInfoColumnHeader(config, metric)] = value;
}
});
return values;
}
export function mapSpreadInfoResponse(
payload: unknown
): MappedSpreadInfoResponse {
const data = getPayloadData(payload);
if (!data) {
return {};
}
return {
averageCommentCount: readStringLike(data.comment_avg),
averageDuration: formatMillisecondsAsSeconds(readNumberLike(data.avg_duration)),
averageLikeCount: readStringLike(data.like_avg),
averageShareCount: readStringLike(data.share_avg),
finishRate: formatBasisPointPercent(
readNumberLike(readNestedValue(data.play_over_rate, "value"))
),
interactionRate: formatBasisPointPercent(
readNumberLike(readNestedValue(data.interact_rate, "value"))
),
playMedian:
readStringLike(data.play_mid) ??
readStringLike(readNestedValue(readNestedValue(data.item_rate, "play_mid"), "value"))
};
}
export function normalizeSpreadInfoConfig(
config: SpreadInfoConfig
): SpreadInfoConfig {
return config.type === 1
? { ...config, flowType: 0, onlyAssign: false }
: { ...config };
}
export function buildSpreadInfoConfigKey(config: SpreadInfoConfig): string {
const normalized = normalizeSpreadInfoConfig(config);
return [
normalized.type,
normalized.onlyAssign ? 1 : 0,
normalized.flowType,
normalized.range
].join(":");
}
export function matchesSpreadMetricRule(
metrics: MappedSpreadInfoResponse,
rule: SpreadMetricFilterRule
): boolean {
const numericValue = readDisplayNumber(metrics[rule.metric]);
return numericValue !== null && numericValue >= rule.threshold;
}
function buildSpreadInfoColumnHeader(
config: SpreadInfoConfig,
metric: SpreadInfoMetricDefinition
): string {
return ["内容数据", ...buildConfigPrefixParts(config), metric.label].join("-");
}
function buildConfigPrefixParts(config: SpreadInfoConfig): string[] {
const typeLabel = config.type === 1 ? "个人视频" : "星图视频";
const rangeLabel = config.range === 2 ? "近30天" : "近90天";
if (config.type === 1) {
return [typeLabel, rangeLabel];
}
return [
config.onlyAssign ? "只看指派" : "不限指派",
config.flowType === 1 ? "排除营销流量" : "不排除营销流量",
typeLabel,
rangeLabel
];
}
function buildXingtuVideoConfigs(): SpreadInfoConfig[] {
const configs: SpreadInfoConfig[] = [];
[false, true].forEach((onlyAssign) => {
([0, 1] as const).forEach((flowType) => {
([2, 3] as const).forEach((range) => {
configs.push({
flowType,
onlyAssign,
range,
type: 2
});
});
});
});
return configs;
}
function getPayloadData(payload: unknown): Record<string, unknown> | null {
if (!isRecord(payload)) {
return null;
}
return isRecord(payload.data) ? payload.data : payload;
}
function readNestedValue(value: unknown, key: string): unknown {
return isRecord(value) ? value[key] : undefined;
}
function readStringLike(value: unknown): string | undefined {
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return String(value);
}
return undefined;
}
function readNumberLike(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : null;
}
return null;
}
function readDisplayNumber(value: string | undefined): number | null {
if (!hasTextValue(value)) {
return null;
}
const parsedValue = Number(value.replace(/[% ,]/g, ""));
return Number.isFinite(parsedValue) ? parsedValue : null;
}
function formatBasisPointPercent(value: number | null): string | undefined {
if (value === null) {
return undefined;
}
return `${formatDecimal(value / 100)}%`;
}
function formatMillisecondsAsSeconds(value: number | null): string | undefined {
if (value === null) {
return undefined;
}
return formatDecimal(value / 100);
}
function formatDecimal(value: number): string {
return value.toFixed(2).replace(/\.?0+$/, "");
}
function resolveBaseUrl(): string {
if (typeof location !== "undefined" && location.origin) {
return location.origin;
}
return "https://www.xingtu.cn";
}
async function defaultFetch(input: string, init?: RequestInit) {
return fetch(input, init);
}
function hasTextValue(value: string | undefined): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+23
View File
@@ -12,6 +12,27 @@ export interface BackendMetrics {
newA3Rate?: string;
}
export type SpreadInfoMetrics = Record<string, string>;
export interface SpreadInfoConfig {
flowType: 0 | 1;
onlyAssign: boolean;
range: 2 | 3;
type: 1 | 2;
}
export type SpreadFilterMetric = "finishRate" | "interactionRate";
export interface SpreadMetricFilterRule {
config: SpreadInfoConfig;
metric: SpreadFilterMetric;
threshold: number;
}
export interface SpreadThresholdFilter {
rules: SpreadMetricFilterRule[];
}
export type MarketSortField =
| keyof Required<AfterSearchRates>
| keyof Required<BackendMetrics>;
@@ -28,6 +49,8 @@ export interface MarketRowSnapshot {
location?: string;
price21To60s?: string;
rates?: AfterSearchRates;
spreadAuthorId?: string;
spreadMetrics?: SpreadInfoMetrics;
}
export interface MarketRecord extends MarketRowSnapshot {
+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>
+3 -2
View File
@@ -104,7 +104,7 @@ async function renderCurrentAuthState(
}
renderLoggedIn(root, response.value);
void runUpdateCheck(root, sendMessage, updateOptions);
await runUpdateCheck(root, sendMessage, updateOptions);
root
.querySelector('[data-popup-sign-out="button"]')
?.addEventListener("click", () => {
@@ -195,9 +195,10 @@ async function runUpdateCheck(
status: "available"
});
bindUpdateDownloadButtons(root, sendMessage, manifest);
} catch {
} catch (error) {
renderUpdateStatus(root, {
currentVersion: options.currentVersion,
message: error instanceof Error ? error.message : String(error),
status: "error"
});
}
+59 -39
View File
@@ -3,11 +3,18 @@ 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>
`;
}
@@ -19,16 +26,24 @@ 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>
<section data-popup-update="root">
<h2></h2>
<p data-popup-update-status="text">...</p>
<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>
<button type="button" data-popup-sign-out="button">退</button>
<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>
`;
}
@@ -38,50 +53,54 @@ export function renderUpdateStatus(
options: {
currentVersion: string;
manifest?: UpdateManifest;
message?: string | null;
status: "checking" | "error" | "latest" | "available";
}
): void {
const container = root.querySelector('[data-popup-update="root"]');
const container = root.querySelector('[data-popup-update-root="root"]');
if (!container) {
return;
}
if (options.status === "checking") {
container.innerHTML = `
<h2></h2>
<p data-popup-update-status="text">${options.currentVersion}</p>
<p>...</p>
<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 = `
<h2></h2>
<p data-popup-update-status="text">${options.currentVersion}</p>
<p></p>
<p></p>
<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 = `
<h2></h2>
<p data-popup-update-status="text">${options.currentVersion}</p>
<p></p>
<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 = `
<h2></h2>
<p data-popup-update-status="text">${options.currentVersion}</p>
<p>${options.manifest.latestVersion}</p>
<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)}
<button type="button" data-popup-download-update="button"></button>
<button type="button" data-popup-download-guide="button">使</button>
<p data-popup-update-download-status="text"> zip chrome://extensions 里重新加载插件。</p>
<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>
`;
}
@@ -103,7 +122,7 @@ function renderReleaseNotes(releaseNotes: string[]): string {
}
return `
<ul>
<ul class="popup-notes">
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
</ul>
`;
@@ -122,15 +141,16 @@ export function renderDevPanel(
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);
+1 -1
View File
@@ -1 +1 @@
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.21:8083";
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://localhost:8083";
+33 -41
View File
@@ -54,29 +54,7 @@ describe("audience-profile-csv", () => {
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"
}
}
status: "success"
},
record: {
authorId: "123",
@@ -85,6 +63,10 @@ describe("audience-profile-csv", () => {
: "达人 A",
: "300w"
},
spreadMetrics: {
"内容数据-个人视频-近30天-播放量中位数": "10913233",
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数": "7502"
},
status: "success"
}
} satisfies AudienceProfileExportRow
@@ -95,8 +77,10 @@ describe("audience-profile-csv", () => {
expect(headerLine).toContain("达人信息,连接用户数");
expect(headerLine).not.toContain("抓取状态");
expect(headerLine).not.toContain("失败原因");
expect(headerLine).toContain("内容数据-个人视频-播放量中位数");
expect(headerLine).toContain("内容数据-星图视频-平均转发");
expect(headerLine).toContain("内容数据-个人视频-近30天-播放量中位数");
expect(headerLine).toContain(
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数"
);
expect(headerLine).toContain("效果预估-1-20s视频-预期CPM");
expect(headerLine).toContain("效果预估-20-60s视频-爆文率");
expect(headerLine).toContain("效果预估-60s以上视频-预期播放量");
@@ -116,8 +100,15 @@ describe("audience-profile-csv", () => {
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, "内容数据-个人视频-近30天-播放量中位数")).toBe(
"10913233"
);
expect(
readCsvValue(
csv,
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数"
)
).toBe("7502");
expect(readCsvValue(csv, "效果预估-1-20s视频-预期CPM")).toBe("120.0");
expect(readCsvValue(csv, "效果预估-20-60s视频-爆文率")).toBe("缺失");
});
@@ -202,7 +193,7 @@ describe("audience-profile-csv", () => {
const row = buildSuccessRow();
const csv = buildAudienceProfileCsv([row], {
selectedHeaders: [
"内容数据-个人视频-播放量中位数",
"内容数据-个人视频-近30天-播放量中位数",
"观众画像-男性占比"
]
});
@@ -210,9 +201,9 @@ describe("audience-profile-csv", () => {
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
"达人信息,连接用户数,内容数据-个人视频-播放量中位数,观众画像-男性占比"
"达人信息,连接用户数,内容数据-个人视频-近30天-播放量中位数,观众画像-男性占比"
);
expect(rowLine).toBe("达人 A,300w,3738.4w,71.7%");
expect(rowLine).toBe("达人 A,300w,10913233,71.7%");
expect(headerLine).not.toContain("秒思api-看后搜数");
expect(headerLine).not.toContain("粉丝画像-女性占比");
});
@@ -227,15 +218,15 @@ describe("audience-profile-csv", () => {
}
});
const csv = buildAudienceProfileCsv([row], {
selectedHeaders: ["内容数据-个人视频-播放量中位数"]
selectedHeaders: ["内容数据-个人视频-近30天-播放量中位数"]
});
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-播放量中位数"
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-近30天-播放量中位数"
);
expect(rowLine).toBe("123,达人 A,成功,,3738.4w");
expect(rowLine).toBe("123,达人 A,成功,,10913233");
});
test("lists headers for field picker defaults", () => {
@@ -244,7 +235,7 @@ describe("audience-profile-csv", () => {
"达人信息",
"连接用户数",
"秒思api-看后搜数",
"内容数据-个人视频-播放量中位数",
"内容数据-个人视频-近30天-播放量中位数",
"效果预估-20-60s视频-预期CPM",
"观众画像-男性占比",
"铁粉画像-小镇青年占比"
@@ -260,7 +251,9 @@ describe("audience-profile-csv", () => {
label: "秒思api数据"
}),
expect.objectContaining({
headers: expect.arrayContaining(["内容数据-个人视频-播放量中位数"]),
headers: expect.arrayContaining([
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数"
]),
label: "内容数据"
}),
expect.objectContaining({
@@ -310,12 +303,7 @@ function buildSuccessRow(
hotRate: "缺失"
}
},
status: "success",
videos: {
personalVideo: {
medianPlay: "3738.4w"
}
}
status: "success"
},
record: {
authorId: "123",
@@ -324,6 +312,10 @@ function buildSuccessRow(
: "达人 A",
: "300w"
},
spreadMetrics: {
"内容数据-个人视频-近30天-播放量中位数": "10913233",
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数": "7502"
},
status: "success",
...overrides
}
+18
View File
@@ -55,4 +55,22 @@ describe("batch-payload", () => {
})
).toThrow(/user/i);
});
test("throws when no creator records are available", () => {
expect(() =>
createBatchPayload({
authState: {
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: {
name: "王少卿",
sub: "p7pdhhtde8kj"
}
},
batchName: "空结果批次",
createdAt: "2026-07-03T03:05:52.432Z",
records: []
})
).toThrow("当前没有可提交的达人");
});
});
+1 -1
View File
@@ -5,7 +5,7 @@ import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
describe("batch-submit-client", () => {
test("exports the default batch submit base url", () => {
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://192.168.31.21:8083");
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://localhost:8083");
});
test("posts the batch payload with a Bearer token", async () => {
+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")
);
});
});
+5 -53
View File
@@ -2,24 +2,12 @@ import { describe, expect, test, vi } from "vitest";
import {
buildBusinessAbilityEstimateUrl,
buildBusinessAbilityVideoUrl,
createBusinessAbilityClient,
mapBusinessAbilityEstimateResponse,
mapBusinessAbilityVideoResponse
mapBusinessAbilityEstimateResponse
} 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"
);
test("builds the commerce spread estimate url used by the Xingtu detail page", () => {
expect(
buildBusinessAbilityEstimateUrl(
"6724241209444794382",
@@ -30,19 +18,6 @@ describe("business-ability-client", () => {
);
});
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: {
@@ -98,15 +73,12 @@ describe("business-ability-client", () => {
});
});
test("loads personal video, Xingtu video, and duration estimate metrics", async () => {
test("loads duration estimate metrics without requesting legacy video content 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(),
json: async () => buildEstimatePayload(),
ok: true
};
});
@@ -126,35 +98,15 @@ describe("business-ability-client", () => {
estimates: expect.objectContaining({
twentyToSixty: expect.objectContaining({ expectedCpm: "212.0" })
}),
status: "success",
videos: {
personalVideo: expect.objectContaining({ medianPlay: "4059.7w" }),
xingtuVideo: expect.objectContaining({ medianPlay: "4059.7w" })
}
status: "success"
});
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: "" },
+69 -9
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { buildMarketCsv } from "../src/content/market/csv-exporter";
import { buildSpreadInfoColumns } from "../src/content/market/spread-info";
import type { MarketRecord } from "../src/content/market/types";
describe("csv-exporter", () => {
@@ -21,12 +22,13 @@ describe("csv-exporter", () => {
"秒思api-新增A3数",
"秒思api-新增A3率",
"秒思api-CPA3",
"秒思api-cp_search"
"秒思api-cp_search",
...buildSpreadInfoColumns()
].join(",")
);
});
test("uses page export field order and appends the two plugin columns", () => {
test("uses page export field order and appends the plugin columns", () => {
const csv = buildMarketCsv([
{
authorId: "123",
@@ -66,11 +68,16 @@ describe("csv-exporter", () => {
"秒思api-新增A3数",
"秒思api-新增A3率",
"秒思api-CPA3",
"秒思api-cp_search"
"秒思api-cp_search",
...buildSpreadInfoColumns()
].join(",")
);
expect(rowLine).toBe(
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
expect(rowLine).toMatch(
new RegExp(
`^${escapeRegExp(
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
)}`
)
);
});
@@ -101,10 +108,13 @@ describe("csv-exporter", () => {
"秒思api-新增A3数",
"秒思api-新增A3率",
"秒思api-CPA3",
"秒思api-cp_search"
"秒思api-cp_search",
...buildSpreadInfoColumns()
].join(",")
);
expect(rowLine).toBe("Alice,100w,,,,,,,,");
expect(rowLine.split(",").slice(0, 10).join(",")).toBe(
"Alice,100w,,,,,,,,"
);
});
test("escapes commas and quotes", () => {
@@ -137,7 +147,10 @@ describe("csv-exporter", () => {
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,,,,,,,,");
expect(rowLine.split(",").slice(0, 12).join(",")).toBe(
"123,Alice,,,,,,,,,,"
);
expect(rowLine.split(",").slice(12).every((cell) => cell === "")).toBe(true);
});
test("uses normalized display values in export rows", () => {
@@ -172,6 +185,53 @@ describe("csv-exporter", () => {
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,");
expect(rowLine.split(",").slice(0, 12).join(",")).toBe(
"123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,"
);
});
test("appends spread info metric columns after backend metrics", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
spreadMetrics: {
"内容数据-个人视频-近30天-完播率": "28.24%",
"内容数据-只看指派-排除营销流量-星图视频-近30天-互动率": "4.02%"
},
status: "success"
} satisfies MarketRecord
]);
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toContain(
[
"秒思api-cp_search",
"内容数据-个人视频-近30天-完播率",
"内容数据-个人视频-近30天-播放量中位数"
].join(",")
);
expect(headerLine).toContain(
"内容数据-只看指派-排除营销流量-星图视频-近30天-互动率"
);
expect(rowLine).toContain("28.24%");
expect(rowLine).toContain("4.02%");
});
test("emits empty spread info cells when spread metrics are absent", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
status: "success"
} satisfies MarketRecord
]);
const [, rowLine] = csv.split("\n");
expect(rowLine.split(",").slice(-70).every((cell) => cell === "")).toBe(true);
});
});
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+1 -1
View File
@@ -43,7 +43,7 @@ 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://localhost:8083/*",
"https://*/*"
]);
expect(releaseManifest.host_permissions).not.toEqual(
+701 -17
View File
@@ -4,16 +4,18 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createMarketResultStore } from "../src/content/market/result-store";
import type { SpreadInfoConfig } from "../src/content/market/types";
const disposers: Array<() => void> = [];
describe("market-content-entry", () => {
beforeEach(() => {
installUsableLocalStorage();
document.body.innerHTML = "";
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();
clearLocalStorage();
window.history.replaceState({}, "", "/");
});
@@ -293,7 +295,10 @@ describe("market-content-entry", () => {
});
test("renders the plugin action bar inside the native market action row", async () => {
document.body.innerHTML = buildMarketFixture();
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
@@ -327,7 +332,7 @@ describe("market-content-entry", () => {
expect(
(document.querySelector('[data-plugin-export-range="select"]') as HTMLSelectElement | null)
?.hidden
).toBe(true);
).toBe(false);
expect(
(
document.querySelector(
@@ -338,7 +343,7 @@ describe("market-content-entry", () => {
expect(
(document.querySelector('[data-plugin-export="button"]') as HTMLButtonElement | null)
?.hidden
).toBe(true);
).toBe(false);
expect(
document.querySelector('[data-plugin-export-audience-profile="button"]')
).not.toBeNull();
@@ -381,6 +386,108 @@ describe("market-content-entry", () => {
expect(audienceProfileByIdExportButton?.style.color).toBe("rgb(255, 255, 255)");
});
test("renders the approved compact toolbar layout from the preview", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
const toolbar = document.querySelector(
'[data-plugin-toolbar="root"]'
) as HTMLElement | null;
const panel = document.querySelector(
'[data-plugin-toolbar-panel="root"]'
) as HTMLElement | null;
const primaryRow = document.querySelector(
'[data-plugin-toolbar-row="primary"]'
) as HTMLElement | null;
const thresholdRow = document.querySelector(
'[data-plugin-toolbar-row="thresholds"]'
) as HTMLElement | null;
const dataGroup = document.querySelector(
'[data-plugin-toolbar-group="data"]'
) as HTMLElement | null;
const metricSelector = document.querySelector(
'[data-plugin-spread-metrics="root"]'
) as HTMLElement | null;
const rulesGroup = document.querySelector(
'[data-plugin-spread-rules="root"]'
) as HTMLElement | null;
const statusText = document.querySelector(
'[data-plugin-export-status="text"]'
) as HTMLElement | null;
const titles = Array.from(
document.querySelectorAll("[data-plugin-toolbar-title]")
) as HTMLElement[];
const buttons = Array.from(
document.querySelectorAll("[data-plugin-toolbar-group='data'] button")
) as HTMLButtonElement[];
const operators = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold-operator]")
).map((element) => element.textContent);
const metricInputs = Array.from(
document.querySelectorAll("[data-plugin-spread-metric]")
) as HTMLInputElement[];
const ruleRows = Array.from(
document.querySelectorAll("[data-plugin-spread-rule]")
) as HTMLElement[];
const thresholdInputs = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold]")
) as HTMLInputElement[];
expect(toolbar?.style.flexWrap).toBe("nowrap");
expect(panel?.style.flexDirection).toBe("column");
expect(panel?.style.alignItems).toBe("center");
expect(primaryRow?.style.flexWrap).toBe("nowrap");
expect(thresholdRow?.style.flexWrap).toBe("nowrap");
expect(thresholdRow?.style.alignItems).toBe("center");
expect(dataGroup?.parentElement).toBe(primaryRow);
expect(statusText?.parentElement).toBe(primaryRow);
expect(metricSelector?.parentElement).toBe(thresholdRow);
expect(rulesGroup?.parentElement).toBe(thresholdRow);
expect(rulesGroup?.style.flexDirection).toBe("column");
expect(primaryRow?.style.justifyContent).toBe("flex-start");
expect(thresholdRow?.style.justifyContent).toBe("flex-start");
expect(titles.map((element) => element.textContent)).toEqual([
"传播指标筛选"
]);
expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)");
expect(operators).toEqual(["≥", "≥"]);
expect(metricInputs.map((input) => input.checked)).toEqual([false, false]);
expect(ruleRows.map((row) => row.hidden)).toEqual([true, true]);
expect(thresholdInputs.map((input) => input.placeholder)).toEqual([
"",
""
]);
expect(thresholdInputs.map((input) => input.step)).toEqual([
"0.1",
"0.1"
]);
expect(buttons.map((button) => button.textContent)).toEqual([
"导出CSV",
"导出选中达人数据",
"按星图ID导出",
"选择字段",
"提交批次"
]);
expect(buttons.map((button) => button.style.backgroundColor)).toEqual([
"rgb(127, 29, 45)",
"rgb(127, 29, 45)",
"rgb(127, 29, 45)",
"rgb(127, 29, 45)",
"rgb(127, 29, 45)"
]);
});
test("remounts the plugin action bar when the native market action row appears later", async () => {
document.body.innerHTML = buildMarketTableOnlyFixture();
const observer = createMutationObserverFactory();
@@ -1133,7 +1240,7 @@ describe("market-content-entry", () => {
expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]);
});
test("toolbar keeps export range controls hidden while retaining the internal default range", async () => {
test("toolbar shows export range controls and reveals custom page input only for custom range", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
@@ -1156,7 +1263,7 @@ describe("market-content-entry", () => {
) as HTMLInputElement | null;
expect(exportRangeSelect?.value).toBe("first-5");
expect(exportRangeSelect?.hidden).toBe(true);
expect(exportRangeSelect?.hidden).toBe(false);
expect(customPagesInput?.hidden).toBe(true);
expect(
document.querySelector('[data-plugin-batch-submit="button"]')
@@ -1165,8 +1272,167 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "custom");
dispatchChange('[data-plugin-export-range="select"]');
expect(exportRangeSelect?.hidden).toBe(true);
expect(customPagesInput?.hidden).toBe(true);
expect(exportRangeSelect?.hidden).toBe(false);
expect(customPagesInput?.hidden).toBe(false);
});
test("toolbar exposes independent metric rules and applies personal-video constraints per rule", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
const finishToggle = document.querySelector(
'[data-plugin-spread-metric="finishRate"]'
) as HTMLInputElement | null;
const interactionToggle = document.querySelector(
'[data-plugin-spread-metric="interactionRate"]'
) as HTMLInputElement | null;
const finishRule = document.querySelector(
'[data-plugin-spread-rule="finishRate"]'
) as HTMLElement | null;
const interactionRule = document.querySelector(
'[data-plugin-spread-rule="interactionRate"]'
) as HTMLElement | null;
const finishRateInput = document.querySelector(
'[data-plugin-spread-threshold="finishRate"]'
) as HTMLInputElement | null;
expect(finishToggle?.checked).toBe(false);
expect(interactionToggle?.checked).toBe(false);
expect(finishRule?.hidden).toBe(true);
expect(interactionRule?.hidden).toBe(true);
expect(finishRateInput?.placeholder).toBe("");
enableSpreadMetric("finishRate");
enableSpreadMetric("interactionRate");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("finishRate", "flowType", "1");
setSpreadRuleSelect("interactionRate", "type", "2");
setSpreadRuleSelect("interactionRate", "onlyAssign", "true");
setSpreadRuleSelect("interactionRate", "flowType", "1");
setSpreadRuleSelect("interactionRate", "type", "1");
const finishAssignSelect = readSpreadRuleSelect(
"finishRate",
"onlyAssign"
);
const finishFlowTypeSelect = readSpreadRuleSelect(
"finishRate",
"flowType"
);
const interactionAssignSelect = readSpreadRuleSelect(
"interactionRate",
"onlyAssign"
);
const interactionFlowTypeSelect = readSpreadRuleSelect(
"interactionRate",
"flowType"
);
expect(finishRule?.hidden).toBe(false);
expect(interactionRule?.hidden).toBe(false);
expect(finishAssignSelect.value).toBe("true");
expect(finishAssignSelect.disabled).toBe(false);
expect(finishFlowTypeSelect.value).toBe("1");
expect(finishFlowTypeSelect.disabled).toBe(false);
expect(interactionAssignSelect.value).toBe("false");
expect(interactionAssignSelect.disabled).toBe(true);
expect(interactionFlowTypeSelect.value).toBe("0");
expect(interactionFlowTypeSelect.disabled).toBe(true);
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
if (!finishToggle) {
throw new Error("Missing finish-rate metric toggle");
}
finishToggle.checked = false;
dispatchChange('[data-plugin-spread-metric="finishRate"]');
finishToggle.checked = true;
dispatchChange('[data-plugin-spread-metric="finishRate"]');
expect(finishRateInput?.value).toBe("");
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]',
"1"
);
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]',
"2"
);
});
test("reads selected spread metrics as independent validated rules", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
"../src/content/market/plugin-toolbar"
);
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
expect(readToolbarSpreadFilter(toolbar)).toEqual({
filter: { rules: [] }
});
enableSpreadMetric("finishRate");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
error: "请输入有效的完播率筛选阈值"
});
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("finishRate", "flowType", "1");
setSpreadRuleSelect("interactionRate", "range", "3");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
filter: {
rules: [
{
config: {
flowType: 1,
onlyAssign: true,
range: 2,
type: 2
},
metric: "finishRate",
threshold: 30
},
{
config: {
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
},
metric: "interactionRate",
threshold: 5
}
]
}
});
});
test("export uses the current page ordering without triggering a full scan", async () => {
@@ -1217,6 +1483,256 @@ describe("market-content-entry", () => {
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
});
test("export hydrates spread info with attribute_datas.id before building csv", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
attachMarketListState([
{
attribute_datas: {
id: "spread-a",
nickname: "Alpha"
},
star_id: "a"
},
{
attribute_datas: {
id: "spread-b",
nickname: "Beta"
},
star_id: "b"
}
]);
const buildCsv = vi.fn(() => "csv-output");
const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => ({
"内容数据-个人视频-近30天-完播率": spreadAuthorId === "spread-a" ? "28.24%" : "18.24%"
}));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadMetrics,
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-a");
expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-b");
expect(buildCsv.mock.calls[0][0]).toEqual([
expect.objectContaining({
authorId: "a",
spreadAuthorId: "spread-a",
spreadMetrics: {
"内容数据-个人视频-近30天-完播率": "28.24%"
}
}),
expect.objectContaining({
authorId: "b",
spreadAuthorId: "spread-b",
spreadMetrics: {
"内容数据-个人视频-近30天-完播率": "18.24%"
}
})
]);
});
test("export requires independent spread metric configs to all match", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
attachMarketListState([
{
attribute_datas: {
id: "spread-a",
nickname: "Alpha"
},
star_id: "a"
},
{
attribute_datas: {
id: "spread-b",
nickname: "Beta"
},
star_id: "b"
}
]);
const buildCsv = vi.fn(() => "csv-output");
const loadSpreadFilterMetrics = vi.fn(async (
spreadAuthorId: string,
config: SpreadInfoConfig
) => {
if (config.type === 2) {
return { finishRate: "35%" };
}
return {
interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%"
};
});
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadFilterMetrics,
loadSpreadMetrics: async () => ({}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
enableSpreadMetric("finishRate");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("interactionRate", "range", "3");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
});
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
});
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
"a"
]);
});
test("export reuses one spread snapshot when metric configs match", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" }
]);
attachMarketListState([
{
attribute_datas: {
id: "spread-a",
nickname: "Alpha"
},
star_id: "a"
}
]);
const buildCsv = vi.fn(() => "csv-output");
const loadSpreadFilterMetrics = vi.fn(async () => ({
finishRate: "35%",
interactionRate: "6%"
}));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadFilterMetrics,
loadSpreadMetrics: async () => ({}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
enableSpreadMetric("finishRate");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
});
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
"a"
]);
});
test("export excludes missing-id and failed spread metric records without aborting", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
attachMarketListState([
{ star_id: "a" },
{
attribute_datas: {
id: "spread-b",
nickname: "Beta"
},
star_id: "b"
}
]);
const buildCsv = vi.fn(() => "csv-output");
const loadSpreadFilterMetrics = vi.fn(async () => {
throw new Error("request failed");
});
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadFilterMetrics,
loadSpreadMetrics: async () => ({}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
enableSpreadMetric("finishRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-b", {
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
});
expect(buildCsv.mock.calls[0][0]).toEqual([]);
});
test(
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
async () => {
@@ -1661,8 +2177,7 @@ describe("market-content-entry", () => {
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const,
videos: {}
status: "success" as const
}));
const loadAudienceProfile = vi.fn(async (_record, target) => {
if (target.source === "fansDistribution" && target.authorType === 5) {
@@ -1750,8 +2265,7 @@ describe("market-content-entry", () => {
}));
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const,
videos: {}
status: "success" as const
}));
const loadAudienceProfile = vi.fn(async () => ({
age: [{ label: "31-40", value: "60%" }],
@@ -1883,13 +2397,12 @@ describe("market-content-entry", () => {
]);
window.localStorage.setItem(
"sces:audience-profile:selectedHeaders",
JSON.stringify(["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"])
JSON.stringify(["内容数据-个人视频-近30天-播放量中位数", "秒思api-看后搜数"])
);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const,
videos: {}
status: "success" as const
}));
const loadAudienceProfile = vi.fn(async () => ({
age: [],
@@ -1923,7 +2436,7 @@ describe("market-content-entry", () => {
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(buildAudienceProfileCsv.mock.calls[0][1]).toEqual({
selectedHeaders: ["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"]
selectedHeaders: ["内容数据-个人视频-近30天-播放量中位数", "秒思api-看后搜数"]
});
});
@@ -1959,7 +2472,7 @@ describe("market-content-entry", () => {
window.localStorage.getItem("sces:audience-profile:selectedHeaders") ?? "[]"
) as string[];
expect(savedHeaders).not.toContain("秒思api-看后搜数");
expect(savedHeaders).toContain("内容数据-个人视频-播放量中位数");
expect(savedHeaders).toContain("内容数据-个人视频-近30天-播放量中位数");
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("字段已保存");
@@ -2158,6 +2671,79 @@ describe("market-content-entry", () => {
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
});
test("batch submit applies all independent spread metric rules", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
attachMarketListState([
{
attribute_datas: {
id: "spread-a",
nickname: "Alpha"
},
star_id: "a"
},
{
attribute_datas: {
id: "spread-b",
nickname: "Beta"
},
star_id: "b"
}
]);
const submitBatch = vi.fn(async () => ({ ok: true }));
const loadSpreadFilterMetrics = vi.fn(async (
spreadAuthorId: string,
config: SpreadInfoConfig
) => {
if (config.type === 2) {
return {
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
};
}
return {
interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%"
};
});
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadFilterMetrics,
promptBatchName: () => "筛选批次",
submitBatch,
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
enableSpreadMetric("finishRate");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("interactionRate", "range", "3");
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 80, 50);
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
expect.objectContaining({
authorId: "a"
})
]);
});
test("opens a custom batch name dialog before submitting", async () => {
document.body.innerHTML = buildMarketFixture();
const submitBatch = vi.fn(async () => ({ ok: true }));
@@ -3553,6 +4139,58 @@ describe("market-content-entry", () => {
});
});
function clearLocalStorage(): void {
if (typeof window.localStorage.clear === "function") {
window.localStorage.clear();
return;
}
for (let index = window.localStorage.length - 1; index >= 0; index -= 1) {
const key = window.localStorage.key(index);
if (key) {
window.localStorage.removeItem(key);
}
}
}
function installUsableLocalStorage(): void {
if (
typeof window.localStorage.getItem === "function" &&
typeof window.localStorage.setItem === "function" &&
typeof window.localStorage.removeItem === "function" &&
typeof window.localStorage.clear === "function"
) {
return;
}
const values = new Map<string, string>();
const storage: Storage = {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, String(value));
}
};
Object.defineProperty(window, "localStorage", {
configurable: true,
value: storage
});
}
function buildMarketFixture() {
return buildMarketPageShell(buildMarketTableOnlyFixture());
}
@@ -4704,6 +5342,52 @@ function click(selector: string) {
element.click();
}
function enableSpreadMetric(metric: "finishRate" | "interactionRate") {
const selector = `[data-plugin-spread-metric="${metric}"]`;
const input = document.querySelector(selector) as HTMLInputElement | null;
if (!input) {
throw new Error(`Missing spread metric toggle: ${metric}`);
}
input.checked = true;
dispatchChange(selector);
}
function readSpreadRuleSelect(
metric: "finishRate" | "interactionRate",
field: "type" | "onlyAssign" | "flowType" | "range"
): HTMLSelectElement {
const selector =
`[data-plugin-spread-rule="${metric}"] ` +
`[data-plugin-spread-filter="${field}"]`;
const select = document.querySelector(selector) as HTMLSelectElement | null;
if (!select) {
throw new Error(`Missing spread rule select: ${metric}.${field}`);
}
return select;
}
function setSpreadRuleSelect(
metric: "finishRate" | "interactionRate",
field: "type" | "onlyAssign" | "flowType" | "range",
value: string
) {
const select = readSpreadRuleSelect(metric, field);
select.value = value;
select.dispatchEvent(new Event("change"));
}
function createNoopToolbarHandlers() {
return {
onConfigureAudienceProfileFields: vi.fn(),
onExport: vi.fn(),
onExportAudienceProfile: vi.fn(),
onExportAudienceProfileByIds: vi.fn(),
onSubmitBatch: vi.fn()
};
}
function clickSelectionCheckboxForAuthor(authorId: string) {
readSelectionCheckboxForAuthor(authorId).click();
}
+2 -1
View File
@@ -6,7 +6,7 @@ import { describe, expect, test } from "vitest";
import { createReleaseArchive } from "../scripts/package-release-archive.mjs";
describe("package-release-archive", () => {
test("creates a zip archive without relying on the system zip binary", async () => {
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");
@@ -21,5 +21,6 @@ describe("package-release-archive", () => {
const archive = await readFile(archivePath);
expect(archive.byteLength).toBeGreaterThan(0);
expect(archive.toString("utf8")).toContain("dist/hello.txt");
});
});
+80 -2
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,6 +57,12 @@ 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");
});
@@ -76,12 +92,16 @@ describe("popup-entry", () => {
}
}))
});
await Promise.resolve();
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();
@@ -118,7 +138,8 @@ describe("popup-entry", () => {
})),
sendMessage
});
await Promise.resolve();
await flushTasks();
await flushTasks();
(
dom.window.document.querySelector(
@@ -245,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()
+43
View File
@@ -155,6 +155,49 @@ describe("silent-export-controller", () => {
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
});
test("keeps attribute_datas.id as the spread author id while preserving star_id as row id", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page_param: {
page: 1
}
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const controller = createSilentExportController({
document,
fetchImpl: async () => ({
json: async () => ({
authors: [
{
attribute_datas: {
id: "spread-1",
nickname: "达人1"
},
star_id: "row-1"
}
]
}),
ok: true
})
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 1
});
expect(records?.[0]).toMatchObject({
authorId: "row-1",
spreadAuthorId: "spread-1"
});
});
test("starts from page 1 when the captured request omits an explicit page number", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
+260
View File
@@ -0,0 +1,260 @@
import { describe, expect, test, vi } from "vitest";
import {
buildSpreadInfoConfigKey,
buildSpreadInfoColumns,
buildSpreadInfoUrl,
createSpreadInfoClient,
DEFAULT_SPREAD_INFO_CONFIGS,
matchesSpreadMetricRule,
normalizeSpreadInfoConfig,
mapSpreadInfoResponse
} from "../src/content/market/spread-info";
describe("spread-info", () => {
test("builds the spread info url with all request parameters", () => {
expect(
buildSpreadInfoUrl(
"7361012802036695050",
{
flowType: 1,
onlyAssign: true,
range: 2,
type: 2
},
"https://www.xingtu.cn"
)
).toBe(
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=7361012802036695050&platform_source=1&platform_channel=1&type=2&flow_type=1&only_assign=true&range=2"
);
});
test("defines personal video ranges with fixed non-prefix parameters", () => {
const personalConfigs = DEFAULT_SPREAD_INFO_CONFIGS.filter(
(config) => config.type === 1
);
expect(personalConfigs).toEqual([
{
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
},
{
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
}
]);
expect(buildSpreadInfoColumns(personalConfigs).slice(0, 2)).toEqual([
"内容数据-个人视频-近30天-完播率",
"内容数据-个人视频-近30天-播放量中位数"
]);
});
test("defines all xingtu video assign flow and range combinations", () => {
const xingtuConfigs = DEFAULT_SPREAD_INFO_CONFIGS.filter(
(config) => config.type === 2
);
expect(xingtuConfigs).toHaveLength(8);
expect(xingtuConfigs).toContainEqual({
flowType: 1,
onlyAssign: true,
range: 2,
type: 2
});
expect(xingtuConfigs).toContainEqual({
flowType: 0,
onlyAssign: false,
range: 3,
type: 2
});
expect(buildSpreadInfoColumns([
{
flowType: 1,
onlyAssign: true,
range: 2,
type: 2
}
])).toEqual([
"内容数据-只看指派-排除营销流量-星图视频-近30天-完播率",
"内容数据-只看指派-排除营销流量-星图视频-近30天-播放量中位数",
"内容数据-只看指派-排除营销流量-星图视频-近30天-互动率",
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均时长",
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均评论数",
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均点赞数",
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均转发数"
]);
});
test("maps spread info response values into display values", () => {
expect(
mapSpreadInfoResponse({
avg_duration: "5600",
comment_avg: "7502",
interact_rate: {
value: 402
},
item_rate: {
play_mid: {
value: 10913233
}
},
like_avg: "494458",
play_over_rate: {
value: 2824
},
share_avg: "188267"
})
).toEqual({
averageCommentCount: "7502",
averageDuration: "56",
averageLikeCount: "494458",
averageShareCount: "188267",
finishRate: "28.24%",
interactionRate: "4.02%",
playMedian: "10913233"
});
});
test("loads each configured spread metric column for one author", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => ({
avg_duration: "5600",
comment_avg: "7502",
interact_rate: {
value: 402
},
like_avg: "494458",
play_mid: "10913233",
play_over_rate: {
value: 2824
},
share_avg: "188267"
}),
ok: true
}));
const client = createSpreadInfoClient({
configs: [
{
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
}
],
fetchImpl
});
await expect(
client.loadAuthorSpreadMetrics("7361012802036695050")
).resolves.toEqual({
"内容数据-个人视频-近30天-互动率": "4.02%",
"内容数据-个人视频-近30天-作品平均点赞数": "494458",
"内容数据-个人视频-近30天-作品平均评论数": "7502",
"内容数据-个人视频-近30天-作品平均时长": "56",
"内容数据-个人视频-近30天-作品平均转发数": "188267",
"内容数据-个人视频-近30天-完播率": "28.24%",
"内容数据-个人视频-近30天-播放量中位数": "10913233"
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=7361012802036695050&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=false&range=2",
expect.objectContaining({
credentials: "include",
method: "GET"
})
);
});
test("normalizes fixed personal-video parameters before grouping", () => {
expect(
normalizeSpreadInfoConfig({
flowType: 1,
onlyAssign: true,
range: 3,
type: 1
})
).toEqual({
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
});
});
test("uses all normalized video dimensions in the config key", () => {
expect(
buildSpreadInfoConfigKey({
flowType: 1,
onlyAssign: true,
range: 3,
type: 1
})
).toBe(
buildSpreadInfoConfigKey({
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
})
);
expect(
buildSpreadInfoConfigKey({
flowType: 0,
onlyAssign: false,
range: 2,
type: 2
})
).not.toBe(
buildSpreadInfoConfigKey({
flowType: 0,
onlyAssign: false,
range: 3,
type: 2
})
);
});
test("matches only the metric named by one filter rule", () => {
expect(
matchesSpreadMetricRule(
{
finishRate: "28.24%",
interactionRate: "4.02%"
},
{
config: {
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
},
metric: "finishRate",
threshold: 28
}
)
).toBe(true);
expect(
matchesSpreadMetricRule(
{
finishRate: "28.24%"
},
{
config: {
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
},
metric: "interactionRate",
threshold: 1
}
)
).toBe(false);
});
});