Compare commits
22
Commits
577f6bb0bd
...
0.0525.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f683b1db4f | ||
|
|
66e814b30f | ||
|
|
d302614b99 | ||
|
|
57e4dc72aa | ||
|
|
8fa9fc4469 | ||
|
|
02d9063a11 | ||
|
|
703a095c08 | ||
|
|
75294ca8c7 | ||
|
|
303efcdc8f | ||
|
|
50933af0a6 | ||
|
|
db95a1f565 | ||
|
|
37f7e0b5e6 | ||
|
|
38da39589f | ||
|
|
39c4191a95 | ||
|
|
249e6a5971 | ||
|
|
ca9ce02db5 | ||
|
|
c8287e8d8e | ||
|
|
26ae3bb4b6 | ||
|
|
66bc49d498 | ||
|
|
03c2fe0cc7 | ||
|
|
b0d615ab6c | ||
|
|
1de508f2c7 |
+34
@@ -0,0 +1,34 @@
|
|||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: release-tag
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: install
|
||||||
|
image: node:20-alpine
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
image: node:20-alpine
|
||||||
|
depends_on:
|
||||||
|
- install
|
||||||
|
commands:
|
||||||
|
- npm test
|
||||||
|
|
||||||
|
- name: release
|
||||||
|
image: node:20-alpine
|
||||||
|
depends_on:
|
||||||
|
- test
|
||||||
|
environment:
|
||||||
|
COS_BUCKET: wksgx-1343191620
|
||||||
|
COS_REGION: ap-nanjing
|
||||||
|
COS_SECRET_ID:
|
||||||
|
from_secret: cos_secret_id
|
||||||
|
COS_SECRET_KEY:
|
||||||
|
from_secret: cos_secret_key
|
||||||
|
commands:
|
||||||
|
- npm run release:tag
|
||||||
@@ -76,16 +76,25 @@ npm run build:release
|
|||||||
npm run package:internal
|
npm run package:internal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
生成更新清单:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run write:latest
|
||||||
|
```
|
||||||
|
|
||||||
生成结果:
|
生成结果:
|
||||||
|
|
||||||
- 构建目录:`dist-release/`
|
- 构建目录:`dist-release/`
|
||||||
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
|
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
|
||||||
|
- 更新清单:`release/latest.json`
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 这个压缩包不是给 Chrome 商店上传的
|
- 这个压缩包不是给 Chrome 商店上传的
|
||||||
- 它是发给公司内部同事使用的交付包
|
- 它是发给公司内部同事使用的交付包
|
||||||
- 同事收到后需要解压,再到 `chrome://extensions` 中 `Load unpacked`
|
- 同事收到后需要解压,再到 `chrome://extensions` 中 `Load unpacked`
|
||||||
|
- COS 发布时,`latest.json` 放在 `star-chart-search-enhancer/latest.json`,ZIP 和 PDF 放在对应版本目录下
|
||||||
|
- 打 tag 后会触发 Drone 发布,推荐格式:`0.MMDD.N`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// src/background/auth/state.ts
|
// src/background/auth/state.ts
|
||||||
function createLoggedOutAuthState(config) {
|
function createLoggedOutAuthState(config, lastError) {
|
||||||
return {
|
return {
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
lastError: lastError ?? null,
|
||||||
resource: config?.apiResource ?? null
|
resource: config?.apiResource ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -64,6 +65,14 @@
|
|||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return createLoggedOutAuthState(config);
|
return createLoggedOutAuthState(config);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await options.authClient.getAccessToken(config.apiResource);
|
||||||
|
} catch (error) {
|
||||||
|
return createLoggedOutAuthState(
|
||||||
|
config,
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
const claims = await options.authClient.getIdTokenClaims();
|
const claims = await options.authClient.getIdTokenClaims();
|
||||||
return createLoggedInAuthState(claims, config);
|
return createLoggedInAuthState(claims, config);
|
||||||
},
|
},
|
||||||
@@ -3194,6 +3203,18 @@
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (isDownloadUpdateMessage(message2)) {
|
||||||
|
void triggerUpdateDownload(chromeLike, message2).then(() => {
|
||||||
|
sendResponse({ ok: true, type: "update:download-ack" });
|
||||||
|
}).catch((error) => {
|
||||||
|
sendResponse({
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
ok: false,
|
||||||
|
type: "update:download-error"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (isBatchSubmitMessage(message2)) {
|
if (isBatchSubmitMessage(message2)) {
|
||||||
authController ??= createAuthController({
|
authController ??= createAuthController({
|
||||||
authClient: createLogtoAuthClient()
|
authClient: createLogtoAuthClient()
|
||||||
@@ -3261,6 +3282,18 @@
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async function triggerUpdateDownload(chromeLike, message2) {
|
||||||
|
if (!chromeLike.downloads?.download) {
|
||||||
|
throw new Error("chrome.downloads.download is unavailable");
|
||||||
|
}
|
||||||
|
await Promise.resolve(
|
||||||
|
chromeLike.downloads.download({
|
||||||
|
filename: message2.filename,
|
||||||
|
saveAs: true,
|
||||||
|
url: message2.url
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
async function handleAuthMessage(authController, message2) {
|
async function handleAuthMessage(authController, message2) {
|
||||||
if (message2.type === "auth:get-state") {
|
if (message2.type === "auth:get-state") {
|
||||||
return {
|
return {
|
||||||
@@ -3314,6 +3347,13 @@
|
|||||||
const candidate = message2;
|
const candidate = message2;
|
||||||
return candidate.type === "download-market-csv" && typeof candidate.csv === "string" && typeof candidate.filename === "string";
|
return candidate.type === "download-market-csv" && typeof candidate.csv === "string" && typeof candidate.filename === "string";
|
||||||
}
|
}
|
||||||
|
function isDownloadUpdateMessage(message2) {
|
||||||
|
if (!message2 || typeof message2 !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const candidate = message2;
|
||||||
|
return candidate.type === "update:download" && typeof candidate.filename === "string" && typeof candidate.url === "string" && candidate.url.startsWith("https://");
|
||||||
|
}
|
||||||
function isBatchSubmitMessage(message2) {
|
function isBatchSubmitMessage(message2) {
|
||||||
if (!message2 || typeof message2 !== "object") {
|
if (!message2 || typeof message2 !== "object") {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+1567
-92
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,7 @@
|
|||||||
return {
|
return {
|
||||||
authorId: readString(readMarketFieldValue(row, attributeDatas, "star_id")) ?? readString(readMarketFieldValue(row, attributeDatas, "id")) ?? "",
|
authorId: readString(readMarketFieldValue(row, attributeDatas, "star_id")) ?? readString(readMarketFieldValue(row, attributeDatas, "id")) ?? "",
|
||||||
authorName: readString(readMarketFieldValue(row, attributeDatas, "nickname")) ?? readString(readMarketFieldValue(row, attributeDatas, "nick_name")) ?? "",
|
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),
|
exportFields: buildMarketExportFieldFallbacks(row, attributeDatas),
|
||||||
hasDirectRatesSource: true,
|
hasDirectRatesSource: true,
|
||||||
location: readMarketLocation(row, attributeDatas),
|
location: readMarketLocation(row, attributeDatas),
|
||||||
@@ -504,6 +505,7 @@
|
|||||||
return {
|
return {
|
||||||
authorId: readString2(row.star_id) ?? readString2(attributeDatas.id) ?? "",
|
authorId: readString2(row.star_id) ?? readString2(attributeDatas.id) ?? "",
|
||||||
authorName: readString2(attributeDatas.nickname) ?? readString2(row.nick_name) ?? "",
|
authorName: readString2(attributeDatas.nickname) ?? readString2(row.nick_name) ?? "",
|
||||||
|
coreUserId: readString2(attributeDatas.core_user_id) ?? void 0,
|
||||||
singleVideoAfterSearchRate
|
singleVideoAfterSearchRate
|
||||||
};
|
};
|
||||||
}).filter((row) => Boolean(row.authorId || row.authorName));
|
}).filter((row) => Boolean(row.authorId || row.authorName));
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"identity",
|
"identity",
|
||||||
"storage"
|
"storage"
|
||||||
],
|
],
|
||||||
"version": "0.2.0421.2",
|
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
@@ -48,11 +47,13 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"version": "0.0525.6",
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://xingtu.cn/ad/creator/market*",
|
"https://xingtu.cn/ad/creator/market*",
|
||||||
"https://*.xingtu.cn/ad/creator/market*",
|
"https://*.xingtu.cn/ad/creator/market*",
|
||||||
"https://login-api.intelligrow.cn/*",
|
"https://login-api.intelligrow.cn/*",
|
||||||
"https://talent-search.intelligrow.cn/*",
|
"https://talent-search.intelligrow.cn/*",
|
||||||
"http://192.168.31.21:8083/*"
|
"http://192.168.31.21:8083/*",
|
||||||
|
"https://*/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+208
-5
@@ -19,10 +19,74 @@
|
|||||||
<p>\u5DF2\u767B\u5F55</p>
|
<p>\u5DF2\u767B\u5F55</p>
|
||||||
<p>${userInfo?.name ?? userInfo?.username ?? "\u672A\u77E5\u7528\u6237"}</p>
|
<p>${userInfo?.name ?? userInfo?.username ?? "\u672A\u77E5\u7528\u6237"}</p>
|
||||||
<p>${userInfo?.email ?? ""}</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>
|
<button type="button" data-popup-sign-out="button">\u9000\u51FA\u767B\u5F55</button>
|
||||||
</section>
|
</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) {
|
function renderDevPanel(root, authState) {
|
||||||
const panel = root.ownerDocument.createElement("section");
|
const panel = root.ownerDocument.createElement("section");
|
||||||
panel.dataset.popupDevPanel = "root";
|
panel.dataset.popupDevPanel = "root";
|
||||||
@@ -134,10 +198,78 @@
|
|||||||
return response.value.accessToken;
|
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
|
// src/popup/index.ts
|
||||||
async function bootPopup(options = {}) {
|
async function bootPopup(options = {}) {
|
||||||
const currentDocument = options.document ?? document;
|
const currentDocument = options.document ?? document;
|
||||||
const popupConfig = readAuthConfig(options.config);
|
const popupConfig = readAuthConfig(options.config);
|
||||||
|
const currentVersion = options.currentVersion ?? readCurrentVersion();
|
||||||
const root = currentDocument.querySelector("#app");
|
const root = currentDocument.querySelector("#app");
|
||||||
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
||||||
if (!root || HTMLElementCtor && !(root instanceof HTMLElementCtor)) {
|
if (!root || HTMLElementCtor && !(root instanceof HTMLElementCtor)) {
|
||||||
@@ -150,9 +282,15 @@
|
|||||||
baseUrl: "http://127.0.0.1:4319",
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
sendMessage
|
sendMessage
|
||||||
}).loadProtectedMockData;
|
}).loadProtectedMockData;
|
||||||
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi);
|
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) {
|
async function renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, updateOptions) {
|
||||||
const response = await sendMessage({ type: "auth:get-state" });
|
const response = await sendMessage({ type: "auth:get-state" });
|
||||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||||
renderLoggedOut(root, "\u8BA4\u8BC1\u72B6\u6001\u8BFB\u53D6\u5931\u8D25");
|
renderLoggedOut(root, "\u8BA4\u8BC1\u72B6\u6001\u8BFB\u53D6\u5931\u8D25");
|
||||||
@@ -163,16 +301,19 @@
|
|||||||
root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => {
|
root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => {
|
||||||
void runAuthAction(root, popupConfig, sendMessage, {
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
actionMessage: { type: "auth:sign-in" },
|
actionMessage: { type: "auth:sign-in" },
|
||||||
fetchProtectedApi
|
fetchProtectedApi,
|
||||||
|
updateOptions
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderLoggedIn(root, response.value);
|
renderLoggedIn(root, response.value);
|
||||||
|
void runUpdateCheck(root, sendMessage, updateOptions);
|
||||||
root.querySelector('[data-popup-sign-out="button"]')?.addEventListener("click", () => {
|
root.querySelector('[data-popup-sign-out="button"]')?.addEventListener("click", () => {
|
||||||
void runAuthAction(root, popupConfig, sendMessage, {
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
actionMessage: { type: "auth:sign-out" },
|
actionMessage: { type: "auth:sign-out" },
|
||||||
fetchProtectedApi
|
fetchProtectedApi,
|
||||||
|
updateOptions
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (popupConfig.enableDevAuthPanel) {
|
if (popupConfig.enableDevAuthPanel) {
|
||||||
@@ -195,12 +336,74 @@
|
|||||||
root,
|
root,
|
||||||
popupConfig,
|
popupConfig,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
options.fetchProtectedApi
|
options.fetchProtectedApi,
|
||||||
|
options.updateOptions
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function isActionError(response) {
|
function isActionError(response) {
|
||||||
return isAuthResponseMessage(response) && !response.ok && response.type === "auth:error";
|
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) {
|
async function runProtectedApiProbe(root, fetchProtectedApi) {
|
||||||
setProtectedApiResult(root, "\u8BF7\u6C42\u4E2D...");
|
setProtectedApiResult(root, "\u8BF7\u6C42\u4E2D...");
|
||||||
try {
|
try {
|
||||||
|
|||||||
+80
-42
@@ -190,15 +190,14 @@ https://xingtu.cn/ad/creator/market
|
|||||||
|
|
||||||
在星图页面中,插件会新增一组自己的操作区,常见元素包括:
|
在星图页面中,插件会新增一组自己的操作区,常见元素包括:
|
||||||
|
|
||||||
- 导出范围下拉框
|
- `导出选中达人数据` 按钮
|
||||||
- `导出CSV` 按钮
|
|
||||||
- `提交批次` 按钮
|
- `提交批次` 按钮
|
||||||
- 状态提示文字
|
- 状态提示文字
|
||||||
- 每一行达人前面的勾选框
|
- 每一行达人前面的勾选框
|
||||||
|
|
||||||
其中:
|
其中:
|
||||||
|
|
||||||
- `导出CSV`:把当前选择的数据导出为表格文件
|
- `导出选中达人数据`:导出已勾选达人,包含内容数据、效果预估、画像等维度
|
||||||
- `提交批次`:把当前选择的数据提交为批次
|
- `提交批次`:把当前选择的数据提交为批次
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -215,37 +214,67 @@ https://xingtu.cn/ad/creator/market
|
|||||||
规则说明:
|
规则说明:
|
||||||
|
|
||||||
- 如果你勾选了达人,再点击导出或提交,系统会优先处理你勾选的这些达人
|
- 如果你勾选了达人,再点击导出或提交,系统会优先处理你勾选的这些达人
|
||||||
- 如果你没有勾选任何达人,则默认按当前导出范围处理全部达人
|
- 导出达人数据必须先勾选达人
|
||||||
|
- 提交批次时,如果你没有勾选任何达人,则默认处理当前列表范围内的达人
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、导出 CSV 的方法
|
## 九、导出达人数据 CSV 的方法
|
||||||
|
|
||||||
### 1. 选择导出范围
|
导出达人数据 CSV 用来导出内容数据、效果预估、画像和秒思 api 数据。
|
||||||
|
|
||||||
在插件工具栏中,你会看到导出范围下拉框,可选:
|
### 1. 勾选达人
|
||||||
|
|
||||||
- `当前页`
|
导出达人数据必须先勾选达人。
|
||||||
- `前5页`
|
|
||||||
- `前10页`
|
|
||||||
- `全部`
|
|
||||||
- `自定义`
|
|
||||||
|
|
||||||
如果选择 `自定义`,需要再输入页数。
|
原因:
|
||||||
|
|
||||||
### 2. 如果只想导出部分达人
|
- 导出达人数据会额外读取达人详情页数据
|
||||||
|
- 为了避免请求太多,只处理你勾选的达人
|
||||||
|
|
||||||
先勾选你想要的达人,再点击导出。
|
### 2. 可选:选择需要导出的字段
|
||||||
|
|
||||||
这样导出的 CSV 只会包含你勾选的人。
|
如果只想导出一部分字段,点击:
|
||||||
|
|
||||||
|
- `选择字段`
|
||||||
|
|
||||||
|
在弹出的窗口里:
|
||||||
|
|
||||||
|
1. 勾选需要的字段
|
||||||
|
2. 取消不需要的字段
|
||||||
|
3. 点击 `保存`
|
||||||
|
|
||||||
|
基础字段会固定保留,例如:
|
||||||
|
|
||||||
|
- 达人ID
|
||||||
|
- 达人名称
|
||||||
|
- 导出状态
|
||||||
|
- 失败原因
|
||||||
|
|
||||||
|
保存后,下次再导出 CSV,会自动沿用这次勾选结果,不需要重新勾选。
|
||||||
|
|
||||||
### 3. 点击导出
|
### 3. 点击导出
|
||||||
|
|
||||||
点击:
|
点击:
|
||||||
|
|
||||||
- `导出CSV`
|
- `导出选中达人数据`
|
||||||
|
|
||||||
### 4. 等待导出完成
|
如果已经有达人星图 ID 列表,也可以点击:
|
||||||
|
|
||||||
|
- `按星图ID导出`
|
||||||
|
|
||||||
|
然后把达人 ID 粘贴进去,每行一个。
|
||||||
|
|
||||||
|
### 4. 导出内容
|
||||||
|
|
||||||
|
CSV 会包含:
|
||||||
|
|
||||||
|
- 内容数据
|
||||||
|
- 效果预估
|
||||||
|
- 画像
|
||||||
|
- 秒思 api 数据
|
||||||
|
|
||||||
|
### 5. 等待导出完成
|
||||||
|
|
||||||
页面上会显示状态,例如:
|
页面上会显示状态,例如:
|
||||||
|
|
||||||
@@ -264,29 +293,19 @@ https://xingtu.cn/ad/creator/market
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十、提交批次的方法
|
## 十一、提交批次的方法
|
||||||
|
|
||||||
### 1. 选择范围
|
### 1. 如果只想提交部分达人
|
||||||
|
|
||||||
和导出一样,先选择导出范围:
|
|
||||||
|
|
||||||
- 当前页
|
|
||||||
- 前5页
|
|
||||||
- 前10页
|
|
||||||
- 全部
|
|
||||||
- 自定义
|
|
||||||
|
|
||||||
### 2. 如果只想提交部分达人
|
|
||||||
|
|
||||||
先勾选想提交的达人。
|
先勾选想提交的达人。
|
||||||
|
|
||||||
### 3. 点击提交
|
### 2. 点击提交
|
||||||
|
|
||||||
点击:
|
点击:
|
||||||
|
|
||||||
- `提交批次`
|
- `提交批次`
|
||||||
|
|
||||||
### 4. 输入批次名称
|
### 3. 输入批次名称
|
||||||
|
|
||||||
这时会弹出一个自定义输入框,不再是浏览器原生弹窗。
|
这时会弹出一个自定义输入框,不再是浏览器原生弹窗。
|
||||||
|
|
||||||
@@ -303,7 +322,7 @@ https://xingtu.cn/ad/creator/market
|
|||||||
- `食品饮料-KOL测试批次`
|
- `食品饮料-KOL测试批次`
|
||||||
- `5月女装达人候选`
|
- `5月女装达人候选`
|
||||||
|
|
||||||
### 5. 确认提交
|
### 4. 确认提交
|
||||||
|
|
||||||
输入后点击:
|
输入后点击:
|
||||||
|
|
||||||
@@ -311,7 +330,7 @@ https://xingtu.cn/ad/creator/market
|
|||||||
|
|
||||||
也可以直接按回车提交。
|
也可以直接按回车提交。
|
||||||
|
|
||||||
### 6. 提交成功的提示
|
### 5. 提交成功的提示
|
||||||
|
|
||||||
如果成功,页面状态会显示:
|
如果成功,页面状态会显示:
|
||||||
|
|
||||||
@@ -321,7 +340,7 @@ https://xingtu.cn/ad/creator/market
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十一、批次名称填写建议
|
## 十二、批次名称填写建议
|
||||||
|
|
||||||
建议使用容易看懂的命名方式:
|
建议使用容易看懂的命名方式:
|
||||||
|
|
||||||
@@ -348,14 +367,33 @@ https://xingtu.cn/ad/creator/market
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十二、如何更新插件
|
## 十三、如何更新插件
|
||||||
|
|
||||||
|
插件弹窗会检查是否有新版本。
|
||||||
|
|
||||||
|
### 方法一:从插件弹窗下载新版本
|
||||||
|
|
||||||
|
1. 点击浏览器右上角的插件图标
|
||||||
|
2. 查看 `版本更新` 区域
|
||||||
|
3. 如果提示发现新版本,点击:
|
||||||
|
- `下载更新包`
|
||||||
|
- `下载使用说明`
|
||||||
|
4. 解压下载到的新版本 zip
|
||||||
|
5. 打开:
|
||||||
|
- `chrome://extensions`
|
||||||
|
6. 找到:
|
||||||
|
- `Star Chart Search Enhancer`
|
||||||
|
7. 点击:
|
||||||
|
- `重新加载`
|
||||||
|
|
||||||
|
如果没有看到新版本提示,可能是网络暂时无法访问更新清单,也可能当前已经是最新版本。
|
||||||
|
|
||||||
|
### 方法二:收到压缩包后手动更新
|
||||||
|
|
||||||
当你收到新的插件压缩包时,不需要重新从零安装。
|
当你收到新的插件压缩包时,不需要重新从零安装。
|
||||||
|
|
||||||
按照下面做:
|
按照下面做:
|
||||||
|
|
||||||
### 方法一:替换文件夹后重新加载
|
|
||||||
|
|
||||||
1. 删除旧的解压文件夹,或用新的内容覆盖旧文件夹
|
1. 删除旧的解压文件夹,或用新的内容覆盖旧文件夹
|
||||||
2. 打开:
|
2. 打开:
|
||||||
- `chrome://extensions`
|
- `chrome://extensions`
|
||||||
@@ -364,7 +402,7 @@ https://xingtu.cn/ad/creator/market
|
|||||||
4. 点击:
|
4. 点击:
|
||||||
- `重新加载`
|
- `重新加载`
|
||||||
|
|
||||||
### 方法二:重新解压到新文件夹再重新加载
|
### 方法三:重新解压到新文件夹再重新加载
|
||||||
|
|
||||||
1. 解压新的压缩包
|
1. 解压新的压缩包
|
||||||
2. 打开:
|
2. 打开:
|
||||||
@@ -380,7 +418,7 @@ https://xingtu.cn/ad/creator/market
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十三、常见问题
|
## 十四、常见问题
|
||||||
|
|
||||||
### 1. 看不到插件按钮
|
### 1. 看不到插件按钮
|
||||||
|
|
||||||
@@ -465,8 +503,8 @@ https://xingtu.cn/ad/creator/market
|
|||||||
3. 点击插件图标确认登录状态
|
3. 点击插件图标确认登录状态
|
||||||
4. 打开星图达人市场页
|
4. 打开星图达人市场页
|
||||||
5. 等待页面数据加载完成
|
5. 等待页面数据加载完成
|
||||||
6. 先勾选需要的人,或直接选择范围
|
6. 先勾选需要的人
|
||||||
7. 需要表格时点 `导出CSV`
|
7. 需要表格时点 `导出选中达人数据`
|
||||||
8. 需要进入后续流程时点 `提交批次`
|
8. 需要进入后续流程时点 `提交批次`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -13,7 +13,59 @@
|
|||||||
|
|
||||||
1. Run `npm test`.
|
1. Run `npm test`.
|
||||||
2. Run `npm run package:internal`.
|
2. Run `npm run package:internal`.
|
||||||
3. Send `release/star-chart-search-enhancer-internal.zip` to coworkers.
|
3. Run `npm run write:latest`.
|
||||||
|
4. Send `release/star-chart-search-enhancer-internal.zip` to coworkers.
|
||||||
|
|
||||||
|
## COS Update Manifest
|
||||||
|
|
||||||
|
The popup checks `src/shared/update-config.ts` for the update manifest URL.
|
||||||
|
|
||||||
|
Before publishing the COS-based update flow:
|
||||||
|
|
||||||
|
1. Upload these files to COS:
|
||||||
|
- `star-chart-search-enhancer/latest.json`
|
||||||
|
- `star-chart-search-enhancer/releases/<version>/star-chart-search-enhancer-internal.zip`
|
||||||
|
- `star-chart-search-enhancer/releases/<version>/星图增强插件-超简单安装使用指南.pdf`
|
||||||
|
2. Make the COS path publicly readable.
|
||||||
|
3. Replace the placeholder `UPDATE_MANIFEST_URL` in `src/shared/update-config.ts` if your COS bucket changes.
|
||||||
|
4. Rebuild and package the extension.
|
||||||
|
|
||||||
|
The release manifest can be generated with a real public base URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
UPDATE_PUBLIC_BASE_URL="https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/<version>" npm run write:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Quick access check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Drone Release Flow
|
||||||
|
|
||||||
|
Tag the repo to trigger the release pipeline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag 0.0525.1
|
||||||
|
git push origin 0.0525.1
|
||||||
|
```
|
||||||
|
|
||||||
|
The Drone job will:
|
||||||
|
|
||||||
|
1. Run `npm ci`.
|
||||||
|
2. Run `npm test`.
|
||||||
|
3. Run `npm run release:tag`.
|
||||||
|
4. Build the release bundle.
|
||||||
|
5. Write `release/latest.json`.
|
||||||
|
6. Upload `latest.json`, the ZIP, and the PDF to COS.
|
||||||
|
|
||||||
|
Drone secrets required:
|
||||||
|
|
||||||
|
- `cos_secret_id`
|
||||||
|
- `cos_secret_key`
|
||||||
|
|
||||||
|
The pipeline uses the tag as the release version. Recommended format: `0.MMDD.N`.
|
||||||
|
|
||||||
## Coworker Install Steps
|
## Coworker Install Steps
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# Market Audience Profile Export Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a selected-creators-only `导出画像CSV` flow that exports current market columns plus detail-page "连接用户" audience profile distributions.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing CSV export untouched and add a separate profile export path. The content controller reuses current selection and market row hydration, loads one selected creator profile at a time through a focused detail-page profile client, then writes a separate CSV with structured audience columns.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Chrome MV3 content scripts, Xingtu authenticated pages, Vitest, jsdom, tsup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Modify: `src/background/auth/controller.ts`
|
||||||
|
- Keep token-readable auth state behavior from the previous fix.
|
||||||
|
- Modify: `src/background/auth/state.ts`
|
||||||
|
- Keep logged-out `lastError` support from the previous fix.
|
||||||
|
- Modify: `tests/background-auth-controller.test.ts`
|
||||||
|
- Keep token-expired regression coverage.
|
||||||
|
- Modify: `src/content/market/auth-gate.ts`
|
||||||
|
- Render expired-login text when auth state carries a token-expired error.
|
||||||
|
- Modify: `src/content/index.ts`
|
||||||
|
- Pass auth failure text into the market auth gate if needed.
|
||||||
|
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||||
|
- Add a `导出画像CSV` button and handler.
|
||||||
|
- Create: `src/content/market/audience-profile-types.ts`
|
||||||
|
- Define normalized distribution and export-row types.
|
||||||
|
- Create: `src/content/market/audience-profile-client.ts`
|
||||||
|
- Load one creator detail page and extract normalized audience profile data.
|
||||||
|
- Create: `src/content/market/audience-profile-csv.ts`
|
||||||
|
- Build CSV columns from market records plus profile distributions.
|
||||||
|
- Modify: `src/content/market/index.ts`
|
||||||
|
- Add selected-only profile export flow and serial profile loading.
|
||||||
|
- Test: `tests/market-auth-gating.test.ts`
|
||||||
|
- Verify expired-login text.
|
||||||
|
- Test: `tests/plugin-toolbar.test.ts`
|
||||||
|
- Verify new toolbar button wiring.
|
||||||
|
- Test: `tests/audience-profile-csv.test.ts`
|
||||||
|
- Verify structured CSV column expansion.
|
||||||
|
- Test: `tests/audience-profile-client.test.ts`
|
||||||
|
- Verify parser behavior against representative detail-page payload/state shapes.
|
||||||
|
- Modify: `tests/market-content-entry.test.ts`
|
||||||
|
- Verify selected-only export behavior and failure handling.
|
||||||
|
|
||||||
|
## Task 1: Expired Login Message
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/content/market/auth-gate.ts`
|
||||||
|
- Modify: `src/content/index.ts`
|
||||||
|
- Test: `tests/market-auth-gating.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing auth gate test**
|
||||||
|
|
||||||
|
Add a test where `sendAuthMessage` returns:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: false,
|
||||||
|
lastError: "Token 已过期"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Assert the page shows `登录已过期,请重新登录`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the failing test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/market-auth-gating.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the gate only renders `请先登录插件`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal auth gate text support**
|
||||||
|
|
||||||
|
Update `renderMarketAuthGate` to accept an optional message string and render it instead of the default title. Update `bootContentScript` to pass `登录已过期,请重新登录` when `lastError` contains `token` or `过期`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/market-auth-gating.test.ts tests/popup-entry.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 2: Toolbar Button
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/content/market/plugin-toolbar.ts`
|
||||||
|
- Test: `tests/plugin-toolbar.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing toolbar tests**
|
||||||
|
|
||||||
|
Create tests that:
|
||||||
|
|
||||||
|
- render the toolbar
|
||||||
|
- assert a button with text `导出画像CSV` exists
|
||||||
|
- click it and assert `onExportAudienceProfile` was called
|
||||||
|
- assert `setToolbarBusyState` disables the new button
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the failing tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/plugin-toolbar.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the button and handler do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement toolbar support**
|
||||||
|
|
||||||
|
Add `onExportAudienceProfile` to `PluginToolbarHandlers`, add `audienceProfileExportButton` to `PluginToolbarDom`, render the new button, wire click handling, and include it in busy-state disabling.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/plugin-toolbar.test.ts tests/market-content-entry.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 3: Profile CSV Builder
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/content/market/audience-profile-types.ts`
|
||||||
|
- Create: `src/content/market/audience-profile-csv.ts`
|
||||||
|
- Test: `tests/audience-profile-csv.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing CSV tests**
|
||||||
|
|
||||||
|
Define a sample market record and sample profile:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const profile = {
|
||||||
|
status: "success",
|
||||||
|
gender: [
|
||||||
|
{ label: "男性", value: "40.6%" },
|
||||||
|
{ label: "女性", value: "59.4%" }
|
||||||
|
],
|
||||||
|
age: [{ label: "18-23", value: "28.6%" }],
|
||||||
|
province: [{ label: "广东", value: "15%" }],
|
||||||
|
regionTop: [{ label: "北京", value: "15%" }],
|
||||||
|
cityTier: [{ label: "一线", value: "20%" }],
|
||||||
|
interestTop: [{ label: "亲子", value: "18%" }],
|
||||||
|
crowd: [{ label: "精致妈妈", value: "12%" }]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Assert the CSV contains separate headers like `连接用户-男性占比`, `省份-广东占比`, `地域TOP1名称`, `地域TOP1占比`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the failing tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/audience-profile-csv.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the files do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement CSV builder**
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
- `AudienceProfileDistributionItem`
|
||||||
|
- `AudienceProfileResult`
|
||||||
|
- `buildAudienceProfileCsv(records, profilesByAuthorId)`
|
||||||
|
|
||||||
|
Reuse `escapeCsvCell` and existing base/rate/backend metric column conventions. Add `画像抓取状态`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/audience-profile-csv.test.ts tests/csv-exporter.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 4: Detail Profile Client and Parser
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/content/market/audience-profile-client.ts`
|
||||||
|
- Test: `tests/audience-profile-client.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Use a logged-in browser to identify the real data source**
|
||||||
|
|
||||||
|
Run a Playwright probe against an authenticated `https://xingtu.cn/ad/creator/author-homepage/douyin-video/<authorId>` page. Capture only `/gw/api/...` JSON responses and page Vue/ECharts state. Record representative payload/state samples in the test file as small fixtures.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write failing parser tests**
|
||||||
|
|
||||||
|
Use the captured fixture to assert the parser returns normalized arrays for gender, age, province, region top 10, city tier, interest top 10, and crowd.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the failing tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/audience-profile-client.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the client/parser does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement parser and client**
|
||||||
|
|
||||||
|
Implement a small parser first. Then implement the client with injectable dependencies:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
createAudienceProfileClient({
|
||||||
|
fetchDetailPage?: (authorId: string) => Promise<unknown>;
|
||||||
|
readProfileFromPage?: (authorId: string) => Promise<unknown>;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer parsed API JSON. Fall back to page state when API JSON is unavailable.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/audience-profile-client.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 5: Controller Export Flow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/content/market/index.ts`
|
||||||
|
- Test: `tests/market-content-entry.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing selected-only export tests**
|
||||||
|
|
||||||
|
Add tests that:
|
||||||
|
|
||||||
|
- select one of two visible rows
|
||||||
|
- click `导出画像CSV`
|
||||||
|
- assert only the selected author profile is requested
|
||||||
|
- assert `onCsvReady` receives a CSV containing profile columns
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write failing no-selection test**
|
||||||
|
|
||||||
|
Assert clicking `导出画像CSV` with no selected rows sets status to `请先勾选需要导出画像的达人` and makes no profile requests.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write failing partial-failure test**
|
||||||
|
|
||||||
|
Mock two selected profiles where one succeeds and one fails. Assert CSV is still generated with one success row and one `画像抓取状态=失败` row.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run failing tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/market-content-entry.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the controller has no profile export flow.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement controller flow**
|
||||||
|
|
||||||
|
Add an injected option `loadAudienceProfile?: (record: MarketRecord) => Promise<AudienceProfileResult>`. Add handler:
|
||||||
|
|
||||||
|
- sync selected state from DOM
|
||||||
|
- reject empty selection
|
||||||
|
- hydrate current-page selected records
|
||||||
|
- load profiles serially
|
||||||
|
- cache successful results by author ID
|
||||||
|
- build profile CSV
|
||||||
|
- call `onCsvReady`
|
||||||
|
- update toolbar progress/status
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/market-content-entry.test.ts tests/audience-profile-csv.test.ts tests/plugin-toolbar.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 6: Full Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify only if failures identify necessary scoped fixes.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused suite**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/market-auth-gating.test.ts tests/plugin-toolbar.test.ts tests/audience-profile-csv.test.ts tests/audience-profile-client.test.ts tests/market-content-entry.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run all tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual logged-in browser verification**
|
||||||
|
|
||||||
|
Load the built extension in Chrome, select one or two market creators, click `导出画像CSV`, and verify the downloaded CSV contains structured profile columns with detail-page data.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# COS Extension Update Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Point the popup update flow at the real COS release bucket and keep the generated release manifest, docs, and tests aligned with that COS-based distribution path.
|
||||||
|
|
||||||
|
**Architecture:** Reuse the existing update-check flow already in `src/shared/update-check.ts`, `src/popup/index.ts`, `src/popup/view.ts`, and `src/background/index.ts`. The only behavior change is the source of truth: the popup should fetch a stable COS-hosted `latest.json`, while `scripts/write-latest-manifest.mjs` should keep generating versioned asset URLs under the COS release folder. Everything else stays manual and user-driven.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Chrome MV3, Vitest, Node.js ESM scripts, Tencent COS public HTTPS URLs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Lock the popup manifest URL to COS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/shared/update-config.ts`
|
||||||
|
- Test: `tests/update-config.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing URL test**
|
||||||
|
|
||||||
|
Add a small test that asserts `UPDATE_MANIFEST_URL` points at the stable COS-hosted `latest.json` URL for this bucket:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(UPDATE_MANIFEST_URL).toBe(
|
||||||
|
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/update-config.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the current constant still uses the placeholder example URL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the constant**
|
||||||
|
|
||||||
|
Replace the placeholder string in `src/shared/update-config.ts` with the COS `latest.json` URL above.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/update-config.test.ts tests/popup-entry.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 2: Generate release assets from the COS base
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/write-latest-manifest.mjs`
|
||||||
|
- Modify: `release/latest.json`
|
||||||
|
- Test: `tests/update-check.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a manifest generation regression test**
|
||||||
|
|
||||||
|
Add or extend a test that proves the generated manifest uses the COS release base for assets, not `example.com`. Use the COS base:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Assert that `zipUrl` and `guideUrl` are derived from that base.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/update-check.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL until the generator default points at COS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the generator default**
|
||||||
|
|
||||||
|
Change `publicBaseUrl` in `scripts/write-latest-manifest.mjs` to default to the COS release base for this bucket and region, while keeping `UPDATE_PUBLIC_BASE_URL` as the override path for future releases.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Regenerate the tracked manifest**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run write:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then confirm `release/latest.json` contains the COS URLs.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/update-check.test.ts tests/popup-entry.test.ts tests/background-index.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Update distribution docs and verify COS access
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/internal-extension-distribution.md`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the release instructions**
|
||||||
|
|
||||||
|
Document the stable manifest URL, the versioned asset base, and the upload flow to COS. Keep the user-facing manual install steps unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the COS verification command**
|
||||||
|
|
||||||
|
Document a `curl -I` check for the public `latest.json` URL and the uploaded ZIP/PDF so a failed COS ACL is caught before release.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the final verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run build:release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS, and the generated release bundle should still open the popup update card correctly.
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Market Audience Profile Export Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a separate CSV export for selected creators that includes the current market export fields plus audience profile data from each creator detail page's "连接用户" tab.
|
||||||
|
|
||||||
|
## User-Approved Decisions
|
||||||
|
|
||||||
|
- Add a new toolbar button named `导出画像CSV`.
|
||||||
|
- Keep the existing `导出CSV` behavior unchanged.
|
||||||
|
- Only allow the new export when at least one creator row is selected.
|
||||||
|
- Do not support "export all" for profile data in this change because detail-page data costs extra API/page loads.
|
||||||
|
- Suggested downloaded filename: `达人连接用户画像_YYYYMMDD_HHmm.csv`.
|
||||||
|
- Export profile distributions as separate structured CSV columns, not as JSON blobs.
|
||||||
|
|
||||||
|
## Data Scope
|
||||||
|
|
||||||
|
Each exported row represents one selected creator. Start with the current market CSV columns, then append audience profile columns for:
|
||||||
|
|
||||||
|
- 性别分布
|
||||||
|
- 年龄分布
|
||||||
|
- 全国省份分布
|
||||||
|
- 地域占比 TOP10
|
||||||
|
- 城市等级分布
|
||||||
|
- 兴趣分布
|
||||||
|
- 八大人群占比
|
||||||
|
|
||||||
|
Fixed distributions should become fixed columns, for example:
|
||||||
|
|
||||||
|
- `连接用户-男性占比`
|
||||||
|
- `连接用户-女性占比`
|
||||||
|
- `连接用户-18-23占比`
|
||||||
|
- `连接用户-24-30占比`
|
||||||
|
- `省份-广东占比`
|
||||||
|
- `城市等级-一线占比`
|
||||||
|
- `八大人群-精致妈妈占比`
|
||||||
|
|
||||||
|
Ranked distributions should become name/value column pairs:
|
||||||
|
|
||||||
|
- `地域TOP1名称`
|
||||||
|
- `地域TOP1占比`
|
||||||
|
- ...
|
||||||
|
- `地域TOP10名称`
|
||||||
|
- `地域TOP10占比`
|
||||||
|
- `兴趣TOP1名称`
|
||||||
|
- `兴趣TOP1占比`
|
||||||
|
- ...
|
||||||
|
- `兴趣TOP10名称`
|
||||||
|
- `兴趣TOP10占比`
|
||||||
|
|
||||||
|
Add a `画像抓取状态` column so partial failures are visible in CSV output.
|
||||||
|
|
||||||
|
## Data Acquisition
|
||||||
|
|
||||||
|
Use an on-demand detail-page probe. The implementation must first confirm the real data source from an authenticated creator detail page:
|
||||||
|
|
||||||
|
1. Prefer Xingtu `/gw/api/...` JSON responses if they expose the required profile data.
|
||||||
|
2. If the API payload is difficult to locate or unstable, read the detail page's Vue/ECharts state from the page context.
|
||||||
|
3. Avoid screen/OCR parsing and avoid relying on rendered chart pixels.
|
||||||
|
|
||||||
|
The export should process selected creators one at a time by default to respect API limits and reduce anti-abuse risk. Cache successful profile results in memory for the current page session.
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
Toolbar behavior:
|
||||||
|
|
||||||
|
- Add `导出画像CSV` next to the existing export actions.
|
||||||
|
- Disable the button while any export/submission action is running.
|
||||||
|
- If no creators are selected, show `请先勾选需要导出画像的达人`.
|
||||||
|
- While exporting, show progress such as `画像导出中 3/12...`.
|
||||||
|
- If plugin auth is expired, show `登录已过期,请重新登录`.
|
||||||
|
- If one creator fails, keep the row in the CSV with `画像抓取状态=失败` and leave profile columns empty.
|
||||||
|
- If all creators fail, do not download a CSV and show a failure message.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Add the feature as a separate export path instead of extending the existing `导出CSV` action. Reuse current selection state, market record hydration, CSV escaping, and runtime download path.
|
||||||
|
|
||||||
|
Proposed units:
|
||||||
|
|
||||||
|
- `audience-profile-client`: load and parse audience profile data for one creator detail page.
|
||||||
|
- `audience-profile-csv`: combine existing market CSV columns with profile-specific columns.
|
||||||
|
- Toolbar additions: add a button and handler for profile export.
|
||||||
|
- Controller additions: filter to selected creators, call the profile client serially, build the CSV, then reuse `onCsvReady`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use TDD. Add focused tests for:
|
||||||
|
|
||||||
|
- Auth state expired detection and user-facing expired-login text.
|
||||||
|
- Toolbar renders and wires the new `导出画像CSV` button.
|
||||||
|
- New export refuses to run without selected creators.
|
||||||
|
- Profile CSV expands fixed and ranked distributions into separate columns.
|
||||||
|
- Controller exports only selected creators and fetches profiles serially.
|
||||||
|
- Failed creator profile fetches produce a failed row while successful rows still export.
|
||||||
|
|
||||||
|
Run focused tests first, then full `npm test`, then `npm run build`.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Unselected/all-page profile export.
|
||||||
|
- Persistent cross-session profile cache.
|
||||||
|
- Visual dashboard UI for profile data.
|
||||||
|
- Changing batch submission payloads.
|
||||||
|
- OCR or screenshot-based chart extraction.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# COS Extension Update Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Use COS as the release source for extension updates. When the popup opens, it checks a public `latest.json` on COS. If the COS version is newer than the installed extension version, the popup shows an update card with download actions for the ZIP and the PDF guide, plus manual reload instructions.
|
||||||
|
|
||||||
|
## Confirmed Scope
|
||||||
|
|
||||||
|
- Update prompt appears only in the extension popup.
|
||||||
|
- No star chart page banner in this change.
|
||||||
|
- The user keeps the current manual install flow: download, unzip, replace the folder, then reload in `chrome://extensions`.
|
||||||
|
|
||||||
|
## Reusable Implementation
|
||||||
|
|
||||||
|
The current repo already has most of the flow:
|
||||||
|
|
||||||
|
- `src/shared/update-check.ts` parses the manifest and compares versions.
|
||||||
|
- `src/popup/index.ts` checks for updates when the popup boots.
|
||||||
|
- `src/popup/view.ts` renders the update status and download actions.
|
||||||
|
- `src/background/index.ts` downloads the ZIP/PDF through `chrome.downloads`.
|
||||||
|
- `scripts/write-latest-manifest.mjs` generates `release/latest.json`.
|
||||||
|
|
||||||
|
This change is mostly activation and configuration, not a rewrite.
|
||||||
|
|
||||||
|
## Manifest Contract
|
||||||
|
|
||||||
|
The public COS manifest must keep these fields:
|
||||||
|
|
||||||
|
- `latestVersion`
|
||||||
|
- `minSupportedVersion`
|
||||||
|
- `publishedAt`
|
||||||
|
- `releaseNotes`
|
||||||
|
- `zipUrl`
|
||||||
|
- `guideUrl`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- All asset URLs must be public HTTPS URLs.
|
||||||
|
- Version comparison stays numeric dotted comparison.
|
||||||
|
- Popup logic only needs `latestVersion` to decide whether to show the update card.
|
||||||
|
- `minSupportedVersion` stays in the manifest for forward compatibility.
|
||||||
|
|
||||||
|
## COS Layout
|
||||||
|
|
||||||
|
Use a fixed release layout like:
|
||||||
|
|
||||||
|
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/latest.json`
|
||||||
|
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/star-chart-search-enhancer-internal.zip`
|
||||||
|
- `https://<cos-domain>/star-chart-search-enhancer/releases/<version>/星图增强插件-超简单安装使用指南.pdf`
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
1. User opens the popup.
|
||||||
|
2. Popup reads the current extension version.
|
||||||
|
3. Popup fetches COS `latest.json` with `no-store`.
|
||||||
|
4. If `latestVersion` is not newer, show “当前已是最新版本”.
|
||||||
|
5. If `latestVersion` is newer, show “发现新版本” plus release notes.
|
||||||
|
6. User clicks:
|
||||||
|
- `下载更新包` for the ZIP
|
||||||
|
- `下载使用说明` for the PDF
|
||||||
|
7. Popup shows the manual upgrade instructions after download starts.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If the manifest is missing, invalid, or unreachable, the popup should show a non-blocking update error.
|
||||||
|
- If download fails, the popup should show a download error and keep the plugin usable.
|
||||||
|
- Update check failures must not block auth or normal plugin behavior.
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
1. Build the release package.
|
||||||
|
2. Package the internal ZIP.
|
||||||
|
3. Generate `latest.json` with the real COS base URL.
|
||||||
|
4. Upload `latest.json`, the ZIP, and the PDF to the COS folder.
|
||||||
|
5. Replace the placeholder manifest URL in `src/shared/update-config.ts`.
|
||||||
|
6. Rebuild and verify the popup update card.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Automatic in-place extension updates.
|
||||||
|
- Auto-reload after download.
|
||||||
|
- Star chart page update prompts.
|
||||||
|
- Chrome Web Store publishing.
|
||||||
|
|
||||||
+47
-9
@@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
4. 点击左上角出现的 **"加载已解压的扩展程序"**
|
4. 点击左上角出现的 **"加载已解压的扩展程序"**
|
||||||
|
|
||||||
5. 选择刚才解压出来的文件夹里的 **`dist-release`** 文件夹
|
5. 选择刚才解压出来的插件文件夹
|
||||||
|
|
||||||
⚠️ **重要**:必须选择 `dist-release` 这个子文件夹,不要选外层文件夹
|
⚠️ **重要**:如果文件夹里能看到 `manifest.json`、`content`、`background`、`popup` 这些文件和文件夹,说明选对了。
|
||||||
|
|
||||||
6. 看到绿色的插件卡片出现,就装好了!
|
6. 看到绿色的插件卡片出现,就装好了!
|
||||||
|
|
||||||
@@ -72,20 +72,58 @@ https://xingtu.cn/ad/creator/market
|
|||||||
|
|
||||||
## 📝 主要功能
|
## 📝 主要功能
|
||||||
|
|
||||||
### 1️⃣ 导出 Excel 表格
|
### 1️⃣ 导出达人数据(CSV)
|
||||||
|
|
||||||
- 勾选你想导出的达人(不勾就选全部)
|
当你需要导出达人的内容数据、效果预估、画像时使用:
|
||||||
- 选择范围:当前页 / 前5页 / 全部
|
|
||||||
- 点击 **"导出CSV"**
|
- 先勾选你想导出数据的达人
|
||||||
|
- 点击 **"导出选中达人数据"**
|
||||||
|
- 等待下载完成
|
||||||
- 文件自动下载到电脑的"下载"文件夹
|
- 文件自动下载到电脑的"下载"文件夹
|
||||||
|
|
||||||
### 2️⃣ 提交批次
|
⚠️ **重要**:导出达人数据必须先勾选达人,因为它会额外请求达人详情页数据,不能默认导出全部。
|
||||||
|
|
||||||
|
- 内容数据:个人视频/星图视频的播放量中位数、完播率、互动率、发布作品、平均时长、平均点赞、平均评论、平均转发
|
||||||
|
- 效果预估:不同视频时长的预期CPM、预期CPE、预期播放量、爆文率
|
||||||
|
- 观众画像、粉丝画像、铁粉画像
|
||||||
|
- 秒思api数据:看后搜率、看后搜数、新增A3数、新增A3率、CPA3、cp_search
|
||||||
|
|
||||||
|
**只导出部分字段**:
|
||||||
|
- 点击 **"选择字段"**
|
||||||
|
- 勾选你需要的字段,取消不需要的字段
|
||||||
|
- 点击 **"保存"**
|
||||||
|
- 再点击 **"导出选中达人数据"** 或 **"按星图ID导出"**
|
||||||
|
|
||||||
|
说明:达人ID、达人名称、导出状态、失败原因等基础字段会固定保留;你保存过一次后,下次导出会自动沿用这次勾选结果,不需要重新勾选。
|
||||||
|
|
||||||
|
### 2️⃣ 按ID导出达人数据
|
||||||
|
|
||||||
|
当你想批量查询特定达人ID的数据时使用:
|
||||||
|
|
||||||
|
- 点击 **"按星图ID导出"**
|
||||||
|
- 在弹出的对话框中输入达人ID(每行一个)
|
||||||
|
- 点击确认
|
||||||
|
- 等待下载完成
|
||||||
|
|
||||||
|
**适用场景**:已知一批达人星图ID,需要批量导出这些达人的CSV。
|
||||||
|
|
||||||
|
### 3️⃣ 提交批次
|
||||||
|
|
||||||
- 勾选你想提交的达人
|
- 勾选你想提交的达人
|
||||||
- 点击 **"提交批次"**
|
- 点击 **"提交批次"**
|
||||||
- 输入批次名称(例如:`5月母婴达人第一批`)
|
- 输入批次名称(例如:`5月母婴达人第一批`)
|
||||||
- 点击确认
|
- 点击确认
|
||||||
|
|
||||||
|
### 4️⃣ 更新插件
|
||||||
|
|
||||||
|
- 点击浏览器右上角的插件图标
|
||||||
|
- 在 **"版本更新"** 区域查看是否有新版本
|
||||||
|
- 如果提示发现新版本,点击 **"下载更新包"** 和 **"下载使用说明"**
|
||||||
|
- 解压下载到的新版本 zip
|
||||||
|
- 打开 `chrome://extensions`
|
||||||
|
- 找到 `Star Chart Search Enhancer`
|
||||||
|
- 点击 **"重新加载"**,或重新选择解压后的新插件文件夹
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔄 如何更新插件
|
## 🔄 如何更新插件
|
||||||
@@ -100,7 +138,7 @@ https://xingtu.cn/ad/creator/market
|
|||||||
⚠️ **如果重新加载后还是旧版本**:
|
⚠️ **如果重新加载后还是旧版本**:
|
||||||
- 先点击插件卡片的 **"移除"** 删除旧版本
|
- 先点击插件卡片的 **"移除"** 删除旧版本
|
||||||
- 然后重新点击 **"加载已解压的扩展程序"**
|
- 然后重新点击 **"加载已解压的扩展程序"**
|
||||||
- 再次选择 `dist-release` 文件夹
|
- 再次选择新解压出来的插件文件夹
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -136,4 +174,4 @@ A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
|
|||||||
2. 页面截图
|
2. 页面截图
|
||||||
3. 扩展 ID(从 chrome://extensions 里看)
|
3. 扩展 ID(从 chrome://extensions 里看)
|
||||||
|
|
||||||
**记住正确的 ID:`**pkjopdibdnomhogjheclhnknmejccffg**`
|
**记住正确的 ID:`pkjopdibdnomhogjheclhnknmejccffg`**
|
||||||
|
|||||||
Generated
+891
-1
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -7,17 +7,21 @@
|
|||||||
"build": "node scripts/build.mjs",
|
"build": "node scripts/build.mjs",
|
||||||
"build:release": "BUILD_TARGET=release node scripts/build.mjs",
|
"build:release": "BUILD_TARGET=release node scripts/build.mjs",
|
||||||
"mock:protected-api": "node scripts/mock-protected-api.mjs",
|
"mock:protected-api": "node scripts/mock-protected-api.mjs",
|
||||||
|
"release:tag": "node scripts/ci/release-tag.mjs",
|
||||||
"package:internal": "npm run build:release && node scripts/package-release.mjs",
|
"package:internal": "npm run build:release && node scripts/package-release.mjs",
|
||||||
"package:release": "npm run build:release && node scripts/package-release.mjs",
|
"package:release": "npm run build:release && node scripts/package-release.mjs",
|
||||||
|
"write:latest": "node scripts/write-latest-manifest.mjs",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:watch": "vitest --passWithNoTests"
|
"test:watch": "vitest --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@logto/chrome-extension": "^0.1.27"
|
"@logto/chrome-extension": "^0.1.27",
|
||||||
|
"yazl": "^3.3.1"
|
||||||
},
|
},
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"cos-nodejs-sdk-v5": "^2.15.4",
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"guideUrl": "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.0525.6/星图增强插件-超简单安装使用指南.pdf",
|
||||||
|
"latestVersion": "0.0525.6",
|
||||||
|
"minSupportedVersion": "0.0525.6",
|
||||||
|
"publishedAt": "2026-05-25",
|
||||||
|
"releaseNotes": [
|
||||||
|
"支持在插件弹窗中检查新版本",
|
||||||
|
"支持一键下载最新版插件压缩包和使用说明"
|
||||||
|
],
|
||||||
|
"zipUrl": "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.0525.6/star-chart-search-enhancer-internal.zip"
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,71 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
import { resolveReleaseVersion } from "../release-version.mjs";
|
||||||
|
import { uploadReleaseAssets } from "./upload-release-assets.mjs";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export async function runReleaseTagPipeline(env = process.env) {
|
||||||
|
const projectRoot = resolveProjectRoot();
|
||||||
|
const releaseVersion = resolveReleaseVersion(env);
|
||||||
|
const publicBaseUrl =
|
||||||
|
env.UPDATE_PUBLIC_BASE_URL ??
|
||||||
|
`https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/${releaseVersion}`;
|
||||||
|
|
||||||
|
console.log(`release version: ${releaseVersion}`);
|
||||||
|
console.log("running build:release");
|
||||||
|
await runNpmScript("build:release", projectRoot, {
|
||||||
|
...env,
|
||||||
|
EXTENSION_VERSION: releaseVersion
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("running package-release");
|
||||||
|
await runNodeScript("scripts/package-release.mjs", projectRoot, {
|
||||||
|
...env,
|
||||||
|
EXTENSION_VERSION: releaseVersion
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("writing latest manifest");
|
||||||
|
await runNpmScript("write:latest", projectRoot, {
|
||||||
|
...env,
|
||||||
|
EXTENSION_VERSION: releaseVersion,
|
||||||
|
UPDATE_PUBLIC_BASE_URL: publicBaseUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("uploading release assets to COS");
|
||||||
|
await uploadReleaseAssets({
|
||||||
|
env: {
|
||||||
|
...env,
|
||||||
|
EXTENSION_VERSION: releaseVersion
|
||||||
|
},
|
||||||
|
projectRoot,
|
||||||
|
releaseVersion
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNpmScript(scriptName, cwd, env) {
|
||||||
|
await execFileAsync("npm", ["run", scriptName], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNodeScript(scriptPath, cwd, env) {
|
||||||
|
await execFileAsync("node", [scriptPath], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProjectRoot() {
|
||||||
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
await runReleaseTagPipeline();
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import COS from "cos-nodejs-sdk-v5";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
import { buildReleaseUploadTargets } from "../release-assets.mjs";
|
||||||
|
|
||||||
|
export async function uploadReleaseAssets(options = {}) {
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
const projectRoot = options.projectRoot ?? resolveProjectRoot();
|
||||||
|
const releaseVersion = options.releaseVersion ?? env.EXTENSION_VERSION ?? env.DRONE_TAG;
|
||||||
|
|
||||||
|
if (!releaseVersion) {
|
||||||
|
throw new Error("release version is required for COS upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cos = options.cosClient ?? createCosClient(env);
|
||||||
|
const targets =
|
||||||
|
options.targets ??
|
||||||
|
buildReleaseUploadTargets({
|
||||||
|
projectRoot,
|
||||||
|
releaseVersion
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const body = await readFile(target.localPath);
|
||||||
|
await putObjectAsync(cos, {
|
||||||
|
Bucket: getRequiredEnv(env, "COS_BUCKET"),
|
||||||
|
Body: body,
|
||||||
|
ContentType: getContentType(target.cosKey),
|
||||||
|
Key: target.cosKey,
|
||||||
|
Region: getRequiredEnv(env, "COS_REGION")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putObjectAsync(client, params) {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
client.putObject(params, (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCosClient(env) {
|
||||||
|
return new COS({
|
||||||
|
SecretId: getRequiredEnv(env, "COS_SECRET_ID"),
|
||||||
|
SecretKey: getRequiredEnv(env, "COS_SECRET_KEY")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentType(key) {
|
||||||
|
if (key.endsWith(".json")) {
|
||||||
|
return "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.endsWith(".pdf")) {
|
||||||
|
return "application/pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.endsWith(".zip")) {
|
||||||
|
return "application/zip";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredEnv(env, name) {
|
||||||
|
const value = env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProjectRoot() {
|
||||||
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { resolveReleaseVersion } from "./release-version.mjs";
|
||||||
|
|
||||||
const sharedIcons = {
|
const sharedIcons = {
|
||||||
16: "assets/icons/icon-16.png",
|
16: "assets/icons/icon-16.png",
|
||||||
32: "assets/icons/icon-32.png",
|
32: "assets/icons/icon-32.png",
|
||||||
@@ -34,7 +36,6 @@ const sharedManifest = {
|
|||||||
manifest_version: 3,
|
manifest_version: 3,
|
||||||
name: "Star Chart Search Enhancer",
|
name: "Star Chart Search Enhancer",
|
||||||
permissions: ["downloads", "identity", "storage"],
|
permissions: ["downloads", "identity", "storage"],
|
||||||
version: "0.2.0421.2",
|
|
||||||
web_accessible_resources: [
|
web_accessible_resources: [
|
||||||
{
|
{
|
||||||
matches: [
|
matches: [
|
||||||
@@ -58,7 +59,8 @@ const hostPermissionsByTarget = {
|
|||||||
"https://*.xingtu.cn/ad/creator/market*",
|
"https://*.xingtu.cn/ad/creator/market*",
|
||||||
"https://login-api.intelligrow.cn/*",
|
"https://login-api.intelligrow.cn/*",
|
||||||
"https://talent-search.intelligrow.cn/*",
|
"https://talent-search.intelligrow.cn/*",
|
||||||
"http://192.168.31.21:8083/*"
|
"http://192.168.31.21:8083/*",
|
||||||
|
"https://*/*"
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ export function createManifest(options = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...sharedManifest,
|
...sharedManifest,
|
||||||
|
version: resolveReleaseVersion(),
|
||||||
host_permissions: hostPermissions
|
host_permissions: hostPermissions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import yazl from "yazl";
|
||||||
|
|
||||||
|
export async function createReleaseArchive({
|
||||||
|
archivePath,
|
||||||
|
rootDirName = "star-chart-search-enhancer-internal",
|
||||||
|
sourceDir
|
||||||
|
}) {
|
||||||
|
const zip = new yazl.ZipFile();
|
||||||
|
const output = createWriteStream(archivePath);
|
||||||
|
|
||||||
|
await addDirectory(zip, sourceDir, sourceDir, rootDirName);
|
||||||
|
zip.end();
|
||||||
|
await pipeline(zip.outputStream, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addDirectory(zip, rootDir, currentDir, rootDirName) {
|
||||||
|
const entries = await readdir(currentDir, { withFileTypes: true });
|
||||||
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const absolutePath = path.join(currentDir, entry.name);
|
||||||
|
const relativePath = path.relative(rootDir, absolutePath);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await addDirectory(zip, rootDir, absolutePath, rootDirName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile()) {
|
||||||
|
zip.addFile(absolutePath, path.join(rootDirName, relativePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { mkdir, rm } from "node:fs/promises";
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { execFile } from "node:child_process";
|
import { createReleaseArchive } from "./package-release-archive.mjs";
|
||||||
import { promisify } from "node:util";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const projectRoot = path.resolve(__dirname, "..");
|
const projectRoot = path.resolve(__dirname, "..");
|
||||||
@@ -17,8 +14,9 @@ const archivePath = path.join(
|
|||||||
|
|
||||||
await mkdir(releaseDir, { recursive: true });
|
await mkdir(releaseDir, { recursive: true });
|
||||||
await rm(archivePath, { force: true });
|
await rm(archivePath, { force: true });
|
||||||
await execFileAsync("zip", ["-X", "-r", archivePath, "."], {
|
await createReleaseArchive({
|
||||||
cwd: sourceDir
|
archivePath,
|
||||||
|
sourceDir
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Internal archive created at ${archivePath}`);
|
console.log(`Internal archive created at ${archivePath}`);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function buildReleaseUploadTargets({
|
||||||
|
projectRoot,
|
||||||
|
releaseVersion
|
||||||
|
}) {
|
||||||
|
const releaseDir = path.join(projectRoot, "release");
|
||||||
|
const releasePrefix = "star-chart-search-enhancer";
|
||||||
|
const releaseVersionPrefix = `${releasePrefix}/releases/${releaseVersion}`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
cosKey: `${releasePrefix}/latest.json`,
|
||||||
|
localPath: path.join(releaseDir, "latest.json")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cosKey: `${releaseVersionPrefix}/star-chart-search-enhancer-internal.zip`,
|
||||||
|
localPath: path.join(releaseDir, "star-chart-search-enhancer-internal.zip")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cosKey: `${releaseVersionPrefix}/星图增强插件-超简单安装使用指南.pdf`,
|
||||||
|
localPath: path.join(releaseDir, "星图增强插件-超简单安装使用指南.pdf")
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const RELEASE_VERSION_PATTERN = /^\d+(?:\.\d+)*$/;
|
||||||
|
|
||||||
|
export function normalizeReleaseVersionTag(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().replace(/^v/i, "");
|
||||||
|
if (!RELEASE_VERSION_PATTERN.test(normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveReleaseVersion(
|
||||||
|
env = process.env,
|
||||||
|
fallbackVersion = "0.2.0421.2"
|
||||||
|
) {
|
||||||
|
const candidates = [env.EXTENSION_VERSION, env.DRONE_TAG, fallbackVersion];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = normalizeReleaseVersionTag(candidate);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("unable to resolve a valid release version");
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function createLatestManifest(options) {
|
||||||
|
const publishedAt = options.publishedAt ?? new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
guideUrl: `${options.publicBaseUrl}/星图增强插件-超简单安装使用指南.pdf`,
|
||||||
|
latestVersion: options.latestVersion,
|
||||||
|
minSupportedVersion: options.minSupportedVersion,
|
||||||
|
publishedAt,
|
||||||
|
releaseNotes: [
|
||||||
|
"支持在插件弹窗中检查新版本",
|
||||||
|
"支持一键下载最新版插件压缩包和使用说明"
|
||||||
|
],
|
||||||
|
zipUrl: `${options.publicBaseUrl}/star-chart-search-enhancer-internal.zip`
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createManifest } from "./manifest.mjs";
|
||||||
|
import { createLatestManifest } from "./write-latest-manifest-data.mjs";
|
||||||
|
import { resolveReleaseVersion } from "./release-version.mjs";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const projectRoot = path.resolve(__dirname, "..");
|
||||||
|
const releaseDir = path.join(projectRoot, "release");
|
||||||
|
const releaseManifest = createManifest({ target: "release" });
|
||||||
|
const latestVersion =
|
||||||
|
process.env.LATEST_VERSION ?? resolveReleaseVersion(process.env, releaseManifest.version);
|
||||||
|
const publicBaseUrl =
|
||||||
|
process.env.UPDATE_PUBLIC_BASE_URL ??
|
||||||
|
`https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/${latestVersion}`;
|
||||||
|
const latestManifest = createLatestManifest({
|
||||||
|
latestVersion,
|
||||||
|
minSupportedVersion: releaseManifest.version,
|
||||||
|
publicBaseUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
await mkdir(releaseDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(releaseDir, "latest.json"),
|
||||||
|
`${JSON.stringify(latestManifest, null, 2)}\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Update manifest written to ${path.join(releaseDir, "latest.json")}`);
|
||||||
@@ -26,6 +26,15 @@ export function createAuthController(options: {
|
|||||||
return createLoggedOutAuthState(config);
|
return createLoggedOutAuthState(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await options.authClient.getAccessToken(config.apiResource);
|
||||||
|
} catch (error) {
|
||||||
|
return createLoggedOutAuthState(
|
||||||
|
config,
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const claims = await options.authClient.getIdTokenClaims();
|
const claims = await options.authClient.getIdTokenClaims();
|
||||||
return createLoggedInAuthState(claims, config);
|
return createLoggedInAuthState(claims, config);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import type { AuthConfig } from "../../shared/auth-config";
|
|||||||
import type { AuthStateValue } from "../../shared/auth-messages";
|
import type { AuthStateValue } from "../../shared/auth-messages";
|
||||||
|
|
||||||
export function createLoggedOutAuthState(
|
export function createLoggedOutAuthState(
|
||||||
config?: Pick<AuthConfig, "apiResource">
|
config?: Pick<AuthConfig, "apiResource">,
|
||||||
|
lastError?: string | null
|
||||||
): AuthStateValue {
|
): AuthStateValue {
|
||||||
return {
|
return {
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
lastError: lastError ?? null,
|
||||||
resource: config?.apiResource ?? null
|
resource: config?.apiResource ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ type BatchSubmitMessage = {
|
|||||||
type: "batch:submit";
|
type: "batch:submit";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DownloadUpdateMessage = {
|
||||||
|
filename: string;
|
||||||
|
type: "update:download";
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function registerBackgroundMessageHandler(
|
export function registerBackgroundMessageHandler(
|
||||||
chromeLike: ChromeLike = readChromeLike(),
|
chromeLike: ChromeLike = readChromeLike(),
|
||||||
dependencies: {
|
dependencies: {
|
||||||
@@ -77,6 +83,22 @@ export function registerBackgroundMessageHandler(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDownloadUpdateMessage(message)) {
|
||||||
|
void triggerUpdateDownload(chromeLike, message)
|
||||||
|
.then(() => {
|
||||||
|
sendResponse({ ok: true, type: "update:download-ack" });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
ok: false,
|
||||||
|
type: "update:download-error"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (isBatchSubmitMessage(message)) {
|
if (isBatchSubmitMessage(message)) {
|
||||||
authController ??= createAuthController({
|
authController ??= createAuthController({
|
||||||
authClient: createLogtoAuthClient()
|
authClient: createLogtoAuthClient()
|
||||||
@@ -161,6 +183,23 @@ export function registerBackgroundMessageHandler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function triggerUpdateDownload(
|
||||||
|
chromeLike: ChromeLike,
|
||||||
|
message: DownloadUpdateMessage
|
||||||
|
): Promise<void> {
|
||||||
|
if (!chromeLike.downloads?.download) {
|
||||||
|
throw new Error("chrome.downloads.download is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.resolve(
|
||||||
|
chromeLike.downloads.download({
|
||||||
|
filename: message.filename,
|
||||||
|
saveAs: true,
|
||||||
|
url: message.url
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAuthMessage(
|
async function handleAuthMessage(
|
||||||
authController: AuthController,
|
authController: AuthController,
|
||||||
message: Parameters<typeof isAuthRequestMessage>[0] & { type: string }
|
message: Parameters<typeof isAuthRequestMessage>[0] & { type: string }
|
||||||
@@ -239,6 +278,22 @@ function isDownloadMarketCsvMessage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDownloadUpdateMessage(
|
||||||
|
message: unknown
|
||||||
|
): message is DownloadUpdateMessage {
|
||||||
|
if (!message || typeof message !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = message as Partial<DownloadUpdateMessage>;
|
||||||
|
return (
|
||||||
|
candidate.type === "update:download" &&
|
||||||
|
typeof candidate.filename === "string" &&
|
||||||
|
typeof candidate.url === "string" &&
|
||||||
|
candidate.url.startsWith("https://")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isBatchSubmitMessage(message: unknown): message is BatchSubmitMessage {
|
function isBatchSubmitMessage(message: unknown): message is BatchSubmitMessage {
|
||||||
if (!message || typeof message !== "object") {
|
if (!message || typeof message !== "object") {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+29
-7
@@ -44,7 +44,11 @@ export async function bootContentScript(
|
|||||||
const authState = await readAuthState(sendAuthMessage);
|
const authState = await readAuthState(sendAuthMessage);
|
||||||
if (!authState?.isAuthenticated) {
|
if (!authState?.isAuthenticated) {
|
||||||
await waitForBodyReady(currentDocument, currentWindow);
|
await waitForBodyReady(currentDocument, currentWindow);
|
||||||
renderMarketAuthGate(currentDocument, currentWindow);
|
renderMarketAuthGate(
|
||||||
|
currentDocument,
|
||||||
|
currentWindow,
|
||||||
|
isExpiredAuthState(authState) ? "登录已过期,请重新登录" : undefined
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ready: Promise.resolve()
|
ready: Promise.resolve()
|
||||||
};
|
};
|
||||||
@@ -54,12 +58,17 @@ export async function bootContentScript(
|
|||||||
|
|
||||||
return controllerFactory({
|
return controllerFactory({
|
||||||
document: currentDocument,
|
document: currentDocument,
|
||||||
onCsvReady: (csv: string) => {
|
onCsvReady: (csv: string, filename?: string) => {
|
||||||
|
if (filename) {
|
||||||
|
downloadCsv(currentDocument, currentWindow, csv, filename);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (requestCsvDownload(csv)) {
|
if (requestCsvDownload(csv)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadCsv(currentDocument, currentWindow, csv);
|
downloadCsv(currentDocument, currentWindow, csv, filename);
|
||||||
},
|
},
|
||||||
window: currentWindow
|
window: currentWindow
|
||||||
});
|
});
|
||||||
@@ -112,7 +121,7 @@ function bootstrapContentScript() {
|
|||||||
|
|
||||||
bootstrapContentScript();
|
bootstrapContentScript();
|
||||||
|
|
||||||
function requestCsvDownload(csv: string): boolean {
|
function requestCsvDownload(csv: string, filename?: string): boolean {
|
||||||
const runtime = (
|
const runtime = (
|
||||||
globalThis as typeof globalThis & {
|
globalThis as typeof globalThis & {
|
||||||
chrome?: { runtime?: ChromeRuntimeLike };
|
chrome?: { runtime?: ChromeRuntimeLike };
|
||||||
@@ -125,7 +134,7 @@ function requestCsvDownload(csv: string): boolean {
|
|||||||
|
|
||||||
runtime.sendMessage({
|
runtime.sendMessage({
|
||||||
csv,
|
csv,
|
||||||
filename: `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
|
filename: filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
|
||||||
type: DOWNLOAD_MARKET_CSV_MESSAGE
|
type: DOWNLOAD_MARKET_CSV_MESSAGE
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -165,14 +174,19 @@ async function waitForBodyReady(document: Document, currentWindow: Window): Prom
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadCsv(document: Document, window: Window, csv: string): void {
|
function downloadCsv(
|
||||||
|
document: Document,
|
||||||
|
window: Window,
|
||||||
|
csv: string,
|
||||||
|
filename?: string
|
||||||
|
): void {
|
||||||
const blob = new Blob(["\uFEFF", csv], {
|
const blob = new Blob(["\uFEFF", csv], {
|
||||||
type: "text/csv;charset=utf-8"
|
type: "text/csv;charset=utf-8"
|
||||||
});
|
});
|
||||||
const objectUrl = window.URL.createObjectURL(blob);
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = objectUrl;
|
link.href = objectUrl;
|
||||||
link.download = `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
|
link.download = filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
@@ -183,6 +197,14 @@ function formatTimestampForFilename(): string {
|
|||||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExpiredAuthState(authState: AuthStateValue | null): boolean {
|
||||||
|
const lastError = authState?.lastError;
|
||||||
|
return (
|
||||||
|
typeof lastError === "string" &&
|
||||||
|
(/token/i.test(lastError) || lastError.includes("过期"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function installMarketPageBridge(document: Document) {
|
function installMarketPageBridge(document: Document) {
|
||||||
if (
|
if (
|
||||||
document.documentElement.querySelector(
|
document.documentElement.querySelector(
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import type { MarketRecord } from "./types";
|
||||||
|
import type {
|
||||||
|
AudienceProfileDistributionItem,
|
||||||
|
AudienceProfileKind,
|
||||||
|
AudienceProfileResult,
|
||||||
|
AudienceProfileSuccess
|
||||||
|
} from "./audience-profile-types";
|
||||||
|
|
||||||
|
interface FetchResponseLike {
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
ok: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = (
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit
|
||||||
|
) => Promise<FetchResponseLike>;
|
||||||
|
|
||||||
|
export type AudienceProfileRequestTarget =
|
||||||
|
| {
|
||||||
|
linkType: number;
|
||||||
|
source: "audienceDistribution";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
authorType: number;
|
||||||
|
source: "fansDistribution";
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AudienceProfileClientOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
fetchImpl?: FetchLike;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DistributionSection =
|
||||||
|
| "age"
|
||||||
|
| "cityTier"
|
||||||
|
| "cityTop"
|
||||||
|
| "crowd"
|
||||||
|
| "gender"
|
||||||
|
| "interest"
|
||||||
|
| "province";
|
||||||
|
|
||||||
|
const SECTION_BY_DISPLAY: Array<[RegExp, DistributionSection]> = [
|
||||||
|
[/性别/, "gender"],
|
||||||
|
[/年龄/, "age"],
|
||||||
|
[/省份|全国省份/, "province"],
|
||||||
|
[/城市分布|地域/, "cityTop"],
|
||||||
|
[/城市等级/, "cityTier"],
|
||||||
|
[/兴趣/, "interest"],
|
||||||
|
[/八大人群/, "crowd"]
|
||||||
|
];
|
||||||
|
|
||||||
|
const GENDER_LABELS: Record<string, string> = {
|
||||||
|
female: "女性",
|
||||||
|
male: "男性"
|
||||||
|
};
|
||||||
|
|
||||||
|
const AGE_ORDER = ["18-23", "24-30", "31-40", "41-50", "50+"];
|
||||||
|
const CITY_TIER_ORDER = ["一线", "新一线", "二线", "三线", "四线", "五线"];
|
||||||
|
|
||||||
|
export const AUDIENCE_PROFILE_TARGETS: Record<
|
||||||
|
AudienceProfileKind,
|
||||||
|
AudienceProfileRequestTarget
|
||||||
|
> = {
|
||||||
|
audience: { linkType: 5, source: "audienceDistribution" },
|
||||||
|
fans: { authorType: 1, source: "fansDistribution" },
|
||||||
|
longtimeFans: { authorType: 5, source: "fansDistribution" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createAudienceProfileClient(
|
||||||
|
options: AudienceProfileClientOptions = {}
|
||||||
|
) {
|
||||||
|
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||||
|
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||||
|
const timeoutMs = options.timeoutMs ?? 8000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async loadAudienceProfile(
|
||||||
|
record: MarketRecord,
|
||||||
|
target: AudienceProfileRequestTarget
|
||||||
|
): Promise<AudienceProfileResult> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
buildAudienceProfileUrl(record.authorId, baseUrl, target),
|
||||||
|
{
|
||||||
|
credentials: "include",
|
||||||
|
method: "GET",
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
failureReason: "request-failed",
|
||||||
|
status: "failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapAudienceProfileResponse(await response.json());
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
failureReason:
|
||||||
|
error instanceof Error && error.name === "AbortError"
|
||||||
|
? "timeout"
|
||||||
|
: "request-failed",
|
||||||
|
status: "failed"
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAudienceProfileUrl(
|
||||||
|
authorId: string,
|
||||||
|
baseUrl: string,
|
||||||
|
target: AudienceProfileRequestTarget
|
||||||
|
): string {
|
||||||
|
const url = new URL(
|
||||||
|
target.source === "audienceDistribution"
|
||||||
|
? "/gw/api/data_sp/author_audience_distribution"
|
||||||
|
: "/gw/api/data_sp/get_author_fans_distribution",
|
||||||
|
baseUrl
|
||||||
|
);
|
||||||
|
url.searchParams.set("o_author_id", authorId);
|
||||||
|
url.searchParams.set("platform_source", "1");
|
||||||
|
if (target.source === "audienceDistribution") {
|
||||||
|
url.searchParams.set("platform_channel", "1");
|
||||||
|
url.searchParams.set("link_type", String(target.linkType));
|
||||||
|
} else {
|
||||||
|
url.searchParams.set("author_type", String(target.authorType));
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAudienceProfileResponse(
|
||||||
|
payload: unknown
|
||||||
|
): AudienceProfileResult {
|
||||||
|
if (!isRecord(payload) || !Array.isArray(payload.distributions)) {
|
||||||
|
return {
|
||||||
|
failureReason: "bad-response",
|
||||||
|
status: "failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile: AudienceProfileSuccess = {
|
||||||
|
status: "success"
|
||||||
|
};
|
||||||
|
|
||||||
|
payload.distributions.forEach((section) => {
|
||||||
|
if (!isRecord(section)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const display = readString(section.type_display);
|
||||||
|
const sectionName = resolveSection(display);
|
||||||
|
if (!sectionName || !Array.isArray(section.distribution_list)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
profile[sectionName] = normalizeDistributionItems(
|
||||||
|
section.distribution_list,
|
||||||
|
sectionName
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(profile).length === 1) {
|
||||||
|
return {
|
||||||
|
failureReason: "missing-profile",
|
||||||
|
status: "failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDistributionItems(
|
||||||
|
rawItems: unknown[],
|
||||||
|
sectionName: DistributionSection
|
||||||
|
): AudienceProfileDistributionItem[] {
|
||||||
|
const parsedItems = rawItems
|
||||||
|
.map((item) => {
|
||||||
|
if (!isRecord(item)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = readString(item.distribution_key);
|
||||||
|
const value = readNumber(item.distribution_value);
|
||||||
|
if (!key || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: normalizeLabel(key, sectionName),
|
||||||
|
rawLabel: key,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is { label: string; rawLabel: string; value: number } =>
|
||||||
|
Boolean(item)
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = parsedItems.reduce((sum, item) => sum + item.value, 0);
|
||||||
|
if (total <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedItems
|
||||||
|
.sort((left, right) => compareDistributionItems(left, right, sectionName))
|
||||||
|
.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: formatPercent(item.value / total)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDistributionItems(
|
||||||
|
left: { rawLabel: string; value: number },
|
||||||
|
right: { rawLabel: string; value: number },
|
||||||
|
sectionName: DistributionSection
|
||||||
|
): number {
|
||||||
|
if (sectionName === "age") {
|
||||||
|
return orderIndex(AGE_ORDER, left.rawLabel) - orderIndex(AGE_ORDER, right.rawLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionName === "cityTier") {
|
||||||
|
return (
|
||||||
|
orderIndex(CITY_TIER_ORDER, left.rawLabel) -
|
||||||
|
orderIndex(CITY_TIER_ORDER, right.rawLabel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return right.value - left.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderIndex(order: string[], value: string): number {
|
||||||
|
const index = order.indexOf(value);
|
||||||
|
return index === -1 ? order.length : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLabel(label: string, sectionName: DistributionSection): string {
|
||||||
|
if (sectionName === "gender") {
|
||||||
|
return GENDER_LABELS[label] ?? label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionName === "cityTier" && !label.endsWith("城市")) {
|
||||||
|
return `${label}城市`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSection(display: string | null): DistributionSection | null {
|
||||||
|
if (!display) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
SECTION_BY_DISPLAY.find(([pattern]) => pattern.test(display))?.[1] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number): string {
|
||||||
|
const percent = Math.round(value * 1000) / 10;
|
||||||
|
return `${Number.isInteger(percent) ? percent.toFixed(0) : percent.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const numericValue = Number(value);
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBaseUrl(): string {
|
||||||
|
if (typeof location !== "undefined" && location.origin) {
|
||||||
|
return location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "https://xingtu.cn";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultFetch(input: string, init?: RequestInit) {
|
||||||
|
return fetch(input, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { escapeCsvCell } from "../../shared/csv";
|
||||||
|
import {
|
||||||
|
buildMarketCsvColumns,
|
||||||
|
listBackendMetricCsvHeaders,
|
||||||
|
listRateCsvHeaders,
|
||||||
|
type CsvColumn
|
||||||
|
} from "./csv-exporter";
|
||||||
|
import type {
|
||||||
|
AudienceProfileDistributionItem,
|
||||||
|
AudienceProfileExportRow,
|
||||||
|
AudienceProfileKind,
|
||||||
|
AudienceProfileResult,
|
||||||
|
BusinessAbilityDurationKind,
|
||||||
|
BusinessAbilityEstimateMetrics,
|
||||||
|
BusinessAbilityVideoKind,
|
||||||
|
BusinessAbilityVideoMetrics
|
||||||
|
} from "./audience-profile-types";
|
||||||
|
|
||||||
|
type AudienceProfileCsvColumn = {
|
||||||
|
header: string;
|
||||||
|
readValue: (row: AudienceProfileExportRow) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AudienceProfileCsvOptions {
|
||||||
|
selectedHeaders?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AudienceProfileCsvFieldGroup = {
|
||||||
|
headers: string[];
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_LAYOUTS: Array<{
|
||||||
|
includeGender: boolean;
|
||||||
|
kind: AudienceProfileKind;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ includeGender: true, kind: "audience", label: "观众画像" },
|
||||||
|
{ includeGender: true, kind: "fans", label: "粉丝画像" },
|
||||||
|
{ includeGender: false, kind: "longtimeFans", label: "铁粉画像" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const GENDER_LABELS = ["男性", "女性"];
|
||||||
|
const AGE_LABELS = ["18-23", "24-30", "31-40", "41-50", "50+"];
|
||||||
|
const CITY_TIER_LABELS = [
|
||||||
|
"一线城市",
|
||||||
|
"二线城市",
|
||||||
|
"三线城市",
|
||||||
|
"四线城市",
|
||||||
|
"五线城市"
|
||||||
|
];
|
||||||
|
const CROWD_LABELS = [
|
||||||
|
"精致妈妈",
|
||||||
|
"都市银发",
|
||||||
|
"新锐白领",
|
||||||
|
"资深中产",
|
||||||
|
"都市蓝领",
|
||||||
|
"Z世代",
|
||||||
|
"小镇中老年",
|
||||||
|
"小镇青年"
|
||||||
|
];
|
||||||
|
|
||||||
|
const BUSINESS_VIDEO_LAYOUTS: Array<{
|
||||||
|
key: BusinessAbilityVideoKind;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "personalVideo", label: "个人视频" },
|
||||||
|
{ key: "xingtuVideo", label: "星图视频" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BUSINESS_VIDEO_METRIC_LAYOUTS: Array<{
|
||||||
|
key: keyof BusinessAbilityVideoMetrics;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "medianPlay", label: "播放量中位数" },
|
||||||
|
{ key: "finishRate", label: "完播率" },
|
||||||
|
{ key: "interactionRate", label: "互动率" },
|
||||||
|
{ key: "publishedItems", label: "发布作品" },
|
||||||
|
{ key: "averageDuration", label: "平均时长" },
|
||||||
|
{ key: "averageLike", label: "平均点赞" },
|
||||||
|
{ key: "averageComment", label: "平均评论" },
|
||||||
|
{ key: "averageShare", label: "平均转发" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BUSINESS_VIDEO_SECTION_LABEL = "内容数据";
|
||||||
|
const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估";
|
||||||
|
|
||||||
|
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
|
||||||
|
key: BusinessAbilityDurationKind;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "oneToTwenty", label: "1-20s视频" },
|
||||||
|
{ key: "twentyToSixty", label: "20-60s视频" },
|
||||||
|
{ key: "overSixty", label: "60s以上视频" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BUSINESS_ESTIMATE_METRIC_LAYOUTS: Array<{
|
||||||
|
key: keyof BusinessAbilityEstimateMetrics;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "expectedCpm", label: "预期CPM" },
|
||||||
|
{ key: "expectedCpe", label: "预期CPE" },
|
||||||
|
{ key: "expectedPlay", label: "预期播放量" },
|
||||||
|
{ key: "hotRate", label: "爆文率" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildAudienceProfileCsv(
|
||||||
|
rows: AudienceProfileExportRow[],
|
||||||
|
options: AudienceProfileCsvOptions = {}
|
||||||
|
): string {
|
||||||
|
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
|
||||||
|
const csvColumns = filterAudienceProfileCsvColumns([
|
||||||
|
...marketColumns.map(toMarketColumn),
|
||||||
|
...buildBusinessAbilityColumns(),
|
||||||
|
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
|
||||||
|
], options.selectedHeaders);
|
||||||
|
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||||
|
const rowLines = rows.map((row) =>
|
||||||
|
csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",")
|
||||||
|
);
|
||||||
|
|
||||||
|
return [headerLine, ...rowLines].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAudienceProfileCsvHeaders(
|
||||||
|
rows: AudienceProfileExportRow[] = []
|
||||||
|
): string[] {
|
||||||
|
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
|
||||||
|
return [
|
||||||
|
...marketColumns.map((column) => column.header),
|
||||||
|
...buildBusinessAbilityColumns().map((column) => column.header),
|
||||||
|
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)).map(
|
||||||
|
(column) => column.header
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFieldGroup[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
headers: listRateCsvHeaders(),
|
||||||
|
label: "看后搜率"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: listBackendMetricCsvHeaders(),
|
||||||
|
label: "秒思api数据"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: buildBusinessVideoColumns().map((column) => column.header),
|
||||||
|
label: "内容数据"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: buildBusinessEstimateColumns().map((column) => column.header),
|
||||||
|
label: "效果预估"
|
||||||
|
},
|
||||||
|
...PROFILE_LAYOUTS.map((layout) => ({
|
||||||
|
headers: buildProfileColumns(layout).map((column) => column.header),
|
||||||
|
label: layout.label
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAudienceProfileCsvColumns(
|
||||||
|
columns: AudienceProfileCsvColumn[],
|
||||||
|
selectedHeaders: string[] | undefined
|
||||||
|
): AudienceProfileCsvColumn[] {
|
||||||
|
if (!selectedHeaders) {
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectableHeaderSet = new Set(listAudienceProfileSelectableHeaders());
|
||||||
|
const selectedHeaderSet = new Set(selectedHeaders);
|
||||||
|
return columns.filter(
|
||||||
|
(column) =>
|
||||||
|
!selectableHeaderSet.has(column.header) ||
|
||||||
|
selectedHeaderSet.has(column.header)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listAudienceProfileSelectableHeaders(): string[] {
|
||||||
|
return listAudienceProfileSelectableFieldGroups().flatMap(
|
||||||
|
(group) => group.headers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
|
||||||
|
return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBusinessVideoColumns(): AudienceProfileCsvColumn[] {
|
||||||
|
return [
|
||||||
|
...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) =>
|
||||||
|
BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({
|
||||||
|
header: `${BUSINESS_VIDEO_SECTION_LABEL}-${videoLayout.label}-${metricLayout.label}`,
|
||||||
|
readValue: (row: AudienceProfileExportRow) =>
|
||||||
|
readBusinessVideoValue(row, videoLayout.key, metricLayout.key)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
|
||||||
|
return [
|
||||||
|
...BUSINESS_ESTIMATE_LAYOUTS.flatMap((durationLayout) =>
|
||||||
|
BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({
|
||||||
|
header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${durationLayout.label}-${metricLayout.label}`,
|
||||||
|
readValue: (row: AudienceProfileExportRow) =>
|
||||||
|
readBusinessEstimateValue(row, durationLayout.key, metricLayout.key)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBusinessVideoValue(
|
||||||
|
row: AudienceProfileExportRow,
|
||||||
|
videoKey: BusinessAbilityVideoKind,
|
||||||
|
metricKey: keyof BusinessAbilityVideoMetrics
|
||||||
|
): string {
|
||||||
|
const businessAbility = row.businessAbility;
|
||||||
|
if (!businessAbility || businessAbility.status !== "success") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return businessAbility.videos[videoKey]?.[metricKey] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBusinessEstimateValue(
|
||||||
|
row: AudienceProfileExportRow,
|
||||||
|
durationKey: BusinessAbilityDurationKind,
|
||||||
|
metricKey: keyof BusinessAbilityEstimateMetrics
|
||||||
|
): string {
|
||||||
|
const businessAbility = row.businessAbility;
|
||||||
|
if (!businessAbility || businessAbility.status !== "success") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return businessAbility.estimates[durationKey]?.[metricKey] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMarketColumn(column: CsvColumn): AudienceProfileCsvColumn {
|
||||||
|
return {
|
||||||
|
header: column.header,
|
||||||
|
readValue: (row) => column.readValue(row.record)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProfileColumns(layout: {
|
||||||
|
includeGender: boolean;
|
||||||
|
kind: AudienceProfileKind;
|
||||||
|
label: string;
|
||||||
|
}): AudienceProfileCsvColumn[] {
|
||||||
|
const columns: AudienceProfileCsvColumn[] = [];
|
||||||
|
|
||||||
|
if (layout.includeGender) {
|
||||||
|
columns.push(
|
||||||
|
...buildFixedDistributionColumns(
|
||||||
|
layout.label,
|
||||||
|
layout.kind,
|
||||||
|
"gender",
|
||||||
|
GENDER_LABELS
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.push(
|
||||||
|
...buildFixedDistributionColumns(layout.label, layout.kind, "age", AGE_LABELS),
|
||||||
|
...buildFixedDistributionColumns(
|
||||||
|
layout.label,
|
||||||
|
layout.kind,
|
||||||
|
"cityTier",
|
||||||
|
CITY_TIER_LABELS
|
||||||
|
),
|
||||||
|
...buildFixedDistributionColumns(layout.label, layout.kind, "crowd", CROWD_LABELS)
|
||||||
|
);
|
||||||
|
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFixedDistributionColumns(
|
||||||
|
prefix: string,
|
||||||
|
kind: AudienceProfileKind,
|
||||||
|
key: "age" | "cityTier" | "crowd" | "gender",
|
||||||
|
labels: string[]
|
||||||
|
): AudienceProfileCsvColumn[] {
|
||||||
|
return labels.map((label) => ({
|
||||||
|
header: `${prefix}-${label}占比`,
|
||||||
|
readValue: (row) => readDistributionValue(row.profiles[kind], key, label)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDistributionValue(
|
||||||
|
profile: AudienceProfileResult,
|
||||||
|
key: "age" | "cityTier" | "crowd" | "gender",
|
||||||
|
label: string
|
||||||
|
): string {
|
||||||
|
if (profile.status !== "success") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
readProfileDistributionItems(profile, key).find(
|
||||||
|
(candidate) => candidate.label === label
|
||||||
|
)?.value ?? "0%"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProfileDistributionItems(
|
||||||
|
profile: AudienceProfileResult,
|
||||||
|
key: "age" | "cityTier" | "crowd" | "gender"
|
||||||
|
): AudienceProfileDistributionItem[] {
|
||||||
|
return profile.status === "success" ? profile[key] ?? [] : [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv";
|
||||||
|
|
||||||
|
export function promptForAudienceProfileFields(
|
||||||
|
document: Document,
|
||||||
|
groups: AudienceProfileCsvFieldGroup[],
|
||||||
|
selectedHeaders: string[]
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const selectableHeaders = groups.flatMap((group) => group.headers);
|
||||||
|
const selectedHeaderSet = new Set(
|
||||||
|
selectedHeaders.filter((header) => selectableHeaders.includes(header))
|
||||||
|
);
|
||||||
|
if (selectedHeaderSet.size === 0) {
|
||||||
|
selectableHeaders.forEach((header) => selectedHeaderSet.add(header));
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.dataset.audienceProfileFieldDialog = "overlay";
|
||||||
|
applyOverlayStyles(overlay);
|
||||||
|
|
||||||
|
const dialog = document.createElement("section");
|
||||||
|
applyDialogStyles(dialog);
|
||||||
|
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
applyTitleStyles(title);
|
||||||
|
|
||||||
|
const hint = document.createElement("p");
|
||||||
|
hint.textContent = "基础字段会固定保留。取消勾选后,本次及后续CSV将不包含对应列。";
|
||||||
|
applyHintStyles(hint);
|
||||||
|
|
||||||
|
const toolbar = document.createElement("div");
|
||||||
|
applyToolbarStyles(toolbar);
|
||||||
|
|
||||||
|
const selectAllButton = document.createElement("button");
|
||||||
|
selectAllButton.type = "button";
|
||||||
|
selectAllButton.textContent = "全选";
|
||||||
|
applySecondaryButtonStyles(selectAllButton);
|
||||||
|
|
||||||
|
const resetButton = document.createElement("button");
|
||||||
|
resetButton.type = "button";
|
||||||
|
resetButton.textContent = "恢复默认";
|
||||||
|
applySecondaryButtonStyles(resetButton);
|
||||||
|
|
||||||
|
toolbar.append(selectAllButton, resetButton);
|
||||||
|
|
||||||
|
const groupContainer = document.createElement("div");
|
||||||
|
applyGroupContainerStyles(groupContainer);
|
||||||
|
|
||||||
|
const fieldInputs: HTMLInputElement[] = [];
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const groupSection = document.createElement("section");
|
||||||
|
groupSection.dataset.audienceProfileFieldDialogGroup = "section";
|
||||||
|
applyGroupSectionStyles(groupSection);
|
||||||
|
|
||||||
|
const groupHeader = document.createElement("label");
|
||||||
|
applyGroupHeaderStyles(groupHeader);
|
||||||
|
|
||||||
|
const groupInput = document.createElement("input");
|
||||||
|
groupInput.type = "checkbox";
|
||||||
|
|
||||||
|
const groupTitle = document.createElement("span");
|
||||||
|
groupTitle.textContent = group.label;
|
||||||
|
|
||||||
|
groupHeader.append(groupInput, groupTitle);
|
||||||
|
|
||||||
|
const fieldList = document.createElement("div");
|
||||||
|
applyFieldListStyles(fieldList);
|
||||||
|
|
||||||
|
const groupFieldInputs = group.headers.map((header) => {
|
||||||
|
const fieldLabel = document.createElement("label");
|
||||||
|
applyFieldLabelStyles(fieldLabel);
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "checkbox";
|
||||||
|
input.value = header;
|
||||||
|
input.dataset.audienceProfileFieldDialogField = "checkbox";
|
||||||
|
input.checked = selectedHeaderSet.has(header);
|
||||||
|
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.textContent = header;
|
||||||
|
|
||||||
|
fieldLabel.append(input, text);
|
||||||
|
fieldList.append(fieldLabel);
|
||||||
|
fieldInputs.push(input);
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncGroupInput = () => {
|
||||||
|
const checkedCount = groupFieldInputs.filter((input) => input.checked).length;
|
||||||
|
groupInput.checked = checkedCount === groupFieldInputs.length;
|
||||||
|
groupInput.indeterminate = checkedCount > 0 && checkedCount < groupFieldInputs.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
groupInput.addEventListener("change", () => {
|
||||||
|
groupFieldInputs.forEach((input) => {
|
||||||
|
input.checked = groupInput.checked;
|
||||||
|
});
|
||||||
|
syncTitle();
|
||||||
|
});
|
||||||
|
groupFieldInputs.forEach((input) => {
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
syncGroupInput();
|
||||||
|
syncTitle();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
syncGroupInput();
|
||||||
|
|
||||||
|
groupSection.append(groupHeader, fieldList);
|
||||||
|
groupContainer.append(groupSection);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
applyActionsStyles(actions);
|
||||||
|
|
||||||
|
const cancelButton = document.createElement("button");
|
||||||
|
cancelButton.type = "button";
|
||||||
|
cancelButton.textContent = "取消";
|
||||||
|
applySecondaryButtonStyles(cancelButton);
|
||||||
|
|
||||||
|
const confirmButton = document.createElement("button");
|
||||||
|
confirmButton.type = "button";
|
||||||
|
confirmButton.dataset.audienceProfileFieldDialogSave = "button";
|
||||||
|
confirmButton.textContent = "保存";
|
||||||
|
applyPrimaryButtonStyles(confirmButton);
|
||||||
|
|
||||||
|
actions.append(cancelButton, confirmButton);
|
||||||
|
dialog.append(title, hint, toolbar, groupContainer, actions);
|
||||||
|
overlay.append(dialog);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
function syncTitle() {
|
||||||
|
const checkedCount = fieldInputs.filter((input) => input.checked).length;
|
||||||
|
title.textContent = `可选字段(已选 ${checkedCount}/${fieldInputs.length} 个字段)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(value: string[] | null) {
|
||||||
|
overlay.remove();
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAllButton.addEventListener("click", () => {
|
||||||
|
fieldInputs.forEach((input) => {
|
||||||
|
input.checked = true;
|
||||||
|
});
|
||||||
|
syncTitle();
|
||||||
|
syncAllGroupInputs(dialog);
|
||||||
|
});
|
||||||
|
resetButton.addEventListener("click", () => {
|
||||||
|
fieldInputs.forEach((input) => {
|
||||||
|
input.checked = true;
|
||||||
|
});
|
||||||
|
syncTitle();
|
||||||
|
syncAllGroupInputs(dialog);
|
||||||
|
});
|
||||||
|
cancelButton.addEventListener("click", () => close(null));
|
||||||
|
confirmButton.addEventListener("click", () => {
|
||||||
|
const nextHeaders = fieldInputs
|
||||||
|
.filter((input) => input.checked)
|
||||||
|
.map((input) => input.value);
|
||||||
|
close(nextHeaders);
|
||||||
|
});
|
||||||
|
overlay.addEventListener("click", (event) => {
|
||||||
|
if (event.target === overlay) {
|
||||||
|
close(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
syncTitle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAllGroupInputs(dialog: HTMLElement): void {
|
||||||
|
dialog
|
||||||
|
.querySelectorAll('[data-audience-profile-field-dialog-group="section"]')
|
||||||
|
.forEach((section) => {
|
||||||
|
const groupInput = section.querySelector(":scope > label > input");
|
||||||
|
const fieldInputs = Array.from(
|
||||||
|
section.querySelectorAll(":scope > div input")
|
||||||
|
) as HTMLInputElement[];
|
||||||
|
if (!(groupInput instanceof HTMLInputElement) || fieldInputs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedCount = fieldInputs.filter((input) => input.checked).length;
|
||||||
|
groupInput.checked = checkedCount === fieldInputs.length;
|
||||||
|
groupInput.indeterminate = checkedCount > 0 && checkedCount < fieldInputs.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOverlayStyles(overlay: HTMLElement): void {
|
||||||
|
overlay.style.position = "fixed";
|
||||||
|
overlay.style.inset = "0";
|
||||||
|
overlay.style.zIndex = "2147483647";
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
overlay.style.alignItems = "center";
|
||||||
|
overlay.style.justifyContent = "center";
|
||||||
|
overlay.style.background = "rgba(15, 23, 42, 0.38)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDialogStyles(dialog: HTMLElement): void {
|
||||||
|
dialog.style.width = "680px";
|
||||||
|
dialog.style.maxWidth = "calc(100vw - 32px)";
|
||||||
|
dialog.style.maxHeight = "calc(100vh - 48px)";
|
||||||
|
dialog.style.display = "flex";
|
||||||
|
dialog.style.flexDirection = "column";
|
||||||
|
dialog.style.background = "#ffffff";
|
||||||
|
dialog.style.borderRadius = "8px";
|
||||||
|
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
|
||||||
|
dialog.style.padding = "20px";
|
||||||
|
dialog.style.boxSizing = "border-box";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTitleStyles(title: HTMLElement): void {
|
||||||
|
title.style.margin = "0 0 8px";
|
||||||
|
title.style.fontSize = "18px";
|
||||||
|
title.style.fontWeight = "700";
|
||||||
|
title.style.color = "#1f2329";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHintStyles(hint: HTMLElement): void {
|
||||||
|
hint.style.margin = "0 0 12px";
|
||||||
|
hint.style.fontSize = "13px";
|
||||||
|
hint.style.lineHeight = "20px";
|
||||||
|
hint.style.color = "#64748b";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToolbarStyles(toolbar: HTMLElement): void {
|
||||||
|
toolbar.style.display = "flex";
|
||||||
|
toolbar.style.gap = "8px";
|
||||||
|
toolbar.style.marginBottom = "12px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGroupContainerStyles(container: HTMLElement): void {
|
||||||
|
container.style.display = "flex";
|
||||||
|
container.style.flexDirection = "column";
|
||||||
|
container.style.gap = "10px";
|
||||||
|
container.style.overflow = "auto";
|
||||||
|
container.style.paddingRight = "4px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGroupSectionStyles(section: HTMLElement): void {
|
||||||
|
section.style.border = "1px solid #e5e7eb";
|
||||||
|
section.style.borderRadius = "8px";
|
||||||
|
section.style.padding = "10px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGroupHeaderStyles(label: HTMLElement): void {
|
||||||
|
label.style.display = "flex";
|
||||||
|
label.style.alignItems = "center";
|
||||||
|
label.style.gap = "8px";
|
||||||
|
label.style.fontWeight = "700";
|
||||||
|
label.style.color = "#1f2329";
|
||||||
|
label.style.marginBottom = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFieldListStyles(list: HTMLElement): void {
|
||||||
|
list.style.display = "grid";
|
||||||
|
list.style.gridTemplateColumns = "repeat(auto-fit, minmax(220px, 1fr))";
|
||||||
|
list.style.gap = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFieldLabelStyles(label: HTMLElement): void {
|
||||||
|
label.style.display = "flex";
|
||||||
|
label.style.alignItems = "center";
|
||||||
|
label.style.gap = "6px";
|
||||||
|
label.style.fontSize = "13px";
|
||||||
|
label.style.lineHeight = "18px";
|
||||||
|
label.style.color = "#374151";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyActionsStyles(actions: HTMLElement): void {
|
||||||
|
actions.style.display = "flex";
|
||||||
|
actions.style.justifyContent = "flex-end";
|
||||||
|
actions.style.columnGap = "8px";
|
||||||
|
actions.style.marginTop = "14px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
||||||
|
button.style.height = "32px";
|
||||||
|
button.style.padding = "0 15px";
|
||||||
|
button.style.border = "1px solid #7f1d2d";
|
||||||
|
button.style.borderRadius = "8px";
|
||||||
|
button.style.background = "#7f1d2d";
|
||||||
|
button.style.color = "#ffffff";
|
||||||
|
button.style.fontWeight = "600";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
||||||
|
button.style.height = "32px";
|
||||||
|
button.style.padding = "0 15px";
|
||||||
|
button.style.border = "1px solid #d0d7de";
|
||||||
|
button.style.borderRadius = "8px";
|
||||||
|
button.style.background = "#ffffff";
|
||||||
|
button.style.color = "#1f2329";
|
||||||
|
button.style.fontWeight = "600";
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { MarketRecord } from "./types";
|
||||||
|
|
||||||
|
export type AudienceProfileKind =
|
||||||
|
| "audience"
|
||||||
|
| "fans"
|
||||||
|
| "longtimeFans";
|
||||||
|
|
||||||
|
export interface AudienceProfileDistributionItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudienceProfileSuccess {
|
||||||
|
age?: AudienceProfileDistributionItem[];
|
||||||
|
cityTier?: AudienceProfileDistributionItem[];
|
||||||
|
cityTop?: AudienceProfileDistributionItem[];
|
||||||
|
crowd?: AudienceProfileDistributionItem[];
|
||||||
|
gender?: AudienceProfileDistributionItem[];
|
||||||
|
interest?: AudienceProfileDistributionItem[];
|
||||||
|
province?: AudienceProfileDistributionItem[];
|
||||||
|
status: "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudienceProfileFailure {
|
||||||
|
failureReason?: string;
|
||||||
|
status: "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudienceProfileSet {
|
||||||
|
audience: AudienceProfileResult;
|
||||||
|
fans: AudienceProfileResult;
|
||||||
|
longtimeFans: AudienceProfileResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AudienceProfileResult =
|
||||||
|
| AudienceProfileSuccess
|
||||||
|
| AudienceProfileFailure;
|
||||||
|
|
||||||
|
export interface AudienceProfileExportRow {
|
||||||
|
businessAbility?: BusinessAbilityResult;
|
||||||
|
profiles: AudienceProfileSet;
|
||||||
|
record: MarketRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BusinessAbilityVideoKind =
|
||||||
|
| "personalVideo"
|
||||||
|
| "xingtuVideo";
|
||||||
|
|
||||||
|
export interface BusinessAbilityVideoMetrics {
|
||||||
|
averageComment: string;
|
||||||
|
averageDuration: string;
|
||||||
|
averageLike: string;
|
||||||
|
averageShare: string;
|
||||||
|
finishRate: string;
|
||||||
|
interactionRate: string;
|
||||||
|
medianPlay: string;
|
||||||
|
publishedItems: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BusinessAbilityDurationKind =
|
||||||
|
| "oneToTwenty"
|
||||||
|
| "twentyToSixty"
|
||||||
|
| "overSixty";
|
||||||
|
|
||||||
|
export interface BusinessAbilityEstimateMetrics {
|
||||||
|
expectedCpe: string;
|
||||||
|
expectedCpm: string;
|
||||||
|
expectedPlay: string;
|
||||||
|
hotRate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessAbilitySuccess {
|
||||||
|
estimates: Partial<
|
||||||
|
Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>
|
||||||
|
>;
|
||||||
|
status: "success";
|
||||||
|
videos: Partial<Record<BusinessAbilityVideoKind, BusinessAbilityVideoMetrics>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessAbilityFailure {
|
||||||
|
failureReason?: string;
|
||||||
|
status: "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BusinessAbilityResult =
|
||||||
|
| BusinessAbilitySuccess
|
||||||
|
| BusinessAbilityFailure;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export function renderMarketAuthGate(
|
export function renderMarketAuthGate(
|
||||||
document: Document,
|
document: Document,
|
||||||
currentWindow: Window
|
currentWindow: Window,
|
||||||
|
message = "请先登录插件"
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
const existingGate = document.querySelector(
|
const existingGate = document.querySelector(
|
||||||
'[data-market-auth-gate="root"]'
|
'[data-market-auth-gate="root"]'
|
||||||
@@ -13,10 +14,14 @@ export function renderMarketAuthGate(
|
|||||||
const root = document.createElement("section");
|
const root = document.createElement("section");
|
||||||
root.dataset.marketAuthGate = "root";
|
root.dataset.marketAuthGate = "root";
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<strong>请先登录插件</strong>
|
<strong></strong>
|
||||||
<p>打开扩展弹窗完成登录后刷新本页</p>
|
<p>打开扩展弹窗完成登录后刷新本页</p>
|
||||||
<button type="button" data-market-auth-help="button">去登录</button>
|
<button type="button" data-market-auth-help="button">去登录</button>
|
||||||
`;
|
`;
|
||||||
|
const title = root.querySelector("strong");
|
||||||
|
if (title) {
|
||||||
|
title.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
root
|
root
|
||||||
.querySelector('[data-market-auth-help="button"]')
|
.querySelector('[data-market-auth-help="button"]')
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { MarketRecord } from "./types";
|
||||||
|
|
||||||
|
interface FetchResponseLike {
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
ok: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = (
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit
|
||||||
|
) => Promise<FetchResponseLike>;
|
||||||
|
|
||||||
|
interface AuthorBaseClientOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
fetchImpl?: FetchLike;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthorBaseClient(options: AuthorBaseClientOptions = {}) {
|
||||||
|
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||||
|
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||||
|
const timeoutMs = options.timeoutMs ?? 8000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async loadAuthorBaseInfo(authorId: string): Promise<MarketRecord> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
buildAuthorBaseInfoUrl(authorId, baseUrl),
|
||||||
|
{
|
||||||
|
credentials: "include",
|
||||||
|
method: "GET",
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return buildFailedRecord(authorId, "request-failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapAuthorBaseInfoResponse(authorId, await response.json());
|
||||||
|
} catch (error) {
|
||||||
|
return buildFailedRecord(
|
||||||
|
authorId,
|
||||||
|
error instanceof Error && error.name === "AbortError"
|
||||||
|
? "timeout"
|
||||||
|
: "request-failed"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthorBaseInfoUrl(
|
||||||
|
authorId: string,
|
||||||
|
baseUrl: string
|
||||||
|
): string {
|
||||||
|
const url = new URL("/gw/api/author/get_author_base_info", baseUrl);
|
||||||
|
url.searchParams.set("o_author_id", authorId);
|
||||||
|
url.searchParams.set("platform_source", "1");
|
||||||
|
url.searchParams.set("platform_channel", "1");
|
||||||
|
url.searchParams.set("recommend", "true");
|
||||||
|
url.searchParams.set("need_sec_uid", "true");
|
||||||
|
url.searchParams.set("need_linkage_info", "true");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAuthorBaseInfoResponse(
|
||||||
|
authorId: string,
|
||||||
|
payload: unknown
|
||||||
|
): MarketRecord {
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return buildFailedRecord(authorId, "bad-response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorName = readString(payload.nick_name);
|
||||||
|
if (!authorName) {
|
||||||
|
return buildFailedRecord(authorId, "missing-rate");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorId,
|
||||||
|
authorName,
|
||||||
|
status: "success"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFailedRecord(
|
||||||
|
authorId: string,
|
||||||
|
failureReason: MarketRecord["failureReason"]
|
||||||
|
): MarketRecord {
|
||||||
|
return {
|
||||||
|
authorId,
|
||||||
|
authorName: "",
|
||||||
|
failureReason,
|
||||||
|
status: "failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBaseUrl(): string {
|
||||||
|
if (typeof location !== "undefined" && location.origin) {
|
||||||
|
return location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "https://xingtu.cn";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultFetch(input: string, init?: RequestInit) {
|
||||||
|
return fetch(input, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
export function promptForAuthorIds(document: Document): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.dataset.authorIdDialog = "overlay";
|
||||||
|
applyOverlayStyles(overlay);
|
||||||
|
|
||||||
|
const dialog = document.createElement("section");
|
||||||
|
applyDialogStyles(dialog);
|
||||||
|
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.textContent = "按星图ID导出";
|
||||||
|
applyTitleStyles(title);
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.dataset.authorIdDialogInput = "textarea";
|
||||||
|
textarea.placeholder = "每行一个星图ID,也支持逗号、空格分隔";
|
||||||
|
applyTextareaStyles(textarea);
|
||||||
|
|
||||||
|
const hint = document.createElement("p");
|
||||||
|
hint.textContent = "粘贴客户提供的达人星图ID,确认后将批量导出达人数据。";
|
||||||
|
applyHintStyles(hint);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
applyActionsStyles(actions);
|
||||||
|
|
||||||
|
const cancelButton = document.createElement("button");
|
||||||
|
cancelButton.type = "button";
|
||||||
|
cancelButton.textContent = "取消";
|
||||||
|
applySecondaryButtonStyles(cancelButton);
|
||||||
|
|
||||||
|
const confirmButton = document.createElement("button");
|
||||||
|
confirmButton.type = "button";
|
||||||
|
confirmButton.textContent = "开始导出";
|
||||||
|
applyPrimaryButtonStyles(confirmButton);
|
||||||
|
|
||||||
|
actions.append(cancelButton, confirmButton);
|
||||||
|
dialog.append(title, hint, textarea, actions);
|
||||||
|
overlay.append(dialog);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const close = (value: string | null) => {
|
||||||
|
overlay.remove();
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelButton.addEventListener("click", () => close(null));
|
||||||
|
confirmButton.addEventListener("click", () => close(textarea.value));
|
||||||
|
overlay.addEventListener("click", (event) => {
|
||||||
|
if (event.target === overlay) {
|
||||||
|
close(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
textarea.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOverlayStyles(overlay: HTMLElement): void {
|
||||||
|
overlay.style.position = "fixed";
|
||||||
|
overlay.style.inset = "0";
|
||||||
|
overlay.style.zIndex = "2147483647";
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
overlay.style.alignItems = "center";
|
||||||
|
overlay.style.justifyContent = "center";
|
||||||
|
overlay.style.background = "rgba(15, 23, 42, 0.38)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDialogStyles(dialog: HTMLElement): void {
|
||||||
|
dialog.style.width = "520px";
|
||||||
|
dialog.style.maxWidth = "calc(100vw - 32px)";
|
||||||
|
dialog.style.background = "#ffffff";
|
||||||
|
dialog.style.borderRadius = "8px";
|
||||||
|
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
|
||||||
|
dialog.style.padding = "20px";
|
||||||
|
dialog.style.boxSizing = "border-box";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTitleStyles(title: HTMLElement): void {
|
||||||
|
title.style.margin = "0 0 8px";
|
||||||
|
title.style.fontSize = "18px";
|
||||||
|
title.style.fontWeight = "700";
|
||||||
|
title.style.color = "#1f2329";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHintStyles(hint: HTMLElement): void {
|
||||||
|
hint.style.margin = "0 0 12px";
|
||||||
|
hint.style.fontSize = "13px";
|
||||||
|
hint.style.lineHeight = "20px";
|
||||||
|
hint.style.color = "#64748b";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextareaStyles(textarea: HTMLTextAreaElement): void {
|
||||||
|
textarea.style.width = "100%";
|
||||||
|
textarea.style.height = "220px";
|
||||||
|
textarea.style.resize = "vertical";
|
||||||
|
textarea.style.border = "1px solid #d0d7de";
|
||||||
|
textarea.style.borderRadius = "6px";
|
||||||
|
textarea.style.padding = "10px";
|
||||||
|
textarea.style.boxSizing = "border-box";
|
||||||
|
textarea.style.fontSize = "13px";
|
||||||
|
textarea.style.lineHeight = "20px";
|
||||||
|
textarea.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||||
|
textarea.style.color = "#1f2329";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyActionsStyles(actions: HTMLElement): void {
|
||||||
|
actions.style.display = "flex";
|
||||||
|
actions.style.justifyContent = "flex-end";
|
||||||
|
actions.style.columnGap = "8px";
|
||||||
|
actions.style.marginTop = "14px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
||||||
|
button.style.height = "32px";
|
||||||
|
button.style.padding = "0 15px";
|
||||||
|
button.style.border = "1px solid #7f1d2d";
|
||||||
|
button.style.borderRadius = "8px";
|
||||||
|
button.style.background = "#7f1d2d";
|
||||||
|
button.style.color = "#ffffff";
|
||||||
|
button.style.fontWeight = "600";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
||||||
|
button.style.height = "32px";
|
||||||
|
button.style.padding = "0 15px";
|
||||||
|
button.style.border = "1px solid #d0d7de";
|
||||||
|
button.style.borderRadius = "8px";
|
||||||
|
button.style.background = "#ffffff";
|
||||||
|
button.style.color = "#1f2329";
|
||||||
|
button.style.fontWeight = "600";
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface ParsedAuthorIds {
|
||||||
|
duplicates: string[];
|
||||||
|
invalidTokens: string[];
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTHOR_ID_PATTERN = /^\d{16,20}$/;
|
||||||
|
|
||||||
|
export function parseAuthorIds(input: string): ParsedAuthorIds {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const duplicates: string[] = [];
|
||||||
|
const invalidTokens: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
input
|
||||||
|
.split(/[\s,,;;]+/)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((token) => {
|
||||||
|
if (!/^\d+$/.test(token) || !AUTHOR_ID_PATTERN.test(token)) {
|
||||||
|
invalidTokens.push(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(token)) {
|
||||||
|
duplicates.push(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(token);
|
||||||
|
ids.push(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
duplicates,
|
||||||
|
ids,
|
||||||
|
invalidTokens
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import type { MarketRecord } from "./types";
|
||||||
|
import type {
|
||||||
|
BusinessAbilityDurationKind,
|
||||||
|
BusinessAbilityEstimateMetrics,
|
||||||
|
BusinessAbilityResult,
|
||||||
|
BusinessAbilitySuccess,
|
||||||
|
BusinessAbilityVideoMetrics
|
||||||
|
} from "./audience-profile-types";
|
||||||
|
|
||||||
|
interface FetchResponseLike {
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
ok: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = (
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit
|
||||||
|
) => Promise<FetchResponseLike>;
|
||||||
|
|
||||||
|
interface BusinessAbilityClientOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
fetchImpl?: FetchLike;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIDEO_TYPES = {
|
||||||
|
personalVideo: 1,
|
||||||
|
xingtuVideo: 2
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function createBusinessAbilityClient(
|
||||||
|
options: BusinessAbilityClientOptions = {}
|
||||||
|
) {
|
||||||
|
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||||
|
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||||
|
const timeoutMs = options.timeoutMs ?? 8000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async loadBusinessAbility(record: MarketRecord): Promise<BusinessAbilityResult> {
|
||||||
|
const personalVideo = await loadJson(
|
||||||
|
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.personalVideo)
|
||||||
|
);
|
||||||
|
const xingtuVideo = await loadJson(
|
||||||
|
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.xingtuVideo)
|
||||||
|
);
|
||||||
|
const estimates = await loadJson(
|
||||||
|
buildBusinessAbilityEstimateUrl(record.authorId, baseUrl)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!personalVideo.ok || !xingtuVideo.ok || !estimates.ok) {
|
||||||
|
return {
|
||||||
|
failureReason:
|
||||||
|
personalVideo.failureReason ??
|
||||||
|
xingtuVideo.failureReason ??
|
||||||
|
estimates.failureReason,
|
||||||
|
status: "failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
estimates: mapBusinessAbilityEstimateResponse(estimates.payload),
|
||||||
|
status: "success",
|
||||||
|
videos: {
|
||||||
|
personalVideo: mapBusinessAbilityVideoResponse(personalVideo.payload),
|
||||||
|
xingtuVideo: mapBusinessAbilityVideoResponse(xingtuVideo.payload)
|
||||||
|
}
|
||||||
|
} satisfies BusinessAbilitySuccess;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadJson(url: string): Promise<
|
||||||
|
| { ok: true; payload: unknown }
|
||||||
|
| { failureReason: string; ok: false }
|
||||||
|
> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
credentials: "include",
|
||||||
|
method: "GET",
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { failureReason: "request-failed", ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, payload: await response.json() };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
failureReason:
|
||||||
|
error instanceof Error && error.name === "AbortError"
|
||||||
|
? "timeout"
|
||||||
|
: "request-failed",
|
||||||
|
ok: false
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBusinessAbilityVideoUrl(
|
||||||
|
authorId: string,
|
||||||
|
baseUrl: string,
|
||||||
|
videoType: number
|
||||||
|
): string {
|
||||||
|
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
|
||||||
|
url.searchParams.set("o_author_id", authorId);
|
||||||
|
url.searchParams.set("platform_source", "1");
|
||||||
|
url.searchParams.set("platform_channel", "1");
|
||||||
|
url.searchParams.set("type", String(videoType));
|
||||||
|
url.searchParams.set("flow_type", "0");
|
||||||
|
url.searchParams.set("only_assign", "true");
|
||||||
|
url.searchParams.set("range", "2");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBusinessAbilityEstimateUrl(
|
||||||
|
authorId: string,
|
||||||
|
baseUrl: string
|
||||||
|
): string {
|
||||||
|
const url = new URL(
|
||||||
|
"/gw/api/aggregator/get_author_commerce_spread_info",
|
||||||
|
baseUrl
|
||||||
|
);
|
||||||
|
url.searchParams.set("o_author_id", authorId);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapBusinessAbilityVideoResponse(
|
||||||
|
payload: unknown
|
||||||
|
): BusinessAbilityVideoMetrics {
|
||||||
|
const data = getPayloadData(payload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
averageComment: formatWan(readNumber(data?.comment_avg)),
|
||||||
|
averageDuration: formatDuration(readNumber(data?.avg_duration)),
|
||||||
|
averageLike: formatWan(readNumber(data?.like_avg)),
|
||||||
|
averageShare: formatWan(readNumber(data?.share_avg)),
|
||||||
|
finishRate: formatBasisPointRate(readNestedNumber(data, "play_over_rate", "value")),
|
||||||
|
interactionRate: formatBasisPointRate(
|
||||||
|
readNestedNumber(data, "interact_rate", "value")
|
||||||
|
),
|
||||||
|
medianPlay: formatWan(readNumber(data?.play_mid)),
|
||||||
|
publishedItems: formatPublishedItems(readNumber(data?.item_num))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapBusinessAbilityEstimateResponse(
|
||||||
|
payload: unknown
|
||||||
|
): Partial<Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>> {
|
||||||
|
const data = getPayloadData(payload);
|
||||||
|
const expectedPlay = formatWan(readNumber(data?.vv));
|
||||||
|
const hotRate = formatDecimalRate(readNumber(data?.platform_hot_rate));
|
||||||
|
|
||||||
|
return {
|
||||||
|
oneToTwenty: {
|
||||||
|
expectedCpe: formatDecimal(readNumber(data?.cpe_1_20), 1),
|
||||||
|
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_1_20), 1),
|
||||||
|
expectedPlay,
|
||||||
|
hotRate
|
||||||
|
},
|
||||||
|
overSixty: {
|
||||||
|
expectedCpe: formatDecimal(readNumber(data?.cpe_60), 1),
|
||||||
|
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_60), 1),
|
||||||
|
expectedPlay,
|
||||||
|
hotRate
|
||||||
|
},
|
||||||
|
twentyToSixty: {
|
||||||
|
expectedCpe: formatDecimal(readNumber(data?.cpe_20_60), 1),
|
||||||
|
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_20_60), 1),
|
||||||
|
expectedPlay,
|
||||||
|
hotRate
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPublishedItems(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return value > 0 && value < 5 ? "<5" : formatDecimal(value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDecimal(value / 100, 0)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBasisPointRate(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDecimal(value / 100, 1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDecimalRate(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "缺失";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDecimal(value * 100, 0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWan(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(value) >= 10000) {
|
||||||
|
return `${formatDecimal(value / 10000, 1)}w`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDecimal(value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDecimal(value: number | null, digits: number): string {
|
||||||
|
if (value === null || !Number.isFinite(value)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixed = value.toFixed(digits);
|
||||||
|
return fixed.replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFixedDecimal(value: number | null, digits: number): string {
|
||||||
|
if (value === null || !Number.isFinite(value)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNestedNumber(
|
||||||
|
data: Record<string, unknown> | null,
|
||||||
|
objectKey: string,
|
||||||
|
valueKey: string
|
||||||
|
): number | null {
|
||||||
|
const objectValue = data?.[objectKey];
|
||||||
|
if (!isRecord(objectValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readNumber(objectValue[valueKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const numericValue = Number(value);
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isRecord(payload.data) ? payload.data : payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBaseUrl(): string {
|
||||||
|
if (typeof location !== "undefined" && location.origin) {
|
||||||
|
return location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "https://xingtu.cn";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultFetch(input: string, init?: RequestInit) {
|
||||||
|
return fetch(input, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
|||||||
import { escapeCsvCell } from "../../shared/csv";
|
import { escapeCsvCell } from "../../shared/csv";
|
||||||
import type { MarketRecord } from "./types";
|
import type { MarketRecord } from "./types";
|
||||||
|
|
||||||
type CsvColumn = {
|
export type CsvColumn = {
|
||||||
header: string;
|
header: string;
|
||||||
readValue: (record: MarketRecord) => string;
|
readValue: (record: MarketRecord) => string;
|
||||||
};
|
};
|
||||||
@@ -28,7 +28,7 @@ const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
|
|||||||
|
|
||||||
const RATE_COLUMNS: CsvColumn[] = [
|
const RATE_COLUMNS: CsvColumn[] = [
|
||||||
{
|
{
|
||||||
header: "单视频看后搜率",
|
header: "商单视频看后搜率",
|
||||||
readValue: (record: MarketRecord) =>
|
readValue: (record: MarketRecord) =>
|
||||||
record.rates?.singleVideoAfterSearchRate
|
record.rates?.singleVideoAfterSearchRate
|
||||||
? normalizeRateDisplay(record.rates.singleVideoAfterSearchRate)
|
? normalizeRateDisplay(record.rates.singleVideoAfterSearchRate)
|
||||||
@@ -45,38 +45,45 @@ const RATE_COLUMNS: CsvColumn[] = [
|
|||||||
|
|
||||||
const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
|
const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
|
||||||
{
|
{
|
||||||
header: "看后搜率",
|
header: "秒思api-看后搜率",
|
||||||
readValue: (record: MarketRecord) =>
|
readValue: (record: MarketRecord) =>
|
||||||
record.backendMetrics?.afterViewSearchRate ?? ""
|
record.backendMetrics?.afterViewSearchRate ?? ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "看后搜数",
|
header: "秒思api-看后搜数",
|
||||||
readValue: (record: MarketRecord) =>
|
readValue: (record: MarketRecord) =>
|
||||||
record.backendMetrics?.afterViewSearchCount ?? ""
|
record.backendMetrics?.afterViewSearchCount ?? ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "新增A3数",
|
header: "秒思api-新增A3数",
|
||||||
readValue: (record: MarketRecord) =>
|
readValue: (record: MarketRecord) =>
|
||||||
record.backendMetrics?.a3IncreaseCount ?? ""
|
record.backendMetrics?.a3IncreaseCount ?? ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "新增A3率",
|
header: "秒思api-新增A3率",
|
||||||
readValue: (record: MarketRecord) =>
|
readValue: (record: MarketRecord) =>
|
||||||
record.backendMetrics?.newA3Rate ?? ""
|
record.backendMetrics?.newA3Rate ?? ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "CPA3",
|
header: "秒思api-CPA3",
|
||||||
readValue: (record: MarketRecord) => record.backendMetrics?.cpa3 ?? ""
|
readValue: (record: MarketRecord) => record.backendMetrics?.cpa3 ?? ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "cp_search",
|
header: "秒思api-cp_search",
|
||||||
readValue: (record: MarketRecord) => record.backendMetrics?.cpSearch ?? ""
|
readValue: (record: MarketRecord) => record.backendMetrics?.cpSearch ?? ""
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function listRateCsvHeaders(): string[] {
|
||||||
|
return RATE_COLUMNS.map((column) => column.header);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBackendMetricCsvHeaders(): string[] {
|
||||||
|
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildMarketCsv(records: MarketRecord[]): string {
|
export function buildMarketCsv(records: MarketRecord[]): string {
|
||||||
const baseColumns = buildBaseColumns(records);
|
const csvColumns = buildMarketCsvColumns(records);
|
||||||
const csvColumns = [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
|
|
||||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||||
const rowLines = records.map((record) =>
|
const rowLines = records.map((record) =>
|
||||||
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||||
@@ -85,7 +92,12 @@ export function buildMarketCsv(records: MarketRecord[]): string {
|
|||||||
return [headerLine, ...rowLines].join("\n");
|
return [headerLine, ...rowLines].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] {
|
||||||
|
const baseColumns = buildBaseColumns(records);
|
||||||
|
return [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||||
const orderedHeaders: string[] = [];
|
const orderedHeaders: string[] = [];
|
||||||
const seenHeaders = new Set<string>();
|
const seenHeaders = new Set<string>();
|
||||||
const excludedHeaders = new Set(["代表视频"]);
|
const excludedHeaders = new Set(["代表视频"]);
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
|||||||
|
|
||||||
const selectionHeader = ensureSyntheticHeaderCell(header, SELECTION_COLUMN_KEY, "");
|
const selectionHeader = ensureSyntheticHeaderCell(header, SELECTION_COLUMN_KEY, "");
|
||||||
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeader);
|
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeader);
|
||||||
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
|
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "商单视频看后搜率");
|
||||||
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
|
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
|
||||||
BACKEND_METRIC_COLUMNS.forEach(({ field, label }) => {
|
BACKEND_METRIC_COLUMNS.forEach(({ field, label }) => {
|
||||||
ensureSyntheticHeaderCell(header, field, label);
|
ensureSyntheticHeaderCell(header, field, label);
|
||||||
@@ -532,7 +532,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
|||||||
pluginHeaderSection,
|
pluginHeaderSection,
|
||||||
headerTemplateCell,
|
headerTemplateCell,
|
||||||
SINGLE_COLUMN_KEY,
|
SINGLE_COLUMN_KEY,
|
||||||
"单视频看后搜率"
|
"商单视频看后搜率"
|
||||||
);
|
);
|
||||||
ensureDivHeaderCell(
|
ensureDivHeaderCell(
|
||||||
pluginHeaderSection,
|
pluginHeaderSection,
|
||||||
@@ -1428,7 +1428,7 @@ function shouldExportColumn(label: string): boolean {
|
|||||||
label !== ACTION_HEADER_TEXT &&
|
label !== ACTION_HEADER_TEXT &&
|
||||||
label !== BACKEND_HEADER_TEXT &&
|
label !== BACKEND_HEADER_TEXT &&
|
||||||
!excludedBackendLabels.has(label) &&
|
!excludedBackendLabels.has(label) &&
|
||||||
label !== "单视频看后搜率" &&
|
label !== "商单视频看后搜率" &&
|
||||||
label !== "个人视频看后搜率"
|
label !== "个人视频看后搜率"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+446
-1
@@ -1,4 +1,19 @@
|
|||||||
import { buildMarketCsv } from "./csv-exporter";
|
import { buildMarketCsv } from "./csv-exporter";
|
||||||
|
import {
|
||||||
|
buildAudienceProfileCsv,
|
||||||
|
listAudienceProfileSelectableFieldGroups,
|
||||||
|
type AudienceProfileCsvOptions
|
||||||
|
} from "./audience-profile-csv";
|
||||||
|
import {
|
||||||
|
AUDIENCE_PROFILE_TARGETS,
|
||||||
|
createAudienceProfileClient,
|
||||||
|
type AudienceProfileRequestTarget
|
||||||
|
} from "./audience-profile-client";
|
||||||
|
import { createAuthorBaseClient } from "./author-base-client";
|
||||||
|
import { parseAuthorIds } from "./author-id-input";
|
||||||
|
import { createBusinessAbilityClient } from "./business-ability-client";
|
||||||
|
import { promptForAudienceProfileFields } from "./audience-profile-field-dialog";
|
||||||
|
import { promptForAuthorIds } from "./author-id-dialog";
|
||||||
import { promptForBatchName } from "./batch-name-dialog";
|
import { promptForBatchName } from "./batch-name-dialog";
|
||||||
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +42,12 @@ import {
|
|||||||
type AuthStateValue
|
type AuthStateValue
|
||||||
} from "../../shared/auth-messages";
|
} from "../../shared/auth-messages";
|
||||||
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
|
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
|
||||||
|
import type {
|
||||||
|
AudienceProfileExportRow,
|
||||||
|
AudienceProfileKind,
|
||||||
|
AudienceProfileResult,
|
||||||
|
BusinessAbilityResult
|
||||||
|
} from "./audience-profile-types";
|
||||||
import type {
|
import type {
|
||||||
BackendMetrics,
|
BackendMetrics,
|
||||||
MarketApiResult,
|
MarketApiResult,
|
||||||
@@ -42,9 +63,21 @@ interface MutationObserverLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMarketControllerOptions {
|
export interface CreateMarketControllerOptions {
|
||||||
|
buildAudienceProfileCsv?: (
|
||||||
|
rows: AudienceProfileExportRow[],
|
||||||
|
options?: AudienceProfileCsvOptions
|
||||||
|
) => string;
|
||||||
buildCsv?: (records: MarketRecord[]) => string;
|
buildCsv?: (records: MarketRecord[]) => string;
|
||||||
document: Document;
|
document: Document;
|
||||||
getAuthState?: () => Promise<AuthStateValue>;
|
getAuthState?: () => Promise<AuthStateValue>;
|
||||||
|
loadAuthorBaseInfo?: (authorId: string) => Promise<MarketRecord>;
|
||||||
|
loadBusinessAbility?: (
|
||||||
|
record: MarketRecord
|
||||||
|
) => Promise<BusinessAbilityResult>;
|
||||||
|
loadAudienceProfile?: (
|
||||||
|
record: MarketRecord,
|
||||||
|
target: AudienceProfileRequestTarget
|
||||||
|
) => Promise<AudienceProfileResult>;
|
||||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||||
Array<BackendMetrics & { starId: string }>
|
Array<BackendMetrics & { starId: string }>
|
||||||
@@ -52,15 +85,22 @@ export interface CreateMarketControllerOptions {
|
|||||||
mutationObserverFactory?: (
|
mutationObserverFactory?: (
|
||||||
callback: MutationCallback
|
callback: MutationCallback
|
||||||
) => MutationObserverLike;
|
) => MutationObserverLike;
|
||||||
onCsvReady?: (csv: string) => void;
|
onCsvReady?: (csv: string, filename?: string) => void;
|
||||||
|
promptAuthorIds?: () => Promise<string | null> | string | null;
|
||||||
promptBatchName?: () => Promise<string | null> | string | null;
|
promptBatchName?: () => Promise<string | null> | string | null;
|
||||||
resultStore?: ReturnType<typeof createMarketResultStore>;
|
resultStore?: ReturnType<typeof createMarketResultStore>;
|
||||||
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
||||||
window: Window;
|
window: Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY =
|
||||||
|
"sces:audience-profile:selectedHeaders";
|
||||||
|
|
||||||
export function createMarketController(options: CreateMarketControllerOptions) {
|
export function createMarketController(options: CreateMarketControllerOptions) {
|
||||||
const marketApiClient = createMarketApiClient();
|
const marketApiClient = createMarketApiClient();
|
||||||
|
const audienceProfileClient = createAudienceProfileClient();
|
||||||
|
const authorBaseClient = createAuthorBaseClient();
|
||||||
|
const businessAbilityClient = createBusinessAbilityClient();
|
||||||
const sendRuntimeMessage = createRuntimeMessageSender();
|
const sendRuntimeMessage = createRuntimeMessageSender();
|
||||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||||
const loadAuthorMetrics =
|
const loadAuthorMetrics =
|
||||||
@@ -69,6 +109,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
options.searchBackendMetrics ??
|
options.searchBackendMetrics ??
|
||||||
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
||||||
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
||||||
|
const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv;
|
||||||
|
const loadAudienceProfile =
|
||||||
|
options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile;
|
||||||
|
const loadAuthorBaseInfo =
|
||||||
|
options.loadAuthorBaseInfo ?? authorBaseClient.loadAuthorBaseInfo;
|
||||||
|
const loadBusinessAbility =
|
||||||
|
options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility;
|
||||||
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
|
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
|
||||||
const mutationObserverFactory =
|
const mutationObserverFactory =
|
||||||
options.mutationObserverFactory ??
|
options.mutationObserverFactory ??
|
||||||
@@ -76,10 +123,24 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const promptBatchName =
|
const promptBatchName =
|
||||||
options.promptBatchName ??
|
options.promptBatchName ??
|
||||||
(() => promptForBatchName(options.document));
|
(() => promptForBatchName(options.document));
|
||||||
|
const promptAuthorIds =
|
||||||
|
options.promptAuthorIds ??
|
||||||
|
(() => promptForAuthorIds(options.document));
|
||||||
const submitBatch =
|
const submitBatch =
|
||||||
options.submitBatch ??
|
options.submitBatch ??
|
||||||
((payload: BatchPayload) =>
|
((payload: BatchPayload) =>
|
||||||
readBatchSubmitAck(sendRuntimeMessage, payload));
|
readBatchSubmitAck(sendRuntimeMessage, payload));
|
||||||
|
const audienceProfileTargets: Array<{
|
||||||
|
kind: AudienceProfileKind;
|
||||||
|
target: AudienceProfileRequestTarget;
|
||||||
|
}> = [
|
||||||
|
{ kind: "audience", target: AUDIENCE_PROFILE_TARGETS.audience },
|
||||||
|
{ kind: "fans", target: AUDIENCE_PROFILE_TARGETS.fans },
|
||||||
|
{
|
||||||
|
kind: "longtimeFans",
|
||||||
|
target: AUDIENCE_PROFILE_TARGETS.longtimeFans
|
||||||
|
}
|
||||||
|
];
|
||||||
let activeProgressLabel = "导出中";
|
let activeProgressLabel = "导出中";
|
||||||
let shouldShowDetailedProgress = true;
|
let shouldShowDetailedProgress = true;
|
||||||
const exportRangeController = createExportRangeController({
|
const exportRangeController = createExportRangeController({
|
||||||
@@ -164,6 +225,143 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
setToolbarBusyState(toolbar, false);
|
setToolbarBusyState(toolbar, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onExportAudienceProfile: async () => {
|
||||||
|
syncSelectionStateFromDom();
|
||||||
|
if (selectedAuthorIds.size === 0) {
|
||||||
|
setToolbarExportStatus(toolbar, "请先勾选需要导出数据的达人");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportTarget = readToolbarExportTarget(toolbar);
|
||||||
|
if (!exportTarget.target) {
|
||||||
|
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setToolbarBusyState(toolbar, true);
|
||||||
|
try {
|
||||||
|
const selectedRecords = filterRecordsBySelectionStrict(
|
||||||
|
await exportRecords(exportTarget.target, "画像导出中", {
|
||||||
|
showDetailedProgress: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (selectedRecords.length === 0) {
|
||||||
|
setToolbarExportStatus(toolbar, "当前导出范围内没有选中的达人");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: AudienceProfileExportRow[] = [];
|
||||||
|
for (let index = 0; index < selectedRecords.length; index += 1) {
|
||||||
|
const record = selectedRecords[index];
|
||||||
|
setToolbarExportStatus(
|
||||||
|
toolbar,
|
||||||
|
`画像导出中 ${index + 1}/${selectedRecords.length}...`
|
||||||
|
);
|
||||||
|
const [profiles, businessAbility] = await Promise.all([
|
||||||
|
loadAudienceProfileSet(record),
|
||||||
|
loadBusinessAbilitySafe(record)
|
||||||
|
]);
|
||||||
|
rows.push({
|
||||||
|
businessAbility,
|
||||||
|
profiles,
|
||||||
|
record
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
rows.every((row) =>
|
||||||
|
Object.values(row.profiles).every((profile) => profile.status === "failed")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onCsvReady?.(
|
||||||
|
buildAudienceCsv(rows, {
|
||||||
|
selectedHeaders: readAudienceProfileSelectedHeaders()
|
||||||
|
}),
|
||||||
|
buildAudienceProfileFilename()
|
||||||
|
);
|
||||||
|
setToolbarExportStatus(toolbar, "");
|
||||||
|
} catch (error) {
|
||||||
|
setToolbarExportStatus(
|
||||||
|
toolbar,
|
||||||
|
error instanceof Error ? error.message : "画像导出失败,请稍后重试"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setToolbarBusyState(toolbar, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onExportAudienceProfileByIds: async () => {
|
||||||
|
const input = await promptAuthorIds();
|
||||||
|
if (input === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseAuthorIds(input);
|
||||||
|
if (parsed.ids.length === 0) {
|
||||||
|
setToolbarExportStatus(toolbar, "请输入有效的达人星图ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setToolbarBusyState(toolbar, true);
|
||||||
|
try {
|
||||||
|
setToolbarExportStatus(
|
||||||
|
toolbar,
|
||||||
|
`识别 ${parsed.ids.length + parsed.duplicates.length + parsed.invalidTokens.length} 个,去重后 ${parsed.ids.length} 个,非法 ${parsed.invalidTokens.length} 个`
|
||||||
|
);
|
||||||
|
|
||||||
|
const backendMetricsByAuthorId = await loadBackendMetricsMap(parsed.ids);
|
||||||
|
const rows: AudienceProfileExportRow[] = [];
|
||||||
|
for (let index = 0; index < parsed.ids.length; index += 1) {
|
||||||
|
const authorId = parsed.ids[index];
|
||||||
|
setToolbarExportStatus(
|
||||||
|
toolbar,
|
||||||
|
`按ID画像导出中 ${index + 1}/${parsed.ids.length}...`
|
||||||
|
);
|
||||||
|
rows.push(
|
||||||
|
await loadAudienceProfileRowById(
|
||||||
|
authorId,
|
||||||
|
backendMetricsByAuthorId.get(authorId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onCsvReady?.(
|
||||||
|
buildAudienceCsv(rows, {
|
||||||
|
selectedHeaders: readAudienceProfileSelectedHeaders()
|
||||||
|
}),
|
||||||
|
buildAudienceProfileFilename(new Date(), "按ID导出")
|
||||||
|
);
|
||||||
|
setToolbarExportStatus(toolbar, "");
|
||||||
|
} catch (error) {
|
||||||
|
setToolbarExportStatus(
|
||||||
|
toolbar,
|
||||||
|
error instanceof Error ? error.message : "按ID导出失败,请稍后重试"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setToolbarBusyState(toolbar, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfigureAudienceProfileFields: async () => {
|
||||||
|
const groups = listAudienceProfileSelectableFieldGroups();
|
||||||
|
const selectedHeaders = readAudienceProfileSelectedHeaders();
|
||||||
|
const nextHeaders = await promptForAudienceProfileFields(
|
||||||
|
options.document,
|
||||||
|
groups,
|
||||||
|
selectedHeaders
|
||||||
|
);
|
||||||
|
if (nextHeaders === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAudienceProfileSelectedHeaders(nextHeaders);
|
||||||
|
setToolbarExportStatus(
|
||||||
|
toolbar,
|
||||||
|
`字段已保存(已选 ${nextHeaders.length}/${readAudienceProfileSelectableHeaders().length} 个字段)`
|
||||||
|
);
|
||||||
|
},
|
||||||
onSubmitBatch: async () => {
|
onSubmitBatch: async () => {
|
||||||
syncSelectionStateFromDom();
|
syncSelectionStateFromDom();
|
||||||
const exportTarget = readToolbarExportTarget(toolbar);
|
const exportTarget = readToolbarExportTarget(toolbar);
|
||||||
@@ -562,6 +760,240 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return selectedRecords.length > 0 ? selectedRecords : records;
|
return selectedRecords.length > 0 ? selectedRecords : records;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
||||||
|
if (selectedAuthorIds.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return records.filter((record) => selectedAuthorIds.has(record.authorId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudienceProfileSet(
|
||||||
|
record: MarketRecord
|
||||||
|
): Promise<AudienceProfileExportRow["profiles"]> {
|
||||||
|
const profiles = {} as AudienceProfileExportRow["profiles"];
|
||||||
|
|
||||||
|
for (const { kind, target } of audienceProfileTargets) {
|
||||||
|
try {
|
||||||
|
profiles[kind] = await loadAudienceProfile(record, target);
|
||||||
|
} catch (error) {
|
||||||
|
profiles[kind] = {
|
||||||
|
failureReason:
|
||||||
|
error instanceof Error ? error.message : "request-failed",
|
||||||
|
status: "failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBusinessAbilitySafe(
|
||||||
|
record: MarketRecord
|
||||||
|
): Promise<BusinessAbilityResult> {
|
||||||
|
try {
|
||||||
|
return await loadBusinessAbility(record);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
failureReason:
|
||||||
|
error instanceof Error ? error.message : "request-failed",
|
||||||
|
status: "failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudienceProfileRowById(
|
||||||
|
authorId: string,
|
||||||
|
backendMetrics?: BackendMetrics
|
||||||
|
): Promise<AudienceProfileExportRow> {
|
||||||
|
const [baseRecord, metricsResult] = await Promise.all([
|
||||||
|
loadAuthorBaseInfoSafe(authorId),
|
||||||
|
loadAuthorMetricsSafe(authorId)
|
||||||
|
]);
|
||||||
|
const recordForRequests = {
|
||||||
|
...baseRecord,
|
||||||
|
authorName: baseRecord.authorName || authorId,
|
||||||
|
...(metricsResult.success ? { rates: metricsResult.rates } : {}),
|
||||||
|
...(backendMetrics
|
||||||
|
? { backendMetrics, backendMetricsStatus: "success" as const }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
const [profiles, businessAbility] = await Promise.all([
|
||||||
|
loadAudienceProfileSet(recordForRequests),
|
||||||
|
loadBusinessAbilitySafe(recordForRequests)
|
||||||
|
]);
|
||||||
|
const failureReasons = collectAudienceProfileRowFailures(
|
||||||
|
baseRecord,
|
||||||
|
profiles,
|
||||||
|
businessAbility
|
||||||
|
);
|
||||||
|
const rowStatus =
|
||||||
|
failureReasons.length === 0
|
||||||
|
? "成功"
|
||||||
|
: hasAudienceProfileRowSuccess(baseRecord, profiles, businessAbility)
|
||||||
|
? "部分成功"
|
||||||
|
: "失败";
|
||||||
|
const authorName = baseRecord.authorName || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
businessAbility,
|
||||||
|
profiles,
|
||||||
|
record: {
|
||||||
|
...recordForRequests,
|
||||||
|
exportFields: {
|
||||||
|
达人ID: authorId,
|
||||||
|
达人名称: authorName,
|
||||||
|
导出状态: rowStatus,
|
||||||
|
失败原因: failureReasons.join("; ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuthorBaseInfoSafe(authorId: string): Promise<MarketRecord> {
|
||||||
|
try {
|
||||||
|
return await loadAuthorBaseInfo(authorId);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
authorId,
|
||||||
|
authorName: "",
|
||||||
|
failureReason:
|
||||||
|
error instanceof Error ? "request-failed" : "request-failed",
|
||||||
|
status: "failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuthorMetricsSafe(
|
||||||
|
authorId: string
|
||||||
|
): Promise<MarketApiResult> {
|
||||||
|
try {
|
||||||
|
return await loadAuthorMetrics(authorId);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
reason: "request-failed",
|
||||||
|
success: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackendMetricsMap(
|
||||||
|
authorIds: string[]
|
||||||
|
): Promise<Map<string, BackendMetrics>> {
|
||||||
|
const metricsMap = new Map<string, BackendMetrics>();
|
||||||
|
if (!searchBackendMetrics || authorIds.length === 0) {
|
||||||
|
return metricsMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await searchBackendMetrics(authorIds);
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const { starId, ...backendMetrics } = row;
|
||||||
|
metricsMap.set(starId, backendMetrics);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return metricsMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metricsMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAudienceProfileRowFailures(
|
||||||
|
baseRecord: MarketRecord,
|
||||||
|
profiles: AudienceProfileExportRow["profiles"],
|
||||||
|
businessAbility: BusinessAbilityResult
|
||||||
|
): string[] {
|
||||||
|
const failures: string[] = [];
|
||||||
|
if (baseRecord.status === "failed") {
|
||||||
|
failures.push(`基础信息:${baseRecord.failureReason ?? "request-failed"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(profiles).forEach(([kind, profile]) => {
|
||||||
|
if (profile.status === "failed") {
|
||||||
|
failures.push(`${readAudienceProfileKindLabel(kind as AudienceProfileKind)}:${profile.failureReason ?? "request-failed"}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (businessAbility.status === "failed") {
|
||||||
|
failures.push(`商业能力:${businessAbility.failureReason ?? "request-failed"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAudienceProfileRowSuccess(
|
||||||
|
baseRecord: MarketRecord,
|
||||||
|
profiles: AudienceProfileExportRow["profiles"],
|
||||||
|
businessAbility: BusinessAbilityResult
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
baseRecord.status === "success" ||
|
||||||
|
businessAbility.status === "success" ||
|
||||||
|
Object.values(profiles).some((profile) => profile.status === "success")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAudienceProfileKindLabel(kind: AudienceProfileKind): string {
|
||||||
|
if (kind === "audience") {
|
||||||
|
return "观众画像";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "fans") {
|
||||||
|
return "粉丝画像";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "铁粉画像";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAudienceProfileSelectableHeaders(): string[] {
|
||||||
|
return listAudienceProfileSelectableFieldGroups().flatMap(
|
||||||
|
(group) => group.headers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAudienceProfileSelectedHeaders(): string[] {
|
||||||
|
const selectableHeaders = readAudienceProfileSelectableHeaders();
|
||||||
|
const selectableHeaderSet = new Set(selectableHeaders);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawValue = options.window.localStorage?.getItem(
|
||||||
|
AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY
|
||||||
|
);
|
||||||
|
if (!rawValue) {
|
||||||
|
return selectableHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = JSON.parse(rawValue) as unknown;
|
||||||
|
if (!Array.isArray(parsedValue)) {
|
||||||
|
return selectableHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedHeaders = parsedValue.filter(
|
||||||
|
(header): header is string =>
|
||||||
|
typeof header === "string" && selectableHeaderSet.has(header)
|
||||||
|
);
|
||||||
|
return selectedHeaders.length > 0 ? selectedHeaders : selectableHeaders;
|
||||||
|
} catch {
|
||||||
|
return selectableHeaders;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAudienceProfileSelectedHeaders(headers: string[]): void {
|
||||||
|
const selectableHeaderSet = new Set(readAudienceProfileSelectableHeaders());
|
||||||
|
const selectedHeaders = headers.filter((header) =>
|
||||||
|
selectableHeaderSet.has(header)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
options.window.localStorage?.setItem(
|
||||||
|
AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY,
|
||||||
|
JSON.stringify(selectedHeaders)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable in hardened browser contexts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareCurrentPageForExport(): Promise<void> {
|
async function prepareCurrentPageForExport(): Promise<void> {
|
||||||
await runSyncCycle();
|
await runSyncCycle();
|
||||||
await harvestCurrentPageForExport();
|
await harvestCurrentPageForExport();
|
||||||
@@ -1270,3 +1702,16 @@ function hasRuntimeMessageSender(): boolean {
|
|||||||
).chrome?.runtime?.sendMessage
|
).chrome?.runtime?.sendMessage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAudienceProfileFilename(
|
||||||
|
date = new Date(),
|
||||||
|
label?: string
|
||||||
|
): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const hour = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minute = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const labelPart = label ? `_${label}` : "";
|
||||||
|
return `达人连接用户画像${labelPart}_${year}${month}${day}_${hour}${minute}.csv`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ import type {
|
|||||||
|
|
||||||
export interface PluginToolbarHandlers {
|
export interface PluginToolbarHandlers {
|
||||||
onExport(): Promise<void> | void;
|
onExport(): Promise<void> | void;
|
||||||
|
onExportAudienceProfile(): Promise<void> | void;
|
||||||
|
onExportAudienceProfileByIds(): Promise<void> | void;
|
||||||
|
onConfigureAudienceProfileFields(): Promise<void> | void;
|
||||||
onSubmitBatch(): Promise<void> | void;
|
onSubmitBatch(): Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginToolbarDom {
|
export interface PluginToolbarDom {
|
||||||
|
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||||
|
audienceProfileExportButton: HTMLButtonElement;
|
||||||
|
audienceProfileFieldButton: HTMLButtonElement;
|
||||||
batchSubmitButton: HTMLButtonElement;
|
batchSubmitButton: HTMLButtonElement;
|
||||||
exportButton: HTMLButtonElement;
|
exportButton: HTMLButtonElement;
|
||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
@@ -37,8 +43,16 @@ export function ensurePluginToolbar(
|
|||||||
"[data-plugin-toolbar='root']"
|
"[data-plugin-toolbar='root']"
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
if (existingRoot) {
|
if (existingRoot) {
|
||||||
ensureToolbarMounted(existingRoot, document);
|
if (
|
||||||
return readToolbarDom(existingRoot);
|
existingRoot.querySelector(
|
||||||
|
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ensureToolbarMounted(existingRoot, document);
|
||||||
|
return readToolbarDom(existingRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
existingRoot.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = document.createElement("section");
|
const root = document.createElement("section");
|
||||||
@@ -47,6 +61,7 @@ export function ensurePluginToolbar(
|
|||||||
|
|
||||||
const exportRangeSelect = document.createElement("select");
|
const exportRangeSelect = document.createElement("select");
|
||||||
exportRangeSelect.dataset.pluginExportRange = "select";
|
exportRangeSelect.dataset.pluginExportRange = "select";
|
||||||
|
exportRangeSelect.hidden = true;
|
||||||
appendOption(exportRangeSelect, "current", "当前页");
|
appendOption(exportRangeSelect, "current", "当前页");
|
||||||
appendOption(exportRangeSelect, "first-5", "前5页");
|
appendOption(exportRangeSelect, "first-5", "前5页");
|
||||||
appendOption(exportRangeSelect, "first-10", "前10页");
|
appendOption(exportRangeSelect, "first-10", "前10页");
|
||||||
@@ -65,12 +80,35 @@ export function ensurePluginToolbar(
|
|||||||
const exportButton = document.createElement("button");
|
const exportButton = document.createElement("button");
|
||||||
exportButton.type = "button";
|
exportButton.type = "button";
|
||||||
exportButton.dataset.pluginExport = "button";
|
exportButton.dataset.pluginExport = "button";
|
||||||
exportButton.textContent = "导出CSV";
|
exportButton.hidden = true;
|
||||||
|
exportButton.tabIndex = -1;
|
||||||
|
|
||||||
|
const audienceProfileExportButton = document.createElement("button");
|
||||||
|
audienceProfileExportButton.type = "button";
|
||||||
|
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
|
||||||
|
audienceProfileExportButton.textContent = "导出选中达人数据";
|
||||||
|
audienceProfileExportButton.title =
|
||||||
|
"仅导出已勾选达人,包含内容数据、效果预估、画像等维度";
|
||||||
|
|
||||||
|
const audienceProfileByIdExportButton = document.createElement("button");
|
||||||
|
audienceProfileByIdExportButton.type = "button";
|
||||||
|
audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button";
|
||||||
|
audienceProfileByIdExportButton.textContent = "按星图ID导出";
|
||||||
|
audienceProfileByIdExportButton.title =
|
||||||
|
"粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选";
|
||||||
|
|
||||||
|
const audienceProfileFieldButton = document.createElement("button");
|
||||||
|
audienceProfileFieldButton.type = "button";
|
||||||
|
audienceProfileFieldButton.dataset.pluginAudienceProfileFields = "button";
|
||||||
|
audienceProfileFieldButton.textContent = "选择字段";
|
||||||
|
audienceProfileFieldButton.title =
|
||||||
|
"勾选本次CSV需要导出的字段,设置会自动保存";
|
||||||
|
|
||||||
const batchSubmitButton = document.createElement("button");
|
const batchSubmitButton = document.createElement("button");
|
||||||
batchSubmitButton.type = "button";
|
batchSubmitButton.type = "button";
|
||||||
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||||
batchSubmitButton.textContent = "提交批次";
|
batchSubmitButton.textContent = "提交批次";
|
||||||
|
batchSubmitButton.title = "将当前选中的达人提交到后续业务批次";
|
||||||
|
|
||||||
const exportStatusText = document.createElement("span");
|
const exportStatusText = document.createElement("span");
|
||||||
exportStatusText.dataset.pluginExportStatus = "text";
|
exportStatusText.dataset.pluginExportStatus = "text";
|
||||||
@@ -80,12 +118,18 @@ export function ensurePluginToolbar(
|
|||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportButton,
|
exportButton,
|
||||||
|
audienceProfileExportButton,
|
||||||
|
audienceProfileByIdExportButton,
|
||||||
|
audienceProfileFieldButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportStatusText
|
exportStatusText
|
||||||
);
|
);
|
||||||
|
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
applyNativeControlStyles(document, {
|
applyNativeControlStyles(document, {
|
||||||
|
audienceProfileExportButton,
|
||||||
|
audienceProfileByIdExportButton,
|
||||||
|
audienceProfileFieldButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
@@ -96,12 +140,24 @@ export function ensurePluginToolbar(
|
|||||||
exportButton.addEventListener("click", () => {
|
exportButton.addEventListener("click", () => {
|
||||||
void handlers.onExport();
|
void handlers.onExport();
|
||||||
});
|
});
|
||||||
|
audienceProfileExportButton.addEventListener("click", () => {
|
||||||
|
void handlers.onExportAudienceProfile();
|
||||||
|
});
|
||||||
|
audienceProfileByIdExportButton.addEventListener("click", () => {
|
||||||
|
void handlers.onExportAudienceProfileByIds();
|
||||||
|
});
|
||||||
|
audienceProfileFieldButton.addEventListener("click", () => {
|
||||||
|
void handlers.onConfigureAudienceProfileFields();
|
||||||
|
});
|
||||||
batchSubmitButton.addEventListener("click", () => {
|
batchSubmitButton.addEventListener("click", () => {
|
||||||
void handlers.onSubmitBatch();
|
void handlers.onSubmitBatch();
|
||||||
});
|
});
|
||||||
exportRangeSelect.addEventListener("change", () => {
|
exportRangeSelect.addEventListener("change", () => {
|
||||||
syncCustomPagesInputVisibility({
|
syncCustomPagesInputVisibility({
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
|
audienceProfileFieldButton,
|
||||||
|
audienceProfileByIdExportButton,
|
||||||
|
audienceProfileExportButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
@@ -111,6 +167,9 @@ export function ensurePluginToolbar(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
|
audienceProfileExportButton,
|
||||||
|
audienceProfileByIdExportButton,
|
||||||
|
audienceProfileFieldButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
@@ -136,6 +195,15 @@ function appendOption(
|
|||||||
|
|
||||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
|
audienceProfileByIdExportButton: root.querySelector(
|
||||||
|
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||||
|
) as HTMLButtonElement,
|
||||||
|
audienceProfileExportButton: root.querySelector(
|
||||||
|
'[data-plugin-export-audience-profile="button"]'
|
||||||
|
) as HTMLButtonElement,
|
||||||
|
audienceProfileFieldButton: root.querySelector(
|
||||||
|
'[data-plugin-audience-profile-fields="button"]'
|
||||||
|
) as HTMLButtonElement,
|
||||||
batchSubmitButton: root.querySelector(
|
batchSubmitButton: root.querySelector(
|
||||||
'[data-plugin-batch-submit="button"]'
|
'[data-plugin-batch-submit="button"]'
|
||||||
) as HTMLButtonElement,
|
) as HTMLButtonElement,
|
||||||
@@ -218,6 +286,9 @@ export function setToolbarBusyState(
|
|||||||
): void {
|
): void {
|
||||||
[
|
[
|
||||||
toolbar.batchSubmitButton,
|
toolbar.batchSubmitButton,
|
||||||
|
toolbar.audienceProfileFieldButton,
|
||||||
|
toolbar.audienceProfileByIdExportButton,
|
||||||
|
toolbar.audienceProfileExportButton,
|
||||||
toolbar.exportButton,
|
toolbar.exportButton,
|
||||||
toolbar.exportRangeSelect,
|
toolbar.exportRangeSelect,
|
||||||
toolbar.exportCustomPagesInput
|
toolbar.exportCustomPagesInput
|
||||||
@@ -234,8 +305,8 @@ export function setToolbarExportStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||||
toolbar.exportCustomPagesInput.hidden =
|
toolbar.exportRangeSelect.hidden = true;
|
||||||
toolbar.exportRangeSelect.value !== "custom";
|
toolbar.exportCustomPagesInput.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||||
@@ -414,6 +485,9 @@ function applyToolbarRootStyles(root: HTMLElement): void {
|
|||||||
function applyNativeControlStyles(
|
function applyNativeControlStyles(
|
||||||
document: Document,
|
document: Document,
|
||||||
controls: {
|
controls: {
|
||||||
|
audienceProfileExportButton: HTMLButtonElement;
|
||||||
|
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||||
|
audienceProfileFieldButton: HTMLButtonElement;
|
||||||
batchSubmitButton: HTMLButtonElement;
|
batchSubmitButton: HTMLButtonElement;
|
||||||
exportButton: HTMLButtonElement;
|
exportButton: HTMLButtonElement;
|
||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
@@ -429,11 +503,18 @@ function applyNativeControlStyles(
|
|||||||
findNativeActionButton(document, "导出");
|
findNativeActionButton(document, "导出");
|
||||||
|
|
||||||
if (nativeButton) {
|
if (nativeButton) {
|
||||||
controls.exportButton.className = nativeButton.className;
|
controls.audienceProfileExportButton.className = nativeButton.className;
|
||||||
|
controls.audienceProfileByIdExportButton.className = nativeButton.className;
|
||||||
|
controls.audienceProfileFieldButton.className = nativeButton.className;
|
||||||
controls.batchSubmitButton.className = nativeButton.className;
|
controls.batchSubmitButton.className = nativeButton.className;
|
||||||
}
|
}
|
||||||
|
|
||||||
[controls.exportButton, controls.batchSubmitButton].forEach((button) => {
|
[
|
||||||
|
controls.audienceProfileExportButton,
|
||||||
|
controls.audienceProfileByIdExportButton,
|
||||||
|
controls.audienceProfileFieldButton,
|
||||||
|
controls.batchSubmitButton
|
||||||
|
].forEach((button) => {
|
||||||
applyPrimaryButtonStyles(button);
|
applyPrimaryButtonStyles(button);
|
||||||
button.style.whiteSpace = "nowrap";
|
button.style.whiteSpace = "nowrap";
|
||||||
});
|
});
|
||||||
@@ -483,26 +564,34 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
|||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
[data-plugin-export="button"]:hover:not(:disabled),
|
[data-plugin-export-audience-profile="button"]:hover:not(:disabled),
|
||||||
|
[data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled),
|
||||||
|
[data-plugin-audience-profile-fields="button"]:hover:not(:disabled),
|
||||||
[data-plugin-batch-submit="button"]:hover:not(:disabled) {
|
[data-plugin-batch-submit="button"]:hover:not(:disabled) {
|
||||||
background-color: #6d1627 !important;
|
background-color: #6d1627 !important;
|
||||||
border-color: #6d1627 !important;
|
border-color: #6d1627 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-plugin-export="button"]:active:not(:disabled),
|
[data-plugin-export-audience-profile="button"]:active:not(:disabled),
|
||||||
|
[data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled),
|
||||||
|
[data-plugin-audience-profile-fields="button"]:active:not(:disabled),
|
||||||
[data-plugin-batch-submit="button"]:active:not(:disabled) {
|
[data-plugin-batch-submit="button"]:active:not(:disabled) {
|
||||||
background-color: #58111f !important;
|
background-color: #58111f !important;
|
||||||
border-color: #58111f !important;
|
border-color: #58111f !important;
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-plugin-export="button"]:focus-visible,
|
[data-plugin-export-audience-profile="button"]:focus-visible,
|
||||||
|
[data-plugin-export-audience-profile-by-id="button"]:focus-visible,
|
||||||
|
[data-plugin-audience-profile-fields="button"]:focus-visible,
|
||||||
[data-plugin-batch-submit="button"]:focus-visible {
|
[data-plugin-batch-submit="button"]:focus-visible {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
|
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-plugin-export="button"]:disabled,
|
[data-plugin-export-audience-profile="button"]:disabled,
|
||||||
|
[data-plugin-export-audience-profile-by-id="button"]:disabled,
|
||||||
|
[data-plugin-audience-profile-fields="button"]:disabled,
|
||||||
[data-plugin-batch-submit="button"]:disabled {
|
[data-plugin-batch-submit="button"]:disabled {
|
||||||
background-color: #c89ca4 !important;
|
background-color: #c89ca4 !important;
|
||||||
border-color: #c89ca4 !important;
|
border-color: #c89ca4 !important;
|
||||||
|
|||||||
+137
-5
@@ -2,6 +2,8 @@ import {
|
|||||||
renderDevPanel,
|
renderDevPanel,
|
||||||
renderLoggedIn,
|
renderLoggedIn,
|
||||||
renderLoggedOut,
|
renderLoggedOut,
|
||||||
|
renderUpdateStatus,
|
||||||
|
setUpdateDownloadStatus,
|
||||||
setProtectedApiResult
|
setProtectedApiResult
|
||||||
} from "./view";
|
} from "./view";
|
||||||
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
|
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
|
||||||
@@ -10,17 +12,27 @@ import {
|
|||||||
type AuthResponseMessage
|
type AuthResponseMessage
|
||||||
} from "../shared/auth-messages";
|
} from "../shared/auth-messages";
|
||||||
import { createProtectedApiClient } from "../shared/protected-api-client";
|
import { createProtectedApiClient } from "../shared/protected-api-client";
|
||||||
|
import {
|
||||||
|
compareExtensionVersions,
|
||||||
|
fetchUpdateManifest as fetchUpdateManifestFromUrl,
|
||||||
|
type UpdateManifest
|
||||||
|
} from "../shared/update-check";
|
||||||
|
import { UPDATE_MANIFEST_URL } from "../shared/update-config";
|
||||||
|
|
||||||
interface BootPopupOptions {
|
interface BootPopupOptions {
|
||||||
config?: Partial<AuthConfig>;
|
config?: Partial<AuthConfig>;
|
||||||
|
currentVersion?: string;
|
||||||
document?: Document;
|
document?: Document;
|
||||||
fetchProtectedApi?: () => Promise<unknown>;
|
fetchProtectedApi?: () => Promise<unknown>;
|
||||||
|
fetchUpdateManifest?: () => Promise<UpdateManifest>;
|
||||||
sendMessage?: (message: unknown) => Promise<unknown>;
|
sendMessage?: (message: unknown) => Promise<unknown>;
|
||||||
|
updateManifestUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
||||||
const currentDocument = options.document ?? document;
|
const currentDocument = options.document ?? document;
|
||||||
const popupConfig = readAuthConfig(options.config);
|
const popupConfig = readAuthConfig(options.config);
|
||||||
|
const currentVersion = options.currentVersion ?? readCurrentVersion();
|
||||||
const root = currentDocument.querySelector("#app");
|
const root = currentDocument.querySelector("#app");
|
||||||
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
||||||
|
|
||||||
@@ -48,15 +60,28 @@ export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
|||||||
baseUrl: "http://127.0.0.1:4319",
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
sendMessage
|
sendMessage
|
||||||
}).loadProtectedMockData;
|
}).loadProtectedMockData;
|
||||||
|
const fetchUpdateManifest =
|
||||||
|
options.fetchUpdateManifest ??
|
||||||
|
(() =>
|
||||||
|
fetchUpdateManifestFromUrl(
|
||||||
|
options.updateManifestUrl ?? UPDATE_MANIFEST_URL
|
||||||
|
));
|
||||||
|
|
||||||
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi);
|
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, {
|
||||||
|
currentVersion,
|
||||||
|
fetchUpdateManifest
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderCurrentAuthState(
|
async function renderCurrentAuthState(
|
||||||
root: HTMLElement,
|
root: HTMLElement,
|
||||||
popupConfig: AuthConfig,
|
popupConfig: AuthConfig,
|
||||||
sendMessage: (message: unknown) => Promise<unknown>,
|
sendMessage: (message: unknown) => Promise<unknown>,
|
||||||
fetchProtectedApi: () => Promise<unknown>
|
fetchProtectedApi: () => Promise<unknown>,
|
||||||
|
updateOptions: {
|
||||||
|
currentVersion: string;
|
||||||
|
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||||
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await sendMessage({ type: "auth:get-state" });
|
const response = await sendMessage({ type: "auth:get-state" });
|
||||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||||
@@ -71,19 +96,22 @@ async function renderCurrentAuthState(
|
|||||||
?.addEventListener("click", () => {
|
?.addEventListener("click", () => {
|
||||||
void runAuthAction(root, popupConfig, sendMessage, {
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
actionMessage: { type: "auth:sign-in" },
|
actionMessage: { type: "auth:sign-in" },
|
||||||
fetchProtectedApi
|
fetchProtectedApi,
|
||||||
|
updateOptions
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLoggedIn(root, response.value);
|
renderLoggedIn(root, response.value);
|
||||||
|
void runUpdateCheck(root, sendMessage, updateOptions);
|
||||||
root
|
root
|
||||||
.querySelector('[data-popup-sign-out="button"]')
|
.querySelector('[data-popup-sign-out="button"]')
|
||||||
?.addEventListener("click", () => {
|
?.addEventListener("click", () => {
|
||||||
void runAuthAction(root, popupConfig, sendMessage, {
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
actionMessage: { type: "auth:sign-out" },
|
actionMessage: { type: "auth:sign-out" },
|
||||||
fetchProtectedApi
|
fetchProtectedApi,
|
||||||
|
updateOptions
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (popupConfig.enableDevAuthPanel) {
|
if (popupConfig.enableDevAuthPanel) {
|
||||||
@@ -103,6 +131,10 @@ async function runAuthAction(
|
|||||||
options: {
|
options: {
|
||||||
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
|
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
|
||||||
fetchProtectedApi: () => Promise<unknown>;
|
fetchProtectedApi: () => Promise<unknown>;
|
||||||
|
updateOptions: {
|
||||||
|
currentVersion: string;
|
||||||
|
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await sendMessage(options.actionMessage);
|
const response = await sendMessage(options.actionMessage);
|
||||||
@@ -121,7 +153,8 @@ async function runAuthAction(
|
|||||||
root,
|
root,
|
||||||
popupConfig,
|
popupConfig,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
options.fetchProtectedApi
|
options.fetchProtectedApi,
|
||||||
|
options.updateOptions
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +166,105 @@ function isActionError(response: unknown): response is Extract<AuthResponseMessa
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runUpdateCheck(
|
||||||
|
root: HTMLElement,
|
||||||
|
sendMessage: (message: unknown) => Promise<unknown>,
|
||||||
|
options: {
|
||||||
|
currentVersion: string;
|
||||||
|
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
renderUpdateStatus(root, {
|
||||||
|
currentVersion: options.currentVersion,
|
||||||
|
status: "checking"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = await options.fetchUpdateManifest();
|
||||||
|
if (compareExtensionVersions(manifest.latestVersion, options.currentVersion) <= 0) {
|
||||||
|
renderUpdateStatus(root, {
|
||||||
|
currentVersion: options.currentVersion,
|
||||||
|
status: "latest"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderUpdateStatus(root, {
|
||||||
|
currentVersion: options.currentVersion,
|
||||||
|
manifest,
|
||||||
|
status: "available"
|
||||||
|
});
|
||||||
|
bindUpdateDownloadButtons(root, sendMessage, manifest);
|
||||||
|
} catch {
|
||||||
|
renderUpdateStatus(root, {
|
||||||
|
currentVersion: options.currentVersion,
|
||||||
|
status: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindUpdateDownloadButtons(
|
||||||
|
root: HTMLElement,
|
||||||
|
sendMessage: (message: unknown) => Promise<unknown>,
|
||||||
|
manifest: UpdateManifest
|
||||||
|
): void {
|
||||||
|
root
|
||||||
|
.querySelector('[data-popup-download-update="button"]')
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
void downloadUpdateAsset(root, sendMessage, {
|
||||||
|
filename: "star-chart-search-enhancer-internal.zip",
|
||||||
|
url: manifest.zipUrl
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
root
|
||||||
|
.querySelector('[data-popup-download-guide="button"]')
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
void downloadUpdateAsset(root, sendMessage, {
|
||||||
|
filename: "星图增强插件-超简单安装使用指南.pdf",
|
||||||
|
url: manifest.guideUrl
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadUpdateAsset(
|
||||||
|
root: HTMLElement,
|
||||||
|
sendMessage: (message: unknown) => Promise<unknown>,
|
||||||
|
options: {
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
setUpdateDownloadStatus(root, "正在下载...");
|
||||||
|
try {
|
||||||
|
await sendMessage({
|
||||||
|
filename: options.filename,
|
||||||
|
type: "update:download",
|
||||||
|
url: options.url
|
||||||
|
});
|
||||||
|
setUpdateDownloadStatus(root, "已触发下载。下载后请解压新版 zip,并在 chrome://extensions 里重新加载插件。");
|
||||||
|
} catch (error) {
|
||||||
|
setUpdateDownloadStatus(
|
||||||
|
root,
|
||||||
|
error instanceof Error ? error.message : "下载失败,请稍后重试"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCurrentVersion(): string {
|
||||||
|
const runtime = (
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
chrome?: {
|
||||||
|
runtime?: {
|
||||||
|
getManifest?: () => { version?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).chrome?.runtime;
|
||||||
|
|
||||||
|
return runtime?.getManifest?.().version ?? "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
async function runProtectedApiProbe(
|
async function runProtectedApiProbe(
|
||||||
root: HTMLElement,
|
root: HTMLElement,
|
||||||
fetchProtectedApi: () => Promise<unknown>
|
fetchProtectedApi: () => Promise<unknown>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AuthStateValue } from "../shared/auth-messages";
|
import type { AuthStateValue } from "../shared/auth-messages";
|
||||||
|
import type { UpdateManifest } from "../shared/update-check";
|
||||||
|
|
||||||
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
|
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -23,11 +24,99 @@ export function renderLoggedIn(
|
|||||||
<p>已登录</p>
|
<p>已登录</p>
|
||||||
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
|
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
|
||||||
<p>${userInfo?.email ?? ""}</p>
|
<p>${userInfo?.email ?? ""}</p>
|
||||||
|
<section data-popup-update="root">
|
||||||
|
<h2>版本更新</h2>
|
||||||
|
<p data-popup-update-status="text">正在检查更新...</p>
|
||||||
|
</section>
|
||||||
<button type="button" data-popup-sign-out="button">退出登录</button>
|
<button type="button" data-popup-sign-out="button">退出登录</button>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderUpdateStatus(
|
||||||
|
root: HTMLElement,
|
||||||
|
options: {
|
||||||
|
currentVersion: string;
|
||||||
|
manifest?: UpdateManifest;
|
||||||
|
status: "checking" | "error" | "latest" | "available";
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
const container = root.querySelector('[data-popup-update="root"]');
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.status === "checking") {
|
||||||
|
container.innerHTML = `
|
||||||
|
<h2>版本更新</h2>
|
||||||
|
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
||||||
|
<p>正在检查更新...</p>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.status === "error") {
|
||||||
|
container.innerHTML = `
|
||||||
|
<h2>版本更新</h2>
|
||||||
|
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
||||||
|
<p>暂时无法检查更新</p>
|
||||||
|
<p>如果需要新版,请联系维护同事获取更新包。</p>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.status === "latest" || !options.manifest) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<h2>版本更新</h2>
|
||||||
|
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
||||||
|
<p>当前已是最新版本</p>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<h2>版本更新</h2>
|
||||||
|
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
||||||
|
<p>发现新版本:${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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUpdateDownloadStatus(
|
||||||
|
root: HTMLElement,
|
||||||
|
value: string
|
||||||
|
): void {
|
||||||
|
const output = root.querySelector('[data-popup-update-download-status="text"]');
|
||||||
|
if (!output) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReleaseNotes(releaseNotes: string[]): string {
|
||||||
|
if (releaseNotes.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<ul>
|
||||||
|
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderDevPanel(
|
export function renderDevPanel(
|
||||||
root: HTMLElement,
|
root: HTMLElement,
|
||||||
authState: AuthStateValue
|
authState: AuthStateValue
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://localhost:8083";
|
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.21:8083";
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export interface UpdateManifest {
|
||||||
|
guideUrl: string;
|
||||||
|
latestVersion: string;
|
||||||
|
minSupportedVersion: string;
|
||||||
|
publishedAt: string;
|
||||||
|
releaseNotes: string[];
|
||||||
|
zipUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareExtensionVersions(left: string, right: string): number {
|
||||||
|
const leftParts = parseVersionParts(left);
|
||||||
|
const rightParts = parseVersionParts(right);
|
||||||
|
const maxLength = Math.max(leftParts.length, rightParts.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < maxLength; index += 1) {
|
||||||
|
const leftValue = leftParts[index] ?? 0;
|
||||||
|
const rightValue = rightParts[index] ?? 0;
|
||||||
|
if (leftValue !== rightValue) {
|
||||||
|
return leftValue - rightValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUpdateManifest(value: unknown): UpdateManifest | null {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<UpdateManifest>;
|
||||||
|
if (
|
||||||
|
!isVersionString(candidate.latestVersion) ||
|
||||||
|
!isVersionString(candidate.minSupportedVersion) ||
|
||||||
|
!isHttpsUrl(candidate.zipUrl) ||
|
||||||
|
!isHttpsUrl(candidate.guideUrl) ||
|
||||||
|
typeof candidate.publishedAt !== "string" ||
|
||||||
|
!Array.isArray(candidate.releaseNotes) ||
|
||||||
|
!candidate.releaseNotes.every((note) => typeof note === "string")
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
guideUrl: candidate.guideUrl,
|
||||||
|
latestVersion: candidate.latestVersion,
|
||||||
|
minSupportedVersion: candidate.minSupportedVersion,
|
||||||
|
publishedAt: candidate.publishedAt,
|
||||||
|
releaseNotes: candidate.releaseNotes,
|
||||||
|
zipUrl: candidate.zipUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUpdateManifest(
|
||||||
|
manifestUrl: string,
|
||||||
|
fetchImpl: typeof fetch = fetch
|
||||||
|
): Promise<UpdateManifest> {
|
||||||
|
const response = await fetchImpl(manifestUrl, {
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`update manifest request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = parseUpdateManifest(await response.json());
|
||||||
|
if (!manifest) {
|
||||||
|
throw new Error("update manifest is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersionParts(value: string): number[] {
|
||||||
|
return value.split(".").map((part) => {
|
||||||
|
const parsed = Number.parseInt(part, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVersionString(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpsUrl(value: unknown): value is string {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(value).protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const UPDATE_MANIFEST_URL =
|
||||||
|
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json";
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUDIENCE_PROFILE_TARGETS,
|
||||||
|
createAudienceProfileClient,
|
||||||
|
mapAudienceProfileResponse
|
||||||
|
} from "../src/content/market/audience-profile-client";
|
||||||
|
|
||||||
|
describe("audience-profile-client", () => {
|
||||||
|
test("loads connection user audience distributions from Xingtu", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => ({
|
||||||
|
json: async () => buildAudiencePayload(),
|
||||||
|
ok: true
|
||||||
|
}));
|
||||||
|
const client = createAudienceProfileClient({
|
||||||
|
baseUrl: "https://www.xingtu.cn",
|
||||||
|
fetchImpl,
|
||||||
|
timeoutMs: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.loadAudienceProfile({
|
||||||
|
authorId: "7294473194298146854",
|
||||||
|
authorName: "奇奇de海洋",
|
||||||
|
status: "success"
|
||||||
|
}, AUDIENCE_PROFILE_TARGETS.audience);
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
|
"https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=5",
|
||||||
|
expect.objectContaining({
|
||||||
|
credentials: "include",
|
||||||
|
method: "GET"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "success",
|
||||||
|
gender: [
|
||||||
|
{ label: "男性", value: "71.7%" },
|
||||||
|
{ label: "女性", value: "28.3%" }
|
||||||
|
],
|
||||||
|
cityTop: expect.arrayContaining([{ label: "广州", value: "30.4%" }])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads fans and iron fan profiles from Xingtu fan distribution endpoint", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => ({
|
||||||
|
json: async () => buildAudiencePayload(),
|
||||||
|
ok: true
|
||||||
|
}));
|
||||||
|
const client = createAudienceProfileClient({
|
||||||
|
baseUrl: "https://www.xingtu.cn",
|
||||||
|
fetchImpl,
|
||||||
|
timeoutMs: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.loadAudienceProfile({
|
||||||
|
authorId: "7294473194298146854",
|
||||||
|
authorName: "奇奇de海洋",
|
||||||
|
status: "success"
|
||||||
|
}, AUDIENCE_PROFILE_TARGETS.longtimeFans);
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
|
"https://www.xingtu.cn/gw/api/data_sp/get_author_fans_distribution?o_author_id=7294473194298146854&platform_source=1&author_type=5",
|
||||||
|
expect.objectContaining({
|
||||||
|
credentials: "include",
|
||||||
|
method: "GET"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps Xingtu audience distribution payload into named profile sections", () => {
|
||||||
|
const result = mapAudienceProfileResponse(buildAudiencePayload());
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "success",
|
||||||
|
age: [
|
||||||
|
{ label: "18-23", value: "20%" },
|
||||||
|
{ label: "24-30", value: "30%" },
|
||||||
|
{ label: "31-40", value: "50%" }
|
||||||
|
],
|
||||||
|
province: [
|
||||||
|
{ label: "广东", value: "60%" },
|
||||||
|
{ label: "浙江", value: "40%" }
|
||||||
|
],
|
||||||
|
cityTier: [{ label: "一线城市", value: "100%" }],
|
||||||
|
interest: [{ label: "随拍", value: "100%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "100%" }]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildAudiencePayload() {
|
||||||
|
return {
|
||||||
|
base_resp: {
|
||||||
|
status_code: 0,
|
||||||
|
status_message: ""
|
||||||
|
},
|
||||||
|
distributions: [
|
||||||
|
{
|
||||||
|
distribution_list: [
|
||||||
|
{ distribution_key: "male", distribution_value: 717 },
|
||||||
|
{ distribution_key: "female", distribution_value: 283 }
|
||||||
|
],
|
||||||
|
type_display: "性别分布"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
distribution_list: [
|
||||||
|
{ distribution_key: "31-40", distribution_value: 50 },
|
||||||
|
{ distribution_key: "18-23", distribution_value: 20 },
|
||||||
|
{ distribution_key: "24-30", distribution_value: 30 }
|
||||||
|
],
|
||||||
|
type_display: "年龄分布"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
distribution_list: [
|
||||||
|
{ distribution_key: "浙江", distribution_value: 40 },
|
||||||
|
{ distribution_key: "广东", distribution_value: 60 }
|
||||||
|
],
|
||||||
|
type_display: "省份分布"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
distribution_list: [
|
||||||
|
{ distribution_key: "广州", distribution_value: 304 },
|
||||||
|
{ distribution_key: "北京", distribution_value: 291 },
|
||||||
|
{ distribution_key: "上海", distribution_value: 405 }
|
||||||
|
],
|
||||||
|
type_display: "城市分布"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
distribution_list: [{ distribution_key: "一线", distribution_value: 1 }],
|
||||||
|
type_display: "城市等级分布"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
distribution_list: [{ distribution_key: "随拍", distribution_value: 1 }],
|
||||||
|
type_display: "兴趣分布"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
distribution_list: [{ distribution_key: "都市蓝领", distribution_value: 1 }],
|
||||||
|
type_display: "八大人群分布"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildAudienceProfileCsv,
|
||||||
|
listAudienceProfileSelectableFieldGroups,
|
||||||
|
listAudienceProfileCsvHeaders
|
||||||
|
} from "../src/content/market/audience-profile-csv";
|
||||||
|
import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types";
|
||||||
|
|
||||||
|
describe("audience-profile-csv", () => {
|
||||||
|
test("exports only requested profile distribution columns", () => {
|
||||||
|
const csv = buildAudienceProfileCsv([
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
audience: {
|
||||||
|
age: [{ label: "31-40", value: "50%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "100%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "100%" }],
|
||||||
|
gender: [
|
||||||
|
{ label: "男性", value: "71.7%" },
|
||||||
|
{ label: "女性", value: "28.3%" }
|
||||||
|
],
|
||||||
|
status: "success"
|
||||||
|
},
|
||||||
|
fans: {
|
||||||
|
age: [{ label: "31-40", value: "40%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "80%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "60%" }],
|
||||||
|
gender: [
|
||||||
|
{ label: "男性", value: "60%" },
|
||||||
|
{ label: "女性", value: "40%" }
|
||||||
|
],
|
||||||
|
status: "success"
|
||||||
|
},
|
||||||
|
longtimeFans: {
|
||||||
|
age: [{ label: "31-40", value: "30%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "70%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "50%" }],
|
||||||
|
status: "success"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
businessAbility: {
|
||||||
|
estimates: {
|
||||||
|
oneToTwenty: {
|
||||||
|
expectedCpe: "2.1",
|
||||||
|
expectedCpm: "120.0",
|
||||||
|
expectedPlay: "250w",
|
||||||
|
hotRate: "100%"
|
||||||
|
},
|
||||||
|
twentyToSixty: {
|
||||||
|
expectedCpe: "3.7",
|
||||||
|
expectedCpm: "212.0",
|
||||||
|
expectedPlay: "250w",
|
||||||
|
hotRate: "缺失"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: "success",
|
||||||
|
videos: {
|
||||||
|
personalVideo: {
|
||||||
|
averageComment: "4.5w",
|
||||||
|
averageDuration: "150s",
|
||||||
|
averageLike: "113.2w",
|
||||||
|
averageShare: "26.5w",
|
||||||
|
finishRate: "15.8%",
|
||||||
|
interactionRate: "3.9%",
|
||||||
|
medianPlay: "3738.4w",
|
||||||
|
publishedItems: "<5"
|
||||||
|
},
|
||||||
|
xingtuVideo: {
|
||||||
|
averageComment: "5.1w",
|
||||||
|
averageDuration: "170s",
|
||||||
|
averageLike: "150.3w",
|
||||||
|
averageShare: "68.4w",
|
||||||
|
finishRate: "19.9%",
|
||||||
|
interactionRate: "5.5%",
|
||||||
|
medianPlay: "4059.7w",
|
||||||
|
publishedItems: "<5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
authorId: "123",
|
||||||
|
authorName: "达人 A",
|
||||||
|
exportFields: {
|
||||||
|
达人信息: "达人 A",
|
||||||
|
连接用户数: "300w"
|
||||||
|
},
|
||||||
|
status: "success"
|
||||||
|
}
|
||||||
|
} satisfies AudienceProfileExportRow
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
|
expect(headerLine).toContain("达人信息,连接用户数");
|
||||||
|
expect(headerLine).not.toContain("抓取状态");
|
||||||
|
expect(headerLine).not.toContain("失败原因");
|
||||||
|
expect(headerLine).toContain("内容数据-个人视频-播放量中位数");
|
||||||
|
expect(headerLine).toContain("内容数据-星图视频-平均转发");
|
||||||
|
expect(headerLine).toContain("效果预估-1-20s视频-预期CPM");
|
||||||
|
expect(headerLine).toContain("效果预估-20-60s视频-爆文率");
|
||||||
|
expect(headerLine).toContain("效果预估-60s以上视频-预期播放量");
|
||||||
|
expect(headerLine).not.toContain("商业能力-个人视频-播放量中位数");
|
||||||
|
expect(headerLine).not.toContain("商业能力-20-60s视频-预期CPM");
|
||||||
|
expect(headerLine).toContain("观众画像-男性占比");
|
||||||
|
expect(headerLine).toContain("粉丝画像-女性占比");
|
||||||
|
expect(headerLine).not.toContain("铁粉画像-男性占比");
|
||||||
|
expect(headerLine).toContain("观众画像-31-40占比");
|
||||||
|
expect(headerLine).toContain("粉丝画像-一线城市占比");
|
||||||
|
expect(headerLine).toContain("铁粉画像-都市蓝领占比");
|
||||||
|
expect(headerLine).not.toContain("观众画像-新一线城市占比");
|
||||||
|
expect(headerLine).not.toContain("粉丝画像-新一线城市占比");
|
||||||
|
expect(headerLine).not.toContain("铁粉画像-新一线城市占比");
|
||||||
|
expect(headerLine).not.toContain("省份");
|
||||||
|
expect(headerLine).not.toContain("地域TOP");
|
||||||
|
expect(headerLine).not.toContain("兴趣TOP");
|
||||||
|
expect(rowLine).toContain("71.7%");
|
||||||
|
expect(rowLine).toContain("60%");
|
||||||
|
expect(readCsvValue(csv, "内容数据-个人视频-播放量中位数")).toBe("3738.4w");
|
||||||
|
expect(readCsvValue(csv, "内容数据-星图视频-平均转发")).toBe("68.4w");
|
||||||
|
expect(readCsvValue(csv, "效果预估-1-20s视频-预期CPM")).toBe("120.0");
|
||||||
|
expect(readCsvValue(csv, "效果预估-20-60s视频-爆文率")).toBe("缺失");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves distribution cells empty when profile loading fails", () => {
|
||||||
|
const csv = buildAudienceProfileCsv([
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
audience: {
|
||||||
|
failureReason: "request-failed",
|
||||||
|
status: "failed"
|
||||||
|
},
|
||||||
|
fans: {
|
||||||
|
failureReason: "timeout",
|
||||||
|
status: "failed"
|
||||||
|
},
|
||||||
|
longtimeFans: {
|
||||||
|
status: "failed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
authorId: "123",
|
||||||
|
authorName: "达人 A",
|
||||||
|
status: "success"
|
||||||
|
}
|
||||||
|
} satisfies AudienceProfileExportRow
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
|
expect(rowLine).not.toContain("失败");
|
||||||
|
expect(rowLine).not.toContain("request-failed");
|
||||||
|
expect(rowLine).not.toContain("timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fills missing fixed distribution buckets with zero for successful profiles", () => {
|
||||||
|
const csv = buildAudienceProfileCsv([
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
audience: { status: "success" },
|
||||||
|
fans: { status: "success" },
|
||||||
|
longtimeFans: {
|
||||||
|
age: [
|
||||||
|
{ label: "18-23", value: "11.1%" },
|
||||||
|
{ label: "24-30", value: "33.3%" },
|
||||||
|
{ label: "31-40", value: "55.6%" }
|
||||||
|
],
|
||||||
|
cityTier: [
|
||||||
|
{ label: "一线城市", value: "10%" },
|
||||||
|
{ label: "二线城市", value: "20%" },
|
||||||
|
{ label: "三线城市", value: "40%" },
|
||||||
|
{ label: "四线城市", value: "30%" }
|
||||||
|
],
|
||||||
|
crowd: [
|
||||||
|
{ label: "精致妈妈", value: "30%" },
|
||||||
|
{ label: "新锐白领", value: "20%" },
|
||||||
|
{ label: "资深中产", value: "10%" },
|
||||||
|
{ label: "都市蓝领", value: "20%" },
|
||||||
|
{ label: "小镇中老年", value: "10%" },
|
||||||
|
{ label: "小镇青年", value: "10%" }
|
||||||
|
],
|
||||||
|
status: "success"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
authorId: "123",
|
||||||
|
authorName: "达人 A",
|
||||||
|
status: "success"
|
||||||
|
}
|
||||||
|
} satisfies AudienceProfileExportRow
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(readCsvValue(csv, "铁粉画像-41-50占比")).toBe("0%");
|
||||||
|
expect(readCsvValue(csv, "铁粉画像-50+占比")).toBe("0%");
|
||||||
|
expect(readCsvValue(csv, "铁粉画像-五线城市占比")).toBe("0%");
|
||||||
|
expect(readCsvValue(csv, "铁粉画像-都市银发占比")).toBe("0%");
|
||||||
|
expect(readCsvValue(csv, "铁粉画像-Z世代占比")).toBe("0%");
|
||||||
|
expect(csv.split("\n")[0]).not.toContain("新一线城市占比");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters export columns by selected headers", () => {
|
||||||
|
const row = buildSuccessRow();
|
||||||
|
const csv = buildAudienceProfileCsv([row], {
|
||||||
|
selectedHeaders: [
|
||||||
|
"内容数据-个人视频-播放量中位数",
|
||||||
|
"观众画像-男性占比"
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
|
expect(headerLine).toBe(
|
||||||
|
"达人信息,连接用户数,内容数据-个人视频-播放量中位数,观众画像-男性占比"
|
||||||
|
);
|
||||||
|
expect(rowLine).toBe("达人 A,300w,3738.4w,71.7%");
|
||||||
|
expect(headerLine).not.toContain("秒思api-看后搜数");
|
||||||
|
expect(headerLine).not.toContain("粉丝画像-女性占比");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("always keeps fixed id export headers when filtering", () => {
|
||||||
|
const row = buildSuccessRow({
|
||||||
|
exportFields: {
|
||||||
|
达人ID: "123",
|
||||||
|
达人名称: "达人 A",
|
||||||
|
导出状态: "成功",
|
||||||
|
失败原因: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const csv = buildAudienceProfileCsv([row], {
|
||||||
|
selectedHeaders: ["内容数据-个人视频-播放量中位数"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
|
expect(headerLine).toBe(
|
||||||
|
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-播放量中位数"
|
||||||
|
);
|
||||||
|
expect(rowLine).toBe("123,达人 A,成功,,3738.4w");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists headers for field picker defaults", () => {
|
||||||
|
expect(listAudienceProfileCsvHeaders([buildSuccessRow()])).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"达人信息",
|
||||||
|
"连接用户数",
|
||||||
|
"秒思api-看后搜数",
|
||||||
|
"内容数据-个人视频-播放量中位数",
|
||||||
|
"效果预估-20-60s视频-预期CPM",
|
||||||
|
"观众画像-男性占比",
|
||||||
|
"铁粉画像-小镇青年占比"
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups selectable profile export fields", () => {
|
||||||
|
expect(listAudienceProfileSelectableFieldGroups()).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.arrayContaining(["秒思api-看后搜数"]),
|
||||||
|
label: "秒思api数据"
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.arrayContaining(["内容数据-个人视频-播放量中位数"]),
|
||||||
|
label: "内容数据"
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.arrayContaining(["效果预估-20-60s视频-预期CPM"]),
|
||||||
|
label: "效果预估"
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.arrayContaining(["观众画像-男性占比"]),
|
||||||
|
label: "观众画像"
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function readCsvValue(csv: string, header: string): string {
|
||||||
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
const headers = headerLine.split(",");
|
||||||
|
const values = rowLine.split(",");
|
||||||
|
const index = headers.indexOf(header);
|
||||||
|
|
||||||
|
expect(index).toBeGreaterThanOrEqual(0);
|
||||||
|
return values[index] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSuccessRow(
|
||||||
|
overrides: Partial<AudienceProfileExportRow["record"]> = {}
|
||||||
|
): AudienceProfileExportRow {
|
||||||
|
return {
|
||||||
|
profiles: {
|
||||||
|
audience: {
|
||||||
|
age: [{ label: "31-40", value: "50%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "100%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "100%" }],
|
||||||
|
gender: [{ label: "男性", value: "71.7%" }],
|
||||||
|
status: "success"
|
||||||
|
},
|
||||||
|
fans: { status: "success" },
|
||||||
|
longtimeFans: { status: "success" }
|
||||||
|
},
|
||||||
|
businessAbility: {
|
||||||
|
estimates: {
|
||||||
|
twentyToSixty: {
|
||||||
|
expectedCpe: "3.7",
|
||||||
|
expectedCpm: "212.0",
|
||||||
|
expectedPlay: "250w",
|
||||||
|
hotRate: "缺失"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: "success",
|
||||||
|
videos: {
|
||||||
|
personalVideo: {
|
||||||
|
medianPlay: "3738.4w"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
authorId: "123",
|
||||||
|
authorName: "达人 A",
|
||||||
|
exportFields: {
|
||||||
|
达人信息: "达人 A",
|
||||||
|
连接用户数: "300w"
|
||||||
|
},
|
||||||
|
status: "success",
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildAuthorBaseInfoUrl,
|
||||||
|
createAuthorBaseClient,
|
||||||
|
mapAuthorBaseInfoResponse
|
||||||
|
} from "../src/content/market/author-base-client";
|
||||||
|
|
||||||
|
describe("author-base-client", () => {
|
||||||
|
test("builds Xingtu author base info url", () => {
|
||||||
|
expect(
|
||||||
|
buildAuthorBaseInfoUrl("6866044569306267651", "https://www.xingtu.cn")
|
||||||
|
).toBe(
|
||||||
|
"https://www.xingtu.cn/gw/api/author/get_author_base_info?o_author_id=6866044569306267651&platform_source=1&platform_channel=1&recommend=true&need_sec_uid=true&need_linkage_info=true"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps author nickname into a market record", () => {
|
||||||
|
expect(mapAuthorBaseInfoResponse("6866044569306267651", {
|
||||||
|
base_resp: { status_code: 0, status_message: "Success" },
|
||||||
|
nick_name: "小九儿"
|
||||||
|
})).toEqual({
|
||||||
|
authorId: "6866044569306267651",
|
||||||
|
authorName: "小九儿",
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads author base info from Xingtu", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => ({
|
||||||
|
json: async () => ({
|
||||||
|
base_resp: { status_code: 0, status_message: "Success" },
|
||||||
|
nick_name: "小九儿"
|
||||||
|
}),
|
||||||
|
ok: true
|
||||||
|
}));
|
||||||
|
const client = createAuthorBaseClient({
|
||||||
|
baseUrl: "https://www.xingtu.cn",
|
||||||
|
fetchImpl,
|
||||||
|
timeoutMs: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.loadAuthorBaseInfo("6866044569306267651")).resolves.toEqual({
|
||||||
|
authorId: "6866044569306267651",
|
||||||
|
authorName: "小九儿",
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
|
"https://www.xingtu.cn/gw/api/author/get_author_base_info?o_author_id=6866044569306267651&platform_source=1&platform_channel=1&recommend=true&need_sec_uid=true&need_linkage_info=true",
|
||||||
|
expect.objectContaining({
|
||||||
|
credentials: "include",
|
||||||
|
method: "GET"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { parseAuthorIds } from "../src/content/market/author-id-input";
|
||||||
|
|
||||||
|
describe("author-id-input", () => {
|
||||||
|
test("parses newline comma and space separated Xingtu author ids", () => {
|
||||||
|
expect(parseAuthorIds(`
|
||||||
|
6866044569306267651
|
||||||
|
7040323176106033165,7088592143119286285
|
||||||
|
7222310247979810854
|
||||||
|
`)).toEqual({
|
||||||
|
duplicates: [],
|
||||||
|
ids: [
|
||||||
|
"6866044569306267651",
|
||||||
|
"7040323176106033165",
|
||||||
|
"7088592143119286285",
|
||||||
|
"7222310247979810854"
|
||||||
|
],
|
||||||
|
invalidTokens: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deduplicates ids and reports invalid tokens", () => {
|
||||||
|
expect(parseAuthorIds(`
|
||||||
|
6866044569306267651
|
||||||
|
bad-id
|
||||||
|
123
|
||||||
|
6866044569306267651
|
||||||
|
`)).toEqual({
|
||||||
|
duplicates: ["6866044569306267651"],
|
||||||
|
ids: ["6866044569306267651"],
|
||||||
|
invalidTokens: ["bad-id", "123"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,27 @@ describe("background-auth-controller", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns unauthenticated state when the access token cannot be read", async () => {
|
||||||
|
const controller = createAuthController({
|
||||||
|
authClient: {
|
||||||
|
getAccessToken: vi.fn(async () => {
|
||||||
|
throw new Error("token expired");
|
||||||
|
}),
|
||||||
|
getIdTokenClaims: vi.fn(),
|
||||||
|
isAuthenticated: vi.fn(async () => true),
|
||||||
|
signIn: vi.fn(),
|
||||||
|
signOut: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.getAuthState()).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
isAuthenticated: false,
|
||||||
|
lastError: "token expired"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("delegates sign in to the auth client", async () => {
|
test("delegates sign in to the auth client", async () => {
|
||||||
const signIn = vi.fn(async () => undefined);
|
const signIn = vi.fn(async () => undefined);
|
||||||
const controller = createAuthController({
|
const controller = createAuthController({
|
||||||
|
|||||||
@@ -48,6 +48,50 @@ describe("background-index", () => {
|
|||||||
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
|
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("downloads extension update assets", async () => {
|
||||||
|
const listeners: Array<
|
||||||
|
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||||
|
> = [];
|
||||||
|
const download = vi.fn(async () => undefined);
|
||||||
|
const sendResponse = vi.fn();
|
||||||
|
|
||||||
|
registerBackgroundMessageHandler({
|
||||||
|
downloads: {
|
||||||
|
download
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
onMessage: {
|
||||||
|
addListener(listener) {
|
||||||
|
listeners.push(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = listeners[0](
|
||||||
|
{
|
||||||
|
filename: "star-chart-search-enhancer-internal.zip",
|
||||||
|
type: "update:download",
|
||||||
|
url: "https://cos.example.com/star-chart-search-enhancer/releases/0.2.0421.3/star-chart-search-enhancer-internal.zip"
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
sendResponse
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(download).toHaveBeenCalledWith({
|
||||||
|
filename: "star-chart-search-enhancer-internal.zip",
|
||||||
|
saveAs: true,
|
||||||
|
url: "https://cos.example.com/star-chart-search-enhancer/releases/0.2.0421.3/star-chart-search-enhancer-internal.zip"
|
||||||
|
});
|
||||||
|
expect(sendResponse).toHaveBeenCalledWith({
|
||||||
|
ok: true,
|
||||||
|
type: "update:download-ack"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("responds to auth:get-state with auth status", async () => {
|
test("responds to auth:get-state with auth status", async () => {
|
||||||
const listeners: Array<
|
const listeners: Array<
|
||||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildBusinessAbilityEstimateUrl,
|
||||||
|
buildBusinessAbilityVideoUrl,
|
||||||
|
createBusinessAbilityClient,
|
||||||
|
mapBusinessAbilityEstimateResponse,
|
||||||
|
mapBusinessAbilityVideoResponse
|
||||||
|
} from "../src/content/market/business-ability-client";
|
||||||
|
|
||||||
|
describe("business-ability-client", () => {
|
||||||
|
test("builds commercial ability urls used by the Xingtu detail page", () => {
|
||||||
|
expect(
|
||||||
|
buildBusinessAbilityVideoUrl(
|
||||||
|
"6724241209444794382",
|
||||||
|
"https://www.xingtu.cn",
|
||||||
|
2
|
||||||
|
)
|
||||||
|
).toBe(
|
||||||
|
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildBusinessAbilityEstimateUrl(
|
||||||
|
"6724241209444794382",
|
||||||
|
"https://www.xingtu.cn"
|
||||||
|
)
|
||||||
|
).toBe(
|
||||||
|
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps video content metrics into page-style display values", () => {
|
||||||
|
expect(mapBusinessAbilityVideoResponse(buildVideoPayload())).toEqual({
|
||||||
|
averageComment: "5.1w",
|
||||||
|
averageDuration: "170s",
|
||||||
|
averageLike: "150.3w",
|
||||||
|
averageShare: "68.4w",
|
||||||
|
finishRate: "19.9%",
|
||||||
|
interactionRate: "5.5%",
|
||||||
|
medianPlay: "4059.7w",
|
||||||
|
publishedItems: "<5"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps duration estimates into page-style display values", () => {
|
||||||
|
expect(mapBusinessAbilityEstimateResponse(buildEstimatePayload())).toEqual({
|
||||||
|
oneToTwenty: {
|
||||||
|
expectedCpe: "2.1",
|
||||||
|
expectedCpm: "120.0",
|
||||||
|
expectedPlay: "250w",
|
||||||
|
hotRate: "100%"
|
||||||
|
},
|
||||||
|
overSixty: {
|
||||||
|
expectedCpe: "4.2",
|
||||||
|
expectedCpm: "240.0",
|
||||||
|
expectedPlay: "250w",
|
||||||
|
hotRate: "100%"
|
||||||
|
},
|
||||||
|
twentyToSixty: {
|
||||||
|
expectedCpe: "3.7",
|
||||||
|
expectedCpm: "212.0",
|
||||||
|
expectedPlay: "250w",
|
||||||
|
hotRate: "100%"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps decimal CPM values and marks missing hot rate", () => {
|
||||||
|
expect(mapBusinessAbilityEstimateResponse({
|
||||||
|
base_resp: { status_code: 0, status_message: "" },
|
||||||
|
cpe_1_20: "1.6347",
|
||||||
|
cpe_20_60: "2.002",
|
||||||
|
cpe_60: "2.104",
|
||||||
|
cpm_1_20: "21.7955",
|
||||||
|
cpm_20_60: "27.5628",
|
||||||
|
cpm_60: "29.877",
|
||||||
|
vv: "1010234"
|
||||||
|
})).toEqual({
|
||||||
|
oneToTwenty: {
|
||||||
|
expectedCpe: "1.6",
|
||||||
|
expectedCpm: "21.8",
|
||||||
|
expectedPlay: "101w",
|
||||||
|
hotRate: "缺失"
|
||||||
|
},
|
||||||
|
overSixty: {
|
||||||
|
expectedCpe: "2.1",
|
||||||
|
expectedCpm: "29.9",
|
||||||
|
expectedPlay: "101w",
|
||||||
|
hotRate: "缺失"
|
||||||
|
},
|
||||||
|
twentyToSixty: {
|
||||||
|
expectedCpe: "2",
|
||||||
|
expectedCpm: "27.6",
|
||||||
|
expectedPlay: "101w",
|
||||||
|
hotRate: "缺失"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads personal video, Xingtu video, and duration estimate metrics", async () => {
|
||||||
|
const requestedUrls: string[] = [];
|
||||||
|
const fetchImpl = vi.fn(async (input: string) => {
|
||||||
|
requestedUrls.push(input);
|
||||||
|
return {
|
||||||
|
json: async () =>
|
||||||
|
input.includes("get_author_commerce_spread_info")
|
||||||
|
? buildEstimatePayload()
|
||||||
|
: buildVideoPayload(),
|
||||||
|
ok: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const client = createBusinessAbilityClient({
|
||||||
|
baseUrl: "https://www.xingtu.cn",
|
||||||
|
fetchImpl,
|
||||||
|
timeoutMs: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.loadBusinessAbility({
|
||||||
|
authorId: "6724241209444794382",
|
||||||
|
authorName: "李蠕蠕",
|
||||||
|
status: "success"
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
estimates: expect.objectContaining({
|
||||||
|
twentyToSixty: expect.objectContaining({ expectedCpm: "212.0" })
|
||||||
|
}),
|
||||||
|
status: "success",
|
||||||
|
videos: {
|
||||||
|
personalVideo: expect.objectContaining({ medianPlay: "4059.7w" }),
|
||||||
|
xingtuVideo: expect.objectContaining({ medianPlay: "4059.7w" })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requestedUrls).toEqual([
|
||||||
|
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=true&range=2",
|
||||||
|
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2",
|
||||||
|
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildVideoPayload() {
|
||||||
|
return {
|
||||||
|
avg_duration: 17002,
|
||||||
|
base_resp: { status_code: 0, status_message: "" },
|
||||||
|
comment_avg: 51404,
|
||||||
|
interact_rate: { value: 551 },
|
||||||
|
item_num: 2,
|
||||||
|
like_avg: 1503028,
|
||||||
|
play_mid: 40596960,
|
||||||
|
play_over_rate: { value: 1991 },
|
||||||
|
share_avg: 684318
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEstimatePayload() {
|
||||||
|
return {
|
||||||
|
base_resp: { status_code: 0, status_message: "" },
|
||||||
|
cpe_1_20: "2.1035",
|
||||||
|
cpe_20_60: "3.7161",
|
||||||
|
cpe_60: "4.2069",
|
||||||
|
cpm_1_20: "119.9976",
|
||||||
|
cpm_20_60: "211.9958",
|
||||||
|
cpm_60: "239.9953",
|
||||||
|
platform_hot_rate: "1",
|
||||||
|
vv: "2500049"
|
||||||
|
};
|
||||||
|
}
|
||||||
+21
-21
@@ -14,14 +14,14 @@ describe("csv-exporter", () => {
|
|||||||
"达人名称",
|
"达人名称",
|
||||||
"地区",
|
"地区",
|
||||||
"21-60s报价",
|
"21-60s报价",
|
||||||
"单视频看后搜率",
|
"商单视频看后搜率",
|
||||||
"个人视频看后搜率",
|
"个人视频看后搜率",
|
||||||
"看后搜率",
|
"秒思api-看后搜率",
|
||||||
"看后搜数",
|
"秒思api-看后搜数",
|
||||||
"新增A3数",
|
"秒思api-新增A3数",
|
||||||
"新增A3率",
|
"秒思api-新增A3率",
|
||||||
"CPA3",
|
"秒思api-CPA3",
|
||||||
"cp_search"
|
"秒思api-cp_search"
|
||||||
].join(",")
|
].join(",")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -59,14 +59,14 @@ describe("csv-exporter", () => {
|
|||||||
"达人信息",
|
"达人信息",
|
||||||
"粉丝数",
|
"粉丝数",
|
||||||
"21-60s报价",
|
"21-60s报价",
|
||||||
"单视频看后搜率",
|
"商单视频看后搜率",
|
||||||
"个人视频看后搜率",
|
"个人视频看后搜率",
|
||||||
"看后搜率",
|
"秒思api-看后搜率",
|
||||||
"看后搜数",
|
"秒思api-看后搜数",
|
||||||
"新增A3数",
|
"秒思api-新增A3数",
|
||||||
"新增A3率",
|
"秒思api-新增A3率",
|
||||||
"CPA3",
|
"秒思api-CPA3",
|
||||||
"cp_search"
|
"秒思api-cp_search"
|
||||||
].join(",")
|
].join(",")
|
||||||
);
|
);
|
||||||
expect(rowLine).toBe(
|
expect(rowLine).toBe(
|
||||||
@@ -94,14 +94,14 @@ describe("csv-exporter", () => {
|
|||||||
[
|
[
|
||||||
"达人信息",
|
"达人信息",
|
||||||
"粉丝数",
|
"粉丝数",
|
||||||
"单视频看后搜率",
|
"商单视频看后搜率",
|
||||||
"个人视频看后搜率",
|
"个人视频看后搜率",
|
||||||
"看后搜率",
|
"秒思api-看后搜率",
|
||||||
"看后搜数",
|
"秒思api-看后搜数",
|
||||||
"新增A3数",
|
"秒思api-新增A3数",
|
||||||
"新增A3率",
|
"秒思api-新增A3率",
|
||||||
"CPA3",
|
"秒思api-CPA3",
|
||||||
"cp_search"
|
"秒思api-cp_search"
|
||||||
].join(",")
|
].join(",")
|
||||||
);
|
);
|
||||||
expect(rowLine).toBe("Alice,100w,,,,,,,,");
|
expect(rowLine).toBe("Alice,100w,,,,,,,,");
|
||||||
|
|||||||
+19
-2
@@ -43,10 +43,11 @@ describe("manifest", () => {
|
|||||||
"https://*.xingtu.cn/ad/creator/market*",
|
"https://*.xingtu.cn/ad/creator/market*",
|
||||||
"https://login-api.intelligrow.cn/*",
|
"https://login-api.intelligrow.cn/*",
|
||||||
"https://talent-search.intelligrow.cn/*",
|
"https://talent-search.intelligrow.cn/*",
|
||||||
"http://192.168.31.21:8083/*"
|
"http://192.168.31.21:8083/*",
|
||||||
|
"https://*/*"
|
||||||
]);
|
]);
|
||||||
expect(releaseManifest.host_permissions).not.toEqual(
|
expect(releaseManifest.host_permissions).not.toEqual(
|
||||||
expect.arrayContaining(["http://*/*", "https://*/*", "http://127.0.0.1:4319/*"])
|
expect.arrayContaining(["http://*/*", "http://127.0.0.1:4319/*"])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,4 +66,20 @@ describe("manifest", () => {
|
|||||||
"32": "assets/icons/icon-32.png"
|
"32": "assets/icons/icon-32.png"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses EXTENSION_VERSION for release builds", () => {
|
||||||
|
const previousVersion = process.env.EXTENSION_VERSION;
|
||||||
|
process.env.EXTENSION_VERSION = "v0.0525.1";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const releaseManifest = createManifest({ target: "release" });
|
||||||
|
expect(releaseManifest.version).toBe("0.0525.1");
|
||||||
|
} finally {
|
||||||
|
if (previousVersion === undefined) {
|
||||||
|
delete process.env.EXTENSION_VERSION;
|
||||||
|
} else {
|
||||||
|
process.env.EXTENSION_VERSION = previousVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,4 +24,24 @@ describe("market-auth-gating", () => {
|
|||||||
expect(createMarketController).not.toHaveBeenCalled();
|
expect(createMarketController).not.toHaveBeenCalled();
|
||||||
expect(document.body.textContent).toContain("请先登录插件");
|
expect(document.body.textContent).toContain("请先登录插件");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows an expired login message when auth state reports a token error", async () => {
|
||||||
|
document.body.innerHTML = "<div></div>";
|
||||||
|
|
||||||
|
await bootContentScript({
|
||||||
|
createMarketController: vi.fn(),
|
||||||
|
document,
|
||||||
|
sendAuthMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: false,
|
||||||
|
lastError: "token expired"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
window
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain("登录已过期,请重新登录");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ describe("market-content-entry", () => {
|
|||||||
document.documentElement.removeAttribute("data-sces-market-rows");
|
document.documentElement.removeAttribute("data-sces-market-rows");
|
||||||
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
|
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
|
||||||
document.documentElement.removeAttribute("data-test-page-index");
|
document.documentElement.removeAttribute("data-test-page-index");
|
||||||
|
window.localStorage.clear();
|
||||||
window.history.replaceState({}, "", "/");
|
window.history.replaceState({}, "", "/");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,6 +210,46 @@ describe("market-content-entry", () => {
|
|||||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url");
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("booted export callback can download a custom csv filename", async () => {
|
||||||
|
const createMarketController = vi.fn(() => ({
|
||||||
|
ready: Promise.resolve()
|
||||||
|
}));
|
||||||
|
let clickedDownload: { download: string; href: string } | null = null;
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
||||||
|
this: HTMLAnchorElement
|
||||||
|
) {
|
||||||
|
clickedDownload = {
|
||||||
|
download: this.download,
|
||||||
|
href: this.href
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
window.history.replaceState({}, "", "/ad/creator/market");
|
||||||
|
Object.defineProperty(window.URL, "createObjectURL", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(() => "blob:test-url")
|
||||||
|
});
|
||||||
|
Object.defineProperty(window.URL, "revokeObjectURL", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn()
|
||||||
|
});
|
||||||
|
|
||||||
|
const { bootContentScript } = await import("../src/content/index");
|
||||||
|
await bootContentScript({
|
||||||
|
createMarketController,
|
||||||
|
sendAuthMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: { isAuthenticated: true }
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
const controllerOptions = createMarketController.mock.calls[0]?.[0];
|
||||||
|
controllerOptions.onCsvReady("列1,列2\n值1,值2", "达人连接用户画像_20260518_1530.csv");
|
||||||
|
|
||||||
|
expect(clickedDownload?.download).toBe("达人连接用户画像_20260518_1530.csv");
|
||||||
|
});
|
||||||
|
|
||||||
test("booted export callback sends the csv to extension runtime when available", async () => {
|
test("booted export callback sends the csv to extension runtime when available", async () => {
|
||||||
const createMarketController = vi.fn(() => ({
|
const createMarketController = vi.fn(() => ({
|
||||||
ready: Promise.resolve()
|
ready: Promise.resolve()
|
||||||
@@ -245,6 +286,7 @@ describe("market-content-entry", () => {
|
|||||||
expect(sendMessage).toHaveBeenCalledWith(
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
csv: "列1,列2\n值1,值2",
|
csv: "列1,列2\n值1,值2",
|
||||||
|
filename: expect.stringMatching(/^star-chart-search-enhancer-/),
|
||||||
type: "download-market-csv"
|
type: "download-market-csv"
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -282,21 +324,61 @@ describe("market-content-entry", () => {
|
|||||||
expect(document.querySelector('[data-plugin-sort-field="select"]')).toBeNull();
|
expect(document.querySelector('[data-plugin-sort-field="select"]')).toBeNull();
|
||||||
|
|
||||||
expect(document.body.firstElementChild).not.toBe(toolbar);
|
expect(document.body.firstElementChild).not.toBe(toolbar);
|
||||||
expect(document.querySelector('[data-plugin-export-range="select"]')).not.toBeNull();
|
expect(
|
||||||
expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull();
|
(document.querySelector('[data-plugin-export-range="select"]') as HTMLSelectElement | null)
|
||||||
|
?.hidden
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
document.querySelector(
|
||||||
|
'[data-plugin-export-custom-pages="input"]'
|
||||||
|
) as HTMLInputElement | null
|
||||||
|
)?.hidden
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(document.querySelector('[data-plugin-export="button"]') as HTMLButtonElement | null)
|
||||||
|
?.hidden
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-plugin-export-audience-profile="button"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-plugin-export-audience-profile-by-id="button"]')
|
||||||
|
).not.toBeNull();
|
||||||
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
|
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
|
||||||
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
|
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
|
||||||
|
|
||||||
const exportButton = document.querySelector(
|
|
||||||
'[data-plugin-export="button"]'
|
|
||||||
) as HTMLButtonElement | null;
|
|
||||||
const batchSubmitButton = document.querySelector(
|
const batchSubmitButton = document.querySelector(
|
||||||
'[data-plugin-batch-submit="button"]'
|
'[data-plugin-batch-submit="button"]'
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
expect(exportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
const audienceProfileExportButton = document.querySelector(
|
||||||
|
'[data-plugin-export-audience-profile="button"]'
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
const audienceProfileByIdExportButton = document.querySelector(
|
||||||
|
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
const audienceProfileFieldButton = document.querySelector(
|
||||||
|
'[data-plugin-audience-profile-fields="button"]'
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(audienceProfileExportButton?.textContent).toBe("导出选中达人数据");
|
||||||
|
expect(audienceProfileExportButton?.title).toBe(
|
||||||
|
"仅导出已勾选达人,包含内容数据、效果预估、画像等维度"
|
||||||
|
);
|
||||||
|
expect(audienceProfileByIdExportButton?.textContent).toBe("按星图ID导出");
|
||||||
|
expect(audienceProfileByIdExportButton?.title).toBe(
|
||||||
|
"粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选"
|
||||||
|
);
|
||||||
|
expect(audienceProfileFieldButton?.textContent).toBe("选择字段");
|
||||||
|
expect(audienceProfileFieldButton?.title).toBe(
|
||||||
|
"勾选本次CSV需要导出的字段,设置会自动保存"
|
||||||
|
);
|
||||||
|
expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次");
|
||||||
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
||||||
expect(exportButton?.style.color).toBe("rgb(255, 255, 255)");
|
expect(audienceProfileExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
||||||
|
expect(audienceProfileByIdExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
||||||
expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)");
|
expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)");
|
||||||
|
expect(audienceProfileExportButton?.style.color).toBe("rgb(255, 255, 255)");
|
||||||
|
expect(audienceProfileByIdExportButton?.style.color).toBe("rgb(255, 255, 255)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("remounts the plugin action bar when the native market action row appears later", async () => {
|
test("remounts the plugin action bar when the native market action row appears later", async () => {
|
||||||
@@ -1051,7 +1133,7 @@ describe("market-content-entry", () => {
|
|||||||
expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]);
|
expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("toolbar defaults export range to the first 5 pages and reveals custom input on demand", async () => {
|
test("toolbar keeps export range controls hidden while retaining the internal default range", async () => {
|
||||||
document.body.innerHTML = buildMarketFixture();
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
|
||||||
const { createMarketController } = await import("../src/content/market/index");
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
@@ -1074,6 +1156,7 @@ describe("market-content-entry", () => {
|
|||||||
) as HTMLInputElement | null;
|
) as HTMLInputElement | null;
|
||||||
|
|
||||||
expect(exportRangeSelect?.value).toBe("first-5");
|
expect(exportRangeSelect?.value).toBe("first-5");
|
||||||
|
expect(exportRangeSelect?.hidden).toBe(true);
|
||||||
expect(customPagesInput?.hidden).toBe(true);
|
expect(customPagesInput?.hidden).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('[data-plugin-batch-submit="button"]')
|
document.querySelector('[data-plugin-batch-submit="button"]')
|
||||||
@@ -1082,7 +1165,8 @@ describe("market-content-entry", () => {
|
|||||||
setSelectValue('[data-plugin-export-range="select"]', "custom");
|
setSelectValue('[data-plugin-export-range="select"]', "custom");
|
||||||
dispatchChange('[data-plugin-export-range="select"]');
|
dispatchChange('[data-plugin-export-range="select"]');
|
||||||
|
|
||||||
expect(customPagesInput?.hidden).toBe(false);
|
expect(exportRangeSelect?.hidden).toBe(true);
|
||||||
|
expect(customPagesInput?.hidden).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("export uses the current page ordering without triggering a full scan", async () => {
|
test("export uses the current page ordering without triggering a full scan", async () => {
|
||||||
@@ -1535,6 +1619,352 @@ describe("market-content-entry", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("audience profile export requires selected creators", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
||||||
|
const loadAudienceProfile = vi.fn();
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
buildAudienceProfileCsv,
|
||||||
|
document,
|
||||||
|
loadAudienceProfile,
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
onCsvReady: vi.fn(),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||||
|
dispatchChange('[data-plugin-export-range="select"]');
|
||||||
|
|
||||||
|
click('[data-plugin-export-audience-profile="button"]');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(loadAudienceProfile).not.toHaveBeenCalled();
|
||||||
|
expect(buildAudienceProfileCsv).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||||||
|
).toContain("请先勾选需要导出数据的达人");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audience profile export loads profiles only for selected creators", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||||
|
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||||
|
]);
|
||||||
|
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
||||||
|
const loadBusinessAbility = vi.fn(async () => ({
|
||||||
|
estimates: {},
|
||||||
|
status: "success" as const,
|
||||||
|
videos: {}
|
||||||
|
}));
|
||||||
|
const loadAudienceProfile = vi.fn(async (_record, target) => {
|
||||||
|
if (target.source === "fansDistribution" && target.authorType === 5) {
|
||||||
|
return {
|
||||||
|
age: [{ label: "31-40", value: "30%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "50%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "70%" }],
|
||||||
|
status: "success" as const
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
age: [{ label: "31-40", value: "60%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "80%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "90%" }],
|
||||||
|
gender: [{ label: "男性", value: "60%" }],
|
||||||
|
status: "success" as const
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const onCsvReady = vi.fn();
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
buildAudienceProfileCsv,
|
||||||
|
document,
|
||||||
|
loadBusinessAbility,
|
||||||
|
loadAudienceProfile,
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
onCsvReady,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
clickSelectionCheckboxForAuthor("222");
|
||||||
|
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||||
|
dispatchChange('[data-plugin-export-range="select"]');
|
||||||
|
|
||||||
|
click('[data-plugin-export-audience-profile="button"]');
|
||||||
|
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
|
||||||
|
|
||||||
|
expect(loadAudienceProfile).toHaveBeenCalledTimes(3);
|
||||||
|
expect(loadBusinessAbility).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadBusinessAbility).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ authorId: "222" })
|
||||||
|
);
|
||||||
|
expect(loadAudienceProfile.mock.calls.map(([, target]) => target)).toEqual([
|
||||||
|
{ linkType: 5, source: "audienceDistribution" },
|
||||||
|
{ authorType: 1, source: "fansDistribution" },
|
||||||
|
{ authorType: 5, source: "fansDistribution" }
|
||||||
|
]);
|
||||||
|
expect(buildAudienceProfileCsv).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
audience: expect.objectContaining({ status: "success" }),
|
||||||
|
fans: expect.objectContaining({ status: "success" }),
|
||||||
|
longtimeFans: expect.objectContaining({ status: "success" })
|
||||||
|
},
|
||||||
|
businessAbility: expect.objectContaining({ status: "success" }),
|
||||||
|
record: expect.objectContaining({ authorId: "222" })
|
||||||
|
}
|
||||||
|
],
|
||||||
|
expect.objectContaining({
|
||||||
|
selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(onCsvReady).toHaveBeenCalledWith(
|
||||||
|
"profile-csv",
|
||||||
|
expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audience profile export by id loads pasted creators without page selection", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
||||||
|
const loadAuthorBaseInfo = vi.fn(async (authorId: string) => ({
|
||||||
|
authorId,
|
||||||
|
authorName: authorId === "6866044569306267651" ? "小九儿" : "达人 B",
|
||||||
|
status: "success" as const
|
||||||
|
}));
|
||||||
|
const loadBusinessAbility = vi.fn(async () => ({
|
||||||
|
estimates: {},
|
||||||
|
status: "success" as const,
|
||||||
|
videos: {}
|
||||||
|
}));
|
||||||
|
const loadAudienceProfile = vi.fn(async () => ({
|
||||||
|
age: [{ label: "31-40", value: "60%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "80%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "90%" }],
|
||||||
|
gender: [{ label: "男性", value: "60%" }],
|
||||||
|
status: "success" as const
|
||||||
|
}));
|
||||||
|
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
|
||||||
|
rates: {
|
||||||
|
personalVideoAfterSearchRate:
|
||||||
|
authorId === "6866044569306267651" ? "12.3%" : "45.6%",
|
||||||
|
singleVideoAfterSearchRate:
|
||||||
|
authorId === "6866044569306267651" ? "7.8%" : "9.1%"
|
||||||
|
},
|
||||||
|
success: true as const
|
||||||
|
}));
|
||||||
|
const searchBackendMetrics = vi.fn(async (starIds: string[]) =>
|
||||||
|
starIds.map((starId) => ({
|
||||||
|
a3IncreaseCount: starId === "6866044569306267651" ? "100" : "200",
|
||||||
|
afterViewSearchCount: starId === "6866044569306267651" ? "300" : "400",
|
||||||
|
afterViewSearchRate: starId === "6866044569306267651" ? "1.1%" : "2.2%",
|
||||||
|
cpSearch: starId === "6866044569306267651" ? "10" : "20",
|
||||||
|
cpa3: starId === "6866044569306267651" ? "30" : "40",
|
||||||
|
newA3Rate: starId === "6866044569306267651" ? "3.3%" : "4.4%",
|
||||||
|
starId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const onCsvReady = vi.fn();
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
buildAudienceProfileCsv,
|
||||||
|
document,
|
||||||
|
loadAuthorBaseInfo,
|
||||||
|
loadBusinessAbility,
|
||||||
|
loadAudienceProfile,
|
||||||
|
loadAuthorMetrics,
|
||||||
|
onCsvReady,
|
||||||
|
promptAuthorIds: () => `
|
||||||
|
6866044569306267651
|
||||||
|
7040323176106033165
|
||||||
|
6866044569306267651
|
||||||
|
bad-id
|
||||||
|
`,
|
||||||
|
searchBackendMetrics,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
loadAuthorMetrics.mockClear();
|
||||||
|
searchBackendMetrics.mockClear();
|
||||||
|
click('[data-plugin-export-audience-profile-by-id="button"]');
|
||||||
|
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
|
||||||
|
|
||||||
|
expect(loadAuthorBaseInfo.mock.calls.map(([authorId]) => authorId)).toEqual([
|
||||||
|
"6866044569306267651",
|
||||||
|
"7040323176106033165"
|
||||||
|
]);
|
||||||
|
expect(loadAudienceProfile).toHaveBeenCalledTimes(6);
|
||||||
|
expect(loadBusinessAbility).toHaveBeenCalledTimes(2);
|
||||||
|
expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual([
|
||||||
|
"6866044569306267651",
|
||||||
|
"7040323176106033165"
|
||||||
|
]);
|
||||||
|
expect(searchBackendMetrics).toHaveBeenCalledTimes(1);
|
||||||
|
expect(searchBackendMetrics).toHaveBeenCalledWith([
|
||||||
|
"6866044569306267651",
|
||||||
|
"7040323176106033165"
|
||||||
|
]);
|
||||||
|
expect(buildAudienceProfileCsv).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
expect.objectContaining({
|
||||||
|
record: expect.objectContaining({
|
||||||
|
authorId: "6866044569306267651",
|
||||||
|
authorName: "小九儿",
|
||||||
|
backendMetrics: expect.objectContaining({
|
||||||
|
a3IncreaseCount: "100",
|
||||||
|
afterViewSearchCount: "300",
|
||||||
|
afterViewSearchRate: "1.1%",
|
||||||
|
cpSearch: "10",
|
||||||
|
cpa3: "30",
|
||||||
|
newA3Rate: "3.3%"
|
||||||
|
}),
|
||||||
|
exportFields: {
|
||||||
|
达人ID: "6866044569306267651",
|
||||||
|
达人名称: "小九儿",
|
||||||
|
导出状态: "成功",
|
||||||
|
失败原因: ""
|
||||||
|
},
|
||||||
|
rates: {
|
||||||
|
personalVideoAfterSearchRate: "12.3%",
|
||||||
|
singleVideoAfterSearchRate: "7.8%"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
record: expect.objectContaining({
|
||||||
|
authorId: "7040323176106033165",
|
||||||
|
authorName: "达人 B",
|
||||||
|
backendMetrics: expect.objectContaining({
|
||||||
|
a3IncreaseCount: "200",
|
||||||
|
afterViewSearchCount: "400",
|
||||||
|
afterViewSearchRate: "2.2%",
|
||||||
|
cpSearch: "20",
|
||||||
|
cpa3: "40",
|
||||||
|
newA3Rate: "4.4%"
|
||||||
|
}),
|
||||||
|
rates: {
|
||||||
|
personalVideoAfterSearchRate: "45.6%",
|
||||||
|
singleVideoAfterSearchRate: "9.1%"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
],
|
||||||
|
expect.objectContaining({
|
||||||
|
selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(onCsvReady).toHaveBeenCalledWith(
|
||||||
|
"profile-csv",
|
||||||
|
expect.stringMatching(/^达人连接用户画像_按ID导出_\d{8}_\d{4}\.csv$/)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audience profile export uses persisted selected csv fields", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"sces:audience-profile:selectedHeaders",
|
||||||
|
JSON.stringify(["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"])
|
||||||
|
);
|
||||||
|
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
||||||
|
const loadBusinessAbility = vi.fn(async () => ({
|
||||||
|
estimates: {},
|
||||||
|
status: "success" as const,
|
||||||
|
videos: {}
|
||||||
|
}));
|
||||||
|
const loadAudienceProfile = vi.fn(async () => ({
|
||||||
|
age: [],
|
||||||
|
crowd: [],
|
||||||
|
cityTier: [],
|
||||||
|
gender: [],
|
||||||
|
status: "success" as const
|
||||||
|
}));
|
||||||
|
const onCsvReady = vi.fn();
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
buildAudienceProfileCsv,
|
||||||
|
document,
|
||||||
|
loadBusinessAbility,
|
||||||
|
loadAudienceProfile,
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
onCsvReady,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
clickSelectionCheckboxForAuthor("111");
|
||||||
|
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||||
|
dispatchChange('[data-plugin-export-range="select"]');
|
||||||
|
|
||||||
|
click('[data-plugin-export-audience-profile="button"]');
|
||||||
|
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
|
||||||
|
|
||||||
|
expect(buildAudienceProfileCsv.mock.calls[0][1]).toEqual({
|
||||||
|
selectedHeaders: ["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audience profile field picker persists the next selected fields", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
click('[data-plugin-audience-profile-fields="button"]');
|
||||||
|
|
||||||
|
const afterSearchCountInput = document.querySelector(
|
||||||
|
'input[data-audience-profile-field-dialog-field="checkbox"][value="秒思api-看后搜数"]'
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
expect(afterSearchCountInput).not.toBeNull();
|
||||||
|
afterSearchCountInput!.checked = false;
|
||||||
|
afterSearchCountInput!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
|
||||||
|
click('[data-audience-profile-field-dialog-save="button"]');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const savedHeaders = JSON.parse(
|
||||||
|
window.localStorage.getItem("sces:audience-profile:selectedHeaders") ?? "[]"
|
||||||
|
) as string[];
|
||||||
|
expect(savedHeaders).not.toContain("秒思api-看后搜数");
|
||||||
|
expect(savedHeaders).toContain("内容数据-个人视频-播放量中位数");
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||||||
|
).toContain("字段已保存");
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"selected export keeps a generic loading status while exporting the default paged range",
|
"selected export keeps a generic loading status while exporting the default paged range",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ describe("market-dom-sync", () => {
|
|||||||
expect(readSelectionHeaderText()).toBe("全选");
|
expect(readSelectionHeaderText()).toBe("全选");
|
||||||
expect(readSelectionRowCheckboxCount()).toBe(2);
|
expect(readSelectionRowCheckboxCount()).toBe(2);
|
||||||
expect(readPluginHeaderTexts()).toEqual([
|
expect(readPluginHeaderTexts()).toEqual([
|
||||||
"单视频看后搜率",
|
"商单视频看后搜率",
|
||||||
"个人视频看后搜率",
|
"个人视频看后搜率",
|
||||||
"看后搜率",
|
"看后搜率",
|
||||||
"看后搜数",
|
"看后搜数",
|
||||||
|
|||||||
@@ -126,4 +126,42 @@ describe("market-page-bridge", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("serializes core user ids from the live market list", async () => {
|
||||||
|
const marketRoot = document.querySelector(".base-author-list") as HTMLElement & {
|
||||||
|
__vue__?: {
|
||||||
|
_setupState?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
marketRoot.__vue__ = {
|
||||||
|
_setupState: {
|
||||||
|
marketState: {
|
||||||
|
marketList: [
|
||||||
|
{
|
||||||
|
attribute_datas: {
|
||||||
|
avg_search_after_view_rate_30d: "0.1234",
|
||||||
|
core_user_id: "core-111",
|
||||||
|
nickname: "搜索达人"
|
||||||
|
},
|
||||||
|
star_id: "search-1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await import("../src/content/market/page-bridge");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
JSON.parse(
|
||||||
|
document.documentElement.getAttribute("data-sces-market-rows") ?? "[]"
|
||||||
|
)
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
authorId: "search-1",
|
||||||
|
authorName: "搜索达人",
|
||||||
|
coreUserId: "core-111"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createReleaseArchive } from "../scripts/package-release-archive.mjs";
|
||||||
|
|
||||||
|
describe("package-release-archive", () => {
|
||||||
|
test("creates a zip archive with a top-level release folder", async () => {
|
||||||
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "release-archive-"));
|
||||||
|
const sourceDir = path.join(tempDir, "source");
|
||||||
|
const archivePath = path.join(tempDir, "archive.zip");
|
||||||
|
|
||||||
|
await mkdir(sourceDir, { recursive: true });
|
||||||
|
await writeFile(path.join(sourceDir, "hello.txt"), "hello world", "utf8");
|
||||||
|
|
||||||
|
await createReleaseArchive({
|
||||||
|
archivePath,
|
||||||
|
sourceDir
|
||||||
|
});
|
||||||
|
|
||||||
|
const archive = await readFile(archivePath);
|
||||||
|
expect(archive.byteLength).toBeGreaterThan(0);
|
||||||
|
expect(archive.toString("utf8")).toContain(
|
||||||
|
"star-chart-search-enhancer-internal/hello.txt"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,101 @@ describe("popup-entry", () => {
|
|||||||
expect(dom.window.document.body.textContent).toContain("token");
|
expect(dom.window.document.body.textContent).toContain("token");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows available extension updates in the popup", async () => {
|
||||||
|
const fetchUpdateManifest = vi.fn(async () => ({
|
||||||
|
guideUrl: "https://cos.example.com/guide.pdf",
|
||||||
|
latestVersion: "0.2.0421.3",
|
||||||
|
minSupportedVersion: "0.2.0421.2",
|
||||||
|
publishedAt: "2026-05-19",
|
||||||
|
releaseNotes: ["支持检查更新"],
|
||||||
|
zipUrl: "https://cos.example.com/plugin.zip"
|
||||||
|
}));
|
||||||
|
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
currentVersion: "0.2.0421.2",
|
||||||
|
document: dom.window.document,
|
||||||
|
fetchUpdateManifest,
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
userInfo: { name: "Dev" }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
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-download-update="button"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-download-guide="button"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("downloads update assets from popup buttons", async () => {
|
||||||
|
const sendMessage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
userInfo: { name: "Dev" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValue({ ok: true, type: "update:download-ack" });
|
||||||
|
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
currentVersion: "0.2.0421.2",
|
||||||
|
document: dom.window.document,
|
||||||
|
fetchUpdateManifest: vi.fn(async () => ({
|
||||||
|
guideUrl: "https://cos.example.com/guide.pdf",
|
||||||
|
latestVersion: "0.2.0421.3",
|
||||||
|
minSupportedVersion: "0.2.0421.2",
|
||||||
|
publishedAt: "2026-05-19",
|
||||||
|
releaseNotes: [],
|
||||||
|
zipUrl: "https://cos.example.com/plugin.zip"
|
||||||
|
})),
|
||||||
|
sendMessage
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
(
|
||||||
|
dom.window.document.querySelector(
|
||||||
|
'[data-popup-download-update="button"]'
|
||||||
|
) as HTMLButtonElement | null
|
||||||
|
)?.click();
|
||||||
|
(
|
||||||
|
dom.window.document.querySelector(
|
||||||
|
'[data-popup-download-guide="button"]'
|
||||||
|
) as HTMLButtonElement | null
|
||||||
|
)?.click();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith({
|
||||||
|
filename: "star-chart-search-enhancer-internal.zip",
|
||||||
|
type: "update:download",
|
||||||
|
url: "https://cos.example.com/plugin.zip"
|
||||||
|
});
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith({
|
||||||
|
filename: "星图增强插件-超简单安装使用指南.pdf",
|
||||||
|
type: "update:download",
|
||||||
|
url: "https://cos.example.com/guide.pdf"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("renders a protected api test button in the dev panel", async () => {
|
test("renders a protected api test button in the dev panel", async () => {
|
||||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { buildReleaseUploadTargets } from "../scripts/release-assets.mjs";
|
||||||
|
|
||||||
|
describe("release-assets", () => {
|
||||||
|
test("maps release files to the COS object keys", () => {
|
||||||
|
expect(
|
||||||
|
buildReleaseUploadTargets({
|
||||||
|
projectRoot: "/repo",
|
||||||
|
releaseVersion: "0.0525.1"
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
cosKey: "star-chart-search-enhancer/latest.json",
|
||||||
|
localPath: path.join("/repo", "release", "latest.json")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cosKey:
|
||||||
|
"star-chart-search-enhancer/releases/0.0525.1/star-chart-search-enhancer-internal.zip",
|
||||||
|
localPath: path.join("/repo", "release", "star-chart-search-enhancer-internal.zip")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cosKey:
|
||||||
|
"star-chart-search-enhancer/releases/0.0525.1/星图增强插件-超简单安装使用指南.pdf",
|
||||||
|
localPath: path.join("/repo", "release", "星图增强插件-超简单安装使用指南.pdf")
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeReleaseVersionTag,
|
||||||
|
resolveReleaseVersion
|
||||||
|
} from "../scripts/release-version.mjs";
|
||||||
|
|
||||||
|
describe("release-version", () => {
|
||||||
|
test("normalizes a tag by stripping a leading v", () => {
|
||||||
|
expect(normalizeReleaseVersionTag("v0.0525.1")).toBe("0.0525.1");
|
||||||
|
expect(normalizeReleaseVersionTag("0.0525.1")).toBe("0.0525.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefers EXTENSION_VERSION over DRONE_TAG and fallback", () => {
|
||||||
|
expect(
|
||||||
|
resolveReleaseVersion(
|
||||||
|
{
|
||||||
|
DRONE_TAG: "0.0525.2",
|
||||||
|
EXTENSION_VERSION: "v0.0525.3"
|
||||||
|
},
|
||||||
|
"0.2.0421.2"
|
||||||
|
)
|
||||||
|
).toBe("0.0525.3");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
compareExtensionVersions,
|
||||||
|
parseUpdateManifest
|
||||||
|
} from "../src/shared/update-check";
|
||||||
|
|
||||||
|
describe("update-check", () => {
|
||||||
|
test("compares dotted extension versions numerically", () => {
|
||||||
|
expect(compareExtensionVersions("0.2.0421.3", "0.2.0421.2")).toBeGreaterThan(0);
|
||||||
|
expect(compareExtensionVersions("0.2.10.0", "0.2.9.9")).toBeGreaterThan(0);
|
||||||
|
expect(compareExtensionVersions("0.2.0421.2", "0.2.0421.2")).toBe(0);
|
||||||
|
expect(compareExtensionVersions("0.2.0421.1", "0.2.0421.2")).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses a valid update manifest", () => {
|
||||||
|
expect(
|
||||||
|
parseUpdateManifest({
|
||||||
|
guideUrl: "https://cos.example.com/guide.pdf",
|
||||||
|
latestVersion: "0.2.0421.3",
|
||||||
|
minSupportedVersion: "0.2.0421.2",
|
||||||
|
publishedAt: "2026-05-19",
|
||||||
|
releaseNotes: ["支持检查更新"],
|
||||||
|
zipUrl: "https://cos.example.com/plugin.zip"
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
guideUrl: "https://cos.example.com/guide.pdf",
|
||||||
|
latestVersion: "0.2.0421.3",
|
||||||
|
minSupportedVersion: "0.2.0421.2",
|
||||||
|
publishedAt: "2026-05-19",
|
||||||
|
releaseNotes: ["支持检查更新"],
|
||||||
|
zipUrl: "https://cos.example.com/plugin.zip"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid update manifests", () => {
|
||||||
|
expect(parseUpdateManifest({ latestVersion: "0.2.0421.3" })).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseUpdateManifest({
|
||||||
|
guideUrl: "javascript:alert(1)",
|
||||||
|
latestVersion: "0.2.0421.3",
|
||||||
|
minSupportedVersion: "0.2.0421.2",
|
||||||
|
publishedAt: "2026-05-19",
|
||||||
|
releaseNotes: [],
|
||||||
|
zipUrl: "https://cos.example.com/plugin.zip"
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { UPDATE_MANIFEST_URL } from "../src/shared/update-config";
|
||||||
|
|
||||||
|
describe("update-config", () => {
|
||||||
|
test("points popup update checks at the COS latest manifest", () => {
|
||||||
|
expect(UPDATE_MANIFEST_URL).toBe(
|
||||||
|
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createLatestManifest } from "../scripts/write-latest-manifest-data.mjs";
|
||||||
|
|
||||||
|
describe("write-latest-manifest-data", () => {
|
||||||
|
test("builds COS asset URLs from the release base", () => {
|
||||||
|
expect(
|
||||||
|
createLatestManifest({
|
||||||
|
latestVersion: "0.2.0421.2",
|
||||||
|
minSupportedVersion: "0.2.0421.2",
|
||||||
|
publicBaseUrl:
|
||||||
|
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2",
|
||||||
|
publishedAt: "2026-05-25"
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
guideUrl:
|
||||||
|
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2/星图增强插件-超简单安装使用指南.pdf",
|
||||||
|
latestVersion: "0.2.0421.2",
|
||||||
|
minSupportedVersion: "0.2.0421.2",
|
||||||
|
publishedAt: "2026-05-25",
|
||||||
|
releaseNotes: [
|
||||||
|
"支持在插件弹窗中检查新版本",
|
||||||
|
"支持一键下载最新版插件压缩包和使用说明"
|
||||||
|
],
|
||||||
|
zipUrl:
|
||||||
|
"https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/releases/0.2.0421.2/star-chart-search-enhancer-internal.zip"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user