Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4440e2ca3 | ||
|
|
4c66262211 | ||
|
|
4b5515f6ec | ||
|
|
ccfb8f14fe | ||
|
|
0122e63872 | ||
|
|
9eb1fe43cc | ||
|
|
121977fd0d | ||
| 7839380613 | |||
| 8aca116949 | |||
| 3a80ef9859 | |||
| 1a7b025aee | |||
| b3b916c6bc | |||
| e1cf2970da | |||
| 18b9d8eee5 | |||
| 48362bd85f | |||
| 6e06a67bde | |||
| d7b35d6149 | |||
| f683b1db4f |
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,12 +2,10 @@
|
||||
.old-reference/
|
||||
.local/
|
||||
dist/
|
||||
# dist-release/
|
||||
# release/
|
||||
node_modules/
|
||||
|
||||
# Build artifacts
|
||||
dist-release/
|
||||
dist-release.pem
|
||||
dist-release.crx
|
||||
|
||||
|
||||
@ -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 |
@ -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
@ -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;
|
||||
}
|
||||
})();
|
||||
@ -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://*/*"
|
||||
]
|
||||
}
|
||||
@ -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>
|
||||
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@ -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.
|
||||
|
||||
685
docs/prototypes/market-toolbar-redesign-preview.html
Normal file
685
docs/prototypes/market-toolbar-redesign-preview.html
Normal file
@ -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>
|
||||
179
docs/superpowers/plans/2026-05-25-popup-update-panel.md
Normal file
179
docs/superpowers/plans/2026-05-25-popup-update-panel.md
Normal file
@ -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"
|
||||
```
|
||||
|
||||
188
docs/superpowers/plans/2026-05-25-unified-dist-distribution.md
Normal file
188
docs/superpowers/plans/2026-05-25-unified-dist-distribution.md
Normal file
@ -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"
|
||||
```
|
||||
|
||||
105
docs/superpowers/plans/2026-06-29-author-spread-info-export.md
Normal file
105
docs/superpowers/plans/2026-06-29-author-spread-info-export.md
Normal file
@ -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`。
|
||||
100
docs/superpowers/specs/2026-05-25-popup-update-panel-design.md
Normal file
100
docs/superpowers/specs/2026-05-25-popup-update-panel-design.md
Normal file
@ -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 和提交批次都应用二次筛选。
|
||||
@ -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
|
||||
|
||||
730
docs/项目流程说明文档.md
Normal file
730
docs/项目流程说明文档.md
Normal file
@ -0,0 +1,730 @@
|
||||
# 项目流程说明文档
|
||||
|
||||
## 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%`;
|
||||
- 平均时长按秒比较;
|
||||
- 播放量、评论、点赞、转发按普通数字比较;
|
||||
- 请求失败或缺少被启用指标的达人视为不满足筛选。
|
||||
- 输出结果:过滤后的达人集合。
|
||||
- 外部依赖:星图传播指标接口。
|
||||
- 失败后如何处理:
|
||||
- 阈值非法:阻止导出或提交并提示;
|
||||
- 单个达人筛选请求失败:跳过该达人;
|
||||
- 全部不满足:导出时可能生成只有表头的 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 | 未确认 | 指标补充失败时字段留空;筛选请求失败时该达人不满足筛选 |
|
||||
| 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. 每次完成较大代码改动后,最终回复必须明确说明:
|
||||
- 是否检查了流程文档;
|
||||
- 是否更新了流程文档;
|
||||
- 更新了哪些部分;
|
||||
- 如果没有更新,为什么不需要更新。
|
||||
|
||||
以下情况都视为必须检查文档的较大改动:
|
||||
|
||||
- 改变整体流程顺序;
|
||||
- 新增、删除或调整流程步骤;
|
||||
- 新增、删除或替换外部接口;
|
||||
- 修改接口参数、鉴权方式、分页方式、限流方式、重试方式、超时规则或并发规则;
|
||||
- 修改数据过滤、去重、合并、覆盖、写入规则;
|
||||
- 修改任务启动方式、运行参数或配置项;
|
||||
- 修改任务成功、失败、部分成功的判断方式;
|
||||
- 修改重复执行、中断恢复或幂等性行为;
|
||||
- 修改最终结果的产出位置或格式;
|
||||
- 修改会影响项目使用者操作方式的任何内容。
|
||||
@ -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
scripts/build-output-path.mjs
Normal file
5
scripts/build-output-path.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function resolveExtensionBuildDir(projectRoot, _buildTarget) {
|
||||
return path.join(projectRoot, "dist");
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
@ -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://*/*"
|
||||
]
|
||||
};
|
||||
|
||||
@ -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,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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[] {
|
||||
|
||||
@ -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
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -31,8 +31,10 @@ import { createMarketApiClient } from "./api-client";
|
||||
import { createExportRangeController } from "./export-range-controller";
|
||||
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
|
||||
import { createSilentExportController } from "./silent-export-controller";
|
||||
import { createSpreadInfoClient, matchesSpreadThresholds } from "./spread-info";
|
||||
import {
|
||||
readToolbarExportTarget,
|
||||
readToolbarSpreadFilter,
|
||||
setToolbarBusyState,
|
||||
setToolbarExportStatus
|
||||
} from "./plugin-toolbar";
|
||||
@ -54,7 +56,8 @@ import type {
|
||||
MarketExportTarget,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot,
|
||||
MarketSortState
|
||||
MarketSortState,
|
||||
SpreadThresholdFilter
|
||||
} from "./types";
|
||||
|
||||
interface MutationObserverLike {
|
||||
@ -79,6 +82,11 @@ export interface CreateMarketControllerOptions {
|
||||
target: AudienceProfileRequestTarget
|
||||
) => Promise<AudienceProfileResult>;
|
||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||
loadSpreadFilterMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadThresholdFilter["config"]
|
||||
) => Promise<Record<string, string | undefined>>;
|
||||
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||
Array<BackendMetrics & { starId: string }>
|
||||
>;
|
||||
@ -101,10 +109,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 +220,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 +265,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
try {
|
||||
const selectedRecords = filterRecordsBySelectionStrict(
|
||||
await exportRecords(exportTarget.target, "画像导出中", {
|
||||
includeSpreadMetrics: true,
|
||||
showDetailedProgress: false
|
||||
})
|
||||
);
|
||||
@ -369,6 +393,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 +413,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 +739,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
target: MarketExportTarget,
|
||||
inProgressLabel = "导出中",
|
||||
progressOptions: {
|
||||
includeSpreadMetrics?: boolean;
|
||||
showDetailedProgress?: boolean;
|
||||
} = {}
|
||||
): Promise<MarketRecord[]> {
|
||||
@ -716,7 +749,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 +760,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
silentExportRecords.map((record) => ({
|
||||
...record,
|
||||
status: record.status ?? "idle"
|
||||
}))
|
||||
})),
|
||||
{
|
||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -760,6 +798,37 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return selectedRecords.length > 0 ? selectedRecords : records;
|
||||
}
|
||||
|
||||
async function applySpreadThresholdFilter(
|
||||
records: MarketRecord[],
|
||||
filter: SpreadThresholdFilter | undefined
|
||||
): Promise<MarketRecord[]> {
|
||||
if (!filter) {
|
||||
return records;
|
||||
}
|
||||
|
||||
const matchedAuthorIds = new Set<string>();
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const spreadAuthorId = record.spreadAuthorId;
|
||||
if (!spreadAuthorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await loadSpreadFilterMetrics(
|
||||
spreadAuthorId,
|
||||
filter.config
|
||||
);
|
||||
if (matchesSpreadThresholds(metrics, filter.thresholds)) {
|
||||
matchedAuthorIds.add(record.authorId);
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
);
|
||||
|
||||
return records.filter((record) => matchedAuthorIds.has(record.authorId));
|
||||
}
|
||||
|
||||
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
||||
if (selectedAuthorIds.size === 0) {
|
||||
return [];
|
||||
@ -810,13 +879,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 +1073,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 +1144,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 +1249,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
existingRecord?.price21To60s,
|
||||
rowSnapshot.price21To60s
|
||||
);
|
||||
const spreadAuthorId = mergeStringValue(
|
||||
existingRecord?.spreadAuthorId,
|
||||
rowSnapshot.spreadAuthorId
|
||||
);
|
||||
return {
|
||||
...existingRecord,
|
||||
...rowSnapshot,
|
||||
@ -1169,6 +1274,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 +1587,8 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
||||
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
||||
location: rowDom.location,
|
||||
price21To60s: rowDom.price21To60s,
|
||||
rates: rowDom.rates
|
||||
rates: rowDom.rates,
|
||||
spreadAuthorId: rowDom.spreadAuthorId
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -68,7 +68,9 @@ export function mapMarketListRow(
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
spreadAuthorId:
|
||||
readString(readMarketFieldValue(row, attributeDatas, "id")) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type {
|
||||
MarketExportScope,
|
||||
MarketExportTarget
|
||||
MarketExportTarget,
|
||||
SpreadThresholdFilter
|
||||
} from "./types";
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
@ -20,6 +21,11 @@ export interface PluginToolbarDom {
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
exportStatusText: HTMLElement;
|
||||
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
||||
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
||||
spreadFilterRangeSelect: HTMLSelectElement;
|
||||
spreadFilterTypeSelect: HTMLSelectElement;
|
||||
spreadThresholdInputs: Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement>;
|
||||
root: HTMLElement;
|
||||
}
|
||||
|
||||
@ -114,15 +120,82 @@ export function ensurePluginToolbar(
|
||||
exportStatusText.dataset.pluginExportStatus = "text";
|
||||
applyStatusStyles(exportStatusText);
|
||||
|
||||
const spreadFilterTypeSelect = document.createElement("select");
|
||||
spreadFilterTypeSelect.dataset.pluginSpreadFilter = "type";
|
||||
appendOption(spreadFilterTypeSelect, "1", "个人视频");
|
||||
appendOption(spreadFilterTypeSelect, "2", "星图视频");
|
||||
spreadFilterTypeSelect.value = "1";
|
||||
|
||||
const spreadFilterOnlyAssignSelect = document.createElement("select");
|
||||
spreadFilterOnlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign";
|
||||
appendOption(spreadFilterOnlyAssignSelect, "false", "不限指派");
|
||||
appendOption(spreadFilterOnlyAssignSelect, "true", "只看指派");
|
||||
spreadFilterOnlyAssignSelect.value = "false";
|
||||
|
||||
const spreadFilterFlowTypeSelect = document.createElement("select");
|
||||
spreadFilterFlowTypeSelect.dataset.pluginSpreadFilter = "flowType";
|
||||
appendOption(spreadFilterFlowTypeSelect, "0", "不排除营销");
|
||||
appendOption(spreadFilterFlowTypeSelect, "1", "排除营销");
|
||||
spreadFilterFlowTypeSelect.value = "0";
|
||||
|
||||
const spreadFilterRangeSelect = document.createElement("select");
|
||||
spreadFilterRangeSelect.dataset.pluginSpreadFilter = "range";
|
||||
appendOption(spreadFilterRangeSelect, "2", "近30天");
|
||||
appendOption(spreadFilterRangeSelect, "3", "近90天");
|
||||
spreadFilterRangeSelect.value = "2";
|
||||
|
||||
const spreadThresholdInputs = createSpreadThresholdInputs(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(
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton
|
||||
);
|
||||
|
||||
const videoGroup = document.createElement("div");
|
||||
videoGroup.dataset.pluginToolbarGroup = "video";
|
||||
applyToolbarGroupStyles(videoGroup);
|
||||
videoGroup.append(
|
||||
createToolbarGroupTitle(document, "视频口径"),
|
||||
spreadFilterTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterRangeSelect
|
||||
);
|
||||
|
||||
const thresholdGroup = document.createElement("div");
|
||||
thresholdGroup.dataset.pluginToolbarGroup = "thresholds";
|
||||
applyThresholdGroupStyles(thresholdGroup);
|
||||
thresholdGroup.append(
|
||||
...createSpreadThresholdControls(document, spreadThresholdInputs)
|
||||
);
|
||||
|
||||
const thresholdTitle = createToolbarGroupTitle(document, "传播指标筛选");
|
||||
firstRow.append(dataGroup, videoGroup, exportStatusText);
|
||||
secondRow.append(thresholdTitle, thresholdGroup);
|
||||
panel.append(firstRow, secondRow);
|
||||
|
||||
root.append(
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput,
|
||||
exportButton,
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportStatusText
|
||||
panel
|
||||
);
|
||||
|
||||
document.body.appendChild(root);
|
||||
@ -133,7 +206,12 @@ export function ensurePluginToolbar(
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect
|
||||
exportRangeSelect,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
...spreadThresholdInputs
|
||||
});
|
||||
ensureToolbarMounted(root, document);
|
||||
|
||||
@ -162,7 +240,30 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
root
|
||||
root,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
spreadThresholdInputs
|
||||
});
|
||||
});
|
||||
spreadFilterTypeSelect.addEventListener("change", () => {
|
||||
syncSpreadFilterControlState({
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
root,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
spreadThresholdInputs
|
||||
});
|
||||
});
|
||||
|
||||
@ -175,9 +276,15 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
spreadThresholdInputs,
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
syncSpreadFilterControlState(toolbarDom);
|
||||
|
||||
return toolbarDom;
|
||||
}
|
||||
@ -193,6 +300,123 @@ function appendOption(
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
function createSpreadThresholdInputs(
|
||||
document: Document
|
||||
): Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement> {
|
||||
return {
|
||||
averageCommentCount: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageCommentCount"
|
||||
),
|
||||
averageDuration: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageDuration"
|
||||
),
|
||||
averageLikeCount: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageLikeCount"
|
||||
),
|
||||
averageShareCount: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageShareCount"
|
||||
),
|
||||
finishRate: createSpreadThresholdInput(document, "finishRate"),
|
||||
interactionRate: createSpreadThresholdInput(document, "interactionRate"),
|
||||
playMedian: createSpreadThresholdInput(document, "playMedian")
|
||||
};
|
||||
}
|
||||
|
||||
function createSpreadThresholdInput(
|
||||
document: Document,
|
||||
key: keyof SpreadThresholdFilter["thresholds"]
|
||||
): HTMLInputElement {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "0";
|
||||
input.step = getSpreadThresholdStep(key);
|
||||
input.dataset.pluginSpreadThreshold = key;
|
||||
return input;
|
||||
}
|
||||
|
||||
function getSpreadThresholdStep(
|
||||
key: keyof SpreadThresholdFilter["thresholds"]
|
||||
): string {
|
||||
return key === "finishRate" || key === "interactionRate" ? "0.1" : "1";
|
||||
}
|
||||
|
||||
function createSpreadThresholdControls(
|
||||
document: Document,
|
||||
inputs: Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement>
|
||||
): HTMLElement[] {
|
||||
const controls: HTMLElement[] = [];
|
||||
const entries: Array<[string, string, HTMLInputElement]> = [
|
||||
["评论", "条", inputs.averageCommentCount],
|
||||
["时长", "秒", inputs.averageDuration],
|
||||
["点赞", "次", inputs.averageLikeCount],
|
||||
["转发", "次", inputs.averageShareCount],
|
||||
["完播率", "%", inputs.finishRate],
|
||||
["互动率", "%", inputs.interactionRate],
|
||||
["播放中位数", "次", inputs.playMedian]
|
||||
];
|
||||
|
||||
entries.forEach(([label, unit, input], index) => {
|
||||
if (index > 0) {
|
||||
controls.push(createSpreadThresholdConjunction(document));
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.dataset.pluginSpreadThresholdControl = input.dataset.pluginSpreadThreshold;
|
||||
applySpreadThresholdControlStyles(wrapper);
|
||||
|
||||
const labelText = document.createElement("span");
|
||||
labelText.textContent = label;
|
||||
|
||||
const operator = document.createElement("b");
|
||||
operator.dataset.pluginSpreadThresholdOperator = "gte";
|
||||
operator.textContent = "≥";
|
||||
|
||||
const unitText = document.createElement("span");
|
||||
unitText.dataset.pluginSpreadThresholdUnit = input.dataset.pluginSpreadThreshold;
|
||||
unitText.textContent = unit;
|
||||
|
||||
wrapper.append(labelText, operator, input, unitText);
|
||||
controls.push(wrapper);
|
||||
});
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
function createSpreadThresholdConjunction(document: Document): HTMLElement {
|
||||
const conjunction = document.createElement("span");
|
||||
conjunction.dataset.pluginSpreadThresholdConjunction = "and";
|
||||
conjunction.textContent = "且";
|
||||
applySpreadThresholdConjunctionStyles(conjunction);
|
||||
return conjunction;
|
||||
}
|
||||
|
||||
function readSpreadThresholdInputs(
|
||||
root: HTMLElement
|
||||
): Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement> {
|
||||
return {
|
||||
averageCommentCount: readSpreadThresholdInput(root, "averageCommentCount"),
|
||||
averageDuration: readSpreadThresholdInput(root, "averageDuration"),
|
||||
averageLikeCount: readSpreadThresholdInput(root, "averageLikeCount"),
|
||||
averageShareCount: readSpreadThresholdInput(root, "averageShareCount"),
|
||||
finishRate: readSpreadThresholdInput(root, "finishRate"),
|
||||
interactionRate: readSpreadThresholdInput(root, "interactionRate"),
|
||||
playMedian: readSpreadThresholdInput(root, "playMedian")
|
||||
};
|
||||
}
|
||||
|
||||
function readSpreadThresholdInput(
|
||||
root: HTMLElement,
|
||||
key: keyof SpreadThresholdFilter["thresholds"]
|
||||
): HTMLInputElement {
|
||||
return root.querySelector(
|
||||
`[data-plugin-spread-threshold="${key}"]`
|
||||
) as HTMLInputElement;
|
||||
}
|
||||
|
||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
const toolbarDom = {
|
||||
audienceProfileByIdExportButton: root.querySelector(
|
||||
@ -219,9 +443,23 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
exportStatusText: root.querySelector(
|
||||
'[data-plugin-export-status="text"]'
|
||||
) as HTMLElement,
|
||||
spreadFilterFlowTypeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="flowType"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterOnlyAssignSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterRangeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="range"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterTypeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="type"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadThresholdInputs: readSpreadThresholdInputs(root),
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
syncSpreadFilterControlState(toolbarDom);
|
||||
return toolbarDom;
|
||||
}
|
||||
|
||||
@ -280,6 +518,51 @@ export function readToolbarExportTarget(
|
||||
};
|
||||
}
|
||||
|
||||
export function readToolbarSpreadFilter(
|
||||
toolbar: PluginToolbarDom
|
||||
): { error?: string; filter?: SpreadThresholdFilter } {
|
||||
const thresholds: SpreadThresholdFilter["thresholds"] = {};
|
||||
|
||||
for (const [key, input] of Object.entries(toolbar.spreadThresholdInputs)) {
|
||||
const trimmedValue = input.value.trim();
|
||||
if (!trimmedValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const numericValue = Number(trimmedValue);
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return {
|
||||
error: "请输入有效筛选阈值"
|
||||
};
|
||||
}
|
||||
|
||||
thresholds[key as keyof SpreadThresholdFilter["thresholds"]] = numericValue;
|
||||
}
|
||||
|
||||
if (Object.keys(thresholds).length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const type = Number(toolbar.spreadFilterTypeSelect.value) === 2 ? 2 : 1;
|
||||
return {
|
||||
filter: {
|
||||
config: {
|
||||
flowType:
|
||||
type === 1
|
||||
? 0
|
||||
: Number(toolbar.spreadFilterFlowTypeSelect.value) === 1
|
||||
? 1
|
||||
: 0,
|
||||
onlyAssign:
|
||||
type === 1 ? false : toolbar.spreadFilterOnlyAssignSelect.value === "true",
|
||||
range: Number(toolbar.spreadFilterRangeSelect.value) === 3 ? 3 : 2,
|
||||
type
|
||||
},
|
||||
thresholds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setToolbarBusyState(
|
||||
toolbar: PluginToolbarDom,
|
||||
isBusy: boolean
|
||||
@ -291,10 +574,18 @@ export function setToolbarBusyState(
|
||||
toolbar.audienceProfileExportButton,
|
||||
toolbar.exportButton,
|
||||
toolbar.exportRangeSelect,
|
||||
toolbar.exportCustomPagesInput
|
||||
toolbar.exportCustomPagesInput,
|
||||
toolbar.spreadFilterTypeSelect,
|
||||
toolbar.spreadFilterOnlyAssignSelect,
|
||||
toolbar.spreadFilterFlowTypeSelect,
|
||||
toolbar.spreadFilterRangeSelect,
|
||||
...Object.values(toolbar.spreadThresholdInputs)
|
||||
].forEach((element) => {
|
||||
element.disabled = isBusy;
|
||||
});
|
||||
if (!isBusy) {
|
||||
syncSpreadFilterControlState(toolbar);
|
||||
}
|
||||
}
|
||||
|
||||
export function setToolbarExportStatus(
|
||||
@ -309,6 +600,16 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||
toolbar.exportCustomPagesInput.hidden = true;
|
||||
}
|
||||
|
||||
function syncSpreadFilterControlState(toolbar: PluginToolbarDom): void {
|
||||
const isPersonalVideo = toolbar.spreadFilterTypeSelect.value !== "2";
|
||||
if (isPersonalVideo) {
|
||||
toolbar.spreadFilterOnlyAssignSelect.value = "false";
|
||||
toolbar.spreadFilterFlowTypeSelect.value = "0";
|
||||
}
|
||||
toolbar.spreadFilterOnlyAssignSelect.disabled = isPersonalVideo;
|
||||
toolbar.spreadFilterFlowTypeSelect.disabled = isPersonalVideo;
|
||||
}
|
||||
|
||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||
const actionRow = findNativeActionRow(document);
|
||||
if (!actionRow) {
|
||||
@ -467,8 +768,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 +779,72 @@ 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 applyThresholdGroupStyles(group: HTMLElement): void {
|
||||
group.style.display = "flex";
|
||||
group.style.alignItems = "center";
|
||||
group.style.gap = "7px";
|
||||
group.style.minWidth = "0";
|
||||
group.style.flex = "1 1 auto";
|
||||
group.style.flexWrap = "nowrap";
|
||||
group.style.overflowX = "auto";
|
||||
group.style.overflowY = "hidden";
|
||||
}
|
||||
|
||||
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,7 +857,14 @@ function applyNativeControlStyles(
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
}
|
||||
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
||||
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
||||
spreadFilterRangeSelect: HTMLSelectElement;
|
||||
spreadFilterTypeSelect: HTMLSelectElement;
|
||||
} & Record<
|
||||
keyof SpreadThresholdFilter["thresholds"],
|
||||
HTMLInputElement
|
||||
>
|
||||
): void {
|
||||
const primaryButton =
|
||||
findButtonContainingText(document, "发布任务") ??
|
||||
@ -519,18 +891,51 @@ function applyNativeControlStyles(
|
||||
button.style.whiteSpace = "nowrap";
|
||||
});
|
||||
|
||||
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
|
||||
const nativeControls = Array.from(Object.values(controls)).filter(
|
||||
(element): element is HTMLInputElement | HTMLSelectElement =>
|
||||
element instanceof document.defaultView!.HTMLInputElement ||
|
||||
element instanceof document.defaultView!.HTMLSelectElement
|
||||
);
|
||||
|
||||
nativeControls.forEach((element) => {
|
||||
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";
|
||||
|
||||
[
|
||||
controls.spreadFilterTypeSelect,
|
||||
controls.spreadFilterOnlyAssignSelect,
|
||||
controls.spreadFilterFlowTypeSelect,
|
||||
controls.spreadFilterRangeSelect
|
||||
].forEach((select) => {
|
||||
select.style.minWidth = "84px";
|
||||
});
|
||||
|
||||
Object.values(controls).forEach((element) => {
|
||||
if (
|
||||
element instanceof document.defaultView!.HTMLInputElement &&
|
||||
element.dataset.pluginSpreadThreshold
|
||||
) {
|
||||
element.style.width =
|
||||
element.dataset.pluginSpreadThreshold === "playMedian" ? "82px" : "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 +953,35 @@ 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 applySpreadThresholdConjunctionStyles(conjunction: HTMLElement): void {
|
||||
conjunction.style.color = "#0f8a5f";
|
||||
conjunction.style.fontSize = "12px";
|
||||
conjunction.style.fontWeight = "900";
|
||||
conjunction.style.whiteSpace = "nowrap";
|
||||
conjunction.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";
|
||||
}
|
||||
|
||||
@ -601,6 +1030,19 @@ 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;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@ -619,8 +1061,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;
|
||||
|
||||
@ -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);
|
||||
|
||||
349
src/content/market/spread-info.ts
Normal file
349
src/content/market/spread-info.ts
Normal file
@ -0,0 +1,349 @@
|
||||
import type { SpreadInfoMetrics, SpreadMetricThresholds } from "./types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
export interface SpreadInfoConfig {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
}
|
||||
|
||||
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 matchesSpreadThresholds(
|
||||
metrics: MappedSpreadInfoResponse,
|
||||
thresholds: SpreadMetricThresholds
|
||||
): boolean {
|
||||
return Object.entries(thresholds).every(([key, threshold]) => {
|
||||
if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metricValue = metrics[key as keyof SpreadMetricThresholds];
|
||||
const numericValue = readDisplayNumber(metricValue);
|
||||
return numericValue !== null && numericValue >= 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;
|
||||
}
|
||||
@ -12,6 +12,28 @@ export interface BackendMetrics {
|
||||
newA3Rate?: string;
|
||||
}
|
||||
|
||||
export type SpreadInfoMetrics = Record<string, string>;
|
||||
|
||||
export interface SpreadMetricThresholds {
|
||||
averageCommentCount?: number;
|
||||
averageDuration?: number;
|
||||
averageLikeCount?: number;
|
||||
averageShareCount?: number;
|
||||
finishRate?: number;
|
||||
interactionRate?: number;
|
||||
playMedian?: number;
|
||||
}
|
||||
|
||||
export interface SpreadThresholdFilter {
|
||||
config: {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
};
|
||||
thresholds: SpreadMetricThresholds;
|
||||
}
|
||||
|
||||
export type MarketSortField =
|
||||
| keyof Required<AfterSearchRates>
|
||||
| keyof Required<BackendMetrics>;
|
||||
@ -28,6 +50,8 @@ export interface MarketRowSnapshot {
|
||||
location?: string;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
spreadMetrics?: SpreadInfoMetrics;
|
||||
}
|
||||
|
||||
export interface MarketRecord extends MarketRowSnapshot {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 @@
|
||||
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.21:8083";
|
||||
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://localhost:8083";
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
tests/build-output-path.test.ts
Normal file
18
tests/build-output-path.test.ts
Normal 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")
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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: "" },
|
||||
|
||||
@ -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, "\\$&");
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -9,11 +9,12 @@ 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 +294,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({
|
||||
@ -381,6 +385,122 @@ 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 videoGroup = document.querySelector(
|
||||
'[data-plugin-toolbar-group="video"]'
|
||||
) as HTMLElement | null;
|
||||
const thresholdGroup = document.querySelector(
|
||||
'[data-plugin-toolbar-group="thresholds"]'
|
||||
) 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 conjunctions = Array.from(
|
||||
document.querySelectorAll("[data-plugin-spread-threshold-conjunction]")
|
||||
).map((element) => element.textContent);
|
||||
const thresholdControls = Array.from(
|
||||
document.querySelectorAll("[data-plugin-spread-threshold-control]")
|
||||
);
|
||||
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(videoGroup?.parentElement).toBe(primaryRow);
|
||||
expect(statusText?.parentElement).toBe(primaryRow);
|
||||
expect(thresholdGroup?.parentElement).toBe(thresholdRow);
|
||||
expect(thresholdGroup?.style.flexWrap).toBe("nowrap");
|
||||
expect(thresholdGroup?.style.overflowX).toBe("auto");
|
||||
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(titles[1]?.style.background).toBe("rgb(238, 245, 255)");
|
||||
expect(document.querySelector("[data-plugin-spread-threshold-rule]")).toBeNull();
|
||||
expect(operators).toEqual(["≥", "≥", "≥", "≥", "≥", "≥", "≥"]);
|
||||
expect(conjunctions).toEqual(["且", "且", "且", "且", "且", "且"]);
|
||||
expect(thresholdInputs.map((input) => input.placeholder)).toEqual([
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
""
|
||||
]);
|
||||
expect(thresholdInputs.map((input) => input.step)).toEqual([
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"0.1",
|
||||
"0.1",
|
||||
"1"
|
||||
]);
|
||||
expect(thresholdControls.map((control) => control.textContent)).toEqual([
|
||||
"评论≥条",
|
||||
"时长≥秒",
|
||||
"点赞≥次",
|
||||
"转发≥次",
|
||||
"完播率≥%",
|
||||
"互动率≥%",
|
||||
"播放中位数≥次"
|
||||
]);
|
||||
expect(buttons.map((button) => button.style.backgroundColor)).toEqual([
|
||||
"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();
|
||||
@ -1169,6 +1289,48 @@ describe("market-content-entry", () => {
|
||||
expect(customPagesInput?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
test("toolbar exposes spread threshold filters and disables fixed personal-video controls", 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 videoTypeSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="type"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const assignSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const flowTypeSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="flowType"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const finishRateInput = document.querySelector(
|
||||
'[data-plugin-spread-threshold="finishRate"]'
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
expect(videoTypeSelect?.value).toBe("1");
|
||||
expect(assignSelect?.value).toBe("false");
|
||||
expect(assignSelect?.disabled).toBe(true);
|
||||
expect(flowTypeSelect?.value).toBe("0");
|
||||
expect(flowTypeSelect?.disabled).toBe(true);
|
||||
expect(finishRateInput?.placeholder).toBe("");
|
||||
|
||||
setSelectValue('[data-plugin-spread-filter="type"]', "2");
|
||||
dispatchChange('[data-plugin-spread-filter="type"]');
|
||||
|
||||
expect(assignSelect?.disabled).toBe(false);
|
||||
expect(flowTypeSelect?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("export uses the current page ordering without triggering a full scan", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const resultStore = createMarketResultStore();
|
||||
@ -1217,6 +1379,129 @@ 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 keeps only records that match spread threshold filters", 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) => ({
|
||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%",
|
||||
interactionRate: "5%"
|
||||
}));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
buildCsv,
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
loadSpreadFilterMetrics,
|
||||
onCsvReady: vi.fn(),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
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(
|
||||
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
|
||||
async () => {
|
||||
@ -1661,8 +1946,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 +2034,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 +2166,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 +2205,7 @@ describe("market-content-entry", () => {
|
||||
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
|
||||
|
||||
expect(buildAudienceProfileCsv.mock.calls[0][1]).toEqual({
|
||||
selectedHeaders: ["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"]
|
||||
selectedHeaders: ["内容数据-个人视频-近30天-播放量中位数", "秒思api-看后搜数"]
|
||||
});
|
||||
});
|
||||
|
||||
@ -1959,7 +2241,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 +2440,64 @@ describe("market-content-entry", () => {
|
||||
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
|
||||
});
|
||||
|
||||
test("batch submit keeps only records that match spread threshold filters", 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) => ({
|
||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
|
||||
}));
|
||||
|
||||
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"]');
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
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 +3893,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());
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
|
||||
214
tests/spread-info.test.ts
Normal file
214
tests/spread-info.test.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildSpreadInfoColumns,
|
||||
buildSpreadInfoUrl,
|
||||
createSpreadInfoClient,
|
||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||
matchesSpreadThresholds,
|
||||
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("matches thresholds using display values and requires every filled threshold", () => {
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
{
|
||||
averageDuration: "56",
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%",
|
||||
playMedian: "10913233"
|
||||
},
|
||||
{
|
||||
averageDuration: 50,
|
||||
finishRate: 28,
|
||||
interactionRate: 4,
|
||||
playMedian: 10000000
|
||||
}
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
{
|
||||
averageDuration: "56",
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%",
|
||||
playMedian: "10913233"
|
||||
},
|
||||
{
|
||||
averageDuration: 57
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
{
|
||||
finishRate: "28.24%"
|
||||
},
|
||||
{
|
||||
finishRate: 20,
|
||||
interactionRate: 1
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user